# フィジカルAI研究室の中身を表紙で伝える ― 記事サムネイル26本を実データの線に

- 記録: LOG471（ひとり研究所の裏側）
- 公開: 2026/11/8
- 記事のページ: https://pixelstomotors.com/record/physical-ai-lab-thumbnails-drawn-from-data
- サイト: Pixels to Motors（ADAPT DESIGN / 島津 栄一） https://pixelstomotors.com/
- 要約: pixelstomotors.com のフィジカルAI研究室の記事サムネイル26本を、各回の実データ（関節の動き・loss・爪の電流など）の線と点に作り直した。色は紙色・墨・赤・青で、青は主役、赤はつかんでいる間、白黒は人の動き。

> AI が読むための記事の全文です（コード・実行ログを含む）。引用するときは上の「記事のページ」の住所を使ってください。

---

**pixelstomotors.com のフィジカルAI研究室の記事サムネイル26本を、英語の見出しと記号の図の表紙から、その回の実データ（ロボットアーム mechArm 270 の関節の動き、学習の loss、爪の電流など）を描いた線と点だけの表紙に作り直しました。** 色は紙色・墨・赤・青の4つに絞り、青はその回の主役（Physical Intelligence の π0.5 が動かした所や、測った値）、赤はグリッパーが物をつかんでいる間、白と黒は人の動き（お手本や手での直し）を表します。

作り直したことで、次のことが変わりました。

- 一覧で回ごとの違いが形で分かる：AI が初めて箱に入れた回は青と赤の線が箱まで届き、AI が「待ち時間」まで覚えた回は青い線が平らに伸び、爪の電流を読んだ回は青い線が赤く跳ね上がる。題名を読む前に、どんな回だったかの見当がつく
- トップページとシリーズの見た目がつながる：トップのフィジカルAI研究室のカード（お手本19本の関節の線）と同じ描き方なので、トップから来た人は同じ研究の続きだと分かる
- 新しい回の表紙を作る手間が減る見込み：KIOXIA の実データを書き出すコード（extract.py）と描くコード（lab_thumb.py）に1回分の指定を足せば表紙ができる。前に1枚ずつ作っていたときの時間は測っていない
- 時間：依頼（16:00）から最初の公開（16:20・写真を使わない19本と Studio から移した回の5本）まで20分。最後の直し（LOG348）の公開は 16:29

この記事では、作り直した手順よりも、なぜこのデザインにしたのかを中心に書きます。依頼は4つでした。

> フィジカルAI研究所のサムネイルをTOPのカードのサムネイルに合わせて記事のサムネイルを変更したい。
> 1.データを表示
> 2.詳細ではなく、抽象的な印象
> 3.テキストは少なくする
> 4.色は増やしても青系だけにする（白（ベージュ）、黒、赤、青で構成の意味）

4つとも、記事一覧のカードという小さな場所で、研究の記録を1枚の絵で伝えるための決まりです。1つずつ、意図を書きます。

| 決まり | 意図 | サムネイルでの形 |
|---|---|---|
| データを表示 | 研究室の記事は記録。中身そのものが表紙になる | 各回の実データ（KIOXIA の記録）をそのまま線と点に |
| 抽象的な印象 | カードは幅250〜350px。数字は読めないが、形は残る | 目盛り・数値・凡例を描かず、線の形だけ |
| テキストを少なく | 題名はカードの下にある。表紙で繰り返さない | 左上と右上の短い英字ラベル2つだけ |
| 色は青系だけ足す | 色の意味をシリーズ全体で同じにする | 紙色・墨＝地と人、赤＝つかんでいる間、青＝主役 |

[図解: LOG471_color_roles.html](https://pixelstomotors.com/embeds/LOG471_color_roles.html)


## 作業ログ ― 4つの決まりと、それぞれの意図

### データを表示する：表紙が、その回の記録そのものになる

フィジカルAI研究室は、ロボットアームが動きを覚える環境を自宅に作り、誰でも同じ手順で始められる形で残すシリーズです。記事は作業ログで、いちばんの中身は、実機で動かした記録、学習の記録、測った値です。

前の表紙は、大きな英語の見出し（「THREE SPOTS. THREE IN THE BOX.」など）と、写真や棒の図を組み合わせた形でした。見出しが強いので、一覧では「文字の表紙」が並んで見えます。図も、記事によってはアームと箱の記号に近いものでした。

新しい表紙は、その回の記録を KIOXIA（外付けの SSD）から読んで、そのまま線にしています。たとえば Log#22（LOG450）は、11月6日に π0.5 で動かした9回の、肩の関節（J1）の角度の動きです。π0.5 が動かした所は青、人がゲームパッドで直した所は白、グリッパーが閉じている所は赤。9本の線が重なり、途中で白い線が割り込んでいるのが見えます。これは記事に書いた「AI が動かし、ずれたら人が直す」の、そのままの記録です。

データで描くと、回ごとに必ず違う形になります。同じ形の表紙を作ろうとしても作れません。前の決まりで苦労していた「似た表紙が並ぶ」を、形の面から避けられます。

- ねらい：表紙を飾りではなく、その回の記録の一部にする
- 変わったこと：26本すべてを、各回の実データ（π0.5 の実機の実行69回、学習の loss 2本、お手本の記録51本、爪の電流1,493点、FK テーブルの6,090点、台帳の382試行など）か、記事に書いた測定値で描いた
- 見込める効果（見込み）：表紙を見て開いた人が、本文の中身との食い違いを感じない。シリーズのページ（/series6）からの記事の開かれ方を、来週のサイトの計測で前の週と比べる

![上はトップのフィジカルAI研究室のカード。左の列は前の表紙（LOG363・LOG450・LOG461）、右の列は試作した新しい表紙](https://pixelstomotors.com/media/LOG471_01_top_card_old_new.png)

### 抽象的な印象にする：カードの幅では、数字より形が残る

一覧のカードの表紙は、幅が約250〜350px です。この大きさでは、目盛りの数字や、線の横に書いた注記は読めません。読めない文字は、表紙の中で模様になり、ノイズになります。

そこで、目盛り・数値・凡例・注記は描かず、線の形だけを残しました。残すのは次のような形です。

| 回 | 形 | 記事の中身 |
|---|---|---|
| Log#11（LOG358） | 青い線が上がって、平らに伸びる | π0.5 がお手本の「止まっている時間」まで覚えた |
| Log#23（LOG461） | 青い線が赤く跳ね上がり、ゆっくり下がる | ブロックをつかむと爪の電流が約15から約870へ |
| Log#14（LOG401） | 青い丸と輪が3段 | 3か所に5回ずつ置き、15回中9回箱に入れた |
| Log#9（LOG352） | 黒・白の線が束になり、途中が赤い | 手で教えたお手本19本 |
| Log#7（LOG348） | 2本の青い道すじが同じ所で止まる | 学習を倍にしても、手を伸ばす先は変わらなかった |

細かいことは本文に書いてあります。表紙は、本文を開く前の「どんな回か」の見当をつけるところまでで止めています。

### テキストを少なくする：題名はカードの下にある

一覧のカードには、表紙の下に LOG 番号・シリーズ名・日付・題名が出ます。表紙の中に大きな見出しを入れると、同じことを2回言うことになります。

文字は、左上と右上の短い英字ラベルの2つだけにしました。左は何の線か（「π0.5」「TRAINING LOSS」「GRIPPER CURRENT」）、右はその回の結果か区切り（「PICK → PLACE」「9 / 15」「FIRST IN THE BOX」）。トップのカードの「DEMONSTRATIONS」「PICK → PLACE」と同じ置き方・同じ字（JetBrains Mono）です。

### 色は青系だけ足す：赤と青の意味を、シリーズのどこでも同じにする

トップのカードは、紙色と墨のツートンに、赤だけを使っています。赤は「グリッパーが箱をつかんでいる間」です。記事の表紙でも、赤はこの意味だけにしました。どの回の表紙でも、赤い区間はつかんでいる間です。

足した色は青だけです。青は「その回の主役」。π0.5 が動かした線、測った電流、loss の線、成功した試行の丸が青になります。記事の図解でも、青を「その図の主役」と決めています（LOG469）。表紙と図解で青の意味がそろいます。

人の動き（手で教えたお手本、ゲームパッドでの直し）は、地の色の反対（紙色の地なら墨、黒い地なら紙色）で描きます。こうすると、AI と人の分担が色で分かれます。Log#17（LOG417）や Log#22（LOG450）では、青い線の途中に白い線が入り、そこが人の直しです。

青系だけにしたのは、シリーズのページで26枚が並んだときに、色の数が増えないようにするためです。黒い地では同じ青が沈んで見えたので、黒い地だけ少し明るい青（#4f8cf5）にしています。紙色の地の青は図解と同じ #2f6fdf です。

| 色 | 値 | 意味 |
|---|---|---|
| 紙色 | #f1f0ec | 地。黒い地では人の動き |
| 墨 | #111111 | 地。紙色の地では人の動き |
| 赤 | #e0262c | グリッパーがつかんでいる間 |
| 青 | #2f6fdf（黒い地では #4f8cf5） | その回の主役 |

### 構図は3つと写真2つ：隣どうしで同じにしない

線の描き方が同じでも、全部が同じ構図だと一覧は単調になります。構図は、トップのカードと同じ上下2段（split）、黒1段（dark）、紙色1段（paper）の3つを基本にし、一覧の並び（LOG 番号の大きい順）で隣どうしが同じにならないよう、記事ごとに割り当てました。割り当ては lab_thumb.py の ORDER に書いています。

ゲームパッドの準備の回（LOG419）と、学び直した π0.5 の回（LOG422）は、前に実機の写真の表紙を使っていました。この2本は、白黒にした実機の写真と、グラフを左右に並べた形にしています（エイイチが試してほしいと言った形）。

![フィジカルAI研究室の新しい表紙26本を一覧の並び（LOG461 から LOG225 まで）で並べたもの。上下2段・黒1段・紙色1段・写真とグラフが、隣どうしで続かない](https://pixelstomotors.com/media/LOG471_02_all_26_thumbnails.png)


## 作業ログ ― GPU ラックの写真は使わない、似た2枚は描き直す

最初の試作で、GPU を借りて学習した回（LOG337・341・348・355・417）に、導入比較ガイドの背景の GPU ラックの写真と、グラフを組み合わせた形を作りました。これは使わないことになりました。

> これはNG。GPUラックの写真は作っちゃダメ

GPU ラックの写真は、この研究室で実際に借りた GPU の写真ではありません。実データだけで作る表紙の中に入ると、そこだけ記録ではない絵になります。5本とも写真なしの形にし、そのあと隣どうしの構図が続かないよう、LOG352 を紙色1段から上下2段に変えました。lab_thumb.py には、GPU ラックの写真を使わないことを書いています。

![使わなかった試作。左に2本のお手本の線、右に GPU ラックの写真](https://pixelstomotors.com/media/LOG471_03_gpu_rack_photo_ng.png)

公開したあと、シリーズのページで LOG348 と LOG355 がそっくりだと分かりました。

> 348と355がそっくりじゃない？

どちらも紙色の地に、下がっていく青い loss の線が1本です。loss の線は、学習がうまく進むとどの回も似た形になります。データで描けば必ず違う形になる、というのは、違うデータを選んだときの話でした。

![シリーズのページ。右上の LOG355 と右下の LOG348 が、どちらも紙色の地に下がっていく loss の線](https://pixelstomotors.com/media/LOG471_04_348_355_similar.png)

LOG348 の記事の中身は「学習を倍にしても、手を伸ばす先は変わらなかった」です。そこで loss ではなく、実機で動かした2回の道すじにしました。根元（J0）とひじ（J2）の角度を横と縦に取り、時間の線ではなく形として描くと、2本の青い道すじが同じ所に行き着き、そこで止まります。右上のラベルは「SAME AIM」です。

- ねらい：同じ種類のデータ（loss）が並んで、一覧で同じ表紙に見えるのを避ける
- 変わったこと：LOG348 を loss の線から、実機2回の関節の道すじに変えた。loss で描く回は LOG355（紙色）と LOG341（黒）の2本で、一覧では間に5本はさまる
- 見込める効果（見込み）：一覧で同じ形が続かない。回の中身（同じ所を狙う）が表紙の形に出る

![LOG348 の新しい表紙。2本の青い道すじが同じ所で止まり、輪で囲んである](https://pixelstomotors.com/media/LOG471_05_348_same_aim.jpg)


## 作業ログ ― KIOXIA の実データを書き出すコード

元のデータは、Mac につないだ KIOXIA の r2r2r-data にあります。Mac の上で読むだけのコードを動かし、表紙に要る値だけを小さな JSON（約500KB）にしました。元のデータは変えていません。

読んだデータは次のとおりです。

| データ | 場所 | 使った回 |
|---|---|---|
| π0.5 の実行の記録（log.jsonl・recording/states.csv） | runs/pi05_policy/ | LOG345・348・358・361・363・403・407・417・422・450 |
| 学習の loss | checkpoints/pi05_mecharm_lora_v1/logs/train.log・lab/Log10/logs/train.log（ほかの回の分も書き出した） | LOG341・355 |
| お手本の記録（episode.npz） | datasets/mecharm_redblock_box_* | LOG337・352・460 |
| 手で教えた動きの再生（drag_v2） | runs/experiments/waypoint_streaming_replay/LOG349_20261018/ | LOG349 |
| LIBERO の成否と GPU メモリ | lab/Log03/logs/ | LOG327 |
| ゲームパッドの長押しの測定 | diag/jog_probe_20261105_153401/ | LOG446 |
| ゲームパッドの押した記録 | runs/experiments/gamepad_teleop/teleop_20261102_192657_dry/ | LOG419 |
| 爪の電流（サーボ7の番地69・70） | diag/servo_current_20261107/180051_pad/ | LOG461 |
| FK テーブルの点 | fk_tables/fk_scan_motion_based_v2.csv | LOG225 |
| 実機の通し試行の台帳 | runs/pick_place_ledger_v3.csv | LOG277 |

爪の電流は、番地69が下の8ビット、番地70が上の8ビットで、いちばん上の1ビットは向きを表します（32,773 のような値になる）。表紙には大きさだけを使うので、`& 0x7FFF` で向きのビットを外しています。

LOG401・LOG436・LOG326・LOG234 は、記事に書いた測定値（3か所の成否、送信にかかった時間、往復の内訳、Mac の空き容量）をそのまま使いました。

### iCloud の フィジカルAI研究室を作る/thumb_data/extract.py（実データを JSON に書き出す）

```python
"""フィジカルAI研究室の記事サムネイル用に、KIOXIA の実データを小さな JSON に書き出す（読むだけ。元データは変えない）。
使い方: cd /Volumes/KIOXIA/r2r2r-data && python3 <このファイル> <出力.json>
描き方は p2m-site の tools/lab_thumbs/lab_thumb.py。"""
import json, csv, glob, os, sys, re, random
import numpy as np
R = lambda v: round(float(v), 1)

def chunks(d):
    pts = []
    for l in open(d + '/log.jsonl'):
        x = json.loads(l)
        if 'actions' in x and x.get('executed'):
            n = x.get('used_step') or len(x['actions'])
            for a in x['actions'][:n]:
                pts.append([R(a[0]), R(a[1]), R(a[2]), 1 if a[6] > 0.5 else 0, 'm'])
        elif x.get('kind') == 'correction' or ('reached_angles' in x and x.get('reached_angles')):
            if pts and x.get('reached_angles'):
                a = x['reached_angles']; g = pts[-1][3]
                pts.append([R(a[0]), R(a[1]), R(a[2]), g, 'c'])
    return pts

def states(f):
    g = 0; pts = []; last = None
    for r in csv.DictReader(open(f)):
        if r['event_type'] == 'gripper_cmd' and r['gripper_state'] != '': g = int(float(r['gripper_state']))
        if r['event_type'] == 'actual' and r['stage'] in ('model', 'correct'):
            p = [R(r['j0']), R(r['j1']), R(r['j2']), g, 'm' if r['stage'] == 'model' else 'c']
            if p != last: pts.append(p); last = p
    return pts[::2]

def runs(pattern):
    out = []
    for d in sorted(glob.glob('runs/pi05_policy/' + pattern)):
        f = d + '/recording/states.csv'
        p = states(f) if os.path.exists(f) else chunks(d)
        if len(p) > 5: out.append(p)
    return out

def loss(f):
    return [[int(m.group(1)), float(m.group(2))] for m in re.finditer(r'Step (\d+):.*?loss=([0-9.]+)', open(f).read())]

def demos(ds, names=None):
    out = []
    for e in sorted(glob.glob(f'datasets/{ds}/raw_episodes/*/episode.npz')):
        name = e.split('/')[-2]
        if names is not None and name not in names: pass
        a = np.load(e)['state']
        out.append({'name': name, 'p': [[R(s[0]), R(s[1]), R(s[2]), 1 if s[6] > 0.5 else 0, 'h'] for s in a]})
    return out

o = {}
o['LOG345'] = runs('pi05_20261017_1*') + runs('pi05_20261017_20*')
o['LOG348'] = runs('pi05_20261017_22*')
o['LOG358'] = runs('pi05_20261021_08*')
o['LOG361'] = runs('pi05_20261021_1[89]*') + runs('pi05_20261021_2*') + runs('pi05_20261022_*')
o['LOG363'] = runs('pi05_20261024_*')
o['LOG401'] = runs('pi05_20261029_1[0-3]*')
o['LOG403'] = runs('pi05_20261029_1[4-9]*')
o['LOG407'] = runs('pi05_20261030_*')
o['LOG417'] = runs('pi05_20261031_*')
o['LOG422'] = runs('pi05_20261101_*')
o['LOG450'] = runs('pi05_20261106_*')
o['loss'] = {'LOG341': loss('checkpoints/pi05_mecharm_lora_v1/logs/train.log'),
             'LOG348': loss('checkpoints/pi05_mecharm_lora_v1/logs/train.log') + loss('lab/Log06/train_resume.log'),
             'LOG355': loss('lab/Log10/logs/train.log'),
             'LOG358': loss('lab/Log11/pod_trim2_logs/train.log'),
             'LOG417': loss('lab/Log17/train/train_trim_corr_v3.log'),
             'LOG422': loss('lab/Log18/train/train_trim_corr_v4.log'),
             'LOG450': loss('lab/Log22/train/train_trim_corr_v5.log')}
o['LOG337'] = [x['p'] for x in demos('mecharm_redblock_box_scripted_v1')][:2]
o['LOG352'] = [x['p'] for x in demos('mecharm_redblock_box_teach_v1')]
# LOG460: 学習データ30本、浅いつかみの7本に印
shallow = set(re.findall(r'\| (\S+) \| \S+ \| \d+ \| 浅い', open('datasets/mecharm_redblock_box_teach_v1_trim2_corr_v5_deep/README.md').read()))
ep = demos('mecharm_redblock_box_teach_v1_trim2') + demos('mecharm_redblock_box_corr_v5')
o['LOG460'] = [{'shallow': x['name'] in shallow, 'p': x['p']} for x in ep]
# LOG349: 手で教えた動きの再生（drag_v2）
o['LOG349'] = []
for f in sorted(glob.glob('runs/experiments/waypoint_streaming_replay/LOG349_20261018/drag_v2_*.json')):
    d = json.load(open(f)); ev = {e['idx']: e['action'] for e in d['gripper_events']}; g = 0; p = []
    for i, a in enumerate(d['angles']):
        if i in ev: g = 1 if ev[i] == 'close' else 0
        p.append([R(a[0]), R(a[1]), R(a[2]), g, 'h'])
    o['LOG349'].append(p[::2])
# LOG327: LIBERO の成否と GPU メモリ
o['LOG327'] = {'success': [l.strip().endswith('True') for l in open('lab/Log03/logs/client.log') if 'Success:' in l],
               'mem': [int(r[' memory.used [MiB]'].split()[0]) for r in csv.DictReader(open('lab/Log03/logs/gpu.csv'))]}
# LOG446: 長押しの角度の動き（jog_probe）
jp = json.load(open('diag/jog_probe_20261105_153401/result.json'))
o['LOG446'] = {k: v.get('samples') for k, v in jp['tests'].items() if isinstance(v, dict) and v.get('samples')}
# LOG419: ゲームパッドの押した記録（試運転 11/02）
o['LOG419'] = [[json.loads(l).get('t_unix'), json.loads(l).get('button'), json.loads(l).get('joint'), json.loads(l).get('delta')] for l in open('runs/experiments/gamepad_teleop/teleop_20261102_192657_dry/log.jsonl')]
# LOG461: 爪の電流（サーボ7 の番地69・70）
base = 'diag/servo_current_20261107/180051_pad/'; cur = []
for r in csv.DictReader(open(base + 'samples.csv')):
    try:
        v = {x[1]: x[2] for x in json.loads(r['regs']) if x[0] == 7}; cur.append((v[69] + v[70] * 256) & 0x7FFF)
    except Exception: cur.append(None)
o['LOG461'] = cur
# LOG225: FK テーブルの点（爪先の位置）
random.seed(1); pts = []
with open('fk_tables/fk_scan_motion_based_v2.csv') as f:
    next(f)
    for i, l in enumerate(f):
        if i % 280 == 0:
            s = l.split(','); pts.append([round(float(s[0]), 4), round(float(s[2]), 4)])
o['LOG225'] = pts
# LOG277: これまでの実機の通し試行（台帳）の置いた位置
o['LOG277'] = [[R(r['block_place_x'] or 0), R(r['block_place_z'] or 0), r['place_verdict']] for r in csv.DictReader(open('runs/pick_place_ledger_v3.csv')) if r['block_place_x'] and r['block_place_z']]
json.dump(o, open(sys.argv[1], 'w'), separators=(',', ':'))
for k, v in o.items(): print(k, len(v) if hasattr(v, '__len__') else v)
```

### extract.py を Mac で動かした出力

```
$ D="$HOME/mnt/PixelstoMotors/デジタルツイン学習R2R2R/フィジカルAI研究室を作る/thumb_data"
$ cd $HOME/mnt/KIOXIA/r2r2r-data && python3 "$D/extract.py" "$D/lab_thumbs.json"
LOG345 7
LOG348 2
LOG358 0
LOG361 21
LOG363 3
LOG401 11
LOG403 5
LOG407 4
LOG417 8
LOG422 9
LOG450 9
loss 7
LOG337 2
LOG352 19
LOG460 30
LOG349 4
LOG327 2
LOG446 5
LOG419 172
LOG461 1493
LOG225 6090
LOG277 382
```

LOG358 が0本でした。10月21日の朝の実行（08時台）を、夕方の時刻の型で探していたためです。型を `pi05_20261021_08*` に直して動かし直すと1本になりました。

```
54:o['LOG358'] = runs('pi05_20261021_08*')
LOG358 1
```


## 作業ログ ― 表紙を描くコード

描くコードは p2m-site の tools/lab_thumbs/lab_thumb.py です。SVG を中に持つ HTML を、1回分ずつ public/thumbs/ に書き出します。Studio から移した回（LOG225・234・277・326・327）は、一覧が使うファイル名（`r-…html`）で書き出します。

### p2m-site の tools/lab_thumbs/lab_thumb.py

```python
"""フィジカルAI研究室の記事のサムネイル（public/thumbs/LOG{N}.html）を、TOP のカード（tools/series_cards/series6.py）と同じ考え方で作る。

決まり（2026-11-08 エイイチの指示）
- 記事の実データを描く。細部ではなく、抽象的な印象で見せる（目盛り・数値の注記は入れない）
- 文字は少なく（左上と右上の短いラベルだけ。LOG番号・タイトルは焼き込まない）
- 色は 紙色・墨・赤・青 だけ（色を足すときも青系だけ）
    紙色 / 墨 … 地と、人の動き（お手本・手での直し）
    赤      … グリッパーが物をつかんでいる間（TOP のカードと同じ意味）
    青      … その回の主役（π0.5 が動かした所、測った値など）
- 構図: split（TOP と同じ上下2段）/ dark（黒1段）/ paper（紙色1段）/ 写真＋グラフ（photo_l・photo_r。試し中）
- GPU ラックの写真は使わない（2026-11-08）
  一覧（LOG番号の大きい順）で同じ構図を続けない。並びは ORDER のコメントで確かめる
- 写真は白黒にして使う

元データは KIOXIA から書き出した JSON。書き出しのコードは同じフォルダの extract.py（Mac で動かす。元データは読むだけ）。
JSON と写真は iCloud の「フィジカルAI研究室を作る/thumb_data/」と各回の LOG{N}_images/ にある。
使い方: python3 tools/lab_thumbs/lab_thumb.py <lab_thumbs.json> <写真のフォルダ> [LOG番号 ...]
        （出力先を変えるときは環境変数 OUT）
"""
import base64
import io
import json
import os
import sys
from pathlib import Path

W, H = 1920, 1080
PAPER, INK, RED, BLUE = '#f1f0ec', '#111111', '#e0262c', '#2f6fdf'
BLUE_D = '#4f8cf5'  # 黒地では同じ青だと沈むので、少しだけ明るい青
L, R = 150, 1770
SITE = Path(__file__).resolve().parents[2]

FONT = ('<link rel="preconnect" href="https://fonts.googleapis.com">'
        '<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">')

# 地ごとの色: m=π0.5 / g=つかんでいる間 / c・h=人（直し・お手本）/ q=比べる相手（薄く）
LIGHT = {'m': (BLUE, '.95'), 'g': (RED, '.9'), 'c': (INK, '.85'), 'h': (INK, '.45'), 'q': (INK, '.18')}
DARK = {'m': (BLUE_D, '.95'), 'g': (RED, '.95'), 'c': (PAPER, '.9'), 'h': (PAPER, '.5'), 'q': (PAPER, '.2')}


# ---------- 部品 ----------

def poly(pts, color, op, w=3.6):
    path = ' '.join(f'{x:.1f},{y:.1f}' for x, y in pts)
    return (f'<polyline points="{path}" fill="none" stroke="{color}" stroke-opacity="{op}" '
            f'stroke-width="{w}" stroke-linejoin="round" stroke-linecap="round"/>')


def labels(left, right, color, x0=L, x1=R, y=150):
    return (f'<text x="{x0}" y="{y}" fill="{color}">{left}</text>'
            f'<text x="{x1}" y="{y}" text-anchor="end" fill="{color}">{right}</text>')


def base(x0, x1, y, color, op='1'):
    return f'<line x1="{x0}" y1="{y}" x2="{x1}" y2="{y}" stroke="{color}" stroke-opacity="{op}" stroke-width="1.5"/>'


def lanes(runs, j, box, pal, w=3.6, kind_of=None, scale=None):
    """runs の関節 j を box=(x0,y0,x1,y1) に重ねて描く。色の優先は 人の直し > つかんでいる間 > π0.5（お手本は人）。
    kind_of(run_index, point) で点の役を変えられる（比べる相手を薄くするなど）。"""
    x0, y0, x1, y1 = box
    vals = [p[j] for r in runs for p in r]
    lo, hi = scale or (min(vals), max(vals))
    n_max = max(len(r) for r in runs)
    out = []
    for ri, r in enumerate(runs):
        pts = [(x0 + i / (n_max - 1) * (x1 - x0), y1 - (p[j] - lo) / (hi - lo) * (y1 - y0)) for i, p in enumerate(r)]

        def role(p):
            if kind_of:
                k = kind_of(ri, p)
                if k: return k
            if p[4] == 'c': return 'c'
            if p[3]: return 'g'
            return 'h' if p[4] == 'h' else 'm'
        seg, cur, segs = [pts[0]], role(r[0]), []
        for xy, p in zip(pts[1:], r[1:]):
            seg.append(xy)
            if role(p) != cur:
                segs.append((cur, seg)); seg = [xy]; cur = role(p)
        segs.append((cur, seg))
        for k, s in segs:
            if len(s) > 1:
                out.append(poly(s, *pal[k], w=w))
    return '\n'.join(out)


def line(vals, box, color, w=4, lo=None, hi=None, logy=False):
    import math
    x0, y0, x1, y1 = box
    v = [math.log10(max(x, 1e-6)) for x in vals] if logy else vals
    lo = min(v) if lo is None else lo
    hi = max(v) if hi is None else hi
    pts = [(x0 + i / (len(v) - 1) * (x1 - x0), y1 - (x - lo) / (hi - lo) * (y1 - y0)) for i, x in enumerate(v)]
    return poly(pts, color, '1', w=w)


def photo(path, width, gray=True, crop=None):
    from PIL import Image, ImageOps
    im = Image.open(path).convert('RGB')
    if crop: im = im.crop(crop)
    if gray: im = ImageOps.grayscale(im).convert('RGB')
    im.thumbnail((width, width))
    buf = io.BytesIO(); im.save(buf, 'JPEG', quality=80)
    return 'data:image/jpeg;base64,' + base64.b64encode(buf.getvalue()).decode()


def image(href, x, y, w, h):
    return f'<image href="{href}" x="{x}" y="{y}" width="{w}" height="{h}" preserveAspectRatio="xMidYMid slice"/>'


def page(body, bg):
    return (f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
            f'<meta name="viewport" content="width=device-width,initial-scale=1">{FONT}'
            f'<style>html,body{{margin:0;background:{bg}}}svg{{display:block;width:100%;height:auto;max-width:1920px;margin:0 auto}}'
            f'text{{font-family:"JetBrains Mono","SFMono-Regular",Menlo,monospace;font-size:44px;letter-spacing:.08em}}</style></head><body>'
            f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}">{body}</svg></body></html>')


# ---------- 構図 ----------

def split(left, right, top, bottom):
    """TOP のカードと同じ上下2段。top・bottom は (box, 地のパレット) を受けて描く関数。"""
    cut = 600
    b = [f'<rect width="{W}" height="{H}" fill="{PAPER}"/>', base(L, R, cut - 60, INK),
         top((L, 250, R, cut - 80), LIGHT), labels(left, right, INK),
         f'<rect y="{cut}" width="{W}" height="{H-cut}" fill="{INK}"/>', base(L, R, H - 90, PAPER, '.7'),
         bottom((L, cut + 90, R, H - 110), DARK)]
    return page('\n'.join(b), PAPER)


def dark(left, right, draw, axis=True):
    b = [f'<rect width="{W}" height="{H}" fill="{INK}"/>', base(L, R, H - 150, PAPER, '.7') if axis else '',
         draw((L, 280, R, H - 175), DARK), labels(left, right, PAPER)]
    return page('\n'.join(b), INK)


def paper(left, right, draw, axis=True):
    b = [f'<rect width="{W}" height="{H}" fill="{PAPER}"/>', base(L, R, H - 150, INK) if axis else '',
         draw((L, 280, R, H - 175), LIGHT), labels(left, right, INK)]
    return page('\n'.join(b), PAPER)


def photo_top(left, right, img, draw):
    """上に写真の帯（白黒）、下は黒地にグラフ。"""
    cut = 430
    b = [f'<rect width="{W}" height="{H}" fill="{INK}"/>', image(img, 0, 0, W, cut),
         f'<rect width="{W}" height="{cut}" fill="{INK}" fill-opacity=".25"/>',
         labels(left, right, PAPER, y=120), base(L, R, H - 90, PAPER, '.7'),
         draw((L, cut + 90, R, H - 110), DARK)]
    return page('\n'.join(b), INK)


def photo_l(left, right, img, draw):
    """左に写真（白黒）、右は黒地にグラフ。"""
    cut = 840
    b = [f'<rect width="{W}" height="{H}" fill="{INK}"/>', image(img, 0, 0, cut, H),
         labels(left, right, PAPER, x0=cut + 90, x1=R), base(cut + 90, R, H - 150, PAPER, '.7'),
         draw((cut + 90, 280, R, H - 175), DARK)]
    return page('\n'.join(b), INK)


def photo_r(left, right, img, draw):
    """左は紙色の地にグラフ、右に写真（白黒）。"""
    cut = 1080
    b = [f'<rect width="{W}" height="{H}" fill="{PAPER}"/>', image(img, cut, 0, W - cut, H),
         labels(left, right, INK, x0=L, x1=cut - 90), base(L, cut - 90, H - 150, INK),
         draw((L, 280, cut - 90, H - 175), LIGHT)]
    return page('\n'.join(b), PAPER)


# ---------- グラフの種類 ----------

def runs_draw(runs, j, w=3.6, **kw):
    return lambda box, pal: lanes(runs, j, box, pal, w=w, **kw)


def loss_draw(steps_loss):
    v = [l for _, l in sorted(steps_loss)]
    return lambda box, pal: line(v, box, pal['m'][0], w=5, logy=True)


def bars_draw(values, main_index, horizontal=False):
    """棒。主役の1本だけ青、ほかは人の色（墨か紙色）。"""
    def f(box, pal):
        x0, y0, x1, y1 = box
        hi = max(values); n = len(values); gap = 60
        out = []
        for i, v in enumerate(values):
            col = pal['m'][0] if i == main_index else pal['c'][0]
            if horizontal:
                bh = (y1 - y0 - gap * (n - 1)) / n; y = y0 + i * (bh + gap)
                out.append(f'<rect x="{x0}" y="{y:.1f}" width="{max(6, (x1-x0)*v/hi):.1f}" height="{bh:.1f}" fill="{col}"/>')
            else:
                bw = (x1 - x0 - gap * (n - 1)) / n; x = x0 + i * (bw + gap); h = max(6, (y1 - y0) * v / hi)
                out.append(f'<rect x="{x:.1f}" y="{y1-h:.1f}" width="{bw:.1f}" height="{h:.1f}" fill="{col}"/>')
        return '\n'.join(out)
    return f


def stack_draw(parts, main_index):
    """1本の横の棒を区間に分ける（往復の時間の内訳など）。"""
    def f(box, pal):
        x0, y0, x1, y1 = box
        tot = sum(parts); x = x0; out = []; h = 150; y = (y0 + y1) / 2 - h / 2
        for i, v in enumerate(parts):
            w = (x1 - x0) * v / tot
            col, op = (pal['m'][0], 1) if i == main_index else (pal['c'][0], .25 + .3 * (i % 2))
            out.append(f'<rect x="{x:.1f}" y="{y}" width="{w-8:.1f}" height="{h}" fill="{col}" fill-opacity="{op}"/>')
            x += w
        return '\n'.join(out)
    return f


def scatter_draw(pts, r=3.0, op=.5, role=None):
    def f(box, pal):
        x0, y0, x1, y1 = box
        xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
        lx, hx, ly, hy = min(xs), max(xs), min(ys), max(ys)
        # 縦横の比をそろえる（形がゆがまないように）
        sx = (x1 - x0) / (hx - lx); sy = (y1 - y0) / (hy - ly); s = min(sx, sy)
        cx = (x0 + x1) / 2 - (lx + hx) / 2 * s; cy = (y0 + y1) / 2 + (ly + hy) / 2 * s
        out = []
        for p in pts:
            k = role(p) if role else 'c'
            col, o = pal[k][0], (op if k == 'c' else .9)
            out.append(f'<circle cx="{cx + p[0]*s:.1f}" cy="{cy - p[1]*s:.1f}" r="{r}" fill="{col}" fill-opacity="{o}"/>')
        return '\n'.join(out)
    return f


def trials_draw(rows):
    """試行を丸で並べる。rows=[(成功数, 回数), ...]。成功は青の丸、失敗は輪だけ。"""
    def f(box, pal):
        x0, y0, x1, y1 = box
        n = max(t for _, t in rows); rad = 62
        dx = (x1 - x0 - 2 * rad) / (n - 1); dy = (y1 - y0 - 2 * rad) / max(1, len(rows) - 1)
        out = []
        for ri, (ok, t) in enumerate(rows):
            for i in range(t):
                cx, cy = x0 + rad + i * dx, y0 + rad + ri * dy
                if i < ok: out.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{rad}" fill="{pal["m"][0]}"/>')
                else: out.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{rad-3}" fill="none" stroke="{pal["c"][0]}" stroke-width="5"/>')
        return '\n'.join(out)
    return f


def ticks_draw(events, lanes_n=6):
    """押した記録。横は時間、縦は関節。1回押すごとに短い縦の線。"""
    def f(box, pal):
        x0, y0, x1, y1 = box
        ts = [e[0] for e in events]; t0, t1 = min(ts), max(ts)
        lh = (y1 - y0) / lanes_n; out = []
        for k in range(lanes_n):
            out.append(base(x0, x1, y0 + (k + .5) * lh, pal['q'][0], '.35'))
        for t, btn, j, d in events:
            if j is None: continue
            x = x0 + (t - t0) / (t1 - t0) * (x1 - x0); y = y0 + (j + .5) * lh
            out.append(f'<rect x="{x-3:.1f}" y="{y-lh*.32:.1f}" width="6" height="{lh*.64:.1f}" fill="{pal["m"][0]}"/>')
        return '\n'.join(out)
    return f


def multi_draw(series, main):
    """何本かの線（時間と角度の組）。main の1本だけ青、ほかは人の色を薄く。"""
    def f(box, pal):
        x0, y0, x1, y1 = box
        allx = [p[0] for s in series.values() for p in s]; ally = [p[1] for s in series.values() for p in s]
        lx, hx, ly, hy = min(allx), max(allx), min(ally), max(ally)
        out = []
        for k, s in series.items():
            pts = [(x0 + (p[0] - lx) / (hx - lx) * (x1 - x0), y1 - (p[1] - ly) / (hy - ly) * (y1 - y0)) for p in s]
            col, op = (pal['m'][0], '1') if k == main else (pal['c'][0], '.45')
            out.append(poly(pts, col, op, w=4))
        return '\n'.join(out)
    return f


def signal_draw(vals, thresh):
    """1本の測った値。主役の値は青、つかんでいる間（値がしきい値を超えた所）は赤。"""
    def f(box, pal):
        x0, y0, x1, y1 = box
        hi = max(vals)
        pts = [(x0 + i / (len(vals) - 1) * (x1 - x0), y1 - x / hi * (y1 - y0)) for i, x in enumerate(vals)]
        out, seg, cur = [], [pts[0]], vals[0] > thresh
        for xy, x in zip(pts[1:], vals[1:]):
            seg.append(xy)
            if (x > thresh) != cur:
                out.append(poly(seg, RED if cur else pal['m'][0], '1', w=4)); seg = [xy]; cur = x > thresh
        out.append(poly(seg, RED if cur else pal['m'][0], '1', w=4))
        return '\n'.join(out)
    return f


def path_draw(runs, jx, jy, w=7):
    """関節2つを縦横に取った道すじ（時間の線ではなく形で見せる）。終わりの点に丸。"""
    def f(box, pal):
        x0, y0, x1, y1 = box
        xs = [p[jx] for r in runs for p in r]; ys = [p[jy] for r in runs for p in r]
        lx, hx, ly, hy = min(xs), max(xs), min(ys), max(ys)
        s = min((x1 - x0) / (hx - lx), (y1 - y0) / (hy - ly))
        cx = (x0 + x1) / 2 - (lx + hx) / 2 * s; cy = (y0 + y1) / 2 + (ly + hy) / 2 * s
        out = []
        for r in runs:
            pts = [(cx + p[jx] * s, cy - p[jy] * s) for p in r]
            seg, cur = [pts[0]], r[0][3]
            for xy, p in zip(pts[1:], r[1:]):
                seg.append(xy)
                if p[3] != cur:
                    out.append(poly(seg, RED if cur else pal['m'][0], '.95', w=w)); seg = [xy]; cur = p[3]
            out.append(poly(seg, RED if cur else pal['m'][0], '.95', w=w))
            ex, ey = pts[-1]
            out.append(f'<circle cx="{ex:.1f}" cy="{ey:.1f}" r="24" fill="none" stroke="{pal["c"][0]}" stroke-width="5"/>')
        return '\n'.join(out)
    return f


def both(*fs):
    return lambda box, pal: '\n'.join(f(box, pal) for f in fs)


# ---------- 記事ごとの指定 ----------
# 一覧の並び（LOG番号の大きい順）と構図。隣どうしで同じ構図にしない
ORDER = """
461 paper   460 split   450 dark    446 paper   436 dark    422 photo_l 419 photo_r 417 dark
407 split   403 dark    401 paper   363 split   361 paper   358 dark    355 paper   352 split
349 dark    348 paper   345 split   341 dark    337 split   327 paper   326 dark    277 paper
234 dark    225 paper
"""


def build(log, d, img_dir):
    n = int(log[3:])
    # GPU ラックの写真（public/site/gbr/reverse.webp）はここでは使わない（2026-11-08 エイイチ「GPUラックの写真は作っちゃダメ」）
    if n == 461:
        return paper('GRIPPER CURRENT', 'EMPTY → GRASP', signal_draw([v or 0 for v in d['LOG461']][::2], 300))
    if n == 460:
        ep = d['LOG460']
        k = lambda ri, p: (None if ep[ri]['shallow'] else 'q') if not p[3] else (None if ep[ri]['shallow'] else 'q')
        runs = [e['p'] for e in ep]
        kind = lambda ri, p: None if ep[ri]['shallow'] else 'q'
        # 浅いつかみの7本を主役（青）に。つかんでいる間は赤のまま
        shallow_role = lambda ri, p: ('g' if p[3] else 'm') if ep[ri]['shallow'] else 'q'
        return split('DEMONSTRATIONS', '7 SHALLOW', runs_draw(runs, 0, w=3.2, kind_of=shallow_role),
                     runs_draw(runs, 1, w=3.2, kind_of=shallow_role))
    if n == 450:
        return dark('π0.5 + GAMEPAD', 'PICK → PLACE', runs_draw(d['LOG450'], 1, w=4))
    if n == 446:
        return paper('GAMEPAD JOG', 'HOLD → STOP', multi_draw(d['LOG446'], 'D_jog_speed30'))
    if n == 436:
        return dark('SEND TIME', '1.6 S → 0.02 S', bars_draw([1.6, 0.02], 1, horizontal=True), axis=False)
    if n == 422:
        img = photo(Path(img_dir) / 'LOG422_images/LOG422_t1.jpg', 1000, crop=(200, 0, 900, 840))
        return photo_l('π0.5 RETRAINED', '9 RUNS', img, runs_draw(d['LOG422'], 1, w=3.6))
    if n == 419:
        img = photo(Path(img_dir) / 'LOG419_images/LOG419_01_gamepad_and_mecharm.jpg', 1000, crop=(250, 120, 1350, 1200))
        return photo_r('GAMEPAD', 'DRY RUN', img, ticks_draw(d['LOG419']))
    if n == 417:
        return dark('π0.5 + HUMAN FIX', '8 RUNS', runs_draw(d['LOG417'], 1, w=3.6))
    if n == 407:
        return split('π0.5 + REWIND', 'PICK → PLACE', runs_draw(d['LOG407'], 0, w=4.5), runs_draw(d['LOG407'], 1, w=4.5))
    if n == 403:
        return dark('π0.5', 'REWIND', runs_draw(d['LOG403'], 1, w=4.5))
    if n == 401:
        return paper('π0.5 · 3 SPOTS', '9 / 15', trials_draw([(5, 5), (3, 5), (1, 5)]), axis=False)
    if n == 363:
        w = 5.5
        return split('π0.5', 'PICK → PLACE', runs_draw(d['LOG363'], 0, w=w), runs_draw(d['LOG363'], 1, w=w))
    if n == 361:
        return paper('π0.5', 'FIRST IN THE BOX', runs_draw(d['LOG361'], 1, w=3))
    if n == 358:
        return dark('π0.5', 'WAITING', runs_draw(d['LOG358'], 1, w=5))
    if n == 355:
        return paper('TRAINING LOSS', '4,000 STEPS', loss_draw(d['loss']['LOG355']))
    if n == 352:
        return split('DEMONSTRATIONS', '19', runs_draw(d['LOG352'], 0, w=3), runs_draw(d['LOG352'], 1, w=3))
    if n == 349:
        return dark('TAUGHT BY HAND', 'REPLAY', runs_draw(d['LOG349'], 1, w=4.5))
    if n == 348:
        return paper('π0.5 · 4,000 STEPS', 'SAME AIM', path_draw(d['LOG348'], 0, 2), axis=False)  # loss は LOG355 とそっくりになるので、実機の2回の道すじ（根元とひじ）を描く
    if n == 345:
        return split('π0.5', 'FIRST RUN', runs_draw(d['LOG345'], 0, w=4), runs_draw(d['LOG345'], 1, w=4))
    if n == 341:
        return dark('TRAINING LOSS', '2,000 STEPS', loss_draw(d['loss']['LOG341']))
    if n == 337:
        demo = [[p[:4] + ['h'] for p in r] for r in d['LOG337']]
        return split('DEMONSTRATIONS', '2', runs_draw(demo, 0, w=5), runs_draw(demo, 1, w=5))
    if n == 327:
        mem = d['LOG327']['mem']
        return paper('LIBERO · GPU MEMORY', '20 / 20', lambda box, pal: line(mem, box, pal['m'][0], w=5, lo=0))
    if n == 326:
        return dark('ROUND TRIP', '391 MS', stack_draw([97.1, 58.3, 235.3], 0), axis=False)
    if n == 277:
        pts = d['LOG277']
        return paper('PLACED BLOCKS', 'BEFORE THE LAB', scatter_draw(pts, r=9, op=.55, role=lambda p: 'c' if p[2] == 'yes' else 'q'), axis=False)
    if n == 234:
        return dark('MAC FREE SPACE', '90 → 20 GB', bars_draw([90, 30, 20], 2), axis=False)
    if n == 225:
        return paper('FK TABLE', '', scatter_draw(d['LOG225'], r=2.6, op=.35), axis=False)
    raise SystemExit(f'指定がない: {log}')


# Studio から移した回は、サムネイルのファイル名が記事の住所の名前（cards.json の thumb）
STUDIO = {225: 'r-precision-is-not-learning-so101', 234: 'r-where-learning-data-lives',
          277: 'r-building-a-physical-ai-lab-at-home', 326: 'r-openpi-remote-inference-first-run',
          327: 'r-pi05-libero-benchmark-first-run', 337: 'r-pi05-mecharm-demo-to-inference'}

if __name__ == '__main__':
    data = json.load(open(sys.argv[1]))
    img_dir = sys.argv[2]
    logs = sys.argv[3:] or [f'LOG{x}' for x in ORDER.split()[::2]]
    outdir = Path(os.environ.get('OUT', SITE / 'public/thumbs'))
    for log in logs:
        n = int(log[3:])
        names = [STUDIO[n]] if n in STUDIO and n != 337 else [f'LOG{n}']  # 337 は Markdown の記事（LOG337.html）が使われる
        html = build(log, data, img_dir)
        for name in names:
            (outdir / f'{name}.html').write_text(html)
        print('書き出し', ', '.join(names))
```

### 書き出しと、SNS・検索結果用の画像の書き出し

```
$ J="/mnt/user-data/uploads/PixelstoMotors/デジタルツイン学習R2R2R/フィジカルAI研究室を作る"
$ python3 tools/lab_thumbs/lab_thumb.py "$J/thumb_data/lab_thumbs.json" "$J" LOG461 LOG460 LOG450 LOG446 LOG436 LOG407 LOG403 LOG401 LOG363 LOG361 LOG358 LOG352 LOG349 LOG345 LOG327 LOG326 LOG277 LOG234 LOG225
（この実行は tail -2 で最後の2行だけを表示させていた）
書き出し LOG234, r-where-learning-data-lives
書き出し LOG225, r-precision-is-not-learning-so101
$ python3 tools/render_og.py LOG461 LOG460 LOG450 LOG446 LOG436 LOG407 LOG403 LOG401 LOG363 LOG361 LOG358 LOG352 LOG349 LOG345 LOG327 LOG326 LOG277 LOG234 LOG225 r-pi05-libero-benchmark-first-run r-openpi-remote-inference-first-run r-building-a-physical-ai-lab-at-home r-where-learning-data-lives r-precision-is-not-learning-so101
書き出し 24 件 → public/og/
```

### 公開した順番（p2m-site の commit）

```
464196f 16:20 フィジカルAI研究室の記事サムネイルを、TOPのカードと同じ形（実データの線・紙色/墨/赤/青）に作り直す（写真の回は保留）
d1c9cbe 16:24 フィジカルAI研究室のサムネイル：GPUラックの写真の回（LOG337・341・348・355・417）も写真なしの形に。並びが続かないよう LOG352 を上下2段に
0a66c53 16:28 LOG422・LOG419 のサムネイルを実機の写真＋グラフの形に（表紙の画像の指定を外し、サムネイルを使う）
04f28f2 16:29 LOG348 のサムネイルを loss から実機2回の道すじに（LOG355 とそっくりだったため）
```

LOG419 と LOG422 は、記事の先頭の項目に表紙の画像（`cover:`）が書いてあり、表紙の画像がサムネイルより優先されていました。この1行を外して、新しいサムネイルが出るようにしています。本文は変えていません。

記事の決まり（article_rules.md）のサムネイルの節には、今も「配色はモノクロ」と書いてあります。ほかのシリーズのサムネイルも別の作業で作り直している途中なので、それが終わってから、まとめて書き直す予定です。
