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.
- flowocr/__init__.py +15 -0
- flowocr/analyze/__init__.py +8 -0
- flowocr/analyze/align.py +151 -0
- flowocr/analyze/build_tracks.py +2886 -0
- flowocr/analyze/cluster_layers.py +155 -0
- flowocr/analyze/game_align.py +652 -0
- flowocr/analyze/gamescript.py +1220 -0
- flowocr/analyze/gtdbundle.py +306 -0
- flowocr/analyze/match.py +67 -0
- flowocr/analyze/matchers/__init__.py +5 -0
- flowocr/analyze/matchers/gametext.py +70 -0
- flowocr/analyze/merge_nameplate.py +178 -0
- flowocr/analyze/models/slot_pair.json +148 -0
- flowocr/analyze/nameplate.py +148 -0
- flowocr/analyze/pair_features.py +168 -0
- flowocr/analyze/pair_model.py +127 -0
- flowocr/analyze/refine_boundaries.py +409 -0
- flowocr/analyze/script_align.py +641 -0
- flowocr/analyze/scriptmatch.py +1018 -0
- flowocr/analyze/slot_learned.py +306 -0
- flowocr/analyze/slot_lines.py +251 -0
- flowocr/analyze/slot_modes.py +143 -0
- flowocr/analyze/slot_pairs.py +562 -0
- flowocr/analyze/slot_veto.py +104 -0
- flowocr/analyze/uigate.py +351 -0
- flowocr/artifacts/__init__.py +5 -0
- flowocr/artifacts/evalkit.py +123 -0
- flowocr/artifacts/matchedio.py +75 -0
- flowocr/artifacts/srtio.py +166 -0
- flowocr/artifacts/tracksio.py +345 -0
- flowocr/extensions.py +77 -0
- flowocr/extract/__init__.py +8 -0
- flowocr/extract/childproc.py +118 -0
- flowocr/extract/decode_proc.py +511 -0
- flowocr/extract/decode_shards.py +574 -0
- flowocr/extract/detpost.py +215 -0
- flowocr/extract/edge_proc.py +314 -0
- flowocr/extract/edge_refine.py +598 -0
- flowocr/extract/fast_det.py +214 -0
- flowocr/extract/ffcheck.py +87 -0
- flowocr/extract/framegrid.py +265 -0
- flowocr/extract/framesource.py +1264 -0
- flowocr/extract/ocr_args.py +754 -0
- flowocr/extract/ocr_complete.py +231 -0
- flowocr/extract/ocr_parallel.py +208 -0
- flowocr/extract/ort_server.py +632 -0
- flowocr/extract/ortclient.py +363 -0
- flowocr/extract/ptsclock.py +150 -0
- flowocr/extract/recdecode.py +78 -0
- flowocr/extract/recort.py +162 -0
- flowocr/extract/recpack.py +94 -0
- flowocr/extract/recpool.py +206 -0
- flowocr/extract/recprep.py +71 -0
- flowocr/extract/refine_video.py +271 -0
- flowocr/extract/regions.py +387 -0
- flowocr/extract/reuse_v2.py +593 -0
- flowocr/extract/run_groups.py +247 -0
- flowocr/extract/run_ocr2.py +1605 -0
- flowocr/extract/supervisor.py +261 -0
- flowocr/extract/timeline.py +84 -0
- flowocr/extract/typewriter_fuse.py +394 -0
- flowocr/models.py +263 -0
- flowocr/output/__init__.py +3 -0
- flowocr/output/export.py +205 -0
- flowocr/output/layout.py +210 -0
- flowocr/output/presets/__init__.py +6 -0
- flowocr/output/presets/_overlay.py +40 -0
- flowocr/output/presets/default.py +21 -0
- flowocr/output/presets/default_all.py +20 -0
- flowocr/output/presets/dev.py +21 -0
- flowocr/output/presets/matched_srt.py +32 -0
- flowocr/output/presets/script.py +20 -0
- flowocr/output/presets/srt_main.py +33 -0
- flowocr/output/render.py +82 -0
- flowocr/output/run_srt.py +83 -0
- flowocr/output/script.py +541 -0
- flowocr/paths.py +168 -0
- flowocr/provenance.py +119 -0
- flowocr/typeset/__init__.py +9 -0
- flowocr/typeset/__main__.py +38 -0
- flowocr/typeset/assfile.py +216 -0
- flowocr/typeset/core.py +821 -0
- flowocr/typeset/fx/__init__.py +11 -0
- flowocr-0.1.0.dist-info/METADATA +109 -0
- flowocr-0.1.0.dist-info/RECORD +91 -0
- flowocr-0.1.0.dist-info/WHEEL +5 -0
- flowocr-0.1.0.dist-info/entry_points.txt +10 -0
- flowocr-0.1.0.dist-info/licenses/LICENSE +674 -0
- flowocr-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +201 -0
- flowocr-0.1.0.dist-info/licenses/LICENSES/PP-OCRv6-NOTICE.md +18 -0
- flowocr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1605 @@
|
|
|
1
|
+
"""det/rec 分离的逐帧 OCR:每帧都检测,只对"新的或变了的"框做识别。
|
|
2
|
+
|
|
3
|
+
依据(acceleration 报告实测):det 29 ms/帧、rec 327 ms/帧(11.3 ms/框),
|
|
4
|
+
钱的 92% 在识别上。所以第一有效的加速是**少送框**(批处理另见 `--rec-bucket`)。
|
|
5
|
+
|
|
6
|
+
判断一个框能不能沿用上一帧的文本,不需要跑模型:
|
|
7
|
+
上一帧有 IoU≥0.7 的对应框,且框内像素的归一化相关系数够高,就直接沿用。
|
|
8
|
+
实测这一步顺带还能压掉 OCR 抖动(同一批像素被读成 `CCTVCom` / `CCTVcom` 那种)。
|
|
9
|
+
|
|
10
|
+
输出格式与 run_ocr.py 完全一致,可直接喂给 flowocr.analyze.build_tracks;
|
|
11
|
+
每条观测多一个 `reused` 字段,标明这条文本是沿用的还是新识别的。
|
|
12
|
+
|
|
13
|
+
**`t_us` 是解码器给的真实 PTS**(`_meta.timebase = "pts"`,2026-09-09 改)。
|
|
14
|
+
在此之前是 `idx / src_fps`,而 `src_fps` 是容器的**平均**帧率——录像有丢帧缺口时
|
|
15
|
+
整条时间轴被拉长,f3 在 7.2 h 处晚 9.5 s、f4 晚 5.0 s
|
|
16
|
+
(fps-and-rec-budget 报告)。旧产物的时间轴不能靠乘一个比例修回来:
|
|
17
|
+
缺口之后还缺累计丢帧时长。
|
|
18
|
+
|
|
19
|
+
用法:
|
|
20
|
+
python -m flowocr.extract.run_ocr2 tmp/clips/zh-sub.mp4 --fps 2 --out out/zh-sub/obs.jsonl
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import itertools
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from flowocr.paths import child_env, resolve_model # 子进程要找得到 flowocr(源码 checkout 里 src/ 不在默认 sys.path)
|
|
35
|
+
|
|
36
|
+
from flowocr.extract import supervisor # 外层监督者 + 环境变量名 / 退出码(不 import 推理库,console script 也能起)
|
|
37
|
+
from flowocr.extract.ocr_args import ort_device # `--device` -> ORT worker 的设备(放共享层:守卫不 import run_ocr2 也够得着)
|
|
38
|
+
from flowocr.extract.supervisor import EXIT_HW_MISMATCH, FAULT_ORPHAN_ENV, HW_REASON_ENV, HW_RETRY_ENV, WORKER_ENV
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__" and not os.environ.get(WORKER_ENV):
|
|
42
|
+
raise SystemExit(supervisor.supervise())
|
|
43
|
+
|
|
44
|
+
import cv2
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
from flowocr.extract import ( # noqa: E402
|
|
48
|
+
edge_refine, # 两遍合一遍:在线回抠的信号(--refine-fused)
|
|
49
|
+
ffcheck, # ffmpeg / ffprobe 够不够用;`_meta.runtime`(只追溯)
|
|
50
|
+
framesource, # 采样帧从哪来(cv2 / ffmpeg 子进程 / 预取)
|
|
51
|
+
decode_proc, # --decode-shards:只解码的子进程(模块顶层只有标准库)
|
|
52
|
+
ocr_args, # 参数定义:复用判据要用同一个解析器(见文件头)
|
|
53
|
+
ocr_complete, # 判据放在共享层,守卫才够得着(audit-4 C8 的教训)
|
|
54
|
+
ocr_parallel, # --workers 的切段 / 拼 _meta
|
|
55
|
+
ptsclock, # 时间戳的判据也是"错了不会报错"的一类
|
|
56
|
+
timeline as tl, # --timeline:各级在做什么 / 等什么的区间(NON_PRODUCT,decode-buffer)
|
|
57
|
+
recpack, # rec 分桶的键和调用(守卫拿假 rec 就能测)
|
|
58
|
+
regions, # 自选 OCR 范围:遮罩、拆框、时间切点(--regions,ocr-regions 计划)
|
|
59
|
+
reuse_v2, # `--pregate` 要用它的 `_crop`(判据只有一份);模块本身只依赖 numpy
|
|
60
|
+
)
|
|
61
|
+
from flowocr.extract.framegrid import TimeGrid # noqa: E402 采样 / 辅助流的时间网格
|
|
62
|
+
from flowocr.extract.fast_det import FastDet # noqa: E402
|
|
63
|
+
from flowocr.extract.recpool import RecPool # noqa: E402 组批第二步:rec 的异步消费者(--rec-async)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
STAGE: dict = defaultdict(float)
|
|
67
|
+
"""主线程各段的墙钟(秒),写进 `_meta.stage_sec`:wait_frames = 等取帧那一层交帧(解码跟不上时涨的就是它)、det、
|
|
68
|
+
decide(复用判据 + 灰度)、rec(裁剪 + 识别)、commit(建行 / 回补 / 落盘,含等在线回抠的证据)。
|
|
69
|
+
2026-09-17 加:融合多付的 +30% 猜了三轮(CPU / 帧环 / GIL)都不对,该直接量。"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def stage(k: str, t0: float) -> None:
|
|
73
|
+
"""`STAGE[k]` 加上 t0 到现在的墙钟;开着 `--timeline` 时同一段也记成区间(谁在什么时候等谁)。"""
|
|
74
|
+
STAGE[k] += tl.span(k, t0) - t0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pregate_of(gray, prev_gray, polys):
|
|
78
|
+
"""**把复用判据里最贵的那一块预先算掉**(`--pregate`):每个框"这一帧 vs 上一个采样点、同一位置"的
|
|
79
|
+
归一化相关系数。
|
|
80
|
+
|
|
81
|
+
为什么它搬得动(2026-09-18,先量后挪):`reuse_v2._gate` 的像素门在**静止匹配**(`prev` / `assoc`,
|
|
82
|
+
实测占沿用的 97%)上比的就是"同一个框、上一个采样点" —— **不需要任何链状态**,
|
|
83
|
+
所以能在 det 这一层算完,而链状态只有主线程有。
|
|
84
|
+
动机是 stage 表:主线程 `decide 5.31 s`(其中 `gate` 3.27 s)+ `commit 7.83 s`,
|
|
85
|
+
而 det 线程那边有约 7 s 空闲(q-hsr-s2 5 分钟、单进程 + `--det-prefetch 2` + ORT)。
|
|
86
|
+
|
|
87
|
+
**值按位相同**:用的是同一个 `corr` 和同一个 `_crop`、同样的两帧像素,所以产物逐字节相同
|
|
88
|
+
(不是"应该相同",是同一个函数同一组输入)。`_gate` 只在 `ref_seq == seq-1 且 ref_box == 框` 时取它。
|
|
89
|
+
"""
|
|
90
|
+
if prev_gray is None:
|
|
91
|
+
return gray, {}
|
|
92
|
+
crop_fn = reuse_v2.ReuseV2._crop
|
|
93
|
+
out = {}
|
|
94
|
+
for p_ in polys:
|
|
95
|
+
b = poly_to_box(p_)
|
|
96
|
+
k = tuple(b)
|
|
97
|
+
if k in out:
|
|
98
|
+
continue
|
|
99
|
+
out[k] = corr(crop_fn(gray, b), crop_fn(prev_gray, b))
|
|
100
|
+
return gray, out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def det_stream(source, fast_det, batch: int, pregate: bool = False, lookahead: int = 0):
|
|
104
|
+
"""取帧 + 检测(`pregate` 时顺手算灰度和静态相关系数)。`batch > 1` 时 det **先行**:
|
|
105
|
+
攒 N 帧一批检测,再逐帧交出去——复用判断和 rec 依赖上一帧的结果,仍然严格逐帧;det 不依赖,所以可以先行。
|
|
106
|
+
取帧那一层每帧都是新数组(cv2 `read()` / ffmpeg 的 copy / cvtColor),攒着不会被覆盖。
|
|
107
|
+
|
|
108
|
+
`lookahead = 1`(`--det-lookahead`,2026-09-25):下一批的**前处理 + 推理**在辅助线程上跑,这边同时做上一批的**后处理**。
|
|
109
|
+
原来三段串在一个线程上(gi-s1:推理 10.9 s、后处理 3.7 s、前处理 1.5 s,det 线程忙 16.5 s / 主循环 18.1 s),
|
|
110
|
+
推理那段大半在等 ORT 回信、后处理是 Python + cv2——两段能叠。批的顺序、每批的计算都不变,产物按构造相同;
|
|
111
|
+
只是解码头比 det 线程再多领先一批,帧环容量按它加(`edge_refine.ring_in_flight`)。"""
|
|
112
|
+
prev_gray = None
|
|
113
|
+
|
|
114
|
+
def pack(frame, polys):
|
|
115
|
+
"""返回 (灰度, 静态相关系数表) 或 None(没开 pregate)。"""
|
|
116
|
+
nonlocal prev_gray
|
|
117
|
+
if not pregate:
|
|
118
|
+
return None
|
|
119
|
+
t0 = time.perf_counter()
|
|
120
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
121
|
+
out = pregate_of(gray, prev_gray, polys)
|
|
122
|
+
prev_gray = gray
|
|
123
|
+
stage("pregate", t0)
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
if batch <= 1:
|
|
127
|
+
for idx, t_sec, frame in source:
|
|
128
|
+
polys = fast_det.predict(frame)[0]
|
|
129
|
+
yield idx, t_sec, frame, polys, pack(frame, polys)
|
|
130
|
+
return
|
|
131
|
+
it = iter(source)
|
|
132
|
+
if lookahead and hasattr(fast_det, "infer_batch"):
|
|
133
|
+
import concurrent.futures as _cf
|
|
134
|
+
pool = _cf.ThreadPoolExecutor(1, thread_name_prefix="det-infer")
|
|
135
|
+
try:
|
|
136
|
+
t0 = time.perf_counter()
|
|
137
|
+
chunk = list(itertools.islice(it, batch))
|
|
138
|
+
stage("wait_frames", t0)
|
|
139
|
+
fut = pool.submit(fast_det.infer_batch, [f for _, _, f in chunk]) if chunk else None
|
|
140
|
+
while chunk:
|
|
141
|
+
# 先把下一批领进来、交给辅助线程去推理,再回头做这一批的后处理——两批就叠起来了
|
|
142
|
+
t0 = time.perf_counter()
|
|
143
|
+
nxt = list(itertools.islice(it, batch))
|
|
144
|
+
stage("wait_frames", t0)
|
|
145
|
+
fut_n = pool.submit(fast_det.infer_batch, [f for _, _, f in nxt]) if nxt else None
|
|
146
|
+
t0 = time.perf_counter()
|
|
147
|
+
preds = fast_det.post_batch(fut.result())
|
|
148
|
+
stage("det", t0) # 开流水时 = 等辅助线程的推理 + 后处理(和上一批重叠掉的推理不在这里),和不开时不可直接比;分项看 --timeline
|
|
149
|
+
for (idx, t_sec, frame), (polys, _) in zip(chunk, preds):
|
|
150
|
+
yield idx, t_sec, frame, polys, pack(frame, polys)
|
|
151
|
+
chunk, fut = nxt, fut_n
|
|
152
|
+
finally:
|
|
153
|
+
pool.shutdown(wait=True)
|
|
154
|
+
return
|
|
155
|
+
while True:
|
|
156
|
+
t0 = time.perf_counter()
|
|
157
|
+
chunk = list(itertools.islice(it, batch))
|
|
158
|
+
stage("wait_frames", t0)
|
|
159
|
+
if not chunk:
|
|
160
|
+
return
|
|
161
|
+
t0 = time.perf_counter()
|
|
162
|
+
preds = fast_det.predict_batch([f for _, _, f in chunk])
|
|
163
|
+
stage("det", t0)
|
|
164
|
+
for (idx, t_sec, frame), (polys, _) in zip(chunk, preds):
|
|
165
|
+
yield idx, t_sec, frame, polys, pack(frame, polys)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def det_stream_threaded(source, fast_det, batch: int, depth: int, pregate: bool = False, lookahead: int = 0):
|
|
169
|
+
"""`det_stream` 整段搬到后台线程,主循环只从队列里取(`--det-prefetch`)。
|
|
170
|
+
|
|
171
|
+
**为什么值得**:stage 表里"等帧 + det"占 51%~62%、而 decide + rec + commit 占 41%~49%
|
|
172
|
+
(inference-runtime 计划的候选 1),两边**互不依赖**——det 不看上一帧的结果(`det_stream` 的原注释)。
|
|
173
|
+
串行时循环时间是两者之和,重叠之后上限是 `max(两者)`:gi-s2 上 41.2 s -> 22.9 s。
|
|
174
|
+
|
|
175
|
+
**产物按构造逐字节相同**:交出去的帧序、det 结果、提交顺序都没变,只是谁在什么时候算。
|
|
176
|
+
|
|
177
|
+
⚠ **前瞻变长了,帧环要跟着放大**:队列里攒 `depth` 批 = 解码器最多跑在主循环前面
|
|
178
|
+
`batch × (1 + depth)` 个采样点(2026-09-17 那个互等死锁就是前瞻装不下,见 `edge_refine.ring_capacity`)。
|
|
179
|
+
这一项算在 `edge_refine.ring_in_flight` 里。
|
|
180
|
+
|
|
181
|
+
⚠ `STAGE` 里的 `wait_frames` / `det` 从此是**工作线程**的墙钟,和主线程那几段**重叠、不能再相加**;
|
|
182
|
+
主线程等队列的时间单独记成 `wait_det`。
|
|
183
|
+
"""
|
|
184
|
+
import queue
|
|
185
|
+
import threading
|
|
186
|
+
|
|
187
|
+
q: queue.Queue = queue.Queue(maxsize=max(1, batch * depth))
|
|
188
|
+
DONE = object()
|
|
189
|
+
|
|
190
|
+
def worker() -> None:
|
|
191
|
+
try:
|
|
192
|
+
for item in det_stream(source, fast_det, batch, pregate, lookahead):
|
|
193
|
+
t0 = time.perf_counter()
|
|
194
|
+
q.put(item)
|
|
195
|
+
tl.span("det_put", t0, a=item[0])
|
|
196
|
+
except BaseException as exc: # 线程里抛的要带回主线程,不能吞
|
|
197
|
+
q.put(exc)
|
|
198
|
+
q.put(DONE)
|
|
199
|
+
|
|
200
|
+
th = threading.Thread(target=worker, name="det-prefetch", daemon=True)
|
|
201
|
+
th.start()
|
|
202
|
+
while True:
|
|
203
|
+
t0 = time.perf_counter()
|
|
204
|
+
item = q.get()
|
|
205
|
+
stage("wait_det", t0)
|
|
206
|
+
if item is DONE:
|
|
207
|
+
return
|
|
208
|
+
if isinstance(item, BaseException):
|
|
209
|
+
raise item
|
|
210
|
+
yield item
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def start_ort_services(args, n: int, who: str = " worker") -> tuple[list, dict]:
|
|
214
|
+
"""`n` 个管线进程共用的 ORT 服务(`--workers` 的各段 / `run_groups` 的各组):起好之后返回 `(服务进程表, 子进程的环境)`,
|
|
215
|
+
环境里 `FLOWOCR_ORT_ADDR` 指向它们。**调用方负责在 finally 里 kill 服务进程**。"""
|
|
216
|
+
# **共享推理服务**(多 worker):起**一个**服务进程,
|
|
217
|
+
# 子进程靠 `FLOWOCR_ORT_ADDR` 接上去——**模型和显存只有一份**。
|
|
218
|
+
# owner 2026-09-19 把 `--workers` 默认压回 1 的理由就是显存(三个 worker 各一套,
|
|
219
|
+
# ORT 15.3 GB,而配额是 8 GB);共享是重新放宽的前提。
|
|
220
|
+
import os
|
|
221
|
+
|
|
222
|
+
env = dict(os.environ)
|
|
223
|
+
srvs: list = []
|
|
224
|
+
if n > 1 and args.ort_share and not env.get("FLOWOCR_ORT_ADDR"):
|
|
225
|
+
import json as _json
|
|
226
|
+
|
|
227
|
+
from flowocr.extract import ortclient as _oc
|
|
228
|
+
# ⚠ **一个引擎一个服务**,不是一个服务带两个模型(2026-09-20 做 det 那一半时定的):
|
|
229
|
+
# 服务端推理是**一把全局锁**,两个引擎塞进同一个服务就把 det 和 rec 串回去了——
|
|
230
|
+
# 而 `run_ocr2` 里早有实测:"共用一个 worker 就串行了,det 段 24.8 → 26.1 s"。
|
|
231
|
+
# 所以 `FLOWOCR_ORT_ADDR` 写成 `rec=host:port,det=host:port`,客户端按模型挑。
|
|
232
|
+
# ⚠ `--exit-on-stdin-eof` + `stdin=PIPE`:服务进程握着显存、accept 循环自己不会停,
|
|
233
|
+
# 这一趟被 Ctrl-C 或中途异常打断时不盯着就会留一个吃 GPU 的孤儿(审计七)。
|
|
234
|
+
# **管道的写端跟着本进程的句柄表关**,所以本进程一没它就读到 EOF——
|
|
235
|
+
# 拿 pid 探活在 Windows 上不管用(还有谁握着句柄就探不出来,实测挂过)。
|
|
236
|
+
# 模型在父进程这里就取好(缺了当场下载),几个子进程不会同时去下同一个文件
|
|
237
|
+
want = [("rec", onnx_path(args, "rec")), ("det", onnx_path(args, "det"))]
|
|
238
|
+
addrs = []
|
|
239
|
+
# 服务的设备:显式给了就照给;`auto` 这里只做便宜检查——**试推理由各段自己做**(`resolve_device`),
|
|
240
|
+
# 某段退回了 CPU,它握手时报的设备和服务对不上,服务不接、它自己起一个 CPU worker(ort_server 握手核设备)
|
|
241
|
+
srv_dev = ort_device(args.device if args.device != "auto" else ("gpu" if gpu_blocker() is None else "cpu"))
|
|
242
|
+
for name, path in want:
|
|
243
|
+
cmd0 = [os.path.abspath(_oc.server_python()), str(_oc.SERVER), "--listen", "0",
|
|
244
|
+
"--model", f"{name}={path}", "--exit-on-stdin-eof",
|
|
245
|
+
"--gpu-mem-mb", "6144", "--max-sessions", str(args.ort_max_sessions),
|
|
246
|
+
"--device", srv_dev] + ort_server_args(args, "cpu" if srv_dev == "cpu" else "gpu")
|
|
247
|
+
p0 = subprocess.Popen(cmd0, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
248
|
+
stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
|
|
249
|
+
bufsize=1) # stdin 这根管子**一直不关**,见上
|
|
250
|
+
srvs.append(p0)
|
|
251
|
+
hello = _json.loads(p0.stdout.readline() or "{}")
|
|
252
|
+
if "port" not in hello:
|
|
253
|
+
# 起不来(比如 `--device gpu` 而这份 onnxruntime 没有 CUDA EP,退出码 3):不共享这个引擎,
|
|
254
|
+
# 各段自己起 worker——显式 gpu 的那边会各自报同一个错,auto 的各自试、各自退
|
|
255
|
+
print(f"[共享服务] 共享 ORT 服务({name},{srv_dev})起不来:{hello};各段自己起 worker", flush=True)
|
|
256
|
+
continue
|
|
257
|
+
addrs.append(f"{name}=127.0.0.1:{hello['port']}")
|
|
258
|
+
if addrs:
|
|
259
|
+
env["FLOWOCR_ORT_ADDR"] = ",".join(addrs)
|
|
260
|
+
print(f"[共享服务] 共享 ORT 服务 {env['FLOWOCR_ORT_ADDR']}"
|
|
261
|
+
f"({n} 个{who}共用;**一个引擎一个服务**)", flush=True)
|
|
262
|
+
return srvs, env
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def run_parallel(args, meta: dict, src_fps: float, grid, start_idx: int,
|
|
266
|
+
end_idx: int, total_frames: int) -> int:
|
|
267
|
+
"""`--workers N`:切成 N 段、N 个子进程同时跑,拼成一份观测。父进程不建模型。
|
|
268
|
+
|
|
269
|
+
子进程的参数 = 父进程的 argv 去掉 `--out/--start/--end/--workers` 再补上各段的值,
|
|
270
|
+
所以**其余旋钮一个不漏**地传下去。拼好之前先写一份 `complete=false` 的占位,
|
|
271
|
+
父进程中途被杀也不会留下一份旧的"已完成"产物。
|
|
272
|
+
"""
|
|
273
|
+
import subprocess
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
cuts = ocr_parallel.split_frames(start_idx, end_idx, grid, args.workers)
|
|
277
|
+
except ValueError as exc:
|
|
278
|
+
raise SystemExit(f"--workers {args.workers}:{exc}——窗口太短,用 --workers 1") from None
|
|
279
|
+
interval = grid.interval_us(src_fps) / 1e6
|
|
280
|
+
# 平均帧率偏离名义帧率的素材(有丢帧缺口,f3 / f4)上,"切点帧号 ÷ src_fps"换出来的起点会漂,
|
|
281
|
+
# 段与段重叠或漏帧(flowocr.extract.ocr_parallel 文件头)。**按整个文件**预估漂移,超过半个源帧就不开
|
|
282
|
+
nominal, avg = ocr_parallel.probe_rates(args.video)
|
|
283
|
+
if nominal and avg:
|
|
284
|
+
drift = ocr_parallel.seam_drift_sec(total_frames, avg, nominal)
|
|
285
|
+
if drift > ocr_parallel.DRIFT_MAX_FRAMES / nominal:
|
|
286
|
+
raise SystemExit(
|
|
287
|
+
f"--workers {args.workers}:这段素材的平均帧率 {avg:.4f} 和名义帧率 {nominal:.4f} "
|
|
288
|
+
f"对不上(有丢帧缺口),切点最多会漂 {drift:.2f} s——段与段会重叠或漏帧。"
|
|
289
|
+
f"用 --workers 1(根治要等窗口改成按 pts 定义)")
|
|
290
|
+
out_path = Path(args.out)
|
|
291
|
+
out_path.write_text(json.dumps({"_meta": {**meta, "complete": False}}, ensure_ascii=False)
|
|
292
|
+
+ "\n", encoding="utf-8")
|
|
293
|
+
bounds = [None, *cuts, None] # None = 沿用父进程的 --start / --end
|
|
294
|
+
base_argv = ocr_parallel.strip_opts(sys.argv[1:], {"--out", "--start", "--end", "--workers"})
|
|
295
|
+
parts = [out_path.with_name(f"{out_path.stem}.part{k}{out_path.suffix}")
|
|
296
|
+
for k in range(args.workers)]
|
|
297
|
+
t0 = time.time()
|
|
298
|
+
srvs, env = start_ort_services(args, args.workers)
|
|
299
|
+
procs = []
|
|
300
|
+
try:
|
|
301
|
+
for k, part in enumerate(parts):
|
|
302
|
+
s = args.start if bounds[k] is None else bounds[k] / src_fps
|
|
303
|
+
e = args.end if bounds[k + 1] is None else bounds[k + 1] / src_fps
|
|
304
|
+
cmd = [sys.executable, "-m", "flowocr.extract.run_ocr2", *base_argv,
|
|
305
|
+
"--out", str(part), "--start", repr(s), "--end", repr(e), *ocr_parallel.CHILD_ARGS]
|
|
306
|
+
procs.append(subprocess.Popen(cmd, env=child_env(env)))
|
|
307
|
+
print(f"[并行] {args.workers} 段,切点 {[round(c / src_fps, 3) for c in cuts]} s", flush=True)
|
|
308
|
+
rcs = [p.wait() for p in procs]
|
|
309
|
+
finally:
|
|
310
|
+
# **服务是这一趟起的,跟着这一趟走**——`try/finally`,不是只管正常路径:
|
|
311
|
+
# 异常 / Ctrl-C 时它握着显存不放(服务端那条 `--exit-on-stdin-eof` 是第二道保险)
|
|
312
|
+
for p0 in srvs:
|
|
313
|
+
p0.kill()
|
|
314
|
+
wall = time.time() - t0
|
|
315
|
+
# 退出码 3 = 那一段自己的覆盖率没过。**完成与否按整个窗口判**(merge_metas):
|
|
316
|
+
# `end=0` 时容器虚报的帧数全压在末段上,末段自己过不了、整体却是够的
|
|
317
|
+
if any(rc not in (0, 3) for rc in rcs):
|
|
318
|
+
print(f"**[并行] 有子进程失败:退出码 {rcs}**——各段产物留在 {parts[0].parent},"
|
|
319
|
+
f"整体产物保持 complete=false", flush=True)
|
|
320
|
+
return next(rc for rc in rcs if rc not in (0, 3))
|
|
321
|
+
metas = []
|
|
322
|
+
for part in parts:
|
|
323
|
+
with part.open(encoding="utf-8") as fh:
|
|
324
|
+
metas.append(json.loads(fh.readline())["_meta"])
|
|
325
|
+
merged = ocr_parallel.merge_metas(metas, meta, wall=wall,
|
|
326
|
+
cuts_sec=[c / src_fps for c in cuts], interval=interval)
|
|
327
|
+
with out_path.open("w", encoding="utf-8") as out:
|
|
328
|
+
out.write(json.dumps({"_meta": merged}, ensure_ascii=False) + "\n")
|
|
329
|
+
for part in parts:
|
|
330
|
+
with part.open(encoding="utf-8") as fh:
|
|
331
|
+
next(fh) # 各段自己的 _meta 不要
|
|
332
|
+
for line in fh:
|
|
333
|
+
out.write(line)
|
|
334
|
+
if merged["complete"]:
|
|
335
|
+
for part in parts:
|
|
336
|
+
part.unlink()
|
|
337
|
+
else: # 没完成就留着各段,排查 / 看哪段断了
|
|
338
|
+
print(f"[并行] 整体未完成,各段产物留在 {parts[0].parent}", flush=True)
|
|
339
|
+
print(json.dumps({k: merged[k] for k in
|
|
340
|
+
("complete", "sampled_frames", "boxes", "rec_calls", "reuse_rate",
|
|
341
|
+
"lost_rec", "wall_sec", "sec_per_frame", "realtime_x", "part_wall_sec")},
|
|
342
|
+
ensure_ascii=False), flush=True)
|
|
343
|
+
if merged["bad_pts"]:
|
|
344
|
+
print(f"**时间轴不可信**:{merged['bad_pts']}", flush=True)
|
|
345
|
+
return 4
|
|
346
|
+
if merged["seam_problems"]:
|
|
347
|
+
print(f"**切点对不上**:{';'.join(merged['seam_problems'])}", flush=True)
|
|
348
|
+
return 0 if merged["complete"] else 3
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def parallel_blocker(args, src_fps: float, grid, start_idx: int, end_idx: int,
|
|
352
|
+
total_frames: int) -> str | None:
|
|
353
|
+
"""`--workers N` 能不能开:不能就返回原因(同 run_parallel 开头那两道门,抽出来给退回单进程用)。"""
|
|
354
|
+
try:
|
|
355
|
+
ocr_parallel.split_frames(start_idx, end_idx, grid, args.workers)
|
|
356
|
+
except ValueError as exc:
|
|
357
|
+
return f"{exc}——窗口太短"
|
|
358
|
+
nominal, avg = ocr_parallel.probe_rates(args.video)
|
|
359
|
+
if nominal and avg:
|
|
360
|
+
drift = ocr_parallel.seam_drift_sec(total_frames, avg, nominal)
|
|
361
|
+
if drift > ocr_parallel.DRIFT_MAX_FRAMES / nominal:
|
|
362
|
+
return (f"平均帧率 {avg:.4f} 和名义帧率 {nominal:.4f} 对不上(有丢帧缺口),"
|
|
363
|
+
f"切点最多会漂 {drift:.2f} s")
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def onnx_path(args, kind: str) -> str:
|
|
368
|
+
"""`--rec-onnx` / `--det-onnx` 的生效路径:给了就用给的,空 = 官方默认(`flowocr.models`,缺了当场取)。"""
|
|
369
|
+
from flowocr import models
|
|
370
|
+
p = args.rec_onnx if kind == "rec" else args.det_onnx
|
|
371
|
+
return resolve_model(p) if p else models.path(kind)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def rec_inflight(args, device: str) -> int:
|
|
375
|
+
"""异步 rec 池实际同时在途几个请求(`--rec-inflight`):只有异步池才有"在途"可言,同步路径当 1。
|
|
376
|
+
|
|
377
|
+
**auto(默认)= GPU 上 2、CPU 上 1**(2026-09-25,defaults §1.17 末尾):CPU 的 det 和 rec 各自就能吃满全部物理核
|
|
378
|
+
(ORT 每个 session 一池物理核数的线程),第二个在途只是第三池线程来挤——gi-s1 60 s `--device cpu` 72.2 → 64.1 s(−11%)。
|
|
379
|
+
显式给数就照给的。在途几个只改批怎么拼、不改读哪一帧(NOISE_EQUIV),生效值记在 `_meta.rec_pool.inflight`。"""
|
|
380
|
+
if not args.rec_async:
|
|
381
|
+
return 1
|
|
382
|
+
if args.rec_inflight == "auto":
|
|
383
|
+
return 1 if device == "cpu" else 2
|
|
384
|
+
return max(1, args.rec_inflight)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def ort_server_args(args, device: str, graph: bool = True) -> list[str]:
|
|
388
|
+
"""追加给 `ort_server` 的参数(单 worker 的管道模式和多 worker 的共享服务**同一份**)。
|
|
389
|
+
`--ort-share-mem`:session 间共用 arena 与权重(decode-buffer);`--ort-cuda-graph`:小批次形状走 CUDA Graph;
|
|
390
|
+
`device` 是解析过的设备(`gpu` / `gpu:N` / `cpu`),CPU 上不按形状分 session、不预热(defaults §1.27)。
|
|
391
|
+
`graph=False`:det 单起的那个服务不捕图(捕图只给 rec 的小批次;det 的批 ≤4、宽 960 的形状否则也会被捕)。"""
|
|
392
|
+
import shlex
|
|
393
|
+
|
|
394
|
+
from flowocr.extract import recort
|
|
395
|
+
warm = ",".join(f"{b}x{w}" for b, w in recort.warm_shapes(max(1, args.rec_bucket or 1)))
|
|
396
|
+
cpu = device == "cpu"
|
|
397
|
+
# CPU 上**不按形状分 session、也不预热**(2026-09-25,defaults §1.27):按形状分 session 是 CUDA EP 每换形状就重规划的对策,CPU EP 没有这个坑,
|
|
398
|
+
# 23 个预热 session 只是白建(每个一池线程 + 一份权重)——gi-s1 60 s `--device cpu` 47.6 / 48.0 → 43.5 / 44.0 s(两个时间点)
|
|
399
|
+
return ((["--share-mem", "1"] if args.ort_share_mem else [])
|
|
400
|
+
+ (["--cuda-graph-batch", str(args.ort_cuda_graph)] if args.ort_cuda_graph and graph else [])
|
|
401
|
+
+ (["--warm", f"rec={warm}"] if args.ort_warm and not cpu else [])
|
|
402
|
+
+ (["--per-shape", "0"] if cpu else [])
|
|
403
|
+
+ ([] if args.ort_spin == "on" else ["--spin", "0"]) # 默认不自旋(owner 2026-09-25,defaults §1.27)
|
|
404
|
+
# 探针 / A/B 用的原样透传(`--threads 4 --per-shape 0` 这类服务端旋钮):进 config(ADDED_NOOP 只赦免空),整串记 `_meta.ort_server_args`
|
|
405
|
+
+ shlex.split(args.ort_server_extra))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def gpu_blocker(device: str = "gpu") -> str | None:
|
|
409
|
+
"""`--device auto` 的**便宜那一半**(不建模型):这份 onnxruntime 有没有 CUDA EP(0.2 s)、有没有卡、要的那张卡在不在。不能用就返回原因。
|
|
410
|
+
过了只说明值得去试——架构 / 驱动 / 运行库够不够,要在 GPU 上真建 session、真推理一次才知道(`trial_infer`)。
|
|
411
|
+
卡数按 `ocr_args.visible_gpu_count` 数(`CUDA_VISIBLE_DEVICES` 或 `nvidia-smi -L`)——`--device gpu:1` 在一卡机器上原来要到 ORT 服务建 session 才报。
|
|
412
|
+
数不到就照旧交给建 session 时报。"""
|
|
413
|
+
import onnxruntime as ort
|
|
414
|
+
if "CUDAExecutionProvider" not in ort.get_available_providers():
|
|
415
|
+
return f"这份 onnxruntime({ort.__version__})没有 CUDAExecutionProvider(有的是 {ort.get_available_providers()})"
|
|
416
|
+
n = ocr_args.visible_gpu_count()
|
|
417
|
+
if n is None:
|
|
418
|
+
return None
|
|
419
|
+
if n < 1:
|
|
420
|
+
return "没有可用的 CUDA 设备(nvidia-smi / CUDA_VISIBLE_DEVICES 一张都没列出)"
|
|
421
|
+
idx = int(ort_device(device).split(":")[1])
|
|
422
|
+
return None if idx < n else f"要 {idx} 号卡,但只看得到 {n} 张(0~{n - 1})"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class RecWorkerStart:
|
|
426
|
+
"""rec 的 ORT worker **在后台线程里起**(起进程、载模型,约 1.3 s),和这边的便宜检查、建 det 的前后处理并行
|
|
427
|
+
(2026-09-23,decode-buffer:起动那几秒原来是串着付)。`client()` 取结果(线程里的异常在这里原样抛);
|
|
428
|
+
`discard()` 不用了就关掉(设备退回 / det 建失败)——不留孤儿 worker。"""
|
|
429
|
+
|
|
430
|
+
def __init__(self, args, device: str) -> None:
|
|
431
|
+
import threading
|
|
432
|
+
|
|
433
|
+
from flowocr.extract import ortclient
|
|
434
|
+
self.device = device
|
|
435
|
+
self._res: list = []
|
|
436
|
+
|
|
437
|
+
def run() -> None:
|
|
438
|
+
try:
|
|
439
|
+
ms = {"rec": onnx_path(args, "rec")}
|
|
440
|
+
if not os.environ.get("FLOWOCR_ORT_ADDR"):
|
|
441
|
+
# (接父进程的共享服务时不塞:那个服务只服 rec,握手不过就会自己再起一整份——--workers 下显存翻倍)
|
|
442
|
+
# det 也放进**这个**服务(同一个 CUDA 上下文、各自的流并发,服务端不加锁):少一个进程和一层跨进程分时,
|
|
443
|
+
# 交错两轮 −3.0%、显存比单独起一个 det 服务少 1 GB 上下(decode-buffer 的 D1)
|
|
444
|
+
ms["det"] = onnx_path(args, "det")
|
|
445
|
+
side = ["--side-listen"] if rec_inflight(args, device) > 1 or "det" in ms else [] # 第 2..N 个在途请求 / det 从旁路端口接进来
|
|
446
|
+
self._res.append(ortclient.OrtClient(ms, in_mb=96 if "det" in ms else 64, out_mb=48 if "det" in ms else 32,
|
|
447
|
+
max_sessions=args.ort_max_sessions, device=ort_device(device),
|
|
448
|
+
extra_args=ort_server_args(args, device) + side))
|
|
449
|
+
except BaseException as exc: # noqa: BLE001 交回主线程
|
|
450
|
+
self._res.append(exc)
|
|
451
|
+
|
|
452
|
+
self._th = threading.Thread(target=run, daemon=True, name="ort-rec-start")
|
|
453
|
+
self._th.start()
|
|
454
|
+
|
|
455
|
+
def client(self):
|
|
456
|
+
self._th.join()
|
|
457
|
+
r = self._res[0]
|
|
458
|
+
if isinstance(r, BaseException):
|
|
459
|
+
raise r
|
|
460
|
+
return r
|
|
461
|
+
|
|
462
|
+
def discard(self) -> None:
|
|
463
|
+
self._th.join()
|
|
464
|
+
if self._res and not isinstance(self._res[0], BaseException):
|
|
465
|
+
close_clients(self._res[0])
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def build_models(args, device: str, rec_start: RecWorkerStart | None = None):
|
|
469
|
+
"""在**解析过的** `device`(gpu / gpu:N / cpu)上建 det / rec,含 ORT worker。
|
|
470
|
+
返回 `(rec, fast_det, det_cli, ort_cli)`;中途失败先关掉已经起来的 ORT worker 再抛(退回 CPU 时不留孤儿)。
|
|
471
|
+
`rec_start`:已经在后台起着的 rec worker(同一设备),建 det 的同时它在起;设备不同就不该传进来。"""
|
|
472
|
+
from flowocr.extract import ortclient, recort
|
|
473
|
+
ort_cli = det_cli = None
|
|
474
|
+
if rec_start is None:
|
|
475
|
+
rec_start = RecWorkerStart(args, device) # 没人预先起:这里起,照样和下面建 det 并行
|
|
476
|
+
try:
|
|
477
|
+
# **一个引擎一个 worker**:det 跑在预取线程上、rec 跑在主线程上,共用一根管道就串行了
|
|
478
|
+
# (实测 det 段 24.8 → 26.1 s),而且一根管道上两个线程同时发请求会串线——det 走同一个服务的旁路端口
|
|
479
|
+
ort_cli, rec_start = rec_start.client(), None
|
|
480
|
+
print(f"[ort] rec worker:{onnx_path(args, 'rec')}", flush=True)
|
|
481
|
+
if "det" in ort_cli.models and ort_cli.side_addr:
|
|
482
|
+
det_cli = ort_cli.side_channel() # 和 rec 同一个服务进程(见 RecWorkerStart)
|
|
483
|
+
print(f"[ort] det 和 rec 同一个服务:{onnx_path(args, 'det')}", flush=True)
|
|
484
|
+
else: # 接的是只服 rec 的共享服务(--workers > 1):det 自己起一个
|
|
485
|
+
det_cli = ortclient.OrtClient({"det": onnx_path(args, "det")}, in_mb=96, out_mb=48,
|
|
486
|
+
device=ort_device(device), extra_args=ort_server_args(args, device, graph=False))
|
|
487
|
+
print(f"[ort] det worker:{onnx_path(args, 'det')}", flush=True)
|
|
488
|
+
rec = recort.OrtRec(model=onnx_path(args, "rec"),
|
|
489
|
+
max_batch=max(1, args.rec_bucket or 1), client=ort_cli, pre=args.rec_pre)
|
|
490
|
+
if args.ort_batch_ladder:
|
|
491
|
+
rec.ladder = tuple(sorted(int(x) for x in args.ort_batch_ladder.split(",")))
|
|
492
|
+
# det 的前后处理配置和模型名读 ONNX 旁边的 inference.yml(FastDet.from_onnx),后处理是 detpost 照抄的那份
|
|
493
|
+
try:
|
|
494
|
+
fast_det = FastDet.from_onnx(onnx_path(args, "det"), ortclient.make_det_runner(det_cli),
|
|
495
|
+
default_yml_ok=args.det_onnx in ("", ocr_args.OLD_DET_ONNX))
|
|
496
|
+
except ValueError as exc:
|
|
497
|
+
raise SystemExit(f"--det-onnx:{exc}") from None
|
|
498
|
+
except BaseException:
|
|
499
|
+
close_clients(det_cli, ort_cli)
|
|
500
|
+
if rec_start is not None: # 起 rec worker 时就失败了:后台那个线程也收掉
|
|
501
|
+
rec_start.discard()
|
|
502
|
+
raise
|
|
503
|
+
return rec, fast_det, det_cli, ort_cli
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def close_clients(*clis) -> None:
|
|
507
|
+
for c in clis:
|
|
508
|
+
if c is not None:
|
|
509
|
+
try:
|
|
510
|
+
c.close()
|
|
511
|
+
except Exception:
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def trial_infer(rec, fast_det) -> None:
|
|
516
|
+
"""GPU 试推理(project-structure 计划:"架构、驱动、运行库和实际 det / rec 初始化/试推理检查"):det 过一帧、rec 过一个裁剪,
|
|
517
|
+
**真走一遍前向**——只建得起模型不算数:缺这张卡的 SASS、cuDNN 找不到引擎、库版本不对,都在第一次前向才炸。
|
|
518
|
+
走的是管线自己的调用路径,所以试过的就是之后要跑的。"""
|
|
519
|
+
frame = np.zeros((320, 320, 3), np.uint8)
|
|
520
|
+
fast_det.predict(frame)
|
|
521
|
+
rec.predict([np.full((48, 160, 3), 255, np.uint8)], batch_size=1)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def resolve_device(args):
|
|
525
|
+
"""`--device` -> `(实际设备, 退回原因或 None, build_models 的四元组)`。
|
|
526
|
+
|
|
527
|
+
* `gpu` / `gpu:N` / `cpu`:原样建。显式 `gpu` 不可用就照常报错——**不回退**,要的就是 GPU;
|
|
528
|
+
* `auto`(默认):便宜检查(`gpu_blocker`)-> GPU 上建模型 + 试推理(`trial_infer`)。**GPU 不可用**(便宜检查不过,
|
|
529
|
+
或试推理的异常是 `ocr_args.GPU_UNAVAILABLE_MARKS` 里那几类:架构不支持、驱动太旧、没有设备、库加载不了)就整体退回 CPU
|
|
530
|
+
(project-structure 计划:"任一加速条件不满足就走 CPU";不做 det 上 GPU、rec 退 CPU 的拼法——两者是同一个 ORT 服务、同一套 CUDA 库,
|
|
531
|
+
一边不行另一边多半也不行,拼出来的组合也没人验过),原因大声打出来、写进 `_meta.device_fallback`;
|
|
532
|
+
**GPU 可用但试推理出了别的错**就报错退出,不换 CPU(release-plan F5:没有可用 GPU 与 GPU 上出了错要分开)。
|
|
533
|
+
|
|
534
|
+
只在开跑前试一次;跑到一半 GPU 出错**不**悄悄换 CPU 接着跑(那是失败,照常报)。
|
|
535
|
+
参数写错(`SystemExit`)不算"GPU 不可用",照常往外抛。
|
|
536
|
+
|
|
537
|
+
⚠ 显式 `gpu` 也先过 `gpu_blocker`:没卡 / 卡号越界在这里就报清楚,别等到建 session 时报一个和设备不相干的错。
|
|
538
|
+
⚠ ONNX 模型**先取好**(缺了就下载)再试 GPU:不然断网时"模型取不到"会被当成"GPU 试推理失败"、白退一次 CPU 再报同一个错。"""
|
|
539
|
+
for kind in ("rec", "det"):
|
|
540
|
+
onnx_path(args, kind)
|
|
541
|
+
# rec 的 ORT worker 先在后台起(约 1.3 s),下面做便宜检查、建 det 的同时它在起
|
|
542
|
+
first = args.device if args.device != "auto" else "gpu"
|
|
543
|
+
rec_start = RecWorkerStart(args, first)
|
|
544
|
+
if args.device != "auto":
|
|
545
|
+
if args.device != "cpu" and (why := gpu_blocker(args.device)):
|
|
546
|
+
rec_start.discard()
|
|
547
|
+
raise SystemExit(f"--device {args.device}:{why}。要自动退回 CPU 用默认的 --device auto,要 CPU 就 --device cpu")
|
|
548
|
+
return args.device, None, build_models(args, args.device, rec_start)
|
|
549
|
+
why = gpu_blocker()
|
|
550
|
+
if why is not None:
|
|
551
|
+
rec_start.discard() # 便宜检查就不过:GPU 上那个不用了
|
|
552
|
+
if why is None:
|
|
553
|
+
models = None
|
|
554
|
+
try:
|
|
555
|
+
models = build_models(args, "gpu", rec_start)
|
|
556
|
+
trial_infer(models[0], models[1])
|
|
557
|
+
except Exception as exc:
|
|
558
|
+
text = str(exc).strip()
|
|
559
|
+
why = f"GPU 试推理失败:{type(exc).__name__}: {text.splitlines()[-1] if text else ''}"
|
|
560
|
+
if models is not None:
|
|
561
|
+
close_clients(models[2], models[3])
|
|
562
|
+
models = None
|
|
563
|
+
if not ocr_args.gpu_unavailable(f"{type(exc).__name__}: {text}"):
|
|
564
|
+
raise SystemExit(f"[设备] GPU 可用,但试推理出错——不退回 CPU(那样会掩盖问题):{why}\n"
|
|
565
|
+
f" 确认要用 CPU 跑就给 --device cpu") from None
|
|
566
|
+
if models is not None:
|
|
567
|
+
return "gpu", None, models
|
|
568
|
+
print(f"[设备] ⚠ --device auto 退回 CPU:{why}", flush=True)
|
|
569
|
+
return "cpu", why, build_models(args, "cpu")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def poly_to_box(poly) -> list[int]:
|
|
573
|
+
xs = [float(p[0]) for p in poly]
|
|
574
|
+
ys = [float(p[1]) for p in poly]
|
|
575
|
+
return [round(min(xs)), round(min(ys)), round(max(xs)), round(max(ys))]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def iou(a: list[int], b: list[int]) -> float:
|
|
579
|
+
ix0, iy0 = max(a[0], b[0]), max(a[1], b[1])
|
|
580
|
+
ix1, iy1 = min(a[2], b[2]), min(a[3], b[3])
|
|
581
|
+
if ix1 <= ix0 or iy1 <= iy0:
|
|
582
|
+
return 0.0
|
|
583
|
+
inter = (ix1 - ix0) * (iy1 - iy0)
|
|
584
|
+
return inter / ((a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def estimate_shift(prev_boxes: list[list[int]], cur_boxes: list[list[int]]) -> tuple[int, int]:
|
|
588
|
+
"""从框的几何估计整帧的滚动位移,**不需要文本**。
|
|
589
|
+
|
|
590
|
+
滚动的评论墙/片尾里,框的位置每帧都变,IoU 直接失配,复用率掉到零,
|
|
591
|
+
可逐框相关系数的开销照付——反而比不复用还慢(实测 ja-title 0.342 vs 0.324)。
|
|
592
|
+
做法:尺寸相近的框两两配对,位移按 12 px 网格投票,取非零众数。
|
|
593
|
+
"""
|
|
594
|
+
from collections import Counter
|
|
595
|
+
votes: Counter = Counter()
|
|
596
|
+
for a in prev_boxes:
|
|
597
|
+
aw, ah = a[2] - a[0], a[3] - a[1]
|
|
598
|
+
for b in cur_boxes:
|
|
599
|
+
bw, bh = b[2] - b[0], b[3] - b[1]
|
|
600
|
+
if abs(aw - bw) > max(6, 0.12 * aw) or abs(ah - bh) > max(4, 0.2 * ah):
|
|
601
|
+
continue
|
|
602
|
+
dx = (b[0] + b[2] - a[0] - a[2]) // 2
|
|
603
|
+
dy = (b[1] + b[3] - a[1] - a[3]) // 2
|
|
604
|
+
if abs(dx) < 4 and abs(dy) < 4:
|
|
605
|
+
continue
|
|
606
|
+
votes[(round(dx / 12), round(dy / 12))] += 1
|
|
607
|
+
if not votes:
|
|
608
|
+
return (0, 0)
|
|
609
|
+
(gx, gy), n = votes.most_common(1)[0]
|
|
610
|
+
return (gx * 12, gy * 12) if n >= 3 else (0, 0)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def gray_crop(frame: np.ndarray, box: list[int]) -> np.ndarray | None:
|
|
614
|
+
h, w = frame.shape[:2]
|
|
615
|
+
x0, y0 = max(0, box[0]), max(0, box[1])
|
|
616
|
+
x1, y1 = min(w, box[2]), min(h, box[3])
|
|
617
|
+
if x1 - x0 < 6 or y1 - y0 < 6:
|
|
618
|
+
return None
|
|
619
|
+
return cv2.cvtColor(frame[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY).astype(np.float32)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def corr(a: np.ndarray | None, b: np.ndarray | None) -> float:
|
|
623
|
+
if a is None or b is None:
|
|
624
|
+
return -1.0
|
|
625
|
+
if a.shape != b.shape:
|
|
626
|
+
b = cv2.resize(b, (a.shape[1], a.shape[0]))
|
|
627
|
+
a = a - a.mean(); b = b - b.mean()
|
|
628
|
+
d = float(np.linalg.norm(a) * np.linalg.norm(b))
|
|
629
|
+
return float((a * b).sum() / d) if d > 1e-6 else 0.0
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@dataclass
|
|
633
|
+
class VideoProbe:
|
|
634
|
+
"""`probe_video` 的结果:帧身份的帧率、画面尺寸、采样网格、窗口的起止帧号。"""
|
|
635
|
+
src_fps: float
|
|
636
|
+
avg_fps: float
|
|
637
|
+
W: int
|
|
638
|
+
H: int
|
|
639
|
+
total_frames: int
|
|
640
|
+
grid: TimeGrid
|
|
641
|
+
start_idx: int
|
|
642
|
+
end_idx: int
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def probe_video(args) -> VideoProbe:
|
|
646
|
+
"""探视频的元数据、定采样网格和窗口(`main` 跑前准备的第一步)。探完就放掉 `cv2.VideoCapture`,真正的解码交给 framesource。"""
|
|
647
|
+
cap = cv2.VideoCapture(args.video)
|
|
648
|
+
if not cap.isOpened():
|
|
649
|
+
raise SystemExit(f"无法打开 {args.video}")
|
|
650
|
+
try:
|
|
651
|
+
avg_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
652
|
+
# **帧身份的帧率**:ffmpeg 路径用容器的名义帧率(framesource.id_rate,复审第三轮 P1)——平均帧率在有缺口的文件上
|
|
653
|
+
# 偏低,`round(t × 平均帧率)` 会让相邻两帧撞号。cv2 路径维持平均帧率:它的 seek(`POS_FRAMES`)语义就是平均帧率
|
|
654
|
+
src_fps = avg_fps
|
|
655
|
+
if args.decoder == "ffmpeg":
|
|
656
|
+
src_fps = framesource.id_rate(ocr_parallel.probe_rates(args.video)[0], avg_fps)
|
|
657
|
+
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
658
|
+
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
659
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
660
|
+
finally:
|
|
661
|
+
cap.release()
|
|
662
|
+
# **采样网格**(`framegrid.TimeGrid`,2026-09-24):按时间取 `--fps` 帧 / 秒,锚在帧号 0 上;整除时就是原来的每 stride 帧一帧。
|
|
663
|
+
# 按帧号 `n` 数的选法(cv2 / `--frame-select index`)编不出不等距的网格,退回最近的整数步长(加时间网格之前的行为)
|
|
664
|
+
try:
|
|
665
|
+
grid = ocr_args.sample_grid(src_fps, args.fps, args.frame_select)
|
|
666
|
+
except ValueError as exc:
|
|
667
|
+
raise SystemExit(str(exc)) from None
|
|
668
|
+
if args.frame_select != "pts" and TimeGrid.for_rate(src_fps, args.fps) != grid:
|
|
669
|
+
print(f"[采样] --frame-select index 只能等距:{grid.describe(src_fps)}({grid.fps(src_fps):.3f} fps,要的是 {args.fps:g})",
|
|
670
|
+
flush=True)
|
|
671
|
+
# 窗口起点对齐到采样网格,采样点才和整片跑时落在同一批帧上
|
|
672
|
+
start_idx = grid.nearest(args.start * src_fps)
|
|
673
|
+
# `--end 0` 的窗口尾按**时长 × 帧率**算,不按帧的个数:名义帧率下有缺口的文件,最后一帧的帧号比帧数大
|
|
674
|
+
# (f3 大 728),按个数截会少跑最后约 12 秒。没缺口的文件两者相同
|
|
675
|
+
end_idx = (int(round(args.end * src_fps)) if args.end > 0
|
|
676
|
+
else int(-(-total_frames * src_fps // avg_fps)) if src_fps != avg_fps else total_frames)
|
|
677
|
+
return VideoProbe(src_fps, avg_fps, W, H, total_frames, grid, start_idx, end_idx)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def check_args(args) -> None:
|
|
681
|
+
"""只看参数组合就能判的冲突,**在碰视频、建模型之前**一次报完(原来散在 `main` 里,有几条要等模型建完才报)。"""
|
|
682
|
+
if args.det_batch < 1 or args.workers < 1:
|
|
683
|
+
raise SystemExit("--det-batch / --workers 至少是 1")
|
|
684
|
+
if args.refine_fused and (not args.reuse_v2 or args.decoder != "ffmpeg"):
|
|
685
|
+
raise SystemExit("--refine-fused 要 --reuse-v2(链事件从它来)和 --decoder ffmpeg(辅助流和采样流数同一批帧)")
|
|
686
|
+
if args.hwaccel and args.decoder != "ffmpeg":
|
|
687
|
+
# `--decoder cv2` 是显式回退,这里不自作主张退硬解——两个都显式给了就是自相矛盾,该报错
|
|
688
|
+
raise SystemExit("--hwaccel 要 --decoder ffmpeg(要软解就 --hwaccel '')")
|
|
689
|
+
# ⚠ 2026-09-20 之前这里还拦着 `--hwaccel` + `--refine-fused --refine-aux shared`,
|
|
690
|
+
# 于是开硬解就自动退回两遍回抠(35–105 s),把硬解省下的全吃回去还倒欠。
|
|
691
|
+
# 现在辅路在显存里 `scale_cuda` 缩完再 `hwdownload`,同一个 ffmpeg 分两路仍然成立。
|
|
692
|
+
if args.trace and args.workers > 1:
|
|
693
|
+
# 子进程的 argv 只换 `--out/--start/--end/--workers`,`--trace` 会**原样传给每个 worker**,
|
|
694
|
+
# 三份轨迹写进同一个文件、最后一个赢——而文件看着一切正常(2026-09-19 自查)。
|
|
695
|
+
raise SystemExit("--trace 要 --workers 1(否则几个 worker 写同一个轨迹文件,互相覆盖)")
|
|
696
|
+
if args.rec_window and not args.reuse_v2:
|
|
697
|
+
# 窗口把判决拆成 stage1/stage2 两半,这两半只有 reuse_v2 那条路上有
|
|
698
|
+
raise SystemExit("--rec-window 要 --reuse-v2(默认开)")
|
|
699
|
+
if args.rec_near and not args.rec_bucket:
|
|
700
|
+
# 近宽是"分桶的分组判据",没有分桶就无处生效——静默无效比报错糟得多
|
|
701
|
+
raise SystemExit("--rec-near 要 --rec-bucket(默认 16):它改的是分桶的分组判据")
|
|
702
|
+
if args.rec_async and not (args.rec_window and args.rec_bucket):
|
|
703
|
+
# 异步消费者是**窗口的后台版**:没有窗口就没有"未结算的帧"可言,没有分桶就没有"挑哪一批"
|
|
704
|
+
raise SystemExit("--rec-async 要 --rec-window N(N ≥ 1)和 --rec-bucket(默认 16)")
|
|
705
|
+
if args.exclude_regions and not args.predet:
|
|
706
|
+
raise SystemExit("--exclude-regions 需要同时给 --predet")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def resolve_aux_grid(args, src_fps: float):
|
|
710
|
+
"""辅助流取哪些帧(`--aux-max-fps` + 时间网格,flowocr.extract.framegrid);没开两遍合一遍是 None。
|
|
711
|
+
120 fps 的片子默认也只收 60 fps、不要求整除、采样帧嵌套在里面。`config` 记的是请求值,
|
|
712
|
+
生效网格记 `_meta.aux_grid`,复用判据比生效网格(ocr_complete.obs_reusable)。"""
|
|
713
|
+
if not args.refine_fused:
|
|
714
|
+
return None
|
|
715
|
+
try:
|
|
716
|
+
aux_grid = ocr_args.aux_grid(src_fps, args.fps, args.aux_max_fps, args.frame_select)
|
|
717
|
+
except ValueError as exc:
|
|
718
|
+
raise SystemExit(str(exc)) from None
|
|
719
|
+
if not aux_grid.uniform and (args.frame_select != "pts" or args.refine_aux in ("nvdec", "cuvid")):
|
|
720
|
+
raise SystemExit(f"辅助流{aux_grid.describe(src_fps)}只配 --frame-select pts + --refine-aux shared / noop:"
|
|
721
|
+
f"按帧号 `n` 数的选法和单独一路的硬解辅助流都编不出绝对帧号")
|
|
722
|
+
return aux_grid
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def base_meta(args, config: dict, vp: VideoProbe, aux_grid) -> dict:
|
|
726
|
+
"""obs 头一行 `_meta` 的起始内容(跑的过程中和跑完还会往里补:设备、范围、pts 区间……)。"""
|
|
727
|
+
grid = vp.grid
|
|
728
|
+
# `engine` 是没人读的历史身份串(det / rec 分离的那版管线最早跑在 PaddleOCR 上),不改
|
|
729
|
+
return {"video": str(Path(args.video).resolve()), "engine": "paddleocr-split",
|
|
730
|
+
# 产物要能追溯到"是拿哪组参数跑的"——不然换了 A/B 臂的旋钮、
|
|
731
|
+
# 文件名没变,驱动就会静默复用上一条臂的产物(audit-4 C7 的形状)。
|
|
732
|
+
# `argv` 只给人看;**复用判据比的是 `config`**(解析后的生效值,含默认值)——
|
|
733
|
+
# 字面 argv 比不出"默认值翻了"(flowocr.extract.ocr_args 文件头)
|
|
734
|
+
"argv": sys.argv[1:],
|
|
735
|
+
"config": config,
|
|
736
|
+
# 代码指纹:`config` 回答"哪组参数",回答不了"哪一版代码"——而 obs 现在装的不只是文本和框,
|
|
737
|
+
# 还有 `--refine-fused` 在线量出来的时刻(tools/ocr_args.CODE_FP_FILES 那段注释)。**不进 config**
|
|
738
|
+
"code_fp": ocr_args.code_fp(),
|
|
739
|
+
"runtime": ffcheck.runtime(), # 包版本 / Python / ffmpeg:只追溯、不进复用判据
|
|
740
|
+
"det_batch": args.det_batch,
|
|
741
|
+
"rec_bucket": args.rec_bucket,
|
|
742
|
+
"workers": args.workers,
|
|
743
|
+
"reuse": not args.no_reuse, "reuse_corr": args.reuse_corr,
|
|
744
|
+
"refresh_every": args.refresh_every,
|
|
745
|
+
"width": vp.W, "height": vp.H, "device": args.device,
|
|
746
|
+
"decoder": args.decoder, "pipe_pix": args.pipe_pix,
|
|
747
|
+
"prefetch": args.prefetch,
|
|
748
|
+
"src_fps": vp.src_fps, "avg_fps": vp.avg_fps, "sample_fps": grid.fps(vp.src_fps),
|
|
749
|
+
# 等距时照旧记 `stride`(探针按它数);不等距的只有 `sample_grid`
|
|
750
|
+
"sample_grid": grid.as_list(), **({"stride": grid.step} if grid.uniform else {}),
|
|
751
|
+
**({"aux_grid": aux_grid.as_list()} if aux_grid is not None else {}),
|
|
752
|
+
# 头一行只是占位,跑完会连同 pts 区间一起重写(见文末的 `clock.meta()`)。
|
|
753
|
+
# **`t_us` 是解码器给的真实 PTS,不是 `idx / src_fps`**——为什么、
|
|
754
|
+
# 以及三道门是什么,全在 `flowocr.extract.ptsclock` 的文件头。
|
|
755
|
+
"timebase": "pts"}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def setup_regions(args, meta: dict, W: int, H: int):
|
|
759
|
+
"""**自选 OCR 范围**(ocr-regions 计划):一次跑一个组。规格原样记进 meta(build_tracks 从这里重建时间关闭段的切点)。
|
|
760
|
+
返回要涂遮罩的组;没给 --regions 或整段全屏时是 None。"""
|
|
761
|
+
if not args.regions:
|
|
762
|
+
return None
|
|
763
|
+
spec = regions.group_spec(args.regions, args.region_group)
|
|
764
|
+
g = regions.from_spec(spec, W, H)
|
|
765
|
+
meta["regions"] = {**spec, "boundaries": g.boundaries()}
|
|
766
|
+
grp = None if g.unrestricted else g
|
|
767
|
+
print(f"[范围] 组 {spec['name']}:{len(spec['rects'])} 个矩形"
|
|
768
|
+
+ (f"、切点 {meta['regions']['boundaries']} s" if meta["regions"]["boundaries"] else "")
|
|
769
|
+
+ ("(整段全屏,不涂)" if grp is None else ""), flush=True)
|
|
770
|
+
return grp
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def apply_region_crop(args, grp, fast_det, meta: dict, W: int, H: int):
|
|
774
|
+
"""`--region-crop`:det 只看组范围的静态外接矩形(ocr-regions 计划的对照臂;改 det 的输入尺寸,框会变——进复用判据)。返回(可能包了一层的)FastDet。
|
|
775
|
+
2026-09-24 起给了 --regions 就默认开,所以裁不了时**让路、说明原因**(同 resolve_split 的规矩),不报错;原因记 `_meta.region_crop_off`。"""
|
|
776
|
+
if not (args.region_crop and grp is not None):
|
|
777
|
+
return fast_det
|
|
778
|
+
bb = grp.union_bbox()
|
|
779
|
+
off = "组范围的外接矩形就是全屏(或某一时段全屏),没得裁" if bb is None or bb == (0, 0, W, H) else ""
|
|
780
|
+
if off:
|
|
781
|
+
meta["region_crop_off"] = off
|
|
782
|
+
print(f"[范围] det 不裁剪:{off}", flush=True)
|
|
783
|
+
return fast_det
|
|
784
|
+
meta["region_crop"] = list(bb)
|
|
785
|
+
print(f"[范围] det 只看外接矩形 {bb}(--region-crop)", flush=True)
|
|
786
|
+
return regions.CroppedDet(fast_det, bb)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def record_devices(args, meta: dict, device: str, fallback, fast_det, det_cli, ort_cli) -> None:
|
|
790
|
+
"""**实际用上的设备和回退原因**(project-structure 计划:"实际生效的设备和回退原因可见,不用 auto 代替结果"),以及用的是哪份模型。
|
|
791
|
+
`config.device` 记的是**请求的**值(`auto` 也照记,它在 NOISE_EQUIV 里、不触发重建),这几个键记结果:
|
|
792
|
+
worker 实际用上的 provider;det 的模型名(读自 ONNX 旁边的 inference.yml,只追溯)。"""
|
|
793
|
+
meta["device_resolved"] = device
|
|
794
|
+
meta["device_fallback"] = fallback
|
|
795
|
+
meta["ort_server_args"] = ort_server_args(args, device) # 服务实际收到的旋钮(CPU 规则、--ort-server-extra 的生效结果)
|
|
796
|
+
meta["det_model"] = fast_det.spec.model_name
|
|
797
|
+
meta["device_effective"] = {"det": f"ort:{det_cli.provider}", "rec": f"ort:{ort_cli.provider}"}
|
|
798
|
+
print(f"[设备] --device {args.device} -> {device}:det {meta['device_effective']['det']}、"
|
|
799
|
+
f"rec {meta['device_effective']['rec']}", flush=True)
|
|
800
|
+
if device == "cpu":
|
|
801
|
+
print(f"[设备] CPU 的调度规则(defaults §1.17 / §1.27):rec 在途 {rec_inflight(args, device)}、"
|
|
802
|
+
"不按形状分 session、不预热", flush=True)
|
|
803
|
+
# **用的是哪份模型文件**(内容,不是路径):config 只记 `--rec-onnx` / `--det-onnx` 的字符串(而且它们在 NOISE_EQUIV 里),
|
|
804
|
+
# 同名文件换了内容复用判据看不出来——这里记 sha256 追溯
|
|
805
|
+
from flowocr import models as _models
|
|
806
|
+
meta["models"] = {k: _models.meta(onnx_path(args, k)) for k in ("rec", "det")}
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def make_rec_pool(args, device: str, rec, ort_cli):
|
|
810
|
+
"""组批第二步:rec 搬到后台线程(recpool.py 文件头)。**推理引擎从此只被那一个线程碰**——
|
|
811
|
+
回补的单框读也走它,不然主线程和它会同时调同一个 predictor。返回 `(旁路客户端, 池子或 None)`:
|
|
812
|
+
`--rec-inflight N` 时第 2..N 个后端各接一条到**同一个** ORT 服务的旁路连接(decode-buffer),收尾要关。"""
|
|
813
|
+
side_clis = [ort_cli.side_channel() for _ in range(rec_inflight(args, device) - 1)]
|
|
814
|
+
side_recs = []
|
|
815
|
+
if side_clis:
|
|
816
|
+
from flowocr.extract import recort
|
|
817
|
+
side_recs = [recort.OrtRec(model=rec.model, max_batch=rec.max_batch, grid=rec.grid, client=c, pre=args.rec_pre)
|
|
818
|
+
for c in side_clis]
|
|
819
|
+
for r in side_recs:
|
|
820
|
+
r.ladder = rec.ladder
|
|
821
|
+
pool = (RecPool([rec] + side_recs, args.rec_bucket, args.rec_near, args.rec_knee)
|
|
822
|
+
if args.rec_async else None)
|
|
823
|
+
return side_clis, pool
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def make_reuse(args, rec, pool, src_fps: float):
|
|
827
|
+
"""复用判据第二版(`reuse_v2.ReuseV2`),回补的读接到池子上(有池子时)或直接调 rec。"""
|
|
828
|
+
def rec_one(crop):
|
|
829
|
+
# 回补用掉的 rec **不在这里记账**:commit_stage2 把这一批的次数当返回值交出去
|
|
830
|
+
# (`settle_one` / `settle_window` 并进 n_rec),异步那条另由 `pool.stats().calls` 兜住。
|
|
831
|
+
if pool is not None:
|
|
832
|
+
# 回补**卡着落盘水位**(主线程正等它),所以插队;含它的那一组会先走
|
|
833
|
+
rid = pool.new_ids(1)[0]
|
|
834
|
+
pool.submit([(rid, crop)])
|
|
835
|
+
got = pool.collect([rid], prio=True)[rid]
|
|
836
|
+
return got if got is not None else ("", 0.0)
|
|
837
|
+
r = rec.predict([crop], batch_size=1)[0]
|
|
838
|
+
return (r["rec_text"], float(r["rec_score"])) if r is not None else ("", 0.0)
|
|
839
|
+
|
|
840
|
+
def rec_many(crops):
|
|
841
|
+
"""回补的一层:一次全交给池子(它会按近宽挑最大的一组发,插队)。"""
|
|
842
|
+
ids = pool.new_ids(len(crops))
|
|
843
|
+
pool.submit(list(zip(ids, [c.copy() for c in crops])))
|
|
844
|
+
got = pool.collect(ids, prio=True)
|
|
845
|
+
return [got[r] if got[r] is not None else ("", 0.0) for r in ids]
|
|
846
|
+
|
|
847
|
+
v2 = reuse_v2.ReuseV2(args, corr, estimate_shift, rec_one)
|
|
848
|
+
v2.src_fps = src_fps # 它自己也写 obs 行,`frame` 认 pts 要用(framesource.pts_frame)
|
|
849
|
+
if pool is not None:
|
|
850
|
+
v2.rec_many = rec_many
|
|
851
|
+
if args.trace:
|
|
852
|
+
v2.trace = [] # 因果轨迹(`--trace`),跑完落盘;见 reuse_v2.trace 那段
|
|
853
|
+
return v2
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def load_dense(args) -> list[list[float]]:
|
|
857
|
+
"""`--exclude-regions`:predet 里选中的那几个区域的全包围盒(落在里面的框不读)。"""
|
|
858
|
+
dense: list[list[float]] = []
|
|
859
|
+
if not args.exclude_regions:
|
|
860
|
+
return dense
|
|
861
|
+
pd = json.loads(Path(args.predet).read_text(encoding="utf-8"))
|
|
862
|
+
tot = sum(r["boxes"] for r in pd["regions"])
|
|
863
|
+
for i in [int(x) for x in args.exclude_regions.split(",") if x.strip()]:
|
|
864
|
+
r = pd["regions"][i]
|
|
865
|
+
dense.append(r["rect_excl"])
|
|
866
|
+
print(f"[predet] 排除区域 {i}:占预扫框 {r['boxes']/max(1,tot):.1%},"
|
|
867
|
+
f"排除框面积 {r['area_frac_excl']:.1%},常驻度 {r['persistence']:.2f}", flush=True)
|
|
868
|
+
return dense
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def setup_edge(args, v2, meta: dict, grp, vp: VideoProbe, aux_grid):
|
|
872
|
+
"""两遍合一遍:辅助流(缩、灰度、全帧率)进帧环,reuse_v2 的链事件驱动在线回抠(flowocr.extract.edge_refine)。
|
|
873
|
+
`--edge-proc`(默认)时回抠在自己的进程里、返回它的代理;否则进程内的 `EdgeRefiner`。挂到 `v2.edge` 上。"""
|
|
874
|
+
src_fps = vp.src_fps
|
|
875
|
+
edge_kw = dict(src_fps=src_fps, start_idx=vp.start_idx, width=vp.W, height=vp.H,
|
|
876
|
+
scale=args.refine_scale, grid=aux_grid.as_list(), thr=args.refine_thr,
|
|
877
|
+
noop=args.refine_aux == "noop",
|
|
878
|
+
ring_cap=edge_refine.ring_capacity(
|
|
879
|
+
vp.grid.max_gap, int(round(edge_refine.LEAD_US * src_fps / 1e6)),
|
|
880
|
+
int(round(edge_refine.END_TAIL_US * src_fps / 1e6)),
|
|
881
|
+
args.refine_ring, edge_refine.ring_in_flight(args), aux=aux_grid))
|
|
882
|
+
if args.edge_proc:
|
|
883
|
+
# 回抠放进自己的进程(edge_proc):辅助流直接连过去,这边只发链事件、收证据。范围遮罩那边自己按规格建
|
|
884
|
+
from flowocr.extract import edge_proc
|
|
885
|
+
edge = edge_proc.EdgeProxy(edge_kwargs=edge_kw, start_idx=vp.start_idx, end_idx=vp.end_idx, src_fps=src_fps,
|
|
886
|
+
width=vp.W, height=vp.H,
|
|
887
|
+
regions_spec=(meta["regions"] if grp is not None else None))
|
|
888
|
+
else:
|
|
889
|
+
edge = edge_refine.EdgeRefiner(**edge_kw)
|
|
890
|
+
if grp is not None: # 辅助流也只看得见范围里的像素(在线回抠的信号不许读进被遮掉的东西)
|
|
891
|
+
edge.ring.premask = regions.aux_masker(grp, edge.Ws, edge.Hs, src_fps)
|
|
892
|
+
v2.edge = edge
|
|
893
|
+
# 延迟落盘要等"那一批开的段都停变了"(edge_refine.settled_by);缓冲的采样点数不够长的话每次落盘都得等工作线程
|
|
894
|
+
# 追平主线程,两个线程就串行了(--refresh-every 小的时候 horizon 只有二十几)
|
|
895
|
+
v2.horizon = max(v2.horizon, edge_refine.MAX_OPEN_SAMPLES + 10)
|
|
896
|
+
return edge
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def settle_hwaccel(args, hw_probe, hw_pool, meta: dict) -> None:
|
|
900
|
+
"""**硬解开不开得了,先试解几格**(同 `--workers` 的老规矩:开不了就退回去、打印原因、meta 记生效值)。
|
|
901
|
+
NVDEC 在有前摇的 av1 上少交付 1~3 帧;`gi-s2` 连 pts 0.000 都不交付,`--frame-select pts` 也选不出第 0 格。
|
|
902
|
+
进了默认就不能让这类素材整段跑不了。判据是 FfmpegSource 自己那道硬解格子核对,不另写一份。
|
|
903
|
+
试解在起动时就在后台跑了(`hw_probe`,和建模型并行),这里取结论;开不了就把 `args.hwaccel` 置空。"""
|
|
904
|
+
if args.hwaccel:
|
|
905
|
+
why = hw_probe.result()
|
|
906
|
+
hw_pool.shutdown()
|
|
907
|
+
if why:
|
|
908
|
+
print(f"[硬解] --hwaccel {args.hwaccel} 在这段素材上开不了,退回软解:{why}", flush=True)
|
|
909
|
+
args.hwaccel = ""
|
|
910
|
+
meta["hwaccel_effective"] = args.hwaccel or ""
|
|
911
|
+
if os.environ.get(HW_RETRY_ENV):
|
|
912
|
+
meta["hwaccel_fallback"] = os.environ[HW_RETRY_ENV] # 硬解跑到一半交付不对、整段改软解重跑的原因
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def open_source(args, dec, edge, meta: dict, vp: VideoProbe, aux_grid):
|
|
916
|
+
"""取帧层:`--decode-shards` 的只解码子进程(硬解开得了才分片),或 framesource 的一路解码;辅助流跟着接到回抠上。
|
|
917
|
+
返回 `(source, dec)`——不分片时 `dec` 已关、是 None。"""
|
|
918
|
+
source_kw = dict(backend=args.decoder, grid=vp.grid, start_idx=vp.start_idx,
|
|
919
|
+
end_idx=vp.end_idx, src_fps=vp.src_fps, width=vp.W, height=vp.H,
|
|
920
|
+
prefetch=args.prefetch, pix=args.pipe_pix, hwaccel=args.hwaccel or None,
|
|
921
|
+
select_by=args.frame_select)
|
|
922
|
+
lanes = None
|
|
923
|
+
if dec is not None:
|
|
924
|
+
# 硬解开得了才分片(试解不过 / 没 GPU 就走一路软解:owner 要每个操作都有能跑的 CPU 版,不求调度一样)
|
|
925
|
+
lanes = ["hw"] * args.decode_shards if args.hwaccel else []
|
|
926
|
+
if len(lanes) < 2:
|
|
927
|
+
print("[解码分片] 不分片:硬解开不了,走一路软解", flush=True)
|
|
928
|
+
dec.close()
|
|
929
|
+
dec, lanes = None, None
|
|
930
|
+
# `_meta.proc_split.decode` 记**生效**的路数(2026-09-24 审计:原来这里关了、meta 仍记请求的 3)
|
|
931
|
+
meta["proc_split"]["decode"] = 0
|
|
932
|
+
meta["proc_split"]["decode_off"] = "硬解开不了"
|
|
933
|
+
if dec is not None:
|
|
934
|
+
# 解码分片在只解码的子进程里(decode_proc + decode_shards):采样帧经共享内存槽过来,det 在这边;
|
|
935
|
+
# 辅助流按块序由那边直接转给回抠进程(它看到的和一路 ffmpeg 直连一样)
|
|
936
|
+
dec.start({
|
|
937
|
+
"video": args.video, "source_kw": source_kw, "W": vp.W, "H": vp.H,
|
|
938
|
+
"slots": decode_proc.slots_for(args.det_batch, args.det_prefetch), "lanes": lanes, "block_sec": args.decode_block,
|
|
939
|
+
"aux": ({"url": edge.url, "pts_addr": edge.pts_addr, "grid": aux_grid.as_list(), "scale": args.refine_scale}
|
|
940
|
+
if edge is not None and args.refine_aux in ("shared", "noop") else None)})
|
|
941
|
+
meta["decode_shards"] = {"lanes": lanes, "block_sec": args.decode_block, "wait_start_sec": round(dec.wait_start_sec, 2)}
|
|
942
|
+
return dec, dec
|
|
943
|
+
source = framesource.make_source(
|
|
944
|
+
args.video, **source_kw,
|
|
945
|
+
aux=(({"remote": edge} if args.edge_proc else {"sink": edge.ring})
|
|
946
|
+
| {"grid": aux_grid.as_list(), "scale": args.refine_scale}
|
|
947
|
+
if edge is not None and args.refine_aux in ("shared", "noop") else None))
|
|
948
|
+
return source, dec
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def main() -> int:
|
|
952
|
+
# 参数定义在共享层(`flowocr.extract.ocr_args`):复用判据要拿**同一个解析器**
|
|
953
|
+
# 算出"这一趟会用哪组生效值",才能和 `_meta.config` 比
|
|
954
|
+
args = ocr_args.parse_args()
|
|
955
|
+
check_args(args)
|
|
956
|
+
ffcheck.require() # ffmpeg / ffprobe / showinfo 缺了就在这里说清楚,别等取帧时 FileNotFoundError
|
|
957
|
+
ffcheck.require_runtime() # onnxruntime 一个都没有 / 两个都在就退出;非 Windows 打警告
|
|
958
|
+
config = ocr_args.product_config(args) # 请求的生效值(拆进程关没关另记 `_meta.proc_split`)
|
|
959
|
+
if args.timeline:
|
|
960
|
+
if args.workers > 1: # 同 --trace:几个 worker 会写同一个文件
|
|
961
|
+
raise SystemExit("--timeline 要 --workers 1(否则几个 worker 写同一份时间线,互相覆盖)")
|
|
962
|
+
Path(args.timeline).parent.mkdir(parents=True, exist_ok=True)
|
|
963
|
+
tl.start(args.timeline) # 子进程(回抠 / 解码)从环境变量认出来、各写各的
|
|
964
|
+
tl.mark("boot", "args") # 起动 / 收尾各段的分界(timeline_report 的"起动"那张表)
|
|
965
|
+
split_off = ocr_args.resolve_split(args)
|
|
966
|
+
# `--decode-shards`:只解码的子进程第一件事就起(它不 import 推理库,起动约 1 s,和这边建模型并行;`--workers` 下每个 worker 自己起)
|
|
967
|
+
# (`run_groups --share-decode` 时不起自己的,接上多组共用的广播解码进程,decode_proc.connect_decoder)
|
|
968
|
+
dec = decode_proc.connect_decoder() if args.workers == 1 and args.decode_shards > 1 else None
|
|
969
|
+
|
|
970
|
+
vp = probe_video(args)
|
|
971
|
+
src_fps, W, H, grid = vp.src_fps, vp.W, vp.H, vp.grid
|
|
972
|
+
total_frames, start_idx, end_idx = vp.total_frames, vp.start_idx, vp.end_idx
|
|
973
|
+
|
|
974
|
+
aux_grid = resolve_aux_grid(args, src_fps)
|
|
975
|
+
out_path = Path(args.out)
|
|
976
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
977
|
+
meta = base_meta(args, config, vp, aux_grid)
|
|
978
|
+
grp = setup_regions(args, meta, W, H)
|
|
979
|
+
|
|
980
|
+
rg_cuts = regions.cuts_us(grp) if grp is not None else [] # 时间切点(µs)+ 那一刻变了的像素:提取层据此切链
|
|
981
|
+
prev_t_us = -(1 << 62)
|
|
982
|
+
if args.workers > 1:
|
|
983
|
+
# 显存配额 8 GB(owner 09-19)。ORT 是按**形状数**涨的,单进程 09-20 实测
|
|
984
|
+
# 5.04 GB;3 worker 就算走共享服务也是 8.6 GB,**已经越线**。
|
|
985
|
+
# 不拦(owner 可能明知故犯地在量东西),但要说出来——默默超配额是最坏的那种。
|
|
986
|
+
print(f"[显存] ⚠ --workers {args.workers}:单进程实测 5.04 GB,"
|
|
987
|
+
"3 worker 共享服务 8.6 GB,**越过 8 GB 配额**。跑之前看一眼 nvidia-smi", flush=True)
|
|
988
|
+
if args.workers > 1:
|
|
989
|
+
# **在建模型之前分叉**:父进程只切段、起子进程、拼结果,不占 GPU。
|
|
990
|
+
# `--workers 3` 是默认(owner 2026-09-16),所以开不了的情况(窗口太短切不出段、有丢帧缺口的素材
|
|
991
|
+
# 切点会漂)**不能再退出**,退回单进程、把原因打出来、meta 记 `workers_effective`。
|
|
992
|
+
# `config.workers` 仍记**请求的**值——复用判据比的是同一段 + 同一条臂,退回是这段素材的确定性行为,
|
|
993
|
+
# 同样的 argv 再跑一遍还是退回,产物可复现
|
|
994
|
+
why = parallel_blocker(args, src_fps, grid, start_idx, end_idx, total_frames)
|
|
995
|
+
if why is None:
|
|
996
|
+
return run_parallel(args, meta, src_fps, grid, start_idx, end_idx, total_frames)
|
|
997
|
+
print(f"[并行] --workers {args.workers} 开不了,退回单进程:{why}", flush=True)
|
|
998
|
+
if not args.det_prefetch:
|
|
999
|
+
print("[并行] 提示:`--det-prefetch` 被显式关掉了;它现在默认是 2,quick 六段上省 15%~25%"
|
|
1000
|
+
"(产物逐字节相同,inference-runtime 计划)", flush=True)
|
|
1001
|
+
meta["workers_effective"] = 1
|
|
1002
|
+
if os.environ.get("FLOWOCR_RUN_GROUPS"): # run_groups 的一组:这一趟怎么共用的(产物不变,不进 config)
|
|
1003
|
+
meta["run_groups"] = json.loads(os.environ["FLOWOCR_RUN_GROUPS"])
|
|
1004
|
+
meta["proc_split"] = {"edge": bool(args.edge_proc and args.refine_fused), "decode": args.decode_shards,
|
|
1005
|
+
**({"decode_off": args.decode_shards_off} if getattr(args, "decode_shards_off", "") else {}),
|
|
1006
|
+
**({"off": split_off} if split_off else {})}
|
|
1007
|
+
|
|
1008
|
+
# 硬解试解(ffmpeg 解 4 格,约 0.65 s)和建模型无关:后台先跑着,用到的时候(建取帧层之前)再取结论
|
|
1009
|
+
hw_probe = hw_pool = None
|
|
1010
|
+
if args.hwaccel:
|
|
1011
|
+
import concurrent.futures as _cf
|
|
1012
|
+
hw_pool = _cf.ThreadPoolExecutor(1, thread_name_prefix="hw-probe")
|
|
1013
|
+
hw_probe = hw_pool.submit(
|
|
1014
|
+
framesource.hwaccel_blocker, args.video, grid=grid, start_idx=start_idx, src_fps=src_fps,
|
|
1015
|
+
width=W, height=H, hwaccel=args.hwaccel, select_by=args.frame_select)
|
|
1016
|
+
tl.mark("boot", "probed")
|
|
1017
|
+
try:
|
|
1018
|
+
device, fallback, (rec, fast_det, det_cli, ort_cli) = resolve_device(args)
|
|
1019
|
+
except BaseException:
|
|
1020
|
+
if dec is not None:
|
|
1021
|
+
dec.close()
|
|
1022
|
+
raise
|
|
1023
|
+
tl.mark("boot", "models")
|
|
1024
|
+
record_devices(args, meta, device, fallback, fast_det, det_cli, ort_cli)
|
|
1025
|
+
fast_det = apply_region_crop(args, grp, fast_det, meta, W, H)
|
|
1026
|
+
side_clis, pool = make_rec_pool(args, device, rec, ort_cli)
|
|
1027
|
+
v2 = make_reuse(args, rec, pool, src_fps) if args.reuse_v2 else None
|
|
1028
|
+
dense = load_dense(args)
|
|
1029
|
+
|
|
1030
|
+
def in_dense(b: list[int]) -> bool:
|
|
1031
|
+
cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2
|
|
1032
|
+
return any(r[0] <= cx <= r[2] and r[1] <= cy <= r[3] for r in dense)
|
|
1033
|
+
|
|
1034
|
+
prev: list[tuple[list[int], str, float, int]] = [] # box, text, conf, 已连续沿用几帧
|
|
1035
|
+
prev_frame: np.ndarray | None = None
|
|
1036
|
+
n_frames = n_boxes = n_rec = n_reused = n_dropped = n_tiny = n_lost = 0
|
|
1037
|
+
# `n_rec` 数的是**送进 rec 的裁剪数**;分桶 / 批处理之后模型真正被调用的次数和它脱钩,另记一个
|
|
1038
|
+
n_rec_calls = 0
|
|
1039
|
+
# **解码中断和正常跑完必须分得开**(methodology-audit-4 报告 C7):
|
|
1040
|
+
# 原来两种情况都是 `break` + 退出码 0 + 写出一份看着正常的 jsonl,
|
|
1041
|
+
# 而驱动脚本按"文件非空就跳过"判定已完成——**一次中断可以变成之后每次都'已经做完'**。
|
|
1042
|
+
# 现在这个值由取帧那一层报(`source.stopped_early`,见循环之后);
|
|
1043
|
+
# 这里先置 False 是为了 `bad_pts` 提前跳出时也有个确定的值。
|
|
1044
|
+
stopped_early = False
|
|
1045
|
+
# PTS 不合法(非有限、为负、不严格递增)不能只记一笔就往下跑:
|
|
1046
|
+
# 一条坏时间轴不会让任何下游报错,只会让所有时间都错一点。
|
|
1047
|
+
# 它**独立于覆盖率那道门**——坏 pts 出现在最后几帧时 `got/want` 照样过。
|
|
1048
|
+
bad_pts: str | None = None
|
|
1049
|
+
clock = ptsclock.PtsClock()
|
|
1050
|
+
t_start = time.time()
|
|
1051
|
+
|
|
1052
|
+
aux = None
|
|
1053
|
+
edge = setup_edge(args, v2, meta, grp, vp, aux_grid) if args.refine_fused else None
|
|
1054
|
+
tl.mark("boot", "edge")
|
|
1055
|
+
settle_hwaccel(args, hw_probe, hw_pool, meta)
|
|
1056
|
+
tl.mark("boot", "hw_checked")
|
|
1057
|
+
source, dec = open_source(args, dec, edge, meta, vp, aux_grid)
|
|
1058
|
+
print(f"[解码] {source.describe()}", flush=True) # 7aa8e1a 删 --front-proc 时连这两行一起删了(2026-09-24 审计找回)
|
|
1059
|
+
tl.mark("boot", "source")
|
|
1060
|
+
# 有前摇的 av1 从文件头起硬解时的平移修法(framesource.av1_preroll_ticks):生效就记刻度,追溯用
|
|
1061
|
+
meta["hw_preroll_ticks"] = getattr(getattr(source, "inner", source), "preroll_ticks", 0)
|
|
1062
|
+
meta["seek_rev"] = framesource.SEEK_REV
|
|
1063
|
+
if meta["hw_preroll_ticks"]:
|
|
1064
|
+
print(f"[硬解] 有前摇的 av1:-ignore_editlist + setpts 平移 {meta['hw_preroll_ticks']} 刻度(试解已核对像素)",
|
|
1065
|
+
flush=True)
|
|
1066
|
+
if edge is not None and args.refine_aux in ("nvdec", "cuvid"):
|
|
1067
|
+
aux = framesource.FfmpegFullRate(args.video, start_idx=start_idx, end_idx=end_idx, src_fps=src_fps,
|
|
1068
|
+
width=W, height=H, step=aux_grid.step, scale=args.refine_scale,
|
|
1069
|
+
sink=edge.ring, hwaccel="cuda", cuvid=args.refine_aux == "cuvid").start()
|
|
1070
|
+
if edge is not None:
|
|
1071
|
+
print(f"[回抠] 在线:辅助流 {edge.Ws}x{edge.Hs} 灰度、{aux_grid.describe(src_fps)},"
|
|
1072
|
+
f"帧环 {edge.ring.cap} 帧({edge.ring.cap * edge.Ws * edge.Hs / 1e6:.0f} MB)", flush=True)
|
|
1073
|
+
|
|
1074
|
+
def make_rec_crop(frame, boxes, gray):
|
|
1075
|
+
"""把"送进 rec 的裁剪"那套规则(外扩、按邻框截断、std 门)**绑到某一帧**上。
|
|
1076
|
+
|
|
1077
|
+
2026-09-18 从循环体里提成工厂(当时是投机要给前方几帧造裁剪,而裁剪规则只能有一份)。
|
|
1078
|
+
投机那条路 09-19 删了,工厂留着——组批的窗口路径同样要"这一帧的裁剪规则"随帧走。
|
|
1079
|
+
"""
|
|
1080
|
+
def rec_crop(i):
|
|
1081
|
+
b_ = boxes[i]
|
|
1082
|
+
h_ = max(1, b_[3] - b_[1])
|
|
1083
|
+
want = int(args.rec_pad_x * h_)
|
|
1084
|
+
# **只往空白处扩。** 固定倍数是错的:yuka(1080p、字幕行两边空)上
|
|
1085
|
+
# 外扩 0.8 倍行高让省略号多读出 50%,同样的设置在 bili(720p、
|
|
1086
|
+
# 字旁边还有字)上反而把邻字的笔画吃进来,省略号还少了 26%。
|
|
1087
|
+
# 所以外扩量再按"到同行最近邻框的距离的一半"截一刀——
|
|
1088
|
+
# 孤立的行能扩满,挤在一起的行几乎不扩。
|
|
1089
|
+
gl = gr = 10 ** 6
|
|
1090
|
+
for k, o_ in enumerate(boxes):
|
|
1091
|
+
if k == i:
|
|
1092
|
+
continue
|
|
1093
|
+
ov = min(b_[3], o_[3]) - max(b_[1], o_[1])
|
|
1094
|
+
if ov < 0.5 * min(h_, max(1, o_[3] - o_[1])):
|
|
1095
|
+
continue # 不在同一行,不挡路
|
|
1096
|
+
if o_[2] <= b_[0]:
|
|
1097
|
+
gl = min(gl, b_[0] - o_[2])
|
|
1098
|
+
elif o_[0] >= b_[2]:
|
|
1099
|
+
gr = min(gr, o_[0] - b_[2])
|
|
1100
|
+
px_l = min(want, gl // 2)
|
|
1101
|
+
px_r = min(want, gr // 2)
|
|
1102
|
+
# 再看一眼要扩进来的是什么。**外扩只在纯背景上安全**:
|
|
1103
|
+
# yuka 的对话框是暗底(扩入边 std 8–16),扩进去只有省略号;
|
|
1104
|
+
# bili 是番剧画面(std 37–39),扩进去的是背景图,rec 的 conf
|
|
1105
|
+
# 变低的比变高的多 2.3 倍。std 这个量在**素材层面**分得很开,
|
|
1106
|
+
# 所以拿它当自动门,不用给每部片子手工设开关。
|
|
1107
|
+
if args.rec_pad_max_std > 0 and (px_l or px_r):
|
|
1108
|
+
sd = []
|
|
1109
|
+
if px_l:
|
|
1110
|
+
sd.append(gray[b_[1]:b_[3], max(0, b_[0]-px_l):b_[0]])
|
|
1111
|
+
if px_r:
|
|
1112
|
+
sd.append(gray[b_[1]:b_[3], b_[2]:min(W, b_[2]+px_r)])
|
|
1113
|
+
sd = [x for x in sd if x.size]
|
|
1114
|
+
if sd and float(np.mean([x.std() for x in sd])) > args.rec_pad_max_std:
|
|
1115
|
+
px_l = px_r = 0
|
|
1116
|
+
py = int(args.rec_pad_y * h_)
|
|
1117
|
+
return frame[max(0, b_[1] - py):min(H, b_[3] + py),
|
|
1118
|
+
max(0, b_[0] - px_l):min(W, b_[2] + px_r)]
|
|
1119
|
+
return rec_crop
|
|
1120
|
+
|
|
1121
|
+
with out_path.open("w", encoding="utf-8") as fh:
|
|
1122
|
+
fh.write(json.dumps({"_meta": meta}, ensure_ascii=False) + "\n")
|
|
1123
|
+
idx = start_idx
|
|
1124
|
+
if start_idx:
|
|
1125
|
+
print(f"[窗口] 帧 {start_idx}–{end_idx}"
|
|
1126
|
+
f"({start_idx/src_fps/60:.1f}–{end_idx/src_fps/60:.1f} min,"
|
|
1127
|
+
f"时间戳仍按原片绝对时间)", flush=True)
|
|
1128
|
+
# 自选范围:det 之前把范围外涂灰(取帧那一层包一下,其余属性照旧从 `source` 取)
|
|
1129
|
+
src_in = regions.MaskedSource(source, grp) if grp is not None else source
|
|
1130
|
+
frames_in = (det_stream_threaded(src_in, fast_det, args.det_batch, args.det_prefetch, args.pregate, args.det_lookahead)
|
|
1131
|
+
if args.det_prefetch else det_stream(src_in, fast_det, args.det_batch, args.pregate, args.det_lookahead))
|
|
1132
|
+
n_settled = [0] # 结算过的帧数(收尾自检:必须等于 stage1 过的帧数)
|
|
1133
|
+
win: list[dict] = [] # `--rec-window`:已 stage1、等文本的帧(按帧序)
|
|
1134
|
+
win_q: list[tuple] = [] # 同步路径:排队的裁剪 (窗内第几帧, 框下标, 裁剪)
|
|
1135
|
+
win_ids: list[list[tuple]] = [] # 异步路径:逐帧的 [(请求号, 框下标)](和 win 同下标)
|
|
1136
|
+
|
|
1137
|
+
def run_rec(batch: list) -> tuple[list, int]:
|
|
1138
|
+
"""把一批裁剪送进 rec,返回 (结果, predict 调用次数)。
|
|
1139
|
+
|
|
1140
|
+
结果长度**恒等于** batch,缺的位置放 None——压缩会让后面的框配到前一个框的文本上,而且看不出来。
|
|
1141
|
+
"""
|
|
1142
|
+
if not batch:
|
|
1143
|
+
return [], 0
|
|
1144
|
+
if args.rec_bucket:
|
|
1145
|
+
results, calls = recpack.predict_bucketed(rec, batch, args.rec_bucket, args.rec_near)
|
|
1146
|
+
else:
|
|
1147
|
+
results = rec.predict(batch, batch_size=1) # `--rec-bucket 0`:一个裁剪一次
|
|
1148
|
+
calls = len(batch)
|
|
1149
|
+
if len(results) < len(batch):
|
|
1150
|
+
results = list(results) + [None] * (len(batch) - len(results))
|
|
1151
|
+
return list(results), calls
|
|
1152
|
+
|
|
1153
|
+
def settle_window() -> None:
|
|
1154
|
+
"""窗口结算(组批第一步,inference-runtime 计划):这一窗攒下的裁剪**一次分桶送 rec**,
|
|
1155
|
+
再按**帧序**逐帧 `commit_stage2`。
|
|
1156
|
+
|
|
1157
|
+
分桶就是"有多少取多少"的贪心消费者退化到同步窗口的样子:`predict_bucketed` 按目标宽排序后
|
|
1158
|
+
贪心成组、每组封顶 `--rec-bucket`,**不会为了凑满一桶而等**。
|
|
1159
|
+
|
|
1160
|
+
⚠ 和逐帧那条路有一处**故意的差别**:后端少返回时(`n_lost`,是个报警、实测恒 0),
|
|
1161
|
+
逐帧那条把那个框整个丢出产物,窗口这条已经在 stage1 建过行了,于是留下一行空文本。
|
|
1162
|
+
丢一个框本来就是"看不出来"的错,留一行看得见的空文本反而更好归因。
|
|
1163
|
+
"""
|
|
1164
|
+
nonlocal n_rec_calls, n_rec, n_lost
|
|
1165
|
+
if not win:
|
|
1166
|
+
return
|
|
1167
|
+
t_r0 = time.perf_counter()
|
|
1168
|
+
results, calls = run_rec([c for _, _, c in win_q])
|
|
1169
|
+
n_rec_calls += calls
|
|
1170
|
+
stage("rec", t_r0)
|
|
1171
|
+
texts: list[dict] = [{} for _ in win]
|
|
1172
|
+
n_none = 0
|
|
1173
|
+
for (pos, i, _c), r in zip(win_q, results):
|
|
1174
|
+
if r is None:
|
|
1175
|
+
n_none += 1
|
|
1176
|
+
continue
|
|
1177
|
+
texts[pos][i] = (r["rec_text"], float(r["rec_score"]))
|
|
1178
|
+
if n_none:
|
|
1179
|
+
print(f" ⚠ 窗口 帧 {win[0]['idx']}–{win[-1]['idx']}: 送进去 {len(win_q)} 个框,"
|
|
1180
|
+
f"丢了 {n_none} 个", flush=True)
|
|
1181
|
+
n_lost += n_none
|
|
1182
|
+
t_st = time.perf_counter()
|
|
1183
|
+
for st_, tx in zip(win, texts):
|
|
1184
|
+
extra = v2.commit_stage2(st_, tx, fh)
|
|
1185
|
+
n_rec += extra
|
|
1186
|
+
n_rec_calls += extra
|
|
1187
|
+
n_settled[0] += 1
|
|
1188
|
+
stage("commit", t_st)
|
|
1189
|
+
win.clear()
|
|
1190
|
+
win_q.clear()
|
|
1191
|
+
|
|
1192
|
+
def settle_one() -> bool:
|
|
1193
|
+
"""异步路径:结算**最老的那一帧**,等它的读齐(并标成优先——它卡着落盘水位)。"""
|
|
1194
|
+
nonlocal n_rec, n_lost
|
|
1195
|
+
if not win:
|
|
1196
|
+
return False
|
|
1197
|
+
rids = [r for r, _ in win_ids[0]]
|
|
1198
|
+
t_w = time.perf_counter()
|
|
1199
|
+
got = pool.collect(rids, block=True, prio=True)
|
|
1200
|
+
STAGE["rec"] += tl.span("rec_wait", t_w) - t_w # 等 rec 的时间记在 rec 段(时间线上单列 rec_wait)
|
|
1201
|
+
texts, n_none = {}, 0
|
|
1202
|
+
for rid, i in win_ids[0]:
|
|
1203
|
+
r = got[rid]
|
|
1204
|
+
if r is None:
|
|
1205
|
+
n_none += 1
|
|
1206
|
+
continue
|
|
1207
|
+
texts[i] = r
|
|
1208
|
+
st_ = win.pop(0)
|
|
1209
|
+
win_ids.pop(0)
|
|
1210
|
+
if n_none:
|
|
1211
|
+
print(f" ⚠ 帧 {st_['idx']}: rec 丢了 {n_none} 个框", flush=True)
|
|
1212
|
+
n_lost += n_none
|
|
1213
|
+
t_st = time.perf_counter()
|
|
1214
|
+
# 回补用掉的调用数由池子自己数(`stats().calls` 含它),这里只并裁剪数
|
|
1215
|
+
n_rec += v2.commit_stage2(st_, texts, fh)
|
|
1216
|
+
n_settled[0] += 1
|
|
1217
|
+
stage("commit", t_st)
|
|
1218
|
+
return True
|
|
1219
|
+
|
|
1220
|
+
def drain(keep: int) -> None:
|
|
1221
|
+
"""异步路径:把未结算的帧压到 `keep` 帧以内。
|
|
1222
|
+
|
|
1223
|
+
**固定滞后**(2026-09-19 复审第 1 条):第 k 帧的 stage2 **恒在**第 k+W−1 帧的 stage1 之后做,
|
|
1224
|
+
结果早到也不提前收。原来这里先"非阻塞地能收多少收多少",于是 stage2 的时刻由后台线程的快慢定,
|
|
1225
|
+
`text_pending` 清得早晚跟着变,**判决就依赖机器忙不忙**——而 `ocr_complete` 是按
|
|
1226
|
+
"同一组生效参数 + code_fp" 复用产物的,配置一样、产物却随机器而变,这条链就断了。
|
|
1227
|
+
代价:严格门的真读比"早到早收"多一点(inference-runtime 计划里量过)。
|
|
1228
|
+
"""
|
|
1229
|
+
while len(win) > keep:
|
|
1230
|
+
settle_one()
|
|
1231
|
+
|
|
1232
|
+
def progress_v2() -> None:
|
|
1233
|
+
if args.progress_every and n_frames % args.progress_every == 0:
|
|
1234
|
+
el = time.time() - t_start
|
|
1235
|
+
done = (idx - start_idx) / max(1, end_idx - start_idx)
|
|
1236
|
+
print(f"[{done:5.1%}] 视频 {t_us/1e6/60:6.1f}min(pts) 已用 {el/60:5.1f}min "
|
|
1237
|
+
f"框 {n_boxes} rec {n_rec} 回补 {v2.stats['recover_reads']}", flush=True)
|
|
1238
|
+
for idx, t_sec, frame, polys, pre in frames_in:
|
|
1239
|
+
tl.mark("frame", idx)
|
|
1240
|
+
# 真实 PTS。`cv2` 那条是 `read()` 之后的 `CAP_PROP_POS_MSEC`
|
|
1241
|
+
# (实测对齐 ffprobe 的 `pts_time`),ffmpeg 那条是 `showinfo` 报的
|
|
1242
|
+
# `pts_time`——两条都是解码器给的,不是帧号推的(docs/architecture/pipeline.md §4)。
|
|
1243
|
+
try:
|
|
1244
|
+
t_us = clock.push(t_sec, where=f"帧 {idx}")
|
|
1245
|
+
except ptsclock.BadPts as exc:
|
|
1246
|
+
bad_pts = str(exc)
|
|
1247
|
+
break
|
|
1248
|
+
boxes = [poly_to_box(p) for p in polys]
|
|
1249
|
+
own_of: dict = {}
|
|
1250
|
+
if grp is not None:
|
|
1251
|
+
# 被遮罩完全分断的 det 框拆成子框、各自裁到有效部分(ocr-regions 计划);
|
|
1252
|
+
# 子框外接矩形里有别的块时 own_of 给出本块的遮罩,rec 裁剪按它涂灰
|
|
1253
|
+
polys, boxes, own_of = regions.split_polys(polys, grp.mask_at(t_sec))
|
|
1254
|
+
if dense: # 噪声区里的框连 rec 都不用送
|
|
1255
|
+
keep_idx = [i for i, b in enumerate(boxes) if not in_dense(b)]
|
|
1256
|
+
n_dropped += len(boxes) - len(keep_idx)
|
|
1257
|
+
# 多边形 / 框 / 子框遮罩**一起**按保留的下标取(2026-09-23 Codex 审计:原来 own_of 没跟着重排,
|
|
1258
|
+
# 删掉前面的框后遮罩配到别的框上 -> IndexError / 涂错)
|
|
1259
|
+
polys, boxes, own_of = regions.take(polys, boxes, own_of, keep_idx)
|
|
1260
|
+
|
|
1261
|
+
decided: list[tuple[int, str, float, int]] = [] # 沿用上一帧的
|
|
1262
|
+
todo: list[int] = [] # 需要识别的
|
|
1263
|
+
corr_of: dict[int, float] = {} # 复用判据算过的框内相关系数(--record-corr 才写出)
|
|
1264
|
+
v2_assign: dict = {}
|
|
1265
|
+
if grp is not None and rg_cuts:
|
|
1266
|
+
# 自选范围的时间切点落在上一个采样点和这一帧之间:切到的位置不许续链(owner:"时间关闭段不可续链")。
|
|
1267
|
+
# 两次采样之间关了又开的也算——两个采样帧都看得见,但中间那段没被观察过
|
|
1268
|
+
hit = [a for tc, a in rg_cuts if prev_t_us < tc <= int(round(t_sec * 1e6)) and a is not None]
|
|
1269
|
+
if hit:
|
|
1270
|
+
area = np.logical_or.reduce(hit)
|
|
1271
|
+
if v2 is not None:
|
|
1272
|
+
v2.sever(area)
|
|
1273
|
+
else:
|
|
1274
|
+
prev = [p for p in prev if not regions.box_hits(p[0], area)]
|
|
1275
|
+
prev_t_us = int(round(t_sec * 1e6))
|
|
1276
|
+
shift = ((0, 0) if args.no_reuse or not prev or v2 is not None
|
|
1277
|
+
else estimate_shift([p[0] for p in prev], boxes))
|
|
1278
|
+
if v2 is not None:
|
|
1279
|
+
t_st = time.perf_counter()
|
|
1280
|
+
# 灰度整帧给复用判据(`reuse_v2` 要留 --reuse-mem + 2 帧):**存 uint8、取裁剪时再转 float32**,
|
|
1281
|
+
# 和原来存 float32 整帧逐位相同,内存差四倍(1080p 8.3 -> 2.1 MB/帧,4K 上更要紧)
|
|
1282
|
+
# `--pregate` 时灰度和静态相关系数在 det 那一层已经算好了(值按位相同,见 pregate_of)
|
|
1283
|
+
gray_full = pre[0] if pre is not None else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
1284
|
+
decided, todo, v2_assign = v2.decide(n_frames, gray_full, boxes,
|
|
1285
|
+
pre=(pre[1] if pre is not None else None))
|
|
1286
|
+
stage("decide", t_st)
|
|
1287
|
+
for i, b in enumerate(boxes):
|
|
1288
|
+
if v2 is not None:
|
|
1289
|
+
break
|
|
1290
|
+
if args.no_reuse or not prev:
|
|
1291
|
+
todo.append(i)
|
|
1292
|
+
continue
|
|
1293
|
+
# 静止假设与滚动假设各试一次,取匹配更好的那个
|
|
1294
|
+
b_back = [b[0] - shift[0], b[1] - shift[1], b[2] - shift[0], b[3] - shift[1]]
|
|
1295
|
+
best = max(prev, key=lambda p: max(iou(b, p[0]), iou(b_back, p[0])))
|
|
1296
|
+
if max(iou(b, best[0]), iou(b_back, best[0])) < args.reuse_iou:
|
|
1297
|
+
todo.append(i)
|
|
1298
|
+
continue
|
|
1299
|
+
moved = iou(b_back, best[0]) > iou(b, best[0])
|
|
1300
|
+
if args.refresh_every and best[3] + 1 >= args.refresh_every:
|
|
1301
|
+
todo.append(i) # 该刷新了,重新识别一次
|
|
1302
|
+
continue
|
|
1303
|
+
# 位置对上了,再看这块像素是不是真没变。
|
|
1304
|
+
# 滚动的框要拿上一帧**它当时所在的位置**去比,不是原地比。
|
|
1305
|
+
ref_box = best[0] if moved else b
|
|
1306
|
+
c = corr(gray_crop(frame, b), gray_crop(prev_frame, ref_box))
|
|
1307
|
+
corr_of[i] = c # --record-corr 用:只记本来就算了的,不多算、不改判决
|
|
1308
|
+
if c >= args.reuse_corr:
|
|
1309
|
+
decided.append((i, best[1], best[2], best[3] + 1))
|
|
1310
|
+
else:
|
|
1311
|
+
todo.append(i)
|
|
1312
|
+
|
|
1313
|
+
gray = (cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
1314
|
+
if args.rec_pad_x and args.rec_pad_max_std > 0 else None)
|
|
1315
|
+
n_reused_here = len(decided) # 此刻 decided 里只有沿用上一帧的
|
|
1316
|
+
# 只把**送进 rec 的裁剪**外扩,obs 里记的仍是原始 det 框——
|
|
1317
|
+
# 复用判据、聚类、几何统计全都不动,这样外扩是个单变量改动。
|
|
1318
|
+
rec_crop = make_rec_crop(frame, boxes, gray)
|
|
1319
|
+
if own_of:
|
|
1320
|
+
# 绑到这一帧(同 make_rec_crop 的规矩:异步 / 窗口路径会晚一点才调它)
|
|
1321
|
+
rec_crop = (lambda i, _base=rec_crop, _f=frame, _b=boxes, _o=own_of:
|
|
1322
|
+
regions.masked_crop(_f, _b[i], _o[i]) if i in _o else _base(i))
|
|
1323
|
+
t_rec0 = time.perf_counter()
|
|
1324
|
+
todo_r = list(todo)
|
|
1325
|
+
crops = [rec_crop(i) for i in todo_r]
|
|
1326
|
+
keep = [i for i, c in zip(todo_r, crops) if c.size and c.shape[0] > 3 and c.shape[1] > 3]
|
|
1327
|
+
crops = [c for c in crops if c.size and c.shape[0] > 3 and c.shape[1] > 3]
|
|
1328
|
+
n_tiny += len(todo_r) - len(keep) # 太小送不进 rec 的:既不是复用也不是识别
|
|
1329
|
+
if args.rec_window:
|
|
1330
|
+
# 组批(inference-runtime 计划):这一帧**不发 rec**,裁剪排进队列;
|
|
1331
|
+
# 判决里和文本无关的那一半(stage1)照常做完,等文本的那一半(stage2)留到窗口结算。
|
|
1332
|
+
# 裁剪是整帧的切片,而帧马上就要放掉——**必须拷**(异步那条还要给后台线程用)。
|
|
1333
|
+
if pool is not None:
|
|
1334
|
+
ids = pool.new_ids(len(keep))
|
|
1335
|
+
pool.submit([(r, c.copy()) for r, c in zip(ids, crops)])
|
|
1336
|
+
win_ids.append(list(zip(ids, keep)))
|
|
1337
|
+
else:
|
|
1338
|
+
for i, c in zip(keep, crops):
|
|
1339
|
+
win_q.append((len(win), i, c.copy()))
|
|
1340
|
+
n_rec += len(crops)
|
|
1341
|
+
stage("rec", t_rec0)
|
|
1342
|
+
n_reused += n_reused_here
|
|
1343
|
+
t_st = time.perf_counter()
|
|
1344
|
+
if args.record_corr:
|
|
1345
|
+
corr_of.update(v2.last_corr)
|
|
1346
|
+
st = v2.commit_stage1(n_frames, idx, t_us, boxes, polys,
|
|
1347
|
+
decided + [(i, "", 0.0, 0) for i in keep], set(keep),
|
|
1348
|
+
v2_assign, rec_crop, corr_of, args.record_corr)
|
|
1349
|
+
stage("commit", t_st)
|
|
1350
|
+
win.append(st)
|
|
1351
|
+
n_boxes += len(decided) + len(keep)
|
|
1352
|
+
n_frames += 1
|
|
1353
|
+
if pool is not None:
|
|
1354
|
+
drain(args.rec_window - 1) # 未结算的帧不超过 W(下一帧进来之前留一个位置)
|
|
1355
|
+
elif len(win) >= args.rec_window:
|
|
1356
|
+
settle_window()
|
|
1357
|
+
progress_v2()
|
|
1358
|
+
continue
|
|
1359
|
+
results, calls = run_rec(crops) # 后端少返回时**不许错位**:缺的位置是 None
|
|
1360
|
+
n_rec_calls += calls
|
|
1361
|
+
stage("rec", t_rec0)
|
|
1362
|
+
# 长度对不上就别静默 zip——短的那一头会把框直接吞掉,输出里不留痕迹。
|
|
1363
|
+
# 分桶路径不会变短(它按下标回填),缺的位置是 None,两种都要数进 n_lost。
|
|
1364
|
+
n_none = sum(r is None for r in results)
|
|
1365
|
+
if len(results) != len(crops) or n_none:
|
|
1366
|
+
lost = len(crops) - len(results) + n_none
|
|
1367
|
+
print(f" ⚠ 帧 {idx}: rec 返回 {len(results) - n_none} 条,"
|
|
1368
|
+
f"送进去 {len(crops)} 个框,丢了 {lost} 个", flush=True)
|
|
1369
|
+
n_lost += lost
|
|
1370
|
+
for i, r in zip(keep, results):
|
|
1371
|
+
if r is None:
|
|
1372
|
+
continue
|
|
1373
|
+
decided.append((i, r["rec_text"], float(r["rec_score"]), 0))
|
|
1374
|
+
read_set = set(keep)
|
|
1375
|
+
n_rec += len(crops)
|
|
1376
|
+
# 复用数按**真走了复用分支的框**算。原来是 len(boxes) - len(crops),
|
|
1377
|
+
# 把"太小被过滤掉的框"也算成复用,reuse_rate 虚高。
|
|
1378
|
+
n_reused += n_reused_here
|
|
1379
|
+
|
|
1380
|
+
if v2 is not None:
|
|
1381
|
+
t_st = time.perf_counter()
|
|
1382
|
+
if args.record_corr:
|
|
1383
|
+
corr_of.update(v2.last_corr) # v2 的相关系数在它自己那儿算(上面 corr_of 只有老路径会填)
|
|
1384
|
+
extra = v2.commit(n_frames, idx, t_us, boxes, polys, decided, read_set, v2_assign,
|
|
1385
|
+
rec_crop, corr_of, args.record_corr, fh)
|
|
1386
|
+
stage("commit", t_st)
|
|
1387
|
+
n_rec += extra
|
|
1388
|
+
n_rec_calls += extra
|
|
1389
|
+
n_boxes += len(decided)
|
|
1390
|
+
n_frames += 1
|
|
1391
|
+
progress_v2()
|
|
1392
|
+
continue
|
|
1393
|
+
cur: list[tuple[list[int], str, float, int]] = []
|
|
1394
|
+
for i, text, score, age in sorted(decided):
|
|
1395
|
+
b = boxes[i]
|
|
1396
|
+
reused = i not in read_set
|
|
1397
|
+
fh.write(json.dumps({
|
|
1398
|
+
# **`frame` 认 pts,不认"这是第几个采样帧"**(2026-09-20,owner:"是 bug 就直接修掉")。
|
|
1399
|
+
# 09-09 把 `t_us` 改成了真实 pts,`frame` 当时没跟着改,于是有丢帧缺口的素材上
|
|
1400
|
+
# 它是"第几个采样帧 × 采样间隔 + 起点"而不是源帧号(f3 跨缺口窗实测差 309~330 帧,
|
|
1401
|
+
# 606 行里 335 行的 `frame/src_fps` 对不上 `t_us`)。下游只拿它当身份用、
|
|
1402
|
+
# 时间一律走 `t_us`,所以轨没被带偏;但"拿 frame 回视频找那一帧"会找错。
|
|
1403
|
+
# ⚠ **只在写出去的这一处换**:`idx` 本身必须留在交付序号那个空间里,
|
|
1404
|
+
# 因为辅助流的帧环是按交付序号编号的(framesource 的 yield 注释)。
|
|
1405
|
+
"frame": framesource.pts_frame(t_us, idx, src_fps),
|
|
1406
|
+
"t_us": t_us, "box": b,
|
|
1407
|
+
"poly": [[round(float(p[0])), round(float(p[1]))] for p in polys[i]],
|
|
1408
|
+
"text": text, "conf": round(score, 4),
|
|
1409
|
+
**({"reused": True} if reused else {}),
|
|
1410
|
+
**({"corr": round(corr_of[i], 4)} if args.record_corr and i in corr_of else {}),
|
|
1411
|
+
}, ensure_ascii=False) + "\n")
|
|
1412
|
+
n_boxes += 1
|
|
1413
|
+
cur.append((b, text, score, age))
|
|
1414
|
+
prev, prev_frame = cur, frame
|
|
1415
|
+
n_frames += 1
|
|
1416
|
+
# 长片必须能看到真进度。**别用输出文件的内容推进度**——
|
|
1417
|
+
# 开了噪声区排除之后,没检测到文字的帧根本不写行,
|
|
1418
|
+
# 文件里最后一条的时间戳会远远落后于实际处理到的位置。
|
|
1419
|
+
if args.progress_every and n_frames % args.progress_every == 0:
|
|
1420
|
+
el = time.time() - t_start
|
|
1421
|
+
done = (idx - start_idx) / max(1, end_idx - start_idx)
|
|
1422
|
+
# 分子是真 pts、分母是帧号推算的——有丢帧缺口的素材上这两个口径
|
|
1423
|
+
# 差几秒(f3 尾端 9.5 s),所以两边都标出来,别看着像同一把尺
|
|
1424
|
+
print(f"[{done:5.1%}] 视频 {t_us/1e6/60:6.1f}min(pts) / "
|
|
1425
|
+
f"{end_idx/src_fps/60:.0f}min(帧号推算) "
|
|
1426
|
+
f"已用 {el/60:5.1f}min 预计还要 {el*(1-done)/max(done,1e-6)/60:5.1f}min "
|
|
1427
|
+
f"框 {n_boxes} rec {n_rec} 丢 {n_dropped}", flush=True)
|
|
1428
|
+
# 最后不满一窗的那几帧(edge.finish 会排空队列,必须在它之前)
|
|
1429
|
+
if pool is not None:
|
|
1430
|
+
drain(0)
|
|
1431
|
+
pool.close()
|
|
1432
|
+
meta["rec_pool"] = pool.stats()
|
|
1433
|
+
n_rec_calls += pool.stats()["calls"]
|
|
1434
|
+
if side_clis: # 旁路连接的分因数据(主连接的在下面 `ort.rec`)
|
|
1435
|
+
meta.setdefault("ort", {})["rec_side"] = [c.stats() for c in side_clis]
|
|
1436
|
+
close_clients(*side_clis)
|
|
1437
|
+
else:
|
|
1438
|
+
settle_window()
|
|
1439
|
+
if edge is not None:
|
|
1440
|
+
if bad_pts is None:
|
|
1441
|
+
meta["edge"] = edge.finish(idx)
|
|
1442
|
+
else:
|
|
1443
|
+
edge.abort()
|
|
1444
|
+
if aux is None: # shared:第二路读线程挂在采样流上
|
|
1445
|
+
aux = getattr(source, "inner", source).aux
|
|
1446
|
+
if aux is not None:
|
|
1447
|
+
aux.join()
|
|
1448
|
+
meta.setdefault("edge", {})["aux_sent"] = aux.sent
|
|
1449
|
+
meta["edge"]["aux_stopped_early"] = aux.stopped_early
|
|
1450
|
+
if hasattr(aux, "last_key"):
|
|
1451
|
+
meta["edge"]["aux_last_key"] = aux.last_key
|
|
1452
|
+
if getattr(aux, "misaligned", ""):
|
|
1453
|
+
meta["edge"]["aux_misaligned"] = aux.misaligned
|
|
1454
|
+
print(f"[回抠] ⚠ 辅助流的 pts 和帧对不上、提前停了:{aux.misaligned}", flush=True)
|
|
1455
|
+
meta["edge"]["aux_cpu_sec"] = round(getattr(aux, "cpu_sec", 0.0), 2)
|
|
1456
|
+
meta["edge"]["aux_shift"] = getattr(aux, "shift", 0)
|
|
1457
|
+
meta["edge"]["aux_mode"] = args.refine_aux
|
|
1458
|
+
meta["edge"]["noop"] = edge.noop
|
|
1459
|
+
elif args.edge_proc and "edge" in meta: # 辅助流收在回抠进程里,它的统计随 finish 一起回来
|
|
1460
|
+
meta["edge"]["aux_mode"] = args.refine_aux
|
|
1461
|
+
meta["edge"]["noop"] = edge.noop
|
|
1462
|
+
if args.rec_window and n_settled[0] != n_frames:
|
|
1463
|
+
# **运行时自检**(2026-09-19 复审第 5 条):接线守卫只看得见"函数还在不在",
|
|
1464
|
+
# 看不见"接错了"。这一条走的是主循环本身:stage1 过几帧,stage2 就得结算几帧。
|
|
1465
|
+
raise SystemExit(f"组批自检不过:stage1 了 {n_frames} 帧,stage2 只结算了 {n_settled[0]} 帧"
|
|
1466
|
+
f"——窗口没排空,或者结算那一路被绕过了")
|
|
1467
|
+
if v2 is not None:
|
|
1468
|
+
v2.flush_all(fh)
|
|
1469
|
+
tl.mark("boot", "loop_done")
|
|
1470
|
+
source.close()
|
|
1471
|
+
if dec is not None:
|
|
1472
|
+
meta["decode_shards"].update(dec.shards or {}) # 各路领了几块、各花多久、预取峰值
|
|
1473
|
+
meta["decode_shards"]["wait_sec"] = round(dec.wait_sec, 2) # 这边等解码进程交帧的时间
|
|
1474
|
+
meta["hw_preroll_ticks"] = dec.preroll_ticks # 第一块才知道(开流时读到的是 0)
|
|
1475
|
+
stopped_early = source.stopped_early
|
|
1476
|
+
# 采样格核对(framesource.grid_step):**偏离格子**的帧 vs **没交付的格**分开记(复审 P1 第 2 条)。
|
|
1477
|
+
# 软解两样都只记——偏离格子只在 index 选帧遇到缺口时出现,缺格是素材自己的缺口;
|
|
1478
|
+
# 硬解那条路在来源里就判过了(偏离、或者源里有却没交付,都 raise HwaccelMismatch -> 整段改软解)
|
|
1479
|
+
off = getattr(source, "grid_off", {}) or {}
|
|
1480
|
+
skipped = getattr(source, "grid_skipped", 0) or 0
|
|
1481
|
+
if off:
|
|
1482
|
+
meta["grid_off"] = {str(k): v for k, v in sorted(off.items())}
|
|
1483
|
+
if skipped:
|
|
1484
|
+
meta["grid_skipped"] = skipped
|
|
1485
|
+
if off or skipped:
|
|
1486
|
+
print(f"[采样格] ⚠ 偏离格子 {sum(off.values())} 帧(偏移取值 {sorted(off)})、没交付的格 {skipped} 个。"
|
|
1487
|
+
f"有丢帧缺口的素材上这是正常的;否则见 hw-decode-results 报告", flush=True)
|
|
1488
|
+
if getattr(source, "stderr_tail", None):
|
|
1489
|
+
print("[解码] ffmpeg 最后几行:\n " + "\n ".join(source.stderr_tail[-4:]),
|
|
1490
|
+
flush=True)
|
|
1491
|
+
|
|
1492
|
+
for _tag, _cli in (("rec", ort_cli), ("det", det_cli)):
|
|
1493
|
+
if _cli is not None: # ORT 那条路的分因数据(ortclient.stats 的说明)
|
|
1494
|
+
meta.setdefault("ort", {})[_tag] = _cli.stats()
|
|
1495
|
+
wall = time.time() - t_start
|
|
1496
|
+
# 完成判据看**实际推进到哪一帧**,不看"文件里有没有东西"。
|
|
1497
|
+
# `total_frames` 在某些容器上不准,所以留 2% 余量,只有明显短缺才判未完成。
|
|
1498
|
+
want = max(1, end_idx - start_idx)
|
|
1499
|
+
# **推进了多少源帧由来源自己报**:`cv2` 数的是 grab/read 的次数,
|
|
1500
|
+
# ffmpeg 那条数的是"最后一个采样帧的下一格 − 起点"——两者口径一致,
|
|
1501
|
+
# 都能喂进同一个 `is_complete`。别再用外面那个 `idx`:
|
|
1502
|
+
# 它是**最后一个采样帧的帧号**,天生比推进量少最多一个采样间隔。
|
|
1503
|
+
got = source.advanced
|
|
1504
|
+
# **`stopped_early` 不能直接当"没跑完"**(2026-09-08 修):解码器读到文件真正的
|
|
1505
|
+
# 结尾也会置上它,而 `CAP_PROP_FRAME_COUNT` 在这批切片上比实际多报了 1%
|
|
1506
|
+
# (36,361 声称 vs 36,001 实际)。于是**九段切片每次跑完都自称未完成**、
|
|
1507
|
+
# 退出码 3,驱动脚本会永远重跑它们。当时我是把产物的 `complete` 手工回填了,
|
|
1508
|
+
# **改数据没改代码**,这次连代码一起改。
|
|
1509
|
+
# 覆盖率这道门本来就是为容器元数据不准留的余量;中途真断了(比如 40% 处
|
|
1510
|
+
# 解码失败)`got/want` 自然过不了这道门,`stopped_early` 那一项是多余且有害的。
|
|
1511
|
+
# 它仍然记进 `_meta`,因为"是读到头了还是被我们停了"是排查时要看的信息。
|
|
1512
|
+
# **坏 PTS 一票否决。** 覆盖率那道门管的是"推进到哪一帧",管不着时间轴对不对。
|
|
1513
|
+
# **辅助流没交够也一票否决**(2026-09-24 审计):回抠证据半路断掉的 obs 不能当成做完了、被一直复用
|
|
1514
|
+
aux_bad = (ocr_complete.aux_shortfall(meta.get("edge"), idx if n_frames else None, aux_grid)
|
|
1515
|
+
if edge is not None and bad_pts is None else None)
|
|
1516
|
+
if aux_bad:
|
|
1517
|
+
meta["aux_incomplete"] = aux_bad
|
|
1518
|
+
print(f"**[回抠] ⚠ {aux_bad}——产物记 complete=false**", flush=True)
|
|
1519
|
+
complete = ocr_complete.is_complete(got, want) and bad_pts is None and aux_bad is None
|
|
1520
|
+
# `end_sec` 原来写的是 `total_frames / src_fps`——又一个平均帧率推算,
|
|
1521
|
+
# 在有缺口的素材上比真实时长短(f3 少 12.1 s)。改成**最后一个采样帧的真实 PTS**。
|
|
1522
|
+
# `start_sec` 仍是**请求的**窗口起点:`tools/ocr_complete.obs_reusable` 拿它和
|
|
1523
|
+
# `data/samples.json` 的 `start_s` 对账,那是"跑的是不是同一段"的判据,不是时间轴。
|
|
1524
|
+
# 实际覆盖到的 PTS 区间另记两个键,谁要判覆盖用它们。
|
|
1525
|
+
meta.update({"complete": complete, "frames_advanced": got, "frames_requested": want,
|
|
1526
|
+
"stopped_early": stopped_early, "bad_pts": bad_pts,
|
|
1527
|
+
"sampled_frames": n_frames, "boxes": n_boxes,
|
|
1528
|
+
"rec_calls": n_rec, "rec_predict_calls": n_rec_calls, "reused_boxes": n_reused,
|
|
1529
|
+
"reuse_rate": round(n_reused / max(1, n_boxes), 4),
|
|
1530
|
+
"dropped_dense": n_dropped, "dropped_tiny": n_tiny, "lost_rec": n_lost,
|
|
1531
|
+
"wall_sec": round(wall, 2), "sec_per_frame": round(wall / max(n_frames, 1), 3),
|
|
1532
|
+
"start_sec": args.start,
|
|
1533
|
+
"end_sec": (args.end if args.end > 0 else
|
|
1534
|
+
(clock.last_us / 1e6 if clock.last_us is not None else 0.0)),
|
|
1535
|
+
**clock.meta(),
|
|
1536
|
+
"realtime_x": round(clock.span_sec / wall, 2) if wall else None,
|
|
1537
|
+
"stage_sec": {k: round(v, 2) for k, v in STAGE.items()},
|
|
1538
|
+
**({"reuse_v2": v2.stats, "reuse_v2_sec": {k: round(x, 2) for k, x in v2.sec.items()}}
|
|
1539
|
+
if v2 is not None else {}),
|
|
1540
|
+
# 平均批直接记下来:**这次就是它把"窗口分支被误删"抓出来的**(5.01 -> 1.67),
|
|
1541
|
+
# 而 rec_calls / rec_predict_calls 两个数要自己相除才看得出来
|
|
1542
|
+
"rec_avg_batch": round(n_rec / max(1, n_rec_calls), 2),
|
|
1543
|
+
})
|
|
1544
|
+
if args.trace and v2 is not None and v2.trace is not None:
|
|
1545
|
+
# 轨迹里带上**真实的张量宽**(重放要按它分桶):`recpack.rec_target_w` 和管线用同一份
|
|
1546
|
+
tp = Path(args.trace)
|
|
1547
|
+
tp.parent.mkdir(parents=True, exist_ok=True)
|
|
1548
|
+
with tp.open("w", encoding="utf-8") as th:
|
|
1549
|
+
head = {"tag": out_path.stem, "frame_us": int(round(grid.interval_us(src_fps))),
|
|
1550
|
+
"reuse_corr": args.reuse_corr, "reuse_corr_digit": args.reuse_corr_digit,
|
|
1551
|
+
"rec_bucket": args.rec_bucket}
|
|
1552
|
+
print(json.dumps({"_meta": head}, ensure_ascii=False), file=th)
|
|
1553
|
+
for r in v2.trace:
|
|
1554
|
+
b = r["box"]
|
|
1555
|
+
r["tw"] = recpack.rec_target_w(max(1, b[3] - b[1]), max(1, b[2] - b[0]))
|
|
1556
|
+
print(json.dumps(r, ensure_ascii=False), file=th)
|
|
1557
|
+
print(f"[轨迹] {len(v2.trace)} 条 -> {tp}", flush=True)
|
|
1558
|
+
if args.timeline:
|
|
1559
|
+
tl.mark("boot", "end")
|
|
1560
|
+
print(f"[时间线] -> {tl.dump()}", flush=True)
|
|
1561
|
+
lines = out_path.read_text(encoding="utf-8").splitlines()
|
|
1562
|
+
lines[0] = json.dumps({"_meta": meta}, ensure_ascii=False)
|
|
1563
|
+
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1564
|
+
print(json.dumps({k: meta[k] for k in
|
|
1565
|
+
("complete", "sampled_frames", "boxes", "rec_calls", "reuse_rate",
|
|
1566
|
+
"dropped_dense", "dropped_tiny", "lost_rec", "wall_sec",
|
|
1567
|
+
"sec_per_frame", "realtime_x")},
|
|
1568
|
+
ensure_ascii=False))
|
|
1569
|
+
if bad_pts:
|
|
1570
|
+
print(f"**时间轴不可信**:{bad_pts}"
|
|
1571
|
+
f"\n 产物留着排查用,但 `_meta.complete=false`——不要拿它进正式成绩。",
|
|
1572
|
+
flush=True)
|
|
1573
|
+
return 4
|
|
1574
|
+
if not complete:
|
|
1575
|
+
# 部分产物留着(可以拿来续跑/排查),但**退出码必须非零**,
|
|
1576
|
+
# 而且 `_meta.complete=false` 会让驱动脚本不把它当成已完成。
|
|
1577
|
+
print(f"**没跑完**:请求 {want} 帧,只推进到 {got} 帧"
|
|
1578
|
+
f"({got/want:.1%});stopped_early={stopped_early}。"
|
|
1579
|
+
f"\n 产物留着但**不算完成**——不要拿它进正式成绩。", flush=True)
|
|
1580
|
+
return 3
|
|
1581
|
+
return 0
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def worker_main() -> int:
|
|
1585
|
+
"""worker:真正干活的那一趟。硬解跑到一半交付不对 -> 写下原因、退出码 `EXIT_HW_MISMATCH`,
|
|
1586
|
+
由外层监督者在**本进程完全退出之后**起软解那一趟(`supervisor.supervise`)。
|
|
1587
|
+
⚠ 软解那一趟自己再炸就照常抛——不许无限重试。"""
|
|
1588
|
+
try:
|
|
1589
|
+
return main()
|
|
1590
|
+
except framesource.HwaccelMismatch as e:
|
|
1591
|
+
if os.environ.get(HW_RETRY_ENV):
|
|
1592
|
+
raise
|
|
1593
|
+
print(f"[硬解] 交付不对:{e}——退出,交给外层改软解重跑", flush=True)
|
|
1594
|
+
orphan = os.environ.get(FAULT_ORPHAN_ENV)
|
|
1595
|
+
if orphan:
|
|
1596
|
+
q = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(600)"])
|
|
1597
|
+
Path(orphan).write_text(str(q.pid), encoding="utf-8")
|
|
1598
|
+
rf = os.environ.get(HW_REASON_ENV)
|
|
1599
|
+
if rf:
|
|
1600
|
+
Path(rf).write_text(str(e), encoding="utf-8")
|
|
1601
|
+
return EXIT_HW_MISMATCH
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
if __name__ == "__main__":
|
|
1605
|
+
raise SystemExit(worker_main())
|