mechArm 270 Pi 動的追従の限界を探る Log#14 | 関節制御の試行錯誤と、外部AIレビューが見つけた致命的バグ
デザイナーがロボットを触ると、見えてくるものがある。LOG200で見えたのはこれ。
DESCEND(爪先降下)と REFINE(微調整追従)のロジックが、ある姿勢領域で破綻していた。j1+j2+j4 を1.6°ずつ動かしているのに、爪先が1mmも動かない。命令は出ている。実機は応答しない。実機を眺めながら、関節を1つずつ試した。j4 単独で円弧降下を作る。降下中もXZを補正する。REFINE は係数を捨ててプローブ式に置き換える。書き終えたコードを codex へ放り込む。返ってきたのは、4つの致命的バグだった。設計の Claude と実装の Claude Code、思考の双子で確認していた限り、絶対に見えなかったやつ。Master ドキュメントの AI ツール分担を書き直すところまで、このセッションで進んだ。
💡 Key Takeaway
LOG186で確立した「j1+j2+j4 同方向連動」のリーチ補正式は、LOG200の DESCEND 後の姿勢領域では成立しない。係数ベースの補正を捨て、実機の応答を見て試行方向を切り替えるプローブ式制御に置き換えた。codex の外部レビューで「target が REFINE 入口で消える」「REFINE 基準角が DESCEND 前に戻る」など、自分では気づけない致命的バグが4つ見つかった。AI ツール分担を一新し、コードレビューを開発フローに組み込んだ。
発生した問題 | 現場判断 | 結果 |
|---|---|---|
DESCEND 開始姿勢で j1/j2 を動かしても爪先が降下しなかった | アームを横から見て関節を1つずつ検証。手首ピボットを軸にした j4 単独円弧降下に転換 | j4 を +1° ずつ動かすことで爪先が降下することを実機で確認 |
j4 降下中に爪先のXZがブロックから外れていく | DESCEND ループ内に毎ステップの XZ 観測と j0+j1+j2 補正を組み込み、1ループ1送信に統合 | 爪先が赤ブロック直上を維持しながら円弧で降りる構造を実装 |
REFINE で関節は動くのに爪先が動かない | LOG186 の j1+j2+j4 連動係数が DESCEND 後の姿勢領域で無効になっていた。係数を捨てプローブ式に転換 | |diff| が前回より減れば同方向継続・増えれば反転する自己修正ループへ |
思考の双子(Claude × Claude Code)では同じ盲点を持ち、致命バグを見逃した | codex に外部レビュー依頼。4件の致命バグを発見(target消失/基準角リセット/failure無視/クランプなし) | レビュー体制を恒久化。Master ドキュメントを更新し AI ツール分担を再設計 |
横カメラを常時表示する
LOG199で持ち越していたタスクから着手した。DESCEND ループ内で cv2.VideoCapture(1) を毎フレーム open → read → release していた処理を、起動時に1度だけ open して保持する構造に書き換えた。
_cap_side = cv2.VideoCapture(SIDE_CAMERA_INDEX)
if not _cap_side.isOpened():
print(f"[WARN] 横カメラ index={SIDE_CAMERA_INDEX} を開けません、スキップします")
_cap_side = None
else:
for _ in range(30):
_cap_side.read() # warmup
cv2.namedWindow("R2R2R Side Tracking", cv2.WINDOW_NORMAL)
cv2.resizeWindow("R2R2R Side Tracking", 960, 540)加えて、上カメラの R2R2R Tracking ウィンドウも DESCEND 中・REFINE 中・GRIPPER CLOSE 中・LIFT 中・HOME 中の全フェーズで止まらないようにした。_update_both_cameras() という関数を main() のクロージャで定義し、フェーズ遷移ごとに呼び出す形にした。
def _update_both_cameras(status_text=""):
"""GRIPPER / LIFT / HOME 等の動作中も両カメラを1フレーム更新する。"""
if cap is not None:
ret_top, frame_top = cap.read()
if ret_top and frame_top is not None:
cv2.imshow("R2R2R Tracking", frame_top)
if _cap_side is not None:
ret_s, frame_s = _cap_side.read()
if ret_s and frame_s is not None:
cv2.imshow("R2R2R Side Tracking", frame_s)
cv2.waitKey(1)これでスクリプト起動から終了まで、両ウィンドウが固まらず映像が流れ続ける状態になった。「いま何が起きているか」を視覚で見失わない。このあとの試行錯誤で、これが何度も効いてくる。
DESCEND を中央高さ判定にしてみた、しかし降りなかった
横カメラ表示が安定したので、DESCEND の完了判定を作り直した。それまでの判定は「tip_side_y が baseline から +30px 増加したら完了」という相対量判定で、爪先がブロックの高さに届いたかは見ていなかった。
赤ブロックを横カメラで検出して、ブロック中央の y 座標を目標として固定する設計。
def detect_red_block_side(frame):
"""横カメラ画像から赤ブロックの bounding box を検出する。"""
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
lower1 = np.array([0, 120, 70]); upper1 = np.array([10, 255, 255])
lower2 = np.array([170, 120, 70]); upper2 = np.array([180, 255, 255])
mask = cv2.bitwise_or(cv2.inRange(hsv, lower1, upper1),
cv2.inRange(hsv, lower2, upper2))
kernel = np.ones((3, 3), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 500:
return None
x, y, w, h = cv2.boundingRect(largest)
return {
"top_y": int(y), "bottom_y": int(y + h),
"center_y": int(y + h // 2), "center_x": int(x + w // 2),
}DESCEND 開始時にこの関数で target_tip_y_side を設定する。実機で動かしてみた。[DESCEND-SIDE] 赤ブロック検出: top_y=732, bottom_y=872, center_y=802 → target に設定。target は画面 y=802 に引かれた。
ところがアームが下まで降りなかった。j2 を -0.5° ずつ動かすモード、続いて j1 を +1° ずつ動かすモードに切り替えても、爪先は target に届かない。横カメラを見ると、爪先がブロックの上空でうろうろしている。
ログを見直す。j1 を 87° → 110° まで動かしているのに、tip_side_y は 619 → 603 と画面上で「上に」動いていた。j1 をプラス方向に振ると、この姿勢領域では爪先が「奥に押し出されて、わずかに上がる」軌道になる。降下方向への効果が消えていた。
関節を1つずつ試して、j4 単独制御へ転換
Side Tracking 画像を眺めながら、関節の意味を1個ずつ検証した。グリッパーはほぼ水平を向いている。爪先はブロックの上空にある。ここから爪先を下に持っていくには、どの関節が効くのか。
j4の動きだけで爪は下がると思うけど?
これが転換点だった。手首ピボットを軸にして j4 を回転させれば、爪先は円弧を描いて降りる。j1 や j2 で姿勢全体を作り変える必要はない。手首だけ振れば届く。
DESCEND を j4 単独制御に書き換えた。
DESCEND_J4_DELTA = -1.0 # 毎ループ j4 を動かす(初期値)
DESCEND_J4_MIN = -180.0 # j4 下限
DESCEND_MAX_LOOPS = 90実装して走らせた。j4 を -90 → -95 と動かした。爪先 tip_side_y は 504 → 436 に「上昇」した。
いやアームは止まっているから関係ないって。
実機を見ている側からの指摘で、視点がまた修正される。アーム全体は止まっている。動いているのは手首だけ。そして手首が振れる方向が「上向き」になっていた。
mechArm の j4 回転方向定義に対して、コードの符号が逆だった。DESCEND_J4_DELTA = +1.0 に反転して再実行。今度は手首が下を向き、爪先がブロックに向かって円弧で降りた。
降下中もXZがズレる、毎ステップ補正に組み込む
j4 が単独で降下を作ることは確認できた。しかし新しい問題が見えた。j4 を回転させると、手首ピボット中心の円弧で爪先が動くため、爪先のXZ位置がブロックから外れていく。横カメラの x も連続して変化していた(188 → 177 → 128px)。
DESCEND ループ内に、毎ステップ XZ を観測してj0・j1・j2 で補正するロジックを組み込んだ。j4 は降下専用、j0 が X 補正、j1+j2 連動が Z 補正という役割分担。1回の send_angles にまとめて送信する。
delta_j0 = -diff_x_mm / MM_PER_DEG_J0_X
delta_j1 = (diff_z_mm / MM_PER_DEG_ELBOW_Z_DAY186) / 2.0
delta_j2 = (diff_z_mm / MM_PER_DEG_ELBOW_Z_DAY186) / 2.0
new_j4 = current_angles[4] + DESCEND_J4_DELTA
current_angles[0] += clip(delta_j0, -3.0, 3.0)
current_angles[1] += clip(delta_j1, -1.5, 1.5)
current_angles[2] += clip(delta_j2, -1.5, 1.5)
current_angles[4] = new_j4
daemon_send_angles(current_angles, wait=0.3)DESCEND ループはこれで「爪先がブロック直上を維持しながら円弧で降りる」構造になった。
REFINE で関節は動くのに爪先が動かない
DESCEND 後の REFINE(DESCEND後 微調整追従)で、新しい謎にぶつかった。
37秒のログを抜粋する。
t=16.9s diff_x=+8.2 diff_z=-9.9 j0=-39.93 j1=+91.11 j2=-16.63 j4=-93.37
t=54.1s diff_x=+7.7 diff_z=-9.4 j0=-52.94 j1=+108.50 j2=+0.76 j4=-110.7637秒間で:
diff_xは 8.2 → 7.7(0.5mm しか縮まっていない)diff_zは -9.9 → -9.4(同じく 0.5mm しか縮まっていない)j0 は 13°、j1 は 17°、j2 は 17°、j4 は 17°動いた
関節は合計64°回っている。爪先のズレは1mmも縮まっていない。「関節は動くのに爪先が動かない」状態だった。
原因はすぐには分からなかった。何度かやり取りした末、ようやく見えた。
REFINE の Z-TRACK は LOG186 で確立した j1+1.6°, j2+1.6°, j4-1.6° の3関節連動。係数 5.0 mm/deg は LOG186 の APPROACH 姿勢で実測した値で、その姿勢領域でしか保証されない。DESCEND を散々重ねた今の姿勢(j1=108°, j2=+0.7°, j4=-110°)は、その測定範囲から大きく外れている。3関節の効果が打ち消し合う比率に変わって、合計効果がほぼゼロに落ちていた。
コードはバグじゃない。式が言う通りに関節を動かしている。物理モデルの前提だけが、いつの間にか変わっていた。
TIPと赤ブロックの位置が合っていない時には細かく修正してほしいんだけど。今のコードではそれはできませんってこと?
問いに対する答えは「できる。やれてないだけ」。係数に依存しない方法に置き換えれば動く。
係数を捨ててプローブ式制御へ置き換える
REFINE を「実機の応答を見て自分で方向を決める」プローブ式に書き換えた。
測れる手段を持つことが、推論より先に来る。 この原則はずっと持っていたのに、係数の中に閉じこもっていた。係数 5.0 mm/deg は「測った時の姿勢」の話で、今の姿勢の話じゃない。今の姿勢で測りたければ、今ここで測ればいい。
設計はこう。
毎ループで TIP と RED を観測
水平X ズレが 3mm 以上 → j0 を 1° 動かす
水平Z ズレが 3mm 以上 → j1 を 1° 動かす
試行方向は変数で保持。
|diff|が前回より減れば同方向継続、増えれば反転水平X・Z 両方が deadband 内になったら、j4 を +1° 動かして降下
tip_side_y >= target_tip_y_sideで REFINE break、GRIPPER CLOSE へX と Z の試行方向は独立に保持する
係数を使わない。姿勢が変わっても、関節の効き方が変わっても、実機の反応だけで自己修正できる。
REFINE_PROBE_DEADBAND_MM = 3.0
REFINE_PROBE_STEP_DEG = 1.0
refine_x_direction = +1
refine_z_direction = +1
refine_prev_abs_diff_x = None
refine_prev_abs_diff_z = None
# X 方向プローブ
if abs_diff_x >= REFINE_PROBE_DEADBAND_MM:
if (refine_prev_abs_diff_x is not None
and abs_diff_x > refine_prev_abs_diff_x):
refine_x_direction = -refine_x_direction
delta_j0_refine = refine_x_direction * REFINE_PROBE_STEP_DEG
refine_prev_abs_diff_x = abs_diff_x
# Z 方向プローブ
if abs_diff_z >= REFINE_PROBE_DEADBAND_MM:
if (refine_prev_abs_diff_z is not None
and abs_diff_z > refine_prev_abs_diff_z):
refine_z_direction = -refine_z_direction
delta_j1_refine = refine_z_direction * REFINE_PROBE_STEP_DEG
refine_prev_abs_diff_z = abs_diff_z
# 両方 deadband 内なら降下
if x_in_deadband and z_in_deadband and target_set:
if tip_side_y_refine >= target_tip_y_side:
return j0_current, red_x_mm, True, history
delta_j4_refine = +REFINE_DESCENT_STEP_DEGこれで実装した。Claude Code に依頼して、書き上がったコードを受け取った。動作確認の前に、外部レビューに通すことにした。
codex レビューで致命的バグが4件見つかった
レビュー依頼用の指示書を作って、コードと一緒に codex へ放り込んだ。
返ってきた判定は ❌4件、⚠️ 多数。実機テストに進む前に直すべき項目として、4件の致命的バグ。
1件目:target が REFINE 入口で消える
DESCEND で記録した target_tip_y_side を、REFINE 呼び出し直前で None にクリアしていた。REFINE 側はこの値を見て降下判定するため、target が常に失われ、降下も完了 break も起きない状態になっていた。
2件目:REFINE の基準角が DESCEND 前に戻る
REFINE 呼び出し時に渡していた基準角度が、DESCEND 開始前の descend_angles だった。せっかく j4 を -90° → -77° まで動かして爪先を降ろしたのに、REFINE の最初の send_angles で j4 が -90° に戻る危険があった。
3件目:REFINE 失敗時も GRIPPER CLOSE に進む
REFINE が timeout や ESC で抜けた場合に refined_success = False が返るが、呼び出し側はこれを見ずに GRIPPER CLOSE → LIFT → HOME へ進んでいた。失敗した姿勢で把持動作が走るとブロックを蹴飛ばすか、関節が変な姿勢で LIFT に入って机を叩く可能性があった。
4件目:REFINE の j0/j1/j4 に安全クランプがない
プローブ式は毎ループ ±1° を積み重ねる。ハンチングや方向誤判定が起きれば、無制限に j0 が動く。MAX_TRACKING_SEC=600秒 まで止まらない。
4件とも、Claude と Claude Code の2者ループでは見つけられなかった。設計したやつと実装したやつは、同じ前提を共有している。同じ言葉、同じ文脈、同じ盲点。思考の双子は、互いの誤りを訂正できない。
修正指示書を作って Claude Code に渡し、5箇所を一括修正した。
_descend_target_y_side = Noneのクリアタイミングを REFINE 完了後に移動post_descend_angles = list(current_angles)で DESCEND 後姿勢を保存し、REFINE に渡すif not refined_success:で GRIPPER CLOSE をスキップし HOME 復帰REFINE 内に
REFINE_J0_MIN/MAX,REFINE_J1_MIN_DELTA/MAX_DELTA,REFINE_J4_MIN/MAX,REFINE_MAX_LOOPS=80を導入横カメラ TIP 未検出時は j4 降下をスキップ
これで実機テスト可能な状態になった。
開発フローを書き直す
このセッションで判明した最大の教訓は、技術的なものじゃなかった。
人間でも「設計者=確認者」は御法度だ。設計したエンジニアと実装したエンジニアが2人で確認するチームは、同じバグを通す。AI も同じ。Claude が指示文を書き、Claude Code が実装し、Claude が「これでよさそう」と確認、Claude Code が「指示通り実装した」と確認。エコーチェンバーの中で「OK」が反射し合っているだけだった。
外部の目を必ず通す。それを運用ルールに昇格させた。
Master ドキュメントの AI ツール分担を以下に書き直した。
- Claude: 設計・戦略・ドキュメント・記事制作・レビュー指示文
- Claude Code: コード実装(Cline は使用しない)
- codex / gemini: コードレビュー(実装後の外部チェック)
- エイイチ: 最終判断・実機操作・目視確認加えて「開発ワークフロー(コード変更時)」というセクションを新設した。
設計:Claude が改修方針を策定、Claude Code 向け指示文を作成
実装:Claude Code が指示文に従ってコード変更
レビュー:codex または gemini に外部レビュー依頼
修正:❌指摘があれば Claude が修正指示を作成 → Claude Code 実装 → 再レビュー
実機テスト:daemon 起動 → スクリプト実行 → 結果共有
次セッションへ:成功/失敗を記事化、知見を memory に反映
物理的リスクがある変更(関節制御・姿勢系)は必ずレビューを挟む。記事・SNS出力のような単純なテキスト出力は不要。
レビュー依頼用のテンプレート code_review_request_template.md も汎用化した。タスクごとに { } プレースホルダーを埋めるだけで使える。チェック項目は A. ロジックの正しさ / B. 副作用・破壊チェック / C. 暴走・安全リスク / D. 制約ルールの遵守 の4分類。判定は ✅ / ⚠️ / ❌ で行番号付き。
締め
触ったコードの行数より、ワークフローの刷新。これが LOG200 の最大の収穫だ。
DESCEND を j4 単独制御に変えたことも、REFINE を係数式からプローブ式に転換したことも、いずれ別の姿勢領域で別の制約に当たる。一方、「思考の双子では同じ盲点を持つ」「外部の目を必ず通す」という運用ルールは、今後の全タスクに効き続ける。
掴めなかった悔しさを、そのままワークフローの強制力に叩き込んだ。
