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