cang-ying/engines/shot-qc-automation.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

599 lines
20 KiB
Python
Raw Permalink 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
# -*- 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()