"""Face gallery — pure file IO over data/faces/face_{id}/. Layout per face: face_{id}/ face_1.jpg ← samples (≥1 required) face_2.jpg face_3.png meta.json ← optional: {"name": "...", "description": "...", "added_at": "..."} `description` is free text the operator writes about the person ("lead engineer, likes coffee") — it's folded into the Gemini primer turn so Gemini can reference it when it recognises that face. No ML — Gemini does the recognition in-context using the samples we feed it via the primer turn. This module's only jobs are: - enumerate enrolled faces - serve & accept JPEG/PNG bytes per face - rename / describe / delete / zip / load-for-primer Thread-safe via a single internal RLock. """ from __future__ import annotations import io import json import re import threading import zipfile from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Iterable from Project.Sanad.core.logger import get_logger log = get_logger("face_gallery") _DIR_RE = re.compile(r"^face_(\d+)$") ALLOWED_EXTS = {".jpg", ".jpeg", ".png"} SAMPLE_NAME_RE = re.compile(r"^face_(\d+)\.(jpg|jpeg|png)$", re.IGNORECASE) @dataclass class PhotoInfo: name: str size_bytes: int path: Path @dataclass class FaceEntry: id: int name: str | None added_at: str | None dir: Path description: str | None = None sample_paths: list[Path] = field(default_factory=list) def to_dict(self) -> dict: return { "id": self.id, "name": self.name, "description": self.description, "added_at": self.added_at, "dir": str(self.dir), "photos": [ {"name": p.name, "size_bytes": p.stat().st_size} for p in self.sample_paths if p.exists() ], } class FaceGallery: """File-system backed gallery rooted at `root` (e.g. data/faces/).""" def __init__(self, root: Path | str) -> None: self.root = Path(root) self._lock = threading.RLock() # ── read ──────────────────────────────────────────────── def _ensure_root(self) -> None: self.root.mkdir(parents=True, exist_ok=True) def _iter_face_dirs(self) -> Iterable[tuple[int, Path]]: if not self.root.exists(): return for child in sorted(self.root.iterdir()): if not child.is_dir(): continue m = _DIR_RE.match(child.name) if not m: continue yield int(m.group(1)), child def _samples_in(self, face_dir: Path) -> list[Path]: out: list[Path] = [] for p in sorted(face_dir.iterdir()): if p.is_file() and p.suffix.lower() in ALLOWED_EXTS: out.append(p) return out def _meta(self, face_dir: Path) -> tuple[str | None, str | None, str | None]: """Return (name, description, added_at) — any may be None.""" meta_path = face_dir / "meta.json" if not meta_path.exists(): return None, None, None try: data = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: return None, None, None name = data.get("name") description = data.get("description") added = data.get("added_at") return (name if name else None), (description if description else None), added def list(self) -> list[FaceEntry]: with self._lock: entries: list[FaceEntry] = [] for face_id, face_dir in self._iter_face_dirs(): name, description, added = self._meta(face_dir) entries.append(FaceEntry( id=face_id, name=name, description=description, added_at=added, dir=face_dir, sample_paths=self._samples_in(face_dir), )) return entries def get(self, face_id: int) -> FaceEntry | None: with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): return None name, description, added = self._meta(face_dir) return FaceEntry( id=face_id, name=name, description=description, added_at=added, dir=face_dir, sample_paths=self._samples_in(face_dir), ) def get_photo(self, face_id: int, photo_name: str) -> Path | None: with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): return None p = face_dir / photo_name try: p.resolve().relative_to(face_dir.resolve()) except ValueError: return None if not p.exists() or p.suffix.lower() not in ALLOWED_EXTS: return None return p # ── write ─────────────────────────────────────────────── def next_id(self) -> int: with self._lock: max_id = 0 for face_id, _ in self._iter_face_dirs(): if face_id > max_id: max_id = face_id return max_id + 1 def _next_sample_name(self, face_dir: Path, ext: str) -> str: """Return next free face_N. filename inside face_dir.""" existing = self._samples_in(face_dir) max_n = 0 for p in existing: m = SAMPLE_NAME_RE.match(p.name) if m: n = int(m.group(1)) if n > max_n: max_n = n return f"face_{max_n + 1}{ext.lower()}" @staticmethod def _detect_ext(jpeg_or_png: bytes) -> str: """Sniff PNG vs JPEG from the magic bytes.""" if len(jpeg_or_png) >= 8 and jpeg_or_png[:8] == b"\x89PNG\r\n\x1a\n": return ".png" return ".jpg" def _write_meta(self, face_dir: Path, name: str | None, description: str | None = None, added_at: str | None = None) -> None: meta: dict[str, str] = {} if name: meta["name"] = name if description: meta["description"] = description meta["added_at"] = added_at or datetime.now().isoformat(timespec="seconds") (face_dir / "meta.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8", ) def create_face(self, image_bytes_list: list[bytes], name: str | None = None, description: str | None = None) -> FaceEntry: """Create a new face_{next_id}/ with one or more samples.""" if not image_bytes_list: raise ValueError("create_face: empty image list") with self._lock: self._ensure_root() face_id = self.next_id() face_dir = self.root / f"face_{face_id}" face_dir.mkdir(parents=True, exist_ok=False) for idx, data in enumerate(image_bytes_list, start=1): ext = self._detect_ext(data) fname = f"face_{idx}{ext}" (face_dir / fname).write_bytes(data) clean_name = (name or "").strip() or None clean_desc = (description or "").strip() or None self._write_meta(face_dir, clean_name, clean_desc) log.info("Created face_%d (samples=%d, name=%s, desc=%s)", face_id, len(image_bytes_list), clean_name or "(unnamed)", "yes" if clean_desc else "no") return self.get(face_id) # type: ignore[return-value] def add_photo(self, face_id: int, image_bytes: bytes) -> str: """Append a new sample to an existing face. Returns the filename.""" with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): raise FileNotFoundError(f"face_{face_id} not found") ext = self._detect_ext(image_bytes) fname = self._next_sample_name(face_dir, ext) (face_dir / fname).write_bytes(image_bytes) log.info("Added sample %s to face_%d", fname, face_id) return fname def rename(self, face_id: int, name: str | None) -> None: """Update meta.json with a new name (or clear it if name is empty). Preserves the existing description + added_at. """ with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): raise FileNotFoundError(f"face_{face_id} not found") _, description, added = self._meta(face_dir) clean = (name or "").strip() or None self._write_meta(face_dir, clean, description, added_at=added) log.info("Renamed face_%d → %s", face_id, clean or "(unnamed)") def set_description(self, face_id: int, description: str | None) -> None: """Update meta.json with a free-text description (or clear it). Preserves the existing name + added_at. The description is folded into the Gemini primer turn so Gemini can reference it. """ with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): raise FileNotFoundError(f"face_{face_id} not found") name, _, added = self._meta(face_dir) clean = (description or "").strip() or None self._write_meta(face_dir, name, clean, added_at=added) log.info("Set description for face_%d (%s)", face_id, "cleared" if not clean else f"{len(clean)} chars") def delete_photo(self, face_id: int, photo_name: str) -> None: """Delete one photo. Refuses if it's the only remaining sample.""" with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): raise FileNotFoundError(f"face_{face_id} not found") samples = self._samples_in(face_dir) if len(samples) <= 1: raise ValueError( "Cannot delete the only photo — delete the face instead." ) target = self.get_photo(face_id, photo_name) if target is None: raise FileNotFoundError(f"photo {photo_name} not found") target.unlink() log.info("Deleted %s from face_%d", photo_name, face_id) def delete_face(self, face_id: int) -> None: """Delete the entire face_{id}/ folder (including meta.json).""" import shutil with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): raise FileNotFoundError(f"face_{face_id} not found") shutil.rmtree(face_dir) log.info("Deleted face_%d", face_id) def zip_face(self, face_id: int) -> bytes: """Return the entire face_{id}/ folder packaged as a ZIP.""" with self._lock: face_dir = self.root / f"face_{face_id}" if not face_dir.is_dir(): raise FileNotFoundError(f"face_{face_id} not found") buf = io.BytesIO() with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: for p in sorted(face_dir.iterdir()): if p.is_file(): zf.write(p, arcname=f"face_{face_id}/{p.name}") return buf.getvalue() # ── primer support (used by gemini/script.py) ─────────── def load_for_primer( self, max_samples_per_face: int = 3, resize_long_side: int = 256, ) -> list[tuple[FaceEntry, list[bytes]]]: """Return [(FaceEntry, [jpeg_bytes,…]), …] for Gemini upload. Resizes each sample to longest-side <= resize_long_side, re-encodes as JPEG (q=85) to keep the token cost manageable. Falls back to the raw bytes if PIL isn't available. """ entries = self.list() if not entries: return [] out: list[tuple[FaceEntry, list[bytes]]] = [] for e in entries: paths = e.sample_paths[:max_samples_per_face] jpegs: list[bytes] = [] for p in paths: try: raw = p.read_bytes() except OSError: continue processed = self._resize_for_primer(raw, resize_long_side) jpegs.append(processed or raw) if jpegs: out.append((e, jpegs)) return out @staticmethod def _resize_for_primer(raw: bytes, long_side: int) -> bytes | None: """Resize image to longest-side ≤ long_side, re-encode JPEG q=85. Returns None on any failure (caller falls back to raw bytes). """ try: from PIL import Image # type: ignore except Exception: return None try: img = Image.open(io.BytesIO(raw)) img.load() if img.mode not in ("RGB", "L"): img = img.convert("RGB") w, h = img.size scale = long_side / max(w, h) if max(w, h) > long_side else 1.0 if scale < 1.0: img = img.resize( (max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS, ) buf = io.BytesIO() img.save(buf, format="JPEG", quality=85, optimize=True) return buf.getvalue() except Exception: return None