diff --git a/CHANGELOG.md b/CHANGELOG.md index 8555a79..e1df89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## Unreleased + +- **Looks-like cues show the gallery face.** Unknown review now stores `guess_top_slug` + `guess_top_photo` (the reference that best explains a guess) and shows a thumbnail next to *looks like* / ★ Looks like headers; hover enlarges, click opens the existing full viewer. Keys are omitted when there is no photo; deleting or setting aside a reference photo refreshes dangling cues. + ## 0.25.1 — 2026-10-06 - **A failed assignment no longer leaves an empty person behind.** Assigning faces created diff --git a/README.md b/README.md index b333fe0..ed91247 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ opened read-only and never changed. *All faces below are AI-generated (StyleGAN) — no real persons.* **Unknown review** — new faces arrive auto-clustered; assign a whole cluster with one -click, or scan your camera history to bootstrap the gallery: +click, or scan your camera history to bootstrap the gallery. A *looks like* guess shows +that person's gallery reference photo beside the name (hover to enlarge): ![Unknown review with auto-clustered faces](docs/screenshot-unknowns.png) diff --git a/app/backfill.py b/app/backfill.py index 8c3b5e3..ec38c6a 100644 --- a/app/backfill.py +++ b/app/backfill.py @@ -79,7 +79,7 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, upgraded = True stats["rescued"] = stats.get("rescued", 0) + 1 emb = face.normed_embedding - slug, name, score = gallery.match(emb) + slug, name, score, top_photo = gallery.match(emb) if gallery.match_ignored(emb) >= max(match_thr, score): stats["ignored"] += 1 continue @@ -102,9 +102,13 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, stats["hires"] = stats.get("hires", 0) + 1 uid = gallery.save_unknown( crop, save_emb, - {"camera": ev["camera"], "event_id": ev["id"], "backfill": True, + {k: v for k, v in { + "camera": ev["camera"], "event_id": ev["id"], "backfill": True, "event_ts": ev.get("start_time"), # wann es passierte, nicht wann wir es fanden - "guess": name, "guess_score": round(float(score), 3)}, + "guess": name, "guess_score": round(float(score), 3), + "guess_top_photo": top_photo, + "guess_top_slug": slug, + }.items() if k not in ("guess_top_photo", "guess_top_slug") or v}, dedupe_sim=dedupe, full_bgr=full, ) stats["dupe" if uid is None else "faces"] += 1 diff --git a/app/gallery.py b/app/gallery.py index 585678b..b767608 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -5,9 +5,12 @@ """ import hashlib import json +import os import logging import re import shutil +import stat as stat_module +import tempfile import threading import time import unicodedata @@ -65,6 +68,13 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40): self.self_outlier_min_photos = 5 # darunter ist der Median nicht belastbar self.dedupe_threshold = 0.65 # ab hier gilt ein Foto als Duplikat (Hover-Highlight + Dedup) self._lock = threading.Lock() + self._refresh_sched_lock = threading.Lock() + self._refresh_deadline = None + self._refresh_first_request = None + self._refresh_thread = None + # Eigenes Lock, nicht self._lock: refresh_guesses ruft match(), und match() + # nimmt self._lock — unter demselben Lock waere das ein Deadlock. + self._refresh_run_lock = threading.Lock() self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]} self._ign_emb = np.zeros((0, 512), dtype=np.float32) self._ign_ids: list[str] = [] @@ -746,11 +756,13 @@ def delete_person(self, slug: str): # ---------- Matching ---------- def match(self, embedding: np.ndarray): - """-> (slug, name, score) der besten Person oder (None, None, best_score). + """-> (slug, name, score, top_photo) der besten Person oder (None, None, best_score, None). Score = Mittel der Top-k Ähnlichkeiten pro Person (statt Max) — eine Person - mit vielen Referenzbildern gewinnt Grenzfälle nicht mehr per Einzel-Ausreißer.""" + mit vielen Referenzbildern gewinnt Grenzfälle nicht mehr per Einzel-Ausreißer. + top_photo = Dateiname mit der höchsten Einzel-Ähnlichkeit bei der Gewinner-Person + (für die Unknown-UI „looks like“-Vorschau).""" with self._lock: - best = (None, None, 0.0) + best = (None, None, 0.0, None) for slug, e in self._cache.items(): if len(e["files"]) == 0: continue @@ -758,7 +770,11 @@ def match(self, embedding: np.ndarray): k = min(self.top_k, len(sims)) score = float(np.sort(sims)[-k:].mean()) if score > best[2]: - best = (slug, e["name"], score) + # Thumbnail = best *single* photo (argmax), not one of the top-k + # that entered the mean score — clearest face for the UI cue. + # files[i] aligns with emb[i] / sims[i] from the same person load. + top_i = int(np.argmax(sims)) + best = (slug, e["name"], score, e["files"][top_i]) return best # ---------- Ignore-Liste (Negativ-Anker) ---------- @@ -1030,15 +1046,140 @@ def assign_unknown(self, uid: str, slug: str): return True def refresh_guesses(self): - """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen).""" + """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen). + + Laeuft serialisiert: der Hintergrund-Worker und ein Request-Handler koennen + sonst gleichzeitig durchlaufen, und zwei Durchgaenge am selben Unknown + verschraenken sich zu "A schreibt halb, B ersetzt" — dann wird genau die + abgeschnittene Datei zur echten, die das atomare Ersetzen verhindern soll. + """ + with self._refresh_run_lock: + self._refresh_guesses_once() + + def _refresh_guesses_once(self): + changed = False for jf in self.unknown_dir.glob("*.json"): try: m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - _, name, score = self.match(np.array(m["embedding"], dtype=np.float32)) + slug, name, score, top_photo = self.match(np.array(m["embedding"], dtype=np.float32)) m["guess"], m["guess_score"] = name, round(float(score), 3) - jf.write_text(json.dumps(m, ensure_ascii=False)) + # Persist slug+file together so the UI does not re-derive the person + # folder from the display name (rename / duplicate / slugify drift). + if slug and top_photo: + m["guess_top_photo"] = top_photo + m["guess_top_slug"] = slug + else: + m.pop("guess_top_photo", None) + m.pop("guess_top_slug", None) + # Atomic replace — a crashed mid-write would otherwise truncate the + # JSON (and the embedding inside it) for a silent loss on next load. + raw = json.dumps(m, ensure_ascii=False) + # Eindeutiger Name je Schreibvorgang: ein fester ".json.tmp" waere + # fuer alle Schreiber derselbe Pfad. Der Rest des Programms sieht ihn + # nie, weil hier nur "*.json" gelesen wird. + fd, tmp_name = tempfile.mkstemp(dir=self.unknown_dir, + prefix=f".{jf.stem}-", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(raw) + # Ohne flush+fsync darf das Dateisystem den Namenstausch vor die + # Daten ziehen: nach einem Stromausfall steht dann eine leere Datei + # da, wo vorher ein vollstaendiges Embedding war. Genau der stille + # Verlust, gegen den das Ersetzen hier antritt. + fh.flush() + os.fsync(fh.fileno()) + # Ist das Ziel inzwischen weg, wurde dieses Unknown waehrend des + # Durchlaufs zugeordnet oder verworfen. os.replace() wuerde es + # wieder anlegen — ohne Bild, mit veralteten Daten, und es staende + # erneut in der Review-Queue. Der Hintergrundlauf macht dieses + # Fenster erst gross, weil er neben den Handlern laeuft. + try: + mode = stat_module.S_IMODE(os.stat(jf).st_mode) + except FileNotFoundError: + tmp.unlink(missing_ok=True) + continue + # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die + # Datei waere danach nur noch fuer den Dienstnutzer lesbar. + os.chmod(tmp, mode) + # Kurz vor dem Tausch noch einmal nachsehen: das Fenster bleibt, + # aber es schrumpft von "ganzer Schreibvorgang" auf zwei Aufrufe. + if not jf.exists(): + tmp.unlink(missing_ok=True) + continue + os.replace(tmp, jf) + changed = True + except OSError: + # Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell. + tmp.unlink(missing_ok=True) + raise + if changed: + self._fsync_unknown_dir() + + def _fsync_unknown_dir(self): + """Einmal je Durchlauf, nicht je Datei: erst damit ist der Namenstausch + selbst dauerhaft, und ein fsync je Unknown waere bei tausenden Eintraegen + der teuerste Teil des Durchlaufs.""" + try: + fd = os.open(self.unknown_dir, os.O_RDONLY) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) + + def request_refresh_guesses(self, delay: float = 0.4, max_delay: float = 2.0): + """Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe). + + Each refresh re-matches every unknown; firing it once per deleted photo + is O(N × unknowns) on the HTTP thread. A short debounce collapses a + cleanup burst into one background pass. + """ + with self._refresh_sched_lock: + now = time.time() + if self._refresh_first_request is None: + self._refresh_first_request = now + # Obergrenze gegen den Dauerstrom: rein nachlaufend wuerde ein Burst + # (Mehrfach-Loeschen, Dedupe-Schleife) die Aktualisierung so lange + # schieben, wie er dauert — die Oberflaeche zeigt derweil Hinweise auf + # Fotos, die es nicht mehr gibt. + self._refresh_deadline = min(now + delay, + self._refresh_first_request + max_delay) + if self._refresh_thread is not None and self._refresh_thread.is_alive(): + return + t = threading.Thread(target=self._refresh_guesses_worker, name="refresh-guesses", + daemon=True) + self._refresh_thread = t + t.start() + + def _refresh_guesses_worker(self): + while True: + with self._refresh_sched_lock: + deadline = self._refresh_deadline + if deadline is None: + return + wait = deadline - time.time() + if wait > 0: + time.sleep(wait) + continue + with self._refresh_sched_lock: + if self._refresh_deadline is not None and time.time() < self._refresh_deadline: + continue + self._refresh_deadline = None + self._refresh_first_request = None + try: + self.refresh_guesses() + except Exception: + log.exception("background refresh_guesses failed") + with self._refresh_sched_lock: + if self._refresh_deadline is None: + self._refresh_thread = None + return def discard_unknown(self, uid: str): (self.unknown_dir / f"{uid}.json").unlink(missing_ok=True) diff --git a/app/history.py b/app/history.py index 023b6e9..57c5864 100644 --- a/app/history.py +++ b/app/history.py @@ -283,7 +283,7 @@ def items(self, limit: int = 100, gallery=None, threshold: float = 0.5) -> list: if gallery is not None and m.get("embedding"): try: emb = np.array(m["embedding"], dtype=np.float32) - _, name, score = gallery.match(emb) + _, name, score, _top = gallery.match(emb) # Nur einen Treffer zeigen, der auch veroeffentlicht wuerde. match() # liefert immer den besten Kandidaten, auch bei 0.13 — das als # "heute waere das X" anzuzeigen waere genau die irrefuehrende Angabe, diff --git a/app/mqtt_listener.py b/app/mqtt_listener.py index 4503d99..50e442d 100644 --- a/app/mqtt_listener.py +++ b/app/mqtt_listener.py @@ -375,7 +375,7 @@ def _handle_face(self, eid: str, st: dict, img, face, source: str = "snapshot", """ via = "" if source == "snapshot" else f" (from the {source})" emb = face.normed_embedding - slug, name, score = self.gallery.match(emb) + slug, name, score, top_photo = self.gallery.match(emb) ig = self.gallery.match_ignored(emb) if ig >= self.ignore_thr and ig >= score: # Gesicht steht auf der Ignore-Liste: nicht melden, nicht taggen, nicht vorlegen @@ -420,11 +420,15 @@ def _handle_face(self, eid: str, st: dict, img, face, source: str = "snapshot", # bestes unsicheres/unbekanntes Gesicht des Events merken, Ablage erst beim Event-Ende prev = st.get("best_unknown") if prev is None or face.det_score > prev["det_score"]: - st["best_unknown"] = {"crop": crop, "emb": emb, "det_score": float(face.det_score), - "guess": name, "guess_score": float(score), "full": img, - # aus der Aufnahme ist bereits das schaerfste Bild — - # ein zweiter Durchgang durch hires waere derselbe Clip - "from_clip": source != "snapshot"} + bu = {"crop": crop, "emb": emb, "det_score": float(face.det_score), + "guess": name, "guess_score": float(score), "full": img, + # aus der Aufnahme ist bereits das schaerfste Bild — + # ein zweiter Durchgang durch hires waere derselbe Clip + "from_clip": source != "snapshot"} + if slug and top_photo: + bu["guess_top_photo"] = top_photo + bu["guess_top_slug"] = slug + st["best_unknown"] = bu def process_local_face(self, eid: str, camera: str, event_ts: float, img, face, media_path: str) -> dict: @@ -449,9 +453,13 @@ def process_local_face(self, eid: str, camera: str, event_ts: float, img, face, u = st["best_unknown"] uid = self.gallery.save_unknown( u["crop"], u["emb"], - {"camera": camera, "event_id": eid, "event_ts": event_ts, + {k: v for k, v in { + "camera": camera, "event_id": eid, "event_ts": event_ts, "media_path": media_path, "guess": u["guess"], - "guess_score": round(u["guess_score"], 3)}, + "guess_score": round(u["guess_score"], 3), + "guess_top_photo": u.get("guess_top_photo"), + "guess_top_slug": u.get("guess_top_slug"), + }.items() if k not in ("guess_top_photo", "guess_top_slug") or v}, full_bgr=u.get("full"), ) self._publish_recognition(eid, st, "unknown", u["guess_score"], @@ -635,7 +643,11 @@ def _finalizer(self): crop, emb, {"camera": st["camera"], "event_id": eid, "event_ts": st.get("start_time"), - "guess": u["guess"], "guess_score": round(u["guess_score"], 3)}, + **{k: v for k, v in { + "guess": u["guess"], "guess_score": round(u["guess_score"], 3), + "guess_top_photo": u.get("guess_top_photo"), + "guess_top_slug": u.get("guess_top_slug"), + }.items() if k not in ("guess_top_photo", "guess_top_slug") or v}}, full_bgr=full, ) # crop/emb statt u[...]: kommt der schaerfere Ausschnitt aus der diff --git a/app/webui.py b/app/webui.py index ae3f210..5291d7d 100644 --- a/app/webui.py +++ b/app/webui.py @@ -77,6 +77,7 @@ def create_person(body: NameBody): @app.delete("/api/persons/{slug}") def delete_person(slug: str): gallery.delete_person(slug) + gallery.request_refresh_guesses() return {"ok": True} class FavBody(BaseModel): @@ -149,6 +150,7 @@ def set_aside_face(slug: str, fname: str, body: AsideBody): if not gallery.set_aside(slug, fname, body.reason): raise HTTPException(404, "unknown person or photo") log.info("%s/%s set aside — %s", slug, fname, body.reason) + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/rename") @@ -169,7 +171,10 @@ def set_favorite(slug: str, body: FavBody): @app.post("/api/persons/{slug}/trimmed/{fname}/restore") def restore_trimmed(slug: str, fname: str): - return {"ok": gallery.restore_trimmed(slug, fname)} + ok = gallery.restore_trimmed(slug, fname) + if ok: + gallery.request_refresh_guesses() + return {"ok": ok} @app.delete("/api/persons/{slug}/trimmed/{fname}") def delete_trimmed(slug: str, fname: str): @@ -188,12 +193,15 @@ def deduplicate(body: dict = None): # zuerst echte Bild-Dubletten (identisches Foto), dann aehnliche Gesichter pix = gallery.deduplicate_pixels_all(dry_run=dry) emb = gallery.deduplicate_all(thr, dry_run=dry) + if not dry and (pix or emb): + gallery.request_refresh_guesses() key = "would_remove" if dry else "moved" return {key: pix + emb, "same_image": pix, "similar_face": emb, "threshold": thr} @app.delete("/api/persons/{slug}/faces/{fname}") def delete_face(slug: str, fname: str): gallery.delete_face(slug, fname) + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/faces/{fname}/unassign") @@ -316,7 +324,7 @@ def auto_assign(): thr = float(cfg["faceid"].get("match_threshold", 0.5)) assigned: dict[str, int] = {} for it in gallery.unknowns(): - slug, name, score = gallery.match(it["embedding"]) + slug, name, score, _top = gallery.match(it["embedding"]) if slug and score >= thr and gallery.assign_unknown(it["id"], slug): assigned[name] = assigned.get(name, 0) + 1 if getattr(processor.frigate, "enabled", True) and it.get("event_id"): diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md index 8555a79..e1df89e 100644 --- a/faceid-addon/CHANGELOG.md +++ b/faceid-addon/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## Unreleased + +- **Looks-like cues show the gallery face.** Unknown review now stores `guess_top_slug` + `guess_top_photo` (the reference that best explains a guess) and shows a thumbnail next to *looks like* / ★ Looks like headers; hover enlarges, click opens the existing full viewer. Keys are omitted when there is no photo; deleting or setting aside a reference photo refreshes dangling cues. + ## 0.25.1 — 2026-10-06 - **A failed assignment no longer leaves an empty person behind.** Assigning faces created diff --git a/faceid-addon/app/backfill.py b/faceid-addon/app/backfill.py index 8c3b5e3..ec38c6a 100644 --- a/faceid-addon/app/backfill.py +++ b/faceid-addon/app/backfill.py @@ -79,7 +79,7 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, upgraded = True stats["rescued"] = stats.get("rescued", 0) + 1 emb = face.normed_embedding - slug, name, score = gallery.match(emb) + slug, name, score, top_photo = gallery.match(emb) if gallery.match_ignored(emb) >= max(match_thr, score): stats["ignored"] += 1 continue @@ -102,9 +102,13 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, stats["hires"] = stats.get("hires", 0) + 1 uid = gallery.save_unknown( crop, save_emb, - {"camera": ev["camera"], "event_id": ev["id"], "backfill": True, + {k: v for k, v in { + "camera": ev["camera"], "event_id": ev["id"], "backfill": True, "event_ts": ev.get("start_time"), # wann es passierte, nicht wann wir es fanden - "guess": name, "guess_score": round(float(score), 3)}, + "guess": name, "guess_score": round(float(score), 3), + "guess_top_photo": top_photo, + "guess_top_slug": slug, + }.items() if k not in ("guess_top_photo", "guess_top_slug") or v}, dedupe_sim=dedupe, full_bgr=full, ) stats["dupe" if uid is None else "faces"] += 1 diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py index 585678b..b767608 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -5,9 +5,12 @@ """ import hashlib import json +import os import logging import re import shutil +import stat as stat_module +import tempfile import threading import time import unicodedata @@ -65,6 +68,13 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40): self.self_outlier_min_photos = 5 # darunter ist der Median nicht belastbar self.dedupe_threshold = 0.65 # ab hier gilt ein Foto als Duplikat (Hover-Highlight + Dedup) self._lock = threading.Lock() + self._refresh_sched_lock = threading.Lock() + self._refresh_deadline = None + self._refresh_first_request = None + self._refresh_thread = None + # Eigenes Lock, nicht self._lock: refresh_guesses ruft match(), und match() + # nimmt self._lock — unter demselben Lock waere das ein Deadlock. + self._refresh_run_lock = threading.Lock() self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]} self._ign_emb = np.zeros((0, 512), dtype=np.float32) self._ign_ids: list[str] = [] @@ -746,11 +756,13 @@ def delete_person(self, slug: str): # ---------- Matching ---------- def match(self, embedding: np.ndarray): - """-> (slug, name, score) der besten Person oder (None, None, best_score). + """-> (slug, name, score, top_photo) der besten Person oder (None, None, best_score, None). Score = Mittel der Top-k Ähnlichkeiten pro Person (statt Max) — eine Person - mit vielen Referenzbildern gewinnt Grenzfälle nicht mehr per Einzel-Ausreißer.""" + mit vielen Referenzbildern gewinnt Grenzfälle nicht mehr per Einzel-Ausreißer. + top_photo = Dateiname mit der höchsten Einzel-Ähnlichkeit bei der Gewinner-Person + (für die Unknown-UI „looks like“-Vorschau).""" with self._lock: - best = (None, None, 0.0) + best = (None, None, 0.0, None) for slug, e in self._cache.items(): if len(e["files"]) == 0: continue @@ -758,7 +770,11 @@ def match(self, embedding: np.ndarray): k = min(self.top_k, len(sims)) score = float(np.sort(sims)[-k:].mean()) if score > best[2]: - best = (slug, e["name"], score) + # Thumbnail = best *single* photo (argmax), not one of the top-k + # that entered the mean score — clearest face for the UI cue. + # files[i] aligns with emb[i] / sims[i] from the same person load. + top_i = int(np.argmax(sims)) + best = (slug, e["name"], score, e["files"][top_i]) return best # ---------- Ignore-Liste (Negativ-Anker) ---------- @@ -1030,15 +1046,140 @@ def assign_unknown(self, uid: str, slug: str): return True def refresh_guesses(self): - """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen).""" + """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen). + + Laeuft serialisiert: der Hintergrund-Worker und ein Request-Handler koennen + sonst gleichzeitig durchlaufen, und zwei Durchgaenge am selben Unknown + verschraenken sich zu "A schreibt halb, B ersetzt" — dann wird genau die + abgeschnittene Datei zur echten, die das atomare Ersetzen verhindern soll. + """ + with self._refresh_run_lock: + self._refresh_guesses_once() + + def _refresh_guesses_once(self): + changed = False for jf in self.unknown_dir.glob("*.json"): try: m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - _, name, score = self.match(np.array(m["embedding"], dtype=np.float32)) + slug, name, score, top_photo = self.match(np.array(m["embedding"], dtype=np.float32)) m["guess"], m["guess_score"] = name, round(float(score), 3) - jf.write_text(json.dumps(m, ensure_ascii=False)) + # Persist slug+file together so the UI does not re-derive the person + # folder from the display name (rename / duplicate / slugify drift). + if slug and top_photo: + m["guess_top_photo"] = top_photo + m["guess_top_slug"] = slug + else: + m.pop("guess_top_photo", None) + m.pop("guess_top_slug", None) + # Atomic replace — a crashed mid-write would otherwise truncate the + # JSON (and the embedding inside it) for a silent loss on next load. + raw = json.dumps(m, ensure_ascii=False) + # Eindeutiger Name je Schreibvorgang: ein fester ".json.tmp" waere + # fuer alle Schreiber derselbe Pfad. Der Rest des Programms sieht ihn + # nie, weil hier nur "*.json" gelesen wird. + fd, tmp_name = tempfile.mkstemp(dir=self.unknown_dir, + prefix=f".{jf.stem}-", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(raw) + # Ohne flush+fsync darf das Dateisystem den Namenstausch vor die + # Daten ziehen: nach einem Stromausfall steht dann eine leere Datei + # da, wo vorher ein vollstaendiges Embedding war. Genau der stille + # Verlust, gegen den das Ersetzen hier antritt. + fh.flush() + os.fsync(fh.fileno()) + # Ist das Ziel inzwischen weg, wurde dieses Unknown waehrend des + # Durchlaufs zugeordnet oder verworfen. os.replace() wuerde es + # wieder anlegen — ohne Bild, mit veralteten Daten, und es staende + # erneut in der Review-Queue. Der Hintergrundlauf macht dieses + # Fenster erst gross, weil er neben den Handlern laeuft. + try: + mode = stat_module.S_IMODE(os.stat(jf).st_mode) + except FileNotFoundError: + tmp.unlink(missing_ok=True) + continue + # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die + # Datei waere danach nur noch fuer den Dienstnutzer lesbar. + os.chmod(tmp, mode) + # Kurz vor dem Tausch noch einmal nachsehen: das Fenster bleibt, + # aber es schrumpft von "ganzer Schreibvorgang" auf zwei Aufrufe. + if not jf.exists(): + tmp.unlink(missing_ok=True) + continue + os.replace(tmp, jf) + changed = True + except OSError: + # Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell. + tmp.unlink(missing_ok=True) + raise + if changed: + self._fsync_unknown_dir() + + def _fsync_unknown_dir(self): + """Einmal je Durchlauf, nicht je Datei: erst damit ist der Namenstausch + selbst dauerhaft, und ein fsync je Unknown waere bei tausenden Eintraegen + der teuerste Teil des Durchlaufs.""" + try: + fd = os.open(self.unknown_dir, os.O_RDONLY) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) + + def request_refresh_guesses(self, delay: float = 0.4, max_delay: float = 2.0): + """Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe). + + Each refresh re-matches every unknown; firing it once per deleted photo + is O(N × unknowns) on the HTTP thread. A short debounce collapses a + cleanup burst into one background pass. + """ + with self._refresh_sched_lock: + now = time.time() + if self._refresh_first_request is None: + self._refresh_first_request = now + # Obergrenze gegen den Dauerstrom: rein nachlaufend wuerde ein Burst + # (Mehrfach-Loeschen, Dedupe-Schleife) die Aktualisierung so lange + # schieben, wie er dauert — die Oberflaeche zeigt derweil Hinweise auf + # Fotos, die es nicht mehr gibt. + self._refresh_deadline = min(now + delay, + self._refresh_first_request + max_delay) + if self._refresh_thread is not None and self._refresh_thread.is_alive(): + return + t = threading.Thread(target=self._refresh_guesses_worker, name="refresh-guesses", + daemon=True) + self._refresh_thread = t + t.start() + + def _refresh_guesses_worker(self): + while True: + with self._refresh_sched_lock: + deadline = self._refresh_deadline + if deadline is None: + return + wait = deadline - time.time() + if wait > 0: + time.sleep(wait) + continue + with self._refresh_sched_lock: + if self._refresh_deadline is not None and time.time() < self._refresh_deadline: + continue + self._refresh_deadline = None + self._refresh_first_request = None + try: + self.refresh_guesses() + except Exception: + log.exception("background refresh_guesses failed") + with self._refresh_sched_lock: + if self._refresh_deadline is None: + self._refresh_thread = None + return def discard_unknown(self, uid: str): (self.unknown_dir / f"{uid}.json").unlink(missing_ok=True) diff --git a/faceid-addon/app/history.py b/faceid-addon/app/history.py index 023b6e9..57c5864 100644 --- a/faceid-addon/app/history.py +++ b/faceid-addon/app/history.py @@ -283,7 +283,7 @@ def items(self, limit: int = 100, gallery=None, threshold: float = 0.5) -> list: if gallery is not None and m.get("embedding"): try: emb = np.array(m["embedding"], dtype=np.float32) - _, name, score = gallery.match(emb) + _, name, score, _top = gallery.match(emb) # Nur einen Treffer zeigen, der auch veroeffentlicht wuerde. match() # liefert immer den besten Kandidaten, auch bei 0.13 — das als # "heute waere das X" anzuzeigen waere genau die irrefuehrende Angabe, diff --git a/faceid-addon/app/mqtt_listener.py b/faceid-addon/app/mqtt_listener.py index 4503d99..50e442d 100644 --- a/faceid-addon/app/mqtt_listener.py +++ b/faceid-addon/app/mqtt_listener.py @@ -375,7 +375,7 @@ def _handle_face(self, eid: str, st: dict, img, face, source: str = "snapshot", """ via = "" if source == "snapshot" else f" (from the {source})" emb = face.normed_embedding - slug, name, score = self.gallery.match(emb) + slug, name, score, top_photo = self.gallery.match(emb) ig = self.gallery.match_ignored(emb) if ig >= self.ignore_thr and ig >= score: # Gesicht steht auf der Ignore-Liste: nicht melden, nicht taggen, nicht vorlegen @@ -420,11 +420,15 @@ def _handle_face(self, eid: str, st: dict, img, face, source: str = "snapshot", # bestes unsicheres/unbekanntes Gesicht des Events merken, Ablage erst beim Event-Ende prev = st.get("best_unknown") if prev is None or face.det_score > prev["det_score"]: - st["best_unknown"] = {"crop": crop, "emb": emb, "det_score": float(face.det_score), - "guess": name, "guess_score": float(score), "full": img, - # aus der Aufnahme ist bereits das schaerfste Bild — - # ein zweiter Durchgang durch hires waere derselbe Clip - "from_clip": source != "snapshot"} + bu = {"crop": crop, "emb": emb, "det_score": float(face.det_score), + "guess": name, "guess_score": float(score), "full": img, + # aus der Aufnahme ist bereits das schaerfste Bild — + # ein zweiter Durchgang durch hires waere derselbe Clip + "from_clip": source != "snapshot"} + if slug and top_photo: + bu["guess_top_photo"] = top_photo + bu["guess_top_slug"] = slug + st["best_unknown"] = bu def process_local_face(self, eid: str, camera: str, event_ts: float, img, face, media_path: str) -> dict: @@ -449,9 +453,13 @@ def process_local_face(self, eid: str, camera: str, event_ts: float, img, face, u = st["best_unknown"] uid = self.gallery.save_unknown( u["crop"], u["emb"], - {"camera": camera, "event_id": eid, "event_ts": event_ts, + {k: v for k, v in { + "camera": camera, "event_id": eid, "event_ts": event_ts, "media_path": media_path, "guess": u["guess"], - "guess_score": round(u["guess_score"], 3)}, + "guess_score": round(u["guess_score"], 3), + "guess_top_photo": u.get("guess_top_photo"), + "guess_top_slug": u.get("guess_top_slug"), + }.items() if k not in ("guess_top_photo", "guess_top_slug") or v}, full_bgr=u.get("full"), ) self._publish_recognition(eid, st, "unknown", u["guess_score"], @@ -635,7 +643,11 @@ def _finalizer(self): crop, emb, {"camera": st["camera"], "event_id": eid, "event_ts": st.get("start_time"), - "guess": u["guess"], "guess_score": round(u["guess_score"], 3)}, + **{k: v for k, v in { + "guess": u["guess"], "guess_score": round(u["guess_score"], 3), + "guess_top_photo": u.get("guess_top_photo"), + "guess_top_slug": u.get("guess_top_slug"), + }.items() if k not in ("guess_top_photo", "guess_top_slug") or v}}, full_bgr=full, ) # crop/emb statt u[...]: kommt der schaerfere Ausschnitt aus der diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py index ae3f210..5291d7d 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -77,6 +77,7 @@ def create_person(body: NameBody): @app.delete("/api/persons/{slug}") def delete_person(slug: str): gallery.delete_person(slug) + gallery.request_refresh_guesses() return {"ok": True} class FavBody(BaseModel): @@ -149,6 +150,7 @@ def set_aside_face(slug: str, fname: str, body: AsideBody): if not gallery.set_aside(slug, fname, body.reason): raise HTTPException(404, "unknown person or photo") log.info("%s/%s set aside — %s", slug, fname, body.reason) + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/rename") @@ -169,7 +171,10 @@ def set_favorite(slug: str, body: FavBody): @app.post("/api/persons/{slug}/trimmed/{fname}/restore") def restore_trimmed(slug: str, fname: str): - return {"ok": gallery.restore_trimmed(slug, fname)} + ok = gallery.restore_trimmed(slug, fname) + if ok: + gallery.request_refresh_guesses() + return {"ok": ok} @app.delete("/api/persons/{slug}/trimmed/{fname}") def delete_trimmed(slug: str, fname: str): @@ -188,12 +193,15 @@ def deduplicate(body: dict = None): # zuerst echte Bild-Dubletten (identisches Foto), dann aehnliche Gesichter pix = gallery.deduplicate_pixels_all(dry_run=dry) emb = gallery.deduplicate_all(thr, dry_run=dry) + if not dry and (pix or emb): + gallery.request_refresh_guesses() key = "would_remove" if dry else "moved" return {key: pix + emb, "same_image": pix, "similar_face": emb, "threshold": thr} @app.delete("/api/persons/{slug}/faces/{fname}") def delete_face(slug: str, fname: str): gallery.delete_face(slug, fname) + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/faces/{fname}/unassign") @@ -316,7 +324,7 @@ def auto_assign(): thr = float(cfg["faceid"].get("match_threshold", 0.5)) assigned: dict[str, int] = {} for it in gallery.unknowns(): - slug, name, score = gallery.match(it["embedding"]) + slug, name, score, _top = gallery.match(it["embedding"]) if slug and score >= thr and gallery.assign_unknown(it["id"], slug): assigned[name] = assigned.get(name, 0) + 1 if getattr(processor.frigate, "enabled", True) and it.get("event_id"): diff --git a/faceid-addon/static/index.html b/faceid-addon/static/index.html index 020ce8a..5655b64 100644 --- a/faceid-addon/static/index.html +++ b/faceid-addon/static/index.html @@ -71,7 +71,13 @@ .tile .x{position:absolute;top:0;right:0;background:var(--bg);border:0;color:var(--bad);font:12px var(--mono);padding:2px 6px;cursor:pointer} .cluster .head{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap} .cluster .head b{font:600 13px var(--mono);color:var(--fg)} -.cluster .guess{font:12px var(--mono);color:var(--warn)} +.cluster .guess{font:12px var(--mono);color:var(--warn);display:inline-flex;align-items:center;gap:6px;position:relative} +.cluster .guess-thumb{width:32px;height:32px;border-radius:4px;object-fit:cover;border:1px solid var(--warn);cursor:zoom-in;background:var(--panel);flex:0 0 auto} +.cluster .guess-pop{display:none;position:absolute;left:0;top:calc(100% + 6px);z-index:20;background:var(--panel);border:1px solid var(--acc);border-radius:6px;padding:6px;box-shadow:0 10px 28px rgba(0,0,0,.55)} +.cluster .guess:hover .guess-pop{display:block} +.cluster .guess-pop img{width:120px;height:120px;object-fit:cover;border-radius:4px;display:block} +.cluster .guess-pop .cap{font:10px var(--mono);color:var(--dim);margin-top:4px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.cluster.sugg .head .guess-thumb{border-color:var(--acc)} .person{border:1px solid var(--line);padding:10px;margin-bottom:14px} .person .head{display:flex;align-items:baseline;gap:12px;margin-bottom:8px} .person .head b{font:600 14px var(--sans)} @@ -182,6 +188,33 @@

FACEID

function errText(e){const s=String(e&&e.message||e);try{const d=JSON.parse(s).detail;if(d)return String(d);}catch(_){}return s} function toast(m){const t=$('#toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2500)} function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))} +function guessPhoto(slug, preferred){ + const p=persons[slug]; if(!p||!p.files||!p.files.length)return ''; + if(preferred&&p.files.includes(preferred))return preferred; + // preferred missing (trimmed/deleted) → newest remaining file + return p.files[p.files.length-1]; +} +function guessCue(name, scorePct, slug, preferred, star){ + if(!name)return ''; + const file=slug?guessPhoto(slug, preferred||''):''; + const label=(star?'★ Looks like ':'looks like ')+esc(name)+(scorePct!=null?` (${scorePct}%)`:''); + if(!file){ + return star + ? `★ Looks like ${esc(name)}` + : `${label}`; + } + const src=`data/persons/${esc(slug)}/${esc(file)}`; + const thumb=``; + const pop=`
${esc(name)}
`; + if(star){ + return `★ Looks like ${esc(name)}${thumb}${pop}`; + } + return `${thumb}${label}${pop}`; +} + function fmtTs(ts){return new Date(ts*1000).toLocaleString(undefined,{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})} // Wann das Ereignis passierte, nicht wann wir es verarbeitet haben — beim Verlaufs-Scan // liegen die Wochen auseinander. @@ -298,7 +331,7 @@

FACEID

return `
- ★ Looks like ${esc(g.name)} + ${guessCue(g.name, null, (g.items[0]&&g.items[0].guess_top_slug)||g.slug, (g.items[0]&&g.items[0].guess_top_photo)||'', true)} ${g.items.length} face(s) · avg ${avg}% @@ -323,7 +356,7 @@

FACEID

CLUSTER ${String(i+1).padStart(2,'0')} ${c.length} capture${c.length>1?'s':''} · ${esc(c[0].camera||'')} - ${c[0].guess?`looks like ${esc(c[0].guess)} (${(c[0].guess_score*100|0)}%)`:''} + ${c[0].guess?guessCue(c[0].guess,(c[0].guess_score*100|0),c[0].guess_top_slug||gslug,c[0].guess_top_photo||'',false):''} diff --git a/static/index.html b/static/index.html index 020ce8a..5655b64 100644 --- a/static/index.html +++ b/static/index.html @@ -71,7 +71,13 @@ .tile .x{position:absolute;top:0;right:0;background:var(--bg);border:0;color:var(--bad);font:12px var(--mono);padding:2px 6px;cursor:pointer} .cluster .head{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap} .cluster .head b{font:600 13px var(--mono);color:var(--fg)} -.cluster .guess{font:12px var(--mono);color:var(--warn)} +.cluster .guess{font:12px var(--mono);color:var(--warn);display:inline-flex;align-items:center;gap:6px;position:relative} +.cluster .guess-thumb{width:32px;height:32px;border-radius:4px;object-fit:cover;border:1px solid var(--warn);cursor:zoom-in;background:var(--panel);flex:0 0 auto} +.cluster .guess-pop{display:none;position:absolute;left:0;top:calc(100% + 6px);z-index:20;background:var(--panel);border:1px solid var(--acc);border-radius:6px;padding:6px;box-shadow:0 10px 28px rgba(0,0,0,.55)} +.cluster .guess:hover .guess-pop{display:block} +.cluster .guess-pop img{width:120px;height:120px;object-fit:cover;border-radius:4px;display:block} +.cluster .guess-pop .cap{font:10px var(--mono);color:var(--dim);margin-top:4px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.cluster.sugg .head .guess-thumb{border-color:var(--acc)} .person{border:1px solid var(--line);padding:10px;margin-bottom:14px} .person .head{display:flex;align-items:baseline;gap:12px;margin-bottom:8px} .person .head b{font:600 14px var(--sans)} @@ -182,6 +188,33 @@

FACEID

function errText(e){const s=String(e&&e.message||e);try{const d=JSON.parse(s).detail;if(d)return String(d);}catch(_){}return s} function toast(m){const t=$('#toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2500)} function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))} +function guessPhoto(slug, preferred){ + const p=persons[slug]; if(!p||!p.files||!p.files.length)return ''; + if(preferred&&p.files.includes(preferred))return preferred; + // preferred missing (trimmed/deleted) → newest remaining file + return p.files[p.files.length-1]; +} +function guessCue(name, scorePct, slug, preferred, star){ + if(!name)return ''; + const file=slug?guessPhoto(slug, preferred||''):''; + const label=(star?'★ Looks like ':'looks like ')+esc(name)+(scorePct!=null?` (${scorePct}%)`:''); + if(!file){ + return star + ? `★ Looks like ${esc(name)}` + : `${label}`; + } + const src=`data/persons/${esc(slug)}/${esc(file)}`; + const thumb=``; + const pop=`
${esc(name)}
`; + if(star){ + return `★ Looks like ${esc(name)}${thumb}${pop}`; + } + return `${thumb}${label}${pop}`; +} + function fmtTs(ts){return new Date(ts*1000).toLocaleString(undefined,{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})} // Wann das Ereignis passierte, nicht wann wir es verarbeitet haben — beim Verlaufs-Scan // liegen die Wochen auseinander. @@ -298,7 +331,7 @@

FACEID

return `
- ★ Looks like ${esc(g.name)} + ${guessCue(g.name, null, (g.items[0]&&g.items[0].guess_top_slug)||g.slug, (g.items[0]&&g.items[0].guess_top_photo)||'', true)} ${g.items.length} face(s) · avg ${avg}% @@ -323,7 +356,7 @@

FACEID

CLUSTER ${String(i+1).padStart(2,'0')} ${c.length} capture${c.length>1?'s':''} · ${esc(c[0].camera||'')} - ${c[0].guess?`looks like ${esc(c[0].guess)} (${(c[0].guess_score*100|0)}%)`:''} + ${c[0].guess?guessCue(c[0].guess,(c[0].guess_score*100|0),c[0].guess_top_slug||gslug,c[0].guess_top_photo||'',false):''} diff --git a/tests/test_gallery_person_names.py b/tests/test_gallery_person_names.py index e9070c9..11f7637 100644 --- a/tests/test_gallery_person_names.py +++ b/tests/test_gallery_person_names.py @@ -1,7 +1,12 @@ +import json import tempfile +import threading +import time import unittest from pathlib import Path +import numpy as np + from app.gallery import Gallery, slugify @@ -45,3 +50,97 @@ def test_same_unicode_name_is_idempotent(self): if __name__ == "__main__": unittest.main() + + +class RefreshGuessesConcurrencyTests(unittest.TestCase): + """Zwei gleichzeitige Durchlaeufe duerfen keine halbe Datei veroeffentlichen.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + d = Path(self.tmp.name) + (d / "persons").mkdir() + (d / "ignored").mkdir() + self.gal = Gallery(d, top_k=3, max_per_person=40) + + def tearDown(self): + self.tmp.cleanup() + + def _unknown(self, uid: str): + emb = [round(float(v), 6) for v in (np.ones(512, dtype=np.float32) / np.sqrt(512))] + (self.gal.unknown_dir / f"{uid}.json").write_text( + json.dumps({"ts": 1, "embedding": emb, "camera": "entrance"})) + + def test_parallel_refreshes_never_publish_a_partial_file(self): + for i in range(12): + self._unknown(f"u{i}") + errors = [] + + def run(): + try: + for _ in range(6): + self.gal.refresh_guesses() + except Exception as exc: # pragma: no cover - nur im Fehlerfall + errors.append(exc) + + threads = [threading.Thread(target=run) for _ in range(4)] + [t.start() for t in threads] + [t.join() for t in threads] + self.assertEqual(errors, []) + for jf in self.gal.unknown_dir.glob("*.json"): + m = json.loads(jf.read_text()) # wirft, wenn abgeschnitten + self.assertEqual(len(m["embedding"]), 512, + f"{jf.name} hat sein Embedding verloren") + + def test_no_temp_files_are_left_behind(self): + self._unknown("u1") + self.gal.refresh_guesses() + leftovers = [p.name for p in self.gal.unknown_dir.iterdir() if p.suffix == ".tmp"] + self.assertEqual(leftovers, []) + + def test_a_burst_cannot_defer_the_refresh_forever(self): + # Rein nachlaufend wuerde jeder Aufruf die Frist neu setzen; die Obergrenze + # muss die Frist festnageln, sobald max_delay erreicht ist. + self._unknown("u1") + start = time.time() + for _ in range(50): + self.gal.request_refresh_guesses(delay=0.4, max_delay=0.3) + with self.gal._refresh_sched_lock: + deadline = self.gal._refresh_deadline + self.assertIsNotNone(deadline) + self.assertLessEqual(deadline - start, 0.35, + "die Obergrenze muss den Dauerstrom abschneiden") + + def test_the_file_mode_survives_a_refresh(self): + # mkstemp legt mit 0600 an und os.replace nimmt den Modus mit — ohne + # Korrektur waere die Datei nach dem ersten Durchlauf nur noch fuer den + # Dienstnutzer lesbar. + self._unknown("u1") + jf = self.gal.unknown_dir / "u1.json" + import os as _os, stat as _stat + _os.chmod(jf, 0o644) + self.gal.refresh_guesses() + self.assertEqual(_stat.S_IMODE(_os.stat(jf).st_mode), 0o644) + + def test_an_unknown_deleted_during_the_pass_is_not_resurrected(self): + # Der Hintergrundlauf laeuft neben den Handlern: wird ein Unknown waehrend + # des Durchlaufs zugeordnet oder verworfen, darf os.replace() es nicht wieder + # anlegen — es stuende sonst ohne Bild erneut in der Review-Queue. + self._unknown("u1") + jf = self.gal.unknown_dir / "u1.json" + (self.gal.unknown_dir / "u1.jpg").write_bytes(b"jpg") + real_match = self.gal.match + + def match_and_delete(emb): + # Genau das, was discard_unknown()/assign_unknown() tun wuerden. + jf.unlink(missing_ok=True) + (self.gal.unknown_dir / "u1.jpg").unlink(missing_ok=True) + return real_match(emb) + + self.gal.match = match_and_delete + try: + self.gal.refresh_guesses() + finally: + self.gal.match = real_match + self.assertFalse(jf.exists(), "ein geloeschtes Unknown darf nicht zurueckkommen") + self.assertEqual([p.name for p in self.gal.unknown_dir.iterdir()], [], + "und auch keine Temp-Datei hinterlassen") diff --git a/tests/test_local_event_processor.py b/tests/test_local_event_processor.py index c4c328a..0435e51 100644 --- a/tests/test_local_event_processor.py +++ b/tests/test_local_event_processor.py @@ -20,7 +20,7 @@ def config(self): class FakeGallery: - def __init__(self, match=(None, None, 0.0), ignored=0.0): + def __init__(self, match=(None, None, 0.0, None), ignored=0.0): self.match_result = match self.ignored = ignored self.saved = [] @@ -74,7 +74,7 @@ def test_unknown_is_saved_with_source_path_and_published_to_recent(self): def test_known_person_does_not_try_to_write_a_frigate_label(self): frigate = FakeFrigate() - gallery = FakeGallery(match=("alice", "Alice", 0.8)) + gallery = FakeGallery(match=("alice", "Alice", 0.8, None)) processor = EventProcessor(config(), object(), gallery, frigate) image = np.zeros((100, 100, 3), dtype=np.uint8) result = processor.process_local_face(