use image::{DynamicImage, ImageBuffer, Rgb, RgbImage};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Copy)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub fn from_hex(hex: &str) -> Result<Self> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Err(DitherError::InvalidHexColor(hex.to_string()));
}
let r = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| DitherError::InvalidHexColor(hex.to_string()))?;
let g = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| DitherError::InvalidHexColor(hex.to_string()))?;
let b = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| DitherError::InvalidHexColor(hex.to_string()))?;
Ok(Self { r, g, b })
}
}
#[derive(Debug, Clone)]
pub struct DitherOptions {
pub foreground: Color,
pub background: Color,
pub width: Option<u32>,
pub height: Option<u32>,
pub contrast: Option<f32>,
}
impl Default for DitherOptions {
fn default() -> Self {
Self {
foreground: Color::new(0, 0, 0),
background: Color::new(255, 255, 255),
width: None,
height: None,
contrast: None,
}
}
}
#[derive(Error, Debug)]
pub enum DitherError {
#[error("Failed to load image: {0}")]
ImageLoadError(#[from] image::ImageError),
#[error("Invalid hex color: {0}")]
InvalidHexColor(String),
#[error("Could not determine image dimensions")]
InvalidDimensions,
#[error("Texture data length {actual} does not match dimensions {width}x{height}")]
InvalidTextureData {
width: usize,
height: usize,
actual: usize,
},
}
pub type Result<T> = std::result::Result<T, DitherError>;
pub struct BlueNoiseTexture {
data: Vec<u8>,
width: usize,
height: usize,
}
impl BlueNoiseTexture {
pub fn from_data(data: Vec<u8>, width: usize, height: usize) -> Result<Self> {
let expected = width
.checked_mul(height)
.ok_or(DitherError::InvalidDimensions)?;
if width == 0 || height == 0 {
return Err(DitherError::InvalidDimensions);
}
if data.len() != expected {
return Err(DitherError::InvalidTextureData {
width,
height,
actual: data.len(),
});
}
Ok(Self {
data,
width,
height,
})
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let img = image::open(path)?;
let gray = img.to_luma8();
let (width, height) = gray.dimensions();
Self::from_data(gray.into_raw(), width as usize, height as usize)
}
#[inline]
fn get(&self, x: u32, y: u32) -> u8 {
let wrap_x = (x as usize) % self.width;
let wrap_y = (y as usize) % self.height;
self.data[wrap_y * self.width + wrap_x]
}
}
fn apply_contrast(img: DynamicImage, contrast: f32) -> DynamicImage {
let mut rgb = img.to_rgb8();
let factor = contrast;
let offset = 128.0 * (1.0 - factor);
for pixel in rgb.pixels_mut() {
for channel in pixel.0.iter_mut() {
let value = *channel as f32;
let adjusted = (value * factor + offset).clamp(0.0, 255.0);
*channel = adjusted as u8;
}
}
DynamicImage::ImageRgb8(rgb)
}
pub fn apply_dithering_to_image(
input: &DynamicImage,
noise_texture: &BlueNoiseTexture,
options: DitherOptions,
) -> RgbImage {
let mut img = input.clone();
if let (Some(width), Some(height)) = (options.width, options.height) {
img = img.resize(width, height, image::imageops::FilterType::Lanczos3);
} else if let Some(width) = options.width {
img = img.resize(width, u32::MAX, image::imageops::FilterType::Lanczos3);
} else if let Some(height) = options.height {
img = img.resize(u32::MAX, height, image::imageops::FilterType::Lanczos3);
}
if let Some(contrast) = options.contrast {
img = apply_contrast(img, contrast);
}
let gray = img.to_luma8();
let (width, height) = gray.dimensions();
let mut output: RgbImage = ImageBuffer::new(width, height);
for y in 0..height {
for x in 0..width {
let pixel_luma = gray.get_pixel(x, y).0[0];
let noise_luma = noise_texture.get(x, y);
let color = if pixel_luma > noise_luma {
options.background
} else {
options.foreground
};
output.put_pixel(x, y, Rgb([color.r, color.g, color.b]));
}
}
output
}
pub fn apply_dithering<P: AsRef<Path>>(
input_path: P,
output_path: P,
noise_texture: &BlueNoiseTexture,
options: DitherOptions,
) -> Result<()> {
let img = image::open(input_path)?;
let output = apply_dithering_to_image(&img, noise_texture, options);
output.save(output_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_from_hex() {
let black = Color::from_hex("#000000").unwrap();
assert_eq!(black.r, 0);
assert_eq!(black.g, 0);
assert_eq!(black.b, 0);
let white = Color::from_hex("#ffffff").unwrap();
assert_eq!(white.r, 255);
assert_eq!(white.g, 255);
assert_eq!(white.b, 255);
let red = Color::from_hex("#ff0000").unwrap();
assert_eq!(red.r, 255);
assert_eq!(red.g, 0);
assert_eq!(red.b, 0);
let blue = Color::from_hex("0000ff").unwrap();
assert_eq!(blue.r, 0);
assert_eq!(blue.g, 0);
assert_eq!(blue.b, 255);
let green = Color::from_hex("#00FF00").unwrap();
assert_eq!(green.r, 0);
assert_eq!(green.g, 255);
assert_eq!(green.b, 0);
}
#[test]
fn test_color_from_hex_invalid() {
assert!(Color::from_hex("#fff").is_err());
assert!(Color::from_hex("#fffffff").is_err());
assert!(Color::from_hex("#gggggg").is_err());
assert!(Color::from_hex("").is_err());
}
#[test]
fn test_color_new() {
let color = Color::new(123, 45, 67);
assert_eq!(color.r, 123);
assert_eq!(color.g, 45);
assert_eq!(color.b, 67);
}
#[test]
fn test_dither_options_default() {
let options = DitherOptions::default();
assert_eq!(options.foreground.r, 0);
assert_eq!(options.foreground.g, 0);
assert_eq!(options.foreground.b, 0);
assert_eq!(options.background.r, 255);
assert_eq!(options.background.g, 255);
assert_eq!(options.background.b, 255);
assert!(options.width.is_none());
assert!(options.height.is_none());
assert!(options.contrast.is_none());
}
#[test]
fn test_blue_noise_texture_from_data() {
let texture = BlueNoiseTexture::from_data(vec![0, 64, 128, 255], 2, 2).unwrap();
assert_eq!(texture.get(0, 0), 0);
assert_eq!(texture.get(1, 0), 64);
assert_eq!(texture.get(2, 0), 0);
assert_eq!(texture.get(0, 2), 0);
}
#[test]
fn test_blue_noise_texture_from_data_rejects_invalid_dimensions() {
assert!(BlueNoiseTexture::from_data(vec![], 0, 1).is_err());
assert!(BlueNoiseTexture::from_data(vec![], usize::MAX, 2).is_err());
}
#[test]
fn test_blue_noise_texture_from_data_rejects_invalid_length() {
assert!(matches!(
BlueNoiseTexture::from_data(vec![0, 1, 2], 2, 2),
Err(DitherError::InvalidTextureData { .. })
));
}
#[test]
fn test_apply_dithering_to_image() {
let input = DynamicImage::ImageRgb8(
RgbImage::from_vec(2, 1, vec![0, 0, 0, 255, 255, 255]).unwrap(),
);
let noise = BlueNoiseTexture::from_data(vec![128], 1, 1).unwrap();
let options = DitherOptions::default();
let output = apply_dithering_to_image(&input, &noise, options);
assert_eq!(output.get_pixel(0, 0).0, [0, 0, 0]);
assert_eq!(output.get_pixel(1, 0).0, [255, 255, 255]);
}
}