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 不入库)
This commit is contained in:
2026-09-05 19:36:23 +08:00
parent d1557eca46
commit 078170c12a
9 changed files with 5658 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
"""从烧录字幕视频自动提取**参考时间轴**(供时间对齐集成测试使用)。
用户选择的参考数据途径:烧录字幕视频自动提取——复用仓库自带的生产链路
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())