Skip to main content

agent_image_diff/
color.rs

1/// Maximum possible YIQ delta squared, used for normalization.
2/// Derived from max possible RGB difference (255,255,255) through YIQ weights.
3const MAX_YIQ_DELTA_SQ: f64 = 35215.0;
4
5pub type Rgba = [u8; 4];
6
7/// Calculate perceptual color delta between two RGBA pixels using YIQ color space.
8/// Returns a value in [0.0, 1.0] representing the magnitude of difference.
9/// Based on Kotsarenko & Ramos (2010) as adapted by pixelmatch.
10pub fn color_delta(px1: Rgba, px2: Rgba, y_only: bool) -> f64 {
11    let (r1, g1, b1) = blend(px1);
12    let (r2, g2, b2) = blend(px2);
13
14    let dr = r1 - r2;
15    let dg = g1 - g2;
16    let db = b1 - b2;
17
18    // Luminance (Y) component
19    let y = 0.29889531 * dr + 0.58662247 * dg + 0.11448223 * db;
20
21    if y_only {
22        return y;
23    }
24
25    // Chrominance (I, Q) components
26    let i = 0.59597799 * dr - 0.27417610 * dg - 0.32180189 * db;
27    let q = 0.21147017 * dr - 0.52261711 * dg + 0.31114694 * db;
28
29    let delta_sq = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
30
31    (delta_sq / MAX_YIQ_DELTA_SQ).sqrt()
32}
33
34/// Blend pixel with alpha against a white background, returning (r, g, b) as f64.
35fn blend(px: Rgba) -> (f64, f64, f64) {
36    let a = px[3] as f64 / 255.0;
37    if (a - 1.0).abs() < f64::EPSILON {
38        return (px[0] as f64, px[1] as f64, px[2] as f64);
39    }
40    let bg = 255.0;
41    (
42        bg * (1.0 - a) + px[0] as f64 * a,
43        bg * (1.0 - a) + px[1] as f64 * a,
44        bg * (1.0 - a) + px[2] as f64 * a,
45    )
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[test]
53    fn identical_pixels_have_zero_delta() {
54        let px = [128, 64, 200, 255];
55        assert_eq!(color_delta(px, px, false), 0.0);
56    }
57
58    #[test]
59    fn black_vs_white_is_high_delta() {
60        let black = [0, 0, 0, 255];
61        let white = [255, 255, 255, 255];
62        let delta = color_delta(black, white, false);
63        assert!(delta > 0.9, "black vs white delta should be near 1.0, got {delta}");
64    }
65
66    #[test]
67    fn similar_colors_have_small_delta() {
68        let a = [100, 100, 100, 255];
69        let b = [102, 100, 101, 255];
70        let delta = color_delta(a, b, false);
71        assert!(delta < 0.02, "similar colors should have small delta, got {delta}");
72    }
73
74    #[test]
75    fn transparent_vs_opaque_blends_against_white() {
76        let transparent = [0, 0, 0, 0]; // fully transparent -> blends to white
77        let white = [255, 255, 255, 255];
78        let delta = color_delta(transparent, white, false);
79        assert!(delta < 0.001, "transparent pixel should blend to white background, got {delta}");
80    }
81
82    #[test]
83    fn y_only_returns_luminance_delta() {
84        let a = [100, 100, 100, 255];
85        let b = [200, 100, 100, 255];
86        let y_delta = color_delta(a, b, true);
87        let full_delta = color_delta(a, b, false);
88        // Y-only should return the signed luminance difference (not magnitude)
89        assert!(y_delta.abs() < full_delta.abs() + 50.0);
90    }
91}