确认所有运行时引用均使用 V2(V3 已停用),并修复一处真实不一致: - 工作流数据文件:learn-translate 用 faster-whisper-large-v2、 zh-direct 用 whisper-large-v2-translate-zh-v0.2-st-ct2(原本即 V2); - 本地库的 demo 最新版本仍指向 large-v3:workflows/demo.json 早已改为 V2, 但 seed 对已存在工作流刻意跳过,导致旧库停留在历史上用 V3 保存的定义, 即本机跑 demo 实际加载 V3 权重。按用户决定移除 demo 工作流及其关联的 6 个 run、1 个批量任务与 431 条明细(媒体库中已放置的 6 个字幕成品保留); - nodes/whisper.py 候选与远端兜底本就是 large-v2; - V3 权重目录保留在盘上仅作对照实验,文档标注为废弃; scripts/compare_whisper_v2_vs_v3.py 保留用于对照。 顺带修复与清理: - src/wov_app/scheduler.py:_file_size 补捕 ValueError(见上一条提交说明 的真实缺陷,此处为同一批改动); - .gitignore:data/ 改为 /data/,避免连带忽略 tests/**/data/; - scripts/*:评测集路径改到 scripts/data/translate_eval/; - 代码注释与文档同步移除 demo 引用(历史调研文档保留说明性引用)。 验证:全量 477 passed;新库 seed 只创建 3 个 V2 工作流。
164 lines
6.4 KiB
Python
164 lines
6.4 KiB
Python
"""从烧录字幕视频自动提取**参考时间轴**(供时间对齐集成测试使用)。
|
||
|
||
用户选择的参考数据途径:烧录字幕视频自动提取——复用仓库自带的生产链路
|
||
(frame-extract 抽帧 → subtitle-ocr 逐帧 OCR → 汇总 SRT),把烧录字幕的
|
||
出现/消失帧时间换算成秒,产出严格符合测试契约的参考字幕:
|
||
|
||
tests/shared/data/alignment/<name>.reference.srt (标准 SRT)
|
||
|
||
为什么烧录字幕的时间是可信的 ground truth:
|
||
|
||
- 视频里烧录的字幕是**画面的一部分**,它在哪一帧出现/消失是客观事实;
|
||
frame-extract 按帧号取帧,时间 = 帧号 / fps,零累计偏差;
|
||
- subtitle-ocr 对逐帧 OCR 后合并相同字幕,结束 = 最后可见帧 + 采样间隔,
|
||
与视频烧录时间对齐(见 nodes/subtitle_ocr.py 的 _assemble_srt)。
|
||
|
||
因此提取出的参考时间轴可直接作为"说话真实发生的时间"与 whisper 产物比对。
|
||
**提取结果请人工核对无误后再使用**:脚本只负责自动化,不负责保证正确。
|
||
|
||
用法(在 vrsub 根目录,需 Ollama glm-ocr 服务可达、有 ffmpeg):
|
||
|
||
uv run python scripts/extract_reference_srt.py <视频路径> [--out tests/shared/data/alignment <name>] [--interval 0.5] [--crop 0,0.75,1,0.25]
|
||
|
||
- --interval:抽帧间隔秒(默认 0.5;字幕时长 2s 时约取 4 帧,够稳定合并)
|
||
- --crop:字幕区域相对比例 x,y,w,h(默认底部 1/4,与生产默认一致)
|
||
- 输出文件名默认 <视频名>.reference.srt;用 --out-dir/--name 可指定目录与
|
||
前缀,保证与 tests/realdata_contract.alignment_candidates() 的发现规则一致
|
||
(<name>.<ext> 与 <name>.reference.srt 同目录同名)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
from wov_sdk.models import InvokeRequest
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
# 与 tests/test_integration_subtitle_ocr.py、tests/test_integration_vlm.py 相同约定。
|
||
OLLAMA_HOST = "http://192.168.123.70:11434"
|
||
MODEL = "glm-ocr:latest"
|
||
|
||
|
||
def _ollama_reachable(host: str = OLLAMA_HOST, model: str = MODEL) -> bool:
|
||
"""探测 Ollama 服务与目标模型是否可用。"""
|
||
try:
|
||
req = urllib.request.Request(
|
||
f"{host}/api/show",
|
||
data=('{"model": "%s"}' % model).encode(),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
return resp.status == 200
|
||
except (urllib.error.URLError, OSError):
|
||
return False
|
||
|
||
|
||
def _register_nodes() -> None:
|
||
"""注册全部内置节点,供 subtitle-ocr 内部调 vlm-ocr 使用。"""
|
||
import sys as _sys
|
||
|
||
_sys.path.insert(0, str(PROJECT_ROOT))
|
||
from wov_app import registry
|
||
|
||
registry.register_all()
|
||
|
||
|
||
def _parse_crop(raw: str) -> list[float]:
|
||
"""解析 crop 参数:'x,y,w,h' -> [x, y, w, h]。"""
|
||
parts = [float(p) for p in raw.split(",")]
|
||
if len(parts) != 4:
|
||
raise SystemExit(f"crop 参数格式错误,应为 x,y,w,h,得到: {raw}")
|
||
return parts
|
||
|
||
|
||
def extract_reference_srt(
|
||
video: Path,
|
||
out_path: Path,
|
||
interval_seconds: float = 0.5,
|
||
crop: list[float] | None = None,
|
||
tmp_root: Path | None = None,
|
||
) -> Path:
|
||
"""跑 frame-extract → subtitle-ocr,把汇总 SRT 写为参考字幕文件。
|
||
|
||
返回写出的参考 SRT 路径。crop 缺省用画面底部 1/4([0, 0.75, 1, 0.25])。
|
||
tmp_root 供测试注入临时目录,缺省用视频同名临时目录(用完清理)。
|
||
"""
|
||
from nodes.frame_extract import invoke as frame_invoke
|
||
from nodes.subtitle_ocr import invoke as ocr_invoke
|
||
|
||
if crop is None:
|
||
crop = [0.0, 0.75, 1.0, 0.25]
|
||
tmp = tmp_root or (video.parent / f"_ref_{video.stem}")
|
||
tmp.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ① 抽帧:按帧号精确取帧(时间 = 帧号/fps,无累计偏差)。
|
||
frames_resp = frame_invoke(
|
||
InvokeRequest(
|
||
run_id=f"ref_{video.stem}",
|
||
node_instance_id="",
|
||
inputs={"video_uri": str(video)},
|
||
params={"interval_seconds": interval_seconds, "crop": crop},
|
||
output_dir=str(tmp / "frames"),
|
||
)
|
||
)
|
||
if frames_resp.status != "completed":
|
||
raise SystemExit(f"抽帧失败: {frames_resp.error}")
|
||
|
||
# ② 逐帧 OCR → 汇总 SRT(消失时间 = 最后可见帧 + 采样间隔)。
|
||
ocr_resp = ocr_invoke(
|
||
InvokeRequest(
|
||
run_id=f"ref_{video.stem}",
|
||
node_instance_id="",
|
||
inputs={"frames_manifest": str(frames_resp.outputs["frames_manifest"])},
|
||
params={"model": MODEL, "ollama_host": OLLAMA_HOST},
|
||
output_dir=str(tmp / "ocr"),
|
||
)
|
||
)
|
||
if ocr_resp.status != "completed":
|
||
raise SystemExit(f"OCR 失败: {ocr_resp.error}")
|
||
|
||
srt = Path(ocr_resp.outputs["srt_uri"])
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_text(srt.read_text(encoding="utf-8"), encoding="utf-8")
|
||
return out_path
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description="从烧录字幕视频自动提取参考时间轴")
|
||
parser.add_argument("video", type=Path, help="烧录字幕视频路径(mp4 等)")
|
||
parser.add_argument("--out-dir", type=Path, default=PROJECT_ROOT / "tests" / "shared" / "data" / "alignment")
|
||
parser.add_argument("--name", type=str, default=None, help="输出名前缀(默认=视频文件名)")
|
||
parser.add_argument("--interval", type=float, default=0.5, help="抽帧间隔秒(默认 0.5)")
|
||
parser.add_argument("--crop", type=str, default="0,0.75,1,0.25", help="字幕区域 x,y,w,h")
|
||
args = parser.parse_args(argv)
|
||
|
||
video = args.video.resolve()
|
||
if not video.is_file():
|
||
print(f"视频不存在: {video}")
|
||
return 1
|
||
if not _ollama_reachable():
|
||
print(f"Ollama 不可用({OLLAMA_HOST} / {MODEL}),无法提取参考字幕。")
|
||
return 2
|
||
|
||
_register_nodes()
|
||
name = args.name or video.stem
|
||
out_path = args.out_dir / f"{name}.reference.srt"
|
||
print(f"提取参考字幕 -> {out_path}", flush=True)
|
||
extract_reference_srt(
|
||
video,
|
||
out_path,
|
||
interval_seconds=args.interval,
|
||
crop=_parse_crop(args.crop),
|
||
)
|
||
print("完成。请人工核对参考时间轴后再用于时间对齐测试。", flush=True)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main()) |