mechArm 270 Pi 動的追従の限界を探る Log#49 | 箱に当たる原因を三度取り違えた。当たりを検知して姿勢を上げるループ
PLACE がいつまでも縮まらない。赤いブロックを白い箱に置くとき、箱の中心からの距離(in_box_dist)が 20〜45mm を行ったり来たりして、0 に向かわない。PICK 側は爪先とブロックの距離が 6.6mm から 1.2mm まで下がって収束しているのに、PLACE だけが止まっている。この一日は、その原因を三度取り違え、そのたびに実測に引き戻され、最後に本当の原因へ行き着いた記録になった。取り違えた過程を消さずに残す。同じ落とし穴は、次に必ず避けられるからだ。
結論を先に書く。PLACE が縮まらない根本は「置く瞬間に爪が白箱に当たって、箱もブロックも動く」ことだった。動いた後の位置を測って補正するから、補正が接触のノイズを学習して発散する。そして当たりを決めるのは、爪先の高さ(ee_y)ではなく、前腕の畳み方(j1)だった。ここに至るまでに、FK テーブルの穴を疑い、カメラの検出ノイズを疑い、爪先の高さ(ee_y)を疑った。三つとも外れだった。
発端 ― 補正が効いているセルと発散するセル
PLACE 補正記憶(置き位置を箱中心に寄せる EMA 補正)を起動した後の実機ログを、セル別に並べた。マットを 20mm 格子で区切り、同じ格子(セル)に落ちた試行だけを時系列で見る。
中央寄りのセル place_x-180_z200 は、きれいに収束していた。
実Δz: +46.1 → -26.0 → +4.2 inbox: 46.2 → 27.6 → 15.0
適用corr dz: (なし) → +18.99 → +3.99補正が符号どおりに反応し、振幅が縮んで収束していく。PICK が n=24 で収束したのと同じカーブだ。ここは「動かすたびに良くなる」が現に回っていた。
ところが左奥のセル place_x-220_z180 は、逆に発散していた。
実Δz: +27.2 → -30.5 → +83.2 → -75.1 inbox: 27.2 → 46.7 → 102.0 → 85.3
適用corr dz: (なし) → +33.74 → +18.74 → +13.19補正は符号どおりに反応しているのに、振幅が縮まず拡大していく。同じ補正ロジックで、片方は収束し、片方は発散する。この差が何なのかが、この一日の問いになった。
一度目の取り違え ― FK テーブルの穴
最初に疑ったのは FK テーブルの穴だった。実行層は FK テーブル(fk_scan_motion_based_v2.csv・Unity Brep 基準)から関節姿勢を引く。target の XZ 座標に最も近い姿勢をテーブルから選ぶ(fk_nearest)。発散セルの補正後 target は z=154〜170mm という手前寄りの帯にあり、この帯の点がテーブルで薄ければ、最近傍が遠くなって姿勢が劣化する ── そう推論した。
同じ日の stage4(PICK 降下)で、実際に送信前ガードが発火していた。
[LOOKUP] stage4_pick target_xz=(+0.2266,+0.2734) → j0=-31.930° method=fk_nearest x_resid=0.02353m z_resid=0.01430m joint_ok=True
[ABORT ] stage=4 reason=送信前ガード NG: x_residual_m=0.02353267482793331 > 0.02target に対して、テーブルの最近傍が 23.5mm 離れている。ガードが「外し掴み」を送る前に止めた。この帯が薄いなら、PLACE の発散も同じ穴のせいだ ── そう考えて「x-220 帯を Unity で再スキャンして埋める」と言った。
エイイチはスクリーンショットを一枚返してきた。FK テーブルの密度ビューアで、x-220 帯(画面右)は放射状に濃い。しかも表示していたのは PLACE 姿勢 194,045 点だけ。点が無いどころか、大量にある。
220帯こんなに濃いのに?穴なんてあるの?
穴を疑った推論が、測る前の思い込みだった。実際に確認したのは、発散セルの stage8(PLACE 降下)の残差だった。
place_withblock fk_nearest target=(-212,165) x_resid=0.0mm z_resid=0.1mm guard=Truex_resid=0.0mm、z_resid=0.1mm。FK は完璧に引けていた。穴は無い。収束セルと比べると、むしろ発散セルのほうが FK 品質は良かった。
発散 x-220: z_resid=0.1mm ← FK品質は最高
収束 x-180: z_resid=1.1mm ← 発散セルより悪いFK 品質(z_resid)と発散は無相関。一度目の「穴」は完全に外れだった。
二度目の取り違え ― カメラの検出ノイズ
FK が完璧に引けているのに実機のブロックがズレる。次に疑ったのは検出ノイズだった。左奥(x-220)はマットの端で、上カメラ(iPhone14 Pro Max)の画角の端にあたる。ここで homography の精度が落ち、置いたブロックの検出値がブレて、補正がノイズを追って発散する ── そう推論した。
発散セルの JSON を掘ると、決定的に見える食い違いが出た。
servo(j0合わせ): block_x=-214.13 箱=-217.71 diff=-3.58mm → converged「合ってる」
place_pre(補正入力): 同じブロックを箱から-40mmズレと測定(実Δx=-40.4)視覚サーボ(j0 で X を合わせる制御)は「箱の 3.58mm 手前、合った」と記録している。ところが補正の入力になる place_pre(上カメラ)は「箱から 40mm ズレ」と測っている。同じブロック、同じ試行で、二つの検出系が矛盾した値を出す。片方のカメラが左奥で狂っているに違いない ── そう考えて、二カメラの検出突合を測ろうとした。
ここでエイイチが、この一日で何度も言っていたことを、もう一度言った。
だから前から言ってるけど、PLACE時に白箱に爪が当たったりすれば白箱ズレたり、ブロックズレたりするんだって。
カメラは二つとも狂っていない。時間差の間に物が動いていた。視覚サーボは j0 合わせの瞬間に測る(まだ当たっていない・合っている)。その後アームが降りて爪が箱に当たり、箱かブロックが動く。place_pre はその後に撮る(動いた後)。だから同じブロックで違う値が出る。検出ノイズではなく、接触という物理イベントが二つの測定の間に挟まっていた。
発散セルの符号がバタつくのも接触なら当然だった。当たり方は毎回違う ── 縁のどこに当たるか、箱がどちらへ動くか、ブロックが弾かれるか転がるか。ランダムに見えたのは検出ノイズではなく、接触そのものがランダムだったからだ。二度目の「検出ノイズ」も外れだった。
三度目の取り違え ― 爪先の高さ(ee_y)
当たりが原因だと分かった。では、当たらないように姿勢を上げればいい。当たるのは爪先が低いからだと考え、「目標 ee_y(爪先の高さ)を下限で縛って、低い姿勢を候補から外す」という案(案B)を立てた。ee_y は「爪先をどれだけ上げるか」という目的そのものを表す軸だから、これで縛れば当たらない高さが保証される ── そう推論した。
Claude Code に実装前の実測をさせた。当たった姿勢と当たらない姿勢の ee_y を、FK テーブルから実際に引いた。
DAY235 安全姿勢: j1=59.15 ee_y=96.4mm 当たらない
今日 衝突姿勢: j1=46.65 ee_y=105.3mm 当たる ← ee_yはむしろ8.9mm高い当たる姿勢のほうが、爪先(ee_y)は 8.9mm 高かった。ee_y の下限で「今日の当たる姿勢を弾いて、LOG235 の当たらない姿勢を残す」線は、軸上に存在しない。案B は成立しない。
なぜ ee_y で弁別できないのか。爪先が高くても、前腕が寝ていれば箱の側面を擦る。当たりを決めていたのは爪先の高さではなく、前腕の畳み方だった。
DAY235: j1=59.15 j2=12.83 → 肩を立て肘を折り畳み、前腕を立てる(箱を避ける)
今日: j1=46.65 j2=27.83 → 肩を伏せ肘を伸ばし、前腕を寝かせて箱側面へ突っ込む弁別軸は ee_y ではなく j1(肩関節・前腕の畳み方)だった。三度目の「ee_y」も外れだった。三度とも、測る前に手近な仮説へ飛びついていた。
正しい軸 ― j1 で当たる姿勢を除外する
fk_nearest は (ee_x, ee_z) の距離だけで姿勢を選ぶ。高さ方向(ee_y)や関節配分(j1)は選択基準に入っていない。だから XZ がたまたま近い「前腕を寝かせた低い j1 姿勢」が勝ってしまう。今日 j1=46.65 が選ばれ、当たらない j1≈59 の姿勢が候補にあったのに全部落選した。
対策は、PLACE 降下の候補を j1≥55° で絞ってから XZ 最近傍を取ることだ(層1・固定フィルタ)。前腕を立てた姿勢だけを候補にする。閾値 55° は LOG235 で実測した安全姿勢 j1=59.15° から 5° の余裕を引いた値で、当たる 46.65° は 8.35° 下で確実に落ちる。
Claude Code の実装前確認で、この閾値が候補を枯らさないことも取れた。
box XZ 全域 362 trial で j1≥55 の GUARD 超過 80→81 件(+1 trial のみ)。p50 差 0.44mm。ABORT 頻発なし。
今日の当たるセルで j1=46.65(resid0.93) → j1=57.39(resid2.26) に切替。当たる姿勢が落選。
stage7 approach_place は j0_lookup 経由で j1=57.39° 構造固定 → 低 j1 は発生しえない。
pick phase は j1=87.74 従来姿勢が不変。PICK 回帰ゼロ。j1 フィルタは codex レビューを通過し、実機に入った。
当たりを数値化する ― 白箱の接触前後ズレ
j1 フィルタで「当たる姿勢タイプ」は除外した。だが、それだけで当たりが消えたかは分からない。目視ではなくデータで判定したい。エイイチの設計はこうだった。
最初と最後の白箱の位置が違うから、爪が白箱に当たっているという判定ができる。画像はもう撮ってるから、最初と最後でどのくらい位置ズレが起こっているかの数値データを台帳に付け足して役立てたい。
白箱を試行の最初(接触前・stage2 で検出済み)と最後(接触後・stage9b 退避後の place 画像から再検出)で測り、その差 box_shift_dist を台帳に記録する。撮影は増やさない ── 画像は既に撮れているので、検出を足すだけだ。最後の検出に退避後の画像を使うのは、リリース前だとグリッパーが箱に被って全体が見えないからで、退避後なら箱が完全に見える。
この box_shift_dist が、当たり検知そのものになる。エイイチの原則がここにも効いた。
画像は残っていれば記憶できるはずだから必ず記憶させて。失敗しても必ず記録を残す!が基本です。
実機で回すと、box_shift_dist はきれいに二峰に分かれた。
当たり疑い: 218.45 / 222.64 / 216.52 / 216.66 (〜220mm)
当たってない: 6.22 / 9.78 / 1.63 (〜10mm)
10mm と 216mm の間に何もない。当たったか当たっていないかが、数値でくっきり弁別できる。閾値をこの谷(50mm 前後)に引けば、当たり判定が焼き付けなしで決まる。
当たりを止めるループ ― 高さ補正(ee_y 学習・j4 優先)
j1 フィルタは「当たる姿勢タイプの床」を作るだけで、セルごとに「当たらない高さまで上げる」動的な調整は持たない。box_shift_dist を見ると、フィルタを通した後もまだ当たりが残っていた(216〜222mm)。そこで、当たったセルを次の試行で当たらない高さまで上げる学習ループを設計した。
ここでエイイチが、上げ方の順序を正した。
アームを上に上げるのはJ1、J2、J4です。複合的な問題ですが、白箱に上がらない程度に上げるだけなので優先順位的にはj4、j2、j1という順番かと考えられます。
爪先を上げるのは j1・j2・j4 の複合で、優先順位は j4(手首)→ j2(肘)→ j1(肩)。白箱に当たらない程度の小さな上げなので、一番先端の j4 で微調整するのが向いている。j1 は LOG235 で「上げすぎると逆にアームが下がって白箱より低くなった。単純比例しない」と実測済みだから、最後に回す。
三層構造になった。
層 | 役割 | 状態 |
|---|---|---|
層1: j1≥55 フィルタ | 前腕を寝かせた当たる姿勢タイプを候補から除外(固定の床) | 実装済み |
層2: Δee_y 学習 | 当たったセルで目標 ee_y を上げる(box_shift で学習) | 実装 |
層3: j4優先の候補選択 | 同一 ee_y 帯で j4>j2>j1 の順に上げた姿勢を選ぶ | 実装 |
層2 の学習則はラチェット式にした。上げは速く、下げは遅い。一度上げて当たらなくなったら、その高さが正しい ── 下げる動機は弱い。
if box_shift_dist(c) > HEIGHT_HIT_THRESH_MM: dee_y[c] += HEIGHT_STEP_MM # 上げ・速い
elif box_shift_dist(c) < HEIGHT_CLEAR_THRESH_MM: dee_y[c] -= HEIGHT_DECAY_MM # 下げ・遅い
# 中間帯は現状維持
dee_y[c] = clamp(dee_y[c], 0.0, HEIGHT_MAX_MM)初期値は保守的に置いた。box_shift の二峰(10mm/216mm)の谷を閾値に、一段の上げは 5mm、下げはその 1/5、上限 40mm。精密値は実機のラチェットが自己調整する。
HEIGHT_HIT_THRESH_MM = 50.0
HEIGHT_CLEAR_THRESH_MM = 20.0
HEIGHT_STEP_MM = 5.0
HEIGHT_DECAY_MM = 1.0
HEIGHT_MAX_MM = 40.0層3 の j4 優先は重み付きスコアで実装した。目標 ee_y 帯の候補に、base 姿勢からの関節変化で重みをかける。j1 を動かす姿勢に最大ペナルティ、j4 は最小。同じ高さなら j4 で稼いだ姿勢がスコア最小になる。
score = W_XZ_DIST*xz_dist + W_J1_PENALTY*|Δj1| + W_J2_PENALTY*|Δj2| + W_J4_PENALTY*|Δj4|
W_XZ_DIST = 1.0 W_J1_PENALTY = 0.3 W_J2_PENALTY = 0.1 W_J4_PENALTY = 0.02高さ補正の記憶は、置き位置を寄せる XZ 補正記憶とは別ファイルにした。入力(box_shift/block_place)も出力(ee_y/target 座標)も別で、片方のノイズがもう片方に混ざらない。
途中で潰した別の詰まり ― VLM のクレジット切れ
補正が回らない試行を掘っていて、判定 API が全滅していることに気づいた。
[JUDGE ] pick adopted=unknown reason=http_400
[CORR ] PLACE update skip: reason=pick_not_yes (final_verdict=unknown)VLM-as-Reward(実機動作 → iPhone 撮影 → VLM 成否判定 → 次試行フィードバック)が http_400 で落ちていた。pick が unknown になると補正が pick_not_yes で skip され、補正記憶が更新されない。
原因を推測せず、400 のレスポンス本文を出させた。400 の本文には必ず理由が入っている。
"type":"invalid_request_error",
"message":"Your credit balance is too low to access the Anthropic API."コードの故障ではなかった。API クレジットの残高切れ。画像サイズもリクエスト形式も正常で、前ターンで疑った「画像でかすぎ」は外れ。成功と 400 の境目も 2 分の空白として綺麗に出て、その間にクレジットがゼロになったのが分かった。補充して蛇口は開いた。次に切れても台帳の reason 列に http_400_no_credit と直接出るよう、記録を細分化した。
台帳を壊した失敗と、その復旧
箱ズレ検出のために台帳を 67→72 列にするとき、手順を取り違えた。列を増やす手順書(data_manifest_rules.md 8節)を「旧台帳を退避して 0 行から作り直す」と額面どおり適用し、過去 393 試行を現台帳から追い出してしまった。補正記憶マップが過去のセル履歴を全て失った。
エイイチの指摘は短かった。
退避させたらダメだよ?前のデータもそのままpick_place_ledger_v3.csvの中に入れておかないと。
手順書は本来、状態語の版が過去行と混ざる場合のための「作り直し」だった。箱ズレ 5 列のような「既存行に空セルを足すだけ」の単純な列追加には過剰で、過去行を保持したまま足せばよかった。データは archive に byte-exact で残っていたので、過去 392 行を 72 列化して現台帳に戻し、履歴は完全復活した。そして手順書自体を、二度と同じ間違いをしないよう二分岐に直した ── 単純な列追加は過去行保持(8-A・既定)、状態語の版が混ざる場合だけ作り直し(8-B)。迷ったら 8-A。
実機で回り始めたループ、そして次の詰まり
高さ補正を含む統合実装が入り、実機で回した。台帳に、ループが回り始めた跡が残った。
10:44:19 boxsh=222.80(当たった) dee_y=0.00
10:51:45 dee_y=5.00 ← 当たった次から +5mm 上がったbox_shift=222.8mm で当たった次の試行から、そのセルの dee_y が 0→5mm に上がった。ラチェットが「当たったら上げる」を実機で正しくやっている。記録欠損の修正も効いて、補正されなかった試行にも理由が必ず残るようになった。
corr_r=pick_not_yes bsh_r=ok
corr_r=applied bsh_r=place_img_none
corr_r=not_reached bsh_r=place_img_noneだが、新しい詰まりが見えた。dee_y を 5mm に上げた後の 5 試行が、box_shift_reason=place_img_none で連続した。退避後の place 画像が撮れていない ── stage9b に到達する前に止まっている。せっかく高さを上げても、上げた効果(当たりが減ったか)が測定できない。ラチェットは「当たったら上げる」まではできているが、「上げたら当たらなくなったか」の閉ループが、画像欠損で切れている。
place_img_none がなぜ起きるか(退避 swept_retreat の j4 スタールか、別の原因か)は、まだ切り分けていない。ここで一日を区切る。当たりを検知して姿勢を上げるループは組めた。回っている。だが効果を測る目が、まだ半分塞がっている。次は、その目を開ける番だ。
