ロボットアームのテレメトリダッシュボードを作る Log#2 | VLM の判定理由と、赤ブロック・白箱の座標を画面と台帳に残す
LOG241R2R2R2026/6/18

ロボットアームのテレメトリダッシュボードを作る Log#2 | VLM の判定理由と、赤ブロック・白箱の座標を画面と台帳に残す

前回(Log#1)で、制御ループの状態をターミナルからブラウザへ逃がす観測層を作った。フェーズも経過秒もグリッパーも画面で見えるようになった。だが、いちばん欲しかった情報はまだターミナルにしか無かった。VLM が pick のたびに出している判定理由——「掴めていない」「ブロックと爪のマーカーを見間違えている」——が、流れて消えるログの中に埋もれていた。

この回でそれを画面に出す。あわせて、アームが何に対して動いているのか(赤ブロックと白箱の位置)を座標で出し、1試行ごとの結果を CSV 台帳に残す。観測(流れる状態をリアルタイムで観る)から、記録(試行の結果を確定して残す)へ、層を1つ足す回になる。

verdict を画面に出す

前回 emit のスキーマには verdict の席を予約してあった。表示側の index.html にも、verdict が来たらドリルインに出すコードは用意済みだった。つまり要るのは、制御ループ側で VLM の判定結果を verdict に詰めて emit に渡す、たった1点の配線だけだった。

ここで形の食い違いが1つあった。判定器 _judge_image() が返すのは {"verdict": "yes"|"no"|"unknown", "confidence": float, "reason": str} で、表示側が期待するのは {success, reward, reason}。これを橋渡しするヘルパを足す。yes/no/unknown を success ∈ {True, False, None} に潰しつつ、潰す前の生の判定(raw_verdict)も併記する。unknown を null に丸めると情報が落ちるためで、画面では生の判定を主表示にする。reward は判定器が算出していないので None 固定(VLM-as-Reward を実装したときに埋める空席)。

橋渡しのヘルパを組んだ。

def _observe_verdict(raw_verdict, confidence, reason):
    """run() から VLM 判定結果を記録する。
    raw_verdict ∈ {'yes','no','unknown',...} を success ∈ {True,False,None} にマップ.
    reward は判定器側で未算出のため None 固定(VLM-as-Reward 実装時に充填).
    """
    if raw_verdict == "yes":
        success = True
    elif raw_verdict == "no":
        success = False
    else:
        success = None
    _OBSERVE_STATE["verdict"] = {
        "success": success,
        "reward": None,
        "reason": reason,
        "confidence": confidence,
        "raw_verdict": raw_verdict,
    }

VLM 判定は「移動を伴わないステージ」で出る。一方 emit は _do_move(移動後の現在角取得直後)の1サイトに集約してある。判定の瞬間には emit が走らない。そこで verdict は状態として保持し、判定後の最初の移動の datagram に相乗りさせる設計にした。この「最後の判定を保持する」仕様により、pick の no 判定は掴み直しの approach 移動に乗り、その approach で「なぜ掴み直しているか」が画面に出る。次の判定、place 判定、または run の冒頭初期化で必ず上書きされるので、無関係なフェーズに古い判定が残ることはない。

判定が確定する3か所で _observe_verdict を呼ぶ。pick 判定の直後、place 判定の直後、そして place 判定の手前で退避動作が失敗して判定不能になった経路。最後の経路を入れないと、place に進めなかったときに古い pick の判定が次の移動に残ってしまう。

            # pick 判定直後
            _observe_verdict(adopted, pj["confidence"], pj["reason"])
            # place 判定直後
            _observe_verdict(place_adopted, place_vlm["confidence"], place_vlm["reason"])
            # stage9b 退避失敗で place 判定不能のとき
            _observe_verdict("unknown", 0.0, "swept_retreat_failed")

emit のサイトは増やさず、既存の1サイトに引数を1つ足すだけにした。

    _observe_emit(
        angles=post_actual,
        phase=_OBSERVE_STATE["phase"],
        phase_t0_ms=_OBSERVE_STATE["phase_t0_ms"],
        gripper=_OBSERVE_STATE["gripper"],
        trial=({"id": _OBSERVE_STATE["run_stem"],
                "task": _OBSERVE_STATE["task"]}
               if _OBSERVE_STATE["run_stem"] else None),
        verdict=_OBSERVE_STATE["verdict"],
        block_xz=_OBSERVE_STATE["block_xz"],
        box_xz=_OBSERVE_STATE["box_xz"],
        block_place_xz=_OBSERVE_STATE["block_place_xz"],
    )

この変更は制御コードに触れるため、実機の前に外部レビュー(codex)を通した。判定は「制御非干渉として実機テストへ進んでよい」。emit が fire-and-forget を維持し、判定経路以外で _observe_verdict が呼ばれず、既存の print・分岐・ログ出力が一切変わっていないことを確認している。

赤ブロックと白箱の座標を乗せる

画面にアームの状態と判定理由が出ても、「何に対して動いているか」が無ければ判断が片側だけになる。赤ブロックがどこにあって、白箱がどこにあって、アームがそのどちらに向かっているのか。これを座標で出す。

赤ブロックと白箱の検出値はもともと stage2 で得ていた(block_xz / box_xz、メートル・Unity 系)。これを emit に相乗りさせる。問題は、block_xz は pick 時点の検出(掴む前にブロックがどこにあったか)だけだということ。「箱にブロックが入ったか」を座標で裏取りするには、place した後にブロックが実際どこに収まったかが要る。

そこで、place 判定のために stage9b で撮っている上カメラ画像から、もう一度赤ブロックを検出する。新規撮影は足さず、既存の判定画像を使い回す。検出は stage2 と同じ detect_red_block + pixel_to_unity(同じ homography)を流用する。

def _detect_block_in_place_img(image_path):
    """place_img(保存済み jpg)から赤ブロックの Unity 座標 [x, z] を検出する.
    stage2 と同じ detect_red_block + pixel_to_unity を使う(同 homography).
    全例外を握り、失敗時 None を返す(制御を絶対止めない).
    """
    if image_path is None:
        return None
    try:
        frame = cv2.imread(str(image_path))
        if frame is None:
            return None
        pixel = detect_red_block(frame)
        if pixel is None:
            return None
        cx, cy = pixel
        ux, uz = pixel_to_unity(cx, cy)
        return [float(ux), float(uz)]
    except Exception:
        return None

place 後にブロックが箱に入って上から見えにくい、あるいは枠外に飛んでいる、ということは正常に起こる。だから検出失敗で None を返しても制御は止めない。むしろ「place 後に検出できない」こと自体が、箱に入った可能性を示す情報にもなる。

白箱の位置は stage2 の検出値を保持して使う。place の間、アームは箱に触れず、ブロックを上から落とすだけなので箱は動かない。place 後に箱を再検出すると、入ったブロックで箱が一部隠れて検出が不安定になるため、安定している pick 時点の box 値を採る。

掴み直しで stage2 を再検出したときは、観測状態の block_xz も最新値へ同期する。これを忘れると、掴み直し中はアームが新しく検出した位置へ向かっているのに、画面のブロックは初回位置に貼り付いたままになる。

            cur_block_xz = new_block_xz
            _OBSERVE_STATE["block_xz"] = [new_block_xz[0], new_block_xz[1]]

block_place_xz を出す配線も含め、制御コードに触れる分は外部レビュー(codex)を通した。検出値を座標変換なしでそのまま代入していること、検出失敗・画像欠損で制御が止まらないこと、box が place 中不動の前提で pick 検出値を保持していることを確認している。

アームの形を描いて、消した

座標を出すなら、アームの姿勢も形で見えたほうが分かりやすいのではないか。そう考えて、関節角からアームの骨格を上面・側面の2ビューで描く実装を一度作った。残しておく価値のある回り道なので、過程ごと記録する。

最初は近似のリンク長で描いたが、mechArm 270-Pi の URDF を読み込んで実測のリンク長で組み直した。URDF のジョイント origin から運動学チェーンを取り、各関節を Trans·RPY·RotZ(angle) で連鎖させる。検算では PICK 姿勢で水平リーチ 256mm(公称作業半径 270mm にほぼ一致)、高さ 115mm まで下がる、と物理的に妥当な値が出た。

問題は、アームの骨格(URDF native・Z 上)と、ブロック・白箱の座標(Unity・Y 上)が別フレームだったこと。両者を同じマット平面に重ねるには、native から Unity への回転を確定する必要がある。これを推測で当てると外す。そこで j0_sweep の実測ログ(67 ファイル・255 ペアの「関節角 ⇄ Unity 先端」対応)を最小二乗で回帰し、回転オフセットを求めた。結果は 83.057 度。単点の手計算で出した候補(82.6 度)と 0.5 度以内で一致し、回転は正しいと確認できた。pick 系フェーズでの残差は 12〜14mm に収まった。

ここまで作って、削除した。理由は1つで、この図がダッシュボードの判断を変えなかったから。テレメトリ画面の規律は「判断を変えるものだけ見せる」で、アームの形は「生きている実感」にはなっても、止めるか・直近の変更が効いたか・同じ失敗の反復か、という判断には効いていなかった。逆に、判断に効くのは赤ブロックと白箱の座標そのものだった。だから2ビューの枠ごと消し、座標を数値で出す表示に差し替えた。正確な姿勢の数値根拠が要るときは、ドリルインの関節角と EE 数値が担う。

作ったものを残すかどうかは、手間では決めない。判断を変えるかどうかで決める。255 ペアの回帰まで通したビューでも、運用の判断を何も変えないなら載せる意味がない。削った。

一試行を台帳に刻む

画面はリアルタイムの状態を映すが、流れて消える。同じ失敗の反復を炙り出すには、試行を確定値として残す層が要る。JSON ログとは別に、CSV の台帳を1枚足す。

1試行=1行。列には、pick と place の VLM 判定(verdict・理由・確信度)、赤ブロックの pick 位置と place 位置、白箱の位置、両者の幾何距離と「箱に入ったか」の判定、終了時の関節角、そして place 判定画像のフルパスを置く。判定を3通り(VLM の verdict・座標の幾何距離・写真のパス)で記録するのが要点で、後から「VLM は yes だが座標は外れていた」のような食い違いを拾えるようにする。CSV だけで集計でき、疑わしい行は写真で目視照合できる。

列の定義はこうなる。

LEDGER_COLS = [
    "timestamp", "run_stem", "trial_no",
    "pick_verdict", "pick_reason", "pick_confidence",
    "place_verdict", "place_reason", "place_confidence",
    "block_pick_x", "block_pick_z",
    "block_place_x", "block_place_z",
    "box_x", "box_z",
    "in_box_geom", "in_box_dist_mm",
    "final_j1", "final_j2", "final_j3",
    "final_j4", "final_j5", "final_j6",
    "gripper", "place_capture_path",
]

台帳は KIOXIA SSD に置く。git 管理外の測定データのホームがそこだからで、ローカルディスクへフォールバック書き込みはしない。SSD が未マウントなら書かずに報告し、制御は通常どおり完了させる。マウント確認のガードを噛ませた。

def _ensure_ssd_mounted():
    """KIOXIA SSD の LEDGER_DIR が存在・書込可なら True、それ以外 False.
    fire-and-forget: 例外を握り False を返す."""
    try:
        if not LEDGER_DIR.exists():
            return False
        if not LEDGER_DIR.is_dir():
            return False
        test = LEDGER_DIR / ".write_test"
        try:
            test.write_text("")
            test.unlink()
            return True
        except Exception:
            return False
    except Exception:
        return False

「箱に入ったか」の幾何判定は、place 後のブロック位置と白箱中心の距離が閾値内かで決める。閾値は白箱半幅 40mm とブロック半幅 12mm から 52mm を目安に置き、1定数で調整できるようにした。

def _ledger_in_box_geometry(block_place_xz, box_xz):
    """block_place と box 中心の距離 mm + IN_BOX_TOL_MM 比較.
    戻り値: (in_box_geom_str, dist_mm_str). どちらか欠落で ('','') を返す."""
    if not (isinstance(block_place_xz, (list, tuple)) and len(block_place_xz) == 2
            and isinstance(box_xz, (list, tuple)) and len(box_xz) == 2):
        return "", ""
    vals = list(block_place_xz) + list(box_xz)
    if not all(isinstance(v, (int, float)) for v in vals):
        return "", ""
    dx_mm = (block_place_xz[0] - box_xz[0]) * 1000.0
    dz_mm = (block_place_xz[1] - box_xz[1]) * 1000.0
    dist = math.hypot(dx_mm, dz_mm)
    return str(dist < IN_BOX_TOL_MM), "{:.1f}".format(dist)

試行終了時に1行を追記する本体。判定や座標は log_record と観測状態の最終値から引き、終了時の関節角は最後の move の post_actual から取る。書き込みは観測と同じ fire-and-forget で、例外が出ても制御は継続する。既存の JSON ログ出力はそのまま残し、CSV は別物として足す。

def _append_ledger(log_record, run_stem):
    """試行 1 行を KIOXIA SSD の CSV 台帳へ追記.
    fire-and-forget: 例外で制御を絶対止めない. ローカルへのフォールバック書き込みはしない.
    既存ヘッダ無ければ新規作成、あれば append.
    """
    try:
        if not _ensure_ssd_mounted():
            print("[LEDGER] KIOXIA SSD ({}) 未マウント or 書込不可 → CSV スキップ"
                  .format(LEDGER_DIR))
            return

        judgments = log_record.get("judgments") or {}
        pick = judgments.get("pick") or {}
        place = judgments.get("place") or {}

        # block/box は _OBSERVE_STATE の最終値 (regrasp 反映済み・stage9b 検出反映済み)
        block_xz       = _OBSERVE_STATE.get("block_xz")
        box_xz         = _OBSERVE_STATE.get("box_xz")
        block_place_xz = _OBSERVE_STATE.get("block_place_xz")
        gripper        = _OBSERVE_STATE.get("gripper")

        # 最後の move の post_actual = 試行終了時の関節角
        final_angles = ["", "", "", "", "", ""]
        for m in reversed(log_record.get("moves", []) or []):
            pa = m.get("post_actual")
            if isinstance(pa, (list, tuple)) and len(pa) == 6:
                for i in range(6):
                    v = pa[i]
                    final_angles[i] = ("{:.2f}".format(v)
                                       if isinstance(v, (int, float)) else "")
                break

        # place 撮影画像のフルパス
        place_capture_path = ""
        for c in reversed(log_record.get("vlm_captures", []) or []):
            if c.get("label") == "place" and c.get("image"):
                place_capture_path = str(c["image"])
                break

        in_box_geom, in_box_dist_mm = _ledger_in_box_geometry(
            block_place_xz, box_xz)

        row = {
            "timestamp":         log_record.get("ended_at") or datetime.now().isoformat(),
            "run_stem":          run_stem,
            "trial_no":          _ledger_extract_trial_no(run_stem),
            "pick_verdict":      (pick.get("verdict") or ""),
            "pick_reason":       (pick.get("reason") or ""),
            "pick_confidence":   (pick.get("confidence")
                                  if isinstance(pick.get("confidence"), (int, float))
                                  else ""),
            "place_verdict":     (place.get("verdict") or ""),
            "place_reason":      (place.get("reason") or ""),
            "place_confidence":  (place.get("confidence")
                                  if isinstance(place.get("confidence"), (int, float))
                                  else ""),
            "block_pick_x":      _ledger_mm_or_blank(block_xz, 0),
            "block_pick_z":      _ledger_mm_or_blank(block_xz, 1),
            "block_place_x":     _ledger_mm_or_blank(block_place_xz, 0),
            "block_place_z":     _ledger_mm_or_blank(block_place_xz, 1),
            "box_x":             _ledger_mm_or_blank(box_xz, 0),
            "box_z":             _ledger_mm_or_blank(box_xz, 1),
            "in_box_geom":       in_box_geom,
            "in_box_dist_mm":    in_box_dist_mm,
            "final_j1":          final_angles[0],
            "final_j2":          final_angles[1],
            "final_j3":          final_angles[2],
            "final_j4":          final_angles[3],
            "final_j5":          final_angles[4],
            "final_j6":          final_angles[5],
            "gripper":           ("" if gripper is None else str(gripper)),
            "place_capture_path": place_capture_path,
        }

        is_new = not LEDGER_CSV_PATH.exists()
        with open(LEDGER_CSV_PATH, "a", newline="") as f:
            w = csv.DictWriter(f, fieldnames=LEDGER_COLS)
            if is_new:
                w.writeheader()
            w.writerow(row)
        print("[LEDGER] append OK: {} (in_box_geom={} dist={})".format(
            LEDGER_CSV_PATH, in_box_geom, in_box_dist_mm))
    except Exception as e:
        print("[LEDGER] [WARN] append 例外(制御は継続): {}".format(e))

台帳の追記は dry_run では行わない。ダミー値が混ざると集計が汚れるため、実機の試行だけを残す。

        if not dry_run:
            _append_ledger(log_record, run_stem)

この CSV 配線と place 検出の配線は、まとめて外部レビュー(codex)を通した。SSD 未マウント時にローカルへ書かないこと、検出失敗で制御が止まらないこと、CSV の同期 I/O が制御終了後(daemon close 後)に走り移動中のタイミングに割り込まないこと、列の値が pick と place を取り違えていないことを確認している。

グリッパー表示のちらつきを抑える

実機で動かすと、グリッパーの OPEN / CLOSE 表示が一瞬だけ実状態と逆に見えることがあった。原因は、表示が「最後に送ったコマンドの意図」を出していて、open と close を短時間に切り替える掴み直し区間で、コマンド送出の瞬間に値が飛ぶこと。mechArm のグリッパーは現在の開閉状態を信頼できる形で読み出せないため、実状態を取りに行く方向は採れない。

そこで、コマンド値の往復ノイズを表示側で吸収する。受信値をそのまま反映せず、同じ値が一定時間(初期 600ms)連続したら確定として表示を更新する。確定前は直前の確定値を保持するので、一瞬の往復は表示に出ない。これは実状態の読み取りではなく、コマンド値のデバウンスで、制御には触れない表示側だけの変更になる。

  function processGripperRx(v){
    var nv = (v === 0 || v === 1) ? v : null;
    if(nv === null){
      // 未受信は即反映(従来挙動)
      state.gripperConfirmed = null;
      state.gripperPending = null;
      state.gripperPendingSince = null;
      return;
    }
    if(nv === state.gripperConfirmed){
      // 既に確定値と同じ → pending を消して安定状態に戻す
      state.gripperPending = null;
      state.gripperPendingSince = null;
      return;
    }
    if(nv !== state.gripperPending){
      // 新しい pending 値を観測開始
      state.gripperPending = nv;
      state.gripperPendingSince = Date.now();
    }
  }
  function tryCommitGripper(){
    if(state.gripperPending === null) return false;
    if(state.gripperPendingSince === null) return false;
    if((Date.now() - state.gripperPendingSince) < GRIPPER_DEBOUNCE_MS) return false;
    state.gripperConfirmed = state.gripperPending;
    state.gripperPending = null;
    state.gripperPendingSince = null;
    return true;
  }

あわせて、グランスの関節表示を J0 単独の大カラムから、j0〜j5 の6軸すべての大カラムに広げた。6軸を均等グリッドで並べ、画面幅に応じて6列・3列・2列へ折り返す。関節の動きを数値の大きさで追えるようにするためで、これも表示側だけの変更になる。

  <div class="cell joints">
    <div class="label">joints — j0..j5</div>
    <div class="joints-grid">
      <div class="joint-cell">
        <div class="joint-label">j0</div>
        <div class="joint-val" id="g-j0">—</div>
        <div class="joint-unit">deg</div>
      </div>
      <!-- j1..j5 も同形で続く -->
    </div>
  </div>

実機運転:初日の収穫

ここまでの配線を入れて、実機で1本通した。pick が3回とも失敗し、place をスキップして HOME へ戻る運転になった。失敗の中身がそのまま、作った観測層の最初の試金石になる。全量のログを残す。

[RUN] run_pick_place.py  dry_run=False  log=run_0001_20260618_145803.json
======================================================================
  [Pi] fresh_mode=1
[INIT  ] fresh_mode=1
[STAGE 1] HOME 復帰 + グリッパー開
[SEND] stage1_to_home: send_angles (single) [  +0.00,   +0.00,   +0.00,   +0.00,   +0.00,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=2.10°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=2.10° (tol 2.5°)
[MOVE  ] stage1_to_home  arrived=True retried=False post_max|Δ|=2.100°
[GRIP  ] stage1_grip_open  action=open  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: OPEN result= None
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[376, 1608] Y=[134, 1023]
[06f] TEMPLATES_DIR overridden to: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/place/templates
[06f] ROTATION_ANGLES_DEG overridden to: [0, 15, 30, 45, 60, 75]
[06f] SCALES overridden to: [0.85, 1.0, 1.15]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1400,709) score=0.660 angle=0° scale=0.85 size=(167x156) template=white_box_20260520_163838_005.png (eval 4.45s)
[DETECT][1/3] box Unity=(-0.209,+0.207) score=0.6602896451950073
  赤ブロック pixel=(591, 905) → Unity=(0.2020, 0.3029)
[DETECT][1/3] block Unity=(+0.202,+0.303) score=null
[06f] DETECT: center=(1400,709) score=0.660 angle=0° scale=0.85 size=(167x156) template=white_box_20260520_163838_005.png (eval 4.40s)
[DETECT][2/3] box Unity=(-0.209,+0.207) score=0.6602375507354736
  赤ブロック pixel=(591, 905) → Unity=(0.2020, 0.3029)
[DETECT][2/3] block Unity=(+0.202,+0.303) score=null
[06f] DETECT: center=(1400,709) score=0.660 angle=0° scale=0.85 size=(167x156) template=white_box_20260520_163838_005.png (eval 4.73s)
[DETECT][3/3] box Unity=(-0.209,+0.207) score=0.6599681377410889
  赤ブロック pixel=(591, 905) → Unity=(0.2020, 0.3029)
[DETECT][3/3] block Unity=(+0.202,+0.303) score=null
[DETECT] block_xz=[0.20198109436035155, 0.3028914489746094] block_score=None
[DETECT] box_xz=[-0.20875727844238282, 0.2071285858154297] box_score=0.6602375507354736
[LOOKUP] stage3_approach_pick  phase=approach_pick  target_xz=(+0.2020,+0.3029)  → j0=-30.817°  method=interp  x_resid=0.00000m  z_resid=0.03047m  joint_ok=True
[SEND] stage3_approach_pick: send_angles (single) [ -30.82,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=86.16°, post-send timeout 14.9s)
[SEND] arrival OK: max|Δ|=1.42° (tol 2.0°)
[MOVE  ] stage3_approach_pick  arrived=True retried=False post_max|Δ|=1.420°
[LOOKUP] stage4_pick  phase=pick  target_xz=(+0.2020,+0.3029)  → j0=-37.621°  method=interp  x_resid=0.00000m  z_resid=0.07175m  joint_ok=True
[INFO  ] stage4_pick z_residual_m=0.0717m (情報のみ・ABORT しない。工程跨ぎや弧高さ差で構造的に大きいことがある)
[SEND] stage4_pick: send_angles (single) [ -37.62,  +88.00,  -21.44,   +1.40,  -70.48,  -32.87]  speed=20 wait=1.0s  (pre max|Δ|=15.47°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.23° (tol 3.0°)
[MOVE  ] stage4_pick  arrived=True retried=False post_max|Δ|=1.230°
[GRIP  ] stage5_grip_close  action=close  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift: send_angles (single) [ -30.82,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage6_lift  arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[CAP-VLM] pick 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260618_145803_frames/run_0001_20260618_145803_pick.jpg
[JUDGE ] pick attempt=1 adopted=no (vlm=no human=None) confidence=0.82 reason=爪の先端付近に赤い物体が見えるが、爪が開いた状態で赤いブロックを把持しているように見えない。白いキューブは爪から離れた位置にあり、爪はブロックを保持していない。
[GRIP  ] regrasp_open_a1  action=open  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: OPEN result= None
[SEND] regrasp_retreat_a1: send_angles (single) [ +85.00,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=116.02°, post-send timeout 19.4s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] regrasp_retreat_a1  arrived=True retried=False post_max|Δ|=1.380°
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[379, 1605] Y=[135, 1021]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1375,701) score=0.652 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.38s)
[DETECT][1/3] box Unity=(-0.197,+0.204) score=0.6515756845474243
  赤ブロック pixel=(611, 833) → Unity=(0.1932, 0.2674)
[DETECT][1/3] block Unity=(+0.193,+0.267) score=null
[06f] DETECT: center=(1375,701) score=0.652 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.45s)
[DETECT][2/3] box Unity=(-0.197,+0.204) score=0.6515924334526062
  赤ブロック pixel=(610, 833) → Unity=(0.1937, 0.2674)
[DETECT][2/3] block Unity=(+0.194,+0.267) score=null
[06f] DETECT: center=(1375,701) score=0.652 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.52s)
[DETECT][3/3] box Unity=(-0.197,+0.204) score=0.6516979932785034
  赤ブロック pixel=(610, 833) → Unity=(0.1937, 0.2674)
[DETECT][3/3] block Unity=(+0.194,+0.267) score=null
[DETECT] block_xz=[0.1936959228515625, 0.2673943481445313] block_score=None
[DETECT] box_xz=[-0.1970546875, 0.20378062438964845] box_score=0.6515924334526062
[LOOKUP] stage3_approach_pick_a2  phase=approach_pick  target_xz=(+0.1937,+0.2674)  → j0=-29.212°  method=interp  x_resid=0.00000m  z_resid=0.01004m  joint_ok=True
[SEND] stage3_approach_pick_a2: send_angles (single) [ -29.21,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=114.37°, post-send timeout 19.2s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage3_approach_pick_a2  arrived=True retried=False post_max|Δ|=1.380°
[LOOKUP] stage4_pick_a2  phase=pick  target_xz=(+0.1937,+0.2674)  → j0=-35.755°  method=interp  x_resid=0.00000m  z_resid=0.02952m  joint_ok=True
[SEND] stage4_pick_a2: send_angles (single) [ -35.75,  +88.00,  -21.44,   +1.40,  -70.48,  -32.87]  speed=20 wait=1.0s  (pre max|Δ|=15.73°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.41° (tol 3.0°)
[MOVE  ] stage4_pick_a2  arrived=True retried=False post_max|Δ|=1.410°
[GRIP  ] stage5_grip_close_a2  action=close  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift_a2: send_angles (single) [ -29.21,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage6_lift_a2  arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[CAP-VLM] pick_a2 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260618_145803_frames/run_0001_20260618_145803_pick_a2.jpg
[JUDGE ] pick attempt=2 adopted=no (vlm=no human=None) confidence=0.85 reason=爪の先端付近に赤いものが見えるが、それは爪自体のマーカー(赤いパーツ)であり、白いブロックは爪から離れた位置(右側)に置かれている。爪は何も掴んでいない開いた状態に見える。
[GRIP  ] regrasp_open_a2  action=open  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: OPEN result= None
[SEND] regrasp_retreat_a2: send_angles (single) [ +85.00,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=113.91°, post-send timeout 19.1s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] regrasp_retreat_a2  arrived=True retried=False post_max|Δ|=1.380°
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[379, 1606] Y=[134, 1021]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1376,701) score=0.652 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.35s)
[DETECT][1/3] box Unity=(-0.197,+0.204) score=0.6517702341079712
  赤ブロック pixel=(577, 863) → Unity=(0.2103, 0.2825)
[DETECT][1/3] block Unity=(+0.210,+0.282) score=null
[06f] DETECT: center=(1375,701) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.44s)
[DETECT][2/3] box Unity=(-0.197,+0.204) score=0.651348888874054
  赤ブロック pixel=(577, 863) → Unity=(0.2103, 0.2825)
[DETECT][2/3] block Unity=(+0.210,+0.282) score=null
[06f] DETECT: center=(1375,701) score=0.652 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.61s)
[DETECT][3/3] box Unity=(-0.197,+0.204) score=0.6517226696014404
  赤ブロック pixel=(577, 864) → Unity=(0.2103, 0.2830)
[DETECT][3/3] block Unity=(+0.210,+0.283) score=null
[DETECT] block_xz=[0.21028047180175782, 0.2824854736328125] block_score=None
[DETECT] box_xz=[-0.1967792510986328, 0.20385183715820313] box_score=0.6517226696014404
[LOOKUP] stage3_approach_pick_a3  phase=approach_pick  target_xz=(+0.2103,+0.2825)  → j0=-32.403°  method=interp  x_resid=0.00000m  z_resid=0.01650m  joint_ok=True
[SEND] stage3_approach_pick_a3: send_angles (single) [ -32.40,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=117.21°, post-send timeout 19.6s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage3_approach_pick_a3  arrived=True retried=False post_max|Δ|=1.380°
[LOOKUP] stage4_pick_a3  phase=pick  target_xz=(+0.2103,+0.2825)  → j0=-39.490°  method=interp  x_resid=0.00000m  z_resid=0.05808m  joint_ok=True
[INFO  ] stage4_pick_a3 z_residual_m=0.0581m (情報のみ・ABORT しない。工程跨ぎや弧高さ差で構造的に大きいことがある)
[SEND] stage4_pick_a3: send_angles (single) [ -39.49,  +88.00,  -21.44,   +1.40,  -70.48,  -32.87]  speed=20 wait=1.0s  (pre max|Δ|=15.73°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.41° (tol 3.0°)
[MOVE  ] stage4_pick_a3  arrived=True retried=False post_max|Δ|=1.410°
[GRIP  ] stage5_grip_close_a3  action=close  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift_a3: send_angles (single) [ -32.40,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE  ] stage6_lift_a3  arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[CAP-VLM] pick_a3 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260618_145803_frames/run_0001_20260618_145803_pick_a3.jpg
[JUDGE ] pick attempt=3 adopted=no (vlm=no human=None) confidence=0.85 reason=爪の先端付近に赤い部品が見えるが、それはグリッパー自体の構造部品(赤いワイヤーまたはパーツ)であり、白いブロックは爪から離れた場所(右側)に置かれている。爪は何も掴んでいない状態に見える。
[RESULT] pick 失敗(3回試行)。place をスキップして HOME 復帰
[SEND] pickfail_to_home: send_angles (single) [  +0.00,   +0.00,   +0.00,   +0.00,   +0.00,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=89.12°, post-send timeout 15.4s)
[SEND] arrival OK: max|Δ|=2.02° (tol 2.5°)
[MOVE  ] pickfail_to_home  arrived=True retried=False post_max|Δ|=1.660°
  [Pi] fresh_mode=0
[LOG   ] /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260618_145803.json
[LEDGER] append OK: /Volumes/KIOXIA/r2r2r-data/runs/pick_place_ledger.csv (in_box_geom= dist=)

この運転で観測層は実機の datagram で動いた。emit から中継、ブラウザまで値が流れ、verdict には VLM の判定理由が乗り、block_xz は試行ごとに更新され、CSV に1行が追記された。

判定理由が3回とも具体的だったことが、いちばんの収穫になる。

  • 1回目:「爪が開いた状態で、白いキューブは爪から離れた位置にある」

  • 2回目:「爪の先端に見える赤いものは爪自体のマーカー(赤いパーツ)であり、白いブロックは右側に離れている」

  • 3回目:「爪の先端に見える赤い部品はグリッパー自体の構造部品であり、白いブロックは右側に離れている」

3回続けて、VLM は爪の赤いマーカーをブロックと混同しかけ、しかし最終的に「掴んでいない」と弾いている。この「同じ理由で続けて落ちた」というパターンこそ、前回のログで台帳に残したいと書いた情報だった。

この運転とは別に、もう1本の試行が台帳に残っている。CSV の中身を全文で示す。

2026-06-18T14:52:43.647625,run_0001_20260618_145115,1,yes,爪の先端部分に赤い四角いブロックが挟まれており、グリッパーが閉じた状態で保持しているように見える,0.85,unknown,swept_retreat_failed,0.0,162.7,276.0,,,-208.7,207.3,,,0.17,1.66,2.10,0.26,-2.19,-29.70,0,
2026-06-18T15:00:32.261714,run_0001_20260618_145803,1,no,爪の先端付近に赤い部品が見えるが、それはグリッパー自体の構造部品(赤いワイヤーまたはパーツ)であり、白いブロックは爪から離れた場所(右側)に置かれている。爪は何も掴んでいない状態に見える。,0.85,,,,210.3,282.5,,,-208.8,207.1,,,-0.26,1.66,-0.35,0.08,-0.43,-30.14,1,

1行目が三重記録の意味を示している。pick の verdict は yes(「爪に赤い四角いブロックが挟まれ、閉じた状態で保持している」確信度 0.85)。だが place の verdict は unknown で理由は swept_retreat_failed——place 判定の手前で退避動作が失敗し、判定そのものに到達できなかった。block_place は空、in_box も空。これは検出に失敗したのではなく、place 後の撮影工程そのものに入れなかったことを示している。pick は成功と判定されたのに、place 工程に進めずに終わった試行が、その経緯ごと1行に残った。単一の成否(yes か no か)だけを記録していたら、この「掴めたが置けなかった」という中身は消えていた。

次にやること

観測層は実機で動いたが、見えたものが次の課題を具体的に示した。

検出のスコアが出ていない。実機ログの全試行で block_score=null が出ていて、赤ブロックの検出はピクセル座標を返すがスコアを返していない。位置は記録できても、その位置をどれだけ信じてよいかはまだ記録できていない。検出の質を数値で測れていないので、ここを埋める。

VLM が爪の赤いマーカーをブロックと見間違えかけている。3回とも判定理由がこの混同に触れている。掴み自体が成立していないのか、撮影の構図が誤読を招いているのか、台帳の写真パスから画像を引いて照合する必要がある。三重記録に写真パスを入れたのは、この照合のためだった。

そして、台帳はまだ1試行ずつの行でしかない。「同じ理由で3回続けて落ちた」を画面で見せるには、行を集計して、試行をまたいだ失敗モードの反復を出す層が要る。単一の datagram を超えたのと同じことを、単一の行に対してもう一度やる。CSV に積み始めたのは、その集計の土台になる。

画面は今を見る。台帳は、同じ失敗が何度起きたかを見る。Log#2 で足したのは、後者の入口だった。

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