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;
27mod region;
28mod severity;
29mod shape;
30mod shifts;
31mod spatial;
32mod summary;
33// Small image constructors shared by this crate's tests and by `blazediff`'s
34// end-to-end interpret tests, which live there because they need `diff`.
35#[cfg(any(test, feature = "testing"))]
36#[doc(hidden)]
37pub mod test_helpers;
38pub mod types;
39
40use blazediff::{DiffOptions, DiffResult};
41use blazediff_shared::yiq::color_delta;
42use blazediff_shared::Image;
43use blazediff_ssim::SsimOutcome;
44use chroma::compute_chroma_stats;
45use color_delta::compute_color_delta;
46use content_analysis::analyze_content;
47use gradient::{compute_gradient_stats, compute_luminance_ncc};
48use interpretation::classify_change_type;
49use shape::{classify_shape, compute_shape_stats};
50
51use spatial::classify_position;
52use types::ChangeType;
53
54/// YIQ squared-delta floor for treating a pixel as actually changed.
55/// 100.0 corresponds to a YIQ-weighted distance of 10, roughly equivalent to
56/// a perceptual delta of ~0.017 — filters near-identical pixels without
57/// throwing away genuine edits.
58pub use region::{
59    detect_regions, extract_change_mask, merge_overlapping_components, ChangeDensity, ComponentInfo,
60};
61pub use severity::classify_severity;
62// Exposed for producers that already hold an exact per-pixel mask and so must
63// not go through `classify_regions`, which refines a coarse one.
64pub use shifts::detect_shifts;
65pub use summary::build_summary;
66pub use types::{BoundingBox, ChangeRegion, ChangeSeverity, InterpretResult};
67
68/// Why a set of regions could not be interpreted.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum InterpretError {
71    /// The two images are not the same size, so one mask cannot describe both.
72    SizeMismatch {
73        img1_width: u32,
74        img1_height: u32,
75        img2_width: u32,
76        img2_height: u32,
77    },
78    /// A supplied region falls outside the image. Regions arrive from callers
79    /// now — including across the wasm and N-API boundaries — so this is a
80    /// rejection rather than an out-of-bounds panic.
81    RegionOutOfBounds {
82        bbox: BoundingBox,
83        width: u32,
84        height: u32,
85    },
86    /// The diff or metric this was asked to interpret failed first.
87    Producer(String),
88}
89
90impl std::fmt::Display for InterpretError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            InterpretError::SizeMismatch {
94                img1_width,
95                img1_height,
96                img2_width,
97                img2_height,
98            } => write!(
99                f,
100                "Image sizes do not match: {}x{} vs {}x{}",
101                img1_width, img1_height, img2_width, img2_height
102            ),
103            InterpretError::RegionOutOfBounds {
104                bbox,
105                width,
106                height,
107            } => write!(
108                f,
109                "Region {}x{} at ({}, {}) falls outside the {}x{} image",
110                bbox.width, bbox.height, bbox.x, bbox.y, width, height
111            ),
112            InterpretError::Producer(e) => write!(f, "{}", e),
113        }
114    }
115}
116
117impl std::error::Error for InterpretError {}
118
119const REFINE_DELTA_FLOOR_SQ: f32 = 100.0;
120
121/// Components below this size never survive detection: single-digit-pixel
122/// specks are compression jitter at any image scale.
123const SPECK_FLOOR: u32 = 8;
124/// Component size that counts as a "speck" for the noise census.
125const NOISE_CENSUS_PIXELS: u32 = 64;
126/// Pixels of noise floor added per censused speck. A pair with hundreds of
127/// speck components (a recompressed photo) raises its own floor into the
128/// hundreds of pixels; a clean render keeps the minimum.
129const NOISE_FLOOR_PER_SPECK: f64 = 3.0;
130/// Noise floor for perfectly clean pairs.
131const NOISE_FLOOR_MIN: u32 = 12;
132/// Bbox-merge slack: components this close are one change. Horizontal slack
133/// is wider than vertical because text fragments on a line sit further apart
134/// than the strokes within them.
135const MERGE_SLACK_X: u32 = 12;
136const MERGE_SLACK_Y: u32 = 8;
137/// Reported-bbox margin: one third of the region dimension, clamped.
138const MARGIN_DIVISOR: u32 = 3;
139const MARGIN_MIN: u32 = 2;
140const MARGIN_MAX: u32 = 12;
141
142/// Refine a coarse mask down to the actually-changed pixels inside the given
143/// bboxes. Pixels marked true in `input_mask` but with a tiny YIQ delta between
144/// `img1` and `img2` are dropped. Used by classifier-only paths so callers can
145/// pass a bbox-filled mask without polluting statistics with unchanged content.
146fn refine_change_mask_in_bboxes(
147    img1: &Image,
148    img2: &Image,
149    input_mask: &[bool],
150    bboxes: &[types::BoundingBox],
151    width: u32,
152) -> Vec<bool> {
153    let pixels1 = img1.as_u32();
154    let pixels2 = img2.as_u32();
155    let mut refined = input_mask.to_vec();
156    for bbox in bboxes {
157        let mut survivors = 0u32;
158        let mut any_delta = false;
159        for y in bbox.y..bbox.y + bbox.height {
160            for x in bbox.x..bbox.x + bbox.width {
161                let idx = (y * width + x) as usize;
162                if !refined[idx] {
163                    continue;
164                }
165                let delta = color_delta(pixels1[idx], pixels2[idx], idx).abs() as f32;
166                if delta < REFINE_DELTA_FLOOR_SQ {
167                    refined[idx] = false;
168                    any_delta |= delta > 0.0;
169                } else {
170                    survivors += 1;
171                }
172            }
173        }
174        // A caller claimed a change here and the pixels do differ, just all
175        // below the floor (a sub-threshold edit, e.g. a subtle uniform
176        // recolor). Refining to nothing would erase the region and zero out
177        // every statistic, so keep the caller's mask for this bbox. Byte-
178        // identical content stays refined away.
179        if survivors == 0 && any_delta {
180            for y in bbox.y..bbox.y + bbox.height {
181                for x in bbox.x..bbox.x + bbox.width {
182                    let idx = (y * width + x) as usize;
183                    refined[idx] = input_mask[idx];
184                }
185            }
186        }
187    }
188    refined
189}
190
191fn count_mask_pixels(mask: &[bool], bbox: &types::BoundingBox, width: u32) -> u32 {
192    let mut pixel_count = 0u32;
193    for y in bbox.y..bbox.y + bbox.height {
194        for x in bbox.x..bbox.x + bbox.width {
195            if mask[(y * width + x) as usize] {
196                pixel_count += 1;
197            }
198        }
199    }
200    pixel_count
201}
202
203fn classify_region_with_mask(
204    img1: &Image,
205    img2: &Image,
206    mask: &[bool],
207    bbox: types::BoundingBox,
208) -> ChangeRegion {
209    let width = img1.width;
210    let height = img1.height;
211    let total_pixels = (width * height) as f64;
212    let pixel_count = count_mask_pixels(mask, &bbox, width);
213    let percentage = if total_pixels > 0.0 {
214        100.0 * pixel_count as f64 / total_pixels
215    } else {
216        0.0
217    };
218    let shape_stats = compute_shape_stats(mask, width, &bbox, pixel_count);
219    let shape = classify_shape(&shape_stats);
220    let position = classify_position(&bbox, width, height);
221
222    let color_delta = compute_color_delta(img1, img2, mask, &bbox, width);
223    let gradient_stats = compute_gradient_stats(img1, img2, mask, &bbox, width, height);
224    let luminance_ncc = compute_luminance_ncc(img1, img2, mask, &bbox, width);
225    let content = analyze_content(img1, img2, mask, &bbox, width, height);
226    let chroma_stats = compute_chroma_stats(img1, img2, mask, &bbox, width);
227    let (change_type, signals) = classify_change_type(
228        &content,
229        &color_delta,
230        &gradient_stats,
231        &shape_stats,
232        &chroma_stats,
233        &bbox,
234        luminance_ncc,
235    );
236
237    ChangeRegion {
238        bbox,
239        pixel_count,
240        percentage,
241        position,
242        shape,
243        shape_stats,
244        change_type,
245        signals,
246        confidence: signals.confidence,
247        color_delta,
248        gradient: gradient_stats,
249        chroma: chroma_stats,
250    }
251}
252
253/// Classify a known change region against a provided full-image change mask.
254///
255/// The mask is evaluated only inside `bbox`, so callers may pass a sparse mask
256/// with one or more labeled regions already marked.
257pub fn classify_region(
258    img1: &Image,
259    img2: &Image,
260    mask: &[bool],
261    bbox: types::BoundingBox,
262) -> ChangeRegion {
263    classify_region_with_mask(img1, img2, mask, bbox)
264}
265
266/// Classify multiple known regions, then run the same shift relabeling pass used
267/// by `interpret()` so classifier-only verification can evaluate final labels.
268///
269/// The caller-supplied `mask` may be coarse (e.g., bbox-filled for verifier
270/// tooling); we refine it to actually-changed pixels inside each bbox before
271/// classification so per-pixel statistics aren't diluted by unchanged content.
272pub fn classify_regions(
273    img1: &Image,
274    img2: &Image,
275    mask: &[bool],
276    bboxes: &[types::BoundingBox],
277) -> Vec<ChangeRegion> {
278    let width = img1.width;
279    let refined = refine_change_mask_in_bboxes(img1, img2, mask, bboxes, width);
280    let mut regions: Vec<ChangeRegion> = bboxes
281        .iter()
282        .copied()
283        .map(|bbox| classify_region_with_mask(img1, img2, &refined, bbox))
284        .collect();
285    detect_shifts(&mut regions, img1, img2, &refined, width);
286    regions
287}
288
289/// Where the change information came from.
290///
291/// Each variant is what one of the producers hands back, taken by name: this
292/// crate sits above them and consumes their results, rather than either of them
293/// depending on it. What differs between the variants is only the precision of
294/// the description, and whether it needs refining.
295pub enum ChangeSource<'a> {
296    /// What [`blazediff::diff`] returns: its result plus the visualization it
297    /// wrote. Changed pixels are the non-grayscale ones, so the mask is exact
298    /// and is used as-is; the counts come from the diff rather than being
299    /// recomputed, because it knows which pixels it excluded as anti-aliasing.
300    ///
301    /// The diff must have been run with [`mask_options`] so the visualization
302    /// is readable as a mask whatever colours the caller wanted.
303    Diff {
304        result: &'a DiffResult,
305        output: &'a Image,
306    },
307    /// What any [`blazediff_ssim`] metric returns. Cells scoring below `floor`
308    /// are changed. The map's grid is coarser than a pixel, so the boxes it
309    /// yields are refined against the source pixels before anything is
310    /// measured.
311    Ssim {
312        outcome: &'a SsimOutcome,
313        floor: f32,
314    },
315    /// Regions the caller already knows — DOM rectangles, a crop list, anything.
316    /// Refined like a score map, so they may be as coarse as you like.
317    Regions(&'a [BoundingBox]),
318}
319
320/// Stable non-grayscale marker colors, so a mask can be read back out of a
321/// diff visualization whatever colours the caller asked for — including
322/// grayscale ones, which would otherwise be indistinguishable from background.
323const MASK_DIFF_COLOR: [u8; 3] = [255, 0, 0];
324const MASK_DIFF_COLOR_ALT: [u8; 3] = [0, 0, 255];
325const MASK_AA_COLOR: [u8; 3] = [255, 255, 0];
326
327/// The [`DiffOptions`] a diff must run with for [`ChangeSource::Diff`] to be
328/// able to recover its mask.
329///
330/// Everything except the three marker colours is taken from `options`, so
331/// threshold and anti-aliasing behave exactly as the caller asked.
332pub fn mask_options(options: &DiffOptions) -> DiffOptions {
333    DiffOptions {
334        aa_color: MASK_AA_COLOR,
335        diff_color: MASK_DIFF_COLOR,
336        diff_color_alt: Some(MASK_DIFF_COLOR_ALT),
337        ..options.clone()
338    }
339}
340
341/// Repaint a mask-coloured visualization in the caller's palette.
342///
343/// Call it after [`interpret`] has read the mask, never before.
344pub fn recolor_output(output: &mut Image, options: &DiffOptions) {
345    let diff_color_alt = options.diff_color_alt.unwrap_or(options.diff_color);
346
347    for pixel in output.data.chunks_exact_mut(4) {
348        let color = match [pixel[0], pixel[1], pixel[2]] {
349            MASK_DIFF_COLOR => options.diff_color,
350            MASK_DIFF_COLOR_ALT => diff_color_alt,
351            MASK_AA_COLOR => options.aa_color,
352            _ => continue,
353        };
354        pixel[..3].copy_from_slice(&color);
355    }
356}
357
358/// Run a pixel diff and interpret it, optionally keeping the visualization.
359///
360/// The convenience path for the common case: it applies [`mask_options`],
361/// runs [`blazediff::diff`], interprets the result, and repaints the output in
362/// the caller's colours afterwards.
363pub fn interpret_diff(
364    image1: &Image,
365    image2: &Image,
366    output: Option<&mut Image>,
367    options: &DiffOptions,
368) -> Result<InterpretResult, InterpretError> {
369    // The diff has to paint its mask somewhere, so a caller that doesn't want
370    // the visualization still needs a buffer — but only that caller: allocating
371    // one unconditionally would double the peak memory of every call that does.
372    let mut scratch;
373    let (diff_output, retain_output) = match output {
374        Some(image) => (image, true),
375        None => {
376            scratch = Image::new_uninit(image1.width, image1.height);
377            (&mut scratch, false)
378        }
379    };
380
381    let result = blazediff::diff(image1, image2, Some(diff_output), &mask_options(options))
382        .map_err(|e| InterpretError::Producer(e.to_string()))?;
383
384    // Read the mask out of the marker colours before repainting them.
385    let interpreted = interpret(
386        image1,
387        image2,
388        ChangeSource::Diff {
389            result: &result,
390            output: diff_output,
391        },
392    )?;
393
394    if retain_output {
395        recolor_output(diff_output, options);
396    }
397
398    Ok(interpreted)
399}
400
401/// Describe what changed between two images.
402///
403/// The single entry point: a pixel diff, a similarity metric and a caller
404/// passing its own boxes all come through here and get identical treatment,
405/// because the statistics are computed from the source pixels either way.
406///
407/// `diff_count` therefore means the same thing on every path — actually-changed
408/// pixels, never map windows.
409pub fn interpret(
410    image1: &Image,
411    image2: &Image,
412    source: ChangeSource<'_>,
413) -> Result<InterpretResult, InterpretError> {
414    let (width, height) = (image1.width, image1.height);
415    if width != image2.width || height != image2.height {
416        return Err(InterpretError::SizeMismatch {
417            img1_width: width,
418            img1_height: height,
419            img2_width: image2.width,
420            img2_height: image2.height,
421        });
422    }
423
424    // A diff that found nothing may leave its output buffer *uninitialized* —
425    // see `Image::new_uninit` — so the mask must not be read in that case.
426    if let ChangeSource::Diff { result, .. } = &source {
427        if result.identical {
428            return Ok(InterpretResult {
429                summary: "Images are identical".to_string(),
430                diff_count: 0,
431                total_regions: 0,
432                regions: Vec::new(),
433                severity: classify_severity(0.0),
434                diff_percentage: 0.0,
435                width,
436                height,
437            });
438        }
439    }
440
441    let total_pixels = (width as f64) * (height as f64);
442
443    let margin_output = matches!(source, ChangeSource::Diff { .. });
444
445    // Each arm produces the regions, the mask the shift pass should read, and
446    // — for the diff, which knows better than we do — its own counts.
447    let (mut regions, mut mask, counts) = match source {
448        ChangeSource::Diff { result, output } => {
449            let mask = extract_change_mask(&output.data, width, height);
450            let mut components = detect_regions(&mask, width, height);
451            // Noise census: the count of speck-sized components is a robust
452            // readout of how noisy this pair is. Clean renders produce none;
453            // recompressed or regenerated photos produce hundreds. The noise
454            // floor below scales with it, so clean pairs keep their smallest
455            // real regions while noisy pairs shed their fragment storm.
456            let n_small = components
457                .iter()
458                .filter(|c| c.pixel_count < NOISE_CENSUS_PIXELS)
459                .count();
460            let area_floor = (NOISE_FLOOR_PER_SPECK * n_small as f64) as u32;
461            components.retain(|component| component.pixel_count >= SPECK_FLOOR);
462            let mut components = if components.len() > 1 {
463                let density = ChangeDensity::new(image1, image2);
464                merge_overlapping_components(components, MERGE_SLACK_X, MERGE_SLACK_Y, &density)
465            } else {
466                components
467            };
468            components.retain(|component| component.pixel_count >= area_floor.max(NOISE_FLOOR_MIN));
469            let regions = components
470                .into_iter()
471                .map(|component| {
472                    let mut region =
473                        classify_region_with_mask(image1, image2, &mask, component.bbox);
474                    region.pixel_count = component.pixel_count;
475                    region.percentage = if total_pixels > 0.0 {
476                        100.0 * component.pixel_count as f64 / total_pixels
477                    } else {
478                        0.0
479                    };
480                    region
481                })
482                .collect::<Vec<_>>();
483            (
484                regions,
485                mask,
486                Some((result.diff_count, result.diff_percentage)),
487            )
488        }
489        ChangeSource::Ssim { outcome, floor } => {
490            let boxes = regions_from_score_map(
491                &outcome.map,
492                outcome.map_width,
493                outcome.map_height,
494                width,
495                height,
496                floor,
497            );
498            let (regions, mask) = classify_coarse(image1, image2, &boxes)?;
499            (regions, mask, None)
500        }
501        ChangeSource::Regions(boxes) => {
502            let (regions, mask) = classify_coarse(image1, image2, boxes)?;
503            (regions, mask, None)
504        }
505    };
506
507    detect_shifts(&mut regions, image1, image2, &mask, width);
508    regions.retain(|region| region.change_type != ChangeType::RenderingNoise);
509
510    // Detected regions get a small scale-relative margin: thresholded pixels
511    // systematically under-cover the perceptual change (anti-aliased fringes,
512    // the unchanged interior padding of a recolored element), so the reported
513    // box extends slightly past them. Caller-supplied and score-map boxes are
514    // echoed exactly.
515    if margin_output {
516        for region in &mut regions {
517            let bbox = &mut region.bbox;
518            let pad_x = (bbox.width / MARGIN_DIVISOR).clamp(MARGIN_MIN, MARGIN_MAX);
519            let pad_y = (bbox.height / MARGIN_DIVISOR).clamp(MARGIN_MIN, MARGIN_MAX);
520            let x0 = bbox.x.saturating_sub(pad_x);
521            let y0 = bbox.y.saturating_sub(pad_y);
522            let x1 = (bbox.x + bbox.width + pad_x).min(width);
523            let y1 = (bbox.y + bbox.height + pad_y).min(height);
524            bbox.x = x0;
525            bbox.y = y0;
526            bbox.width = x1 - x0;
527            bbox.height = y1 - y0;
528        }
529    }
530
531    // A coarse source has no authoritative count of its own, so it comes from
532    // the pixels that survived refinement — counted over the union of the
533    // boxes, because a coarse source is free to overlap them and DOM
534    // rectangles routinely do.
535    let (diff_count, diff_percentage) = counts.unwrap_or_else(|| {
536        let count = count_masked_union(&mut mask, width, &regions);
537        let percentage = if total_pixels > 0.0 {
538            100.0 * count as f64 / total_pixels
539        } else {
540            0.0
541        };
542        (count, percentage)
543    });
544
545    if regions.is_empty() && diff_count == 0 {
546        return Ok(InterpretResult {
547            summary: "Images are identical".to_string(),
548            diff_count: 0,
549            total_regions: 0,
550            regions: Vec::new(),
551            severity: classify_severity(0.0),
552            diff_percentage: 0.0,
553            width,
554            height,
555        });
556    }
557
558    let severity = classify_severity(diff_percentage);
559    let summary = build_summary(&regions, &severity, diff_percentage);
560
561    Ok(InterpretResult {
562        summary,
563        diff_count,
564        total_regions: regions.len(),
565        regions,
566        severity,
567        diff_percentage,
568        width,
569        height,
570    })
571}
572
573/// Classify boxes that may be coarser than a pixel: fill them into a mask,
574/// refine that against the source pixels, then measure.
575fn classify_coarse(
576    image1: &Image,
577    image2: &Image,
578    boxes: &[BoundingBox],
579) -> Result<(Vec<ChangeRegion>, Vec<bool>), InterpretError> {
580    let (width, height) = (image1.width, image1.height);
581    let total_pixels = (width as f64) * (height as f64);
582
583    for bbox in boxes {
584        // u32 arithmetic, so check the sums for overflow rather than trusting
585        // `x + width <= image_width` to stay in range.
586        let right = bbox.x.checked_add(bbox.width);
587        let bottom = bbox.y.checked_add(bbox.height);
588        let fits = matches!((right, bottom), (Some(r), Some(b)) if r <= width && b <= height);
589        if !fits {
590            return Err(InterpretError::RegionOutOfBounds {
591                bbox: *bbox,
592                width,
593                height,
594            });
595        }
596    }
597
598    let mut mask = vec![false; (width * height) as usize];
599    for bbox in boxes {
600        for y in bbox.y..bbox.y + bbox.height {
601            let row = (y * width) as usize;
602            mask[row + bbox.x as usize..row + (bbox.x + bbox.width) as usize].fill(true);
603        }
604    }
605    let refined = refine_change_mask_in_bboxes(image1, image2, &mask, boxes, width);
606
607    let regions = boxes
608        .iter()
609        .copied()
610        .map(|bbox| {
611            let mut region = classify_region_with_mask(image1, image2, &refined, bbox);
612            region.percentage = if total_pixels > 0.0 {
613                100.0 * region.pixel_count as f64 / total_pixels
614            } else {
615                0.0
616            };
617            region
618        })
619        .collect();
620
621    Ok((regions, refined))
622}
623
624/// Count the changed pixels under `regions`, charging one shared by two boxes
625/// only once.
626///
627/// Overlap is normal for a coarse source — DOM rectangles nest and overlap —
628/// and summing each region's own `pixel_count` would count the shared pixels
629/// twice, which can push `diff_percentage` past 100%. Each pixel is cleared as
630/// it is counted, so a second box over it finds nothing left to count.
631fn count_masked_union(mask: &mut [bool], width: u32, regions: &[ChangeRegion]) -> u32 {
632    let mut count = 0;
633    for region in regions {
634        let bbox = region.bbox;
635        for y in bbox.y..bbox.y + bbox.height {
636            let row = (y * width) as usize;
637            for pixel in &mut mask[row + bbox.x as usize..row + (bbox.x + bbox.width) as usize] {
638                if *pixel {
639                    *pixel = false;
640                    count += 1;
641                }
642            }
643        }
644    }
645    count
646}
647
648#[cfg(test)]
649mod region_entry_tests {
650    use super::*;
651    use crate::test_helpers::*;
652
653    fn bbox(x: u32, y: u32, width: u32, height: u32) -> BoundingBox {
654        BoundingBox {
655            x,
656            y,
657            width,
658            height,
659        }
660    }
661
662    #[test]
663    fn no_regions_means_identical() {
664        let a = make_solid_image(64, 64, 10, 20, 30);
665        let result = interpret(&a, &a, ChangeSource::Regions(&[])).unwrap();
666        assert_eq!(result.summary, "Images are identical");
667        assert_eq!(result.diff_count, 0);
668        assert_eq!(result.total_regions, 0);
669    }
670
671    #[test]
672    fn a_region_over_unchanged_pixels_contributes_nothing() {
673        let a = make_solid_image(64, 64, 10, 20, 30);
674        let result = interpret(&a, &a, ChangeSource::Regions(&[bbox(8, 8, 16, 16)])).unwrap();
675        assert_eq!(result.diff_count, 0);
676        assert_eq!(result.diff_percentage, 0.0);
677    }
678
679    #[test]
680    fn a_changed_region_is_classified() {
681        let a = make_solid_image(64, 64, 255, 255, 255);
682        let mut b = make_solid_image(64, 64, 255, 255, 255);
683        fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
684
685        let result = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 8, 8)])).unwrap();
686        assert_eq!(result.total_regions, 1);
687        assert_eq!(result.diff_count, 64);
688        assert_eq!(result.regions[0].bbox, bbox(16, 16, 8, 8));
689    }
690
691    /// Caller-supplied boxes overlap all the time — nested DOM rectangles do —
692    /// and a pixel under two of them still changed once. Summing each region's
693    /// own count would report it twice and can push the percentage past 100.
694    #[test]
695    fn overlapping_regions_count_a_shared_pixel_once() {
696        let a = make_solid_image(64, 64, 255, 255, 255);
697        let mut b = make_solid_image(64, 64, 255, 255, 255);
698        fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
699
700        // Both boxes enclose the whole 8x8 change.
701        let result = interpret(
702            &a,
703            &b,
704            ChangeSource::Regions(&[bbox(8, 8, 24, 24), bbox(16, 16, 16, 16)]),
705        )
706        .unwrap();
707
708        assert_eq!(result.diff_count, 64);
709        assert_eq!(result.diff_percentage, 100.0 * 64.0 / (64.0 * 64.0));
710    }
711
712    /// The point of the regions-in API: a producer that only knows a coarse
713    /// box still gets per-pixel counts, because the mask is refined against the
714    /// source pixels before anything is measured.
715    #[test]
716    fn a_coarse_region_still_yields_per_pixel_counts() {
717        let a = make_solid_image(64, 64, 255, 255, 255);
718        let mut b = make_solid_image(64, 64, 255, 255, 255);
719        fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
720
721        let exact = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 8, 8)])).unwrap();
722        // Quantized to a 16px grid, as an SSIM window map would give.
723        let coarse = interpret(&a, &b, ChangeSource::Regions(&[bbox(16, 16, 16, 16)])).unwrap();
724
725        assert_eq!(coarse.diff_count, exact.diff_count);
726        assert_eq!(coarse.regions[0].pixel_count, exact.regions[0].pixel_count);
727    }
728
729    #[test]
730    fn mismatched_sizes_are_rejected() {
731        let a = make_solid_image(64, 64, 0, 0, 0);
732        let b = make_solid_image(32, 64, 0, 0, 0);
733        assert!(matches!(
734            interpret(&a, &b, ChangeSource::Regions(&[])),
735            Err(InterpretError::SizeMismatch { .. })
736        ));
737    }
738
739    #[test]
740    fn an_out_of_bounds_region_is_rejected_rather_than_panicking() {
741        let a = make_solid_image(64, 64, 0, 0, 0);
742        for out in [
743            bbox(60, 0, 8, 8),
744            bbox(0, 60, 8, 8),
745            bbox(u32::MAX, 0, 8, 8),
746        ] {
747            assert!(
748                matches!(
749                    interpret(&a, &a, ChangeSource::Regions(&[out])),
750                    Err(InterpretError::RegionOutOfBounds { .. })
751                ),
752                "{out:?} should be rejected"
753            );
754        }
755    }
756}
757
758/// Turn a lower-resolution scalar score map into regions in image coordinates.
759///
760/// This is how a similarity metric becomes a region producer: threshold its
761/// local map at `floor`, take connected components at map resolution, then
762/// scale each box back up. The result is deliberately coarse — a box is only
763/// ever as precise as the map's grid — but [`interpret`] refines
764/// against the source pixels before measuring anything, so the statistics it
765/// derives are still per-pixel.
766///
767/// Scores are "higher is more similar", matching `blazediff-ssim`, so a cell
768/// counts as changed when it falls *below* `floor`. `NaN` cells count as
769/// changed: `ms-ssim` with product pooling yields `NaN` on globally
770/// anticorrelated content, which is emphatically not a match.
771pub fn regions_from_score_map(
772    map: &[f32],
773    map_width: usize,
774    map_height: usize,
775    width: u32,
776    height: u32,
777    floor: f32,
778) -> Vec<BoundingBox> {
779    if map_width == 0 || map_height == 0 || width == 0 || height == 0 {
780        return Vec::new();
781    }
782    if map.len() < map_width * map_height {
783        return Vec::new();
784    }
785
786    let changed: Vec<bool> = map[..map_width * map_height]
787        .iter()
788        .map(|score| !(*score >= floor))
789        .collect();
790
791    // Map cell `m` covers image pixels [m * size / map_size, (m + 1) * size / map_size),
792    // so the same floor division gives both edges and the cells tile the image
793    // exactly. Rounding the upper edge up instead would widen every box by a
794    // pixel and make the boxes of two adjacent components overlap.
795    let scale =
796        |m: u32, size: u32, map_size: u32| (m as u64 * size as u64 / map_size as u64) as u32;
797
798    detect_regions(&changed, map_width as u32, map_height as u32)
799        .into_iter()
800        .filter_map(|component| {
801            let bbox = component.bbox;
802            let x = scale(bbox.x, width, map_width as u32);
803            let y = scale(bbox.y, height, map_height as u32);
804            let right = scale(bbox.x + bbox.width, width, map_width as u32);
805            let bottom = scale(bbox.y + bbox.height, height, map_height as u32);
806            // A map cell can scale to zero pixels when the map is larger than
807            // the image, which `ssim`'s 'valid' convolution never produces but
808            // a caller-supplied map might.
809            (right > x && bottom > y).then_some(BoundingBox {
810                x,
811                y,
812                width: right - x,
813                height: bottom - y,
814            })
815        })
816        .collect()
817}
818
819#[cfg(test)]
820mod score_map_tests {
821    use super::*;
822
823    #[test]
824    fn an_all_similar_map_yields_no_regions() {
825        let map = vec![1.0f32; 4 * 4];
826        assert!(regions_from_score_map(&map, 4, 4, 64, 64, 0.99).is_empty());
827    }
828
829    #[test]
830    fn a_low_cell_becomes_a_scaled_box() {
831        let mut map = vec![1.0f32; 4 * 4];
832        map[5] = 0.1; // cell (1, 1) of a 4x4 map over a 64x64 image
833        let regions = regions_from_score_map(&map, 4, 4, 64, 64, 0.99);
834        assert_eq!(
835            regions,
836            vec![BoundingBox {
837                x: 16,
838                y: 16,
839                width: 16,
840                height: 16
841            }]
842        );
843    }
844
845    /// The cells tile the image exactly, so a box stops where the next cell
846    /// starts — including when the image size isn't a multiple of the map's,
847    /// which is every 'valid' convolution the metrics produce. Rounding the
848    /// upper edge up instead would hand two adjacent components a shared pixel.
849    #[test]
850    fn a_cell_maps_to_its_exact_slice_of_a_non_divisible_image() {
851        let mut map = vec![1.0f32; 3 * 3];
852        map[4] = 0.1; // cell (1, 1) of a 3x3 map over a 64x64 image
853        let regions = regions_from_score_map(&map, 3, 3, 64, 64, 0.99);
854        assert_eq!(
855            regions,
856            vec![BoundingBox {
857                x: 21,
858                y: 21,
859                width: 21,
860                height: 21
861            }]
862        );
863    }
864
865    #[test]
866    fn adjacent_cells_merge_into_one_region() {
867        let mut map = vec![1.0f32; 4 * 4];
868        map[5] = 0.1;
869        map[6] = 0.1;
870        let regions = regions_from_score_map(&map, 4, 4, 64, 64, 0.99);
871        assert_eq!(regions.len(), 1);
872        assert_eq!(regions[0].width, 32);
873    }
874
875    #[test]
876    fn nan_counts_as_changed() {
877        let mut map = vec![1.0f32; 4 * 4];
878        map[0] = f32::NAN;
879        assert_eq!(regions_from_score_map(&map, 4, 4, 64, 64, 0.99).len(), 1);
880    }
881
882    #[test]
883    fn a_degenerate_map_yields_nothing() {
884        assert!(regions_from_score_map(&[], 0, 0, 64, 64, 0.99).is_empty());
885        assert!(regions_from_score_map(&[0.0], 4, 4, 64, 64, 0.99).is_empty());
886    }
887}
888
889#[cfg(test)]
890mod source_parity_tests {
891    use super::*;
892    use crate::test_helpers::*;
893    use blazediff_ssim::{ssim, Plane, Rgba8, SsimOptions};
894
895    fn pair() -> (Image, Image) {
896        let a = make_solid_image(64, 64, 255, 255, 255);
897        let mut b = make_solid_image(64, 64, 255, 255, 255);
898        fill_block(&mut b, 16, 16, 8, 8, 0, 0, 0);
899        (a, b)
900    }
901
902    /// A pixel diff and an SSIM map are two descriptions of the same change, so
903    /// interpreting either must reach the same conclusion. That symmetry is the
904    /// whole point of one entry point taking both producers' results.
905    #[test]
906    fn a_diff_and_an_ssim_map_agree() {
907        let (a, b) = pair();
908        let options = DiffOptions::default();
909
910        let mut output = Image::new_uninit(a.width, a.height);
911        let result = blazediff::diff(&a, &b, Some(&mut output), &mask_options(&options)).unwrap();
912        let from_diff = interpret(
913            &a,
914            &b,
915            ChangeSource::Diff {
916                result: &result,
917                output: &output,
918            },
919        )
920        .unwrap();
921
922        let plane = |image: &Image| Plane::from_rgba8(Rgba8::new(&image.data, 64, 64)).unwrap();
923        let outcome = ssim(&plane(&a), &plane(&b), &SsimOptions::default()).unwrap();
924        let from_ssim = interpret(
925            &a,
926            &b,
927            ChangeSource::Ssim {
928                outcome: &outcome,
929                floor: 0.99,
930            },
931        )
932        .unwrap();
933
934        assert_eq!(from_diff.total_regions, 1);
935        assert_eq!(from_ssim.total_regions, 1);
936
937        // The boxes differ, and are meant to: the diff localizes the change
938        // to the pixel and reports it with a small presentation margin, the
939        // map only knows its window. Both must enclose the true change.
940        let change = BoundingBox {
941            x: 16,
942            y: 16,
943            width: 8,
944            height: 8,
945        };
946        let encloses = |outer: &BoundingBox, inner: &BoundingBox| {
947            outer.x <= inner.x
948                && outer.y <= inner.y
949                && outer.x + outer.width >= inner.x + inner.width
950                && outer.y + outer.height >= inner.y + inner.height
951        };
952        let (margined, coarse) = (from_diff.regions[0].bbox, from_ssim.regions[0].bbox);
953        assert!(
954            encloses(&margined, &change),
955            "{margined:?} should enclose {change:?}"
956        );
957        // The margin is bounded: no more than MARGIN_MAX past the change.
958        assert!(margined.x + margined.width <= change.x + change.width + MARGIN_MAX);
959        assert!(margined.width <= change.width + 2 * MARGIN_MAX);
960        assert!(
961            encloses(&coarse, &change),
962            "{coarse:?} should enclose {change:?}"
963        );
964
965        // What must match is everything measured from the pixels.
966        assert_eq!(
967            from_diff.regions[0].change_type,
968            from_ssim.regions[0].change_type
969        );
970        assert_eq!(from_diff.diff_count, 64);
971        assert_eq!(from_ssim.diff_count, 64);
972    }
973
974    #[test]
975    fn an_unchanged_pair_is_identical_from_either_source() {
976        let a = make_solid_image(64, 64, 10, 20, 30);
977        let options = DiffOptions::default();
978
979        let mut output = Image::new_uninit(64, 64);
980        let result = blazediff::diff(&a, &a, Some(&mut output), &mask_options(&options)).unwrap();
981        let from_diff = interpret(
982            &a,
983            &a,
984            ChangeSource::Diff {
985                result: &result,
986                output: &output,
987            },
988        )
989        .unwrap();
990        assert_eq!(from_diff.summary, "Images are identical");
991
992        let plane = Plane::from_rgba8(Rgba8::new(&a.data, 64, 64)).unwrap();
993        let outcome = ssim(&plane, &plane, &SsimOptions::default()).unwrap();
994        let from_ssim = interpret(
995            &a,
996            &a,
997            ChangeSource::Ssim {
998                outcome: &outcome,
999                floor: 0.99,
1000            },
1001        )
1002        .unwrap();
1003        assert_eq!(from_ssim.summary, "Images are identical");
1004    }
1005}
1006
1007#[cfg(test)]
1008mod diff_source_tests {
1009
1010    use super::*;
1011    use crate::test_helpers::*;
1012    use blazediff::{diff, DiffOptions};
1013    use types::*;
1014    #[test]
1015    fn test_identical_images() {
1016        let img1 = make_solid_image(100, 100, 128, 128, 128);
1017        let img2 = make_solid_image(100, 100, 128, 128, 128);
1018        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1019
1020        assert_eq!(result.total_regions, 0);
1021        assert!(result.regions.is_empty());
1022        assert_eq!(result.severity, ChangeSeverity::Low);
1023        assert_eq!(result.diff_percentage, 0.0);
1024        assert_eq!(result.summary, "Images are identical");
1025    }
1026
1027    #[test]
1028    fn test_single_pixel_change_is_filtered_as_noise() {
1029        // Subtle single-pixel deltas sit below interpret()'s noise floor, so
1030        // they don't show up as actionable regions.
1031        let img1 = make_solid_image(100, 100, 128, 128, 128);
1032        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1033        set_pixel(&mut img2, 50, 50, 130, 130, 130);
1034
1035        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1036
1037        assert_eq!(result.total_regions, 0);
1038        assert!(result.regions.is_empty());
1039    }
1040
1041    #[test]
1042    fn test_interpret_with_output_matches_diff() {
1043        let img1 = make_solid_image(32, 32, 200, 200, 200);
1044        let img2 = make_solid_image(32, 32, 50, 50, 50);
1045        let options = DiffOptions {
1046            include_aa: true,
1047            diff_color_alt: Some([0, 128, 255]),
1048            ..Default::default()
1049        };
1050        let mut expected = Image::new(32, 32);
1051        let diff_result = diff(&img1, &img2, Some(&mut expected), &options).unwrap();
1052        let mut actual = Image::new(32, 32);
1053
1054        let interpretation = interpret_diff(&img1, &img2, Some(&mut actual), &options).unwrap();
1055
1056        assert_eq!(interpretation.diff_count, diff_result.diff_count);
1057        assert_eq!(actual.data, expected.data);
1058    }
1059
1060    #[test]
1061    fn test_interpret_with_grayscale_alt_color_keeps_regions() {
1062        let img1 = make_solid_image(32, 32, 200, 200, 200);
1063        let img2 = make_solid_image(32, 32, 50, 50, 50);
1064        let options = DiffOptions {
1065            include_aa: true,
1066            diff_color_alt: Some([32, 32, 32]),
1067            ..Default::default()
1068        };
1069        let mut output = Image::new(32, 32);
1070
1071        let result = interpret_diff(&img1, &img2, Some(&mut output), &options).unwrap();
1072
1073        assert_eq!(result.diff_count, 32 * 32);
1074        assert!(result.total_regions > 0);
1075        assert_eq!(&output.data[..4], &[32, 32, 32, 255]);
1076    }
1077
1078    #[test]
1079    fn test_block_addition() {
1080        let img1 = make_solid_image(100, 100, 128, 128, 128);
1081        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1082        fill_block(&mut img2, 0, 0, 40, 40, 255, 0, 0);
1083
1084        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1085
1086        assert_eq!(result.total_regions, 1);
1087        assert_eq!(result.regions[0].position, SpatialPosition::TopLeft);
1088        assert_eq!(result.severity, ChangeSeverity::High);
1089        assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1090        assert!(result.summary.contains("Content added"));
1091    }
1092
1093    #[test]
1094    fn test_block_deletion() {
1095        let mut img1 = make_solid_image(100, 100, 128, 128, 128);
1096        fill_block(&mut img1, 0, 0, 40, 40, 255, 0, 0);
1097        let img2 = make_solid_image(100, 100, 128, 128, 128);
1098
1099        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1100
1101        assert_eq!(result.total_regions, 1);
1102        assert_eq!(result.regions[0].change_type, ChangeType::Deletion);
1103        assert!(result.summary.contains("Content removed"));
1104    }
1105
1106    #[test]
1107    fn test_scattered_additions() {
1108        let img1 = make_solid_image(100, 100, 128, 128, 128);
1109        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1110        fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1111        fill_block(&mut img2, 80, 80, 10, 10, 0, 255, 0);
1112
1113        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1114
1115        assert_eq!(result.total_regions, 2);
1116        let positions: Vec<SpatialPosition> = result.regions.iter().map(|r| r.position).collect();
1117        assert!(positions.contains(&SpatialPosition::TopLeft));
1118        assert!(positions.contains(&SpatialPosition::BottomRight));
1119        assert!(result
1120            .regions
1121            .iter()
1122            .all(|r| r.change_type == ChangeType::Addition));
1123    }
1124
1125    #[test]
1126    fn test_full_image_color_change() {
1127        let img1 = make_solid_image(100, 100, 0, 0, 0);
1128        let img2 = make_solid_image(100, 100, 255, 255, 255);
1129
1130        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1131
1132        assert_eq!(result.total_regions, 1);
1133        assert_eq!(result.severity, ChangeSeverity::High);
1134        assert!(result.diff_percentage > 90.0);
1135        assert_eq!(result.regions[0].shape, ChangeShape::SolidRegion);
1136        assert!(
1137            matches!(
1138                result.regions[0].change_type,
1139                ChangeType::ColorChange | ChangeType::ContentChange
1140            ),
1141            "Expected ColorChange or ContentChange for full image swap, got: {:?}",
1142            result.regions[0].change_type
1143        );
1144    }
1145
1146    #[test]
1147    fn test_severity_boundaries() {
1148        let img1 = make_solid_image(100, 100, 128, 128, 128);
1149
1150        let mut img2_low = make_solid_image(100, 100, 128, 128, 128);
1151        for i in 0..50 {
1152            set_pixel(&mut img2_low, i % 100, i / 100, 255, 0, 0);
1153        }
1154        let result_low = interpret_diff(&img1, &img2_low, None, &DiffOptions::default()).unwrap();
1155        assert_eq!(result_low.severity, ChangeSeverity::Low);
1156
1157        let mut img2_med = make_solid_image(100, 100, 128, 128, 128);
1158        fill_block(&mut img2_med, 0, 0, 20, 25, 255, 0, 0);
1159        let result_med = interpret_diff(&img1, &img2_med, None, &DiffOptions::default()).unwrap();
1160        assert_eq!(result_med.severity, ChangeSeverity::Medium);
1161
1162        let mut img2_high = make_solid_image(100, 100, 128, 128, 128);
1163        fill_block(&mut img2_high, 0, 0, 50, 50, 255, 0, 0);
1164        let result_high = interpret_diff(&img1, &img2_high, None, &DiffOptions::default()).unwrap();
1165        assert_eq!(result_high.severity, ChangeSeverity::High);
1166    }
1167
1168    #[test]
1169    fn test_json_roundtrip() {
1170        let img1 = make_solid_image(100, 100, 128, 128, 128);
1171        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1172        fill_block(&mut img2, 10, 10, 20, 20, 255, 0, 0);
1173
1174        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1175        let json = serde_json::to_string(&result).unwrap();
1176        let deserialized: InterpretResult = serde_json::from_str(&json).unwrap();
1177
1178        assert_eq!(result, deserialized);
1179    }
1180
1181    #[test]
1182    fn test_hollow_frame_is_addition() {
1183        let img1 = make_solid_image(100, 100, 128, 128, 128);
1184        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1185
1186        let bx = 35u32;
1187        let by = 35u32;
1188        let bw = 30u32;
1189        let bh = 30u32;
1190        for y in by..by + bh {
1191            for x in bx..bx + bw {
1192                if x == bx || x == bx + bw - 1 || y == by || y == by + bh - 1 {
1193                    set_pixel(&mut img2, x, y, 255, 0, 0);
1194                }
1195            }
1196        }
1197
1198        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1199
1200        assert_eq!(result.total_regions, 1);
1201        assert_eq!(result.regions[0].shape, ChangeShape::ContourFrame);
1202        assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1203    }
1204
1205    #[test]
1206    fn test_sparse_noise() {
1207        let img1 = make_solid_image(100, 100, 128, 128, 128);
1208        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1209
1210        let bx = 10u32;
1211        let by = 10u32;
1212        let size = 80u32;
1213        for y in (by..by + size).step_by(6) {
1214            for x in bx..bx + size {
1215                set_pixel(&mut img2, x, y, 133, 133, 133);
1216            }
1217        }
1218        for x in (bx..bx + size).step_by(6) {
1219            for y in by..by + size {
1220                set_pixel(&mut img2, x, y, 133, 133, 133);
1221            }
1222        }
1223
1224        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1225
1226        for r in &result.regions {
1227            assert_eq!(
1228                r.change_type,
1229                ChangeType::Addition,
1230                "Expected Addition for sparse subtle grid, got: {:?}",
1231                r.change_type
1232            );
1233        }
1234    }
1235
1236    #[test]
1237    fn test_shift_detection() {
1238        let mut img1 = make_solid_image(100, 100, 255, 255, 255);
1239        fill_block(&mut img1, 10, 10, 20, 20, 40, 40, 40);
1240
1241        let mut img2 = make_solid_image(100, 100, 255, 255, 255);
1242        fill_block(&mut img2, 60, 60, 20, 20, 40, 40, 40);
1243
1244        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1245
1246        assert_eq!(result.total_regions, 2);
1247        assert!(
1248            result
1249                .regions
1250                .iter()
1251                .all(|r| r.change_type == ChangeType::Shift),
1252            "Expected both regions as Shift, got: {:?}",
1253            result
1254                .regions
1255                .iter()
1256                .map(|r| r.change_type)
1257                .collect::<Vec<_>>()
1258        );
1259        assert!(result.summary.contains("Content shifted"));
1260    }
1261
1262    #[test]
1263    fn test_size_mismatch_error() {
1264        let img1 = make_solid_image(100, 100, 128, 128, 128);
1265        let img2 = make_solid_image(200, 200, 128, 128, 128);
1266
1267        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default());
1268        assert!(result.is_err());
1269    }
1270
1271    #[test]
1272    fn test_summary_format() {
1273        let img1 = make_solid_image(100, 100, 128, 128, 128);
1274        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1275        fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1276        fill_block(&mut img2, 80, 80, 10, 10, 0, 255, 0);
1277
1278        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1279
1280        assert!(result.summary.contains("visual change detected"));
1281        assert!(result.summary.contains("2 regions"));
1282    }
1283
1284    #[test]
1285    fn test_summary_has_descriptions() {
1286        let img1 = make_solid_image(100, 100, 128, 128, 128);
1287        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1288        fill_block(&mut img2, 5, 5, 10, 10, 255, 0, 0);
1289
1290        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1291
1292        assert!(result.summary.contains("region"));
1293        assert!(result.summary.lines().count() >= 2);
1294    }
1295
1296    #[test]
1297    fn test_signals_populated() {
1298        let img1 = make_solid_image(100, 100, 128, 128, 128);
1299        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1300        fill_block(&mut img2, 0, 0, 40, 40, 255, 0, 0);
1301
1302        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1303
1304        assert_eq!(result.total_regions, 1);
1305        let region = &result.regions[0];
1306        assert!(region.confidence > 0.0);
1307        assert!(region.signals.blends_with_bg_in_img1);
1308    }
1309
1310    #[test]
1311    fn test_grey_shift_is_addition() {
1312        let img1 = make_solid_image(100, 100, 128, 128, 128);
1313        let mut img2 = make_solid_image(100, 100, 128, 128, 128);
1314        fill_block(&mut img2, 0, 0, 40, 40, 220, 220, 220);
1315
1316        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1317
1318        assert_eq!(result.total_regions, 1);
1319        assert_eq!(result.regions[0].change_type, ChangeType::Addition);
1320    }
1321
1322    #[test]
1323    fn test_shift_not_detected_for_different_sizes() {
1324        let mut img1 = make_solid_image(200, 100, 255, 255, 255);
1325        fill_block(&mut img1, 10, 10, 40, 40, 40, 40, 40);
1326
1327        let mut img2 = make_solid_image(200, 100, 255, 255, 255);
1328        fill_block(&mut img2, 140, 10, 10, 10, 40, 40, 40);
1329
1330        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1331
1332        let shift_count = result
1333            .regions
1334            .iter()
1335            .filter(|r| r.change_type == ChangeType::Shift)
1336            .count();
1337        assert_eq!(
1338            shift_count, 0,
1339            "Different-sized blocks should not be detected as shift"
1340        );
1341    }
1342
1343    #[test]
1344    fn test_shift_not_detected_for_different_luminance() {
1345        let mut img1 = make_solid_image(200, 100, 200, 200, 200);
1346        fill_block(&mut img1, 10, 10, 30, 30, 20, 20, 20);
1347
1348        let mut img2 = make_solid_image(200, 100, 200, 200, 200);
1349        fill_block(&mut img2, 140, 10, 30, 30, 200, 50, 50);
1350
1351        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1352
1353        let shift_count = result
1354            .regions
1355            .iter()
1356            .filter(|r| r.change_type == ChangeType::Shift)
1357            .count();
1358        assert_eq!(
1359            shift_count, 0,
1360            "Blocks with different luminance should not be shift"
1361        );
1362    }
1363
1364    #[test]
1365    fn test_no_shift_when_only_additions() {
1366        let img1 = make_solid_image(200, 100, 200, 200, 200);
1367        let mut img2 = make_solid_image(200, 100, 200, 200, 200);
1368        fill_block(&mut img2, 10, 10, 30, 30, 40, 40, 40);
1369        fill_block(&mut img2, 140, 10, 30, 30, 40, 40, 40);
1370
1371        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1372
1373        let shift_count = result
1374            .regions
1375            .iter()
1376            .filter(|r| r.change_type == ChangeType::Shift)
1377            .count();
1378        assert_eq!(
1379            shift_count, 0,
1380            "Two additions with no deletion cannot form a shift"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_object_on_both_images_is_not_addition_or_deletion() {
1386        let mut img1 = make_solid_image(100, 100, 200, 200, 200);
1387        fill_block(&mut img1, 30, 30, 40, 40, 255, 0, 0);
1388        let mut img2 = make_solid_image(100, 100, 200, 200, 200);
1389        fill_block(&mut img2, 30, 30, 40, 40, 0, 0, 255);
1390
1391        let result = interpret_diff(&img1, &img2, None, &DiffOptions::default()).unwrap();
1392
1393        for r in &result.regions {
1394            assert!(
1395                r.change_type != ChangeType::Addition && r.change_type != ChangeType::Deletion,
1396                "Object present in both images should not be Addition or Deletion, got {:?}",
1397                r.change_type
1398            );
1399        }
1400    }
1401}