"""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 ( _face_aware_crop_box, _plain_center_crop_box, 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], smart_crop_faces: bool, 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 smart_crop_faces/orientation must match the settings that were active then -- otherwise the crop box and rotation computed here won't match what's actually on screen. The crop math runs in logical (pre-rotation) space, matching render_frame()'s composition step; 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")) if smart_crop_faces and faces: left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces) crop_w, crop_h = right - left, bottom - top else: left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h) labels = [] for face in named[:MAX_LABELED_FACES]: face_w = face.get("imageWidth") or fitted.width face_h = face.get("imageHeight") or fitted.height scale_x = fitted.width / face_w scale_y = fitted.height / face_h center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x bottom_y = face["boundingBoxY2"] * scale_y frame_x = (center_x - left) * (logical_w / crop_w) frame_y = (bottom_y - top) * (logical_h / crop_h) 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