322 lines
11 KiB
Python
Raw Permalink Normal View History

#!/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)
# 正确合并:先在 bottom_region 坐标系里算完,最后再加偏移
x_bottom = min(all_x)
y_bottom = min(all_y)
max_x_bottom = max([all_x[i] + all_w[i] for i in range(len(all_x))])
max_y_bottom = max([all_y[i] + all_h[i] for i in range(len(all_y))])
w = max_x_bottom - x_bottom
h = max_y_bottom - y_bottom
# 扩展边界框(包含描边)
padding = 10
x = max(0, x_bottom - padding)
y = max(0, y_bottom - padding) + int(height * 0.85) # 偏移只加一次,在最后
w = min(width - x, w + 2 * padding)
h = min(height - y, h + 2 * padding)
# 计算字幕参数
# 先做边界校验,防止裁剪出空数组
x = max(0, min(x, width - 1))
y = max(0, min(y, height - 1))
w = max(1, min(w, width - x))
h = max(1, min(h, height - y))
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()