diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c831b..8555a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 0.25.1 — 2026-10-06 + +- **A failed assignment no longer leaves an empty person behind.** Assigning faces created + the person first and assigned afterwards, so when nothing could be assigned — stale ids, an + empty selection, or a second tab that got there first — the answer was HTTP 200 with + `{"assigned": 0}` and the person stayed. Through "Track as new" that meant an empty + `Unnamed-xxxxxx` in the gallery on every failed attempt, while the UI reported + `0 face(s) → Unnamed-ab12cd` and looked like success. It now answers 409 and removes a + person that was created for that very call; an existing person is never deleted, including + when its display name or a different casing was typed. +- **The action buttons show what they are doing.** ASSIGN, ASSIGN ALL and "Track as new" had + no hover, pressed or keyboard-focus style at all. Measured: 1.66:1 from rest to hover, + 1.50:1 from hover to pressed, with the label staying above 4.5:1 on both, and a focus ring + at 13:1 against the panel. Pressing also shifts by 1px, which `prefers-reduced-motion` + turns off. +- **Failures are visible.** A click whose request failed used to do nothing and say nothing: + the request helper is awaited in 25 handlers, most without their own error handling. A + single listener now reports every one of them, and still logs the original error to the + console so a programming mistake keeps its stack trace. + ## 0.25.0 — 2026-10-06 - **Folder mode is available in the Home Assistant app.** The directory watcher has existed diff --git a/app/webui.py b/app/webui.py index 700fa41..ae3f210 100644 --- a/app/webui.py +++ b/app/webui.py @@ -275,6 +275,11 @@ def unknowns(): def assign(body: AssignBody): persons_now = gallery.persons() slug = body.person if body.person in persons_now else gallery.create_person(body.person) + # Am aufgeloesten Slug entscheiden, nicht an der Eingabe: create_person ist + # absichtlich idempotent und gibt zu "Juli", "juli " oder einem zweiten Tab + # denselben vorhandenen Slug zurueck. Wer hier die Eingabe prueft, loescht + # bei null Treffern eine bestehende Person samt ihrer Gesichter. + created_now = slug not in persons_now name = gallery.persons()[slug]["name"] n = 0 for uid in body.ids: @@ -290,6 +295,18 @@ def assign(body: AssignBody): # Zuordnung ans Original-Event zurückspielen (Mensch bestätigt -> Score 1.0) if getattr(processor.frigate, "enabled", True) and meta.get("event_id"): processor.frigate.set_sub_label(meta["event_id"], name, 1.0) + if n == 0: + # Kein Gesicht gelandet heisst: veraltete IDs, leere Auswahl, oder ein + # zweiter Tab war schneller. Das war bisher ein HTTP 200 mit + # {"assigned": 0} — die Oberflaeche meldete "0 face(s) → Name" und sah aus + # wie Erfolg. Wurde die Person fuer genau diesen Aufruf angelegt, gehoert + # sie wieder weg: sonst haeuft jeder Fehlversuch ueber "Track as new" ein + # leeres Unnamed-xxxxxx in der Galerie an, das niemand zuordnen kann. + # Eine vorhandene Person wird nie geloescht. + if created_now: + gallery.delete_person(slug) + raise HTTPException(409, f"no face was assigned — {name} was not created") + raise HTTPException(409, "no face was assigned — the queue may have changed") gallery.refresh_guesses() return {"assigned": n, "slug": slug} diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md index e2c831b..8555a79 100644 --- a/faceid-addon/CHANGELOG.md +++ b/faceid-addon/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 0.25.1 — 2026-10-06 + +- **A failed assignment no longer leaves an empty person behind.** Assigning faces created + the person first and assigned afterwards, so when nothing could be assigned — stale ids, an + empty selection, or a second tab that got there first — the answer was HTTP 200 with + `{"assigned": 0}` and the person stayed. Through "Track as new" that meant an empty + `Unnamed-xxxxxx` in the gallery on every failed attempt, while the UI reported + `0 face(s) → Unnamed-ab12cd` and looked like success. It now answers 409 and removes a + person that was created for that very call; an existing person is never deleted, including + when its display name or a different casing was typed. +- **The action buttons show what they are doing.** ASSIGN, ASSIGN ALL and "Track as new" had + no hover, pressed or keyboard-focus style at all. Measured: 1.66:1 from rest to hover, + 1.50:1 from hover to pressed, with the label staying above 4.5:1 on both, and a focus ring + at 13:1 against the panel. Pressing also shifts by 1px, which `prefers-reduced-motion` + turns off. +- **Failures are visible.** A click whose request failed used to do nothing and say nothing: + the request helper is awaited in 25 handlers, most without their own error handling. A + single listener now reports every one of them, and still logs the original error to the + console so a programming mistake keeps its stack trace. + ## 0.25.0 — 2026-10-06 - **Folder mode is available in the Home Assistant app.** The directory watcher has existed diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py index 700fa41..ae3f210 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -275,6 +275,11 @@ def unknowns(): def assign(body: AssignBody): persons_now = gallery.persons() slug = body.person if body.person in persons_now else gallery.create_person(body.person) + # Am aufgeloesten Slug entscheiden, nicht an der Eingabe: create_person ist + # absichtlich idempotent und gibt zu "Juli", "juli " oder einem zweiten Tab + # denselben vorhandenen Slug zurueck. Wer hier die Eingabe prueft, loescht + # bei null Treffern eine bestehende Person samt ihrer Gesichter. + created_now = slug not in persons_now name = gallery.persons()[slug]["name"] n = 0 for uid in body.ids: @@ -290,6 +295,18 @@ def assign(body: AssignBody): # Zuordnung ans Original-Event zurückspielen (Mensch bestätigt -> Score 1.0) if getattr(processor.frigate, "enabled", True) and meta.get("event_id"): processor.frigate.set_sub_label(meta["event_id"], name, 1.0) + if n == 0: + # Kein Gesicht gelandet heisst: veraltete IDs, leere Auswahl, oder ein + # zweiter Tab war schneller. Das war bisher ein HTTP 200 mit + # {"assigned": 0} — die Oberflaeche meldete "0 face(s) → Name" und sah aus + # wie Erfolg. Wurde die Person fuer genau diesen Aufruf angelegt, gehoert + # sie wieder weg: sonst haeuft jeder Fehlversuch ueber "Track as new" ein + # leeres Unnamed-xxxxxx in der Galerie an, das niemand zuordnen kann. + # Eine vorhandene Person wird nie geloescht. + if created_now: + gallery.delete_person(slug) + raise HTTPException(409, f"no face was assigned — {name} was not created") + raise HTTPException(409, "no face was assigned — the queue may have changed") gallery.refresh_guesses() return {"assigned": n, "slug": slug} diff --git a/faceid-addon/config.yaml b/faceid-addon/config.yaml index 44e3086..2b9c9aa 100644 --- a/faceid-addon/config.yaml +++ b/faceid-addon/config.yaml @@ -1,5 +1,5 @@ name: FaceID -version: "0.25.0" +version: "0.25.1" slug: faceid description: Face recognition for Frigate — trainable gallery, clustered unknown review, HA sensors url: https://github.com/SkyTechNerds/faceid diff --git a/faceid-addon/static/index.html b/faceid-addon/static/index.html index 0a8dc4a..020ce8a 100644 --- a/faceid-addon/static/index.html +++ b/faceid-addon/static/index.html @@ -82,7 +82,23 @@ padding:5px 12px;cursor:pointer;display:inline-block;line-height:1.35;text-decoration:none;vertical-align:middle} .ghost,button.ghost{background:none;border:1px solid var(--line);color:var(--dim);font:12px var(--mono); padding:4px 10px;cursor:pointer;display:inline-block;line-height:1.35;text-decoration:none;vertical-align:middle} -.ghost:hover,button.ghost:hover{color:var(--fg)} +.ghost:hover,button.ghost:hover{color:var(--fg);border-color:var(--fg)} +/* Zustaende der Handlungsknoepfe. Vorher hatte .act keinen einzigen: Zeigen, + Druecken und Tastaturfokus sahen identisch aus, obwohl ASSIGN und "Track as new" + Personen anlegen. Gemessen statt geschaetzt: Grundzustand zu Hover 1.66:1, Hover zu + Active 1.50:1 — unter etwa 1.5:1 sieht man den Wechsel nicht. Der dunkle Text bleibt + dabei ueber 4.5:1 (7.3:1 auf Hover, 4.9:1 auf Active). Der Fokusring liegt dank + outline-offset auf dem Panel und steht dort mit 13:1. */ +.act:hover,button.act:hover{background:oklch(70% .25 130)} +.act:active,button.act:active{background:oklch(60% .20 130);transform:translateY(1px)} +.ghost:active,button.ghost:active{background:color-mix(in oklab,var(--fg) 12%,transparent)} +.act:focus-visible,button.act:focus-visible,.ghost:focus-visible,button.ghost:focus-visible{ + outline:2px solid var(--fg);outline-offset:2px} +.act[disabled],button.act[disabled],.ghost[disabled],button.ghost[disabled]{ + opacity:.45;cursor:not-allowed} +/* Die einzige Bewegung hier ist der 1px-Versatz beim Druecken — genau der gehoert + abgeschaltet. 'transition:none' waere wirkungslos gewesen: keine deklariert. */ +@media (prefers-reduced-motion:reduce){.act:active,button.act:active{transform:none}} /* Regler in der Akzentfarbe statt im Browser-Blau — das Blau war die einzige Farbe der Oberflaeche, die nicht aus der Palette stammte, und stand direkt neben den gruenen Zahlenwerten. Gilt zugleich fuer Ankreuzfelder. */ @@ -151,6 +167,18 @@

FACEID

async function j(url,opt){const r=await fetch(url,opt);if(!r.ok)throw new Error(await r.text());return r.json()} // FastAPI verpackt den Grund in {"detail": ...} — ohne das Auspacken sieht der // Nutzer nur rohes JSON und weiss nicht, was an seiner Eingabe falsch war. +// Netzwerk- und Serverfehler sichtbar machen, statt sie zu verschlucken. Die +// Handler rufen 'await j(...)' an 56 Stellen in 25 Funktionen auf, die meisten ohne +// eigenes catch — ohne das hier bleibt ein fehlgeschlagener Klick ein stilles +// Nichts. Ein Platz statt 25 verhindert, dass der naechste Handler es wieder vergisst. +window.addEventListener('unhandledrejection', ev => { + // Erst in die Konsole, dann der Toast: preventDefault() unterdrueckt die + // Standardausgabe des Browsers, und ein TypeError waere sonst nur noch ein auf + // 120 Zeichen gekuerzter Hinweis ohne Stapel — schlechter als vorher. + console.error('unhandled rejection:', ev.reason); + toast('Failed: ' + errText(ev.reason).slice(0, 120)); + ev.preventDefault(); +}); 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]))} diff --git a/static/index.html b/static/index.html index 0a8dc4a..020ce8a 100644 --- a/static/index.html +++ b/static/index.html @@ -82,7 +82,23 @@ padding:5px 12px;cursor:pointer;display:inline-block;line-height:1.35;text-decoration:none;vertical-align:middle} .ghost,button.ghost{background:none;border:1px solid var(--line);color:var(--dim);font:12px var(--mono); padding:4px 10px;cursor:pointer;display:inline-block;line-height:1.35;text-decoration:none;vertical-align:middle} -.ghost:hover,button.ghost:hover{color:var(--fg)} +.ghost:hover,button.ghost:hover{color:var(--fg);border-color:var(--fg)} +/* Zustaende der Handlungsknoepfe. Vorher hatte .act keinen einzigen: Zeigen, + Druecken und Tastaturfokus sahen identisch aus, obwohl ASSIGN und "Track as new" + Personen anlegen. Gemessen statt geschaetzt: Grundzustand zu Hover 1.66:1, Hover zu + Active 1.50:1 — unter etwa 1.5:1 sieht man den Wechsel nicht. Der dunkle Text bleibt + dabei ueber 4.5:1 (7.3:1 auf Hover, 4.9:1 auf Active). Der Fokusring liegt dank + outline-offset auf dem Panel und steht dort mit 13:1. */ +.act:hover,button.act:hover{background:oklch(70% .25 130)} +.act:active,button.act:active{background:oklch(60% .20 130);transform:translateY(1px)} +.ghost:active,button.ghost:active{background:color-mix(in oklab,var(--fg) 12%,transparent)} +.act:focus-visible,button.act:focus-visible,.ghost:focus-visible,button.ghost:focus-visible{ + outline:2px solid var(--fg);outline-offset:2px} +.act[disabled],button.act[disabled],.ghost[disabled],button.ghost[disabled]{ + opacity:.45;cursor:not-allowed} +/* Die einzige Bewegung hier ist der 1px-Versatz beim Druecken — genau der gehoert + abgeschaltet. 'transition:none' waere wirkungslos gewesen: keine deklariert. */ +@media (prefers-reduced-motion:reduce){.act:active,button.act:active{transform:none}} /* Regler in der Akzentfarbe statt im Browser-Blau — das Blau war die einzige Farbe der Oberflaeche, die nicht aus der Palette stammte, und stand direkt neben den gruenen Zahlenwerten. Gilt zugleich fuer Ankreuzfelder. */ @@ -151,6 +167,18 @@

FACEID

async function j(url,opt){const r=await fetch(url,opt);if(!r.ok)throw new Error(await r.text());return r.json()} // FastAPI verpackt den Grund in {"detail": ...} — ohne das Auspacken sieht der // Nutzer nur rohes JSON und weiss nicht, was an seiner Eingabe falsch war. +// Netzwerk- und Serverfehler sichtbar machen, statt sie zu verschlucken. Die +// Handler rufen 'await j(...)' an 56 Stellen in 25 Funktionen auf, die meisten ohne +// eigenes catch — ohne das hier bleibt ein fehlgeschlagener Klick ein stilles +// Nichts. Ein Platz statt 25 verhindert, dass der naechste Handler es wieder vergisst. +window.addEventListener('unhandledrejection', ev => { + // Erst in die Konsole, dann der Toast: preventDefault() unterdrueckt die + // Standardausgabe des Browsers, und ein TypeError waere sonst nur noch ein auf + // 120 Zeichen gekuerzter Hinweis ohne Stapel — schlechter als vorher. + console.error('unhandled rejection:', ev.reason); + toast('Failed: ' + errText(ev.reason).slice(0, 120)); + ev.preventDefault(); +}); 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]))} diff --git a/tests/test_assign_guards.py b/tests/test_assign_guards.py new file mode 100644 index 0000000..54faba4 --- /dev/null +++ b/tests/test_assign_guards.py @@ -0,0 +1,96 @@ +"""Was bei einer Zuordnung schiefgehen darf, ohne Spuren zu hinterlassen. + +Gemessen an der laufenden Instanz: POST /api/unknowns/assign mit veralteten IDs gab +HTTP 200 und {"assigned": 0, "slug": "testperson"} zurueck — und die Person war +angelegt. Ueber "Track as new" (PR #32) heisst das: jeder Fehlversuch laesst ein +leeres Unnamed-xxxxxx in der Galerie zurueck. +""" +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from app.gallery import Gallery +from app.webui import build_app + + +def _app(tmp: Path): + data = tmp / "data" + (data / "persons").mkdir(parents=True) + (data / "ignored").mkdir() + gallery = Gallery(data, top_k=3, max_per_person=40) + cfg = {"faceid": {"match_threshold": 0.5, "unknown_threshold": 0.35, + "suggest_threshold": 0.4, "cluster_eps": 0.55, + "ignore_threshold": 0.5, "dedupe_threshold": 0.65}, + "folder": {"enabled": False}, "frigate": {"url": ""}, "mqtt": {}} + proc = SimpleNamespace(_announced=set(), match_thr=0.5, unknown_thr=0.35, + ignore_thr=0.5, history=None, logbuffer=None, + frigate=SimpleNamespace(enabled=False), + folder_ingest=SimpleNamespace(status=lambda: {}, + max_indexed_files=5000, + enabled=False)) + app = build_app(cfg, None, gallery, proc, data, Path("static")) + return app, gallery + + +def _route(app, path: str): + for r in app.routes: + if getattr(r, "path", None) == path: + return r.endpoint + raise AssertionError(f"route {path} not found") + + +class AssignGuardTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.app, self.gallery = _app(Path(self.tmp.name)) + self.assign = _route(self.app, "/api/unknowns/assign") + from app.webui import AssignBody + self.Body = AssignBody + + def tearDown(self): + self.tmp.cleanup() + + def test_a_failed_assign_does_not_leave_an_empty_person(self): + from fastapi import HTTPException + with self.assertRaises(HTTPException) as cm: + self.assign(self.Body(ids=["does-not-exist"], person="Unnamed-ab12cd")) + self.assertEqual(cm.exception.status_code, 409) + self.assertEqual(self.gallery.persons(), {}, + "die fuer diesen Aufruf angelegte Person muss weg sein") + self.assertFalse((Path(self.tmp.name) / "data" / "persons" / "unnamed-ab12cd").exists()) + + def test_an_existing_person_survives_a_failed_assign(self): + from fastapi import HTTPException + slug = self.gallery.create_person("Juli") + with self.assertRaises(HTTPException) as cm: + self.assign(self.Body(ids=["does-not-exist"], person=slug)) + self.assertEqual(cm.exception.status_code, 409) + self.assertIn(slug, self.gallery.persons(), + "eine vorhandene Person darf ein Fehlversuch nie loeschen") + + def test_a_display_name_for_an_existing_person_never_deletes_it(self): + # create_person ist idempotent: "Juli" liefert den vorhandenen Slug "juli" + # zurueck. Wer am Eingabewert statt am aufgeloesten Slug entscheidet, loescht + # hier eine bestehende Person samt Gesichtern. + from fastapi import HTTPException + slug = self.gallery.create_person("Juli") + self.assertEqual(slug, "juli") + for typed in ("Juli", " juli ", "JULI"): + with self.assertRaises(HTTPException) as cm: + self.assign(self.Body(ids=["does-not-exist"], person=typed)) + self.assertEqual(cm.exception.status_code, 409) + self.assertIn(slug, self.gallery.persons(), + f"Eingabe {typed!r} darf die vorhandene Person nicht loeschen") + + def test_a_successful_assign_still_returns_the_count(self): + # Gegenprobe: der 409 darf nur den Nullfall treffen. + import numpy as np + slug = self.gallery.create_person("Juli") + emb = np.ones(512, dtype=np.float32) / np.sqrt(512) + crop = np.full((80, 80, 3), 127, dtype=np.uint8) + uid = self.gallery.save_unknown(crop, emb, {"camera": "entrance"}) + self.assertIsNotNone(uid) + r = self.assign(self.Body(ids=[uid], person=slug)) + self.assertEqual(r["assigned"], 1) + self.assertEqual(r["slug"], slug)