""" 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]