diff --git a/CHANGELOG.md b/CHANGELOG.md index 722da98..76a9aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 5.1.0 — 2026-08-11 + +- **Temporary Guest Access:** create a guest from one quality-checked photo, limit + recognition to a date/time window, selected cameras and an entry count, then revoke + or delete it immediately from a friendly UI. Guest matching uses a stricter score and + margin; liveness plus a second factor are mandatory and FaceID never unlocks alone. +- **Site map and estimated routes:** drag cameras onto a responsive site canvas, connect + plausible transitions and see each person's last observed camera. Saved links also + constrain live scenario and appearance-ReID paths; the UI clearly labels locations as + estimates rather than GPS positions. +- **Anonymous traffic analytics:** camera/zone traffic share, peak hour and common + transitions are calculated from Frigate person events without using identities or + face images in the aggregate calculations. +- **Route clip playlist:** every visit can play its Frigate recognition clips in + chronological camera order. The player shows route progress, advances automatically + and skips expired/missing clips without stopping the remaining journey. +- **Operational backup:** guest templates, guest audit decisions and the site map are + included in safe backups and restores. + ## 5.0.4 — 2026-08-11 - **Camera participation from the UI:** every Frigate camera can now be enabled or diff --git a/README.md b/README.md index 19ffb3c..469ffca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # FaceID — self-hosted face recognition for Frigate + Home Assistant -## FaceID 5.0: a product workflow for homes and offices +## FaceID 5.1: a product workflow for homes and offices + +5.1 adds temporary guest passes with strict liveness/second-factor boundaries, a +drag-and-drop camera site map, anonymous Frigate person-traffic analytics and a visit +player that plays recognition clips in camera order. Map locations are explicitly +estimated, and face recognition alone never authorizes a door unlock. Version 5 adds a dedicated **Users** area for normal day-to-day operation. Create a person, upload 5–10 photos, see a plain-language quality result for each photo, rename diff --git a/app/__init__.py b/app/__init__.py index aca1843..b5651b8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -VERSION = "5.0.4" +VERSION = "5.1.0" diff --git a/app/access_control.py b/app/access_control.py index 7408497..c91c7f5 100644 --- a/app/access_control.py +++ b/app/access_control.py @@ -11,15 +11,15 @@ ROLE_TABS = { "admin": ["*"], "operator": [ - "dashboard", "users", "unknowns", "visits", "intercom", "liveness", + "dashboard", "users", "guests", "site-map", "unknowns", "visits", "intercom", "liveness", "automations", "activity", "calibration", "privacy", "health-center", ], - "viewer": ["dashboard", "visits", "liveness", "activity"], + "viewer": ["dashboard", "site-map", "visits", "liveness", "activity"], } MUTATING_PREFIXES = { "operator": ( "/api/persons", "/api/unknowns", "/api/audit", - "/api/intercom", "/api/liveness", + "/api/intercom", "/api/liveness", "/api/guests", "/api/site-map", ), "viewer": (), } diff --git a/app/audit.py b/app/audit.py index 51ce299..1765ef9 100644 --- a/app/audit.py +++ b/app/audit.py @@ -759,6 +759,17 @@ def dashboard_summary(self): "processing": counts.get("processing", 0), } + def traffic_events(self, *, after_ts: float, limit: int = 10000) -> list[dict]: + """Anonymous person-event facts for zone traffic analytics.""" + with self._lock, self._connection() as con: + con.row_factory = sqlite3.Row + rows = con.execute( + "SELECT event_id, camera, start_ts, end_ts, status FROM events " + "WHERE start_ts>=? AND status!='processing' ORDER BY start_ts LIMIT ?", + (float(after_ts), max(1, min(int(limit), 100000))), + ).fetchall() + return [dict(row) for row in rows] + def labeled_events(self): with self._lock, self._connection() as con: con.row_factory = sqlite3.Row diff --git a/app/backup_util.py b/app/backup_util.py index f0d6835..ae2f88c 100644 --- a/app/backup_util.py +++ b/app/backup_util.py @@ -13,10 +13,11 @@ log = logging.getLogger("faceid.backup") # Nur die unersetzliche Handarbeit sichern — nicht die Unknown-Queue oder Frigate-Vollbilder. -BACKUP_SUBDIRS = ("persons", "ignored", "body") +BACKUP_SUBDIRS = ("persons", "ignored", "body", "guests") BACKUP_FILES = ( "settings.json", "learning_runs.json", "frigate_sync.json", - "camera_profiles.json", "access_control.json", "schema.json", + "camera_profiles.json", "access_control.json", "guest_access.json", + "site_map.json", "schema.json", ) @@ -60,8 +61,8 @@ def build_backup_gz(data_dir: Path) -> bytes: if audit.is_file(): _add_audit_snapshot(tar, audit) manifest = json.dumps({ - "format": 5, "created": time.time(), - "includes": [*BACKUP_SUBDIRS, "settings", "learning-runs", "sync-ledger", "camera-profiles", "access-policy", "schema", "audit-history"], + "format": 6, "created": time.time(), + "includes": [*BACKUP_SUBDIRS, "settings", "learning-runs", "sync-ledger", "camera-profiles", "access-policy", "guest-access", "site-map", "schema", "audit-history"], "excludes": ["frigate-credentials", "mqtt-credentials", "clips", "media-cache"], "restore_note": "body classifier is rebuilt from reviewed material after restore", }, indent=2).encode("utf-8") diff --git a/app/guest_access.py b/app/guest_access.py new file mode 100644 index 0000000..842c8aa --- /dev/null +++ b/app/guest_access.py @@ -0,0 +1,170 @@ +"""Temporary guest identities and fail-closed access eligibility decisions.""" +from __future__ import annotations + +import json +import os +import shutil +import threading +import time +import uuid +from pathlib import Path + +import cv2 +import numpy as np + + +class GuestAccess: + def __init__(self, data_dir: Path, *, threshold: float = .62, margin: float = .12): + self.root = data_dir / "guests" + self.root.mkdir(parents=True, exist_ok=True) + self.events_path = data_dir / "guest_access.json" + self.threshold = max(.5, float(threshold)) + self.margin = max(.08, float(margin)) + self._lock = threading.RLock() + + @staticmethod + def _atomic_json(path: Path, value) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush(); os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + def _meta(self, guest_id: str) -> dict | None: + path = self.root / guest_id / "meta.json" + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else None + except (OSError, json.JSONDecodeError): + return None + + @staticmethod + def status(meta: dict, now: float | None = None) -> str: + now = float(now or time.time()) + if meta.get("revoked"): + return "revoked" + if now < float(meta.get("valid_from") or 0): + return "future" + if now > float(meta.get("valid_until") or 0): + return "expired" + maximum = int(meta.get("max_entries") or 0) + if maximum and int(meta.get("entries_used") or 0) >= maximum: + return "used" + return "active" + + def list(self) -> list[dict]: + with self._lock: + result = [] + for folder in self.root.iterdir(): + if not folder.is_dir(): + continue + meta = self._meta(folder.name) + if not meta: + continue + result.append({**meta, "status": self.status(meta), "photo": f"media/guests/{folder.name}/face.jpg"}) + return sorted(result, key=lambda item: (item["status"] != "active", item.get("valid_from", 0))) + + def create(self, *, name: str, valid_from: float, valid_until: float, + max_entries: int, allowed_cameras: list[str], crop, embedding) -> dict: + name = str(name).strip() + if not name or len(name) > 100: + raise ValueError("guest name must contain 1-100 characters") + if float(valid_until) <= float(valid_from): + raise ValueError("valid_until must be after valid_from") + if float(valid_until) - float(valid_from) > 90 * 86400: + raise ValueError("guest access may not exceed 90 days") + guest_id = uuid.uuid4().hex[:12] + folder = self.root / guest_id + folder.mkdir() + meta = { + "id": guest_id, "name": name, "valid_from": float(valid_from), + "valid_until": float(valid_until), "max_entries": max(1, min(int(max_entries), 1000)), + "entries_used": 0, "allowed_cameras": sorted({str(x) for x in allowed_cameras if str(x)}), + "revoked": False, "created_ts": time.time(), + } + try: + if not cv2.imwrite(str(folder / "face.jpg"), crop, [cv2.IMWRITE_JPEG_QUALITY, 92]): + raise ValueError("could not store guest face") + with (folder / "embedding.npy").open("wb") as handle: + np.save(handle, np.asarray(embedding, dtype=np.float32)) + handle.flush(); os.fsync(handle.fileno()) + self._atomic_json(folder / "meta.json", meta) + except Exception: + shutil.rmtree(folder, ignore_errors=True) + raise + return {**meta, "status": self.status(meta), "photo": f"media/guests/{guest_id}/face.jpg"} + + def revoke(self, guest_id: str) -> dict: + with self._lock: + meta = self._meta(guest_id) + if not meta: + raise KeyError(guest_id) + meta["revoked"] = True + self._atomic_json(self.root / guest_id / "meta.json", meta) + return {**meta, "status": self.status(meta)} + + def delete(self, guest_id: str) -> None: + with self._lock: + folder = (self.root / guest_id).resolve() + if folder.parent != self.root.resolve() or not folder.is_dir(): + raise KeyError(guest_id) + shutil.rmtree(folder) + + def candidates(self, embedding, *, camera: str, now: float | None = None, limit: int = 2) -> list[tuple[str, str, float]]: + now = float(now or time.time()) + rows = [] + with self._lock: + for meta in self.list(): + if self.status(meta, now) != "active": + continue + allowed = meta.get("allowed_cameras") or [] + if allowed and camera not in allowed: + continue + try: + reference = np.load(self.root / meta["id"] / "embedding.npy") + score = float(np.asarray(reference, dtype=np.float32) @ np.asarray(embedding, dtype=np.float32)) + except (OSError, ValueError): + continue + rows.append((f"guest:{meta['id']}", meta["name"], score)) + return sorted(rows, key=lambda item: item[2], reverse=True)[:max(1, limit)] + + def evaluate(self, guest_id: str, *, camera: str, score: float, runner_up_score: float, + liveness_confirmed: bool, second_factor: bool, event_id: str | None = None) -> dict: + """Return and audit eligibility. Only a complete decision consumes an entry.""" + with self._lock: + meta = self._meta(guest_id) + if not meta: + raise KeyError(guest_id) + reasons = [] + if self.status(meta) != "active": reasons.append("guest_not_active") + if meta.get("allowed_cameras") and camera not in meta["allowed_cameras"]: reasons.append("camera_not_allowed") + if float(score) < self.threshold: reasons.append("score_too_low") + if float(score) - float(runner_up_score) < self.margin: reasons.append("margin_too_low") + if not liveness_confirmed: reasons.append("liveness_required") + if not second_factor: reasons.append("second_factor_required") + authorized = not reasons + if authorized: + meta["entries_used"] = int(meta.get("entries_used") or 0) + 1 + self._atomic_json(self.root / guest_id / "meta.json", meta) + event = { + "id": uuid.uuid4().hex, "guest_id": guest_id, "guest_name": meta["name"], + "event_id": event_id, "camera": camera, "score": round(float(score), 4), + "authorized": authorized, "reasons": reasons, "ts": time.time(), + } + try: + history = json.loads(self.events_path.read_text(encoding="utf-8")) + if not isinstance(history, list): history = [] + except (OSError, json.JSONDecodeError): + history = [] + self._atomic_json(self.events_path, [event, *history[:999]]) + return {**event, "entries_used": meta["entries_used"], "entries_left": max(0, int(meta["max_entries"]) - int(meta["entries_used"]))} + + def history(self, limit: int = 100) -> list[dict]: + try: + rows = json.loads(self.events_path.read_text(encoding="utf-8")) + return rows[:max(1, min(int(limit), 1000))] if isinstance(rows, list) else [] + except (OSError, json.JSONDecodeError): + return [] diff --git a/app/main.py b/app/main.py index d0f5b7d..ef0b8f9 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,8 @@ from .visits import VisitService from .liveness import LivenessDetector from .access_control import AccessControl +from .guest_access import GuestAccess +from .site_intelligence import SiteIntelligence from .migrations import run_migrations BASE = Path(__file__).resolve().parent.parent @@ -151,6 +153,14 @@ def main(): data_dir / "access_control.json", enabled=bool(f.get("access_control_enabled", False)), ) + processor.guest_access = GuestAccess( + data_dir, + threshold=float(f.get("guest_match_threshold", max(.62, processor.match_thr + .08))), + margin=float(f.get("guest_match_margin", max(.12, processor.match_margin))), + ) + processor.site_intelligence = SiteIntelligence( + data_dir / "site_map.json", audit, processor.camera_profiles, processor.visits, + ) processor.liveness = LivenessDetector( model_path=str(f.get("liveness_model_path") or "/opt/faceid/models/liveness.onnx"), enabled=bool(f.get("liveness_enabled", True)), diff --git a/app/mqtt_listener.py b/app/mqtt_listener.py index 33c89ed..bb46c35 100644 --- a/app/mqtt_listener.py +++ b/app/mqtt_listener.py @@ -481,7 +481,19 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna if st["liveness"].get("confirmed"): st["liveness_blocked"] = False emb = face.normed_embedding - candidates = self.gallery.match_candidates(emb, limit=2) + candidates = self.gallery.match_candidates(emb, limit=3) + guest_access = getattr(self, "guest_access", None) + if guest_access is not None: + guest_candidates = guest_access.candidates( + emb, camera=st["camera"], now=st.get("start_time"), limit=2, + ) + combined = sorted([*candidates, *guest_candidates], key=lambda item: item[2], reverse=True) + if combined and str(combined[0][0]).startswith("guest:"): + lead = float(combined[0][2]) - float(combined[1][2] if len(combined) > 1 else 0) + if float(combined[0][2]) >= guest_access.threshold and lead >= guest_access.margin: + candidates = combined[:2] + else: + candidates = [item for item in combined if not str(item[0]).startswith("guest:")][:2] slug, name, score = candidates[0] if candidates else (None, None, 0.0) _, runner_up, runner_up_score = ( candidates[1] if len(candidates) > 1 else (None, None, 0.0) @@ -545,6 +557,21 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna st["best_score"], st["best_person"] = decision.score, decision.person st["done"] = True st["final_decision"] = decision + if decision.slug and str(decision.slug).startswith("guest:"): + guest_id = str(decision.slug).split(":", 1)[1] + st["guest"] = {"id": guest_id, "name": decision.person} + access_result = guest_access.evaluate( + guest_id, camera=st["camera"], score=decision.score, + runner_up_score=decision.runner_up_score, + liveness_confirmed=bool((st.get("liveness") or {}).get("confirmed")), + second_factor=False, event_id=eid, + ) + st["guest_access"] = access_result + if self.client: + self.client.publish( + f"{self.prefix}/v1/guest_access", + json.dumps(access_result, ensure_ascii=False), retain=False, + ) self._publish_recognition( eid, st, decision.person, decision.score, decision=decision ) @@ -818,6 +845,8 @@ def _post_event( "scenario": scenario, "body": st.get("body"), "liveness": st.get("liveness"), + "guest": st.get("guest"), + "guest_access": st.get("guest_access"), } if self.client and st.get("body"): self.client.publish( diff --git a/app/site_intelligence.py b/app/site_intelligence.py new file mode 100644 index 0000000..4b03143 --- /dev/null +++ b/app/site_intelligence.py @@ -0,0 +1,100 @@ +"""Honest camera-zone map and anonymous traffic analytics.""" +from __future__ import annotations + +import json +import os +import threading +import time +import uuid +from collections import Counter +from pathlib import Path + + +class SiteIntelligence: + def __init__(self, path: Path, audit, camera_profiles, visits): + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + self.audit = audit + self.camera_profiles = camera_profiles + self.visits = visits + self._lock = threading.RLock() + + def _read(self) -> dict: + try: + value = json.loads(self.path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + def _write(self, value: dict) -> None: + temporary = self.path.with_name(f".{self.path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush(); os.fsync(handle.fileno()) + os.replace(temporary, self.path) + finally: + temporary.unlink(missing_ok=True) + + def map(self, cameras: list[str]) -> dict: + stored = self._read() + nodes = stored.get("cameras") if isinstance(stored.get("cameras"), dict) else {} + count = max(1, len(cameras)) + result = [] + for index, camera in enumerate(cameras): + saved = nodes.get(camera) if isinstance(nodes.get(camera), dict) else {} + profile = self.camera_profiles.get(camera) + result.append({ + "camera": camera, "label": saved.get("label") or camera, + "x": float(saved.get("x", 10 + (index % 4) * 26)), + "y": float(saved.get("y", 15 + (index // 4) * 28)), + "role": profile.get("role", "observation"), + "enabled": profile.get("enabled", True), + }) + links = stored.get("links") if isinstance(stored.get("links"), list) else [] + return {"title": stored.get("title") or "האתר שלי", "cameras": result, "links": links, + "notice": "המיקום והמסלול משוערים לפי מצלמות וזמנים — אינם GPS."} + + def update(self, body: dict, cameras: list[str]) -> dict: + known = set(cameras) + title = str(body.get("title") or "האתר שלי").strip()[:100] + nodes = {} + for item in body.get("cameras") or []: + camera = str(item.get("camera") or "") + if camera not in known: continue + nodes[camera] = {"label": str(item.get("label") or camera)[:100], + "x": max(2, min(float(item.get("x", 50)), 98)), + "y": max(4, min(float(item.get("y", 50)), 96))} + links, seen = [], set() + for link in body.get("links") or []: + if not isinstance(link, list) or len(link) != 2: continue + a, b = str(link[0]), str(link[1]) + key = tuple(sorted((a, b))) + if a in known and b in known and a != b and key not in seen: + links.append([a, b]); seen.add(key) + with self._lock: self._write({"title": title, "cameras": nodes, "links": links}) + return self.map(cameras) + + def analytics(self, *, cameras: list[str], days: int = 7) -> dict: + days = max(1, min(int(days), 90)) + rows = self.audit.traffic_events(after_ts=time.time() - days * 86400) + counts = Counter(row["camera"] for row in rows) + hours = Counter(time.localtime(float(row.get("start_ts") or 0)).tm_hour for row in rows) + transitions = Counter() + # Identity is used only transiently to join events into visits; output is anonymous. + for visit in self.visits.list(days=days, limit=1000): + route = visit.get("route") or [] + for a, b in zip(route, route[1:]): + if a != b: transitions[(a, b)] += 1 + peak = max(hours, key=hours.get) if hours else None + total = sum(counts.values()) + return { + "days": days, "total_person_events": total, "peak_hour": peak, + "cameras": [{"camera": camera, "events": counts[camera], + "share": round(counts[camera] / total, 4) if total else 0} + for camera in cameras], + "hours": [{"hour": hour, "events": hours[hour]} for hour in range(24)], + "transitions": [{"from": a, "to": b, "count": count} + for (a, b), count in transitions.most_common(20)], + "privacy": "Aggregates contain counts and routes only; no face image or identity is returned.", + } diff --git a/app/visits.py b/app/visits.py index ab7fa92..b3dc8de 100644 --- a/app/visits.py +++ b/app/visits.py @@ -34,7 +34,7 @@ def list(self, *, person: str | None = None, days: int = 30, limit: int = 200): "end_ts": float(event.get("end_ts") or event["start_ts"]), "first_camera": event["camera"], "last_camera": event["camera"], - "route": [], "events": [], "scores": [], + "route": [], "events": [], "timeline": [], "scores": [], "arrival": "confirmed" if starts_entry else "observed", "departure": "not_observed", } @@ -48,6 +48,10 @@ def list(self, *, person: str | None = None, days: int = 30, limit: int = 200): if not previous["route"] or previous["route"][-1] != event["camera"]: previous["route"].append(event["camera"]) previous["events"].append(event["event_id"]) + previous["timeline"].append({ + "event_id": event["event_id"], "camera": event["camera"], + "start_ts": float(event["start_ts"]), + }) previous["scores"].append(float(event.get("score") or 0)) if role in {"exit", "entry_exit"}: previous["departure"] = "confirmed" diff --git a/app/webui.py b/app/webui.py index 0c56d67..6c29e7a 100644 --- a/app/webui.py +++ b/app/webui.py @@ -75,7 +75,7 @@ async def role_guard(request, call_next): @app.get("/media/{kind}/{item_path:path}") def media(kind: str, item_path: str): """Serve only gallery JPEGs; never expose embeddings, settings or backups.""" - if kind not in {"persons", "unknowns", "ignored"}: + if kind not in {"persons", "unknowns", "ignored", "guests"}: raise HTTPException(404, "Unknown media collection") base = (data_dir / kind).resolve() target = (base / item_path).resolve() @@ -604,7 +604,7 @@ async def restore(file: UploadFile, merge: bool = False): except tarfile.TarError: raise HTTPException(400, "Not a valid .tar.gz backup") members = tar.getmembers() - allowed_roots = {"persons", "ignored", "body", "system", "manifest.json"} + allowed_roots = {"persons", "ignored", "body", "guests", "system", "manifest.json"} for m in members: norm = Path(m.name) if (m.name.startswith("/") or ".." in norm.parts or @@ -614,7 +614,7 @@ async def restore(file: UploadFile, merge: bool = False): raise HTTPException(400, "Executable body models are never accepted from backups; restore material and retrain") if not merge: write_backup_file(data_dir, data_dir / "backups" / "before-restore") - for sub in ("persons", "ignored", "body"): + for sub in ("persons", "ignored", "body", "guests"): d = data_dir / sub if d.exists(): for f in d.rglob("*"): @@ -633,7 +633,8 @@ async def restore(file: UploadFile, merge: bool = False): if norm.parts[0] == "system": if len(norm.parts) != 2 or norm.parts[1] not in { "settings.json", "learning_runs.json", "frigate_sync.json", - "camera_profiles.json", "access_control.json", "schema.json", + "camera_profiles.json", "access_control.json", "guest_access.json", + "site_map.json", "schema.json", "audit.db" }: continue @@ -1156,6 +1157,131 @@ def _camera_names(): names.update(processor.frigate.cameras()) return sorted(name for name in names if name) + @app.get("/api/guests") + def guests(): + service = getattr(processor, "guest_access", None) + if service is None: + raise HTTPException(503, "Guest access is unavailable") + return { + "guests": service.list(), "history": service.history(50), + "threshold": service.threshold, "margin": service.margin, + "safety": "A face match creates eligibility only. Liveness and a second factor are required before entry is authorized.", + } + + @app.post("/api/guests") + async def create_guest(name: str, valid_from: float, valid_until: float, + max_entries: int = 1, cameras: str = "", file: UploadFile = None): + service = getattr(processor, "guest_access", None) + if service is None: + raise HTTPException(503, "Guest access is unavailable") + if file is None: + raise HTTPException(400, "A clear guest photo is required") + raw = await file.read() + if len(raw) > 15_000_000: + raise HTTPException(413, "Photo is larger than 15 MB") + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + raise HTTPException(400, "The uploaded file is not a readable image") + if max(image.shape[:2]) > 2400: + scale = 2400 / max(image.shape[:2]); image = cv2.resize(image, None, fx=scale, fy=scale) + face, image = find_face_padded(engine, image, min_px=100) + if face is None: + raise HTTPException(400, "No clear face of at least 100 pixels was found") + quality = measure_face_quality(image, face, min_face_px=100, min_quality=max(.4, processor.min_face_quality)) + if not quality.usable: + raise HTTPException(400, "The face is not sharp, bright or frontal enough for temporary access") + selected = [item for item in cameras.split(",") if item] + unknown = set(selected) - set(_camera_names()) + if unknown: + raise HTTPException(400, f"Unknown cameras: {', '.join(sorted(unknown))}") + try: + guest = service.create( + name=name, valid_from=valid_from, valid_until=valid_until, + max_entries=max_entries, allowed_cameras=selected, + crop=crop_face(image, face.bbox), embedding=face.normed_embedding, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "guest": guest, "quality": quality.to_dict()} + + @app.post("/api/guests/{guest_id}/revoke") + def revoke_guest(guest_id: str): + try: + return {"ok": True, "guest": processor.guest_access.revoke(guest_id)} + except KeyError as exc: + raise HTTPException(404, "Unknown guest") from exc + + @app.delete("/api/guests/{guest_id}") + def delete_guest(guest_id: str): + try: + processor.guest_access.delete(guest_id) + except KeyError as exc: + raise HTTPException(404, "Unknown guest") from exc + return {"ok": True} + + @app.post("/api/guests/{guest_id}/authorize") + def authorize_guest(guest_id: str, body: dict): + event_id = str(body.get("event_id") or "") + detail = processor.audit.event_detail(event_id) if processor.audit and event_id else None + pending = next((item for item in processor.guest_access.history(1000) + if item.get("guest_id") == guest_id and item.get("event_id") == event_id), None) + guest = next((item for item in processor.guest_access.list() if item["id"] == guest_id), None) + if not detail or not pending or not guest: + raise HTTPException(400, "No matching server-verified guest recognition was found") + event = detail["event"] + if event.get("status") != "recognized" or event.get("person") != guest["name"]: + raise HTTPException(400, "The event is not a finalized recognition for this guest") + try: + return processor.guest_access.evaluate( + guest_id, camera=str(event.get("camera") or ""), + score=float(event.get("score") or 0), + runner_up_score=max(0.0, float(event.get("score") or 0) - float(event.get("margin") or 0)), + liveness_confirmed=event.get("liveness_status") == "live", + second_factor=bool(body.get("second_factor")), + event_id=event_id, + ) + except KeyError as exc: + raise HTTPException(404, "Unknown guest") from exc + + @app.get("/api/site-map") + def site_map(days: int = 7): + service = getattr(processor, "site_intelligence", None) + if service is None: + raise HTTPException(503, "Site intelligence is unavailable") + cameras = _camera_names() + visits = processor.visits.list(days=max(1, min(days, 30)), limit=50) + latest = {} + for visit in visits: + latest.setdefault(visit["person"], visit) + return { + "map": service.map(cameras), + "analytics": service.analytics(cameras=cameras, days=days), + "people": [{ + "person": item["person"], "camera": item["last_camera"], + "last_seen": item["end_ts"], "open": item["open"], + "route": item["route"], "timeline": item.get("timeline", []), + } for item in latest.values()], + } + + @app.post("/api/site-map") + def save_site_map(body: dict): + service = getattr(processor, "site_intelligence", None) + if service is None: + raise HTTPException(503, "Site intelligence is unavailable") + try: + updated = service.update(body, _camera_names()) + graph = {camera: set() for camera in _camera_names()} + for left, right in updated.get("links", []): + graph.setdefault(left, set()).add(right) + graph.setdefault(right, set()).add(left) + if getattr(processor, "scenario_manager", None) is not None: + processor.scenario_manager.camera_graph = graph + if getattr(processor, "reid", None) is not None: + processor.reid.camera_graph = graph + return {"ok": True, "map": updated} + except (TypeError, ValueError) as exc: + raise HTTPException(400, str(exc)) from exc + @app.get("/api/cameras/studio") def camera_studio(days: int = 7): profiles = getattr(processor, "camera_profiles", None) diff --git a/docs/example-config.yaml b/docs/example-config.yaml index 96513da..d5bf7eb 100644 --- a/docs/example-config.yaml +++ b/docs/example-config.yaml @@ -73,6 +73,8 @@ faceid: suggest_threshold: 0.40 set_sub_label: false # opt-in: write confirmed names back to Frigate events access_control_enabled: false # 5.0 role foundation; enable only after assignments are configured + guest_match_threshold: 0.62 # stricter than the resident recognition default + guest_match_margin: 0.12 # guest must clearly lead every other identity presence_window: 120 # camera sensor shows everyone seen within this window (s) calibration_target_far: 0.01 # maximum false-accept rate for recommendations scenario_window: 90 # seconds for a visit to continue across cameras diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md index 722da98..76a9aa8 100644 --- a/faceid-addon/CHANGELOG.md +++ b/faceid-addon/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 5.1.0 — 2026-08-11 + +- **Temporary Guest Access:** create a guest from one quality-checked photo, limit + recognition to a date/time window, selected cameras and an entry count, then revoke + or delete it immediately from a friendly UI. Guest matching uses a stricter score and + margin; liveness plus a second factor are mandatory and FaceID never unlocks alone. +- **Site map and estimated routes:** drag cameras onto a responsive site canvas, connect + plausible transitions and see each person's last observed camera. Saved links also + constrain live scenario and appearance-ReID paths; the UI clearly labels locations as + estimates rather than GPS positions. +- **Anonymous traffic analytics:** camera/zone traffic share, peak hour and common + transitions are calculated from Frigate person events without using identities or + face images in the aggregate calculations. +- **Route clip playlist:** every visit can play its Frigate recognition clips in + chronological camera order. The player shows route progress, advances automatically + and skips expired/missing clips without stopping the remaining journey. +- **Operational backup:** guest templates, guest audit decisions and the site map are + included in safe backups and restores. + ## 5.0.4 — 2026-08-11 - **Camera participation from the UI:** every Frigate camera can now be enabled or diff --git a/faceid-addon/DOCS.md b/faceid-addon/DOCS.md index 398664f..c1333a9 100644 --- a/faceid-addon/DOCS.md +++ b/faceid-addon/DOCS.md @@ -32,7 +32,13 @@ Full documentation: https://github.com/r11a/faceid 5. Open the **FaceID** panel in the sidebar. Recommended first step: run the backfill (see main README) or just wait — every detected unknown face shows up for review. -## First 5.0 check +## First 5.1 check + +Open **Guests** to create a time/camera/count-limited temporary pass, and open +**Investigation → Site map** to position cameras and connect plausible transitions. +From **Visits**, use **Play clips by route** to review the matching Frigate clips in +chronological order. A guest face match is only eligibility evidence: liveness and a +second factor remain mandatory before any door-control automation acts. 1. Open **Daily operation → Users**, create a test person and add 5–10 clear photos. Rejected photos show the reason instead of silently weakening the gallery. diff --git a/faceid-addon/app/__init__.py b/faceid-addon/app/__init__.py index aca1843..b5651b8 100644 --- a/faceid-addon/app/__init__.py +++ b/faceid-addon/app/__init__.py @@ -1 +1 @@ -VERSION = "5.0.4" +VERSION = "5.1.0" diff --git a/faceid-addon/app/access_control.py b/faceid-addon/app/access_control.py index 7408497..c91c7f5 100644 --- a/faceid-addon/app/access_control.py +++ b/faceid-addon/app/access_control.py @@ -11,15 +11,15 @@ ROLE_TABS = { "admin": ["*"], "operator": [ - "dashboard", "users", "unknowns", "visits", "intercom", "liveness", + "dashboard", "users", "guests", "site-map", "unknowns", "visits", "intercom", "liveness", "automations", "activity", "calibration", "privacy", "health-center", ], - "viewer": ["dashboard", "visits", "liveness", "activity"], + "viewer": ["dashboard", "site-map", "visits", "liveness", "activity"], } MUTATING_PREFIXES = { "operator": ( "/api/persons", "/api/unknowns", "/api/audit", - "/api/intercom", "/api/liveness", + "/api/intercom", "/api/liveness", "/api/guests", "/api/site-map", ), "viewer": (), } diff --git a/faceid-addon/app/audit.py b/faceid-addon/app/audit.py index 51ce299..1765ef9 100644 --- a/faceid-addon/app/audit.py +++ b/faceid-addon/app/audit.py @@ -759,6 +759,17 @@ def dashboard_summary(self): "processing": counts.get("processing", 0), } + def traffic_events(self, *, after_ts: float, limit: int = 10000) -> list[dict]: + """Anonymous person-event facts for zone traffic analytics.""" + with self._lock, self._connection() as con: + con.row_factory = sqlite3.Row + rows = con.execute( + "SELECT event_id, camera, start_ts, end_ts, status FROM events " + "WHERE start_ts>=? AND status!='processing' ORDER BY start_ts LIMIT ?", + (float(after_ts), max(1, min(int(limit), 100000))), + ).fetchall() + return [dict(row) for row in rows] + def labeled_events(self): with self._lock, self._connection() as con: con.row_factory = sqlite3.Row diff --git a/faceid-addon/app/backup_util.py b/faceid-addon/app/backup_util.py index f0d6835..ae2f88c 100644 --- a/faceid-addon/app/backup_util.py +++ b/faceid-addon/app/backup_util.py @@ -13,10 +13,11 @@ log = logging.getLogger("faceid.backup") # Nur die unersetzliche Handarbeit sichern — nicht die Unknown-Queue oder Frigate-Vollbilder. -BACKUP_SUBDIRS = ("persons", "ignored", "body") +BACKUP_SUBDIRS = ("persons", "ignored", "body", "guests") BACKUP_FILES = ( "settings.json", "learning_runs.json", "frigate_sync.json", - "camera_profiles.json", "access_control.json", "schema.json", + "camera_profiles.json", "access_control.json", "guest_access.json", + "site_map.json", "schema.json", ) @@ -60,8 +61,8 @@ def build_backup_gz(data_dir: Path) -> bytes: if audit.is_file(): _add_audit_snapshot(tar, audit) manifest = json.dumps({ - "format": 5, "created": time.time(), - "includes": [*BACKUP_SUBDIRS, "settings", "learning-runs", "sync-ledger", "camera-profiles", "access-policy", "schema", "audit-history"], + "format": 6, "created": time.time(), + "includes": [*BACKUP_SUBDIRS, "settings", "learning-runs", "sync-ledger", "camera-profiles", "access-policy", "guest-access", "site-map", "schema", "audit-history"], "excludes": ["frigate-credentials", "mqtt-credentials", "clips", "media-cache"], "restore_note": "body classifier is rebuilt from reviewed material after restore", }, indent=2).encode("utf-8") diff --git a/faceid-addon/app/guest_access.py b/faceid-addon/app/guest_access.py new file mode 100644 index 0000000..842c8aa --- /dev/null +++ b/faceid-addon/app/guest_access.py @@ -0,0 +1,170 @@ +"""Temporary guest identities and fail-closed access eligibility decisions.""" +from __future__ import annotations + +import json +import os +import shutil +import threading +import time +import uuid +from pathlib import Path + +import cv2 +import numpy as np + + +class GuestAccess: + def __init__(self, data_dir: Path, *, threshold: float = .62, margin: float = .12): + self.root = data_dir / "guests" + self.root.mkdir(parents=True, exist_ok=True) + self.events_path = data_dir / "guest_access.json" + self.threshold = max(.5, float(threshold)) + self.margin = max(.08, float(margin)) + self._lock = threading.RLock() + + @staticmethod + def _atomic_json(path: Path, value) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush(); os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + def _meta(self, guest_id: str) -> dict | None: + path = self.root / guest_id / "meta.json" + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else None + except (OSError, json.JSONDecodeError): + return None + + @staticmethod + def status(meta: dict, now: float | None = None) -> str: + now = float(now or time.time()) + if meta.get("revoked"): + return "revoked" + if now < float(meta.get("valid_from") or 0): + return "future" + if now > float(meta.get("valid_until") or 0): + return "expired" + maximum = int(meta.get("max_entries") or 0) + if maximum and int(meta.get("entries_used") or 0) >= maximum: + return "used" + return "active" + + def list(self) -> list[dict]: + with self._lock: + result = [] + for folder in self.root.iterdir(): + if not folder.is_dir(): + continue + meta = self._meta(folder.name) + if not meta: + continue + result.append({**meta, "status": self.status(meta), "photo": f"media/guests/{folder.name}/face.jpg"}) + return sorted(result, key=lambda item: (item["status"] != "active", item.get("valid_from", 0))) + + def create(self, *, name: str, valid_from: float, valid_until: float, + max_entries: int, allowed_cameras: list[str], crop, embedding) -> dict: + name = str(name).strip() + if not name or len(name) > 100: + raise ValueError("guest name must contain 1-100 characters") + if float(valid_until) <= float(valid_from): + raise ValueError("valid_until must be after valid_from") + if float(valid_until) - float(valid_from) > 90 * 86400: + raise ValueError("guest access may not exceed 90 days") + guest_id = uuid.uuid4().hex[:12] + folder = self.root / guest_id + folder.mkdir() + meta = { + "id": guest_id, "name": name, "valid_from": float(valid_from), + "valid_until": float(valid_until), "max_entries": max(1, min(int(max_entries), 1000)), + "entries_used": 0, "allowed_cameras": sorted({str(x) for x in allowed_cameras if str(x)}), + "revoked": False, "created_ts": time.time(), + } + try: + if not cv2.imwrite(str(folder / "face.jpg"), crop, [cv2.IMWRITE_JPEG_QUALITY, 92]): + raise ValueError("could not store guest face") + with (folder / "embedding.npy").open("wb") as handle: + np.save(handle, np.asarray(embedding, dtype=np.float32)) + handle.flush(); os.fsync(handle.fileno()) + self._atomic_json(folder / "meta.json", meta) + except Exception: + shutil.rmtree(folder, ignore_errors=True) + raise + return {**meta, "status": self.status(meta), "photo": f"media/guests/{guest_id}/face.jpg"} + + def revoke(self, guest_id: str) -> dict: + with self._lock: + meta = self._meta(guest_id) + if not meta: + raise KeyError(guest_id) + meta["revoked"] = True + self._atomic_json(self.root / guest_id / "meta.json", meta) + return {**meta, "status": self.status(meta)} + + def delete(self, guest_id: str) -> None: + with self._lock: + folder = (self.root / guest_id).resolve() + if folder.parent != self.root.resolve() or not folder.is_dir(): + raise KeyError(guest_id) + shutil.rmtree(folder) + + def candidates(self, embedding, *, camera: str, now: float | None = None, limit: int = 2) -> list[tuple[str, str, float]]: + now = float(now or time.time()) + rows = [] + with self._lock: + for meta in self.list(): + if self.status(meta, now) != "active": + continue + allowed = meta.get("allowed_cameras") or [] + if allowed and camera not in allowed: + continue + try: + reference = np.load(self.root / meta["id"] / "embedding.npy") + score = float(np.asarray(reference, dtype=np.float32) @ np.asarray(embedding, dtype=np.float32)) + except (OSError, ValueError): + continue + rows.append((f"guest:{meta['id']}", meta["name"], score)) + return sorted(rows, key=lambda item: item[2], reverse=True)[:max(1, limit)] + + def evaluate(self, guest_id: str, *, camera: str, score: float, runner_up_score: float, + liveness_confirmed: bool, second_factor: bool, event_id: str | None = None) -> dict: + """Return and audit eligibility. Only a complete decision consumes an entry.""" + with self._lock: + meta = self._meta(guest_id) + if not meta: + raise KeyError(guest_id) + reasons = [] + if self.status(meta) != "active": reasons.append("guest_not_active") + if meta.get("allowed_cameras") and camera not in meta["allowed_cameras"]: reasons.append("camera_not_allowed") + if float(score) < self.threshold: reasons.append("score_too_low") + if float(score) - float(runner_up_score) < self.margin: reasons.append("margin_too_low") + if not liveness_confirmed: reasons.append("liveness_required") + if not second_factor: reasons.append("second_factor_required") + authorized = not reasons + if authorized: + meta["entries_used"] = int(meta.get("entries_used") or 0) + 1 + self._atomic_json(self.root / guest_id / "meta.json", meta) + event = { + "id": uuid.uuid4().hex, "guest_id": guest_id, "guest_name": meta["name"], + "event_id": event_id, "camera": camera, "score": round(float(score), 4), + "authorized": authorized, "reasons": reasons, "ts": time.time(), + } + try: + history = json.loads(self.events_path.read_text(encoding="utf-8")) + if not isinstance(history, list): history = [] + except (OSError, json.JSONDecodeError): + history = [] + self._atomic_json(self.events_path, [event, *history[:999]]) + return {**event, "entries_used": meta["entries_used"], "entries_left": max(0, int(meta["max_entries"]) - int(meta["entries_used"]))} + + def history(self, limit: int = 100) -> list[dict]: + try: + rows = json.loads(self.events_path.read_text(encoding="utf-8")) + return rows[:max(1, min(int(limit), 1000))] if isinstance(rows, list) else [] + except (OSError, json.JSONDecodeError): + return [] diff --git a/faceid-addon/app/main.py b/faceid-addon/app/main.py index d0f5b7d..ef0b8f9 100644 --- a/faceid-addon/app/main.py +++ b/faceid-addon/app/main.py @@ -28,6 +28,8 @@ from .visits import VisitService from .liveness import LivenessDetector from .access_control import AccessControl +from .guest_access import GuestAccess +from .site_intelligence import SiteIntelligence from .migrations import run_migrations BASE = Path(__file__).resolve().parent.parent @@ -151,6 +153,14 @@ def main(): data_dir / "access_control.json", enabled=bool(f.get("access_control_enabled", False)), ) + processor.guest_access = GuestAccess( + data_dir, + threshold=float(f.get("guest_match_threshold", max(.62, processor.match_thr + .08))), + margin=float(f.get("guest_match_margin", max(.12, processor.match_margin))), + ) + processor.site_intelligence = SiteIntelligence( + data_dir / "site_map.json", audit, processor.camera_profiles, processor.visits, + ) processor.liveness = LivenessDetector( model_path=str(f.get("liveness_model_path") or "/opt/faceid/models/liveness.onnx"), enabled=bool(f.get("liveness_enabled", True)), diff --git a/faceid-addon/app/mqtt_listener.py b/faceid-addon/app/mqtt_listener.py index 33c89ed..bb46c35 100644 --- a/faceid-addon/app/mqtt_listener.py +++ b/faceid-addon/app/mqtt_listener.py @@ -481,7 +481,19 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna if st["liveness"].get("confirmed"): st["liveness_blocked"] = False emb = face.normed_embedding - candidates = self.gallery.match_candidates(emb, limit=2) + candidates = self.gallery.match_candidates(emb, limit=3) + guest_access = getattr(self, "guest_access", None) + if guest_access is not None: + guest_candidates = guest_access.candidates( + emb, camera=st["camera"], now=st.get("start_time"), limit=2, + ) + combined = sorted([*candidates, *guest_candidates], key=lambda item: item[2], reverse=True) + if combined and str(combined[0][0]).startswith("guest:"): + lead = float(combined[0][2]) - float(combined[1][2] if len(combined) > 1 else 0) + if float(combined[0][2]) >= guest_access.threshold and lead >= guest_access.margin: + candidates = combined[:2] + else: + candidates = [item for item in combined if not str(item[0]).startswith("guest:")][:2] slug, name, score = candidates[0] if candidates else (None, None, 0.0) _, runner_up, runner_up_score = ( candidates[1] if len(candidates) > 1 else (None, None, 0.0) @@ -545,6 +557,21 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna st["best_score"], st["best_person"] = decision.score, decision.person st["done"] = True st["final_decision"] = decision + if decision.slug and str(decision.slug).startswith("guest:"): + guest_id = str(decision.slug).split(":", 1)[1] + st["guest"] = {"id": guest_id, "name": decision.person} + access_result = guest_access.evaluate( + guest_id, camera=st["camera"], score=decision.score, + runner_up_score=decision.runner_up_score, + liveness_confirmed=bool((st.get("liveness") or {}).get("confirmed")), + second_factor=False, event_id=eid, + ) + st["guest_access"] = access_result + if self.client: + self.client.publish( + f"{self.prefix}/v1/guest_access", + json.dumps(access_result, ensure_ascii=False), retain=False, + ) self._publish_recognition( eid, st, decision.person, decision.score, decision=decision ) @@ -818,6 +845,8 @@ def _post_event( "scenario": scenario, "body": st.get("body"), "liveness": st.get("liveness"), + "guest": st.get("guest"), + "guest_access": st.get("guest_access"), } if self.client and st.get("body"): self.client.publish( diff --git a/faceid-addon/app/site_intelligence.py b/faceid-addon/app/site_intelligence.py new file mode 100644 index 0000000..4b03143 --- /dev/null +++ b/faceid-addon/app/site_intelligence.py @@ -0,0 +1,100 @@ +"""Honest camera-zone map and anonymous traffic analytics.""" +from __future__ import annotations + +import json +import os +import threading +import time +import uuid +from collections import Counter +from pathlib import Path + + +class SiteIntelligence: + def __init__(self, path: Path, audit, camera_profiles, visits): + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + self.audit = audit + self.camera_profiles = camera_profiles + self.visits = visits + self._lock = threading.RLock() + + def _read(self) -> dict: + try: + value = json.loads(self.path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + def _write(self, value: dict) -> None: + temporary = self.path.with_name(f".{self.path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush(); os.fsync(handle.fileno()) + os.replace(temporary, self.path) + finally: + temporary.unlink(missing_ok=True) + + def map(self, cameras: list[str]) -> dict: + stored = self._read() + nodes = stored.get("cameras") if isinstance(stored.get("cameras"), dict) else {} + count = max(1, len(cameras)) + result = [] + for index, camera in enumerate(cameras): + saved = nodes.get(camera) if isinstance(nodes.get(camera), dict) else {} + profile = self.camera_profiles.get(camera) + result.append({ + "camera": camera, "label": saved.get("label") or camera, + "x": float(saved.get("x", 10 + (index % 4) * 26)), + "y": float(saved.get("y", 15 + (index // 4) * 28)), + "role": profile.get("role", "observation"), + "enabled": profile.get("enabled", True), + }) + links = stored.get("links") if isinstance(stored.get("links"), list) else [] + return {"title": stored.get("title") or "האתר שלי", "cameras": result, "links": links, + "notice": "המיקום והמסלול משוערים לפי מצלמות וזמנים — אינם GPS."} + + def update(self, body: dict, cameras: list[str]) -> dict: + known = set(cameras) + title = str(body.get("title") or "האתר שלי").strip()[:100] + nodes = {} + for item in body.get("cameras") or []: + camera = str(item.get("camera") or "") + if camera not in known: continue + nodes[camera] = {"label": str(item.get("label") or camera)[:100], + "x": max(2, min(float(item.get("x", 50)), 98)), + "y": max(4, min(float(item.get("y", 50)), 96))} + links, seen = [], set() + for link in body.get("links") or []: + if not isinstance(link, list) or len(link) != 2: continue + a, b = str(link[0]), str(link[1]) + key = tuple(sorted((a, b))) + if a in known and b in known and a != b and key not in seen: + links.append([a, b]); seen.add(key) + with self._lock: self._write({"title": title, "cameras": nodes, "links": links}) + return self.map(cameras) + + def analytics(self, *, cameras: list[str], days: int = 7) -> dict: + days = max(1, min(int(days), 90)) + rows = self.audit.traffic_events(after_ts=time.time() - days * 86400) + counts = Counter(row["camera"] for row in rows) + hours = Counter(time.localtime(float(row.get("start_ts") or 0)).tm_hour for row in rows) + transitions = Counter() + # Identity is used only transiently to join events into visits; output is anonymous. + for visit in self.visits.list(days=days, limit=1000): + route = visit.get("route") or [] + for a, b in zip(route, route[1:]): + if a != b: transitions[(a, b)] += 1 + peak = max(hours, key=hours.get) if hours else None + total = sum(counts.values()) + return { + "days": days, "total_person_events": total, "peak_hour": peak, + "cameras": [{"camera": camera, "events": counts[camera], + "share": round(counts[camera] / total, 4) if total else 0} + for camera in cameras], + "hours": [{"hour": hour, "events": hours[hour]} for hour in range(24)], + "transitions": [{"from": a, "to": b, "count": count} + for (a, b), count in transitions.most_common(20)], + "privacy": "Aggregates contain counts and routes only; no face image or identity is returned.", + } diff --git a/faceid-addon/app/visits.py b/faceid-addon/app/visits.py index ab7fa92..b3dc8de 100644 --- a/faceid-addon/app/visits.py +++ b/faceid-addon/app/visits.py @@ -34,7 +34,7 @@ def list(self, *, person: str | None = None, days: int = 30, limit: int = 200): "end_ts": float(event.get("end_ts") or event["start_ts"]), "first_camera": event["camera"], "last_camera": event["camera"], - "route": [], "events": [], "scores": [], + "route": [], "events": [], "timeline": [], "scores": [], "arrival": "confirmed" if starts_entry else "observed", "departure": "not_observed", } @@ -48,6 +48,10 @@ def list(self, *, person: str | None = None, days: int = 30, limit: int = 200): if not previous["route"] or previous["route"][-1] != event["camera"]: previous["route"].append(event["camera"]) previous["events"].append(event["event_id"]) + previous["timeline"].append({ + "event_id": event["event_id"], "camera": event["camera"], + "start_ts": float(event["start_ts"]), + }) previous["scores"].append(float(event.get("score") or 0)) if role in {"exit", "entry_exit"}: previous["departure"] = "confirmed" diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py index 0c56d67..6c29e7a 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -75,7 +75,7 @@ async def role_guard(request, call_next): @app.get("/media/{kind}/{item_path:path}") def media(kind: str, item_path: str): """Serve only gallery JPEGs; never expose embeddings, settings or backups.""" - if kind not in {"persons", "unknowns", "ignored"}: + if kind not in {"persons", "unknowns", "ignored", "guests"}: raise HTTPException(404, "Unknown media collection") base = (data_dir / kind).resolve() target = (base / item_path).resolve() @@ -604,7 +604,7 @@ async def restore(file: UploadFile, merge: bool = False): except tarfile.TarError: raise HTTPException(400, "Not a valid .tar.gz backup") members = tar.getmembers() - allowed_roots = {"persons", "ignored", "body", "system", "manifest.json"} + allowed_roots = {"persons", "ignored", "body", "guests", "system", "manifest.json"} for m in members: norm = Path(m.name) if (m.name.startswith("/") or ".." in norm.parts or @@ -614,7 +614,7 @@ async def restore(file: UploadFile, merge: bool = False): raise HTTPException(400, "Executable body models are never accepted from backups; restore material and retrain") if not merge: write_backup_file(data_dir, data_dir / "backups" / "before-restore") - for sub in ("persons", "ignored", "body"): + for sub in ("persons", "ignored", "body", "guests"): d = data_dir / sub if d.exists(): for f in d.rglob("*"): @@ -633,7 +633,8 @@ async def restore(file: UploadFile, merge: bool = False): if norm.parts[0] == "system": if len(norm.parts) != 2 or norm.parts[1] not in { "settings.json", "learning_runs.json", "frigate_sync.json", - "camera_profiles.json", "access_control.json", "schema.json", + "camera_profiles.json", "access_control.json", "guest_access.json", + "site_map.json", "schema.json", "audit.db" }: continue @@ -1156,6 +1157,131 @@ def _camera_names(): names.update(processor.frigate.cameras()) return sorted(name for name in names if name) + @app.get("/api/guests") + def guests(): + service = getattr(processor, "guest_access", None) + if service is None: + raise HTTPException(503, "Guest access is unavailable") + return { + "guests": service.list(), "history": service.history(50), + "threshold": service.threshold, "margin": service.margin, + "safety": "A face match creates eligibility only. Liveness and a second factor are required before entry is authorized.", + } + + @app.post("/api/guests") + async def create_guest(name: str, valid_from: float, valid_until: float, + max_entries: int = 1, cameras: str = "", file: UploadFile = None): + service = getattr(processor, "guest_access", None) + if service is None: + raise HTTPException(503, "Guest access is unavailable") + if file is None: + raise HTTPException(400, "A clear guest photo is required") + raw = await file.read() + if len(raw) > 15_000_000: + raise HTTPException(413, "Photo is larger than 15 MB") + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + raise HTTPException(400, "The uploaded file is not a readable image") + if max(image.shape[:2]) > 2400: + scale = 2400 / max(image.shape[:2]); image = cv2.resize(image, None, fx=scale, fy=scale) + face, image = find_face_padded(engine, image, min_px=100) + if face is None: + raise HTTPException(400, "No clear face of at least 100 pixels was found") + quality = measure_face_quality(image, face, min_face_px=100, min_quality=max(.4, processor.min_face_quality)) + if not quality.usable: + raise HTTPException(400, "The face is not sharp, bright or frontal enough for temporary access") + selected = [item for item in cameras.split(",") if item] + unknown = set(selected) - set(_camera_names()) + if unknown: + raise HTTPException(400, f"Unknown cameras: {', '.join(sorted(unknown))}") + try: + guest = service.create( + name=name, valid_from=valid_from, valid_until=valid_until, + max_entries=max_entries, allowed_cameras=selected, + crop=crop_face(image, face.bbox), embedding=face.normed_embedding, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "guest": guest, "quality": quality.to_dict()} + + @app.post("/api/guests/{guest_id}/revoke") + def revoke_guest(guest_id: str): + try: + return {"ok": True, "guest": processor.guest_access.revoke(guest_id)} + except KeyError as exc: + raise HTTPException(404, "Unknown guest") from exc + + @app.delete("/api/guests/{guest_id}") + def delete_guest(guest_id: str): + try: + processor.guest_access.delete(guest_id) + except KeyError as exc: + raise HTTPException(404, "Unknown guest") from exc + return {"ok": True} + + @app.post("/api/guests/{guest_id}/authorize") + def authorize_guest(guest_id: str, body: dict): + event_id = str(body.get("event_id") or "") + detail = processor.audit.event_detail(event_id) if processor.audit and event_id else None + pending = next((item for item in processor.guest_access.history(1000) + if item.get("guest_id") == guest_id and item.get("event_id") == event_id), None) + guest = next((item for item in processor.guest_access.list() if item["id"] == guest_id), None) + if not detail or not pending or not guest: + raise HTTPException(400, "No matching server-verified guest recognition was found") + event = detail["event"] + if event.get("status") != "recognized" or event.get("person") != guest["name"]: + raise HTTPException(400, "The event is not a finalized recognition for this guest") + try: + return processor.guest_access.evaluate( + guest_id, camera=str(event.get("camera") or ""), + score=float(event.get("score") or 0), + runner_up_score=max(0.0, float(event.get("score") or 0) - float(event.get("margin") or 0)), + liveness_confirmed=event.get("liveness_status") == "live", + second_factor=bool(body.get("second_factor")), + event_id=event_id, + ) + except KeyError as exc: + raise HTTPException(404, "Unknown guest") from exc + + @app.get("/api/site-map") + def site_map(days: int = 7): + service = getattr(processor, "site_intelligence", None) + if service is None: + raise HTTPException(503, "Site intelligence is unavailable") + cameras = _camera_names() + visits = processor.visits.list(days=max(1, min(days, 30)), limit=50) + latest = {} + for visit in visits: + latest.setdefault(visit["person"], visit) + return { + "map": service.map(cameras), + "analytics": service.analytics(cameras=cameras, days=days), + "people": [{ + "person": item["person"], "camera": item["last_camera"], + "last_seen": item["end_ts"], "open": item["open"], + "route": item["route"], "timeline": item.get("timeline", []), + } for item in latest.values()], + } + + @app.post("/api/site-map") + def save_site_map(body: dict): + service = getattr(processor, "site_intelligence", None) + if service is None: + raise HTTPException(503, "Site intelligence is unavailable") + try: + updated = service.update(body, _camera_names()) + graph = {camera: set() for camera in _camera_names()} + for left, right in updated.get("links", []): + graph.setdefault(left, set()).add(right) + graph.setdefault(right, set()).add(left) + if getattr(processor, "scenario_manager", None) is not None: + processor.scenario_manager.camera_graph = graph + if getattr(processor, "reid", None) is not None: + processor.reid.camera_graph = graph + return {"ok": True, "map": updated} + except (TypeError, ValueError) as exc: + raise HTTPException(400, str(exc)) from exc + @app.get("/api/cameras/studio") def camera_studio(days: int = 7): profiles = getattr(processor, "camera_profiles", None) diff --git a/faceid-addon/config.yaml b/faceid-addon/config.yaml index 85290c8..7b06653 100644 --- a/faceid-addon/config.yaml +++ b/faceid-addon/config.yaml @@ -1,5 +1,5 @@ name: FaceID -version: "5.0.4" +version: "5.1.0" slug: faceid description: Face recognition for Frigate — trainable gallery, clustered unknown review, HA sensors url: https://github.com/r11a/faceid @@ -12,7 +12,7 @@ boot: auto init: false ingress: true ingress_port: 8600 -ingress_entry: ui-5.0.4 +ingress_entry: ui-5.1.0 panel_icon: mdi:face-recognition panel_title: FaceID ports: @@ -83,6 +83,8 @@ options: ai_embedding_model: embeddinggemma set_sub_label: false access_control_enabled: false + guest_match_threshold: 0.62 + guest_match_margin: 0.12 cameras: [] discovery_cameras: [] schema: @@ -148,6 +150,8 @@ schema: ai_embedding_model: str set_sub_label: bool access_control_enabled: bool + guest_match_threshold: float(0.5,0.9) + guest_match_margin: float(0.08,0.5) cameras: - str discovery_cameras: diff --git a/faceid-addon/run.sh b/faceid-addon/run.sh index 48c61d9..4721948 100755 --- a/faceid-addon/run.sh +++ b/faceid-addon/run.sh @@ -112,6 +112,8 @@ faceid: ai_embedding_model: $(cfg '.ai_embedding_model') set_sub_label: $(cfg '.set_sub_label') access_control_enabled: $(cfg '.access_control_enabled // false') + guest_match_threshold: $(cfg '.guest_match_threshold // 0.62') + guest_match_margin: $(cfg '.guest_match_margin // 0.12') min_face_px: 48 det_size: 640 max_attempts: 6 diff --git a/faceid-addon/static/index.html b/faceid-addon/static/index.html index 3e13b52..5391a51 100644 --- a/faceid-addon/static/index.html +++ b/faceid-addon/static/index.html @@ -203,6 +203,7 @@ .sync-card input{position:absolute;top:14px;right:14px;z-index:2;width:20px;height:20px} .sync-card.done{opacity:.55}.progress-note{padding:12px;border-right:3px solid var(--acc);background:var(--panel);color:var(--dim)} .product-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(285px,1fr));gap:14px}.product-card{border:1px solid var(--line);border-radius:20px;background:linear-gradient(150deg,#24272a,#181a1c);padding:17px;box-shadow:0 12px 34px #0004}.product-card.ready{border-color:#50785a}.product-card.review{border-color:#78613e}.product-card.new{border-style:dashed}.product-card .card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.state-chip{display:inline-flex;border-radius:99px;padding:4px 9px;font-size:11px;font-weight:800;background:var(--panel2);color:var(--dim)}.state-chip.ready{color:#aee6b8}.state-chip.review{color:#f2cb8c}.wizard{border:1px solid color-mix(in oklab,var(--acc) 55%,var(--line));border-radius:22px;background:linear-gradient(135deg,color-mix(in oklab,var(--panel) 88%,var(--acc) 12%),var(--panel));padding:22px;margin-bottom:22px}.wizard h2{font:800 24px var(--sans);color:var(--fg);text-transform:none;letter-spacing:0}.wizard-flow{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.wizard-flow div{display:flex;gap:9px;align-items:center;color:var(--dim)}.wizard-flow b{width:28px;height:28px;border-radius:9px;background:var(--acc);color:#152018;display:grid;place-items:center}.intercom-preview{position:relative;border-radius:18px;overflow:hidden;background:#08090a;min-height:280px;display:grid;place-items:center}.intercom-preview img{width:100%;max-height:62vh;object-fit:contain}.intercom-guide{position:absolute;border:3px solid #74cbe8;border-radius:42% 42% 48% 48%;box-shadow:0 0 0 9999px #0004;pointer-events:none}.result-banner{padding:13px 16px;border-radius:14px;background:var(--panel2);border-right:4px solid var(--accent2);margin:12px 0}.result-banner.excellent{border-color:var(--good)}.result-banner.improve,.result-banner.acceptable{border-color:var(--warn)}.result-banner.no_face{border-color:var(--bad)}.danger{color:#ffd1d5!important;border-color:#824a50!important}.user-dialog-grid{display:grid;grid-template-columns:180px 1fr;gap:18px}.user-dialog-grid .avatar{width:180px;height:180px}.upload-report{display:grid;gap:7px;margin-top:12px}.upload-report div{padding:9px;border-radius:10px;background:var(--panel2)} +.guest-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}.guest-card{display:grid;grid-template-columns:82px 1fr;gap:13px;padding:15px;border:1px solid var(--line);border-radius:18px;background:var(--panel)}.guest-card img{width:82px;height:82px;object-fit:cover;border-radius:14px}.guest-card .card-actions{grid-column:1/-1;display:flex;gap:7px;flex-wrap:wrap}.site-stage{position:relative;min-height:520px;border:1px solid var(--line);border-radius:22px;overflow:hidden;background-color:#151719;background-image:linear-gradient(#ffffff08 1px,transparent 1px),linear-gradient(90deg,#ffffff08 1px,transparent 1px);background-size:32px 32px}.site-stage svg{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.site-node{position:absolute;transform:translate(-50%,-50%);min-width:118px;padding:10px 12px;border:1px solid #5d6862;border-radius:14px;background:#222628ee;color:var(--fg);box-shadow:0 10px 28px #0007;cursor:grab;touch-action:none}.site-node.restricted{border-color:var(--bad)}.site-node.intercom,.site-node.entry,.site-node.entry_exit{border-color:var(--good)}.site-node small{display:block;color:var(--dim);margin-top:3px}.traffic-bar{height:10px;border-radius:99px;background:linear-gradient(90deg,#92d3a0,#e5b96f,#d97878);min-width:3px}.playlist-progress{display:flex;gap:6px;flex-wrap:wrap;padding:10px 0}.playlist-progress span{padding:5px 9px;border-radius:99px;background:var(--panel2);color:var(--dim)}.playlist-progress span.on{background:var(--acc);color:#101410}.playlist-progress span.done{color:var(--good)} .liveness-visual{position:relative;margin:14px 0;border-radius:16px;overflow:hidden;background:#090a0b}.liveness-visual img{display:block;width:100%;height:auto}.face-outline{display:none;position:absolute;border:3px solid var(--warn);border-radius:12px;box-shadow:0 0 0 2px #0009,0 0 22px color-mix(in oklab,var(--warn) 65%,transparent);pointer-events:none}.face-outline.live{border-color:var(--good);box-shadow:0 0 0 2px #0009,0 0 22px color-mix(in oklab,var(--good) 65%,transparent)}.face-outline.spoof{border-color:var(--bad);box-shadow:0 0 0 2px #0009,0 0 22px color-mix(in oklab,var(--bad) 65%,transparent)}.face-outline span{position:absolute;right:-3px;bottom:calc(100% + 5px);white-space:nowrap;padding:5px 9px;border-radius:8px;background:#101214e8;border:1px solid currentColor;color:#fff;font:800 12px var(--mono)}.live-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin:10px 0}.live-metric{background:var(--panel2);border:1px solid var(--line);border-radius:11px;padding:9px}.live-metric b,.live-metric span{display:block}.live-metric b{font-size:15px}.live-metric span{color:var(--dim);font-size:10px;margin-top:2px}.guidance-list{display:grid;gap:6px;margin-top:9px}.guidance-list div{padding:8px 10px;border-radius:9px;background:#ffffff08}.guidance-list div::before{content:'←';color:var(--acc);margin-left:7px;font-weight:900} @media(max-width:850px){.hero{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,1fr)}nav{top:64px}} @media(max-width:850px){.filters{grid-template-columns:1fr 1fr}.event-media{grid-template-columns:1fr}} @@ -241,12 +242,14 @@