From daa9127e2e85872737eb70c5eced2040fb267ad5 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 26 Feb 2026 15:42:18 +0000 Subject: [PATCH 1/2] now havce option to pad blurring mask --- autoarray/dataset/grids.py | 2 +- autoarray/inversion/mesh/interpolator/knn.py | 5 +- autoarray/mask/derive/mask_2d.py | 5 +- autoarray/mask/mask_2d_util.py | 108 +++++++++++++++---- 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/autoarray/dataset/grids.py b/autoarray/dataset/grids.py index 4026d9b86..34c388c3f 100644 --- a/autoarray/dataset/grids.py +++ b/autoarray/dataset/grids.py @@ -101,7 +101,7 @@ def blurring(self): else: blurring_mask = self.mask.derive_mask.blurring_from( - kernel_shape_native=self.psf.kernel.shape_native + kernel_shape_native=self.psf.kernel.shape_native, allow_padding=True ) self._blurring = Grid2D.from_mask( diff --git a/autoarray/inversion/mesh/interpolator/knn.py b/autoarray/inversion/mesh/interpolator/knn.py index 92d6e03c7..85efa089d 100644 --- a/autoarray/inversion/mesh/interpolator/knn.py +++ b/autoarray/inversion/mesh/interpolator/knn.py @@ -149,7 +149,10 @@ def _mappings_sizes_weights(self): try: query_points = self.data_grid.over_sampled.array except AttributeError: - query_points = self.data_grid.array + try: + query_points = self.data_grid.array + except AttributeError: + query_points = self.data_grid mappings, weights, _ = get_interpolation_weights( points=self.mesh_grid_xy, diff --git a/autoarray/mask/derive/mask_2d.py b/autoarray/mask/derive/mask_2d.py index 0a2197ed8..ad1e1c558 100644 --- a/autoarray/mask/derive/mask_2d.py +++ b/autoarray/mask/derive/mask_2d.py @@ -139,7 +139,9 @@ def all_false(self) -> Mask2D: origin=self.mask.origin, ) - def blurring_from(self, kernel_shape_native: Tuple[int, int]) -> Mask2D: + def blurring_from( + self, kernel_shape_native: Tuple[int, int], allow_padding: bool = False + ) -> Mask2D: """ Returns a blurring ``Mask2D``, representing all masked pixels (given by ``True``) whose values are blurred into unmasked pixels (given by ``False``) when a 2D convolution is performed. @@ -201,6 +203,7 @@ def blurring_from(self, kernel_shape_native: Tuple[int, int]) -> Mask2D: blurring_mask = mask_2d_util.blurring_mask_2d_from( mask_2d=self.mask, kernel_shape_native=kernel_shape_native, + allow_padding=allow_padding, ) return Mask2D( diff --git a/autoarray/mask/mask_2d_util.py b/autoarray/mask/mask_2d_util.py index 8a30736ec..c7017240f 100644 --- a/autoarray/mask/mask_2d_util.py +++ b/autoarray/mask/mask_2d_util.py @@ -461,8 +461,48 @@ def min_false_distance_to_edge(mask: np.ndarray) -> Tuple[int, int]: return min(top_dist, bottom_dist), min(left_dist, right_dist) +def blurring_mask_required_shape_from( + mask_2d: np.ndarray, + kernel_shape_native: Tuple[int, int], +) -> Tuple[int, int]: + """ + Return the minimal shape the mask must be padded to so that a kernel with the given + footprint can be applied without sampling beyond the array edge. + + Parameters + ---------- + mask_2d + 2D boolean array where False is unmasked and True is masked. + kernel_shape_native + (ky, kx) footprint of the convolution kernel. + + Returns + ------- + required_shape + The minimal (ny, nx) shape such that the minimum distance from any unmasked + pixel to the array edge is at least (ky//2, kx//2). + """ + mask_2d = np.asarray(mask_2d, dtype=bool) + + ky, kx = kernel_shape_native + if ky <= 0 or kx <= 0: + raise ValueError( + f"kernel_shape_native must be positive, got {kernel_shape_native}." + ) + + pad_y, pad_x = ky // 2, kx // 2 + y_distance, x_distance = min_false_distance_to_edge(mask_2d) + + extra_y = max(0, pad_y - y_distance) + extra_x = max(0, pad_x - x_distance) + + return (mask_2d.shape[0] + 2 * extra_y, mask_2d.shape[1] + 2 * extra_x) + + def blurring_mask_2d_from( - mask_2d: np.ndarray, kernel_shape_native: Tuple[int, int] + mask_2d: np.ndarray, + kernel_shape_native: Tuple[int, int], + allow_padding: bool = False, ) -> np.ndarray: """ Return the blurring mask for a 2D mask and kernel footprint. @@ -472,31 +512,61 @@ def blurring_mask_2d_from( - True = masked (excluded) The returned *blurring mask* is a mask where the blurring-region pixels are unmasked (False). + + Parameters + ---------- + mask_2d + 2D boolean mask. + kernel_shape_native + (ky, kx) kernel footprint. + allow_padding + If False (default), raises an exception when the mask is too small + for the kernel footprint. + If True, pads the mask symmetrically with masked pixels (True) to the + minimal required shape and emits a warning. """ mask_2d = np.asarray(mask_2d, dtype=bool) - ky, kx = kernel_shape_native - if ky <= 0 or kx <= 0: - raise ValueError( - f"kernel_shape_native must be positive, got {kernel_shape_native}." + required_shape = blurring_mask_required_shape_from(mask_2d, kernel_shape_native) + + if required_shape != mask_2d.shape: + + if not allow_padding: + raise exc.MaskException( + "The input mask is too small for the kernel shape. " + f"Current shape: {mask_2d.shape}, required shape: {required_shape}. " + "Set allow_padding=True to pad automatically." + ) + + warnings.warn( + f"Mask padded from {mask_2d.shape} to {required_shape} " + f"to support kernel footprint {kernel_shape_native}.", + UserWarning, ) - # Keep your existing guard (optional) - y_distance, x_distance = min_false_distance_to_edge(mask_2d) - if (y_distance < ky // 2) or (x_distance < kx // 2): - raise exc.MaskException( - "The input mask is too small for the kernel shape. " - "Please pad the mask before computing the blurring mask." + dy = required_shape[0] - mask_2d.shape[0] + dx = required_shape[1] - mask_2d.shape[1] + + pad_top = dy // 2 + pad_bottom = dy - pad_top + pad_left = dx // 2 + pad_right = dx - pad_left + + mask_2d = np.pad( + mask_2d, + pad_width=((pad_top, pad_bottom), (pad_left, pad_right)), + mode="constant", + constant_values=True, # outside is masked ) - # Kernel footprint (support only) + ky, kx = kernel_shape_native + pad_y, pad_x = ky // 2, kx // 2 structure = np.ones((ky, kx), dtype=bool) # Unmasked region (True where unmasked) unmasked = ~mask_2d - # Explicit padding: outside-of-array is masked => outside is NOT unmasked (False) - pad_y, pad_x = ky // 2, kx // 2 + # Pad so outside behaves as masked unmasked_padded = np.pad( unmasked, pad_width=((pad_y, pad_y), (pad_x, pad_x)), @@ -504,19 +574,17 @@ def blurring_mask_2d_from( constant_values=False, ) - # Pixels within kernel footprint of any unmasked pixel near_unmasked_padded = binary_dilation(unmasked_padded, structure=structure) + near_unmasked = near_unmasked_padded[ pad_y : pad_y + mask_2d.shape[0], pad_x : pad_x + mask_2d.shape[1], ] - # Blurring REGION: masked pixels that are near unmasked pixels - blurring_region = mask_2d & near_unmasked # True on the ring (in region-space) + blurring_region = mask_2d & near_unmasked - # Convert region -> mask semantics: ring should be unmasked (False) - blurring_mask = np.ones_like(mask_2d, dtype=bool) # start fully masked - blurring_mask[blurring_region] = False # unmask the ring + blurring_mask = np.ones_like(mask_2d, dtype=bool) + blurring_mask[blurring_region] = False return blurring_mask From 5fba2605ea1dc679d79de29eb37e820a9fb32771 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 26 Feb 2026 15:46:39 +0000 Subject: [PATCH 2/2] enforce odd x odd or even x even --- autoarray/dataset/grids.py | 2 ++ autoarray/mask/mask_2d_util.py | 64 ++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/autoarray/dataset/grids.py b/autoarray/dataset/grids.py index 34c388c3f..c0bcb833d 100644 --- a/autoarray/dataset/grids.py +++ b/autoarray/dataset/grids.py @@ -104,6 +104,8 @@ def blurring(self): kernel_shape_native=self.psf.kernel.shape_native, allow_padding=True ) + blurring_mask = blurring_mask.resized_from(new_shape=(120, 120)) + self._blurring = Grid2D.from_mask( mask=blurring_mask, over_sample_size=1, diff --git a/autoarray/mask/mask_2d_util.py b/autoarray/mask/mask_2d_util.py index c7017240f..259bfa449 100644 --- a/autoarray/mask/mask_2d_util.py +++ b/autoarray/mask/mask_2d_util.py @@ -461,13 +461,21 @@ def min_false_distance_to_edge(mask: np.ndarray) -> Tuple[int, int]: return min(top_dist, bottom_dist), min(left_dist, right_dist) -def blurring_mask_required_shape_from( +import warnings +from typing import Tuple + +import numpy as np +from scipy.ndimage import binary_dilation + + +def required_shape_for_kernel( mask_2d: np.ndarray, kernel_shape_native: Tuple[int, int], ) -> Tuple[int, int]: """ Return the minimal shape the mask must be padded to so that a kernel with the given - footprint can be applied without sampling beyond the array edge. + footprint can be applied without sampling beyond the array edge, while preserving + parity (odd->odd, even->even) in each dimension. Parameters ---------- @@ -480,7 +488,8 @@ def blurring_mask_required_shape_from( ------- required_shape The minimal (ny, nx) shape such that the minimum distance from any unmasked - pixel to the array edge is at least (ky//2, kx//2). + pixel to the array edge is at least (ky//2, kx//2), and each dimension keeps + the same parity as the input mask. """ mask_2d = np.asarray(mask_2d, dtype=bool) @@ -496,7 +505,16 @@ def blurring_mask_required_shape_from( extra_y = max(0, pad_y - y_distance) extra_x = max(0, pad_x - x_distance) - return (mask_2d.shape[0] + 2 * extra_y, mask_2d.shape[1] + 2 * extra_x) + new_y = mask_2d.shape[0] + 2 * extra_y + new_x = mask_2d.shape[1] + 2 * extra_x + + # Preserve parity per axis: odd->odd, even->even + if (new_y % 2) != (mask_2d.shape[0] % 2): + new_y += 1 + if (new_x % 2) != (mask_2d.shape[1] % 2): + new_x += 1 + + return new_y, new_x def blurring_mask_2d_from( @@ -511,7 +529,13 @@ def blurring_mask_2d_from( - False = unmasked (included) - True = masked (excluded) - The returned *blurring mask* is a mask where the blurring-region pixels are unmasked (False). + The returned blurring mask is a *mask* where the blurring-region pixels are + unmasked (False) and all other pixels are masked (True). + + If the input mask is too small for the kernel footprint: + - allow_padding=False (default): raises an exception. + - allow_padding=True: pads the mask symmetrically with masked pixels (True) to the + minimal required shape (with parity preserved) and emits a warning. Parameters ---------- @@ -520,17 +544,18 @@ def blurring_mask_2d_from( kernel_shape_native (ky, kx) kernel footprint. allow_padding - If False (default), raises an exception when the mask is too small - for the kernel footprint. - If True, pads the mask symmetrically with masked pixels (True) to the - minimal required shape and emits a warning. + If False, raise if padding is required. If True, pad and warn. + + Returns + ------- + blurring_mask + Boolean mask of the same shape as the (possibly padded) input. """ mask_2d = np.asarray(mask_2d, dtype=bool) - required_shape = blurring_mask_required_shape_from(mask_2d, kernel_shape_native) + required_shape = required_shape_for_kernel(mask_2d, kernel_shape_native) if required_shape != mask_2d.shape: - if not allow_padding: raise exc.MaskException( "The input mask is too small for the kernel shape. " @@ -540,7 +565,7 @@ def blurring_mask_2d_from( warnings.warn( f"Mask padded from {mask_2d.shape} to {required_shape} " - f"to support kernel footprint {kernel_shape_native}.", + f"(parity preserved) to support kernel footprint {kernel_shape_native}.", UserWarning, ) @@ -559,6 +584,14 @@ def blurring_mask_2d_from( constant_values=True, # outside is masked ) + # (Optional) hard invariant: parity preserved after any padding + if (mask_2d.shape[0] % 2) != (required_shape[0] % 2) or (mask_2d.shape[1] % 2) != ( + required_shape[1] % 2 + ): + raise RuntimeError( + f"Parity invariant violated: got {mask_2d.shape}, expected parity of {required_shape}." + ) + ky, kx = kernel_shape_native pad_y, pad_x = ky // 2, kx // 2 structure = np.ones((ky, kx), dtype=bool) @@ -566,7 +599,7 @@ def blurring_mask_2d_from( # Unmasked region (True where unmasked) unmasked = ~mask_2d - # Pad so outside behaves as masked + # Explicit padding so outside behaves as masked => outside is NOT unmasked unmasked_padded = np.pad( unmasked, pad_width=((pad_y, pad_y), (pad_x, pad_x)), @@ -574,21 +607,22 @@ def blurring_mask_2d_from( constant_values=False, ) + # Pixels within kernel footprint of any unmasked pixel near_unmasked_padded = binary_dilation(unmasked_padded, structure=structure) - near_unmasked = near_unmasked_padded[ pad_y : pad_y + mask_2d.shape[0], pad_x : pad_x + mask_2d.shape[1], ] + # Blurring region: masked pixels near unmasked pixels blurring_region = mask_2d & near_unmasked + # Return as a mask: blurring region is unmasked (False), everything else masked (True) blurring_mask = np.ones_like(mask_2d, dtype=bool) blurring_mask[blurring_region] = False return blurring_mask - def mask_slim_indexes_from( mask_2d: np.ndarray, return_masked_indexes: bool = True ) -> np.ndarray: