ロボットアームのテレメトリダッシュボードを作る 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 Noneplace 後にブロックが箱に入って上から見えにくい、あるいは枠外に飛んでいる、ということは正常に起こる。だから検出失敗で 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 で足したのは、後者の入口だった。
