"""サービスロボット導入シミュレーション(/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)