agent-image-diff 0.2.4

Structured image diff with JSON output for agent workflows
Documentation
mod cli;
mod classify;
mod cluster;
mod color;
mod compare;
mod antialias;
mod denoise;
mod dilate;
mod merge;
mod output;
mod region;

use anyhow::{Context, Result};
use clap::Parser;
use image::GenericImageView;

fn main() -> Result<()> {
    let cli = cli::Cli::parse();

    if cli.crop {
        return run_crop(&cli);
    }

    let candidate = cli
        .candidate
        .as_ref()
        .context("Missing <candidate> argument (required for diff mode)")?;

    let img1 = image::open(&cli.baseline)
        .context("Failed to open baseline image")?
        .to_rgba8();

    let img2 = image::open(candidate)
        .context("Failed to open candidate image")?
        .to_rgba8();

    let mut compare_result = compare::compare(
        &img1,
        &img2,
        &compare::CompareOptions {
            threshold: cli.threshold,
            detect_antialias: cli.detect_antialias,
        },
    );

    // Remove small noise clusters before dilation
    denoise::denoise(
        &mut compare_result.diff_mask,
        compare_result.width,
        compare_result.height,
        cli.denoise,
    );

    // Dilate the diff mask to bridge nearby changed pixels
    let dilated_mask = dilate::dilate(
        &compare_result.diff_mask,
        compare_result.width,
        compare_result.height,
        cli.dilate,
    );

    // Cluster on the dilated mask
    let labels = cluster::label_components(
        &dilated_mask,
        compare_result.width,
        compare_result.height,
        cli.connectivity,
    );

    // Extract regions using original (undilated) diff mask for accurate stats
    let mut regions = region::extract_regions(
        &labels,
        &compare_result.delta_map,
        compare_result.width,
        compare_result.height,
        cli.min_region_size,
    );

    // Merge nearby regions
    merge::merge_regions(&mut regions, cli.merge_distance);

    // Classify regions
    classify::classify_regions(
        &mut regions,
        &img1,
        &img2,
        &compare_result.diff_mask,
        compare_result.width,
        compare_result.height,
    );

    let total_pixels = compare_result.width as u64 * compare_result.height as u64;
    let changed_pixels = compare_result.diff_mask.iter().filter(|&&v| v).count() as u64;

    let dimension_mismatch = if img1.width() != img2.width() || img1.height() != img2.height() {
        Some(region::DimensionMismatch {
            baseline: region::Dimensions {
                width: img1.width(),
                height: img1.height(),
            },
            candidate: region::Dimensions {
                width: img2.width(),
                height: img2.height(),
            },
        })
    } else {
        None
    };

    let result = region::DiffResult {
        dimensions: region::Dimensions {
            width: compare_result.width,
            height: compare_result.height,
        },
        stats: region::DiffStats {
            changed_pixels,
            total_pixels,
            diff_percentage: if total_pixels > 0 {
                (changed_pixels as f64 / total_pixels as f64) * 100.0
            } else {
                0.0
            },
            region_count: regions.len(),
            antialiased_pixels: compare_result.aa_pixel_count,
        },
        is_match: regions.is_empty(),
        regions,
        dimension_mismatch,
    };

    if !cli.quiet {
        let stdout = std::io::stdout();
        let mut out = stdout.lock();
        match cli.format {
            cli::OutputFormat::Json => {
                output::json::write_json(&result, cli.verbose, cli.pretty, &mut out)?
            }
            cli::OutputFormat::Summary => output::summary::write_summary(&result, &mut out)?,
            cli::OutputFormat::Image => {
                let path = cli.output.as_ref().context(
                    "-o/--output path is required when using --format=image",
                )?;
                let diff_img = output::image::render_diff_image(
                    &img2,
                    &compare_result.diff_mask,
                    &labels,
                    &result.regions,
                );
                diff_img.save(path).context("Failed to save diff image")?;
            }
        }
    }

    // Always write diff image if -o is provided (regardless of format)
    if let Some(path) = &cli.output {
        if !matches!(cli.format, cli::OutputFormat::Image) {
            let diff_img = output::image::render_diff_image(
                &img2,
                &compare_result.diff_mask,
                &labels,
                &result.regions,
            );
            diff_img.save(path).context("Failed to save diff image")?;
        }
    }

    Ok(())
}

fn run_crop(cli: &cli::Cli) -> Result<()> {
    let x = cli.x.context("--x is required with --crop")?;
    let y = cli.y.context("--y is required with --crop")?;
    let w = cli.crop_width.context("--crop-width is required with --crop")?;
    let h = cli
        .crop_height
        .context("--crop-height is required with --crop")?;
    let output = cli
        .output
        .as_ref()
        .context("-o/--output is required with --crop")?;

    let img = image::open(&cli.baseline).context("Failed to open image")?;
    let (img_w, img_h) = img.dimensions();
    if x + w > img_w || y + h > img_h {
        anyhow::bail!(
            "Crop region ({},{} {}x{}) exceeds image dimensions ({}x{})",
            x,
            y,
            w,
            h,
            img_w,
            img_h
        );
    }
    img.crop_imm(x, y, w, h)
        .save(output)
        .context("Failed to save cropped image")?;
    Ok(())
}