"""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