mechArm 270 Pi 動的追従の限界を探る Log#6 | SSHオーバーヘッドを40倍速化して通信律速の壁を越える
LOG188R2R2R2026/5/5

mechArm 270 Pi 動的追従の限界を探る Log#6 | SSHオーバーヘッドを40倍速化して通信律速の壁を越える

前セッションで判明した「1ループあたりSSH送信6〜8秒」という通信律速を解消するための一連の作業を記録する。time.sleep(2)の削除から始まり、SSH接続の永続化、exec_commandのノンブロッキング化までを段階的に適用した。途中、ノンブロッキング化によってPi側の処理速度を超える送信が起き、SSHチャンネルが枯渇する現象に当たった。最小送信インターバルの導入で抑止した。最終的に1ループ 6.4秒 → 0.13秒、約40倍の高速化を達成した。

💡 Key Takeaway

通信律速は段階的に切り分けて削っていく必要がある。time.sleep(2)の削除単独では効果が観測できず、SSH接続の永続化で6.4秒→4.0秒(約1.6倍)、exec_commandのノンブロッキング化で4.0秒→0.13秒(約30倍)と、ボトルネックが移動するたびに数値が変わった。ノンブロッキング化はPi側の処理速度を超える送信を許す副作用があり、最小送信インターバルでチャンネル枯渇を抑止する設計が必要だった。

発生した問題

現場判断

結果

time.sleep(2) 削除単独では LOOP_TIME に変化が見えない

Pi側の mc.send_angles 完了待ちが stdout.read() でブロッキングされており、SSH接続確立コスト自体が支配的だと推定。SSH接続を永続化(モジュールレベルで保持して再利用)する変更を入れた

6.4秒 → 4.0秒。約2.5秒削減

SSH接続を永続化しても 1ループ4.0秒残る

stdout.read() が Pi 側の python3 プロセス終了まで待っていた。exec_command 後の read() を削除しノンブロッキング化

4.0秒 → 0.13秒。約30倍の削減

ノンブロッキング化後、SSHチャンネルが枯渇し Connect failed で異常終了

0.13秒で連続送信するとPi側の send_angles が完了する前に次のコマンドが積まれ、開いたチャンネル数が上限を超える。最小送信インターバル SEND_INTERVAL_SEC = 0.5 を導入

連発抑止のロジックが入った(実機での連続稼働での最終検証は持ち越し)

find_iriun_camera が想定と異なる index を掴んでマット四隅検出が失敗

過去のdaylog記録通り、Mac環境変化で OpenCV の VideoCapture index は変動する。Camera 0/1/2 を総当たりして brightness と画像を確認、index 2 が Iriun Webcam だと特定

CAMERA_INDEX = 1 → 2 に変更

出発点:1ループ 6.4秒 という通信律速

前セッションの最後で判明した数字は重かった。動的追従ループの 1 周あたりの所要時間:

LOOP_TIME: 6.449s
LOOP_TIME: 6.421s
LOOP_TIME: 6.438s
LOOP_TIME: 6.423s

カメラ画像取得・赤ブロック検出・青シールからのTIP計算・差分計算・ロボット指令——この一連を 1 周回るのに 6.4 秒。視覚サーボとして成立する周期ではない。

ボトルネックは Mac から Pi にロボット指令を送る ssh_send_angles() だった。中身はこうなっている:

def ssh_send_angles(angles, speed=SPEED, wait=5):
    angles_str = str(angles)
    cmd = (
        f"python3 -c \""
        f"from pymycobot.mycobot import MyCobot; import time; "
        f"mc = MyCobot('/dev/ttyAMA0', 1000000); time.sleep(2); "
        f"mc.send_angles({angles_str}, {speed}); time.sleep({wait}); "
        f"print('ANGLES:', mc.get_angles())\""
    )
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    client.connect(SSH_HOST, username=SSH_USER, key_filename=SSH_KEY_PATH)
    stdin, stdout, stderr = client.exec_command(cmd)
    output = stdout.read().decode("utf-8", errors="ignore").strip()
    error = stderr.read().decode("utf-8", errors="ignore").strip()
    client.close()
    ...

1 回の呼び出しで 5 つのコストが発生していた。

  1. paramiko.SSHClient() インスタンス作成

  2. client.connect() で SSH 接続を新規確立(鍵認証含む)

  3. exec_command() で Pi 側に python3 プロセス起動

  4. python3 起動後 time.sleep(2) で 2 秒待機

  5. stdout.read() が python3 プロセス終了までブロッキング待機

これらが追従ループ内で毎ループ走っていた。Z 追従を入れた前セッションの設計では j0 用と j1/j2/j4 用で 1 ループ 2 回送信されていたため 6〜8 秒になっていた。

軽量化レバーは前セッションで 7 段階に整理してあったので、今セッションは上から順番に削っていく。

レバー1:time.sleep(2) を削除する

最も単純な変更から始めた。コマンド文字列内の time.sleep(2) を削除する。

# 変更前
f"mc = MyCobot('/dev/ttyAMA0', 1000000); time.sleep(2); "
# 変更後
f"mc = MyCobot('/dev/ttyAMA0', 1000000); "

MyCobot インスタンス生成直後の 2 秒待機は、ライブラリ初期化の安定化を目的に入れられたもの。実機で削除しても send_angles が無視されるかどうかは未確認だった。

実機で最小テストを走らせる。

ssh ubuntu@192.168.2.1 -i ~/.ssh/id_rsa_pi
python3 -c "
from pymycobot.mycobot import MyCobot
import time
mc = MyCobot('/dev/ttyAMA0', 1000000)
mc.send_angles([0,0,0,0,0,-30], 20)
time.sleep(3)
print('OK:', mc.get_angles())
"

アームは HOME に動いた。time.sleep(2) は削除しても動作に影響しないことが確認できた。MyCobot ライブラリは内部でシリアル通信を確立するための短い時間が必要だったが、send_angles が呼ばれるまでに from import などの処理が挟まり、実質的な初期化時間は確保されていた。

ただしこの段階では、ループ全体の所要時間がどう変わるかはまだ測れない。レバー2・4 と組み合わせて初めて計測できる。

レバー2+4:SSH送信を1ループ1回に統合し、get_angles ログ出力を削除

Z 追従を入れた構造では、追従ループ内で j0 更新と Z 追従更新が別々の ssh_send_angles 呼び出しになっていた。1 ループ最大 2 回の SSH 送信になる。これを 1 回に統合する。

各関節の更新値をループ内で変数として保持し、ループ末尾で [j0, j1, j2, j3, j4, j5] を 1 回だけ送る形に変えた。

# j0更新(追従、X方向のdiffのみで判定)
angles_changed = False
if diff_x_mm is not None and abs(diff_x_mm) > TRACK_DEADBAND_MM:
    delta_deg = diff_x_mm / MM_PER_DEG_J0_X
    delta_deg = max(-MAX_DELTA_DEG, min(MAX_DELTA_DEG, delta_deg))
    j0_new = j0_current + delta_deg
    if abs(j0_new - initial_j0) > J0_RELATIVE_LIMIT_DAY185:
        sign = 1 if (j0_new - initial_j0) > 0 else -1
        j0_new = initial_j0 + sign * J0_RELATIVE_LIMIT_DAY185
    j0_current = j0_new
    angles_changed = True

# Z方向追従(j1+j2+j4 同方向3関節連動「リーチ伸ばし」制御)
if diff_z_mm is not None and abs(diff_z_mm) > TRACK_DEADBAND_Z_MM_DAY186:
    reach_delta_deg = diff_z_mm / MM_PER_DEG_ELBOW_Z_DAY186
    reach_delta_deg = max(-MAX_DELTA_ELBOW_DEG_DAY186,
                           min(MAX_DELTA_ELBOW_DEG_DAY186, reach_delta_deg))
    j1_current = j1_current + reach_delta_deg
    j2_current = j2_current + reach_delta_deg
    j4_current = j4_current - reach_delta_deg
    angles_changed = True

# 1ループ1回のSSH送信
if angles_changed:
    new_angles = [j0_current, j1_current, j2_current,
                  approach_angles_base[3], j4_current, approach_angles_base[5]]
    ssh_send_angles(new_angles, wait=0)

同時に、ssh_send_angles 内の print('ANGLES:', mc.get_angles()) を削除した。追従ループ中はカメラで TIP 位置を取っているため Pi 側の現在角度は不要で、これが stdout.read() のブロッキング時間を伸ばしていた。

LOOP_TIME 計測用に dynamic_tracking_loop の冒頭と末尾に時間取得を追加して、実機で走らせた。

LOOP_TIME: 6.449s   ← Z-TRACK発火時
LOOP_TIME: 6.421s   ← 同上
LOOP_TIME: 6.438s   ← 同上
LOOP_TIME: 6.423s   ← 同上
LOOP_TIME: 0.179s   ← RED:NG(送信なし)時
LOOP_TIME: 0.129s   ← 同上
LOOP_TIME: 0.158s   ← 同上

明確な差が出た。SSH 送信が発生しないループは 0.13〜0.18 秒で回っている。送信が発生するループだけが 6.4 秒残っている。

つまり、削るべきは送信ロジックそのものだった。

レバー5:SSH接続を永続化する

ssh_send_angles の中で毎回 paramiko.SSHClient() → connect() → close() を繰り返している構造が支配的だと判断した。SSH のハンドシェイクと鍵認証で 1 回 数秒かかる。これをループ内で繰り返している。

モジュールレベルにグローバル SSH クライアントを保持して、初回のみ接続を確立する形に変更する。

_ssh_client = None

def get_ssh_client():
    """SSH接続を取得・再利用する。切断されていたら再接続する。"""
    global _ssh_client
    if _ssh_client is None or not _ssh_client.get_transport() or not _ssh_client.get_transport().is_active():
        _ssh_client = paramiko.SSHClient()
        _ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        _ssh_client.connect(SSH_HOST, username=SSH_USER, key_filename=SSH_KEY_PATH)
    return _ssh_client


def close_ssh_client():
    """スクリプト終了時にSSH接続を閉じる。"""
    global _ssh_client
    if _ssh_client:
        _ssh_client.close()
        _ssh_client = None

ssh_send_angles / ssh_get_angles / ssh_set_gripper の3関数全てを get_ssh_client() を経由する形に変更した。各関数内の client.close() も削除(接続を閉じてしまうと次回の get で再接続が走る)。

スクリプト終了時の finally 句で close_ssh_client() を呼ぶ形にした。

finally:
    close_ssh_client()
    cap.release()
    cv2.destroyAllWindows()

実機で再計測する。

LOOP_TIME: 4.007s   ← Z-TRACK発火時
LOOP_TIME: 4.076s
LOOP_TIME: 4.034s
LOOP_TIME: 4.125s
LOOP_TIME: 4.117s
LOOP_TIME: 0.128s   ← 送信なし時
LOOP_TIME: 0.131s

6.4 秒 → 4.0 秒、約 2.5 秒削減。SSH 接続確立コストの占める比重が見えた。ただし 4.0 秒残っているということは、ボトルネックがまだ別の場所にある。

ノンブロッキング化:exec_command 後に read() しない

wait=0 を渡しているのに 4 秒かかっている、という事実が手がかりだった。これは stdout.read() が Pi 側の python3 プロセスが終了するまでブロッキング待機していることを意味する。

stdin, stdout, stderr = client.exec_command(cmd)
output = stdout.read().decode("utf-8", errors="ignore").strip()  # ← ここでブロッキング
error = stderr.read().decode("utf-8", errors="ignore").strip()

Pi 側で mc.send_angles(...) 自体に数秒かかるため、その完了をそのまま待っていた。send_angles は非同期送信なので完了を待つ必然性はない。

stdout.read() / stderr.read() を削除した。

def ssh_send_angles(angles, speed=SPEED, wait=5):
    ...
    client = get_ssh_client()
    client.exec_command(cmd)
    # ノンブロッキング: Pi側の完了を待たずに即リターン

実機で走らせる。

LOOP_TIME: 0.139s   ← Z-TRACK発火時
LOOP_TIME: 0.130s
LOOP_TIME: 0.131s
LOOP_TIME: 0.171s
LOOP_TIME: 0.129s
LOOP_TIME: 0.165s
LOOP_TIME: 0.138s
LOOP_TIME: 0.156s
LOOP_TIME: 0.140s

4.0 秒 → 0.13 秒、約 30 倍の削減。SSH 送信ありのループが、SSH 送信なしのループとほぼ同じ速度になった。出発点 6.4 秒からは合計約 50 倍の削減。

ただし、これで終わらなかった。

副作用:SSHチャンネル枯渇による Connect failed

ノンブロッキング化したログをそのまま見ていくと、ある時点でこう出た。

[Z-TRACK] diff_z=-53.6mm reach_Δ=-2.00° j1=+67.74 j2=-40.00 j4=-70.00
Secsh channel 12 open FAILED: open failed: Connect failed

[ERROR] ChannelException(2, 'Connect failed')

ログを遡ると、diff_z=-53.6mm が全く変化しないまま [Z-TRACK] が毎ループ発火し続けていた(Z 追従の方向問題は別途調査対象として残った)。0.13 秒間隔で SSH コマンドを連発していた。Pi 側の mc.send_angles は一回数秒かかるので、完了する前に次のコマンドが積まれ、開いたチャンネルが上限を超えていた。

ノンブロッキング化は「待たない」設計だが、「待たない」と「無制限に送る」は同じではなかった。送信側に最低限のレートリミットを入れる必要があった。

最小送信インターバルで連発を抑止する

モジュールレベルに前回送信時刻を保持し、SEND_INTERVAL_SEC 未満の間隔ではスキップする形にした。

_last_send_time = 0.0
SEND_INTERVAL_SEC = 0.5

def ssh_send_angles(angles, speed=SPEED, wait=5):
    global _last_send_time
    now = time.time()
    if now - _last_send_time < SEND_INTERVAL_SEC:
        return  # インターバル未満はスキップ
    _last_send_time = now
    ...

0.5 秒は仮値だが、Pi 側の send_angles 完了時間(小振幅で 数百 ms)を考えると妥当な範囲。

Connect failed 抑止の最終的な実機検証は、別の問題(カメラインデックス変動)に当たって今セッションでは持ち越しになった。

カメラインデックスが 1 から 2 に変わっていた

ノンブロッキング検証中、再起動後の実行で [Mac] × マット四隅検出失敗 が出るようになった。スクリプトは追従ループに入る前に止まる。

OpenCV のカメラインデックスを総当たりで確認した。

Camera 0: brightness=0.0    ← 真っ黒
Camera 1: brightness=129.8  ← 何か映っている
Camera 2: brightness=131.1  ← 何か映っている
Camera 3: not available
Camera 4: not available

両方の brightness が高い。どちらが Iriun Webcam か判別するため、それぞれから 1 フレーム保存して目視した。

import cv2
cap = cv2.VideoCapture(1); ret, frame = cap.read()
if ret: cv2.imwrite('/tmp/cam1_test.png', frame)
cap.release()
cap = cv2.VideoCapture(2); ret, frame = cap.read()
if ret: cv2.imwrite('/tmp/cam2_test.png', frame)
cap.release()

Camera 1 はマットの右半分しか映っておらず、四隅が画面に収まっていない。Camera 2 はマット全体が綺麗に映っており、四隅も全部画面内。Iriun Webcam は Camera 2 だった。

# 30行目
CAMERA_INDEX = 1  # 変更前
CAMERA_INDEX = 2  # 変更後

LOG153 で同じ症状が記録されている(macOS のアップデートや接続デバイス変更で OpenCV の VideoCapture index は再割り当てされる)。find_iriun_camera() 的な動的検出を入れる選択肢もあるが、index を切り替えるたびに Iriun 側のカメラ向き状態がリセットされる別の症状もあるため、当面は手動で固定する運用にした。

実測まとめ:6.4秒 → 0.13秒

段階

LOOP_TIME(SSH送信あり)

比較

LOG186 終了時

6.4〜6.5 秒

ベースライン

レバー2+4 適用後

6.4〜6.5 秒

ほぼ変化なし(送信側のSSH確立コストが支配的)

SSH接続永続化後

4.0〜4.1 秒

約 1.6 倍速化(−2.4秒)

ノンブロッキング化後

0.13〜0.17 秒

約 30 倍速化(−3.9秒)

合計で約 50 倍の削減。視覚サーボループとして「送れる速度」の壁は越えた。次に来るのは「動ける速度」(mechArm の物理応答律速、Log#2 で計測した約 5〜10 Hz)。

完成した SSH 関連コードのフルバージョン

# モジュールレベル
SSH_HOST = "192.168.2.1"
SSH_USER = "ubuntu"
SSH_KEY_PATH = os.path.expanduser("~/.ssh/id_rsa_pi")
SPEED = 20

_ssh_client = None
_last_send_time = 0.0
SEND_INTERVAL_SEC = 0.5


def get_ssh_client():
    """SSH接続を取得・再利用する。切断されていたら再接続する。"""
    global _ssh_client
    if _ssh_client is None or not _ssh_client.get_transport() or not _ssh_client.get_transport().is_active():
        _ssh_client = paramiko.SSHClient()
        _ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        _ssh_client.connect(SSH_HOST, username=SSH_USER, key_filename=SSH_KEY_PATH)
    return _ssh_client


def close_ssh_client():
    """スクリプト終了時にSSH接続を閉じる。"""
    global _ssh_client
    if _ssh_client:
        _ssh_client.close()
        _ssh_client = None


def ssh_send_angles(angles, speed=SPEED, wait=5):
    global _last_send_time
    now = time.time()
    if now - _last_send_time < SEND_INTERVAL_SEC:
        return  # インターバル未満はスキップ
    _last_send_time = now
    angles_str = str(angles)
    cmd = (
        f"python3 -c \""
        f"from pymycobot.mycobot import MyCobot; import time; "
        f"mc = MyCobot('/dev/ttyAMA0', 1000000); "
        f"mc.send_angles({angles_str}, {speed}); time.sleep({wait})\""
    )
    client = get_ssh_client()
    client.exec_command(cmd)
    # ノンブロッキング: Pi側の完了を待たずに即リターン


def ssh_get_angles():
    """Pi側で現在のANGLESを取得(デバッグ用)"""
    cmd = (
        f"python3 -c \""
        f"from pymycobot.mycobot import MyCobot; import time; "
        f"mc = MyCobot('/dev/ttyAMA0', 1000000); "
        f"print('ANGLES_GET:', mc.get_angles())\""
    )
    client = get_ssh_client()
    stdin, stdout, stderr = client.exec_command(cmd)
    output = stdout.read().decode("utf-8", errors="ignore").strip()
    for line in output.split("\n"):
        if line.startswith("ANGLES_GET:"):
            try:
                return eval(line.replace("ANGLES_GET:", "").strip())
            except Exception:
                pass
    return None


def ssh_set_gripper(state, speed=70, wait=2):
    """グリッパー開閉制御。state=0: 開く / state=1: 閉じる"""
    cmd = (
        f"python3 -c \""
        f"from pymycobot.mycobot import MyCobot; import time; "
        f"mc = MyCobot('/dev/ttyAMA0', 1000000); time.sleep(2); "
        f"result = mc.set_gripper_state({state}, {speed}); "
        f"time.sleep({wait}); "
        f"print('GRIPPER:', 'OPEN' if {state} == 0 else 'CLOSE', 'result=', result)\""
    )
    client = get_ssh_client()
    stdin, stdout, stderr = client.exec_command(cmd)
    output = stdout.read().decode("utf-8", errors="ignore").strip()
    error = stderr.read().decode("utf-8", errors="ignore").strip()
    if output:
        print(f"  [Pi] {output}")
    if error and "no longer maintained" not in error:
        print(f"  [Pi stderr] {error}")

設計上の使い分け:

  • ssh_send_angles:ループ内で連発される。ノンブロッキング・最小インターバル制御。

  • ssh_get_angles:デバッグ時のみ呼ばれる。戻り値が必要なのでブロッキング。

  • ssh_set_gripper:PICK / PLACE で 1 回ずつ呼ばれる。状態遷移なので完了待機が必要。Pi 側 time.sleep(2) も残す(同じコマンドの連続送信を避けるための保険)。

残課題

このセッションで完全には解決していないものがある。

Z追従の方向問題。ログ上 diff_z=-53.6mm が変化しないまま [Z-TRACK] が毎ループ発火し続けていた。MM_PER_DEG_ELBOW_Z_DAY186 = 5.0 の係数自体が仮値で実機キャリブレーションが未実施。3 関節同方向連動の方向(j1+, j2+, j4-)が実際に爪先を Z+ 方向に動かすかも検証されていない。

iPhone カメラの向きリセット。cv2.VideoCapture() を開くたびに iPhone 側の Iriun Webcam のカメラ向きが変わる症状を再確認した。マット全体が画面に収まっていた状態が、スクリプト実行で別の構図に変わってしまう。物理的に iPhone を再配置するしかない。Iriun 側に向き固定の設定があるかは未調査。

SEND_INTERVAL_SEC の最終検証。0.5 秒の妥当性は実機で長時間の連続稼働では検証していない。Pi 側 send_angles の振幅依存性(Log#2 で計測済み、10°で約500ms)を考えると、0.5 秒は実用範囲内のはず。

このセッションが示したこと

通信律速の解消は段階的にしか進まなかった。time.sleep(2) 削除単独では数値が変わらず、SSH 接続永続化で 1.6 倍、ノンブロッキング化で 30 倍と、ボトルネックが移動するたびに削減量が変わった。最初に見えていた「6.4 秒の壁」は単一の原因ではなく、複数のコストの合計だった。

Log#1 で「設定上 91Hz まで送れる」と確認していたのに、実装が間に合っていなかった。ハードウェア能力ではなくソフトウェア設計の問題だった。paramiko を素直に使うと毎回 SSH 確立を繰り返す。exec_command の stdout.read() を素直に呼ぶとプロセス完了まで待つ。どちらも書き方として「素直」だが、ループ内で繰り返すと致命的になる。

ノンブロッキング化は速度を上げると同時に、Pi 側の処理速度を超える送信を許す副作用を持っていた。速くするだけでは設計として不十分で、レートリミットの仕組みが必須になった。「速さ」と「壊れない」を両立させるための一段が要る。

次のセッションでは Z 追従の方向問題に戻る。指令が十分な速度で届く状態は作れたので、ようやく追従設計そのものの議論に移れる。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。