From 6e353c22718abd11234d662711faa305546562b3 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sat, 18 Jul 2026 15:32:29 -0400 Subject: [PATCH] Add server-configurable refresh interval + face-aware cropping Two features, both toggleable/settable from the web config UI: Refresh interval: new GET /frame/config returns {"refresh_interval_s": ...} as plain JSON. Reuses the endpoint the frame already needs to hit for a reachability check each wake cycle (previously /health) rather than adding a third round trip, and always returns 200 with current settings regardless of Immich-configured state so it stays valid as a pure reachability signal. Clamped to [60, 86400] seconds in POST /api/config. Face-aware cropping: GET /api/faces?id={assetId} on Immich already returns real per-photo face bounding boxes from its own People-feature ML -- confirmed against a live instance, boxes scaled to the asset's native resolution. No face detection built or bundled here at all, just an API call plus rectangle math. image_pipeline.render_frame() gains an optional `faces` param: when present, computes the largest crop window matching the panel's aspect ratio that fits in the source image, centered on the union of all face boxes' centroid (scaled into the downloaded preview's actual resolution) instead of the image's geometric center, clamped to stay within bounds. No faces (or the smart_crop_faces config toggle off) falls straight back to the existing ImageOps.fit() center-crop -- zero behavior change in that case. A faces-lookup failure logs and degrades to center-crop rather than failing the whole request. Verified: unit tests for the crop-box math (horizontal shift toward an off-center face, edge clamping), a full mock-Immich end-to-end pass (extended to serve /faces) confirming the toggle changes output and the response is still exactly 192,000 bytes, and a live comparison against a real 4-face photo on the user's Immich instance (crop top shifted from 528px to 246px toward the detected faces). --- server/app/config.py | 2 ++ server/app/image_pipeline.py | 55 +++++++++++++++++++++++++++++++-- server/app/immich_client.py | 13 ++++++++ server/app/main.py | 31 ++++++++++++++++++- server/app/templates/index.html | 14 +++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/server/app/config.py b/server/app/config.py index 80181a7..37bbe17 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -21,6 +21,8 @@ class FrameConfig(BaseModel): album_id: str = "" order: str = "sequential" # or "shuffle" cursor: int = 0 + refresh_interval_s: int = 3600 + smart_crop_faces: bool = True def load() -> FrameConfig: diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index b9771f5..35812f3 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -34,13 +34,64 @@ def _build_palette_image() -> Image.Image: _PALETTE_IMAGE = _build_palette_image() -def render_frame(source: Image.Image) -> bytes: +def _face_aware_crop_box( + img_width: int, img_height: int, target_width: int, target_height: int, faces: list[dict] +) -> tuple[int, int, int, int]: + """Largest crop window matching target_width:target_height that fits + inside the source image, centered on the union of all face bounding + boxes instead of the image's geometric center. Doesn't guarantee every + face survives if they're spread wider than the crop window allows -- + just biases toward keeping them on screen, best-effort. + + Each face's box is given relative to its own imageWidth/imageHeight + (the resolution Immich ran detection on), which may differ from the + downloaded preview's resolution passed in here, so each box is scaled + into img_width/img_height space before use. + """ + min_x = min_y = float("inf") + max_x = max_y = float("-inf") + for face in faces: + face_w = face.get("imageWidth") or img_width + face_h = face.get("imageHeight") or img_height + scale_x = img_width / face_w + scale_y = img_height / face_h + min_x = min(min_x, face["boundingBoxX1"] * scale_x) + max_x = max(max_x, face["boundingBoxX2"] * scale_x) + min_y = min(min_y, face["boundingBoxY1"] * scale_y) + max_y = max(max_y, face["boundingBoxY2"] * scale_y) + + faces_cx = (min_x + max_x) / 2 + faces_cy = (min_y + max_y) / 2 + + target_ratio = target_width / target_height + if img_width / img_height > target_ratio: + crop_h = img_height + crop_w = int(crop_h * target_ratio) + else: + crop_w = img_width + crop_h = int(crop_w / target_ratio) + + left = max(0, min(faces_cx - crop_w / 2, img_width - crop_w)) + top = max(0, min(faces_cy - crop_h / 2, img_height - crop_h)) + + return (int(left), int(top), int(left) + crop_w, int(top) + crop_h) + + +def render_frame(source: Image.Image, faces: list[dict] | None = None) -> bytes: """Fits `source` to the panel's resolution, quantizes it to the 6-color palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes. + + If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops + toward keeping them on screen instead of a plain center-crop. """ fitted = ImageOps.exif_transpose(source.convert("RGB")) - fitted = ImageOps.fit(fitted, (EPD_WIDTH, EPD_HEIGHT), method=Image.LANCZOS) + + if faces: + box = _face_aware_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT, faces) + fitted = fitted.crop(box).resize((EPD_WIDTH, EPD_HEIGHT), Image.LANCZOS) + else: + fitted = ImageOps.fit(fitted, (EPD_WIDTH, EPD_HEIGHT), method=Image.LANCZOS) quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG) pixels = quantized.load() diff --git a/server/app/immich_client.py b/server/app/immich_client.py index 21d6c06..1586be6 100644 --- a/server/app/immich_client.py +++ b/server/app/immich_client.py @@ -33,6 +33,19 @@ class ImmichClient: resp.raise_for_status() return resp.json().get("assets", {}).get("items", []) + def get_asset_faces(self, asset_id: str) -> list[dict]: + """Face bounding boxes Immich already computed for its own People + feature -- reused here to bias cropping toward keeping faces on + screen instead of running our own detection.""" + resp = httpx.get( + f"{self.base_url}/api/faces", + params={"id": asset_id}, + headers=self._headers, + timeout=10, + ) + resp.raise_for_status() + return resp.json() + def download_asset_preview(self, asset_id: str) -> bytes: resp = httpx.get( f"{self.base_url}/api/assets/{asset_id}/thumbnail", diff --git a/server/app/main.py b/server/app/main.py index bfe36d0..46b2cc5 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -4,6 +4,7 @@ the panel, and serves the ESP32 a ready-to-display frame.""" from __future__ import annotations import io +import logging import random import httpx @@ -16,15 +17,30 @@ from . import config from .image_pipeline import render_frame from .immich_client import ImmichClient +logger = logging.getLogger(__name__) + app = FastAPI(title="ESPresso Frame Server") templates = Jinja2Templates(directory="app/templates") +MIN_REFRESH_INTERVAL_S = 60 +MAX_REFRESH_INTERVAL_S = 86400 + @app.get("/health") def health() -> dict: return {"status": "ok"} +@app.get("/frame/config") +def frame_config(): + """Device-facing settings, polled by the frame alongside its + reachability check. Always returns 200 with current settings + (defaults if nothing's been saved yet) -- no Immich-configured gate, + since this doubles as the "is the server up" signal.""" + cfg = config.load() + return {"refresh_interval_s": cfg.refresh_interval_s} + + @app.get("/", response_class=HTMLResponse) def index(request: Request): cfg = config.load() @@ -49,6 +65,8 @@ def api_config_save( immich_api_key: str = Form(""), album_id: str = Form(""), order: str = Form("sequential"), + refresh_interval_s: int = Form(3600), + smart_crop_faces: bool = Form(True), ): cfg = config.load() cfg.immich_url = immich_url.strip() @@ -57,6 +75,8 @@ def api_config_save( cfg.cursor = 0 # restart from the top of a newly selected album cfg.album_id = album_id cfg.order = order if order in ("sequential", "shuffle") else "sequential" + cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)) + cfg.smart_crop_faces = smart_crop_faces config.save(cfg) return {"status": "saved"} @@ -91,7 +111,16 @@ def frame_image(): except httpx.HTTPError as e: raise HTTPException(502, f"Could not download asset from Immich: {e}") from e + faces = None + if cfg.smart_crop_faces: + try: + faces = client.get_asset_faces(asset["id"]) + except httpx.HTTPError as e: + # A faces lookup hiccup shouldn't block showing a photo at + # all -- just fall back to a plain center-crop this cycle. + logger.warning("Could not fetch faces for asset %s: %s", asset["id"], e) + source = Image.open(io.BytesIO(jpeg_bytes)) - frame_bytes = render_frame(source) + frame_bytes = render_frame(source, faces=faces) return Response(content=frame_bytes, media_type="application/octet-stream") diff --git a/server/app/templates/index.html b/server/app/templates/index.html index cdd1c04..c6d6025 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -10,6 +10,9 @@ p.sub { color: #666; font-size: 14px; margin-top: -8px; } label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; } input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; } + .checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; } + .checkbox-row input { width: auto; margin-top: 0; } + .checkbox-row label { margin-top: 0; font-weight: normal; } button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; } button:hover { background: #1d4ed8; } button.secondary { background: #6b7280; margin-right: 8px; } @@ -41,6 +44,14 @@ + +
+ + +
@@ -54,11 +65,14 @@ } async function saveConfig() { + const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60; const body = new URLSearchParams({ immich_url: document.getElementById('immich_url').value, immich_api_key: document.getElementById('immich_api_key').value, album_id: document.getElementById('album_id').value || '', order: document.getElementById('order').value, + refresh_interval_s: String(minutes * 60), + smart_crop_faces: String(document.getElementById('smart_crop_faces').checked), }); const resp = await fetch('/api/config', { method: 'POST',