Skip to main content

fastpack_core/imaging/
dither.rs

1use image::{DynamicImage, Rgba, RgbaImage};
2
3use crate::types::pixel_format::PixelFormat;
4
5/// Apply Floyd-Steinberg dithering to quantize an RGBA8888 image to the
6/// target pixel format. The output is always RGBA8888; the quantization
7/// reduces the number of unique colour values to match the target bit depth.
8///
9/// `PixelFormat::Rgba8888` is a no-op; the input is returned unchanged.
10pub fn dither(image: &DynamicImage, format: PixelFormat) -> DynamicImage {
11    match format {
12        PixelFormat::Rgba8888 => image.clone(),
13        PixelFormat::Rgb888 => dither_rgb888(image),
14        PixelFormat::Rgb565 => dither_rgb565(image),
15        PixelFormat::Rgba4444 => dither_rgba4444(image),
16        PixelFormat::Rgba5551 => dither_rgba5551(image),
17        PixelFormat::Alpha8 => dither_alpha8(image),
18    }
19}
20
21fn dither_rgb565(image: &DynamicImage) -> DynamicImage {
22    let src = image.to_rgba8();
23    let (w, h) = src.dimensions();
24    let mut err = vec![[0i32; 4]; (w * h) as usize];
25
26    let mut dst = RgbaImage::new(w, h);
27    for y in 0..h {
28        for x in 0..w {
29            let p = src.get_pixel(x, y);
30            let idx = (y * w + x) as usize;
31
32            let r_in = (p[0] as i32 + err[idx][0]).clamp(0, 255) as u8;
33            let g_in = (p[1] as i32 + err[idx][1]).clamp(0, 255) as u8;
34            let b_in = (p[2] as i32 + err[idx][2]).clamp(0, 255) as u8;
35
36            let r_q = quantize5(r_in);
37            let g_q = quantize6(g_in);
38            let b_q = quantize5(b_in);
39
40            dst.put_pixel(x, y, Rgba([r_q, g_q, b_q, 255]));
41
42            diffuse(&mut err, w, h, x, y, r_in as i32 - r_q as i32, 0);
43            diffuse(&mut err, w, h, x, y, g_in as i32 - g_q as i32, 1);
44            diffuse(&mut err, w, h, x, y, b_in as i32 - b_q as i32, 2);
45        }
46    }
47    DynamicImage::ImageRgba8(dst)
48}
49
50fn dither_rgb888(image: &DynamicImage) -> DynamicImage {
51    // RGB888 is the same bit depth as RGBA8888 channels; just drop alpha.
52    let src = image.to_rgba8();
53    let (w, h) = src.dimensions();
54    let mut dst = RgbaImage::new(w, h);
55    for y in 0..h {
56        for x in 0..w {
57            let p = src.get_pixel(x, y);
58            dst.put_pixel(x, y, Rgba([p[0], p[1], p[2], 255]));
59        }
60    }
61    DynamicImage::ImageRgba8(dst)
62}
63
64fn dither_rgba4444(image: &DynamicImage) -> DynamicImage {
65    let src = image.to_rgba8();
66    let (w, h) = src.dimensions();
67    let mut err = vec![[0i32; 4]; (w * h) as usize];
68
69    let mut dst = RgbaImage::new(w, h);
70    for y in 0..h {
71        for x in 0..w {
72            let p = src.get_pixel(x, y);
73            let idx = (y * w + x) as usize;
74
75            let r_in = (p[0] as i32 + err[idx][0]).clamp(0, 255) as u8;
76            let g_in = (p[1] as i32 + err[idx][1]).clamp(0, 255) as u8;
77            let b_in = (p[2] as i32 + err[idx][2]).clamp(0, 255) as u8;
78            let a_in = (p[3] as i32 + err[idx][3]).clamp(0, 255) as u8;
79
80            let r_q = quantize4(r_in);
81            let g_q = quantize4(g_in);
82            let b_q = quantize4(b_in);
83            let a_q = quantize4(a_in);
84
85            dst.put_pixel(x, y, Rgba([r_q, g_q, b_q, a_q]));
86
87            diffuse(&mut err, w, h, x, y, r_in as i32 - r_q as i32, 0);
88            diffuse(&mut err, w, h, x, y, g_in as i32 - g_q as i32, 1);
89            diffuse(&mut err, w, h, x, y, b_in as i32 - b_q as i32, 2);
90            diffuse(&mut err, w, h, x, y, a_in as i32 - a_q as i32, 3);
91        }
92    }
93    DynamicImage::ImageRgba8(dst)
94}
95
96fn dither_rgba5551(image: &DynamicImage) -> DynamicImage {
97    let src = image.to_rgba8();
98    let (w, h) = src.dimensions();
99    let mut err = vec![[0i32; 4]; (w * h) as usize];
100
101    let mut dst = RgbaImage::new(w, h);
102    for y in 0..h {
103        for x in 0..w {
104            let p = src.get_pixel(x, y);
105            let idx = (y * w + x) as usize;
106
107            let r_in = (p[0] as i32 + err[idx][0]).clamp(0, 255) as u8;
108            let g_in = (p[1] as i32 + err[idx][1]).clamp(0, 255) as u8;
109            let b_in = (p[2] as i32 + err[idx][2]).clamp(0, 255) as u8;
110            let a_in = (p[3] as i32 + err[idx][3]).clamp(0, 255) as u8;
111
112            let r_q = quantize5(r_in);
113            let g_q = quantize5(g_in);
114            let b_q = quantize5(b_in);
115            let a_q = if a_in >= 128 { 255u8 } else { 0u8 };
116
117            dst.put_pixel(x, y, Rgba([r_q, g_q, b_q, a_q]));
118
119            diffuse(&mut err, w, h, x, y, r_in as i32 - r_q as i32, 0);
120            diffuse(&mut err, w, h, x, y, g_in as i32 - g_q as i32, 1);
121            diffuse(&mut err, w, h, x, y, b_in as i32 - b_q as i32, 2);
122            diffuse(&mut err, w, h, x, y, a_in as i32 - a_q as i32, 3);
123        }
124    }
125    DynamicImage::ImageRgba8(dst)
126}
127
128fn dither_alpha8(image: &DynamicImage) -> DynamicImage {
129    // Alpha-only: keep alpha, set RGB to zero.
130    let src = image.to_rgba8();
131    let (w, h) = src.dimensions();
132    let mut dst = RgbaImage::new(w, h);
133    for y in 0..h {
134        for x in 0..w {
135            let p = src.get_pixel(x, y);
136            dst.put_pixel(x, y, Rgba([0, 0, 0, p[3]]));
137        }
138    }
139    DynamicImage::ImageRgba8(dst)
140}
141
142/// Floyd-Steinberg error diffusion. Spreads `error` in channel `ch` from
143/// pixel (x, y) to its four neighbours using the standard 7/16, 3/16,
144/// 5/16, 1/16 weights.
145fn diffuse(err: &mut [[i32; 4]], w: u32, h: u32, x: u32, y: u32, error: i32, ch: usize) {
146    if error == 0 {
147        return;
148    }
149    let (x, y, w, h) = (x as usize, y as usize, w as usize, h as usize);
150
151    if x + 1 < w {
152        err[y * w + (x + 1)][ch] += error * 7 / 16;
153    }
154    if y + 1 < h {
155        if x > 0 {
156            err[(y + 1) * w + (x - 1)][ch] += error * 3 / 16;
157        }
158        err[(y + 1) * w + x][ch] += error * 5 / 16;
159        if x + 1 < w {
160            err[(y + 1) * w + (x + 1)][ch] += error / 16;
161        }
162    }
163}
164
165/// Quantize an 8-bit value to 5-bit precision, then expand back to 8 bits.
166/// Expansion uses the standard replication formula: (v5 << 3) | (v5 >> 2).
167fn quantize5(v: u8) -> u8 {
168    let v5 = v >> 3;
169    (v5 << 3) | (v5 >> 2)
170}
171
172/// Quantize an 8-bit value to 6-bit precision, then expand back to 8 bits.
173fn quantize6(v: u8) -> u8 {
174    let v6 = v >> 2;
175    (v6 << 2) | (v6 >> 4)
176}
177
178/// Quantize an 8-bit value to 4-bit precision, then expand back to 8 bits.
179/// 4-bit value expands as (v4 << 4) | v4 = v4 * 17.
180fn quantize4(v: u8) -> u8 {
181    let v4 = v >> 4;
182    (v4 << 4) | v4
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn rgba8888_is_noop() {
191        let img = DynamicImage::new_rgba8(4, 4);
192        let out = dither(&img, PixelFormat::Rgba8888);
193        assert_eq!(out.width(), 4);
194        assert_eq!(out.height(), 4);
195    }
196
197    #[test]
198    fn rgb565_alpha_forced_to_255() {
199        let mut src = RgbaImage::new(1, 1);
200        src.put_pixel(0, 0, Rgba([255, 128, 64, 200]));
201        let out = dither(&DynamicImage::ImageRgba8(src), PixelFormat::Rgb565);
202        let p = out.to_rgba8().get_pixel(0, 0).0;
203        assert_eq!(p[3], 255, "rgb565 forces alpha to 255");
204    }
205
206    #[test]
207    fn rgba5551_threshold_opaque() {
208        let mut src = RgbaImage::new(1, 1);
209        src.put_pixel(0, 0, Rgba([255, 255, 255, 200]));
210        let out = dither(&DynamicImage::ImageRgba8(src), PixelFormat::Rgba5551);
211        let p = out.to_rgba8().get_pixel(0, 0).0;
212        assert_eq!(p[3], 255, "alpha >= 128 rounds to 255");
213    }
214
215    #[test]
216    fn rgba5551_threshold_transparent() {
217        let mut src = RgbaImage::new(1, 1);
218        src.put_pixel(0, 0, Rgba([255, 255, 255, 50]));
219        let out = dither(&DynamicImage::ImageRgba8(src), PixelFormat::Rgba5551);
220        let p = out.to_rgba8().get_pixel(0, 0).0;
221        assert_eq!(p[3], 0, "alpha < 128 rounds to 0");
222    }
223
224    #[test]
225    fn quantize5_round_trips() {
226        // Pure white should survive round-trip.
227        assert_eq!(quantize5(255), 255);
228        // Pure black should survive.
229        assert_eq!(quantize5(0), 0);
230    }
231
232    #[test]
233    fn quantize4_round_trips() {
234        assert_eq!(quantize4(255), 255);
235        assert_eq!(quantize4(0), 0);
236    }
237
238    #[test]
239    fn rgba4444_preserves_dimensions() {
240        let img = DynamicImage::new_rgba8(8, 8);
241        let out = dither(&img, PixelFormat::Rgba4444);
242        assert_eq!((out.width(), out.height()), (8, 8));
243    }
244}