havocompare/
image.rs

1use std::path::Path;
2
3use image::{DynamicImage, Rgb};
4use image_compare::{Algorithm, Metric, Similarity};
5use schemars_derive::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use tracing::error;
9
10use crate::report::DiffDetail;
11use crate::{get_file_name, report};
12
13#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
14pub enum RGBACompareMode {
15    /// full RGBA comparison - probably not intuitive, rarely what you want outside of video processing
16    /// Will do MSSIM on luma, then RMS on U and V and alpha channels.
17    /// The calculation of the score is then pixel-wise the minimum of each pixels similarity.
18    /// To account for perceived indifference in lower alpha regions, this down-weights the difference linearly with mean alpha channel.
19    Hybrid,
20    /// 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
21    HybridBlended { r: u8, b: u8, g: u8 },
22}
23
24impl Default for RGBACompareMode {
25    fn default() -> Self {
26        Self::HybridBlended { r: 0, b: 0, g: 0 }
27    }
28}
29
30#[allow(clippy::upper_case_acronyms)]
31#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone, Default)]
32pub enum RGBCompareMode {
33    ///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
34    RMS,
35    ///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
36    MSSIM,
37    ///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.
38    #[default]
39    Hybrid,
40}
41
42#[allow(clippy::upper_case_acronyms)]
43#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
44/// The distance algorithm to use for grayscale comparison, see
45/// https://github.com/ChrisRega/image-compare for equations
46pub enum GrayStructureAlgorithm {
47    /// SSIM with 8x8 pixel windows and averaging over the result
48    MSSIM,
49    /// Classic RMS distance
50    RMS,
51}
52
53/// See https://github.com/ChrisRega/image-compare for equations
54/// Distance metrics for histograms for grayscale comparison
55#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
56pub enum GrayHistogramCompareMetric {
57    /// 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}}$
58    Correlation,
59    /// Chi-Square $d(H_1,H_2) = \sum _I \frac{\left(H_1(I)-H_2(I)\right)^2}{H_1(I)}$
60    ChiSquare,
61    /// Intersection $d(H_1,H_2) = \sum _I \min (H_1(I), H_2(I))$
62    Intersection,
63    /// 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)}}$
64    Hellinger,
65}
66
67#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
68pub enum GrayCompareMode {
69    /// Compare gray values pixel structure
70    Structure(GrayStructureAlgorithm),
71    /// Compare gray values by histogram
72    Histogram(GrayHistogramCompareMetric),
73}
74
75#[allow(clippy::upper_case_acronyms)]
76#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
77pub enum CompareMode {
78    /// Compare images as RGB
79    RGB(RGBCompareMode),
80    /// Compare images as RGBA
81    RGBA(RGBACompareMode),
82    /// Compare images as luminance / grayscale
83    Gray(GrayCompareMode),
84}
85
86#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
87/// Image comparison config options
88pub struct ImageCompareConfig {
89    /// Threshold for image comparison < 0.5 is very dissimilar, 1.0 is identical
90    pub threshold: f64,
91    #[serde(flatten)]
92    /// How to compare the two images
93    pub mode: CompareMode,
94}
95
96#[derive(Debug, Error)]
97pub enum Error {
98    #[error("Error loading image {0}")]
99    ImageDecoding(#[from] image::ImageError),
100    #[error("Problem creating hash report {0}")]
101    Reporting(#[from] report::Error),
102    #[error("Image comparison algorithm failed {0}")]
103    ImageComparison(#[from] image_compare::CompareError),
104    #[error("Problem processing file name {0}")]
105    FileNameParsing(String),
106}
107
108struct ComparisonResult {
109    score: f64,
110    image: Option<DynamicImage>,
111}
112
113impl From<Similarity> for ComparisonResult {
114    fn from(value: Similarity) -> Self {
115        Self {
116            image: Some(value.image.to_color_map()),
117            score: value.score,
118        }
119    }
120}
121
122pub fn compare_paths<P: AsRef<Path>>(
123    nominal_path: P,
124    actual_path: P,
125    config: &ImageCompareConfig,
126) -> Result<report::Difference, Error> {
127    let nominal = image::open(nominal_path.as_ref())?;
128    let actual = image::open(actual_path.as_ref())?;
129    let result: ComparisonResult = match &config.mode {
130        CompareMode::RGBA(c) => {
131            let nominal = nominal.into_rgba8();
132            let actual = actual.into_rgba8();
133            match c {
134                RGBACompareMode::Hybrid => {
135                    image_compare::rgba_hybrid_compare(&nominal, &actual)?.into()
136                }
137                RGBACompareMode::HybridBlended { r, g, b } => {
138                    image_compare::rgba_blended_hybrid_compare(
139                        (&nominal).into(),
140                        (&actual).into(),
141                        Rgb([*r, *g, *b]),
142                    )?
143                    .into()
144                }
145            }
146        }
147        CompareMode::RGB(c) => {
148            let nominal = nominal.into_rgb8();
149            let actual = actual.into_rgb8();
150            match c {
151                RGBCompareMode::RMS => image_compare::rgb_similarity_structure(
152                    &Algorithm::RootMeanSquared,
153                    &nominal,
154                    &actual,
155                )?
156                .into(),
157                RGBCompareMode::MSSIM => image_compare::rgb_similarity_structure(
158                    &Algorithm::MSSIMSimple,
159                    &nominal,
160                    &actual,
161                )?
162                .into(),
163                RGBCompareMode::Hybrid => {
164                    image_compare::rgb_hybrid_compare(&nominal, &actual)?.into()
165                }
166            }
167        }
168        CompareMode::Gray(c) => {
169            let nominal = nominal.into_luma8();
170            let actual = actual.into_luma8();
171            match c {
172                GrayCompareMode::Structure(c) => match c {
173                    GrayStructureAlgorithm::MSSIM => image_compare::gray_similarity_structure(
174                        &Algorithm::MSSIMSimple,
175                        &nominal,
176                        &actual,
177                    )?
178                    .into(),
179                    GrayStructureAlgorithm::RMS => image_compare::gray_similarity_structure(
180                        &Algorithm::RootMeanSquared,
181                        &nominal,
182                        &actual,
183                    )?
184                    .into(),
185                },
186                GrayCompareMode::Histogram(c) => {
187                    let metric = match c {
188                        GrayHistogramCompareMetric::Correlation => Metric::Correlation,
189                        GrayHistogramCompareMetric::ChiSquare => Metric::ChiSquare,
190                        GrayHistogramCompareMetric::Intersection => Metric::Intersection,
191                        GrayHistogramCompareMetric::Hellinger => Metric::Hellinger,
192                    };
193                    let score =
194                        image_compare::gray_similarity_histogram(metric, &nominal, &actual)?;
195                    ComparisonResult { score, image: None }
196                }
197            }
198        }
199    };
200
201    let mut result_diff = report::Difference::new_for_file(&nominal_path, &actual_path);
202    if result.score < config.threshold {
203        let out_path_set = if let Some(i) = result.image {
204            let nominal_file_name =
205                get_file_name(nominal_path.as_ref()).ok_or(Error::FileNameParsing(format!(
206                    "Could not extract filename from path {:?}",
207                    nominal_path.as_ref()
208                )))?;
209            let out_path = (nominal_file_name + "diff_image.png").to_string();
210            i.save(&out_path)?;
211            Some(out_path)
212        } else {
213            None
214        };
215
216        let error_message = format!(
217            "Diff for image {} was not met, expected {}, found {}",
218            nominal_path.as_ref().to_string_lossy(),
219            config.threshold,
220            result.score
221        );
222        error!("{}", &error_message);
223
224        result_diff.push_detail(DiffDetail::Image {
225            diff_image: out_path_set,
226            score: result.score,
227        });
228        result_diff.error();
229    }
230    Ok(result_diff)
231}
232
233#[cfg(test)]
234mod test {
235    use super::*;
236
237    #[test]
238    fn identity() {
239        let result = compare_paths(
240            "tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg",
241            "tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg",
242            &ImageCompareConfig {
243                threshold: 1.0,
244                mode: CompareMode::RGB(RGBCompareMode::Hybrid),
245            },
246        )
247        .unwrap();
248        assert!(!result.is_error);
249    }
250
251    #[test]
252    fn pin_diff_image() {
253        let result = compare_paths(
254            "tests/integ/data/images/expected/SaveImage_100DPI_default_size.jpg",
255            "tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg",
256            &ImageCompareConfig {
257                threshold: 1.0,
258                mode: CompareMode::RGBA(RGBACompareMode::Hybrid),
259            },
260        )
261        .unwrap();
262        assert!(result.is_error);
263        if let DiffDetail::Image {
264            score: _,
265            diff_image,
266        } = result.detail.first().unwrap()
267        {
268            let img = image::open(diff_image.as_ref().unwrap())
269                .unwrap()
270                .into_rgba8();
271            let nom = image::open("tests/integ/data/images/diff_100_DPI.png")
272                .unwrap()
273                .into_rgba8();
274            let diff_result = image_compare::rgba_hybrid_compare(&img, &nom)
275                .expect("Wrong dimensions of diff images!");
276            assert_eq!(diff_result.score, 0.9879023078642883);
277        } else {
278            unreachable!();
279        }
280    }
281}