cang-ying/engines/subtitle-pipeline/qc-tools/subtitle-safe-area-qc.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

508 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
"""
Subtitle Safe Area QC · 字幕安全区域质检器
=============================================
自动检查字幕有没有遮脸、遮身体表演、贴边、看不清。
依赖:
pip install opencv-python pillow numpy
# 可选pip install face-recognition dlib # 人脸识别
# 可选pip install ultralytics # YOLO 人体检测
用法:
# 检查单张帧
python subtitle-safe-area-qc.py --frame frame.png --subtitle-box x,y,w,h --output qc-report.json
# 批量检查视频帧
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --output qc-report.json
# 交互式标注模式(手动框选人脸/身体区域)
python subtitle-safe-area-qc.py --frame frame.png --interactive
# 生成质检报告(包含问题帧截图)
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --generate-report --output-dir ./qc-report/
输出 JSON 格式:
{
"frame": "frame_00123.png",
"timestamp": 12.34,
"issues": [
{"type": "face_occlusion", "severity": "high", "bbox": [x,y,w,h]},
{"type": "body_occlusion", "severity": "medium", "bbox": [x,y,w,h]},
{"type": "edge_too_close", "severity": "low", "distance": 5},
{"type": "low_contrast", "severity": "medium", "contrast_ratio": 2.1}
],
"safe": false
}
路径:
video-ai-system/engines/subtitle-pipeline/qc-tools/subtitle-safe-area-qc.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)
# 尝试导入可选依赖
try:
import face_recognition
FACE_RECOGNITION_AVAILABLE = True
except ImportError:
FACE_RECOGNITION_AVAILABLE = False
print("[WARN] 未安装 face-recognition将使用简化人脸检测")
try:
from ultralytics import YOLO
YOLO_AVAILABLE = True
except ImportError:
YOLO_AVAILABLE = False
print("[WARN] 未安装 ultralytics将使用简化身体检测")
def detect_faces(image: np.ndarray) -> list:
"""
检测人脸区域
:param image: 图片数组
:return: 人脸边界框列表 [[x,y,w,h], ...]
"""
if FACE_RECOGNITION_AVAILABLE:
# 使用 face_recognition 库(基于 dlib
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
face_locations = face_recognition.face_locations(rgb_image)
# 转换为 [x,y,w,h] 格式
boxes = []
for top, right, bottom, left in face_locations:
x = left
y = top
w = right - left
h = bottom - top
boxes.append([x, y, w, h])
return boxes
else:
# 简化方案:使用 OpenCV Haar Cascade
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
face_cascade = cv2.CascadeClassifier(cascade_path)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.1, 4)
return faces.tolist() if len(faces) > 0 else []
def detect_body(image: np.ndarray) -> list:
"""
检测身体区域
:param image: 图片数组
:return: 身体边界框列表 [[x,y,w,h], ...]
"""
if YOLO_AVAILABLE:
# 使用 YOLO 检测人体
model = YOLO("yolov8n.pt") # 自动下载
results = model(image)
boxes = []
for result in results:
for box in result.boxes:
cls = int(box.cls[0])
# COCO 数据集中person 的类别 ID 是 0
if cls == 0:
x1, y1, x2, y2 = box.xyxy[0].tolist()
boxes.append([int(x1), int(y1), int(x2-x1), int(y2-y1)])
return boxes
else:
# 简化方案:使用背景减除或轮廓检测
print("[WARN] 未安装 YOLO使用简化身体检测可能不准确")
# 策略:检测图像中的大轮廓(假设身体是大区域)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY_INV)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
boxes = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area > image.shape[0] * image.shape[1] * 0.1: # 面积 > 10%
x, y, w, h = cv2.boundingRect(cnt)
boxes.append([x, y, w, h])
return boxes
def detect_subtitle_box(image: np.ndarray) -> list:
"""
检测字幕框位置
:param image: 图片数组
:return: 字幕边界框 [x,y,w,h]
"""
# 策略:检测底部区域的白色文本
height, width = image.shape[:2]
# 裁剪底部区域
bottom_region = image[int(height * 0.85):, :]
# 转为灰度图
gray = cv2.cvtColor(bottom_region, cv2.COLOR_BGR2GRAY)
# 二值化(检测白色文本)
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
# 查找轮廓
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return [0, int(height * 0.85), width, int(height * 0.12)] # 默认底部区域
# 收集所有轮廓的边界框
all_x = []
all_y = []
all_w = []
all_h = []
for cnt in contours:
x, y, w, h = cv2.boundingRect(cnt)
all_x.append(x)
all_y.append(y)
all_w.append(w)
all_h.append(h)
# 合并所有轮廓的边界框
# 先在 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
# 最后统一加偏移
x = x_bottom
y = y_bottom + int(height * 0.85)
# w, h 不需要加偏移它们是在bottom_region里的尺寸
return [x, y, w, h]
def calculate_iou(box1: list, box2: list) -> float:
"""
计算两个边界框的 IOU交并比
:param box1: 边界框1 [x,y,w,h]
:param box2: 边界框2 [x,y,w,h]
:return: IOU 值0-1
"""
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[0] + box1[2], box2[0] + box2[2])
y2 = min(box1[1] + box1[3], box2[1] + box2[3])
if x2 < x1 or y2 < y1:
return 0.0
intersection = (x2 - x1) * (y2 - y1)
area1 = box1[2] * box1[3]
area2 = box2[2] * box2[3]
union = area1 + area2 - intersection
return intersection / union if union > 0 else 0.0
def check_subtitle_safe_area(image_path: str, subtitle_box: list = None, interactive: bool = False) -> dict:
"""
检查字幕安全区域
:param image_path: 图片路径
:param subtitle_box: 字幕框 [x,y,w,h](如果为 None自动检测
: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] 检查图片:{image_path} ({width}x{height})")
# 检测字幕框
if subtitle_box is None:
if interactive:
print("[INFO] 交互式标注模式:请手动框选字幕区域")
roi = cv2.selectROI("Select Subtitle Region", img, showCrosshair=True)
cv2.destroyAllWindows()
subtitle_box = [int(roi[0]), int(roi[1]), int(roi[2]), int(roi[3])]
else:
subtitle_box = detect_subtitle_box(img)
print(f"[INFO] 字幕框x={subtitle_box[0]}, y={subtitle_box[1]}, w={subtitle_box[2]}, h={subtitle_box[3]}")
# 检测人脸
print("[INFO] 检测人脸...")
face_boxes = detect_faces(img)
print(f"[INFO] 找到 {len(face_boxes)} 个人脸")
# 检测身体
print("[INFO] 检测身体...")
body_boxes = detect_body(img)
print(f"[INFO] 找到 {len(body_boxes)} 个身体区域")
# 检查问题
issues = []
# 1. 检查是否遮挡脸部
for i, face_box in enumerate(face_boxes):
iou = calculate_iou(subtitle_box, face_box)
if iou > 0.1: # IOU > 10% 认为有遮挡
severity = "high" if iou > 0.5 else "medium"
issues.append({
"type": "face_occlusion",
"severity": severity,
"bbox": face_box,
"iou": round(iou, 2),
"message": f"字幕遮挡脸部IOU={iou:.2f}"
})
print(f"[ISSUE] 字幕遮挡脸部IOU={iou:.2f}")
# 2. 检查是否遮挡身体
for i, body_box in enumerate(body_boxes):
iou = calculate_iou(subtitle_box, body_box)
if iou > 0.2: # IOU > 20% 认为有遮挡
severity = "high" if iou > 0.6 else "medium"
issues.append({
"type": "body_occlusion",
"severity": severity,
"bbox": body_box,
"iou": round(iou, 2),
"message": f"字幕遮挡身体IOU={iou:.2f}"
})
print(f"[ISSUE] 字幕遮挡身体IOU={iou:.2f}")
# 3. 检查是否贴边
edge_threshold = 20 # 像素
if subtitle_box[0] < edge_threshold:
issues.append({
"type": "edge_too_close",
"severity": "low",
"distance": subtitle_box[0],
"message": f"字幕距离左边缘太近({subtitle_box[0]}px"
})
print(f"[ISSUE] 字幕距离左边缘太近({subtitle_box[0]}px")
if (subtitle_box[0] + subtitle_box[2]) > (width - edge_threshold):
distance = (subtitle_box[0] + subtitle_box[2]) - width
issues.append({
"type": "edge_too_close",
"severity": "low",
"distance": abs(distance),
"message": f"字幕距离右边缘太近({abs(distance)}px"
})
print(f"[ISSUE] 字幕距离右边缘太近({abs(distance)}px")
# 4. 检查对比度(字幕是否看不清)
# 简化方案:计算字幕区域和背景的平均亮度差
subtitle_region = img[
subtitle_box[1]:subtitle_box[1]+subtitle_box[3],
subtitle_box[0]:subtitle_box[0]+subtitle_box[2]
]
if subtitle_region.size > 0:
# 计算字幕区域的平均亮度
subtitle_gray = cv2.cvtColor(subtitle_region, cv2.COLOR_BGR2GRAY)
subtitle_brightness = np.mean(subtitle_gray)
# 计算背景区域的平均亮度(字幕框上方的区域)
background_region = img[
max(0, subtitle_box[1]-subtitle_box[3]):subtitle_box[1],
subtitle_box[0]:subtitle_box[0]+subtitle_box[2]
]
if background_region.size > 0:
background_gray = cv2.cvtColor(background_region, cv2.COLOR_BGR2GRAY)
background_brightness = np.mean(background_gray)
# 计算对比度(亮度差)
contrast = abs(subtitle_brightness - background_brightness)
contrast_ratio = contrast / max(subtitle_brightness, background_brightness)
if contrast_ratio < 0.3: # 对比度 < 30% 认为看不清
severity = "high" if contrast_ratio < 0.1 else "medium"
issues.append({
"type": "low_contrast",
"severity": severity,
"contrast_ratio": round(contrast_ratio, 2),
"message": f"字幕对比度太低({contrast_ratio:.2f}),可能看不清"
})
print(f"[ISSUE] 字幕对比度太低({contrast_ratio:.2f}),可能看不清")
# 生成报告
report = {
"image_path": image_path,
"video_width": width,
"video_height": height,
"subtitle_box": subtitle_box,
"face_boxes": face_boxes,
"body_boxes": body_boxes,
"issues": issues,
"safe": len(issues) == 0,
"issue_count": len(issues)
}
if report["safe"]:
print(f"[OK] 字幕安全区域检查通过")
else:
print(f"[WARN] 发现 {len(issues)} 个问题")
return report
def batch_check_video(video_path: str, ass_path: str, output_dir: str, sample_interval: float = 1.0):
"""
批量检查视频帧
:param video_path: 视频文件路径
:param ass_path: ASS 字幕文件路径
:param output_dir: 输出目录
:param sample_interval: 采样间隔(秒)
"""
# 打开视频
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")
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
frames_dir = os.path.join(output_dir, "problem-frames/")
os.makedirs(frames_dir, exist_ok=True)
# 读取 ASS 字幕文件(获取字幕时间点)
# 简化方案:假设字幕在底部,直接检测
# TODO: 解析 ASS 文件,获取准确的字幕时间点
reports = []
frame_count = 0
while True:
ret, frame = cap.read()
if not ret:
break
timestamp = frame_count / fps
# 按采样间隔检查
if timestamp % sample_interval < (1.0 / fps):
# 保存帧为临时文件
temp_frame_path = os.path.join(output_dir, f"temp_frame_{frame_count:06d}.png")
cv2.imwrite(temp_frame_path, frame)
# 检查字幕安全区域
report = check_subtitle_safe_area(temp_frame_path)
if report and not report["safe"]:
# 保存问题帧
problem_frame_path = os.path.join(frames_dir, f"problem_{frame_count:06d}.png")
cv2.imwrite(problem_frame_path, frame)
report["timestamp"] = timestamp
report["problem_frame"] = problem_frame_path
reports.append(report)
print(f"[WARN] 发现问题的帧:{timestamp:.2f}s")
# 删除临时文件
os.remove(temp_frame_path)
frame_count += 1
cap.release()
# 保存报告
report_path = os.path.join(output_dir, "qc-report.json")
with open(report_path, "w", encoding="utf-8") as f:
json.dump(reports, f, ensure_ascii=False, indent=2)
print(f"\n[OK] 批量检查完成")
print(f"[INFO] 共检查 {frame_count} 帧,发现 {len(reports)} 个有问题帧")
print(f"[INFO] 报告已保存:{report_path}")
def main():
parser = argparse.ArgumentParser(
description="Subtitle Safe Area QC · 字幕安全区域质检器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python subtitle-safe-area-qc.py --frame frame.png --subtitle-box 100,1750,880,120 --output qc-report.json
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --output qc-report.json
python subtitle-safe-area-qc.py --frame frame.png --interactive
"""
)
parser.add_argument("--frame", help="单张帧图片路径")
parser.add_argument("--video", help="视频文件路径(批量检查)")
parser.add_argument("--subtitle-box", help="字幕框坐标 x,y,w,h逗号分隔")
parser.add_argument("--subtitle-ass", help="ASS 字幕文件路径(用于批量检查)")
parser.add_argument("--output", help="输出报告文件路径")
parser.add_argument("--interactive", action="store_true", help="交互式标注模式")
parser.add_argument("--generate-report", action="store_true", help="生成质检报告(包含问题帧截图)")
parser.add_argument("--output-dir", help="输出目录(用于批量检查)")
parser.add_argument("--sample-interval", type=float, default=1.0, help="采样间隔(秒,用于批量检查)")
args = parser.parse_args()
if args.frame:
# 单张帧检查
subtitle_box = None
if args.subtitle_box:
subtitle_box = [int(x) for x in args.subtitle_box.split(",")]
report = check_subtitle_safe_area(args.frame, subtitle_box, args.interactive)
if not report:
sys.exit(1)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"[OK] 质检报告已保存:{args.output}")
elif args.video:
# 批量检查
if not args.subtitle_ass:
print("[ERROR] 批量检查需要指定 --subtitle-ass")
sys.exit(1)
output_dir = args.output_dir or "./qc-report/"
batch_check_video(args.video, args.subtitle_ass, output_dir, args.sample_interval)
else:
print("[ERROR] 请指定 --frame 或 --video")
sys.exit(1)
if __name__ == "__main__":
main()