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,215 @@
|
|
|
1
|
+
"""det 的**模型配置**与 **DB 后处理**,不依赖 Paddle(2026-09-25)。
|
|
2
|
+
|
|
3
|
+
为什么(decode-buffer 计划的审计余项"检测的模型配置、前后处理、执行后端解耦"):det 推理换到 ORT 之后,管线进程原来仍要
|
|
4
|
+
建一份完整的 Paddle det,只为借它两样东西——前处理参数(缩放上限、归一化系数)和后处理对象(`DBPostProcess`)。
|
|
5
|
+
这里把那两样自己拿一份(2026-10 起运行时干脆没有 Paddle 了):
|
|
6
|
+
|
|
7
|
+
* `DetSpec`:一组前后处理参数。`from_yml` 从 ONNX 仓库自带的 `inference.yml` 读(PaddleX 就是从同一份 yml 建的,
|
|
8
|
+
缩放默认按模型名同 PaddleX 的 `_get_text_det_resize_defaults`)。读法照 PaddleX 的 `_build`:守卫拿冻结的 PaddleX 结果
|
|
9
|
+
(几种 yml 写法、全字段)做回归。
|
|
10
|
+
* `DBPostProcess`:照抄 PaddleX `paddlex/inference/models/text_detection/processors.py` 的同名类(Apache-2.0,
|
|
11
|
+
`LICENSES/Apache-2.0.txt`),只保留管线用到的 `box_type=quad` + `score_mode=fast` + 不膨胀,算术逐行相同——
|
|
12
|
+
照抄时和 PaddleX 的原实现**逐字节相同**(随机概率图对拍;真帧上另有探针验过 det 的多边形逐个相同);守卫现在对着
|
|
13
|
+
冻结的 PaddleX 输出(输入一起存着)做回归。
|
|
14
|
+
|
|
15
|
+
这个模块只依赖 numpy / cv2 / pyclipper / pyyaml(都在 `pyproject.toml` 的主依赖里)。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import cv2
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pyclipper
|
|
26
|
+
|
|
27
|
+
MAX_LIMIT_MODELS = frozenset({
|
|
28
|
+
"PP-OCRv5_server_det", "PP-OCRv5_mobile_det", "PP-OCRv4_server_det", "PP-OCRv4_mobile_det",
|
|
29
|
+
"PP-OCRv3_server_det", "PP-OCRv3_mobile_det", "PP-OCRv6_medium_det", "PP-OCRv6_small_det", "PP-OCRv6_tiny_det",
|
|
30
|
+
})
|
|
31
|
+
"""PaddleX 里缩放默认是"长边 ≤ 960"的那些模型(`predictor._TEXT_DET_MAX_LIMIT_MODELS`);其余默认"短边 ≥ 736"。"""
|
|
32
|
+
|
|
33
|
+
KNOWN_OPS = frozenset({"DecodeImage", "DetLabelEncode", "DetResizeForTest", "NormalizeImage", "ToCHWImage", "KeepKeys"})
|
|
34
|
+
"""PaddleX det 的 `_FUNC_MAP` 认的前处理 op(`DetLabelEncode` / `KeepKeys` 在它那里是空操作)。"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_scale(v) -> float:
|
|
38
|
+
"""`NormalizeImage.scale`:yml 里常写成字符串 `1./255.`。PaddleX 用 `eval`;这里只认数或 `a/b`,
|
|
39
|
+
按 `float(a) / float(b)` 算——和 `eval` 同一次浮点除法,结果逐位相同,不执行模型目录里的任意字符串。"""
|
|
40
|
+
if not isinstance(v, str):
|
|
41
|
+
return float(v)
|
|
42
|
+
a, sep, b = v.partition("/")
|
|
43
|
+
try:
|
|
44
|
+
return float(a) / float(b) if sep else float(a)
|
|
45
|
+
except ValueError:
|
|
46
|
+
raise ValueError(f"NormalizeImage.scale 只认数或 a/b:{v!r}") from None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DetSpec:
|
|
51
|
+
"""det 的前后处理参数——`FastDet` 只认这一份,不认 predictor。"""
|
|
52
|
+
|
|
53
|
+
model_name: str
|
|
54
|
+
limit_side_len: int
|
|
55
|
+
limit_type: str
|
|
56
|
+
max_side_limit: int
|
|
57
|
+
img_mode: str
|
|
58
|
+
"""`DecodeImage.img_mode`:BGR 就不换通道;RGB 才要 BGR->RGB。"""
|
|
59
|
+
alpha: tuple[float, float, float]
|
|
60
|
+
beta: tuple[float, float, float]
|
|
61
|
+
"""归一化 `v * alpha[c] + beta[c]`(PaddleX `NormalizeImage`:alpha = scale / std、beta = −mean / std,Python 双精度)。"""
|
|
62
|
+
thresh: float
|
|
63
|
+
box_thresh: float
|
|
64
|
+
unclip_ratio: float
|
|
65
|
+
max_candidates: int
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_yml(cls, path: str | Path) -> "DetSpec":
|
|
69
|
+
"""从模型目录的 `inference.yml` 建(官方 ONNX 仓库自带;和 PaddleX 模型目录里的是同一份)。
|
|
70
|
+
**照 PaddleX `TextDetRunnerPredictor._build` 的读法**,包括它不看的键(守卫拿冻结的 PaddleX `_build` 结果对几种 yml 写法做回归):
|
|
71
|
+
* 读图默认 RGB,`DecodeImage` 在才按它的 `img_mode`;
|
|
72
|
+
* `DetResizeForTest` 里的 `limit_side_len` / `limit_type` / `max_side_limit` PaddleX 都不看(前两个被 `build_resize` 的具名参数吃掉、
|
|
73
|
+
换成按模型名的默认;最后一个调用时被 predictor 的 4000 盖掉),只有 `resize_long` 改默认边长;`image_shape` 是另一种缩放,快路径没写;
|
|
74
|
+
* `NormalizeImage` 的 `order` 缺省是 `""`(按 hwc 走),只有 `chw` 快路径没写;
|
|
75
|
+
* 认不得的前处理 op PaddleX 会 KeyError,这里同样拒绝。"""
|
|
76
|
+
import yaml
|
|
77
|
+
cfg = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
78
|
+
name = cfg["Global"]["model_name"]
|
|
79
|
+
ops: dict[str, dict] = {}
|
|
80
|
+
for op in cfg["PreProcess"]["transform_ops"]:
|
|
81
|
+
key = list(op)[0]
|
|
82
|
+
if key not in KNOWN_OPS:
|
|
83
|
+
raise ValueError(f"{path}:前处理 op {key} PaddleX 的 det 不认")
|
|
84
|
+
ops[key] = op[key] or {}
|
|
85
|
+
if "NormalizeImage" not in ops or "DetResizeForTest" not in ops:
|
|
86
|
+
raise ValueError(f"{path}:不是 DB det 的前处理(要 DetResizeForTest + NormalizeImage)")
|
|
87
|
+
norm = ops["NormalizeImage"]
|
|
88
|
+
if norm.get("order", "") == "chw":
|
|
89
|
+
raise ValueError("这个模型的 Normalize 是 chw 序,快路径只写了 hwc 序")
|
|
90
|
+
scale = _parse_scale(norm.get("scale", 1 / 255))
|
|
91
|
+
mean = norm.get("mean") or [0.485, 0.456, 0.406]
|
|
92
|
+
std = norm.get("std") or [0.229, 0.224, 0.225]
|
|
93
|
+
rz = ops["DetResizeForTest"]
|
|
94
|
+
if "image_shape" in rz:
|
|
95
|
+
raise ValueError(f"{path}:DetResizeForTest 给了 image_shape(定形缩放),快路径只写了按边长缩放")
|
|
96
|
+
side = rz.get("resize_long", 960 if name in MAX_LIMIT_MODELS else 736)
|
|
97
|
+
kind = "max" if name in MAX_LIMIT_MODELS else "min"
|
|
98
|
+
if "DecodeImage" in ops:
|
|
99
|
+
if "img_mode" not in ops["DecodeImage"]:
|
|
100
|
+
raise ValueError(f"{path}:DecodeImage 没写 img_mode(PaddleX 在这里直接报错)")
|
|
101
|
+
img_mode = str(ops["DecodeImage"]["img_mode"])
|
|
102
|
+
else:
|
|
103
|
+
img_mode = "RGB"
|
|
104
|
+
post = cfg["PostProcess"]
|
|
105
|
+
if post.get("name") != "DBPostProcess":
|
|
106
|
+
raise ValueError(f"{path}:后处理是 {post.get('name')},只支持 DBPostProcess")
|
|
107
|
+
if post.get("box_type", "quad") != "quad" or post.get("score_mode", "fast") != "fast" or post.get("use_dilation", False):
|
|
108
|
+
raise ValueError(f"{path}:只支持 box_type=quad、score_mode=fast、不膨胀的 DBPostProcess")
|
|
109
|
+
return cls(model_name=name, limit_side_len=int(side), limit_type=kind, max_side_limit=4000, img_mode=img_mode,
|
|
110
|
+
alpha=tuple(scale / std[i] for i in range(3)), beta=tuple(-mean[i] / std[i] for i in range(3)),
|
|
111
|
+
thresh=float(post.get("thresh", 0.3)), box_thresh=float(post.get("box_thresh", 0.6)),
|
|
112
|
+
unclip_ratio=float(post.get("unclip_ratio", 2.0)), max_candidates=int(post.get("max_candidates", 1000)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DBPostProcess:
|
|
116
|
+
"""DB 的后处理(概率图 -> 四点框 + 分数),照抄 PaddleX 的 `DBPostProcess`(quad / fast / 不膨胀那条路)。
|
|
117
|
+
|
|
118
|
+
调用形状和 PaddleX 一样:`post(preds, img_shapes, thresh=…, box_thresh=…, unclip_ratio=…)`,
|
|
119
|
+
`preds[0]` 是 `[N, 1, H, W]` 的概率图,`img_shapes[i] = (src_h, src_w, ratio_h, ratio_w)`;
|
|
120
|
+
返回 `(boxes 列表, scores 列表)`,每帧的 boxes 是 `int16` 的 `[k, 4, 2]`。
|
|
121
|
+
⚠ 算术**一处都别"顺手改"**:`round` 是 Python 的银行家舍入、`box_score_fast` 的 floor / ceil 夹在 `w-1` 上、
|
|
122
|
+
`min_size` 的两道门一道 `< 3` 一道 `< 5`——每一处都决定框的像素坐标。
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
MIN_SIZE = 3
|
|
126
|
+
|
|
127
|
+
def __init__(self, spec: DetSpec) -> None:
|
|
128
|
+
self.thresh, self.box_thresh, self.unclip_ratio = spec.thresh, spec.box_thresh, spec.unclip_ratio
|
|
129
|
+
self.max_candidates = spec.max_candidates
|
|
130
|
+
|
|
131
|
+
def __call__(self, preds, img_shapes, thresh: float | None = None, box_thresh: float | None = None,
|
|
132
|
+
unclip_ratio: float | None = None):
|
|
133
|
+
boxes, scores = [], []
|
|
134
|
+
for pred, img_shape in zip(preds[0], img_shapes):
|
|
135
|
+
b, s = self.process(pred, img_shape, thresh or self.thresh, box_thresh or self.box_thresh,
|
|
136
|
+
unclip_ratio or self.unclip_ratio)
|
|
137
|
+
boxes.append(b)
|
|
138
|
+
scores.append(s)
|
|
139
|
+
return boxes, scores
|
|
140
|
+
|
|
141
|
+
def process(self, pred, img_shape, thresh: float, box_thresh: float, unclip_ratio: float):
|
|
142
|
+
pred = pred[0, :, :]
|
|
143
|
+
src_h, src_w, _ratio_h, _ratio_w = img_shape
|
|
144
|
+
return self.boxes_from_bitmap(pred, pred > thresh, src_w, src_h, box_thresh, unclip_ratio)
|
|
145
|
+
|
|
146
|
+
def boxes_from_bitmap(self, pred, bitmap, dest_width, dest_height, box_thresh: float, unclip_ratio: float):
|
|
147
|
+
height, width = bitmap.shape
|
|
148
|
+
width_scale = dest_width / width
|
|
149
|
+
height_scale = dest_height / height
|
|
150
|
+
# PaddleX 写的是 `(bitmap * 255).astype(np.uint8)`——bool 先升成 int64 再转回,一帧两次 50 万元素的临时数组。
|
|
151
|
+
# `findContours` 只看非零,bool 的 0 / 1 按字节看就是 uint8 的 0 / 1,轮廓逐点相同(随机图 + 真帧 120 帧对拍过)
|
|
152
|
+
contours, _ = cv2.findContours(bitmap.view(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
|
153
|
+
boxes, scores = [], []
|
|
154
|
+
for index in range(min(len(contours), self.max_candidates)):
|
|
155
|
+
contour = contours[index]
|
|
156
|
+
points, sside = self.get_mini_boxes(contour)
|
|
157
|
+
if sside < self.MIN_SIZE:
|
|
158
|
+
continue
|
|
159
|
+
points = np.array(points)
|
|
160
|
+
score = self.box_score_fast(pred, points.reshape(-1, 2))
|
|
161
|
+
if box_thresh > score:
|
|
162
|
+
continue
|
|
163
|
+
box = self.unclip(points, unclip_ratio).reshape(-1, 1, 2)
|
|
164
|
+
box, sside = self.get_mini_boxes(box)
|
|
165
|
+
if sside < self.MIN_SIZE + 2:
|
|
166
|
+
continue
|
|
167
|
+
box = np.array(box)
|
|
168
|
+
for i in range(box.shape[0]):
|
|
169
|
+
box[i, 0] = max(0, min(round(box[i, 0] * width_scale), dest_width))
|
|
170
|
+
box[i, 1] = max(0, min(round(box[i, 1] * height_scale), dest_height))
|
|
171
|
+
boxes.append(box.astype(np.int16))
|
|
172
|
+
scores.append(score)
|
|
173
|
+
return np.array(boxes, dtype=np.int16), scores
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def unclip(box, unclip_ratio: float):
|
|
177
|
+
area = cv2.contourArea(box)
|
|
178
|
+
length = cv2.arcLength(box, True)
|
|
179
|
+
distance = area * unclip_ratio / length
|
|
180
|
+
offset = pyclipper.PyclipperOffset()
|
|
181
|
+
offset.AddPath(box, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
|
|
182
|
+
try:
|
|
183
|
+
expanded = np.array(offset.Execute(distance))
|
|
184
|
+
except ValueError:
|
|
185
|
+
expanded = np.array(offset.Execute(distance)[0])
|
|
186
|
+
return expanded
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def get_mini_boxes(contour):
|
|
190
|
+
bounding_box = cv2.minAreaRect(contour)
|
|
191
|
+
points = sorted(list(cv2.boxPoints(bounding_box)), key=lambda x: x[0])
|
|
192
|
+
if points[1][1] > points[0][1]:
|
|
193
|
+
index_1, index_4 = 0, 1
|
|
194
|
+
else:
|
|
195
|
+
index_1, index_4 = 1, 0
|
|
196
|
+
if points[3][1] > points[2][1]:
|
|
197
|
+
index_2, index_3 = 2, 3
|
|
198
|
+
else:
|
|
199
|
+
index_2, index_3 = 3, 2
|
|
200
|
+
box = [points[index_1], points[index_2], points[index_3], points[index_4]]
|
|
201
|
+
return box, min(bounding_box[1])
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def box_score_fast(bitmap, _box):
|
|
205
|
+
h, w = bitmap.shape[:2]
|
|
206
|
+
box = _box.copy()
|
|
207
|
+
xmin = max(0, min(math.floor(box[:, 0].min()), w - 1))
|
|
208
|
+
xmax = max(0, min(math.ceil(box[:, 0].max()), w - 1))
|
|
209
|
+
ymin = max(0, min(math.floor(box[:, 1].min()), h - 1))
|
|
210
|
+
ymax = max(0, min(math.ceil(box[:, 1].max()), h - 1))
|
|
211
|
+
mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=np.uint8)
|
|
212
|
+
box[:, 0] = box[:, 0] - xmin
|
|
213
|
+
box[:, 1] = box[:, 1] - ymin
|
|
214
|
+
cv2.fillPoly(mask, box.reshape(1, -1, 2).astype(np.int32), 1)
|
|
215
|
+
return cv2.mean(bitmap[ymin: ymax + 1, xmin: xmax + 1], mask)[0]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""在线回抠放进**自己的进程**:辅助流接收(`framesource.AuxReader`)+ 辅助帧环(`FrameRing`)+ `EdgeRefiner`(`--edge-proc`)。
|
|
2
|
+
|
|
3
|
+
为什么(decode-buffer 的 S2 第一刀):同进程时回抠工作线程是 UI 密的素材上的瓶颈,而且是**被 GIL 饿着**的瓶颈——
|
|
4
|
+
wuwa-s2 上它忙 24.9 s、自己的 CPU 只有 11.5 s;环放大只把等待从"det 等帧"挪到"落盘等回抠"。它和辅助流收帧都是碰像素的活,
|
|
5
|
+
挪出去之后管线进程里少两个抢 GIL 的线程,它自己也有一把整的 GIL。
|
|
6
|
+
|
|
7
|
+
**算法一行不动**:`EdgeRefiner` 原样跑在这边,只把它和管线进程之间的三样东西换成消息:
|
|
8
|
+
|
|
9
|
+
* 链事件(`sample`):行对象换成**行号**(管线进程里 `id(row)`,那边握着行、行活着号就不会被复用);
|
|
10
|
+
* 证据(`_settle` / `_end` 写 `row["edge"]["on" / "off"]`):这边的行是 `RowRef` 替身,写进去就发 `("ev", 行号, 键, 证据)` 回去,
|
|
11
|
+
管线进程的收信线程挂到真的行上;
|
|
12
|
+
* 进度(`done_seq`):`EdgeRefiner.on_done` 每批发 `("done", 批号)`。**证据先于进度发出、同一条连接按序到达**,
|
|
13
|
+
所以管线进程看到"第 k 批做完"时,第 k 批及以前的证据都已经挂上了——延迟落盘(`wait_batch(settled_by)`)的规矩不用改。
|
|
14
|
+
|
|
15
|
+
辅助流:ffmpeg 的第二路输出直接 connect 到**这边** listen 的端口(像素不经过管线进程);它的 pts 在 ffmpeg 的 stderr 里,
|
|
16
|
+
只能由管线进程读,所以逐行转发过来(每秒 60 条小消息)。⚠ **别攒批转发**:这边收一帧要先拿到它的 pts,攒着不发的话,
|
|
17
|
+
这边不读 TCP -> ffmpeg 写不出 -> 不再吐 stderr 行 -> 批永远攒不满,互等。
|
|
18
|
+
|
|
19
|
+
进程生命周期走 `childproc`(起子进程、口令、限时等连接、收尾,和只解码进程共用一份)。连接一断(管线进程死了)这边就退出。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import traceback
|
|
27
|
+
from queue import Queue
|
|
28
|
+
|
|
29
|
+
class _Slot:
|
|
30
|
+
"""`row["edge"]` 的替身:往里写证据就是发回管线进程。"""
|
|
31
|
+
__slots__ = ("rid", "send")
|
|
32
|
+
|
|
33
|
+
def __init__(self, rid: int, send) -> None:
|
|
34
|
+
self.rid, self.send = rid, send
|
|
35
|
+
|
|
36
|
+
def __setitem__(self, key: str, rec: dict) -> None:
|
|
37
|
+
self.send(("ev", self.rid, key, rec))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RowRef:
|
|
41
|
+
"""管线进程里一行的替身。`EdgeRefiner` 对行只做 `row.setdefault("edge", {})[键] = 证据` 这一件事(守卫核这一点)。"""
|
|
42
|
+
__slots__ = ("rid", "_slot")
|
|
43
|
+
|
|
44
|
+
def __init__(self, rid: int, send) -> None:
|
|
45
|
+
self.rid, self._slot = rid, _Slot(rid, send)
|
|
46
|
+
|
|
47
|
+
def setdefault(self, key: str, default=None):
|
|
48
|
+
if key != "edge":
|
|
49
|
+
raise KeyError(f"回抠只该往行里写 edge,碰了 {key!r}")
|
|
50
|
+
return self._slot
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def serve() -> int:
|
|
54
|
+
from flowocr.extract import childproc, edge_refine, framesource
|
|
55
|
+
from flowocr.extract import timeline as tl
|
|
56
|
+
|
|
57
|
+
conn, send = childproc.connect()
|
|
58
|
+
tl.start_from_env("edge") # --timeline:这个进程写 `<路径>.edge.jsonl`
|
|
59
|
+
try:
|
|
60
|
+
cfg = conn.recv()
|
|
61
|
+
except (EOFError, OSError):
|
|
62
|
+
return 0
|
|
63
|
+
if isinstance(cfg, tuple): # ("x",):管线进程起不来 / 不用了(同 decode_proc)
|
|
64
|
+
return 0
|
|
65
|
+
edge = edge_refine.EdgeRefiner(**cfg["edge"])
|
|
66
|
+
if cfg.get("regions"):
|
|
67
|
+
from flowocr.extract import regions
|
|
68
|
+
g = regions.from_spec(cfg["regions"], cfg["W"], cfg["H"])
|
|
69
|
+
if not g.unrestricted:
|
|
70
|
+
edge.ring.premask = regions.aux_masker(g, edge.Ws, edge.Hs, cfg["src_fps"])
|
|
71
|
+
pts_q: Queue = Queue()
|
|
72
|
+
aux = framesource.AuxReader(start_idx=cfg["start_idx"], n_frames=cfg["aux_n"], step=edge.grid.step,
|
|
73
|
+
width=edge.Ws, height=edge.Hs, sink=edge.ring, pts_q=pts_q,
|
|
74
|
+
src_fps=cfg["src_fps"]).start()
|
|
75
|
+
edge.on_done = lambda seq: send(("done", seq))
|
|
76
|
+
edge.on_error = lambda exc: send(("err", "".join(traceback.format_exception(exc)))) # 带 traceback:回抠进程里的错管线进程那边要看得见在哪
|
|
77
|
+
# **pts 的第二个入口**(`--decode-shards`):ffmpeg 跑在解码进程里时,stderr 也在那边读——让它直接连过来送 pts,
|
|
78
|
+
# 不绕管线进程转一道。消息同主连接的 "p" / "pe"
|
|
79
|
+
from multiprocessing.connection import Listener
|
|
80
|
+
import secrets
|
|
81
|
+
feed_key = secrets.token_bytes(16)
|
|
82
|
+
feed = Listener(("127.0.0.1", 0), authkey=feed_key)
|
|
83
|
+
|
|
84
|
+
def feeder() -> None:
|
|
85
|
+
try:
|
|
86
|
+
c = feed.accept()
|
|
87
|
+
while True:
|
|
88
|
+
m = c.recv()
|
|
89
|
+
if m[0] == "p":
|
|
90
|
+
pts_q.put(m[1])
|
|
91
|
+
else:
|
|
92
|
+
pts_q.put(framesource.QUEUE_SENTINEL)
|
|
93
|
+
return
|
|
94
|
+
except (EOFError, OSError):
|
|
95
|
+
pts_q.put(framesource.QUEUE_SENTINEL) # 解码进程没了:辅助流就此停(AuxReader 记 stopped_early)
|
|
96
|
+
|
|
97
|
+
threading.Thread(target=feeder, daemon=True, name="pts-feed").start()
|
|
98
|
+
send(("hello", {"url": aux.url, "Ws": edge.Ws, "Hs": edge.Hs, "cap": edge.ring.cap,
|
|
99
|
+
"pts_addr": [feed.address[1], feed_key.hex()]}))
|
|
100
|
+
refs: dict[int, RowRef] = {}
|
|
101
|
+
|
|
102
|
+
def ref(rid: int) -> RowRef:
|
|
103
|
+
r = refs.get(rid)
|
|
104
|
+
if r is None:
|
|
105
|
+
r = refs[rid] = RowRef(rid, send)
|
|
106
|
+
return r
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
while True:
|
|
110
|
+
try:
|
|
111
|
+
msg = conn.recv()
|
|
112
|
+
except (EOFError, OSError):
|
|
113
|
+
edge.abort() # 管线进程没了:不留孤儿
|
|
114
|
+
return 1
|
|
115
|
+
tag = msg[0]
|
|
116
|
+
if tag == "p":
|
|
117
|
+
pts_q.put(msg[1])
|
|
118
|
+
elif tag == "pe":
|
|
119
|
+
pts_q.put(framesource.QUEUE_SENTINEL)
|
|
120
|
+
elif tag == "s":
|
|
121
|
+
_, seq, idx, t_us, items, ended, stale, live = msg
|
|
122
|
+
edge.sample(seq, idx, t_us, [(k, ref(rid), box, text, kind) for k, rid, box, text, kind in items],
|
|
123
|
+
[(k, ref(rid), box, text, li) for k, rid, box, text, li in ended], set(stale))
|
|
124
|
+
if live is not None: # 替身表跟着管线进程那边的行号表剪(它隔一阵发来仍可能被写的行号)
|
|
125
|
+
keep = set(live)
|
|
126
|
+
for rid in [r for r in refs if r not in keep]:
|
|
127
|
+
del refs[rid]
|
|
128
|
+
elif tag == "f":
|
|
129
|
+
stats = edge.finish(msg[1])
|
|
130
|
+
aux.join()
|
|
131
|
+
stats.update({"aux_sent": aux.sent, "aux_stopped_early": aux.stopped_early, "aux_last_key": aux.last_key,
|
|
132
|
+
"aux_cpu_sec": round(aux.cpu_sec, 2), "aux_shift": 0,
|
|
133
|
+
"ring_cap": edge.ring.cap, "proc": True})
|
|
134
|
+
if aux.misaligned:
|
|
135
|
+
stats["aux_misaligned"] = aux.misaligned
|
|
136
|
+
tl.dump() # 在回 fin 之前:B 收到就收摊了
|
|
137
|
+
send(("fin", stats))
|
|
138
|
+
return 0
|
|
139
|
+
elif tag == "x":
|
|
140
|
+
edge.abort()
|
|
141
|
+
return 0
|
|
142
|
+
finally:
|
|
143
|
+
try:
|
|
144
|
+
conn.close()
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class EdgeProxy:
|
|
150
|
+
"""管线进程这边:和 `EdgeRefiner` 同一组接口(`sample` / `settled_by` / `wait_batch` / `finish` / `abort`),活在另一个进程里。"""
|
|
151
|
+
|
|
152
|
+
STALL_SEC = 60.0
|
|
153
|
+
"""回抠进程多久没前进算卡住:**看进度、不看总等待**,和解码分片(`decode_shards` 的 `STALL_SEC`)同一个判据。
|
|
154
|
+
它落后管线很多批时一直在做(CPU 上、或收尾时积压一大截),等得再久也不算卡;做完的批号这么久不动才算。
|
|
155
|
+
原来是"这一批总共等了 300 s",积压够长的正常收尾也会被判卡住,真卡住又要白等 5 分钟。"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, *, edge_kwargs: dict, start_idx: int, end_idx: int, src_fps: float, width: int, height: int,
|
|
158
|
+
regions_spec: dict | None = None, start_timeout: float = 120.0, stall_sec: float = STALL_SEC) -> None:
|
|
159
|
+
from types import SimpleNamespace
|
|
160
|
+
|
|
161
|
+
from flowocr.extract import childproc
|
|
162
|
+
|
|
163
|
+
self.child = childproc.Child("flowocr.extract.edge_proc", "回抠进程", timeout=start_timeout)
|
|
164
|
+
self.proc = self.child.proc
|
|
165
|
+
self.conn = self.child.conn # 限时等它连上(连不上就杀掉、抛)
|
|
166
|
+
from flowocr.extract.framegrid import AuxGrid
|
|
167
|
+
|
|
168
|
+
self.conn.send({"edge": edge_kwargs, "start_idx": start_idx, "src_fps": src_fps, "W": width, "H": height,
|
|
169
|
+
"regions": regions_spec, "aux_n": AuxGrid.from_list(edge_kwargs["grid"]).n_frames(start_idx, end_idx)})
|
|
170
|
+
hello = self.conn.recv()
|
|
171
|
+
if hello[0] != "hello":
|
|
172
|
+
raise RuntimeError(f"回抠进程握手失败:{hello!r}")
|
|
173
|
+
h = hello[1]
|
|
174
|
+
self.url, self.Ws, self.Hs = h["url"], h["Ws"], h["Hs"]
|
|
175
|
+
self.pts_addr = h["pts_addr"]
|
|
176
|
+
"""`[端口, 口令 hex]`:只解码进程(`--decode-shards`)直接把辅路 pts 送到这里(不经过管线进程)。"""
|
|
177
|
+
self.ring = SimpleNamespace(cap=h["cap"])
|
|
178
|
+
self.noop = bool(edge_kwargs.get("noop"))
|
|
179
|
+
self.stall_sec = stall_sec
|
|
180
|
+
self.rows: dict[int, tuple[dict, int]] = {} # 行号 -> (行, 最近一次发出去时的批号)
|
|
181
|
+
self.done_seq = -1
|
|
182
|
+
self.cv = threading.Condition()
|
|
183
|
+
self.error: str | None = None
|
|
184
|
+
self.stats: dict | None = None
|
|
185
|
+
self.wait_sec = 0.0
|
|
186
|
+
self.last_seq = -1
|
|
187
|
+
self.th = threading.Thread(target=self._recv, daemon=True, name="edge-proxy")
|
|
188
|
+
self.th.start()
|
|
189
|
+
|
|
190
|
+
# ---------- 发 ----------
|
|
191
|
+
def _send(self, msg) -> None:
|
|
192
|
+
self.child.send(msg)
|
|
193
|
+
|
|
194
|
+
def pts_put(self, item) -> None:
|
|
195
|
+
"""ffmpeg stderr 的辅路 pts(`framesource.FfmpegSource` 的 drain 线程逐行调)。"""
|
|
196
|
+
from flowocr.extract.framesource import QUEUE_SENTINEL
|
|
197
|
+
try:
|
|
198
|
+
self._send(("pe",) if item is QUEUE_SENTINEL else ("p", item))
|
|
199
|
+
except OSError:
|
|
200
|
+
pass # 那边已经退了:错误在 wait_batch / finish 里报
|
|
201
|
+
|
|
202
|
+
def sample(self, seq: int, idx: int, t_us: int, items: list[tuple], ended: list[tuple],
|
|
203
|
+
stale: set[int] | tuple = ()) -> None:
|
|
204
|
+
its, ens = [], []
|
|
205
|
+
for k, row, box, text, kind in items:
|
|
206
|
+
self.rows[id(row)] = (row, seq)
|
|
207
|
+
its.append((k, id(row), list(box), text, kind))
|
|
208
|
+
for k, row, box, text, li in ended:
|
|
209
|
+
if row is None:
|
|
210
|
+
continue
|
|
211
|
+
self.rows[id(row)] = (row, seq)
|
|
212
|
+
ens.append((k, id(row), list(box), text, li))
|
|
213
|
+
self.last_seq = seq
|
|
214
|
+
live = None
|
|
215
|
+
if seq % 64 == 0:
|
|
216
|
+
# 行号表剪枝:证据最晚落在开段那一行上(段在 MAX_OPEN_SAMPLES 批内停变或放弃),再老的行不会再被写。
|
|
217
|
+
# 按**那边已经做完的批号**剪(它可能落后这边若干批),不按这边刚发的批号
|
|
218
|
+
from flowocr.extract.edge_refine import MAX_OPEN_SAMPLES
|
|
219
|
+
floor = self.done_seq - MAX_OPEN_SAMPLES - 16
|
|
220
|
+
for rid in [r for r, (_, s) in self.rows.items() if s < floor]:
|
|
221
|
+
del self.rows[rid]
|
|
222
|
+
live = list(self.rows)
|
|
223
|
+
self._send(("s", seq, idx, t_us, its, ens, list(stale), live))
|
|
224
|
+
|
|
225
|
+
# ---------- 收 ----------
|
|
226
|
+
def _recv(self) -> None:
|
|
227
|
+
try:
|
|
228
|
+
while True:
|
|
229
|
+
msg = self.conn.recv()
|
|
230
|
+
tag = msg[0]
|
|
231
|
+
if tag == "ev":
|
|
232
|
+
_, rid, key, rec = msg
|
|
233
|
+
hit = self.rows.get(rid)
|
|
234
|
+
if hit is None:
|
|
235
|
+
self.error = f"回抠进程发来的证据落在一行已经剪掉的行上(行号 {rid}、{key})"
|
|
236
|
+
else:
|
|
237
|
+
hit[0].setdefault("edge", {})[key] = rec
|
|
238
|
+
elif tag == "done":
|
|
239
|
+
with self.cv:
|
|
240
|
+
self.done_seq = msg[1]
|
|
241
|
+
self.cv.notify_all()
|
|
242
|
+
elif tag == "err":
|
|
243
|
+
self.error = f"回抠进程出错:{msg[1]}"
|
|
244
|
+
elif tag == "fin":
|
|
245
|
+
self.stats = msg[1]
|
|
246
|
+
if tag in ("err", "fin"):
|
|
247
|
+
with self.cv:
|
|
248
|
+
self.cv.notify_all()
|
|
249
|
+
if tag == "fin":
|
|
250
|
+
return
|
|
251
|
+
except (EOFError, OSError) as exc:
|
|
252
|
+
if self.stats is None and self.error is None:
|
|
253
|
+
self.error = f"回抠进程断开:{exc!r}(退出码 {self.proc.poll()})"
|
|
254
|
+
finally:
|
|
255
|
+
with self.cv:
|
|
256
|
+
self.cv.notify_all()
|
|
257
|
+
|
|
258
|
+
def _check(self) -> None:
|
|
259
|
+
if self.error is not None:
|
|
260
|
+
raise RuntimeError(self.error)
|
|
261
|
+
|
|
262
|
+
# ---------- 和 EdgeRefiner 同名的几个 ----------
|
|
263
|
+
@staticmethod
|
|
264
|
+
def settled_by(seq: int) -> int:
|
|
265
|
+
from flowocr.extract.edge_refine import MAX_OPEN_SAMPLES
|
|
266
|
+
return seq + MAX_OPEN_SAMPLES + 1
|
|
267
|
+
|
|
268
|
+
def _wait(self, done, what: str) -> None:
|
|
269
|
+
"""等到 `done()`;做完的批号 `stall_sec` 秒没动就记成出错(见 `STALL_SEC`)。出错 / 断开时 `_recv` 会叫醒这里。"""
|
|
270
|
+
last, t_last = self.done_seq, time.perf_counter()
|
|
271
|
+
with self.cv:
|
|
272
|
+
while not done() and self.error is None:
|
|
273
|
+
self.cv.wait(0.5)
|
|
274
|
+
now = time.perf_counter()
|
|
275
|
+
if self.done_seq != last:
|
|
276
|
+
last, t_last = self.done_seq, now
|
|
277
|
+
elif now - t_last > self.stall_sec:
|
|
278
|
+
self.error = f"{what}:回抠进程 {self.stall_sec:.0f} s 没前进(停在第 {self.done_seq} 批)"
|
|
279
|
+
|
|
280
|
+
def wait_batch(self, seq: int) -> None:
|
|
281
|
+
t0 = time.perf_counter()
|
|
282
|
+
self._wait(lambda: self.done_seq >= seq or self.stats is not None, f"等回抠进程做完第 {seq} 批")
|
|
283
|
+
from flowocr.extract import timeline as tl
|
|
284
|
+
self.wait_sec += tl.span("wait_batch", t0, a=seq) - t0
|
|
285
|
+
self._check()
|
|
286
|
+
|
|
287
|
+
FINISH_SEC = 300.0
|
|
288
|
+
"""批号追到头之后回抠进程还要算结尾窗、汇总统计——这段**没有进度信号**,按 `STALL_SEC` 判会误伤(CPU 上尤其),给固定宽限(原来的 300 s)。"""
|
|
289
|
+
|
|
290
|
+
def finish(self, last_idx: int) -> dict:
|
|
291
|
+
self._send(("f", last_idx))
|
|
292
|
+
# 两段:先按进度等积压的批做完(和 wait_batch 同一个判据),再给收尾一段固定宽限
|
|
293
|
+
self._wait(lambda: self.done_seq >= self.last_seq or self.stats is not None or not self.th.is_alive(),
|
|
294
|
+
"等回抠进程做完积压的批")
|
|
295
|
+
if self.error is None:
|
|
296
|
+
self.th.join(self.FINISH_SEC)
|
|
297
|
+
if self.th.is_alive() and self.stats is None:
|
|
298
|
+
self.error = f"回抠进程收尾(结尾窗 / 汇总)超过 {self.FINISH_SEC:.0f} s"
|
|
299
|
+
if self.error is not None or self.stats is None:
|
|
300
|
+
self.child.close(wait=10) # 出错也收掉子进程,不留给监督者按进程树收
|
|
301
|
+
self._check()
|
|
302
|
+
if self.stats is None:
|
|
303
|
+
raise RuntimeError("回抠进程没交回统计就断了")
|
|
304
|
+
self.child.close(wait=30) # 它发完 fin 就自己退了:告别发不出去也无妨,限时等、不退就杀
|
|
305
|
+
st = dict(self.stats)
|
|
306
|
+
st["wait_batch_sec"] = round(self.wait_sec, 2) # 管线进程这边等它的时间(那边的 wait_batch 从来没人调)
|
|
307
|
+
return st
|
|
308
|
+
|
|
309
|
+
def abort(self) -> None:
|
|
310
|
+
self.child.close(wait=10)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
sys.exit(serve())
|