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] = [
315 [-0.46875, 0.03125, -0.34375, 0.15625],
316 [ 0.28125, -0.21875, 0.40625, -0.09375],
317 [-0.28125, 0.21875, -0.40625, 0.09375],
318 [ 0.46875, -0.03125, 0.34375, -0.15625],
319];
320
321fn ordered_spread(palette: &Palette) -> f64 {
332 let is_grayscale =
333 palette.colors.len() >= 3 && palette.colors.iter().all(|&[r, g, b]| r == g && g == b);
334 if is_grayscale {
335 1.0 / (palette.colors.len() - 1) as f64
336 } else {
337 1.0
338 }
339}
340
341pub fn ordered_dither(pixels: &[u8], width: usize, palette: &Palette) -> Vec<u8> {
352 ordered_dither_impl(pixels, width, palette, None)
353}
354
355pub fn ordered_dither_with_canonical(
356 pixels: &[u8],
357 width: usize,
358 palette: &Palette,
359 canonical_palette: &Palette,
360) -> Vec<u8> {
361 ordered_dither_impl(pixels, width, palette, Some(canonical_palette))
362}
363
364fn ordered_dither_impl(
365 pixels: &[u8],
366 width: usize,
367 palette: &Palette,
368 canonical_palette: Option<&Palette>,
369) -> Vec<u8> {
370 let (_palette_linear, palette_lab) = build_palette_lab(palette);
371 let spread = ordered_spread(palette);
372
373 let lut: Vec<[f64; 256]> = (0..16)
377 .map(|t| {
378 let threshold = BAYER_4X4[t / 4][t % 4] * spread;
379 let mut row = [0.0_f64; 256];
380 for (v, slot) in row.iter_mut().enumerate() {
381 *slot = srgb_fraction_to_linear((v as f64 / 255.0 + threshold).clamp(0.0, 1.0));
382 }
383 row
384 })
385 .collect();
386
387 pixels
388 .par_chunks(3)
389 .enumerate()
390 .map(|(i, rgb)| {
391 if let Some(canonical_palette) = canonical_palette
392 && let Some(idx) = exact_palette_index(rgb, canonical_palette)
393 {
394 return idx;
395 }
396
397 let x = i % width;
398 let y = i / width;
399 let t = (y % 4) * 4 + (x % 4);
400
401 let lab = rgb_to_oklab(
402 lut[t][rgb[0] as usize],
403 lut[t][rgb[1] as usize],
404 lut[t][rgb[2] as usize],
405 );
406 match_pixel_oklab(lab, &palette_lab, WAB) as u8
407 })
408 .collect()
409}
410
411#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::palettes::ColorScheme;
417
418 fn solid_image(r: u8, g: u8, b: u8, w: usize, h: usize) -> Vec<u8> {
419 vec![r, g, b].into_iter().cycle().take(w * h * 3).collect()
420 }
421
422 #[test]
423 fn solid_white_maps_to_white() {
424 let pixels = solid_image(255, 255, 255, 4, 4);
425 let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
426 assert!(out.iter().all(|&v| v == 1));
428 }
429
430 #[test]
431 fn solid_black_maps_to_black() {
432 let pixels = solid_image(0, 0, 0, 4, 4);
433 let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
434 assert!(out.iter().all(|&v| v == 0));
435 }
436
437 #[test]
438 fn output_length_matches_pixels() {
439 let pixels = solid_image(128, 64, 200, 10, 7);
440 let out = floyd_steinberg(&pixels, 10, 7, ColorScheme::Bwr.palette(), true);
441 assert_eq!(out.len(), 10 * 7);
442 }
443
444 #[test]
445 fn all_algorithms_produce_correct_length() {
446 let pixels = solid_image(128, 64, 200, 10, 7);
447 let pal = ColorScheme::Bwr.palette();
448 assert_eq!(burkes(&pixels, 10, 7, pal, false).len(), 70);
449 assert_eq!(atkinson(&pixels, 10, 7, pal, false).len(), 70);
450 assert_eq!(stucki(&pixels, 10, 7, pal, false).len(), 70);
451 assert_eq!(sierra(&pixels, 10, 7, pal, false).len(), 70);
452 assert_eq!(sierra_lite(&pixels, 10, 7, pal, false).len(), 70);
453 assert_eq!(jarvis_judice_ninke(&pixels, 10, 7, pal, false).len(), 70);
454 assert_eq!(ordered_dither(&pixels, 10, pal).len(), 70);
455 assert_eq!(direct_map(&pixels, pal, pal).len(), 70);
456 }
457
458 #[test]
459 fn direct_map_pure_red_maps_to_red_in_bwr() {
460 let pixels = vec![255u8, 0, 0];
462 let out = direct_map(&pixels, ColorScheme::Bwr.palette(), ColorScheme::Bwr.palette());
463 assert_eq!(out[0], 2, "pure sRGB red should map to red ink (index 2) in BWR");
464 }
465
466 #[test]
467 fn direct_map_pure_blue_maps_to_blue_in_bwgbry() {
468 let pixels = vec![0u8, 0, 255];
470 let out = direct_map(&pixels, ColorScheme::Bwgbry.palette(), ColorScheme::Bwgbry.palette());
471 assert_eq!(out[0], 4, "pure sRGB blue should map to blue ink (index 4) in BWGBRY");
472 }
473
474 #[test]
475 fn serpentine_differs_from_raster_on_gradient() {
476 let pixels: Vec<u8> = (0u8..=255)
479 .flat_map(|v| [v, v / 2, 255 - v])
480 .cycle()
481 .take(16 * 16 * 3)
482 .collect();
483 let raster = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), false);
484 let serpentine = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), true);
485 assert_ne!(raster, serpentine, "serpentine and raster should differ on a gradient");
486 }
487
488 #[test]
506 fn ordered_dither_activity_is_perceptually_uniform() {
507 const W: usize = 256;
508 const H: usize = 16; const BANDS: usize = 8;
510 const BAND_W: usize = W / BANDS;
511
512 let mut pixels = Vec::with_capacity(W * H * 3);
514 for _ in 0..H {
515 for x in 0..W {
516 let v = x as u8;
517 pixels.extend_from_slice(&[v, v, v]);
518 }
519 }
520 let out = ordered_dither(&pixels, W, ColorScheme::Mono.palette());
521
522 let mut transitions = [0usize; BANDS];
524 for y in 0..H {
525 for x in 0..W - 1 {
526 if out[y * W + x] != out[y * W + x + 1] {
527 transitions[(x / BAND_W).min(BANDS - 1)] += 1;
528 }
529 }
530 }
531
532 let mid = &transitions[1..BANDS - 2];
536 let max = *mid.iter().max().unwrap();
537 let min = *mid.iter().min().unwrap();
538 assert!(min > 0, "every mid-tone band should have at least one transition: {transitions:?}");
539 let ratio = max as f64 / min as f64;
540 assert!(
541 ratio < 5.0,
542 "ordered-dither activity is perceptually skewed (max/min = {ratio:.2}); \
543 expected sRGB-space ordered dither to spread roughly uniformly across \
544 mid-tones. Per-band transitions: {transitions:?}"
545 );
546 }
547
548 #[test]
549 fn bayer_matrix_is_zero_mean() {
550 let sum: f64 = BAYER_4X4.iter().flatten().sum();
553 assert!(sum.abs() < 1e-12, "Bayer thresholds must sum to zero, got {sum}");
554 }
555
556 #[test]
557 fn ordered_dither_is_brightness_neutral_at_midpoint() {
558 let pal = ColorScheme::Mono.palette();
561 let (_, pal_lab) = build_palette_lab(pal);
562 let midpoint = (0u16..=255)
563 .find(|&v| {
564 let lin = srgb_channel_to_linear(v as u8);
565 let lab = rgb_to_oklab(lin, lin, lin);
566 match_pixel_oklab(lab, &pal_lab, WAB) == 1
567 })
568 .expect("mono palette must have a black→white transition") as u8;
569
570 const N: usize = 32;
573 let pixels = solid_image(midpoint, midpoint, midpoint, N, N);
574 let out = ordered_dither(&pixels, N, pal);
575 let white = out.iter().filter(|&&v| v == 1).count() as f64 / out.len() as f64;
576 assert!(
577 (white - 0.5).abs() <= 1.0 / 16.0,
578 "ordered dither at the mono midpoint should be ~50% white, got {white}"
579 );
580 }
581
582 #[test]
583 fn ordered_dither_grayscale16_tracks_ramp_and_uses_many_levels() {
584 const W: usize = 256;
588 const H: usize = 4;
589 let pal = ColorScheme::Grayscale16.palette();
590
591 let mut pixels = Vec::with_capacity(W * H * 3);
592 for _ in 0..H {
593 for x in 0..W {
594 let v = x as u8;
595 pixels.extend_from_slice(&[v, v, v]);
596 }
597 }
598 let out = ordered_dither(&pixels, W, pal);
599
600 let levels: Vec<f64> = pal.colors.iter().map(|&[r, _, _]| r as f64).collect();
602 let mut max_err = 0.0_f64;
603 for bx in 0..W / 4 {
604 let mut sum = 0.0;
605 for y in 0..H {
606 for dx in 0..4 {
607 let x = bx * 4 + dx;
608 sum += levels[out[y * W + x] as usize];
609 }
610 }
611 let avg = sum / (H * 4) as f64;
612 let input = (bx * 4 + 1) as f64; max_err = max_err.max((avg - input).abs());
614 }
615 assert!(max_err < 24.0, "block-averaged output should track the ramp, max_err={max_err}");
616
617 let mut seen = [false; 16];
619 for &idx in &out {
620 seen[idx as usize] = true;
621 }
622 let used = seen.iter().filter(|&&s| s).count();
623 assert!(used >= 12, "GS16 ordered dither should use ≥12 levels, used {used}");
624 }
625
626 #[test]
627 fn serpentine_first_row_matches_raster() {
628 let pixels: Vec<u8> = (0u8..=255)
630 .flat_map(|v| [v, 128u8.wrapping_add(v), 64u8])
631 .cycle()
632 .take(8 * 4 * 3)
633 .collect();
634 let raster = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), false);
635 let serpentine = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), true);
636 assert_eq!(
637 &raster[0..8],
638 &serpentine[0..8],
639 "first row must be identical (both scan left-to-right)"
640 );
641 }
642}