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,148 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "flowocr-slotpair-lr/1",
|
|
3
|
+
"train": "seed1,seed2,seed3",
|
|
4
|
+
"n_pairs": 520,
|
|
5
|
+
"same_share": 0.4846153846153846,
|
|
6
|
+
"films": [
|
|
7
|
+
"f1",
|
|
8
|
+
"f2",
|
|
9
|
+
"f5",
|
|
10
|
+
"gi1",
|
|
11
|
+
"gi2",
|
|
12
|
+
"hsr",
|
|
13
|
+
"zzz"
|
|
14
|
+
],
|
|
15
|
+
"names": [
|
|
16
|
+
"geom:dx_min",
|
|
17
|
+
"geom:dx0",
|
|
18
|
+
"geom:dcx",
|
|
19
|
+
"geom:dx1",
|
|
20
|
+
"geom:dcy_h",
|
|
21
|
+
"geom:dcy",
|
|
22
|
+
"geom:log_h",
|
|
23
|
+
"geom:y_ov",
|
|
24
|
+
"geom:line_pitch",
|
|
25
|
+
"geom:adjacent",
|
|
26
|
+
"geom:adjacent_cooccur",
|
|
27
|
+
"text:d_kana",
|
|
28
|
+
"text:d_han",
|
|
29
|
+
"text:d_digit",
|
|
30
|
+
"text:d_latin",
|
|
31
|
+
"text:d_punct",
|
|
32
|
+
"text:d_len",
|
|
33
|
+
"text:both_sentence",
|
|
34
|
+
"text:one_sentence",
|
|
35
|
+
"text:both_digit",
|
|
36
|
+
"context:jaccard",
|
|
37
|
+
"context:empty",
|
|
38
|
+
"context:size_ratio",
|
|
39
|
+
"time:log_gap",
|
|
40
|
+
"time:same_win",
|
|
41
|
+
"time:cooccur",
|
|
42
|
+
"time:dur_ratio",
|
|
43
|
+
"block:d_log",
|
|
44
|
+
"block:min",
|
|
45
|
+
"motion:any",
|
|
46
|
+
"motion:both"
|
|
47
|
+
],
|
|
48
|
+
"mean": [
|
|
49
|
+
0.0699228766025641,
|
|
50
|
+
0.10799429086538462,
|
|
51
|
+
0.1030068108974359,
|
|
52
|
+
0.12767177483974357,
|
|
53
|
+
3.1936652700273584,
|
|
54
|
+
0.09923344017094017,
|
|
55
|
+
0.30246944085804583,
|
|
56
|
+
0.35316681611785117,
|
|
57
|
+
0.24837446201624103,
|
|
58
|
+
0.1346153846153846,
|
|
59
|
+
0.007692307692307693,
|
|
60
|
+
0.3061476335921521,
|
|
61
|
+
0.24061425390179172,
|
|
62
|
+
0.09853019440556515,
|
|
63
|
+
0.14903000468764457,
|
|
64
|
+
0.08401970682965783,
|
|
65
|
+
0.516788669038046,
|
|
66
|
+
0.4576923076923077,
|
|
67
|
+
0.3403846153846154,
|
|
68
|
+
0.05961538461538462,
|
|
69
|
+
0.1330363353387835,
|
|
70
|
+
0.0019230769230769232,
|
|
71
|
+
0.6306918193550426,
|
|
72
|
+
7.592952697687726,
|
|
73
|
+
0.1,
|
|
74
|
+
0.01730769230769231,
|
|
75
|
+
1.0078705003845618,
|
|
76
|
+
0.681853793161734,
|
|
77
|
+
0.4485574931192945,
|
|
78
|
+
0.1519230769230769,
|
|
79
|
+
0.05961538461538462
|
|
80
|
+
],
|
|
81
|
+
"scale": [
|
|
82
|
+
0.13388792386621018,
|
|
83
|
+
0.16085754868973048,
|
|
84
|
+
0.14479836490555978,
|
|
85
|
+
0.15020911351160118,
|
|
86
|
+
4.6376748461006905,
|
|
87
|
+
0.14218357888211486,
|
|
88
|
+
0.36134124663010053,
|
|
89
|
+
0.45206150864662475,
|
|
90
|
+
0.2073480274603558,
|
|
91
|
+
0.341312295178824,
|
|
92
|
+
0.0873678207046196,
|
|
93
|
+
0.3029061891248656,
|
|
94
|
+
0.26230960474646586,
|
|
95
|
+
0.23337660396216284,
|
|
96
|
+
0.31294393806224324,
|
|
97
|
+
0.11976005219725482,
|
|
98
|
+
0.48693909795094464,
|
|
99
|
+
0.49820684376230495,
|
|
100
|
+
0.47383850518302423,
|
|
101
|
+
0.2367728669686296,
|
|
102
|
+
0.15949265901986293,
|
|
103
|
+
0.04381071442267121,
|
|
104
|
+
0.5857174299340488,
|
|
105
|
+
2.345660014795674,
|
|
106
|
+
0.3,
|
|
107
|
+
0.13041524487066136,
|
|
108
|
+
0.8573828048996426,
|
|
109
|
+
0.740210935304176,
|
|
110
|
+
0.684135247485618,
|
|
111
|
+
0.3589463130069757,
|
|
112
|
+
0.2367728669686296
|
|
113
|
+
],
|
|
114
|
+
"coef": [
|
|
115
|
+
-0.13316152736046133,
|
|
116
|
+
-0.3411358672387191,
|
|
117
|
+
-0.11410692547482951,
|
|
118
|
+
0.38227353581851553,
|
|
119
|
+
-0.8556508892457648,
|
|
120
|
+
0.5270848240780451,
|
|
121
|
+
-0.42942571709031624,
|
|
122
|
+
0.5520948637275153,
|
|
123
|
+
-0.22074383095431555,
|
|
124
|
+
0.22895759486717707,
|
|
125
|
+
0.029224699823575707,
|
|
126
|
+
-0.0482683314998253,
|
|
127
|
+
-0.2586310825741506,
|
|
128
|
+
-0.4625004096003223,
|
|
129
|
+
-0.2559156547516965,
|
|
130
|
+
-0.12127908932177868,
|
|
131
|
+
-0.16204901205862512,
|
|
132
|
+
0.5942057710862333,
|
|
133
|
+
-0.012059639241410746,
|
|
134
|
+
-0.04113937671082802,
|
|
135
|
+
0.9731627405821162,
|
|
136
|
+
-0.04713657421699171,
|
|
137
|
+
-0.4469760989209348,
|
|
138
|
+
-0.34000970439419825,
|
|
139
|
+
-0.10206692048539355,
|
|
140
|
+
0.14914510378121065,
|
|
141
|
+
-0.18930687051081943,
|
|
142
|
+
-0.7373219232853451,
|
|
143
|
+
-0.04902222631119288,
|
|
144
|
+
-0.3098984971627217,
|
|
145
|
+
0.49250801094168795
|
|
146
|
+
],
|
|
147
|
+
"intercept": -0.22617480264433304
|
|
148
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""**名牌带的几何判据(七条阈值)**——⚠ **2026-09-20 起不再是唯一实现,也不再是推荐的那份**。
|
|
2
|
+
|
|
3
|
+
取代它的是 `flowocr.analyze.uigate` 的**框位 + 相对位置**判据(ui-gate 计划):
|
|
4
|
+
五部整片上这七条**四部挑中 0 个**,框位那条五部都有输出,唯一可比的 f3 上精确率打平、召回 +14 个点。
|
|
5
|
+
病根有两层:①第 3 条"字比正文大"和第 6 条"时长落在 [2,10] 秒"**实测全不成立**
|
|
6
|
+
(owner 2026-09-19:第 3 条"不对,删掉",第 6 条改成"时长通常 >= 台词");
|
|
7
|
+
②更要紧的是**输入**——五部里三部的名牌 run 被一个五千到一万条的巨型区域吞掉了,
|
|
8
|
+
**区域那一侧根本没有名牌可挑**。
|
|
9
|
+
|
|
10
|
+
**2026-09-20 起 `merge_nameplate` 默认不用它了**(`--np-source track`):名牌改由
|
|
11
|
+
`build_tracks` 打标、投影成一条 `kind="nameplate"` 的轨。这里留下的是:
|
|
12
|
+
`attach()`(贴法 99.8% 准,和判据是两件事)+ 七条阈值本身(`--np-source geom` 的对照臂)。
|
|
13
|
+
**要删七条,先把 `probe_nameplate_geom.py` 一起处理掉**——它存在的意义就是评这七条。
|
|
14
|
+
|
|
15
|
+
以下是原来的说明。
|
|
16
|
+
|
|
17
|
+
来历(methodology-audit-2 报告):把名牌并进主轨,input1 上命中 +66、
|
|
18
|
+
纯标点行漏条率 97.6% → 52.4%,比"并整个辩论屏区域"两项都好。但那次挑名牌用的是
|
|
19
|
+
**角色名表**——探针能用、修法不能用(换素材就没有名表了)。所以判据只看几何和
|
|
20
|
+
区域统计量,**一个字都不看**;名表只出现在 `probe_nameplate_geom.py` 里,用来评
|
|
21
|
+
这个模块准不准(精确率/召回)。
|
|
22
|
+
|
|
23
|
+
放在单独模块而不是留在探针里,是因为它现在有两个消费者
|
|
24
|
+
(`probe_nameplate_geom.py` 评判据、`merge_nameplate.py` 用判据)。
|
|
25
|
+
历史上这个项目在"同一条规则抄两份"上栽过——methodology-audit 报告那次
|
|
26
|
+
是 5 个工具各写各的 SRT 解析,代价是一个错了 30 小时的计数。
|
|
27
|
+
|
|
28
|
+
形状假设(每一条都在五部整片上验过,数见 methodology-audit-2 报告):
|
|
29
|
+
|
|
30
|
+
* 位置固定:`cx` 在全片上几乎不动(`--cx-std`);
|
|
31
|
+
* 在正文**上方紧挨着的一档**:`cy` 比主轨小 `--dy` ~ `--dy-max`——
|
|
32
|
+
**上界不能省**,否则屏幕顶上的标题/计时器也会被挑中;
|
|
33
|
+
* **字比正文大**:字高中位是主轨的 `--font-ratio` 倍以上;
|
|
34
|
+
* **词表很小**:十来个名字轮着出,`distinct_text_ratio` 低;
|
|
35
|
+
* **cue 时长像台词**:中位 4.5–5.5 s。
|
|
36
|
+
这一条是 2026-09-07 换上去的,换掉的是 `dominant_share <= 0.5`——
|
|
37
|
+
那道闸门本意是排水印,实际**先把主角的名牌排除了**(`階堂ヒロ` 占那块区域
|
|
38
|
+
68–85%),f3/f4/f5 的召回因此卡在 40–51%。真正分得开的是时长:
|
|
39
|
+
名牌 4.5–5.5 s、`Auto` 按钮 21–43 s、误识的 `大`/`米`/`è` 0.5–1.5 s。
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import statistics as st
|
|
44
|
+
from bisect import bisect_left
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def region_stats(d: dict) -> list[dict]:
|
|
48
|
+
"""把 `tracks.json` 的每个区域压成判据要用的那几个量(`runs` 原样带着)。"""
|
|
49
|
+
W, H = d["size"]
|
|
50
|
+
regs = []
|
|
51
|
+
for r in d["regions"]:
|
|
52
|
+
runs = r["runs"]
|
|
53
|
+
if not runs:
|
|
54
|
+
continue
|
|
55
|
+
cx = [(b["box"][0] + b["box"][2]) / 2 / W for b in runs]
|
|
56
|
+
f = r["features"]
|
|
57
|
+
regs.append({"i": r["index"], "label": r["label"], "n": len(runs),
|
|
58
|
+
"cy": st.median((b["box"][1] + b["box"][3]) / 2 / H for b in runs),
|
|
59
|
+
"cx_std": st.pstdev(cx) if len(cx) > 1 else 0.0,
|
|
60
|
+
"h": st.median(b["box"][3] - b["box"][1] for b in runs),
|
|
61
|
+
"pers": f["persistence"], "distinct": f["distinct_text_ratio"],
|
|
62
|
+
"domi": f["dominant_share"], "dur": f["median_dur_s"],
|
|
63
|
+
"fill": f["fill_active"], "score": r["primary_score"], "runs": runs})
|
|
64
|
+
return regs
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def explain(r: dict, main_r: dict, a) -> list[tuple[str, bool, str]]:
|
|
68
|
+
"""逐条判据的 (名字, 过没过, 实测值 vs 门槛)。**`pick()` 就是按它判的**——
|
|
69
|
+
两处分开写必然漂移,这个项目在"同一条规则抄两份"上栽过。
|
|
70
|
+
|
|
71
|
+
换素材时最常问的是"为什么一个都没挑中",而那时**没有角色名表**
|
|
72
|
+
(名表只有 yuka 那套有)。所以这个函数不看文本、不需要参照,
|
|
73
|
+
`probe_nameplate_geom.py --names` 省掉时就靠它回答。
|
|
74
|
+
"""
|
|
75
|
+
return [
|
|
76
|
+
("run 数", r["n"] >= a.min_runs, f"{r['n']} >= {a.min_runs}"),
|
|
77
|
+
("在正文上方一档", main_r["cy"] - a.dy_max <= r["cy"] <= main_r["cy"] - a.dy,
|
|
78
|
+
f"cy {r['cy']:.3f},要落在 [{main_r['cy'] - a.dy_max:.3f}, {main_r['cy'] - a.dy:.3f}]"),
|
|
79
|
+
("字比正文大", r["h"] >= main_r["h"] * a.font_ratio,
|
|
80
|
+
f"字高 {r['h']:.0f} >= {main_r['h']:.0f}×{a.font_ratio} = {main_r['h'] * a.font_ratio:.0f}"),
|
|
81
|
+
("位置固定", r["cx_std"] <= a.cx_std, f"cx std {r['cx_std']:.3f} <= {a.cx_std}"),
|
|
82
|
+
("词表小", r["distinct"] <= a.max_distinct,
|
|
83
|
+
f"distinct {r['distinct']:.3f} <= {a.max_distinct}"),
|
|
84
|
+
("时长像台词", a.dur_lo <= r["dur"] <= a.dur_hi,
|
|
85
|
+
f"中位 {r['dur']:.1f}s 落在 [{a.dur_lo}, {a.dur_hi}]"),
|
|
86
|
+
("不是常驻", r["fill"] <= a.max_fill, f"fill {r['fill']:.2f} <= {a.max_fill}"),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pick(regs: list[dict], a) -> tuple[dict, list[dict]]:
|
|
91
|
+
"""返回 `(主轨区域, 判为名牌的区域)`。`a` 是 `add_args()` 那组参数。"""
|
|
92
|
+
main_r = max(regs, key=lambda x: x["score"])
|
|
93
|
+
picked = [r for r in regs if r["i"] != main_r["i"]
|
|
94
|
+
and all(ok for _, ok, _ in explain(r, main_r, a))]
|
|
95
|
+
return main_r, picked
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def attach(main: list, names: list) -> tuple[list, int]:
|
|
99
|
+
"""把名牌**贴进时间重叠最大的那条正文 cue 的首行**,返回 (SRT blocks, 贴上几条)。
|
|
100
|
+
|
|
101
|
+
贴法决定一切(speaker-name-line 报告离线量过三种):
|
|
102
|
+
"覆盖 cue 起点的最后一条" 只有 73.1% 对、"起点最近 ±1.5s" 97.2%、
|
|
103
|
+
**"时间重叠最大" 99.8%**(938/940)。第一版探针用的是 73% 那种,
|
|
104
|
+
结论出来是"贴了等于没贴"——那是贴错了 27%,不是名字没用。
|
|
105
|
+
|
|
106
|
+
两个消费者:`probe_add_speaker.py`(探针,名表过滤后贴)和
|
|
107
|
+
`merge_nameplate.py --paste`(修法,几何判据挑区域后贴)。**别抄第三份。**
|
|
108
|
+
"""
|
|
109
|
+
starts = [c.start for c in names]
|
|
110
|
+
blocks, tagged = [], 0
|
|
111
|
+
for c in main:
|
|
112
|
+
i = bisect_left(starts, c.start)
|
|
113
|
+
nm, best = None, 0.0
|
|
114
|
+
for j in range(max(0, i - 8), min(len(names), i + 8)):
|
|
115
|
+
o = min(names[j].end, c.end) - max(names[j].start, c.start)
|
|
116
|
+
if o > best:
|
|
117
|
+
nm, best = names[j], o
|
|
118
|
+
lines = list(c.lines)
|
|
119
|
+
if nm is not None and nm.lines and nm.text("") not in c.text(""):
|
|
120
|
+
lines = [nm.lines[0]] + lines
|
|
121
|
+
tagged += 1
|
|
122
|
+
blocks.append((int(round(c.start * 1e6)), int(round(c.end * 1e6)), lines))
|
|
123
|
+
return blocks, tagged
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def add_args(ap) -> None:
|
|
127
|
+
"""判据的旋钮。两个消费者共用,**别抄第二份**。"""
|
|
128
|
+
ap.add_argument("--dy", type=float, default=0.04,
|
|
129
|
+
help="名牌至少要在主轨上方这么多(归一化 cy)")
|
|
130
|
+
ap.add_argument("--dy-max", type=float, default=0.15,
|
|
131
|
+
help="但也不能高太多——那是屏幕顶上的标题")
|
|
132
|
+
ap.add_argument("--max-distinct", type=float, default=0.25,
|
|
133
|
+
help="不同文本的占比上限:名牌就十来个名字轮着出")
|
|
134
|
+
ap.add_argument("--dur-lo", type=float, default=2.0,
|
|
135
|
+
help="cue 时长中位的下限(秒):名牌跟着台词走,一句几秒")
|
|
136
|
+
ap.add_argument("--dur-hi", type=float, default=10.0,
|
|
137
|
+
help="上限:常驻按钮/水印一挂就是几十秒(`Auto` 实测 21-43 s)")
|
|
138
|
+
ap.add_argument("--max-fill", type=float, default=0.8,
|
|
139
|
+
help="fill_active 上限:真水印几乎全程在(`#` 实测 1.00)。"
|
|
140
|
+
"**在 yuka 五部上一个数都不改**,是给别的素材兜底的")
|
|
141
|
+
ap.add_argument("--font-ratio", type=float, default=1.3,
|
|
142
|
+
help="字高中位至少是主轨的几倍")
|
|
143
|
+
ap.add_argument("--cx-std", type=float, default=0.10,
|
|
144
|
+
help="cx 的标准差上限(位置固定)。0.06 -> 0.10 让 f4 的召回从 "
|
|
145
|
+
"50.7%% 涨到 90.1%%,另外四部一个数不动;0.14 和 0.10 结果完全相同,"
|
|
146
|
+
"**阈值坐在平台上而不是悬崖上**")
|
|
147
|
+
ap.add_argument("--min-runs", type=int, default=30,
|
|
148
|
+
help="区域小于这么多 run 就不考虑")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""**两条 run 该不该在一起**的成对特征,按"通道"分组——用来回答"哪种信号真有信息"(next-steps,owner 2026-09-21)。
|
|
2
|
+
|
|
3
|
+
聚类的尺(`slot_pairs.py`)产出了带标注的 run 对;这里给每一对算特征,`pair_model.py` 拿它们做逐通道消融。
|
|
4
|
+
**先知道哪条通道有用,再决定把它怎么接进聚类**——不先造算法再找证据。
|
|
5
|
+
|
|
6
|
+
通道:
|
|
7
|
+
|
|
8
|
+
geom 几何:x0 / cx / x1 / cy / 上下边的差、字高比、y 区间重叠。现行缝合只用这一条
|
|
9
|
+
text 文本形态:长度、假名 / 汉字 / 拉丁 / 数字 / 标点占比、是不是句子样(句末标点、含假名)。
|
|
10
|
+
同一个元素里的文本形态很稳(台词是句子、HUD 是数字、名牌是短名词)
|
|
11
|
+
context **时间当关系信号用**(owner:"时间是个可用于否决的弱信号,但怎么用需要好的设计"):
|
|
12
|
+
一条 run 在屏时,**同屏还有哪些位置有字**。对话正文总和名牌、Auto 键一起出现;
|
|
13
|
+
菜单项总和别的菜单项一起出现。两条 run 位置相同、同屏伙伴却完全不同 → 多半不是同一个元素。
|
|
14
|
+
做法:画面切成 CELLS 网格,每秒记哪些格子有字;run 的上下文 = 它在屏期间出现过的格子
|
|
15
|
+
(去掉自己那一格和紧邻的),按 idf 加权(常驻水印那种格子几乎不计);两条 run 比加权 Jaccard
|
|
16
|
+
time 朴素的时间:间隔(对数)、是否同一个 60 s 窗、在屏时长比
|
|
17
|
+
block 块:同屏、同左边、同字高的行数(阅读面板 / 名单是一大块,对话是 1~3 行)
|
|
18
|
+
motion `Run.moving` / `Run.scrolling`
|
|
19
|
+
|
|
20
|
+
用法:
|
|
21
|
+
F = PairFeatures(L) # L 来自 cluster_layers.load
|
|
22
|
+
x, names = F.pair(i, j) # 两条 run 的下标 -> 特征向量
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import unicodedata
|
|
28
|
+
from collections import Counter, defaultdict
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CELLS = (16, 18) # x 方向 16 格、y 方向 18 格(1080p 上 120 × 60 px)
|
|
32
|
+
CTX_SECONDS = 12 # 一条 run 只取在屏的前这么多秒算上下文(常驻的不必扫全程)
|
|
33
|
+
CHANNELS = ("geom", "text", "context", "time", "block", "motion")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def char_profile(t: str) -> dict[str, float]:
|
|
37
|
+
t = "".join(unicodedata.normalize("NFKC", t).split())
|
|
38
|
+
n = max(1, len(t))
|
|
39
|
+
c = Counter()
|
|
40
|
+
for ch in t:
|
|
41
|
+
o = ord(ch)
|
|
42
|
+
if 0x3040 <= o <= 0x30FF:
|
|
43
|
+
c["kana"] += 1
|
|
44
|
+
elif 0x4E00 <= o <= 0x9FFF:
|
|
45
|
+
c["han"] += 1
|
|
46
|
+
elif ch.isdigit():
|
|
47
|
+
c["digit"] += 1
|
|
48
|
+
elif ch.isascii() and ch.isalpha():
|
|
49
|
+
c["latin"] += 1
|
|
50
|
+
elif not ch.isalnum():
|
|
51
|
+
c["punct"] += 1
|
|
52
|
+
out = {k: c[k] / n for k in ("kana", "han", "digit", "latin", "punct")}
|
|
53
|
+
out["len"] = math.log1p(len(t))
|
|
54
|
+
out["sentence"] = float(bool(t) and (t[-1] in "。!?!?…」』))" or c["kana"] >= 3))
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def content_len(t: str) -> int:
|
|
59
|
+
return sum(1 for c in t if c.isalnum())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
MIN_CONTENT, MIN_OBS = 3, 2
|
|
63
|
+
"""**只在"像样的文字"里抽**:内容 ≥3 字、至少 2 次观测。第一版按 run 数加权、不筛,
|
|
64
|
+
f5 头 4 对里 3 对是 OCR 把**鼠标指针**读成的字(只能标 unsure),标注预算被垃圾区域吃掉。
|
|
65
|
+
⚠ 这是一道筛子:尺量不到"垃圾 run 落在哪个槽位"——那是清噪的事,不是聚类的对错;
|
|
66
|
+
`sample` 会把筛掉的 run 占比打出来,读数时摆在旁边。"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def good_run(r) -> bool:
|
|
70
|
+
return content_len(r.text) >= MIN_CONTENT and r.n_obs >= MIN_OBS
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
FEATURE_NAMES = [
|
|
74
|
+
"geom:dx_min", "geom:dx0", "geom:dcx", "geom:dx1", "geom:dcy_h", "geom:dcy", "geom:log_h", "geom:y_ov",
|
|
75
|
+
"geom:line_pitch", "geom:adjacent", "geom:adjacent_cooccur", "text:d_kana", "text:d_han", "text:d_digit", "text:d_latin", "text:d_punct", "text:d_len",
|
|
76
|
+
"text:both_sentence", "text:one_sentence", "text:both_digit", "context:jaccard", "context:empty",
|
|
77
|
+
"context:size_ratio", "time:log_gap", "time:same_win", "time:cooccur", "time:dur_ratio", "block:d_log",
|
|
78
|
+
"block:min", "motion:any", "motion:both"]
|
|
79
|
+
"""`pair()` 吐的特征顺序。落盘的模型按这张表核对——特征一改,旧模型就不许再用。"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PairFeatures:
|
|
83
|
+
def __init__(self, L) -> None:
|
|
84
|
+
self.L = L
|
|
85
|
+
self.runs = L.runs
|
|
86
|
+
W, H = L.W, L.H
|
|
87
|
+
self.cell = [(min(CELLS[0] - 1, int(r.cx / W * CELLS[0])), min(CELLS[1] - 1, int(r.cy / H * CELLS[1])))
|
|
88
|
+
for r in self.runs]
|
|
89
|
+
self.prof = [char_profile(r.text) for r in self.runs]
|
|
90
|
+
# 每秒哪些格子有字 / 哪些 run 在屏
|
|
91
|
+
self.by_sec: dict[int, list[int]] = defaultdict(list)
|
|
92
|
+
for i, r in enumerate(self.runs):
|
|
93
|
+
for s in range(int(r.t_start // 1e6), int(r.t_end // 1e6) + 1):
|
|
94
|
+
self.by_sec[s].append(i)
|
|
95
|
+
occ = Counter()
|
|
96
|
+
for s, ids in self.by_sec.items():
|
|
97
|
+
for c in {self.cell[i] for i in ids}:
|
|
98
|
+
occ[c] += 1
|
|
99
|
+
T = max(1, len(self.by_sec))
|
|
100
|
+
self.idf = {c: math.log(T / n) for c, n in occ.items()}
|
|
101
|
+
self._ctx: dict[int, dict] = {}
|
|
102
|
+
self._blk: dict[int, int] = {}
|
|
103
|
+
|
|
104
|
+
def context(self, i: int) -> dict[tuple, float]:
|
|
105
|
+
if i not in self._ctx:
|
|
106
|
+
r, (cx, cy) = self.runs[i], self.cell[i]
|
|
107
|
+
s0 = int(r.t_start // 1e6)
|
|
108
|
+
cells = set()
|
|
109
|
+
for s in range(s0, min(int(r.t_end // 1e6), s0 + CTX_SECONDS) + 1):
|
|
110
|
+
cells.update(self.cell[j] for j in self.by_sec.get(s, ()) if j != i)
|
|
111
|
+
self._ctx[i] = {c: self.idf.get(c, 0.0) for c in cells
|
|
112
|
+
if abs(c[0] - cx) > 1 or abs(c[1] - cy) > 1}
|
|
113
|
+
return self._ctx[i]
|
|
114
|
+
|
|
115
|
+
def block(self, i: int) -> int:
|
|
116
|
+
"""同屏、左边对齐(±1.5% 画面宽)、字高相近(比 ≤1.3)的行数(含自己)。"""
|
|
117
|
+
if i not in self._blk:
|
|
118
|
+
r = self.runs[i]
|
|
119
|
+
s = int((r.t_start + min(r.t_end, r.t_start + 2_000_000)) // 2e6)
|
|
120
|
+
self._blk[i] = sum(1 for j in self.by_sec.get(s, ())
|
|
121
|
+
if abs(self.runs[j].box[0] - r.box[0]) <= 0.015 * self.L.W
|
|
122
|
+
and max(self.runs[j].h, r.h) / max(1.0, min(self.runs[j].h, r.h)) <= 1.3)
|
|
123
|
+
return self._blk[i]
|
|
124
|
+
|
|
125
|
+
def pair(self, i: int, j: int) -> tuple[list[float], list[str]]:
|
|
126
|
+
a, b, W, H = self.runs[i], self.runs[j], self.L.W, self.L.H
|
|
127
|
+
h = max(1.0, min(a.h, b.h))
|
|
128
|
+
f: dict[str, float] = {}
|
|
129
|
+
dx0, dcx, dx1 = abs(a.box[0] - b.box[0]) / W, abs(a.cx - b.cx) / W, abs(a.box[2] - b.box[2]) / W
|
|
130
|
+
f["geom:dx_min"] = min(dx0, dcx, dx1)
|
|
131
|
+
f["geom:dx0"], f["geom:dcx"], f["geom:dx1"] = dx0, dcx, dx1
|
|
132
|
+
f["geom:dcy_h"] = min(20.0, abs(a.cy - b.cy) / h)
|
|
133
|
+
f["geom:dcy"] = abs(a.cy - b.cy) / H
|
|
134
|
+
f["geom:log_h"] = abs(math.log(max(1.0, a.h) / max(1.0, b.h)))
|
|
135
|
+
ov = min(a.box[3], b.box[3]) - max(a.box[1], b.box[1])
|
|
136
|
+
f["geom:y_ov"] = max(0.0, ov) / h
|
|
137
|
+
f["geom:line_pitch"] = abs((abs(a.cy - b.cy) / h) - round(abs(a.cy - b.cy) / h / 1.35) * 1.35) # 离"整数行距"多远
|
|
138
|
+
# **相邻行**:同锚点、同字高、隔一到两个行距。线性模型只有"竖直距离越大越不像"一项,表达不了
|
|
139
|
+
# "多行正文的上下行是同一块"——hsr 的三行台词因此被拆成两个区域、每段都够不着剧本行(2026-09-21)。
|
|
140
|
+
# 同屏出现的相邻行是最强的证据(同一时刻、同一个锚点、差一个行距 = 同一块多行文本),单列一项。
|
|
141
|
+
gap0 = max(0, max(a.t_start, b.t_start) - min(a.t_end, b.t_end))
|
|
142
|
+
adj = float(0.7 <= f["geom:dcy_h"] <= 2.9 and f["geom:dx_min"] <= 0.02 and f["geom:log_h"] <= 0.26)
|
|
143
|
+
f["geom:adjacent"] = adj
|
|
144
|
+
f["geom:adjacent_cooccur"] = adj * float(gap0 == 0)
|
|
145
|
+
pa, pb = self.prof[i], self.prof[j]
|
|
146
|
+
for k in ("kana", "han", "digit", "latin", "punct", "len"):
|
|
147
|
+
f[f"text:d_{k}"] = abs(pa[k] - pb[k])
|
|
148
|
+
f["text:both_sentence"] = pa["sentence"] * pb["sentence"]
|
|
149
|
+
f["text:one_sentence"] = abs(pa["sentence"] - pb["sentence"])
|
|
150
|
+
f["text:both_digit"] = float(pa["digit"] > 0.3 and pb["digit"] > 0.3)
|
|
151
|
+
ca, cb = self.context(i), self.context(j)
|
|
152
|
+
inter = sum(min(ca[c], cb[c]) for c in ca.keys() & cb.keys())
|
|
153
|
+
union = sum(ca.values()) + sum(cb.values()) - inter
|
|
154
|
+
f["context:jaccard"] = inter / union if union > 0 else 0.0
|
|
155
|
+
f["context:empty"] = float(not ca or not cb)
|
|
156
|
+
f["context:size_ratio"] = abs(math.log((1 + len(ca)) / (1 + len(cb))))
|
|
157
|
+
gap = max(0, max(a.t_start, b.t_start) - min(a.t_end, b.t_end)) / 1e6
|
|
158
|
+
f["time:log_gap"] = math.log1p(gap)
|
|
159
|
+
f["time:same_win"] = float(a.t_start // 60_000_000 == b.t_start // 60_000_000)
|
|
160
|
+
f["time:cooccur"] = float(gap == 0)
|
|
161
|
+
f["time:dur_ratio"] = abs(math.log((1 + a.t_end - a.t_start) / (1 + b.t_end - b.t_start)))
|
|
162
|
+
f["block:d_log"] = abs(math.log(self.block(i) / self.block(j)))
|
|
163
|
+
f["block:min"] = math.log(min(self.block(i), self.block(j)))
|
|
164
|
+
f["motion:any"] = float(a.moving or a.scrolling or b.moving or b.scrolling)
|
|
165
|
+
f["motion:both"] = float((a.moving or a.scrolling) and (b.moving or b.scrolling))
|
|
166
|
+
names = list(f)
|
|
167
|
+
assert names == FEATURE_NAMES, "pair() 的特征顺序和 FEATURE_NAMES 对不上"
|
|
168
|
+
return [f[k] for k in names], names
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""逐通道消融:哪种信号对"这两条 run 该不该在一起"真有信息(特征见 `pair_features.py`)。
|
|
2
|
+
|
|
3
|
+
在 dev 的标注对上训一个小模型、在留出集上测,**逐通道**报:单独用这一条 / 全用 / 全用但去掉这一条。
|
|
4
|
+
同一批留出对上各条缝合臂的对错率摆在旁边当参照(臂只有"同槽 / 异槽"两个输出,模型是它的上界参照,不是替代品)。
|
|
5
|
+
|
|
6
|
+
⚠ 样本只有几百对:只读**方向和量级**,别读小数点。模型用带标准化的逻辑回归(可解释、不容易过拟合);
|
|
7
|
+
`--gbdt` 另报一个浅的梯度提升做对照——两者方向不一致的通道不要信。
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python -m flowocr.analyze.pair_model --train dev --test acceptance
|
|
11
|
+
python -m flowocr.analyze.pair_model --train dev,acceptance --test seed3 # seed 3 那批标完之后
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import glob
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from flowocr.analyze import cluster_layers as CL # noqa: E402
|
|
23
|
+
from flowocr.analyze import pair_features as PF # noqa: E402
|
|
24
|
+
from flowocr import paths # noqa: E402
|
|
25
|
+
from flowocr.analyze import slot_pairs as SP # noqa: E402
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(selectors: list[str]):
|
|
29
|
+
"""selectors:`dev` / `acceptance`(按 split)或 `seedN`(按 seed)。返回 X, y, 元信息, 特征名。"""
|
|
30
|
+
X, y, meta, names = [], [], [], None
|
|
31
|
+
by_tag: dict[str, list] = {}
|
|
32
|
+
for p in sorted(glob.glob(str(paths.data_root() / "data/gt/slotpairs-*.json"))):
|
|
33
|
+
doc = json.loads(Path(p).read_text(encoding="utf-8"))
|
|
34
|
+
if not (doc.get("split") in selectors or f"seed{doc['seed']}" in selectors):
|
|
35
|
+
continue
|
|
36
|
+
if any(s.startswith("seed") for s in selectors) and f"seed{doc['seed']}" not in selectors:
|
|
37
|
+
continue
|
|
38
|
+
by_tag.setdefault(doc["tag"], []).extend(doc["items"])
|
|
39
|
+
for tag, items in by_tag.items():
|
|
40
|
+
L = CL.load(tag)
|
|
41
|
+
F = PF.PairFeatures(L)
|
|
42
|
+
key = {(r.t_start, r.t_end, r.text, tuple(round(v) for v in r.box)): i for i, r in enumerate(L.runs)}
|
|
43
|
+
for it in items:
|
|
44
|
+
if it["label"] not in ("same", "different"):
|
|
45
|
+
continue
|
|
46
|
+
i, j = key.get(SP.item_key(it["a"])), key.get(SP.item_key(it["b"]))
|
|
47
|
+
if i is None or j is None:
|
|
48
|
+
continue
|
|
49
|
+
x, names = F.pair(i, j)
|
|
50
|
+
X.append(x)
|
|
51
|
+
y.append(int(it["label"] == "same"))
|
|
52
|
+
meta.append((tag, it["id"], it["stratum"]))
|
|
53
|
+
return np.array(X, float), np.array(y), meta, names
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> int:
|
|
57
|
+
from sklearn.ensemble import GradientBoostingClassifier
|
|
58
|
+
from sklearn.linear_model import LogisticRegression
|
|
59
|
+
from sklearn.metrics import roc_auc_score
|
|
60
|
+
from sklearn.pipeline import make_pipeline
|
|
61
|
+
from sklearn.preprocessing import StandardScaler
|
|
62
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
63
|
+
ap.add_argument("--train", default="dev")
|
|
64
|
+
ap.add_argument("--test", default="acceptance")
|
|
65
|
+
ap.add_argument("--gbdt", action="store_true")
|
|
66
|
+
a = ap.parse_args()
|
|
67
|
+
Xtr, ytr, _, names = load(a.train.split(","))
|
|
68
|
+
Xte, yte, mte, _ = load(a.test.split(","))
|
|
69
|
+
print(f"训练 {len(ytr)} 对(same {ytr.mean():.0%})、留出 {len(yte)} 对(same {yte.mean():.0%});特征 {len(names)} 个")
|
|
70
|
+
|
|
71
|
+
def fit_eval(cols: list[int], gbdt: bool = False) -> tuple[float, float]:
|
|
72
|
+
m = (GradientBoostingClassifier(n_estimators=60, max_depth=2, subsample=0.8, random_state=0) if gbdt
|
|
73
|
+
else make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=2000)))
|
|
74
|
+
m.fit(Xtr[:, cols], ytr)
|
|
75
|
+
p = m.predict_proba(Xte[:, cols])[:, 1]
|
|
76
|
+
return float(((p > 0.5) == yte).mean()), float(roc_auc_score(yte, p))
|
|
77
|
+
|
|
78
|
+
chan = {c: [k for k, n in enumerate(names) if n.startswith(c + ":")] for c in PF.CHANNELS}
|
|
79
|
+
allc = list(range(len(names)))
|
|
80
|
+
kinds = [("LR", False)] + ([("GBDT", True)] if a.gbdt else [])
|
|
81
|
+
for kind, g in kinds:
|
|
82
|
+
acc, auc = fit_eval(allc, g)
|
|
83
|
+
print(f"\n[{kind}] 全部通道:准确率 {acc:.1%}、AUC {auc:.3f}")
|
|
84
|
+
print(f" {'通道':<10}{'只用它':>16}{'去掉它':>16} (准确率 / AUC)")
|
|
85
|
+
for c, cols in chan.items():
|
|
86
|
+
a1, u1 = fit_eval(cols, g)
|
|
87
|
+
a0, u0 = fit_eval([k for k in allc if k not in cols], g)
|
|
88
|
+
print(f" {c:<10}{f'{a1:.1%} / {u1:.3f}':>16}{f'{a0:.1%} / {u0:.3f}':>16}")
|
|
89
|
+
m = make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=2000)).fit(Xtr, ytr)
|
|
90
|
+
coef = m[-1].coef_[0]
|
|
91
|
+
print("\nLR 标准化系数(正 = 倾向 same):")
|
|
92
|
+
for k in np.argsort(-np.abs(coef))[:14]:
|
|
93
|
+
print(f" {names[k]:<24}{coef[k]:+.2f}")
|
|
94
|
+
|
|
95
|
+
# 参照:同一批留出对上各条缝合臂的准确率
|
|
96
|
+
from flowocr.analyze import slot_lines
|
|
97
|
+
from flowocr.analyze import slot_modes
|
|
98
|
+
by_tag: dict[str, list[int]] = {}
|
|
99
|
+
for k, (tag, _, _) in enumerate(mte):
|
|
100
|
+
by_tag.setdefault(tag, []).append(k)
|
|
101
|
+
docs = {}
|
|
102
|
+
for p in sorted(glob.glob(str(paths.data_root() / "data/gt/slotpairs-*.json"))):
|
|
103
|
+
d = json.loads(Path(p).read_text(encoding="utf-8"))
|
|
104
|
+
for it in d["items"]:
|
|
105
|
+
docs[it["id"]] = it
|
|
106
|
+
right: dict[str, int] = {}
|
|
107
|
+
n_ref = 0
|
|
108
|
+
for tag, ks in by_tag.items():
|
|
109
|
+
L = CL.load(tag)
|
|
110
|
+
asg = {name: SP.assignment(L, CL.stitch(L, **over)) for name, over in SP.ARMS.items()}
|
|
111
|
+
asg["modes"] = SP.assignment(L, slot_modes.stitch(L))
|
|
112
|
+
got = slot_lines.assign(L)
|
|
113
|
+
asg["lines"] = {(r.t_start, r.t_end, r.text, tuple(round(v) for v in r.box)): got[i] for i, r in enumerate(L.runs)}
|
|
114
|
+
for k in ks:
|
|
115
|
+
v = SP.verdicts(asg, docs[mte[k][1]])
|
|
116
|
+
if v is None: # 对不回 run 的不进任何臂的分母
|
|
117
|
+
continue
|
|
118
|
+
n_ref += 1
|
|
119
|
+
for name, ok in v.items():
|
|
120
|
+
right[name] = right.get(name, 0) + int(ok)
|
|
121
|
+
print("\n参照——同一批留出对上各条缝合臂的准确率(含 S1):")
|
|
122
|
+
print(f" ({n_ref}/{len(yte)} 对在各臂上都对得回 run) " + " ".join(f"{n} {v / max(1, n_ref):.1%}" for n, v in right.items()))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
raise SystemExit(main())
|