"""フィジカル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))
