diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2365aa5..bcaeb41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5814db4..7cf5f2d 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. +## 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 diff --git a/app/__init__.py b/app/__init__.py index 91ab71e..01997c3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -VERSION = "5.2.1" +VERSION = "5.3.0" diff --git a/app/audit.py b/app/audit.py index 9360e9a..125bc4d 100644 --- a/app/audit.py +++ b/app/audit.py @@ -950,7 +950,10 @@ 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( @@ -958,7 +961,7 @@ def retry_job(self, event_id: str, kind: str, error: str, delay: float = 5.0): (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=? @@ -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() diff --git a/app/clip_analyzer.py b/app/clip_analyzer.py index 2e02b5e..4eb62ed 100644 --- a/app/clip_analyzer.py +++ b/app/clip_analyzer.py @@ -7,6 +7,7 @@ import numpy as np from .quality import FaceQuality, measure_face_quality +from .media_errors import ClipNotReady @dataclass @@ -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: diff --git a/app/enrollment.py b/app/enrollment.py new file mode 100644 index 0000000..5c68fbb --- /dev/null +++ b/app/enrollment.py @@ -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) diff --git a/app/frame_distributor.py b/app/frame_distributor.py index b3a5d3d..5cdaf53 100644 --- a/app/frame_distributor.py +++ b/app/frame_distributor.py @@ -12,6 +12,8 @@ import cv2 import numpy as np +from .media_errors import ClipNotReady + log = logging.getLogger("faceid.frames") @@ -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: diff --git a/app/media_errors.py b/app/media_errors.py new file mode 100644 index 0000000..7f7d9e9 --- /dev/null +++ b/app/media_errors.py @@ -0,0 +1,2 @@ +class ClipNotReady(RuntimeError): + """Frigate has not made an event clip readable yet.""" diff --git a/app/mqtt_listener.py b/app/mqtt_listener.py index c4e6829..73102d6 100644 --- a/app/mqtt_listener.py +++ b/app/mqtt_listener.py @@ -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") @@ -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. @@ -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)) @@ -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: @@ -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"]: diff --git a/app/webui.py b/app/webui.py index 9986edf..7e24eb1 100644 --- a/app/webui.py +++ b/app/webui.py @@ -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") @@ -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, @@ -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, diff --git a/docs/example-config.yaml b/docs/example-config.yaml index d5bf7eb..fb05622 100644 --- a/docs/example-config.yaml +++ b/docs/example-config.yaml @@ -48,6 +48,8 @@ faceid: clip_analysis: true # sample the finished clip for diverse, stronger evidence clip_max_frames: 24 clip_max_samples: 8 + clip_retry_attempts: 3 # a finished event clip may need a moment to appear + clip_retry_seconds: 10 # unavailable is retried; a readable clip with no face is final # Clips are downloaded once and shared by recognition + browser playback. # These hard disk-cache limits protect the host from long recordings. media_max_clip_mb: 150 diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md index 5814db4..7cf5f2d 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. +## 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 diff --git a/faceid-addon/DOCS.md b/faceid-addon/DOCS.md index 2639113..e7c7050 100644 --- a/faceid-addon/DOCS.md +++ b/faceid-addon/DOCS.md @@ -32,7 +32,14 @@ Full documentation: https://github.com/r11a/faceid 5. Open the **FaceID** panel in the sidebar. Recommended first step: run the backfill (see main README) or just wait — every detected unknown face shows up for review. -## First 5.1 check +## First 5.3 check + +Upload one clear single-person photo and one group photo in **People → Known people**. +The single photo should be accepted normally. When the group cannot be matched with a +clear lead, FaceID must show the photo with a numbered frame around every eligible face +and save nothing until you choose one. In **Cameras → Intercom**, enable high-resolution +capture only for the entrance camera; it uses the configured Frigate API and does not +require exposing go2rtc port 1984. Open **Guests** to create a time/camera/count-limited temporary pass, and open **Investigation → Site map** to position cameras and connect plausible transitions. @@ -70,6 +77,7 @@ second factor remain mandatory before any door-control automation acts. | `min_confirmations` | distinct agreeing frames required before publishing | | `min_face_quality` | reject weak size, blur, lighting and pose evidence | | `clip_analysis` / `clip_max_*` | sample diverse faces from the finished recording | +| `clip_retry_attempts` / `clip_retry_seconds` | retry a Frigate clip that has not finished preparing; a readable clip with no face is not retried | | `cluster_eps` | how aggressively unknown faces are grouped in the review UI | | `presence_window` | camera sensor lists everyone seen within this many seconds | | `recognition_session_seconds` | continuous same-person/same-camera sightings stay one visit; default 300 seconds | diff --git a/faceid-addon/app/__init__.py b/faceid-addon/app/__init__.py index 91ab71e..01997c3 100644 --- a/faceid-addon/app/__init__.py +++ b/faceid-addon/app/__init__.py @@ -1 +1 @@ -VERSION = "5.2.1" +VERSION = "5.3.0" diff --git a/faceid-addon/app/audit.py b/faceid-addon/app/audit.py index 9360e9a..125bc4d 100644 --- a/faceid-addon/app/audit.py +++ b/faceid-addon/app/audit.py @@ -950,7 +950,10 @@ 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( @@ -958,7 +961,7 @@ def retry_job(self, event_id: str, kind: str, error: str, delay: float = 5.0): (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=? @@ -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() diff --git a/faceid-addon/app/clip_analyzer.py b/faceid-addon/app/clip_analyzer.py index 2e02b5e..4eb62ed 100644 --- a/faceid-addon/app/clip_analyzer.py +++ b/faceid-addon/app/clip_analyzer.py @@ -7,6 +7,7 @@ import numpy as np from .quality import FaceQuality, measure_face_quality +from .media_errors import ClipNotReady @dataclass @@ -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: diff --git a/faceid-addon/app/enrollment.py b/faceid-addon/app/enrollment.py new file mode 100644 index 0000000..5c68fbb --- /dev/null +++ b/faceid-addon/app/enrollment.py @@ -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) diff --git a/faceid-addon/app/frame_distributor.py b/faceid-addon/app/frame_distributor.py index b3a5d3d..5cdaf53 100644 --- a/faceid-addon/app/frame_distributor.py +++ b/faceid-addon/app/frame_distributor.py @@ -12,6 +12,8 @@ import cv2 import numpy as np +from .media_errors import ClipNotReady + log = logging.getLogger("faceid.frames") @@ -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: diff --git a/faceid-addon/app/media_errors.py b/faceid-addon/app/media_errors.py new file mode 100644 index 0000000..7f7d9e9 --- /dev/null +++ b/faceid-addon/app/media_errors.py @@ -0,0 +1,2 @@ +class ClipNotReady(RuntimeError): + """Frigate has not made an event clip readable yet.""" diff --git a/faceid-addon/app/mqtt_listener.py b/faceid-addon/app/mqtt_listener.py index c4e6829..73102d6 100644 --- a/faceid-addon/app/mqtt_listener.py +++ b/faceid-addon/app/mqtt_listener.py @@ -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") @@ -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. @@ -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)) @@ -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: @@ -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"]: diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py index 9986edf..7e24eb1 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -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") @@ -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, @@ -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, diff --git a/faceid-addon/config.yaml b/faceid-addon/config.yaml index 3314376..a0ca96c 100644 --- a/faceid-addon/config.yaml +++ b/faceid-addon/config.yaml @@ -1,5 +1,5 @@ name: FaceID -version: "5.2.1" +version: "5.3.0" slug: faceid description: Face recognition for Frigate — trainable gallery, clustered unknown review, HA sensors url: https://github.com/r11a/faceid @@ -12,7 +12,7 @@ boot: auto init: false ingress: true ingress_port: 8600 -ingress_entry: ui-5.2.1 +ingress_entry: ui-5.3.0 panel_icon: mdi:face-recognition panel_title: FaceID ports: @@ -40,6 +40,8 @@ options: clip_analysis: true clip_max_frames: 24 clip_max_samples: 8 + clip_retry_attempts: 3 + clip_retry_seconds: 10 video_decode: auto body_enabled: false body_threshold: 0.72 @@ -113,6 +115,8 @@ schema: clip_analysis: bool clip_max_frames: int(4,120) clip_max_samples: int(2,30) + clip_retry_attempts: int(1,10) + clip_retry_seconds: int(1,120) video_decode: list(auto|software|vaapi|cuda) body_enabled: bool body_threshold: float(0.5,0.99) diff --git a/faceid-addon/run.sh b/faceid-addon/run.sh index 187b075..55c68cc 100755 --- a/faceid-addon/run.sh +++ b/faceid-addon/run.sh @@ -69,6 +69,8 @@ faceid: clip_analysis: $(cfg '.clip_analysis') clip_max_frames: $(cfg '.clip_max_frames') clip_max_samples: $(cfg '.clip_max_samples') + clip_retry_attempts: $(cfg '.clip_retry_attempts // 3') + clip_retry_seconds: $(cfg '.clip_retry_seconds // 10') video_decode: $(cfg '.video_decode // "auto"') body_enabled: $(cfg '.body_enabled // false') body_threshold: $(cfg '.body_threshold // 0.72') diff --git a/faceid-addon/static/index.html b/faceid-addon/static/index.html index 4bf679b..520dec1 100644 --- a/faceid-addon/static/index.html +++ b/faceid-addon/static/index.html @@ -202,7 +202,7 @@ .sync-card img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;background:var(--panel2)} .sync-card input{position:absolute;top:14px;right:14px;z-index:2;width:20px;height:20px} .sync-card.done{opacity:.55}.progress-note{padding:12px;border-right:3px solid var(--acc);background:var(--panel);color:var(--dim)} -.product-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(285px,1fr));gap:14px}.product-card{border:1px solid var(--line);border-radius:20px;background:linear-gradient(150deg,#24272a,#181a1c);padding:17px;box-shadow:0 12px 34px #0004}.product-card.ready{border-color:#50785a}.product-card.review{border-color:#78613e}.product-card.new{border-style:dashed}.product-card .card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.state-chip{display:inline-flex;border-radius:99px;padding:4px 9px;font-size:11px;font-weight:800;background:var(--panel2);color:var(--dim)}.state-chip.ready{color:#aee6b8}.state-chip.review{color:#f2cb8c}.wizard{border:1px solid color-mix(in oklab,var(--acc) 55%,var(--line));border-radius:22px;background:linear-gradient(135deg,color-mix(in oklab,var(--panel) 88%,var(--acc) 12%),var(--panel));padding:22px;margin-bottom:22px}.wizard h2{font:800 24px var(--sans);color:var(--fg);text-transform:none;letter-spacing:0}.wizard-flow{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.wizard-flow div{display:flex;gap:9px;align-items:center;color:var(--dim)}.wizard-flow b{width:28px;height:28px;border-radius:9px;background:var(--acc);color:#152018;display:grid;place-items:center}.intercom-preview{position:relative;border-radius:18px;overflow:hidden;background:#08090a;min-height:280px;display:grid;place-items:center}.intercom-preview img{width:100%;max-height:62vh;object-fit:contain}.intercom-guide{position:absolute;border:3px solid #74cbe8;border-radius:42% 42% 48% 48%;box-shadow:0 0 0 9999px #0004;pointer-events:none}.result-banner{padding:13px 16px;border-radius:14px;background:var(--panel2);border-right:4px solid var(--accent2);margin:12px 0}.result-banner.excellent{border-color:var(--good)}.result-banner.improve,.result-banner.acceptable{border-color:var(--warn)}.result-banner.no_face{border-color:var(--bad)}.danger{color:#ffd1d5!important;border-color:#824a50!important}.user-dialog-grid{display:grid;grid-template-columns:180px 1fr;gap:18px}.user-dialog-grid .avatar{width:180px;height:180px}.upload-report{display:grid;gap:7px;margin-top:12px}.upload-report div{padding:9px;border-radius:10px;background:var(--panel2)} +.product-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(285px,1fr));gap:14px}.product-card{border:1px solid var(--line);border-radius:20px;background:linear-gradient(150deg,#24272a,#181a1c);padding:17px;box-shadow:0 12px 34px #0004}.product-card.ready{border-color:#50785a}.product-card.review{border-color:#78613e}.product-card.new{border-style:dashed}.product-card .card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.state-chip{display:inline-flex;border-radius:99px;padding:4px 9px;font-size:11px;font-weight:800;background:var(--panel2);color:var(--dim)}.state-chip.ready{color:#aee6b8}.state-chip.review{color:#f2cb8c}.wizard{border:1px solid color-mix(in oklab,var(--acc) 55%,var(--line));border-radius:22px;background:linear-gradient(135deg,color-mix(in oklab,var(--panel) 88%,var(--acc) 12%),var(--panel));padding:22px;margin-bottom:22px}.wizard h2{font:800 24px var(--sans);color:var(--fg);text-transform:none;letter-spacing:0}.wizard-flow{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.wizard-flow div{display:flex;gap:9px;align-items:center;color:var(--dim)}.wizard-flow b{width:28px;height:28px;border-radius:9px;background:var(--acc);color:#152018;display:grid;place-items:center}.intercom-preview{position:relative;border-radius:18px;overflow:hidden;background:#08090a;min-height:280px;display:grid;place-items:center}.intercom-preview img{width:100%;max-height:62vh;object-fit:contain}.intercom-guide{position:absolute;border:3px solid #74cbe8;border-radius:42% 42% 48% 48%;box-shadow:0 0 0 9999px #0004;pointer-events:none}.result-banner{padding:13px 16px;border-radius:14px;background:var(--panel2);border-right:4px solid var(--accent2);margin:12px 0}.result-banner.excellent{border-color:var(--good)}.result-banner.improve,.result-banner.acceptable{border-color:var(--warn)}.result-banner.no_face{border-color:var(--bad)}.danger{color:#ffd1d5!important;border-color:#824a50!important}.user-dialog-grid{display:grid;grid-template-columns:180px 1fr;gap:18px}.user-dialog-grid .avatar{width:180px;height:180px}.upload-report{display:grid;gap:7px;margin-top:12px}.upload-report div{padding:9px;border-radius:10px;background:var(--panel2)}.enrollment-choice{position:relative;display:inline-block;max-width:100%;background:#090a0b;border-radius:16px;overflow:hidden}.enrollment-choice img{display:block;max-width:100%;max-height:64vh}.face-choice{position:absolute;display:grid;place-items:center;border:3px solid #73d69a;background:#10261988;color:#fff;border-radius:10px;font-weight:900;min-width:34px;min-height:34px;box-shadow:0 0 0 2px #0008}.face-choice:hover,.face-choice:focus-visible{background:#73d69acc;color:#102016;transform:scale(1.03)} .guest-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}.guest-card{display:grid;grid-template-columns:82px 1fr;gap:13px;padding:15px;border:1px solid var(--line);border-radius:18px;background:var(--panel)}.guest-card img{width:82px;height:82px;object-fit:cover;border-radius:14px}.guest-card .card-actions{grid-column:1/-1;display:flex;gap:7px;flex-wrap:wrap}.site-stage{position:relative;min-height:520px;border:1px solid var(--line);border-radius:22px;overflow:hidden;background-color:#151719;background-image:linear-gradient(#ffffff08 1px,transparent 1px),linear-gradient(90deg,#ffffff08 1px,transparent 1px);background-size:32px 32px}.site-stage svg{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.site-node{position:absolute;transform:translate(-50%,-50%);min-width:118px;padding:10px 12px;border:1px solid #5d6862;border-radius:14px;background:#222628ee;color:var(--fg);box-shadow:0 10px 28px #0007;cursor:grab;touch-action:none}.site-node.restricted{border-color:var(--bad)}.site-node.intercom,.site-node.entry,.site-node.entry_exit{border-color:var(--good)}.site-node small{display:block;color:var(--dim);margin-top:3px}.traffic-bar{height:10px;border-radius:99px;background:linear-gradient(90deg,#92d3a0,#e5b96f,#d97878);min-width:3px}.playlist-progress{display:flex;gap:6px;flex-wrap:wrap;padding:10px 0}.playlist-progress button{min-height:44px;padding:8px 13px;border:1px solid var(--line);border-radius:99px;background:var(--panel2);color:var(--dim);cursor:pointer;font:750 12px var(--sans);touch-action:manipulation}.playlist-progress button:hover,.playlist-progress button:focus-visible{border-color:var(--acc);color:var(--fg);outline:none}.playlist-progress button.on{background:var(--acc);border-color:var(--acc);color:#101410;box-shadow:0 0 0 3px color-mix(in oklab,var(--acc) 22%,transparent)}.playlist-progress button.done{color:var(--good);border-color:#4d7055}.playlist-progress button.missing{color:var(--warn);text-decoration:line-through} .liveness-visual{position:relative;margin:14px 0;border-radius:16px;overflow:hidden;background:#090a0b}.liveness-visual img{display:block;width:100%;height:auto}.face-outline{display:none;position:absolute;border:3px solid var(--warn);border-radius:12px;box-shadow:0 0 0 2px #0009,0 0 22px color-mix(in oklab,var(--warn) 65%,transparent);pointer-events:none}.face-outline.live{border-color:var(--good);box-shadow:0 0 0 2px #0009,0 0 22px color-mix(in oklab,var(--good) 65%,transparent)}.face-outline.spoof{border-color:var(--bad);box-shadow:0 0 0 2px #0009,0 0 22px color-mix(in oklab,var(--bad) 65%,transparent)}.face-outline span{position:absolute;right:-3px;bottom:calc(100% + 5px);white-space:nowrap;padding:5px 9px;border-radius:8px;background:#101214e8;border:1px solid currentColor;color:#fff;font:800 12px var(--mono)}.live-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin:10px 0}.live-metric{background:var(--panel2);border:1px solid var(--line);border-radius:11px;padding:9px}.live-metric b,.live-metric span{display:block}.live-metric b{font-size:15px}.live-metric span{color:var(--dim);font-size:10px;margin-top:2px}.guidance-list{display:grid;gap:6px;margin-top:9px}.guidance-list div{padding:8px 10px;border-radius:9px;background:#ffffff08}.guidance-list div::before{content:'←';color:var(--acc);margin-left:7px;font-weight:900} @media(max-width:850px){.hero{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,1fr)}nav{top:64px}} @@ -283,7 +283,7 @@

FaceID מרכז