Build and push server image / build-and-push (push) Successful in 39s
/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).
82 lines
3.5 KiB
Python
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
|