Skip to content

Commit 01698d5

Browse files
committed
feat: add support for ignore regions
- Added --ignore flag to skip specific image areas (x,y,w,h) - Implemented region-aware comparison loop - Updated README with usage examples
1 parent 9a97c68 commit 01698d5

4 files changed

Lines changed: 113 additions & 7 deletions

File tree

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# image-diff
2+
3+
![Build Status](https://img.shields.io/badge/build-passing-brightgreen)
4+
![License](https://img.shields.io/badge/license-MIT-blue)
5+
![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)
6+
7+
**image-diff** is a high-performance CLI tool designed for visual regression testing and dataset validation. It provides structural and pixel-level comparison of images with instant terminal-native previews.
8+
9+
## Features
10+
11+
- **Blazing Fast:** Parallel directory processing using Rust's Rayon.
12+
- **Terminal Previews:** View diff heatmaps directly in your terminal using ANSI half-blocks.
13+
- **Dual Metrics:** Reports both raw Pixel Similarity and Structural Similarity (SSIM).
14+
- **Directory Diffing:** Recursively compare folders of images with summary reporting.
15+
- **CI/CD Ready:** Support for JSON output and semantic exit codes.
16+
17+
## Installation
18+
19+
Ensure you have Rust installed, then clone and build:
20+
21+
```bash
22+
git clone https://github.com/cachevector/image-diff
23+
cd image-diff
24+
cargo build --release
25+
```
26+
27+
The binary will be available at `./target/release/image-diff`.
28+
29+
## Usage
30+
31+
### Compare two images
32+
```bash
33+
image-diff baseline.png screenshot.png --preview --output diff.png
34+
```
35+
36+
### Compare directories recursively
37+
```bash
38+
image-diff ./goldens/ ./screenshots/ --threshold 0.1
39+
```
40+
41+
### Automation & CI/CD
42+
Fail the build if any differences are found and output machine-readable results:
43+
```bash
44+
image-diff a.png b.png --json --fail-on-diff
45+
```
46+
47+
### Ignore dynamic regions
48+
Ignore parts of the image that change frequently (like timestamps):
49+
```bash
50+
image-diff a.png b.png --ignore 0,0,100,50 --ignore 500,500,200,100
51+
```
52+
53+
## CLI Options
54+
55+
| Option | Description | Default |
56+
| :--- | :--- | :--- |
57+
| `-t, --threshold` | Sensitivity for pixel comparison (0.0 to 1.0) | `0.1` |
58+
| `-p, --preview` | Render a low-res diff heatmap in the terminal | `false` |
59+
| `-o, --output` | Path to save the high-res diff overlay image | `None` |
60+
| `-i, --ignore` | Ignore region in `x,y,w,h` format | `[]` |
61+
| `--json` | Output machine-readable results in JSON format | `false` |
62+
| `--fail-on-diff` | Return exit code 1 if differences are detected | `false` |

src/compare.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,26 @@ pub struct DiffResult {
1414
pub diff_image: Option<ImageBuffer<Rgba<u8>, Vec<u8>>>,
1515
}
1616

17+
#[derive(Serialize, Clone, Debug)]
18+
pub struct Region {
19+
pub x: u32,
20+
pub y: u32,
21+
pub width: u32,
22+
pub height: u32,
23+
}
24+
25+
impl Region {
26+
pub fn contains(&self, x: u32, y: u32) -> bool {
27+
x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height
28+
}
29+
}
30+
1731
pub fn compare_images(
1832
path_a: &Path,
1933
path_b: &Path,
2034
threshold: f32,
2135
generate_diff: bool,
36+
ignore_regions: &[Region],
2237
) -> Result<DiffResult> {
2338
let img_a = image::open(path_a)?;
2439
let img_b = image::open(path_b)?;
@@ -57,10 +72,16 @@ pub fn compare_images(
5772

5873
for y in 0..max_height {
5974
for x in 0..max_width {
75+
let is_ignored = ignore_regions.iter().any(|r| r.contains(x, y));
76+
6077
let pixel_a = rgba_a.get_pixel(x, y);
6178
let pixel_b = rgba_b.get_pixel(x, y);
6279

63-
let dist = color_distance(pixel_a, pixel_b);
80+
let dist = if is_ignored {
81+
0.0 // Treat as identical
82+
} else {
83+
color_distance(pixel_a, pixel_b)
84+
};
6485

6586
let is_different = dist > (threshold as f64);
6687

@@ -70,9 +91,10 @@ pub fn compare_images(
7091
buffer.put_pixel(x, y, Rgba([255, 0, 255, 255]));
7192
}
7293
} else if let Some(ref mut buffer) = diff_buffer {
73-
let r = (pixel_a[0] as f32 * 0.1) as u8;
74-
let g = (pixel_a[1] as f32 * 0.1) as u8;
75-
let b = (pixel_a[2] as f32 * 0.1) as u8;
94+
let factor = if is_ignored { 0.02 } else { 0.1 };
95+
let r = (pixel_a[0] as f32 * factor) as u8;
96+
let g = (pixel_a[1] as f32 * factor) as u8;
97+
let b = (pixel_a[2] as f32 * factor) as u8;
7698
buffer.put_pixel(x, y, Rgba([r, g, b, 255]));
7799
}
78100
}

src/dir.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::compare::{compare_images, DiffResult};
1+
use crate::compare::{compare_images, DiffResult, Region};
22
use anyhow::Result;
33
use indicatif::{ProgressBar, ProgressStyle};
44
use rayon::prelude::*;
@@ -24,6 +24,7 @@ pub fn compare_directories(
2424
dir_a: &Path,
2525
dir_b: &Path,
2626
threshold: f32,
27+
ignore_regions: &[Region],
2728
) -> Result<Vec<DirDiffItem>> {
2829
let files_a: Vec<PathBuf> = WalkDir::new(dir_a)
2930
.into_iter()
@@ -46,7 +47,7 @@ pub fn compare_directories(
4647
let status = if !path_b.exists() {
4748
DirDiffStatus::MissingInB
4849
} else {
49-
match compare_images(&path_a, &path_b, threshold, false) {
50+
match compare_images(&path_a, &path_b, threshold, false, ignore_regions) {
5051
Ok(res) => DirDiffStatus::Match(res),
5152
Err(e) => DirDiffStatus::Error(e.to_string()),
5253
}

src/main.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ use clap::Parser;
77
use colored::*;
88
use std::path::PathBuf;
99

10+
use crate::compare::Region;
11+
use std::str::FromStr;
12+
13+
impl FromStr for Region {
14+
type Err = anyhow::Error;
15+
fn from_str(s: &str) -> Result<Self, Self::Err> {
16+
let parts: Vec<u32> = s.split(',')
17+
.map(|p| p.parse::<u32>())
18+
.collect::<std::result::Result<Vec<_>, _>>()?;
19+
if parts.len() != 4 {
20+
return Err(anyhow::anyhow!("Region must be in format x,y,width,height"));
21+
}
22+
Ok(Region { x: parts[0], y: parts[1], width: parts[2], height: parts[3] })
23+
}
24+
}
25+
1026
#[derive(Parser, Debug)]
1127
#[command(author, version, about, long_about = None)]
1228
struct Args {
@@ -35,6 +51,10 @@ struct Args {
3551
/// Output results in JSON format
3652
#[arg(long)]
3753
json: bool,
54+
55+
/// Ignore regions in format x,y,width,height (can be used multiple times)
56+
#[arg(short, long, value_delimiter = ' ')]
57+
ignore: Vec<Region>,
3858
}
3959

4060
fn main() -> Result<()> {
@@ -53,6 +73,7 @@ fn run_file_diff(args: &Args) -> Result<()> {
5373
&args.path_b,
5474
args.threshold,
5575
args.output.is_some() || args.preview,
76+
&args.ignore,
5677
)?;
5778

5879
if args.json {
@@ -86,7 +107,7 @@ fn run_file_diff(args: &Args) -> Result<()> {
86107
}
87108

88109
fn run_dir_diff(args: &Args) -> Result<()> {
89-
let items = dir::compare_directories(&args.path_a, &args.path_b, args.threshold)?;
110+
let items = dir::compare_directories(&args.path_a, &args.path_b, args.threshold, &args.ignore)?;
90111

91112
let mut diff_count = 0;
92113

0 commit comments

Comments
 (0)