@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +1109 -0
  2. package/README.md +76 -10
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/docs/styles/sheet-8-styles.png +0 -0
  5. package/docs/styles/sheet-before-after.png +0 -0
  6. package/index.js +4 -1
  7. package/package.json +7 -3
  8. package/schemas/film.schema.json +329 -2
  9. package/skills/video-studio/SKILL.md +270 -13
  10. package/skills/video-studio/assets/golden_frames.json +65 -0
  11. package/skills/video-studio/assets/scenes.py +2162 -1190
  12. package/skills/video-studio/assets/web/engine.js +63 -2
  13. package/skills/video-studio/assets/web/film.js +224 -101
  14. package/skills/video-studio/assets/web/index.html +4 -2
  15. package/skills/video-studio/assets/web/qa_text.js +36 -8
  16. package/skills/video-studio/references/audio-variation.md +4 -3
  17. package/skills/video-studio/references/audio.md +2 -2
  18. package/skills/video-studio/references/change-impact.md +1 -1
  19. package/skills/video-studio/references/quality-gates.md +25 -3
  20. package/skills/video-studio/references/scene-grammar.md +306 -0
  21. package/skills/video-studio/references/style-packs.md +108 -3
  22. package/skills/video-studio/references/v08-quality.md +256 -0
  23. package/skills/video-studio/references/web-engine.md +19 -2
  24. package/skills/video-studio/references/word-timeline.md +147 -0
  25. package/skills/video-studio/requirements.txt +14 -0
  26. package/skills/video-studio/scripts/filmcheck.py +218 -0
  27. package/skills/video-studio/scripts/filmkit.py +521 -38
  28. package/skills/video-studio/scripts/gates.py +88 -4
  29. package/skills/video-studio/scripts/grammar.py +559 -0
  30. package/skills/video-studio/scripts/preflight.py +774 -0
  31. package/skills/video-studio/scripts/qa_frames.py +49 -8
  32. package/skills/video-studio/scripts/qaasr.py +1563 -0
  33. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  34. package/skills/video-studio/scripts/render.py +764 -32
  35. package/skills/video-studio/scripts/scorekit.py +16 -30
  36. package/skills/video-studio/scripts/selfcheck.py +483 -0
  37. package/skills/video-studio/scripts/sfxkit.py +130 -14
  38. package/skills/video-studio/scripts/stagewatch.py +19 -6
  39. package/skills/video-studio/scripts/stylekit.py +242 -12
  40. package/skills/video-studio/scripts/voicekit.py +15 -7
  41. package/skills/video-studio/scripts/webengine.py +1119 -72
  42. package/tools/ci_check.py +173 -31
  43. package/tools/estimate.py +130 -19
  44. package/tools/golden_frames.py +664 -0
  45. package/tools/silhouette_qa.py +910 -0
@@ -0,0 +1,910 @@
1
+ # -*- coding: utf-8 -*-
2
+ """silhouette_qa.py — 跨风格剪影可区分门禁(G13,v0.9.0)。
3
+
4
+ 解决什么问题
5
+ ------------
6
+ 用户反馈"每部片看起来都差不多,只是颜色不同"。颜色差异肉眼明显、结构差异却从来没人
7
+ 量过:八套风格共用同一批场景绘制器与版式几何时,换 style 只换了皮肤(配色 / 字体 /
8
+ 纹理 / 字距),灰度三值化后的**构图骨架**(前景占比、重心、边缘占用、左右对称)几乎
9
+ 不动。本工具把"骨架到底有没有变"变成可复核的数字,而不是审美口水。
10
+
11
+ 算法(验收口径;改阈值 / 改指标之前先读这一节)
12
+ ----------------------------------------------
13
+ 对每一对风格 (A, B):
14
+ 1. 用同一份合成剧本(title="剪影校验",固定 3 段内容,每段 dur=4.0s),只改 style;
15
+ 对每个场景类型各渲 3 帧:t_local = 0.05 / 0.5 / 0.9 × dur。
16
+ frame = int(t_global * fps),fps=30;渲染直接调 assets/scenes.py 的 draw_frame,
17
+ **不叠字幕、不经过 ffmpeg** —— 本工具量的是"渲染器画出来的构图",不是成片编码。
18
+ 合成剧本的 scene 同时写 kind / type(旧版 scenes.py 只认 kind,新版认 type),
19
+ 并补上 stats / sides / steps / series / quote 等内容字段:同一段内容在不同版本
20
+ 的绘制器下都有素材可画,才有资格比较"构图是不是同一张骨架"。
21
+ 2. 灰度化(PIL 的 ITU-R 601 亮度)+ 降采样到 480×270(box)—— 低分辨率压掉字体、
22
+ 纹理、抗锯齿这些"皮肤级"差异,留下块状结构。
23
+ 3. 三值化:基准亮度 b = 本帧**四角各 40×40 块灰度中位数**。用四角而不是主题 bg 色,
24
+ 是因为深浅主题的 bg 亮度本来就差一个数量级;四角在本项目里永远是背景(正文与装饰
25
+ 都在安全区内),拿它当"本帧背景亮度"跨深浅主题才是同一把尺子。d = |gray - b|:
26
+ d < 15 -> 0 背景
27
+ 15 <= d < 60 -> 1 中间(纹理、半透明卡片、渐变、抗锯齿边)
28
+ d >= 60 -> 2 前景(文字、实心图形)
29
+ 4. 五项指标(全部在 480×270 上算,百分比相对画面):
30
+ F 前景档(2) 像素占比 × 100
31
+ Cx/Cy 前景像素质心 x / y(0..100,按像素中心口径)
32
+ B 四条边各 6% 带宽内"非背景档(1 或 2)"覆盖率的最大值 × 100
33
+ S 左右半幅前景掩码的 IoU:**右半幅水平翻转后**再与左半幅比。不翻转就不是
34
+ "对称度",而是"左右两半同一相对位置都有前景"——那对居中标题恒为 0,
35
+ 量到的不是对称性,量的是版式靠左还是靠右。
36
+ 5. 判定"该场景可区分":五项里任一项达标即可 ——
37
+ ΔF >= 6pp、|ΔCx| >= 8、|ΔCy| >= 8、ΔB >= 14、ΔS >= 0.20。
38
+ 关键约束:**同一项指标必须在 3 帧上全部达标**。挑单帧最容易骗过门禁:入场瞬间
39
+ 两套风格的位移差本来就大,只看一帧会把"同一骨架 + 不同缓动"误判成结构不同。
40
+ 6. 一对风格在**任一场景类型**上可区分即算 PASS;所有场景都不达标才算 FAIL。
41
+ 一条场景类型不合格、另一条合格,结论仍是"能区分"——用户看的是整片,一种构图
42
+ 不同就足以打破"每部片一个样"。
43
+
44
+ 改造前跑基线为什么仍然会"看起来差不多"
45
+ --------------------------------------
46
+ v0.8 的 scenes.py 只有 hook / body / outro 三个绘制器,且三者共用同一套骨架(背景板 →
47
+ 眉标 → 大字标题 → 副标题 → 强调线 → HUD),差异只在颜色、字号、位移。本工具按
48
+ "hero / list / stat" 这类场景类型渲帧时,旧渲染器认不出这些类型、全部回落到 body ——
49
+ 三类场景画的是同一张骨架,这正是要暴露的问题。
50
+
51
+ 但**不要期待改造前"全红"**:8 套风格本身的版式(left-stack / split-right / center-stack)
52
+ 与 HUD 档位不同,灰度结构上仍会拉开一部分对子;尤其跨明暗族(深底 vs 浅底)会被 ΔB
53
+ 天然抬到很高(B 的基准是四角中位数,浅色主题整屏 bg→bg2 渐变就可能让整条边带"非背景")。
54
+ 所以报告按**明暗族分组**统计:同族通过数才是"骨架真的不同"的保守下界;跨族且只靠 ΔB
55
+ 通过的对会被单独点名,复核时别把它们当成结构差异的证据。这是口径本身的局限,不是
56
+ 实现 bug —— 改判定线要单独评审,不能靠这里偷偷放宽/收紧。
57
+
58
+ 输出与退出码
59
+ ------------
60
+ * 人读表格:风格对 × 场景类型 × 五项差值(按帧列出)+ PASS/FAIL(靠哪项达标)
61
+ * 汇总:N 对风格、通过 M 对,失败对按"最接近阈值"排序(便于优先修)
62
+ * --json 输出机读结构(含每对、每场景、每帧的指标明细),供门禁消费
63
+ * 退出码:全部通过 0;有失败 1;--strict 下存在跳过(skip)时 2。
64
+ 跳过 = 帧渲不出来 / 指标缺值,**skipped != 通过**:它只说明这次没证据,不说明
65
+ 骨架相同;--strict 用于 CI 里把"没跑成"也当红。
66
+
67
+ CLI
68
+ ---
69
+ python tools/silhouette_qa.py [--styles tech-deep,magazine,...]
70
+ [--scenes hero,list,stat]
71
+ [--frames 0.05,0.5,0.9]
72
+ [--json] [--strict] [--out PATH]
73
+ [--scenes-py PATH] [--skill-root DIR]
74
+ --scenes 场景类型(逗号分隔,默认 hero,list,stat)
75
+ --scenes-py 诊断用:换一份 scenes.py 渲染(默认 <skill-root>/assets/scenes.py)
76
+ --skill-root 诊断用:改用含 scripts/ 与 assets/ 的另一份技能快照(如 git archive
77
+ 出来的改造前版本),便于"改造前 vs 改造后"用同一个工具对比;
78
+ JSON 的 inputs 会记录两边的 sha1,避免比错文件
79
+ --out 额外落一份 JSON(缺省只打印,不写文件)
80
+ --strict 有跳过时退出码 2(缺省跳过不影响退出码,但报告里一定单独列出)
81
+
82
+ 依赖:stdlib + numpy + Pillow + 同仓库的 filmkit / stylekit / scenes(项目已有依赖,无新增);
83
+ 不依赖 TTS / ffmpeg / 网络,纯本地渲染。
84
+ """
85
+ from __future__ import annotations
86
+
87
+ __version__ = "0.9.0"
88
+
89
+ import argparse
90
+ import copy
91
+ import hashlib
92
+ import importlib.util
93
+ import itertools
94
+ import json
95
+ import os
96
+ import sys
97
+ import time
98
+ import traceback
99
+
100
+ import numpy as np
101
+ from PIL import Image
102
+
103
+ HERE = os.path.dirname(os.path.abspath(__file__))
104
+ ROOT = os.path.dirname(HERE)
105
+ SKILL = os.path.join(ROOT, "skills", "video-studio")
106
+ SCRIPTS = os.path.join(SKILL, "scripts")
107
+ ASSETS = os.path.join(SKILL, "assets")
108
+ DEFAULT_SCENES_PY = os.path.join(ASSETS, "scenes.py")
109
+
110
+ RESULT_VERSION = 1
111
+ FPS = 30
112
+ SCENE_DUR = 4.0
113
+ DEFAULT_FRACS = (0.05, 0.5, 0.9)
114
+ DEFAULT_SCENE_TYPES = ("hero", "list", "stat")
115
+ SYNTH_TITLE = "剪影校验"
116
+
117
+ # 量化口径(480×270 是本工具定的"结构分辨率":再低会把小字抹掉,再高会开始量字体)
118
+ QUAL_W, QUAL_H = 480, 270
119
+ CORNER = 40
120
+ TIER_BG_MAX = 15 # d < 15 -> 背景档 0
121
+ TIER_FG_MIN = 60 # d >= 60 -> 前景档 2
122
+ EDGE_BAND = 0.06 # 四边各 6% 带宽
123
+ METRIC_ORDER = ("F", "Cx", "Cy", "B", "S")
124
+ # 达标线:设计稿里逐对给的是目标值,这里只放"判定线"。改这里等于改验收口径,需说明理由。
125
+ THRESHOLDS = {"F": 6.0, "Cx": 8.0, "Cy": 8.0, "B": 14.0, "S": 0.20}
126
+ METRIC_LABELS = {
127
+ "F": "前景像素占比(%)", "Cx": "前景质心x(占宽%)", "Cy": "前景质心y(占高%)",
128
+ "B": "四边非背景覆盖率(%)", "S": "左右半幅前景 IoU",
129
+ }
130
+
131
+ # ============================================================================
132
+ # 合成剧本:同一份内容,只有 style 不同
133
+ # ============================================================================
134
+ # 每类场景给足该类型的"原料字段":hero 要 big/sub/kicker,list 要 items,stat 要
135
+ # stats 或"数字+单位",其余类型按 grammar.py 的契约补齐。给全的理由:本工具要能在
136
+ # 改造前的 3 绘制器、改造后的 10 绘制器上都跑——旧版忽略多余字段,新版缺字段就画空。
137
+ # 文案固定不随 style 变化,否则"构图差"里会混进"内容差"。
138
+ SCENE_CONTENT = {
139
+ "hero": dict(
140
+ kicker="00 · 开场", chapter="01 · 开场",
141
+ big="结构决定第一印象",
142
+ sub="同一份稿子,八种构图骨架",
143
+ text="结构决定第一印象。同一份稿子,八种构图骨架。",
144
+ items=[]),
145
+ "statement": dict(
146
+ kicker="01 · 金句", chapter="01 · 金句",
147
+ big="骨架不同,观感才不同",
148
+ sub="颜色只是皮肤",
149
+ text="骨架不同,观感才不同。颜色只是皮肤。",
150
+ items=[]),
151
+ "list": dict(
152
+ kicker="02 · 要点", chapter="02 · 要点",
153
+ big="三条结构判据",
154
+ sub="只比灰度三值化后的骨架",
155
+ text="三条结构判据:前景占比、质心位移、边缘覆盖与对称度。",
156
+ items=[{"text": "前景占比 F", "ok": True},
157
+ {"text": "质心位移 Cx / Cy", "ok": True},
158
+ {"text": "边缘覆盖 B 与对称度 S", "ok": True}]),
159
+ "stat": dict(
160
+ kicker="03 · 数据", chapter="03 · 数据",
161
+ big="27%",
162
+ sub="同一份稿子的构图差异",
163
+ text="同一份稿子的构图差异 27%",
164
+ items=[],
165
+ stats=[{"value": 27, "unit": "%", "label": "构图差异", "note": "同一稿实测"}]),
166
+ "compare": dict(
167
+ kicker="04 · 对比", chapter="04 · 对比",
168
+ big="改造前后",
169
+ sub="同一段话,两种骨架",
170
+ text="改造前后:同一段话,两种骨架。",
171
+ items=[],
172
+ sides={"left": {"head": "改造前",
173
+ "items": ["三段共用 body", "结构几乎不变", "只能靠换色区分"]},
174
+ "right": {"head": "改造后",
175
+ "items": ["十类场景语法", "每类独立骨架", "结构指标可判定"]}}),
176
+ "process": dict(
177
+ kicker="05 · 流程", chapter="05 · 流程",
178
+ big="四步出片",
179
+ sub="从稿件到成片",
180
+ text="四步出片:定稿、分镜、渲染、质检。",
181
+ items=[],
182
+ steps=[{"label": "定稿"}, {"label": "分镜"}, {"label": "渲染"}, {"label": "质检"}]),
183
+ "quote": dict(
184
+ kicker="06 · 引语", chapter="06 · 引语",
185
+ big="颜色只是皮肤",
186
+ sub="结构才是骨架",
187
+ text="「颜色只是皮肤,结构才是骨架」——剪影校验",
188
+ items=[],
189
+ quote="颜色只是皮肤,结构才是骨架",
190
+ by="剪影校验"),
191
+ "chapter": dict(
192
+ kicker="02", chapter="02 · 结构",
193
+ big="第二章 结构",
194
+ sub="",
195
+ text="第二章 结构",
196
+ items=[]),
197
+ "chart": dict(
198
+ kicker="07 · 数据条", chapter="07 · 数据条",
199
+ big="三项结构指标",
200
+ sub="同一份稿子的实测差值",
201
+ text="三项结构指标:前景占比 42,边缘覆盖 31,对称度 0.18。",
202
+ items=[],
203
+ series=[{"label": "前景占比", "value": 42, "unit": "%"},
204
+ {"label": "边缘覆盖", "value": 31, "unit": "%"},
205
+ {"label": "对称度", "value": 0.18, "unit": ""}]),
206
+ "outro": dict(
207
+ kicker="99 · 收尾", chapter="99 · 收尾",
208
+ big="完",
209
+ sub="剪影校验",
210
+ text="完。剪影校验。",
211
+ items=[]),
212
+ }
213
+ SUPPORTED_SCENE_TYPES = tuple(SCENE_CONTENT)
214
+
215
+
216
+ # ---------------------------------------------------------------- 基础工具
217
+ def _setup_stdio():
218
+ """输出被重定向时切 UTF-8(Windows 控制台默认 GBK,中文会乱码)。与 render.py 同口径。"""
219
+ for stream in (sys.stdout, sys.stderr):
220
+ try:
221
+ if stream is not None and not stream.isatty():
222
+ stream.reconfigure(encoding="utf-8", errors="replace")
223
+ except Exception: # noqa: BLE001
224
+ pass
225
+
226
+
227
+ def _sha1_file(path):
228
+ """文件内容 sha1(读不了返回 "missing",不抛异常)。"""
229
+ try:
230
+ h = hashlib.sha1()
231
+ with open(str(path), "rb") as fh:
232
+ for blk in iter(lambda: fh.read(1 << 20), b""):
233
+ h.update(blk)
234
+ return h.hexdigest()
235
+ except OSError:
236
+ return "missing"
237
+
238
+
239
+ def _rel(path):
240
+ """尽量把路径写成相对仓库根的 posix 形式(JSON 要跨机器可读)。"""
241
+ try:
242
+ rel = os.path.relpath(os.path.abspath(str(path)), ROOT)
243
+ except ValueError: # 跨盘符
244
+ return os.path.basename(str(path))
245
+ return rel.replace(os.sep, "/")
246
+
247
+
248
+ def _short_err(exc):
249
+ """异常 -> 一行可读原因(traceback 只留末尾几行,别糊满报告)。"""
250
+ return "%s: %s" % (type(exc).__name__, exc)
251
+
252
+
253
+ # ---------------------------------------------------------------- 依赖装载
254
+ def _modules(scripts_dir=SCRIPTS):
255
+ """装载 filmkit / stylekit。tools/ 与 scripts/ 不同目录,先补 sys.path(同 golden_frames)。
256
+
257
+ 支持换一个 scripts 目录(--skill-root 诊断用):先清掉 sys.modules 里的同名条目,
258
+ 否则第二次调用拿到的还是上一份模块,"改造前 vs 改造后"会串档。
259
+ """
260
+ scripts_dir = os.path.abspath(str(scripts_dir))
261
+ if scripts_dir not in sys.path:
262
+ sys.path.insert(0, scripts_dir)
263
+ for name in ("filmkit", "stylekit"):
264
+ if sys.modules.get(name) is not None:
265
+ path = str(getattr(sys.modules[name], "__file__", "") or "")
266
+ if os.path.dirname(os.path.abspath(path)) != scripts_dir:
267
+ del sys.modules[name]
268
+ try:
269
+ import filmkit as K
270
+ import stylekit as ST
271
+ return K, ST
272
+ except Exception as e: # noqa: BLE001
273
+ raise RuntimeError("装载 filmkit/stylekit 失败(%s):%s" % (scripts_dir, e)) from e
274
+
275
+
276
+ def _load_scenes(path):
277
+ """按路径装载 scenes.py(独立模块名,避免与站点包里的同名模块串档)。"""
278
+ path = os.path.abspath(str(path))
279
+ if not os.path.isfile(path):
280
+ raise RuntimeError("scenes.py 不存在:%s" % path)
281
+ spec = importlib.util.spec_from_file_location("silhouette_scenes", path)
282
+ if spec is None or spec.loader is None:
283
+ raise RuntimeError("scenes.py 无法作为模块加载:%s" % path)
284
+ mod = importlib.util.module_from_spec(spec)
285
+ sys.modules["silhouette_scenes"] = mod
286
+ spec.loader.exec_module(mod)
287
+ if not callable(getattr(mod, "draw_frame", None)):
288
+ raise RuntimeError("scenes.py 缺少 draw_frame(scene, t_local, dur, t_global, "
289
+ "script, frame) 契约:%s" % path)
290
+ return mod
291
+
292
+
293
+ # ---------------------------------------------------------------- 合成剧本 / 场景
294
+ def build_script(style_id, scene_types):
295
+ """造一份"只有 style 不同"的合成剧本(title/lines 固定,dur 固定 4.0s)。"""
296
+ lines = []
297
+ for i, stype in enumerate(scene_types):
298
+ c = SCENE_CONTENT[stype]
299
+ row = dict(c)
300
+ row.update(id="qa-%02d-%s" % (i + 1, stype), kind=stype, type=stype,
301
+ dur=SCENE_DUR)
302
+ lines.append(row)
303
+ script = dict(title=SYNTH_TITLE, style=str(style_id), fps=FPS, lines=lines,
304
+ _total=round(SCENE_DUR * len(scene_types), 3))
305
+ return script
306
+
307
+
308
+ def build_scenes(scene_types):
309
+ """造 scene dict:start/end 连续排,三条内容共用同一段长。
310
+
311
+ kind 与 type 都写同一个场景类型:改造前的 scenes.py 只认 kind(认不出就回落
312
+ body —— 这正是基线大量 FAIL 的机制),改造后的 scenes.py 认 type / scene_type。
313
+ 额外写 scene_type 是因为 grammar.plan_scenes 落盘的字段名就叫 scene_type,
314
+ 新版绘制器可能直接读它;多一个别名不改变任何绘制结果,只增加版本兼容面。
315
+ """
316
+ rows = []
317
+ for i, stype in enumerate(scene_types):
318
+ content = copy.deepcopy(SCENE_CONTENT[stype])
319
+ start = round(i * SCENE_DUR, 6)
320
+ row = dict(content)
321
+ row.setdefault("text", "")
322
+ row.setdefault("big", "")
323
+ row.setdefault("items", [])
324
+ row.update(id="qa-%02d-%s" % (i + 1, stype), kind=stype, type=stype,
325
+ scene_type=stype, start=start, end=round(start + SCENE_DUR, 6),
326
+ dur=SCENE_DUR)
327
+ rows.append(row)
328
+ return rows
329
+
330
+
331
+ # ---------------------------------------------------------------- 渲染
332
+ def render_frame(scenes_mod, script, scene, frac, fps=FPS):
333
+ """渲染一帧并返回 (img, frame)。frame 与真实管线同口径 = int(t_global * fps)。
334
+
335
+ 加 1e-6 是为了吸收浮点误差:0.9*4.0 + 8.0 在二进制里是 11.599999…,×30 后
336
+ int() 会少一帧(347 而不是 348)。t_global*t 的理论值才是帧号口径,epsilon
337
+ 不改变 int 语义,只避免"同一时刻因浮点顺序不同差一帧"。
338
+ """
339
+ t_local = float(frac) * float(scene["dur"])
340
+ t_global = float(scene["start"]) + t_local
341
+ frame = int(t_global * float(fps) + 1e-6)
342
+ img = scenes_mod.draw_frame(scene, t_local, float(scene["dur"]), t_global,
343
+ script, frame)
344
+ if img is None:
345
+ raise RuntimeError("draw_frame 返回 None(scene=%s, t=%.3fs)"
346
+ % (scene.get("id"), t_global))
347
+ if not isinstance(img, Image.Image):
348
+ raise RuntimeError("draw_frame 返回 %r,不是 PIL.Image(scene=%s)"
349
+ % (type(img).__name__, scene.get("id")))
350
+ return img.convert("RGB"), frame
351
+
352
+
353
+ # ---------------------------------------------------------------- 量化 / 指标
354
+ def quantize(img):
355
+ """灰度 + 降采样 + 三值化。返回 (gray, base, tier)。
356
+
357
+ base 取四角 40×40 的中位数:本项目正文/装饰都在安全区内,四角一定是背景;而
358
+ 主题 bg 色在不同明暗族之间差一个数量级,直接拿"主题色"当基准会让深色与浅色
359
+ 风格的 |gray-b| 落在不同的分档分布里——那不是构图差,是配色差被误算成结构差。
360
+ """
361
+ gray = np.asarray(img.convert("L").resize((QUAL_W, QUAL_H), Image.BOX),
362
+ dtype=np.float32)
363
+ corners = np.concatenate([
364
+ gray[:CORNER, :CORNER].ravel(),
365
+ gray[:CORNER, -CORNER:].ravel(),
366
+ gray[-CORNER:, :CORNER].ravel(),
367
+ gray[-CORNER:, -CORNER:].ravel(),
368
+ ])
369
+ base = float(np.median(corners))
370
+ d = np.abs(gray - base)
371
+ tier = np.zeros((QUAL_H, QUAL_W), dtype=np.uint8)
372
+ tier[d >= TIER_BG_MAX] = 1
373
+ tier[d >= TIER_FG_MIN] = 2
374
+ return gray, base, tier
375
+
376
+
377
+ def measure(img):
378
+ """一张图 -> (五项指标, 基准亮度)。数值全部是 Python float(JSON 可序列化)。
379
+
380
+ 基准亮度一并返回是为了**诊断**:它定义了"本帧背景有多亮",据此可以把风格分成
381
+ 明暗族。量到的 B 在深浅主题之间天然不可比(浅色主题整屏 bg→bg2 的渐变就可能超过
382
+ 15 的中间档门槛,整条边带都算"非背景"),所以报告要按明暗族分组看,别让"跨族
383
+ 背景亮度差"冒充"构图骨架差"。判定口径本身不因此改变。
384
+ """
385
+ _gray, base, tier = quantize(img)
386
+ fg = tier == 2
387
+ total = int(fg.size)
388
+ F = float(fg.sum()) * 100.0 / float(total)
389
+ ys, xs = np.nonzero(fg)
390
+ if xs.size:
391
+ # 像素中心口径:第 x 列的中心在 x+0.5,再折算成 0..100
392
+ Cx = float(xs.mean() + 0.5) * 100.0 / float(QUAL_W)
393
+ Cy = float(ys.mean() + 0.5) * 100.0 / float(QUAL_H)
394
+ else:
395
+ # 整帧没有前景就没有质心;这里返回 None(比较阶段按"缺值"处理),
396
+ # 不拿 0 顶替——0 会被读成"重心在左上角",把一个空帧说成极端构图。
397
+ Cx = Cy = None
398
+ nonbg = tier != 0
399
+ band_x = max(1, int(round(QUAL_W * EDGE_BAND)))
400
+ band_y = max(1, int(round(QUAL_H * EDGE_BAND)))
401
+ # B = 四边覆盖率的**最大值**:只要有一条边被构图压住,两套风格的"边缘语言"就不同。
402
+ # 取最大值而不是平均,是为了不被另外三条空边稀释(大多数版式只有一侧有内容)。
403
+ cover = (float(nonbg[:band_y, :].mean()), float(nonbg[-band_y:, :].mean()),
404
+ float(nonbg[:, :band_x].mean()), float(nonbg[:, -band_x:].mean()))
405
+ B = max(cover) * 100.0
406
+ half = QUAL_W // 2
407
+ left = fg[:, :half]
408
+ right = fg[:, QUAL_W - half:][:, ::-1] # 右半幅翻转:量的是对称性
409
+ union = int(np.logical_or(left, right).sum())
410
+ inter = int(np.logical_and(left, right).sum())
411
+ # 两半都空 = 完美对称(空构图自己跟自己的 IoU 是 1);只有一边空才是 0。
412
+ S = 1.0 if union == 0 else float(inter) / float(union)
413
+ metrics = {"F": round(F, 3), "Cx": None if Cx is None else round(Cx, 3),
414
+ "Cy": None if Cy is None else round(Cy, 3),
415
+ "B": round(B, 3), "S": round(S, 4)}
416
+ return metrics, base
417
+
418
+
419
+ # ---------------------------------------------------------------- 逐风格渲染
420
+ def render_style(scenes_mod, ST, style_id, scene_types, fracs, fps=FPS):
421
+ """渲一套风格的全部帧。逐帧 try/except:一帧炸了记 skip,不让整个门禁崩。
422
+
423
+ 返回 {"meta": {...}, "scenes": {scene_type: [frame_record, ...]}}。
424
+ """
425
+ script = build_script(style_id, scene_types)
426
+ plan = ST.resolve_script_style(script)
427
+ # 先解好风格计划塞进 _style:scenes._plan() 优先读它,避免每帧重复解析;
428
+ # 也保证同一风格 9 帧用的是同一份计划(含 theme_overrides)。
429
+ script["_style"] = plan
430
+ out = {"meta": dict(style=str(style_id),
431
+ style_name=plan.get("style_name"),
432
+ theme_id=plan.get("theme_id"),
433
+ layout_default=plan.get("layout_default"),
434
+ legacy=bool(plan.get("legacy"))),
435
+ "scenes": {}}
436
+ for scene in build_scenes(scene_types):
437
+ stype = str(scene.get("type"))
438
+ recs = []
439
+ for frac in fracs:
440
+ rec = {"frac": round(float(frac), 4)}
441
+ try:
442
+ img, frame = render_frame(scenes_mod, script, scene, frac, fps)
443
+ rec["t_local"] = round(float(frac) * SCENE_DUR, 4)
444
+ rec["t_global"] = round(float(scene["start"]) + float(frac) * SCENE_DUR, 4)
445
+ rec["frame"] = int(frame)
446
+ rec["metrics"], base = measure(img)
447
+ rec["base"] = round(float(base), 1)
448
+ rec["error"] = None
449
+ except Exception as e: # noqa: BLE001 —— 单帧失败降级为 skip
450
+ rec["metrics"] = None
451
+ rec["error"] = _short_err(e)
452
+ rec["traceback"] = traceback.format_exc()[-800:]
453
+ recs.append(rec)
454
+ out["scenes"][stype] = recs
455
+ # 明暗族:一帧四角基准亮度的中位数。只作诊断分组,不参与判定。
456
+ # 阈值 128 是灰阶中点:本项目 8 套主题明显分成"深底亮字"与"浅底暗字"两族。
457
+ bases = [rec["base"] for rows in out["scenes"].values() for rec in rows
458
+ if rec.get("base") is not None]
459
+ meta = out["meta"]
460
+ meta["base_median"] = round(float(np.median(bases)), 1) if bases else None
461
+ meta["family"] = ("unknown" if not bases else
462
+ ("light" if meta["base_median"] >= 128 else "dark"))
463
+ return out
464
+
465
+
466
+ # ---------------------------------------------------------------- 比较 / 判定
467
+ def _metric_of(rec, key):
468
+ """取一帧的某项指标;渲染失败或质心缺值 -> None。"""
469
+ md = rec.get("metrics")
470
+ if not isinstance(md, dict):
471
+ return None
472
+ v = md.get(key)
473
+ return None if v is None else float(v)
474
+
475
+
476
+ def compare_pair(render, style_a, style_b, scene_types):
477
+ """比较一对风格。返回可 JSON 化的 pair 结果(含每场景 × 每指标 × 每帧的差值)。
478
+
479
+ 判定核心(与模块文档同口径):
480
+ * 场景可区分 = 存在一项指标,其**全部帧**的 |Δ| 都 >= 阈值;
481
+ * 风格对通过 = 任一场景可区分。
482
+ """
483
+ pair = {"a": style_a, "b": style_b, "status": "fail",
484
+ "passed": [], "closest": None, "scenes": []}
485
+ best = None # (ratio, scene, metric, min_delta),给失败对排序用
486
+ for stype in scene_types:
487
+ rows_a = render[style_a]["scenes"].get(stype) or []
488
+ rows_b = render[style_b]["scenes"].get(stype) or []
489
+ scene_row = {"scene": stype, "status": "fail",
490
+ "passed_metrics": [], "metrics": {}, "skip_reason": ""}
491
+ n = min(len(rows_a), len(rows_b))
492
+ errors = []
493
+ for m in METRIC_ORDER:
494
+ deltas, missing = [], False
495
+ for i in range(n):
496
+ va = _metric_of(rows_a[i], m)
497
+ vb = _metric_of(rows_b[i], m)
498
+ if va is None or vb is None:
499
+ deltas.append(None)
500
+ missing = True
501
+ else:
502
+ deltas.append(round(abs(va - vb), 4))
503
+ all_frames = (n > 0) and not missing
504
+ min_delta = min(deltas) if all_frames else None
505
+ passed = bool(all_frames and min_delta + 1e-9 >= THRESHOLDS[m])
506
+ scene_row["metrics"][m] = {"deltas": deltas, "min": min_delta,
507
+ "need": THRESHOLDS[m], "passed": passed}
508
+ if passed:
509
+ scene_row["passed_metrics"].append(m)
510
+ elif min_delta is not None:
511
+ ratio = min_delta / THRESHOLDS[m] if THRESHOLDS[m] else 0.0
512
+ # 失败对里"最接近通过"的指标:完成度 = 3 帧里最差那帧 / 阈值
513
+ if best is None or ratio > best[0]:
514
+ best = (round(ratio, 4), stype, m, min_delta)
515
+ for side, rows in (("A", rows_a), ("B", rows_b)):
516
+ for rec in rows[:n]:
517
+ if rec.get("error"):
518
+ errors.append("%s@t=%s: %s" % (side, rec.get("frac"),
519
+ rec.get("error")))
520
+ if scene_row["passed_metrics"]:
521
+ scene_row["status"] = "pass"
522
+ elif errors:
523
+ # 有帧渲不出来 / 指标缺值 -> 这一场景没有证据,记 skip 而不是 fail:
524
+ # "没量到"不能当成"结构相同",也不能冤枉改动。
525
+ scene_row["status"] = "skip"
526
+ scene_row["skip_reason"] = ";".join(sorted(set(errors))[:3])
527
+ pair["scenes"].append(scene_row)
528
+ passed_rows = [r for r in pair["scenes"] if r["status"] == "pass"]
529
+ if passed_rows:
530
+ pair["status"] = "pass"
531
+ pair["passed"] = [{"scene": r["scene"], "metrics": list(r["passed_metrics"])}
532
+ for r in passed_rows]
533
+ elif all(r["status"] == "skip" for r in pair["scenes"]):
534
+ pair["status"] = "skip"
535
+ if pair["status"] == "fail" and best is not None:
536
+ pair["closest"] = {"scene": best[1], "metric": best[2],
537
+ "delta": best[3], "need": THRESHOLDS[best[2]],
538
+ "ratio": best[0]}
539
+ return pair
540
+
541
+
542
+ def run_qa(scenes_py, styles, scene_types, fracs, fps=FPS, scripts_dir=SCRIPTS):
543
+ """主流程:装载 -> 逐风格渲帧 -> 两两比较 -> 汇总。返回 (result, elapsed_s)。"""
544
+ K, ST = _modules(scripts_dir)
545
+ scenes_mod = _load_scenes(scenes_py)
546
+ t0 = time.perf_counter()
547
+ render = {}
548
+ for sid in styles:
549
+ render[sid] = render_style(scenes_mod, ST, sid, scene_types, fracs, fps)
550
+ pairs = [compare_pair(render, a, b, scene_types)
551
+ for a, b in itertools.combinations(styles, 2)]
552
+ elapsed = time.perf_counter() - t0
553
+ styles_meta = [render[sid]["meta"] for sid in styles]
554
+ summary = summarize(pairs, styles_meta)
555
+ render_errors = []
556
+ for sid in styles:
557
+ for stype in scene_types:
558
+ for rec in render[sid]["scenes"].get(stype) or []:
559
+ if rec.get("error"):
560
+ render_errors.append({"style": sid, "scene": stype,
561
+ "frac": rec.get("frac"),
562
+ "error": rec.get("error")})
563
+ result = {
564
+ "version": RESULT_VERSION,
565
+ "generator": "silhouette_qa.py %s" % __version__,
566
+ "comment": "G13 跨风格剪影可区分:同一份合成剧本只换 style,灰度三值化后比"
567
+ "F/Cx/Cy/B/S;同一场景 3 帧须同一项指标全部达标,任一场景达标即通过。"
568
+ "颜色差异不参与判定。",
569
+ "params": {
570
+ "styles": list(styles), "scenes": list(scene_types),
571
+ "frames": [round(float(x), 4) for x in fracs],
572
+ "fps": int(fps), "scene_dur": SCENE_DUR,
573
+ "synth_title": SYNTH_TITLE,
574
+ "quant": {"size": [QUAL_W, QUAL_H], "corner": CORNER,
575
+ "tier_bg_max": TIER_BG_MAX, "tier_fg_min": TIER_FG_MIN,
576
+ "edge_band": EDGE_BAND},
577
+ "thresholds": dict(THRESHOLDS),
578
+ },
579
+ "inputs": {
580
+ "skill_root": _rel(os.path.dirname(os.path.abspath(scripts_dir))),
581
+ "scripts_dir": _rel(scripts_dir),
582
+ "scenes_py": _rel(scenes_py),
583
+ "scenes_sha1": _sha1_file(scenes_py),
584
+ "stylekit_sha1": _sha1_file(os.path.join(scripts_dir, "stylekit.py")),
585
+ "filmkit_sha1": _sha1_file(os.path.join(scripts_dir, "filmkit.py")),
586
+ "tool_sha1": _sha1_file(os.path.abspath(__file__)),
587
+ },
588
+ "styles": styles_meta,
589
+ "render": {sid: render[sid]["scenes"] for sid in styles},
590
+ "pairs": pairs,
591
+ "summary": summary,
592
+ "render_errors": render_errors,
593
+ }
594
+ return result, elapsed
595
+
596
+
597
+ def summarize(pairs, styles_meta):
598
+ """汇总:通过 / 失败 / 跳过各几对,失败对按"最接近阈值"降序排。
599
+
600
+ 额外按**明暗族**分组统计。理由:B 的基准是四角中位数,浅色主题(bg 240 左右、
601
+ bg2 220 左右)整屏渐变就可能超过 15 的中间档门槛,整条边带都被判"非背景",
602
+ 于是浅 vs 深的 ΔB 往往只是背景亮度差,不是构图差。分组统计能把这种"跨族虚高"
603
+ 与"同族内真的画得不一样"分开——同族通过数才是骨架差异的保守下界。
604
+ """
605
+ passed = [{"a": p["a"], "b": p["b"], "passed": p["passed"]}
606
+ for p in pairs if p["status"] == "pass"]
607
+ failed = [p for p in pairs if p["status"] == "fail"]
608
+ skipped = [{"a": p["a"], "b": p["b"]}
609
+ for p in pairs if p["status"] == "skip"]
610
+ fam = {m.get("style"): m.get("family") for m in styles_meta}
611
+ by_family = {}
612
+ for label, same in (("same", True), ("cross", False)):
613
+ rows = [p for p in pairs
614
+ if (fam.get(p["a"]) == fam.get(p["b"])) == same]
615
+ by_family[label] = {"pairs": len(rows),
616
+ "pass": sum(1 for p in rows if p["status"] == "pass"),
617
+ "fail": sum(1 for p in rows if p["status"] == "fail"),
618
+ "skip": sum(1 for p in rows if p["status"] == "skip")}
619
+ passed_by_metric = {m: 0 for m in METRIC_ORDER}
620
+ for p in pairs:
621
+ if p["status"] != "pass":
622
+ continue
623
+ ms = set()
624
+ for row in p["passed"]:
625
+ ms.update(row["metrics"])
626
+ for m in ms:
627
+ passed_by_metric[m] += 1
628
+ # 跨族且只靠 ΔB 通过的对:B 由明暗族差贡献的可能性最大,报告里单独点名。
629
+ cross_b_only = [{"a": p["a"], "b": p["b"]} for p in pairs
630
+ if p["status"] == "pass"
631
+ and fam.get(p["a"]) != fam.get(p["b"])
632
+ and {m for row in p["passed"] for m in row["metrics"]} == {"B"}]
633
+ failed_sorted = sorted(
634
+ failed,
635
+ key=lambda p: (-(p["closest"]["ratio"] if p.get("closest") else -1.0),
636
+ p["a"], p["b"]))
637
+ return {
638
+ "pairs": len(pairs),
639
+ "pass": len(passed),
640
+ "fail": len(failed),
641
+ "skip": len(skipped),
642
+ "passed": passed,
643
+ "failed": [{"a": p["a"], "b": p["b"], "closest": p.get("closest")}
644
+ for p in failed_sorted],
645
+ "skipped": skipped,
646
+ "by_family": by_family,
647
+ "passed_by_metric": passed_by_metric,
648
+ "cross_family_b_only": cross_b_only,
649
+ "all_pass": len(failed) == 0 and len(skipped) == 0,
650
+ }
651
+
652
+
653
+ # ---------------------------------------------------------------- 人读报告
654
+ def _fmt_metric(key, value):
655
+ if value is None:
656
+ return "-"
657
+ return ("%.3f" % value) if key == "S" else ("%.1f" % value)
658
+
659
+
660
+ def _fmt_deltas(metric_data, key):
661
+ d = metric_data.get("deltas") or []
662
+ if not d or any(v is None for v in d):
663
+ return "-".ljust(8 if key != "S" else 10)
664
+ return "/".join(_fmt_metric(key, v) for v in d)
665
+
666
+
667
+ def _scene_line(row):
668
+ mark = ""
669
+ if row["status"] == "pass":
670
+ mark = "PASS(%s)" % "+".join(row["passed_metrics"])
671
+ elif row["status"] == "skip":
672
+ mark = "SKIP"
673
+ else:
674
+ mark = "FAIL"
675
+ parts = []
676
+ for k in METRIC_ORDER:
677
+ md = row["metrics"][k]
678
+ star = "*" if md["passed"] else " "
679
+ parts.append("Δ%s %s%s" % (k, _fmt_deltas(md, k), star))
680
+ return " %-8s %s %s" % (row["scene"], " | ".join(parts), mark)
681
+
682
+
683
+ def print_human(res):
684
+ """打印人读报告:每对的每类场景一行,失败对按接近程度排序。"""
685
+ p = res["params"]
686
+ th = p["thresholds"]
687
+ print("== silhouette_qa(G13 跨风格剪影可区分)")
688
+ print("画布量化 %d×%d(四角 %d×%d 取基准),帧时刻 %s × %.1fs,fps=%d"
689
+ % (p["quant"]["size"][0], p["quant"]["size"][1], p["quant"]["corner"],
690
+ p["quant"]["corner"], "/".join("%.2f" % f for f in p["frames"]),
691
+ p["scene_dur"], p["fps"]))
692
+ print("风格 %d 套:%s" % (len(p["styles"]), ", ".join(p["styles"])))
693
+ fam = {}
694
+ for m in res.get("styles") or []:
695
+ fam.setdefault(str(m.get("family")), []).append(str(m.get("style")))
696
+ print("明暗族(四角基准亮度中位数):%s"
697
+ % ";".join("%s=[%s]" % (k, ", ".join(v)) for k, v in sorted(fam.items())))
698
+ print("场景 %d 类:%s;合成剧本 title=%r"
699
+ % (len(p["scenes"]), ", ".join(p["scenes"]), p["synth_title"]))
700
+ print("达标线:ΔF>=%.1fpp |ΔCx|>=%.1f |ΔCy|>=%.1f ΔB>=%.1f ΔS>=%.2f"
701
+ "(同一场景各帧须同一项全部达标,任一场景达标即 PASS)"
702
+ % (th["F"], th["Cx"], th["Cy"], th["B"], th["S"]))
703
+ print("指标:%s" % ";".join("%s=%s" % (k, METRIC_LABELS[k]) for k in METRIC_ORDER))
704
+ print("法:右半幅翻转后与左半幅算 IoU;Δ 列 = 各帧 |差值|;* = 该项达标")
705
+ print("")
706
+ total = len(res["pairs"])
707
+ b_only = {(x["a"], x["b"]) for x in
708
+ (res["summary"].get("cross_family_b_only") or [])}
709
+ for i, pair in enumerate(res["pairs"], 1):
710
+ if pair["status"] == "pass":
711
+ verdict = "PASS"
712
+ detail = ";".join("%s·%s" % (r["scene"], "+".join(r["metrics"]))
713
+ for r in pair["passed"])
714
+ warn = "[跨族仅 ΔB,慎判]" if (pair["a"], pair["b"]) in b_only else ""
715
+ head = "[%2d/%d] %-22s %-4s(%s)%s" % (
716
+ i, total, "%s vs %s" % (pair["a"], pair["b"]),
717
+ verdict, detail, warn)
718
+ elif pair["status"] == "skip":
719
+ head = "[%2d/%d] %-22s SKIP(没有可比的帧)" % (i, total, "%s vs %s" % (pair["a"], pair["b"]))
720
+ else:
721
+ c = pair.get("closest") or {}
722
+ head = "[%2d/%d] %-22s FAIL(最接近 %s:Δ%s=%.3f/%.3f,完成度 %.0f%%)" % (
723
+ i, total, "%s vs %s" % (pair["a"], pair["b"]),
724
+ c.get("scene", "?"), c.get("metric", "?"),
725
+ c.get("delta", float("nan")), c.get("need", float("nan")),
726
+ 100.0 * c.get("ratio", 0.0))
727
+ print(head)
728
+ for row in pair["scenes"]:
729
+ print(_scene_line(row))
730
+ if row.get("skip_reason"):
731
+ print(" ↳ 跳过原因:%s" % row["skip_reason"])
732
+ print("")
733
+ s = res["summary"]
734
+ print("== 汇总")
735
+ print("风格对 %d:通过 %d,失败 %d,跳过 %d%s"
736
+ % (s["pairs"], s["pass"], s["fail"], s["skip"],
737
+ "(全部通过)" if s["all_pass"] else ""))
738
+ bf = s.get("by_family") or {}
739
+ if bf:
740
+ print(" 同明暗族:%d 对(通过 %d / 失败 %d)——骨架差异的保守下界"
741
+ % (bf["same"]["pairs"], bf["same"]["pass"], bf["same"]["fail"]))
742
+ print(" 跨明暗族:%d 对(通过 %d / 失败 %d)"
743
+ % (bf["cross"]["pairs"], bf["cross"]["pass"], bf["cross"]["fail"]))
744
+ pbm = s.get("passed_by_metric") or {}
745
+ if pbm:
746
+ print(" 达标指标分布(一对可多项):%s"
747
+ % ",".join("%s %d 对" % (k, pbm[k]) for k in METRIC_ORDER if pbm.get(k)))
748
+ if s.get("cross_family_b_only"):
749
+ print(" [口径提示]跨族且仅靠 ΔB 通过:%s"
750
+ % ",".join("%s vs %s" % (x["a"], x["b"])
751
+ for x in s["cross_family_b_only"]))
752
+ print(" B 的基准是四角中位数:浅色主题整屏 bg→bg2 渐变本身就可能让整条边带"
753
+ "“非背景”,跨族 ΔB 常是背景亮度差而非构图差;判定照口径保留,复核时看同族分组。")
754
+ if s["passed"]:
755
+ print("通过的对:")
756
+ for row in s["passed"]:
757
+ print(" %-12s vs %-12s %s"
758
+ % (row["a"], row["b"],
759
+ ";".join("%s·%s" % (x["scene"], "+".join(x["metrics"]))
760
+ for x in row["passed"])))
761
+ if s["failed"]:
762
+ print("失败的对(按最接近阈值排序,越靠前越接近通过):")
763
+ for i, row in enumerate(s["failed"], 1):
764
+ c = row.get("closest") or {}
765
+ if c:
766
+ print(" %2d. %-12s vs %-12s 最接近 %s·Δ%s=%.3f(阈值 %.3f,完成度 %.0f%%)"
767
+ % (i, row["a"], row["b"], c.get("scene"), c.get("metric"),
768
+ c.get("delta", 0.0), c.get("need", 0.0),
769
+ 100.0 * c.get("ratio", 0.0)))
770
+ else:
771
+ print(" %2d. %-12s vs %-12s 无可比指标" % (i, row["a"], row["b"]))
772
+ if s["skipped"]:
773
+ print("跳过的对(skipped != 通过):")
774
+ for row in s["skipped"]:
775
+ print(" %s vs %s" % (row["a"], row["b"]))
776
+ if res.get("render_errors"):
777
+ print("渲染失败帧:")
778
+ for e in res["render_errors"]:
779
+ print(" %s/%s/t=%s: %s" % (e["style"], e["scene"], e["frac"], e["error"]))
780
+
781
+
782
+ # ---------------------------------------------------------------- CLI
783
+ def _parse_styles(ST, raw):
784
+ """--styles:缺省 = stylekit.STYLE_IDS 全部 8 套(不含 legacy)。"""
785
+ if not str(raw or "").strip():
786
+ return list(ST.STYLE_IDS)
787
+ ids = []
788
+ for part in str(raw).replace(" ", "").split(","):
789
+ if not part:
790
+ continue
791
+ if part in ids:
792
+ raise SystemExit("--styles 里有重复风格 %r" % part)
793
+ if part not in getattr(ST, "STYLES", {}):
794
+ raise SystemExit("未知风格 %r;可用:%s" % (part, " / ".join(ST.ALL_STYLE_IDS)))
795
+ ids.append(part)
796
+ if not ids:
797
+ raise SystemExit("--styles 里没有有效风格")
798
+ return ids
799
+
800
+
801
+ def _parse_scene_types(raw):
802
+ """--scenes:场景类型列表;默认 hero,list,stat(3 类,够用且快)。"""
803
+ if not str(raw or "").strip():
804
+ return list(DEFAULT_SCENE_TYPES)
805
+ out = []
806
+ for part in str(raw).replace(" ", "").split(","):
807
+ if not part:
808
+ continue
809
+ if part not in SUPPORTED_SCENE_TYPES:
810
+ raise SystemExit("未知场景类型 %r;可用:%s"
811
+ % (part, " / ".join(SUPPORTED_SCENE_TYPES)))
812
+ if part not in out:
813
+ out.append(part)
814
+ if not out:
815
+ raise SystemExit("--scenes 里没有有效场景类型")
816
+ return out
817
+
818
+
819
+ def _parse_frames(raw):
820
+ """--frames:段内时刻(0..1 的比例);默认 0.05/0.5/0.9。"""
821
+ if not str(raw or "").strip():
822
+ return list(DEFAULT_FRACS)
823
+ out = []
824
+ for part in str(raw).replace(" ", "").split(","):
825
+ if not part:
826
+ continue
827
+ try:
828
+ v = float(part)
829
+ except ValueError:
830
+ raise SystemExit("--frames 必须是逗号分隔的 0..1 比例,收到 %r" % part)
831
+ if not (0.0 <= v <= 1.0):
832
+ raise SystemExit("--frames 里 %r 超出 0..1(这是段内时刻占 dur 的比例)" % part)
833
+ out.append(v)
834
+ if not out:
835
+ raise SystemExit("--frames 里没有有效值")
836
+ return out
837
+
838
+
839
+ def _json_bytes(data):
840
+ """确定性 JSON 字节:固定缩进、不带时间戳(基线可 diff、可提交)。"""
841
+ return (json.dumps(data, ensure_ascii=False, indent=1) + "\n").encode("utf-8")
842
+
843
+
844
+ def main(argv=None):
845
+ _setup_stdio()
846
+ ap = argparse.ArgumentParser(
847
+ description="跨风格剪影可区分门禁 G13:同一内容换 style 渲帧,三值化后比结构指标")
848
+ ap.add_argument("--styles", default="",
849
+ help="逗号分隔风格 id(缺省 stylekit.STYLE_IDS 全部 8 套)")
850
+ ap.add_argument("--scenes", default="",
851
+ help="逗号分隔场景类型(缺省 hero,list,stat)")
852
+ ap.add_argument("--frames", default="",
853
+ help="段内时刻比例,逗号分隔(缺省 0.05,0.5,0.9)")
854
+ ap.add_argument("--scenes-py", default="",
855
+ help="诊断用:指定另一份 scenes.py 渲染(A/B 对比改造前后)")
856
+ ap.add_argument("--skill-root", default="",
857
+ help="诊断用:改用含 scripts/ 与 assets/ 的另一份技能目录"
858
+ "(如 git archive 出来的改造前快照),缺省用当前仓库")
859
+ ap.add_argument("--json", action="store_true", help="stdout 只输出 JSON(机读)")
860
+ ap.add_argument("--strict", action="store_true",
861
+ help="存在跳过(渲不出/缺值)时退出码 2;跳过绝不写成通过")
862
+ ap.add_argument("--out", default="", help="额外把 JSON 写到该路径(缺省不写文件)")
863
+ a = ap.parse_args(argv)
864
+ try:
865
+ skill_root = os.path.abspath(a.skill_root) if a.skill_root else SKILL
866
+ scripts_dir = os.path.join(skill_root, "scripts")
867
+ scenes_py = (os.path.abspath(a.scenes_py) if a.scenes_py
868
+ else os.path.join(skill_root, "assets", "scenes.py"))
869
+ _K, ST = _modules(scripts_dir)
870
+ styles = _parse_styles(ST, a.styles)
871
+ scene_types = _parse_scene_types(a.scenes)
872
+ fracs = _parse_frames(a.frames)
873
+ if len(styles) < 2:
874
+ raise SystemExit("至少需要 2 套风格才能两两比较,收到 %d 套" % len(styles))
875
+ res, elapsed = run_qa(scenes_py, styles, scene_types, fracs, FPS, scripts_dir)
876
+ except SystemExit:
877
+ raise
878
+ except Exception as e: # noqa: BLE001
879
+ # 装载/全局性失败是**工具跑不成**,不是判 FAIL:明确报错并按失败退出,
880
+ # 免得 CI 把"工具没跑起来"当成"风格不可区分"去修构图。
881
+ sys.stderr.write("silhouette_qa 运行失败:%s: %s\n" % (type(e).__name__, e))
882
+ sys.stderr.write(traceback.format_exc()[-2000:])
883
+ return 1
884
+ payload = _json_bytes(res)
885
+ if a.out:
886
+ out_path = os.path.abspath(a.out)
887
+ d = os.path.dirname(out_path)
888
+ if d:
889
+ os.makedirs(d, exist_ok=True)
890
+ with open(out_path, "wb") as fh:
891
+ fh.write(payload)
892
+ if a.json:
893
+ sys.stdout.write(payload.decode("utf-8"))
894
+ sys.stdout.flush()
895
+ else:
896
+ print_human(res)
897
+ print("渲染 %d 风格 × %d 场景 × %d 帧,用时 %.2fs"
898
+ % (len(styles), len(scene_types), len(fracs), elapsed))
899
+ if a.out:
900
+ print("JSON 已写入:%s" % os.path.abspath(a.out))
901
+ s = res["summary"]
902
+ if s["fail"] > 0:
903
+ return 1
904
+ if s["skip"] > 0 and a.strict:
905
+ return 2
906
+ return 0
907
+
908
+
909
+ if __name__ == "__main__":
910
+ raise SystemExit(main())