Display mode: crop to fill, crop to faces, stretch to fill, shrink to fit
Build and push server image / build-and-push (push) Successful in 40s

Replaces the smart_crop_faces boolean with a 4-way display_mode select
on each frame's Configuration tab (image_pipeline.DISPLAY_MODES):

- Crop to fill / Crop to faces: the previous False/True behavior,
  unchanged (center-crop trimming excess, optionally shifted to keep
  faces on screen).
- Stretch to fill (new): fills the panel exactly, aspect ratio not
  preserved -- a plain resize, no crop.
- Shrink to fit (new): the whole photo visible, letterboxed with white
  where it doesn't fill the panel.

Named-face overlay label positioning (face_labels.py, the manage menu's
"who's in this photo") now goes through a shared _placement_transform()
in image_pipeline.py instead of duplicating crop-box math, so label
placement stays correct (and in-bounds) under all four modes, not just
the two crop ones -- letterbox/stretch never crop a face out, so labels
just use straight scale+offset math there.

Schema migration v5 adds display_mode, backfills it from the old
boolean (True/False -> crop_faces/crop_fill), and drops the boolean.

Verified against the live-shaped test database: the migration
(existing frames correctly preserved as crop_faces), the config page's
new 4-option select, actual renders under letterbox (confirmed real
white letterbox padding in the packed panel-code bytes) and
stretch_fill, invalid-input fallback, and the standing legacy-device
curl suite.
This commit is contained in:
2026-07-22 01:32:52 -04:00
parent e802882fc1
commit 49bc9f9ec9
10 changed files with 125 additions and 46 deletions
+19 -25
View File
@@ -15,12 +15,7 @@ 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,
)
from .image_pipeline import _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
@@ -31,20 +26,21 @@ MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
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 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 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 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.
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:
@@ -53,24 +49,22 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_face
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)
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]:
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
img_scale_x = fitted.width / face_w
img_scale_y = fitted.height / face_h
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
bottom_y = face["boundingBoxY2"] * scale_y
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
bottom_y = face["boundingBoxY2"] * img_scale_y
frame_x = (center_x - left) * (logical_w / crop_w)
frame_y = (bottom_y - top) * (logical_h / crop_h)
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