mechArm 270 Pi 動的追従の限界を探る Log#47 | アーム学習の記憶を人の目に渡すためのダッシュボードを制作
LOG270R2R2R2026/7/4

mechArm 270 Pi 動的追従の限界を探る Log#47 | アーム学習の記憶を人の目に渡すためのダッシュボードを制作

補正記憶という仕組みがある。アームがブロックを掴もうとしてズレたら、その位置とズレ量を覚えておき、次に同じ場所を狙うとき狙いを補正する。試行を重ねるほど、同じ場所のズレは縮んでいく。実機に載せて、PICK の平均誤差は 41.2mm から 12.3mm まで下がった。仕組みとしては効いている。

でも、ある日ふと止まった。この記憶、中身が誰にも見えていない。correction_memory.json というファイルにセルごとの補正量が溜まっていくのだけど、それはコードとデータの中にあるだけで、人が眺められる形にはなっていない。アームがマットのどこを苦手としていて、どこはもう学び終えたのか。ひと目で分かる地図があってもいいはずだった。

この記録は、その地図を作るまでの一連。SNS で見かけた「フィジカル AI」を巡る議論から入って、補正記憶の可能性を掘り、可視化を優先事項に決め、モックを何度も直し、実データを読むツールとして実装し、最後に背景を実機のマットに合わせて黒く反転させるところまで。技術の話とデザインの話が半分ずつになった。

きっかけは他人の議論だった

X で、フィジカル AI の覇権を巡る連投が流れてきた。制御設計エンジニアの言葉を引用したもので、要旨はこうだった。フィジカル AI は「ロボット × 大規模 AI × リアルタイム制御 × サプライチェーン」の総合格闘技で、4 つの技術レイヤーに分解できる。AI(基盤モデル・模倣学習・世界モデル)、ロボット機構、制御ソフト、サプライチェーン。日本が強いのは機構と、現場に蓄積されたリアルのデータ。ただし覇権を決めるのは AI とサプライチェーンで、そこは米中が押さえている。

それに対する反論もあった。フィジカル AI ≠ 模倣学習・世界モデル、フィジカル ≠ ロボット。必要なのはジオメトリカル・トポロジカルなAI で、付加価値は加工工程にある、と。

この議論を眺めていて、引っかかったことがある。どちらの論者も AI・機構・制御は語るのに、体験のレイヤーが一切出てこない。高難度タスク特化のシステムであれ加工工程であれ、最終的に現場の人間が使いこなせるかは体験設計の問題で、そこは空席のままだった。4 層モデルに欠けている第 5 のレイヤーがあるとすれば、それは体験だ。

もうひとつ、別の投稿が流れてきた。ファナックが AWS の GPU で模倣学習の時間を 60 時間から 4.8 時間に短縮した、というニュースに対して、「そろそろ模倣学習に意味はないと気付くフェーズかもしれない」というコメント。速くできるようになったことと、そのアプローチが正しいことは別、という指摘だった。模倣学習はデモをコピーする手法で、見せたこと以外に汎化しない。60 時間が 4.8 時間になっても、間違った山を速く登っているだけかもしれない。

この二つが並んだとき、自分がやっている補正記憶の位置が少し見えた気がした。模倣だけでは足りない。失敗から学んで直していく仕組みが要る。補正記憶はまさにそれをやっている。

補正記憶の「その先」を考える

補正記憶が効くのは分かった。では、その先に何があるか。仕組みを抽象化すると「空間位置をキーに、実測と理想の差分を蓄積し、次回の行動を補正する」になる。この骨格は掴み位置補正以外にも刺さる。

考えられる用途を並べてみた。

  • ハードの個体差・経年劣化の検出器。補正記憶の分布は「この個体のクセの指紋」で、サーボがヘタるとクセが時間とともにドリフトするはず。メンテナンス予兆になる

  • ハード間の移植資産。補正記憶の中身(学んだ Δ)は機体固有だけど、「セルキー+指数移動平均+クランプ」という構造自体はハード非依存。新しい機体に載せれば、その機体が自分のクセを自分で学ぶ

  • 環境変化の検出。収束していたセルの補正量が突然跳ねたら、それはアームでなく環境が変わったサイン(マットがずれた、カメラが動いた)

  • 苦手マップとしての計画への入力。補正が大きい・収束しないセルはそのアームの不得意領域。将来の計画層に「得意なセルを経由する経路を選ぶ」入力を渡せる

ただ、ここで冷静になる必要があった。「試行のたびに更新される生きた記憶」が新しいのか、と自問したとき、正直それ単体では弱い。研究の世界にはオンライン残差学習や、人間の修正を逐次取り込む手法が既にある。「運用中に学習し続ける」概念自体は先行例がある。

分解して考えた。セル別誤差補償は産業キャリブレーションの領域別補償と同型で、新しくない。試行ごとのオンライン更新も研究では先行例がある。では何が空いているのか。

測定器が iPhone カメラだけであること。学習の全過程が台帳に全量残って公開されていること。そして学習を人間が読める形にすること。この 3 つだった。産業ロボットのキャリブレーションはレーザートラッカー(数百万円級)で 1 万点測るような話で、その誤差マップは論文の Figure としては存在するけれど、現場の人間が日常的に見て触るインターフェースとして設計された事例はほぼない。

つまりオリジナリティの核は「生きた記憶」ではなく、「10 万円のアームと iPhone だけで、学習の全過程が検証可能な形で残る補正ループを個人が回している」という事実の側にある。新しい手法を発明したのではなく、誰でも検証できる形で成立させた。その上に載る次の一手が、可視化だった。

「補正体験の UX 化」を具体で捉え直す

可視化の話をする前に、一度大きく振りかぶって「補正体験の UX 化」という言い方をした。でも抽象的すぎて自分でもピンと来なかったので、具体で捉え直した。

最初に思い描いていたのは、失敗した瞬間に画面上のデジタルツインにゴーストグリッパーが出て、人間が指でドラッグして正しい位置に置き直す、みたいな絵だった。座標を数値で直すのを、ズレを指で直すに変換する、と。人間同士なら新人が掴み方を間違えたとき先輩は座標値を言わず「もうちょい右」と手を添えて直す。あの身体的な訂正のやりとりをロボットとの間に成立させる、という話をした。

ただ、ここで足を止めることになった。この説明の前提として「今はログや座標値を見て、コード側のオフセットを手で直している」と書いたのだけど、それは推測だった。実際の補正記憶は、もう人間の介在なしで自己補正する段階にある。tip 測定でズレを取得して指数移動平均で記憶を更新し、次回同じセルで狙いを自動補正する。人がログを見てコードを直す仕組みではない。

推測で語っていたことに気づいて、過去の実装を調べ直した。そうすると「補正を人間ができるようにする」という方向自体が、このシステムには当てはまらないと分かった。機械はもう勝手に補正している。だから UX 化の意味は反転する。

機械が勝手にやっている学習を、人間が読める・触れるものにする。

これが、このシステムでの補正体験の UX 化だった。ゴーストを指で動かして教える話ではなく、機械が既に学んだ中身を人の目に渡す話。可視化そのものが答えだった。

まず台帳を読む。確実なことだけ話す

方向が決まったので、台帳の実データを確認した。憶測で設計に入らず、まず測る。可視化の素材が実際どうなっているかを見ないと、絵に描いた地図を作ってしまう。

台帳 pick_place_ledger_v3.csv は 291 試行(2026-06-18〜07-03)、67 列。補正記憶に関わる列を抽出して集計した。

data rows: 291
PICK corr rows: 152  unique cells: 73
PLACE corr rows: 11  unique cells: 8
PICK applied dx: min -8.3 max 12.2 | dz: min -40.0 max 22.7
PLACE applied dx: min -20.8 max -9.6 | dz: min 12.5 max 24.5
PICK cells: total 73, visited once 48, visited >=3 18

ここから、可視化の前提になる事実が 3 つ見えた。

ひとつ。セルキーの表記が 2 世代混在している。旧形式 x220_z200 と新形式 pick_x220_z200 が同じ台帳に併存していた。LOG262 でキーに phase プレフィックスを付けた前後で表記が変わり、両方が残っている。可視化ツールは両形式を同一セルとして正規化して読む必要がある。

ふたつ。73 セル中 48 セルは 1 回しか訪問されていない。3 回以上は 18 セルだけ。つまり「学習の収束カーブ」を見せられるセルは少数で、地図の大半は「1 点の記憶があるだけ」の状態。可視化の設計は「収束の可視化」より先に「訪問密度の可視化」(どこが学習済みでどこが空白か)の方がデータの実態に合っている。

みっつ。「試行を重ねるとズレが縮む」はデータ上、一様には成立していない。最多訪問セルの残差の推移を並べるとよく分かる。

--- PICK cells with >=3 trials: tip_block_dist_mm sequence ---
pick_x200_z260 n=15: [6.6, 6.4, 11.0, 6.8, 6.3, 7.8, 9.9, 9.1, 9.5, 8.8, 9.0, 8.8, 8.3, 8.8, 14.2]
pick_x220_z260 n=10: [3.6, 14.9, 2.9, 14.2, 9.1, 11.8, 9.3, 13.0, 10.7, 13.4]
pick_x160_z280 n=6: [12.9, 6.6, 3.4, 50.2, 9.1, 7.7]
pick_x220_z240 n=6: [7.4, 7.1, 9.3, 5.3, 0.9, 4.1]
x220_z200 n=5: [16.4, 25.0, 24.3, 23.7, 10.0]
x240_z220 n=4: [12.2, 12.5, 7.2, 5.7]
x260_z200 n=4: [31.6, 42.6, -, 2.1]

--- PLACE cells with >=2 trials: in_box_dist_mm sequence ---
place_x-220_z160 n=3: [60.5, 241.3, 24.0]

x240_z220 は 12.2→12.5→7.2→5.7 と縮んでいる(改善中)。pick_x200_z260 は 15 試行やって 8〜9mm 帯から動かない(横ばい)。place_x-220_z160 は 60.5→241.3→24.0 と大きく振れている(不安定)。セルによって収束・横ばい・振動がバラバラに存在するのが実態だった。

この 3 点が、その後のツール設計をほぼ決めた。地図の状態は 3 分類(+記録 1 回)で持つ。データを増やすより先に、まず今のデータを読めるようにする。

データを増やす前に、見えるようにする

ここで一度、こう聞かれた。可視化を作るために完走を増やしてデータを溜めておいた方がいいか、と。

答えは逆だった。可視化を先に作ってから増やす方が合理的。

理由は台帳の事実に沿っている。可視化の設計・実装には今の 291 行で十分で、収束・横ばい・振動の 3 パターンが既に実データに揃っている。表示ロジックはデータが 1000 行になっても変わらない。むしろ今データを増やすと、73 セル中 48 セルが訪問 1 回という状態のまま、また薄く広く散るだけになりかねない。どのセルを再訪すれば検証になるのかを判断する道具がないまま走らせても、盲目的な収集になる。測る手段が先、というのは自分の原則そのものだった。

順序はこう決めた。可視化を今の台帳で作る(実機不要)。地図を見て再訪すべきセル・振動を検証すべきセルを選ぶ。そのセルに絞って完走を回す。ここで初めてデータを増やす。「たくさん走らせてから見る」ではなく「見えるようにしてから狙って走らせる」。

モックを作り、言葉を削っていく

デザインのベースは、前に作ったダッシュボード(mecharm_data_blueprint.html)を踏襲した。モノクロ基調、IBM Plex Mono と Noto Sans JP、1.5px の罫線。ただし今回は色(不安定=赤/改善中=緑)が要るので、その 2 色だけ意味を持たせた。

構成は 2 層にした。上段にマット俯瞰の地図、セルをクリックすると下段にそのセルの残差の推移が出る。PICK と PLACE はタブで切り替える。地図のセルは、大きさ=訪問回数、濃さ=最新残差、矢印=適用した補正の平均方向。マットは 620×450mm の実寸比で、アームベースを原点に置いた。

最初のモックは実データ(この 291 行から抽出したもの)を焼き込んだ状態で出した。ここから何度も直すことになる。ほとんどが言葉と見た目の話だった。

最初に引っかかったのは状態語だった。「収束」「振動」という制御工学の言葉を使っていたのだけど、初見で伝わらない、と。実際そのとおりで、見せたいのは「学習が効いているか・効いていないか・様子がおかしいか」の 3 状態でしかない。言葉を並べて選んでもらった。

収束

振動

横ばい

単発

改善中

不安定

変化なし

記録1回

このツールは運用中に一瞬で読む画面なので、2〜4 字で状態が断定できる言葉がいい。「改善中/不安定/変化なし/記録1回」に差し替えた。

その後も削り続けた。凡例に「不安定(ズレが暴れている)」と補足を付けていたら、それも伝わらないから削っていい、と言われて「不安定」だけにした。見出しに付けていた「学習の地図」という呼称も、「補正記憶マップ」で通じるから要らない、と外した。言葉は足すより削る作業だった。

機能もいくつか足した。統計カード(記録1回/変化なし/改善中/不安定)をクリックすると、地図がその状態のセルだけに絞られるようにした。非該当セルは消さずに薄く沈めて、位置関係は保つ。ただ、合計値のカード(記憶セル数・補正記録つき試行)も同じ見た目でクリックできそうに見える問題が出て、これは押せるものだけカード型・押せない合計値は枠なしの数字、と形で区別した。

見た目を作り込む。四角から丸へ、枠から塗りへ

状態の表し方も変えていった。最初は状態を枠線の色(赤枠・緑枠)で示していたのだけど、枠線がデザイン的に弱いという指摘で、塗り自体を状態色にした。赤ベタ=不安定、緑ベタ=改善中、それ以外は残差のグレー濃淡。

次にセルの形。■(四角)から●(丸)にした。四角と矢印が重なると方向が読みにくかったので、丸にして、矢印は状態色に白縁を付けて重なっても見えるようにした。凡例のスウォッチも丸に揃えた。

このあたりは細かい往復が続いた。矢印に色がないと何が何だか分からない、四角と重なるところは白縁にすればいい、点は四角ではなく丸に——という具合に、一つずつ潰していった。

セル位置は推測しない。実コードで確定する

ここで一度、実装に入る前に潰しておくべき問題があった。モックはセルキーの量子化値(x220 など)を円の位置と仮定して描いていたのだけど、run_pick_place.py の _corr_key が floor なのか round なのかを実コードで確認していなかった。20mm 格子なので、間違っていれば最大 10mm ずれて表示される。

実コードを読んだ。

def _corr_key(phase, x_m, z_m):
    """(phase, x, z)[m] → セルキー文字列 (例: 'pick_x180_z260' / 'place_x-40_z100')。
    DAY262: phase プレフィックスを付けて pick/place の名前空間を分離
    (同一 (x,z) セルでも掴み残差と置き残差は別物なので学習を汚染しない).
    CELL_MM=20 で round(中心) して mm 化."""
    cx = int(round(float(x_m) * 1000.0 / CELL_MM) * CELL_MM)
    cz = int(round(float(z_m) * 1000.0 / CELL_MM) * CELL_MM)
    return "{}_x{}_z{}".format(phase, cx, cz)

round だった。つまりキー値はセルの中心を指している。モックは角と仮定していたので、全セルを中心に置き直した。パスを推測で焼かない、という原則の描画版だった。実装に渡す前に、この一点を実コードで確定させておいたことで、後戻りがなくなった。

実装:読み取り専用のビューアと、変更検知サーバー

モックのデザインが固まったので、実データを読む正式版を Claude Code に実装してもらった。構成は 2 ファイル。台帳と補正記憶を読んでブラウザに配るサーバー(corr_map_server.py)と、モック由来の UI(static_corr_map/index.html)。

設計思想は、前に作った別のツール(observe_server.py)と同じにした。localhost 限定・読み取り専用・最新主義・落ちないことを優先。台帳ファイルの変更を検知して、行が増えた瞬間にブラウザへ WebSocket で push する。

更新の検知方式は少し議論した。「数秒ごとに再読み込み」ではなく「CSV が増えたのを感知して更新」にしたかった。方式は 2 つあって、ブラウザ単体(File System Access API でファイル参照を持ち続け、更新日時だけを見て変化時に読み直す)か、ローカルサーバーでファイルシステムイベントを検知するか。run_pick_place.py の 1 試行は分単位なので数秒の遅れは問題にならないけれど、真のイベント駆動で確実に拾える後者を選んだ。observe_server.py と同じ発想でいける。

Claude Code の実装は動作確認まで通った。healthz で台帳 291 行・PICK 61 セル・PLACE 8 セルが出て、これは自分が事前に抽出した期待値と一致した。自分の抽出ロジックと Claude Code の実装が同じ結果を出した、という相互検証にもなっていた。

GET /healthz
{
  "ok": true,
  "ledger_path": "/Volumes/KIOXIA/r2r2r-data/runs/pick_place_ledger_v3.csv",
  "ledger_rows": 291,
  "pick_cells": 61,
  "place_cells": 8,
  "corr_mem_present": true,
  "error": null,
  "rev": 1,
  "connected_ws": 0
}

抽出セル数が 73 でなく 61 なのは、モックが旧キーと新キーを別カウントしていた分をサーバー側で正規化して統合したため。ここも正規化が効いている証拠になった。

codex レビュー:読むだけのツールでも、実機運用に触れる面がある

物理制御を含まない表示層なので、ワークフロー上は外部レビュー必須の区分ではない。ただ台帳 CSV をパースするコードで、しかも実機運用中の台帳を読む。そこだけは実機と接触する面があるので、codex に通した。

依頼のとき一番見てほしいと伝えたのは、この一点だった。run_pick_place.py が台帳へ追記している最中に同じ CSV を読む。そのとき読み取りロックやファイルハンドルの掴みっぱなしで、台帳への追記を妨げないか。表示ツールとはいえ、ここだけは実機の動作に影響しうる。

レビューは通った。❌ なし。追記を妨げる処理はなく、CSV は with 文で開いて読み終われば閉じ、ロックも取らない。指摘は ⚠️ が 1 点だけで、CSV 全体のパース例外時に「該当行だけスキップ」ではなくその回のパース全体がエラー扱いになる、という堅牢性の話だった。通常の不完全な最終行では落ちず、起きても次のポーリングで回復する。物理リスクでもデータ破損リスクでもないので、任意改善として保留し、実データで使ってみて実際にちらつきが出たら直す、とした。起きにくい例外に机上で先回りするより、まず動かして実際に困ったら直す方が、原則にも合っている。

背景を、実機のマットに合わせる

実データで起動して、地図が出た。モックと同じ見た目で 291 行が描画された。ここで最後の変更が入った。

俯瞰図の背景が白ベースだと見づらい、と。実機のマットは黒(OLFA のカッターマット)で、地図も同じ黒地にすれば、目の前のマットと画面が一対一で対応して直感的になる。現実に合わせた方がイメージしやすい。

反転の範囲は、地図エリアだけにした。統計カードやセル詳細の表は白地の方が読みやすいので、マット俯瞰図だけを黒地にする。「マット=黒」の直感と「データ=読みやすい白地」を両立させる。

ただ反転は色の意味の作り直しを伴った。最初、残差のグレー濃淡をそのまま反転(残差小=暗い、大=明るい)にしたら、残差の小さい良いセル——つまり学習が効いているセルが黒地に沈んで消えた。良い状態のものが見えないのは本末転倒だった。

直し方は、残差の大小に関わらず全セルが黒地から最低限浮く下限の明るさを確保すること。濃淡レンジを「黒〜明るい」ではなく「中間グレー〜明るいグレー」に圧縮した。それでもまだ暗いセルが沈んだので、下限をさらに底上げした。最終的に、グレーセルの明るさは残差小で 165、大で 240。未測定セルだけ 140 で少し暗くして区別する。全セルが黒地から浮くようにした。

矢印の縁も直した。白地のときは白縁で潰れを防いでいたけれど、黒地では白縁が逆に浮きすぎて悪目立ちする。地の黒に馴染む黒縁に統一した。

この黒地反転を Claude Code に渡すとき、変更するのは UI の見た目だけで、サーバー側・WebSocket 接続部・データパースには一切触らないよう指示した。反映後の healthz は反転前と同一(291 行・61 セル・8 セル)で、接続部を壊していないことを確認した。

補正記憶マップ 全コード

mechArm 270 Pi 動的追従の限界を探る Log#47 | アーム学習の記憶を人の目に渡すためのダッシュボードを制作|補正記憶マップ 全コード

確定した補正記憶マップの全コードを載せる。これは実データを焼き込んだ確定版で、Claude Code が実装した LIVE 版(static_corr_map/index.html)は、この const DATA = {...} の焼き込みを空にして WebSocket 受信で差し替える接続コードに置き換え、サーバー(corr_map_server.py)が台帳を読んで同じ形の JSON を配る構成になっている。描画ロジック・状態判定・座標変換・フィルタは下記と同一。焼き込みデータ部分だけ、全61+8セルのうち構造が分かる先頭のみを示している(実データは台帳291行から機械的に抽出される)。

状態判定のロジックはこのコードの classify() にある。隣接する試行間で残差が 30mm を超えて跳ねたら不安定、最新残差が最初の 60% 以下かつ 15mm 以下なら改善中、それ以外は変化なし、記録が 1 回だけなら記録1回。座標変換 mmx / mmz は、マットの物理定義(X+ が画面左、Z+ が画面下手前)をそのまま SVG 座標へ写している。セルの中心座標は run_pick_place.py の _corr_key(round(x/20)*20)に合わせてある。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>補正記憶マップ(モック)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
<style>
  :root{
    --ink:#0d0d0d; --paper:#f5f5f0; --card:#ffffff; --soft:#e8e6df;
    --line:#cdc9bf; --mut:#6a635a; --accent:#c0362c; --accent-soft:#f5e4e1;
    --ok:#2f7d3f; --ok-soft:#e9f3ec;
    --sans:"Noto Sans JP","Hiragino Kaku Gothic ProN",sans-serif;
    --mono:"IBM Plex Mono","SFMono-Regular",Menlo,Consolas,monospace;
  }
  *{box-sizing:border-box}
  html{-webkit-text-size-adjust:100%}
  body{margin:0; background:var(--paper); color:var(--ink); font-family:var(--sans); line-height:1.55; padding:30px 18px 60px; font-size:14px}
  .sheet{max-width:1160px; margin:0 auto}
  .mono{font-family:var(--mono)}

  header{border-bottom:2px solid var(--ink); padding-bottom:13px}
  .kicker{font-family:var(--mono); font-size:11px; letter-spacing:.2em; text-transform:uppercase; color:var(--mut)}
  h1{font-size:22px; margin:7px 0 4px; font-weight:700; letter-spacing:-.01em}
  .sub{font-size:12.5px; color:var(--mut); margin:0; max-width:860px}
  .sub .mono{font-size:11.5px}

  .tabs{display:flex; margin:22px 0 0; border-bottom:1.5px solid var(--ink)}
  .tab{appearance:none; background:var(--card); border:1.5px solid var(--ink); border-bottom:none; font-family:var(--sans); font-size:13px; font-weight:700; color:var(--ink); padding:11px 22px; cursor:pointer; border-radius:4px 4px 0 0; margin-right:6px; transform:translateY(1.5px)}
  .tab[aria-selected="true"]{background:var(--ink); color:var(--paper)}
  .tab:hover:not([aria-selected="true"]){background:var(--soft)}
  .tab .cnt{font-family:var(--mono); font-weight:600; font-size:11px; margin-left:7px; color:inherit; opacity:.65}

  h2.sec{font-family:var(--mono); font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:var(--mut); border-bottom:1px solid var(--line); padding-bottom:6px; margin:26px 0 14px}
  h2.sec .n{color:var(--ink); font-weight:700; margin-right:8px}

  .figwrap{border:1.5px solid var(--ink); border-radius:4px; background:var(--card); padding:16px 14px; overflow-x:auto}
  .figdark{background:#1a1a1a; border-color:#1a1a1a}
  .figdark .cap{color:#8f8a80}
  .fig{width:100%; height:auto; min-width:620px; display:block}
  .cap{font-size:11.5px; color:var(--mut); margin:9px 2px 0}
  .legend{display:flex; flex-wrap:wrap; gap:6px 18px; margin:10px 2px 0; font-size:11px; color:var(--mut); align-items:center}
  .lgnd{display:inline-flex; align-items:center; gap:6px}
  .swb{display:inline-block; width:14px; height:14px; border:1.4px solid var(--ink)}
  .swc{display:inline-block; width:14px; height:14px; border-radius:50%}

  /* stats strip */
  .stats{display:flex; flex-wrap:wrap; gap:10px; margin:16px 0 0}
  .stat{border:1.5px solid var(--ink); border-radius:4px; background:var(--card); padding:8px 14px; min-width:118px}
  .stat .v{font-family:var(--mono); font-size:20px; font-weight:700; line-height:1.15}
  .stat .v.warn{color:var(--accent)}
  .stat .v.ok{color:var(--ok)}
  .stat .k{font-size:10.5px; color:var(--mut); letter-spacing:.04em}
  button.stat{appearance:none; font-family:var(--sans); text-align:left; cursor:pointer}
  button.stat:hover{background:var(--soft)}
  button.stat.on{background:var(--ink)}
  button.stat.on .k{color:var(--paper)}
  button.stat.on .v{color:var(--paper)}
  button.stat.on .v.ok{color:#8fd4a0}
  button.stat.on .v.warn{color:#f0a49b}
  .stat.plain{border:none; background:transparent; padding-left:2px}
  .statsep{width:1px; background:var(--line); align-self:stretch; margin:0 4px}
  .filter-note{font-size:11.5px; color:var(--mut); margin:8px 2px 0; min-height:1.2em}

  /* detail panel */
  .detail{border:1.5px solid var(--ink); border-radius:4px; background:var(--card); padding:14px; margin-top:14px; display:none}
  .detail.show{display:block; animation:fade .18s ease}
  @keyframes fade{from{opacity:0; transform:translateY(4px)}to{opacity:1; transform:none}}
  .d-head{display:flex; flex-wrap:wrap; align-items:baseline; gap:8px 14px; border-bottom:1px solid var(--line); padding-bottom:8px; margin-bottom:10px}
  .d-key{font-family:var(--mono); font-weight:700; font-size:15px}
  .badge{font-family:var(--mono); font-size:10.5px; font-weight:600; padding:1px 8px; border-radius:3px; border:1.4px solid var(--ink)}
  .badge.osc{background:var(--accent); border-color:var(--accent); color:#fff}
  .badge.conv{background:var(--ok); border-color:var(--ok); color:#fff}
  .badge.flat{background:var(--soft)}
  .badge.single{background:var(--card); color:var(--mut); border-color:var(--line)}
  .d-hint{font-size:11.5px; color:var(--mut)}
  table.tt{border-collapse:collapse; width:100%; font-family:var(--mono); font-size:11.5px; margin-top:8px}
  table.tt th{font-family:var(--sans); font-size:10.5px; color:var(--mut); font-weight:500; text-align:right; padding:3px 8px; border-bottom:1px solid var(--ink)}
  table.tt th:first-child{text-align:left}
  table.tt td{text-align:right; padding:3px 8px; border-bottom:1px solid var(--line); white-space:nowrap}
  table.tt td:first-child{text-align:left; color:var(--mut)}
  table.tt td.big{color:var(--accent); font-weight:600}
  .spark-cap{font-size:10.5px; color:var(--mut); margin-top:2px}
  .empty{font-size:12.5px; color:var(--mut); padding:6px 2px}

  footer{margin-top:34px; border-top:1px solid var(--line); padding-top:10px; font-size:11px; color:var(--mut); font-family:var(--mono)}
  @media (max-width:640px){ body{padding:24px 12px 48px} .tab{padding:9px 14px; font-size:12px} }
</style>
</head>
<body>
<div class="sheet">

<header>
  <div class="kicker">CORRECTION MEMORY MAP — MOCK</div>
  <h1>補正記憶マップ</h1>
  <p class="sub">マット(620×450mm)を20mm格子で俯瞰し、セルごとの補正記憶の状態を表示する。
  正方形の<strong>大きさ=訪問回数 n</strong>、<strong>濃さ=最新の残差(mm)</strong>、<strong>矢印=適用補正Δの平均方向</strong>。
  セルの色は学習の状態: <span style="color:var(--accent); font-weight:700">赤=不安定</span> / <span style="color:var(--ok); font-weight:700">緑=改善中</span>、グレーの濃淡=最新残差。セルをクリックすると下に履歴が出る。統計カードをクリックすると地図がその状態のセルに絞られる。
  <span class="mono">データ: pick_place_ledger_v3.csv 291行(2026-06-18〜07-03)埋め込みスナップショット</span></p>
</header>

<div class="tabs" role="tablist">
  <button class="tab" id="tab-pick" role="tab" aria-selected="true" onclick="switchPhase('pick')">PICK 補正記憶<span class="cnt" id="cnt-pick"></span></button>
  <button class="tab" id="tab-place" role="tab" aria-selected="false" onclick="switchPhase('place')">PLACE 補正記憶<span class="cnt" id="cnt-place"></span></button>
</div>

<div class="stats" id="stats"></div>
<div class="filter-note" id="filter-note"></div>

<h2 class="sec"><span class="n">01</span>マット俯瞰</h2>
<div class="figwrap figdark">
  <svg id="map" class="fig" viewBox="0 0 700 530" xmlns="http://www.w3.org/2000/svg"></svg>
  <div class="legend">
    <span class="lgnd"><span class="swc" style="width:8px;height:8px; background:#b5b1a8"></span><span class="swc" style="width:14px;height:14px; background:#b5b1a8"></span><span class="swc" style="width:20px;height:20px; background:#b5b1a8"></span>大きさ = 訪問回数 n</span>
    <span class="lgnd">明るさ = 最新残差 <span class="swc" style="background:#878780"></span>→<span class="swc" style="background:#b8b4a9"></span>→<span class="swc" style="background:#ebe7dd"></span> 0〜40mm+(明るいほど残差大)</span>
    <span class="lgnd"><span class="swc" style="background:var(--accent)"></span>不安定</span>
    <span class="lgnd"><span class="swc" style="background:var(--ok)"></span>改善中</span>
    <span class="lgnd"><span style="font-family:var(--mono)">↗</span>適用Δの平均方向(状態色・黒縁・×2拡大表示)</span>
  </div>
  <p class="cap">座標系はDAY163-164確定のUnity/マット定義(X+=画面左・Z+=画面下手前)。⌖=アームベース(原点)。セルキー値=20mm格子のセル中心(run_pick_place.py の _corr_key 実装で確定)。背景は実機マット(黒)に合わせている。状態の判定は暫定ロジック(下記脚注)。</p>
</div>

<h2 class="sec"><span class="n">02</span>セル詳細(クリックで表示)</h2>
<div class="empty" id="d-empty">地図上のセルをクリックすると、そのセルの試行履歴と残差の推移が表示されます。</div>
<div class="detail" id="detail"></div>

<footer>
  MOCK v0.1 — 状態判定(暫定): n=1→記録1回 / 隣接試行間の跳ね>30mm→不安定 / 最新残差が最初の60%以下かつ15mm以下→改善中 / それ以外→変化なし。閾値は仮置き。実装版はCSV/JSONをローカル読み込みに置換する。
</footer>

</div>

<script>
const DATA = {"pick":[{"x":140,"z":260,"n":5,"trials":[{"ts":"06-26T21:01","dx":null,"dz":null,"dist":null,"v":""},{"ts":"06-26T21:02","dx":null,"dz":null,"dist":null,"v":""},{"ts":"06-26T21:03","dx":null,"dz":null,"dist":null,"v":""},{"ts":"06-26T21:11","dx":null,"dz":null,"dist":null,"v":""},{"ts":"06-26T21:24","dx":null,"dz":null,"dist":13.7,"v":"yes"}]},{"x":260,"z":280,"n":1,"trials":[{"ts":"06-26T21:27","dx":null,"dz":null,"dist":106.5,"v":"yes"}]},{"...":"(全61セル。実データは台帳から自動抽出)"}],"place":[{"x":-220,"z":160,"n":3,"trials":[{"ts":"07-03T20:23","dx":-20.8,"dz":17.9,"dist":60.5,"v":"yes"},{"ts":"07-03T20:25","dx":-10.6,"dz":12.5,"dist":241.3,"v":"no"},{"ts":"07-03T20:27","dx":-10.6,"dz":12.5,"dist":24.0,"v":"yes"}]},{"...":"(全8セル)"}]};  /* ← 実データは台帳291行から自動抽出。全61+8セル。ここでは構造を示すため先頭のみ */

/* ---- 座標変換: mm → svg ----
   マット: X +310(画面左)〜-310(右) / Z -89(上)〜+361(下)
   svg: マット左上を (40,40) に、1mm=1unit */
const OX=40, OY=40;
const mmx = x => OX + (310 - x);
const mmz = z => OY + (z + 89);

function classify(cell){
  const ds = cell.trials.map(t=>t.dist).filter(d=>d!=null);
  if(ds.length<=1) return 'single';
  for(let i=1;i<ds.length;i++){ if(Math.abs(ds[i]-ds[i-1])>30) return 'osc'; }
  const first=ds[0], last=ds[ds.length-1];
  if(last<=first*0.6 && last<=15) return 'conv';
  return 'flat';
}
function lastDist(cell){
  const ds = cell.trials.map(t=>t.dist).filter(d=>d!=null);
  return ds.length? ds[ds.length-1] : null;
}
function meanDelta(cell){
  const xs=cell.trials.map(t=>t.dx).filter(v=>v!=null);
  const zs=cell.trials.map(t=>t.dz).filter(v=>v!=null);
  if(!xs.length && !zs.length) return null;
  const mx = xs.length? xs.reduce((a,b)=>a+b,0)/xs.length : 0;
  const mz = zs.length? zs.reduce((a,b)=>a+b,0)/zs.length : 0;
  return [mx,mz];
}
const CLS_LABEL={single:'記録1回', conv:'改善中', osc:'不安定', flat:'変化なし'};

let phase='pick', selected=null;

function drawMap(){
  const svg=document.getElementById('map');
  const cells=DATA[phase];
  let s='';
  /* マット枠 */
  s+=`<rect x="${OX}" y="${OY}" width="620" height="450" fill="#000000" stroke="#4a463f" stroke-width="1.5"/>`;
  /* 20mm格子(薄) */
  for(let gx=-300; gx<=300; gx+=20) s+=`<line x1="${mmx(gx)}" y1="${OY}" x2="${mmx(gx)}" y2="${OY+450}" stroke="#3d3931" stroke-width="0.6"/>`;
  for(let gz=-80; gz<=360; gz+=20) s+=`<line x1="${OX}" y1="${mmz(gz)}" x2="${OX+620}" y2="${mmz(gz)}" stroke="#3d3931" stroke-width="0.6"/>`;
  /* 軸ラベル */
  s+=`<text x="${mmx(310)+2}" y="${OY-8}" font-family="var(--mono)" font-size="10" fill="#8f8a80">x=+310(画面左)</text>`;
  s+=`<text x="${mmx(-310)}" y="${OY-8}" text-anchor="end" font-family="var(--mono)" font-size="10" fill="#8f8a80">x=-310(右)</text>`;
  s+=`<text x="${OX-4}" y="${mmz(-89)+10}" text-anchor="end" font-family="var(--mono)" font-size="10" fill="#8f8a80">z=-89</text>`;
  s+=`<text x="${OX-4}" y="${mmz(361)}" text-anchor="end" font-family="var(--mono)" font-size="10" fill="#8f8a80">z=+361</text>`;
  /* アームベース(原点) */
  s+=`<g stroke="#d0ccc2" stroke-width="1.5"><line x1="${mmx(0)-9}" y1="${mmz(0)}" x2="${mmx(0)+9}" y2="${mmz(0)}"/><line x1="${mmx(0)}" y1="${mmz(0)-9}" x2="${mmx(0)}" y2="${mmz(0)+9}"/><circle cx="${mmx(0)}" cy="${mmz(0)}" r="5.5" fill="none"/></g>`;
  s+=`<text x="${mmx(0)+12}" y="${mmz(0)+4}" font-family="var(--mono)" font-size="10" fill="#8f8a80">ARM BASE (0,0)</text>`;

  for(const c of cells){
    const cls=classify(c), ld=lastDist(c);
    const dim = filter && cls!==filter;
    const cx=mmx(c.x), cy=mmz(c.z); /* キー値=セル中心 (run_pick_place.py _corr_key: round(x/20)*20) */
    const side=Math.min(6+Math.min(c.n,12)*1.3, 20);
    const t = ld==null? 0 : Math.min(ld,40)/40;
    /* 黒地: 全セルが黒から明確に浮くよう下限を底上げ。残差小=明るめグレー(165)→残差大=白に近い(240)。
       未測定(ld==null)のみやや暗め(140)で区別するが、それでも黒地から浮く */
    const base = ld==null? 140 : Math.round(165+(240-165)*t);
    const g = base;
    let fill;
    if(cls==='osc') fill='var(--accent)';
    else if(cls==='conv') fill='var(--ok)';
    else fill=`rgb(${g},${g},${Math.round(g*0.97)})`;
    const arrowCol = cls==='osc'? 'var(--accent)' : cls==='conv'? 'var(--ok)' : '#e0dcd2';
    const r = side/2;
    const sel = (selected && selected.x===c.x && selected.z===c.z);
    s+=`<g class="cellg" style="cursor:pointer" opacity="${dim?0.15:1}" onclick="selectCell(${c.x},${c.z})">`;
    if(sel) s+=`<circle cx="${cx}" cy="${cy}" r="${r+5}" fill="none" stroke="#d0ccc2" stroke-width="1.2" stroke-dasharray="3 2"/>`;
    s+=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"/>`;
    const md=meanDelta(c);
    if(md && (Math.abs(md[0])>1 || Math.abs(md[1])>1)){
      /* Δ(dx,dz)方向。dxはマットx方向=画面では反転、dzは画面下方向 */
      const ax=-md[0]*2, az=md[1]*2;
      const L=Math.hypot(ax,az), cap=26;
      const k=L>cap? cap/L : 1;
      const ex=cx+ax*k, ey=cy+az*k;
      const ang=Math.atan2(ey-cy,ex-cx);
      const head=`M ${ex} ${ey} L ${ex-6*Math.cos(ang-0.42)} ${ey-6*Math.sin(ang-0.42)} L ${ex-6*Math.cos(ang+0.42)} ${ey-6*Math.sin(ang+0.42)} Z`;
      const halo = '#000000';  /* 黒地ベースに合わせフチは黒で統一 */
      s+=`<line x1="${cx}" y1="${cy}" x2="${ex}" y2="${ey}" stroke="${halo}" stroke-width="4" stroke-linecap="round"/>`;
      s+=`<path d="${head}" fill="none" stroke="${halo}" stroke-width="3" stroke-linejoin="round"/>`;
      s+=`<line x1="${cx}" y1="${cy}" x2="${ex}" y2="${ey}" stroke="${arrowCol}" stroke-width="1.6"/>`;
      s+=`<path d="${head}" fill="${arrowCol}"/>`;
    }
    s+=`</g>`;
  }
  svg.innerHTML=s;
}

let filter=null; /* null=全表示 / 'single'|'conv'|'osc'|'flat' */
function setFilter(f){
  filter = (filter===f)? null : f;  /* 同じカード再クリックで解除 */
  selected=null;
  document.getElementById('detail').className='detail';
  document.getElementById('d-empty').style.display='block';
  drawStats(); drawMap();
}
function drawStats(){
  const cells=DATA[phase];
  const n1=cells.filter(c=>c.n===1).length;
  const conv=cells.filter(c=>classify(c)==='conv').length;
  const osc=cells.filter(c=>classify(c)==='osc').length;
  const flat=cells.filter(c=>classify(c)==='flat').length;
  const trials=cells.reduce((a,c)=>a+c.n,0);
  const on=f=> filter===f? ' on':'';
  document.getElementById('stats').innerHTML=
    `<div class="stat plain"><div class="v">${cells.length}</div><div class="k">記憶セル数</div></div>`+
    `<div class="stat plain"><div class="v">${trials}</div><div class="k">補正記録つき試行</div></div>`+
    `<div class="statsep"></div>`+
    `<button class="stat pick${on('single')}" onclick="setFilter('single')"><div class="v">${n1}</div><div class="k">記録1回のみ</div></button>`+
    `<button class="stat pick${on('flat')}" onclick="setFilter('flat')"><div class="v">${flat}</div><div class="k">変化なし</div></button>`+
    `<button class="stat pick${on('conv')}" onclick="setFilter('conv')"><div class="v ok">${conv}</div><div class="k">改善中</div></button>`+
    `<button class="stat pick${on('osc')}" onclick="setFilter('osc')"><div class="v warn">${osc}</div><div class="k">不安定</div></button>`;
  document.getElementById('cnt-pick').textContent=`(${DATA.pick.length})`;
  document.getElementById('cnt-place').textContent=`(${DATA.place.length})`;
  document.getElementById('filter-note').textContent =
    filter? `絞り込み中: ${CLS_LABEL[filter]}(カード再クリックで解除)` : '';
}

function sparkline(ds){
  if(ds.length<2) return '';
  const W=220,H=44,P=4;
  const mx=Math.max(...ds), mn=Math.min(...ds);
  const sx=i=>P+i*(W-2*P)/(ds.length-1);
  const sy=v=>mx===mn? H/2 : P+(H-2*P)*(1-(v-mn)/(mx-mn));
  let p=ds.map((v,i)=>`${i?'L':'M'} ${sx(i).toFixed(1)} ${sy(v).toFixed(1)}`).join(' ');
  let dots=ds.map((v,i)=>`<circle cx="${sx(i).toFixed(1)}" cy="${sy(v).toFixed(1)}" r="2.2" fill="var(--ink)"/>`).join('');
  return `<svg width="${W}" height="${H}" style="display:block"><path d="${p}" fill="none" stroke="var(--ink)" stroke-width="1.4"/>${dots}</svg>
  <div class="spark-cap mono">min ${mn.toFixed(1)} / max ${mx.toFixed(1)} mm</div>`;
}

function selectCell(x,z){
  const c=DATA[phase].find(c=>c.x===x&&c.z===z);
  selected=c; drawMap();
  const cls=classify(c);
  const distLabel = phase==='pick'? 'tip残差 dist(mm)' : '箱中心距離 in_box(mm)';
  const ds=c.trials.map(t=>t.dist).filter(d=>d!=null);
  let rows=c.trials.map(t=>{
    const big = t.dist!=null && t.dist>40;
    return `<tr><td>${t.ts||''}</td><td>${t.dx==null?'—':t.dx.toFixed(1)}</td><td>${t.dz==null?'—':t.dz.toFixed(1)}</td><td class="${big?'big':''}">${t.dist==null?'—':t.dist.toFixed(1)}</td><td>${t.v||'—'}</td></tr>`;
  }).join('');
  document.getElementById('d-empty').style.display='none';
  const el=document.getElementById('detail');
  el.className='detail show';
  el.innerHTML=`
    <div class="d-head">
      <span class="d-key">${phase}_x${c.x}_z${c.z}</span>
      <span class="badge ${cls}">${CLS_LABEL[cls]}</span>
      <span class="d-hint mono">n=${c.n}</span>
      <span class="d-hint">${distLabel} の推移 ↓</span>
    </div>
    ${sparkline(ds)}
    <table class="tt">
      <tr><th>timestamp</th><th>適用Δx</th><th>適用Δz</th><th>残差dist</th><th>verdict</th></tr>
      ${rows}
    </table>`;
}

function switchPhase(p){
  phase=p; selected=null; filter=null;
  document.getElementById('tab-pick').setAttribute('aria-selected', p==='pick');
  document.getElementById('tab-place').setAttribute('aria-selected', p==='place');
  document.getElementById('detail').className='detail';
  document.getElementById('d-empty').style.display='block';
  drawStats(); drawMap();
}
switchPhase('pick');
</script>
</body>
</html>

できたもの

mechArm 270 Pi 動的追従の限界を探る Log#47 | アーム学習の記憶を人の目に渡すためのダッシュボードを制作|できたもの

補正記憶マップができた。マットを 20mm 格子で俯瞰し、セルごとに大きさ=訪問回数、明るさ=最新残差、矢印=補正の平均方向を出す。赤=不安定、緑=改善中。統計カードで絞り込み、セルをクリックすると残差の推移がスパークラインで出る。台帳が増えれば、開いたまま自動で反映される。背景は実機のマットと同じ黒。

この地図で初めて、いくつかのことがひと目で分かるようになった。マットの手前側にセルが集中していて奥はほぼ空白なこと(届く範囲で学習が進んでいる)。改善中の緑セルと不安定の赤セルが同じ領域に混在していること。pick_x200_z260 のように 15 回訪問しても横ばいのセルがあること。これらは台帳の CSV を掘れば分かる話ではあったけれど、掘らないと分からなかった。地図にした瞬間、掘らずに見えるようになった。

残っているのは、実機での自動反映確認だけ。ブラウザを開いたまま 1 試行走らせて、台帳に行が増えた数秒後に地図が変わるか。これは機体が手元にあるときにやる。

補正記憶は、効いているかどうかを数字で追うことはできていた。でもそれが「マットのどこで、どう効いているか」は、地図になるまで誰も見ていなかった。機械が勝手に学んだ中身を、人の目に渡す。今回作ったのはそれだけのものだけど、評価軸が「動いている間、失敗を学んで動きが良くなっていくか」であるなら、その良くなっていく過程を人が読めることは、たぶん仕組みそのものと同じくらい要る。

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