From 66709893be79013dc6972bea6e3570338576a98c Mon Sep 17 00:00:00 2001 From: Christopher Regali Date: Tue, 27 Feb 2024 23:50:11 +0100 Subject: [PATCH] First shot at adding more image compare options --- Cargo.toml | 2 +- src/image.rs | 188 ++++++++++++++++++++++++++++++++++++----- src/lib.rs | 7 +- src/report/mod.rs | 17 ++-- src/report/template.rs | 2 + 5 files changed, 184 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 746e55c..ee30050 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ description = "A flexible rule-based file and folder comparison tool and crate i repository = "https://github.com/VolumeGraphics/havocompare" homepage = "https://github.com/VolumeGraphics/havocompare" documentation = "https://docs.rs/havocompare" -version = "0.5.4" +version = "0.6.0" edition = "2021" license = "MIT" authors = ["Volume Graphics GmbH"] diff --git a/src/image.rs b/src/image.rs index 8fa5671..a695dc6 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,5 +1,7 @@ use crate::report::DiffDetail; use crate::{get_file_name, report}; +use image::{DynamicImage, Rgb}; +use image_compare::{Algorithm, Metric, Similarity}; use schemars_derive::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -7,23 +9,68 @@ use thiserror::Error; use tracing::error; #[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] -/// Image comparison config options -pub struct ImageCompareConfig { - /// Threshold for image comparison < 0.5 is very dissimilar, 1.0 is identical - pub threshold: f64, +pub enum RGBACompareMode { + /// full RGBA comparison - probably not intuitive, rarely what you want outside of video processing + /// Will do MSSIM on luma, then RMS on U and V and alpha channels. + /// The calculation of the score is then pixel-wise the minimum of each pixels similarity. + /// To account for perceived indifference in lower alpha regions, this down-weights the difference linearly with mean alpha channel. + Hybrid, + /// pre-blend the background in RGBA with this color, use the background RGB values you would assume the pictures to be seen on - usually either black or white + HybridBlended { r: u8, b: u8, g: u8 }, } -impl ImageCompareConfig { - /// create an [`ImageCompareConfig`] given the threshold - pub fn from_threshold(threshold: f64) -> Self { - ImageCompareConfig { threshold } +impl Default for RGBACompareMode { + fn default() -> Self { + Self::HybridBlended { r: 0, b: 0, g: 0 } } } -impl Default for ImageCompareConfig { - fn default() -> Self { - ImageCompareConfig::from_threshold(1.0) - } +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone, Default)] +pub enum RGBCompareMode { + ///Comparing rgb images using structure. RGB structure similarity is performed by doing a channel split and taking the maximum deviation (minimum similarity) for the result. The image contains the complete deviations. Algorithm: RMS + RMS, + ///Comparing rgb images using structure. RGB structure similarity is performed by doing a channel split and taking the maximum deviation (minimum similarity) for the result. The image contains the complete deviations. Algorithm: MSSIM + MSSIM, + ///Comparing structure via MSSIM on Y channel, comparing color-diff-vectors on U and V summing the squares Please mind that the RGBSimilarity-Image does not contain plain RGB here. Probably what you want. + #[default] + Hybrid, +} + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub enum GrayStructureAlgorithm { + MSSIM, + RMS, +} + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub enum GrayHistogramCompareMetric { + Correlation, + ChiSquare, + Intersection, + Hellinger, +} + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub enum GrayCompareMode { + Structure(GrayStructureAlgorithm), + Histogram(GrayHistogramCompareMetric), +} + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub enum CompareMode { + RGB(RGBCompareMode), + RGBA(RGBACompareMode), + Gray(GrayCompareMode), +} + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +/// Image comparison config options +pub struct ImageCompareConfig { + /// Threshold for image comparison < 0.5 is very dissimilar, 1.0 is identical + pub threshold: f64, + #[serde(flatten)] + /// How to compare the two images + pub mode: CompareMode, } #[derive(Debug, Error)] @@ -38,15 +85,99 @@ pub enum Error { FileNameParsing(String), } +struct ComparisonResult { + score: f64, + image: Option, +} + +impl From for ComparisonResult { + fn from(value: Similarity) -> Self { + Self { + image: Some(value.image.to_color_map()), + score: value.score, + } + } +} + pub fn compare_paths>( nominal_path: P, actual_path: P, config: &ImageCompareConfig, ) -> Result { - let nominal = image::open(nominal_path.as_ref())?.into_rgba8(); - let actual = image::open(actual_path.as_ref())?.into_rgba8(); + let nominal = image::open(nominal_path.as_ref())?; + let actual = image::open(actual_path.as_ref())?; + let result: ComparisonResult = match &config.mode { + CompareMode::RGBA(c) => { + let nominal = nominal.into_rgba8(); + let actual = actual.into_rgba8(); + match c { + RGBACompareMode::Hybrid => { + image_compare::rgba_hybrid_compare(&nominal, &actual)?.into() + } + RGBACompareMode::HybridBlended { r, g, b } => { + image_compare::rgba_blended_hybrid_compare( + (&nominal).into(), + (&actual).into(), + Rgb([*r, *g, *b]), + )? + .into() + } + } + } + CompareMode::RGB(c) => { + let nominal = nominal.into_rgb8(); + let actual = actual.into_rgb8(); + match c { + RGBCompareMode::RMS => image_compare::rgb_similarity_structure( + &Algorithm::RootMeanSquared, + &nominal, + &actual, + )? + .into(), + RGBCompareMode::MSSIM => image_compare::rgb_similarity_structure( + &Algorithm::MSSIMSimple, + &nominal, + &actual, + )? + .into(), + RGBCompareMode::Hybrid => { + image_compare::rgb_hybrid_compare(&nominal, &actual)?.into() + } + } + } + CompareMode::Gray(c) => { + let nominal = nominal.into_luma8(); + let actual = actual.into_luma8(); + match c { + GrayCompareMode::Structure(c) => match c { + GrayStructureAlgorithm::MSSIM => image_compare::gray_similarity_structure( + &Algorithm::MSSIMSimple, + &nominal, + &actual, + )? + .into(), + GrayStructureAlgorithm::RMS => image_compare::gray_similarity_structure( + &Algorithm::RootMeanSquared, + &nominal, + &actual, + )? + .into(), + }, + GrayCompareMode::Histogram(c) => { + let metric = match c { + GrayHistogramCompareMetric::Correlation => Metric::Correlation, + GrayHistogramCompareMetric::ChiSquare => Metric::ChiSquare, + GrayHistogramCompareMetric::Intersection => Metric::Intersection, + GrayHistogramCompareMetric::Hellinger => Metric::Hellinger, + }; + let score = + image_compare::gray_similarity_histogram(metric, &nominal, &actual)?; + ComparisonResult { score, image: None } + } + } + } + }; - let result = image_compare::rgba_hybrid_compare(&nominal, &actual)?; let nominal_file_name = get_file_name(nominal_path.as_ref()).ok_or(Error::FileNameParsing(format!( "Could not extract filename from path {:?}", @@ -56,8 +187,13 @@ pub fn compare_paths>( let mut result_diff = report::Difference::new_for_file(&nominal_path, &actual_path); if result.score < config.threshold { - let color_map = result.image.to_color_map(); - color_map.save(PathBuf::from(&out_path))?; + let out_path_set; + if let Some(i) = result.image { + i.save(PathBuf::from(&out_path))?; + out_path_set = Some(out_path); + } else { + out_path_set = None; + } let error_message = format!( "Diff for image {} was not met, expected {}, found {}", @@ -66,8 +202,9 @@ pub fn compare_paths>( result.score ); error!("{}", &error_message); + result_diff.push_detail(DiffDetail::Image { - diff_image: out_path, + diff_image: out_path_set, score: result.score, }); result_diff.error(); @@ -77,6 +214,7 @@ pub fn compare_paths>( #[cfg(test)] mod test { + use super::*; use crate::image::{compare_paths, ImageCompareConfig}; use crate::report::DiffDetail; @@ -85,7 +223,10 @@ mod test { let result = compare_paths( "tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg", "tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg", - &ImageCompareConfig { threshold: 1.0 }, + &ImageCompareConfig { + threshold: 1.0, + mode: CompareMode::RGB(RGBCompareMode::Hybrid), + }, ) .unwrap(); assert!(!result.is_error); @@ -96,7 +237,10 @@ mod test { let result = compare_paths( "tests/integ/data/images/expected/SaveImage_100DPI_default_size.jpg", "tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg", - &ImageCompareConfig { threshold: 1.0 }, + &ImageCompareConfig { + threshold: 1.0, + mode: CompareMode::RGB(RGBCompareMode::Hybrid), + }, ) .unwrap(); assert!(result.is_error); @@ -105,7 +249,9 @@ mod test { diff_image, } = result.detail.first().unwrap() { - let img = image::open(diff_image).unwrap().into_rgb8(); + let img = image::open(diff_image.as_ref().unwrap()) + .unwrap() + .into_rgb8(); let nom = image::open("tests/integ/data/images/diff_100_DPI.png") .unwrap() .into_rgb8(); diff --git a/src/lib.rs b/src/lib.rs index 15c5063..ff4aeb6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -379,12 +379,15 @@ pub fn validate_config(config_file: impl AsRef) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::image::ImageCompareConfig; + use crate::image::{CompareMode, ImageCompareConfig, RGBCompareMode}; #[test] fn folder_not_found_is_false() { let rule = Rule { name: "test rule".to_string(), - file_type: ComparisonMode::Image(ImageCompareConfig { threshold: 1.0 }), + file_type: ComparisonMode::Image(ImageCompareConfig { + threshold: 1.0, + mode: CompareMode::RGB(RGBCompareMode::Hybrid), + }), pattern_include: vec!["*.".to_string()], pattern_exclude: None, }; diff --git a/src/report/mod.rs b/src/report/mod.rs index ab2e7e2..b8a4f49 100644 --- a/src/report/mod.rs +++ b/src/report/mod.rs @@ -130,7 +130,7 @@ pub enum DiffDetail { CSV(DiffType), Image { score: f64, - diff_image: String, + diff_image: Option, }, Text { actual: String, @@ -352,7 +352,7 @@ pub(crate) fn write_csv_detail( pub fn write_image_detail( nominal: impl AsRef, actual: impl AsRef, - diffs: &[(&f64, &String)], + diffs: &[(&f64, &Option)], report_dir: impl AsRef, ) -> Result, Error> { if diffs.is_empty() { @@ -393,12 +393,13 @@ pub fn write_image_detail( .map_err(|e| FatIOError::from_std_io_err(e, nominal.as_ref().to_path_buf()))?; let (score, diff_image) = diffs[0]; - let img_target = detail_path.path.join(diff_image); - fs::copy(diff_image, &img_target) - .map_err(|e| FatIOError::from_std_io_err(e, img_target.to_path_buf()))?; - + if let Some(img) = diff_image { + let img_target = detail_path.path.join(img); + fs::copy(img, &img_target) + .map_err(|e| FatIOError::from_std_io_err(e, img_target.to_path_buf()))?; + ctx.insert("diff_image", diff_image); + } ctx.insert("error", &format!("Score {score}")); - ctx.insert("diff_image", diff_image); ctx.insert("actual_image", &actual_image); ctx.insert("nominal_image", &nominal_image); @@ -721,7 +722,7 @@ pub(crate) fn create_html( .unwrap_or_else(|e| log_detail_html_creation_error(&e)) } ComparisonMode::Image(_) => { - let diffs: Vec<(&f64, &String)> = file + let diffs: Vec<(&f64, &Option)> = file .detail .iter() .filter_map(|r| match r { diff --git a/src/report/template.rs b/src/report/template.rs index 91fc2f7..68e0834 100644 --- a/src/report/template.rs +++ b/src/report/template.rs @@ -251,10 +251,12 @@ pub const PLAIN_IMAGE_DETAIL_TEMPLATE: &str = r#"

+{% if diff_image %}

Diff:

+{% endif %}