白箱検出、3方式の試行。OpenCV HSV失敗からテンプレートマッチング0.994まで
LOG165R2R2R2026/4/20

白箱検出、3方式の試行。OpenCV HSV失敗からテンプレートマッチング0.994まで

白箱の検出方式を3回変えた日だった。

最初のテンプレートマッチングはスコア0.547でズレが54mm。OpenCV HSV白色マスク方式に切り替えたらロボットアームの筐体を拾って悪化。テンプレートマッチングに戻して新規テンプレートを12枚撮り直したら、スコアが0.994になった。

3回の試行で見えてきたのは、「方式の問題」だと思っていたことが「データの問題」だったということ。そして、白箱をpixel精度で検出できても、lookup tableのカバー範囲外なら動かないという、別レイヤーの壁。

出発点の状態

homography座標系への全面移行は前日に済んでいた。アフィン2点キャリブを廃止し、マット四隅自動検出 → homography.npy でpixel→Unity座標変換する方式が動いている。赤ブロックの検出はpixel精度3mm以内で確認済み。

問題は白箱だった。

白箱検出 Unity座標: (-0.239, 0.238)
実測中心:           (-0.185, +0.222)
X方向誤差: 54mm

白箱検出にはテンプレートマッチングを使っていた。auto学習で追加された古いテンプレート3枚がある。スコアの最高値は0.547で、青枠が実物より一回り大きい。

「余計ひどいね。ちゃんと白箱を認識しているか、確認した方がいいんじゃない?」

この問いかけから、3回の試行が始まった。

試行1: テンプレートマッチング(古い3枚)

3枚のテンプレート × 5スケールで15通りのスコアを全探索。

テンプレート

0.70

0.85

1.00

1.15

1.30

white_box.png

0.503

0.456

0.446

0.442

0.486

white_box_auto_20260419_083634.png

0.471

0.515

0.487

0.504

0.487

white_box_auto_20260419_122937.png

0.489

0.450

0.475

0.536

0.547

全体が0.44〜0.55の団子状態。最大スケール(1.30)が選ばれている=テンプレートが実物より小さい側に振れている。

white_box_auto_20260419_083634.png と 122937.png はLOG163-164でマット位置を100mm上にずらし、homography再キャリブをする以前に自動学習で保存されたもの。物理的な設定が変わっていたのに、テンプレートが更新されていなかった。

この段階でやるべきだったこと: 現環境でテンプレートを撮り直す。 そこには気づかず、方式を変える方向に舵を切った。

試行2: OpenCV HSV白色マスク方式

白箱は白い。ならばHSV色空間で白を抽出して最大面積輪郭を取ればいい。テンプレート依存がなくなり、環境変化にも強くなるはず。

バックアップ後、テンプレート関連を全削除した。

削除した定数:       TEMPLATE_DIR / TEMPLATE_SCALES / TEMPLATE_SCORE_THRESHOLD
                    TEMPLATE_AUTO_ADD_THRESHOLD / TEMPLATE_SIMILARITY_THRESHOLD
                    TEMPLATE_MAX_COUNT / TEMPLATE_PROTECTED / TEMPLATE_LEARNING_LOG
削除したグローバル:  ALL_TEMPLATES
削除した関数:       load_all_templates() / _log_learning_event() / _try_auto_add_template()

914行 → 808行(-106行)。find_white_box() の実装:

def find_white_box(frame, debug_log=False):
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
    lower = np.array([0, 0, 180])
    upper = np.array([180, 50, 255])
    mask = cv2.inRange(hsv, lower, upper)

    kernel = np.ones((5, 5), np.uint8)
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)

    # マット範囲外をゼロに
    if _mat_px_range is not None:
        min_x, max_x, min_y, max_y = _mat_px_range
        mask_clipped = np.zeros_like(mask)
        mask_clipped[min_y:max_y+1, min_x:max_x+1] = \
            mask[min_y:max_y+1, min_x:max_x+1]
        mask = mask_clipped

    contours, _ = cv2.findContours(
        mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return None

    best_contour = max(contours, key=cv2.contourArea)
    if cv2.contourArea(best_contour) < 3000:
        return None

    x, y, w, h = cv2.boundingRect(best_contour)
    return (x + w // 2, y + h // 2)

実行したログ:

[Mac] ✓ 白箱検出: HSV白色マスク方式
...
[Mac] ✓ 白箱検出: pixel=(700, 250), area=31190px²
[Mac]    → Unity座標: (0.1188, 0.0041)
[Mac] PICK Unity座標: (0.2115, 0.2966)
[Mac] △ PICK lookup失敗: dist=125.4mm > 100mm
[Mac] × PICK lookup失敗 → 固定角度モードにfallback

白箱の実物は画面右下にある。しかし検出座標は pixel=(700, 250) ―― マット上部だった。

デバッグ可視化を追加して確認した。_save_white_debug() を実装し、白色マスクを青半透明、全候補の外接矩形を緑、採用領域を赤で重畳して ~/r2r2r-scripts/logs/debug_frames/ に保存する。

def _save_white_debug(frame, mask, candidates, best):
    dbg_dir = os.path.join(LOGS_DIR, "debug_frames")
    os.makedirs(dbg_dir, exist_ok=True)

    cv2.imwrite(os.path.join(dbg_dir, "white_mask_frame.jpg"), mask)

    debug_img = frame.copy()
    blue_overlay = np.zeros_like(debug_img)
    blue_overlay[:, :, 0] = mask
    cv2.addWeighted(blue_overlay, 0.5, debug_img, 1.0, 0, debug_img)

    if _mat_px_range is not None:
        min_x, max_x, min_y, max_y = _mat_px_range
        cv2.rectangle(debug_img,
                      (min_x, min_y), (max_x, max_y), (0, 255, 255), 2)

    for (area, cx, cy, x, y, w, h) in candidates:
        cv2.rectangle(debug_img, (x, y), (x + w, y + h), (0, 255, 0), 2)
        cv2.putText(debug_img, f"{area:.0f}", (x, y - 5),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)

    if best is not None:
        _, cx, cy, bx, by, bw, bh = best
        cv2.rectangle(debug_img,
                      (bx, by), (bx + bw, by + bh), (0, 0, 255), 3)
        cv2.circle(debug_img, (cx, cy), 10, (0, 0, 255), 2)

    cv2.imwrite(os.path.join(dbg_dir, "white_debug_frame.jpg"), debug_img)

保存された white_debug_frame.jpg を見ると、マット上部に広がる青い領域がはっきりわかった。ロボットアームの白い筐体がマットに侵入している部分が、そのまま最大白色領域として認識されていた。

「白い」という条件だけでは、白箱と白いアームを区別できない。 マット範囲フィルタで「マット外」は切り落とせても、アームはマット上に存在する。HSV方式の限界はここだった。

「これ前みたいに白箱を画像で覚えさせた方がいいんじゃないの?」

試行3: テンプレートマッチング復帰 + ROIクリップ + 12枚撮影

テンプレート方式に戻す。ただし今回は cv2.matchTemplate をマット範囲ROI内でのみ実行する改良を加えた。アームの白筐体を物理的に候補から外せる。

def find_white_box(frame, debug_log=False):
    if _mat_px_range is None:
        return None

    min_x, max_x, min_y, max_y = _mat_px_range

    # マット範囲をROIとして切り出し
    roi = frame[min_y:max_y+1, min_x:max_x+1]
    gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

    best_score = -1.0
    best_result = None

    for template_info in ALL_TEMPLATES:
        template_gray = template_info['gray']
        template_name = template_info['name']

        for scale in TEMPLATE_SCALES:
            scaled_w = int(template_gray.shape[1] * scale)
            scaled_h = int(template_gray.shape[0] * scale)
            if scaled_w >= roi.shape[1] or scaled_h >= roi.shape[0]:
                continue

            scaled_template = cv2.resize(template_gray, (scaled_w, scaled_h))
            result = cv2.matchTemplate(
                gray_roi, scaled_template, cv2.TM_CCOEFF_NORMED)
            _, max_val, _, max_loc = cv2.minMaxLoc(result)

            if max_val > best_score:
                best_score = max_val
                mx, my = max_loc
                # ROI座標 → 元frame座標に変換
                cx = mx + min_x + scaled_w // 2
                cy = my + min_y + scaled_h // 2
                best_result = (cx, cy, scaled_w, scaled_h, template_name)

    if best_score < TEMPLATE_SCORE_THRESHOLD:
        return None

    cx, cy, w, h, template_name = best_result
    return (cx, cy)

バックアップ: r2r2r_loop.py.bak_DAY165_pre_template_restore(848行 → 939行、+91行)

古いテンプレート3枚のまま実行。アームの誤検出は消えたが、スコアは依然0.547。ROIクリップはアームを除外できても、「データが古い」という根本は変わらない。

ここで新規テンプレート撮影専用のスクリプトを書いた。capture_white_box_templates.py(143行)。

#!/usr/bin/env python3
"""
capture_white_box_templates.py
白箱テンプレートを複数角度・位置で撮影・切り出し保存する。
SPACE: cv2.selectROIで矩形選択→切り出し保存 / Q: 終了
"""
import os, sys, time, cv2, numpy as np
from datetime import datetime
from mat_corner_detection import (
    capture_frames, detect_corners, remove_outliers_and_average)

CAMERA_INDEX  = 2
WARMUP_FRAMES = 30
TEMPLATES_DIR = os.path.expanduser("~/r2r2r-scripts/templates")
MIN_W, MIN_H  = 30, 30

def detect_mat_range(cap):
    frames  = capture_frames(cap, 10)
    results = [detect_corners(f) for f in frames
               if detect_corners(f) is not None]
    if len(results) < 3:
        return None
    avg    = remove_outliers_and_average(results)
    xs, ys = avg[:, 0], avg[:, 1]
    return (int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max()))

def main():
    os.makedirs(TEMPLATES_DIR, exist_ok=True)
    cap = cv2.VideoCapture(CAMERA_INDEX)
    if not cap.isOpened():
        print("[ERROR] カメラを開けませんでした"); sys.exit(1)

    for _ in range(WARMUP_FRAMES): cap.read()
    time.sleep(0.3)

    mat_px_range  = detect_mat_range(cap)
    capture_count = 0

    while True:
        ret, frame = cap.read()
        if not ret: continue

        disp = frame.copy()
        if mat_px_range:
            min_x, max_x, min_y, max_y = mat_px_range
            cv2.rectangle(disp,
                (min_x, min_y), (max_x, max_y), (0, 255, 255), 2)
        cv2.putText(disp,
            f"Saved: {capture_count} | SPACE: capture  Q: quit",
            (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255,255,255), 2)
        cv2.imshow("Capture White Box Templates", disp)
        key = cv2.waitKey(1) & 0xFF

        if key in (ord('q'), ord('Q')): break

        if key == ord(' '):
            roi = cv2.selectROI("Select Region", frame,
                                showCrosshair=True, fromCenter=False)
            cv2.destroyWindow("Select Region")
            x, y, w, h = roi
            if w < MIN_W or h < MIN_H:
                print(f"[WARN] 小さすぎます ({w}x{h})")
                continue
            capture_count += 1
            ts  = datetime.now().strftime("%Y%m%d_%H%M%S")
            dst = os.path.join(TEMPLATES_DIR,
                    f"white_box_{ts}_{capture_count:03d}.png")
            cv2.imwrite(dst, frame[y:y+h, x:x+w])
            print(f"[SAVED] {os.path.basename(dst)} ({w}x{h}px)"
                  f" — 累計 {capture_count}枚")

    cap.release()
    cv2.destroyAllWindows()
    print(f"\n[INFO] 終了。テンプレート合計: {capture_count}枚")

if __name__ == "__main__": main()

古いテンプレート3枚を templates/archive_pre_DAY165/ に退避してから撮影を開始。

マット上に白箱を置き、位置と向きを変えながら12回。四隅・四辺中央・中央付近の3×4グリッドを念頭に、向きは0°/15°/30°/45°を循環させた。アームに近い位置(影が落ちる)、マット端(照り返しが強い)も含めた。

#

ファイル名

サイズ

001

white_box_20260420_192715_001.png

186×167

002

white_box_20260420_192733_002.png

180×184

003

white_box_20260420_192746_003.png

193×171

004

white_box_20260420_192758_004.png

188×190

005

white_box_20260420_192808_005.png

174×191

006

white_box_20260420_192822_006.png

188×183

007

white_box_20260420_192831_007.png

188×191

008

white_box_20260420_192845_008.png

183×170

009

white_box_20260420_192854_009.png

195×185

010

white_box_20260420_192905_010.png

184×190

011

white_box_20260420_192915_011.png

185×189

012

white_box_20260420_192926_012.png

192×187

幅174〜195、高さ167〜191px。合計12枚、約400KB。

スコア0.994

12枚を読み込ませて実行した。起動ログ:

=== R2R2R Loop (homography方式) ===
[Mac] FK scan CSV読み込み中...
[Mac] ✓ 213312点ロード, ee_yフィルタ後: 156603点
[Mac] ✓ テンプレート 12枚読み込み完了
      - white_box_20260420_192715_001.png: 186x167
      ...(12枚)...
      - white_box_20260420_192926_012.png: 192x187
[Mac] ✓ マット範囲: X=[291, 1543] Y=[45, 956]

スコア一覧(scale=1.00 抜粋):

テンプレート

scale=0.85

scale=1.00

scale=1.15

001.png

0.784

0.887

0.643

002.png

0.746

0.937

0.640

007.png

0.744

0.817

0.624

008.png

0.793

0.815

0.598

011.png

0.802

0.910

0.690

012.png

0.802

0.994

0.628

最高スコア: 0.994 (threshold=0.5)
✓ 白箱検出: pixel=(1434, 835)
  score=0.994, template=white_box_20260420_192926_012.png × scale=1.00
→ Unity座標: (-0.2677, 0.3128)

0.547 → 0.994。 Detection画面の青い矩形が白箱の外形とピタリ一致した。複数テンプレートが0.8〜0.9台を出していて、冗長性がある。どれかが外れても他でカバーできる。

lookup結果: PICK成功 / PLACE範囲外

[Mac] PICK Unity座標: (0.0883, 0.3269)
[Mac] ✓ PICK lookup成功: ee=(0.0641, 0.0412, 0.2313) dist=98.6mm
[Mac]    PICK angles=[-15.0, 107.7, -63.3, 3.6, -40.1, -30.0]

[Mac] PLACE Unity座標: (-0.2677, 0.3128)
[Mac] △ PLACE lookup失敗: dist=188.6mm > 100mm
[Mac] △ PLACE lookup失敗 → PLACE固定角度 fallback

PICK側は成功(dist=98.6mm、閾値100mmギリギリ)。PLACE側は失敗。白箱のUnity座標(-0.2677, 0.3128) = ロボット座標 X=-268mm, Y=313mm付近。現行 fk_scan_motion_based.csv(LOG151生成)のカバー範囲 Robot X -30〜-90, Y +100〜+180 から完全に外れている。

レイヤー

状態

数値

白箱pixel検出

✅ 成功

score 0.994

赤ブロック→Unity変換

✅ 成功

誤差3mm以内

PICK lookup

✅ 成功

dist 98.6mm

PLACE lookup

❌ 失敗

dist 188.6mm(範囲外)

pixel検出の成功と実機動作の成功は別レイヤーにある。どのレイヤーで止まっているかを切り分けることが、次の手を正しく決める前提になる。

判明したこと

方式を変えるより先に、データを疑うべきだった。朝の段階でテンプレートが古いと分かっていれば、HSV方式への切り替えというタスクは発生しなかった。「テンプレートマッチングのスコアが低い」は「方式が悪い」と「データが古い」の両方を含んでいる。その切り分けをせずに方式を変えたのが遠回りだった。

環境依存のテンプレートは、環境が変わるたびに更新するものだ。LOG163-164でマット位置を変えてhomographyを再キャリブした。そのタイミングでテンプレートも更新すべきだった。auto学習は「閾値を通過した瞬間に保存する」だけで、意図的なバリエーション撮影ではない。

テンプレートマッチングという古典的な手法でも、12枚のバリエーション撮影でスコア0.994まで到達する。問題は方式ではなく、環境に合わせてデータを更新する運用があるかどうかだった。

バックアップ一覧

ファイル名

内容

r2r2r_loop.py.bak_DAY165_pre_homography

homography移行前

r2r2r_loop.py.bak_DAY165_pre_whitehsv

HSV方式切替前

r2r2r_loop.py.bak_DAY165_pre_template_restore

テンプレート復元前

homography.npy.bak_20260420_181438

homography再キャリブ前

templates/archive_pre_DAY165/

古いテンプレート3枚

次の課題

PLACE側lookup tableの範囲拡張。現行カバー範囲 Robot X -30〜-90, Y +100〜+180 に対し、白箱の置かれる位置は X=-268mm, Y=313mm 付近。PICK側もdist=98.6mmでギリギリなので、両方をカバーするUnity FK scanの範囲再設計が必要になる。

白箱が見える。次はその位置まで腕を届かせる。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」にインスピレーションを受けて命名していますが、実装内容は本家研究とは異なる独自のアプローチです。