calendar_render.py's _build_* functions now take a real target box and pick font sizes/margins from three discrete size tiers (nearest pixel-area fit) instead of always laying out at full panel size and resizing after the fact -- a calendar widget placed smaller than the full panel gets an actually-legible layout instead of shrunk text. Month view falls back to agenda below the smallest tier, where 7 columns can no longer stay readable. The old photo-inlay split (inlay_region/_content_region/_paste_inlay) is deleted along with it -- arbitrary widget placement already subsumes what a fixed half-panel split did, and every call site has passed photo_inlay=None since the Phase 2 cutover. Also adds HTTP-level test coverage for GET .../preview/calendar, which had none before this -- it's what caught a stale photo_inlay kwarg left over from the _build signature change that would have TypeError'd on every request.
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. a photo widget placed
|
|
in one corner of the panel rather than full-screen (see
|
|
routers/common.py's build_manage_content, which passes each photo
|
|
widget's own placement rect) -- without this a label would be placed
|
|
as if the photo filled the entire canvas, landing well off where the
|
|
widget actually is. None (the default) means the photo fills the
|
|
whole logical canvas.
|
|
|
|
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
|