Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion autoarray/dataset/grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion autoarray/inversion/mesh/interpolator/knn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion autoarray/mask/derive/mask_2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +142 to +144
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allow_padding parameter is missing from the Parameters section of the docstring. This parameter should be documented to explain its purpose and behavior to users of this method.

Copilot uses AI. Check for mistakes.
"""
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.
Expand Down Expand Up @@ -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(
Expand Down
144 changes: 123 additions & 21 deletions autoarray/mask/mask_2d_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
Comment on lines +464 to +499
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function will raise a generic ValueError from min_false_distance_to_edge if the input mask contains no unmasked pixels (all True values). Consider catching this ValueError and raising a more context-specific exception (like MaskException) with a clearer error message that explains the issue in terms of blurring mask calculation.

Copilot uses AI. Check for mistakes.
)

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.
Expand All @@ -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(
Comment on lines +532 to +560
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new allow_padding functionality is not covered by tests. Consider adding test cases that verify: 1) padding occurs correctly when the mask is too small and allow_padding=True, 2) the warning is emitted, 3) the padding amounts are calculated correctly for both even and odd differences, and 4) the resulting blurring mask has the expected shape and values after padding.

Copilot uses AI. Check for mistakes.
"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)),
Expand All @@ -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:
Expand Down
Loading