Skip to content

Commit 6f69b4c

Browse files
committed
Fall back to the recording when the snapshot has no face
Frigate picks its snapshot by highest person score, which is not the same as "a face is visible" — often it is the moment someone turns away. Measured over seven days of real events, only 19% of snapshots held a usable face: 55% had none at all, 14% one below min_face_px, 12% one too uncertain. Re-checking failed events against the clip found a good face in 9 of 12 (det 0.68-0.87). Those events were previously lost entirely — not recognised, not even queued for review. Runs only when the snapshot found nothing, once per event, on its own thread with a short queue, so live recognition and presence updates are never delayed and an event burst skips scans instead of building a backlog. The frame is picked by detection quality, deliberately not by gallery similarity: choosing whichever of twelve frames looks most like someone known would flatter the numbers and invite misassignments. Identity is decided afterwards, exactly as on the snapshot path. Gains depend on the camera angle, so the docs say so instead of quoting one number: at a front door at head height the recording rescued 4 of 4, on a high-mounted indoor camera and a zoomed garden view none. Raising det_size does not help either (1280 gave results identical to 640 at twice the cost) — the limit is the viewing angle, not the resolution.
1 parent c573522 commit 6f69b4c

13 files changed

Lines changed: 445 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,40 @@
33
All notable changes to FaceID. The Home Assistant app shows this file in the
44
update dialog; standalone users can watch GitHub releases.
55

6+
## 0.9.0 — 2026-08-08
7+
8+
- **Recognition now falls back to the recording when the snapshot has no face.** This is
9+
the single biggest limit on recognition — bigger than the gallery, the threshold or
10+
`match_top_k`, all of which took far more effort to tune.
11+
12+
Frigate picks its snapshot by highest *person* score, which is not the same as "a face
13+
is visible" — frequently it is the moment someone turns away. Over seven days of real
14+
events only **19%** of snapshots held a usable face: 55% had no detectable face at all,
15+
14% one below `min_face_px`, 12% one too uncertain to trust.
16+
17+
Re-checking twelve of the failed events against the clip found a good face in **nine**
18+
(det 0.68–0.87).
19+
20+
How much this gains depends on where a camera points, so measure yours: at the front
21+
door, at head height, the recording rescued 4 of 4 events; on a high-mounted indoor
22+
camera and a zoomed garden view it rescued none — there the clip holds no face either.
23+
24+
Three plausible explanations were measured and ruled out: night (IR failed 11 times,
25+
colour 12), distance (not one crop was narrower than 120px), and detection resolution
26+
(`det_size` 1280 gave results identical to 640 at twice the cost). What remains is the
27+
viewing angle.
28+
29+
- Runs only when the snapshot found nothing, once per event, on its own thread with a
30+
short queue — live recognition and presence updates are never delayed, and a burst of
31+
events skips scans rather than building a backlog. New option **Search the recording**
32+
(`clip_fallback`, default on); turn it off on tight hardware.
33+
- The frame is selected by detection quality, deliberately **not** by gallery similarity:
34+
picking whichever of twelve frames looks most like someone known would flatter the
35+
numbers and invite misassignments. Identity is decided afterwards, as on the snapshot
36+
path.
37+
- Faces found this way skip the `hires_enroll` pass — they already come from the
38+
recording, so re-scanning the same clip would only cost time.
39+
640
## 0.8.2 — 2026-08-08
741

842
- **Fixed: the app refused to start with "No MQTT broker configured" even with Mosquitto

README.md

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,61 @@ is compared against the snapshot face and the best identity match wins — so wi
204204
people in frame, the wrong face can't be enrolled. When no frame yields a usable face
205205
(roughly one event in three), the original snapshot is kept.
206206

207-
Live recognition still uses the fast snapshot path; only enrollment takes the slower,
208-
sharper route (a few seconds and one clip download per event). Needs recordings enabled
209-
for the camera.
207+
Live recognition tries the fast snapshot path first; only when that yields nothing does it
208+
fall back to the recording (see below). Needs recordings enabled for the camera.
209+
210+
## When the snapshot has no face
211+
212+
**Frigate picks its snapshot by highest *person* score — which is not the same as "a face
213+
is visible".** Often the clearest view *of a person* is the moment they turn away. This is
214+
not a rare edge case. Measured over seven days of real events on a four-camera setup:
215+
216+
| what the snapshot gave us | events | |
217+
|---|---|---|
218+
| no face detected at all | 23 | 55% |
219+
| face too small (median 34px) | 6 | 14% |
220+
| detection too uncertain | 5 | 12% |
221+
| **usable face** | **8** | **19%** |
222+
223+
Two obvious suspects turned out to be innocent. **Night is not the problem** — IR and
224+
colour failed at almost the same rate (11 vs 12). **Distance is not the problem either**
225+
not one crop was narrower than 120px; the people were plenty large in frame.
226+
227+
The recording tells a different story. Of twelve failed events re-checked frame by frame,
228+
**nine had a perfectly good face** (det 0.68–0.87) that the snapshot simply missed.
229+
230+
So **Settings → Search the recording** (default on) does exactly that: when an event ends
231+
and the snapshot never produced a face, FaceID samples frames across the clip and takes
232+
the best one.
233+
234+
**How much it helps depends entirely on where the camera points**, so measure yours rather
235+
than expecting a number. On the setup above, checked per camera:
236+
237+
| camera | recording rescued |
238+
|---|---|
239+
| front door, at head height | 4/4 (and 9/12 in a wider sample) |
240+
| child's room, mounted high | 0/4 |
241+
| garden, zoomed | 0/2 |
242+
243+
Where people walk towards the lens, almost every failed snapshot is recoverable. Where the
244+
camera looks down at people, or catches them side-on at distance, the clip holds no face
245+
either — no amount of scanning invents one. Raising `det_size` does not change this: at
246+
1280 instead of 640 the results were identical, at twice the cost (11.1s vs 5.4s per
247+
event). The limit is the viewing angle, not the resolution.
248+
249+
Three deliberate details:
250+
251+
* **Only when the snapshot found nothing.** Events that already worked cost nothing extra.
252+
* **Selection is by detection quality, never by gallery similarity.** Picking whichever of
253+
twelve frames happens to look most like someone you know would inflate the numbers and
254+
invite misassignments. The frame is chosen on image quality; identity is decided
255+
afterwards, exactly as on the snapshot path.
256+
* **Its own thread, short queue.** A clip scan takes seconds. It must not delay live
257+
recognition or presence updates, and during a burst of events it skips rather than
258+
building a backlog that runs minutes behind reality.
259+
260+
Turn it off if your hardware is tight — the cost is a few seconds of CPU per *otherwise
261+
failed* event, plus one clip download.
210262

211263
### Recovering missed events
212264

@@ -308,6 +360,18 @@ backup from `data/backups`) and adds a practical probe against recent Frigate ev
308360
including how much headroom each recognition has above the threshold. Events whose face
309361
is already in the gallery are excluded — they score ~1.0 and measure nothing.
310362

363+
**Running FaceID as a Home Assistant app?** You have no shell, so the part that decides
364+
your threshold was moved into the UI: Settings → *Does it actually work?* runs the
365+
leave-one-out test and the practical probe as a background job and reports the one number
366+
that matters, how high a stranger got. That covers threshold and `match_top_k`.
367+
368+
Two things still need a shell, and neither is required to run FaceID well:
369+
370+
* the **coverage report** (`coverage.py`) — which angles, cameras and IR shots each
371+
person is missing
372+
* **comparing two galleries** (`--baseline`) — useful after a round of enrolling, but the
373+
UI analysis already tells you where you stand today
374+
311375

312376
## How training stays healthy
313377

app/hires.py

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
voller Kameraauflösung auf (z. B. 2560x1920). Für die Galerie lohnt sich deshalb der
55
Umweg über die Aufnahme: dort sind Gesichter typischerweise doppelt so groß.
66
7-
Bewusst nur fürs Enrollment (Review-Queue, Verlaufs-Scan) — die Live-Erkennung bleibt
8-
schnell auf dem Snapshot.
7+
Die Live-Erkennung arbeitet zuerst auf dem Snapshot — der ist sofort da und kostet
8+
nichts. Erst wenn der gar kein Gesicht hergibt, lohnt der Blick in die Aufnahme
9+
(``find_face_in_clip``): gemessen an sieben Tagen echter Ereignisse hatten nur 19 %
10+
der Snapshots ein verwertbares Gesicht, der Clip lieferte in 9 von 12 Fällen doch
11+
noch eines. Frigate wählt seinen Snapshot nach dem höchsten Personen-Score aus, und
12+
das ist ein anderes Kriterium als "Gesicht sichtbar".
913
"""
1014
import logging
1115
import os
@@ -39,33 +43,29 @@ def _pick(engine, frame, ref_embedding, min_px, identity_min, min_det=0.55):
3943
return best
4044

4145

42-
def _scan_clip(engine, frigate, event_id, ref_embedding, max_frames, min_px, identity_min,
43-
min_det):
46+
def _clip_frames(frigate, event_id, max_frames):
47+
"""Gleichmäßig über den Clip verteilte Frames — der Generator raeumt selbst auf.
48+
49+
Wird bewusst immer vollstaendig durchlaufen (kein ``break`` beim Aufrufer), damit
50+
das ``finally`` die heruntergeladene Datei sicher wieder loescht.
51+
"""
4452
fd, path = tempfile.mkstemp(suffix=".mp4", prefix="faceid-clip-")
4553
os.close(fd)
4654
try:
4755
if not frigate.download_clip(event_id, path):
48-
return None
56+
return
4957
cap = cv2.VideoCapture(path)
5058
try:
5159
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
5260
if total <= 0:
53-
return None
54-
# Gleichmäßig über den Clip abtasten — Frigate wählt für den Snapshot den
55-
# besten Moment, den kennen wir nicht.
56-
idxs = np.linspace(0, total - 1, min(max_frames, total)).astype(int)
57-
best = None
58-
for i in idxs:
61+
return
62+
# Frigate waehlt fuer den Snapshot den besten Moment nach seinem Kriterium,
63+
# den kennen wir nicht — also gleichmaessig abtasten.
64+
for i in np.linspace(0, total - 1, min(max_frames, total)).astype(int):
5965
cap.set(cv2.CAP_PROP_POS_FRAMES, int(i))
6066
ok, frame = cap.read()
61-
if not ok or frame is None:
62-
continue
63-
cand = _pick(engine, frame, ref_embedding, min_px, identity_min, min_det)
64-
if cand is None:
65-
continue
66-
if best is None or cand[1] > best[0][1]:
67-
best = (cand, frame)
68-
return (best[0][2], best[1]) if best else None
67+
if ok and frame is not None:
68+
yield frame
6969
finally:
7070
cap.release()
7171
finally:
@@ -75,6 +75,42 @@ def _scan_clip(engine, frigate, event_id, ref_embedding, max_frames, min_px, ide
7575
pass
7676

7777

78+
def _scan_clip(engine, frigate, event_id, ref_embedding, max_frames, min_px, identity_min,
79+
min_det):
80+
best = None
81+
for frame in _clip_frames(frigate, event_id, max_frames):
82+
cand = _pick(engine, frame, ref_embedding, min_px, identity_min, min_det)
83+
if cand is None:
84+
continue
85+
if best is None or cand[1] > best[0][1]:
86+
best = (cand, frame)
87+
return (best[0][2], best[1]) if best else None
88+
89+
90+
def find_face_in_clip(engine, frigate, event_id: str, max_frames: int = 12,
91+
min_px: int = 48, min_det: float = 0.65):
92+
"""Bestes Gesicht im Clip — fuer Ereignisse, deren Snapshot gar keines hergab.
93+
94+
Ausgewaehlt wird nach det_score, ausdruecklich NICHT nach Galerie-Aehnlichkeit:
95+
sonst sucht man sich aus zwoelf Frames denjenigen heraus, der zufaellig am ehesten
96+
wie jemand Bekanntes aussieht, und rechnet sich die Erkennung schoen. Kriterium
97+
bleibt die Bildqualitaet, die Zuordnung kommt danach — genau wie beim Snapshot.
98+
99+
-> (face, frame) oder None
100+
"""
101+
best = None
102+
for frame in _clip_frames(frigate, event_id, max_frames):
103+
for f in engine.faces(frame):
104+
w = float(f.bbox[2] - f.bbox[0])
105+
h = float(f.bbox[3] - f.bbox[1])
106+
if w < min_px or h < min_px or float(f.det_score) < min_det:
107+
continue
108+
key = (float(f.det_score), w)
109+
if best is None or key > best[0]:
110+
best = (key, f, frame)
111+
return (best[1], best[2]) if best else None
112+
113+
78114
def _scan_recordings(engine, frigate, camera, start_time, end_time, ref_embedding,
79115
attempts, min_px, identity_min, min_det):
80116
"""Fallback ohne Clip: einzelne Aufnahme-Frames per HTTP abklopfen."""

app/mqtt_listener.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
- Match >= match_threshold -> Person publizieren + Frigate sub_label
55
- Match < unknown_threshold -> als Unbekannter in die Review-Queue
66
- dazwischen -> unsicher; nur Review-Queue, keine Meldung
7+
8+
Gibt der Snapshot ueberhaupt kein Gesicht her, wird am Ereignisende die Aufnahme
9+
abgetastet (clip_fallback) — das ist der haeufigste Fall, nicht die Ausnahme.
710
"""
811
import json
912
import logging
@@ -16,7 +19,7 @@
1619
import requests
1720

1821
from .engine import FaceEngine, crop_face
19-
from .hires import upgrade_face
22+
from .hires import find_face_in_clip, upgrade_face
2023

2124
log = logging.getLogger("faceid.mqtt")
2225

@@ -43,6 +46,18 @@ def __init__(self, cfg: dict, engine, gallery, frigate):
4346
self.ignore_thr = float(f.get("ignore_threshold", f.get("match_threshold", 0.5)))
4447
self.ignore_learning = bool(f.get("ignore_learning", True))
4548
self.hires_enroll = bool(f.get("hires_enroll", True))
49+
# Frigate waehlt seinen Snapshot nach dem hoechsten Personen-Score, nicht danach,
50+
# ob ein Gesicht zu sehen ist — das ist oft der Moment, in dem jemand weggeht.
51+
# Ueber sieben Tage echter Ereignisse hatten nur 19 % der Snapshots ein
52+
# verwertbares Gesicht; im Clip fanden sich 9 von 12 doch noch. Deshalb: erst
53+
# Snapshot (sofort da, kostet nichts), und nur wenn der leer bleibt, die Aufnahme.
54+
self.clip_fallback = bool(f.get("clip_fallback", True))
55+
self.clip_frames = int(f.get("clip_fallback_frames", 12))
56+
# strenger als beim Snapshot (0.55): aus zwoelf Frames darf man waehlerisch sein
57+
self.clip_min_det = float(f.get("clip_fallback_min_det", 0.65))
58+
# klein gehalten: bei einem Ereignisschwall lieber welche auslassen als eine
59+
# Warteschlange aufbauen, die Minuten hinter der Gegenwart herlaeuft
60+
self.clip_queue: "queue.Queue[str]" = queue.Queue(maxsize=20)
4661
# Ereignisse, die Frigate nicht per MQTT meldet (z. B. per API angelegte
4762
# Kamera-Meldungen als Zuverlaessigkeits-Bruecke), per Abfrage nachziehen.
4863
self.poll_interval = float(f.get("poll_interval", 0))
@@ -73,6 +88,11 @@ def start(self):
7388
self._check_frigate()
7489
threading.Thread(target=self._worker, daemon=True, name="faceid-worker").start()
7590
threading.Thread(target=self._finalizer, daemon=True, name="faceid-finalizer").start()
91+
# Eigener Thread: ein Clip-Scan dauert Sekunden, im Finalizer wuerde er die
92+
# Anwesenheits-Aktualisierung blockieren, im Worker die naechsten Snapshots.
93+
# Laeuft unabhaengig von clip_fallback, damit die Option in den Einstellungen
94+
# sofort greift statt erst nach einem Neustart — er wartet dann nur an der Queue.
95+
threading.Thread(target=self._clip_worker, daemon=True, name="faceid-clip").start()
7696
if self.poll_interval > 0:
7797
threading.Thread(target=self._poller, daemon=True, name="faceid-poller").start()
7898

@@ -177,6 +197,16 @@ def _process(self, eid: str):
177197
log.info("event %s (%s): attempt %d, no face detected in snapshot %dx%d",
178198
eid, st["camera"], st["attempts"], w, h)
179199
return
200+
self._handle_face(eid, st, img, face)
201+
202+
def _handle_face(self, eid: str, st: dict, img, face, source: str = "snapshot"):
203+
"""Gefundenes Gesicht zuordnen, melden, ablegen.
204+
205+
Gemeinsam fuer Snapshot und Aufnahme — beide Wege muessen dieselben Schwellen,
206+
dieselbe Ignore-Logik und dieselbe Meldung verwenden, sonst haengt das Ergebnis
207+
davon ab, welcher Weg zufaellig gegriffen hat.
208+
"""
209+
via = "" if source == "snapshot" else f" (from the {source})"
180210
emb = face.normed_embedding
181211
slug, name, score = self.gallery.match(emb)
182212
ig = self.gallery.match_ignored(emb)
@@ -194,7 +224,8 @@ def _process(self, eid: str):
194224
log.info("event %s (%s): ignored face (sim %.3f)", eid, st["camera"], ig)
195225
return
196226
crop = crop_face(img, face.bbox)
197-
log.info("event %s (%s): attempt %d, match %s (%.3f)", eid, st["camera"], st["attempts"], name, score)
227+
log.info("event %s (%s): attempt %d, match %s (%.3f)%s", eid, st["camera"],
228+
st["attempts"], name, score, via)
198229

199230
if slug and score >= self.match_thr:
200231
if score > st["best_score"]:
@@ -209,7 +240,10 @@ def _process(self, eid: str):
209240
prev = st.get("best_unknown")
210241
if prev is None or face.det_score > prev["det_score"]:
211242
st["best_unknown"] = {"crop": crop, "emb": emb, "det_score": float(face.det_score),
212-
"guess": name, "guess_score": float(score), "full": img}
243+
"guess": name, "guess_score": float(score), "full": img,
244+
# aus der Aufnahme ist bereits das schaerfste Bild —
245+
# ein zweiter Durchgang durch hires waere derselbe Clip
246+
"from_clip": source != "snapshot"}
213247

214248
def _poller(self):
215249
"""Frigate-Ereignisse abfragen, die per MQTT nie ankommen.
@@ -259,6 +293,39 @@ def _poller(self):
259293
except queue.Full:
260294
log.warning("queue full — dropped polled event %s", eid)
261295

296+
def _clip_worker(self):
297+
"""Ereignisse nachbearbeiten, deren Snapshot kein Gesicht hergab."""
298+
while True:
299+
eid = self.clip_queue.get()
300+
try:
301+
self._process_clip(eid)
302+
except Exception:
303+
log.exception("error while scanning the recording of event %s", eid)
304+
finally:
305+
st = self.events.get(eid)
306+
if st is not None:
307+
st["clip_pending"] = False
308+
309+
def _process_clip(self, eid: str):
310+
st = self.events.get(eid)
311+
if st is None or st["done"]:
312+
return
313+
t0 = time.time()
314+
hit = find_face_in_clip(self.engine, self.frigate, eid,
315+
max_frames=self.clip_frames,
316+
min_px=self.min_face_px,
317+
min_det=self.clip_min_det)
318+
took = time.time() - t0
319+
if hit is None:
320+
log.info("event %s (%s): no face in the recording either (%d frames, %.1fs)",
321+
eid, st["camera"], self.clip_frames, took)
322+
return
323+
face, frame = hit
324+
log.info("event %s (%s): the recording has a face the snapshot missed "
325+
"(%dpx, det %.2f, %.1fs)", eid, st["camera"],
326+
int(face.bbox[2] - face.bbox[0]), float(face.det_score), took)
327+
self._handle_face(eid, st, frame, face, source="recording")
328+
262329
def _finalizer(self):
263330
"""Beendete Events abschließen: Unknown ablegen, 'unbekannt' melden, aufräumen."""
264331
while True:
@@ -273,10 +340,27 @@ def _finalizer(self):
273340
continue
274341
if now - st["last_try"] < self.retry_secs + 1 and not expired:
275342
continue # letzter Versuch evtl. noch in der Queue
343+
# Snapshot hat nichts gefunden -> in der Aufnahme nachsehen, bevor das
344+
# Ereignis verworfen wird. Erst hier, weil der Clip erst am Ende steht.
345+
if (self.clip_fallback and st["best_person"] is None
346+
and st["best_unknown"] is None and not st.get("clip_tried")):
347+
st["clip_tried"] = True
348+
try:
349+
self.clip_queue.put_nowait(eid)
350+
st["clip_pending"] = True
351+
st["clip_since"] = now
352+
continue
353+
except queue.Full:
354+
log.info("event %s: clip queue full, skipping the recording scan", eid)
355+
if st.get("clip_pending"):
356+
if now - st.get("clip_since", now) < 300:
357+
continue # laeuft noch
358+
log.warning("event %s: recording scan did not finish, closing anyway", eid)
359+
st["clip_pending"] = False
276360
if st["best_person"] is None and st["best_unknown"] is not None:
277361
u = st["best_unknown"]
278362
crop, emb, full = u["crop"], u["emb"], u.get("full")
279-
if self.hires_enroll:
363+
if self.hires_enroll and not u.get("from_clip"):
280364
# schärferes Gesicht aus der Aufnahme holen (bessere Referenz)
281365
try:
282366
hi = upgrade_face(self.engine, self.frigate, st["camera"],

0 commit comments

Comments
 (0)