eye-patch 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eye_patch/__init__.py +11 -0
- eye_patch/_version.py +24 -0
- eye_patch/_version.pyi +2 -0
- eye_patch/masking.py +1174 -0
- eye_patch/naming.py +40 -0
- eye_patch/pixel_utils.py +101 -0
- eye_patch/py.typed +0 -0
- eye_patch-1.0.0.dist-info/METADATA +60 -0
- eye_patch-1.0.0.dist-info/RECORD +12 -0
- eye_patch-1.0.0.dist-info/WHEEL +4 -0
- eye_patch-1.0.0.dist-info/entry_points.txt +2 -0
- eye_patch-1.0.0.dist-info/licenses/LICENSE +28 -0
eye_patch/masking.py
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
"""Utility functions to make image based masks from images, with the initial
|
|
2
|
+
thought being towards FITS images.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from argparse import ArgumentParser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NamedTuple, TypeAlias
|
|
11
|
+
|
|
12
|
+
import astropy.units as u
|
|
13
|
+
import numpy as np
|
|
14
|
+
from astropy.io import fits
|
|
15
|
+
from astropy.wcs import WCS
|
|
16
|
+
from capn_crunch import BaseOptions, add_options_to_parser, create_options_from_parser
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
from radio_beam import Beam
|
|
19
|
+
from reproject import reproject_interp
|
|
20
|
+
from scipy.ndimage import (
|
|
21
|
+
binary_dilation as scipy_binary_dilation, # Rename to distinguish from skimage
|
|
22
|
+
)
|
|
23
|
+
from scipy.ndimage import (
|
|
24
|
+
binary_erosion as scipy_binary_erosion, # Rename to distinguish from skimage
|
|
25
|
+
)
|
|
26
|
+
from scipy.ndimage import binary_fill_holes, label, maximum_filter, minimum_filter
|
|
27
|
+
from scipy.signal import fftconvolve
|
|
28
|
+
|
|
29
|
+
from eye_patch.naming import FITSMaskNames, create_fits_mask_names
|
|
30
|
+
|
|
31
|
+
# Add explicit export so mypy on tests is ok
|
|
32
|
+
__all__ = ["create_options_from_parser"]
|
|
33
|
+
|
|
34
|
+
# TODO: Need to remove a fair amount of old approaches, and deprecate some of the toy functions
|
|
35
|
+
|
|
36
|
+
# The masks can be represented as either booleans or floats. If booleans they get typecase to floats
|
|
37
|
+
# during fits file creation.
|
|
38
|
+
MaskLike: TypeAlias = NDArray[np.floating]
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("__name__")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MaskingOptions(BaseOptions):
|
|
44
|
+
"""Contains options for the creation of clean masks from some subject
|
|
45
|
+
image. Clipping levels specified are in units of RMS (or sigma). They
|
|
46
|
+
are NOT in absolute units.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
base_snr_clip: float = 4
|
|
50
|
+
"""A base clipping level to be used should other options not be activated"""
|
|
51
|
+
flood_fill: bool = False
|
|
52
|
+
"""Whether to attempt to flood fill when constructing a mask. This should be `True` for ``grow_low_snr_islands`` and ``suppress_artefacts`` to have an effect. """
|
|
53
|
+
flood_fill_positive_seed_clip: float = 4.5
|
|
54
|
+
"""The clipping level to seed islands that will be grown to lower signal metric"""
|
|
55
|
+
flood_fill_positive_flood_clip: float = 1.5
|
|
56
|
+
"""Clipping level used to grow seeded islands down to"""
|
|
57
|
+
flood_fill_use_mac: bool = False
|
|
58
|
+
"""If True, the clipping levels are used as the `increase_factor` when using a minimum absolute clip"""
|
|
59
|
+
flood_fill_use_mac_box_size: int = 75
|
|
60
|
+
"""The size of the mac box size should mac be used"""
|
|
61
|
+
flood_fill_use_mac_adaptive_step_factor: float = 2.0
|
|
62
|
+
"""Stepping size used to increase box by should adaptive detect poor boxcar statistics"""
|
|
63
|
+
flood_fill_use_mac_adaptive_skew_delta: float = 0.2
|
|
64
|
+
"""A box is consider too small for a pixel if the fractional proportion of positive pixels is larger than the deviation away of (0.5 + frac). This threshold is therefore 0 to 0.5"""
|
|
65
|
+
flood_fill_use_mac_adaptive_max_depth: int | None = None
|
|
66
|
+
"""Determines the number of adaptive boxcar scales to use when constructing seed mask. If None no adaptive boxcar sizes"""
|
|
67
|
+
grow_low_snr_island: bool = False
|
|
68
|
+
"""Whether to attempt to grow a mask to capture islands of low SNR (e.g. diffuse emission)"""
|
|
69
|
+
grow_low_snr_island_clip: float = 1.75
|
|
70
|
+
"""The minimum significance levels of pixels to be to seed low SNR islands for consideration"""
|
|
71
|
+
grow_low_snr_island_size: int = 768
|
|
72
|
+
"""The number of pixels an island has to be for it to be accepted"""
|
|
73
|
+
beam_shape_erode: bool = False
|
|
74
|
+
"""Erode the mask using the shape of the restoring beam"""
|
|
75
|
+
beam_shape_erode_minimum_response: float = 0.6
|
|
76
|
+
"""The minimum response of the beam that is used to form the erode structure shape"""
|
|
77
|
+
beam_shape_erode_scales: tuple[int, ...] | None = None
|
|
78
|
+
"""The multi-scale convolution sizes, in pixels, to perform a binary erosion with. Output scales are encoded as a bitmapped value (e.g. n'th scale is n'th bit)"""
|
|
79
|
+
convolve_first: bool = False
|
|
80
|
+
"""Attempt to construct mask across scales by first convolving the input image by a scale kernel, then run the island construction stage"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_beam_mask_kernel(
|
|
84
|
+
fits_header: fits.Header,
|
|
85
|
+
kernel_size: int | tuple[int, int] = 100,
|
|
86
|
+
minimum_response: float = 0.6,
|
|
87
|
+
pixel_scale: int | None = None,
|
|
88
|
+
auto_resize: bool = True,
|
|
89
|
+
) -> NDArray[np.bool]:
|
|
90
|
+
"""Make a mask using the shape of a beam in a FITS Header object. The
|
|
91
|
+
beam properties in the ehader are used to generate the two-dimensional
|
|
92
|
+
Gaussian main lobe, from which a cut is made based on the minimum
|
|
93
|
+
power.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
fits_header (fits.Header): The FITS header to create beam from
|
|
97
|
+
kernel_size (int | tuple[int, int], optional): Size of the output kernel in pixels. Will be a square. Defaults to 100.
|
|
98
|
+
minimum_response (float, optional): Minimum response of the beam shape for the mask to be constructed from. Defaults to 0.6.
|
|
99
|
+
pixel_scale (int | None, optional): Convolve the restoring beam by this scale. The input scale is in pixel units. Defaults to None.
|
|
100
|
+
auto_resize (bool, optional): If True and the kernel is evaluated to all 1's, enlarge the ``kernel_size`` by a factor of 2. Defaults to True.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
KeyError: Raised if CDELT1 and CDELT2 missing
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
NDArray[np.bool]: Boolean mask of the kernel shape
|
|
107
|
+
"""
|
|
108
|
+
assert 0.0 < minimum_response < 1.0, (
|
|
109
|
+
f"{minimum_response=}, should be between 0 to 1 (exclusive)"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
POSITION_KEYS = ("CDELT1", "CDELT2")
|
|
113
|
+
if not all(key in fits_header for key in POSITION_KEYS):
|
|
114
|
+
msg = f"{POSITION_KEYS=} all need to be present"
|
|
115
|
+
raise KeyError(msg)
|
|
116
|
+
|
|
117
|
+
beam = Beam.from_fits_header(fits_header)
|
|
118
|
+
assert isinstance(beam, Beam)
|
|
119
|
+
|
|
120
|
+
cdelt1, cdelt2 = np.abs(fits_header["CDELT1"]), np.abs(fits_header["CDELT2"])
|
|
121
|
+
assert np.isclose(cdelt1, cdelt2), (
|
|
122
|
+
f"Pixel scales {cdelt1=} {cdelt2=}, but must be equal"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
pixel_unit = u.Unit("deg")
|
|
126
|
+
|
|
127
|
+
if pixel_scale is not None:
|
|
128
|
+
assert isinstance(pixel_scale, int), (
|
|
129
|
+
f"{pixel_scale=} and is of type {type(pixel_scale)}, but should be int"
|
|
130
|
+
)
|
|
131
|
+
logger.debug(f"Convoling {beam=} with {pixel_scale=}")
|
|
132
|
+
arcsecond_kernel = int(pixel_scale) * cdelt1 * pixel_unit
|
|
133
|
+
scale_beam = Beam(arcsecond_kernel, arcsecond_kernel, 0 * u.deg)
|
|
134
|
+
|
|
135
|
+
logger.debug(f"Convolving with {scale_beam=}")
|
|
136
|
+
beam = beam.convolve(scale_beam)
|
|
137
|
+
|
|
138
|
+
if isinstance(kernel_size, int):
|
|
139
|
+
kernel_size = (kernel_size, kernel_size)
|
|
140
|
+
|
|
141
|
+
# Trust no one, mate
|
|
142
|
+
assert len(kernel_size) == 2, (
|
|
143
|
+
f"{kernel_size=}, but should be a tuple[int, int] by now"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
for iteration in range(10):
|
|
147
|
+
y_kernel_size, x_kernel_size = kernel_size
|
|
148
|
+
|
|
149
|
+
# NOTE: This could be slow for large sizes.
|
|
150
|
+
# TODO: Consider evaluating the inner, padding the outer?
|
|
151
|
+
k = beam.as_kernel(
|
|
152
|
+
pixscale=cdelt1 * pixel_unit, x_size=x_kernel_size, y_size=y_kernel_size
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
mask = k.array > (np.max(k.array) * minimum_response)
|
|
156
|
+
if not auto_resize:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# Ensure that there be a border of empty pixels, mate. This
|
|
160
|
+
# way we be sure ye larger enough to contain the beam response.
|
|
161
|
+
if (
|
|
162
|
+
np.all(mask[0, :] == 0)
|
|
163
|
+
and np.all(mask[-1, :] == 0)
|
|
164
|
+
and np.all(mask[:, 0] == 0)
|
|
165
|
+
and np.all(mask[-1, :] == 0)
|
|
166
|
+
):
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
kernel_size = tuple(ks + ks * (iteration + 1) for ks in kernel_size) # type: ignore[assignment]
|
|
170
|
+
logger.warning(f"Increasing to {kernel_size=} for {pixel_scale=}")
|
|
171
|
+
else:
|
|
172
|
+
logger.warning(
|
|
173
|
+
f"{kernel_size=} appears too small for {pixel_scale=} after {iteration=}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
assert isinstance(mask, np.ndarray), f"Expected a numpy array, have {type(mask)}"
|
|
177
|
+
return mask
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def fft_binary_erosion(
|
|
181
|
+
mask: MaskLike,
|
|
182
|
+
kernel: NDArray[np.bool],
|
|
183
|
+
) -> NDArray[np.bool]:
|
|
184
|
+
"""Attempt to perform a binary erosion using convolution therom. FFT
|
|
185
|
+
the input mask, FFT the input kernel, multiply, FFT the result. This
|
|
186
|
+
would give the running sum of the kernel moved across the image, which
|
|
187
|
+
in the case of boolean-type values will be the number of overlapping 1's.
|
|
188
|
+
|
|
189
|
+
The returned mask is after ensuring the kernel total sum matches the running
|
|
190
|
+
sum.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
mask (MaskLike): The mask that will be eroded
|
|
194
|
+
kernel (NDArray[np.bool]): The kernel structure used for the erosion
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
NDArray[np.bool]: The eroded mask
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
logger.info("Beginning fft erosion ... ")
|
|
201
|
+
original_shape = mask.shape
|
|
202
|
+
|
|
203
|
+
mask = mask[..., :, :].astype(float)
|
|
204
|
+
kernel = kernel[..., :, :].astype(float)
|
|
205
|
+
|
|
206
|
+
erode_sum = np.floor(np.abs(fftconvolve(mask, kernel, mode="same")) + 0.5)
|
|
207
|
+
|
|
208
|
+
logger.info("... finished erosion")
|
|
209
|
+
|
|
210
|
+
erosion_mask: NDArray[np.bool] = erode_sum >= np.sum(kernel)
|
|
211
|
+
return np.reshape(erosion_mask, original_shape)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def create_multi_scale_erosion(
|
|
215
|
+
mask: MaskLike,
|
|
216
|
+
fits_header: fits.Header,
|
|
217
|
+
scale: int,
|
|
218
|
+
minimum_response: float = 0.6,
|
|
219
|
+
) -> NDArray[np.bool]:
|
|
220
|
+
"""Specialised encode per-scale clean masks as bitmapped per-pixel integer values.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
mask (MaskLike): The current mask that will be eroded based on the beam shape
|
|
224
|
+
fits_header (fits.Header): The fits header of the mask used to generate the beam kernel shape
|
|
225
|
+
scales (list[int] | None, optional): Defines the scales that are being used during multi-scale clean. Perform the beam erosion at each of these scales. Defaults to None.
|
|
226
|
+
minimum_response (float, optional): The minimum response of the main restoring beam to craft the shape from. Defaults to 0.6.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
NDArray[np.bool]: The eroded mask
|
|
230
|
+
"""
|
|
231
|
+
# Trust no one on the high seas, mate
|
|
232
|
+
if not all(key in fits_header for key in ["BMAJ", "BMIN", "BPA"]):
|
|
233
|
+
logger.warning(
|
|
234
|
+
"Beam parameters missing. Not performing the beam shape erosion. "
|
|
235
|
+
)
|
|
236
|
+
return mask # type: ignore[return-value]
|
|
237
|
+
|
|
238
|
+
logger.info(f"Eroding with {scale=}, {minimum_response=}")
|
|
239
|
+
|
|
240
|
+
# NOTE; The kernel_size=max() is to avoid excessive resize warnings
|
|
241
|
+
# at low scales
|
|
242
|
+
beam_mask_kernel = create_beam_mask_kernel(
|
|
243
|
+
fits_header=fits_header,
|
|
244
|
+
minimum_response=minimum_response,
|
|
245
|
+
pixel_scale=scale,
|
|
246
|
+
kernel_size=max(16, int(1 + scale * 1.1)),
|
|
247
|
+
auto_resize=True,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# This handles any unsqueezed dimensions
|
|
251
|
+
beam_mask_kernel = beam_mask_kernel.reshape(
|
|
252
|
+
mask.shape[:-2] + beam_mask_kernel.shape
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# As far as this pirate knows the scipy_binary_erosion used
|
|
256
|
+
# at the bottom is evaluated in the imade plane. For larger
|
|
257
|
+
# images and larger kernels (particularly the later), memory
|
|
258
|
+
# usage ballons. I suspect scipy tries to stack all shifted kernels
|
|
259
|
+
# and does the dot product in one go for all positions. In such
|
|
260
|
+
# an instance a better thing to do is via convolution - larger upfront
|
|
261
|
+
# compute cost but no memory explosion.
|
|
262
|
+
size_ratio = np.prod(beam_mask_kernel.shape) / np.prod(mask.shape)
|
|
263
|
+
logger.debug(f"{size_ratio=}")
|
|
264
|
+
fft_erosion_mode = (size_ratio) > 5e-5
|
|
265
|
+
|
|
266
|
+
if fft_erosion_mode:
|
|
267
|
+
logger.debug(
|
|
268
|
+
"Will be using fft-based binary erosion, based on size of kernel-to-mask"
|
|
269
|
+
)
|
|
270
|
+
return fft_binary_erosion(mask=mask, kernel=beam_mask_kernel)
|
|
271
|
+
|
|
272
|
+
eroded_mask: NDArray[np.bool] = scipy_binary_erosion(
|
|
273
|
+
input=mask, iterations=1, structure=beam_mask_kernel
|
|
274
|
+
)
|
|
275
|
+
return eroded_mask
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def beam_shape_erode(
|
|
279
|
+
mask: MaskLike,
|
|
280
|
+
fits_header: fits.Header,
|
|
281
|
+
minimum_response: float = 0.6,
|
|
282
|
+
scales: list[int] | tuple[int, ...] | None = None,
|
|
283
|
+
) -> MaskLike:
|
|
284
|
+
"""Construct a kernel representing the shape of the restoring beam at
|
|
285
|
+
a particular level, and use it as the basis of a binary erosion of the
|
|
286
|
+
input mask.
|
|
287
|
+
|
|
288
|
+
The ``fits_header`` is used to construct the beam shape that matches the
|
|
289
|
+
same pixel size
|
|
290
|
+
|
|
291
|
+
The ``scales`` parameters outlines the scales being used to clean at during
|
|
292
|
+
some multi-scale deconvolution algorithm. If provided the binary eroision is
|
|
293
|
+
carried out at each scale (the beam is convolved by a circular gaussian at
|
|
294
|
+
this scale). The outputs are stored as a bitmask per pixel where the n'th scales
|
|
295
|
+
is stored as the n'th bit.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
mask (MaskLike): The current mask that will be eroded based on the beam shape
|
|
299
|
+
fits_header (fits.Header): The fits header of the mask used to generate the beam kernel shape
|
|
300
|
+
minimum_response (float, optional): The minimum response of the main restoring beam to craft the shape from. Defaults to 0.6.
|
|
301
|
+
scales (list[int] | tuple[int, ...] | None, optional): Defines the scales that are being used during multi-scale clean. Perform the beam erosion at each of these scales. Defaults to None.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
MaskLike: The eroded beam shape. If a no/single scale provide it is a bool return, otherwise int32.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
if not all(key in fits_header for key in ["BMAJ", "BMIN", "BPA"]):
|
|
308
|
+
logger.warning(
|
|
309
|
+
"Beam parameters missing. Not performing the beam shape erosion. "
|
|
310
|
+
)
|
|
311
|
+
return mask
|
|
312
|
+
|
|
313
|
+
scales = [0] if scales is None else scales
|
|
314
|
+
assert isinstance(scales, (list, tuple)), f"{scales} should be a list or tuple"
|
|
315
|
+
logger.info(f"Eroding across {scales=}")
|
|
316
|
+
|
|
317
|
+
out_mask = np.zeros(mask.shape)
|
|
318
|
+
|
|
319
|
+
for index, scale in enumerate(scales):
|
|
320
|
+
erode_mask = create_multi_scale_erosion(
|
|
321
|
+
mask=mask,
|
|
322
|
+
fits_header=fits_header,
|
|
323
|
+
scale=scale,
|
|
324
|
+
minimum_response=minimum_response,
|
|
325
|
+
)
|
|
326
|
+
out_mask[erode_mask == 1.0] += 2**index
|
|
327
|
+
|
|
328
|
+
if len(scales) == 1:
|
|
329
|
+
return out_mask.astype(mask.dtype)
|
|
330
|
+
|
|
331
|
+
return out_mask
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def extract_beam_mask_from_mosaic(
|
|
335
|
+
fits_beam_image_path: Path, fits_mosaic_mask_names: FITSMaskNames
|
|
336
|
+
) -> FITSMaskNames:
|
|
337
|
+
"""Extract a region based on an existing FITS image from a masked FITS
|
|
338
|
+
image. Here a masked FITS image is intended to be one created by
|
|
339
|
+
``create_snr_mask_from_fits``.
|
|
340
|
+
|
|
341
|
+
A simple cutout (e.g. ``CutOut2D``) might not work as intended, as
|
|
342
|
+
when creating the field image (the intended use case here) might be
|
|
343
|
+
regridded onto a unique pixel grid that does not correspond to one
|
|
344
|
+
that would be constructed by wsclean.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
fits_beam_image_path (Path): The template image with a valid WCS. This region is used to extract the masked region
|
|
348
|
+
fits_mosaic_mask_names (FITSMaskNames): The previously masked image
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
FITSMaskNames: _description_
|
|
352
|
+
"""
|
|
353
|
+
# TODO: Ideally we can accept an arbitrary WCS, or read the wsclean docs to
|
|
354
|
+
# try to construct it ourselves. The last thing that this pirate wants is
|
|
355
|
+
# to run the imager in a dry-run type mode mode purely for the WCS.
|
|
356
|
+
|
|
357
|
+
mask_names = create_fits_mask_names(fits_image=fits_beam_image_path)
|
|
358
|
+
|
|
359
|
+
with fits.open(fits_beam_image_path) as beam_image:
|
|
360
|
+
header = beam_image[0].header
|
|
361
|
+
beam_image_shape = beam_image[0].data.shape[-2:]
|
|
362
|
+
|
|
363
|
+
logger.info(
|
|
364
|
+
f"Will extract {beam_image_shape} from {fits_mosaic_mask_names.mask_fits}"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
with fits.open(fits_mosaic_mask_names.mask_fits) as mosaic_mask:
|
|
368
|
+
logger.info("Extracting region")
|
|
369
|
+
extract_image = reproject_interp(
|
|
370
|
+
input_data=(
|
|
371
|
+
np.squeeze(mosaic_mask[0].data),
|
|
372
|
+
WCS(mosaic_mask[0].header).celestial,
|
|
373
|
+
),
|
|
374
|
+
output_projection=WCS(header).celestial,
|
|
375
|
+
shape_out=beam_image_shape,
|
|
376
|
+
order=0,
|
|
377
|
+
)
|
|
378
|
+
logger.info("Clipping extract mask results (interpolation errors)")
|
|
379
|
+
extract_image = np.clip(extract_image, 0.0, 1.0)
|
|
380
|
+
|
|
381
|
+
fits.writeto(mask_names.mask_fits, extract_image[0].astype(np.int32), header)
|
|
382
|
+
|
|
383
|
+
return mask_names
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _get_signal_image(
|
|
387
|
+
image: NDArray[np.floating] | None = None,
|
|
388
|
+
rms: NDArray[np.floating] | None = None,
|
|
389
|
+
background: NDArray[np.floating] | None = None,
|
|
390
|
+
signal: NDArray[np.floating] | None = None,
|
|
391
|
+
) -> NDArray[np.floating]:
|
|
392
|
+
"""Construct a signal image from inputs. The signal is defined as:
|
|
393
|
+
|
|
394
|
+
>>> (image - background) / rms
|
|
395
|
+
|
|
396
|
+
Should an input to ``signal`` be provided it will take precedence.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
image (NDArray[np.floating] | None, optional): The base image. Defaults to None.
|
|
400
|
+
rms (NDArray[np.floating] | None, optional): The noise estimate over the image. Defaults to None.
|
|
401
|
+
background (NDArray[np.floating] | None, optional): The background across the image. Defaults to None.
|
|
402
|
+
signal (NDArray[np.floating] | None, optional): A pre-calculate signal image. Defaults to None.
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
ValueError: Raised when no signal is provided and there is not enough information to calculate one.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
NDArray[np.floating]: The output signal array
|
|
409
|
+
"""
|
|
410
|
+
if all(item is None for item in (image, background, rms, signal)):
|
|
411
|
+
msg = "No input maps have been provided. "
|
|
412
|
+
raise ValueError(msg)
|
|
413
|
+
|
|
414
|
+
if signal is not None:
|
|
415
|
+
return signal
|
|
416
|
+
|
|
417
|
+
assert isinstance(image, np.ndarray), f"{image=}, but should be an array"
|
|
418
|
+
assert isinstance(rms, np.ndarray), f"{rms=}, but should be an array"
|
|
419
|
+
if background is None:
|
|
420
|
+
logger.info("No background supplied, assuming zeros. ")
|
|
421
|
+
background = np.zeros_like(image)
|
|
422
|
+
|
|
423
|
+
assert isinstance(background, np.ndarray)
|
|
424
|
+
out_signal: NDArray[np.floating] = (image - background) / rms
|
|
425
|
+
|
|
426
|
+
return out_signal
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def grow_low_snr_mask(
|
|
430
|
+
image: NDArray[np.floating] | None = None,
|
|
431
|
+
rms: NDArray[np.floating] | None = None,
|
|
432
|
+
background: NDArray[np.floating] | None = None,
|
|
433
|
+
signal: NDArray[np.floating] | None = None,
|
|
434
|
+
grow_low_snr: float = 2.0,
|
|
435
|
+
grow_low_island_size: int = 512,
|
|
436
|
+
region_mask: NDArray[np.bool] | None = None,
|
|
437
|
+
) -> NDArray[np.bool]:
|
|
438
|
+
"""There may be cases where normal thresholding operations based on simple pixel-wise SNR
|
|
439
|
+
cuts fail to pick up diffuse, low surface brightness regions of emission. When some type
|
|
440
|
+
of masking operation is used there may be instances where these regions are never cleaned.
|
|
441
|
+
|
|
442
|
+
Sometimes smoothing can help to pick up these features, but when attempting to pass such
|
|
443
|
+
a mask through to the imagery of choice the islands may be larger than the features at their
|
|
444
|
+
native resolution, unless some other more sophisticated filtering is performed.
|
|
445
|
+
|
|
446
|
+
This function attempts to grow masks to capture islands of contiguous pixels above a low
|
|
447
|
+
SNR cut that would otherwise go uncleaned.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
Args:
|
|
451
|
+
image (NDArray[np.floating] | None, optional): The total intensity pixels to have the mask for. Defaults to None.
|
|
452
|
+
rms (NDArray[np.floating] | None, optional): The noise across the image. Defaults to None.
|
|
453
|
+
background (NDArray[np.floating] | None, optional): The background acros the image. If None, zeros are assumed. Defaults to None.
|
|
454
|
+
signal(NDArray[np.floating] | None, optional): A signal map. Defaults to None.
|
|
455
|
+
grow_low_snr (float, optional): The SNR pixekls have to be above. Defaults to 2.
|
|
456
|
+
grow_low_island_size (int, optional): The minimum number of pixels an island should be for it to be considered valid. Defaults to 512.
|
|
457
|
+
region_mask (NDArray[np.bool] | None, optional): Whether some region should be masked out before the island size constraint is applied. Defaults to None.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
NDArray[np.bool]: The derived mask of objects with low-surface brightness
|
|
461
|
+
"""
|
|
462
|
+
# TODO: The `grow_low_island_size` should be represented in solid angle relative to the restoring beam
|
|
463
|
+
|
|
464
|
+
signal = _get_signal_image(
|
|
465
|
+
image=image, rms=rms, background=background, signal=signal
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
logger.info(
|
|
469
|
+
f"Growing mask for low surface brightness using {grow_low_snr=} {grow_low_island_size=}"
|
|
470
|
+
)
|
|
471
|
+
low_snr_mask: NDArray[np.bool] = signal > grow_low_snr
|
|
472
|
+
low_snr_mask = scipy_binary_dilation(
|
|
473
|
+
input=low_snr_mask, iterations=2, structure=np.ones((3, 3))
|
|
474
|
+
)
|
|
475
|
+
low_snr_mask = scipy_binary_erosion(
|
|
476
|
+
input=low_snr_mask, iterations=2, structure=np.ones((3, 3))
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if region_mask is not None:
|
|
480
|
+
low_snr_mask[region_mask] = False
|
|
481
|
+
|
|
482
|
+
mask_labels, _no_labels = label(low_snr_mask, structure=np.ones((3, 3)))
|
|
483
|
+
_, counts = np.unique(mask_labels.flatten(), return_counts=True)
|
|
484
|
+
|
|
485
|
+
small_islands = [
|
|
486
|
+
idx
|
|
487
|
+
for idx, count in enumerate(counts)
|
|
488
|
+
if count < grow_low_island_size and idx > 0
|
|
489
|
+
]
|
|
490
|
+
low_snr_mask[np.isin(mask_labels, small_islands)] = False
|
|
491
|
+
|
|
492
|
+
return low_snr_mask
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class SkewResult(NamedTuple):
|
|
496
|
+
positive_pixel_frac: NDArray[np.floating]
|
|
497
|
+
"""The fraction of positive pixels in a boxcar function"""
|
|
498
|
+
skew_mask: NDArray[np.bool]
|
|
499
|
+
"""Mask of pixel positions indicating which positions failed the skew test"""
|
|
500
|
+
box_size: int
|
|
501
|
+
"""Size of the boxcar window applies"""
|
|
502
|
+
skew_delta: float
|
|
503
|
+
"""The test threshold for skew"""
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def create_boxcar_skew_mask(
|
|
507
|
+
image: NDArray[np.floating], skew_delta: float, box_size: int
|
|
508
|
+
) -> SkewResult:
|
|
509
|
+
assert 0.0 < skew_delta < 0.5, f"{skew_delta=}, but should be 0.0 to 0.5"
|
|
510
|
+
assert len(image.shape) == 2, (
|
|
511
|
+
f"Expected two dimensions, got image shape of {image.shape}"
|
|
512
|
+
)
|
|
513
|
+
logger.info(f"Computing boxcar skew with {box_size=} and {skew_delta=}")
|
|
514
|
+
positive_pixels = (image > 0.0).astype(np.float32)
|
|
515
|
+
|
|
516
|
+
# Counting positive pixel fraction here. The su
|
|
517
|
+
window_shape = (box_size, box_size)
|
|
518
|
+
positive_pixel_fraction = fftconvolve(
|
|
519
|
+
in1=positive_pixels, in2=np.ones(window_shape, dtype=np.float32), mode="same"
|
|
520
|
+
) / np.prod(window_shape)
|
|
521
|
+
positive_pixel_fraction = np.clip(
|
|
522
|
+
positive_pixel_fraction, 0.0, 1.0
|
|
523
|
+
) # trust nothing
|
|
524
|
+
|
|
525
|
+
skew_mask = positive_pixel_fraction > (0.5 + skew_delta)
|
|
526
|
+
logger.info(f"{np.sum(skew_mask)} pixels above {skew_delta=} with {box_size=}")
|
|
527
|
+
|
|
528
|
+
return SkewResult(
|
|
529
|
+
positive_pixel_frac=positive_pixel_fraction,
|
|
530
|
+
skew_mask=skew_mask,
|
|
531
|
+
skew_delta=skew_delta,
|
|
532
|
+
box_size=box_size,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _minimum_absolute_clip(
|
|
537
|
+
image: NDArray[np.floating], increase_factor: float = 2.0, box_size: int = 100
|
|
538
|
+
) -> NDArray[np.bool]:
|
|
539
|
+
"""Given an input image or signal array, construct a simple image mask by applying a
|
|
540
|
+
rolling boxcar minimum filter, and then selecting pixels above a cut of
|
|
541
|
+
the absolute value value scaled by `increase_factor`. This is a pixel-wise operation.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
image (NDArray[np.floating]): The input array to consider
|
|
545
|
+
increase_factor (float, optional): How large to scale the absolute minimum by. Defaults to 2.0.
|
|
546
|
+
box_size (int, optional): Size of the rolling boxcar minimum filtr. Defaults to 100.
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
NDArray[np.bool]: The mask of pixels above the locally varying threshold
|
|
550
|
+
"""
|
|
551
|
+
logger.info(f"Minimum absolute clip, {increase_factor=} {box_size=}")
|
|
552
|
+
rolling_box_min: NDArray[np.floating] = minimum_filter(image, box_size, mode="wrap")
|
|
553
|
+
|
|
554
|
+
mac_mask: NDArray[np.bool] = image > (increase_factor * np.abs(rolling_box_min))
|
|
555
|
+
return mac_mask
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _adaptive_minimum_absolute_clip(
|
|
559
|
+
image: NDArray[np.floating],
|
|
560
|
+
increase_factor: float = 2.0,
|
|
561
|
+
box_size: int = 100,
|
|
562
|
+
adaptive_max_depth: int = 3,
|
|
563
|
+
adaptive_box_step: float = 2.0,
|
|
564
|
+
adaptive_skew_delta: float = 0.2,
|
|
565
|
+
) -> NDArray[np.bool]:
|
|
566
|
+
"""Derive the minimum absolute clip where the box size is adaptively scaled
|
|
567
|
+
if an excess of positive pixels is detected towards some direction. Should a
|
|
568
|
+
set of pixels fail the skewness test then a larger box is used and the MAC
|
|
569
|
+
from that larger box is used.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
image (NDArray[np.floating]): The input image to compute the adaptive MAC against
|
|
573
|
+
increase_factor (float, optional): The factor to increase minimum absolute value by before applying the clip. Defaults to 2.0.
|
|
574
|
+
box_size (int, optional): The size of the box in pixels. Defaults to 100.
|
|
575
|
+
adaptive_max_depth (int, optional): The maximum number of times the box can be enlarged. Defaults to 3.
|
|
576
|
+
adaptive_box_step (float, optional): The factor the box is increased by for pixels that failed the skewness test. Defaults to 2.0.
|
|
577
|
+
adaptive_skew_delta (float, optional): The deviation away from 0.5 that is allowed for positive / (positive + negative) before a region is considered skewed. Defaults to 0.2.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
NDArray[np.bool]: The final mask
|
|
581
|
+
"""
|
|
582
|
+
logger.info(
|
|
583
|
+
f"Using adaptive minimum absolute clip with {box_size=} {adaptive_skew_delta=}"
|
|
584
|
+
)
|
|
585
|
+
min_value = minimum_filter(input=image, size=box_size, mode="wrap")
|
|
586
|
+
|
|
587
|
+
for box_round in range(adaptive_max_depth, 0, -1):
|
|
588
|
+
skew_results = create_boxcar_skew_mask(
|
|
589
|
+
image=image, skew_delta=adaptive_skew_delta, box_size=box_size
|
|
590
|
+
)
|
|
591
|
+
if np.all(~skew_results.skew_mask):
|
|
592
|
+
logger.info("No skewed islands detected")
|
|
593
|
+
break
|
|
594
|
+
if any(box_size > dim for dim in image.shape):
|
|
595
|
+
logger.info(f"{box_size=} larger than a dimension in {image.shape=}")
|
|
596
|
+
break
|
|
597
|
+
|
|
598
|
+
logger.info(f"({box_round}) Growing {box_size=} {adaptive_box_step=}")
|
|
599
|
+
box_size = int(box_size * adaptive_box_step)
|
|
600
|
+
_min_value = minimum_filter(input=image, size=box_size, mode="wrap")
|
|
601
|
+
logger.debug("Slicing minimum values into place")
|
|
602
|
+
|
|
603
|
+
min_value[skew_results.skew_mask] = _min_value[skew_results.skew_mask]
|
|
604
|
+
|
|
605
|
+
mac_mask: NDArray[np.bool] = image > (np.abs(min_value) * increase_factor)
|
|
606
|
+
return mac_mask
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def minimum_absolute_clip(
|
|
610
|
+
image: NDArray[np.floating],
|
|
611
|
+
increase_factor: float = 2.0,
|
|
612
|
+
box_size: int = 100,
|
|
613
|
+
adaptive_max_depth: int | None = None,
|
|
614
|
+
adaptive_box_step: float = 2.0,
|
|
615
|
+
adaptive_skew_delta: float = 0.2,
|
|
616
|
+
) -> NDArray[np.bool]:
|
|
617
|
+
"""Implements minimum absolute clip method. A minimum filter of a particular
|
|
618
|
+
boxc size is applied to the input image. The absolute of the output is taken
|
|
619
|
+
and increased by a guard factor, which forms the clipping level used to construct
|
|
620
|
+
a clean mask:
|
|
621
|
+
|
|
622
|
+
>>> image > (absolute(minimum_filter(image, box)) * factor)
|
|
623
|
+
|
|
624
|
+
The idea is only valid for zero mean and normally distributed pixels, with
|
|
625
|
+
positive definite flux, making it appropriate for Stokes I.
|
|
626
|
+
|
|
627
|
+
Larger box sizes and guard factors will make the mask more conservative. Should
|
|
628
|
+
the boxcar be too small relative to some feature it is aligned it is possible
|
|
629
|
+
that an excess of positive pixels will produce an less than optimal clipping
|
|
630
|
+
level. An adaptive box size mode, if activated, attempts to use a larger box
|
|
631
|
+
around these regions.
|
|
632
|
+
|
|
633
|
+
The basic idea being detecting regions where the boxcar is too small is around
|
|
634
|
+
the idea that there should be a similar number of positive to negative pixels.
|
|
635
|
+
Should there be too many positive pixels in a region it is likely there is an
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
image (NDArray[np.floating]): Image to create a mask for
|
|
639
|
+
increase_factor (float, optional): The guard factor used to inflate the absolute of the minimum filter. Defaults to 2.0.
|
|
640
|
+
box_size (int, optional): Size of the box car of the minimum filter. Defaults to 100.
|
|
641
|
+
adaptive_max_depth (int | None, optional): The maximum number of rounds that the adaptive mode is allowed to perform when rescaling boxcar results in certain directions. Defaults to None.
|
|
642
|
+
adaptive_box_step (float, optional): A multiplicative factor to increase the boxcar size by each round. Defaults to 2.0.
|
|
643
|
+
adaptive_skew_delta (float, optional): Minimum deviation from 0.5 that needs to be met to classify a region as skewed. Defaults to 0.2.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
NDArray[np.bool]: Final mask
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
if adaptive_max_depth is None:
|
|
650
|
+
return _minimum_absolute_clip(
|
|
651
|
+
image=image, box_size=box_size, increase_factor=increase_factor
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
adaptive_max_depth = int(adaptive_max_depth)
|
|
655
|
+
|
|
656
|
+
return _adaptive_minimum_absolute_clip(
|
|
657
|
+
image=image,
|
|
658
|
+
increase_factor=increase_factor,
|
|
659
|
+
box_size=box_size,
|
|
660
|
+
adaptive_max_depth=adaptive_max_depth,
|
|
661
|
+
adaptive_box_step=adaptive_box_step,
|
|
662
|
+
adaptive_skew_delta=adaptive_skew_delta,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _verify_set_positive_seed_clip(
|
|
667
|
+
positive_seed_clip: float, signal: NDArray[np.floating]
|
|
668
|
+
) -> float:
|
|
669
|
+
"""Ensure that the positive seed clip is handled appropriately. If it
|
|
670
|
+
is too high then it is set to be 90-percent of the maximum signal"""
|
|
671
|
+
max_signal = np.max(signal)
|
|
672
|
+
if max_signal < positive_seed_clip:
|
|
673
|
+
logger.critical(
|
|
674
|
+
f"The maximum signal {max_signal:.4f} is below the provided {positive_seed_clip=}. "
|
|
675
|
+
"Setting clip to 90 percent of maximum. "
|
|
676
|
+
)
|
|
677
|
+
positive_seed_clip = max_signal * 0.9
|
|
678
|
+
|
|
679
|
+
return positive_seed_clip
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def reverse_negative_flood_fill(
|
|
683
|
+
base_image: NDArray[np.floating],
|
|
684
|
+
masking_options: MaskingOptions,
|
|
685
|
+
) -> NDArray[np.bool]:
|
|
686
|
+
"""Attempt to:
|
|
687
|
+
|
|
688
|
+
* seed masks around bright regions of an image and grow them to lower significance thresholds
|
|
689
|
+
* remove regions of negative and positive islands that surround bright sources.
|
|
690
|
+
|
|
691
|
+
An initial set of islands (and masks) are constructed by first
|
|
692
|
+
using the `positive_seed_clip` to create an initial SNR based
|
|
693
|
+
mask. These islands then are binary dilated to grow the islands
|
|
694
|
+
to adjacent pixels at a lower significance level (see `scipy.ndimage.binary_dilation`).
|
|
695
|
+
|
|
696
|
+
Next an attempt is made to remove artefacts around bright sources, where
|
|
697
|
+
there are likely to be positive and negative artefacts
|
|
698
|
+
that originate from calibration errors, deconvolution errors, or residual
|
|
699
|
+
structure from an incomplete clean.
|
|
700
|
+
|
|
701
|
+
This operation will search for islands of _negative_ pixels above a
|
|
702
|
+
threshold. These pixels are then grown after a guard mask has been constructed
|
|
703
|
+
around bright pixels.
|
|
704
|
+
|
|
705
|
+
The assumptions that go into this process:
|
|
706
|
+
|
|
707
|
+
* the no genuine source of negative sky emission
|
|
708
|
+
* if there are bright negative artefacts there are likely bright positive artefacts nearby
|
|
709
|
+
* such negative pixels are ~10% level artefacts from a presumed bright sources
|
|
710
|
+
|
|
711
|
+
Optionally, the `grow_low_snr_mask` may also be considered via the `grow_low_snr` and `grow_low_island_size`
|
|
712
|
+
parameters.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
base_image (NDArray[np.floating]): The base image or signal map that is used throughout the fill procedure.
|
|
716
|
+
masking_options (MaskingOptions): Options to carry out masking.
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
NDArray[np.bool]: Mask of the pixels to clean
|
|
720
|
+
"""
|
|
721
|
+
|
|
722
|
+
logger.info("Will be reversing flood filling")
|
|
723
|
+
logger.info(f"{masking_options=} ")
|
|
724
|
+
|
|
725
|
+
if masking_options.flood_fill_use_mac:
|
|
726
|
+
positive_mask = minimum_absolute_clip(
|
|
727
|
+
image=base_image,
|
|
728
|
+
increase_factor=masking_options.flood_fill_positive_seed_clip,
|
|
729
|
+
box_size=masking_options.flood_fill_use_mac_box_size,
|
|
730
|
+
adaptive_max_depth=masking_options.flood_fill_use_mac_adaptive_max_depth,
|
|
731
|
+
adaptive_box_step=masking_options.flood_fill_use_mac_adaptive_step_factor,
|
|
732
|
+
adaptive_skew_delta=masking_options.flood_fill_use_mac_adaptive_skew_delta,
|
|
733
|
+
)
|
|
734
|
+
flood_floor_mask = minimum_absolute_clip(
|
|
735
|
+
image=base_image,
|
|
736
|
+
increase_factor=masking_options.flood_fill_positive_flood_clip,
|
|
737
|
+
box_size=masking_options.flood_fill_use_mac_box_size,
|
|
738
|
+
adaptive_max_depth=masking_options.flood_fill_use_mac_adaptive_max_depth,
|
|
739
|
+
adaptive_box_step=masking_options.flood_fill_use_mac_adaptive_step_factor,
|
|
740
|
+
adaptive_skew_delta=masking_options.flood_fill_use_mac_adaptive_skew_delta,
|
|
741
|
+
)
|
|
742
|
+
else:
|
|
743
|
+
# Sanity check the upper clip level, you rotten seadog
|
|
744
|
+
positive_seed_clip = _verify_set_positive_seed_clip(
|
|
745
|
+
positive_seed_clip=masking_options.flood_fill_positive_seed_clip,
|
|
746
|
+
signal=base_image,
|
|
747
|
+
)
|
|
748
|
+
# Here we create the mask image that will start the binary dilation
|
|
749
|
+
# process, and we will ensure only pixels above the `positive_flood_clip`
|
|
750
|
+
# are allowed to be dilated. In other words we are growing the mask
|
|
751
|
+
positive_mask = base_image >= positive_seed_clip
|
|
752
|
+
flood_floor_mask = base_image > masking_options.flood_fill_positive_flood_clip
|
|
753
|
+
|
|
754
|
+
positive_dilated_mask: NDArray[np.bool] = scipy_binary_dilation(
|
|
755
|
+
input=positive_mask,
|
|
756
|
+
mask=flood_floor_mask,
|
|
757
|
+
iterations=1000,
|
|
758
|
+
structure=np.ones((3, 3)),
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
if masking_options.grow_low_snr_island:
|
|
762
|
+
low_snr_mask = grow_low_snr_mask(
|
|
763
|
+
signal=base_image,
|
|
764
|
+
grow_low_snr=masking_options.grow_low_snr_island_clip,
|
|
765
|
+
grow_low_island_size=masking_options.grow_low_snr_island_size,
|
|
766
|
+
)
|
|
767
|
+
positive_dilated_mask[low_snr_mask] = True
|
|
768
|
+
|
|
769
|
+
return positive_dilated_mask
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _create_signal_from_rmsbkg(
|
|
773
|
+
image: Path | NDArray[np.floating],
|
|
774
|
+
rms: Path | NDArray[np.floating],
|
|
775
|
+
bkg: Path | NDArray[np.floating],
|
|
776
|
+
) -> NDArray[np.floating]:
|
|
777
|
+
"""Form the signal image from the rms and background"""
|
|
778
|
+
logger.info("Creating signal image")
|
|
779
|
+
|
|
780
|
+
if isinstance(image, Path):
|
|
781
|
+
with fits.open(image) as in_fits:
|
|
782
|
+
logger.info(f"Loading {image}")
|
|
783
|
+
image = in_fits[0].data
|
|
784
|
+
|
|
785
|
+
assert isinstance(image, np.ndarray), (
|
|
786
|
+
f"Expected the image to be a numpy array by now, instead have {type(image)}"
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
if isinstance(bkg, Path):
|
|
790
|
+
with fits.open(bkg) as in_fits:
|
|
791
|
+
logger.info(f"Loading {bkg=}")
|
|
792
|
+
bkg = in_fits[0].data
|
|
793
|
+
|
|
794
|
+
assert isinstance(bkg, np.ndarray), (
|
|
795
|
+
f"Expected the background to be a numpy array by now, instead have {type(bkg)}"
|
|
796
|
+
)
|
|
797
|
+
logger.info("Subtracting background")
|
|
798
|
+
image -= bkg
|
|
799
|
+
|
|
800
|
+
if isinstance(rms, Path):
|
|
801
|
+
with fits.open(rms) as in_fits:
|
|
802
|
+
logger.info(f"Loading {rms=}")
|
|
803
|
+
rms = in_fits[0].data
|
|
804
|
+
|
|
805
|
+
assert isinstance(rms, np.ndarray), (
|
|
806
|
+
f"Expected the rms to be a numpy array by now, instead have {type(rms)}"
|
|
807
|
+
)
|
|
808
|
+
logger.info("Dividing by rms")
|
|
809
|
+
image /= rms
|
|
810
|
+
|
|
811
|
+
return np.array(image)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _need_to_make_signal(masking_options: MaskingOptions) -> bool:
|
|
815
|
+
"""Isolated functions to consider whether a signal image is needed"""
|
|
816
|
+
return not masking_options.flood_fill_use_mac
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
# TODO: Need to rename this function
|
|
820
|
+
# TODO: This should be made into a dispatcher type function
|
|
821
|
+
def create_snr_mask_from_fits(
|
|
822
|
+
fits_image_path: Path,
|
|
823
|
+
masking_options: MaskingOptions,
|
|
824
|
+
fits_rms_path: Path | None,
|
|
825
|
+
fits_bkg_path: Path | None,
|
|
826
|
+
create_signal_fits: bool = False,
|
|
827
|
+
overwrite: bool = True,
|
|
828
|
+
) -> FITSMaskNames:
|
|
829
|
+
"""Create a mask for an input FITS image based on a signal to noise given a corresponding pair of RMS and background FITS images.
|
|
830
|
+
|
|
831
|
+
Internally should a signal image be needed it is computed as something akin to:
|
|
832
|
+
> signal = (image - background) / rms
|
|
833
|
+
|
|
834
|
+
This is done in a staged manner to minimise the number of (potentially large) images
|
|
835
|
+
held in memory.
|
|
836
|
+
|
|
837
|
+
Each of the input images needs to share the same shape. This means that compression
|
|
838
|
+
features offered by some tooling (e.g. BANE --compress) can not be used.
|
|
839
|
+
|
|
840
|
+
Depending on the `MaksingOptions` used the signal image may not be needed.
|
|
841
|
+
|
|
842
|
+
Once the signal map as been computed, all pixels below ``min_snr`` are flagged.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
fits_image_path (Path): Path to the FITS file containing an image
|
|
846
|
+
masking_options (MaskingOptions): Configurable on the masking operation procedure.
|
|
847
|
+
fits_rms_path (Path | None, optional): Path to the FITS file with an RMS image corresponding to ``fits_image_path``. Defaults to None.
|
|
848
|
+
fits_bkg_path (Path | None, optional): Path to the FITS file with an background image corresponding to ``fits_image_path``. Defaults to None.
|
|
849
|
+
create_signal_fits (bool, optional): Create an output signal map. Defaults to False.
|
|
850
|
+
overwrite (bool): Passed to `fits.writeto`, and will overwrite files should they exist. Defaults to True.
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
FITSMaskNames: Container describing the signal and mask FITS image paths. If ``create_signal_path`` is None, then the ``signal_fits`` attribute will be None.
|
|
854
|
+
"""
|
|
855
|
+
# Short cut to the convolve first
|
|
856
|
+
if masking_options.convolve_first:
|
|
857
|
+
logger.warning(
|
|
858
|
+
"Using the convolve then erode algorithm, some options may be ignored"
|
|
859
|
+
)
|
|
860
|
+
return create_convolved_erosion_mask(
|
|
861
|
+
fits_image_path=fits_image_path, masking_options=masking_options
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
mask_names = create_fits_mask_names(
|
|
865
|
+
fits_image=fits_image_path, include_signal_path=create_signal_fits
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
# TODOL Make the bkg and rms images optional. Don't need to load if mac is usede
|
|
869
|
+
with fits.open(fits_image_path) as fits_image:
|
|
870
|
+
fits_header = fits_image[0].header
|
|
871
|
+
signal_data = fits_image[0].data
|
|
872
|
+
|
|
873
|
+
if _need_to_make_signal(masking_options=masking_options):
|
|
874
|
+
assert isinstance(fits_rms_path, Path) and isinstance(fits_bkg_path, Path), ( # noqa: PT018
|
|
875
|
+
"Expected paths for input RMS and bkg FITS files"
|
|
876
|
+
)
|
|
877
|
+
signal_data = _create_signal_from_rmsbkg(
|
|
878
|
+
image=signal_data, rms=fits_rms_path, bkg=fits_bkg_path
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
if create_signal_fits:
|
|
882
|
+
logger.info(f"Writing {mask_names.signal_fits}")
|
|
883
|
+
fits.writeto(
|
|
884
|
+
filename=mask_names.signal_fits,
|
|
885
|
+
data=signal_data,
|
|
886
|
+
header=fits_header,
|
|
887
|
+
overwrite=overwrite,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Following the help in wsclean:
|
|
891
|
+
# WSClean accepts masks in CASA format and in fits file format. A mask is a
|
|
892
|
+
# normal, single polarization image file, where all zero values are interpreted
|
|
893
|
+
# as being not masked, and all non-zero values are interpreted as masked. In the
|
|
894
|
+
# case of a fits file, the file may either contain a single frequency or it may
|
|
895
|
+
# contain a cube of images.
|
|
896
|
+
if masking_options.flood_fill:
|
|
897
|
+
# TODO: The image and signal masks both don't need to be inputs. Image is only used
|
|
898
|
+
# if mac = True
|
|
899
|
+
mask_data = reverse_negative_flood_fill(
|
|
900
|
+
base_image=np.squeeze(signal_data),
|
|
901
|
+
masking_options=masking_options,
|
|
902
|
+
)
|
|
903
|
+
mask_data = mask_data.reshape(signal_data.shape)
|
|
904
|
+
else:
|
|
905
|
+
logger.info(f"Clipping using a {masking_options.base_snr_clip=}")
|
|
906
|
+
mask_data = (signal_data > masking_options.base_snr_clip).astype(float)
|
|
907
|
+
|
|
908
|
+
if masking_options.beam_shape_erode:
|
|
909
|
+
mask_data = beam_shape_erode( # type: ignore[assignment]
|
|
910
|
+
mask=mask_data,
|
|
911
|
+
fits_header=fits_header,
|
|
912
|
+
minimum_response=masking_options.beam_shape_erode_minimum_response,
|
|
913
|
+
scales=masking_options.beam_shape_erode_scales,
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
logger.info(f"Writing {mask_names.mask_fits}")
|
|
917
|
+
fits.writeto(
|
|
918
|
+
filename=mask_names.mask_fits,
|
|
919
|
+
data=mask_data,
|
|
920
|
+
header=fits_header,
|
|
921
|
+
overwrite=overwrite,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
return mask_names
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def convolve_image_by_scale(
|
|
928
|
+
image_data: NDArray[np.floating], scale: int
|
|
929
|
+
) -> NDArray[np.floating]:
|
|
930
|
+
"""Generate a convolved version of the input image based on some convolved scale.
|
|
931
|
+
Note that here the scale refers to the scales used by wsclean. Per the multiscale
|
|
932
|
+
documentation, where considering gaussians:
|
|
933
|
+
|
|
934
|
+
>> fwhm = 0.45 * scale
|
|
935
|
+
|
|
936
|
+
Also not that:
|
|
937
|
+
|
|
938
|
+
>> fwhm = 2.355 * sigma
|
|
939
|
+
|
|
940
|
+
Also note that the initial stage of this function is to first apply an 'open' filter,
|
|
941
|
+
which preprocessed the image by using a set of minimum and maximum operations. This
|
|
942
|
+
attempts to separate out emission at different scales before subsequent convolution.
|
|
943
|
+
This feels counter productive, but is done to avoid confusion type effocts with
|
|
944
|
+
convolving scales.
|
|
945
|
+
|
|
946
|
+
Args:
|
|
947
|
+
image_data (NDArray[np.floating]): The imaged to be convolved
|
|
948
|
+
scale (int): The pixel scale to smooth with
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
NDArray[np.floating]: Smoother version of the input image
|
|
952
|
+
"""
|
|
953
|
+
|
|
954
|
+
logger.info("Computing the open filter image")
|
|
955
|
+
min_image = minimum_filter(image_data, scale, mode="wrap")
|
|
956
|
+
image_data = maximum_filter(min_image, scale, mode="wrap")
|
|
957
|
+
|
|
958
|
+
logger.info(f"Convoling with {scale=}")
|
|
959
|
+
|
|
960
|
+
# wsclean scales are converted to a fwhm as 0.45 * pixel scales. To convert
|
|
961
|
+
# the FWHM to a sigma we divided by 2.355, matttee
|
|
962
|
+
fwhm = 0.45 * scale
|
|
963
|
+
sigma = fwhm / 2.355
|
|
964
|
+
|
|
965
|
+
logger.info(f"Generating gaussian kernel for {scale=} {fwhm=:.3f} {sigma=:.3f}")
|
|
966
|
+
|
|
967
|
+
pix_sigma = int(sigma * 5)
|
|
968
|
+
x = np.linspace(0, pix_sigma, pix_sigma)
|
|
969
|
+
y = np.linspace(0, pix_sigma, pix_sigma)
|
|
970
|
+
|
|
971
|
+
x -= x[-1] / 2
|
|
972
|
+
y -= y[-1] / 2
|
|
973
|
+
|
|
974
|
+
# Form the 2d image grid here, ya sea dog
|
|
975
|
+
distance_squared = x[None, :] ** 2 + y[:, None] ** 2
|
|
976
|
+
kernel = np.exp(-(distance_squared / (2 * sigma**2)))
|
|
977
|
+
|
|
978
|
+
# Make sure the ships match dimensions
|
|
979
|
+
kernel = kernel.reshape(image_data.shape[:-2] + kernel.shape)
|
|
980
|
+
logger.info(f"{image_data.shape=} {kernel.shape=}")
|
|
981
|
+
image_convolved: NDArray[np.floating] = fftconvolve(image_data, kernel, mode="same")
|
|
982
|
+
return image_convolved
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# TODO: This needs some specific unit tests
|
|
986
|
+
def create_convolved_erosion_mask(
|
|
987
|
+
fits_image_path: Path,
|
|
988
|
+
masking_options: MaskingOptions,
|
|
989
|
+
) -> FITSMaskNames:
|
|
990
|
+
"""Create a per-scale clean mask by smoothing the input image to some scale, and then
|
|
991
|
+
applying a reverse flood fill with the minimum absolute clip statistic. After the initial
|
|
992
|
+
mask per scale is constructed a binary erosion is then performed to further refine the mask.
|
|
993
|
+
|
|
994
|
+
Args:
|
|
995
|
+
fits_image_path (Path): The path to the FITS image to load.
|
|
996
|
+
masking_options (MaskingOptions): Options to use throughout the convolve and erosion process.
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
FITSMaskNames: Clean mask written to a FITS file. Should multiple scales be specified they are encoded bitwise per pixel.
|
|
1000
|
+
"""
|
|
1001
|
+
mask_names = create_fits_mask_names(fits_image=fits_image_path)
|
|
1002
|
+
|
|
1003
|
+
logger.info(f"Getting image data from {fits_image_path=}")
|
|
1004
|
+
base_image = fits.getdata(fits_image_path)
|
|
1005
|
+
fits_header = fits.getheader(fits_image_path)
|
|
1006
|
+
|
|
1007
|
+
original_shape = base_image.shape
|
|
1008
|
+
|
|
1009
|
+
base_image = np.squeeze(base_image)
|
|
1010
|
+
assert len(base_image.shape) == 2, (
|
|
1011
|
+
f"The shape of data is {original_shape}, and can not be converted to a two-dimensional image"
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
scales = (
|
|
1015
|
+
[0]
|
|
1016
|
+
if masking_options.beam_shape_erode_scales is None
|
|
1017
|
+
else masking_options.beam_shape_erode_scales
|
|
1018
|
+
)
|
|
1019
|
+
logger.info(f"Will be processing {scales=}")
|
|
1020
|
+
|
|
1021
|
+
output_mask = np.zeros_like(base_image)
|
|
1022
|
+
|
|
1023
|
+
for index, scale in enumerate(scales):
|
|
1024
|
+
convolved_image = base_image.copy()
|
|
1025
|
+
logger.info(f"Processing {scale=}")
|
|
1026
|
+
|
|
1027
|
+
# Image at native scale does not need to be convolved
|
|
1028
|
+
if scale > 0:
|
|
1029
|
+
convolved_image = convolve_image_by_scale(
|
|
1030
|
+
image_data=convolved_image, scale=scale
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
box_size = masking_options.flood_fill_use_mac_box_size + scale
|
|
1034
|
+
|
|
1035
|
+
positive_mask = minimum_absolute_clip(
|
|
1036
|
+
image=convolved_image,
|
|
1037
|
+
increase_factor=masking_options.flood_fill_positive_seed_clip,
|
|
1038
|
+
box_size=box_size,
|
|
1039
|
+
adaptive_max_depth=masking_options.flood_fill_use_mac_adaptive_max_depth,
|
|
1040
|
+
adaptive_box_step=masking_options.flood_fill_use_mac_adaptive_step_factor,
|
|
1041
|
+
adaptive_skew_delta=masking_options.flood_fill_use_mac_adaptive_skew_delta,
|
|
1042
|
+
)
|
|
1043
|
+
flood_fill_positive_flood_clip = masking_options.flood_fill_positive_flood_clip
|
|
1044
|
+
if scale == 0 and flood_fill_positive_flood_clip < 0.8:
|
|
1045
|
+
logger.warning(
|
|
1046
|
+
f"{flood_fill_positive_flood_clip=}, too low for {scale=}. Setting to 0.8"
|
|
1047
|
+
)
|
|
1048
|
+
flood_fill_positive_flood_clip = 0.8
|
|
1049
|
+
|
|
1050
|
+
flood_floor_mask = minimum_absolute_clip(
|
|
1051
|
+
image=convolved_image,
|
|
1052
|
+
increase_factor=flood_fill_positive_flood_clip,
|
|
1053
|
+
box_size=box_size,
|
|
1054
|
+
adaptive_max_depth=masking_options.flood_fill_use_mac_adaptive_max_depth,
|
|
1055
|
+
adaptive_box_step=masking_options.flood_fill_use_mac_adaptive_step_factor,
|
|
1056
|
+
adaptive_skew_delta=masking_options.flood_fill_use_mac_adaptive_skew_delta,
|
|
1057
|
+
)
|
|
1058
|
+
flood_floor_mask = binary_fill_holes(flood_floor_mask)
|
|
1059
|
+
|
|
1060
|
+
# Ensure positive only pixels. useful (necessary?) at larger scales bridging
|
|
1061
|
+
# across negative pixels
|
|
1062
|
+
positive_dilated_mask = scipy_binary_dilation(
|
|
1063
|
+
input=positive_mask,
|
|
1064
|
+
mask=flood_floor_mask,
|
|
1065
|
+
iterations=1000,
|
|
1066
|
+
structure=np.ones((3, 3)),
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
scale_mask = (
|
|
1070
|
+
create_multi_scale_erosion(
|
|
1071
|
+
mask=positive_dilated_mask,
|
|
1072
|
+
fits_header=fits_header,
|
|
1073
|
+
scale=scale,
|
|
1074
|
+
minimum_response=masking_options.beam_shape_erode_minimum_response,
|
|
1075
|
+
)
|
|
1076
|
+
== 1
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
output_mask[scale_mask] += 2**index
|
|
1080
|
+
|
|
1081
|
+
logger.info(f"Writing {mask_names.scale_mask_fits}")
|
|
1082
|
+
fits.writeto(
|
|
1083
|
+
mask_names.scale_mask_fits,
|
|
1084
|
+
header=fits_header,
|
|
1085
|
+
data=output_mask.reshape(original_shape),
|
|
1086
|
+
overwrite=True,
|
|
1087
|
+
)
|
|
1088
|
+
return mask_names
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def get_parser() -> ArgumentParser:
|
|
1092
|
+
parser = ArgumentParser(
|
|
1093
|
+
description="Simple utility functions to create masks from FITS images. "
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
subparser = parser.add_subparsers(
|
|
1097
|
+
dest="mode", help="Operation mode of flint_bandpass"
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
fits_parser = subparser.add_parser(
|
|
1101
|
+
"mask",
|
|
1102
|
+
help="Create a mask for an image, potentially using its RMS and BKG images (e.g. outputs from BANE). Output FITS image will default to the image with a mask suffix.",
|
|
1103
|
+
)
|
|
1104
|
+
fits_parser.add_argument("image", type=Path, help="Path to the input image. ")
|
|
1105
|
+
fits_parser = add_options_to_parser(
|
|
1106
|
+
parser=fits_parser, options_class=MaskingOptions
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
fits_parser.add_argument(
|
|
1110
|
+
"--rms-fits", type=Path, help="Path to the RMS of the input image. "
|
|
1111
|
+
)
|
|
1112
|
+
fits_parser.add_argument(
|
|
1113
|
+
"--bkg-fits", type=Path, help="Path to the BKG of the input image. "
|
|
1114
|
+
)
|
|
1115
|
+
fits_parser.add_argument(
|
|
1116
|
+
"--save-signal",
|
|
1117
|
+
action="store_true",
|
|
1118
|
+
help="Save the signal image internally generated (should it be generated)",
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
extract_parser = subparser.add_parser(
|
|
1122
|
+
"extractmask",
|
|
1123
|
+
help="Extract a beam FITS masked region from a larger FITS mask mosaic image. ",
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
extract_parser.add_argument(
|
|
1127
|
+
"beam_image",
|
|
1128
|
+
type=Path,
|
|
1129
|
+
help="The FITS image with the WCS that will be used to extract the mask region from",
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
extract_parser.add_argument(
|
|
1133
|
+
"mosaic_image",
|
|
1134
|
+
type=Path,
|
|
1135
|
+
help="The field mosaic image that will be used as a basis to extract a region mask from",
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
return parser
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def cli() -> None:
|
|
1142
|
+
parser = get_parser()
|
|
1143
|
+
|
|
1144
|
+
args = parser.parse_args()
|
|
1145
|
+
|
|
1146
|
+
if args.mode == "mask":
|
|
1147
|
+
masking_options = create_options_from_parser(
|
|
1148
|
+
parser_namespace=args, options_class=MaskingOptions
|
|
1149
|
+
)
|
|
1150
|
+
if masking_options.convolve_first:
|
|
1151
|
+
create_convolved_erosion_mask(
|
|
1152
|
+
fits_image_path=args.image, masking_options=masking_options
|
|
1153
|
+
)
|
|
1154
|
+
else:
|
|
1155
|
+
create_snr_mask_from_fits(
|
|
1156
|
+
fits_image_path=args.image,
|
|
1157
|
+
fits_rms_path=args.rms_fits,
|
|
1158
|
+
fits_bkg_path=args.bkg_fits,
|
|
1159
|
+
create_signal_fits=args.save_signal,
|
|
1160
|
+
masking_options=masking_options,
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
elif args.mode == "extractmask":
|
|
1164
|
+
extract_beam_mask_from_mosaic(
|
|
1165
|
+
fits_beam_image_path=args.beam_image,
|
|
1166
|
+
fits_mosaic_mask_names=FITSMaskNames(mask_fits=args.mosaic_image),
|
|
1167
|
+
)
|
|
1168
|
+
else:
|
|
1169
|
+
logger.error(f"Supplied mode {args.mode} is not known. ")
|
|
1170
|
+
parser.print_help()
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
if __name__ == "__main__":
|
|
1174
|
+
cli()
|