Quantize with a measured Spectra 6 palette and OKLab-space ordered dithering
Build and push server image / test (push) Successful in 58s
Build and push server image / build-and-push (push) Successful in 2m44s
Build and push server image / deploy (push) Successful in 53s

DEFAULT_PALETTE_RGB was a guessed approximation of the panel's ink
colors (pure sRGB primaries); swap in epdoptimize's measured spectra6
palette instead, which is far more muted/darker, matching how these
inks actually look.

_quantize now matches against the palette in OKLab space (perceptual
distance) instead of PIL's raw-RGB quantize(), with lightness weighted
down relative to hue/chroma when selecting the nearest color -- this
palette's inks are lit so differently from their sRGB namesakes
(muted dark red, bright yellow) that unweighted distance let lightness
dominate and mismatch hue (pure red nearest "yellow").

Dithering switched from Floyd-Steinberg error diffusion to a Bayer
ordered dither: true error diffusion is an inherently serial per-pixel
loop, and doing that in pure Python for a full 800x480 panel took
~1s, blowing past the render-latency budget the "render widgets
concurrently" fix (previous commit) exists to protect. The ordered
dither finds each pixel's true nearest and second-nearest palette
color and mixes between them (via projection onto that segment, not
distance ratio) using a tiled Bayer threshold -- fully vectorized, no
Python-level pixel loop.
This commit is contained in:
2026-07-28 04:20:25 +00:00
parent a48c84ed4a
commit 05b417a29b
3 changed files with 185 additions and 34 deletions
+182 -32
View File
@@ -5,13 +5,14 @@ from __future__ import annotations
import io import io
import math import math
import numpy as np
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
EPD_WIDTH = 800 EPD_WIDTH = 800
EPD_HEIGHT = 480 EPD_HEIGHT = 480
# PIL's TrueType rendering antialiases by default (graduated gray edge # PIL's TrueType rendering antialiases by default (graduated gray edge
# pixels). Those survive straight into _quantize's Floyd-Steinberg # pixels). Those survive straight into _quantize's error-diffusion
# dithering, which -- confirmed visually -- turns them into scattered # dithering, which -- confirmed visually -- turns them into scattered
# colored speckles along every glyph edge once forced onto the panel's 6 # colored speckles along every glyph edge once forced onto the panel's 6
# colors, since a mid-gray input has no close palette match and the # colors, since a mid-gray input has no close palette match and the
@@ -148,20 +149,24 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
return int(logical_h - 1 - y), int(x) return int(logical_h - 1 - y), int(x)
return int(x), int(y) return int(x), int(y)
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable # Measured sRGB appearance of each of the panel's 6 ink colors on an
# placeholders, not measured values (Waveshare doesn't publish exact # actual Spectra 6 panel -- sourced from epdoptimize's "spectra6" palette
# color primaries for this panel). This is the fallback for any frame # (github.com/paperlesspaper/epdoptimize, src/dither/data/default-palettes
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's # .json), not our own calibration, but a much better starting point than a
# Configuration tab -- "Advanced configuration" -- once you can compare # guess: e-ink ink never reaches full sRGB saturation/contrast, so this is
# a rendered test image against the real panel; different panel units # uniformly darker and more muted than the naive (0,0,0)/(255,255,255)/pure
# can vary enough to be worth calibrating per frame). # hues this used to be. 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 = [ DEFAULT_PALETTE_RGB = [
(0, 0, 0), # BLACK (31, 34, 38), # BLACK
(255, 255, 255), # WHITE (185, 199, 201), # WHITE
(255, 219, 0), # YELLOW (193, 187, 30), # YELLOW
(207, 0, 15), # RED (98, 32, 30), # RED
(0, 39, 133), # BLUE (35, 63, 142), # BLUE
(0, 133, 55), # GREEN (53, 86, 58), # GREEN
] ]
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"] PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
@@ -222,10 +227,137 @@ def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
return None return None
def _build_palette_image(palette_rgb: list) -> Image.Image: def _rgb_to_oklab(rgb: "np.ndarray") -> "np.ndarray":
pal_img = Image.new("P", (1, 1)) """(...,3) uint8/float sRGB -> (...,3) float32 OKLab (Bjorn Ottosson's
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb]) formulation, https://bottosson.github.io/posts/oklab/). Used instead
return pal_img of raw RGB distance for palette matching/error diffusion below --
Euclidean distance in OKLab tracks perceived color difference far
better than in RGB, which matters a lot once the "colors" being
matched against are a 6-entry palette this coarse."""
linear = (rgb.astype(np.float32) / 255.0)
linear = np.where(linear <= 0.04045, linear / 12.92, ((linear + 0.055) / 1.055) ** 2.4)
r, g, b = linear[..., 0], linear[..., 1], linear[..., 2]
l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
l_, m_, s_ = np.cbrt(l), np.cbrt(m), np.cbrt(s)
L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
return np.stack([L, a, b2], axis=-1)
# Lightness is weighted down relative to a/b when *choosing* the nearest
# palette entry (established color-difference formulas -- CIE94, CMC --
# do the same, on the general principle that a lightness mismatch reads
# as less objectionable than a hue mismatch). Not optional polish: this
# palette's ink colors are far darker/lighter than their sRGB namesakes
# (e.g. "red" ink is a dark #62201E, "yellow" ink is a bright #C1BB1E),
# so unweighted OKLab distance lets that lightness gap dominate and pure
# saturated red (high L) ends up nearer "yellow" (L=0.77) than "red"
# (L=0.35) even though red is unambiguously closer in hue/chroma (a/b) --
# confirmed both analytically and by DEFAULT_PALETTE_RGB's own test
# coverage (test_render_size_invariants.py's pure-red/pure-blue check).
_LIGHTNESS_MATCH_WEIGHT = 0.5
def _nearest_palette_indices(oklab_pixels: "np.ndarray", palette_oklab: "np.ndarray") -> "np.ndarray":
"""(H,W,3) OKLab pixels, (K,3) OKLab palette -> (H,W) index array, no
error diffusion -- the "flat"/undithered quantization, vectorized
(K is always 6, so brute-force all-pairs distance is cheap and this
stays a single numpy call rather than a per-pixel Python loop)."""
diffs2 = (oklab_pixels[:, :, None, :] - palette_oklab[None, None, :, :]) ** 2
weights = np.array([_LIGHTNESS_MATCH_WEIGHT, 1.0, 1.0], dtype=np.float32)
dist2 = (diffs2 * weights).sum(axis=-1)
return np.argmin(dist2, axis=2)
def _bayer_matrix(n: int) -> "np.ndarray":
"""Recursive construction of the standard n x n (n a power of 2)
Bayer ordered-dithering threshold matrix, values 0..n*n-1, each used
exactly once -- the classic recursive doubling
(https://en.wikipedia.org/wiki/Ordered_dithering)."""
if n == 1:
return np.zeros((1, 1))
smaller = _bayer_matrix(n // 2)
return np.block([
[4 * smaller, 4 * smaller + 2],
[4 * smaller + 3, 4 * smaller + 1],
])
# Normalized to [0, 1): a deterministic per-pixel threshold tiled across
# the image, used (like classic ordered/Bayer dithering) to decide, for
# each pixel, whether it plots as its nearest or second-nearest palette
# color -- see _ordered_dither_oklab.
_BAYER_8 = (_bayer_matrix(8) + 0.5) / 64.0
def _ordered_dither_oklab(oklab_pixels: "np.ndarray", palette_oklab: "np.ndarray") -> "np.ndarray":
"""(H,W,3) OKLab pixels, (K,3) OKLab palette -> (H,W) uint8 index
array, ordered (Bayer matrix) dithering -- picked over error
diffusion (Floyd-Steinberg/Atkinson/etc.) specifically because it's
fully vectorizable: no pixel-to-pixel dependency to chain through a
Python loop, just a fixed number of numpy calls over the whole
image. A straight per-pixel error-diffusion loop in Python was
measured at ~1s for a full 800x480 panel -- see
test_widgets_render_concurrently's latency budget (the whole reason
widgets render concurrently in the first place, see git history) --
which this avoids entirely.
Finds each pixel's true nearest and second-nearest palette color and
mixes between exactly those two, using the Bayer threshold as the
per-pixel coin flip -- the standard generalization of ordered
dithering to a palette whose entries aren't evenly spaced (unlike,
say, dithering 0-255 gray down to a handful of even steps). The
mixing fraction is the pixel's projection onto the segment from its
nearest color to its second-nearest, NOT distance-to-nearest over
total distance (d0/(d0+d1)) -- an earlier version used that ratio
and it's wrong whenever the second-nearest color is simply far away
in an unrelated direction rather than genuinely "on the other side"
of the pixel: d1 being large made the ratio look small-mixing-needed
only when d0 was *also* comparably large, so a pixel sitting almost
exactly on its nearest color still got a large fraction of an
unrelated second color -- confirmed visually as entire regions
(e.g. a pale sky, clearly nearest White) rendering as flat blocks of
a wrong, unrelated color (Yellow) instead of White. Projection onto
the actual nearest-neighbor segment doesn't have that failure mode:
a pixel essentially at c0 projects to ~0 regardless of where c1 is."""
weights = np.array([_LIGHTNESS_MATCH_WEIGHT, 1.0, 1.0], dtype=np.float32)
scale = np.sqrt(weights)
pixels_w = oklab_pixels * scale
palette_w = palette_oklab * scale
dist2 = ((pixels_w[:, :, None, :] - palette_w[None, None, :, :]) ** 2).sum(axis=-1) # (H, W, K)
order = np.argsort(dist2, axis=-1)
idx0, idx1 = order[..., 0], order[..., 1]
c0 = palette_w[idx0] # (H, W, 3)
c1 = palette_w[idx1] # (H, W, 3)
segment = c1 - c0
to_pixel = pixels_w - c0
segment_len2 = (segment * segment).sum(axis=-1)
t = np.divide((to_pixel * segment).sum(axis=-1), segment_len2,
out=np.zeros_like(segment_len2), where=segment_len2 > 1e-12)
t = np.clip(t, 0.0, 1.0)
h, w, _ = oklab_pixels.shape
threshold = np.tile(_BAYER_8, (h // 8 + 1, w // 8 + 1))[:h, :w]
use_second = threshold < t
return np.where(use_second, idx1, idx0).astype(np.uint8)
def _index_array_to_p_image(idx_array: "np.ndarray", palette_rgb: list) -> Image.Image:
"""(H,W) palette-index array -> a PIL "P"-mode image carrying
`palette_rgb` as its palette, so downstream code (as_png's
.convert("RGB"), _transpose_and_pack's pixels[x, y] index lookups)
behaves exactly as it did with PIL's own quantize()."""
img = Image.fromarray(idx_array, mode="P")
padded = list(palette_rgb) + [(0, 0, 0)] * (256 - len(palette_rgb))
img.putpalette([channel for rgb in padded for channel in rgb])
return img
def _plain_center_crop_box( def _plain_center_crop_box(
@@ -403,21 +535,39 @@ def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Ima
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image: def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
"""RGB -> palette-quantized P-mode image, same size/orientation as """RGB -> palette-quantized P-mode image, same size/orientation as
`img` (no rotation here). dither_strength blends `img` toward its own `img` (no rotation here). Matches against the palette in OKLab space
flat (undithered) quantization before running Floyd-Steinberg on the (perceptual distance, not raw RGB -- see _rgb_to_oklab/
blend: at 0 there's zero quantization error left to diffuse (so the _nearest_palette_indices) and, when dithering, jitters that match
result IS the flat quantization, no dithering texture at all); at 1 with a Bayer ordered-dither pattern rather than Floyd-Steinberg error
it's `img` unchanged (full-strength dithering, this project's diffusion -- see _ordered_dither_oklab for why (short version: error
original always-on behavior); values between give a smooth continuum diffusion is inherently a serial per-pixel loop, and doing that in
of dithering intensity rather than an on/off toggle.""" Python for a full 800x480 panel blew well past this project's
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB) render-latency budget). dither_strength blends `img` toward its own
if dither_strength >= 1.0: flat (undithered) quantization before dithering the blend: at 0 the
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) blend IS the flat quantization (nothing left for the jitter to push
across a color boundary, so 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_rgb = palette_rgb or DEFAULT_PALETTE_RGB
palette_oklab = _rgb_to_oklab(np.asarray(palette_rgb, dtype=np.float32))
rgb_array = np.asarray(img.convert("RGB"))
oklab_pixels = _rgb_to_oklab(rgb_array)
if dither_strength <= 0.0: if dither_strength <= 0.0:
return img.quantize(palette=palette_image, dither=Image.Dither.NONE) flat_idx = _nearest_palette_indices(oklab_pixels, palette_oklab)
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB") return _index_array_to_p_image(flat_idx.astype(np.uint8), palette_rgb)
blended = Image.blend(flat, img, dither_strength)
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) if dither_strength < 1.0:
flat_idx = _nearest_palette_indices(oklab_pixels, palette_oklab)
palette_arr = np.asarray(palette_rgb, dtype=np.uint8)
flat_rgb = Image.fromarray(palette_arr[flat_idx], mode="RGB")
blended = Image.blend(flat_rgb, img.convert("RGB"), dither_strength)
oklab_pixels = _rgb_to_oklab(np.asarray(blended))
dithered_idx = _ordered_dither_oklab(oklab_pixels, palette_oklab)
return _index_array_to_p_image(dithered_idx, palette_rgb)
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes: def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
+1
View File
@@ -3,6 +3,7 @@ starlette==0.41.3
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
httpx==0.28.1 httpx==0.28.1
pillow==12.3.0 pillow==12.3.0
numpy==2.5.1
python-multipart==0.0.20 python-multipart==0.0.20
jinja2==3.1.5 jinja2==3.1.5
sqlalchemy==2.0.51 sqlalchemy==2.0.51
+2 -2
View File
@@ -135,7 +135,7 @@ def test_solid_border_draws_the_configured_palette_color(client, db_session):
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
img = _preview_pixels(client) img = _preview_pixels(client)
assert img.getpixel((0, 0)) == (207, 0, 15) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke assert img.getpixel((0, 0)) == (98, 32, 30) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke
def test_no_border_leaves_the_edge_unmarked(client, db_session): def test_no_border_leaves_the_edge_unmarked(client, db_session):
@@ -146,4 +146,4 @@ def test_no_border_leaves_the_edge_unmarked(client, db_session):
render path already painting it that color.""" render path already painting it that color."""
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
img = _preview_pixels(client) img = _preview_pixels(client)
assert img.getpixel((0, 0)) != (207, 0, 15) assert img.getpixel((0, 0)) != (98, 32, 30)