"""フィジカル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 <写真のフォルダ> [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 = ('' '') # 地ごとの色: 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'') def labels(left, right, color, x0=L, x1=R, y=150): return (f'{left}' f'{right}') def base(x0, x1, y, color, op='1'): return f'' 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'' def page(body, bg): return (f'' f'{FONT}' f'' f'{body}') # ---------- 構図 ---------- def split(left, right, top, bottom): """TOP のカードと同じ上下2段。top・bottom は (box, 地のパレット) を受けて描く関数。""" cut = 600 b = [f'', base(L, R, cut - 60, INK), top((L, 250, R, cut - 80), LIGHT), labels(left, right, INK), f'', 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'', 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'', 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'', image(img, 0, 0, W, cut), f'', 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'', 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'', 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'') else: bw = (x1 - x0 - gap * (n - 1)) / n; x = x0 + i * (bw + gap); h = max(6, (y1 - y0) * v / hi) out.append(f'') 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'') 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'') 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'') else: out.append(f'') 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'') 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'') 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))