flowocr 0.1.0__py3-none-any.whl

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 (91) hide show
  1. flowocr/__init__.py +15 -0
  2. flowocr/analyze/__init__.py +8 -0
  3. flowocr/analyze/align.py +151 -0
  4. flowocr/analyze/build_tracks.py +2886 -0
  5. flowocr/analyze/cluster_layers.py +155 -0
  6. flowocr/analyze/game_align.py +652 -0
  7. flowocr/analyze/gamescript.py +1220 -0
  8. flowocr/analyze/gtdbundle.py +306 -0
  9. flowocr/analyze/match.py +67 -0
  10. flowocr/analyze/matchers/__init__.py +5 -0
  11. flowocr/analyze/matchers/gametext.py +70 -0
  12. flowocr/analyze/merge_nameplate.py +178 -0
  13. flowocr/analyze/models/slot_pair.json +148 -0
  14. flowocr/analyze/nameplate.py +148 -0
  15. flowocr/analyze/pair_features.py +168 -0
  16. flowocr/analyze/pair_model.py +127 -0
  17. flowocr/analyze/refine_boundaries.py +409 -0
  18. flowocr/analyze/script_align.py +641 -0
  19. flowocr/analyze/scriptmatch.py +1018 -0
  20. flowocr/analyze/slot_learned.py +306 -0
  21. flowocr/analyze/slot_lines.py +251 -0
  22. flowocr/analyze/slot_modes.py +143 -0
  23. flowocr/analyze/slot_pairs.py +562 -0
  24. flowocr/analyze/slot_veto.py +104 -0
  25. flowocr/analyze/uigate.py +351 -0
  26. flowocr/artifacts/__init__.py +5 -0
  27. flowocr/artifacts/evalkit.py +123 -0
  28. flowocr/artifacts/matchedio.py +75 -0
  29. flowocr/artifacts/srtio.py +166 -0
  30. flowocr/artifacts/tracksio.py +345 -0
  31. flowocr/extensions.py +77 -0
  32. flowocr/extract/__init__.py +8 -0
  33. flowocr/extract/childproc.py +118 -0
  34. flowocr/extract/decode_proc.py +511 -0
  35. flowocr/extract/decode_shards.py +574 -0
  36. flowocr/extract/detpost.py +215 -0
  37. flowocr/extract/edge_proc.py +314 -0
  38. flowocr/extract/edge_refine.py +598 -0
  39. flowocr/extract/fast_det.py +214 -0
  40. flowocr/extract/ffcheck.py +87 -0
  41. flowocr/extract/framegrid.py +265 -0
  42. flowocr/extract/framesource.py +1264 -0
  43. flowocr/extract/ocr_args.py +754 -0
  44. flowocr/extract/ocr_complete.py +231 -0
  45. flowocr/extract/ocr_parallel.py +208 -0
  46. flowocr/extract/ort_server.py +632 -0
  47. flowocr/extract/ortclient.py +363 -0
  48. flowocr/extract/ptsclock.py +150 -0
  49. flowocr/extract/recdecode.py +78 -0
  50. flowocr/extract/recort.py +162 -0
  51. flowocr/extract/recpack.py +94 -0
  52. flowocr/extract/recpool.py +206 -0
  53. flowocr/extract/recprep.py +71 -0
  54. flowocr/extract/refine_video.py +271 -0
  55. flowocr/extract/regions.py +387 -0
  56. flowocr/extract/reuse_v2.py +593 -0
  57. flowocr/extract/run_groups.py +247 -0
  58. flowocr/extract/run_ocr2.py +1605 -0
  59. flowocr/extract/supervisor.py +261 -0
  60. flowocr/extract/timeline.py +84 -0
  61. flowocr/extract/typewriter_fuse.py +394 -0
  62. flowocr/models.py +263 -0
  63. flowocr/output/__init__.py +3 -0
  64. flowocr/output/export.py +205 -0
  65. flowocr/output/layout.py +210 -0
  66. flowocr/output/presets/__init__.py +6 -0
  67. flowocr/output/presets/_overlay.py +40 -0
  68. flowocr/output/presets/default.py +21 -0
  69. flowocr/output/presets/default_all.py +20 -0
  70. flowocr/output/presets/dev.py +21 -0
  71. flowocr/output/presets/matched_srt.py +32 -0
  72. flowocr/output/presets/script.py +20 -0
  73. flowocr/output/presets/srt_main.py +33 -0
  74. flowocr/output/render.py +82 -0
  75. flowocr/output/run_srt.py +83 -0
  76. flowocr/output/script.py +541 -0
  77. flowocr/paths.py +168 -0
  78. flowocr/provenance.py +119 -0
  79. flowocr/typeset/__init__.py +9 -0
  80. flowocr/typeset/__main__.py +38 -0
  81. flowocr/typeset/assfile.py +216 -0
  82. flowocr/typeset/core.py +821 -0
  83. flowocr/typeset/fx/__init__.py +11 -0
  84. flowocr-0.1.0.dist-info/METADATA +109 -0
  85. flowocr-0.1.0.dist-info/RECORD +91 -0
  86. flowocr-0.1.0.dist-info/WHEEL +5 -0
  87. flowocr-0.1.0.dist-info/entry_points.txt +10 -0
  88. flowocr-0.1.0.dist-info/licenses/LICENSE +674 -0
  89. flowocr-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +201 -0
  90. flowocr-0.1.0.dist-info/licenses/LICENSES/PP-OCRv6-NOTICE.md +18 -0
  91. flowocr-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2886 @@
1
+ """把逐帧 OCR 观测(obs.jsonl)合成带起止时间的条目,再聚成文字轨/区域。
2
+
3
+ 三级结构:
4
+ obs 逐帧的一个检测框
5
+ run 同一位置、同一文本在连续采样帧上的一次出现 —— 带 start/end 的条目
6
+ track 同一"行位置"上的所有 run(例如底部字幕这一行)
7
+ region 若干 track 组成的块(例如整面滚动评论墙、一个对话框)
8
+
9
+ 引擎无关:任何引擎只要吐出 obs.jsonl 就能用这个工具,指标口径才可比。
10
+
11
+ 用法:
12
+ python -m flowocr.analyze.build_tracks out/zh-multi/rapidocr-v6small.jsonl --outdir out/zh-multi
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import functools
18
+ import hashlib
19
+ import json
20
+ import statistics
21
+ import subprocess
22
+ import sys
23
+ from collections import Counter
24
+ from dataclasses import dataclass, field
25
+ import re
26
+ from difflib import SequenceMatcher
27
+ from pathlib import Path
28
+
29
+ from flowocr.artifacts import evalkit # noqa: E402
30
+ from flowocr.artifacts import srtio # noqa: E402
31
+ from flowocr.artifacts import tracksio # noqa: E402
32
+ from flowocr.analyze import align # noqa: E402
33
+ from flowocr.analyze import uigate # noqa: E402
34
+ from flowocr.output import run_srt # noqa: E402 写 SRT / 清旧产物(阶段 C 第二刀搬过去的,这里只 re-export)
35
+ from flowocr.provenance import DIRTY_PATHS, git_head, portable, version, code_fp as _code_fp # noqa: E402,F401
36
+
37
+
38
+ # ---------- 基础几何 ----------
39
+
40
+ def iou(a: list[float], b: list[float]) -> float:
41
+ ix0, iy0 = max(a[0], b[0]), max(a[1], b[1])
42
+ ix1, iy1 = min(a[2], b[2]), min(a[3], b[3])
43
+ if ix1 <= ix0 or iy1 <= iy0:
44
+ return 0.0
45
+ inter = (ix1 - ix0) * (iy1 - iy0)
46
+ area_a = (a[2] - a[0]) * (a[3] - a[1])
47
+ area_b = (b[2] - b[0]) * (b[3] - b[1])
48
+ return inter / (area_a + area_b - inter)
49
+
50
+
51
+ def x_overlap_ratio(a: list[float], b: list[float], mutual: bool = False) -> float:
52
+ """x 方向重叠占多少。
53
+
54
+ 默认除以**较窄**的那个——"这个窄条整个落在那条宽带的 x 范围内"算完全重叠。
55
+ `mutual=True` 除以**较宽**的那个,要求互相都覆盖不少。
56
+
57
+ 为什么需要后者(yuka 整片实测):这游戏在几乎每个高度都渲染小字,
58
+ 行高分位 5/50/95 是 26/37/60 px——**"字号相近"那道闸门形同虚设**;
59
+ 而宽度分位是 28/110/726 px,于是一条 111 px 的窄条落在 372 px 的字幕带里
60
+ 就算完全重叠,把字幕带和满屏小字串成一个 y 跨度 0.92 的巨型区域,
61
+ 字幕带作为独立区域直接消失(3536/3749 条 run 被吞)。缩小时间窗救不回来,
62
+ 因为串联发生在窗内。
63
+ """
64
+ ov = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
65
+ wa, wb = a[2] - a[0], b[2] - b[0]
66
+ return ov / max(1.0, max(wa, wb) if mutual else min(wa, wb))
67
+
68
+
69
+ # 时钟/日期长什么样:12:34、12:34:56、06/17、2026-09-03
70
+ TIME_RE = re.compile(r"\d{1,2}\s*[::]\s*\d{2}|\d{1,4}\s*[/\-年月]\s*\d{1,2}")
71
+
72
+
73
+ PREFIX_MIN_CHARS = 3
74
+ """前缀加分要求短的那一侧至少几个字(`--prefix-min 0` 关掉这道门)。
75
+
76
+ **3 不是新常数**:它就是 owner 定的三档里"有内容 ≥3 字"那条线
77
+ (`evalkit.triviality`),1–2 字属于 trivial 档、没有判别力。
78
+
79
+ 为什么需要(methodology-audit-4 报告 C4 的第二条,2026-09-08 量的):
80
+ `a.startswith(b)` 直接给 0.95,不看两边差多少。**在 2 fps 采样下,
81
+ 打字机一帧能长出十几个字,所以低长度比本身是正常的**——
82
+ 审计说的"1 个字不该算前缀"里,"低比例"那半是错的:
83
+
84
+ 短 5 'すがのオレ' -> 长 22 'すがのオレでも、人間とフェイの関係がさらに悪'
85
+ 短 10 '今令尹から聞いたわ。' -> 长 32 '今令尹から聞いたわ。あなたの言う通り…'
86
+
87
+ **但"短到 1–2 字"那半是对的**。把"只有靠这条分支才配得上"(SequenceMatcher
88
+ < sim_thr)的那些匹配按短边字数拆开:
89
+
90
+ | 素材 | 决定性前缀匹配 | 短边 1–2 字 |
91
+ | --- | ---: | ---: |
92
+ | gi-s2 | 80 | 33 = **41%** |
93
+ | wuwa-s1 | 270 | 151 = **56%** |
94
+ | hsr-s2 | 381 | 224 = **59%** |
95
+
96
+ 而 1–2 字那批的样例是 `1` / `0` / `6` / `が死` / `任務`——数字和碎片,
97
+ `1` 就这么配上了 `1-ザ-ID:712688376`。≥3 字那批则全是真的打字机增长。
98
+ **这条线把两类干净地分开了。**
99
+ """
100
+
101
+
102
+ @functools.lru_cache(maxsize=1 << 18) # 约 26 万对,内存几十 MB;重复是时间上局部的,LRU 留得住
103
+ def _ratio(a: str, b: str) -> float:
104
+ """`SequenceMatcher(None, a, b).ratio()`,按 (a, b) 缓存——**纯函数**,同一段 UI / 字幕逐帧重复出现、
105
+ 整场 gi1 上 build_runs 调它 2.7M 次、difflib 占了建轨的 1/3(2026-09-24 profile)。⚠ 参数顺序别换:ratio 不对称。"""
106
+ return SequenceMatcher(None, a, b).ratio()
107
+
108
+
109
+ def text_sim(a: str, b: str, prefix_min: int = 0) -> float:
110
+ if not a or not b:
111
+ return 0.0
112
+ if a == b:
113
+ return 1.0
114
+ if ((a.startswith(b) or b.startswith(a)) # 打字机:逐字长出来
115
+ and min(len(a), len(b)) >= prefix_min):
116
+ return 0.95
117
+ return _ratio(a, b)
118
+
119
+
120
+ def text_sim_at_least(a: str, b: str, thr: float, prefix_min: int = 0) -> bool:
121
+ """`text_sim(a, b, prefix_min) >= thr` 的**逐位等价**快路:相等 / 前缀两条照 `text_sim` 先判(1.0 / 0.95),
122
+ 其余先过长度上界,够不着就不算 ratio。上界写成和 difflib 算 ratio **同一个浮点表达式**(`2.0 * M / (la + lb)`,
123
+ 匹配数 M ≤ min(la, lb)、浮点除法对分子单调),所以"上界 < thr ⇒ ratio < thr"不用绕有理数逼近。守卫拿原定义对拍。"""
124
+ if not a or not b:
125
+ return 0.0 >= thr
126
+ if a == b:
127
+ return 1.0 >= thr
128
+ if (a.startswith(b) or b.startswith(a)) and min(len(a), len(b)) >= prefix_min:
129
+ return 0.95 >= thr
130
+ la, lb = len(a), len(b)
131
+ if 2.0 * min(la, lb) / (la + lb) < thr:
132
+ return False
133
+ return _ratio(a, b) >= thr
134
+
135
+
136
+ def best_overlap(head: str, tail: str, min_k: int = 4, thr: float = 0.85) -> int:
137
+ """head 的后缀与 tail 的前缀重叠多少个字符(允许 OCR 误差)。
138
+
139
+ **不能"从长到短、命中即返回"**:新闻 ticker 里 `万盎司`、`了19` 这种重复子串会让
140
+ 某个错误的 k 先过阈值,接出 `6245万盎司月6万盎司增加了19` 这种断头话。
141
+ 改成在所有过阈值的 k 里取 `ratio × k` 最大的——既要匹配得好,也要重叠得长。
142
+
143
+ 返回 0 表示没有可信重叠,**调用方必须放弃拼接**,否则 OCR 抖动(`ABC` / `ABD`)
144
+ 会被首尾相接成 `ABCABD`。
145
+ """
146
+ best_k, best_score = 0, 0.0
147
+ # 快路(2026-09-24,逐位等价):两段都是 k 个字时 ratio 的上界是 quick_ratio = 字符多重集交集 / k。
148
+ # 交集随 k 增量维护(head 的后缀在左边多一个字、tail 的前缀在右边多一个字),上界够不着 thr 的 k 不建 SequenceMatcher。
149
+ # 整场 zzz 上这个函数原来 53 s(滚动字幕的拼接)
150
+ n = min(len(head), len(tail))
151
+ ca: dict[str, int] = {}
152
+ cb: dict[str, int] = {}
153
+ inter = 0
154
+ for k in range(1, n + 1):
155
+ x, y = head[-k], tail[k - 1]
156
+ if ca.get(x, 0) < cb.get(x, 0):
157
+ inter += 1
158
+ ca[x] = ca.get(x, 0) + 1
159
+ if cb.get(y, 0) < ca.get(y, 0):
160
+ inter += 1
161
+ cb[y] = cb.get(y, 0) + 1
162
+ if k < min_k or 2.0 * inter / (2 * k) < thr: # 和 difflib quick_ratio 同一个浮点表达式
163
+ continue
164
+ ratio = _ratio(head[-k:], tail[:k])
165
+ if ratio < thr:
166
+ continue
167
+ score = ratio * k
168
+ if score > best_score:
169
+ best_k, best_score = k, score
170
+ return best_k
171
+
172
+
173
+ def stitch_window_texts(texts: list[str]) -> str:
174
+ """把一串"同一句话的滑动窗口"拼回整句。
175
+
176
+ 滚动字幕(新闻 ticker、片尾)的完整句子从不在单帧里出现:文字从右边长出来、
177
+ 从左边滚出去,每一帧只看得到一个窗口。按时间顺序做后缀-前缀重叠拼接即可。
178
+
179
+ 静止文本与打字机是这件事的退化情形——前者所有窗口相同,后者互为前缀,
180
+ 拼出来就是最长的那个,所以这个函数可以无条件地替代"取最长"。
181
+ """
182
+ out = ""
183
+ for t in texts:
184
+ t = t.strip()
185
+ if not t:
186
+ continue
187
+ if not out:
188
+ out = t
189
+ continue
190
+ if t in out: # 完全被包含:没有新信息
191
+ continue
192
+ if out in t: # 打字机长出来的:直接换成更长的
193
+ out = t
194
+ continue
195
+ k = best_overlap(out, t)
196
+ # 滑动窗口相邻两帧本该重叠大半;重叠太少说明这一帧要么是抖动、
197
+ # 要么中间断了片,硬接只会造出断头话。宁可丢这一帧。
198
+ if k >= 0.4 * min(len(out), len(t)):
199
+ out = out + t[k:]
200
+ return out
201
+
202
+
203
+ def looks_like_sliding(texts: list[str]) -> bool:
204
+ """判断这串观测是不是"滑动窗口",而不是静止/打字机。
205
+
206
+ 判据:存在一对观测,彼此都不是对方的前缀(排除打字机),却有可观的首尾重叠。
207
+ """
208
+ uniq = list(dict.fromkeys(t.strip() for t in texts if t.strip()))
209
+ for a, b in zip(uniq, uniq[1:]):
210
+ if a.startswith(b) or b.startswith(a) or a in b or b in a:
211
+ continue
212
+ if best_overlap(a, b, min_k=4) >= 4:
213
+ return True
214
+ return False
215
+
216
+
217
+ class DisjointSet:
218
+ def __init__(self, n: int) -> None:
219
+ self.parent = list(range(n))
220
+
221
+ def find(self, i: int) -> int:
222
+ while self.parent[i] != i:
223
+ self.parent[i] = self.parent[self.parent[i]]
224
+ i = self.parent[i]
225
+ return i
226
+
227
+ def union(self, a: int, b: int) -> None:
228
+ ra, rb = self.find(a), self.find(b)
229
+ if ra != rb:
230
+ self.parent[rb] = ra
231
+
232
+ def groups(self) -> list[list[int]]:
233
+ out: dict[int, list[int]] = {}
234
+ for i in range(len(self.parent)):
235
+ out.setdefault(self.find(i), []).append(i)
236
+ return list(out.values())
237
+
238
+
239
+ # ---------- 数据结构 ----------
240
+
241
+ @dataclass
242
+ class Run:
243
+ box: list[float]
244
+ text: str
245
+ t_start: int
246
+ t_end: int
247
+ n_obs: int
248
+ conf: float
249
+ texts: list[str] = field(default_factory=list)
250
+ reused: list[bool] = field(default_factory=list)
251
+ """`texts` 里每一票是不是**沿用**来的(run_ocr2 的 `reused` 字段)。
252
+
253
+ 为什么要记(methodology-audit-4 报告 C4):框级复用把上一帧的文本原样搬过来,
254
+ 可下游投票**把它当成一次独立的读数**。实测沿用占全部观测的 32%–55%
255
+ (wuwa-s1 / gi-s2),也就是**三分之一到一半的票不是独立的**——
256
+ 一次读错会被自己复制出压倒性多数。"""
257
+ scrolling: bool = False
258
+ moving: bool = False
259
+ """几何上跳过位置(IoU 掉下阈值、又不是打字机在长)。和 `scrolling` 不是一回事:
260
+ 那个是**文本**像滑动窗口,这个是**框**在动,两者可以各自成立。"""
261
+ confs: list[float] = field(default_factory=list)
262
+ """逐票的置信度。`conf` 字段只留最大值,看不出**趋势**——而淡入淡出正是
263
+ "读数随时间变"(text-motion 计划的 `fade` 原语)。和 `texts`/`reused` 同长。"""
264
+ boxes: list[tuple[int, list[float]]] = field(default_factory=list)
265
+ """位置历史 `[(t_us, box)]`。**只有动过的才留全**(owner 2026-09-09:
266
+ `boxes[]` 只给 moving 的事件落盘),没动过的就只有首帧那一条——
267
+ 122k 条 run 每条都留全帧位置,内存和产物都不必要地大。"""
268
+ times: list[int] = field(default_factory=list)
269
+ """逐票的观测时刻,和 `texts` 同长。只在内存里用(`sampled_full`),不落盘。"""
270
+ clip_start: int | None = None
271
+ clip_end: int | None = None
272
+ """被**自选范围的时间关闭段**截断的那一端(µs,切点时刻;ocr-regions 计划)。起点:run 的首票前一个采样间隔里
273
+ 有切点切到这个框("范围打开的那一刻起才看得见",不是"文字在那时出现");终点:末票后一个采样间隔里有切点切到它,
274
+ t_end 截到切点。被截的那一端回抠不去找自然出现 / 消失(refine_boundaries.apply)。"""
275
+
276
+ @property
277
+ def cx(self) -> float:
278
+ return (self.box[0] + self.box[2]) / 2
279
+
280
+ @property
281
+ def cy(self) -> float:
282
+ return (self.box[1] + self.box[3]) / 2
283
+
284
+ @property
285
+ def h(self) -> float:
286
+ return self.box[3] - self.box[1]
287
+
288
+ @property
289
+ def w(self) -> float:
290
+ return self.box[2] - self.box[0]
291
+
292
+
293
+ # ---------- 阶段一:时间合并 ----------
294
+
295
+ def estimate_shift(prev: list[dict], cur: list[dict]) -> tuple[float, float]:
296
+ """估计相邻两个采样帧之间**滚动文字**的整体位移。
297
+
298
+ 做法:把两帧里文本几乎相同的框两两配对,收集位移,按 12 px 的网格投票。
299
+ 静态文字会在 (0,0) 堆出一座大山,所以这里**只找非零的那个众数**——
300
+ 静止的情况由调用方单独试 (0,0),两者取匹配更好的一个。这样同一帧里
301
+ "ticker 在滚 + 其它都不动" 也能各自匹配上。
302
+ """
303
+ votes: Counter[tuple[int, int]] = Counter()
304
+ for a in prev:
305
+ for b in cur:
306
+ if not text_sim_at_least(a["text"], b["text"], 0.9):
307
+ continue
308
+ dx = (b["box"][0] + b["box"][2] - a["box"][0] - a["box"][2]) / 2
309
+ dy = (b["box"][1] + b["box"][3] - a["box"][1] - a["box"][3]) / 2
310
+ if abs(dx) < 4 and abs(dy) < 4:
311
+ continue # 没动,不参与投票
312
+ votes[(round(dx / 12), round(dy / 12))] += 1
313
+ if not votes:
314
+ return (0.0, 0.0)
315
+ top = votes.most_common(2)
316
+ (gx, gy), n = top[0]
317
+ second = top[1][1] if len(top) > 1 else 0
318
+ # **光有 3 票不够,还得赢得干净。** yuka input4 上屏幕里有一排重复的短 UI 文字,
319
+ # 相邻帧的"第 k 个"和"第 k+1 个"互相配对,在网格间距上(288=24×12、408=34×12)
320
+ # 轻松凑够 3 票,下一帧符号还反过来——那不是滚动,是噪声。3000 个采样时刻能累到
321
+ # (-30204, -19584) px。真在滚的东西只有一个众数;重复 UI 的票会散在几个间距上。
322
+ #
323
+ # **这条余量我撤过一次又装回来了,因为第一次的账没算全。**
324
+ # 撤的理由是它没修好 input4 的 run 断裂(真凶是 gap_frames,见 build_runs),
325
+ # 而且 f2/f3 上有代价。但当时**还没测不带余量的 f4**。五部测全之后(剧本命中):
326
+ # f1 5814 -> 5813 (-1) f2 5058 -> 5172 (+114) f3 4911 -> 4934 (+23)
327
+ # f4 3908 -> **3250 (-658)** f5 2795 -> 2793 (-2)
328
+ # 跨片可比的口径上,f4 从 +1.0% 掉到 **-16.0%**(主轨 4,387 -> 3,633 条、
329
+ # 区域里的 track 从 1,234 掉到 1,047)。一部的塌方远大于两部的小赚,装回来。
330
+ if n < 3 or n < 2 * second:
331
+ return (0.0, 0.0)
332
+ return (gx * 12.0, gy * 12.0)
333
+
334
+
335
+ TYPEWRITER_MIN_CHARS = 2
336
+ """打字机增长匹配要求上一帧至少打出了几个字。**默认 2 是旧行为**;`--typewriter-min-chars 1` 是对照臂。
337
+
338
+ 1 字的首帧过不了这道门:hsr 5591 s 那一句首帧只采到 `知`(0.5 s),整句 `知ってる、捕まえたのは僕だ。`
339
+ 从下一帧才开始,两者成了两条 run,`知` 于是单独成了一条 cue(owner 2026-09-12 看预览时指出)。
340
+ 放到 1 字时**前缀必须逐字相同**(1 个字没有"允许 OCR 噪声"的余地),几何判据照旧。"""
341
+
342
+
343
+ GROW_H_RATIO = 1.7
344
+ """打字机生长的框高比门(`--grow-h-ratio`,0 = 不设门):上一帧和这一帧的框高相差超过这个倍数就不算"长出来的"。
345
+ **2026-09-24 owner 定从 1.4 放到 1.7**(defaults §1.22):yuka-f1 遮罩臂上整句的框被 det 撑到 53 px、半截是 35 px(1.51 倍),
346
+ 1.4 让链断、首字晚 41 帧(ocr-regions 计划末尾);1.7 在六份打字机真值上逐项不变、11 段用途 2 覆盖 ±0 / 重复认领 −1、
347
+ 碎片少,再放到 2.0 或不设门重复认领反而涨(h-ratio 计划)。仍是手设常数,同字高比一类(owner:保持警惕、防过拟合)。"""
348
+
349
+
350
+ def typewriter_growth(prev_box: list[float], prev_text: str,
351
+ cur_box: list[float], cur_text: str,
352
+ min_chars: int = TYPEWRITER_MIN_CHARS, h_ratio: float = GROW_H_RATIO) -> bool:
353
+ """当前这个框,是不是上一帧那个框"多打出来几个字"的样子?
354
+
355
+ 打字机的中间态过不了常规匹配:`[クり` 和 `[クリス] 乾燥させると…` 的文本相似度低,
356
+ 框也从窄变宽、IoU 掉到阈值以下,于是被当成两条独立的 run,
357
+ 在 SRT 里变成一条半截字幕 + 一条完整字幕。整片实测这是"多出条目"的主要来源之一。
358
+
359
+ 判据两条,都不含时长/速度常数(owner:ms/char 不许钉死):
360
+ 1. 短的那段文本是长的那段的**前缀**(允许 OCR 噪声;只有 1 个字时逐字相同)
361
+ 2. 两个框在**同一行**上,且是从同一侧长出去的
362
+ """
363
+ a, b = "".join(prev_text.split()), "".join(cur_text.split())
364
+ if len(a) < max(1, min_chars) or len(b) <= len(a):
365
+ return False
366
+ if len(a) < 2 and not b.startswith(a):
367
+ return False
368
+ # 几何的几条先判、文本相似度最后算(全是"且",先后不改结果;2026-09-24:整场 zzz 上这个函数 540 万次、70 s,大半花在 difflib 上)
369
+ ah, bh = prev_box[3] - prev_box[1], cur_box[3] - cur_box[1]
370
+ if h_ratio and max(ah, bh) / max(1.0, min(ah, bh)) > h_ratio:
371
+ return False
372
+ lo, hi = max(prev_box[1], cur_box[1]), min(prev_box[3], cur_box[3])
373
+ if hi - lo < 0.6 * min(ah, bh): # 不在同一行
374
+ return False
375
+ tol = 0.6 * min(ah, bh)
376
+ grew_right = abs(cur_box[0] - prev_box[0]) <= tol and cur_box[2] > prev_box[2]
377
+ grew_both = (cur_box[0] <= prev_box[0] + tol and cur_box[2] >= prev_box[2] - tol
378
+ and abs((cur_box[0] + cur_box[2]) - (prev_box[0] + prev_box[2])) <= 2 * tol)
379
+ if not (grew_right or grew_both):
380
+ return False
381
+ return len(a) < 2 or _ratio(a, b[:len(a)]) >= 0.8
382
+
383
+ def _joined(pieces: list[dict]) -> str:
384
+ """几块按从左到右拼成一行。两块之间的水平间隙 ≥ 0.3 个行高才隔一个空格(原文那里有空),否则直接拼——
385
+ 按几何判,不按字符类别:`HP` | `100` 紧挨着切开的不该多出空格(peer 审查 6b47a0c 第 3 条)。"""
386
+ out, prev = "", None
387
+ for p in pieces:
388
+ if prev is not None:
389
+ h = min(prev["box"][3] - prev["box"][1], p["box"][3] - p["box"][1])
390
+ if p["box"][0] - prev["box"][2] >= 0.3 * h:
391
+ out += " "
392
+ out += p["text"]
393
+ prev = p
394
+ return out
395
+
396
+
397
+ def rejoin_split_lines(obs_t: list[dict], open_runs: list[Run], iou_thr: float, sim_thr: float) -> list[dict]:
398
+ """det 在某个采样点上把**一条在场 run 的那一行**切成几块时,把这几块拼回一个观测(followups「det 切行的碎片另起事件」)。
399
+
400
+ 不拼的话,切出的块各自另起 run:叠加里重影、骗出假打字机(`今令尹から聞いたわ。` 接着长回整句,被画成打了 1 s)、
401
+ 段落模式的主轨 SRT 里同一行出现两遍(wuwa-s1 31.5 s、125.7 s)。通用判据,不看素材:
402
+ 1. run 是静止的(没动过、不是滚动文本);
403
+ 2. 块都在这一行上(和 run 的框竖直重叠 ≥ 0.6 个较小行高、横向落在它的框里,两侧各留 0.6 个行高),至少两块,彼此横向不重叠过半;
404
+ 3. 从左到右拼起来的文本和 run 的文本过 `sim_thr`,块的外接框和 run 的框过 `iou_thr`——两道门和普通匹配是同一对;
405
+ 4. 拼起来的得分(IoU + 文本相似,同普通匹配)高于其中任何单独一块。
406
+ 拼成的观测:框取外接、文本取拼接、置信度取最低、`reused` 只在每块都是沿用时才算沿用;`rejoined` 记块数(只在内存里)。
407
+ 每块只拼一次;run 按观测数从多到少先挑。"""
408
+ if len(obs_t) < 2 or not open_runs:
409
+ return obs_t
410
+ taken: set[int] = set()
411
+ merged: list[dict] = []
412
+ for r in sorted(open_runs, key=lambda r: -r.n_obs):
413
+ if r.moving or r.scrolling:
414
+ continue
415
+ rb, rh = r.box, r.h
416
+ tol = 0.6 * rh
417
+ pieces = []
418
+ for k, o in enumerate(obs_t):
419
+ if k in taken:
420
+ continue
421
+ b = o["box"]
422
+ bh = b[3] - b[1]
423
+ if min(rb[3], b[3]) - max(rb[1], b[1]) < 0.6 * min(rh, bh):
424
+ continue
425
+ if b[0] < rb[0] - tol or b[2] > rb[2] + tol:
426
+ continue
427
+ pieces.append(k)
428
+ if len(pieces) < 2:
429
+ continue
430
+ pieces.sort(key=lambda k: obs_t[k]["box"][0])
431
+ ps = [obs_t[k] for k in pieces]
432
+ if any(x_overlap_ratio(a["box"], b["box"]) > 0.5 for a, b in zip(ps, ps[1:])):
433
+ continue
434
+ box = [min(p["box"][0] for p in ps), min(p["box"][1] for p in ps),
435
+ max(p["box"][2] for p in ps), max(p["box"][3] for p in ps)]
436
+ text = _joined(ps)
437
+ geo, sim = iou(box, rb), text_sim(text, r.text)
438
+ if geo < iou_thr or sim < sim_thr:
439
+ continue
440
+ # 切出的长块单独也可能过门(wuwa-s1 31.5 s:右边 23 字那块和整行 IoU 0.48 > 0.35),照常匹配就会接上它、左边那块另起 run。
441
+ # 所以不是"有单块能接上就不拼",而是拼起来的比任何单块都更像这一行才拼(得分同普通匹配:IoU + 文本相似)
442
+ if geo + sim <= max(iou(p["box"], rb) + text_sim(p["text"], r.text) for p in ps):
443
+ continue
444
+ taken.update(pieces)
445
+ merged.append({**max(ps, key=lambda p: p["box"][2] - p["box"][0]), "box": box, "text": text,
446
+ "conf": min(p["conf"] for p in ps), "reused": all(bool(p.get("reused")) for p in ps),
447
+ "rejoined": len(ps)})
448
+ if not taken:
449
+ return obs_t
450
+ return [o for k, o in enumerate(obs_t) if k not in taken] + merged
451
+
452
+
453
+ def build_runs(obs: list[dict], frame_us: int, iou_thr: float, sim_thr: float,
454
+ gap_frames: int, grow_typewriter: bool = True,
455
+ text_pick: str = "vote", tail_max: int = 3,
456
+ vote_independent: bool = False, prefix_min: int = 0,
457
+ typewriter_min_chars: int = TYPEWRITER_MIN_CHARS,
458
+ cuts: list | tuple = (), grow_h_ratio: float = GROW_H_RATIO,
459
+ rejoin_split: bool = True) -> list[Run]:
460
+ """`cuts`:自选范围的时间切点 `[(t_us, 那一刻遮罩变了的像素 H×W bool)]`(`regions.cuts_us`)。
461
+ 切到某个 run 的框的切点,run **不许跨过它**延续;落在首票前 / 末票后一个采样间隔里的记成 `clip_start` / `clip_end`。
462
+ `rejoin_split`:det 把在场的一行切成几块时拼回去再匹配(`rejoin_split_lines`;`--no-rejoin-split` 是对照臂)。"""
463
+
464
+ def cut_hit(box, lo: int, hi: int) -> int | None:
465
+ """(lo, hi] 里第一个切到这个框的切点。"""
466
+ for tc, area in cuts:
467
+ if lo < tc <= hi:
468
+ x0, y0 = max(0, int(box[0])), max(0, int(box[1]))
469
+ if area[y0:max(y0, int(box[3])), x0:max(x0, int(box[2]))].any():
470
+ return tc
471
+ return None
472
+
473
+ by_t: dict[int, list[dict]] = {}
474
+ for o in obs:
475
+ by_t.setdefault(o["t_us"], []).append(o)
476
+ times = sorted(by_t)
477
+
478
+ # 每个采样间隔的滚动位移,累加成"从第一帧起的累计位移",
479
+ # 这样跨了几帧的 run 也能算出该往哪挪。
480
+ cum: dict[int, tuple[float, float]] = {times[0]: (0.0, 0.0)} if times else {}
481
+ for prev_t, t in zip(times, times[1:]):
482
+ dx, dy = estimate_shift(by_t[prev_t], by_t[t])
483
+ px, py = cum[prev_t]
484
+ cum[t] = (px + dx, py + dy)
485
+
486
+ open_runs: list[Run] = []
487
+ closed: list[Run] = []
488
+ for t in times:
489
+ # **半帧的余量不是可有可无的。** `frame_us = round(1e6/sample_fps)`,
490
+ # 而实际采样间隔会在它上下抖 1 µs(源视频 fps 不是整数时尤其)。
491
+ # 没有余量时,只要 round 向下落了一点点,**每隔一两帧就会有一个间隔比
492
+ # tol 大 1 µs**,run 当场收摊:
493
+ # input4 sample_fps 1.99957 -> frame_us 500,108;实测间隔 500,108/500,109
494
+ # **38% 的间隔超出 1 µs** -> obs/run 只有 2.1,主轨 20,108 条里
495
+ # 64% 是上一条的逐字重复
496
+ # input3 sample_fps 1.99927 -> frame_us 500,182;实测 500,182/500,181
497
+ # round 恰好落在上面,一次都没超 -> 完全正常(重复率 2%)
498
+ # 半帧余量不会放宽语义:真丢一帧是 2 个间隔,仍然超出。
499
+ tol = int((gap_frames + 0.5) * frame_us)
500
+ still: list[Run] = []
501
+ for r in open_runs: # 断太久的收摊;被时间关闭段切到的也收摊
502
+ (still if t - r.t_end <= tol and not (cuts and cut_hit(r.box, r.t_end, t) is not None)
503
+ else closed).append(r)
504
+ open_runs = still
505
+ obs_t = rejoin_split_lines(by_t[t], open_runs, iou_thr, sim_thr) if rejoin_split else by_t[t]
506
+
507
+ used: set[int] = set()
508
+ for o in sorted(obs_t, key=lambda x: -x["conf"]):
509
+ best_i, best_score = -1, 0.0
510
+ for i, r in enumerate(open_runs):
511
+ if i in used:
512
+ continue
513
+ sim = text_sim(o["text"], r.text, prefix_min)
514
+ # 打字机中间态要单独判:它**既可能**文本对不上(`[クり` 对整句),
515
+ # **也可能**文本对得上但框对不上——`ひょ` 长成 `ひょこひょこしてる〜`
516
+ # 之后框宽了 4.8 倍,IoU 只有 0.19,照样被几何判据挡掉。
517
+ grow = grow_typewriter and typewriter_growth(r.box, r.text,
518
+ o["box"], o["text"],
519
+ typewriter_min_chars, grow_h_ratio)
520
+ if sim < sim_thr and not grow:
521
+ continue
522
+ # 静止假设与滚动假设各试一次,取好的那个:
523
+ # 同一帧里既有不动的字幕、又有滚动的 ticker 时,两者都能匹配上。
524
+ dx = cum[t][0] - cum[r.t_end][0]
525
+ dy = cum[t][1] - cum[r.t_end][1]
526
+ geo = iou(o["box"], r.box)
527
+ if dx or dy:
528
+ moved = [r.box[0] + dx, r.box[1] + dy, r.box[2] + dx, r.box[3] + dy]
529
+ geo = max(geo, iou(o["box"], moved))
530
+ if geo < iou_thr and not grow:
531
+ continue
532
+ # 打字机中间态的 sim 会很低(`[クり` 对整句),靠 grow 把它抬到 1.0,
533
+ # 否则一个几何上更巧合的候选就把它抢走了。
534
+ # **比较用的键和存下来的必须是同一个**——原来比较用加权值、存的是
535
+ # `geo + sim`,后来的普通候选拿 1.05 去比 0.49,boost 等于没有。
536
+ key = geo + max(sim, 1.0 if grow else 0.0)
537
+ if key > best_score:
538
+ best_i, best_score = i, key
539
+ if best_i >= 0:
540
+ r = open_runs[best_i]
541
+ used.add(best_i)
542
+ moving = (iou(o["box"], r.box) < iou_thr
543
+ and not typewriter_growth(r.box, r.text, o["box"], o["text"],
544
+ typewriter_min_chars, grow_h_ratio))
545
+ prev_t = r.t_end # 上一次观测的时刻(轨迹要用)
546
+ r.t_end = t
547
+ r.n_obs += 1
548
+ r.texts.append(o["text"])
549
+ r.times.append(t)
550
+ r.reused.append(bool(o.get("reused")))
551
+ if len(o["text"]) > len(r.text): # 打字机:留最长的那一版
552
+ r.text = o["text"]
553
+ if moving:
554
+ # 轨迹:只有动过的才记。**位移之前要先把静止段收尾**——
555
+ # 不然一条"先站着不动、1.5 秒后才上移"的字,轨迹里只有首帧和
556
+ # 移动后两个点,导出成 `\move` 就是**从头开始匀速滑**,
557
+ # 把停顿抹掉了(运动形态审计正是要看停顿)。
558
+ # 只在**发生位移时**补一个点,代价是 O(位移次数),不是 O(观测数)。
559
+ if r.boxes and r.boxes[-1][0] != prev_t:
560
+ r.boxes.append((prev_t, list(r.box)))
561
+ r.moving = True
562
+ r.boxes.append((t, list(o["box"])))
563
+ r.box = list(o["box"]) # 跟着文字走,别把整条滚动轨迹并成一个大框
564
+ else:
565
+ r.box = [min(r.box[0], o["box"][0]), min(r.box[1], o["box"][1]),
566
+ max(r.box[2], o["box"][2]), max(r.box[3], o["box"][3])]
567
+ r.conf = max(r.conf, o["conf"])
568
+ r.confs.append(float(o["conf"]))
569
+ else:
570
+ # **新建的 run 也要占位**(methodology-audit-4 报告 C4):
571
+ # 不占位的话,**同一帧**里后面那个重叠/相似的框还能匹配到它,
572
+ # `n_obs += 1`——于是"一个时刻的两个框"被记成"两帧都看到过"。
573
+ # 下游拿 `n_obs` 当时间支持度(还用它推代表观测时刻去取模板),
574
+ # 会去一个**根本没观测过的帧**上抠图。
575
+ # 同帧去重是另一件事(该不该合),这里只保证**时间支持度不虚增**。
576
+ open_runs.append(Run(box=list(o["box"]), text=o["text"], t_start=t, t_end=t,
577
+ n_obs=1, conf=o["conf"], texts=[o["text"]], times=[t],
578
+ reused=[bool(o.get("reused"))],
579
+ confs=[float(o["conf"])],
580
+ boxes=[(t, list(o["box"]))],
581
+ clip_start=cut_hit(o["box"], t - frame_us, t) if cuts else None))
582
+ used.add(len(open_runs) - 1)
583
+ closed.extend(open_runs)
584
+ for r in closed:
585
+ # 最后一段静止也要收尾(同上:末次位移之后它还在屏幕上待了一会儿)
586
+ if r.moving and r.boxes and r.boxes[-1][0] != r.t_end:
587
+ r.boxes.append((r.t_end, list(r.box)))
588
+ tc = cut_hit(r.box, r.t_end, r.t_end + frame_us) if cuts else None
589
+ r.t_end += frame_us # 末帧也占满一个采样间隔
590
+ if tc is not None: # 自选范围在这一个间隔里关掉了:截到切点
591
+ r.t_end, r.clip_end = tc, tc
592
+ if looks_like_sliding(r.texts):
593
+ # 滚动文本:整句从不在单帧出现,得按重叠拼回来
594
+ stitched = stitch_window_texts(r.texts)
595
+ if len(stitched) > len(r.text):
596
+ r.text = stitched
597
+ r.scrolling = True
598
+ continue
599
+ # 一条 run 最终用哪一版文本。**这是个有取舍的选择,不是显然的**:
600
+ # vote 多数投票,能挡掉抖动帧的垃圾尾巴(`本当に帰る` 8 次 vs `本当に帰る火` 1 次)
601
+ # longest 取最长,能保住打字机打完的那一版(`ナノカの【幻視】……)` 只出现 2 次,
602
+ # 却是对的),但会把上面那个 `火` 一起收进来
603
+ # tail 折中:多数投票,但若最长版是多数版的延长且只多几个字,取最长
604
+ # 实测(yuka 窗口)见 script-corpus 报告,别凭直觉选。
605
+ # **投票只数独立的读数**(`--vote-reused` 可以退回旧行为):
606
+ # 框级复用把上一帧的文本原样搬过来,旧代码把它当成一次独立的票,
607
+ # 于是一次读错会被自己复制出压倒性多数(沿用占 32%–55%,audit-4 C4)。
608
+ # 一条 run 若**全部**是沿用的(首帧那一票之外再没重识别过),
609
+ # 就没有独立票可数,只好退回全体——**不能因此把它清空**。
610
+ votes = ([(t, c) for t, c, ru in zip(r.texts, r.confs, r.reused) if not ru] if vote_independent
611
+ else list(zip(r.texts, r.confs))) or list(zip(r.texts, r.confs))
612
+ pool = [t for t, _ in votes]
613
+ common = Counter(pool).most_common(1)[0][0]
614
+ longest = max(pool, key=len)
615
+ if text_pick in ("conf", "maxconf"):
616
+ # conf 每一票按 rec 置信度加权(Subtitle Edit 的"时长 × conf":我们每票时长都是一个采样间隔,退化成 conf 加权)
617
+ # maxconf 取置信度最高的那一票(videocr-PaddleOCR 的做法)
618
+ # 同样守着"不比最长版短太多"那道门,和 vote 可比(综合清单 M3,try-list-results 报告)
619
+ if text_pick == "conf":
620
+ w: Counter = Counter()
621
+ for t, c in votes:
622
+ w[t] += c
623
+ pick = max(w, key=lambda t: (w[t], -pool.index(t)))
624
+ else:
625
+ pick = max(votes, key=lambda tc: tc[1])[0]
626
+ if len(pick) >= len(r.text) * 0.8:
627
+ r.text = pick
628
+ elif text_pick == "longest":
629
+ r.text = longest
630
+ elif text_pick == "tail" and longest.startswith(common) and 0 < len(longest) - len(common) <= tail_max:
631
+ r.text = longest
632
+ elif len(common) >= len(r.text) * 0.8:
633
+ r.text = common
634
+ closed.sort(key=lambda r: (r.t_start, r.cy, r.cx))
635
+ return closed
636
+
637
+
638
+ # ---------- 阶段二/三:空间聚类 ----------
639
+
640
+ def build_tracks(runs: list[Run], W: int, H: int) -> list[list[int]]:
641
+ """行级聚类:按"到轨道质心的距离"分配,而不是并查集。
642
+
643
+ 并查集在这里会**串联**——滚动的评论墙里每条 run 都和上下相邻的那条挨得够近,
644
+ 一路 union 下去就把整面墙合成一条"行"(实测 ja-title 出现过 y 跨度 0.29H 的单轨)。
645
+ 质心法不会传染:新 run 只跟轨道当前的中位位置比。
646
+ """
647
+ order = sorted(range(len(runs)), key=lambda i: (runs[i].cy, runs[i].cx))
648
+ tracks: list[list[int]] = []
649
+ cent: list[tuple[float, float, float]] = [] # cy, cx, h 的中位数
650
+ for i in order:
651
+ r = runs[i]
652
+ best, best_d = -1, None
653
+ for k, (cy, cx, h) in enumerate(cent):
654
+ if max(r.h, h) / max(1.0, min(r.h, h)) > 1.6:
655
+ continue
656
+ dy = abs(r.cy - cy)
657
+ if dy > max(0.4 * min(r.h, h), 0.010 * H):
658
+ continue
659
+ members = [runs[j] for j in tracks[k]]
660
+ if (max(x_overlap_ratio(r.box, m.box) for m in members) < 0.30
661
+ and abs(r.cx - cx) > 0.05 * W):
662
+ continue
663
+ if best_d is None or dy < best_d:
664
+ best, best_d = k, dy
665
+ if best < 0:
666
+ tracks.append([i])
667
+ cent.append((r.cy, r.cx, r.h))
668
+ else:
669
+ tracks[best].append(i)
670
+ ms = [runs[j] for j in tracks[best]]
671
+ cent[best] = (statistics.median(m.cy for m in ms),
672
+ statistics.median(m.cx for m in ms),
673
+ statistics.median(m.h for m in ms))
674
+ return tracks
675
+
676
+
677
+ def cooccur(a: list[tuple[int, int]], b: list[tuple[int, int]]) -> bool:
678
+ """两串已合并、已排序的时段有没有交集(= 同屏过)。"""
679
+ i = j = 0
680
+ while i < len(a) and j < len(b):
681
+ if a[i][0] <= b[j][1] and b[j][0] <= a[i][1]:
682
+ return True
683
+ if a[i][1] < b[j][1]:
684
+ i += 1
685
+ else:
686
+ j += 1
687
+ return False
688
+
689
+
690
+ def alternations(a: list[tuple[int, int]], b: list[tuple[int, int]]) -> int:
691
+ """两条轨的时段按起点并成一串之后,在 A / B 之间切换了几次。
692
+
693
+ "你一条我一条"(同一块里的几行,行数随台词变,未必同屏)切换很多次;
694
+ "你一段我一段"(阅读面板念一阵、字幕带播一阵)只切换一两次。"""
695
+ i = j = n = 0
696
+ last = ""
697
+ while i < len(a) or j < len(b):
698
+ take_a = j >= len(b) or (i < len(a) and a[i][0] <= b[j][0])
699
+ src = "a" if take_a else "b"
700
+ if src != last:
701
+ n += 1
702
+ last = src
703
+ i += take_a
704
+ j += not take_a
705
+ return max(0, n - 1)
706
+
707
+
708
+ REGION_H_RATIO = 1.7
709
+ """窗内并区的字高比门(`--region-h-ratio`,0 = 不设门):两条行级轨的中位字高相差超过这个倍数就不连边。
710
+ 第一版(2026-09-04)手设的常数,没扫过、没标定(2026-09-24 查的来历);h-ratio 计划重新量:在平台上、维持。
711
+ ⚠ owner:"保持警惕、很容易过拟合"——素材间方向相反;某款游戏的版式吃它,**做成按游戏的补丁**(matchers.gametext.PATCHES),别动这里。"""
712
+
713
+
714
+ def build_regions(runs: list[Run], tracks: list[list[int]], W: int, H: int,
715
+ mutual_x: bool = False, time_gate: int = 0,
716
+ h_ratio: float = REGION_H_RATIO) -> list[list[int]]:
717
+ """把行级 track 再并成块:x 方向重叠、字号相近(`h_ratio`,0 = 不看字号)、y 相邻。
718
+
719
+ `time_gate > 0` 再加一道**时间**门(默认 0 = 关):两条轨要么同屏过,要么时段交错
720
+ ≥`time_gate` 次,才连边。它针对的是窗内并查集把**从不同屏的两块**串成一块
721
+ (星铁的遗器阅读面板和字幕带,game-text-corpus 报告)。
722
+ 量过的账在 game-text-corpus 报告里:星铁整场 +34(cue、对不上剧本、重复认领三项同时变好),绝区零 +1,
723
+ 六段切片 ±0,**但原神 −49 / −58 / −30**——不是门判错,是**主轨只能挑一个区域**接不住
724
+ 被正确分开的几条带(product-goals 第 15 条)。所以默认关,按游戏显式开
725
+ (第 14 条;星铁的证据是一场整片 + 两段切片)。"""
726
+ def stat(tr: list[int]) -> tuple[float, float, float, float, float]:
727
+ rs = [runs[i] for i in tr]
728
+ return (statistics.median(r.box[0] for r in rs), statistics.median(r.box[2] for r in rs),
729
+ statistics.median(r.cy for r in rs), statistics.median(r.h for r in rs),
730
+ statistics.median(r.cx for r in rs))
731
+
732
+ st = [stat(t) for t in tracks]
733
+ iv = ([uigate.merge_intervals(sorted((runs[i].t_start, runs[i].t_end) for i in t)) for t in tracks]
734
+ if time_gate else None)
735
+ ds = DisjointSet(len(tracks))
736
+ for i in range(len(tracks)):
737
+ for j in range(i + 1, len(tracks)):
738
+ (ax0, ax1, acy, ah, acx), (bx0, bx1, bcy, bh, bcx) = st[i], st[j]
739
+ if h_ratio and max(ah, bh) / max(1.0, min(ah, bh)) > h_ratio:
740
+ continue
741
+ if (x_overlap_ratio([ax0, 0, ax1, 1], [bx0, 0, bx1, 1], mutual_x) < 0.45
742
+ and abs(acx - bcx) > 0.05 * W):
743
+ continue
744
+ # 邻接按**较小**的那个行高判,不能按较大的:否则一个大字号的框
745
+ # (zh-news 里有 h=173px 的画中画标题)能允许 Δcy 到 0.35H,
746
+ # 并查集顺着它把整屏从 cy 0.0 串到 0.9,串成一个假的"字幕区"。
747
+ if abs(acy - bcy) > 2.2 * min(ah, bh):
748
+ continue
749
+ if iv is not None and not (cooccur(iv[i], iv[j])
750
+ or alternations(iv[i], iv[j]) >= time_gate):
751
+ continue
752
+ ds.union(i, j)
753
+ return ds.groups()
754
+
755
+
756
+ # ---------- 阶段三之二:按时间分段聚类 ----------
757
+ #
758
+ # 为什么需要(full-film-run 报告):区域聚类原本是**纯空间**的。
759
+ # 60 秒片段上很漂亮,3h20m 整片上退化成 44 个区域、其中一个塞了 278 条轨、
760
+ # y 跨度 0.96——因为 200 分钟里文字几乎在每个位置都出现过,轨与轨全部连通。
761
+ #
762
+ # 修法不是把空间判据调严(那会在短片上误分),而是**别把整片的 run 汇到一起聚**。
763
+ # 分两步:
764
+ # 1. 在时间窗内聚类(窗内版式基本不变,等价于我们已经验证过的 60 秒场景)
765
+ # 2. 跨窗把位置一致的区域缝成"版式槽位"(slot)
766
+ #
767
+ # 缝合顺带给出一个**免费且很有用的统计量**:槽位在多少个窗里出现过。
768
+ # 主字幕带会在几乎每个窗里都出现;片头标题只出现在一两个窗里。
769
+ # 这比 label_region 里那堆单帧几何阈值可靠得多——它是全片统计。
770
+
771
+
772
+ SAFE_RUNS_PER_WINDOW = 1000
773
+ """窗内 run 数的经验安全线。见 clustering 报告的窗长扫描:
774
+ 1200s 窗(每窗 ~860 条)仍正常,2400s 窗(每窗 ~1725 条)退化。取中间的整数。"""
775
+
776
+
777
+ def window_of(r: Run, window_us: int) -> int:
778
+ """run 归哪个窗:按**中点**,跨窗的 run 不会被切成两半。"""
779
+ return ((r.t_start + r.t_end) // 2) // window_us
780
+
781
+
782
+ def cluster_windowed(runs: list[Run], W: int, H: int, window_us: int,
783
+ mutual_x: bool = False, time_gate: int = 0,
784
+ h_ratio: float = REGION_H_RATIO) -> tuple[list[list[int]], list[dict]]:
785
+ """窗内聚类。返回 (全局 tracks, 窗内区域列表)。
786
+
787
+ tracks 用全局 run 下标,方便下游沿用;窗内区域记 {win, tracks, runs}。
788
+ """
789
+ by_win: dict[int, list[int]] = {}
790
+ for i, r in enumerate(runs):
791
+ by_win.setdefault(window_of(r, window_us), []).append(i)
792
+
793
+ all_tracks: list[list[int]] = []
794
+ win_regions: list[dict] = []
795
+ for win in sorted(by_win):
796
+ idx = by_win[win]
797
+ sub = [runs[i] for i in idx]
798
+ tr = build_tracks(sub, W, H)
799
+ rg = build_regions(sub, tr, W, H, mutual_x, time_gate, h_ratio)
800
+ base = len(all_tracks)
801
+ all_tracks.extend([[idx[j] for j in t] for t in tr])
802
+ for group in rg:
803
+ win_regions.append({"win": win,
804
+ "tracks": [base + t for t in group],
805
+ "runs": [idx[j] for t in group for j in tr[t]]})
806
+ return all_tracks, win_regions
807
+
808
+
809
+ def pct(vals: list[float], q: float) -> float:
810
+ v = sorted(vals)
811
+ return v[min(len(v) - 1, max(0, int(round(q * (len(v) - 1)))))] if v else 0.0
812
+
813
+
814
+ def region_geom(runs: list[Run], run_ids: list[int]) -> tuple[float, float, float, float, float, float]:
815
+ """区域的几何签名:(cx, x0, x1, y0, y1, h)。
816
+
817
+ **端点用 2%/98% 分位,不用 min/max。** 这是 predet 里踩过的同一个坑
818
+ (predet 报告):min/max 会被离群框撑大,成员越多包围盒越大,
819
+ 最后一个槽位能吞掉整屏。分位数让签名随样本收敛而不是发散。
820
+ """
821
+ rs = [runs[i] for i in run_ids]
822
+ return (statistics.median(r.cx for r in rs),
823
+ pct([r.box[0] for r in rs], 0.02), pct([r.box[2] for r in rs], 0.98),
824
+ pct([r.box[1] for r in rs], 0.02), pct([r.box[3] for r in rs], 0.98),
825
+ statistics.median(r.h for r in rs))
826
+
827
+
828
+ def span_overlap(a0: float, a1: float, b0: float, b1: float, floor: float) -> float:
829
+ """两个区间的重叠,除以**较短**那个的长度。长度给一个下限,
830
+ 否则单行区域(span≈0)会退化成除以 0 或动辄 1.0。"""
831
+ ov = min(a1, b1) - max(a0, b0)
832
+ if ov <= 0:
833
+ return 0.0
834
+ return ov / max(floor, min(a1 - a0, b1 - b0))
835
+
836
+
837
+ SLOT_ANCHOR_H = 1.2
838
+ """槽位与成员"共边"的容差,单位是行高。见下面 stitch_slots 的说明。"""
839
+
840
+ SLOT_SPAN_RATIO = 3.0
841
+ """槽位与成员的 y 跨度最多差几倍(跨度先垫到 2 倍行高再比)。"""
842
+
843
+ SLOT_H_RATIO = 1.7
844
+ """跨窗缝合的字高比门(`--slot-h-ratio`,0 = 不设门):区域和槽位签名的字高相差超过这个倍数就不缝。
845
+ 和 `REGION_H_RATIO` 同一个来历(手设、没标定),分开成两个旋钮是为了分层量。"""
846
+
847
+
848
+ def slot_fits(a: tuple[float, float, float, float, float, float],
849
+ b: tuple[float, float, float, float, float, float],
850
+ W: int, anchor: bool, mutual_x: bool = False,
851
+ span_ratio: float = SLOT_SPAN_RATIO, h_ratio: float = SLOT_H_RATIO) -> float | None:
852
+ """两个区域/槽位的几何签名能不能算同一个版式槽位。返回 y 重叠度,不匹配返回 None。
853
+
854
+ 签名是 region_geom 的 (cx, x0, x1, y0, y1, h)。判据见 stitch_slots 的说明。
855
+
856
+ `mutual_x`(`--slot-mutual-x`,默认关)把 x 判据从"除以较窄的"换成"除以较宽的",
857
+ 也就是 `build_regions` 的 `--mutual-x` 在**缝合层**的对应物。
858
+ 来历见 stitch_slots 的"大吃小还没治干净"那段。
859
+ """
860
+ return slot_fits_why(a, b, W, anchor, mutual_x, span_ratio, h_ratio)[0]
861
+
862
+
863
+ def slot_fits_why(a: tuple[float, float, float, float, float, float],
864
+ b: tuple[float, float, float, float, float, float],
865
+ W: int, anchor: bool, mutual_x: bool = False,
866
+ span_ratio: float = SLOT_SPAN_RATIO, h_ratio: float = SLOT_H_RATIO) -> tuple[float | None, str]:
867
+ """`slot_fits` 的可解释版:返回 `(y 重叠度或 None, 是哪一条拦下的)`。
868
+
869
+ `slot_fits` 就是它的第一个返回值——**只写一份判据**。
870
+ 存在的理由:整片上 44–45% 的槽位成员不再满足最终签名
871
+ (gamestream-first-look 报告),"有多少"已经量出来了,
872
+ "**卡在哪一条**"才是决定要不要允许拆分/重分配的设计输入。
873
+ 同 `nameplate.explain()` 的做法。
874
+ """
875
+ (cx, x0, x1, y0, y1, h), (scx, sx0, sx1, sy0, sy1, sh) = a, b
876
+ if h_ratio and max(h, sh) / max(1.0, min(h, sh)) > h_ratio:
877
+ return None, f"字高比>{h_ratio:g}"
878
+ if (x_overlap_ratio([x0, 0, x1, 1], [sx0, 0, sx1, 1], mutual_x) < 0.30
879
+ and abs(cx - scx) > 0.08 * W):
880
+ return None, "x 不重叠且 cx 差远"
881
+ yo = span_overlap(y0, y1, sy0, sy1, min(h, sh))
882
+ if yo < 0.5:
883
+ return None, "y 重叠<0.5"
884
+ if anchor:
885
+ if min(abs(y0 - sy0), abs(y1 - sy1)) > SLOT_ANCHOR_H * max(h, sh):
886
+ return None, "上下边都没对齐"
887
+ floor = 2.0 * min(h, sh)
888
+ sa, sb = y1 - y0, sy1 - sy0
889
+ if max(sa, sb) / max(floor, min(sa, sb)) > span_ratio:
890
+ return None, "y 跨度比超限"
891
+ return yo, ""
892
+
893
+
894
+ def slot_geom_median(runs: list[Run], win_regions: list[dict], members: list[int]):
895
+ """槽位签名 = 各成员窗区域**各自几何的逐分量中位数**(`--slot-geom median`)。
896
+
897
+ 默认的 `pooled` 是把成员的 run 全倒进一个池子再取 2%/98% 分位,
898
+ 于是**一个巨大的成员会把签名撑开**:f5 的槽位吃进辩论屏之后,签名从
899
+ 名牌带的 `x=[214,761] y=[588,856]` 长成 `x=[90,1762] y=[157,865]`,
900
+ 之后任何窄带都"整个落在里面"。中位数对这种成员不敏感——
901
+ 这和 `region_geom` 用分位数而不是 min/max 是同一条理由,只是又往上了一层。
902
+
903
+ **道理对,五部一跑还是否掉了**(名牌纯度:i1 1.5 / i2 0.9 / i3 87.0 / i4 37.4 /
904
+ i5 76.3)。它和 `--slot-mutual-x` 坏在同样的两部——那两部的名牌槽位本来就是
905
+ 贪心顺序的运气,任何改签名的动作都会把分配重新洗一遍。留着这条路是为了
906
+ 别再走第二遍;要走通得先治"贪心依赖顺序"本身。
907
+ """
908
+ gs = [region_geom(runs, win_regions[w]["runs"]) for w in members]
909
+ return tuple(statistics.median(g[k] for g in gs) for k in range(6))
910
+
911
+
912
+ def moving_share(runs: list[Run], ids: list[int]) -> float:
913
+ """这一批 run 里**带 `move` 原语**的占比(text-motion 计划)。
914
+
915
+ 两个原语都算:"框在动"(`Run.moving`,几何上跳过位置)和"文本像滑动窗口"
916
+ (`Run.scrolling`,`looks_like_sliding` 判的)。两者可以各自成立:
917
+ 单行 ticker 常常只有后者,评论墙整块上滚常常只有前者。
918
+
919
+ **占比而不是布尔**:一个区域里混着不动的行是常态(片尾表里也有静止的标题)。
920
+ """
921
+ if not ids:
922
+ return 0.0
923
+ n = sum(1 for i in ids if runs[i].moving or runs[i].scrolling)
924
+ return n / len(ids)
925
+
926
+
927
+ def stitch_slots(runs: list[Run], win_regions: list[dict], W: int, H: int,
928
+ anchor: bool = True, mutual_x: bool = False,
929
+ span_ratio: float = SLOT_SPAN_RATIO,
930
+ geom_mode: str = "pooled", move_seed_last: float = 0.0,
931
+ reassign: bool = False, reassign_iters: int = 3,
932
+ h_ratio: float = SLOT_H_RATIO) -> list[list[int]]:
933
+ """把各窗的区域缝成跨窗的"版式槽位"。返回每个槽位含哪些 win_regions 下标。
934
+
935
+ 和 build_tracks 一样用**质心分配**而不是并查集:并查集在这里会重新引入
936
+ 我们刚修掉的那个病——槽位 A 和 B 各差一点,一路 union 又串成一片。
937
+
938
+ 判据是 **y 区间重叠**,不是 y 中心距离。中心距离在多行字幕上是错的:
939
+ 同一条字幕带,1 行时中心在 0.85、3 行时在 0.76,差 54 px 就缝不上了
940
+ (实测整片上对话被拆成 #1 266 条 / #3 63 条两个槽位)。区间重叠没这个问题。
941
+
942
+ **但光有区间重叠会被"大吃小"**(6.2h 整片实测,clustering 报告):
943
+ `span_overlap` 除的是较短那个区间,于是任何小区域只要整个落在大区域里
944
+ 就得 1.0。片尾滚动 staff 表在一个 60 s 窗里是一个 y 跨度 0.97 的合法区域,
945
+ 而 `stitch_slots` 按 run 数降序排——它必然第一个当种子,然后把 5302 个
946
+ 窗区域里的 3249 个吸进同一个槽位(29222 条 run 吃掉 19122 条),
947
+ 这些成员的 y 跨度中位数只有 0.041。
948
+ 病**不在窗内聚类**:窗内区域只有 19/5302 超过 0.5H,且全是这张滚动表。
949
+
950
+ 两条对称性判据(`anchor=False` 可关掉对比):
951
+ **共边** 同一个版式槽位是屏幕上一个**固定位置**:字幕带 1 行变 3 行时,
952
+ 往上长还是往下长取决于对齐方式,但总有一条边不动。要求
953
+ min(|Δy0|, |Δy1|) <= SLOT_ANCHOR_H × 行高。
954
+ **同量级** 槽位和成员应当是**同一个框**,而不是一个套在另一个里面。
955
+ 跨度比(垫 2 倍行高后)超过 SLOT_SPAN_RATIO 就不是。
956
+ """
957
+ def sig_of(members: list[int]):
958
+ if geom_mode == "median":
959
+ return slot_geom_median(runs, win_regions, members)
960
+ return region_geom(runs, [j for w in members for j in win_regions[w]["runs"]])
961
+
962
+ # **在动的区域不当种子**(text-motion 计划第 2 步,`--move-seed-last`,默认关):
963
+ # 65% 那次事故的机制是"排序键把最坏的样本排在最前面"——片尾滚动表在一个 60 s 窗里
964
+ # 是个 y 跨度 0.97 的**合法**大区域,按 run 数降序它必然第一个当种子。
965
+ # 这里不把它从缝合里删掉(那会丢 run),只是**排到最后**:等静止的版式先立住,
966
+ # 它再去找位置;找不到就自己开一个槽位,那正是它该待的地方。
967
+ mv = ([moving_share(runs, win_regions[i]["runs"]) for i in range(len(win_regions))]
968
+ if move_seed_last > 0 else [0.0] * len(win_regions))
969
+ order = sorted(range(len(win_regions)),
970
+ key=lambda i: (mv[i] >= move_seed_last if move_seed_last > 0 else False,
971
+ -len(win_regions[i]["runs"])))
972
+ slots: list[list[int]] = []
973
+ cent: list[tuple[float, float, float, float, float, float]] = []
974
+ for i in order:
975
+ cx, x0, x1, y0, y1, h = region_geom(runs, win_regions[i]["runs"])
976
+ best, best_d = -1, None
977
+ for k, sig in enumerate(cent):
978
+ yo = slot_fits((cx, x0, x1, y0, y1, h), sig, W, anchor, mutual_x, span_ratio, h_ratio)
979
+ if yo is None:
980
+ continue
981
+ if best_d is None or 1 - yo < best_d:
982
+ best, best_d = k, 1 - yo
983
+ if best < 0:
984
+ slots.append([i])
985
+ cent.append((cx, x0, x1, y0, y1, h))
986
+ else:
987
+ slots[best].append(i)
988
+ cent[best] = sig_of(slots[best])
989
+
990
+ if not anchor:
991
+ return slots # --no-slot-anchor = 整条 2026-09-04 的修法都退回
992
+
993
+ # 收敛后再合并一轮。**贪心分配天生依赖顺序**:排序键是 run 数,
994
+ # 第一个种子是"最大"的而不是"最典型"的。yuka 整片实测——字幕带的种子
995
+ # 是个 y=[0.943,0.998] 的底部窄片(62 条 run),紧接着来的 [0.849,0.932]
996
+ # 和它不重叠只好另开一个槽位;等前者长到 [0.799,0.974] 时后者已经独立存在,
997
+ # 于是同一条字幕带散在 13 个槽位里(947 个窗区域最多的那个只占 326)。
998
+ # 槽位的几何这时已经收敛,用同一套判据再扫一遍就能缝上,而且不会把
999
+ # anchor/同量级挡掉的东西放进来。
1000
+ cent = [sig_of(sl) for sl in slots]
1001
+ changed = True
1002
+ while changed: # 合并会让几何变,直到不动点为止
1003
+ changed = False
1004
+ a = 0
1005
+ while a < len(slots):
1006
+ b = a + 1
1007
+ while b < len(slots):
1008
+ if slot_fits(cent[a], cent[b], W, anchor, mutual_x, span_ratio, h_ratio) is None:
1009
+ b += 1
1010
+ continue
1011
+ slots[a].extend(slots[b])
1012
+ del slots[b], cent[b]
1013
+ cent[a] = sig_of(slots[a])
1014
+ changed = True
1015
+ a += 1
1016
+
1017
+ if reassign:
1018
+ # **允许重分配 / 分裂**(audit-4 C1,2026-09-20):上面那两步只会"合",
1019
+ # 于是成员是按**当时**的签名收进来的、而签名随后被别的成员改掉——
1020
+ # 整片实测 44~45% 的成员不再满足最终签名的闸门。这里对着**最终**签名重判一遍:
1021
+ # 不满足就换一个满足的,都不满足就自己独立成槽。换完签名又变,所以迭代到不动点。
1022
+ #
1023
+ # ⚠ **`slot_consistency` 不是这一步的验收**(审计七):它用的是**同一个谓词**
1024
+ # (`slot_fits_why`),所以那个百分比只是这个循环的**收敛残差**;而且它把
1025
+ # `len(sl) < 2` 的槽位排出分母——全拆成单例也能让它归零。所以这里连
1026
+ # **收敛与否**和**碎片化**一起打出来,两个数摆在一起才读得了。
1027
+ n0, single0 = len(slots), sum(1 for sl in slots if len(sl) == 1)
1028
+ mem0 = sorted(i for sl in slots for i in sl)
1029
+ converged, used = False, 0
1030
+ for _ in range(max(1, reassign_iters)):
1031
+ cent = [sig_of(sl) for sl in slots]
1032
+ moved = 0
1033
+ for k in range(len(slots)):
1034
+ if len(slots[k]) <= 1:
1035
+ continue
1036
+ for i in list(slots[k]):
1037
+ g = region_geom(runs, win_regions[i]["runs"])
1038
+ if slot_fits(g, cent[k], W, anchor, mutual_x, span_ratio, h_ratio) is not None:
1039
+ continue
1040
+ best, best_d = -1, None
1041
+ for k2, sig in enumerate(cent):
1042
+ if k2 == k:
1043
+ continue
1044
+ yo = slot_fits(g, sig, W, anchor, mutual_x, span_ratio, h_ratio)
1045
+ if yo is not None and (best_d is None or 1 - yo < best_d):
1046
+ best, best_d = k2, 1 - yo
1047
+ if len(slots[k]) <= 1:
1048
+ break
1049
+ slots[k].remove(i)
1050
+ if best >= 0:
1051
+ slots[best].append(i)
1052
+ else:
1053
+ slots.append([i])
1054
+ cent.append(g)
1055
+ moved += 1
1056
+ slots = [sl for sl in slots if sl]
1057
+ used += 1
1058
+ if not moved:
1059
+ converged = True
1060
+ break
1061
+ # **成员守恒**:重分配只搬家,不许凭空多出或丢掉一个窗区域
1062
+ mem1 = sorted(i for sl in slots for i in sl)
1063
+ if mem1 != mem0:
1064
+ raise AssertionError(f"重分配把成员弄丢/弄多了:{len(mem0)} -> {len(mem1)}")
1065
+ n_mem = len(mem1)
1066
+ print(f"缝合重分配(--slot-reassign):{used}/{max(1, reassign_iters)} 轮、"
1067
+ + ("**收敛**" if converged else "**没收敛**(还有成员在动,加 --slot-reassign-iters)")
1068
+ + f";槽位 {n0} → {len(slots)}(单成员 {single0} → "
1069
+ + f"{sum(1 for sl in slots if len(sl) == 1)})、成员 {n_mem} 条"
1070
+ + "。⚠ 一致性那个百分比是这一步的**收敛残差**,不是质量指标(audit-7)",
1071
+ flush=True)
1072
+ return slots
1073
+
1074
+
1075
+ # ---------- 特征与判读 ----------
1076
+
1077
+ def dominant_text_share(rs: list[Run]) -> float:
1078
+ """最大的一组"意思相同"的文本占了多少时长。
1079
+
1080
+ 水印被 OCR 抖成十几种写法时,distinct_text_ratio 会骗人(看着像内容一直在变),
1081
+ 这个指标不会:抖动版本之间的相似度很高,会被归到同一组。
1082
+ """
1083
+ # 下面是 `text_sim(a, rep) >= 0.8` 的**逐位等价**展开,只为把 SequenceMatcher
1084
+ # 挡在门外。这一个函数就是长片上 build_tracks 的全部开销:
1085
+ # 6.2h yuka(218 个区域、最大的一个 6,316 条 run)cProfile 下累计 683.9 s,
1086
+ # 而 stitch_slots 只有 10.0 s——**瓶颈不在聚类,在这个统计量**。
1087
+ # 两道门,都是 ratio 的上界,达不到 0.8 就不必真跑:
1088
+ # 长度门 ratio <= 2*min/(min+max),要它 >= 0.8 就得 3*min >= 2*max
1089
+ # quick_ratio 字符多重集的交集
1090
+ # 相等/前缀两条在门之前判——它们返回 1.0 / 0.95,和长度无关。
1091
+ # 实测(整片 build_tracks 端到端):yuka 5m13 -> 2m05,sz 31.6 s -> 18.7 s,
1092
+ # 两片的全部产物逐字节相同。
1093
+ # 每组一个 SequenceMatcher、seq2 = 代表文本:b2j(difflib 里最贵的那步)只在代表换了时重建,
1094
+ # 原来每比一次都 set_seq2 重建一遍(2026-09-24 profile:整场 gi1 上 __chain_b 33 s)。seq1 / seq2 的角色不变(ratio 不对称)
1095
+ groups: list[tuple[str, float]] = [] # 代表文本, 累计时长
1096
+ sms: list[SequenceMatcher | None] = [] # 和 groups 对齐;代表为空的组是 None
1097
+ for r in rs:
1098
+ dur = (r.t_end - r.t_start) / 1e6
1099
+ a = r.text
1100
+ la = len(a)
1101
+ for k, (rep, tot) in enumerate(groups):
1102
+ if not a or not rep:
1103
+ continue
1104
+ if not (a == rep or a.startswith(rep) or rep.startswith(a)):
1105
+ lb = len(rep)
1106
+ if 3 * min(la, lb) < 2 * max(la, lb):
1107
+ continue
1108
+ sm = sms[k]
1109
+ sm.set_seq1(a)
1110
+ if sm.quick_ratio() < 0.8 or sm.ratio() < 0.8:
1111
+ continue
1112
+ new_rep = rep if len(rep) >= la else a
1113
+ groups[k] = (new_rep, tot + dur)
1114
+ if new_rep is not rep:
1115
+ sms[k] = SequenceMatcher(None, "", new_rep) if new_rep else None
1116
+ break
1117
+ else:
1118
+ groups.append((a, dur))
1119
+ sms.append(SequenceMatcher(None, "", a) if a else None)
1120
+ total = sum(t for _, t in groups)
1121
+ return max(t for _, t in groups) / total if total else 0.0
1122
+
1123
+
1124
+ def script_of(ch: str) -> str:
1125
+ """一个字符属于哪套文字。用码位范围,不引第三方库。"""
1126
+ o = ord(ch)
1127
+ if 0x3040 <= o <= 0x30FF or 0x31F0 <= o <= 0x31FF:
1128
+ return "kana"
1129
+ if 0xAC00 <= o <= 0xD7AF or 0x1100 <= o <= 0x11FF:
1130
+ return "hangul"
1131
+ if 0x4E00 <= o <= 0x9FFF or 0x3400 <= o <= 0x4DBF or 0xF900 <= o <= 0xFAFF:
1132
+ return "han"
1133
+ if 0x0400 <= o <= 0x04FF:
1134
+ return "cyrillic"
1135
+ if ch.isascii() and ch.isalpha():
1136
+ return "latin"
1137
+ if ch.isdigit() or (0xFF10 <= o <= 0xFF19):
1138
+ return "digit"
1139
+ return "other"
1140
+
1141
+
1142
+ def text_language(t: str) -> tuple[str, float]:
1143
+ """一段文本的语言,以及"有多少字是真文字"(不含数字/符号)。
1144
+
1145
+ 只按码位判,不猜语种细分:**判读不求完备**(owner 2026-09-03)。
1146
+ 日文靠假名认,但**不能用"假名占总字数"当阈值**——中文字幕被多语模型识别时
1147
+ 会掺进几个假名噪声,zh-multi 上就有 6.9%。改用 `假名/(假名+汉字)`:
1148
+ 日文正常在 40% 以上,中文的噪声在 10% 以下,中间空得很开。
1149
+ """
1150
+ c: Counter[str] = Counter()
1151
+ for ch in t:
1152
+ if not ch.isspace():
1153
+ c[script_of(ch)] += 1
1154
+ total = sum(c.values())
1155
+ if not total:
1156
+ return "none", 0.0
1157
+ letter_frac = (total - c["digit"] - c["other"]) / total
1158
+ cjk = c["kana"] + c["han"]
1159
+ if cjk and c["kana"] / cjk >= 0.15:
1160
+ lang = "ja"
1161
+ elif c["hangul"] >= 0.15 * total:
1162
+ lang = "ko"
1163
+ elif c["han"] >= 0.2 * total:
1164
+ lang = "zh"
1165
+ elif c["cyrillic"] >= 0.2 * total:
1166
+ lang = "ru"
1167
+ elif c["latin"] >= 0.2 * total:
1168
+ lang = "latin"
1169
+ else:
1170
+ lang = "sym" # 数字/符号为主:多半是 UI、比分、时钟
1171
+ return lang, round(letter_frac, 3)
1172
+
1173
+
1174
+ def region_language(texts: list[str]) -> tuple[str, str, float]:
1175
+ """区域的语言:主语言、混合成分、真文字占比。
1176
+
1177
+ **逐条判再统计,不是把整区文本拼起来判**:双语字幕带(zh-sub 就是中日两行叠着)
1178
+ 拼起来只会得到一个多数语言,丢掉"这一带同时有两种语言"这个对用途 2 很要紧的信息
1179
+ ——哪一行是原文、哪一行是译文,正是匹配器要知道的。
1180
+ """
1181
+ langs = [text_language(t) for t in texts if t.strip()]
1182
+ if not langs:
1183
+ return "none", "", 0.0
1184
+ c = Counter(l for l, _ in langs)
1185
+ letter_frac = round(sum(f for _, f in langs) / len(langs), 3)
1186
+ main = c.most_common(1)[0][0]
1187
+ mix = "+".join(sorted(k for k, v in c.items()
1188
+ if k not in ("none", "sym") and v >= 0.2 * len(langs)))
1189
+ return main, mix, letter_frac
1190
+
1191
+ def primary_score(f: dict, label: str) -> float:
1192
+ """这个槽位有多像"主字幕/主对话轨"。
1193
+
1194
+ Owner 的方向是**把可能高价值的标出来**,不求完备判读,所以这里只出一个可比的分,
1195
+ 由调用方看排序,而不是给一个 True/False 断言。
1196
+
1197
+ 三个乘数,都来自已经有的统计量,没有引入新常数:
1198
+ - 条数:主轨的条目一定多
1199
+ - 常驻度:主轨在大多数时间窗里都出现([clustering.md](clustering.md))
1200
+ - 是不是"会换内容":水印/时钟 fill_active 高但 dominant_share 也高,要排掉
1201
+ 单条时长离谱(太短或长到像常驻)和纯符号轨直接给 0。
1202
+ """
1203
+ if label in ("text-wall", "clock", "noise", "static-overlay"):
1204
+ return 0.0 # 这几类已被现成规则认出来,不在打分里重造判据
1205
+ if f["lang"] in ("sym", "none") or f["letter_frac"] < 0.5:
1206
+ return 0.0
1207
+ # **只有下限,没有上限**(2026-09-08 去掉上限)。上限原来是 15 s,职责是
1208
+ # "排掉常驻",可那件事下一行的 resident 判据已经在做了——**两道闸门管同一件事,
1209
+ # 而上限是个悬崖**:原神对话节奏慢,cue 时长中位 14.5 s 紧贴 15,
1210
+ # 把同一段素材从 10 分钟切成 5 分钟,中位挪到 16.2 s,label 从 subtitle 翻成 misc、
1211
+ # 分数归零、**主轨凭空消失**(data/samples.md 的 q-gi-s2,quick 集第一次跑就撞上)。
1212
+ # 去掉之前在 22 份现成产物上做过模拟(不重跑,只拿 tracks.json 的 features 重算):
1213
+ # **只有那一份的主轨会变,其余 21 份一模一样**。
1214
+ if f["median_dur_s"] < 0.3:
1215
+ return 0.0
1216
+ if f["fill_active"] > 0.7 and f["dominant_share"] > 0.5: # 常驻不换内容 = 水印
1217
+ return 0.0
1218
+ # 最后两项都在说同一件事:**主字幕带是一条又宽又扁的带**。
1219
+ #
1220
+ # `w` —— 直播评论墙老是抢主轨(ja-title、zh-gamesub 上实测),
1221
+ # 它和字幕带最干净的差别就是宽度:墙是竖窄条(w 0.04–0.11),
1222
+ # 字幕带横贯画面(w 0.19–0.34)。
1223
+ # `w/(w+y_span)` —— "扁"。这一项是被 yuka 逼出来的:法庭辩论那一屏的大字散在
1224
+ # 画面中部,聚类把它和说话人名牌并成一个区域,724 条 run 压过了
1225
+ # 真字幕带的 604 条(129.5 对 126.2)。那个区域 y 跨度 0.40,
1226
+ # 真字幕带只有 0.11——**宽度分不开它们,扁不扁分得开**。
1227
+ #
1228
+ # 两项都是必要条件,所以相乘,和上面几项同一个写法。**不引入新常数**:
1229
+ # w 和 y_span 都是现成的统计量,比值本身无量纲。
1230
+ band = f["w"] / max(1e-6, f["w"] + f["y_span"])
1231
+ return round(f["n_runs"] * f["persistence"] * (1 - f["dominant_share"])
1232
+ * f["w"] * band, 2)
1233
+
1234
+ def covered_windows(runs: list[Run], run_ids: list[int], window_us: int) -> list[int]:
1235
+ """这些 run **真实覆盖**到的时间窗(用来算常驻度)。
1236
+
1237
+ 和 `window_of()` 的分工(methodology-audit-4 报告 C2):
1238
+ `window_of` 按中点给 run 定**一个**归属窗——那是为了"同一条 run 不被重复输出",
1239
+ 是对的;但拿归属窗数常驻度会被**切分**左右:一条 0–180 秒一直在屏上的 run
1240
+ 只归中间那个窗,persistence 算 1/3;把同一段文字切成三条相邻 run 就变成 1.0。
1241
+ **屏上占用没变,分却变了。** 所以常驻度改用真实覆盖。
1242
+ """
1243
+ out: set[int] = set()
1244
+ for i in run_ids:
1245
+ r = runs[i]
1246
+ lo = r.t_start // window_us
1247
+ hi = max(r.t_start, r.t_end - 1) // window_us
1248
+ out.update(range(lo, hi + 1))
1249
+ return sorted(out)
1250
+
1251
+
1252
+ def slot_consistency(runs: list[Run], win_regions: list[dict], slots: list[list[int]],
1253
+ W: int, anchor: bool, mutual_x: bool,
1254
+ span_ratio: float, geom_mode: str, h_ratio: float = SLOT_H_RATIO
1255
+ ) -> tuple[int, int, Counter]:
1256
+ """缝合完之后,回头看**每个成员还满不满足最终签名的闸门**。
1257
+
1258
+ 返回 `(不满足的, 总数, 卡在哪一条的计数)`——第三项是 2026-09-08 加的:
1259
+ 整片上这个数是 44–45%,"卡在哪一条"才是决定要不要允许拆分/重分配的设计输入。
1260
+
1261
+ 存在的理由(methodology-audit-4 报告 C1):`stitch_slots` 是贪心的,
1262
+ 每并进一个成员就用池化后的几何重算签名,**只合不分**,
1263
+ 从不回头检查早先的成员对最终签名还成不成立。
1264
+ "每次合并都过了闸门" != "最终全体成员都遵守闸门"——
1265
+ 审计给的反例:五个窗区域纵向依次偏移 20 px,默认参数把它们并成一个槽位,
1266
+ 而拿最终签名去问 `slot_fits`,中间那个成员返回 None。
1267
+
1268
+ **这里只报数、不改算法**:要不要允许拆分/重分配是聚类架构的取舍,
1269
+ 得先有这个数才知道它值不值得动(audit-4 的建议也是先把它做成可测指标)。
1270
+ """
1271
+ bad = tot = 0
1272
+ why: Counter[str] = Counter()
1273
+ for sl in slots:
1274
+ if len(sl) < 2:
1275
+ continue
1276
+ ids = [j for w in sl for j in win_regions[w]["runs"]]
1277
+ sig = (slot_geom_median(runs, win_regions, sl) if geom_mode == "median"
1278
+ else region_geom(runs, ids))
1279
+ for w in sl:
1280
+ tot += 1
1281
+ g = region_geom(runs, win_regions[w]["runs"])
1282
+ ok, reason = slot_fits_why(sig, g, W, anchor, mutual_x, span_ratio, h_ratio)
1283
+ if ok is None:
1284
+ bad += 1
1285
+ why[reason] += 1
1286
+ return bad, tot, why
1287
+
1288
+
1289
+ MAIN_LABELS = ("subtitle", "dialogue-or-caption")
1290
+ """能当正文轨的标签。**排名和接受是两件事**(methodology-audit-4 报告 C3)。"""
1291
+
1292
+
1293
+ MAIN_BAND_DEFAULT = 3.0
1294
+ """`--main-band` 的默认值:并带时允许的中心 y 差,单位是**主轨行高**。
1295
+
1296
+ **2026-09-08 由关(0)改成开(3),owner 拍板。** 依据是五部整片的头对头
1297
+ (clustering 报告那一节):默认几何 + 并带,净命中 +1/+3/+0/+1/+6,
1298
+ "有内容 ≥3 字" 0/+3/0/0/+2,**五部没有一部是负的**;同时把两次"主轨换区域"
1299
+ 的事故各抹平一次(`--slot-geom median` 的 −191 追回 190、
1300
+ `--rec-batch-size 8` 的 −662 追回 663)。用途 1 的产物零影响。
1301
+
1302
+ 3 这个数不是扫出来的最优值,是"一个对话框的第二行大约差 1–3 倍行高"这个
1303
+ 版式常识取的上界,一次就用了它。**没扫过 2 / 4 / 5。**
1304
+ """
1305
+
1306
+
1307
+ def band_regions(report, main_i: int, max_dcy: float,
1308
+ h_lo: float = 0.7, h_hi: float = 1.4,
1309
+ need_label: bool = False, need_x: bool = False,
1310
+ geom: dict | None = None,
1311
+ W: int = 0, mutual_x: bool = False) -> list[int]:
1312
+ """和主轨**同一条字幕带**的那些区域(不含主轨自己)。
1313
+
1314
+ 存在的理由(gamestream-first-look 报告):匹配器只看主轨一个区域,
1315
+ 所以聚类只要把字幕带切成两块,第二块里的台词就**整段消失**——
1316
+ f1 上 `--slot-geom median` 丢的 191 条连续台词一条没丢,只是跑到了 region07。
1317
+ audit-4 C1 的出路因此不是"让签名更严"(更严只是把"合错了"换成"没合上"),
1318
+ 而是**让主轨可以由多个区域组成**。
1319
+
1320
+ 判据相对**主轨自己的字高**归一化,无量纲(owner 指示 7):
1321
+ 中心 y 差 `max_dcy` 倍字高以内、字高比在 `[h_lo, h_hi]`。
1322
+ 一个对话框的第二行大约差 1–3 倍字高。
1323
+
1324
+ **时间共现这一维实测没用**(一次性探针 `probe_main_band.py`,f1-med):
1325
+ 几何选 13 个区域 1,907 条 run,再加"时间共现 ≥50%"只剔掉 1 条;
1326
+ 而**只按时间共现**会选中 79 个区域 25,457 条——画面下方的常驻 UI 和谁都共现。
1327
+ 所以这里只用几何,探针里那两列仍然打出来,好在别的素材上复查。
1328
+ """
1329
+ mf = next(f for ri, _, f in report if ri == main_i)
1330
+ mh = max(mf["h"], 1e-6)
1331
+ out = []
1332
+ for ri, lab, f in report:
1333
+ if ri == main_i:
1334
+ continue
1335
+ if not (abs(f["cy"] - mf["cy"]) / mh <= max_dcy and h_lo <= f["h"] / mh <= h_hi):
1336
+ continue
1337
+ # ① 和主轨的**接受判据**对齐(`--main-band-label`)。
1338
+ # 第一眼看它"太狠"(quick 六段里三段的并带大幅缩水),
1339
+ # **摆出被挡掉的区域才发现挡对了**:q-yuka-f1-band 缩掉的那 96 条 cue
1340
+ # 来自 r00——`misc`、cy=0.982、cx=0.094、文本是 `Wns.3`/`Bsy`/`Duininy`,
1341
+ # 是屏幕左下角的 UI 垃圾。**先摆文本,再算比例。**
1342
+ if need_label and lab not in MAIN_LABELS:
1343
+ continue
1344
+ # ② 复用另外两层现成的 x 判据(`--main-band-x`;区间重叠 **或** 中心对齐),
1345
+ # 不新写一条。三种对齐都盖得住:靠左/靠右走区间重叠
1346
+ # (短行整个落在长行里,分母取较窄的那个),居中走 cx 距离。
1347
+ if need_x:
1348
+ if geom is not None and W:
1349
+ a, b = geom.get(main_i), geom.get(ri)
1350
+ if a and b:
1351
+ ax0, ax1, acx = a[1], a[2], a[0]
1352
+ bx0, bx1, bcx = b[1], b[2], b[0]
1353
+ if (x_overlap_ratio([ax0, 0, ax1, 1], [bx0, 0, bx1, 1], mutual_x) < 0.30
1354
+ and abs(acx - bcx) > 0.08 * W):
1355
+ continue
1356
+ out.append(ri)
1357
+ return out
1358
+
1359
+
1360
+ MAIN_MOVE_MAX = 0.3
1361
+ """`--main-move-max` 的默认:`moving_share` 达到这么多的区域不当主轨(owner 2026-09-11 翻成默认)。
1362
+
1363
+ 翻之前是 1.0(不拦),理由是"当前素材上没发生过、是保险不是改善"。09-11 发生了:星铁 / 绝区零整场的
1364
+ 主轨分别被滚动的阅读面板(`moving_share` 0.536)和左下角实时弹幕(0.779)抢走,命中 71/626、0/1,288。
1365
+ 先翻成 0.5,同日改成 **0.3**:离线扫过,两场整场、九段切片、原神两场、《魔裁》五部共 16 份产物在
1366
+ 0.3–0.5 之间挑中的主轨完全相同;被接受的真主轨 `moving_share` 最高 0.048,0.5 离阅读面板只剩 0.036,
1367
+ 0.3 两边的余量都大得多(game-text-corpus 报告、docs/architecture/defaults.md)。"""
1368
+
1369
+
1370
+ def pick_main(report: list[tuple[int, str, dict]],
1371
+ min_score: float = 0.0,
1372
+ move_max: float = MAIN_MOVE_MAX) -> tuple[tuple | None, list[tuple]]:
1373
+ """从区域里挑主轨,挑不出来就**明说没有**。返回 `(选中的, 候选列表)`。
1374
+
1375
+ 为什么不是 `max(primary_score)`(这是 2026-09-08 改的):
1376
+
1377
+ * 旧写法只要 `report` 非空就无条件指一个主轨,**哪怕所有区域都是 0 分**——
1378
+ 菜单、片头、纯 UI 的片段也会被指一条送去当正文匹配,错误再沿链放大到
1379
+ 挑名牌、并同带碎片(methodology-audit-4 报告 C3)。
1380
+ * 换素材实测:有台词的 5 段里错了 2 段(gamestream-first-look 报告)。
1381
+ `hsr-s2` 挑中主播自己压的中英双语字幕、`zzz-s2` 挑中屏幕顶部的角色名带,
1382
+ 而且**错的那两条分数比对的还高**(11.5 / 25.6 对 5.4 / 6.0)——
1383
+ 单靠调分数的权重救不回来。
1384
+
1385
+ 修法**不引入新常数**:`label_region` 里的 `subtitle` / `dialogue-or-caption`
1386
+ 已经编码了"在画面下部、够宽、每条几秒"这些形状条件,只是 `primary_score`
1387
+ 从来没用过它。把它当**接受判据**(分数排名照旧)之后,10 个有真值的素材
1388
+ (5 段游戏切片 + yuka 五部)**全部选对**。
1389
+
1390
+ `move_max`(`--main-move-max`,默认 `MAIN_MOVE_MAX` = 0.3;1.0 = 不拦)是第二道接受判据:
1391
+ 在动的区域不当主轨(text-motion 计划第 3 步)。滚动的东西——片尾表、
1392
+ ticker、评论墙、阅读面板、实时弹幕——都不是用途 2 要的那条正文轨;而它一旦当上主轨,
1393
+ 整部片子的匹配就全废(星铁 / 绝区零整场实测,见 `MAIN_MOVE_MAX`)。
1394
+ """
1395
+ # `move_max >= 1.0` = **关**。别写成 `share < move_max`:`moving_share` 本身能到 1.0,
1396
+ # 于是"整块都在动"的区域在关着的时候也被排除了——关掉的旋钮不许改行为。
1397
+ cand = [x for x in report
1398
+ if x[2]["primary_score"] > max(0.0, min_score) and x[1] in MAIN_LABELS
1399
+ and (move_max >= 1.0 or x[2].get("moving_share", 0.0) < move_max)]
1400
+ cand.sort(key=lambda x: -x[2]["primary_score"])
1401
+ return (cand[0] if cand else None), cand
1402
+
1403
+
1404
+ def region_features(rs: list[Run], n_tracks: int, W: int, H: int,
1405
+ dur_us: int, active_us: int, persistence: float) -> dict:
1406
+ covered = uigate.merge_intervals([(r.t_start, r.t_end) for r in rs])
1407
+ on_us = sum(e - s for s, e in covered)
1408
+ texts = [r.text for r in rs]
1409
+ durs = [(r.t_end - r.t_start) / 1e6 for r in rs]
1410
+ lang, lang_mix, letter_frac = region_language(texts)
1411
+ return {
1412
+ "n_tracks": n_tracks,
1413
+ "n_runs": len(rs),
1414
+ # fill 是"占整片多久",fill_active 是"在它出现的那些窗里占多久"。
1415
+ # 长片上必须分开看:片头标题的 fill 只有 0.002,fill_active 却接近 1,
1416
+ # 它不是噪声,只是**存在期很短**。只看 fill 会把它当噪声扔掉。
1417
+ "fill": round(on_us / max(1, dur_us), 3),
1418
+ "fill_active": round(on_us / max(1, active_us), 3),
1419
+ "persistence": round(persistence, 3),
1420
+ "dominant_share": round(dominant_text_share(rs), 3),
1421
+ "time_text_ratio": round(sum(bool(TIME_RE.search(t)) for t in texts) / max(1, len(texts)), 3),
1422
+ "distinct_text_ratio": round(len(set(texts)) / max(1, len(texts)), 3),
1423
+ # 带 `move` 原语的 run 占比(text-motion 计划)。落进产物是有意的:
1424
+ # 用途 1 要知道这块在不在动,`pick_main` 的第二道接受判据也读它。
1425
+ "moving_share": round(sum(1 for r in rs if r.moving or r.scrolling)
1426
+ / max(1, len(rs)), 3),
1427
+ "median_dur_s": round(statistics.median(durs), 2),
1428
+ "cx": round(statistics.median(r.cx for r in rs) / W, 3),
1429
+ "cy": round(statistics.median(r.cy for r in rs) / H, 3),
1430
+ "w": round(statistics.median(r.w for r in rs) / W, 3),
1431
+ "h": round(statistics.median(r.h for r in rs) / H, 4),
1432
+ "lang": lang,
1433
+ "lang_mix": lang_mix,
1434
+ "letter_frac": letter_frac,
1435
+ # 端点用 2%/98% 分位,不用 min/max——与 region_geom 同一个理由
1436
+ # (predet 报告踩过的坑):min/max 会被离群框撑大。这里不是纯统计洁癖,
1437
+ # y_span 直接喂 label_region 的 text-wall 判据,**一个离群 run 就能把主字幕带
1438
+ # 打成 text-wall**,primary_score 随即归 0、主轨凭空消失。
1439
+ "y_span": round((pct([r.cy for r in rs], 0.98) - pct([r.cy for r in rs], 0.02)) / H, 3),
1440
+ "x_span": round((pct([r.box[2] for r in rs], 0.98)
1441
+ - pct([r.box[0] for r in rs], 0.02)) / W, 3),
1442
+ }
1443
+
1444
+
1445
+ def label_region(f: dict, min_runs: int) -> str:
1446
+ """v1 启发式判读。故意写得直白,方便看清是哪条规则在起作用。
1447
+
1448
+ 按 owner 的方向:**不求完备**,只把可能高价值的标出来。
1449
+ v1 相对 v0 的唯一实质变化是把"常驻"判据从 fill 换成 **fill_active × persistence**——
1450
+ 长片上 fill 会被"存在期短"稀释,水印和片头标题会被混为一谈。
1451
+ """
1452
+ # 噪声 = 又少又短。**只看条数会误杀常驻区域**:一个纹丝不动的时钟文本
1453
+ # 只会产生 1 条 run,却铺满整段——那是最不像噪声的东西。
1454
+ # 单独出现一次的真文字(台词、道具名、奖励标题)也会落进来(抽样约五分之一);
1455
+ # "最长 run 内容字 ≥5 就不判 noise"量过:用途 2 的覆盖 11 组全不变,没收益,没留。
1456
+ if f["n_runs"] < min_runs and f["fill_active"] < 0.2:
1457
+ return "noise"
1458
+ resident = f["fill_active"] > 0.70 and f["persistence"] > 0.5 # 常驻 = 窗内满 且 大多数窗都在
1459
+ if resident and f["time_text_ratio"] >= 0.5:
1460
+ return "clock" # 常驻 + 文本长得就像时间,比统计 churn 可靠
1461
+ if resident and f["dominant_share"] > 0.50:
1462
+ return "static-overlay" # 水印 / 台标 / 常驻 UI(允许 OCR 抖动)
1463
+ if f["n_tracks"] >= 5 and f["y_span"] > 0.25 and f["x_span"] < 0.40:
1464
+ return "text-wall" # 滚动评论墙 / 弹幕列
1465
+ # 时长**只剩下限**,和 `primary_score` 保持同一口径。
1466
+ # 这里原来是 12、那边是 15,两层说同一件事却用两个数,代价是实测踩到的:
1467
+ # 原神某段对话节奏慢,主轨 cue 时长中位 14.5 s,于是**分数认它、label 不认它**,
1468
+ # 判成 misc,按 label 做接受判定时就把真主轨挡在门外(gamestream-first-look.md)。
1469
+ # 09-08 先对齐到 15(2,154 个区域里 62 个换标签),当天又发现**对齐还不够**:
1470
+ # 15 是个悬崖,同一段素材换个窗口长度就能从 14.5 s 挪到 16.2 s,主轨凭空消失。
1471
+ # 上限的职责"排掉常驻"由上面的 resident 判据承担,这里不再重复一遍。
1472
+ if f["cy"] > 0.60 and f["w"] > 0.12 and f["median_dur_s"] >= 0.3 and f["n_runs"] >= 3:
1473
+ return "subtitle" # 下方、宽、条目多
1474
+ if 0.35 < f["cy"] <= 0.95 and f["n_runs"] >= 3:
1475
+ return "dialogue-or-caption"
1476
+ if f["h"] < 0.03 and f["n_runs"] <= 3:
1477
+ return "ui-static"
1478
+ return "misc"
1479
+
1480
+
1481
+ # ---------- 输出 ----------
1482
+
1483
+ srt_ts = srtio.fmt_ts_us
1484
+ """时间戳格式与"一条 = 一个 cue"的口径统一在 `flowocr.artifacts.srtio`。
1485
+ 历史上 5 个工具各写各的解析,导致 "条数" 被 shell 里一个写错的 grep 破坏了 30 小时
1486
+ (methodology-audit 报告)。"""
1487
+
1488
+
1489
+ def segment_runs(rs: list[Run]) -> list[tuple[int, int, list[Run]]]:
1490
+ """把一个区域内互相重叠的 run 切成不重叠的时间段,每段带当时在场的所有行。
1491
+
1492
+ 这是"字幕该长什么样"的定式:说话人名 + 台词同时在屏上,就该是一条两行的字幕,
1493
+ 而不是两条时间重叠的条目。
1494
+ """
1495
+ bounds = sorted({t for r in rs for t in (r.t_start, r.t_end)})
1496
+ out: list[tuple[int, int, list[Run]]] = []
1497
+ for s, e in zip(bounds, bounds[1:]):
1498
+ active = [r for r in rs if r.t_start <= s and r.t_end >= e]
1499
+ if active:
1500
+ active.sort(key=lambda r: (r.cy, r.cx))
1501
+ if out and [id(x) for x in out[-1][2]] == [id(x) for x in active] and out[-1][1] == s:
1502
+ out[-1] = (out[-1][0], e, active) # 内容没变就接上,别切碎
1503
+ else:
1504
+ out.append((s, e, active))
1505
+ return out
1506
+
1507
+
1508
+ def merge_growing_segments(segs: list[tuple[int, int, list[Run]]]
1509
+ ) -> list[tuple[int, int, list[Run]]]:
1510
+ """把"行只增不减"的相邻段并成一条。
1511
+
1512
+ 打字机让同一条字幕的各行**错开出现**:说话人名先出、第一行再出、第二行最后出。
1513
+ `segment_runs` 在每个 run 的首尾都切一刀,于是一条字幕被切成三条,前两条是半截。
1514
+ 整片实测这类半截占了"比望言多出的条目"的一大半。
1515
+
1516
+ 判据只用集合关系,不用任何时长常数:**前一段的行是后一段的真子集且时间相接**,
1517
+ 就是"又多出来一行",属于同一条字幕。反过来(行减少)说明这条字幕在收尾,
1518
+ 不并——那才是真的换条。
1519
+ """
1520
+ out: list[tuple[int, int, list[Run]]] = []
1521
+ for s, e, active in segs:
1522
+ if out:
1523
+ ps, pe, prev = out[-1]
1524
+ ids, pids = {id(r) for r in active}, {id(r) for r in prev}
1525
+ if pe == s and pids < ids:
1526
+ out[-1] = (ps, e, active)
1527
+ continue
1528
+ out.append((s, e, active))
1529
+ return out
1530
+
1531
+ def group_cues(rs: list[Run], end_tol_us: int, min_overlap: float = 0.5
1532
+ ) -> list[tuple[int, int, list[Run]]]:
1533
+ """按"一起消失"把行并成一条字幕。
1534
+
1535
+ `segment_runs` 在每个 run 的首尾都切一刀,对**打字机**是灾难:
1536
+ 说话人名先出、第一行再出、第二行最后出,一条字幕就被切成三条,
1537
+ 前两条都是半截。整片实测这类碎片占了"比望言多出的条目"的一大半
1538
+ (`[クり` / `[クリス]` / `[クリス] + 全句` 各自成条)。
1539
+
1540
+ 换个定式:**同一条字幕的所有行是一起消失的**,出现却可以错开。
1541
+ 于是按 t_end 分组、组内取 min(t_start) 作起点——
1542
+ 起点自然就是 owner 说的"首字出现"那一态(text-effects 报告)。
1543
+
1544
+ 容差 `end_tol_us` 挂在**采样间隔**上,不是拍脑袋的毫秒数:
1545
+ 两行若在同一个采样点上还在、下一个采样点都没了,就是一起消失的。
1546
+ """
1547
+ cues: list[dict] = []
1548
+ for r in sorted(rs, key=lambda x: (x.t_end, x.t_start)):
1549
+ hit = None
1550
+ for c in reversed(cues):
1551
+ if c["end"] < r.t_end - end_tol_us:
1552
+ break # 按 t_end 排过序,再往前只会更远
1553
+ ov = min(r.t_end, c["end"]) - max(r.t_start, c["start"])
1554
+ # 除以**较长**的那个,不是较短的:一条 3 秒台词落在一条 100 秒常驻行里,
1555
+ # 除以较短的会得到 1.0(全包含),把常驻行当成同一条字幕并进来——
1556
+ # 实测这样干起点误差 P90 从 1.0s 炸到 19.5s。除以较长的则是 0.03,正确排除。
1557
+ # 同一条字幕的各行只差一个打字的前导时间,时长本来就相当。
1558
+ if ov > 0 and ov / max(r.t_end - r.t_start, c["end"] - c["start"], 1) >= min_overlap:
1559
+ hit = c
1560
+ break
1561
+ if hit is None:
1562
+ cues.append({"start": r.t_start, "end": r.t_end, "runs": [r]})
1563
+ else:
1564
+ hit["runs"].append(r)
1565
+ hit["start"] = min(hit["start"], r.t_start)
1566
+ hit["end"] = max(hit["end"], r.t_end)
1567
+ out = []
1568
+ for c in sorted(cues, key=lambda c: (c["start"], c["end"])):
1569
+ c["runs"].sort(key=lambda r: (r.cy, r.cx))
1570
+ out.append((c["start"], c["end"], c["runs"]))
1571
+ return out
1572
+
1573
+ def drop_slivers(segs: list[tuple[int, int, list[Run]]], min_us: int) -> list[tuple[int, int, list[Run]]]:
1574
+ """丢掉过短的时间段。
1575
+
1576
+ 边界回抠之后,同一区域里两条 run 的首尾不再对齐到同一个采样点,
1577
+ 于是段切分会在交界处留下几十毫秒的碎片。它们不是新内容,只是切分的副产品。
1578
+
1579
+ 直接丢弃、留一个小空隙,而不是并进邻居:并进去会把邻居的边界推走 min_seg 那么多,
1580
+ 丢弃最多只损失碎片自身的长度。实测(zh-sub)并进邻居会让终点误差中位从 0.117 s
1581
+ 退回 0.248 s,丢弃则不会。
1582
+ """
1583
+ return [(s, e, a) for s, e, a in segs if e - s >= min_us] or list(segs[:1])
1584
+
1585
+
1586
+ UI_SHARE = 0.5
1587
+ """判"常驻 UI"的占比门(`--ui-share` 的默认值)。**别在别处再写一个 0.5**——
1588
+ `track_health` 的报警门要严格低于它,那边 `from build_tracks import UI_SHARE` 读这一份
1589
+ (2026-09-19 复审:那边原来另写了个字面量,注释还说"只跟着这边读")。"""
1590
+
1591
+
1592
+ def persistent_ui_lines(segs, min_share: float, max_len: int = 6,
1593
+ min_support: int = 20, min_onshare: float = 0.0,
1594
+ film_us: int = 0) -> set[str]:
1595
+ """判为"常驻 UI"的那些短行。抽出来是为了**能把删了什么打印出来**。
1596
+
1597
+ `min_onshare` 是**全片尺度**的地板(默认 0 = 关,行为和以前逐字节一致):
1598
+ 含这一行的段总时长 / 全片时长。加它的理由见 `drop_persistent_ui` 的注释里
1599
+ 那段"整片上塌了";**别在没跑五部头对头之前打开它**。
1600
+ """
1601
+ if not segs or min_share <= 0:
1602
+ return set()
1603
+ cnt: Counter[str] = Counter()
1604
+ on: Counter[str] = Counter()
1605
+ for s, e, active in segs:
1606
+ # 一条 cue 里同一行出现两次只算一次,否则打字机重复会把数撑起来
1607
+ lines = {r.text.strip() for r in active if 0 < len(r.text.strip()) <= max_len}
1608
+ cnt.update(lines)
1609
+ for t in lines:
1610
+ on[t] += e - s
1611
+ keep = {k for k, v in cnt.items() if v / len(segs) >= min_share and v >= min_support}
1612
+ if min_onshare > 0:
1613
+ # film_us 拿不到就**不做这道过滤**,而不是拿区域自己的跨度顶替——
1614
+ # 那样又会退回"分母是区域"的老毛病(gamestream-first-look 报告)。
1615
+ if film_us > 0:
1616
+ keep = {k for k in keep if on[k] / film_us >= min_onshare}
1617
+ else:
1618
+ raise ValueError("给了 min_onshare 却没给 film_us——这道地板是全片尺度的")
1619
+ return keep
1620
+
1621
+
1622
+ def drop_persistent_ui(segs, min_share: float = 0.5, max_len: int = 6,
1623
+ min_support: int = 20, min_onshare: float = 0.0,
1624
+ film_us: int = 0):
1625
+ """把**常驻 UI 短行**从每条 cue 里剔掉(`Auto` / `Skip` / 图标误识成的单字…)。
1626
+
1627
+ 为什么需要(head2head-5films 报告):这类按钮常年挂在屏幕上、位置固定、
1628
+ 字号和正文接近,聚类没有理由把它和正文分开。实测 yuka `input3` 的主轨里
1629
+ **97.5% 的 cue 带着 `Auto`**(5,714/5,873),`input5` 是 18.4%(`大`/`New`/`Game`),
1630
+ 而人手框的基线是 0.0–0.2%。
1631
+
1632
+ 判据**不写死词表**(写死了换个游戏就没用):某个短行作为独立一行出现在
1633
+ 超过 `min_share` 比例的 cue 里,就不可能是台词。
1634
+
1635
+ **阈值不能低。** 0.2 会把说话人名牌一起判进去(`階堂ヒロ` 在 f3 占 27%、f5 占 28%),
1636
+ 而名牌该不该留是另一回事(speaker-name-line 报告)。默认 0.5:
1637
+ UI 按钮是常年挂着的,名牌因为要轮换说话人,天然到不了一半。
1638
+
1639
+ **`min_support` 是绝对条数下限,不能省**(methodology-audit-2 报告):
1640
+ 只看比例的话,1 条 cue 的小区域里任何 ≤6 字的行都是 100%,**必被整片清空**。
1641
+ 实测代价:加这条规则之后 yuka 五部的产物里出现了 89–139 个**空的区域 SRT**
1642
+ (之前是 0),`zh-news` 60 秒片段上一旋钮 A/B 删掉 16% 的 cue、
1643
+ 23 个区域清空 11 个——里面有台标 `CCTV2` 和一条像人名条的 `李浩`。
1644
+ 主轨感觉不到(它的 UI 行出现几千次),**用途 1 要的正是那些小区域**。
1645
+ 默认 20:`Auto` 在 f3 出现 5,714 次、`大`/`New` 在 f5 约 700 次,离 20 都很远。
1646
+
1647
+ 实测(input3 整片,过同一个 match_ref 再对剧本):
1648
+ 命中 4,917 -> **4,934**,疑似真漏 97 -> **87**。
1649
+ f1/f2/f5 上这条规则**一行都不剔**(它们的常驻短行都够不着 0.5)。
1650
+ (注:f1 的产物仍然变了,但那是同一批改动里 `estimate_shift` 那条的效果,
1651
+ 不是这条——两条一起改、只对了一次账,是我的疏忽。)
1652
+
1653
+ **已知的假阳性**:真台词如果就是同一句短话、还占了过半的 cue,会被误删。
1654
+ 写测试时踩到过(拿同一句台词复制 10 遍当样本,它自己被判成了 UI)。
1655
+ 真实素材上不太可能——过半的 cue 说同一句 6 字以内的话,那本来也不像对话轨。
1656
+
1657
+ **"名牌天然到不了一半"这句在整片上塌了**(2026-09-08,
1658
+ gamestream-first-look 报告):`share` 的分母是**这个区域自己的 cue 数**,
1659
+ 一个只在某几场戏里存在的小区域,里头任何一行都轻易过半;`min_support 20`
1660
+ 在 3 小时素材上也拦不住一个次要角色。实测原神两场整片把
1661
+ `カチーナ` / `ムアラニ` / `ラウマ` 都当成常驻 UI 删了(一个区域掉 514 条 cue)。
1662
+ **主轨没被咬、`tracks.json` 不过滤,所以两个产物都没受影响**——是记账,不是急修。
1663
+
1664
+ 候选修法是 `min_onshare`(全片在场占比的地板,默认关)。
1665
+ **它不是免费的**:一次性探针 `probe_ui_burst.py` 量过,0.3 的地板在四部整片上
1666
+ 只留下 `Auto`(yuka f3/f5)和 `Q`(gi2),把这条规则现在删的东西放过 97%——
1667
+ 包括 docstring 上面记着有收益的那些。**要开先跑五部头对头。**
1668
+ """
1669
+ ui = persistent_ui_lines(segs, min_share, max_len, min_support, min_onshare, film_us)
1670
+ if not ui:
1671
+ return segs
1672
+ out = []
1673
+ for s, e, active in segs:
1674
+ keep = [r for r in active if r.text.strip() not in ui]
1675
+ if keep:
1676
+ out.append((s, e, keep))
1677
+ return out
1678
+
1679
+
1680
+ BODY_MIN_CHARS = 3
1681
+ """`--srt-mode body` 里能当切点的行至少几个**内容字**——owner 三档里"有内容 ≥3 字"那条线,不是新常数。"""
1682
+
1683
+
1684
+ def body_segments(rs: list[Run], end_tol_us: int = 500_000) -> list[tuple[int, int, list[Run]]]:
1685
+ """**按正文行切段**(`--srt-mode body`):只有正文行的首尾是切点,别的行按时间重叠挂进去。
1686
+
1687
+ `segment` 在**每个** run 的首尾都切一刀,于是这些不是正文的东西也在切 cue
1688
+ (game-text-corpus:整场 zzz 相邻 cue 近一半正文逐字相同;hsr 5591 s 一句切成五条):
1689
+ * 名牌被 OCR 每帧读成不同的样子(`・・ビリー・・` / `ビリー` / `• ビU— •`),一变就是一条新 run;
1690
+ * ▼ 翻页提示半秒一闪(读成 `回` / `A`);
1691
+ * 打字机首帧的 1–2 字碎片。
1692
+
1693
+ **正文行(锚)** = 内容 ≥ `BODY_MIN_CHARS` 字、而且同一时刻下方没有另一条有内容的行的 run——
1694
+ 名牌、称号、两行正文的上一行都在它上方,不当锚;碎片和闪烁的提示内容不够,也不当锚。
1695
+ * 锚的起点往前放宽到"和它重叠、比它先出现的非锚行"的最早起点(打字机先出名牌 / 先出第一行,
1696
+ cue 起点仍是首字出现),但不越过同一区域里上一个锚的终点(同一个说话人连说两句时,名牌那条 run 横跨两句);
1697
+ * 一个锚都不沾的非锚行(没有名牌的纯标点台词、孤立的 UI)用它自己的首尾切;
1698
+ * 段里的行 = 在这段里出现过的全部 run(不要求盖满整段:闪烁的提示只盖住一部分),按 (cy, cx) 排;
1699
+ * 相邻两段锚集合相同、或者前一段的锚是后一段的真子集(又打出来一行)就接上。
1700
+ """
1701
+ if not rs:
1702
+ return []
1703
+ order = sorted(rs, key=lambda r: (r.t_start, r.t_end))
1704
+ content = {id(r) for r in rs if evalkit.content_len(r.text) >= BODY_MIN_CHARS}
1705
+
1706
+ # 时间上重叠的对:扫一遍,活动表里只留还没结束的
1707
+ pairs: list[tuple[Run, Run]] = []
1708
+ active: list[Run] = []
1709
+ for r in order:
1710
+ active = [o for o in active if o.t_end > r.t_start]
1711
+ pairs.extend((o, r) for o in active)
1712
+ active.append(r)
1713
+
1714
+ def height(r: Run) -> float:
1715
+ return r.box[3] - r.box[1]
1716
+
1717
+ def dur(r: Run) -> int:
1718
+ return max(1, r.t_end - r.t_start)
1719
+
1720
+ def ov(a: Run, b: Run) -> int:
1721
+ return min(a.t_end, b.t_end) - max(a.t_start, b.t_start)
1722
+
1723
+ above: set[int] = set() # 同一时刻下方有另一条有内容的行
1724
+ fragment: set[int] = set() # 同一行里被一条更长久的有内容行盖住的碎片
1725
+ overlaps: dict[int, list[Run]] = {}
1726
+ for a, b in pairs:
1727
+ overlaps.setdefault(id(a), []).append(b)
1728
+ overlaps.setdefault(id(b), []).append(a)
1729
+ # 还要 x 上有重叠:名牌居中压在正文上方、两行正文上下对齐;角落里的 UID 水印不算"在上方"
1730
+ if id(a) in content and id(b) in content and min(a.box[2], b.box[2]) > max(a.box[0], b.box[0]):
1731
+ h = 0.5 * min(height(a), height(b))
1732
+ # 两行要**互相**陪着至少各自一半时长才算"上一行 / 下一行"(重叠 ≥ 较长那条的一半):
1733
+ # ▼ 翻页提示被读成 `un2ra` 只闪 1 秒,不许把正文判成上一行;
1734
+ # 常驻的 HUD(`Lv.90`、血条 `19000/24829`)压在正文下面几十秒,也不许——
1735
+ # body 臂第二轮 gi1 丢的 8 行里 4 行是这个:正文被判成上一行、HUD 当了锚,正文盖不住半段被丢掉
1736
+ if a.cy < b.cy - h:
1737
+ if ov(a, b) >= 0.5 * max(dur(a), dur(b)):
1738
+ above.add(id(a))
1739
+ elif b.cy < a.cy - h:
1740
+ if ov(a, b) >= 0.5 * max(dur(a), dur(b)):
1741
+ above.add(id(b))
1742
+ else:
1743
+ # 同一行:短命的那条是碎片(HUD 数字 `15m` 在正文行上闪一下),不当锚
1744
+ short, long_ = (a, b) if dur(a) < dur(b) else (b, a)
1745
+ if dur(short) < 0.5 * dur(long_):
1746
+ fragment.add(id(short))
1747
+ anchors = sorted((r for r in rs if id(r) in content and id(r) not in above and id(r) not in fragment),
1748
+ key=lambda r: (r.t_start, r.t_end))
1749
+ is_anchor = {id(r) for r in anchors}
1750
+
1751
+ def squash(s: str) -> str:
1752
+ return "".join(s.split())
1753
+
1754
+ def attached(o: Run, a: Run) -> bool:
1755
+ """非锚行 o 和锚 a **真的同屏过**:重叠至少占 o 自己时长的一半。只是首尾挨着(run 末帧补的那半个
1756
+ 采样间隔)不算——否则一句 1–2 字的短台词(`はあ…` / `うむ…`)会被下一句的锚吞进去
1757
+ (body 臂第一版在 gi1 丢的 6 行里有 4 行是这个形状)。"""
1758
+ return ov(o, a) >= 0.5 * dur(o)
1759
+
1760
+ # **打字机首帧**:o 正好在锚 a 出现的那一刻消失,而且 a 的正文以 o 开头(hsr 5591 s 的 `知`)。
1761
+ # 两者时间上不重叠,进不了 `overlaps`,单独按"a 的起点 = o 的终点"找
1762
+ by_start: dict[int, list[Run]] = {}
1763
+ for a in anchors:
1764
+ by_start.setdefault(a.t_start, []).append(a)
1765
+ lead_of: dict[int, list[int]] = {} # 锚 id -> 它的打字机首帧的起点
1766
+ is_lead: set[int] = set()
1767
+ for o in rs:
1768
+ if id(o) in is_anchor:
1769
+ continue
1770
+ for a in by_start.get(o.t_end, ()):
1771
+ if o.t_start < a.t_start and squash(a.text).startswith(squash(o.text)):
1772
+ lead_of.setdefault(id(a), []).append(o.t_start)
1773
+ is_lead.add(id(o))
1774
+
1775
+ # 各自成句的非锚行(没挂在任何锚上:没有名牌的短台词)。锚的起点往前放宽时不许越过它们——
1776
+ # 名牌横跨 `はあ…` 和下一句时,没有这道闸,下一句的起点会被名牌一路拉回到 `はあ…` 的开头
1777
+ solo = [r for r in rs if id(r) not in is_anchor and id(r) not in is_lead
1778
+ and not any(id(o) in is_anchor and attached(r, o) for o in overlaps.get(id(r), ()))]
1779
+ barriers = sorted(anchors + solo, key=lambda r: r.t_start)
1780
+ start_of: dict[int, int] = {}
1781
+ j, reach = 0, -1
1782
+ for a in anchors:
1783
+ while j < len(barriers) and barriers[j].t_start < a.t_start:
1784
+ reach = max(reach, barriers[j].t_end)
1785
+ j += 1
1786
+ floor = min(reach, a.t_start)
1787
+ early = [o.t_start for o in overlaps.get(id(a), ())
1788
+ if id(o) not in is_anchor and o.t_start < a.t_start and attached(o, a)]
1789
+ early += lead_of.get(id(a), [])
1790
+ start_of[id(a)] = max(min([a.t_start, *early]), floor) if early else a.t_start
1791
+
1792
+ bounds = {t for a in anchors for t in (start_of[id(a)], a.t_end)}
1793
+ bounds |= {t for r in solo for t in (r.t_start, r.t_end)}
1794
+ span = {id(r): (start_of.get(id(r), r.t_start), r.t_end) for r in rs}
1795
+
1796
+ segs: list[tuple[int, int, list[Run], frozenset[int]]] = []
1797
+ run_of = {id(r): r for r in rs}
1798
+ bs = sorted(bounds)
1799
+ by_start = sorted(rs, key=lambda r: span[id(r)][0])
1800
+ k, live = 0, []
1801
+ for s, e in zip(bs, bs[1:]):
1802
+ while k < len(by_start) and span[id(by_start[k])][0] < e:
1803
+ live.append(by_start[k])
1804
+ k += 1
1805
+ live = [r for r in live if span[id(r)][1] > s]
1806
+ here = list(live)
1807
+ if not here:
1808
+ continue
1809
+ key = frozenset(id(r) for r in here if id(r) in is_anchor)
1810
+ # 没有锚的段不往后并:`frozenset() < {锚}` 恒成立,并了就把前面那句短台词吞进下一句。
1811
+ # "又打出来一行"要求新锚和旧锚**一起消失**(容差同 group_cues:同一条字幕的各行一起消失)——
1812
+ # 常驻 HUD(`Lv.90`)当锚挂几十秒,正文一出现就被当成"HUD 那条字幕的第二行"并进去;
1813
+ # 两个对话框前后脚交替(zzz 2899 s)也会这样并成一条
1814
+ grows = (segs and segs[-1][3] and segs[-1][3] < key
1815
+ and all(abs(run_of[i].t_end - run_of[j].t_end) <= end_tol_us
1816
+ for i in segs[-1][3] for j in key - segs[-1][3]))
1817
+ if segs and segs[-1][1] == s and (segs[-1][3] == key or grows):
1818
+ ps, _, prev, _ = segs[-1]
1819
+ seen = {id(x) for x in prev}
1820
+ segs[-1] = (ps, e, prev + [r for r in here if id(r) not in seen], key)
1821
+ continue
1822
+ segs.append((s, e, here, key))
1823
+
1824
+ def keep(r: Run, s: int, e: int) -> bool:
1825
+ # 非锚行盖不住这段一半的不挂进来:闪烁的 ▼ 提示、打字机首帧碎片、HUD 数字(`15m`)、
1826
+ # 名牌 / 水印被读成几种的抖动版(`NICAL` / `AICAL`、`•• •`)。第一版只滤 1–2 字的,
1827
+ # 3 字以上的抖动版全挂进来,cue 开头垫了 4 行,匹配器的读法(最多去掉开头 3 行)够不着正文。
1828
+ # 名牌、两行正文的上一行、常驻水印都盖满整段,照留;没有锚的段里它自己就是整段,照留
1829
+ if id(r) in is_anchor:
1830
+ return True
1831
+ inside = min(r.t_end, e) - max(r.t_start, s)
1832
+ # 盖不住半段、但自己**整条都在这段里、读到过 ≥3 次**的也留:那是一句真的短行
1833
+ # (名牌底下的 `…!!!`,body 臂第二轮 gi1 11064 s);闪烁的 ▼ / HUD / 抖动版只有 1–2 次观测
1834
+ return inside >= 0.5 * (e - s) or (inside >= 0.9 * (r.t_end - r.t_start) and r.n_obs >= 3)
1835
+ def dedupe(lines: list[Run]) -> list[Run]:
1836
+ # 同一段里文本逐字相同的几条 run 只留活得最久的那条:同一行字被 OCR 断成几条 run 时全挂进来,
1837
+ # cue 里就是 `1つ教えてあげるね` 连写三遍,匹配器的相似度被拖到门下(body 臂第三轮 hsr 5318 s 丢的那一行)
1838
+ best: dict[str, Run] = {}
1839
+ for r in lines:
1840
+ k = squash(r.text)
1841
+ if k not in best or dur(r) > dur(best[k]):
1842
+ best[k] = r
1843
+ return list(best.values())
1844
+
1845
+ return [(s, e, sorted(dedupe([r for r in lines if keep(r, s, e)] or lines), key=lambda r: (r.cy, r.cx)))
1846
+ for s, e, lines, _ in segs]
1847
+
1848
+
1849
+ def cue_segments(rs: list[Run], mode: str = "cue", min_seg_us: int = 150_000,
1850
+ end_tol_us: int = 500_000, min_overlap: float = 0.5
1851
+ ) -> list[tuple[int, int, list[Run]]]:
1852
+ """**切分**:一条轨上的 run 分成 cue。mode 四档:
1853
+
1854
+ cue(按一起消失分条)/ segment(每个首尾边界都切,默认)/ body(只按正文行切,见 `body_segments`)/
1855
+ raw(一条 run 一条)。
1856
+
1857
+ 抽出来是因为切分的结果现在要**落进 JSON**(`docs/architecture/artifacts.md`):
1858
+ 导出器只投影、不重新切——切两遍就会有两份不一样的"条数",而这个项目
1859
+ 在"一条是什么"上已经栽过一次 30 小时的账。
1860
+ **段里 run 的顺序就是导出时的行序**,别在别处重排。
1861
+ """
1862
+ if mode == "cue":
1863
+ return [(s, e, a) for s, e, a in group_cues(rs, end_tol_us, min_overlap)
1864
+ if e - s >= min_seg_us]
1865
+ if mode == "segment":
1866
+ return merge_growing_segments(drop_slivers(segment_runs(rs), min_seg_us))
1867
+ if mode == "body":
1868
+ return drop_slivers(body_segments(rs, end_tol_us), min_seg_us)
1869
+ return [(r.t_start, r.t_end, [r]) for r in sorted(rs, key=lambda r: r.t_start)]
1870
+
1871
+
1872
+ def split_hidden(rs: list[Run], hide: set[int], run_idx: dict[int, int], seg_kw: dict):
1873
+ """一条区域轨的切分:`hide` 里的 run(框位 UI、振り仮名——导出时都要剔的)和正文**分开切**,再按时间排回一份。
1874
+ 返回 `(全部段, 只有正文的段)`;`hide` 为空时两者是同一份 `cue_segments(rs)`。"""
1875
+ if not hide:
1876
+ segs = cue_segments(rs, **seg_kw)
1877
+ return segs, segs
1878
+ body = [r for r in rs if run_idx[id(r)] not in hide]
1879
+ hid = [r for r in rs if run_idx[id(r)] in hide]
1880
+ body_segs = cue_segments(body, **seg_kw)
1881
+ if not hid:
1882
+ return body_segs, body_segs
1883
+ return sorted(body_segs + cue_segments(hid, **seg_kw), key=lambda s: (s[0], s[1])), body_segs
1884
+
1885
+
1886
+ DROPPED_UI = run_srt.DROPPED_UI # 同一个 list 对象:run_srt 往里 append,main() 从这里读
1887
+ write_srt_segments = run_srt.write_srt_segments
1888
+ clear_stale_srts = run_srt.clear_stale_srts
1889
+
1890
+
1891
+ def write_srt(path: Path, rs: list[Run], mode: str = "cue", min_seg_us: int = 150_000,
1892
+ end_tol_us: int = 500_000, min_overlap: float = 0.5,
1893
+ ui_share: float = 0.5, ui_min_support: int = 20,
1894
+ ui_min_onshare: float = 0.0, film_us: int = 0) -> int:
1895
+ """切分 + 判 UI + 投影,一步到位。**`build_tracks` 自己不用它**(它要把中间结果
1896
+ 落进 JSON),留给回抠和探针这类"手上只有 run"的调用方。"""
1897
+ segs = cue_segments(rs, mode, min_seg_us, end_tol_us, min_overlap)
1898
+ ui = persistent_ui_lines(segs, ui_share, min_support=ui_min_support,
1899
+ min_onshare=ui_min_onshare, film_us=film_us)
1900
+ return write_srt_segments(path, segs, ui)
1901
+
1902
+
1903
+ SAMPLED_FULL_SHARE = 0.9
1904
+ """`sampled_full`:读到的内容字数达到最终文本的这么多,就算"全字出现"。留 10% 给 OCR 在最后几个字上的抖动。"""
1905
+
1906
+
1907
+ def sampled_full(r: Run) -> int | None:
1908
+ """打字机"全字出现"的**采样级**估计:第一票内容字数达到最终文本 `SAMPLED_FULL_SHARE` 的那个观测时刻。
1909
+
1910
+ 和事件的 `t_full` 不是一回事:`t_full` 只由回抠(refine_boundaries)逐帧量出来填,**不许拿别的顶替**
1911
+ (owner 指示 4 的打字机三态);这个是采样间隔精度的观测值,另起一个字段 `t_full_sampled`,
1912
+ 给"导出时还原打字机效果"这类不需要逐帧精度的用途(字幕稿的打字机判定,`flowocr.output.script.effect_times`)。
1913
+ 首票就读全了 = 没有打字过程,记成 `t_start`;滚动拼出来的文本(每票都只有一段)永远够不着,记 None。"""
1914
+ if not r.times or len(r.times) != len(r.texts):
1915
+ return None
1916
+ final = evalkit.content_len(r.text)
1917
+ if final == 0:
1918
+ return None
1919
+ for t, s in zip(r.times, r.texts):
1920
+ if evalkit.content_len(s) >= SAMPLED_FULL_SHARE * final:
1921
+ return min(max(t, r.t_start), r.t_end)
1922
+ return None
1923
+
1924
+
1925
+ def event_dict(r: Run, eid: int, region: int, with_texts: bool = False) -> dict:
1926
+ """一条 run 落成 JSON 里的一个**事件**(`output-format-plan(已归档)`)。
1927
+
1928
+ `t_full` 这里恒为 `None`:全字出现的时刻只有回抠量得出来,
1929
+ **不许拿 `t_start` 顶替**(owner 指示 4 的打字机三态,顶替就等于把三态压成两态)。
1930
+ """
1931
+ flags = []
1932
+ if r.moving:
1933
+ flags.append("moving")
1934
+ if r.scrolling:
1935
+ flags.append("scrolling")
1936
+ if r.clip_start is not None:
1937
+ flags.append("clipped_start") # 自选范围的时间关闭段截的,不是文字自然出现(ocr-regions 计划)
1938
+ if r.clip_end is not None:
1939
+ flags.append("clipped_end")
1940
+ if getattr(r, "nameplate", False):
1941
+ # **名牌**:框位侧的判据挑的(`tools/uigate.pick_nameplates`,ui-gate 计划)。
1942
+ # 和 `ui_filtered` 一样是**打标不删**——用途 1 要留着、用途 2 可以拿它并进正文。
1943
+ flags.append("nameplate")
1944
+ ev = {"id": eid, "region": region, "t_start": r.t_start, "t_full": None,
1945
+ "t_end": r.t_end, "box": r.box, "text": r.text, "conf": r.conf,
1946
+ "n_obs": r.n_obs,
1947
+ # 独立票:框级复用把上一帧的文本原样搬过来,那不是一次新读数(audit-4 C4)。
1948
+ # **全是沿用票时就是 0**,不许回退成 `n_obs`——那正好把"这条一次都没被重新
1949
+ # 识别过"这件事抹掉,而它恰恰是下游最该知道的。(投票时那个"没有独立票就
1950
+ # 退回全体"的回退是另一回事,那是为了不把文本清空。)
1951
+ "n_independent": sum(1 for x in r.reused if not x),
1952
+ "flags": flags}
1953
+ full = sampled_full(r)
1954
+ if full is not None:
1955
+ ev["t_full_sampled"] = full
1956
+ if r.moving: # 轨迹**只给 moving 的**(owner 2026-09-09)
1957
+ ev["boxes"] = [[t, b] for t, b in r.boxes]
1958
+ if with_texts:
1959
+ ev["texts"] = [[t, bool(u)] for t, u in zip(r.texts, r.reused)]
1960
+ return ev
1961
+
1962
+
1963
+ PANEL_MIN_LINES = 5
1964
+ PANEL_MIN_H = 0.15
1965
+ """**面板样**(对话记录 / 列表式长面板)的判据:一条 cue 有 ≥ `PANEL_MIN_LINES` 行、
1966
+ 且框并集高过画面高的 `PANEL_MIN_H`。
1967
+
1968
+ 为什么要这个标(matcher 计划):星铁的对话记录面板一屏列着好几句**旧台词**,
1969
+ 喂给匹配器时文本是真的、**时间是错的**(那句话是早先说的)。它在 hsr 上被聚类并进了
1970
+ `dialogue-or-caption`,**按 label 剔不掉**;按行数单独剔也不行("名牌 + 两行字幕"就是 3 行)。
1971
+
1972
+ 阈值是量出来的(a1-mask-reuse 实验里的 `panel_features.py`,hsr 整场):
1973
+
1974
+ | 判据 | 可疑认领背后的 cue | 换喂入新增命中背后的 cue | 主轨正文 cue |
1975
+ | --- | ---: | ---: | ---: |
1976
+ | 行数 ≥4 | 22/28 | 9/70 | 35/485 |
1977
+ | **行数 ≥5 且框高 > 0.15** | **20/28** | 6/70 | **4/485** |
1978
+ | 行数 ≥6 且框高 > 0.15 | 17/28 | 3/70 | 2/485 |
1979
+
1980
+ **只打标、不改任何投影**(artifacts.md 第 1 条:过滤不删事件、判定写进 JSON):
1981
+ 现有 SRT / ASS 逐字节不变,`scriptmatch --feed` 那边按这个标取舍。"""
1982
+
1983
+
1984
+ def panel_like(evs: list[dict], height: int) -> bool:
1985
+ """这条 cue 是不是"面板样"(见 `PANEL_MIN_LINES` / `PANEL_MIN_H`)。只看形状,不看文本。"""
1986
+ if len(evs) < PANEL_MIN_LINES or height <= 0:
1987
+ return False
1988
+ y0 = min(e["box"][1] for e in evs)
1989
+ y1 = max(e["box"][3] for e in evs)
1990
+ return (y1 - y0) / height > PANEL_MIN_H
1991
+
1992
+
1993
+ def cues_from_segments(track_id: str, segs: list[tuple[int, int, list[Run]]],
1994
+ eid_of: dict[int, int], events: list[dict] | None = None,
1995
+ height: int = 0) -> list[dict]:
1996
+ """把切分结果记成 cue 表。`events` 的顺序**就是**导出时的行序。
1997
+
1998
+ 给了 `events`(全量事件表)和 `height`(画面高)时顺便打 `panel_like` 标(见上)。
1999
+ """
2000
+ out = []
2001
+ for i, (s, e, a) in enumerate(segs):
2002
+ ids = [eid_of[id(r)] for r in a]
2003
+ cue = {"id": f"{track_id}c{i:05d}", "t_start": s, "t_end": e, "events": ids}
2004
+ if events is not None and panel_like([events[j] for j in ids], height):
2005
+ cue["panel_like"] = True
2006
+ out.append(cue)
2007
+ return out
2008
+
2009
+
2010
+ def text_ui_lines(segs, args, ui_kw: dict) -> set[str]:
2011
+ """文本那条常驻 UI 判据(逐轨)——**`--ui-gate footprint` 时真的关掉它**。
2012
+
2013
+ ⚠ 这个包装存在的唯一理由是审计七:`--ui-gate` 的帮助里写着
2014
+ "`footprint` = 只用框位那条",而区域轨和并带主轨都**无条件**调用
2015
+ `persistent_ui_lines`,于是 `footprint` 和 `both` 在现存产物上
2016
+ `events` / `tracks` / `regions` 逐键相同——**消融臂塌成了默认臂**。
2017
+ 正是 `ocr_ab.sh` 那次"默认一翻、空参数塌成同一条臂"的同形。
2018
+ """
2019
+ if args.ui_gate == "footprint":
2020
+ return set()
2021
+ return persistent_ui_lines(segs, args.ui_share, **ui_kw)
2022
+
2023
+
2024
+ def ui_footprints(runs: list[Run], frame_us: int, args, observable=None) -> set[int]:
2025
+ """**框位 + 时间占比**的常驻 UI 门,返回判中的 run 的下标。
2026
+
2027
+ 和现行的 `persistent_ui_lines` 两处不同(ui-gate 计划):
2028
+ ①**分母是时间**(在屏时长 ÷ 全片),不是 cue 数——读法变了它也不动;
2029
+ ②**按框位**,不按文本——于是每秒都在变的 HUD(`01:17:46`、`388`)也抓得到,
2030
+ 而 `max_len <= 6` 那条硬截断挡住的长文本 UI(`UID: 1300174139`)也够得着。
2031
+
2032
+ 判据在 `flowocr.analyze.uigate`(判据只有一份):静态 UI = 翻动小 + 内容字短;
2033
+ 动态 HUD = 数字多且短。两档都要求在屏久、框宽稳、**不是名牌**、**不在正文带里**。
2034
+ """
2035
+ if args.ui_gate == "text":
2036
+ return set()
2037
+ # `w = n_obs`:一条 run 合并了多少次观测——`churn` 的门是在 obs 那一侧标定的
2038
+ items = [uigate.Item(tuple(r.box), r.t_start, r.t_end, r.text, key=i, w=max(1, r.n_obs))
2039
+ for i, r in enumerate(runs)]
2040
+ if not items:
2041
+ return set()
2042
+ # ⚠ **`step` 是采样间隔,不是 run 时长的中位数**(审计七):原来传中位数,
2043
+ # 于是 `occupancy` 把每条短 run 都垫到中位数那么长,同一段占用拆得越碎占比越高。
2044
+ # run 的 `t_end` 本来就已经加过一个 `frame_us`,所以这里补齐是恒等的。
2045
+ step = max(1, frame_us)
2046
+ # ⚠ **分母是这份产物实际覆盖的跨度**,不是整片时长:窗口产物(`--start/--end` 切出来的
2047
+ # 20 分钟)上,拿 6.2 h 当分母会把 `Auto` 的 74.5% 算成 **4.0%**,门当然接不住——
2048
+ # 而 `Auto` 污染主轨那次翻车(293/595 条 cue)要接的正是它。整片产物上两者相同。
2049
+ dur_us = max(max(i.t1 for i in items) - min(i.t0 for i in items), 1)
2050
+ fps = uigate.footprints(items)
2051
+ # 自选范围(ocr-regions 计划):占比的分母是**这个框位看得见的时长**,关闭时段不算"UI 缺席"
2052
+ t_lo = min(i.t0 for i in items)
2053
+ ms = [(f, uigate.measure(f, dur_us if observable is None else max(1, observable(f.box, t_lo, t_lo + dur_us)), step))
2054
+ for f in fps]
2055
+ cands = [{"box": f.box, "n": m["n"]} for f, m in ms
2056
+ if m["share"] >= 0.02 and m["clen"] >= uigate.BODY_CLEN and m["dig"] < 0.3]
2057
+ bands = uigate.bands(cands)
2058
+ out: set[int] = set()
2059
+ for f, m in ms:
2060
+ m.update(uigate.relate(f.box, m["clen"], bands))
2061
+ # ⚠ **名牌标是唯一的事实来源**:这里的带是全片算的,而名牌标是**按 120 秒窗**挑的,
2062
+ # 两边各判一次就会打架——实测 wuwa-s2 上 UI 门咬掉了 11 条打了名牌标的 run
2063
+ # (`ヴェリーナ` 这种)。所以直接认那个标,不在这里重判。
2064
+ if any(getattr(runs[it.key], "nameplate", False) for it in f.items):
2065
+ continue
2066
+ if (m["share"] < args.ui_fp_share or m["wvar"] > args.ui_fp_wvar
2067
+ or uigate.is_nameplate(m) or m.get("in_band")):
2068
+ continue
2069
+ static_ui = m["churn"] <= args.ui_fp_churn and m["clen"] <= args.ui_fp_clen
2070
+ dyn_hud = m["dig"] >= 0.3 and m["clen"] <= 6
2071
+ if static_ui or dyn_hud:
2072
+ out |= {it.key for it in f.items}
2073
+ return out
2074
+
2075
+
2076
+ RUBY_H = (0.3, 0.75)
2077
+ """注音字高 / 正文字高的范围。日文注音一般是正文的一半(wuwa-s1 `こんれいいん` 28 px 对 `今令尹…` 40–44 px);两侧留宽,靠别的几条收紧。"""
2078
+
2079
+
2080
+ def ruby_runs(runs: list[Run]) -> set[int]:
2081
+ """⚠ 观察项(09-26 审计定级:中度过拟合嫌疑,defaults.md 1.30):判据有两条是按单个误标补的,常数没扫,调参和评估同一批素材。
2082
+
2083
+ **振り仮名**:紧贴在一条含汉字的正文行上方、字高约一半、全是假名的短 run,返回下标(followups「振り仮名当成行」)。
2084
+
2085
+ 不标的话注音是独立事件:主轨 SRT 里排在正文前单独成行、叠加里单独一小块、回抠后还会拿到几秒"打字机"。判据只看几何和文字系统,
2086
+ 不看素材:
2087
+ 1. 文本全是假名(去掉空白和 `ー・`),2–16 字(单个假名多是读残的碎片:wuwa-s2 面板里的 `を`、gi-s1 的 `ヤ`);
2088
+ 2. 同时在场(时间重叠 ≥ 自己时长的一半)的某条正文 run 含汉字,字高比在 `RUBY_H` 里;
2089
+ 3. 注音的下沿落在正文上沿上下(上方 0.8 个注音字高到下方 0.5 个之间),横向整个落在正文的 x 范围里(两侧各留一个注音字高);
2090
+ 4. 中心**不**和正文行的中心对齐(差不到半个注音字高):那是居中压在对话上方的名牌(zzz-s1 `リン`),注音压在具体的汉字上;
2091
+ 5. 注音横向覆盖的正文字符(按正文框等宽估)里汉字过半:真注音横跨的就是它注的那几个汉字(「けんてんかまえ」下是「懸天の構」),
2092
+ 片假名的名字 / 称号 / 任务列表项恰好落在一行含汉字的正文上方时,底下多半是假名(gi2-w1 名牌「バーテンダー」下是「すまない、」,
2093
+ hsr `スターゲイザー`;peer 审查 edb5cc4 第 1 条)。
2094
+ """
2095
+ def kana_only(t: str) -> bool:
2096
+ s = "".join(ch for ch in t if not ch.isspace() and ch not in "ー・")
2097
+ return 2 <= len(s) <= 16 and all(script_of(ch) == "kana" for ch in s)
2098
+
2099
+ cands = [i for i, r in enumerate(runs) if kana_only(r.text)]
2100
+ if not cands:
2101
+ return set()
2102
+ bucket_us = 10_000_000
2103
+ bodies: dict[int, list[int]] = {}
2104
+ for j, r in enumerate(runs):
2105
+ if any(script_of(ch) == "han" for ch in r.text):
2106
+ for k in range(r.t_start // bucket_us, r.t_end // bucket_us + 1):
2107
+ bodies.setdefault(k, []).append(j)
2108
+ out: set[int] = set()
2109
+ for i in cands:
2110
+ r = runs[i]
2111
+ dur = max(1, r.t_end - r.t_start)
2112
+ seen: set[int] = set()
2113
+ for k in range(r.t_start // bucket_us, r.t_end // bucket_us + 1):
2114
+ for j in bodies.get(k, ()):
2115
+ if j in seen or j == i:
2116
+ continue
2117
+ seen.add(j)
2118
+ b = runs[j]
2119
+ if min(r.t_end, b.t_end) - max(r.t_start, b.t_start) < 0.5 * dur:
2120
+ continue
2121
+ if not (RUBY_H[0] <= r.h / max(1.0, b.h) <= RUBY_H[1]):
2122
+ continue
2123
+ if not (b.box[1] - 0.8 * r.h <= r.box[3] <= b.box[1] + 0.5 * r.h):
2124
+ continue
2125
+ if r.box[0] < b.box[0] - r.h or r.box[2] > b.box[2] + r.h:
2126
+ continue
2127
+ if abs(r.cx - b.cx) < 0.5 * r.h:
2128
+ continue
2129
+ # 注音压在汉字上:按正文框等宽估出注音横向覆盖的是正文的哪几个字,其中汉字要过半
2130
+ n = len(b.text)
2131
+ cw = max(1.0, b.w) / max(1, n)
2132
+ k0, k1 = int((r.box[0] - b.box[0]) / cw), int((r.box[2] - b.box[0]) / cw)
2133
+ under = [ch for ch in b.text[max(0, k0):max(0, k1) + 1] if not ch.isspace()]
2134
+ if not under or 2 * sum(script_of(ch) == "han" for ch in under) < len(under):
2135
+ continue
2136
+ out.add(i)
2137
+ break
2138
+ if i in out:
2139
+ break
2140
+ return out
2141
+
2142
+
2143
+ RUN_SPECIFIC = ("outdir", "tag")
2144
+ """每次都不一样、不进默认指纹的选项。"""
2145
+
2146
+
2147
+ def defaults_fingerprint(ap: argparse.ArgumentParser) -> tuple[str, str]:
2148
+ """当前**默认值**的指纹:`(12 位 hash, 一行 k=v)`。
2149
+
2150
+ 为什么要它(audit-5):`SUFFIX` 为空的那条臂跑的是"当前默认",
2151
+ 而默认是会变的。改默认那次直接把 A/B 的基准臂**覆盖**掉了,三处文档里
2152
+ 引的数从产物里数不回来。日志里留下这一行,"默认变了"至少是可见的。
2153
+
2154
+ 列表**不手挑**:手挑的清单一定会漏掉下一个新旋钮。直接遍历 parser
2155
+ (argparse 没有公开的"列出所有选项"接口,只能读 `_actions`)。
2156
+ """
2157
+ items = sorted(f"{a.dest}={a.default}" for a in ap._actions
2158
+ if a.option_strings and a.dest not in ("help", "print_defaults")
2159
+ and a.dest not in RUN_SPECIFIC)
2160
+ line = " ".join(items)
2161
+ return hashlib.sha1(line.encode("utf-8")).hexdigest()[:12], line
2162
+
2163
+
2164
+ # `DIRTY_PATHS` / `git_head` / 指纹的实现 2026-09-21 搬进了 `flowocr.provenance`(文件头讲了为什么并成一份);
2165
+ # 这里只留带默认表的入口,`gamescript` / `scriptmatch` / `refine_boundaries` 仍借 `bt.code_fp` / `bt.git_head` 用。
2166
+
2167
+ CODE_FP_FILES = ("src/flowocr/analyze/build_tracks.py", "src/flowocr/artifacts/evalkit.py", "src/flowocr/artifacts/srtio.py", "src/flowocr/artifacts/tracksio.py",
2168
+ "src/flowocr/analyze/uigate.py", "src/flowocr/analyze/align.py", "src/flowocr/output/run_srt.py",
2169
+ "src/flowocr/provenance.py",
2170
+ # ⚠ 下面三个是**函数里懒 import** 的(自选范围的切点 / 可观察时长;`--cluster learned` / `lines`),同样决定产物——
2171
+ # 2026-09-23 审计前守卫只认顶格 import,两个都漏了。`flowocr.extensions` 是加载器,用户匹配器的身份记在 provenance 的 `matcher`
2172
+ "src/flowocr/extract/regions.py", "src/flowocr/analyze/slot_learned.py", "src/flowocr/analyze/slot_lines.py",
2173
+ "src/flowocr/analyze/slot_modes.py", "src/flowocr/analyze/pair_features.py", # slot_lines / slot_learned 顶格 import 的;模型另记在 cluster_fp
2174
+ # `--refine auto`(默认)在建轨末尾结算回抠(`settle_refine` 里懒 import);回抠自己的代码指纹
2175
+ # 另记在产物的 `boundary_refined.code_fp`(refine_boundaries.CODE_FP_FILES)
2176
+ "src/flowocr/analyze/refine_boundaries.py", "src/flowocr/output/export.py")
2177
+ """产出 `-tracks.json` 的代码:本文件 + 它 import 的本地模块,**仓库相对路径**。自检拿 import 行核这张表
2178
+ (顺着 shim 找真实现)。`provenance.py` 在表里是因为它被直接 import——它一变全部产物的指纹都变,
2179
+ 这是诚实的:写 provenance 的代码就是产物的一部分。"""
2180
+
2181
+
2182
+ REFINE_SALVAGE_END = True
2183
+ """回抠结算救回被回补作废、窗口凑齐的结尾证据(`--refine-salvage-end`,defaults.md 1.32)。
2184
+ 建轨、`refine_boundaries` 的两个入口、开发探针都读这一个值,离线探针对账的才是生产默认那条路。"""
2185
+
2186
+
2187
+ def code_fp(files: tuple[str, ...] = CODE_FP_FILES, root: Path | None = None) -> str:
2188
+ """`flowocr.provenance.code_fp` 带上默认表;`root` 只给自检用。"""
2189
+ return _code_fp(files, root)
2190
+
2191
+
2192
+ def cache_stale(doc: dict) -> str | None:
2193
+ """评测驱动判建轨缓存能不能复用:产它的代码和现在的不一样时返回一句原因,一样就是 None。
2194
+ 三份指纹都要对上——本文件的表(`code_fp`)、`--cluster learned` / `lines` 另记的(`cluster_fp`)、
2195
+ 建轨末尾结算回抠的(`refine_boundaries.refine_code_changed`)。原来驱动只比 build_tracks.py 的 mtime,
2196
+ 默认行为放在 tracksio / refine_boundaries / slot_lines 里的改动会被静默复用(2026-09-26 审计)。
2197
+ 参数对不对另由驱动比 argv(`tracksio.build_args`)。"""
2198
+ prov = doc["provenance"]
2199
+ now = code_fp()
2200
+ if prov.get("code_fp") != now:
2201
+ return f"建轨代码指纹对不上:产物是 {prov.get('code_fp')},现在是 {now}"
2202
+ if prov.get("cluster_fp"):
2203
+ from flowocr.analyze import slot_learned
2204
+ kind = (prov.get("args") or {}).get("cluster") or "learned"
2205
+ cur = slot_learned.fingerprint(kind)
2206
+ if prov["cluster_fp"] != cur:
2207
+ return f"--cluster {kind} 的指纹对不上:产物是 {prov['cluster_fp']},现在是 {cur}"
2208
+ from flowocr.analyze import refine_boundaries as RB
2209
+ return RB.refine_code_changed(doc)
2210
+
2211
+
2212
+ def provenance(args, meta: dict, main_srt: str = "", main_track: str = "",
2213
+ main_cues: int = 0) -> dict:
2214
+ """"这是谁、用哪一版代码、什么参数产的"。**并进 `-tracks.json`,不再单独一个文件**
2215
+ (`output-format-plan(已归档)`:一级产物只有一个)。
2216
+
2217
+ 存在的理由(methodology-audit-2 报告):09-06 那一夜改了三次 `build_tracks`,
2218
+ 最终结果落在临时目录,而规范目录 `out/yuka-f1..f4` 留着改动前的产物;
2219
+ `h2h_report` 只按文件名 glob,于是**照着文档的复现命令跑,打出来的是另一张表**
2220
+ (而且更好看)。产物自己带上版本,下游才有得校验。
2221
+
2222
+ `argv` 记的是**这次命令行上真正打了什么**(不是生效值)。
2223
+ `head2head.sh` 拿它和这一趟的 `BUILD_ARGS` 比:不比就会出
2224
+ methodology-audit-3 报告那个坑——同一个 `SUFFIX` 换了旋钮再跑,
2225
+ 缓存只看 `build_tracks.py` 的 mtime,**产物照旧复用、旋钮被静默忽略**。
2226
+ """
2227
+ extra = {}
2228
+ if getattr(args, "cluster", "slots") in ("learned", "lines"): # 这两条路另有代码(+ 模型),单独记指纹(默认臂的指纹不受它影响)
2229
+ from flowocr.analyze import slot_learned
2230
+ extra["cluster_fp"] = slot_learned.fingerprint(args.cluster)
2231
+ return {"tool": "build_tracks.py", "version": version(), "git_head": git_head(), "code_fp": code_fp(), **extra,
2232
+ # 路径经 `portable`(数据根下相对、用户目录下 `~/`):产物会被分享,别带出用户名(release-plan F8)。
2233
+ # `argv` 和 `obs` 用同一个函数,`tracksio.build_args` 按字符串相等剥 obs 的那一格照样对得上
2234
+ "obs": portable(args.obs), "obs_mtime": Path(args.obs).stat().st_mtime,
2235
+ "video": portable(meta.get("video")), "sample_fps": meta.get("sample_fps"),
2236
+ "main_srt": main_srt, "main_track": main_track, "main_cues": main_cues,
2237
+ "argv": [portable(x) for x in sys.argv[1:]],
2238
+ **({"matcher": {"spec": portable(args.matcher), "patch": args.matcher_patch,
2239
+ "overridden": args.matcher_overridden}} if getattr(args, "matcher", None) else {}),
2240
+ "args": {k: portable(v) if isinstance(v, str) else v for k, v in sorted(vars(args).items())}}
2241
+
2242
+
2243
+ def probe_size(video: str) -> tuple[int, int]:
2244
+ out = subprocess.run(
2245
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
2246
+ "-show_entries", "stream=width,height", "-of", "csv=p=0", video],
2247
+ capture_output=True, text=True, check=True).stdout.strip()
2248
+ w, h = out.split(",")[:2]
2249
+ return int(w), int(h)
2250
+
2251
+
2252
+ def effective_tag(args) -> str:
2253
+ """产物的标签:`--tag`,没给就是 obs 的文件名(去扩展名)。**只有这一处定**——
2254
+ 匹配器补丁按它认游戏,产物文件名也用它;原来补丁那边拿的是 `args.tag`(None),
2255
+ 于是 `hsr-s2.jsonl --matcher gametext` 不写 `--tag` 就静默丢了星铁的时间门(Codex 复审 P2 复现)。"""
2256
+ return args.tag or Path(args.obs).stem
2257
+
2258
+
2259
+ def apply_matcher_patch(ap: argparse.ArgumentParser, argv: list[str] | None = None):
2260
+ """解析命令行,并把**匹配器的聚类补丁**按计划的顺序套进去:
2261
+ 默认聚类规则 -> 匹配器补丁 -> 用户显式覆盖。
2262
+
2263
+ 做法:先解析一遍拿到 `--matcher` / `--matcher-ctx`,把补丁 `set_defaults` 成**新的默认值**,再解析一遍——
2264
+ 于是命令行上显式给过的值自然压过补丁(argparse 的默认值只在没给时才用),
2265
+ "不能把 argparse 填入的默认值误当用户覆盖"这条也就不靠猜(计划)。
2266
+
2267
+ 冲突(补丁要 A、命令行显式给了 B)**明确报告**:打印出来 + 写进 provenance 的 `matcher.overridden`,不静默丢掉。
2268
+ 补丁里出现解析器不认的键就当场报错——匹配器写错参数名不该悄悄没效果。
2269
+ """
2270
+ from flowocr import extensions
2271
+ args = ap.parse_args(argv)
2272
+ args.matcher_patch, args.matcher_overridden = {}, {}
2273
+ if not args.matcher:
2274
+ return args
2275
+ ctx = {"tag": effective_tag(args)} # 不写 --tag 时产物用 obs 文件名当标签,匹配器认游戏也得看同一个(复审 P2)
2276
+ for kv in args.matcher_ctx:
2277
+ if "=" not in kv:
2278
+ raise SystemExit(f"--matcher-ctx 要 K=V:{kv}")
2279
+ k, v = kv.split("=", 1)
2280
+ ctx[k] = v
2281
+ mod = extensions.load(args.matcher, "matcher")
2282
+ patch = dict(getattr(mod, "cluster_patch", lambda *_a, **_k: {})(ctx, None) or {})
2283
+ known = {a.dest for a in ap._actions if a.option_strings}
2284
+ unknown = sorted(set(patch) - known)
2285
+ if unknown:
2286
+ raise SystemExit(f"匹配器 `{args.matcher}` 的 cluster_patch 给了认不出的参数 {unknown}"
2287
+ f"(键是参数名如 region_time_gate,不是 CLI 旗标)")
2288
+ if patch:
2289
+ ap.set_defaults(**patch)
2290
+ args = ap.parse_args(argv)
2291
+ args.matcher_patch = patch
2292
+ args.matcher_overridden = {k: getattr(args, k) for k, v in patch.items() if getattr(args, k) != v}
2293
+ kept = {k: v for k, v in patch.items() if k not in args.matcher_overridden}
2294
+ print(f"[匹配器补丁] {args.matcher}:{kept or '(无)'}"
2295
+ + (f";命令行覆盖了 {args.matcher_overridden}" if args.matcher_overridden else ""), flush=True)
2296
+ return args
2297
+
2298
+
2299
+ def make_parser() -> argparse.ArgumentParser:
2300
+ """命令行解析器单独拿出来:只读 obs 重建某一层的工具(`flowocr.analyze.cluster_layers`)要的是
2301
+ **和管线同一份默认值**,不许在那边再抄一遍(抄的那份会在默认值翻转时静默落后)。"""
2302
+ ap = argparse.ArgumentParser(prog="flowocr-tracks")
2303
+ ap.add_argument("obs")
2304
+ ap.add_argument("--outdir", required=True)
2305
+ ap.add_argument("--tag", default=None, help="产物文件名前缀,默认取 obs 文件名")
2306
+ # 补丁不是改全局默认,覆盖了哪几个会打印出来、也写进 provenance(matcher 计划)
2307
+ ap.add_argument("--matcher", default=None,
2308
+ help="匹配器(内置名 / 模块名 / .py 路径,见 flowocr.extensions)。给了就取它的 `cluster_patch` "
2309
+ "当默认聚类规则的补丁:生效顺序是内置默认 -> 匹配器补丁 -> 命令行显式给的")
2310
+ ap.add_argument("--matcher-ctx", action="append", default=[], metavar="K=V",
2311
+ help="给匹配器 `cluster_patch` 的上下文,可多次;`tag` 自动带上(内置 gametext 按它认游戏)")
2312
+ ap.add_argument("--iou", type=float, default=0.35)
2313
+ ap.add_argument("--sim", type=float, default=0.55)
2314
+ # 半帧余量是给 `round()` 出来的标称间隔留的抖动余地(build_runs 里那段 1 µs 的账),不是给丢帧留的。
2315
+ # 代价实测(gamestream-first-look 报告):wuwa-s1 过场里字幕掉单帧、run 断成两条,主轨逐字重复 15%;
2316
+ # `--gap-frames 3`(容忍 2 帧)降到 3%。改默认要五部头对头
2317
+ ap.add_argument("--gap-frames", type=int, default=1,
2318
+ help="断链容差:tol = (g + 0.5) × 采样间隔,实际容忍 g-1 个丢帧——默认 1 = 一帧都不许丢")
2319
+ ap.add_argument("--min-conf", type=float, default=0.5)
2320
+ ap.add_argument("--min-runs", type=int, default=2,
2321
+ help="区域少于这么多条 run 就判为 noise(单帧误检)")
2322
+ ap.add_argument("--window-sec", type=float, default=60.0,
2323
+ help="聚类的时间窗(秒)。0 = 不分段,退回纯空间聚类——"
2324
+ "只在短片上等价,长片上会把整屏串成一个区域")
2325
+ ap.add_argument("--min-persistence", type=float, default=0.0,
2326
+ help="只保留在这么大比例的窗里出现过的槽位(0 = 全留)")
2327
+ ap.add_argument("--min-chars", type=int, default=0,
2328
+ help="run 的文本短于这么多字就丢掉(去噪,默认关)")
2329
+ ap.add_argument("--min-obs", type=int, default=1,
2330
+ help="run 被看到的次数少于这么多就丢掉(去噪,默认关)")
2331
+ # yuka 整片上默认口径会把字幕带和满屏小字串成 y 跨度 0.92 的巨型区域(x_overlap_ratio 的注释)
2332
+ ap.add_argument("--mutual-x", action="store_true",
2333
+ help="区域合并的 x 重叠改成除以较宽的那个(要求互相覆盖);默认除以较窄的——窄条落在宽带里就算完全重叠")
2334
+ # 针对"从不同屏的两块被并查集串成一块":星铁整场 +34 条命中,绝区零 +1、六段切片 ±0,但原神 −49 / −58 / −30
2335
+ # (那边主轨只能挑一个区域,接不住被正确分开的几条带)。按游戏显式开(game-text-corpus 报告)
2336
+ ap.add_argument("--region-time-gate", type=int, default=0, metavar="N",
2337
+ help="窗内并区多一道时间门:两条轨要么同屏过、要么时段交错 ≥N 次才连边(默认 0 = 关)")
2338
+ # 2026-09-24 从 1.4 放宽(defaults.md §1.22);手设常数、没标定过
2339
+ ap.add_argument("--grow-h-ratio", type=float, default=GROW_H_RATIO, metavar="R",
2340
+ help=f"打字机生长的框高比门:前后两帧框高相差超过 R 倍就不算长出来的(默认 {GROW_H_RATIO};0 = 不看框高)")
2341
+ ap.add_argument("--rejoin-split", action=argparse.BooleanOptionalAction, default=True,
2342
+ help="det 在某个采样点上把一条在场的行切成几块时,拼回去当那一行的延续,不另起 run(默认开)")
2343
+ # 1.7 是第一版手设的、没标定过(h-ratio 计划)
2344
+ ap.add_argument("--region-h-ratio", type=float, default=REGION_H_RATIO, metavar="R",
2345
+ help=f"窗内并区的字高比门:两条行级轨中位字高相差超过 R 倍就不连边(默认 {REGION_H_RATIO};0 = 不看字号)")
2346
+ ap.add_argument("--slot-h-ratio", type=float, default=SLOT_H_RATIO, metavar="R",
2347
+ help=f"跨窗缝合的字高比门:区域和槽位签名字高相差超过 R 倍就不缝(默认 {SLOT_H_RATIO};0 = 不看字号)")
2348
+ # learned(2026-09-21)不建议当默认:两个抽样框合计 74.2% vs median 70.2%,没见过的游戏(鸣潮)上没有优势、过度合并;
2349
+ # 训练过的游戏的长窗上重复认领降一半以上(cluster-ruler 计划)。模型只在《魔裁》/ 原神 / 星铁 / 绝区零的标注上训过。
2350
+ # lines(2026-09-26)接进管线,公平排名要照 slot_lines 自己抽样才能下
2351
+ ap.add_argument("--cluster", choices=("slots", "learned", "lines"), default="slots",
2352
+ help="区域怎么来:slots(默认)= 窗内聚类 + 跨窗缝合;lines(实验性)= 手写规则的行位 -> 带"
2353
+ "(`flowocr.analyze.slot_lines`,同刻同框的上下行并成一条带,散 run 回落到缝合分组);learned(实验性)= 行位当节点、"
2354
+ "并不并由学出来的成对亲和度决定(`flowocr.analyze.slot_learned`,模型随代码)")
2355
+ # owner 2026-09-21 翻的(原默认 pooled)。依据是聚类的尺:两个抽样框合计 312 对上 median 对 pooled 逐对 39 : 16、
2356
+ # 错误率 22% vs 30%(第一轮报的 32 : 8 只照 pooled 抽,有抽样框偏差),median 之下 --slot-reassign 开关分不出来;
2357
+ # 五部有内容档 +0/+14/-1/+21/-7、重复认领不涨;游戏两个 40 分钟窗上用途 2 逐数相同。
2358
+ # 09-07 在五部上被否过(名牌纯度 i1 1.5 / i2 0.9)、09-11 又因净命中 -191~+93 没采纳——那两把尺量的是名牌纯度
2359
+ # (奖励碎片化,audit-3)和主轨换区域(--main-band 进默认后不再出现)
2360
+ ap.add_argument("--slot-geom", choices=("pooled", "median"), default="median",
2361
+ help="槽位签名怎么算:median(默认)= 各成员窗区域几何的逐分量中位数;"
2362
+ "pooled = 成员的 run 倒一起取分位(一个大成员就能把签名撑开)")
2363
+ # "大吃小"的闸门:名牌带 268 px 落进 708 px 的混合槽位,比值 2.64 从 3.0 底下钻过去了
2364
+ ap.add_argument("--slot-span-ratio", type=float, default=SLOT_SPAN_RATIO,
2365
+ help=f"槽位和候选的 y 跨度最多差几倍(垫 2 倍行高后;默认 {SLOT_SPAN_RATIO})")
2366
+ # 名牌带(x=[214,761])和满屏小字没有 x 重叠,但槽位质心一旦长成 x=[90,1762] 的大框,窄带就整个落在里面了
2367
+ ap.add_argument("--slot-mutual-x", action="store_true",
2368
+ help="缝合层的 x 判据改成除以较宽的那个(--mutual-x 的跨窗版;默认关)")
2369
+ # 2026-09-04 之前的缝合。纯重叠会被大吃小——片尾滚动表当种子能吞掉全片 65% 的 run
2370
+ ap.add_argument("--no-slot-anchor", action="store_true",
2371
+ help="退回老的跨窗缝合:纯区间重叠、且不做收敛后的合并轮(对照用)")
2372
+ ap.add_argument("--text-pick", choices=("vote", "longest", "tail", "conf", "maxconf"), default="vote",
2373
+ help="一条 run 用哪一版文本:vote=多数投票(默认,挡抖动垃圾)、"
2374
+ "longest=取最长(保住打字机打完那版,但会收进垃圾尾巴)、"
2375
+ "tail=多数投票但允许补回 ≤--tail-max 个字的延长、"
2376
+ "conf=按 rec 置信度加权投票、maxconf=取置信度最高的那一票(后两个是对照臂)")
2377
+ # 3 是 owner 三档里"有内容 ≥3 字"那条线,不是新常数。候选比较里 41%–59% 的前缀匹配短边只有 1–2 字(`1` 配上了
2378
+ # `1-ザ-ID:712688376`),≥3 字那批全是真打字机增长;实测产物几乎不变(三段整片切片里只有 hsr-s2 多出 3 条 run)——堵一个没发作过的洞
2379
+ ap.add_argument("--prefix-min", type=int, default=PREFIX_MIN_CHARS,
2380
+ help=f"前缀加分(0.95)要求短的那一侧至少几个字(默认 {PREFIX_MIN_CHARS};0 = 不设门)")
2381
+ # methodology-audit-4 报告 C4:复用把上一帧的文本原样搬过来,实测沿用占全部观测的 32%–55%(wuwa-s1 / gi-s2),
2382
+ # 一次读错会被自己复制出压倒性多数。开之前先跑 dev_tools/sampleset.sh quick
2383
+ ap.add_argument("--vote-independent", action="store_true",
2384
+ help="一条 run 的文本投票只数独立的读数,排除框级复用搬过来的票(观测里的 `reused`;默认关)。"
2385
+ "全是沿用的 run 退回全体投票")
2386
+ ap.add_argument("--tail-max", type=int, default=3,
2387
+ help="--text-pick tail 时,最多补回几个字")
2388
+ ap.add_argument("--no-typewriter", action="store_true",
2389
+ help="关掉打字机增长匹配(对照用)")
2390
+ # auto 是 owner 2026-09-25 定的;测量只在阶段 1、结算只在这里,不另存 -tracks-refined.json(owner 2026-09-26)
2391
+ ap.add_argument("--refine", choices=("auto", "off"), default="auto",
2392
+ help="auto(默认):obs 里有在线回抠的证据(run_ocr2 默认会量)就在建轨末尾结算,"
2393
+ "产物和 SRT 直接是帧级时刻、带打字机 / 淡入淡出;没有证据就停在采样级并提示(用默认设置重跑阶段 1 即可)。"
2394
+ "off = 不回抠(采样级,对照或给开发探针当输入)")
2395
+ # 2026-09-26 进默认(defaults.md 1.32):yuka 两窗救回的结尾证据和开视频离线测量逐帧相同,结尾真值不变
2396
+ ap.add_argument("--refine-salvage-end", action=argparse.BooleanOptionalAction, default=REFINE_SALVAGE_END,
2397
+ help="回抠结算时,被回补作废、窗口却凑齐了的结尾证据照样用(默认开):回补改的是文本,"
2398
+ "结尾量的是框里像素什么时候变,按末次观测时刻 + 框认领")
2399
+ ap.add_argument("--srt-mode", choices=("segment", "cue", "body", "raw"), default="segment",
2400
+ help="segment=按同屏行集合切段,再把『行只增不减』的相邻段并掉"
2401
+ "(默认,这是打字机错开出现的正解);"
2402
+ "cue=纯按『一起消失』分组(对常驻说话人名会失手,见 docs);"
2403
+ "body=只有正文行的首尾是切点,名牌 / 闪烁提示 / 碎片挂进去(见 body_segments,对照臂);"
2404
+ "raw=一条 run 一条")
2405
+ ap.add_argument("--typewriter-min-chars", type=int, default=TYPEWRITER_MIN_CHARS,
2406
+ help=f"打字机增长匹配要求上一帧至少几个字(默认 {TYPEWRITER_MIN_CHARS};"
2407
+ "1 = 首帧只打出 1 个字也接上,那时前缀必须逐字相同,见 TYPEWRITER_MIN_CHARS)")
2408
+ # 2026-09-08 由 0 改的(MAIN_BAND_DEFAULT 的注释)。存在的理由:匹配器只看主轨一个区域,聚类一把字幕带切成两块,
2409
+ # 第二块的台词就整段消失(f1 上实测 191 条连续台词跑到了 region07,gamestream-first-look 报告)
2410
+ ap.add_argument("--main-band", type=float, default=MAIN_BAND_DEFAULT,
2411
+ help=f"把和主轨同一条字幕带的区域并进主轨,参数是允许的中心 y 差(几倍主轨字高;默认 {MAIN_BAND_DEFAULT:g},"
2412
+ "0 = 关、主轨只是单个区域)。只影响并出来的 `<tag>-main.srt`,逐区域 SRT 不变")
2413
+ ap.add_argument("--main-band-h", type=float, nargs=2, default=[0.7, 1.4],
2414
+ help="并带时允许的字高比区间")
2415
+ ap.add_argument("--ruby-mark", action=argparse.BooleanOptionalAction, default=True,
2416
+ help="按几何标振り仮名(紧贴含汉字正文上方、字高约一半、全假名),事件打 ruby 标;SRT、匹配器、default 叠加不给,全部文字的叠加照画(默认开)")
2417
+ ap.add_argument("--seg-skip-footprint", action=argparse.BooleanOptionalAction, default=True,
2418
+ help="切 cue 之前把框位门标了的常驻 UI run 拿出来(默认开):区域轨里它们单独成 cue,并带主轨里直接不要。"
2419
+ "不拿的话只含水印的空段会并进下一条 cue、把 cue 起点提前")
2420
+ # 实测不该开:quick 六段上三段的并带整个消失——被切下来的那半正因为"是碎片"(run 少、时长统计变了)才被判成 misc
2421
+ # (clustering 报告);09-21 拆开量过,要挡的 UI 垃圾 --main-band-x 已经挡住(defaults.md 1.30)
2422
+ ap.add_argument("--main-band-label", action="store_true",
2423
+ help="并带时要求被并区域也是可当主轨的标签(默认关,不建议开)")
2424
+ # 2026-09-26 进默认(defaults.md 1.30):只看 y 的并带会把画面两侧同高度的 UI / 图标误读并进主轨
2425
+ ap.add_argument("--main-band-x", action=argparse.BooleanOptionalAction, default=True,
2426
+ help="并带时再看 x:区间重叠 <0.30 且中心差 >0.08 画宽的区域不并(复用缝合层的 x 判据;默认开)")
2427
+ # 菜单 / 片头那种片段实测最高分只有 0.2;门槛多少合适没有数据支撑,留给调用方按用途定(methodology-audit-4 报告 C3)
2428
+ ap.add_argument("--main-min-score", type=float, default=0.0,
2429
+ help="主轨分要高过这个才算数(默认 0 = 只要求 > 0)。宁可没有主轨也不要一条垃圾轨时调高")
2430
+ # 2026-09-20 起默认开(clustering 报告):只合不分时整片实测 44~45% 的成员不再满足最终签名的闸门(audit-4 C1)
2431
+ ap.add_argument("--no-slot-reassign", dest="slot_reassign", action="store_false",
2432
+ help="关掉重分配,退回只合不分的老缝合(对照用)。默认缝合收敛之后对着最终签名重判一遍:"
2433
+ "不满足的成员换槽位,都不满足就独立成槽")
2434
+ ap.add_argument("--slot-reassign-iters", type=int, default=3,
2435
+ help="重分配迭代几轮(换完签名会变,所以要迭代;上限防振荡)")
2436
+ # 2026-09-20 翻成 both(ui-gate 计划)。文本那条门的分母是 cue 数(管线自己的产物),同一段素材三条臂读出
2437
+ # 56.0/53.0/49.2%、默认臂离门只有 6 个点(inference-runtime 计划);时间占比三条臂完全相同、余量 34 个点
2438
+ ap.add_argument("--ui-gate", default="both", choices=("text", "footprint", "both"),
2439
+ help="常驻 UI 用哪份判据:text = 按文本 + cue 占比;footprint = 按框位 + 时间占比"
2440
+ "(flowocr.analyze.uigate);both = 两个的并集(默认)")
2441
+ ap.add_argument("--ui-fp-share", type=float, default=0.25,
2442
+ help="框位门:在屏时长占全片的比例下限")
2443
+ ap.add_argument("--ui-fp-wvar", type=float, default=0.13, help="框位门:框宽离散上限")
2444
+ ap.add_argument("--ui-fp-churn", type=float, default=0.06, help="框位门:文本翻动上限")
2445
+ ap.add_argument("--ui-fp-clen", type=int, default=13, help="框位门:内容字中位上限")
2446
+ ap.add_argument("--no-nameplate-mark", dest="nameplate_mark", action="store_false",
2447
+ help="不给 run 打名牌标(默认打)。判据在 flowocr.analyze.uigate.pick_nameplates:"
2448
+ "在正文带上方一档 + 时长 >= 台词 × 比例 + 词表小,按时间分窗")
2449
+ # 不分窗的话全片只聚出一条带,"在带上方"会把整片落在它上方的一切都收了(f5 精确率 11%)
2450
+ ap.add_argument("--nameplate-window", type=float, default=120.0,
2451
+ help="挑名牌的时间窗(秒)")
2452
+ # owner 2026-09-19:时长通常 >= 台词。这条是精确率的主力:去掉它 f3 从 76% 塌到 6%
2453
+ ap.add_argument("--nameplate-dur-ratio", type=float, default=0.8,
2454
+ help="名牌时长至少是正文的多少倍")
2455
+ ap.add_argument("--nameplate-churn", type=float, default=0.8,
2456
+ help="名牌的文本翻动上限(十来个名字轮着出)")
2457
+ # 2026-09-20 补的第 4 条判据:`z-xingchuan`(根本没有名牌)上前三条标了 198 条弹幕 / OCR 噪声,只有这条拦得住(198 → 21)。
2458
+ # 只有一个工作点、没扫过平台(审计七),要 A/B 就给这个旋钮;z-shuigong 上它掉的 70 条里真名牌 1 条(followups-evidence-0926 报告)
2459
+ ap.add_argument("--nameplate-cover", type=float, default=0.3,
2460
+ help="名牌里属于重复文本的条目至少占这么多(`uigate.measure` 的 `cover`;0 = 关掉这条判据)")
2461
+ # 0.2 会把说话人名牌一起删
2462
+ ap.add_argument("--ui-share", type=float, default=UI_SHARE,
2463
+ help="短行(<=6 字)在这么大比例的 cue 里独立出现就判为常驻 UI 并剔掉(0 = 关;别调到 0.5 以下)")
2464
+ # 存在的理由是 --ui-share 的分母是区域自己的 cue 数,场景局部的小区域里任何一行都轻易过半(整片上把角色名牌当 UI 删了,
2465
+ # gamestream-first-look 报告)。开之前先跑五部头对头:0.3 的地板在四部整片上只留下 `Auto` 和 `Q`,把现在删的东西放过 97%
2466
+ ap.add_argument("--ui-min-onshare", type=float, default=0.0,
2467
+ help="文本那条 UI 门再加一道全片尺度的地板:含这一行的段总时长 / 全片时长要到这个数才算常驻 UI(默认 0 = 关)")
2468
+ # 只看比例的话 1 条 cue 的小区域会被整个清空(methodology-audit-2 报告)
2469
+ ap.add_argument("--ui-min-support", type=int, default=20,
2470
+ help="常驻 UI 判定的绝对条数下限:那一行至少要出现这么多次")
2471
+ ap.add_argument("--cue-min-overlap", type=float, default=0.5,
2472
+ help="并进同一条 cue 的行,重叠时长要占**较长**那条的多大比例")
2473
+ ap.add_argument("--cue-end-tol-frames", type=float, default=1.0,
2474
+ help="判定『一起消失』的容差,单位是采样间隔——不是固定毫秒")
2475
+ # text-motion 计划第 2 步
2476
+ ap.add_argument("--move-seed-last", type=float, default=0.0, metavar="SHARE",
2477
+ help="在动的区域不当缝合种子:给一个占比阈值(例 0.5 = 过半 run 在动的区域)排到最后再去找槽位(默认 0 = 关)")
2478
+ # text-motion 计划第 3 步;0.3 的依据见 MAIN_MOVE_MAX 的注释
2479
+ ap.add_argument("--main-move-max", type=float, default=MAIN_MOVE_MAX, metavar="SHARE",
2480
+ help=f"在动的区域不当主轨:`moving_share` 达到这个值就不接受(默认 {MAIN_MOVE_MAX};1.0 = 不拦)")
2481
+ ap.add_argument("--event-texts", action="store_true",
2482
+ help="每个事件把逐票文本和沿用标记也写进 JSON(大,默认关)")
2483
+ ap.add_argument("--print-defaults", action="store_true",
2484
+ help="打印当前默认值的指纹后退出(head2head 的默认臂会记一行)")
2485
+ return ap
2486
+
2487
+
2488
+ def settle_refine(doc: dict, obs_path: Path, tag: str, salvage_end: bool = REFINE_SALVAGE_END) -> bool:
2489
+ """`--refine auto`:obs 里有在线回抠的证据就在这里把回抠结算掉——**在内存里就地改 doc**,调用方随后重出 SRT、最后才写 tracks.json。
2490
+ 结算用 `refine_boundaries.refine_tracks` 的 obs 证据那条路、默认标签;产物不另存 `-tracks-refined.json`:一级产物只有一份
2491
+ (AGENTS.md 护栏)。测量只在阶段 1(`run_ocr2` 默认在线量)、结算只在这里(owner 2026-09-26)。没有证据时产物停在采样级、打一行提示。"""
2492
+ from flowocr.analyze import refine_boundaries as RB # 懒 import:RB 顶层 import 了本模块
2493
+ meta, fused = RB.obs_evidence(obs_path)
2494
+ if not fused or not meta.get("src_fps"):
2495
+ print("回抠(--refine auto):obs 里没有在线回抠的证据(旧 obs 或 --refine-aux noop),产物停在采样级;"
2496
+ "要帧级时刻和打字机效果,用默认设置重跑阶段 1(run_ocr2 默认在线量证据),再建轨")
2497
+ return False
2498
+ fps = float(meta["src_fps"])
2499
+ print("回抠(--refine auto,读 obs 里的证据,不开视频):", flush=True)
2500
+ try:
2501
+ st = RB.refine_tracks(doc, decoder="obs", fps=fps, obs=obs_path, obs_code_fp=meta.get("code_fp", ""),
2502
+ salvage_stale_end=salvage_end)
2503
+ except (SystemExit, Exception) as exc:
2504
+ # 有证据却结算不了(一条都认领不到 = obs 和轨对不上;或证据行坏了抛 KeyError / ValueError):tracks.json 还没写,
2505
+ # 不会留下被驱动当缓存复用的采样级产物(2026-09-25 / 26 复查)
2506
+ raise SystemExit(f"回抠(--refine auto)结算失败:{exc}\n 没写 {tag}-tracks.json(目录里的区域 SRT 是采样级的,"
2507
+ f"别单独拿去用)。要采样级的产物就加 --refine off 重跑建轨") from None
2508
+ for line in RB.summary_lines(tag, st, doc, fps, "obs"):
2509
+ print(line)
2510
+ return True
2511
+
2512
+
2513
+ def main() -> int:
2514
+ ap = make_parser()
2515
+ # **在 parse_args 之前拦**:`obs` 和 `--outdir` 是必填的,
2516
+ # 只想问"现在的默认是什么"时不该被逼着编两个路径出来。
2517
+ if "--print-defaults" in sys.argv[1:]:
2518
+ fp, line = defaults_fingerprint(ap)
2519
+ print(f"默认指纹 {fp}")
2520
+ print(line)
2521
+ return 0
2522
+ args = apply_matcher_patch(ap)
2523
+
2524
+ lines = Path(args.obs).read_text(encoding="utf-8").splitlines()
2525
+ meta = json.loads(lines[0])["_meta"]
2526
+ obs = [json.loads(x) for x in lines[1:] if x.strip()]
2527
+ obs = [o for o in obs if o["conf"] >= args.min_conf]
2528
+ if not obs:
2529
+ raise SystemExit("没有满足置信度的观测")
2530
+
2531
+ W, H = meta.get("width"), meta.get("height")
2532
+ if not W:
2533
+ W, H = probe_size(meta["video"])
2534
+ frame_us = int(round(1e6 / meta["sample_fps"]))
2535
+ t0_us = min(o["t_us"] for o in obs)
2536
+ dur_us = max(o["t_us"] for o in obs) + frame_us
2537
+
2538
+ cuts = ()
2539
+ observable = None # (框, t0, t1) -> 这个框位在 [t0, t1) 里看得见多久;None = 全程看得见
2540
+ if meta.get("regions"): # 自选范围:时间关闭段不许被事件跨过、时间支持度按可观察时间算(ocr-regions 计划)
2541
+ from flowocr.extract import regions as RG
2542
+ rgrp = RG.from_spec(meta["regions"], W, H)
2543
+ cuts = RG.cuts_us(rgrp)
2544
+ if not rgrp.unrestricted:
2545
+ observable = rgrp.observable_us
2546
+ print(f"[范围] 组 {meta['regions']['name']}:{len(cuts)} 个时间切点", flush=True)
2547
+ runs = build_runs(obs, frame_us, args.iou, args.sim, args.gap_frames,
2548
+ grow_typewriter=not args.no_typewriter,
2549
+ text_pick=args.text_pick, tail_max=args.tail_max,
2550
+ vote_independent=args.vote_independent,
2551
+ prefix_min=args.prefix_min,
2552
+ typewriter_min_chars=args.typewriter_min_chars, grow_h_ratio=args.grow_h_ratio,
2553
+ rejoin_split=args.rejoin_split,
2554
+ cuts=cuts)
2555
+ # 去噪:**默认不开**。整片上量过取舍(noise 报告),
2556
+ # 这两条砍掉的"多出条目"里既有 OCR 垃圾也有基线漏掉的真台词,
2557
+ # 只有"误伤了多少条对得上基线的"是能确证的代价,所以交给调用方决定。
2558
+ if args.min_chars > 0 or args.min_obs > 1:
2559
+ keep = [r for r in runs
2560
+ if len("".join(r.text.split())) >= args.min_chars and r.n_obs >= args.min_obs]
2561
+ print(f"去噪:{len(runs)} -> {len(keep)} 条 run"
2562
+ f"(min_chars={args.min_chars}, min_obs={args.min_obs})")
2563
+ runs = keep
2564
+
2565
+ # 时间合并(build_runs)始终是全局的——窗边界不能把一条字幕切断。
2566
+ # 只有**空间聚类**分窗做。
2567
+ if args.window_sec > 0:
2568
+ window_us = int(args.window_sec * 1e6)
2569
+ tracks, win_regions = cluster_windowed(runs, W, H, window_us, args.mutual_x,
2570
+ args.region_time_gate, args.region_h_ratio)
2571
+ # 窗内 run 太多,空间聚类会重新开始串联。实测(3h20m 整片)失效点在
2572
+ # 每窗 ~860 条(窗 1200s,仍正常)到 ~1725 条(窗 2400s,y 跨度 0.91)之间。
2573
+ # 默认 60s 窗在该片上每窗只有 43 条,余量 20 倍以上;这里只在越界时提醒。
2574
+ per_win = Counter(window_of(r, window_us) for r in runs)
2575
+ if per_win and max(per_win.values()) > SAFE_RUNS_PER_WINDOW:
2576
+ print(f" ⚠ 有窗内 run 数达 {max(per_win.values())}(经验安全线 {SAFE_RUNS_PER_WINDOW}),"
2577
+ f"聚类可能重新串联;把 --window-sec 调小", flush=True)
2578
+ slots = stitch_slots(runs, win_regions, W, H, not args.no_slot_anchor,
2579
+ args.slot_mutual_x, args.slot_span_ratio, args.slot_geom,
2580
+ move_seed_last=args.move_seed_last,
2581
+ reassign=args.slot_reassign,
2582
+ reassign_iters=args.slot_reassign_iters,
2583
+ h_ratio=args.slot_h_ratio)
2584
+ # 缝合是贪心且只合不分的,**最终成员未必还满足最终签名的闸门**
2585
+ # (methodology-audit-4 报告 C1)。这里只报数,不改算法。
2586
+ bad_mem, tot_mem, why_mem = slot_consistency(
2587
+ runs, win_regions, slots, W, not args.no_slot_anchor,
2588
+ args.slot_mutual_x, args.slot_span_ratio, args.slot_geom, args.slot_h_ratio)
2589
+ if tot_mem:
2590
+ top = "、".join(f"{k} {v}" for k, v in why_mem.most_common(3))
2591
+ print(f" 槽位最终一致性:{bad_mem}/{tot_mem} = "
2592
+ f"{bad_mem/tot_mem:.1%} 的成员**不满足最终签名的闸门**"
2593
+ f"(贪心只合不分的代价,见 audit-4 C1;这是观测值,不是失败)"
2594
+ + (f";卡在:{top}" if top else ""),
2595
+ flush=True)
2596
+ # 窗数按**观测实际覆盖的时间跨度**算,不是从 0 算到片尾。
2597
+ # 只跑一个时间窗(run_ocr2 的 --start/--end)时,后者会把 persistence 的
2598
+ # 分母撑成整片长度:40 分钟的主轨算成 40/181 = 0.22,primary_score 直接塌掉。
2599
+ # 整片跑时 t0_us≈0,这个改动是恒等的。
2600
+ n_windows = max(1, (dur_us - 1) // window_us - t0_us // window_us + 1)
2601
+ groups = []
2602
+ for sl in slots:
2603
+ run_ids = sorted({i for w in sl for i in win_regions[w]["runs"]})
2604
+ # 常驻度按 run **真实覆盖**到的窗算,不按"归属窗"(见 covered_windows)
2605
+ wins = covered_windows(runs, run_ids, window_us)
2606
+ groups.append((run_ids, len(wins), wins))
2607
+ if args.cluster in ("learned", "lines"):
2608
+ # 行级 track(`tracks`)照旧用窗内聚类的——下游拿它给事件挂 track 号;**只换"哪些 run 是一个区域"**
2609
+ n_slots = len(groups)
2610
+ slot_of_run = {i: k for k, (ids, _, _) in enumerate(groups) for i in ids}
2611
+ if args.cluster == "learned":
2612
+ from flowocr.analyze import slot_learned
2613
+ new = slot_learned.groups_for_pipeline(runs, W, H, effective_tag(args), fallback=slot_of_run)
2614
+ else:
2615
+ from flowocr.analyze import slot_lines
2616
+ new = slot_lines.groups_for_pipeline(runs, W, H, fallback=slot_of_run)
2617
+ groups = []
2618
+ for run_ids in new:
2619
+ wins = covered_windows(runs, run_ids, window_us)
2620
+ groups.append((run_ids, len(wins), wins))
2621
+ print(f"区域构建(--cluster {args.cluster}):缝合给的 {n_slots} 个槽位换成 {len(groups)} 个区域", flush=True)
2622
+ else:
2623
+ if args.cluster in ("learned", "lines"):
2624
+ raise SystemExit(f"--cluster {args.cluster} 只接在分窗那条路上(散 run 要回落到跨窗缝合的分组);别和 --window-sec 0 一起给")
2625
+ window_us, n_windows = dur_us, 1
2626
+ tracks = build_tracks(runs, W, H)
2627
+ groups = [(sorted(i for t in g for i in tracks[t]), 1, [0])
2628
+ for g in build_regions(runs, tracks, W, H, args.mutual_x, args.region_time_gate,
2629
+ args.region_h_ratio)]
2630
+
2631
+ if t0_us > window_us:
2632
+ print(f" 注:观测从 {t0_us/1e6/60:.1f} min 开始(窗口跑),"
2633
+ f"窗数按覆盖跨度算 = {n_windows},不是从 0 算到片尾", flush=True)
2634
+
2635
+ def active_span(rs: list[Run], wins: list[int]) -> int:
2636
+ """槽位"活着"的时长:所在时间窗的并集,再并上 run 自身的区间。
2637
+
2638
+ 只按 `窗数 × 窗长` 算是错的——一条 91 秒的 run 只归属一个窗(按中点),
2639
+ 覆盖时长却超过窗长,于是 fill_active 会算出 1.19 这种数。
2640
+ """
2641
+ spans = [(w * window_us, min(dur_us, (w + 1) * window_us)) for w in wins]
2642
+ spans += [(r.t_start, r.t_end) for r in rs]
2643
+ return sum(e - s for s, e in uigate.merge_intervals(spans))
2644
+
2645
+ track_of = {i: t for t, ids in enumerate(tracks) for i in ids}
2646
+ groups.sort(key=lambda g: -len(g[0]))
2647
+
2648
+ outdir = Path(args.outdir)
2649
+ outdir.mkdir(parents=True, exist_ok=True)
2650
+ tag = effective_tag(args)
2651
+
2652
+ n_stale = clear_stale_srts(outdir, tag)
2653
+ if n_stale:
2654
+ print(f"清掉上一轮的 {n_stale} 个旧产物(label 变了会留下同 index 的旧 SRT;"
2655
+ "`-provenance.json` 已并进 tracks.json,旧的那份也删;tracks.json 本轮成功才重写)")
2656
+
2657
+ report = []
2658
+ doc = {"tag": tag, "meta": meta, "frame_us": frame_us, "size": [W, H],
2659
+ "window_sec": args.window_sec, "n_windows": n_windows,
2660
+ "regions": [], "tracks": [], "events": []}
2661
+ events: list[dict] = doc["events"]
2662
+ eid_of: dict[int, int] = {} # id(Run) -> 事件 id
2663
+ region_runs: dict[int, list[Run]] = {}
2664
+ region_ids: dict[int, list[int]] = {}
2665
+ seg_kw = dict(mode=args.srt_mode, end_tol_us=int(args.cue_end_tol_frames * frame_us),
2666
+ min_overlap=args.cue_min_overlap)
2667
+ ui_kw = dict(min_support=args.ui_min_support,
2668
+ min_onshare=args.ui_min_onshare, film_us=dur_us)
2669
+ # **名牌标**:在出事件之前全片挑一次(**按时间分窗**,判据在 `flowocr.analyze.uigate`)。
2670
+ # 为什么不按区域挑:五部整片上**三部的名牌 run 被巨型区域吞掉了**,区域侧根本没得挑
2671
+ # (ui-gate 计划)。框位是按 IoU 聚的,不经过区域那一层。
2672
+ if args.nameplate_mark:
2673
+ _items = [uigate.Item(tuple(r.box), r.t_start, r.t_end, r.text, key=i, w=max(1, r.n_obs),
2674
+ clipped=r.clip_start is not None or r.clip_end is not None)
2675
+ for i, r in enumerate(runs)]
2676
+ _np = uigate.pick_nameplates(_items, win_us=int(args.nameplate_window * 1e6),
2677
+ dur_ratio=args.nameplate_dur_ratio,
2678
+ max_churn=args.nameplate_churn,
2679
+ min_cover=args.nameplate_cover,
2680
+ step_us=frame_us, observable=observable)
2681
+ _keys = {it.key for it in _np}
2682
+ for i, r in enumerate(runs):
2683
+ r.nameplate = i in _keys
2684
+ print(f"名牌标:{len(_keys)} / {len(runs)} 条 run"
2685
+ f"(窗 {args.nameplate_window:.0f}s,时长 ≥ 正文×{args.nameplate_dur_ratio},"
2686
+ f"词表 ≤ {args.nameplate_churn})")
2687
+
2688
+ # **框位 + 时间占比的常驻 UI 门**(`--ui-gate`,2026-09-20 起默认 `both`)。
2689
+ ui_fp = ui_footprints(runs, frame_us, args, observable)
2690
+ run_idx = {id(r): i for i, r in enumerate(runs)}
2691
+ if args.ui_gate != "text":
2692
+ print(f"常驻 UI(框位门):{len(ui_fp)} / {len(runs)} 条 run"
2693
+ f"(占比 ≥{args.ui_fp_share:.0%}、框宽离散 ≤{args.ui_fp_wvar}、"
2694
+ f"翻动 ≤{args.ui_fp_churn}、内容字 ≤{args.ui_fp_clen};分母是**时间**不是 cue 数)")
2695
+ # 振り仮名:全局按几何标(`ruby_runs`),和框位 UI 一样在剔 UI 的导出里剔(`tracksio.cue_lines`)
2696
+ ruby = ruby_runs(runs) if args.ruby_mark else set()
2697
+ if ruby:
2698
+ print(f"振り仮名:{len(ruby)} / {len(runs)} 条 run(紧贴含汉字正文上方、字高 {RUBY_H[0]}–{RUBY_H[1]} 倍、全假名)")
2699
+ # 两个集合别混:`export_drop` 是剔 UI 的导出(SRT 投影)里不给的,和开关无关;
2700
+ # `seg_apart` 是切 cue 之前和正文分开切的,框位 UI 那一半跟着 --seg-skip-footprint 走
2701
+ export_drop = ui_fp | ruby
2702
+ seg_apart = (ui_fp if args.seg_skip_footprint else set()) | ruby
2703
+
2704
+ ri = 0
2705
+ for run_ids, n_win, wins in groups:
2706
+ if n_win / n_windows < args.min_persistence:
2707
+ continue
2708
+ rs = [runs[i] for i in run_ids]
2709
+ region_runs[ri] = rs
2710
+ region_ids[ri] = run_ids
2711
+ span_us = dur_us
2712
+ if observable is not None: # 自选范围:fill 的分母是这块区域看得见的时长(成员外接框里有有效像素的时段)
2713
+ bb = [min(r.box[0] for r in rs), min(r.box[1] for r in rs), max(r.box[2] for r in rs), max(r.box[3] for r in rs)]
2714
+ span_us = max(1, observable(bb, 0, dur_us)) # 口径同原来的 dur_us(从 0 算),只扣掉关闭时段
2715
+ f = region_features(rs, len({track_of[i] for i in run_ids}), W, H,
2716
+ span_us, active_span(rs, wins), n_win / n_windows)
2717
+ lab = label_region(f, args.min_runs)
2718
+ f["primary_score"] = primary_score(f, lab)
2719
+ for r in sorted(rs, key=lambda r: r.t_start):
2720
+ eid_of[id(r)] = len(events)
2721
+ events.append(event_dict(r, len(events), ri, with_texts=args.event_texts))
2722
+ # 切分和判 UI 在这里做一次,**结果既写进 JSON 也用来出 SRT**——
2723
+ # 导出器不再重新判断(`output-format-plan(已归档)`)。
2724
+ # 框位 UI / 振り仮名和正文**分开切**(`--seg-skip-footprint`):混着切的话只含水印的空段会并进下一条 cue、把起点提前,
2725
+ # 而默认 `--feed nonoise` 喂给匹配器的正是区域轨(peer 审查 6b47a0c 第 1 条:gi-s1 r13 15/17 条、zzz-s1 r06 最多早 168 s)。
2726
+ # 它们的事件照旧留在这条轨里、各自成 cue,用途 1 的叠加照样按轨标 UI;文本那条 UI 判据只数正文的段
2727
+ segs, body_segs = split_hidden(rs, seg_apart, run_idx, seg_kw)
2728
+ ui = text_ui_lines(body_segs, args, ui_kw)
2729
+ for r in rs:
2730
+ # `ui_filtered` **逐轨**(文本那条判据)、`ui_footprint` **全局**(框位那条)——
2731
+ # 混成一个 flag 的话,导出器按 flag 投影时会把"区域轨里判的 UI"带进主轨,
2732
+ # 而那两处的分母本来就不同(主轨的 UI 是并带之后**重新判**的)。
2733
+ if r.text.strip() in ui:
2734
+ events[eid_of[id(r)]]["flags"].append("ui_filtered")
2735
+ if run_idx[id(r)] in ui_fp:
2736
+ events[eid_of[id(r)]]["flags"].append("ui_footprint")
2737
+ if run_idx[id(r)] in ruby:
2738
+ events[eid_of[id(r)]]["flags"].append("ruby")
2739
+ srt_name = f"{tag}-region{ri:02d}-{lab}.srt"
2740
+ write_srt_segments(outdir / srt_name, segs, ui, export_drop, run_idx)
2741
+ tid = f"r{ri:02d}"
2742
+ doc["tracks"].append({"id": tid, "kind": "region", "region": ri, "label": lab,
2743
+ "srt": srt_name, "ui_lines": sorted(ui),
2744
+ "cues": cues_from_segments(tid, segs, eid_of, events, H)})
2745
+ doc["regions"].append({
2746
+ "index": ri, "label": lab, "n_windows_present": n_win,
2747
+ "lang": f["lang"], "primary_score": f["primary_score"], "features": f})
2748
+ report.append((ri, lab, f))
2749
+ ri += 1
2750
+
2751
+ # **主轨是哪个文件,由这里定,不让下游去 glob 猜。**
2752
+ # `head2head.sh` 原来按 `f4-region01-*.srt | head -1` 取,隔夜的旧 label 排在前面
2753
+ # 就整部量错(见上面清理那段)。写进 provenance,下游直接读。
2754
+ main, cands = pick_main(report, args.main_min_score, args.main_move_max)
2755
+ main_srt = f"{tag}-region{main[0]:02d}-{main[1]}.srt" if main else ""
2756
+ main_track = f"r{main[0]:02d}" if main else ""
2757
+ band: list[int] = []
2758
+ if main and args.main_band > 0:
2759
+ # 几何用和另外两层同一个 `region_geom`(2%/98% 分位),别另算一套
2760
+ geom = {ri: region_geom(runs, ids) for ri, ids in region_ids.items()}
2761
+ band = band_regions(report, main[0], args.main_band, *args.main_band_h,
2762
+ need_label=args.main_band_label,
2763
+ need_x=args.main_band_x, geom=geom, W=W,
2764
+ mutual_x=args.slot_mutual_x)
2765
+ if band:
2766
+ # **并成一份新产物,不动逐区域 SRT**:用途 1 要的是分开的块
2767
+ # (名牌/正文/称号是屏幕上三个位置),
2768
+ # 合并只是给匹配器用的主轨。
2769
+ merged = [r for i in [main[0], *band] for r in region_runs.get(i, [])]
2770
+ # **框位门标了的常驻 UI、振り仮名在切分之前拿掉**(`--seg-skip-footprint`,默认开):投影时反正要剔,
2771
+ # 留着切分的话,只含水印的空段会并进下一条 cue、把 cue 起点提前(wuwa-s2 8 条、最多早 3.7 s)。
2772
+ # 标是全局按框位 / 几何定的,切分前已知。主轨不给它们单独成 cue(主轨的叠加本来就不画 UI)
2773
+ n_fp = 0
2774
+ if seg_apart:
2775
+ kept = [r for r in merged if run_idx[id(r)] not in seg_apart]
2776
+ n_fp, merged = len(merged) - len(kept), kept
2777
+ main_srt = f"{tag}-main.srt"
2778
+ main_track = "main"
2779
+ in_main = {id(r) for r in merged}
2780
+ for i in band:
2781
+ for r in region_runs.get(i, []):
2782
+ if id(r) in in_main: # 被拿掉的 UI / 注音不在任何主轨 cue 里,不打"并进了主轨"
2783
+ events[eid_of[id(r)]]["flags"].append("band_merged")
2784
+ segs = cue_segments(merged, **seg_kw)
2785
+ # 并带后的常驻 UI 是**重新判**的:分母换成了合并后的 cue 数,
2786
+ # 同一行在区域轨里够比例、在主轨里不一定够。所以权威清单逐轨存
2787
+ # (tracksio 的 `tracks[].ui_lines`),不是事件上那个方便标记。
2788
+ ui = text_ui_lines(segs, args, ui_kw)
2789
+ # ⚠ **框位门在这里也要生效**:那次翻车(`Auto` 污染 293/595 条主轨 cue)
2790
+ # 就发生在这条并带主轨上。只接区域循环等于没接——主轨是另写的一份。
2791
+ n = write_srt_segments(outdir / main_srt, segs, ui, export_drop, run_idx)
2792
+ doc["tracks"].append({"id": "main", "kind": "main", "regions": [main[0], *band],
2793
+ "label": main[1], "srt": main_srt, "ui_lines": sorted(ui),
2794
+ "cues": cues_from_segments("main", segs, eid_of, events, H)})
2795
+ print(f"主轨并带(--main-band {args.main_band:g}):region{main[0]:02d} + "
2796
+ + "、".join(f"r{i:02d}" for i in band)
2797
+ + f" -> {main_srt},{len(merged)} 条 run、**{n} 条 cue**"
2798
+ + (f"(切分前拿掉框位 UI / 振り仮名 {n_fp} 条 run)" if n_fp else ""))
2799
+ prov = provenance(args, meta, main_srt, main_track,
2800
+ srtio.count_cues(outdir / main_srt) if main_srt else 0)
2801
+ # 候选也写进 provenance:**排名不等于确定正文**,下游要能自己挑
2802
+ # (methodology-audit-4 报告 C3)。
2803
+ prov["main_candidates"] = [
2804
+ {"index": ri, "label": lab, "score": f["primary_score"], "cy": round(f["cy"], 3),
2805
+ "srt": f"{tag}-region{ri:02d}-{lab}.srt", "track": f"r{ri:02d}"}
2806
+ for ri, lab, f in cands[:5]]
2807
+ # **名牌轨**:把打了标的 run 单独投影成一条轨(`kind="nameplate"`)。
2808
+ # 切分走的还是同一套 `cue_segments`——导出器不重新判断(docs/architecture/artifacts.md)。
2809
+ # ⚠ 它和区域轨**有重叠**:同一条 run 既在它自己的区域轨里,也在这条轨里。
2810
+ # 这是有意的(打标不删):用途 1 要在原位看到名牌,用途 2 要把它并进正文。
2811
+ # ⚠ **只收有事件的 run**(审计七):`eid_of` 只在保留下来的区域循环里填,
2812
+ # 而 `--min-persistence > 0` 会丢掉一批区域——它们里面的名牌 run 没有事件,
2813
+ # 收进来就是 `cues_from_segments` 里一个 `KeyError`(默认 0.0 时碰不到,
2814
+ # 而且 `tracksio` 的子集不变量也会在同一处挡住)。
2815
+ np_runs = [r for r in runs if getattr(r, "nameplate", False) and id(r) in eid_of]
2816
+ n_np_orphan = sum(1 for r in runs if getattr(r, "nameplate", False)) - len(np_runs)
2817
+ if n_np_orphan:
2818
+ print(f" 注:{n_np_orphan} 条名牌 run 所在的区域被 --min-persistence "
2819
+ f"{args.min_persistence} 丢掉了,不进名牌轨")
2820
+ if np_runs:
2821
+ np_segs = cue_segments(sorted(np_runs, key=lambda r: r.t_start), **seg_kw)
2822
+ np_name = f"{tag}-nameplate.srt"
2823
+ write_srt_segments(outdir / np_name, np_segs, set())
2824
+ doc["tracks"].append({"id": "np", "kind": "nameplate", "region": -1,
2825
+ "label": "nameplate", "srt": np_name, "ui_lines": [],
2826
+ "cues": cues_from_segments("np", np_segs, eid_of, events, H)})
2827
+ print(f"名牌轨:{len(np_segs)} 条 cue -> {np_name}")
2828
+
2829
+ doc["provenance"] = prov
2830
+ # 对齐方式在这里判、写进区域(叠加导出只照着锚边,`flowocr.analyze.align`)
2831
+ n_align = align.annotate(doc)
2832
+ print("对齐:" + "、".join(f"{a}({by}){k}" for (a, by), k in sorted(n_align.items())) + " 个区域")
2833
+ # **一级产物最后写**:回抠在内存里结算、SRT 重出成功之后才落 tracks.json(开头已删掉上一轮的那份)。
2834
+ # 中途哪一步失败都不留下 tracks.json——驱动判缓存只认它,留着一份就会配着采样级或写了一半的 SRT 被复用(2026-09-26 复查)
2835
+ doc = json.loads(json.dumps(doc)) # 结算吃的和落盘再读回来的是同一种结构(tuple 都成 list),同以前"写了再读回来结算"
2836
+ if args.refine == "auto" and settle_refine(doc, Path(args.obs), tag, args.refine_salvage_end):
2837
+ from flowocr.output import export
2838
+ export.export_srt(doc, outdir) # SRT 换成帧级时刻
2839
+ tracksio.dump(outdir / f"{tag}-tracks.json", doc)
2840
+
2841
+ win_note = f"窗 {args.window_sec:g}s × {n_windows}" if args.window_sec > 0 else "不分窗"
2842
+ ci = _ratio.cache_info()
2843
+ print(f"{tag}: {len(obs)} obs -> {len(runs)} runs -> {len(tracks)} tracks -> {len(report)} regions"
2844
+ f" ({W}x{H}, {dur_us / 1e6:.0f}s, 采样 {meta['sample_fps']:.1f}fps, {win_note})"
2845
+ f";文本相似度缓存命中 {ci.hits}/{ci.hits + ci.misses}(满 {ci.currsize}/{ci.maxsize})")
2846
+ cols = ("#", "label", "lang", "主轨分", "trk", "run", "pers", "fillA", "domi",
2847
+ "dur", "cx", "cy", "yspan")
2848
+ print(" ".join(f"{c:>{n}}" for c, n in zip(cols, (2, 20, 7, 8, 4, 5, 5, 5, 5, 5, 5, 5, 5))))
2849
+ for ri, lab, f in report:
2850
+ star = " ←主轨" if main and ri == main[0] else ""
2851
+ print(f"{ri:>2} {lab:<20} {(f['lang_mix'] or f['lang']):>7} {f['primary_score']:>8.1f} "
2852
+ f"{f['n_tracks']:>4} {f['n_runs']:>5} {f['persistence']:>5.2f} "
2853
+ f"{f['fill_active']:>5.2f} {f['dominant_share']:>5.2f} {f['median_dur_s']:>5.1f} "
2854
+ f"{f['cx']:>5.2f} {f['cy']:>5.2f} {f['y_span']:>5.2f}{star}")
2855
+
2856
+ # **"没有可接受的主轨"是一个正当结果,不是要藏起来的失败。**
2857
+ # 屏上没有正文的片段(菜单、片头、探索态)本来就不该硬指一条
2858
+ # (methodology-audit-4 报告 C3)。
2859
+ if not main:
2860
+ best = max(report, key=lambda x: x[2]["primary_score"], default=None)
2861
+ print(f"**没有可接受的主轨**:没有区域同时满足『分数 > "
2862
+ f"{max(0.0, args.main_min_score):g}』和『label 属于 {'/'.join(MAIN_LABELS)}』。"
2863
+ + (f"分最高的是 region{best[0]:02d}({best[1]},{best[2]['primary_score']:.1f} 分),"
2864
+ f"**它没被当成主轨**。" if best else "")
2865
+ + "\n 下游拿 provenance 里的 main_srt(现在是空的)应当明确失败,"
2866
+ "不要退回去 glob 猜。")
2867
+ elif len(cands) > 1:
2868
+ alt = "、".join(f"region{ri:02d}({lab} {f['primary_score']:.1f})"
2869
+ for ri, lab, f in cands[1:4])
2870
+ print(f"主轨候选(排名不等于确定正文):**region{main[0]:02d} "
2871
+ f"{main[1]} {main[2]['primary_score']:.1f} 分**;其次 {alt}")
2872
+
2873
+ # **删了什么要打出来。** 静默删除正是上一次事故的形状:加了常驻 UI 剔除之后
2874
+ # 磁盘上多出 89–139 个空的区域 SRT,一行日志都没有(methodology-audit-2 报告)。
2875
+ if DROPPED_UI:
2876
+ tot_l = sum(x[2] for x in DROPPED_UI)
2877
+ tot_c = sum(x[3] for x in DROPPED_UI)
2878
+ print(f"常驻 UI 剔除(--ui-share {args.ui_share} / --ui-min-support "
2879
+ f"{args.ui_min_support}):{len(DROPPED_UI)}/{len(report)} 个区域受影响,"
2880
+ f"共删掉 {tot_l} 行、其中 {tot_c} 条 cue 被整条删掉")
2881
+ for name, ui, dl, dc in sorted(DROPPED_UI, key=lambda x: -x[2])[:8]:
2882
+ print(f" {name:<44} -{dl:>6} 行 / -{dc:>5} 条 {sorted(ui)[:6]}")
2883
+ return 0
2884
+
2885
+ if __name__ == "__main__":
2886
+ raise SystemExit(main())