Skip to content

Commit 3cdff2d

Browse files
Merge pull request #15 from mirsazzathossain/dev
feat: Add Masking Functionality for Single and Batch Image Processing
2 parents 35fd8ff + 76cc569 commit 3cdff2d

File tree

7 files changed

+342
-83
lines changed

7 files changed

+342
-83
lines changed

.github/ISSUE_TEMPLATE/bug_report.md

+13-11
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
---
22
name: Bug report
33
about: Create a report to help us improve
4-
title: ''
5-
labels: ''
6-
assignees: ''
7-
4+
title: ""
5+
labels: ""
6+
assignees: ""
87
---
98

109
**Describe the bug**
1110
A clear and concise description of what the bug is.
1211

1312
**To Reproduce**
1413
Steps to reproduce the behavior:
14+
1515
1. Go to '...'
1616
2. Click on '....'
1717
3. Scroll down to '....'
@@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen.
2424
If applicable, add screenshots to help explain your problem.
2525

2626
**Desktop (please complete the following information):**
27-
- OS: [e.g. iOS]
28-
- Browser [e.g. chrome, safari]
29-
- Version [e.g. 22]
27+
28+
- OS: [e.g. iOS]
29+
- Browser [e.g. chrome, safari]
30+
- Version [e.g. 22]
3031

3132
**Smartphone (please complete the following information):**
32-
- Device: [e.g. iPhone6]
33-
- OS: [e.g. iOS8.1]
34-
- Browser [e.g. stock browser, safari]
35-
- Version [e.g. 22]
33+
34+
- Device: [e.g. iPhone6]
35+
- OS: [e.g. iOS8.1]
36+
- Browser [e.g. stock browser, safari]
37+
- Version [e.g. 22]
3638

3739
**Additional context**
3840
Add any other context about the problem here.

.github/ISSUE_TEMPLATE/feature_request.md

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
---
22
name: Feature request
33
about: Suggest an idea for this project
4-
title: ''
5-
labels: ''
6-
assignees: ''
7-
4+
title: ""
5+
labels: ""
6+
assignees: ""
87
---
98

109
**Is your feature request related to a problem? Please describe.**

CHANGELOG.md

+64-64
Large diffs are not rendered by default.

rgc/utils/data.py

+77-3
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,11 @@ def __init__(self) -> None:
124124

125125
class _FileNotFoundError(Exception):
126126
"""
127-
An exception to be raised when the FITS file is not found.
127+
An exception to be raised when a file is not found.
128128
"""
129129

130-
def __init__(self, fits_file: str) -> None:
131-
super().__init__(f"File {fits_file} not found.")
130+
def __init__(self, message: str = "File not found.") -> None:
131+
super().__init__(message)
132132

133133

134134
def fits_to_png(fits_file: str, img_size: Optional[tuple[int, int]] = None) -> Image.Image:
@@ -189,3 +189,77 @@ def fits_to_png_bulk(fits_dir: str, png_dir: str, img_size: Optional[tuple[int,
189189

190190
if image is not None:
191191
image.save(png_file)
192+
193+
194+
def mask_image(image: Image.Image, mask: Image.Image) -> Image.Image:
195+
"""
196+
Mask an image with a given mask image.
197+
198+
:param image: The image to be masked.
199+
:type image: Image.Image
200+
201+
:param mask: The mask image.
202+
:type mask: Image.Image
203+
204+
:return: A PIL Image object containing the masked image.
205+
:rtype: Image.Image
206+
"""
207+
image_array = np.array(image)
208+
mask_array = np.array(mask)
209+
210+
if image_array.shape != mask_array.shape:
211+
raise _ImageMaskDimensionError()
212+
213+
masked_array = np.where(mask_array == 0, 0, image_array)
214+
masked_image = Image.fromarray(masked_array, mode="L")
215+
216+
return cast(Image.Image, masked_image)
217+
218+
219+
class _ImageMaskDimensionError(Exception):
220+
"""
221+
An exception to be raised when the dimensions of the image and mask do not match.
222+
"""
223+
224+
def __init__(self) -> None:
225+
super().__init__("Image and mask must have the same dimensions.")
226+
227+
228+
class _ImageMaskCountMismatchError(Exception):
229+
"""
230+
An exception to be raised when the number of images and masks do not match.
231+
"""
232+
233+
def __init__(self, message: str = "Number of images and masks must match and be non-zero.") -> None:
234+
super().__init__(message)
235+
236+
237+
def mask_image_bulk(image_dir: str, mask_dir: str, masked_dir: str) -> None:
238+
image_paths = sorted(Path(image_dir).glob("*.png"))
239+
mask_paths = sorted(Path(mask_dir).glob("*.png"))
240+
241+
if len(image_paths) == 0 or len(mask_paths) == 0:
242+
raise _FileNotFoundError()
243+
244+
if len(image_paths) != len(mask_paths):
245+
raise _ImageMaskCountMismatchError() from None
246+
247+
os.makedirs(masked_dir, exist_ok=True)
248+
249+
for image_path in image_paths:
250+
mask_path = Path(mask_dir) / image_path.name
251+
252+
if not mask_path.exists():
253+
print(f"Skipping {image_path.name} due to missing mask.")
254+
continue
255+
256+
image = Image.open(image_path)
257+
mask = Image.open(mask_path)
258+
259+
if image.size != mask.size:
260+
print(f"Skipping {image_path.name} due to mismatched dimensions.")
261+
continue
262+
else:
263+
masked_image = mask_image(image, mask)
264+
265+
masked_image.save(Path(masked_dir) / image_path.name)

tests/test_mask_image.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import unittest
2+
3+
import numpy as np
4+
from PIL import Image
5+
6+
from rgc.utils.data import _ImageMaskDimensionError, mask_image
7+
8+
9+
class TestMaskImage(unittest.TestCase):
10+
def setUp(self):
11+
# Create sample images and masks for testing
12+
self.image_array = np.array([[100, 150, 200], [50, 75, 100], [0, 25, 50]], dtype=np.uint8)
13+
self.mask_array = np.array([[1, 0, 1], [0, 1, 0], [1, 1, 0]], dtype=np.uint8)
14+
self.image = Image.fromarray(self.image_array, mode="L")
15+
self.mask = Image.fromarray(self.mask_array, mode="L")
16+
17+
def test_mask_all_zeros(self):
18+
zero_mask_array = np.zeros_like(self.mask_array)
19+
zero_mask = Image.fromarray(zero_mask_array, mode="L")
20+
21+
expected_array = np.zeros_like(self.image_array)
22+
_ = Image.fromarray(expected_array, mode="L")
23+
24+
result_image = mask_image(self.image, zero_mask)
25+
result_array = np.array(result_image)
26+
27+
np.testing.assert_array_equal(result_array, expected_array)
28+
29+
def test_mask_all_ones(self):
30+
ones_mask_array = np.ones_like(self.mask_array)
31+
ones_mask = Image.fromarray(ones_mask_array, mode="L")
32+
33+
expected_array = self.image_array.copy()
34+
_ = Image.fromarray(expected_array, mode="L")
35+
36+
result_image = mask_image(self.image, ones_mask)
37+
result_array = np.array(result_image)
38+
39+
np.testing.assert_array_equal(result_array, expected_array)
40+
41+
def test_non_matching_dimension(self):
42+
small_mask_array = np.array([[1, 0]], dtype=np.uint8)
43+
small_mask = Image.fromarray(small_mask_array, mode="L")
44+
45+
with self.assertRaises(_ImageMaskDimensionError):
46+
mask_image(self.image, small_mask)
47+
48+
def test_empty_image(self):
49+
empty_image_array = np.array([[]], dtype=np.uint8)
50+
empty_image = Image.fromarray(empty_image_array, mode="L")
51+
52+
with self.assertRaises(_ImageMaskDimensionError):
53+
mask_image(empty_image, self.mask)
54+
55+
def test_empty_mask(self):
56+
empty_mask_array = np.array([[]], dtype=np.uint8)
57+
empty_mask = Image.fromarray(empty_mask_array, mode="L")
58+
59+
with self.assertRaises(_ImageMaskDimensionError):
60+
mask_image(self.image, empty_mask)
61+
62+
63+
if __name__ == "__main__":
64+
unittest.main()

tests/test_mask_image_bulk.py

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import os
2+
import shutil
3+
import tempfile
4+
import unittest
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
import numpy as np
9+
from PIL import Image
10+
11+
from rgc.utils.data import _FileNotFoundError, _ImageMaskCountMismatchError, mask_image_bulk
12+
13+
14+
class TestMaskImageBulk(unittest.TestCase):
15+
def setUp(self):
16+
self.image_dir = tempfile.mkdtemp()
17+
self.mask_dir = tempfile.mkdtemp()
18+
self.masked_dir = tempfile.mkdtemp()
19+
20+
self.image_array = np.array([[100, 150, 200], [50, 75, 100], [0, 25, 50]], dtype=np.uint8)
21+
self.mask_array = np.array([[1, 0, 1], [0, 1, 0], [1, 1, 0]], dtype=np.uint8)
22+
23+
self.image_path = Path(self.image_dir) / "test_image.png"
24+
self.mask_path = Path(self.mask_dir) / "test_image.png"
25+
26+
Image.fromarray(self.image_array, mode="L").save(self.image_path)
27+
Image.fromarray(self.mask_array, mode="L").save(self.mask_path)
28+
29+
def tearDown(self):
30+
shutil.rmtree(self.image_dir)
31+
shutil.rmtree(self.mask_dir)
32+
shutil.rmtree(self.masked_dir)
33+
34+
def test_mask_image_bulk(self):
35+
mask_image_bulk(self.image_dir, self.mask_dir, self.masked_dir)
36+
masked_file_path = Path(self.masked_dir) / "test_image.png"
37+
self.assertTrue(masked_file_path.exists())
38+
masked_image = Image.open(masked_file_path)
39+
masked_array = np.array(masked_image)
40+
expected_array = np.array([[100, 0, 200], [0, 75, 0], [0, 25, 0]], dtype=np.uint8)
41+
np.testing.assert_array_equal(masked_array, expected_array)
42+
43+
@patch("builtins.print")
44+
def test_dimension_mismatch(self, mock_print):
45+
# Ensure mask_dir is empty
46+
for mask_file in Path(self.mask_dir).glob("*.png"):
47+
os.remove(mask_file)
48+
49+
# Create a mask with a different dimension
50+
mismatch_mask_array = np.array([[1, 0]], dtype=np.uint8)
51+
mismatch_mask_path = Path(self.mask_dir) / "test_image.png"
52+
Image.fromarray(mismatch_mask_array, mode="L").save(mismatch_mask_path)
53+
54+
# Ensure image_dir contains only the test image
55+
for image_file in Path(self.image_dir).glob("*.png"):
56+
os.remove(image_file)
57+
58+
Image.fromarray(self.image_array, mode="L").save(Path(self.image_dir) / "test_image.png")
59+
60+
# Run the function and check if the dimension mismatch is handled
61+
mask_image_bulk(self.image_dir, self.mask_dir, self.masked_dir)
62+
63+
# Check if masked directory is still empty
64+
self.assertFalse(
65+
list(Path(self.masked_dir).glob("*.png")),
66+
"Masked directory should be empty if there is a dimension mismatch",
67+
)
68+
69+
# Verify that the print statement was made
70+
# Ensure to check the exact message your code prints
71+
mock_print.assert_called_with("Skipping test_image.png due to mismatched dimensions.")
72+
73+
def test_missing_mask_file(self):
74+
# Create a directory with an image but without a corresponding mask
75+
missing_mask_dir = tempfile.mkdtemp()
76+
Image.fromarray(self.image_array, mode="L").save(Path(missing_mask_dir) / "fake_image.png")
77+
78+
mask_image_bulk(self.image_dir, missing_mask_dir, self.masked_dir)
79+
80+
# Check that masked directory is still empty
81+
self.assertFalse(os.listdir(self.masked_dir), "Masked directory should be empty if mask file is missing")
82+
83+
shutil.rmtree(missing_mask_dir)
84+
85+
def test_empty_image_dir(self):
86+
empty_image_dir = tempfile.mkdtemp()
87+
with self.assertRaises(_FileNotFoundError):
88+
mask_image_bulk(empty_image_dir, self.mask_dir, self.masked_dir)
89+
shutil.rmtree(empty_image_dir)
90+
91+
def test_empty_mask_dir(self):
92+
empty_mask_dir = tempfile.mkdtemp()
93+
with self.assertRaises(_FileNotFoundError):
94+
mask_image_bulk(self.image_dir, empty_mask_dir, self.masked_dir)
95+
shutil.rmtree(empty_mask_dir)
96+
97+
def test_non_matching_images_and_masks(self):
98+
extra_image_dir = tempfile.mkdtemp()
99+
extra_mask_dir = tempfile.mkdtemp()
100+
101+
extra_image_path = Path(extra_image_dir) / "extra_image.png"
102+
Image.fromarray(self.image_array, mode="L").save(extra_image_path)
103+
104+
extra_image_path = Path(extra_image_dir) / "extra_image_2.png"
105+
Image.fromarray(self.image_array, mode="L").save(extra_image_path)
106+
107+
with self.assertRaises(_ImageMaskCountMismatchError):
108+
mask_image_bulk(extra_image_dir, self.mask_dir, self.masked_dir)
109+
110+
extra_mask_path = Path(extra_mask_dir) / "extra_mask.png"
111+
Image.fromarray(self.mask_array, mode="L").save(extra_mask_path)
112+
113+
self.assertFalse(os.listdir(self.masked_dir))
114+
115+
shutil.rmtree(extra_image_dir)
116+
shutil.rmtree(extra_mask_dir)
117+
118+
119+
if __name__ == "__main__":
120+
unittest.main()

uv.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)