mechArm 270 Pi 動的追従の限界を探る Log#13 | 降りてないアームを 横カメラに監視させる
DESCENDフェーズは Log#10 から続く宿題だった。視覚サーボで上カメラの xz が収束したあと、アームをマット高さまで降ろす。j2=-30 まで肘を伸ばせば爪先が下に届くはずだった。Log#12 で fk_scan_calibration.py が 18 姿勢分のキャリブを残してくれて、上カメラから見えない領域に横カメラがどう見ているかが、初めて 4 点だけ手元にあった。
本セッションの開始時点で visual_servo_pick.py の DESCEND は、target_j2 = -30.0 への一発送信から、毎ループ j2 -= 0.5° の段階送信に変えたところだった。実機で走らせると、DESCEND-STEP のログは確かに j2 が進んでいる。j2 = -21.85 → -26.87 まで進んで、上カメラ越しに「収束した」と判定される。STABLE+CONVERGED 把持に移行(2D dist=4.8mm) も出る。
ところが横で実機を見ていると、爪先はマットに届いていない。
横カメラ(iPhone 11 / Iriun)でアームを真横から見て、初めてそれが映る。アームは確かに動いている。j2 が進んだ分だけ姿勢が変わっている。しかし爪先の絶対的な高さは、ほとんど下がっていない。むしろ上に逃げる瞬間さえある。「命令通り動いているのに、降りていない」という奇妙な状態だ。
このセッションは、その「降りていなさ」を上カメラ側からではなく、横カメラ側から監視する仕組みを組み込んだ作業の記録だ。最後には Log#12 で取った 4 点のキャリブが、「j2 を浅くする」ではなく「j1 を増やす」が降下の本当の方向だと示すところまで進んだ。
💡 Key Takeaway
DESCENDで j2 を -0.5° ずつ進めても爪先がマット高さまで届かない問題に対し、横カメラの tip_pixel_y を「降りているか」の客観指標として制御ループに組み込んだ。さらに Log#12 のキャリブ 4 点から「j1=70→90 で side_py が 67→613 へ動く」ことが読み取れ、DESCEND の真の方向は j2 ではなく j1 であると確定。横カメラを観察用ログから判定用センサーに格上げし、no-progress 検出時に j2 モードから j1 モードへ切り替える二段制御を実装した。
発生した問題 | 現場判断 | 結果 |
|---|---|---|
DESCEND-STEP で j2 が進んでいるのに、横から見るとアームがマットまで降りていない | 上カメラの 2D 収束判定だけでは「降下しているか」がわからない。横カメラを判定に組み込む | tip_side_y を毎ループ取得し、初回値からの増加量で降下進捗を判定する設計に変更 |
横カメラのログは出ているが、ただ print しているだけで判定に使っていない | tip_side_y が +30px 増えたら DESCEND-DONE、5 フレーム見ても +10px 未満なら no-progress と確定 | 「監視員」が「センサー」に格上げ。降下の進捗が制御ループの内側で評価されるようになった |
j2 を浅くする方向では物理的に降下していなかった | Log#12 の fk_scan_calibration.csv の side=OK 4 点を読み直し、j1=70→90 で side_py=67→613 と確認 | DESCEND の方向を「j2 を浅くする」から「j1 を増やす」に転換。no-progress モードで j1 を +1.0° 進める設計に切り替え |
キャリブが残した 4 点が、横カメラを話せるようにした
Log#12 で fk_scan_calibration.py を走らせたとき、j1=70〜90 の前方姿勢では爪先の緑シールが横カメラ画角に入るが、j0=±37 の横向き姿勢ではアームが画角の端に逃げて取れなかった。結果として 18 姿勢中 4 点だけが side=OK で記録された。当時はサンプル数の少なさを「次セッションで PICK 姿勢付近を追加収集」とハンドオフに書いた。
本セッションで DESCEND が降りない問題に当たったとき、その 4 点を改めて読み直した。
pose | j0 | j1 | top_unity_z (m) | side_px (x, y) |
|---|---|---|---|---|
10 | 0 | 70 | 0.346 | (852, 67) |
11 | 0 | 80 | 0.335 | (842, 346) |
16 | 37 | 70 | 0.303 | (1734, 135) |
18 | 37 | 90 | 0.280 | (1686, 613) |
j0 を固定して j1 だけを増やす行を抜き出すと、こうなる。
j0=0: j1=70 → side_py= 67
j1=80 → side_py=346
j0=37: j1=70 → side_py=135
j1=90 → side_py=613横カメラの座標系は画像の上端が y=0、下に行くほど y が増える。side_py が増える=爪先が画面で下に動く=実機でマット方向に降りる、という対応関係になる。
j1 を 70 から 80 に上げると side_py が 67 から 346 に増える。j1 を 70 から 90 に上げると side_py が 135 から 613 まで増える。つまり「j1 を増やすと爪先がマット方向に動く」が、この 4 点だけで明確に出ている。
逆に j2 はどうか。Log#12 のキャリブで取った 18 姿勢は j1 と j0 の二次元グリッドで取っているため、j2 を独立に振った点は手元にない。ただ、DESCEND ループで実機を走らせている観測ログから、j2 = -21.85 → -26.87 で side_py がどう動いたかが取れている。
実機の実機ログから DESCEND-SIDE の系列を抜き出す。
[DESCEND-SIDE] tip_side=(326, 574) ← descend開始時
[DESCEND-STEP] #1 j2=-21.85 (Δ=-0.50) diff_x=+0.7 diff_z=+4.7
[DESCEND-SIDE] tip_side=(331, 589) ← +15
[DESCEND-STEP] #2 j2=-22.26 (Δ=-0.50) diff_x=+5.2 diff_z=+4.2
[DESCEND-SIDE] tip_side=(323, 589)
[DESCEND-STEP] #3 j2=-23.87 (Δ=-0.50) diff_x=+1.4 diff_z=+7.7
[DESCEND-SIDE] tip_side=(317, 560) ← -29(戻った)
[DESCEND-STEP] #4 j2=-25.63 (Δ=-0.50) diff_x=-2.3 diff_z=+6.2
[DESCEND-SIDE] tip_side=(303, 559)
[DESCEND-STEP] #5 j2=-26.13 (Δ=-0.50) diff_x=-5.3 diff_z=+3.5
[DESCEND-SIDE] tip_side=None
[DESCEND-STEP] #9 j2=-28.13 (Δ=-0.50) diff_x=-4.0 diff_z=-3.4
[DESCEND-SIDE] tip_side=(261, 508) ← -82(さらに戻った)
[DESCEND-STEP] #11 j2=-26.86 (Δ=-0.50) diff_x=-1.2 diff_z=-6.7
[DESCEND-SIDE] tip_side=(318, 505)開始時 574 から見て、j2 が 5° 進む間に side_py は 589 まで上がったあと、560、559、508、505 と「上に戻る」方向に動いている。j2 を浅くしても、横カメラから見ると爪先は降下していない。むしろ上方に逃げている。
これでようやく「j2 を浅くする = 肘を伸ばす = 爪先が下に出る」という幾何学的直感が、現在の姿勢領域では成り立っていないことが裏取りできた。Log#12 のキャリブ 4 点と、本セッションの実機ログ、2 つのデータから同じ結論が出ている。
上カメラには映らない情報
上カメラと横カメラ、それぞれの役割を整理する。
上カメラ(Continuity / iPhone 14 Pro Max)
・マット平面(X-Z 平面)の俯瞰
・blueシール 3 点 → TIP の XZ 座標が取れる
・homography で px → mm 変換可
横カメラ(Iriun / iPhone 11)
・マット平面を真横から見る
・greenシール 2 点 → TIP の (px_x, px_y) が取れる
・py が画像の高さ=アームの実高さ
・py↔Unity の変換式はまだ無い(pixel のまま)DESCEND 中に上カメラから見える情報は「TIP の XZ 座標が target_xz とどれだけずれているか」だけだ。爪先がマット平面に対してどの高さにあるかは、上カメラには映らない。homography はマット平面の平面座標への射影なので、Y 方向(マット面に垂直な方向)は最初から取れない。
これまでの DESCEND ロジックは、上カメラの XZ 収束を「降下完了」のシグナルにしていた。STABLE+CONVERGED 把持に移行(2D dist=4.8mm) が出れば、その時点で gripper を閉じる流れだった。だが「XZ が収束している」と「爪先がマットに届いている」は別の条件だ。
横カメラの py は、まさにこの上カメラから抜けている Y 方向の情報を持っている。Log#10 で iPhone 11 を物理固定し、Log#11 で検出パイプラインを上カメラと前カメラの 2 系統に分けたのは、この瞬間のためだった。横カメラはここで「監視員」として制御ループに入る権利がある。
横カメラのログを「センサー」に格上げする
最初にやったのは、DESCEND ループ内で横カメラを開いてフレームを取り、detect_tip_side() を呼んで結果をログ出力するだけのパッチだ。これは Log#12 で auto_detect_cameras.py と side_camera_utils.py を安定化した上に乗っているので、追加コードはこれだけで済む。
# 横カメラ(index=1)から1フレーム取得 → detect_tip_side
cap_side = cv2.VideoCapture(1)
tip_pixel_side = None
if cap_side.isOpened():
for _ in range(5):
cap_side.read()
ret_s, frame_side = cap_side.read()
cap_side.release()
if ret_s and frame_side is not None:
tip_pixel_side, _, _ = detect_tip_side(frame_side)
else:
cap_side.release()
print(f"[DESCEND-SIDE] tip_side={tip_pixel_side}")これを DESCEND ループの先頭に入れて実機を走らせると、tip_side のログが毎ループ流れる。冒頭で抜粋した実機ログは、ここで初めて取れたものだ。
ただし、この段階では tip_side は print しているだけで、制御の判断には使っていない。j2 を進める / 止める / モードを切り替える、いずれの判定にも反映されない。横で人間が見て「降りていないな」と気づくしかない。これは「監視員」ではなく「ただのログ係」だ。
DESCEND が物理的に降下しているかを制御ループの内側で評価するには、tip_side_y を判定の入力にする必要がある。やっていることはシンプルで、初回有効な tip_side_y を descend_start_y として記録し、以降は差分(delta_y)を見る。
3つの境界で降下を定義した。
descend_start_y = 初回 tip_side が取れたフレームの y
delta_y = tip_side_y - descend_start_y
if delta_y >= DESCEND_SUCCESS_PX (30):
→ DESCEND-DONE(降下成功、ループ break)
elif descend_mode == "j2"
and 有効フレーム数 >= DESCEND_NO_PROGRESS_FRAMES (5)
and delta_y < DESCEND_NO_PROGRESS_PX (10):
→ DESCEND-NO-PROGRESS(j2 モードでは降下していない、j1 モードに切替)DESCEND_SUCCESS_PX = 30 は、Log#12 のキャリブで j1=70 → 80 のとき side_py が 67 → 346 と 279 動いた数字から取った。10° の j1 変化で 279px なら、5px/° 程度の感度がある。30px は j1 を 6° 程度進めれば十分達成できる量で、しかも検出ノイズに対して明らかにマージンがある。
DESCEND_NO_PROGRESS_PX = 10 は、検出ノイズと小さな揺らぎを越える最低限の閾値。+10px に達しないまま 5 フレーム経ったら、その方向には降下していないと判定する。
DESCEND_NO_PROGRESS_FRAMES = 5 は、瞬間的な検出失敗や 1 フレームのノイズで誤切替しないためのバッファ。
横カメラの tip_side が None(検出失敗)のフレームはカウントしない。これは Log#11 で確定した検出パイプラインの設計(横向き姿勢では爪先が画角外、または手前のアームが緑シールを隠す)から来ている。検出できなかったフレームを「降下していない」と判定すると、ノイズで誤切替が頻発する。
モード切替で j2 から j1 へ
横カメラの判定が「no-progress」と確定したら、DESCEND の方向を切り替える。j2 を浅くするのをやめて、j1 を増やす方向で降下を続ける。
descend_mode という状態変数を追加して、ループ内のステップ更新ブロックをモード依存にした。
if descend_mode == "j1":
# no-progress モード: j1を+1.0度進めて降下。j2は停止
j1_step = min(DESCEND_J1_NO_PROGRESS_STEP_DEG,
DESCEND_J1_LIMIT - j1_cur)
j1_cur += j1_step
angles_changed = True
print(f"[DESCEND-STEP-J1] #{descend_loop+1} "
f"j1={j1_cur:+.2f} (Δj1=+{j1_step:.2f}) {diff_info}")
else:
# デフォルトモード: j2のみ進める
j2_cur -= DESCEND_STEP_J2_DEG
angles_changed = True
print(f"[DESCEND-STEP-J2] #{descend_loop+1} "
f"j2={j2_cur:+.2f} (Δj2=-{DESCEND_STEP_J2_DEG:.2f}) {diff_info}")j1 のステップ量は DESCEND_J1_NO_PROGRESS_STEP_DEG = 1.0 度。j2 の 0.5 度より粗いが、Log#12 のキャリブで「j1 を 10 度動かすと side_py が 279 動く」と分かっているので、5 度進めば DESCEND_SUCCESS_PX = 30 に余裕で到達する。1 度ずつ進めて 5〜6 ループで降下成功、を狙った設計だ。
安全装置として DESCEND_J1_LIMIT = 110.0 を置いた。memory に書いてある「j1 限界 135 度超過事故」を踏まえ、110 度で止める。110 度を超えるとループは [DESCEND-DONE] DESCEND J1上限到達 j1=+110.xx を出して break する。
XZ 補正(j0 と j1/j2/j4 連動)と RED:NG リカバリは、モードに関係なくループ内で毎回実行する。j1 モードに入っても、上カメラから赤ブロックが見えなくなれば j0 を ±3° ずつずらして再検出を試みる、というロジックは残っている。
DESCEND ループの全量コード
DESCEND ループ本体(visual_servo_pick.py L1106-1280 付近、main 関数内)を全量で残す。冒頭の状態変数からループ脱出後の post 観測まで、関連する範囲を 1 つの塊として読めるようにする。
DESCEND_STEP_J2_DEG = 0.5 # デフォルトのj2ステップ量(毎ループ実行)
DESCEND_MAX_LOOPS = 30
DESCEND_J1_LIMIT = 110.0 # j1の絶対上限(安全マージン)兼 降下成功条件
DESCEND_J1_NO_PROGRESS_STEP_DEG = 1.0 # no-progressモードのj1ステップ量
DESCEND_NO_PROGRESS_FRAMES = 5 # この回数有効フレームで降下不足ならモード切替
DESCEND_NO_PROGRESS_PX = 10 # 降下判定の閾値(px)
DESCEND_SUCCESS_PX = 30 # 降下成功の閾値(px)
target_j2 = DESCEND_J2_DAY185
# 横カメラY追従用の状態
descend_start_y = None # 初回有効 tip_side_y
side_y_valid_count = 0 # tip_side が取れた有効フレーム数
descend_mode = "j2" # "j2"(default) or "j1"(no-progress時)
# RED:NG 連続時の j0 探索リカバリ用カウンタ
RED_NG_TRIGGER = 5 # 連続NGがこの回数に達したらずらす
RED_NG_MAX_NUDGE_COUNT = 8 # ずらし最大回数(+3,-3,+6,-6,+9,-9,+10,-10)
red_ng_count = 0
recovery_nudge_count = 0
search_origin_j0 = None # 探索開始時の j0_cur
for descend_loop in range(DESCEND_MAX_LOOPS):
# 横カメラ(index=1)から1フレーム取得 → detect_tip_side
cap_side = cv2.VideoCapture(1)
tip_pixel_side = None
if cap_side.isOpened():
for _ in range(5):
cap_side.read()
ret_s, frame_side = cap_side.read()
cap_side.release()
if ret_s and frame_side is not None:
tip_pixel_side, _, _ = detect_tip_side(frame_side)
else:
cap_side.release()
# 横カメラY追従ロジック(成功判定 / no-progress モード切替)
descent_complete = False
if tip_pixel_side is not None:
if descend_start_y is None:
descend_start_y = tip_pixel_side[1]
side_y_valid_count = 1
print(f"[DESCEND-SIDE] start_y={descend_start_y} (baseline記録)")
else:
side_y_valid_count += 1
delta_y = tip_pixel_side[1] - descend_start_y
if delta_y >= DESCEND_SUCCESS_PX:
print(f"[DESCEND-DONE] tip_side_y +{delta_y}px 増加で降下成功")
descent_complete = True
elif (descend_mode == "j2"
and side_y_valid_count >= DESCEND_NO_PROGRESS_FRAMES
and delta_y < DESCEND_NO_PROGRESS_PX):
descend_mode = "j1"
print(f"[DESCEND-NO-PROGRESS] tip_side_y not increasing, switching to j1+")
print(f"[DESCEND-SIDE] tip_side={tip_pixel_side} "
f"delta_y={delta_y:+d} count={side_y_valid_count} mode={descend_mode}")
else:
print(f"[DESCEND-SIDE] tip_side=None (skip)")
if descent_complete:
break
# フレーム取得(2枚捨て+1枚読み、追従ループと同じ流儀)
for _ in range(2):
cap.read()
ret_d, frame_d = cap.read()
if not ret_d:
time.sleep(LOOP_INTERVAL_SEC)
continue
# 赤ブロック + TIP 検出
red_pixel_d = detect_red_block(frame_d)
tip_pixel_d, _, tip_unity_d = detect_tip_from_frame(frame_d)
diff_x_mm_d = None
diff_z_mm_d = None
if red_pixel_d is not None and tip_unity_d is not None:
rx_unity, rz_unity = pixel_to_unity(red_pixel_d[0], red_pixel_d[1])
red_x_mm_d = rx_unity * 1000
red_z_mm_d = rz_unity * 1000
tip_x_mm_d = tip_unity_d[0] * 1000
tip_z_mm_d = tip_unity_d[1] * 1000
diff_x_mm_d = red_x_mm_d - tip_x_mm_d
diff_z_mm_d = red_z_mm_d - tip_z_mm_d
# RED:NG 連続時のj0探索リカバリ
if red_pixel_d is None:
red_ng_count += 1
if red_ng_count >= RED_NG_TRIGGER and recovery_nudge_count < RED_NG_MAX_NUDGE_COUNT:
if search_origin_j0 is None:
search_origin_j0 = j0_cur
# 探索パターン: +3, -3, +6, -6, +9, -9, +10, -10
magnitude = min(10.0, 3.0 * (recovery_nudge_count // 2 + 1))
sign = 1 if recovery_nudge_count % 2 == 0 else -1
target_offset = magnitude * sign
j0_cur = search_origin_j0 + target_offset
recovery_nudge_count += 1
red_ng_count = 0
print(f"[RED-SEARCH] #{recovery_nudge_count} "
f"j0={j0_cur:+.2f} (探索offset={target_offset:+.1f}°)")
else:
if recovery_nudge_count > 0:
print(f"[RED-RECOVERED] j0={j0_cur:+.2f} (探索結果から復帰)")
red_ng_count = 0
recovery_nudge_count = 0
search_origin_j0 = None
# XZ補正とDESCEND(j2進め)を毎ループ両方実行
angles_changed = False
# XZ補正(閾値超過分のみ)
if diff_x_mm_d is not None and abs(diff_x_mm_d) > TRACK_DEADBAND_MM:
delta_deg = diff_x_mm_d / MM_PER_DEG_J0_X
delta_deg = max(-MAX_DELTA_DEG, min(MAX_DELTA_DEG, delta_deg))
j0_cur += delta_deg
angles_changed = True
if diff_z_mm_d is not None and abs(diff_z_mm_d) > TRACK_DEADBAND_Z_MM_DAY189:
reach_delta = diff_z_mm_d / MM_PER_DEG_ELBOW_Z_DAY189
reach_delta = max(-MAX_DELTA_ELBOW_DEG_DAY189,
min(MAX_DELTA_ELBOW_DEG_DAY189, reach_delta))
j1_cur -= reach_delta
j2_cur -= reach_delta
j4_cur += reach_delta
angles_changed = True
# DESCEND継続(モード依存:j2デフォルト or j1 no-progress)
if j1_cur >= DESCEND_J1_LIMIT:
print(f"[DESCEND-DONE] DESCEND J1上限到達 j1={j1_cur:+.2f}")
break
diff_info = (f"diff_x={diff_x_mm_d:+.1f} diff_z={diff_z_mm_d:+.1f}"
if diff_x_mm_d is not None else "diff=N/A")
if descend_mode == "j1":
# no-progress モード: j1を+1.0度進めて降下。j2は停止
j1_step = min(DESCEND_J1_NO_PROGRESS_STEP_DEG,
DESCEND_J1_LIMIT - j1_cur)
j1_cur += j1_step
angles_changed = True
print(f"[DESCEND-STEP-J1] #{descend_loop+1} "
f"j1={j1_cur:+.2f} (Δj1=+{j1_step:.2f}) {diff_info}")
else:
# デフォルトモード: j2のみ進める
j2_cur -= DESCEND_STEP_J2_DEG
angles_changed = True
print(f"[DESCEND-STEP-J2] #{descend_loop+1} "
f"j2={j2_cur:+.2f} (Δj2=-{DESCEND_STEP_J2_DEG:.2f}) {diff_info}")
if angles_changed:
daemon_send_angles(
[j0_cur, j1_cur, j2_cur,
APPROACH_PICK_BASE_DAY185[3], j4_cur, APPROACH_PICK_BASE_DAY185[5]],
wait=0,
)
time.sleep(LOOP_INTERVAL_SEC)
else:
print(f"[DESCEND-TIMEOUT] {DESCEND_MAX_LOOPS}ループ到達 "
f"j2={j2_cur:+.2f} (目標 {target_j2:+.2f})")XZ 補正、RED:NG リカバリ、DESCEND(モード依存)。3 つの並行制御を走らせつつ、横カメラの「降下成功」を最優先の割り込みとして置いた。descent_complete = True でその場で他を全部抜けて break する。
試行錯誤の道のり
ここまでの完成形に至るまで、ループは 5 回書き換えている。各バージョンで実機を走らせ、ログを見て次の修正を決めた。
最初は target_j2 = -30.0 への一発送信だった。XZ 収束のあと daemon_send_angles([j0, 87.74, -30.0, ...], wait=2) を 1 回だけ送る。これだと j2 が一気に動く分、途中で XZ がずれても補正できない。XZ がずれたまま降下が完了し、爪先がブロックの横に着地する。
次に DESCEND を毎ループ XZ 追従付きの段階送信に変えた。target_j2 に向かって毎ループ j2 を更新するが、XZ 閾値外なら DESCEND を一時停止して XZ 補正に専念する設計。これは「XZ がずれたら降下を止めて補正する」が直感的に思えた。
しかし実機で走らせると、XZ 補正中に何度も DESCEND が止まり、進むペースが極端に遅い。さらに XZ 閾値内に戻った瞬間に大きく j2 を動かすので、姿勢の急変で再び XZ がずれる。振動しながら遅く進む、最悪の挙動になった。
「止まるな、細かく動かせ」という指示で、xz_out_of_range の分岐を廃止し、XZ 補正と DESCEND ステップを毎ループ両方実行する設計に変えた。DESCEND_STEP_J2_DEG = 2.0 → 0.5 も同時に入れた。
実機で走らせると、確かに j2 は止まらずに進む。j2 = -21.85 → -26.87 まで 0.5° ずつ進んだ。STABLE+CONVERGED 把持に移行(2D dist=4.8mm) も出た。それでもアームは降りていない。
ここで実機を横から見て、「降りてないアームを横カメラに監視させて」という指示が入った。
最初に入れたのは print だけのパッチだ。cv2.VideoCapture(1) を開いて 1 フレーム読み、detect_tip_side() を呼んで tip_side=(x, y) をログ出力する。これだけで「横カメラから見ると爪先がどう動いているか」が可視化される。
実装して走らせたとき、横カメラの side_camera_utils.py が auto_detect_cameras.py から _count_blue_markers, _count_green_markers を import している都合で、循環 import で起動エラーになった。auto_detect_cameras.py 側で from visual_servo_pick import detect_blue_markers をコメント根拠として残していたのが原因で、これを「HSV閾値は visual_servo_pick.detect_blue_markers に準拠」というコメントに置き換えて解消した。
再実行して取れたのが、冒頭で抜粋した DESCEND-SIDE ログだ。j2 が進む間に side_py が 574 → 589 → 560 → 559 → 508 → 505 と動いていた。明らかに降下していない。むしろ上に逃げている。
ログを見て「横カメラを print から判定に格上げする」設計に進み、tip_side_y を descend_start_y と比較する仕組みを入れた。これが完成版だ。
DESCEND-SIDE ログから読める姿勢学
DESCEND-SIDE ログには姿勢領域の境界が出ている。
start_y=574
+15 (j2=-21.85後)
+15 (j2=-22.26後)
-14 (j2=-23.87後) ← 戻りはじめ
-15 (j2=-25.63後)
-66 (j2=-28.13後) ← 大きく戻った
-69 (j2=-26.86後)最初の 2 ステップ(j2=-21.85〜-22.26)は side_py が +15 増えている。j2 を浅くする初期では、わずかに降下する。
しかし 3 ステップ目(j2=-23.87)から side_py は戻りはじめる。j2 が -24 を超えたあたりから、爪先は上方に逃げ始める。これは mechArm の幾何学で、特定の姿勢領域を超えると j2 の効果が反転する境界があることを示している。
memory には「DESCEND は『j1 を増やす』ではなく『j2 を浅くする』アプローチで実装(j1 限界 135 度超過事故あり)」と書かれていた。これは過去の事故から来た安全側の判断で、その時点では合理的だった。だが、j2 を浅くする方向は、特定の姿勢領域でしか降下効果を持たない。
APPROACH_PICK_BASE_DAY185 = [None, 87.74, -20.0, 3.6, -90.0, -30.0] から DESCEND に入る今回の構成では、j1=87.74 が高すぎて j2 だけでは降下できない。j1 を増やす方向に切り替えると、Log#12 のキャリブが示した通り、確実に降下する。
ただし j1 を 110 度の上限まで使い切ると、それでも届かない可能性がある。Log#10 で iPhone 11 を物理固定したあと、Log#11 でアームの届く範囲を再確認した時点で、PICK 領域は j1=90〜95 度付近に集中していることがわかっていた。j1=110 まで使えば理論的にはマット高さに届くはずだが、姿勢の安全性(前傾しすぎないこと)との両立は次セッションの検証項目になる。
j1 への転換
DESCEND の真の方向が決まった、というのが一番大きい。「j2 を浅くして肘を伸ばす」から「j1 を増やして上腕を傾ける」への転換。memory に書いてあった過去の方針を上書きする結論で、しかも実機ログと Log#12 のキャリブの両方から裏取りされている。
そして、横カメラが「監視員」から「センサー」になった。Log#10 で物理固定し、Log#11 で検出パイプラインを分割し、Log#12 でカメラ index 検出を安定化させた。3 つの Log を積み上げて、初めて横カメラが制御ループの判定入力に立った。
完成形のループは次のような状態だ。
DESCEND が「降下しているか」を上カメラの XZ ではなく、横カメラの py で評価する
j2 モードで 5 フレーム見て降下不足なら、j1 モードに自動切替
どちらのモードでも XZ 補正と RED:NG リカバリは並行して動く
j1 上限 110° で安全停止、または py が +30px 増えれば降下成功
実機で完走した DESCEND は、まだない。本セッションの最後の試行では、j2 モードから j1 モードへの切替判定が動くところまでは確認できた。次セッションでは、j1 モードに入ったあと実際にマット高さまで到達するかを実機で見る。そこで初めて、Log#4 から続いてきた「STABLE+CONVERGED 把持に移行」のあとの「掴めた」を、次のシリーズで書けるかどうかが決まる。
