Build and push server image / build-and-push (push) Successful in 32s
Battery (firmware + server, disabled by default): new battery.c reads a 2x200k voltage divider via ADC oneshot with curve-fitting calibration (the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve, and restores the pin to button duty after each read -- the settled XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per wake. Skipped entirely when on mains (a 2x100k VBUS divider into a spare digital pin -- the 5V pin is dead on battery power, so presence = mains, where the charging voltage would read misleadingly full) or when the reading is implausible. The manage overlay gains a battery region (static outline glyph + "NN%", below the manage QR, all menu levels), and the device POSTs to the new /frame/battery endpoint after a successful fetch; the server stores percent + as-of timestamp, exposed via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO / FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board); compile-verified both disabled and enabled, hardware bring-up deferred until the ordered XIAO + batteries arrive. Orientation (server-side only): new config setting + web UI dropdown (landscape / portrait / landscape_flipped / portrait_flipped). Photos are composed/cropped at the logical hanging shape (portrait crops at 480x800, so face-aware crops match how the frame actually hangs), then rotated losslessly into the panel's native 800x480 byte layout after dithering -- the device never knows. Face-label anchors are transformed through the same rotation (logical_to_native()) so they stay attached to faces on rotated frames. Known documented limitation: the on-device manage overlay still renders in native orientation, so it appears sideways on a portrait-hung frame (QRs scan at any rotation; text reads sideways).
86 lines
3.5 KiB
Python
86 lines
3.5 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 (
|
|
_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
|