diff --git a/server/README.md b/server/README.md index e0a484f..2fd56f4 100644 --- a/server/README.md +++ b/server/README.md @@ -153,7 +153,10 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, `quiet_hours_*` + `timezone` (a pure server-side decision shaping what `refresh_interval_s` gets handed to the device), `firmware_update_repo_url`, `firmware_auto_update`, - `battery_alert_threshold_pct` -- percent, or `-1`/blank to disable). + `battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --, + `palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/ + blue/green order --, `palette_reset` -- `true` clears back to the + default palette). - `POST .../take-control` -- always succeeds for a linked user. - `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`. - `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated), @@ -193,10 +196,14 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, to photos this frame is actually showing or has queued, not any Immich asset ID someone might guess -- a second layer a leaked device token alone wouldn't bypass. -- The 6-color palette RGB values in `app/image_pipeline.py` are - approximations, not measured values (Waveshare doesn't publish exact - color primaries for this panel) -- tune them once you can compare a - rendered test image against the real panel. +- The 6-color palette RGB values in `app/image_pipeline.py` + (`DEFAULT_PALETTE_RGB`) are approximations, not measured values + (Waveshare doesn't publish exact color primaries for this panel). + Each frame's Configuration tab has an **Advanced configuration** + section (collapsed by default) with a color picker per ink color -- + tune them once you can compare a rendered photo against the real + panel, and "Reset to defaults" to go back. Different panel units can + vary enough to be worth calibrating per frame. ## Deploying a pre-built image diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index 268b4f9..aa30356 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -45,39 +45,56 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]: return int(logical_h - 1 - y), int(x) return int(x), int(y) -# Approximate sRGB for each of the panel's 6 ink colors. These are -# reasonable placeholders, not measured values -- Waveshare doesn't publish -# exact color primaries for this panel. Tune them once you can compare a -# rendered test image against the real panel. -PALETTE_RGB = [ - # (0, 0, 0), # BLACK - # (255, 255, 255), # WHITE - # (255, 219, 0), # YELLOW - # (207, 0, 15), # RED - # (0, 39, 133), # BLUE - # (0, 133, 55), # GREEN - (0, 0, 0), - (255, 255, 255), - (255, 243, 56), - (191, 0, 0), - (100, 64, 255), - (67, 138, 28) +# Approximate sRGB for each of the panel's 6 ink colors -- reasonable +# placeholders, not measured values (Waveshare doesn't publish exact +# color primaries for this panel). This is the fallback for any frame +# that hasn't tuned its own (Frame.palette_rgb, set from a frame's +# Configuration tab -- "Advanced configuration" -- once you can compare +# a rendered test image against the real panel; different panel units +# can vary enough to be worth calibrating per frame). +DEFAULT_PALETTE_RGB = [ + (0, 0, 0), # BLACK + (255, 255, 255), # WHITE + (255, 243, 56), # YELLOW + (191, 0, 0), # RED + (100, 64, 255), # BLUE + (67, 138, 28), # GREEN ] +PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"] + # The panel's actual 4-bit color codes (see firmware/components/epd7in3e), -# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream. +# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the +# hardware protocol, never user-configurable. 0x4 is intentionally unused +# upstream. PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6] -def _build_palette_image() -> Image.Image: +def palette_to_hex(palette_rgb: list) -> list[str]: + """[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced + configuration color pickers.""" + return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb] + + +def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None: + """"#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly + a 6-hex-digit color (what always sends, but a + direct API call might not).""" + hex_str = hex_str.strip().lstrip("#") + if len(hex_str) != 6: + return None + try: + return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)) + except ValueError: + return None + + +def _build_palette_image(palette_rgb: list) -> Image.Image: pal_img = Image.new("P", (1, 1)) - pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb]) + pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb]) return pal_img -_PALETTE_IMAGE = _build_palette_image() - - def _plain_center_crop_box( img_width: int, img_height: int, target_width: int, target_height: int ) -> tuple[float, float, int, int]: @@ -153,7 +170,7 @@ def _face_aware_crop_box( def render_frame(source: Image.Image, faces: list[dict] | None = None, - orientation: str = "landscape") -> bytes: + orientation: str = "landscape", palette_rgb: list | 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. @@ -164,6 +181,9 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None, `orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how the frame physically hangs, then rotates into native panel space -- the output byte layout is identical either way. + + `palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors, + see Frame.palette_rgb) -- None uses the default. """ logical_w, logical_h = logical_render_size(orientation) fitted = ImageOps.exif_transpose(source.convert("RGB")) @@ -174,15 +194,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None, else: fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) - return _quantize_and_pack(fitted, orientation) + return _quantize_and_pack(fitted, orientation, palette_rgb) -def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes: +def _quantize_and_pack(logical_img: Image.Image, orientation: str, palette_rgb: list | None = None) -> bytes: """The shared back half of rendering: 6-color Floyd-Steinberg quantization, rotation into native panel space, and 2-pixels/byte packing. Takes an RGB image already composed at logical_render_size() for the orientation.""" - quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG) + palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB) + quantized = logical_img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) transpose = ORIENTATION_TRANSPOSE.get(orientation) if transpose is not None: quantized = quantized.transpose(transpose) @@ -201,7 +222,7 @@ def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes: def render_placeholder(lines: list[str], qr_url: str | None = None, - orientation: str = "landscape") -> bytes: + orientation: str = "landscape", palette_rgb: list | None = None) -> bytes: """A readable full-panel message (plus an optional QR code) in the same packed format as render_frame -- what /frame/image serves for a frame that isn't claimed or configured yet, so a fresh device shows @@ -246,4 +267,4 @@ def render_placeholder(lines: list[str], qr_url: str | None = None, if qr_img: img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14)) - return _quantize_and_pack(img, orientation) + return _quantize_and_pack(img, orientation, palette_rgb) diff --git a/server/app/migration.py b/server/app/migration.py index 9ac6ada..4a46e76 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -52,10 +52,18 @@ def _migration_3(conn) -> None: conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls")) +def _migration_4(conn) -> None: + """Advanced configuration: a per-frame color palette override. NULL + for every existing row -- exactly "use the default", no behavior + change until a frame's Configuration tab sets one.""" + conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), (3, _migration_3), + (4, _migration_4), ] diff --git a/server/app/models.py b/server/app/models.py index 70b2543..85f3166 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -124,6 +124,11 @@ class Frame(Base): smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True) orientation: Mapped[str] = mapped_column(String, default="landscape") queue_target_len: Mapped[int] = mapped_column(Integer, default=20) + # Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/ + # blue/green, matching image_pipeline.PANEL_CODES order) overriding + # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the + # default -- most frames never touch this. + palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # -- state -- current_asset_id: Mapped[str] = mapped_column(String, default="") diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index adec110..c979523 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -28,6 +28,7 @@ from sqlalchemy.orm import Session from .. import gitea_releases, photo_queue, quiet_hours from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db +from ..image_pipeline import PALETTE_LABELS, hex_to_rgb from ..firmware import firmware_path, parse_app_version from ..models import BatteryLog, Frame from .common import ( @@ -79,6 +80,8 @@ def api_config_save( firmware_update_repo_url: str | None = Form(None), firmware_auto_update: bool | None = Form(None), battery_alert_threshold_pct: int | None = Form(None), + palette: list[str] | None = Form(None), + palette_reset: bool | None = Form(None), frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): @@ -124,6 +127,15 @@ def api_config_save( # A changed threshold should be able to fire again immediately, # not stay suppressed by a flag set under the old value. cfg.battery_alert_sent = False + if palette_reset: + cfg.palette_rgb = None + elif palette is not None: + if len(palette) != len(PALETTE_LABELS): + raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}") + parsed = [hex_to_rgb(h) for h in palette] + if any(rgb is None for rgb in parsed): + raise HTTPException(400, "Palette colors must be #rrggbb hex values") + cfg.palette_rgb = [list(rgb) for rgb in parsed] cfg.stats_config_saves += 1 return {"status": "saved"} diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 04ed6e8..d048026 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -85,7 +85,7 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes: logger.warning("Could not fetch faces for asset %s: %s", asset_id, e) source = Image.open(io.BytesIO(jpeg_bytes)) - return render_frame(source, faces=faces, orientation=frame.orientation) + return render_frame(source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb) def battery_estimate_s(frame: Frame) -> int | None: diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 095f86a..257d020 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -54,16 +54,19 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes: ["This frame isn't claimed yet", "Scan to link it to your account:"], qr_url=claim_url, orientation=frame.orientation, + palette_rgb=frame.palette_rgb, ) if frame.owner_user_id is None: return render_placeholder( ["Almost there!", f"Open {base} to finish setting up this frame."], orientation=frame.orientation, + palette_rgb=frame.palette_rgb, ) return render_placeholder( ["Almost there!", "Pick an album for this frame:", base], qr_url=base, orientation=frame.orientation, + palette_rgb=frame.palette_rgb, ) diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index d8f4148..f66b13a 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from ..auth import can_view_frame, current_user from ..db import get_db +from ..image_pipeline import DEFAULT_PALETTE_RGB, PALETTE_LABELS, palette_to_hex from ..models import Frame from ..quiet_hours import ALL_TIMEZONES from .common import shell_context @@ -40,7 +41,11 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get @router.get("/frames/{frame_id}/config", response_class=HTMLResponse) def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)): return _frame_page( - request, db, frame_id, "frame_config.html", "config", timezones=ALL_TIMEZONES + request, db, frame_id, "frame_config.html", "config", + timezones=ALL_TIMEZONES, + palette_labels=PALETTE_LABELS, + default_palette_rgb=DEFAULT_PALETTE_RGB, + palette_to_hex=palette_to_hex, ) diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 416a5b9..bf6fc11 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -66,6 +66,41 @@ async function loadControl() { document.getElementById('take-control').addEventListener('click', takeControl); +// ---- Advanced configuration: color palette ---- + +function paletteInputs() { + return Array.from(document.querySelectorAll('[id^="palette_"]')) + .sort((a, b) => Number(a.id.split('_')[1]) - Number(b.id.split('_')[1])); +} + +async function savePalette(body) { + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + } catch (e) { + showStatus(false, e.message); + } +} + +document.getElementById('palette-save').addEventListener('click', () => { + const body = new URLSearchParams(); + for (const input of paletteInputs()) { + body.append('palette', input.value); + } + savePalette(body); +}); + +document.getElementById('palette-reset').addEventListener('click', () => { + const inputs = paletteInputs(); + window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; }); + savePalette(new URLSearchParams({ palette_reset: 'true' })); +}); + // ---- Battery alerts card ---- document.getElementById('battery-alert-save').addEventListener('click', async () => { diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 0fcfee6..716b160 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -168,6 +168,30 @@ summary.card-title { cursor: pointer; margin-bottom: 0; } details.card[open] summary.card-title { margin-bottom: 14px; } details.card .sub { margin-top: 8px; } +.palette-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 12px; + margin-top: 14px; +} +.palette-swatch { + display: flex; + align-items: center; + gap: 8px; + margin-top: 0; + font-size: 13px; + font-weight: 500; + color: var(--text); +} +.palette-swatch input[type="color"] { + width: 34px; + height: 34px; + padding: 2px; + margin-top: 0; + border-radius: 8px; + cursor: pointer; +} + .card { background: var(--surface); border: 1px solid var(--border); diff --git a/server/app/templates/app_base.html b/server/app/templates/app_base.html index 29a1fbd..728a022 100644 --- a/server/app/templates/app_base.html +++ b/server/app/templates/app_base.html @@ -34,7 +34,11 @@ href="/frames/{{ f.id }}"> {{ f.name or ("Frame " ~ f.id) }} - {% if f.owner_user_id is none %}{% endif %} + {% if f.owner_user_id is none %} + + {% elif f.battery_percent >= 0 %} + + {% endif %} {% endfor %} {% if not sidebar_frames %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index ebafb02..018bea0 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -112,6 +112,26 @@ disable. Needs SMTP configured by an admin.
+ +Color-quantization values used when dithering photos + for this panel -- approximations by default, since exact primaries + aren't published. Tune them by comparing a rendered photo against + the physical panel; different panel units can vary enough to be + worth calibrating per frame.
+