mechArm 270 Pi 動的追従の限界を探る Log#12 | なぜアームが動かないのか カメラとdaemonの干渉を追った
デザイナーがロボットを触ると、見えてくるものがある。LOG197で見えたのはこれ。
横カメラのpixel座標とUnity座標の対応関係を実データで取ることがこのセッションの目標だった。横カメラのキャリブレーションデータがあれば、「横カメラのpixelがどの実座標に対応するか」という変換式(B)が作れる。変換式ができれば、視覚サーボループに横カメラのZ・Y方向観測値を組み込める(C)。これがデュアルカメラ統合の最後のインフラ工事だ。
そのためにFKスキャンで60姿勢を回し、各姿勢で上カメラのpixel・Unity座標と横カメラのpixelを同時記録するスクリプト fk_scan_calibration.py を新規作成して実行に臨んだ。
スクリプトは動いた。でもアームが動かなかった。
コードはエラーなく通っている。daemonは {"ok": true} を返している。スキャンログには top=OK が流れている。それでもアームは1姿勢目から最後まで同じ場所に固まったままだった。
「なぜ動かないのか」を1つずつ切り分けた。pymycobot直接では動く。daemon経由でも動く。カメラなしのループでも動く。カメラとdaemonを組み合わせたときだけ止まる。
原因はロボット制御の問題ではなかった。cv2.VideoCapture() の実行中にmacOSのAVFoundationが処理を占有して、daemonとのTCPソケットが応答できなくなっていた。カメラがdaemonを殺していた。
キャリブCSVは1行も取れなかった。副産物として auto_detect_cameras.py の安定化と横カメラのTIP検出改善が進んだ。原因は確定した。解決策は次セッションに持ち越す。
💡 Key Takeaway
キャリブデータ収集スクリプトでアームが動かなかった原因は、cv2.VideoCapture() の実行中にdaemonとのTCP接続が切れることだった。pymycobot直接では動く、daemon直接では動く、カメラなしループでも動く——カメラとdaemonの共存だけが止まる。この切り分けで問題の輪郭が確定した。
発生した問題 | 現場判断 | 結果 |
|---|---|---|
スキャン中にアームが全く動かない | カメラなし→動く、daemon直接→動く、pymycobot直接→動く の順で切り分け | カメラ取得処理がdaemonのTCP接続を殺していることが確定 |
Continuity Camera keep-openで同じフレームが返り続ける | mean値テストで133.25が5回連続一致。macOS AVFoundationとOpenCVの相性問題と判断 | 都度open/closeに戻すが、根本のdaemon干渉は解消せず |
| warmup_framesを30→120、cap.release()後にtime.sleep(0.5)追加 | 3回連続実行で全て安定。D完了 |
キャリブデータ収集の設計意図
前セッションまでで横カメラのTIP検出インフラは揃っていた。auto_detect_cameras.py でカメラindexを自動判定し、detect_tip_side() で緑シール2点の中点からTIPを取る。
残っていたのはAの作業——横カメラのpixel座標とUnity座標の対応関係を実データで取ること。FKスキャンで60姿勢を回し、各姿勢で上カメラのpixel・Unity座標と横カメラのpixelを同時記録したCSVを作る。このCSVがあれば横カメラのpixel→Unity座標変換式(Bの作業)に進める。
新規スクリプト fk_scan_calibration.py を作成した。
D: auto_detect_cameras.py の安定化
スキャンを走らせる前に、まずDから片付けた。auto_detect_cameras.py の連続実行が約2/3の頻度でRuntimeErrorを出す問題だ。
原因はIriun + macOSの自動露出リセット + warmupフレーム不足。修正点は3箇所。
warmup_frames のデフォルト値を30から120に変更。cap.release() 直後に time.sleep(0.5) を2箇所追加。各インデックスの試行間に time.sleep(0.3) を追加。
修正後の関数定義と該当ループ:
def auto_detect_cameras(
candidate_indices: list = None,
warmup_frames: int = 120, # 30 → 120 に変更
save_debug_frames: bool = True,
debug_dir: str = "logs/debug_frames",
) -> dict:
...
for idx in candidate_indices:
...
cap = cv2.VideoCapture(idx)
if not cap.isOpened():
all_results.append(entry)
cap.release()
time.sleep(0.5) # 追加
continue
entry["opened"] = True
for _ in range(warmup_frames):
cap.read()
ret, frame = cap.read()
cap.release()
time.sleep(0.5) # 追加
...
all_results.append(entry)
time.sleep(0.3) # 追加3回連続実行の結果:
上カメラ index: 2
横カメラ index: 1
index 0: exclude (blue=0, green=0, opened=True)
index 1: side (blue=2, green=2, opened=True)
index 2: top (blue=5, green=0, opened=True)
index 3: skipped (blue=0, green=0, opened=False)3回とも同じ結果。Dはここで片付いた。
横カメラ: 緑シール検出の改善
最初のスキャン実行で横カメラのside=MISSが全姿勢で続いた。logs/calib_frames/ のannotated画像を確認すると、緑の検出点がモニターのベゼル付近にあり、爪の緑シールは検出されていなかった。
爪の角度で緑シールが影になり、HSV閾値から漏れていた。光が当たる確率を単純に上げるため、シールを縦2枚に増やした。
さらに detect_tip_side() に設計上の問題が見つかった。元の実装は青シール(爪上面)が2点以上見えない場合にTIPをNoneで返す設計だった。HOME姿勢では爪が真上を向くため横カメラから青シールが見えない。青シールチェックを削除した。
# 修正前
if not blue_centers or len(blue_centers) < 2:
return None, blue_for_return, None
if not green_centers or len(green_centers) < 2:
return None, blue_for_return, None
# 修正後: 青シールチェックを削除
if not green_centers or len(green_centers) < 2:
return None, blue_for_return, None修正後の detect_tip_side() フルコード:
def detect_tip_side(frame):
"""
横カメラのフレームから TIP(爪先)の pixel 位置を計算する。
TIP は緑シール2点(爪前面)の中点として返す。
座標系変換(pixel → mm)は別タスクのため、tip_yz_mm は常に None。
Args:
frame: BGR フォーマットの ndarray (H, W, 3)
Returns:
tuple (tip_pixel, blue_markers, tip_yz_mm)
"""
blue_centers, _bmask = _count_blue_markers(frame)
green_centers, _gmask = _count_green_markers(frame)
blue_for_return = blue_centers if blue_centers else None
if not green_centers or len(green_centers) < 2:
return None, blue_for_return, None
# 横カメラの背景ノイズ対策:3点以上なら画面下方の2点を採用
green_pair = _pick_bottom_two(green_centers)
# X座標昇順で左右を識別
green_sorted = sorted(green_pair, key=lambda p: p[0])
green_left, green_right = green_sorted[0], green_sorted[1]
tip = (
(green_left[0] + green_right[0]) // 2,
(green_left[1] + green_right[1]) // 2,
)
return tip, blue_centers, None修正後のテスト:
python3 -c "
import cv2, time
from auto_detect_cameras import _count_green_markers
from side_camera_utils import detect_tip_side
cap = cv2.VideoCapture(1)
for _ in range(120):
cap.read()
ret, frame = cap.read()
cap.release()
time.sleep(0.5)
tip, blue, _ = detect_tip_side(frame)
green, _ = _count_green_markers(frame)
print(f'tip_pixel: {tip}')
print(f'green_centers: {green}')
"結果:
tip_pixel: (962, 61)
green_centers: [(1085, 70), (840, 53)]HOME姿勢で横カメラのTIP検出が成立した。
fk_scan_calibration.py のフルコード
#!/usr/bin/env python3
"""
fk_scan_calibration.py
横カメラの pixel ↔ Unity (z, y) 変換式を作るためのキャリブレーションデータ収集。
FK scan から ee_y/ee_x/ee_z 制約で 60 姿勢を均等サンプリングし、
各姿勢で「上カメラ pixel」「横カメラ pixel」「Unity TIP 座標」を同時記録する。
出力: ~/r2r2r-scripts/fk_scan_calibration.csv
ヘッダ: j0,j1,j2,j3,j4,j5,top_px_x,top_px_y,top_unity_x,top_unity_z,side_px_x,side_px_y
"""
import os
import sys
import csv
import time
import cv2
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import visual_servo_pick
from visual_servo_pick import (
detect_tip_from_frame,
load_fk_table,
get_daemon_socket,
_daemon_request,
close_daemon_socket,
)
from side_camera_utils import detect_tip_side
HOMOGRAPHY_PATH = os.path.expanduser("~/r2r2r-scripts/homography.npy")
OUT_CSV_PATH = os.path.expanduser("~/r2r2r-scripts/fk_scan_calibration.csv")
HOME_ANGLES = [0, 0, 0, 0, 0, -30]
MOVE_SPEED = 15
MOVE_WAIT = 4
POST_MOVE_SETTLE = 1.0
EE_Y_MIN, EE_Y_MAX = -0.080, -0.010
EE_X_MIN, EE_X_MAX = -0.200, 0.200
EE_Z_MIN, EE_Z_MAX = -0.050, 0.350
N_SAMPLES = 60
CAM_WARMUP_FRAMES = 10
CAM_RELEASE_SLEEP = 0.5
CSV_HEADER = [
"j0", "j1", "j2", "j3", "j4", "j5",
"top_px_x", "top_px_y",
"top_unity_x", "top_unity_z",
"side_px_x", "side_px_y",
]
def load_homography_into_module():
H = np.load(HOMOGRAPHY_PATH)
visual_servo_pick._homography = H
print(f"[Mac] homography をロード: shape={H.shape}")
def pick_calibration_poses():
table = visual_servo_pick._fk_table
filtered = [
(ee_x, ee_y, ee_z, joints) for (ee_x, ee_y, ee_z, joints) in table
if EE_Y_MIN <= ee_y <= EE_Y_MAX
and EE_X_MIN <= ee_x <= EE_X_MAX
and EE_Z_MIN <= ee_z <= EE_Z_MAX
]
print(f"[Mac] FK制約合致: {len(filtered)} / {len(table)} 点")
if len(filtered) == 0:
raise RuntimeError("制約を満たす姿勢が0件")
n = min(N_SAMPLES, len(filtered))
indices = np.linspace(0, len(filtered) - 1, n).round().astype(int)
return [filtered[i] for i in indices]
def capture_one_frame(cam_idx):
cap = cv2.VideoCapture(cam_idx)
if not cap.isOpened():
cap.release()
time.sleep(CAM_RELEASE_SLEEP)
return None
for _ in range(CAM_WARMUP_FRAMES):
cap.read()
ret, frame = cap.read()
cap.release()
time.sleep(CAM_RELEASE_SLEEP)
if not ret or frame is None:
return None
return frame
def send_pose(joints, speed=MOVE_SPEED, wait=MOVE_WAIT):
_daemon_request({
"cmd": "send_angles",
"angles": list(joints),
"speed": speed,
"wait": wait,
})
def go_home():
print(f"[Mac] HOME へ移動: {HOME_ANGLES}")
send_pose(HOME_ANGLES)
time.sleep(POST_MOVE_SETTLE)
def ensure_daemon_connected():
try:
_daemon_request({"cmd": "ping"})
except Exception:
print("[Mac] daemon 再接続中...")
close_daemon_socket()
get_daemon_socket()
_daemon_request({"cmd": "ping"})
print("[Mac] ✓ daemon 再接続完了")
def main():
print("=" * 70)
print("fk_scan_calibration.py ── 横カメラキャリブ用データ収集")
print("=" * 70)
CALIB_FRAME_DIR = os.path.expanduser("~/r2r2r-scripts/logs/calib_frames")
os.makedirs(CALIB_FRAME_DIR, exist_ok=True)
load_fk_table()
load_homography_into_module()
poses = pick_calibration_poses()
print(f"[Mac] スキャン姿勢数: {len(poses)}")
top_idx = 2 # Continuity Camera (iPhone 14 Pro Max)
side_idx = 1 # Iriun Webcam (iPhone 11)
print(f"[Mac] カメラindex固定: 上={top_idx}, 横={side_idx}")
print("[Mac] デーモンソケット接続 (TCP 5555)...")
get_daemon_socket()
print("[Mac] ✓ daemon ready")
out_f = open(OUT_CSV_PATH, "w", newline="")
writer = csv.writer(out_f)
writer.writerow(CSV_HEADER)
out_f.flush()
recorded = 0
skipped = 0
try:
go_home()
print("[Mac] グリッパーを開く...")
_daemon_request({"cmd": "set_gripper", "state": 0, "speed": 80, "wait": 2})
time.sleep(1.0)
for i, (ee_x_target, ee_y_target, ee_z_target, joints) in enumerate(poses, start=1):
ensure_daemon_connected()
send_pose(joints)
time.sleep(POST_MOVE_SETTLE)
frame_top = capture_one_frame(top_idx)
tip_pixel_top = None
tip_unity = None
top_blue_markers = None
if frame_top is not None:
tip_pixel_top, top_blue_markers, tip_unity = detect_tip_from_frame(frame_top)
frame_side = capture_one_frame(side_idx)
tip_pixel_side = None
side_blue_markers = None
if frame_side is not None:
tip_pixel_side, side_blue_markers, _ = detect_tip_side(frame_side)
top_ok = (tip_pixel_top is not None) and (tip_unity is not None)
side_ok = tip_pixel_side is not None
joints_str = " ".join(f"j{k}={joints[k]:+.1f}" for k in range(6))
top_desc = (
f"OK(px={tip_pixel_top[0]},{tip_pixel_top[1]} "
f"unity={tip_unity[0]:+.3f},{tip_unity[1]:+.3f})"
if top_ok else "MISS"
)
side_desc = (
f"OK(px={tip_pixel_side[0]},{tip_pixel_side[1]})"
if side_ok else "MISS"
)
if top_ok:
side_x = tip_pixel_side[0] if side_ok else -1
side_y = tip_pixel_side[1] if side_ok else -1
writer.writerow([
f"{joints[0]:.3f}", f"{joints[1]:.3f}", f"{joints[2]:.3f}",
f"{joints[3]:.3f}", f"{joints[4]:.3f}", f"{joints[5]:.3f}",
tip_pixel_top[0], tip_pixel_top[1],
f"{tip_unity[0]:.6f}", f"{tip_unity[1]:.6f}",
side_x, side_y,
])
out_f.flush()
recorded += 1
status = "RECORDED"
else:
skipped += 1
status = "SKIPPED"
if frame_top is not None:
annotated_top = frame_top.copy()
if top_blue_markers:
for pt in top_blue_markers:
cv2.circle(annotated_top, tuple(map(int, pt)), 18, (0, 255, 0), 2)
if tip_pixel_top is not None:
cv2.circle(annotated_top, tuple(map(int, tip_pixel_top)), 25, (0, 165, 255), 3)
cv2.putText(
annotated_top,
f"blue={len(top_blue_markers) if top_blue_markers else 0} tip={'OK' if tip_pixel_top else 'MISS'}",
(20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 165, 255), 2,
)
cv2.imwrite(os.path.join(CALIB_FRAME_DIR, f"pose_{i:03d}_top.png"), annotated_top)
if frame_side is not None:
annotated_side = frame_side.copy()
if side_blue_markers:
for pt in side_blue_markers:
cv2.circle(annotated_side, tuple(map(int, pt)), 18, (0, 255, 0), 2)
if tip_pixel_side is not None:
cv2.circle(annotated_side, tuple(map(int, tip_pixel_side)), 25, (0, 165, 255), 3)
cv2.putText(
annotated_side,
f"side tip={'OK' if tip_pixel_side else 'MISS'}",
(20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 165, 255), 2,
)
cv2.imwrite(os.path.join(CALIB_FRAME_DIR, f"pose_{i:03d}_side.png"), annotated_side)
print(
f"pose {i}/{len(poses)}: {joints_str} "
f"top={top_desc} side={side_desc} → {status}"
)
finally:
try:
go_home()
except Exception as e:
print(f"[Mac] [WARN] HOME 復帰失敗: {e}")
try:
close_daemon_socket()
except Exception as e:
print(f"[Mac] [WARN] daemon close 失敗: {e}")
out_f.close()
print()
print("=" * 70)
print(f"完了: recorded={recorded}, skipped={skipped}")
print(f"出力: {OUT_CSV_PATH}")
print("=" * 70)
if __name__ == "__main__":
main()スキャン開始直後の出力:
======================================================================
fk_scan_calibration.py ── 横カメラキャリブ用データ収集
======================================================================
[Mac] FK scan CSV読み込み中...
[Mac] ✓ 512087点ロード
[Mac] homography をロード: shape=(3, 3)
[Mac] FK制約合致: 2200 / 512087 点
[Mac] スキャン姿勢数: 60
[Mac] カメラindex固定: 上=2, 横=1
[Mac] デーモンソケット接続 (TCP 5555)...
[Mac] ✓ daemon ready
[Mac] HOME へ移動: [0, 0, 0, 0, 0, -30]
[Mac] グリッパーを開く...
pose 1/60: j0=-37.0 j1=+120.0 j2=-39.0 j3=+13.5 j4=-75.0 j5=-30.5 top=OK(px=657,696 unity=+0.163,+0.223) side=MISS → RECORDED
pose 2/60: j0=-32.0 j1=+120.0 j2=-29.0 j3=-11.5 j4=-60.0 j5=-30.5 top=OK(px=655,695 unity=+0.164,+0.222) side=MISS → RECORDED
pose 3/60: j0=-61.9 j1=+107.7 j2=-33.3 j3=-11.4 j4=-50.1 j5=-30.0 top=OK(px=606,660 unity=+0.187,+0.205) side=MISS → RECORDED
pose 4/60: j0=-36.9 j1=+117.7 j2=-33.3 j3=-6.4 j4=-75.1 j5=-30.0 top=OK(px=584,744 unity=+0.198,+0.245) side=MISS → RECORDEDtop=OKは安定しているが、アームが動いていないことに気づいた。unity座標が2パターンに固定されていた。
アームが動かない問題の切り分け
ステップ1: Pi側でpymycobotを直接実行
from pymycobot.mycobot import MyCobot
mc = MyCobot('/dev/ttyAMA0', 1000000)
print('current:', mc.get_angles())
mc.send_angles([0, 60, -30, 0, -30, -30], 15)
import time; time.sleep(4)
print('after:', mc.get_angles())結果:
current: [-0.17, 1.49, 0.61, -0.26, 42.97, -29.7]
after: []動いた。after: [] はアームが移動中にget_angles()を呼ぶpymycobotの既知挙動。問題はpymycobot自体にはない。
ステップ2: Mac側からdaemonに直接send_anglesを送る
最初に、Pi側のdaemonファイルがMacの修正版と同期していないことが判明した。
# Pi側ファイルの確認
ssh ubuntu@192.168.2.1 -i ~/.ssh/id_rsa_pi \
"grep -n 'send_angles 呼び出し前' ~/r2r2r-scripts/mecharm_daemon.py"
# 何も返らない → 古いバージョン
# MacからPiにコピー
scp -i ~/.ssh/id_rsa_pi ~/r2r2r-scripts/mecharm_daemon.py \
ubuntu@192.168.2.1:~/r2r2r-scripts/mecharm_daemon.py
# mecharm_daemon.py 100% 4846 1.7MB/s 00:00コピー後にdaemonを再起動して実行:
import socket, json
s = socket.socket()
s.settimeout(15)
s.connect(('192.168.2.1', 5555))
s.sendall(json.dumps({'cmd': 'ping'}).encode() + b'\n')
print('ping:', s.recv(4096))
cmd = {'cmd': 'send_angles', 'angles': [0, 60, -30, 0, -30, -30], 'speed': 15, 'wait': 4}
s.sendall(json.dumps(cmd).encode() + b'\n')
print('send_angles:', s.recv(4096))
s.close()結果:
ping: b'{"ok": true}\n'
send_angles: b'{"ok": true}\n'Pi側のdaemonログ:
[DAEMON 16:24:18] 接続: ('192.168.2.2', 63245)
[DAEMON 16:24:18] send_angles 呼び出し前: angles=[0, 60, -30, 0, -30, -30], speed=15, wait=4.0
[DAEMON 16:24:18] send_angles 呼び出し後: result=None
[DAEMON 16:24:18] send_angles 後の現在角度: [-0.17, 61.17, -28.21, -0.26, -30.32, -29.79]
[DAEMON 16:24:22] 切断: ('192.168.2.2', 63245)daemon経由でも動く。角度が目標値に変化している。
ステップ3: カメラなしのスキャンループで動かす
import sys, os, time
sys.path.insert(0, os.path.expanduser('~/r2r2r-scripts'))
import visual_servo_pick as vsp
from visual_servo_pick import load_fk_table, get_daemon_socket, _daemon_request, close_daemon_socket
import numpy as np
vsp._homography = np.load(os.path.expanduser('~/r2r2r-scripts/homography.npy'))
load_fk_table()
get_daemon_socket()
poses = [
[0, 0, 0, 0, 0, -30],
[0, 60, -30, 0, -30, -30],
[30, 80, -40, 0, -40, -30],
[0, 0, 0, 0, 0, -30],
]
for i, p in enumerate(poses):
print(f'pose {i}: {p}')
_daemon_request({'cmd': 'send_angles', 'angles': p, 'speed': 15, 'wait': 4})
time.sleep(1.0)
print(f'pose {i}: done')
close_daemon_socket()Pi側のdaemonログ:
[DAEMON 16:38:07] 接続: ('192.168.2.2', 63909)
[DAEMON 16:38:07] send_angles 呼び出し前: angles=[0, 0, 0, 0, 0, -30], speed=15, wait=4.0
[DAEMON 16:38:07] send_angles 呼び出し後: result=None
[DAEMON 16:38:07] send_angles 後の現在角度: [-27.15, 90.26, -29.35, 10.37, -56.25, -30.05]
[DAEMON 16:38:12] send_angles 呼び出し前: angles=[0, 60, -30, 0, -30, -30], speed=15, wait=4.0
[DAEMON 16:38:12] send_angles 呼び出し後: result=None
[DAEMON 16:38:12] send_angles 後の現在角度: []
[DAEMON 16:38:17] send_angles 呼び出し前: angles=[30, 80, -40, 0, -40, -30], speed=15, wait=4.0
[DAEMON 16:38:17] send_angles 呼び出し後: result=None
[DAEMON 16:38:17] send_angles 後の現在角度: []
[DAEMON 16:38:22] send_angles 呼び出し前: angles=[0, 0, 0, 0, 0, -30], speed=15, wait=4.0
[DAEMON 16:38:22] send_angles 呼び出し後: result=None
[DAEMON 16:38:22] send_angles 後の現在角度: []アームが動いた。目視でも確認。
カメラ取得処理がdaemon接続を殺していた。問題はアームでも通信設計でもなく、それだった。
なぜカメラがdaemonを殺すのか
capture_one_frame() は cv2.VideoCapture(idx) をopen → warmup(10フレーム) → read → releaseする処理だ。
def capture_one_frame(cam_idx):
cap = cv2.VideoCapture(cam_idx) # AVFoundation経由でopen
if not cap.isOpened():
cap.release()
time.sleep(CAM_RELEASE_SLEEP)
return None
for _ in range(CAM_WARMUP_FRAMES): # 10フレームwarmup
cap.read()
ret, frame = cap.read()
cap.release()
time.sleep(CAM_RELEASE_SLEEP) # 0.5秒sleep
if not ret or frame is None:
return None
return frameこの一連の実行中に、daemonとのTCPソケット接続が切れる。
これはロボット制御の問題ではなく、「Mac上で複数のリアルタイム処理を共存させる」問題だった。OpenCVのAVFoundationバックエンドがGILを掴んで、socketを窒息させている——そんな仮説を立てた。macOS上のContinuity CameraはAVFoundationを経由するため特に影響が大きい。
スキャン中にdaemonのsend_anglesログが一切出ていなかったことが決定的な証拠だ。daemonがコマンドを受け取る前に接続が切れている。
副次: Continuity Cameraのkeep-openでフレームが更新されない
上カメラをスキャン全体でopen保持したまま姿勢ごとにフレームを取れば高速化できると考えた。mean値テストで検証した。
import cv2, time
cap = cv2.VideoCapture(2)
for _ in range(120):
cap.read()
for i in range(5):
for _ in range(5):
cap.read()
ret, frame = cap.read()
if ret:
print(f'frame {i}: mean={frame.mean():.2f}')
time.sleep(1.0)
cap.release()結果:
frame 0: mean=133.25
frame 1: mean=133.27
frame 2: mean=133.26
frame 3: mean=133.25
frame 4: mean=133.255回連続でほぼ同一値。keep-openではフレームが更新されない。
都度open/closeの場合:
for i in range(5):
cap = cv2.VideoCapture(2)
for _ in range(10):
cap.read()
ret, frame = cap.read()
cap.release()
time.sleep(0.5)
if ret:
print(f'frame {i}: mean={frame.mean():.2f}')
time.sleep(1.0)結果:
frame 0: mean=133.40
frame 1: mean=133.19
frame 2: mean=133.32
frame 3: mean=133.32
frame 4: mean=133.36mean値が変化している。都度open/closeでフレームは更新される。ただしこれがdaemon干渉の原因でもある。
mecharm_daemon.py のログ追加(デバッグ用修正)
問題調査のためにdaemonのsend_anglesブロックにログを追加した。修正後のフルコード:
#!/usr/bin/env python3
"""
mecharm_daemon.py
Pi 側で常駐する MyCobot 制御デーモン。
TCP socket で Mac からの JSON コマンドを受け取り、永続化された MyCobot 経由で実機に転送する。
"""
import json
import socket
import sys
import time
import signal
import traceback
from pymycobot.mycobot import MyCobot
DAEMON_HOST = "0.0.0.0"
DAEMON_PORT = 5555
SERIAL_PORT = "/dev/ttyAMA0"
BAUDRATE = 1000000
_mc = None
_server_sock = None
_running = True
def log(msg):
print(f"[DAEMON {time.strftime('%H:%M:%S')}] {msg}", flush=True)
def init_mycobot():
global _mc
log(f"MyCobot 初期化中 ({SERIAL_PORT}, baud={BAUDRATE})...")
_mc = MyCobot(SERIAL_PORT, BAUDRATE)
log("✓ MyCobot 初期化完了")
def handle_command(cmd_obj):
cmd = cmd_obj.get("cmd")
if cmd == "ping":
return {"ok": True}
elif cmd == "send_angles":
try:
angles = cmd_obj["angles"]
speed = int(cmd_obj.get("speed", 20))
wait = float(cmd_obj.get("wait", 0))
log(f"send_angles 呼び出し前: angles={angles}, speed={speed}, wait={wait}")
result = _mc.send_angles(angles, speed)
log(f"send_angles 呼び出し後: result={result}")
current_angles = _mc.get_angles()
log(f"send_angles 後の現在角度: {current_angles}")
if wait > 0:
time.sleep(wait)
return {"ok": True}
except Exception as e:
log(f"send_angles 例外: {type(e).__name__}: {e}")
log(traceback.format_exc().rstrip())
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
elif cmd == "get_angles":
a = None
for attempt in range(3):
a = _mc.get_angles()
if a and len(a) == 6:
break
time.sleep(0.1)
if not a or len(a) != 6:
return {"ok": False, "error": "get_angles returned empty/invalid after 3 retries"}
return {"ok": True, "angles": a}
elif cmd == "set_gripper":
state = int(cmd_obj["state"])
speed = int(cmd_obj.get("speed", 100))
wait = float(cmd_obj.get("wait", 0))
time.sleep(2)
result = _mc.set_gripper_state(state, speed)
if wait > 0:
time.sleep(wait)
return {"ok": True, "result": result}
else:
return {"ok": False, "error": f"unknown cmd: {cmd}"}
def serve_client(client_sock, addr):
log(f"接続: {addr}")
buf = b""
try:
while _running:
data = client_sock.recv(4096)
if not data:
break
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.strip()
if not line:
continue
try:
cmd_obj = json.loads(line.decode("utf-8"))
response = handle_command(cmd_obj)
except Exception as e:
response = {"ok": False, "error": f"{type(e).__name__}: {e}"}
log(f"コマンド処理エラー: {e}")
resp_line = (json.dumps(response) + "\n").encode("utf-8")
client_sock.sendall(resp_line)
except ConnectionResetError:
log(f"接続リセット: {addr}")
except Exception as e:
log(f"クライアントエラー: {e}")
finally:
client_sock.close()
log(f"切断: {addr}")
def shutdown_handler(signum, frame):
global _running
log(f"シグナル受信 ({signum}), シャットダウン開始")
_running = False
if _server_sock:
try:
_server_sock.close()
except Exception:
pass
sys.exit(0)
def main():
global _server_sock
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
init_mycobot()
_server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
_server_sock.bind((DAEMON_HOST, DAEMON_PORT))
_server_sock.listen(1)
log(f"ポート {DAEMON_PORT} で待ち受け中...")
try:
while _running:
try:
client_sock, addr = _server_sock.accept()
except OSError:
break
serve_client(client_sock, addr)
finally:
_server_sock.close()
log("シャットダウン完了")
if __name__ == "__main__":
main()このセッションで確定したこと・していないこと
確定したこと:
D完了:
auto_detect_cameras.pywarmup_frames 30→120、cap.release()後にtime.sleep追加で3回連続安定detect_tip_side()青シールチェック削除 → HOME姿勢でのTIP検出成立:tip_pixel=(962, 61)緑シール縦2枚配置でHOME姿勢でも検出可能なことを確認
daemon経由のsend_anglesはカメラなしの場合は正常動作する
カメラ取得がdaemonのTCP接続を切断することを切り分けで確定
Pi側へのファイル配布はscpが必要(gitでは自動同期されない)
Continuity CameraはOpenCVのkeep-openでフレームが更新されない(AVFoundationの仕様)
未解決:
capture_one_frame()実行中のdaemon接続切れ → send_pose wait=0化 + Mac側sleep設計を次セッションで実装横カメラ(side)の全姿勢MISS → 緑シール縦2枚で改善したが、スキャン実行中の検証は未完
fk_scan_calibration.csv未取得
感想
「アームが動かない」という状態から、「なぜ動かないのか」を1つずつ切り分けていく時間だった。pymycobot単体では動く、daemon経由でも動く、カメラなしでも動く——3つの確認で「カメラとdaemonの共存」が問題だと絞り込めた。
Physical AIでは、正しい部品を組み合わせても、システムは普通に壊れる。問題は精度ではなく、共存だった。「精度より体験の成立を優先する」という原則はいつも体験の手前で、インフラの話になる。
