From d1da1ebb6b7907364cf9800046e996acf000af5d Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:18:39 +0200 Subject: [PATCH 01/12] feat(core): add Direct Binary Search (DBS) refinement Optional post-dither refinement step (`DitherConfig::dbs`, off by default) that minimises perceived error under a luminance/chrominance eye model in YyCxCz (Kolpatzik & Bouman CSF constants). Exact canonical pixels stay pinned. Ported from OpenDithering's dbs.ts (MIT). Adds the `dbs_compare` example for side-by-side PNG/HTML comparison. Bindings pass `dbs: None`; exposing it is left for a follow-up. --- packages/python/src/lib.rs | 1 + packages/rust/core/examples/dbs_compare.rs | 236 +++++++++ packages/rust/core/src/dbs.rs | 538 +++++++++++++++++++++ packages/rust/core/src/lib.rs | 67 ++- packages/rust/wasm/src/lib.rs | 1 + 5 files changed, 840 insertions(+), 3 deletions(-) create mode 100644 packages/rust/core/examples/dbs_compare.rs create mode 100644 packages/rust/core/src/dbs.rs diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs index 98d396a..10bd6ff 100644 --- a/packages/python/src/lib.rs +++ b/packages/python/src/lib.rs @@ -105,6 +105,7 @@ fn dither_image( highlights, tone: parse_tone(tone), gamut: parse_gamut(gamut), + dbs: None, }; // Validation and `Palette` construction stay under the GIL; only the diff --git a/packages/rust/core/examples/dbs_compare.rs b/packages/rust/core/examples/dbs_compare.rs new file mode 100644 index 0000000..fbb3e31 --- /dev/null +++ b/packages/rust/core/examples/dbs_compare.rs @@ -0,0 +1,236 @@ +//! Visual comparison of plain error diffusion against the same output refined by DBS. +//! +//! For each fixture image (resized to fill the panel), renders every variant in measured ink +//! colours and writes PNGs plus an `index.html` for side-by-side viewing: +//! +//! //source.png, .png full-size renders +//! //strip.png source | variants, side by side +//! //zoom.png centre crop at ZOOM× nearest-neighbour, same order +//! +//! Metrics: mean OKLab dE over 4×4 block averages (neutral — independent of the DBS eye model) +//! and DBS perceived error (the quantity DBS minimises, so DBS wins it by construction). +//! +//! cargo run --release --example dbs_compare -- [options] [image files…] +//! +//! Options (defaults in brackets): +//! --palette spectra6|mono [spectra6] --passes N [10] +//! --size WxH [800x480] --distance CM [40] +//! --ppi P [127] --out DIR [target/dbs_compare] +//! +//! Without image arguments, every image in tests/fixtures/images is used. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use epaper_dithering_core::{ + color_space::srgb_channel_to_linear, + color_space_lab::rgb_to_oklab, + dbs::{perceived_error, DbsParams}, + dither, dither_with_canonical, + enums::{DitherMode, GamutCompression, ToneCompression}, + measured_palettes::SPECTRA_7_3_6COLOR, + palettes::{ColorScheme, Palette}, + types::ImageBuffer, + DitherConfig, +}; +use image::{imageops, RgbImage}; + +const BLOCK: usize = 4; +const ZOOM: u32 = 4; +const CROP: (u32, u32) = (160, 120); + +struct Args { + measured: bool, + width: u32, + height: u32, + params: DbsParams, + out: PathBuf, + images: Vec, +} + +fn parse_args() -> Args { + let mut a = Args { + measured: true, + width: 800, + height: 480, + params: DbsParams::default(), + out: Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/dbs_compare"), + images: Vec::new(), + }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + let mut val = || it.next().unwrap_or_else(|| panic!("{arg} needs a value")); + match arg.as_str() { + "--palette" => a.measured = match val().as_str() { + "spectra6" => true, + "mono" => false, + p => panic!("unknown palette {p} (spectra6|mono)"), + }, + "--size" => { + let v = val(); + let (w, h) = v.split_once('x').expect("--size WxH"); + (a.width, a.height) = (w.parse().expect("width"), h.parse().expect("height")); + } + "--passes" => a.params.max_passes = val().parse().expect("--passes N"), + "--distance" => a.params.viewing_distance_cm = val().parse().expect("--distance CM"), + "--ppi" => a.params.ppi = val().parse().expect("--ppi P"), + "--out" => a.out = val().into(), + _ if arg.starts_with("--") => panic!("unknown option {arg}"), + _ => a.images.push(arg.into()), + } + } + if a.images.is_empty() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/images"); + a.images = std::fs::read_dir(&dir) + .expect("fixtures/images") + .filter_map(|e| Some(e.ok()?.path())) + .filter(|p| p.is_file() && matches!(p.extension().and_then(|e| e.to_str()), Some("png" | "jpg" | "jpeg"))) + .collect(); + a.images.sort(); + } + a +} + +/// Mean OKLab dE between two flat RGB buffers, averaged over BLOCK × BLOCK tiles. +fn block_delta_e(a: &[u8], b: &[u8], width: usize, height: usize) -> f64 { + let mut total = 0.0; + let mut blocks = 0usize; + for by in (0..height).step_by(BLOCK) { + for bx in (0..width).step_by(BLOCK) { + let (mut sa, mut sb, mut n) = ([0.0; 3], [0.0; 3], 0.0); + for y in by..(by + BLOCK).min(height) { + for x in bx..(bx + BLOCK).min(width) { + let i = (y * width + x) * 3; + for c in 0..3 { + sa[c] += srgb_channel_to_linear(a[i + c]); + sb[c] += srgb_channel_to_linear(b[i + c]); + } + n += 1.0; + } + } + let la = rgb_to_oklab(sa[0] / n, sa[1] / n, sa[2] / n); + let lb = rgb_to_oklab(sb[0] / n, sb[1] / n, sb[2] / n); + total += ((la.l - lb.l).powi(2) + (la.a - lb.a).powi(2) + (la.b - lb.b).powi(2)).sqrt(); + blocks += 1; + } + } + total / blocks as f64 +} + +fn render(indices: &[u8], palette: &Palette, w: u32, h: u32) -> RgbImage { + let rgb = indices.iter().flat_map(|&i| palette.colors[i as usize]).collect(); + RgbImage::from_raw(w, h, rgb).expect("render buffer size") +} + +/// Images side by side with a 4 px white gutter. +fn hstack(images: &[RgbImage]) -> RgbImage { + const GAP: u32 = 4; + let h = images.iter().map(RgbImage::height).max().unwrap_or(0); + let w = images.iter().map(|i| i.width() + GAP).sum::() - GAP; + let mut out = RgbImage::from_pixel(w, h, image::Rgb([255, 255, 255])); + let mut x = 0; + for img in images { + imageops::replace(&mut out, img, x.into(), 0); + x += img.width() + GAP; + } + out +} + +fn zoom_crop(img: &RgbImage) -> RgbImage { + let (cw, ch) = (CROP.0.min(img.width()), CROP.1.min(img.height())); + let crop = imageops::crop_imm(img, (img.width() - cw) / 2, (img.height() - ch) / 2, cw, ch).to_image(); + imageops::resize(&crop, cw * ZOOM, ch * ZOOM, imageops::FilterType::Nearest) +} + +struct Variant { + name: &'static str, + mode: DitherMode, + dbs: bool, +} + +const VARIANTS: [Variant; 4] = [ + Variant { name: "burkes", mode: DitherMode::Burkes, dbs: false }, + Variant { name: "burkes_dbs", mode: DitherMode::Burkes, dbs: true }, + Variant { name: "dizzy", mode: DitherMode::Dizzy, dbs: false }, + Variant { name: "dizzy_dbs", mode: DitherMode::Dizzy, dbs: true }, +]; + +fn main() { + let args = parse_args(); + let palette: &Palette = if args.measured { &SPECTRA_7_3_6COLOR } else { ColorScheme::Mono.palette() }; + let (w, h) = (args.width as usize, args.height as usize); + std::fs::create_dir_all(&args.out).expect("create output dir"); + + println!( + "palette {}, {}x{}, {} cm, {} ppi, ≤{} passes → {}\n", + if args.measured { "spectra6 (measured)" } else { "mono" }, + w, h, args.params.viewing_distance_cm, args.params.ppi, args.params.max_passes, + args.out.display() + ); + println!("{:<22} {:<12} {:>10} {:>14} {:>10}", "image", "variant", "block dE", "perceived E", "time"); + + let mut html = String::from( + "DBS comparison\ + ", + ); + html += &format!( + "

DBS comparison

{}, {w}×{h}, {} cm, {} ppi, ≤{} passes. Order: source, {}.

", + if args.measured { "Spectra 6 measured" } else { "Mono" }, + args.params.viewing_distance_cm, args.params.ppi, args.params.max_passes, + VARIANTS.iter().map(|v| v.name).collect::>().join(", ") + ); + + for path in &args.images { + let stem = path.file_stem().and_then(|s| s.to_str()).expect("image name"); + let src = image::open(path) + .unwrap_or_else(|e| panic!("failed to load {}: {e}", path.display())) + .resize_to_fill(args.width, args.height, imageops::FilterType::Lanczos3) + .to_rgb8(); + let dir = args.out.join(stem); + std::fs::create_dir_all(&dir).expect("create image dir"); + src.save(dir.join("source.png")).expect("save source"); + + let buf = ImageBuffer::new(src.as_raw(), w); + let mut renders = vec![src.clone()]; + let mut rows = String::new(); + + for v in &VARIANTS { + let cfg = DitherConfig { + mode: v.mode, + dbs: v.dbs.then_some(args.params), + ..Default::default() + }; + let t = Instant::now(); + let indices = if args.measured { + let cfg = DitherConfig { tone: ToneCompression::Auto, gamut: GamutCompression::Auto, ..cfg }; + dither_with_canonical(&buf, palette, ColorScheme::Bwgbry.palette(), cfg) + } else { + dither(&buf, palette, cfg) + }; + let elapsed = t.elapsed(); + + let img = render(&indices, palette, args.width, args.height); + let de = block_delta_e(src.as_raw(), img.as_raw(), w, h); + let pe = perceived_error(src.as_raw(), w, h, palette, &indices, &args.params); + println!("{stem:<22} {:<12} {de:>10.4} {pe:>14.4e} {:>9.2}s", v.name, elapsed.as_secs_f64()); + rows += &format!("{}{de:.4}{pe:.4e}{:.2}s", v.name, elapsed.as_secs_f64()); + + img.save(dir.join(format!("{}.png", v.name))).expect("save variant"); + renders.push(img); + } + + hstack(&renders).save(dir.join("strip.png")).expect("save strip"); + hstack(&renders.iter().map(zoom_crop).collect::>()) + .save(dir.join("zoom.png")) + .expect("save zoom"); + + html += &format!( + "

{stem}

variantblock dEperceived Etime{rows}
\ +

" + ); + } + + std::fs::write(args.out.join("index.html"), html).expect("write index.html"); + println!("\nopen {}", args.out.join("index.html").display()); +} diff --git a/packages/rust/core/src/dbs.rs b/packages/rust/core/src/dbs.rs new file mode 100644 index 0000000..17255a4 --- /dev/null +++ b/packages/rust/core/src/dbs.rs @@ -0,0 +1,538 @@ +//! Colour Direct Binary Search (DBS): iterative refinement of an already-dithered image. +//! +//! Minimises perceived error `E = Σ_ch Σ_m ((p_ch ∗ e_ch)(m))²`, where `e = halftone − target` +//! in YyCxCz and `p_ch` is a per-channel eye-model blur: narrow for luminance, wider for +//! chrominance. YyCxCz is a linear transform of XYZ (linearised CIELAB), so blurring the error +//! averages light the way the eye does — unlike OKLab, which `color_space_lab` uses for +//! per-pixel matching. +//! +//! Each pass visits every pixel in raster order and tries changing it to every other ink and +//! swapping it with each differing 8-neighbour, applying the move with the largest decrease +//! in `E`. Trial cost is O(1) via the cached correlation `c_pe = c_pp ⋆ e`, where +//! `c_pp = p ⋆ p` is the blur's autocorrelation; an accepted move updates `c_pe` over the +//! footprint of `c_pp` only. +//! +//! Eye-model blurs are stored as full 2-D tables and `c_pp` is derived from them generically, +//! so a non-separable model (e.g. the exponential CSF's spatial kernel) can be swapped in. +//! +//! References: Analoui & Allebach (1992); Lieberman & Allebach (1997); Agar & Allebach (2005); +//! Flohr, Kolpatzik et al. (1993, YyCxCz); Kolpatzik & Bouman (1992, CSF constants); +//! Mullen (1985). Ported from OpenDithering's `dbs.ts` (MIT, © Guy Sie). + +use rayon::prelude::*; + +use crate::color_space::srgb_channel_to_linear; +use crate::palettes::Palette; + +// Kolpatzik & Bouman (1992) exponential CSF decay constants, W(f) ∝ exp(−α·f), f in cycles/degree. +// Luminance: Näsänen's α(L) = 1 / (0.525·ln L + 3.91) at L = 11 cd/m² ≈ 0.193. +// Chrominance: fitted to Mullen (1985), α = 0.419 for both opponent channels. +const ALPHA_LUM: f64 = 0.193; +const ALPHA_CHROMA: f64 = 0.419; + +/// Linear sRGB → CIE XYZ (D65), IEC 61966-2-1. +const SRGB_TO_XYZ: [[f64; 3]; 3] = [ + [0.412_456_4, 0.357_576_1, 0.180_437_5], + [0.212_672_9, 0.715_152_2, 0.072_175_0], + [0.019_333_9, 0.119_192_0, 0.950_304_1], +]; + +/// sRGB bytes → YyCxCz: `Yy = 116·Y/Yn`, `Cx = 500·(X/Xn − Y/Yn)`, `Cz = 200·(Y/Yn − Z/Zn)`. +/// The white point is sRGB white, so (255, 255, 255) maps to zero chroma. Measured palettes +/// are normalised to paper white, which therefore lands at the same point. +fn srgb_to_yycxcz(rgb: [u8; 3]) -> [f64; 3] { + let lin = rgb.map(srgb_channel_to_linear); + let [x, y, z] = SRGB_TO_XYZ.map(|row| row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]); + let [xn, yn, zn] = SRGB_TO_XYZ.map(|row| row[0] + row[1] + row[2]); + [116.0 * y / yn, 500.0 * (x / xn - y / yn), 200.0 * (y / yn - z / zn)] +} + +/// Square, centred 2-D table: `data[(dy + r)·size + (dx + r)]` for `|dx|, |dy| ≤ r`. +#[derive(Clone)] +struct Kernel2D { + radius: usize, + size: usize, + data: Vec, +} + +impl Kernel2D { + #[inline] + fn at(&self, dx: isize, dy: isize) -> f64 { + let r = self.radius as isize; + self.data[((dy + r) * self.size as isize + dx + r) as usize] + } +} + +/// Normalised (unit-sum) sampled Gaussian, truncated at `ceil(3σ)`. +fn gaussian_blur(sigma: f64) -> Kernel2D { + let r = (3.0 * sigma).ceil().max(1.0) as usize; + let size = 2 * r + 1; + let g: Vec = (0..size) + .map(|i| { + let d = i as f64 - r as f64; + (-(d * d) / (2.0 * sigma * sigma)).exp() + }) + .collect(); + let sum: f64 = g.iter().sum(); + let data = (0..size * size).map(|i| g[i / size] * g[i % size] / (sum * sum)).collect(); + Kernel2D { radius: r, size, data } +} + +/// `c(d) = Σ_u p(u)·p(u + d)`, with twice the radius of `p`. +fn autocorrelate(p: &Kernel2D) -> Kernel2D { + let pr = p.radius as isize; + let radius = 2 * p.radius; + let size = 2 * radius + 1; + let mut data = vec![0.0; size * size]; + for dy in -(radius as isize)..=radius as isize { + for dx in -(radius as isize)..=radius as isize { + let mut s = 0.0; + for uy in (-pr).max(-pr - dy)..=pr.min(pr - dy) { + for ux in (-pr).max(-pr - dx)..=pr.min(pr - dx) { + s += p.at(ux, uy) * p.at(ux + dx, uy + dy); + } + } + data[(dy + radius as isize) as usize * size + (dx + radius as isize) as usize] = s; + } + } + Kernel2D { radius, size, data } +} + +/// Eye-model blur width in pixels for a CSF decay constant at the given viewing geometry. +fn eye_sigma_px(alpha: f64, params: &DbsParams) -> f64 { + let pixel_deg = (2.54 / params.ppi / params.viewing_distance_cm).to_degrees(); + alpha / (2.0 * std::f64::consts::PI) / pixel_deg +} + +/// Per-channel eye-model blur: index 0 = luminance (Yy), 1–2 = chrominance (Cx, Cz). +fn eye_blurs(params: &DbsParams) -> [Kernel2D; 3] { + assert!( + params.ppi.is_finite() && params.ppi > 0.0, + "DBS ppi must be positive, got {}", + params.ppi + ); + assert!( + params.viewing_distance_cm.is_finite() && params.viewing_distance_cm > 0.0, + "DBS viewing distance must be positive, got {}", + params.viewing_distance_cm + ); + let lum = gaussian_blur(eye_sigma_px(ALPHA_LUM, params)); + let chroma = gaussian_blur(eye_sigma_px(ALPHA_CHROMA, params)); + [lum, chroma.clone(), chroma] +} + +/// Palette inks and target pixels in YyCxCz. +fn to_yycxcz(pixels: &[u8], width: usize, height: usize, palette: &Palette) -> (Vec<[f64; 3]>, Vec<[f64; 3]>) { + assert_eq!(pixels.len(), width * height * 3, "pixel buffer size mismatch"); + let inks = palette.colors.iter().map(|&c| srgb_to_yycxcz(c)).collect(); + let target = pixels.par_chunks_exact(3).map(|c| srgb_to_yycxcz([c[0], c[1], c[2]])).collect(); + (inks, target) +} + +/// `out(m) = Σ_d c(d)·e(m + d)` over the image (e is zero outside it). +fn correlate(e: &[f64], width: usize, height: usize, c: &Kernel2D) -> Vec { + let r = c.radius as isize; + let (w, h) = (width as isize, height as isize); + let mut out = vec![0.0; e.len()]; + out.par_chunks_mut(width).enumerate().for_each(|(y, row)| { + let y = y as isize; + for (x, o) in row.iter_mut().enumerate() { + let x = x as isize; + let mut s = 0.0; + for dy in (-r).max(-y)..=r.min(h - 1 - y) { + let base = ((y + dy) * w + x) as usize; + for dx in (-r).max(-x)..=r.min(w - 1 - x) { + s += c.at(dx, dy) * e[(base as isize + dx) as usize]; + } + } + *o = s; + } + }); + out +} + +/// 8-neighbourhood offsets `(dx, dy)` tried for swaps. +const NEIGHBOURS: [(isize, isize); 8] = + [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]; + +/// `(x + dx, y + dy)` if it lies inside a `width × height` image. +fn neighbour(x: usize, y: usize, dx: isize, dy: isize, width: usize, height: usize) -> Option<(usize, usize)> { + let nx = x.checked_add_signed(dx).filter(|&v| v < width)?; + let ny = y.checked_add_signed(dy).filter(|&v| v < height)?; + Some((nx, ny)) +} + +/// Parameters for DBS refinement. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DbsParams { + /// Distance between viewer and panel, in centimetres. + pub viewing_distance_cm: f64, + /// Panel pixel density, in pixels per inch. + pub ppi: f64, + /// Upper bound on full-image passes; refinement stops earlier once a pass changes nothing. + pub max_passes: u32, +} + +impl Default for DbsParams { + /// 40 cm from a ~127 ppi panel (7.3" 800×480), at most 10 passes. + fn default() -> Self { + Self { viewing_distance_cm: 40.0, ppi: 127.0, max_passes: 10 } + } +} + +/// Diagnostics from a `dbs_refine` run. +#[derive(Debug, Clone, PartialEq)] +pub struct DbsStats { + /// Passes actually run (≤ `max_passes`). + pub passes: u32, + /// Number of accepted changes in each pass. + pub accepted_per_pass: Vec, + /// Perceived error of the starting halftone. + pub initial_error: f64, + /// Perceived error of the refined halftone. + pub final_error: f64, +} + +/// Refine palette `indices` in place to minimise perceived error against `pixels` (sRGB, +/// `width × height × 3`), as seen through `palette` (pass the measured palette). +/// +/// `pinned` pixels are never changed or swapped, and their target is taken to be their own +/// ink, so they neither carry error nor pull neighbours to compensate for it. +/// +/// # Panics +/// Panics if buffer lengths disagree with `width × height`, or on a non-positive `ppi` or +/// viewing distance. +pub fn dbs_refine( + pixels: &[u8], + width: usize, + height: usize, + palette: &Palette, + indices: &mut [u8], + pinned: Option<&[bool]>, + params: &DbsParams, +) -> DbsStats { + let n_px = width * height; + assert_eq!(indices.len(), n_px, "index buffer size mismatch"); + if let Some(p) = pinned { + assert_eq!(p.len(), n_px, "pinned mask size mismatch"); + } + let is_pinned = |i: usize| pinned.is_some_and(|p| p[i]); + + let (inks, mut target) = to_yycxcz(pixels, width, height, palette); + for (i, t) in target.iter_mut().enumerate() { + if is_pinned(i) { + *t = inks[indices[i] as usize]; + } + } + + let cpp = eye_blurs(params).map(|p| autocorrelate(&p)); + let cpp0 = [cpp[0].at(0, 0), cpp[1].at(0, 0), cpp[2].at(0, 0)]; + + let mut cpe: [Vec; 3] = std::array::from_fn(|ch| { + let e: Vec = (0..n_px).map(|i| inks[indices[i] as usize][ch] - target[i][ch]).collect(); + correlate(&e, width, height, &cpp[ch]) + }); + let mut err: f64 = (0..3) + .map(|ch| { + (0..n_px) + .map(|i| (inks[indices[i] as usize][ch] - target[i][ch]) * cpe[ch][i]) + .sum::() + }) + .sum(); + let initial_error = err; + + // c_pe += a·c_pp(· − (mx, my)), clipped to the image. + let add_kernel = |c: &mut [f64], k: &Kernel2D, mx: usize, my: usize, a: f64| { + let r = k.radius; + for y in my.saturating_sub(r)..=(my + r).min(height - 1) { + let row = y * width; + let dy = y as isize - my as isize; + for x in mx.saturating_sub(r)..=(mx + r).min(width - 1) { + c[row + x] += a * k.at(x as isize - mx as isize, dy); + } + } + }; + + const EPS: f64 = 1e-9; + let n_inks = inks.len(); + let mut accepted_per_pass = Vec::new(); + + for _ in 0..params.max_passes { + let mut accepted = 0; + for y in 0..height { + for x in 0..width { + let m = y * width + x; + if is_pinned(m) { + continue; + } + let k = indices[m] as usize; + let c = [cpe[0][m], cpe[1][m], cpe[2][m]]; + + enum Move { Toggle(usize), Swap { n: usize, nx: usize, ny: usize } } + let mut best_de = -EPS; + let mut best = None; + + for j in (0..n_inks).filter(|&j| j != k) { + let de: f64 = (0..3) + .map(|ch| { + let a = inks[j][ch] - inks[k][ch]; + cpp0[ch] * a * a + 2.0 * a * c[ch] + }) + .sum(); + if de < best_de { + best_de = de; + best = Some(Move::Toggle(j)); + } + } + + for (dx, dy) in NEIGHBOURS { + let Some((nx, ny)) = neighbour(x, y, dx, dy, width, height) else { continue }; + let n = ny * width + nx; + let kn = indices[n] as usize; + if kn == k || is_pinned(n) { + continue; + } + // m takes kn (Δ = a), n takes k (Δ = −a). + let de: f64 = (0..3) + .map(|ch| { + let a = inks[kn][ch] - inks[k][ch]; + 2.0 * a * a * (cpp0[ch] - cpp[ch].at(dx, dy)) + 2.0 * a * (c[ch] - cpe[ch][n]) + }) + .sum(); + if de < best_de { + best_de = de; + best = Some(Move::Swap { n, nx, ny }); + } + } + + let Some(mv) = best else { continue }; + accepted += 1; + err += best_de; + match mv { + Move::Toggle(j) => { + for ch in 0..3 { + add_kernel(&mut cpe[ch], &cpp[ch], x, y, inks[j][ch] - inks[k][ch]); + } + indices[m] = j as u8; + } + Move::Swap { n, nx, ny } => { + let kn = indices[n] as usize; + for ch in 0..3 { + let a = inks[kn][ch] - inks[k][ch]; + add_kernel(&mut cpe[ch], &cpp[ch], x, y, a); + add_kernel(&mut cpe[ch], &cpp[ch], nx, ny, -a); + } + indices.swap(m, n); + } + } + } + } + accepted_per_pass.push(accepted); + if accepted == 0 { + break; + } + } + + DbsStats { + passes: accepted_per_pass.len() as u32, + accepted_per_pass, + initial_error, + final_error: err, + } +} + +/// Perceived error `Σ_ch Σ_m ((p_ch ∗ e_ch)(m))²` of `indices` against `pixels`, computed +/// directly by blurring the error over the whole plane (no correlation shortcut). This is the +/// quantity `dbs_refine` minimises; useful for comparing halftones under the eye model. +pub fn perceived_error( + pixels: &[u8], + width: usize, + height: usize, + palette: &Palette, + indices: &[u8], + params: &DbsParams, +) -> f64 { + assert_eq!(indices.len(), width * height, "index buffer size mismatch"); + let (inks, target) = to_yycxcz(pixels, width, height, palette); + let blurs = eye_blurs(params); + let (w, h) = (width as isize, height as isize); + + (0..3) + .map(|ch| { + let p = &blurs[ch]; + let r = p.radius as isize; + let e: Vec = (0..indices.len()).map(|i| inks[indices[i] as usize][ch] - target[i][ch]).collect(); + // The blurred error is non-zero up to r pixels outside the image. + (-r..h + r) + .into_par_iter() + .map(|oy| { + let mut s = 0.0; + for ox in -r..w + r { + let mut v = 0.0; + for y in (oy - r).max(0)..=(oy + r).min(h - 1) { + for x in (ox - r).max(0)..=(ox + r).min(w - 1) { + v += p.at(ox - x, oy - y) * e[(y * w + x) as usize]; + } + } + s += v * v; + } + s + }) + .sum::() + }) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::algorithms::{error_diffusion_dither, BURKES}; + use crate::measured_palettes::SPECTRA_7_3_6COLOR; + use crate::palettes::ColorScheme; + use approx::assert_relative_eq; + + /// Smooth colour gradient: enough structure that Burkes leaves room for improvement. + fn gradient(w: usize, h: usize) -> Vec { + let mut px = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + px.push((x * 255 / (w - 1)) as u8); + px.push((y * 255 / (h - 1)) as u8); + px.push((255 - x * 255 / (w - 1)) as u8); + } + } + px + } + + fn burkes(px: &[u8], w: usize, h: usize, palette: &Palette) -> Vec { + error_diffusion_dither(px, w, h, palette, &BURKES, true) + } + + #[test] + fn perceived_error_is_zero_when_halftone_equals_target() { + let palette = &SPECTRA_7_3_6COLOR; + let indices: Vec = (0..24).map(|i| (i % palette.colors.len()) as u8).collect(); + let px: Vec = indices.iter().flat_map(|&i| palette.colors[i as usize]).collect(); + let e = perceived_error(&px, 6, 4, palette, &indices, &DbsParams::default()); + assert!(e.abs() < 1e-9, "expected zero error, got {e}"); + } + + #[test] + fn perceived_error_is_positive_for_a_mismatched_halftone() { + let palette = ColorScheme::Mono.palette(); + let px = vec![128u8; 8 * 8 * 3]; + let indices = vec![0u8; 64]; // all black for a mid-grey target + assert!(perceived_error(&px, 8, 8, palette, &indices, &DbsParams::default()) > 0.0); + } + + #[test] + fn refine_reduces_perceived_error() { + let (w, h) = (32, 24); + let px = gradient(w, h); + let palette = &SPECTRA_7_3_6COLOR; + let params = DbsParams::default(); + let mut idx = burkes(&px, w, h, palette); + let before = perceived_error(&px, w, h, palette, &idx, ¶ms); + + dbs_refine(&px, w, h, palette, &mut idx, None, ¶ms); + + let after = perceived_error(&px, w, h, palette, &idx, ¶ms); + assert!(after < before * 0.95, "DBS should cut error noticeably: {before} -> {after}"); + } + + /// The incremental bookkeeping (ΔE formulas, correlation updates) must agree with a + /// from-scratch blur-and-square of the final halftone. Catches wrong toggle/swap deltas. + #[test] + fn reported_errors_match_direct_computation() { + let (w, h) = (32, 24); + let px = gradient(w, h); + let palette = &SPECTRA_7_3_6COLOR; + let params = DbsParams::default(); + let mut idx = burkes(&px, w, h, palette); + let before = perceived_error(&px, w, h, palette, &idx, ¶ms); + + let stats = dbs_refine(&px, w, h, palette, &mut idx, None, ¶ms); + + let after = perceived_error(&px, w, h, palette, &idx, ¶ms); + assert_relative_eq!(stats.initial_error, before, max_relative = 1e-9); + assert_relative_eq!(stats.final_error, after, max_relative = 1e-9); + } + + /// After convergence no single toggle or 8-neighbour swap may lower the error, judged by + /// brute-force recomputation — the defining property of a DBS local optimum. + #[test] + fn converged_result_is_a_local_minimum() { + let (w, h) = (10, 8); + let px = gradient(w, h); + let palette = &SPECTRA_7_3_6COLOR; + let params = DbsParams { max_passes: 200, ..Default::default() }; + let mut idx = burkes(&px, w, h, palette); + + let stats = dbs_refine(&px, w, h, palette, &mut idx, None, ¶ms); + assert_eq!(stats.accepted_per_pass.last(), Some(&0), "should converge: {stats:?}"); + + let base = perceived_error(&px, w, h, palette, &idx, ¶ms); + let tol = base * 1e-9 + 1e-9; + for m in 0..w * h { + for k in 0..palette.colors.len() as u8 { + let mut t = idx.clone(); + t[m] = k; + let e = perceived_error(&px, w, h, palette, &t, ¶ms); + assert!(e >= base - tol, "toggle at {m} to {k} lowers error {base} -> {e}"); + } + for (dx, dy) in NEIGHBOURS { + let Some((nx, ny)) = neighbour(m % w, m / w, dx, dy, w, h) else { continue }; + let mut t = idx.clone(); + t.swap(m, ny * w + nx); + let e = perceived_error(&px, w, h, palette, &t, ¶ms); + assert!(e >= base - tol, "swap {m} with ({nx},{ny}) lowers error {base} -> {e}"); + } + } + } + + #[test] + fn pinned_pixels_never_change() { + let (w, h) = (32, 24); + let px = gradient(w, h); + let palette = &SPECTRA_7_3_6COLOR; + let mut idx = burkes(&px, w, h, palette); + let pinned: Vec = (0..w * h).map(|i| i % 3 == 0).collect(); + let original = idx.clone(); + + let stats = dbs_refine(&px, w, h, palette, &mut idx, Some(&pinned), &DbsParams::default()); + + assert!(stats.accepted_per_pass.iter().sum::() > 0, "unpinned pixels should still move"); + for i in (0..w * h).filter(|&i| pinned[i]) { + assert_eq!(idx[i], original[i], "pinned pixel {i} changed"); + } + } + + /// Pins the colour-science decision that DBS measures error in linear light: a flat + /// sRGB 188 (linear ≈ 0.50) on black/white must come out ≈ 50% white ink, not the + /// ≈ 74% an sRGB-space average would give. + #[test] + fn flat_grey_reaches_linear_light_coverage() { + let (w, h) = (48, 48); + let px = vec![188u8; w * h * 3]; + let palette = ColorScheme::Mono.palette(); + let white = palette.colors.iter().position(|&c| c == [255, 255, 255]).unwrap() as u8; + let mut idx = burkes(&px, w, h, palette); + + dbs_refine(&px, w, h, palette, &mut idx, None, &DbsParams { max_passes: 50, ..Default::default() }); + + let frac = idx.iter().filter(|&&i| i == white).count() as f64 / (w * h) as f64; + assert!((0.46..=0.54).contains(&frac), "white coverage {frac:.3}, expected ≈ 0.50"); + } + + #[test] + fn zero_passes_leaves_indices_untouched() { + let (w, h) = (16, 12); + let px = gradient(w, h); + let palette = &SPECTRA_7_3_6COLOR; + let mut idx = burkes(&px, w, h, palette); + let original = idx.clone(); + let stats = dbs_refine(&px, w, h, palette, &mut idx, None, &DbsParams { max_passes: 0, ..Default::default() }); + assert_eq!(idx, original); + assert_eq!(stats.passes, 0); + } +} diff --git a/packages/rust/core/src/lib.rs b/packages/rust/core/src/lib.rs index 06d7918..233de66 100644 --- a/packages/rust/core/src/lib.rs +++ b/packages/rust/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod algorithms; pub mod color_space; pub mod color_space_lab; pub mod composite; +pub mod dbs; pub mod dizzy; pub mod enums; pub mod error; @@ -12,6 +13,7 @@ pub mod tone_map; pub mod types; use crate::color_space::{linear_channel_to_srgb, srgb_channel_to_linear}; +use crate::dbs::DbsParams; use crate::enums::{DitherMode, GamutCompression, ToneCompression}; use crate::palettes::Palette; use crate::types::ImageBuffer; @@ -24,7 +26,7 @@ use crate::types::ImageBuffer; /// ``` /// /// Pre-processing pipeline (applied in order, each step is a no-op at its identity value): -/// `exposure → saturation → shadows/highlights → tone → gamut → dither`. +/// `exposure → saturation → shadows/highlights → tone → gamut → dither → dbs`. #[derive(Debug, Clone, PartialEq)] pub struct DitherConfig { /// Dithering algorithm. @@ -45,6 +47,9 @@ pub struct DitherConfig { pub tone: ToneCompression, /// Gamut compression. Auto = full strength on out-of-gamut; Fixed(s) = manual. pub gamut: GamutCompression, + /// Optional Direct Binary Search refinement of the dithered output (see `dbs`). Slow: + /// seconds on a full panel, so `None` by default. + pub dbs: Option, } impl Default for DitherConfig { @@ -58,6 +63,7 @@ impl Default for DitherConfig { highlights: 0.0, tone: ToneCompression::Fixed(0.0), gamut: GamutCompression::None, + dbs: None, } } } @@ -162,7 +168,8 @@ fn dither_impl( { return indices; } - return dispatch(img, p, canonical, config.mode, config.serpentine, pin_exact_pixels); + let indices = dispatch(img, p, canonical, config.mode, config.serpentine, pin_exact_pixels); + return refine(img, p, canonical, indices, config.dbs, pin_exact_pixels); } // Convert sRGB bytes → linear, apply pre-processing pipeline, convert back. @@ -192,7 +199,29 @@ fn dither_impl( .collect(); let processed_img = ImageBuffer::new(&processed, img.width); - dispatch(&processed_img, p, canonical, config.mode, config.serpentine, pin_exact_pixels) + let indices = dispatch(&processed_img, p, canonical, config.mode, config.serpentine, pin_exact_pixels); + refine(&processed_img, p, canonical, indices, config.dbs, pin_exact_pixels) +} + +/// Final pipeline step: optional DBS refinement against the (pre-processed) target image. +/// Pixels the dispatch pinned to an exact canonical colour stay pinned. +fn refine( + img: &ImageBuffer, + p: &Palette, + canonical: &Palette, + mut indices: Vec, + params: Option, + pin_exact_pixels: bool, +) -> Vec { + let Some(params) = params else { return indices }; + let pinned: Option> = pin_exact_pixels.then(|| { + img.data + .chunks_exact(3) + .map(|rgb| algorithms::exact_palette_index(rgb, canonical).is_some()) + .collect() + }); + dbs::dbs_refine(img.data, img.width, img.height, p, &mut indices, pinned.as_deref(), ¶ms); + indices } #[cfg(test)] @@ -335,4 +364,36 @@ mod tests { ); assert_eq!(output.len(), 4); } + + #[test] + fn dbs_refinement_lowers_perceived_error() { + let pixels: Vec = (0..24 * 16) + .flat_map(|i| [(i % 24 * 10) as u8, (i / 24 * 16) as u8, 200 - (i % 24 * 8) as u8]) + .collect(); + let img = ImageBuffer::new(&pixels, 24); + let params = DbsParams::default(); + + let plain = dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig::default()); + let refined = dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig { dbs: Some(params), ..Default::default() }); + + let err = |idx: &[u8]| dbs::perceived_error(&pixels, 24, 16, &SPECTRA_7_3_6COLOR, idx, ¶ms); + assert!(err(&refined) < err(&plain), "{} !< {}", err(&refined), err(&plain)); + } + + #[test] + fn exact_canonical_pixels_stay_pinned_under_dbs() { + let mut image = pixels([128, 128, 128], 8); + image[0..3].copy_from_slice(&[0, 255, 0]); + image[9..12].copy_from_slice(&[0, 255, 0]); + let img = ImageBuffer::new(&image, 4); + + let output = dither_with_canonical( + &img, + &SPECTRA_7_3_6COLOR, + ColorScheme::Bwgbry.palette(), + DitherConfig { dbs: Some(DbsParams::default()), ..Default::default() }, + ); + assert_eq!(output[0], 5); + assert_eq!(output[3], 5); + } } diff --git a/packages/rust/wasm/src/lib.rs b/packages/rust/wasm/src/lib.rs index 5bace9f..3c51632 100644 --- a/packages/rust/wasm/src/lib.rs +++ b/packages/rust/wasm/src/lib.rs @@ -70,6 +70,7 @@ pub fn dither_image( highlights, tone: parse_tone(tone), gamut: parse_gamut(gamut), + dbs: None, }; if palette_bytes.is_empty() { From 753f75f07b939d7ada163c6c40b2e56a0ba0268a Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:34:00 +0200 Subject: [PATCH 02/12] chore(core): add --variants and --baseline options to dbs_compare --variants limits a run to named variants for faster benchmarking loops. --baseline compares renders against an earlier --out directory and reports the percentage of differing pixels, plus per-variant totals, so optimizations can be checked for bit-identical output. --- packages/rust/core/examples/dbs_compare.rs | 49 ++++++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/rust/core/examples/dbs_compare.rs b/packages/rust/core/examples/dbs_compare.rs index fbb3e31..952d5cf 100644 --- a/packages/rust/core/examples/dbs_compare.rs +++ b/packages/rust/core/examples/dbs_compare.rs @@ -16,6 +16,8 @@ //! --palette spectra6|mono [spectra6] --passes N [10] //! --size WxH [800x480] --distance CM [40] //! --ppi P [127] --out DIR [target/dbs_compare] +//! --variants a,b,… [all] --baseline DIR compare renders against an +//! earlier --out (% pixels differing) //! //! Without image arguments, every image in tests/fixtures/images is used. @@ -45,6 +47,8 @@ struct Args { height: u32, params: DbsParams, out: PathBuf, + variants: Vec, + baseline: Option, images: Vec, } @@ -55,6 +59,8 @@ fn parse_args() -> Args { height: 480, params: DbsParams::default(), out: Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/dbs_compare"), + variants: Vec::new(), + baseline: None, images: Vec::new(), }; let mut it = std::env::args().skip(1); @@ -75,6 +81,8 @@ fn parse_args() -> Args { "--distance" => a.params.viewing_distance_cm = val().parse().expect("--distance CM"), "--ppi" => a.params.ppi = val().parse().expect("--ppi P"), "--out" => a.out = val().into(), + "--variants" => a.variants = val().split(',').map(str::to_owned).collect(), + "--baseline" => a.baseline = Some(val().into()), _ if arg.starts_with("--") => panic!("unknown option {arg}"), _ => a.images.push(arg.into()), } @@ -167,7 +175,18 @@ fn main() { w, h, args.params.viewing_distance_cm, args.params.ppi, args.params.max_passes, args.out.display() ); - println!("{:<22} {:<12} {:>10} {:>14} {:>10}", "image", "variant", "block dE", "perceived E", "time"); + let variants: Vec<&Variant> = VARIANTS + .iter() + .filter(|v| args.variants.is_empty() || args.variants.iter().any(|n| n == v.name)) + .collect(); + assert!(!variants.is_empty(), "no variant matches {:?}", args.variants); + // Per variant: (sum block dE, sum perceived E, total seconds, differing px, compared px). + let mut totals = vec![(0.0, 0.0, 0.0, 0usize, 0usize); variants.len()]; + + println!( + "{:<22} {:<12} {:>10} {:>14} {:>10} {:>9}", + "image", "variant", "block dE", "perceived E", "time", "Δpx %" + ); let mut html = String::from( "DBS comparison\ @@ -178,7 +197,7 @@ fn main() { "

DBS comparison

{}, {w}×{h}, {} cm, {} ppi, ≤{} passes. Order: source, {}.

", if args.measured { "Spectra 6 measured" } else { "Mono" }, args.params.viewing_distance_cm, args.params.ppi, args.params.max_passes, - VARIANTS.iter().map(|v| v.name).collect::>().join(", ") + variants.iter().map(|v| v.name).collect::>().join(", ") ); for path in &args.images { @@ -195,7 +214,7 @@ fn main() { let mut renders = vec![src.clone()]; let mut rows = String::new(); - for v in &VARIANTS { + for (vi, v) in variants.iter().enumerate() { let cfg = DitherConfig { mode: v.mode, dbs: v.dbs.then_some(args.params), @@ -213,7 +232,22 @@ fn main() { let img = render(&indices, palette, args.width, args.height); let de = block_delta_e(src.as_raw(), img.as_raw(), w, h); let pe = perceived_error(src.as_raw(), w, h, palette, &indices, &args.params); - println!("{stem:<22} {:<12} {de:>10.4} {pe:>14.4e} {:>9.2}s", v.name, elapsed.as_secs_f64()); + let diff = args.baseline.as_ref().and_then(|b| { + let base = image::open(b.join(stem).join(format!("{}.png", v.name))).ok()?.to_rgb8(); + (base.dimensions() == img.dimensions()) + .then(|| base.pixels().zip(img.pixels()).filter(|(a, b)| a != b).count()) + }); + let t = &mut totals[vi]; + (t.0, t.1, t.2) = (t.0 + de, t.1 + pe, t.2 + elapsed.as_secs_f64()); + if let Some(d) = diff { + (t.3, t.4) = (t.3 + d, t.4 + w * h); + } + let diff_col = diff.map_or("-".into(), |d| format!("{:.3}", 100.0 * d as f64 / (w * h) as f64)); + println!( + "{stem:<22} {:<12} {de:>10.4} {pe:>14.4e} {:>9.2}s {diff_col:>9}", + v.name, + elapsed.as_secs_f64() + ); rows += &format!("{}{de:.4}{pe:.4e}{:.2}s", v.name, elapsed.as_secs_f64()); img.save(dir.join(format!("{}.png", v.name))).expect("save variant"); @@ -231,6 +265,13 @@ fn main() { ); } + println!("\n{:<22} {:<12} {:>10} {:>14} {:>10} {:>9}", "TOTAL", "variant", "mean dE", "mean E", "time", "Δpx %"); + let n = args.images.len() as f64; + for (v, t) in variants.iter().zip(&totals) { + let diff_col = if t.4 > 0 { format!("{:.3}", 100.0 * t.3 as f64 / t.4 as f64) } else { "-".into() }; + println!("{:<22} {:<12} {:>10.4} {:>14.4e} {:>9.2}s {diff_col:>9}", "", v.name, t.0 / n, t.1 / n, t.2); + } + std::fs::write(args.out.join("index.html"), html).expect("write index.html"); println!("\nopen {}", args.out.join("index.html").display()); } From 6d8735fd23a374a08974fe397e81083d7eee89e8 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:34:10 +0200 Subject: [PATCH 03/12] perf(core): interleave DBS correlation channels and fuse chroma update Store c_pe as one [f64; 3] per pixel instead of three planes, and update Cx and Cz in a single footprint sweep since they share the chrominance kernel. Output is bit-identical; DBS time on the 8 fixtures drops from 16.37 s to 10.58 s (1.55x). --- packages/rust/core/src/dbs.rs | 78 ++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/packages/rust/core/src/dbs.rs b/packages/rust/core/src/dbs.rs index 17255a4..55f3ec2 100644 --- a/packages/rust/core/src/dbs.rs +++ b/packages/rust/core/src/dbs.rs @@ -104,8 +104,11 @@ fn eye_sigma_px(alpha: f64, params: &DbsParams) -> f64 { alpha / (2.0 * std::f64::consts::PI) / pixel_deg } -/// Per-channel eye-model blur: index 0 = luminance (Yy), 1–2 = chrominance (Cx, Cz). -fn eye_blurs(params: &DbsParams) -> [Kernel2D; 3] { +/// Which eye-model blur each YyCxCz channel uses: Yy → luminance (0), Cx and Cz → chrominance (1). +const CHANNEL_BLUR: [usize; 3] = [0, 1, 1]; + +/// Eye-model blurs `[luminance, chrominance]`; index with `CHANNEL_BLUR`. +fn eye_blurs(params: &DbsParams) -> [Kernel2D; 2] { assert!( params.ppi.is_finite() && params.ppi > 0.0, "DBS ppi must be positive, got {}", @@ -118,7 +121,7 @@ fn eye_blurs(params: &DbsParams) -> [Kernel2D; 3] { ); let lum = gaussian_blur(eye_sigma_px(ALPHA_LUM, params)); let chroma = gaussian_blur(eye_sigma_px(ALPHA_CHROMA, params)); - [lum, chroma.clone(), chroma] + [lum, chroma] } /// Palette inks and target pixels in YyCxCz. @@ -193,6 +196,26 @@ pub struct DbsStats { pub final_error: f64, } +/// Calls `f(c_pe(x, y), k(x − mx, y − my))` over the footprint of `k` centred on `(mx, my)`, +/// clipped to the image. +fn sweep( + cpe: &mut [[f64; 3]], + width: usize, + height: usize, + k: &Kernel2D, + (mx, my): (usize, usize), + mut f: impl FnMut(&mut [f64; 3], f64), +) { + let r = k.radius; + for y in my.saturating_sub(r)..=(my + r).min(height - 1) { + let row = y * width; + let dy = y as isize - my as isize; + for x in mx.saturating_sub(r)..=(mx + r).min(width - 1) { + f(&mut cpe[row + x], k.at(x as isize - mx as isize, dy)); + } + } +} + /// Refine palette `indices` in place to minimise perceived error against `pixels` (sRGB, /// `width × height × 3`), as seen through `palette` (pass the measured palette). /// @@ -225,32 +248,33 @@ pub fn dbs_refine( } } - let cpp = eye_blurs(params).map(|p| autocorrelate(&p)); - let cpp0 = [cpp[0].at(0, 0), cpp[1].at(0, 0), cpp[2].at(0, 0)]; + let [cpp_lum, cpp_chroma] = eye_blurs(params).map(|p| autocorrelate(&p)); + let cpp = [&cpp_lum, &cpp_chroma, &cpp_chroma]; + let cpp0 = cpp.map(|k| k.at(0, 0)); - let mut cpe: [Vec; 3] = std::array::from_fn(|ch| { + // c_pe per pixel, channels interleaved so one footprint sweep touches each cache line once. + let per_channel: [Vec; 3] = std::array::from_fn(|ch| { let e: Vec = (0..n_px).map(|i| inks[indices[i] as usize][ch] - target[i][ch]).collect(); - correlate(&e, width, height, &cpp[ch]) + correlate(&e, width, height, cpp[ch]) }); + let mut cpe: Vec<[f64; 3]> = (0..n_px).map(|i| per_channel.each_ref().map(|c| c[i])).collect(); let mut err: f64 = (0..3) .map(|ch| { (0..n_px) - .map(|i| (inks[indices[i] as usize][ch] - target[i][ch]) * cpe[ch][i]) + .map(|i| (inks[indices[i] as usize][ch] - target[i][ch]) * cpe[i][ch]) .sum::() }) .sum(); let initial_error = err; - // c_pe += a·c_pp(· − (mx, my)), clipped to the image. - let add_kernel = |c: &mut [f64], k: &Kernel2D, mx: usize, my: usize, a: f64| { - let r = k.radius; - for y in my.saturating_sub(r)..=(my + r).min(height - 1) { - let row = y * width; - let dy = y as isize - my as isize; - for x in mx.saturating_sub(r)..=(mx + r).min(width - 1) { - c[row + x] += a * k.at(x as isize - mx as isize, dy); - } - } + // c_pe += a_ch·c_pp_ch(· − m) for every channel. Cx and Cz share the chrominance kernel, + // so they are updated in a single sweep. + let add_kernels = |cpe: &mut [[f64; 3]], m: (usize, usize), a: [f64; 3]| { + sweep(cpe, width, height, &cpp_lum, m, |c, k| c[0] += a[0] * k); + sweep(cpe, width, height, &cpp_chroma, m, |c, k| { + c[1] += a[1] * k; + c[2] += a[2] * k; + }); }; const EPS: f64 = 1e-9; @@ -266,7 +290,7 @@ pub fn dbs_refine( continue; } let k = indices[m] as usize; - let c = [cpe[0][m], cpe[1][m], cpe[2][m]]; + let c = cpe[m]; enum Move { Toggle(usize), Swap { n: usize, nx: usize, ny: usize } } let mut best_de = -EPS; @@ -296,7 +320,7 @@ pub fn dbs_refine( let de: f64 = (0..3) .map(|ch| { let a = inks[kn][ch] - inks[k][ch]; - 2.0 * a * a * (cpp0[ch] - cpp[ch].at(dx, dy)) + 2.0 * a * (c[ch] - cpe[ch][n]) + 2.0 * a * a * (cpp0[ch] - cpp[ch].at(dx, dy)) + 2.0 * a * (c[ch] - cpe[n][ch]) }) .sum(); if de < best_de { @@ -310,18 +334,14 @@ pub fn dbs_refine( err += best_de; match mv { Move::Toggle(j) => { - for ch in 0..3 { - add_kernel(&mut cpe[ch], &cpp[ch], x, y, inks[j][ch] - inks[k][ch]); - } + add_kernels(&mut cpe, (x, y), std::array::from_fn(|ch| inks[j][ch] - inks[k][ch])); indices[m] = j as u8; } Move::Swap { n, nx, ny } => { let kn = indices[n] as usize; - for ch in 0..3 { - let a = inks[kn][ch] - inks[k][ch]; - add_kernel(&mut cpe[ch], &cpp[ch], x, y, a); - add_kernel(&mut cpe[ch], &cpp[ch], nx, ny, -a); - } + let a: [f64; 3] = std::array::from_fn(|ch| inks[kn][ch] - inks[k][ch]); + add_kernels(&mut cpe, (x, y), a); + add_kernels(&mut cpe, (nx, ny), a.map(|v| -v)); indices.swap(m, n); } } @@ -359,7 +379,7 @@ pub fn perceived_error( (0..3) .map(|ch| { - let p = &blurs[ch]; + let p = &blurs[CHANNEL_BLUR[ch]]; let r = p.radius as isize; let e: Vec = (0..indices.len()).map(|i| inks[indices[i] as usize][ch] - target[i][ch]).collect(); // The blurred error is non-zero up to r pixels outside the image. From beddd43ccfd2131d3e24063d5cf234fdb2b891f7 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:34:12 +0200 Subject: [PATCH 04/12] perf(core): sweep DBS update footprint over row slices Zip kernel and c_pe row slices instead of indexing per element, removing per-element index arithmetic and bounds checks. Output is bit-identical; 10.58 s -> 6.71 s (1.58x). --- packages/rust/core/src/dbs.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/rust/core/src/dbs.rs b/packages/rust/core/src/dbs.rs index 55f3ec2..e72b65b 100644 --- a/packages/rust/core/src/dbs.rs +++ b/packages/rust/core/src/dbs.rs @@ -207,11 +207,15 @@ fn sweep( mut f: impl FnMut(&mut [f64; 3], f64), ) { let r = k.radius; + let (x0, x1) = (mx.saturating_sub(r), (mx + r).min(width - 1)); + // Kernel column of x0 is x0 − mx + r. + let kx0 = x0 + r - mx; for y in my.saturating_sub(r)..=(my + r).min(height - 1) { - let row = y * width; - let dy = y as isize - my as isize; - for x in mx.saturating_sub(r)..=(mx + r).min(width - 1) { - f(&mut cpe[row + x], k.at(x as isize - mx as isize, dy)); + let krow = (y + r - my) * k.size; + let kernel = &k.data[krow + kx0..=krow + kx0 + (x1 - x0)]; + let row = &mut cpe[y * width + x0..=y * width + x1]; + for (c, &kv) in row.iter_mut().zip(kernel) { + f(c, kv); } } } From 3ae070609e4ea1be8d108dffb8dffe494d7a88dd Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:34:26 +0200 Subject: [PATCH 05/12] perf(core): truncate DBS eye-model blur at 2.5 sigma Truncate the Gaussian blur itself (not its autocorrelation), so c_pp stays the exact autocorrelation and the error model remains self-consistent. Chroma kernel radius 7 -> 6, autocorrelation 29x29 -> 25x25. 6.71 s -> 5.44 s (1.24x); block dE 0.0667 -> 0.0666. 2.0 sigma was a further 15% faster with no measurable change, but block dE cannot see texture, so the conservative value is kept pending on-panel checks. --- packages/rust/core/src/dbs.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/rust/core/src/dbs.rs b/packages/rust/core/src/dbs.rs index e72b65b..fb2c85f 100644 --- a/packages/rust/core/src/dbs.rs +++ b/packages/rust/core/src/dbs.rs @@ -63,9 +63,13 @@ impl Kernel2D { } } -/// Normalised (unit-sum) sampled Gaussian, truncated at `ceil(3σ)`. +/// Blur truncation in standard deviations. Update cost grows with the square of the +/// autocorrelation radius (2× the blur radius), so this is the main speed/accuracy knob. +const BLUR_TRUNCATION_SIGMAS: f64 = 2.5; + +/// Normalised (unit-sum) sampled Gaussian, truncated at `ceil(BLUR_TRUNCATION_SIGMAS·σ)`. fn gaussian_blur(sigma: f64) -> Kernel2D { - let r = (3.0 * sigma).ceil().max(1.0) as usize; + let r = (BLUR_TRUNCATION_SIGMAS * sigma).ceil().max(1.0) as usize; let size = 2 * r + 1; let g: Vec = (0..size) .map(|i| { From 1af7db6d9238718cd376c4a90749da861da5c094 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:34:38 +0200 Subject: [PATCH 06/12] perf(core): run DBS on parallel tiles Each pass runs in four phases over a 2x2 checkerboard of tiles. Tiles of one phase are at least 2*margin+1 apart (margin = autocorrelation radius + 1), so they never touch each other's data and run in parallel with rayon on private region copies; no unsafe. The result depends only on the fixed tile order, not on thread count (new test), and the error cross-check now spans multiple tiles to catch an undersized margin. 5.44 s -> 2.07 s (2.6x) on 4P+4E cores with unchanged quality; ~4% overhead when single-threaded. --- packages/rust/core/src/dbs.rs | 327 ++++++++++++++++++++++++---------- 1 file changed, 236 insertions(+), 91 deletions(-) diff --git a/packages/rust/core/src/dbs.rs b/packages/rust/core/src/dbs.rs index fb2c85f..711b112 100644 --- a/packages/rust/core/src/dbs.rs +++ b/packages/rust/core/src/dbs.rs @@ -6,9 +6,9 @@ //! averages light the way the eye does — unlike OKLab, which `color_space_lab` uses for //! per-pixel matching. //! -//! Each pass visits every pixel in raster order and tries changing it to every other ink and -//! swapping it with each differing 8-neighbour, applying the move with the largest decrease -//! in `E`. Trial cost is O(1) via the cached correlation `c_pe = c_pp ⋆ e`, where +//! Each pass visits every pixel (raster order within tiles; tiles run in parallel, see +//! `dbs_refine`) and tries changing it to every other ink and swapping it with each differing +//! 8-neighbour, applying the move with the largest decrease in `E`. Trial cost is O(1) via the cached correlation `c_pe = c_pp ⋆ e`, where //! `c_pp = p ⋆ p` is the blur's autocorrelation; an accepted move updates `c_pe` over the //! footprint of `c_pp` only. //! @@ -224,12 +224,173 @@ fn sweep( } } +/// Read-only state shared by every tile. +struct Shared<'a> { + width: usize, + height: usize, + inks: &'a [[f64; 3]], + cpp_lum: &'a Kernel2D, + cpp_chroma: &'a Kernel2D, + pinned: Option<&'a [bool]>, +} + +impl Shared<'_> { + fn is_pinned(&self, i: usize) -> bool { + self.pinned.is_some_and(|p| p[i]) + } +} + +/// Inclusive-exclusive rectangle `[x0, x1) × [y0, y1)` in image coordinates. +#[derive(Clone, Copy)] +struct Rect { + x0: usize, + y0: usize, + x1: usize, + y1: usize, +} + +impl Rect { + fn width(&self) -> usize { + self.x1 - self.x0 + } +} + +/// A tile's private working copy of `c_pe` and `indices` over `rect` (the tile plus a margin +/// wide enough to hold every read and write its moves make). +struct Region { + rect: Rect, + cpe: Vec<[f64; 3]>, + indices: Vec, +} + +impl Region { + fn copy_from(rect: Rect, cpe: &[[f64; 3]], indices: &[u8], width: usize) -> Self { + let rows = rect.y0..rect.y1; + let span = |y: usize| y * width + rect.x0..y * width + rect.x1; + Self { + rect, + cpe: rows.clone().flat_map(|y| cpe[span(y)].iter().copied()).collect(), + indices: rows.flat_map(|y| indices[span(y)].iter().copied()).collect(), + } + } + + fn copy_into(&self, cpe: &mut [[f64; 3]], indices: &mut [u8], width: usize) { + let w = self.rect.width(); + for (ry, y) in (self.rect.y0..self.rect.y1).enumerate() { + let span = y * width + self.rect.x0..y * width + self.rect.x1; + cpe[span.clone()].copy_from_slice(&self.cpe[ry * w..(ry + 1) * w]); + indices[span].copy_from_slice(&self.indices[ry * w..(ry + 1) * w]); + } + } +} + +/// One raster pass of DBS over the pixels of `tile`, working in `region`. Returns the number +/// of accepted moves and the summed change in perceived error. +fn refine_tile(sh: &Shared, region: &mut Region, tile: Rect) -> (usize, f64) { + const EPS: f64 = 1e-9; + let Region { rect, cpe, indices } = region; + let (rw, rh) = (rect.width(), rect.y1 - rect.y0); + let local = |x: usize, y: usize| (y - rect.y0) * rw + (x - rect.x0); + let cpp = [sh.cpp_lum, sh.cpp_chroma, sh.cpp_chroma]; + let cpp0 = cpp.map(|k| k.at(0, 0)); + let inks = sh.inks; + + // c_pe += a_ch·c_pp_ch(· − m) for every channel. Cx and Cz share the chrominance kernel, + // so they are updated in a single sweep. The region margin covers the whole footprint, so + // clipping to the region only ever clips at true image edges. + let add_kernels = |cpe: &mut [[f64; 3]], (x, y): (usize, usize), a: [f64; 3]| { + let m = (x - rect.x0, y - rect.y0); + sweep(cpe, rw, rh, sh.cpp_lum, m, |c, k| c[0] += a[0] * k); + sweep(cpe, rw, rh, sh.cpp_chroma, m, |c, k| { + c[1] += a[1] * k; + c[2] += a[2] * k; + }); + }; + + let (mut accepted, mut delta) = (0, 0.0); + for y in tile.y0..tile.y1 { + for x in tile.x0..tile.x1 { + if sh.is_pinned(y * sh.width + x) { + continue; + } + let m = local(x, y); + let k = indices[m] as usize; + let c = cpe[m]; + + enum Move { Toggle(usize), Swap { n: usize, nx: usize, ny: usize } } + let mut best_de = -EPS; + let mut best = None; + + for j in (0..inks.len()).filter(|&j| j != k) { + let de: f64 = (0..3) + .map(|ch| { + let a = inks[j][ch] - inks[k][ch]; + cpp0[ch] * a * a + 2.0 * a * c[ch] + }) + .sum(); + if de < best_de { + best_de = de; + best = Some(Move::Toggle(j)); + } + } + + for (dx, dy) in NEIGHBOURS { + let Some((nx, ny)) = neighbour(x, y, dx, dy, sh.width, sh.height) else { continue }; + let n = local(nx, ny); + let kn = indices[n] as usize; + if kn == k || sh.is_pinned(ny * sh.width + nx) { + continue; + } + // m takes kn (Δ = a), n takes k (Δ = −a). + let de: f64 = (0..3) + .map(|ch| { + let a = inks[kn][ch] - inks[k][ch]; + 2.0 * a * a * (cpp0[ch] - cpp[ch].at(dx, dy)) + 2.0 * a * (c[ch] - cpe[n][ch]) + }) + .sum(); + if de < best_de { + best_de = de; + best = Some(Move::Swap { n, nx, ny }); + } + } + + let Some(mv) = best else { continue }; + accepted += 1; + delta += best_de; + match mv { + Move::Toggle(j) => { + add_kernels(cpe, (x, y), std::array::from_fn(|ch| inks[j][ch] - inks[k][ch])); + indices[m] = j as u8; + } + Move::Swap { n, nx, ny } => { + let kn = indices[n] as usize; + let a: [f64; 3] = std::array::from_fn(|ch| inks[kn][ch] - inks[k][ch]); + add_kernels(cpe, (x, y), a); + add_kernels(cpe, (nx, ny), a.map(|v| -v)); + indices.swap(m, n); + } + } + } + } + (accepted, delta) +} + +/// Smallest tile side. Larger tiles amortise the region copies; smaller ones expose more +/// parallelism. The correctness bound (`2·margin + 1`) is enforced separately. +const MIN_TILE: usize = 64; + /// Refine palette `indices` in place to minimise perceived error against `pixels` (sRGB, /// `width × height × 3`), as seen through `palette` (pass the measured palette). /// /// `pinned` pixels are never changed or swapped, and their target is taken to be their own /// ink, so they neither carry error nor pull neighbours to compensate for it. /// +/// Each pass runs in four phases over a 2×2 checkerboard of tiles. A move inside a tile reads +/// within 1 px of it and writes within `margin = R + 1` of it (R = autocorrelation radius), so +/// tiles of one phase — at least one tile side (≥ 2·margin + 1) apart — never touch each +/// other's data and run in parallel on private copies. The result depends only on the fixed +/// tile order, not on thread count or scheduling. +/// /// # Panics /// Panics if buffer lengths disagree with `width × height`, or on a non-positive `ppi` or /// viewing distance. @@ -247,18 +408,16 @@ pub fn dbs_refine( if let Some(p) = pinned { assert_eq!(p.len(), n_px, "pinned mask size mismatch"); } - let is_pinned = |i: usize| pinned.is_some_and(|p| p[i]); let (inks, mut target) = to_yycxcz(pixels, width, height, palette); for (i, t) in target.iter_mut().enumerate() { - if is_pinned(i) { + if pinned.is_some_and(|p| p[i]) { *t = inks[indices[i] as usize]; } } let [cpp_lum, cpp_chroma] = eye_blurs(params).map(|p| autocorrelate(&p)); let cpp = [&cpp_lum, &cpp_chroma, &cpp_chroma]; - let cpp0 = cpp.map(|k| k.at(0, 0)); // c_pe per pixel, channels interleaved so one footprint sweep touches each cache line once. let per_channel: [Vec; 3] = std::array::from_fn(|ch| { @@ -275,84 +434,46 @@ pub fn dbs_refine( .sum(); let initial_error = err; - // c_pe += a_ch·c_pp_ch(· − m) for every channel. Cx and Cz share the chrominance kernel, - // so they are updated in a single sweep. - let add_kernels = |cpe: &mut [[f64; 3]], m: (usize, usize), a: [f64; 3]| { - sweep(cpe, width, height, &cpp_lum, m, |c, k| c[0] += a[0] * k); - sweep(cpe, width, height, &cpp_chroma, m, |c, k| { - c[1] += a[1] * k; - c[2] += a[2] * k; - }); + let sh = Shared { width, height, inks: &inks, cpp_lum: &cpp_lum, cpp_chroma: &cpp_chroma, pinned }; + let margin = cpp_lum.radius.max(cpp_chroma.radius) + 1; + let side = MIN_TILE.max(2 * margin + 1); + let tiles = |phase_x: usize, phase_y: usize| { + let (cols, rows) = (width.div_ceil(side), height.div_ceil(side)); + (0..rows) + .filter(move |ty| ty % 2 == phase_y) + .flat_map(move |ty| (0..cols).filter(move |tx| tx % 2 == phase_x).map(move |tx| (tx, ty))) + .map(|(tx, ty)| Rect { + x0: tx * side, + y0: ty * side, + x1: ((tx + 1) * side).min(width), + y1: ((ty + 1) * side).min(height), + }) + .collect::>() + }; + let with_margin = |t: Rect| Rect { + x0: t.x0.saturating_sub(margin), + y0: t.y0.saturating_sub(margin), + x1: (t.x1 + margin).min(width), + y1: (t.y1 + margin).min(height), }; - const EPS: f64 = 1e-9; - let n_inks = inks.len(); let mut accepted_per_pass = Vec::new(); - for _ in 0..params.max_passes { let mut accepted = 0; - for y in 0..height { - for x in 0..width { - let m = y * width + x; - if is_pinned(m) { - continue; - } - let k = indices[m] as usize; - let c = cpe[m]; - - enum Move { Toggle(usize), Swap { n: usize, nx: usize, ny: usize } } - let mut best_de = -EPS; - let mut best = None; - - for j in (0..n_inks).filter(|&j| j != k) { - let de: f64 = (0..3) - .map(|ch| { - let a = inks[j][ch] - inks[k][ch]; - cpp0[ch] * a * a + 2.0 * a * c[ch] - }) - .sum(); - if de < best_de { - best_de = de; - best = Some(Move::Toggle(j)); - } - } - - for (dx, dy) in NEIGHBOURS { - let Some((nx, ny)) = neighbour(x, y, dx, dy, width, height) else { continue }; - let n = ny * width + nx; - let kn = indices[n] as usize; - if kn == k || is_pinned(n) { - continue; - } - // m takes kn (Δ = a), n takes k (Δ = −a). - let de: f64 = (0..3) - .map(|ch| { - let a = inks[kn][ch] - inks[k][ch]; - 2.0 * a * a * (cpp0[ch] - cpp[ch].at(dx, dy)) + 2.0 * a * (c[ch] - cpe[n][ch]) - }) - .sum(); - if de < best_de { - best_de = de; - best = Some(Move::Swap { n, nx, ny }); - } - } - - let Some(mv) = best else { continue }; - accepted += 1; - err += best_de; - match mv { - Move::Toggle(j) => { - add_kernels(&mut cpe, (x, y), std::array::from_fn(|ch| inks[j][ch] - inks[k][ch])); - indices[m] = j as u8; - } - Move::Swap { n, nx, ny } => { - let kn = indices[n] as usize; - let a: [f64; 3] = std::array::from_fn(|ch| inks[kn][ch] - inks[k][ch]); - add_kernels(&mut cpe, (x, y), a); - add_kernels(&mut cpe, (nx, ny), a.map(|v| -v)); - indices.swap(m, n); - } - } + for (phase_x, phase_y) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let results: Vec<_> = tiles(phase_x, phase_y) + .into_par_iter() + .map(|tile| { + let mut region = Region::copy_from(with_margin(tile), &cpe, indices, width); + let (n, delta) = refine_tile(&sh, &mut region, tile); + (region, n, delta) + }) + .collect(); + // Regions of one phase are disjoint; fold in tile order for a deterministic sum. + for (region, n, delta) in results { + region.copy_into(&mut cpe, indices, width); + accepted += n; + err += delta; } } accepted_per_pass.push(accepted); @@ -470,20 +591,24 @@ mod tests { /// The incremental bookkeeping (ΔE formulas, correlation updates) must agree with a /// from-scratch blur-and-square of the final halftone. Catches wrong toggle/swap deltas. + /// + /// The 200×150 case spans several tiles, so it also checks that tile margins are wide + /// enough: an update clipped at a margin would silently leave `c_pe` wrong. #[test] fn reported_errors_match_direct_computation() { - let (w, h) = (32, 24); - let px = gradient(w, h); - let palette = &SPECTRA_7_3_6COLOR; - let params = DbsParams::default(); - let mut idx = burkes(&px, w, h, palette); - let before = perceived_error(&px, w, h, palette, &idx, ¶ms); - - let stats = dbs_refine(&px, w, h, palette, &mut idx, None, ¶ms); - - let after = perceived_error(&px, w, h, palette, &idx, ¶ms); - assert_relative_eq!(stats.initial_error, before, max_relative = 1e-9); - assert_relative_eq!(stats.final_error, after, max_relative = 1e-9); + for (w, h) in [(32, 24), (200, 150)] { + let px = gradient(w, h); + let palette = &SPECTRA_7_3_6COLOR; + let params = DbsParams::default(); + let mut idx = burkes(&px, w, h, palette); + let before = perceived_error(&px, w, h, palette, &idx, ¶ms); + + let stats = dbs_refine(&px, w, h, palette, &mut idx, None, ¶ms); + + let after = perceived_error(&px, w, h, palette, &idx, ¶ms); + assert_relative_eq!(stats.initial_error, before, max_relative = 1e-9); + assert_relative_eq!(stats.final_error, after, max_relative = 1e-9); + } } /// After convergence no single toggle or 8-neighbour swap may lower the error, judged by @@ -552,6 +677,26 @@ mod tests { assert!((0.46..=0.54).contains(&frac), "white coverage {frac:.3}, expected ≈ 0.50"); } + /// Tiles run in parallel; the result must depend only on the fixed tile order, never on + /// thread count or scheduling. The image spans several tiles in each direction. + #[test] + fn result_is_independent_of_thread_count() { + let (w, h) = (200, 150); + let px = gradient(w, h); + let palette = &SPECTRA_7_3_6COLOR; + let init = burkes(&px, w, h, palette); + let run = |threads: usize| { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + let mut idx = init.clone(); + let stats = pool.install(|| dbs_refine(&px, w, h, palette, &mut idx, None, &DbsParams::default())); + (idx, stats) + }; + let (one, s1) = run(1); + let (four, s4) = run(4); + assert_eq!(one, four); + assert_eq!(s1, s4); + } + #[test] fn zero_passes_leaves_indices_untouched() { let (w, h) = (16, 12); From 33a4b89034572869cf1dfe7fe58211e4d8a3b719 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:56:25 +0200 Subject: [PATCH 07/12] feat(core): add DbsParams::validate Returns a typed InvalidDbsParams error for non-finite or non-positive viewing distance or ppi, so FFI bindings can report bad input as an error instead of hitting the panic in dbs_refine. --- packages/rust/core/src/dbs.rs | 60 +++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/rust/core/src/dbs.rs b/packages/rust/core/src/dbs.rs index 711b112..7a34b5d 100644 --- a/packages/rust/core/src/dbs.rs +++ b/packages/rust/core/src/dbs.rs @@ -113,16 +113,9 @@ const CHANNEL_BLUR: [usize; 3] = [0, 1, 1]; /// Eye-model blurs `[luminance, chrominance]`; index with `CHANNEL_BLUR`. fn eye_blurs(params: &DbsParams) -> [Kernel2D; 2] { - assert!( - params.ppi.is_finite() && params.ppi > 0.0, - "DBS ppi must be positive, got {}", - params.ppi - ); - assert!( - params.viewing_distance_cm.is_finite() && params.viewing_distance_cm > 0.0, - "DBS viewing distance must be positive, got {}", - params.viewing_distance_cm - ); + if let Err(e) = params.validate() { + panic!("{e}"); + } let lum = gaussian_blur(eye_sigma_px(ALPHA_LUM, params)); let chroma = gaussian_blur(eye_sigma_px(ALPHA_CHROMA, params)); [lum, chroma] @@ -187,6 +180,34 @@ impl Default for DbsParams { } } +/// A `DbsParams` field outside its valid range (finite and > 0). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct InvalidDbsParams { + pub field: &'static str, + pub value: f64, +} + +impl std::fmt::Display for InvalidDbsParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "DBS {} must be finite and greater than 0, got {}", self.field, self.value) + } +} + +impl std::error::Error for InvalidDbsParams {} + +impl DbsParams { + /// Check that the viewing geometry is usable. FFI boundaries call this to report a clean + /// error; `dbs_refine` panics on invalid parameters. + pub fn validate(&self) -> Result<(), InvalidDbsParams> { + for (field, value) in [("ppi", self.ppi), ("viewing_distance_cm", self.viewing_distance_cm)] { + if !(value.is_finite() && value > 0.0) { + return Err(InvalidDbsParams { field, value }); + } + } + Ok(()) + } +} + /// Diagnostics from a `dbs_refine` run. #[derive(Debug, Clone, PartialEq)] pub struct DbsStats { @@ -557,6 +578,25 @@ mod tests { error_diffusion_dither(px, w, h, palette, &BURKES, true) } + #[test] + fn validate_accepts_defaults() { + assert_eq!(DbsParams::default().validate(), Ok(())); + } + + #[test] + fn validate_rejects_non_positive_or_non_finite_geometry() { + for (params, field) in [ + (DbsParams { ppi: 0.0, ..Default::default() }, "ppi"), + (DbsParams { ppi: f64::NAN, ..Default::default() }, "ppi"), + (DbsParams { viewing_distance_cm: -1.0, ..Default::default() }, "viewing_distance_cm"), + (DbsParams { viewing_distance_cm: f64::INFINITY, ..Default::default() }, "viewing_distance_cm"), + ] { + let err = params.validate().unwrap_err(); + assert_eq!(err.field, field); + assert!(err.to_string().contains(field), "message should name the field: {err}"); + } + } + #[test] fn perceived_error_is_zero_when_halftone_equals_target() { let palette = &SPECTRA_7_3_6COLOR; From dd47065672ea0ebdddf3dedf8dbb03148132de1c Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:56:27 +0200 Subject: [PATCH 08/12] feat(python): expose DBS refinement dither_image(..., dbs=None | True | DbsParams(...)). DbsParams is a frozen dataclass (viewing_distance_cm=40, ppi=127, max_passes=10) validated on construction; the binding takes flat dbs_* keyword arguments and also validates, raising ValueError. Runs inside the existing GIL-released call. --- packages/python/README.md | 18 ++++ .../python/src/epaper_dithering/__init__.py | 3 +- packages/python/src/epaper_dithering/_rs.pyi | 3 + packages/python/src/epaper_dithering/core.py | 50 +++++++++++ packages/python/src/lib.rs | 20 ++++- packages/python/tests/test_dithering.py | 86 +++++++++++++++++++ 6 files changed, 177 insertions(+), 3 deletions(-) diff --git a/packages/python/README.md b/packages/python/README.md index 401c09e..c3a7c4b 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -28,6 +28,7 @@ pip install epaper-dithering - **10 Color Schemes**: Support for mono, 3-color, 4-color, 6-color, 7-color, and grayscale e-paper displays - **Pre-dither Adjustments**: Per-image exposure, saturation, shadows, highlights, dynamic-range compression, and gamut compression — all orthogonal knobs you can mix freely - **Serpentine Scanning**: Reduces directional artifacts in error diffusion (enabled by default) +- **DBS Refinement**: Optional Direct Binary Search pass that optimizes the dithered pixels against a model of the eye (slower: ~0.3 s per 800×480 panel on a multi-core machine) - **RGBA Support**: Automatic compositing on white background for transparent images ## Quick Start @@ -60,6 +61,7 @@ dither_image( highlights=0.0, # highlight compression, S-curve upper half tone="auto", # dynamic-range compression: "auto" | 0.0–1.0 gamut="auto", # gamut compression: "auto" | 0.0–1.0 + dbs=None, # DBS refinement: None | True | DbsParams(...) ) ``` @@ -185,6 +187,22 @@ result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut="of Note: `gamut` also has no effect for theoretical `ColorScheme` palettes. +#### DBS Refinement + +Direct Binary Search (DBS) runs after dithering and improves the result directly: it repeatedly changes pixels to other inks, or swaps them with neighbours, whenever that lowers the error *as perceived* through a model of the eye — sharp for brightness, blurrier for colour, scaled to your viewing distance and panel resolution. It typically cuts visible tone and colour error noticeably, at the cost of runtime (about 0.3 s per 800×480 panel on a multi-core machine, versus ~30 ms for plain dithering). The dither call releases the GIL, so other threads keep running meanwhile. + +```python +from epaper_dithering import DbsParams + +# Default viewing geometry: 40 cm from a ~127 ppi panel (7.3" 800×480) +result = dither_image(img, SPECTRA_7_3_6COLOR, dbs=True) + +# Match your setup: a wall-mounted 13.3" panel viewed from 1.5 m +result = dither_image(img, SPECTRA_7_3_6COLOR, dbs=DbsParams(viewing_distance_cm=150, ppi=150)) +``` + +`max_passes` (default 10) caps the number of refinement passes; most of the improvement happens in the first few. Pixels pinned to exact display colors (see below) are never changed by DBS. + `DitherMode.NONE` performs direct nearest-color mapping without error diffusion or ordered dithering. It is intended for already-quantized graphics, not continuous-tone photos: because there is no error diffusion, on limited palettes (especially BWR) a continuous-tone image or a large flat mid-tone area can map to an unexpected ink — for example, a solid mid-gray region can render as solid red. Use an error-diffusion mode (e.g. `FLOYD_STEINBERG`, `BURKES`) for photographic input. For built-in measured palettes, pure canonical display colors such as `(255, 0, 0)` map directly to the corresponding firmware palette index even though matching uses measured display RGB values. For built-in measured palettes, exact canonical display colors are also protected in ordered and error-diffusion modes when pre-processing is off: an image made entirely of display colors is returned as a direct palette-index map, and exact display-color pixels inside a mixed image keep their canonical index instead of being rematched to the measured RGB palette. Pre-processing runs before that exact-pixel check, so explicit `tone="auto"`/`gamut="auto"` or other adjustments may intentionally alter those pixels first. diff --git a/packages/python/src/epaper_dithering/__init__.py b/packages/python/src/epaper_dithering/__init__.py index 76aa422..e941d0e 100644 --- a/packages/python/src/epaper_dithering/__init__.py +++ b/packages/python/src/epaper_dithering/__init__.py @@ -4,7 +4,7 @@ for limited-color e-paper/e-ink displays. """ -from .core import dither_image +from .core import DbsParams, dither_image from .enums import DitherMode from .palettes import ( BWRY_3_97, @@ -23,6 +23,7 @@ __all__ = [ "dither_image", + "DbsParams", "DitherMode", "ColorPalette", "ColorScheme", diff --git a/packages/python/src/epaper_dithering/_rs.pyi b/packages/python/src/epaper_dithering/_rs.pyi index 9f9b0b4..b7e0cf6 100644 --- a/packages/python/src/epaper_dithering/_rs.pyi +++ b/packages/python/src/epaper_dithering/_rs.pyi @@ -14,6 +14,9 @@ def dither_image( highlights: float = ..., tone: float | None = ..., gamut: float | None = ..., + dbs_passes: int | None = ..., + dbs_viewing_distance_cm: float = ..., + dbs_ppi: float = ..., ) -> bytes: ... def composite_rgba(rgba: bytes) -> bytes: ... def measured_palettes() -> list[tuple[str, list[int], list[str], int, int]]: ... diff --git a/packages/python/src/epaper_dithering/core.py b/packages/python/src/epaper_dithering/core.py index 14f6fb5..3006c63 100644 --- a/packages/python/src/epaper_dithering/core.py +++ b/packages/python/src/epaper_dithering/core.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +import math +from dataclasses import dataclass from typing import cast from PIL import Image @@ -15,6 +17,49 @@ _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True) +class DbsParams: + """Direct Binary Search refinement settings. + + DBS improves a dithered image by repeatedly changing pixels to other inks or swapping + them with neighbours whenever that lowers the error as perceived through a model of the + eye at the given viewing geometry. It is slow (seconds on a full panel), so it is off + unless requested. + + Attributes: + viewing_distance_cm: Distance between viewer and panel. Larger values blur more. + ppi: Panel pixel density. A 7.3" 800x480 panel is about 127 ppi. + max_passes: Upper bound on full-image passes; stops early once nothing improves. + """ + + viewing_distance_cm: float = 40.0 + ppi: float = 127.0 + max_passes: int = 10 + + def __post_init__(self) -> None: + for name in ("viewing_distance_cm", "ppi"): + value = getattr(self, name) + if not (math.isfinite(value) and value > 0): + raise ValueError(f"{name} must be finite and greater than 0, got {value}") + if self.max_passes < 0: + raise ValueError(f"max_passes must be >= 0, got {self.max_passes}") + + +def _dbs_kwargs(dbs: DbsParams | bool | None) -> dict[str, object]: + """Map the `dbs` argument onto the binding's flat `dbs_*` keyword arguments.""" + if dbs is None or dbs is False: + return {} + if dbs is True: + dbs = DbsParams() + if not isinstance(dbs, DbsParams): + raise TypeError(f"dbs must be DbsParams, bool, or None, got {type(dbs).__name__}") + return { + "dbs_passes": dbs.max_passes, + "dbs_viewing_distance_cm": dbs.viewing_distance_cm, + "dbs_ppi": dbs.ppi, + } + + def _to_rgb_bytes(image: Image.Image) -> tuple[bytes, int, int]: """Convert PIL image to flat RGB bytes. Composites RGBA on white.""" if image.mode == "RGBA": @@ -48,6 +93,7 @@ def dither_image( # pylint: disable=too-many-arguments highlights: float = 0.0, tone: float | str = 0.0, gamut: float | str = 0.0, + dbs: DbsParams | bool | None = None, ) -> Image.Image: """Apply dithering to an image for e-paper display. @@ -69,6 +115,9 @@ def dither_image( # pylint: disable=too-many-arguments to display range, 0.0–1.0 = fixed strength. Only meaningful for measured palettes. gamut: Gamut compression for out-of-gamut pixels. 0.0 = off, "auto" = full strength on out-of-gamut pixels (smoothstep), 0.0–1.0 = fixed strength. + dbs: Direct Binary Search refinement after dithering. `None`/`False` = off, + `True` = default `DbsParams()`, or a `DbsParams` for custom viewing geometry. + Slow: seconds on a full panel. Returns: Dithered palette-mode (`"P"`) PIL Image matching the color scheme. @@ -92,6 +141,7 @@ def dither_image( # pylint: disable=too-many-arguments "highlights": highlights, "tone": _compression(tone), "gamut": _compression(gamut), + **_dbs_kwargs(dbs), } if isinstance(color_scheme, ColorScheme): diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs index 10bd6ff..32b97a9 100644 --- a/packages/python/src/lib.rs +++ b/packages/python/src/lib.rs @@ -1,6 +1,7 @@ use epaper_dithering_core::{ color_space_lab::rgb_to_oklab, composite::composite_rgba_on_white, + dbs::DbsParams, dither, dither_with_canonical, DitherConfig, enums::{DitherMode, GamutCompression, ToneCompression}, measured_palettes::CATALOG, @@ -48,10 +49,21 @@ fn parse_gamut(v: Option) -> GamutCompression { } } +/// `dbs_passes = None` disables DBS refinement; otherwise build and validate its parameters. +fn parse_dbs(passes: Option, viewing_distance_cm: f64, ppi: f64) -> PyResult> { + passes + .map(|max_passes| { + let params = DbsParams { viewing_distance_cm, ppi, max_passes }; + params.validate().map(|()| params).map_err(|e| PyValueError::new_err(e.to_string())) + }) + .transpose() +} + /// Dither a flat RGB image. /// /// Pass either `scheme_id` (idealized color scheme) or `palette_bytes` + `accent_idx` -/// (measured palette). If both are given, `palette_bytes` wins. +/// (measured palette). If both are given, `palette_bytes` wins. `dbs_passes` enables +/// Direct Binary Search refinement with up to that many passes. #[pyfunction] #[pyo3(signature = ( pixels, width, height, *, @@ -59,6 +71,7 @@ fn parse_gamut(v: Option) -> GamutCompression { mode_id=1, serpentine=true, exposure=1.0, saturation=1.0, shadows=0.0, highlights=0.0, tone=0.0, gamut=0.0, + dbs_passes=None, dbs_viewing_distance_cm=40.0, dbs_ppi=127.0, ))] #[allow(clippy::too_many_arguments)] fn dither_image( @@ -77,6 +90,9 @@ fn dither_image( highlights: f64, tone: Option, gamut: Option, + dbs_passes: Option, + dbs_viewing_distance_cm: f64, + dbs_ppi: f64, ) -> PyResult> { validate_image(&pixels, width)?; // Additionally validate against the caller's height instead of silently @@ -105,7 +121,7 @@ fn dither_image( highlights, tone: parse_tone(tone), gamut: parse_gamut(gamut), - dbs: None, + dbs: parse_dbs(dbs_passes, dbs_viewing_distance_cm, dbs_ppi)?, }; // Validation and `Palette` construction stay under the GIL; only the diff --git a/packages/python/tests/test_dithering.py b/packages/python/tests/test_dithering.py index 04b8ddb..4028ca9 100644 --- a/packages/python/tests/test_dithering.py +++ b/packages/python/tests/test_dithering.py @@ -557,3 +557,89 @@ def test_is_deterministic(self): a = dither_image(img, ColorScheme.BWR, mode=DitherMode.DIZZY) b = dither_image(img, ColorScheme.BWR, mode=DitherMode.DIZZY) assert list(a.getdata()) == list(b.getdata()) + + +def _color_gradient(width: int = 40, height: int = 30) -> Image.Image: + """Smooth colour gradient: enough structure that DBS changes the halftone.""" + img = Image.new("RGB", (width, height)) + img.putdata( + [ + (x * 255 // (width - 1), y * 255 // (height - 1), 255 - x * 255 // (width - 1)) + for y in range(height) + for x in range(width) + ] + ) + return img + + +class TestDbs: + """Direct Binary Search refinement (`dbs=`).""" + + def test_changes_output_for_measured_palette(self): + from epaper_dithering import SPECTRA_7_3_6COLOR + + img = _color_gradient() + plain = dither_image(img, SPECTRA_7_3_6COLOR) + refined = dither_image(img, SPECTRA_7_3_6COLOR, dbs=True) + + assert refined.mode == "P" + assert refined.size == plain.size + assert refined.tobytes() != plain.tobytes() + + def test_changes_output_for_color_scheme(self): + img = _color_gradient() + plain = dither_image(img, ColorScheme.BWR) + refined = dither_image(img, ColorScheme.BWR, dbs=True) + assert refined.tobytes() != plain.tobytes() + + def test_true_means_default_params(self): + from epaper_dithering import DbsParams + + img = _color_gradient() + assert ( + dither_image(img, ColorScheme.BWR, dbs=True).tobytes() + == dither_image(img, ColorScheme.BWR, dbs=DbsParams()).tobytes() + ) + + @pytest.mark.parametrize("off", [None, False]) + def test_off_values_match_plain_dither(self, off): + img = _color_gradient() + assert dither_image(img, ColorScheme.BWR, dbs=off).tobytes() == dither_image(img, ColorScheme.BWR).tobytes() + + def test_params_are_passed_through(self): + """Viewing geometry sets the eye-model blur width, so it must change the result.""" + from epaper_dithering import DbsParams + + img = _color_gradient() + near = dither_image(img, ColorScheme.BWR, dbs=DbsParams(viewing_distance_cm=15.0)) + far = dither_image(img, ColorScheme.BWR, dbs=DbsParams(viewing_distance_cm=150.0)) + assert near.tobytes() != far.tobytes() + + def test_zero_passes_matches_plain_dither(self): + from epaper_dithering import DbsParams + + img = _color_gradient() + assert ( + dither_image(img, ColorScheme.BWR, dbs=DbsParams(max_passes=0)).tobytes() + == dither_image(img, ColorScheme.BWR).tobytes() + ) + + @pytest.mark.parametrize( + "kwargs", + [{"ppi": 0.0}, {"ppi": float("nan")}, {"viewing_distance_cm": -1.0}, {"max_passes": -1}], + ) + def test_invalid_params_raise_value_error(self, kwargs): + from epaper_dithering import DbsParams + + with pytest.raises(ValueError): + DbsParams(**kwargs) + + def test_rejects_non_params_values(self): + with pytest.raises(TypeError): + dither_image(_color_gradient(), ColorScheme.BWR, dbs=10) # type: ignore[arg-type] + + def test_binding_validates_geometry(self): + """The Rust binding reports bad geometry as ValueError instead of panicking.""" + pixels, width, height = _to_rgb_bytes(_color_gradient(8, 8)) + with pytest.raises(ValueError, match="ppi"): + _rs.dither_image(pixels, width, height, scheme_id=1, dbs_passes=10, dbs_ppi=0.0) From 4a6233476d7537cb11e6431c1dcc79e4fb975d6f Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:56:29 +0200 Subject: [PATCH 09/12] feat(js): expose DBS refinement ditherImage(..., { dbs?: boolean | DbsParams }) with DbsParams { viewingDistanceCm, ppi, maxPasses }. Validated in TypeScript because wasm-bindgen silently coerces numbers into u32 (-1 -> 4294967295); the WASM binding validates geometry again as a backstop. --- packages/javascript/README.md | 24 ++++++++++- packages/javascript/src/core.ts | 46 ++++++++++++++++++++- packages/javascript/src/index.ts | 2 +- packages/javascript/src/wasm.d.ts | 1 + packages/javascript/tests/dithering.test.ts | 44 ++++++++++++++++++++ packages/rust/wasm/src/lib.rs | 17 +++++++- 6 files changed, 129 insertions(+), 5 deletions(-) diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 0d4587d..8053d08 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -13,6 +13,7 @@ High-quality dithering algorithms for e-paper/e-ink displays, powered by a Rust/ - **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches - **Pre-dither Adjustments**: Per-image exposure, saturation, shadows, highlights, dynamic-range compression, and gamut compression — all orthogonal knobs - **Serpentine Scanning**: Alternates row direction to eliminate directional artifacts +- **DBS Refinement**: Optional Direct Binary Search pass that optimizes the dithered pixels against a model of the eye - **Universal**: Works in browser (Canvas API) and Node.js (≥18) - **Zero Dependencies**: WASM binary bundled inline, no image library required @@ -124,8 +125,29 @@ ditherImage(image: ImageBuffer, palette: ColorScheme | ColorPalette, options?: D | `highlights` | `number` | `0.0` | Highlight compression strength (S-curve upper half). `0.0` = off, `1.0` = strong | | `tone` | `number \| 'auto' \| 'off'` | `0.0` | Dynamic range compression. `0.0`/`'off'` = disabled; `'auto'` = histogram-based; numeric = fixed strength. Ignored for `ColorScheme` | | `gamut` | `number \| 'auto' \| 'off'` | `0.0` | Pre-dither gamut compression. `0.0`/`'off'` = disabled; `'auto'` = activate when image exceeds palette gamut; numeric = fixed. Ignored for `ColorScheme` | +| `dbs` | `boolean \| DbsParams` | `false` | Direct Binary Search refinement after dithering. `true` = default parameters. See below | -Pre-processing pipeline: `exposure → saturation → shadows/highlights → tone → gamut → dither`. Each step is a no-op at its identity value. +Pipeline: `exposure → saturation → shadows/highlights → tone → gamut → dither → dbs`. Each step is a no-op at its identity value. + +#### DBS refinement + +Direct Binary Search (DBS) improves the dithered result directly: it repeatedly changes pixels to other inks, or swaps them with neighbours, whenever that lowers the error *as perceived* through a model of the eye — sharp for brightness, blurrier for colour, scaled to viewing distance and panel resolution. It is much slower than plain dithering (seconds on a full panel in a single-threaded browser) and blocks the calling thread, so run it in a Web Worker. + +```typescript +// Default geometry: 40 cm from a ~127 ppi panel (7.3" 800×480) +ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { dbs: true }); + +// Match your setup +ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { dbs: { viewingDistanceCm: 150, ppi: 150, maxPasses: 10 } }); +``` + +| `DbsParams` field | Default | Description | +|---|---|---| +| `viewingDistanceCm` | `40` | Distance between viewer and panel. Larger values blur more | +| `ppi` | `127` | Panel pixel density | +| `maxPasses` | `10` | Upper bound on refinement passes; stops early once nothing improves | + +Pixels pinned to exact display colors (see below) are never changed by DBS. `DitherMode.NONE` performs direct nearest-color mapping without error diffusion or ordered dithering. It is intended for already-quantized graphics, not continuous-tone photos: because there is no error diffusion, on limited palettes (especially BWR) a continuous-tone image or a large flat mid-tone area can map to an unexpected ink — for example, a solid mid-gray region can render as solid red. Use an error-diffusion mode (e.g. `FLOYD_STEINBERG`, `BURKES`) for photographic input. Built-in measured palettes carry their canonical firmware `scheme`, so pure display colors map to the corresponding firmware palette index even when measured RGB values are used for matching. diff --git a/packages/javascript/src/core.ts b/packages/javascript/src/core.ts index ff3c83b..6e55b87 100644 --- a/packages/javascript/src/core.ts +++ b/packages/javascript/src/core.ts @@ -21,11 +21,25 @@ const wasmInstance = new WebAssembly.Instance(wasmModule, { __wbg_set_wasm(wasmInstance.exports); (wasmInstance.exports as Record void>).__wbindgen_start?.(); +/** + * Direct Binary Search refinement settings. DBS improves a dithered image by repeatedly + * changing pixels to other inks or swapping them with neighbours whenever that lowers the + * error as perceived through a model of the eye at the given viewing geometry. + */ +export interface DbsParams { + /** Distance between viewer and panel in cm. Larger values blur more. Default: `40`. */ + viewingDistanceCm?: number; + /** Panel pixel density. A 7.3" 800×480 panel is about 127 ppi. Default: `127`. */ + ppi?: number; + /** Upper bound on full-image passes; stops early once nothing improves. Default: `10`. */ + maxPasses?: number; +} + /** * Options for `ditherImage`. All fields are optional — defaults are sensible. * - * Pre-processing pipeline (each step is a no-op at its identity value): - * `exposure → saturation → shadows/highlights → tone → gamut → dither`. + * Pipeline (each step is a no-op at its identity value): + * `exposure → saturation → shadows/highlights → tone → gamut → dither → dbs`. */ export interface DitherOptions { /** Dithering algorithm. Default: `DitherMode.BURKES`. */ @@ -44,6 +58,12 @@ export interface DitherOptions { tone?: number | 'auto' | 'off'; /** Gamut compression: `0.0`/`'off'` disables, `'auto'` opts in. Default: `0.0`. */ gamut?: number | 'auto' | 'off'; + /** + * Direct Binary Search refinement after dithering: `false` disables, `true` uses default + * {@link DbsParams}. Slow — seconds on a full panel, and it blocks the calling thread, so + * run it in a Web Worker in the browser. Default: `false`. + */ + dbs?: boolean | DbsParams; } /** @@ -68,7 +88,9 @@ export function ditherImage( highlights = 0.0, tone = 0.0, gamut = 0.0, + dbs = false, } = options; + const dbsArgs = parseDbs(dbs); const expectedLength = image.width * image.height * 4; if (image.data.length !== expectedLength) { @@ -114,11 +136,31 @@ export function ditherImage( mode as number, serpentine, exposure, saturation, shadows, highlights, toneArg, gamutArg, + ...dbsArgs, ); return { width: image.width, height: image.height, indices, palette: outputColors }; } +/** + * Map the `dbs` option onto the binding's `(dbs_passes, viewing_distance_cm, ppi)` arguments; + * `dbs_passes = undefined` disables DBS. Validated here because wasm-bindgen silently coerces + * numbers into `u32` (e.g. `-1` → 4294967295). + */ +function parseDbs(dbs: boolean | DbsParams): [number | undefined, number, number] { + if (dbs === false) return [undefined, 40, 127]; + const { viewingDistanceCm = 40, ppi = 127, maxPasses = 10 } = dbs === true ? {} : dbs; + for (const [name, value] of [['viewingDistanceCm', viewingDistanceCm], ['ppi', ppi]] as const) { + if (!(Number.isFinite(value) && value > 0)) { + throw new Error(`dbs.${name} must be finite and greater than 0, got ${value}`); + } + } + if (!(Number.isInteger(maxPasses) && maxPasses >= 0 && maxPasses <= 0xffffffff)) { + throw new Error(`dbs.maxPasses must be a non-negative integer, got ${maxPasses}`); + } + return [maxPasses, viewingDistanceCm, ppi]; +} + /** Map `'auto'` → `undefined` (Rust None = auto), `'off'` → 0.0, number → pass through. */ function parseCompression(v: number | 'auto' | 'off'): number | undefined { if (v === 'auto') return undefined; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 16166c0..33a452d 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -1,5 +1,5 @@ export { ditherImage } from './core'; -export type { DitherOptions } from './core'; +export type { DbsParams, DitherOptions } from './core'; export { DitherMode } from './enums'; export { ColorScheme, diff --git a/packages/javascript/src/wasm.d.ts b/packages/javascript/src/wasm.d.ts index ad2e62b..e21bb90 100644 --- a/packages/javascript/src/wasm.d.ts +++ b/packages/javascript/src/wasm.d.ts @@ -18,6 +18,7 @@ declare module '*epaper_dithering_wasm_bg.js' { mode_id: number, serpentine: boolean, exposure: number, saturation: number, shadows: number, highlights: number, tone?: number | null, gamut?: number | null, + dbs_passes?: number | null, dbs_viewing_distance_cm?: number, dbs_ppi?: number, ): Uint8Array; export function measured_palettes(): string; export function __wbg_set_wasm(wasm: unknown): void; diff --git a/packages/javascript/tests/dithering.test.ts b/packages/javascript/tests/dithering.test.ts index 6ff5df1..0bfc238 100644 --- a/packages/javascript/tests/dithering.test.ts +++ b/packages/javascript/tests/dithering.test.ts @@ -8,6 +8,7 @@ import { fromValue, SPECTRA_7_3_6COLOR, } from '../src'; +import type { DbsParams, DitherOptions } from '../src'; import { createTestImage, createGradient, createTransparentTestImage } from './fixtures'; // Imported after '../src' so that core.ts has already run __wbg_set_wasm. import { composite_rgba as compositeRgba } from '../src/wasm-core/epaper_dithering_wasm_bg.js'; @@ -544,3 +545,46 @@ describe('Dizzy dithering', () => { expect(Array.from(a.indices)).toEqual(Array.from(b.indices)); }); }); + +describe('DBS refinement', () => { + // Grayscale gradient on BWR: enough structure that DBS changes the halftone. + const image = createGradient(40, 30); + const indices = (options: DitherOptions = {}) => Array.from(ditherImage(image, ColorScheme.BWR, options).indices); + + it('changes the output for a color scheme', () => { + expect(indices({ dbs: true })).not.toEqual(indices()); + }); + + it('changes the output for a measured palette', () => { + const plain = ditherImage(image, SPECTRA_7_3_6COLOR).indices; + const refined = ditherImage(image, SPECTRA_7_3_6COLOR, { dbs: true }).indices; + expect(Array.from(refined)).not.toEqual(Array.from(plain)); + }); + + it('true means default parameters', () => { + expect(indices({ dbs: true })).toEqual(indices({ dbs: {} })); + expect(indices({ dbs: true })).toEqual(indices({ dbs: { viewingDistanceCm: 40, ppi: 127, maxPasses: 10 } })); + }); + + it('false and omitted match plain dithering', () => { + expect(indices({ dbs: false })).toEqual(indices()); + }); + + it('passes viewing geometry through', () => { + expect(indices({ dbs: { viewingDistanceCm: 15 } })).not.toEqual(indices({ dbs: { viewingDistanceCm: 150 } })); + }); + + it('zero passes matches plain dithering', () => { + expect(indices({ dbs: { maxPasses: 0 } })).toEqual(indices()); + }); + + it.each([ + [{ ppi: 0 }, /ppi/], + [{ ppi: Number.NaN }, /ppi/], + [{ viewingDistanceCm: -1 }, /viewingDistanceCm/], + [{ maxPasses: -1 }, /maxPasses/], + [{ maxPasses: 1.5 }, /maxPasses/], + ] as [DbsParams, RegExp][])('rejects invalid parameters %o', (dbs, message) => { + expect(() => ditherImage(image, ColorScheme.BWR, { dbs })).toThrow(message); + }); +}); diff --git a/packages/rust/wasm/src/lib.rs b/packages/rust/wasm/src/lib.rs index 3c51632..bd72bb1 100644 --- a/packages/rust/wasm/src/lib.rs +++ b/packages/rust/wasm/src/lib.rs @@ -1,5 +1,6 @@ use epaper_dithering_core::{ composite::composite_rgba_on_white, + dbs::DbsParams, dither, dither_with_canonical, DitherConfig, enums::{DitherMode, GamutCompression, ToneCompression}, measured_palettes::CATALOG, @@ -28,8 +29,19 @@ fn parse_gamut(v: Option) -> GamutCompression { } } +/// `dbs_passes = None` disables DBS refinement; otherwise build and validate its parameters. +fn parse_dbs(passes: Option, viewing_distance_cm: f64, ppi: f64) -> Result, JsValue> { + passes + .map(|max_passes| { + let params = DbsParams { viewing_distance_cm, ppi, max_passes }; + params.validate().map(|()| params).map_err(|e| JsValue::from_str(&e.to_string())) + }) + .transpose() +} + /// Dither a flat RGB image. Accepts either an idealized `scheme_id` or a measured /// `palette_bytes`/`accent_idx` pair; `palette_bytes` empty ⇒ use `scheme_id`. +/// `dbs_passes` enables Direct Binary Search refinement with up to that many passes. /// /// Returns a `Uint8Array` of palette indices (one per pixel, length = width × height). #[wasm_bindgen] @@ -48,6 +60,9 @@ pub fn dither_image( highlights: f64, tone: Option, gamut: Option, + dbs_passes: Option, + dbs_viewing_distance_cm: f64, + dbs_ppi: f64, ) -> Result, JsValue> { if width == 0 { return Err(JsValue::from_str("width must be greater than 0")); @@ -70,7 +85,7 @@ pub fn dither_image( highlights, tone: parse_tone(tone), gamut: parse_gamut(gamut), - dbs: None, + dbs: parse_dbs(dbs_passes, dbs_viewing_distance_cm, dbs_ppi)?, }; if palette_bytes.is_empty() { From 6282d76bfe298cffd40d7027ae329d4b05ef4154 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 7 Oct 2026 12:57:46 +0200 Subject: [PATCH 10/12] style(rust): use as_chunks for fixed-size chunking Clippy 1.99 (used by CI's unpinned stable toolchain) adds chunks_exact_to_as_chunks, which fails `-D warnings` on four sites: composite.rs, two in core lib.rs, and the iOS wrapper. as_chunks yields &[u8; N] chunks, so the iOS palette parse simplifies to to_vec(). Clean on both 1.96 and 1.99. --- packages/rust/core/src/composite.rs | 2 +- packages/rust/core/src/lib.rs | 8 ++++++-- packages/rust/ios/src/lib.rs | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/rust/core/src/composite.rs b/packages/rust/core/src/composite.rs index 178f29f..3ecfc2d 100644 --- a/packages/rust/core/src/composite.rs +++ b/packages/rust/core/src/composite.rs @@ -39,7 +39,7 @@ pub fn composite_rgba_on_white(rgba: &[u8]) -> Result, InvalidRgbaLength return Err(InvalidRgbaLength { len: rgba.len() }); } let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3); - for px in rgba.chunks_exact(4) { + for px in rgba.as_chunks::<4>().0 { let a = px[3] as f64 / 255.0; let inv = 1.0 - a; rgb.push((px[0] as f64 * a + 255.0 * inv).round() as u8); diff --git a/packages/rust/core/src/lib.rs b/packages/rust/core/src/lib.rs index 233de66..330763a 100644 --- a/packages/rust/core/src/lib.rs +++ b/packages/rust/core/src/lib.rs @@ -175,7 +175,9 @@ fn dither_impl( // Convert sRGB bytes → linear, apply pre-processing pipeline, convert back. let mut linear: Vec<[f64; 3]> = img .data - .chunks_exact(3) + .as_chunks::<3>() + .0 + .iter() .map(|c| [ srgb_channel_to_linear(c[0]), srgb_channel_to_linear(c[1]), @@ -216,7 +218,9 @@ fn refine( let Some(params) = params else { return indices }; let pinned: Option> = pin_exact_pixels.then(|| { img.data - .chunks_exact(3) + .as_chunks::<3>() + .0 + .iter() .map(|rgb| algorithms::exact_palette_index(rgb, canonical).is_some()) .collect() }); diff --git a/packages/rust/ios/src/lib.rs b/packages/rust/ios/src/lib.rs index ff0dd1a..5102a9d 100644 --- a/packages/rust/ios/src/lib.rs +++ b/packages/rust/ios/src/lib.rs @@ -67,7 +67,7 @@ fn build_palette(ptr: *const u8, len: usize, accent_idx: usize) -> Result