mechArm 270 Pi 動的追従の限界を探る Log#25 | 降下は通った。だが個人ロボティクスは本題より「カメラが何番か」で時間が溶ける
把持したブロックを白箱の上まで降ろす動作が、実機で通った。段階降下も再送制御も設計どおり動いた。降下そのものは短時間で済んだ。それより長くかかったのは「カメラが今日は何番で繋がっているか」を探すことだった。前進と足踏みが同居した記録を残す。
💡 Key Takeaway
PLACE 動作の降下(DESCEND)が実機で成立した。白箱の上空 120mm から 100mm へ、段階降下・到達確認・限定再送・HOLD 停止、すべて設計どおりに動いた。だが二つの足場の弱さが露わになった。一つは位置精度で、上カメラだけで水平位置を決めると白箱の手前にずれてブロックが箱に入らない。もう一つはカメラの安定性で、Continuity Camera と Iriun の接続が変わるたびに OpenCV のカメラ番号が入れ替わり、本題に入る前に番号探しで時間が溶ける。後者は、カメラを番号ではなく固有名で引く仕組みを入れて根本から断った。個人で物理 AI を動かすとき、難所は本題そのものの論理よりも、そこへたどり着くまでの足場にある。
降下は設計どおり動いた
把持したブロックを白箱の上へ降ろす。これが PLACE 動作の中盤にあたる。降下のスクリプトは前回までに組んであり、外部レビューも通していた。実機で動かすと、降下そのものは素直に通った。
白箱の上面は 45mm。その上にクリアランスを置き、把持したブロックの下面をそこへ運ぶ。エンドエフェクタの高さ(ee_y)でいうと、上空の 120mm から離す直前の 100mm へ、10mm 刻みで 2 段に分けて降ろす。一段ごとに到達を実測で確認し、未到達なら同じ目標を一度だけ送り直す。降下は接触の危険がある側なので、過去の水平移動で許していた「最大 3 回の再送」は持ち込まず、再送は一度きりにして、それでも届かなければその場で止める設計にした。
実機ログを残す。白箱の位置を変えた二回ぶんを順に載せる。一回目は最終段で止まり、二回目は最後まで降りた。なぜ一回目が止まったかは、このあと説明する。
一回目(途中で安全に中断した回)の全ログ:
[DESCEND] 15_descend_move を import して降下を開始します
[INIT] DESCEND 起点
[INIT] approach_measured = [18.98, 66.97, -12.65, -20.74, -66.00, -29.70]
[INIT] X_app=-0.134m Z_app=+0.258m ee_y=+0.120m (APPROACH)
[INIT] 降下段 ee_y = [0.11, 0.1] m
[LOG] /Users/shimazueiichi/r2r2r-scripts/logs/descend_log_20260602_183303.csv
[DESCEND[0] ee_y=0.110m] target_xyz = (-0.134, +0.110, +0.258)
[DESCEND[0] ee_y=0.110m] LOOKUP 512087 点 カラム不一致 0 件 到達不可で除外 382269 件
[DESCEND[0] ee_y=0.110m] 最近傍 dist=23.3mm ee_y_n=+0.102m joints=[19.00, 69.91, -14.14, -20.08, -66.27, -29.44]
[DESCEND[0] ee_y=0.110m] j5 を -30 に固定 → sent_angles=[19.00, 69.91, -14.14, -20.08, -66.27, -30.00]
[DESCEND[0] ee_y=0.110m] send_angles [19.00, 69.91, -14.14, -20.08, -66.27, -30.00]
[DESCEND[0] ee_y=0.110m] [WAIT] t=0.5s max_err=1.40°
[DESCEND[0] ee_y=0.110m] 到達
[DESCEND[1] ee_y=0.100m] target_xyz = (-0.134, +0.100, +0.258)
[DESCEND[1] ee_y=0.100m] LOOKUP 512087 点 カラム不一致 0 件 到達不可で除外 382269 件
[DESCEND[1] ee_y=0.100m] 最近傍 dist=20.7mm ee_y_n=+0.080m joints=[40.00, 65.17, 1.63, 27.90, -70.57, 0.00]
[DESCEND[1] ee_y=0.100m] [ABORT] 最近傍の高さが低すぎます: ee_y_n=+0.080 < +0.085 (目標 +0.100 の下振れ 15mm 超)
[ABORT] 降下中断。グリッパーは閉じたまま、追加送信なしで終了。HOME 復帰は実行しません。目視判断に渡します最初の段(110mm)は試行一回で到達した。問題は二段目だ。目標は 100mm なのに、テーブルから引けた一番近い姿勢の高さが 80mm まで落ちていた。15mm を超える下振れだったので、安全装置が「これ以上低い姿勢は送らない」と判断して止めた。狙いより深く降りる姿勢を物理的に弾く仕組みが、想定どおり働いた。
二段目で引けた姿勢の関節角を、一段目と並べてみる。
1段目 [19.00, 69.91, -14.14, -20.08, -66.27, -29.44] ← 素直
2段目 [40.00, 65.17, 1.63, 27.90, -70.57, 0.00] ← 腕全体が別の形一段目は肩を少し倒して素直に降りる姿勢。二段目は肘と手首が大きく捻れた、まったく別の腕の形になっている。これは「この白箱の真上・この高さ」を満たす素直な姿勢が、姿勢テーブルにほとんど無かったからだ。距離だけで一番近い点を引いた結果、たまたま近いだけの捻れた姿勢が選ばれ、しかもそれが低すぎた。安全装置はその捻れた低い姿勢を送る前に止めた。
白箱を少し動かして二回目を走らせると、今度は二段目も素直な姿勢が引けて、最後まで降りた。
二回目(最後まで降りた回)の全ログ:
[DESCEND] 15_descend_move を import して降下を開始します
[INIT] DESCEND 起点
[INIT] approach_measured = [22.93, 66.97, -12.65, -24.78, -64.16, -29.70]
[INIT] X_app=-0.158m Z_app=+0.250m ee_y=+0.120m (APPROACH)
[INIT] 降下段 ee_y = [0.11, 0.1] m
[LOG] /Users/shimazueiichi/r2r2r-scripts/logs/descend_log_20260602_183726.csv
[DESCEND[0] ee_y=0.110m] target_xyz = (-0.158, +0.110, +0.250)
[DESCEND[0] ee_y=0.110m] LOOKUP 512087 点 カラム不一致 0 件 到達不可で除外 382269 件
[DESCEND[0] ee_y=0.110m] 最近傍 dist=19.4mm ee_y_n=+0.102m joints=[23.00, 69.91, -14.14, -20.08, -66.27, -29.44]
[DESCEND[0] ee_y=0.110m] j5 を -30 に固定 → sent_angles=[23.00, 69.91, -14.14, -20.08, -66.27, -30.00]
[DESCEND[0] ee_y=0.110m] send_angles [23.00, 69.91, -14.14, -20.08, -66.27, -30.00]
[DESCEND[0] ee_y=0.110m] [WAIT] t=3.2s max_err=4.17°
[DESCEND[0] ee_y=0.110m] [WAIT] t=8.5s max_err=4.17°
[DESCEND[0] ee_y=0.110m] 未到達 max_err=4.17°。1回だけ再送する
[DESCEND[0] ee_y=0.110m] [WAIT] t=0.5s max_err=1.49°
[DESCEND[0] ee_y=0.110m] 到達
[DESCEND[1] ee_y=0.100m] target_xyz = (-0.158, +0.100, +0.250)
[DESCEND[1] ee_y=0.100m] LOOKUP 512087 点 カラム不一致 0 件 到達不可で除外 382269 件
[DESCEND[1] ee_y=0.100m] 最近傍 dist=17.3mm ee_y_n=+0.100m joints=[23.00, 69.91, -14.14, -24.08, -66.27, -29.44]
[DESCEND[1] ee_y=0.100m] j5 を -30 に固定 → sent_angles=[23.00, 69.91, -14.14, -24.08, -66.27, -30.00]
[DESCEND[1] ee_y=0.100m] send_angles [23.00, 69.91, -14.14, -24.08, -66.27, -30.00]
[DESCEND[1] ee_y=0.100m] [WAIT] t=0.5s max_err=1.49°
[DESCEND[1] ee_y=0.100m] 到達
[HOLD] 最終段 ee_y=0.100m に到達。グリッパー閉じたまま保持。目視確認に渡します。(RELEASE / RETREAT / HOME 復帰は別ステップ)一段目で一度、目標に届かず止まる場面があった。誤差 4.17° のまま動かなくなり、再送を一度かけたら今度は通った。送信が低い確率ですり抜ける現象は前から知っていて、それを吸収するための再送が、ちょうど効いた。二段目は一発で到達し、離す直前の高さで停止した。
降下の仕組みとしては、これで意図どおりに動いている。段ごとに高さを刻み、到達を実測で確かめ、危険な姿勢は送る前に弾き、止まるべきときは止まる。降下は通った。
だが、上から見ているだけでは箱に乗らない
降下が止まった姿勢を、横から撮った。爪の先が白箱の上面より下まで来ているのに、箱の手前の縁にかぶっている。このまま離せば、ブロックは箱の中ではなく手前のへりか外に落ちる。原因は、降下の水平位置(左右と奥行き)を上カメラだけで決めていることだ。上から見れば左右はよく分かる。けれど奥行き、つまり手前と奥の距離は、上から見るだけでは詰めきれない。横からの距離を測る仕組み(横カメラの座標変換)はまだ入れていないので、奥行きが白箱の中心からずれたまま降りてしまう。
ここで設計の役割分担を決め直した。横カメラには、水平位置を合わせる仕事をさせない。横カメラは「離してよい高さに来ているか」の判定だけを担う関所にする。水平位置を合わせるのは上カメラの仕事で、しかも合わせるタイミングは「降りる前」ではなく「離す直前の低い高さまで降ろしてから」にする。高い位置で合わせても、降りる途中で関節の動きにつれて位置が流れるからだ。低い位置で、把持したブロックの中心と白箱の中心を、上カメラの平面で重なるまで少しずつ補正する。
この位置合わせは一度の補正では決まらない。白箱がどこにあるかで、寄せる向きも量も変わる。だから視覚フィードバックのループで詰める。掴んでいるブロックを動かして固定された白箱へ寄せる形になり、これまで作ってきた「アームを固定された対象へ寄せる」向きとは可動側と目標が入れ替わる。寄せる向きの符号を取り違えると逆走するので、ここは机上で決めず、実機で実際に少しだけ動かして「ブロックが白箱に近づく向きか」を測ってから本番に入る設計にした。位置合わせの本体と、その係数を測る較正モードまでは組み終えている。係数のうち横方向は実測で取れた。奥行き方向はまだ取りきれていない。これは次の課題として持ち越す。
ここまでが、降下が通ったうえで見えた一つ目の足場、位置精度の話だ。
本題より「カメラが何番か」で時間が溶ける
その日、時間の大半を奪ったのは降下でも位置合わせでもなかった。カメラが今どの番号で繋がっているかを探すことだった。
この環境では上カメラに iPhone を Continuity Camera として、横カメラに別の iPhone を Iriun という別経路で使っている。OpenCV はカメラを 0、1、2 という番号で開く。ところがこの番号は、接続しているカメラの台数や種類が変わるたびに総入れ替えになる。Continuity の iPhone がスリープで一度切れると番号が消え、復帰すると別の番号で戻る。横の iPhone が Continuity と Iriun の両方で二重に出てしまい、同時に二台の Continuity は使えないという OS の制約に引っかかったりもする。
実際、その日のあいだに、繋がっているカメラは四台になったり三台になったり二台になったりした。番号で「上カメラは 3 番」と決めて起動すると、起動した時には 3 番が居なくて止まる。番号を撮り直して中身を見て、上から写っているのはどれか、横はどれか、内蔵カメラはどれかを毎回仕分ける。これを何度も繰り返した。降下のスクリプト自体は正しく動くのに、そこへたどり着く前のカメラ探しで時間が溶けていく。
ここで考え方を変えた。番号ではなく、カメラの固有名で引けばいい。番号は接続順で入れ替わるが、名前は変わらない。Mac に繋がっているカメラの名前を列挙したら、こう出た。
index=0 name=FaceTime HDカメラ ← 内蔵(除外)
index=1 name=Iriun Camera ← 横カメラ
index=2 name=iPhone14 Pro MAXのカメラ ← 上カメラ(Continuity)番号は変わっても、この名前は変わらない。上カメラは名前に iPhone を含むもの、横カメラは Iriun を含むもの、内蔵は FaceTime を含むので除外する。名前から番号を引く小さな共通モジュールを書いた。
"""
camera_resolver.py
カメラを OpenCV の index 番号ではなく「固有名」で特定する共通モジュール。
背景:
AVFoundation(macOS) の index は、接続台数や Continuity の接続/切断で
毎回入れ替わる。番号で指定すると繋ぎ直すたびにズレる。
各カメラは固有の名前を持つ(番号が変わっても名前は不変)ので、
名前でindexを引けば番号変動に振り回されない。
"""
import cv2
try:
from cv2_enumerate_cameras import enumerate_cameras
_HAS_ENUM = True
except ImportError:
_HAS_ENUM = False
# ---- 名前キー(部分一致・大文字小文字無視)---------------------------------
TOP_NAME_KEYS = ["iphone"]
SIDE_NAME_KEYS = ["iriun"]
EXCLUDE_NAME_KEYS = ["facetime"]
def list_cameras():
"""接続中カメラの一覧を返す。各要素は dict {index, name, path}。"""
if not _HAS_ENUM:
return []
cams = []
for c in enumerate_cameras(cv2.CAP_AVFOUNDATION):
cams.append({"index": c.index, "name": c.name or "", "path": c.path or ""})
return cams
def _name_matches(name, keys):
low = name.lower()
return any(k.lower() in low for k in keys)
def find_camera_index(name_keys, cams=None):
"""name_keys のいずれかを名前に部分一致で含むカメラの index を返す。
EXCLUDE_NAME_KEYS に該当するものは候補から除外。見つからなければ None。"""
if cams is None:
cams = list_cameras()
matched = []
for c in cams:
name = c["name"]
if _name_matches(name, EXCLUDE_NAME_KEYS):
continue
if _name_matches(name, name_keys):
matched.append(c)
if not matched:
return None
matched.sort(key=lambda c: c["index"])
return matched[0]["index"]
def resolve_cameras(require_top=True, require_side=True, verbose=True):
"""上カメラ(iPhone Continuity)と横カメラ(Iriun)の index を名前から解決する。
戻り値: {"top": int or None, "side": int or None, "all": [...]}
"""
cams = list_cameras()
if not cams:
raise RuntimeError(
"カメラを列挙できません。cv2-enumerate-cameras 未インストールか、"
"カメラが1台も繋がっていません。"
"pip install cv2-enumerate-cameras を確認してください"
)
if verbose:
print("[CAM] 接続中カメラ一覧:")
for c in cams:
print(f" index={c['index']} name={c['name']}")
top = find_camera_index(TOP_NAME_KEYS, cams)
side = find_camera_index(SIDE_NAME_KEYS, cams)
if verbose:
print(f"[CAM] 上カメラ(iPhone) index={top}")
print(f"[CAM] 横カメラ(Iriun) index={side}")
if require_top and top is None:
raise RuntimeError(
"上カメラ(iPhone Continuity)が見つかりません。"
"iPhone のロックを解除し、連係カメラをオンにして接続してください"
"(自動ロックを『なし』にすると寝て落ちるのを防げます)"
)
if require_side and side is None:
raise RuntimeError(
"横カメラ(Iriun)が見つかりません。"
"Iriun Webcam アプリを起動して接続してください"
)
return {"top": top, "side": side, "all": cams}動かすと、番号がどう入れ替わっていても名前から正しく引けた。
[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
top (iPhone) -> index 2
side (Iriun) -> index 1これで番号探しから解放された。スクリプト側は起動時にこの解決を一度走らせれば、その日の番号が何であっても上と横を正しく掴む。番号を手で指定する作業が消えた。
二つの足場の話
降下というやりたいことの本体は、実は素直に通った。段階的に降ろし、到達を確かめ、危険を弾き、止まるべきときに止まる。設計どおりだった。
時間を奪ったのは、動作ロジックの本体ではなく、カメラの接続という周辺の足場だった。降りた先で箱に乗るかという位置精度。そして、そもそもカメラが何番で繋がっているかという接続の安定性。前者は横からの距離を測る仕組みを次に入れる。後者は名前で引く仕組みを入れて、その場で断った。
個人で物理 AI を動かしていると、難しいのは中心の論理よりも、その論理にたどり着くまでの足場だ。カメラが毎回違う番号で繋がる。接続が途中で切れる。掴んだはずのものが落ちている。本筋とは別のところで時間が溶けていく。だから足場を先に固める。番号で毎回つまずくなら、番号に依存しない仕組みに変える。その場しのぎで番号を探し直すのではなく、二度と探さなくていい形にする。足場を一つずつ仕組みに変えていくことが、毎日続けられる開発の土台になる。降下が通った日に、足場の弱さが二つ見えた。一つはその日のうちに塞いだ。
