cang-ying/engines/shot-qc-automation.py

599 lines
20 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SHOT-QC-AUTOMATION
镜头QC自动化 每个镜头自动拆帧检查竖屏字幕换脸牌匾遮挡现代物品
功能:
1. 输入视频文件
2. 自动拆帧
3. 检查:
- 竖屏 (9:16)
- 字幕存在性和位置
- 换脸 (与参考图对比)
- 牌匾文字正确性
- 遮挡 (人物被遮挡)
- 现代物品 (手机汽车等)
4. 输出 QC 报告 JSON
依赖:
pip install opencv-python numpy # 基础
pip install pytesseract # OCR (需要系统安装 tesseract)
# YOLO 可选: pip install ultralytics
用法:
python shot-qc-automation.py --video input.mp4 --character CHAR-003-SuBai
python shot-qc-automation.py --batch video_list.json
python shot-qc-automation.py --video input.mp4 --output qc_report.json
"""
import os
import sys
import json
import argparse
from pathlib import Path
from datetime import datetime
import numpy as np
try:
import cv2
CV2_AVAILABLE = True
except ImportError:
CV2_AVAILABLE = False
print("⚠️ OpenCV (cv2) 未安装,将使用简化模式")
try:
import pytesseract
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
print("⚠️ pytesseract 未安装OCR 功能不可用")
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "engines"))
class ShotQCAutomation:
"""镜头QC自动化"""
def __init__(self, character_id=None, reference_images=None):
self.character_id = character_id
self.reference_images = reference_images or []
self.qc_items = [
"vertical_screen", # 竖屏
"subtitle", # 字幕
"face_swap", # 换脸
"plaque_text", # 牌匾文字
"occlusion", # 遮挡
"modern_items" # 现代物品
]
# 加载参考图
self.reference_images_data = []
if character_id:
self._load_reference_images()
def _load_reference_images(self):
"""加载角色参考图"""
if not CV2_AVAILABLE:
return
char_dir = PROJECT_ROOT / "assets" / "characters" / self.character_id / "approved"
if not char_dir.exists():
print(f"⚠️ 角色目录不存在: {char_dir}")
return
for img_file in char_dir.glob("*.png"):
img = cv2.imread(str(img_file))
if img is not None:
self.reference_images_data.append({
"path": str(img_file),
"data": img,
"name": img_file.name
})
print(f" ✓ 已加载参考图: {img_file.name}")
print(f" 共加载 {len(self.reference_images_data)} 张参考图")
def qc_video(self, video_path, output_path=None):
"""
QC 单个视频
返回:
{
"video_path": str,
"passed": bool,
"score": float, # 0-10
"checks": {
"vertical_screen": {"passed": bool, "detail": str},
"subtitle": {...},
...
},
"frames_checked": int,
"issues": list
}
"""
print(f"\n🔍 QC 视频: {Path(video_path).name}")
print("=" * 60)
video_path = Path(video_path)
if not video_path.exists():
return {"passed": False, "error": f"视频不存在: {video_path}"}
if not CV2_AVAILABLE:
print("⚠️ OpenCV 不可用,跳过 QC")
return {
"passed": None,
"warning": "OpenCV 不可用QC 未执行",
"qc_skipped": True
}
# 打开视频
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
return {"passed": False, "error": f"无法打开视频: {video_path}"}
# 视频信息
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f" 分辨率: {width}x{height}")
print(f" FPS: {fps}")
print(f" 帧数: {frame_count}")
# 检查项
results = {
"video_path": str(video_path),
"resolution": f"{width}x{height}",
"fps": fps,
"frame_count": frame_count,
"passed": True,
"score": 10.0,
"checks": {},
"frames_checked": 0,
"issues": []
}
# 1. 竖屏检查
print(f"\n [1/6] 竖屏检查...")
vertical_result = self._check_vertical_screen(width, height)
results["checks"]["vertical_screen"] = vertical_result
if not vertical_result["passed"]:
results["passed"] = False
results["score"] -= 2.0
results["issues"].append("竖屏比例错误")
# 2. 字幕检查 (抽帧)
print(f" [2/6] 字幕检查...")
subtitle_result = self._check_subtitle(cap, frame_count, fps)
results["checks"]["subtitle"] = subtitle_result
if not subtitle_result["passed"]:
results["passed"] = False
results["score"] -= 1.5
results["issues"].append("字幕检查失败")
# 3. 换脸检查 (与参考图对比)
print(f" [3/6] 换脸检查...")
face_swap_result = self._check_face_swap(cap, frame_count, fps)
results["checks"]["face_swap"] = face_swap_result
if not face_swap_result["passed"]:
results["passed"] = False
results["score"] -= 2.0
results["issues"].append("疑似换脸")
# 4. 牌匾文字检查
print(f" [4/6] 牌匾文字检查...")
plaque_result = self._check_plaque_text(cap, frame_count, fps)
results["checks"]["plaque_text"] = plaque_result
if not plaque_result["passed"]:
results["passed"] = False
results["score"] -= 1.5
results["issues"].append("牌匾文字错误")
# 5. 遮挡检查
print(f" [5/6] 遮挡检查...")
occlusion_result = self._check_occlusion(cap, frame_count, fps)
results["checks"]["occlusion"] = occlusion_result
if not occlusion_result["passed"]:
results["passed"] = False
results["score"] -= 1.0
results["issues"].append("人物被遮挡")
# 6. 现代物品检查
print(f" [6/6] 现代物品检查...")
modern_result = self._check_modern_items(cap, frame_count, fps)
results["checks"]["modern_items"] = modern_result
if not modern_result["passed"]:
results["passed"] = False
results["score"] -= 1.0
results["issues"].append("检测到现代物品")
# 确保分数在 0-10 之间
results["score"] = max(0, min(10, results["score"]))
# 重置视频到开头
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
cap.release()
# 打印总结
print(f"\n📊 QC 总结")
print(f" 通过: {'' if results['passed'] else ''}")
print(f" 分数: {results['score']:.1f}/10")
print(f" 问题数: {len(results['issues'])}")
for issue in results["issues"]:
print(f" - {issue}")
# 保存报告
if output_path is None:
output_path = PROJECT_ROOT / "outputs" / "qc_reports" / f"{video_path.stem}_qc.json"
else:
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n 报告已保存: {output_path}")
return results
def _check_vertical_screen(self, width, height):
"""检查竖屏 (9:16)"""
# 竖屏: 宽度 < 高度,比例接近 9:16
if width >= height:
return {
"passed": False,
"detail": f"横屏 {width}x{height},应为竖屏 9:16",
"aspect_ratio": width / height
}
# 检查比例是否接近 9:16
ratio = width / height
target_ratio = 9 / 16 # ≈ 0.5625
if abs(ratio - target_ratio) < 0.05:
return {
"passed": True,
"detail": f"竖屏比例正确 {width}x{height} (ratio={ratio:.3f})",
"aspect_ratio": ratio
}
else:
return {
"passed": False,
"detail": f"竖屏比例不正确 {width}x{height} (ratio={ratio:.3f}, target={target_ratio:.3f})",
"aspect_ratio": ratio
}
def _check_subtitle(self, cap, frame_count, fps):
"""检查字幕 (抽帧 + OCR)"""
if not TESSERACT_AVAILABLE:
return {
"passed": True, # 无法检查,默认通过
"detail": "Tesseract OCR 不可用,跳过字幕检查",
"skipped": True
}
# 抽帧: 每秒抽1帧
sample_interval = int(fps)
if sample_interval < 1:
sample_interval = 1
frames_to_check = []
for i in range(0, frame_count, sample_interval):
frames_to_check.append(i)
# 限制最多检查 30 帧
if len(frames_to_check) > 30:
step = len(frames_to_check) // 30
frames_to_check = frames_to_check[::step][:30]
subtitle_found = False
subtitle_positions = []
for frame_idx in frames_to_check:
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = cap.read()
if not ret:
continue
# OCR 检测字幕 (通常在画面底部 1/4 区域)
height, width = frame.shape[:2]
subtitle_region = frame[int(height * 0.75):, :] # 底部 1/4
try:
text = pytesseract.image_to_string(subtitle_region, config='--psm 6')
if text.strip():
subtitle_found = True
subtitle_positions.append(frame_idx / fps) # 秒数
except Exception as e:
pass
if subtitle_found:
return {
"passed": True,
"detail": f"检测到字幕,出现位置: {len(subtitle_positions)}",
"subtitle_positions": subtitle_positions[:10] # 前10个位置
}
else:
return {
"passed": False,
"detail": "未检测到字幕",
"subtitle_positions": []
}
def _check_face_swap(self, cap, frame_count, fps):
"""检查换脸 (与参考图对比)"""
if len(self.reference_images_data) == 0:
return {
"passed": True, # 无参考图,无法检查
"detail": "无参考图,跳过换脸检查",
"skipped": True
}
# 抽帧: 每分钟抽1帧
sample_interval = int(fps * 60)
if sample_interval < 1:
sample_interval = 1
frames_to_check = []
for i in range(0, frame_count, sample_interval):
frames_to_check.append(i)
# 限制最多检查 10 帧
if len(frames_to_check) > 10:
step = len(frames_to_check) // 10
frames_to_check = frames_to_check[::step][:10]
face_swap_detected = False
suspicious_frames = []
for frame_idx in frames_to_check:
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = cap.read()
if not ret:
continue
# 简化方法: 比较直方图
frame_hist = cv2.calcHist([frame], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
cv2.normalize(frame_hist, frame_hist)
for ref in self.reference_images_data:
ref_hist = cv2.calcHist([ref["data"]], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
cv2.normalize(ref_hist, ref_hist)
# 比较直方图相关性
similarity = cv2.compareHist(frame_hist, ref_hist, cv2.HISTCMP_CORREL)
if similarity < 0.3: # 低相似度 = 可能换脸
face_swap_detected = True
suspicious_frames.append({
"frame": frame_idx,
"time": frame_idx / fps,
"similarity": float(similarity)
})
if not face_swap_detected:
return {
"passed": True,
"detail": f"未检测到换脸 (检查了 {len(frames_to_check)} 帧)",
"frames_checked": len(frames_to_check)
}
else:
return {
"passed": False,
"detail": f"疑似换脸 (检测到 {len(suspicious_frames)} 处异常)",
"suspicious_frames": suspicious_frames[:5]
}
def _check_plaque_text(self, cap, frame_count, fps):
"""检查牌匾文字 (OCR)"""
if not TESSERACT_AVAILABLE:
return {
"passed": True,
"detail": "Tesseract OCR 不可用,跳过牌匾文字检查",
"skipped": True
}
# 抽帧: 牌匾通常静止,抽 5 帧即可
frames_to_check = [0, int(frame_count * 0.25), int(frame_count * 0.5), int(frame_count * 0.75), frame_count - 1]
frames_to_check = [f for f in frames_to_check if f < frame_count]
plaque_text_detected = []
correct_text = "天道宗" # 期望的牌匾文字
for frame_idx in frames_to_check:
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = cap.read()
if not ret:
continue
# OCR 整帧
try:
text = pytesseract.image_to_string(frame, config='--psm 6')
if correct_text in text:
plaque_text_detected.append({
"frame": frame_idx,
"time": frame_idx / fps,
"text": text.strip()[:50]
})
except Exception as e:
pass
if len(plaque_text_detected) > 0:
return {
"passed": True,
"detail": f"牌匾文字正确 '{correct_text}' (在 {len(plaque_text_detected)} 帧中检测到)",
"detected": plaque_text_detected
}
else:
return {
"passed": False,
"detail": f"未检测到牌匾文字 '{correct_text}'",
"warning": "可能牌匾文字错误或未出现在画面中"
}
def _check_occlusion(self, cap, frame_count, fps):
"""检查遮挡 (人物被遮挡)"""
# 简化方法: 检测画面中是否突然出现大块纯色区域 (可能是水印或遮挡)
sample_interval = int(fps * 10) # 每10秒抽1帧
if sample_interval < 1:
sample_interval = 1
frames_to_check = []
for i in range(0, frame_count, sample_interval):
frames_to_check.append(i)
occlusion_detected = False
for frame_idx in frames_to_check:
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = cap.read()
if not ret:
continue
# 转灰度
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 计算灰度直方图
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
hist = hist.flatten()
# 如果某个灰度值占比过高 = 可能有遮挡/水印
max_ratio = np.max(hist) / (frame.shape[0] * frame.shape[1])
if max_ratio > 0.3: # 30% 以上像素是同一颜色
occlusion_detected = True
break
if not occlusion_detected:
return {
"passed": True,
"detail": f"未检测到明显遮挡 (检查了 {len(frames_to_check)} 帧)"
}
else:
return {
"passed": False,
"detail": "检测到可能的遮挡 (画面中有大块纯色区域)"
}
def _check_modern_items(self, cap, frame_count, fps):
"""检查现代物品 (手机、汽车等)"""
# 简化方法: 检测画面中是否有现代物品的特征颜色/形状
# TODO: 使用 YOLO 检测现代物品
# 暂时跳过,返回通过
return {
"passed": True,
"detail": "现代物品检查 (TODO: 需要 YOLO 模型)",
"skipped": True,
"todo": "Implement YOLO-based modern item detection"
}
def batch_qc(self, video_list_config):
"""
批量 QC
video_list_config 格式:
{
"videos": [
{"path": "ep01-shot01.mp4", "character": "CHAR-003-SuBai"},
...
]
}
"""
if isinstance(video_list_config, str):
config_file = Path(video_list_config)
with open(config_file, "r", encoding="utf-8") as f:
config = json.load(f)
videos = config.get("videos", [])
elif isinstance(video_list_config, list):
videos = video_list_config
else:
videos = []
print(f"\n📦 批量 QC: {len(videos)} 个视频")
results = []
for i, video_config in enumerate(videos):
print(f"\n 进度: [{i+1}/{len(videos)}]")
video_path = video_config.get("path")
character_id = video_config.get("character", self.character_id)
qc = ShotQCAutomation(character_id=character_id)
result = qc.qc_video(video_path)
results.append(result)
# 统计
passed_count = sum(1 for r in results if r.get("passed"))
print(f"\n✅ 批量 QC 完成: {passed_count}/{len(results)} 通过")
# 保存批量报告
report_path = PROJECT_ROOT / "outputs" / "qc_reports" / f"batch_qc_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
report_path.parent.mkdir(parents=True, exist_ok=True)
with open(report_path, "w", encoding="utf-8") as f:
json.dump({
"total": len(results),
"passed": passed_count,
"results": results,
"generated_at": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
print(f" 报告已保存: {report_path}")
return results
def main():
parser = argparse.ArgumentParser(description="SHOT-QC-AUTOMATION")
parser.add_argument("--video", type=str, help="输入视频路径")
parser.add_argument("--character", type=str, help="角色ID (用于换脸检查)")
parser.add_argument("--output", type=str, help="输出 QC 报告路径")
parser.add_argument("--batch", type=str, help="批量 QC 配置文件 (JSON)")
args = parser.parse_args()
if args.batch:
# 批量模式
qc = ShotQCAutomation(character_id=args.character)
results = qc.batch_qc(args.batch)
sys.exit(0 if all(r.get("passed") for r in results) else 1)
if not args.video:
parser.print_help()
sys.exit(1)
# 单文件模式
qc = ShotQCAutomation(character_id=args.character)
result = qc.qc_video(args.video, output_path=args.output)
if result.get("passed"):
print(f"\n✅ QC 通过")
sys.exit(0)
elif result.get("passed") is None and result.get("qc_skipped"):
print(f"\n⚠️ QC 跳过 (依赖不可用)")
sys.exit(0)
else:
if result.get("error"):
print(f"\n❌ QC 失败: {result['error']}")
else:
issues = result.get("issues") or []
issue_text = "".join(issues) if issues else "未通过阈值"
print(f"\n❌ QC 未通过: {issue_text}")
sys.exit(1)
if __name__ == "__main__":
main()