動かしてみて初めて見えた4つの罠 。lookup方式の実機初稼働で踏んだ落とし穴の連鎖
LOG153R2R2R2026/4/17

動かしてみて初めて見えた4つの罠 。lookup方式の実機初稼働で踏んだ落とし穴の連鎖

Pi側のnearest-neighbor lookup実装をr2r2r_loop.pyに統合し、カメラ→ホモグラフィ→lookup→補間→send_angles()の完全ループを初めて実機で走らせた。動き出すまでに4つの罠を順に踏んだ。禁止したはずのsend_coords()がClaude Codeの生成コードに復活していた。

座標系判定でカメラ視点を読み違えた。カメラのインデックスが過去から1つズレていた。最後にlookup tableのカバレッジ不足で、ブロック位置に動的追従できなかった。*それでもループは回った。*手でブロックをグリッパの位置に動かせば、PICK→PLACEまで完走した。残った課題はただ一つ、「lookupが動的に追従するか」だけになった。

💡 Key Takeaway
完璧を目指さず、まず動かしてループを回す。R2R2Rの設計思想は「精度より体験の成立を優先する。精度はあとからお金で解決できる」だが、これは「机上で詰めきれる」という意味ではなく「動かして見える問題を直す」という意味だった。事前検証で完璧なテーブルを作ろうとしても、実機を動かさないと見えない罠は必ずある。動かす→踏む→直す、を最小単位で回すことが、Sim-to-Realループの本体だ。

踏んだ罠

何が起きたか

判断

① send_coords()復活

Claude CodeがLOG152で禁止したsend_coords()を含むコードを書いてきた

CLAUDE.mdに禁止ルールを明記。Claude Code自身に恒久ルール化させた

② カメラ視点の読み違い

アーム俯瞰画像を「前傾している」と判断、実際は水平に伸びていた

スクショ3枚並べてもらって確定。判定を取り直し

③ カメラindexのズレ

detect_block_position.py時代のindex 1が今は真っ黒、Iriunはindex 2にいた

全indexの輝度を計測して特定

④ lookup tableのカバレッジ不足

検出も変換もlookupロジックも完璧、ただテーブルが今のブロック位置をカバーしてなかった

「動かしてみて初めて見える」を体現。テーブル拡張は明日

R2R2Rループの第1周回を走らせる準備が整っていた

—— 9点試し引きで7点FAIL、それでも本実装に進む判断。

シミュレーション補間アーキテクチャは一本通っていた。実機で記録した8姿勢(motion_log_block.csv)をMac側でEaseInOut補間して81点に拡張、同じCSVをUnityの20体バリエーション再生にもPi側のsend_angles()にも渡せる構造。Unity側はLOG152で動作確認済み、残るのはPi側のnearest-neighbor lookup実装だった。

LOG153は事前検証から始めた。fk_scan_motion_based.csv(134,456点)に対し、マット9点(4隅+4辺中点+中央)でnearest-neighbor検索を実機通信なしで走らせる。結果は厳しかった。

DIST_OK     : 1点(P4 LR corner = 0.12mm)
DIST_WARN   : 1点(P6 bottom mid = 16.97mm)
DIST_FAIL   : 7点(30〜273mm)

CSV内のj0範囲が[-65.0, -15.0]しかなく、マットの片側半分しかカバーされていない状態。最初は「テーブルを拡張してから本実装」と判断しかけた。が、エイイチの一言で方針が反転した。

「シミュレーションで取得したデータとリアルで取得したデータを補足しながら再度ギャップを埋めて動作を作る想定。そのためのシミュ・リアルループ」

つまり閉ループ運用が前提だった。完璧なテーブルを事前に作るのではなく、まず動かす、ズレを見る、ギャップを埋める、また動かす——R2R2Rの本来の意味どおりの設計だった。これを軍師側(Claude)が読み違えていた。「事前準備で完璧にしよう」モードに入っていた。プロジェクトの設計哲学「精度より体験の成立を優先する」を、机上で完璧を目指す方向に誤って解釈していた。

判断を切り替え、9点試し引きの7点FAILは「問題」ではなく「ループ第1周回のスタート地点」と位置づけ直した。本実装に進む。

罠①:禁止したはずのsend_coords()がコード生成で復活していた

—— Claude Codeに渡した最初のスクリプトに、LOG152で禁止確定したsend_coords()が含まれていた。

最初の検証で、Pi側に「j0をマイナス方向に動かしてカメラ画像のどっち側に動くか」を確認する小スクリプトをClaude Codeに作らせた。返ってきたコードを開いてみると、こうなっていた。

mc.send_coords([200, -100, 200, 0, 90, 0], 30)

明確に禁止したはずの関数。

send_coords() IK は RZ値が位置によって +167°〜−140°で暴れる。send_angles() + lookup table のみ使用。

Claude Codeが参照しているCLAUDE.mdに、この禁止ルールが書かれていなかった。「LOG152で確定した」と口頭で伝えても、コード生成のコンテキストには反映されない。プロジェクトの暗黙ルールは、ドキュメント化されていない限り再発する。

修正は2段階。スクリプト本体からsend_coords()を消してsend_angles()に置換する作業と、CLAUDE.md自体に「send_coords()使用禁止」を恒久ルールとして追記する作業。Claude Codeに両方やらせた。後者が本質で、これをやらないと同じミスがまた起きる。

修正前

修正後

「LOG152で確定済み」と口頭で伝達

CLAUDE.mdに明文化

個別スクリプトで違反検出→修正

プロジェクト全体の物理憲法に追加

違反するたびに指摘

コード生成時に違反コードが生成されない

軍師(Claude)の側にも問題があった。指示書を書く時に「物理憲法」として禁止事項を毎回コピペで含めるべきだった。それが抜けていた。

罠②:カメラ視点を読み違えて座標系判定を間違えかけた

—— アーム俯瞰画像を「前傾している」と判断、実際は水平に伸びていただけだった。

軸方向確認のテストで、j0を-30°に動かしてEEがカメラ画像のどっちに動くかを観察した。エイイチからスクショが届く。私(Claude)は俯瞰映像を見て、こう判断した。

アームが前方に倒れている姿勢。これは Step 1 のホーム姿勢として想定外。

エイイチが即座に訂正してくれた。

「明らかに前傾」じゃなくて、カメラは上からだから、アームは左に動いているよ。

カメラがマットの真上から下向きで撮影していることを、私は側面視点で見ていた。「前傾している」と見えたのは、実際は「アームが水平面でマットの方向に水平に伸びている」状態だった。ロボティクスの観察において、カメラ視点の取り違えは決定的なミスにつながる。座標系判定が完全に逆になり得る。

その後、エイイチが「右に動いて、真ん中に戻ってきたよ」と報告してきた。さっきの「左」と矛盾する。判定が真逆になる重大な情報なので、もう一度実行してもらってStep1・Step2・Step3の3枚スクショを並べて確定させた。結果、最初の「左」が正解だった。

私(Claude)が見ていたもの

実際

「アームが前傾している」(側面視点で解釈)

「真上から見たアームが水平に伸びている」

「右に動いた」を「右に移動」と解釈

「右側から元の位置(中央)に戻ってきた」の意味

カメラは真上、アームは水平。ロボティクスでは「視点」を最初に揃えないと、すべての判断が崩れる。

罠③:カメラのインデックスが過去から1つズレていた

—— detect_block_position.pyが動いていた頃のindex 1が、今は真っ黒画面を返していた。

lookup実装を完了して、いざ実機を起動する。検出が動かない。

[Mac] FK scan CSV読み込み中...
[Mac] ✓ 134456点ロード, ee_yフィルタ後: 77747点
[Mac] ✓ ホモグラフィ読み込み完了
[Mac] ✓ カメラ初期化完了(30フレーム読み捨て済み)

[Attempt 1/3] === lookup方式 ===
[Mac] × ブロックが検出できませんでした(10回リトライ失敗)

カメラには赤ブロックがハッキリ映っているのに、検出ゼロ。最初は赤検出ロジックを疑った。Claude Codeに過去の動作実績があるdetect_block_position.pyの実装を完全移植させ、HSV閾値も面積閾値も30フレーム読み捨ても全部揃えた。それでも同じ症状。

切り分けのため、検出失敗時にフレーム画像を保存させた。返ってきた画像はすべて真っ黒だった。1920×1080サイズは正しく取得しているのに、内容が黒。これは「カメラから何も読めていない」のサイン。

カメラインデックスを総当たりで確認する。

Camera 0: brightness=0.0   ← 真っ黒
Camera 1: brightness=0.0   ← 真っ黒  ← r2r2r_loop.pyが見ていたのはここ
Camera 2: brightness=115.2 ← 正常
Camera 3: brightness=117.2 ← 正常

detect_block_position.py時代に動いていたcv2.VideoCapture(1)が、今は別物を指していた。Mac環境(接続デバイス、Iriunのバージョン、macOSアップデート)が変わってインデックスが再割り当てされた結果。Iriun Webcamは現状index 2 か 3にいた。

修正は1行。cv2.VideoCapture(1) → cv2.VideoCapture(2)。これで検出が即座に成功した。

✓ ブロック検出: pixel=(388, 827) → robot=(124.4, -155.3)
Unity座標: (-0.1553, 0.1244)

過去のスクリプトに「動いていた」という事実は、未来の動作を保証しない。外部デバイスのインデックスは、コード本体とは独立に変動する。これも机上では絶対に見つからない罠だった。

罠④:lookup table のカバレッジ不足で動的追従ができなかった

—— 検出も変換もlookupロジックも完璧、ただテーブルが今のブロック位置をカバーしていなかった。

検出が成功して、いよいよlookup→アーム動作のフェーズへ。ターミナルが期待通り進んでいく。

✓ ブロック検出: pixel=(388, 827) → robot=(124.4, -155.3)
Unity座標: (-0.1553, 0.1244)
△ lookup失敗: dist=170.1mm > 100mm → fallback HOME
✗ lookup失敗 → 固定角度モードに fallback

距離閾値100mmを超えて、fallback(固定角度)に落ちる。これは設計通りの挙動だ。100mm超のlookup結果でアームを動かすと、想定と全然違う場所に降りる可能性があるから、fallbackで固定姿勢か(または)ホームに戻すように作ってあった。

ブロックを置き換えて再度試す。

✓ ブロック検出: pixel=(554, 862) → robot=(134.5, -105.2)
Unity座標: (-0.1052, 0.1345)
△ lookup失敗: dist=123.8mm > 100mm → fallback HOME
✗ lookup失敗 → 固定角度モードに fallback

これも同じ。lookupの最近傍距離が100mmを切らない。原因は事前検証で予測していた通りで、テーブルのj0範囲が[-65, -15]しかカバーしていないため、マット下半分(Y負側)の領域には届かない。9点試し引きで7点FAILだった結果が、実機でも忠実に再現されただけだった。

ここで重要な選択肢が見えた。lookupそのものが破綻しているわけではない。検出も座標変換も判定も全部動いている。問題は「テーブルが偏っているせいで、今のブロック位置に対して有効な姿勢が引けない」だけ。これはR2R2Rループの第2周回(テーブル拡張)で必ず解決する課題で、第1周回の到達点としては完成している。

それでもループは回った ── 手動アシストでPICK→PLACEまで完走

—— ブロックをグリッパの位置に手で動かしたら、最後まで動いた。

lookup失敗→固定角度fallback→APPROACH→PICK→グリッパ閉→APPROACH→PLACE→グリッパ開→ホーム、という設計したシーケンス全体が、固定角度モードで完走した。エイイチが「想定通りの動作」と確認。さらに途中でブロックを手でグリッパの位置に動かしてやったら、実際に物理的に掴んでPLACEまで運んだ。

これは大きな成果だ。整理するとこうなる。

検証項目

結果

カメラ取得(index 2)

✅ 1920×1080安定取得

赤ブロック検出(HSV)

✅ pixel座標出力

ホモグラフィ変換

✅ pixel→Robot座標

座標変換(Robot→Unity)

✅ unity_x, unity_z出力

nearest-neighbor lookup

✅ ロジック動作(カバレッジ外でfallback発火)

距離閾値100mm fallback

✅ 設計通り発火

固定角度モードへの自動切替

✅

APPROACH段階の経由

✅ HOME→APPROACH→PICK→APPROACH→PLACE

グリッパ物理動作

✅ ブロック把持・開放成功

send_coords()完全撤廃

✅ プロジェクト内0件

「lookupの動的追従」だけが未実証で、それ以外の全要素が実機で動いた。lookup自体は明日、ブロックをテーブルカバレッジ内(マット右上、PLACEの小鉢の左隣あたり)に置き換えれば、必ずdist < 100mmの成功ケースが見られる。

4つの罠に共通していたパターン

—— 全部「机上では見つからない」種類の問題だった。

LOG153で踏んだ4つの罠を並べると、共通点が浮かび上がる。

罠

性質

机上で予防できたか

① send_coords()復活

プロジェクトルールがコード生成側に伝わっていなかった

ドキュメント化していれば一部予防可能

② カメラ視点読み違い

観察者(私)が物理視点を間違えた

現場の写真を見ない限り予防不可能

③ カメラindexズレ

外部環境(OS、Iriun)の変動

完全に予防不可能

④ lookup tableカバレッジ不足

テーブルのj0範囲が偏っていた

事前検証で予測可能だが、実機で動かさないと現実の影響度がわからない

①は防げた。②③は実機を動かして観察しなければ気づきようがない。④は予測できていたが、実機で動かしたことで「lookupそのものは動く、テーブルだけが問題」という切り分けが完了した。

事前準備でテーブルを完璧にしようとしても、①②③は同じように出る。だから「事前準備に時間をかけて完璧を目指す」より、「最小実装で動かして罠を踏んでから直す」のほうが、結果的に速い。これがR2R2Rループの本来の使い方だ。エイイチが言っていた「シミュ・リアルループ」とは、この罠を踏みに行く設計のことだった。

lookupの動的追従、ただ1つ

—— マット右上にブロックを置けば、距離100mm以内で成功するはず。

LOG154は最小タスクから入る。

[1] 赤ブロックを画面右上(Robot座標 X -30〜-90、Y +100〜+180 付近)に置く
    ※ 9点試し引きでP4=LR corner robot(128.6, 182.9)が dist=0.12mmだった領域
[2] r2r2r_loop.py 実行
[3] lookup成功(dist < 100mm)を確認
[4] アームがlookup結果通りにブロック真上に降りるか観察
[5] 成功 → lookup方式の動的追従の実機実証完了
   失敗 → 原因切り分け(座標変換 / EE位置オフセット / etc.)

ここが通れば、いよいよR2R2Rループの第2周回(B-1起点追加によるテーブル拡張)に進む。マット左側の4箇所×3高さの実機ティーチングを行い、再スキャンで20万点規模のテーブルを生成。これでマット全域での動的追従が可能になる。

LOG153の到達点を一行で言えばこうなる。

「lookup方式の物理パイプラインは実機で完成した。残るは動的追従の実証だけ」。

実装コード

以下、LOG153で書いた・修正した全コードを記録として残す。未来の自分がLOG153を再現・参照できるよう、ファイル名・パス・全文を併記する。

~/r2r2r-scripts/r2r2r_loop.py

カメラ→ホモグラフィ→lookup→補間→SSH送信→Pi側send_angles()の全パイプライン。

#!/usr/bin/env python3
"""
R2R2R ループスクリプト(lookup対応版)
カメラ検出 → ホモグラフィ → lookup → 補間 → SSH送信 → Pi側send_angles()
"""

import sys
import os
import time
import csv
import json
import math
import re
import numpy as np
import cv2
import paramiko
from datetime import datetime


# ===== 設定 =====
LOG_FILE = "r2r2r_log.csv"
SSH_HOST = "192.168.2.1"
SSH_USER = "ubuntu"
SSH_KEY_PATH = os.path.expanduser("~/.ssh/id_rsa_pi")
REMOTE_SCRIPT = "/home/ubuntu/pick_and_place_fixed.py"
FK_CSV_PATH = os.path.expanduser("~/r2r2r-scripts/fk_scan_motion_based.csv")
HOMOGRAPHY_PATH = os.path.expanduser("~/r2r2r-scripts/homography.npy")

# 定数
HOME = [0, 0, 0, 0, 0, -30]
PICK_EE_Y = 0.027       # PICK高さ(m)
EE_Y_TOL = 0.020        # ee_yフィルタ閾値 ±20mm
DIST_THRESHOLD = 0.100   # 距離閾値 100mm(m単位)
INTERP_STEPS = 5
PLACE_ROI = (1571, 607, 249, 211)  # 変更禁止

# APPROACH_PICK ベース角度(J2〜J6は固定、J1だけlookup結果で置換)
APPROACH_PICK_BASE = [None, 81.91, -30.14, -8.08, -50.27, -29.44]

# 固定PLACE角度(PLACEはlookup tableカバレッジ外のためfallback)
APPROACH_PLACE = [59.85, 16.87, 27.94, 10.01, -45.17, -35.33]
PLACE_ANGLES = [54.49, 45.17, 26.63, 2.9, -70.57, -29.26]
PLACE_RELEASE = [59.85, 30.0, 27.94, 10.01, -45.17, -35.33]


# ===== lookup table =====
_fk_table = None
_fk_filtered = None


def load_fk_table():
    """FK scan CSVをメモリにロード(起動時に1回)"""
    global _fk_table, _fk_filtered
    print("[Mac] FK scan CSV読み込み中...")
    rows = []
    with open(FK_CSV_PATH, "r") as f:
        reader = csv.reader(f)
        next(reader)  # ヘッダスキップ
        for row in reader:
            ee_x = float(row[0])
            ee_y = float(row[1])
            ee_z = float(row[2])
            joints = [float(row[j + 3]) for j in range(6)]
            rows.append((ee_x, ee_y, ee_z, joints))
    _fk_table = rows

    # ee_yフィルタ(PICK高さ付近のみ)
    ee_y_min = PICK_EE_Y - EE_Y_TOL
    ee_y_max = PICK_EE_Y + EE_Y_TOL
    _fk_filtered = [(x, y, z, j) for x, y, z, j in _fk_table
                     if ee_y_min <= y <= ee_y_max]
    print(f"[Mac] ✓ {len(_fk_table)}点ロード, ee_yフィルタ後: {len(_fk_filtered)}点")


def robot_to_unity(robot_x, robot_y):
    """Robot XY (mm) → Unity EE座標 (m)"""
    unity_x = robot_y * 0.001
    unity_z = robot_x * 0.001
    return unity_x, unity_z


def lookup_nearest_angles(unity_x, unity_z):
    """
    (ee_x, ee_z)の2D距離でnearest-neighbor検索。
    距離 > DIST_THRESHOLD ならNoneを返す(fallback用)。
    """
    best = None
    best_dist = float("inf")
    for ee_x, ee_y, ee_z, joints in _fk_filtered:
        dist = math.sqrt((ee_x - unity_x) ** 2 + (ee_z - unity_z) ** 2)
        if dist < best_dist:
            best_dist = dist
            best = (ee_x, ee_y, ee_z, joints)

    if best is None or best_dist > DIST_THRESHOLD:
        print(f"[Mac] ⚠ lookup失敗: dist={best_dist*1000:.1f}mm > {DIST_THRESHOLD*1000:.0f}mm → fallback HOME")
        return None, best_dist

    ee_x, ee_y, ee_z, joints = best
    print(f"[Mac] ✓ lookup成功: ee=({ee_x:.4f}, {ee_y:.4f}, {ee_z:.4f}) dist={best_dist*1000:.1f}mm")
    print(f"[Mac]   angles={[round(j, 1) for j in joints]}")
    return joints, best_dist


def interpolate_angles(start, end, steps=5, easing=True):
    """補間角度リストを生成"""
    result = []
    for s in range(1, steps + 1):
        t = s / steps
        if easing:
            t = t * t * (3 - 2 * t)  # smoothstep
        angles = [round(a + (b - a) * t, 2) for a, b in zip(start, end)]
        result.append(angles)
    return result


# ===== カメラ(detect_block_position.py のロジックに完全一致) =====

DEBUG_FRAME_DIR = os.path.expanduser("~/r2r2r-scripts/logs/debug_frames")


def detect_red_block(frame, debug_log=False):
    """
    フレーム内の赤ブロックを検出して重心を計算。
    detect_block_position.py の detect_red_block() と同一ロジック。
    """
    hsv_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

    lower_red1 = np.array([0, 100, 100])
    upper_red1 = np.array([10, 255, 255])
    lower_red2 = np.array([160, 100, 100])
    upper_red2 = np.array([180, 255, 255])

    mask1 = cv2.inRange(hsv_frame, lower_red1, upper_red1)
    mask2 = cv2.inRange(hsv_frame, lower_red2, upper_red2)
    red_mask = cv2.bitwise_or(mask1, mask2)

    if debug_log:
        nonzero_count = cv2.countNonZero(red_mask)
        print(f"  赤マスク反応ピクセル数: {nonzero_count}")

    contours, _ = cv2.findContours(red_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        if debug_log:
            print(f"  輪郭なし")
        return None

    max_contour = max(contours, key=cv2.contourArea)
    contour_area = cv2.contourArea(max_contour)
    if debug_log:
        print(f"  最大輪郭面積: {contour_area:.0f}px (閾値: 500)")
    if contour_area < 500:
        return None

    M = cv2.moments(max_contour)
    if M["m00"] == 0:
        return None

    cx = int(M["m10"] / M["m00"])
    cy = int(M["m01"] / M["m00"])
    return cx, cy


def pixel_to_world(pixel_point, homography_matrix):
    """ピクセル座標を実世界座標(mm)に変換"""
    pt = np.array([[pixel_point]], dtype=np.float32)
    world = cv2.perspectiveTransform(pt, homography_matrix)
    return world[0][0]


def detect_block_position(cap, homography_matrix):
    """カメラで赤ブロックを検出してRobot座標を返す"""
    os.makedirs(DEBUG_FRAME_DIR, exist_ok=True)

    centroid = None
    frame = None
    for i in range(10):
        ret, frame = cap.read()
        if not ret:
            print(f"  [リトライ {i}] cap.read() 失敗")
            continue
        print(f"  [リトライ {i}] フレーム取得 {frame.shape[1]}x{frame.shape[0]}")
        centroid = detect_red_block(frame, debug_log=True)
        if centroid is not None:
            break
        # 失敗フレームを保存
        timestamp = int(time.time() * 1000)
        debug_path = os.path.join(DEBUG_FRAME_DIR, f"fail_{timestamp}_attempt{i}.jpg")
        cv2.imwrite(debug_path, frame)
        print(f"  デバッグフレーム保存: {debug_path}")
        time.sleep(0.1)

    if centroid is None or frame is None:
        print("[Mac] ✗ ブロックが検出できませんでした(10回リトライ失敗)")
        return None, None

    pixel_x, pixel_y = centroid
    robot_xy = pixel_to_world((pixel_x, pixel_y), homography_matrix)
    robot_x, robot_y = float(robot_xy[0]), float(robot_xy[1])
    print(f"[Mac] ✓ ブロック検出: pixel=({pixel_x}, {pixel_y}) → robot=({robot_x:.1f}, {robot_y:.1f})")

    # デバッグ表示(緑丸 + Robot座標テキスト、2秒)
    display_frame = frame.copy()
    cv2.circle(display_frame, (int(pixel_x), int(pixel_y)), 10, (0, 255, 0), 2)
    text = f"Robot: ({robot_x:.1f} mm, {robot_y:.1f} mm)"
    cv2.putText(display_frame, text, (int(pixel_x) + 15, int(pixel_y) - 10),
               cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
    cv2.imshow("Detected Block", display_frame)
    cv2.waitKey(2000)
    cv2.destroyAllWindows()

    return robot_x, robot_y


# ===== SSH通信 =====

def ssh_connect():
    """SSH接続を作成"""
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    client.connect(SSH_HOST, username=SSH_USER, key_filename=SSH_KEY_PATH, timeout=30)
    return client


def transfer_file_scp(local_path, remote_path):
    """scpでファイルを転送"""
    try:
        client = ssh_connect()
        sftp = client.open_sftp()
        sftp.put(local_path, remote_path)
        sftp.close()
        client.close()
        return True
    except Exception as e:
        print(f"  ✗ ファイル転送エラー: {e}")
        return False


def execute_lookup_commands(commands):
    """
    Pi側にコマンドリストをJSON送信して実行。
    pick_and_place_fixed.py --lookup モードで起動。
    """
    try:
        local_script = os.path.join(os.path.dirname(__file__), "pick_and_place_fixed.py")
        if not transfer_file_scp(local_script, REMOTE_SCRIPT):
            return False, "", "ファイル転送失敗"

        json_data = json.dumps(commands)
        client = ssh_connect()
        stdin, stdout, stderr = client.exec_command(f"python3 {REMOTE_SCRIPT} --lookup")
        stdin.write(json_data)
        stdin.channel.shutdown_write()

        exit_code = stdout.channel.recv_exit_status()
        output = stdout.read().decode("utf-8", errors="ignore")
        error = stderr.read().decode("utf-8", errors="ignore")
        client.close()

        return exit_code == 0, output, error

    except Exception as e:
        return False, "", str(e)


def get_current_angles_from_pi():
    """Pi側から現在の関節角度を取得"""
    commands = [{"action": "GET_ANGLES"}]
    success, output, error = execute_lookup_commands(commands)
    if success:
        match = re.search(r"CURRENT_ANGLES=(\[.*?\])", output)
        if match:
            return json.loads(match.group(1))
    return HOME  # フォールバック


# ===== メイン =====

def write_log(trial_number, result, fail_step, reason):
    """ログをCSVファイルに記録"""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    write_header = not os.path.exists(LOG_FILE)
    with open(LOG_FILE, "a", newline="") as f:
        writer = csv.writer(f)
        if write_header:
            writer.writerow(["timestamp", "trial_number", "result", "fail_step", "reason"])
        writer.writerow([timestamp, trial_number, result,
                         fail_step if fail_step else "", reason])
    print(f"  📝 ログ記録: trial={trial_number} result={result}")


def make_approach_angles(pick_angles):
    """lookup結果のJ1 + APPROACH_PICKのJ2〜J6でAPPROACH角度を生成"""
    return [
        pick_angles[0],          # J1: lookup結果(ベース回転を物体方向へ)
        APPROACH_PICK_BASE[1],   # J2: 81.91
        APPROACH_PICK_BASE[2],   # J3: -30.14
        APPROACH_PICK_BASE[3],   # J4: -8.08
        APPROACH_PICK_BASE[4],   # J5: -50.27
        APPROACH_PICK_BASE[5],   # J6: -29.44
    ]


def build_pick_commands(pick_angles, current_angles):
    """PICK動作のコマンドリストを生成(APPROACH段階あり)"""
    approach_angles = make_approach_angles(pick_angles)
    interp_to_approach = interpolate_angles(current_angles, approach_angles, steps=INTERP_STEPS, easing=True)
    interp_to_pick = interpolate_angles(approach_angles, pick_angles, steps=INTERP_STEPS, easing=True)
    interp_liftup = interpolate_angles(pick_angles, approach_angles, steps=INTERP_STEPS, easing=True)
    return [
        {"action": "HOME"},
        {"action": "GRIPPER_OPEN"},
        {"action": "INTERPOLATED_MOVE", "label": "TO_APPROACH_PICK",
         "interp_list": interp_to_approach},
        {"action": "INTERPOLATED_MOVE", "label": "TO_PICK",
         "interp_list": interp_to_pick},
        {"action": "GRIPPER_CLOSE"},
        {"action": "INTERPOLATED_MOVE", "label": "LIFTUP_TO_APPROACH",
         "interp_list": interp_liftup},
    ]


def build_place_commands(current_angles):
    """PLACE動作のコマンドリストを生成(固定角度)"""
    interp_to_approach = interpolate_angles(current_angles, APPROACH_PLACE, steps=INTERP_STEPS, easing=True)
    interp_to_place = interpolate_angles(APPROACH_PLACE, PLACE_ANGLES, steps=INTERP_STEPS, easing=True)
    return [
        {"action": "INTERPOLATED_MOVE", "label": "TO_APPROACH_PLACE",
         "interp_list": interp_to_approach},
        {"action": "INTERPOLATED_MOVE", "label": "TO_PLACE",
         "interp_list": interp_to_place},
        {"action": "MOVE", "label": "PLACE_RELEASE", "angles": PLACE_RELEASE},
        {"action": "GRIPPER_OPEN"},
        {"action": "MOVE", "label": "APPROACH_PLACE_RETURN", "angles": APPROACH_PLACE},
        {"action": "HOME"},
    ]


def run_lookup_attempt(attempt, cap, homography_matrix):
    """lookup方式で1回のPICK&PLACEを実行"""
    print(f"\n[Attempt {attempt}/3] === lookup方式 ===")

    # 1. カメラで赤ブロック検出
    robot_x, robot_y = detect_block_position(cap, homography_matrix)
    if robot_x is None:
        return False

    # 2. Robot → Unity変換
    unity_x, unity_z = robot_to_unity(robot_x, robot_y)
    print(f"[Mac] Unity座標: ({unity_x:.4f}, {unity_z:.4f})")

    # 3. nearest-neighbor lookup
    pick_angles, dist = lookup_nearest_angles(unity_x, unity_z)
    if pick_angles is None:
        print(f"[Mac] ✗ lookup失敗 → 固定角度モードにfallback")
        return run_fixed_attempt(attempt)

    # 4. 現在の角度を取得
    current_angles = get_current_angles_from_pi()
    print(f"[Mac] 現在角度: {current_angles}")

    # 5. PICK + PLACEコマンドを構築
    pick_cmds = build_pick_commands(pick_angles, current_angles)
    approach_after_pick = make_approach_angles(pick_angles)
    place_cmds = build_place_commands(approach_after_pick)
    all_commands = pick_cmds + place_cmds

    # 6. SSH送信・実行
    print(f"[Mac] コマンド送信中... ({len(all_commands)}ステップ)")
    success, output, error = execute_lookup_commands(all_commands)

    if output:
        print(f"[Pi出力]:\n{output}")
    if error:
        print(f"[Piエラー]:\n{error}")

    return success


def run_fixed_attempt(attempt):
    """固定角度モードのfallback"""
    print(f"[Attempt {attempt}/3] 固定角度モードで実行...")
    try:
        local_script = os.path.join(os.path.dirname(__file__), "pick_and_place_fixed.py")
        if not transfer_file_scp(local_script, REMOTE_SCRIPT):
            return False

        client = ssh_connect()
        stdin, stdout, stderr = client.exec_command(f"python3 {REMOTE_SCRIPT}")
        exit_code = stdout.channel.recv_exit_status()
        output = stdout.read().decode("utf-8", errors="ignore")
        error = stderr.read().decode("utf-8", errors="ignore")
        client.close()

        if output:
            print(f"[Pi出力]:\n{output}")
        if error:
            print(f"[Piエラー]:\n{error}")

        return exit_code == 0

    except Exception as e:
        print(f"  ✗ SSH実行エラー: {e}")
        return False


def main():
    """メイン処理"""
    print("=== R2R2R Loop Start (lookup版) ===")

    # FK table ロード(起動時1回)
    load_fk_table()

    # ホモグラフィ読み込み(起動時1回)
    homography_matrix = np.load(HOMOGRAPHY_PATH)
    print(f"[Mac] ✓ ホモグラフィ読み込み完了")

    # カメラ初期化(起動時1回、30フレーム読み捨て)
    cap = cv2.VideoCapture(2)
    if not cap.isOpened():
        print("[Mac] ✗ カメラが見つかりません (index 1)")
        sys.exit(1)
    for _ in range(30):
        cap.read()
    print("[Mac] ✓ カメラ初期化完了(30フレーム読み捨て済み)")

    # 起動時サンプルフレーム保存
    os.makedirs(DEBUG_FRAME_DIR, exist_ok=True)
    ret, first_frame = cap.read()
    if ret:
        h, w, c = first_frame.shape
        print(f"[Mac] カメラフレーム取得: {w}x{h}, channels={c}")
        startup_path = os.path.join(DEBUG_FRAME_DIR, "startup.jpg")
        cv2.imwrite(startup_path, first_frame)
        print(f"[Mac] 起動時フレーム保存: {startup_path}")
    else:
        print("[Mac] ⚠ 起動時フレーム取得失敗")

    try:
        for attempt in range(1, 4):
            print(f"\n[Attempt {attempt}/3] ブロックをPICK位置に置いてください")

            # lookup方式で実行
            ssh_success = run_lookup_attempt(attempt, cap, homography_matrix)

            # 10秒待機
            print(f"[Attempt {attempt}/3] Waiting 10s for completion...")
            time.sleep(10)

            # カメラ判定(VLM無効化 — 赤検出のみで判定)
            print(f"[Attempt {attempt}/3] Checking camera...")
            ret, frame = cap.read()
            if not ret:
                print(f"[Attempt {attempt}/3] Failed to read camera")
                continue

            # PLACE ROI内で赤ブロックを検出
            rx, ry, rw, rh = PLACE_ROI
            roi_frame = frame[ry:ry+rh, rx:rx+rw]
            centroid = detect_red_block(roi_frame)
            is_detected = centroid is not None
            reason = f"ROI内赤ブロック検出={'あり' if is_detected else 'なし'}"

            if is_detected:
                print(f"[Attempt {attempt}/3] {reason} → SUCCESS ✓")
                write_log(attempt, "SUCCESS", None, reason)
                print("=== Loop Complete: SUCCESS ===")
                return
            else:
                print(f"[Attempt {attempt}/3] {reason} → FAIL ✗")
                fail_step_input = input("  失敗したステップを入力 (pick / place / none → Enter): ").strip()
                fail_step = fail_step_input if fail_step_input in ["pick", "place"] else None
                write_log(attempt, "FAIL", fail_step, reason)

        print("=== Loop Complete: FAILED ===")

    finally:
        cap.release()
        cv2.destroyAllWindows()


if __name__ == "__main__":
    main()

~/r2r2r-scripts/test_lookup_9points.py

本実装の前に走らせた、9点試し引き検証。実機通信なし、CSV検索のみで134,456点に対するnearest-neighborの妥当性を確認。

#!/usr/bin/env python3
"""
nearest-neighbor lookup 9点試し引き検証スクリプト
fk_scan_motion_based.csv から9点を検索し、距離・関節角度の妥当性を検証する。
実機通信なし、CSV検索のみ。
"""

import csv
import os
import math
from datetime import datetime

CSV_PATH = os.path.expanduser("~/r2r2r-scripts/fk_scan_motion_based.csv")
LOG_DIR = os.path.expanduser("~/r2r2r-scripts/logs")

# mechArm 270-Pi 可動域(公式スペック)
JOINT_LIMITS = [
    (-165.0, 165.0),  # j0
    (-165.0, 165.0),  # j1
    (-165.0, 165.0),  # j2
    (-165.0, 165.0),  # j3
    (-165.0, 165.0),  # j4
    (-175.0, 175.0),  # j5
]
ANGLE_WARN_MARGIN = 5.0  # 可動域端から±5°以内でWARN

# マット4隅(確定済み・変更禁止)
UL = (-89.0, -202.6)
UR = (-112.5, 204.6)
LL = (138.1, -172.9)
LR = (128.6, 182.9)

# 9点生成
def midpoint(*points):
    n = len(points)
    return (sum(p[0] for p in points) / n, sum(p[1] for p in points) / n)

POINTS = [
    ("P1 (UL corner)", UL),
    ("P2 (UR corner)", UR),
    ("P3 (LL corner)", LL),
    ("P4 (LR corner)", LR),
    ("P5 (top mid)", midpoint(UL, UR)),
    ("P6 (bottom mid)", midpoint(LL, LR)),
    ("P7 (left mid)", midpoint(UL, LL)),
    ("P8 (right mid)", midpoint(UR, LR)),
    ("P9 (center)", midpoint(UL, UR, LL, LR)),
]


def robot_to_unity(robot_x, robot_y):
    """Robot XY → Unity EE座標変換(確定済み)"""
    unity_x = robot_y * 0.001
    unity_z = robot_x * 0.001
    return unity_x, unity_z


def load_csv(path):
    """CSVを読み込んでリストで返す"""
    rows = []
    with open(path, "r") as f:
        reader = csv.reader(f)
        next(reader)  # ヘッダスキップ
        for row in reader:
            ee_x = float(row[0])
            ee_y = float(row[1])
            ee_z = float(row[2])
            joints = [float(row[j + 3]) for j in range(6)]
            rows.append((ee_x, ee_y, ee_z, joints))
    return rows


def nearest_neighbor(rows, query_x, query_z):
    """(ee_x, ee_z)の2次元ユークリッド距離で最近傍を検索"""
    best = None
    best_dist = float("inf")
    for ee_x, ee_y, ee_z, joints in rows:
        dist = math.sqrt((ee_x - query_x) ** 2 + (ee_z - query_z) ** 2)
        if dist < best_dist:
            best_dist = dist
            best = (ee_x, ee_y, ee_z, joints)
    return best, best_dist


def check_dist(dist_mm):
    if dist_mm <= 10:
        return "DIST_OK"
    elif dist_mm <= 20:
        return "DIST_WARN"
    else:
        return "DIST_FAIL"


def check_angles(joints):
    for j, angle in enumerate(joints):
        lo, hi = JOINT_LIMITS[j]
        if angle <= lo + ANGLE_WARN_MARGIN or angle >= hi - ANGLE_WARN_MARGIN:
            return "ANGLE_WARN"
    return "ANGLE_OK"


def main():
    print("=== nearest-neighbor lookup 9点検証 ===")
    print(f"CSV: {CSV_PATH}")

    rows = load_csv(CSV_PATH)
    print(f"読み込み完了: {len(rows)}点\n")

    timestamp = datetime.now().strftime("%Y%m%d_%H%M")
    log_path = os.path.join(LOG_DIR, f"lookup_test_{timestamp}.txt")

    lines = []
    dist_flags = {"DIST_OK": 0, "DIST_WARN": 0, "DIST_FAIL": 0}
    angle_flags = {"ANGLE_OK": 0, "ANGLE_WARN": 0}
    ee_ys = []

    for name, (robot_x, robot_y) in POINTS:
        unity_x, unity_z = robot_to_unity(robot_x, robot_y)
        match, dist = nearest_neighbor(rows, unity_x, unity_z)
        ee_x, ee_y, ee_z, joints = match
        dist_mm = dist * 1000  # m → mm

        df = check_dist(dist_mm)
        af = check_angles(joints)
        dist_flags[df] += 1
        angle_flags[af] += 1
        ee_ys.append(ee_y)

        block = []
        block.append(f"=== Point {name} ===")
        block.append(f"Query  : robot({robot_x}, {robot_y}) → unity({unity_x:.4f}, {unity_z:.4f})")
        block.append(f"Match  : ee=({ee_x:.4f}, {ee_y:.4f}, {ee_z:.4f})")
        block.append(f"         j0={joints[0]:.1f}, j1={joints[1]:.1f}, j2={joints[2]:.1f}, j3={joints[3]:.1f}, j4={joints[4]:.1f}, j5={joints[5]:.1f}")
        block.append(f"Dist   : {dist_mm:.2f} mm")
        block.append(f"ee_y   : {ee_y:.4f}")
        block.append(f"Flags  : {df}, {af}")

        # ANGLE_WARNの詳細
        if af == "ANGLE_WARN":
            for j, angle in enumerate(joints):
                lo, hi = JOINT_LIMITS[j]
                if angle <= lo + ANGLE_WARN_MARGIN:
                    block.append(f"  ⚠ j{j}={angle:.1f}° (下限{lo:.0f}°まで{angle - lo:.1f}°)")
                elif angle >= hi - ANGLE_WARN_MARGIN:
                    block.append(f"  ⚠ j{j}={angle:.1f}° (上限{hi:.0f}°まで{hi - angle:.1f}°)")

        block.append("")
        text = "\n".join(block)
        print(text)
        lines.append(text)

    # サマリ
    ee_y_min = min(ee_ys)
    ee_y_max = max(ee_ys)
    ee_y_mean = sum(ee_ys) / len(ee_ys)
    ee_y_stddev = math.sqrt(sum((y - ee_y_mean) ** 2 for y in ee_ys) / len(ee_ys))

    summary = []
    summary.append("=== Summary ===")
    summary.append(f"Total points : 9")
    summary.append(f"DIST_OK      : {dist_flags['DIST_OK']}")
    summary.append(f"DIST_WARN    : {dist_flags['DIST_WARN']}")
    summary.append(f"DIST_FAIL    : {dist_flags['DIST_FAIL']}")
    summary.append(f"ANGLE_OK     : {angle_flags['ANGLE_OK']}")
    summary.append(f"ANGLE_WARN   : {angle_flags['ANGLE_WARN']}")
    summary.append(f"ee_y range   : [{ee_y_min:.4f}, {ee_y_max:.4f}]")
    summary.append(f"ee_y stddev  : {ee_y_stddev:.4f}")
    summary.append("")

    summary_text = "\n".join(summary)
    print(summary_text)
    lines.append(summary_text)

    # ログ書き出し
    with open(log_path, "w") as f:
        f.write("\n".join(lines))
    print(f"ログ保存: {log_path}")


if __name__ == "__main__":
    main()

verify_y_direction.py

罠①の修正後の最終版。最初Claude Codeがsend_coords()を含むコードを書いてきたものを、send_angles()一本に書き直した。

#!/usr/bin/env python3
"""
Y方向確認スクリプト(Pi側)— send_angles版
ホーム → j0 負側 → ホーム の動作で、カメラ映像上の方向を確認する。
"""

from pymycobot.mycobot import MyCobot
import time

mc = MyCobot('/dev/ttyAMA0', 1000000)
time.sleep(3)
print("接続完了")

# Step 1: ホームへ
print("\n[Step 1] ホーム位置へ移動...")
mc.send_angles([0, 0, 0, 0, 0, -30], 20)
time.sleep(5)
print(f"  現在角度: {mc.get_angles()}")
input("  → カメラでホーム位置を確認してください。Enterで次へ...")

# Step 2: j0 を負側に -30° 振る(他の関節は維持)
print("\n[Step 2] j0 を -30° に動かす...")
mc.send_angles([-30, 0, 0, 0, 0, -30], 20)
time.sleep(5)
print(f"  現在角度: {mc.get_angles()}")
input("  → カメラでEEがどっち側に動いたか確認してください。Enterで次へ...")

# Step 3: ホームに戻す
print("\n[Step 3] ホームに戻す...")
mc.send_angles([0, 0, 0, 0, 0, -30], 20)
time.sleep(5)
print(f"  現在角度: {mc.get_angles()}")

print("\n=== 確認完了 ===")
print("画像「左」に動いた → 座標系OK(画像左 = Robot Y負側、j0 負 = Y 負側)")
print("画像「右」に動いた → 座標系の解釈を再検討")

~/r2r2r-scripts/CLAUDE.md

罠①を踏まえて、CLAUDE.md の末尾に「send_coords()使用禁止」を恒久ルール化した。今後Claude Codeに何を書かせても、このルールが効く。

## 物理憲法(追加ルール)

### send_coords() 使用禁止(DAY152で確定)
- mc.send_coords() の IK は RZ値が位置によって +167°〜−140° で暴れる
- ワークスペース全域で IK が不安定なため使用不可
- すべての動作は mc.send_angles() + lookup table 経由で実装する
- 例外なし。最適化や一時的な実験目的でも使用禁止
- 違反した場合、エイイチが即座に指摘・修正を要求

カメラindex特定の検証スニペット(罠③の決定打)

罠③で「カメラindex 1が真っ黒」を発見した時の検証コード。総当たりで全indexの輝度を測定し、Iriun Webcamの実際のindex(=2)を特定した。

import cv2
for i in range(5):
    cap = cv2.VideoCapture(i)
    if cap.isOpened():
        for _ in range(10):
            cap.read()
        ret, frame = cap.read()
        if ret:
            print(f'Camera {i}: OK, shape={frame.shape}, mean brightness={frame.mean():.1f}')
        else:
            print(f'Camera {i}: opened but no frame')
        cap.release()
    else:
        print(f'Camera {i}: not available')

実行結果:

Camera 0: OK, shape=(1080, 1920, 3), mean brightness=0.0
Camera 1: OK, shape=(1080, 1920, 3), mean brightness=0.0
Camera 2: OK, shape=(1080, 1920, 3), mean brightness=115.2
Camera 3: OK, shape=(1080, 1920, 3), mean brightness=117.2

これで「Iriunはindex 2か3にいる」と特定。各indexからフレーム画像を保存して目視確認し、index 2を採用。

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