From 71b5943f2dfe31ce0a478a5ae90d8849192ea382 Mon Sep 17 00:00:00 2001 From: Raytesnel Date: Wed, 4 Feb 2026 17:19:19 +0100 Subject: [PATCH 1/5] Add Mask to the ImageMutations --- .../scratch-core/src/mutations/__init__.py | 4 +- packages/scratch-core/src/mutations/filter.py | 39 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/scratch-core/src/mutations/__init__.py b/packages/scratch-core/src/mutations/__init__.py index 68e249f2..0db9179d 100644 --- a/packages/scratch-core/src/mutations/__init__.py +++ b/packages/scratch-core/src/mutations/__init__.py @@ -9,8 +9,8 @@ can be chained together using a pipeline (e.g. `returns.pipeline.pipe`). """ -from .filter import LevelMap +from .filter import LevelMap, Mask from .spatial import CropToMask, Resample -__all__ = ["LevelMap", "CropToMask", "Resample"] +__all__ = ["LevelMap", "Mask", "Resample", "CropToMask"] diff --git a/packages/scratch-core/src/mutations/filter.py b/packages/scratch-core/src/mutations/filter.py index c4e92235..4ec86109 100644 --- a/packages/scratch-core/src/mutations/filter.py +++ b/packages/scratch-core/src/mutations/filter.py @@ -1,12 +1,16 @@ from typing import NamedTuple -from container_models.base import FloatArray1D, FloatArray2D + +import numpy as np +from loguru import logger + +from container_models.base import BinaryMask, FloatArray1D, FloatArray2D from container_models.scan_image import ScanImage from conversion.leveling.data_types import SurfaceTerms from conversion.leveling.solver.design import build_design_matrix from conversion.leveling.solver.grid import get_2d_grid from conversion.leveling.solver.transforms import normalize_coordinates +from exceptions import ImageShapeMismatchError from mutations.base import ImageMutation -import numpy as np class PointCloud(NamedTuple): @@ -15,6 +19,37 @@ class PointCloud(NamedTuple): zs: FloatArray1D +class Mask(ImageMutation): + """ + A Image Mutation for masking the Image. all False / 0 values on the mask are masked on the given image. + """ + + def __init__(self, mask: BinaryMask) -> None: + """ + Initialize the Mask mutation. + + :param mask: Input BinaryMask to be resized + """ + self.mask = mask + self.mask = mask + + def apply_on_image(self, scan_image: ScanImage) -> ScanImage: + """ + Apply the mask to the image. + + :params scan_image: Input ScanImage to be masked + :return: Masked ScanImage + :raises ImageShapeMismatchError: If the mask and image shapes do not match + """ + if self.mask.shape != scan_image.data.shape: + raise ImageShapeMismatchError( + f"Mask shape: {self.mask.shape} does not match image shape: {scan_image.data.shape}" + ) + logger.info("Applying mask to scan_image") + scan_image.data[~self.mask] = np.nan + return scan_image + + class LevelMap(ImageMutation): """ Image mutation that performs surface leveling by fitting and subtracting From 428cb4bf3d9dc6566ef15f4286ff94f6fdbf08cd Mon Sep 17 00:00:00 2001 From: Raytesnel Date: Thu, 5 Feb 2026 10:54:46 +0100 Subject: [PATCH 2/5] add tests --- .../scratch-core/tests/mutations/test_mask.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/scratch-core/tests/mutations/test_mask.py diff --git a/packages/scratch-core/tests/mutations/test_mask.py b/packages/scratch-core/tests/mutations/test_mask.py new file mode 100644 index 00000000..fbb87fc8 --- /dev/null +++ b/packages/scratch-core/tests/mutations/test_mask.py @@ -0,0 +1,77 @@ +import re +from container_models.scan_image import ScanImage +from exceptions import ImageShapeMismatchError +from mutations.filter import Mask +import numpy as np +import pytest + + +class TestMask2dArray: + @pytest.fixture + def scan_image( + self, + ): + return ScanImage( + data=np.array([[1, 2], [3, 4]], dtype=float), scale_x=1.0, scale_y=1.0 + ) + + def test_mask_sets_background_pixels_to_nan(self, scan_image: ScanImage) -> None: + # Arrange + mask = np.array([[1, 0], [0, 1]], dtype=bool) + masking_mutator = Mask(mask=mask) + # Act + result = masking_mutator.apply_on_image(scan_image=scan_image) + # Assert + assert np.array_equal( + result.data, np.array([[1, np.nan], [np.nan, 4]]), equal_nan=True + ) + + def test_raises_on_shape_mismatch(self, scan_image: ScanImage) -> None: + # Arrange + mask = np.array([[1, 0, 0], [0, 1, 0]], dtype=bool) + masking_mutator = Mask(mask=mask) + # Act / Assert + with pytest.raises( + ImageShapeMismatchError, + match=re.escape( + f"Mask shape: {mask.shape} does not match image shape: {scan_image.data.shape}" + ), + ): + masking_mutator.apply_on_image(scan_image=scan_image) + + def test_full_mask_preserves_all_values( + self, scan_image: ScanImage, caplog: pytest.LogCaptureFixture + ) -> None: + # Arrange + mask = np.ones((2, 2), dtype=bool) + masking_mutator = Mask(mask=mask) + # Act + result = masking_mutator.apply_on_image(scan_image=scan_image) + # Assert + assert np.array_equal(result.data, scan_image.data, equal_nan=True) + + def test_full_mask_skips_calculation( + self, scan_image: ScanImage, caplog: pytest.LogCaptureFixture + ) -> None: + # Arrange + mask = np.ones((2, 2), dtype=bool) + masking_mutator = Mask(mask=mask) + # Act + result = masking_mutator(scan_image=scan_image).unwrap() + # Assert + assert np.array_equal(result.data, scan_image.data, equal_nan=True) + assert ( + "skipping masking, Mask area is not containing any masking fields." + in caplog.messages + ) + + def test_empty_mask_sets_all_to_nan( + self, scan_image: ScanImage, caplog: pytest.LogCaptureFixture + ) -> None: + # Arrange + mask = np.zeros((2, 2), dtype=bool) + masking_mutator = Mask(mask=mask) + result = masking_mutator(scan_image=scan_image).unwrap() + + assert np.all(np.isnan(result.data)) + assert "Applying mask to scan_image" in caplog.messages From d2c761a02ddec7e80e0d0304cef7187b685e194f Mon Sep 17 00:00:00 2001 From: Raytesnel Date: Thu, 5 Feb 2026 10:54:59 +0100 Subject: [PATCH 3/5] add skip --- packages/scratch-core/src/mutations/filter.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/scratch-core/src/mutations/filter.py b/packages/scratch-core/src/mutations/filter.py index 4ec86109..ab8d0250 100644 --- a/packages/scratch-core/src/mutations/filter.py +++ b/packages/scratch-core/src/mutations/filter.py @@ -31,7 +31,16 @@ def __init__(self, mask: BinaryMask) -> None: :param mask: Input BinaryMask to be resized """ self.mask = mask - self.mask = mask + + @property + def skip_predicate(self) -> bool: + """skips the computation if all is not masked""" + if self.mask.all(): + logger.warning( + "skipping masking, Mask area is not containing any masking fields." + ) + return True + return False def apply_on_image(self, scan_image: ScanImage) -> ScanImage: """ From 4bb8d6f80016ce98c76bc16a5c23de1451bad881 Mon Sep 17 00:00:00 2001 From: Raytesnel Date: Thu, 5 Feb 2026 11:08:18 +0100 Subject: [PATCH 4/5] update docstrings --- packages/scratch-core/src/mutations/filter.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/scratch-core/src/mutations/filter.py b/packages/scratch-core/src/mutations/filter.py index ab8d0250..2f6df754 100644 --- a/packages/scratch-core/src/mutations/filter.py +++ b/packages/scratch-core/src/mutations/filter.py @@ -21,20 +21,32 @@ class PointCloud(NamedTuple): class Mask(ImageMutation): """ - A Image Mutation for masking the Image. all False / 0 values on the mask are masked on the given image. + Image mutation that applies a binary mask to a scan image. + + All pixels corresponding to `False` (or zero) values in the mask + are set to `np.nan` in the image data. Pixels where the mask is + `True` remain unchanged. """ def __init__(self, mask: BinaryMask) -> None: """ Initialize the Mask mutation. - :param mask: Input BinaryMask to be resized + :param mask: Binary mask indicating which pixels should be kept (`True`) + or masked (`False`). """ self.mask = mask @property def skip_predicate(self) -> bool: - """skips the computation if all is not masked""" + """ + Determine whether the masking operation can be skipped. + + If the mask contains no masked pixels (i.e. all values are `True`), + applying the mask would have no effect and the mutation is skipped. + + :returns: bool `True` if the mutation can be skipped, otherwise `False`. + """ if self.mask.all(): logger.warning( "skipping masking, Mask area is not containing any masking fields." @@ -46,9 +58,9 @@ def apply_on_image(self, scan_image: ScanImage) -> ScanImage: """ Apply the mask to the image. - :params scan_image: Input ScanImage to be masked - :return: Masked ScanImage - :raises ImageShapeMismatchError: If the mask and image shapes do not match + :params scan_image: Input scan image to which the mask is applied. + :return: The masked scan image. + :raises ImageShapeMismatchError: If the mask shape does not match the image data shape. """ if self.mask.shape != scan_image.data.shape: raise ImageShapeMismatchError( From f592561b0710676e1ec723738d847108e611a269 Mon Sep 17 00:00:00 2001 From: Raytesnel Date: Thu, 5 Feb 2026 11:10:56 +0100 Subject: [PATCH 5/5] removed unuesed parameters --- packages/scratch-core/tests/mutations/test_mask.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/scratch-core/tests/mutations/test_mask.py b/packages/scratch-core/tests/mutations/test_mask.py index fbb87fc8..d253e255 100644 --- a/packages/scratch-core/tests/mutations/test_mask.py +++ b/packages/scratch-core/tests/mutations/test_mask.py @@ -1,9 +1,11 @@ import re + +import numpy as np +import pytest + from container_models.scan_image import ScanImage from exceptions import ImageShapeMismatchError from mutations.filter import Mask -import numpy as np -import pytest class TestMask2dArray: @@ -39,9 +41,7 @@ def test_raises_on_shape_mismatch(self, scan_image: ScanImage) -> None: ): masking_mutator.apply_on_image(scan_image=scan_image) - def test_full_mask_preserves_all_values( - self, scan_image: ScanImage, caplog: pytest.LogCaptureFixture - ) -> None: + def test_full_mask_preserves_all_values(self, scan_image: ScanImage) -> None: # Arrange mask = np.ones((2, 2), dtype=bool) masking_mutator = Mask(mask=mask)