From fab9017f500e0e81945be41667d2a3015859b4bc Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 18:03:18 +0100 Subject: [PATCH 01/16] fix: use sRGB space for error diffusion, conditional auto tone compression --- packages/python/pyproject.toml | 3 +- .../python/src/epaper_dithering/algorithms.py | 56 ++++++++++++------- .../python/src/epaper_dithering/tone_map.py | 27 ++++++--- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index fbd731d..4cc8aa8 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -75,9 +75,10 @@ argument-rgx = "^[a-zA-Z_][a-zA-Z0-9_]*$" [tool.pylint.design] max-args = 8 max-positional-arguments = 8 -max-locals = 40 +max-locals = 50 max-returns = 10 max-branches = 15 +max-statements = 60 [tool.pylint.messages_control] disable = [ diff --git a/packages/python/src/epaper_dithering/algorithms.py b/packages/python/src/epaper_dithering/algorithms.py index 5a2ad21..d5ba906 100644 --- a/packages/python/src/epaper_dithering/algorithms.py +++ b/packages/python/src/epaper_dithering/algorithms.py @@ -228,11 +228,25 @@ def error_diffusion_dither( # Pre-compute palette LAB components for scalar per-pixel matching palette_L, palette_a, palette_b, palette_C = precompute_palette_lab(palette_linear) - # Pre-extract palette linear RGB as Python floats (avoids numpy indexing in loop) - palette_rgb = [ - (float(palette_linear[i, 0]), float(palette_linear[i, 1]), float(palette_linear[i, 2])) - for i in range(len(palette_srgb)) - ] + # Build the error-diffusion buffer in sRGB space. + # Colour matching uses linear+LAB (perceptually accurate), but error is + # accumulated in sRGB so that mid-tone brightness matches human perception. + # A pixel at sRGB 128 needs ~50% dithering dots, not ~21% (what linear gives). + _lc = np.clip(pixels_linear, 0.0, 1.0) + pixels_srgb_float: np.ndarray = ( + np.where( + _lc <= 0.0031308, + _lc * 12.92, + 1.055 * np.power(np.maximum(_lc, 0.0), 1.0 / 2.4) - 0.055, + ) + * 255.0 + ) # float32, range [0, 255] + + # Pre-extract palette sRGB as Python floats for error computation + palette_srgb_f = [(float(r), float(g), float(b)) for r, g, b in palette_srgb] + + # LUT: sRGB integer [0-255] → linear float [0-1], avoids per-pixel power calls + _lut = [i / (255.0 * 12.92) if i / 255.0 <= 0.04045 else ((i / 255.0 + 0.055) / 1.055) ** 2.4 for i in range(256)] # Pre-normalize kernel weights (eliminates division per pixel) normalized_offsets = [(dx, dy, weight / kernel.divisor) for dx, dy, weight in kernel.offsets] @@ -250,23 +264,27 @@ def error_diffusion_dither( x_range = range(width) # Left to right for x in x_range: - # Read current pixel as scalars (clamped to valid range) - # Note: pixels_linear buffer can be outside [0, 1] due to error accumulation - r = max(0.0, min(1.0, float(pixels_linear[y, x, 0]))) - g = max(0.0, min(1.0, float(pixels_linear[y, x, 1]))) - b = max(0.0, min(1.0, float(pixels_linear[y, x, 2]))) + # Read sRGB value with accumulated error, clamped to [0, 255] + r_s = max(0.0, min(255.0, float(pixels_srgb_float[y, x, 0]))) + g_s = max(0.0, min(255.0, float(pixels_srgb_float[y, x, 1]))) + b_s = max(0.0, min(255.0, float(pixels_srgb_float[y, x, 2]))) + + # Convert to linear for LCH-weighted LAB colour matching via LUT + r_lin = _lut[int(r_s)] + g_lin = _lut[int(g_s)] + b_lin = _lut[int(b_s)] # Find closest palette color using LCH-weighted LAB distance - new_idx = _match_pixel_lch(r, g, b, palette_L, palette_a, palette_b, palette_C) + new_idx = _match_pixel_lch(r_lin, g_lin, b_lin, palette_L, palette_a, palette_b, palette_C) # Store palette index output_pixels[y, x] = new_idx - # Calculate quantization error per channel (in linear space) - pr, pg, pb = palette_rgb[new_idx] - err_r = r - pr - err_g = g - pg - err_b = b - pb + # Calculate quantization error in sRGB space + pr, pg, pb = palette_srgb_f[new_idx] + err_r = r_s - pr + err_g = g_s - pg + err_b = b_s - pb # Distribute error using pre-normalized kernel weights for dx, dy, nw in normalized_offsets: @@ -278,9 +296,9 @@ def error_diffusion_dither( # Check bounds and distribute error if 0 <= nx < width and 0 <= ny < height: - pixels_linear[ny, nx, 0] += err_r * nw - pixels_linear[ny, nx, 1] += err_g * nw - pixels_linear[ny, nx, 2] += err_b * nw + pixels_srgb_float[ny, nx, 0] += err_r * nw + pixels_srgb_float[ny, nx, 1] += err_g * nw + pixels_srgb_float[ny, nx, 2] += err_b * nw # ===== Output Assembly ===== output.putdata(output_pixels.flatten()) diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index 2118964..48b52a3 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -85,15 +85,17 @@ def auto_compress_dynamic_range( pixels_linear: np.ndarray, palette_linear: np.ndarray, ) -> np.ndarray: - """Auto-levels dynamic range compression fitted to display capabilities. + """Conditionally compress dynamic range to display capabilities. - Analyzes the image's actual luminance distribution and remaps it to the - display's [black_Y, white_Y] range. Uses 2nd/98th percentiles to ignore - outliers, maximizing contrast within the display's capabilities. + Analyzes the image's actual luminance distribution (2nd/98th percentiles) + and only applies compression when the image content genuinely exceeds the + display's reproducible range. Images that already fit within the display's + [black_Y, white_Y] range are returned unchanged (ICC Black Point + Compensation style). - For full-range images this is equivalent to compress_dynamic_range(..., 1.0). - For narrow-range images (e.g., all midtones) this preserves more contrast - by stretching the used range to fill the display range. + This avoids the over-compression that occurs when unconditionally stretching + a well-exposed image to fill the display range — which washes out colors + by pushing already-correct highlights above the display white point. Args: pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. @@ -122,6 +124,17 @@ def auto_compress_dynamic_range( # Uniform image: fall back to standard linear compression return compress_dynamic_range(pixels_linear, palette_linear, 1.0) + # Only compress if the image content genuinely exceeds the display range. + # Allow 10% of display_range as tolerance to avoid compressing images that + # merely approach the display limits without meaningfully clipping. + TOLERANCE = 0.10 + fits_shadows = p_low >= black_Y - TOLERANCE * display_range + fits_highlights = p_high <= white_Y + TOLERANCE * display_range + + if fits_shadows and fits_highlights: + # Image already fits within the display's reproducible range — no change. + return pixels_linear + # Remap: [p_low, p_high] → [black_Y, white_Y] normalized_Y = (Y - p_low) / image_range target_Y = black_Y + normalized_Y * display_range From 157bd6fb180a2ad9957fb63dc74c2c22542ff6ee Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 18:03:50 +0100 Subject: [PATCH 02/16] feat: add toneCompression parameter (number | 'auto') to errorDiffusionDither, directPaletteMap and orderedDither for parity with the Python API. --- packages/javascript/src/algorithms.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/javascript/src/algorithms.ts b/packages/javascript/src/algorithms.ts index 69f8814..b5c525d 100644 --- a/packages/javascript/src/algorithms.ts +++ b/packages/javascript/src/algorithms.ts @@ -1,6 +1,6 @@ import { SRGB_TO_LINEAR_LUT } from './color_space'; import { precomputePaletteLab, matchPixelLch } from './color_space_lab'; -import { autoCompressDynamicRange } from './tone_map'; +import { autoCompressDynamicRange, compressDynamicRange } from './tone_map'; import type { RGB, ImageBuffer, PaletteImageBuffer, ColorPalette } from './types'; import { ColorScheme, getPalette } from './palettes'; @@ -72,6 +72,7 @@ function errorDiffusionDither( scheme: ColorScheme | ColorPalette, kernel: ErrorKernel[], serpentine: boolean, + toneCompression: number | 'auto' = 'auto', ): PaletteImageBuffer { const { width, height } = image; const palette = resolvePalette(scheme); @@ -83,7 +84,11 @@ function errorDiffusionDither( // Tone compression for measured palettes only if (typeof scheme !== 'number') { - autoCompressDynamicRange(pixels, width, height, paletteLinear); + if (toneCompression === 'auto') { + autoCompressDynamicRange(pixels, width, height, paletteLinear); + } else if (toneCompression > 0) { + compressDynamicRange(pixels, width, height, paletteLinear, toneCompression); + } } // Pre-compute palette LAB for the hot loop @@ -152,6 +157,7 @@ function errorDiffusionDither( export function directPaletteMap( image: ImageBuffer, scheme: ColorScheme | ColorPalette, + toneCompression: number | 'auto' = 'auto', ): PaletteImageBuffer { const { width, height } = image; const palette = resolvePalette(scheme); @@ -160,7 +166,11 @@ export function directPaletteMap( const pixels = buildLinearBuffer(image); if (typeof scheme !== 'number') { - autoCompressDynamicRange(pixels, width, height, paletteLinear); + if (toneCompression === 'auto') { + autoCompressDynamicRange(pixels, width, height, paletteLinear); + } else if (toneCompression > 0) { + compressDynamicRange(pixels, width, height, paletteLinear, toneCompression); + } } const { L: palL, a: palA, b: palB, C: palC } = precomputePaletteLab(paletteLinear); @@ -181,6 +191,7 @@ export function directPaletteMap( export function orderedDither( image: ImageBuffer, scheme: ColorScheme | ColorPalette, + toneCompression: number | 'auto' = 'auto', ): PaletteImageBuffer { const { width, height } = image; const palette = resolvePalette(scheme); @@ -189,7 +200,11 @@ export function orderedDither( const pixels = buildLinearBuffer(image); if (typeof scheme !== 'number') { - autoCompressDynamicRange(pixels, width, height, paletteLinear); + if (toneCompression === 'auto') { + autoCompressDynamicRange(pixels, width, height, paletteLinear); + } else if (toneCompression > 0) { + compressDynamicRange(pixels, width, height, paletteLinear, toneCompression); + } } const { L: palL, a: palA, b: palB, C: palC } = precomputePaletteLab(paletteLinear); From d60c79773623dae363a6688f2bc36b2999f5455f Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 18:04:07 +0100 Subject: [PATCH 03/16] feat: add comparison script --- packages/python/scripts/compare.py | 225 +++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 packages/python/scripts/compare.py diff --git a/packages/python/scripts/compare.py b/packages/python/scripts/compare.py new file mode 100644 index 0000000..7755247 --- /dev/null +++ b/packages/python/scripts/compare.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Visual dithering comparison — produces three focused contact sheets. + + schemes.png All color schemes, Burkes, tc=0 + algorithms_{NAME}.png All dither modes for every color scheme and measured palette + tone_compression.png Measured palettes: tc=0 | tc=auto | tc=1.0 + +Add --docs to also generate images for the README in docs/images/: + Full resolution in docs/images/ + 50% thumbnails in docs/images/thumbs/ + +Usage: + uv run scripts/compare.py [image] [--width W] [--height H] [--out DIR] [--docs] + +Examples: + uv run scripts/compare.py + uv run scripts/compare.py photo.jpg --width 400 --height 300 + uv run scripts/compare.py photo.jpg --docs +""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import epaper_dithering as _lib +from epaper_dithering import ColorPalette, ColorScheme, DitherMode, dither_image +from PIL import Image, ImageDraw, ImageFont + +DISPLAY_WIDTH = 800 +DISPLAY_HEIGHT = 480 +LABEL_H = 32 +FONT_SIZE = 18 +DOCS_SCALE = 0.5 + +# Discovered automatically from the library — no manual updates needed +ALL_ALGORITHMS: list[DitherMode] = list(DitherMode) + +COLOR_SCHEMES: list[tuple[str, ColorScheme]] = [(s.name, s) for s in ColorScheme] + +MEASURED_PALETTES: list[tuple[str, ColorPalette]] = [ + (name, getattr(_lib, name)) for name in _lib.__all__ if isinstance(getattr(_lib, name), ColorPalette) +] + +ALL_PALETTES_FOR_ALGO = COLOR_SCHEMES + MEASURED_PALETTES # type: ignore[assignment] +COLOR_SCHEME_NAMES = {s for s, _ in COLOR_SCHEMES} + + +def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for name in ("DejaVuSans.ttf", "Arial.ttf", "FreeSans.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + pass + return ImageFont.load_default() + + +def tc_str(tc: float | str) -> str: + if tc == "auto": + return "tc=auto" + if tc == 1.0 or tc == 1: + return "tc=100%" + return f"tc={tc}" + + +def render(src: Image.Image, scheme: object, mode: DitherMode, tc: float | str) -> tuple[Image.Image, float]: + t0 = time.perf_counter() + dithered = dither_image(src, scheme, mode, tone_compression=tc) + return dithered.convert("RGB"), time.perf_counter() - t0 + + +def make_sheet( + cells: list[tuple[str, Image.Image]], + cols: int, + iw: int, + ih: int, + scale: float = 1.0, +) -> Image.Image: + """Arrange (label, image) cells into a labeled grid, optionally scaled.""" + tw = int(iw * scale) + th = int(ih * scale) + label_h = max(1, int(LABEL_H * scale)) + font = load_font(max(8, int(FONT_SIZE * scale))) + + rows = (len(cells) + cols - 1) // cols + sheet = Image.new("RGB", (tw * cols, (th + label_h) * rows), (40, 40, 40)) + draw = ImageDraw.Draw(sheet) + for i, (label, img) in enumerate(cells): + col, row = i % cols, i // cols + x, y = col * tw, row * (th + label_h) + sheet.paste(img.resize((tw, th), Image.LANCZOS) if scale != 1.0 else img, (x, y)) + draw.text((x + 4, y + th + 4), label, fill=(220, 220, 220), font=font) + return sheet + + +def save(sheet: Image.Image, path: Path, also_thumb: bool = False) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + sheet.save(path) + print(f" → {path}") + if also_thumb: + thumb_path = path.parent / "thumbs" / path.name + thumb_path.parent.mkdir(parents=True, exist_ok=True) + w, h = sheet.size + sheet.resize((int(w * DOCS_SCALE), int(h * DOCS_SCALE)), Image.LANCZOS).save(thumb_path) + print(f" → {thumb_path}") + + +def run(image_path: Path, out_dir: Path, width: int, height: int, docs: bool, docs_algo: str, docs_tc: str) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + src = Image.open(image_path).convert("RGB").resize((width, height), Image.LANCZOS) + iw, ih = width, height + + print(f"Input: {image_path} ({width}×{height})\n") + + # ------------------------------------------------------------------ + # 1. schemes.png — all color schemes, Burkes, tc=0 + # ------------------------------------------------------------------ + print("── schemes ──────────────────────────────────") + scheme_cells: list[tuple[str, Image.Image]] = [] + for label, scheme in COLOR_SCHEMES: + img, t = render(src, scheme, DitherMode.BURKES, 0) + scheme_cells.append((f"{label} · Burkes · tc=0", img)) + print(f" {label:<16} {t * 1000:>6.0f}ms") + save(make_sheet(scheme_cells, 4, iw, ih), out_dir / "schemes.png") + print() + + # ------------------------------------------------------------------ + # 2. algorithms_{NAME}.png — all dither modes for every palette + # ------------------------------------------------------------------ + print("── algorithms ───────────────────────────────") + algo_cells_by_name: dict[str, list[tuple[str, Image.Image]]] = {} + for palette_label, palette in ALL_PALETTES_FOR_ALGO: + tc: float | str = 0 if palette_label in COLOR_SCHEME_NAMES else "auto" + cells: list[tuple[str, Image.Image]] = [] + for mode in ALL_ALGORITHMS: + img, t = render(src, palette, mode, tc) + cells.append((f"{mode.name} · {palette_label} · {tc_str(tc)}", img)) + print(f" {palette_label:<16} {mode.name:<22} {t * 1000:>6.0f}ms") + algo_cells_by_name[palette_label] = cells + save(make_sheet(cells, 3, iw, ih), out_dir / f"algorithms_{palette_label}.png") + print() + + # ------------------------------------------------------------------ + # 3. tone_compression.png — measured palettes, tc=0 | tc=auto | tc=1.0 + # ------------------------------------------------------------------ + print("── tone_compression ─────────────────────────") + tc_cells: list[tuple[str, Image.Image]] = [] + tc_cells_solum: list[tuple[str, Image.Image]] = [] + for pal_label, palette in MEASURED_PALETTES: + for tc_val in [0, "auto", 1.0]: + img, t = render(src, palette, DitherMode.BURKES, tc_val) + label = f"{pal_label} · Burkes · {tc_str(tc_val)}" + tc_cells.append((label, img)) + if pal_label == "SOLUM_BWR": + tc_cells_solum.append((label, img)) + print(f" {pal_label:<14} {tc_str(tc_val):<10} {t * 1000:>6.0f}ms") + save(make_sheet(tc_cells, 3, iw, ih), out_dir / "tone_compression.png") + print() + + # ------------------------------------------------------------------ + # --docs: full-res + 50% thumbnails for the README + # ------------------------------------------------------------------ + if docs: + docs_dir = Path(__file__).parent.parent / "docs" / "images" + print(f"── docs → {docs_dir}") + + # algorithms: use the requested palette, fall back to first available + algo_palette_names = list(algo_cells_by_name.keys()) + algo_key = ( + docs_algo + if docs_algo in algo_cells_by_name + else next((n for n in ["BWR", "MONO"] if n in algo_cells_by_name), algo_palette_names[0]) + ) + if algo_key != docs_algo: + print(f" (note: --docs-algo-palette '{docs_algo}' not found, using '{algo_key}')") + + # tone compression: use the requested palette, fall back to first measured + tc_palette_names = [name for name, _ in MEASURED_PALETTES] + tc_key = docs_tc if docs_tc in tc_palette_names else tc_palette_names[0] + if tc_key != docs_tc: + print(f" (note: --docs-tc-palette '{docs_tc}' not found, using '{tc_key}')") + tc_cells_docs = [cell for cell in tc_cells if cell[0].startswith(tc_key)] + + save(make_sheet(scheme_cells, 4, iw, ih), docs_dir / "schemes.png", also_thumb=True) + save(make_sheet(algo_cells_by_name[algo_key], 3, iw, ih), docs_dir / "algorithms.png", also_thumb=True) + print(f" (palette: {algo_key})") + save(make_sheet(tc_cells_docs, 3, iw, ih), docs_dir / "tone_compression.png", also_thumb=True) + print(f" (palette: {tc_key})") + + +def main() -> None: + here = Path(__file__).parent.parent + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("image", nargs="?", default=str(here / "marienplatz.jpg")) + parser.add_argument("--width", type=int, default=DISPLAY_WIDTH) + parser.add_argument("--height", type=int, default=DISPLAY_HEIGHT) + parser.add_argument("--out", default=str(here / "compare_out")) + parser.add_argument("--docs", action="store_true", help="Also write images to docs/images/ for the README") + parser.add_argument( + "--docs-algo-palette", default="BWR", metavar="NAME", help="Palette for docs algorithms image (default: BWR)" + ) + parser.add_argument( + "--docs-tc-palette", + default="SOLUM_BWR", + metavar="NAME", + help="Palette for docs tone_compression image (default: SOLUM_BWR)", + ) + args = parser.parse_args() + run( + Path(args.image), + Path(args.out), + args.width, + args.height, + args.docs, + args.docs_algo_palette, + args.docs_tc_palette, + ) + + +if __name__ == "__main__": + main() From 79040946367ab20f38cce9ca5746466d499fefa3 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 18:18:44 +0100 Subject: [PATCH 04/16] feat: change lab to oklab --- .../src/epaper_dithering/color_space_lab.py | 101 +++++++++--------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/packages/python/src/epaper_dithering/color_space_lab.py b/packages/python/src/epaper_dithering/color_space_lab.py index dd8b837..d01e657 100644 --- a/packages/python/src/epaper_dithering/color_space_lab.py +++ b/packages/python/src/epaper_dithering/color_space_lab.py @@ -1,8 +1,9 @@ -"""LAB color space conversions and LCH-weighted color matching for dithering. +"""OKLab color space conversions and LCH-weighted color matching for dithering. -CIELAB (L*a*b*) is a perceptually uniform color space where equal distances -represent equal perceived color differences. This module uses LAB for color -matching with a dithering-optimized LCH (Lightness-Chroma-Hue) weighting. +OKLab (Ottosson 2020) is a perceptually uniform color space with better hue +linearity than CIELAB. Equal distances in OKLab represent more consistent +perceived color differences across all hue angles, including the yellow/purple +regions where CIELAB is known to warp. Why LCH Weighting for Dithering: --------------------------------- @@ -13,7 +14,7 @@ - Error diffusion CANNOT compensate for hue errors (no way to mix green from non-green palette colors) -The LCH decomposition uses the CIE identity: da^2 + db^2 = dC^2 + dH^2, +The LCH decomposition uses the identity: da^2 + db^2 = dC^2 + dH^2, allowing us to weight the three perceptual dimensions independently: - Lightness (WL=0.5): de-emphasized, error diffusion handles this - Chroma (WC=1.0): standard weight @@ -21,7 +22,8 @@ References: ---------- -- CIE 1976 L*a*b* color space +- Ottosson, B. — "A perceptual color space for image processing", 2020 + https://bottosson.github.io/posts/oklab/ - http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html """ @@ -46,15 +48,26 @@ dtype=np.float64, ) -# D65 reference white point -_XN = 0.95047 -_YN = 1.00000 -_ZN = 1.08883 -_D65_WHITE = np.array([_XN, _YN, _ZN], dtype=np.float64) +# OKLab matrices (Ottosson 2020) +# M1: XYZ → LMS (Hunt-Pointer-Estevez with Bradford adaptation) +_M1 = np.array( + [ + [0.8189330101, 0.3618667424, -0.1288597137], + [0.0329845436, 0.9293118715, 0.0361456387], + [0.0482003018, 0.2643662691, 0.6338517070], + ], + dtype=np.float64, +) -# CIE LAB constants -_EPSILON = 216.0 / 24389.0 # 0.008856... -_KAPPA = 24389.0 / 27.0 # 903.296... +# M2: cbrt(LMS) → OKLab +_M2 = np.array( + [ + [0.2104542553, 0.7936177850, -0.0040720468], + [1.9779984951, -2.4285922050, 0.4505937099], + [0.0259040371, 0.7827717662, -0.8086757660], + ], + dtype=np.float64, +) # LCH distance weights for dithering _WL = 0.5 # lightness: de-emphasized (error diffusion compensates) @@ -68,40 +81,25 @@ def rgb_to_lab(rgb: np.ndarray) -> np.ndarray: - """Convert linear RGB to CIELAB color space. + """Convert linear RGB to OKLab color space. Args: rgb: Linear RGB values in [0, 1] range. Shape: (..., 3) Returns: - LAB values. L in [0, 100], a and b in [-128, 127]. Shape: (..., 3) + OKLab values. L in [0, 1], a and b in [-0.5, 0.5]. Shape: (..., 3) """ xyz = rgb @ _M_RGB_TO_XYZ.T - xyz_norm = xyz / _D65_WHITE - - def f(t: np.ndarray) -> np.ndarray: - mask = t > _EPSILON - result = np.empty_like(t) - result[mask] = np.cbrt(t[mask]) - result[~mask] = (_KAPPA * t[~mask] + 16.0) / 116.0 - return result - - fx = f(xyz_norm[..., 0]) - fy = f(xyz_norm[..., 1]) - fz = f(xyz_norm[..., 2]) - - L = 116.0 * fy - 16.0 - a = 500.0 * (fx - fy) - b = 200.0 * (fy - fz) - - return np.stack([L, a, b], axis=-1) + lms = xyz @ _M1.T + lms_ = np.cbrt(lms) + return lms_ @ _M2.T def find_closest_palette_color_lab( rgb_linear: np.ndarray, palette_linear: np.ndarray, ) -> np.ndarray: - """Find closest palette color using LCH-weighted LAB distance. + """Find closest palette color using LCH-weighted OKLab distance. Optimized for batch operations (entire image at once). Uses numpy broadcasting to compute distances for all pixels simultaneously. @@ -145,28 +143,27 @@ def find_closest_palette_color_lab( # ============================================================================= -def _lab_f(t: float) -> float: - """CIE LAB nonlinear function (scalar version).""" - if t > _EPSILON: - return float(t ** (1.0 / 3.0)) - return (_KAPPA * t + 16.0) / 116.0 - - def _rgb_to_lab_scalar(r: float, g: float, b: float) -> tuple[float, float, float]: - """Convert a single linear RGB pixel to LAB (scalar, no numpy).""" + """Convert a single linear RGB pixel to OKLab (scalar, no numpy).""" # RGB -> XYZ (inline matrix multiply) x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b - # Normalize by D65 white point - fx = _lab_f(x / _XN) - fy = _lab_f(y / _YN) - fz = _lab_f(z / _ZN) + # XYZ -> LMS (M1) + l = 0.8189330101 * x + 0.3618667424 * y + (-0.1288597137) * z # noqa: E741 + m = 0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z + s = 0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z + + # Cube root + l_ = math.cbrt(l) + m_ = math.cbrt(m) + s_ = math.cbrt(s) - L = 116.0 * fy - 16.0 - a = 500.0 * (fx - fy) - b_val = 200.0 * (fy - fz) + # cbrt(LMS) -> OKLab (M2) + L = 0.2104542553 * l_ + 0.7936177850 * m_ + (-0.0040720468) * s_ + a = 1.9779984951 * l_ + (-2.4285922050) * m_ + 0.4505937099 * s_ + b_val = 0.0259040371 * l_ + 0.7827717662 * m_ + (-0.8086757660) * s_ return L, a, b_val @@ -185,7 +182,7 @@ def _match_pixel_lch( Args: r, g, b: Pixel in linear RGB [0, 1] - palette_L, palette_a, palette_b: Pre-computed LAB components of palette + palette_L, palette_a, palette_b: Pre-computed OKLab components of palette palette_C: Pre-computed chroma of palette colors Returns: @@ -217,7 +214,7 @@ def _match_pixel_lch( def precompute_palette_lab( palette_linear: np.ndarray, ) -> tuple[tuple[float, ...], tuple[float, ...], tuple[float, ...], tuple[float, ...]]: - """Pre-compute palette LAB components for scalar matching. + """Pre-compute palette OKLab components for scalar matching. Call once before the error diffusion loop, then pass results to _match_pixel_lch() for each pixel. From c5e4df90c1cf33ce1b4e1f12d184c1f382254352 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 18:52:20 +0100 Subject: [PATCH 05/16] feat: add gamut compression --- packages/python/scripts/compare.py | 40 ++++++++--- .../python/src/epaper_dithering/algorithms.py | 42 +++++++++--- packages/python/src/epaper_dithering/core.py | 25 ++++--- .../python/src/epaper_dithering/tone_map.py | 66 +++++++++++++++++++ 4 files changed, 146 insertions(+), 27 deletions(-) diff --git a/packages/python/scripts/compare.py b/packages/python/scripts/compare.py index 7755247..a9b171b 100644 --- a/packages/python/scripts/compare.py +++ b/packages/python/scripts/compare.py @@ -67,9 +67,11 @@ def tc_str(tc: float | str) -> str: return f"tc={tc}" -def render(src: Image.Image, scheme: object, mode: DitherMode, tc: float | str) -> tuple[Image.Image, float]: +def render( + src: Image.Image, scheme: object, mode: DitherMode, tc: float | str, gc: float = 0.0 +) -> tuple[Image.Image, float]: t0 = time.perf_counter() - dithered = dither_image(src, scheme, mode, tone_compression=tc) + dithered = dither_image(src, scheme, mode, tone_compression=tc, gamut_compression=gc) return dithered.convert("RGB"), time.perf_counter() - t0 @@ -109,12 +111,22 @@ def save(sheet: Image.Image, path: Path, also_thumb: bool = False) -> None: print(f" → {thumb_path}") -def run(image_path: Path, out_dir: Path, width: int, height: int, docs: bool, docs_algo: str, docs_tc: str) -> None: +def run( + image_path: Path, + out_dir: Path, + width: int, + height: int, + docs: bool, + docs_algo: str, + docs_tc: str, + gamut_compression: float = 0.0, +) -> None: out_dir.mkdir(parents=True, exist_ok=True) src = Image.open(image_path).convert("RGB").resize((width, height), Image.LANCZOS) iw, ih = width, height - print(f"Input: {image_path} ({width}×{height})\n") + gc_label = f" · gc={gamut_compression}" if gamut_compression > 0 else "" + print(f"Input: {image_path} ({width}×{height}){gc_label}\n") # ------------------------------------------------------------------ # 1. schemes.png — all color schemes, Burkes, tc=0 @@ -122,8 +134,8 @@ def run(image_path: Path, out_dir: Path, width: int, height: int, docs: bool, do print("── schemes ──────────────────────────────────") scheme_cells: list[tuple[str, Image.Image]] = [] for label, scheme in COLOR_SCHEMES: - img, t = render(src, scheme, DitherMode.BURKES, 0) - scheme_cells.append((f"{label} · Burkes · tc=0", img)) + img, t = render(src, scheme, DitherMode.BURKES, 0, gamut_compression) + scheme_cells.append((f"{label} · Burkes · tc=0{gc_label}", img)) print(f" {label:<16} {t * 1000:>6.0f}ms") save(make_sheet(scheme_cells, 4, iw, ih), out_dir / "schemes.png") print() @@ -137,8 +149,8 @@ def run(image_path: Path, out_dir: Path, width: int, height: int, docs: bool, do tc: float | str = 0 if palette_label in COLOR_SCHEME_NAMES else "auto" cells: list[tuple[str, Image.Image]] = [] for mode in ALL_ALGORITHMS: - img, t = render(src, palette, mode, tc) - cells.append((f"{mode.name} · {palette_label} · {tc_str(tc)}", img)) + img, t = render(src, palette, mode, tc, gamut_compression) + cells.append((f"{mode.name} · {palette_label} · {tc_str(tc)}{gc_label}", img)) print(f" {palette_label:<16} {mode.name:<22} {t * 1000:>6.0f}ms") algo_cells_by_name[palette_label] = cells save(make_sheet(cells, 3, iw, ih), out_dir / f"algorithms_{palette_label}.png") @@ -152,8 +164,8 @@ def run(image_path: Path, out_dir: Path, width: int, height: int, docs: bool, do tc_cells_solum: list[tuple[str, Image.Image]] = [] for pal_label, palette in MEASURED_PALETTES: for tc_val in [0, "auto", 1.0]: - img, t = render(src, palette, DitherMode.BURKES, tc_val) - label = f"{pal_label} · Burkes · {tc_str(tc_val)}" + img, t = render(src, palette, DitherMode.BURKES, tc_val, gamut_compression) + label = f"{pal_label} · Burkes · {tc_str(tc_val)}{gc_label}" tc_cells.append((label, img)) if pal_label == "SOLUM_BWR": tc_cells_solum.append((label, img)) @@ -209,6 +221,13 @@ def main() -> None: metavar="NAME", help="Palette for docs tone_compression image (default: SOLUM_BWR)", ) + parser.add_argument( + "--gamut-compression", + type=float, + default=0.0, + metavar="GC", + help="Gamut compression strength 0.0–1.0 (default: 0.0 = off). Try 0.7–0.9 for vivid colors.", + ) args = parser.parse_args() run( Path(args.image), @@ -218,6 +237,7 @@ def main() -> None: args.docs, args.docs_algo_palette, args.docs_tc_palette, + args.gamut_compression, ) diff --git a/packages/python/src/epaper_dithering/algorithms.py b/packages/python/src/epaper_dithering/algorithms.py index d5ba906..3d8d672 100644 --- a/packages/python/src/epaper_dithering/algorithms.py +++ b/packages/python/src/epaper_dithering/algorithms.py @@ -14,7 +14,7 @@ precompute_palette_lab, ) from .palettes import ColorPalette, ColorScheme -from .tone_map import auto_compress_dynamic_range, compress_dynamic_range +from .tone_map import auto_compress_dynamic_range, compress_dynamic_range, gamut_compress @dataclass(frozen=True) @@ -169,6 +169,7 @@ def error_diffusion_dither( kernel: ErrorDiffusionKernel, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Generic error diffusion dithering with any kernel. @@ -225,6 +226,10 @@ def error_diffusion_dither( elif isinstance(tone_compression, float): pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) + # Gamut compression: blend out-of-gamut pixels toward nearest palette color + if gamut_compression > 0.0: + pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) + # Pre-compute palette LAB components for scalar per-pixel matching palette_L, palette_a, palette_b, palette_C = precompute_palette_lab(palette_linear) @@ -323,6 +328,7 @@ def floyd_steinberg_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply Floyd-Steinberg error diffusion dithering. @@ -337,11 +343,12 @@ def floyd_steinberg_dither( color_scheme: Target color scheme serpentine: Use serpentine scanning (reduces artifacts) tone_compression: Dynamic range compression strength (0.0-1.0) + gamut_compression: Blend out-of-gamut colors toward palette (0.0-1.0) Returns: Dithered image """ - return error_diffusion_dither(image, color_scheme, FLOYD_STEINBERG, serpentine, tone_compression) + return error_diffusion_dither(image, color_scheme, FLOYD_STEINBERG, serpentine, tone_compression, gamut_compression) def burkes_dither( @@ -349,6 +356,7 @@ def burkes_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply Burkes error diffusion dithering. @@ -361,11 +369,12 @@ def burkes_dither( color_scheme: Target color scheme serpentine: Use serpentine scanning (reduces artifacts) tone_compression: Dynamic range compression strength (0.0-1.0) + gamut_compression: Blend out-of-gamut colors toward palette (0.0-1.0) Returns: Dithered image """ - return error_diffusion_dither(image, color_scheme, BURKES, serpentine, tone_compression) + return error_diffusion_dither(image, color_scheme, BURKES, serpentine, tone_compression, gamut_compression) def sierra_dither( @@ -373,6 +382,7 @@ def sierra_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply Sierra error diffusion dithering. @@ -392,7 +402,7 @@ def sierra_dither( Returns: Dithered image """ - return error_diffusion_dither(image, color_scheme, SIERRA, serpentine, tone_compression) + return error_diffusion_dither(image, color_scheme, SIERRA, serpentine, tone_compression, gamut_compression) def sierra_lite_dither( @@ -400,6 +410,7 @@ def sierra_lite_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply Sierra Lite error diffusion dithering. @@ -418,7 +429,7 @@ def sierra_lite_dither( Returns: Dithered image """ - return error_diffusion_dither(image, color_scheme, SIERRA_LITE, serpentine, tone_compression) + return error_diffusion_dither(image, color_scheme, SIERRA_LITE, serpentine, tone_compression, gamut_compression) def atkinson_dither( @@ -426,6 +437,7 @@ def atkinson_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply Atkinson error diffusion dithering. @@ -445,7 +457,7 @@ def atkinson_dither( Returns: Dithered image """ - return error_diffusion_dither(image, color_scheme, ATKINSON, serpentine, tone_compression) + return error_diffusion_dither(image, color_scheme, ATKINSON, serpentine, tone_compression, gamut_compression) def stucki_dither( @@ -453,6 +465,7 @@ def stucki_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply Stucki error diffusion dithering. @@ -472,7 +485,7 @@ def stucki_dither( Returns: Dithered image """ - return error_diffusion_dither(image, color_scheme, STUCKI, serpentine, tone_compression) + return error_diffusion_dither(image, color_scheme, STUCKI, serpentine, tone_compression, gamut_compression) def jarvis_judice_ninke_dither( @@ -480,6 +493,7 @@ def jarvis_judice_ninke_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply Jarvis-Judice-Ninke error diffusion dithering. @@ -499,7 +513,9 @@ def jarvis_judice_ninke_dither( Returns: Dithered image """ - return error_diffusion_dither(image, color_scheme, JARVIS_JUDICE_NINKE, serpentine, tone_compression) + return error_diffusion_dither( + image, color_scheme, JARVIS_JUDICE_NINKE, serpentine, tone_compression, gamut_compression + ) # ============================================================================= @@ -511,6 +527,7 @@ def direct_palette_map( image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Map image colors directly to palette without dithering. @@ -549,6 +566,10 @@ def direct_palette_map( elif isinstance(tone_compression, float): pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) + # Gamut compression: blend out-of-gamut pixels toward nearest palette color + if gamut_compression > 0.0: + pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) + # Find closest palette color for ALL pixels at once using LAB output_pixels = find_closest_palette_color_lab(pixels_linear, palette_linear) @@ -565,6 +586,7 @@ def ordered_dither( image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply ordered (Bayer) dithering with full vectorization. @@ -618,6 +640,10 @@ def ordered_dither( elif isinstance(tone_compression, float): pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) + # Gamut compression: blend out-of-gamut pixels toward nearest palette color + if gamut_compression > 0.0: + pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) + # ===== VECTORIZED ORDERED DITHERING ===== # Create threshold matrix for entire image using broadcasting diff --git a/packages/python/src/epaper_dithering/core.py b/packages/python/src/epaper_dithering/core.py index 28f0122..3d151aa 100644 --- a/packages/python/src/epaper_dithering/core.py +++ b/packages/python/src/epaper_dithering/core.py @@ -19,6 +19,7 @@ def dither_image( mode: DitherMode = DitherMode.BURKES, serpentine: bool = True, tone_compression: float | str = "auto", + gamut_compression: float = 0.0, ) -> Image.Image: """Apply dithering to image for e-paper display. @@ -33,6 +34,11 @@ def dither_image( "auto" = analyze image histogram and fit to display range. 0.0 = disabled, 0.0-1.0 = fixed linear compression strength. Only applies to measured ColorPalette. + gamut_compression: Pre-dithering gamut compression (default: 0.0 = off). + Blends out-of-gamut pixels toward their nearest palette color before + dithering. Useful for images with highly saturated colors the palette + cannot reproduce (e.g. vivid purple on a BWGBRY display). + 0.0 = disabled, 0.7-0.9 = recommended for typical use. Returns: Dithered palette image matching color scheme @@ -42,22 +48,23 @@ def dither_image( _LOGGER.debug("Applying %s dithering for %s palette", mode.name, scheme_name) tc = tone_compression + gc = gamut_compression match mode: case DitherMode.NONE: - return algorithms.direct_palette_map(image, color_scheme, tc) + return algorithms.direct_palette_map(image, color_scheme, tc, gc) case DitherMode.ORDERED: - return algorithms.ordered_dither(image, color_scheme, tc) + return algorithms.ordered_dither(image, color_scheme, tc, gc) case DitherMode.FLOYD_STEINBERG: - return algorithms.floyd_steinberg_dither(image, color_scheme, serpentine, tc) + return algorithms.floyd_steinberg_dither(image, color_scheme, serpentine, tc, gc) case DitherMode.ATKINSON: - return algorithms.atkinson_dither(image, color_scheme, serpentine, tc) + return algorithms.atkinson_dither(image, color_scheme, serpentine, tc, gc) case DitherMode.STUCKI: - return algorithms.stucki_dither(image, color_scheme, serpentine, tc) + return algorithms.stucki_dither(image, color_scheme, serpentine, tc, gc) case DitherMode.SIERRA: - return algorithms.sierra_dither(image, color_scheme, serpentine, tc) + return algorithms.sierra_dither(image, color_scheme, serpentine, tc, gc) case DitherMode.SIERRA_LITE: - return algorithms.sierra_lite_dither(image, color_scheme, serpentine, tc) + return algorithms.sierra_lite_dither(image, color_scheme, serpentine, tc, gc) case DitherMode.JARVIS_JUDICE_NINKE: - return algorithms.jarvis_judice_ninke_dither(image, color_scheme, serpentine, tc) + return algorithms.jarvis_judice_ninke_dither(image, color_scheme, serpentine, tc, gc) case _: # BURKES or fallback - return algorithms.burkes_dither(image, color_scheme, serpentine, tc) + return algorithms.burkes_dither(image, color_scheme, serpentine, tc, gc) diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index 48b52a3..eb14eda 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -11,6 +11,8 @@ import numpy as np +from .color_space_lab import rgb_to_lab + # ITU-R BT.709 luminance coefficients (same as sRGB) _LUM_R = 0.2126729 _LUM_G = 0.7151522 @@ -157,3 +159,67 @@ def auto_compress_dynamic_range( clipped: np.ndarray = np.clip(result, 0.0, 1.0) return clipped + + +def gamut_compress( + pixels_linear: np.ndarray, + palette_linear: np.ndarray, + strength: float = 1.0, +) -> np.ndarray: + """Blend out-of-gamut pixels toward their nearest palette color. + + Pre-dithering step that reduces colors lying far outside the display's + reproducible gamut. Colors near any palette color are left unchanged; + colors far outside are blended toward the nearest palette color. + + Useful for images with highly saturated colors the palette cannot reproduce + (e.g. vivid purple on a BWGBRY display where the measured red is very dark). + Without compression, error diffusion produces a muddy mix of red/blue dots; + with compression the result is a controlled, intentional blend. + + Distance is measured in OKLab with the same LCH weighting used for palette + matching, so compression is applied where it matters perceptually. Based on + the gamut mapping approach of Stone, Cowan & Beatty (ACM TOG 1988). + + Args: + pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. + palette_linear: Palette in linear RGB, shape (N, 3). + strength: 0.0 = no compression, 1.0 = full blend to nearest palette + color for pixels far outside the gamut. Values of 0.7–0.9 are + recommended for typical use. + + Returns: + Modified pixels_linear array with out-of-gamut colors compressed. + """ + if strength <= 0.0: + return pixels_linear + + lab_pixels = rgb_to_lab(pixels_linear) # (H, W, 3) + lab_palette = rgb_to_lab(palette_linear) # (N, 3) + + # Euclidean OKLab distance to every palette color: (H, W, N) + # NOTE: LCH-weighted distance is NOT used here. The LCH hue weight causes + # near-achromatic palette colors (black, white) to appear "nearest" to any + # saturated pixel because their low chroma produces near-zero hue mismatch. + # Plain Euclidean OKLab finds the genuinely closest color by all three + # dimensions, so purple correctly maps to blue/red, not to black. + diff = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] # (H, W, N, 3) + dist_sq = np.sum(diff**2, axis=-1) # (H, W, N) + + # Nearest palette color distance per pixel + nearest_idx = np.argmin(dist_sq, axis=-1) # (H, W) + nearest_dist = np.sqrt(np.take_along_axis(dist_sq, nearest_idx[..., np.newaxis], axis=-1).squeeze(-1)) # (H, W) + + # Smoothstep blend factor: 0 inside gamut, ramps to 1 far outside + # Threshold chosen in OKLab LCH-weighted space: 0.05 ≈ just-noticeable difference + _THRESHOLD = 0.05 + _THRESHOLD_MAX = 0.20 + t = np.clip((nearest_dist - _THRESHOLD) / (_THRESHOLD_MAX - _THRESHOLD), 0.0, 1.0) + blend_factor = t * t * (3.0 - 2.0 * t) * strength # smoothstep × strength + + # Blend toward nearest palette color in linear RGB + nearest_rgb = palette_linear[nearest_idx] # (H, W, 3) + result = pixels_linear + blend_factor[..., np.newaxis] * (nearest_rgb - pixels_linear) + + clipped: np.ndarray = np.clip(result, 0.0, 1.0) + return clipped From 9a9ffee1f734af51c3a96fa09178a3a1036538be Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 19:01:10 +0100 Subject: [PATCH 06/16] feat: add auto mode for gamut compression --- packages/python/scripts/compare.py | 14 ++++---- .../python/src/epaper_dithering/algorithms.py | 34 ++++++++++-------- packages/python/src/epaper_dithering/core.py | 8 +++-- .../python/src/epaper_dithering/tone_map.py | 36 +++++++++++++++++++ 4 files changed, 68 insertions(+), 24 deletions(-) diff --git a/packages/python/scripts/compare.py b/packages/python/scripts/compare.py index a9b171b..21646fa 100644 --- a/packages/python/scripts/compare.py +++ b/packages/python/scripts/compare.py @@ -68,7 +68,7 @@ def tc_str(tc: float | str) -> str: def render( - src: Image.Image, scheme: object, mode: DitherMode, tc: float | str, gc: float = 0.0 + src: Image.Image, scheme: object, mode: DitherMode, tc: float | str, gc: float | str = 0.0 ) -> tuple[Image.Image, float]: t0 = time.perf_counter() dithered = dither_image(src, scheme, mode, tone_compression=tc, gamut_compression=gc) @@ -119,13 +119,13 @@ def run( docs: bool, docs_algo: str, docs_tc: str, - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> None: out_dir.mkdir(parents=True, exist_ok=True) src = Image.open(image_path).convert("RGB").resize((width, height), Image.LANCZOS) iw, ih = width, height - gc_label = f" · gc={gamut_compression}" if gamut_compression > 0 else "" + gc_label = f" · gc={gamut_compression}" if gamut_compression != 0.0 else "" print(f"Input: {image_path} ({width}×{height}){gc_label}\n") # ------------------------------------------------------------------ @@ -223,12 +223,12 @@ def main() -> None: ) parser.add_argument( "--gamut-compression", - type=float, - default=0.0, + default="auto", metavar="GC", - help="Gamut compression strength 0.0–1.0 (default: 0.0 = off). Try 0.7–0.9 for vivid colors.", + help="Gamut compression: 'auto' (default), or strength 0.0–1.0.", ) args = parser.parse_args() + gc: float | str = "auto" if args.gamut_compression == "auto" else float(args.gamut_compression) run( Path(args.image), Path(args.out), @@ -237,7 +237,7 @@ def main() -> None: args.docs, args.docs_algo_palette, args.docs_tc_palette, - args.gamut_compression, + gc, ) diff --git a/packages/python/src/epaper_dithering/algorithms.py b/packages/python/src/epaper_dithering/algorithms.py index 3d8d672..8f03627 100644 --- a/packages/python/src/epaper_dithering/algorithms.py +++ b/packages/python/src/epaper_dithering/algorithms.py @@ -14,7 +14,7 @@ precompute_palette_lab, ) from .palettes import ColorPalette, ColorScheme -from .tone_map import auto_compress_dynamic_range, compress_dynamic_range, gamut_compress +from .tone_map import auto_compress_dynamic_range, auto_gamut_compress, compress_dynamic_range, gamut_compress @dataclass(frozen=True) @@ -169,7 +169,7 @@ def error_diffusion_dither( kernel: ErrorDiffusionKernel, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Generic error diffusion dithering with any kernel. @@ -227,7 +227,9 @@ def error_diffusion_dither( pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) # Gamut compression: blend out-of-gamut pixels toward nearest palette color - if gamut_compression > 0.0: + if gamut_compression == "auto": + pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) + elif isinstance(gamut_compression, float) and gamut_compression > 0.0: pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) # Pre-compute palette LAB components for scalar per-pixel matching @@ -328,7 +330,7 @@ def floyd_steinberg_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply Floyd-Steinberg error diffusion dithering. @@ -356,7 +358,7 @@ def burkes_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply Burkes error diffusion dithering. @@ -382,7 +384,7 @@ def sierra_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply Sierra error diffusion dithering. @@ -410,7 +412,7 @@ def sierra_lite_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply Sierra Lite error diffusion dithering. @@ -437,7 +439,7 @@ def atkinson_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply Atkinson error diffusion dithering. @@ -465,7 +467,7 @@ def stucki_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply Stucki error diffusion dithering. @@ -493,7 +495,7 @@ def jarvis_judice_ninke_dither( color_scheme: ColorScheme | ColorPalette, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply Jarvis-Judice-Ninke error diffusion dithering. @@ -527,7 +529,7 @@ def direct_palette_map( image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Map image colors directly to palette without dithering. @@ -567,7 +569,9 @@ def direct_palette_map( pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) # Gamut compression: blend out-of-gamut pixels toward nearest palette color - if gamut_compression > 0.0: + if gamut_compression == "auto": + pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) + elif isinstance(gamut_compression, float) and gamut_compression > 0.0: pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) # Find closest palette color for ALL pixels at once using LAB @@ -586,7 +590,7 @@ def ordered_dither( image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = 0.0, ) -> Image.Image: """Apply ordered (Bayer) dithering with full vectorization. @@ -641,7 +645,9 @@ def ordered_dither( pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) # Gamut compression: blend out-of-gamut pixels toward nearest palette color - if gamut_compression > 0.0: + if gamut_compression == "auto": + pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) + elif isinstance(gamut_compression, float) and gamut_compression > 0.0: pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) # ===== VECTORIZED ORDERED DITHERING ===== diff --git a/packages/python/src/epaper_dithering/core.py b/packages/python/src/epaper_dithering/core.py index 3d151aa..a195f24 100644 --- a/packages/python/src/epaper_dithering/core.py +++ b/packages/python/src/epaper_dithering/core.py @@ -19,7 +19,7 @@ def dither_image( mode: DitherMode = DitherMode.BURKES, serpentine: bool = True, tone_compression: float | str = "auto", - gamut_compression: float = 0.0, + gamut_compression: float | str = "auto", ) -> Image.Image: """Apply dithering to image for e-paper display. @@ -34,11 +34,13 @@ def dither_image( "auto" = analyze image histogram and fit to display range. 0.0 = disabled, 0.0-1.0 = fixed linear compression strength. Only applies to measured ColorPalette. - gamut_compression: Pre-dithering gamut compression (default: 0.0 = off). + gamut_compression: Pre-dithering gamut compression (default: "auto"). Blends out-of-gamut pixels toward their nearest palette color before dithering. Useful for images with highly saturated colors the palette cannot reproduce (e.g. vivid purple on a BWGBRY display). - 0.0 = disabled, 0.7-0.9 = recommended for typical use. + "auto" = only compress when image content genuinely exceeds the + palette gamut (p95 nearest-palette distance > 0.25 in OKLab). + 0.0 = disabled, 0.7-0.9 = fixed strength for vivid/synthetic images. Returns: Dithered palette image matching color scheme diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index eb14eda..7342181 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -223,3 +223,39 @@ def gamut_compress( clipped: np.ndarray = np.clip(result, 0.0, 1.0) return clipped + + +def auto_gamut_compress( + pixels_linear: np.ndarray, + palette_linear: np.ndarray, +) -> np.ndarray: + """Conditionally apply gamut compression based on image content. + + Analyzes the image's 95th-percentile nearest-palette-color distance and + only compresses when a meaningful fraction of pixels genuinely lie outside + the display's reproducible gamut. Images whose colors already fall within + the palette's gamut are returned unchanged. + + Args: + pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. + palette_linear: Palette in linear RGB, shape (N, 3). + + Returns: + Modified pixels_linear array, or the original if already in gamut. + """ + lab_pixels = rgb_to_lab(pixels_linear) + lab_palette = rgb_to_lab(palette_linear) + + diff = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] + dist_sq = np.sum(diff**2, axis=-1) + nearest_dist = np.sqrt(np.min(dist_sq, axis=-1)) # (H, W) + + p95 = float(np.percentile(nearest_dist, 95)) + + # Only compress if a significant portion of the image is out of gamut. + # 0.25 sits between natural photos (p95 ≈ 0.20) and synthetic/vivid images + # (p95 ≈ 0.30+). Raised above _THRESHOLD_MAX (0.20) to avoid false triggers. + if p95 <= 0.25: + return pixels_linear + + return gamut_compress(pixels_linear, palette_linear, strength=0.7) From 05685a86529d9ef3319b8785b38a0f27b62aebb8 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 22:32:34 +0100 Subject: [PATCH 07/16] fix: correct OKLab LCH weights and gamut compression gating --- .../python/src/epaper_dithering/algorithms.py | 15 +++++++++------ .../src/epaper_dithering/color_space_lab.py | 18 +++++++++++++++--- packages/python/src/epaper_dithering/core.py | 10 ++++++++-- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/python/src/epaper_dithering/algorithms.py b/packages/python/src/epaper_dithering/algorithms.py index 8f03627..7c5930c 100644 --- a/packages/python/src/epaper_dithering/algorithms.py +++ b/packages/python/src/epaper_dithering/algorithms.py @@ -226,9 +226,10 @@ def error_diffusion_dither( elif isinstance(tone_compression, float): pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) - # Gamut compression: blend out-of-gamut pixels toward nearest palette color + # Gamut compression: auto only for measured palettes; explicit strength works on all if gamut_compression == "auto": - pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) + if isinstance(color_scheme, ColorPalette): + pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) elif isinstance(gamut_compression, float) and gamut_compression > 0.0: pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) @@ -568,9 +569,10 @@ def direct_palette_map( elif isinstance(tone_compression, float): pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) - # Gamut compression: blend out-of-gamut pixels toward nearest palette color + # Gamut compression: auto only for measured palettes; explicit strength works on all if gamut_compression == "auto": - pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) + if isinstance(color_scheme, ColorPalette): + pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) elif isinstance(gamut_compression, float) and gamut_compression > 0.0: pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) @@ -644,9 +646,10 @@ def ordered_dither( elif isinstance(tone_compression, float): pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) - # Gamut compression: blend out-of-gamut pixels toward nearest palette color + # Gamut compression: auto only for measured palettes; explicit strength works on all if gamut_compression == "auto": - pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) + if isinstance(color_scheme, ColorPalette): + pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) elif isinstance(gamut_compression, float) and gamut_compression > 0.0: pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) diff --git a/packages/python/src/epaper_dithering/color_space_lab.py b/packages/python/src/epaper_dithering/color_space_lab.py index d01e657..bd387aa 100644 --- a/packages/python/src/epaper_dithering/color_space_lab.py +++ b/packages/python/src/epaper_dithering/color_space_lab.py @@ -69,10 +69,22 @@ dtype=np.float64, ) -# LCH distance weights for dithering +# LCH distance weights for dithering (tuned for OKLab scale) +# +# OKLab: L ∈ [0, 1], C ∈ [0, ~0.4] — very different scale from CIELAB +# (CIELAB: L ∈ [0, 100], C ∈ [0, ~130]). +# +# To maintain the same relative emphasis as the original CIELAB weights +# (WL=0.5, WC=1.0, WH=2.0), the chroma/hue weights must be scaled up: +# CIELAB: effective L range = 100×0.5 = 50, C range = 130×1.0 = 130 → C is 2.6× L +# OKLab: effective L range = 1×0.5 = 0.5, target C = 2.6×0.5 = 1.3 → WC = 1.3/0.4 ≈ 3.0 +# +# Without this scaling, chroma penalties become negligible in OKLab units, +# causing achromatic pixels to map to intermediate-L chromatic palette colors +# (e.g. green at L=0.43) instead of neutral black/white. _WL = 0.5 # lightness: de-emphasized (error diffusion compensates) -_WC = 1.0 # chroma: standard weight -_WH = 2.0 # hue: emphasized (error diffusion cannot compensate) +_WC = 3.0 # chroma: scaled up for OKLab's smaller C range [0, ~0.4] +_WH = 6.0 # hue: emphasized (error diffusion cannot compensate) # ============================================================================= diff --git a/packages/python/src/epaper_dithering/core.py b/packages/python/src/epaper_dithering/core.py index a195f24..50818f5 100644 --- a/packages/python/src/epaper_dithering/core.py +++ b/packages/python/src/epaper_dithering/core.py @@ -39,12 +39,18 @@ def dither_image( dithering. Useful for images with highly saturated colors the palette cannot reproduce (e.g. vivid purple on a BWGBRY display). "auto" = only compress when image content genuinely exceeds the - palette gamut (p95 nearest-palette distance > 0.25 in OKLab). - 0.0 = disabled, 0.7-0.9 = fixed strength for vivid/synthetic images. + palette gamut (p95 nearest-palette distance > 0.25 in OKLab); + auto mode only activates for measured ColorPalette, not ColorScheme. + 0.0 = disabled, 0.7-0.9 = fixed strength (works on all palette types). Returns: Dithered palette image matching color scheme """ + if not isinstance(tone_compression, (float, str)): + raise TypeError(f"tone_compression must be float or 'auto', got {type(tone_compression).__name__}") + if not isinstance(gamut_compression, (float, str)): + raise TypeError(f"gamut_compression must be float or 'auto', got {type(gamut_compression).__name__}") + # Log color scheme name if available scheme_name = color_scheme.name if isinstance(color_scheme, ColorScheme) else "custom" _LOGGER.debug("Applying %s dithering for %s palette", mode.name, scheme_name) From f0c1db4e7d485fd8eb2f5313f34a832477d90421 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 14 Mar 2026 22:32:56 +0100 Subject: [PATCH 08/16] feat: add gamut_compression sheet to compare script --- packages/python/scripts/compare.py | 45 ++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/python/scripts/compare.py b/packages/python/scripts/compare.py index 21646fa..bb79de4 100644 --- a/packages/python/scripts/compare.py +++ b/packages/python/scripts/compare.py @@ -4,6 +4,7 @@ schemes.png All color schemes, Burkes, tc=0 algorithms_{NAME}.png All dither modes for every color scheme and measured palette tone_compression.png Measured palettes: tc=0 | tc=auto | tc=1.0 + gamut_compression.png Measured palettes: gc=0 | gc=auto | gc=1.0 Add --docs to also generate images for the README in docs/images/: Full resolution in docs/images/ @@ -67,6 +68,16 @@ def tc_str(tc: float | str) -> str: return f"tc={tc}" +def gc_str(gc: float | str) -> str: + if gc == "auto": + return "gc=auto" + if gc == 1.0 or gc == 1: + return "gc=100%" + if gc == 0 or gc == 0.0: + return "gc=0" + return f"gc={gc}" + + def render( src: Image.Image, scheme: object, mode: DitherMode, tc: float | str, gc: float | str = 0.0 ) -> tuple[Image.Image, float]: @@ -119,6 +130,7 @@ def run( docs: bool, docs_algo: str, docs_tc: str, + docs_gc: str, gamut_compression: float | str = 0.0, ) -> None: out_dir.mkdir(parents=True, exist_ok=True) @@ -173,6 +185,20 @@ def run( save(make_sheet(tc_cells, 3, iw, ih), out_dir / "tone_compression.png") print() + # ------------------------------------------------------------------ + # 4. gamut_compression.png — measured palettes, gc=0 | gc=auto | gc=1.0 + # ------------------------------------------------------------------ + print("── gamut_compression ────────────────────────") + gc_cells: list[tuple[str, Image.Image]] = [] + for pal_label, palette in MEASURED_PALETTES: + for gc_val in [0, "auto", 1.0]: + img, t = render(src, palette, DitherMode.BURKES, "auto", gc_val) + label = f"{pal_label} · Burkes · {gc_str(gc_val)}" + gc_cells.append((label, img)) + print(f" {pal_label:<14} {gc_str(gc_val):<10} {t * 1000:>6.0f}ms") + save(make_sheet(gc_cells, 3, iw, ih), out_dir / "gamut_compression.png") + print() + # ------------------------------------------------------------------ # --docs: full-res + 50% thumbnails for the README # ------------------------------------------------------------------ @@ -191,17 +217,25 @@ def run( print(f" (note: --docs-algo-palette '{docs_algo}' not found, using '{algo_key}')") # tone compression: use the requested palette, fall back to first measured - tc_palette_names = [name for name, _ in MEASURED_PALETTES] - tc_key = docs_tc if docs_tc in tc_palette_names else tc_palette_names[0] + measured_palette_names = [name for name, _ in MEASURED_PALETTES] + tc_key = docs_tc if docs_tc in measured_palette_names else measured_palette_names[0] if tc_key != docs_tc: print(f" (note: --docs-tc-palette '{docs_tc}' not found, using '{tc_key}')") tc_cells_docs = [cell for cell in tc_cells if cell[0].startswith(tc_key)] + # gamut compression: use the requested palette, fall back to first measured + gc_key = docs_gc if docs_gc in measured_palette_names else measured_palette_names[0] + if gc_key != docs_gc: + print(f" (note: --docs-gc-palette '{docs_gc}' not found, using '{gc_key}')") + gc_cells_docs = [cell for cell in gc_cells if cell[0].startswith(gc_key)] + save(make_sheet(scheme_cells, 4, iw, ih), docs_dir / "schemes.png", also_thumb=True) save(make_sheet(algo_cells_by_name[algo_key], 3, iw, ih), docs_dir / "algorithms.png", also_thumb=True) print(f" (palette: {algo_key})") save(make_sheet(tc_cells_docs, 3, iw, ih), docs_dir / "tone_compression.png", also_thumb=True) print(f" (palette: {tc_key})") + save(make_sheet(gc_cells_docs, 3, iw, ih), docs_dir / "gamut_compression.png", also_thumb=True) + print(f" (palette: {gc_key})") def main() -> None: @@ -221,6 +255,12 @@ def main() -> None: metavar="NAME", help="Palette for docs tone_compression image (default: SOLUM_BWR)", ) + parser.add_argument( + "--docs-gc-palette", + default="SPECTRA_7_3_6COLOR", + metavar="NAME", + help="Palette for docs gamut_compression image (default: SPECTRA_7_3_6COLOR)", + ) parser.add_argument( "--gamut-compression", default="auto", @@ -237,6 +277,7 @@ def main() -> None: args.docs, args.docs_algo_palette, args.docs_tc_palette, + args.docs_gc_palette, gc, ) From 715dc0af89fc1236ef7f50fcdc0b30f5520f0bd8 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 15 Mar 2026 12:55:56 +0100 Subject: [PATCH 09/16] feat(tone-map): use Reinhard 2004 log-skewness for auto compression strength --- .../python/src/epaper_dithering/tone_map.py | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index 7342181..404b37d 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -95,6 +95,12 @@ def auto_compress_dynamic_range( [black_Y, white_Y] range are returned unchanged (ICC Black Point Compensation style). + When compression is needed, strength is derived from the Reinhard 2004 + log-histogram skewness — the position of the geometric mean luminance + within the log luminance range. A balanced image (log-average near center) + gets partial compression, preserving perceived contrast. A heavily skewed + image (dark scene with bright highlights) gets full compression. + This avoids the over-compression that occurs when unconditionally stretching a well-exposed image to fill the display range — which washes out colors by pushing already-correct highlights above the display white point. @@ -137,9 +143,36 @@ def auto_compress_dynamic_range( # Image already fits within the display's reproducible range — no change. return pixels_linear - # Remap: [p_low, p_high] → [black_Y, white_Y] + # Derive compression strength from Reinhard 2004 log-histogram skewness. + # + # Skewness = where the geometric mean (log-average) luminance sits within + # the log luminance range [log(p_low), log(p_high)]: + # 0 = log-average at the bright end → balanced/bright image → less compression + # 1 = log-average at the dark end → dark scene, bright highlights → full compression + # + # strength = clip(skew ^ 1.4, 0, 1) — the 1.4 exponent from Reinhard 2004 + # makes the response non-linear, more sensitive at extremes. + Y_nonzero = Y.ravel() + Y_nonzero = Y_nonzero[Y_nonzero > 1e-6] + if len(Y_nonzero) > 0: + L_lav = float(np.exp(np.mean(np.log(Y_nonzero + 1e-5)))) + log_min = float(np.log(max(p_low, 1e-5))) + log_max = float(np.log(max(p_high, 1e-5))) + log_range = log_max - log_min + if log_range > 1e-6: + skew = (log_max - float(np.log(L_lav + 1e-5))) / log_range + strength = float(np.clip(skew**1.4, 0.0, 1.0)) + else: + strength = 1.0 + else: + strength = 1.0 + + # Remap: [p_low, p_high] → [black_Y, white_Y], blended at computed strength. + # At strength=0: original luminance preserved. + # At strength=1: full content-adaptive remap (original behaviour). normalized_Y = (Y - p_low) / image_range - target_Y = black_Y + normalized_Y * display_range + target_Y_full = black_Y + normalized_Y * display_range + target_Y = Y + strength * (target_Y_full - Y) # Scale RGB proportionally to preserve hue safe_Y = np.where(Y > 1e-6, Y, 1.0) From 68d9b7cf53e27e0df7ab7d4a387957a54ca22fc2 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 15 Mar 2026 13:44:49 +0100 Subject: [PATCH 10/16] feat(tone-map): continuous strength for auto gamut compression --- packages/python/src/epaper_dithering/tone_map.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index 404b37d..b1fbd83 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -283,6 +283,7 @@ def auto_gamut_compress( dist_sq = np.sum(diff**2, axis=-1) nearest_dist = np.sqrt(np.min(dist_sq, axis=-1)) # (H, W) + p50 = float(np.percentile(nearest_dist, 50)) p95 = float(np.percentile(nearest_dist, 95)) # Only compress if a significant portion of the image is out of gamut. @@ -291,4 +292,13 @@ def auto_gamut_compress( if p95 <= 0.25: return pixels_linear - return gamut_compress(pixels_linear, palette_linear, strength=0.7) + # Derive strength from two independent signals (calibrated OKLab distances): + # p95 / 0.35: images with extreme out-of-gamut outliers → full strength + # p50 / 0.12: images where the median pixel is moderately out-of-gamut + # (widespread issue, not just outliers) → scale up + # Take the maximum so either signal can drive full compression independently. + s_p95 = float(np.clip(p95 / 0.35, 0.0, 1.0)) + s_p50 = float(np.clip(p50 / 0.12, 0.0, 1.0)) + strength = max(s_p95, s_p50) + + return gamut_compress(pixels_linear, palette_linear, strength=strength) From 7755c3dde42fed2b199c7823c47fb1d114374177 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 15 Mar 2026 13:45:09 +0100 Subject: [PATCH 11/16] feat(compare): add gamut_compression contact sheet; fix int literals --- packages/python/scripts/compare.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/python/scripts/compare.py b/packages/python/scripts/compare.py index bb79de4..f6571f0 100644 --- a/packages/python/scripts/compare.py +++ b/packages/python/scripts/compare.py @@ -146,7 +146,7 @@ def run( print("── schemes ──────────────────────────────────") scheme_cells: list[tuple[str, Image.Image]] = [] for label, scheme in COLOR_SCHEMES: - img, t = render(src, scheme, DitherMode.BURKES, 0, gamut_compression) + img, t = render(src, scheme, DitherMode.BURKES, 0.0, gamut_compression) scheme_cells.append((f"{label} · Burkes · tc=0{gc_label}", img)) print(f" {label:<16} {t * 1000:>6.0f}ms") save(make_sheet(scheme_cells, 4, iw, ih), out_dir / "schemes.png") @@ -158,7 +158,7 @@ def run( print("── algorithms ───────────────────────────────") algo_cells_by_name: dict[str, list[tuple[str, Image.Image]]] = {} for palette_label, palette in ALL_PALETTES_FOR_ALGO: - tc: float | str = 0 if palette_label in COLOR_SCHEME_NAMES else "auto" + tc: float | str = 0.0 if palette_label in COLOR_SCHEME_NAMES else "auto" cells: list[tuple[str, Image.Image]] = [] for mode in ALL_ALGORITHMS: img, t = render(src, palette, mode, tc, gamut_compression) @@ -175,7 +175,7 @@ def run( tc_cells: list[tuple[str, Image.Image]] = [] tc_cells_solum: list[tuple[str, Image.Image]] = [] for pal_label, palette in MEASURED_PALETTES: - for tc_val in [0, "auto", 1.0]: + for tc_val in [0.0, "auto", 1.0]: img, t = render(src, palette, DitherMode.BURKES, tc_val, gamut_compression) label = f"{pal_label} · Burkes · {tc_str(tc_val)}{gc_label}" tc_cells.append((label, img)) @@ -191,7 +191,7 @@ def run( print("── gamut_compression ────────────────────────") gc_cells: list[tuple[str, Image.Image]] = [] for pal_label, palette in MEASURED_PALETTES: - for gc_val in [0, "auto", 1.0]: + for gc_val in [0.0, "auto", 1.0]: img, t = render(src, palette, DitherMode.BURKES, "auto", gc_val) label = f"{pal_label} · Burkes · {gc_str(gc_val)}" gc_cells.append((label, img)) From 4263183aabc97113908dafa22bb9c89fe8e9cfe7 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 15 Mar 2026 14:02:03 +0100 Subject: [PATCH 12/16] feat(tone-map): iterative optimization for auto gamut compression strength --- .../python/src/epaper_dithering/tone_map.py | 78 ++++++++++++------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index b1fbd83..36dcb4f 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -258,47 +258,71 @@ def gamut_compress( return clipped +def _optimize_compression_strength( + pixels_linear: np.ndarray, + palette_linear: np.ndarray, + compress_fn: object, + candidates: list[float], +) -> float: + """Select compression strength that minimizes mean OKLab nearest-palette distance. + + Evaluates each candidate strength by applying compression and measuring the + mean OKLab distance between compressed pixels and their nearest palette color. + No dithering required — error diffusion redistributes but does not change + total quantization error, so pre-diffusion distance is a valid proxy. + + Args: + pixels_linear: Image in linear RGB, shape (H, W, 3). + palette_linear: Palette in linear RGB, shape (N, 3). + compress_fn: Callable(pixels, palette, strength) -> compressed_pixels. + candidates: Strength values to evaluate (must include 0.0). + + Returns: + Strength from candidates with minimum mean quantization error. + """ + lab_palette = rgb_to_lab(palette_linear) # (N, 3) + + best_strength = candidates[0] + best_error = float("inf") + + for s in candidates: + if s > 0.0: + compressed = compress_fn(pixels_linear, palette_linear, s) # type: ignore[operator] + else: + compressed = pixels_linear + lab_pixels = rgb_to_lab(compressed) + diff = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] # (H, W, N, 3) + nearest_dist = np.sqrt(np.min(np.sum(diff**2, axis=-1), axis=-1)) # (H, W) + error = float(np.mean(nearest_dist)) + + if error < best_error: + best_error = error + best_strength = s + + return best_strength + + def auto_gamut_compress( pixels_linear: np.ndarray, palette_linear: np.ndarray, ) -> np.ndarray: """Conditionally apply gamut compression based on image content. - Analyzes the image's 95th-percentile nearest-palette-color distance and - only compresses when a meaningful fraction of pixels genuinely lie outside - the display's reproducible gamut. Images whose colors already fall within - the palette's gamut are returned unchanged. + Selects the compression strength that minimizes mean OKLab nearest-palette + distance across 5 candidate values. Strength 0.0 is always a candidate, so + images already within the display's gamut are returned unchanged. Args: pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. palette_linear: Palette in linear RGB, shape (N, 3). Returns: - Modified pixels_linear array, or the original if already in gamut. + Modified pixels_linear array with optimal gamut compression applied. """ - lab_pixels = rgb_to_lab(pixels_linear) - lab_palette = rgb_to_lab(palette_linear) - - diff = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] - dist_sq = np.sum(diff**2, axis=-1) - nearest_dist = np.sqrt(np.min(dist_sq, axis=-1)) # (H, W) - - p50 = float(np.percentile(nearest_dist, 50)) - p95 = float(np.percentile(nearest_dist, 95)) + _CANDIDATES = [0.0, 0.25, 0.5, 0.75, 1.0] + strength = _optimize_compression_strength(pixels_linear, palette_linear, gamut_compress, _CANDIDATES) - # Only compress if a significant portion of the image is out of gamut. - # 0.25 sits between natural photos (p95 ≈ 0.20) and synthetic/vivid images - # (p95 ≈ 0.30+). Raised above _THRESHOLD_MAX (0.20) to avoid false triggers. - if p95 <= 0.25: + if strength <= 0.0: return pixels_linear - # Derive strength from two independent signals (calibrated OKLab distances): - # p95 / 0.35: images with extreme out-of-gamut outliers → full strength - # p50 / 0.12: images where the median pixel is moderately out-of-gamut - # (widespread issue, not just outliers) → scale up - # Take the maximum so either signal can drive full compression independently. - s_p95 = float(np.clip(p95 / 0.35, 0.0, 1.0)) - s_p50 = float(np.clip(p50 / 0.12, 0.0, 1.0)) - strength = max(s_p95, s_p50) - return gamut_compress(pixels_linear, palette_linear, strength=strength) From bdddc9756c9098ef6bd4bf616b7385cb2f317d52 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 15 Mar 2026 14:49:27 +0100 Subject: [PATCH 13/16] feat(tone-map): hue-preserving gamut compression --- .../python/src/epaper_dithering/tone_map.py | 92 ++++++++++++------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index 36dcb4f..c3a9b0a 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -199,27 +199,20 @@ def gamut_compress( palette_linear: np.ndarray, strength: float = 1.0, ) -> np.ndarray: - """Blend out-of-gamut pixels toward their nearest palette color. + """Blend out-of-gamut pixels toward the hue-matching point on the palette hull. Pre-dithering step that reduces colors lying far outside the display's - reproducible gamut. Colors near any palette color are left unchanged; - colors far outside are blended toward the nearest palette color. - - Useful for images with highly saturated colors the palette cannot reproduce - (e.g. vivid purple on a BWGBRY display where the measured red is very dark). - Without compression, error diffusion produces a muddy mix of red/blue dots; - with compression the result is a controlled, intentional blend. - - Distance is measured in OKLab with the same LCH weighting used for palette - matching, so compression is applied where it matters perceptually. Based on - the gamut mapping approach of Stone, Cowan & Beatty (ACM TOG 1988). + reproducible gamut. For each pixel, finds the point on the nearest palette + edge whose hue matches the pixel's hue — so a vivid purple lands between + blue and red rather than collapsing to the Euclidean nearest vertex. This + preserves the pixel's hue character and gives error diffusion a better + starting point for mixing. Args: pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. palette_linear: Palette in linear RGB, shape (N, 3). - strength: 0.0 = no compression, 1.0 = full blend to nearest palette - color for pixels far outside the gamut. Values of 0.7–0.9 are - recommended for typical use. + strength: 0.0 = no compression, 1.0 = full blend to hue-matching + palette edge point for pixels far outside the gamut. Returns: Modified pixels_linear array with out-of-gamut colors compressed. @@ -229,30 +222,65 @@ def gamut_compress( lab_pixels = rgb_to_lab(pixels_linear) # (H, W, 3) lab_palette = rgb_to_lab(palette_linear) # (N, 3) + n_colors = len(palette_linear) - # Euclidean OKLab distance to every palette color: (H, W, N) - # NOTE: LCH-weighted distance is NOT used here. The LCH hue weight causes - # near-achromatic palette colors (black, white) to appear "nearest" to any - # saturated pixel because their low chroma produces near-zero hue mismatch. - # Plain Euclidean OKLab finds the genuinely closest color by all three - # dimensions, so purple correctly maps to blue/red, not to black. - diff = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] # (H, W, N, 3) - dist_sq = np.sum(diff**2, axis=-1) # (H, W, N) + pixel_a = lab_pixels[..., 1] # (H, W) — OKLab a (green↔red) + pixel_b = lab_pixels[..., 2] # (H, W) — OKLab b (blue↔yellow) - # Nearest palette color distance per pixel - nearest_idx = np.argmin(dist_sq, axis=-1) # (H, W) - nearest_dist = np.sqrt(np.take_along_axis(dist_sq, nearest_idx[..., np.newaxis], axis=-1).squeeze(-1)) # (H, W) + # For each palette edge, find the point where the interpolated hue matches + # the pixel's hue. This is hue-preserving gamut mapping: a vivid purple lands + # between blue and red rather than collapsing to the Euclidean nearest vertex. + # + # Hue equality condition: (a0 + t*da)*pixel_b == (b0 + t*db)*pixel_a + # Solving for t: t = (b0*pixel_a - a0*pixel_b) / (da*pixel_b - db*pixel_a) + # + # t is clipped to [0, 1] so edges that don't span the pixel's hue degrade + # gracefully to their nearest endpoint. + best_dist_sq = np.full(pixels_linear.shape[:2], np.inf) + best_target_rgb = np.zeros_like(pixels_linear) + + for i in range(n_colors): + for j in range(i + 1, n_colors): + a0 = lab_palette[i, 1] + b0 = lab_palette[i, 2] + da = lab_palette[j, 1] - a0 + db = lab_palette[j, 2] - b0 + denom = da * pixel_b - db * pixel_a # (H, W) + numer = b0 * pixel_a - a0 * pixel_b # (H, W) + valid = np.abs(denom) > 1e-10 + seg_t = np.clip( + np.where(valid, numer / np.where(valid, denom, 1.0), 0.0), + 0.0, + 1.0, + ) # (H, W) + nearest_lab = lab_palette[i] + seg_t[..., np.newaxis] * (lab_palette[j] - lab_palette[i]) + dist_sq = np.sum((lab_pixels - nearest_lab) ** 2, axis=-1) + target_rgb = palette_linear[i] + seg_t[..., np.newaxis] * (palette_linear[j] - palette_linear[i]) + better = dist_sq < best_dist_sq + best_dist_sq = np.where(better, dist_sq, best_dist_sq) + best_target_rgb = np.where(better[..., np.newaxis], target_rgb, best_target_rgb) + + # Fallback: nearest palette vertex — handles achromatic pixels (hue undefined) + # and any degenerate cases where all segments clip to endpoints. + diff_v = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] # (H, W, N, 3) + dist_sq_v = np.sum(diff_v**2, axis=-1) # (H, W, N) + nearest_v_idx = np.argmin(dist_sq_v, axis=-1) # (H, W) + nearest_v_dist_sq = np.take_along_axis(dist_sq_v, nearest_v_idx[..., np.newaxis], axis=-1).squeeze(-1) + nearest_v_rgb = palette_linear[nearest_v_idx] + better_v = nearest_v_dist_sq < best_dist_sq + best_dist_sq = np.where(better_v, nearest_v_dist_sq, best_dist_sq) + best_target_rgb = np.where(better_v[..., np.newaxis], nearest_v_rgb, best_target_rgb) + + nearest_dist = np.sqrt(best_dist_sq) # Smoothstep blend factor: 0 inside gamut, ramps to 1 far outside - # Threshold chosen in OKLab LCH-weighted space: 0.05 ≈ just-noticeable difference + # Threshold chosen in OKLab space: 0.05 ≈ just-noticeable difference _THRESHOLD = 0.05 _THRESHOLD_MAX = 0.20 - t = np.clip((nearest_dist - _THRESHOLD) / (_THRESHOLD_MAX - _THRESHOLD), 0.0, 1.0) - blend_factor = t * t * (3.0 - 2.0 * t) * strength # smoothstep × strength + blend_t = np.clip((nearest_dist - _THRESHOLD) / (_THRESHOLD_MAX - _THRESHOLD), 0.0, 1.0) + blend_factor = blend_t * blend_t * (3.0 - 2.0 * blend_t) * strength # smoothstep × strength - # Blend toward nearest palette color in linear RGB - nearest_rgb = palette_linear[nearest_idx] # (H, W, 3) - result = pixels_linear + blend_factor[..., np.newaxis] * (nearest_rgb - pixels_linear) + result = pixels_linear + blend_factor[..., np.newaxis] * (best_target_rgb - pixels_linear) clipped: np.ndarray = np.clip(result, 0.0, 1.0) return clipped From fac71125e4efa1da0f7c7b0371f2d435d7309006 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 15 Mar 2026 18:29:35 +0100 Subject: [PATCH 14/16] feat: add SPECTRA_7_3_6COLOR_V2 palette with calibrated measurements --- .../python/src/epaper_dithering/__init__.py | 2 ++ .../python/src/epaper_dithering/palettes.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/python/src/epaper_dithering/__init__.py b/packages/python/src/epaper_dithering/__init__.py index c1f8d35..d875fed 100644 --- a/packages/python/src/epaper_dithering/__init__.py +++ b/packages/python/src/epaper_dithering/__init__.py @@ -14,6 +14,7 @@ MONO_4_26, SOLUM_BWR, SPECTRA_7_3_6COLOR, + SPECTRA_7_3_6COLOR_V2, ColorPalette, ColorScheme, ) @@ -27,6 +28,7 @@ "ColorScheme", # Measured palettes for specific displays (v0.4.0) "SPECTRA_7_3_6COLOR", + "SPECTRA_7_3_6COLOR_V2", "MONO_4_26", "BWRY_4_2", "BWRY_3_97", diff --git a/packages/python/src/epaper_dithering/palettes.py b/packages/python/src/epaper_dithering/palettes.py index a25ba6d..b73e0c4 100644 --- a/packages/python/src/epaper_dithering/palettes.py +++ b/packages/python/src/epaper_dithering/palettes.py @@ -224,6 +224,23 @@ def from_value(cls, value: int) -> ColorScheme: accent="red", ) +# 7.3" Spectra™ 6-color (BWGBRY scheme) — v2 measurement +# Measured: 2026-03-15 +# Equipment: iPhone 15 Pro Max RAW + Affinity (v3), A4 paper white reference +# Method: DNG with linear tone curve, WB from A4 paper, uniform ×2.4 scale +# (paper measured at 100,100,100 → target 240 ≈ 88% A4 reflectance) +SPECTRA_7_3_6COLOR_V2 = ColorPalette( + colors={ + "black": (31, 24, 41), + "white": (168, 180, 182), + "yellow": (180, 173, 0), + "red": (113, 24, 19), + "blue": (36, 70, 139), + "green": (50, 84, 60), + }, + accent="red", +) + # 4.26" Monochrome (MONO scheme) # TODO: Measure actual display MONO_4_26 = ColorPalette( From 3c25c1dac7fdf48d1f805e751e87164782cec3f8 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 15 Mar 2026 18:29:49 +0100 Subject: [PATCH 15/16] refactor: simplify gamut compression to uniform nearest-hull-point --- .../src/epaper_dithering/color_space_lab.py | 22 +-- .../python/src/epaper_dithering/tone_map.py | 132 +++++------------- 2 files changed, 44 insertions(+), 110 deletions(-) diff --git a/packages/python/src/epaper_dithering/color_space_lab.py b/packages/python/src/epaper_dithering/color_space_lab.py index bd387aa..3e08736 100644 --- a/packages/python/src/epaper_dithering/color_space_lab.py +++ b/packages/python/src/epaper_dithering/color_space_lab.py @@ -17,8 +17,11 @@ The LCH decomposition uses the identity: da^2 + db^2 = dC^2 + dH^2, allowing us to weight the three perceptual dimensions independently: - Lightness (WL=0.5): de-emphasized, error diffusion handles this -- Chroma (WC=1.0): standard weight -- Hue (WH=2.0): emphasized, prevents cross-hue errors like green->yellow +- Chroma (WC=3.0): scaled up for OKLab's smaller C range [0, ~0.4] +- Hue (WH=6.0): emphasized, prevents cross-hue errors like green->yellow + +Note: gamut compression uses plain Euclidean OKLab (not LCH-weighted) since +it operates on the image before dithering and targets a different goal. References: ---------- @@ -69,19 +72,8 @@ dtype=np.float64, ) -# LCH distance weights for dithering (tuned for OKLab scale) -# -# OKLab: L ∈ [0, 1], C ∈ [0, ~0.4] — very different scale from CIELAB -# (CIELAB: L ∈ [0, 100], C ∈ [0, ~130]). -# -# To maintain the same relative emphasis as the original CIELAB weights -# (WL=0.5, WC=1.0, WH=2.0), the chroma/hue weights must be scaled up: -# CIELAB: effective L range = 100×0.5 = 50, C range = 130×1.0 = 130 → C is 2.6× L -# OKLab: effective L range = 1×0.5 = 0.5, target C = 2.6×0.5 = 1.3 → WC = 1.3/0.4 ≈ 3.0 -# -# Without this scaling, chroma penalties become negligible in OKLab units, -# causing achromatic pixels to map to intermediate-L chromatic palette colors -# (e.g. green at L=0.43) instead of neutral black/white. + +# LCH distance weights for dithering _WL = 0.5 # lightness: de-emphasized (error diffusion compensates) _WC = 3.0 # chroma: scaled up for OKLab's smaller C range [0, ~0.4] _WH = 6.0 # hue: emphasized (error diffusion cannot compensate) diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py index c3a9b0a..8040d73 100644 --- a/packages/python/src/epaper_dithering/tone_map.py +++ b/packages/python/src/epaper_dithering/tone_map.py @@ -199,20 +199,19 @@ def gamut_compress( palette_linear: np.ndarray, strength: float = 1.0, ) -> np.ndarray: - """Blend out-of-gamut pixels toward the hue-matching point on the palette hull. + """Blend out-of-gamut pixels toward the nearest point on the palette hull. - Pre-dithering step that reduces colors lying far outside the display's - reproducible gamut. For each pixel, finds the point on the nearest palette - edge whose hue matches the pixel's hue — so a vivid purple lands between - blue and red rather than collapsing to the Euclidean nearest vertex. This - preserves the pixel's hue character and gives error diffusion a better - starting point for mixing. + For each palette edge (pair of palette colors), finds the nearest point on + that segment in 3D OKLab space. The overall nearest hull point across all + edges and vertices is used as the compression target. + + A smoothstep blend factor ramps from 0 at _THRESHOLD to 1 at _THRESHOLD_MAX, + so only pixels far outside the gamut are significantly affected. Args: pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. palette_linear: Palette in linear RGB, shape (N, 3). - strength: 0.0 = no compression, 1.0 = full blend to hue-matching - palette edge point for pixels far outside the gamut. + strength: 0.0 = no compression, 1.0 = full blend toward nearest hull point. Returns: Modified pixels_linear array with out-of-gamut colors compressed. @@ -224,35 +223,24 @@ def gamut_compress( lab_palette = rgb_to_lab(palette_linear) # (N, 3) n_colors = len(palette_linear) - pixel_a = lab_pixels[..., 1] # (H, W) — OKLab a (green↔red) - pixel_b = lab_pixels[..., 2] # (H, W) — OKLab b (blue↔yellow) - - # For each palette edge, find the point where the interpolated hue matches - # the pixel's hue. This is hue-preserving gamut mapping: a vivid purple lands - # between blue and red rather than collapsing to the Euclidean nearest vertex. - # - # Hue equality condition: (a0 + t*da)*pixel_b == (b0 + t*db)*pixel_a - # Solving for t: t = (b0*pixel_a - a0*pixel_b) / (da*pixel_b - db*pixel_a) - # - # t is clipped to [0, 1] so edges that don't span the pixel's hue degrade - # gracefully to their nearest endpoint. best_dist_sq = np.full(pixels_linear.shape[:2], np.inf) best_target_rgb = np.zeros_like(pixels_linear) + # For each palette edge: nearest-point-on-segment in 3D OKLab for i in range(n_colors): for j in range(i + 1, n_colors): - a0 = lab_palette[i, 1] - b0 = lab_palette[i, 2] - da = lab_palette[j, 1] - a0 - db = lab_palette[j, 2] - b0 - denom = da * pixel_b - db * pixel_a # (H, W) - numer = b0 * pixel_a - a0 * pixel_b # (H, W) - valid = np.abs(denom) > 1e-10 - seg_t = np.clip( - np.where(valid, numer / np.where(valid, denom, 1.0), 0.0), - 0.0, - 1.0, - ) # (H, W) + edge = lab_palette[j] - lab_palette[i] # (3,) + edge_len_sq = float(np.dot(edge, edge)) + if edge_len_sq > 1e-10: + diff_from_i = lab_pixels - lab_palette[i] # (H, W, 3) + seg_t = np.clip( + np.einsum("hwc,c->hw", diff_from_i, edge) / edge_len_sq, + 0.0, + 1.0, + ) + else: + seg_t = np.zeros(pixels_linear.shape[:2]) + nearest_lab = lab_palette[i] + seg_t[..., np.newaxis] * (lab_palette[j] - lab_palette[i]) dist_sq = np.sum((lab_pixels - nearest_lab) ** 2, axis=-1) target_rgb = palette_linear[i] + seg_t[..., np.newaxis] * (palette_linear[j] - palette_linear[i]) @@ -260,8 +248,8 @@ def gamut_compress( best_dist_sq = np.where(better, dist_sq, best_dist_sq) best_target_rgb = np.where(better[..., np.newaxis], target_rgb, best_target_rgb) - # Fallback: nearest palette vertex — handles achromatic pixels (hue undefined) - # and any degenerate cases where all segments clip to endpoints. + # Also consider nearest palette vertex (catches pixels nearer to a vertex + # than to any edge segment) diff_v = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] # (H, W, N, 3) dist_sq_v = np.sum(diff_v**2, axis=-1) # (H, W, N) nearest_v_idx = np.argmin(dist_sq_v, axis=-1) # (H, W) @@ -273,10 +261,14 @@ def gamut_compress( nearest_dist = np.sqrt(best_dist_sq) - # Smoothstep blend factor: 0 inside gamut, ramps to 1 far outside - # Threshold chosen in OKLab space: 0.05 ≈ just-noticeable difference - _THRESHOLD = 0.05 - _THRESHOLD_MAX = 0.20 + # Smoothstep: no effect below _THRESHOLD, full effect at _THRESHOLD_MAX. + # Distances in OKLab units (L ∈ [0,1], a/b ∈ [-0.5, 0.5]): + # Natural photos / near-gamut colors: dist < 0.10 → unaffected + # sRGB primaries on a 6-color display: dist ≈ 0.19–0.22 → slight blend + # Vivid purple on BWGBRY display: dist ≈ 0.29 → meaningful blend + # Magenta: dist ≈ 0.35+ → strong blend + _THRESHOLD = 0.15 + _THRESHOLD_MAX = 0.45 blend_t = np.clip((nearest_dist - _THRESHOLD) / (_THRESHOLD_MAX - _THRESHOLD), 0.0, 1.0) blend_factor = blend_t * blend_t * (3.0 - 2.0 * blend_t) * strength # smoothstep × strength @@ -286,71 +278,21 @@ def gamut_compress( return clipped -def _optimize_compression_strength( - pixels_linear: np.ndarray, - palette_linear: np.ndarray, - compress_fn: object, - candidates: list[float], -) -> float: - """Select compression strength that minimizes mean OKLab nearest-palette distance. - - Evaluates each candidate strength by applying compression and measuring the - mean OKLab distance between compressed pixels and their nearest palette color. - No dithering required — error diffusion redistributes but does not change - total quantization error, so pre-diffusion distance is a valid proxy. - - Args: - pixels_linear: Image in linear RGB, shape (H, W, 3). - palette_linear: Palette in linear RGB, shape (N, 3). - compress_fn: Callable(pixels, palette, strength) -> compressed_pixels. - candidates: Strength values to evaluate (must include 0.0). - - Returns: - Strength from candidates with minimum mean quantization error. - """ - lab_palette = rgb_to_lab(palette_linear) # (N, 3) - - best_strength = candidates[0] - best_error = float("inf") - - for s in candidates: - if s > 0.0: - compressed = compress_fn(pixels_linear, palette_linear, s) # type: ignore[operator] - else: - compressed = pixels_linear - lab_pixels = rgb_to_lab(compressed) - diff = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] # (H, W, N, 3) - nearest_dist = np.sqrt(np.min(np.sum(diff**2, axis=-1), axis=-1)) # (H, W) - error = float(np.mean(nearest_dist)) - - if error < best_error: - best_error = error - best_strength = s - - return best_strength - - def auto_gamut_compress( pixels_linear: np.ndarray, palette_linear: np.ndarray, ) -> np.ndarray: - """Conditionally apply gamut compression based on image content. + """Apply moderate gamut compression suitable for most images. - Selects the compression strength that minimizes mean OKLab nearest-palette - distance across 5 candidate values. Strength 0.0 is always a candidate, so - images already within the display's gamut are returned unchanged. + Uses a fixed strength of 0.5, which compresses strongly out-of-gamut colors + (vivid purple, magenta) while leaving near-gamut colors nearly unchanged. + Pass an explicit strength to gamut_compress() for finer control. Args: pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. palette_linear: Palette in linear RGB, shape (N, 3). Returns: - Modified pixels_linear array with optimal gamut compression applied. + Modified pixels_linear array with gamut compression applied. """ - _CANDIDATES = [0.0, 0.25, 0.5, 0.75, 1.0] - strength = _optimize_compression_strength(pixels_linear, palette_linear, gamut_compress, _CANDIDATES) - - if strength <= 0.0: - return pixels_linear - - return gamut_compress(pixels_linear, palette_linear, strength=strength) + return gamut_compress(pixels_linear, palette_linear, strength=1.0) From 86b107513085ba7b0967022f3c3b95f677c84104 Mon Sep 17 00:00:00 2001 From: gabriel Date: Mon, 16 Mar 2026 17:46:48 +0100 Subject: [PATCH 16/16] fix(tests): update LAB/auto-compress tests for OKLab and conservative auto strength --- packages/python/scripts/pipeline.py | 294 +++++++++++++++++++ packages/python/tests/test_color_matching.py | 43 ++- packages/python/tests/test_dithering.py | 2 +- 3 files changed, 316 insertions(+), 23 deletions(-) create mode 100644 packages/python/scripts/pipeline.py diff --git a/packages/python/scripts/pipeline.py b/packages/python/scripts/pipeline.py new file mode 100644 index 0000000..edb4b97 --- /dev/null +++ b/packages/python/scripts/pipeline.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Pipeline visualization: saves one image per dithering step for inspection. + +Usage: + uv run scripts/pipeline.py [options] [image1.jpg image2.jpg ...] + +Options: + --tc auto|0|0.5|1.0 Tone compression (default: auto) + --gc auto|0|0.5|1.0 Gamut compression (default: auto) + --palette SPECTRA_V2|SPECTRA_V1|BWRY_3_97|... (default: SPECTRA_V2) + --mode BURKES|FLOYD_STEINBERG|ATKINSON|... (default: BURKES) + +Output: pipeline_out/pipeline__tc_gc.png — a vertical strip: + 1. Input (sRGB) + 2. After sRGB→linear + luminance histogram + 3. After tone compression + luminance histogram + 4. After gamut compression | OKLab movement heatmap + 5. Direct palette map (no error diffusion) + 6. Final dithered output + +Defaults to marienplatz.jpg if no image paths given. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import epaper_dithering as _lib +import numpy as np +from epaper_dithering import DitherMode +from epaper_dithering.algorithms import get_palette_colors +from epaper_dithering.color_space import linear_to_srgb, srgb_to_linear +from epaper_dithering.color_space_lab import rgb_to_lab +from epaper_dithering.tone_map import ( + auto_compress_dynamic_range, + auto_gamut_compress, + compress_dynamic_range, + gamut_compress, +) +from PIL import Image, ImageDraw, ImageFont + +# ── Layout constants ────────────────────────────────────────────────────────── +WIDTH, HEIGHT = 800, 480 +LABEL_H = 20 +HIST_H = 80 + +# ── Luminance weights (ITU-R BT.709) ───────────────────────────────────────── +_WR, _WG, _WB = 0.2126729, 0.7151522, 0.0721750 + +# ── Available palettes ──────────────────────────────────────────────────────── +PALETTES: dict[str, _lib.ColorPalette] = { + "SPECTRA_V2": _lib.SPECTRA_7_3_6COLOR_V2, + "SPECTRA_V1": _lib.SPECTRA_7_3_6COLOR, + "BWRY_3_97": _lib.BWRY_3_97, + "BWRY_4_2": _lib.BWRY_4_2, + "MONO_4_26": _lib.MONO_4_26, + "SOLUM_BWR": _lib.SOLUM_BWR, + "HANSHOW_BWR": _lib.HANSHOW_BWR, + "HANSHOW_BWY": _lib.HANSHOW_BWY, +} + +# ── Available dither modes ──────────────────────────────────────────────────── +MODES: dict[str, DitherMode] = {m.name: m for m in DitherMode} + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def parse_compression(val: str) -> float | str: + """Parse 'auto', '0', '0.5', '1.0' → "auto" or float.""" + if val.lower() == "auto": + return "auto" + return float(val) + + +def fmt(val: float | str) -> str: + return val if isinstance(val, str) else f"{val:.2g}" + + +def load_font(size: int = 13) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for name in ("DejaVuSans.ttf", "Arial.ttf", "FreeSans.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + pass + return ImageFont.load_default() + + +def lum(px: np.ndarray) -> np.ndarray: + return _WR * px[:, :, 0] + _WG * px[:, :, 1] + _WB * px[:, :, 2] + + +def to_pil(pixels_linear: np.ndarray) -> Image.Image: + srgb = linear_to_srgb(np.clip(pixels_linear, 0.0, 1.0).astype(np.float32)) + return Image.fromarray(srgb, "RGB") + + +def add_label(img: Image.Image, text: str, font: ImageFont.ImageFont) -> Image.Image: + out = Image.new("RGB", (img.width, img.height + LABEL_H), (20, 20, 20)) + out.paste(img, (0, LABEL_H)) + ImageDraw.Draw(out).text((6, 4), text, fill=(220, 220, 220), font=font) + return out + + +def with_histogram( + img: Image.Image, + Y: np.ndarray, + black_Y: float, + white_Y: float, + font: ImageFont.ImageFont, +) -> Image.Image: + w = img.width + strip = Image.new("RGB", (w, HIST_H), (30, 30, 30)) + draw = ImageDraw.Draw(strip) + + hist, _ = np.histogram(Y.ravel(), bins=256, range=(0.0, 1.0)) + max_count = max(int(hist.max()), 1) + bar_w = max(1, w // 256) + for i, count in enumerate(hist): + x = int(i * w / 256) + bh = int((count / max_count) * (HIST_H - 4)) + draw.rectangle([x, HIST_H - bh - 2, x + bar_w - 1, HIST_H - 2], fill=(130, 130, 130)) + + def vline(val: float, color: tuple[int, int, int], label: str) -> None: + x = int(val * w) + draw.line([(x, 0), (x, HIST_H)], fill=color, width=2) + draw.text((x + 3, 2), label, fill=color, font=font) + + p2 = float(np.percentile(Y, 2)) + p98 = float(np.percentile(Y, 98)) + vline(black_Y, (80, 80, 255), f"disp_black {black_Y:.3f}") + vline(white_Y, (255, 80, 80), f"disp_white {white_Y:.3f}") + vline(p2, (0, 220, 220), f"p2={p2:.3f}") + vline(p98, (255, 160, 0), f"p98={p98:.3f}") + + combined = Image.new("RGB", (w, img.height + HIST_H)) + combined.paste(img, (0, 0)) + combined.paste(strip, (0, img.height)) + return combined + + +def gamut_heatmap(before: np.ndarray, after: np.ndarray) -> Image.Image: + dist = np.sqrt(np.sum((rgb_to_lab(after) - rgb_to_lab(before)) ** 2, axis=-1)) + scaled = np.clip(dist / 0.3 * 255, 0, 255).astype(np.uint8) + rgb = np.zeros((*scaled.shape, 3), dtype=np.uint8) + rgb[:, :, 0] = scaled + rgb[:, :, 1] = (scaled * 0.25).astype(np.uint8) + return Image.fromarray(rgb, "RGB") + + +def apply_tc(pixels_linear: np.ndarray, palette_linear: np.ndarray, tc: float | str) -> tuple[np.ndarray, str]: + if tc == 0.0: + return pixels_linear, "disabled" + if tc == "auto": + result = auto_compress_dynamic_range(pixels_linear.copy(), palette_linear) + changed = not np.array_equal(result, pixels_linear) + return result, "applied" if changed else "SKIPPED (image fits display range)" + result = compress_dynamic_range(pixels_linear.copy(), palette_linear, float(tc)) + return result, f"strength={tc:.2g}" + + +def apply_gc(pixels_linear: np.ndarray, palette_linear: np.ndarray, gc: float | str) -> tuple[np.ndarray, str]: + if gc == 0.0: + return pixels_linear, "disabled" + if gc == "auto": + result = auto_gamut_compress(pixels_linear.copy(), palette_linear) + return result, "auto" + result = gamut_compress(pixels_linear.copy(), palette_linear, float(gc)) + return result, f"strength={gc:.2g}" + + +# ── Main per-image function ─────────────────────────────────────────────────── + + +def run( + image_path: Path, + palette: _lib.ColorPalette, + palette_name: str, + tc: float | str, + gc: float | str, + mode: DitherMode, + out_path: Path, +) -> None: + src = Image.open(image_path).convert("RGB").resize((WIDTH, HEIGHT), Image.LANCZOS) + font = load_font(13) + + palette_srgb = get_palette_colors(palette) + palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32)) + black_Y = float(_WR * palette_linear[0, 0] + _WG * palette_linear[0, 1] + _WB * palette_linear[0, 2]) + white_Y = float(_WR * palette_linear[1, 0] + _WG * palette_linear[1, 1] + _WB * palette_linear[1, 2]) + + # ── Step 1: Input ────────────────────────────────────────────────────────── + panel1 = add_label(src.copy(), f"1. Input (sRGB) palette={palette_name}", font) + + # ── Step 2: sRGB→linear ─────────────────────────────────────────────────── + pixels_srgb = np.array(src, dtype=np.uint8) + pixels_linear = srgb_to_linear(pixels_srgb.astype(np.float32)) + Y2 = lum(pixels_linear) + p2_2, p98_2 = float(np.percentile(Y2, 2)), float(np.percentile(Y2, 98)) + panel2 = with_histogram(to_pil(pixels_linear), Y2, black_Y, white_Y, font) + panel2 = add_label( + panel2, + f"2. After sRGB→linear (p2={p2_2:.3f} p98={p98_2:.3f} | display: [{black_Y:.3f}, {white_Y:.3f}])", + font, + ) + + # ── Step 3: Tone compression ─────────────────────────────────────────────── + pixels_tc, tc_note = apply_tc(pixels_linear, palette_linear, tc) + Y3 = lum(pixels_tc) + p2_3, p98_3 = float(np.percentile(Y3, 2)), float(np.percentile(Y3, 98)) + panel3 = with_histogram(to_pil(pixels_tc), Y3, black_Y, white_Y, font) + panel3 = add_label( + panel3, + f"3. Tone compression [tc={fmt(tc)} — {tc_note}] (p2={p2_3:.3f} p98={p98_3:.3f})", + font, + ) + + # ── Step 4: Gamut compression ───────────────────────────────────────────── + pixels_gc, gc_note = apply_gc(pixels_tc, palette_linear, gc) + n_moved = int(np.sum(np.any(np.abs(pixels_gc - pixels_tc) > 1e-5, axis=-1))) + pct_moved = 100.0 * n_moved / (WIDTH * HEIGHT) + + heatmap = gamut_heatmap(pixels_tc, pixels_gc) + half_w = WIDTH // 2 + side = Image.new("RGB", (WIDTH, HEIGHT)) + side.paste(to_pil(pixels_gc).resize((half_w, HEIGHT), Image.LANCZOS), (0, 0)) + side.paste(heatmap.resize((half_w, HEIGHT), Image.LANCZOS), (half_w, 0)) + d = ImageDraw.Draw(side) + d.text((4, 4), "result", fill=(200, 200, 200), font=font) + d.text((half_w + 4, 4), f"heatmap: {pct_moved:.1f}% pixels moved", fill=(255, 130, 80), font=font) + panel4 = add_label( + side, + f"4. Gamut compression [gc={fmt(gc)} — {gc_note}] {n_moved:,} px / {pct_moved:.1f}% affected | heatmap", + font, + ) + + # ── Step 5: Direct palette map ──────────────────────────────────────────── + direct = _lib.dither_image(src, palette, DitherMode.NONE, tone_compression=tc, gamut_compression=gc) + panel5 = add_label( + direct.convert("RGB"), f"5. Direct palette map (no error diffusion) tc={fmt(tc)} gc={fmt(gc)}", font + ) + + # ── Step 6: Final output ────────────────────────────────────────────────── + final = _lib.dither_image(src, palette, mode, tone_compression=tc, gamut_compression=gc) + panel6 = add_label(final.convert("RGB"), f"6. Final: {mode.name} tc={fmt(tc)} gc={fmt(gc)}", font) + + # ── Assemble vertical strip ─────────────────────────────────────────────── + panels = [panel1, panel2, panel3, panel4, panel5, panel6] + total_h = sum(p.height for p in panels) + sheet = Image.new("RGB", (WIDTH, total_h), (10, 10, 10)) + y = 0 + for p in panels: + sheet.paste(p, (0, y)) + y += p.height + + sheet.save(out_path) + print(f" → {out_path} ({sheet.width}×{sheet.height})") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Pipeline visualization for epaper dithering.") + parser.add_argument("images", nargs="*", help="Image paths (default: marienplatz.jpg)") + parser.add_argument("--tc", default="auto", help="Tone compression: auto|0|0.5|1.0 (default: auto)") + parser.add_argument("--gc", default="auto", help="Gamut compression: auto|0|0.5|1.0 (default: auto)") + parser.add_argument("--palette", default="SPECTRA_V2", choices=list(PALETTES), help="Palette (default: SPECTRA_V2)") + parser.add_argument("--mode", default="BURKES", choices=list(MODES), help="Dither mode (default: BURKES)") + args = parser.parse_args() + + tc = parse_compression(args.tc) + gc = parse_compression(args.gc) + palette = PALETTES[args.palette] + mode = MODES[args.mode] + + here = Path(__file__).parent.parent + image_paths = [here / p for p in args.images] if args.images else [here / "marienplatz.jpg"] + out_dir = here / "pipeline_out" + out_dir.mkdir(parents=True, exist_ok=True) + + suffix = f"_tc{fmt(tc)}_gc{fmt(gc)}_{args.palette}" + for img_path in image_paths: + if not img_path.exists(): + print(f" skip (not found): {img_path}") + continue + out_path = out_dir / f"pipeline_{img_path.stem}{suffix}.png" + print(f"Processing {img_path.name} (tc={fmt(tc)} gc={fmt(gc)} palette={args.palette} mode={args.mode})...") + run(img_path, palette, args.palette, tc, gc, mode, out_path) + + +if __name__ == "__main__": + main() diff --git a/packages/python/tests/test_color_matching.py b/packages/python/tests/test_color_matching.py index f999201..8d88621 100644 --- a/packages/python/tests/test_color_matching.py +++ b/packages/python/tests/test_color_matching.py @@ -13,12 +13,12 @@ class TestLABConversion: """Test RGB to LAB conversion accuracy.""" def test_white_converts_to_l100(self): - """Pure white in linear RGB should produce L*=100, a=0, b=0.""" + """Pure white in linear RGB should produce L=1, a=0, b=0 (OKLab).""" white = np.array([1.0, 1.0, 1.0]) lab = rgb_to_lab(white) - assert lab[0] == pytest.approx(100.0, abs=0.1) - assert lab[1] == pytest.approx(0.0, abs=0.5) - assert lab[2] == pytest.approx(0.0, abs=0.5) + assert lab[0] == pytest.approx(1.0, abs=1e-4) + assert lab[1] == pytest.approx(0.0, abs=1e-3) + assert lab[2] == pytest.approx(0.0, abs=1e-3) def test_black_converts_to_l0(self): """Pure black should produce L*=0, a=0, b=0.""" @@ -29,16 +29,16 @@ def test_black_converts_to_l0(self): assert lab[2] == pytest.approx(0.0, abs=0.5) def test_midgray_lightness(self): - """50% linear gray should produce L* around 76 (perceptual midpoint).""" + """50% linear gray should produce L around 0.79 in OKLab (cbrt(0.5) ≈ 0.794).""" gray = np.array([0.5, 0.5, 0.5]) lab = rgb_to_lab(gray) - assert 70 < lab[0] < 80, f"50% linear gray L* should be ~76, got {lab[0]:.1f}" + assert 0.75 < lab[0] < 0.85, f"50% linear gray L should be ~0.79, got {lab[0]:.3f}" def test_red_has_positive_a(self): - """Pure red should have positive a* (red-green axis).""" + """Pure red should have positive a (red-green axis in OKLab, range ~[-0.5, 0.5]).""" red = np.array([1.0, 0.0, 0.0]) lab = rgb_to_lab(red) - assert lab[1] > 50, f"Red should have high positive a*, got {lab[1]:.1f}" + assert lab[1] > 0.1, f"Red should have positive a, got {lab[1]:.3f}" def test_batch_matches_single(self): """Batch conversion should match individual conversions.""" @@ -191,16 +191,17 @@ def _luminance(self, pixels): """Compute per-pixel luminance.""" return 0.2126729 * pixels[:, :, 0] + 0.7151522 * pixels[:, :, 1] + 0.0721750 * pixels[:, :, 2] - def test_full_range_gradient_matches_linear(self): - """Full-range gradient should produce similar result to linear compression.""" + def test_full_range_gradient_compresses_highlights(self): + """Full-range gradient should have lower p98 after auto compression than original.""" palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) pixels = np.linspace(0.0, 1.0, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32) auto_result = auto_compress_dynamic_range(pixels.copy(), palette_linear) - linear_result = compress_dynamic_range(pixels.copy(), palette_linear, 1.0) + Y_orig = self._luminance(pixels) + Y_auto = self._luminance(auto_result) - # For a full-range gradient, auto should be close to linear 1.0 - np.testing.assert_allclose(auto_result, linear_result, atol=0.05) + # Auto should reduce highlights — p98 of result < p98 of input + assert float(np.percentile(Y_auto, 98)) < float(np.percentile(Y_orig, 98)) def test_narrow_range_has_more_contrast(self): """Narrow-range image should have more contrast with auto than fixed 1.0.""" @@ -229,11 +230,8 @@ def test_uniform_image_falls_back(self): np.testing.assert_allclose(result, expected, atol=1e-5) def test_output_luminance_within_display_range(self): - """Auto-compressed output luminance should stay within display range (with tolerance).""" + """Auto-compressed output should be closer to display range than the raw input.""" palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - black_Y = float( - 0.2126729 * palette_linear[0, 0] + 0.7151522 * palette_linear[0, 1] + 0.0721750 * palette_linear[0, 2] - ) white_Y = float( 0.2126729 * palette_linear[1, 0] + 0.7151522 * palette_linear[1, 1] + 0.0721750 * palette_linear[1, 2] ) @@ -242,8 +240,9 @@ def test_output_luminance_within_display_range(self): result = auto_compress_dynamic_range(pixels, palette_linear) result_Y = self._luminance(result) - # p2/p98 percentile-based: the 2% outliers at each end may exceed the range slightly - p2 = float(np.percentile(result_Y, 2)) - p98 = float(np.percentile(result_Y, 98)) - assert p2 >= black_Y - 0.01, f"p2 luminance {p2:.4f} should be near black_Y {black_Y:.4f}" - assert p98 <= white_Y + 0.01, f"p98 luminance {p98:.4f} should be near white_Y {white_Y:.4f}" + # Auto compression uses conservative strength — p98 should be reduced + # toward white_Y but won't necessarily reach it for a balanced gradient. + p98_orig = float(np.percentile(self._luminance(pixels), 98)) + p98_result = float(np.percentile(result_Y, 98)) + assert p98_result < p98_orig, "Auto compression should reduce highlights" + assert p98_result > white_Y, "Conservative compression leaves some overshoot" diff --git a/packages/python/tests/test_dithering.py b/packages/python/tests/test_dithering.py index b30a5a3..4f555ed 100644 --- a/packages/python/tests/test_dithering.py +++ b/packages/python/tests/test_dithering.py @@ -144,7 +144,7 @@ def test_ordered_dithering_uses_threshold_correctly(self): black_count = pixels.count(0) white_count = pixels.count(1) ratio = black_count / (black_count + white_count) - assert 0.20 < ratio < 0.35, f"Should be ~25% black with LAB matching, got ratio {ratio:.2f}" + assert 0.10 < ratio < 0.45, f"Should use mostly white with some black, got ratio {ratio:.2f}" def test_all_error_diffusion_with_serpentine(self): """Test all error diffusion algorithms accept serpentine parameter."""