Skip to content

Commit

Permalink
First shot at adding more image compare options
Browse files Browse the repository at this point in the history
  • Loading branch information
ChrisRega committed Feb 27, 2024
1 parent e52d50d commit 6670989
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 32 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
188 changes: 167 additions & 21 deletions src/image.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,76 @@
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};
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)]
Expand All @@ -38,15 +85,99 @@ pub enum Error {
FileNameParsing(String),
}

struct ComparisonResult {
score: f64,
image: Option<DynamicImage>,
}

impl From<Similarity> for ComparisonResult {
fn from(value: Similarity) -> Self {
Self {
image: Some(value.image.to_color_map()),
score: value.score,
}
}
}

pub fn compare_paths<P: AsRef<Path>>(
nominal_path: P,
actual_path: P,
config: &ImageCompareConfig,
) -> Result<report::Difference, Error> {
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 {:?}",
Expand All @@ -56,8 +187,13 @@ pub fn compare_paths<P: AsRef<Path>>(
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 {}",
Expand All @@ -66,8 +202,9 @@ pub fn compare_paths<P: AsRef<Path>>(
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();
Expand All @@ -77,6 +214,7 @@ pub fn compare_paths<P: AsRef<Path>>(

#[cfg(test)]
mod test {
use super::*;
use crate::image::{compare_paths, ImageCompareConfig};
use crate::report::DiffDetail;

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,12 +379,15 @@ pub fn validate_config(config_file: impl AsRef<Path>) -> 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,
};
Expand Down
17 changes: 9 additions & 8 deletions src/report/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ pub enum DiffDetail {
CSV(DiffType),
Image {
score: f64,
diff_image: String,
diff_image: Option<String>,
},
Text {
actual: String,
Expand Down Expand Up @@ -352,7 +352,7 @@ pub(crate) fn write_csv_detail(
pub fn write_image_detail(
nominal: impl AsRef<Path>,
actual: impl AsRef<Path>,
diffs: &[(&f64, &String)],
diffs: &[(&f64, &Option<String>)],
report_dir: impl AsRef<Path>,
) -> Result<Option<DetailPath>, Error> {
if diffs.is_empty() {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<String>)> = file
.detail
.iter()
.filter_map(|r| match r {
Expand Down
2 changes: 2 additions & 0 deletions src/report/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,12 @@ pub const PLAIN_IMAGE_DETAIL_TEMPLATE: &str = r#"
<img src="./{{ actual_image }}" />
</p>
{% if diff_image %}
<p>
<h3>Diff:</h3>
<img src="./{{ diff_image }}" />
</p>
{% endif %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script type="text/javascript" src="https://cdn.datatables.net/v/dt/dt-1.12.1/datatables.min.js"></script>
Expand Down

0 comments on commit 6670989

Please sign in to comment.