add regression test, sample images, reference dithers

This commit is contained in:
saji 2024-07-30 21:46:15 -05:00
parent eb422b87fb
commit 0d444e9701
12 changed files with 132 additions and 44 deletions

View file

@ -13,4 +13,4 @@ fn criterion_benchmark(c: &mut Criterion) {
} }
criterion_group!(benches, criterion_benchmark); criterion_group!(benches, criterion_benchmark);
criterion_main!(benches); criterion_main!(benches);

BIN
samples/sample_0.tiff Normal file

Binary file not shown.

BIN
samples/sample_1.tiff Normal file

Binary file not shown.

BIN
samples/sample_2.tiff Normal file

Binary file not shown.

View file

@ -1,5 +1,5 @@
use crate::display::EInkPanel; use crate::display::EInkPanel;
use crate::dither::{DitherMethod, DitherPalette, DitheredImage}; use crate::dither::{DitherMethod, Palette, DitheredImage};
use axum::async_trait; use axum::async_trait;
use axum::extract::{FromRequest, Multipart, State}; use axum::extract::{FromRequest, Multipart, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
@ -96,7 +96,7 @@ pub fn router() -> Router<Context> {
struct ImageRequest { struct ImageRequest {
image: Box<RgbImage>, image: Box<RgbImage>,
dither_method: DitherMethod, dither_method: DitherMethod,
palette: DitherPalette, palette: Palette,
} }
#[async_trait] #[async_trait]
@ -124,7 +124,7 @@ where
Some("palette") => { Some("palette") => {
let data = field.bytes().await?; let data = field.bytes().await?;
let val = str::from_utf8(&data)?; let val = str::from_utf8(&data)?;
palette = Some(DitherPalette::from_str(val)?); palette = Some(Palette::from_str(val)?);
} }
Some("dither_method") => { Some("dither_method") => {
let data = field.bytes().await?; let data = field.bytes().await?;
@ -140,7 +140,7 @@ where
Ok(Self { Ok(Self {
image: i, image: i,
dither_method: dither_method.unwrap_or(DitherMethod::NearestNeighbor), dither_method: dither_method.unwrap_or(DitherMethod::NearestNeighbor),
palette: palette.unwrap_or(DitherPalette::Default), palette: palette.unwrap_or(Palette::Default),
}) })
}, },
) )

View file

@ -31,12 +31,12 @@ const SIMPLE_PALETTE: [Srgb; 7] = [
]; ];
#[derive(strum::EnumString, Serialize, Deserialize, PartialEq, Eq, Debug)] #[derive(strum::EnumString, Serialize, Deserialize, PartialEq, Eq, Debug)]
pub enum DitherPalette { pub enum Palette {
Default, Default,
Simple, Simple,
} }
impl DitherPalette { impl Palette {
#[must_use] #[must_use]
pub const fn value(&self) -> &[Srgb] { pub const fn value(&self) -> &[Srgb] {
match self { 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 { pub enum DitherMethod {
NearestNeighbor, NearestNeighbor,
FloydSteinberg, FloydSteinberg,
@ -59,11 +61,11 @@ impl DitherMethod {
#[must_use] #[must_use]
pub fn get_ditherer(&self) -> Box<dyn Ditherer> { pub fn get_ditherer(&self) -> Box<dyn Ditherer> {
match self { match self {
Self::NearestNeighbor => Box::new(NNDither {}), Self::NearestNeighbor => Box::new(NearestNeighbor {}),
Self::Atkinson => Box::new(ErrorDiffusionDither::new(ATKINSON_DITHER_POINTS)), Self::Atkinson => Box::new(ErrorDiffusion::new(ATKINSON_DITHER_POINTS)),
Self::FloydSteinberg => Box::new(ErrorDiffusionDither::new(FLOYD_STEINBERG_POINTS)), Self::FloydSteinberg => Box::new(ErrorDiffusion::new(FLOYD_STEINBERG_POINTS)),
Self::Stuki => Box::new(ErrorDiffusionDither::new(STUKI_DITHER_POINTS)), Self::Stuki => Box::new(ErrorDiffusion::new(STUKI_DITHER_POINTS)),
Self::Sierra => Box::new(ErrorDiffusionDither::new(SIERRA_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) (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) { 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! // sRGB view into the given image. zero copy!
let srgb = <&[Srgb<u8>]>::from_components(&**img); let srgb = <&[Srgb<u8>]>::from_components(&**img);
@ -246,16 +246,16 @@ static STUKI_DITHER_POINTS: &[DiffusionPoint] = &[
pub type DiffusionMatrix<'a> = &'a [DiffusionPoint]; pub type DiffusionMatrix<'a> = &'a [DiffusionPoint];
#[derive(Debug)] #[derive(Debug)]
pub struct ErrorDiffusionDither<'a>(&'a [DiffusionPoint]); pub struct ErrorDiffusion<'a>(&'a [DiffusionPoint]);
impl<'a> ErrorDiffusionDither<'a> { impl<'a> ErrorDiffusion<'a> {
#[must_use] #[must_use]
pub const fn new(dm: DiffusionMatrix<'a>) -> Self { pub const fn new(dm: DiffusionMatrix<'a>) -> Self {
Self(dm) Self(dm)
} }
} }
impl<'a> Ditherer for ErrorDiffusionDither<'a> { impl<'a> Ditherer for ErrorDiffusion<'a> {
#[instrument] #[instrument]
fn dither(&mut self, img: &RgbImage, output: &mut DitheredImage) { fn dither(&mut self, img: &RgbImage, output: &mut DitheredImage) {
// create a copy of the image in Lab space, mutable. // 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 curr_pix = temp_img[index];
let (nearest, err) = nearest_neighbor(curr_pix, &output.palette); let (nearest, err) = nearest_neighbor(curr_pix, &output.palette);
// set the color in the output buffer. // 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. // take the error, and propagate it.
for point in self.0 { for point in self.0 {
// bounds checking. // bounds checking.

View file

@ -0,0 +1 @@
pub mod dither;

View file

@ -1,11 +1,13 @@
pub mod api; pub mod api;
pub mod display; pub mod display;
pub mod errors;
pub mod dither; pub mod dither;
pub mod errors;
use std::path::PathBuf;
use crate::display::{EInkPanel, FakeEInk, Wrapper}; use crate::display::{EInkPanel, FakeEInk, Wrapper};
use crate::dither::{DitherMethod, DitheredImage}; use crate::dither::{DitherMethod, DitheredImage, Palette};
use clap::{Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use image::RgbImage; use image::RgbImage;
use tracing::{error, info}; use tracing::{error, info};
@ -19,6 +21,8 @@ struct Cli {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum Command { enum Command {
/// Convert an image into a dithered form.
Convert(ConvertArgs),
/// Load a single image /// Load a single image
Show, Show,
/// Display a test pattern /// Display a test pattern
@ -27,38 +31,62 @@ enum Command {
Serve, 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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
let cli = Cli::parse(); let cli = Cli::parse();
println!("CLI {cli:?}"); println!("CLI {cli:?}");
if matches!(cli.command, Command::Show) { match cli.command {
let img: RgbImage = image::ImageReader::open("image.png")?.decode()?.into(); Command::Convert(a) => {
error!("HI"); let input = image::ImageReader::open(a.input_file)?
let mut display = FakeEInk {}; .decode()?
.into_rgb8();
let mut method = a.dither_method.get_ditherer();
let mut eink_buf = DitheredImage::default(); let mut result = DitheredImage::new(
let mut dither = DitherMethod::Atkinson.get_ditherer(); input.width(),
input.height(),
Palette::Default.value().to_vec(),
);
method.dither(&input, &mut result);
dither.dither(&img, &mut eink_buf); result.into_rgbimage().save(a.output_file)?;
display.display(&eink_buf)?; }
} Command::Show => {
if matches!(cli.command, Command::Test) { let img: RgbImage = image::ImageReader::open("image.png")?.decode()?.into();
let mut display = Wrapper::new()?; 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) { dither.dither(&img, &mut eink_buf);
let display = FakeEInk {}; 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 ctx = api::Context::new(Box::new(display));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
info!("Listening on 0.0.0.0:3000"); let app = api::router().with_state(ctx);
axum::serve(listener, app).await?; 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(()) Ok(())

56
tests/dither.rs Normal file
View file

@ -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(())
}

Binary file not shown.

Binary file not shown.

Binary file not shown.