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
+{% if diff_image %}
Diff:
+{% endif %}