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