Build and push server image / build-and-push (push) Successful in 32s
Two rounds of follow-up work on the manage-button overlay:
1. Location formatting: US/Canada now show abbreviated state/province
("CA", "ON") instead of the full name, other countries show the full
country name, and each is its own line (was one line, now wraps to
two) so longer international place names have more room without
threatening to overlap the top-right QR box. The bottom-left share QR
also gets a "SCAN TO DOWNLOAD" caption.
2. Escalating menu: pressing the manage button again while its overlay
is already up adds a second level -- each Immich-identified person's
name labeled next to their face in the photo (using Immich's own
face recognition/People data, no detection/recognition added to this
project). A third press exits immediately instead of waiting out the
30s auto-revert timer. No new Immich API needed -- GET /api/faces
already embeds a nullable person.name per face; new
server/app/face_labels.py maps a named face's box into the final
800x480 frame's pixel space (reusing crop-box math extracted from
image_pipeline.py's face-aware cropping). Capped at 4 named faces,
sized to a real firmware RAM budget: each label is its own malloc'd
overlay region on the device, alongside the 4 fixed corner regions
already in use. New GET /frame/face-labels returns a flattened
fixed-slot JSON shape (not a real array) so firmware's existing
flat-scalar parser can read it without needing an actual array
parser. No persistent state needed for the escalation itself -- it's
all local control flow within one continuous awake session
(frame_client.c's run_management_menu()).
73 lines
3.1 KiB
Python
73 lines
3.1 KiB
Python
"""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 EPD_HEIGHT, EPD_WIDTH, _face_aware_crop_box, _plain_center_crop_box
|
|
|
|
# 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) -> list[dict]:
|
|
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in final
|
|
800x480 frame 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 must match the
|
|
setting that was active then -- otherwise the crop box computed here
|
|
won't match what's actually on screen.
|
|
"""
|
|
named = [face for face in faces if (face.get("person") or {}).get("name")]
|
|
if not named:
|
|
return []
|
|
|
|
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, EPD_WIDTH, EPD_HEIGHT, faces)
|
|
crop_w, crop_h = right - left, bottom - top
|
|
else:
|
|
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT)
|
|
|
|
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) * (EPD_WIDTH / crop_w)
|
|
frame_y = (bottom_y - top) * (EPD_HEIGHT / crop_h)
|
|
|
|
if not (0 <= frame_x <= EPD_WIDTH and 0 <= frame_y <= EPD_HEIGHT):
|
|
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] + "..."
|
|
|
|
labels.append({"name": name, "x": int(frame_x), "y": int(frame_y)})
|
|
|
|
return labels
|