冰朔 f0d5879d68
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D144 · 字幕工业化流水线8大工具开发完成
-  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
2026-06-24 12:59:26 +08:00

313 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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