flowocr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. flowocr/__init__.py +15 -0
  2. flowocr/analyze/__init__.py +8 -0
  3. flowocr/analyze/align.py +151 -0
  4. flowocr/analyze/build_tracks.py +2886 -0
  5. flowocr/analyze/cluster_layers.py +155 -0
  6. flowocr/analyze/game_align.py +652 -0
  7. flowocr/analyze/gamescript.py +1220 -0
  8. flowocr/analyze/gtdbundle.py +306 -0
  9. flowocr/analyze/match.py +67 -0
  10. flowocr/analyze/matchers/__init__.py +5 -0
  11. flowocr/analyze/matchers/gametext.py +70 -0
  12. flowocr/analyze/merge_nameplate.py +178 -0
  13. flowocr/analyze/models/slot_pair.json +148 -0
  14. flowocr/analyze/nameplate.py +148 -0
  15. flowocr/analyze/pair_features.py +168 -0
  16. flowocr/analyze/pair_model.py +127 -0
  17. flowocr/analyze/refine_boundaries.py +409 -0
  18. flowocr/analyze/script_align.py +641 -0
  19. flowocr/analyze/scriptmatch.py +1018 -0
  20. flowocr/analyze/slot_learned.py +306 -0
  21. flowocr/analyze/slot_lines.py +251 -0
  22. flowocr/analyze/slot_modes.py +143 -0
  23. flowocr/analyze/slot_pairs.py +562 -0
  24. flowocr/analyze/slot_veto.py +104 -0
  25. flowocr/analyze/uigate.py +351 -0
  26. flowocr/artifacts/__init__.py +5 -0
  27. flowocr/artifacts/evalkit.py +123 -0
  28. flowocr/artifacts/matchedio.py +75 -0
  29. flowocr/artifacts/srtio.py +166 -0
  30. flowocr/artifacts/tracksio.py +345 -0
  31. flowocr/extensions.py +77 -0
  32. flowocr/extract/__init__.py +8 -0
  33. flowocr/extract/childproc.py +118 -0
  34. flowocr/extract/decode_proc.py +511 -0
  35. flowocr/extract/decode_shards.py +574 -0
  36. flowocr/extract/detpost.py +215 -0
  37. flowocr/extract/edge_proc.py +314 -0
  38. flowocr/extract/edge_refine.py +598 -0
  39. flowocr/extract/fast_det.py +214 -0
  40. flowocr/extract/ffcheck.py +87 -0
  41. flowocr/extract/framegrid.py +265 -0
  42. flowocr/extract/framesource.py +1264 -0
  43. flowocr/extract/ocr_args.py +754 -0
  44. flowocr/extract/ocr_complete.py +231 -0
  45. flowocr/extract/ocr_parallel.py +208 -0
  46. flowocr/extract/ort_server.py +632 -0
  47. flowocr/extract/ortclient.py +363 -0
  48. flowocr/extract/ptsclock.py +150 -0
  49. flowocr/extract/recdecode.py +78 -0
  50. flowocr/extract/recort.py +162 -0
  51. flowocr/extract/recpack.py +94 -0
  52. flowocr/extract/recpool.py +206 -0
  53. flowocr/extract/recprep.py +71 -0
  54. flowocr/extract/refine_video.py +271 -0
  55. flowocr/extract/regions.py +387 -0
  56. flowocr/extract/reuse_v2.py +593 -0
  57. flowocr/extract/run_groups.py +247 -0
  58. flowocr/extract/run_ocr2.py +1605 -0
  59. flowocr/extract/supervisor.py +261 -0
  60. flowocr/extract/timeline.py +84 -0
  61. flowocr/extract/typewriter_fuse.py +394 -0
  62. flowocr/models.py +263 -0
  63. flowocr/output/__init__.py +3 -0
  64. flowocr/output/export.py +205 -0
  65. flowocr/output/layout.py +210 -0
  66. flowocr/output/presets/__init__.py +6 -0
  67. flowocr/output/presets/_overlay.py +40 -0
  68. flowocr/output/presets/default.py +21 -0
  69. flowocr/output/presets/default_all.py +20 -0
  70. flowocr/output/presets/dev.py +21 -0
  71. flowocr/output/presets/matched_srt.py +32 -0
  72. flowocr/output/presets/script.py +20 -0
  73. flowocr/output/presets/srt_main.py +33 -0
  74. flowocr/output/render.py +82 -0
  75. flowocr/output/run_srt.py +83 -0
  76. flowocr/output/script.py +541 -0
  77. flowocr/paths.py +168 -0
  78. flowocr/provenance.py +119 -0
  79. flowocr/typeset/__init__.py +9 -0
  80. flowocr/typeset/__main__.py +38 -0
  81. flowocr/typeset/assfile.py +216 -0
  82. flowocr/typeset/core.py +821 -0
  83. flowocr/typeset/fx/__init__.py +11 -0
  84. flowocr-0.1.0.dist-info/METADATA +109 -0
  85. flowocr-0.1.0.dist-info/RECORD +91 -0
  86. flowocr-0.1.0.dist-info/WHEEL +5 -0
  87. flowocr-0.1.0.dist-info/entry_points.txt +10 -0
  88. flowocr-0.1.0.dist-info/licenses/LICENSE +674 -0
  89. flowocr-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +201 -0
  90. flowocr-0.1.0.dist-info/licenses/LICENSES/PP-OCRv6-NOTICE.md +18 -0
  91. flowocr-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,271 @@
1
+ """回抠的**像素测量**那一半:开视频、按 run 的解码窗取裁剪、量终点 / 首字 / 全字的信号,写进 `job["measured"]`。
2
+
3
+ 2026-09-22 从 `flowocr.analyze.refine_boundaries` 拆出来(正规化 project-structure:打开视频、测像素信号在阶段 1;
4
+ 在已有证据上结算时间在阶段 2)。**这里不写时间**——`measure()` 的输出是五元组 `(new_e, ok_gone, sig, last_full, end_how)`,
5
+ 和 `run_ocr2 --refine-fused` 在线量好、经 `edge_refine.measured_from_records` 重建出来的是**同一种证据**;
6
+ 结算只有 `flowocr.analyze.refine_boundaries.apply` 一份。
7
+
8
+ 两条取帧的路:`refine_sequential`(cv2 顺序解码,窗内 retrieve、窗外 grab)和 `refine_ffmpeg`
9
+ (ffmpeg 子进程只送各 run 的解码窗,av1 走 libdav1d;像素和 cv2 那条路不逐字节相同,时刻可能差 ±1 帧)。
10
+ `make_job` 定每条 run 要哪些帧(打字机窗 / 结尾窗 / 模板帧),时间 ↔ 帧号两头都走 `ptsclock.IndexClock`。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import time
15
+
16
+ import cv2
17
+ import numpy as np
18
+
19
+ from flowocr.extract import framesource, ptsclock
20
+ from flowocr.extract import typewriter_fuse as TF
21
+
22
+
23
+ PAD = 6
24
+ """首字 / 全字的裁剪在框外各扩这么多像素(首尾字格要带一点边,不然贴边的笔画被切掉)。"""
25
+
26
+ LEAD_US = 50_000
27
+ """打字机窗在"首字前一个采样间隔"之外再往前多取这么久:窗口首帧当"还没字"的参照。"""
28
+
29
+ def crop_of(frame: np.ndarray, box: list[float], pad: int = 0) -> tuple[np.ndarray, tuple[int, int, int, int]]:
30
+ """灰度裁剪 + 框在裁剪里的位置 (x0, y0, x1, y1)。贴边时外扩被截掉,位置照实给。"""
31
+ h, w = frame.shape[:2]
32
+ bx0, by0 = max(0, int(box[0])), max(0, int(box[1]))
33
+ bx1, by1 = min(w, int(box[2])), min(h, int(box[3]))
34
+ if bx1 - bx0 < 4 or by1 - by0 < 4:
35
+ return np.zeros((4, 4), np.uint8), (0, 0, 4, 4)
36
+ x0, y0 = max(0, bx0 - pad), max(0, by0 - pad)
37
+ x1, y1 = min(w, bx1 + pad), min(h, by1 + pad)
38
+ return (cv2.cvtColor(frame[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY),
39
+ (bx0 - x0, by0 - y0, bx1 - x0, by1 - y0))
40
+
41
+ def video_codec(path: str) -> str:
42
+ """容器里的视频编码四字码(`av01` / `vp09` / `avc1` / `hev1`…),小写;打不开就是空串。"""
43
+ cap = cv2.VideoCapture(path)
44
+ v = int(cap.get(cv2.CAP_PROP_FOURCC)) if cap.isOpened() else 0
45
+ cap.release()
46
+ return "".join(chr((v >> (8 * k)) & 0xFF) for k in range(4)).strip().lower() if v else ""
47
+
48
+ def video_fps(path: str) -> float:
49
+ cap = cv2.VideoCapture(path)
50
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
51
+ cap.release()
52
+ # 打不开的文件 OpenCV 给 **−1**,`or 30.0` 放它过去:整条时间轴按负帧率算,
53
+ # 产物是垃圾而**一行报错都没有**(2026-09-17 审计时拿错路径撞上的:起点平均移动 8158 s)。
54
+ # `--decoder obs` 不开视频,但仍要这个 fps 换算帧号,所以这一步一样不能猜
55
+ if fps <= 0:
56
+ raise SystemExit(f"读不出帧率({fps}):{path} 打不开或不是视频。"
57
+ f"回抠所有时刻都按它换算,猜一个会让整条时间轴错到不像话")
58
+ return fps
59
+
60
+ SEEK_WORTH_FRAMES = 900
61
+ """跳过这么多帧以上才值得 seek,否则 grab 过去更便宜。
62
+ 按实测(seek 85 ms / grab 0.3 ms)盈亏点在约 280 帧,这里取三倍余量——
63
+ 估偏了也只是多 grab 一会儿,不会出错。"""
64
+
65
+ def first_true(lo: int, hi: int, pred) -> tuple[int, bool]:
66
+ """[lo, hi] 里第一个满足的下标 + **找没找到**。都不满足时返回 `(hi, False)`。
67
+
68
+ 第二个返回值不是可有可无的(methodology-audit-4 报告 C5):
69
+ 原来"没找到"和"边界正好在 hi"返回同一个值,调用方分不开,
70
+ 于是**搜索失败的默认端点被当成了观测到的边界**。
71
+ """
72
+ for i in range(lo, hi + 1):
73
+ if pred(i):
74
+ return i, True
75
+ return hi, False
76
+
77
+ def last_true(lo: int, hi: int, pred) -> tuple[int, bool]:
78
+ """[lo, hi] 里最后一个满足的下标 + 找没找到。都不满足时返回 `(lo, False)`。"""
79
+ for i in range(hi, lo - 1, -1):
80
+ if pred(i):
81
+ return i, True
82
+ return lo, False
83
+
84
+ def make_job(run: dict, frame_us: int, us_per_frame: float, clock=None) -> dict:
85
+ """一条 run 要解码的帧窗。span = 一个采样间隔的帧数:
86
+
87
+ * `[lo, hi_start]`:打字机窗,从首字前一个多采样间隔到采样级全字之后一个采样间隔(最后一帧当"最终样子");
88
+ * `[e, hi]`:找全字消失;
89
+ * `mid`:消失判据的模板帧——**中间那次观测**,不是时间跨度的中点(n_obs=1 时中点已经没字了)。
90
+
91
+ **时间 ↔ 帧号两头都走 `clock`**(`ptsclock.IndexClock`,2026-09-17):run 的时间是真实 pts,
92
+ 而 `us_per_frame = 1e6 / src_fps` 是容器**平均**帧率——丢帧缺口的素材上两者差到几百毫秒(f4 20 分钟 −260 ms、
93
+ f3 7.2 h 处 9.5 s),窗口就开在错的地方、写回去的时刻也带着同一份偏差。
94
+ `clock=None` 时退回常量换算,和改之前**逐位相同**(CFR 素材上两者恒等)。"""
95
+ clock = clock or ptsclock.IndexClock(us_per_frame)
96
+ span = int(round(frame_us / us_per_frame))
97
+ s_idx = int(round(clock.idx_of(run["t_start"])))
98
+ e_idx = int(round(clock.idx_of(run["t_end"] - frame_us)))
99
+ mid_us = run["t_start"] + max(0, run.get("n_obs", 1) - 1) / 2 * frame_us
100
+ mid_idx = int(round(clock.idx_of(mid_us)))
101
+ tfs = run.get("t_full_sampled")
102
+ tfs_idx = int(round(clock.idx_of(tfs if tfs is not None else run["t_start"])))
103
+ lo = max(0, s_idx - span - int(round(LEAD_US / us_per_frame)))
104
+ # 窗口末帧就是"最终样子"的模板,**不许越过末次观测帧**:短条目(n_obs=2,采样级全字就是末次观测)
105
+ # 往后多取一个采样间隔,模板会落在字已经消失 / 下一句的帧上,全字出现被判成消失那一刻
106
+ # (水族馆 `(粉塵爆発…!)` 晚了 45 帧,2026-09-12)
107
+ hi_start = max(s_idx, min(tfs_idx + span, e_idx))
108
+ # 结尾窗:末次观测前半个多采样间隔(首帧当"字还完整"的模板)到 t_end 之后 0.2 s(淡出 / 交叉淡出的尾巴)
109
+ end_lo = max(mid_idx, int(round(clock.idx_of(run["t_end"] - 1.5 * frame_us - 50_000))))
110
+ end_hi = int(round(clock.idx_of(run["t_end"] + 200_000)))
111
+ return {"run": run, "box": run["box"], "s": s_idx, "e": e_idx, "mid": mid_idx, "span": span,
112
+ "end_lo": end_lo, "end_hi": end_hi,
113
+ "lo": min(lo, mid_idx, end_lo), "tw_lo": lo, "hi_start": hi_start,
114
+ "hi": max(e_idx + span, mid_idx, hi_start, end_hi),
115
+ "us_per_frame": us_per_frame, "clock": clock, "frame_us": frame_us, "crops": {}, "ocr": [],
116
+ "refined": False, "moved": False, "d_start": 0.0, "d_end": 0.0}
117
+
118
+ def wants(job: dict, i: int) -> bool:
119
+ """这一帧在不在 job 的解码窗里(打字机窗 / 结尾窗 / 模板帧)。"""
120
+ return ((job["tw_lo"] <= i <= job["hi_start"]) or (job["e"] <= i <= job["hi"]) or i == job["mid"]
121
+ or (job["end_lo"] <= i <= job["end_hi"]))
122
+
123
+ def refine_ffmpeg(video: str, jobs: list[dict], thr: float, progress_every: int, pix: str = "nv12",
124
+ step: int = 1, scale: float = 1.0) -> None:
125
+ """同 refine_sequential,但解码走 ffmpeg 子进程:先把所有 job 要的帧号并成区间,`select` 只送这些帧
126
+ (av1 走 libdav1d,OpenCV 内置的 av1 解码慢 12 倍;hw-decode-results 报告)。
127
+ 帧的像素和 cv2 那条路**不逐字节相同**(nv12 -> BGR 的转换,max|Δ| 约 3),回抠出的时刻可能差 ±1 帧。"""
128
+ if not jobs:
129
+ return
130
+ jobs.sort(key=lambda j: j["lo"])
131
+ cap = cv2.VideoCapture(video)
132
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
133
+ W, H = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
134
+ cap.release()
135
+ need = set()
136
+ for j in jobs:
137
+ need.update(i for i in range(j["lo"], j["hi"] + 1) if wants(j, i))
138
+ intervals = framesource.merge_intervals(sorted(need), gap=2)
139
+ keep = [j["mid"] for j in jobs] # 模板帧无论 step 都要
140
+ # **窗口解码要用同一份时钟**(2026-09-18 审计的 P1):make_job 已经按真实 pts 算窗口了,
141
+ # 但 seek 还在按 base/src_fps 走,取到的帧和标的帧号会错配。时钟就是各 job 共用的那一个。
142
+ clock = jobs[0]["clock"]
143
+ src = framesource.FfmpegWindows(video, intervals, src_fps=fps, width=W, height=H, pix=pix,
144
+ step=step, keep=keep, scale=scale,
145
+ ts_of=(lambda i: clock.t_of(i) / 1e6) if len(clock) else None)
146
+ n_send = len(framesource.window_frames(intervals, step, keep))
147
+ print(f" [解码] ffmpeg 窗口模式:{len(intervals)} 个区间、{n_send} 帧(step {step}、scale {scale};"
148
+ f"顺序解码要过 {jobs[-1]['hi']} 帧)", flush=True)
149
+ if scale < 1.0: # 框按同一比例缩;裁剪 / 融合都在缩后的像素上做
150
+ for j in jobs:
151
+ j["box"] = [v * scale for v in j["run"]["box"]]
152
+ active: list[dict] = []
153
+ ji, done = 0, 0
154
+ t0 = time.time()
155
+ for idx, _t, frame in src:
156
+ while ji < len(jobs) and jobs[ji]["lo"] <= idx:
157
+ active.append(jobs[ji]); ji += 1
158
+ for j in active:
159
+ if wants(j, idx):
160
+ j["crops"][idx] = crop_of(frame, j["box"], PAD)
161
+ still = []
162
+ for j in active:
163
+ if idx >= j["hi"] and idx >= j["mid"]:
164
+ measure(j, thr)
165
+ done += 1
166
+ if progress_every and done % progress_every == 0:
167
+ print(f" 已回抠 {done}/{len(jobs)} 条,用时 {(time.time()-t0)/60:.1f} min", flush=True)
168
+ else:
169
+ still.append(j)
170
+ active = still
171
+ for j in active:
172
+ measure(j, thr)
173
+ if src.stopped_early:
174
+ print(" ⚠ ffmpeg 窗口解码提前结束:" + " | ".join(src.stderr_tail[-3:]), flush=True)
175
+
176
+ def refine_sequential(video: str, jobs: list[dict], thr: float, progress_every: int) -> None:
177
+ """单向走一遍视频,每个 job 的解码窗一关就 `measure`(量终点、收集像素信号);写时间在 `apply`。"""
178
+ if not jobs:
179
+ return
180
+ jobs.sort(key=lambda j: j["lo"])
181
+ cap = cv2.VideoCapture(video)
182
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
183
+ idx, ji, done = 0, 0, 0
184
+ active: list[dict] = []
185
+ t0 = time.time()
186
+
187
+ while idx < total and (ji < len(jobs) or active):
188
+ while ji < len(jobs) and jobs[ji]["lo"] <= idx:
189
+ active.append(jobs[ji]); ji += 1
190
+ if not active:
191
+ nxt = jobs[ji]["lo"] if ji < len(jobs) else total
192
+ if nxt - idx > SEEK_WORTH_FRAMES:
193
+ cap.set(cv2.CAP_PROP_POS_FRAMES, nxt)
194
+ idx = nxt
195
+ continue
196
+ if not cap.grab():
197
+ break
198
+ need = [j for j in active if wants(j, idx)]
199
+ if need:
200
+ ok, frame = cap.retrieve()
201
+ if ok:
202
+ for j in need:
203
+ j["crops"][idx] = crop_of(frame, j["box"], PAD)
204
+ idx += 1
205
+ still = []
206
+ for j in active:
207
+ if idx > j["hi"] and idx > j["mid"]:
208
+ measure(j, thr)
209
+ done += 1
210
+ if progress_every and done % progress_every == 0:
211
+ print(f" 已回抠 {done}/{len(jobs)} 条,用时 {(time.time()-t0)/60:.1f} min", flush=True)
212
+ else:
213
+ still.append(j)
214
+ active = still
215
+ for j in active:
216
+ measure(j, thr)
217
+ cap.release()
218
+
219
+ def measure(job: dict, thr: float) -> None:
220
+ """解码窗关上时调:量终点、收集首字 / 全字的像素信号(`typewriter_fuse.signals`),**不写时间**。"""
221
+ crops, run = job["crops"], job["run"]
222
+ job["crops"] = {}
223
+ if job["mid"] not in crops:
224
+ return
225
+
226
+ def inner(c):
227
+ img, (x0, y0, x1, y1) = c
228
+ return img[y0:y1, x0:x1]
229
+ template = inner(crops[job["mid"]])
230
+ if template.size < 64:
231
+ return
232
+
233
+ def looks_full(i: int) -> bool:
234
+ c = crops.get(i)
235
+ return c is not None and TF.ncc(inner(c), template) >= thr
236
+
237
+ # 没被观测到的那一侧**原样退回**,不写"精确"的新时间(audit-4 C5)。
238
+ # 终点和首字 / 全字各自有各自的证据:终点没观测到(长条目中途背景变了、模板不再像)
239
+ # 不该连带把打字机两个时刻也丢掉——gi-s2 上两条 17–23 s 的台词就是这么整条没回抠的。
240
+ t_gone, ok_gone = last_true(job["e"], job["hi"], looks_full)
241
+ clock = job["clock"] # 帧号 -> 真实时间(见 make_job 的说明)
242
+ old_e = run["t_end"]
243
+ new_e = int(round(clock.t_of(t_gone + 1))) if ok_gone else old_e
244
+ sig = None
245
+ idxs = [i for i in range(job["tw_lo"], job["hi_start"] + 1) if i in crops]
246
+ # step > 1 时窗内只有每 step 帧一张;两端都要在,中间至少有一半(step 1 时就是原来的"一帧不缺")
247
+ if idxs and idxs[0] == job["tw_lo"] and idxs[-1] == job["hi_start"] and \
248
+ len(idxs) * job.get("step", 1) >= job["hi_start"] - job["tw_lo"] + 1:
249
+ sig = TF.signals(np.stack([crops[i][0] for i in idxs]), [int(round(clock.t_of(i))) for i in idxs],
250
+ crops[idxs[-1]][1], run["text"])
251
+ # 结尾两个关键帧:字形对比度曲线(typewriter_fuse.end_keyframes)。给得出"完全消失"就用它当 t_end,
252
+ # 给不出(下一句紧接着在同位置出现、窗口首帧字就不完整)退回上面的整框相关系数
253
+ # 开放边界(见 `edge_refine.measured_from_records`):最后还像的那一帧之后,窗里再没有真解到的帧——
254
+ # 一路像到窗尾,或者视频先结束了(片尾还在屏上的字):没看到它消失,t_end 只是下界
255
+ end_open = ok_gone and not any(i in crops for i in range(t_gone + 1, job["hi"] + 1))
256
+ end_how = ("open" if end_open else "ncc") if ok_gone else None
257
+ last_full = None
258
+ eidx = [i for i in range(job["end_lo"], job["end_hi"] + 1) if i in crops]
259
+ if eidx and eidx[0] == job["end_lo"] and eidx[-1] == job["end_hi"] and \
260
+ len(eidx) * job.get("step", 1) >= job["end_hi"] - job["end_lo"] + 1:
261
+ last_full, gone = TF.end_keyframes(np.stack([crops[i][0] for i in eidx]),
262
+ [int(round(clock.t_of(i))) for i in eidx], crops[eidx[0]][1], run["text"])
263
+ # 末次观测那一帧 OCR 还读到了字:曲线说更早就消失 = 被背景 / 模板骗了,不采纳
264
+ # (水族馆一条被判到 t_full_sampled 之前,产物校验当场拒收,2026-09-12)
265
+ if gone is not None and gone > clock.t_of(job["e"]):
266
+ new_e, ok_gone, end_how = gone, True, "curve"
267
+ if last_full is not None and last_full < clock.t_of(job["e"]) - job["frame_us"]:
268
+ last_full = None
269
+ if end_how == "open":
270
+ last_full = None
271
+ job["measured"] = (new_e, ok_gone, sig, last_full, end_how)
@@ -0,0 +1,387 @@
1
+ """自选 OCR 范围与识别组(ocr-regions 计划):范围的解析、某一时刻的生效遮罩、det 框按遮罩拆分。
2
+
3
+ **语义**(方案,owner 定):
4
+
5
+ * 一个识别组 = 若干正矩形 / 负矩形。坐标是整幅画面宽高的**百分比**,可带原片时间段 `[t0, t1)`(秒,缺省 = 片头 / 片尾)。
6
+ 组 g 在时刻 t 的有效范围 `R_g(t) = ∪ 生效正矩形 − ∪ 生效负矩形`。
7
+ 组里**一个正矩形都没配** = 隐含全屏正矩形;**配了但此刻一个都不生效** = 此刻范围为空(不退回全屏)。
8
+ * 范围外的像素按 rec 的 padding 语义**填中灰**:rec 归一化 `(x/255 − 0.5)/0.5` 后补 0,对应原像素 127.5;
9
+ uint8 只能取整,用 `FILL = 128`(张量里是 +0.0039,不宣称逐位相同,方案)。det 看到的是**同一种颜色**的像素,
10
+ 不是它张量里的 0(det 的逐通道归一化不同)。
11
+ * **det 框按遮罩拆**:框 B 里的有效像素 `B ∩ R_g(t)` 没有 → 丢;连通 → 一个框(有孔也不拆);
12
+ **被完全分断成几块 → 每块一个子框**,裁到那一块的最小外接矩形,外接矩形里**不属于它的有效像素也要涂灰**
13
+ (包括同一父框的另一块)。连通性是**框内有效范围**的连通性,按 **4 邻接**(只在角上相碰不算连通)。
14
+ 没被分断的框也裁到有效部分的外接矩形:外面那圈本来就是灰的,留着只会让 rec 读一段灰。
15
+ * 时间:范围只在"配置切换"时刻变(`boundaries`);那一刻前后的观测**不能被一个事件跨过**——
16
+ 由下游按 `_meta.regions` 里的切点断开(build_tracks),这里只负责给出切点。
17
+
18
+ 像素坐标:百分比 × 宽高后**四舍五入**,矩形是半开区间 `[x0, x1) × [y0, y1)`,并截到画面内。
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+
28
+ FILL = 128
29
+ """范围外像素的填充值(见文件头:rec padding 的 127.5 取整)。"""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Rect:
34
+ x0: float
35
+ y0: float
36
+ x1: float
37
+ y1: float
38
+ neg: bool = False
39
+ t0: float | None = None
40
+ t1: float | None = None
41
+
42
+ def active(self, t: float) -> bool:
43
+ return (self.t0 is None or t >= self.t0) and (self.t1 is None or t < self.t1)
44
+
45
+ def pixels(self, W: int, H: int) -> tuple[int, int, int, int]:
46
+ c = lambda v, n: min(n, max(0, int(round(v / 100.0 * n)))) # noqa: E731
47
+ return c(self.x0, W), c(self.y0, H), c(self.x1, W), c(self.y1, H)
48
+
49
+
50
+ def parse_rect(d: dict) -> Rect:
51
+ box = d["box"]
52
+ if len(box) != 4:
53
+ raise ValueError(f"矩形要 [x0, y0, x1, y1](百分比):{d}")
54
+ x0, y0, x1, y1 = (float(v) for v in box)
55
+ if not (0 <= x0 < x1 <= 100 and 0 <= y0 < y1 <= 100):
56
+ raise ValueError(f"矩形坐标要 0 ≤ x0 < x1 ≤ 100、0 ≤ y0 < y1 ≤ 100(百分比):{d}")
57
+ t = d.get("t") or [None, None]
58
+ t0, t1 = (None if v is None else float(v) for v in t)
59
+ if t0 is not None and t1 is not None and not t0 < t1:
60
+ raise ValueError(f"时间段要 t0 < t1:{d}")
61
+ return Rect(x0, y0, x1, y1, bool(d.get("neg", False)), t0, t1)
62
+
63
+
64
+ class RegionGroup:
65
+ """一个识别组在一段视频上的范围。`mask_at(t)` 返回 None = 此刻不受限(全屏),否则 H×W 的 bool。"""
66
+
67
+ def __init__(self, name: str, rects: list[Rect], W: int, H: int) -> None:
68
+ self.name, self.rects, self.W, self.H = name, list(rects), W, H
69
+ self.has_pos = any(not r.neg for r in self.rects)
70
+ self._cache: dict[tuple, np.ndarray | None] = {}
71
+
72
+ @property
73
+ def unrestricted(self) -> bool:
74
+ """整段都是全屏(默认组,或者只有空的配置):调用方可以整个跳过遮罩。"""
75
+ return not self.rects
76
+
77
+ def key_at(self, t: float) -> tuple[int, ...]:
78
+ return tuple(i for i, r in enumerate(self.rects) if r.active(t))
79
+
80
+ def mask_for(self, key: tuple[int, ...]) -> np.ndarray | None:
81
+ if key in self._cache:
82
+ return self._cache[key]
83
+ act = [self.rects[i] for i in key]
84
+ pos = [r for r in act if not r.neg]
85
+ neg = [r for r in act if r.neg]
86
+ if not self.has_pos and not neg:
87
+ m = None # 没配正矩形、此刻也没负矩形:全屏
88
+ else:
89
+ m = np.zeros((self.H, self.W), bool)
90
+ if self.has_pos:
91
+ for r in pos:
92
+ x0, y0, x1, y1 = r.pixels(self.W, self.H)
93
+ m[y0:y1, x0:x1] = True
94
+ else:
95
+ m[:] = True # 没配正矩形 = 隐含全屏正矩形,仍扣负矩形
96
+ for r in neg:
97
+ x0, y0, x1, y1 = r.pixels(self.W, self.H)
98
+ m[y0:y1, x0:x1] = False
99
+ if m.all():
100
+ m = None
101
+ self._cache[key] = m
102
+ return m
103
+
104
+ def mask_at(self, t: float) -> np.ndarray | None:
105
+ return self.mask_for(self.key_at(t))
106
+
107
+ def boundaries(self) -> list[float]:
108
+ """范围可能变化的时刻(秒,升序)。只列**真的变了遮罩**的:拆法不同的等价配置不产生切点。"""
109
+ ts = sorted({v for r in self.rects for v in (r.t0, r.t1) if v is not None})
110
+ out = []
111
+ for t in ts:
112
+ before, after = self.mask_for(self.key_at(t - 1e-6)), self.mask_for(self.key_at(t))
113
+ if not _same(before, after):
114
+ out.append(t)
115
+ return out
116
+
117
+ def observable_us(self, box, t0_us: int, t1_us: int) -> int:
118
+ """[t0, t1) 里这个框位**看得见**的时长(µs):按切点分段,**框里有有效像素**的段加起来(方案:
119
+ 常驻度 / UI 占比 / 时间支持的分母用"相关位置允许观察的时间",关闭时段不算文字缺席)。
120
+
121
+ 2026-09-23(Codex 审计 P2):原来只看框中心——带孔的连通框(有孔不拆)中心落在孔里、或区域的中位中心落在两块之间时,
122
+ 明明看得见却算成 0,调用方再 `max(1, ·)` 把占比放大成上百万。"""
123
+ cuts = [int(round(b * 1e6)) for b in self.boundaries() if t0_us < b * 1e6 < t1_us]
124
+ edges = [t0_us, *cuts, t1_us]
125
+ tot = 0
126
+ for a, b in zip(edges, edges[1:]):
127
+ m = self.mask_at((a + b) / 2e6)
128
+ if m is None or box_hits(box, m):
129
+ tot += b - a
130
+ return tot
131
+
132
+ def union_bbox(self) -> tuple[int, int, int, int] | None:
133
+ """整段里**任何时刻**有效像素的外接矩形 `(x0, y0, x1, y1)`(像素,右下开);某时刻全屏 / 从来没有有效像素时返回 None(没得裁)。
134
+ 方案的"遮罩加单个最小外接矩形":一个**静态**的框,盖住每一刻的有效范围——时变配置也对(框里被关掉的部分本来就是涂过的)。"""
135
+ keys = {self.key_at(-1e18)} | {self.key_at(t) for t in self.boundaries()}
136
+ rows = cols = None
137
+ for key in keys:
138
+ m = self.mask_for(key)
139
+ if m is None:
140
+ return None
141
+ r, c = m.any(axis=1), m.any(axis=0)
142
+ rows = r if rows is None else rows | r
143
+ cols = c if cols is None else cols | c
144
+ if rows is None or not rows.any():
145
+ return None
146
+ ys, xs = np.flatnonzero(rows), np.flatnonzero(cols)
147
+ return int(xs[0]), int(ys[0]), int(xs[-1]) + 1, int(ys[-1]) + 1
148
+
149
+ def changed_area(self, t: float) -> np.ndarray | None:
150
+ """切点 t 前后遮罩不同的像素(bool H×W);None = 没变。下游拿它判"哪些事件被这个切点切到"。"""
151
+ before, after = self.mask_for(self.key_at(t - 1e-6)), self.mask_for(self.key_at(t))
152
+ if _same(before, after):
153
+ return None
154
+ full = np.ones((self.H, self.W), bool)
155
+ return (full if before is None else before) != (full if after is None else after)
156
+
157
+
158
+ def take(polys: list, boxes: list, own_of: dict, keep: list[int]) -> tuple[list, list, dict]:
159
+ """按保留的下标 `keep` 同时取多边形、框和子框遮罩(`split_polys` 的三件套是平行的,**必须一起重排**)。"""
160
+ return ([polys[i] for i in keep], [boxes[i] for i in keep],
161
+ {j: own_of[i] for j, i in enumerate(keep) if i in own_of})
162
+
163
+
164
+ def box_hits(box, area: np.ndarray) -> bool:
165
+ """框(至少 1 像素,截到画面内)里有没有 `area` 为真的像素。"""
166
+ H, W = area.shape
167
+ x0, y0 = min(W - 1, max(0, int(box[0]))), min(H - 1, max(0, int(box[1])))
168
+ x1, y1 = max(x0 + 1, min(W, int(box[2]))), max(y0 + 1, min(H, int(box[3])))
169
+ return bool(area[y0:y1, x0:x1].any())
170
+
171
+
172
+ def _same(a: np.ndarray | None, b: np.ndarray | None) -> bool:
173
+ if a is None or b is None:
174
+ return a is None and b is None
175
+ return bool(np.array_equal(a, b))
176
+
177
+
178
+ def fill_rects(mask: np.ndarray) -> list[tuple[int, int, int, int]]:
179
+ """`~mask` 拆成矩形 `(y0, y1, x0, x1)`:先按"行完全相同"分横带,每条带里按列取连续段。**纯函数,守卫直接测。**
180
+ 遮罩都是矩形的并减矩形,拆出来只有几块;一个遮罩只拆一次(`apply_mask` 按对象缓存)。"""
181
+ H = mask.shape[0]
182
+ cuts = np.flatnonzero(np.any(mask[1:] != mask[:-1], axis=1)) + 1
183
+ ys = [0, *cuts.tolist(), H]
184
+ out = []
185
+ for y0, y1 in zip(ys, ys[1:]):
186
+ d = np.diff(np.concatenate(([0], (~mask[y0]).astype(np.int8), [0])))
187
+ out += [(y0, y1, int(a), int(b)) for a, b in zip(np.flatnonzero(d == 1), np.flatnonzero(d == -1))]
188
+ return out
189
+
190
+
191
+ _RECTS: dict[int, tuple[np.ndarray, list]] = {}
192
+
193
+
194
+ def apply_mask(frame: np.ndarray, mask: np.ndarray | None) -> np.ndarray:
195
+ """范围外涂 FILL(**原地**,返回同一个数组)。frame 是 H×W×C 或 H×W,和 mask 同尺寸。
196
+ 按矩形切片赋值、结果和 `frame[~mask] = FILL` 逐位相同——布尔索引在 1080p 上一帧 48 ms,
197
+ 自选范围的一组整体反而比全屏慢 59%(hsr-s2 35.7 对 22.4 s,2026-09-24);切片赋值约 1 ms。"""
198
+ if mask is None:
199
+ return frame
200
+ hit = _RECTS.get(id(mask))
201
+ if hit is None or hit[0] is not mask: # 遮罩对象是 RegionGroup / aux_masker 缓存着的,id 稳定
202
+ hit = _RECTS[id(mask)] = (mask, fill_rects(mask))
203
+ for y0, y1, x0, x1 in hit[1]:
204
+ frame[y0:y1, x0:x1] = FILL
205
+ return frame
206
+
207
+
208
+ def label4(m: np.ndarray) -> tuple[int, np.ndarray]:
209
+ """4 邻接连通域标号(同 `cv2.connectedComponents(connectivity=4)` 的约定:0 = 背景,块从 1 起),**不依赖 cv2**(守卫在没装 cv2 的解释器里跑)。
210
+ 做法:按行 / 列的变化点把遮罩压成小网格——任意两条相邻变化点之间的格子内部必然一致——在网格上 BFS,再展开回像素。
211
+ 范围是轴对齐矩形拼出来的,网格只有几格到几十格。"""
212
+ h, w = m.shape
213
+ yb = np.concatenate(([0], np.flatnonzero(np.any(m[1:] != m[:-1], axis=1)) + 1, [h]))
214
+ xb = np.concatenate(([0], np.flatnonzero(np.any(m[:, 1:] != m[:, :-1], axis=0)) + 1, [w]))
215
+ cell = m[yb[:-1]][:, xb[:-1]]
216
+ cl = np.zeros(cell.shape, np.int32)
217
+ n = 0
218
+ for i0, j0 in zip(*np.nonzero(cell)):
219
+ if cl[i0, j0]:
220
+ continue
221
+ n += 1
222
+ cl[i0, j0] = n
223
+ stack = [(i0, j0)]
224
+ while stack:
225
+ i, j = stack.pop()
226
+ for a, b in ((i - 1, j), (i + 1, j), (i, j - 1), (i, j + 1)):
227
+ if 0 <= a < cell.shape[0] and 0 <= b < cell.shape[1] and cell[a, b] and not cl[a, b]:
228
+ cl[a, b] = n
229
+ stack.append((a, b))
230
+ lab = np.repeat(np.repeat(cl, np.diff(yb), axis=0), np.diff(xb), axis=1)
231
+ return n + 1, lab
232
+
233
+
234
+ def split_box(box, mask: np.ndarray | None) -> list[tuple[list[int], np.ndarray | None]]:
235
+ """一个 det 框按遮罩拆(见文件头)。返回 [(子框, own)]:`own` 是子框大小的 bool,
236
+ **子框里有不属于这一块的有效像素时**才给(rec / 复用取裁剪时要把 ~own 涂灰),否则 None。
237
+ mask 为 None(此刻全屏)原样返回。"""
238
+ bx0, by0, bx1, by1 = (int(v) for v in box)
239
+ if mask is None:
240
+ return [([bx0, by0, bx1, by1], None)]
241
+ H, W = mask.shape
242
+ # det 框可能略出画面:负坐标切片会从另一头绕回来,先截到画面里再切
243
+ x0, y0, x1, y1 = max(0, bx0), max(0, by0), min(W, bx1), min(H, by1)
244
+ m = mask[y0:y1, x0:x1]
245
+ if m.size and m.all():
246
+ return [([bx0, by0, bx1, by1], None)] # 整框都在范围里:原样(不拿截过的框替换)
247
+ if not m.any():
248
+ return []
249
+ n, lab = label4(m)
250
+ out = []
251
+ for k in range(1, n):
252
+ ys, xs = np.nonzero(lab == k)
253
+ sx0, sy0, sx1, sy1 = int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
254
+ sub = lab[sy0:sy1, sx0:sx1]
255
+ own = sub == k
256
+ foreign = (sub != k) & (sub != 0)
257
+ out.append(([x0 + sx0, y0 + sy0, x0 + sx1, y0 + sy1], own if foreign.any() else None))
258
+ out.sort(key=lambda s: (s[0][0], s[0][1]))
259
+ return out
260
+
261
+
262
+ def masked_crop(frame: np.ndarray, box, own: np.ndarray | None) -> np.ndarray:
263
+ """子框的裁剪:`own` 给了就把不属于这一块的像素涂灰(返回副本,不写坏源帧)。"""
264
+ x0, y0, x1, y1 = box
265
+ c = frame[y0:y1, x0:x1]
266
+ if own is None:
267
+ return c
268
+ c = c.copy()
269
+ c[~own] = FILL
270
+ return c
271
+
272
+
273
+ # ---------- 接进管线用的几件(run_ocr2 / build_tracks) ----------
274
+
275
+ def group_spec(path: str | Path, name: str = "") -> dict:
276
+ """配置文件里挑出**一个组**的规格(规范化的 dict,进 `_meta.config` 当复用判据用:同内容换路径算同一条臂,
277
+ 改了文件内容就对不上)。文件里只有一个组时 `name` 可以不给;多个组必须给——每组是一条独立 OCR 线(方案)。"""
278
+ spec = json.loads(Path(path).read_text(encoding="utf-8"))
279
+ groups = spec.get("groups") or [{"name": "all", "rects": []}] # 没有组 = 一个隐含的全屏组(方案表第一行)
280
+ names = [g.get("name") for g in groups]
281
+ if any(not n for n in names) or len(set(names)) != len(names):
282
+ raise ValueError(f"{path}:每个组要有唯一的 name(产物按组分文件):{names}")
283
+ if not name:
284
+ if len(groups) != 1:
285
+ raise ValueError(f"{path} 里有 {len(groups)} 个组 {names},要用 --region-group 挑一个")
286
+ g = groups[0]
287
+ else:
288
+ hit = [g for g in groups if g.get("name") == name]
289
+ if not hit:
290
+ raise ValueError(f"{path} 里没有组 {name!r}(有 {names})")
291
+ g = hit[0]
292
+ rects = [parse_rect(r) for r in g.get("rects") or ()] # 早校验
293
+ return {"name": g["name"], "rects": [{"box": [r.x0, r.y0, r.x1, r.y1], "neg": r.neg, "t": [r.t0, r.t1]}
294
+ for r in rects]}
295
+
296
+
297
+ def from_spec(spec: dict, W: int, H: int) -> RegionGroup:
298
+ """`group_spec` 的 dict -> RegionGroup(run_ocr2 起组、build_tracks 从 obs 的 `_meta.regions` 重建都走它)。"""
299
+ return RegionGroup(spec["name"], [parse_rect(r) for r in spec.get("rects") or ()], W, H)
300
+
301
+
302
+ class MaskedSource:
303
+ """包一层取帧来源:每帧按它的真实 pts 涂掉范围外的像素(**det 之前**,方案),其余属性原样转给里面那个。
304
+ 帧是来源每次新给的数组(run_ocr2 的 det_stream 注释),原地涂不会写坏别人的东西。"""
305
+
306
+ def __init__(self, source, group: RegionGroup) -> None:
307
+ self._src, self._g = source, group
308
+
309
+ def __iter__(self):
310
+ for idx, t_sec, frame in self._src:
311
+ yield idx, t_sec, apply_mask(frame, self._g.mask_at(t_sec))
312
+
313
+ def __getattr__(self, k):
314
+ return getattr(self._src, k)
315
+
316
+
317
+ def aux_masker(group: RegionGroup, Ws: int, Hs: int, src_fps: float):
318
+ """辅助流(缩小的灰度帧,edge_refine 的帧环)用的遮罩:同一个范围按比例缩到辅助流尺寸(最近邻),按帧号换算时间。
319
+ ⚠ 辅助流只有帧号没有逐帧 pts(edge_refine 文件头),时间按 `帧号 / 名义帧率` 算——切点附近可能差一帧。
320
+ 辅助流的帧可能是只读的(`np.frombuffer`),要涂就先拷。"""
321
+ ry = np.minimum((np.arange(Hs) * group.H) // Hs, group.H - 1) # 最近邻取样(不依赖 cv2)
322
+ rx = np.minimum((np.arange(Ws) * group.W) // Ws, group.W - 1)
323
+ cache: dict[tuple, np.ndarray | None] = {}
324
+
325
+ def pre(idx: int, frame: np.ndarray) -> np.ndarray:
326
+ key = group.key_at(idx / src_fps)
327
+ if key not in cache:
328
+ m = group.mask_for(key)
329
+ cache[key] = None if m is None else m[ry][:, rx]
330
+ m = cache[key]
331
+ if m is None:
332
+ return frame
333
+ return apply_mask(frame.copy(), m)
334
+ return pre
335
+
336
+
337
+ class CroppedDet:
338
+ """`--region-crop`(方案的对照臂):det 只看组范围的静态外接矩形那一块(`RegionGroup.union_bbox`;范围外本来就涂过),
339
+ 多边形平移回原片坐标。和 `FastDet` 同接口(`predict` / `predict_batch`)。
340
+ ⚠ **会改 det 的输入尺寸**(缩放策略按边长走,小块可能被放大)——框会变,所以它进复用判据、是一条要单独对质量和速度的臂,不是调度旋钮。"""
341
+
342
+ def __init__(self, inner, bbox: tuple[int, int, int, int]) -> None:
343
+ self.inner, self.bbox = inner, bbox
344
+ self.off = np.array([bbox[0], bbox[1]])
345
+
346
+ def _crop(self, img: np.ndarray) -> np.ndarray:
347
+ x0, y0, x1, y1 = self.bbox
348
+ return np.ascontiguousarray(img[y0:y1, x0:x1])
349
+
350
+ def _shift(self, polys):
351
+ return [np.asarray(p) + self.off for p in polys]
352
+
353
+ def predict(self, img: np.ndarray):
354
+ polys, scores = self.inner.predict(self._crop(img))
355
+ return self._shift(polys), scores
356
+
357
+ def predict_batch(self, imgs: list) -> list[tuple]:
358
+ return [(self._shift(p), s) for p, s in self.inner.predict_batch([self._crop(im) for im in imgs])]
359
+
360
+ # 流水的两半(`FastDet.infer_batch` / `post_batch`):裁在前半、平移在后半
361
+ def infer_batch(self, imgs: list) -> tuple:
362
+ return self.inner.infer_batch([self._crop(im) for im in imgs])
363
+
364
+ def post_batch(self, state: tuple) -> list[tuple]:
365
+ return [(self._shift(p), s) for p, s in self.inner.post_batch(state)]
366
+
367
+
368
+ def split_polys(polys, mask: np.ndarray | None):
369
+ """det 的多边形按遮罩拆(`split_box`)。返回 (新多边形表, 新框表, {新下标: own})。
370
+ 框没被遮到的原样保留多边形;裁过 / 拆过的换成子框的矩形(几何裁剪不是模型的新检测,方案第 2 条)。"""
371
+ boxes_in = [[round(min(float(p[0]) for p in q)), round(min(float(p[1]) for p in q)), # 口径同 run_ocr2.poly_to_box
372
+ round(max(float(p[0]) for p in q)), round(max(float(p[1]) for p in q))] for q in polys]
373
+ if mask is None:
374
+ return list(polys), boxes_in, {}
375
+ out_p, out_b, own_of = [], [], {}
376
+ for q, b in zip(polys, boxes_in):
377
+ for sb, own in split_box(b, mask):
378
+ out_p.append(q if sb == b else [[sb[0], sb[1]], [sb[2], sb[1]], [sb[2], sb[3]], [sb[0], sb[3]]])
379
+ if own is not None:
380
+ own_of[len(out_b)] = own
381
+ out_b.append(sb)
382
+ return out_p, out_b, own_of
383
+
384
+
385
+ def cuts_us(group: RegionGroup) -> list[tuple[int, np.ndarray]]:
386
+ """时间关闭段的切点(µs)+ 那一刻遮罩变了的像素:下游判"哪些事件被这个切点切到"(build_tracks.build_runs)。"""
387
+ return [(int(round(t * 1e6)), group.changed_area(t)) for t in group.boundaries()]