Skip to main content

blazediff_interpret/
lib.rs

1//! Structured region analysis: given two images and a set of changed regions,
2//! describe what changed in each one.
3//!
4//! The classifier is deliberately separate from whatever *found* the regions.
5//! `blazediff` finds them by connected components over a pixel-diff mask,
6//! `blazediff-ssim` can find them by thresholding a score map, and a caller can
7//! simply supply them — DOM rectangles, a JS-side diff, anything. All three get
8//! identical per-pixel classification, because a coarse region is refined
9//! against the source pixels before any statistic is computed.
10//!
11//! ```
12//! use blazediff_interpret::{interpret, types::BoundingBox, ChangeSource};
13//! # use blazediff_shared::Image;
14//! # let (a, b) = (Image::new(64, 64), Image::new(64, 64));
15//! let result = interpret(&a, &b, ChangeSource::Regions(&[BoundingBox { x: 8, y: 8, width: 16, height: 16 }]))?;
16//! println!("{}", result.summary);
17//! # Ok::<(), blazediff_interpret::InterpretError>(())
18//! ```
19
20mod 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// Small image constructors shared by this crate's tests and by `blazediff`'s
33// end-to-end interpret tests, which live there because they need `diff`.
34#[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
52/// YIQ squared-delta floor for treating a pixel as actually changed.
53/// 100.0 corresponds to a YIQ-weighted distance of 10, roughly equivalent to
54/// a perceptual delta of ~0.017 — filters near-identical pixels without
55/// throwing away genuine edits.
56pub use region::{detect_regions, extract_change_mask, ComponentInfo};
57pub use severity::classify_severity;
58// Exposed for producers that already hold an exact per-pixel mask and so must
59// not go through `classify_regions`, which refines a coarse one.
60pub use shifts::detect_shifts;
61pub use summary::build_summary;
62pub use types::{BoundingBox, ChangeRegion, ChangeSeverity, InterpretResult};
63
64/// Why a set of regions could not be interpreted.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum InterpretError {
67    /// The two images are not the same size, so one mask cannot describe both.
68    SizeMismatch {
69        img1_width: u32,
70        img1_height: u32,
71        img2_width: u32,
72        img2_height: u32,
73    },
74    /// A supplied region falls outside the image. Regions arrive from callers
75    /// now — including across the wasm and N-API boundaries — so this is a
76    /// rejection rather than an out-of-bounds panic.
77    RegionOutOfBounds {
78        bbox: BoundingBox,
79        width: u32,
80        height: u32,
81    },
82    /// The diff or metric this was asked to interpret failed first.
83    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
117/// Refine a coarse mask down to the actually-changed pixels inside the given
118/// bboxes. Pixels marked true in `input_mask` but with a tiny YIQ delta between
119/// `img1` and `img2` are dropped. Used by classifier-only paths so callers can
120/// pass a bbox-filled mask without polluting statistics with unchanged content.
121fn 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
207/// Classify a known change region against a provided full-image change mask.
208///
209/// The mask is evaluated only inside `bbox`, so callers may pass a sparse mask
210/// with one or more labeled regions already marked.
211pub 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
220/// Classify multiple known regions, then run the same shift relabeling pass used
221/// by `interpret()` so classifier-only verification can evaluate final labels.
222///
223/// The caller-supplied `mask` may be coarse (e.g., bbox-filled for verifier
224/// tooling); we refine it to actually-changed pixels inside each bbox before
225/// classification so per-pixel statistics aren't diluted by unchanged content.
226pub 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
243/// Where the change information came from.
244///
245/// Each variant is what one of the producers hands back, taken by name: this
246/// crate sits above them and consumes their results, rather than either of them
247/// depending on it. What differs between the variants is only the precision of
248/// the description, and whether it needs refining.
249pub enum ChangeSource<'a> {
250    /// What [`blazediff::diff`] returns: its result plus the visualization it
251    /// wrote. Changed pixels are the non-grayscale ones, so the mask is exact
252    /// and is used as-is; the counts come from the diff rather than being
253    /// recomputed, because it knows which pixels it excluded as anti-aliasing.
254    ///
255    /// The diff must have been run with [`mask_options`] so the visualization
256    /// is readable as a mask whatever colours the caller wanted.
257    Diff {
258        result: &'a DiffResult,
259        output: &'a Image,
260    },
261    /// What any [`blazediff_ssim`] metric returns. Cells scoring below `floor`
262    /// are changed. The map's grid is coarser than a pixel, so the boxes it
263    /// yields are refined against the source pixels before anything is
264    /// measured.
265    Ssim {
266        outcome: &'a SsimOutcome,
267        floor: f32,
268    },
269    /// Regions the caller already knows — DOM rectangles, a crop list, anything.
270    /// Refined like a score map, so they may be as coarse as you like.
271    Regions(&'a [BoundingBox]),
272}
273
274/// Stable non-grayscale marker colors, so a mask can be read back out of a
275/// diff visualization whatever colours the caller asked for — including
276/// grayscale ones, which would otherwise be indistinguishable from background.
277const 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
281/// The [`DiffOptions`] a diff must run with for [`ChangeSource::Diff`] to be
282/// able to recover its mask.
283///
284/// Everything except the three marker colours is taken from `options`, so
285/// threshold and anti-aliasing behave exactly as the caller asked.
286pub 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
295/// Repaint a mask-coloured visualization in the caller's palette.
296///
297/// Call it after [`interpret`] has read the mask, never before.
298pub 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
312/// Run a pixel diff and interpret it, optionally keeping the visualization.
313///
314/// The convenience path for the common case: it applies [`mask_options`],
315/// runs [`blazediff::diff`], interprets the result, and repaints the output in
316/// the caller's colours afterwards.
317pub fn interpret_diff(
318    image1: &Image,
319    image2: &Image,
320    output: Option<&mut Image>,
321    options: &DiffOptions,
322) -> Result<InterpretResult, InterpretError> {
323    // The diff has to paint its mask somewhere, so a caller that doesn't want
324    // the visualization still needs a buffer — but only that caller: allocating
325    // one unconditionally would double the peak memory of every call that does.
326    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    // Read the mask out of the marker colours before repainting them.
339    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
355/// Describe what changed between two images.
356///
357/// The single entry point: a pixel diff, a similarity metric and a caller
358/// passing its own boxes all come through here and get identical treatment,
359/// because the statistics are computed from the source pixels either way.
360///
361/// `diff_count` therefore means the same thing on every path — actually-changed
362/// pixels, never map windows.
363pub 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    // A diff that found nothing may leave its output buffer *uninitialized* —
379    // see `Image::new_uninit` — so the mask must not be read in that case.
380    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    // Each arm produces the regions, the mask the shift pass should read, and
398    // — for the diff, which knows better than we do — its own counts.
399    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    // A coarse source has no authoritative count of its own, so it comes from
444    // the pixels that survived refinement — counted over the union of the
445    // boxes, because a coarse source is free to overlap them and DOM
446    // rectangles routinely do.
447    let (diff_count, diff_percentage) = counts.unwrap_or_else(|| {
448        let count = count_masked_union(&mut mask, width, &regions);
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(&regions, &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
485/// Classify boxes that may be coarser than a pixel: fill them into a mask,
486/// refine that against the source pixels, then measure.
487fn 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        // u32 arithmetic, so check the sums for overflow rather than trusting
497        // `x + width <= image_width` to stay in range.
498        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
536/// Count the changed pixels under `regions`, charging one shared by two boxes
537/// only once.
538///
539/// Overlap is normal for a coarse source — DOM rectangles nest and overlap —
540/// and summing each region's own `pixel_count` would count the shared pixels
541/// twice, which can push `diff_percentage` past 100%. Each pixel is cleared as
542/// it is counted, so a second box over it finds nothing left to count.
543fn 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    /// Caller-supplied boxes overlap all the time — nested DOM rectangles do —
604    /// and a pixel under two of them still changed once. Summing each region's
605    /// own count would report it twice and can push the percentage past 100.
606    #[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        // Both boxes enclose the whole 8x8 change.
613        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    /// The point of the regions-in API: a producer that only knows a coarse
625    /// box still gets per-pixel counts, because the mask is refined against the
626    /// source pixels before anything is measured.
627    #[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        // Quantized to a 16px grid, as an SSIM window map would give.
635        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
670/// Turn a lower-resolution scalar score map into regions in image coordinates.
671///
672/// This is how a similarity metric becomes a region producer: threshold its
673/// local map at `floor`, take connected components at map resolution, then
674/// scale each box back up. The result is deliberately coarse — a box is only
675/// ever as precise as the map's grid — but [`interpret`] refines
676/// against the source pixels before measuring anything, so the statistics it
677/// derives are still per-pixel.
678///
679/// Scores are "higher is more similar", matching `blazediff-ssim`, so a cell
680/// counts as changed when it falls *below* `floor`. `NaN` cells count as
681/// changed: `ms-ssim` with product pooling yields `NaN` on globally
682/// anticorrelated content, which is emphatically not a match.
683pub 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    // Map cell `m` covers image pixels [m * size / map_size, (m + 1) * size / map_size),
704    // so the same floor division gives both edges and the cells tile the image
705    // exactly. Rounding the upper edge up instead would widen every box by a
706    // pixel and make the boxes of two adjacent components overlap.
707    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            // A map cell can scale to zero pixels when the map is larger than
719            // the image, which `ssim`'s 'valid' convolution never produces but
720            // a caller-supplied map might.
721            (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; // cell (1, 1) of a 4x4 map over a 64x64 image
745        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    /// The cells tile the image exactly, so a box stops where the next cell
758    /// starts — including when the image size isn't a multiple of the map's,
759    /// which is every 'valid' convolution the metrics produce. Rounding the
760    /// upper edge up instead would hand two adjacent components a shared pixel.
761    #[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; // cell (1, 1) of a 3x3 map over a 64x64 image
765        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    /// A pixel diff and an SSIM map are two descriptions of the same change, so
815    /// interpreting either must reach the same conclusion. That symmetry is the
816    /// whole point of one entry point taking both producers' results.
817    #[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        // The boxes differ, and are meant to: the diff knows the change to the
850        // pixel, the map only to its window. The coarse one encloses it.
851        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        // What must match is everything measured from the pixels.
870        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        // Subtle single-pixel deltas sit below interpret()'s noise floor, so
934        // they don't show up as actionable regions.
935        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}