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