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_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::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),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod dither;
|
74
src/main.rs
74
src/main.rs
|
@ -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
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