Files
vrsub/scripts/extract_reference_srt.py
T
cat-shark 078170c12a feat: 真实数据时间对齐集成测试框架
- tests/realdata_contract.py:数据契约底座(SRT 解析/清洗、时间对齐量化
  指标 align_report、幻觉词/专名判定、提示词规则拼接)
- tests/test_integration_alignment.py:流水线产物 vs 硬字幕参考的时间对齐
  (真实数据复现"字幕过早/过晚",红→绿闭环)
- tests/test_integration_prompt_rules.py:寒暄幻觉/专名提示词规则测试
- scripts/extract_reference_srt.py:从烧录字幕视频自动提取参考时间轴
- testdata/REALDATA_README.md + alignment/*.reference.srt:真实参考字幕
  (视频素材较大,gitignore 不入库)
2026-09-05 19:36:23 +08:00

164 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从烧录字幕视频自动提取**参考时间轴**(供时间对齐集成测试使用)。
用户选择的参考数据途径:烧录字幕视频自动提取——复用仓库自带的生产链路
frame-extract 抽帧 → subtitle-ocr 逐帧 OCR → 汇总 SRT),把烧录字幕的
出现/消失帧时间换算成秒,产出严格符合测试契约的参考字幕:
testdata/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 testdata/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 / "testdata" / "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())