Files
espresso_frame/server/app/main.py
T
tfaour 6e353c2271
Build and push server image / build-and-push (push) Failing after 10s
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).
2026-07-18 15:32:29 -04:00

127 lines
4.2 KiB
Python

"""ESPresso Frame server: pulls photos from Immich, pre-processes them for
the panel, and serves the ESP32 a ready-to-display frame."""
from __future__ import annotations
import io
import logging
import random
import httpx
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from PIL import Image
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()
return templates.TemplateResponse("index.html", {"request": request, "cfg": cfg})
@app.get("/api/albums")
def api_albums():
cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
try:
albums = ImmichClient(cfg.immich_url, cfg.immich_api_key).list_albums()
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@app.post("/api/config")
def api_config_save(
immich_url: str = Form(""),
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()
cfg.immich_api_key = immich_api_key.strip()
if album_id != cfg.album_id:
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"}
@app.get("/frame/image")
def frame_image():
cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if not cfg.album_id:
raise HTTPException(400, "No album configured yet")
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
try:
assets = client.list_album_assets(cfg.album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
if not assets:
raise HTTPException(404, "Album has no photos")
if cfg.order == "shuffle":
asset = random.choice(assets)
else:
index = cfg.cursor % len(assets)
asset = assets[index]
cfg.cursor = (index + 1) % len(assets)
config.save(cfg)
try:
jpeg_bytes = client.download_asset_preview(asset["id"])
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, faces=faces)
return Response(content=frame_bytes, media_type="application/octet-stream")