Skip to content

Commit b032d95

Browse files
committed
Added image parsers to all classes that take images
Quantity arrays are now acceptable inputs. After parsing, the attribute is now of type instead of as before.
1 parent 55a5bef commit b032d95

File tree

3 files changed

+208
-85
lines changed

3 files changed

+208
-85
lines changed

specreduce/background.py

+43-9
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
from dataclasses import dataclass, field
55

66
import numpy as np
7-
from astropy.nddata import NDData
7+
from astropy.nddata import NDData, VarianceUncertainty
88
from astropy import units as u
9+
from specutils import Spectrum1D
910

1011
from specreduce.extract import _ap_weight_image, _to_spectrum1d_pixels
1112
from specreduce.tracing import Trace, FlatTrace
@@ -54,6 +55,41 @@ class Background:
5455
disp_axis: int = 1
5556
crossdisp_axis: int = 0
5657

58+
def _parse_image(self):
59+
"""
60+
Convert all accepted image types to a consistently formatted Spectrum1D.
61+
"""
62+
63+
if isinstance(self.image, np.ndarray):
64+
img = self.image
65+
elif isinstance(self.image, u.quantity.Quantity):
66+
img = self.image.value
67+
else: # NDData, including CCDData and Spectrum1D
68+
img = self.image.data
69+
70+
# mask and uncertainty are set as None when they aren't specified upon
71+
# creating a Spectrum1D object, so we must check whether these
72+
# attributes are absent *and* whether they are present but set as None
73+
if getattr(self.image, 'mask', None) is not None:
74+
mask = self.image.mask
75+
else:
76+
mask = np.ma.masked_invalid(img).mask
77+
78+
if getattr(self.image, 'uncertainty', None) is not None:
79+
uncertainty = self.image.uncertainty
80+
else:
81+
uncertainty = VarianceUncertainty(np.ones(img.shape))
82+
83+
unit = getattr(self.image, 'unit', u.Unit('DN')) # or u.Unit()?
84+
85+
spectral_axis = getattr(self.image, 'spectral_axis',
86+
(np.arange(img.shape[self.disp_axis])
87+
if hasattr(self, 'disp_axis')
88+
else np.arange(img.shape[1])) * u.pix)
89+
90+
self.image = Spectrum1D(img * unit, spectral_axis=spectral_axis,
91+
uncertainty=uncertainty, mask=mask)
92+
5793
def __post_init__(self):
5894
"""
5995
Determine the background from an image for subtraction.
@@ -86,6 +122,8 @@ def _to_trace(trace):
86122
raise ValueError('trace_object.trace_pos must be >= 1')
87123
return trace
88124

125+
self._parse_image()
126+
89127
if self.width < 0:
90128
raise ValueError("width must be positive")
91129
if self.width == 0:
@@ -95,12 +133,7 @@ def _to_trace(trace):
95133
if isinstance(self.traces, Trace):
96134
self.traces = [self.traces]
97135

98-
if isinstance(self.image, NDData):
99-
# NOTE: should the NDData structure instead be preserved?
100-
# (NDData includes Spectrum1D under its umbrella)
101-
self.image = self.image.data
102-
103-
bkg_wimage = np.zeros_like(self.image, dtype=np.float64)
136+
bkg_wimage = np.zeros_like(self.image.data, dtype=np.float64)
104137
for trace in self.traces:
105138
trace = _to_trace(trace)
106139
windows_max = trace.trace.data.max() + self.width/2
@@ -131,10 +164,11 @@ def _to_trace(trace):
131164
self.bkg_wimage = bkg_wimage
132165

133166
if self.statistic == 'average':
134-
self.bkg_array = np.average(self.image, weights=self.bkg_wimage,
167+
self.bkg_array = np.average(self.image.data,
168+
weights=self.bkg_wimage,
135169
axis=self.crossdisp_axis)
136170
elif self.statistic == 'median':
137-
med_image = self.image.copy()
171+
med_image = self.image.data.copy()
138172
med_image[np.where(self.bkg_wimage) == 0] = np.nan
139173
self.bkg_array = np.nanmedian(med_image, axis=self.crossdisp_axis)
140174
else:

specreduce/extract.py

+125-65
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from astropy import units as u
99
from astropy.modeling import Model, models, fitting
10-
from astropy.nddata import NDData
10+
from astropy.nddata import NDData, VarianceUncertainty
1111

1212
from specreduce.core import SpecreduceOperation
1313
from specreduce.tracing import Trace, FlatTrace
@@ -164,6 +164,41 @@ class BoxcarExtract(SpecreduceOperation):
164164
def spectrum(self):
165165
return self.__call__()
166166

167+
def _parse_image(self):
168+
"""
169+
Convert all accepted image types to a consistently formatted Spectrum1D.
170+
"""
171+
172+
if isinstance(self.image, np.ndarray):
173+
img = self.image
174+
elif isinstance(self.image, u.quantity.Quantity):
175+
img = self.image.value
176+
else: # NDData, including CCDData and Spectrum1D
177+
img = self.image.data
178+
179+
# mask and uncertainty are set as None when they aren't specified upon
180+
# creating a Spectrum1D object, so we must check whether these
181+
# attributes are absent *and* whether they are present but set as None
182+
if getattr(self.image, 'mask', None) is not None:
183+
mask = self.image.mask
184+
else:
185+
mask = np.ma.masked_invalid(img).mask
186+
187+
if getattr(self.image, 'uncertainty', None) is not None:
188+
uncertainty = self.image.uncertainty
189+
else:
190+
uncertainty = VarianceUncertainty(np.ones(img.shape))
191+
192+
unit = getattr(self.image, 'unit', u.Unit('DN')) # or u.Unit()?
193+
194+
spectral_axis = getattr(self.image, 'spectral_axis',
195+
(np.arange(img.shape[self.disp_axis])
196+
if hasattr(self, 'disp_axis')
197+
else np.arange(img.shape[1])) * u.pix)
198+
199+
self.image = Spectrum1D(img * unit, spectral_axis=spectral_axis,
200+
uncertainty=uncertainty, mask=mask)
201+
167202
def __call__(self, image=None, trace_object=None, width=None,
168203
disp_axis=None, crossdisp_axis=None):
169204
"""
@@ -285,6 +320,86 @@ class HorneExtract(SpecreduceOperation):
285320
def spectrum(self):
286321
return self.__call__()
287322

323+
def _parse_image(self, variance=None, mask=None, unit=None):
324+
"""
325+
Convert all accepted image types to a consistently formatted Spectrum1D.
326+
Takes some extra arguments exactly as they come from self.__call__() to
327+
handle cases where users specify them as arguments instead of as
328+
attributes of their image object.
329+
"""
330+
331+
if isinstance(self.image, np.ndarray):
332+
img = self.image
333+
elif isinstance(self.image, u.quantity.Quantity):
334+
img = self.image.value
335+
else: # NDData, including CCDData and Spectrum1D
336+
img = self.image.data
337+
338+
# mask is set as None when not specified upon creating a Spectrum1D
339+
# object, so we must check whether it is absent *and* whether it's
340+
# present but set as None
341+
if getattr(self.image, 'mask', None) is not None:
342+
mask = self.image.mask
343+
else:
344+
mask = np.ma.masked_invalid(img).mask
345+
346+
# Process uncertainties, converting to variances when able and throwing
347+
# an error when uncertainties are missing or less easily converted
348+
if (hasattr(self.image, 'uncertainty')
349+
and self.image.uncertainty is not None):
350+
if self.image.uncertainty.uncertainty_type == 'var':
351+
variance = self.image.uncertainty.array
352+
elif self.image.uncertainty.uncertainty_type == 'std':
353+
warnings.warn("image NDData object's uncertainty "
354+
"interpreted as standard deviation. if "
355+
"incorrect, use VarianceUncertainty when "
356+
"assigning image object's uncertainty.")
357+
variance = self.image.uncertainty.array**2
358+
elif self.image.uncertainty.uncertainty_type == 'ivar':
359+
variance = 1 / self.image.uncertainty.array
360+
else:
361+
# other options are InverseVariance and UnknownVariance
362+
raise ValueError("image NDData object has unexpected "
363+
"uncertainty type. instead, try "
364+
"VarianceUncertainty or StdDevUncertainty.")
365+
elif (hasattr(self.image, 'uncertainty')
366+
and self.image.uncertainty is None):
367+
# ignore variance arg to focus on updating NDData object
368+
raise ValueError('image NDData object lacks uncertainty')
369+
else:
370+
if variance is None:
371+
raise ValueError("if image is a numpy or Quantity array, a "
372+
"variance must be specified. consider "
373+
"wrapping it into one object by instead "
374+
"passing an NDData image.")
375+
elif self.image.shape != variance.shape:
376+
raise ValueError("image and variance shapes must match")
377+
378+
if np.any(variance < 0):
379+
raise ValueError("variance must be fully positive")
380+
if np.all(variance == 0):
381+
# technically would result in infinities, but since they're all
382+
# zeros, we can override ones to simulate an unweighted case
383+
variance = np.ones_like(variance)
384+
if np.any(variance == 0):
385+
# exclude such elements by editing the input mask
386+
mask[variance == 0] = True
387+
# replace the variances to avoid a divide by zero warning
388+
variance[variance == 0] = np.nan
389+
390+
variance = VarianceUncertainty(variance)
391+
392+
unit = getattr(self.image, 'unit',
393+
u.Unit(self.unit) if self.unit is not None else u.Unit())
394+
395+
spectral_axis = getattr(self.image, 'spectral_axis',
396+
(np.arange(img.shape[self.disp_axis])
397+
if hasattr(self, 'disp_axis')
398+
else np.arange(img.shape[1])) * u.pix)
399+
400+
self.image = Spectrum1D(img * unit, spectral_axis=spectral_axis,
401+
uncertainty=variance, mask=mask)
402+
288403
def __call__(self, image=None, trace_object=None,
289404
disp_axis=None, crossdisp_axis=None,
290405
bkgrd_prof=None,
@@ -347,71 +462,16 @@ def __call__(self, image=None, trace_object=None,
347462
mask = mask if mask is not None else self.mask
348463
unit = unit if unit is not None else self.unit
349464

350-
# handle image and associated data based on image's type
351-
if isinstance(image, NDData):
352-
# (NDData includes Spectrum1D under its umbrella)
353-
img = np.ma.array(image.data, mask=image.mask)
354-
unit = image.unit if image.unit is not None else u.Unit()
355-
356-
if image.uncertainty is not None:
357-
# prioritize NDData's uncertainty over variance argument
358-
if image.uncertainty.uncertainty_type == 'var':
359-
variance = image.uncertainty.array
360-
elif image.uncertainty.uncertainty_type == 'std':
361-
# NOTE: CCDData defaults uncertainties given as pure arrays
362-
# to std and logs a warning saying so upon object creation.
363-
# should we remind users again here?
364-
warnings.warn("image NDData object's uncertainty "
365-
"interpreted as standard deviation. if "
366-
"incorrect, use VarianceUncertainty when "
367-
"assigning image object's uncertainty.")
368-
variance = image.uncertainty.array**2
369-
elif image.uncertainty.uncertainty_type == 'ivar':
370-
variance = 1 / image.uncertainty.array
371-
else:
372-
# other options are InverseVariance and UnknownVariance
373-
raise ValueError("image NDData object has unexpected "
374-
"uncertainty type. instead, try "
375-
"VarianceUncertainty or StdDevUncertainty.")
376-
else:
377-
# ignore variance arg to focus on updating NDData object
378-
raise ValueError('image NDData object lacks uncertainty')
379-
380-
else:
381-
if variance is None:
382-
raise ValueError('if image is a numpy array, a variance must '
383-
'be specified. consider wrapping it into one '
384-
'object by instead passing an NDData image.')
385-
elif image.shape != variance.shape:
386-
raise ValueError('image and variance shapes must match')
387-
388-
# check optional arguments, filling them in if absent
389-
if mask is None:
390-
mask = np.ma.masked_invalid(image).mask
391-
elif image.shape != mask.shape:
392-
raise ValueError('image and mask shapes must match.')
393-
394-
if isinstance(unit, str):
395-
unit = u.Unit(unit)
396-
else:
397-
unit = unit if unit is not None else u.Unit()
398-
399-
# create image
400-
img = np.ma.array(image, mask=mask)
401-
402-
if np.all(variance == 0):
403-
# technically would result in infinities, but since they're all zeros
404-
# we can just do the unweighted case by overriding with all ones
405-
variance = np.ones_like(variance)
465+
# parse image and replace optional arguments with updated values
466+
self._parse_image(variance, mask, unit)
467+
variance = self.image.uncertainty.array
468+
unit = self.image.unit
406469

407-
if np.any(variance < 0):
408-
raise ValueError("variance must be fully positive")
409-
410-
if np.any(variance == 0):
411-
# exclude these elements by editing the input mask
412-
img.mask[variance == 0] = True
413-
# replace the variances to avoid a divide by zero warning
414-
variance[variance == 0] = np.nan
470+
# mask any previously uncaught invalid values
471+
or_mask = np.logical_or(mask,
472+
np.ma.masked_invalid(self.image.data).mask)
473+
img = np.ma.masked_array(self.image.data, or_mask)
474+
mask = img.mask
415475

416476
# co-add signal in each image column
417477
ncols = img.shape[crossdisp_axis]

specreduce/tracing.py

+40-11
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import warnings
66

77
from astropy.modeling import fitting, models
8-
from astropy.nddata import NDData
8+
from astropy.nddata import NDData, VarianceUncertainty
99
from astropy.stats import gaussian_sigma_to_fwhm
10+
from astropy import units as u
1011
from scipy.interpolate import UnivariateSpline
1112
from specutils import Spectrum1D
1213
import numpy as np
@@ -39,9 +40,39 @@ def __getitem__(self, i):
3940
return self.trace[i]
4041

4142
def _parse_image(self):
42-
if isinstance(self.image, Spectrum1D):
43-
# NOTE: should the Spectrum1D structure instead be preserved?
44-
self.image = self.image.data
43+
"""
44+
Convert all accepted image types to a consistently formatted Spectrum1D.
45+
"""
46+
47+
if isinstance(self.image, np.ndarray):
48+
img = self.image
49+
elif isinstance(self.image, u.quantity.Quantity):
50+
img = self.image.value
51+
else: # NDData, including CCDData and Spectrum1D
52+
img = self.image.data
53+
54+
# mask and uncertainty are set as None when they aren't specified upon
55+
# creating a Spectrum1D object, so we must check whether these
56+
# attributes are absent *and* whether they are present but set as None
57+
if getattr(self.image, 'mask', None) is not None:
58+
mask = self.image.mask
59+
else:
60+
mask = np.ma.masked_invalid(img).mask
61+
62+
if getattr(self.image, 'uncertainty', None) is not None:
63+
uncertainty = self.image.uncertainty
64+
else:
65+
uncertainty = VarianceUncertainty(np.ones(img.shape))
66+
67+
unit = getattr(self.image, 'unit', u.Unit('DN')) # or u.Unit()?
68+
69+
spectral_axis = getattr(self.image, 'spectral_axis',
70+
(np.arange(img.shape[self._disp_axis])
71+
if hasattr(self, '_disp_axis')
72+
else np.arange(img.shape[1])) * u.pix)
73+
74+
self.image = Spectrum1D(img * unit, spectral_axis=spectral_axis,
75+
uncertainty=uncertainty, mask=mask)
4576

4677
@property
4778
def shape(self):
@@ -115,7 +146,7 @@ def set_position(self, trace_pos):
115146
Position of the trace
116147
"""
117148
self.trace_pos = trace_pos
118-
self.trace = np.ones_like(self.image[0]) * self.trace_pos
149+
self.trace = np.ones_like(self.image.data[0]) * self.trace_pos
119150
self._bound_trace()
120151

121152

@@ -212,12 +243,10 @@ class KosmosTrace(Trace):
212243
def __post_init__(self):
213244
super()._parse_image()
214245

215-
# handle multiple image types and mask uncaught invalid values
216-
if isinstance(self.image, NDData):
217-
img = np.ma.masked_invalid(np.ma.masked_array(self.image.data,
218-
mask=self.image.mask))
219-
else:
220-
img = np.ma.masked_invalid(self.image)
246+
# mask any previously uncaught invalid values
247+
or_mask = np.logical_or(self.image.mask,
248+
np.ma.masked_invalid(self.image.data).mask)
249+
img = np.ma.masked_array(self.image.data, or_mask)
221250

222251
# validate arguments
223252
valid_peak_methods = ('gaussian', 'centroid', 'max')

0 commit comments

Comments
 (0)