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.
95 lines
4.2 KiB
Python
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
|