@@ -461,13 +461,21 @@ def min_false_distance_to_edge(mask: np.ndarray) -> Tuple[int, int]:
461461 return min (top_dist , bottom_dist ), min (left_dist , right_dist )
462462
463463
464- def blurring_mask_required_shape_from (
464+ import warnings
465+ from typing import Tuple
466+
467+ import numpy as np
468+ from scipy .ndimage import binary_dilation
469+
470+
471+ def required_shape_for_kernel (
465472 mask_2d : np .ndarray ,
466473 kernel_shape_native : Tuple [int , int ],
467474) -> Tuple [int , int ]:
468475 """
469476 Return the minimal shape the mask must be padded to so that a kernel with the given
470- footprint can be applied without sampling beyond the array edge.
477+ footprint can be applied without sampling beyond the array edge, while preserving
478+ parity (odd->odd, even->even) in each dimension.
471479
472480 Parameters
473481 ----------
@@ -480,7 +488,8 @@ def blurring_mask_required_shape_from(
480488 -------
481489 required_shape
482490 The minimal (ny, nx) shape such that the minimum distance from any unmasked
483- pixel to the array edge is at least (ky//2, kx//2).
491+ pixel to the array edge is at least (ky//2, kx//2), and each dimension keeps
492+ the same parity as the input mask.
484493 """
485494 mask_2d = np .asarray (mask_2d , dtype = bool )
486495
@@ -496,7 +505,16 @@ def blurring_mask_required_shape_from(
496505 extra_y = max (0 , pad_y - y_distance )
497506 extra_x = max (0 , pad_x - x_distance )
498507
499- return (mask_2d .shape [0 ] + 2 * extra_y , mask_2d .shape [1 ] + 2 * extra_x )
508+ new_y = mask_2d .shape [0 ] + 2 * extra_y
509+ new_x = mask_2d .shape [1 ] + 2 * extra_x
510+
511+ # Preserve parity per axis: odd->odd, even->even
512+ if (new_y % 2 ) != (mask_2d .shape [0 ] % 2 ):
513+ new_y += 1
514+ if (new_x % 2 ) != (mask_2d .shape [1 ] % 2 ):
515+ new_x += 1
516+
517+ return new_y , new_x
500518
501519
502520def blurring_mask_2d_from (
@@ -511,7 +529,13 @@ def blurring_mask_2d_from(
511529 - False = unmasked (included)
512530 - True = masked (excluded)
513531
514- The returned *blurring mask* is a mask where the blurring-region pixels are unmasked (False).
532+ The returned blurring mask is a *mask* where the blurring-region pixels are
533+ unmasked (False) and all other pixels are masked (True).
534+
535+ If the input mask is too small for the kernel footprint:
536+ - allow_padding=False (default): raises an exception.
537+ - allow_padding=True: pads the mask symmetrically with masked pixels (True) to the
538+ minimal required shape (with parity preserved) and emits a warning.
515539
516540 Parameters
517541 ----------
@@ -520,17 +544,18 @@ def blurring_mask_2d_from(
520544 kernel_shape_native
521545 (ky, kx) kernel footprint.
522546 allow_padding
523- If False (default), raises an exception when the mask is too small
524- for the kernel footprint.
525- If True, pads the mask symmetrically with masked pixels (True) to the
526- minimal required shape and emits a warning.
547+ If False, raise if padding is required. If True, pad and warn.
548+
549+ Returns
550+ -------
551+ blurring_mask
552+ Boolean mask of the same shape as the (possibly padded) input.
527553 """
528554 mask_2d = np .asarray (mask_2d , dtype = bool )
529555
530- required_shape = blurring_mask_required_shape_from (mask_2d , kernel_shape_native )
556+ required_shape = required_shape_for_kernel (mask_2d , kernel_shape_native )
531557
532558 if required_shape != mask_2d .shape :
533-
534559 if not allow_padding :
535560 raise exc .MaskException (
536561 "The input mask is too small for the kernel shape. "
@@ -540,7 +565,7 @@ def blurring_mask_2d_from(
540565
541566 warnings .warn (
542567 f"Mask padded from { mask_2d .shape } to { required_shape } "
543- f"to support kernel footprint { kernel_shape_native } ." ,
568+ f"(parity preserved) to support kernel footprint { kernel_shape_native } ." ,
544569 UserWarning ,
545570 )
546571
@@ -559,36 +584,45 @@ def blurring_mask_2d_from(
559584 constant_values = True , # outside is masked
560585 )
561586
587+ # (Optional) hard invariant: parity preserved after any padding
588+ if (mask_2d .shape [0 ] % 2 ) != (required_shape [0 ] % 2 ) or (mask_2d .shape [1 ] % 2 ) != (
589+ required_shape [1 ] % 2
590+ ):
591+ raise RuntimeError (
592+ f"Parity invariant violated: got { mask_2d .shape } , expected parity of { required_shape } ."
593+ )
594+
562595 ky , kx = kernel_shape_native
563596 pad_y , pad_x = ky // 2 , kx // 2
564597 structure = np .ones ((ky , kx ), dtype = bool )
565598
566599 # Unmasked region (True where unmasked)
567600 unmasked = ~ mask_2d
568601
569- # Pad so outside behaves as masked
602+ # Explicit padding so outside behaves as masked => outside is NOT unmasked
570603 unmasked_padded = np .pad (
571604 unmasked ,
572605 pad_width = ((pad_y , pad_y ), (pad_x , pad_x )),
573606 mode = "constant" ,
574607 constant_values = False ,
575608 )
576609
610+ # Pixels within kernel footprint of any unmasked pixel
577611 near_unmasked_padded = binary_dilation (unmasked_padded , structure = structure )
578-
579612 near_unmasked = near_unmasked_padded [
580613 pad_y : pad_y + mask_2d .shape [0 ],
581614 pad_x : pad_x + mask_2d .shape [1 ],
582615 ]
583616
617+ # Blurring region: masked pixels near unmasked pixels
584618 blurring_region = mask_2d & near_unmasked
585619
620+ # Return as a mask: blurring region is unmasked (False), everything else masked (True)
586621 blurring_mask = np .ones_like (mask_2d , dtype = bool )
587622 blurring_mask [blurring_region ] = False
588623
589624 return blurring_mask
590625
591-
592626def mask_slim_indexes_from (
593627 mask_2d : np .ndarray , return_masked_indexes : bool = True
594628) -> np .ndarray :
0 commit comments