mechArm 270 Pi 動的追従の限界を探る Log#28 | 「できた」が場所を変えると崩れる。
LOG224R2R2R2026/6/6

mechArm 270 Pi 動的追従の限界を探る Log#28 | 「できた」が場所を変えると崩れる。

デザイナーがロボットを触ると、見えてくるものがある。LOG224で見えたのはこれ。

拡充したテーブルで、接近が通った

前回、掴んだ赤ブロックを白箱の上で離す動作(PLACE)の途中で詰まっていた。落下点が白箱の中心からずれるのを打ち消す補正を入れたところ、降下が止まった。原因は補正でも符号でもなく、その狙いへ降りる姿勢が姿勢テーブル(FK テーブル)に無いという、別の問題だった。テーブルを生成元の Unity まで遡って作り直し、512,087 点から 710,704 点へ拡充した。検算では狙い 3 点すべてが通り、回帰の悪化もなかった。

検算が通ることと、実機が降りられるかは別だ。拡充したテーブルで実際に接近・降下できるかを確かめた。

白箱を左奥(box ≒ -0.177, +0.218)に固定し、赤ブロックを爪に挟んで接近を実行した。実機ログを全量載せる。

[INIT] 上カメラ index=2  横カメラ index=1
[INIT] daemon 接続中...
  [Pi] fresh_mode=1
[INIT] fresh_mode=1
[PRE]  現在姿勢: [0.17, 1.23, 0.96, 0.00, 0.17, -29.70]
[PRE]  HOME ズレ max_err=1.23°  (許容 5.0°)
[BOX] pixel 中心=(1338, 716)  score=0.636  angle=0°
[BOX] Unity 座標: (unity_x=-0.177, unity_z=+0.218) [単位 m]
[BOX] 補正前 box xz = (-0.177, +0.218) m
[APPROACH] 把持補正後の狙い xz = (-0.152, +0.193) m  (offset = +0.025, -0.025)
[LOOKUP] 目標 (ee_x=-0.152, ee_y=+0.120, ee_z=+0.193)
[LOOKUP] CSV スキャン中: /Users/shimazueiichi/r2r2r-scripts/fk_scan_motion_based_v2.csv
[LOOKUP] 完了: 710704 点  カラム不一致 0 件  到達不可で除外 487739 件
[LOOKUP] 最近傍: ee=(-0.158, +0.126, +0.217)  3D距離=25.9mm
[LOOKUP] 最近傍 関節角度: [13.00, 72.00, -5.00, -42.00, -96.00, -30.00]
[LOOKUP] しきい値判定: OK (25.9mm <= 100mm)
[STAGE1] 到達
[STAGE2] 到達

接近は中断せず、白箱上空の APPROACH 姿勢(STAGE2)まで到達した。前回、同じ左奥で詰まっていたのと同じ座標だ。前回はこの狙いへ降りる姿勢がテーブルに無くて選べなかった。それが今回は 710,704 点から 3D 距離 25.9mm で引け、関節制限も通って、実機も到達した。テーブルの穴は埋まったと、実機で確認できた。

降下は、1mm 足りずに止まっていた

接近の次は降下(DESCEND)だ。白箱の上空 120mm から、離す高さ 110mm へ降ろす。ここで、前回まで繰り返し止まっていた現象が、また出た。

[DESCEND[0] ee_y=0.110m] target_xyz = (-0.152, +0.110, +0.193)
[DESCEND[0] ee_y=0.110m] LOOKUP 710704 点  カラム不一致 0 件  到達不可で除外 487739 件
[DESCEND[0] ee_y=0.110m] 最近傍 dist=23.9mm  ee_y_n=+0.104m  joints=[13.00, 77.00, -5.00, -42.00, -96.00, -30.00]
[DESCEND[0] ee_y=0.110m] [ABORT] 最近傍の高さが低すぎます: ee_y_n=+0.104 < +0.105 (目標 +0.110 の下振れ 5mm 超)

目標 110mm に対して、テーブルから引けた一番近い姿勢の高さが 104mm。下振れ 6mm。安全装置は「目標より 5mm を超えて下に外れる姿勢は送らない」と設定してあったので、6mm の下振れが 1mm だけ閾値を超えて、送る前に止まった。

止まり方が前回までと違うことに気づいた。以前あった破綻 ── 距離だけで一番近い点を引いた結果、肘や手首が大きく捻れた、まったく別の腕の形が選ばれて、それが低すぎて止まる ── ではない。引けた姿勢 [13, 77, -5, -42, -96, -30] は、接近姿勢 [13, 72, -5, -42, -96, -30] から j1 が 5°動いただけの、素直な降下だ。姿勢の質は正常で、高さがちょうど 1mm 足りないだけだった。

この同じ 104mm が、白箱を動かさずに繰り返した 4 回すべてで、寸分違わず引かれた。

ee_y_n=+0.104m  dist≈23.9-24.1mm  joints=[13, 77, -5, -42, -96, -30]

4 回とも同じ点。これはばらつきではなく、このテーブルがこの白箱位置に対して持っている「最も狙いに近い降下姿勢」が 104mm で固定されているということだ。試行を重ねて偶然 110mm 近辺を引き当てる、という当てにいく手は消えた。104mm 以外の選択肢が、そもそもテーブルに無い。

その一方で、4 回とも、降下を中断したあと「だいたいの位置で離す」工程へ進めば、ブロックは白箱に入った。落下点を見ると、左奥のこの 1 点では狙いの中心近くに落ちている。

白箱(-0.177, +0.218) / 赤ブロック(-0.174, +0.242)  ΔX=+3mm  ΔZ=+24mm  → y
白箱(-0.177, +0.218) / 赤ブロック(-0.178, +0.246)  ΔX=-1mm  ΔZ=+28mm  → y
白箱(-0.176, +0.218) / 赤ブロック(-0.185, +0.253)  ΔX=-9mm  ΔZ=+35mm  → y
白箱(-0.176, +0.218) / 赤ブロック(-0.179, +0.247)  ΔX=-3mm  ΔZ=+29mm  → y

横(ΔX)は -9 〜 +3mm でほぼ中心。前回、補正前は -50mm だったのが、半量の補正でぴたりと寄った。奥行き(ΔZ)は +24 〜 +35mm 残るが、80mm 角の白箱には収まる。降下は ABORT しているが、離す工程が拾って 4 回とも入った。

安全閾値を 1mm 緩めて、降下が最後まで通った

104mm が固定で、姿勢の質も正常なら、安全閾値を 1mm だけ広げて、この 104mm を送れるようにすればいい。降下の高さ下振れ許容 EE_Y_MIN_TOL を 5mm から 6mm へ変える。変更はこの 1 箇所だけだ。

EE_Y_MIN_TOL = 0.006   # 降下用。最近傍が目標より下に外れてよい上限(m)= 6mm。
                       # 110mm 化の安全意図に合わせ 15→5mm に縮小(codex R2)。
                       # DAY224: 左奥領域でテーブル最近傍が固定的に 104mm(目標110に対し
                       # 下振れ6mm)で、姿勢自体は素直な降下と4回再現で確認。5→6mm に
                       # 1mm だけ広げ、この 104mm 姿勢を送って descend を通す。
                       # 104mm は旧 release 接触高さ(100mm)の +4mm 直近のため、初回は目視で
                       # 爪・把持ブロックと白箱フチのクリアランスを確認すること。
                       # 目標110mmに対し 104mm 未満の FK 姿勢は引き続き送らず ABORT。

判定式は既存のまま if ee_y_n < ee_y_stage - EE_Y_MIN_TOL: で、110mm 段に対する閾値は 0.110 - 0.006 = 0.104。104mm ちょうどは通し、103mm 以下は止める。

この変更は降下に直結するので、外部レビュー(codex)に静的レビューを依頼した。レビューは、テーブルの真値を CSV から引いて確認していた。

提示座標でCSV確認した最近傍は ee_y=0.104469m で、下限 0.104000m より約0.47mm上でした。

104mm 表示の真値は 104.47mm で、閾値の 104.0mm より 0.47mm 上にいた。境界ぴったりではなく、わずかに通る側に余裕がある。同時に、物理接触リスクについての注意も返ってきた。

104.469mmは旧接触100mmから約4.5mmしか余裕がなく、同姿勢近傍のCSVでは j1 5°差で ee_y が約21mm動くため、1°程度の到達ズレでも余裕を食います。初回目視前提なら許容範囲ですが、「FK高さチェックだけで安全」とは言い切らない方がいいです。

テーブル上は 104.47mm で安全でも、実機が送信角ぴったりに止まる保証はなく、到達誤差 1°が高さ 4mm に化ける敏感な領域だという指摘だ。紙の上の 4.5mm 余裕は、実機では数 mm まで縮みうる。FK の高さチェックだけでは担保しきれない。だから初回は目視で見ることを条件に、実機へ進めた。

降下の本体(run_descend)の全文を載せる。安全ガード ── テーブルに姿勢があるか、3D 距離が離れすぎていないか、高さが低すぎないか、関節制限内か ── はすべて送信の手前に置いてある。

def descend_step(target_angles, label, proceed_on_timeout=False):
    """1段の降下送信。
    手順:
      1) send_angles(target, speed=SPEED, wait=0) を1回送る
      2) Mac 側で sleep + get_angles で到達確認(REACH_TIMEOUT_SEC まで)
      3) 未到達なら同一 target を「1回だけ」再送(14 の 3 回再送は持ち込まない)
      4) 再送後も未到達なら追加送信せず未到達で返す(張り付き・押し付け回避)

    proceed_on_timeout=True(HOLD 廃止経路)の場合:
      2) の待機後、tight reach 未達でも 3)4) を行わず settle 姿勢で
      reach_ok=True を返す。再送・ABORT しない(17 の RELEASE 経路用)。
      JOINT_LIMITS チェックと最初の send + 待機は通常どおり実施する。

    戻り値: (reach_ok: bool, resent: bool, last_measured: list or None)
    """
    violations = check_joint_limits(target_angles)
    if violations:
        for i, v, lo, hi in violations:
            print(f"[{label}] [LIMITS] NG: j{i}={v:.2f} 範囲 [{lo}, {hi}] 外")
        return False, False, None

    print(f"[{label}] send_angles {fmt(target_angles)}")
    daemon_send_angles(target_angles, speed=SPEED, wait=0)
    ok, err, measured = _wait_for_reach(target_angles, label)
    if ok:
        return True, False, measured

    if proceed_on_timeout:
        err_str = f"{err:.2f}" if err is not None else "None"
        print(
            f"[{label}] tight reach 未達 max_err={err_str}°だが "
            "proceed_on_timeout=True のため settle 姿勢で次へ進む(再送しない)"
        )
        return True, False, measured

    err_str = f"{err:.2f}" if err is not None else "None"
    print(f"[{label}] 未到達 max_err={err_str}°。1回だけ再送する")
    daemon_send_angles(target_angles, speed=SPEED, wait=0)
    ok2, err2, measured2 = _wait_for_reach(target_angles, label)
    if ok2:
        return True, True, measured2

    err2_str = f"{err2:.2f}" if err2 is not None else "None"
    print(f"[{label}] 再送後も未到達 max_err={err2_str}°。これ以上送信しない")
    return False, True, measured2 if measured2 is not None else measured


def _make_log_path():
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    return LOG_DIR / f"descend_log_{ts}.csv"


def run_descend(X_app, Z_app, approach_measured, proceed_on_timeout=False):
    """X_app, Z_app, approach_measured を引数で受ける降下ランナー。

    引数:
        X_app, Z_app: float (m)。APPROACH 探索に使った Unity 座標 X・Z。
                      降下中は固定したまま ee_y のみを下げる。
        approach_measured: list[float] 6 要素。APPROACH 到達時の実測関節角(度)。
        proceed_on_timeout: bool。デフォルト False=従来挙動(tight reach 未達なら
                      1回再送→なお未達なら ABORT)。True の場合、送信後 settle
                      したら tight reach 未達でも「降下完了」として次へ進む
                      (HOLD 廃止経路用。17 RELEASE モードから呼ばれる)。
                      降下前の安全ガード(FK 最近傍 / 3D 距離 / EE_Y_MIN_TOL /
                      JOINT_LIMITS)は値に関わらず常に有効。

    戻り値: bool(True=最終段到達 / False=中断)

    前提:
        daemon 接続・fresh_mode=1 設定は caller 側で済ませてあること。
        本関数は daemon 接続・切断・fresh_mode を一切触らない。
    """
    # 降下の狙い点を把持オフセット分だけ逆向きに補正する。
    # 補正は FK 最近傍探索に渡る狙い xz のみ。記録(place_trial_log)には
    # 補正前の生 box(X_app, Z_app)を渡し、CSV の box は実際の白箱位置に保つ。
    X_desc = X_app + PLACE_X_OFFSET_M
    Z_desc = Z_app + PLACE_Z_OFFSET_M

    print("[INIT] DESCEND 起点")
    print(f"[INIT] approach_measured = {fmt(approach_measured)}")
    print(
        f"[INIT] 補正前 box xz = ({X_app:+.3f}, {Z_app:+.3f}) m  "
        f"ee_y={EE_Y_APPROACH:+.3f}m (APPROACH)"
    )
    print(
        f"[DESCEND] 把持補正後の狙い xz = ({X_desc:+.3f}, {Z_desc:+.3f}) m  "
        f"(offset = {PLACE_X_OFFSET_M:+.3f}, {PLACE_Z_OFFSET_M:+.3f})"
    )
    print(f"[INIT] 降下段 ee_y = {EE_Y_STAGES} m")

    log_path = _make_log_path()
    print(f"[LOG] {log_path}")

    aborted = False
    with open(log_path, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow([
            "stage", "ee_y_target", "target_xyz",
            "nearest_dist_mm", "sent_angles", "measured",
            "resent", "reach_ok", "aborted",
        ])
        f.flush()

        for stage_idx, ee_y_stage in enumerate(EE_Y_STAGES):
            label = f"DESCEND[{stage_idx}] ee_y={ee_y_stage:.3f}m"
            place_preview.update(phase=f"DESCEND {ee_y_stage*1000:.0f}mm")
            target_xyz = (X_desc, ee_y_stage, Z_desc)
            print(
                f"\n[{label}] target_xyz = "
                f"({X_desc:+.3f}, {ee_y_stage:+.3f}, {Z_desc:+.3f})"
            )

            best_row, best_dist_sq, data_rows, skipped, skipped_unreach = scan_csv_for_nearest(
                CSV_PATH, X_desc, ee_y_stage, Z_desc
            )
            print(
                f"[{label}] LOOKUP {data_rows} 点  "
                f"カラム不一致 {skipped} 件  到達不可で除外 {skipped_unreach} 件"
            )

            if best_row is None:
                print(
                    f"[{label}] [ABORT] 到達可能な FK 姿勢が見つかりません"
                    "(JOINT_LIMITS 内の有効姿勢が CSV に1件もありません)"
                )
                writer.writerow([
                    stage_idx, ee_y_stage, list(target_xyz),
                    "", "", "", False, False, True,
                ])
                f.flush()
                place_trial_log.log_trial(
                    "descend_abort", X_app, Z_app,
                    stage="descend",
                    abort_reason="FK姿勢なし",
                )
                aborted = True
                break

            dist_mm = math.sqrt(best_dist_sq) * 1000.0
            ee_y_n = best_row[1]
            joints = list(best_row[3:9])
            print(
                f"[{label}] 最近傍 dist={dist_mm:.1f}mm  ee_y_n={ee_y_n:+.3f}m  "
                f"joints={fmt(joints)}"
            )

            if dist_mm > DIST_THRESHOLD_MM:
                print(
                    f"[{label}] [ABORT] 最近傍 3D 距離 {dist_mm:.1f}mm > "
                    f"{DIST_THRESHOLD_MM:.0f}mm。妥当な姿勢が見つからないため降下を続けません"
                )
                writer.writerow([
                    stage_idx, ee_y_stage, list(target_xyz),
                    f"{dist_mm:.2f}", "", "", False, False, True,
                ])
                f.flush()
                place_trial_log.log_trial(
                    "descend_abort", X_app, Z_app,
                    stage="descend",
                    abort_reason=f"3D距離超過 {dist_mm:.1f}mm>{DIST_THRESHOLD_MM:.0f}mm",
                )
                aborted = True
                break

            if ee_y_n < ee_y_stage - EE_Y_MIN_TOL:
                print(
                    f"[{label}] [ABORT] 最近傍の高さが低すぎます: "
                    f"ee_y_n={ee_y_n:+.3f} < {ee_y_stage - EE_Y_MIN_TOL:+.3f} "
                    f"(目標 {ee_y_stage:+.3f} の下振れ {EE_Y_MIN_TOL*1000:.0f}mm 超)"
                )
                writer.writerow([
                    stage_idx, ee_y_stage, list(target_xyz),
                    f"{dist_mm:.2f}", "", "", False, False, True,
                ])
                f.flush()
                place_trial_log.log_trial(
                    "descend_abort", X_app, Z_app,
                    stage="descend",
                    abort_reason=f"高さ下振れ ee_y_n={ee_y_n:+.3f}<{ee_y_stage - EE_Y_MIN_TOL:+.3f}",
                )
                aborted = True
                break

            sent_angles = list(joints)
            sent_angles[5] = J5_FIX
            print(f"[{label}] j5 を {J5_FIX} に固定 → sent_angles={fmt(sent_angles)}")

            reach_ok, resent, measured = descend_step(
                sent_angles, label, proceed_on_timeout=proceed_on_timeout
            )
            writer.writerow([
                stage_idx, ee_y_stage, list(target_xyz),
                f"{dist_mm:.2f}", fmt(sent_angles), fmt(measured),
                resent, reach_ok, not reach_ok,
            ])
            f.flush()

            if not reach_ok:
                print(
                    f"[{label}] [ABORT] 未到達のため降下中断。"
                    "追加送信なしで停止します"
                )
                aborted = True
                break

            print(f"[{label}] 到達")

    if aborted:
        print(
            "\n[ABORT] 降下中断。"
            "グリッパーは閉じたまま、追加送信なしで終了。"
            "HOME 復帰は実行しません。エイイチの目視判断に渡します"
        )
        return False

    print(
        f"\n[HOLD] 最終段 ee_y={EE_Y_STAGES[-1]:.3f}m に到達。"
        "グリッパー閉じたまま保持。目視確認に渡します。"
        "(RELEASE / RETREAT / HOME 復帰は別ステップ)"
    )
    return True

実機で動かすと、降下は最後まで通った。

[DESCEND[0] ee_y=0.110m] 最近傍 dist=23.9mm  ee_y_n=+0.104m  joints=[13.00, 77.00, -5.00, -42.00, -96.00, -30.00]
[DESCEND[0] ee_y=0.110m] j5 を -30 に固定 → sent_angles=[13.00, 77.00, -5.00, -42.00, -96.00, -30.00]
[DESCEND[0] ee_y=0.110m] send_angles [13.00, 77.00, -5.00, -42.00, -96.00, -30.00]
[DESCEND[0] ee_y=0.110m] [WAIT] t=0.6s  max_err=1.14°
[DESCEND[0] ee_y=0.110m] 到達

[HOLD] 最終段 ee_y=0.110m に到達。グリッパー閉じたまま保持。目視確認に渡します。

これまで 4 回送る前に止まっていた 104mm が、今回は送られて、実機が max_err 1.14°で一発到達した。レビューが警告した「1°ズレで 4mm 動く敏感な領域」に対して、実機は 1.14°で止まったので、高さのブレは数 mm 以内に収まった。爪と白箱フチの接触もなく降り切った。離すと、白箱に入った。接近・降下・離すの一連が、初めて繋がった。

アームが、自分を白箱と誤検出した

次に、同じ左奥でもう数回回して落下点の安定性を見ようとした。ところが、白箱検出がおかしな場所を指した。

[06f] DETECT: center=(1013,214) score=0.631 angle=75° scale=1.0 size=(212x197) template=white_box_20260520_163929_013.png
[BOX] pixel 中心=(1013, 214)  score=0.631  angle=75°
[BOX] Unity 座標: (unity_x=-0.017, unity_z=-0.031) [単位 m]
[LOOKUP] 最近傍: ee=(-0.175, +0.016, +0.017)  3D距離=222.8mm
[ABORT] しきい値超過: 222.8mm > 100mm

検出中心が、それまで安定していた (1335, 716) 付近から (1013, 214) ── 画面中央の上のほう ── へ飛んだ。角度も 0°から 75°、選ばれたテンプレートも別物に変わった。スコアは 0.631 で、白箱本物のときとほぼ同じ。導かれた狙い unity(-0.017, -0.031) はアームのほぼ真ん前で、そこへの最近傍は 222.8mm 離れていた。距離しきい値が捕まえて、送信前に止まった。誤検出 → ありえない狙い → 距離しきい値が捕まえて停止、という安全の連鎖は、設計どおり働いた。

同じコマンドを繰り返しても、3 回とも同じ (1005, 213) 付近・角度 75°でロックした。ランダムなら毎回違う場所に飛ぶ。同じ場所に固定でロックするのは、何か特定のものを安定して拾っているということだ。

上カメラの保存画像を見て、原因が分かった。白箱は右下にちゃんと写っている。なのに検出が拾った中心は、画面中央上 ── アーム胴体の白〜グレーの四角い塊だった。テンプレートマッチングは「登録した白箱に形と明るさが似た場所」を探す。真上から見ると、白箱もアーム胴体も、白〜グレーの四角だ。検出器は、自分の胴体と白箱を区別する手段を持っていない。

これは前にも一度起きていた問題だった。テンプレを白箱に最適化することで「白箱のほうが胴体より強くマッチする」状態に持ち込み、いったんは消えていた。だが「マット内にアームが映り込む」構造そのものは残っていた。接近前に検出が走るときのアーム姿勢が、回によって胴体を画面中央上に大きく横たえる形になると、その胴体が白箱を上回るスコアを出す。今回その条件を踏んだ。

緩める方向では直らない。白箱が検出されないのではなく、胴体が白箱と同じスコアで検出されてしまうのが問題だ。しきい値を緩めれば胴体をますます拾う。見る範囲を絞るしかない。

胴体が写る帯を、検出から外した

白箱がこれまで置かれた全ての位置の、画像上の座標が記録に残っていた。胴体の誤検出位置と並べると、きれいに分離していた。

白箱(正しい検出)   P1(1390,594) P2(652,716) P3(584,385) P4(1383,355) 左奥(1335,715)
胴体(誤検出)3回    (1013,214) (1012,215) (1001,211)   size≈212×197

胴体は画面の中央上(X≒1005, Y≒213)に集中し、白箱 5 点は誰もその帯に入らない。白箱の X は 584, 652, 1335, 1383, 1390 で、胴体の X 帯(900〜1110)には一つも入らない。だから「中央上の矩形だけ」を検出対象から外せば、白箱の置き場所を狭めずに胴体だけを落とせる。

除外帯を X:850〜1150 / Y:80〜360 に決めた。検出候補の中心がこの矩形に入ったら、最良候補(overall_best)の選定対象から外す。マッチング計算は変えず、結果の採否だけを中心座標で判定する。06f の検出本体(evaluate_frame_gray)に、この除外を足した。変更箇所を含む全文を載せる。

# 胴体除外ROI(pixel・top カメラ画像座標)。
# HOME系姿勢でアーム胴体が画角中央上に写り込み、白箱と誤検出されるのを防ぐ。
# この矩形内に「検出候補の中心(cx,cy)」が入った候補は overall_best 更新の対象から外す。
# 実測(DAY224): 胴体中心≈(1005,213) size≈212×197。白箱5点(P1〜P4+左奥)は全て帯の外。
# 注意: これは固定pixel座標。カメラ位置・マット範囲が変わると帯がずれ、胴体取りこぼし or
#       白箱巻き込みの可能性がある。将来マット範囲相対へ移行する余地あり(DAY224時点は固定で可)。
BODY_EXCLUDE_ROI_PX = (850, 1150, 80, 360)   # (min_x, max_x, min_y, max_y)


def _is_in_body_exclude_roi(cx, cy):
    """検出候補の中心が胴体除外ROI内なら True(= 採用しない)。"""
    min_x, max_x, min_y, max_y = BODY_EXCLUDE_ROI_PX
    return (min_x <= cx <= max_x) and (min_y <= cy <= max_y)


def evaluate_frame_gray(frame, mat_bbox, templates_by_group):
    """グレースケール版 evaluate_frame。

    元の test_white_box_score.evaluate_frame と同じインターフェイス。
    内部で roi と template を BGR → GRAY に変換してから cv2.matchTemplate を呼ぶ。
    cv2.matchTemplate の計算量がチャネル数に比例するため、3 チャネル → 1 チャネルで
    約 3 倍速になる想定。
    """
    min_x, max_x, min_y, max_y = mat_bbox
    roi = frame[min_y:max_y, min_x:max_x]
    if roi.size == 0:
        print("[06f] ERROR: ROI が空です")
        return {"day165": [], "auto": [], "overall_best": None}

    # ★ ここでグレースケール化 ★
    roi_gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
    roi_h, roi_w = roi_gray.shape[:2]

    results = {"day165": [], "auto": []}
    overall_best = None
    excluded_best = None  # 胴体除外ROIで弾いた最高スコア候補(診断ログ用)

    for group, tmpls in templates_by_group.items():
        for t in tmpls:
            best_score = -1.0
            best_scale = None
            best_angle = None
            best_size = (0, 0)
            best_cxcy = (0, 0)

            for rot in t["rotations"]:
                angle = rot["angle"]
                template_bgr = rot["bgr"]
                # ★ テンプレもグレースケール化 ★
                template_gray = cv2.cvtColor(template_bgr, cv2.COLOR_BGR2GRAY)
                for scale in twbs.SCALES:
                    sw = int(template_gray.shape[1] * scale)
                    sh = int(template_gray.shape[0] * scale)
                    if sw < 30 or sh < 30:
                        continue
                    if sw >= roi_w or sh >= roi_h:
                        continue
                    scaled = cv2.resize(template_gray, (sw, sh))
                    res = cv2.matchTemplate(roi_gray, scaled, cv2.TM_CCOEFF_NORMED)
                    _, max_val, _, max_loc = cv2.minMaxLoc(res)
                    if max_val > best_score:
                        best_score = max_val
                        best_scale = scale
                        best_angle = angle
                        best_size = (sw, sh)
                        mx, my = max_loc
                        cx = mx + min_x + sw // 2
                        cy = my + min_y + sh // 2
                        best_cxcy = (cx, cy)

            row = {
                "name": t["name"],
                "best_score": best_score,
                "best_scale": best_scale,
                "best_angle": best_angle,
                "scaled_w": best_size[0],
                "scaled_h": best_size[1],
                "cx": best_cxcy[0],
                "cy": best_cxcy[1],
            }
            results[group].append(row)

            if best_score > -1.0:
                cand = dict(row)
                cand["group"] = group
                # 胴体除外ROI内に中心が入る候補は overall_best の対象にしない
                if _is_in_body_exclude_roi(cand["cx"], cand["cy"]):
                    cand["excluded_body_roi"] = True
                    if excluded_best is None or cand["best_score"] > excluded_best["best_score"]:
                        excluded_best = cand
                else:
                    if overall_best is None or cand["best_score"] > overall_best["best_score"]:
                        overall_best = cand

    results["overall_best"] = overall_best
    results["excluded_best"] = excluded_best
    return results

変更は、定数とヘルパ関数の追加、overall_best 更新箇所のフィルタ、そして全候補が除外されたときに分かるログ ── この 3 箇所だけだ。マッチング計算、全候補の保持、検出結果の戻り値のキーは変えていない。全候補が除外されると overall_best は None になり、呼び出し側は検出失敗として送信前に止まる。

この変更も降下と同様、外部レビュー(codex)を通した。実体を読んで、報告と実装が一致していること、None 経路が送信前 ABORT に繋がること、実測 8 点(胴体 3・白箱 5)が正しく分離することを、行番号付きで確認できた。固定ピクセル座標であることのリスクについては、こう整理された。

胴体取りこぼしは旧誤検出が復活して危険寄り、白箱巻き込みは送信前ABORTなので安全側の停止です。

壊れ方が非対称で、危険な側(取りこぼし)に倒れても、その先の距離しきい値が二重に捕まえる。実機が示したとおりだ。

実機で動かすと、検出は白箱に戻った。

[06f] DETECT: center=(1355,737) score=0.634 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png
[BOX] pixel 中心=(1355, 737)  score=0.634  angle=0°
[BOX] Unity 座標: (unity_x=-0.197, unity_z=+0.228) [単位 m]
[LOOKUP] 最近傍: ee=(-0.177, +0.118, +0.205)  3D距離=6.1mm

誤検出の (1005, 213)・角度 75°が消え、白箱本来の右下・角度 0°・3D 距離 6.1mm に戻った。胴体除外は効いた。

白箱を動かしたら、全く入らなくなった

ここまでで、接近が通り、降下も通り、誤検出も消えた。白箱を別の場所へ動かして、同じことが起きるかを確かめた。右側(box ≒ +0.20, +0.22)と中央寄り(box ≒ +0.13, +0.24)に置いて回した。

白箱(+0.199, +0.223) / 赤ブロック(+0.276, +0.203)  ΔX=+77mm  ΔZ=-20mm  → n
白箱(+0.133, +0.237) / 赤ブロック(+0.237, +0.249)  ΔX=+104mm ΔZ=+12mm  → n
白箱(+0.201, +0.228) / 赤ブロック(+0.281, +0.206)  ΔX=+80mm  ΔZ=-22mm  → n

横のずれ(ΔX)が +77 〜 +104mm。左奥では -9 〜 +3mm だったのが、白箱を右へ動かしただけで 8 〜 10cm ずれて、全く入らなくなった。

最初は検出か、テーブルか、と疑った。だが右側の 3 回のうち 2 回は、接近も降下も低い 3D 距離で正しく引けていた。

[LOOKUP] 最近傍: ee=(+0.234, +0.117, +0.205)  3D距離=8.4mm
[DESCEND[0] ee_y=0.110m] 最近傍 dist=10.2mm  ee_y_n=+0.108m

接近 8.4mm、降下 10.2mm。テーブルはこの座標に十分な姿勢を持っていて、アームは狙った場所に正しく到達している。それでもブロックは白箱から 8cm ずれて落ちた。lookup は合っている。アームは行くべき場所へ行っている。なのに外れる。ずれの原因は、テーブルでも検出でもなく、その後ろにあった。

1 点で入っていたのは、3 つのずれの偶然の相殺だった

ずれの正体は、把持を打ち消す補正のかけ方にあった。落下点を中心へ寄せる補正(PLACE_X_OFFSET / PLACE_Z_OFFSET)は、Unity 座標の X・Z へ固定の値で足している。だが掴んだブロックとグリッパー先端の位置関係は、グリッパーの向き ── アームの j0 の回転角 ── と一緒に回る。補正は向きに追随せず、固定のまま足される。だから、ある向きでしか合わない。

白箱の置き場所が変われば、そこへ届くアームの向き(j0)が変わる。左奥では j0 が +13 〜 +18°、右側では -30 〜 -40°。アームの向きが 70°近く違う。同じ固定補正でも、グリッパーがこれだけ向きを変えれば、補正が打ち消すべきずれとの関係が崩れる。

左奥 ×6   j0=+13〜+18°   ΔX=-9〜+3mm     → 主に入る
右側 ×3   j0=-30〜-40°   ΔX=+77〜+104mm   → 全部外れる

右側の 3 回のうち 2 回は、接近も降下も低い 3D 距離で正しく引けていた(接近 8.4mm、降下 10.2mm)。lookup は合っていて、アームは狙った場所へ行っている。それでもブロックは 8cm ずれた。ずれは lookup より後ろ、固定補正とアームの向きの食い違いから出ている。

左奥では、検出のずれ・テーブル最近傍のずれ・固定補正と向きの食い違い、その 3 つがたまたま小さく相殺し合って、中心近くに落ちていた。右側ではその 3 つが揃って大きく出て、8cm 外れた。

「白箱を 1 点に固定すれば入る」は、その 1 点でだけ成り立つ偶然だった。場所を変えると、成立は崩れた。

白箱検出にも、まだ精度の問題が残っている。スコアが 0.63 〜 0.68 と低い(以前の安定した検出では 0.80 前後だった)。スケールが回ごとに 0.85 と 1.0 で揺れる。検出枠が白箱の縁の一部に乗り、中心に来ない。テンプレートが、白箱全体ではなく白い縁の一部に部分マッチしている。検出が返す中心は、物体の中心ではなく縁寄りに出ていて、そのずれ方が白箱の置き場所で変わる。これも固定補正で消せないずれの一つだ。

次にやること

次は、白箱検出のテンプレートを今の環境で撮り直す。今のテンプレートは数週間前に撮ったもので、その後の照明・白箱の向き・カメラ角の変化と乖離し、白箱全体ではなく縁に部分マッチするようになった。スコアの低下(0.80 → 0.65)が、その乖離の数値的な現れだ。白色マスクで白箱本体を自動トリムして撮り直し、スコアと中心が戻るかを測る。それで戻れば、検出の中心ずれは経年乖離が原因だったと確定する。戻らなければ、テンプレートマッチングを離れ、白い領域の輪郭から幾何中心を直接計算する方法へ切り替える。

固定補正と向きの食い違いも、別に詰める。補正を Unity 座標へ固定で足すのをやめ、グリッパーの向き(j0)に合わせて回すか、向きごとに測り直す。

接近が通り、降下も通り、誤検出も消えた。積み上げた先で、白箱を動かした瞬間に全部外れた。1 点で入っていたのは、3 つのずれがそこで偶然打ち消し合っていただけだった。場所を変えて初めて、その下に隠れていたずれが見えた。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。