epaper.py
Rama main · réplica de lectura del repositorio original.
"""
E-paper rendering for the stateless per-display image endpoint (Track B).
Crops/fits a source image to an exact panel size and quantizes it to a fixed
device palette with Floyd–Steinberg dithering, so a "dumb" frame (DIY ESP32 +
Waveshare, Inky Impression, a TRMNL in BYOS mode, etc.) can fetch a ready-to-blit
image over HTTP without running the JS Canvas app.
No dithering existed in the app before this; see ROADMAP.md (Track B).
"""
import io
import math
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageEnhance, ImageMath, ImageOps
# --- Palettes -----------------------------------------------------------------
# Nominal sRGB anchors per device family. Per-panel colour tuning is deferred
# (the same task for every palette and best done against real hardware).
PALETTES = {
# E Ink Spectra 6 (E6): black, white, red, yellow, blue, green.
"spectra6": [
(0, 0, 0), (255, 255, 255), (191, 0, 0),
(255, 243, 56), (0, 0, 178), (0, 156, 72),
],
# E Ink ACeP / Gallery 7-colour: Spectra 6 set + orange.
"acep7": [
(0, 0, 0), (255, 255, 255), (191, 0, 0), (255, 243, 56),
(0, 0, 178), (0, 156, 72), (228, 120, 0),
],
# 2-bit greyscale (4 levels) — common small mono panels.
"gray4": [(0, 0, 0), (85, 85, 85), (170, 170, 170), (255, 255, 255)],
# 4-bit greyscale (16 levels).
"gray16": [(i * 17, i * 17, i * 17) for i in range(16)],
}
# Colour palettes get a saturation pre-boost; greyscale ones don't.
_COLOR_PALETTES = {"spectra6", "acep7"}
# --- E Ink Spectra 6 (EL133UF1) per-panel calibration (bench-tuned 2026-07-19) ------------------------
# The nominal spectra6 anchors above are near-pure and MORE saturated than the panel can physically
# show, so Floyd-Steinberg over-diffused (heavy grain) and dithered deep reds toward orange. On real
# glass we instead dither toward the panel's MEASURED primaries (Pimoroni inky's EL133UF1
# SATURATED_PALETTE) — which matches inky-native quality but stays UNIVERSAL: the server produces the
# dithered frame and any dumb client (Waveshare/ESP32/TRMNL) just blits it, no inky lib required.
# Order matches PALETTES["spectra6"]: black, white, red, yellow, blue, green.
SPECTRA6_DITHER_PALETTE = [
(0, 0, 0), (161, 164, 165), (156, 72, 75),
(208, 190, 71), (61, 59, 94), (58, 91, 70),
]
# The dithered frame is RE-ENCODED to these PURE primaries (same pixel indices) before output, so every
# client maps each colour unambiguously — including an inky client's set_image(), whose internal
# re-quantize would otherwise snap our muted blue/green (61,59,94 / 58,91,70) to BLACK.
SPECTRA6_OUTPUT_PALETTE = [
(0, 0, 0), (255, 255, 255), (255, 0, 0),
(255, 255, 0), (0, 0, 255), (0, 255, 0),
]
# --- The shipping tone recipe (ADR-098, 2026-08-29) -----------------------------------------------
# ⚠️ INTERIM AND LABEL-DERIVED. 0.75 comes from ADR-093 — 23 three-level human judgements — and it
# replaced `_adaptive_gamma`, which was condemned twice: worse than a constant (R^2 = -3.137, ADR-081)
# and the WORST option measured on dark paintings (gamma 1.40 on The Night Watch renders 85.0% of the
# shadow region as bare black, against 67.4% for no correction at all, ADR-094).
#
# 🔑 The physics says this number is not a preference at all. Once the panel's own white ink is the
# adapting white — which is what an observer adapts to on reflective media — the media-relative
# encoded ratio is srgb_encode(Y_white) = 0.641, and the exact ratio is a CURVE, ~0.33 in the shadows
# rising to 0.64 at the top, not a scale. (It coincides with the naive palette ratio 163.3/255 = 0.6405
# only because this white ink is near-neutral; that is not an identity.) The gap between that
# and the human mean of 0.73-0.80 is a one-parameter preference residual, not a free knob.
# The physics-first model re-decides this. CHANGING EITHER VALUE REQUIRES AN ADR.
SPECTRA6_WHITE_POINT = 0.75
SPECTRA6_GAMMA = 1.0
# --- Toe curve candidate (bench-validated 2026-09-22, won two blind panel sessions) ----------------
# INERT — gated by SPECTRA6_TOE_ENABLED. Production still ships the flat white-point/gamma LUT above
# until an ADR flips this. LO=40 and KNEE=54 come from `usable_window.digital_low` in
# `bench-eink/analysis/B1_transfer_function.json` — the panel's MEASURED usable floor, not a chosen
# aesthetic constant. Above KNEE it is exactly the shipping linear scale (`min(255, y*WHITE_POINT)`,
# same WHITE_POINT as above); below KNEE it lifts shadows on a gamma-2.2 toe instead of the flat LUT's
# floor differently below KNEE.
SPECTRA6_TOE_ENABLED = False
SPECTRA6_TOE_LO = 40.0
SPECTRA6_TOE_KNEE = 54.0
SPECTRA6_TOE_GAMMA = 2.2
# Requested extension -> (PIL format, media type).
VALID_FORMATS = {
"png": ("PNG", "image/png"),
"bmp": ("BMP", "image/bmp"),
}
VALID_FITS = ("cover", "contain")
# Per-aspect crop presets: "how do I best fill THIS screen shape with this work?" Authored per work
# (one normalized box per common panel shape) because a focal point can only SLIDE a fixed-size
# window — it can't CHOOSE one. Museum art clusters near-square, so cropping to a 9:16 panel discards
# ~53% of the median work; an explicit box lets the composition be picked instead of computed.
#
# NOT to be confused with build_pack's Tier-1 `crop_box`/`needs_frame_crop`, which answers a different
# question one level up — "what IS the artwork" (trim the photographed frame/mat/wall) — and is baked
# into the master bytes at pack build. By the time we render here, Tier 1 is already resolved.
ASPECT_CROP_KEYS = ("16:9", "9:16", "4:3", "3:4")
def normalize_crop_box(crop_box) -> tuple | None:
"""Validate a normalized art rectangle [x0,y0,x1,y1] in 0..1 -> tuple, else None.
Shared by the renderer and build_pack's Tier-1 frame-crop so the two can't drift on what counts
as a valid box. A near-full-frame box is deliberately None ("already fills the frame" — agents
return [0,0,1,1] to mean that), which keeps it a true no-op rather than a pointless re-encode.
"""
if not (isinstance(crop_box, (list, tuple)) and len(crop_box) == 4):
return None
try:
x0, y0, x1, y1 = (float(v) for v in crop_box)
except (TypeError, ValueError):
return None
if not (0.0 <= x0 < x1 <= 1.0 and 0.0 <= y0 < y1 <= 1.0):
return None
if x0 <= 0.002 and y0 <= 0.002 and x1 >= 0.998 and y1 >= 0.998:
return None
return (x0, y0, x1, y1)
def pick_crop_for_aspect(crops, w: int, h: int) -> tuple | None:
"""Choose the authored crop whose key ratio is nearest the requested w:h, or None.
Nearest-by-ratio (not exact-match) so an odd panel — an 1872x1404 e-ink, a 5:4 monitor — still
gets the closest sensible preset instead of silently falling back to a focal cover. Compared in
log space so 16:9-vs-4:3 and 9:16-vs-3:4 are judged by proportional, not absolute, distance.
"""
if not isinstance(crops, dict) or not crops or w <= 0 or h <= 0:
return None
target = w / h
best, best_dist = None, None
for key, box in crops.items():
try:
kw, kh = (float(v) for v in str(key).split(":"))
ratio = kw / kh
except (TypeError, ValueError, ZeroDivisionError):
continue
if ratio <= 0:
continue
dist = abs(math.log(target / ratio))
if best_dist is None or dist < best_dist:
box = normalize_crop_box(box)
if box is not None:
best, best_dist = box, dist
return best
_PALETTE_IMAGE_CACHE: dict = {}
def _fit_rgb(image_path: Path, w: int, h: int, fit: str = "cover",
focal: tuple = (0.5, 0.5), crop_box=None) -> Image.Image:
"""Open a source image, honour EXIF orientation, normalise ANY input mode
(JPEG/PNG/WebP, incl. CMYK / RGBA / palette / greyscale) to RGB, and fit it to
exactly w x h — cover-crop (anchored on the normalized focal point, default
centered) or contain (letterbox onto white; coerced to cover when the work's orientation
mismatches the panel's — ADR-148).
An optional normalized `crop_box` pre-crops to an authored region first; since that box is
authored AT the target aspect the subsequent fit is near-lossless when the panel matches it; if the panel's aspect differs, the focal point (remapped
into the cropped region) still anchors the cover-fit.
Absent/invalid box -> the focal path below, unchanged, so zero crop data is a true no-op.
Shared by the e-ink renderer and the full-colour (Frame TV) renderer so the
orient/crop behaviour can't drift between outputs.
"""
if fit not in VALID_FITS:
fit = "cover"
box = normalize_crop_box(crop_box)
with Image.open(image_path) as src:
img = ImageOps.exif_transpose(src)
# C5: composite any transparency onto white "paper" BEFORE flattening. A plain convert("RGB")
# fills transparent regions with black, which renders wrong on a paper-white e-ink/Frame panel.
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(bg, img).convert("RGB")
else:
img = img.convert("RGB")
if box is not None:
iw, ih = img.size
left, upper = round(box[0] * iw), round(box[1] * ih)
right, lower = round(box[2] * iw), round(box[3] * ih)
# a degenerate box on a tiny source would crop to nothing — keep the full frame instead
if right - left >= 1 and lower - upper >= 1:
img = img.crop((left, upper, right, lower))
# focal is in full-image space; remap it into the cropped region so the fit below
# still anchors on the subject when the panel aspect differs from the box's
focal = (
min(max((focal[0] - box[0]) / (box[2] - box[0]), 0.0), 1.0),
min(max((focal[1] - box[1]) / (box[3] - box[1]), 0.0), 1.0),
)
# ADR-085 closed by ADR-148: a work whose orientation doesn't match the panel's (portrait on a
# landscape panel, or the reverse) always CROPS TO FILL — never side margins. The authored
# `aspect_crops` box was applied above when present, else the focal-anchored cover below is the
# fallback. An explicit fit="contain" is still honoured when the orientations agree (or either is
# square), where it can only add thin bars rather than waste half the panel.
if fit == "contain" and img.width != img.height and w != h and (img.width > img.height) != (w > h):
fit = "cover"
if fit == "cover":
return ImageOps.fit(
img, (w, h), method=Image.Resampling.LANCZOS, centering=focal
)
# contain — letterbox onto white "paper"
scaled = img.copy()
scaled.thumbnail((w, h), Image.Resampling.LANCZOS)
fitted = Image.new("RGB", (w, h), (255, 255, 255))
fitted.paste(scaled, ((w - scaled.width) // 2, (h - scaled.height) // 2))
return fitted
def _flat_palette(colors) -> list:
"""Flatten a colour list to a padded 256-entry palette (repeat black; harmless duplicate)."""
flat: list = []
for rgb in colors:
flat.extend(rgb)
return flat + [0, 0, 0] * (256 - len(colors))
def _cached_palette_image(key: str, colors) -> Image.Image:
"""Build (once) a Pillow 'P' image carrying the palette, for quantize()."""
if key not in _PALETTE_IMAGE_CACHE:
pal = Image.new("P", (1, 1))
pal.putpalette(_flat_palette(colors))
_PALETTE_IMAGE_CACHE[key] = pal
return _PALETTE_IMAGE_CACHE[key]
def _palette_image(name: str) -> Image.Image:
return _cached_palette_image(name, PALETTES[name])
def _apply_gamma(img: Image.Image, gamma: float) -> Image.Image:
"""Per-channel gamma via LUT. gamma>1 darkens highlights/midtones.
NOT called in production since ADR-098 — the shipping path goes through `_tone_lut`. Retained
because four maintainer tools sweep gamma with it and the physics-first work needs it.
"""
if abs(gamma - 1.0) < 1e-3:
return img
lut = [round(255 * (i / 255) ** gamma) for i in range(256)]
return img.point(lut * len(img.getbands()))
@lru_cache(maxsize=32)
def _tone_lut(white_point: float, gamma: float) -> tuple:
"""The 256-entry tone LUT: white-point scale, then gamma. ONE definition of the recipe.
Four call sites used to build this by hand — production, eink_show, eink_scurve, eink_bench — and
four implementations of one LUT is a drift factory. Returns a tuple so it stays hashable and
cacheable; callers do `img.point(list(lut) * 3)`.
⚠️ ORDER IS LOAD-BEARING and matches `eink_bench.cmd_full`: white-point scales FIRST, gamma acts
on the scaled value. Swapping them is a different curve, and a judgement filed against the wrong
one is the error the bench harness exists to prevent.
PIL-only and integer — this runs on the Pi inside the render (no numpy in the app venv).
"""
wp = 1.0 if white_point <= 0 else white_point
out = []
for i in range(256):
v = min(255, int(round(i * wp)))
if abs(gamma - 1.0) >= 1e-3:
v = int(round(255 * (v / 255) ** gamma))
out.append(max(0, min(255, v)))
return tuple(out)
def _spectra6_toe(img: Image.Image) -> Image.Image:
"""Luminance-scalar toe curve — see SPECTRA6_TOE_ENABLED above for the constants and status.
Per pixel: `y = 0.2126R + 0.7152G + 0.0722B` (Rec.709 luma). Above KNEE, `target = min(255,
y*WHITE_POINT)` — shipping's existing linear scale. Below KNEE, `target = LO * (y/KNEE)**GAMMA` —
a gamma-2.2 curve down to 0, joining the linear branch at y=KNEE (both give ~LO there).
⚠️ ALL THREE CHANNELS ARE SCALED BY THE SINGLE SCALAR `target / y` — this is NOT applied
per-channel via `.point()` like `_tone_lut`. `_tone_lut` gets away with `.point()` only because
white-point-scale + gamma=1.0 is a pure linear map, which preserves the R:G:B ratio by
construction. This curve has a KNEE (a join of two different curves), so it is not linear, and a
per-channel LUT would push each channel toward the knee at a different point in its own range —
measured +0.20 to +0.27 saturation and up to 9.4 degrees hue rotation in the shadows. Deriving one
scalar from luminance and applying it uniformly preserves the ratio exactly, by definition.
PIL-only, no numpy (the Pi render has none) — `ImageMath` does the per-pixel arithmetic at C
speed instead of a Python loop over pixels.
Guard: at y==0 the toe formula already yields target==0 (0**GAMMA == 0), so `target / max(y,
eps)` never divides a nonzero numerator by zero — the `max(y, eps)` only keeps the division
defined, it never has to rescue a runaway ratio.
"""
r, g, b = (band.convert("F") for band in img.split())
# Pillow 12 removed `ImageMath.eval` (deprecated since 9.5); `unsafe_eval` is the literal
# continuation of the same function under a name that flags the string-eval risk — this module
# only ever evaluates our own fixed expression strings, never user input, so the rename carries
# no behavior change (bit-identical, see tests/test_epaper.py).
y = ImageMath.unsafe_eval("0.2126*r + 0.7152*g + 0.0722*b", r=r, g=g, b=b)
linear = ImageMath.unsafe_eval("min(y * wp, 255.0)", y=y, wp=SPECTRA6_WHITE_POINT)
toe = ImageMath.unsafe_eval(
"lo * (y / knee) ** gamma",
y=y, lo=SPECTRA6_TOE_LO, knee=SPECTRA6_TOE_KNEE, gamma=SPECTRA6_TOE_GAMMA,
)
above_knee = ImageMath.unsafe_eval("float(y >= knee)", y=y, knee=SPECTRA6_TOE_KNEE)
target = ImageMath.unsafe_eval(
"above*linear + (1.0-above)*toe", above=above_knee, linear=linear, toe=toe
)
scale = ImageMath.unsafe_eval("target / max(y, eps)", target=target, y=y, eps=1e-6)
# 🔑 Above the knee this curve IS shipping's linear scale, so a pixel there must come out
# BIT-IDENTICAL to shipping — and `band * scale + 0.5` does not deliver that. `_tone_lut` rounds
# with Python's `round()`, which is banker's rounding; `+ 0.5` then truncating is round-half-up.
# They disagree on 32 of the 256 byte values, so ~1 pixel in 3 comes out +-1 on some channel —
# and Floyd-Steinberg turns a +-1 input change into a wholesale reshuffle of dot positions.
# Measured before this fix: works with below_knee < 0.001, which this curve provably cannot
# touch, still differed from shipping in 24-50% OF PIXELS. That made the toe a tone change AND
# an unrelated dither-noise change on every work in the library, including the ones it does
# nothing to. Taking the LUT's own output above the knee makes the toe a strict superset of
# shipping: a work with no below-knee content renders exactly as it does today.
shipping = img.point(list(_tone_lut(SPECTRA6_WHITE_POINT, SPECTRA6_GAMMA)) * 3)
sr, sg, sb = (band.convert("F") for band in shipping.split())
def _blend(band, ship):
# `.convert("L")` truncates, which is the round-half-up half of `+ 0.5`; do it BEFORE the
# blend so the toe branch carries its own rounding and the shipping branch carries the LUT's.
toed = ImageMath.unsafe_eval(
"max(min(band * scale + 0.5, 255.0), 0.0)", band=band, scale=scale
).convert("L").convert("F")
return ImageMath.unsafe_eval(
"above * ship + (1.0 - above) * toed", above=above_knee, ship=ship, toed=toed
).convert("L")
return Image.merge("RGB", (_blend(r, sr), _blend(g, sg), _blend(b, sb)))
# --- Chroma correction: a HUE-CONDITIONED curve (bench-derived 2026-08-28, ADR-088) ---------------
# The dither's colour failure is gamut compression toward the hull: LOW-chroma tones acquire false
# colour because they must be rebuilt from vivid primaries, while genuinely saturated colour is
# already served. So attenuate by how saturated a pixel already is -- s' = max(s**k, s*floor).
#
# The FLOOR cannot be one number per image. Measured on the two works that disagree: Flaming June's
# skin (PIL hue ~12-18) must lose its colour or it reads orange, while Sunflowers' pale wall (hue
# ~32) must keep its colour because it genuinely is yellow. A per-image scalar cannot say "this
# faint colour is spurious and that one is real" -- but the pixel's HUE can: the two populations
# separate at 0.999 accuracy on hue alone (overlap 0.002).
#
# The mechanism is which ink can serve the hue. The wall at 32 sits essentially ON the yellow ink
# (36), so its colour survives the dither honestly; skin at 12-18 is far from both yellow(36) and
# red(253), so whatever chroma it keeps gets rebuilt from inks of the wrong hue and reads false.
# Hence: floor high where an ink matches the hue, low where none does.
_CHROMATIC_INK_HUES = None
def _chromatic_ink_hues() -> list:
"""PIL-HSV hues (0..255) of the panel's CHROMATIC inks -- black/white carry no hue.
Derived from SPECTRA6_DITHER_PALETTE rather than hardcoded, so re-measuring the panel's
primaries moves the chroma rule with it instead of silently desynchronising from the dither.
"""
global _CHROMATIC_INK_HUES
if _CHROMATIC_INK_HUES is None:
hues = []
for rgb in SPECTRA6_DITHER_PALETTE:
px = Image.new("RGB", (1, 1), tuple(rgb)).convert("HSV")
h, s, _v = px.getpixel((0, 0))
if s > 32: # black(0,0,0) and white(161,164,165) have no meaningful hue
hues.append(float(h))
_CHROMATIC_INK_HUES = hues
return _CHROMATIC_INK_HUES
def _hue_error(hue: float) -> float:
"""Smallest circular distance (PIL hue units, 0..128) from `hue` to any chromatic ink."""
best = 128.0
for ih in _chromatic_ink_hues():
d = abs(hue - ih) % 256.0
best = min(best, min(d, 256.0 - d))
return best
def _hue_error_fraction(hue: float) -> float:
"""How unserved a hue is, normalised by its LOCAL gap: 0.0 on an ink, 1.0 midway between two.
A fixed absolute cutoff cannot work here, because the panel's chromatic inks are unevenly
spaced (yellow 36, green 100, blue 172, red 253). Measured 2026-08-28: with an absolute
cutoff of 20 units the floor reaches ZERO across 9.4% / 12.5% / 16.0% of the circle in the
yellow-green, green-blue and blue-red gaps and across 0.0% of the narrow red-yellow arc --
38% of all hues annihilated, and the one surviving arc is exactly the WARM one. On the panel
that showed up as a woodblock's pale blue vanishing (hue ~130-150, dead centre of the
green-blue gap) while warm-dominated oils were served well. That is an accident of ink
spacing, not a colour-science decision.
Normalising by the half-gap makes every gap behave the same: full protection on an ink,
falling to none only at the single midpoint hue between two inks.
"""
inks = sorted(_chromatic_ink_hues())
ring = inks + [inks[0] + 256.0]
h = hue % 256.0
if h < inks[0]:
h += 256.0
for lo, hi in zip(ring, ring[1:]):
if lo <= h <= hi:
half = (hi - lo) / 2.0
return min(1.0, min(h - lo, hi - h) / half) if half > 0 else 0.0
return 1.0
def apply_chroma_curve(img: Image.Image, chroma_gamma: float, floor_max: float,
hue_e0: float, bands: int = 48, gap_normalised: bool = False,
floor_min: float = 0.0) -> Image.Image:
"""s' = max(s**k, s*floor(hue)) on HSV saturation, with floor keyed on hue-to-ink distance.
floor(h) = floor_max * max(0, 1 - hue_error(h) / hue_e0)
`hue_e0` is the hue distance (PIL units; 256 = full circle) at which a hue is considered
unservable by any ink and its faint colour is crushed entirely.
Implemented as `bands` hue slices rather than a true 2-D transform because Pillow's `point()`
is per-channel: each slice gets its own saturation LUT and is pasted through a hue mask. All
C-speed, no numpy -- which is the point, since this has to run on the Pi inside the render.
Banding in the FLOOR is second-order (it only sets a lower bound on chroma), so ~48 slices is
visually indistinguishable from a continuous curve.
"""
if abs(chroma_gamma - 1.0) < 1e-3 and floor_max <= 0.0:
return img
hue_c, sat_c, val_c = img.convert("HSV").split()
out_sat = sat_c.copy()
for b in range(bands):
lo = b * 256 // bands
hi = (b + 1) * 256 // bands
if hi <= lo:
continue
mid = (lo + hi - 1) / 2.0
if gap_normalised:
unserved = _hue_error_fraction(mid)
else:
unserved = min(1.0, _hue_error(mid) / max(hue_e0, 1e-6))
floor = max(floor_min, floor_max * (1.0 - unserved))
lut = [min(255, int(round(255.0 * max((i / 255.0) ** chroma_gamma, (i / 255.0) * floor))))
for i in range(256)]
mask = hue_c.point([255 if lo <= i < hi else 0 for i in range(256)])
out_sat.paste(sat_c.point(lut), mask=mask)
return Image.merge("HSV", (hue_c, out_sat, val_c)).convert("RGB")
@lru_cache(maxsize=128)
def render_for_epaper(
image_path: Path,
w: int,
h: int,
palette: str = "spectra6",
fit: str = "cover",
focal: tuple = (0.5, 0.5),
fmt: str = "png",
enhance: bool = True,
crop_box: tuple | None = None,
) -> bytes:
"""Render a source image to a palette-dithered bitmap sized exactly w x h.
Cached on its arguments like get_optimized_image(); images in _Library are
content-stable so path is a sufficient key. NOTE: because of that cache every argument must be
hashable — pass `crop_box` as a TUPLE, never the list it arrives as from JSON.
"""
if palette not in PALETTES:
raise ValueError(f"Unknown palette '{palette}'. Options: {', '.join(PALETTES)}")
fmt = fmt.lower()
if fmt not in VALID_FORMATS:
raise ValueError(f"Unknown format '{fmt}'. Options: {', '.join(VALID_FORMATS)}")
if fit not in VALID_FITS:
fit = "cover"
fitted = _fit_rgb(image_path, w, h, fit, focal, crop_box)
if palette == "spectra6":
# Bench-calibrated path: a CONSTANT tone recipe (ADR-098), then Floyd-Steinberg dither toward
# the panel's REAL primaries, then re-encode to pure primaries so any client maps it correctly.
# `enhance` gates the tone correction (default on). See SPECTRA6_WHITE_POINT above for why the
# constant replaced the per-image `_adaptive_gamma`, and why 0.75 is interim.
if enhance:
if SPECTRA6_TOE_ENABLED:
fitted = _spectra6_toe(fitted)
else:
fitted = fitted.point(list(_tone_lut(SPECTRA6_WHITE_POINT, SPECTRA6_GAMMA)) * 3)
quantized = fitted.quantize(
palette=_cached_palette_image("_spectra6_dither", SPECTRA6_DITHER_PALETTE),
dither=Image.Dither.FLOYDSTEINBERG,
)
quantized.putpalette(_flat_palette(SPECTRA6_OUTPUT_PALETTE))
else:
if enhance:
# Gentle pre-boost so the tiny palette doesn't read as muddy.
fitted = ImageEnhance.Contrast(fitted).enhance(1.12)
if palette in _COLOR_PALETTES:
fitted = ImageEnhance.Color(fitted).enhance(1.25)
quantized = fitted.quantize(
palette=_palette_image(palette), dither=Image.Dither.FLOYDSTEINBERG
)
buf = io.BytesIO()
pil_format, _media = VALID_FORMATS[fmt]
if pil_format == "BMP":
# 24-bit RGB BMP is the broadest common denominator for firmware
# that reads pixels and maps to its own palette. Packed/raw 1-bit
# device buffers are deferred (v2).
quantized.convert("RGB").save(buf, format="BMP")
else:
quantized.save(buf, format="PNG", optimize=True)
return buf.getvalue()
@lru_cache(maxsize=64)
def render_fullcolor(
image_path: Path,
w: int,
h: int,
fit: str = "cover",
focal: tuple = (0.5, 0.5),
quality: int = 90,
crop_box: tuple | None = None,
) -> bytes:
"""Render a source image to a full-colour JPEG sized exactly w x h, for displays
that want a normal image (e.g. a Samsung Frame TV's Art Mode at 3840x2160) — same
EXIF-orient + cover/contain framing as the e-ink path, but no palette quantization
or dithering. Cached on its arguments like render_for_epaper() — so `crop_box` must
be a hashable TUPLE."""
fitted = _fit_rgb(image_path, w, h, fit, focal, crop_box)
buf = io.BytesIO()
fitted.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
def media_type_for(ext: str) -> str:
"""image/png or image/bmp for a requested extension."""
return VALID_FORMATS[ext.lower()][1]