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 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// Small image constructors shared by this crate's tests and by `blazediff`'s
36// end-to-end interpret tests, which live there because they need `diff`.
37#[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
56/// YIQ squared-delta floor for treating a pixel as actually changed.
57/// 100.0 corresponds to a YIQ-weighted distance of 10, roughly equivalent to
58/// a perceptual delta of ~0.017 — filters near-identical pixels without
59/// throwing away genuine edits.
60pub use region::{
61    detect_regions, extract_change_mask, merge_overlapping_components, ChangeDensity, ComponentInfo,
62};
63pub use severity::classify_severity;
64// Exposed for producers that already hold an exact per-pixel mask and so must
65// not go through `classify_regions`, which refines a coarse one.
66pub use shifts::detect_shifts;
67pub use summary::build_summary;
68pub use types::{BoundingBox, ChangeRegion, ChangeSeverity, InterpretResult};
69
70/// Why a set of regions could not be interpreted.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum InterpretError {
73    /// The two images are not the same size, so one mask cannot describe both.
74    SizeMismatch {
75        img1_width: u32,
76        img1_height: u32,
77        img2_width: u32,
78        img2_height: u32,
79    },
80    /// A supplied region falls outside the image. Regions arrive from callers
81    /// now — including across the wasm and N-API boundaries — so this is a
82    /// rejection rather than an out-of-bounds panic.
83    RegionOutOfBounds {
84        bbox: BoundingBox,
85        width: u32,
86        height: u32,
87    },
88    /// The diff or metric this was asked to interpret failed first.
89    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
123/// Components below this size never survive detection: single-digit-pixel
124/// specks are compression jitter at any image scale.
125const SPECK_FLOOR: u32 = 8;
126/// Component size that counts as a "speck" for the noise census.
127const NOISE_CENSUS_PIXELS: u32 = 64;
128/// Pixels of noise floor added per censused speck. A pair with hundreds of
129/// speck components (a recompressed photo) raises its own floor into the
130/// hundreds of pixels; a clean render keeps the minimum.
131const NOISE_FLOOR_PER_SPECK: f64 = 3.0;
132/// Noise floor for perfectly clean pairs.
133const NOISE_FLOOR_MIN: u32 = 12;
134/// Bbox-merge slack: components this close are one change. Horizontal slack
135/// is wider than vertical because text fragments on a line sit further apart
136/// than the strokes within them.
137const MERGE_SLACK_X: u32 = 12;
138const MERGE_SLACK_Y: u32 = 8;
139/// Reported-bbox margin: one third of the region dimension, clamped.
140const MARGIN_DIVISOR: u32 = 3;
141const MARGIN_MIN: u32 = 2;
142const MARGIN_MAX: u32 = 12;
143
144/// Refine a coarse mask down to the actually-changed pixels inside the given
145/// bboxes. Pixels marked true in `input_mask` but with a tiny YIQ delta between
146/// `img1` and `img2` are dropped. Used by classifier-only paths so callers can
147/// pass a bbox-filled mask without polluting statistics with unchanged content.
148fn 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        // A caller claimed a change here and the pixels do differ, just all
177        // below the floor (a sub-threshold edit, e.g. a subtle uniform
178        // recolor). Refining to nothing would erase the region and zero out
179        // every statistic, so keep the caller's mask for this bbox. Byte-
180        // identical content stays refined away.
181        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
255/// Classify a known change region against a provided full-image change mask.
256///
257/// The mask is evaluated only inside `bbox`, so callers may pass a sparse mask
258/// with one or more labeled regions already marked.
259pub 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
268/// Classify multiple known regions, then run the same shift relabeling pass used
269/// by `interpret()` so classifier-only verification can evaluate final labels.
270///
271/// The caller-supplied `mask` may be coarse (e.g., bbox-filled for verifier
272/// tooling); we refine it to actually-changed pixels inside each bbox before
273/// classification so per-pixel statistics aren't diluted by unchanged content.
274pub 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
291/// Where the change information came from.
292///
293/// Each variant is what one of the producers hands back, taken by name: this
294/// crate sits above them and consumes their results, rather than either of them
295/// depending on it. What differs between the variants is only the precision of
296/// the description, and whether it needs refining.
297pub enum ChangeSource<'a> {
298    /// What [`blazediff::diff`] returns: its result plus the visualization it
299    /// wrote. Changed pixels are the non-grayscale ones, so the mask is exact
300    /// and is used as-is; the counts come from the diff rather than being
301    /// recomputed, because it knows which pixels it excluded as anti-aliasing.
302    ///
303    /// The diff must have been run with [`mask_options`] so the visualization
304    /// is readable as a mask whatever colours the caller wanted.
305    Diff {
306        result: &'a DiffResult,
307        output: &'a Image,
308    },
309    /// What any [`blazediff_ssim`] metric returns. Cells scoring below `floor`
310    /// are changed. The map's grid is coarser than a pixel, so the boxes it
311    /// yields are refined against the source pixels before anything is
312    /// measured.
313    Ssim {
314        outcome: &'a SsimOutcome,
315        floor: f32,
316    },
317    /// Regions the caller already knows — DOM rectangles, a crop list, anything.
318    /// Refined like a score map, so they may be as coarse as you like.
319    Regions(&'a [BoundingBox]),
320}
321
322/// Stable non-grayscale marker colors, so a mask can be read back out of a
323/// diff visualization whatever colours the caller asked for — including
324/// grayscale ones, which would otherwise be indistinguishable from background.
325const 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
329/// The [`DiffOptions`] a diff must run with for [`ChangeSource::Diff`] to be
330/// able to recover its mask.
331///
332/// Everything except the three marker colours is taken from `options`, so
333/// threshold and anti-aliasing behave exactly as the caller asked.
334pub 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
343/// Repaint a mask-coloured visualization in the caller's palette.
344///
345/// Call it after [`interpret`] has read the mask, never before.
346pub 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
360/// Run a pixel diff and interpret it, optionally keeping the visualization.
361///
362/// The convenience path for the common case: it applies [`mask_options`],
363/// runs [`blazediff::diff`], interprets the result, and repaints the output in
364/// the caller's colours afterwards.
365pub fn interpret_diff(
366    image1: &Image,
367    image2: &Image,
368    output: Option<&mut Image>,
369    options: &DiffOptions,
370) -> Result<InterpretResult, InterpretError> {
371    // The diff has to paint its mask somewhere, so a caller that doesn't want
372    // the visualization still needs a buffer — but only that caller: allocating
373    // one unconditionally would double the peak memory of every call that does.
374    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    // Read the mask out of the marker colours before repainting them.
387    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
403/// Describe what changed between two images.
404///
405/// The single entry point: a pixel diff, a similarity metric and a caller
406/// passing its own boxes all come through here and get identical treatment,
407/// because the statistics are computed from the source pixels either way.
408///
409/// `diff_count` therefore means the same thing on every path — actually-changed
410/// pixels, never map windows.
411pub 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    // A diff that found nothing may leave its output buffer *uninitialized* —
427    // see `Image::new_uninit` — so the mask must not be read in that case.
428    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    // Each arm produces the regions, the mask the shift pass should read, and
448    // — for the diff, which knows better than we do — its own counts.
449    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            // Noise census: the count of speck-sized components is a robust
454            // readout of how noisy this pair is. Clean renders produce none;
455            // recompressed or regenerated photos produce hundreds. The noise
456            // floor below scales with it, so clean pairs keep their smallest
457            // real regions while noisy pairs shed their fragment storm.
458            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    // Detected regions get a small scale-relative margin: thresholded pixels
513    // systematically under-cover the perceptual change (anti-aliased fringes,
514    // the unchanged interior padding of a recolored element), so the reported
515    // box extends slightly past them. Caller-supplied and score-map boxes are
516    // echoed exactly.
517    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    // A coarse source has no authoritative count of its own, so it comes from
534    // the pixels that survived refinement — counted over the union of the
535    // boxes, because a coarse source is free to overlap them and DOM
536    // rectangles routinely do.
537    let (diff_count, diff_percentage) = counts.unwrap_or_else(|| {
538        let count = count_masked_union(&mut mask, width, &regions);
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(&regions, &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
575/// Classify boxes that may be coarser than a pixel: fill them into a mask,
576/// refine that against the source pixels, then measure.
577fn 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        // u32 arithmetic, so check the sums for overflow rather than trusting
587        // `x + width <= image_width` to stay in range.
588        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
626/// Count the changed pixels under `regions`, charging one shared by two boxes
627/// only once.
628///
629/// Overlap is normal for a coarse source — DOM rectangles nest and overlap —
630/// and summing each region's own `pixel_count` would count the shared pixels
631/// twice, which can push `diff_percentage` past 100%. Each pixel is cleared as
632/// it is counted, so a second box over it finds nothing left to count.
633fn 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    /// Caller-supplied boxes overlap all the time — nested DOM rectangles do —
694    /// and a pixel under two of them still changed once. Summing each region's
695    /// own count would report it twice and can push the percentage past 100.
696    #[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        // Both boxes enclose the whole 8x8 change.
703        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    /// The point of the regions-in API: a producer that only knows a coarse
715    /// box still gets per-pixel counts, because the mask is refined against the
716    /// source pixels before anything is measured.
717    #[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        // Quantized to a 16px grid, as an SSIM window map would give.
725        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
760/// Turn a lower-resolution scalar score map into regions in image coordinates.
761///
762/// This is how a similarity metric becomes a region producer: threshold its
763/// local map at `floor`, take connected components at map resolution, then
764/// scale each box back up. The result is deliberately coarse — a box is only
765/// ever as precise as the map's grid — but [`interpret`] refines
766/// against the source pixels before measuring anything, so the statistics it
767/// derives are still per-pixel.
768///
769/// Scores are "higher is more similar", matching `blazediff-ssim`, so a cell
770/// counts as changed when it falls *below* `floor`. `NaN` cells count as
771/// changed: `ms-ssim` with product pooling yields `NaN` on globally
772/// anticorrelated content, which is emphatically not a match.
773pub 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    // Map cell `m` covers image pixels [m * size / map_size, (m + 1) * size / map_size),
794    // so the same floor division gives both edges and the cells tile the image
795    // exactly. Rounding the upper edge up instead would widen every box by a
796    // pixel and make the boxes of two adjacent components overlap.
797    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            // A map cell can scale to zero pixels when the map is larger than
809            // the image, which `ssim`'s 'valid' convolution never produces but
810            // a caller-supplied map might.
811            (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; // cell (1, 1) of a 4x4 map over a 64x64 image
835        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    /// The cells tile the image exactly, so a box stops where the next cell
848    /// starts — including when the image size isn't a multiple of the map's,
849    /// which is every 'valid' convolution the metrics produce. Rounding the
850    /// upper edge up instead would hand two adjacent components a shared pixel.
851    #[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; // cell (1, 1) of a 3x3 map over a 64x64 image
855        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    /// A pixel diff and an SSIM map are two descriptions of the same change, so
905    /// interpreting either must reach the same conclusion. That symmetry is the
906    /// whole point of one entry point taking both producers' results.
907    #[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        // The boxes differ, and are meant to: the diff localizes the change
940        // to the pixel and reports it with a small presentation margin, the
941        // map only knows its window. Both must enclose the true change.
942        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        // The margin is bounded: no more than MARGIN_MAX past the change.
960        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        // What must match is everything measured from the pixels.
968        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        // Subtle single-pixel deltas sit below interpret()'s noise floor, so
1032        // they don't show up as actionable regions.
1033        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}