Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 7 additions & 3 deletions app/backfill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
155 changes: 148 additions & 7 deletions app/gallery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -746,19 +756,25 @@ 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
sims = e["emb"] @ embedding
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])
Comment thread
Alien10140 marked this conversation as resolved.
return best

# ---------- Ignore-Liste (Negativ-Anker) ----------
Expand Down Expand Up @@ -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:
Comment thread
Alien10140 marked this conversation as resolved.
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 "<uid>.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,
Comment thread
the-codemole[bot] marked this conversation as resolved.
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)
Comment thread
the-codemole[bot] marked this conversation as resolved.
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)
Expand Down
2 changes: 1 addition & 1 deletion app/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 21 additions & 9 deletions app/mqtt_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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"],
Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions app/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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):
Expand All @@ -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")
Expand Down Expand Up @@ -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"):
Expand Down
4 changes: 4 additions & 0 deletions faceid-addon/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading