Skip to content

Commit 0ea84c9

Browse files
authored
Merge pull request #245 from hpparvi/weighted_boxcar_extraction
Boxcar extraction with non-finite pixels
2 parents 180c081 + bf647cd commit 0ea84c9

File tree

4 files changed

+94
-56
lines changed

4 files changed

+94
-56
lines changed

CHANGES.rst

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,16 @@ Other changes
1818

1919
New Features
2020
^^^^^^^^^^^^
21+
2122
- Added the ``mask_treatment`` parameter to Background, Trace, and Boxcar Extract
22-
operations to handle non-finite data and boolean masks. Choice of ``filter``,
23-
``omit``, or ``zero-fill``. [#216]
23+
operations to handle non-finite data and boolean masks. Available options are
24+
``filter``, ``omit``, or ``zero-fill``, with ``exclude`` additionally available
25+
for BoxcarExtract. [#216]
26+
27+
- Modified BoxcarExtract to ignore non-finite pixels when ``mask_treatment`` is set
28+
to ``exclude``; otherwise, non-finite values are propagated. Boxcar extraction is
29+
now carried out as a weighed sum over the window. When no non-finite values are
30+
present, the extracted spectra remain unchanged from the previous behaviour.
2431

2532
API Changes
2633
^^^^^^^^^^^

docs/extraction_quickstart.rst

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,17 @@ then this will be used. Otherwise, the ``variance`` parameter must be set.::
8888
extract = specreduce.extract.HorneExtract(image-bg, trace, variance=var_array)
8989

9090
An optional mask array for the image may be supplied to HorneExtract as well.
91-
This follows the same convention and can either be attacted to ``image`` if it
92-
is and ``astropy.NDData`` object, or supplied as a keyword argrument. Note that
93-
any wavelengths columns containing any masked values will be omitted from the
94-
extraction.
91+
This follows the same convention and can either be attached to ``image`` if it
92+
is an ``astropy.NDData`` object, or supplied as a keyword argument.
93+
94+
The extraction methods automatically detect non-finite pixels in the input
95+
image and combine them with the user-supplied mask to prevent them from biasing the
96+
extraction. In the boxcar extraction, the treatment of these pixels is controlled by
97+
the ``mask_treatment`` option. When set to ``exclude`` (the default), non-finite
98+
pixels within the extraction window are excluded from the extraction, and the extracted
99+
flux is scaled according to the effective number of unmasked pixels. When using other
100+
options (``filter`` or ``omit``), the non-finite values may be propagated or treated
101+
differently as documented in the API.
95102

96103
The previous examples in this section show how to initialize the BoxcarExtract
97104
or HorneExtract objects with their required parameters. To extract the 1D
@@ -111,8 +118,8 @@ or, for example to override the original ``trace_object``::
111118
Spatial profile options
112119
-----------------------
113120
The Horne algorithm provides two options for fitting the spatial profile to the
114-
cross_dispersion direction of the source: a Gaussian fit (default),
115-
or an empirical 'interpolated_profile' option.
121+
cross dispersion direction of the source: a Gaussian fit (default),
122+
or an empirical ``interpolated_profile`` option.
116123

117124
If the default Gaussian option is used, an optional background model may be
118125
supplied as well (default is a 2D Polynomial) to account

specreduce/extract.py

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,15 @@ class BoxcarExtract(SpecreduceOperation):
150150
cross-dispersion axis
151151
mask_treatment
152152
The method for handling masked or non-finite data. Choice of ``filter``,
153-
``omit``, or ``zero-fill``. If `filter` is chosen, the mask is ignored
153+
``omit``, or ``exclude``. If ``filter`` is chosen, the mask is ignored
154154
and the non-finite data will passed to the extraction as is. If ``omit``
155155
is chosen, columns along disp_axis with any masked or non-finite data
156156
values will be fully masked (i.e, 2D mask is collapsed to 1D and applied).
157-
If ``zero-fill`` is chosen, masked and non-finite data will be replaced
158-
with 0.0 in the input image, and the mask will then be dropped.
159-
For all three options, the input mask (optional on input NDData object)
160-
will be combined with a mask generated from any non-finite values in the
161-
image data.
157+
If ``exclude`` is chosen, masked and non-finite data will be excluded
158+
from the extraction and the spectrum extraction is carried out as a
159+
weighted sum. For all three options, the input mask (optional on input
160+
NDData object) will be combined with a mask generated from any non-finite
161+
values in the image data.
162162
163163
Returns
164164
-------
@@ -171,8 +171,9 @@ class BoxcarExtract(SpecreduceOperation):
171171
disp_axis: int = 1
172172
crossdisp_axis: int = 0
173173
# TODO: should disp_axis and crossdisp_axis be defined in the Trace object?
174-
mask_treatment: Literal['filter', 'omit', 'zero-fill'] = 'filter'
175-
_valid_mask_treatment_methods = ('filter', 'omit', 'zero-fill')
174+
# TODO: should the 'filter' option be changed to 'propagate'
175+
mask_treatment: Literal['filter', 'omit', 'exclude'] = 'exclude'
176+
_valid_mask_treatment_methods = ('filter', 'omit', 'exclude')
176177

177178
@property
178179
def spectrum(self):
@@ -182,61 +183,63 @@ def __call__(self, image: NDData | None = None,
182183
trace: Trace | None = None,
183184
width: float | None = None,
184185
disp_axis: int | None = None,
185-
crossdisp_axis: int | None = None):
186+
crossdisp_axis: int | None = None) -> Spectrum1D:
186187
"""
187188
Extract the 1D spectrum using the boxcar method.
188189
189190
Parameters
190191
----------
191-
image : `~astropy.nddata.NDData`-like or array-like, required
192-
image with 2-D spectral image data
193-
trace : Trace, required
194-
trace object
195-
width : float, optional
196-
width of extraction aperture in pixels [default: 5]
197-
disp_axis : int, optional
198-
dispersion axis [default: 1]
199-
crossdisp_axis : int, optional
200-
cross-dispersion axis [default: 0]
192+
image
193+
The image with 2-D spectral image data
194+
trace
195+
The trace object
196+
width
197+
The width of extraction aperture in pixels
198+
disp_axis
199+
The dispersion axis
200+
crossdisp_axis
201+
The cross-dispersion axis
201202
202203
Returns
203204
-------
204-
spec : `~specutils.Spectrum1D`
205+
spec
205206
The extracted 1d spectrum with flux expressed in the same
206207
units as the input image, or u.DN, and pixel units
207208
"""
208209
image = image if image is not None else self.image
209-
trace = trace if trace is not None else self.trace_object
210-
width = width if width is not None else self.width
211-
disp_axis = disp_axis if disp_axis is not None else self.disp_axis
212-
crossdisp_axis = crossdisp_axis if crossdisp_axis is not None else self.crossdisp_axis
210+
trace = trace or self.trace_object
211+
width = width or self.width
212+
disp_axis = disp_axis or self.disp_axis
213+
cdisp_axis = crossdisp_axis or self.crossdisp_axis
214+
mask_mapping = {'filter': 'filter', 'exclude': 'filter', 'omit': 'omit'}
215+
216+
if width <= 0:
217+
raise ValueError("The window width must be positive")
213218

214219
# Parse image, including masked/nonfinite data handling based on
215-
# choice of `mask_treatment`, which for BoxcarExtract can be filter, zero-fill, or
216-
# omit. non-finite data will be masked, always. Returns a Spectrum1D.
220+
# choice of `mask_treatment`, which for BoxcarExtract can be filter, omit, or
221+
# exclude. non-finite data will be masked, always. Returns a Spectrum1D.
217222
self.image = self._parse_image(image, disp_axis=disp_axis,
218-
mask_treatment=self.mask_treatment)
219-
220-
# # _parse_image returns a Spectrum1D. convert this to a masked array
221-
# # for ease of calculations here (even if there is no masked data).
222-
# img = np.ma.masked_array(self.image.data, self.image.mask)
223+
mask_treatment=mask_mapping[self.mask_treatment])
224+
225+
# Spectrum extraction
226+
# ===================
227+
# Assign no weight to non-finite pixels outside the window. Non-finite pixels inside
228+
# the window will be propagated to the sum if mask treatment is either ``filter`` or
229+
# ``omit`` or excluded if the chosen mask treatment option is ``exclude``. In the
230+
# latter case, the flux is calculated as the average of the non-masked pixels inside
231+
# the window multiplied by the window width.
232+
window_weights = _ap_weight_image(trace, width, disp_axis, cdisp_axis, self.image.shape)
233+
234+
if self.mask_treatment == 'exclude':
235+
image_cleaned = np.where(~self.image.mask, self.image.data*window_weights, 0.0)
236+
weights = np.where(~self.image.mask, window_weights, 0.0)
237+
spectrum = image_cleaned.sum(axis=cdisp_axis) / weights.sum(axis=cdisp_axis) * width
238+
else:
239+
image_windowed = np.where(window_weights, self.image.data*window_weights, 0.0)
240+
spectrum = np.sum(image_windowed, axis=cdisp_axis)
223241

224-
if width <= 0:
225-
raise ValueError("width must be positive")
226-
227-
# weight image to use for extraction
228-
wimg = _ap_weight_image(trace,
229-
width,
230-
disp_axis,
231-
crossdisp_axis,
232-
self.image.shape)
233-
234-
# extract, assigning no weight to non-finite pixels outside the window
235-
# (non-finite pixels inside the window will still make it into the sum)
236-
image_windowed = np.where(wimg, self.image.data*wimg, 0)
237-
ext1d = np.sum(image_windowed, axis=crossdisp_axis)
238-
return Spectrum1D(ext1d * self.image.unit,
239-
spectral_axis=self.image.spectral_axis)
242+
return Spectrum1D(spectrum * self.image.unit, spectral_axis=self.image.spectral_axis)
240243

241244

242245
@dataclass

specreduce/tests/test_extract.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,35 @@ def test_boxcar_extraction(mk_test_img):
8383
assert np.allclose(spectrum.flux.value, np.full_like(spectrum.flux.value, 67.15))
8484

8585

86+
def test_boxcar_nonfinite_handling(mk_test_img):
87+
image = mk_test_img
88+
image.data[14, 2] = np.nan
89+
image.data[14, 4] = np.inf
90+
91+
trace = FlatTrace(image, 15.0)
92+
boxcar = BoxcarExtract(image, trace, width=6, mask_treatment='filter')
93+
spectrum = boxcar()
94+
target = np.full_like(spectrum.flux.value, 90.)
95+
target[2] = np.nan
96+
target[4] = np.inf
97+
np.testing.assert_equal(spectrum.flux.value, target)
98+
99+
trace = FlatTrace(image, 15.0)
100+
boxcar = BoxcarExtract(image, trace, width=6, mask_treatment='exclude')
101+
spectrum = boxcar()
102+
target = np.full_like(spectrum.flux.value, 90.)
103+
target[[2, 4]] = 91.2
104+
np.testing.assert_allclose(spectrum.flux.value, target)
105+
106+
86107
def test_boxcar_outside_image_condition(mk_test_img):
87108
#
88109
# Trace is such that extraction aperture lays partially outside the image
89110
#
90111
image = mk_test_img
91112

92113
trace = FlatTrace(image, 3.0)
93-
boxcar = BoxcarExtract(image, trace)
114+
boxcar = BoxcarExtract(image, trace, mask_treatment='filter')
94115

95116
spectrum = boxcar(width=10.)
96117
assert np.allclose(spectrum.flux.value, np.full_like(spectrum.flux.value, 32.0))

0 commit comments

Comments
 (0)