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,593 @@
1
+ """复用判据第二版(`run_ocr2 --reuse-v2`,**2026-09-17 起默认开**,`--no-reuse-v2` 退回旧判据):reuse-budget 计划的落地配方。
2
+
3
+ 和默认判据(上一帧同位置 IoU >= 0.7、整框 ncc >= --reuse-corr、连续沿用 --refresh-every 帧强制重读)比,多四样:
4
+ 1. **按手里的文本分门**:上次真读到的文本含任何数字 -> 门是 --reuse-corr-digit(0.98),其余 -> --reuse-corr(v2 下默认 0.8)。
5
+ 2. **框宽门**:|本帧框宽 - 链上框宽| > --reuse-dw 就不沿用(打字机在长字、det 框在变宽,像素判据看不见)。
6
+ 3. **链首跨空档**:上一帧没有 IoU >= 0.7 的框时,依次试上一帧 IoU >= --reuse-link-iou 的框、再上一帧同位置的框、
7
+ 最近 --reuse-mem 个采样点内在同一位置结束的链;像素门在**本帧的框**上对那一帧算,门 --reuse-link-corr。
8
+ **拦下就当新链、不回补旧链**(回补旧链尾巴会把收益吃光)。
9
+ 4. **回补**:沿用过的帧把送进 rec 的那块裁剪留在缓存里(全局 --reuse-cache-mb,超了当场真读——
10
+ **预留和放行是一个原子动作**,见 decide 里那一段);真读出来的文本 != 手里的,就在缓存里递归二分
11
+ 把变化点找出来(端点相同就剪枝),改掉已缓冲、还没落盘的行。
12
+ 所以 obs 行**延迟落盘**(refresh + mem + 2 个采样点),顺序不变。
13
+ 回补一旦把变化点前移,这一段的**在线回抠证据就作废**(`edge_stale`,见 commit 里那一段)。
14
+
15
+ 产物格式不变:沿用的行仍是 `reused: true`;跨空档沿用的多一个 `link` 字段;回补时被真读的那几帧变成非沿用行。
16
+ 这些参数只在 `--reuse-v2` 打开时才进 `_meta.config`(flowocr.extract.ocr_args 的 OPT_IN);`--reuse-corr` / `--refresh-every` 的默认跟着它走(`ocr_args.resolve`)。
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import itertools
21
+ import json
22
+ import os
23
+ import re
24
+ import time
25
+
26
+ import numpy as np
27
+
28
+ from flowocr.extract import framesource # `pts_frame`:obs 的 `frame` 认 pts(判据只有那一份)
29
+
30
+ _PUNCT = re.compile(r"[\W_]+")
31
+
32
+
33
+ def same_text(a: str, b: str) -> bool:
34
+ """回补里"文本变没变"的判据:**去掉标点和空白之后**比(2026-09-24,ocr-regions 计划末尾)。
35
+ 框抖一下 rec 就可能掉一个 `、`:`…如く、` 重读成 `…如く`,原来逐字比 -> 判"变化点前移" -> 这一段的在线证据作废、
36
+ 那一句不回抠(yuka-f1 遮罩臂首字晚 13 帧)。两边都只剩标点时仍逐字比(`……!` 和 `??` 是两句)。
37
+ ⚠ 去空白这一半只对 CJK 口径成立:拉丁文里 `no where` 和 `nowhere` 会被判成同一句(本项目语料是日 / 中文,暂不区分)。"""
38
+ na, nb = _PUNCT.sub("", a), _PUNCT.sub("", b)
39
+ return na == nb if (na or nb) else a == b
40
+
41
+ _UID = itertools.count(1)
42
+
43
+
44
+ def iou(a: list[int], b: list[int]) -> float:
45
+ x0, y0 = max(a[0], b[0]), max(a[1], b[1])
46
+ x1, y1 = min(a[2], b[2]), min(a[3], b[3])
47
+ if x1 <= x0 or y1 <= y0:
48
+ return 0.0
49
+ inter = (x1 - x0) * (y1 - y0)
50
+ return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter)
51
+
52
+
53
+ def hasdigit(t: str) -> bool:
54
+ return any(ch.isdigit() for ch in t)
55
+
56
+
57
+ class Chain:
58
+ __slots__ = ("box", "text", "conf", "age", "last_seq", "pending", "cache_bytes", "ended_at", "uid", "last_row",
59
+ "last_idx", "inflight", "confirmed", "read_w", "severed")
60
+
61
+ def __init__(self, box, text, conf, seq):
62
+ self.box, self.text, self.conf, self.age, self.last_seq = box, text, conf, 0, seq
63
+ self.pending: list[list] = [] # [seq, row(dict), crop(np.ndarray)],从上次真读起沿用过的帧
64
+ self.cache_bytes = 0
65
+ self.ended_at: int | None = None
66
+ self.uid = next(_UID) # 两遍合一遍(edge_refine)按它认链:id() 会被回收复用
67
+ self.last_row: dict | None = None
68
+ self.last_idx = -1
69
+ self.inflight = 0
70
+ """这条链上**还没结算的真读**有几个(`--rec-window` 的窗口里才会大于 0)。见 `text_pending`。
71
+
72
+ 2026-09-23(Codex 审计 P1):原来是一个布尔,结算**任何一个**读就清掉——W=4 下同一条链连着三帧都真读时,
73
+ 最老那个结算完、后两个还在途,链却已经是"文本已知、已证实",下一帧按 0.8 放行了。计数才对得上"有结果待返回"。"""
74
+ self.severed = False
75
+ """自选范围的时间切点切到了这条链(`ReuseV2.sever`):之后不许再被接上(owner:"时间关闭段不可续链")。"""
76
+ self.confirmed = False
77
+ """这条链的文本**被连续两次真读证实过**(后一次真读和手里的文本逐字相同)。没证实之前像素门走严格门(`--reuse-confirm`)。
78
+
79
+ 为什么(2026-09-22,decode-buffer):打字机的中间态被真读之后,句尾再长出一两个字只占整框很小的像素份额,
80
+ 整框相关系数 0.94 过得了非数字的 0.8 门,于是**半截读数被一路沿用到字消失**(yuka-f5 14954 s 缺句尾 `も`,
81
+ 6 秒里没有一次真读)。文字刚出现、还没被第二次读证实的那段时间正是打字机在长的时候,这时只认 0.98 的严门。
82
+ 证实之后照旧 0.8——长期稳定的字背景一动就要重读,那是 reuse-v2 当初放宽的理由,不动它。"""
83
+ self.read_w: int | None = None
84
+ """最近一次**真读**时这个框的宽(`--reuse-dw-anchor`)。宽度门原来和上一个采样点的框比(`chain.box` 每帧都跟着推),
85
+ 字从面板边缘一点点露出来时每一步只挪 2~4 px,永远过不了 `--reuse-dw 8`,而从真读到现在已经累计宽了 10 px
86
+ (wuwa-s2 65~69 s `白銀みさ` → `白銀みさき`,decode-buffer)。"""
87
+
88
+ @property
89
+ def text_pending(self) -> bool:
90
+ """**还有真读在途**(策略 B,inference-runtime 计划):像素门因此走严格门,判决不依赖未知文本。"""
91
+ return self.inflight > 0
92
+
93
+
94
+ class ReuseV2:
95
+ def __init__(self, args, corr_fn, estimate_shift_fn, rec_one):
96
+ self.src_fps = 0.0 # run_ocr2 起完就填(obs 的 `frame` 认 pts 要用)
97
+ self.a = args
98
+ self.corr, self.estimate_shift = corr_fn, estimate_shift_fn
99
+ self.rec_one = rec_one # crop -> (text, conf),回补用;调用方自己数 rec
100
+ self.rec_many = None # [crop] -> [(text, conf)];给了就**按层**发回补(见 _recover)
101
+ self.edge = None # 两遍合一遍:edge_refine.EdgeRefiner(--refine-fused 时由 run_ocr2 挂上)
102
+ self.live: list[Chain] = []
103
+ self.ended: list[Chain] = []
104
+ self.grays: dict[int, np.ndarray] = {} # 采样点 -> 灰度帧(uint8,`_crop` 取出时再转 float32)
105
+ self.buf: dict[int, list[dict]] = {} # 采样点 -> 该帧的行(延迟落盘)
106
+ self.cache = 0
107
+ self.reserved: dict[int, int] = {} # 本帧放行沿用的框 -> 已经**预留**的缓存字节(见 decide)
108
+ self.cache_cap = int(args.reuse_cache_mb * (1 << 20))
109
+ self.horizon = (args.refresh_every or 0) + args.reuse_mem + 2
110
+ self.seq = -1
111
+ self.stats = {"reuse_prev": 0, "reuse_moved": 0, "reuse_assoc": 0, "reuse_gap1": 0, "reuse_memory": 0,
112
+ "recover_reads": 0, "recover_events": 0, "cache_forced": 0, "cache_peak": 0,
113
+ "cache_overrun": 0, "edge_stale": 0,
114
+ "gate_digit": 0, "gate_unconfirmed": 0, "gate_dw": 0, "gate_pix": 0, "refresh": 0}
115
+ # `decide` / `commit` 内部的分段计时(进 `_meta.reuse_v2_sec`)。**先量再挪**:
116
+ # "把 decide / commit 挪出主线程"之前要知道里头到底是谁占时间——这个项目在"凭印象归因"上栽过两次
117
+ # (docs/dev-guide/pitfalls.md「优化之前先 profile」)。`gate` 里就是逐框那次相关系数,是搬得动的那一块。
118
+ self.sec = {"match": 0.0, "gate": 0.0, "rows": 0.0, "pending": 0.0, "recover": 0.0,
119
+ "edge": 0.0, "flush": 0.0}
120
+ self.trace: list | None = None
121
+ """`run_ocr2 --trace` 时挂上一个 list:**逐框记下真实的因果**——链的 uid、链接类型、
122
+ 拦下它的是哪道门、相关系数、手里的文本含不含数字、以及最终是沿用还是真读。
123
+
124
+ 为什么要它(2026-09-18 审计第 5 条):窗口延迟读的收益原来是拿 **obs** 模拟的,而 obs 里
125
+ 没有链 id(只能用框坐标近似)、没有门的类型、而且**文本已经是回补之后的**——
126
+ 审计指出这几点都会让模拟偏。有了这份轨迹就能**按因果顺序重放**,不用再近似。
127
+ ⚠ 只记、不改判决;`--trace` 在 `NON_PRODUCT` 里,不进复用判据。"""
128
+
129
+ @staticmethod
130
+ def _crop(gray: np.ndarray, box: list[int]):
131
+ """灰度帧上按框裁(同 run_ocr2.gray_crop 的尺寸门)。存的是 uint8、取出转 float32:
132
+ 和存 float32 整帧**逐位相同**(cvtColor 出来就是 uint8,astype 是精确的),而 `grays` 要留
133
+ `--reuse-mem + 2` 帧,1080p 下 8.3 MB/帧 × 18 × 3 worker = 450 MB,4K 上四倍——
134
+ 这和"缓存上限让内存与分辨率无关"的设计目标相抵(2026-09-17 审计)。"""
135
+ h, w = gray.shape[:2]
136
+ x0, y0, x1, y1 = max(0, box[0]), max(0, box[1]), min(w, box[2]), min(h, box[3])
137
+ if x1 - x0 < 6 or y1 - y0 < 6:
138
+ return None
139
+ return gray[y0:y1, x0:x1].astype(np.float32)
140
+
141
+ def crop_bytes(self, shape, box: list[int]) -> int:
142
+ """回补要缓存的那块裁剪有多少字节,**口径同 run_ocr2.rec_crop 但按上界算**
143
+ (外扩按 `--rec-pad-x/y` 满算、不管邻框和 std 门会不会把它砍掉),所以 commit 里必然放得下。"""
144
+ h, w = shape[0], shape[1]
145
+ bh = max(1, box[3] - box[1])
146
+ px, py = int(self.a.rec_pad_x * bh), int(self.a.rec_pad_y * bh)
147
+ y0, y1 = max(0, box[1] - py), min(h, box[3] + py)
148
+ x0, x1 = max(0, box[0] - px), min(w, box[2] + px)
149
+ return max(0, y1 - y0) * max(0, x1 - x0) * 3
150
+
151
+ # ---- 每帧第一步:决定哪些框沿用、哪些送 rec ----
152
+ def _match(self, seq: int, boxes, live, ended) -> tuple[dict, tuple[int, int]]:
153
+ """返回 (框下标 -> (链, 链接类型, 参照采样点, 参照框), 整帧位移)。**decide 和跨空档接链共用这一份。**
154
+
155
+ **一帧内一条链只能被一个框认领,匹配分高者得**(2026-09-16 实跑排障):链是共享的可变状态,
156
+ 两个框同一帧认领同一条链时,后处理的那个真读会把链上的文本改成自己的——gi-s1 里下方那块名牌
157
+ 经位移匹配(整列上移 96 px)接到上方名牌的链上,把 `サンドローネ` 改成了 `シャルロット`,再沿用 3 s。
158
+ 默认逻辑没有这个问题(prev 是不可变的列表,真读另起一条)。先给每个框算候选,再按分数排序独占分配。
159
+ """
160
+ a = self.a
161
+ shift = self.estimate_shift([c.box for c in live], boxes) if live else (0, 0)
162
+ cand = [] # (score, i, chain, link, ref_seq, ref_box)
163
+ for i, b in enumerate(boxes):
164
+ if not live:
165
+ continue
166
+ b_back = [b[0] - shift[0], b[1] - shift[1], b[2] - shift[0], b[3] - shift[1]]
167
+ for c in live:
168
+ s_static, s_moved = iou(b, c.box), iou(b_back, c.box)
169
+ if max(s_static, s_moved) >= a.reuse_iou:
170
+ moved = s_moved > s_static
171
+ # 静止匹配优先于位移匹配(同分时静止的排前面)
172
+ cand.append((max(s_static, s_moved) + (0.0 if moved else 1.0), i, c,
173
+ "moved" if moved else "prev", seq - 1, (c.box if moved else b)))
174
+ elif s_static >= a.reuse_link_iou:
175
+ cand.append((s_static, i, c, "assoc", seq - 1, b))
176
+ cand.sort(key=lambda x: -x[0])
177
+ pick: dict[int, tuple] = {}
178
+ taken: set[int] = set()
179
+ for _, i, c, link, ref_seq, ref_box in cand:
180
+ if i in pick or id(c) in taken:
181
+ continue
182
+ pick[i] = (c, link, ref_seq, ref_box)
183
+ taken.add(id(c))
184
+ for i, b in enumerate(boxes):
185
+ if i in pick:
186
+ continue
187
+ for c in ended: # 最近结束的在后面;先找 k-2(gap1)再找更早的(memory)
188
+ if id(c) in taken or c.last_seq not in self.grays:
189
+ continue
190
+ if iou(b, c.box) >= a.reuse_iou:
191
+ pick[i] = (c, ("gap1" if c.last_seq == seq - 2 else "memory"), c.last_seq, b)
192
+ taken.add(id(c))
193
+ break
194
+ return pick, shift # shift 只给 FLOWOCR_REUSE_DEBUG 那一行用(漏了它就是个只在排障时发作的 NameError)
195
+
196
+ def _gate(self, chain, link, b, gray, ref_gray, ref_box,
197
+ pre_corr: float | None = None) -> tuple[str, float]:
198
+ """这个框能不能沿用:返回 (""=能沿用 / 拦下它的那道门的 stats 键, 算出来的相关系数)。
199
+
200
+ **decide 和跨空档接链共用这一份。** 不碰 stats、不碰缓存预算——那两样只有 decide 该做。
201
+
202
+ (`age_add` / `pixel` 两个参数是投机用的,`--rec-lookahead` 一删就没有调用方了,
203
+ 跟着删掉——留着死参数比留着死函数更难发现,2026-09-19 复审第 4 条。)
204
+ """
205
+ a = self.a
206
+ if a.refresh_every and chain.age + 1 >= a.refresh_every:
207
+ return "refresh", 0.0
208
+ # 宽度变化量**和真读那一帧比**(`--reuse-dw-anchor`,默认开):和上一个采样点比时,一点点长宽永远过门(见 Chain.read_w)
209
+ ref_w = chain.read_w if (a.reuse_dw_anchor and chain.read_w is not None) else chain.box[2] - chain.box[0]
210
+ if abs((b[2] - b[0]) - ref_w) > a.reuse_dw:
211
+ return "gate_dw", 0.0
212
+ if pre_corr is not None:
213
+ c_val = pre_corr # det 那一层预算的(`--pregate`),同函数同输入,值按位相同
214
+ else:
215
+ c_val = (self.corr(self._crop(gray, b), self._crop(ref_gray, ref_box))
216
+ if ref_gray is not None else -1.0)
217
+ # **文本待定 = 一律严格门**(策略 B):这条链最近一次真读还没回来,`hasdigit(chain.text)` 问的是
218
+ # **旧**文本的数字性,拿它选门就等于让判决依赖一个未知量。取严格的那一档,判决从此是确定的——
219
+ # 代价是多读几次(实测 +2.6%~8.9% 的读),方向是"更新的文本"
220
+ digit = hasdigit(chain.text) or chain.text_pending
221
+ # **没被第二次真读证实的文本也走严门**(`--reuse-confirm`):打字机中间态的句尾在长时,整框相关系数看不出来
222
+ unconfirmed = a.reuse_confirm and not chain.confirmed
223
+ thr = a.reuse_corr_digit if (digit or unconfirmed) else a.reuse_corr
224
+ if link == "moved":
225
+ thr = max(thr, a.reuse_moved_corr)
226
+ elif link != "prev":
227
+ thr = max(thr, a.reuse_link_corr)
228
+ if c_val < thr:
229
+ return ("gate_digit" if digit else "gate_unconfirmed" if unconfirmed else "gate_pix"), c_val
230
+ return "", c_val
231
+
232
+ def decide(self, seq: int, gray: np.ndarray, boxes: list[list[int]], pre: dict | None = None):
233
+ """返回 (decided, todo, assign):decided = [(i, text, conf, age)],todo = [i],
234
+ assign[i] = (链, 链接类型) 给 commit 用(真读的框也可能挂在链上)。
235
+
236
+ `pre`:`run_ocr2.pregate_of` 在 det 那一层预先算好的**静态相关系数**(框 -> 值,`--pregate`)。
237
+ 只在"参照就是上一个采样点的同一个框"时能用(`prev` / `assoc`),值按位相同——所以产物不变。"""
238
+ a = self.a
239
+ self.seq = seq
240
+ self.grays[seq] = gray
241
+ for k in [k for k in self.grays if k < seq - a.reuse_mem - 2]:
242
+ del self.grays[k]
243
+ self._release_reservations()
244
+ decided, todo, assign = [], [], {}
245
+ self.last_corr: dict[int, float] = {} # 这一帧算过的相关系数(--record-corr 取它,见下面那处)
246
+ live = self.live
247
+ dbg = os.environ.get("FLOWOCR_REUSE_DEBUG") # "x0,y0,x1,y1":打出落在这个框上的决定(排障用,不进 config)
248
+ dbg_box = [int(x) for x in dbg.split(",")] if dbg else None
249
+ t_m = time.perf_counter()
250
+ pick, shift = self._match(seq, boxes, live, self.ended)
251
+ pick = {i: p for i, p in pick.items() if not p[0].severed} # 切点切过的链不许续(`sever`)
252
+ self.sec["match"] += time.perf_counter() - t_m
253
+ for i, b in enumerate(boxes):
254
+ chain, link, ref_seq, ref_box = pick.get(i, (None, None, None, None))
255
+ if dbg_box is not None and iou(b, dbg_box) >= 0.5:
256
+ print(f"[dbg] seq {seq} box {b} shift {shift} -> {link} chain.text={chain.text if chain else None!r} "
257
+ f"chain.box={chain.box if chain else None} age={chain.age if chain else None} "
258
+ f"live={[(c.text, c.box) for c in live if iou(b, c.box) >= 0.3 or iou([b[0]-shift[0], b[1]-shift[1], b[2]-shift[0], b[3]-shift[1]], c.box) >= 0.3]}", flush=True)
259
+ if chain is None:
260
+ if self.trace is not None:
261
+ self.trace.append({"seq": seq, "box": list(b), "chain": None, "link": None,
262
+ "gate": "new", "corr": None, "digit": None, "read": True})
263
+ todo.append(i)
264
+ continue
265
+ assign[i] = (chain, link)
266
+ t_g = time.perf_counter()
267
+ # 预算好的那一格只在"参照 = 上一个采样点的同一个框"时对得上(静止匹配,占沿用的 97%)
268
+ pc = (pre.get(tuple(b)) if pre is not None and ref_seq == seq - 1 and ref_box == b else None)
269
+ gate, c_val = self._gate(chain, link, b, gray, self.grays.get(ref_seq), ref_box, pre_corr=pc)
270
+ self.sec["gate"] += time.perf_counter() - t_g
271
+ # `--record-corr` 要的就是这个值。**以前它在 v2 路径上是静默空转的**(2026-09-18 发现):
272
+ # `corr_of` 只有老的 prev 路径会填,而 v2 早就是默认——旋钮给了、产物里一个 `corr` 都没有。
273
+ # 只记"本来就算了的",不多算一次、也不改判决。
274
+ self.last_corr[i] = c_val
275
+ tr_row = None
276
+ if self.trace is not None:
277
+ tr_row = {"seq": seq, "box": list(b), "chain": chain.uid, "link": link,
278
+ "gate": gate or "", "corr": round(c_val, 4),
279
+ "digit": hasdigit(chain.text), "read": bool(gate)}
280
+ self.trace.append(tr_row)
281
+ if gate:
282
+ self.stats[gate] += 1
283
+ todo.append(i)
284
+ continue
285
+ # 缓存预算:超了就当场真读(cache_budget.py 量的就是这个语义:只多几次 rec,不换质量)。
286
+ # **预留和放行必须是一个原子动作**(2026-09-17 审计的最小复现):第一版每个框各自看同一个余额、
287
+ # 又都不预留,只剩 400 B 时同一帧两个各 300 B 的框**双双获准沿用**,commit 里第二个的裁剪放不下
288
+ # 就被丢掉、行却已经是 reused——下一次发现变化时它回补不了,错文本留在产物里,
289
+ # 而 `cache_forced` 记的是"改成真读",两种语义混在一个计数器上。现在预留按 `crop_bytes` 的上界算。
290
+ est = self.crop_bytes(gray.shape, b)
291
+ if self.cache + est > self.cache_cap:
292
+ self.stats["cache_forced"] += 1
293
+ if self.trace is not None:
294
+ self.trace[-1].update(gate="cache_forced", read=True)
295
+ todo.append(i)
296
+ continue
297
+ self.cache += est
298
+ self.reserved[i] = est
299
+ self.stats["cache_peak"] = max(self.stats["cache_peak"], self.cache)
300
+ decided.append((i, chain.text, chain.conf, chain.age + 1))
301
+ return decided, todo, assign
302
+
303
+ def sever(self, area) -> int:
304
+ """自选范围的时间切点(`regions.cuts_us`):框和 `area`(H×W bool,那一刻遮罩变了的像素)有交的链,
305
+ 活链和记着的已结束链都**不许再被接上**——下一帧同一位置按新链真读,旧链在 commit 里照常结束(结束事件照发)。
306
+ owner 定的"时间关闭段不可续链"在提取层的落点(ocr-regions 计划;Codex 审计 P1-2)。返回切到的链数。"""
307
+ n = 0
308
+ for c in (*self.live, *self.ended):
309
+ x0, y0, x1, y1 = (int(v) for v in c.box)
310
+ if not c.severed and area[max(0, y0):max(y0 + 1, y1), max(0, x0):max(x0 + 1, x1)].any():
311
+ c.severed = True
312
+ n += 1
313
+ return n
314
+
315
+ def _release_reservations(self) -> None:
316
+ """没走到 commit 的预留(正常路径上是空的)还给预算。"""
317
+ for i in list(self.reserved):
318
+ self.cache -= self.reserved.pop(i)
319
+
320
+ # ---- 每帧第二步:建行、更新链、落盘。**拆成两半**(2026-09-19,inference-runtime 计划组批那节的 B)----
321
+ # stage1 = 和"读回来的文本"无关的那一半(建行骨架、更新 box/age、占缓存、算哪些链结束);
322
+ # stage2 = 要文本的那一半(回填行、回补、给 edge 事件定 changed/same、推落盘水位)。
323
+ # **判据只有一份**:`commit` 就是 stage1 + 马上 stage2,窗口模式只是把 stage2 推迟到窗口结算。
324
+ def commit(self, seq: int, idx: int, t_us: int, boxes, polys, decided, read_set: set[int],
325
+ assign: dict, rec_crop, corr_of: dict, record_corr: bool, fh) -> int:
326
+ """decided 含沿用的和真读的(真读的 age=0)。read_set = 真送进 rec 且拿到结果的框。返回回补用掉的 rec 次数。"""
327
+ st = self.commit_stage1(seq, idx, t_us, boxes, polys, decided, read_set, assign,
328
+ rec_crop, corr_of, record_corr)
329
+ texts = {i: (text, score) for i, text, score, _age in decided if i in read_set}
330
+ return self.commit_stage2(st, texts, fh)
331
+
332
+ def commit_stage1(self, seq: int, idx: int, t_us: int, boxes, polys, decided,
333
+ read_set: set[int], assign: dict, rec_crop, corr_of: dict,
334
+ record_corr: bool) -> dict:
335
+ """不需要"读回来的文本"的那一半。返回一个 state,交给 `commit_stage2`。
336
+
337
+ 真读的框在这里只建**行骨架**(`text` 先空着)、把链的 `box`/`age`/`last_seq` 推到这一帧,
338
+ 并把链标成 `text_pending`——窗口模式下接下来几帧的像素门会因此走**严格门**
339
+ (判决从此不依赖未知文本,inference-runtime 计划的策略 B)。
340
+ """
341
+ a = self.a
342
+ rows: list[dict] = []
343
+ new_live: list[Chain] = []
344
+ touched: set[int] = set()
345
+ events: list[list] = [] # (链 uid, 行, 框, 文本, kind);真读那几条 kind 先留空,stage2 填
346
+ deferred: list[dict] = [] # 真读的框:等文本回来才做得了的那一半
347
+ inherit: list[tuple] = [] # 沿用的框:(行, 链, 事件)——文本由 stage2 从链上取
348
+ t_r = time.perf_counter()
349
+ for i, text, score, age in sorted(decided):
350
+ b = boxes[i]
351
+ reused = i not in read_set
352
+ chain, link = assign.get(i, (None, None))
353
+ # ⚠ **写 obs 的第二处**(主路在 run_ocr2)。`frame` 认 pts,判据只有 framesource 那一份——
354
+ # 2026-09-20 第一版只改了主路,缺口之后一半的行还是旧编号,而没有任何一层报错。
355
+ row = {"frame": framesource.pts_frame(t_us, idx, self.src_fps), "t_us": t_us, "box": b,
356
+ "poly": [[round(float(p[0])), round(float(p[1]))] for p in polys[i]],
357
+ "text": text, "conf": round(score, 4)}
358
+ if reused:
359
+ row["reused"] = True
360
+ if link != "prev":
361
+ row["link"] = link
362
+ if record_corr and i in corr_of:
363
+ row["corr"] = round(corr_of[i], 4)
364
+ rows.append(row)
365
+ if reused:
366
+ # 跨空档接回旧链(gap1 / memory):框消失过又出现,对回抠是一次**新出现**(build_tracks 在这里起新 run)
367
+ ev = [chain.uid, row, b, text, "new" if link in ("gap1", "memory") else "same"]
368
+ events.append(ev)
369
+ inherit.append((row, chain, ev))
370
+ chain.last_row, chain.last_idx = row, idx
371
+ # 缓存裁剪给回补用。预算已经在 decide 里**预留**过(`crop_bytes` 是上界),所以这里必然放得下;
372
+ # `cache_overrun` 是"上界算错了"的报警,守卫钉着它必须是 0——绝不能再走回"放不下就不缓存、
373
+ # 行却仍是 reused"那条路(那等于承诺了可回补又偷偷收回)
374
+ t_p = time.perf_counter()
375
+ crop = rec_crop(i)
376
+ nb = int(crop.nbytes)
377
+ res = self.reserved.pop(i, 0)
378
+ self.cache -= res
379
+ if self.cache + nb <= self.cache_cap:
380
+ chain.pending.append([seq, row, crop.copy()]) # 切片持有整帧,必须拷
381
+ chain.cache_bytes += nb
382
+ self.cache += nb
383
+ self.sec["pending"] += time.perf_counter() - t_p
384
+ self.stats["cache_peak"] = max(self.stats["cache_peak"], self.cache)
385
+ else:
386
+ self.stats["cache_overrun"] += 1
387
+ chain.age = age
388
+ chain.box, chain.last_seq = b, seq
389
+ self.stats[f"reuse_{link}"] += 1
390
+ if id(chain) not in touched:
391
+ new_live.append(chain)
392
+ touched.add(id(chain))
393
+ continue
394
+ # 真读:文本还不知道。这里只把**和文本无关的**那些推进去
395
+ ev = [None, row, b, None, None]
396
+ events.append(ev)
397
+ deferred.append({"i": i, "row": row, "box": b, "chain": chain, "link": link, "ev": ev})
398
+ if chain is not None and link in ("prev", "moved"):
399
+ chain.read_w = b[2] - b[0]
400
+ chain.box, chain.last_seq, chain.age = b, seq, 0
401
+ chain.last_row, chain.last_idx = row, idx
402
+ chain.inflight += 1 # 策略 B:文本没回来之前,这条链的像素门走严格门
403
+ if id(chain) not in touched:
404
+ new_live.append(chain)
405
+ touched.add(id(chain))
406
+ else:
407
+ # 没链、或跨空档链接被门拦下 / 到期:都当新链,不回补旧链
408
+ nc = Chain(b, "", 0.0, seq)
409
+ nc.read_w = b[2] - b[0]
410
+ nc.last_row, nc.last_idx = row, idx
411
+ nc.inflight = 1
412
+ new_live.append(nc)
413
+ deferred[-1]["new_chain"] = nc
414
+ # 本帧没接上的活链 -> 结束(缓存丢掉);结束太久的丢掉
415
+ alive = {id(c) for c in new_live}
416
+ ended_now: list[tuple] = []
417
+ for c in self.live:
418
+ if id(c) not in alive:
419
+ self._drop_pending(c)
420
+ c.ended_at = seq
421
+ self.ended.append(c)
422
+ if c.last_row is not None:
423
+ # 文本**不在这里抄**:异步窗口下这条链最后几次真读可能还在途,此刻的 c.text 是旧的;
424
+ # stage2(按帧序、那些读都已结算)再取——见 commit_stage2 里交给回抠的那一处
425
+ ended_now.append((c.uid, c.last_row, c.box, c, c.last_idx))
426
+ self.ended = [c for c in self.ended if id(c) not in alive and seq - c.last_seq <= a.reuse_mem + 1]
427
+ self.live = new_live
428
+ self.buf[seq] = rows
429
+ self.sec["rows"] += time.perf_counter() - t_r
430
+ return {"seq": seq, "idx": idx, "t_us": t_us, "rows": rows, "events": events,
431
+ "ended_now": ended_now, "deferred": deferred, "inherit": inherit}
432
+
433
+ def commit_stage2(self, st: dict, texts: dict, fh) -> int:
434
+ """要"读回来的文本"的那一半:回填行、回补、给 edge 事件定 kind、推落盘水位。返回回补用掉的 rec 次数。
435
+
436
+ `texts`:框下标 -> (文本, 分数)。**窗口模式下这一步推迟到窗口结算**,于是 stage1 那会儿
437
+ 链的首读可能还没回来(`text_pending`)、沿用行里写的是空文本——所以**沿用行的文本在这里才定**,
438
+ 从链上取。stage2 严格按帧序跑,而沿用意味着这条链上一次真读在更早的帧,那一帧的 stage2 已经跑过,
439
+ 因此此刻链上的文本必然是最终的。(W=0 时这一步是恒等变换:链的文本就是 stage1 写进行里的那个。)
440
+ """
441
+ seq = st["seq"]
442
+ extra_rec = 0
443
+ stale: set[int] = set() # 这一批里历史文本被回补改过的链 uid(见下面 _recover 那一处)
444
+ t_r = time.perf_counter()
445
+ for row, chain, ev in st["inherit"]:
446
+ row["text"], row["conf"], ev[3] = chain.text, round(chain.conf, 4), chain.text
447
+ # **两遍**(2026-09-26):先收齐这一帧所有要回补的链,一起按层二分(`_recover`),再逐链收尾。
448
+ # 链之间互不依赖(回补只改这条链自己的历史行,收尾只动这条链),所以和逐链做完全等价,
449
+ # 差别只在同一层的读并成一次发——原来每条链每层各发一次、几乎全是批 1(wuwa-s2 320 次调用读 323 个框)
450
+ todo: list[tuple] = [] # (d, 文本, 分数, 回补任务下标或 None)
451
+ jobs: list[tuple] = [] # (chain, 新文本, 新分数, 这一帧之前的沿用行)
452
+ for d in st["deferred"]:
453
+ text, score = texts.get(d["i"], ("", 0.0))
454
+ row, chain, ev = d["row"], d["chain"], d["ev"]
455
+ row["text"], row["conf"] = text, round(score, 4)
456
+ nc = d.get("new_chain")
457
+ if nc is not None:
458
+ nc.text, nc.conf = text, score
459
+ nc.inflight -= 1
460
+ ev[0], ev[3], ev[4] = nc.uid, text, "new"
461
+ continue
462
+ # 回补只看**这一帧之前**的沿用行;更晚的那些是"读还没回来时按旧文本写出去的",
463
+ # 由下面的前向回填统一改(窗口模式才会有)。⚠ **别手工切 `chain.pending`**:
464
+ # 缓存的账在 `_drop_pending` 里,切了不退就是虚高(见 `_recover` 的说明)
465
+ before = [pnd for pnd in chain.pending if pnd[0] < seq]
466
+ j = None
467
+ if before and not same_text(text, chain.text):
468
+ j = len(jobs)
469
+ jobs.append((chain, text, score, before))
470
+ todo.append((d, text, score, j))
471
+ moved = []
472
+ if jobs:
473
+ t_rc = time.perf_counter()
474
+ moved = self._recover(jobs)
475
+ self.sec["recover"] += time.perf_counter() - t_rc
476
+ for d, text, score, j in todo:
477
+ chain, ev = d["chain"], d["ev"]
478
+ if j is not None:
479
+ moved_back = moved[j]
480
+ if self.trace is not None:
481
+ self.trace.append({"seq": seq, "box": list(d["box"]), "chain": chain.uid,
482
+ "link": "recover", "gate": "recover", "corr": None,
483
+ "digit": None, "read": True, "n_reads": moved_back[0]})
484
+ extra_rec += moved_back[0]
485
+ # **回补改的是过去,链事件只能往前发**(2026-09-17 审计):文本从 `AAA / AAA / BBB` 被改成
486
+ # `AAA / BBB / BBB` 之后,变化点其实在上一个采样点,而在线回抠这一批才收到 "changed"——
487
+ # 它开的打字机窗晚一个采样点,旧句的结尾窗也挂在一行已经被改写的文本上。
488
+ # 帧环只留 5 个采样间隔,回补能往回摸 --refresh-every 个采样点,重新量是不可能的;
489
+ # 所以**把这一批受影响的证据标成 stale**,第二遍当没量到、该 run 退回采样级(口径同 short)。
490
+ # 彻底修法(事件重放 / 按链保留像素)写在 reuse-budget 计划第 2 条。
491
+ if moved_back[1]:
492
+ stale.add(chain.uid)
493
+ self.stats["edge_stale"] += 1
494
+ self._drop_pending(chain, upto=seq)
495
+ ev[0], ev[3], ev[4] = chain.uid, text, "changed" if text != chain.text else "same"
496
+ # 两次真读逐字相同才算证实;变了(打字机在长 / 换句 / 回补改了历史)就回到未证实,下一帧起重新走严门
497
+ chain.confirmed = text == chain.text
498
+ chain.text, chain.conf = text, score
499
+ chain.inflight -= 1
500
+ self.sec["rows"] += time.perf_counter() - t_r
501
+ t_e = time.perf_counter()
502
+ if self.edge is not None:
503
+ # 链断的文本在这里才取(2026-09-23):原来 stage1 抄的是在途读之前的旧文本,回抠认不出"长字续接",
504
+ # 段被拆开、全字偏早(gi-s2 全字代价 22 -> 75、relinked 184 -> 51)。同步时两者相同
505
+ ended = [(u, row, bx, ch.text, li) for u, row, bx, ch, li in st["ended_now"]]
506
+ self.edge.sample(seq, st["idx"], st["t_us"], [tuple(e) for e in st["events"]],
507
+ ended, stale)
508
+ self.sec["edge"] += time.perf_counter() - t_e
509
+ t_f = time.perf_counter()
510
+ self._flush(fh, seq - self.horizon)
511
+ self.sec["flush"] += time.perf_counter() - t_f
512
+ return extra_rec
513
+
514
+ def _recover(self, jobs: list[tuple]) -> list[tuple[int, bool]]:
515
+ """递归二分:每个任务 `(chain, 新文本, 新分数, pend)` 里 pend[j] 的文本未知,端点是 held(chain.text)和新读。
516
+ 逐任务返回 (用掉的 rec 次数, 有没有把历史行的文本改掉)——改过就说明变化点比这一帧早,调用方要作废那一段的边界证据。
517
+
518
+ `pend` 是**这一帧之前**的沿用行(窗口模式下更晚的那些是"读还没回来时按旧文本写出去的",由调用方前向回填)。
519
+ **缓存的回收不在这里做**——统一交给 `_drop_pending`,不然会出现"条目丢了、`cache_bytes` 没退"的虚高
520
+ (2026-09-19 实测:缓存虚高 -> `cache_forced` 多触发 -> 判决变了,14 行产物不同)。"""
521
+ # 每个任务自己的端点表和栈;同一层里所有任务的中点**一次**发出去(`rec_many`)
522
+ texts = [{-1: (ch.text, ch.conf), len(pend): (t, c)} for ch, t, c, pend in jobs]
523
+ stacks = [[(-1, len(pend))] for _, _, _, pend in jobs]
524
+ reads = [0] * len(jobs)
525
+ changed = [False] * len(jobs)
526
+ # **按层走**(2026-09-19,首轮审计第 2 条):同一层的几个中点互不依赖(每个区间只看自己的端点),
527
+ # 所以可以一次性发出去;2026-09-26 起同一帧里各条链的同一层也并在一起发。`rec_many` 没给就退化成逐个读——
528
+ # 两条路的**读和结果完全一样**,差别只在"发几次"
529
+ while True:
530
+ layer = [] # (任务, lo, mid, hi)
531
+ for k, stack in enumerate(stacks):
532
+ tx = texts[k]
533
+ for lo, hi in stack:
534
+ if hi - lo <= 1 or same_text(tx[lo][0], tx[hi][0]):
535
+ continue
536
+ layer.append((k, lo, (lo + hi) // 2, hi))
537
+ stacks[k] = []
538
+ if not layer:
539
+ break
540
+ crops = [jobs[k][3][m][2] for k, _, m, _ in layer]
541
+ got = (self.rec_many(crops) if self.rec_many is not None
542
+ else [self.rec_one(c) for c in crops])
543
+ for (k, lo, mid, hi), (t, c) in zip(layer, got):
544
+ pend = jobs[k][3]
545
+ reads[k] += 1
546
+ texts[k][mid] = (t, c)
547
+ row = pend[mid][1]
548
+ changed[k] = changed[k] or not same_text(t, row["text"])
549
+ row["text"], row["conf"] = t, round(c, 4)
550
+ row.pop("reused", None)
551
+ row.pop("link", None)
552
+ row["recovered"] = True
553
+ # 中点这一行是**真读过的**:`same_text` 判"没改历史"(标点变体)时也换成这次的读数——它是一次独立观测,
554
+ # 留旧写法反而是拿沿用的文本冒充真读(2026-09-26 查过,不改)。中点到 hi 之间推断的那几行改成"相同就不覆盖"
555
+ # 也不会改变任何输出:`same_text` 可传递,一行最后没被读到,说明它所在区间两端的读数"相同"、父区间却不同,
556
+ # 所以最后一次覆盖它的读数一定和它当时的文本"不同",两种写法留下的值一样
557
+ for j in range(mid + 1, hi):
558
+ changed[k] = changed[k] or not same_text(t, pend[j][1]["text"])
559
+ pend[j][1]["text"], pend[j][1]["conf"] = t, round(c, 4)
560
+ stacks[k] += [(lo, mid), (mid, hi)]
561
+ self.stats["recover_reads"] += sum(reads)
562
+ self.stats["recover_events"] += len(jobs)
563
+ return list(zip(reads, changed))
564
+
565
+ def _drop_pending(self, chain: Chain, upto: int | None = None) -> None:
566
+ """丢掉这条链缓存着的裁剪(回补用的)。`upto` 给了就只丢 `seq <= upto` 的那些——
567
+ 窗口模式下,读还没回来的那几帧里这条链**又攒了新的沿用行**,那些不能一起丢
568
+ (它们还要回填文本、还可能被下一次回补用到)。"""
569
+ if upto is None:
570
+ self.cache -= chain.cache_bytes
571
+ chain.pending, chain.cache_bytes = [], 0
572
+ return
573
+ keep, freed = [], 0
574
+ for pnd in chain.pending:
575
+ if pnd[0] <= upto:
576
+ freed += int(pnd[2].nbytes)
577
+ else:
578
+ keep.append(pnd)
579
+ chain.pending = keep
580
+ chain.cache_bytes -= freed
581
+ self.cache -= freed
582
+
583
+ def _flush(self, fh, upto: int) -> None:
584
+ if self.edge is not None and any(k <= upto for k in self.buf):
585
+ # 写之前等 edge 证据挂上:不是等那一批本身,是等它开的段都停变了(edge_refine.settled_by);
586
+ # 主线程因此按辅助流的节奏背压,辅助流慢于 OCR 时墙钟跟着慢,但证据不丢
587
+ self.edge.wait_batch(min(self.edge.settled_by(upto), self.seq))
588
+ for s in sorted(k for k in self.buf if k <= upto):
589
+ for row in self.buf.pop(s):
590
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
591
+
592
+ def flush_all(self, fh) -> None:
593
+ self._flush(fh, 10 ** 12)