Skip to content

Commit 7043f78

Browse files
authored
Merge pull request #159 from ojustino/background-handle-nans
Prevent (most) NaN propagation in `Background` and `BoxcarExtract`
2 parents 7be9a76 + bd72fae commit 7043f78

File tree

4 files changed

+52
-20
lines changed

4 files changed

+52
-20
lines changed

CHANGES.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ API Changes
1010
Bug Fixes
1111
^^^^^^^^^
1212

13+
- Output 1D spectra from Background no longer include NaNs. Output 1D
14+
spectra from BoxcarExtract no longer include NaNs when none are present
15+
in the extraction window. NaNs in the window will still propagate to
16+
BoxcarExtract's extracted 1D spectrum. [#159]
17+
18+
- Backgrounds using median statistic properly ignore zero-weighted pixels
19+
[#159]
20+
1321

1422
1.3.0 (2022-12-05)
1523
------------------
@@ -48,6 +56,7 @@ Bug Fixes
4856
function after change to upstream API. This will make specreduce
4957
be compatible with numpy 1.24 or later. [#155]
5058

59+
5160
1.2.0 (2022-10-04)
5261
------------------
5362

specreduce/background.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,20 @@ def _to_trace(trace):
133133

134134
self.bkg_wimage = bkg_wimage
135135

136+
# mask user-highlighted and invalid values (if any) before taking stats
137+
or_mask = (np.logical_or(~np.isfinite(self.image.data), self.image.mask)
138+
if self.image.mask is not None
139+
else ~np.isfinite(self.image.data))
140+
136141
if self.statistic == 'average':
137-
self._bkg_array = np.average(self.image.data,
138-
weights=self.bkg_wimage,
139-
axis=self.crossdisp_axis)
142+
image_ma = np.ma.masked_array(self.image.data, mask=or_mask)
143+
self._bkg_array = np.ma.average(image_ma,
144+
weights=self.bkg_wimage,
145+
axis=self.crossdisp_axis).data
140146
elif self.statistic == 'median':
141-
med_image = self.image.data.copy()
142-
med_image[np.where(self.bkg_wimage) == 0] = np.nan
143-
self._bkg_array = np.nanmedian(med_image, axis=self.crossdisp_axis)
147+
med_mask = np.logical_or(self.bkg_wimage == 0, or_mask)
148+
image_ma = np.ma.masked_array(self.image.data, mask=med_mask)
149+
self._bkg_array = np.ma.median(image_ma, axis=self.crossdisp_axis).data
144150
else:
145151
raise ValueError("statistic must be 'average' or 'median'")
146152

@@ -232,7 +238,7 @@ def bkg_image(self, image=None):
232238
233239
Returns
234240
-------
235-
Spectrum1D object with same shape as ``image``.
241+
`~specutils.Spectrum1D` object with same shape as ``image``.
236242
"""
237243
image = self._parse_image(image)
238244
return Spectrum1D(np.tile(self._bkg_array,
@@ -261,11 +267,11 @@ def bkg_spectrum(self, image=None):
261267
bkg_image = self.bkg_image(image)
262268

263269
try:
264-
return bkg_image.collapse(np.sum, axis=self.crossdisp_axis)
270+
return bkg_image.collapse(np.nansum, axis=self.crossdisp_axis)
265271
except u.UnitTypeError:
266272
# can't collapse with a spectral axis in pixels because
267273
# SpectralCoord only allows frequency/wavelength equivalent units...
268-
ext1d = np.sum(bkg_image.flux, axis=self.crossdisp_axis)
274+
ext1d = np.nansum(bkg_image.flux, axis=self.crossdisp_axis)
269275
return Spectrum1D(ext1d, bkg_image.spectral_axis)
270276

271277
def sub_image(self, image=None):
@@ -280,7 +286,7 @@ def sub_image(self, image=None):
280286
281287
Returns
282288
-------
283-
array with same shape as ``image``
289+
`~specutils.Spectrum1D` object with same shape as ``image``.
284290
"""
285291
image = self._parse_image(image)
286292

@@ -313,11 +319,11 @@ def sub_spectrum(self, image=None):
313319
sub_image = self.sub_image(image=image)
314320

315321
try:
316-
return sub_image.collapse(np.sum, axis=self.crossdisp_axis)
322+
return sub_image.collapse(np.nansum, axis=self.crossdisp_axis)
317323
except u.UnitTypeError:
318324
# can't collapse with a spectral axis in pixels because
319325
# SpectralCoord only allows frequency/wavelength equivalent units...
320-
ext1d = np.sum(sub_image.flux, axis=self.crossdisp_axis)
326+
ext1d = np.nansum(sub_image.flux, axis=self.crossdisp_axis)
321327
return Spectrum1D(ext1d, spectral_axis=sub_image.spectral_axis)
322328

323329
def __rsub__(self, image):

specreduce/extract.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,10 @@ def __call__(self, image=None, trace_object=None, width=None,
236236
crossdisp_axis,
237237
self.image.shape)
238238

239-
# extract
240-
ext1d = np.sum(self.image.data * wimg, axis=crossdisp_axis)
239+
# extract, assigning no weight to non-finite pixels outside the window
240+
# (non-finite pixels inside the window will still make it into the sum)
241+
image_windowed = np.where(wimg, self.image.data*wimg, 0)
242+
ext1d = np.sum(image_windowed, axis=crossdisp_axis)
241243
return Spectrum1D(ext1d * self.image.unit,
242244
spectral_axis=self.image.spectral_axis)
243245

specreduce/tests/test_background.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
# Test image is comprised of 30 rows with 10 columns each. Row content
1414
# is row index itself. This makes it easy to predict what should be the
1515
# value extracted from a region centered at any arbitrary Y position.
16-
image = np.ones(shape=(30, 10))
17-
for j in range(image.shape[0]):
18-
image[j, ::] *= j
19-
image = Spectrum1D(image * u.DN,
20-
uncertainty=VarianceUncertainty(np.ones_like(image)))
16+
img = np.ones(shape=(30, 10))
17+
for j in range(img.shape[0]):
18+
img[j, ::] *= j
19+
image = Spectrum1D(img * u.DN,
20+
uncertainty=VarianceUncertainty(np.ones_like(img)))
2121
image_um = Spectrum1D(image.flux,
2222
spectral_axis=np.arange(image.data.shape[1]) * u.um,
2323
uncertainty=VarianceUncertainty(np.ones_like(image.data)))
@@ -28,7 +28,7 @@ def test_background():
2828
# Try combinations of extraction center, and even/odd
2929
# extraction aperture sizes.
3030
#
31-
trace_pos = 15.0
31+
trace_pos = 15
3232
trace = FlatTrace(image, trace_pos)
3333
bkg_sep = 5
3434
bkg_width = 2
@@ -72,10 +72,25 @@ def test_background():
7272
assert isinstance(bkg_spec, Spectrum1D)
7373
sub_spec = bg1.sub_spectrum()
7474
assert isinstance(sub_spec, Spectrum1D)
75+
7576
# test that width==0 results in no background
7677
bg = Background.two_sided(image, trace, bkg_sep, width=0)
7778
assert np.all(bg.bkg_image().flux == 0)
7879

80+
# test that any NaNs in input image (whether in or outside the window) don't
81+
# propagate to _bkg_array (which affects bkg_image and sub_image methods) or
82+
# the final 1D spectra.
83+
img[0, 0] = np.nan # out of window
84+
img[trace_pos, 0] = np.nan # in window
85+
stats = ['average', 'median']
86+
87+
for st in stats:
88+
bg = Background(img, trace-bkg_sep, width=bkg_width, statistic=st)
89+
assert np.isnan(bg.image.flux).sum() == 2
90+
assert np.isnan(bg._bkg_array).sum() == 0
91+
assert np.isnan(bg.bkg_spectrum().flux).sum() == 0
92+
assert np.isnan(bg.sub_spectrum().flux).sum() == 0
93+
7994

8095
def test_warnings_errors():
8196
# image.shape (30, 10)

0 commit comments

Comments
 (0)