From b233885576fd3cb49a2d8dee3929ff6ff27d9dd1 Mon Sep 17 00:00:00 2001 From: Alien10140 <4649783+Alien10140@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:05:06 +0000 Subject: [PATCH 1/8] Show gallery reference face on looks-like Unknown cues Persist guess_top_photo (best single-photo match) with each guess and render a thumb + hover enlarge on rest clusters and suggestion headers so role+hex stranger IDs are visually identifiable during review. --- CHANGELOG.md | 5 ++++ README.md | 3 ++- app/backfill.py | 5 ++-- app/gallery.py | 20 ++++++++++++---- app/history.py | 2 +- app/mqtt_listener.py | 11 +++++---- app/webui.py | 2 +- faceid-addon/app/backfill.py | 5 ++-- faceid-addon/app/gallery.py | 20 ++++++++++++---- faceid-addon/app/history.py | 2 +- faceid-addon/app/mqtt_listener.py | 11 +++++---- faceid-addon/app/webui.py | 2 +- faceid-addon/static/index.html | 37 ++++++++++++++++++++++++++--- static/index.html | 37 ++++++++++++++++++++++++++--- tests/test_local_event_processor.py | 4 ++-- 15 files changed, 131 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8555a79..bec76f5 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_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. + ## 0.25.1 — 2026-10-06 - **A failed assignment no longer leaves an empty person behind.** Assigning faces created @@ -78,6 +82,7 @@ update dialog; standalone users can watch GitHub releases. wrote them into the generated config — the same defect as #24, in three more fields. Found by a new test that checks every option reaches the generated file. + ## 0.24.0 — 2026-09-15 - **The folder input no longer remembers every file forever.** `data/folder_ingest.json` 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..5b80f52 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 @@ -104,7 +104,8 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, crop, save_emb, {"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}, 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..28c2c3a 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -746,11 +746,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 +760,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) ---------- @@ -1036,8 +1042,12 @@ def refresh_guesses(self): m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - _, name, score = self.match(np.array(m["embedding"], dtype=np.float32)) + _, name, score, top_photo = self.match(np.array(m["embedding"], dtype=np.float32)) m["guess"], m["guess_score"] = name, round(float(score), 3) + if top_photo: + m["guess_top_photo"] = top_photo + else: + m.pop("guess_top_photo", None) jf.write_text(json.dumps(m, ensure_ascii=False)) def discard_unknown(self, uid: str): 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..d02216e 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 @@ -421,7 +421,8 @@ def _handle_face(self, eid: str, st: dict, img, face, source: str = "snapshot", 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, + "guess": name, "guess_score": float(score), + "guess_top_photo": top_photo, "full": img, # aus der Aufnahme ist bereits das schaerfste Bild — # ein zweiter Durchgang durch hires waere derselbe Clip "from_clip": source != "snapshot"} @@ -451,7 +452,8 @@ def process_local_face(self, eid: str, camera: str, event_ts: float, img, face, u["crop"], u["emb"], {"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")}, full_bgr=u.get("full"), ) self._publish_recognition(eid, st, "unknown", u["guess_score"], @@ -635,7 +637,8 @@ 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)}, + "guess": u["guess"], "guess_score": round(u["guess_score"], 3), + "guess_top_photo": u.get("guess_top_photo")}, 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..633d2d4 100644 --- a/app/webui.py +++ b/app/webui.py @@ -316,7 +316,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/app/backfill.py b/faceid-addon/app/backfill.py index 8c3b5e3..5b80f52 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 @@ -104,7 +104,8 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, crop, save_emb, {"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}, 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..28c2c3a 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -746,11 +746,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 +760,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) ---------- @@ -1036,8 +1042,12 @@ def refresh_guesses(self): m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - _, name, score = self.match(np.array(m["embedding"], dtype=np.float32)) + _, name, score, top_photo = self.match(np.array(m["embedding"], dtype=np.float32)) m["guess"], m["guess_score"] = name, round(float(score), 3) + if top_photo: + m["guess_top_photo"] = top_photo + else: + m.pop("guess_top_photo", None) jf.write_text(json.dumps(m, ensure_ascii=False)) def discard_unknown(self, uid: str): 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..d02216e 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 @@ -421,7 +421,8 @@ def _handle_face(self, eid: str, st: dict, img, face, source: str = "snapshot", 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, + "guess": name, "guess_score": float(score), + "guess_top_photo": top_photo, "full": img, # aus der Aufnahme ist bereits das schaerfste Bild — # ein zweiter Durchgang durch hires waere derselbe Clip "from_clip": source != "snapshot"} @@ -451,7 +452,8 @@ def process_local_face(self, eid: str, camera: str, event_ts: float, img, face, u["crop"], u["emb"], {"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")}, full_bgr=u.get("full"), ) self._publish_recognition(eid, st, "unknown", u["guess_score"], @@ -635,7 +637,8 @@ 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)}, + "guess": u["guess"], "guess_score": round(u["guess_score"], 3), + "guess_top_photo": u.get("guess_top_photo")}, 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..633d2d4 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -316,7 +316,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..96b86cc 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,31 @@

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; + 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 +329,7 @@

FACEID

return `
- ★ Looks like ${esc(g.name)} + ${guessCue(g.name, null, g.slug, (g.items[0]&&g.items[0].guess_top_photo)||'', true)} ${g.items.length} face(s) · avg ${avg}% @@ -323,7 +354,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),gslug,c[0].guess_top_photo||'',false):''} diff --git a/static/index.html b/static/index.html index 020ce8a..96b86cc 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,31 @@

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; + 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 +329,7 @@

FACEID

return `
- ★ Looks like ${esc(g.name)} + ${guessCue(g.name, null, g.slug, (g.items[0]&&g.items[0].guess_top_photo)||'', true)} ${g.items.length} face(s) · avg ${avg}% @@ -323,7 +354,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),gslug,c[0].guess_top_photo||'',false):''} 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( From 1789db194ee765300f6d2e969a27a1986b2fc36d Mon Sep 17 00:00:00 2001 From: Alien10140 <4649783+Alien10140@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:33:39 +0000 Subject: [PATCH 2/8] Persist guess_top_slug with photo; omit empty guess_top keys Store the winning person slug next to guess_top_photo so the Unknown UI does not re-derive the gallery folder from the display name. Only write those keys when a photo exists (same convention as refresh_guesses), and hide a broken thumb if the file is gone. Co-authored-by: Cursor --- CHANGELOG.md | 2 +- app/backfill.py | 7 +++++-- app/gallery.py | 8 ++++++-- app/mqtt_listener.py | 27 ++++++++++++++++++--------- faceid-addon/app/backfill.py | 7 +++++-- faceid-addon/app/gallery.py | 8 ++++++-- faceid-addon/app/mqtt_listener.py | 27 ++++++++++++++++++--------- faceid-addon/static/index.html | 8 +++++--- static/index.html | 8 +++++--- 9 files changed, 69 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bec76f5..83e2248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ update dialog; standalone users can watch GitHub releases. ## Unreleased -- **Looks-like cues show the gallery face.** Unknown review now stores `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. +- **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. ## 0.25.1 — 2026-10-06 diff --git a/app/backfill.py b/app/backfill.py index 5b80f52..ec38c6a 100644 --- a/app/backfill.py +++ b/app/backfill.py @@ -102,10 +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_top_photo": top_photo}, + "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 28c2c3a..f486abf 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -1042,12 +1042,16 @@ def refresh_guesses(self): m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - _, name, score, top_photo = 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) - if top_photo: + # 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) jf.write_text(json.dumps(m, ensure_ascii=False)) def discard_unknown(self, uid: str): diff --git a/app/mqtt_listener.py b/app/mqtt_listener.py index d02216e..50e442d 100644 --- a/app/mqtt_listener.py +++ b/app/mqtt_listener.py @@ -420,12 +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), - "guess_top_photo": top_photo, "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: @@ -450,10 +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_top_photo": u.get("guess_top_photo")}, + "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"], @@ -637,8 +643,11 @@ def _finalizer(self): crop, emb, {"camera": st["camera"], "event_id": eid, "event_ts": st.get("start_time"), + **{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_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/backfill.py b/faceid-addon/app/backfill.py index 5b80f52..ec38c6a 100644 --- a/faceid-addon/app/backfill.py +++ b/faceid-addon/app/backfill.py @@ -102,10 +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_top_photo": top_photo}, + "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 28c2c3a..f486abf 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -1042,12 +1042,16 @@ def refresh_guesses(self): m = json.loads(jf.read_text()) except (json.JSONDecodeError, OSError): continue - _, name, score, top_photo = 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) - if top_photo: + # 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) jf.write_text(json.dumps(m, ensure_ascii=False)) def discard_unknown(self, uid: str): diff --git a/faceid-addon/app/mqtt_listener.py b/faceid-addon/app/mqtt_listener.py index d02216e..50e442d 100644 --- a/faceid-addon/app/mqtt_listener.py +++ b/faceid-addon/app/mqtt_listener.py @@ -420,12 +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), - "guess_top_photo": top_photo, "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: @@ -450,10 +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_top_photo": u.get("guess_top_photo")}, + "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"], @@ -637,8 +643,11 @@ def _finalizer(self): crop, emb, {"camera": st["camera"], "event_id": eid, "event_ts": st.get("start_time"), + **{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_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/static/index.html b/faceid-addon/static/index.html index 96b86cc..5655b64 100644 --- a/faceid-addon/static/index.html +++ b/faceid-addon/static/index.html @@ -191,6 +191,7 @@

FACEID

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){ @@ -205,8 +206,9 @@

FACEID

const src=`data/persons/${esc(slug)}/${esc(file)}`; const thumb=``; - const pop=`
${esc(name)}
`; + const pop=`
${esc(name)}
`; if(star){ return `★ Looks like ${esc(name)}${thumb}${pop}`; } @@ -329,7 +331,7 @@

FACEID

return `
- ${guessCue(g.name, null, g.slug, (g.items[0]&&g.items[0].guess_top_photo)||'', true)} + ${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}% @@ -354,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?guessCue(c[0].guess,(c[0].guess_score*100|0),gslug,c[0].guess_top_photo||'',false):''} + ${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 96b86cc..5655b64 100644 --- a/static/index.html +++ b/static/index.html @@ -191,6 +191,7 @@

FACEID

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){ @@ -205,8 +206,9 @@

FACEID

const src=`data/persons/${esc(slug)}/${esc(file)}`; const thumb=``; - const pop=`
${esc(name)}
`; + const pop=`
${esc(name)}
`; if(star){ return `★ Looks like ${esc(name)}${thumb}${pop}`; } @@ -329,7 +331,7 @@

FACEID

return `
- ${guessCue(g.name, null, g.slug, (g.items[0]&&g.items[0].guess_top_photo)||'', true)} + ${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}% @@ -354,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?guessCue(c[0].guess,(c[0].guess_score*100|0),gslug,c[0].guess_top_photo||'',false):''} + ${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):''} From dd581e2efbdf61c4a5476b00ecbff71bec4a2d25 Mon Sep 17 00:00:00 2001 From: Alien10140 <4649783+Alien10140@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:35:50 +0000 Subject: [PATCH 3/8] Drop stray blank line in CHANGELOG before 0.24.0 Co-authored-by: Cursor --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e2248..7c9065c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,6 @@ update dialog; standalone users can watch GitHub releases. wrote them into the generated config — the same defect as #24, in three more fields. Found by a new test that checks every option reaches the generated file. - ## 0.24.0 — 2026-09-15 - **The folder input no longer remembers every file forever.** `data/folder_ingest.json` From 4af4d0effbca47c695a62cbe2a1c3a7101d785d0 Mon Sep 17 00:00:00 2001 From: Alien10140 <4649783+Alien10140@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:38:00 +0000 Subject: [PATCH 4/8] Refresh looks-like cues when gallery photos change Call refresh_guesses after person/photo delete, set-aside, restore, and non-dry dedupe so stored guess_top_slug/photo pointers do not dangle. Co-authored-by: Cursor --- CHANGELOG.md | 2 +- app/webui.py | 10 +++++++++- faceid-addon/app/webui.py | 10 +++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9065c..e1df89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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. +- **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 diff --git a/app/webui.py b/app/webui.py index 633d2d4..f476b93 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.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.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.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.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.refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/faces/{fname}/unassign") diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py index 633d2d4..f476b93 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.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.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.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.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.refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/faces/{fname}/unassign") From 27c47a437f122b5c2f8c8a0811603462b0a6df3a Mon Sep 17 00:00:00 2001 From: Alien10140 <4649783+Alien10140@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:41:56 +0000 Subject: [PATCH 5/8] Debounce gallery cue refresh; write unknown JSON atomically request_refresh_guesses coalesces delete/set-aside/dedupe bursts into one background refresh_guesses pass, and each metadata rewrite uses temp+replace so a crash cannot truncate an unknown's embedding JSON. Co-authored-by: Cursor --- app/gallery.py | 50 ++++++++++++++++++++++++++++++++++++- app/webui.py | 10 ++++---- faceid-addon/app/gallery.py | 50 ++++++++++++++++++++++++++++++++++++- faceid-addon/app/webui.py | 10 ++++---- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/app/gallery.py b/app/gallery.py index f486abf..6aefcd9 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -5,6 +5,7 @@ """ import hashlib import json +import os import logging import re import shutil @@ -65,6 +66,9 @@ 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_thread = None self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]} self._ign_emb = np.zeros((0, 512), dtype=np.float32) self._ign_ids: list[str] = [] @@ -1052,7 +1056,51 @@ def refresh_guesses(self): else: m.pop("guess_top_photo", None) m.pop("guess_top_slug", None) - jf.write_text(json.dumps(m, ensure_ascii=False)) + # 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) + tmp = jf.with_suffix(".json.tmp") + tmp.write_text(raw) + os.replace(tmp, jf) + + def request_refresh_guesses(self, delay: float = 0.4): + """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: + self._refresh_deadline = time.time() + 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 + 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/webui.py b/app/webui.py index f476b93..5291d7d 100644 --- a/app/webui.py +++ b/app/webui.py @@ -77,7 +77,7 @@ def create_person(body: NameBody): @app.delete("/api/persons/{slug}") def delete_person(slug: str): gallery.delete_person(slug) - gallery.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": True} class FavBody(BaseModel): @@ -150,7 +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.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/rename") @@ -173,7 +173,7 @@ def set_favorite(slug: str, body: FavBody): def restore_trimmed(slug: str, fname: str): ok = gallery.restore_trimmed(slug, fname) if ok: - gallery.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": ok} @app.delete("/api/persons/{slug}/trimmed/{fname}") @@ -194,14 +194,14 @@ def deduplicate(body: dict = None): pix = gallery.deduplicate_pixels_all(dry_run=dry) emb = gallery.deduplicate_all(thr, dry_run=dry) if not dry and (pix or emb): - gallery.refresh_guesses() + 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.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/faces/{fname}/unassign") diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py index f486abf..6aefcd9 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -5,6 +5,7 @@ """ import hashlib import json +import os import logging import re import shutil @@ -65,6 +66,9 @@ 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_thread = None self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]} self._ign_emb = np.zeros((0, 512), dtype=np.float32) self._ign_ids: list[str] = [] @@ -1052,7 +1056,51 @@ def refresh_guesses(self): else: m.pop("guess_top_photo", None) m.pop("guess_top_slug", None) - jf.write_text(json.dumps(m, ensure_ascii=False)) + # 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) + tmp = jf.with_suffix(".json.tmp") + tmp.write_text(raw) + os.replace(tmp, jf) + + def request_refresh_guesses(self, delay: float = 0.4): + """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: + self._refresh_deadline = time.time() + 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 + 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/webui.py b/faceid-addon/app/webui.py index f476b93..5291d7d 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -77,7 +77,7 @@ def create_person(body: NameBody): @app.delete("/api/persons/{slug}") def delete_person(slug: str): gallery.delete_person(slug) - gallery.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": True} class FavBody(BaseModel): @@ -150,7 +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.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/rename") @@ -173,7 +173,7 @@ def set_favorite(slug: str, body: FavBody): def restore_trimmed(slug: str, fname: str): ok = gallery.restore_trimmed(slug, fname) if ok: - gallery.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": ok} @app.delete("/api/persons/{slug}/trimmed/{fname}") @@ -194,14 +194,14 @@ def deduplicate(body: dict = None): pix = gallery.deduplicate_pixels_all(dry_run=dry) emb = gallery.deduplicate_all(thr, dry_run=dry) if not dry and (pix or emb): - gallery.refresh_guesses() + 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.refresh_guesses() + gallery.request_refresh_guesses() return {"ok": True} @app.post("/api/persons/{slug}/faces/{fname}/unassign") From 5cd50b248e490cdcdb0db2999069f6f0dd5f9de0 Mon Sep 17 00:00:00 2001 From: skycryer Date: Wed, 7 Oct 2026 11:21:16 +0200 Subject: [PATCH 6/8] Serialise the refresh pass and cap the debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the review of the debounced refresh. The temp file was named after its target, so every writer of the same unknown shared one path — and nothing stopped a request handler's direct refresh_guesses() from running while the background worker was in one. Two passes then interleaved as 'A writes half, B replaces', publishing the truncated file as the real metadata: exactly what the atomic replace is there to prevent. The pass now runs under its own lock (not self._lock, which match() already holds), and each write goes through tempfile.mkstemp() so no two writers can collide. A failed write unlinks its temp file instead of leaving it behind. The debounce was trailing-only, so a steady stream closer than the delay — bulk deletion, a dedupe cleanup loop — deferred the refresh for the whole burst while the UI kept showing cues for photos that were already gone. The deadline is now also capped at first_request + max_delay (2 s default). Verified the other way round: with the shared temp name and the trailing-only deadline restored, both new tests fail, five runs out of five — the concurrency one on the FileNotFoundError from two threads racing for the same temp path. 108 tests green with the fix. --- app/gallery.py | 49 +++++++++++++++++++---- faceid-addon/CHANGELOG.md | 4 ++ faceid-addon/app/gallery.py | 49 +++++++++++++++++++---- tests/test_gallery_person_names.py | 64 ++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 14 deletions(-) diff --git a/app/gallery.py b/app/gallery.py index 6aefcd9..0fe78ff 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -9,6 +9,7 @@ import logging import re import shutil +import tempfile import threading import time import unicodedata @@ -68,7 +69,11 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40): 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] = [] @@ -1040,7 +1045,17 @@ 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): for jf in self.unknown_dir.glob("*.json"): try: m = json.loads(jf.read_text()) @@ -1059,11 +1074,22 @@ def refresh_guesses(self): # 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) - tmp = jf.with_suffix(".json.tmp") - tmp.write_text(raw) - os.replace(tmp, jf) - - def request_refresh_guesses(self, delay: float = 0.4): + # 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) + os.replace(tmp, jf) + except OSError: + # Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell. + tmp.unlink(missing_ok=True) + raise + + 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 @@ -1071,7 +1097,15 @@ def request_refresh_guesses(self, delay: float = 0.4): cleanup burst into one background pass. """ with self._refresh_sched_lock: - self._refresh_deadline = time.time() + delay + 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", @@ -1093,6 +1127,7 @@ def _refresh_guesses_worker(self): 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: 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/gallery.py b/faceid-addon/app/gallery.py index 6aefcd9..0fe78ff 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -9,6 +9,7 @@ import logging import re import shutil +import tempfile import threading import time import unicodedata @@ -68,7 +69,11 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40): 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] = [] @@ -1040,7 +1045,17 @@ 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): for jf in self.unknown_dir.glob("*.json"): try: m = json.loads(jf.read_text()) @@ -1059,11 +1074,22 @@ def refresh_guesses(self): # 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) - tmp = jf.with_suffix(".json.tmp") - tmp.write_text(raw) - os.replace(tmp, jf) - - def request_refresh_guesses(self, delay: float = 0.4): + # 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) + os.replace(tmp, jf) + except OSError: + # Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell. + tmp.unlink(missing_ok=True) + raise + + 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 @@ -1071,7 +1097,15 @@ def request_refresh_guesses(self, delay: float = 0.4): cleanup burst into one background pass. """ with self._refresh_sched_lock: - self._refresh_deadline = time.time() + delay + 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", @@ -1093,6 +1127,7 @@ def _refresh_guesses_worker(self): 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: diff --git a/tests/test_gallery_person_names.py b/tests/test_gallery_person_names.py index e9070c9..b5a6261 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,62 @@ 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") From 9736b048ef45bcd5d77857ff327dad7b3e5bc687 Mon Sep 17 00:00:00 2001 From: skycryer Date: Wed, 7 Oct 2026 11:26:48 +0200 Subject: [PATCH 7/8] Keep the file mode, and make the replace durable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two differences from the plain write_text() this replaced. mkstemp() creates with 0600 and os.replace() carries that mode onto the target, so every refresh would have narrowed each unknown's JSON to the service user. The mode of the existing file is now copied onto the temp file before the replace, falling back to 0644. And the comment promised more than the code delivered: without flush+fsync the rename can land ahead of the data, leaving a zero-length file after a power cut — the silent loss the atomic replace is there to prevent. The write is now fsynced, and the directory once per pass rather than per file. Measured before claiming it is cheap: 200 unknowns 43.4 ms with fsync against 41.6 ms without (0.009 ms per file), 2000 unknowns 426 ms against 428 ms — inside the noise. The mode test fails with 384 != 420 when the chmod is removed. --- app/gallery.py | 33 ++++++++++++++++++++++++++++++ faceid-addon/app/gallery.py | 33 ++++++++++++++++++++++++++++++ tests/test_gallery_person_names.py | 11 ++++++++++ 3 files changed, 77 insertions(+) diff --git a/app/gallery.py b/app/gallery.py index 0fe78ff..1813a05 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -9,6 +9,7 @@ import logging import re import shutil +import stat as stat_module import tempfile import threading import time @@ -1056,6 +1057,7 @@ def refresh_guesses(self): 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()) @@ -1083,11 +1085,42 @@ def _refresh_guesses_once(self): 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()) + # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die + # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus + # des Ziels uebernehmen, sonst die uebliche Vorgabe. + try: + os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode)) + except OSError: + os.chmod(tmp, 0o644) 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). diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py index 0fe78ff..1813a05 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -9,6 +9,7 @@ import logging import re import shutil +import stat as stat_module import tempfile import threading import time @@ -1056,6 +1057,7 @@ def refresh_guesses(self): 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()) @@ -1083,11 +1085,42 @@ def _refresh_guesses_once(self): 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()) + # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die + # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus + # des Ziels uebernehmen, sonst die uebliche Vorgabe. + try: + os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode)) + except OSError: + os.chmod(tmp, 0o644) 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). diff --git a/tests/test_gallery_person_names.py b/tests/test_gallery_person_names.py index b5a6261..e77c0aa 100644 --- a/tests/test_gallery_person_names.py +++ b/tests/test_gallery_person_names.py @@ -109,3 +109,14 @@ def test_a_burst_cannot_defer_the_refresh_forever(self): 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) From 8ccf4b42fa090466bab27eb960613fb9f6daab03 Mon Sep 17 00:00:00 2001 From: skycryer Date: Wed, 7 Oct 2026 11:39:52 +0200 Subject: [PATCH 8/8] Do not resurrect an unknown deleted during the pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.replace() recreates its target unconditionally, so an unknown that was assigned or discarded while the refresh was mid-pass came back as a JSON file with stale metadata and no crop — and reappeared in the review queue. The debounced background worker widened that window by design: the pass now runs beside the handlers instead of inside one. The target is stat'ed once (its mode is needed anyway) and a missing file means skip rather than write, with a second existence check immediately before the replace. The window shrinks from the whole write to two calls; closing it fully would need the handlers to take the refresh lock, which is a bigger change than this PR should carry. Reverting the skip makes the new test fail with 'True is not false: ein geloeschtes Unknown darf nicht zurueckkommen'. 110 tests green. --- app/gallery.py | 23 +++++++++++++++++------ faceid-addon/app/gallery.py | 23 +++++++++++++++++------ tests/test_gallery_person_names.py | 24 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/app/gallery.py b/app/gallery.py index 1813a05..b767608 100644 --- a/app/gallery.py +++ b/app/gallery.py @@ -1091,13 +1091,24 @@ def _refresh_guesses_once(self): # Verlust, gegen den das Ersetzen hier antritt. fh.flush() os.fsync(fh.fileno()) - # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die - # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus - # des Ziels uebernehmen, sonst die uebliche Vorgabe. + # 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: - os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode)) - except OSError: - os.chmod(tmp, 0o644) + 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: diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py index 1813a05..b767608 100644 --- a/faceid-addon/app/gallery.py +++ b/faceid-addon/app/gallery.py @@ -1091,13 +1091,24 @@ def _refresh_guesses_once(self): # Verlust, gegen den das Ersetzen hier antritt. fh.flush() os.fsync(fh.fileno()) - # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die - # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus - # des Ziels uebernehmen, sonst die uebliche Vorgabe. + # 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: - os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode)) - except OSError: - os.chmod(tmp, 0o644) + 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: diff --git a/tests/test_gallery_person_names.py b/tests/test_gallery_person_names.py index e77c0aa..11f7637 100644 --- a/tests/test_gallery_person_names.py +++ b/tests/test_gallery_person_names.py @@ -120,3 +120,27 @@ def test_the_file_mode_survives_a_refresh(self): _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")