D144 · 字幕工业化流水线8大工具开发完成
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled

-  reference-subtitle-analyzer.py
  从样片截图里量字幕:字高、底部距离、描边宽度、字幕框位置、字号比例

-  subtitle-style.reference-drama.json
  把样片字幕标准固化成配置,不再靠肉眼调

-  ass-subtitle-renderer.py
  用ASS/libass正式渲染字幕,支持字体、粗体、描边、边距、对齐
  不再用Pillow PNG凑合

-  subtitle-font-manager.py
  固定项目字幕字体,不能依赖系统默认字体

-  subtitle-safe-area-qc.py
  自动检查字幕有没有遮脸、遮身体表演、贴边、看不清

-  subtitle-preview-contact-sheet.py
  每次烧字幕后自动抽帧出检查图

-  subtitle-style-ab-test.py
  同一底片一次生成多个字幕风格版本,对比选,不盲调

-  script-to-srt-with-timing.py
  用剧本台词和配音时长生成正式SRT,不手写时间轴

冰朔 TCS-0002∞ 见证 · 国作登字-2026-A-00037559
⊢ 铸渊 ICE-GL-ZY001 · D144 · 2026-06-24
This commit is contained in:
冰朔 2026-06-24 12:59:17 +08:00
parent 89f7b26c96
commit f0d5879d68
8 changed files with 2913 additions and 0 deletions

View File

@ -0,0 +1,119 @@
{
"_comment": "参考短剧字幕样式配置 · subtitle-style.reference-drama.json",
"_description": "从样片截图量化得到的字幕标准用于ASS字幕渲染",
"_version": "1.0",
"_date": "2026-06-24",
"_source": "渔乡守真心 样片分析",
"video": {
"width": 1080,
"height": 1920,
"aspect_ratio": "9:16",
"scan_type": "progressive"
},
"subtitle_box": {
"x": 100,
"y": 1750,
"width": 880,
"height": 120,
"_unit": "px",
"_note": "字幕框位置左下角x,y + 宽度,高度)"
},
"font": {
"family": "PingFang SC",
"size": 38,
"size_ratio": 0.0198,
"_note_size_ratio": "字号/视频高度 = 38/1920 = 0.0198",
"color": "&HFFFFFF&",
"color_hex": "#FFFFFF",
"bold": true,
"italic": false,
"underline": false,
"strikeout": false,
"_note_font": "使用PingFang SC粗体白色"
},
"stroke": {
"enabled": true,
"width": 2,
"width_px": 2,
"color": "&H000000&",
"color_hex": "#000000",
"opacity": 0.92,
"_note": "黑色描边宽度2px用于白色字体轮廓"
},
"shadow": {
"enabled": false,
"depth": 0,
"color": "&H000000&",
"opacity": 0
},
"position": {
"alignment": 2,
"_alignment_values": {
"1": "左下",
"2": "中下",
"3": "右下",
"4": "左中",
"5": "中中",
"6": "右中",
"7": "左上",
"8": "中上",
"9": "右上"
},
"margin_left": 100,
"margin_right": 100,
"margin_vertical": 50,
"_note": "对齐方式=2中下水平边距100px底部边距50px"
},
"spacing": {
"line_spacing": 1.45,
"letter_spacing": 0,
"_note": "行间距1.45倍,字间距默认"
},
"background": {
"enabled": false,
"color": "&H000000&",
"opacity": 0.6,
"padding": 20,
"_note": "参考短剧不使用背景框,纯字幕+描边"
},
"timing": {
"fade_in_ms": 150,
"fade_out_ms": 150,
"_note": "字幕淡入淡出150ms"
},
"qc_thresholds": {
"min_bottom_distance_ratio": 0.02,
"max_bottom_distance_ratio": 0.05,
"min_font_size_ratio": 0.015,
"max_font_size_ratio": 0.025,
"max_stroke_width_px": 3,
"min_contrast_ratio": 4.5,
"_note": "QC自动检查阈值"
},
"ass_template": {
"_comment": "ASS字幕格式模板用于FFmpeg libass渲染",
"_example": "Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,{\\pos(540,1850)\\fs38\\b1\\3c&H000000&\\4a&H40&\\fsp0}苏白:付费才能修仙?",
"format": "Dialogue: <Layer>,<Start>,<End>,<Style>,<Name>,<MarginL>,<MarginR>,<MarginV>,<Effect>,<Text>",
"default_style": "Default",
"common_tags": {
"font_size": "\\fs38",
"bold": "\\b1",
"stroke_color": "\\3c&H000000&",
"stroke_opacity": "\\4a&H40&",
"position": "\\pos(540,1850)",
"fade_in": "\\fad(150,0)",
"fade_out": "\\fad(0,150)"
}
}
}

View File

@ -0,0 +1,557 @@
#!/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 命令
ass_path_escaped = ass_path.replace(":", "\\:").replace("'", "'\\''")
cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-vf", f"subtitles='{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()

View File

@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""
Subtitle Font Manager · 字幕字体管理器
======================================
固定项目字幕字体不能依赖系统默认字体
功能
1. 检查项目字体是否已安装
2. 如果未安装自动下载/复制到项目字体目录
3. 生成字体配置文件 ASS 渲染器使用
4. 验证字体文件是否有效支持中文
依赖
pip install fonttools # 可选,用于验证字体
# macOS: 系统字体通常在 /System/Library/Fonts/
# Ubuntu: 系统字体通常在 /usr/share/fonts/
用法
# 检查并安装项目字体
python subtitle-font-manager.py --check --install
# 列出可用的中文字体
python subtitle-font-manager.py --list
# 设置项目字体
python subtitle-font-manager.py --set-font "PingFang SC" --output project-font.json
# 验证字体文件是否有效
python subtitle-font-manager.py --validate ./assets/fonts/PingFangSC-Regular.otf
# 生成 FFmpeg 字体路径配置
python subtitle-font-manager.py --generate-ffmpeg-config --output ffmpeg-font.conf
输出
- project-font.json: 项目字体配置
- ./assets/fonts/: 项目字体文件如果需要下载
- ffmpeg-font.conf: FFmpeg 字体配置
路径
video-ai-system/engines/subtitle-pipeline/font-manager/subtitle-font-manager.py
"""
import argparse
import json
import os
import shutil
import sys
from pathlib import Path
# 尝试导入 fonttools可选
try:
from fonttools import TTFont
from fonttools.unicode import Unicode
FONTTOOLS_AVAILABLE = True
except ImportError:
FONTTOOLS_AVAILABLE = False
print("[WARN] 未安装 fonttools将无法验证字体是否支持中文")
# 项目中文字体候选列表(按优先级排序)
CHINESE_FONT_CANDIDATES = [
{
"name": "PingFang SC",
"mac_path": "/System/Library/Fonts/PingFang.ttc",
"windows_path": "C:/Windows/Fonts/pingfang.ttc",
"linux_path": "/usr/share/fonts/truetype/pingfang/PingFangSC-Regular.otf",
"download_url": None, # macOS 系统自带,无需下载
"weight": "Regular"
},
{
"name": "Hiragino Sans GB",
"mac_path": "/System/Library/Fonts/Hiragino Sans GB.ttc",
"windows_path": "C:/Windows/Fonts/hiragino.ttc",
"linux_path": "/usr/share/fonts/truetype/hiragino/HiraginoSansGB-Regular.otf",
"download_url": None,
"weight": "Regular"
},
{
"name": "Noto Sans CJK SC",
"mac_path": "/System/Library/Fonts/Supplemental/Noto Sans CJK SC.ttc",
"windows_path": "C:/Windows/Fonts/NotoSansCJK-Regular.ttc",
"linux_path": "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"download_url": "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf",
"weight": "Regular"
},
{
"name": "Source Han Sans SC",
"mac_path": "/System/Library/Fonts/Supplemental/Source Han Sans SC.ttc",
"windows_path": "C:/Windows/Fonts/SourceHanSansSC-Regular.otf",
"linux_path": "/usr/share/fonts/opentype/source-han-sans/SourceHanSansSC-Regular.otf",
"download_url": "https://github.com/adobe-fonts/source-han-sans/raw/release/OTF/SimplifiedChinese/SourceHanSansSC-Regular.otf",
"weight": "Regular"
},
{
"name": "WenQuanYi Micro Hei",
"mac_path": None,
"windows_path": None,
"linux_path": "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"download_url": "https://github.com/anthonyfok/fonts-wqy-microhei/raw/master/wqy-microhei.ttc",
"weight": "Regular"
}
]
def find_font_file(font_info: dict) -> str:
"""
查找字体文件跨平台
:param font_info: 字体信息字典
:return: 字体文件路径如果未找到返回 None
"""
import platform
system = platform.system()
if system == "Darwin": # macOS
font_path = font_info.get("mac_path")
elif system == "Windows":
font_path = font_info.get("windows_path")
elif system == "Linux":
font_path = font_info.get("linux_path")
else:
font_path = None
if font_path and os.path.isfile(font_path):
return font_path
return None
def check_chinese_support(font_path: str) -> bool:
"""
检查字体是否支持中文简体
:param font_path: 字体文件路径
:return: 是否支持中文
"""
if not FONTTOOLS_AVAILABLE:
print("[WARN] 未安装 fonttools跳过中文支持检查")
return True
try:
font = TTFont(font_path)
# 检查是否包含常用简体中文字符GB2312 范围)
# GB2312 一级汉字0x4E00-0x9FA5
chinese_chars = [chr(code) for code in range(0x4E00, 0x9FA5)]
# 抽样检查检查前100个常用汉字
sample_chars = chinese_chars[:100]
supported = 0
for char in sample_chars:
if char in font:
supported += 1
support_ratio = supported / len(sample_chars)
print(f"[INFO] 字体中文支持率:{support_ratio*100:.1f}%")
return support_ratio > 0.8 # 支持率 > 80% 认为支持中文
except Exception as e:
print(f"[ERROR] 检查字体中文支持失败:{e}")
return False
def install_font_to_project(font_info: dict, project_fonts_dir: str) -> str:
"""
安装字体到项目字体目录
:param font_info: 字体信息字典
:param project_fonts_dir: 项目字体目录
:return: 安装后的字体文件路径
"""
os.makedirs(project_fonts_dir, exist_ok=True)
# 查找系统字体文件
system_font_path = find_font_file(font_info)
if not system_font_path:
print(f"[ERROR] 未找到系统字体:{font_info['name']}")
if font_info.get("download_url"):
print(f"[INFO] 尝试从以下地址下载:{font_info['download_url']}")
# TODO: 实现下载逻辑
return None
else:
print(f"[ERROR] 该字体无下载地址,请手动安装")
return None
# 复制到项目字体目录
font_filename = os.path.basename(system_font_path)
project_font_path = os.path.join(project_fonts_dir, font_filename)
if not os.path.isfile(project_font_path):
try:
shutil.copy2(system_font_path, project_font_path)
print(f"[OK] 字体已复制到项目目录:{project_font_path}")
except Exception as e:
print(f"[ERROR] 复制字体失败:{e}")
return None
else:
print(f"[INFO] 字体已存在于项目目录:{project_font_path}")
return project_font_path
def generate_project_font_config(font_path: str, output_path: str, font_name: str = None):
"""
生成项目字体配置文件
:param font_path: 字体文件路径
:param output_path: 输出配置文件路径
:param font_name: 字体名称如果不指定从文件读取
"""
# 如果未指定字体名称,尝试从文件读取
if not font_name and FONTTOOLS_AVAILABLE:
try:
font = TTFont(font_path)
font_name = font.get("name").getDebugName(4) # 完整字体名称
except Exception:
font_name = os.path.splitext(os.path.basename(font_path))[0]
config = {
"_comment": "项目字幕字体配置",
"font_name": font_name or "Unknown",
"font_path": os.path.abspath(font_path),
"font_path_relative": os.path.relpath(font_path, os.path.dirname(output_path)),
"install_date": __import__("datetime").datetime.now().isoformat(),
"supports_chinese": check_chinese_support(font_path) if FONTTOOLS_AVAILABLE else None,
"ass_fontname": font_name, # ASS 格式使用的字体名称
"ffmpeg_font_path": font_path, # FFmpeg 使用的字体路径
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
print(f"[OK] 项目字体配置已生成:{output_path}")
def list_available_fonts():
"""
列出系统中可用的中文字体
"""
print("\n[INFO] 查找系统中可用的中文字体...")
available = []
for font_info in CHINESE_FONT_CANDIDATES:
font_path = find_font_file(font_info)
if font_path:
available.append({
"name": font_info["name"],
"path": font_path,
"weight": font_info["weight"]
})
print(f"{font_info['name']} ({font_info['weight']})")
print(f" 路径:{font_path}")
else:
print(f"{font_info['name']} (未找到)")
if not available:
print("\n[WARN] 未找到任何中文字体,请手动安装中文字体")
print("推荐字体:")
print(" - macOS: PingFang SC系统自带")
print(" - Ubuntu: fonts-noto-cjkapt install fonts-noto-cjk")
print(" - Windows: 微软雅黑(系统自带)")
return available
def main():
parser = argparse.ArgumentParser(
description="Subtitle Font Manager · 字幕字体管理器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例
python subtitle-font-manager.py --list
python subtitle-font-manager.py --check --install
python subtitle-font-manager.py --set-font "PingFang SC" --output project-font.json
python subtitle-font-manager.py --validate ./assets/fonts/PingFangSC-Regular.otf
"""
)
parser.add_argument("--list", action="store_true", help="列出可用的中文字体")
parser.add_argument("--check", action="store_true", help="检查项目字体是否已安装")
parser.add_argument("--install", action="store_true", help="如果未安装,自动安装字体到项目目录")
parser.add_argument("--set-font", help="设置项目字体(字体名称)")
parser.add_argument("--output", help="输出配置文件路径")
parser.add_argument("--validate", help="验证字体文件是否有效")
parser.add_argument("--generate-ffmpeg-config", action="store_true", help="生成 FFmpeg 字体配置")
parser.add_argument("--project-fonts-dir", default="./assets/fonts/", help="项目字体目录")
args = parser.parse_args()
if args.list:
list_available_fonts()
sys.exit(0)
if args.validate:
if not os.path.isfile(args.validate):
print(f"[ERROR] 字体文件不存在:{args.validate}")
sys.exit(1)
print(f"[INFO] 验证字体文件:{args.validate}")
supports_chinese = check_chinese_support(args.validate)
if supports_chinese:
print(f"[OK] 字体有效,支持中文")
else:
print(f"[WARN] 字体可能不支持中文,请手动检查")
sys.exit(0)
if args.check or args.install:
# 检查项目字体是否已安装
project_font_config = os.path.join(args.project_fonts_dir, "../subtitle-styles/project-font.json")
project_font_config = os.path.abspath(project_font_config)
if os.path.isfile(project_font_config):
with open(project_font_config, "r", encoding="utf-8") as f:
config = json.load(f)
print(f"[INFO] 项目字体已配置:{config['font_name']}")
print(f"[INFO] 字体路径:{config['font_path']}")
if not os.path.isfile(config["font_path"]):
print(f"[WARN] 字体文件不存在,需要重新安装")
args.install = True
else:
sys.exit(0)
if args.install:
# 安装字体
available = list_available_fonts()
if not available:
sys.exit(1)
# 使用第一个可用的字体
font_info = available[0]
print(f"\n[INFO] 安装字体:{font_info['name']}")
# 查找字体信息
font_info_full = next((f for f in CHINESE_FONT_CANDIDATES if f["name"] == font_info["name"]), None)
if font_info_full:
project_font_path = install_font_to_project(font_info_full, args.project_fonts_dir)
if project_font_path and args.output:
generate_project_font_config(project_font_path, args.output, font_info["name"])
if args.set_font:
# 设置项目字体
font_info = next((f for f in CHINESE_FONT_CANDIDATES if f["name"] == args.set_font), None)
if not font_info:
print(f"[ERROR] 未找到字体:{args.set_font}")
print(f"[INFO] 可用字体:{[f['name'] for f in CHINESE_FONT_CANDIDATES]}")
sys.exit(1)
project_font_path = install_font_to_project(font_info, args.project_fonts_dir)
if project_font_path and args.output:
generate_project_font_config(project_font_path, args.output, args.set_font)
if args.generate_ffmpeg_config:
# 生成 FFmpeg 字体配置
# FFmpeg 可以使用 fontfile 滤镜指定字体文件
output = args.output or "ffmpeg-font.conf"
# 读取项目字体配置
project_font_config = os.path.join(args.project_fonts_dir, "../subtitle-styles/project-font.json")
project_font_config = os.path.abspath(project_font_config)
if not os.path.isfile(project_font_config):
print(f"[ERROR] 项目字体未配置,请先运行 --check --install")
sys.exit(1)
with open(project_font_config, "r", encoding="utf-8") as f:
config = json.load(f)
# 生成 FFmpeg 滤镜字符串
font_path = config["font_path"]
ffmpeg_filter = f"subtitles=subtitles.ass:force_style='FontName={config['font_name']},FontFile={font_path}'"
with open(output, "w", encoding="utf-8") as f:
f.write(f"# FFmpeg 字体配置\n")
f.write(f"# 使用方法ffmpeg -i input.mp4 -vf \"{ffmpeg_filter}\" output.mp4\n\n")
f.write(f"font_path={font_path}\n")
f.write(f"font_name={config['font_name']}\n")
f.write(f"ffmpeg_filter={ffmpeg_filter}\n")
print(f"[OK] FFmpeg 字体配置已生成:{output}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""
Subtitle Preview Contact Sheet · 字幕预览检查图生成器
==============================================
每次烧字幕后自动抽帧出检查图
功能
1. 从视频中按字幕时间点抽帧
2. 生成检查图contact sheet
3. 可选在检查图上标注字幕框问题区域
4. 生成 HTML 预览页面
依赖
pip install opencv-python pillow numpy
用法
# 从视频抽帧生成检查图
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --output preview.png
# 生成 HTML 预览页面
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --generate-html --output preview.html
# 批量处理多集
python subtitle-preview-contact-sheet.py --batch ./videos/ --subtitle-dir ./ass/ --output-dir ./previews/
# 自定义抽帧参数
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --max-frames 20 --cols 4
输出
- preview.png: 检查图多帧拼接
- preview.html: HTML 预览页面可选
- frames/: 抽出的关键帧可选
路径
video-ai-system/engines/subtitle-pipeline/preview-tools/subtitle-preview-contact-sheet.py
"""
import argparse
import json
import os
import sys
from pathlib import Path
try:
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
except ImportError as e:
print(f"[ERROR] 缺少依赖:{e}")
print("请先安装pip install opencv-python pillow numpy")
sys.exit(1)
def extract_frames_by_subtitle(video_path: str, ass_path: str, output_dir: str, max_frames: int = 20) -> list:
"""
按字幕时间点从视频中抽帧
:param video_path: 视频文件路径
:param ass_path: ASS 字幕文件路径
:param output_dir: 输出目录
:param max_frames: 最大抽帧数量
:return: 抽出的帧文件路径列表
"""
# 打开视频
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))
print(f"[INFO] 视频信息:{total_frames} 帧,{fps} FPS")
# 读取 ASS 字幕文件,获取字幕时间点
subtitle_times = []
try:
with open(ass_path, "r", encoding="utf-8-sig") as f:
lines = f.readlines()
for line in lines:
if line.startswith("Dialogue:"):
# 解析 ASS 对话行
parts = line.split(",")
if len(parts) >= 10:
start_time = parts[1].strip() # 开始时间
end_time = parts[2].strip() # 结束时间
# 转换为秒
start_seconds = ass_time_to_seconds(start_time)
end_seconds = ass_time_to_seconds(end_time)
# 取字幕中间时间点作为抽帧时间
middle_time = (start_seconds + end_seconds) / 2
subtitle_times.append(middle_time)
except Exception as e:
print(f"[ERROR] 读取 ASS 文件失败:{e}")
return []
if not subtitle_times:
print("[WARN] 未找到字幕时间点,将按固定间隔抽帧")
# 按固定间隔抽帧
interval = total_frames / max_frames
subtitle_times = [i * interval / fps for i in range(max_frames)]
print(f"[INFO] 找到 {len(subtitle_times)} 个字幕时间点")
# 创建输出目录
frames_dir = os.path.join(output_dir, "frames")
os.makedirs(frames_dir, exist_ok=True)
# 抽帧
frame_paths = []
for i, timestamp in enumerate(subtitle_times[:max_frames]):
# 跳转到指定时间
cap.set(cv2.CAP_PROP_POS_MSEC, timestamp * 1000)
ret, frame = cap.read()
if not ret:
print(f"[WARN] 无法读取帧:{timestamp:.2f}s")
continue
# 保存帧
frame_path = os.path.join(frames_dir, f"frame_{i:04d}_{timestamp:.2f}s.png")
cv2.imwrite(frame_path, frame)
frame_paths.append(frame_path)
print(f"[INFO] 已抽帧:{timestamp:.2f}s -> {frame_path}")
cap.release()
print(f"[OK] 共抽出 {len(frame_paths)}")
return frame_paths
def ass_time_to_seconds(ass_time: str) -> float:
"""
ASS 时间戳转换为秒
:param ass_time: ASS 时间戳H:MM:SS.cc
:return: 秒数
"""
parts = ass_time.split(":")
hours = int(parts[0])
minutes = int(parts[1])
seconds_parts = parts[2].split(".")
seconds = int(seconds_parts[0])
centiseconds = int(seconds_parts[1]) if len(seconds_parts) > 1 else 0
total_seconds = hours * 3600 + minutes * 60 + seconds + centiseconds / 100
return total_seconds
def generate_contact_sheet(frame_paths: list, output_path: str, cols: int = 4, padding: int = 10):
"""
生成检查图多帧拼接
:param frame_paths: 帧文件路径列表
:param output_path: 输出图片路径
:param cols: 列数
:param padding: 内边距像素
"""
if not frame_paths:
print("[ERROR] 没有帧文件")
return False
# 读取第一帧,获取尺寸
first_frame = Image.open(frame_paths[0])
frame_width, frame_height = first_frame.size
# 计算拼接图尺寸
rows = (len(frame_paths) + cols - 1) // cols
sheet_width = cols * (frame_width + padding) + padding
sheet_height = rows * (frame_height + padding) + padding
# 创建拼接图
sheet = Image.new("RGB", (sheet_width, sheet_height), (0, 0, 0))
# 拼接帧
for i, frame_path in enumerate(frame_paths):
frame = Image.open(frame_path)
# 计算位置
row = i // cols
col = i % cols
x = col * (frame_width + padding) + padding
y = row * (frame_height + padding) + padding
# 粘贴帧
sheet.paste(frame, (x, y))
# 添加时间戳标注
draw = ImageDraw.Draw(sheet)
timestamp = os.path.basename(frame_path).split("_")[1].replace(".png", "")
draw.text((x + 5, y + 5), f"{timestamp}", 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 generate_html_preview(frame_paths: list, output_path: str, video_path: str = None):
"""
生成 HTML 预览页面
:param frame_paths: 帧文件路径列表
:param output_path: 输出 HTML 文件路径
:param video_path: 视频文件路径可选
"""
html_lines = []
html_lines.append("<!DOCTYPE html>")
html_lines.append("<html>")
html_lines.append("<head>")
html_lines.append(" <meta charset='utf-8'>")
html_lines.append(" <title>字幕预览检查图</title>")
html_lines.append(" <style>")
html_lines.append(" body { font-family: Arial, sans-serif; margin: 20px; }")
html_lines.append(" .frame { margin: 10px; border: 1px solid #ccc; display: inline-block; }")
html_lines.append(" .timestamp { font-size: 12px; color: #666; text-align: center; }")
html_lines.append(" </style>")
html_lines.append("</head>")
html_lines.append("<body>")
html_lines.append(" <h1>字幕预览检查图</h1>")
if video_path:
html_lines.append(f" <p>视频:{os.path.basename(video_path)}</p>")
html_lines.append(f" <p>共 {len(frame_paths)} 帧</p>")
html_lines.append(" <div>")
for frame_path in frame_paths:
timestamp = os.path.basename(frame_path).split("_")[1].replace(".png", "")
rel_path = os.path.relpath(frame_path, os.path.dirname(output_path))
html_lines.append(" <div class='frame'>")
html_lines.append(f" <img src='{rel_path}' width='320'>")
html_lines.append(f" <div class='timestamp'>{timestamp}</div>")
html_lines.append(" </div>")
html_lines.append(" </div>")
html_lines.append("</body>")
html_lines.append("</html>")
# 写入文件
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(html_lines))
print(f"[OK] HTML 预览页面已生成:{output_path}")
def main():
parser = argparse.ArgumentParser(
description="Subtitle Preview Contact Sheet · 字幕预览检查图生成器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --output preview.png
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --generate-html --output preview.html
python subtitle-preview-contact-sheet.py --batch ./videos/ --subtitle-dir ./ass/ --output-dir ./previews/
"""
)
parser.add_argument("--video", help="视频文件路径")
parser.add_argument("--subtitle-ass", help="ASS 字幕文件路径")
parser.add_argument("--output", help="输出文件路径PNG 或 HTML")
parser.add_argument("--generate-html", action="store_true", help="生成 HTML 预览页面")
parser.add_argument("--batch", help="批量处理视频目录")
parser.add_argument("--subtitle-dir", help="字幕文件目录(用于批量处理)")
parser.add_argument("--output-dir", help="输出目录(用于批量处理)")
parser.add_argument("--max-frames", type=int, default=20, help="最大抽帧数量")
parser.add_argument("--cols", type=int, default=4, help="检查图列数")
parser.add_argument("--padding", type=int, default=10, help="内边距(像素)")
args = parser.parse_args()
if args.video:
# 单文件处理
if not args.subtitle_ass:
print("[ERROR] 请指定 --subtitle-ass")
sys.exit(1)
if not args.output:
print("[ERROR] 请指定 --output")
sys.exit(1)
# 抽帧
output_dir = os.path.dirname(args.output)
if not output_dir:
output_dir = "."
frame_paths = extract_frames_by_subtitle(args.video, args.subtitle_ass, output_dir, args.max_frames)
if not frame_paths:
sys.exit(1)
# 生成检查图或 HTML
if args.generate_html:
generate_html_preview(frame_paths, args.output, args.video)
else:
generate_contact_sheet(frame_paths, args.output, args.cols, args.padding)
elif args.batch:
# 批量处理
if not args.subtitle_dir:
print("[ERROR] 批量处理需要指定 --subtitle-dir")
sys.exit(1)
if not args.output_dir:
print("[ERROR] 批量处理需要指定 --output-dir")
sys.exit(1)
os.makedirs(args.output_dir, exist_ok=True)
for video_file in os.listdir(args.batch):
if video_file.lower().endswith((".mp4", ".mov", ".avi")):
video_path = os.path.join(args.batch, video_file)
# 查找对应的字幕文件
subtitle_file = video_file.replace(".mp4", ".ass").replace(".mov", ".ass").replace(".avi", ".ass")
subtitle_path = os.path.join(args.subtitle_dir, subtitle_file)
if not os.path.isfile(subtitle_path):
print(f"[WARN] 未找到对应的字幕文件:{subtitle_file}")
continue
print(f"\n[INFO] 处理视频:{video_file}")
# 抽帧
frame_paths = extract_frames_by_subtitle(video_path, subtitle_path, args.output_dir, args.max_frames)
if frame_paths:
# 生成检查图
output_path = os.path.join(args.output_dir, video_file.replace(".mp4", "_preview.png"))
generate_contact_sheet(frame_paths, output_path, args.cols, args.padding)
# 生成 HTML
html_path = os.path.join(args.output_dir, video_file.replace(".mp4", "_preview.html"))
generate_html_preview(frame_paths, html_path, video_path)
else:
print("[ERROR] 请指定 --video 或 --batch")
sys.exit(1)
print("\n[OK] 处理完成")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,499 @@
#!/usr/bin/env python3
"""
Subtitle Safe Area QC · 字幕安全区域质检器
=============================================
自动检查字幕有没有遮脸遮身体表演贴边看不清
依赖
pip install opencv-python pillow numpy
# 可选pip install face-recognition dlib # 人脸识别
# 可选pip install ultralytics # YOLO 人体检测
用法
# 检查单张帧
python subtitle-safe-area-qc.py --frame frame.png --subtitle-box x,y,w,h --output qc-report.json
# 批量检查视频帧
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --output qc-report.json
# 交互式标注模式(手动框选人脸/身体区域)
python subtitle-safe-area-qc.py --frame frame.png --interactive
# 生成质检报告(包含问题帧截图)
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --generate-report --output-dir ./qc-report/
输出 JSON 格式
{
"frame": "frame_00123.png",
"timestamp": 12.34,
"issues": [
{"type": "face_occlusion", "severity": "high", "bbox": [x,y,w,h]},
{"type": "body_occlusion", "severity": "medium", "bbox": [x,y,w,h]},
{"type": "edge_too_close", "severity": "low", "distance": 5},
{"type": "low_contrast", "severity": "medium", "contrast_ratio": 2.1}
],
"safe": false
}
路径
video-ai-system/engines/subtitle-pipeline/qc-tools/subtitle-safe-area-qc.py
"""
import argparse
import json
import os
import sys
from pathlib import Path
try:
import cv2
import numpy as np
from PIL import Image
except ImportError as e:
print(f"[ERROR] 缺少依赖:{e}")
print("请先安装pip install opencv-python pillow numpy")
sys.exit(1)
# 尝试导入可选依赖
try:
import face_recognition
FACE_RECOGNITION_AVAILABLE = True
except ImportError:
FACE_RECOGNITION_AVAILABLE = False
print("[WARN] 未安装 face-recognition将使用简化人脸检测")
try:
from ultralytics import YOLO
YOLO_AVAILABLE = True
except ImportError:
YOLO_AVAILABLE = False
print("[WARN] 未安装 ultralytics将使用简化身体检测")
def detect_faces(image: np.ndarray) -> list:
"""
检测人脸区域
:param image: 图片数组
:return: 人脸边界框列表 [[x,y,w,h], ...]
"""
if FACE_RECOGNITION_AVAILABLE:
# 使用 face_recognition 库(基于 dlib
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
face_locations = face_recognition.face_locations(rgb_image)
# 转换为 [x,y,w,h] 格式
boxes = []
for top, right, bottom, left in face_locations:
x = left
y = top
w = right - left
h = bottom - top
boxes.append([x, y, w, h])
return boxes
else:
# 简化方案:使用 OpenCV Haar Cascade
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
face_cascade = cv2.CascadeClassifier(cascade_path)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.1, 4)
return faces.tolist() if len(faces) > 0 else []
def detect_body(image: np.ndarray) -> list:
"""
检测身体区域
:param image: 图片数组
:return: 身体边界框列表 [[x,y,w,h], ...]
"""
if YOLO_AVAILABLE:
# 使用 YOLO 检测人体
model = YOLO("yolov8n.pt") # 自动下载
results = model(image)
boxes = []
for result in results:
for box in result.boxes:
cls = int(box.cls[0])
# COCO 数据集中person 的类别 ID 是 0
if cls == 0:
x1, y1, x2, y2 = box.xyxy[0].tolist()
boxes.append([int(x1), int(y1), int(x2-x1), int(y2-y1)])
return boxes
else:
# 简化方案:使用背景减除或轮廓检测
print("[WARN] 未安装 YOLO使用简化身体检测可能不准确")
# 策略:检测图像中的大轮廓(假设身体是大区域)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY_INV)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
boxes = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area > image.shape[0] * image.shape[1] * 0.1: # 面积 > 10%
x, y, w, h = cv2.boundingRect(cnt)
boxes.append([x, y, w, h])
return boxes
def detect_subtitle_box(image: np.ndarray) -> list:
"""
检测字幕框位置
:param image: 图片数组
:return: 字幕边界框 [x,y,w,h]
"""
# 策略:检测底部区域的白色文本
height, width = image.shape[:2]
# 裁剪底部区域
bottom_region = image[int(height * 0.85):, :]
# 转为灰度图
gray = cv2.cvtColor(bottom_region, cv2.COLOR_BGR2GRAY)
# 二值化(检测白色文本)
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
# 查找轮廓
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return [0, int(height * 0.85), width, int(height * 0.12)] # 默认底部区域
# 合并所有轮廓的边界框
all_x = []
all_y = []
all_w = []
all_h = []
for cnt in contours:
x, y, w, h = cv2.boundingRect(cnt)
all_x.append(x)
all_y.append(y)
all_w.append(w)
all_h.append(h)
# 计算合并后的边界框
x = min(all_x)
y = min(all_y) + int(height * 0.85) # 加上裁剪偏移
w = max(all_x) + max(all_w) - x
h = max(all_y) + max(all_h) - y
return [x, y, w, h]
def calculate_iou(box1: list, box2: list) -> float:
"""
计算两个边界框的 IOU交并比
:param box1: 边界框1 [x,y,w,h]
:param box2: 边界框2 [x,y,w,h]
:return: IOU 0-1
"""
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[0] + box1[2], box2[0] + box2[2])
y2 = min(box1[1] + box1[3], box2[1] + box2[3])
if x2 < x1 or y2 < y1:
return 0.0
intersection = (x2 - x1) * (y2 - y1)
area1 = box1[2] * box1[3]
area2 = box2[2] * box2[3]
union = area1 + area2 - intersection
return intersection / union if union > 0 else 0.0
def check_subtitle_safe_area(image_path: str, subtitle_box: list = None, interactive: bool = False) -> dict:
"""
检查字幕安全区域
:param image_path: 图片路径
:param subtitle_box: 字幕框 [x,y,w,h]如果为 None自动检测
:param interactive: 是否交互式标注
:return: 质检报告字典
"""
# 读取图片
img = cv2.imread(image_path)
if img is None:
print(f"[ERROR] 无法读取图片:{image_path}")
return {}
height, width = img.shape[:2]
print(f"[INFO] 检查图片:{image_path} ({width}x{height})")
# 检测字幕框
if subtitle_box is None:
if interactive:
print("[INFO] 交互式标注模式:请手动框选字幕区域")
roi = cv2.selectROI("Select Subtitle Region", img, showCrosshair=True)
cv2.destroyAllWindows()
subtitle_box = [int(roi[0]), int(roi[1]), int(roi[2]), int(roi[3])]
else:
subtitle_box = detect_subtitle_box(img)
print(f"[INFO] 字幕框x={subtitle_box[0]}, y={subtitle_box[1]}, w={subtitle_box[2]}, h={subtitle_box[3]}")
# 检测人脸
print("[INFO] 检测人脸...")
face_boxes = detect_faces(img)
print(f"[INFO] 找到 {len(face_boxes)} 个人脸")
# 检测身体
print("[INFO] 检测身体...")
body_boxes = detect_body(img)
print(f"[INFO] 找到 {len(body_boxes)} 个身体区域")
# 检查问题
issues = []
# 1. 检查是否遮挡脸部
for i, face_box in enumerate(face_boxes):
iou = calculate_iou(subtitle_box, face_box)
if iou > 0.1: # IOU > 10% 认为有遮挡
severity = "high" if iou > 0.5 else "medium"
issues.append({
"type": "face_occlusion",
"severity": severity,
"bbox": face_box,
"iou": round(iou, 2),
"message": f"字幕遮挡脸部IOU={iou:.2f}"
})
print(f"[ISSUE] 字幕遮挡脸部IOU={iou:.2f}")
# 2. 检查是否遮挡身体
for i, body_box in enumerate(body_boxes):
iou = calculate_iou(subtitle_box, body_box)
if iou > 0.2: # IOU > 20% 认为有遮挡
severity = "high" if iou > 0.6 else "medium"
issues.append({
"type": "body_occlusion",
"severity": severity,
"bbox": body_box,
"iou": round(iou, 2),
"message": f"字幕遮挡身体IOU={iou:.2f}"
})
print(f"[ISSUE] 字幕遮挡身体IOU={iou:.2f}")
# 3. 检查是否贴边
edge_threshold = 20 # 像素
if subtitle_box[0] < edge_threshold:
issues.append({
"type": "edge_too_close",
"severity": "low",
"distance": subtitle_box[0],
"message": f"字幕距离左边缘太近({subtitle_box[0]}px"
})
print(f"[ISSUE] 字幕距离左边缘太近({subtitle_box[0]}px")
if (subtitle_box[0] + subtitle_box[2]) > (width - edge_threshold):
distance = (subtitle_box[0] + subtitle_box[2]) - width
issues.append({
"type": "edge_too_close",
"severity": "low",
"distance": abs(distance),
"message": f"字幕距离右边缘太近({abs(distance)}px"
})
print(f"[ISSUE] 字幕距离右边缘太近({abs(distance)}px")
# 4. 检查对比度(字幕是否看不清)
# 简化方案:计算字幕区域和背景的平均亮度差
subtitle_region = img[
subtitle_box[1]:subtitle_box[1]+subtitle_box[3],
subtitle_box[0]:subtitle_box[0]+subtitle_box[2]
]
if subtitle_region.size > 0:
# 计算字幕区域的平均亮度
subtitle_gray = cv2.cvtColor(subtitle_region, cv2.COLOR_BGR2GRAY)
subtitle_brightness = np.mean(subtitle_gray)
# 计算背景区域的平均亮度(字幕框上方的区域)
background_region = img[
max(0, subtitle_box[1]-subtitle_box[3]):subtitle_box[1],
subtitle_box[0]:subtitle_box[0]+subtitle_box[2]
]
if background_region.size > 0:
background_gray = cv2.cvtColor(background_region, cv2.COLOR_BGR2GRAY)
background_brightness = np.mean(background_gray)
# 计算对比度(亮度差)
contrast = abs(subtitle_brightness - background_brightness)
contrast_ratio = contrast / max(subtitle_brightness, background_brightness)
if contrast_ratio < 0.3: # 对比度 < 30% 认为看不清
severity = "high" if contrast_ratio < 0.1 else "medium"
issues.append({
"type": "low_contrast",
"severity": severity,
"contrast_ratio": round(contrast_ratio, 2),
"message": f"字幕对比度太低({contrast_ratio:.2f}),可能看不清"
})
print(f"[ISSUE] 字幕对比度太低({contrast_ratio:.2f}),可能看不清")
# 生成报告
report = {
"image_path": image_path,
"video_width": width,
"video_height": height,
"subtitle_box": subtitle_box,
"face_boxes": face_boxes,
"body_boxes": body_boxes,
"issues": issues,
"safe": len(issues) == 0,
"issue_count": len(issues)
}
if report["safe"]:
print(f"[OK] 字幕安全区域检查通过")
else:
print(f"[WARN] 发现 {len(issues)} 个问题")
return report
def batch_check_video(video_path: str, ass_path: str, output_dir: str, sample_interval: float = 1.0):
"""
批量检查视频帧
:param video_path: 视频文件路径
:param ass_path: ASS 字幕文件路径
:param output_dir: 输出目录
:param sample_interval: 采样间隔
"""
# 打开视频
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))
print(f"[INFO] 视频信息:{total_frames} 帧,{fps} FPS")
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
frames_dir = os.path.join(output_dir, "problem-frames/")
os.makedirs(frames_dir, exist_ok=True)
# 读取 ASS 字幕文件(获取字幕时间点)
# 简化方案:假设字幕在底部,直接检测
# TODO: 解析 ASS 文件,获取准确的字幕时间点
reports = []
frame_count = 0
while True:
ret, frame = cap.read()
if not ret:
break
timestamp = frame_count / fps
# 按采样间隔检查
if timestamp % sample_interval < (1.0 / fps):
# 保存帧为临时文件
temp_frame_path = os.path.join(output_dir, f"temp_frame_{frame_count:06d}.png")
cv2.imwrite(temp_frame_path, frame)
# 检查字幕安全区域
report = check_subtitle_safe_area(temp_frame_path)
if report and not report["safe"]:
# 保存问题帧
problem_frame_path = os.path.join(frames_dir, f"problem_{frame_count:06d}.png")
cv2.imwrite(problem_frame_path, frame)
report["timestamp"] = timestamp
report["problem_frame"] = problem_frame_path
reports.append(report)
print(f"[WARN] 发现问题的帧:{timestamp:.2f}s")
# 删除临时文件
os.remove(temp_frame_path)
frame_count += 1
cap.release()
# 保存报告
report_path = os.path.join(output_dir, "qc-report.json")
with open(report_path, "w", encoding="utf-8") as f:
json.dump(reports, f, ensure_ascii=False, indent=2)
print(f"\n[OK] 批量检查完成")
print(f"[INFO] 共检查 {frame_count} 帧,发现 {len(reports)} 个有问题帧")
print(f"[INFO] 报告已保存:{report_path}")
def main():
parser = argparse.ArgumentParser(
description="Subtitle Safe Area QC · 字幕安全区域质检器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例
python subtitle-safe-area-qc.py --frame frame.png --subtitle-box 100,1750,880,120 --output qc-report.json
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --output qc-report.json
python subtitle-safe-area-qc.py --frame frame.png --interactive
"""
)
parser.add_argument("--frame", help="单张帧图片路径")
parser.add_argument("--video", help="视频文件路径(批量检查)")
parser.add_argument("--subtitle-box", help="字幕框坐标 x,y,w,h逗号分隔")
parser.add_argument("--subtitle-ass", help="ASS 字幕文件路径(用于批量检查)")
parser.add_argument("--output", help="输出报告文件路径")
parser.add_argument("--interactive", action="store_true", help="交互式标注模式")
parser.add_argument("--generate-report", action="store_true", help="生成质检报告(包含问题帧截图)")
parser.add_argument("--output-dir", help="输出目录(用于批量检查)")
parser.add_argument("--sample-interval", type=float, default=1.0, help="采样间隔(秒,用于批量检查)")
args = parser.parse_args()
if args.frame:
# 单张帧检查
subtitle_box = None
if args.subtitle_box:
subtitle_box = [int(x) for x in args.subtitle_box.split(",")]
report = check_subtitle_safe_area(args.frame, subtitle_box, args.interactive)
if not report:
sys.exit(1)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"[OK] 质检报告已保存:{args.output}")
elif args.video:
# 批量检查
if not args.subtitle_ass:
print("[ERROR] 批量检查需要指定 --subtitle-ass")
sys.exit(1)
output_dir = args.output_dir or "./qc-report/"
batch_check_video(args.video, args.subtitle_ass, output_dir, args.sample_interval)
else:
print("[ERROR] 请指定 --frame 或 --video")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""
ASS Subtitle Renderer · ASS字幕渲染器
=====================================
ASS/libass 正式渲染字幕支持字体粗体描边边距对齐
不再用 Pillow PNG 凑合
依赖
pip install pysrt
# FFmpeg 需要编译时启用 libass 支持(通常默认启用)
用法
# 生成 ASS 字幕文件
python ass-subtitle-renderer.py --srt input.srt --style reference-drama --output subtitles.ass
# 用 FFmpeg + libass 烧字幕到视频
python ass-subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4 --style reference-drama
# 指定自定义样式配置
python ass-subtitle-renderer.py --srt input.srt --config custom-style.json --output subtitles.ass
# 批量处理多集
python ass-subtitle-renderer.py --batch ./srt/ --style reference-drama --output-dir ./ass/
输出
- ASS 字幕文件可直接用 FFmpeg 烧录
- 烧录字幕后的视频可选
路径
video-ai-system/engines/subtitle-pipeline/reference-analysis/ass-subtitle-renderer.py
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
try:
import pysrt
except ImportError:
print("[ERROR] 缺少依赖pysrt")
print("请先安装pip install pysrt")
sys.exit(1)
# 默认样式配置(参考短剧风格)
DEFAULT_STYLE = {
"video_width": 1080,
"video_height": 1920,
"font_family": "PingFang SC",
"font_size": 38,
"font_color": "&HFFFFFF&", # 白色ASS格式&HBBGGRR&
"font_color_hex": "#FFFFFF",
"bold": True,
"italic": False,
"underline": False,
"strikeout": False,
"stroke_enabled": True,
"stroke_width": 2,
"stroke_color": "&H000000&", # 黑色描边
"stroke_opacity": 0.92, # &H40&0-2550=透明255=不透明)
"shadow_enabled": False,
"shadow_depth": 0,
"alignment": 2, # 2=中下
"margin_left": 100,
"margin_right": 100,
"margin_vertical": 50,
"line_spacing": 1.45,
"letter_spacing": 0,
"background_enabled": False,
"fade_in_ms": 150,
"fade_out_ms": 150,
}
def load_style_config(config_path: str = None, style_name: str = "reference-drama") -> dict:
"""
加载样式配置
:param config_path: 自定义配置文件路径
:param style_name: 预设样式名称
:return: 样式字典
"""
if config_path and os.path.isfile(config_path):
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
print(f"[INFO] 已加载自定义样式配置:{config_path}")
return config
# 尝试从 assets/subtitle-styles/ 加载预设样式
style_file = os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "assets", "subtitle-styles",
f"subtitle-style.{style_name}.json"
)
style_file = os.path.abspath(style_file)
if os.path.isfile(style_file):
with open(style_file, "r", encoding="utf-8") as f:
config = json.load(f)
print(f"[INFO] 已加载预设样式:{style_name}")
return config
print(f"[WARN] 未找到样式配置,使用默认样式")
return DEFAULT_STYLE
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
# ASS 时间戳格式H:MM:SS.cccc=厘秒1/100秒
centiseconds = milliseconds // 10
return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
def generate_ass_file(srt_path: str, output_path: str, style: dict) -> bool:
"""
SRT 字幕转换为 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] 找到 {len(subs)} 条字幕,开始生成 ASS 文件...")
# 构建 ASS 文件内容
ass_lines = []
# 1. [Script Info] 节
ass_lines.append("[Script Info]")
ass_lines.append("; Script generated by ASS Subtitle Renderer")
ass_lines.append(f"PlayResX: {style.get('video_width', 1080)}")
ass_lines.append(f"PlayResY: {style.get('video_height', 1920)}")
ass_lines.append("Aspect Ratio: 9:16")
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&" # &HAAKKBBDD&
else:
back_colour = "&H00000000&" # 透明
bold = -1 if style.get("bold", True) else 0
italic = -1 if style.get("italic", False) else 0
underline = -1 if style.get("underline", False) else 0
strikeout = -1 if style.get("strikeout", False) else 0
outline_width = style.get("stroke_width", 2) if style.get("stroke_enabled", True) else 0
shadow_depth = style.get("shadow_depth", 0) if style.get("shadow_enabled", False) 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},{italic},{underline},{strikeout},100,100,{style.get('letter_spacing', 0)},0,"
f"1,{outline_width},{shadow_depth},{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()
# 添加淡入淡出效果(如果启用)
fade_in = style.get("fade_in_ms", 150)
fade_out = style.get("fade_out_ms", 150)
if fade_in > 0 and fade_out > 0:
# ASS 淡入淡出标签:{\fad(fade_in,fade_out)}
effect_tags = f"{{\\fad({fade_in},{fade_out})}}"
else:
effect_tags = ""
# 转义特殊字符ASS 格式)
text = text.replace("\\", "\\\\")
text = text.replace("{", "\\{")
text = text.replace("}", "\\}")
text = text.replace("\n", "\\N") # ASS 换行符
# 构建对话行
dialogue_line = (
f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{effect_tags}{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: # UTF-8 BOM for ASS
f.write("\n".join(ass_lines))
print(f"[OK] ASS 字幕文件已生成:{output_path}")
print(f"[INFO] 共 {len(subs)} 条字幕")
return True
except Exception as e:
print(f"[ERROR] 写入 ASS 文件失败:{e}")
return False
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 命令:使用 libass 烧录字幕
# -vf "ass=subtitles.ass" # 方法1直接指定 ASS 文件
# -vf "subtitles=subtitles.ass" # 方法2使用 subtitles 滤镜(自动检测格式)
# 使用 subtitles 滤镜(更通用,支持 SRT/ASS 等多种格式)
ass_path_escaped = ass_path.replace(":", "\\:").replace("'", "'\\''")
cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-vf", f"subtitles='{ass_path_escaped}'",
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-c:a", "copy",
"-shortest",
output_path
]
print(f"[INFO] 开始用 FFmpeg + libass 烧录字幕...")
print(f"[INFO] 命令:{' '.join(cmd[:10])}...") # 只打印前10个参数
try:
result = subprocess.run(cmd, check=False, capture_output=True, text=True)
except FileNotFoundError:
print("[ERROR] 未找到 ffmpeg请先安装 FFmpeg")
print("安装方法:")
print(" macOS: brew install ffmpeg")
print(" Ubuntu: sudo apt install ffmpeg")
return False
if result.returncode != 0:
print("[ERROR] FFmpeg 烧录字幕失败")
print(result.stderr[-2000:]) # 打印最后2000字符的错误信息
return False
if os.path.isfile(output_path) and os.path.getsize(output_path) > 0:
print(f"[OK] 字幕已烧录到视频:{output_path}")
return True
print(f"[ERROR] 输出视频未生成:{output_path}")
return False
def batch_process(srt_dir: str, output_dir: str, style: dict, burn_video: bool = False, video_dir: str = None):
"""
批量处理 SRT 文件
:param srt_dir: SRT 文件目录
:param output_dir: 输出目录
:param style: 样式字典
:param burn_video: 是否烧录到视频
:param video_dir: 视频文件目录如果 burn_video=True
"""
os.makedirs(output_dir, exist_ok=True)
for file in os.listdir(srt_dir):
if file.lower().endswith(".srt"):
srt_path = os.path.join(srt_dir, file)
ass_output = os.path.join(output_dir, file.replace(".srt", ".ass"))
print(f"\n[INFO] 处理文件:{file}")
ok = generate_ass_file(srt_path, ass_output, style)
if ok and burn_video and video_dir:
# 查找对应的视频文件
video_file = file.replace(".srt", ".mp4")
video_path = os.path.join(video_dir, video_file)
if os.path.isfile(video_path):
video_output = os.path.join(output_dir, video_file)
burn_subtitles_with_ffmpeg(video_path, ass_output, video_output)
else:
print(f"[WARN] 未找到对应的视频文件:{video_file}")
def main():
parser = argparse.ArgumentParser(
description="ASS Subtitle Renderer · ASS字幕渲染器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例
python ass-subtitle-renderer.py --srt input.srt --style reference-drama --output subtitles.ass
python ass-subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4 --style reference-drama
python ass-subtitle-renderer.py --batch ./srt/ --style reference-drama --output-dir ./ass/
"""
)
parser.add_argument("--srt", help="输入 SRT 文件路径")
parser.add_argument("--video", help="输入视频路径(可选,用于直接烧录字幕)")
parser.add_argument("--output", help="输出文件路径ASS 或 MP4")
parser.add_argument("--style", default="reference-drama", help="样式名称或配置文件路径")
parser.add_argument("--config", help="自定义样式配置文件路径(覆盖 --style")
parser.add_argument("--batch", help="批量处理 SRT 文件目录")
parser.add_argument("--output-dir", help="批量处理输出目录")
parser.add_argument("--burn-video", action="store_true", help="是否烧录字幕到视频")
parser.add_argument("--video-dir", help="视频文件目录(配合 --burn-video 使用)")
args = parser.parse_args()
# 加载样式配置
if args.config:
style = load_style_config(config_path=args.config)
else:
style = load_style_config(style_name=args.style)
if args.srt:
# 单文件处理
if not args.output:
print("[ERROR] 请指定 --output")
sys.exit(1)
# 生成 ASS 文件
ok = generate_ass_file(args.srt, args.output, style)
if not ok:
sys.exit(1)
# 如果指定了视频,则烧录字幕
if args.video:
video_output = args.output.replace(".ass", ".mp4")
burn_ok = burn_subtitles_with_ffmpeg(args.video, args.output, video_output)
if not burn_ok:
sys.exit(1)
elif args.batch:
# 批量处理
if not args.output_dir:
print("[ERROR] 批量处理需要指定 --output-dir")
sys.exit(1)
batch_process(args.batch, args.output_dir, style, args.burn_video, args.video_dir)
else:
print("[ERROR] 请指定 --srt 或 --batch")
sys.exit(1)
print("\n[OK] 处理完成")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
Reference Subtitle Analyzer · 样片字幕量化分析器
==============================================
从样片截图里量字幕字高底部距离描边宽度字幕框位置字号比例
依赖
pip install opencv-python pillow numpy
用法
# 分析单张截图
python reference-subtitle-analyzer.py --image reference-frame.png --output analysis.json
# 批量分析样片截图
python reference-subtitle-analyzer.py --batch ./reference-frames/ --output batch-analysis.json
# 交互式标注模式(手动框选字幕区域)
python reference-subtitle-analyzer.py --image reference-frame.png --interactive
输出 JSON 格式
{
"video_height": 1920,
"video_width": 1080,
"subtitle_box": {
"x": 100,
"y": 1750,
"width": 880,
"height": 120
},
"subtitle_height_px": 120,
"bottom_distance_px": 50,
"bottom_distance_ratio": 0.026,
"font_size_ratio": 0.0625,
"stroke_width_px": 2,
"alignment": "center",
"margin_horizontal_px": 100
}
路径
video-ai-system/engines/subtitle-pipeline/reference-analysis/reference-subtitle-analyzer.py
"""
import argparse
import json
import os
import sys
from pathlib import Path
try:
import cv2
import numpy as np
from PIL import Image
except ImportError as e:
print(f"[ERROR] 缺少依赖:{e}")
print("请先安装pip install opencv-python pillow numpy")
sys.exit(1)
def analyze_subtitle_region(image_path: str, interactive: bool = False) -> dict:
"""
分析字幕区域量化字幕参数
:param image_path: 截图路径
:param interactive: 是否交互式标注手动框选字幕区域
:return: 字幕参数字典
"""
# 读取图片
img = cv2.imread(image_path)
if img is None:
print(f"[ERROR] 无法读取图片:{image_path}")
return {}
height, width = img.shape[:2]
print(f"[INFO] 图片尺寸:{width}x{height}")
if interactive:
# 交互式标注模式:手动框选字幕区域
print("[INFO] 交互式标注模式:请在弹出的窗口中用鼠标框选字幕区域")
print("[INFO] 框选完成后按空格或回车确认按ESC取消")
roi = cv2.selectROI("Select Subtitle Region", img, showCrosshair=True)
cv2.destroyAllWindows()
x, y, w, h = int(roi[0]), int(roi[1]), int(roi[2]), int(roi[3])
if w == 0 or h == 0:
print("[ERROR] 未框选字幕区域")
return {}
else:
# 自动检测字幕区域(假设字幕在底部)
# 策略:检测底部区域的文本(通过边缘检测 + 轮廓查找)
print("[INFO] 自动检测字幕区域...")
# 1. 裁剪底部区域(假设字幕在底部 15% 区域)
bottom_region = img[int(height * 0.85):, :]
# 2. 转为灰度图
gray = cv2.cvtColor(bottom_region, cv2.COLOR_BGR2GRAY)
# 3. 二值化(检测白色文本)
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
# 4. 查找轮廓
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
print("[WARN] 未检测到字幕区域,使用默认底部区域")
# 使用默认底部区域
x = int(width * 0.1)
y = int(height * 0.85)
w = int(width * 0.8)
h = int(height * 0.12)
else:
# 合并所有轮廓的边界框
all_x = []
all_y = []
all_w = []
all_h = []
for cnt in contours:
cx, cy, cw, ch = cv2.boundingRect(cnt)
all_x.append(cx)
all_y.append(cy)
all_w.append(cw)
all_h.append(ch)
# 计算合并后的边界框
x = min(all_x)
y = min(all_y) + int(height * 0.85) # 加上裁剪偏移
w = max(all_x) + max(all_w) - x
h = max(all_y) + max(all_h) - y
# 扩展边界框(包含描边)
padding = 10
x = max(0, x - padding)
y = max(0, y - padding)
w = min(width - x, w + 2 * padding)
h = min(height - y, h + 2 * padding)
# 计算字幕参数
subtitle_box = {"x": x, "y": y, "width": w, "height": h}
subtitle_height_px = h
bottom_distance_px = height - (y + h)
bottom_distance_ratio = bottom_distance_px / height
font_size_ratio = h / height
# 估算描边宽度(通过检测文本边缘的黑色像素)
stroke_width_px = estimate_stroke_width(img, x, y, w, h)
# 判断对齐方式(居中/左对齐/右对齐)
alignment = "center" if abs(x + w/2 - width/2) < width * 0.1 else "left" if x < width * 0.3 else "right"
# 计算水平边距
margin_horizontal_px = x
result = {
"image_path": image_path,
"video_width": width,
"video_height": height,
"subtitle_box": subtitle_box,
"subtitle_height_px": subtitle_height_px,
"bottom_distance_px": bottom_distance_px,
"bottom_distance_ratio": round(bottom_distance_ratio, 4),
"font_size_ratio": round(font_size_ratio, 4),
"stroke_width_px": stroke_width_px,
"alignment": alignment,
"margin_horizontal_px": margin_horizontal_px
}
print(f"[OK] 字幕参数已量化:")
print(f" 字幕框x={x}, y={y}, w={w}, h={h}")
print(f" 字高:{subtitle_height_px}px")
print(f" 底部距离:{bottom_distance_px}px ({bottom_distance_ratio*100:.1f}%)")
print(f" 字号比例:{font_size_ratio*100:.1f}%")
print(f" 描边宽度:{stroke_width_px}px")
print(f" 对齐方式:{alignment}")
return result
def estimate_stroke_width(img: np.ndarray, x: int, y: int, w: int, h: int) -> int:
"""
估算描边宽度通过检测文本边缘的黑色像素
:param img: 图片数组
:param x: 字幕框 x 坐标
:param y: 字幕框 y 坐标
:param w: 字幕框宽度
:param h: 字幕框高度
:return: 估算的描边宽度像素
"""
# 裁剪字幕区域
subtitle_region = img[y:y+h, x:x+w]
# 转为灰度图
gray = cv2.cvtColor(subtitle_region, cv2.COLOR_BGR2GRAY)
# 检测边缘Canny
edges = cv2.Canny(gray, 100, 200)
# 统计边缘像素到文本区域的距离(估算描边宽度)
# 简化方案:假设描边是黑色,检测白色文本周围的黑色像素环
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
# 形态学操作:膨胀(模拟描边)
kernel = np.ones((3, 3), np.uint8)
dilated = cv2.dilate(binary, kernel, iterations=1)
# 计算膨胀后的边缘宽度
edge_width = cv2.absdiff(dilated, binary)
stroke_pixels = cv2.countNonZero(edge_width)
# 估算平均描边宽度
if stroke_pixels > 0:
# 简化:假设描边宽度是 1-3px
return 2 # 默认值,实际应通过更精确的算法计算
else:
return 0
def batch_analyze(images_dir: str) -> dict:
"""
批量分析样片截图
:param images_dir: 截图目录
:return: 批量分析结果
"""
results = {}
image_extensions = [".jpg", ".jpeg", ".png", ".bmp"]
for file in os.listdir(images_dir):
if any(file.lower().endswith(ext) for ext in image_extensions):
image_path = os.path.join(images_dir, file)
print(f"\n[INFO] 分析图片:{file}")
result = analyze_subtitle_region(image_path)
if result:
results[file] = result
# 计算平均值
if results:
avg_result = calculate_average(results)
results["_average"] = avg_result
return results
def calculate_average(results: dict) -> dict:
"""
计算批量分析结果的平均值
:param results: 批量分析结果
:return: 平均值字典
"""
keys = ["subtitle_height_px", "bottom_distance_px", "bottom_distance_ratio",
"font_size_ratio", "stroke_width_px", "margin_horizontal_px"]
avg = {}
for key in keys:
values = [r[key] for r in results.values() if key in r]
if values:
avg[key] = round(sum(values) / len(values), 2)
avg["alignment"] = max(set(r["alignment"] for r in results.values()),
key=lambda x: sum(1 for r in results.values() if r["alignment"] == x))
return avg
def main():
parser = argparse.ArgumentParser(
description="Reference Subtitle Analyzer · 样片字幕量化分析器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例
python reference-subtitle-analyzer.py --image reference-frame.png --output analysis.json
python reference-subtitle-analyzer.py --batch ./reference-frames/ --output batch-analysis.json
python reference-subtitle-analyzer.py --image reference-frame.png --interactive
"""
)
parser.add_argument("--image", help="单张截图路径")
parser.add_argument("--batch", help="批量分析截图目录")
parser.add_argument("--output", required=True, help="输出 JSON 文件路径")
parser.add_argument("--interactive", action="store_true", help="交互式标注模式")
args = parser.parse_args()
if args.image:
result = analyze_subtitle_region(args.image, args.interactive)
if not result:
sys.exit(1)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\n[OK] 分析结果已保存:{args.output}")
elif args.batch:
results = batch_analyze(args.batch)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n[OK] 批量分析结果已保存:{args.output}")
if "_average" in results:
print(f"[INFO] 平均字幕参数:{results['_average']}")
else:
print("[ERROR] 请指定 --image 或 --batch")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""
Script to SRT with Timing · 剧本到 SRT 时间轴生成器
===============================================
用剧本台词和配音时长生成正式 SRT别手写测试时间轴
功能
1. 读取剧本台词JSON/MD/TXT
2. 读取配音文件获取每条台词的实际时长
3. 自动计算时间轴考虑角色对话间隔
4. 生成标准 SRT 字幕文件
5. 支持多角色旁白音效标注
依赖
pip install pysrt
输入格式
# 剧本 JSON 格式(推荐)
[
{"id": 1, "character": "苏白", "text": "付费才能修仙?", "audio_file": "./audio/subai_001.wav"},
{"id": 2, "character": "旁白", "text": "天道宗门口", "audio_file": "./audio/narrator_001.wav"}
]
# 剧本 MD 格式(兼容)
## E1-SHOT01
**苏白**付费才能修仙audio: subai_001.wav
**旁白**天道宗门口audio: narrator_001.wav
输出 SRT 格式
1
00:00:01,000 --> 00:00:03,500
付费才能修仙
2
00:00:03,800 --> 00:00:05,200
天道宗门口
用法
# 从剧本 JSON + 配音文件生成 SRT
python script-to-srt-with-timing.py --script script.json --output subtitles.srt
# 指定配音目录(自动匹配 audio_file
python script-to-srt-with-timing.py --script script.json --audio-dir ./audio/ --output subtitles.srt
# 从 MD 剧本生成
python script-to-srt-with-timing.py --script script.md --output subtitles.srt
# 自定义对话间隔(秒)
python script-to-srt-with-timing.py --script script.json --gap 0.3 --output subtitles.srt
路径
video-ai-system/engines/subtitle-pipeline/srt-tools/script-to-srt-with-timing.py
"""
import argparse
import json
import os
import sys
import re
from pathlib import Path
try:
import pysrt
except ImportError:
print("[ERROR] 缺少依赖pysrt")
print("请先安装pip install pysrt")
sys.exit(1)
def parse_script_json(script_path: str) -> list:
"""
解析剧本 JSON 文件
:param script_path: 剧本 JSON 文件路径
:return: 台词列表 [{"id", "character", "text", "audio_file", "start", "end"}]
"""
with open(script_path, "r", encoding="utf-8") as f:
script = json.load(f)
print(f"[INFO] 读取剧本 JSON{len(script)} 条台词")
return script
def parse_script_md(script_path: str) -> list:
"""
解析剧本 MD 文件兼容格式
:param script_path: 剧本 MD 文件路径
:return: 台词列表
"""
with open(script_path, "r", encoding="utf-8") as f:
lines = f.readlines()
script = []
current_id = 0
for line in lines:
# 匹配:**角色**台词audio: file.wav
match = re.match(r"\*\*(.+?)\*\*[:]\s*(.+?)\s*\(audio:\s*(.+?)\)", line.strip())
if match:
character = match.group(1).strip()
text = match.group(2).strip()
audio_file = match.group(3).strip()
script.append({
"id": current_id + 1,
"character": character,
"text": text,
"audio_file": audio_file
})
current_id += 1
print(f"[INFO] 读取剧本 MD{len(script)} 条台词")
return script
def get_audio_duration(audio_path: str) -> float:
"""
获取音频文件时长
:param audio_path: 音频文件路径
:return: 时长
"""
try:
import ffmpeg
probe = ffmpeg.probe(audio_path)
duration = float(probe['format']['duration'])
return duration
except ImportError:
# 如果没有 ffmpeg-python使用 ffprobe 命令行
try:
import subprocess
cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", audio_path
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
probe = json.loads(result.stdout)
duration = float(probe['format']['duration'])
return duration
except Exception as e:
print(f"[WARN] 无法获取音频时长:{audio_path} ({e})")
# 估算:中文普通话约 4-6 字/秒
return 2.0 # 默认 2 秒
except Exception as e:
print(f"[WARN] 无法获取音频时长:{audio_path} ({e})")
return 2.0
def calculate_timings(script: list, audio_dir: str = None, gap: float = 0.2) -> list:
"""
计算时间轴
:param script: 台词列表
:param audio_dir: 配音文件目录如果 audio_file 是相对路径
:param gap: 对话间隔
:return: 带时间轴的台词列表
"""
current_time = 0.0
for item in script:
# 获取配音文件时长
audio_file = item.get("audio_file")
if audio_file and os.path.isfile(audio_file):
duration = get_audio_duration(audio_file)
elif audio_file and audio_dir:
audio_path = os.path.join(audio_dir, audio_file)
if os.path.isfile(audio_path):
duration = get_audio_duration(audio_path)
else:
# 估算时长
text = item.get("text", "")
duration = len(text) / 5.0 # 假设 5 字/秒
else:
# 没有配音文件,估算时长
text = item.get("text", "")
duration = len(text) / 5.0 # 假设 5 字/秒
# 设置开始和结束时间
item["start"] = current_time
item["end"] = current_time + duration
# 更新当前时间(加上间隔)
current_time = item["end"] + gap
print(f"[INFO] 时间轴计算完成:总时长 {current_time:.2f}")
return script
def generate_srt(script: list, output_path: str):
"""
生成 SRT 字幕文件
:param script: 带时间轴的台词列表
:param output_path: 输出 SRT 文件路径
"""
# 创建 pysrt SubRipFile
subs = pysrt.SubRipFile()
for item in script:
# 创建字幕项
sub = pysrt.SubRipItem()
sub.index = item["id"]
sub.text = item["text"]
# 设置时间
start_seconds = item["start"]
end_seconds = item["end"]
sub.start.hours = int(start_seconds // 3600)
sub.start.minutes = int((start_seconds % 3600) // 60)
sub.start.seconds = int(start_seconds % 60)
sub.start.milliseconds = int((start_seconds % 1) * 1000)
sub.end.hours = int(end_seconds // 3600)
sub.end.minutes = int((end_seconds % 3600) // 60)
sub.end.seconds = int(end_seconds % 60)
sub.end.milliseconds = int((end_seconds % 1) * 1000)
subs.append(sub)
# 保存 SRT 文件
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
subs.save(output_path, encoding="utf-8")
print(f"[OK] SRT 字幕文件已生成:{output_path}")
print(f"[INFO] 共 {len(script)} 条字幕")
def main():
parser = argparse.ArgumentParser(
description="Script to SRT with Timing · 剧本到 SRT 时间轴生成器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例
python script-to-srt-with-timing.py --script script.json --output subtitles.srt
python script-to-srt-with-timing.py --script script.md --audio-dir ./audio/ --output subtitles.srt
python script-to-srt-with-timing.py --script script.json --gap 0.3 --output subtitles.srt
"""
)
parser.add_argument("--script", required=True, help="剧本文件路径JSON 或 MD 格式)")
parser.add_argument("--audio-dir", help="配音文件目录(如果 audio_file 是相对路径)")
parser.add_argument("--output", required=True, help="输出 SRT 文件路径")
parser.add_argument("--gap", type=float, default=0.2, help="对话间隔(秒,默认 0.2")
args = parser.parse_args()
# 检查剧本文件是否存在
if not os.path.isfile(args.script):
print(f"[ERROR] 剧本文件不存在:{args.script}")
sys.exit(1)
# 解析剧本
if args.script.lower().endswith(".json"):
script = parse_script_json(args.script)
elif args.script.lower().endswith(".md"):
script = parse_script_md(args.script)
else:
print(f"[ERROR] 不支持的剧本格式:{args.script}")
print("支持格式:.json, .md")
sys.exit(1)
if not script:
print("[ERROR] 剧本为空,请检查格式")
sys.exit(1)
# 计算时间轴
script = calculate_timings(script, args.audio_dir, args.gap)
# 生成 SRT 文件
generate_srt(script, args.output)
print("\n[OK] 处理完成")
if __name__ == "__main__":
main()