Skip to content

Commit 0192706

Browse files
authored
Merge pull request #144 from ojustino/accept-spectrum1d
Better handled Spectrum1D images across classes
2 parents 2a63e7d + 90b6460 commit 0192706

File tree

9 files changed

+488
-176
lines changed

9 files changed

+488
-176
lines changed

CHANGES.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ API Changes
1313
- Renamed KosmosTrace as FitTrace, a conglomerate class for traces that are fit
1414
to images instead of predetermined [#128]
1515
- The default number of bins for FitTrace is now its associated image's number
16-
of dispersion pixels instead of 20. Its default peak_method is now 'max'. [#128]
16+
of dispersion pixels instead of 20. Its default peak_method is now 'max' [#128]
17+
- All operations now accept Spectrum1D and Quantity-type images. All accepted
18+
image types are now processed internally as Spectrum1D objects [#144]
19+
- All operations' ``image`` attributes are now coerced Spectrum1D objects [#144]
1720

1821
Bug Fixes
1922
^^^^^^^^^

setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ github_project = astropy/specreduce
1414
[options]
1515
zip_safe = False
1616
packages = find:
17-
python_requires = >=3.7
17+
python_requires = >=3.8
1818
setup_requires = setuptools_scm
1919
install_requires =
2020
astropy
21-
specutils
21+
specutils>=1.9.1
2222
synphot
2323
matplotlib
2424
photutils

specreduce/background.py

Lines changed: 70 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@
55

66
import numpy as np
77
from astropy.nddata import NDData
8+
from astropy.utils.decorators import deprecated_attribute
89
from astropy import units as u
10+
from specutils import Spectrum1D
911

10-
from specreduce.extract import _ap_weight_image, _to_spectrum1d_pixels
12+
from specreduce.core import _ImageParser
13+
from specreduce.extract import _ap_weight_image
1114
from specreduce.tracing import Trace, FlatTrace
1215

1316
__all__ = ['Background']
1417

1518

1619
@dataclass
17-
class Background:
20+
class Background(_ImageParser):
1821
"""
1922
Determine the background from an image for subtraction.
2023
@@ -27,7 +30,7 @@ class Background:
2730
2831
Parameters
2932
----------
30-
image : `~astropy.nddata.NDData` or array-like
33+
image : `~astropy.nddata.NDData`-like or array-like
3134
image with 2-D spectral image data
3235
traces : List
3336
list of trace objects (or integers to define FlatTraces) to
@@ -54,13 +57,16 @@ class Background:
5457
disp_axis: int = 1
5558
crossdisp_axis: int = 0
5659

60+
# TO-DO: update bkg_array with Spectrum1D alternative (is bkg_image enough?)
61+
bkg_array = deprecated_attribute('bkg_array', '1.3')
62+
5763
def __post_init__(self):
5864
"""
5965
Determine the background from an image for subtraction.
6066
6167
Parameters
6268
----------
63-
image : `~astropy.nddata.NDData` or array-like
69+
image : `~astropy.nddata.NDData`-like or array-like
6470
image with 2-D spectral image data
6571
traces : List
6672
list of trace objects (or integers to define FlatTraces) to
@@ -86,17 +92,18 @@ def _to_trace(trace):
8692
raise ValueError('trace_object.trace_pos must be >= 1')
8793
return trace
8894

95+
self.image = self._parse_image(self.image)
96+
8997
if self.width < 0:
9098
raise ValueError("width must be positive")
91-
9299
if self.width == 0:
93-
self.bkg_array = np.zeros(self.image.shape[self.disp_axis])
100+
self._bkg_array = np.zeros(self.image.shape[self.disp_axis])
94101
return
95102

96103
if isinstance(self.traces, Trace):
97104
self.traces = [self.traces]
98105

99-
bkg_wimage = np.zeros_like(self.image, dtype=np.float64)
106+
bkg_wimage = np.zeros_like(self.image.data, dtype=np.float64)
100107
for trace in self.traces:
101108
trace = _to_trace(trace)
102109
windows_max = trace.trace.data.max() + self.width/2
@@ -127,12 +134,13 @@ def _to_trace(trace):
127134
self.bkg_wimage = bkg_wimage
128135

129136
if self.statistic == 'average':
130-
self.bkg_array = np.average(self.image, weights=self.bkg_wimage,
131-
axis=self.crossdisp_axis)
137+
self._bkg_array = np.average(self.image.data,
138+
weights=self.bkg_wimage,
139+
axis=self.crossdisp_axis)
132140
elif self.statistic == 'median':
133-
med_image = self.image.copy()
141+
med_image = self.image.data.copy()
134142
med_image[np.where(self.bkg_wimage) == 0] = np.nan
135-
self.bkg_array = np.nanmedian(med_image, axis=self.crossdisp_axis)
143+
self._bkg_array = np.nanmedian(med_image, axis=self.crossdisp_axis)
136144
else:
137145
raise ValueError("statistic must be 'average' or 'median'")
138146

@@ -150,9 +158,11 @@ def two_sided(cls, image, trace_object, separation, **kwargs):
150158
151159
Parameters
152160
----------
153-
image : nddata-compatible image
154-
image with 2-D spectral image data
155-
trace_object: Trace
161+
image : `~astropy.nddata.NDData`-like or array-like
162+
Image with 2-D spectral image data. Assumes cross-dispersion
163+
(spatial) direction is axis 0 and dispersion (wavelength)
164+
direction is axis 1.
165+
trace_object: `~specreduce.tracing.Trace`
156166
estimated trace of the spectrum to center the background traces
157167
separation: float
158168
separation from ``trace_object`` for the background regions
@@ -167,6 +177,7 @@ def two_sided(cls, image, trace_object, separation, **kwargs):
167177
crossdisp_axis : int
168178
cross-dispersion axis
169179
"""
180+
image = cls._parse_image(cls, image)
170181
kwargs['traces'] = [trace_object-separation, trace_object+separation]
171182
return cls(image=image, **kwargs)
172183

@@ -183,9 +194,11 @@ def one_sided(cls, image, trace_object, separation, **kwargs):
183194
184195
Parameters
185196
----------
186-
image : nddata-compatible image
187-
image with 2-D spectral image data
188-
trace_object: Trace
197+
image : `~astropy.nddata.NDData`-like or array-like
198+
Image with 2-D spectral image data. Assumes cross-dispersion
199+
(spatial) direction is axis 0 and dispersion (wavelength)
200+
direction is axis 1.
201+
trace_object: `~specreduce.tracing.Trace`
189202
estimated trace of the spectrum to center the background traces
190203
separation: float
191204
separation from ``trace_object`` for the background, positive will be
@@ -201,6 +214,7 @@ def one_sided(cls, image, trace_object, separation, **kwargs):
201214
crossdisp_axis : int
202215
cross-dispersion axis
203216
"""
217+
image = cls._parse_image(cls, image)
204218
kwargs['traces'] = [trace_object+separation]
205219
return cls(image=image, **kwargs)
206220

@@ -210,28 +224,32 @@ def bkg_image(self, image=None):
210224
211225
Parameters
212226
----------
213-
image : nddata-compatible image or None
214-
image with 2-D spectral image data. If None, will extract
215-
the background from ``image`` used to initialize the class.
227+
image : `~astropy.nddata.NDData`-like or array-like, optional
228+
Image with 2-D spectral image data. Assumes cross-dispersion
229+
(spatial) direction is axis 0 and dispersion (wavelength)
230+
direction is axis 1. If None, will extract the background
231+
from ``image`` used to initialize the class. [default: None]
216232
217233
Returns
218234
-------
219-
array with same shape as ``image``.
235+
Spectrum1D object with same shape as ``image``.
220236
"""
221-
if image is None:
222-
image = self.image
223-
224-
return np.tile(self.bkg_array, (image.shape[0], 1))
237+
image = self._parse_image(image)
238+
return Spectrum1D(np.tile(self._bkg_array,
239+
(image.shape[0], 1)) * image.unit,
240+
spectral_axis=image.spectral_axis)
225241

226242
def bkg_spectrum(self, image=None):
227243
"""
228244
Expose the 1D spectrum of the background.
229245
230246
Parameters
231247
----------
232-
image : nddata-compatible image or None
233-
image with 2-D spectral image data. If None, will extract
234-
the background from ``image`` used to initialize the class.
248+
image : `~astropy.nddata.NDData`-like or array-like, optional
249+
Image with 2-D spectral image data. Assumes cross-dispersion
250+
(spatial) direction is axis 0 and dispersion (wavelength)
251+
direction is axis 1. If None, will extract the background
252+
from ``image`` used to initialize the class. [default: None]
235253
236254
Returns
237255
-------
@@ -240,10 +258,15 @@ def bkg_spectrum(self, image=None):
240258
units as the input image (or u.DN if none were provided) and
241259
the spectral axis expressed in pixel units.
242260
"""
243-
bkg_image = self.bkg_image(image=image)
261+
bkg_image = self.bkg_image(image)
244262

245-
ext1d = np.sum(bkg_image, axis=self.crossdisp_axis)
246-
return _to_spectrum1d_pixels(ext1d * getattr(image, 'unit', u.DN))
263+
try:
264+
return bkg_image.collapse(np.sum, axis=self.crossdisp_axis)
265+
except u.UnitTypeError:
266+
# can't collapse with a spectral axis in pixels because
267+
# SpectralCoord only allows frequency/wavelength equivalent units...
268+
ext1d = np.sum(bkg_image.flux, axis=self.crossdisp_axis)
269+
return Spectrum1D(ext1d, bkg_image.spectral_axis)
247270

248271
def sub_image(self, image=None):
249272
"""
@@ -259,14 +282,16 @@ def sub_image(self, image=None):
259282
-------
260283
array with same shape as ``image``
261284
"""
262-
if image is None:
263-
image = self.image
285+
image = self._parse_image(image)
264286

265-
if isinstance(image, NDData):
266-
# https://docs.astropy.org/en/stable/nddata/mixins/ndarithmetic.html
267-
return image.subtract(self.bkg_image(image)*image.unit)
268-
else:
269-
return image - self.bkg_image(image)
287+
# a compare_wcs argument is needed for Spectrum1D.subtract() in order to
288+
# avoid a TypeError from SpectralCoord when image's spectral axis is in
289+
# pixels. it is not needed when image's spectral axis has physical units
290+
kwargs = ({'compare_wcs': None} if image.spectral_axis.unit == u.pix
291+
else {})
292+
293+
# https://docs.astropy.org/en/stable/nddata/mixins/ndarithmetic.html
294+
return image.subtract(self.bkg_image(image), **kwargs)
270295

271296
def sub_spectrum(self, image=None):
272297
"""
@@ -287,8 +312,13 @@ def sub_spectrum(self, image=None):
287312
"""
288313
sub_image = self.sub_image(image=image)
289314

290-
ext1d = np.sum(sub_image, axis=self.crossdisp_axis)
291-
return _to_spectrum1d_pixels(ext1d * getattr(image, 'unit', u.DN))
315+
try:
316+
return sub_image.collapse(np.sum, axis=self.crossdisp_axis)
317+
except u.UnitTypeError:
318+
# can't collapse with a spectral axis in pixels because
319+
# SpectralCoord only allows frequency/wavelength equivalent units...
320+
ext1d = np.sum(sub_image.flux, axis=self.crossdisp_axis)
321+
return Spectrum1D(ext1d, spectral_axis=sub_image.spectral_axis)
292322

293323
def __rsub__(self, image):
294324
"""

specreduce/core.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,86 @@
11
# Licensed under a 3-clause BSD style license - see LICENSE.rst
22

33
import inspect
4+
import numpy as np
5+
6+
from astropy import units as u
7+
from astropy.nddata import VarianceUncertainty
48
from dataclasses import dataclass
9+
from specutils import Spectrum1D
510

611
__all__ = ['SpecreduceOperation']
712

813

14+
class _ImageParser:
15+
"""
16+
Coerces images from accepted formats to Spectrum1D objects for
17+
internal use in specreduce's operation classes.
18+
19+
Fills any and all of uncertainty, mask, units, and spectral axis
20+
that are missing in the provided image with generic values.
21+
Accepted image types are:
22+
23+
- `~specutils.spectra.spectrum1d.Spectrum1D` (preferred)
24+
- `~astropy.nddata.ccddata.CCDData`
25+
- `~astropy.nddata.ndddata.NDDData`
26+
- `~astropy.units.quantity.Quantity`
27+
- `~numpy.ndarray`
28+
"""
29+
def _parse_image(self, image, disp_axis=1):
30+
"""
31+
Convert all accepted image types to a consistently formatted
32+
Spectrum1D object.
33+
34+
Parameters
35+
----------
36+
image : `~astropy.nddata.NDData`-like or array-like, required
37+
The image to be parsed. If None, defaults to class' own
38+
image attribute.
39+
disp_axis : int, optional
40+
The index of the image's dispersion axis. Should not be
41+
changed until operations can handle variable image
42+
orientations. [default: 1]
43+
"""
44+
45+
# would be nice to handle (cross)disp_axis consistently across
46+
# operations (public attribute? private attribute? argument only?) so
47+
# it can be called from self instead of via kwargs...
48+
49+
if image is None:
50+
# useful for Background's instance methods
51+
return self.image
52+
53+
if isinstance(image, np.ndarray):
54+
img = image
55+
elif isinstance(image, u.quantity.Quantity):
56+
img = image.value
57+
else: # NDData, including CCDData and Spectrum1D
58+
img = image.data
59+
60+
# mask and uncertainty are set as None when they aren't specified upon
61+
# creating a Spectrum1D object, so we must check whether these
62+
# attributes are absent *and* whether they are present but set as None
63+
if getattr(image, 'mask', None) is not None:
64+
mask = image.mask
65+
else:
66+
mask = np.ma.masked_invalid(img).mask
67+
68+
if getattr(image, 'uncertainty', None) is not None:
69+
uncertainty = image.uncertainty
70+
else:
71+
uncertainty = VarianceUncertainty(np.ones(img.shape))
72+
73+
unit = getattr(image, 'unit', u.Unit('DN'))
74+
75+
spectral_axis = getattr(image, 'spectral_axis',
76+
np.arange(img.shape[disp_axis]) * u.pix)
77+
78+
return Spectrum1D(img * unit, spectral_axis=spectral_axis,
79+
uncertainty=uncertainty, mask=mask)
80+
81+
982
@dataclass
10-
class SpecreduceOperation:
83+
class SpecreduceOperation(_ImageParser):
1184
"""
1285
An operation to perform as part of a spectroscopic reduction pipeline.
1386

0 commit comments

Comments
 (0)