agent-image-diff 0.2.4

Structured image diff with JSON output for agent workflows
Documentation
use serde::Serialize;
use std::collections::HashMap;

#[derive(Debug, Serialize)]
pub struct DiffResult {
    pub dimensions: Dimensions,
    pub stats: DiffStats,
    #[serde(rename = "match")]
    pub is_match: bool,
    pub regions: Vec<Region>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dimension_mismatch: Option<DimensionMismatch>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Dimensions {
    pub width: u32,
    pub height: u32,
}

#[derive(Debug, Serialize)]
pub struct DiffStats {
    pub changed_pixels: u64,
    pub total_pixels: u64,
    pub diff_percentage: f64,
    pub region_count: usize,
    pub antialiased_pixels: u64,
}

#[derive(Debug, Serialize)]
pub struct Region {
    pub id: u32,
    pub bounding_box: BoundingBox,
    pub pixel_count: u32,
    pub avg_delta: f64,
    pub max_delta: f64,
    pub label: String,
    /// Component label IDs from clustering that belong to this region.
    /// Used internally for mapping pixels to regions in the image renderer.
    #[serde(skip)]
    pub component_ids: Vec<u32>,
}

#[derive(Debug, Clone, Serialize)]
pub struct BoundingBox {
    pub x: u32,
    pub y: u32,
    pub width: u32,
    pub height: u32,
}

#[derive(Debug, Clone, Serialize)]
pub struct DimensionMismatch {
    pub baseline: Dimensions,
    pub candidate: Dimensions,
}

struct RegionAccumulator {
    min_x: u32,
    min_y: u32,
    max_x: u32,
    max_y: u32,
    pixel_count: u32,
    delta_sum: f64,
    max_delta: f64,
}

pub fn extract_regions(
    labels: &[u32],
    delta_map: &[f64],
    width: u32,
    height: u32,
    min_region_size: u32,
) -> Vec<Region> {
    let mut accumulators: HashMap<u32, RegionAccumulator> = HashMap::new();

    for y in 0..height {
        for x in 0..width {
            let idx = (y * width + x) as usize;
            let label = labels[idx];
            if label == 0 {
                continue;
            }
            let delta = delta_map[idx];
            accumulators
                .entry(label)
                .and_modify(|acc| {
                    acc.min_x = acc.min_x.min(x);
                    acc.min_y = acc.min_y.min(y);
                    acc.max_x = acc.max_x.max(x);
                    acc.max_y = acc.max_y.max(y);
                    acc.pixel_count += 1;
                    acc.delta_sum += delta;
                    acc.max_delta = acc.max_delta.max(delta);
                })
                .or_insert(RegionAccumulator {
                    min_x: x,
                    min_y: y,
                    max_x: x,
                    max_y: y,
                    pixel_count: 1,
                    delta_sum: delta,
                    max_delta: delta,
                });
        }
    }

    let mut regions: Vec<Region> = accumulators
        .into_iter()
        .filter(|(_, acc)| acc.pixel_count >= min_region_size)
        .map(|(component_id, acc)| Region {
            id: 0, // assigned after sorting
            bounding_box: BoundingBox {
                x: acc.min_x,
                y: acc.min_y,
                width: acc.max_x - acc.min_x + 1,
                height: acc.max_y - acc.min_y + 1,
            },
            pixel_count: acc.pixel_count,
            avg_delta: acc.delta_sum / acc.pixel_count as f64,
            max_delta: acc.max_delta,
            label: String::new(),
            component_ids: vec![component_id],
        })
        .collect();

    // Sort by pixel count descending (largest region first)
    regions.sort_by(|a, b| b.pixel_count.cmp(&a.pixel_count));

    // Assign contiguous IDs
    for (i, region) in regions.iter_mut().enumerate() {
        region.id = (i + 1) as u32;
    }

    regions
}