Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
96 lines
4.3 KiB
Python
96 lines
4.3 KiB
Python
"""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
|