diff --git a/benches/dithering.rs b/benches/dithering.rs index 81516fb..28481a8 100644 --- a/benches/dithering.rs +++ b/benches/dithering.rs @@ -13,4 +13,4 @@ fn criterion_benchmark(c: &mut Criterion) { } criterion_group!(benches, criterion_benchmark); -criterion_main!(benches); \ No newline at end of file +criterion_main!(benches); diff --git a/samples/sample_0.tiff b/samples/sample_0.tiff new file mode 100644 index 0000000..6c5fe2f Binary files /dev/null and b/samples/sample_0.tiff differ diff --git a/samples/sample_1.tiff b/samples/sample_1.tiff new file mode 100644 index 0000000..08def93 Binary files /dev/null and b/samples/sample_1.tiff differ diff --git a/samples/sample_2.tiff b/samples/sample_2.tiff new file mode 100644 index 0000000..97ca385 Binary files /dev/null and b/samples/sample_2.tiff differ diff --git a/src/api.rs b/src/api.rs index 42ada33..a572edf 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,5 +1,5 @@ use crate::display::EInkPanel; -use crate::dither::{DitherMethod, DitherPalette, DitheredImage}; +use crate::dither::{DitherMethod, Palette, DitheredImage}; use axum::async_trait; use axum::extract::{FromRequest, Multipart, State}; use axum::http::{header, StatusCode}; @@ -96,7 +96,7 @@ pub fn router() -> Router { struct ImageRequest { image: Box, dither_method: DitherMethod, - palette: DitherPalette, + palette: Palette, } #[async_trait] @@ -124,7 +124,7 @@ where Some("palette") => { let data = field.bytes().await?; let val = str::from_utf8(&data)?; - palette = Some(DitherPalette::from_str(val)?); + palette = Some(Palette::from_str(val)?); } Some("dither_method") => { let data = field.bytes().await?; @@ -140,7 +140,7 @@ where Ok(Self { image: i, dither_method: dither_method.unwrap_or(DitherMethod::NearestNeighbor), - palette: palette.unwrap_or(DitherPalette::Default), + palette: palette.unwrap_or(Palette::Default), }) }, ) diff --git a/src/dither.rs b/src/dither.rs index ee6cbbe..d4a17b6 100644 --- a/src/dither.rs +++ b/src/dither.rs @@ -31,12 +31,12 @@ const SIMPLE_PALETTE: [Srgb; 7] = [ ]; #[derive(strum::EnumString, Serialize, Deserialize, PartialEq, Eq, Debug)] -pub enum DitherPalette { +pub enum Palette { Default, Simple, } -impl DitherPalette { +impl Palette { #[must_use] pub const fn value(&self) -> &[Srgb] { match self { @@ -46,7 +46,9 @@ impl DitherPalette { } } -#[derive(strum::EnumString, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[derive( + strum::EnumString, strum::Display, Serialize, Deserialize, PartialEq, Eq, Debug, Clone, +)] pub enum DitherMethod { NearestNeighbor, FloydSteinberg, @@ -59,11 +61,11 @@ impl DitherMethod { #[must_use] pub fn get_ditherer(&self) -> Box { match self { - Self::NearestNeighbor => Box::new(NNDither {}), - Self::Atkinson => Box::new(ErrorDiffusionDither::new(ATKINSON_DITHER_POINTS)), - Self::FloydSteinberg => Box::new(ErrorDiffusionDither::new(FLOYD_STEINBERG_POINTS)), - Self::Stuki => Box::new(ErrorDiffusionDither::new(STUKI_DITHER_POINTS)), - Self::Sierra => Box::new(ErrorDiffusionDither::new(SIERRA_DITHER_POINTS)), + Self::NearestNeighbor => Box::new(NearestNeighbor {}), + Self::Atkinson => Box::new(ErrorDiffusion::new(ATKINSON_DITHER_POINTS)), + Self::FloydSteinberg => Box::new(ErrorDiffusion::new(FLOYD_STEINBERG_POINTS)), + Self::Stuki => Box::new(ErrorDiffusion::new(STUKI_DITHER_POINTS)), + Self::Sierra => Box::new(ErrorDiffusion::new(SIERRA_DITHER_POINTS)), } } } @@ -144,12 +146,10 @@ fn nearest_neighbor(input_color: Lab, palette: &[Srgb]) -> (u8, Lab) { (nearest as u8, color_diff) } -pub struct NNDither(); +pub struct NearestNeighbor(); -impl Ditherer for NNDither { +impl Ditherer for NearestNeighbor { fn dither(&mut self, img: &RgbImage, output: &mut DitheredImage) { - assert!(img.width() == 800); - assert!(img.height() == 480); // sRGB view into the given image. zero copy! let srgb = <&[Srgb]>::from_components(&**img); @@ -246,16 +246,16 @@ static STUKI_DITHER_POINTS: &[DiffusionPoint] = &[ pub type DiffusionMatrix<'a> = &'a [DiffusionPoint]; #[derive(Debug)] -pub struct ErrorDiffusionDither<'a>(&'a [DiffusionPoint]); +pub struct ErrorDiffusion<'a>(&'a [DiffusionPoint]); -impl<'a> ErrorDiffusionDither<'a> { +impl<'a> ErrorDiffusion<'a> { #[must_use] pub const fn new(dm: DiffusionMatrix<'a>) -> Self { Self(dm) } } -impl<'a> Ditherer for ErrorDiffusionDither<'a> { +impl<'a> Ditherer for ErrorDiffusion<'a> { #[instrument] fn dither(&mut self, img: &RgbImage, output: &mut DitheredImage) { // create a copy of the image in Lab space, mutable. @@ -275,7 +275,10 @@ impl<'a> Ditherer for ErrorDiffusionDither<'a> { let curr_pix = temp_img[index]; let (nearest, err) = nearest_neighbor(curr_pix, &output.palette); // set the color in the output buffer. - *output.buf.get_mut(index).unwrap() = nearest; + *output + .buf + .get_mut(index) + .expect("always in bounds of image") = nearest; // take the error, and propagate it. for point in self.0 { // bounds checking. diff --git a/src/lib.rs b/src/lib.rs index e69de29..99c74cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod dither; diff --git a/src/main.rs b/src/main.rs index 8d8df80..e02d2d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,13 @@ pub mod api; pub mod display; -pub mod errors; pub mod dither; +pub mod errors; + +use std::path::PathBuf; use crate::display::{EInkPanel, FakeEInk, Wrapper}; -use crate::dither::{DitherMethod, DitheredImage}; -use clap::{Parser, Subcommand}; +use crate::dither::{DitherMethod, DitheredImage, Palette}; +use clap::{Args, Parser, Subcommand}; use image::RgbImage; use tracing::{error, info}; @@ -19,6 +21,8 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Convert an image into a dithered form. + Convert(ConvertArgs), /// Load a single image Show, /// Display a test pattern @@ -27,38 +31,62 @@ enum Command { Serve, } +#[derive(Args, Debug)] +struct ConvertArgs { + #[arg(short, long, default_value_t = DitherMethod::NearestNeighbor)] + dither_method: DitherMethod, + input_file: PathBuf, + output_file: PathBuf, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); let cli = Cli::parse(); println!("CLI {cli:?}"); - if matches!(cli.command, Command::Show) { - let img: RgbImage = image::ImageReader::open("image.png")?.decode()?.into(); - error!("HI"); - let mut display = FakeEInk {}; + match cli.command { + Command::Convert(a) => { + let input = image::ImageReader::open(a.input_file)? + .decode()? + .into_rgb8(); + let mut method = a.dither_method.get_ditherer(); - let mut eink_buf = DitheredImage::default(); - let mut dither = DitherMethod::Atkinson.get_ditherer(); + let mut result = DitheredImage::new( + input.width(), + input.height(), + Palette::Default.value().to_vec(), + ); + method.dither(&input, &mut result); - dither.dither(&img, &mut eink_buf); - display.display(&eink_buf)?; - } - if matches!(cli.command, Command::Test) { - let mut display = Wrapper::new()?; + result.into_rgbimage().save(a.output_file)?; + } + Command::Show => { + let img: RgbImage = image::ImageReader::open("image.png")?.decode()?.into(); + error!("HI"); + let mut display = FakeEInk {}; - display.test()?; - } + let mut eink_buf = DitheredImage::default(); + let mut dither = DitherMethod::Atkinson.get_ditherer(); - if matches!(cli.command, Command::Serve) { - let display = FakeEInk {}; + dither.dither(&img, &mut eink_buf); + display.display(&eink_buf)?; + } + Command::Test => { + let mut display = Wrapper::new()?; - let ctx = api::Context::new(Box::new(display)); + display.test()?; + } + Command::Serve => { + let display = FakeEInk {}; - let app = api::router().with_state(ctx); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; - info!("Listening on 0.0.0.0:3000"); - axum::serve(listener, app).await?; + let ctx = api::Context::new(Box::new(display)); + + let app = api::router().with_state(ctx); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; + info!("Listening on 0.0.0.0:3000"); + axum::serve(listener, app).await?; + } } Ok(()) diff --git a/tests/dither.rs b/tests/dither.rs new file mode 100644 index 0000000..e31ebda --- /dev/null +++ b/tests/dither.rs @@ -0,0 +1,56 @@ +// Regression tests for dithering methods. This file ensures that changes made to how the +// algorithms are implemented does not affect the actual algorithm. This is done with +// 'known-good' sample images (see `samples/`). If changes to the algorithms are made, +// these tests will fail, and that is to be expected. In that case the reference images +// will need to be updated as well. + +use anyhow::Result; +use image::{ImageReader, RgbImage}; +use pi_frame_server::dither::{DitherMethod, DitheredImage, Palette}; + +fn compare_original(sample_file: &str, reference_file: &str, method: &DitherMethod) -> Result<()> { + let image: RgbImage = ImageReader::open(format!("samples/{sample_file}"))? + .decode()? + .into_rgb8(); + + // process + let mut method = method.get_ditherer(); + + let mut result = DitheredImage::new( + image.width(), + image.height(), + Palette::Default.value().to_vec(), + ); + + method.dither(&image, &mut result); + + let expected = ImageReader::open(format!("tests/known_samples/{reference_file}"))? + .decode()? + .into_rgb8() + .into_raw(); + + let output = result.into_rgbimage().into_raw(); + + assert_eq!(output, expected); + + Ok(()) +} +#[test] +fn test_sample_images() -> Result<()> { + compare_original( + "sample_0.tiff", + "sample_0_atkinson.tiff", + &DitherMethod::Atkinson, + )?; + compare_original( + "sample_1.tiff", + "sample_1_atkinson.tiff", + &DitherMethod::Atkinson, + )?; + compare_original( + "sample_1.tiff", + "sample_1_floydsteinberg.tiff", + &DitherMethod::FloydSteinberg, + )?; + Ok(()) +} diff --git a/tests/known_samples/sample_0_atkinson.tiff b/tests/known_samples/sample_0_atkinson.tiff new file mode 100644 index 0000000..2235d5f Binary files /dev/null and b/tests/known_samples/sample_0_atkinson.tiff differ diff --git a/tests/known_samples/sample_1_atkinson.tiff b/tests/known_samples/sample_1_atkinson.tiff new file mode 100644 index 0000000..ae94570 Binary files /dev/null and b/tests/known_samples/sample_1_atkinson.tiff differ diff --git a/tests/known_samples/sample_1_floydsteinberg.tiff b/tests/known_samples/sample_1_floydsteinberg.tiff new file mode 100644 index 0000000..483feb2 Binary files /dev/null and b/tests/known_samples/sample_1_floydsteinberg.tiff differ