Per-frame palette calibration + sidebar battery indicator
Build and push server image / build-and-push (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 40s
Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.
Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.
Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
This commit is contained in:
@@ -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 <input type="color"> 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)
|
||||
|
||||
Reference in New Issue
Block a user