Files
espresso_frame/server/app/face_labels.py
T
tfaour aa194be09a Calendar mode polish batch + Today & Tomorrow view
Responds to post-launch feedback on calendar mode: configurable
week-start day for week/month views, crisper non-antialiased text
(threshold-masked instead of drawn straight, so Floyd-Steinberg
dithering doesn't speckle glyph edges), a color-coded/proportionally
filled battery icon on the manage overlay, word-wrapped placeholder
text so "Calendar isn't set up yet" no longer clips in portrait, photo
inlay support extended from agenda-only to every view, and a fix so
manage-overlay face labels reposition correctly when a photo inlay is
active (they previously assumed the photo filled the whole canvas).

Also adds a fourth calendar view, "Today & Tomorrow" -- a two-day
agenda that reuses the same per-day row-layout helper the single-day
agenda view already has.
2026-07-22 21:20:44 -04:00

95 lines
4.2 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", region: tuple[int, int, int, int] | None = None) -> 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.
`region` is (x0, y0, w, h): where in the logical canvas the photo
actually landed, if not the whole thing -- e.g. calendar mode's
agenda photo-inlay only occupies half the panel (see
calendar_render.inlay_region), and without this a label would be
placed as if the photo filled the entire canvas, landing well off
where the inlaid photo actually is. None (the default) means the
photo fills the whole logical canvas, matching every other caller
(photos mode always renders full-panel).
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 []
if region is None:
logical_w, logical_h = logical_render_size(orientation)
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
else:
region_x0, region_y0, target_w, target_h = region
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, target_w, target_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
# Relative to the region's own origin first (matches
# _placement_transform's target_w/target_h space), then shifted
# into full-canvas coordinates.
region_x = center_x * scale_x + offset_x
region_y = bottom_y * scale_y + offset_y
if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
continue # this face got cropped out of the region entirely
labels.append({"name": face["person"]["name"],
"x": int(region_x + region_x0), "y": int(region_y + region_y0)})
return labels