add regression test, sample images, reference dithers
This commit is contained in:
parent
eb422b87fb
commit
0d444e9701
|
@ -13,4 +13,4 @@ fn criterion_benchmark(c: &mut Criterion) {
|
|||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
criterion_main!(benches);
|
||||
|
|
BIN
samples/sample_0.tiff
Normal file
BIN
samples/sample_0.tiff
Normal file
Binary file not shown.
BIN
samples/sample_1.tiff
Normal file
BIN
samples/sample_1.tiff
Normal file
Binary file not shown.
BIN
samples/sample_2.tiff
Normal file
BIN
samples/sample_2.tiff
Normal file
Binary file not shown.
|
@ -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<Context> {
|
|||
struct ImageRequest {
|
||||
image: Box<RgbImage>,
|
||||
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),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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<dyn Ditherer> {
|
||||
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<u8>]>::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.
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
pub mod dither;
|
74
src/main.rs
74
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(())
|
||||
|
|
56
tests/dither.rs
Normal file
56
tests/dither.rs
Normal 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(())
|
||||
}
|
BIN
tests/known_samples/sample_0_atkinson.tiff
Normal file
BIN
tests/known_samples/sample_0_atkinson.tiff
Normal file
Binary file not shown.
BIN
tests/known_samples/sample_1_atkinson.tiff
Normal file
BIN
tests/known_samples/sample_1_atkinson.tiff
Normal file
Binary file not shown.
BIN
tests/known_samples/sample_1_floydsteinberg.tiff
Normal file
BIN
tests/known_samples/sample_1_floydsteinberg.tiff
Normal file
Binary file not shown.
Loading…
Reference in a new issue