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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions app/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Comment thread
the-codemole[bot] marked this conversation as resolved.
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}

Expand Down
20 changes: 20 additions & 0 deletions faceid-addon/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions faceid-addon/app/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}

Expand Down
2 changes: 1 addition & 1 deletion faceid-addon/config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 29 additions & 1 deletion faceid-addon/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -151,6 +167,18 @@ <h1>FACEID</h1>
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
Expand Down
30 changes: 29 additions & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -151,6 +167,18 @@ <h1>FACEID</h1>
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 => {
Comment thread
the-codemole[bot] marked this conversation as resolved.
// 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
Expand Down
96 changes: 96 additions & 0 deletions tests/test_assign_guards.py
Original file line number Diff line number Diff line change
@@ -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)
Loading