#!/usr/bin/env python3 """依頼139:病院の立体の見た目の静止画・動画に、問題の輪と右のパネルを付ける(1920×1080)。 左 1360×1080=Unity で撮った 2720×2160 を縮めたもの。輪は床の上(高さ FLOOR_Y)に、cam_{name}.json の行列で画素に移して描く (依頼136 の vid136.py と同じ色・同じ印。10 秒出す)。引きのカメラ(top_wide・obl_l30_wide)だけ、ロボットの位置に白の輪を足す。 右 560=配膳のパネル(rec_lp_20261105/p0_round/after/round_after_panel.png)と同じ作り:白地・小さな見出し・大きな時刻・ 「ロボット・スタッフがしていること」・数。中身は病院の値(ロボットの状態・助けに出ているスタッフ・大廊下の人数・撮り始めからの問題1〜5)。 python3 pan139.py stills <回のフォルダ> python3 pan139.py video <回のフォルダ> <出力.mp4> python3 pan139.py sheets <回のフォルダ> <出力フォルダ> """ import csv, json, math, os, subprocess, sys, tempfile from PIL import Image, ImageDraw, ImageFont FFMPEG = "/opt/homebrew/Caskroom/miniforge/base/envs/ros_env/bin/ffmpeg" FONT_B = os.path.expanduser("~/Library/Fonts/NotoSansJP-Bold.otf") FONT_R = os.path.expanduser("~/Library/Fonts/NotoSansJP-Regular.otf") W, H, SW = 1920, 1080, 1360 # 全体・左の場面の幅(配膳のパネル付きと同じ割り) FLOOR_Y = 0.2 # 床の上の面(StoreLayout.floorThickness) REC_FROM = 8845.0 # 撮り始め(動画の始め。静止画の数もここから数える) SHOW_S = 10.0 TYPES = {1: "急に止まった", 2: "ひやり", 3: "人がよけさせられた", 4: "立ち往生", 5: "横切り"} COLORS = {1: (220, 30, 40), 2: (255, 120, 0), 3: (230, 200, 0), 4: (150, 60, 210), 5: (20, 110, 230)} FAST_COL, OVER_COL = (255, 0, 0), (0, 0, 0) # 立体の色(配膳の材質。画面での見え方に近い値) YELLOW, BLUE, WHITE, DARK = (250, 204, 33), (23, 97, 230), (255, 255, 255), (77, 79, 84) INK, GRAY, PANEL_BG, LINE = (29, 29, 27), (110, 110, 108), (249, 247, 244), (200, 198, 194) CAM_JA = {"top_wide": "1 真上・引き", "top_near": "2 真上・寄り", "obl_l30_wide": "3 60°見下ろし・左30°・引き", "obl_r30_near": "4 60°見下ろし・右30°・寄り"} CAM_ORDER = ["top_wide", "top_near", "obl_l30_wide", "obl_r30_near"] WIDE = {"top_wide", "obl_l30_wide"} SCHEME_JA = {"A": "A ミント×木目", "B": "B 生成り×木目", "C": "C 黄緑×木目"} STATE = [("待ち", "待っている"), ("行き", "行き"), ("帰り", "帰り"), ("移る", "表と裏口を移る")] def font(b, s): return ImageFont.truetype(FONT_B if b else FONT_R, s) def clock(t): s = int(math.floor(7.5 * 3600 + t + 1e-6)) return "%d時%02d分%02d秒" % (s // 3600, s % 3600 // 60, s % 60) class Cam: def __init__(self, path): j = json.load(open(path, encoding="utf-8")) self.name, self.w, self.h = j["name"], j["width"], j["height"] self.v, self.p = j["worldToCameraMatrix"], j["projectionMatrix"] self.sx, self.sy = SW / self.w, H / self.h def px(self, x, z, y=FLOOR_Y): P = (x, y, z, 1.0) c = [sum(self.v[r][k] * P[k] for k in range(4)) for r in range(4)] q = [sum(self.p[r][k] * c[k] for k in range(4)) for r in range(4)] if q[3] <= 1e-6: return None u = (q[0] / q[3] + 1) / 2 * self.w * self.sx v = (1 - q[1] / q[3]) / 2 * self.h * self.sy return u, v def ppm(self, x, z): a, b = self.px(x - 0.5, z), self.px(x + 0.5, z) return math.hypot(b[0] - a[0], b[1] - a[1]) def ring(d, cam, x, z, r_m, min_px, col, width): r = max(r_m, min_px / max(cam.ppm(x, z), 1e-3)) pts = [cam.px(x + r * math.cos(a), z + r * math.sin(a)) for a in [i * 2 * math.pi / 48 for i in range(49)]] if any(p is None for p in pts): return d.line(pts, fill=col, width=width, joint="curve") def square(d, cam, x, z, r_m, min_px, col, width): r = max(r_m, min_px / max(cam.ppm(x, z), 1e-3)) pts = [cam.px(x - r, z - r), cam.px(x + r, z - r), cam.px(x + r, z + r), cam.px(x - r, z + r), cam.px(x - r, z - r)] d.line(pts, fill=col, width=width) return pts def overlay(im, cam, ev, t, fr, robot_xz): d = ImageDraw.Draw(im) if cam.name in WIDE and robot_xz is not None: ring(d, cam, robot_xz[0], robot_xz[1], 1.2, 15, (40, 40, 40), 6) ring(d, cam, robot_xz[0], robot_xz[1], 1.2, 15, (255, 255, 255), 3) for e in ev: te = float(e["t_s"]) if te > t + 1e-6 or t - te > SHOW_S: continue k = int(e["type"]) fade = 1.0 - (t - te) / SHOW_S * 0.7 x, z = float(e["x"]), float(e["z"]) layer = Image.new("RGBA", im.size, (0, 0, 0, 0)) mark(ImageDraw.Draw(layer), cam, e, k, x, z, fr) alpha = layer.split()[3].point(lambda v: int(v * fade)) layer.putalpha(alpha) im.paste(layer, (0, 0), layer) def mark(d, cam, e, k, x, z, fr): a = 255 if True: if k == 6: p = square(d, cam, x, z, 0.8, 22, (255, 230, 0, a), 5) d.line([p[0], p[2]], fill=OVER_COL + (a,), width=6) d.line([p[1], p[3]], fill=OVER_COL + (a,), width=6) return if k == 2 and e.get("note") == "速いまま": square(d, cam, x, z, 0.65, 18, FAST_COL + (a,), 4) p = square(d, cam, x, z, 0.85, 25, FAST_COL + (a,), 3) d.text((max(q[0] for q in p) + 3, min(q[1] for q in p)), "速", font=fr, fill=FAST_COL + (a,)) return ring(d, cam, x, z, 0.6 if k != 4 else 0.85, 12 if k != 4 else 18, COLORS[k] + (a,), 4) def legend(im): d = ImageDraw.Draw(im) f = font(False, 24) items = ["患者・付き添い", "杖の人", "車いす", "スタッフ", "ストレッチャー", "ロボット"] x0, y0, x = 24, 24, 44 widths = [int(d.textlength(s, font=f)) for s in items] total = sum(widths) + len(items) * 52 + 20 d.rectangle([x0, y0, x0 + total, y0 + 56], fill=(242, 241, 238)) y = y0 + 28 for s, wdt in zip(items, widths): if s == "患者・付き添い": d.ellipse([x, y - 11, x + 22, y + 11], fill=YELLOW) elif s == "杖の人": d.ellipse([x, y - 11, x + 22, y + 11], fill=YELLOW) d.line([x + 25, y - 9, x + 25, y + 12], fill=DARK, width=4) elif s == "車いす": d.rectangle([x - 1, y - 13, x + 23, y + 13], fill=DARK) d.ellipse([x + 4, y - 7, x + 18, y + 7], fill=YELLOW) elif s == "スタッフ": d.ellipse([x, y - 11, x + 22, y + 11], fill=BLUE) elif s == "ストレッチャー": d.rectangle([x - 2, y - 8, x + 18, y + 8], fill=(222, 223, 225), outline=(150, 150, 150)) d.ellipse([x + 19, y - 6, x + 31, y + 6], fill=BLUE) else: d.ellipse([x, y - 11, x + 22, y + 11], fill=WHITE, outline=(60, 60, 60), width=2) d.text((x + 36, y), s, font=f, fill=INK, anchor="lm") x += wdt + 52 def counts(ev, t): c = {k: 0 for k in range(1, 8)} for e in ev: te = float(e["t_s"]) if REC_FROM - 1e-6 <= te <= t + 1e-6: c[int(e["type"])] += 1 if e["type"] == "2" and e.get("note") == "速いまま": c[7] += 1 return c def panel(im, row, ev, t, scheme, camname, summ): d = ImageDraw.Draw(im) d.rectangle([SW, 0, W, H], fill=PANEL_BG) x0, xr = SW + 40, W - 40 f_s, f_m, f_mb, f_big, f_num = font(False, 24), font(False, 27), font(True, 29), font(True, 54), font(True, 46) d.text((x0, 24), "病院 h0・%s・%.1fm/秒・種%d" % (summ["plan"], summ["speed_mps"], summ["seed"]), font=f_s, fill=GRAY) d.text((x0, 56), "色 %s" % SCHEME_JA[scheme], font=f_s, fill=GRAY) d.text((x0, 88), "カメラ %s" % CAM_JA[camname], font=f_s, fill=GRAY) d.text((x0, 122), clock(t), font=f_big, fill=INK) y = 206 d.text((x0, y), "ロボット・スタッフがしていること", font=f_mb, fill=INK) y += 52 d.text((x0, y), "ロボット 1台", font=f_m, fill=INK) y += 44 cur = row["leg_dir"] for key, ja in STATE: on = key == cur d.text((x0 + 8, y), ja, font=f_m, fill=INK if on else (150, 150, 148)) if on: d.ellipse([xr - 40, y + 2, xr - 4, y + 38], fill=WHITE, outline=(80, 80, 80), width=3) if key in ("行き", "帰り", "移る") and row["leg_from"]: d.text((xr - 52, y + 20), "%s→%s" % (row["leg_from"], row["leg_to"]), font=f_s, fill=GRAY, anchor="rm") y += 40 y += 10 d.text((x0, y), "スタッフ", font=f_m, fill=INK) y += 44 rs = int(row["rescue_staff"]) d.text((x0 + 8, y), "ロボットの助け", font=f_m, fill=INK if rs else (150, 150, 148)) for i in range(rs): d.rectangle([xr - 36 - i * 44, y + 3, xr - 4 - i * 44, y + 35], fill=BLUE) y += 56 d.line([x0, y, xr, y], fill=LINE, width=2) y += 18 d.text((x0, y + 8), "大廊下の人(x0〜70)", font=f_m, fill=INK) d.text((xr, y + 28), row["hall_people"], font=f_num, fill=INK, anchor="rm") y += 70 d.line([x0, y, xr, y], fill=LINE, width=2) y += 16 s0 = int(7.5 * 3600 + REC_FROM) d.text((x0, y), "撮り始め(%d:%02d:%02d)からの問題" % (s0 // 3600, s0 % 3600 // 60, s0 % 60), font=f_mb, fill=INK) y += 50 c = counts(ev, t) for k in range(1, 6): d.ellipse([x0 + 6, y + 6, x0 + 30, y + 30], outline=COLORS[k], width=4) d.text((x0 + 44, y + 18), "%d %s" % (k, TYPES[k]), font=f_m, fill=INK, anchor="lm") d.text((xr, y + 18), str(c[k]), font=font(True, 34), fill=INK, anchor="rm") y += 46 y += 6 # 印の見方(速いままのひやり・止まりきれなかった・白の輪) d.rectangle([x0 + 9, y + 5, x0 + 27, y + 23], outline=FAST_COL, width=3) d.rectangle([x0 + 4, y, x0 + 32, y + 28], outline=FAST_COL, width=2) d.text((x0 + 44, y + 14), "うち速いままのひやり %d" % c[7], font=f_s, fill=INK, anchor="lm") y += 38 d.rectangle([x0 + 5, y + 1, x0 + 31, y + 27], outline=(255, 210, 0), width=4) d.line([x0 + 9, y + 5, x0 + 27, y + 23], fill=OVER_COL, width=5) d.line([x0 + 9, y + 23, x0 + 27, y + 5], fill=OVER_COL, width=5) d.text((x0 + 44, y + 14), "止まりきれなかった %d" % c[6], font=f_s, fill=INK, anchor="lm") y += 40 note = "輪=問題の起きた所(10秒)" + ("・白い輪=ロボット" if camname in WIDE else "") d.text((x0, min(y + 4, H - 40)), note, font=font(False, 21), fill=GRAY) def compose(run, rowd, ev, summ, cams): src = Image.open(os.path.join(run, "look139", rowd["file"])).convert("RGB").resize((SW, H), Image.LANCZOS) im = Image.new("RGB", (W, H), PANEL_BG) im.paste(src, (0, 0)) t = float(rowd["t_s"]) cam = cams[rowd["cam"]] overlay(im, cam, ev, t, font(True, 22), (float(rowd["robot_x"]), float(rowd["robot_z"]))) legend(im) panel(im, rowd, ev, t, rowd["scheme"], rowd["cam"], summ) return im def load(run): rows = list(csv.DictReader(open(os.path.join(run, "look139", "look_frames.csv"), encoding="utf-8"))) ev = list(csv.DictReader(open(os.path.join(run, "events.csv"), encoding="utf-8"))) summ = json.load(open(os.path.join(run, "summary.json"), encoding="utf-8")) cams = {n: Cam(os.path.join(run, "look139", "cam_%s.json" % n)) for n in CAM_ORDER} return rows, ev, summ, cams def main(): mode, run = sys.argv[1], sys.argv[2] rows, ev, summ, cams = load(run) if mode == "stills": out = os.path.join(run, "look139", "panel") os.makedirs(out, exist_ok=True) for r in rows: if r["kind"] != "still": continue im = compose(run, r, ev, summ, cams) f = os.path.join(out, "still_%s_%s.png" % (r["scheme"], r["cam"])) im.save(f) print(f) elif mode == "video": camname, dst = sys.argv[3], sys.argv[4] tmp = tempfile.mkdtemp(prefix="pan139_") vr = [r for r in rows if r["kind"] == "vid" and r["cam"] == camname] for i, r in enumerate(vr): compose(run, r, ev, summ, cams).save(os.path.join(tmp, "c%05d.png" % i)) # 0.2 秒ごとのコマを 25 コマ/秒で流す=店の時刻の 5 倍 subprocess.check_call([FFMPEG, "-y", "-loglevel", "error", "-framerate", "25", "-i", os.path.join(tmp, "c%05d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", dst]) print(dst, len(vr), "コマ", len(vr) / 25.0, "秒") elif mode == "sheets": out = sys.argv[3] os.makedirs(out, exist_ok=True) pdir = os.path.join(run, "look139", "panel") fh, fl = font(True, 40), font(True, 30) t = float([r for r in rows if r["kind"] == "still"][0]["t_s"]) head = "依頼139 病院の立体の見た目・%s・%.1fm/秒・種%d・%s" % (summ["plan"], summ["speed_mps"], summ["seed"], clock(t)) # カメラごとに色3案を横に並べる cw, ch, top = 1280, 720, 130 for i, cn in enumerate(CAM_ORDER): sheet = Image.new("RGB", (cw * 3 + 40, ch + top + 20), (255, 255, 255)) d = ImageDraw.Draw(sheet) d.text((20, 18), head + " カメラ " + CAM_JA[cn], font=fh, fill=INK) for j, s in enumerate("ABC"): im = Image.open(os.path.join(pdir, "still_%s_%s.png" % (s, cn))).resize((cw, ch), Image.LANCZOS) x = 10 + j * (cw + 10) sheet.paste(im, (x, top)) d.text((x, top - 44), SCHEME_JA[s], font=fl, fill=INK) f = os.path.join(out, "compare_cam%d_%s.png" % (i + 1, cn)) sheet.save(f) print(f) # 12 枚の一覧(横=色3案・縦=カメラ4つ) cw, ch, top, left = 960, 540, 130, 260 sheet = Image.new("RGB", (left + cw * 3 + 40, top + (ch + 12) * 4 + 10), (255, 255, 255)) d = ImageDraw.Draw(sheet) d.text((20, 18), head + " 色3案×カメラ4つ", font=fh, fill=INK) for j, s in enumerate("ABC"): d.text((left + j * (cw + 10), top - 46), SCHEME_JA[s], font=fl, fill=INK) for i, cn in enumerate(CAM_ORDER): y = top + i * (ch + 12) for k, line in enumerate(CAM_JA[cn].split("・")): d.text((16, y + 20 + k * 42), line, font=font(True, 28), fill=INK) for j, s in enumerate("ABC"): im = Image.open(os.path.join(pdir, "still_%s_%s.png" % (s, cn))).resize((cw, ch), Image.LANCZOS) sheet.paste(im, (left + j * (cw + 10), y)) f = os.path.join(out, "overview_12.png") sheet.save(f) print(f) if __name__ == "__main__": main()