Skip to content

Commit 9a97c68

Browse files
committed
feat: add SSIM perceptual diffing and JSON output support
- Integrated image-compare crate for structural similarity (SSIM) - Added --json flag for machine-readable results - Improved image handling with automatic padding for mismatched dimensions - Updated CLI output with detailed pixel and structural statistics
1 parent b56c49e commit 9a97c68

4 files changed

Lines changed: 112 additions & 66 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@ colored = "2.1.0"
1515
indicatif = "0.17.8"
1616
terminal_size = "0.3.0"
1717
pathdiff = "0.2.1"
18+
serde = { version = "1.0.200", features = ["derive"] }
19+
serde_json = "1.0.116"
20+
image-compare = "0.5.0"

src/compare.rs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
use anyhow::Result;
22
use image::{GenericImageView, ImageBuffer, Rgba};
3+
use image_compare::Algorithm;
4+
use serde::Serialize;
35
use std::path::Path;
46

7+
#[derive(Serialize)]
58
pub struct DiffResult {
69
pub score: f64,
10+
pub ssim_score: f64,
711
pub diff_pixels: u64,
812
pub total_pixels: u64,
13+
#[serde(skip)]
914
pub diff_image: Option<ImageBuffer<Rgba<u8>, Vec<u8>>>,
1015
}
1116

@@ -24,6 +29,23 @@ pub fn compare_images(
2429
let max_width = width_a.max(width_b);
2530
let max_height = height_a.max(height_b);
2631

32+
// For SSIM, we need identical dimensions.
33+
// We'll use the max dimensions and pad with transparent pixels if needed.
34+
let mut rgba_a = img_a.to_rgba8();
35+
let mut rgba_b = img_b.to_rgba8();
36+
37+
if width_a != max_width || height_a != max_height {
38+
let mut new_a = ImageBuffer::new(max_width, max_height);
39+
image::imageops::overlay(&mut new_a, &rgba_a, 0, 0);
40+
rgba_a = new_a;
41+
}
42+
43+
if width_b != max_width || height_b != max_height {
44+
let mut new_b = ImageBuffer::new(max_width, max_height);
45+
image::imageops::overlay(&mut new_b, &rgba_b, 0, 0);
46+
rgba_b = new_b;
47+
}
48+
2749
let mut diff_pixels = 0u64;
2850
let total_pixels = (max_width as u64) * (max_height as u64);
2951

@@ -35,30 +57,19 @@ pub fn compare_images(
3557

3658
for y in 0..max_height {
3759
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);
60+
let pixel_a = rgba_a.get_pixel(x, y);
61+
let pixel_b = rgba_b.get_pixel(x, y);
62+
63+
let dist = color_distance(pixel_a, pixel_b);
5164

5265
let is_different = dist > (threshold as f64);
5366

5467
if is_different {
5568
diff_pixels += 1;
5669
if let Some(ref mut buffer) = diff_buffer {
57-
// Magenta for differences
5870
buffer.put_pixel(x, y, Rgba([255, 0, 255, 255]));
5971
}
6072
} else if let Some(ref mut buffer) = diff_buffer {
61-
// Dimmed version of original for non-diff
6273
let r = (pixel_a[0] as f32 * 0.1) as u8;
6374
let g = (pixel_a[1] as f32 * 0.1) as u8;
6475
let b = (pixel_a[2] as f32 * 0.1) as u8;
@@ -69,14 +80,21 @@ pub fn compare_images(
6980

7081
let score = 1.0 - (diff_pixels as f64 / total_pixels as f64);
7182

83+
// Calculate SSIM using RGB
84+
let rgb_a = image::DynamicImage::ImageRgba8(rgba_a).to_rgb8();
85+
let rgb_b = image::DynamicImage::ImageRgba8(rgba_b).to_rgb8();
86+
let ssim_score = image_compare::rgb_similarity_structure(&Algorithm::MSSIMSimple, &rgb_a, &rgb_b).unwrap().score;
87+
7288
Ok(DiffResult {
7389
score,
90+
ssim_score,
7491
diff_pixels,
7592
total_pixels,
7693
diff_image: diff_buffer,
7794
})
7895
}
7996

97+
8098
fn color_distance(p1: &Rgba<u8>, p2: &Rgba<u8>) -> f64 {
8199
let r_diff = (p1[0] as f64 - p2[0] as f64) / 255.0;
82100
let g_diff = (p1[1] as f64 - p2[1] as f64) / 255.0;

src/dir.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ use anyhow::Result;
33
use indicatif::{ProgressBar, ProgressStyle};
44
use rayon::prelude::*;
55
use std::path::{Path, PathBuf};
6+
use serde::Serialize;
67
use walkdir::WalkDir;
78

9+
#[derive(Serialize)]
10+
#[serde(tag = "type", content = "data")]
811
pub enum DirDiffStatus {
912
Match(DiffResult),
1013
MissingInB,
1114
Error(String),
1215
}
1316

17+
#[derive(Serialize)]
1418
pub struct DirDiffItem {
1519
pub relative_path: PathBuf,
1620
pub status: DirDiffStatus,

src/main.rs

Lines changed: 72 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ struct Args {
3131
/// Fail if any difference is found (non-zero exit code)
3232
#[arg(long)]
3333
fail_on_diff: bool,
34+
35+
/// Output results in JSON format
36+
#[arg(long)]
37+
json: bool,
3438
}
3539

3640
fn main() -> Result<()> {
@@ -51,21 +55,26 @@ fn run_file_diff(args: &Args) -> Result<()> {
5155
args.output.is_some() || args.preview,
5256
)?;
5357

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-
}
58+
if args.json {
59+
println!("{}", serde_json::to_string_pretty(&res)?);
60+
} else {
61+
println!("{}", "Comparison Result:".bold());
62+
println!(" Pixel Similarity: {:.2}%", res.score * 100.0);
63+
println!(" SSIM Score: {:.4}", res.ssim_score);
64+
println!(" Diff Pixels: {}", res.diff_pixels);
65+
println!(" Total Pixels: {}", res.total_pixels);
66+
67+
if let Some(diff_img) = &res.diff_image {
68+
if let Some(output_path) = &args.output {
69+
diff_img.save(output_path)?;
70+
println!(" Diff image saved to: {}", output_path.display().to_string().cyan());
71+
}
6472

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);
73+
if args.preview {
74+
println!("\n{}", "Terminal Preview:".bold());
75+
let dynamic_img = image::DynamicImage::ImageRgba8(diff_img.clone());
76+
terminal::print_preview(&dynamic_img);
77+
}
6978
}
7079
}
7180

@@ -79,49 +88,61 @@ fn run_file_diff(args: &Args) -> Result<()> {
7988
fn run_dir_diff(args: &Args) -> Result<()> {
8089
let items = dir::compare_directories(&args.path_a, &args.path_b, args.threshold)?;
8190

82-
println!("\n{:<40} {:<10} {:<10}", "File", "Score", "Status");
83-
println!("{}", "-".repeat(65));
84-
8591
let mut diff_count = 0;
8692

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-
);
93+
if args.json {
94+
println!("{}", serde_json::to_string_pretty(&items)?);
95+
// Calculate diff_count for exit code even in JSON mode
96+
for item in &items {
97+
match item.status {
98+
dir::DirDiffStatus::Match(ref res) if res.diff_pixels > 0 => diff_count += 1,
99+
dir::DirDiffStatus::MissingInB => diff_count += 1,
100+
_ => {}
110101
}
111-
dir::DirDiffStatus::Error(e) => {
112-
println!("{:<40} {:<10} {:<10}",
113-
item.relative_path.display().to_string(),
114-
"ERROR".red(),
115-
e.yellow()
116-
);
102+
}
103+
} else {
104+
println!("\n{:<40} {:<10} {:<10} {:<10}", "File", "Pixel", "SSIM", "Status");
105+
println!("{}", "-".repeat(75));
106+
107+
for item in &items {
108+
match item.status {
109+
dir::DirDiffStatus::Match(ref res) => {
110+
let status = if res.diff_pixels > 0 {
111+
diff_count += 1;
112+
"DIFF".red()
113+
} else {
114+
"OK".green()
115+
};
116+
println!("{:<40} {:<10.2}% {:<10.4} {:<10}",
117+
item.relative_path.display().to_string(),
118+
res.score * 100.0,
119+
res.ssim_score,
120+
status
121+
);
122+
}
123+
dir::DirDiffStatus::MissingInB => {
124+
diff_count += 1;
125+
println!("{:<40} {:<10} {:<10}",
126+
item.relative_path.display().to_string(),
127+
"-".dimmed(),
128+
"MISSING".yellow()
129+
);
130+
}
131+
dir::DirDiffStatus::Error(ref e) => {
132+
println!("{:<40} {:<10} {:<10}",
133+
item.relative_path.display().to_string(),
134+
"ERROR".red(),
135+
e.yellow()
136+
);
137+
}
117138
}
118139
}
119-
}
120140

121-
println!("\nSummary: {} files compared, {} differences found.",
122-
total_files,
123-
diff_count
124-
);
141+
println!("\nSummary: {} files compared, {} differences found.",
142+
items.len(),
143+
diff_count
144+
);
145+
}
125146

126147
if args.fail_on_diff && diff_count > 0 {
127148
std::process::exit(1);

0 commit comments

Comments
 (0)