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("Blue noise texture is empty")]
EmptyNoiseTexture,
}
pub type Result<T> = std::result::Result<T, DitherError>;
pub struct BlueNoiseTexture {
data: Vec<u8>,
width: usize,
height: usize,
}
impl BlueNoiseTexture {
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();
if width == 0 || height == 0 {
return Err(DitherError::InvalidDimensions);
}
Ok(Self {
data: gray.into_raw(),
width: width as usize,
height: height as usize,
})
}
pub fn from_data(data: Vec<u8>, width: usize, height: usize) -> Result<Self> {
if data.is_empty() {
return Err(DitherError::EmptyNoiseTexture);
}
Ok(Self {
data,
width,
height,
})
}
#[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<P: AsRef<Path>>(
input_path: P,
output_path: P,
noise_texture: &BlueNoiseTexture,
options: DitherOptions,
) -> Result<()> {
let mut img = image::open(input_path)?;
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.save(output_path)?;
Ok(())
}
pub fn ordered_dither(
value: u8,
x: u32,
y: u32,
noise_texture: &BlueNoiseTexture,
levels: usize,
) -> u8 {
let threshold = noise_texture.get(x, y);
let normalized = value as f32 / 255.0;
let step = 1.0 / levels as f32;
let quantized = (normalized / step).floor() as usize;
let fraction = (normalized % step) / step;
let output = if fraction > threshold as f32 / 255.0 {
(quantized + 1).min(levels - 1)
} else {
quantized
};
((output * 255) / (levels - 1)) as u8
}
#[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 data = vec![0u8, 128, 255, 64];
let texture = BlueNoiseTexture::from_data(data.clone(), 2, 2).unwrap();
assert_eq!(texture.width, 2);
assert_eq!(texture.height, 2);
assert_eq!(texture.data, data);
}
#[test]
fn test_blue_noise_texture_from_data_empty() {
let empty = vec![];
let result = BlueNoiseTexture::from_data(empty, 0, 0);
assert!(result.is_err());
}
#[test]
fn test_blue_noise_texture_get_wrapping() {
let data = vec![10, 20, 30, 40]; let texture = BlueNoiseTexture::from_data(data, 2, 2).unwrap();
assert_eq!(texture.get(0, 0), 10);
assert_eq!(texture.get(1, 0), 20);
assert_eq!(texture.get(0, 1), 30);
assert_eq!(texture.get(1, 1), 40);
assert_eq!(texture.get(2, 0), 10); assert_eq!(texture.get(3, 1), 40); assert_eq!(texture.get(0, 2), 10); assert_eq!(texture.get(5, 7), 40); }
#[test]
fn test_ordered_dither_binary() {
let data = vec![128u8; 64]; let texture = BlueNoiseTexture::from_data(data, 8, 8).unwrap();
let result_low = ordered_dither(64, 0, 0, &texture, 2);
let result_high = ordered_dither(192, 0, 0, &texture, 2);
assert_eq!(result_low, 0);
assert_eq!(result_high, 255);
}
#[test]
fn test_ordered_dither_levels() {
let data = vec![0u8; 16]; let texture = BlueNoiseTexture::from_data(data, 4, 4).unwrap();
let levels = 4;
for value in [0, 85, 170, 255] {
let result = ordered_dither(value, 0, 0, &texture, levels);
assert!(result <= 255);
}
let result1 = ordered_dither(0, 0, 0, &texture, levels);
let result2 = ordered_dither(255, 0, 0, &texture, levels);
assert_ne!(result1, result2);
}
#[test]
fn test_ordered_dither_range() {
let data = vec![100u8; 16];
let texture = BlueNoiseTexture::from_data(data, 4, 4).unwrap();
for value in 0..=255 {
let result = ordered_dither(value, 0, 0, &texture, 2);
assert!(result <= 255);
}
}
}