agent-image-diff 0.2.4

Structured image diff with JSON output for agent workflows
Documentation
/// Morphological dilation of a boolean mask.
///
/// Expands true pixels by `radius` in all directions using separable
/// box dilation (horizontal pass then vertical pass). O(n) per pass
/// regardless of radius.
///
/// The dilated mask is used for clustering only — original mask is
/// preserved for accurate pixel statistics.
pub fn dilate(mask: &[bool], width: u32, height: u32, radius: u32) -> Vec<bool> {
    if radius == 0 {
        return mask.to_vec();
    }

    let w = width as usize;
    let h = height as usize;
    let r = radius as usize;

    // Horizontal pass: for each row, dilate along x
    let mut horiz = vec![false; w * h];
    for y in 0..h {
        let row_start = y * w;
        // Count of true pixels in the sliding window
        // Window covers [x - r, x + r]
        let mut count = 0usize;

        // Initialize window for x=0: covers [0, min(r, w-1)]
        for wx in 0..=(r.min(w - 1)) {
            if mask[row_start + wx] {
                count += 1;
            }
        }

        for x in 0..w {
            if count > 0 {
                horiz[row_start + x] = true;
            }
            // Slide window: add pixel entering on the right, remove pixel leaving on the left
            let add_x = x + r + 1;
            if add_x < w && mask[row_start + add_x] {
                count += 1;
            }
            if x >= r {
                let remove_x = x - r;
                if mask[row_start + remove_x] {
                    count -= 1;
                }
            }
        }
    }

    // Vertical pass: for each column, dilate along y using horiz as input
    let mut result = vec![false; w * h];
    for x in 0..w {
        let mut count = 0usize;

        // Initialize window for y=0
        for wy in 0..=(r.min(h - 1)) {
            if horiz[wy * w + x] {
                count += 1;
            }
        }

        for y in 0..h {
            if count > 0 {
                result[y * w + x] = true;
            }
            let add_y = y + r + 1;
            if add_y < h && horiz[add_y * w + x] {
                count += 1;
            }
            if y >= r {
                let remove_y = y - r;
                if horiz[remove_y * w + x] {
                    count -= 1;
                }
            }
        }
    }

    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn radius_zero_is_noop() {
        let mask = vec![false, true, false, false];
        let result = dilate(&mask, 2, 2, 0);
        assert_eq!(result, mask);
    }

    #[test]
    fn single_pixel_expands() {
        // 5x5 grid with one true pixel at center (2,2)
        let mut mask = vec![false; 25];
        mask[2 * 5 + 2] = true;

        let result = dilate(&mask, 5, 5, 1);

        // Should expand to a cross/diamond shape with radius 1
        // Center and all 4-neighbors should be true
        assert!(result[2 * 5 + 2]); // center
        assert!(result[1 * 5 + 2]); // up
        assert!(result[3 * 5 + 2]); // down
        assert!(result[2 * 5 + 1]); // left
        assert!(result[2 * 5 + 3]); // right
        // Corners at distance 2 should also be true (box dilation, not diamond)
        assert!(result[1 * 5 + 1]); // up-left
        assert!(result[1 * 5 + 3]); // up-right
        assert!(result[3 * 5 + 1]); // down-left
        assert!(result[3 * 5 + 3]); // down-right
    }

    #[test]
    fn nearby_pixels_merge() {
        // Two pixels 3 apart: (0,0) and (0,4) in a 5x1 grid
        let mask = vec![true, false, false, false, true];
        let result = dilate(&mask, 5, 1, 2);
        // With radius 2, they should merge into one contiguous block
        assert!(result.iter().all(|&v| v));
    }

    #[test]
    fn distant_pixels_stay_separate() {
        // Two pixels far apart in a 10x1 grid
        let mut mask = vec![false; 10];
        mask[0] = true;
        mask[9] = true;
        let result = dilate(&mask, 10, 1, 2);
        // Pixel 0 expands to [0,2], pixel 9 expands to [7,9]
        // Middle pixels [3,6] should remain false
        assert!(!result[4]);
        assert!(!result[5]);
    }
}