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