- 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 不入库)
192 lines
7.7 KiB
Python
192 lines
7.7 KiB
Python
"""视频字幕生成流水线 → 时间对齐集成测试(真实数据复现"字幕时间不吻合")。
|
||
|
||
背景:用户反馈 demo"视频字幕生成"产物的字幕与人物说话时间不吻合——存在
|
||
字幕**过早**或**过晚**展示。本测试**不自己跑 whisper**(长视频转写慢、低
|
||
显存易 OOM),而是:
|
||
|
||
1. 直接用现有"视频字幕生成"流水线跑一遍真实视频(该流水线已产出
|
||
transcript.srt 中文翻译字幕,时间轴来自 whisper + 提示词翻译);
|
||
2. 测试读取流水线**产物文件夹**里的最终字幕(.srt/.ass);
|
||
3. 与人工校对的**参考字幕**(testdata/alignment/<name>.reference.srt,即
|
||
硬字幕 OCR 提取、时间轴为"说话真实发生的时间")做时间对齐量化评估,
|
||
复现"过早/过晚"问题(红),为修复提供绿标准。
|
||
|
||
产物来源(用户告知):流水线结果文件夹,含最终字幕文件。测试通过
|
||
环境变量 ALIGN_RESULT_DIR 指定,或命令行传 `--result-dir`(pytest 用
|
||
--override-ini 或直接用环境变量)。
|
||
|
||
数据契约:
|
||
- 参考字幕:``testdata/alignment/<name>.reference.srt``(硬字幕/人工校对)
|
||
- 流水线产物:``$ALIGN_RESULT_DIR/*.srt|*.ass``(最终字幕,时间轴为产物)
|
||
素材/参考/产物任一缺失时测试整体跳过(不污染 100% 覆盖率门禁)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from tests.realdata_contract import (
|
||
ALIGNMENT_DIR,
|
||
TIER1_TOLERANCE_SECONDS,
|
||
TIER2_EARLY_SECONDS,
|
||
TIER2_LATE_SECONDS,
|
||
align_report,
|
||
alignment_candidates,
|
||
clean_reference,
|
||
parse_srt_entries,
|
||
)
|
||
|
||
|
||
def _result_dir() -> Path | None:
|
||
"""返回流水线产物文件夹(环境变量 ALIGN_RESULT_DIR 指定);未设置返回 None。"""
|
||
raw = os.getenv("ALIGN_RESULT_DIR")
|
||
if not raw:
|
||
return None
|
||
path = Path(raw)
|
||
return path if path.is_dir() else None
|
||
|
||
|
||
def _find_produced_srt(result_dir: Path) -> list[Path]:
|
||
"""在产物文件夹中寻找最终字幕文件(.srt / .ass),按名称排序。"""
|
||
files = []
|
||
for ext in (".srt", ".ass"):
|
||
files.extend(result_dir.glob(f"*{ext}"))
|
||
# 排除参考字幕与临时文件。
|
||
files = [f for f in files if ".reference" not in f.name and not f.name.startswith("._")]
|
||
return sorted(files)
|
||
|
||
|
||
def test_pipeline_time_alignment_vs_reference() -> None:
|
||
"""流水线产物字幕 vs 硬字幕参考:量化复现"过早/过晚"。
|
||
|
||
做法(真实数据,不 mock):
|
||
- 取 testdata/alignment/<name>.reference.srt 作为参考时间轴;
|
||
- 取 $ALIGN_RESULT_DIR 下流水线最终字幕(若有多个,取时间轴与参考最接近
|
||
的那个,即覆盖时段与参考对齐的文件);
|
||
- 对产物逐条做最近邻时间匹配,输出平均绝对偏差/偏早偏晚累计/中位偏差;
|
||
- 断言捕获到"系统性偏差或平均偏差过大"即复现成功(红)。
|
||
|
||
修复后(vad 策略/对齐策略/翻译时间戳策略改善)偏差应回落,测试转绿。
|
||
"""
|
||
candidates = alignment_candidates()
|
||
if not candidates:
|
||
pytest.skip(
|
||
f"缺少参考数据({ALIGNMENT_DIR}/<name>.<ext> + <name>.reference.srt),"
|
||
"跳过时间对齐集成测试"
|
||
)
|
||
result_dir = _result_dir()
|
||
if result_dir is None:
|
||
pytest.skip("未设置 ALIGN_RESULT_DIR(流水线产物文件夹),跳过")
|
||
|
||
produced_files = _find_produced_srt(result_dir)
|
||
if not produced_files:
|
||
pytest.skip(
|
||
f"{result_dir} 下没有 .srt/.ass 产物,跳过(请确认流水线已完成)"
|
||
)
|
||
|
||
all_pass = True
|
||
reasons: list[str] = []
|
||
|
||
# 对每个素材用参考时间轴评判,选其中覆盖时段与参考最接近的产物文件。
|
||
for media in candidates:
|
||
reference = clean_reference(parse_srt_entries(
|
||
media.with_suffix(".reference.srt").read_text(encoding="utf-8")
|
||
))
|
||
if not reference:
|
||
reasons.append(f"{media.stem}: 参考字幕为空")
|
||
all_pass = False
|
||
continue
|
||
|
||
# 择优:产物文件与参考起始时间差最小的作为该素材的评判对象。
|
||
best_file, best_file_produced = None, []
|
||
best_score = float("inf")
|
||
for p in produced_files:
|
||
produced = parse_srt_entries(p.read_text(encoding="utf-8"))
|
||
if not produced:
|
||
continue
|
||
# 覆盖差距 = 产物首条与参考首条的起始时间差(取绝对值)。
|
||
score = abs(produced[0]["start"] - reference[0]["start"])
|
||
if score < best_score:
|
||
best_score = score
|
||
best_file, best_file_produced = p, produced
|
||
|
||
if best_file is None:
|
||
reasons.append(f"{media.stem}: 产物文件均无法与参考匹配")
|
||
all_pass = False
|
||
continue
|
||
|
||
report = align_report(media.stem, True, best_file_produced, reference)
|
||
print("\n" + report.format_summary())
|
||
print(f" 产物文件: {best_file.name}")
|
||
|
||
reason = None
|
||
if report.mean_abs_error > TIER1_TOLERANCE_SECONDS:
|
||
reason = (
|
||
f"{media.stem} 平均绝对偏差 {report.mean_abs_error:.2f}s > "
|
||
f"容差 {TIER1_TOLERANCE_SECONDS}s(过早/过晚普遍存在)"
|
||
)
|
||
elif report.consistently_early:
|
||
reason = (
|
||
f"{media.stem} 系统性偏早(中位 {report.bias:+.2f}s < "
|
||
f"-{TIER2_EARLY_SECONDS}s)"
|
||
)
|
||
elif report.consistently_late:
|
||
reason = (
|
||
f"{media.stem} 系统性偏晚(中位 {report.bias:+.2f}s > "
|
||
f"+{TIER2_LATE_SECONDS}s)"
|
||
)
|
||
if reason:
|
||
reasons.append(reason)
|
||
all_pass = False
|
||
else:
|
||
print(f" 对齐正常: {media.stem}")
|
||
|
||
# 结论性断言:至少量化捕获到一个"过早/过晚"信号才是"复现成功"。
|
||
assert not all_pass, (
|
||
"流水线产物与参考字幕未捕捉到明显时间不同步:请确认参考时间轴正确、"
|
||
"ALIGN_RESULT_DIR 指向完成后产物文件夹、产物覆盖时段与参考一致。"
|
||
)
|
||
if reasons:
|
||
raise AssertionError(
|
||
"已量化复现字幕时间与说话时间不吻合:\n- " + "\n- ".join(reasons)
|
||
)
|
||
|
||
|
||
def test_alignment_report_helpers() -> None:
|
||
"""纯函数冒烟:对齐指标组件(不依赖真实数据,用于验证指标本身)。"""
|
||
produced = [
|
||
{"start": 1.0, "end": 2.0, "text": "a"},
|
||
{"start": 4.0, "end": 5.0, "text": "b"},
|
||
]
|
||
reference = [
|
||
{"start": 1.0, "end": 2.0, "text": "A"},
|
||
{"start": 4.0, "end": 5.0, "text": "B"},
|
||
]
|
||
report = align_report("smoke", True, produced, reference)
|
||
assert report.mean_abs_error == 0.0 # 完美对齐时平均绝对偏差为 0
|
||
assert not report.consistently_early
|
||
assert not report.consistently_late
|
||
assert report.bias == 0.0
|
||
|
||
# 系统性偏晚:产物起始全部比参考晚 1.5s。
|
||
produced_late = [
|
||
{"start": 2.5, "end": 3.5, "text": "a"},
|
||
{"start": 5.5, "end": 6.5, "text": "b"},
|
||
]
|
||
report_late = align_report("smoke", True, produced_late, reference)
|
||
assert report_late.consistently_late
|
||
assert report_late.bias > TIER2_LATE_SECONDS
|
||
|
||
# 系统性偏早:产物起始全部比参考早 1.5s。
|
||
ref_early = [
|
||
{"start": 2.0, "end": 3.0, "text": "X"},
|
||
]
|
||
produced_early = [
|
||
{"start": 0.5, "end": 1.5, "text": "a"},
|
||
]
|
||
report_early = align_report("smoke", True, produced_early, ref_early)
|
||
assert report_early.consistently_early
|
||
assert report_early.bias < -TIER2_EARLY_SECONDS |