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,261 @@
|
|
|
1
|
+
"""`run_ocr2` 的**外层监督者**:不 import 任何推理库,只起 worker 子进程干活,硬解中途交付不对就等第一趟整棵进程树退干净再起软解那一趟。
|
|
2
|
+
|
|
3
|
+
2026-09-22 从 `run_ocr2.py` 里搬出来(正规化阶段 D):发行包的命令行入口 `flowocr-ocr`(`pyproject` 的 `[project.scripts]`)
|
|
4
|
+
要能**不 import `run_ocr2`** 就起监督者——那个模块顶层 import cv2 / numpy 和整个提取包(当时还直接 `from paddleocr import …`),
|
|
5
|
+
console script 会先把模块整个 import 进来,监督者"自己不占显存"这条(Codex 复审第三轮 P2)就破了。`python -m flowocr.extract.run_ocr2`(启动器 shim 已删)
|
|
6
|
+
照旧:`run_ocr2` 的 `__main__` 在推理库 import 之前调 `supervise()`,行为不变。
|
|
7
|
+
|
|
8
|
+
常量(环境变量名 / 退出码)也住在这里:worker 侧(`run_ocr2`)从这里 import。
|
|
9
|
+
契约测试:`dev_tools/hw_retry_check.py`(重试前整棵树退干净,Job Object 与 psutil 兜底两条路)。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from flowocr.paths import child_env
|
|
20
|
+
|
|
21
|
+
WORKER_ENV = "FLOWOCR_OCR_WORKER"
|
|
22
|
+
"""设了就是 worker 那一趟(监督者起子进程时设);没设就是监督者。"""
|
|
23
|
+
HW_RETRY_ENV = "FLOWOCR_HWACCEL_RETRIED"
|
|
24
|
+
"""第二趟(软解重跑)的环境里带着第一趟失败的原因,worker 写进 `_meta.hwaccel_fallback`。"""
|
|
25
|
+
HW_REASON_ENV = "FLOWOCR_HWACCEL_REASON_FILE"
|
|
26
|
+
"""worker 把硬解交付不对的原因写进这个文件,监督者读了再决定起不起第二趟。"""
|
|
27
|
+
EXIT_HW_MISMATCH = 75
|
|
28
|
+
"""硬解跑到一半交付不对时 worker 的退出码:外层监督者见到它,**等这一趟完全退出之后**再起软解那一趟。"""
|
|
29
|
+
EXIT_HW_UNCLEAN = 76
|
|
30
|
+
"""硬解交付不对、而且第一趟的子进程没能确认全部退出:监督者**不重试**,也不许调用方(`run_groups` 的整批软解重跑)重试——
|
|
31
|
+
残留和新一趟会同时占卡(显存配额 8 GB)。2026-09-25 审计(Codex P1):广播解码那条路原来仍退 75,run_groups 照样重跑。"""
|
|
32
|
+
FAULT_ORPHAN_ENV = "FLOWOCR_FAULT_ORPHAN"
|
|
33
|
+
"""**只给测试用**:值是一个文件路径。硬解故障那一刻 worker 故意留下一个**不收的孙进程**(模拟不听话的模型服务),
|
|
34
|
+
pid 写进这个文件——验证监督者在起第二趟之前把它清掉(`hw_retry_check.py`)。"""
|
|
35
|
+
FAULT_NO_JOB_ENV = "FLOWOCR_FAULT_NO_JOB"
|
|
36
|
+
"""**只给测试用**:假装 Job Object 建不起来,验证 psutil 兜底那条路。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def supervise(argv: list[str] | None = None) -> int:
|
|
40
|
+
"""**外层监督者**(2026-09-20,Codex 复审第三轮 P2):不 import 任何推理库,只起 worker 子进程干活。
|
|
41
|
+
|
|
42
|
+
为什么不在同一个进程里重试:第一版在 `except HwaccelMismatch` 里直接起软解子进程——
|
|
43
|
+
traceback 还挂着 `main()` 的整个栈帧(ORT 客户端、det 模型都活着),推理库的显存池也不会因为对象被回收就还给系统,
|
|
44
|
+
于是两套推理资源同时在卡上,"合计仍在 8 GB 内"那句注释是没核过的推断。**进程边界才是可靠的释放**:
|
|
45
|
+
worker 退出码 `EXIT_HW_MISMATCH` -> 这里等它 `wait()` 完、再起 `--hwaccel ''` 那一趟。
|
|
46
|
+
**监督者死了 worker 也要跟着死**(不留孤儿占卡):Windows 上用 Job Object + `KILL_ON_JOB_CLOSE`——
|
|
47
|
+
监督者哪怕被强杀,job 句柄随之关闭,系统把 worker **连同它起的 ffmpeg / ORT 服务**一起杀掉。
|
|
48
|
+
⚠ 第一版用的是 stdin 看门狗(worker 起一根线程阻塞在 `sys.stdin.read()` 上),**在 Windows 上会死锁**:
|
|
49
|
+
同步管道句柄上有读挂着时,worker 起 ffmpeg 要复制继承 stdin 句柄,就一直等那个读结束——
|
|
50
|
+
契约测试 `hw_retry_check.py` 跑出来的是"worker 一行输出都没有就卡死"。ORT 服务端用同一招没事,
|
|
51
|
+
是因为它启动看门狗之后不再起子进程。"""
|
|
52
|
+
import tempfile
|
|
53
|
+
argv = [sys.executable, "-m", "flowocr.extract.run_ocr2", *(sys.argv[1:] if argv is None else argv)]
|
|
54
|
+
fd, reason_file = tempfile.mkstemp(prefix="flowocr-hw-", suffix=".txt")
|
|
55
|
+
os.close(fd)
|
|
56
|
+
env = child_env(dict(os.environ, **{WORKER_ENV: "1", HW_REASON_ENV: reason_file}))
|
|
57
|
+
try:
|
|
58
|
+
rc, clean = _run_worker(argv, env)
|
|
59
|
+
if rc == EXIT_HW_MISMATCH and os.environ.get("FLOWOCR_DECODE_HUB"):
|
|
60
|
+
# 多组共用一个广播解码进程(run_groups --share-decode):**不在这里各自重跑**——N 组各自改软解就是 N 路软解同时起
|
|
61
|
+
# (审核:av1 一路软解 189 核·秒 / 5 分钟,两路就吃满 CPU)。退出码原样交给 run_groups,由它整批改软解逐组重跑
|
|
62
|
+
if not clean:
|
|
63
|
+
print("[硬解] 跑到一半交付不对,但这一趟的子进程没能确认全部退出:**不交给 run_groups 重跑**(重跑会和残留同时占卡)", flush=True)
|
|
64
|
+
return EXIT_HW_UNCLEAN
|
|
65
|
+
print("[硬解] 跑到一半交付不对;这一组由广播解码供帧,交给 run_groups 整批重跑", flush=True)
|
|
66
|
+
return rc
|
|
67
|
+
if rc == EXIT_HW_MISMATCH:
|
|
68
|
+
reason = Path(reason_file).read_text(encoding="utf-8").strip() or "(原因没写出来)"
|
|
69
|
+
if not clean:
|
|
70
|
+
# 确认不了第一趟的整棵进程树(ORT 服务 / ffmpeg)都退了,就**不起**第二趟——
|
|
71
|
+
# 宁可这一段失败,也不让两套推理资源同时占卡(显存配额 8 GB)
|
|
72
|
+
print(f"[硬解] 交付不对({reason}),但第一趟的子进程没能确认全部退出,**不重试**", flush=True)
|
|
73
|
+
return EXIT_HW_UNCLEAN
|
|
74
|
+
print(f"[硬解] 跑到一半交付不对,第一趟的整棵进程树已清空;整段改软解重跑:{reason}", flush=True)
|
|
75
|
+
rc, _ = _run_worker(argv + ["--hwaccel", ""], dict(env, **{HW_RETRY_ENV: reason[:500]}))
|
|
76
|
+
return rc
|
|
77
|
+
finally:
|
|
78
|
+
try:
|
|
79
|
+
os.unlink(reason_file)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _kill_on_close_job():
|
|
85
|
+
"""Windows:建一个"句柄一关、里面的进程全杀"的 Job Object;别的系统或建不成返回 None(照常跑,只是少一道保险)。"""
|
|
86
|
+
if os.name != "nt" or os.environ.get(FAULT_NO_JOB_ENV):
|
|
87
|
+
return None
|
|
88
|
+
import ctypes
|
|
89
|
+
from ctypes import wintypes
|
|
90
|
+
|
|
91
|
+
class BASIC(ctypes.Structure):
|
|
92
|
+
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), ("PerJobUserTimeLimit", ctypes.c_int64),
|
|
93
|
+
("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
94
|
+
("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD),
|
|
95
|
+
("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD),
|
|
96
|
+
("SchedulingClass", wintypes.DWORD)]
|
|
97
|
+
|
|
98
|
+
class IO(ctypes.Structure):
|
|
99
|
+
_fields_ = [(n, ctypes.c_uint64) for n in ("r", "w", "o", "rb", "wb", "ob")]
|
|
100
|
+
|
|
101
|
+
class EXT(ctypes.Structure):
|
|
102
|
+
_fields_ = [("Basic", BASIC), ("Io", IO), ("ProcessMemoryLimit", ctypes.c_size_t),
|
|
103
|
+
("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
104
|
+
("PeakJobMemoryUsed", ctypes.c_size_t)]
|
|
105
|
+
|
|
106
|
+
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
107
|
+
k32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
108
|
+
job = k32.CreateJobObjectW(None, None)
|
|
109
|
+
if not job:
|
|
110
|
+
return None
|
|
111
|
+
info = EXT()
|
|
112
|
+
info.Basic.LimitFlags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
113
|
+
if not k32.SetInformationJobObject(wintypes.HANDLE(job), 9, ctypes.byref(info), ctypes.sizeof(info)):
|
|
114
|
+
return None # 9 = JobObjectExtendedLimitInformation
|
|
115
|
+
return (k32, job)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _job_active(k32, h) -> int:
|
|
119
|
+
"""Job 里还活着几个进程(JobObjectBasicAccountingInformation.ActiveProcesses)。查不到返回 -1。"""
|
|
120
|
+
import ctypes
|
|
121
|
+
from ctypes import wintypes
|
|
122
|
+
|
|
123
|
+
class ACC(ctypes.Structure):
|
|
124
|
+
_fields_ = [("TotalUserTime", ctypes.c_int64), ("TotalKernelTime", ctypes.c_int64),
|
|
125
|
+
("ThisPeriodTotalUserTime", ctypes.c_int64), ("ThisPeriodTotalKernelTime", ctypes.c_int64),
|
|
126
|
+
("TotalPageFaultCount", wintypes.DWORD), ("TotalProcesses", wintypes.DWORD),
|
|
127
|
+
("ActiveProcesses", wintypes.DWORD), ("TotalTerminatedProcesses", wintypes.DWORD)]
|
|
128
|
+
acc = ACC()
|
|
129
|
+
if not k32.QueryInformationJobObject(wintypes.HANDLE(h), 1, ctypes.byref(acc), ctypes.sizeof(acc), None):
|
|
130
|
+
return -1
|
|
131
|
+
return int(acc.ActiveProcesses)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _drain_job(job, timeout: float = 30.0) -> bool:
|
|
135
|
+
"""**这一趟结束后,把 Job 里剩下的进程全杀掉、等到清零、再关句柄**(2026-09-20,Codex 复审第四轮)。
|
|
136
|
+
|
|
137
|
+
上一版只 `wait()` 了直接子进程(worker),Job 句柄一直开着,`KILL_ON_JOB_CLOSE` 要等**监督者最后退出**才触发——
|
|
138
|
+
worker 退出码 75 之后,它起的 ORT 服务 / ffmpeg 这类孙进程可能还活着,第二趟就和它们同时占卡。
|
|
139
|
+
返回"确认清空了";清不空(超时 / 查不到)返回 False,调用方据此**不起下一趟**。"""
|
|
140
|
+
from ctypes import wintypes
|
|
141
|
+
k32, h = job
|
|
142
|
+
k32.TerminateJobObject(wintypes.HANDLE(h), 1)
|
|
143
|
+
deadline = time.time() + timeout
|
|
144
|
+
n = _job_active(k32, h)
|
|
145
|
+
while n != 0 and time.time() < deadline:
|
|
146
|
+
time.sleep(0.1)
|
|
147
|
+
n = _job_active(k32, h)
|
|
148
|
+
k32.CloseHandle(wintypes.HANDLE(h))
|
|
149
|
+
return n == 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _track_tree(p: subprocess.Popen, seen: dict) -> None:
|
|
153
|
+
"""Job 不可用时的兜底:worker 活着的时候反复记下它的整棵进程树(pid -> create_time,防 pid 复用)。"""
|
|
154
|
+
try:
|
|
155
|
+
import psutil
|
|
156
|
+
for c in psutil.Process(p.pid).children(recursive=True):
|
|
157
|
+
try:
|
|
158
|
+
seen.setdefault(c.pid, c.create_time())
|
|
159
|
+
except psutil.Error:
|
|
160
|
+
pass
|
|
161
|
+
except Exception: # noqa: BLE001 psutil 不在 / 进程刚好退了
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _ppid_table(psutil) -> dict[int, int]:
|
|
166
|
+
"""全机 pid -> ppid。psutil 的 `ppid_map`(私有,但它自己的 `Process.children` 就靠它)一次拿全、3 ms;
|
|
167
|
+
没有这个函数(换了 psutil 版本 / 平台)就退回 `process_iter`(冷的时候 1 s 级,结果相同)。"""
|
|
168
|
+
f = getattr(getattr(psutil, "_psplatform", None), "ppid_map", None)
|
|
169
|
+
if f is not None:
|
|
170
|
+
try:
|
|
171
|
+
return dict(f())
|
|
172
|
+
except Exception: # noqa: BLE001 私有接口出任何问题都退回公开的那条
|
|
173
|
+
pass
|
|
174
|
+
return {q.pid: q.info["ppid"] for q in psutil.process_iter(["ppid"])}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _drain_tree(seen: dict, root_pid: int, t_start: float, timeout: float = 30.0) -> bool:
|
|
178
|
+
"""兜底的收尾:记下来的那些进程里还活着的(create_time 对得上),逐个杀掉并等它们退出。
|
|
179
|
+
|
|
180
|
+
⚠ 轮询是每 0.5 s 一次,**worker 退出前一瞬间才起的子进程**记不到——所以收尾时再按 `ppid` 扫一遍、求闭包:
|
|
181
|
+
Windows 上孤儿进程的 `ppid` 仍然指着已经死掉的父进程,起始时间晚于这一趟开始的就是它的后代。"""
|
|
182
|
+
try:
|
|
183
|
+
import psutil
|
|
184
|
+
except ImportError:
|
|
185
|
+
print("[监督] ⚠ 没有 Job Object、也没有 psutil:确认不了第一趟的子进程都退了", flush=True)
|
|
186
|
+
return False
|
|
187
|
+
roots = {root_pid, *seen}
|
|
188
|
+
grew = True
|
|
189
|
+
while grew:
|
|
190
|
+
grew = False
|
|
191
|
+
# pid -> ppid 全表一次拿,`create_time` 只问父进程在树里的那几个(2026-09-23,decode-buffer):
|
|
192
|
+
# `process_iter` 冷的时候要给每个进程建 Process 对象、逐个打开进程取 create_time,全机 325 个 1.07 s——每次 worker 退出都白付
|
|
193
|
+
for pid, ppid in _ppid_table(psutil).items():
|
|
194
|
+
if ppid not in roots or pid in roots:
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
ct = psutil.Process(pid).create_time()
|
|
198
|
+
except psutil.Error:
|
|
199
|
+
continue
|
|
200
|
+
if ct >= t_start - 1:
|
|
201
|
+
roots.add(pid)
|
|
202
|
+
seen.setdefault(pid, ct)
|
|
203
|
+
grew = True
|
|
204
|
+
alive = []
|
|
205
|
+
for pid, ct in seen.items():
|
|
206
|
+
try:
|
|
207
|
+
q = psutil.Process(pid)
|
|
208
|
+
if abs(q.create_time() - ct) < 1e-3:
|
|
209
|
+
alive.append(q)
|
|
210
|
+
except psutil.Error:
|
|
211
|
+
pass
|
|
212
|
+
for q in alive:
|
|
213
|
+
try:
|
|
214
|
+
q.kill()
|
|
215
|
+
except psutil.Error:
|
|
216
|
+
pass
|
|
217
|
+
_gone, still = psutil.wait_procs(alive, timeout=timeout)
|
|
218
|
+
return not still
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _run_worker(argv: list[str], env: dict) -> tuple[int, bool]:
|
|
222
|
+
"""跑一趟 worker,返回 `(退出码, 这一趟的整棵进程树确认清空了没有)`。"""
|
|
223
|
+
t_start = time.time()
|
|
224
|
+
p = subprocess.Popen(argv, env=env)
|
|
225
|
+
job = _kill_on_close_job()
|
|
226
|
+
if job is not None:
|
|
227
|
+
from ctypes import wintypes
|
|
228
|
+
k32, h = job
|
|
229
|
+
if not k32.AssignProcessToJobObject(wintypes.HANDLE(h), wintypes.HANDLE(int(p._handle))):
|
|
230
|
+
print("[监督] ⚠ 没能把 worker 放进 Job Object,改用进程树兜底", flush=True)
|
|
231
|
+
k32.CloseHandle(wintypes.HANDLE(h))
|
|
232
|
+
job = None
|
|
233
|
+
seen: dict = {}
|
|
234
|
+
try:
|
|
235
|
+
while True:
|
|
236
|
+
try:
|
|
237
|
+
rc = p.wait(timeout=0.5)
|
|
238
|
+
break
|
|
239
|
+
except subprocess.TimeoutExpired:
|
|
240
|
+
_track_tree(p, seen)
|
|
241
|
+
finally:
|
|
242
|
+
if p.poll() is None: # 监督者自己出事(Ctrl-C 之类):别留下占卡的 worker
|
|
243
|
+
p.kill()
|
|
244
|
+
p.wait()
|
|
245
|
+
# ⚠ **Job 在也要按进程树收尾**(2026-09-20,契约测试 hw_retry_check.py 跑出来的):Windows 上 venv 的
|
|
246
|
+
# `Scripts\python.exe` 是个启动器,它给真解释器另建一个允许**静默脱离**的 Job,于是 worker 起的子进程全都逃出了
|
|
247
|
+
# 我们这个 Job——`TerminateJobObject` 之后 Job 里立刻是 0 个进程,那个故意不收的孙进程却一直活到监督者退出之后。
|
|
248
|
+
# 上一轮"只杀监督者、3 秒后全没了"能过,靠的是连锁反应(worker 死 -> ORT 服务读到 stdin EOF、ffmpeg 管道断),
|
|
249
|
+
# 不是 Job 杀的。所以 Job 只当"监督者被强杀"时的尽力保险,**确认清空靠进程树**
|
|
250
|
+
clean_job = _drain_job(job) if job is not None else True
|
|
251
|
+
clean = _drain_tree(seen, p.pid, t_start) and clean_job
|
|
252
|
+
return rc, clean
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main() -> int:
|
|
256
|
+
"""`flowocr-ocr` 的入口(`[project.scripts]`):参数原样交给 worker。"""
|
|
257
|
+
return supervise()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""各级时间线(`run_ocr2 --timeline <路径>`,NON_PRODUCT;decode-buffer)。
|
|
2
|
+
|
|
3
|
+
`STAGE` 只给每一级的**总和**:解码、det、判决、rec 各自 14~16 s,流水线却要 24.5 s——总和看不出**谁在什么时候堵谁**。
|
|
4
|
+
这里把每一段"在做什么 / 在等什么"记成区间 `(种类, 线程, 起, 止, 参数)`,跑完落盘,`dev_tools/timeline_report.py` 按时间对齐各线程来读。
|
|
5
|
+
|
|
6
|
+
* **关着的时候零开销**:`span` / `mark` 先看一个模块级的 None。开着时每条事件一个元组 append(GIL 下原子);
|
|
7
|
+
5 分钟段约十几万条、几 MB。**全在内存里、跑完才落盘**,所以封顶 `MAX_EVENTS` 条(约 1 GB 以内):
|
|
8
|
+
超了之后的丢掉、`_meta.dropped` 记丢了几条——整片要看时间线就开一个窗口跑,别开整片。
|
|
9
|
+
* **跨进程对得上**:时刻一律 `time.perf_counter()`——Windows 上是系统级的 QPC,各进程同一根轴;
|
|
10
|
+
每份文件头上另记一对 `(time.time(), perf_counter())` 锚点,换平台时按锚点对齐。
|
|
11
|
+
* 子进程(`--edge-proc` 的回抠进程、`--decode-shards` 的只解码进程)从环境变量 `FLOWOCR_TIMELINE` 认出开着,
|
|
12
|
+
各写 `<路径>.<角色>.jsonl`;管线进程写 `<路径>` 本身。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
ENV = "FLOWOCR_TIMELINE"
|
|
22
|
+
|
|
23
|
+
MAX_EVENTS = 5_000_000
|
|
24
|
+
_ev: list | None = None
|
|
25
|
+
_dropped = 0
|
|
26
|
+
_path: str | None = None
|
|
27
|
+
_role = "main"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start(path: str, role: str = "main") -> None:
|
|
31
|
+
"""开始记。管线进程调这个(同时把路径放进环境变量,之后起的子进程都跟着记)。"""
|
|
32
|
+
global _ev, _path, _role, _dropped
|
|
33
|
+
_ev, _path, _role, _dropped = [], path, role, 0
|
|
34
|
+
if role == "main":
|
|
35
|
+
os.environ[ENV] = path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def start_from_env(role: str) -> None:
|
|
39
|
+
"""子进程:环境变量里有路径就开始记,写 `<路径>.<角色>.jsonl`。"""
|
|
40
|
+
p = os.environ.get(ENV)
|
|
41
|
+
if p:
|
|
42
|
+
start(f"{p}.{role}.jsonl", role)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def on() -> bool:
|
|
46
|
+
return _ev is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def span(kind: str, t0: float, t1: float | None = None, a=None) -> float:
|
|
50
|
+
"""记一段 `[t0, t1)`(t1 不给 = 现在),返回 t1——调用方可以接着当下一段的起点。"""
|
|
51
|
+
t1 = time.perf_counter() if t1 is None else t1
|
|
52
|
+
if _ev is not None:
|
|
53
|
+
_add((kind, threading.current_thread().name, t0, t1, a))
|
|
54
|
+
return t1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def mark(kind: str, a=None) -> None:
|
|
58
|
+
"""记一个时刻(零长的区间)。"""
|
|
59
|
+
if _ev is not None:
|
|
60
|
+
t = time.perf_counter()
|
|
61
|
+
_add((kind, threading.current_thread().name, t, t, a))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _add(e: tuple) -> None:
|
|
65
|
+
global _dropped
|
|
66
|
+
if len(_ev) < MAX_EVENTS: # type: ignore[arg-type]
|
|
67
|
+
_ev.append(e) # type: ignore[union-attr]
|
|
68
|
+
else:
|
|
69
|
+
_dropped += 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dump() -> str | None:
|
|
73
|
+
"""落盘(JSONL:头一行 `_meta`,之后一行一条)。没开就什么都不做。返回写到哪了。"""
|
|
74
|
+
if _ev is None or not _path:
|
|
75
|
+
return None
|
|
76
|
+
ev = list(_ev)
|
|
77
|
+
with open(_path, "w", encoding="utf-8") as f:
|
|
78
|
+
f.write(json.dumps({"_meta": {"role": _role, "pid": os.getpid(),
|
|
79
|
+
"anchor": [time.time(), time.perf_counter()], "n": len(ev),
|
|
80
|
+
"dropped": _dropped}}) + "\n")
|
|
81
|
+
for k, th, t0, t1, a in ev:
|
|
82
|
+
f.write(json.dumps({"k": k, "th": th, "t0": round(t0, 6), "t1": round(t1, 6), "a": a},
|
|
83
|
+
ensure_ascii=False) + "\n")
|
|
84
|
+
return _path
|