semdiff-differ-image 0.4.2

Image diff calculator and reporters for semdiff.
Documentation
use color::{AlphaColor, Oklab, Srgb};
use image::{ImageError, ImageFormat, Rgba, RgbaImage};
use mime::Mime;
use semdiff_core::fs::FileLeaf;
use semdiff_core::{Diff, DiffCalculator, MayUnsupported};
use thiserror::Error;

pub mod report_html;
pub mod report_json;
pub mod report_summary;

#[cfg(test)]
mod tests;

pub struct ImageDiffReporter;

#[derive(Debug)]
pub struct ImageDiff {
    equal: bool,
    expected: ImageData,
    actual: ImageData,
    diff_stat: ImageDiffStat,
    diff_image: RgbaImage,
}

#[derive(Debug, Clone)]
pub struct ImageData {
    pub mime: Mime,
    pub width: u32,
    pub height: u32,
    pub data: RgbaImage,
}

#[derive(Debug)]
pub struct ImageDiffStat {
    pub diff_pixels: u64,
    pub total_pixels: u64,
    pub diff_ratio: f32,
}

impl Diff for ImageDiff {
    fn equal(&self) -> bool {
        self.equal
    }
}

impl ImageDiff {
    pub fn expected(&self) -> &ImageData {
        &self.expected
    }

    pub fn actual(&self) -> &ImageData {
        &self.actual
    }

    pub fn diff_stat(&self) -> &ImageDiffStat {
        &self.diff_stat
    }

    pub fn diff_image(&self) -> &RgbaImage {
        &self.diff_image
    }
}

#[derive(Debug, Error)]
pub enum ImageDiffError {
    #[error("image error: {0}")]
    Image(#[from] ImageError),
}

#[derive(Debug, Clone, Copy, Default)]
pub struct ImageDiffCalculator {
    max_distance: f32,
    max_diff_ratio: f32,
}

impl ImageDiffCalculator {
    pub fn new(max_distance: f32, max_diff_ratio: f32) -> Self {
        Self {
            max_distance,
            max_diff_ratio,
        }
    }

    #[inline(always)]
    fn pixel_diff(&self, expected: Rgba<u8>, actual: Rgba<u8>) -> bool {
        let (expected_oklab, expected_alpha) = Self::to_oklab_alpha(expected);
        let (actual_oklab, actual_alpha) = Self::to_oklab_alpha(actual);
        let delta_l = expected_oklab[0] - actual_oklab[0];
        let delta_a = expected_oklab[1] - actual_oklab[1];
        let delta_b = expected_oklab[2] - actual_oklab[2];
        let delta_alpha = expected_alpha - actual_alpha;
        let distance = (delta_l * delta_l + delta_a * delta_a + delta_b * delta_b + delta_alpha * delta_alpha).sqrt();
        distance > self.max_distance
    }

    #[inline(always)]
    fn to_oklab_alpha(pixel: Rgba<u8>) -> ([f32; 3], f32) {
        let [r, g, b, a] = pixel.0;
        let oklab = AlphaColor::<Srgb>::from_rgba8(r, g, b, a).convert::<Oklab>();
        let [l, a, b, alpha] = oklab.components;
        ([l, a, b], alpha)
    }

    fn compare(&self, expected: &RgbaImage, actual: &RgbaImage) -> (ImageDiffStat, RgbaImage) {
        let (expected_width, expected_height) = expected.dimensions();
        let (actual_width, actual_height) = actual.dimensions();
        let max_width = expected_width.max(actual_width);
        let max_height = expected_height.max(actual_height);
        let min_width = expected_width.min(actual_width);
        let min_height = expected_height.min(actual_height);
        let total_pixels = u64::from(max_width) * u64::from(max_height);
        let mut diff_pixels = 0u64;
        let mut diff_image = RgbaImage::new(max_width, max_height);
        const DIFF_PIXEL_COLOR: Rgba<u8> = Rgba([255, 255, 255, 180]);
        const SAME_PIXEL_COLOR: Rgba<u8> = Rgba([255, 255, 255, 0]);
        for y in 0..min_height {
            for x in 0..min_width {
                let expected_pixel = *expected.get_pixel(x, y);
                let actual_pixel = *actual.get_pixel(x, y);
                let diff_pixel = if self.pixel_diff(expected_pixel, actual_pixel) {
                    diff_pixels += 1;
                    DIFF_PIXEL_COLOR
                } else {
                    SAME_PIXEL_COLOR
                };
                diff_image.put_pixel(x, y, diff_pixel);
            }
            for x in min_width..max_width {
                diff_pixels += 1;
                diff_image.put_pixel(x, y, DIFF_PIXEL_COLOR);
            }
        }
        for y in min_height..max_height {
            for x in 0..max_width {
                diff_pixels += 1;
                diff_image.put_pixel(x, y, DIFF_PIXEL_COLOR);
            }
        }
        let diff_ratio = if total_pixels == 0 {
            0.0
        } else {
            diff_pixels as f32 / total_pixels as f32
        };
        (
            ImageDiffStat {
                diff_pixels,
                total_pixels,
                diff_ratio,
            },
            diff_image,
        )
    }
}

impl DiffCalculator<FileLeaf> for ImageDiffCalculator {
    type Error = ImageDiffError;
    type Diff = ImageDiff;

    fn diff(
        &self,
        _name: &str,
        expected: FileLeaf,
        actual: FileLeaf,
    ) -> Result<MayUnsupported<Self::Diff>, Self::Error> {
        let (Some(expected_format), Some(actual_format)) = (image_format(&expected.kind), image_format(&actual.kind))
        else {
            return Ok(MayUnsupported::Unsupported);
        };
        let expected_image = match image::load_from_memory_with_format(&expected.content, expected_format) {
            Ok(image) => image,
            Err(_) => return Ok(MayUnsupported::Unsupported),
        };
        let actual_image = match image::load_from_memory_with_format(&actual.content, actual_format) {
            Ok(image) => image,
            Err(_) => return Ok(MayUnsupported::Unsupported),
        };
        let expected_image = expected_image.into_rgba8();
        let actual_image = actual_image.into_rgba8();
        let (diff_stat, diff_image) = self.compare(&expected_image, &actual_image);
        let expected_data = ImageData {
            mime: expected.kind,
            width: expected_image.width(),
            height: expected_image.height(),
            data: expected_image,
        };
        let actual_data = ImageData {
            mime: actual.kind,
            width: actual_image.width(),
            height: actual_image.height(),
            data: actual_image,
        };
        let equal = diff_stat.diff_ratio <= self.max_diff_ratio;
        Ok(MayUnsupported::Ok(ImageDiff {
            equal,
            expected: expected_data,
            actual: actual_data,
            diff_stat,
            diff_image,
        }))
    }
}

fn image_format(mime: &Mime) -> Option<ImageFormat> {
    if mime.type_() != mime::IMAGE {
        return None;
    }
    let format = ImageFormat::from_mime_type(mime.essence_str())?;
    format.reading_enabled().then_some(format)
}