"""Offline face-crop preview — NO Gemini call, NO API key required. Loads an image, normalizes EXIF orientation, resolves the face/crop box (auto detection or a manual override), validates it, and writes: - crops/preview-face-crop-.png (the crop that would be sent) - comparison/preview-face-crop-box-.png (original w/ face+crop boxes | crop) so a human can confirm the crop is correct BEFORE spending a real Gemini call. Logs box coordinates only (no image data / no personal data, no API key). Exit codes: 0 valid crop, 2 invalid/refused (e.g. forehead-only or no face box). """ from __future__ import annotations import argparse import sys from io import BytesIO from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from PIL import Image, ImageDraw # noqa: E402 from app.services.gemini_client import ( # noqa: E402 crop_to_box, normalize_image_orientation_bytes, resolve_face_and_crop, ) from app.services.photo_qc import get_photo_qc # noqa: E402 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Offline face-crop preview (no API call)") parser.add_argument("--input-image", required=True) parser.add_argument("--evidence-root", default="runtime/gemini-smoke-evidence") parser.add_argument("--tag", default="crop-preview-01") args = parser.parse_args(argv) input_path = Path(args.input_image) if not input_path.exists(): print(f"REFUSED: input image not found: {input_path}") return 2 smoke = Path(args.evidence_root) / "gemini-smoke" crop_png = smoke / "crops" / f"preview-face-crop-{args.tag}.png" overlay_png = smoke / "comparison" / f"preview-face-crop-box-{args.tag}.png" for p in (crop_png, overlay_png): p.parent.mkdir(parents=True, exist_ok=True) image_bytes = normalize_image_orientation_bytes(input_path.read_bytes()) orig = Image.open(BytesIO(image_bytes)).convert("RGB") qc = get_photo_qc().check_input(image_bytes, "image/png") fallback = qc.face_boxes[0] if qc.passed and qc.face_boxes else None resolved = resolve_face_and_crop(image_bytes, orig.size, fallback) face_box = resolved["face_box"] crop_box = resolved["crop_box"] validation = resolved["validation"] # Overlay: original with face_box (green) and crop_box (red). overlay = orig.copy() draw = ImageDraw.Draw(overlay) if face_box: fx, fy, fw, fh = face_box draw.rectangle([fx, fy, fx + fw, fy + fh], outline=(0, 220, 0), width=4) draw.text((fx + 4, fy + 4), "face", fill=(0, 220, 0)) if crop_box: draw.rectangle(list(crop_box), outline=(255, 40, 40), width=4) draw.text((crop_box[0] + 4, crop_box[1] + 4), "crop", fill=(255, 40, 40)) panels = [("orig + boxes", overlay)] if crop_box: crop_img = Image.open(BytesIO(crop_to_box(image_bytes, crop_box))).convert("RGB") crop_img.save(crop_png) panels.append(("crop", crop_img)) h = 420 thumbs = [(lbl, im.resize((max(1, int(im.width * h / im.height)), h))) for lbl, im in panels] pad, label_h = 12, 26 total_w = sum(t.width for _, t in thumbs) + pad * (len(thumbs) + 1) canvas = Image.new("RGB", (total_w, h + label_h + pad * 2), (245, 245, 245)) cd = ImageDraw.Draw(canvas) x = pad for lbl, t in thumbs: canvas.paste(t, (x, pad + label_h)) cd.text((x, pad + 6), lbl, fill=(20, 20, 20)) x += t.width + pad canvas.save(overlay_png) print(f"image_size={orig.size}") print(f"face_box={face_box} (source={resolved['face_source']})") print(f"crop_box={crop_box} (source={resolved['crop_source']})") print(f"preview_crop={crop_png.name}") print(f"preview_overlay={overlay_png.name}") if validation.ok: print("crop_valid=YES") for w in validation.warnings: print(f"WARN: {w}") return 0 print(f"crop_valid=NO ({validation.hard_fail})") print('Set a manual crop box (normalized 0..1) and re-run, e.g.:') print(' $env:GEMINI_FACE_CROP_BOX_NORM = "0.25,0.30,0.75,0.85"') return 2 if __name__ == "__main__": sys.exit(main())