From 2ade88ad6d13c8874c5cf686ed707383265f2236 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:21:24 -0400 Subject: [PATCH 1/2] chore(dither): FFI/binding robustness, pipeline repair, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 3-5 of the code-review follow-up (stacked on the math + perf fixes): Robustness (Issues #7, #9): - ImageBuffer::new validates width and buffer length (panics on zero width / ragged buffer) instead of silently truncating in release; contract backstopped by tests. - Python and WASM dither_image validate image dimensions and accent_idx before constructing the buffer/palette, returning proper ValueError/JsValue errors rather than panicking the extension. - JS ditherImage throws a clear error when a measured palette's accent name is not one of its colors (previously passed -1 into WASM → opaque abort). Adds a vitest case. Tooling & docs (Issues #5, #6, #10): - Expose tone_compress, gamut_compress, and rgb_to_oklab_buffer on the Python _rs module and repair scripts/pipeline.py, which imported modules deleted in the Rust port. Runs end-to-end again. - Drop the stale examples/wab_sweep.rs reference in the WAB doc comment. - CALIBRATION.md: document that palette values must be sRGB-encoded (not linear-light), with the encoding formula for linear-workflow (DNG) measurements. CHANGELOGs updated for all three packages. Co-Authored-By: Claude Fable 5 --- packages/javascript/CHANGELOG.md | 10 ++ packages/javascript/src/core.ts | 5 + packages/javascript/tests/dithering.test.ts | 16 ++++ packages/python/CHANGELOG.md | 15 +++ packages/python/Cargo.lock | 4 +- packages/python/docs/CALIBRATION.md | 17 ++++ packages/python/scripts/pipeline.py | 78 ++++++++++----- packages/python/src/epaper_dithering/_rs.pyi | 7 ++ packages/python/src/lib.rs | 99 ++++++++++++++++++++ packages/rust/core/CHANGELOG.md | 28 ++++++ packages/rust/core/src/color_space_lab.rs | 8 +- packages/rust/core/src/types.rs | 32 ++++++- packages/rust/wasm/src/lib.rs | 20 ++++ 13 files changed, 308 insertions(+), 31 deletions(-) diff --git a/packages/javascript/CHANGELOG.md b/packages/javascript/CHANGELOG.md index 50cd291..6207e95 100644 --- a/packages/javascript/CHANGELOG.md +++ b/packages/javascript/CHANGELOG.md @@ -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) diff --git a/packages/javascript/src/core.ts b/packages/javascript/src/core.ts index 36ea2a8..a593f39 100644 --- a/packages/javascript/src/core.ts +++ b/packages/javascript/src/core.ts @@ -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; } diff --git a/packages/javascript/tests/dithering.test.ts b/packages/javascript/tests/dithering.test.ts index 6b4f061..380a323 100644 --- a/packages/javascript/tests/dithering.test.ts +++ b/packages/javascript/tests/dithering.test.ts @@ -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/ + ); + }); +}); diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md index b72bdc9..ea4d0d2 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -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) diff --git a/packages/python/Cargo.lock b/packages/python/Cargo.lock index fe1f360..da6d7f3 100644 --- a/packages/python/Cargo.lock +++ b/packages/python/Cargo.lock @@ -35,7 +35,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "epaper-dithering" -version = "3.0.0" +version = "5.0.3" dependencies = [ "epaper-dithering-core", "pyo3", @@ -43,7 +43,7 @@ dependencies = [ [[package]] name = "epaper-dithering-core" -version = "3.0.0" +version = "4.0.0" dependencies = [ "rayon", ] diff --git a/packages/python/docs/CALIBRATION.md b/packages/python/docs/CALIBRATION.md index a29744f..7861ce6 100644 --- a/packages/python/docs/CALIBRATION.md +++ b/packages/python/docs/CALIBRATION.md @@ -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`: diff --git a/packages/python/scripts/pipeline.py b/packages/python/scripts/pipeline.py index ee29646..9e0fca8 100644 --- a/packages/python/scripts/pipeline.py +++ b/packages/python/scripts/pipeline.py @@ -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 @@ -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 ─────────────────────────────────────────────────── @@ -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]) @@ -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) @@ -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) diff --git a/packages/python/src/epaper_dithering/_rs.pyi b/packages/python/src/epaper_dithering/_rs.pyi index 1e1cfd4..fecdcdf 100644 --- a/packages/python/src/epaper_dithering/_rs.pyi +++ b/packages/python/src/epaper_dithering/_rs.pyi @@ -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]: ... diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs index 9a4a0bb..d5c23b5 100644 --- a/packages/python/src/lib.rs +++ b/packages/python/src/lib.rs @@ -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; @@ -12,6 +14,22 @@ fn parse_mode(v: u8) -> PyResult { 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) -> ToneCompression { match v { None => ToneCompression::Auto, @@ -57,6 +75,7 @@ fn dither_image( gamut: Option, ) -> PyResult> { let _ = height; + validate_image(pixels, width)?; let img = ImageBuffer::new(pixels, width); let config = DitherConfig { mode: parse_mode(mode_id)?, @@ -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) @@ -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> { + 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 { + 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 { + 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, palette_bytes: &[u8], strength: Option) -> PyResult> { + 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, palette_bytes: &[u8], strength: Option) -> PyResult> { + 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) -> PyResult> { + 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)`. @@ -116,5 +212,8 @@ fn measured_palettes() -> Vec<(String, Vec, Vec, 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(()) } diff --git a/packages/rust/core/CHANGELOG.md b/packages/rust/core/CHANGELOG.md index f21a294..d6880cc 100644 --- a/packages/rust/core/CHANGELOG.md +++ b/packages/rust/core/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## Unreleased + +### Bug Fixes + +* **ordered dither:** use the standard zero-mean Bayer normalization `((v+0.5)/16 − 0.5)`. + The previous matrix had mean −1/32 and darkened every ordered-dithered image by ~8/255. +* **ordered dither:** scale the threshold amplitude to the palette's quantization step for + grayscale palettes (`1/(levels−1)`) so dense ramps (GRAYSCALE_16) are no longer swamped + by full-range dither noise; mono/color palettes are unchanged. +* **tone:** clamp the auto dynamic-range remap to `[0, 1]` so percentile outliers saturate + at the display black/white points instead of extrapolating past them (dark outliers were + crushed to pure black). +* **tone:** honor `strength` and preserve chroma for near-black pixels in the auto compressor; + clamp skewness before `powf` to avoid a NaN that could poison every output pixel on + high-key images. +* **tone:** pivot the shadows/highlights S-curve at perceptual mid-gray (gamma space) rather + than linear 0.5 (≈ sRGB 188), and clamp the strength inputs to `[0, 1]`. +* **types:** `ImageBuffer::new` now validates dimensions (panics on zero width / ragged + buffer) instead of silently truncating in release builds. + +### Performance + +* Fold the sRGB→XYZ→LMS conversion into Ottosson's single combined matrix (and matching + inverse), halving the per-pixel matrix work in OKLab conversion. +* **ordered dither:** precompute a per-threshold gamma LUT, removing three `powf` calls per + pixel (~13% faster); output is byte-identical. +* **gamut:** hoist the O(n²) palette-edge geometry out of the per-pixel loop. + ## [4.0.0](https://github.com/OpenDisplay/epaper-dithering/compare/epaper-dithering-core-v3.0.0...epaper-dithering-core-v4.0.0) (2026-05-21) diff --git a/packages/rust/core/src/color_space_lab.rs b/packages/rust/core/src/color_space_lab.rs index fcaa5b5..b76e5fe 100644 --- a/packages/rust/core/src/color_space_lab.rs +++ b/packages/rust/core/src/color_space_lab.rs @@ -18,10 +18,10 @@ const M2: [[f64; 3]; 3] = [ /// Chromatic-axes weight for `match_pixel_oklab`. /// -/// Empirically validated by `examples/wab_sweep.rs` against the regression fixture set -/// (Burkes + Spectra 6-color, mean OKLab ΔE on 4×4-block-averaged outputs). 1.5 is a -/// conservative choice that improves saturated subjects without over-saturating neutrals; -/// see GitHub issue #28 for the methodology and justification. +/// Empirically validated by sweeping `wab` against the regression fixture set (Burkes + +/// Spectra 6-color, mean OKLab ΔE on 4×4-block-averaged outputs). 1.5 is a conservative +/// choice that improves saturated subjects without over-saturating neutrals; see GitHub +/// issue #28 for the methodology and justification. pub const WAB: f64 = 1.5; #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/packages/rust/core/src/types.rs b/packages/rust/core/src/types.rs index efbb7c2..e27b211 100644 --- a/packages/rust/core/src/types.rs +++ b/packages/rust/core/src/types.rs @@ -7,9 +7,39 @@ pub struct ImageBuffer<'a> { impl<'a> ImageBuffer<'a> { /// Create from flat RGB bytes (len = width × height × 3). + /// + /// # Panics + /// Panics if `width == 0` or `data.len()` is not exactly `width × height × 3`. FFI + /// boundaries validate and return errors before reaching this contract backstop. pub fn new(data: &'a [u8], width: usize) -> Self { + assert!(width > 0, "image width must be non-zero"); let height = data.len() / 3 / width; - debug_assert_eq!(data.len(), width * height * 3, "pixel buffer size mismatch"); + assert_eq!(data.len(), width * height * 3, "pixel buffer size mismatch (len={}, width={width})", data.len()); Self { data, width, height } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_accepts_well_formed_buffer() { + let data = [0u8; 2 * 3 * 3]; // 3×2 RGB + let img = ImageBuffer::new(&data, 3); + assert_eq!(img.height, 2); + } + + #[test] + #[should_panic(expected = "width must be non-zero")] + fn new_rejects_zero_width() { + ImageBuffer::new(&[0u8; 3], 0); + } + + #[test] + #[should_panic(expected = "pixel buffer size mismatch")] + fn new_rejects_ragged_buffer() { + // 7 bytes is neither a whole number of pixels nor rows for width 2. + ImageBuffer::new(&[0u8; 7], 2); + } +} diff --git a/packages/rust/wasm/src/lib.rs b/packages/rust/wasm/src/lib.rs index 51d644d..67f00c9 100644 --- a/packages/rust/wasm/src/lib.rs +++ b/packages/rust/wasm/src/lib.rs @@ -48,6 +48,17 @@ pub fn dither_image( tone: Option, gamut: Option, ) -> Result, JsValue> { + if width == 0 { + return Err(JsValue::from_str("width must be greater than 0")); + } + if pixels.len() % 3 != 0 { + return Err(JsValue::from_str("pixel buffer length must be a multiple of 3")); + } + if (pixels.len() / 3) % width != 0 { + return Err(JsValue::from_str( + "pixel count is not a whole number of rows for the given width", + )); + } let img = ImageBuffer::new(pixels, width); let config = DitherConfig { mode: parse_mode(mode_id)?, @@ -69,6 +80,15 @@ pub fn dither_image( return Err(JsValue::from_str("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(JsValue::from_str("palette must have at least 2 colors")); + } + if accent_idx >= colors.len() { + return Err(JsValue::from_str(&format!( + "accent_idx {accent_idx} out of range for {}-color palette", + colors.len() + ))); + } let palette = Palette::new(colors, accent_idx); if let Ok(scheme) = ColorScheme::try_from(scheme_id) { Ok(dither_with_canonical(&img, &palette, scheme.palette(), config)) From ece8af725794d20c844052b91ae0b3a18d409bf6 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:20:44 -0400 Subject: [PATCH 2/2] style(python): apply ruff format to pipeline.py and _rs.pyi Match the project ruff config (import ordering + single-line stub signatures) so the prek/ruff-format CI hook passes. Co-Authored-By: Claude Fable 5 --- packages/python/scripts/pipeline.py | 6 +++--- packages/python/src/epaper_dithering/_rs.pyi | 8 ++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/python/scripts/pipeline.py b/packages/python/scripts/pipeline.py index 9e0fca8..9d86132 100644 --- a/packages/python/scripts/pipeline.py +++ b/packages/python/scripts/pipeline.py @@ -29,12 +29,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -import numpy as np -from PIL import Image, ImageDraw, ImageFont - import epaper_dithering as _lib import epaper_dithering._rs as _rs +import numpy as np from epaper_dithering import DitherMode +from PIL import Image, ImageDraw, ImageFont # ── Color-space helpers (self-contained; the Rust core owns the canonical math) ── @@ -67,6 +66,7 @@ def rgb_to_lab(px: np.ndarray) -> np.ndarray: 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 diff --git a/packages/python/src/epaper_dithering/_rs.pyi b/packages/python/src/epaper_dithering/_rs.pyi index fecdcdf..7a734d8 100644 --- a/packages/python/src/epaper_dithering/_rs.pyi +++ b/packages/python/src/epaper_dithering/_rs.pyi @@ -16,10 +16,6 @@ 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 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]: ...