"""Maps named faces (from Immich's own face recognition/People feature) onto their position in the final rendered 800x480 frame, for the manage-button overlay's escalated "who's in this photo" menu level. 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, logical_to_native # Small caps, not arbitrary: each label is its own malloc'd overlay # buffer on the device (see firmware/main/manage_qr_overlay.c), and the # four existing fixed corner regions already use a meaningful chunk of # the ESP32-C6's limited RAM. Capping at 4 short names keeps the total # overlay memory budget well clear of the WiFi/HTTP stack's own needs. MAX_LABELED_FACES = 4 NAME_MAX_LEN = 10 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 native 800x480 panel pixel space at each named face's bottom-center point. 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 and rotation computed here won't match what's actually on screen. The placement math runs in logical (pre-rotation) space, matching render_frame()'s composition step (see image_pipeline._placement_transform, shared so the two can't drift apart); each anchor is then rotated into native panel coordinates via logical_to_native(), since the firmware draws labels in native space. """ 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 name = face["person"]["name"] if len(name) > NAME_MAX_LEN: name = name[: NAME_MAX_LEN - 3] + "..." native_x, native_y = logical_to_native(frame_x, frame_y, orientation) labels.append({"name": name, "x": native_x, "y": native_y}) return labels