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,562 @@
1
+ """**聚类质量的尺**:抽 run 对 → 摆帧 → 标"该不该在一起" → 给任何一种聚类量误合并 / 误拆分(next-steps)。
2
+
3
+ 为什么是 **run 对**而不是窗区域对:窗区域是 `cluster_windowed` 的产物,下标随代码变;
4
+ 而且窗内聚类自己也会错(星铁的"字幕行连进阅读面板")。run 有稳定的身份(起止时刻 + 框 + 文本),
5
+ 于是同一份标注能量**任何**一层:窗内聚类、跨窗缝合、最终区域,乃至以后完全不同的算法。
6
+
7
+ **几何和各臂的分歧只用来挑样本,不当真值**(cluster-ruler 计划的规矩)。四层抽样:
8
+
9
+ S1 分歧对 在臂 X 里同槽、在臂 Y 里不同槽——信息量最大,但**只能比臂、算不出绝对错误率**
10
+ S2 同槽随机对 基准臂(`BASE`)里同一个槽位的两个窗区域(按 run 数加权)——给**基准臂**的误合并率一个无偏的底
11
+ S3 近邻异槽对 基准臂里不同槽、但 y 区间重叠且 x 相交——给**基准臂**的误拆分率的底
12
+ (⚠ 随机异槽对几乎全是"显然不同",没信息;所以这个率是"近邻里的",不是全体的)
13
+ S4 窗内对 同一个窗区域里的两条 run——量窗内聚类
14
+
15
+ ⚠ **每条臂在照自己抽的样本上都最差**(S2 只照得见基准臂的误合并、S3 只照得见它的误拆分)。比两条臂,要两条臂各当一次基准、
16
+ 合起来报(cluster-ruler 计划的更正框)。行级算法没有窗区域,用 `sample-learned` / `sample-lines`(run 级的 S1 / S2 / S3)。
17
+
18
+ 标注四值(协议全文在 cluster-ruler 计划):
19
+ same 同一个屏幕元素 / 同一条带(行数不同、第 1 行和第 3 行,都算 same)
20
+ widget 不同元素、但属于同一个控件(对话框的名牌 + 正文)——合不合都不算错
21
+ different 不同元素(哪怕在不同时刻占同一个位置)
22
+ unsure 看不出来(帧上找不到那行字、转场中……)
23
+
24
+ 用法:
25
+ python -m flowocr.analyze.slot_pairs sample f5 --n 40 --seed 1 # 抽样 + 摆帧(要 ffmpeg、PIL)
26
+ python -m flowocr.analyze.slot_pairs sample-learned f5 --seed 4 # 照 slot_learned 自己的聚类抽(run 级、留这一部片子不训)
27
+ python -m flowocr.analyze.slot_pairs sample-lines f5 --n 25 --seed 5 # 照 slot_lines 的行位抽(run 级)
28
+ python -m flowocr.analyze.slot_pairs merge data/gt/slotpairs-f5-s1.json tmp/slotpairs/f5/labels.json --by sonnet
29
+ python -m flowocr.analyze.slot_pairs score data/gt/slotpairs-*.json # 各臂的误合并 / 误拆分
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import io
35
+ import json
36
+ import random
37
+ import subprocess
38
+ from collections import Counter
39
+ from pathlib import Path
40
+
41
+ from flowocr.analyze import build_tracks as bt # noqa: E402
42
+ from flowocr.analyze import cluster_layers as CL # noqa: E402
43
+ from flowocr import paths # noqa: E402
44
+
45
+ ARMS: dict[str, dict] = {
46
+ "pooled": {"geom_mode": "pooled"},
47
+ "pooled-nr": {"geom_mode": "pooled", "reassign": False},
48
+ "median": {"geom_mode": "median"},
49
+ "median-nr": {"geom_mode": "median", "reassign": False},
50
+ }
51
+ """挑分歧对用的臂(`cluster_layers.stitch` 的覆盖项)。`score` 另外还能量外部给的分配。
52
+ **每条臂都把旋钮显式写出来**:第一版有一条 `"default": {}`,`--slot-geom` 一翻默认它就和 `median` 塌成同一条
53
+ (docs/dev-guide/verification.md「A/B 的规矩」:对照臂把关掉的旋钮显式写出来)。seed 1 / 2 的真值是按当时的默认(= 现在的 `pooled`)抽的。"""
54
+
55
+ BASE = "median"
56
+ """S2 / S3 按哪条臂的同槽 / 异槽关系抽——取当前默认,于是这两层对**它**是无偏的率。"""
57
+
58
+ SHARE = {"S1": 0.40, "S2": 0.25, "S3": 0.25, "S4": 0.10}
59
+ LABELS = ("same", "widget", "different", "unsure")
60
+ SCHEMA = "flowocr-slotpairs/1"
61
+
62
+
63
+ def run_key(r) -> list:
64
+ return [r.t_start, r.t_end, [round(v) for v in r.box], r.text]
65
+
66
+
67
+ from flowocr.analyze.pair_features import MIN_CONTENT, MIN_OBS, good_run # noqa: E402
68
+
69
+
70
+ def pick_run(L, wi: int, rnd: random.Random, avoid: int = -1) -> int:
71
+ return rnd.choice([i for i in L.win_regions[wi]["runs"] if i != avoid and good_run(L.runs[i])])
72
+
73
+
74
+ def slot_index(slots: list[list[int]]) -> dict[int, int]:
75
+ return {w: k for k, sl in enumerate(slots) for w in sl}
76
+
77
+
78
+ def sample(L, n: int, seed: int) -> list[dict]:
79
+ rnd = random.Random(seed)
80
+ wr = L.win_regions
81
+ arms = {name: slot_index(CL.stitch(L, **over)) for name, over in ARMS.items()}
82
+ weight = [sum(1 for i in w["runs"] if good_run(L.runs[i])) for w in wr]
83
+ idx = list(range(len(wr)))
84
+ n_good = sum(weight)
85
+ print(f" [筛子] 像样的 run {n_good}/{len(L.runs)}({n_good / len(L.runs):.1%});"
86
+ f"有像样 run 的窗区域 {sum(1 for x in weight if x)}/{len(wr)}——只在这些里面抽")
87
+ geom = [bt.region_geom(L.runs, w["runs"]) for w in wr]
88
+ members: dict[str, dict[int, list[int]]] = {}
89
+ for name, so in arms.items():
90
+ m: dict[int, list[int]] = {}
91
+ for w, k in so.items():
92
+ m.setdefault(k, []).append(w)
93
+ members[name] = m
94
+
95
+ def mate(arm: str, i: int) -> int | None:
96
+ c = [j for j in members[arm][arms[arm][i]] if j != i and weight[j]]
97
+ return rnd.choices(c, [weight[j] for j in c])[0] if c else None
98
+
99
+ def near(i: int) -> int | None:
100
+ _, x0, x1, y0, y1, h = geom[i]
101
+ c = [j for j in idx if weight[j] and arms[BASE][j] != arms[BASE][i] and wr[j]["win"] != wr[i]["win"]
102
+ and bt.span_overlap(y0, y1, geom[j][3], geom[j][4], min(h, geom[j][5])) >= 0.5
103
+ and min(x1, geom[j][2]) > max(x0, geom[j][1])]
104
+ return rnd.choice(c) if c else None
105
+
106
+ want = {s: round(n * p) for s, p in SHARE.items()}
107
+ out, seen = [], set()
108
+
109
+ def add(stratum: str, i: int, j: int, same_wr: bool = False) -> bool:
110
+ a = pick_run(L, i, rnd)
111
+ b = pick_run(L, j, rnd, avoid=a) if same_wr else pick_run(L, j, rnd)
112
+ k = tuple(sorted((a, b)))
113
+ if a == b or k in seen:
114
+ return False
115
+ seen.add(k)
116
+ out.append({"stratum": stratum, "runs": [a, b], "wr": [i, j],
117
+ "together": {name: so[i] == so[j] for name, so in arms.items()}})
118
+ return True
119
+
120
+ pairs_x = [(x, y) for x in ARMS for y in ARMS if x != y and BASE in (x, y)]
121
+ for stratum in ("S1", "S2", "S3", "S4"):
122
+ got = tries = 0
123
+ while got < want[stratum] and tries < 4000:
124
+ tries += 1
125
+ i = rnd.choices(idx, weight)[0]
126
+ if stratum == "S1":
127
+ x, y = pairs_x[tries % len(pairs_x)]
128
+ j = mate(x, i)
129
+ ok = j is not None and arms[y][i] != arms[y][j] and add("S1", i, j)
130
+ elif stratum == "S2":
131
+ j = mate(BASE, i)
132
+ ok = j is not None and add("S2", i, j)
133
+ elif stratum == "S3":
134
+ j = near(i)
135
+ ok = j is not None and add("S3", i, j)
136
+ else:
137
+ ok = weight[i] >= 2 and add("S4", i, i, same_wr=True)
138
+ got += bool(ok)
139
+ if got < want[stratum]:
140
+ print(f" ⚠ {stratum} 只抽到 {got}/{want[stratum]}(候选不够)")
141
+ rnd.shuffle(out) # 标注的人不该从顺序里看出是哪一层
142
+ return out
143
+
144
+
145
+ def grab(video: str, t: float):
146
+ from PIL import Image
147
+ p = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-ss", f"{max(0.0, t):.3f}", "-i", video,
148
+ "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"],
149
+ capture_output=True)
150
+ if p.returncode or not p.stdout:
151
+ raise RuntimeError(f"ffmpeg 抽帧失败 t={t}: {p.stderr.decode(errors='replace')[:200]}")
152
+ return Image.open(io.BytesIO(p.stdout)).convert("RGB")
153
+
154
+
155
+ def box_at(r, t_us: float) -> list[float]:
156
+ """run 在 t 时刻的框。**动过的 run(滚动面板、评论墙)`r.box` 是它最后的位置**——
157
+ 拿它画在中途那一帧上,黄框会落在滚进那个位置的另一行字上(2026-09-24 复标查出来的:
158
+ 动过的对复标不一致 5/13,没动过的 5/37;cluster-ruler)。按轨迹取 t 之前最后一个点。"""
159
+ if not getattr(r, "moving", False) or not r.boxes:
160
+ return r.box
161
+ past = [b for tb, b in r.boxes if tb <= t_us]
162
+ return past[-1] if past else r.boxes[0][1]
163
+
164
+
165
+ def panel(L, r, tag: str):
166
+ """一侧:上面是整帧(960 宽)+ 黄框,下面是框周围的放大条。"""
167
+ from PIL import Image, ImageDraw
168
+ t = (r.t_start + min(r.t_end, r.t_start + 4_000_000)) / 2e6 # 长驻的取前 4 秒的中点
169
+ im = grab(L.video, t)
170
+ sc = 960 / im.width
171
+ small = im.resize((960, round(im.height * sc)))
172
+ d = ImageDraw.Draw(small)
173
+ box = box_at(r, t * 1e6)
174
+ x0, y0, x1, y1 = [v * sc for v in box]
175
+ d.rectangle([x0 - 3, y0 - 3, x1 + 3, y1 + 3], outline=(255, 230, 0), width=3)
176
+ d.text((8, 6), f"{tag} t={t:.1f}s", fill=(255, 230, 0))
177
+ bx0, by0, bx1, by1 = box
178
+ mx, my = 0.5 * (by1 - by0) + 40, 1.0 * (by1 - by0) + 20
179
+ crop = im.crop((max(0, int(bx0 - mx)), max(0, int(by0 - my)),
180
+ min(im.width, int(bx1 + mx)), min(im.height, int(by1 + my))))
181
+ z = min(960 / crop.width, 160 / crop.height)
182
+ crop = crop.resize((max(1, round(crop.width * z)), max(1, round(crop.height * z))))
183
+ out = Image.new("RGB", (960, small.height + 168), (24, 24, 24))
184
+ out.paste(small, (0, 0))
185
+ out.paste(crop, (0, small.height + 8))
186
+ return out
187
+
188
+
189
+ def render(L, pair: dict, dest: Path) -> None:
190
+ from PIL import Image
191
+ a, b = (panel(L, L.runs[i], s) for i, s in zip(pair["runs"], "AB"))
192
+ im = Image.new("RGB", (a.width + b.width + 8, max(a.height, b.height)), (24, 24, 24))
193
+ im.paste(a, (0, 0))
194
+ im.paste(b, (a.width + 8, 0))
195
+ im.save(dest, quality=85)
196
+
197
+
198
+ def cmd_sample(a) -> int:
199
+ root = paths.data_root()
200
+ L = CL.load(a.tag)
201
+ pairs = sample(L, a.n, a.seed)
202
+ imgdir = root / "tmp" / "slotpairs" / a.tag
203
+ imgdir.mkdir(parents=True, exist_ok=True)
204
+ items = []
205
+ for n, p in enumerate(pairs):
206
+ pid = f"{a.tag}-s{a.seed}-{n:03d}"
207
+ render(L, p, imgdir / f"{pid}.jpg")
208
+ ra, rb = (L.runs[i] for i in p["runs"])
209
+ items.append({"id": pid, "image": f"tmp/slotpairs/{a.tag}/{pid}.jpg",
210
+ "a": {"key": run_key(ra), "win": L.win_regions[p["wr"][0]]["win"]},
211
+ "b": {"key": run_key(rb), "win": L.win_regions[p["wr"][1]]["win"]},
212
+ "label": None, "labeled_by": None, "note": "",
213
+ # 下面两样**标注时不给看**(`labeling_view` 会剥掉),只在分析时用
214
+ "stratum": p["stratum"], "together": p["together"]})
215
+ print(f" {pid} {p['stratum']} A={ra.text[:18]!r} B={rb.text[:18]!r}", flush=True)
216
+ dest = root / "data" / "gt" / f"slotpairs-{a.tag}-s{a.seed}.json"
217
+ doc = {"schema": SCHEMA, "tag": a.tag, "video": L.video, "obs": str(L.obs), "seed": a.seed,
218
+ "arms": ARMS, "base": BASE, "share": SHARE, "split": a.split,
219
+ "filter": {"min_content": MIN_CONTENT, "min_obs": MIN_OBS}, "items": items}
220
+ if dest.exists() and not a.force:
221
+ raise SystemExit(f"{dest} 已存在(里面可能有标注);要覆盖给 --force")
222
+ dest.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
223
+ view = [{"id": it["id"], "image": it["image"], "a_text": it["a"]["key"][3], "b_text": it["b"]["key"][3]}
224
+ for it in items]
225
+ (imgdir / f"to-label-s{a.seed}.json").write_text(json.dumps(view, ensure_ascii=False, indent=1), encoding="utf-8")
226
+ print(f"{len(items)} 对 -> {dest};给标注者看的清单(不含分层和各臂的判断)-> {imgdir / f'to-label-s{a.seed}.json'}")
227
+ return 0
228
+
229
+
230
+ RUN_SHARE = {"S1": 0.30, "S2": 0.35, "S3": 0.35}
231
+
232
+
233
+ def sample_runs(L, base: dict[int, int], other: dict[int, int], n: int, seed: int) -> list[dict]:
234
+ """**run 级**抽样:基准是一份 `run 下标 -> 类号`(行级算法没有窗区域,`sample` 那套用不上)。
235
+
236
+ S1 分歧对 base 同类 / other 异类,或反过来
237
+ S2 同类随机对 base 同一类、不同的 60 s 窗——base 的误合并率
238
+ S3 近邻异类对 base 不同类、不同窗、两个框 y 重叠 ≥ 半个字高且 x 相交——base 近邻里的误拆分率
239
+ """
240
+ rnd = random.Random(seed)
241
+ good = [i for i, r in enumerate(L.runs) if good_run(r) and i in base]
242
+ win = {i: L.runs[i].t_start // 60_000_000 for i in good}
243
+ members: dict[int, list[int]] = {}
244
+ for i in good:
245
+ members.setdefault(base[i], []).append(i)
246
+ print(f" [筛子] 像样的 run {len(good)}/{len(L.runs)};base 里含像样 run 的类 {len(members)} 个"
247
+ f"(其中 ≥2 条的 {sum(1 for v in members.values() if len(v) > 1)})")
248
+
249
+ def near(i: int) -> int | None:
250
+ a = L.runs[i]
251
+ c = [j for j in good if base[j] != base[i] and win[j] != win[i]
252
+ and min(a.box[3], L.runs[j].box[3]) - max(a.box[1], L.runs[j].box[1]) >= 0.5 * min(a.h, L.runs[j].h)
253
+ and min(a.box[2], L.runs[j].box[2]) > max(a.box[0], L.runs[j].box[0])]
254
+ return rnd.choice(c) if c else None
255
+
256
+ out, seen = [], set()
257
+ for stratum, share in RUN_SHARE.items():
258
+ got = tries = 0
259
+ while got < round(n * share) and tries < 6000:
260
+ tries += 1
261
+ i = rnd.choice(good)
262
+ if stratum == "S3":
263
+ j = near(i)
264
+ else:
265
+ c = [j for j in members[base[i]] if win[j] != win[i]]
266
+ j = rnd.choice(c) if c else None
267
+ if stratum == "S1" and (j is None or other.get(i) == other.get(j)):
268
+ j = near(i) # 反方向的分歧:base 异类、other 同类
269
+ if j is not None and other.get(i) != other.get(j):
270
+ j = None
271
+ k = tuple(sorted((i, j))) if j is not None else None
272
+ if k is None or k in seen:
273
+ continue
274
+ seen.add(k)
275
+ out.append({"stratum": stratum, "runs": [i, j],
276
+ "together": {"base": base[i] == base[j], "other": other.get(i) == other.get(j)}})
277
+ got += 1
278
+ if got < round(n * share):
279
+ print(f" ⚠ {stratum} 只抽到 {got}/{round(n * share)}")
280
+ rnd.shuffle(out)
281
+ return out
282
+
283
+
284
+ def cmd_sample_learned(a) -> int:
285
+ """照 `slot_learned` 自己的聚类抽样(模型**留这一部片子不训**),给它报绝对错误率。"""
286
+ from flowocr.analyze import pair_model
287
+ from flowocr.analyze import slot_learned
288
+ root = paths.data_root()
289
+ L = CL.load(a.tag)
290
+ X, y, meta, _ = pair_model.load(a.train.split(","))
291
+ keep = [k for k, m in enumerate(meta) if m[0] != a.tag]
292
+ model = slot_learned.fit(X[keep][:, slot_learned.COLS], y[keep])
293
+ base = slot_learned.assign(L, model, verbose=True)
294
+ med = assignment(L, CL.stitch(L, **ARMS["median"]))
295
+ other = {i: med.get((r.t_start, r.t_end, r.text, tuple(round(v) for v in r.box))) for i, r in enumerate(L.runs)}
296
+ pairs = sample_runs(L, base, other, a.n, a.seed)
297
+ imgdir = root / "tmp" / "slotpairs" / a.tag
298
+ imgdir.mkdir(parents=True, exist_ok=True)
299
+ items = []
300
+ for n_, p_ in enumerate(pairs):
301
+ pid = f"{a.tag}-s{a.seed}-{n_:03d}"
302
+ render(L, p_, imgdir / f"{pid}.jpg")
303
+ ra, rb = (L.runs[i] for i in p_["runs"])
304
+ items.append({"id": pid, "image": f"tmp/slotpairs/{a.tag}/{pid}.jpg",
305
+ "a": {"key": run_key(ra), "win": int(ra.t_start // 60_000_000)},
306
+ "b": {"key": run_key(rb), "win": int(rb.t_start // 60_000_000)},
307
+ "label": None, "labeled_by": None, "note": "",
308
+ "stratum": p_["stratum"], "together": p_["together"]})
309
+ dest = root / "data" / "gt" / f"slotpairs-{a.tag}-s{a.seed}.json"
310
+ if dest.exists() and not a.force:
311
+ raise SystemExit(f"{dest} 已存在;要覆盖给 --force")
312
+ doc = {"schema": SCHEMA, "tag": a.tag, "video": L.video, "obs": str(L.obs), "seed": a.seed,
313
+ "base": "learned(lofo)", "train": a.train, "share": RUN_SHARE, "split": a.split,
314
+ "filter": {"min_content": MIN_CONTENT, "min_obs": MIN_OBS}, "items": items}
315
+ dest.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
316
+ view = [{"id": it["id"], "image": it["image"], "a_text": it["a"]["key"][3], "b_text": it["b"]["key"][3]} for it in items]
317
+ (imgdir / f"to-label-s{a.seed}.json").write_text(json.dumps(view, ensure_ascii=False, indent=1), encoding="utf-8")
318
+ print(f"{len(items)} 对 -> {dest}")
319
+ return 0
320
+
321
+
322
+ def cmd_sample_lines(a) -> int:
323
+ """照 `slot_lines` 自己的分组抽样(`build_tracks --cluster lines` 的区域),给它一个和 median / learned 对等的抽样框。
324
+ 其余和 `sample-learned` 相同:S1 = 和 median 的分歧对,S2 / S3 = lines 的同组 / 近邻异组。"""
325
+ from flowocr.analyze import slot_lines
326
+ root = paths.data_root()
327
+ L = CL.load(a.tag)
328
+ base = slot_lines.assign(L)
329
+ med = assignment(L, CL.stitch(L, **ARMS["median"]))
330
+ other = {i: med.get((r.t_start, r.t_end, r.text, tuple(round(v) for v in r.box))) for i, r in enumerate(L.runs)}
331
+ pairs = sample_runs(L, base, other, a.n, a.seed)
332
+ imgdir = root / "tmp" / "slotpairs" / a.tag
333
+ imgdir.mkdir(parents=True, exist_ok=True)
334
+ items = []
335
+ for n_, p_ in enumerate(pairs):
336
+ pid = f"{a.tag}-s{a.seed}-{n_:03d}"
337
+ render(L, p_, imgdir / f"{pid}.jpg")
338
+ ra, rb = (L.runs[i] for i in p_["runs"])
339
+ items.append({"id": pid, "image": f"tmp/slotpairs/{a.tag}/{pid}.jpg",
340
+ "a": {"key": run_key(ra), "win": int(ra.t_start // 60_000_000)},
341
+ "b": {"key": run_key(rb), "win": int(rb.t_start // 60_000_000)},
342
+ "label": None, "labeled_by": None, "note": "",
343
+ "stratum": p_["stratum"], "together": p_["together"]})
344
+ dest = root / "data" / "gt" / f"slotpairs-{a.tag}-s{a.seed}.json"
345
+ if dest.exists() and not a.force:
346
+ raise SystemExit(f"{dest} 已存在;要覆盖给 --force")
347
+ doc = {"schema": SCHEMA, "tag": a.tag, "video": L.video, "obs": str(L.obs), "seed": a.seed,
348
+ "base": "lines", "share": RUN_SHARE, "split": a.split,
349
+ "filter": {"min_content": MIN_CONTENT, "min_obs": MIN_OBS}, "items": items}
350
+ dest.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
351
+ view = [{"id": it["id"], "image": it["image"], "a_text": it["a"]["key"][3], "b_text": it["b"]["key"][3]} for it in items]
352
+ (imgdir / f"to-label-s{a.seed}.json").write_text(json.dumps(view, ensure_ascii=False, indent=1), encoding="utf-8")
353
+ print(f"{len(items)} 对 -> {dest}")
354
+ return 0
355
+
356
+
357
+ def assignment(L, slots: list[list[int]]) -> dict[tuple, int]:
358
+ out = {}
359
+ for k, sl in enumerate(slots):
360
+ for w in sl:
361
+ for i in L.win_regions[w]["runs"]:
362
+ r = L.runs[i]
363
+ out[(r.t_start, r.t_end, r.text, tuple(round(v) for v in r.box))] = k
364
+ return out
365
+
366
+
367
+ def item_key(side: dict) -> tuple:
368
+ t0, t1, box, text = side["key"]
369
+ return (t0, t1, text, tuple(box))
370
+
371
+
372
+ ANCHOR_IOU = 0.5
373
+ ANCHOR_SIM = 0.6
374
+
375
+
376
+ def reanchor(key: tuple, runs: list) -> tuple | None:
377
+ """标注里的 run 在当前的层里对不回(建 run 的代码改了,起止 / 文本 / 框变了一点)时,找它现在是哪一条:
378
+ 时间重叠 ≥ 两者较短时长的一半、框 IoU ≥ `ANCHOR_IOU`、文本相似 ≥ `ANCHOR_SIM`,得分(IoU + 相似 + 时间重叠比)**唯一最高**的那条。
379
+ 找不到或并列就是 None(照旧记"对不回")。只在精确键对不上时用,所以对得上的样本一条不动。"""
380
+ t0, t1, text, box = key
381
+ best, best_s, tie = None, -1.0, False
382
+ for r in runs:
383
+ ov = min(t1, r.t_end) - max(t0, r.t_start)
384
+ if ov <= 0 or ov < 0.5 * max(1, min(t1 - t0, r.t_end - r.t_start)):
385
+ continue
386
+ g = bt.iou(list(box), r.box)
387
+ if g < ANCHOR_IOU:
388
+ continue
389
+ s = bt.text_sim(text, r.text)
390
+ if s < ANCHOR_SIM:
391
+ continue
392
+ sc = g + s + ov / max(1, max(t1 - t0, r.t_end - r.t_start))
393
+ if sc > best_s + 1e-9:
394
+ best, best_s, tie = r, sc, False
395
+ elif abs(sc - best_s) <= 1e-9:
396
+ tie = True
397
+ if best is None or tie:
398
+ return None
399
+ return (best.t_start, best.t_end, best.text, tuple(round(v) for v in best.box))
400
+
401
+
402
+ def verdicts(assigns: dict[str, dict], it: dict, runs: list | None = None) -> dict[str, bool] | None:
403
+ """一对标注在各臂上判得对不对。**任何一条臂对不回任何一端,就返回 None**——这一对不进任何臂的分母。
404
+ 直接写 `asg.get(a) == asg.get(b)` 是错的:两端都找不到时 `None == None` 被当成"同类",
405
+ 标 `same` 的丢失样本就成了"预测正确";而且各臂丢的不一样时分母也不一致(Codex 审计 2026-09-21)。
406
+ 给了 `runs`(当前的层)就先把对不回的一端重新锚定(`reanchor`),各臂共用同一份 run,锚定结果对各臂一致。
407
+ 两端锚到**同一条** run(切行拼接把标注时的两块拼成了一条)也返回 None:这一对在当前的层里已经不是两条 run,
408
+ 照算的话各臂都白拿"同槽"(2026-09-26 审计)。"""
409
+ ka, kb, out = item_key(it["a"]), item_key(it["b"]), {}
410
+ if runs is not None:
411
+ any_asg = next(iter(assigns.values()))
412
+ ka = ka if ka in any_asg else (reanchor(ka, runs) or ka)
413
+ kb = kb if kb in any_asg else (reanchor(kb, runs) or kb)
414
+ if ka == kb:
415
+ return None
416
+ for name, asg in assigns.items():
417
+ if ka not in asg or kb not in asg:
418
+ return None
419
+ out[name] = (asg[ka] == asg[kb]) == (it["label"] == "same")
420
+ return out
421
+
422
+
423
+ _ASSIGN: dict[str, dict] = {}
424
+ _RUNS: dict[str, list] = {}
425
+
426
+
427
+ def score_doc(doc: dict, arms: dict[str, dict], extra=None) -> dict:
428
+ """每条臂:误合并 = 标 different 却同槽;误拆分 = 标 same 却异槽。`widget` / `unsure` 不进分母。
429
+ S1 是按分歧挑的,**单独报**;S2 / S3 / S4 才是率。对不回的 run 先重新锚定(`reanchor`),锚上的记 `(层, "reanchored")`。"""
430
+ res = {}
431
+ if doc["tag"] not in _ASSIGN: # 同一部片子的几批标注共用一次缝合
432
+ L = CL.load(doc["tag"])
433
+ _RUNS[doc["tag"]] = L.runs
434
+ _ASSIGN[doc["tag"]] = {name: assignment(L, CL.stitch(L, **over)) for name, over in arms.items()}
435
+ for name, fn in (extra or {}).items():
436
+ got = fn(L) # 槽位表(窗区域下标)或 run 下标 -> 槽号
437
+ _ASSIGN[doc["tag"]][name] = (
438
+ {(r.t_start, r.t_end, r.text, tuple(round(v) for v in r.box)): got[i]
439
+ for i, r in enumerate(L.runs) if i in got} if isinstance(got, dict) else assignment(L, got))
440
+ assigns = _ASSIGN[doc["tag"]]
441
+ res = {name: Counter() for name in assigns}
442
+ any_asg = next(iter(assigns.values()))
443
+ for it in doc["items"]:
444
+ if it["label"] not in ("same", "different"):
445
+ continue
446
+ v = verdicts(assigns, it, _RUNS.get(doc["tag"])) # 任何一条臂对不回 -> 各臂都记 lost:**各臂的分母必须一致**
447
+ moved = (item_key(it["a"]) not in any_asg) or (item_key(it["b"]) not in any_asg)
448
+ for name in assigns:
449
+ if v is not None and moved:
450
+ res[name][(it["stratum"], "reanchored")] += 1
451
+ if v is None:
452
+ res[name][(it["stratum"], "lost")] += 1
453
+ else:
454
+ tog = v[name] == (it["label"] == "same")
455
+ res[name][(it["stratum"], it["label"], "together" if tog else "apart")] += 1
456
+ return res
457
+
458
+
459
+ def cmd_merge(a) -> int:
460
+ """把标注者写的 `labels*.json` 并进真值文件。id 要逐条对上;已有标注不覆盖(除非 --force)。"""
461
+ gt = Path(a.gt)
462
+ doc = json.loads(gt.read_text(encoding="utf-8"))
463
+ labs = {x["id"]: x for x in json.loads(Path(a.labels).read_text(encoding="utf-8"))}
464
+ ids = [it["id"] for it in doc["items"]]
465
+ if set(labs) != set(ids):
466
+ raise SystemExit(f"id 对不上:标注多 {sorted(set(labs) - set(ids))[:3]}、少 {sorted(set(ids) - set(labs))[:3]}")
467
+ n = 0
468
+ for it in doc["items"]:
469
+ x = labs[it["id"]]
470
+ if x["label"] not in LABELS:
471
+ raise SystemExit(f"{it['id']}: 未知标签 {x['label']!r}")
472
+ if it["label"] is not None and not a.force:
473
+ continue
474
+ it.update(label=x["label"], labeled_by=a.by,
475
+ note=" / ".join(v for v in (x.get("what_a"), x.get("what_b"), x.get("note")) if v))
476
+ n += 1
477
+ gt.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
478
+ print(f"{gt.name}: 并入 {n} 条({a.by});{dict(Counter(it['label'] for it in doc['items']))}")
479
+ return 0
480
+
481
+
482
+ def cmd_score(a) -> int:
483
+ from flowocr.analyze import slot_lines
484
+ from flowocr.analyze import slot_modes
485
+ from flowocr.analyze import slot_veto
486
+ extra = {"modes": slot_modes.stitch, "lines": slot_lines.assign, "veto": slot_veto.stitch,
487
+ "veto+soft": lambda L: slot_veto.stitch(L, soft=0.3),
488
+ "soft-only": lambda L: slot_veto.stitch(L, td_max=99.0, soft=0.3)}
489
+ tot: dict[str, Counter] = {}
490
+ for p in a.gt:
491
+ doc = json.loads(Path(p).read_text(encoding="utf-8"))
492
+ if a.split and doc.get("split") != a.split:
493
+ continue
494
+ lab = Counter(it["label"] for it in doc["items"])
495
+ print(f"[{doc['tag']} s{doc['seed']} {doc.get('split')}] {dict(lab)}")
496
+ for name, c in score_doc(doc, ARMS, extra).items():
497
+ tot.setdefault(name, Counter()).update(c)
498
+ strata = ("S1", "S2", "S3", "S4", "S2-4")
499
+ for c in tot.values(): # S2-4 = 三个"率"层合起来(S1 按分歧挑,不并)
500
+ for k, v in list(c.items()):
501
+ if k[0] in ("S2", "S3", "S4"):
502
+ c[("S2-4",) + k[1:]] += v
503
+ any_c = next(iter(tot.values()))
504
+ n_of = {s: sum(v for k, v in any_c.items() if k[0] == s and len(k) == 3) for s in strata}
505
+ print("\n每格 = 误合并+误拆分=合计;分母是该层标了 same 或 different 的对数("
506
+ + "、".join(f"{s} n={n_of[s]}" for s in strata) + ")")
507
+ print(f"{'臂':<12}" + "".join(f"{s:>14}" for s in strata))
508
+ for name, c in tot.items():
509
+ row = f"{name:<12}"
510
+ for s in strata:
511
+ fm, fs = c[(s, "different", "together")], c[(s, "same", "apart")]
512
+ row += f"{f'{fm}+{fs}={fm + fs}':>14}"
513
+ lost = sum(v for k, v in c.items() if k[1] == "lost" and k[0] != "S2-4")
514
+ moved = sum(v for k, v in c.items() if k[1] == "reanchored" and k[0] != "S2-4")
515
+ print(row + (f" ⚠ 对不回 run {lost}" if lost else "") + (f" (重新锚定 {moved} 对)" if moved else ""))
516
+ print("\n误合并 = 标 different 却同槽;误拆分 = 标 same 却异槽;widget / unsure 不进分母。\n"
517
+ "⚠ S1 按各臂分歧挑,只能比臂。S2(默认臂同槽)/ S3(默认臂近邻异槽)对**默认臂**是无偏的率,\n"
518
+ " 对别的臂是『同一批对上的表现』:默认臂在 S2 只可能误合并、在 S3 只可能误拆分,别的臂两种都可能。")
519
+ return 0
520
+
521
+
522
+ def main() -> int:
523
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
524
+ sub = ap.add_subparsers(dest="cmd", required=True)
525
+ s = sub.add_parser("sample")
526
+ s.add_argument("tag")
527
+ s.add_argument("--n", type=int, default=40)
528
+ s.add_argument("--seed", type=int, default=1)
529
+ s.add_argument("--split", default="dev", choices=("dev", "acceptance"))
530
+ s.add_argument("--force", action="store_true")
531
+ s.set_defaults(fn=cmd_sample)
532
+ sl = sub.add_parser("sample-learned")
533
+ sl.add_argument("tag")
534
+ sl.add_argument("--n", type=int, default=40)
535
+ sl.add_argument("--seed", type=int, default=4)
536
+ sl.add_argument("--train", default="seed1,seed2,seed3")
537
+ sl.add_argument("--split", default="dev", choices=("dev", "acceptance"))
538
+ sl.add_argument("--force", action="store_true")
539
+ sl.set_defaults(fn=cmd_sample_learned)
540
+ sn = sub.add_parser("sample-lines")
541
+ sn.add_argument("tag")
542
+ sn.add_argument("--n", type=int, default=40)
543
+ sn.add_argument("--seed", type=int, default=5)
544
+ sn.add_argument("--split", default="dev", choices=("dev", "acceptance"))
545
+ sn.add_argument("--force", action="store_true")
546
+ sn.set_defaults(fn=cmd_sample_lines)
547
+ m = sub.add_parser("merge")
548
+ m.add_argument("gt")
549
+ m.add_argument("labels")
550
+ m.add_argument("--by", required=True, help="labeled_by:谁标的(sonnet / fable / owner)")
551
+ m.add_argument("--force", action="store_true")
552
+ m.set_defaults(fn=cmd_merge)
553
+ c = sub.add_parser("score")
554
+ c.add_argument("gt", nargs="+")
555
+ c.add_argument("--split", default="", help="只量 dev 或 acceptance")
556
+ c.set_defaults(fn=cmd_score)
557
+ a = ap.parse_args()
558
+ return a.fn(a)
559
+
560
+
561
+ if __name__ == "__main__":
562
+ raise SystemExit(main())
@@ -0,0 +1,104 @@
1
+ """**原型三**:现行缝合(`median` 签名)+ **文本形态否决**(next-steps,owner 2026-09-21 让逐一试的思路之一)。
2
+
3
+ 为什么是"否决"而不是新算法:`median` 在尺上已经是最好的一条臂,它剩下的错里**误合并**这一半有个共同形状——
4
+ 不同时刻占同一位置的不同元素(对话正文 vs 存档菜单的 `Empty` / 标题菜单按钮 / 伤害数字 / 道具名)。纯几何分不开它们,
5
+ 而**文本形态**一眼就能分:台词是含假名的句子,菜单项是短名词,HUD 是数字。
6
+
7
+ 量过的分离度(dev,`median` 判同槽的标注对;窗区域级的六项形态占比 L1 距离):
8
+ 真合并 48 对里 47 对 ≤ 0.87,误合并 23 对里 15 对 ≥ 1.16。所以门取 **1.0**——落在两簇之间的空档里,不是扫出来的最优点。
9
+
10
+ 做法:每个窗区域一个文本画像 = 它那些"像样的 run"的字符类别占比(假名 / 汉字 / 数字 / 拉丁 / 标点)+ 句子样占比的均值;
11
+ 槽位的画像 = 成员画像的逐分量中位数(和几何签名同一个道理)。窗区域进槽、槽与槽合并之前多过一道:画像距离 > `TD_MAX` 就不许。
12
+ 像样的 run 不足 `MIN_GOOD` 条的窗区域没有画像,**不参与否决**(证据不足就不拦——否决只该在有把握时出手)。
13
+
14
+ ⚠ 缝合的骨架是照 `build_tracks.stitch_slots` 抄的(贪心分配 + 收敛后合并;没抄 reassign——`median` 之下它开关分不出来)。
15
+ 这是原型:赢了再把否决移植进 `stitch_slots`,不长期留两份骨架。
16
+
17
+ 用法:
18
+ from flowocr.analyze import slot_veto; slots = slot_veto.stitch(L) # L 来自 cluster_layers.load
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import statistics
23
+ import sys
24
+
25
+ from flowocr.analyze import build_tracks as bt # noqa: E402
26
+ from flowocr.analyze.pair_features import char_profile, good_run # noqa: E402
27
+
28
+ KEYS = ("kana", "han", "digit", "latin", "punct", "sentence")
29
+ TD_MAX = 1.0
30
+ MIN_GOOD = 2
31
+
32
+
33
+ def profiles(L) -> list[dict | None]:
34
+ out = []
35
+ for w in L.win_regions:
36
+ ps = [char_profile(L.runs[i].text) for i in w["runs"] if good_run(L.runs[i])]
37
+ out.append({k: sum(p[k] for p in ps) / len(ps) for k in KEYS} if len(ps) >= MIN_GOOD else None)
38
+ return out
39
+
40
+
41
+ def stitch(L, td_max: float = TD_MAX, soft: float = 0.0) -> list[list[int]]:
42
+ """`soft > 0`:几何上有多个槽位相容时,挑的时候把文本距离也算进去(`1 - y 重叠 + soft × 文本距离`),
43
+ 而不是只看 y 重叠。否决管"不许进",它管"进哪个"。"""
44
+ runs, wr, W = L.runs, L.win_regions, L.W
45
+ a = L.args
46
+ anchor, mutual_x, span = not a["no_slot_anchor"], a["slot_mutual_x"], a["slot_span_ratio"]
47
+ prof = profiles(L)
48
+
49
+ def slot_prof(members: list[int]) -> dict | None:
50
+ ps = [prof[w] for w in members if prof[w] is not None]
51
+ return {k: statistics.median(p[k] for p in ps) for k in KEYS} if ps else None
52
+
53
+ def td(p: dict | None, q: dict | None) -> float | None:
54
+ return None if p is None or q is None else sum(abs(p[k] - q[k]) for k in KEYS)
55
+
56
+ def vetoed(p: dict | None, q: dict | None) -> bool:
57
+ d = td(p, q)
58
+ return d is not None and d > td_max
59
+
60
+ order = sorted(range(len(wr)), key=lambda i: -len(wr[i]["runs"]))
61
+ slots: list[list[int]] = []
62
+ cent, sprof = [], []
63
+ for i in order:
64
+ g = bt.region_geom(runs, wr[i]["runs"])
65
+ best, best_d = -1, None
66
+ for k, sig in enumerate(cent):
67
+ yo = bt.slot_fits(g, sig, W, anchor, mutual_x, span)
68
+ if yo is None or vetoed(prof[i], sprof[k]):
69
+ continue
70
+ d = 1 - yo + soft * (td(prof[i], sprof[k]) or 0.0)
71
+ if best_d is None or d < best_d:
72
+ best, best_d = k, d
73
+ if best < 0:
74
+ slots.append([i]); cent.append(g); sprof.append(prof[i])
75
+ else:
76
+ slots[best].append(i)
77
+ cent[best] = bt.slot_geom_median(runs, wr, slots[best])
78
+ sprof[best] = slot_prof(slots[best])
79
+ changed = True
80
+ while changed:
81
+ changed = False
82
+ x = 0
83
+ while x < len(slots):
84
+ y = x + 1
85
+ while y < len(slots):
86
+ if bt.slot_fits(cent[x], cent[y], W, anchor, mutual_x, span) is None or vetoed(sprof[x], sprof[y]):
87
+ y += 1
88
+ continue
89
+ slots[x].extend(slots[y])
90
+ del slots[y], cent[y], sprof[y]
91
+ cent[x] = bt.slot_geom_median(runs, wr, slots[x])
92
+ sprof[x] = slot_prof(slots[x])
93
+ changed = True
94
+ x += 1
95
+ return slots
96
+
97
+
98
+ if __name__ == "__main__":
99
+ from flowocr.analyze import cluster_layers as CL
100
+ for tag in sys.argv[1:]:
101
+ L = CL.load(tag)
102
+ base, v = CL.stitch(L, geom_mode="median", reassign=False), stitch(L)
103
+ print(f"{tag}: median-nr 槽位 {len(base)} -> 加文本否决 {len(v)};"
104
+ f"有画像的窗区域 {sum(p is not None for p in profiles(L))}/{len(L.win_regions)}")