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