diff --git a/Cargo.toml b/Cargo.toml index c550fd2..a18c32d 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"] @@ -19,7 +19,7 @@ path = "src/print_args.rs" [dependencies] -clap = { version = "4.4", features = ["derive"] } +clap = { version = "4.5", features = ["derive"] } chrono = "0.4" serde = "1.0" serde_yaml = "0.9" @@ -27,8 +27,8 @@ schemars = "0.8" schemars_derive = "0.8" thiserror = "1.0" regex = "1.10" -image = "0.24" -image-compare = "0.3" +image = "0.25" +image-compare = "0.4" tracing = "0.1" tracing-subscriber = "0.3" serde_json = "1.0" @@ -38,18 +38,17 @@ strsim = "0.11" itertools = "0.12" tera = "1.19" sha2 = "0.10" -data-encoding = "2.5" +data-encoding = "2.6" permutation = "0.4" pdf-extract = "0.7" vg_errortools = "0.1" -rayon = "1.8.0" +rayon = "1.10.0" enable-ansi-support = "0.2" -tempfile = "3.8" +tempfile = "3.10" fs_extra = "1.3" -opener = "0.6" +opener = "0.7" anyhow = "1.0" -json_diff_ng = { version = "0.4" } - +json_diff_ng = { version = "0.5" } [dev-dependencies] env_logger = "0.11" diff --git a/README.md b/README.md index 48af352..f6336f1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Havocompare - a folder comparison utility + [![Crates.io](https://img.shields.io/crates/d/havocompare?style=flat)](https://crates.io/crates/havocompare) [![Documentation](https://docs.rs/havocompare/badge.svg)](https://docs.rs/havocompare) ![CI](https://github.com/VolumeGraphics/havocompare/actions/workflows/rust.yml/badge.svg?branch=main "CI") @@ -6,6 +7,7 @@ [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) ## Contributors: + Contributors @@ -13,6 +15,7 @@ ## Quickstart ### 0. Install havocompare + You have rust? cool! try: `cargo install havocompare` @@ -20,28 +23,35 @@ You just want a binary: Check our binary downloads on github-pages ### 1. Create a config file -Havocompare was developed with a few design goals in mind. We wanted a human-readable and easily composable configuration file format. -After a few tries we ended up with the current format, which is a list of rules inside a yaml file. + +Havocompare was developed with a few design goals in mind. We wanted a human-readable and easily composable +configuration file format. +After a few tries we ended up with the current format, which is a list of rules inside a yaml file. See the following example `config.yaml`: + ```yaml rules: - name: "Numerical results csv" # you can have multiple includes and excludes - pattern_include: + pattern_include: - "**/export_*.csv" # excludes are optional - pattern_exclude: + pattern_exclude: - "**/export_1337.csv" CSV: comparison_modes: - Relative: 0.1 - Absolute: 1.0 ``` -It creates a new rule named rule including all files matching "export_*.csv" in all sub-folders but exclude "export_1337.csv". -String cells will be checked for perfect identity, numbers (including numbers with units) will be checked for a relative deviation smaller than `0.1` + +It creates a new rule named rule including all files matching "export_*.csv" in all sub-folders but exclude " +export_1337.csv". +String cells will be checked for perfect identity, numbers (including numbers with units) will be checked for a relative +deviation smaller than `0.1` AND absolute deviation smaller than `1.0`. __Comparison rules__ + - Relative means validity is checked like: `|nominal - actual| / |nominal| < tolerance` - Absolute means validity is checked like: `|nominal - actual| < tolerance` - "nan" and "nan" is equal @@ -51,43 +61,56 @@ __Comparison rules__ Running the comparison is super easy, just supply nominal, actual and the config: `./havocompare compare nominal_dir actual_dir config.yaml` -The report of the comparison will be written inside the `./report` folder. Differences will also be printed to the terminal. -Furthermore, if differences are found, the return code will be `1`, if no differences are found, it will be `0` making integration of +The report of the comparison will be written inside the `./report` folder. Differences will also be printed to the +terminal. +Furthermore, if differences are found, the return code will be `1`, if no differences are found, it will be `0` making +integration of havocompare into a CI system rather easy. ## Details on the config + ### Validation Scheme -Writing a valid configuration file can be error-prone without auto-completion. We suggest using json schema to validate your yaml + +Writing a valid configuration file can be error-prone without auto-completion. We suggest using json schema to validate +your yaml and even enable auto-completion in IDEs like pycharm. To generate the schema you can call: `./havocompare schema > config_scheme.json` and import the resulting scheme into your IDE. ### Comparison options + #### CSV -The `comparison_modes` option is required and of type 'list'. It can comprise either a relative numerical ('Relative') maximum deviation or a maximum -deviation ('Absolute'). -You can specify the decimal separator and the field separator. If you don't specify, havocompare will try to guess it from each csv file. -Note: If delimiters are not specified, even different delimiters between nominal and actual are accepted as long as all deviations are in bounds. + +The `comparison_modes` option is required and of type 'list'. It can comprise either a relative numerical ('Relative') +maximum deviation or a maximum +deviation ('Absolute'). +You can specify the decimal separator and the field separator. If you don't specify, havocompare will try to guess it +from each csv file. +Note: If delimiters are not specified, even different delimiters between nominal and actual are accepted as long as all +deviations are in bounds. To ignore specific cells, you can specify an exclusion regex. -The preprocessing steps are done after the file is parsed using the given delimiters (or guessing) but before anything else. Processing order is as written in the list. -In the below example, headers will be extracted from the csv-input file, then a column with the title "Column to delete" will be deleted. +The preprocessing steps are done after the file is parsed using the given delimiters (or guessing) but before anything +else. Processing order is as written in the list. +In the below example, headers will be extracted from the csv-input file, then a column with the title "Column to delete" +will be deleted. If any of the preprocessing steps fail, havocompare will exit with an error immediately so use them carefully. See the following example with all optional parameters set: + ```yaml rules: - name: "CSV - Demo all options" # what files to include - use as many as make sense to reduce duplication in your rules - pattern_include: + pattern_include: - "**/*.csv" # optional: of all included files, remove the ones matching any exclude pattern - pattern_exclude: + pattern_exclude: - "**/ignored.csv" CSV: # delimiters are optional, if not given, they will be auto-detected. # auto-detection allows different delimiters for nominal and actual decimal_separator: '.' - field_delimiter: ';' + field_delimiter: ';' # can have Absolute or Relative or both comparison_modes: - Absolute: 1.0 @@ -121,30 +144,36 @@ rules: ``` #### Image comparison -Image comparison is done using the `image compare` crate's hybrid comparison which does MSSIM on the luma and RMS on the color information. -Only a threshold can be specified: + +Image comparison is done using the `image compare` crate. +Specify loads of options here and then filter on threshold. + ```yaml rules: - name: "JPG comparison" - pattern_include: + pattern_include: - "**/*.jpg" # exclude can of course also be specified! Image: - # threshold is between 0.0 for total difference, 0.5 for very dissimilar and 1.0 for perfect mach - # Usually you want to test with values between 0.90 and 0.97 + # Compare images in RGBA-mode, can also be RGB and Gray + # Comparison mode set to Hybrid means we want MSSIM on the Y channel and 2 dim vec diff on UV for color information + RGBA: Hybrid threshold: 0.9 ``` #### Plain text comparison -For plain text comparison the file is read and compared line by line. For each line the normalized Damerau-Levenshtein distance from the `strsim` -crate is used. You can ignore single lines which you know are different by specifying an arbitrary number of ignored lines: + +For plain text comparison the file is read and compared line by line. For each line the normalized Damerau-Levenshtein +distance from the `strsim` +crate is used. You can ignore single lines which you know are different by specifying an arbitrary number of ignored +lines: ```yaml rules: - name: "HTML-Compare strict" - pattern_exclude: + pattern_exclude: - "**/*_changed.html" - pattern_include: + pattern_include: - "**/*.html" PlainText: # Normalized Damerau-Levenshtein distance @@ -157,14 +186,16 @@ rules: ``` #### PDF text comparison -For PDF text comparison the text will be extracted and written to temporary files. The files will then be compared using the Plain text comparison: + +For PDF text comparison the text will be extracted and written to temporary files. The files will then be compared using +the Plain text comparison: ```yaml rules: - name: "PDF-Text-Compare" - pattern_exclude: + pattern_exclude: - "**/*_changed.pdf" - pattern_include: + pattern_include: - "**/*.pdf" PDFText: # Normalized Damerau-Levenshtein distance @@ -176,15 +207,15 @@ rules: - "[A-Z]*[0-9]" ``` - #### Hash comparison + For binary files which cannot otherwise be checked we can also do a simple hash comparison. Currently, we only support SHA-256 but more checks can be added easily. ```yaml rules: - name: "Hash comparison strict" - pattern_exclude: + pattern_exclude: - "**/*.bin" Hash: # Currently we only have Sha256 @@ -192,12 +223,13 @@ rules: ``` #### File metadata comparison + For the cases where the pure existence or some metadata are already enough. ```yaml rules: - name: "Metadata comparison" - pattern_exclude: + pattern_exclude: - "**/*.bin" FileProperties: # nom/act file paths must not contain whitespace @@ -209,8 +241,8 @@ rules: ``` #### Run external comparison tool -In case you want to run an external comparison tool, you can use this option +In case you want to run an external comparison tool, you can use this option ```yaml rules: @@ -226,24 +258,25 @@ rules: - "--only-images" ``` - #### JSON comparison + Compares JSON files for different keys in both files and mismatches in values. -ignore_keys elements will be ignored, full regex matching on only the key names / paths is supported. +`ignore_keys` is a list of regexes that are matched against the individual key names, the key value pair is excluded from the comparison if a regex matches. The values are not affected by this. ```yaml rules: -- name: "Compare JSON files" - pattern_include: - - "**/*.json" - Json: - ignore_keys: - # drop "ignore_this_key" and "ignore_this_keys" with this regex :) - - "ignore_this_key(s?)" + - name: "Compare JSON files" + pattern_include: + - "**/*.json" + Json: + ignore_keys: + # drop "ignore_this_key" and "ignore_this_keys" with this regex :) + - "ignore_this_key(s?)" ``` ### Use HavoCompare in your unit-tests + 1. Add havocompare to your dev-dependencies: ```toml [dev-dependencies] @@ -272,38 +305,51 @@ rules: ## Changelog +### 0.6.0 + +- Add new options for image compare module (a lot of options!) +- Bump json-compare to new version fixing bugs in regex field excludes and sorting + ### 0.5.4 + - Add option to run single file mode from CLI ### 0.5.3 + - Add option to sort json arrays (including deep sorting) - Make single file comparison function to public api - Update dependencies, fix broken pdf import regarding whitespaces - ### 0.5.2 + - Preserve white spaces in CSV and PDF report instead of being collapsed by HTML - Add + and - symbols to the report index - Display combined file names in report index if the compared file names are different ### 0.5.1 + - Fix potential crash in JSON checking ### 0.5.0 + - Add JSON checking ### 0.4.0 + - Separate reporting logic from comparison logic - Implement a machine-readable JSON reporting ### 0.3.2 + - Allow direct opening of reports after comparison with `--open` - Parsing failures when running `compare` are now propagated to terminal ### 0.3.1 + - Fix swapped actual and nominal for hash and text-compares ### 0.3.0 + - Allow RGBA image comparison - Add file metadata comparison - Add external checking @@ -312,33 +358,40 @@ rules: - Added config yaml validation command to check whether file can be loaded or not ### 0.2.4 + - add check for row lines of both compared csv files, and throw error if they are unequal - Add deletion by cell -- Simplify report sub-folders creation: sub-folders are now created temporarily in the temp folder instead of in the current working folder +- Simplify report sub-folders creation: sub-folders are now created temporarily in the temp folder instead of in the + current working folder - Change report row numbering to always start with 0, so row deletion is more understandable - fix floating point value comparison of non-displayable diff values ### 0.2.3 -- bump pdf-extract crate to 0.6.4 to fix "'attempted to leave type `linked_hash_map::Node, object::Object>` uninitialized'" + +- bump pdf-extract crate to 0.6.4 to fix "'attempted to leave + type `linked_hash_map::Node, object::Object>` uninitialized'" ### 0.2.2 + - Include files which has error and can't be compared to the report - Fixed a bug which caused the program exited early out of rules-loop, and not processing all ### 0.2.0 + - Deletion of columns will no longer really delete them but replace every value with "DELETED" - Expose config struct to library API - Fixed a bug regarding wrong handling of multiple empty lines -- Reworked CSV reporting to have an interleaved and more compact view - - Display the relative path of compared files instead of file name in the report index.html - - Made header-extraction fallible but uncritical - can now always be enabled +- Reworked CSV reporting to have an interleaved and more compact view + - Display the relative path of compared files instead of file name in the report index.html + - Made header-extraction fallible but uncritical - can now always be enabled - Wrote a completely new csv parser: - - Respects escaping with '\' - - Allows string-literals containing unescaped field separators (field1, "field2, but as literal", field3) - - Allows multi-line string literals with quotes + - Respects escaping with '\' + - Allows string-literals containing unescaped field separators (field1, "field2, but as literal", field3) + - Allows multi-line string literals with quotes - CSVs with non-rectangular format will now fail ### 0.1.4 + - Add multiple includes and excludes - warning, this will break rules-files from 0.1.3 and earlier - Remove all `unwrap` and `expect` in the library code in favor of correct error propagation - Add preprocessing options for CSV files @@ -347,14 +400,17 @@ rules: - Add PDF-Text compare ### 0.1.3: + - Add optional cli argument to configure the folder to store the report ### 0.1.2: + - Add SHA-256 comparison mode - Fix BOM on windows for CSV comparison ### 0.1.1: - - Better error message on folder not found - - Better test coverage - - Fix colors on Windows terminal - - Extend CI to Windows and mac + +- Better error message on folder not found +- Better test coverage +- Fix colors on Windows terminal +- Extend CI to Windows and mac diff --git a/config_scheme.json b/config_scheme.json index 3670404..18238b0 100644 --- a/config_scheme.json +++ b/config_scheme.json @@ -87,6 +87,50 @@ } } }, + "GrayCompareMode": { + "oneOf": [ + { + "type": "object", + "required": [ + "Structure" + ], + "properties": { + "Structure": { + "$ref": "#/definitions/GrayStructureAlgorithm" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Histogram" + ], + "properties": { + "Histogram": { + "$ref": "#/definitions/GrayHistogramCompareMetric" + } + }, + "additionalProperties": false + } + ] + }, + "GrayHistogramCompareMetric": { + "type": "string", + "enum": [ + "Correlation", + "ChiSquare", + "Intersection", + "Hellinger" + ] + }, + "GrayStructureAlgorithm": { + "type": "string", + "enum": [ + "MSSIM", + "RMS" + ] + }, "HTMLCompareConfig": { "description": "Plain text comparison config, also used for PDF", "type": "object", @@ -137,6 +181,44 @@ "ImageCompareConfig": { "description": "Image comparison config options", "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "RGB" + ], + "properties": { + "RGB": { + "$ref": "#/definitions/RGBCompareMode" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "RGBA" + ], + "properties": { + "RGBA": { + "$ref": "#/definitions/RGBACompareMode" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Gray" + ], + "properties": { + "Gray": { + "$ref": "#/definitions/GrayCompareMode" + } + }, + "additionalProperties": false + } + ], "required": [ "threshold" ], @@ -392,6 +474,77 @@ } } }, + "RGBACompareMode": { + "oneOf": [ + { + "description": "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.", + "type": "string", + "enum": [ + "Hybrid" + ] + }, + { + "description": "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", + "type": "object", + "required": [ + "HybridBlended" + ], + "properties": { + "HybridBlended": { + "type": "object", + "required": [ + "b", + "g", + "r" + ], + "properties": { + "b": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "g": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "r": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + } + ] + }, + "RGBCompareMode": { + "oneOf": [ + { + "description": "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", + "type": "string", + "enum": [ + "RMS" + ] + }, + { + "description": "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", + "type": "string", + "enum": [ + "MSSIM" + ] + }, + { + "description": "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.", + "type": "string", + "enum": [ + "Hybrid" + ] + } + ] + }, "Rule": { "description": "Representing a single comparison rule", "type": "object", diff --git a/src/hash.rs b/src/hash.rs index c709205..438ce4e 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -1,15 +1,16 @@ -use crate::{report, Deserialize, Serialize}; -use data_encoding::HEXLOWER; - -use crate::report::{DiffDetail, Difference}; -use schemars_derive::JsonSchema; use std::fs::File; use std::io::Read; use std::path::{Path, PathBuf}; + +use data_encoding::HEXLOWER; +use schemars_derive::JsonSchema; use thiserror::Error; use vg_errortools::fat_io_wrap_std; use vg_errortools::FatIOError; +use crate::{Deserialize, report, Serialize}; +use crate::report::{DiffDetail, Difference}; + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy)] pub enum HashFunction { Sha256, @@ -84,9 +85,10 @@ pub fn compare_files>( #[cfg(test)] mod test { - use super::*; use crate::hash::HashFunction::Sha256; + use super::*; + #[test] fn identity() { let f1 = Sha256 @@ -100,7 +102,7 @@ mod test { #[test] fn hash_pinning() { - let sum = "bc3abb411d305c4436185c474be3db2608e910612a573f6791b143d7d749b699"; + let sum = "378f768a589f29fcbd23835ec4764a53610fc910e60b540e1e5204bdaf2c73a0"; let f1 = Sha256 .hash_file(File::open("tests/integ/data/images/diff_100_DPI.png").unwrap()) .unwrap(); diff --git a/src/image.rs b/src/image.rs index 8fa5671..ccbb5a9 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,29 +1,93 @@ -use crate::report::DiffDetail; -use crate::{get_file_name, report}; +use std::path::Path; + +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; +use crate::{get_file_name, report}; +use crate::report::DiffDetail; + #[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)] +/// The distance algorithm to use for grayscale comparison, see +/// https://github.com/ChrisRega/image-compare for equations +pub enum GrayStructureAlgorithm { + /// SSIM with 8x8 pixel windows and averaging over the result + MSSIM, + /// Classic RMS distance + RMS, +} + +/// See https://github.com/ChrisRega/image-compare for equations +/// Distance metrics for histograms for grayscale comparison +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub enum GrayHistogramCompareMetric { + /// Correlation $d(H_1,H_2) = \frac{\sum_I (H_1(I) - \bar{H_1}) (H_2(I) - \bar{H_2})}{\sqrt{\sum_I(H_1(I) - \bar{H_1})^2 \sum_I(H_2(I) - \bar{H_2})^2}}$ + Correlation, + /// Chi-Square $d(H_1,H_2) = \sum _I \frac{\left(H_1(I)-H_2(I)\right)^2}{H_1(I)}$ + ChiSquare, + /// Intersection $d(H_1,H_2) = \sum _I \min (H_1(I), H_2(I))$ + Intersection, + /// Hellinger distance $d(H_1,H_2) = \sqrt{1 - \frac{1}{\sqrt{\int{H_1} \int{H_2}}} \sum_I \sqrt{H_1(I) \cdot H_2(I)}}$ + Hellinger, +} + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub enum GrayCompareMode { + /// Compare gray values pixel structure + Structure(GrayStructureAlgorithm), + /// Compare gray values by histogram + Histogram(GrayHistogramCompareMetric), +} + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub enum CompareMode { + /// Compare images as RGB + RGB(RGBCompareMode), + /// Compare images as RGBA + RGBA(RGBACompareMode), + /// Compare images as luminance / grayscale + 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,26 +102,113 @@ 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 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 {:?}", - nominal_path.as_ref() - )))?; - let out_path = (nominal_file_name + "diff_image.png").to_string(); - let mut result_diff = report::Difference::new_for_file(&nominal_path, &actual_path); + 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 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 { + let nominal_file_name = + get_file_name(nominal_path.as_ref()).ok_or(Error::FileNameParsing(format!( + "Could not extract filename from path {:?}", + nominal_path.as_ref() + )))?; + let out_path = (nominal_file_name + "diff_image.png").to_string(); + i.save(&out_path)?; + Some(out_path) + } else { + None + }; let error_message = format!( "Diff for image {} was not met, expected {}, found {}", @@ -66,8 +217,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,15 +229,17 @@ pub fn compare_paths>( #[cfg(test)] mod test { - use crate::image::{compare_paths, ImageCompareConfig}; - use crate::report::DiffDetail; + use super::*; #[test] fn identity() { 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 +250,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::RGBA(RGBACompareMode::Hybrid), + }, ) .unwrap(); assert!(result.is_error); @@ -105,11 +262,13 @@ 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_rgba8(); let nom = image::open("tests/integ/data/images/diff_100_DPI.png") .unwrap() - .into_rgb8(); - let diff_result = image_compare::rgb_hybrid_compare(&img, &nom) + .into_rgba8(); + let diff_result = image_compare::rgba_hybrid_compare(&img, &nom) .expect("Wrong dimensions of diff images!"); assert_eq!(diff_result.score, 1.0); } else { diff --git a/src/json.rs b/src/json.rs index b31a15e..1ef98e9 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,12 +1,14 @@ -use crate::report::{DiffDetail, Difference}; -use crate::Error; +use std::path::Path; + use itertools::Itertools; use regex::Regex; use schemars_derive::JsonSchema; use serde::{Deserialize, Serialize}; -use std::path::Path; use tracing::error; +use crate::Error; +use crate::report::{DiffDetail, Difference}; + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] /// configuration for the json compare module pub struct JsonConfig { @@ -33,7 +35,8 @@ pub(crate) fn compare_files>( let actual = vg_errortools::fat_io_wrap_std(&actual, &std::fs::read_to_string)?; let ignores = config.get_ignore_list()?; - let json_diff = json_diff::process::compare_jsons(&nominal, &actual, config.sort_arrays); + let json_diff = + json_diff::process::compare_jsons(&nominal, &actual, config.sort_arrays, &ignores); let json_diff = match json_diff { Ok(diff) => diff, Err(e) => { @@ -45,11 +48,7 @@ pub(crate) fn compare_files>( return Ok(diff); } }; - let filtered_diff: Vec<_> = json_diff - .all_diffs() - .into_iter() - .filter(|(_d, v)| !ignores.iter().any(|excl| excl.is_match(v.get_key()))) - .collect(); + let filtered_diff: Vec<_> = json_diff.all_diffs(); if !filtered_diff.is_empty() { for (d_type, key) in filtered_diff.iter() { diff --git a/src/lib.rs b/src/lib.rs index 56dec61..48524ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -379,11 +379,16 @@ pub fn validate_config(config_file: impl AsRef) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::image::{CompareMode, 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 %} diff --git a/tests/integ/data/images/diff_100_DPI.png b/tests/integ/data/images/diff_100_DPI.png index 4d0ae5c..316f83d 100644 Binary files a/tests/integ/data/images/diff_100_DPI.png and b/tests/integ/data/images/diff_100_DPI.png differ diff --git a/tests/integ/jpg_compare.yml b/tests/integ/jpg_compare.yml index ffba804..57d0047 100644 --- a/tests/integ/jpg_compare.yml +++ b/tests/integ/jpg_compare.yml @@ -3,4 +3,5 @@ rules: pattern_include: - "**/*.jpg" Image: + RGBA: Hybrid threshold: 0.9