@@ -938,10 +938,23 @@ def resized_from(self, new_shape, pad_value: int = 0.0) -> Mask2D:
938938 @property
939939 def is_circular (self ) -> bool :
940940 """
941- Returns whether the mask is circular or not .
941+ Returns whether the unmasked region of the mask is a filled circular disc .
942942
943- This is performed by taking the central row and column of the mask (based on the mask centre) and counting
944- the number of unmasked pixels. If the number of unmasked pixels is the same, the mask is circular.
943+ The check is robust to circles whose centre is offset from the coordinate origin, including offsets that
944+ do not align with pixel boundaries. It rejects annular, square and other non-disc shapes.
945+
946+ The algorithm:
947+
948+ 1) The bounding box of unmasked pixels must be square to within one pixel. Offset centres that fall between
949+ pixel centres can produce a one-pixel asymmetry in the bounding box, which is allowed; anything larger
950+ indicates a non-circular shape such as an ellipse. Tiny masks (bounding box <= 2 pixels) require an exactly
951+ square bounding box, since the one-pixel slack would otherwise admit 1x2 strips.
952+ 2) The pixel at the geometric centre of the bounding box must be unmasked. This rejects annular masks whose
953+ inner hole is at least one pixel wide.
954+ 3) The centre and radius of the unmasked region are inferred from the bounding box and pixel count, and a
955+ reference circular mask is built with those parameters. The input mask must match the reference within a
956+ small number of pixels (tolerance scales with mask area to absorb rim quantization). This rejects squares,
957+ crosses and tight annuli that slipped past the earlier checks.
945958
946959 This function does not support rectangular masks and an exception will be raised if the pixel scales in each
947960 direction are different.
@@ -955,22 +968,55 @@ def is_circular(self) -> bool:
955968 """
956969 )
957970
958- pixel_coordinates_2d = self .geometry .pixel_coordinates_2d_from (
959- scaled_coordinates_2d = self .mask_centre
971+ where = np .where (np .invert (self .array ))
972+ if where [0 ].size == 0 :
973+ return False
974+
975+ y_min , y_max = int (where [0 ].min ()), int (where [0 ].max ())
976+ x_min , x_max = int (where [1 ].min ()), int (where [1 ].max ())
977+ y_extent = y_max - y_min + 1
978+ x_extent = x_max - x_min + 1
979+
980+ if abs (y_extent - x_extent ) > 1 :
981+ return False
982+ if max (y_extent , x_extent ) <= 2 and y_extent != x_extent :
983+ return False
984+
985+ cy = (y_max + y_min ) // 2
986+ cx = (x_max + x_min ) // 2
987+ if bool (self .array [cy , cx ]):
988+ return False
989+
990+ actual_area = int (where [0 ].size )
991+ inferred_radius_pix = np .sqrt (actual_area / np .pi )
992+ inferred_radius = inferred_radius_pix * self .pixel_scales [0 ]
993+
994+ y_centre_scaled = (
995+ 0.5 * (self .shape_native [0 ] - 1 ) - 0.5 * (y_min + y_max )
996+ ) * self .pixel_scales [0 ]
997+ x_centre_scaled = (
998+ 0.5 * (x_min + x_max ) - 0.5 * (self .shape_native [1 ] - 1 )
999+ ) * self .pixel_scales [1 ]
1000+
1001+ expected = mask_2d_util .mask_2d_circular_from (
1002+ shape_native = self .shape_native ,
1003+ pixel_scales = self .pixel_scales ,
1004+ radius = inferred_radius ,
1005+ centre = (y_centre_scaled , x_centre_scaled ),
9601006 )
9611007
962- central_row_pixels = sum (np .invert (self [pixel_coordinates_2d [0 ], :]))
963- central_column_pixels = sum (np .invert (self [:, pixel_coordinates_2d [1 ]]))
964-
965- return central_row_pixels == central_column_pixels
1008+ diff_count = int (np .sum (self .array != expected ))
1009+ tolerance = max (2 , int (0.1 * actual_area ))
1010+ return diff_count <= tolerance
9661011
9671012 @property
9681013 def circular_radius (self ) -> float :
9691014 """
9701015 Returns the radius in scaled units of a circular mask.
9711016
972- This is performed by taking the central row of the mask (based on the mask centre) and counting the number of
973- unmasked pixels. The radius is then half the number of unmasked pixels times the pixel scale.
1017+ The radius is computed from the bounding box of the unmasked region, taking the larger of the y and x extents
1018+ as the diameter in pixels. This is robust to offset centres that fall between pixel boundaries (where the
1019+ bounding box can be asymmetric by one pixel).
9741020
9751021 The mask is first checked that it is circular using the `is_circular` property, with an exception raised if
9761022 it is not.
@@ -987,15 +1033,14 @@ def circular_radius(self) -> float:
9871033 raise exc .MaskException (
9881034 """
9891035 A circular radius can only be computed for a circular mask.
990-
1036+
9911037 The `is_circular` property of this mask has returned False, indicating the mask is not circular.
9921038 """
9931039 )
9941040
995- pixel_coordinates_2d = self .geometry .pixel_coordinates_2d_from (
996- scaled_coordinates_2d = self .mask_centre
997- )
998-
999- central_row_pixels = sum (np .invert (self [pixel_coordinates_2d [0 ], :]))
1041+ where = np .where (np .invert (self .array ))
1042+ y_extent = int (where [0 ].max () - where [0 ].min () + 1 )
1043+ x_extent = int (where [1 ].max () - where [1 ].min () + 1 )
1044+ diameter = max (y_extent , x_extent )
10001045
1001- return central_row_pixels * self .pixel_scales [0 ] / 2.0
1046+ return diameter * self .pixel_scales [0 ] / 2.0
0 commit comments