Ctrl K
ComunidadGuías…

epaper.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
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]