convolve-uv 0.1.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.
convolve_uv/__init__.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
import astropy.units as u
|
|
4
|
+
import numpy as np
|
|
5
|
+
from astropy.convolution import convolve_fft, interpolate_replace_nans
|
|
6
|
+
from astropy.utils.console import ProgressBar
|
|
7
|
+
from astropy.wcs.utils import proj_plane_pixel_scales
|
|
8
|
+
from radio_beam import Beam
|
|
9
|
+
from radio_beam.utils import BeamError
|
|
10
|
+
from spectral_cube import Projection, SpectralCube, VaryingResolutionSpectralCube
|
|
11
|
+
|
|
12
|
+
FWHM_TO_SIGMA = 1.0 / np.sqrt(8.0 * np.log(2.0))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def beam_covariance_en(
|
|
16
|
+
beam: Beam,
|
|
17
|
+
) -> np.ndarray:
|
|
18
|
+
"""Gaussian covariance in (east, north), in square degrees.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
beam (Beam): The beam to compute the covariance for.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
np.ndarray: The 2x2 covariance matrix of the beam in (east, north) coordinates.
|
|
25
|
+
"""
|
|
26
|
+
smaj = beam.major.to_value(u.deg) * FWHM_TO_SIGMA
|
|
27
|
+
smin = beam.minor.to_value(u.deg) * FWHM_TO_SIGMA
|
|
28
|
+
angle = beam.pa.to_value(u.rad)
|
|
29
|
+
major_hat = np.array([np.sin(angle), np.cos(angle)])
|
|
30
|
+
minor_hat = np.array([np.cos(angle), -np.sin(angle)])
|
|
31
|
+
|
|
32
|
+
cov = smaj**2 * np.outer(major_hat, major_hat) + smin**2 * np.outer(minor_hat, minor_hat)
|
|
33
|
+
|
|
34
|
+
return cov
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def kernel_covariance_pixels(
|
|
38
|
+
cube_slice: Projection,
|
|
39
|
+
target_beam: Beam,
|
|
40
|
+
) -> np.ndarray:
|
|
41
|
+
"""Return target-minus-input covariance in pixel (x, y) coordinates.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cube_slice (Projection): 2D projection of a full 3D SpectralCube.
|
|
45
|
+
target_beam (Beam): The desired circular beam to convolve to.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
np.ndarray: The 2x2 covariance matrix of the kernel in pixel coordinates.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
target = beam_covariance_en(target_beam)
|
|
52
|
+
source = beam_covariance_en(cube_slice.beam)
|
|
53
|
+
kernel_sky = target - source
|
|
54
|
+
|
|
55
|
+
eigenvalues, eigenvectors = np.linalg.eigh(kernel_sky)
|
|
56
|
+
eigenvalues = np.maximum(eigenvalues, 0.0)
|
|
57
|
+
kernel_sky = (eigenvectors * eigenvalues) @ eigenvectors.T
|
|
58
|
+
|
|
59
|
+
# pixel_scale_matrix maps (dx, dy) pixels to local projected (east, north)
|
|
60
|
+
# degrees. This includes rotation and unequal pixel scales.
|
|
61
|
+
jacobian = np.asarray(cube_slice.wcs.celestial.pixel_scale_matrix, dtype=float)
|
|
62
|
+
if jacobian.shape != (2, 2) or abs(np.linalg.det(jacobian)) < 1e-20:
|
|
63
|
+
raise ValueError("The celestial WCS has a singular pixel-scale matrix")
|
|
64
|
+
sky_to_pix = np.linalg.inv(jacobian)
|
|
65
|
+
|
|
66
|
+
cov = sky_to_pix @ kernel_sky @ sky_to_pix.T
|
|
67
|
+
|
|
68
|
+
return cov
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def transfer_function(
|
|
72
|
+
shape_yx: tuple[int, int],
|
|
73
|
+
covariance_xy: np.ndarray,
|
|
74
|
+
) -> np.ndarray:
|
|
75
|
+
"""Analytic Fourier transform of a unit-integral Gaussian.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
shape_yx (tuple[int, int]): The shape of the 2D array in (y, x) order.
|
|
79
|
+
covariance_xy (np.ndarray): The 2x2 covariance matrix of the Gaussian in pixel coordinates.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
np.ndarray: The transfer function in Fourier space.
|
|
83
|
+
"""
|
|
84
|
+
ny, nx = shape_yx
|
|
85
|
+
fx = np.fft.rfftfreq(nx)
|
|
86
|
+
fy = np.fft.fftfreq(ny)
|
|
87
|
+
|
|
88
|
+
# k is cycles/pixel and covariance is in pixel^2.
|
|
89
|
+
exponent = (
|
|
90
|
+
-2.0
|
|
91
|
+
* np.pi**2
|
|
92
|
+
* (
|
|
93
|
+
covariance_xy[0, 0] * fx[None, :] ** 2
|
|
94
|
+
+ 2.0 * covariance_xy[0, 1] * fy[:, None] * fx[None, :]
|
|
95
|
+
+ covariance_xy[1, 1] * fy[:, None] ** 2
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
t_func = np.exp(exponent)
|
|
99
|
+
|
|
100
|
+
return t_func
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fft_filter(
|
|
104
|
+
data: np.ndarray,
|
|
105
|
+
transfer: np.ndarray,
|
|
106
|
+
) -> np.ndarray:
|
|
107
|
+
"""Simple wrapper around the FFT-based filtering of a 2D array with a transfer function.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
data (np.ndarray): The 2D array to be filtered.
|
|
111
|
+
transfer (np.ndarray): The transfer function in Fourier space.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
np.ndarray: The filtered 2D array.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
data_fft_filtered = np.fft.irfft2(
|
|
118
|
+
np.fft.rfft2(data) * transfer,
|
|
119
|
+
s=data.shape,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return data_fft_filtered
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def do_convolution(
|
|
126
|
+
image_slice: Projection,
|
|
127
|
+
target_beam: Beam,
|
|
128
|
+
boundary: str = "fill",
|
|
129
|
+
fill_value: float | int = 0.0,
|
|
130
|
+
pad_sigma: float = 8.0,
|
|
131
|
+
nan_treatment: str = "interpolate",
|
|
132
|
+
preserve_nan: bool = False,
|
|
133
|
+
) -> np.ndarray:
|
|
134
|
+
"""Perform the actual convolution
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
image_slice (Projection | SpectralCube): Either a full SpectralCube instance,
|
|
138
|
+
or a projection of a full 3D SpectralCube. If a full SpectralCube, then the cube should only
|
|
139
|
+
have two dimensions
|
|
140
|
+
target_beam (Beam): The desired circular beam to convolve to.
|
|
141
|
+
boundary (str, optional): ``"wrap"`` gives the exact periodic DFT solution. ``"fill"`` pads by
|
|
142
|
+
``fill_value`` for ``pad_sigma`` kernel sigmas before transforming to reduce wrapping.
|
|
143
|
+
fill_value (float|int, optional): The value to use outside the array when using boundary=``fill``.
|
|
144
|
+
Defaults to 0.0
|
|
145
|
+
pad_sigma (float, optional): Number of kernel sigmas to pad when using "pad" boundary. Defaults to 8.0.
|
|
146
|
+
nan_treatment (str, optional): The method used to handle NaNs in the input slice:
|
|
147
|
+
* 'interpolate': ``NaN`` values are replaced with interpolated
|
|
148
|
+
values using the kernel as an interpolation function. Note that
|
|
149
|
+
if the kernel has a sum equal to zero, NaN interpolation is not
|
|
150
|
+
possible and will raise an exception.
|
|
151
|
+
* 'fill': ``NaN`` values are replaced by ``fill_value`` prior to
|
|
152
|
+
convolution.
|
|
153
|
+
Defaults to "interpolate".
|
|
154
|
+
preserve_nan (bool, optional): After performing convolution, should pixels that were originally NaN again
|
|
155
|
+
become NaN? Defaults to False.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
np.ndarray: The convolved image_slice
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
# Check beams are as we expect
|
|
162
|
+
try:
|
|
163
|
+
beam = image_slice.beam
|
|
164
|
+
except AttributeError:
|
|
165
|
+
raise AttributeError("image_slice must have a valid beam")
|
|
166
|
+
|
|
167
|
+
if not isinstance(beam, Beam):
|
|
168
|
+
raise TypeError("Input beam must be a Beam object")
|
|
169
|
+
|
|
170
|
+
# If the beams are identical, we just return the data
|
|
171
|
+
if beam == target_beam:
|
|
172
|
+
return image_slice
|
|
173
|
+
|
|
174
|
+
# Check the beams can be deconvolved
|
|
175
|
+
try:
|
|
176
|
+
kernel = target_beam.deconvolve(image_slice.beam)
|
|
177
|
+
except BeamError:
|
|
178
|
+
raise ValueError("The target beam is smaller than the input beam, so cannot be deconvolved")
|
|
179
|
+
|
|
180
|
+
# Pull out the pixel scale, convert the kernel to an array
|
|
181
|
+
pix_scale = proj_plane_pixel_scales(image_slice.wcs.celestial)[0] * u.deg
|
|
182
|
+
kernel = kernel.as_kernel(pixscale=pix_scale).array
|
|
183
|
+
|
|
184
|
+
covariance = kernel_covariance_pixels(image_slice, target_beam)
|
|
185
|
+
data = image_slice.unitless_filled_data[:]
|
|
186
|
+
|
|
187
|
+
# Keep track of NaNs, in case we need to put them back in later
|
|
188
|
+
nan_mask = np.isnan(data)
|
|
189
|
+
|
|
190
|
+
# If we're filling NaNs, then do that here
|
|
191
|
+
if nan_treatment == "fill":
|
|
192
|
+
data = np.where(np.isfinite(data), data, fill_value)
|
|
193
|
+
elif nan_treatment == "interpolate":
|
|
194
|
+
data = interpolate_replace_nans(
|
|
195
|
+
data,
|
|
196
|
+
kernel,
|
|
197
|
+
convolve=convolve_fft,
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
raise ValueError("nan_treatment must be 'interpolate' or 'fill'")
|
|
201
|
+
|
|
202
|
+
# Keep track of where pixels are valid
|
|
203
|
+
valid = np.isfinite(data)
|
|
204
|
+
|
|
205
|
+
pad_y = pad_x = 0
|
|
206
|
+
if boundary == "fill":
|
|
207
|
+
# Marginal standard deviations give a conservative axis-wise pad.
|
|
208
|
+
pad_x = int(np.ceil(pad_sigma * np.sqrt(covariance[0, 0])))
|
|
209
|
+
pad_y = int(np.ceil(pad_sigma * np.sqrt(covariance[1, 1])))
|
|
210
|
+
pad_width = [(0, 0)] * (data.ndim - 2) + [(pad_y, pad_y), (pad_x, pad_x)]
|
|
211
|
+
data = np.pad(data, pad_width, mode="constant", constant_values=fill_value)
|
|
212
|
+
valid = np.pad(valid, pad_width, mode="constant", constant_values=False)
|
|
213
|
+
|
|
214
|
+
transfer = transfer_function(data.shape, covariance)
|
|
215
|
+
numerator = fft_filter(np.where(valid, data, 0.0), transfer)
|
|
216
|
+
|
|
217
|
+
# Account for NaNs. This is essentially to get around numerical issues
|
|
218
|
+
denominator = fft_filter(valid.astype(float), transfer)
|
|
219
|
+
scale = max(float(transfer.mean()), 1e-14)
|
|
220
|
+
cube_slice_conv = np.divide(
|
|
221
|
+
numerator,
|
|
222
|
+
denominator,
|
|
223
|
+
out=np.full_like(numerator, np.nan),
|
|
224
|
+
where=denominator > 1e-12 * scale,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# If we've padded, then back out this pad
|
|
228
|
+
if boundary == "fill":
|
|
229
|
+
ys = slice(pad_y, -pad_y or None)
|
|
230
|
+
xs = slice(pad_x, -pad_x or None)
|
|
231
|
+
cube_slice_conv = cube_slice_conv[ys, xs]
|
|
232
|
+
|
|
233
|
+
# If we're preserving NaNs, then put them back in
|
|
234
|
+
if preserve_nan:
|
|
235
|
+
cube_slice_conv[nan_mask] = np.nan
|
|
236
|
+
|
|
237
|
+
# If we're Jy/beam-like, account for that here
|
|
238
|
+
if image_slice.unit.is_equivalent(u.Jy / u.beam):
|
|
239
|
+
beam_ratio_factor = (target_beam.sr / image_slice.beam.sr).value
|
|
240
|
+
else:
|
|
241
|
+
beam_ratio_factor = 1.0
|
|
242
|
+
cube_slice_conv *= beam_ratio_factor
|
|
243
|
+
|
|
244
|
+
# If the dtype has changed, then revert here
|
|
245
|
+
if cube_slice_conv.dtype != image_slice.dtype:
|
|
246
|
+
cube_slice_conv = cube_slice_conv.astype(image_slice.dtype)
|
|
247
|
+
|
|
248
|
+
return cube_slice_conv
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def convolve_uv(
|
|
252
|
+
image: Projection | SpectralCube | VaryingResolutionSpectralCube,
|
|
253
|
+
target_beam: Beam,
|
|
254
|
+
boundary: str = "fill",
|
|
255
|
+
fill_value: float | int = 0.0,
|
|
256
|
+
pad_sigma: float = 8.0,
|
|
257
|
+
nan_treatment: str = "interpolate",
|
|
258
|
+
preserve_nan: bool = False,
|
|
259
|
+
) -> Projection | SpectralCube:
|
|
260
|
+
"""Convolve a 2D projection to a round Gaussian beam exactly in uv space.
|
|
261
|
+
|
|
262
|
+
The spatial Gaussian transfer function is evaluated analytically on the DFT
|
|
263
|
+
grid. No image-plane convolution kernel is sampled, so sub-pixel kernels are
|
|
264
|
+
handled without the discretisation problem in ``astropy.convolution``.
|
|
265
|
+
|
|
266
|
+
Note this will also check if the slice units are Jy/beam like, and account for that here
|
|
267
|
+
so no extra renormalisation is required
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
image (Projection | SpectralCube | VaryingResolutionSpectralCube): Either a full SpectralCube instance,
|
|
271
|
+
or a projection of a full 3D SpectralCube.
|
|
272
|
+
target_beam (Beam): The desired circular beam to convolve to.
|
|
273
|
+
boundary (str, optional): ``"wrap"`` gives the exact periodic DFT solution. ``"fill"`` pads by
|
|
274
|
+
``fill_value`` for ``pad_sigma`` kernel sigmas before transforming to reduce wrapping.
|
|
275
|
+
fill_value (float|int, optional): The value to use outside the array when using boundary=``fill``.
|
|
276
|
+
Defaults to 0.0
|
|
277
|
+
pad_sigma (float, optional): Number of kernel sigmas to pad when using "pad" boundary. Defaults to 8.0.
|
|
278
|
+
nan_treatment (str, optional): The method used to handle NaNs in the input slice:
|
|
279
|
+
* 'interpolate': ``NaN`` values are replaced with interpolated
|
|
280
|
+
values using the kernel as an interpolation function. Note that
|
|
281
|
+
if the kernel has a sum equal to zero, NaN interpolation is not
|
|
282
|
+
possible and will raise an exception.
|
|
283
|
+
* 'fill': ``NaN`` values are replaced by ``fill_value`` prior to
|
|
284
|
+
convolution.
|
|
285
|
+
Defaults to "interpolate".
|
|
286
|
+
preserve_nan (bool, optional): After performing convolution, should pixels that were originally NaN again
|
|
287
|
+
become NaN? Defaults to False.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Projection | SpectralCube: The convolved Projection or SpectralCube
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
# We need to keep everything in memory
|
|
294
|
+
image.allow_huge_operations = True
|
|
295
|
+
|
|
296
|
+
if boundary not in ["fill", "wrap"]:
|
|
297
|
+
raise ValueError("boundary must be 'fill' or 'wrap'")
|
|
298
|
+
|
|
299
|
+
if nan_treatment not in ["interpolate", "fill"]:
|
|
300
|
+
raise ValueError("nan_treatment must be 'interpolate' or 'fill'")
|
|
301
|
+
|
|
302
|
+
# If we're a cube, then we need to loop over each plane
|
|
303
|
+
if not isinstance(image, Projection):
|
|
304
|
+
n_chan = image.shape[0]
|
|
305
|
+
|
|
306
|
+
data_conv = np.zeros(image.shape, dtype=image.unmasked_data[0, 0, 0].dtype)
|
|
307
|
+
|
|
308
|
+
# To avoid adding in unnecessary slice info to the header,
|
|
309
|
+
# take a copy of the cube
|
|
310
|
+
image_copy = copy.deepcopy(image)
|
|
311
|
+
|
|
312
|
+
with ProgressBar(n_chan) as bar:
|
|
313
|
+
for chan in range(n_chan):
|
|
314
|
+
data_conv[chan] = do_convolution(
|
|
315
|
+
image_copy[chan],
|
|
316
|
+
target_beam=target_beam,
|
|
317
|
+
boundary=boundary,
|
|
318
|
+
fill_value=fill_value,
|
|
319
|
+
pad_sigma=pad_sigma,
|
|
320
|
+
nan_treatment=nan_treatment,
|
|
321
|
+
preserve_nan=preserve_nan,
|
|
322
|
+
)
|
|
323
|
+
bar.update()
|
|
324
|
+
|
|
325
|
+
# If we're a VaryingResolutionSpectralCube, then we need to return a SpectralCube with the new beam
|
|
326
|
+
if isinstance(image, VaryingResolutionSpectralCube):
|
|
327
|
+
image_conv = SpectralCube(
|
|
328
|
+
data=data_conv,
|
|
329
|
+
wcs=image.wcs,
|
|
330
|
+
mask=image.mask,
|
|
331
|
+
meta=image.meta,
|
|
332
|
+
fill_value=image.fill_value,
|
|
333
|
+
header=image.header,
|
|
334
|
+
beam=target_beam,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
else:
|
|
338
|
+
image_conv = image._new_cube_with(data=data_conv, beam=target_beam)
|
|
339
|
+
|
|
340
|
+
else:
|
|
341
|
+
slice_conv = do_convolution(
|
|
342
|
+
image,
|
|
343
|
+
target_beam=target_beam,
|
|
344
|
+
boundary=boundary,
|
|
345
|
+
fill_value=fill_value,
|
|
346
|
+
pad_sigma=pad_sigma,
|
|
347
|
+
nan_treatment=nan_treatment,
|
|
348
|
+
preserve_nan=preserve_nan,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
image_conv = image._new_projection_with(data=slice_conv, beam=target_beam)
|
|
352
|
+
|
|
353
|
+
# Since we've convolved to a beam, if there's still references to multibeam tables,
|
|
354
|
+
# remove that
|
|
355
|
+
if "CASAMBM" in image_conv.header:
|
|
356
|
+
del image_conv._header["CASAMBM"]
|
|
357
|
+
|
|
358
|
+
return image_conv
|