feat: 任务断点续跑/暂停中断/LLM 过滤优化与调度容错
调度与状态机: - 修复 PAUSED 任务被拾起后复活执行(点击暂停反而开始任务):next_queued_run 只取 QUEUED,execute_run 以 PAUSED 进入直接返回,暂停必须显式 resume - 重启恢复:启动时 recover_interrupted_runs 把遗留 RUNNING 置 QUEUED(保留产物) - 暂停信号 paused.flag:暂停接口写、继续/重试清除,OCR 逐帧检查秒级中断, 节点内被暂停保持 PAUSED 不误报 FAILED - 调度轮询容错:_loop 异常不杀死线程(曾致任务永久停留 QUEUED) subtitle-ocr 节点级断点: - ocr_partial.jsonl 逐帧存档,重启/暂停后只处理未处理帧,产物与一次跑完一致 - 进度日志携带窗口平均耗时与线程数;取消后抑制进度日志井喷 llm-filter 过滤质量与限流自适应: - 上下文净化:喂给 LLM 的是过滤后的字幕(规则层垃圾从上下文剔除) - 正则确定性过滤:裸网址域名、HTML/水印模式直接删除 - 429/5xx 指数退避重试;worker 限流错误 report_failure 内存临时降最大线程数 并缩容(无错误窗口回升),失败条目降并发后重试一轮 - 保留长文本保护(noise 不删 ≥min_keep_len 文本,LLM 判定不稳的必要兜底) 前端: - 工作流编排页支持选择工作流编辑(加载最新/历史版本)、版本历史面板、 新建/编辑双模式;管理后台编辑跳转 workflow.html?edit=<id> 工作流:ocr-subtitle v7(filter pool_max_workers=20、pool_fast_threshold=1)
This commit is contained in:
+72
-4
@@ -55,7 +55,7 @@ class AdaptiveThreadPool:
|
||||
fast_threshold: float = 0.3,
|
||||
slow_threshold: float = 1.0,
|
||||
clock=time.monotonic,
|
||||
on_progress: Callable[[int, int, float], None] | None = None,
|
||||
on_progress: Callable[[int, int, float, float, int], None] | None = None,
|
||||
) -> None:
|
||||
"""初始化;clock 可注入便于测试;on_progress(done,total,rate) 每次完成回调。"""
|
||||
self._worker = worker
|
||||
@@ -73,6 +73,15 @@ class AdaptiveThreadPool:
|
||||
self._results: list = []
|
||||
self._lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
# 取消标记:worker 检测到取消(如暂停信号)后设置,后续完成的任务
|
||||
# 不再触发进度回调——暂停时队列中剩余大量任务会快速退出,若仍逐项
|
||||
# 打印进度会在数秒内打出上万行日志。
|
||||
self._cancel_event = threading.Event()
|
||||
# 有效最大线程数:初始等于 max_workers;消费错误(如 API 限流)时
|
||||
# report_failure 临时收紧,连续无错误窗口后逐步回升——并发自适应配额。
|
||||
self._effective_max_workers = max_workers
|
||||
# 当前窗口内消费错误计数:窗口评估时无错误才允许恢复有效上限。
|
||||
self._window_failures = 0
|
||||
# 滚动窗口起点与已记录的单次耗时。
|
||||
self._window_start = clock()
|
||||
# 观测到的最大并发线程数(供测试与监控)。
|
||||
@@ -83,6 +92,9 @@ class AdaptiveThreadPool:
|
||||
self._total = 0
|
||||
self._started_at = 0.0
|
||||
self._window_times: list[float] = []
|
||||
# 最近一次窗口评估的平均单任务耗时(秒):供进度回调诊断使用,
|
||||
# 与扩缩容决策共用同一依据;窗口尚未评估时为 None(回退累计平均)。
|
||||
self._window_avg_time: float | None = None
|
||||
|
||||
def _run(self) -> None:
|
||||
"""工作线程主循环:取任务 → 执行 → 记录耗时并自适应评估。"""
|
||||
@@ -107,10 +119,16 @@ class AdaptiveThreadPool:
|
||||
self._results.append((seq, result))
|
||||
# 进度回调:已完成数、总数与平均处理速度(条/秒)。
|
||||
self._completed += 1
|
||||
if self._on_progress is not None:
|
||||
if self._on_progress is not None and not self._cancel_event.is_set():
|
||||
elapsed_total = max(self._clock() - self._started_at, 1e-9)
|
||||
with self._lock:
|
||||
workers = self._target_workers
|
||||
self._on_progress(
|
||||
self._completed, self._total, self._completed / elapsed_total
|
||||
self._completed,
|
||||
self._total,
|
||||
self._completed / elapsed_total,
|
||||
self._current_avg_time(elapsed_total),
|
||||
workers,
|
||||
)
|
||||
self._tick(elapsed)
|
||||
self._queue.task_done()
|
||||
@@ -128,15 +146,36 @@ class AdaptiveThreadPool:
|
||||
avg = sum(self._window_times) / len(self._window_times)
|
||||
self._window_start = self._clock()
|
||||
self._window_times.clear()
|
||||
# 记录本次窗口平均耗时:进度回调据此展示"当前扩缩容依据"。
|
||||
self._window_avg_time = avg
|
||||
with self._lock:
|
||||
current = self._target_workers
|
||||
# 窗口内无消费错误 → 有效上限逐步回升(错误降下来的并发慢慢恢复)。
|
||||
if (
|
||||
self._window_failures == 0
|
||||
and self._effective_max_workers < self.max_workers
|
||||
):
|
||||
self._effective_max_workers += 1
|
||||
# 重置窗口错误计数,进入下一窗口。
|
||||
self._window_failures = 0
|
||||
# 扩容上限用有效最大线程数:错误窗口内即使响应快也不超过收紧后的上限。
|
||||
self._resize(
|
||||
decide(
|
||||
current, avg, self.min_workers, self.max_workers,
|
||||
current, avg, self.min_workers, self._effective_max_workers,
|
||||
self.fast_threshold, self.slow_threshold,
|
||||
)
|
||||
)
|
||||
|
||||
def _current_avg_time(self, elapsed_total: float) -> float:
|
||||
"""返回供进度回调展示的平均单任务耗时(秒)。
|
||||
|
||||
优先使用最近一次窗口评估的平均耗时(与扩缩容决策同一依据);
|
||||
窗口尚未评估过时回退为启动至今的累计平均,避免无数据可看。
|
||||
"""
|
||||
if self._window_avg_time is not None:
|
||||
return self._window_avg_time
|
||||
return elapsed_total / max(self._completed, 1)
|
||||
|
||||
def _resize(self, target: int) -> None:
|
||||
"""调整并发目标:扩容启动新线程;缩容压入等量停止哨兵(幂等)。
|
||||
|
||||
@@ -157,6 +196,28 @@ class AdaptiveThreadPool:
|
||||
self._queue.put((None, _POISON))
|
||||
self._target_workers = target
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""请求取消本批任务:后续完成的任务不再触发进度回调。
|
||||
|
||||
供调用方在工作线程内检测到外部信号(如暂停)时调用,抑制暂停后
|
||||
队列中剩余任务快速退出导致的进度日志井喷;下一批 map 自动重置。
|
||||
"""
|
||||
self._cancel_event.set()
|
||||
|
||||
def report_failure(self) -> None:
|
||||
"""通知一次消费错误(如 API 限流 429):临时降低有效最大线程数并缩容。
|
||||
|
||||
供工作线程捕获可退避错误(限流/服务端 5xx)后调用:并发立即收紧到
|
||||
新上限,后续请求减少从而避开持续限流;连续无错误窗口后有效上限
|
||||
逐步回升到 max_workers(见 _tick 的恢复逻辑)。
|
||||
"""
|
||||
with self._lock:
|
||||
self._window_failures += 1
|
||||
if self._effective_max_workers > self.min_workers:
|
||||
self._effective_max_workers -= 1
|
||||
# 缩容到新上限(幂等:目标低于当前才放停止哨兵)。
|
||||
self._resize(self._effective_max_workers)
|
||||
|
||||
def map(self, items) -> list:
|
||||
"""按输入顺序返回每个 item 经 worker 处理后的结果列表。"""
|
||||
self._results = []
|
||||
@@ -164,6 +225,13 @@ class AdaptiveThreadPool:
|
||||
self._total = len(items)
|
||||
self._started_at = self._clock()
|
||||
self._stop.clear()
|
||||
# 每批任务开始时重置取消状态:上一批的取消不延续到下一批。
|
||||
self._cancel_event.clear()
|
||||
# 上一批任务结束后工作线程已全部退出(_stop 停止)但 _target_workers
|
||||
# 仍记旧值,_resize 不会重新启动线程——实际无线程时归零后重建。
|
||||
with self._lock:
|
||||
if not self._threads:
|
||||
self._target_workers = 0
|
||||
self._resize(self.min_workers)
|
||||
for seq, item in enumerate(items):
|
||||
self._queue.put((seq, item))
|
||||
|
||||
+95
-30
@@ -19,8 +19,9 @@
|
||||
上下文取首次出现位置,结果缓存复用——修复"同一句字幕 5 留 6 删"的
|
||||
判定不一致,同时把长视频的 LLM 调用量降到唯一文本数。
|
||||
|
||||
4. **长文本保护**:长度 ≥ min_keep_len 的文本,仅 garbage/overlay 两个
|
||||
明确垃圾类别可删,noise 不构成删除依据,防止长对话被误删。
|
||||
4. **上下文净化**:喂给 LLM 的上下文是**过滤后的字幕**——规则层确定性的
|
||||
垃圾(横线/HTML/网址/水印等)从上下文中剔除,只留下有意义的对白,
|
||||
避免覆盖层垃圾污染 LLM 的场景判断导致误删真实对话。
|
||||
|
||||
参考真实任务 run_ac7f480a3ccb(2026-08,OCR 1666 条):旧实现把 394 条
|
||||
真实对话当噪声删掉(占删除 35%)、同文本判定不一致;新实现按上述机制
|
||||
@@ -32,6 +33,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
@@ -72,11 +75,22 @@ DELETE_CATEGORIES = {CATEGORY_GARBAGE, CATEGORY_OVERLAY, CATEGORY_NOISE}
|
||||
_DASH_RE = re.compile(r"^[\s\-—_~=•・。..、]+$")
|
||||
# 规则层正则:URL / 邮箱。
|
||||
_URL_OR_MAIL_RE = re.compile(r"^(https?://|www\.)\S+$|^[\w.+-]+@[\w.-]+\.\w+$")
|
||||
# 规则层正则:裸网址/域名(含中文夹杂的注册地址,如 "水火地址 489155.com")。
|
||||
_DOMAIN_RE = re.compile(
|
||||
r"[\w-]+\.(?:com|net|org|cn|tv|me|io|xyz|cc|top|info|biz)(?:[/\s.,;::!?))]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 规则层正则:HTML/脚本/播放器水印模式(OCR 常把网页界面识别成这类文本)。
|
||||
_HTML_MARK_RE = re.compile(
|
||||
r"html\s*code|<\s*[a-z][^>]*>|javascript|web\s*address|watermark|sign\s*in",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 默认水印/覆盖层 token(casefold 后比较,可经 overlay_tokens 参数覆盖)。
|
||||
DEFAULT_OVERLAY_TOKENS = frozenset(
|
||||
{"html", "background", "___", "cleaning", "buffering", "loading"}
|
||||
{"html", "background", "___", "cleaning", "buffering", "loading", "marketing"}
|
||||
)
|
||||
# 长文本保护阈值:≥ 该长度的文本,noise 类别不构成删除依据。
|
||||
# LLM 判定不稳定(实测把完整对话句误判 noise),长度是必要兜底而非删除依据。
|
||||
DEFAULT_MIN_KEEP_LEN = 12
|
||||
|
||||
|
||||
@@ -111,16 +125,19 @@ def serialize_srt(entries: list[dict]) -> str:
|
||||
def _rule_verdict(text: str, overlay_tokens: set[str]) -> bool | None:
|
||||
"""确定性规则层:返回 True(删除)/ None(交给 LLM 多维判断)。
|
||||
|
||||
规则覆盖 OCR 噪声的稳定模式:空文本、纯横线装饰、URL/邮箱、水印 token
|
||||
(HTML/background 等)、单双 ASCII 字符。含 CJK 的短文本不算垃圾,
|
||||
因为"嗯/好"等可能是内容;其余情况返回 None 交由 LLM 分类。
|
||||
规则覆盖 OCR 噪声的稳定模式:空文本、纯横线装饰、URL/邮箱、裸网址
|
||||
域名、HTML/水印模式(html code/标签/javascript 等)、水印 token、单双
|
||||
ASCII 字符。含 CJK 的短文本不算垃圾,因为"嗯/好"等可能是内容;
|
||||
其余情况返回 None 交由 LLM 分类。
|
||||
"""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
if _DASH_RE.match(t):
|
||||
return True
|
||||
if _URL_OR_MAIL_RE.match(t):
|
||||
if _URL_OR_MAIL_RE.match(t) or _DOMAIN_RE.search(t):
|
||||
return True
|
||||
if _HTML_MARK_RE.search(t):
|
||||
return True
|
||||
if t.casefold() in overlay_tokens:
|
||||
return True
|
||||
@@ -141,9 +158,10 @@ def _dedup_key(text: str) -> str:
|
||||
def _should_delete(category: str, text: str, min_keep_len: int) -> bool:
|
||||
"""按 LLM 类别与长文本保护决定是否删除。
|
||||
|
||||
repeat/dialogue 一律保留;garbage/overlay 一律删除(含长文本——
|
||||
这两个是明确的垃圾信号);noise 对短文本删除,但 ≥min_keep_len 的
|
||||
长文本不删(noise 太模糊,不足以推翻一句完整台词)。
|
||||
repeat/dialogue 一律保留;garbage/overlay 一律删除(明确的垃圾信号);
|
||||
noise 对短文本删除,但 ≥min_keep_len 的长文本不删——LLM 判定不稳定,
|
||||
完整对话句常被误判 noise,长度保护是必要兜底(实测移除后新增误删
|
||||
124 条真实长对话)。长度只用于"保护",不用于"删除"。
|
||||
"""
|
||||
if category not in DELETE_CATEGORIES:
|
||||
return False
|
||||
@@ -151,9 +169,9 @@ def _should_delete(category: str, text: str, min_keep_len: int) -> bool:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _judge_category(
|
||||
entries: list[dict], index: int, context_size: int, params: dict
|
||||
entries: list[dict], index: int, context_size: int, params: dict,
|
||||
overlay_tokens: set[str] | None = None,
|
||||
) -> str:
|
||||
"""调用 LLM 把目标字幕归入五类之一,返回类别词(未识别回退 dialogue)。
|
||||
|
||||
@@ -164,10 +182,16 @@ def _judge_category(
|
||||
start = max(0, index - context_size)
|
||||
end = min(len(entries), index + context_size + 1)
|
||||
target_pos = index - start
|
||||
lines = [
|
||||
f"{TARGET_MARK}{text}" if pos == target_pos else text
|
||||
for pos, text in enumerate(entry["text"] for entry in entries[start:end])
|
||||
]
|
||||
# 上下文净化:喂给 LLM 的是**过滤后的字幕**——相邻条目若被确定性规则层
|
||||
# 识别为垃圾(横线/HTML/水印 token/URL/裸域名等)直接从上下文中剔除,
|
||||
# 避免覆盖层垃圾污染 LLM 对整段场景的判断(误删相邻的真实对话)。
|
||||
tokens = overlay_tokens if overlay_tokens is not None else DEFAULT_OVERLAY_TOKENS
|
||||
lines = []
|
||||
for pos, entry in enumerate(entries[start:end]):
|
||||
if pos != target_pos and _rule_verdict(entry["text"], tokens) is True:
|
||||
continue
|
||||
text = entry["text"]
|
||||
lines.append(f"{TARGET_MARK}{text}" if pos == target_pos else text)
|
||||
|
||||
# LLM 兼容接口配置:地址/Key/模型/超时均可通过环境变量覆盖(默认 SiliconFlow)。
|
||||
api_base = os.getenv(
|
||||
@@ -186,6 +210,8 @@ def _judge_category(
|
||||
"noise:与上下文无关、无实际语义的杂项\n"
|
||||
"repeat:内容性重复(如语气词、呻吟、重复的感叹或对话),属于内容本身\n"
|
||||
"dialogue:正常对话\n"
|
||||
"字幕序列已经过确定性规则过滤(装饰性横线、HTML、网址/水印等已被剔除),"
|
||||
"请只依据剩下的对话内容判断目标字幕,不要臆测被过滤掉的部分。\n"
|
||||
"只输出一个类别英文单词,不要输出其他内容。"
|
||||
)
|
||||
body = {
|
||||
@@ -210,8 +236,23 @@ def _judge_category(
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=request_timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
# 429(限流)与 5xx(服务端错误)时指数退避重试:请求被限流时让出时间,
|
||||
# 使窗口平均响应变慢,触发自适应线程池"慢响应减线程",并发自动回落到
|
||||
# 限流配额内;最多尝试 3 次,耗尽仍失败则抛出,由调用方(任务)重新处理。
|
||||
max_attempts = 3
|
||||
retry_delay = 1.0
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=request_timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
break
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 429 and not (500 <= exc.code < 600):
|
||||
raise
|
||||
if attempt >= max_attempts - 1:
|
||||
raise
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
content = str(payload["choices"][0]["message"]["content"]).strip().lower()
|
||||
# 精确匹配五个类别词(兼容"garbagexxx"这类多余输出)。
|
||||
for category in _ALL_CATEGORIES:
|
||||
@@ -226,8 +267,8 @@ def _judge_category(
|
||||
def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
"""过滤 SRT 中多余/无意义的字幕,产物为 filtered.srt。
|
||||
|
||||
流程:规则层(确定性删除)→ LLM 层(去重后按唯一文本多维分类)→
|
||||
长文本保护 → 保留条重新编号输出。
|
||||
流程:规则层(确定性删除)→ LLM 层(去重后按唯一文本多维分类,
|
||||
上下文为过滤后的字幕)→ 保留条重新编号输出。
|
||||
"""
|
||||
srt_uri = request.inputs.get("srt_uri")
|
||||
if not srt_uri:
|
||||
@@ -267,13 +308,23 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
else:
|
||||
pool_indices = llm_needed
|
||||
|
||||
# 单条判断的工作函数:返回类别词。
|
||||
# 单条判断的工作函数:返回类别词;overlay_tokens 用于上下文净化。
|
||||
def judge_one(index: int) -> str:
|
||||
return _judge_category(entries, index, context_size, request.params)
|
||||
try:
|
||||
return _judge_category(entries, index, context_size, request.params, overlay_tokens)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# 限流/服务端错误:通知线程池临时降低最大并发,避免持续超配额。
|
||||
if exc.code == 429 or 500 <= exc.code < 600:
|
||||
pool.report_failure()
|
||||
raise
|
||||
|
||||
# 进度日志:打印已判定条数、总数与平均处理速度(条/s)。
|
||||
def log_progress(done: int, total: int, rate: float) -> None:
|
||||
logger.info("字幕判定进度 %d/%d 条 (%.1f 条/s)", done, total, rate)
|
||||
# 进度日志:打印已判定条数、总数、平均处理速度(条/s)、最近窗口
|
||||
# 平均单条耗时与当前线程数(与 OCR 节点同一回调协议)。
|
||||
def log_progress(done: int, total: int, rate: float, avg_time: float, workers: int) -> None:
|
||||
logger.info(
|
||||
"字幕判定进度 %d/%d 条 (%.1f 条/s, 平均 %.2fs/条, 线程 %d/%d)",
|
||||
done, total, rate, avg_time, workers, pool.max_workers,
|
||||
)
|
||||
|
||||
# 自适应并发调用 LLM:按实测负载弹性伸缩,避免压垮 LLM 接口。
|
||||
pool = AdaptiveThreadPool(
|
||||
@@ -287,11 +338,25 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
)
|
||||
categories = pool.map(pool_indices)
|
||||
|
||||
# 限流/服务端错误已在 worker 内 report_failure 临时降并发:把失败的
|
||||
# 条目在收紧后的并发下重试一轮(_judge_category 内还有 429/5xx 退避),
|
||||
# 二次仍失败才整体失败——避免 20 并发一拥而上被限流打挂整个任务。
|
||||
failed = [
|
||||
index for index, category in zip(pool_indices, categories)
|
||||
if isinstance(category, Exception)
|
||||
]
|
||||
if failed:
|
||||
logger.info("判定失败 %d 条,降并发后重试", len(failed))
|
||||
retried = pool.map(failed)
|
||||
for index, category in zip(failed, retried):
|
||||
if isinstance(category, Exception):
|
||||
# 二次仍失败(限流持续/非限流错误):整体失败,由任务重试。
|
||||
return InvokeResponse(status="failed", error=str(category))
|
||||
cat_by_index[index] = category
|
||||
for index, category in zip(pool_indices, categories):
|
||||
# 并行下 LLM 异常被线程池隔离为异常结果:任一条失败即整体失败,
|
||||
# 避免静默输出未过滤结果。
|
||||
# 首次失败的条目已在重试分支处理,这里只登记成功结果。
|
||||
if isinstance(category, Exception):
|
||||
return InvokeResponse(status="failed", error=str(category))
|
||||
continue
|
||||
cat_by_index[index] = category
|
||||
if dedupe:
|
||||
# 去重填充:与首次出现同键的条目复用同一类别,保证判定一致。
|
||||
@@ -299,11 +364,11 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
if i not in cat_by_index:
|
||||
cat_by_index[i] = cat_by_index[first_of_key[_dedup_key(entries[i]["text"])]]
|
||||
|
||||
# 阶段 3:合并规则与 LLM 判定,应用长文本保护并输出。
|
||||
# 阶段 3:合并规则与 LLM 判定,输出保留条。
|
||||
kept: list[dict] = []
|
||||
removed = 0
|
||||
for i, (entry, rule_verdict) in enumerate(zip(entries, rule_verdicts)):
|
||||
# 规则层命中即删(True);未命中则按 LLM 类别与长文本保护判定。
|
||||
# 规则层命中即删(True);未命中则按 LLM 类别判定。
|
||||
if rule_verdict is True:
|
||||
removed += 1
|
||||
continue
|
||||
|
||||
+110
-30
@@ -10,6 +10,7 @@ SRT 基准数据:每条字幕消失时间 = 最后可见帧时间 + 采样间
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from nodes.adaptive_pool import AdaptiveThreadPool
|
||||
@@ -20,6 +21,44 @@ from wov_sdk.models import InvokeRequest, InvokeResponse
|
||||
|
||||
logger = get_logger("subtitle-ocr")
|
||||
|
||||
# 节点级断点存档的写锁:多线程 OCR 并发完成时串行化追加写,避免行交错。
|
||||
_partial_lock = threading.Lock()
|
||||
|
||||
# 断点存档文件名:每行 {"frame": 帧序号(0-based), "text": 该帧OCR文本}。
|
||||
# OCR 每帧完成后立即追加一行;进程重启后从存档恢复已处理帧,只对未处理
|
||||
# 帧重新调用 vlm-ocr——2 小时视频级任务中断后不浪费已完成的帧。
|
||||
_PARTIAL_NAME = "ocr_partial.jsonl"
|
||||
|
||||
# 暂停信号文件名:位于 run 根目录(<storage>/runs/<run_id>/paused.flag),
|
||||
# 由暂停接口写入、继续/重试/删除时清除;节点逐帧检查,存在即中止。
|
||||
_PAUSE_FLAG = "paused.flag"
|
||||
|
||||
|
||||
class PauseRequested(Exception):
|
||||
"""节点内暂停信号:OCR 检测到任务被暂停后抛出,由调度器保持 PAUSED。
|
||||
|
||||
不把暂停误报为 FAILED:调度器捕获异常时若任务状态已是 PAUSED,
|
||||
则保持暂停等待用户 resume,从断点存档继续未处理帧。
|
||||
"""
|
||||
|
||||
|
||||
def _load_partial(output_dir: Path) -> dict[int, str]:
|
||||
"""读取节点级断点存档,返回 {帧序号: OCR 文本};无存档时返回空字典。"""
|
||||
path = output_dir / _PARTIAL_NAME
|
||||
if not path.is_file():
|
||||
return {}
|
||||
result: dict[int, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# 进程被杀时可能残留半行写入:跳过该行,对应帧视为未处理。
|
||||
continue
|
||||
result[int(item["frame"])] = str(item["text"])
|
||||
return result
|
||||
|
||||
# 默认垃圾词:无文字帧的模型输出可能反复出现这些词。
|
||||
def _sampling_interval(manifest: list[dict], default: float) -> float:
|
||||
"""从帧清单时间轴推导采样间隔(相邻帧时间差的中位数)。
|
||||
@@ -112,9 +151,30 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
if request.params.get(key) is not None
|
||||
}
|
||||
|
||||
# 节点级断点:读取已处理帧存档,只对未处理帧调用 vlm-ocr。
|
||||
output_dir = Path(request.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
partial_path = output_dir / _PARTIAL_NAME
|
||||
partial_texts = _load_partial(output_dir)
|
||||
pending = [(i, item) for i, item in enumerate(manifest) if i not in partial_texts]
|
||||
if partial_texts:
|
||||
logger.info(
|
||||
"检测到节点级断点:%d/%d 帧已处理,本次只处理剩余 %d 帧",
|
||||
len(partial_texts), len(manifest), len(pending),
|
||||
)
|
||||
|
||||
# 单帧 OCR:并行池的工作函数,返回该帧识别文本(失败/空/超长均返回空串)。
|
||||
# 每帧无论结果如何都把 {frame, text} 追加到断点存档,重启后不再重跑该帧。
|
||||
def ocr_frame(payload) -> str:
|
||||
index, item = payload
|
||||
# 暂停检查:调度器置 PAUSED 并向 run 根写入 paused.flag 后,工作线程
|
||||
# 立即中止(不 OCR、不写存档,该帧恢复时重跑),让 map 快速结束。
|
||||
if (Path(request.output_dir).parent.parent / _PAUSE_FLAG).exists():
|
||||
# 抑制后续进度回调:队列中剩余大量帧会快速失败退出,避免逐项
|
||||
# 打印"OCR 进度"导致日志井喷。
|
||||
pool.cancel()
|
||||
raise PauseRequested(f"OCR 被暂停(run {request.run_id})")
|
||||
text = ""
|
||||
response = registry.invoke(
|
||||
"vlm-ocr",
|
||||
InvokeRequest(
|
||||
@@ -128,42 +188,62 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
if response.status != "completed":
|
||||
# 单帧失败不中断整体,跳过该帧继续汇总。
|
||||
logger.warning("帧 %d OCR 失败,跳过: %s", index, response.error)
|
||||
return ""
|
||||
logger.info("帧 %d/%d OCR 完成: %r", index + 1, len(manifest), response.outputs.get("text"))
|
||||
text = str(response.outputs.get("text", "")).strip()
|
||||
if not text:
|
||||
return ""
|
||||
# 超长输出视为模型异常(重复循环等),直接报错并跳过该帧。
|
||||
if len(text) > max_result_chars:
|
||||
logger.warning(
|
||||
"帧 %d OCR 输出超长(%d > %d),跳过: %r",
|
||||
index, len(text), max_result_chars, text[:60],
|
||||
)
|
||||
return ""
|
||||
else:
|
||||
logger.info("帧 %d/%d OCR 完成: %r", index + 1, len(manifest), response.outputs.get("text"))
|
||||
text = str(response.outputs.get("text", "")).strip()
|
||||
if len(text) > max_result_chars:
|
||||
# 超长输出视为模型异常(重复循环等),直接报错并跳过该帧。
|
||||
logger.warning(
|
||||
"帧 %d OCR 输出超长(%d > %d),跳过: %r",
|
||||
index, len(text), max_result_chars, text[:60],
|
||||
)
|
||||
text = ""
|
||||
# 断点存档:成功/失败/空串都记录"已处理",恢复时保持一致行为。
|
||||
with _partial_lock:
|
||||
with partial_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"frame": index, "text": text}, ensure_ascii=False) + "\n")
|
||||
return text
|
||||
|
||||
# 进度日志:打印已识别帧数、总数与平均处理速度(帧/s)。
|
||||
def log_progress(done: int, total: int, rate: float) -> None:
|
||||
logger.info("OCR 进度 %d/%d 帧 (%.1f 帧/s)", done, total, rate)
|
||||
if pending:
|
||||
# 进度日志:打印已识别帧数、总数、平均处理速度(帧/s)、最近窗口平均
|
||||
# 单帧耗时与当前线程数——便于判断多线程是否因单帧处理过慢而未启用
|
||||
# (窗口平均响应 ≥ fast_threshold 时自适应池不会扩容)。
|
||||
def log_progress(done: int, total: int, rate: float, avg_time: float, workers: int) -> None:
|
||||
logger.info(
|
||||
"OCR 进度 %d/%d 帧 (%.1f 帧/s, 平均 %.2fs/帧, 线程 %d/%d)",
|
||||
done, total, rate, avg_time, workers, pool.max_workers,
|
||||
)
|
||||
|
||||
# 自适应并发调用 vlm-ocr:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限
|
||||
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1),
|
||||
# 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。
|
||||
pool = AdaptiveThreadPool(
|
||||
worker=ocr_frame,
|
||||
on_progress=log_progress,
|
||||
min_workers=int(request.params.get("pool_min_workers", 1)),
|
||||
max_workers=int(request.params.get("pool_max_workers", 16)),
|
||||
window_seconds=float(request.params.get("pool_window_seconds", 10.0)),
|
||||
fast_threshold=float(request.params.get("pool_fast_threshold", 0.3)),
|
||||
slow_threshold=float(request.params.get("pool_slow_threshold", 1.0)),
|
||||
)
|
||||
texts = pool.map(list(enumerate(manifest)))
|
||||
# 自适应并发调用 vlm-ocr:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限
|
||||
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1),
|
||||
# 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。
|
||||
pool = AdaptiveThreadPool(
|
||||
worker=ocr_frame,
|
||||
on_progress=log_progress,
|
||||
min_workers=int(request.params.get("pool_min_workers", 1)),
|
||||
max_workers=int(request.params.get("pool_max_workers", 16)),
|
||||
window_seconds=float(request.params.get("pool_window_seconds", 10.0)),
|
||||
fast_threshold=float(request.params.get("pool_fast_threshold", 0.3)),
|
||||
slow_threshold=float(request.params.get("pool_slow_threshold", 1.0)),
|
||||
)
|
||||
pending_texts = pool.map(pending)
|
||||
# 任一工作线程检测到暂停信号即整体中止:已写盘的断点存档保留,
|
||||
# resume 后从剩余帧续跑;以 failed 返回让调度器保持 PAUSED(不误报失败)。
|
||||
if any(isinstance(text, PauseRequested) for text in pending_texts):
|
||||
return InvokeResponse(status="failed", error=f"OCR 被暂停(run {request.run_id})")
|
||||
# 新增结果按帧号归位,与断点存档合并成完整帧序文本列表。
|
||||
new_by_index = {i: t for (i, _item), t in zip(pending, pending_texts)}
|
||||
else:
|
||||
new_by_index = {}
|
||||
|
||||
# 完整帧序文本:优先取存档,其次取本次新增(未处理帧必有其一条目)。
|
||||
texts = [
|
||||
partial_texts.get(i, new_by_index.get(i, ""))
|
||||
for i in range(len(manifest))
|
||||
]
|
||||
# 按帧顺序合并连续相同字幕(与重组装旧数据共用 _merge_kept)。
|
||||
kept = _merge_kept(manifest, texts)
|
||||
output_dir = Path(request.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
# output_dir 已在断点初始化时创建(mkdir 幂等),此处直接使用。
|
||||
output_path = output_dir / "subtitle.srt"
|
||||
output_path.write_text("\n".join(_assemble_srt(kept, interval)), encoding="utf-8")
|
||||
logger.info("字幕汇总完成: %d 条", len(kept))
|
||||
|
||||
Reference in New Issue
Block a user