"""
Batch QA harness — swap many SOURCE faces onto every gender + location, save the
results, build a contact-sheet grid per location, and write a metrics report.
Put source faces in:
tests/faces/male/*.jpg tests/faces/female/*.jpg
(or pass --faces
). If empty, falls back to external/HF_weights/input/.
Run on the GPU machine:
python scripts/batch_test.py # face-only, all locations (fast)
python scripts/batch_test.py --hair # include HairFastGAN (slow!)
python scripts/batch_test.py --gender Male --limit 5
python scripts/batch_test.py --locations "ICT Department,Library"
Outputs:
tests/output///.jpg
tests/output///_grid.jpg (contact sheet)
tests/output/report.csv (alignment/blend/ΔE/naturalness)
"""
import os, sys, csv, glob, time, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
try:
from dotenv import load_dotenv; load_dotenv()
except Exception:
pass
import cv2
import numpy as np
from utils.image_io import resize_keep_aspect
from core.detector import detect_faces, _get_insightface
from core.swapper import swap_face_insightface
from core.super_res import restore_faces, _device
from core.head_swap import (full_head_swap, swap_hair, match_skin_to_source,
transfer_glasses)
from core.hair_transfer import transfer_hair
from core.blender import laplacian_blend
from core.segmentor import segment_hair_neck_skin
from core.quality_checker import compute_quality_score, _naturalness_score
from core import supabase_store
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FACES_DIR = os.path.join(ROOT, "tests", "faces")
OUT_DIR = os.path.join(ROOT, "tests", "output")
LOC_DIR = os.path.join(ROOT, "Location")
EXTS = (".jpg", ".jpeg", ".png", ".webp", ".JPG", ".JPEG", ".PNG")
def list_sources(gender, faces_dir):
d = os.path.join(faces_dir, gender.lower())
files = [f for f in sorted(glob.glob(os.path.join(d, "*"))) if f.endswith(EXTS)]
if not files: # fallback sample set
files = sorted(glob.glob(os.path.join(ROOT, "external", "HF_weights", "input", "*.png")))
return files
def list_locations(gender):
"""Locations with a usable target image (Supabase if enabled, else local)."""
if supabase_store.is_enabled():
return [(l["folder"], None) for l in supabase_store.list_locations(gender) if l["has_image"]]
out = []
gdir = os.path.join(LOC_DIR, gender)
if os.path.isdir(gdir):
for name in sorted(os.listdir(gdir)):
folder = os.path.join(gdir, name)
if os.path.isdir(folder):
imgs = [f for f in sorted(os.listdir(folder)) if f.endswith(EXTS)]
if imgs:
out.append((name, os.path.join(folder, imgs[0])))
return out
def load_target(gender, folder, local_path):
if local_path:
return cv2.imread(local_path)
data = supabase_store.get_image_bytes(gender, folder)
if not data:
return None
return cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
def run_swap(src, tgt, with_hair):
fs, ft = detect_faces(src), detect_faces(tgt)
if not fs or not ft:
return None, {}
base = tgt
hs = full_head_swap(src, tgt)
if hs is not None:
base = hs
sw = swap_face_insightface(src, base)
sw = restore_faces(sw)
sw = match_skin_to_source(sw, src, fs[0], ft[0], strength=0.75)
if with_hair:
try:
hf = transfer_hair(face_bgr=sw, shape_bgr=src, color_bgr=src)
if hf is not None:
pad = int(max(hf.shape[:2]) * 0.4)
hfp = cv2.copyMakeBorder(hf, pad, pad, pad, pad, cv2.BORDER_CONSTANT, value=(127, 127, 127))
sw = swap_hair(sw, hfp, sw, include_face=False)
except Exception as e:
print(" hair err:", e)
try:
sw = transfer_glasses(sw, src)
fm = segment_hair_neck_skin(sw).get("face_mask")
if fm is not None and fm.max() > 0:
sw = laplacian_blend(sw, tgt, fm, levels=4)
except Exception:
pass
q = compute_quality_score(sw, tgt, None, None)
return sw, q
def grid(images, cols=5, cell=220):
if not images:
return None
rows = (len(images) + cols - 1) // cols
canvas = np.full((rows * cell, cols * cell, 3), 240, np.uint8)
for i, im in enumerate(images):
r, c = divmod(i, cols)
thumb = resize_keep_aspect(im, cell - 8)
h, w = thumb.shape[:2]
y, x = r * cell + (cell - h) // 2, c * cell + (cell - w) // 2
canvas[y:y+h, x:x+w] = thumb
return canvas
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--faces", default=FACES_DIR)
ap.add_argument("--gender", choices=["Male", "Female", "both"], default="both")
ap.add_argument("--locations", default="", help="comma-separated location labels (default: all)")
ap.add_argument("--limit", type=int, default=0, help="max sources per gender (0 = all)")
ap.add_argument("--hair", action="store_true", help="include HairFastGAN (slow)")
args = ap.parse_args()
if _get_insightface() is None:
print("InsightFace not available — aborting."); return 1
print(f"device: {_device()} hair: {args.hair}")
genders = ["Male", "Female"] if args.gender == "both" else [args.gender]
want_locs = {s.strip().lower() for s in args.locations.split(",") if s.strip()}
os.makedirs(OUT_DIR, exist_ok=True)
rows = [("gender", "location", "source", "alignment", "blend", "delta_e", "naturalness", "seconds")]
total = 0
for gender in genders:
sources = list_sources(gender, args.faces)
if args.limit:
sources = sources[:args.limit]
locs = list_locations(gender)
if want_locs:
from core.detector import _get_cascade # noqa
def clean(n): import re; return re.sub(r"^\s*\d+\.\s*", "", n).strip().lower()
locs = [(f, p) for (f, p) in locs if clean(f) in want_locs]
print(f"\n== {gender}: {len(sources)} sources x {len(locs)} locations ==")
for folder, lpath in locs:
tgt = load_target(gender, folder, lpath)
if tgt is None:
print(f" [skip] {folder}: no target"); continue
tgt = resize_keep_aspect(tgt, 1536 if _device() == "cuda" else 1024)
odir = os.path.join(OUT_DIR, gender, folder.replace("/", "_"))
os.makedirs(odir, exist_ok=True)
results = []
for sp in sources:
src = resize_keep_aspect(cv2.imread(sp), 1536 if _device() == "cuda" else 1024)
name = os.path.splitext(os.path.basename(sp))[0]
t = time.time()
sw, q = run_swap(src, tgt, args.hair)
dt = time.time() - t
if sw is None:
print(f" [fail] {folder} <- {name}"); continue
cv2.imwrite(os.path.join(odir, f"{name}.jpg"), sw)
results.append(sw)
rows.append((gender, folder, name, q.get("alignment"), q.get("blend"),
q.get("delta_e"), q.get("naturalness"), round(dt, 1)))
total += 1
print(f" ok {folder} <- {name} ({dt:.1f}s) align={q.get('alignment')} nat={q.get('naturalness')}")
g = grid(results)
if g is not None:
cv2.imwrite(os.path.join(odir, "_grid.jpg"), g)
with open(os.path.join(OUT_DIR, "report.csv"), "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(rows)
print(f"\nDone — {total} swaps. Report: tests/output/report.csv")
return 0
if __name__ == "__main__":
sys.exit(main())