1mod color_delta;
21mod content_analysis;
22mod gradient;
23mod interpretation;
24#[cfg(feature = "napi")]
25mod napi;
26mod region;
27mod severity;
28mod shape;
29mod shifts;
30mod spatial;
31mod summary;
32#[cfg(any(test, feature = "testing"))]
35#[doc(hidden)]
36pub mod test_helpers;
37pub mod types;
38
39use blazediff::{DiffOptions, DiffResult};
40use blazediff_shared::yiq::color_delta;
41use blazediff_shared::Image;
42use blazediff_ssim::SsimOutcome;
43use color_delta::compute_color_delta;
44use content_analysis::analyze_content;
45use gradient::{compute_gradient_stats, compute_luminance_ncc};
46use interpretation::classify_change_type;
47use shape::{classify_shape, compute_shape_stats};
48
49use spatial::classify_position;
50use types::ChangeType;
51
52pub use region::{detect_regions, extract_change_mask, ComponentInfo};
57pub use severity::classify_severity;
58pub use shifts::detect_shifts;
61pub use summary::build_summary;
62pub use types::{BoundingBox, ChangeRegion, ChangeSeverity, InterpretResult};
63
64#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum InterpretError {
67 SizeMismatch {
69 img1_width: u32,
70 img1_height: u32,
71 img2_width: u32,
72 img2_height: u32,
73 },
74 RegionOutOfBounds {
78 bbox: BoundingBox,
79 width: u32,
80 height: u32,
81 },
82 Producer(String),
84}
85
86impl std::fmt::Display for InterpretError {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 InterpretError::SizeMismatch {
90 img1_width,
91 img1_height,
92 img2_width,
93 img2_height,
94 } => write!(
95 f,
96 "Image sizes do not match: {}x{} vs {}x{}",
97 img1_width, img1_height, img2_width, img2_height
98 ),
99 InterpretError::RegionOutOfBounds {
100 bbox,
101 width,
102 height,
103 } => write!(
104 f,
105 "Region {}x{} at ({}, {}) falls outside the {}x{} image",
106 bbox.width, bbox.height, bbox.x, bbox.y, width, height
107 ),
108 InterpretError::Producer(e) => write!(f, "{}", e),
109 }
110 }
111}
112
113impl std::error::Error for InterpretError {}
114
115const REFINE_DELTA_FLOOR_SQ: f32 = 100.0;
116
117fn refine_change_mask_in_bboxes(
122 img1: &Image,
123 img2: &Image,
124 input_mask: &[bool],
125 bboxes: &[types::BoundingBox],
126 width: u32,
127) -> Vec<bool> {
128 let pixels1 = img1.as_u32();
129 let pixels2 = img2.as_u32();
130 let mut refined = input_mask.to_vec();
131 for bbox in bboxes {
132 for y in bbox.y..bbox.y + bbox.height {
133 for x in bbox.x..bbox.x + bbox.width {
134 let idx = (y * width + x) as usize;
135 if !refined[idx] {
136 continue;
137 }
138 let delta = color_delta(pixels1[idx], pixels2[idx], idx).abs() as f32;
139 if delta < REFINE_DELTA_FLOOR_SQ {
140 refined[idx] = false;
141 }
142 }
143 }
144 }
145 refined
146}
147
148fn count_mask_pixels(mask: &[bool], bbox: &types::BoundingBox, width: u32) -> u32 {
149 let mut pixel_count = 0u32;
150 for y in bbox.y..bbox.y + bbox.height {
151 for x in bbox.x..bbox.x + bbox.width {
152 if mask[(y * width + x) as usize] {
153 pixel_count += 1;
154 }
155 }
156 }
157 pixel_count
158}
159
160fn classify_region_with_mask(
161 img1: &Image,
162 img2: &Image,
163 mask: &[bool],
164 bbox: types::BoundingBox,
165) -> ChangeRegion {
166 let width = img1.width;
167 let height = img1.height;
168 let total_pixels = (width * height) as f64;
169 let pixel_count = count_mask_pixels(mask, &bbox, width);
170 let percentage = if total_pixels > 0.0 {
171 100.0 * pixel_count as f64 / total_pixels
172 } else {
173 0.0
174 };
175 let shape_stats = compute_shape_stats(mask, width, &bbox, pixel_count);
176 let shape = classify_shape(&shape_stats);
177 let position = classify_position(&bbox, width, height);
178
179 let color_delta = compute_color_delta(img1, img2, mask, &bbox, width);
180 let gradient_stats = compute_gradient_stats(img1, img2, mask, &bbox, width, height);
181 let luminance_ncc = compute_luminance_ncc(img1, img2, mask, &bbox, width);
182 let content = analyze_content(img1, img2, mask, &bbox, width, height);
183 let (change_type, signals) = classify_change_type(
184 &content,
185 &color_delta,
186 &gradient_stats,
187 &shape_stats,
188 &bbox,
189 luminance_ncc,
190 );
191
192 ChangeRegion {
193 bbox,
194 pixel_count,
195 percentage,
196 position,
197 shape,
198 shape_stats,
199 change_type,
200 signals,
201 confidence: signals.confidence,
202 color_delta,
203 gradient: gradient_stats,
204 }
205}
206
207pub fn classify_region(
212 img1: &Image,
213 img2: &Image,
214 mask: &[bool],
215 bbox: types::BoundingBox,
216) -> ChangeRegion {
217 classify_region_with_mask(img1, img2, mask, bbox)
218}
219
220pub fn classify_regions(
227 img1: &Image,
228 img2: &Image,
229 mask: &[bool],
230 bboxes: &[types::BoundingBox],
231) -> Vec<ChangeRegion> {
232 let width = img1.width;
233 let refined = refine_change_mask_in_bboxes(img1, img2, mask, bboxes, width);
234 let mut regions: Vec<ChangeRegion> = bboxes
235 .iter()
236 .copied()
237 .map(|bbox| classify_region_with_mask(img1, img2, &refined, bbox))
238 .collect();
239 detect_shifts(&mut regions, img1, img2, &refined, width);
240 regions
241}
242
243pub enum ChangeSource<'a> {
250 Diff {
258 result: &'a DiffResult,
259 output: &'a Image,
260 },
261 Ssim {
266 outcome: &'a SsimOutcome,
267 floor: f32,
268 },
269 Regions(&'a [BoundingBox]),
272}
273
274const MASK_DIFF_COLOR: [u8; 3] = [255, 0, 0];
278const MASK_DIFF_COLOR_ALT: [u8; 3] = [0, 0, 255];
279const MASK_AA_COLOR: [u8; 3] = [255, 255, 0];
280
281pub fn mask_options(options: &DiffOptions) -> DiffOptions {
287 DiffOptions {
288 aa_color: MASK_AA_COLOR,
289 diff_color: MASK_DIFF_COLOR,
290 diff_color_alt: Some(MASK_DIFF_COLOR_ALT),
291 ..options.clone()
292 }
293}
294
295pub fn recolor_output(output: &mut Image, options: &DiffOptions) {
299 let diff_color_alt = options.diff_color_alt.unwrap_or(options.diff_color);
300
301 for pixel in output.data.chunks_exact_mut(4) {
302 let color = match [pixel[0], pixel[1], pixel[2]] {
303 MASK_DIFF_COLOR => options.diff_color,
304 MASK_DIFF_COLOR_ALT => diff_color_alt,
305 MASK_AA_COLOR => options.aa_color,
306 _ => continue,
307 };
308 pixel[..3].copy_from_slice(&color);
309 }
310}
311
312pub fn interpret_diff(
318 image1: &Image,
319 image2: &Image,
320 output: Option<&mut Image>,
321 options: &DiffOptions,
322) -> Result<InterpretResult, InterpretError> {
323 let mut scratch;
327 let (diff_output, retain_output) = match output {
328 Some(image) => (image, true),
329 None => {
330 scratch = Image::new_uninit(image1.width, image1.height);
331 (&mut scratch, false)
332 }
333 };
334
335 let result = blazediff::diff(image1, image2, Some(diff_output), &mask_options(options))
336 .map_err(|e| InterpretError::Producer(e.to_string()))?;
337
338 let interpreted = interpret(
340 image1,
341 image2,
342 ChangeSource::Diff {
343 result: &result,
344 output: diff_output,
345 },
346 )?;
347
348 if retain_output {
349 recolor_output(diff_output, options);
350 }
351
352 Ok(interpreted)
353}
354
355pub fn interpret(
364 image1: &Image,
365 image2: &Image,
366 source: ChangeSource<'_>,
367) -> Result<InterpretResult, InterpretError> {
368 let (width, height) = (image1.width, image1.height);
369 if width != image2.width || height != image2.height {
370 return Err(InterpretError::SizeMismatch {
371 img1_width: width,
372 img1_height: height,
373 img2_width: image2.width,
374 img2_height: image2.height,
375 });
376 }
377
378 if let ChangeSource::Diff { result, .. } = &source {
381 if result.identical {
382 return Ok(InterpretResult {
383 summary: "Images are identical".to_string(),
384 diff_count: 0,
385 total_regions: 0,
386 regions: Vec::new(),
387 severity: classify_severity(0.0),
388 diff_percentage: 0.0,
389 width,
390 height,
391 });
392 }
393 }
394
395 let total_pixels = (width as f64) * (height as f64);
396
397 let (mut regions, mut mask, counts) = match source {
400 ChangeSource::Diff { result, output } => {
401 let mask = extract_change_mask(&output.data, width, height);
402 let regions = detect_regions(&mask, width, height)
403 .into_iter()
404 .map(|component| {
405 let mut region =
406 classify_region_with_mask(image1, image2, &mask, component.bbox);
407 region.pixel_count = component.pixel_count;
408 region.percentage = if total_pixels > 0.0 {
409 100.0 * component.pixel_count as f64 / total_pixels
410 } else {
411 0.0
412 };
413 region
414 })
415 .collect::<Vec<_>>();
416 (
417 regions,
418 mask,
419 Some((result.diff_count, result.diff_percentage)),
420 )
421 }
422 ChangeSource::Ssim { outcome, floor } => {
423 let boxes = regions_from_score_map(
424 &outcome.map,
425 outcome.map_width,
426 outcome.map_height,
427 width,
428 height,
429 floor,
430 );
431 let (regions, mask) = classify_coarse(image1, image2, &boxes)?;
432 (regions, mask, None)
433 }
434 ChangeSource::Regions(boxes) => {
435 let (regions, mask) = classify_coarse(image1, image2, boxes)?;
436 (regions, mask, None)
437 }
438 };
439
440 detect_shifts(&mut regions, image1, image2, &mask, width);
441 regions.retain(|region| region.change_type != ChangeType::RenderingNoise);
442
443 let (diff_count, diff_percentage) = counts.unwrap_or_else(|| {
448 let count = count_masked_union(&mut mask, width, ®ions);
449 let percentage = if total_pixels > 0.0 {
450 100.0 * count as f64 / total_pixels
451 } else {
452 0.0
453 };
454 (count, percentage)
455 });
456
457 if regions.is_empty() && diff_count == 0 {
458 return Ok(InterpretResult {
459 summary: "Images are identical".to_string(),
460 diff_count: 0,
461 total_regions: 0,
462 regions: Vec::new(),
463 severity: classify_severity(0.0),
464 diff_percentage: 0.0,
465 width,
466 height,
467 });
468 }
469
470 let severity = classify_severity(diff_percentage);
471 let summary = build_summary(®ions, &severity, diff_percentage);
472
473 Ok(InterpretResult {
474 summary,
475 diff_count,
476 total_regions: regions.len(),
477 regions,
478 severity,
479 diff_percentage,
480 width,
481 height,
482 })
483}
484
485fn classify_coarse(
488 image1: &Image,
489 image2: &Image,
490 boxes: &[BoundingBox],
491) -> Result<(Vec<ChangeRegion>, Vec<bool>), InterpretError> {
492 let (width, height) = (image1.width, image1.height);
493 let total_pixels = (width as f64) * (height as f64);
494
495 for bbox in boxes {
496 let right = bbox.x.checked_add(bbox.width);
499 let bottom = bbox.y.checked_add(bbox.height);
500 let fits = matches!((right, bottom), (Some(r), Some(b)) if r <= width && b <= height);
501 if !fits {
502 return Err(InterpretError::RegionOutOfBounds {
503 bbox: *bbox,
504 width,
505 height,
506 });
507 }
508 }
509
510 let mut mask = vec![false; (width * height) as usize];
511 for bbox in boxes {
512 for y in bbox.y..bbox.y + bbox.height {
513 let row = (y * width) as usize;
514 mask[row + bbox.x as usize..row + (bbox.x + bbox.width) as usize].fill(true);
515 }
516 }
517 let refined = refine_change_mask_in_bboxes(image1, image2, &mask, boxes, width);
518
519 let regions = boxes
520 .iter()
521 .copied()
522 .map(|bbox| {
523 let mut region = classify_region_with_mask(image1, image2, &refined, bbox);
524 region.percentage = if total_pixels > 0.0 {
525 100.0 * region.pixel_count as f64 / total_pixels
526 } else {
527 0.0
528 };
529 region
530 })
531 .collect();
532
533 Ok((regions, refined))
534}
535
536fn count_masked_union(mask: &mut [bool], width: u32, regions: &[ChangeRegion]) -> u32 {
544 let mut count = 0;
545 for region in regions {
546 let bbox = region.bbox;
547 for y in bbox.y..bbox.y + bbox.height {
548 let row = (y * width) as usize;
549 for pixel in &mut mask[row + bbox.x as usize..row + (bbox.x + bbox.width) as usize] {
550 if *pixel {
551 *pixel = false;
552 count += 1;
553 }
554 }
555 }
556 }
557 count
558}
559
560#[cfg(test)]
561mod region_entry_tests {
562 use super::*;
563 use crate::test_helpers::*;
564
565 fn bbox(x: u32, y: u32, width: u32, height: u32) -> BoundingBox {
566 BoundingBox {
567 x,
568 y,
569 width,
570 height,
571 }
572 }
573
574 #[test]
575 fn no_regions_means_identical() {
576 let a = make_solid_image(64, 64, 10, 20, 30);
577 let result = interpret(&a, &a, ChangeSource::Regions(&[])).unwrap();
578 assert_eq!(result.summary, "Images are identical");
579 assert_eq!(result.diff_count, 0);
580 assert_eq!(result.total_regions, 0);
581 }
582
583 #[test]
584 fn a_region_over_unchanged_pixels_contributes_nothing() {
585 let a = make_solid_image(64, 64, 10, 20, 30);
586 let result = interpret(&a, &a, ChangeSource::Regions(&[bbox(8, 8, 16, 16)])).unwrap();
587 assert_eq!(result.diff_count, 0);
588 assert_eq!(result.diff_percentage, 0.0);
589 }
590
591 #[test]
592 fn a_changed_region_is_classified() {
593 let a = make_solid_image(64, 64, 255, 255, 255);
594 let mut b = make_solid_image(64, 64, 255, 255, 255);
595 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
596
597 let result = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 8, 8)])).unwrap();
598 assert_eq!(result.total_regions, 1);
599 assert_eq!(result.diff_count, 64);
600 assert_eq!(result.regions[0].bbox, bbox(16, 16, 8, 8));
601 }
602
603 #[test]
607 fn overlapping_regions_count_a_shared_pixel_once() {
608 let a = make_solid_image(64, 64, 255, 255, 255);
609 let mut b = make_solid_image(64, 64, 255, 255, 255);
610 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
611
612 let result = interpret(
614 &a,
615 &b,
616 ChangeSource::Regions(&[bbox(8, 8, 24, 24), bbox(16, 16, 16, 16)]),
617 )
618 .unwrap();
619
620 assert_eq!(result.diff_count, 64);
621 assert_eq!(result.diff_percentage, 100.0 * 64.0 / (64.0 * 64.0));
622 }
623
624 #[test]
628 fn a_coarse_region_still_yields_per_pixel_counts() {
629 let a = make_solid_image(64, 64, 255, 255, 255);
630 let mut b = make_solid_image(64, 64, 255, 255, 255);
631 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
632
633 let exact = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 8, 8)])).unwrap();
634 let coarse = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 16, 16)])).unwrap();
636
637 assert_eq!(coarse.diff_count, exact.diff_count);
638 assert_eq!(coarse.regions[0].pixel_count, exact.regions[0].pixel_count);
639 }
640
641 #[test]
642 fn mismatched_sizes_are_rejected() {
643 let a = make_solid_image(64, 64, 0, 0, 0);
644 let b = make_solid_image(32, 64, 0, 0, 0);
645 assert!(matches!(
646 interpret(&a, &b, ChangeSource::Regions(&[])),
647 Err(InterpretError::SizeMismatch { .. })
648 ));
649 }
650
651 #[test]
652 fn an_out_of_bounds_region_is_rejected_rather_than_panicking() {
653 let a = make_solid_image(64, 64, 0, 0, 0);
654 for out in [
655 bbox(60, 0, 8, 8),
656 bbox(0, 60, 8, 8),
657 bbox(u32::MAX, 0, 8, 8),
658 ] {
659 assert!(
660 matches!(
661 interpret(&a, &a, ChangeSource::Regions(&[out])),
662 Err(InterpretError::RegionOutOfBounds { .. })
663 ),
664 "{out:?} should be rejected"
665 );
666 }
667 }
668}
669
670pub fn regions_from_score_map(
684 map: &[f32],
685 map_width: usize,
686 map_height: usize,
687 width: u32,
688 height: u32,
689 floor: f32,
690) -> Vec<BoundingBox> {
691 if map_width == 0 || map_height == 0 || width == 0 || height == 0 {
692 return Vec::new();
693 }
694 if map.len() < map_width * map_height {
695 return Vec::new();
696 }
697
698 let changed: Vec<bool> = map[..map_width * map_height]
699 .iter()
700 .map(|score| !(*score >= floor))
701 .collect();
702
703 let scale =
708 |m: u32, size: u32, map_size: u32| (m as u64 * size as u64 / map_size as u64) as u32;
709
710 detect_regions(&changed, map_width as u32, map_height as u32)
711 .into_iter()
712 .filter_map(|component| {
713 let bbox = component.bbox;
714 let x = scale(bbox.x, width, map_width as u32);
715 let y = scale(bbox.y, height, map_height as u32);
716 let right = scale(bbox.x + bbox.width, width, map_width as u32);
717 let bottom = scale(bbox.y + bbox.height, height, map_height as u32);
718 (right > x && bottom > y).then_some(BoundingBox {
722 x,
723 y,
724 width: right - x,
725 height: bottom - y,
726 })
727 })
728 .collect()
729}
730
731#[cfg(test)]
732mod score_map_tests {
733 use super::*;
734
735 #[test]
736 fn an_all_similar_map_yields_no_regions() {
737 let map = vec![1.0f32; 4 * 4];
738 assert!(regions_from_score_map(&map, 4, 4, 64, 64, 0.99).is_empty());
739 }
740
741 #[test]
742 fn a_low_cell_becomes_a_scaled_box() {
743 let mut map = vec![1.0f32; 4 * 4];
744 map[5] = 0.1; let regions = regions_from_score_map(&map, 4, 4, 64, 64, 0.99);
746 assert_eq!(
747 regions,
748 vec![BoundingBox {
749 x: 16,
750 y: 16,
751 width: 16,
752 height: 16
753 }]
754 );
755 }
756
757 #[test]
762 fn a_cell_maps_to_its_exact_slice_of_a_non_divisible_image() {
763 let mut map = vec![1.0f32; 3 * 3];
764 map[4] = 0.1; let regions = regions_from_score_map(&map, 3, 3, 64, 64, 0.99);
766 assert_eq!(
767 regions,
768 vec![BoundingBox {
769 x: 21,
770 y: 21,
771 width: 21,
772 height: 21
773 }]
774 );
775 }
776
777 #[test]
778 fn adjacent_cells_merge_into_one_region() {
779 let mut map = vec![1.0f32; 4 * 4];
780 map[5] = 0.1;
781 map[6] = 0.1;
782 let regions = regions_from_score_map(&map, 4, 4, 64, 64, 0.99);
783 assert_eq!(regions.len(), 1);
784 assert_eq!(regions[0].width, 32);
785 }
786
787 #[test]
788 fn nan_counts_as_changed() {
789 let mut map = vec![1.0f32; 4 * 4];
790 map[0] = f32::NAN;
791 assert_eq!(regions_from_score_map(&map, 4, 4, 64, 64, 0.99).len(), 1);
792 }
793
794 #[test]
795 fn a_degenerate_map_yields_nothing() {
796 assert!(regions_from_score_map(&[], 0, 0, 64, 64, 0.99).is_empty());
797 assert!(regions_from_score_map(&[0.0], 4, 4, 64, 64, 0.99).is_empty());
798 }
799}
800
801#[cfg(test)]
802mod source_parity_tests {
803 use super::*;
804 use crate::test_helpers::*;
805 use blazediff_ssim::{ssim, Plane, Rgba8, SsimOptions};
806
807 fn pair() -> (Image, Image) {
808 let a = make_solid_image(64, 64, 255, 255, 255);
809 let mut b = make_solid_image(64, 64, 255, 255, 255);
810 fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
811 (a, b)
812 }
813
814 #[test]
818 fn a_diff_and_an_ssim_map_agree() {
819 let (a, b) = pair();
820 let options = DiffOptions::default();
821
822 let mut output = Image::new_uninit(a.width, a.height);
823 let result = blazediff::diff(&a, &b, Some(&mut output), &mask_options(&options)).unwrap();
824 let from_diff = interpret(
825 &a,
826 &b,
827 ChangeSource::Diff {
828 result: &result,
829 output: &output,
830 },
831 )
832 .unwrap();
833
834 let plane = |image: &Image| Plane::from_rgba8(Rgba8::new(&image.data, 64, 64)).unwrap();
835 let outcome = ssim(&plane(&a), &plane(&b), &SsimOptions::default()).unwrap();
836 let from_ssim = interpret(
837 &a,
838 &b,
839 ChangeSource::Ssim {
840 outcome: &outcome,
841 floor: 0.99,
842 },
843 )
844 .unwrap();
845
846 assert_eq!(from_diff.total_regions, 1);
847 assert_eq!(from_ssim.total_regions, 1);
848
849 let (exact, coarse) = (from_diff.regions[0].bbox, from_ssim.regions[0].bbox);
852 assert_eq!(
853 exact,
854 BoundingBox {
855 x: 16,
856 y: 16,
857 width: 8,
858 height: 8
859 }
860 );
861 assert!(
862 coarse.x <= exact.x
863 && coarse.y <= exact.y
864 && coarse.x + coarse.width >= exact.x + exact.width
865 && coarse.y + coarse.height >= exact.y + exact.height,
866 "{coarse:?} should enclose {exact:?}"
867 );
868
869 assert_eq!(
871 from_diff.regions[0].change_type,
872 from_ssim.regions[0].change_type
873 );
874 assert_eq!(from_diff.diff_count, 64);
875 assert_eq!(from_ssim.diff_count, 64);
876 }
877
878 #[test]
879 fn an_unchanged_pair_is_identical_from_either_source() {
880 let a = make_solid_image(64, 64, 10, 20, 30);
881 let options = DiffOptions::default();
882
883 let mut output = Image::new_uninit(64, 64);
884 let result = blazediff::diff(&a, &a, Some(&mut output), &mask_options(&options)).unwrap();
885 let from_diff = interpret(
886 &a,
887 &a,
888 ChangeSource::Diff {
889 result: &result,
890 output: &output,
891 },
892 )
893 .unwrap();
894 assert_eq!(from_diff.summary, "Images are identical");
895
896 let plane = Plane::from_rgba8(Rgba8::new(&a.data, 64, 64)).unwrap();
897 let outcome = ssim(&plane, &plane, &SsimOptions::default()).unwrap();
898 let from_ssim = interpret(
899 &a,
900 &a,
901 ChangeSource::Ssim {
902 outcome: &outcome,
903 floor: 0.99,
904 },
905 )
906 .unwrap();
907 assert_eq!(from_ssim.summary, "Images are identical");
908 }
909}
910
911#[cfg(test)]
912mod diff_source_tests {
913
914 use super::*;
915 use crate::test_helpers::*;
916 use blazediff::{diff, DiffOptions};
917 use types::*;
918 #[test]
919 fn test_identical_images() {
920 let img1 = make_solid_image(100, 100, 128, 128, 128);
921 let img2 = make_solid_image(100, 100, 128, 128, 128);
922 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
923
924 assert_eq!(result.total_regions, 0);
925 assert!(result.regions.is_empty());
926 assert_eq!(result.severity, ChangeSeverity::Low);
927 assert_eq!(result.diff_percentage, 0.0);
928 assert_eq!(result.summary, "Images are identical");
929 }
930
931 #[test]
932 fn test_single_pixel_change_is_filtered_as_noise() {
933 let img1 = make_solid_image(100, 100, 128, 128, 128);
936 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
937 set_pixel(&mut img2, 50, 50, 130, 130, 130);
938
939 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
940
941 assert_eq!(result.total_regions, 0);
942 assert!(result.regions.is_empty());
943 }
944
945 #[test]
946 fn test_interpret_with_output_matches_diff() {
947 let img1 = make_solid_image(32, 32, 200, 200, 200);
948 let img2 = make_solid_image(32, 32, 50, 50, 50);
949 let options = DiffOptions {
950 include_aa: true,
951 diff_color_alt: Some([0, 128, 255]),
952 ..Default::default()
953 };
954 let mut expected = Image::new(32, 32);
955 let diff_result = diff(&img1, &img2, Some(&mut expected), &options).unwrap();
956 let mut actual = Image::new(32, 32);
957
958 let interpretation = interpret_diff(&img1, &img2, Some(&mut actual), &options).unwrap();
959
960 assert_eq!(interpretation.diff_count, diff_result.diff_count);
961 assert_eq!(actual.data, expected.data);
962 }
963
964 #[test]
965 fn test_interpret_with_grayscale_alt_color_keeps_regions() {
966 let img1 = make_solid_image(32, 32, 200, 200, 200);
967 let img2 = make_solid_image(32, 32, 50, 50, 50);
968 let options = DiffOptions {
969 include_aa: true,
970 diff_color_alt: Some([32, 32, 32]),
971 ..Default::default()
972 };
973 let mut output = Image::new(32, 32);
974
975 let result = interpret_diff(&img1, &img2, Some(&mut output), &options).unwrap();
976
977 assert_eq!(result.diff_count, 32 * 32);
978 assert!(result.total_regions > 0);
979 assert_eq!(&output.data[..4], &[32, 32, 32, 255]);
980 }
981
982 #[test]
983 fn test_block_addition() {
984 let img1 = make_solid_image(100, 100, 128, 128, 128);
985 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
986 fill_block(&mut img2, 0, 0, 40, 40, 255, 0, 0);
987
988 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
989
990 assert_eq!(result.total_regions, 1);
991 assert_eq!(result.regions[0].position, SpatialPosition::TopLeft);
992 assert_eq!(result.severity, ChangeSeverity::High);
993 assert_eq!(result.regions[0].change_type, ChangeType::Addition);
994 assert!(result.summary.contains("Content added"));
995 }
996
997 #[test]
998 fn test_block_deletion() {
999 let mut img1 = make_solid_image(100, 100, 128, 128, 128);
1000 fill_block(&mut img1, 0, 0, 40, 40, 255, 0, 0);
1001 let img2 = make_solid_image(100, 100, 128, 128, 128);
1002
1003 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1004
1005 assert_eq!(result.total_regions, 1);
1006 assert_eq!(result.regions[0].change_type, ChangeType::Deletion);
1007 assert!(result.summary.contains("Content removed"));
1008 }
1009
1010 #[test]
1011 fn test_scattered_additions() {
1012 let img1 = make_solid_image(100, 100, 128, 128, 128);
1013 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1014 fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1015 fill_block(&mut img2, 80, 80, 10, 10, 0, 255, 0);
1016
1017 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1018
1019 assert_eq!(result.total_regions, 2);
1020 let positions: Vec<SpatialPosition> = result.regions.iter().map(|r| r.position).collect();
1021 assert!(positions.contains(&SpatialPosition::TopLeft));
1022 assert!(positions.contains(&SpatialPosition::BottomRight));
1023 assert!(result
1024 .regions
1025 .iter()
1026 .all(|r| r.change_type == ChangeType::Addition));
1027 }
1028
1029 #[test]
1030 fn test_full_image_color_change() {
1031 let img1 = make_solid_image(100, 100, 0, 0, 0);
1032 let img2 = make_solid_image(100, 100, 255, 255, 255);
1033
1034 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1035
1036 assert_eq!(result.total_regions, 1);
1037 assert_eq!(result.severity, ChangeSeverity::High);
1038 assert!(result.diff_percentage > 90.0);
1039 assert_eq!(result.regions[0].shape, ChangeShape::SolidRegion);
1040 assert!(
1041 matches!(
1042 result.regions[0].change_type,
1043 ChangeType::ColorChange | ChangeType::ContentChange
1044 ),
1045 "Expected ColorChange or ContentChange for full image swap, got: {:?}",
1046 result.regions[0].change_type
1047 );
1048 }
1049
1050 #[test]
1051 fn test_severity_boundaries() {
1052 let img1 = make_solid_image(100, 100, 128, 128, 128);
1053
1054 let mut img2_low = make_solid_image(100, 100, 128, 128, 128);
1055 for i in 0..50 {
1056 set_pixel(&mut img2_low, i % 100, i / 100, 255, 0, 0);
1057 }
1058 let result_low = interpret_diff(&img1, &img2_low, None, &DiffOptions::default()).unwrap();
1059 assert_eq!(result_low.severity, ChangeSeverity::Low);
1060
1061 let mut img2_med = make_solid_image(100, 100, 128, 128, 128);
1062 fill_block(&mut img2_med, 0, 0, 20, 25, 255, 0, 0);
1063 let result_med = interpret_diff(&img1, &img2_med, None, &DiffOptions::default()).unwrap();
1064 assert_eq!(result_med.severity, ChangeSeverity::Medium);
1065
1066 let mut img2_high = make_solid_image(100, 100, 128, 128, 128);
1067 fill_block(&mut img2_high, 0, 0, 50, 50, 255, 0, 0);
1068 let result_high = interpret_diff(&img1, &img2_high, None, &DiffOptions::default()).unwrap();
1069 assert_eq!(result_high.severity, ChangeSeverity::High);
1070 }
1071
1072 #[test]
1073 fn test_json_roundtrip() {
1074 let img1 = make_solid_image(100, 100, 128, 128, 128);
1075 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1076 fill_block(&mut img2, 10, 10, 20, 20, 255, 0, 0);
1077
1078 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1079 let json = serde_json::to_string(&result).unwrap();
1080 let deserialized: InterpretResult = serde_json::from_str(&json).unwrap();
1081
1082 assert_eq!(result, deserialized);
1083 }
1084
1085 #[test]
1086 fn test_hollow_frame_is_addition() {
1087 let img1 = make_solid_image(100, 100, 128, 128, 128);
1088 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1089
1090 let bx = 35u32;
1091 let by = 35u32;
1092 let bw = 30u32;
1093 let bh = 30u32;
1094 for y in by..by + bh {
1095 for x in bx..bx + bw {
1096 if x == bx || x == bx + bw - 1 || y == by || y == by + bh - 1 {
1097 set_pixel(&mut img2, x, y, 255, 0, 0);
1098 }
1099 }
1100 }
1101
1102 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1103
1104 assert_eq!(result.total_regions, 1);
1105 assert_eq!(result.regions[0].shape, ChangeShape::ContourFrame);
1106 assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1107 }
1108
1109 #[test]
1110 fn test_sparse_noise() {
1111 let img1 = make_solid_image(100, 100, 128, 128, 128);
1112 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1113
1114 let bx = 10u32;
1115 let by = 10u32;
1116 let size = 80u32;
1117 for y in (by..by + size).step_by(6) {
1118 for x in bx..bx + size {
1119 set_pixel(&mut img2, x, y, 133, 133, 133);
1120 }
1121 }
1122 for x in (bx..bx + size).step_by(6) {
1123 for y in by..by + size {
1124 set_pixel(&mut img2, x, y, 133, 133, 133);
1125 }
1126 }
1127
1128 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1129
1130 for r in &result.regions {
1131 assert_eq!(
1132 r.change_type,
1133 ChangeType::Addition,
1134 "Expected Addition for sparse subtle grid, got: {:?}",
1135 r.change_type
1136 );
1137 }
1138 }
1139
1140 #[test]
1141 fn test_shift_detection() {
1142 let mut img1 = make_solid_image(100, 100, 255, 255, 255);
1143 fill_block(&mut img1, 10, 10, 20, 20, 40, 40, 40);
1144
1145 let mut img2 = make_solid_image(100, 100, 255, 255, 255);
1146 fill_block(&mut img2, 60, 60, 20, 20, 40, 40, 40);
1147
1148 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1149
1150 assert_eq!(result.total_regions, 2);
1151 assert!(
1152 result
1153 .regions
1154 .iter()
1155 .all(|r| r.change_type == ChangeType::Shift),
1156 "Expected both regions as Shift, got: {:?}",
1157 result
1158 .regions
1159 .iter()
1160 .map(|r| r.change_type)
1161 .collect::<Vec<_>>()
1162 );
1163 assert!(result.summary.contains("Content shifted"));
1164 }
1165
1166 #[test]
1167 fn test_size_mismatch_error() {
1168 let img1 = make_solid_image(100, 100, 128, 128, 128);
1169 let img2 = make_solid_image(200, 200, 128, 128, 128);
1170
1171 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default());
1172 assert!(result.is_err());
1173 }
1174
1175 #[test]
1176 fn test_summary_format() {
1177 let img1 = make_solid_image(100, 100, 128, 128, 128);
1178 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1179 fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1180 fill_block(&mut img2, 80, 80, 10, 10, 0, 255, 0);
1181
1182 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1183
1184 assert!(result.summary.contains("visual change detected"));
1185 assert!(result.summary.contains("2 regions"));
1186 }
1187
1188 #[test]
1189 fn test_summary_has_descriptions() {
1190 let img1 = make_solid_image(100, 100, 128, 128, 128);
1191 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1192 fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1193
1194 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1195
1196 assert!(result.summary.contains("region"));
1197 assert!(result.summary.lines().count() >= 2);
1198 }
1199
1200 #[test]
1201 fn test_signals_populated() {
1202 let img1 = make_solid_image(100, 100, 128, 128, 128);
1203 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1204 fill_block(&mut img2, 0, 0, 40, 40, 255, 0, 0);
1205
1206 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1207
1208 assert_eq!(result.total_regions, 1);
1209 let region = &result.regions[0];
1210 assert!(region.confidence > 0.0);
1211 assert!(region.signals.blends_with_bg_in_img1);
1212 }
1213
1214 #[test]
1215 fn test_grey_shift_is_addition() {
1216 let img1 = make_solid_image(100, 100, 128, 128, 128);
1217 let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1218 fill_block(&mut img2, 0, 0, 40, 40, 220, 220, 220);
1219
1220 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1221
1222 assert_eq!(result.total_regions, 1);
1223 assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1224 }
1225
1226 #[test]
1227 fn test_shift_not_detected_for_different_sizes() {
1228 let mut img1 = make_solid_image(200, 100, 255, 255, 255);
1229 fill_block(&mut img1, 10, 10, 40, 40, 40, 40, 40);
1230
1231 let mut img2 = make_solid_image(200, 100, 255, 255, 255);
1232 fill_block(&mut img2, 140, 10, 10, 10, 40, 40, 40);
1233
1234 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1235
1236 let shift_count = result
1237 .regions
1238 .iter()
1239 .filter(|r| r.change_type == ChangeType::Shift)
1240 .count();
1241 assert_eq!(
1242 shift_count, 0,
1243 "Different-sized blocks should not be detected as shift"
1244 );
1245 }
1246
1247 #[test]
1248 fn test_shift_not_detected_for_different_luminance() {
1249 let mut img1 = make_solid_image(200, 100, 200, 200, 200);
1250 fill_block(&mut img1, 10, 10, 30, 30, 20, 20, 20);
1251
1252 let mut img2 = make_solid_image(200, 100, 200, 200, 200);
1253 fill_block(&mut img2, 140, 10, 30, 30, 200, 50, 50);
1254
1255 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1256
1257 let shift_count = result
1258 .regions
1259 .iter()
1260 .filter(|r| r.change_type == ChangeType::Shift)
1261 .count();
1262 assert_eq!(
1263 shift_count, 0,
1264 "Blocks with different luminance should not be shift"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_no_shift_when_only_additions() {
1270 let img1 = make_solid_image(200, 100, 200, 200, 200);
1271 let mut img2 = make_solid_image(200, 100, 200, 200, 200);
1272 fill_block(&mut img2, 10, 10, 30, 30, 40, 40, 40);
1273 fill_block(&mut img2, 140, 10, 30, 30, 40, 40, 40);
1274
1275 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1276
1277 let shift_count = result
1278 .regions
1279 .iter()
1280 .filter(|r| r.change_type == ChangeType::Shift)
1281 .count();
1282 assert_eq!(
1283 shift_count, 0,
1284 "Two additions with no deletion cannot form a shift"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_object_on_both_images_is_not_addition_or_deletion() {
1290 let mut img1 = make_solid_image(100, 100, 200, 200, 200);
1291 fill_block(&mut img1, 30, 30, 40, 40, 255, 0, 0);
1292 let mut img2 = make_solid_image(100, 100, 200, 200, 200);
1293 fill_block(&mut img2, 30, 30, 40, 40, 0, 0, 255);
1294
1295 let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1296
1297 for r in &result.regions {
1298 assert!(
1299 r.change_type != ChangeType::Addition && r.change_type != ChangeType::Deletion,
1300 "Object present in both images should not be Addition or Deletion, got {:?}",
1301 r.change_type
1302 );
1303 }
1304 }
1305}