diff --git a/autoarray/dataset/grids.py b/autoarray/dataset/grids.py index 4026d9b86..c0bcb833d 100644 --- a/autoarray/dataset/grids.py +++ b/autoarray/dataset/grids.py @@ -101,9 +101,11 @@ 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 ) + 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/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..259bfa449 100644 --- a/autoarray/mask/mask_2d_util.py +++ b/autoarray/mask/mask_2d_util.py @@ -461,8 +461,66 @@ def min_false_distance_to_edge(mask: np.ndarray) -> Tuple[int, int]: return min(top_dist, bottom_dist), min(left_dist, right_dist) +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, while preserving + parity (odd->odd, even->even) in each dimension. + + 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), and each dimension keeps + the same parity as the input mask. + """ + 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) + + 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( - 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. @@ -471,32 +529,77 @@ 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 + ---------- + mask_2d + 2D boolean mask. + kernel_shape_native + (ky, kx) kernel footprint. + allow_padding + 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) - 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 = 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. " + 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"(parity preserved) 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) + # (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) # 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 + # 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)), @@ -511,16 +614,15 @@ def blurring_mask_2d_from( 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: masked pixels near unmasked pixels + 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 + # 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: