"""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 EPD_HEIGHT, EPD_WIDTH, _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, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> 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, panel_w, panel_h) 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