agent-image-diff 0.2.4

Structured image diff with JSON output for agent workflows
Documentation
use image::RgbaImage;

use crate::region::Region;

/// Labels for region classification.
pub const LABEL_ADDED: &str = "added";
pub const LABEL_REMOVED: &str = "removed";
pub const LABEL_COLOR_CHANGE: &str = "color-change";
pub const LABEL_CONTENT_CHANGE: &str = "content-change";

/// Classify each region by analyzing pixels in the baseline and candidate images.
///
/// Labels:
/// - "added": region area is mostly uniform/empty in baseline
/// - "removed": region area is mostly uniform/empty in candidate
/// - "color-change": structure (edges) matches but colors differ
/// - "content-change": structure differs (fallback)
pub fn classify_regions(
    regions: &mut [Region],
    baseline: &RgbaImage,
    candidate: &RgbaImage,
    diff_mask: &[bool],
    width: u32,
    height: u32,
) {
    for region in regions.iter_mut() {
        region.label = classify_one(region, baseline, candidate, diff_mask, width, height);
    }
}

fn classify_one(
    region: &Region,
    baseline: &RgbaImage,
    candidate: &RgbaImage,
    diff_mask: &[bool],
    width: u32,
    height: u32,
) -> String {
    let bb = &region.bounding_box;

    // Collect changed pixels within the bounding box
    let mut baseline_variances = Vec::new();
    let mut candidate_variances = Vec::new();
    let mut gradient_matches = 0u32;
    let mut gradient_total = 0u32;

    let x_end = (bb.x + bb.width).min(width);
    let y_end = (bb.y + bb.height).min(height);

    for y in bb.y..y_end {
        for x in bb.x..x_end {
            let idx = (y * width + x) as usize;
            if idx >= diff_mask.len() || !diff_mask[idx] {
                continue;
            }

            // Compute local luminance for variance check
            if x < baseline.width() && y < baseline.height() {
                baseline_variances.push(pixel_luminance(baseline, x, y));
            }
            if x < candidate.width() && y < candidate.height() {
                candidate_variances.push(pixel_luminance(candidate, x, y));
            }

            // Gradient direction check (needs neighbors)
            if x > 0 && x + 1 < width && y > 0 && y + 1 < height
                && x < baseline.width().saturating_sub(1)
                && y < baseline.height().saturating_sub(1)
                && x < candidate.width().saturating_sub(1)
                && y < candidate.height().saturating_sub(1)
            {
                gradient_total += 1;
                if gradients_match(baseline, candidate, x, y) {
                    gradient_matches += 1;
                }
            }
        }
    }

    // Check for added/removed: one image has very low variance (uniform background)
    let baseline_var = variance(&baseline_variances);
    let candidate_var = variance(&candidate_variances);
    let low_var_threshold = 100.0; // luminance variance threshold for "uniform"

    if baseline_var < low_var_threshold && candidate_var >= low_var_threshold {
        return LABEL_ADDED.to_string();
    }
    if candidate_var < low_var_threshold && baseline_var >= low_var_threshold {
        return LABEL_REMOVED.to_string();
    }

    // Check for color-change vs content-change via gradient matching
    if gradient_total > 0 {
        let match_ratio = gradient_matches as f64 / gradient_total as f64;
        if match_ratio > 0.7 {
            return LABEL_COLOR_CHANGE.to_string();
        }
    }

    LABEL_CONTENT_CHANGE.to_string()
}

/// Get luminance of a pixel (simple weighted average).
fn pixel_luminance(img: &RgbaImage, x: u32, y: u32) -> f64 {
    let px = img.get_pixel(x, y).0;
    0.299 * px[0] as f64 + 0.587 * px[1] as f64 + 0.114 * px[2] as f64
}

/// Check if the gradient direction (horizontal and vertical) at (x,y)
/// is similar in both images. Returns true if signs match.
fn gradients_match(baseline: &RgbaImage, candidate: &RgbaImage, x: u32, y: u32) -> bool {
    let b_gx = pixel_luminance(baseline, x + 1, y) - pixel_luminance(baseline, x - 1, y);
    let b_gy = pixel_luminance(baseline, x, y + 1) - pixel_luminance(baseline, x, y - 1);
    let c_gx = pixel_luminance(candidate, x + 1, y) - pixel_luminance(candidate, x - 1, y);
    let c_gy = pixel_luminance(candidate, x, y + 1) - pixel_luminance(candidate, x, y - 1);

    // Signs match if both have same direction (or both near zero)
    let threshold = 5.0; // ignore tiny gradients
    let gx_match = (b_gx.abs() < threshold && c_gx.abs() < threshold)
        || (b_gx.signum() == c_gx.signum());
    let gy_match = (b_gy.abs() < threshold && c_gy.abs() < threshold)
        || (b_gy.signum() == c_gy.signum());

    gx_match && gy_match
}

/// Compute variance of a slice of f64 values.
fn variance(values: &[f64]) -> f64 {
    if values.is_empty() {
        return 0.0;
    }
    let mean = values.iter().sum::<f64>() / values.len() as f64;
    values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64
}

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

    #[test]
    fn variance_of_uniform_is_zero() {
        let vals = vec![100.0, 100.0, 100.0];
        assert_eq!(variance(&vals), 0.0);
    }

    #[test]
    fn variance_of_varied_is_high() {
        let vals = vec![0.0, 255.0, 0.0, 255.0];
        assert!(variance(&vals) > 1000.0);
    }

    #[test]
    fn variance_empty_is_zero() {
        assert_eq!(variance(&[]), 0.0);
    }
}