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,598 @@
|
|
|
1
|
+
"""两遍合一遍:OCR 那遍**在线**把首字 / 全字 / 消失的像素信号量出来,写进 obs 行的 `edge` 字段。
|
|
2
|
+
|
|
3
|
+
回抠(`flowocr.analyze.refine_boundaries`)原来是第二遍:建轨之后按 run 的首尾开解码窗、顺序解码整片。
|
|
4
|
+
它要的只是每条 run 首尾几十帧里那个框的裁剪,而 OCR 那遍的解码器本来就解了每一帧、`--reuse-v2` 的链在线知道
|
|
5
|
+
每个框何时**出现 / 变了 / 消失**。所以把两件事并起来(reuse-budget 计划):
|
|
6
|
+
|
|
7
|
+
* 一路**全帧率、缩到 540p、只要亮度(gray)**的辅助流跟在采样流旁边(`framesource.FfmpegFullRate`),
|
|
8
|
+
进一个有界的帧环 `FrameRing`。1080p 全帧率走 Windows 管道只有 660 MB/s、比 OCR 本身还慢,
|
|
9
|
+
540p 之后只比只送采样帧多 4 s / 5 min(probe_fullrate)。
|
|
10
|
+
* `reuse_v2.commit` 每个采样点把"这一帧哪些框是新链 / 文本变了 / 没变、哪些链断了"交给 `EdgeRefiner.sample`;
|
|
11
|
+
工作线程从环里抠**变化段**(链首或文本变化起,到文本停变后的那一个采样间隔为止)的裁剪,
|
|
12
|
+
停变那一帧当模板,跑 `typewriter_fuse.signals`(和回抠**同一份**信号代码);链断时抠结尾窗跑 `end_keyframes`
|
|
13
|
+
+ 整框相关系数的退回判据。结果只有十几个数,挂到链首行(`edge.on`)/ 末行(`edge.off`)上。
|
|
14
|
+
* 之后 `refine_boundaries --decoder obs` 不开视频:按 run 找到它首末行上的证据,重建 `measure()` 的输出,
|
|
15
|
+
走原来的 `apply()`(OCR 硬区间、多信号融合、只防晚很多——一行不改)。
|
|
16
|
+
|
|
17
|
+
窗口口径照抄 refine_boundaries.make_job:打字机窗从首字前一个采样间隔再往前 LEAD(50 ms),到采样级全字之后
|
|
18
|
+
一个采样间隔(不越过末次观测帧);结尾窗从 t_end 前 1.5 个间隔 + 50 ms 到 t_end 后 200 ms;退回判据在
|
|
19
|
+
[末次观测帧, +一个间隔] 上。
|
|
20
|
+
|
|
21
|
+
**时间:帧号换算,但锚在一个真实 pts 上**(2026-09-17 审计改的)。辅助流只有帧号,没有逐帧 pts,
|
|
22
|
+
所以窗内仍按 `Δ帧 × us_per_frame` 走;但每条证据记下它所属采样点的 `a_idx / a_us`(`a_us` 是 obs 行的
|
|
23
|
+
真实 pts),时间一律算成 `a_us + (i − a_idx) × us_per_frame`。**这样掉的是"帧号时钟和 pts 的累计偏差"**:
|
|
24
|
+
有丢帧缺口的素材上(yuka f4,`src_fps` 59.9870)`t_us − idx/fps` 在 20 分钟窗口里从 +2.3 ms 漂到 **−257.7 ms**,
|
|
25
|
+
f3 在 7.2 h 处是 9.5 s(docs/dev-guide/pitfalls.md「容器平均帧率不是时间轴」),而 run 的 t_start/t_end 是 pts;不锚就是两套时钟混用。
|
|
26
|
+
锚之后只剩窗内(几十帧)的漂,缺口不落在窗里就是 0。CFR 素材上 `t_us − idx/fps ≡ 0`,所以历史读数不变。
|
|
27
|
+
彻底修法(辅助流带 pts、两遍回抠也改)在 reuse-budget 计划第 1 条。
|
|
28
|
+
|
|
29
|
+
本模块**不 import cv2**(守卫在系统 Python 里跑;裁剪是 numpy 切片,辅助流已经是灰度)。
|
|
30
|
+
线程模型:OCR 主线程只往队列里放;工作线程等环里的帧、抠、算、写回行的 dict;`wait_batch` 让延迟落盘那一层
|
|
31
|
+
在写某个采样点的行之前先等它那一批处理完。环满时辅助流的读线程阻塞(背压),环的容量按窗口口径算,
|
|
32
|
+
工作线程最多落后主线程两个采样点,不会死锁(见 `ring_capacity`)。
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
from difflib import SequenceMatcher
|
|
39
|
+
from queue import Queue
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
from flowocr.extract import timeline as _tl # --timeline(decode-buffer);只有标准库
|
|
44
|
+
from flowocr.extract.framegrid import AuxGrid, TimeGrid
|
|
45
|
+
from flowocr.extract import typewriter_fuse as TF
|
|
46
|
+
|
|
47
|
+
PAD = 6
|
|
48
|
+
"""首字 / 全字的裁剪在框外各扩这么多像素(**全分辨率下**,同 refine_boundaries.PAD;按 scale 缩)。"""
|
|
49
|
+
LEAD_US = 50_000
|
|
50
|
+
"""打字机窗在"首字前一个采样间隔"之外再往前多取这么久(同 refine_boundaries.LEAD_US)。"""
|
|
51
|
+
END_LEAD_US = 50_000
|
|
52
|
+
"""结尾窗起点:t_end 前 1.5 个采样间隔再往前这么久。"""
|
|
53
|
+
END_TAIL_US = 200_000
|
|
54
|
+
"""结尾窗终点:t_end 之后这么久(淡出 / 交叉淡出的尾巴)。"""
|
|
55
|
+
MAX_OPEN_SAMPLES = 40
|
|
56
|
+
"""变化段最多跨这么多采样点还没停变就放弃(滚动的跑马灯 / 计时器不是打字机;也别让裁剪堆无限长)。"""
|
|
57
|
+
GROW_SIM = 0.6
|
|
58
|
+
"""文本变了:新文本的前缀和旧文本像到这个程度算**打字机在长**(旧文本没消失,不用给它开结尾窗);
|
|
59
|
+
否则是**换句**(同一位置紧接着出下一句),旧文本的末行也要一份 `edge.off`——build_tracks 会在这里切 run。"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_growth(old: str, new: str) -> bool:
|
|
63
|
+
a, b = TF.chars(old), TF.chars(new)
|
|
64
|
+
if not a or not b or len(b) <= len(a):
|
|
65
|
+
return False
|
|
66
|
+
return SequenceMatcher(None, a, b[:len(a)]).ratio() >= GROW_SIM
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_jitter(old: str, new: str) -> bool:
|
|
70
|
+
"""同一段字被 rec 读得略有不同(字数没长):不是变化,别重开窗。"""
|
|
71
|
+
a, b = TF.chars(old), TF.chars(new)
|
|
72
|
+
return bool(a and b) and not is_growth(old, new) and SequenceMatcher(None, a, b).ratio() >= GROW_SIM
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def overlap_match(box, ref_box) -> float:
|
|
76
|
+
"""证据行的框配 run 的框:同一行(y 重叠过半)且 x 重叠 ≥ 较窄者的一半 -> 返回 x 重叠占较窄者的比例,否则 0。"""
|
|
77
|
+
h = max(1.0, ref_box[3] - ref_box[1])
|
|
78
|
+
if min(box[3], ref_box[3]) - max(box[1], ref_box[1]) < 0.5 * h:
|
|
79
|
+
return 0.0
|
|
80
|
+
w = max(1.0, min(box[2] - box[0], ref_box[2] - ref_box[0]))
|
|
81
|
+
ov = (min(box[2], ref_box[2]) - max(box[0], ref_box[0])) / w
|
|
82
|
+
return ov if ov >= 0.5 else 0.0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def line_match(box, ref_box) -> bool:
|
|
86
|
+
"""同 typewriter_fuse.ocr_points 的"同一行位置":y 重叠过半、左沿差不到 1.5 个字高。"""
|
|
87
|
+
x0, y0, _, y1 = ref_box
|
|
88
|
+
h = max(1.0, y1 - y0)
|
|
89
|
+
return min(box[3], y1) - max(box[1], y0) >= 0.5 * h and abs(box[0] - x0) <= 1.5 * h
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ring_in_flight(args) -> int:
|
|
93
|
+
"""解码头到回抠工作线程之间最多压着几个采样帧——帧环的容量按它给(`ring_capacity`;漏一项就环满卡解码):
|
|
94
|
+
取帧预取 + det 批 ×(1 + det 预取 + det 流水的前瞻)+ rec 固定滞后窗口 + `--decode-shards` 的共享内存槽。
|
|
95
|
+
放在这里而不是 run_ocr2:守卫要能按行为测它(run_ocr2 顶层 import 推理库,守卫够不着),原来只能查源码里有没有那几个参数名。"""
|
|
96
|
+
from flowocr.extract import decode_proc # 模块顶层只有标准库
|
|
97
|
+
ahead = args.det_lookahead if args.det_batch > 1 else 0 # `--det-lookahead`:辅助线程上正在推理的那一批
|
|
98
|
+
return (args.prefetch + args.det_batch * (1 + args.det_prefetch + ahead) + args.rec_window
|
|
99
|
+
+ (decode_proc.slots_for(args.det_batch, args.det_prefetch) if args.decode_shards > 1 else 0))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def aux_frames_in(aux: AuxGrid | None, span: int) -> int:
|
|
103
|
+
"""连续 `span` 个源帧里辅助流最多有几帧(帧环装的是辅助流的帧,容量按它数)。没给网格 = 每帧都要。
|
|
104
|
+
等距步长 q:⌈span / q⌉ + 1;不等距(自己的网格 ∪ 采样网格):两个网格各自的上界相加,宁多勿少。"""
|
|
105
|
+
if aux is None:
|
|
106
|
+
return span
|
|
107
|
+
|
|
108
|
+
def upper(g: TimeGrid) -> int:
|
|
109
|
+
return -(-span * g.p // g.q) + 1
|
|
110
|
+
if aux.uniform:
|
|
111
|
+
return min(span, upper(aux.own))
|
|
112
|
+
return min(span, upper(aux.own) + upper(aux.sample))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ring_capacity(gap: int, lead: int, tail: int, strides: int = 5, in_flight: int = 1, aux: AuxGrid | None = None) -> int:
|
|
116
|
+
"""帧环要装多少帧:`release` 之后环里留着工作线程处理的那个采样点之前 2 个间隔(+ lead + 3,回看窗),
|
|
117
|
+
往前一直到**解码头**——中间压着 `in_flight` 个采样帧(取帧预取 + det 批 ×(1 + det 预取)+ rec 固定滞后窗口
|
|
118
|
+
+ `--decode-shards` 的共享内存槽,调用方逐项加,`ring_in_flight`),外加工作线程最多落后的 2 个采样点(文件头)。
|
|
119
|
+
所以下界是 **in_flight + 4 个采样间隔** + lead + tail(结尾窗)+ 8 个源帧的跨度。`strides`(`--refine-ring`)只是地板。
|
|
120
|
+
`aux` 给了就把这段跨度换算成**辅助流的帧数**(`aux_frames_in`):素材帧率高于 `--aux-max-fps` 时辅助流隔帧取,
|
|
121
|
+
按源帧数给容量会多占一倍内存(120 fps 的片子默认只收 60 fps);没给就按源帧数(每帧都进环)。
|
|
122
|
+
|
|
123
|
+
装不下就不只是慢:一路解码时采样帧和辅助流出自**同一个 ffmpeg**,环一满整个 ffmpeg 就停。
|
|
124
|
+
分片(`--decode-shards`,默认)时辅助流经转发线程按块序送来,环满先卡转发线程、再经字节预算压回各块的 ffmpeg;
|
|
125
|
+
主流最多领先辅助流 `ahead` 块 + 预算,等待链不成环(2026-09-25 审计追过),但 `in_flight` 这条公式还是按一路的结构写的。
|
|
126
|
+
* 2026-09-17:第一版没算批 det 的前瞻,`--det-batch 8` + 5 个间隔的环**互等到死**(主循环等采样帧、辅助流等环),
|
|
127
|
+
100 秒没出一帧;于是加了 det 批那一项。
|
|
128
|
+
* 2026-09-23(decode-buffer):那一项只算了 det 批 ×(1 + 预取)+ 窗口(≈ 17 个间隔),漏了取帧预取和上面那 4 个,
|
|
129
|
+
回抠换进程之后环满仍卡 8~14 s / 5 分钟。T4 放大到 28 / 40 个间隔:`--edge-proc` 打平、当时的 `--front-proc`(已删)每段 −1%~−3%,方向一致。
|
|
130
|
+
现在的默认(预取 4、批 4、det 预取 2、窗口 4)是 24 个间隔,拆进程再加 16 个槽是 40——1080p 60 fps、0.35 灰度时约 180 / 305 MB 内存。"""
|
|
131
|
+
return aux_frames_in(aux, max(5, strides, in_flight + 4) * gap + lead + tail + 8)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FrameRing:
|
|
135
|
+
"""帧号 -> 灰度帧的有界环。生产者 `push` 满了就阻塞;消费者 `wait_for(idx)` 等到那一帧到了(或流结束);
|
|
136
|
+
`release(upto)` 丢掉更早的帧、并记住 `floor`——**之后再推进来的、低于 floor 的帧只记帧号不存**。
|
|
137
|
+
第一版没有 floor,死锁过一次(yuka-f5 实跑,2026-09-16):辅助流落后于 OCR 主循环时,工作线程按"这一批没有要抠的窗"
|
|
138
|
+
先把低位帧号放掉了,随后辅助流才把这些已经放掉的旧帧推进来、把环填满、阻塞;而工作线程下一批等的是更高的帧号。"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, cap: int, stall_sec: float = 90.0) -> None:
|
|
141
|
+
# `stall_sec`:推不进去又没人来取,卡这么久就**认定互等**并炸(2026-09-17)。
|
|
142
|
+
# 容量算对了就永远不该触发;但"等某个一定会来的东西"的地方必须有出口——
|
|
143
|
+
# 这已经是同一条教训的第三次(帧环死锁、cuvid 负 pts,都是等到天荒地老)
|
|
144
|
+
self.cap = cap
|
|
145
|
+
self.stall_sec = stall_sec
|
|
146
|
+
self.stalled = False
|
|
147
|
+
self.frames: dict[int, np.ndarray] = {}
|
|
148
|
+
self.cv = threading.Condition()
|
|
149
|
+
self.highest = -1
|
|
150
|
+
self.floor = -1
|
|
151
|
+
self.eof = False
|
|
152
|
+
self.pushed = 0
|
|
153
|
+
self.dropped = 0
|
|
154
|
+
self.blocked_sec = 0.0
|
|
155
|
+
self.premask = None
|
|
156
|
+
"""`(帧号, 帧) -> 帧`:进环之前按自选范围涂灰(`regions.aux_masker`,ocr-regions 计划)。None = 不受限。
|
|
157
|
+
在锁外做(逐像素的活不该卡住消费者)。"""
|
|
158
|
+
|
|
159
|
+
def push(self, idx: int, frame: np.ndarray) -> None:
|
|
160
|
+
if self.premask is not None:
|
|
161
|
+
frame = self.premask(idx, frame)
|
|
162
|
+
with self.cv:
|
|
163
|
+
if idx >= self.floor and len(self.frames) >= self.cap:
|
|
164
|
+
t0 = time.perf_counter()
|
|
165
|
+
while idx >= self.floor and len(self.frames) >= self.cap and not self.eof:
|
|
166
|
+
self.cv.wait(0.5)
|
|
167
|
+
if time.perf_counter() - t0 > self.stall_sec:
|
|
168
|
+
# 环满、没人取、流也没结束 = 互等(容量不够装下主循环的前瞻)。
|
|
169
|
+
# 标记 + 放行,让消费者那边抛出来,别静静地挂着
|
|
170
|
+
self.stalled = True
|
|
171
|
+
self.eof = True
|
|
172
|
+
self.cv.notify_all()
|
|
173
|
+
return
|
|
174
|
+
self.blocked_sec += _tl.span("ring_blocked", t0, a=idx) - t0
|
|
175
|
+
if self.eof:
|
|
176
|
+
return
|
|
177
|
+
if idx >= self.floor:
|
|
178
|
+
self.frames[idx] = frame
|
|
179
|
+
else:
|
|
180
|
+
self.dropped += 1
|
|
181
|
+
self.highest = max(self.highest, idx)
|
|
182
|
+
self.pushed += 1
|
|
183
|
+
self.cv.notify_all()
|
|
184
|
+
|
|
185
|
+
def close(self) -> None:
|
|
186
|
+
with self.cv:
|
|
187
|
+
self.eof = True
|
|
188
|
+
self.cv.notify_all()
|
|
189
|
+
|
|
190
|
+
def wait_for(self, idx: int) -> bool:
|
|
191
|
+
"""等到帧号 ≥ idx 的帧进过环。流先结束了就 False;**互等**了就抛(见 `stall_sec`)。"""
|
|
192
|
+
with self.cv:
|
|
193
|
+
while self.highest < idx and not self.eof:
|
|
194
|
+
self.cv.wait(0.5)
|
|
195
|
+
if self.stalled:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"帧环互等:容量 {self.cap} 帧装不下主循环的前瞻(`--det-batch` × 采样间隔),"
|
|
198
|
+
f"辅助流推不进来、采样帧也就停了。把 `--refine-ring` 调大,或减小 `--det-batch`")
|
|
199
|
+
return self.highest >= idx
|
|
200
|
+
|
|
201
|
+
def take(self, lo: int, hi: int) -> list[tuple[int, np.ndarray]]:
|
|
202
|
+
with self.cv:
|
|
203
|
+
return sorted((k, f) for k, f in self.frames.items() if lo <= k <= hi)
|
|
204
|
+
|
|
205
|
+
def release(self, upto: int) -> None:
|
|
206
|
+
with self.cv:
|
|
207
|
+
self.floor = max(self.floor, upto)
|
|
208
|
+
for k in [k for k in self.frames if k < upto]:
|
|
209
|
+
del self.frames[k]
|
|
210
|
+
self.cv.notify_all()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Seg:
|
|
214
|
+
"""一条链的一个**变化段**:从链首 / 文本开始变,到停变后的那个采样点。裁剪按整行带(框的 y 范围外扩、整幅宽)存,
|
|
215
|
+
停变时再按最终的框切——打字机在长字、框在变宽,段开始时不知道最终的框有多宽。"""
|
|
216
|
+
__slots__ = ("key", "row", "text", "box", "y0", "y1", "lo", "idxs", "stacks", "opened_seq", "last_seq", "short",
|
|
217
|
+
"stale")
|
|
218
|
+
|
|
219
|
+
def __init__(self, key: int, row: dict, text: str, box, y0: int, y1: int, lo: int, seq: int) -> None:
|
|
220
|
+
self.key, self.row, self.text, self.box = key, row, text, box
|
|
221
|
+
self.y0, self.y1, self.lo = y0, y1, lo
|
|
222
|
+
self.idxs: list[int] = []
|
|
223
|
+
self.stacks: list[np.ndarray] = []
|
|
224
|
+
self.opened_seq = self.last_seq = seq
|
|
225
|
+
self.short = False
|
|
226
|
+
self.stale = False # 回补把这一段的变化点前移了 -> 窗口口径已经不对,证据作废
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class EdgeRefiner:
|
|
230
|
+
def __init__(self, *, src_fps: float, start_idx: int, width: int, height: int,
|
|
231
|
+
scale: float, grid, thr: float, ring_cap: int | None = None, noop: bool = False) -> None:
|
|
232
|
+
# `grid` = 辅助流取哪些帧(`AuxGrid` 或它的 `as_list()`,跨进程传的是后者);采样网格是它的 `sample`(嵌套在里面)。
|
|
233
|
+
# 窗口里"上一个 / 下一个采样帧"都问采样网格(不等距时相邻间隔差一帧),不再按固定步长减
|
|
234
|
+
self.grid = grid if isinstance(grid, AuxGrid) else AuxGrid.from_list(grid)
|
|
235
|
+
self.sgrid = self.grid.sample
|
|
236
|
+
if not 0 < scale <= 1.0:
|
|
237
|
+
raise ValueError(f"scale 要在 (0, 1],给的是 {scale}")
|
|
238
|
+
self.start_idx = start_idx
|
|
239
|
+
gap = self.sgrid.max_gap # 相邻两个采样帧最多隔几帧(等距就是步长)
|
|
240
|
+
self.us = 1_000_000 / src_fps
|
|
241
|
+
self.thr = thr
|
|
242
|
+
self.W, self.H = width, height
|
|
243
|
+
self.Ws, self.Hs = ((int(width * scale / 2) * 2, int(height * scale / 2) * 2) if scale < 1.0
|
|
244
|
+
else (width, height))
|
|
245
|
+
self.sx, self.sy = self.Ws / width, self.Hs / height
|
|
246
|
+
self.pad = max(2, int(round(PAD * self.sx)))
|
|
247
|
+
self.lead = int(round(LEAD_US / self.us))
|
|
248
|
+
self.end_lead = int(round(END_LEAD_US / self.us))
|
|
249
|
+
self.tail = int(round(END_TAIL_US / self.us))
|
|
250
|
+
self.ring = FrameRing(ring_cap or ring_capacity(gap, self.lead, self.tail, aux=self.grid))
|
|
251
|
+
self.keep_back = 2 * gap + self.lead + self.end_lead + 3
|
|
252
|
+
"""放掉帧环里早于"当前采样帧 − 这么多"的帧:最早的窗口起点是上一个采样帧再往前 lead / 结尾窗的 1.5 个间隔 + end_lead。"""
|
|
253
|
+
self.anchor = (start_idx, int(round(start_idx * self.us))) # (帧号, 那一帧的真实 pts),见文件头"时间"
|
|
254
|
+
self.open: dict[int, Seg] = {}
|
|
255
|
+
self.first_idx: dict[int, int] = {}
|
|
256
|
+
self.last: dict[int, tuple] = {} # 链 uid -> (行, 框, 文本) 上一个采样点的样子(换句时给旧句开结尾窗)
|
|
257
|
+
self.q: Queue = Queue()
|
|
258
|
+
self.done_seq = -1
|
|
259
|
+
self.done_cv = threading.Condition()
|
|
260
|
+
self.error: BaseException | None = None
|
|
261
|
+
self.on_done = None
|
|
262
|
+
"""`(seq) -> None`:每处理完一批就调(在工作线程里)。独立进程版(`edge_proc`)靠它把进度发回管线进程。"""
|
|
263
|
+
self.on_error = None
|
|
264
|
+
"""`(exc) -> None`:工作线程抛错时调(同上)。"""
|
|
265
|
+
# `worker_busy_sec` 是线程的墙钟(GIL 之下大半在等主线程),**`worker_cpu_sec` 才是它自己的算力**
|
|
266
|
+
self.stats = {"segments": 0, "on": 0, "off": 0, "off_replaced": 0, "relinked": 0, "jitter": 0,
|
|
267
|
+
"short_windows": 0, "stale_windows": 0, "gave_up": 0,
|
|
268
|
+
"frames_pushed": 0, "ring_stalled": 0, "ring_blocked_sec": 0.0,
|
|
269
|
+
"worker_busy_sec": 0.0, "worker_cpu_sec": 0.0,
|
|
270
|
+
"worker_wait_sec": 0.0, "signals_sec": 0.0, "end_sec": 0.0, "wait_batch_sec": 0.0}
|
|
271
|
+
# `--refine-aux noop`:空臂(辅助流照收进环、工作线程只放帧不算),量"辅助流那条路"单独多付多少(成本拆分用,不进 config)
|
|
272
|
+
self.noop = noop
|
|
273
|
+
self.th = threading.Thread(target=self._run, daemon=True, name="edge-refine")
|
|
274
|
+
self.th.start()
|
|
275
|
+
|
|
276
|
+
# ---------- OCR 主线程这边 ----------
|
|
277
|
+
def sample(self, seq: int, idx: int, t_us: int, items: list[tuple], ended: list[tuple],
|
|
278
|
+
stale: set[int] | tuple = ()) -> None:
|
|
279
|
+
"""一个采样点:items = [(链 uid, 行, 框, 文本, "new"|"changed"|"same")](本帧仍在的框),
|
|
280
|
+
ended = [(链 uid, 末行, 框, 文本, 末次观测帧号)](本帧没接上的链)。
|
|
281
|
+
`t_us` 是这一帧的**真实 pts**(时间锚,见文件头);`stale` = 这一批里历史文本被回补改过的链 uid。"""
|
|
282
|
+
self.q.put(("s", seq, idx, t_us, items, ended, set(stale)))
|
|
283
|
+
|
|
284
|
+
def settled_by(self, seq: int) -> int:
|
|
285
|
+
"""写第 seq 批的行之前要等到哪一批处理完:段最晚在 MAX_OPEN_SAMPLES 批之后停变或放弃、结尾证据在链断那一批挂上,
|
|
286
|
+
所以是 seq + MAX_OPEN_SAMPLES + 1。第一版只等第 seq 批本身,辅助流一落后(NVDEC 路,工作线程比主线程慢几十个采样点)
|
|
287
|
+
行就先被写出去了、证据挂在已落盘的 dict 上——yuka-f5 的 on 行从 465 掉到 299(2026-09-16 深夜)。"""
|
|
288
|
+
return seq + MAX_OPEN_SAMPLES + 1
|
|
289
|
+
|
|
290
|
+
def wait_batch(self, seq: int) -> None:
|
|
291
|
+
"""等到第 seq 批处理完(延迟落盘写那一批的行之前调;调用方给 `settled_by(批号)`)。"""
|
|
292
|
+
t0 = time.perf_counter()
|
|
293
|
+
with self.done_cv:
|
|
294
|
+
while self.done_seq < seq and self.error is None and self.th.is_alive():
|
|
295
|
+
self.done_cv.wait(0.5)
|
|
296
|
+
self.stats["wait_batch_sec"] += _tl.span("wait_batch", t0, a=seq) - t0
|
|
297
|
+
if self.error is not None:
|
|
298
|
+
raise self.error
|
|
299
|
+
|
|
300
|
+
def finish(self, last_idx: int) -> dict:
|
|
301
|
+
"""流结束:还开着的段按末次采样帧停变,等工作线程清空队列。返回统计。"""
|
|
302
|
+
self.q.put(("f", last_idx))
|
|
303
|
+
self.th.join()
|
|
304
|
+
self.ring.close()
|
|
305
|
+
if self.error is not None:
|
|
306
|
+
raise self.error
|
|
307
|
+
self.stats["frames_pushed"] = self.ring.pushed
|
|
308
|
+
self.stats["ring_stalled"] = int(self.ring.stalled)
|
|
309
|
+
self.stats["frames_below_floor"] = self.ring.dropped
|
|
310
|
+
self.stats["ring_blocked_sec"] = round(self.ring.blocked_sec, 2)
|
|
311
|
+
for k in ("worker_busy_sec", "worker_wait_sec", "signals_sec", "end_sec", "wait_batch_sec"):
|
|
312
|
+
self.stats[k] = round(self.stats[k], 2)
|
|
313
|
+
return self.stats
|
|
314
|
+
|
|
315
|
+
def abort(self) -> None:
|
|
316
|
+
self.ring.close()
|
|
317
|
+
self.q.put(("x",))
|
|
318
|
+
|
|
319
|
+
# ---------- 工作线程 ----------
|
|
320
|
+
def _run(self) -> None:
|
|
321
|
+
try:
|
|
322
|
+
while True:
|
|
323
|
+
t_idle = time.perf_counter()
|
|
324
|
+
msg = self.q.get()
|
|
325
|
+
t0 = _tl.span("edge_idle", t_idle)
|
|
326
|
+
if msg[0] == "x":
|
|
327
|
+
return
|
|
328
|
+
if msg[0] == "f":
|
|
329
|
+
for seg in list(self.open.values()):
|
|
330
|
+
self._settle(seg, msg[1], seg.box)
|
|
331
|
+
self.open.clear()
|
|
332
|
+
self.stats["worker_busy_sec"] += time.perf_counter() - t0
|
|
333
|
+
return
|
|
334
|
+
_, seq, idx, t_us, items, ended, stale = msg
|
|
335
|
+
c0 = time.thread_time()
|
|
336
|
+
self.anchor = (idx, t_us) # 时间锚:这一批的证据按这一帧的真实 pts 折算
|
|
337
|
+
if self.noop: # 成本拆分用的空臂:辅助流照收、什么都不算
|
|
338
|
+
self.ring.release(idx - self.keep_back)
|
|
339
|
+
else:
|
|
340
|
+
self._batch(seq, idx, items, ended, stale)
|
|
341
|
+
self.stats["worker_cpu_sec"] += time.thread_time() - c0
|
|
342
|
+
self.stats["worker_busy_sec"] += _tl.span("edge_batch", t0, a=idx) - t0
|
|
343
|
+
with self.done_cv:
|
|
344
|
+
self.done_seq = seq
|
|
345
|
+
self.done_cv.notify_all()
|
|
346
|
+
if self.on_done is not None:
|
|
347
|
+
self.on_done(seq)
|
|
348
|
+
except BaseException as exc: # noqa: BLE001 主线程在 wait_batch / finish 里再抛
|
|
349
|
+
self.error = exc
|
|
350
|
+
self.ring.close()
|
|
351
|
+
with self.done_cv:
|
|
352
|
+
self.done_cv.notify_all()
|
|
353
|
+
if self.on_error is not None:
|
|
354
|
+
self.on_error(exc)
|
|
355
|
+
|
|
356
|
+
def _t(self, i: int) -> int:
|
|
357
|
+
"""帧号 -> 时间:锚在这一批采样点的真实 pts 上(见文件头"时间")。"""
|
|
358
|
+
a_idx, a_us = self.anchor
|
|
359
|
+
return int(round(a_us + (i - a_idx) * self.us))
|
|
360
|
+
|
|
361
|
+
def _batch(self, seq: int, idx: int, items: list[tuple], ended: list[tuple],
|
|
362
|
+
stale: set[int] = frozenset()) -> None:
|
|
363
|
+
# 链断了、同一行紧接着起了新链(打字机长字让框变宽、IoU 掉到 0.7 以下;或 rec 抖动换了文本):
|
|
364
|
+
# 按文本判是**长 / 抖**(旧链的状态过继给新链,不开结尾窗、不重开打字机窗)还是**换句**(照常)
|
|
365
|
+
kinds = {}
|
|
366
|
+
left = []
|
|
367
|
+
for key, row, box, text, last_idx in ended:
|
|
368
|
+
heir = next((it for it in items if it[4] == "new" and it[0] not in kinds and line_match(it[2], box)
|
|
369
|
+
and (is_growth(text, it[3]) or is_jitter(text, it[3]))), None)
|
|
370
|
+
if heir is None:
|
|
371
|
+
left.append((key, row, box, text, last_idx))
|
|
372
|
+
continue
|
|
373
|
+
k2 = heir[0]
|
|
374
|
+
for d in (self.open, self.first_idx, self.last):
|
|
375
|
+
if key in d:
|
|
376
|
+
d[k2] = d.pop(key)
|
|
377
|
+
self.last.setdefault(k2, (row, box, text))
|
|
378
|
+
kinds[k2] = "changed" if is_growth(text, heir[3]) else "same"
|
|
379
|
+
self.stats["relinked"] += 1
|
|
380
|
+
ended = left
|
|
381
|
+
for key, row, box, text, kind in items:
|
|
382
|
+
kind = kinds.get(key, kind)
|
|
383
|
+
seg = self.open.get(key)
|
|
384
|
+
prev = self.last.get(key)
|
|
385
|
+
self.last[key] = (row, box, text)
|
|
386
|
+
if kind == "changed" and prev is not None and is_jitter(prev[2], text):
|
|
387
|
+
self.stats["jitter"] += 1
|
|
388
|
+
kind = "same"
|
|
389
|
+
if kind == "changed" and prev is not None and not is_growth(prev[2], text):
|
|
390
|
+
# 换句:同一条链上旧句直接被下一句顶掉。旧句的末行要结尾证据(build_tracks 在这里切 run),
|
|
391
|
+
# 打字机在长(新文本以旧文本为前缀)时不用——旧字还在屏幕上
|
|
392
|
+
self._end(key, prev[0], prev[1], prev[2], self.sgrid.prev(idx), key in stale)
|
|
393
|
+
self.stats["off_replaced"] += 1
|
|
394
|
+
if seg is not None: # 上一句还没停变就被顶掉:按末次观测帧停变
|
|
395
|
+
self._settle(seg, self.sgrid.prev(idx), prev[1])
|
|
396
|
+
del self.open[key]
|
|
397
|
+
seg = None
|
|
398
|
+
self.first_idx[key] = idx
|
|
399
|
+
if kind in ("new", "changed"):
|
|
400
|
+
if kind == "new":
|
|
401
|
+
self.first_idx[key] = idx
|
|
402
|
+
if seg is None:
|
|
403
|
+
seg = self._open(key, row, text, box, idx, seq)
|
|
404
|
+
seg.stale = key in stale # 回补前移了变化点:这一段的窗起点已经不对(见 reuse_v2.commit)
|
|
405
|
+
self._capture(seg, seg.lo, idx)
|
|
406
|
+
else:
|
|
407
|
+
seg.stale = seg.stale or key in stale
|
|
408
|
+
self._capture(seg, self.sgrid.prev(idx) + 1, idx)
|
|
409
|
+
seg.text, seg.box, seg.last_seq = text, box, seq
|
|
410
|
+
if seq - seg.opened_seq > MAX_OPEN_SAMPLES:
|
|
411
|
+
self.stats["gave_up"] += 1
|
|
412
|
+
del self.open[key]
|
|
413
|
+
elif seg is not None: # 停变:这一个间隔也要,末帧当模板
|
|
414
|
+
seg.stale = seg.stale or key in stale
|
|
415
|
+
self._capture(seg, self.sgrid.prev(idx) + 1, idx)
|
|
416
|
+
self._settle(seg, idx, box)
|
|
417
|
+
del self.open[key]
|
|
418
|
+
for key, row, box, text, last_idx in ended:
|
|
419
|
+
seg = self.open.pop(key, None)
|
|
420
|
+
if seg is not None: # 断的时候还在变:模板只能是末次观测帧
|
|
421
|
+
seg.stale = seg.stale or key in stale
|
|
422
|
+
self._settle(seg, last_idx, box)
|
|
423
|
+
self._end(key, row, box, text, last_idx, key in stale)
|
|
424
|
+
self.first_idx.pop(key, None)
|
|
425
|
+
self.last.pop(key, None)
|
|
426
|
+
self.ring.release(idx - self.keep_back)
|
|
427
|
+
|
|
428
|
+
def _open(self, key: int, row: dict, text: str, box, idx: int, seq: int) -> Seg:
|
|
429
|
+
y0 = max(0, int(box[1] * self.sy) - 2 * self.pad)
|
|
430
|
+
y1 = min(self.Hs, int(box[3] * self.sy) + 2 * self.pad)
|
|
431
|
+
lo = max(self.start_idx, self.sgrid.prev(idx) - self.lead)
|
|
432
|
+
# 窗口起点对齐到辅助流的网格上(`AuxGrid.floor`):窗口里的帧号就是网格上的帧、证据重建时(`sig_from_records`)
|
|
433
|
+
# 按同一个网格数得回来;采样帧一定在网格上(嵌套),时间网格才盖住采样级的 t_start——
|
|
434
|
+
# 2026-09-16 那次(step 2、起点 = 采样帧 − 33 是奇数、没对齐)网格全是奇数帧,首字吸附到网格上比 t_start 晚 1 帧,产物校验当场拒收
|
|
435
|
+
lo = self.grid.floor(lo)
|
|
436
|
+
seg = self.open[key] = Seg(key, row, text, box, y0, y1, lo, seq)
|
|
437
|
+
self.stats["segments"] += 1
|
|
438
|
+
return seg
|
|
439
|
+
|
|
440
|
+
def _capture(self, seg: Seg, lo: int, hi: int) -> None:
|
|
441
|
+
t0 = time.perf_counter()
|
|
442
|
+
ok = self.ring.wait_for(hi)
|
|
443
|
+
self.stats["worker_wait_sec"] += _tl.span("edge_wait_frame", t0) - t0
|
|
444
|
+
if not ok:
|
|
445
|
+
seg.short = True
|
|
446
|
+
got = self.ring.take(lo, hi)
|
|
447
|
+
if not got:
|
|
448
|
+
seg.short = True
|
|
449
|
+
return
|
|
450
|
+
seg.idxs.extend(k for k, _ in got)
|
|
451
|
+
seg.stacks.append(np.stack([f[seg.y0:seg.y1] for _, f in got]))
|
|
452
|
+
|
|
453
|
+
def _inner(self, seg_y0: int, seg_y1: int, box) -> tuple[tuple[int, int, int, int], tuple[int, int, int, int]] | None:
|
|
454
|
+
"""最终的框(缩放后)在整行带里的裁剪范围 + 框在裁剪里的位置。"""
|
|
455
|
+
bx0, by0 = max(0, int(box[0] * self.sx)), max(seg_y0, int(box[1] * self.sy))
|
|
456
|
+
bx1, by1 = min(self.Ws, int(box[2] * self.sx)), min(seg_y1, int(box[3] * self.sy))
|
|
457
|
+
if bx1 - bx0 < 4 or by1 - by0 < 4:
|
|
458
|
+
return None
|
|
459
|
+
x0, y0 = max(0, bx0 - self.pad), max(seg_y0, by0 - self.pad)
|
|
460
|
+
x1, y1 = min(self.Ws, bx1 + self.pad), min(seg_y1, by1 + self.pad)
|
|
461
|
+
return (x0, y0 - seg_y0, x1, y1 - seg_y0), (bx0 - x0, by0 - y0, bx1 - x0, by1 - y0)
|
|
462
|
+
|
|
463
|
+
def _settle(self, seg: Seg, hi: int, box) -> None:
|
|
464
|
+
"""停变:拼起整段裁剪、按最终的框切、跑 signals,结果挂到段首行。"""
|
|
465
|
+
a_idx, a_us = self.anchor
|
|
466
|
+
rec = {"lo": seg.lo, "hi": hi, **self.grid.spec(), "a_idx": a_idx, "a_us": a_us,
|
|
467
|
+
"n": len(TF.chars(seg.text)), "text": seg.text,
|
|
468
|
+
"box": [int(v) for v in box], "first_old": None, "first_rise": None, "full_glyph": None, "cell": None}
|
|
469
|
+
cut = self._inner(seg.y0, seg.y1, box)
|
|
470
|
+
if seg.stale:
|
|
471
|
+
self.stats["stale_windows"] += 1
|
|
472
|
+
rec["stale"] = True
|
|
473
|
+
if seg.short or not seg.stacks or cut is None or seg.idxs[-1] < self.grid.prev(hi):
|
|
474
|
+
self.stats["short_windows"] += 1
|
|
475
|
+
rec["short"] = True
|
|
476
|
+
else:
|
|
477
|
+
(x0, y0, x1, y1), inner = cut
|
|
478
|
+
frames = np.concatenate(seg.stacks)[:, y0:y1, x0:x1]
|
|
479
|
+
ts = [self._t(i) for i in seg.idxs]
|
|
480
|
+
t0 = time.perf_counter()
|
|
481
|
+
sig = TF.signals(frames, ts, inner, seg.text)
|
|
482
|
+
self.stats["signals_sec"] += time.perf_counter() - t0
|
|
483
|
+
if sig is not None:
|
|
484
|
+
rec.update({k: sig[k] for k in ("first_old", "first_rise", "full_glyph")})
|
|
485
|
+
rec["cell"] = [float(sig["cell"][0]), float(sig["cell"][1])] if sig["cell"] else None
|
|
486
|
+
seg.row.setdefault("edge", {})["on"] = rec
|
|
487
|
+
self.stats["on"] += 1
|
|
488
|
+
|
|
489
|
+
def _end(self, key: int, row: dict, box, text: str, last_idx: int, stale: bool = False) -> None:
|
|
490
|
+
"""链断了:结尾窗(对比度曲线)+ 退回判据(整框相关系数、模板 = 末次观测帧)。"""
|
|
491
|
+
t_end_idx = self.sgrid.next(last_idx) # 下一个采样帧(run 的 t_end)
|
|
492
|
+
lo = max(self.first_idx.get(key, last_idx), t_end_idx - int(round(1.5 * (t_end_idx - last_idx))) - self.end_lead)
|
|
493
|
+
lo = self.grid.floor(lo) # 同 _open:对齐到辅助流的网格
|
|
494
|
+
hi = self.grid.ceil(t_end_idx + self.tail)
|
|
495
|
+
a_idx, a_us = self.anchor
|
|
496
|
+
rec = {"lo": lo, "hi": hi, **self.grid.spec(), "a_idx": a_idx, "a_us": a_us,
|
|
497
|
+
"n": len(TF.chars(text)), "text": text,
|
|
498
|
+
"last_idx": last_idx, "box": [int(v) for v in box], "ncc_gone": None, "last_full": None, "gone": None}
|
|
499
|
+
if stale: # 回补改写了这一段的历史文本:末行的文本 / 时刻都不再是它量的那个
|
|
500
|
+
self.stats["stale_windows"] += 1
|
|
501
|
+
rec["stale"] = True
|
|
502
|
+
t0 = time.perf_counter()
|
|
503
|
+
full = self.ring.wait_for(hi)
|
|
504
|
+
self.stats["worker_wait_sec"] += _tl.span("edge_wait_frame", t0) - t0
|
|
505
|
+
t0 = time.perf_counter()
|
|
506
|
+
got = self.ring.take(min(lo, last_idx), max(hi, t_end_idx))
|
|
507
|
+
y0 = max(0, int(box[1] * self.sy) - 2 * self.pad)
|
|
508
|
+
y1 = min(self.Hs, int(box[3] * self.sy) + 2 * self.pad)
|
|
509
|
+
cut = self._inner(y0, y1, box)
|
|
510
|
+
if not got or cut is None:
|
|
511
|
+
self.stats["short_windows"] += 1
|
|
512
|
+
rec["short"] = True
|
|
513
|
+
else:
|
|
514
|
+
(cx0, cy0, cx1, cy1), inner = cut
|
|
515
|
+
by = {k: f[y0:y1][cy0:cy1, cx0:cx1] for k, f in got}
|
|
516
|
+
ix0, iy0, ix1, iy1 = inner
|
|
517
|
+
tpl = by.get(last_idx)
|
|
518
|
+
if tpl is not None and tpl[iy0:iy1, ix0:ix1].size >= 64:
|
|
519
|
+
t_in = tpl[iy0:iy1, ix0:ix1]
|
|
520
|
+
gone = None
|
|
521
|
+
for k in reversed(self.grid.frames(last_idx, t_end_idx)): # last_true:[末次观测帧, +一个间隔]
|
|
522
|
+
f = by.get(k)
|
|
523
|
+
if f is not None and TF.ncc(f[iy0:iy1, ix0:ix1], t_in) >= self.thr:
|
|
524
|
+
gone = k
|
|
525
|
+
break
|
|
526
|
+
rec["ncc_gone"] = gone
|
|
527
|
+
want = self.grid.frames(lo, hi)
|
|
528
|
+
idxs = [k for k in want if k in by]
|
|
529
|
+
if full and idxs and len(idxs) == len(want): # 网格上的帧一帧不缺(lo / hi 都在网格上)
|
|
530
|
+
frames = np.stack([by[k] for k in idxs])
|
|
531
|
+
last_full, gone_c = TF.end_keyframes(frames, [self._t(k) for k in idxs], inner, text)
|
|
532
|
+
rec["last_full"], rec["gone"] = last_full, gone_c
|
|
533
|
+
else:
|
|
534
|
+
self.stats["short_windows"] += 1
|
|
535
|
+
rec["short"] = True
|
|
536
|
+
row.setdefault("edge", {})["off"] = rec
|
|
537
|
+
self.stats["off"] += 1
|
|
538
|
+
self.stats["end_sec"] += time.perf_counter() - t0
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------- 第二遍(refine_boundaries --decoder obs)这边:把行上的证据重建成 measure() 的输出 ----------
|
|
542
|
+
|
|
543
|
+
def rec_time(rec: dict, i: int, us_per_frame: float) -> int:
|
|
544
|
+
"""证据记录里的帧号 -> 时间:锚在它那一批采样点的真实 pts 上(`a_idx` / `a_us`,见文件头"时间")。
|
|
545
|
+
没有锚的旧产物退回纯帧号时钟(那是 2026-09-17 之前的口径)。"""
|
|
546
|
+
a_idx, a_us = rec.get("a_idx"), rec.get("a_us")
|
|
547
|
+
if a_idx is None or a_us is None:
|
|
548
|
+
return int(round(i * us_per_frame))
|
|
549
|
+
return int(round(a_us + (i - a_idx) * us_per_frame))
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def usable(rec: dict | None) -> bool:
|
|
553
|
+
"""这条证据能不能用:窗口凑齐了(不 short),而且没被回补作废(不 stale,见 reuse_v2.commit)。"""
|
|
554
|
+
return bool(rec) and not rec.get("short") and not rec.get("stale")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def sig_from_records(first: dict, last: dict, us_per_frame: float) -> dict | None:
|
|
558
|
+
"""段首行的 `edge.on`(首字那一侧)+ 采样级全字之前最后一段的 `edge.on`(全字那一侧)-> typewriter_fuse 的 sig。
|
|
559
|
+
同一段时两者是同一个记录。任一侧 short / stale 就当没量出来。"""
|
|
560
|
+
if not usable(first) or not usable(last):
|
|
561
|
+
return None
|
|
562
|
+
lo, hi = first["lo"], last["hi"]
|
|
563
|
+
if hi < lo:
|
|
564
|
+
return None
|
|
565
|
+
grid = AuxGrid.from_spec(first) # 等距的记 `step`、不等距的记 `grid`(`AuxGrid.spec`)
|
|
566
|
+
cell = None
|
|
567
|
+
if first.get("cell") and last.get("cell"):
|
|
568
|
+
cell = (first["cell"][0], last["cell"][1])
|
|
569
|
+
return {"ts": [rec_time(first, i, us_per_frame) for i in grid.frames(lo, hi)], "n": last["n"],
|
|
570
|
+
"first_old": first["first_old"], "first_rise": first["first_rise"],
|
|
571
|
+
"full_glyph": last["full_glyph"], "cell": cell}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def measured_from_records(on_first: dict | None, on_last: dict | None, off: dict | None,
|
|
575
|
+
old_end_us: int, us_per_frame: float, frame_us: int, sgrid: TimeGrid) -> tuple | None:
|
|
576
|
+
"""-> refine_boundaries.measure 写进 job["measured"] 的那个五元组 (new_e, ok_gone, sig, last_full, end_how)。
|
|
577
|
+
没有任何证据返回 None(该条原样退回,同 no_evidence)。口径同 measure:曲线给得出"完全消失"就用它,
|
|
578
|
+
末次观测帧之后才算;`last_full` 不早于末次观测帧减一个采样间隔。
|
|
579
|
+
**开放边界**(`end_how = "open"`,2026-09-26 owner 定):退回判据一路像到链断那个采样点——查找范围
|
|
580
|
+
`[末次观测帧, sgrid.next(末次观测帧)]`(`_end` 里的那个,`sgrid` 是这份 obs 的采样网格)的最后一帧字还在,
|
|
581
|
+
窗里没看到它消失(det 把一行切碎、读数变了另起一条 run,字其实还在屏上)。t_end 照旧取"最后还像的一帧 + 1",
|
|
582
|
+
但那只是下界;完全显示结束也就不知道,不填。**按网格判、不拿 run 的 t_end 比**:run 的 t_end 是末次观测 + 名义间隔,
|
|
583
|
+
不整除的采样(60 fps 上 2.2 / 2.1 fps)和实际的下一格差一帧,两个方向都会判反(2026-09-26 Codex 复审)。"""
|
|
584
|
+
sig = sig_from_records(on_first, on_last, us_per_frame) if on_first and on_last else None
|
|
585
|
+
new_e, ok_gone, last_full, end_how = old_end_us, False, None, None
|
|
586
|
+
if usable(off):
|
|
587
|
+
e_us = rec_time(off, off["last_idx"], us_per_frame)
|
|
588
|
+
if off.get("ncc_gone") is not None:
|
|
589
|
+
new_e, ok_gone = rec_time(off, off["ncc_gone"] + 1, us_per_frame), True
|
|
590
|
+
end_how = "open" if off["ncc_gone"] >= sgrid.next(off["last_idx"]) else "ncc"
|
|
591
|
+
if off.get("gone") is not None and off["gone"] > e_us:
|
|
592
|
+
new_e, ok_gone, end_how = int(off["gone"]), True, "curve"
|
|
593
|
+
last_full = off.get("last_full")
|
|
594
|
+
if (last_full is not None and last_full < e_us - frame_us) or end_how == "open":
|
|
595
|
+
last_full = None
|
|
596
|
+
if sig is None and not ok_gone and last_full is None:
|
|
597
|
+
return None
|
|
598
|
+
return (new_e, ok_gone, sig, last_full, end_how)
|