Color/contrast/dithering sliders + before/after render preview
Advanced configuration gains three sliders (PIL ImageEnhance factors for color/contrast, 0-2, 1=unchanged; a 0-1 dithering strength) applied to every photo this frame renders. Confirmed the parameter conventions against a similar project (jwchen119/EPF: ImageEnhance.Color/Contrast, 1.0 baseline) before implementing; dithering strength isn't natively exposed by PIL's quantize(), so it's implemented by blending the source toward its own flat/undithered quantization before running Floyd- Steinberg on the blend -- at 0 there's no quantization error left to diffuse (exactly the flat result), at 1 it's the original unmodified behavior, with a smooth continuum between rather than dithering being an on/off toggle. image_pipeline.py split into composition (_compose), enhancement (_enhance), quantization (_quantize), and transpose+pack stages so render_frame (device bytes) and the new render_preview_png (a normal viewable PNG, upright logical orientation) share the same pipeline instead of duplicating it. Named-face overlay label math (face_labels.py) was already routed through the shared _placement_transform, so it needed no changes for the new params. Also added the requested before/after comparison: the Configuration tab's new Preview card shows the current photo's untouched Immich preview next to that same photo run through the frame's actual saved rendering pipeline (two new GET endpoints, /preview/original and /preview/rendered) -- immediate visual feedback for tuning the palette and these new sliders. "Refresh preview" re-fetches after saving. Schema migration v6 adds color_boost/contrast_boost/dither_strength, defaulting to 1.0/1.0/1.0 -- reproduces the exact previous rendering until a frame's Configuration tab changes one. Verified against the live-shaped test database: the migration, sliders persisting and clamping out-of-range input, both preview endpoints (real JPEG passthrough / real PNG at correct logical size+orientation), confirmed dither_strength=0 actually changes the rendered bytes vs. default, and the standing legacy-device curl suite.
This commit is contained in:
+17
-7
@@ -147,16 +147,26 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
- `GET .../albums` -- the owner's Immich albums.
|
||||
- `POST .../config` -- **partial** update: only provided fields change
|
||||
(`name`, `album_id` -- resets queue/history on change --, `order`,
|
||||
`refresh_interval_s`, `smart_crop_faces`, `queue_target_len`,
|
||||
`orientation` (composed logically then rotated server-side; the
|
||||
on-device manage overlay still renders native, a known limitation),
|
||||
`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`,
|
||||
`refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/
|
||||
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
|
||||
`queue_target_len`, `orientation` (composed logically then rotated
|
||||
server-side; the on-device manage overlay still renders native, a
|
||||
known limitation), `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 --,
|
||||
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
|
||||
blue/green order --, `palette_reset` -- `true` clears back to the
|
||||
default palette).
|
||||
default palette --, `color_boost`/`contrast_boost` -- PIL
|
||||
`ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` --
|
||||
0-1, blends toward a flat/undithered quantization before running
|
||||
Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).
|
||||
- `GET .../preview/original`, `GET .../preview/rendered` -- the
|
||||
before/after comparison on the Configuration tab: the current
|
||||
photo's Immich preview untouched (JPEG), and that same photo run
|
||||
through this frame's actual saved rendering pipeline (PNG, upright
|
||||
logical orientation, not packed device bytes) -- reflects saved
|
||||
settings, not unsaved slider positions.
|
||||
- `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),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
|
||||
EPD_WIDTH = 800
|
||||
EPD_HEIGHT = 480
|
||||
@@ -208,55 +210,59 @@ def _placement_transform(
|
||||
return scale_x, scale_y, -left * scale_x, -top * scale_y
|
||||
|
||||
|
||||
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
display_mode: str = DEFAULT_DISPLAY_MODE) -> 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.
|
||||
|
||||
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
|
||||
is reconciled with the panel's: crop_fill (center-crop to fill,
|
||||
excess trimmed), crop_faces (as crop_fill, but shifts the crop to
|
||||
keep `faces` on screen -- falls back to crop_fill if none), stretch_fill
|
||||
(fills exactly, aspect ratio not preserved), letterbox (whole photo
|
||||
visible, letterboxed with LETTERBOX_BG where it doesn't fill).
|
||||
|
||||
`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.
|
||||
"""
|
||||
def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image:
|
||||
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
|
||||
image at logical_render_size(orientation), before enhancement or
|
||||
quantization. See render_frame for what each display_mode does."""
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
||||
|
||||
if display_mode == "stretch_fill":
|
||||
fitted = fitted.resize((logical_w, logical_h), Image.LANCZOS)
|
||||
elif display_mode == "letterbox":
|
||||
return fitted.resize((logical_w, logical_h), Image.LANCZOS)
|
||||
if display_mode == "letterbox":
|
||||
scale = min(logical_w / fitted.width, logical_h / fitted.height)
|
||||
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
|
||||
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
|
||||
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
||||
canvas.paste(resized, ((logical_w - new_w) // 2, (logical_h - new_h) // 2))
|
||||
fitted = canvas
|
||||
elif display_mode == "crop_faces" and faces:
|
||||
return canvas
|
||||
if display_mode == "crop_faces" and faces:
|
||||
box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
|
||||
fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
|
||||
else: # crop_fill, or crop_faces with no faces detected
|
||||
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
||||
|
||||
return _quantize_and_pack(fitted, orientation, palette_rgb)
|
||||
return fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
|
||||
return ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
|
||||
|
||||
|
||||
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."""
|
||||
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
|
||||
if color_boost != 1.0:
|
||||
img = ImageEnhance.Color(img).enhance(color_boost)
|
||||
if contrast_boost != 1.0:
|
||||
img = ImageEnhance.Contrast(img).enhance(contrast_boost)
|
||||
return img
|
||||
|
||||
|
||||
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
|
||||
"""RGB -> palette-quantized P-mode image, same size/orientation as
|
||||
`img` (no rotation here). dither_strength blends `img` toward its own
|
||||
flat (undithered) quantization before running Floyd-Steinberg on the
|
||||
blend: at 0 there's zero quantization error left to diffuse (so the
|
||||
result IS the flat quantization, no dithering texture at all); at 1
|
||||
it's `img` unchanged (full-strength dithering, this project's
|
||||
original always-on behavior); values between give a smooth continuum
|
||||
of dithering intensity rather than an on/off toggle."""
|
||||
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
||||
quantized = logical_img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
if dither_strength >= 1.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
if dither_strength <= 0.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.NONE)
|
||||
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB")
|
||||
blended = Image.blend(flat, img, dither_strength)
|
||||
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
|
||||
|
||||
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
||||
"""Rotates a logical-space quantized image into native panel space
|
||||
and packs it 2 pixels/byte the way epd7in3e.c expects. Always
|
||||
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes."""
|
||||
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||
if transpose is not None:
|
||||
quantized = quantized.transpose(transpose)
|
||||
@@ -274,6 +280,54 @@ def _quantize_and_pack(logical_img: Image.Image, orientation: str, palette_rgb:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
||||
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
|
||||
"""Fits `source` to the panel's resolution, applies color/contrast
|
||||
enhancement, quantizes it to the 6-color palette, and packs 2
|
||||
pixels/byte the way epd7in3e.c expects. Always returns exactly
|
||||
EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
||||
|
||||
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
|
||||
is reconciled with the panel's: crop_fill (center-crop to fill,
|
||||
excess trimmed), crop_faces (as crop_fill, but shifts the crop to
|
||||
keep `faces` on screen -- falls back to crop_fill if none), stretch_fill
|
||||
(fills exactly, aspect ratio not preserved), letterbox (whole photo
|
||||
visible, letterboxed with LETTERBOX_BG where it doesn't fill).
|
||||
|
||||
`color_boost`/`contrast_boost` are PIL ImageEnhance factors (1.0 =
|
||||
unchanged, matching PIL's own convention); `dither_strength` is
|
||||
0.0-1.0 (see _quantize).
|
||||
|
||||
`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.
|
||||
"""
|
||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
||||
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
|
||||
"""Identical composition/enhancement/quantization pipeline as
|
||||
render_frame, but returned as a normal browser-viewable PNG in
|
||||
logical (upright, as-the-frame-actually-hangs) orientation rather
|
||||
than packed native-panel bytes and rotation -- what the web UI's
|
||||
"how it will look on the frame" preview shows."""
|
||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None) -> bytes:
|
||||
"""A readable full-panel message (plus an optional QR code) in the
|
||||
@@ -320,4 +374,5 @@ 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, palette_rgb)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
@@ -70,12 +70,23 @@ def _migration_5(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces"))
|
||||
|
||||
|
||||
def _migration_6(conn) -> None:
|
||||
"""Advanced configuration: color/contrast enhancement + dithering
|
||||
strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0)
|
||||
reproduce the exact previous rendering -- no behavior change until a
|
||||
frame's Configuration tab adjusts one."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
(3, _migration_3),
|
||||
(4, _migration_4),
|
||||
(5, _migration_5),
|
||||
(6, _migration_6),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -131,6 +131,13 @@ class Frame(Base):
|
||||
# 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)
|
||||
# Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged
|
||||
# (see image_pipeline.render_frame).
|
||||
color_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
contrast_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
||||
# original always-on full-strength Floyd-Steinberg dithering.
|
||||
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
|
||||
# -- state --
|
||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
@@ -28,12 +28,19 @@ 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 DEFAULT_DISPLAY_MODE, DISPLAY_MODES, PALETTE_LABELS, hex_to_rgb
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
PALETTE_LABELS,
|
||||
hex_to_rgb,
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame
|
||||
from .common import (
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
fetch_source_and_faces,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -82,6 +89,9 @@ def api_config_save(
|
||||
battery_alert_threshold_pct: int | None = Form(None),
|
||||
palette: list[str] | None = Form(None),
|
||||
palette_reset: bool | None = Form(None),
|
||||
color_boost: float | None = Form(None),
|
||||
contrast_boost: float | None = Form(None),
|
||||
dither_strength: float | None = Form(None),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -136,6 +146,12 @@ def api_config_save(
|
||||
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]
|
||||
if color_boost is not None:
|
||||
cfg.color_boost = max(0.0, min(2.0, color_boost))
|
||||
if contrast_boost is not None:
|
||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||
if dither_strength is not None:
|
||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
|
||||
@@ -316,6 +332,53 @@ def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
def _current_asset_id(frame: Frame, db: Session) -> str:
|
||||
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
|
||||
picks a current photo if none is set yet, otherwise just reads it,
|
||||
never advances early."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
asset_id = cfg.current_asset_id
|
||||
if not asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
return asset_id
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/original")
|
||||
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The Immich preview image behind the currently-displayed photo,
|
||||
unprocessed -- the "now displaying" side of the Configuration tab's
|
||||
before/after comparison."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
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
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/rendered")
|
||||
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The same photo run through this frame's actual saved rendering
|
||||
pipeline (display mode, palette, color/contrast/dithering) and
|
||||
exported as a PNG -- the "how it will look on the frame" side of the
|
||||
comparison. Not a live preview of unsaved slider values; reflects
|
||||
whatever's currently saved."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
png = render_preview_png(
|
||||
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode=frame.display_mode, color_boost=frame.color_boost,
|
||||
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/firmware")
|
||||
def api_firmware_upload(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@@ -69,7 +69,11 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
||||
return assets
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
||||
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
|
||||
"""The shared first half of rendering: download the Immich preview
|
||||
and (only if display_mode needs it) its detected faces. Used by both
|
||||
render_asset (device-facing) and the web UI's rendered-preview
|
||||
endpoint (routers/api_frames.py) so they can't drift apart."""
|
||||
try:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
@@ -84,9 +88,15 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
||||
# 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))
|
||||
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode)
|
||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength)
|
||||
|
||||
|
||||
def battery_estimate_s(frame: Frame) -> int | None:
|
||||
|
||||
@@ -119,7 +119,19 @@ for (let i = 0; i < palettePickerCount; i++) {
|
||||
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
|
||||
}
|
||||
|
||||
async function savePalette(body) {
|
||||
// Sliders: live numeric readout next to each, no save until the button
|
||||
// below is clicked.
|
||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||
const input = document.getElementById(id);
|
||||
const readout = document.getElementById(`${id}_value`);
|
||||
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
||||
});
|
||||
|
||||
async function savePalette(extra) {
|
||||
const body = new URLSearchParams(extra || {});
|
||||
for (const input of paletteHexInputs()) {
|
||||
body.append('palette', input.value);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
@@ -128,17 +140,18 @@ async function savePalette(body) {
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('palette-save').addEventListener('click', () => {
|
||||
const body = new URLSearchParams();
|
||||
for (const input of paletteHexInputs()) {
|
||||
body.append('palette', input.value);
|
||||
}
|
||||
savePalette(body);
|
||||
savePalette({
|
||||
color_boost: document.getElementById('color_boost').value,
|
||||
contrast_boost: document.getElementById('contrast_boost').value,
|
||||
dither_strength: document.getElementById('dither_strength').value,
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('palette-reset').addEventListener('click', () => {
|
||||
@@ -147,9 +160,24 @@ document.getElementById('palette-reset').addEventListener('click', () => {
|
||||
inputs[i].value = hex;
|
||||
syncPaletteFromHex(i);
|
||||
});
|
||||
savePalette(new URLSearchParams({ palette_reset: 'true' }));
|
||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||
document.getElementById(id).value = '1';
|
||||
document.getElementById(`${id}_value`).textContent = '1.00';
|
||||
});
|
||||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
||||
});
|
||||
|
||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||
|
||||
function loadPreview() {
|
||||
const bust = Date.now(); // avoid a stale cached image after settings change
|
||||
document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`;
|
||||
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
|
||||
}
|
||||
|
||||
document.getElementById('preview-refresh').addEventListener('click', loadPreview);
|
||||
loadPreview();
|
||||
|
||||
// ---- Battery alerts card ----
|
||||
|
||||
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
||||
|
||||
@@ -197,6 +197,35 @@ details.card .sub { margin-top: 8px; }
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
accent-color: var(--accent);
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.preview-compare {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-alt);
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -156,9 +156,42 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="palette-save">Save</button>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 20px;">Image adjustments</h2>
|
||||
<label>Color enhancement <span class="slider-value" id="color_boost_value">{{ "%.2f" | format(frame.color_boost) }}</span>
|
||||
<input type="range" id="color_boost" min="0" max="2" step="0.05" value="{{ frame.color_boost }}">
|
||||
</label>
|
||||
<label>Contrast <span class="slider-value" id="contrast_boost_value">{{ "%.2f" | format(frame.contrast_boost) }}</span>
|
||||
<input type="range" id="contrast_boost" min="0" max="2" step="0.05" value="{{ frame.contrast_boost }}">
|
||||
</label>
|
||||
<label>Dithering strength <span class="slider-value" id="dither_strength_value">{{ "%.2f" | format(frame.dither_strength) }}</span>
|
||||
<input type="range" id="dither_strength" min="0" max="1" step="0.05" value="{{ frame.dither_strength }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">1.00 is unchanged for color/
|
||||
contrast. Dithering strength trades noise texture for smoother
|
||||
gradients as it goes down; 0 is a flat, un-dithered quantization.
|
||||
Use the preview below to compare.</p>
|
||||
|
||||
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
||||
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||
</details>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">The current photo, and exactly how it renders on the
|
||||
panel with this frame's saved settings above.</p>
|
||||
<div class="preview-compare">
|
||||
<div class="preview-pane">
|
||||
<p class="sub">Now displaying</p>
|
||||
<img class="preview-img" id="preview-original" alt="Original photo">
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<p class="sub">How it will look on the frame</p>
|
||||
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user