agent-image-diff 0.2.4

Structured image diff with JSON output for agent workflows
Documentation
/// Maximum possible YIQ delta squared, used for normalization.
/// Derived from max possible RGB difference (255,255,255) through YIQ weights.
const MAX_YIQ_DELTA_SQ: f64 = 35215.0;

pub type Rgba = [u8; 4];

/// Calculate perceptual color delta between two RGBA pixels using YIQ color space.
/// Returns a value in [0.0, 1.0] representing the magnitude of difference.
/// Based on Kotsarenko & Ramos (2010) as adapted by pixelmatch.
pub fn color_delta(px1: Rgba, px2: Rgba, y_only: bool) -> f64 {
    let (r1, g1, b1) = blend(px1);
    let (r2, g2, b2) = blend(px2);

    let dr = r1 - r2;
    let dg = g1 - g2;
    let db = b1 - b2;

    // Luminance (Y) component
    let y = 0.29889531 * dr + 0.58662247 * dg + 0.11448223 * db;

    if y_only {
        return y;
    }

    // Chrominance (I, Q) components
    let i = 0.59597799 * dr - 0.27417610 * dg - 0.32180189 * db;
    let q = 0.21147017 * dr - 0.52261711 * dg + 0.31114694 * db;

    let delta_sq = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;

    (delta_sq / MAX_YIQ_DELTA_SQ).sqrt()
}

/// Blend pixel with alpha against a white background, returning (r, g, b) as f64.
fn blend(px: Rgba) -> (f64, f64, f64) {
    let a = px[3] as f64 / 255.0;
    if (a - 1.0).abs() < f64::EPSILON {
        return (px[0] as f64, px[1] as f64, px[2] as f64);
    }
    let bg = 255.0;
    (
        bg * (1.0 - a) + px[0] as f64 * a,
        bg * (1.0 - a) + px[1] as f64 * a,
        bg * (1.0 - a) + px[2] as f64 * a,
    )
}

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

    #[test]
    fn identical_pixels_have_zero_delta() {
        let px = [128, 64, 200, 255];
        assert_eq!(color_delta(px, px, false), 0.0);
    }

    #[test]
    fn black_vs_white_is_high_delta() {
        let black = [0, 0, 0, 255];
        let white = [255, 255, 255, 255];
        let delta = color_delta(black, white, false);
        assert!(delta > 0.9, "black vs white delta should be near 1.0, got {delta}");
    }

    #[test]
    fn similar_colors_have_small_delta() {
        let a = [100, 100, 100, 255];
        let b = [102, 100, 101, 255];
        let delta = color_delta(a, b, false);
        assert!(delta < 0.02, "similar colors should have small delta, got {delta}");
    }

    #[test]
    fn transparent_vs_opaque_blends_against_white() {
        let transparent = [0, 0, 0, 0]; // fully transparent -> blends to white
        let white = [255, 255, 255, 255];
        let delta = color_delta(transparent, white, false);
        assert!(delta < 0.001, "transparent pixel should blend to white background, got {delta}");
    }

    #[test]
    fn y_only_returns_luminance_delta() {
        let a = [100, 100, 100, 255];
        let b = [200, 100, 100, 255];
        let y_delta = color_delta(a, b, true);
        let full_delta = color_delta(a, b, false);
        // Y-only should return the signed luminance difference (not magnitude)
        assert!(y_delta.abs() < full_delta.abs() + 50.0);
    }
}