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 Hybrid,
20 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 RMS,
35 MSSIM,
37 #[default]
39 Hybrid,
40}
41
42#[allow(clippy::upper_case_acronyms)]
43#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
44pub enum GrayStructureAlgorithm {
47 MSSIM,
49 RMS,
51}
52
53#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
56pub enum GrayHistogramCompareMetric {
57 Correlation,
59 ChiSquare,
61 Intersection,
63 Hellinger,
65}
66
67#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
68pub enum GrayCompareMode {
69 Structure(GrayStructureAlgorithm),
71 Histogram(GrayHistogramCompareMetric),
73}
74
75#[allow(clippy::upper_case_acronyms)]
76#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
77pub enum CompareMode {
78 RGB(RGBCompareMode),
80 RGBA(RGBACompareMode),
82 Gray(GrayCompareMode),
84}
85
86#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
87pub struct ImageCompareConfig {
89 pub threshold: f64,
91 #[serde(flatten)]
92 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}