diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a4a90..5814db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 5.2.1 — 2026-08-12 + +- **Bounded review inbox:** the verification screen keeps at most 200 representative + crops, 12 per likely identity and 14 days. Existing oversized queues are reduced + automatically on startup without deleting event metadata or investigation history. +- **Verify without polluting enrollment:** confirming a known person now labels and + closes all related tasks without copying every crop into the person's reference + gallery. Adding a training image is a separate, explicit action capped at three. +- **Visit-aware recognition:** the first sighting is an arrival, a move to another + camera is a transition and continuing visibility on the same camera is a quiet + presence update. Quiet updates refresh HA presence/last-seen state but do not create + duplicate notifications, automations, AI jobs, body-training material or statistics. +- **Hard evidence limits:** investigation images are capped at 300 known and 300 other + events, decoded-frame cache at 25 events and optional AI grids at 100. Event time, + score and decision metadata remain after transient imagery is removed. +- **Friendlier review UI:** explains the automatic limits, uses clear primary actions, + moves destructive options behind disclosure and provides manual cleanup feedback. + ## 5.2.0 — 2026-08-12 - **Five stable product areas:** Home, Events, People, Cameras and Management replace diff --git a/app/__init__.py b/app/__init__.py index 221603a..91ab71e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -VERSION = "5.2.0" +VERSION = "5.2.1" diff --git a/app/audit.py b/app/audit.py index 1765ef9..9360e9a 100644 --- a/app/audit.py +++ b/app/audit.py @@ -14,6 +14,11 @@ def __init__(self, path: Path, retention_days: int = 90): self.evidence_dir = self.path.parent / "audit_images" self.evidence_dir.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() + self.evidence_known_days = 30 + self.evidence_unknown_days = 14 + self.evidence_known_max = 300 + self.evidence_unknown_max = 300 + self._evidence_since_prune = 0 self._init_db() self.prune(retention_days) @@ -38,6 +43,13 @@ def save_evidence(self, event_id: str, image) -> Path | None: temporary = target.with_suffix(".tmp") temporary.write_bytes(encoded.tobytes()) temporary.replace(target) + self._evidence_since_prune += 1 + if self._evidence_since_prune >= 25: + self._evidence_since_prune = 0 + self.prune_evidence( + self.evidence_known_days, self.evidence_unknown_days, + self.evidence_known_max, self.evidence_unknown_max, + ) return target except (OSError, ValueError): return None @@ -145,6 +157,7 @@ def _init_db(self): ("ground_truth_by", "TEXT"), ("liveness_status", "TEXT"), ("liveness_score", "REAL"), + ("occurrence", "TEXT"), ): self._ensure_column(con, "events", column, declaration) @@ -246,12 +259,13 @@ def finalize( score: float = 0.0, margin: float = 0.0, confirmations: int = 0, + occurrence: str | None = None, ): with self._lock, self._connection() as con: con.execute( """ UPDATE events SET end_ts=?, status=?, person=?, score=?, margin=?, - confirmations=?, updated_ts=? + confirmations=?, occurrence=?, updated_ts=? WHERE event_id=? """, ( @@ -261,6 +275,7 @@ def finalize( score, margin, confirmations, + occurrence, time.time(), event_id, ), @@ -303,18 +318,25 @@ def prune(self, retention_days: int): except OSError: pass - def recent(self, limit: int = 100, status: str | None = None): + def recent( + self, limit: int = 100, status: str | None = None, + include_presence_updates: bool = False, + ): sql = """ SELECT event_id, camera, start_ts, end_ts, status, person, score, margin, confirmations, updated_ts, ground_truth, scenario_id, ai_description, ai_tags, probable_person, probable_score, - liveness_status, liveness_score + liveness_status, liveness_score, occurrence FROM events """ - params = [] + clauses, params = [], [] + if not include_presence_updates: + clauses.append("COALESCE(occurrence,'')!='presence_update'") if status: - sql += " WHERE status=?" + clauses.append("status=?") params.append(status) + if clauses: + sql += f" WHERE {' AND '.join(clauses)}" sql += " ORDER BY updated_ts DESC LIMIT ?" params.append(max(1, min(int(limit), 500))) with self._lock, self._connection() as con: @@ -326,8 +348,11 @@ def search_events( status: str | None = None, person: str | None = None, camera: str | None = None, date_from: float | None = None, date_to: float | None = None, query: str | None = None, + include_presence_updates: bool = False, ): clauses, params = [], [] + if not include_presence_updates: + clauses.append("COALESCE(occurrence,'')!='presence_update'") if status: clauses.append("status=?") params.append(status) @@ -357,7 +382,8 @@ def search_events( event_id, camera, start_ts, end_ts, status, person, score, margin, confirmations, updated_ts, ground_truth, ground_truth_ts, ground_truth_by, scenario_id, ai_description, ai_tags, - probable_person, probable_score, liveness_status, liveness_score + probable_person, probable_score, liveness_status, liveness_score, + occurrence """ with self._lock, self._connection() as con: con.row_factory = sqlite3.Row @@ -507,6 +533,7 @@ def person_profile( con.row_factory = sqlite3.Row recognition_times = con.execute( "SELECT start_ts FROM events WHERE status='recognized' " + "AND COALESCE(occurrence,'')!='presence_update' " "AND person=? AND start_ts IS NOT NULL", (person,), ).fetchall() @@ -561,6 +588,7 @@ def system_report(self): SUM(CASE WHEN status IN ('unknown','ambiguous') THEN 1 ELSE 0 END) review, AVG(CASE WHEN score>0 THEN score END) avg_score FROM events WHERE status!='processing' AND start_ts>=? + AND COALESCE(occurrence,'')!='presence_update' GROUP BY camera ORDER BY events DESC """, (time.time() - 7 * 86400,) ).fetchall() @@ -582,7 +610,8 @@ def recognized_timeline( self, *, person: str | None = None, after_ts: float = 0 ) -> list[dict]: """Chronological recognized events used to derive visits, not raw frame counts.""" - where = ["status='recognized'", "person IS NOT NULL", "start_ts>=?"] + where = ["status='recognized'", "person IS NOT NULL", "start_ts>=?", + "COALESCE(occurrence,'')!='presence_update'"] params: list = [float(after_ts)] if person: where.append("person=?") @@ -620,6 +649,7 @@ def camera_funnels(self, days: int = 7) -> list[dict]: AVG(CASE WHEN o.quality>0 THEN o.quality END) avg_quality FROM events e LEFT JOIN observation_summary o ON o.event_id=e.event_id WHERE e.status!='processing' AND e.start_ts>=? + AND COALESCE(e.occurrence,'')!='presence_update' GROUP BY e.camera ORDER BY events DESC """, (cutoff,) ).fetchall() @@ -649,21 +679,34 @@ def camera_samples(self, camera: str, limit: int = 24) -> list[dict]: ).fetchall() return [dict(row) for row in rows] - def prune_evidence(self, known_days: int, unknown_days: int) -> int: - """Apply separate image retention without deleting recognition metadata.""" + def prune_evidence( + self, known_days: int, unknown_days: int, + known_max: int = 300, unknown_max: int = 300, + ) -> int: + """Bound evidence by age and count without deleting event metadata.""" + self.evidence_known_days = int(known_days) + self.evidence_unknown_days = int(unknown_days) + self.evidence_known_max = max(0, int(known_max)) + self.evidence_unknown_max = max(0, int(unknown_max)) now = time.time() removed = 0 with self._lock, self._connection() as con: rows = con.execute( "SELECT event_id, status, updated_ts FROM events " - "WHERE status!='processing'" + "WHERE status!='processing' ORDER BY updated_ts DESC" ).fetchall() + kept = {"known": 0, "unknown": 0} for event_id, status, updated_ts in rows: - days = known_days if status == "recognized" else unknown_days - if days <= 0 or float(updated_ts or now) >= now - days * 86400: - continue + kind = "known" if status == "recognized" else "unknown" + days = known_days if kind == "known" else unknown_days + maximum = self.evidence_known_max if kind == "known" else self.evidence_unknown_max path = self.evidence_path(event_id) - if path.is_file(): + if not path.is_file(): + continue + kept[kind] += 1 + expired = days > 0 and float(updated_ts or now) < now - days * 86400 + over_cap = maximum == 0 or kept[kind] > maximum + if expired or over_cap: try: path.unlink() removed += 1 @@ -703,6 +746,7 @@ def person_statistics(self): SUM(CASE WHEN start_ts>=? THEN 1 ELSE 0 END) AS last_30_days FROM events WHERE status='recognized' AND person IS NOT NULL + AND COALESCE(occurrence,'')!='presence_update' GROUP BY person """, (now - 7 * 86400, now - 30 * 86400), @@ -722,6 +766,7 @@ def person_statistics(self): """ SELECT camera, COUNT(*) AS count FROM events WHERE status='recognized' AND person=? + AND COALESCE(occurrence,'')!='presence_update' GROUP BY camera ORDER BY count DESC, camera """, (row["person"],), @@ -745,6 +790,7 @@ def dashboard_summary(self): SELECT status, COUNT(*) AS count FROM events WHERE date(start_ts, 'unixepoch', 'localtime') = date('now', 'localtime') + AND COALESCE(occurrence,'')!='presence_update' GROUP BY status """ ).fetchall() @@ -765,7 +811,8 @@ def traffic_events(self, *, after_ts: float, limit: int = 10000) -> list[dict]: 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 ?", + "WHERE start_ts>=? AND status!='processing' " + "AND COALESCE(occurrence,'')!='presence_update' ORDER BY start_ts LIMIT ?", (float(after_ts), max(1, min(int(limit), 100000))), ).fetchall() return [dict(row) for row in rows] diff --git a/app/frame_distributor.py b/app/frame_distributor.py index 3ade9fd..b3a5d3d 100644 --- a/app/frame_distributor.py +++ b/app/frame_distributor.py @@ -22,6 +22,7 @@ def __init__(self, data_dir: Path, media_store, *, decode_mode: str = "auto", ma self.root.mkdir(parents=True, exist_ok=True) self.decode_mode = decode_mode if decode_mode in ("auto", "software", "vaapi", "cuda") else "auto" self.max_frames = max(4, min(int(max_frames), 120)) + self.max_cached_events = 25 self._lock = threading.Lock() self._stats = {"requests": 0, "cache_hits": 0, "ffmpeg": 0, "opencv": 0, "hardware": 0, "fallbacks": 0, "last_backend": None, "last_error": None} @@ -127,11 +128,16 @@ def report(self) -> dict: def prune(self): cutoff = time.time() - float(getattr(self.media_store, "retention_seconds", 86400)) - for directory in self.root.iterdir(): + directories = sorted( + (path for path in self.root.iterdir() if path.is_dir()), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + for index, directory in enumerate(directories): if not directory.is_dir(): continue try: - if directory.stat().st_mtime >= cutoff: + if index < self.max_cached_events and directory.stat().st_mtime >= cutoff: continue for path in directory.iterdir(): path.unlink(missing_ok=True) diff --git a/app/gallery.py b/app/gallery.py index 3fda3d5..98869b5 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -68,6 +68,12 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40): self.max_per_person = int(max_per_person) # 0 = unbegrenzt self.trimmed_keep = 10 # wie viele beiseitegelegte Fotos je Person aufgehoben werden self.dedupe_threshold = 0.65 # ab hier gilt ein Foto als Duplikat (Hover-Highlight + Dedup) + # The review queue is an inbox, not a second photo archive. Recognition + # events remain in AuditStore even when their temporary review crop is pruned. + self.review_queue_max_total = 200 + self.review_queue_max_per_cluster = 12 + self.review_queue_retention_days = 14 + self.review_queue_dedupe_days = 7 self._lock = threading.Lock() self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]} self._ign_emb = np.zeros((0, 512), dtype=np.float32) @@ -752,7 +758,7 @@ def _drop_ignored(self, iid: str): def save_unknown(self, crop_bgr: np.ndarray, embedding: np.ndarray, meta: dict, dedupe_sim: float = 0.75, full_bgr: np.ndarray | None = None): - """Unbekanntes Gesicht ablegen; sehr ähnliche jüngste Unknowns werden übersprungen.""" + """Store one useful review sample while the durable event stays in AuditStore.""" with self._lock: now = time.time() for jf in self.unknown_dir.glob("*.json"): @@ -760,7 +766,7 @@ def save_unknown(self, crop_bgr: np.ndarray, embedding: np.ndarray, meta: dict, m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - if now - m.get("ts", 0) < 3600: + if now - m.get("ts", 0) < self.review_queue_dedupe_days * 86400: sim = float(np.dot(np.array(m["embedding"], dtype=np.float32), embedding)) if sim > dedupe_sim: return None @@ -770,7 +776,94 @@ def save_unknown(self, crop_bgr: np.ndarray, embedding: np.ndarray, meta: dict, cv2.imwrite(str(self.unknown_dir / f"{uid}_full.jpg"), full_bgr, [cv2.IMWRITE_JPEG_QUALITY, 85]) meta = dict(meta, ts=now, embedding=[round(float(v), 6) for v in embedding]) _atomic_write_json(self.unknown_dir / f"{uid}.json", meta) - return uid + self.prune_unknown_queue() + return uid + + def _delete_unknown_files(self, uid: str): + (self.unknown_dir / f"{uid}.json").unlink(missing_ok=True) + (self.unknown_dir / f"{uid}.jpg").unlink(missing_ok=True) + (self.unknown_dir / f"{uid}_full.jpg").unlink(missing_ok=True) + + def prune_unknown_queue(self) -> dict: + """Bound the review inbox by age, representative cluster and global size. + + Only temporary review crops are removed. Audit events, scores, timestamps, + evidence retention and statistics are deliberately untouched. + """ + with self._lock: + now = time.time() + entries = [] + removed = {"expired": 0, "duplicate_event": 0, "cluster_cap": 0, "global_cap": 0} + seen_events = set() + for jf in sorted(self.unknown_dir.glob("*.json"), reverse=True): + try: + meta = json.loads(jf.read_text(encoding="utf-8")) + emb = np.array(meta["embedding"], dtype=np.float32) + except (json.JSONDecodeError, OSError, KeyError, ValueError): + self._delete_unknown_files(jf.stem) + removed["expired"] += 1 + continue + ts = float(meta.get("ts") or 0) + if self.review_queue_retention_days > 0 and now - ts > self.review_queue_retention_days * 86400: + self._delete_unknown_files(jf.stem); removed["expired"] += 1; continue + event_id = str(meta.get("event_id") or "") + if event_id and event_id in seen_events: + self._delete_unknown_files(jf.stem); removed["duplicate_event"] += 1; continue + if event_id: + seen_events.add(event_id) + entries.append({"id": jf.stem, "meta": meta, "emb": emb, "ts": ts}) + + # Known-person suggestions are naturally one task. Remaining faces are + # greedily grouped by cosine similarity so every identity keeps a small, + # varied set instead of one image per hour forever. + groups: list[list[dict]] = [] + known: dict[str, list[dict]] = {} + unknown: list[dict] = [] + for item in entries: + guess = str(item["meta"].get("guess") or "").strip() + if guess: + known.setdefault(guess.casefold(), []).append(item) + else: + unknown.append(item) + groups.extend(known.values()) + unknown_groups: list[list[dict]] = [] + for item in unknown: + target = next((group for group in unknown_groups + if float(np.dot(group[0]["emb"], item["emb"])) >= 0.78), None) + if target is None: + unknown_groups.append([item]) + else: + target.append(item) + groups.extend(unknown_groups) + + kept_groups: list[list[dict]] = [] + for group in groups: + group.sort(key=lambda row: row["ts"], reverse=True) + keep = group[:max(1, self.review_queue_max_per_cluster)] + kept_groups.append(keep) + for item in group[len(keep):]: + self._delete_unknown_files(item["id"]); removed["cluster_cap"] += 1 + + # Round-robin across identities prevents a busy doorway or one resident + # from consuming the complete queue. + globally_kept = [] + for index in range(max((len(group) for group in kept_groups), default=0)): + for group in kept_groups: + if index < len(group): + globally_kept.append(group[index]) + for item in globally_kept[max(1, self.review_queue_max_total):]: + self._delete_unknown_files(item["id"]); removed["global_cap"] += 1 + return { + **removed, + "removed": sum(removed.values()), + "remaining": min(len(globally_kept), max(1, self.review_queue_max_total)), + "policy": { + "max_total": max(1, self.review_queue_max_total), + "max_per_identity": max(1, self.review_queue_max_per_cluster), + "retention_days": max(1, self.review_queue_retention_days), + "dedupe_days": max(1, self.review_queue_dedupe_days), + }, + } def unknowns(self): out = [] @@ -826,6 +919,4 @@ def refresh_guesses(self): _atomic_write_json(jf, m) def discard_unknown(self, uid: str): - (self.unknown_dir / f"{uid}.json").unlink(missing_ok=True) - (self.unknown_dir / f"{uid}.jpg").unlink(missing_ok=True) - (self.unknown_dir / f"{uid}_full.jpg").unlink(missing_ok=True) + self._delete_unknown_files(uid) diff --git a/app/main.py b/app/main.py index ef0b8f9..d0da587 100644 --- a/app/main.py +++ b/app/main.py @@ -68,6 +68,14 @@ def main(): max_per_person=int(cfg["faceid"].get("max_faces_per_person", 40))) gallery.trimmed_keep = int(cfg["faceid"].get("trimmed_keep", 10)) gallery.dedupe_threshold = float(cfg["faceid"].get("dedupe_threshold", 0.65)) + gallery.review_queue_max_total = int(cfg["faceid"].get("review_queue_max_total", 200)) + gallery.review_queue_max_per_cluster = int(cfg["faceid"].get("review_queue_max_per_cluster", 12)) + gallery.review_queue_retention_days = int(cfg["faceid"].get("review_queue_retention_days", 14)) + gallery.review_queue_dedupe_days = int(cfg["faceid"].get("review_queue_dedupe_days", 7)) + queue_cleanup = gallery.prune_unknown_queue() + if queue_cleanup["removed"]: + log.info("review queue bounded: removed %d redundant crops; %d representative samples remain", + queue_cleanup["removed"], queue_cleanup["remaining"]) frigate_cfg = cfg["frigate"] frigate = FrigateAPI( frigate_cfg["url"], @@ -108,6 +116,8 @@ def main(): audit.prune_evidence( int(cfg["faceid"].get("known_evidence_days", 30)), int(cfg["faceid"].get("unknown_evidence_days", 14)), + int(cfg["faceid"].get("known_evidence_max", 300)), + int(cfg["faceid"].get("unknown_evidence_max", 300)), ) f = cfg["faceid"] camera_graph = f.get("camera_graph") or {} diff --git a/app/mqtt_listener.py b/app/mqtt_listener.py index bb46c35..c4e6829 100644 --- a/app/mqtt_listener.py +++ b/app/mqtt_listener.py @@ -21,6 +21,7 @@ from .decision import DecisionAccumulator, DecisionPolicy from .quality import measure_face_quality from .clip_analyzer import ClipAnalyzer +from .presence import RecognitionSessionTracker log = logging.getLogger("faceid.mqtt") @@ -64,6 +65,9 @@ def __init__( self.cameras = set(f.get("cameras") or []) self.set_sub_label = bool(f.get("set_sub_label", False)) self.presence_window = float(f.get("presence_window", 120)) + self.recognition_sessions = RecognitionSessionTracker( + f.get("recognition_session_seconds", 300) + ) self.ignore_thr = float(f.get("ignore_threshold", f.get("match_threshold", 0.5))) self.ignore_learning = bool(f.get("ignore_learning", True)) self.hires_enroll = bool(f.get("hires_enroll", True)) @@ -552,12 +556,17 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna ) if decision.status == "recognized": - if self.audit: + occurrence = self.recognition_sessions.classify( + decision.person, st["camera"], st.get("start_time") + ) + st["recognition_occurrence"] = occurrence + actionable = occurrence != "presence_update" + if self.audit and actionable: self.audit.save_evidence(eid, crop) 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:"): + if actionable and 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( @@ -577,7 +586,7 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna ) if self.set_sub_label: self.frigate.set_sub_label(eid, decision.person, decision.score) - if self.reid is not None: + if actionable and self.reid is not None: self.reid.seed( decision.person, st["camera"], st.get("context_frame"), ts=st.get("start_time"), @@ -587,6 +596,7 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna eid, "recognized", end_ts=st.get("end_time"), person=decision.person, score=decision.score, margin=decision.margin, confirmations=decision.confirmations, + occurrence=occurrence, ) return @@ -755,6 +765,7 @@ def _finalizer(self): person=decision.person, score=decision.score, margin=decision.margin, confirmations=decision.confirmations, + occurrence=st.get("recognition_occurrence"), ) elif not self.audit.was_finalized(eid): liveness_state = (st.get("liveness") or {}).get("state") @@ -797,6 +808,8 @@ def _post_event( if st.get("post_processed"): return st["post_processed"] = True + occurrence = st.get("recognition_occurrence") + actionable = occurrence != "presence_update" probable_person, probable_score = None, 0.0 if ( self.reid is not None @@ -816,7 +829,7 @@ def _post_event( ) link_person = person if status == "recognized" else probable_person scenario = None - if self.scenario_manager is not None: + if actionable and self.scenario_manager is not None: try: scenario = self.scenario_manager.attach( eid, @@ -847,8 +860,9 @@ def _post_event( "liveness": st.get("liveness"), "guest": st.get("guest"), "guest_access": st.get("guest_access"), + "occurrence": occurrence, } - if self.client and st.get("body"): + if actionable and self.client and st.get("body"): self.client.publish( f"{self.prefix}/body/advisory", json.dumps({"event_id": eid, "camera": st["camera"], **st["body"]}, @@ -856,7 +870,8 @@ def _post_event( retain=False, ) if ( - self.body_recognition is not None and status == "recognized" and person + actionable and self.body_recognition is not None + and status == "recognized" and person and st.get("context_frame") is not None ): try: @@ -865,14 +880,14 @@ def _post_event( ) except Exception: log.exception("event %s: could not stage body material", eid) - if self.dispatcher is not None: + if actionable and self.dispatcher is not None: try: self.dispatcher.dispatch( payload, client=self.client, prefix=self.prefix ) except Exception: log.exception("event %s: automation dispatch failed", eid) - if self.ai_context is not None: + if actionable and self.ai_context is not None: try: self.ai_context.submit( eid, st.get("context_frame"), @@ -892,6 +907,7 @@ def _publish_recognition( "event_id": eid, "ts": time.time(), "decision": decision_status or (decision.status if decision else name), "liveness": st.get("liveness"), + "occurrence": st.get("recognition_occurrence"), } if decision is not None: payload.update({ @@ -900,10 +916,12 @@ def _publish_recognition( "runner_up": decision.runner_up, "runner_up_score": round(float(decision.runner_up_score), 3), }) - self.recent.appendleft(payload) + actionable = st.get("recognition_occurrence") != "presence_update" + if actionable: + self.recent.appendleft(payload) # faceid/event genau einmal pro (Event, Person) — Score-Verbesserungen lösen keine # erneute Meldung aus (sonst mehrere Notifications für dieselbe Sichtung) - if self.client and st.get("announced") != name: + if actionable and self.client and st.get("announced") != name: st["announced"] = name self.client.publish(f"{self.prefix}/event", json.dumps(payload, ensure_ascii=False)) self.present.setdefault(st["camera"], {})[name] = time.time() diff --git a/app/presence.py b/app/presence.py new file mode 100644 index 0000000..81c7f0b --- /dev/null +++ b/app/presence.py @@ -0,0 +1,38 @@ +"""Turn noisy detector observations into useful human-facing visits.""" +from __future__ import annotations + +import threading +import time + + +class RecognitionSessionTracker: + """Classify a recognition as an arrival, camera move, or quiet update. + + Every observation refreshes the session. A continuously visible person therefore + produces one arrival, while a real absence followed by a return starts a new visit. + """ + + def __init__(self, gap_seconds: float = 300): + self.gap_seconds = max(30.0, float(gap_seconds)) + self._sessions: dict[str, dict] = {} + self._lock = threading.Lock() + + def classify(self, person: str, camera: str, ts: float | None = None) -> str: + observed_ts = float(ts or time.time()) + key = str(person).strip().casefold() + with self._lock: + previous = self._sessions.get(key) + if previous is None or observed_ts - previous["ts"] > self.gap_seconds: + occurrence = "arrival" + elif previous["camera"] != camera: + occurrence = "camera_transition" + else: + occurrence = "presence_update" + self._sessions[key] = {"camera": camera, "ts": observed_ts} + cutoff = observed_ts - self.gap_seconds * 4 + self._sessions = { + session_key: session + for session_key, session in self._sessions.items() + if session["ts"] >= cutoff + } + return occurrence diff --git a/app/vision_advisor.py b/app/vision_advisor.py index beb3391..233e4f2 100644 --- a/app/vision_advisor.py +++ b/app/vision_advisor.py @@ -40,6 +40,12 @@ def candidate_grid(self, event_id: str, *, limit=12) -> Path | None: safe = self.frames.media_store._path(event_id).stem path = self.audit_dir / f"{safe}.jpg" cv2.imwrite(str(path), grid, [cv2.IMWRITE_JPEG_QUALITY, 88]) + grids = sorted( + self.audit_dir.glob("*.jpg"), key=lambda item: item.stat().st_mtime, + reverse=True, + ) + for old in grids[100:]: + old.unlink(missing_ok=True) return path def inspect(self, event_id: str, candidates: list[str]) -> dict: diff --git a/app/webui.py b/app/webui.py index 6c29e7a..9986edf 100644 --- a/app/webui.py +++ b/app/webui.py @@ -312,6 +312,57 @@ def assign(body: AssignBody): gallery.refresh_guesses() return {"assigned": n, "slug": slug} + @app.post("/api/unknowns/resolve") + def resolve_unknowns(body: AssignBody, request: Request): + """Human-confirm queue events without turning every crop into gallery data.""" + persons_now = gallery.persons() + if body.person in persons_now: + name = persons_now[body.person]["name"] + else: + match = next((person["name"] for person in persons_now.values() + if person["name"] == body.person), None) + if match is None: + raise HTTPException(400, "Choose an existing person") + name = match + reviewer = (request.headers.get("x-remote-user-name") + or request.headers.get("x-forwarded-user") or "operator") + resolved = labeled = 0 + for uid in body.ids[:1000]: + jf = gallery.unknown_dir / f"{uid}.json" + try: + meta = json.loads(jf.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + event_id = str(meta.get("event_id") or "") + if event_id and processor.audit is not None: + labeled += int(processor.audit.set_ground_truth( + event_id, name, reviewer, action="review_queue_resolve" + )) + if processor.set_sub_label and event_id: + processor.frigate.set_sub_label(event_id, name, 1.0) + gallery.discard_unknown(uid) + resolved += 1 + return {"resolved": resolved, "labeled": labeled, "person": name, + "gallery_photos_added": 0} + + @app.post("/api/unknowns/maintenance") + def maintain_unknowns(): + return gallery.prune_unknown_queue() + + @app.get("/api/unknowns/policy") + def unknown_queue_policy(): + return { + "max_total": gallery.review_queue_max_total, + "max_per_identity": gallery.review_queue_max_per_cluster, + "retention_days": gallery.review_queue_retention_days, + "dedupe_days": gallery.review_queue_dedupe_days, + "evidence_max_total": ( + processor.audit.evidence_known_max + + processor.audit.evidence_unknown_max + if processor.audit is not None else 0 + ), + } + @app.post("/api/unknowns/auto_assign") def auto_assign(): """Alle Unknowns mit Galerie-Match >= match_threshold der vorgeschlagenen Person zuordnen.""" @@ -675,13 +726,14 @@ def activity( limit: int = 100, offset: int = 0, status: str | None = None, person: str | None = None, camera: str | None = None, date_from: float | None = None, date_to: float | None = None, - q: str | None = None, + q: str | None = None, include_presence_updates: bool = False, ): if processor.audit is None: return {"events": [], "scenarios": []} result = processor.audit.search_events( limit=limit, offset=offset, status=status, person=person, camera=camera, date_from=date_from, date_to=date_to, query=q, + include_presence_updates=include_presence_updates, ) result["scenarios"] = processor.audit.recent_scenarios(limit=limit) return result diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md index 83a4a90..5814db4 100644 --- a/faceid-addon/CHANGELOG.md +++ b/faceid-addon/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 5.2.1 — 2026-08-12 + +- **Bounded review inbox:** the verification screen keeps at most 200 representative + crops, 12 per likely identity and 14 days. Existing oversized queues are reduced + automatically on startup without deleting event metadata or investigation history. +- **Verify without polluting enrollment:** confirming a known person now labels and + closes all related tasks without copying every crop into the person's reference + gallery. Adding a training image is a separate, explicit action capped at three. +- **Visit-aware recognition:** the first sighting is an arrival, a move to another + camera is a transition and continuing visibility on the same camera is a quiet + presence update. Quiet updates refresh HA presence/last-seen state but do not create + duplicate notifications, automations, AI jobs, body-training material or statistics. +- **Hard evidence limits:** investigation images are capped at 300 known and 300 other + events, decoded-frame cache at 25 events and optional AI grids at 100. Event time, + score and decision metadata remain after transient imagery is removed. +- **Friendlier review UI:** explains the automatic limits, uses clear primary actions, + moves destructive options behind disclosure and provides manual cleanup feedback. + ## 5.2.0 — 2026-08-12 - **Five stable product areas:** Home, Events, People, Cameras and Management replace diff --git a/faceid-addon/DOCS.md b/faceid-addon/DOCS.md index c1333a9..2639113 100644 --- a/faceid-addon/DOCS.md +++ b/faceid-addon/DOCS.md @@ -72,6 +72,7 @@ second factor remain mandatory before any door-control automation acts. | `clip_analysis` / `clip_max_*` | sample diverse faces from the finished recording | | `cluster_eps` | how aggressively unknown faces are grouped in the review UI | | `presence_window` | camera sensor lists everyone seen within this many seconds | +| `recognition_session_seconds` | continuous same-person/same-camera sightings stay one visit; default 300 seconds | | `visit_gap_minutes` | gap after which repeated detections count as a new visit | | `calibration_target_far` | false-accept target for calibration recommendations | | `scenario_window` | maximum gap between identity-linked cross-camera events | @@ -89,6 +90,9 @@ second factor remain mandatory before any door-control automation acts. | `set_sub_label` | opt in to writing confirmed names back to Frigate events | | `ignore_margin` | ignore match must beat the best enrolled person by this much | | `audit_retention_days` | days to keep the SQLite decision history (0 = forever) | +| `known_evidence_max` / `unknown_evidence_max` | hard image caps; metadata remains after an image is pruned | +| `review_queue_max_total` / `review_queue_max_per_cluster` | hard limits for representative verification crops | +| `review_queue_retention_days` / `review_queue_dedupe_days` | age and similarity window for the verification inbox | | `cameras` | process only these cameras (empty = all) | | `discovery_cameras` | cameras that get a Home Assistant sensor | | `suggest_threshold` | score at which unknown faces are grouped into a "looks like " suggestion | diff --git a/faceid-addon/app/__init__.py b/faceid-addon/app/__init__.py index 221603a..91ab71e 100644 --- a/faceid-addon/app/__init__.py +++ b/faceid-addon/app/__init__.py @@ -1 +1 @@ -VERSION = "5.2.0" +VERSION = "5.2.1" diff --git a/faceid-addon/app/audit.py b/faceid-addon/app/audit.py index 1765ef9..9360e9a 100644 --- a/faceid-addon/app/audit.py +++ b/faceid-addon/app/audit.py @@ -14,6 +14,11 @@ def __init__(self, path: Path, retention_days: int = 90): self.evidence_dir = self.path.parent / "audit_images" self.evidence_dir.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() + self.evidence_known_days = 30 + self.evidence_unknown_days = 14 + self.evidence_known_max = 300 + self.evidence_unknown_max = 300 + self._evidence_since_prune = 0 self._init_db() self.prune(retention_days) @@ -38,6 +43,13 @@ def save_evidence(self, event_id: str, image) -> Path | None: temporary = target.with_suffix(".tmp") temporary.write_bytes(encoded.tobytes()) temporary.replace(target) + self._evidence_since_prune += 1 + if self._evidence_since_prune >= 25: + self._evidence_since_prune = 0 + self.prune_evidence( + self.evidence_known_days, self.evidence_unknown_days, + self.evidence_known_max, self.evidence_unknown_max, + ) return target except (OSError, ValueError): return None @@ -145,6 +157,7 @@ def _init_db(self): ("ground_truth_by", "TEXT"), ("liveness_status", "TEXT"), ("liveness_score", "REAL"), + ("occurrence", "TEXT"), ): self._ensure_column(con, "events", column, declaration) @@ -246,12 +259,13 @@ def finalize( score: float = 0.0, margin: float = 0.0, confirmations: int = 0, + occurrence: str | None = None, ): with self._lock, self._connection() as con: con.execute( """ UPDATE events SET end_ts=?, status=?, person=?, score=?, margin=?, - confirmations=?, updated_ts=? + confirmations=?, occurrence=?, updated_ts=? WHERE event_id=? """, ( @@ -261,6 +275,7 @@ def finalize( score, margin, confirmations, + occurrence, time.time(), event_id, ), @@ -303,18 +318,25 @@ def prune(self, retention_days: int): except OSError: pass - def recent(self, limit: int = 100, status: str | None = None): + def recent( + self, limit: int = 100, status: str | None = None, + include_presence_updates: bool = False, + ): sql = """ SELECT event_id, camera, start_ts, end_ts, status, person, score, margin, confirmations, updated_ts, ground_truth, scenario_id, ai_description, ai_tags, probable_person, probable_score, - liveness_status, liveness_score + liveness_status, liveness_score, occurrence FROM events """ - params = [] + clauses, params = [], [] + if not include_presence_updates: + clauses.append("COALESCE(occurrence,'')!='presence_update'") if status: - sql += " WHERE status=?" + clauses.append("status=?") params.append(status) + if clauses: + sql += f" WHERE {' AND '.join(clauses)}" sql += " ORDER BY updated_ts DESC LIMIT ?" params.append(max(1, min(int(limit), 500))) with self._lock, self._connection() as con: @@ -326,8 +348,11 @@ def search_events( status: str | None = None, person: str | None = None, camera: str | None = None, date_from: float | None = None, date_to: float | None = None, query: str | None = None, + include_presence_updates: bool = False, ): clauses, params = [], [] + if not include_presence_updates: + clauses.append("COALESCE(occurrence,'')!='presence_update'") if status: clauses.append("status=?") params.append(status) @@ -357,7 +382,8 @@ def search_events( event_id, camera, start_ts, end_ts, status, person, score, margin, confirmations, updated_ts, ground_truth, ground_truth_ts, ground_truth_by, scenario_id, ai_description, ai_tags, - probable_person, probable_score, liveness_status, liveness_score + probable_person, probable_score, liveness_status, liveness_score, + occurrence """ with self._lock, self._connection() as con: con.row_factory = sqlite3.Row @@ -507,6 +533,7 @@ def person_profile( con.row_factory = sqlite3.Row recognition_times = con.execute( "SELECT start_ts FROM events WHERE status='recognized' " + "AND COALESCE(occurrence,'')!='presence_update' " "AND person=? AND start_ts IS NOT NULL", (person,), ).fetchall() @@ -561,6 +588,7 @@ def system_report(self): SUM(CASE WHEN status IN ('unknown','ambiguous') THEN 1 ELSE 0 END) review, AVG(CASE WHEN score>0 THEN score END) avg_score FROM events WHERE status!='processing' AND start_ts>=? + AND COALESCE(occurrence,'')!='presence_update' GROUP BY camera ORDER BY events DESC """, (time.time() - 7 * 86400,) ).fetchall() @@ -582,7 +610,8 @@ def recognized_timeline( self, *, person: str | None = None, after_ts: float = 0 ) -> list[dict]: """Chronological recognized events used to derive visits, not raw frame counts.""" - where = ["status='recognized'", "person IS NOT NULL", "start_ts>=?"] + where = ["status='recognized'", "person IS NOT NULL", "start_ts>=?", + "COALESCE(occurrence,'')!='presence_update'"] params: list = [float(after_ts)] if person: where.append("person=?") @@ -620,6 +649,7 @@ def camera_funnels(self, days: int = 7) -> list[dict]: AVG(CASE WHEN o.quality>0 THEN o.quality END) avg_quality FROM events e LEFT JOIN observation_summary o ON o.event_id=e.event_id WHERE e.status!='processing' AND e.start_ts>=? + AND COALESCE(e.occurrence,'')!='presence_update' GROUP BY e.camera ORDER BY events DESC """, (cutoff,) ).fetchall() @@ -649,21 +679,34 @@ def camera_samples(self, camera: str, limit: int = 24) -> list[dict]: ).fetchall() return [dict(row) for row in rows] - def prune_evidence(self, known_days: int, unknown_days: int) -> int: - """Apply separate image retention without deleting recognition metadata.""" + def prune_evidence( + self, known_days: int, unknown_days: int, + known_max: int = 300, unknown_max: int = 300, + ) -> int: + """Bound evidence by age and count without deleting event metadata.""" + self.evidence_known_days = int(known_days) + self.evidence_unknown_days = int(unknown_days) + self.evidence_known_max = max(0, int(known_max)) + self.evidence_unknown_max = max(0, int(unknown_max)) now = time.time() removed = 0 with self._lock, self._connection() as con: rows = con.execute( "SELECT event_id, status, updated_ts FROM events " - "WHERE status!='processing'" + "WHERE status!='processing' ORDER BY updated_ts DESC" ).fetchall() + kept = {"known": 0, "unknown": 0} for event_id, status, updated_ts in rows: - days = known_days if status == "recognized" else unknown_days - if days <= 0 or float(updated_ts or now) >= now - days * 86400: - continue + kind = "known" if status == "recognized" else "unknown" + days = known_days if kind == "known" else unknown_days + maximum = self.evidence_known_max if kind == "known" else self.evidence_unknown_max path = self.evidence_path(event_id) - if path.is_file(): + if not path.is_file(): + continue + kept[kind] += 1 + expired = days > 0 and float(updated_ts or now) < now - days * 86400 + over_cap = maximum == 0 or kept[kind] > maximum + if expired or over_cap: try: path.unlink() removed += 1 @@ -703,6 +746,7 @@ def person_statistics(self): SUM(CASE WHEN start_ts>=? THEN 1 ELSE 0 END) AS last_30_days FROM events WHERE status='recognized' AND person IS NOT NULL + AND COALESCE(occurrence,'')!='presence_update' GROUP BY person """, (now - 7 * 86400, now - 30 * 86400), @@ -722,6 +766,7 @@ def person_statistics(self): """ SELECT camera, COUNT(*) AS count FROM events WHERE status='recognized' AND person=? + AND COALESCE(occurrence,'')!='presence_update' GROUP BY camera ORDER BY count DESC, camera """, (row["person"],), @@ -745,6 +790,7 @@ def dashboard_summary(self): SELECT status, COUNT(*) AS count FROM events WHERE date(start_ts, 'unixepoch', 'localtime') = date('now', 'localtime') + AND COALESCE(occurrence,'')!='presence_update' GROUP BY status """ ).fetchall() @@ -765,7 +811,8 @@ def traffic_events(self, *, after_ts: float, limit: int = 10000) -> list[dict]: 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 ?", + "WHERE start_ts>=? AND status!='processing' " + "AND COALESCE(occurrence,'')!='presence_update' ORDER BY start_ts LIMIT ?", (float(after_ts), max(1, min(int(limit), 100000))), ).fetchall() return [dict(row) for row in rows] diff --git a/faceid-addon/app/frame_distributor.py b/faceid-addon/app/frame_distributor.py index 3ade9fd..b3a5d3d 100644 --- a/faceid-addon/app/frame_distributor.py +++ b/faceid-addon/app/frame_distributor.py @@ -22,6 +22,7 @@ def __init__(self, data_dir: Path, media_store, *, decode_mode: str = "auto", ma self.root.mkdir(parents=True, exist_ok=True) self.decode_mode = decode_mode if decode_mode in ("auto", "software", "vaapi", "cuda") else "auto" self.max_frames = max(4, min(int(max_frames), 120)) + self.max_cached_events = 25 self._lock = threading.Lock() self._stats = {"requests": 0, "cache_hits": 0, "ffmpeg": 0, "opencv": 0, "hardware": 0, "fallbacks": 0, "last_backend": None, "last_error": None} @@ -127,11 +128,16 @@ def report(self) -> dict: def prune(self): cutoff = time.time() - float(getattr(self.media_store, "retention_seconds", 86400)) - for directory in self.root.iterdir(): + directories = sorted( + (path for path in self.root.iterdir() if path.is_dir()), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + for index, directory in enumerate(directories): if not directory.is_dir(): continue try: - if directory.stat().st_mtime >= cutoff: + if index < self.max_cached_events and directory.stat().st_mtime >= cutoff: continue for path in directory.iterdir(): path.unlink(missing_ok=True) diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py index 3fda3d5..98869b5 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -68,6 +68,12 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40): self.max_per_person = int(max_per_person) # 0 = unbegrenzt self.trimmed_keep = 10 # wie viele beiseitegelegte Fotos je Person aufgehoben werden self.dedupe_threshold = 0.65 # ab hier gilt ein Foto als Duplikat (Hover-Highlight + Dedup) + # The review queue is an inbox, not a second photo archive. Recognition + # events remain in AuditStore even when their temporary review crop is pruned. + self.review_queue_max_total = 200 + self.review_queue_max_per_cluster = 12 + self.review_queue_retention_days = 14 + self.review_queue_dedupe_days = 7 self._lock = threading.Lock() self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]} self._ign_emb = np.zeros((0, 512), dtype=np.float32) @@ -752,7 +758,7 @@ def _drop_ignored(self, iid: str): def save_unknown(self, crop_bgr: np.ndarray, embedding: np.ndarray, meta: dict, dedupe_sim: float = 0.75, full_bgr: np.ndarray | None = None): - """Unbekanntes Gesicht ablegen; sehr ähnliche jüngste Unknowns werden übersprungen.""" + """Store one useful review sample while the durable event stays in AuditStore.""" with self._lock: now = time.time() for jf in self.unknown_dir.glob("*.json"): @@ -760,7 +766,7 @@ def save_unknown(self, crop_bgr: np.ndarray, embedding: np.ndarray, meta: dict, m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - if now - m.get("ts", 0) < 3600: + if now - m.get("ts", 0) < self.review_queue_dedupe_days * 86400: sim = float(np.dot(np.array(m["embedding"], dtype=np.float32), embedding)) if sim > dedupe_sim: return None @@ -770,7 +776,94 @@ def save_unknown(self, crop_bgr: np.ndarray, embedding: np.ndarray, meta: dict, cv2.imwrite(str(self.unknown_dir / f"{uid}_full.jpg"), full_bgr, [cv2.IMWRITE_JPEG_QUALITY, 85]) meta = dict(meta, ts=now, embedding=[round(float(v), 6) for v in embedding]) _atomic_write_json(self.unknown_dir / f"{uid}.json", meta) - return uid + self.prune_unknown_queue() + return uid + + def _delete_unknown_files(self, uid: str): + (self.unknown_dir / f"{uid}.json").unlink(missing_ok=True) + (self.unknown_dir / f"{uid}.jpg").unlink(missing_ok=True) + (self.unknown_dir / f"{uid}_full.jpg").unlink(missing_ok=True) + + def prune_unknown_queue(self) -> dict: + """Bound the review inbox by age, representative cluster and global size. + + Only temporary review crops are removed. Audit events, scores, timestamps, + evidence retention and statistics are deliberately untouched. + """ + with self._lock: + now = time.time() + entries = [] + removed = {"expired": 0, "duplicate_event": 0, "cluster_cap": 0, "global_cap": 0} + seen_events = set() + for jf in sorted(self.unknown_dir.glob("*.json"), reverse=True): + try: + meta = json.loads(jf.read_text(encoding="utf-8")) + emb = np.array(meta["embedding"], dtype=np.float32) + except (json.JSONDecodeError, OSError, KeyError, ValueError): + self._delete_unknown_files(jf.stem) + removed["expired"] += 1 + continue + ts = float(meta.get("ts") or 0) + if self.review_queue_retention_days > 0 and now - ts > self.review_queue_retention_days * 86400: + self._delete_unknown_files(jf.stem); removed["expired"] += 1; continue + event_id = str(meta.get("event_id") or "") + if event_id and event_id in seen_events: + self._delete_unknown_files(jf.stem); removed["duplicate_event"] += 1; continue + if event_id: + seen_events.add(event_id) + entries.append({"id": jf.stem, "meta": meta, "emb": emb, "ts": ts}) + + # Known-person suggestions are naturally one task. Remaining faces are + # greedily grouped by cosine similarity so every identity keeps a small, + # varied set instead of one image per hour forever. + groups: list[list[dict]] = [] + known: dict[str, list[dict]] = {} + unknown: list[dict] = [] + for item in entries: + guess = str(item["meta"].get("guess") or "").strip() + if guess: + known.setdefault(guess.casefold(), []).append(item) + else: + unknown.append(item) + groups.extend(known.values()) + unknown_groups: list[list[dict]] = [] + for item in unknown: + target = next((group for group in unknown_groups + if float(np.dot(group[0]["emb"], item["emb"])) >= 0.78), None) + if target is None: + unknown_groups.append([item]) + else: + target.append(item) + groups.extend(unknown_groups) + + kept_groups: list[list[dict]] = [] + for group in groups: + group.sort(key=lambda row: row["ts"], reverse=True) + keep = group[:max(1, self.review_queue_max_per_cluster)] + kept_groups.append(keep) + for item in group[len(keep):]: + self._delete_unknown_files(item["id"]); removed["cluster_cap"] += 1 + + # Round-robin across identities prevents a busy doorway or one resident + # from consuming the complete queue. + globally_kept = [] + for index in range(max((len(group) for group in kept_groups), default=0)): + for group in kept_groups: + if index < len(group): + globally_kept.append(group[index]) + for item in globally_kept[max(1, self.review_queue_max_total):]: + self._delete_unknown_files(item["id"]); removed["global_cap"] += 1 + return { + **removed, + "removed": sum(removed.values()), + "remaining": min(len(globally_kept), max(1, self.review_queue_max_total)), + "policy": { + "max_total": max(1, self.review_queue_max_total), + "max_per_identity": max(1, self.review_queue_max_per_cluster), + "retention_days": max(1, self.review_queue_retention_days), + "dedupe_days": max(1, self.review_queue_dedupe_days), + }, + } def unknowns(self): out = [] @@ -826,6 +919,4 @@ def refresh_guesses(self): _atomic_write_json(jf, m) def discard_unknown(self, uid: str): - (self.unknown_dir / f"{uid}.json").unlink(missing_ok=True) - (self.unknown_dir / f"{uid}.jpg").unlink(missing_ok=True) - (self.unknown_dir / f"{uid}_full.jpg").unlink(missing_ok=True) + self._delete_unknown_files(uid) diff --git a/faceid-addon/app/main.py b/faceid-addon/app/main.py index ef0b8f9..d0da587 100644 --- a/faceid-addon/app/main.py +++ b/faceid-addon/app/main.py @@ -68,6 +68,14 @@ def main(): max_per_person=int(cfg["faceid"].get("max_faces_per_person", 40))) gallery.trimmed_keep = int(cfg["faceid"].get("trimmed_keep", 10)) gallery.dedupe_threshold = float(cfg["faceid"].get("dedupe_threshold", 0.65)) + gallery.review_queue_max_total = int(cfg["faceid"].get("review_queue_max_total", 200)) + gallery.review_queue_max_per_cluster = int(cfg["faceid"].get("review_queue_max_per_cluster", 12)) + gallery.review_queue_retention_days = int(cfg["faceid"].get("review_queue_retention_days", 14)) + gallery.review_queue_dedupe_days = int(cfg["faceid"].get("review_queue_dedupe_days", 7)) + queue_cleanup = gallery.prune_unknown_queue() + if queue_cleanup["removed"]: + log.info("review queue bounded: removed %d redundant crops; %d representative samples remain", + queue_cleanup["removed"], queue_cleanup["remaining"]) frigate_cfg = cfg["frigate"] frigate = FrigateAPI( frigate_cfg["url"], @@ -108,6 +116,8 @@ def main(): audit.prune_evidence( int(cfg["faceid"].get("known_evidence_days", 30)), int(cfg["faceid"].get("unknown_evidence_days", 14)), + int(cfg["faceid"].get("known_evidence_max", 300)), + int(cfg["faceid"].get("unknown_evidence_max", 300)), ) f = cfg["faceid"] camera_graph = f.get("camera_graph") or {} diff --git a/faceid-addon/app/mqtt_listener.py b/faceid-addon/app/mqtt_listener.py index bb46c35..c4e6829 100644 --- a/faceid-addon/app/mqtt_listener.py +++ b/faceid-addon/app/mqtt_listener.py @@ -21,6 +21,7 @@ from .decision import DecisionAccumulator, DecisionPolicy from .quality import measure_face_quality from .clip_analyzer import ClipAnalyzer +from .presence import RecognitionSessionTracker log = logging.getLogger("faceid.mqtt") @@ -64,6 +65,9 @@ def __init__( self.cameras = set(f.get("cameras") or []) self.set_sub_label = bool(f.get("set_sub_label", False)) self.presence_window = float(f.get("presence_window", 120)) + self.recognition_sessions = RecognitionSessionTracker( + f.get("recognition_session_seconds", 300) + ) self.ignore_thr = float(f.get("ignore_threshold", f.get("match_threshold", 0.5))) self.ignore_learning = bool(f.get("ignore_learning", True)) self.hires_enroll = bool(f.get("hires_enroll", True)) @@ -552,12 +556,17 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna ) if decision.status == "recognized": - if self.audit: + occurrence = self.recognition_sessions.classify( + decision.person, st["camera"], st.get("start_time") + ) + st["recognition_occurrence"] = occurrence + actionable = occurrence != "presence_update" + if self.audit and actionable: self.audit.save_evidence(eid, crop) 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:"): + if actionable and 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( @@ -577,7 +586,7 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna ) if self.set_sub_label: self.frigate.set_sub_label(eid, decision.person, decision.score) - if self.reid is not None: + if actionable and self.reid is not None: self.reid.seed( decision.person, st["camera"], st.get("context_frame"), ts=st.get("start_time"), @@ -587,6 +596,7 @@ def _process_face(self, eid: str, st: dict, img, face, quality=None, source="sna eid, "recognized", end_ts=st.get("end_time"), person=decision.person, score=decision.score, margin=decision.margin, confirmations=decision.confirmations, + occurrence=occurrence, ) return @@ -755,6 +765,7 @@ def _finalizer(self): person=decision.person, score=decision.score, margin=decision.margin, confirmations=decision.confirmations, + occurrence=st.get("recognition_occurrence"), ) elif not self.audit.was_finalized(eid): liveness_state = (st.get("liveness") or {}).get("state") @@ -797,6 +808,8 @@ def _post_event( if st.get("post_processed"): return st["post_processed"] = True + occurrence = st.get("recognition_occurrence") + actionable = occurrence != "presence_update" probable_person, probable_score = None, 0.0 if ( self.reid is not None @@ -816,7 +829,7 @@ def _post_event( ) link_person = person if status == "recognized" else probable_person scenario = None - if self.scenario_manager is not None: + if actionable and self.scenario_manager is not None: try: scenario = self.scenario_manager.attach( eid, @@ -847,8 +860,9 @@ def _post_event( "liveness": st.get("liveness"), "guest": st.get("guest"), "guest_access": st.get("guest_access"), + "occurrence": occurrence, } - if self.client and st.get("body"): + if actionable and self.client and st.get("body"): self.client.publish( f"{self.prefix}/body/advisory", json.dumps({"event_id": eid, "camera": st["camera"], **st["body"]}, @@ -856,7 +870,8 @@ def _post_event( retain=False, ) if ( - self.body_recognition is not None and status == "recognized" and person + actionable and self.body_recognition is not None + and status == "recognized" and person and st.get("context_frame") is not None ): try: @@ -865,14 +880,14 @@ def _post_event( ) except Exception: log.exception("event %s: could not stage body material", eid) - if self.dispatcher is not None: + if actionable and self.dispatcher is not None: try: self.dispatcher.dispatch( payload, client=self.client, prefix=self.prefix ) except Exception: log.exception("event %s: automation dispatch failed", eid) - if self.ai_context is not None: + if actionable and self.ai_context is not None: try: self.ai_context.submit( eid, st.get("context_frame"), @@ -892,6 +907,7 @@ def _publish_recognition( "event_id": eid, "ts": time.time(), "decision": decision_status or (decision.status if decision else name), "liveness": st.get("liveness"), + "occurrence": st.get("recognition_occurrence"), } if decision is not None: payload.update({ @@ -900,10 +916,12 @@ def _publish_recognition( "runner_up": decision.runner_up, "runner_up_score": round(float(decision.runner_up_score), 3), }) - self.recent.appendleft(payload) + actionable = st.get("recognition_occurrence") != "presence_update" + if actionable: + self.recent.appendleft(payload) # faceid/event genau einmal pro (Event, Person) — Score-Verbesserungen lösen keine # erneute Meldung aus (sonst mehrere Notifications für dieselbe Sichtung) - if self.client and st.get("announced") != name: + if actionable and self.client and st.get("announced") != name: st["announced"] = name self.client.publish(f"{self.prefix}/event", json.dumps(payload, ensure_ascii=False)) self.present.setdefault(st["camera"], {})[name] = time.time() diff --git a/faceid-addon/app/presence.py b/faceid-addon/app/presence.py new file mode 100644 index 0000000..81c7f0b --- /dev/null +++ b/faceid-addon/app/presence.py @@ -0,0 +1,38 @@ +"""Turn noisy detector observations into useful human-facing visits.""" +from __future__ import annotations + +import threading +import time + + +class RecognitionSessionTracker: + """Classify a recognition as an arrival, camera move, or quiet update. + + Every observation refreshes the session. A continuously visible person therefore + produces one arrival, while a real absence followed by a return starts a new visit. + """ + + def __init__(self, gap_seconds: float = 300): + self.gap_seconds = max(30.0, float(gap_seconds)) + self._sessions: dict[str, dict] = {} + self._lock = threading.Lock() + + def classify(self, person: str, camera: str, ts: float | None = None) -> str: + observed_ts = float(ts or time.time()) + key = str(person).strip().casefold() + with self._lock: + previous = self._sessions.get(key) + if previous is None or observed_ts - previous["ts"] > self.gap_seconds: + occurrence = "arrival" + elif previous["camera"] != camera: + occurrence = "camera_transition" + else: + occurrence = "presence_update" + self._sessions[key] = {"camera": camera, "ts": observed_ts} + cutoff = observed_ts - self.gap_seconds * 4 + self._sessions = { + session_key: session + for session_key, session in self._sessions.items() + if session["ts"] >= cutoff + } + return occurrence diff --git a/faceid-addon/app/vision_advisor.py b/faceid-addon/app/vision_advisor.py index beb3391..233e4f2 100644 --- a/faceid-addon/app/vision_advisor.py +++ b/faceid-addon/app/vision_advisor.py @@ -40,6 +40,12 @@ def candidate_grid(self, event_id: str, *, limit=12) -> Path | None: safe = self.frames.media_store._path(event_id).stem path = self.audit_dir / f"{safe}.jpg" cv2.imwrite(str(path), grid, [cv2.IMWRITE_JPEG_QUALITY, 88]) + grids = sorted( + self.audit_dir.glob("*.jpg"), key=lambda item: item.stat().st_mtime, + reverse=True, + ) + for old in grids[100:]: + old.unlink(missing_ok=True) return path def inspect(self, event_id: str, candidates: list[str]) -> dict: diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py index 6c29e7a..9986edf 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -312,6 +312,57 @@ def assign(body: AssignBody): gallery.refresh_guesses() return {"assigned": n, "slug": slug} + @app.post("/api/unknowns/resolve") + def resolve_unknowns(body: AssignBody, request: Request): + """Human-confirm queue events without turning every crop into gallery data.""" + persons_now = gallery.persons() + if body.person in persons_now: + name = persons_now[body.person]["name"] + else: + match = next((person["name"] for person in persons_now.values() + if person["name"] == body.person), None) + if match is None: + raise HTTPException(400, "Choose an existing person") + name = match + reviewer = (request.headers.get("x-remote-user-name") + or request.headers.get("x-forwarded-user") or "operator") + resolved = labeled = 0 + for uid in body.ids[:1000]: + jf = gallery.unknown_dir / f"{uid}.json" + try: + meta = json.loads(jf.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + event_id = str(meta.get("event_id") or "") + if event_id and processor.audit is not None: + labeled += int(processor.audit.set_ground_truth( + event_id, name, reviewer, action="review_queue_resolve" + )) + if processor.set_sub_label and event_id: + processor.frigate.set_sub_label(event_id, name, 1.0) + gallery.discard_unknown(uid) + resolved += 1 + return {"resolved": resolved, "labeled": labeled, "person": name, + "gallery_photos_added": 0} + + @app.post("/api/unknowns/maintenance") + def maintain_unknowns(): + return gallery.prune_unknown_queue() + + @app.get("/api/unknowns/policy") + def unknown_queue_policy(): + return { + "max_total": gallery.review_queue_max_total, + "max_per_identity": gallery.review_queue_max_per_cluster, + "retention_days": gallery.review_queue_retention_days, + "dedupe_days": gallery.review_queue_dedupe_days, + "evidence_max_total": ( + processor.audit.evidence_known_max + + processor.audit.evidence_unknown_max + if processor.audit is not None else 0 + ), + } + @app.post("/api/unknowns/auto_assign") def auto_assign(): """Alle Unknowns mit Galerie-Match >= match_threshold der vorgeschlagenen Person zuordnen.""" @@ -675,13 +726,14 @@ def activity( limit: int = 100, offset: int = 0, status: str | None = None, person: str | None = None, camera: str | None = None, date_from: float | None = None, date_to: float | None = None, - q: str | None = None, + q: str | None = None, include_presence_updates: bool = False, ): if processor.audit is None: return {"events": [], "scenarios": []} result = processor.audit.search_events( limit=limit, offset=offset, status=status, person=person, camera=camera, date_from=date_from, date_to=date_to, query=q, + include_presence_updates=include_presence_updates, ) result["scenarios"] = processor.audit.recent_scenarios(limit=limit) return result diff --git a/faceid-addon/config.yaml b/faceid-addon/config.yaml index 23ca7c3..3314376 100644 --- a/faceid-addon/config.yaml +++ b/faceid-addon/config.yaml @@ -1,5 +1,5 @@ name: FaceID -version: "5.2.0" +version: "5.2.1" 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.2.0 +ingress_entry: ui-5.2.1 panel_icon: mdi:face-recognition panel_title: FaceID ports: @@ -64,10 +64,17 @@ options: audit_retention_days: 90 known_evidence_days: 30 unknown_evidence_days: 14 + known_evidence_max: 300 + unknown_evidence_max: 300 + review_queue_max_total: 200 + review_queue_max_per_cluster: 12 + review_queue_retention_days: 14 + review_queue_dedupe_days: 7 liveness_enabled: true liveness_threshold: 0.5 liveness_required_frames: 3 presence_window: 120 + recognition_session_seconds: 300 visit_gap_minutes: 15 calibration_target_far: 0.01 scenario_window: 90 @@ -130,10 +137,17 @@ schema: audit_retention_days: int(0,3650) known_evidence_days: int(1,3650) unknown_evidence_days: int(1,3650) + known_evidence_max: int(0,5000) + unknown_evidence_max: int(0,5000) + review_queue_max_total: int(25,1000) + review_queue_max_per_cluster: int(3,50) + review_queue_retention_days: int(1,90) + review_queue_dedupe_days: int(1,30) liveness_enabled: bool liveness_threshold: float(0.0,1.0) liveness_required_frames: int(2,8) presence_window: int(10,3600) + recognition_session_seconds: int(30,3600) visit_gap_minutes: int(2,120) calibration_target_far: float(0.0,0.5) scenario_window: int(5,900) diff --git a/faceid-addon/run.sh b/faceid-addon/run.sh index 4721948..187b075 100755 --- a/faceid-addon/run.sh +++ b/faceid-addon/run.sh @@ -93,10 +93,13 @@ faceid: audit_retention_days: $(cfg '.audit_retention_days') known_evidence_days: $(cfg '.known_evidence_days') unknown_evidence_days: $(cfg '.unknown_evidence_days') + known_evidence_max: $(cfg '.known_evidence_max // 300') + unknown_evidence_max: $(cfg '.unknown_evidence_max // 300') liveness_enabled: $(cfg '.liveness_enabled // true') liveness_threshold: $(cfg '.liveness_threshold // 0.5') liveness_required_frames: $(cfg '.liveness_required_frames // 3') presence_window: $(cfg '.presence_window') + recognition_session_seconds: $(cfg '.recognition_session_seconds // 300') visit_gap_minutes: $(cfg '.visit_gap_minutes // 15') calibration_target_far: $(cfg '.calibration_target_far') scenario_window: $(cfg '.scenario_window') diff --git a/faceid-addon/static/index.html b/faceid-addon/static/index.html index 631732a..4bf679b 100644 --- a/faceid-addon/static/index.html +++ b/faceid-addon/static/index.html @@ -283,7 +283,7 @@

FaceID מרכז