354 lines
13 KiB
Python
354 lines
13 KiB
Python
#!/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()
|