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,231 @@
1
+ """OCR 产物"跑完没有"的判据。**单独放一个文件是为了让守卫够得着。**
2
+
3
+ 来历:C8 那次的教训(audit4-response 报告)——判据写在
4
+ paddleocr 实验目录 里的脚本顶层,而那个文件 import cv2 和 paddleocr,
5
+ **共享层的自检够不着,够不着就等于测不了**。这条判据同样属于"错了不会报错"
6
+ 的一类,所以照 `dev_tools/predet_stop.py` 的先例抽出来。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ COVER_TOL = 0.98
13
+ """容器元数据允许虚报多少。`CAP_PROP_FRAME_COUNT` 在游戏切片上多报 1%
14
+ (声称 36,361、实际 36,001)——这不是没跑完,是元数据不准。"""
15
+
16
+
17
+ def is_complete(got: int, want: int, tol: float = COVER_TOL) -> bool:
18
+ """推进到的帧数够不够。
19
+
20
+ **不要再把"解码器提前返回"当成没跑完**(2026-09-08):读到文件真正的结尾
21
+ 也会提前返回,于是九段切片每次跑完都自称未完成、退出码 3,
22
+ 驱动脚本会永远重跑它们。当时是把产物里的 `complete` 手工回填了——
23
+ **改数据没改代码**。
24
+
25
+ 中途真断了这道门自己会拦:40% 处解码失败时 `got/want` 就是 0.4。
26
+ """
27
+ return got >= max(1, want) * tol
28
+
29
+
30
+ def aux_shortfall(edge_meta: dict | None, last_idx: int | None, grid) -> str | None:
31
+ """辅助流(两遍合一遍的回抠证据)有没有**交够**:返回问题(None = 没问题)。**纯函数,守卫直接测。**
32
+
33
+ 2026-09-24 审计(Claude / Codex 两份都指出):辅助流出错原来只记进 `_meta.edge`、`complete` 照样为真,
34
+ 于是回抠证据半路断掉的 obs 会被一直复用。**`aux_stopped_early` 不能当判据**——有丢帧缺口的素材上单路也会置上
35
+ (辅助流按数量收口、缺口处凑不够数,f3 / f4 每一臂都是 True)。判据是两条:
36
+ ①pts 和帧对不上(`aux_misaligned`,停下来的那一刻起证据就没了);②**辅助流没盖到采样流的最后一帧**
37
+ (同一个 ffmpeg 出两路,最后一个辅助帧号不该早于 `grid.cover_floor(最后一个采样帧)`;`grid` 是 `AuxGrid`)。
38
+ `aux_last_key` 不在(旧产物 / 不经 `AuxReader` 的 nvdec / cuvid 那几路)就只判第①条。"""
39
+ e = edge_meta or {}
40
+ if "aux_sent" not in e:
41
+ return None
42
+ if e.get("aux_misaligned"):
43
+ return f"辅助流的 pts 和帧对不上、提前停了:{e['aux_misaligned']}"
44
+ if last_idx is None or "aux_last_key" not in e:
45
+ return None
46
+ k = e["aux_last_key"]
47
+ if k is None or k < grid.cover_floor(last_idx):
48
+ return f"辅助流只到帧 {k}、采样流到了帧 {last_idx}(辅助流半路断了,之后的回抠证据是缺的)"
49
+ return None
50
+
51
+
52
+ def obs_reusable(path, argv=None) -> tuple[bool, str]:
53
+ """一份观测产物能不能直接复用。返回 `(能不能, 不能的话为什么)`。
54
+
55
+ **判据只有这一份**(audit-5):三个驱动脚本以前各自内联一段
56
+ `python -c` 读 `_meta`,`run_full_ocr.sh` 那份甚至根本没读、只看
57
+ `[ -s ]`——一次中断留下的半截 jsonl 也是非空的,于是"一次中断变成
58
+ 之后每次都已经做完",而且不报错。
59
+
60
+ 四个条件,缺一不可:
61
+ - `_meta.complete` 为真(跑完了);
62
+ - **`_meta.timebase == "pts"`**(时间轴是新口径的);
63
+ - **`_meta.decoder` 是当前默认**(`framesource.DEFAULT_DECODER`,两种解码器不混用);
64
+ - 给了 `argv`(这一趟**要跑的** `run_ocr2` 命令行)时,**生效配置**对得上
65
+ (**跑的是同一段、同一条臂**——`--start` / `--end` 就在配置里):用 `flowocr.extract.ocr_args` 那同一个解析器把 `argv` 解析出来,
66
+ 和产物的 `_meta.config` 逐键比,`--out` / `--progress-every` 除外。
67
+ **缺键按 `ocr_args.ADDED_NOOP` 兜底**:后加的、默认空操作的旋钮不该让现成产物全部重建
68
+ (2026-09-18;进那张表的前提是"默认不开时产物逐字节相同",见那段说明)。
69
+ 比的是**解析后的值**,不是命令行字面——字面比不出"默认值翻了":
70
+ `--rec-bucket` 一旦进默认,对照臂旧产物的 argv 一字不变,会被当成新默认复用
71
+ (2026-09-10 审查;旧版比的就是字面)。**没有 `config` 字段的产物一律重建**,
72
+ "没记"和"记的是别的"在后果上没区别。三个驱动(`sampleset.sh` / `run_full_ocr.sh` /
73
+ `run_gamestream_ocr.sh`)和 `ocr_ab.sh` 都传 `argv`。
74
+
75
+ **窗口只有这一种判法**(审查 2026-09-10):原来另有一条"`start`/`end` 差 0.51 s 以内算同一窗口"
76
+ 的容差判据,而给了 argv 时配置按值精确比 `start`/`end`,那条容差永远走不到——
77
+ 同一件事两套判据,删掉了。驱动传给这里的和传给 run_ocr2 的是同一串参数,不需要容差。
78
+
79
+ timebase 那道门是 2026-09-10 补的。fps-and-rec-budget 报告 写的政策是
80
+ 「**旧产物一律作废重跑**,不做兼容、不回填」,但这条政策**没有落到代码里**:
81
+ 旧产物(`t_us = idx / src_fps`)的 `complete` 也是 true,于是
82
+ `sampleset.sh` 对 `out/samples/` 下那 6 份一份都不重跑,
83
+ 代表性片段集会无限期停在旧时间轴上(f3 尾端差 5.1 s),**而且不报错**。
84
+ """
85
+ from flowocr.extract import ocr_args
86
+
87
+ # 先解析,再看文件:驱动拼错了参数,不管产物在不在都该当场炸
88
+ want_cfg = ocr_args.config_of(argv) if argv is not None else None
89
+ ok, why = obs_finished(path)
90
+ if not ok:
91
+ return False, why
92
+ meta = _read_meta(path)
93
+ if meta.get("timebase") != "pts":
94
+ return False, (f"时间轴不是 pts(产物记的是 {meta.get('timebase')!r})"
95
+ f"——旧口径 `idx / src_fps` 的产物一律重跑,回填补不回丢帧缺口")
96
+ # **解码器也要是当前默认那个**(owner 2026-09-10:"不想有的用 cv2 有的用 ffmpeg")。
97
+ # 两种解码器的产物不逐字节相同,不拦的话,默认一改,磁盘上旧默认产的那批仍然
98
+ # "已跑完"、被驱动脚本跳过——于是同一批里两种混着,而且不报错。和上面 timebase
99
+ # 那道门是同一个形状:写在文档里 ≠ 代码上拦得住。
100
+ from flowocr.extract import framesource
101
+
102
+ if meta.get("decoder") != framesource.DEFAULT_DECODER:
103
+ return False, (f"解码器是 {meta.get('decoder')!r},当前默认是 "
104
+ f"{framesource.DEFAULT_DECODER!r}——两种解码器的产物不混用")
105
+ if want_cfg is not None:
106
+ got = meta.get("config")
107
+ if got is None:
108
+ return False, "产物没有 config 字段(记不清是拿哪组生效参数跑的)"
109
+ if meta.get("region_crop_off") and got.get("region_crop") and want_cfg.get("region_crop") is False:
110
+ # 请求了 --region-crop、实际没裁(外接矩形就是全屏、或旧产物的 --no-fast-det;原因在 `_meta.region_crop_off`):
111
+ # 产物和 --no-region-crop 跑的是同一条路,这次要的就是 --no-region-crop 时按实际比,不然回退臂会白重建一次。
112
+ # 这次仍按默认要裁时照记录比(同样的参数再跑一遍,照旧复用)。反过来(产物没裁、这次要裁)裁不裁得了要跑起来才知道,照旧重建
113
+ got = {**got, "region_crop": False}
114
+ diff = ocr_args.config_differs(got, want_cfg)
115
+ if diff:
116
+ return False, "参数对不上:" + ",".join(
117
+ f"{k} 产物 {got.get(k)!r} / 要的是 {want_cfg.get(k)!r}" for k in diff)
118
+ # **采样网格和辅助流网格按生效值比**(2026-09-24 时间网格):两者都要结合素材的帧率才定得下来,
119
+ # 按产物自己的 `src_fps` 算要的网格。时间网格之前的产物只记整数步长:采样 `_meta.stride`;
120
+ # 辅助流 `_meta.aux_step_effective`(加 `--aux-max-fps` 之后)或 `config.refine_step`(更早,没封顶)
121
+ from flowocr.extract.framegrid import AuxGrid, TimeGrid
122
+
123
+ src = meta.get("src_fps") or 0.0
124
+ try:
125
+ want_s = ocr_args.sample_grid(src, want_cfg["fps"], want_cfg.get("frame_select", "pts"))
126
+ want_a = (ocr_args.aux_grid(src, want_cfg["fps"], want_cfg.get("aux_max_fps", ocr_args.AUX_MAX_FPS),
127
+ want_cfg.get("frame_select", "pts")) if want_cfg.get("refine_fused") else None)
128
+ except ValueError as exc:
129
+ return False, str(exc)
130
+ got_s = (TimeGrid(*meta["sample_grid"]) if meta.get("sample_grid")
131
+ else TimeGrid.every(meta["stride"]) if meta.get("stride") else None)
132
+ if got_s != want_s:
133
+ return False, (f"采样网格对不上:产物 {got_s.describe(src) if got_s else '没记'} / "
134
+ f"要的是 {want_s.describe(src)}(--fps)")
135
+ if want_a is not None:
136
+ got_a = (AuxGrid.from_list(meta["aux_grid"]) if meta.get("aux_grid")
137
+ else AuxGrid(TimeGrid.every(meta.get("aux_step_effective", got.get("refine_step", 1))), got_s))
138
+ if got_a != want_a:
139
+ return False, (f"辅助流的生效网格对不上:产物 {got_a.describe(src)} / 要的是 {want_a.describe(src)}(--aux-max-fps)")
140
+ stale = seek_dropped_first(meta)
141
+ if stale:
142
+ return False, stale
143
+ # **代码指纹对不上时至少要吭一声**(2026-09-19 复审第 3 条)。
144
+ # `code_fp` **故意不进复用判据**——进了就等于"动一下 tools/ 就重跑所有整片",代价和收益不成比例。
145
+ # 但"旋钮没变、代码变了"正是 09-06 / 09-10 那一族的形状:文件名和配置都没动,驱动静默复用旧产物。
146
+ # 这次就踩到了(固定滞后改完,沿用旧臂名会直接 [skip])。所以:**判据不变,但把它喊出来**;
147
+ # 探索用的驱动可以 `FP_CHECK=1` 把它升级成"重建"。
148
+ fp_now, fp_got = ocr_args.code_fp(), meta.get("code_fp")
149
+ if fp_got and fp_got != fp_now:
150
+ msg = (f"⚠ 代码指纹不同(产物 {fp_got} / 现在 {fp_now}):旋钮一样、**代码变了**。"
151
+ f"要重跑就换臂名,或者 FP_CHECK=1")
152
+ if os.environ.get("FP_CHECK") == "1":
153
+ return False, msg.replace("⚠ ", "").replace("要重跑就换臂名,或者 FP_CHECK=1",
154
+ "FP_CHECK=1 要求重建")
155
+ return True, msg
156
+ return True, ""
157
+
158
+
159
+ def seek_dropped_first(meta: dict) -> str:
160
+ """修之前的精确 seek 丢了窗口首格的产物(`framesource.SEEK_REV`):返回作废的原因,不是就返回空串。
161
+
162
+ 代码指纹故意不进复用判据(见 `obs_reusable` 末尾),所以一次改了"读哪一帧"的修复要自己认出受影响的旧产物,
163
+ 否则沿用原臂名的驱动会一直复用坏的那份(2026-09-26 审计:`--fps 2.2` 的 yuka-f5 窗口)。认的是那个形状本身——
164
+ 没有 `seek_rev`、窗口从片中起、记了缺格、首个采样点比起点晚了至少一个采样间隔;别的旧产物照旧复用。"""
165
+ if (meta.get("seek_rev") or 1) >= _seek_rev() or not (meta.get("start_sec") or 0) > 0:
166
+ return ""
167
+ fps, first = meta.get("sample_fps"), meta.get("pts_first_sec")
168
+ if not (meta.get("grid_skipped") or 0) > 0 or not fps or first is None:
169
+ return ""
170
+ late = first - meta["start_sec"]
171
+ if late < 1 / fps:
172
+ return ""
173
+ return (f"精确 seek 修之前的产物丢了窗口首格(首个采样点晚起点 {late:.3f} s ≥ 一个采样间隔、缺格 {meta['grid_skipped']})"
174
+ f"——重跑(framesource.SEEK_REV)")
175
+
176
+
177
+ def _seek_rev() -> int:
178
+ from flowocr.extract import framesource
179
+ return framesource.SEEK_REV
180
+
181
+
182
+ def obs_finished(path) -> tuple[bool, str]:
183
+ """只问"写完了没有"——给**读**观测的下游用(`gametext_eval.sh`),不是给决定重不重跑的驱动用。
184
+
185
+ 读的一侧不在乎时间轴口径和解码器(文本检索不看时间,老的 gamestream 观测也照读),
186
+ 但**半截产物**照样要拦:整场 OCR 跑着的时候 `out/gamestream/<tag>.jsonl` 已经非空,
187
+ `[ -s ]` 会放它进去,量出来是"前二十分钟"的数、而且不报错(2026-09-11 起整场绝区零 /
188
+ 星铁和评测同时在跑)。"""
189
+ try:
190
+ meta = _read_meta(path)
191
+ except Exception as exc: # 不在、空的、半行、没有 _meta
192
+ return False, f"读不到 _meta({type(exc).__name__})"
193
+ if not meta.get("complete"):
194
+ return False, "_meta.complete 不为真(还在跑,或者中途断了)"
195
+ return True, ""
196
+
197
+
198
+ def _read_meta(path) -> dict:
199
+ import json
200
+
201
+ with open(path, encoding="utf-8") as fh:
202
+ return json.loads(fh.readline())["_meta"]
203
+
204
+
205
+ def _main(argv: list[str] | None = None) -> int:
206
+ """`python -m flowocr.extract.ocr_complete <obs.jsonl> [--argv <run_ocr2 的参数…>]`。
207
+
208
+ 能复用则退出码 0、不打印任何东西;不能则打印一行原因、退出码 1。
209
+ 驱动脚本按退出码分支,**不要再自己解析 jsonl**。
210
+ """
211
+ import argparse
212
+
213
+ ap = argparse.ArgumentParser(
214
+ description="观测产物能不能复用(跑完了 + pts + 默认解码器 + 同一组生效参数,含窗口)")
215
+ ap.add_argument("obs")
216
+ ap.add_argument("--finished", action="store_true",
217
+ help="只问写完了没有(读观测的下游用,见 obs_finished);不能和 --argv 同给")
218
+ ap.add_argument("--argv", nargs=argparse.REMAINDER, default=None,
219
+ help="这一趟要跑的 run_ocr2 argv,**必须放在最后**;"
220
+ "给了就解析成生效配置,和产物 _meta.config 逐键比")
221
+ a = ap.parse_args(argv)
222
+ if a.finished and a.argv is not None:
223
+ ap.error("--finished 只问写完没有,给 --argv 说明你要的是复用判据")
224
+ ok, why = obs_finished(a.obs) if a.finished else obs_reusable(a.obs, a.argv)
225
+ if why:
226
+ print(why) # 能复用但有话说(代码指纹不同)时也打——驱动会把它跟在 [skip] 后面
227
+ return 0 if ok else 1
228
+
229
+
230
+ if __name__ == "__main__":
231
+ raise SystemExit(_main())
@@ -0,0 +1,208 @@
1
+ """`run_ocr2 --workers N` 里**不碰模型的那部分**:切段、给子进程改参数、把各段的 `_meta` 合成一份。
2
+
3
+ 放共享层是为了守卫够得着(audit-4 C8)。依据(fps-and-rec-budget 报告):单进程时 GPU
4
+ 大半时间在等主线程的 CPU 活,N 个进程各跑一段能把空档填上——3 个 worker 在 quick 三段上 −26%~−47%。
5
+
6
+ **切点对齐采样格**:切点帧号在采样网格上(`framegrid.TimeGrid`)。代价之一:每段开头没有"上一帧",复用链从头开始、
7
+ 强制刷新换了相位——所以 `workers` 是**产物参数**(进 `_meta.config`),两种跑法的产物不混用。
8
+
9
+ ⚠ **有 pts 缺口 / 平均帧率偏离名义帧率的素材上不能开**(2026-09-10 审查)。子进程的起点是
10
+ `切点帧号 / src_fps` 秒——和 `--start` 同一个"帧号 ÷ 平均帧率"的换算——而 `src_fps` 是容器的
11
+ **平均**帧率。一段里若含丢帧缺口,它实际覆盖的时间比算出来的长,和下一段**重叠**(f3 实测 +4.65 s);
12
+ 不含缺口时它比算出来的短,和下一段之间**漏一截**(f3 上按平均帧率推,每个切点可达数秒,没实测),
13
+ 而且 PTS 仍然递增、**段间递增判据拦不住**。所以两道门:
14
+ 父进程先按 `seam_drift_sec` 预估**整个文件**上的切点漂移,超过半个源帧就拒绝;
15
+ 拼的时候再逐个切点核首尾间隔(`seam_problems`),重叠和漏帧都判未完成。
16
+ 漂移**按整个文件**估、门限**半个源帧**(复审 2026-09-10):按窗口长度估的话,f3 上 5 分钟窗口
17
+ 算出 0.109 s 会被放行,段与段之间静默差出好几个源帧——落在切点核对的容差里、拦不住。
18
+ 按整个文件估,有缺口的素材不论取哪段都拒;被放行的素材(f5 整片 0.001 s、游戏切片 0)
19
+ 切点漂移不到半帧,**采样帧和单进程逐帧相同**。
20
+ `r_frame_rate` 当名义帧率在某些容器上可能是时基派生值、造成误拒;现有素材没撞上,报错里两个帧率都印着。
21
+ 根治要把窗口改成按 pts 定义(fps-and-rec-budget 报告第 1 条的待定项)。
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import subprocess
27
+
28
+ from flowocr.extract import ocr_complete
29
+
30
+ SUM_KEYS = ("frames_advanced", "frames_requested", "sampled_frames", "boxes", "rec_calls",
31
+ "rec_predict_calls", "reused_boxes", "dropped_dense", "dropped_tiny", "lost_rec", "grid_skipped")
32
+ """各段相加就是整体的计数。"""
33
+
34
+ DICT_SUM_KEYS = ("reuse_v2", "reuse_v2_sec", "edge", "stage_sec", "rec_spec", "grid_off")
35
+ """各段里是"计数字典"的键,逐键相加进整体(数值型的键才加;bool 不算)。"""
36
+
37
+ DICT_MAX_KEYS = frozenset({"cache_peak", "aux_shift"})
38
+ """这些键**不是计数,是峰值 / 偏移**:取各段的最大值,不然 `--reuse-cache-mb 8` 的三个 worker
39
+ 会被加成 21 MB(2026-09-17 审计时从产物里发现的口径错)。"""
40
+
41
+ SEAM_TOL = 0.5
42
+ """切点处相邻两段首尾的间隔,允许偏离一个采样间隔多少(倍)。正常就是恰好一个采样间隔。"""
43
+
44
+ DRIFT_MAX_FRAMES = 0.5
45
+ """整个文件上预估的切点漂移不许超过几个**源帧**。半帧以内,切点落在哪一帧和单进程一致。
46
+ f5 整片 0.001 s(0.06 帧)、游戏切片 0;f3 12.1 s、f4 6.1 s。"""
47
+
48
+
49
+ def split_frames(start_idx: int, end_idx: int, grid, n: int) -> list[int]:
50
+ """`[start_idx, end_idx)` 等分成 n 段,返回 n−1 个**内部切点帧号**(在采样网格 `grid` 上、严格递增;`start_idx` 在格上)。
51
+
52
+ 段太短切不开时抛——那种窗口本来就不值得并行。
53
+ """
54
+ if n < 2:
55
+ return []
56
+ span = end_idx - start_idx
57
+ k0 = grid.index(start_idx)
58
+ cuts = [grid.at(k0 + round(span * k / n * grid.p / grid.q)) for k in range(1, n)] # 等距时就是 start + round(…/步长) × 步长
59
+ pts = [start_idx, *cuts, end_idx]
60
+ if any(b <= a for a, b in zip(pts, pts[1:])):
61
+ raise ValueError(f"窗口太短,切不成 {n} 段:{pts}")
62
+ return cuts
63
+
64
+
65
+ def strip_opts(argv: list[str], names: set[str]) -> list[str]:
66
+ """从 argv 里去掉这些**带值**的选项(`--x v` 和 `--x=v` 两种写法都认),其余原样保留。"""
67
+ out, skip = [], False
68
+ for tok in argv:
69
+ if skip:
70
+ skip = False
71
+ continue
72
+ name = tok.split("=", 1)[0]
73
+ if name in names:
74
+ skip = "=" not in tok
75
+ continue
76
+ out.append(tok)
77
+ return out
78
+
79
+
80
+ def parse_rate(s: str) -> float | None:
81
+ """ffprobe 的 `60/1`、`60000/1001` → 浮点;`0/0`、`N/A`、空 → None。"""
82
+ try:
83
+ num, den = s.split("/") if "/" in s else (s, "1")
84
+ num, den = float(num), float(den)
85
+ except ValueError:
86
+ return None
87
+ return num / den if num > 0 and den > 0 else None
88
+
89
+
90
+ def probe_rates(video: str) -> tuple[float | None, float | None]:
91
+ """`(名义帧率 r_frame_rate, 平均帧率 avg_frame_rate)`。探不到的给 None。"""
92
+ try:
93
+ out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
94
+ "stream=r_frame_rate,avg_frame_rate", "-of", "json", video],
95
+ capture_output=True, text=True, timeout=60).stdout
96
+ st = json.loads(out)["streams"][0]
97
+ except Exception:
98
+ return None, None
99
+ return parse_rate(st.get("r_frame_rate", "")), parse_rate(st.get("avg_frame_rate", ""))
100
+
101
+
102
+ def seam_drift_sec(span_frames: int, avg_fps: float, nominal_fps: float) -> float:
103
+ """按"帧号 ÷ 平均帧率"换算切点时,`span_frames` 帧上最多能漂出多少秒。
104
+ 调用方传**整个文件**的帧数,门限才是素材的属性、不随窗口变。"""
105
+ return span_frames * abs(1.0 / avg_fps - 1.0 / nominal_fps)
106
+
107
+
108
+ def seam_problems(metas: list[dict], interval: float, tol: float = SEAM_TOL) -> list[str]:
109
+ """逐个切点核:下一段的首帧 PTS − 上一段的末帧 PTS 应该恰好是一个采样间隔。
110
+ ≤ 0 是**重叠**,太大是**漏帧**——后者 PTS 仍递增,只有这里能拦。"""
111
+ out = []
112
+ for k in range(len(metas) - 1):
113
+ a, b = metas[k].get("pts_last_sec"), metas[k + 1].get("pts_first_sec")
114
+ if a is None or b is None:
115
+ out.append(f"切点 {k}:段首 / 段尾 PTS 缺失")
116
+ continue
117
+ d = b - a
118
+ if d <= 0:
119
+ out.append(f"切点 {k}:重叠 {-d:.3f} s({a:.3f} → {b:.3f})")
120
+ elif abs(d - interval) > tol * interval:
121
+ out.append(f"切点 {k}:间隔 {d:.3f} s,应为 {interval:.3f} s"
122
+ + ("(漏帧)" if d > interval else ""))
123
+ return out
124
+
125
+
126
+ CHILD_ARGS = ["--workers", "1", "--decode-shards", "0"]
127
+ """`--workers N` 的子进程**追加**的参数(排在父进程 argv 之后,同名的后者生效)。
128
+ 解码分片和多 worker 互斥(`ocr_args.resolve_split`),可子进程只看得见 `--workers 1`、会按默认再开几路——
129
+ `--workers 3` 就成了 6~9 个解码上下文(2026-09-24 审计)。所以由父进程显式关掉;它们在 SCHEDULING 里,不改复用判据。"""
130
+
131
+
132
+ def merge_metas(metas: list[dict], base: dict, *, wall: float, cuts_sec: list[float],
133
+ interval: float) -> dict:
134
+ """各段的 `_meta` → 整体的 `_meta`。`base` 是父进程按同一组参数建的那份(video / config / 模型…)。
135
+
136
+ **完成**按**整个窗口**判,和单进程同一把尺:
137
+ - 覆盖率:各段推进帧数之和 / 请求帧数之和过 `ocr_complete.is_complete`。
138
+ **不能要求每段各自过**——`end=0` 时容器多报的那 1% 帧数全压在最后一段上,
139
+ 3 个 worker 时末段只有 97%,单进程是 99%(审查 2026-09-10);
140
+ - **中间各段**必须各自完成(它们的请求帧数是切出来的,不含容器的虚报);
141
+ - 没有坏 PTS,切点处首尾间隔恰好一个采样间隔(`seam_problems`)。
142
+ """
143
+ m = dict(base)
144
+ for k in SUM_KEYS:
145
+ m[k] = sum(x.get(k, 0) or 0 for x in metas)
146
+ for k in DICT_SUM_KEYS: # 各段的计数字典逐键相加(reuse_v2 / edge 的统计)
147
+ parts = [x[k] for x in metas if isinstance(x.get(k), dict)]
148
+ if parts:
149
+ def agg(kk: str, parts=parts):
150
+ vals = [d[kk] for d in parts if isinstance(d.get(kk), (int, float))]
151
+ # **峰值不能相加**(2026-09-17):三个 worker 各自的 8 MB 上限被加成 21 MB,
152
+ # 看着像"上限没守住",其实是口径错了。峰值取最大,其余相加
153
+ if kk in DICT_MAX_KEYS:
154
+ return round(max(vals), 2) if any(isinstance(v, float) for v in vals) else max(vals)
155
+ return (round(sum(float(v) for v in vals), 2) if any(isinstance(v, float) for v in vals)
156
+ else sum(int(v) for v in vals))
157
+ m[k] = {kk: agg(kk)
158
+ for kk in dict.fromkeys(kk for d in parts for kk in d if isinstance(d.get(kk), (int, float)))}
159
+ # 实际用了几个 worker:退回单进程时 run_ocr2 自己写 1,并行跑完由这里写段数——
160
+ # 不写的话并行产物里这个键干脆没有,读的人分不出"三个 worker"和"旧产物"(2026-09-17)
161
+ m["workers_effective"] = len(metas)
162
+ splits = [x["proc_split"] for x in metas if "proc_split" in x] # 拆进程的生效值(各段一样就记一份,不一样逐段列)
163
+ if splits:
164
+ m["proc_split"] = splits[0] if all(sp == splits[0] for sp in splits) else splits
165
+ # 硬解的生效值(2026-09-20 整体检查时补的):父进程早早 `return run_parallel`、**不做**试解,
166
+ # 而这里又是从父进程那份 `base` 起步——不写的话各段 `hwaccel_effective` 全丢,
167
+ # 读的人分不出"真走了硬解"和"每段都退回了软解"(和上面 workers_effective 同一个形状)。
168
+ # 各段可能不一致(有前摇的 av1 只有**第一段**从文件开头起解,后面几段 seek 到非零位置都没事)
169
+ effs = [x.get("hwaccel_effective") for x in metas if "hwaccel_effective" in x]
170
+ if effs:
171
+ m["hwaccel_effective"] = (effs[0] if len(set(effs)) == 1
172
+ else "mixed:" + ",".join(e or "soft" for e in effs))
173
+ # 推理设备的生效值 / 用的模型文件同一个形状(2026-09-22 接手自查):父进程不建模型、`base` 里没有,不抄就全丢。
174
+ # `--device auto` 由各段各自解析(各自试推理),可能不一致——不一致时逐段列出来
175
+ for key in ("device_resolved", "device_fallback", "device_effective", "models"):
176
+ vals = [x.get(key) for x in metas if key in x]
177
+ if vals:
178
+ m[key] = vals[0] if all(v == vals[0] for v in vals) else {"mixed": vals}
179
+ seams = seam_problems(metas, interval)
180
+ bad = next((x["bad_pts"] for x in metas if x.get("bad_pts")), None)
181
+ overlap = [s for s in seams if "重叠" in s]
182
+ if overlap and bad is None:
183
+ bad = "段间 PTS 不递增:" + ";".join(overlap)
184
+ complete = (all(x.get("complete") for x in metas[:-1])
185
+ and ocr_complete.is_complete(m["frames_advanced"], m["frames_requested"])
186
+ and bad is None and not seams)
187
+ firsts = [x.get("pts_first_sec") for x in metas]
188
+ lasts = [x.get("pts_last_sec") for x in metas]
189
+ m.update({
190
+ "complete": complete,
191
+ "stopped_early": any(x.get("stopped_early") for x in metas),
192
+ "bad_pts": bad,
193
+ "seam_problems": seams,
194
+ "reuse_rate": round(m["reused_boxes"] / max(1, m["boxes"]), 4),
195
+ "wall_sec": round(wall, 2),
196
+ "sec_per_frame": round(wall / max(m["sampled_frames"], 1), 3),
197
+ "start_sec": metas[0].get("start_sec"),
198
+ "seek_rev": metas[0].get("seek_rev"),
199
+ "end_sec": metas[-1].get("end_sec"),
200
+ "timebase": "pts",
201
+ "pts_first_sec": firsts[0],
202
+ "pts_last_sec": lasts[-1],
203
+ "cuts_sec": cuts_sec,
204
+ "part_wall_sec": [x.get("wall_sec") for x in metas],
205
+ })
206
+ span = (lasts[-1] - firsts[0]) if firsts[0] is not None and lasts[-1] is not None else 0.0
207
+ m["realtime_x"] = round(span / wall, 2) if wall else None
208
+ return m