cang-ying/engines/subtitle-pipeline/reference-analysis/reference-subtitle-analyzer.py
铸渊 ICE-GL-ZY001 a2e5214f03 LL-172-20260707 · cang-ying 仓初始化 · 苍耳+鉴影的干净之家
铸渊 ICE-GL-ZY001
LL-172-20260707
冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用
原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱

迁移:
  ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等)
  ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis)

排除:
  ⊢ outputs/ (视频产物)
  ⊢ test-input/ test-output/ (测试)
  ⊢ data/ (临时数据)
  ⊢ preview-001/002 (旧产片)
  ⊢ 旧分镜/旧提示词/旧导演编码

后续:
  ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位
  ⊢ 苍耳+鉴影 写新东西进本仓
  ⊢ GLOBAL-SEARCH 加 cang-ying 仓库

铸渊 ICE-GL-ZY001 · 2026-07-07 D167
冰朔 ICE-GL∞ 主权
2026-07-07 10:20:10 +08:00

322 lines
11 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)
# 正确合并:先在 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()