1use crate::color_space::{srgb_channel_to_linear, srgb_fraction_to_linear};
4use crate::color_space_lab::{PaletteLab, match_pixel_oklab, rgb_to_oklab, WAB};
5use crate::palettes::Palette;
6use rayon::prelude::*;
7
8pub struct KernelOffset {
12 pub dx: i32,
13 pub dy: i32,
14 pub weight: f64,
15}
16
17pub struct Kernel {
18 pub offsets: &'static [KernelOffset],
19}
20
21pub static FLOYD_STEINBERG: Kernel = Kernel {
24 offsets: &[
25 KernelOffset { dx: 1, dy: 0, weight: 7.0 / 16.0 },
26 KernelOffset { dx: -1, dy: 1, weight: 3.0 / 16.0 },
27 KernelOffset { dx: 0, dy: 1, weight: 5.0 / 16.0 },
28 KernelOffset { dx: 1, dy: 1, weight: 1.0 / 16.0 },
29 ],
30};
31
32pub static ATKINSON: Kernel = Kernel {
33 offsets: &[
34 KernelOffset { dx: 1, dy: 0, weight: 1.0 / 8.0 },
35 KernelOffset { dx: 2, dy: 0, weight: 1.0 / 8.0 },
36 KernelOffset { dx: -1, dy: 1, weight: 1.0 / 8.0 },
37 KernelOffset { dx: 0, dy: 1, weight: 1.0 / 8.0 },
38 KernelOffset { dx: 1, dy: 1, weight: 1.0 / 8.0 },
39 KernelOffset { dx: 0, dy: 2, weight: 1.0 / 8.0 },
40 ],
41};
42
43pub static BURKES: Kernel = Kernel {
44 offsets: &[
45 KernelOffset { dx: 1, dy: 0, weight: 8.0 / 32.0 },
46 KernelOffset { dx: 2, dy: 0, weight: 4.0 / 32.0 },
47 KernelOffset { dx: -2, dy: 1, weight: 2.0 / 32.0 },
48 KernelOffset { dx: -1, dy: 1, weight: 4.0 / 32.0 },
49 KernelOffset { dx: 0, dy: 1, weight: 8.0 / 32.0 },
50 KernelOffset { dx: 1, dy: 1, weight: 4.0 / 32.0 },
51 KernelOffset { dx: 2, dy: 1, weight: 2.0 / 32.0 },
52 ],
53};
54
55pub static STUCKI: Kernel = Kernel {
56 offsets: &[
57 KernelOffset { dx: 1, dy: 0, weight: 8.0 / 42.0 },
58 KernelOffset { dx: 2, dy: 0, weight: 4.0 / 42.0 },
59 KernelOffset { dx: -2, dy: 1, weight: 2.0 / 42.0 },
60 KernelOffset { dx: -1, dy: 1, weight: 4.0 / 42.0 },
61 KernelOffset { dx: 0, dy: 1, weight: 8.0 / 42.0 },
62 KernelOffset { dx: 1, dy: 1, weight: 4.0 / 42.0 },
63 KernelOffset { dx: 2, dy: 1, weight: 2.0 / 42.0 },
64 KernelOffset { dx: -2, dy: 2, weight: 1.0 / 42.0 },
65 KernelOffset { dx: -1, dy: 2, weight: 2.0 / 42.0 },
66 KernelOffset { dx: 0, dy: 2, weight: 4.0 / 42.0 },
67 KernelOffset { dx: 1, dy: 2, weight: 2.0 / 42.0 },
68 KernelOffset { dx: 2, dy: 2, weight: 1.0 / 42.0 },
69 ],
70};
71
72pub static SIERRA: Kernel = Kernel {
73 offsets: &[
74 KernelOffset { dx: 1, dy: 0, weight: 5.0 / 32.0 },
75 KernelOffset { dx: 2, dy: 0, weight: 3.0 / 32.0 },
76 KernelOffset { dx: -2, dy: 1, weight: 2.0 / 32.0 },
77 KernelOffset { dx: -1, dy: 1, weight: 4.0 / 32.0 },
78 KernelOffset { dx: 0, dy: 1, weight: 5.0 / 32.0 },
79 KernelOffset { dx: 1, dy: 1, weight: 4.0 / 32.0 },
80 KernelOffset { dx: 2, dy: 1, weight: 2.0 / 32.0 },
81 KernelOffset { dx: -1, dy: 2, weight: 2.0 / 32.0 },
82 KernelOffset { dx: 0, dy: 2, weight: 3.0 / 32.0 },
83 KernelOffset { dx: 1, dy: 2, weight: 2.0 / 32.0 },
84 ],
85};
86
87pub static SIERRA_LITE: Kernel = Kernel {
88 offsets: &[
89 KernelOffset { dx: 1, dy: 0, weight: 2.0 / 4.0 },
90 KernelOffset { dx: -1, dy: 1, weight: 1.0 / 4.0 },
91 KernelOffset { dx: 0, dy: 1, weight: 1.0 / 4.0 },
92 ],
93};
94
95pub static JARVIS_JUDICE_NINKE: Kernel = Kernel {
96 offsets: &[
97 KernelOffset { dx: 1, dy: 0, weight: 7.0 / 48.0 },
98 KernelOffset { dx: 2, dy: 0, weight: 5.0 / 48.0 },
99 KernelOffset { dx: -2, dy: 1, weight: 3.0 / 48.0 },
100 KernelOffset { dx: -1, dy: 1, weight: 5.0 / 48.0 },
101 KernelOffset { dx: 0, dy: 1, weight: 7.0 / 48.0 },
102 KernelOffset { dx: 1, dy: 1, weight: 5.0 / 48.0 },
103 KernelOffset { dx: 2, dy: 1, weight: 3.0 / 48.0 },
104 KernelOffset { dx: -2, dy: 2, weight: 1.0 / 48.0 },
105 KernelOffset { dx: -1, dy: 2, weight: 3.0 / 48.0 },
106 KernelOffset { dx: 0, dy: 2, weight: 5.0 / 48.0 },
107 KernelOffset { dx: 1, dy: 2, weight: 3.0 / 48.0 },
108 KernelOffset { dx: 2, dy: 2, weight: 1.0 / 48.0 },
109 ],
110};
111
112fn build_palette_lab(palette: &Palette) -> (Vec<[f64; 3]>, PaletteLab) {
115 let palette_linear: Vec<[f64; 3]> = palette
116 .colors
117 .iter()
118 .map(|&[r, g, b]| {
119 [
120 srgb_channel_to_linear(r),
121 srgb_channel_to_linear(g),
122 srgb_channel_to_linear(b),
123 ]
124 })
125 .collect();
126 let palette_lab = PaletteLab::from_linear_rgb(&palette_linear);
127 (palette_linear, palette_lab)
128}
129
130pub fn error_diffusion_dither(
134 pixels: &[u8],
135 width: usize,
136 height: usize,
137 palette: &Palette,
138 kernel: &Kernel,
139 serpentine: bool,
140) -> Vec<u8> {
141 error_diffusion_dither_impl(pixels, width, height, palette, None, kernel, serpentine)
142}
143
144pub fn error_diffusion_dither_with_canonical(
150 pixels: &[u8],
151 width: usize,
152 height: usize,
153 palette: &Palette,
154 canonical_palette: &Palette,
155 kernel: &Kernel,
156 serpentine: bool,
157) -> Vec<u8> {
158 error_diffusion_dither_impl(
159 pixels,
160 width,
161 height,
162 palette,
163 Some(canonical_palette),
164 kernel,
165 serpentine,
166 )
167}
168
169fn error_diffusion_dither_impl(
170 pixels: &[u8],
171 width: usize,
172 height: usize,
173 palette: &Palette,
174 canonical_palette: Option<&Palette>,
175 kernel: &Kernel,
176 serpentine: bool,
177) -> Vec<u8> {
178 let (_palette_linear, palette_lab) = build_palette_lab(palette);
179
180 let palette_srgb_f: Vec<[f64; 3]> = palette
182 .colors
183 .iter()
184 .map(|&[r, g, b]| [r as f64, g as f64, b as f64])
185 .collect();
186
187 let mut buf: Vec<f64> = pixels.iter().map(|&v| v as f64).collect();
189
190 let lut: Vec<f64> = (0u8..=255).map(srgb_channel_to_linear).collect();
192
193 let mut output = vec![0u8; width * height];
194
195 for y in 0..height {
196 let reverse = serpentine && y % 2 == 1;
198
199 for xi in 0..width {
200 let x = if reverse { width - 1 - xi } else { xi };
201 let idx = (y * width + x) * 3;
202
203 if let Some(canonical_palette) = canonical_palette
204 && let Some(exact_idx) =
205 exact_palette_index(&pixels[idx..idx + 3], canonical_palette)
206 {
207 output[y * width + x] = exact_idx;
208 continue;
209 }
210
211 let rs = buf[idx].clamp(0.0, 255.0);
212 let gs = buf[idx + 1].clamp(0.0, 255.0);
213 let bs = buf[idx + 2].clamp(0.0, 255.0);
214
215 let r_lin = lut[rs.round() as usize];
216 let g_lin = lut[gs.round() as usize];
217 let b_lin = lut[bs.round() as usize];
218
219 let pixel_lab = rgb_to_oklab(r_lin, g_lin, b_lin);
220 let best_idx = match_pixel_oklab(pixel_lab, &palette_lab, WAB);
221
222 output[y * width + x] = best_idx as u8;
223
224 let err_r = rs - palette_srgb_f[best_idx][0];
226 let err_g = gs - palette_srgb_f[best_idx][1];
227 let err_b = bs - palette_srgb_f[best_idx][2];
228
229 for offset in kernel.offsets {
230 let effective_dx = if reverse { -offset.dx } else { offset.dx };
231
232 let nx = x as i64 + effective_dx as i64;
233 let ny = y as i64 + offset.dy as i64;
234
235 if nx >= 0 && nx < width as i64 && ny >= 0 && ny < height as i64 {
236 let ni = (ny as usize * width + nx as usize) * 3;
237 buf[ni] += err_r * offset.weight;
238 buf[ni + 1] += err_g * offset.weight;
239 buf[ni + 2] += err_b * offset.weight;
240 }
241 }
242 }
243 }
244
245 output
246}
247
248pub fn floyd_steinberg(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
251 error_diffusion_dither(pixels, w, h, palette, &FLOYD_STEINBERG, serpentine)
252}
253pub fn atkinson(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
254 error_diffusion_dither(pixels, w, h, palette, &ATKINSON, serpentine)
255}
256pub fn burkes(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
257 error_diffusion_dither(pixels, w, h, palette, &BURKES, serpentine)
258}
259pub fn stucki(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
260 error_diffusion_dither(pixels, w, h, palette, &STUCKI, serpentine)
261}
262pub fn sierra(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
263 error_diffusion_dither(pixels, w, h, palette, &SIERRA, serpentine)
264}
265pub fn sierra_lite(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
266 error_diffusion_dither(pixels, w, h, palette, &SIERRA_LITE, serpentine)
267}
268pub fn jarvis_judice_ninke(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
269 error_diffusion_dither(pixels, w, h, palette, &JARVIS_JUDICE_NINKE, serpentine)
270}
271
272fn exact_palette_index(rgb: &[u8], palette: &Palette) -> Option<u8> {
276 palette
277 .colors
278 .iter()
279 .position(|&color| rgb == color)
280 .and_then(|idx| u8::try_from(idx).ok())
281}
282
283pub fn try_exact_palette_map(pixels: &[u8], canonical_palette: &Palette) -> Option<Vec<u8>> {
284 pixels
285 .par_chunks(3)
286 .map(|rgb| exact_palette_index(rgb, canonical_palette))
287 .collect()
288}
289
290pub fn direct_map(pixels: &[u8], palette: &Palette, canonical_palette: &Palette) -> Vec<u8> {
291 let (_, palette_lab) = build_palette_lab(palette);
292 pixels
293 .par_chunks(3)
294 .map(|rgb| {
295 if let Some(idx) = exact_palette_index(rgb, canonical_palette) {
296 return idx;
297 }
298
299 let r = srgb_channel_to_linear(rgb[0]);
300 let g = srgb_channel_to_linear(rgb[1]);
301 let b = srgb_channel_to_linear(rgb[2]);
302 let lab = rgb_to_oklab(r, g, b);
303 match_pixel_oklab(lab, &palette_lab, WAB) as u8
304 })
305 .collect()
306}
307
308const BAYER_4X4: [[f64; 4]; 4] = [
314 [-0.5000, 0.0000, -0.3750, 0.1250],
315 [ 0.2500, -0.2500, 0.3750, -0.1250],
316 [-0.3125, 0.1875, -0.4375, 0.0625],
317 [ 0.4375, -0.0625, 0.3125, -0.1875],
318];
319
320pub fn ordered_dither(pixels: &[u8], width: usize, palette: &Palette) -> Vec<u8> {
328 ordered_dither_impl(pixels, width, palette, None)
329}
330
331pub fn ordered_dither_with_canonical(
332 pixels: &[u8],
333 width: usize,
334 palette: &Palette,
335 canonical_palette: &Palette,
336) -> Vec<u8> {
337 ordered_dither_impl(pixels, width, palette, Some(canonical_palette))
338}
339
340fn ordered_dither_impl(
341 pixels: &[u8],
342 width: usize,
343 palette: &Palette,
344 canonical_palette: Option<&Palette>,
345) -> Vec<u8> {
346 let (_palette_linear, palette_lab) = build_palette_lab(palette);
347
348 pixels
349 .par_chunks(3)
350 .enumerate()
351 .map(|(i, rgb)| {
352 if let Some(canonical_palette) = canonical_palette
353 && let Some(idx) = exact_palette_index(rgb, canonical_palette)
354 {
355 return idx;
356 }
357
358 let x = i % width;
359 let y = i / width;
360
361 let threshold = BAYER_4X4[y % 4][x % 4];
362
363 let r_srgb = (rgb[0] as f64 / 255.0 + threshold).clamp(0.0, 1.0);
365 let g_srgb = (rgb[1] as f64 / 255.0 + threshold).clamp(0.0, 1.0);
366 let b_srgb = (rgb[2] as f64 / 255.0 + threshold).clamp(0.0, 1.0);
367
368 let lab = rgb_to_oklab(
369 srgb_fraction_to_linear(r_srgb),
370 srgb_fraction_to_linear(g_srgb),
371 srgb_fraction_to_linear(b_srgb),
372 );
373 match_pixel_oklab(lab, &palette_lab, WAB) as u8
374 })
375 .collect()
376}
377
378#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::palettes::ColorScheme;
384
385 fn solid_image(r: u8, g: u8, b: u8, w: usize, h: usize) -> Vec<u8> {
386 vec![r, g, b].into_iter().cycle().take(w * h * 3).collect()
387 }
388
389 #[test]
390 fn solid_white_maps_to_white() {
391 let pixels = solid_image(255, 255, 255, 4, 4);
392 let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
393 assert!(out.iter().all(|&v| v == 1));
395 }
396
397 #[test]
398 fn solid_black_maps_to_black() {
399 let pixels = solid_image(0, 0, 0, 4, 4);
400 let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
401 assert!(out.iter().all(|&v| v == 0));
402 }
403
404 #[test]
405 fn output_length_matches_pixels() {
406 let pixels = solid_image(128, 64, 200, 10, 7);
407 let out = floyd_steinberg(&pixels, 10, 7, ColorScheme::Bwr.palette(), true);
408 assert_eq!(out.len(), 10 * 7);
409 }
410
411 #[test]
412 fn all_algorithms_produce_correct_length() {
413 let pixels = solid_image(128, 64, 200, 10, 7);
414 let pal = ColorScheme::Bwr.palette();
415 assert_eq!(burkes(&pixels, 10, 7, pal, false).len(), 70);
416 assert_eq!(atkinson(&pixels, 10, 7, pal, false).len(), 70);
417 assert_eq!(stucki(&pixels, 10, 7, pal, false).len(), 70);
418 assert_eq!(sierra(&pixels, 10, 7, pal, false).len(), 70);
419 assert_eq!(sierra_lite(&pixels, 10, 7, pal, false).len(), 70);
420 assert_eq!(jarvis_judice_ninke(&pixels, 10, 7, pal, false).len(), 70);
421 assert_eq!(ordered_dither(&pixels, 10, pal).len(), 70);
422 assert_eq!(direct_map(&pixels, pal, pal).len(), 70);
423 }
424
425 #[test]
426 fn direct_map_pure_red_maps_to_red_in_bwr() {
427 let pixels = vec![255u8, 0, 0];
429 let out = direct_map(&pixels, ColorScheme::Bwr.palette(), ColorScheme::Bwr.palette());
430 assert_eq!(out[0], 2, "pure sRGB red should map to red ink (index 2) in BWR");
431 }
432
433 #[test]
434 fn direct_map_pure_blue_maps_to_blue_in_bwgbry() {
435 let pixels = vec![0u8, 0, 255];
437 let out = direct_map(&pixels, ColorScheme::Bwgbry.palette(), ColorScheme::Bwgbry.palette());
438 assert_eq!(out[0], 4, "pure sRGB blue should map to blue ink (index 4) in BWGBRY");
439 }
440
441 #[test]
442 fn serpentine_differs_from_raster_on_gradient() {
443 let pixels: Vec<u8> = (0u8..=255)
446 .flat_map(|v| [v, v / 2, 255 - v])
447 .cycle()
448 .take(16 * 16 * 3)
449 .collect();
450 let raster = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), false);
451 let serpentine = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), true);
452 assert_ne!(raster, serpentine, "serpentine and raster should differ on a gradient");
453 }
454
455 #[test]
469 fn ordered_dither_activity_is_perceptually_uniform() {
470 const W: usize = 256;
471 const H: usize = 16; const BANDS: usize = 8;
473 const BAND_W: usize = W / BANDS;
474
475 let mut pixels = Vec::with_capacity(W * H * 3);
477 for _ in 0..H {
478 for x in 0..W {
479 let v = x as u8;
480 pixels.extend_from_slice(&[v, v, v]);
481 }
482 }
483 let out = ordered_dither(&pixels, W, ColorScheme::Mono.palette());
484
485 let mut transitions = [0usize; BANDS];
487 for y in 0..H {
488 for x in 0..W - 1 {
489 if out[y * W + x] != out[y * W + x + 1] {
490 transitions[(x / BAND_W).min(BANDS - 1)] += 1;
491 }
492 }
493 }
494
495 let mid = &transitions[1..BANDS - 1];
498 let max = *mid.iter().max().unwrap();
499 let min = *mid.iter().min().unwrap();
500 assert!(min > 0, "every mid-tone band should have at least one transition: {transitions:?}");
501 let ratio = max as f64 / min as f64;
502 assert!(
503 ratio < 5.0,
504 "ordered-dither activity is perceptually skewed (max/min = {ratio:.2}); \
505 expected sRGB-space ordered dither to spread roughly uniformly across \
506 mid-tones. Per-band transitions: {transitions:?}"
507 );
508 }
509
510 #[test]
511 fn serpentine_first_row_matches_raster() {
512 let pixels: Vec<u8> = (0u8..=255)
514 .flat_map(|v| [v, 128u8.wrapping_add(v), 64u8])
515 .cycle()
516 .take(8 * 4 * 3)
517 .collect();
518 let raster = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), false);
519 let serpentine = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), true);
520 assert_eq!(
521 &raster[0..8],
522 &serpentine[0..8],
523 "first row must be identical (both scan left-to-right)"
524 );
525 }
526}