Files
espresso_frame/server/app/face_labels.py
T
tfaour f24c3b9c8e
Build and push server image / build-and-push (push) Successful in 39s
Gate firmware auto-check behind control+CSRF; skip faces with a null bounding box
/api/frames/{id}/firmware/check could silently stage new firmware as a
side effect (the auto-apply path, when firmware_auto_update is on and
a newer release exists) but was gated by require_frame_view instead of
require_frame_control like its sibling firmware routes, and being a
GET, was exempt from the app's CSRF check (which only applies to
non-GET/HEAD/OPTIONS). A linked viewer without control -- or a
cross-site page riding a control-holding victim's session via a plain
GET -- could trigger an unreviewed firmware install. Now POST +
require_frame_control, matching /firmware/apply-latest; the frontend's
two callers (passive poll on page load, "Check now" button) both
already handle a 409 from a non-controller gracefully via the existing
apiError()/control-banner pattern, so this doesn't change UX for a
frame's actual controller.

Separately: Immich has been observed to return a face detection entry
with a null bounding-box field (a still-pending or otherwise
incomplete detection). Both places that do arithmetic on those fields
-- image_pipeline._face_aware_crop_box (crop_faces display mode) and
face_labels.compute_face_labels (manage-menu name labels) -- crashed
with an unhandled TypeError on such an entry, taking down that frame's
whole photo instead of the intended graceful fallback. Both now skip
any face missing a bounding-box field via a shared _has_bounding_box()
check; a face list with zero valid entries already degrades cleanly to
the plain center crop (the existing inf/-inf sentinel math already
handled "no faces" correctly, it just couldn't tell "none passed
Immich" apart from "one broken entry" before).
2026-07-22 16:21:37 -04:00

82 lines
3.5 KiB
Python

"""Maps named faces (from Immich's own face recognition/People feature)
onto their position in the final rendered 800x480 frame, for the
manage-button overlay's escalated "who's in this photo" menu level.
No face detection or recognition happens here or anywhere else in this
project -- Immich's GET /api/faces?id={assetId} already returns each
detected face's bounding box plus a nullable `person` object (with a
`name`, if the user has identified them in Immich); this module only
does the coordinate math to place a label next to a *named* one.
"""
from __future__ import annotations
import io
from PIL import Image, ImageOps
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size, logical_to_native
# Small caps, not arbitrary: each label is its own malloc'd overlay
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
# four existing fixed corner regions already use a meaningful chunk of
# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total
# overlay memory budget well clear of the WiFi/HTTP stack's own needs.
MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape") -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
800x480 panel pixel space at each named face's bottom-center point.
Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for
the currently-displayed frame, and display_mode/orientation must
match the settings that were active then -- otherwise the placement
and rotation computed here won't match what's actually on screen.
The placement math runs in logical (pre-rotation) space, matching
render_frame()'s composition step (see image_pipeline._placement_transform,
shared so the two can't drift apart); each anchor is then rotated
into native panel coordinates via logical_to_native(), since the
firmware draws labels in native space.
"""
named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named:
return []
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
scale_x, scale_y, offset_x, offset_y = _placement_transform(
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces
)
labels = []
for face in named[:MAX_LABELED_FACES]:
if not _has_bounding_box(face):
continue
face_w = face.get("imageWidth") or fitted.width
face_h = face.get("imageHeight") or fitted.height
img_scale_x = fitted.width / face_w
img_scale_y = fitted.height / face_h
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
bottom_y = face["boundingBoxY2"] * img_scale_y
frame_x = center_x * scale_x + offset_x
frame_y = bottom_y * scale_y + offset_y
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
continue # this face got cropped out of the final frame entirely
name = face["person"]["name"]
if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..."
native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
labels.append({"name": name, "x": native_x, "y": native_y})
return labels