cang-ying/engines/subtitle-pipeline/preview-tools/subtitle-preview-contact-sheet.py

354 lines
13 KiB
Python
Raw Permalink Normal View History

#!/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()