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