458 lines
17 KiB
Python
458 lines
17 KiB
Python
"""Recognition tab — camera vision + face gallery + hot toggles.
|
||
|
||
Single router covering:
|
||
- Vision / Face Recognition toggles (hot — no Gemini restart needed)
|
||
- Live camera preview (latest JPEG drop)
|
||
- Face gallery CRUD: enroll, upload, capture, rename, delete, ZIP
|
||
- Per-photo download + delete
|
||
|
||
Toggle changes write data/.recognition_state.json atomically. The Gemini
|
||
child polls that file at 1 Hz and applies changes mid-session.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||
from pydantic import BaseModel
|
||
|
||
from Project.Sanad.config import BASE_DIR
|
||
from Project.Sanad.core.logger import get_logger
|
||
from Project.Sanad.dashboard.routes._safe_io import check_upload_size
|
||
from Project.Sanad.vision import recognition_state
|
||
|
||
log = get_logger("recognition_routes")
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ── paths (resolved from BASE_DIR) ──────────────────────────
|
||
|
||
STATE_PATH = BASE_DIR / "data" / ".recognition_state.json"
|
||
FACES_DIR = BASE_DIR / "data" / "faces"
|
||
|
||
ALLOWED_IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
|
||
|
||
|
||
# ── helpers ─────────────────────────────────────────────────
|
||
|
||
def _get_camera():
|
||
"""Lazy import to avoid circular import on dashboard load."""
|
||
try:
|
||
from Project.Sanad.main import camera # type: ignore
|
||
return camera
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _get_gallery():
|
||
"""Lazy import — same reason."""
|
||
try:
|
||
from Project.Sanad.main import gallery # type: ignore
|
||
return gallery
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _bump_and_write_state(**changes) -> recognition_state.RecognitionState:
|
||
"""Apply changes (vision_enabled, face_rec_enabled) and persist."""
|
||
return recognition_state.mutate(STATE_PATH, **changes)
|
||
|
||
|
||
def _bump_gallery_version() -> int:
|
||
cur = recognition_state.read(STATE_PATH)
|
||
new_version = cur.gallery_version + 1
|
||
recognition_state.mutate(STATE_PATH, gallery_version=new_version)
|
||
return new_version
|
||
|
||
|
||
# ── state + toggles ─────────────────────────────────────────
|
||
|
||
@router.get("/state")
|
||
async def get_state():
|
||
"""Return the current toggle/camera/gallery state."""
|
||
st = recognition_state.read(STATE_PATH)
|
||
cam = _get_camera()
|
||
gallery = _get_gallery()
|
||
faces_count = 0
|
||
photos_count = 0
|
||
if gallery is not None:
|
||
try:
|
||
entries = gallery.list()
|
||
faces_count = len(entries)
|
||
photos_count = sum(len(e.sample_paths) for e in entries)
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"vision_enabled": st.vision_enabled,
|
||
"face_rec_enabled": st.face_rec_enabled,
|
||
"gallery_version": st.gallery_version,
|
||
"camera": cam.status() if cam is not None else {
|
||
"running": False, "backend": None, "error": "camera subsystem unavailable"
|
||
},
|
||
"faces_count": faces_count,
|
||
"photos_count": photos_count,
|
||
}
|
||
|
||
|
||
@router.post("/vision")
|
||
async def set_vision(on: bool = Query(...)):
|
||
"""Enable / disable camera vision (hot — no Gemini restart)."""
|
||
cam = _get_camera()
|
||
if cam is None:
|
||
log.warning("vision toggle requested but camera subsystem unavailable")
|
||
raise HTTPException(503, "Camera subsystem not available.")
|
||
|
||
if on and not cam.is_running():
|
||
ok = cam.start()
|
||
if not ok:
|
||
log.warning("vision ON requested but camera.start() failed: %s",
|
||
cam.error or "no backend")
|
||
_bump_and_write_state(vision_enabled=False)
|
||
raise HTTPException(503,
|
||
f"Camera could not start (no backend). {cam.error or ''}")
|
||
elif (not on) and cam.is_running():
|
||
cam.stop()
|
||
|
||
st = _bump_and_write_state(vision_enabled=bool(on))
|
||
log.info("vision %s (backend=%s)", "ON" if on else "OFF",
|
||
cam.backend if cam.is_running() else "none")
|
||
return {"ok": True, "vision_enabled": st.vision_enabled,
|
||
"camera": cam.status()}
|
||
|
||
|
||
@router.post("/face-rec")
|
||
async def set_face_rec(on: bool = Query(...)):
|
||
"""Enable / disable face recognition (hot — no Gemini restart).
|
||
|
||
The Gemini child picks the change up within ~1 s: ON re-sends the
|
||
gallery primer and tells Gemini it can recognise people; OFF tells
|
||
Gemini to disregard the gallery and stop identifying anyone. Both
|
||
take effect on the live session — no reconnect needed.
|
||
"""
|
||
st = _bump_and_write_state(face_rec_enabled=bool(on))
|
||
log.info("face recognition %s", "ON" if on else "OFF")
|
||
return {"ok": True, "face_rec_enabled": st.face_rec_enabled}
|
||
|
||
|
||
@router.post("/sync")
|
||
async def sync_gallery():
|
||
"""Bump gallery_version so the child re-sends the primer if face-rec is ON."""
|
||
v = _bump_gallery_version()
|
||
log.info("gallery sync requested → v.%d", v)
|
||
return {"ok": True, "gallery_version": v}
|
||
|
||
|
||
# ── live preview ────────────────────────────────────────────
|
||
|
||
@router.get("/frame.jpg")
|
||
async def latest_frame():
|
||
"""Serve the most recent camera frame straight from the daemon's
|
||
in-memory cache (no file drop — frames are also pushed to the Gemini
|
||
child over its stdin)."""
|
||
cam = _get_camera()
|
||
if cam is None:
|
||
raise HTTPException(503, "Camera subsystem unavailable.")
|
||
jpeg = cam.snapshot_jpeg()
|
||
if not jpeg:
|
||
raise HTTPException(404, "No frame captured yet.")
|
||
return Response(
|
||
content=jpeg,
|
||
media_type="image/jpeg",
|
||
headers={"Cache-Control": "no-store, must-revalidate"},
|
||
)
|
||
|
||
|
||
# ── camera resolution / quality ─────────────────────────────
|
||
|
||
class CameraConfigPayload(BaseModel):
|
||
width: Optional[int] = None
|
||
height: Optional[int] = None
|
||
fps: Optional[int] = None
|
||
jpeg_quality: Optional[int] = None
|
||
|
||
|
||
@router.post("/camera-config")
|
||
async def set_camera_config(payload: CameraConfigPayload):
|
||
"""Hot-swap the camera capture profile (resolution / fps / JPEG quality).
|
||
|
||
If the camera is running, CameraDaemon.reconfigure() rebuilds the
|
||
pipeline at the new profile (~0.5 s gap). If idle, the values just
|
||
take effect on the next start. Bounds are sanity-checked here so a
|
||
fat-fingered value can't wedge the daemon."""
|
||
cam = _get_camera()
|
||
if cam is None:
|
||
raise HTTPException(503, "Camera subsystem unavailable.")
|
||
if payload.width is not None and not (160 <= payload.width <= 1920):
|
||
raise HTTPException(400, "width out of range (160–1920)")
|
||
if payload.height is not None and not (120 <= payload.height <= 1080):
|
||
raise HTTPException(400, "height out of range (120–1080)")
|
||
if payload.fps is not None and not (1 <= payload.fps <= 60):
|
||
raise HTTPException(400, "fps out of range (1–60)")
|
||
if payload.jpeg_quality is not None and not (10 <= payload.jpeg_quality <= 95):
|
||
raise HTTPException(400, "jpeg_quality out of range (10–95)")
|
||
profile = cam.reconfigure(
|
||
width=payload.width, height=payload.height,
|
||
fps=payload.fps, jpeg_quality=payload.jpeg_quality,
|
||
)
|
||
log.info("camera reconfigured via dashboard → %s", profile)
|
||
return {"ok": True, "profile": profile, "camera": cam.status()}
|
||
|
||
|
||
# ── face gallery routes ─────────────────────────────────────
|
||
|
||
def _validate_image(content: bytes, filename: str | None = None) -> None:
|
||
"""Reject non-JPEG/PNG content + oversize uploads."""
|
||
check_upload_size(content)
|
||
if len(content) < 16:
|
||
raise HTTPException(400, "Image too small / empty.")
|
||
is_jpeg = content[:3] == b"\xff\xd8\xff"
|
||
is_png = content[:8] == b"\x89PNG\r\n\x1a\n"
|
||
if not (is_jpeg or is_png):
|
||
raise HTTPException(
|
||
400,
|
||
f"Only JPEG/PNG accepted (got {filename or 'unknown'}).",
|
||
)
|
||
|
||
|
||
def _entry_to_dict(entry) -> dict:
|
||
photos = []
|
||
for p in entry.sample_paths:
|
||
try:
|
||
photos.append({"name": p.name, "size_bytes": p.stat().st_size})
|
||
except OSError:
|
||
continue
|
||
return {
|
||
"id": entry.id,
|
||
"name": entry.name,
|
||
"description": entry.description,
|
||
"added_at": entry.added_at,
|
||
"photos": photos,
|
||
}
|
||
|
||
|
||
@router.get("/faces")
|
||
async def list_faces():
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
entries = gallery.list()
|
||
return {"faces": [_entry_to_dict(e) for e in entries],
|
||
"total": len(entries)}
|
||
|
||
|
||
class RenamePayload(BaseModel):
|
||
name: Optional[str] = None
|
||
|
||
|
||
class DescribePayload(BaseModel):
|
||
description: Optional[str] = None
|
||
|
||
|
||
@router.post("/faces/enroll")
|
||
async def enroll_from_camera(name: Optional[str] = Query(default=None),
|
||
description: Optional[str] = Query(default=None)):
|
||
"""Create a new face from the camera's latest snapshot."""
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
cam = _get_camera()
|
||
if cam is None or not cam.is_running():
|
||
raise HTTPException(409, "Camera is not running. Toggle Vision ON first.")
|
||
# get_fresh_frame waits briefly for a current frame so the enrolled
|
||
# photo is the scene the user is posing for, not a stale buffer.
|
||
jpeg = cam.get_fresh_frame(max_age_s=0.5, timeout_s=1.5)
|
||
if not jpeg:
|
||
raise HTTPException(409, "Camera has no frame yet. Wait a moment and retry.")
|
||
entry = gallery.create_face(
|
||
[jpeg],
|
||
name=name.strip() if name else None,
|
||
description=description.strip() if description else None,
|
||
)
|
||
v = _bump_gallery_version()
|
||
log.info("enrolled face_%d via camera (name=%s, desc=%s, v.%d)",
|
||
entry.id, name or "(unnamed)",
|
||
"yes" if description else "no", v)
|
||
return {"ok": True, "face": _entry_to_dict(entry)}
|
||
|
||
|
||
@router.post("/faces/upload")
|
||
async def enroll_from_upload(
|
||
files: list[UploadFile] = File(...),
|
||
name: Optional[str] = Query(default=None),
|
||
description: Optional[str] = Query(default=None),
|
||
):
|
||
"""Create a new face from uploaded image file(s)."""
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
if not files:
|
||
raise HTTPException(400, "At least one image file required.")
|
||
image_bytes: list[bytes] = []
|
||
for f in files:
|
||
content = await f.read()
|
||
_validate_image(content, f.filename)
|
||
image_bytes.append(content)
|
||
entry = gallery.create_face(
|
||
image_bytes,
|
||
name=name.strip() if name else None,
|
||
description=description.strip() if description else None,
|
||
)
|
||
v = _bump_gallery_version()
|
||
log.info("enrolled face_%d via upload (%d photos, name=%s, desc=%s, v.%d)",
|
||
entry.id, len(image_bytes), name or "(unnamed)",
|
||
"yes" if description else "no", v)
|
||
return {"ok": True, "face": _entry_to_dict(entry)}
|
||
|
||
|
||
@router.post("/faces/{face_id}/capture")
|
||
async def capture_to_face(face_id: int):
|
||
"""Add a new sample (from the camera) to an existing face."""
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
cam = _get_camera()
|
||
if cam is None or not cam.is_running():
|
||
raise HTTPException(409, "Camera is not running. Toggle Vision ON first.")
|
||
jpeg = cam.get_fresh_frame(max_age_s=0.5, timeout_s=1.5)
|
||
if not jpeg:
|
||
raise HTTPException(409, "Camera has no frame yet.")
|
||
try:
|
||
fname = gallery.add_photo(face_id, jpeg)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(404, str(exc))
|
||
v = _bump_gallery_version()
|
||
log.info("captured new photo for face_%d → %s (v.%d)", face_id, fname, v)
|
||
return {"ok": True, "added": fname, "face": _entry_to_dict(gallery.get(face_id))}
|
||
|
||
|
||
@router.post("/faces/{face_id}/upload")
|
||
async def upload_to_face(face_id: int, files: list[UploadFile] = File(...)):
|
||
"""Add one or more uploaded samples to an existing face."""
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
if gallery.get(face_id) is None:
|
||
raise HTTPException(404, f"face_{face_id} not found")
|
||
added: list[str] = []
|
||
for f in files:
|
||
content = await f.read()
|
||
_validate_image(content, f.filename)
|
||
try:
|
||
fname = gallery.add_photo(face_id, content)
|
||
added.append(fname)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(404, str(exc))
|
||
v = _bump_gallery_version()
|
||
log.info("uploaded %d photo(s) to face_%d (v.%d)", len(added), face_id, v)
|
||
return {"ok": True, "added": added,
|
||
"face": _entry_to_dict(gallery.get(face_id))}
|
||
|
||
|
||
@router.post("/faces/{face_id}/rename")
|
||
async def rename_face(face_id: int, payload: RenamePayload):
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
try:
|
||
gallery.rename(face_id, payload.name)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(404, str(exc))
|
||
v = _bump_gallery_version()
|
||
log.info("renamed face_%d → %s (v.%d)", face_id,
|
||
payload.name or "(unnamed)", v)
|
||
return {"ok": True, "face": _entry_to_dict(gallery.get(face_id))}
|
||
|
||
|
||
@router.post("/faces/{face_id}/describe")
|
||
async def describe_face(face_id: int, payload: DescribePayload):
|
||
"""Set / clear a face's free-text description. The description is
|
||
folded into the Gemini primer turn so Gemini can reference it."""
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
try:
|
||
gallery.set_description(face_id, payload.description)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(404, str(exc))
|
||
v = _bump_gallery_version()
|
||
log.info("described face_%d (%s, v.%d)", face_id,
|
||
"set" if payload.description else "cleared", v)
|
||
return {"ok": True, "face": _entry_to_dict(gallery.get(face_id))}
|
||
|
||
|
||
@router.delete("/faces/{face_id}")
|
||
async def delete_face(face_id: int):
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
try:
|
||
gallery.delete_face(face_id)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(404, str(exc))
|
||
v = _bump_gallery_version()
|
||
log.info("deleted face_%d (v.%d)", face_id, v)
|
||
return {"ok": True, "deleted": face_id}
|
||
|
||
|
||
@router.delete("/faces/{face_id}/photo/{photo_name}")
|
||
async def delete_photo(face_id: int, photo_name: str):
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
# safe filename — only allow simple file names, no traversal
|
||
if "/" in photo_name or ".." in photo_name or "\x00" in photo_name:
|
||
raise HTTPException(400, "Invalid photo name.")
|
||
try:
|
||
gallery.delete_photo(face_id, photo_name)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(404, str(exc))
|
||
except ValueError as exc:
|
||
raise HTTPException(400, str(exc))
|
||
v = _bump_gallery_version()
|
||
log.info("deleted photo %s from face_%d (v.%d)", photo_name, face_id, v)
|
||
return {"ok": True, "deleted": photo_name}
|
||
|
||
|
||
@router.get("/faces/{face_id}/photo/{photo_name}")
|
||
async def get_photo(face_id: int, photo_name: str,
|
||
download: int = Query(default=0)):
|
||
"""Serve a single photo. Add ?download=1 for attachment disposition."""
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
if "/" in photo_name or ".." in photo_name or "\x00" in photo_name:
|
||
raise HTTPException(400, "Invalid photo name.")
|
||
path = gallery.get_photo(face_id, photo_name)
|
||
if path is None:
|
||
raise HTTPException(404, "Photo not found.")
|
||
media = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
||
headers = {}
|
||
if download:
|
||
headers["Content-Disposition"] = (
|
||
f'attachment; filename="face_{face_id}_{photo_name}"'
|
||
)
|
||
return FileResponse(path, media_type=media, headers=headers)
|
||
|
||
|
||
@router.get("/faces/{face_id}/download.zip")
|
||
async def download_face_zip(face_id: int):
|
||
gallery = _get_gallery()
|
||
if gallery is None:
|
||
raise HTTPException(503, "Face gallery subsystem unavailable.")
|
||
try:
|
||
data = gallery.zip_face(face_id)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(404, str(exc))
|
||
return StreamingResponse(
|
||
io.BytesIO(data),
|
||
media_type="application/zip",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="face_{face_id}.zip"',
|
||
"Content-Length": str(len(data)),
|
||
},
|
||
)
|