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,214 @@
1
+ """det 的前后处理(推理在 ORT 服务里)。前处理最早是为绕开 PaddleX 的 Python 前处理链写的,要求和它**逐字节相同**。
2
+
3
+ 为什么(fps-and-rec-budget 报告):det 每帧的时间里,`run()` 和进出拷贝之外
4
+ 还有一大块是 **PaddleX 的 Python 前后处理 + 胶水**——换运行时(ORT / TensorRT)动不到它。
5
+ 它有多大,同一次运行里的逐段计时见 一次性探针 `probe_infer_split.py`(原来那个"约 12 ms"
6
+ 是跨两次运行相减估的,端到端只兑现了 3–4 ms/帧,别再引)。这一层做的事本身很少:
7
+
8
+ Read(PP-OCRv6 det 的配置里是 BGR,不换序) -> Resize(长边 960、对齐 32)
9
+ -> Normalize(逐通道 astype/乘/加) -> ToCHW(transpose) -> ToBatch(stack)
10
+
11
+ 慢在实现:`Normalize` 走 `cv2.split` -> 三次 `astype(float32)` -> 三次乘加 -> `cv2.merge`,
12
+ 每一步都新建一个 6.3 MB 的数组;再加上每个 op 上的 `@benchmark.timeit` 装饰器。
13
+
14
+ 这里把 **astype + 乘 + 加** 折成**一张 256×1×3 的 float32 LUT**,`cv2.LUT` 一趟 C++ 出结果;
15
+ 若某个模型的 Read 真要 RGB,换序也折进 LUT 的下标(见 `__init__` 里按 Read op 实测的那段)。
16
+ **这是恒等变换,不是近似**:LUT 里存的就是 `v * alpha[c] + beta[c]`,
17
+ uint8 的 256 个取值全枚举,float32 的乘加逐位相同。
18
+
19
+ 后处理(DBPostProcess)是正确性关键的一块:用 `flowocr.extract.detpost` 里照抄 PaddleX 的那份
20
+ (照抄时和 PaddleX 逐字节相同;2026-10 运行时去掉 Paddle 之后,守卫对着冻结的 PaddleX 输出做回归)。
21
+
22
+ 用法:
23
+
24
+ fast = FastDet.from_onnx(onnx_path, runner, default_yml_ok) # 配置读 ONNX 旁边的 inference.yml,后处理是 detpost 的
25
+ polys = fast.predict(frame_bgr)
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import time
30
+ from pathlib import Path
31
+
32
+ import cv2
33
+ import numpy as np
34
+
35
+ from flowocr.extract import detpost
36
+ from flowocr.extract import timeline as _tl # --timeline:det 拆成前处理 / 推理 / 后处理三段(关着零开销)
37
+
38
+
39
+ class FastDet:
40
+ """一组前后处理参数(`detpost.DetSpec`)+ 一个 `runner([blob]) -> preds` + 一个后处理对象。"""
41
+
42
+ def __init__(self, spec: detpost.DetSpec, runner, post_op) -> None:
43
+ self.spec = spec
44
+ self.runner = runner
45
+ self.post_op = post_op
46
+ self.thresh, self.box_thresh, self.unclip_ratio = spec.thresh, spec.box_thresh, spec.unclip_ratio
47
+ self.max_side_limit = spec.max_side_limit
48
+ self.limit_side_len, self.limit_type = spec.limit_side_len, spec.limit_type
49
+
50
+ # **通道序按配置的 Read op 来,别照 PaddleX 源码猜**:`_build()` 里写的是 `ReadImage(format="RGB")`,
51
+ # 但配置里的 `DecodeImage: img_mode: BGR` 会把它整个换掉——PP-OCRv6 det 实际是
52
+ # **BGR 进、alpha/beta 直接按通道下标用**(那组常数虽然长得像 ImageNet 的 RGB 均值,
53
+ # 但模型就是这么训的)。我按源码前几行假设了换序,产物立刻对不上。
54
+ if spec.img_mode == "RGB":
55
+ self._swap = True # 需要 BGR->RGB:换序折进 LUT 下标
56
+ order = [2, 1, 0]
57
+ elif spec.img_mode == "BGR":
58
+ self._swap = False
59
+ order = [0, 1, 2]
60
+ else:
61
+ raise ValueError(f"快路径不支持 Read format={spec.img_mode}")
62
+
63
+ lut = np.empty((256, 1, 3), np.float32)
64
+ v = np.arange(256, dtype=np.float32)
65
+ for c in range(3):
66
+ k = order[c] # 输入第 c 通道在模型眼里是第 k 个通道
67
+ lut[:, 0, c] = v * np.float32(spec.alpha[k]) + np.float32(spec.beta[k])
68
+ self._lut = lut
69
+ self._bufs: dict[str, np.ndarray] = {}
70
+
71
+ @classmethod
72
+ def from_onnx(cls, onnx_path: str, runner, default_yml_ok: bool) -> "FastDet":
73
+ """配置读 ONNX 旁边的 `inference.yml`(官方仓库自带;模型名也从它来,`spec.model_name`),后处理用 `detpost.DBPostProcess`。
74
+ 旁边没有 yml 时**只有** `default_yml_ok`(默认模型的官方 ONNX / 以前自转的 `ocr_args.OLD_DET_ONNX`,
75
+ `ocr_args.EQUIV_VALUES` 把两者当同一类)按默认模型的官方 yml 取;自带的别的 ONNX 缺 yml 就报错——
76
+ 按默认模型的阈值和缩放跑别的权重,框会悄悄变(审计 2026-09-25)。"""
77
+ yml = Path(onnx_path).with_name("inference.yml")
78
+ if not yml.is_file():
79
+ if not default_yml_ok:
80
+ raise ValueError(f"{onnx_path} 旁边没有 inference.yml:自带的 det ONNX 要连同它的 inference.yml 放在同一个目录")
81
+ from flowocr import models
82
+ yml = Path(models.path("det")).with_name("inference.yml")
83
+ print(f"[det] {onnx_path} 旁边没有 inference.yml,前后处理配置按默认模型的官方 yml:{yml}", flush=True)
84
+ spec = detpost.DetSpec.from_yml(yml)
85
+ return cls(spec, runner, detpost.DBPostProcess(spec))
86
+
87
+ # -- 前处理 ---------------------------------------------------------------
88
+
89
+ def _resize_shape(self, h: int, w: int) -> tuple[int, int, float, float]:
90
+ """照抄 processors.py 的 resize_image_type0,**包括那两处 int() 截断**。"""
91
+ lsl, lt = self.limit_side_len, self.limit_type
92
+ if lt == "max":
93
+ ratio = float(lsl) / (h if h > w else w) if max(h, w) > lsl else 1.0
94
+ elif lt == "min":
95
+ ratio = float(lsl) / (h if h < w else w) if min(h, w) < lsl else 1.0
96
+ elif lt == "resize_long":
97
+ ratio = float(lsl) / max(h, w)
98
+ else:
99
+ raise ValueError(f"not support limit type {lt}")
100
+ rh, rw = int(h * ratio), int(w * ratio)
101
+ if max(rh, rw) > self.max_side_limit:
102
+ r2 = float(self.max_side_limit) / max(rh, rw)
103
+ rh, rw = int(rh * r2), int(rw * r2)
104
+ rh = max(int(round(rh / 32) * 32), 32)
105
+ rw = max(int(round(rw / 32) * 32), 32)
106
+ return rh, rw, rh / float(h), rw / float(w)
107
+
108
+ def _target_hw(self, h: int, w: int) -> tuple[int, int]:
109
+ """这帧会被缩到多大(含 PaddleX 那条"太小先补到 32"的规矩)。"""
110
+ if h + w < 64:
111
+ h, w = max(32, h), max(32, w)
112
+ return self._resize_shape(h, w)[:2]
113
+
114
+ def preprocess(self, img: np.ndarray, *, copy: bool = False, dst: np.ndarray | None = None):
115
+ """⚠ 返回的 blob 默认是**复用的缓冲区**,下一次调用就会被覆盖。
116
+
117
+ 要留着(比如攒一批做对照)就传 `copy=True`。复用不是微优化:
118
+ 每帧新分配 6.3 MB 再释放,实测让端到端多花约 4.5 ms/帧——
119
+ 新页是冷的,`copy_from_cpu` 读它要吃缺页。
120
+ 给了 `dst`(形状 `(1, 3, rh, rw)` 的 float32 视图)就直接写进它——批处理用,省一次拷贝。
121
+ """
122
+ if img.ndim != 3 or img.shape[2] != 3 or img.dtype != np.uint8:
123
+ # LUT 是按 uint8 的 256 个取值建的,换了 dtype 就不是恒等变换了;
124
+ # 通道数不对 `cv2.LUT` 会用同一张表去套,静默算出别的东西。
125
+ raise ValueError(f"快路径只吃 3 通道 uint8,拿到 {img.shape} {img.dtype}")
126
+ src_h, src_w = img.shape[:2]
127
+ if src_h + src_w < 64:
128
+ # PaddleX 的 `resize()` 在这里先补零到至少 32×32,再算比例,
129
+ # 而返回的 shape 向量记的仍是**原始**尺寸。真素材碰不到,但换形状对账会咬。
130
+ pad = np.zeros((max(32, src_h), max(32, src_w), img.shape[2]), img.dtype)
131
+ pad[:src_h, :src_w] = img
132
+ img = pad
133
+ h, w = img.shape[:2]
134
+ rh, rw, ratio_h, ratio_w = self._resize_shape(h, w)
135
+ if (rh, rw) != (h, w):
136
+ img = cv2.resize(img, (rw, rh), dst=self._buf("rs", (rh, rw, 3), np.uint8))
137
+ ratio_h, ratio_w = rh / float(h), rw / float(w)
138
+ else:
139
+ ratio_h = ratio_w = 1.0
140
+ # uint8 HWC -> 归一化的 float32 HWC,一趟 C++(BGR->RGB 若需要则已折进 LUT 系数)
141
+ normed = cv2.LUT(img, self._lut, dst=self._buf("norm", (rh, rw, 3), np.float32))
142
+ chw = normed.transpose(2, 0, 1)
143
+ if self._swap: # LUT 只改了系数,通道位置还要真的换
144
+ chw = chw[::-1]
145
+ if dst is not None:
146
+ if dst.shape != (1, 3, rh, rw) or dst.dtype != np.float32:
147
+ raise ValueError(f"dst 形状不对:要 {(1, 3, rh, rw)} float32,给的 {dst.shape} {dst.dtype}")
148
+ blob = dst
149
+ else:
150
+ blob = self._buf("blob", (1, 3, rh, rw), np.float32)
151
+ blob[0] = chw # 写进常驻缓冲,不新分配
152
+ return (blob.copy() if copy else blob), np.array([src_h, src_w, ratio_h, ratio_w])
153
+
154
+ def _buf(self, key: str, shape: tuple, dtype) -> np.ndarray:
155
+ b = self._bufs.get(key)
156
+ if b is None or b.shape != shape or b.dtype != dtype:
157
+ b = np.empty(shape, dtype)
158
+ self._bufs[key] = b
159
+ return b
160
+
161
+ # -- 全流程 ---------------------------------------------------------------
162
+
163
+ def predict(self, img: np.ndarray):
164
+ t = time.perf_counter()
165
+ blob, shape = self.preprocess(img)
166
+ t = _tl.span("det_pre", t)
167
+ preds = self.runner([blob])
168
+ t = _tl.span("det_infer", t)
169
+ polys, scores = self.post_op(
170
+ preds, [shape],
171
+ thresh=self.thresh,
172
+ box_thresh=self.box_thresh,
173
+ unclip_ratio=self.unclip_ratio,
174
+ )
175
+ _tl.span("det_post", t)
176
+ return polys[0], scores[0]
177
+
178
+ def predict_batch(self, imgs: list[np.ndarray]) -> list[tuple]:
179
+ """几帧一次送进模型(`run_ocr2 --det-batch`)。返回每帧的 `(polys, scores)`,顺序同输入。
180
+
181
+ **不逐字节等于逐帧**:cuDNN 按批大小可能换卷积算法,f5 上 48 帧里 2 帧 poly 有细微差
182
+ (一次性探针 `probe_det_batch.py`)。尺寸不一的帧拼不了批,退回逐帧。
183
+ """
184
+ return self.post_batch(self.infer_batch(imgs))
185
+
186
+ def infer_batch(self, imgs: list[np.ndarray]) -> tuple:
187
+ """`predict_batch` 的前半:前处理 + 推理,返回一个不透明的 state 交给 `post_batch`(2026-09-25,`run_ocr2 --det-lookahead`:
188
+ 辅助线程跑这一半,和上一批的后处理重叠——det 线程原来把前处理 1.5 s、推理 10.9 s、后处理 3.7 s 串在一起)。
189
+ state 里只有 runner 返回的新数组和 shape 向量:前处理的常驻缓冲区下一次就被覆盖,不能留在 state 里。"""
190
+ if len(imgs) == 1 or len({im.shape for im in imgs}) != 1:
191
+ return ("done", [self.predict(im) for im in imgs])
192
+ t = time.perf_counter()
193
+ rh, rw = self._target_hw(*imgs[0].shape[:2])
194
+ batch = self._buf(f"batch{len(imgs)}", (len(imgs), 3, rh, rw), np.float32)
195
+ shapes = [self.preprocess(im, dst=batch[i:i + 1])[1] for i, im in enumerate(imgs)]
196
+ t = _tl.span("det_pre", t)
197
+ preds = self.runner([batch])
198
+ _tl.span("det_infer", t)
199
+ return ("preds", preds, shapes)
200
+
201
+ def post_batch(self, state: tuple) -> list[tuple]:
202
+ """`predict_batch` 的后半:DB 后处理。和 `infer_batch` 合起来就是原来的 `predict_batch`,计算一步不差。"""
203
+ if state[0] == "done":
204
+ return state[1]
205
+ _, preds, shapes = state
206
+ t = time.perf_counter()
207
+ polys, scores = self.post_op(
208
+ preds, shapes,
209
+ thresh=self.thresh,
210
+ box_thresh=self.box_thresh,
211
+ unclip_ratio=self.unclip_ratio,
212
+ )
213
+ _tl.span("det_post", t)
214
+ return list(zip(polys, scores))
@@ -0,0 +1,87 @@
1
+ """ffmpeg / ffprobe 在不在、够不够用,推理后端装对了没有,以及这一趟的运行时版本(release-plan F13 / F8)。
2
+
3
+ ffmpeg **不随包分发**(owner 2026-09-25:避开 ffmpeg 构建的再分发义务),用户自己装、放进 `PATH`。
4
+ 这里只拦"缺了必挂"的:两个可执行文件、`showinfo`(逐帧 pts 从它的 stderr 读,`framesource` 文件头)、`select`、`scale`。
5
+ **硬解能力不在这里判**:NVDEC / `scale_cuda` 缺了由 `framesource.hwaccel_blocker` 试解不过、退回软解(原因写进 `_meta`),
6
+ 照这台机器实际能跑的走,不按构建选项猜。版本下限没有依据(只在 8.0 上跑过),所以不卡版本号,只记下来。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import importlib.metadata
12
+ import platform
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+
17
+ REQUIRED_FILTERS = ("showinfo", "select", "scale")
18
+ DISTS = ("flowocr", "numpy", "opencv-contrib-python", "onnxruntime-gpu", "onnxruntime")
19
+ """`_meta.runtime` 记哪些包的版本(按分发名查元数据、不 import)。"""
20
+
21
+
22
+ def _run(cmd: list[str]) -> str:
23
+ return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace",
24
+ timeout=30).stdout
25
+
26
+
27
+ @functools.lru_cache(maxsize=1)
28
+ def probe() -> dict:
29
+ """`{"ffmpeg": 路径, "ffprobe": 路径, "version": 首行, "missing": [缺的东西]}`。一个进程只探一次。"""
30
+ ff, fp = shutil.which("ffmpeg"), shutil.which("ffprobe")
31
+ missing = [n for n, p in (("ffmpeg", ff), ("ffprobe", fp)) if p is None]
32
+ version = ""
33
+ if ff:
34
+ try:
35
+ version = (_run([ff, "-hide_banner", "-version"]).splitlines() or [""])[0].strip()
36
+ names = {ln.split()[1] for ln in _run([ff, "-hide_banner", "-filters"]).splitlines()
37
+ if len(ln.split()) >= 3 and ln[:1] == " "}
38
+ missing += [f"滤镜 {f}" for f in REQUIRED_FILTERS if f not in names]
39
+ except (OSError, subprocess.SubprocessError) as exc: # 在 PATH 上但起不来(损坏、权限、卡住)
40
+ missing.append(f"能运行的 ffmpeg({ff}:{type(exc).__name__}: {exc})")
41
+ return {"ffmpeg": ff, "ffprobe": fp, "version": version, "missing": missing}
42
+
43
+
44
+ def require() -> dict:
45
+ """缺了就带着说明退出;够用就返回 `probe()`。"""
46
+ p = probe()
47
+ if p["missing"]:
48
+ raise SystemExit(f"ffmpeg 不够用:缺 {', '.join(p['missing'])}。flowocr 不带 ffmpeg,要自己装一个完整构建"
49
+ f"(如 gyan.dev / BtbN 的 Windows full build),把它的 bin 目录放进 PATH(ffmpeg 与 ffprobe 都要)")
50
+ return p
51
+
52
+
53
+ ORT_DISTS = ("onnxruntime-gpu", "onnxruntime")
54
+ """推理后端的两个分发:装的是同一个 `onnxruntime` 顶层包,只能有一个(`[nvidia]` / `[cpu]` 两个 extra 各装一个)。"""
55
+
56
+
57
+ def require_runtime() -> None:
58
+ """推理后端装对了没有、平台是不是验证过的。装成包时 extras 的互斥进不了元数据,所以起跑时查:
59
+ 一个都没有 -> 退出并说装哪个 extra;两个都在 -> 退出并说只留一个(后装的那个盖掉前一个,可能装成了不带 CUDA 的那份);
60
+ 不是 Windows -> 打一行警告照跑(owner 2026-10-02:只在 Windows 上验证过,不挡想试的人)。"""
61
+ have = []
62
+ for d in ORT_DISTS:
63
+ try:
64
+ importlib.metadata.version(d)
65
+ have.append(d)
66
+ except importlib.metadata.PackageNotFoundError:
67
+ pass
68
+ if not have:
69
+ raise SystemExit("没装推理后端(onnxruntime)。装的时候选一个 extra:有 NVIDIA 显卡 `flowocr[nvidia]`,没有 `flowocr[cpu]`"
70
+ "(源码装就是 `uv sync --frozen --extra nvidia` 或 `--extra cpu`)")
71
+ if len(have) > 1:
72
+ raise SystemExit("onnxruntime-gpu 和 onnxruntime 同时装着,两个会互相覆盖。只留一个:重装时 extra 只选 nvidia 或 cpu 之一"
73
+ "(源码装删掉 .venv 再 `uv sync`)")
74
+ if sys.platform != "win32":
75
+ print(f"[平台] ⚠ flowocr 只在 Windows 上验证过,这台是 {platform.system()}:照跑,出了问题欢迎反馈", flush=True)
76
+
77
+
78
+ def runtime() -> dict:
79
+ """这一趟的运行时:Python、各推理库的版本、ffmpeg 的版本行。**只追溯、不进复用判据**(同一组 config 可以出自不同运行时)。"""
80
+ pkgs = {}
81
+ for d in DISTS:
82
+ try:
83
+ pkgs[d] = importlib.metadata.version(d)
84
+ except importlib.metadata.PackageNotFoundError:
85
+ pass
86
+ return {"python": sys.version.split()[0], "platform": platform.platform(), "packages": pkgs,
87
+ "ffmpeg": probe()["version"]}
@@ -0,0 +1,265 @@
1
+ """取哪些帧:**时间网格**(2026-09-24,owner:三种间隔都不要求整除,"采样帧 ⊆ 高精帧环 ⊆ 辅助流"嵌套)。
2
+
3
+ 帧号一律是 pts 帧号 `round(t × 名义帧率)`(`framesource.id_rate`),网格锚在帧号 0 上:
4
+
5
+ * `TimeGrid(p, q)`:帧率比 p/q(约分、≤ 1)的网格。第 k 格是帧 `ceil(k·q/p)`;第 i 帧在格上 ⇔ `floor(i·p/q)` 在这一帧变了。
6
+ floor / ceil / 前后格 / 区间里的格都是整数闭式,不逐帧循环。**p = 1 时就是原来的"每 q 帧一帧"**(`uniform`),
7
+ 公式逐项退化成原来的整除算术——所以整除的情形和改之前逐字节相同。采样用它(`--fps`)。
8
+ * `AuxGrid(own, sample)`:辅助流 = 自己的网格(`--aux-max-fps`)∪ 采样网格(**嵌套**:采样帧一定在辅助流里)。
9
+ 回抠的边界锚在采样帧上(融合把首字夹在 [t_start − 一个间隔, t_start]),采样帧不在辅助流里边界就落不到它上面
10
+ (09-16 那次:格子没对齐、首字系统性晚 1 帧、产物校验当场拒收)。两个都等距、而且采样步长是辅助步长的倍数时,
11
+ 并集就是自己那个网格(`uniform`),证据记录照旧只写 `step`。
12
+
13
+ ffmpeg 的 `select` 表达式和 Python 用同一组整数算 floor(`N·p/q` 的分子是精确整数、商离整数至少 1/q,双精度不会判错),
14
+ 两边选出来的帧一致(守卫在 6 小时帧号内抽查)。**纯函数,守卫直接测。**
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from fractions import Fraction
19
+
20
+ FPS_DEN = 10_000
21
+ """帧率换成有理数时分母的上限:59.94(60000/1001)、23.976(24000/1001)这类名义帧率都精确落得下。"""
22
+ CAP_TOL = 1e-3
23
+ """辅助流上限落到整数步长的容差(相对):60.0000x 这类名义帧率不算超(同时间网格之前的步长判据)。"""
24
+ SNAP_DEN = 100
25
+ SNAP_REL = 1e-5
26
+ """两个帧率之比离一个**分母 ≤ `SNAP_DEN` 的简单分数**不到 `SNAP_REL`(相对)就吸附上去(2026-09-24 审计)。
27
+ 为什么:用户敲的是 `29.97` / `59.94`,素材的名义帧率是 30000/1001 / 60000/1001——两边各自有理数化之后比值是 999999/2000000,
28
+ 离 1/2 只差 1e-6,却被当成真不整除(不等距网格、`--fps 59.94` 直接报错、辅助流每 4.6 h 静默少一帧)。
29
+ 1e-5 的相对误差 6 小时累计 0.2 s;真不整除的(59.94 上 2 fps 是 1001/30000,离 1/30 差 1e-3)不受影响。"""
30
+
31
+
32
+ class TimeGrid:
33
+ __slots__ = ("p", "q")
34
+
35
+ def __init__(self, p: int, q: int) -> None:
36
+ if p < 1 or q < 1:
37
+ raise ValueError(f"网格的帧率比要为正:{p}/{q}")
38
+ f = min(Fraction(p, q), Fraction(1))
39
+ self.p, self.q = f.numerator, f.denominator
40
+
41
+ @classmethod
42
+ def for_rate(cls, src_fps: float, fps: float) -> "TimeGrid":
43
+ """素材 `src_fps` 上取 `fps` 帧 / 秒(比素材还高就是每帧都要)。"""
44
+ if src_fps <= 0 or fps <= 0:
45
+ raise ValueError(f"帧率要为正:素材 {src_fps} / 要的 {fps}")
46
+ r = Fraction(fps).limit_denominator(FPS_DEN) / Fraction(src_fps).limit_denominator(FPS_DEN)
47
+ s = r.limit_denominator(SNAP_DEN)
48
+ if abs(s - r) <= SNAP_REL * r:
49
+ r = s
50
+ return cls(r.numerator, r.denominator)
51
+
52
+ @classmethod
53
+ def every(cls, step: int) -> "TimeGrid":
54
+ """每 `step` 帧一帧(探针 / 旧产物的整数步长)。"""
55
+ return cls(1, max(1, int(step)))
56
+
57
+ @property
58
+ def uniform(self) -> bool:
59
+ return self.p == 1
60
+
61
+ @property
62
+ def step(self) -> int | None:
63
+ """等距时的步长;不等距是 None。"""
64
+ return self.q if self.p == 1 else None
65
+
66
+ @property
67
+ def max_gap(self) -> int:
68
+ """相邻两格最多隔几帧(等距就是步长)。"""
69
+ return -(-self.q // self.p)
70
+
71
+ def fps(self, src_fps: float) -> float:
72
+ return src_fps * self.p / self.q
73
+
74
+ def interval_us(self, src_fps: float) -> float:
75
+ """名义间隔(微秒)。不等距时实际间隔在它上下差不到一帧。"""
76
+ return 1e6 * self.q / (self.p * src_fps)
77
+
78
+ def as_list(self) -> list[int]:
79
+ return [self.p, self.q]
80
+
81
+ # ---------- 选帧(整数闭式)----------
82
+ def at(self, k: int) -> int:
83
+ """第 k 格的帧号 `ceil(k·q/p)`。"""
84
+ return -(-k * self.q // self.p)
85
+
86
+ def index(self, i: int) -> int:
87
+ """不大于 i 的最近一格是第几格(`floor(i·p/q)`)。"""
88
+ return i * self.p // self.q
89
+
90
+ def member(self, i: int) -> bool:
91
+ return self.at(self.index(i)) == i
92
+
93
+ def floor(self, i: int) -> int:
94
+ return self.at(self.index(i))
95
+
96
+ def ceil(self, i: int) -> int:
97
+ return self.at(self.index(i - 1) + 1)
98
+
99
+ def prev(self, i: int) -> int:
100
+ """严格小于 i 的最近一格。"""
101
+ return self.floor(i - 1)
102
+
103
+ def next(self, i: int) -> int:
104
+ """严格大于 i 的最近一格。"""
105
+ return self.at(self.index(i) + 1)
106
+
107
+ def nearest(self, x: float) -> int:
108
+ """离帧号 x(可以是小数)最近的一格(按**帧距**;一样近取前一格)——窗口起点对齐用。
109
+ 等距时就是原来的 `round(x / q) * q`(逐位保留,含 Python 的银行家舍入)。
110
+ 不等距时不能按格号四舍五入:60 fps 上 2.2 fps 的前两格是 0 / 28,x = 13.7 的格号 0.502 会舍到 28,而 0 更近(2026-09-24 Codex 审计)。"""
111
+ if self.p == 1:
112
+ return self.at(round(x / self.q))
113
+ lo = self.floor(int(x // 1))
114
+ hi = self.next(lo)
115
+ return lo if x - lo <= hi - x else hi
116
+
117
+ def frames(self, lo: int, hi: int) -> list[int]:
118
+ """[lo, hi] 里的格(升序)。"""
119
+ if hi < lo:
120
+ return []
121
+ if self.p == 1:
122
+ q = self.q
123
+ return list(range(lo + (-lo) % q, hi + 1, q))
124
+ return [self.at(k) for k in range(self.index(lo - 1) + 1, self.index(hi) + 1)]
125
+
126
+ def n_frames(self, lo: int, hi_excl: int) -> int:
127
+ """[lo, hi_excl) 里最多交付几格(ffmpeg 的 `-frames:v`)。等距的照旧是 `ceil((hi − lo) / q)`(起点对齐时就是精确数)。"""
128
+ if self.p == 1:
129
+ return max(1, -(-(hi_excl - lo) // self.q))
130
+ return max(1, self.index(hi_excl - 1) - self.index(lo - 1))
131
+
132
+ def cond(self, n: str) -> str:
133
+ """ffmpeg 表达式:帧号表达式 `n` 在格上(逗号已转义)。"""
134
+ if self.p == 1:
135
+ return rf"not(mod({n}\,{self.q}))"
136
+ return rf"not(eq(floor({n}*{self.p}/{self.q})\,floor(({n}-1)*{self.p}/{self.q})))"
137
+
138
+ def select(self, n: str) -> str:
139
+ """`select` 的条件;空串 = 每帧都要。等距时和加时间网格之前逐字相同。"""
140
+ return "" if self.p == 1 and self.q == 1 else self.cond(n)
141
+
142
+ def describe(self, src_fps: float) -> str:
143
+ if self.p == 1:
144
+ return "每帧一帧" if self.q == 1 else f"每 {self.q} 帧一帧"
145
+ return f"按时间网格 {self.fps(src_fps):.3f} fps({self.p}/{self.q})"
146
+
147
+ def __eq__(self, other) -> bool:
148
+ return isinstance(other, TimeGrid) and (self.p, self.q) == (other.p, other.q)
149
+
150
+ def __hash__(self) -> int:
151
+ return hash((self.p, self.q))
152
+
153
+ def __repr__(self) -> str:
154
+ return f"TimeGrid({self.p}/{self.q})"
155
+
156
+
157
+ def meta_stride(meta: dict) -> int:
158
+ """obs `_meta` 里的**等距**采样步长。只认等距采样的探针(按"每 stride 帧一帧"数帧号的那几个)用它:
159
+ 时间网格上不等距的产物没有 `stride`,直接 `meta["stride"]` 是一个看不出原因的 KeyError(2026-09-24 审计)。"""
160
+ s = meta.get("stride")
161
+ if not s:
162
+ raise ValueError(f"这份 obs 的采样不等距(`_meta.sample_grid` = {meta.get('sample_grid')}),这个探针只认等距采样"
163
+ f"(每 stride 帧一帧);用整除素材帧率的 --fps 重跑一份")
164
+ return int(s)
165
+
166
+
167
+ class AuxGrid:
168
+ """辅助流:自己的网格 ∪ 采样网格(嵌套)。"""
169
+ __slots__ = ("own", "sample")
170
+
171
+ def __init__(self, own: TimeGrid, sample: TimeGrid) -> None:
172
+ self.own, self.sample = own, sample
173
+
174
+ @classmethod
175
+ def for_video(cls, src_fps: float, aux_max_fps: float, sample: TimeGrid) -> "AuxGrid":
176
+ """辅助流帧率 = min(上限, 素材帧率)。上限**不超过它 `CAP_TOL`** 就落得到某个整数步长(素材帧率 / k)时取那个步长:
177
+ 它是上限、不是目标值——119.88 fps 的素材给 60 就该隔帧(59.94 fps),不该为多出的 0.1% 走不等距网格(2026-09-24 Fable 复审)。"""
178
+ k = max(1, round(src_fps / aux_max_fps))
179
+ if src_fps / k <= aux_max_fps * (1 + CAP_TOL):
180
+ return cls(TimeGrid.every(k), sample)
181
+ return cls(TimeGrid.for_rate(src_fps, aux_max_fps), sample)
182
+
183
+ @classmethod
184
+ def from_list(cls, g: list[int]) -> "AuxGrid":
185
+ """`as_list()` 的逆:`[p, q, 采样 p, 采样 q]`(跨进程 / 进 `_meta` / 证据记录的写法)。"""
186
+ return cls(TimeGrid(g[0], g[1]), TimeGrid(g[2], g[3]))
187
+
188
+ @classmethod
189
+ def from_spec(cls, rec: dict) -> "AuxGrid":
190
+ """证据记录里的写法 -> 网格:`grid` 或等距的 `step`(等距时并集就是自己的网格,采样网格取它自己)。"""
191
+ if rec.get("grid"):
192
+ return cls.from_list(rec["grid"])
193
+ g = TimeGrid.every(rec.get("step") or 1)
194
+ return cls(g, g)
195
+
196
+ def as_list(self) -> list[int]:
197
+ return self.own.as_list() + self.sample.as_list()
198
+
199
+ @property
200
+ def uniform(self) -> bool:
201
+ """并集退化成自己那个等距网格:自己每帧都要(什么都盖得住),或两个都等距、采样步长是辅助步长的倍数。"""
202
+ return self.own.p == 1 and (self.own.q == 1 or (self.sample.p == 1 and self.sample.q % self.own.q == 0))
203
+
204
+ @property
205
+ def step(self) -> int | None:
206
+ return self.own.q if self.uniform else None
207
+
208
+ def key(self) -> tuple:
209
+ """复用判据比的规范形。等距的并集按步长比;不等距的按两个网格比——**帧集合相同、写法不同的偶尔会不相等**
210
+ (如 2/3 ∪ 1/3 和 2/3 ∪ 2/3),后果只是多重建一次,不会把不同的帧集合判成相同(2026-09-24 审计把原来"帧集合相同就相等"改准)。"""
211
+ return ("step", self.own.q) if self.uniform else tuple(self.as_list())
212
+
213
+ def spec(self) -> dict:
214
+ """写进证据记录的样子:等距的照旧只写 `step`(和旧产物逐字节相同),不等距的写 `grid`。"""
215
+ return {"step": self.own.q} if self.uniform else {"grid": self.as_list()}
216
+
217
+ def member(self, i: int) -> bool:
218
+ return self.own.member(i) or self.sample.member(i)
219
+
220
+ def floor(self, i: int) -> int:
221
+ return self.own.floor(i) if self.uniform else max(self.own.floor(i), self.sample.floor(i))
222
+
223
+ def ceil(self, i: int) -> int:
224
+ return self.own.ceil(i) if self.uniform else min(self.own.ceil(i), self.sample.ceil(i))
225
+
226
+ def prev(self, i: int) -> int:
227
+ return self.floor(i - 1)
228
+
229
+ def frames(self, lo: int, hi: int) -> list[int]:
230
+ if self.uniform:
231
+ return self.own.frames(lo, hi)
232
+ return sorted(set(self.own.frames(lo, hi)) | set(self.sample.frames(lo, hi)))
233
+
234
+ def n_frames(self, lo: int, hi_excl: int) -> int:
235
+ """[lo, hi_excl) 里辅路最多交付几帧(`-frames:v`、`AuxReader` 读几帧)。"""
236
+ if self.uniform:
237
+ return self.own.n_frames(lo, hi_excl)
238
+ return max(1, len(self.frames(lo, hi_excl - 1)))
239
+
240
+ def cover_floor(self, last_idx: int) -> int:
241
+ """辅助流交付到的最后一帧至少要到这里,才算盖住了采样流的最后一帧(判"辅助流半路断了")。
242
+ 等距的照旧留 `q − 1` 帧余量;不等距的:采样帧一定在辅助流里(嵌套),要盖到不大于它的最近一个入选帧。"""
243
+ if self.uniform:
244
+ return last_idx - (self.own.q - 1)
245
+ return self.floor(last_idx)
246
+
247
+ def select(self, n: str) -> str:
248
+ """ffmpeg `select` 的条件;空串 = 每帧都要。等距时和加时间网格之前逐字相同。"""
249
+ if self.uniform:
250
+ return self.own.select(n)
251
+ return f"({self.sample.cond(n)}+{self.own.cond(n)})"
252
+
253
+ def describe(self, src_fps: float) -> str:
254
+ if self.uniform:
255
+ return self.own.describe(src_fps)
256
+ return f"按时间网格 {self.own.fps(src_fps):.3f} fps({self.own.p}/{self.own.q},含采样帧)"
257
+
258
+ def __eq__(self, other) -> bool:
259
+ return isinstance(other, AuxGrid) and self.key() == other.key()
260
+
261
+ def __hash__(self) -> int:
262
+ return hash(self.key())
263
+
264
+ def __repr__(self) -> str:
265
+ return f"AuxGrid({self.own} ∪ 采样 {self.sample})"