Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/javascript/CHANGELOG.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changelog is generated by release please.

Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Bug Fixes

* Inherits the core dithering-correctness fixes (brightness-neutral ordered dither,
grayscale threshold scaling, tone-mapping clamps, perceptual shadows/highlights pivot)
via a rebuilt WASM core.
* `ditherImage` now throws a clear error when a measured palette's `accent` name is not one
of its colors, instead of passing an invalid index into WASM and aborting.

## [2.2.1](https://github.com/OpenDisplay/epaper-dithering/compare/javascript-v2.2.0...javascript-v2.2.1) (2026-03-12)


Expand Down
5 changes: 5 additions & 0 deletions packages/javascript/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export function ditherImage(
const colors = Object.values(palette.colors);
paletteBytes = new Uint8Array(colors.flatMap(c => [c.r, c.g, c.b]));
accentIdx = Object.keys(palette.colors).indexOf(palette.accent);
if (accentIdx < 0) {
throw new Error(
`accent color '${palette.accent}' not found in palette colors [${Object.keys(palette.colors).join(', ')}]`,
);
}
outputColors = colors;
}

Expand Down
16 changes: 16 additions & 0 deletions packages/javascript/tests/dithering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,19 @@ describe('DitherMode', () => {
expect(DitherMode.JARVIS_JUDICE_NINKE).toBe(8);
});
});

describe('measured palette validation', () => {
it('throws a clear error when the accent name is not a palette color', () => {
const image = createTestImage(4, 4, { r: 128, g: 128, b: 128 });
const badPalette = {
colors: {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
},
accent: 'crimson', // not present in colors
};
expect(() => ditherImage(image, badPalette, { mode: DitherMode.BURKES })).toThrow(
/accent color 'crimson' not found/
);
});
});
15 changes: 15 additions & 0 deletions packages/python/CHANGELOG.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changelog is generated by release please.

Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## Unreleased

### Bug Fixes

* Inherits the core dithering-correctness fixes: brightness-neutral ordered dither,
grayscale threshold scaling, auto tone-mapping clamps, and the perceptual
shadows/highlights pivot.
* Validate image dimensions and `accent_idx` at the FFI boundary, returning `ValueError`
instead of panicking the Rust extension on malformed input.

### Other

* Expose `tone_compress`, `gamut_compress`, and `rgb_to_oklab_buffer` on the `_rs` module
for tooling, and repair `scripts/pipeline.py` to the current API.

## [5.0.7](https://github.com/OpenDisplay/epaper-dithering/compare/epaper-dithering-v5.0.6...epaper-dithering-v5.0.7) (2026-06-29)


Expand Down
4 changes: 2 additions & 2 deletions packages/python/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions packages/python/docs/CALIBRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,23 @@ Normalized: (102 x 1.186, 8 x 1.175, 0 x 1.170)

Round to the nearest integer. Clamp to [0, 255] if any value exceeds the range.

> [!IMPORTANT]
> **Palette values must be sRGB-encoded (gamma), not linear-light.**
> The library decodes every palette color with the sRGB→linear transfer function before
> matching, so it expects standard gamma-encoded RGB — the same space a normal JPEG/PNG
> eyedropper reports. If you sampled from a **linear** workflow (e.g. a DNG developed with
> a *linear* tone curve, as in the SPECTRA v2 measurement), your normalized numbers are
> linear-light and must be gamma-encoded before you use them, or every color will read
> too dark. Apply the sRGB OETF per channel to each normalized value `c` in `[0, 1]`:
>
> ```
> encoded = 12.92 · c if c ≤ 0.0031308
> 1.055 · c^(1/2.4) − 0.055 otherwise
> ```
>
> then scale by 255. Standard photo editors (Photoshop/GIMP eyedropper on an sRGB export)
> already report gamma-encoded values, so no extra step is needed there.

## Step 6: Create Your ColorPalette

Use the normalized values to create a `ColorPalette`:
Expand Down
78 changes: 54 additions & 24 deletions packages/python/scripts/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,44 @@

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

import epaper_dithering as _lib
import epaper_dithering._rs as _rs
from epaper_dithering import DitherMode

# ── Color-space helpers (self-contained; the Rust core owns the canonical math) ──


def srgb_to_linear(a: np.ndarray) -> np.ndarray:
"""sRGB [0, 255] → linear [0, 1] (IEC 61966-2-1)."""
x = a.astype(np.float64) / 255.0
return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)


def linear_to_srgb(a: np.ndarray) -> np.ndarray:
"""Linear [0, 1] → sRGB uint8 [0, 255]."""
x = np.clip(a, 0.0, 1.0)
s = np.where(x <= 0.0031308, x * 12.92, 1.055 * x ** (1.0 / 2.4) - 0.055)
return np.clip(s * 255.0, 0, 255).astype(np.uint8)


def get_palette_colors(palette: _lib.ColorPalette) -> list[tuple[int, int, int]]:
"""Ordered sRGB colors of a measured palette."""
return list(palette.colors.values())


def _palette_bytes(palette: _lib.ColorPalette) -> bytes:
return bytes(c for rgb in get_palette_colors(palette) for c in rgb)


def rgb_to_lab(px: np.ndarray) -> np.ndarray:
"""Linear-RGB (H, W, 3) → OKLab (H, W, 3) via the Rust core."""
flat = np.ascontiguousarray(px, dtype=np.float64).reshape(-1).tolist()
out = _rs.rgb_to_oklab_buffer(flat)
return np.array(out, dtype=np.float64).reshape(px.shape)

# ── Layout constants ──────────────────────────────────────────────────────────
WIDTH, HEIGHT = 800, 480
LABEL_H = 20
Expand Down Expand Up @@ -152,25 +176,30 @@ def gamut_heatmap(before: np.ndarray, after: np.ndarray) -> Image.Image:
return Image.fromarray(rgb, "RGB")


def apply_tc(pixels_linear: np.ndarray, palette_linear: np.ndarray, tc: float | str) -> tuple[np.ndarray, str]:
def _run_step(fn, pixels_linear: np.ndarray, palette_bytes: bytes, strength: float | None) -> np.ndarray:
flat = np.ascontiguousarray(pixels_linear, dtype=np.float64).reshape(-1).tolist()
out = fn(flat, palette_bytes, strength)
return np.array(out, dtype=np.float64).reshape(pixels_linear.shape)


def apply_tc(pixels_linear: np.ndarray, palette_bytes: bytes, tc: float | str) -> tuple[np.ndarray, str]:
if tc == 0.0:
return pixels_linear, "disabled"
strength = None if tc == "auto" else float(tc)
result = _run_step(_rs.tone_compress, pixels_linear, palette_bytes, strength)
if tc == "auto":
result = auto_compress_dynamic_range(pixels_linear.copy(), palette_linear)
changed = not np.array_equal(result, pixels_linear)
changed = not np.allclose(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]:
def apply_gc(pixels_linear: np.ndarray, palette_bytes: bytes, 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}"
# gamut_compress: None → full strength (auto), else fixed strength.
strength = None if gc == "auto" else float(gc)
result = _run_step(_rs.gamut_compress, pixels_linear, palette_bytes, strength)
return result, "auto" if gc == "auto" else f"strength={gc:.2g}"


# ── Main per-image function ───────────────────────────────────────────────────
Expand All @@ -189,7 +218,8 @@ def run(
font = load_font(13)

palette_srgb = get_palette_colors(palette)
palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32))
palette_bytes = _palette_bytes(palette)
palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float64))
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])

Expand All @@ -209,7 +239,7 @@ def run(
)

# ── Step 3: Tone compression ───────────────────────────────────────────────
pixels_tc, tc_note = apply_tc(pixels_linear, palette_linear, tc)
pixels_tc, tc_note = apply_tc(pixels_linear, palette_bytes, 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)
Expand All @@ -220,7 +250,7 @@ def run(
)

# ── Step 4: Gamut compression ─────────────────────────────────────────────
pixels_gc, gc_note = apply_gc(pixels_tc, palette_linear, gc)
pixels_gc, gc_note = apply_gc(pixels_tc, palette_bytes, 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)

Expand Down
7 changes: 7 additions & 0 deletions packages/python/src/epaper_dithering/_rs.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@ def dither_image(
gamut: float | None = ...,
) -> bytes: ...
def measured_palettes() -> list[tuple[str, list[int], list[str], int, int]]: ...
def tone_compress(
pixels: list[float], palette_bytes: bytes, strength: float | None = ...
) -> list[float]: ...
def gamut_compress(
pixels: list[float], palette_bytes: bytes, strength: float | None = ...
) -> list[float]: ...
def rgb_to_oklab_buffer(pixels: list[float]) -> list[float]: ...
99 changes: 99 additions & 0 deletions packages/python/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use epaper_dithering_core::{
color_space_lab::rgb_to_oklab,
dither, dither_with_canonical, DitherConfig,
enums::{DitherMode, GamutCompression, ToneCompression},
measured_palettes::CATALOG,
palettes::{ColorScheme, Palette},
tone_map,
types::ImageBuffer,
};
use pyo3::exceptions::PyValueError;
Expand All @@ -12,6 +14,22 @@ fn parse_mode(v: u8) -> PyResult<DitherMode> {
DitherMode::try_from(v).map_err(|e| PyValueError::new_err(e.to_string()))
}

/// Validate image dimensions before constructing an `ImageBuffer` (which panics on mismatch).
fn validate_image(pixels: &[u8], width: usize) -> PyResult<()> {
if width == 0 {
return Err(PyValueError::new_err("width must be greater than 0"));
}
if !pixels.len().is_multiple_of(3) {
return Err(PyValueError::new_err("pixel buffer length must be a multiple of 3"));
}
if !(pixels.len() / 3).is_multiple_of(width) {
return Err(PyValueError::new_err(
"pixel count is not a whole number of rows for the given width",
));
}
Ok(())
}

fn parse_tone(v: Option<f64>) -> ToneCompression {
match v {
None => ToneCompression::Auto,
Expand Down Expand Up @@ -57,6 +75,7 @@ fn dither_image(
gamut: Option<f64>,
) -> PyResult<Vec<u8>> {
let _ = height;
validate_image(pixels, width)?;
let img = ImageBuffer::new(pixels, width);
let config = DitherConfig {
mode: parse_mode(mode_id)?,
Expand All @@ -75,6 +94,15 @@ fn dither_image(
return Err(PyValueError::new_err("palette_bytes length must be a multiple of 3"));
}
let colors: Vec<[u8; 3]> = bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
if colors.len() < 2 {
return Err(PyValueError::new_err("palette must have at least 2 colors"));
}
if accent_idx >= colors.len() {
return Err(PyValueError::new_err(format!(
"accent_idx {accent_idx} out of range for {}-color palette",
colors.len()
)));
}
let palette = Palette::new(colors, accent_idx);
if let Some(id) = scheme_id {
let scheme = ColorScheme::try_from(id)
Expand All @@ -93,6 +121,74 @@ fn dither_image(
}
}

// ── Preprocessing steps (linear-RGB buffers) for tooling/visualization ──────────
//
// These expose the pre-dither pipeline stages so scripts can inspect intermediate
// buffers. Input/output are flat linear-RGB f64 (length a multiple of 3).

fn to_linear_pixels(pixels: &[f64]) -> PyResult<Vec<[f64; 3]>> {
if !pixels.len().is_multiple_of(3) {
return Err(PyValueError::new_err("pixel buffer length must be a multiple of 3"));
}
Ok(pixels.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
}

fn palette_from_bytes(palette_bytes: &[u8]) -> PyResult<Palette> {
if !palette_bytes.len().is_multiple_of(3) {
return Err(PyValueError::new_err("palette_bytes length must be a multiple of 3"));
}
let colors: Vec<[u8; 3]> = palette_bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
if colors.len() < 2 {
return Err(PyValueError::new_err("palette must have at least 2 colors"));
}
// accent_idx is irrelevant for tone/gamut ops; use 0.
Ok(Palette::new(colors, 0))
}

fn flatten(pixels: Vec<[f64; 3]>) -> Vec<f64> {
pixels.into_iter().flatten().collect()
}

/// Dynamic-range compression on a linear-RGB buffer. `strength=None` → auto (histogram-based).
#[pyfunction]
#[pyo3(signature = (pixels, palette_bytes, strength=None))]
fn tone_compress(pixels: Vec<f64>, palette_bytes: &[u8], strength: Option<f64>) -> PyResult<Vec<f64>> {
let mut px = to_linear_pixels(&pixels)?;
let palette = palette_from_bytes(palette_bytes)?;
match strength {
None => tone_map::auto_compress_dynamic_range(&mut px, &palette),
Some(s) if s > 0.0 => tone_map::compress_dynamic_range(&mut px, &palette, s),
_ => {}
}
Ok(flatten(px))
}

/// Gamut compression on a linear-RGB buffer. `strength=None` → full strength (1.0).
#[pyfunction]
#[pyo3(signature = (pixels, palette_bytes, strength=None))]
fn gamut_compress(pixels: Vec<f64>, palette_bytes: &[u8], strength: Option<f64>) -> PyResult<Vec<f64>> {
let mut px = to_linear_pixels(&pixels)?;
let palette = palette_from_bytes(palette_bytes)?;
let s = strength.unwrap_or(1.0);
if s > 0.0 {
tone_map::gamut_compress(&mut px, &palette, s);
}
Ok(flatten(px))
}

/// Convert a flat linear-RGB f64 buffer to flat OKLab f64 (L, a, b per pixel).
#[pyfunction]
fn rgb_to_oklab_buffer(pixels: Vec<f64>) -> PyResult<Vec<f64>> {
let px = to_linear_pixels(&pixels)?;
Ok(px
.into_iter()
.flat_map(|[r, g, b]| {
let lab = rgb_to_oklab(r, g, b);
[lab.l, lab.a, lab.b]
})
.collect())
}

/// Returns all measured palettes from the Rust catalog.
///
/// Each entry is `(id, rgb_bytes, color_names, accent_idx, scheme_id)`.
Expand All @@ -116,5 +212,8 @@ fn measured_palettes() -> Vec<(String, Vec<u8>, Vec<String>, usize, u8)> {
fn _rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(dither_image, m)?)?;
m.add_function(wrap_pyfunction!(measured_palettes, m)?)?;
m.add_function(wrap_pyfunction!(tone_compress, m)?)?;
m.add_function(wrap_pyfunction!(gamut_compress, m)?)?;
m.add_function(wrap_pyfunction!(rgb_to_oklab_buffer, m)?)?;
Ok(())
}
Loading
Loading