Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds, render agenda/week/month views. manage_overlay.py: composites the manage-button overlay server-side (QR, battery, location/date, share-QR, face labels), reused by every render mode. device.py/common.py wire both together: mode dispatch for /frame/image+advance+back, and the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings calendar URL field) and the icalendar/recurring-ical-events deps.
77 lines
3.3 KiB
Python
77 lines
3.3 KiB
Python
"""Maps named faces (from Immich's own face recognition/People feature)
|
|
onto their position in the final rendered frame, for the manage-button
|
|
overlay's named-face labels (see manage_overlay.py, which draws them).
|
|
|
|
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
|
|
|
|
# Not a memory constraint anymore (the overlay renders server-side now,
|
|
# not malloc'd per-label on the device) -- purely a legibility cap. A
|
|
# photo with a dozen named people would just be visual clutter regardless
|
|
# of what's rendering it.
|
|
MAX_LABELED_FACES = 6
|
|
|
|
|
|
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
|
|
logical (pre-rotation) frame space at each named face's bottom-center
|
|
point -- manage_overlay.compose() draws these directly onto the
|
|
logical-space image before it's rotated into native panel space, so
|
|
no rotation happens here (contrast with the old firmware-side
|
|
version, which drew post-rotation and needed logical_to_native).
|
|
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
|
|
computed here won't match what's actually on screen.
|
|
|
|
The placement math matches render_frame()'s own composition step
|
|
exactly (see image_pipeline._placement_transform, shared so the two
|
|
can't drift apart).
|
|
"""
|
|
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
|
|
|
|
labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)})
|
|
|
|
return labels
|