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