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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-python@v5
with:
Expand Down Expand Up @@ -119,3 +121,25 @@ jobs:
print(f"script block {i} does not parse:\n{r.stderr}"); sys.exit(1)
print(f"{len(blocks)} script block(s) parse")
EOF

- name: Released tag still matches shipped code
if: github.event_name == 'push'
run: |
version=$(python -c 'import yaml; print(yaml.safe_load(open("faceid-addon/config.yaml"))["version"])')
tag="v${version}"
if git rev-parse -q --verify "refs/tags/${tag}^{commit}" >/dev/null; then
tagged=$(git rev-parse "${tag}^{commit}")
current=$(git rev-parse HEAD)
if [ "${tagged}" != "${current}" ]; then
changed=$(git diff --name-only "${tag}"..HEAD -- \
app static requirements.txt faceid-addon/app faceid-addon/static \
faceid-addon/config.yaml faceid-addon/run.sh \
faceid-addon/Dockerfile faceid-addon/requirements.txt)
if [ -n "${changed}" ]; then
echo "${tag} no longer points at the code shipped as ${version}:"
echo "${changed}"
echo "Bump the version and publish a new tag instead of changing a released build."
exit 1
fi
fi
fi
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.

## 5.3.0 — 2026-08-12

- **Safe group-photo enrollment:** FaceID no longer assumes that the largest face
in an uploaded photo belongs to the selected person. A strong, clearly leading
gallery match can be accepted automatically; otherwise the UI shows the photo,
draws every eligible face and requires an explicit choice before anything is saved.
- **Honest clip retry states:** a Frigate clip that is not finalized yet is distinct
from a readable clip containing no usable face. Unavailable clips retry three times
at configurable intervals and remain visible in evidence as `clip_not_ready` instead
of silently completing as “no face”.
- **Secure high-resolution intercom path:** cameras with high-resolution capture enabled
fetch their current recording frame through the configured Frigate API—normally the
authenticated 8971 endpoint. FaceID does not expose or require go2rtc port 1984, and
the evidence records the `secure_live_hires` source used for the decision.
- **Release integrity:** CI now has the full tag history and refuses a stale release tag
when shipped application files changed after that tag. Backend, UI, manifest and
changelog version consistency remains mandatory.
- **Regression coverage:** focused tests protect multi-person enrollment selection,
unavailable-clip classification and the authenticated high-resolution camera path.

## 5.2.1 — 2026-08-12

- **Bounded review inbox:** the verification screen keeps at most 200 representative
Expand Down
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "5.2.1"
VERSION = "5.3.0"
8 changes: 6 additions & 2 deletions app/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -950,15 +950,18 @@ def complete_job(self, event_id: str, kind: str):
(time.time(), event_id, kind),
)

def retry_job(self, event_id: str, kind: str, error: str, delay: float = 5.0):
def retry_job(
self, event_id: str, kind: str, error: str, delay: float = 5.0,
max_attempts: int = 5,
) -> str:
now = time.time()
with self._lock, self._connection() as con:
row = con.execute(
"SELECT attempts FROM jobs WHERE event_id=? AND kind=?",
(event_id, kind),
).fetchone()
attempts = int(row[0]) if row else 1
status = "failed" if attempts >= 5 else "pending"
status = "failed" if attempts >= max(1, int(max_attempts)) else "pending"
con.execute(
"""
UPDATE jobs SET status=?, available_ts=?, last_error=?, updated_ts=?
Expand All @@ -969,6 +972,7 @@ def retry_job(self, event_id: str, kind: str, error: str, delay: float = 5.0):
event_id, kind,
),
)
return status

def pending_jobs(self, limit: int = 100):
now = time.time()
Expand Down
7 changes: 5 additions & 2 deletions app/clip_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import numpy as np

from .quality import FaceQuality, measure_face_quality
from .media_errors import ClipNotReady


@dataclass
Expand Down Expand Up @@ -46,12 +47,14 @@ def analyze(self, event_id: str, reference_embedding=None, *, min_face_px=None,
)
if self.media_store is not None:
path = self.media_store.clip_path(event_id)
return self._analyze_file(str(path), reference_embedding, effective_min_face_px, roi) if path else []
if path is None:
raise ClipNotReady(f"Frigate clip {event_id} is not ready")
return self._analyze_file(str(path), reference_embedding, effective_min_face_px, roi)
fd, path = tempfile.mkstemp(suffix=".mp4", prefix="faceid-analyze-")
os.close(fd)
try:
if not self.frigate.download_clip(event_id, path):
return []
raise ClipNotReady(f"Frigate clip {event_id} is not ready")
return self._analyze_file(path, reference_embedding, effective_min_face_px, roi)
finally:
try:
Expand Down
66 changes: 66 additions & 0 deletions app/enrollment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Safe face selection for photo enrollment.

An uploaded family photo must never silently enroll the largest bystander. This
module keeps that selection policy small and independently testable.
"""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class EnrollmentSelection:
face: object | None
index: int | None
reason: str
candidates: list[dict]


def choose_enrollment_face(
faces, reference_embeddings=None, *, requested_index: int | None = None,
min_face_px: int = 60, auto_threshold: float = 0.45,
auto_margin: float = 0.08,
) -> EnrollmentSelection:
"""Select one safe face or ask the UI for an explicit choice."""
eligible = []
for index, face in enumerate(faces or []):
width = int(face.bbox[2] - face.bbox[0])
height = int(face.bbox[3] - face.bbox[1])
face_px = min(width, height)
if face_px >= int(min_face_px):
eligible.append((index, face, face_px))

if requested_index is not None:
selected = next((row for row in eligible if row[0] == requested_index), None)
if selected is None:
return EnrollmentSelection(None, None, "invalid_selection", [])
return EnrollmentSelection(selected[1], selected[0], "user_selected", [])

if not eligible:
return EnrollmentSelection(None, None, "no_usable_face", [])
if len(eligible) == 1:
return EnrollmentSelection(eligible[0][1], eligible[0][0], "single_face", [])

references = np.asarray(reference_embeddings) if reference_embeddings is not None else None
scores = []
if references is not None and references.ndim == 2 and len(references):
for _, face, _ in eligible:
scores.append(float(np.max(references @ face.normed_embedding)))
order = np.argsort(scores)[::-1]
best = int(order[0])
runner_up = float(scores[int(order[1])]) if len(order) > 1 else 0.0
if scores[best] >= auto_threshold and scores[best] - runner_up >= auto_margin:
index, face, _ = eligible[best]
return EnrollmentSelection(face, index, "clear_gallery_match", [])

candidates = []
for position, (index, face, face_px) in enumerate(eligible):
candidates.append({
"index": index,
"bbox": [float(value) for value in face.bbox[:4]],
"face_px": face_px,
"match_score": round(scores[position], 3) if scores else None,
})
return EnrollmentSelection(None, None, "needs_selection", candidates)
4 changes: 3 additions & 1 deletion app/frame_distributor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import cv2
import numpy as np

from .media_errors import ClipNotReady

log = logging.getLogger("faceid.frames")


Expand Down Expand Up @@ -41,7 +43,7 @@ def frames(self, event_id: str, *, limit: int | None = None) -> list[tuple[int,
clip = self.media_store.clip_path(event_id)
if clip is None:
self._stats["last_error"] = "clip unavailable"
return []
raise ClipNotReady(f"Frigate clip {event_id} is not ready")
target.mkdir(parents=True, exist_ok=True)
cached = self._decode(clip, target, limit)
else:
Expand Down
2 changes: 2 additions & 0 deletions app/media_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ClipNotReady(RuntimeError):
"""Frigate has not made an event clip readable yet."""
48 changes: 43 additions & 5 deletions app/mqtt_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .quality import measure_face_quality
from .clip_analyzer import ClipAnalyzer
from .presence import RecognitionSessionTracker
from .media_errors import ClipNotReady

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

Expand Down Expand Up @@ -72,6 +73,8 @@ def __init__(
self.ignore_learning = bool(f.get("ignore_learning", True))
self.hires_enroll = bool(f.get("hires_enroll", True))
self.clip_analysis = bool(f.get("clip_analysis", True))
self.clip_retry_attempts = max(1, int(f.get("clip_retry_attempts", 3)))
self.clip_retry_seconds = max(1.0, float(f.get("clip_retry_seconds", 10)))
self.min_face_quality = float(f.get("min_face_quality", 0.35))
# Ereignisse, die Frigate nicht per MQTT meldet (z. B. per API angelegte
# Kamera-Meldungen als Zuverlaessigkeits-Bruecke), per Abfrage nachziehen.
Expand Down Expand Up @@ -335,6 +338,35 @@ def _worker(self):
self._process(eid)
if self.audit:
self.audit.complete_job(eid, kind)
except ClipNotReady as e:
state = self.events.get(eid)
retries = int((state or {}).get("clip_not_ready_retries", 0)) + 1
if state is not None:
state["clip_not_ready_retries"] = retries
state["clip_queued"] = False
status = "pending"
if self.audit:
self.audit.observation(
eid, int((state or {}).get("attempts", 0)),
"clip_not_ready", source="clip",
)
status = self.audit.retry_job(
eid, kind, str(e), delay=self.clip_retry_seconds,
max_attempts=self.clip_retry_attempts,
)
if status == "failed" or retries >= self.clip_retry_attempts:
if state is not None:
state["clip_analyzed"] = True
log.warning(
"event %s: clip remained unavailable after %d attempts; continuing without clip evidence",
eid, retries,
)
else:
log.info(
"event %s: clip not ready; retry %d/%d in %.0fs",
eid, retries + 1, self.clip_retry_attempts,
self.clip_retry_seconds,
)
except Exception as e:
if self.audit:
self.audit.retry_job(eid, kind, str(e))
Expand Down Expand Up @@ -382,10 +414,16 @@ def _process(self, eid: str):
if self.camera_profiles is not None else None
)
img = None
if camera_profile and camera_profile.get("mode") == "intercom" and camera_profile.get("high_resolution"):
img = self.frigate.recording_frame(
st["camera"], float(st.get("start_time") or time.time())
)
source = "snapshot"
if camera_profile and camera_profile.get("high_resolution"):
# Stay on Frigate's configured API (normally authenticated 8971). No
# browser-visible go2rtc/1984 connection or extra credential path.
timestamps = [time.time(), float(st.get("start_time") or time.time())]
for timestamp in timestamps:
img = self.frigate.recording_frame(st["camera"], timestamp)
if img is not None:
source = "secure_live_hires"
break
if img is None:
img = self.frigate.snapshot(eid, crop=True)
if img is None:
Expand Down Expand Up @@ -449,7 +487,7 @@ def _process(self, eid: str):
eid, st["camera"], st["attempts"], w, h)
return
quality, face = max(usable, key=lambda item: item[0].score)
self._process_face(eid, st, img, face, quality=quality, source="snapshot")
self._process_face(eid, st, img, face, quality=quality, source=source)

def _process_face(self, eid: str, st: dict, img, face, quality=None, source="snapshot"):
if st["done"]:
Expand Down
70 changes: 63 additions & 7 deletions app/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .calibration import UNKNOWN_LABEL, build_calibration_report
from .gallery_coach import gallery_coach_report
from .quality import measure_face_quality
from .enrollment import choose_enrollment_face
from pathlib import Path as _P

log = logging.getLogger("faceid.web")
Expand Down Expand Up @@ -226,26 +227,77 @@ def ignore_person(slug: str):
gallery.refresh_guesses()
return {"ignored_faces": n}

def enrollment_preview(image, candidates: list[dict]) -> dict:
"""Return a bounded preview and normalized boxes for an explicit UI choice."""
height, width = image.shape[:2]
preview = image
if max(height, width) > 960:
scale = 960 / max(height, width)
preview = cv2.resize(image, None, fx=scale, fy=scale)
ok, encoded = cv2.imencode(".jpg", preview, [cv2.IMWRITE_JPEG_QUALITY, 82])
boxes = []
for item in candidates:
left, top, right, bottom = item["bbox"]
boxes.append({
**item,
"bbox": [left / width, top / height, right / width, bottom / height],
})
return {
"preview": (
"data:image/jpeg;base64," + base64.b64encode(encoded).decode("ascii")
if ok else None
),
"candidates": boxes,
}

@app.post("/api/persons/{slug}/photos")
async def upload_photos(slug: str, files: list[UploadFile]):
async def upload_photos(
slug: str, files: list[UploadFile], face_index: int | None = None,
):
"""Fotos (z. B. aus der Foto-Library) hochladen: Gesicht extrahieren + einlernen."""
if slug not in gallery.persons():
raise HTTPException(404, "Unknown person")
added, skipped, details = 0, [], []
for uf in files:
for upload_index, uf in enumerate(files):
raw = await uf.read()
img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if img is None:
skipped.append(f"{uf.filename}: not an image")
details.append({"file": uf.filename, "status": "rejected", "message": "הקובץ אינו תמונה תקינה"})
details.append({"file": uf.filename, "upload_index": upload_index, "status": "rejected", "message": "הקובץ אינו תמונה תקינה"})
continue
if max(img.shape[:2]) > 2000: # Foto-Library-Bilder einkürzen, Detection reicht so
s = 2000 / max(img.shape[:2])
img = cv2.resize(img, None, fx=s, fy=s)
face, img = find_face_padded(engine, img, min_px=60)
faces = list(engine.faces(img))
if not faces:
padded_face, padded_image = find_face_padded(engine, img, min_px=60)
if padded_face is not None:
img, faces = padded_image, [padded_face]
selection = choose_enrollment_face(
faces, gallery.embeddings(slug), requested_index=face_index,
min_face_px=60,
)
if selection.reason == "invalid_selection":
skipped.append(f"{uf.filename}: selected face is no longer available")
details.append({
"file": uf.filename, "upload_index": upload_index,
"status": "rejected",
"message": "הפנים שנבחרו אינן זמינות עוד; נסו לבחור שוב",
})
continue
if selection.reason == "needs_selection":
skipped.append(f"{uf.filename}: choose one of the detected faces")
details.append({
"file": uf.filename, "upload_index": upload_index,
"status": "needs_selection",
"message": "נמצאו כמה אנשים — בחרו את הפנים ששייכות לאדם הזה",
**enrollment_preview(img, selection.candidates),
})
continue
face = selection.face
if face is None:
skipped.append(f"{uf.filename}: no face found")
details.append({"file": uf.filename, "status": "rejected", "message": "לא נמצאו פנים ברורות"})
details.append({"file": uf.filename, "upload_index": upload_index, "status": "rejected", "message": "לא נמצאו פנים ברורות בגודל 60 פיקסלים לפחות"})
continue
quality = measure_face_quality(
img, face, min_face_px=60,
Expand All @@ -261,12 +313,16 @@ async def upload_photos(slug: str, files: list[UploadFile]):
else:
message = "זווית הפנים או איכות התמונה אינן מתאימות"
skipped.append(f"{uf.filename}: low quality")
details.append({"file": uf.filename, "status": "rejected", "message": message, "quality": quality.to_dict()})
details.append({"file": uf.filename, "upload_index": upload_index, "status": "rejected", "message": message, "quality": quality.to_dict()})
continue
gallery.add_face(slug, crop_face(img, face.bbox), face.normed_embedding,
source={"camera": "upload"})
added += 1
details.append({"file": uf.filename, "status": "added", "message": "התמונה נוספה", "quality": quality.to_dict()})
details.append({
"file": uf.filename, "upload_index": upload_index,
"status": "added", "message": "התמונה נוספה",
"selection": selection.reason, "quality": quality.to_dict(),
})
count = gallery.persons().get(slug, {}).get("count", 0)
return {
"added": added, "skipped": skipped, "details": details, "count": count,
Expand Down
Loading
Loading