Skip to content

Commit b56c49e

Browse files
committed
feat: implement image-diff CLI with terminal previews and recursive directory diffing
- Core pixel-by-pixel comparison with threshold support - Parallel directory walking and comparison using Rayon - ANSI half-block terminal preview for instant feedback - Semantic exit codes and directory summary reporting
1 parent da946f0 commit b56c49e

6 files changed

Lines changed: 356 additions & 21 deletions

File tree

.gitignore

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,6 @@
1-
# Generated by Cargo
2-
# will have compiled files and executables
3-
debug
4-
target
5-
6-
# These are backup files generated by rustfmt
7-
**/*.rs.bk
8-
9-
# MSVC Windows builds of rustc generate these, which store debugging information
10-
*.pdb
11-
12-
# Generated by cargo mutants
13-
# Contains mutation testing data
14-
**/mutants.out*/
15-
16-
# RustRover
17-
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
18-
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
19-
# and can be added to the global gitignore or merged into this file. For a more nuclear
20-
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
21-
#.idea/
1+
/target
2+
Cargo.lock
3+
.DS_Store
4+
*.png
5+
*.jpg
6+
*.jpeg

Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "image-diff"
3+
version = "0.1.0"
4+
edition = "2021"
5+
authors = ["Batman"]
6+
description = "Visual Difference Detection CLI for Images"
7+
8+
[dependencies]
9+
image = "0.25.1"
10+
clap = { version = "4.5.4", features = ["derive"] }
11+
rayon = "1.10.0"
12+
walkdir = "2.5.0"
13+
anyhow = "1.0.82"
14+
colored = "2.1.0"
15+
indicatif = "0.17.8"
16+
terminal_size = "0.3.0"
17+
pathdiff = "0.2.1"

src/compare.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
use anyhow::Result;
2+
use image::{GenericImageView, ImageBuffer, Rgba};
3+
use std::path::Path;
4+
5+
pub struct DiffResult {
6+
pub score: f64,
7+
pub diff_pixels: u64,
8+
pub total_pixels: u64,
9+
pub diff_image: Option<ImageBuffer<Rgba<u8>, Vec<u8>>>,
10+
}
11+
12+
pub fn compare_images(
13+
path_a: &Path,
14+
path_b: &Path,
15+
threshold: f32,
16+
generate_diff: bool,
17+
) -> Result<DiffResult> {
18+
let img_a = image::open(path_a)?;
19+
let img_b = image::open(path_b)?;
20+
21+
let (width_a, height_a) = img_a.dimensions();
22+
let (width_b, height_b) = img_b.dimensions();
23+
24+
let max_width = width_a.max(width_b);
25+
let max_height = height_a.max(height_b);
26+
27+
let mut diff_pixels = 0u64;
28+
let total_pixels = (max_width as u64) * (max_height as u64);
29+
30+
let mut diff_buffer = if generate_diff {
31+
Some(ImageBuffer::new(max_width, max_height))
32+
} else {
33+
None
34+
};
35+
36+
for y in 0..max_height {
37+
for x in 0..max_width {
38+
let pixel_a = if x < width_a && y < height_a {
39+
img_a.get_pixel(x, y)
40+
} else {
41+
Rgba([0, 0, 0, 0])
42+
};
43+
44+
let pixel_b = if x < width_b && y < height_b {
45+
img_b.get_pixel(x, y)
46+
} else {
47+
Rgba([0, 0, 0, 0])
48+
};
49+
50+
let dist = color_distance(&pixel_a, &pixel_b);
51+
52+
let is_different = dist > (threshold as f64);
53+
54+
if is_different {
55+
diff_pixels += 1;
56+
if let Some(ref mut buffer) = diff_buffer {
57+
// Magenta for differences
58+
buffer.put_pixel(x, y, Rgba([255, 0, 255, 255]));
59+
}
60+
} else if let Some(ref mut buffer) = diff_buffer {
61+
// Dimmed version of original for non-diff
62+
let r = (pixel_a[0] as f32 * 0.1) as u8;
63+
let g = (pixel_a[1] as f32 * 0.1) as u8;
64+
let b = (pixel_a[2] as f32 * 0.1) as u8;
65+
buffer.put_pixel(x, y, Rgba([r, g, b, 255]));
66+
}
67+
}
68+
}
69+
70+
let score = 1.0 - (diff_pixels as f64 / total_pixels as f64);
71+
72+
Ok(DiffResult {
73+
score,
74+
diff_pixels,
75+
total_pixels,
76+
diff_image: diff_buffer,
77+
})
78+
}
79+
80+
fn color_distance(p1: &Rgba<u8>, p2: &Rgba<u8>) -> f64 {
81+
let r_diff = (p1[0] as f64 - p2[0] as f64) / 255.0;
82+
let g_diff = (p1[1] as f64 - p2[1] as f64) / 255.0;
83+
let b_diff = (p1[2] as f64 - p2[2] as f64) / 255.0;
84+
let a_diff = (p1[3] as f64 - p2[3] as f64) / 255.0;
85+
86+
// Euclidean distance in RGBA space
87+
(r_diff * r_diff + g_diff * g_diff + b_diff * b_diff + a_diff * a_diff).sqrt()
88+
}

src/dir.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use crate::compare::{compare_images, DiffResult};
2+
use anyhow::Result;
3+
use indicatif::{ProgressBar, ProgressStyle};
4+
use rayon::prelude::*;
5+
use std::path::{Path, PathBuf};
6+
use walkdir::WalkDir;
7+
8+
pub enum DirDiffStatus {
9+
Match(DiffResult),
10+
MissingInB,
11+
Error(String),
12+
}
13+
14+
pub struct DirDiffItem {
15+
pub relative_path: PathBuf,
16+
pub status: DirDiffStatus,
17+
}
18+
19+
pub fn compare_directories(
20+
dir_a: &Path,
21+
dir_b: &Path,
22+
threshold: f32,
23+
) -> Result<Vec<DirDiffItem>> {
24+
let files_a: Vec<PathBuf> = WalkDir::new(dir_a)
25+
.into_iter()
26+
.filter_map(|e| e.ok())
27+
.filter(|e| is_image(e.path()))
28+
.map(|e| e.path().to_path_buf())
29+
.collect();
30+
31+
let pb = ProgressBar::new(files_a.len() as u64);
32+
pb.set_style(ProgressStyle::default_bar()
33+
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
34+
.unwrap());
35+
36+
let results: Vec<DirDiffItem> = files_a
37+
.into_par_iter()
38+
.map(|path_a| {
39+
let relative = path_a.strip_prefix(dir_a).unwrap();
40+
let path_b = dir_b.join(relative);
41+
42+
let status = if !path_b.exists() {
43+
DirDiffStatus::MissingInB
44+
} else {
45+
match compare_images(&path_a, &path_b, threshold, false) {
46+
Ok(res) => DirDiffStatus::Match(res),
47+
Err(e) => DirDiffStatus::Error(e.to_string()),
48+
}
49+
};
50+
51+
pb.inc(1);
52+
53+
DirDiffItem {
54+
relative_path: relative.to_path_buf(),
55+
status,
56+
}
57+
})
58+
.collect();
59+
60+
pb.finish_with_message("Done");
61+
Ok(results)
62+
}
63+
64+
fn is_image(path: &Path) -> bool {
65+
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
66+
matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "webp" | "bmp")
67+
}

src/main.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
mod compare;
2+
mod dir;
3+
mod terminal;
4+
5+
use anyhow::Result;
6+
use clap::Parser;
7+
use colored::*;
8+
use std::path::PathBuf;
9+
10+
#[derive(Parser, Debug)]
11+
#[command(author, version, about, long_about = None)]
12+
struct Args {
13+
/// First image or directory
14+
path_a: PathBuf,
15+
16+
/// Second image or directory
17+
path_b: PathBuf,
18+
19+
/// Threshold for difference (0.0 to 1.0)
20+
#[arg(short, long, default_value_t = 0.1)]
21+
threshold: f32,
22+
23+
/// Output path for diff overlay image (single file mode only)
24+
#[arg(short, long)]
25+
output: Option<PathBuf>,
26+
27+
/// Print preview in terminal
28+
#[arg(short, long)]
29+
preview: bool,
30+
31+
/// Fail if any difference is found (non-zero exit code)
32+
#[arg(long)]
33+
fail_on_diff: bool,
34+
}
35+
36+
fn main() -> Result<()> {
37+
let args = Args::parse();
38+
39+
if args.path_a.is_dir() && args.path_b.is_dir() {
40+
run_dir_diff(&args)
41+
} else {
42+
run_file_diff(&args)
43+
}
44+
}
45+
46+
fn run_file_diff(args: &Args) -> Result<()> {
47+
let res = compare::compare_images(
48+
&args.path_a,
49+
&args.path_b,
50+
args.threshold,
51+
args.output.is_some() || args.preview,
52+
)?;
53+
54+
println!("{}", "Comparison Result:".bold());
55+
println!(" Similarity: {:.2}%", res.score * 100.0);
56+
println!(" Diff Pixels: {}", res.diff_pixels);
57+
println!(" Total Pixels: {}", res.total_pixels);
58+
59+
if let Some(diff_img) = &res.diff_image {
60+
if let Some(output_path) = &args.output {
61+
diff_img.save(output_path)?;
62+
println!(" Diff image saved to: {}", output_path.display().to_string().cyan());
63+
}
64+
65+
if args.preview {
66+
println!("\n{}", "Terminal Preview:".bold());
67+
let dynamic_img = image::DynamicImage::ImageRgba8(diff_img.clone());
68+
terminal::print_preview(&dynamic_img);
69+
}
70+
}
71+
72+
if args.fail_on_diff && res.diff_pixels > 0 {
73+
std::process::exit(1);
74+
}
75+
76+
Ok(())
77+
}
78+
79+
fn run_dir_diff(args: &Args) -> Result<()> {
80+
let items = dir::compare_directories(&args.path_a, &args.path_b, args.threshold)?;
81+
82+
println!("\n{:<40} {:<10} {:<10}", "File", "Score", "Status");
83+
println!("{}", "-".repeat(65));
84+
85+
let mut diff_count = 0;
86+
87+
let total_files = items.len();
88+
for item in items {
89+
match item.status {
90+
dir::DirDiffStatus::Match(res) => {
91+
let status = if res.diff_pixels > 0 {
92+
diff_count += 1;
93+
"DIFF".red()
94+
} else {
95+
"OK".green()
96+
};
97+
println!("{:<40} {:<10.2}% {:<10}",
98+
item.relative_path.display().to_string(),
99+
res.score * 100.0,
100+
status
101+
);
102+
}
103+
dir::DirDiffStatus::MissingInB => {
104+
diff_count += 1;
105+
println!("{:<40} {:<10} {:<10}",
106+
item.relative_path.display().to_string(),
107+
"-".dimmed(),
108+
"MISSING".yellow()
109+
);
110+
}
111+
dir::DirDiffStatus::Error(e) => {
112+
println!("{:<40} {:<10} {:<10}",
113+
item.relative_path.display().to_string(),
114+
"ERROR".red(),
115+
e.yellow()
116+
);
117+
}
118+
}
119+
}
120+
121+
println!("\nSummary: {} files compared, {} differences found.",
122+
total_files,
123+
diff_count
124+
);
125+
126+
if args.fail_on_diff && diff_count > 0 {
127+
std::process::exit(1);
128+
}
129+
130+
Ok(())
131+
}

src/terminal.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use colored::*;
2+
use image::{DynamicImage, GenericImageView, Rgba};
3+
use terminal_size::{terminal_size, Width};
4+
5+
pub fn print_preview(img: &DynamicImage) {
6+
let (tw, _th) = if let Some((Width(w), h)) = terminal_size() {
7+
(w as u32, h.0 as u32)
8+
} else {
9+
(80, 24)
10+
};
11+
12+
let (width, height) = img.dimensions();
13+
let aspect_ratio = height as f32 / width as f32;
14+
15+
// We use half blocks, so each character is 2 vertical pixels
16+
let target_width = tw.min(width).min(80); // Cap width for readability
17+
let target_height = (target_width as f32 * aspect_ratio) as u32;
18+
19+
let resized = img.resize_exact(target_width, target_height * 2, image::imageops::FilterType::Nearest);
20+
21+
for y in (0..resized.height()).step_by(2) {
22+
for x in 0..resized.width() {
23+
let top = resized.get_pixel(x, y);
24+
let bottom = if y + 1 < resized.height() {
25+
resized.get_pixel(x, y + 1)
26+
} else {
27+
Rgba([0, 0, 0, 0])
28+
};
29+
30+
let top_color = to_ansi_color(top);
31+
let bottom_color = to_ansi_color(bottom);
32+
33+
print!("{}", "▀".truecolor(top_color.0, top_color.1, top_color.2)
34+
.on_truecolor(bottom_color.0, bottom_color.1, bottom_color.2));
35+
}
36+
println!();
37+
}
38+
}
39+
40+
fn to_ansi_color(pixel: Rgba<u8>) -> (u8, u8, u8) {
41+
let alpha = pixel[3] as f32 / 255.0;
42+
(
43+
(pixel[0] as f32 * alpha) as u8,
44+
(pixel[1] as f32 * alpha) as u8,
45+
(pixel[2] as f32 * alpha) as u8,
46+
)
47+
}

0 commit comments

Comments
 (0)