agent-image-diff 0.2.4

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

use crate::antialias;
use crate::color;

pub struct CompareResult {
    pub diff_mask: Vec<bool>,
    pub delta_map: Vec<f64>,
    pub width: u32,
    pub height: u32,
    pub aa_pixel_count: u64,
}

pub struct CompareOptions {
    pub threshold: f64,
    pub detect_antialias: bool,
}

/// Compare two images pixel by pixel.
///
/// If images have different dimensions, the comparison area is the
/// minimum of both dimensions. Pixels outside the intersection in
/// the larger image are marked as different.
pub fn compare(img1: &RgbaImage, img2: &RgbaImage, options: &CompareOptions) -> CompareResult {
    let width = img1.width().min(img2.width());
    let height = img1.height().min(img2.height());
    let max_width = img1.width().max(img2.width());
    let max_height = img1.height().max(img2.height());

    let total = max_width as usize * max_height as usize;
    let mut diff_mask = vec![false; total];
    let mut delta_map = vec![0.0f64; total];

    // Mark pixels outside the intersection as different
    if max_width > width || max_height > height {
        for y in 0..max_height {
            for x in 0..max_width {
                if x >= width || y >= height {
                    let idx = (y * max_width + x) as usize;
                    diff_mask[idx] = true;
                    delta_map[idx] = 1.0;
                }
            }
        }
    }

    // Compare the intersection region in parallel across rows
    let rows: Vec<(Vec<bool>, Vec<f64>, u64)> = (0..height)
        .into_par_iter()
        .map(|y| {
            let mut row_mask = vec![false; width as usize];
            let mut row_delta = vec![0.0f64; width as usize];
            let mut row_aa = 0u64;

            for x in 0..width {
                let px1 = img1.get_pixel(x, y).0;
                let px2 = img2.get_pixel(x, y).0;

                // Fast path: identical pixels
                if px1 == px2 {
                    continue;
                }

                let delta = color::color_delta(px1, px2, false);
                row_delta[x as usize] = delta;

                if delta > options.threshold {
                    if options.detect_antialias
                        && (antialias::is_antialiased(img1, img2, x, y)
                            || antialias::is_antialiased(img2, img1, x, y))
                    {
                        row_aa += 1;
                    } else {
                        row_mask[x as usize] = true;
                    }
                }
            }

            (row_mask, row_delta, row_aa)
        })
        .collect();

    // Flatten rows into the output arrays
    let mut total_aa = 0u64;
    for (y, (row_mask, row_delta, row_aa)) in rows.into_iter().enumerate() {
        let row_start = y * max_width as usize;
        for (x, (mask, delta)) in row_mask.into_iter().zip(row_delta).enumerate() {
            diff_mask[row_start + x] = mask;
            delta_map[row_start + x] = delta;
        }
        total_aa += row_aa;
    }

    CompareResult {
        diff_mask,
        delta_map,
        width: max_width,
        height: max_height,
        aa_pixel_count: total_aa,
    }
}