File size: 27,140 Bytes
68e2686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
#!/usr/bin/env python3
"""teaser · 世界编辑 case 的合成 / 打分 / 选片(maliang venv,CPU)。输入 = edits.py 的结果目录 <e81>/cases/<name>/(edit.json +
rollout.py 推完的 gen.mp4 / ids.npz / meta.json),原版 = edit.json 里 orig.roll(outputs/teaser/roll/<base>/gen.mp4,同首帧同种子)。

子命令
  side   一条 case:「原版 | 编辑版」并排(1920×1080),上方两张语义图(左 = 原版条件,右 = 编辑后条件,编辑区域高亮 + 白描边),
         编辑版上在编辑时刻弹出标签(如 "+ Tower @ 2.0 s"),底部时间条标出编辑帧;--hl N:编辑版视频上编辑区域描边 N 帧(默认 12)。
  grid   同一种编辑的若干 case 宫格(每格 = 编辑版 + 右上角编辑后语义小窗 + 底部标签;--pairs ⇒ 每格「原版 | 编辑版」两小格)。
  reel   若干 case 的 side 串接(相邻交叉淡化 --fade 帧;case 的 side 没有就先出)。
  sheet  一条 case 的帧拼图(原版 / 编辑版 / 编辑后语义 × 编辑前后 6 个时刻)→ <case>/edit_sheet.jpg(目测用)。
  score  编辑是否在视频里生效 → <case>/edit_score.json:
           * delta_in / delta_out:帧 ≥ 编辑结束(且 ≥ 编辑帧 + 4)的每 4 帧,编辑区域(掩码腐蚀 2 px)里编辑版与原版的 Lab ΔE
             均值 vs 区域外(掩码膨胀 24 px 之外、角色外)的 ΔE 均值;effect = delta_in − delta_out,ratio = in / out;
           * fit_edit / fit_orig:编辑区域里的 8×8 块按「本帧区域外各类的 Lab 外观(对角高斯)」分类,判成编辑后类 / 原类的比例
             (编辑后类在区域外不存在 ⇒ 记 None);fit = fit_edit − fit_orig;
           * pre_delta:编辑帧之前的帧(≥ 4)整帧 ΔE(双向教师看全片条件 ⇒ 编辑前也会有点变化,因果学生应 ≈ 0)。
         有 vscore(outputs/teaser/vscore/edit/<name>.vscore.json)就一起汇总。
  index  全部 case → <out>/index.jsonl + index.md(编辑类型、标签、指标、路径),按 edit_score 排序。
  dmd    双向教师 vs 因果 DMD 学生 2×2 对照(<case>/edit_dmd_cmp.mp4;DMD 结果在 --dmd_root,默认 <e81>/dmd/<name>、<base>__orig)。

usage($PY = .venv/bin/python,在 $M 下运行):
  $PY scripts/teaser/compose_edit.py side outputs/teaser/edit/e81/cases/<name> [-o x.mp4]
  $PY scripts/teaser/compose_edit.py sheet outputs/teaser/edit/e81/cases/<name>
  $PY scripts/teaser/compose_edit.py score 'outputs/teaser/edit/e81/cases/*' [--procs 32]
  $PY scripts/teaser/compose_edit.py grid c1 c2 ... -o g.mp4 --cols 3 [--pairs]
  $PY scripts/teaser/compose_edit.py reel @list.txt -o r.mp4
  $PY scripts/teaser/compose_edit.py index 'outputs/teaser/edit/e81/cases/*' -o outputs/teaser/edit/e81/showcase
  $PY scripts/teaser/compose_edit.py dmd outputs/teaser/edit/e81/cases/<name> [--dmd_root DIR] [-o x.mp4]
glob 参数要加引号(脚本自己展开),也可以 @list.txt(每行一个 case 目录)。
"""
from __future__ import annotations

import argparse
import glob
import json
import os
import sys
from concurrent.futures import ProcessPoolExecutor

import cv2
import numpy as np
from PIL import Image, ImageDraw

M = "/opt/dlami/nvme/zhiyangdeng/maliang"
HERE = os.path.dirname(os.path.abspath(__file__))
for _p in (f"{M}/code/gc25d", HERE):
    if _p not in sys.path:
        sys.path.insert(0, _p)
import compose as CP  # noqa: E402
import geometry as GEO  # noqa: E402

FPS = 16
H, W = 480, 832
HL = (255, 214, 64)          # 编辑区域高亮色(暖黄)
ACCENT = (255, 196, 40)


def rp(p: str) -> str:
    return p if os.path.isabs(p) else os.path.join(M, p)


def expand(items):
    out = []
    for it in items:
        if it.startswith("@"):
            out += [ln.strip() for ln in open(it[1:]) if ln.strip() and not ln.startswith("#")]
        elif any(ch in it for ch in "*?["):
            out += sorted(glob.glob(rp(it)))
        else:
            out.append(it)
    return [rp(x) for x in out]


# ------------------------------------------------------------------ case ----
def load(cd: str, video: bool = True) -> dict:
    cd = rp(cd)
    e = json.load(open(os.path.join(cd, "edit.json")))
    z = np.load(os.path.join(cd, "mask.npz"))
    mask = np.unpackbits(z["bits"])[:int(np.prod(z["shape"]))].reshape(z["shape"]).astype(bool)
    c = dict(dir=cd, edit=e, mask=mask, ids1=np.load(os.path.join(cd, "ids.npz"))["ids"],
             ids0=np.load(rp(e["orig"]["ids"]))["ids"], name=e["name"])
    if video:
        c["gen1"] = CP.read_video(os.path.join(cd, "gen.mp4"))
        c["gen0"] = CP.read_video(rp(e["orig"]["gen"]))
    return c


def base_labels(e: dict) -> tuple[str, str]:
    b = e["base"]
    scene = b.get("scene") or ""
    try:
        scene = CP.SCENE_EN.get(b["scene"].split("@")[0], CP.pretty(b["scene"].split("@")[0]))
        look = CP.LOOK_EN.get(b["scene"].split("@")[1], "") if "@" in b["scene"] else ""
        scene = f"{scene} · {look}" if look else scene
    except Exception:                                                     # noqa: BLE001
        pass
    mp = CP.MAP_EN.get(b.get("theme") or "", CP.pretty(b.get("theme")))
    return scene, mp


_LF = {}


def label_pill(s: str, size: int, fg=(20, 20, 24), bg=ACCENT, alpha: float = 0.95):
    """编辑标签的小胶囊:拉丁 / 箭头走 DejaVu Sans Bold(Noto 变体字体没有 →),中文走 Droid。"""
    from PIL import ImageFont
    key = size
    if key not in _LF:
        _LF[key] = (ImageFont.truetype("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf", size),
                    ImageFont.truetype(CP.FONT_CJK, size))
    lat, cjk = _LF[key]
    runs = CP._runs(s)
    w = sum((cjk if c else lat).getlength(r) for c, r in runs)
    px, py = round(size * 0.55), round(size * 0.3)
    W_, H_ = int(np.ceil(w)) + 2 * px, int(round(size * 1.1)) + 2 * py
    im = Image.new("RGBA", (W_, H_), (0, 0, 0, 0))
    d = ImageDraw.Draw(im)
    d.rounded_rectangle((0, 0, W_ - 1, H_ - 1), radius=H_ // 2, fill=(*bg, int(255 * alpha)))
    x = px
    for c, r in runs:
        f = cjk if c else lat
        d.text((x, py + 0.85 * size), r, font=f, fill=(*fg, 255), anchor="ls")
        x += f.getlength(r)
    a = np.asarray(im, np.float32)
    return a[..., :3].astype(np.uint8), a[..., 3:] / 255.0


def hl_sem(ids: np.ndarray, mask: np.ndarray, w: int, h: int, strength: float = 0.0) -> np.ndarray:
    """语义色图 + 编辑区域:区域外压暗 strength,区域描白边。"""
    im = CP.sem_panel(ids, w, h).astype(np.float32)
    m = cv2.resize(mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST) > 0
    if m.any():
        if strength > 0:
            im[~m] *= 1 - strength
        mm = cv2.dilate(m.astype(np.uint8), np.ones((3, 3), np.uint8))
        o = (cv2.dilate(mm, np.ones((3, 3), np.uint8)) - mm) > 0
        im[o] = (255, 255, 255)
    return im.clip(0, 255).astype(np.uint8)


def outline_on(img: np.ndarray, mask: np.ndarray, color=HL, alpha: float = 1.0, t: int = 2) -> np.ndarray:
    h, w = img.shape[:2]
    m = cv2.resize(mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST)
    m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
    cs, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cs = [c for c in cs if cv2.contourArea(c) > 30]
    if not cs:
        return img
    ov = img.copy()
    cv2.drawContours(ov, cs, -1, color, t, cv2.LINE_AA)
    return cv2.addWeighted(ov, alpha, img, 1 - alpha, 0)


def timeline(w: int, f: int, n: int, k: int, k1: int, h: int = 10) -> np.ndarray:
    bar = np.zeros((h, w, 3), np.uint8)
    bar[:] = (44, 48, 58)
    x = lambda i: int(round(i / max(1, n - 1) * (w - 1)))
    bar[:, :x(f) + 1] = (110, 118, 136)
    bar[:, x(k):x(max(k1, k)) + 2] = ACCENT
    return bar


# ------------------------------------------------------------------ side ----
def side_frames(c: dict, hl: int = 12, canvas=(1920, 1080)):
    e = c["edit"]
    Wc, Hc = canvas
    k, k1 = e["edit_frame"], max(e["edit_end"], e["edit_frame"])
    n = min(len(c["gen0"]), len(c["gen1"]), len(c["ids1"]))
    m, gap = 40, 40
    vw = (Wc - 2 * m - gap) // 2
    vh = int(round(vw * H / W))
    sw, sh = int(vw * 0.5), int(vw * 0.5 * H / W)
    top = 92
    y_sem = top
    y_vid = y_sem + sh + 46
    xs = [m, m + vw + gap]
    scene, mp = base_labels(e)
    head = Image.new("RGB", (Wc, top - 12), CP.BG)
    d = ImageDraw.Draw(head)
    x = CP.draw_text(d, m, 22, "Edit the map, the world follows", 30, CP.FG, "SemiBold")
    CP.draw_text(d, x + 24, 30, f"{scene}  ·  {mp} map", 20, CP.MUTED)
    head = np.asarray(head)
    lab = label_pill(e["label"], 26)
    lab_o = CP.pill("Original", 22, alpha=0.55)
    lab_e = CP.pill("Edited", 22, alpha=0.55)
    sem_l0 = CP.pill("original map", 17, alpha=0.6)
    sem_l1 = CP.pill("edited map", 17, alpha=0.6)
    for f in range(n):
        img = np.zeros((Hc, Wc, 3), np.uint8)
        img[:] = CP.BG
        img[12:12 + head.shape[0]] = head
        on = f >= k
        s0 = CP.sem_panel(c["ids0"][f], sw, sh)
        s1 = hl_sem(c["ids1"][f], c["mask"][f], sw, sh) if on else CP.sem_panel(c["ids1"][f], sw, sh)
        for i, (sp, sl) in enumerate(((s0, sem_l0), (s1, sem_l1))):
            xx = xs[i] + (vw - sw) // 2
            img[y_sem:y_sem + sh, xx:xx + sw] = sp
            CP.frame_rect(img, xx, y_sem, sw, sh)
            CP.paste_rgba(img, sl, xx + 8, y_sem + 8)
        g0 = CP.fit(c["gen0"][f], vw, vh)
        g1 = CP.fit(c["gen1"][f], vw, vh)
        if hl and k <= f < k1 + hl and c["mask"][f].any():
            a = 1.0 if f < k1 + hl // 2 else max(0.0, 1 - (f - k1 - hl // 2) / max(1, hl - hl // 2))
            g1 = outline_on(g1, c["mask"][f], HL, 0.85 * a, 2)
        for i, (g, lb) in enumerate(((g0, lab_o), (g1, lab_e))):
            img[y_vid:y_vid + vh, xs[i]:xs[i] + vw] = g
            CP.frame_rect(img, xs[i], y_vid, vw, vh, ACCENT if (i == 1 and on) else CP.BORDER, 2 if (i == 1 and on) else 1)
            CP.paste_rgba(img, lb, xs[i] + 12, y_vid + 12)
        if on:
            pop = min(1.0, (f - k + 1) / 4)
            rgb, al = lab
            CP.paste_rgba(img, (rgb, al * pop), xs[1] + vw - rgb.shape[1] - 14, y_vid + 12)
        bar = timeline(2 * vw + gap, f, n, k, k1)
        img[y_vid + vh + 18:y_vid + vh + 18 + bar.shape[0], m:m + bar.shape[1]] = bar
        rgb, al = CP.pill(f"{f / FPS:.2f} s", 18, alpha=0.0)
        CP.paste_rgba(img, (rgb, al), m, y_vid + vh + 32)
        yield img


def cmd_side(a) -> None:
    for cd in expand(a.cases):
        c = load(cd)
        out = a.o or os.path.join(cd, "edit_side.mp4")
        CP.write_video(out, side_frames(c, a.hl), crf=a.crf)
        print(out)


# ------------------------------------------------------------------ sheet ----
def sheet_idx(e: dict, n: int = 81):
    k, k1 = e["edit_frame"], max(e["edit_end"], e["edit_frame"])
    idx = [max(0, k - 6), min(n - 1, k + 3), min(n - 1, (k + k1) // 2 + 4), min(n - 1, k1 + 6), min(n - 1, (k1 + n) // 2 + 6), n - 1]
    return sorted(set(idx))


def cmd_sheet(a) -> None:
    for cd in expand(a.cases):
        c = load(cd)
        e = c["edit"]
        idx = sheet_idx(e, len(c["gen1"]))
        tw, th = W // 2, H // 2
        rows = []
        for kind in ("orig", "edit", "sem"):
            tiles = []
            for f in idx:
                if kind == "orig":
                    im = CP.fit(c["gen0"][f], tw, th)
                elif kind == "edit":
                    im = CP.fit(c["gen1"][f], tw, th)
                    if f >= e["edit_frame"] and c["mask"][f].any():
                        im = outline_on(im, c["mask"][f], HL, 0.9, 1)
                else:
                    im = hl_sem(c["ids1"][f], c["mask"][f], tw, th)
                im = im.copy()
                txt = f"{kind} f{f} {f / FPS:.2f}s"
                cv2.putText(im, txt, (6, th - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 0), 3, cv2.LINE_AA)
                cv2.putText(im, txt, (6, th - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
                tiles.append(im)
            rows.append(np.concatenate(tiles, 1))
        body = np.concatenate(rows, 0)
        head = Image.new("RGB", (body.shape[1], 28), CP.BG)
        sc = ""
        sp = os.path.join(cd, "edit_score.json")
        if os.path.exists(sp):
            s = json.load(open(sp))
            sc = f"  ·  effect {s.get('effect')}  ratio {s.get('ratio')}  fit {s.get('fit')}"
        CP.draw_text(ImageDraw.Draw(head), 8, 5, f"{e['name']}  ·  {e['label']}{sc}", 15, CP.FG, "SemiBold")
        out = a.o or os.path.join(cd, "edit_sheet.jpg")
        cv2.imwrite(out, cv2.cvtColor(np.concatenate([np.asarray(head), body], 0), cv2.COLOR_RGB2BGR),
                    [cv2.IMWRITE_JPEG_QUALITY, 88])
        print(out)


# ------------------------------------------------------------------ score ----
def _lab(rgb):
    return cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB).astype(np.float32) * np.array([100 / 255, 1, 1], np.float32) - [0, 128, 128]


def _collapse(ids):
    out = ids.copy()
    out[(ids >= 12) & (ids <= 16)] = 10
    out[(ids >= 17) & (ids <= 21)] = 11
    return out


def _fit_block(L, ids_edit, ids_orig, mask, cell=8):
    """编辑区域里的 8×8 块按区域外各类(编辑后语义)的 Lab 外观分类 → (判成编辑后类的比例, 判成原类的比例, 块数)。"""
    h, w = H // cell, W // cell
    blk = L[:h * cell, :w * cell].reshape(h, cell, w, cell, 3)
    feat = np.concatenate([blk.mean((1, 3)), blk[..., 0].std((1, 3))[..., None]], -1).reshape(-1, 4)

    def maj(ids):
        b = ids[:h * cell, :w * cell].reshape(h, cell, w, cell).transpose(0, 2, 1, 3).reshape(h * w, -1)
        mj = np.array([np.bincount(r, minlength=256).argmax() for r in b])
        return mj, (b == mj[:, None]).mean(1)
    me, pe = maj(ids_edit)
    mo, _ = maj(ids_orig)
    mb = mask[:h * cell, :w * cell].reshape(h, cell, w, cell).transpose(0, 2, 1, 3).reshape(h * w, -1).mean(1)
    inside = (mb >= 0.75) & (pe >= 0.75) & (me != mo)
    outside = (mb == 0) & (pe >= 0.75)
    if inside.sum() < 3:
        return None, None, int(inside.sum())
    cls = [c for c in np.unique(me[outside]) if (me[outside] == c).sum() >= 4]
    if int(np.bincount(me[inside]).argmax()) not in cls:
        return None, None, int(inside.sum())
    X = feat[outside & np.isin(me, cls)]
    y = me[outside & np.isin(me, cls)]
    mu = np.stack([X[y == c].mean(0) for c in cls])
    var = np.stack([X[y == c].var(0) for c in cls]) + 4.0
    Xi = feat[inside]
    ll = -0.5 * (((Xi[:, None] - mu[None]) ** 2 / var[None]).sum(-1) + np.log(var).sum(-1)[None])
    pred = np.asarray(cls)[ll.argmax(1)]
    return float((pred == me[inside]).mean()), float((pred == mo[inside]).mean()), int(inside.sum())


def score_one(cd: str) -> dict | None:
    cd = rp(cd)
    if not os.path.exists(os.path.join(cd, "gen.mp4")):
        return None
    c = load(cd)
    e = c["edit"]
    k, k1 = e["edit_frame"], max(e["edit_end"], e["edit_frame"])
    n = min(len(c["gen0"]), len(c["gen1"]))
    t0 = min(n - 1, max(k1, k + 4))
    frames = list(range(t0, n, 4)) or [n - 1]
    ins, outs, fe, fo, nb = [], [], [], [], []
    for f in frames:
        m = c["mask"][f]
        if m.sum() < 200:
            continue
        L0, L1 = _lab(c["gen0"][f]), _lab(c["gen1"][f])
        dE = np.linalg.norm(L1 - L0, axis=-1)
        mi = cv2.erode(m.astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
        if mi.sum() < 100:
            mi = m
        chars = (_collapse(c["ids0"][f]) >= 10) | (_collapse(c["ids1"][f]) >= 10)
        mo = ~(cv2.dilate(m.astype(np.uint8), np.ones((49, 49), np.uint8)) > 0) & ~cv2.dilate(chars.astype(np.uint8),
                                                                                              np.ones((31, 31), np.uint8)).astype(bool)
        ins.append(float(dE[mi & ~chars].mean()) if (mi & ~chars).sum() > 50 else float(dE[mi].mean()))
        if mo.sum() > 2000:
            outs.append(float(dE[mo].mean()))
        a1, a0, b = _fit_block(L1, _collapse(c["ids1"][f]), _collapse(c["ids0"][f]), m)
        if a1 is not None:
            fe.append(a1)
            fo.append(a0)
            nb.append(b)
    pre = []
    for f in range(4, max(4, k - 1), 6):
        pre.append(float(np.linalg.norm(_lab(c["gen1"][f]) - _lab(c["gen0"][f]), axis=-1).mean()))
    d_in = float(np.mean(ins)) if ins else None
    d_out = float(np.mean(outs)) if outs else None
    r = dict(name=c["name"], edit=e["edit"], variant=e["variant"], label=e["label"], edit_frame=k, edit_end=k1,
             frames=frames, delta_in=None if d_in is None else round(d_in, 3), delta_out=None if d_out is None else round(d_out, 3),
             effect=None if d_in is None or d_out is None else round(d_in - d_out, 3),
             ratio=None if d_in is None or not d_out else round(d_in / d_out, 3),
             fit_edit=round(float(np.mean(fe)), 3) if fe else None, fit_orig=round(float(np.mean(fo)), 3) if fo else None,
             fit=round(float(np.mean(fe) - np.mean(fo)), 3) if fe else None, fit_blocks=int(np.mean(nb)) if nb else 0,
             pre_delta=round(float(np.mean(pre)), 3) if pre else None,
             mask_px_mean=round(float(c["mask"][t0:].sum((1, 2)).mean()), 1))
    vs = f"{M}/outputs/teaser/vscore/edit/{c['name']}.vscore.json"
    if os.path.exists(vs):
        v = json.load(open(vs))
        r.update(teaser=v.get("teaser"), flags=v.get("flags"))
    json.dump(r, open(os.path.join(cd, "edit_score.json"), "w"), indent=1)
    return r


def cmd_score(a) -> None:
    cds = expand(a.cases)
    with ProcessPoolExecutor(a.procs) as ex:
        for r in ex.map(score_one, cds):
            if r:
                print(json.dumps({k: r[k] for k in ("name", "effect", "ratio", "fit", "pre_delta")}))


# ------------------------------------------------------------------ grid / reel ----
def cmd_grid(a) -> None:
    cs = [load(cd) for cd in expand(a.cases)]
    Wc, Hc = a.canvas
    n = len(cs)
    cols = a.cols
    rows = (n + cols - 1) // cols
    cell_w = (Wc - 2 * a.margin - (cols - 1) * a.gap) // cols
    aspect = (2 * W + 8) / H if a.pairs else W / H
    cell_h = int(cell_w / aspect)
    if rows * cell_h + (rows - 1) * a.gap + 2 * a.margin + 60 > Hc:
        cell_h = (Hc - 2 * a.margin - 60 - (rows - 1) * a.gap) // rows
        cell_w = int(cell_h * aspect)
    x0 = (Wc - (cols * cell_w + (cols - 1) * a.gap)) // 2
    y0 = 60 + (Hc - 60 - (rows * cell_h + (rows - 1) * a.gap)) // 2
    labs = [label_pill(c["edit"]["label"], 16) for c in cs]
    title = Image.new("RGB", (Wc, 56), CP.BG)
    CP.draw_text(ImageDraw.Draw(title), a.margin, 14, a.title or "Edit the map, the world follows", 26, CP.FG, "SemiBold")
    title = np.asarray(title)
    nf = min(len(c["gen1"]) for c in cs)

    def frames():
        for f in range(nf):
            img = np.zeros((Hc, Wc, 3), np.uint8)
            img[:] = CP.BG
            img[:56] = title
            for i, c in enumerate(cs):
                r_, q = divmod(i, cols)
                x, y = x0 + q * (cell_w + a.gap), y0 + r_ * (cell_h + a.gap)
                e = c["edit"]
                on = f >= e["edit_frame"]
                if a.pairs:
                    hw = (cell_w - 4) // 2
                    img[y:y + cell_h, x:x + hw] = CP.fit(c["gen0"][f], hw, cell_h)
                    img[y:y + cell_h, x + hw + 4:x + hw + 4 + hw] = CP.fit(c["gen1"][f], hw, cell_h)
                    gx, gw = x + hw + 4, hw
                else:
                    img[y:y + cell_h, x:x + cell_w] = CP.fit(c["gen1"][f], cell_w, cell_h)
                    gx, gw = x, cell_w
                iw = int(gw * 0.3)
                ih = int(iw * H / W)
                ins = hl_sem(c["ids1"][f], c["mask"][f], iw, ih) if on else CP.sem_panel(c["ids1"][f], iw, ih)
                img[y + 6:y + 6 + ih, gx + gw - iw - 6:gx + gw - 6] = ins
                CP.frame_rect(img, gx + gw - iw - 6, y + 6, iw, ih, (255, 255, 255) if on else CP.BORDER, 1)
                CP.frame_rect(img, x, y, cell_w, cell_h, ACCENT if on else CP.BORDER, 1)
                if on:
                    rgb, al = labs[i]
                    CP.paste_rgba(img, (rgb, al * min(1.0, (f - e["edit_frame"] + 1) / 4)), gx + 8, y + cell_h - rgb.shape[0] - 8)
            yield img
    CP.write_video(a.o, frames(), crf=a.crf)
    print(a.o)


def cmd_reel(a) -> None:
    import subprocess
    vids = []
    for cd in expand(a.cases):
        if cd.endswith(".mp4"):
            vids.append(cd)
            continue
        sp = os.path.join(cd, "edit_side.mp4")
        if not os.path.exists(sp):
            CP.write_video(sp, side_frames(load(cd), a.hl), crf=16)
        vids.append(sp)
    cmd = [sys.executable, os.path.join(HERE, "compose.py"), "reel", *vids, "-o", a.o, "--fade", str(a.fade)]
    subprocess.run(cmd, check=True)
    print(a.o)


# ------------------------------------------------------------------ dmd ----
DMD_ROOT = f"{M}/outputs/teaser/edit/e81/dmd"


def cmd_dmd(a) -> None:
    """双向教师 vs 因果 DMD 学生(同首帧、同条件、同种子):2×2 =「双向 原版 | 双向 编辑版」/「DMD 原版 | DMD 编辑版」+ 编辑后语义小窗。
    DMD 结果 = <dmd_root>/<name>/gen.mp4 与 <dmd_root>/<base>__orig/gen.mp4(scripts/teaser/rollout_dmd.py 出的)。"""
    for cd in expand(a.cases):
        c = load(cd)
        e = c["edit"]
        root = a.dmd_root or DMD_ROOT
        dp, do = os.path.join(root, c["name"], "gen.mp4"), os.path.join(root, e["base"]["name"] + "__orig", "gen.mp4")
        if not (os.path.exists(dp) and os.path.exists(do)):
            print(f"[skip] 没有 DMD 结果:{c['name']}")
            continue
        d1, d0 = CP.read_video(dp), CP.read_video(do)
        n = min(len(d1), len(d0), len(c["gen1"]), len(c["gen0"]))
        Wc, Hc = 1920, 1080
        vw, vh = 780, 450
        xs, ys = (160, 980), (92, 92 + vh + 30)
        k, k1 = e["edit_frame"], max(e["edit_end"], e["edit_frame"])
        head = Image.new("RGB", (Wc, 80), CP.BG)
        dr = ImageDraw.Draw(head)
        x = CP.draw_text(dr, 160, 22, "Bidirectional teacher vs causal student", 28, CP.FG, "SemiBold")
        CP.draw_text(dr, x + 24, 29, e["label"], 20, CP.MUTED)
        head = np.asarray(head)
        tags = [[CP.pill(t, 20, alpha=0.6) for t in row] for row in (("Teacher · original", "Teacher · edited"),
                                                                       ("Student (causal) · original", "Student (causal) · edited"))]
        lab = label_pill(e["label"], 22)

        def frames():
            for f in range(n):
                img = np.zeros((Hc, Wc, 3), np.uint8)
                img[:] = CP.BG
                img[:80] = head
                for r, (g0, g1) in enumerate(((c["gen0"], c["gen1"]), (d0, d1))):
                    for q, g in enumerate((g0, g1)):
                        im = CP.fit(g[f], vw, vh)
                        if q == 1 and k <= f < k1 + 12 and c["mask"][f].any():
                            im = outline_on(im, c["mask"][f], HL, 0.8, 2)
                        img[ys[r]:ys[r] + vh, xs[q]:xs[q] + vw] = im
                        on = q == 1 and f >= k
                        CP.frame_rect(img, xs[q], ys[r], vw, vh, ACCENT if on else CP.BORDER, 2 if on else 1)
                        CP.paste_rgba(img, tags[r][q], xs[q] + 10, ys[r] + 10)
                        if on:
                            rgb, al = lab
                            CP.paste_rgba(img, (rgb, al * min(1.0, (f - k + 1) / 4)), xs[q] + vw - rgb.shape[1] - 10, ys[r] + 10)
                iw, ih = 224, 129
                ins = hl_sem(c["ids1"][f], c["mask"][f], iw, ih) if f >= k else CP.sem_panel(c["ids1"][f], iw, ih)
                img[ys[1] + vh - ih - 10:ys[1] + vh - 10, xs[1] + vw - iw - 10:xs[1] + vw - 10] = ins
                bar = timeline(xs[1] + vw - xs[0], f, n, k, k1, 8)
                img[ys[1] + vh + 14:ys[1] + vh + 22, xs[0]:xs[0] + bar.shape[1]] = bar
                yield img
        out = a.o or os.path.join(cd, "edit_dmd_cmp.mp4")
        CP.write_video(out, frames(), crf=a.crf)
        print(out)


# ------------------------------------------------------------------ index ----
def cmd_index(a) -> None:
    rows = []
    for cd in expand(a.cases):
        ep = os.path.join(cd, "edit.json")
        if not os.path.exists(ep):
            continue
        e = json.load(open(ep))
        r = dict(name=e["name"], dir=cd, edit=e["edit"], variant=e["variant"], label=e["label"], k=e["edit_frame"],
                 k_end=e["edit_end"], base=e["base"]["name"], theme=e["base"]["theme"], scene=e["base"]["scene"],
                 rolled=os.path.exists(os.path.join(cd, "meta.json")), weak=e.get("weak"),
                 orig=e["orig"]["gen"], gen=os.path.join(cd, "gen.mp4"))
        sp = os.path.join(cd, "edit_score.json")
        if os.path.exists(sp):
            s = json.load(open(sp))
            r.update({k: s.get(k) for k in ("effect", "ratio", "fit", "fit_edit", "fit_orig", "pre_delta", "teaser", "flags")})
        rows.append(r)
    os.makedirs(a.o, exist_ok=True)
    rows.sort(key=lambda r: (r["edit"], -(r.get("effect") or -99)))
    with open(os.path.join(a.o, "index.jsonl"), "w") as fh:
        for r in rows:
            fh.write(json.dumps(r, ensure_ascii=False) + "\n")
    with open(os.path.join(a.o, "index.md"), "w") as fh:
        fh.write(f"# 世界编辑 case({len(rows)} 条)\n\n| edit | 标签 | effect | ratio | fit | pre | teaser | case |\n|---|---|---|---|---|---|---|---|\n")
        for r in rows:
            fh.write(f"| {r['edit']} | {r['label']} | {r.get('effect')} | {r.get('ratio')} | {r.get('fit')} | {r.get('pre_delta')} | "
                     f"{r.get('teaser')} | `{r['name']}` |\n")
    print(f"index:{len(rows)} 条 → {a.o}/index.md")


def build_parser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(description="teaser 世界编辑合成 / 打分")
    ap.add_argument("cmd", choices=["side", "grid", "reel", "sheet", "score", "index", "dmd"])
    ap.add_argument("cases", nargs="+")
    ap.add_argument("-o", default=None)
    ap.add_argument("--hl", type=int, default=12)
    ap.add_argument("--crf", type=int, default=16)
    ap.add_argument("--cols", type=int, default=3)
    ap.add_argument("--pairs", action="store_true")
    ap.add_argument("--margin", type=int, default=32)
    ap.add_argument("--gap", type=int, default=16)
    ap.add_argument("--canvas", type=lambda s: tuple(int(x) for x in s.split("x")), default=(1920, 1080))
    ap.add_argument("--title", default=None)
    ap.add_argument("--fade", type=int, default=8)
    ap.add_argument("--procs", type=int, default=24)
    ap.add_argument("--dmd_root", default=None)
    return ap


def main(argv=None) -> None:
    a = build_parser().parse_args(argv)
    dict(side=cmd_side, grid=cmd_grid, reel=cmd_reel, sheet=cmd_sheet, score=cmd_score, index=cmd_index, dmd=cmd_dmd)[a.cmd](a)


if __name__ == "__main__":
    main()