| """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-<tag>.png (the crop that would be sent) |
| - comparison/preview-face-crop-box-<tag>.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 |
|
|
| from app.services.gemini_client import ( |
| crop_to_box, |
| normalize_image_orientation_bytes, |
| resolve_face_and_crop, |
| ) |
| from app.services.photo_qc import get_photo_qc |
|
|
|
|
| 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 = 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()) |
|
|