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,
},
);
denoise::denoise(
&mut compare_result.diff_mask,
compare_result.width,
compare_result.height,
cli.denoise,
);
let dilated_mask = dilate::dilate(
&compare_result.diff_mask,
compare_result.width,
compare_result.height,
cli.dilate,
);
let labels = cluster::label_components(
&dilated_mask,
compare_result.width,
compare_result.height,
cli.connectivity,
);
let mut regions = region::extract_regions(
&labels,
&compare_result.delta_map,
compare_result.width,
compare_result.height,
cli.min_region_size,
);
merge::merge_regions(&mut regions, cli.merge_distance);
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")?;
}
}
}
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(())
}