1mod chroma;
21mod color_delta;
22mod content_analysis;
23mod gradient;
24mod interpretation;
25#[cfg(feature = "napi")]
26mod napi;
27#[cfg(feature = "python")]
28mod python;
29mod region;
30mod severity;
31mod shape;
32mod shifts;
33mod spatial;
34mod summary;
35#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
36mod wasm;
37#[cfg(any(test, feature = "testing"))]
40#[doc(hidden)]
41pub mod test_helpers;
42pub mod types;
43
44use blazediff::{DiffOptions, DiffResult};
45use blazediff_shared::yiq::color_delta;
46use blazediff_shared::Image;
47use blazediff_ssim::SsimOutcome;
48use chroma::compute_chroma_stats;
49use color_delta::compute_color_delta;
50use content_analysis::analyze_content;
51use gradient::{compute_gradient_stats, compute_luminance_ncc};
52use interpretation::classify_change_type;
53use shape::{classify_shape, compute_shape_stats};
54
55use spatial::classify_position;
56use types::ChangeType;
57
58pub use region::{
63 detect_regions, extract_change_mask, merge_overlapping_components, ChangeDensity, ComponentInfo,
64};
65pub use severity::classify_severity;
66pub use shifts::detect_shifts;
69pub use summary::build_summary;
70pub use types::{BoundingBox, ChangeRegion, ChangeSeverity, InterpretResult};
71
72#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum InterpretError {
75 SizeMismatch {
77 img1_width: u32,
78 img1_height: u32,
79 img2_width: u32,
80 img2_height: u32,
81 },
82 RegionOutOfBounds {
86 bbox: BoundingBox,
87 width: u32,
88 height: u32,
89 },
90 Producer(String),
92}
93
94impl std::fmt::Display for InterpretError {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 InterpretError::SizeMismatch {
98 img1_width,
99 img1_height,
100 img2_width,
101 img2_height,
102 } => write!(
103 f,
104 "Image sizes do not match: {}x{} vs {}x{}",
105 img1_width, img1_height, img2_width, img2_height
106 ),
107 InterpretError::RegionOutOfBounds {
108 bbox,
109 width,
110 height,
111 } => write!(
112 f,
113 "Region {}x{} at ({}, {}) falls outside the {}x{} image",
114 bbox.width, bbox.height, bbox.x, bbox.y, width, height
115 ),
116 InterpretError::Producer(e) => write!(f, "{}", e),
117 }
118 }
119}
120
121impl std::error::Error for InterpretError {}
122
123const REFINE_DELTA_FLOOR_SQ: f32 = 100.0;
124
125const SPECK_FLOOR: u32 = 8;
128const NOISE_CENSUS_PIXELS: u32 = 64;
130const NOISE_FLOOR_PER_SPECK: f64 = 3.0;
134const NOISE_FLOOR_MIN: u32 = 12;
136const MERGE_SLACK_X: u32 = 12;
140const MERGE_SLACK_Y: u32 = 8;
141const MARGIN_DIVISOR: u32 = 3;
143const MARGIN_MIN: u32 = 2;
144const MARGIN_MAX: u32 = 12;
145
146fn refine_change_mask_in_bboxes(
151 img1: &Image,
152 img2: &Image,
153 input_mask: &[bool],
154 bboxes: &[types::BoundingBox],
155 width: u32,
156) -> Vec<bool> {
157 let pixels1 = img1.as_u32();
158 let pixels2 = img2.as_u32();
159 let mut refined = input_mask.to_vec();
160 for bbox in bboxes {
161 let mut survivors = 0u32;
162 let mut any_delta = false;
163 for y in bbox.y..bbox.y + bbox.height {
164 for x in bbox.x..bbox.x + bbox.width {
165 let idx = (y * width + x) as usize;
166 if !refined[idx] {
167 continue;
168 }
169 let delta = color_delta(pixels1[idx], pixels2[idx], idx).abs() as f32;
170 if delta < REFINE_DELTA_FLOOR_SQ {
171 refined[idx] = false;
172 any_delta |= delta > 0.0;
173 } else {
174 survivors += 1;
175 }
176 }
177 }
178 if survivors == 0 && any_delta {
184 for y in bbox.y..bbox.y + bbox.height {
185 for x in bbox.x..bbox.x + bbox.width {
186 let idx = (y * width + x) as usize;
187 refined[idx] = input_mask[idx];
188 }
189 }
190 }
191 }
192 refined
193}
194
195fn count_mask_pixels(mask: &[bool], bbox: &types::BoundingBox, width: u32) -> u32 {
196 let mut pixel_count = 0u32;
197 for y in bbox.y..bbox.y + bbox.height {
198 for x in bbox.x..bbox.x + bbox.width {
199 if mask[(y * width + x) as usize] {
200 pixel_count += 1;
201 }
202 }
203 }
204 pixel_count
205}
206
207fn classify_region_with_mask(
208 img1: &Image,
209 img2: &Image,
210 mask: &[bool],
211 bbox: types::BoundingBox,
212) -> ChangeRegion {
213 let width = img1.width;
214 let height = img1.height;
215 let total_pixels = (width * height) as f64;
216 let pixel_count = count_mask_pixels(mask, &bbox, width);
217 let percentage = if total_pixels > 0.0 {
218 100.0 * pixel_count as f64 / total_pixels
219 } else {
220 0.0
221 };
222 let shape_stats = compute_shape_stats(mask, width, &bbox, pixel_count);
223 let shape = classify_shape(&shape_stats);
224 let position = classify_position(&bbox, width, height);
225
226 let color_delta = compute_color_delta(img1, img2, mask, &bbox, width);
227 let gradient_stats = compute_gradient_stats(img1, img2, mask, &bbox, width, height);
228 let luminance_ncc = compute_luminance_ncc(img1, img2, mask, &bbox, width);
229 let content = analyze_content(img1, img2, mask, &bbox, width, height);
230 let chroma_stats = compute_chroma_stats(img1, img2, mask, &bbox, width);
231 let (change_type, signals) = classify_change_type(
232 &content,
233 &color_delta,
234 &gradient_stats,
235 &shape_stats,
236 &chroma_stats,
237 &bbox,
238 luminance_ncc,
239 );
240
241 ChangeRegion {
242 bbox,
243 pixel_count,
244 percentage,
245 position,
246 shape,
247 shape_stats,
248 change_type,
249 signals,
250 confidence: signals.confidence,
251 color_delta,
252 gradient: gradient_stats,
253 chroma: chroma_stats,
254 }
255}
256
257pub fn classify_region(
262 img1: &Image,
263 img2: &Image,
264 mask: &[bool],
265 bbox: types::BoundingBox,
266) -> ChangeRegion {
267 classify_region_with_mask(img1, img2, mask, bbox)
268}
269
270pub fn classify_regions(
277 img1: &Image,
278 img2: &Image,
279 mask: &[bool],
280 bboxes: &[types::BoundingBox],
281) -> Vec<ChangeRegion> {
282 let width = img1.width;
283 let refined = refine_change_mask_in_bboxes(img1, img2, mask, bboxes, width);
284 let mut regions: Vec<ChangeRegion> = bboxes
285 .iter()
286 .copied()
287 .map(|bbox| classify_region_with_mask(img1, img2, &refined, bbox))
288 .collect();
289 detect_shifts(&mut regions, img1, img2, &refined, width);
290 regions
291}
292
293pub enum ChangeSource<'a> {
300 Diff {
308 result: &'a DiffResult,
309 output: &'a Image,
310 },
311 Ssim {
316 outcome: &'a SsimOutcome,
317 floor: f32,
318 },
319 Regions(&'a [BoundingBox]),
322}
323
324const MASK_DIFF_COLOR: [u8; 3] = [255, 0, 0];
328const MASK_DIFF_COLOR_ALT: [u8; 3] = [0, 0, 255];
329const MASK_AA_COLOR: [u8; 3] = [255, 255, 0];
330
331pub fn mask_options(options: &DiffOptions) -> DiffOptions {
337 DiffOptions {
338 aa_color: MASK_AA_COLOR,
339 diff_color: MASK_DIFF_COLOR,
340 diff_color_alt: Some(MASK_DIFF_COLOR_ALT),
341 ..options.clone()
342 }
343}
344
345pub fn recolor_output(output: &mut Image, options: &DiffOptions) {
349 let diff_color_alt = options.diff_color_alt.unwrap_or(options.diff_color);
350
351 for pixel in output.data.chunks_exact_mut(4) {
352 let color = match [pixel[0], pixel[1], pixel[2]] {
353 MASK_DIFF_COLOR => options.diff_color,
354 MASK_DIFF_COLOR_ALT => diff_color_alt,
355 MASK_AA_COLOR => options.aa_color,
356 _ => continue,
357 };
358 pixel[..3].copy_from_slice(&color);
359 }
360}
361
362pub fn interpret_diff(
368 image1: &Image,
369 image2: &Image,
370 output: Option<&mut Image>,
371 options: &DiffOptions,
372) -> Result<InterpretResult, InterpretError> {
373 let mut scratch;
377 let (diff_output, retain_output) = match output {
378 Some(image) => (image, true),
379 None => {
380 scratch = Image::new_uninit(image1.width, image1.height);
381 (&mut scratch, false)
382 }
383 };
384
385 let result = blazediff::diff(image1, image2, Some(diff_output), &mask_options(options))
386 .map_err(|e| InterpretError::Producer(e.to_string()))?;
387
388 let interpreted = interpret(
390 image1,
391 image2,
392 ChangeSource::Diff {
393 result: &result,
394 output: diff_output,
395 },
396 )?;
397
398 if retain_output {
399 recolor_output(diff_output, options);
400 }
401
402 Ok(interpreted)
403}
404
405pub fn interpret(
414 image1: &Image,
415 image2: &Image,
416 source: ChangeSource<'_>,
417) -> Result<InterpretResult, InterpretError> {
418 let (width, height) = (image1.width, image1.height);
419 if width != image2.width || height != image2.height {
420 return Err(InterpretError::SizeMismatch {
421 img1_width: width,
422 img1_height: height,
423 img2_width: image2.width,
424 img2_height: image2.height,
425 });
426 }
427
428 if let ChangeSource::Diff { result, .. } = &source {
431 if result.identical {
432 return Ok(InterpretResult {
433 summary: "Images are identical".to_string(),
434 diff_count: 0,
435 total_regions: 0,
436 regions: Vec::new(),
437 severity: classify_severity(0.0),
438 diff_percentage: 0.0,
439 width,
440 height,
441 });
442 }
443 }
444
445 let total_pixels = (width as f64) * (height as f64);
446
447 let margin_output = matches!(source, ChangeSource::Diff { .. });
448
449 let (mut regions, mut mask, counts) = match source {
452 ChangeSource::Diff { result, output } => {
453 let mask = extract_change_mask(&output.data, width, height);
454 let mut components = detect_regions(&mask, width, height);
455 let n_small = components
461 .iter()
462 .filter(|c| c.pixel_count < NOISE_CENSUS_PIXELS)
463 .count();
464 let area_floor = (NOISE_FLOOR_PER_SPECK * n_small as f64) as u32;
465 components.retain(|component| component.pixel_count >= SPECK_FLOOR);
466 let mut components = if components.len() > 1 {
467 let density = ChangeDensity::new(image1, image2);
468 merge_overlapping_components(components, MERGE_SLACK_X, MERGE_SLACK_Y, &density)
469 } else {
470 components
471 };
472 components.retain(|component| component.pixel_count >= area_floor.max(NOISE_FLOOR_MIN));
473 let regions = components
474 .into_iter()
475 .map(|component| {
476 let mut region =
477 classify_region_with_mask(image1, image2, &mask, component.bbox);
478 region.pixel_count = component.pixel_count;
479 region.percentage = if total_pixels > 0.0 {
480 100.0 * component.pixel_count as f64 / total_pixels
481 } else {
482 0.0
483 };
484 region
485 })
486 .collect::<Vec<_>>();
487 (
488 regions,
489 mask,
490 Some((result.diff_count, result.diff_percentage)),
491 )
492 }
493 ChangeSource::Ssim { outcome, floor } => {
494 let boxes = regions_from_score_map(
495 &outcome.map,
496 outcome.map_width,
497 outcome.map_height,
498 width,
499 height,
500 floor,
501 );
502 let (regions, mask) = classify_coarse(image1, image2, &boxes)?;
503 (regions, mask, None)
504 }
505 ChangeSource::Regions(boxes) => {
506 let (regions, mask) = classify_coarse(image1, image2, boxes)?;
507 (regions, mask, None)
508 }
509 };
510
511 detect_shifts(&mut regions, image1, image2, &mask, width);
512 regions.retain(|region| region.change_type != ChangeType::RenderingNoise);
513
514 if margin_output {
520 for region in &mut regions {
521 let bbox = &mut region.bbox;
522 let pad_x = (bbox.width / MARGIN_DIVISOR).clamp(MARGIN_MIN, MARGIN_MAX);
523 let pad_y = (bbox.height / MARGIN_DIVISOR).clamp(MARGIN_MIN, MARGIN_MAX);
524 let x0 = bbox.x.saturating_sub(pad_x);
525 let y0 = bbox.y.saturating_sub(pad_y);
526 let x1 = (bbox.x + bbox.width + pad_x).min(width);
527 let y1 = (bbox.y + bbox.height + pad_y).min(height);
528 bbox.x = x0;
529 bbox.y = y0;
530 bbox.width = x1 - x0;
531 bbox.height = y1 - y0;
532 }
533 }
534
535 let (diff_count, diff_percentage) = counts.unwrap_or_else(|| {
540 let count = count_masked_union(&mut mask, width, ®ions);
541 let percentage = if total_pixels > 0.0 {
542 100.0 * count as f64 / total_pixels
543 } else {
544 0.0
545 };
546 (count, percentage)
547 });
548
549 if regions.is_empty() && diff_count == 0 {
550 return Ok(InterpretResult {
551 summary: "Images are identical".to_string(),
552 diff_count: 0,
553 total_regions: 0,
554 regions: Vec::new(),
555 severity: classify_severity(0.0),
556 diff_percentage: 0.0,
557 width,
558 height,
559 });
560 }
561
562 let severity = classify_severity(diff_percentage);
563 let summary = build_summary(®ions, &severity, diff_percentage);
564
565 Ok(InterpretResult {
566 summary,
567 diff_count,
568 total_regions: regions.len(),
569 regions,
570 severity,
571 diff_percentage,
572 width,
573 height,
574 })
575}
576
577fn classify_coarse(
580 image1: &Image,
581 image2: &Image,
582 boxes: &[BoundingBox],
583) -> Result<(Vec<ChangeRegion>, Vec<bool>), InterpretError> {
584 let (width, height) = (image1.width, image1.height);
585 let total_pixels = (width as f64) * (height as f64);
586
587 for bbox in boxes {
588 let right = bbox.x.checked_add(bbox.width);
591 let bottom = bbox.y.checked_add(bbox.height);
592 let fits = matches!((right, bottom), (Some(r), Some(b)) if r <= width && b <= height);
593 if !fits {
594 return Err(InterpretError::RegionOutOfBounds {
595 bbox: *bbox,
596 width,
597 height,
598 });
599 }
600 }
601
602 let mut mask = vec![false; (width * height) as usize];
603 for bbox in boxes {
604 for y in bbox.y..bbox.y + bbox.height {
605 let row = (y * width) as usize;
606 mask[row + bbox.x as usize..row + (bbox.x + bbox.width) as usize].fill(true);
607 }
608 }
609 let refined = refine_change_mask_in_bboxes(image1, image2, &mask, boxes, width);
610
611 let regions = boxes
612 .iter()
613 .copied()
614 .map(|bbox| {
615 let mut region = classify_region_with_mask(image1, image2, &refined, bbox);
616 region.percentage = if total_pixels > 0.0 {
617 100.0 * region.pixel_count as f64 / total_pixels
618 } else {
619 0.0
620 };
621 region
622 })
623 .collect();
624
625 Ok((regions, refined))
626}
627
628fn count_masked_union(mask: &mut [bool], width: u32, regions: &[ChangeRegion]) -> u32 {
636 let mut count = 0;
637 for region in regions {
638 let bbox = region.bbox;
639 for y in bbox.y..bbox.y + bbox.height {
640 let row = (y * width) as usize;
641 for pixel in &mut mask[row + bbox.x as usize..row + (bbox.x + bbox.width) as usize] {
642 if *pixel {
643 *pixel = false;
644 count += 1;
645 }
646 }
647 }
648 }
649 count
650}
651
652#[cfg(test)]
653mod region_entry_tests {
654 use super::*;
655 use crate::test_helpers::*;
656
657 fn bbox(x: u32, y: u32, width: u32, height: u32) -> BoundingBox {
658 BoundingBox {
659 x,
660 y,
661 width,
662 height,
663 }
664 }
665
666 #[test]
667 fn no_regions_means_identical() {
668 let a = make_solid_image(64, 64, 10, 20, 30);
669 let result = interpret(&a, &a, ChangeSource::Regions(&[])).unwrap();
670 assert_eq!(result.summary, "Images are identical");
671 assert_eq!(result.diff_count, 0);
672 assert_eq!(result.total_regions, 0);
673 }
674
675 #[test]
676 fn a_region_over_unchanged_pixels_contributes_nothing() {
677 let a = make_solid_image(64, 64, 10, 20, 30);
678 let result = interpret(&a, &a, ChangeSource::Regions(&[bbox(8, 8, 16, 16)])).unwrap();
679 assert_eq!(result.diff_count, 0);
680 assert_eq!(result.diff_percentage, 0.0);
681 }
682
683 #[test]
684 fn a_changed_region_is_classified() {
685 let a = make_solid_image(64, 64, 255, 255, 255);
686 let mut b = make_solid_image(64, 64, 255, 255, 255);
687 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
688
689 let result = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 8, 8)])).unwrap();
690 assert_eq!(result.total_regions, 1);
691 assert_eq!(result.diff_count, 64);
692 assert_eq!(result.regions[0].bbox, bbox(16, 16, 8, 8));
693 }
694
695 #[test]
699 fn overlapping_regions_count_a_shared_pixel_once() {
700 let a = make_solid_image(64, 64, 255, 255, 255);
701 let mut b = make_solid_image(64, 64, 255, 255, 255);
702 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
703
704 let result = interpret(
706 &a,
707 &b,
708 ChangeSource::Regions(&[bbox(8, 8, 24, 24), bbox(16, 16, 16, 16)]),
709 )
710 .unwrap();
711
712 assert_eq!(result.diff_count, 64);
713 assert_eq!(result.diff_percentage, 100.0 * 64.0 / (64.0 * 64.0));
714 }
715
716 #[test]
720 fn a_coarse_region_still_yields_per_pixel_counts() {
721 let a = make_solid_image(64, 64, 255, 255, 255);
722 let mut b = make_solid_image(64, 64, 255, 255, 255);
723 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
724
725 let exact = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 8, 8)])).unwrap();
726 let coarse = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 16, 16)])).unwrap();
728
729 assert_eq!(coarse.diff_count, exact.diff_count);
730 assert_eq!(coarse.regions[0].pixel_count, exact.regions[0].pixel_count);
731 }
732
733 #[test]
734 fn mismatched_sizes_are_rejected() {
735 let a = make_solid_image(64, 64, 0, 0, 0);
736 let b = make_solid_image(32, 64, 0, 0, 0);
737 assert!(matches!(
738 interpret(&a, &b, ChangeSource::Regions(&[])),
739 Err(InterpretError::SizeMismatch { .. })
740 ));
741 }
742
743 #[test]
744 fn an_out_of_bounds_region_is_rejected_rather_than_panicking() {
745 let a = make_solid_image(64, 64, 0, 0, 0);
746 for out in [
747 bbox(60, 0, 8, 8),
748 bbox(0, 60, 8, 8),
749 bbox(u32::MAX, 0, 8, 8),
750 ] {
751 assert!(
752 matches!(
753 interpret(&a, &a, ChangeSource::Regions(&[out])),
754 Err(InterpretError::RegionOutOfBounds { .. })
755 ),
756 "{out:?} should be rejected"
757 );
758 }
759 }
760}
761
762pub fn regions_from_score_map(
776 map: &[f32],
777 map_width: usize,
778 map_height: usize,
779 width: u32,
780 height: u32,
781 floor: f32,
782) -> Vec<BoundingBox> {
783 if map_width == 0 || map_height == 0 || width == 0 || height == 0 {
784 return Vec::new();
785 }
786 if map.len() < map_width * map_height {
787 return Vec::new();
788 }
789
790 let changed: Vec<bool> = map[..map_width * map_height]
791 .iter()
792 .map(|score| !(*score >= floor))
793 .collect();
794
795 let scale =
800 |m: u32, size: u32, map_size: u32| (m as u64 * size as u64 / map_size as u64) as u32;
801
802 detect_regions(&changed, map_width as u32, map_height as u32)
803 .into_iter()
804 .filter_map(|component| {
805 let bbox = component.bbox;
806 let x = scale(bbox.x, width, map_width as u32);
807 let y = scale(bbox.y, height, map_height as u32);
808 let right = scale(bbox.x + bbox.width, width, map_width as u32);
809 let bottom = scale(bbox.y + bbox.height, height, map_height as u32);
810 (right > x && bottom > y).then_some(BoundingBox {
814 x,
815 y,
816 width: right - x,
817 height: bottom - y,
818 })
819 })
820 .collect()
821}
822
823#[cfg(test)]
824mod score_map_tests {
825 use super::*;
826
827 #[test]
828 fn an_all_similar_map_yields_no_regions() {
829 let map = vec![1.0f32; 4 * 4];
830 assert!(regions_from_score_map(&map, 4, 4, 64, 64, 0.99).is_empty());
831 }
832
833 #[test]
834 fn a_low_cell_becomes_a_scaled_box() {
835 let mut map = vec![1.0f32; 4 * 4];
836 map[5] = 0.1; let regions = regions_from_score_map(&map, 4, 4, 64, 64, 0.99);
838 assert_eq!(
839 regions,
840 vec![BoundingBox {
841 x: 16,
842 y: 16,
843 width: 16,
844 height: 16
845 }]
846 );
847 }
848
849 #[test]
854 fn a_cell_maps_to_its_exact_slice_of_a_non_divisible_image() {
855 let mut map = vec![1.0f32; 3 * 3];
856 map[4] = 0.1; let regions = regions_from_score_map(&map, 3, 3, 64, 64, 0.99);
858 assert_eq!(
859 regions,
860 vec![BoundingBox {
861 x: 21,
862 y: 21,
863 width: 21,
864 height: 21
865 }]
866 );
867 }
868
869 #[test]
870 fn adjacent_cells_merge_into_one_region() {
871 let mut map = vec![1.0f32; 4 * 4];
872 map[5] = 0.1;
873 map[6] = 0.1;
874 let regions = regions_from_score_map(&map, 4, 4, 64, 64, 0.99);
875 assert_eq!(regions.len(), 1);
876 assert_eq!(regions[0].width, 32);
877 }
878
879 #[test]
880 fn nan_counts_as_changed() {
881 let mut map = vec![1.0f32; 4 * 4];
882 map[0] = f32::NAN;
883 assert_eq!(regions_from_score_map(&map, 4, 4, 64, 64, 0.99).len(), 1);
884 }
885
886 #[test]
887 fn a_degenerate_map_yields_nothing() {
888 assert!(regions_from_score_map(&[], 0, 0, 64, 64, 0.99).is_empty());
889 assert!(regions_from_score_map(&[0.0], 4, 4, 64, 64, 0.99).is_empty());
890 }
891}
892
893#[cfg(test)]
894mod source_parity_tests {
895 use super::*;
896 use crate::test_helpers::*;
897 use blazediff_ssim::{ssim, Plane, Rgba8, SsimOptions};
898
899 fn pair() -> (Image, Image) {
900 let a = make_solid_image(64, 64, 255, 255, 255);
901 let mut b = make_solid_image(64, 64, 255, 255, 255);
902 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
903 (a, b)
904 }
905
906 #[test]
910 fn a_diff_and_an_ssim_map_agree() {
911 let (a, b) = pair();
912 let options = DiffOptions::default();
913
914 let mut output = Image::new_uninit(a.width, a.height);
915 let result = blazediff::diff(&a, &b, Some(&mut output), &mask_options(&options)).unwrap();
916 let from_diff = interpret(
917 &a,
918 &b,
919 ChangeSource::Diff {
920 result: &result,
921 output: &output,
922 },
923 )
924 .unwrap();
925
926 let plane = |image: &Image| Plane::from_rgba8(Rgba8::new(&image.data, 64, 64)).unwrap();
927 let outcome = ssim(&plane(&a), &plane(&b), &SsimOptions::default()).unwrap();
928 let from_ssim = interpret(
929 &a,
930 &b,
931 ChangeSource::Ssim {
932 outcome: &outcome,
933 floor: 0.99,
934 },
935 )
936 .unwrap();
937
938 assert_eq!(from_diff.total_regions, 1);
939 assert_eq!(from_ssim.total_regions, 1);
940
941 let change = BoundingBox {
945 x: 16,
946 y: 16,
947 width: 8,
948 height: 8,
949 };
950 let encloses = |outer: &BoundingBox, inner: &BoundingBox| {
951 outer.x <= inner.x
952 && outer.y <= inner.y
953 && outer.x + outer.width >= inner.x + inner.width
954 && outer.y + outer.height >= inner.y + inner.height
955 };
956 let (margined, coarse) = (from_diff.regions[0].bbox, from_ssim.regions[0].bbox);
957 assert!(
958 encloses(&margined, &change),
959 "{margined:?} should enclose {change:?}"
960 );
961 assert!(margined.x + margined.width <= change.x + change.width + MARGIN_MAX);
963 assert!(margined.width <= change.width + 2 * MARGIN_MAX);
964 assert!(
965 encloses(&coarse, &change),
966 "{coarse:?} should enclose {change:?}"
967 );
968
969 assert_eq!(
971 from_diff.regions[0].change_type,
972 from_ssim.regions[0].change_type
973 );
974 assert_eq!(from_diff.diff_count, 64);
975 assert_eq!(from_ssim.diff_count, 64);
976 }
977
978 #[test]
979 fn an_unchanged_pair_is_identical_from_either_source() {
980 let a = make_solid_image(64, 64, 10, 20, 30);
981 let options = DiffOptions::default();
982
983 let mut output = Image::new_uninit(64, 64);
984 let result = blazediff::diff(&a, &a, Some(&mut output), &mask_options(&options)).unwrap();
985 let from_diff = interpret(
986 &a,
987 &a,
988 ChangeSource::Diff {
989 result: &result,
990 output: &output,
991 },
992 )
993 .unwrap();
994 assert_eq!(from_diff.summary, "Images are identical");
995
996 let plane = Plane::from_rgba8(Rgba8::new(&a.data, 64, 64)).unwrap();
997 let outcome = ssim(&plane, &plane, &SsimOptions::default()).unwrap();
998 let from_ssim = interpret(
999 &a,
1000 &a,
1001 ChangeSource::Ssim {
1002 outcome: &outcome,
1003 floor: 0.99,
1004 },
1005 )
1006 .unwrap();
1007 assert_eq!(from_ssim.summary, "Images are identical");
1008 }
1009}
1010
1011#[cfg(test)]
1012mod diff_source_tests {
1013
1014 use super::*;
1015 use crate::test_helpers::*;
1016 use blazediff::{diff, DiffOptions};
1017 use types::*;
1018 #[test]
1019 fn test_identical_images() {
1020 let img1 = make_solid_image(100, 100, 128, 128, 128);
1021 let img2 = make_solid_image(100, 100, 128, 128, 128);
1022 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1023
1024 assert_eq!(result.total_regions, 0);
1025 assert!(result.regions.is_empty());
1026 assert_eq!(result.severity, ChangeSeverity::Low);
1027 assert_eq!(result.diff_percentage, 0.0);
1028 assert_eq!(result.summary, "Images are identical");
1029 }
1030
1031 #[test]
1032 fn test_single_pixel_change_is_filtered_as_noise() {
1033 let img1 = make_solid_image(100, 100, 128, 128, 128);
1036 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1037 set_pixel(&mut img2, 50, 50, 130, 130, 130);
1038
1039 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1040
1041 assert_eq!(result.total_regions, 0);
1042 assert!(result.regions.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_interpret_with_output_matches_diff() {
1047 let img1 = make_solid_image(32, 32, 200, 200, 200);
1048 let img2 = make_solid_image(32, 32, 50, 50, 50);
1049 let options = DiffOptions {
1050 include_aa: true,
1051 diff_color_alt: Some([0, 128, 255]),
1052 ..Default::default()
1053 };
1054 let mut expected = Image::new(32, 32);
1055 let diff_result = diff(&img1, &img2, Some(&mut expected), &options).unwrap();
1056 let mut actual = Image::new(32, 32);
1057
1058 let interpretation = interpret_diff(&img1, &img2, Some(&mut actual), &options).unwrap();
1059
1060 assert_eq!(interpretation.diff_count, diff_result.diff_count);
1061 assert_eq!(actual.data, expected.data);
1062 }
1063
1064 #[test]
1065 fn test_interpret_with_grayscale_alt_color_keeps_regions() {
1066 let img1 = make_solid_image(32, 32, 200, 200, 200);
1067 let img2 = make_solid_image(32, 32, 50, 50, 50);
1068 let options = DiffOptions {
1069 include_aa: true,
1070 diff_color_alt: Some([32, 32, 32]),
1071 ..Default::default()
1072 };
1073 let mut output = Image::new(32, 32);
1074
1075 let result = interpret_diff(&img1, &img2, Some(&mut output), &options).unwrap();
1076
1077 assert_eq!(result.diff_count, 32 * 32);
1078 assert!(result.total_regions > 0);
1079 assert_eq!(&output.data[..4], &[32, 32, 32, 255]);
1080 }
1081
1082 #[test]
1083 fn test_block_addition() {
1084 let img1 = make_solid_image(100, 100, 128, 128, 128);
1085 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1086 fill_block(&mut img2, 0, 0, 40, 40, 255, 0, 0);
1087
1088 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1089
1090 assert_eq!(result.total_regions, 1);
1091 assert_eq!(result.regions[0].position, SpatialPosition::TopLeft);
1092 assert_eq!(result.severity, ChangeSeverity::High);
1093 assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1094 assert!(result.summary.contains("Content added"));
1095 }
1096
1097 #[test]
1098 fn test_block_deletion() {
1099 let mut img1 = make_solid_image(100, 100, 128, 128, 128);
1100 fill_block(&mut img1, 0, 0, 40, 40, 255, 0, 0);
1101 let img2 = make_solid_image(100, 100, 128, 128, 128);
1102
1103 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1104
1105 assert_eq!(result.total_regions, 1);
1106 assert_eq!(result.regions[0].change_type, ChangeType::Deletion);
1107 assert!(result.summary.contains("Content removed"));
1108 }
1109
1110 #[test]
1111 fn test_scattered_additions() {
1112 let img1 = make_solid_image(100, 100, 128, 128, 128);
1113 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1114 fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1115 fill_block(&mut img2, 80, 80, 10, 10, 0, 255, 0);
1116
1117 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1118
1119 assert_eq!(result.total_regions, 2);
1120 let positions: Vec<SpatialPosition> = result.regions.iter().map(|r| r.position).collect();
1121 assert!(positions.contains(&SpatialPosition::TopLeft));
1122 assert!(positions.contains(&SpatialPosition::BottomRight));
1123 assert!(result
1124 .regions
1125 .iter()
1126 .all(|r| r.change_type == ChangeType::Addition));
1127 }
1128
1129 #[test]
1130 fn test_full_image_color_change() {
1131 let img1 = make_solid_image(100, 100, 0, 0, 0);
1132 let img2 = make_solid_image(100, 100, 255, 255, 255);
1133
1134 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1135
1136 assert_eq!(result.total_regions, 1);
1137 assert_eq!(result.severity, ChangeSeverity::High);
1138 assert!(result.diff_percentage > 90.0);
1139 assert_eq!(result.regions[0].shape, ChangeShape::SolidRegion);
1140 assert!(
1141 matches!(
1142 result.regions[0].change_type,
1143 ChangeType::ColorChange | ChangeType::ContentChange
1144 ),
1145 "Expected ColorChange or ContentChange for full image swap, got: {:?}",
1146 result.regions[0].change_type
1147 );
1148 }
1149
1150 #[test]
1151 fn test_severity_boundaries() {
1152 let img1 = make_solid_image(100, 100, 128, 128, 128);
1153
1154 let mut img2_low = make_solid_image(100, 100, 128, 128, 128);
1155 for i in 0..50 {
1156 set_pixel(&mut img2_low, i % 100, i / 100, 255, 0, 0);
1157 }
1158 let result_low = interpret_diff(&img1, &img2_low, None, &DiffOptions::default()).unwrap();
1159 assert_eq!(result_low.severity, ChangeSeverity::Low);
1160
1161 let mut img2_med = make_solid_image(100, 100, 128, 128, 128);
1162 fill_block(&mut img2_med, 0, 0, 20, 25, 255, 0, 0);
1163 let result_med = interpret_diff(&img1, &img2_med, None, &DiffOptions::default()).unwrap();
1164 assert_eq!(result_med.severity, ChangeSeverity::Medium);
1165
1166 let mut img2_high = make_solid_image(100, 100, 128, 128, 128);
1167 fill_block(&mut img2_high, 0, 0, 50, 50, 255, 0, 0);
1168 let result_high = interpret_diff(&img1, &img2_high, None, &DiffOptions::default()).unwrap();
1169 assert_eq!(result_high.severity, ChangeSeverity::High);
1170 }
1171
1172 #[test]
1173 fn test_json_roundtrip() {
1174 let img1 = make_solid_image(100, 100, 128, 128, 128);
1175 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1176 fill_block(&mut img2, 10, 10, 20, 20, 255, 0, 0);
1177
1178 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1179 let json = serde_json::to_string(&result).unwrap();
1180 let deserialized: InterpretResult = serde_json::from_str(&json).unwrap();
1181
1182 assert_eq!(result, deserialized);
1183 }
1184
1185 #[test]
1186 fn test_hollow_frame_is_addition() {
1187 let img1 = make_solid_image(100, 100, 128, 128, 128);
1188 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1189
1190 let bx = 35u32;
1191 let by = 35u32;
1192 let bw = 30u32;
1193 let bh = 30u32;
1194 for y in by..by + bh {
1195 for x in bx..bx + bw {
1196 if x == bx || x == bx + bw - 1 || y == by || y == by + bh - 1 {
1197 set_pixel(&mut img2, x, y, 255, 0, 0);
1198 }
1199 }
1200 }
1201
1202 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1203
1204 assert_eq!(result.total_regions, 1);
1205 assert_eq!(result.regions[0].shape, ChangeShape::ContourFrame);
1206 assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1207 }
1208
1209 #[test]
1210 fn test_sparse_noise() {
1211 let img1 = make_solid_image(100, 100, 128, 128, 128);
1212 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1213
1214 let bx = 10u32;
1215 let by = 10u32;
1216 let size = 80u32;
1217 for y in (by..by + size).step_by(6) {
1218 for x in bx..bx + size {
1219 set_pixel(&mut img2, x, y, 133, 133, 133);
1220 }
1221 }
1222 for x in (bx..bx + size).step_by(6) {
1223 for y in by..by + size {
1224 set_pixel(&mut img2, x, y, 133, 133, 133);
1225 }
1226 }
1227
1228 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1229
1230 for r in &result.regions {
1231 assert_eq!(
1232 r.change_type,
1233 ChangeType::Addition,
1234 "Expected Addition for sparse subtle grid, got: {:?}",
1235 r.change_type
1236 );
1237 }
1238 }
1239
1240 #[test]
1241 fn test_shift_detection() {
1242 let mut img1 = make_solid_image(100, 100, 255, 255, 255);
1243 fill_block(&mut img1, 10, 10, 20, 20, 40, 40, 40);
1244
1245 let mut img2 = make_solid_image(100, 100, 255, 255, 255);
1246 fill_block(&mut img2, 60, 60, 20, 20, 40, 40, 40);
1247
1248 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1249
1250 assert_eq!(result.total_regions, 2);
1251 assert!(
1252 result
1253 .regions
1254 .iter()
1255 .all(|r| r.change_type == ChangeType::Shift),
1256 "Expected both regions as Shift, got: {:?}",
1257 result
1258 .regions
1259 .iter()
1260 .map(|r| r.change_type)
1261 .collect::<Vec<_>>()
1262 );
1263 assert!(result.summary.contains("Content shifted"));
1264 }
1265
1266 #[test]
1267 fn test_size_mismatch_error() {
1268 let img1 = make_solid_image(100, 100, 128, 128, 128);
1269 let img2 = make_solid_image(200, 200, 128, 128, 128);
1270
1271 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default());
1272 assert!(result.is_err());
1273 }
1274
1275 #[test]
1276 fn test_summary_format() {
1277 let img1 = make_solid_image(100, 100, 128, 128, 128);
1278 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1279 fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1280 fill_block(&mut img2, 80, 80, 10, 10, 0, 255, 0);
1281
1282 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1283
1284 assert!(result.summary.contains("visual change detected"));
1285 assert!(result.summary.contains("2 regions"));
1286 }
1287
1288 #[test]
1289 fn test_summary_has_descriptions() {
1290 let img1 = make_solid_image(100, 100, 128, 128, 128);
1291 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1292 fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1293
1294 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1295
1296 assert!(result.summary.contains("region"));
1297 assert!(result.summary.lines().count() >= 2);
1298 }
1299
1300 #[test]
1301 fn test_signals_populated() {
1302 let img1 = make_solid_image(100, 100, 128, 128, 128);
1303 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1304 fill_block(&mut img2, 0, 0, 40, 40, 255, 0, 0);
1305
1306 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1307
1308 assert_eq!(result.total_regions, 1);
1309 let region = &result.regions[0];
1310 assert!(region.confidence > 0.0);
1311 assert!(region.signals.blends_with_bg_in_img1);
1312 }
1313
1314 #[test]
1315 fn test_grey_shift_is_addition() {
1316 let img1 = make_solid_image(100, 100, 128, 128, 128);
1317 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1318 fill_block(&mut img2, 0, 0, 40, 40, 220, 220, 220);
1319
1320 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1321
1322 assert_eq!(result.total_regions, 1);
1323 assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1324 }
1325
1326 #[test]
1327 fn test_shift_not_detected_for_different_sizes() {
1328 let mut img1 = make_solid_image(200, 100, 255, 255, 255);
1329 fill_block(&mut img1, 10, 10, 40, 40, 40, 40, 40);
1330
1331 let mut img2 = make_solid_image(200, 100, 255, 255, 255);
1332 fill_block(&mut img2, 140, 10, 10, 10, 40, 40, 40);
1333
1334 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1335
1336 let shift_count = result
1337 .regions
1338 .iter()
1339 .filter(|r| r.change_type == ChangeType::Shift)
1340 .count();
1341 assert_eq!(
1342 shift_count, 0,
1343 "Different-sized blocks should not be detected as shift"
1344 );
1345 }
1346
1347 #[test]
1348 fn test_shift_not_detected_for_different_luminance() {
1349 let mut img1 = make_solid_image(200, 100, 200, 200, 200);
1350 fill_block(&mut img1, 10, 10, 30, 30, 20, 20, 20);
1351
1352 let mut img2 = make_solid_image(200, 100, 200, 200, 200);
1353 fill_block(&mut img2, 140, 10, 30, 30, 200, 50, 50);
1354
1355 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1356
1357 let shift_count = result
1358 .regions
1359 .iter()
1360 .filter(|r| r.change_type == ChangeType::Shift)
1361 .count();
1362 assert_eq!(
1363 shift_count, 0,
1364 "Blocks with different luminance should not be shift"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_no_shift_when_only_additions() {
1370 let img1 = make_solid_image(200, 100, 200, 200, 200);
1371 let mut img2 = make_solid_image(200, 100, 200, 200, 200);
1372 fill_block(&mut img2, 10, 10, 30, 30, 40, 40, 40);
1373 fill_block(&mut img2, 140, 10, 30, 30, 40, 40, 40);
1374
1375 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1376
1377 let shift_count = result
1378 .regions
1379 .iter()
1380 .filter(|r| r.change_type == ChangeType::Shift)
1381 .count();
1382 assert_eq!(
1383 shift_count, 0,
1384 "Two additions with no deletion cannot form a shift"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_object_on_both_images_is_not_addition_or_deletion() {
1390 let mut img1 = make_solid_image(100, 100, 200, 200, 200);
1391 fill_block(&mut img1, 30, 30, 40, 40, 255, 0, 0);
1392 let mut img2 = make_solid_image(100, 100, 200, 200, 200);
1393 fill_block(&mut img2, 30, 30, 40, 40, 0, 0, 255);
1394
1395 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1396
1397 for r in &result.regions {
1398 assert!(
1399 r.change_type != ChangeType::Addition && r.change_type != ChangeType::Deletion,
1400 "Object present in both images should not be Addition or Deletion, got {:?}",
1401 r.change_type
1402 );
1403 }
1404 }
1405}