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,363 @@
1
+ """ONNX Runtime 常驻 worker 的**客户端**:起进程、管共享内存、一次调用一次往返。
2
+
3
+ 服务端是同目录的 `ort_server.py`(独立进程;用哪个解释器起见 `server_python()`,为什么隔离见那份文件头)。
4
+ 一个客户端可以带多个模型(det 和 rec 共用一个进程)。
5
+
6
+ 用法:
7
+
8
+ cli = OrtClient({"det": "models/PP-OCRv6_medium_det_onnx/inference.onnx",
9
+ "rec": "models/PP-OCRv6_medium_rec_onnx/inference_argmax.onnx"})
10
+ outs = cli.run("rec", x) # x 是 float32 的 C 序数组;outs 是 list[np.ndarray]
11
+ cli.close()
12
+
13
+ ⚠ **形状要收敛**:服务端每个形状开一个 session(ORT 每次形状变化都重规划)。
14
+ 调用方自己负责把形状取整到少数几个,否则显存会被 session 吃光。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import importlib.util
19
+ import json
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import time
25
+ from multiprocessing import shared_memory
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+
30
+ from flowocr import paths
31
+
32
+ SERVER = Path(__file__).resolve().parent / "ort_server.py"
33
+ """worker 脚本和这个文件同目录(2026-09-22 起在包里);venv / 模型仍跟着**产物根**走(`flowocr.paths` 那条)。"""
34
+
35
+
36
+
37
+ def _has_onnxruntime() -> bool:
38
+ return importlib.util.find_spec("onnxruntime") is not None
39
+
40
+
41
+ def server_python() -> str:
42
+ """起 ORT worker 用哪个解释器:`FLOWOCR_ORT_PYTHON` 设了就是它;否则**当前解释器**(要装了 onnxruntime——
43
+ `[nvidia]` / `[cpu]` extras 都带)。原来还有第三条:退回 旧的 onnxrt 实验 venv(双 venv 时代的布局),
44
+ 发布前删了(release-plan F7:生产代码不依赖可整目录删的 `explore/`);要用别的解释器就设 `FLOWOCR_ORT_PYTHON`。"""
45
+ configured = os.environ.get("FLOWOCR_ORT_PYTHON", "").strip()
46
+ if configured: # 显式指定(A/B 换 ORT 版本、或发行时把 worker 放在另一个解释器里)
47
+ return configured
48
+ if _has_onnxruntime():
49
+ return sys.executable
50
+ raise SystemExit(f"这个解释器({sys.executable})没装 onnxruntime:装 flowocr 的 [nvidia] 或 [cpu] extras"
51
+ f"(uv sync --extra nvidia|cpu),或用 FLOWOCR_ORT_PYTHON 指一个装了的解释器")
52
+
53
+
54
+ def pick_addr(env: str, models) -> str:
55
+ """`FLOWOCR_ORT_ADDR` 里挑出**这个 client 该连哪个服务**。
56
+
57
+ 两种写法:
58
+
59
+ * `host:port` —— 不分引擎的老写法,连上去由握手核能力;
60
+ * `rec=host:port,det=host:port` —— **一个引擎一个服务**(2026-09-20 接 det 那半时改的)。
61
+ 服务端推理是一把全局锁,两个引擎塞进同一个服务就把 det 和 rec 串回去了,
62
+ 而 `run_ocr2` 早有实测:"共用一个 worker 就串行了,det 段 24.8 → 26.1 s"。
63
+
64
+ 要的模型分散在不同服务里就返回空串——这个 client 自己 spawn 一个带全套的 worker,
65
+ 正确性比共享重要(一个 client 一根管子一块共享内存,连不了两个服务)。
66
+ """
67
+ env = (env or "").strip()
68
+ if not env:
69
+ return ""
70
+ if "=" not in env:
71
+ return env
72
+ table = dict(part.split("=", 1) for part in env.split(",") if "=" in part)
73
+ hit = {table.get(m, "") for m in models}
74
+ return hit.pop() if len(hit) == 1 and all(hit) else ""
75
+
76
+
77
+ class OrtClient:
78
+ def __init__(self, models: dict[str, str], in_mb: int = 64, out_mb: int = 32,
79
+ python: str | None = None, gpu_mem_mb: int = 6144, max_sessions: int = 12,
80
+ device: str = "gpu:0", extra_args: list[str] | None = None, attach: str = "") -> None:
81
+ """`gpu_mem_mb` / `max_sessions`:**显存配额**(owner 2026-09-19 定整机 8 GB)。
82
+ CUDA EP 的 arena 上限 + per-shape 池子的条数上限——超了 ORT 当场报 OOM、池子按 LRU 淘汰,
83
+ 而不是把卡吃光(实测一条扫宽 × 批的探针同时活着 24 个 session、吃到 15 GB)。
84
+ `python` 不给就按 `server_python()`;模型路径经 `paths.resolve_model`(相对当前目录找不到就到模型根找)。
85
+ `device`:`gpu:N` / `cpu`(`gpu` 当 `gpu:0`),原样交给 worker(`ort_server --device`);调用方按自己的 `--device` 传,
86
+ **别让 worker 自己挑**(NVIDIA 包里 `--device cpu` 时 rec 曾经照样上 GPU,Codex 审计 P1;卡号曾经丢在半路,复审 P2)。
87
+ 实际用上的 provider 在 `self.provider`,`stats()` 带出去。
88
+ `extra_args`:原样追加给 `ort_server` 的参数(比如 `--share-mem 1`;管线由 `run_ocr2.ort_server_args` 给)。
89
+ `attach`:**只接**这个地址上的现成服务(`side_channel` 用)——接不上就抛,不自己起(那会多出一整份模型和显存)。"""
90
+ from flowocr.extract.ocr_args import ort_device # ort_server 顶层 import onnxruntime,别为一个字符串归一去拉它
91
+ try:
92
+ self.device = ort_device(device)
93
+ except SystemExit as exc:
94
+ raise ValueError(str(exc)) from None
95
+ self.cap_in, self.mb = in_mb * 2 ** 20, (in_mb, out_mb)
96
+ self.shm = shared_memory.SharedMemory(create=True, size=self.cap_in + out_mb * 2 ** 20)
97
+ if attach:
98
+ if not self._attach(attach, models):
99
+ self.shm.close()
100
+ self.shm.unlink()
101
+ raise RuntimeError(f"接不上 ORT 服务 {attach}")
102
+ return
103
+ python = python or server_python()
104
+ models = {n: paths.resolve_model(p) for n, p in models.items()}
105
+ # **有现成的服务就接上去**(`FLOWOCR_ORT_ADDR=host:port`):N 个 worker 共用一份模型
106
+ # = **显存只有一份**。owner 2026-09-19 把 `--workers` 默认压回 1 的理由就是显存,
107
+ # 共享是重新放宽的前提。自己的共享内存还是自己建——张量不走 socket。
108
+ addr = pick_addr(os.environ.get("FLOWOCR_ORT_ADDR", ""), models)
109
+ if addr and self._attach(addr, models):
110
+ return
111
+ self.shared = False
112
+ args = [os.path.abspath(python), str(SERVER), "--shm", self.shm.name,
113
+ "--in-bytes", str(self.cap_in), "--gpu-mem-mb", str(gpu_mem_mb),
114
+ "--max-sessions", str(max_sessions), "--device", device]
115
+ for name, path in models.items():
116
+ args += ["--model", f"{name}={path}"]
117
+ args += list(extra_args or ())
118
+ # ⚠ Windows 的 CreateProcess 不吃"相对路径 + 正斜杠"的可执行文件(WinError 2,实测)
119
+ # worker 的 stderr 落一个临时文件:它建 session 就死的时候(2026-09-22 nightly:ORT 1.26 的 CPU EP 打不开 fp16 模型),
120
+ # 原来 DEVNULL 只剩客户端一行 JSONDecodeError,病因要另起进程才看得到
121
+ import tempfile
122
+ self._err = tempfile.NamedTemporaryFile(prefix="flowocr-ort-", suffix=".log", delete=False)
123
+ self.proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
124
+ stderr=self._err, text=True, encoding="utf-8",
125
+ bufsize=1)
126
+ line = self.proc.stdout.readline()
127
+ try:
128
+ hello = json.loads(line) if line.strip() else {}
129
+ except json.JSONDecodeError:
130
+ hello = {"line": line[:300]}
131
+ if "ready" not in hello:
132
+ tail = self._stderr_tail() # 先取,close() 会把那个临时文件删掉
133
+ self.close()
134
+ raise RuntimeError(f"ORT worker 起不来:{hello};它的 stderr 尾部:{tail}")
135
+ self.provider, self.ort_version = hello.get("provider", ""), hello.get("ort_version", "")
136
+ self.side_addr = f"127.0.0.1:{hello['port']}" if hello.get("port") else ""
137
+ """服务开了旁路端口(`extra_args` 里有 `--side-listen`)时,`side_channel()` 从这里再接连接进去。"""
138
+ self.pre = bool(hello.get("pre"))
139
+ """服务端会做 rec 前处理(能收原样的 uint8 裁剪,`run_crops`)。"""
140
+ self.models = list(models)
141
+ self.t = {"send": 0.0, "wait": 0.0, "infer": 0.0, "pre": 0.0, "calls": 0}
142
+ """分段计时(**别猜,量**):调用方自己决定什么时候打出来。"""
143
+ self.lock = threading.Lock()
144
+ """⚠ **一个 client 同时只能有一个调用在飞**:一根管道 + 一块共享内存,
145
+ 两个线程各写各的请求会串线——回信认不出是谁的,输入区也会互相盖。
146
+ 管线里 det 在预取线程、rec 在主线程,**真会同时调**(2026-09-18 接 det 后端时差点栽)。
147
+ 锁只保证不坏;要并行就给两个引擎各起一个 worker(`run_ocr2` 就是这么做的)。"""
148
+ self.shapes: dict = {}
149
+
150
+ def _attach(self, addr: str, models: dict[str, str]) -> bool:
151
+ """接上现成的共享服务。**接不上就返回 False,由调用方自己 spawn**。
152
+
153
+ ⚠ `FLOWOCR_ORT_ADDR` 是**进程级**的,而一个 run_ocr2 进程里 det 和 rec 是**两个 client**
154
+ (det 在预取线程、rec 在主线程,共用一个 worker 会串行——实测 det 段 24.8 → 26.1 s)。
155
+ 共享服务现在只加载 rec,所以握手必须报出**自己要哪几个模型**(`want`)让服务端核:
156
+ 核不过就退回自己 spawn。不核的话 `--workers N`
157
+ 会把 det 请求打进只服 rec 的服务里、在服务端 `models["det"]` 上炸(审计七)。
158
+ """
159
+ import socket
160
+ try:
161
+ host, _, port = addr.rpartition(":")
162
+ sock = socket.create_connection((host or "127.0.0.1", int(port)), timeout=10)
163
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 关 Nagle,同 ort_server 那一侧(decode-buffer)
164
+ f = sock.makefile("rwb")
165
+ f.write((json.dumps({"hello": {"shm": self.shm.name, "in_bytes": self.cap_in,
166
+ "want": sorted(models), "device": self.device}}) + "\n").encode())
167
+ f.flush()
168
+ ok = json.loads(f.readline() or "{}")
169
+ except Exception as e: # 服务没了 / 连不上:自己起一个就是
170
+ print(f"[ort] 接不上共享服务 {addr}({e!r}),自己起一个 worker", flush=True)
171
+ return False
172
+ if not ok.get("ok"):
173
+ f.close()
174
+ sock.close()
175
+ print(f"[ort] 共享服务 {addr} 不提供 {ok.get('missing')} / 设备是 {ok.get('device')}"
176
+ f"(它有 {ok.get('models')},这个 client 要 {self.device}),自己起一个 worker", flush=True)
177
+ return False
178
+ sock.settimeout(None)
179
+ self.provider, self.ort_version = ok.get("provider", ""), ok.get("ort_version", "")
180
+ self.side_addr = addr
181
+ self.pre = bool(ok.get("pre"))
182
+ self.sock, self.f, self.proc = sock, f, None
183
+ self.models = list(models)
184
+ self.t = {"send": 0.0, "wait": 0.0, "infer": 0.0, "pre": 0.0, "calls": 0}
185
+ self.lock = threading.Lock()
186
+ self.shapes = {}
187
+ self.shared = True
188
+ return True
189
+
190
+ def side_channel(self) -> "OrtClient":
191
+ """再接一条到**同一个服务进程**的连接(自己的共享内存、自己的锁),给同时在途的第 2..N 个请求用
192
+ (`run_ocr2 --rec-inflight`,decode-buffer):模型和显存仍只有一份。
193
+ 管道模式起服务时要带 `--side-listen`;接的是共享服务就连同一个地址。
194
+ **接不上就抛**——这里不退回自己 spawn(那会多出一整份模型和显存)。"""
195
+ if not self.side_addr:
196
+ raise RuntimeError("这个 ORT 服务没开旁路端口(起它时要给 --side-listen)")
197
+ return OrtClient(dict.fromkeys(self.models, ""), in_mb=self.mb[0], out_mb=self.mb[1],
198
+ device=self.device, attach=self.side_addr)
199
+
200
+ def run(self, model: str, x: np.ndarray) -> list[np.ndarray]:
201
+ if x.dtype != np.float32 or not x.flags["C_CONTIGUOUS"]:
202
+ x = np.ascontiguousarray(x, dtype=np.float32)
203
+ if x.nbytes > self.cap_in:
204
+ raise ValueError(f"输入 {x.nbytes / 2**20:.0f} MB 超过共享内存的输入区 "
205
+ f"{self.cap_in / 2**20:.0f} MB")
206
+ with self.lock:
207
+ t0 = time.perf_counter()
208
+ np.ndarray(x.shape, dtype=np.float32, buffer=self.shm.buf[: x.nbytes])[...] = x
209
+ return self._roundtrip(model, {"model": model, "shape": list(x.shape)}, t0)
210
+
211
+ def run_crops(self, model: str, crops: list[np.ndarray], grid: int, batch: int) -> list[np.ndarray]:
212
+ """rec:送**原样的 uint8 裁剪**(依次紧排进共享内存),前处理在服务进程里做(`recprep.batch`,要 `self.pre`)。
213
+ 为什么(2026-09-23,decode-buffer):前处理在管线进程里抢 GIL,6,400 个裁剪要 5~9 s(单独跑约 1 s)。"""
214
+ crops = [np.ascontiguousarray(c, dtype=np.uint8) for c in crops]
215
+ total = sum(c.nbytes for c in crops)
216
+ if total > self.cap_in:
217
+ raise ValueError(f"裁剪共 {total / 2**20:.0f} MB 超过共享内存的输入区 {self.cap_in / 2**20:.0f} MB")
218
+ with self.lock:
219
+ t0 = time.perf_counter()
220
+ off = 0
221
+ for c in crops:
222
+ self.shm.buf[off: off + c.nbytes] = c.reshape(-1).data
223
+ off += c.nbytes
224
+ return self._roundtrip(model, {"model": model, "crops": [list(c.shape) for c in crops],
225
+ "grid": grid, "batch": batch}, t0)
226
+
227
+ def _roundtrip(self, model: str, req_d: dict, t0: float) -> list[np.ndarray]:
228
+ """输入已经写进共享内存:发一行请求、等一行回信、按回信切输出。**两种请求共用这一份**。"""
229
+ req = json.dumps(req_d) + "\n"
230
+ if self.shared: # 共用一个服务进程:走 socket
231
+ self.f.write(req.encode())
232
+ self.f.flush()
233
+ t1 = time.perf_counter()
234
+ line = self.f.readline().decode()
235
+ else:
236
+ self.proc.stdin.write(req)
237
+ self.proc.stdin.flush()
238
+ t1 = time.perf_counter()
239
+ line = self.proc.stdout.readline()
240
+ t2 = time.perf_counter()
241
+ if not line:
242
+ raise RuntimeError("ORT worker 没了(连接断了)")
243
+ rep = json.loads(line)
244
+ if "outs" not in rep:
245
+ raise RuntimeError(f"ORT worker 报错:{rep}")
246
+ out, off = [], self.cap_in
247
+ for dtype, shape in rep["outs"]:
248
+ arr = np.ndarray(tuple(shape), dtype=np.dtype(dtype),
249
+ buffer=self.shm.buf[off: off + int(np.prod(shape)) * np.dtype(dtype).itemsize])
250
+ out.append(arr.copy())
251
+ off += arr.nbytes
252
+ self.t["send"] += t1 - t0
253
+ self.t["wait"] += t2 - t1
254
+ self.t["infer"] += rep.get("infer_ms", 0.0) / 1000.0
255
+ self.t["pre"] += rep.get("pre_ms", 0.0) / 1000.0 # 服务端做的前处理(只有 run_crops 有)
256
+ self.t["built"] = rep.get("built", 0) # 服务端自报的累计数(不是增量)
257
+ self.t["evicted"] = rep.get("evicted", 0)
258
+ self.t["warmed"] = rep.get("warmed", 0) # 服务端起动后后台预热进池子的形状数(--warm)
259
+ self.t["build"] = rep.get("build_ms", 0.0) / 1000.0
260
+ self.t["calls"] += 1
261
+ key = (model, tuple(rep.get("shape") or req_d.get("shape") or ()))
262
+ self.shapes[key] = self.shapes.get(key, 0) + 1
263
+ return out
264
+
265
+ def stats(self) -> dict:
266
+ """给 `_meta` 的分因数据(2026-09-19 两轮审计)。
267
+
268
+ * `wait_sec − infer_sec` 是**客户端这一侧**看到的"推理之外的钱":建 session + IPC + 输出拷贝。
269
+ IPC 实测每次调用 0.12~0.33 ms,量级小,但**它确实混在里面**,别把这个差整个当成建 session。
270
+ ⚠ **共享服务下 `built` / `evicted` / `build_sec` 是跨客户端的全局累计值**
271
+ (服务端一份计数器、逐次回给每个客户端),所以多 worker 时**别把它当这一个 worker 的账**。
272
+ 那组证据是单进程量的、不受影响(审计七第三条)。
273
+
274
+ * `built` / `evicted` / `build_sec` 是**服务端自报的**:建了几个 session、LRU 淘汰了几次、
275
+ 建它们花了多久。**只有 `build_sec` 分不开**"每个新形状一次性的规划"(整片上摊薄)和
276
+ "反复淘汰重建"(整片上持续付)——而这两种对整片的预测相反。`evicted > 0` 就是后者在发生。
277
+ """
278
+ return {"calls": self.t["calls"], "send_sec": round(self.t["send"], 2),
279
+ "wait_sec": round(self.t["wait"], 2), "infer_sec": round(self.t["infer"], 2),
280
+ "overhead_sec": round(self.t["wait"] - self.t["infer"] - self.t["pre"], 2),
281
+ "server_pre_sec": round(self.t["pre"], 2), "pre_on_server": self.pre,
282
+ "built": self.t.get("built", 0), "evicted": self.t.get("evicted", 0), "warmed": self.t.get("warmed", 0),
283
+ "build_sec": round(self.t.get("build", 0.0), 2),
284
+ "shapes": len(self.shapes), "device": self.device, "provider": self.provider,
285
+ "ort_version": self.ort_version}
286
+
287
+ def report(self, tag: str = "") -> None:
288
+ if not self.t["calls"]:
289
+ return
290
+ print(f"[ort{tag}] {self.t['calls']} 次调用:送 {self.t['send']:.1f}s、等回信 "
291
+ f"{self.t['wait']:.1f}s(worker 自报推理 {self.t['infer']:.1f}s);"
292
+ f"{len(self.shapes)} 种形状", flush=True)
293
+
294
+ def close(self) -> None:
295
+ if getattr(self, "shared", False):
296
+ # 接上去的那条:**只关自己这一端**,服务进程是别人的
297
+ try:
298
+ self.f.write(b'{"bye":1}\n')
299
+ self.f.flush()
300
+ self.f.close()
301
+ self.sock.close()
302
+ except Exception:
303
+ pass
304
+ if getattr(self, "shm", None):
305
+ self.shm.close()
306
+ try:
307
+ self.shm.unlink()
308
+ except FileNotFoundError:
309
+ pass
310
+ self.shm = None
311
+ return
312
+ try:
313
+ if getattr(self, "proc", None) and self.proc.poll() is None:
314
+ self.proc.stdin.write(json.dumps({"bye": 1}) + "\n")
315
+ self.proc.stdin.flush()
316
+ self.proc.wait(timeout=5)
317
+ except Exception:
318
+ if getattr(self, "proc", None):
319
+ self.proc.kill()
320
+ finally:
321
+ if getattr(self, "shm", None):
322
+ self.shm.close()
323
+ try:
324
+ self.shm.unlink()
325
+ except FileNotFoundError:
326
+ pass
327
+ err = getattr(self, "_err", None)
328
+ if err is not None: # worker 的 stderr 临时文件:正常退出就删掉
329
+ self._err = None
330
+ try:
331
+ err.close()
332
+ os.unlink(err.name)
333
+ except OSError:
334
+ pass
335
+
336
+ def _stderr_tail(self, n: int = 1500) -> str:
337
+ err = getattr(self, "_err", None)
338
+ if err is None:
339
+ return ""
340
+ try:
341
+ err.flush()
342
+ return Path(err.name).read_text(encoding="utf-8", errors="replace")[-n:].strip()
343
+ except OSError:
344
+ return ""
345
+
346
+ def __del__(self) -> None:
347
+ self.close()
348
+
349
+
350
+ def make_det_runner(client: OrtClient, model: str = "det"):
351
+ """给 `FastDet` 换 runner:它只要一个 `runner([blob]) -> preds` 的可调用对象。
352
+
353
+ 所以 det 的**前处理(`FastDet.preprocess`)和后处理(`post_op`)一个字都不用动**,
354
+ 换掉的只是中间那一下推理。det 的输入是定尺(按 `--det-batch` 有一两种形状),
355
+ 没有 rec 那个动态宽的坑。
356
+
357
+ ⚠ **只用 fp32 那份**:实测 fp16 的 det 会多出 10% 的框(IoU≥0.5 只有 94.4%、24 帧没有一帧
358
+ 多边形完全相同),fp32 则 21/24 帧完全相同、108 vs 107 框(一次性探针 `probe_ort_det.py`)。
359
+ """
360
+ def runner(blobs):
361
+ x = blobs[0]
362
+ return client.run(model, x)
363
+ return runner
@@ -0,0 +1,150 @@
1
+ """采样帧时间戳的唯一判据:只认解码器给的真实 PTS,且必须严格递增。
2
+
3
+ **单独放一个文件是为了让守卫够得着**——和 `flowocr.extract.ocr_complete` 同一个理由
4
+ (audit-4 C8):判据写在 `run_ocr2.py`(当时在 paddleocr 实验目录)里的话,那个文件
5
+ import cv2 和 paddleocr,共享层的自检够不着,够不着就等于测不了。
6
+ 而这条判据恰好属于**错了不会报错**的一类:一条坏时间轴不会让任何下游抛异常,
7
+ 只会让所有时间都错一点。
8
+
9
+ ## 为什么不能再用 `idx / src_fps`
10
+
11
+ `src_fps` 是 `CAP_PROP_FPS`,即**容器的平均帧率**。直播录像有丢帧缺口时它低于
12
+ 标称的 60,于是帧计数 × (1/59.978) 在缺口**之前**就已经比真实 pts 慢,
13
+ 缺口之后还要再跳一次。实测(fps-and-rec-budget 报告):
14
+
15
+ | 素材 | `src_fps` | pts 缺口 | 管线比真实 pts 晚 |
16
+ | --- | ---: | --- | --- |
17
+ | f1 / f2 / f5 | 60.000 | 无 | 0 |
18
+ | f3 | 59.978 | 两处(5.2 s + 7.0 s) | 7.2 h 处 **9.5 s** |
19
+ | f4 | 59.987 | 一处(6.0 s) | 6.4 h 处 **5.0 s** |
20
+
21
+ 旧产物**不能**乘 `src_fps / round(src_fps)` 修回来:那只消掉伸缩,
22
+ 缺口之后仍缺累计丢帧时长。
23
+
24
+ ## `CAP_PROP_POS_MSEC` 的语义(2026-09-09 实测)
25
+
26
+ `read()` 之后 `cap.get(CAP_PROP_POS_MSEC)` 给的是**刚读到那一帧**的 pts,
27
+ 逐帧对齐 ffprobe 的 `frame=pts_time`(从 0.0 起,不是下一帧)。
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import bisect
32
+ import json
33
+ import math
34
+
35
+
36
+ class BadPts(ValueError):
37
+ """解码器给的时间戳不可信。**不要吞掉它**——产物必须判为未完成。"""
38
+
39
+
40
+ class PtsClock:
41
+ """按顺序喂进每个采样帧的 pts(秒),换出 `t_us`(微秒整数)。
42
+
43
+ 三道门,任何一道不过就抛 `BadPts`:非有限、为负、不严格递增。
44
+
45
+ 严格递增而不是非递减:两个采样帧拿到同一个 `t_us`,下游会把两次观测
46
+ 叠成一个时刻——这既不会报错,也数不出来。
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self.first_us: int | None = None
51
+ self.last_us: int | None = None
52
+ self.n: int = 0
53
+
54
+ def push(self, t_sec: float, *, where: str = "") -> int:
55
+ tag = f"{where}:" if where else ""
56
+ if not math.isfinite(t_sec) or t_sec < 0:
57
+ raise BadPts(f"{tag}解码器给的 PTS 无效({t_sec!r})")
58
+ t_us = int(round(t_sec * 1_000_000))
59
+ if self.last_us is not None and t_us <= self.last_us:
60
+ raise BadPts(f"{tag}PTS 没有严格递增"
61
+ f"({self.last_us / 1e6:.6f}s -> {t_sec:.6f}s)")
62
+ if self.first_us is None:
63
+ self.first_us = t_us
64
+ self.last_us = t_us
65
+ self.n += 1
66
+ return t_us
67
+
68
+ @property
69
+ def span_sec(self) -> float:
70
+ """覆盖到的 pts 跨度(秒)。一帧都没有时是 0。"""
71
+ if self.first_us is None or self.last_us is None:
72
+ return 0.0
73
+ return (self.last_us - self.first_us) / 1e6
74
+
75
+ def meta(self) -> dict:
76
+ """写进 `_meta` 的那几个键。`timebase` 是下游认时间轴的唯一凭据。"""
77
+ return {"timebase": "pts",
78
+ "pts_first_sec": self.first_us / 1e6 if self.first_us is not None else None,
79
+ "pts_last_sec": self.last_us / 1e6 if self.last_us is not None else None}
80
+
81
+
82
+ class IndexClock:
83
+ """**帧号 ↔ 真实时间**的双向映射(回抠这一级用)。
84
+
85
+ 为什么需要它(2026-09-17,reuse-budget 计划第 1 条):回抠拿 run 的时间(**真实 pts**)
86
+ 换算成帧号去开解码窗,再把窗内的帧号换算回时间写进产物——两头都用 `us_per_frame = 1e6 / src_fps`,
87
+ 而 `src_fps` 是**容器的平均帧率**。于是在丢帧缺口的素材上,窗口位置和输出时刻都带着那份偏差:
88
+ 实测 yuka f4 一个 20 分钟窗口里**一处缺口都没有**,光是"平均帧率 59.9870 vs 窗内真实 60.000"这一项,
89
+ 从头到尾就漂 **−260 ms**(≈ 15 帧);f3 在 7.2 h 处是 **9.5 s**(本文件开头那张表)。
90
+
91
+ 做法:拿 obs 里逐采样点的 `(帧号, t_us)` 当锚点做**分段线性**插值——采样点本身就是解码器给的真实 pts,
92
+ 不用再解一遍视频;锚点之间只剩局部帧率的抖动。锚点之外(窗口首尾探出去的几十帧)退回名义帧率。
93
+
94
+ **不给锚点时它就是原来的常量换算**(`t_of(i) = i × us_per_frame`,逐位相同),所以 CFR 素材上产物不变。
95
+ """
96
+
97
+ __slots__ = ("us", "f", "t", "rate")
98
+
99
+ def __init__(self, us_per_frame: float, pairs=()) -> None:
100
+ self.us = float(us_per_frame)
101
+ pairs = sorted(pairs)
102
+ self.f = [int(p[0]) for p in pairs]
103
+ self.t = [float(p[1]) for p in pairs]
104
+ # 每段的局部帧率(µs/帧);只有一个锚点时全程退回名义值
105
+ self.rate = [((self.t[i + 1] - self.t[i]) / (self.f[i + 1] - self.f[i]))
106
+ for i in range(len(self.f) - 1)]
107
+
108
+ def __len__(self) -> int:
109
+ return len(self.f)
110
+
111
+ def t_of(self, idx: float) -> float:
112
+ """帧号 -> 时间(µs,**浮点**:调用方按原来的口径自己 `int(round(...))`)。"""
113
+ if not self.f:
114
+ return idx * self.us
115
+ k = bisect.bisect_right(self.f, idx) - 1
116
+ if k < 0:
117
+ return self.t[0] - (self.f[0] - idx) * self.us
118
+ if k >= len(self.f) - 1:
119
+ return self.t[-1] + (idx - self.f[-1]) * self.us
120
+ return self.t[k] + (idx - self.f[k]) * self.rate[k]
121
+
122
+ def idx_of(self, t_us: float) -> float:
123
+ """时间(µs)-> 帧号(浮点,同上)。和 `t_of` 严格互逆。"""
124
+ if not self.t:
125
+ return t_us / self.us
126
+ k = bisect.bisect_right(self.t, t_us) - 1
127
+ if k < 0:
128
+ return self.f[0] - (self.t[0] - t_us) / self.us
129
+ if k >= len(self.t) - 1:
130
+ return self.f[-1] + (t_us - self.t[-1]) / self.us
131
+ return self.f[k] + (t_us - self.t[k]) / self.rate[k]
132
+
133
+
134
+ def index_clock_from_obs(obs_path, us_per_frame: float) -> IndexClock:
135
+ """从 obs 的逐采样点 `(frame, t_us)` 建 `IndexClock`。只流式扫一遍、按帧号去重;
136
+ `timebase` 不是 pts 的旧产物一律退回常量换算(那种时间轴本身就不可信,拿来归因的工具一律只认 pts 时间轴)。"""
137
+ pairs: dict[int, int] = {}
138
+ with open(obs_path, encoding="utf-8") as fh:
139
+ meta = json.loads(fh.readline()).get("_meta", {})
140
+ if meta.get("timebase") != "pts":
141
+ return IndexClock(us_per_frame)
142
+ for line in fh:
143
+ i = line.find('"frame": ')
144
+ j = line.find('"t_us": ')
145
+ if i < 0 or j < 0:
146
+ continue
147
+ f = int(line[i + 9:line.find(",", i)])
148
+ if f not in pairs:
149
+ pairs[f] = int(line[j + 8:line.find(",", j)])
150
+ return IndexClock(us_per_frame, pairs.items())
@@ -0,0 +1,78 @@
1
+ """rec 的**前处理 + CTC 贪心解码**:换推理后端时两边共用这一份。
2
+
3
+ 为什么单独一个模块("判据只有一份",同 `srtio` / `recpack` 的思路):
4
+ 要把 rec 换到别的运行时(inference-runtime 计划第 2 条),**PaddleX 内部那两段**
5
+ ——`RecResizeImg` 的前处理和 `CTCLabelDecode` 的后处理——就必须在我们这边有一份等价实现。
6
+ 一旦这份和 PaddleX 对不上,换后端就会静默改文本,而"文本不一样"在下游看起来和"模型不一样"没区别。
7
+ 所以:**这份实现先拿 一次性探针 `probe_rec_decode.py` 对着 PaddleX 逐条验,验过了再给后端用。**
8
+
9
+ 口径来自模型自己的 `inference.yml`(不是抄网上的):
10
+ * `PreProcess.RecResizeImg.image_shape = [3, 48, 320]`、`DecodeImage.img_mode = BGR`
11
+ (cv2 读出来就是 BGR,**不做通道交换**);
12
+ * 张量宽用 `recpack.rec_target_w`——**和管线分桶键同一份代码**;
13
+ * `PostProcess.name = CTCLabelDecode`、字表在 `PostProcess.character_dict` 里。
14
+ 标签表 = `['blank'] + 字表 + [' ']`(PaddleOCR 的 `use_space_char`),共 18,710 类。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ from flowocr.extract import recpack # noqa: F401
24
+ # 前处理(裁剪 -> 张量)搬进了 recprep:ORT 服务进程要按文件路径加载它(2026-09-23,decode-buffer)。这里照旧导出
25
+ from flowocr.extract.recprep import REC_H, grid_w, preprocess # noqa: F401
26
+
27
+ ITEM = " - "
28
+ """`character_dict` 那一段每一项的固定前缀(缩进两格)。"""
29
+
30
+
31
+ @functools.lru_cache(maxsize=4)
32
+ def load_labels(model_dir: str) -> tuple[str, ...]:
33
+ """从模型目录的 `inference.yml` 里读标签表:`['blank'] + character_dict + [' ']`。
34
+
35
+ ⚠ 不用 yaml 解析器(生产 venv 里不一定有):这一段的结构是固定的
36
+ `PostProcess:` -> `character_dict:` -> 一行一个 ` - <字符>`,直到下一个键。
37
+
38
+ ⚠⚠ **不能对整行 `strip()`**:字表里**有空白字符本身**(全角空格 U+3000、` ` 这类),
39
+ `str.strip()` 认 Unicode 空白,于是 `" -  "` 被 strip 成 `"-"`、判成"列表结束"——
40
+ 第一版就这么写的,解析在第 1,750 类上提前收工,解码时 `labels[i]` 直接 IndexError(2026-09-18 实测)。
41
+ 所以按**固定前缀** `ITEM` 切,剩下的部分原样留着。
42
+
43
+ 读出来的条数**必须**和模型输出的类别数对账(调用方拿 `len(labels)` 比)——18,710 类。
44
+ """
45
+ txt = (Path(model_dir) / "inference.yml").read_text(encoding="utf-8").splitlines()
46
+ try:
47
+ i = next(k for k, ln in enumerate(txt) if ln.strip() == "character_dict:")
48
+ except StopIteration:
49
+ raise ValueError(f"{model_dir}/inference.yml 里没有 PostProcess.character_dict")
50
+ chars: list[str] = []
51
+ for ln in txt[i + 1:]:
52
+ if not ln.startswith(ITEM):
53
+ break
54
+ v = ln[len(ITEM):]
55
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"":
56
+ v = v[1:-1].replace("''", "'")
57
+ chars.append(v)
58
+ return ("blank", *chars, " ")
59
+
60
+
61
+ def ctc_greedy(cls: np.ndarray, prob: np.ndarray, labels: tuple[str, ...]) -> list[tuple[str, float]]:
62
+ """CTC 贪心解码:逐样本**先并相邻重复、再去 blank**,分数 = 留下那些位置的概率均值。
63
+
64
+ `cls` / `prob` 形状都是 [batch, T](`cls` 是 argmax 的类别,`prob` 是那一位的概率)——
65
+ 正是 argmax 融合过的 ONNX 直接给的两个小张量,也等价于对 logits 自己取 argmax / max。
66
+ 顺序要紧:**先并重复再去 blank**(反过来会把 `ll` 这种叠字并成一个)。
67
+ """
68
+ out: list[tuple[str, float]] = []
69
+ for k in range(cls.shape[0]):
70
+ ids, ps = cls[k], prob[k]
71
+ keep = np.ones(len(ids), dtype=bool)
72
+ keep[1:] = ids[1:] != ids[:-1]
73
+ keep &= ids != 0
74
+ sel = np.nonzero(keep)[0]
75
+ text = "".join(labels[i] for i in ids[sel]) if len(sel) else ""
76
+ score = float(ps[sel].mean()) if len(sel) else 0.0
77
+ out.append((text, score))
78
+ return out