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,409 @@
1
+ """把条目的起止时间回抠到视频帧精度,并分辨文字的出现方式——**结算**那一半 + CLI。
2
+
3
+ 每条条目给三个时刻而不是两个:
4
+
5
+ t_start 首字出现
6
+ t_full 全字出现
7
+ t_end 全字消失
8
+
9
+ `t_end` 用框内像素的归一化相关系数:拿 run 中间那次观测的裁剪当模板,最后一帧仍像它就还在。
10
+ **首字 / 全字是多信号融合**(`flowocr.extract.typewriter_fuse`,2026-09-12 起):OCR 中途读数外推、
11
+ 首字格"开始出现"、逐字格定稿时刻的稳健直线、尾字格定稿,OCR 证据给硬区间,投票取同意最多的那簇。
12
+ 原来的"整框相关系数过门"在打字机上系统性偏早(最后一两个字对整框贡献太小,没打完就过门),
13
+ 五款游戏的盲标上全字出现几乎全在 4 帧以外(text-effects 报告)。
14
+
15
+ 2026-09-22 拆成两半(正规化 project-structure):**像素测量**在 `flowocr.extract.refine_video`(开视频、取裁剪、
16
+ `measure()` 写 `job["measured"]`),这里只做**在已有证据上结算**——`refine_from_obs` 从 obs 行的 `edge`(在线量的)
17
+ 重建同一种 `measured`,`apply` 把 `measured` 结算成三个时刻,`attach_ocr` 挂 OCR 中途读数。`refine_tracks` 串起
18
+ "测量 + 结算":`build_tracks --refine auto`(默认,2026-09-25)用 obs 证据那条路在建轨末尾结算;开视频测的两条路(cv2 / ffmpeg)只给开发探针
19
+ `dev_tools/refine_offline_probe.py` 核对在线证据用。**没有命令行入口**(owner 2026-09-26:测量只在阶段 1、结算只在阶段 2,不另存 `-tracks-refined.json`)。
20
+ 提取侧的名字这里 re-export,老调用方的 `rb.make_job` / `rb.measure` 不变。
21
+
22
+ 2 fps 采样意味着边界天然有 ±0.25 s 的量化误差,而真正缺的信息只在**每条 run 首尾那一段**里:
23
+ 单向顺序解码一遍视频(seek 比顺序读贵 34 倍,boundary-refinement 报告),窗内的帧 retrieve、窗外只 grab。
24
+
25
+ obs 里没有在线证据(旧 obs、`--no-refine-fused`、非默认的解码器 / 复用设置)时,建轨停在采样级并提示;
26
+ 要帧级时刻就用默认设置重跑阶段 1(`run_ocr2` 默认在线量证据)。
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import bisect
31
+ import json
32
+ import statistics
33
+ from collections import defaultdict
34
+ from pathlib import Path
35
+
36
+ from flowocr.analyze import build_tracks as bt
37
+ from flowocr.artifacts import tracksio
38
+ from flowocr.extract import edge_refine, ptsclock
39
+ from flowocr.extract import typewriter_fuse as TF
40
+ from flowocr.extract.framegrid import TimeGrid
41
+ from flowocr.extract.refine_video import ( # noqa: F401 提取那一半(re-export 给老调用方)
42
+ LEAD_US, PAD, SEEK_WORTH_FRAMES, crop_of, first_true, last_true, make_job, measure, refine_ffmpeg,
43
+ refine_sequential, video_codec, video_fps, wants,
44
+ )
45
+
46
+
47
+ CODE_FP_FILES = ("src/flowocr/analyze/refine_boundaries.py", "src/flowocr/extract/refine_video.py",
48
+ "src/flowocr/extract/typewriter_fuse.py",
49
+ "src/flowocr/extract/edge_refine.py", "src/flowocr/artifacts/tracksio.py",
50
+ # `ptsclock` 定的是窗口开在哪一帧、以及帧号换回什么时刻——**它一变,回抠的数就变**
51
+ # (2026-09-18 审计补上;`framesource` 的窗口 seek 也读同一份时钟)
52
+ "src/flowocr/extract/ptsclock.py", "src/flowocr/extract/framesource.py")
53
+ """回抠这一级的代码指纹覆盖哪些文件(口径同 build_tracks.code_fp,仓库相对路径)。"""
54
+
55
+ FADE_MAX_US = 500_000
56
+ """完全显示结束最多比完全消失早这么多(30 帧 @60fps);实测最长淡出约 18 帧。"""
57
+
58
+ MAX_GROW_US = 30_000_000
59
+ """按 obs 证据回抠时,一条 run 的采样级全字最多比它的起点晚这么久(找段首行只往前看这么远)。"""
60
+
61
+ def obs_sample_grid(meta: dict) -> TimeGrid:
62
+ """obs 的采样网格(帧号上):`_meta.sample_grid`(2026-09-24 起);更早的只有等距的 `stride`。"""
63
+ if meta.get("sample_grid"):
64
+ return TimeGrid(*meta["sample_grid"])
65
+ if meta.get("stride"):
66
+ return TimeGrid.every(int(meta["stride"]))
67
+ raise ValueError("obs 的 _meta 既没有 sample_grid 也没有 stride:判不了回抠证据的查找终点(开放边界),用默认设置重跑阶段 1")
68
+
69
+
70
+ def refine_from_obs(jobs: list[dict], obs_path: Path, us_per_frame: float, frame_us: int,
71
+ salvage_stale_end: bool = bt.REFINE_SALVAGE_END) -> dict:
72
+ """两遍合一遍(flowocr.extract.edge_refine):不开视频,从 obs 行的 `edge` 证据重建 `measure()` 的输出。
73
+ 段首行的 `on` 给首字那一侧、采样级全字之前最后一段的 `on` 给全字那一侧、run 末行的 `off` 给结尾。
74
+ `salvage_stale_end`:被回补作废、窗口却凑齐了的结尾证据照样认领(理由见认领处)。返回对账用的计数。"""
75
+ if not jobs:
76
+ return {}
77
+ n_salvage = got_salvaged = 0
78
+ order = sorted(jobs, key=lambda j: j["run"]["t_start"])
79
+ starts = [j["run"]["t_start"] for j in order]
80
+ # "前一票"(起点前一个采样点)按 **1.5 个名义间隔**找,不按恰好一个间隔:时间网格上采样间隔不等距(差一帧,2026-09-24),
81
+ # gi-s2 --fps 2.2 上前一票在 466.7 ms 前、名义间隔 454.5 ms,差 12 ms 整条证据没认领上。等距时认领到的集合不变(再前一票在 2 个间隔处)
82
+ prev_tol = int(1.5 * frame_us)
83
+ by_last: dict[int, list[dict]] = defaultdict(list)
84
+ for j in jobs:
85
+ by_last[j["run"]["t_end"] - frame_us].append(j)
86
+ n_on = n_off = 0
87
+ # 证据行和 run 的配法:**同一行 + x 重叠过半**(`edge_refine.overlap_match`),不是 ocr_points 那个"左沿差不到 1.5 个字高"——
88
+ # det 把一行切成两个框时(gi-s2 `ハツ。` + `もしオレが…`),宽框那条链的证据窗才是全字那一侧的,
89
+ # 它的左沿离 run 左沿 100 px,按左沿判被拒、只剩窄框那条早了 48 帧的证据(2026-09-16)。
90
+ # 同一时刻多条都配上:取 x 重叠最大的;全字那一侧再在所有候选里取模板最晚(hi 最大)的
91
+ with open(obs_path, encoding="utf-8") as f:
92
+ sgrid = obs_sample_grid(json.loads(next(f))["_meta"])
93
+ for line in f:
94
+ if '"edge"' not in line:
95
+ continue
96
+ o = json.loads(line)
97
+ ev, t, box = o.get("edge") or {}, o["t_us"], o["box"]
98
+ if "on" in ev and edge_refine.usable(ev["on"]):
99
+ n_on += 1
100
+ for j in order[bisect.bisect_left(starts, t - MAX_GROW_US):bisect.bisect_right(starts, t + prev_tol)]:
101
+ run = j["run"]
102
+ tfs = run.get("t_full_sampled")
103
+ # 采样级全字之后**一个采样间隔内**起的段也算(两遍的打字机窗到 t_full_sampled + span 为止:
104
+ # 最后一两个字在 90% 那一票之后才打完,全字那一侧的证据在那一段上)
105
+ # 移动的 run(有 boxes 轨迹)拿起点那一帧的框配;起点前一个采样点起的段也收(打字机第一票
106
+ # 被 build_tracks 单独切成一条时,段首证据挂在那一票上),段的窗要盖到 run 起点才算
107
+ ov = edge_refine.overlap_match(box, run["boxes"][0][1] if run.get("boxes") else run["box"])
108
+ if ov and run["t_start"] - prev_tol <= t <= (tfs if tfs is not None else run["t_start"]) + prev_tol \
109
+ and (t >= run["t_start"]
110
+ or edge_refine.rec_time(ev["on"], ev["on"]["hi"], us_per_frame)
111
+ >= run["t_start"] - us_per_frame):
112
+ j.setdefault("ev_on", []).append((t, ov, ev["on"]))
113
+ off = ev.get("off")
114
+ # 被回补作废的结尾证据(`salvage_stale_end`):回补改写的是那一段的**文本**,结尾窗量的是框里像素什么时候变、
115
+ # 模板是末次观测那一帧——按"末次观测时刻 + 框"认领到 run 上(下面 by_last + overlap_match),这两样回补不改,证据仍然量的是它
116
+ salvage = bool(salvage_stale_end and off and off.get("stale") and not off.get("short"))
117
+ if off is not None and (edge_refine.usable(off) or salvage):
118
+ n_off += 1
119
+ n_salvage += salvage
120
+ use = dict(off, stale=False) if salvage else off
121
+ for j in by_last.get(t, []):
122
+ rb_ = j["run"]["boxes"][-1][1] if j["run"].get("boxes") else j["run"]["box"]
123
+ ov = edge_refine.overlap_match(box, rb_)
124
+ if ov and ov > j.get("ev_off_ov", 0.0):
125
+ j["ev_off"], j["ev_off_ov"] = use, ov
126
+ j["ev_off_salvaged"] = salvage
127
+ got_first = got_last = got_off = 0
128
+ for j in jobs:
129
+ run = j["run"]
130
+ ons = j.get("ev_on", [])
131
+ # 退一步:起点**前一票**起的段。前一票 = 起点之前 1.5 个名义间隔内最晚的那一票——不按 `t_start − frame_us` 精确相等配:
132
+ # 时间网格上采样间隔不等距(差一帧),59.94 fps 素材上 pts 取整也会让间隔差 1 µs(2026-09-24);等距整数帧率时候选只有原来那一票
133
+ before = [t for t, _, _ in ons if run["t_start"] - prev_tol <= t < run["t_start"]]
134
+ at_start = [(ov, r) for t, ov, r in ons if t == run["t_start"]] or \
135
+ [(ov, r) for t, ov, r in ons if before and t == max(before)]
136
+ first = max(at_start, key=lambda x: x[0])[1] if at_start else None
137
+ last = max(ons, key=lambda x: (x[2]["hi"], x[1]))[2] if ons else None
138
+ if first is None: # run 起点那一行没有段首证据(链从更早接过来):首字那一侧没法量
139
+ last = None
140
+ got_first += first is not None
141
+ got_last += last is not None
142
+ got_off += "ev_off" in j
143
+ got_salvaged += bool(j.get("ev_off_salvaged"))
144
+ m = edge_refine.measured_from_records(first, last, j.get("ev_off"), run["t_end"], us_per_frame, frame_us, sgrid)
145
+ if m is not None:
146
+ j["measured"] = m
147
+ # `on_rows` / `off_rows` 数的是**能用的**证据行(`edge_refine.usable`:窗口凑齐、没被回补作废;开了救回时含救回的结尾证据);
148
+ # 作废的那些在 obs 的 `_meta.edge.stale_windows` / `_meta.reuse_v2.edge_stale` 里数
149
+ out = {"on_rows": n_on, "off_rows": n_off, "jobs": len(jobs), "jobs_with_on": got_first,
150
+ "jobs_with_off": got_off}
151
+ if salvage_stale_end:
152
+ out.update(off_rows_salvaged=n_salvage, jobs_with_off_salvaged=got_salvaged)
153
+ return out
154
+
155
+ def apply(job: dict, v_ocr: float | None, v_cell: float | None) -> None:
156
+ """全片解码完再调:首字外推要借全片的打字速度中位数(`v_cell` 来自各条的逐字格斜率),所以融合放在这里。"""
157
+ if "measured" not in job:
158
+ return
159
+ new_e, ok_gone, sig, last_full, end_how = job.pop("measured")
160
+ run = job["run"]
161
+ old_s, old_e = run["t_start"], run["t_end"]
162
+ est = None
163
+ if sig is not None:
164
+ tfs = run.get("t_full_sampled")
165
+ est = TF.combine(sig, old_s, tfs if tfs is not None else old_s, job["frame_us"], job["ocr"], v_ocr, v_cell)
166
+ if est is not None and est["first_how"] == est["full_how"] == "none":
167
+ est = None # 两个时刻都只是采样级的退回值,不算量出来了
168
+ if not ok_gone:
169
+ job["no_evidence"] = True
170
+ if est is None:
171
+ return
172
+ if est is not None:
173
+ new_s, new_full = est["first"], est["full"]
174
+ else:
175
+ new_s, new_full = old_s, None
176
+ # 自选范围的时间关闭段截断的那一端**不找自然边界**(ocr-regions 计划):
177
+ # "范围在那一刻打开 / 关上"不等于"文字在那一刻出现 / 消失",回抠也不许量到范围外的时段
178
+ flags = run.get("flags") or ()
179
+ if "clipped_start" in flags:
180
+ new_s = old_s
181
+ if "clipped_end" in flags:
182
+ new_e = old_e
183
+ if new_e <= new_s:
184
+ return
185
+ run["t_start"], run["t_end"] = new_s, new_e
186
+ # 字段留着、值为空——"没量出来"和"没有这个字段"是两回事
187
+ run["t_full"] = None if new_full is None else min(max(new_full, new_s), new_e)
188
+ if est is not None:
189
+ run["t_full_how"] = {"first": est["first_how"], "full": est["full_how"]}
190
+ if run["t_full"] is not None:
191
+ run["grow_ms"] = round((run["t_full"] - run["t_start"]) / 1000)
192
+ # 完全显示结束:不早于全字出现(没有就首字)、不晚于完全消失;没量出来就留 None(同 t_full 的规矩)
193
+ # 另一道下限:不早于完全消失前 FADE_MAX_US——清晰显示短了难看、长了无妨(owner 2026-09-12);
194
+ # 实测最长淡出约 18 帧(原神慢淡出 / 星铁整屏渐黑),30 帧留余量
195
+ floor = max(run["t_full"] if run["t_full"] is not None else new_s, new_e - FADE_MAX_US)
196
+ run["t_full_end"] = None if last_full is None else min(max(last_full, floor), new_e)
197
+ if end_how:
198
+ run["t_end_how"] = end_how
199
+ job["refined"] = True
200
+ job["moved"] = run["t_start"] != old_s or run["t_end"] != old_e
201
+ job["d_start"] = abs(run["t_start"] - old_s) / 1e6
202
+ job["d_end"] = abs(run["t_end"] - old_e) / 1e6
203
+
204
+ def attach_ocr(jobs: list[dict], obs_path: Path) -> float | None:
205
+ """把每条 job 打字机窗里的 OCR 观测挂上去(`typewriter_fuse.ocr_points`),返回这段视频的典型打字速度(µs/字)。
206
+ obs 只流式扫一遍:按窗口并集二分,不整份读进内存。"""
207
+ wins = sorted(((j["run"]["t_start"], j["run"].get("t_full_sampled") or j["run"]["t_start"], j) for j in jobs),
208
+ key=lambda w: w[:2])
209
+ starts = [w[0] for w in wins]
210
+ ends_max = [] # 前缀最大终点:二分到的左边界之前也可能有窗盖住 t
211
+ m = -1
212
+ for _, e, _ in wins:
213
+ m = max(m, e)
214
+ ends_max.append(m)
215
+ rows: dict[int, list] = defaultdict(list)
216
+ with open(obs_path, encoding="utf-8") as f:
217
+ next(f) # _meta
218
+ for line in f:
219
+ i = line.find('"t_us": ')
220
+ if i < 0:
221
+ continue
222
+ t = int(line[i + 8:line.find(",", i)])
223
+ k = bisect.bisect_right(starts, t) - 1
224
+ if k < 0 or ends_max[k] < t:
225
+ continue
226
+ o = json.loads(line)
227
+ rows[t].append((t, o["box"], o["text"]))
228
+ ts_sorted = sorted(rows)
229
+ for s, e, j in wins:
230
+ a, b = bisect.bisect_left(ts_sorted, s), bisect.bisect_right(ts_sorted, e)
231
+ cand = [r for t in ts_sorted[a:b] for r in rows[t]]
232
+ j["ocr"] = TF.ocr_points(cand, j["box"], TF.chars(j["run"]["text"]))
233
+ vs = [v for j in jobs if (v := TF.ocr_slope(j["ocr"], len(TF.chars(j["run"]["text"]))))]
234
+ # 样本太少不给(`TF.OCR_SLOPES_MIN` 的来历):两三句的中位数就是那两三句自己,一句假读数就能把全片速度定歪
235
+ return statistics.median(vs) if len(vs) >= TF.OCR_SLOPES_MIN else None
236
+
237
+ DEFAULT_LABELS = "subtitle,dialogue-or-caption"
238
+ """默认只回抠这些标签的区域(CLI `--labels` 的默认值;build_tracks 的 `--refine auto` 用同一个)。"""
239
+
240
+
241
+ def obs_evidence(obs_path: Path) -> tuple[dict, bool]:
242
+ """obs 的 `_meta`,以及里面**真的有**在线回抠的证据没有。要"真的有证据"才算,不是"配置说开了融合"
243
+ (2026-09-17 审计):`--refine-aux noop` 那条空臂配置一模一样、产物里一条 `edge` 都没有;这种 obs 以前会被
244
+ auto 挑成 obs,然后整段静默退回采样级。"""
245
+ meta: dict = {}
246
+ if obs_path.is_file():
247
+ with open(obs_path, encoding="utf-8") as f:
248
+ meta = json.loads(f.readline()).get("_meta", {}) or {}
249
+ edge = meta.get("edge") or {}
250
+ fused = bool(meta.get("config", {}).get("refine_fused")) and not edge.get("noop") and int(edge.get("on", 0)) > 0
251
+ return meta, fused
252
+
253
+
254
+ def refine_tracks(data: dict, *, decoder: str, fps: float, obs: Path | None, video: str = "", thr: float = 0.80,
255
+ labels: str = DEFAULT_LABELS, regions: str = "", window: tuple[int, int] | None = None,
256
+ step: int = 1, scale: float = 1.0, progress_every: int = 200, obs_code_fp: str = "",
257
+ salvage_stale_end: bool = bt.REFINE_SALVAGE_END) -> dict:
258
+ """在 `data`(`tracksio.load` 读出的 tracks)上**就地**回抠:测量(obs 证据 / 开视频)-> 结算三个时刻 ->
259
+ 整段文字效果 `text_effect` -> `boundary_refined` -> cue 首尾按成员重算。返回打印用的统计。
260
+ `decoder="obs"`(读 obs 里的在线证据)是产物流程唯一的路:`build_tracks --refine auto` 在建轨末尾调它;
261
+ `"cv2"` / `"ffmpeg"`(开视频测)只给开发探针 `dev_tools/refine_offline_probe.py` 核对在线证据。"""
262
+ # **回抠之前把每个事件的首尾抄一份**:cue 的边界只跟着"当初生成它的那个成员边界"走,
263
+ # 靠这份快照配对(tracksio.refresh_cue_bounds 的注释里写了为什么不能按 min/max 重算)。
264
+ before = tracksio.event_times(data)
265
+ # 区域视图**另存一个变量**:`data` 本身要原样写回去,
266
+ # 把带 runs 的视图塞回 data["regions"] 会在产物里多出一份重复的事件。
267
+ regions_view = tracksio.regions_with_runs(data)
268
+ frame_us = data["frame_us"]
269
+ want = None if labels == "all" else set(labels.split(","))
270
+ only = {int(x) for x in regions.split(",") if x.strip()} if regions else None
271
+ us_per_frame = 1_000_000 / fps
272
+
273
+ n_runs = n_moved = 0
274
+ start_shifts: list[float] = []
275
+ end_shifts: list[float] = []
276
+ how: dict[str, int] = defaultdict(int)
277
+ evidence: dict = {}
278
+ if obs is None or not obs.is_file():
279
+ raise SystemExit(f"找不到 OCR 观测 {obs}(首字 / 全字融合要用中途读数):用 --obs 指定")
280
+ # 帧号 <-> 真实时间:拿 obs 逐采样点的 pts 当锚点(`ptsclock.IndexClock`,见 make_job)。
281
+ # 没有锚点(旧产物的时间轴不是 pts)时它就是原来的常量换算
282
+ clock = ptsclock.index_clock_from_obs(obs, us_per_frame)
283
+ print(f" [时钟] {len(clock)} 个 pts 锚点"
284
+ + ("(帧号 × 1/src_fps,旧时间轴)" if not len(clock) else
285
+ f";不锚的话首尾差 {(clock.t_of(clock.f[-1]) - clock.f[-1] * us_per_frame) / 1000:+.1f} ms"), flush=True)
286
+ jobs = [make_job(run, frame_us, us_per_frame, clock)
287
+ for region in regions_view
288
+ if (only is None or region["index"] in only)
289
+ and (only is not None or want is None or region["label"] in want)
290
+ for run in region["runs"]
291
+ if not window or (run["t_start"] < window[1] and run["t_end"] > window[0])]
292
+ v_ocr = attach_ocr(jobs, obs)
293
+ if decoder == "obs":
294
+ evidence = refine_from_obs(jobs, obs, us_per_frame, frame_us, salvage_stale_end)
295
+ evidence["obs_code_fp"] = obs_code_fp # 产这些证据的是哪一版代码(不是本工具的 code_fp)
296
+ print(f" [证据] obs 里 on {evidence.get('on_rows', 0)} 行 / off {evidence.get('off_rows', 0)} 行;"
297
+ f"{evidence.get('jobs', 0)} 条 run 里段首证据 {evidence.get('jobs_with_on', 0)}、"
298
+ f"结尾证据 {evidence.get('jobs_with_off', 0)}"
299
+ + (f"(其中救回被回补作废的 {evidence['jobs_with_off_salvaged']} 条)" if "jobs_with_off_salvaged" in evidence else ""),
300
+ flush=True)
301
+ # 一条都没认领到 = 这份 obs 配不上这份轨(换了段、换了区域、或者证据全被作废)。
302
+ # **静默退回采样级是错的**,口径同 compare_timing"配上 0 条非零退出"
303
+ if jobs and not evidence["jobs_with_on"] and not evidence["jobs_with_off"]:
304
+ raise SystemExit(f"[证据] {len(jobs)} 条 run 一条证据都没认领到(obs {obs}):"
305
+ f"obs 和轨对不上,或者证据都被回补作废了。要开视频就给 --decoder ffmpeg / cv2")
306
+ elif decoder == "ffmpeg":
307
+ if step > 1:
308
+ for j in jobs:
309
+ j["step"] = step
310
+ # 窗口两端对齐到 step 的格子,不然"两端都在"永远不成立
311
+ j["tw_lo"] -= j["tw_lo"] % step
312
+ j["hi_start"] -= j["hi_start"] % step
313
+ j["end_lo"] -= j["end_lo"] % step
314
+ j["end_hi"] -= j["end_hi"] % step
315
+ j["lo"] = min(j["lo"], j["tw_lo"], j["end_lo"])
316
+ refine_ffmpeg(video, jobs, thr, progress_every, step=step, scale=scale)
317
+ elif step > 1 or scale < 1.0:
318
+ raise SystemExit("--step / --scale 只在 ffmpeg 窗口模式下有效(--decoder ffmpeg)")
319
+ else:
320
+ refine_sequential(video, jobs, thr, progress_every)
321
+ # 打字速度:OCR 中途读数拟得出来就用它;拟不出来(魔裁:打字快,中途读数都只有一个)
322
+ # 借逐字格直线斜率的全片中位数——**只给首字外推用**,借给全字外推在淡入字体上系统性偏晚
323
+ speeds = [v for j in jobs if (v := TF.cell_speed(j["measured"][2] if "measured" in j else None))]
324
+ v_cell = statistics.median(speeds) if speeds else None
325
+ for j in jobs:
326
+ apply(j, v_ocr, v_cell)
327
+ # n_runs 只数**真回抠成功**的(模板取失败、终点没观测到的不算)
328
+ done = [j for j in jobs if j["refined"]]
329
+ n_runs = len(done)
330
+ n_moved = sum(1 for j in done if j["moved"])
331
+ start_shifts = [j["d_start"] for j in done]
332
+ end_shifts = [j["d_end"] for j in done]
333
+ for j in done:
334
+ h = j["run"].get("t_full_how")
335
+ how[f"首字 {h['first']}" if h else "首字 未量"] += 1
336
+ how[f"全字 {h['full']}" if h else "全字 未量"] += 1
337
+ how[f"消失 {j['run'].get('t_end_how', '未量')}"] += 1 # open = 窗里没看到消失,t_end 只是下界
338
+
339
+ # 打字机是**整段素材的属性**,不是逐条猜出来的。
340
+ # 判据是"每字生长时间"的离散度:真打字机的速度是常数,分布很紧;
341
+ # 淡入、场景切换这类杂音测出来的 grow 则四散。
342
+ rates, grows = [], []
343
+ for reg in regions_view:
344
+ for r in reg["runs"]:
345
+ n = len(TF.chars(r.get("text", "")))
346
+ if "grow_ms" in r and n >= 4:
347
+ rates.append(r["grow_ms"] / n)
348
+ grows.append(r["grow_ms"])
349
+ effect = {"samples": len(rates)}
350
+ if len(rates) >= 8:
351
+ rates.sort()
352
+ q1, med, q3 = (rates[len(rates)//4], statistics.median(rates), rates[3*len(rates)//4])
353
+ spread = (q3 - q1) / med if med > 0 else float("inf")
354
+ # 分三档而不是二值:owner 说判读不求完美,标出"可能是"就够。
355
+ # **不设 ms/char 的绝对下限**(owner 2026-09-04);唯一的物理下限是"生长必须长到视频帧分辨得出来"。
356
+ med_grow = statistics.median(grows) if grows else 0
357
+ verdict = ("instant" if med_grow < us_per_frame / 1000 else
358
+ "typewriter" if spread <= 0.7 else
359
+ "likely-typewriter" if spread <= 1.0 else "instant")
360
+ effect.update({"ms_per_char_median": round(med, 1), "iqr_over_median": round(spread, 2),
361
+ "verdict": verdict})
362
+ # 淡出:整段素材的属性,和打字机并列记(有 t_full_end 的条目里,完全显示结束到完全消失的中位时长)
363
+ fades = sorted((r["t_end"] - r["t_full_end"]) / 1000 for reg in regions_view for r in reg["runs"]
364
+ if r.get("t_full_end") is not None and r.get("t_end_how") == "curve")
365
+ if fades:
366
+ effect["fade_out_ms_median"] = round(statistics.median(fades), 1)
367
+ effect["fade_out_ms_p75"] = round(fades[3 * len(fades) // 4], 1)
368
+ effect["fade_out_samples"] = len(fades)
369
+ data["text_effect"] = effect
370
+ mean_start = sum(start_shifts) / len(start_shifts) if start_shifts else 0.0
371
+ mean_end = sum(end_shifts) / len(end_shifts) if end_shifts else 0.0
372
+ data["boundary_refined"] = {"thr": thr, "labels": labels, "regions": regions or "all",
373
+ "typewriter": "fuse", "code_fp": bt.code_fp(CODE_FP_FILES),
374
+ "decoder": decoder, "step": step, "scale": scale,
375
+ **({"evidence": evidence} if decoder == "obs" else {}),
376
+ "runs": n_runs, "moved": n_moved, "how": dict(how),
377
+ # 起点/终点分开报,别再合成一个"平均移动"
378
+ "mean_start_shift_s": round(mean_start, 4),
379
+ "mean_end_shift_s": round(mean_end, 4)}
380
+ # 回抠**只更新时间**:cue 的首尾按成员重算,切分本身一个字不动(audit-4 C6)。
381
+ # 起点口径(first / full)**不写进 JSON**,交给导出器投影——写回去就切不回来了
382
+ tracksio.refresh_cue_bounds(data, before)
383
+ return {"runs": n_runs, "moved": n_moved, "mean_start": mean_start, "mean_end": mean_end, "how": dict(how)}
384
+
385
+
386
+ def refine_code_changed(doc: dict) -> str | None:
387
+ """产物回抠过(有 `boundary_refined`)、而产它的回抠代码和现在的不一样时,返回一句原因;否则 None。
388
+ 评测驱动判建轨缓存用:`--refine auto` 起建轨产物里的时刻由这一级的代码算,只看 build_tracks.py 的 mtime
389
+ 会在只改了回抠代码时静默复用旧产物(2026-09-26 复查)。"""
390
+ br = doc.get("boundary_refined")
391
+ if not br:
392
+ return None
393
+ now = bt.code_fp(CODE_FP_FILES)
394
+ return None if br.get("code_fp") == now else f"回抠代码指纹对不上:产物是 {br.get('code_fp')},现在是 {now}"
395
+
396
+
397
+ def summary_lines(tag: str, st: dict, data: dict, fps: float, decoder: str) -> list[str]:
398
+ """回抠之后打印的几行(CLI 与 build_tracks 同一份)。"""
399
+ out = [f"{tag}: 回抠成功 {st['runs']} 条,其中 {st['moved']} 条边界被移动,"
400
+ f"起点平均移动 {st['mean_start']:.3f}s、终点 {st['mean_end']:.3f}s"
401
+ f"({fps:.0f} fps,采样间隔 {data['frame_us']/1e6:.2f}s,{decoder})"]
402
+ if st["how"]:
403
+ out.append(" 首字 / 全字 / 消失从哪来:" + "、".join(f"{k} {v}" for k, v in sorted(st["how"].items())))
404
+ eff = data["text_effect"]
405
+ if "verdict" in eff:
406
+ out.append(f" 文字效果:**{eff['verdict']}** —— 每字生长 {eff['ms_per_char_median']} ms/char,"
407
+ f"离散度 IQR/中位 = {eff['iqr_over_median']}"
408
+ f"(≤0.7 打字机 / ≤1.0 疑似 / 其余一次性,样本 {eff['samples']} 条)")
409
+ return out