Skip to content

Commit 022fb04

Browse files
committed
feat: add image-based masking support
- Added --mask flag to ignore pixels based on a mask image - Pixels that are black or transparent in the mask are ignored - Updated unit tests and README
1 parent 57dbbaa commit 022fb04

File tree

4 files changed

+71
-9
lines changed

4 files changed

+71
-9
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,15 @@ image-diff a.png b.png --json --fail-on-diff
4545
```
4646

4747
### Ignore dynamic regions
48-
Ignore parts of the image that change frequently (like timestamps):
48+
Ignore parts of the image that change frequently using coordinates:
4949
```bash
50-
image-diff a.png b.png --ignore 0,0,100,50 --ignore 500,500,200,100
50+
image-diff a.png b.png --ignore 0,0,100,50
51+
```
52+
53+
### Image-based Masking
54+
Use an image as a mask. Black pixels in the mask image will be ignored in the comparison:
55+
```bash
56+
image-diff a.png b.png --mask mask.png
5157
```
5258

5359
## CLI Options
@@ -58,5 +64,6 @@ image-diff a.png b.png --ignore 0,0,100,50 --ignore 500,500,200,100
5864
| `-p, --preview` | Render a low-res diff heatmap in the terminal | `false` |
5965
| `-o, --output` | Path to save the high-res diff overlay image | `None` |
6066
| `-i, --ignore` | Ignore region in `x,y,w,h` format | `[]` |
67+
| `-m, --mask` | Path to a mask image (black = ignore) | `None` |
6168
| `--json` | Output machine-readable results in JSON format | `false` |
6269
| `--fail-on-diff` | Return exit code 1 if differences are detected | `false` |

src/compare.rs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub fn compare_images(
3434
threshold: f32,
3535
generate_diff: bool,
3636
ignore_regions: &[Region],
37+
mask_path: Option<&Path>,
3738
) -> Result<DiffResult> {
3839
let img_a = image::open(path_a)?;
3940
let img_b = image::open(path_b)?;
@@ -44,8 +45,13 @@ pub fn compare_images(
4445
let max_width = width_a.max(width_b);
4546
let max_height = height_a.max(height_b);
4647

48+
let mask_img = if let Some(path) = mask_path {
49+
Some(image::open(path)?.to_rgba8())
50+
} else {
51+
None
52+
};
53+
4754
// For SSIM, we need identical dimensions.
48-
// We'll use the max dimensions and pad with transparent pixels if needed.
4955
let mut rgba_a = img_a.to_rgba8();
5056
let mut rgba_b = img_b.to_rgba8();
5157

@@ -72,7 +78,19 @@ pub fn compare_images(
7278

7379
for y in 0..max_height {
7480
for x in 0..max_width {
75-
let is_ignored = ignore_regions.iter().any(|r| r.contains(x, y));
81+
let mut is_ignored = ignore_regions.iter().any(|r| r.contains(x, y));
82+
83+
if !is_ignored {
84+
if let Some(ref mask) = mask_img {
85+
if x < mask.width() && y < mask.height() {
86+
let mask_pixel = mask.get_pixel(x, y);
87+
// Ignore if mask pixel is black or has low alpha
88+
if (mask_pixel[0] == 0 && mask_pixel[1] == 0 && mask_pixel[2] == 0) || mask_pixel[3] < 128 {
89+
is_ignored = true;
90+
}
91+
}
92+
}
93+
}
7694

7795
let pixel_a = rgba_a.get_pixel(x, y);
7896
let pixel_b = rgba_b.get_pixel(x, y);
@@ -160,7 +178,7 @@ mod tests {
160178
img.save(file_a.path())?;
161179
img.save(file_b.path())?;
162180

163-
let res = compare_images(file_a.path(), file_b.path(), 0.1, false, &[])?;
181+
let res = compare_images(file_a.path(), file_b.path(), 0.1, false, &[], None)?;
164182
assert_eq!(res.diff_pixels, 0);
165183
assert_eq!(res.score, 1.0);
166184
assert!(res.ssim_score > 0.99);
@@ -181,14 +199,39 @@ mod tests {
181199
img_b.save(file_b.path())?;
182200

183201
// Without ignore
184-
let res1 = compare_images(file_a.path(), file_b.path(), 0.1, false, &[])?;
202+
let res1 = compare_images(file_a.path(), file_b.path(), 0.1, false, &[], None)?;
185203
assert_eq!(res1.diff_pixels, 1);
186204

187205
// With ignore
188206
let ignore = [Region { x: 5, y: 5, width: 1, height: 1 }];
189-
let res2 = compare_images(file_a.path(), file_b.path(), 0.1, false, &ignore)?;
207+
let res2 = compare_images(file_a.path(), file_b.path(), 0.1, false, &ignore, None)?;
190208
assert_eq!(res2.diff_pixels, 0);
191209
assert_eq!(res2.score, 1.0);
192210
Ok(())
193211
}
212+
213+
#[test]
214+
fn test_compare_with_mask() -> Result<()> {
215+
let mut img_a: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(10, 10);
216+
for p in img_a.pixels_mut() { *p = Rgba([100, 100, 100, 255]); }
217+
218+
let mut img_b = img_a.clone();
219+
img_b.put_pixel(5, 5, Rgba([255, 0, 0, 255]));
220+
221+
let mut mask: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(10, 10);
222+
for p in mask.pixels_mut() { *p = Rgba([255, 255, 255, 255]); }
223+
mask.put_pixel(5, 5, Rgba([0, 0, 0, 255])); // Mask out the difference
224+
225+
let file_a = tempfile::Builder::new().suffix(".png").tempfile()?;
226+
let file_b = tempfile::Builder::new().suffix(".png").tempfile()?;
227+
let file_mask = tempfile::Builder::new().suffix(".png").tempfile()?;
228+
229+
img_a.save(file_a.path())?;
230+
img_b.save(file_b.path())?;
231+
mask.save(file_mask.path())?;
232+
233+
let res = compare_images(file_a.path(), file_b.path(), 0.1, false, &[], Some(file_mask.path()))?;
234+
assert_eq!(res.diff_pixels, 0);
235+
Ok(())
236+
}
194237
}

src/dir.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub fn compare_directories(
2525
dir_b: &Path,
2626
threshold: f32,
2727
ignore_regions: &[Region],
28+
mask_path: Option<&Path>,
2829
) -> Result<Vec<DirDiffItem>> {
2930
let files_a: Vec<PathBuf> = WalkDir::new(dir_a)
3031
.into_iter()
@@ -47,7 +48,7 @@ pub fn compare_directories(
4748
let status = if !path_b.exists() {
4849
DirDiffStatus::MissingInB
4950
} else {
50-
match compare_images(&path_a, &path_b, threshold, false, ignore_regions) {
51+
match compare_images(&path_a, &path_b, threshold, false, ignore_regions, mask_path) {
5152
Ok(res) => DirDiffStatus::Match(res),
5253
Err(e) => DirDiffStatus::Error(e.to_string()),
5354
}

src/main.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ struct Args {
5555
/// Ignore regions in format x,y,width,height (can be used multiple times)
5656
#[arg(short, long, value_delimiter = ' ')]
5757
ignore: Vec<Region>,
58+
59+
/// Path to a mask image (black areas are ignored)
60+
#[arg(short, long)]
61+
mask: Option<PathBuf>,
5862
}
5963

6064
fn main() -> Result<()> {
@@ -74,6 +78,7 @@ fn run_file_diff(args: &Args) -> Result<()> {
7478
args.threshold,
7579
args.output.is_some() || args.preview,
7680
&args.ignore,
81+
args.mask.as_deref(),
7782
)?;
7883

7984
if args.json {
@@ -107,7 +112,13 @@ fn run_file_diff(args: &Args) -> Result<()> {
107112
}
108113

109114
fn run_dir_diff(args: &Args) -> Result<()> {
110-
let items = dir::compare_directories(&args.path_a, &args.path_b, args.threshold, &args.ignore)?;
115+
let items = dir::compare_directories(
116+
&args.path_a,
117+
&args.path_b,
118+
args.threshold,
119+
&args.ignore,
120+
args.mask.as_deref()
121+
)?;
111122

112123
let mut diff_count = 0;
113124

0 commit comments

Comments
 (0)