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,247 @@
|
|
|
1
|
+
"""自选 OCR 范围的多组(ocr-regions 计划):**每组一条独立 OCR 线、同时跑**,阶段一能共用的部分共用。
|
|
2
|
+
|
|
3
|
+
python -m flowocr.extract.run_groups <视频> --regions <json> --outdir <目录> [--start 秒 --end 秒] [其余参数原样给 run_ocr2]
|
|
4
|
+
|
|
5
|
+
产物:`<目录>/<组名>.jsonl`(和单组 `run_ocr2 --regions … --region-group …` 同一份东西,`_meta.run_groups` 另记这一趟的共用方式)。
|
|
6
|
+
之后各组各自建轨(阶段二)、各自输出(阶段三)。
|
|
7
|
+
|
|
8
|
+
**共用的是什么**(落地结构,方案的首版):每组一个管线进程(组之间的事件状态天然隔离),
|
|
9
|
+
det 和 rec 各走**一个**共享 ORT 服务——模型和显存一份(同 `--workers`,`run_ocr2.start_ort_services`);
|
|
10
|
+
`--share-decode`(默认开)时解码也只有一份:一个广播解码进程(`decode_proc.hub_serve`)按分片解一遍、同一份采样帧给各组、
|
|
11
|
+
辅助流按组各一条有界队列转给各组的回抠进程。为什么值得(方案):两组各自分片解 = 4 个 ffmpeg 上下文,det / rec 推理被分时
|
|
12
|
+
拖慢约 1.5 倍;各自不分片则供帧跟不上——共用一份分片解码两头都要。
|
|
13
|
+
硬解中途交付不对:各组**不**各自改软解(那是 N 路软解同时起),整批失败后这里改软解**逐组串行**重跑。
|
|
14
|
+
各组遮罩不同,整帧 det 的**计算**本来就是 N 份;共用服务省的是权重和 CUDA 上下文。
|
|
15
|
+
|
|
16
|
+
`--serial`:逐组串行(对照臂;不共用 ORT 服务)。一组失败**不取消**其余组(各组是独立的线,互不依赖),退出码取第一个非零。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from flowocr.paths import child_env
|
|
28
|
+
|
|
29
|
+
GROUPS_ENV = "FLOWOCR_RUN_GROUPS"
|
|
30
|
+
"""子进程从这里认出自己是 `run_groups` 的一组(run_ocr2 写进 `_meta.run_groups`;产物不变,所以不进 config)。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def group_names(spec_path: str) -> list[str]:
|
|
34
|
+
d = json.loads(Path(spec_path).read_text(encoding="utf-8"))
|
|
35
|
+
return [g["name"] for g in (d.get("groups") or [{"name": "all"}])]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def per_group(argv: list[str], g: str) -> list[str]:
|
|
39
|
+
"""各组写各自的时间线(`--timeline P` / `--timeline=P` -> `P.<组名>`;几组写同一份会互相覆盖)。纯函数,守卫直接测。"""
|
|
40
|
+
out: list[str] = []
|
|
41
|
+
it = iter(range(len(argv)))
|
|
42
|
+
for i in it:
|
|
43
|
+
a = argv[i]
|
|
44
|
+
if a == "--timeline" and i + 1 < len(argv):
|
|
45
|
+
out += [a, f"{argv[i + 1]}.{g}"]
|
|
46
|
+
next(it, None)
|
|
47
|
+
elif a.startswith("--timeline="):
|
|
48
|
+
out.append(f"{a}.{g}")
|
|
49
|
+
else:
|
|
50
|
+
out.append(a)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_rest(rest: list[str]) -> None:
|
|
55
|
+
"""原样转给 run_ocr2 的参数里不许有这几个:`--workers N` 会变成 组数 × N 个进程(每组再各切段);
|
|
56
|
+
`--out` / `--regions` / `--region-group` 由这里按组给。"""
|
|
57
|
+
for a in rest:
|
|
58
|
+
key = a.split("=", 1)[0]
|
|
59
|
+
if key in ("--workers", "--out", "--regions", "--region-group"):
|
|
60
|
+
raise SystemExit(f"run_groups:{key} 由这里按组给 / 不支持(多组 × 多段 = 进程数相乘),别放进转给 run_ocr2 的参数里")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hub_wanted(args) -> str:
|
|
64
|
+
"""该不该起广播解码:返回不起的原因(空 = 起)。**看 `resolve_split` 之后的值**(审核:`--decode-shards 0/1` / `--decoder cv2` /
|
|
65
|
+
`--frame-select index` 时各组根本不会去接广播进程,它就永远等不满订阅数、全体挂到超时)。纯函数,守卫直接测。"""
|
|
66
|
+
import contextlib
|
|
67
|
+
import copy
|
|
68
|
+
import io
|
|
69
|
+
|
|
70
|
+
from flowocr.extract import ocr_args
|
|
71
|
+
a = ocr_args.resolve(copy.deepcopy(args)) # 副本:resolve_split 会把 decode_shards 就地改成 0,别动调用方那份
|
|
72
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
73
|
+
ocr_args.resolve_split(a)
|
|
74
|
+
if a.decode_shards <= 1:
|
|
75
|
+
return getattr(a, "decode_shards_off", "") or f"--decode-shards {a.decode_shards}(不分片)"
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Hub:
|
|
80
|
+
"""广播解码进程(`decode_proc --hub n`):地址写进子进程环境(`decode_proc.HUB_ENV`);`started` 在它开流之后置上;
|
|
81
|
+
`close()` 收整棵进程树(它起的几路 ffmpeg 不能成孤儿)。起不来时 `proc is None`(各组各解各的)。"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, env: dict, n: int, reason_file: str) -> None:
|
|
84
|
+
import secrets
|
|
85
|
+
import threading
|
|
86
|
+
|
|
87
|
+
from flowocr.extract import decode_proc as D
|
|
88
|
+
|
|
89
|
+
key = secrets.token_bytes(16)
|
|
90
|
+
self.started = threading.Event()
|
|
91
|
+
self.clean = True # close() 确认整棵树退了没有(它自己正常退出的算干净)
|
|
92
|
+
self.proc = subprocess.Popen([sys.executable, "-m", "flowocr.extract.decode_proc", "--hub", str(n)],
|
|
93
|
+
env=child_env(dict(env, **{D.HUB_KEY_ENV: key.hex(), D.HUB_REASON_ENV: reason_file})),
|
|
94
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8",
|
|
95
|
+
errors="replace")
|
|
96
|
+
hello: dict = {}
|
|
97
|
+
for _ in range(200): # 第一行 JSON 前可能混着 import 期的告警(stderr 并进来了)
|
|
98
|
+
ln = self.proc.stdout.readline()
|
|
99
|
+
if not ln:
|
|
100
|
+
break
|
|
101
|
+
try:
|
|
102
|
+
hello = json.loads(ln)
|
|
103
|
+
break
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
print(f"[广播解码] {ln.rstrip()}", flush=True)
|
|
106
|
+
if "port" not in hello:
|
|
107
|
+
self.close()
|
|
108
|
+
self.proc = None
|
|
109
|
+
print("[多组] 广播解码进程起不来,各组各解各的", flush=True)
|
|
110
|
+
return
|
|
111
|
+
env[D.HUB_ENV] = f"{hello['port']}:{key.hex()}"
|
|
112
|
+
print(f"[多组] 广播解码进程在 127.0.0.1:{hello['port']}({n} 组共用一次解码)", flush=True)
|
|
113
|
+
threading.Thread(target=self._drain, daemon=True, name="hub-out").start()
|
|
114
|
+
|
|
115
|
+
def _drain(self) -> None: # 之后它打的行照常转出来;不读的话管道满了它就卡住
|
|
116
|
+
for ln in self.proc.stdout:
|
|
117
|
+
if ln.startswith('{"started"'):
|
|
118
|
+
self.started.set()
|
|
119
|
+
else:
|
|
120
|
+
print(f"[广播解码] {ln.rstrip()}", flush=True)
|
|
121
|
+
|
|
122
|
+
def close(self) -> bool:
|
|
123
|
+
"""整棵进程树收掉(psutil 在就先收子进程——它起的 ffmpeg;再收它自己)。返回**确认全退了**没有
|
|
124
|
+
(整批软解重跑的前提;psutil 不在时子进程确认不了,按没确认算)。"""
|
|
125
|
+
if self.proc is None:
|
|
126
|
+
return True
|
|
127
|
+
if self.proc.poll() is not None:
|
|
128
|
+
return self.clean # 已经收过(运行中途有组先退时):用那一次的结果;自己退的算干净
|
|
129
|
+
kids_gone = False
|
|
130
|
+
try:
|
|
131
|
+
import psutil
|
|
132
|
+
kids = psutil.Process(self.proc.pid).children(recursive=True)
|
|
133
|
+
for c in kids:
|
|
134
|
+
try:
|
|
135
|
+
c.kill()
|
|
136
|
+
except psutil.Error:
|
|
137
|
+
pass
|
|
138
|
+
kids_gone = not psutil.wait_procs(kids, timeout=10)[1]
|
|
139
|
+
except Exception: # noqa: BLE001 psutil 不在 / 进程刚退
|
|
140
|
+
pass
|
|
141
|
+
self.proc.kill()
|
|
142
|
+
self.clean = _reaped(self.proc, 10) and kids_gone
|
|
143
|
+
return self.clean
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _reaped(p, timeout: float) -> bool:
|
|
147
|
+
"""等进程退出,限时;返回退没退(`TimeoutExpired` 不往外抛)。"""
|
|
148
|
+
try:
|
|
149
|
+
p.wait(timeout)
|
|
150
|
+
except subprocess.TimeoutExpired:
|
|
151
|
+
pass
|
|
152
|
+
return p.poll() is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv: list[str] | None = None) -> int:
|
|
156
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
157
|
+
ap.add_argument("video")
|
|
158
|
+
ap.add_argument("--regions", required=True)
|
|
159
|
+
ap.add_argument("--outdir", required=True)
|
|
160
|
+
ap.add_argument("--start", type=float, default=0.0)
|
|
161
|
+
ap.add_argument("--end", type=float, default=0.0)
|
|
162
|
+
ap.add_argument("--serial", action="store_true", help="逐组串行(对照臂;不共用 ORT 服务)")
|
|
163
|
+
ap.add_argument("--share-decode", action=argparse.BooleanOptionalAction, default=True,
|
|
164
|
+
help="多组共用一个广播解码进程(默认开;关 = 各组各解各的)")
|
|
165
|
+
ns, rest = ap.parse_known_args(argv)
|
|
166
|
+
check_rest(rest)
|
|
167
|
+
names = group_names(ns.regions)
|
|
168
|
+
out = Path(ns.outdir)
|
|
169
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
base = [ns.video, "--start", repr(ns.start), "--end", repr(ns.end), "--regions", ns.regions, *rest]
|
|
171
|
+
cmds = {g: [sys.executable, "-m", "flowocr.extract.run_ocr2", *per_group(base, g), "--region-group", g,
|
|
172
|
+
"--out", str(out / f"{g}.jsonl")] for g in names}
|
|
173
|
+
t0 = time.time()
|
|
174
|
+
if ns.serial or len(names) == 1:
|
|
175
|
+
import os
|
|
176
|
+
env = dict(os.environ, **{GROUPS_ENV: json.dumps({"groups": len(names), "mode": "serial", "ort_shared": False})})
|
|
177
|
+
rcs = {g: subprocess.call(c, env=child_env(env)) for g, c in cmds.items()}
|
|
178
|
+
else:
|
|
179
|
+
from flowocr.extract import ocr_args
|
|
180
|
+
from flowocr.extract import ffcheck
|
|
181
|
+
from flowocr.extract import run_ocr2 as R
|
|
182
|
+
args = ocr_args.parse_args([ns.video, "--out", str(out / "_"), *rest])
|
|
183
|
+
ffcheck.require_runtime() # 没装 / 装重了推理后端就在父进程说清楚,别等 ort_server 的 ImportError
|
|
184
|
+
srvs, env = R.start_ort_services(args, len(names), " 组") # 共享服务时模型在父进程先取好(首跑时别让几组子进程同时去取同一份)
|
|
185
|
+
import tempfile
|
|
186
|
+
fd, reason_file = tempfile.mkstemp(prefix="flowocr-hub-", suffix=".txt")
|
|
187
|
+
import os
|
|
188
|
+
os.close(fd)
|
|
189
|
+
why_not = hub_wanted(args) if ns.share_decode else "--no-share-decode"
|
|
190
|
+
if why_not and ns.share_decode:
|
|
191
|
+
print(f"[多组] 不起广播解码({why_not}),各组各解各的", flush=True)
|
|
192
|
+
hub = Hub(env, len(names), reason_file) if not why_not else None
|
|
193
|
+
if hub is not None and hub.proc is None:
|
|
194
|
+
hub = None
|
|
195
|
+
env[GROUPS_ENV] = json.dumps({"groups": len(names), "mode": "parallel", "ort_shared": "FLOWOCR_ORT_ADDR" in env,
|
|
196
|
+
"decode_shared": hub is not None})
|
|
197
|
+
procs: dict = {}
|
|
198
|
+
try:
|
|
199
|
+
procs = {g: subprocess.Popen(c, env=child_env(env)) for g, c in cmds.items()}
|
|
200
|
+
print(f"[多组] {len(names)} 组同时跑:{names}", flush=True)
|
|
201
|
+
while any(p.poll() is None for p in procs.values()):
|
|
202
|
+
# 广播进程开流之前有组退出了(模型建不起来之类):它永远等不满订阅数——收掉它,其余组立刻断开、错误清楚
|
|
203
|
+
if (hub is not None and not hub.started.is_set() and hub.proc.poll() is None
|
|
204
|
+
and any(p.poll() not in (None, 0) for p in procs.values())): # 正常跑完的组(发了 "x" 自己解)不算
|
|
205
|
+
print("[多组] 有组在广播解码开流之前就退出了,收掉广播进程(其余组会报'广播解码进程断开')", flush=True)
|
|
206
|
+
hub.close()
|
|
207
|
+
time.sleep(0.5)
|
|
208
|
+
rcs = {g: p.returncode for g, p in procs.items()}
|
|
209
|
+
finally:
|
|
210
|
+
# 先收各组、再收服务(审核:只收服务的话,还活着的组连接一断会按"接不上共享服务"那条路各自再起一整份 worker——显存翻倍)
|
|
211
|
+
for p in procs.values():
|
|
212
|
+
if p.poll() is None:
|
|
213
|
+
p.kill()
|
|
214
|
+
# 这一批的资源**确认全退了**才许整批重跑(审计 2026-09-25,Codex 复审 P1):各组、广播解码进程树、共享 ORT 服务——
|
|
215
|
+
# 后两样是这里起的,不在各组监督者清理的进程树里,"各组都清干净了"盖不住它们
|
|
216
|
+
leftover = [f"组 {g}" for g, p in procs.items() if not _reaped(p, 30)]
|
|
217
|
+
if hub is not None and not hub.close():
|
|
218
|
+
leftover.append("广播解码进程")
|
|
219
|
+
for p0 in srvs:
|
|
220
|
+
p0.kill()
|
|
221
|
+
leftover += ["共享 ORT 服务" for p0 in srvs if not _reaped(p0, 30)]
|
|
222
|
+
from flowocr.extract.supervisor import EXIT_HW_MISMATCH, EXIT_HW_UNCLEAN
|
|
223
|
+
if hub is not None and (EXIT_HW_UNCLEAN in rcs.values() or (leftover and EXIT_HW_MISMATCH in rcs.values())):
|
|
224
|
+
# 有组的子进程没能确认全部退出(supervisor 退 76),或这一批自己起的进程没退:整批重跑会和残留同时占卡,宁可这一批失败
|
|
225
|
+
print(f"[多组] 广播解码硬解中途交付不对,但没能确认全部退出({'、'.join(leftover) or '组内子进程'}):**不整批重跑**", flush=True)
|
|
226
|
+
rcs = {g: EXIT_HW_UNCLEAN if rc == EXIT_HW_MISMATCH else rc for g, rc in rcs.items()}
|
|
227
|
+
elif hub is not None and EXIT_HW_MISMATCH in rcs.values():
|
|
228
|
+
# 广播解码硬解交付不对:各组没有各自改软解(supervisor 见到广播地址就原样退 75),这里整批改软解、逐组串行
|
|
229
|
+
print("[多组] 广播解码硬解中途交付不对,整批改软解逐组重跑", flush=True)
|
|
230
|
+
import os
|
|
231
|
+
from flowocr.extract.supervisor import HW_RETRY_ENV
|
|
232
|
+
reason = Path(reason_file).read_text(encoding="utf-8").strip() or "(广播解码没写出原因)"
|
|
233
|
+
env2 = dict(os.environ, **{GROUPS_ENV: json.dumps({"groups": len(names), "mode": "serial-soft-retry",
|
|
234
|
+
"ort_shared": False, "decode_shared": False}),
|
|
235
|
+
HW_RETRY_ENV: reason[:500]}) # 重跑的 obs 记 `_meta.hwaccel_fallback`(为什么是软解)
|
|
236
|
+
rcs = {g: subprocess.call(c + ["--hwaccel", ""], env=child_env(env2)) for g, c in cmds.items()}
|
|
237
|
+
try:
|
|
238
|
+
os.unlink(reason_file)
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
241
|
+
print(f"[多组] 完成 {time.time() - t0:.1f} s,退出码 {rcs}", flush=True)
|
|
242
|
+
bad = [rc for rc in rcs.values() if rc != 0]
|
|
243
|
+
return bad[0] if bad else 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
sys.exit(main())
|