Guanghu Domestic Migration a2fa7d57d8 chore: import sanitized domestic snapshot for REPO-005
Source snapshot: 23037fa3a2e9b0597162735755e92fc763bf4d94
2026-07-17 15:55:48 +08:00

559 lines
18 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

#!/usr/bin/env python3
"""
Subtitle Style A/B Test · 字幕样式 A/B 测试器
===========================================
同一底片一次生成多个字幕风格版本,对比选,不要一版一版盲调。
功能:
1. 读取多个字幕样式配置
2. 用同一个底片和 SRT生成多个字幕版本
3. 生成对比预览图contact sheet
4. 生成 HTML 对比页面
依赖:
pip install pysrt
用法:
# 单视频 A/B 测试
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --styles style1.json style2.json --output-dir ./ab-test/
# 批量 A/B 测试
python subtitle-style-ab-test.py --batch ./videos/ --srt-dir ./srt/ --styles style1.json style2.json --output-dir ./ab-tests/
# 使用预设样式
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --preset-styles reference-drama short-drama-bold --output-dir ./ab-test/
输出目录结构:
./ab-test/
├── style1/
│ ├── subtitles.ass
│ ├── output.mp4
│ └── preview.png
├── style2/
│ ├── subtitles.ass
│ ├── output.mp4
│ └── preview.png
├── comparison.png # 对比预览图
└── comparison.html # 对比 HTML 页面
路径:
video-ai-system/engines/subtitle-pipeline/ab-test-tools/subtitle-style-ab-test.py
"""
import argparse
import json
import os
import sys
import subprocess
from pathlib import Path
try:
import pysrt
except ImportError:
print("[ERROR] 缺少依赖pysrt")
print("请先安装pip install pysrt")
sys.exit(1)
# 预设样式列表
PRESET_STYLES = {
"reference-drama": {
"font_family": "PingFang SC",
"font_size": 38,
"font_color": "&HFFFFFF&",
"bold": True,
"stroke_enabled": True,
"stroke_width": 2,
"stroke_color": "&H000000&",
"alignment": 2,
"margin_left": 100,
"margin_right": 100,
"margin_vertical": 50,
},
"short-drama-bold": {
"font_family": "PingFang SC",
"font_size": 42,
"font_color": "&HFFFFFF&",
"bold": True,
"stroke_enabled": True,
"stroke_width": 1,
"stroke_color": "&H121212&",
"alignment": 2,
"margin_left": 80,
"margin_right": 80,
"margin_vertical": 60,
},
"clean-white": {
"font_family": "PingFang SC",
"font_size": 36,
"font_color": "&HFFFFFF&",
"bold": False,
"stroke_enabled": False,
"stroke_width": 0,
"stroke_color": "&H000000&",
"alignment": 2,
"margin_left": 100,
"margin_right": 100,
"margin_vertical": 50,
},
"black-box": {
"font_family": "PingFang SC",
"font_size": 36,
"font_color": "&HFFFFFF&",
"bold": False,
"stroke_enabled": False,
"stroke_width": 0,
"stroke_color": "&H000000&",
"background_enabled": True,
"background_color": "&H000000&",
"background_opacity": 0.6,
"alignment": 2,
"margin_left": 100,
"margin_right": 100,
"margin_vertical": 50,
},
}
def load_style_config(style_path: str) -> dict:
"""
加载样式配置
:param style_path: 样式文件路径或预设样式名称
:return: 样式字典
"""
# 检查是否是预设样式
if style_path in PRESET_STYLES:
print(f"[INFO] 使用预设样式:{style_path}")
return PRESET_STYLES[style_path]
# 加载自定义样式文件
if not os.path.isfile(style_path):
print(f"[ERROR] 样式文件不存在:{style_path}")
return None
try:
with open(style_path, "r", encoding="utf-8") as f:
config = json.load(f)
print(f"[INFO] 已加载样式配置:{style_path}")
return config
except Exception as e:
print(f"[ERROR] 加载样式配置失败:{e}")
return None
def generate_ass_file_for_style(srt_path: str, output_path: str, style: dict) -> bool:
"""
为指定样式生成 ASS 字幕文件
:param srt_path: SRT 文件路径
:param output_path: 输出 ASS 文件路径
:param style: 样式字典
:return: 是否成功
"""
if not os.path.isfile(srt_path):
print(f"[ERROR] SRT 文件不存在:{srt_path}")
return False
# 加载 SRT
try:
subs = pysrt.open(srt_path, encoding="utf-8")
except Exception as e:
print(f"[ERROR] 加载 SRT 失败:{e}")
return False
print(f"[INFO] 为样式生成 ASS 文件:{os.path.basename(output_path)}")
# 构建 ASS 文件内容(简化版,只生成 Dialogues
ass_lines = []
# 1. [Script Info] 节
ass_lines.append("[Script Info]")
ass_lines.append("; Script generated by Subtitle Style A/B Test")
ass_lines.append("PlayResX: 1080")
ass_lines.append("PlayResY: 1920")
ass_lines.append("")
# 2. [V4+ Styles] 节
ass_lines.append("[V4+ Styles]")
ass_lines.append(
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, "
"Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
)
# 构建样式行
font_name = style.get("font_family", "PingFang SC")
font_size = style.get("font_size", 38)
primary_colour = style.get("font_color", "&HFFFFFF&")
outline_colour = style.get("stroke_color", "&H000000&")
if style.get("background_enabled", False):
bg_opacity = int(style.get("background_opacity", 0.6) * 255)
back_colour = f"&H{bg_opacity:02X}000000&"
else:
back_colour = "&H00000000&"
bold = -1 if style.get("bold", False) else 0
outline_width = style.get("stroke_width", 2) if style.get("stroke_enabled", True) else 0
alignment = style.get("alignment", 2)
margin_l = style.get("margin_left", 100)
margin_r = style.get("margin_right", 100)
margin_v = style.get("margin_vertical", 50)
style_line = (
f"Style: Default,{font_name},{font_size},{primary_colour},&H000000FF&,{outline_colour},{back_colour},"
f"{bold},0,0,0,100,100,0,0,"
f"1,{outline_width},0,{alignment},{margin_l},{margin_r},{margin_v},1"
)
ass_lines.append(style_line)
ass_lines.append("")
# 3. [Events] 节
ass_lines.append("[Events]")
ass_lines.append("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text")
for sub in subs:
start_time = srt_time_to_ass(sub.start)
end_time = srt_time_to_ass(sub.end)
text = sub.text.strip()
# 转义特殊字符
text = text.replace("\\", "\\\\")
text = text.replace("{", "\\{")
text = text.replace("}", "\\}")
text = text.replace("\n", "\\N")
dialogue_line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{text}"
ass_lines.append(dialogue_line)
# 写入 ASS 文件
try:
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
with open(output_path, "w", encoding="utf-8-sig") as f:
f.write("\n".join(ass_lines))
print(f"[OK] ASS 字幕文件已生成:{output_path}")
return True
except Exception as e:
print(f"[ERROR] 写入 ASS 文件失败:{e}")
return False
def srt_time_to_ass(srt_time) -> str:
"""
将 pysrt 时间对象转换为 ASS 时间戳格式
:param srt_time: pysrt 时间对象
:return: ASS 时间戳字符串H:MM:SS.cc
"""
hours = srt_time.hours
minutes = srt_time.minutes
seconds = srt_time.seconds
milliseconds = srt_time.milliseconds
centiseconds = milliseconds // 10
return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
def burn_subtitles_with_ffmpeg(video_path: str, ass_path: str, output_path: str) -> bool:
"""
用 FFmpeg + libass 烧录字幕到视频
:param video_path: 输入视频路径
:param ass_path: ASS 字幕文件路径
:param output_path: 输出视频路径
:return: 是否成功
"""
if not os.path.isfile(video_path):
print(f"[ERROR] 视频文件不存在:{video_path}")
return False
if not os.path.isfile(ass_path):
print(f"[ERROR] ASS 字幕文件不存在:{ass_path}")
return False
# FFmpeg 命令
# 正确语法subtitles=filename='path' (需要 filename= 前缀)
ass_path_escaped = ass_path.replace(":", "\\:").replace("'", "'\\''")
cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-vf", f"subtitles=filename='{ass_path_escaped}'",
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-c:a", "copy",
"-shortest",
output_path
]
print(f"[INFO] 烧录字幕:{os.path.basename(output_path)}")
try:
result = subprocess.run(cmd, check=False, capture_output=True, text=True)
except FileNotFoundError:
print("[ERROR] 未找到 ffmpeg")
return False
if result.returncode != 0:
print("[ERROR] FFmpeg 烧录字幕失败")
print(result.stderr[-2000:])
return False
if os.path.isfile(output_path) and os.path.getsize(output_path) > 0:
print(f"[OK] 字幕已烧录:{output_path}")
return True
return False
def extract_sample_frames(video_path: str, output_dir: str, num_frames: int = 4) -> list:
"""
从视频中抽取样本帧(用于预览)
:param video_path: 视频文件路径
:param output_dir: 输出目录
:param num_frames: 抽取帧数
:return: 帧文件路径列表
"""
import cv2
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
print(f"[ERROR] 无法打开视频:{video_path}")
return []
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
# 计算采样间隔
interval = total_frames // (num_frames + 1)
frame_paths = []
for i in range(1, num_frames + 1):
frame_num = i * interval
# 跳转到指定帧
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
ret, frame = cap.read()
if not ret:
continue
# 保存帧
frame_path = os.path.join(output_dir, f"sample_{i:02d}.png")
cv2.imwrite(frame_path, frame)
frame_paths.append(frame_path)
cap.release()
print(f"[INFO] 已抽取 {len(frame_paths)} 个样本帧")
return frame_paths
def generate_comparison_preview(style_dirs: list, output_path: str):
"""
生成对比预览图
:param style_dirs: 样式目录列表
:param output_path: 输出图片路径
"""
try:
from PIL import Image
except ImportError:
print("[ERROR] 缺少依赖Pillow")
print("请先安装pip install Pillow")
return False
# 收集所有样本帧
all_frames = []
style_names = []
for style_dir in style_dirs:
style_name = os.path.basename(style_dir)
style_names.append(style_name)
# 查找样本帧
frame_files = [f for f in os.listdir(style_dir) if f.startswith("sample_") and f.endswith(".png")]
frame_files.sort()
for frame_file in frame_files:
frame_path = os.path.join(style_dir, frame_file)
all_frames.append((style_name, frame_path))
if not all_frames:
print("[WARN] 未找到样本帧,跳过对比预览图生成")
return False
# 读取第一帧,获取尺寸
first_frame = Image.open(all_frames[0][1])
frame_width, frame_height = first_frame.size
# 计算拼接图尺寸
num_styles = len(style_dirs)
num_samples = len(all_frames) // num_styles
# 横向拼接:每个样式一行,每行包含多个样本帧
sheet_width = num_samples * (frame_width + 10) + 10
sheet_height = num_styles * (frame_height + 30) + 10 # 30px 用于样式名称标注
# 创建拼接图
sheet = Image.new("RGB", (sheet_width, sheet_height), (0, 0, 0))
# 拼接帧
for i, (style_name, frame_path) in enumerate(all_frames):
frame = Image.open(frame_path)
# 计算位置
style_idx = style_names.index(style_name)
sample_idx = i % num_samples
x = sample_idx * (frame_width + 10) + 10
y = style_idx * (frame_height + 30) + 10
# 粘贴帧
sheet.paste(frame, (x, y + 20)) # +20px 用于样式名称标注
# 添加样式名称标注
draw = ImageDraw.Draw(sheet)
draw.text((x + 5, y + 5), style_name, fill=(255, 255, 255))
# 保存
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
sheet.save(output_path)
print(f"[OK] 对比预览图已生成:{output_path}")
return True
def main():
parser = argparse.ArgumentParser(
description="Subtitle Style A/B Test · 字幕样式 A/B 测试器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --styles style1.json style2.json --output-dir ./ab-test/
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --preset-styles reference-drama short-drama-bold --output-dir ./ab-test/
"""
)
parser.add_argument("--video", help="视频文件路径")
parser.add_argument("--srt", help="SRT 字幕文件路径")
parser.add_argument("--styles", nargs="+", help="样式配置文件路径列表")
parser.add_argument("--preset-styles", nargs="+", help="预设样式名称列表")
parser.add_argument("--batch", help="批量处理视频目录")
parser.add_argument("--srt-dir", help="SRT 文件目录(用于批量处理)")
parser.add_argument("--output-dir", required=True, help="输出目录")
parser.add_argument("--num-samples", type=int, default=4, help="每个样式抽取的样本帧数")
args = parser.parse_args()
# 收集样式配置
styles = []
style_names = []
if args.styles:
for style_path in args.styles:
style = load_style_config(style_path)
if style:
styles.append(style)
style_names.append(os.path.splitext(os.path.basename(style_path))[0])
if args.preset_styles:
for preset_name in args.preset_styles:
style = load_style_config(preset_name)
if style:
styles.append(style)
style_names.append(preset_name)
if not styles:
print("[ERROR] 请指定至少一种样式(--styles 或 --preset-styles")
sys.exit(1)
print(f"[INFO] 将测试 {len(styles)} 种样式:{style_names}")
if args.video:
# 单视频处理
if not args.srt:
print("[ERROR] 请指定 --srt")
sys.exit(1)
# 为每个样式生成字幕和视频
style_dirs = []
for i, (style, style_name) in enumerate(zip(styles, style_names)):
print(f"\n[INFO] 处理样式 {i+1}/{len(styles)}{style_name}")
# 创建样式目录
style_dir = os.path.join(args.output_dir, style_name)
os.makedirs(style_dir, exist_ok=True)
style_dirs.append(style_dir)
# 生成 ASS 文件
ass_path = os.path.join(style_dir, "subtitles.ass")
ok = generate_ass_file_for_style(args.srt, ass_path, style)
if ok:
# 烧录字幕到视频
output_video = os.path.join(style_dir, os.path.basename(args.video))
burn_subtitles_with_ffmpeg(args.video, ass_path, output_video)
# 抽取样本帧
extract_sample_frames(output_video, style_dir, args.num_samples)
# 生成对比预览图
comparison_preview = os.path.join(args.output_dir, "comparison.png")
generate_comparison_preview(style_dirs, comparison_preview)
elif args.batch:
# 批量处理
if not args.srt_dir:
print("[ERROR] 批量处理需要指定 --srt-dir")
sys.exit(1)
for video_file in os.listdir(args.batch):
if not video_file.lower().endswith((".mp4", ".mov", ".avi")):
continue
video_path = os.path.join(args.batch, video_file)
# 查找对应的 SRT 文件
srt_file = video_file.replace(".mp4", ".srt").replace(".mov", ".srt").replace(".avi", ".srt")
srt_path = os.path.join(args.srt_dir, srt_file)
if not os.path.isfile(srt_path):
print(f"[WARN] 未找到对应的 SRT 文件:{srt_file}")
continue
print(f"\n[INFO] 批量处理:{video_file}")
# 为每个样式生成字幕和视频
video_output_dir = os.path.join(args.output_dir, os.path.splitext(video_file)[0])
for i, (style, style_name) in enumerate(zip(styles, style_names)):
print(f"\n[INFO] 处理样式 {i+1}/{len(styles)}{style_name}")
style_dir = os.path.join(video_output_dir, style_name)
os.makedirs(style_dir, exist_ok=True)
# 生成 ASS 文件
ass_path = os.path.join(style_dir, "subtitles.ass")
ok = generate_ass_file_for_style(srt_path, ass_path, style)
if ok:
# 烧录字幕到视频
output_video = os.path.join(style_dir, video_file)
burn_subtitles_with_ffmpeg(video_path, ass_path, output_video)
# 抽取样本帧
extract_sample_frames(output_video, style_dir, args.num_samples)
print("\n[OK] A/B 测试完成")
if __name__ == "__main__":
main()