Files
momentry_core/scripts/extract_face_crops.py

397 lines
14 KiB
Python
Raw 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
"""
extract_face_crops.py - 批量提取 face crops
Usage:
python3 scripts/extract_face_crops.py --uuid <file_uuid>
python3 scripts/extract_face_crops.py --uuid <file_uuid> --video <video_path>
儲存位置: {OUTPUT_DIR}/.faces/{file_uuid}/{trace_id}/{frame}.jpg
條件:
- trace_id != None and trace_id != 0
- landmarks.left_eye or landmarks.right_eye
品檢:
- file_size > 500 bytes
- mean_brightness > 5
- std_deviation > 10
Retry: 最多 3 次
"""
import argparse
import json
import subprocess
import os
import sys
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Set
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
# Constants
MAX_RETRIES = 3
MIN_FILE_SIZE = 500
MIN_BRIGHTNESS = 5
MIN_STD_DEV = 10
FFMPEG_TIMEOUT = 30
MAX_WORKERS = 8 # Parallel threads for ffmpeg
class FaceCropExtractor:
def __init__(self, output_dir: str):
self.output_dir = Path(output_dir)
self.faces_dir = self.output_dir / ".faces"
self.faces_dir.mkdir(parents=True, exist_ok=True)
self.stats = {"total_faces": 0, "qualified": 0, "successful": 0, "failed": 0, "skipped": 0, "low_confidence": 0, "too_small": 0}
self.stats_lock = threading.Lock()
def process_video(self, uuid: str, video_path: str) -> dict:
"""處理單一影片"""
face_json = self.output_dir / f"{uuid}.face.json"
traced_json = self.output_dir / f"{uuid}.face_traced.json"
if not face_json.exists():
print(f"[ERROR] face.json not found: {uuid}")
return {"error": "face.json not found"}
if not os.path.exists(video_path):
print(f"[ERROR] Video not found: {video_path}")
return {"error": "video not found"}
# Load face.json (landmarks)
print(f"[LOAD] Reading {face_json}")
with open(face_json) as f:
face_data = json.load(f)
# Load face_traced.json if exists (trace_id)
traced_data = {}
if traced_json.exists():
print(f"[LOAD] Reading {traced_json}")
with open(traced_json) as f:
traced_data = json.load(f)
# Build lookup: (frame, x, y) -> trace_id from traced_data
trace_lookup: Dict[Tuple[int, int, int], int] = {}
frames = traced_data.get("frames", {})
if isinstance(frames, dict):
for fnum, frm in frames.items():
faces = frm.get("faces", [])
if faces is None:
continue
for face in faces:
if face is None:
continue
trace_id = face.get("trace_id")
if trace_id and trace_id != 0:
x = face.get("x", 0)
y = face.get("y", 0)
key = (int(fnum), x, y)
trace_lookup[key] = trace_id
# Create output directory
uuid_dir = self.faces_dir / uuid
uuid_dir.mkdir(parents=True, exist_ok=True)
results = {"successful": [], "failed": []}
processed: Set[Tuple[int, int]] = set() # (trace_id, frame)
trace_counts: Dict[int, int] = {} # trace_id -> count
# Process faces from face.json
frames = face_data.get("frames", {})
if isinstance(frames, dict):
frame_items = frames.items()
elif isinstance(frames, list):
frame_items = [(frm.get("frame"), frm) for frm in frames]
else:
frame_items = []
# Collect extraction tasks
tasks = []
for fnum, frm in frame_items:
if fnum is None:
continue
faces = frm.get("faces", [])
if faces is None:
continue
for face in faces:
if face is None:
continue
self.stats["total_faces"] += 1
bb = face.get("bbox", face)
x = bb.get("x", 0) if isinstance(bb, dict) else 0
y = bb.get("y", 0) if isinstance(bb, dict) else 0
w = bb.get("width", 0) if isinstance(bb, dict) else 0
h = bb.get("height", 0) if isinstance(bb, dict) else 0
confidence = face.get("confidence", 0.0)
# Quality filtering: confidence + size
MIN_CONFIDENCE = 0.6
MIN_SIZE = 20
if confidence < MIN_CONFIDENCE:
self.stats["low_confidence"] += 1
continue
if w < MIN_SIZE or h < MIN_SIZE:
self.stats["too_small"] += 1
continue
# Check: has eyes
lm = face.get("landmarks")
has_eyes = False
if lm:
if isinstance(lm, dict):
has_eyes = lm.get("left_eye") or lm.get("right_eye")
elif isinstance(lm, list) and len(lm) >= 2:
has_eyes = True
if not has_eyes:
self.stats["skipped"] += 1
continue
self.stats["qualified"] += 1
# Check: in trace
key = (int(fnum), x, y)
trace_id = trace_lookup.get(key) or face.get("trace_id")
# Determine output directory
if trace_id and trace_id != 0:
output_dir = uuid_dir / str(trace_id)
crop_key = (trace_id, int(fnum))
else:
# No trace_id → unbound directory
output_dir = uuid_dir / "unbound"
crop_key = ("unbound", int(fnum), x, y) # unique key for unbound
if crop_key in processed:
continue
processed.add(crop_key)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{fnum}.jpg"
tasks.append({
"trace_id": trace_id or "unbound",
"frame": int(fnum),
"x": x, "y": y, "w": w, "h": h,
"output_path": output_path
})
# Parallel extraction
print(f"[EXTRACT] Processing {len(tasks)} faces with {MAX_WORKERS} threads...")
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {
executor.submit(
self.extract_face, video_path, t["frame"],
t["x"], t["y"], t["w"], t["h"], t["output_path"]
): t for t in tasks
}
for i, future in enumerate(as_completed(futures)):
t = futures[future]
result = future.result()
if result["success"]:
self.stats["successful"] += 1
results["successful"].append({
"trace_id": t["trace_id"],
"frame": t["frame"],
"path": str(t["output_path"])
})
trace_counts[t["trace_id"]] = trace_counts.get(t["trace_id"], 0) + 1
else:
self.stats["failed"] += 1
results["failed"].append({
"trace_id": t["trace_id"],
"frame": t["frame"],
"bbox": {"x": t["x"], "y": t["y"], "w": t["w"], "h": t["h"]},
"reason": result.get("reason", "unknown")
})
# Progress every 1000
if (i + 1) % 1000 == 0:
print(f" Progress: {i+1}/{len(tasks)} ({self.stats['successful']} OK, {self.stats['failed']} fail)")
# Write summary
self.write_summary(uuid, trace_counts, results)
return results
def extract_face(self, video_path: str, frame: int, x: int, y: int,
w: int, h: int, output_path: Path) -> dict:
"""提取 face crop含 retry使用 -ss 快速 seek"""
for attempt in range(MAX_RETRIES):
try:
ts = frame / 24.0 # FPS is always 24 for this video
cmd = [
"ffmpeg", "-y", "-ss", f"{ts:.3f}",
"-i", video_path,
"-vf", f"crop={w}:{h}:{x}:{y}",
"-frames:v", "1",
"-q:v", "2", # 高品質 JPEG
str(output_path)
]
proc = subprocess.run(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=FFMPEG_TIMEOUT
)
if proc.returncode != 0:
if attempt < MAX_RETRIES - 1:
continue
return {"success": False, "reason": "ffmpeg_failed"}
# Quality check
quality = self.check_quality(output_path)
if quality["ok"]:
return {"success": True, "path": str(output_path)}
# Quality failed, retry
if attempt < MAX_RETRIES - 1:
# Remove bad file
if output_path.exists():
output_path.unlink()
continue
return {"success": False, "reason": quality.get("reason", "quality_failed")}
except subprocess.TimeoutExpired:
if attempt < MAX_RETRIES - 1:
continue
return {"success": False, "reason": "timeout"}
except Exception as e:
return {"success": False, "reason": str(e)}
return {"success": False, "reason": "max_retries"}
def check_quality(self, path: Path) -> dict:
"""品檢"""
if not path.exists():
return {"ok": False, "reason": "file_not_exist"}
file_size = path.stat().st_size
if file_size < MIN_FILE_SIZE:
return {"ok": False, "reason": f"empty_file ({file_size}B)"}
try:
from PIL import Image
import numpy as np
img = Image.open(path)
arr = np.array(img.convert('RGB'))
mean_brightness = arr.mean()
if mean_brightness < MIN_BRIGHTNESS:
return {"ok": False, "reason": f"black_frame (mean={mean_brightness:.1f})"}
std_dev = arr.std()
if std_dev < MIN_STD_DEV:
return {"ok": False, "reason": f"low_contrast (std={std_dev:.1f})"}
return {"ok": True}
except ImportError:
# PIL not available, skip advanced quality check
return {"ok": True}
except Exception as e:
return {"ok": False, "reason": str(e)}
def write_summary(self, uuid: str, trace_counts: Dict[int, int], results: dict):
"""寫摘要報告"""
summary_path = self.faces_dir / uuid / "_summary.json"
summary = {
"file_uuid": uuid,
"timestamp": datetime.now().isoformat(),
"stats": self.stats,
"trace_counts": trace_counts,
"total_traces": len(trace_counts),
"failed_count": len(results["failed"]),
"failed_faces": results["failed"] if results["failed"] else None
}
with open(summary_path, "w") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"\n[SUMMARY] Written to {summary_path}")
def print_stats(self):
"""印統計"""
print(f"\n=== Statistics ===")
print(f"Total faces scanned: {self.stats['total_faces']}")
print(f"Filtered (low confidence < 0.6): {self.stats['low_confidence']}")
print(f"Filtered (too small < 20px): {self.stats['too_small']}")
print(f"Qualified (trace_id + eyes): {self.stats['qualified']}")
print(f"Successfully extracted: {self.stats['successful']}")
print(f"Failed: {self.stats['failed']}")
print(f"Skipped (no trace/eyes): {self.stats['skipped']}")
def main():
parser = argparse.ArgumentParser(
description="Extract face crops from videos",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument("--uuid", type=str, required=True,
help="File UUID to process")
parser.add_argument("--video", type=str,
help="Video file path (optional, will check DB if not provided)")
parser.add_argument("--output-dir", type=str,
default="/Users/accusys/momentry/output_dev",
help="Output directory (default: output_dev)")
args = parser.parse_args()
# Get video path
video_path = args.video
if not video_path:
# Query from DB
video_path = query_video_path_from_db(args.uuid)
if not video_path:
print(f"[ERROR] Video path not found for UUID: {args.uuid}")
sys.exit(1)
print(f"=== Face Crop Extraction ===")
print(f"UUID: {args.uuid}")
print(f"Video: {video_path}")
print(f"Output: {args.output_dir}/.faces/{args.uuid}/")
print()
extractor = FaceCropExtractor(args.output_dir)
results = extractor.process_video(args.uuid, video_path)
extractor.print_stats()
def query_video_path_from_db(uuid: str) -> Optional[str]:
"""從 PostgreSQL 取得影片路徑"""
psql_path = "/opt/homebrew/Cellar/libpq/18.3/bin/psql"
if not os.path.exists(psql_path):
return None
cmd = [
psql_path, "-U", "accusys", "-d", "momentry", "-t", "-A",
"-c", f"SELECT file_path FROM public.videos WHERE file_uuid = '{uuid}' LIMIT 1"
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
path = proc.stdout.strip()
return path if path else None
except Exception:
return None
if __name__ == "__main__":
main()