"""サービスロボット導入シミュレーション（/series8）の記事サムネイル37本を作る（2026-11-08）。
TOP のカードと同じく、Unity で動かした店の画像を主役にする。文字は入れない。
並べ方（1枚・左右・上下・3列・格子・大小の組み合わせ・切り抜き・真上から）は回ごとに変え、一覧（LOG番号の大きい順）で同じ形が続かないようにする。
Unity の背景（茶色がかった灰色）は、TOP のカードと同じ明るい灰色に置き換える（暗い版は墨色）。

素材は KIOXIA の service_sim/unity の録画から ffmpeg で切り出した静止画（iCloud の
「サービスロボット導入シミュレーション/thumb_data/」。切り出しの時刻はファイル名と make.py の SRC のコメント）。
Mac 連携で /mnt/user-data/uploads/ に写してから動かす。
  python3 make.py [LOG番号 ...]   # out/LOG{N}.png（1920×1080）
  python3 publish.py              # webp にして public/thumbs/img/ へ、HTML を書き換える
"""
import os, sys
import numpy as np, cv2
from PIL import Image

D = os.environ.get('SRC', '/mnt/user-data/uploads/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/thumb_data/')
W, H = 1920, 1080
LIGHT = (236, 238, 242)   # TOP のカードの地
INK = (17, 17, 17)
BG = np.array([103, 98, 94])
G = 14                     # 区切りの幅
os.makedirs('out', exist_ok=True)
_cache = {}


def load(name, clean=True, bg=LIGHT):
    k = (name, clean, bg)
    if k in _cache: return _cache[k]
    a = np.array(Image.open(D + name + '.jpg').convert('RGB'))
    if clean:  # 外周につながった背景色だけを置き換える
        near = (np.abs(a.astype(int) - BG).sum(-1) < 22).astype(np.uint8)
        n, lab = cv2.connectedComponents(near)
        edge = set(np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]]))) - {0}
        m = np.isin(lab, list(edge))
        m = cv2.dilate(m.astype(np.uint8), np.ones((3, 3), np.uint8)).astype(bool) & (np.abs(a.astype(int) - BG).sum(-1) < 60)
        a = a.copy(); a[m] = bg
    _cache[k] = a
    return a


def bbox(name):
    a = np.array(Image.open(D + name + '.jpg').convert('RGB')).astype(int)
    m = np.abs(a - BG).sum(-1) > 22
    ys, xs = np.nonzero(m)
    return xs.min(), ys.min(), xs.max(), ys.max()


def marks(name):
    """ロボット（白い筒）とスタッフ（青い筒）の位置。大きい順"""
    a = np.array(Image.open(D + name + '.jpg').convert('RGB')).astype(int)
    mx = a.max(-1); mn = a.min(-1)
    out = {}
    for k, m in [('r', (mn > 200) & (mx - mn < 14)), ('s', (a[..., 2] > 150) & (a[..., 0] < 80) & (a[..., 1] < 120))]:
        n, _, st, c = cv2.connectedComponentsWithStats(m.astype(np.uint8))
        lim = 400 * (a.shape[1] / 3840) ** 2
        out[k] = [tuple(c[i]) for i in sorted(range(1, n), key=lambda i: -st[i][4]) if st[i][4] > lim]
    return out


def crop(name, fx, fy, z, w, h, clean=True, bg=LIGHT):
    """店の範囲（bbox）の中の位置 fx,fy（0〜1）を中心に、出力 w×h へ。z は店の幅に対する切り出し幅の割合。
    fx に 'rr'（ロボット2台の中間）・'r0'・'r1'・'s0'（スタッフ）を書くと、その位置を中心にする（fy はずらす量 (dx, dy)、切り出し幅に対する割合）"""
    a = load(name, clean, bg)
    x0, y0, x1, y1 = bbox(name)
    cw = (x1 - x0) * z; ch = cw * h / w
    if isinstance(fx, str):
        mk = marks(name)
        if fx == 'rr': p, q = mk['r'][0], mk['r'][1]; cx, cy = (p[0] + q[0]) / 2, (p[1] + q[1]) / 2
        else: cx, cy = mk[fx[0]][int(fx[1])]
        dx, dy = fy or (0, 0); cx += dx * cw; cy += dy * ch
    else:
        cx = x0 + (x1 - x0) * fx; cy = y0 + (y1 - y0) * fy
    sx = int(round(cx - cw / 2)); sy = int(round(cy - ch / 2))
    sx = max(0, min(a.shape[1] - int(cw), sx)); sy = max(0, min(a.shape[0] - int(ch), sy))
    part = a[sy:sy + int(ch), sx:sx + int(cw)]
    if part.shape[0] < int(ch) or part.shape[1] < int(cw):  # はみ出す分は地の色で埋める
        pad = np.full((int(ch), int(cw), 3), bg, np.uint8); pad[:part.shape[0], :part.shape[1]] = part; part = pad
    interp = cv2.INTER_AREA if part.shape[1] > w else cv2.INTER_CUBIC
    return cv2.resize(part, (w, h), interpolation=interp)


def canvas(col=LIGHT): return np.full((H, W, 3), col, np.uint8)
def put(c, img, x, y): c[y:y + img.shape[0], x:x + img.shape[1]] = img
def save(c, n): Image.fromarray(c).save(f'out/LOG{n}.png'); print('LOG', n)


# ---- 並べ方
def full(n, s):                       # 1枚を全面に（寄り）
    c = canvas(); put(c, crop(*s, W, H), 0, 0); save(c, n)


def cut(n, s, bg=LIGHT):              # 店を丸ごと、地の上に（TOP のカードと同じ見せ方）
    c = canvas(bg); put(c, crop(*s, W, H, bg=bg), 0, 0); save(c, n)


def split(n, a, b, r=0.5, bg=LIGHT):  # 左右（r は左の割合）
    wl = int((W - G) * r); wr = W - G - wl
    c = canvas(bg); put(c, crop(*a, wl, H, bg=bg), 0, 0); put(c, crop(*b, wr, H, bg=bg), wl + G, 0); save(c, n)


def bands(n, ss, bg=LIGHT):           # 上下に帯
    k = len(ss); h = (H - G * (k - 1)) // k
    c = canvas(bg)
    for i, s in enumerate(ss): put(c, crop(*s, W, h, bg=bg), 0, i * (h + G))
    save(c, n)


def cols(n, ss, bg=LIGHT):            # 縦の列
    k = len(ss); w = (W - G * (k - 1)) // k
    c = canvas(bg)
    for i, s in enumerate(ss): put(c, crop(*s, w, H, bg=bg), i * (w + G), 0)
    save(c, n)


def grid(n, ss, nc, nr, bg=LIGHT):    # 格子
    w = (W - G * (nc - 1)) // nc; h = (H - G * (nr - 1)) // nr
    c = canvas(bg)
    for i, s in enumerate(ss): put(c, crop(*s, w, h, bg=bg), (i % nc) * (w + G), (i // nc) * (h + G))
    save(c, n)


def mosaic(n, big, a, b, right=False, bg=LIGHT):  # 大1＋小2
    wb = int((W - G) * 0.64); ws = W - G - wb; hs = (H - G) // 2
    c = canvas(bg); xb, xs = (ws + G, 0) if right else (0, wb + G)
    put(c, crop(*big, wb, H, bg=bg), xb, 0); put(c, crop(*a, ws, hs, bg=bg), xs, 0); put(c, crop(*b, ws, H - hs - G, bg=bg), xs, hs + G); save(c, n)


def inset(n, wide, zoom, corner='br', bg=LIGHT):  # 引きの全面に、寄りを重ねる
    c = canvas(bg); put(c, crop(*wide, W, H, bg=bg), 0, 0)
    w, h = 760, 760
    z = crop(*zoom, w, h, bg=bg)
    x = W - w - 60 if corner[1] == 'r' else 60; y = H - h - 60 if corner[0] == 'b' else 60
    c[y - 10:y + h + 10, x - 10:x + w + 10] = INK; put(c, z, x, y); save(c, n)


# ---- 各回（素材名, 店の範囲の中の中心 x, y, 切り出し幅の割合）
JOBS = {
    466: lambda n: split(n, ('large_p6_r30_1000', .5, .5, 1.04), ('large_p6_r30_1000', .66, .7, .22), r=.58),
    465: lambda n: cols(n, [('large_p6_l30', 'r0', None, .3), ('large_p6_r30_1000', 'rr', None, .3), ('large_p7_l30', 'r1', None, .3)]),
    456: lambda n: bands(n, [('large_p6_r30_1000', 'rr', (.05, 0), .55), ('large_p7_l30', 'r1', None, .55)]),
    452: lambda n: cut(n, ('large_p7_r30', .5, .5, 1.04)),
    444: lambda n: split(n, ('bus_p4_l30', 'r0', (.1, -.1), .4), ('bus_p5_r30', 'r0', (0, -.1), .4)),
    440: lambda n: full(n, ('bus_p2_l30', 'rr', None, .6)),
    435: lambda n: grid(n, [('top_p1', k, None, .3) for k in ('r0', 'r1', 's0', 's3')], 2, 2),
    434: lambda n: inset(n, ('face_p3_r30_640', .5, .5, 1.05), ('face_p3_r30_640', 'rr', None, .25), corner='tr'),
    426: lambda n: mosaic(n, ('face_p5_r30_526', 'rr', (0, .12), .42), ('face_p4_r30_461', 'rr', None, .3), ('large_p6_l30', 'rr', None, .3)),
    423: lambda n: split(n, ('close_aisle16_l30', .55, .5, .45), ('top_p4', .5, .5, .84), r=.34),
    418: lambda n: grid(n, [(f'top_p{p}', k, None, .2) for p in (1, 3, 5) for k in ('r0', 'r1', 's0', 's1')], 4, 3),
    415: lambda n: full(n, ('top_p0', .5, .5, .9)),
    414: lambda n: cut(n, ('obl_p3_r30', .5, .5, 1.04)),
    409: lambda n: cols(n, [('face_p4_l30_299', 's0', None, .3), ('bus_p4_l30', 's0', None, .3), ('face_p1_r30_349', 's1', None, .3)]),
    405: lambda n: bands(n, [('close_aisle16_l30', .5, .4, .9), ('obl_p5_l30', .45, .5, .5)]),
    404: lambda n: split(n, ('face_p1_l30_349', 'r0', None, .45), ('face_p1_r30_349', 'r0', None, .45)),
    402: lambda n: grid(n, [(nm, 'r0', None, .45) for nm in ['face_p1_l30_349', 'face_p2_l30_408', 'face_p3_r30_640', 'face_p4_l30_299', 'face_p5_r30_526', 'obl_p0_r30']], 3, 2),
    400: lambda n: full(n, ('close_dish16_l30', .5, .5, .85)),
    398: lambda n: mosaic(n, ('obl_p3_l30', .45, .5, .5), ('obl_p3_r30', .3, .4, .35), ('top_p3', .7, .7, .35), right=True),
    397: lambda n: cols(n, [('obl_p5_l30', .4, .5, .4), ('obl_p5_r30', .5, .45, .4), ('top_p5', .4, .5, .35)]),
    393: lambda n: cut(n, ('obl_p4_l30', .5, .5, 1.04), bg=INK),
    391: lambda n: split(n, ('face_p1_r30_349', 's1', None, .4), ('face_p2_l30_408', 'r0', None, .3), r=.6),
    384: lambda n: bands(n, [('top_p5', .3, .35, .62), ('top_p5', .75, .55, .62)]),
    383: lambda n: grid(n, [('bus_p2_r30', .5, .5, .9), ('bus_p5_l30', .5, .5, .9), ('obl_p0_r30', .5, .5, .9), ('obl_p4_r30', .5, .5, .9)], 2, 2, bg=INK),
    382: lambda n: inset(n, ('obl_p0_l30', .5, .5, 1.05), ('obl_p0_l30', 'r0', (-.2, .2), .22), corner='tr'),
    379: lambda n: full(n, ('close_door18_r30', .35, .5, .75)),
    374: lambda n: split(n, ('top_p4', .5, .6, .4), ('face_p4_l30_299', 'r0', None, .35)),
    370: lambda n: bands(n, [('close_aisle16_r30', .5, .45, 1.0), ('close_dish20_l30', .5, .55, 1.0)]),
    367: lambda n: cut(n, ('obl_p2_r30', .5, .5, 1.04), bg=INK),
    366: lambda n: full(n, ('face_p4_r30_461', 'rr', None, .5)),
    364: lambda n: mosaic(n, ('obl_p5_r30', .45, .45, .45), ('top_p5', .5, .3, .4), ('close_dish20_l30', .4, .5, .8)),
    362: lambda n: split(n, ('top_p2', .45, .5, .62), ('obl_p2_l30', .5, .5, .75)),
    360: lambda n: grid(n, [(f'top_p{p}', .5, .5, 1.0) for p in range(6)], 3, 2),
    359: lambda n: full(n, ('top_p3', .5, .5, .7)),
    357: lambda n: cut(n, ('obl_p1_l30', .5, .5, 1.04)),
    356: lambda n: cols(n, [(f'obl_p{p}_l30', .35, .58, .3) for p in [0, 2, 3, 4, 5]]),
    353: lambda n: mosaic(n, ('seq_p1_r30_520', .45, .5, .4), ('face_p1_l30_159', 'r0', None, .3), ('seq_p1_r30_150', 's0', None, .3)),
    351: lambda n: grid(n, [(f'top_p{p}', .5, .5, 1.0) for p in [1, 2, 4, 5]], 2, 2),
}

if __name__ == '__main__':
    want = [int(x) for x in sys.argv[1:]] or list(JOBS)
    for n in want: JOBS[n](n)
