visualastro 0.0.2__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.
- visualastro/__init__.py +15 -0
- visualastro/data_cube.py +494 -0
- visualastro/data_cube_utils.py +127 -0
- visualastro/io.py +251 -0
- visualastro/numerical_utils.py +285 -0
- visualastro/photometry.py +40 -0
- visualastro/plot_utils.py +804 -0
- visualastro/plotting.py +797 -0
- visualastro/reduction.py +136 -0
- visualastro/spectra.py +817 -0
- visualastro/spectra_utils.py +294 -0
- visualastro/stylelib/astro.mplstyle +815 -0
- visualastro/stylelib/default.mplstyle +815 -0
- visualastro/stylelib/latex.mplstyle +815 -0
- visualastro/stylelib/minimal.mplstyle +817 -0
- visualastro/visual_classes.py +282 -0
- visualastro/visual_plots.py +1047 -0
- visualastro-0.0.2.dist-info/LICENSE +21 -0
- visualastro-0.0.2.dist-info/METADATA +35 -0
- visualastro-0.0.2.dist-info/RECORD +22 -0
- visualastro-0.0.2.dist-info/WHEEL +5 -0
- visualastro-0.0.2.dist-info/top_level.txt +1 -0
visualastro/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Core Classes
|
|
2
|
+
from .visual_classes import DataCube, ExtractedSpectrum, FitsFile
|
|
3
|
+
|
|
4
|
+
# Submodules
|
|
5
|
+
from .data_cube import *
|
|
6
|
+
from .data_cube_utils import *
|
|
7
|
+
from .io import *
|
|
8
|
+
from .numerical_utils import *
|
|
9
|
+
from .photometry import *
|
|
10
|
+
from .plotting import *
|
|
11
|
+
from .plot_utils import *
|
|
12
|
+
from .reduction import *
|
|
13
|
+
from .spectra import *
|
|
14
|
+
from .spectra_utils import *
|
|
15
|
+
from .visual_plots import *
|
visualastro/data_cube.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import warnings
|
|
3
|
+
from astropy.io import fits
|
|
4
|
+
import astropy.units as u
|
|
5
|
+
from astropy.utils.exceptions import AstropyWarning
|
|
6
|
+
from matplotlib.patches import Ellipse
|
|
7
|
+
import numpy as np
|
|
8
|
+
from regions import PixCoord, EllipsePixelRegion, EllipseAnnulusPixelRegion
|
|
9
|
+
from spectral_cube import SpectralCube
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
from .data_cube_utils import compute_line, get_spectral_slice_value, slice_cube
|
|
12
|
+
from .io import get_dtype, get_errors
|
|
13
|
+
from .numerical_utils import (
|
|
14
|
+
check_units_consistency, convert_units,
|
|
15
|
+
get_data, shift_by_radial_vel
|
|
16
|
+
)
|
|
17
|
+
from .plot_utils import (
|
|
18
|
+
add_colorbar, plot_ellipses, plot_interactive_ellipse,
|
|
19
|
+
return_imshow_norm, set_unit_labels, set_vmin_vmax,
|
|
20
|
+
)
|
|
21
|
+
from .visual_classes import DataCube, FitsFile
|
|
22
|
+
|
|
23
|
+
warnings.filterwarnings('ignore', category=AstropyWarning)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_data_cube(filepath, error=True, hdu=0, dtype=None,
|
|
27
|
+
print_info=True, transpose=False):
|
|
28
|
+
'''
|
|
29
|
+
Load a sequence of FITS files into a 3D data cube.
|
|
30
|
+
This function searches for all FITS files matching a given path pattern,
|
|
31
|
+
loads them into a NumPy array of shape (T, M, N), and bundles the data
|
|
32
|
+
and headers into a `DataCube` object.
|
|
33
|
+
Parameters
|
|
34
|
+
––––––––––
|
|
35
|
+
filepath : str
|
|
36
|
+
Path pattern to FITS files. Wildcards are supported.
|
|
37
|
+
Example: 'Spectro-Module/raw/HARPS*.fits'
|
|
38
|
+
dtype : numpy.dtype, optional, default=None
|
|
39
|
+
Data type for the loaded FITS data. If None, will use
|
|
40
|
+
the dtype of the provided data, promoting integer or
|
|
41
|
+
unsigned to `np.float64`.
|
|
42
|
+
print_info : bool, optional, default=True
|
|
43
|
+
If True, print summary information about the loaded cube.
|
|
44
|
+
transpose : bool, optional, default=False
|
|
45
|
+
If True, transpose each 2D image before stacking into the cube.
|
|
46
|
+
Returns
|
|
47
|
+
–––––––
|
|
48
|
+
cube : DataCube
|
|
49
|
+
A DataCube object containing:
|
|
50
|
+
- 'cube.data' : np.ndarray of shape (T, M, N)
|
|
51
|
+
- 'cube.headers' : list of astropy.io.fits.Header objects
|
|
52
|
+
Example
|
|
53
|
+
–––––––
|
|
54
|
+
Search for all fits files starting with 'HARPS' with .fits extention and load them.
|
|
55
|
+
filepath = 'Spectro-Module/raw/HARPS.*.fits'
|
|
56
|
+
'''
|
|
57
|
+
# searches for all files within a directory
|
|
58
|
+
fits_files = sorted(glob.glob(filepath))
|
|
59
|
+
# allocate ixMxN data cube array and header array
|
|
60
|
+
n_files = len(fits_files)
|
|
61
|
+
|
|
62
|
+
# load first file to determine shape, dtype, and check for errors
|
|
63
|
+
with fits.open(fits_files[0]) as hdul:
|
|
64
|
+
if print_info:
|
|
65
|
+
hdul.info()
|
|
66
|
+
|
|
67
|
+
data = hdul[hdu].data
|
|
68
|
+
header = hdul[hdu].header
|
|
69
|
+
err = get_errors(hdul, dtype)
|
|
70
|
+
|
|
71
|
+
dt = get_dtype(data, dtype)
|
|
72
|
+
|
|
73
|
+
if transpose:
|
|
74
|
+
data = data.T
|
|
75
|
+
if err is not None:
|
|
76
|
+
err = err.T
|
|
77
|
+
|
|
78
|
+
# Preallocate data cube and headers
|
|
79
|
+
datacube = np.zeros((n_files, data.shape[0], data.shape[1]), dtype=dt)
|
|
80
|
+
datacube[0] = data.astype(dt)
|
|
81
|
+
headers = [None] * n_files
|
|
82
|
+
headers[0] = header
|
|
83
|
+
# preallocate error array if needed and error exists
|
|
84
|
+
error_array = None
|
|
85
|
+
if error and err is not None:
|
|
86
|
+
error_array = np.zeros_like(datacube, dtype=dt)
|
|
87
|
+
error_array[0] = err.astype(dt)
|
|
88
|
+
|
|
89
|
+
# loop through remaining files
|
|
90
|
+
for i, file in enumerate(tqdm(fits_files[1:], desc="Loading FITS")):
|
|
91
|
+
with fits.open(file) as hdul:
|
|
92
|
+
data = hdul[hdu].data
|
|
93
|
+
headers[i+1] = hdul[hdu].header
|
|
94
|
+
err = get_errors(hdul, dt)
|
|
95
|
+
if transpose:
|
|
96
|
+
data = data.T
|
|
97
|
+
if err is not None:
|
|
98
|
+
err = err.T
|
|
99
|
+
datacube[i+1] = data.astype(dt)
|
|
100
|
+
if error_array is not None and err is not None:
|
|
101
|
+
error_array[i+1] = err.astype(dt)
|
|
102
|
+
|
|
103
|
+
return DataCube(datacube, headers, error_array)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_spectral_cube(filepath, hdu, error=True, header=True, dtype=None, print_info=False):
|
|
107
|
+
'''
|
|
108
|
+
Load a spectral cube from a FITS file, optionally including errors and header.
|
|
109
|
+
Parameters
|
|
110
|
+
––––––––––
|
|
111
|
+
filepath : str
|
|
112
|
+
Path to the FITS file to read.
|
|
113
|
+
hdu : int or str
|
|
114
|
+
HDU index or name to read from the FITS file.
|
|
115
|
+
error : bool, optional, default=True
|
|
116
|
+
If True, load the associated error array using `get_errors`.
|
|
117
|
+
header : bool, optional, default=True
|
|
118
|
+
If True, load the HDU header.
|
|
119
|
+
dtype : data-type, optional, default=None
|
|
120
|
+
Desired NumPy dtype for the error array. If None, inferred
|
|
121
|
+
from FITS data, promoting integer and unsigned to `np.float64`.
|
|
122
|
+
print_info : bool, optional, default=False
|
|
123
|
+
If True, print FITS file info to the console.
|
|
124
|
+
Returns
|
|
125
|
+
–––––––
|
|
126
|
+
DataCube
|
|
127
|
+
A `DataCube` object containing:
|
|
128
|
+
- data : SpectralCube
|
|
129
|
+
Fits file data loaded as SpectralCube object.
|
|
130
|
+
- header : astropy.io.fits.Header
|
|
131
|
+
Fits file header.
|
|
132
|
+
- error : np.ndarray
|
|
133
|
+
Fits file error array.
|
|
134
|
+
- value : np.ndarray
|
|
135
|
+
Fits file data as np.ndarray.
|
|
136
|
+
Ex:
|
|
137
|
+
data = cube.data
|
|
138
|
+
'''
|
|
139
|
+
# load SpectralCube from filepath
|
|
140
|
+
spectral_cube = SpectralCube.read(filepath, hdu=hdu)
|
|
141
|
+
# initialize error and header objects
|
|
142
|
+
error_array = None
|
|
143
|
+
hdr = None
|
|
144
|
+
# open fits file
|
|
145
|
+
with fits.open(filepath) as hdul:
|
|
146
|
+
# print fits info
|
|
147
|
+
if print_info:
|
|
148
|
+
hdul.info()
|
|
149
|
+
# load error array
|
|
150
|
+
if error:
|
|
151
|
+
error_array = get_errors(hdul, dtype)
|
|
152
|
+
# load header
|
|
153
|
+
if header:
|
|
154
|
+
hdr = hdul[hdu].header
|
|
155
|
+
|
|
156
|
+
return DataCube(spectral_cube, headers=hdr, errors=error_array)
|
|
157
|
+
|
|
158
|
+
def plot_spectral_cube(cubes, idx, ax, vmin=None, vmax=None, percentile=[3,99.5],
|
|
159
|
+
norm='asinh', radial_vel=None, unit=None, cmap='turbo', **kwargs):
|
|
160
|
+
'''
|
|
161
|
+
Plot a single spectral slice from one or more spectral cubes.
|
|
162
|
+
Parameters
|
|
163
|
+
––––––––––
|
|
164
|
+
cubes : DataCube, SpectralCube, or list of such
|
|
165
|
+
One or more spectral cubes to plot. All cubes should have consistent units.
|
|
166
|
+
idx : int
|
|
167
|
+
Index along the spectral axis corresponding to the slice to plot.
|
|
168
|
+
ax : matplotlib.axes.Axes
|
|
169
|
+
The axes on which to draw the slice.
|
|
170
|
+
vmin, vmax : float, optional, default=None
|
|
171
|
+
Minimum and maximum values for image scaling. Overrides percentile if provided.
|
|
172
|
+
percentile : list of two floats, default=[3, 99.5]
|
|
173
|
+
Percentile values for automatic scaling if vmin/vmax are not specified.
|
|
174
|
+
norm : str or None, default='asinh'
|
|
175
|
+
Normalization type for `imshow`. Use None for linear scaling.
|
|
176
|
+
radial_vel : float or astropy.units.Quantity, optional, default=None
|
|
177
|
+
Radial velocity to shift spectral axis to the rest frame.
|
|
178
|
+
unit : astropy.units.Unit or str, optional, default=None
|
|
179
|
+
Desired spectral axis unit for labeling.
|
|
180
|
+
cmap : str, list, or tuple, default='turbo'
|
|
181
|
+
Colormap(s) to use for plotting.
|
|
182
|
+
|
|
183
|
+
**kwargs : dict, optional
|
|
184
|
+
Additional plotting parameters.
|
|
185
|
+
|
|
186
|
+
Supported keywords:
|
|
187
|
+
|
|
188
|
+
- `title` : bool, default=False
|
|
189
|
+
If True, display spectral slice label as plot title.
|
|
190
|
+
- `emission_line` : str or None, default=None
|
|
191
|
+
Optional emission line label to display instead of slice value.
|
|
192
|
+
- `text_loc` : list of float, default=[0.03, 0.03]
|
|
193
|
+
Relative axes coordinates for overlay text placement.
|
|
194
|
+
- `text_color` : str, default='k'
|
|
195
|
+
Color of overlay text.
|
|
196
|
+
- `colorbar` : bool, default=True
|
|
197
|
+
Whether to add a colorbar.
|
|
198
|
+
- `cbar_width` : float, default=0.03
|
|
199
|
+
Width of the colorbar.
|
|
200
|
+
- `cbar_pad` : float, default=0.015
|
|
201
|
+
Padding between axes and colorbar.
|
|
202
|
+
- `clabel` : str, bool, or None, default=True
|
|
203
|
+
Label for colorbar. If True, automatically generate from cube unit.
|
|
204
|
+
- `xlabel`, `ylabel` : str, default='Right Ascension', 'Declination'
|
|
205
|
+
Axes labels.
|
|
206
|
+
- `spectral_label` : bool, default=True
|
|
207
|
+
Whether to draw spectral slice value as a label.
|
|
208
|
+
- `highlight` : bool, default=True
|
|
209
|
+
Whether to highlight interactive ellipse if plotted.
|
|
210
|
+
- `ellipses` : list or None, default=None
|
|
211
|
+
Ellipse objects to overlay on the image.
|
|
212
|
+
- `plot_ellipse` : bool, default=False
|
|
213
|
+
If True, plot a default or interactive ellipse.
|
|
214
|
+
- `center` : list of two ints, default=[Nx//2, Ny//2]
|
|
215
|
+
Center of default ellipse.
|
|
216
|
+
- `w`, `h` : float, default=X//5, Y//5
|
|
217
|
+
Width and height of default ellipse.
|
|
218
|
+
- `angle` : float or None, default=None
|
|
219
|
+
Angle of ellipse in degrees.
|
|
220
|
+
Notes
|
|
221
|
+
–––––
|
|
222
|
+
- If multiple cubes are provided, they are overplotted in sequence.
|
|
223
|
+
'''
|
|
224
|
+
# check cube units match and ensure cubes is iterable
|
|
225
|
+
cubes = check_units_consistency(cubes)
|
|
226
|
+
# –––– Kwargs ––––
|
|
227
|
+
# labels
|
|
228
|
+
title = kwargs.get('title', False)
|
|
229
|
+
emission_line = kwargs.get('emission_line', None)
|
|
230
|
+
text_loc = kwargs.get('text_loc', [0.03, 0.03])
|
|
231
|
+
text_color = kwargs.get('text_color', 'k')
|
|
232
|
+
colorbar = kwargs.get('colorbar', True)
|
|
233
|
+
cbar_width = kwargs.get('cbar_width', 0.03)
|
|
234
|
+
cbar_pad = kwargs.get('cbar_pad', 0.015)
|
|
235
|
+
clabel = kwargs.get('clabel', True)
|
|
236
|
+
xlabel = kwargs.get('xlabel', 'Right Ascension')
|
|
237
|
+
ylabel = kwargs.get('ylabel', 'Declination')
|
|
238
|
+
draw_spectral_label = kwargs.get('spectral_label', True)
|
|
239
|
+
highlight = kwargs.get('highlight', True)
|
|
240
|
+
# plot ellipse
|
|
241
|
+
ellipses = kwargs.get('ellipses', None)
|
|
242
|
+
plot_ellipse = (
|
|
243
|
+
True if ellipses is not None else kwargs.get('plot_ellipse', False)
|
|
244
|
+
)
|
|
245
|
+
_, X, Y = get_data(cubes[0]).shape
|
|
246
|
+
center = kwargs.get('center', [X//2, Y//2])
|
|
247
|
+
w = kwargs.get('w', X//5)
|
|
248
|
+
h = kwargs.get('h', Y//5)
|
|
249
|
+
angle = kwargs.get('angle', None)
|
|
250
|
+
|
|
251
|
+
for cube in cubes:
|
|
252
|
+
# extract data component
|
|
253
|
+
cube = get_data(cube)
|
|
254
|
+
|
|
255
|
+
# return data cube slices
|
|
256
|
+
cube_slice = slice_cube(cube, idx)
|
|
257
|
+
data = cube_slice.value
|
|
258
|
+
|
|
259
|
+
# compute imshow stretch
|
|
260
|
+
vmin, vmax = set_vmin_vmax(data, percentile, vmin, vmax)
|
|
261
|
+
cube_norm = return_imshow_norm(vmin, vmax, norm)
|
|
262
|
+
|
|
263
|
+
# imshow data
|
|
264
|
+
if norm is None:
|
|
265
|
+
im = ax.imshow(data, origin='lower', vmin=vmin, vmax=vmax, cmap=cmap)
|
|
266
|
+
else:
|
|
267
|
+
im = ax.imshow(data, origin='lower', cmap=cmap, norm=cube_norm)
|
|
268
|
+
|
|
269
|
+
# determine unit of colorbar
|
|
270
|
+
cbar_unit = set_unit_labels(cube.unit)
|
|
271
|
+
# set colorbar label
|
|
272
|
+
if clabel is True:
|
|
273
|
+
clabel = f'${cbar_unit}$' if cbar_unit is not None else None
|
|
274
|
+
# set colorbar
|
|
275
|
+
if colorbar:
|
|
276
|
+
add_colorbar(im, ax, cbar_width, cbar_pad, clabel)
|
|
277
|
+
|
|
278
|
+
# plot ellipses
|
|
279
|
+
if plot_ellipse:
|
|
280
|
+
# plot Ellipse objects
|
|
281
|
+
if ellipses is not None:
|
|
282
|
+
plot_ellipses(ellipses, ax)
|
|
283
|
+
# plot ellipse with angle
|
|
284
|
+
elif angle is not None:
|
|
285
|
+
e = Ellipse(xy=(center[0], center[1]), width=w, height=h, angle=angle, fill=False)
|
|
286
|
+
ax.add_patch(e)
|
|
287
|
+
# plot default/interactive ellipse
|
|
288
|
+
else:
|
|
289
|
+
plot_interactive_ellipse(center, w, h, ax, text_loc, text_color, highlight)
|
|
290
|
+
draw_spectral_label = False
|
|
291
|
+
|
|
292
|
+
# plot wavelength/frequency of current spectral slice, and emission line
|
|
293
|
+
if draw_spectral_label:
|
|
294
|
+
# compute spectral axis value of slice for label
|
|
295
|
+
spectral_axis = convert_units(cube.spectral_axis, unit)
|
|
296
|
+
spectral_axis = shift_by_radial_vel(spectral_axis, radial_vel)
|
|
297
|
+
spectral_value = get_spectral_slice_value(spectral_axis, idx)
|
|
298
|
+
unit_label = set_unit_labels(spectral_axis.unit)
|
|
299
|
+
|
|
300
|
+
# lambda for wavelength, f for frequency
|
|
301
|
+
spectral_type = r'\lambda = ' if spectral_axis.unit.physical_type == 'length' else r'f = '
|
|
302
|
+
# replace spectral type with emission line if provided
|
|
303
|
+
if emission_line is None:
|
|
304
|
+
slice_label = fr'${spectral_type}{spectral_value:0.2f}\,\mathrm{{{unit_label}}}$'
|
|
305
|
+
else:
|
|
306
|
+
# replace spaces with latex format
|
|
307
|
+
emission_line = emission_line.replace(' ', r'\ ')
|
|
308
|
+
slice_label = fr'$\mathrm{{{emission_line}}}\,{spectral_value:0.2f}\,\mathrm{{{unit_label}}}$'
|
|
309
|
+
# display label as either a title or text in figure
|
|
310
|
+
if title:
|
|
311
|
+
ax.set_title(slice_label, color=text_color, loc='center')
|
|
312
|
+
else:
|
|
313
|
+
ax.text(text_loc[0], text_loc[1], slice_label,
|
|
314
|
+
transform=ax.transAxes, color=text_color)
|
|
315
|
+
|
|
316
|
+
# set axes labels
|
|
317
|
+
ax.set_xlabel(xlabel)
|
|
318
|
+
ax.set_ylabel(ylabel)
|
|
319
|
+
ax.coords['dec'].set_ticklabel(rotation=90)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def mask_image(image, ellipse_region=None, region=None,
|
|
323
|
+
line_points=None, invert_region=False, above_line=True,
|
|
324
|
+
preserve_shape=True, existing_mask=None, **kwargs):
|
|
325
|
+
'''
|
|
326
|
+
Mask an image with modular filters.
|
|
327
|
+
Supports applying an elliptical or annular region mask, an optional
|
|
328
|
+
line cut (upper or lower half-plane), and combining with an existing mask.
|
|
329
|
+
Parameters
|
|
330
|
+
––––––––––
|
|
331
|
+
image : array-like, DataCube, FitsFile, or SpectralCube
|
|
332
|
+
Input image or cube. If higher-dimensional, the mask is applied
|
|
333
|
+
to the last two axes.
|
|
334
|
+
ellipse_region : `EllipsePixelRegion` or `EllipseAnnulusPixelRegion`, optional, default=None
|
|
335
|
+
Region object specifying an ellipse or annulus.
|
|
336
|
+
region : str {'annulus', 'ellipse'}, optional, default=None
|
|
337
|
+
Type of region to apply. Ignored if `ellipse_region` is provided.
|
|
338
|
+
line_points : array-like, shape (2, 2), optional, default=None
|
|
339
|
+
Two (x, y) points defining a line for masking above/below.
|
|
340
|
+
Ex: [[0,2], [20,10]]
|
|
341
|
+
invert_region : bool, default=False
|
|
342
|
+
If True, invert the region mask.
|
|
343
|
+
above_line : bool, default=True
|
|
344
|
+
If True, keep the region above the line. If False, keep below.
|
|
345
|
+
preserve_shape : bool, default=True
|
|
346
|
+
If True, return an array of the same shape with masked values set to NaN.
|
|
347
|
+
If False, return only the unmasked pixels.
|
|
348
|
+
existing_mask : ndarray of bool, optional, default=None
|
|
349
|
+
An existing mask to combine (union) with the new mask.
|
|
350
|
+
**kwargs : dict, optional
|
|
351
|
+
Additional plotting parameters.
|
|
352
|
+
|
|
353
|
+
Supported keywords:
|
|
354
|
+
|
|
355
|
+
- center : tuple of float, optional, default=None
|
|
356
|
+
Center coordinates (x, y).
|
|
357
|
+
- w : float, optional, default=None
|
|
358
|
+
Width of ellipse.
|
|
359
|
+
- h : float, optional, default=None
|
|
360
|
+
Height of ellipse.
|
|
361
|
+
- angle : float, optional, default=0
|
|
362
|
+
Rotation angle in degrees.
|
|
363
|
+
- tolerance : float, optional, default=2
|
|
364
|
+
Tolerance for annulus inner/outer radii
|
|
365
|
+
Returns
|
|
366
|
+
–––––––
|
|
367
|
+
masked_image : ndarray or SpectralCube
|
|
368
|
+
Image with mask applied. Type matches input.
|
|
369
|
+
masks : ndarray of bool or list
|
|
370
|
+
If multiple masks are combined, returns a list containing the
|
|
371
|
+
master mask followed by individual masks. Otherwise returns a single mask.
|
|
372
|
+
'''
|
|
373
|
+
# –––– Kwargs ––––
|
|
374
|
+
center = kwargs.get('center', None)
|
|
375
|
+
w = kwargs.get('w', None)
|
|
376
|
+
h = kwargs.get('h', None)
|
|
377
|
+
angle = kwargs.get('angle', 0)
|
|
378
|
+
tolerance = kwargs.get('tolerance', 2)
|
|
379
|
+
|
|
380
|
+
# ensure working with array
|
|
381
|
+
if isinstance(image, (DataCube, FitsFile)):
|
|
382
|
+
image = image.data
|
|
383
|
+
elif isinstance(image, (list, tuple)):
|
|
384
|
+
image = np.asarray(image)
|
|
385
|
+
|
|
386
|
+
# determine image shape
|
|
387
|
+
N, M = image.shape[-2:]
|
|
388
|
+
y, x = np.indices((N, M))
|
|
389
|
+
# empty list to hold all masks
|
|
390
|
+
masks = []
|
|
391
|
+
|
|
392
|
+
# early return if just applying an existing mask
|
|
393
|
+
if ellipse_region is None and region is None and line_points is None and existing_mask is not None:
|
|
394
|
+
if existing_mask.shape != image.shape[-2:]:
|
|
395
|
+
raise ValueError("existing_mask must have same shape as image")
|
|
396
|
+
|
|
397
|
+
if isinstance(image, np.ndarray):
|
|
398
|
+
if preserve_shape:
|
|
399
|
+
masked_image = np.full_like(image, np.nan, dtype=float)
|
|
400
|
+
masked_image[..., existing_mask] = image[..., existing_mask]
|
|
401
|
+
else:
|
|
402
|
+
masked_image = image[..., existing_mask]
|
|
403
|
+
else:
|
|
404
|
+
# if spectral cube or similar object
|
|
405
|
+
masked_image = image.with_mask(existing_mask)
|
|
406
|
+
|
|
407
|
+
return masked_image
|
|
408
|
+
|
|
409
|
+
# –––– Region Mask ––––
|
|
410
|
+
# if ellipse region is passed in use those values
|
|
411
|
+
if ellipse_region is not None:
|
|
412
|
+
center = ellipse_region.center
|
|
413
|
+
a = ellipse_region.width / 2
|
|
414
|
+
b = ellipse_region.height / 2
|
|
415
|
+
angle = ellipse_region.angle if ellipse_region.angle is not None else 0
|
|
416
|
+
# accept user defined center, w, and h values if used
|
|
417
|
+
elif None not in (center, w, h):
|
|
418
|
+
a = w / 2
|
|
419
|
+
b = h / 2
|
|
420
|
+
# stop program if attempting to plot a region without necessary data
|
|
421
|
+
elif region is not None:
|
|
422
|
+
raise ValueError("Either 'ellipse_region' or 'center', 'w', 'h' must be provided.")
|
|
423
|
+
|
|
424
|
+
# construct region
|
|
425
|
+
if region is not None:
|
|
426
|
+
if region.lower() == 'annulus':
|
|
427
|
+
region_obj = EllipseAnnulusPixelRegion(
|
|
428
|
+
center=PixCoord(center[0], center[1]),
|
|
429
|
+
inner_width=2*(a - tolerance),
|
|
430
|
+
inner_height=2*(b - tolerance),
|
|
431
|
+
outer_width=2*(a + tolerance),
|
|
432
|
+
outer_height=2*(b + tolerance),
|
|
433
|
+
angle=angle * u.deg
|
|
434
|
+
)
|
|
435
|
+
elif region.lower() == 'ellipse':
|
|
436
|
+
region_obj = EllipsePixelRegion(
|
|
437
|
+
center=PixCoord(center[0], center[1]),
|
|
438
|
+
width=2*a,
|
|
439
|
+
height=2*b,
|
|
440
|
+
angle=angle * u.deg
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
raise ValueError("region must be 'annulus' or 'ellipse'")
|
|
444
|
+
|
|
445
|
+
# filter by region mask
|
|
446
|
+
region_mask = region_obj.to_mask(mode='center').to_image((N, M)).astype(bool)
|
|
447
|
+
if invert_region:
|
|
448
|
+
region_mask = ~region_mask
|
|
449
|
+
masks.append(region_mask.copy())
|
|
450
|
+
else:
|
|
451
|
+
# empty mask if no region
|
|
452
|
+
region_mask = np.ones((N, M), dtype=bool)
|
|
453
|
+
|
|
454
|
+
# –––– Line Mask ––––
|
|
455
|
+
if line_points is not None:
|
|
456
|
+
# start from previous mask
|
|
457
|
+
line_mask = region_mask.copy()
|
|
458
|
+
# compute slope and intercept of line
|
|
459
|
+
m, b_line = compute_line(line_points)
|
|
460
|
+
# filter out points above/below line
|
|
461
|
+
line_mask &= (y >= m*x + b_line) if above_line else (y <= m*x + b_line)
|
|
462
|
+
# add line region to mask array
|
|
463
|
+
masks.append(line_mask.copy())
|
|
464
|
+
else:
|
|
465
|
+
# empty mask if no region
|
|
466
|
+
line_mask = region_mask.copy()
|
|
467
|
+
|
|
468
|
+
# –––– Combine Masks ––––
|
|
469
|
+
# start master mask with line_mask (or region if no line)
|
|
470
|
+
mask = line_mask.copy()
|
|
471
|
+
|
|
472
|
+
# union with existing mask if provided
|
|
473
|
+
if existing_mask is not None:
|
|
474
|
+
if existing_mask.shape != mask.shape:
|
|
475
|
+
raise ValueError("existing_mask must have the same shape as the image")
|
|
476
|
+
mask |= existing_mask
|
|
477
|
+
|
|
478
|
+
# –––– Apply Mask ––––
|
|
479
|
+
# if numpy array:
|
|
480
|
+
if isinstance(image, np.ndarray):
|
|
481
|
+
if preserve_shape:
|
|
482
|
+
masked_image = np.full_like(image, np.nan, dtype=float)
|
|
483
|
+
masked_image[..., mask] = image[..., mask]
|
|
484
|
+
else:
|
|
485
|
+
masked_image = image[..., mask]
|
|
486
|
+
# if spectral cube object
|
|
487
|
+
else:
|
|
488
|
+
masked_image = image.with_mask(mask)
|
|
489
|
+
|
|
490
|
+
# ––––– Final Mask List –––––
|
|
491
|
+
# Return master mask as first element
|
|
492
|
+
masks = [mask] + masks if len(masks) > 1 else mask
|
|
493
|
+
|
|
494
|
+
return masked_image, masks
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import astropy.units as u
|
|
2
|
+
from astropy.units import spectral
|
|
3
|
+
import numpy as np
|
|
4
|
+
from .numerical_utils import get_data
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Cube Manipulation Functions
|
|
8
|
+
# –––––––––––––––––––––––––––
|
|
9
|
+
def extract_spectral_axis(cube, unit=None):
|
|
10
|
+
'''
|
|
11
|
+
Extract the spectral axis from a data cube and optionally
|
|
12
|
+
convert it to a specified unit.
|
|
13
|
+
Parameters
|
|
14
|
+
––––––––––
|
|
15
|
+
cube : SpectralCube
|
|
16
|
+
The input spectral data cube.
|
|
17
|
+
unit : astropy.units.Unit, optional
|
|
18
|
+
Desired unit for the spectral axis. If None, the axis
|
|
19
|
+
is returned in its native units.
|
|
20
|
+
Returns
|
|
21
|
+
–––––––
|
|
22
|
+
spectral_axis : Quantity
|
|
23
|
+
The spectral axis of the cube, optionally converted
|
|
24
|
+
to the specified unit.
|
|
25
|
+
'''
|
|
26
|
+
axis = cube.spectral_axis
|
|
27
|
+
# return axis if unit is None
|
|
28
|
+
if unit is None:
|
|
29
|
+
return axis
|
|
30
|
+
# if a unit is specified, attempt to
|
|
31
|
+
# convert axis to those units
|
|
32
|
+
try:
|
|
33
|
+
return axis.to(unit, equivalencies=spectral())
|
|
34
|
+
except u.UnitConversionError:
|
|
35
|
+
raise ValueError(f"Cannot convert spectral axis from {axis.unit} to {unit}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def slice_cube(cube, idx):
|
|
39
|
+
'''
|
|
40
|
+
Return a slice of a data cube along the first axis.
|
|
41
|
+
Parameters
|
|
42
|
+
––––––––––
|
|
43
|
+
cube : np.ndarray
|
|
44
|
+
Input data cube, typically with shape (T, N, ...) where T is the first axis.
|
|
45
|
+
idx : int or list of int
|
|
46
|
+
Index or indices specifying the slice along the first axis:
|
|
47
|
+
- i -> returns 'cube[i]'
|
|
48
|
+
- [i] -> returns 'cube[i]'
|
|
49
|
+
- [i, j] -> returns 'cube[i:j+1].sum(axis=0)'
|
|
50
|
+
Returns
|
|
51
|
+
–––––––
|
|
52
|
+
cube : np.ndarray
|
|
53
|
+
Sliced cube with shape (N, ...).
|
|
54
|
+
'''
|
|
55
|
+
cube = get_data(cube)
|
|
56
|
+
# if index is integer
|
|
57
|
+
if isinstance(idx, int):
|
|
58
|
+
return cube[idx]
|
|
59
|
+
# if index is list of integers
|
|
60
|
+
elif isinstance(idx, list):
|
|
61
|
+
# list of len 1
|
|
62
|
+
if len(idx) == 1:
|
|
63
|
+
return cube[idx[0]]
|
|
64
|
+
# list of len 2
|
|
65
|
+
elif len(idx) == 2:
|
|
66
|
+
start, end = idx
|
|
67
|
+
return cube[start:end+1].sum(axis=0)
|
|
68
|
+
|
|
69
|
+
raise ValueError("'idx' must be an int or a list of one or two integers")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_spectral_slice_value(spectral_axis, idx):
|
|
73
|
+
'''
|
|
74
|
+
Return a representative value from a spectral axis
|
|
75
|
+
given an index or index range.
|
|
76
|
+
Parameters
|
|
77
|
+
––––––––––
|
|
78
|
+
spectral_axis : Quantity
|
|
79
|
+
The spectral axis (e.g., wavelength, frequency, or
|
|
80
|
+
velocity) as an 'astropy.units.Quantity' array.
|
|
81
|
+
idx : int or list of int
|
|
82
|
+
Index or indices specifying the slice along the first axis:
|
|
83
|
+
- i -> returns 'spectral_axis[i]'
|
|
84
|
+
- [i] -> returns 'spectral_axis[i]'
|
|
85
|
+
- [i, j] -> returns '(spectral_axis[i] + spectral_axis[j+1])/2'
|
|
86
|
+
Returns
|
|
87
|
+
–––––––
|
|
88
|
+
spectral_value : float
|
|
89
|
+
The spectral value at the specified index or index
|
|
90
|
+
range, in the units of 'spectral_axis'.
|
|
91
|
+
'''
|
|
92
|
+
if isinstance(idx, int):
|
|
93
|
+
return spectral_axis[idx].value
|
|
94
|
+
elif isinstance(idx, list):
|
|
95
|
+
if len(idx) == 1:
|
|
96
|
+
return spectral_axis[idx[0]].value
|
|
97
|
+
elif len(idx) == 2:
|
|
98
|
+
return (spectral_axis[idx[0]].value + spectral_axis[idx[1]+1].value)/2
|
|
99
|
+
|
|
100
|
+
raise ValueError("'idx' must be an int or a list of one or two integers")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Cube Masking Functions
|
|
104
|
+
# ––––––––––––––––––––––
|
|
105
|
+
def compute_line(points):
|
|
106
|
+
'''
|
|
107
|
+
Compute the slope and intercept of a line passing through two points.
|
|
108
|
+
Parameters
|
|
109
|
+
––––––––––
|
|
110
|
+
points : list or tuple of tuples
|
|
111
|
+
A sequence containing exactly two points, each as (x, y), e.g.,
|
|
112
|
+
[(x0, y0), (x1, y1)].
|
|
113
|
+
Returns
|
|
114
|
+
–––––––
|
|
115
|
+
m : float
|
|
116
|
+
Slope of the line.
|
|
117
|
+
b : float
|
|
118
|
+
Intercept of the line (y = m*x + b).
|
|
119
|
+
Notes
|
|
120
|
+
–––––
|
|
121
|
+
- The function assumes the two points have different x-coordinates.
|
|
122
|
+
- If the x-coordinates are equal, a ZeroDivisionError will be raised.
|
|
123
|
+
'''
|
|
124
|
+
m = (points[0][1] - points[1][1]) / (points[0][0] - points[1][0])
|
|
125
|
+
b = points[0][1] - m*points[0][0]
|
|
126
|
+
|
|
127
|
+
return m, b
|