508 lines
17 KiB
Python
Raw Permalink Normal View History

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