Files
vrsub/scripts/compare_whisper_v2_vs_v3.py
T

248 lines
9.7 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.
"""whisper large-v3 vs large-v2 同配置对比实验脚本(savr-1054 全片)。
目的:验证 large-v2 是否比当前生产用的 large-v3 更适合本项目(日语成人内容,
呻吟/BGM 混叠、弱语音)。方法:对同一视频,用相同参数分别跑 large-v3 与
large-v2 的「生产 VAD」与「无 VAD」两条链路,统计条数/覆盖时长/幻觉长段等
指标,与 docs/调研-whisper漏句与decode_full验证.md 中记录的 v3 历史结果对照。
复现路径:直接调用 nodes/whisper.py 的 invoke(真实节点代码),不绕开注册表
或重写业务逻辑。显式传 vad_parameters 复现文档实验一的"生产 VAD
threshold0.5/ms1000/pad200",并关闭自动 VADWOV_AUTO_VAD=0),确保
v3/v2 差异纯粹来自模型,而非信号分析分支抖动。
用法:
uv run python scripts/compare_whisper_v2_vs_v3.py \
--video /mnt/fnOS/123/savr-1054/4k2.me@savr01054_2_8k.mp4 \
--out data/experiments/whisper_v2_vs_v3
产物:
<out>/audio.wav 16k 单声道(ffmpeg 提取一次,复用)
<out>/<tag>/transcript.srt 各组合的 SRT 产物
<out>/summary.csv 指标汇总
控制台打印对比表
"""
from __future__ import annotations
import argparse
import csv
import os
import re
import subprocess
import sys
import time
from pathlib import Path
# 保证脚本可从仓库根目录直接 import nodes/wov_sdk。
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ.setdefault("WOV_AUTO_VAD", "0") # 关闭自动 VAD,用显式参数
from nodes.ffmpeg import _ffmpeg_bin # noqa: E402
from nodes.subtitle_cleanup import JAPANESE_HALLUCINATION_TOKENS # noqa: E402
from wov_sdk.models import InvokeRequest # noqa: E402
# 实验一的生产 VAD 配置(文档记录):threshold0.5 / min_silence1000 / pad200。
PROD_VAD_PARAMS = {
"threshold": 0.5,
"min_silence_duration_ms": 1000,
"speech_pad_ms": 200,
}
# 幻觉长段判定:展示时长 ≥ 阈值 且文本命中日文寒暄词表(与 subtitle_cleanup
# 的 decode_full 清洗同一判据,这里不删除只统计)。
HALLUC_THRESHOLD_SECONDS = 15.0
VIDEO_PATH = "/mnt/fnOS/123/savr-1054/4k2.me@savr01054_2_8k.mp4"
OUT_DIR = ROOT / "data" / "experiments" / "whisper_v2_vs_v3"
def extract_audio(video: Path, out_dir: Path) -> Path:
"""用 ffmpeg 提取 16k 单声道 wav(与 ffmpeg-extract 节点相同规格)。"""
wav = out_dir / "audio.wav"
if wav.is_file() and wav.stat().st_size > 0:
return wav
subprocess.run(
[
_ffmpeg_bin(),
"-y", "-i", str(video),
"-vn", "-ac", "1", "-ar", "16000",
str(wav),
],
check=True, capture_output=True,
)
return wav
def parse_srt(srt_text: str) -> list[dict]:
"""解析 SRT 文本为 cue 列表(序号/起止/文本/时长),文档统计口径。"""
cues = []
# 标准 SRT 块:序号 / 时间轴 / 文本行(可多行)/ 空行。
blocks = re.split(r"\n\s*\n", srt_text.strip())
for block in blocks:
lines = [ln for ln in block.splitlines() if ln.strip()]
if len(lines) < 2:
continue
if not lines[0].strip().isdigit():
continue
m = re.match(
r"(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*(\d+):(\d+):(\d+)[,.](\d+)",
lines[1],
)
if not m:
continue
start = (int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3])) + int(m[4]) / 1000
end = (int(m[5]) * 3600 + int(m[6]) * 60 + int(m[7])) + int(m[8]) / 1000
text = " ".join(lines[2:]).strip()
cues.append({
"start": start,
"end": end,
"duration": end - start,
"text": text,
})
return cues
def stats(cues: list[dict], total_seconds: float) -> dict:
"""汇总指标:条数/展示时长和/覆盖时长(并集)/幻觉长段。"""
count = len(cues)
display_sum = sum(c["duration"] for c in cues)
# 覆盖并集:按开始时间排序后合并重叠区间。
ordered = sorted(cues, key=lambda c: c["start"])
union = 0.0
cur_s, cur_e = None, None
for c in ordered:
if cur_s is None:
cur_s, cur_e = c["start"], c["end"]
elif c["start"] <= cur_e:
cur_e = max(cur_e, c["end"])
else:
union += cur_e - cur_s
cur_s, cur_e = c["start"], c["end"]
if cur_s is not None:
union += cur_e - cur_s
# 幻觉长段:时长≥阈值 且 文本含任一寒暄词。
halluc = [
c for c in cues
if c["duration"] >= HALLUC_THRESHOLD_SECONDS
and any(tok in c["text"] for tok in JAPANESE_HALLUCINATION_TOKENS)
]
halluc_seconds = sum(c["duration"] for c in halluc)
return {
"count": count,
"display_sum": round(display_sum, 1),
"union": round(union, 1),
"coverage_pct": round(union / total_seconds * 100, 1) if total_seconds else 0.0,
"halluc_count": len(halluc),
"halluc_seconds": round(halluc_seconds, 1),
}
def run_combo(tag: str, model_path: str, wav: Path, out_dir: Path,
vad_filter: bool, vad_parameters: dict | None) -> dict:
"""跑一趟 whisper.invoke(真实节点路径),返回统计与产物路径。"""
combo_dir = out_dir / tag
combo_dir.mkdir(parents=True, exist_ok=True)
params: dict = {
"language": "ja",
"model_path": model_path,
"condition_on_previous_text": False,
"chunk_seconds": 60,
"vad_filter": vad_filter,
"beam_size": 1,
}
if vad_parameters is not None:
params["vad_parameters"] = vad_parameters
request = InvokeRequest(
run_id=f"exp-{tag}",
node_instance_id="whisper",
inputs={"audio_uri": str(wav)},
params=params,
output_dir=str(combo_dir),
)
started = time.monotonic()
from nodes.whisper import invoke
response = invoke(request)
elapsed = time.monotonic() - started
if response.status != "completed":
raise RuntimeError(f"[{tag}] invoke failed: {response.error}")
srt_path = combo_dir / "transcript.srt"
cues = parse_srt(srt_path.read_text(encoding="utf-8"))
return {"tag": tag, "model": model_path, "elapsed": round(elapsed, 1), "cues": cues}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--video", default=VIDEO_PATH, help="待测视频路径")
parser.add_argument("--out", default=str(OUT_DIR), help="实验输出目录")
args = parser.parse_args()
video = Path(args.video)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
if not video.is_file():
sys.exit(f"视频不存在: {video}")
# 用 ffmpeg -i 的 stderr 探测视频时长(ffprobe 可能不在 PATH)。
probe = subprocess.run(
[_ffmpeg_bin(), "-i", str(video)],
capture_output=True, text=True,
).stderr
m = re.search(r"Duration:\s*(\d+):(\d+):(\d+)\.(\d+)", probe)
if m:
total_seconds = int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 100
else:
total_seconds = 0.0
print(f"视频: {video} 时长 {total_seconds:.1f}s")
print("提取 16k 单声道音频 ...")
wav = extract_audio(video, out_dir)
# 4 个组合:v3/v2 × 生产VAD/无VAD,串行执行(模型加载复用由 faster-whisper
# 内部承担,这里每次 invoke 新载一次,串行避免显存竞争)。
combos = [
("v3_vad", "faster-whisper-large-v3", True, PROD_VAD_PARAMS),
("v3_novad", "faster-whisper-large-v3", False, None),
("v2_vad", "faster-whisper-large-v2", True, PROD_VAD_PARAMS),
("v2_novad", "faster-whisper-large-v2", False, None),
]
results = []
for tag, model, vad_filter, vp in combos:
print(f"\n=== {tag} (model={model}, vad_filter={vad_filter}) ===")
r = run_combo(tag, model, wav, out_dir, vad_filter, vp)
s = stats(r["cues"], total_seconds)
r.update(s)
results.append(r)
# 简要打印每条产物前若干行验证非空。
print(f" {s['count']} 条 / 展示和 {s['display_sum']}s / "
f"覆盖 {s['union']}s ({s['coverage_pct']}%) / "
f"幻觉长段 {s['halluc_count']}{s['halluc_seconds']}s "
f"/ 耗时 {r['elapsed']}s")
# 汇总表:与控制台对齐输出 CSV + 终端 markdown 表格。
csv_path = out_dir / "summary.csv"
with csv_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["tag", "model", "count", "display_sum_s", "union_s",
"coverage_pct", "halluc_count", "halluc_seconds_s",
"elapsed_s"])
for r in results:
writer.writerow([r["tag"], r["model"], r["count"], r["display_sum"],
r["union"], r["coverage_pct"], r["halluc_count"],
r["halluc_seconds"], r["elapsed"]])
print("\n===== 汇总(大模型实物对比,同代码同配置) =====")
print(f"{'tag':9s} {'条数':>5s} {'展示和s':>8s} {'覆盖s':>7s} "
f"{'覆盖%':>6s} {'幻觉段数':>7s} {'幻觉s':>7s} {'耗时s':>7s}")
for r in results:
print(f"{r['tag']:9s} {r['count']:5d} {r['display_sum']:8.1f} "
f"{r['union']:7.1f} {r['coverage_pct']:6.1f} "
f"{r['halluc_count']:7d} {r['halluc_seconds']:7.1f} "
f"{r['elapsed']:7.1f}")
print(f"\n历史 large-v3 文档值(调研文档 2026-09):"
f"生产VAD 115 条/621s 覆盖;无VAD 369 条/983s 覆盖(有 119-149s、"
f"600-630s 30s 幻觉长段)。")
print(f"汇总已存: {csv_path}")
if __name__ == "__main__":
main()