Skip to content

Commit 2b4dfc1

Browse files
committed
fix input for background.traces, raise error in FlatTrace for negative trace
fix docstring fix for masked values in FitTrace . . . .. , . .
1 parent c2d1715 commit 2b4dfc1

File tree

8 files changed

+905
-123
lines changed

8 files changed

+905
-123
lines changed

CHANGES.rst

+6-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ Bug Fixes
3939
peaks. Previously for Gaussian, the entire fit failed. [#205, #206]
4040

4141
- Fixed input of `traces` in `Background`. Added a condition to 'FlatTrace' that
42-
trace position must be a positive number. [#211]
42+
43+
- Fix in FitTrace to set fully-masked column bin peaks to NaN. Previously, for
44+
peak_method='max' these were set to 0.0, and for peak_method='centroid' they
45+
were set to the number of rows in the image, biasing the final fit to all bin
46+
peaks. [#206]
47+
4348

4449
Other changes
4550
^^^^^^^^^^^^^

specreduce/background.py

+76-14
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ class Background(_ImageParser):
3232
----------
3333
image : `~astropy.nddata.NDData`-like or array-like
3434
image with 2-D spectral image data
35-
traces : trace, int, float (single or list)
35+
traces : List, `tracing.Trace`, int, float
3636
Individual or list of trace object(s) (or integers/floats to define
37-
FlatTraces) to extract the background. If None, a FlatTrace at the
37+
FlatTraces) to extract the background. If None, a `FlatTrace` at the
3838
center of the image (according to `disp_axis`) will be used.
3939
width : float
4040
width of extraction aperture in pixels
@@ -44,8 +44,22 @@ class Background(_ImageParser):
4444
pixels.
4545
disp_axis : int
4646
dispersion axis
47+
[default: 1]
4748
crossdisp_axis : int
4849
cross-dispersion axis
50+
[default: 0]
51+
mask_treatment : string, optional
52+
The method for handling masked or non-finite data. Choice of `filter`,
53+
`omit`, or `zero-fill`. If `filter` is chosen, masked and non-finite
54+
data will not contribute to the background statistic that is calculated
55+
in each column along `disp_axis`. If `omit` is chosen, columns along
56+
disp_axis with any masked/non-finite data values will be fully masked
57+
(i.e, 2D mask is collapsed to 1D and applied). If `zero-fill` is chosen,
58+
masked/non-finite data will be replaced with 0.0 in the input image,
59+
and the mask will then be dropped. For all three options, the input mask
60+
(optional on input NDData object) will be combined with a mask generated
61+
from any non-finite values in the image data.
62+
[default: ``filter``]
4963
"""
5064
# required so numpy won't call __rsub__ on individual elements
5165
# https://stackoverflow.com/a/58409215
@@ -57,6 +71,8 @@ class Background(_ImageParser):
5771
statistic: str = 'average'
5872
disp_axis: int = 1
5973
crossdisp_axis: int = 0
74+
mask_treatment : str = 'filter'
75+
_valid_mask_treatment_methods = ('filter', 'omit', 'zero-fill')
6076

6177
# TO-DO: update bkg_array with Spectrum1D alternative (is bkg_image enough?)
6278
bkg_array = deprecated_attribute('bkg_array', '1.3')
@@ -82,9 +98,29 @@ def __post_init__(self):
8298
dispersion axis
8399
crossdisp_axis : int
84100
cross-dispersion axis
101+
mask_treatment : string
102+
The method for handling masked or non-finite data. Choice of `filter`,
103+
`omit`, or `zero-fill`. If `filter` is chosen, masked/non-finite data
104+
will be filtered during the fit to each bin/column (along disp. axis) to
105+
find the peak. If `omit` is chosen, columns along disp_axis with any
106+
masked/non-finite data values will be fully masked (i.e, 2D mask is
107+
collapsed to 1D and applied). If `zero-fill` is chosen, masked/non-finite
108+
data will be replaced with 0.0 in the input image, and the mask will then
109+
be dropped. For all three options, the input mask (optional on input
110+
NDData object) will be combined with a mask generated from any non-finite
111+
values in the image data.
85112
"""
113+
114+
# Parse image, including masked/nonfinite data handling based on
115+
# choice of `mask_treatment`. Any uncaught nonfinte data values will be
116+
# masked as well. Returns a Spectrum1D.
86117
self.image = self._parse_image(self.image)
87118

119+
# always work with masked array, even if there is no masked
120+
# or nonfinite data, in case padding is needed. if not, mask will be
121+
# dropped at the end and a regular array will be returned.
122+
img = np.ma.masked_array(self.image.data, self.image.mask)
123+
88124
if self.width < 0:
89125
raise ValueError("width must be positive")
90126
if self.width == 0:
@@ -95,9 +131,13 @@ def __post_init__(self):
95131

96132
bkg_wimage = np.zeros_like(self.image.data, dtype=np.float64)
97133
for trace in self.traces:
134+
# note: ArrayTrace can have masked values, but if it does a MaskedArray
135+
# will be returned so this should be reflected in the window size here
136+
# (i.e, np.nanmax is not required.)
98137
windows_max = trace.trace.data.max() + self.width/2
99138
windows_min = trace.trace.data.min() - self.width/2
100-
if windows_max >= self.image.shape[self.crossdisp_axis]:
139+
140+
if windows_max > self.image.shape[self.crossdisp_axis]:
101141
warnings.warn("background window extends beyond image boundaries " +
102142
f"({windows_max} >= {self.image.shape[self.crossdisp_axis]})")
103143
if windows_min < 0:
@@ -115,27 +155,26 @@ def __post_init__(self):
115155
raise ValueError("background regions overlapped")
116156
if np.any(np.sum(bkg_wimage, axis=self.crossdisp_axis) == 0):
117157
raise ValueError("background window does not remain in bounds across entire dispersion axis") # noqa
158+
# check if image contained within background window is fully-nonfinite and raise an error
159+
if np.all(img.mask[bkg_wimage > 0]):
160+
raise ValueError("Image is fully masked within background window determined by `width`.")
118161

119162
if self.statistic == 'median':
120163
# make it clear in the expose image that partial pixels are fully-weighted
121164
bkg_wimage[bkg_wimage > 0] = 1
122165

123166
self.bkg_wimage = bkg_wimage
124167

125-
# mask user-highlighted and invalid values (if any) before taking stats
126-
or_mask = (np.logical_or(~np.isfinite(self.image.data), self.image.mask)
127-
if self.image.mask is not None
128-
else ~np.isfinite(self.image.data))
129-
130168
if self.statistic == 'average':
131-
image_ma = np.ma.masked_array(self.image.data, mask=or_mask)
132-
self._bkg_array = np.ma.average(image_ma,
169+
self._bkg_array = np.ma.average(img,
133170
weights=self.bkg_wimage,
134-
axis=self.crossdisp_axis).data
171+
axis=self.crossdisp_axis)
172+
135173
elif self.statistic == 'median':
136-
med_mask = np.logical_or(self.bkg_wimage == 0, or_mask)
137-
image_ma = np.ma.masked_array(self.image.data, mask=med_mask)
138-
self._bkg_array = np.ma.median(image_ma, axis=self.crossdisp_axis).data
174+
# combine where background weight image is 0 with image masked (which already
175+
# accounts for non-finite data that wasn't already masked)
176+
img.mask = np.logical_or(self.bkg_wimage == 0, self.image.mask)
177+
self._bkg_array = np.ma.median(img, axis=self.crossdisp_axis)
139178
else:
140179
raise ValueError("statistic must be 'average' or 'median'")
141180

@@ -204,7 +243,19 @@ def two_sided(cls, image, trace_object, separation, **kwargs):
204243
dispersion axis
205244
crossdisp_axis : int
206245
cross-dispersion axis
246+
mask_treatment : string
247+
The method for handling masked or non-finite data. Choice of `filter`,
248+
`omit`, or `zero-fill`. If `filter` is chosen, masked/non-finite data
249+
will be filtered during the fit to each bin/column (along disp. axis) to
250+
find the peak. If `omit` is chosen, columns along disp_axis with any
251+
masked/non-finite data values will be fully masked (i.e, 2D mask is
252+
collapsed to 1D and applied). If `zero-fill` is chosen, masked/non-finite
253+
data will be replaced with 0.0 in the input image, and the mask will then
254+
be dropped. For all three options, the input mask (optional on input
255+
NDData object) will be combined with a mask generated from any non-finite
256+
values in the image data.
207257
"""
258+
208259
image = _ImageParser._get_data_from_image(image) if image is not None else cls.image
209260
kwargs['traces'] = [trace_object-separation, trace_object+separation]
210261
return cls(image=image, **kwargs)
@@ -241,6 +292,17 @@ def one_sided(cls, image, trace_object, separation, **kwargs):
241292
dispersion axis
242293
crossdisp_axis : int
243294
cross-dispersion axis
295+
mask_treatment : string
296+
The method for handling masked or non-finite data. Choice of `filter`,
297+
`omit`, or `zero-fill`. If `filter` is chosen, masked/non-finite data
298+
will be filtered during the fit to each bin/column (along disp. axis) to
299+
find the peak. If `omit` is chosen, columns along disp_axis with any
300+
masked/non-finite data values will be fully masked (i.e, 2D mask is
301+
collapsed to 1D and applied). If `zero-fill` is chosen, masked/non-finite
302+
data will be replaced with 0.0 in the input image, and the mask will then
303+
be dropped. For all three options, the input mask (optional on input
304+
NDData object) will be combined with a mask generated from any non-finite
305+
values in the image data.
244306
"""
245307
image = _ImageParser._get_data_from_image(image) if image is not None else cls.image
246308
kwargs['traces'] = [trace_object+separation]

specreduce/core.py

+115-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Licensed under a 3-clause BSD style license - see LICENSE.rst
22

3+
from copy import deepcopy
34
import inspect
45
from dataclasses import dataclass
56

@@ -53,14 +54,20 @@ def _parse_image(self, image, disp_axis=1):
5354

5455
img = self._get_data_from_image(image)
5556

56-
# mask and uncertainty are set as None when they aren't specified upon
57-
# creating a Spectrum1D object, so we must check whether these
58-
# attributes are absent *and* whether they are present but set as None
59-
if getattr(image, 'mask', None) is not None:
60-
mask = image.mask
61-
else:
62-
mask = ~np.isfinite(img)
57+
mask = getattr(image, 'mask', None)
58+
59+
# next, handle masked and nonfinite data in image.
60+
# A mask will be created from any nonfinite image data, and combined
61+
# with any additional 'mask' passed in. If image is being parsed within
62+
# a specreduce operation that has 'mask_treatment' options, this will be
63+
# handled as well. Note that input data may be modified if a fill value
64+
# is chosen to handle masked data. The returned image will always have
65+
# `image.mask` even if there are no nonfinte or masked values.
66+
img, mask = self._mask_and_nonfinite_data_handling(image=img, mask=mask)
6367

68+
# mask (handled above) and uncertainty are set as None when they aren't
69+
# specified upon creating a Spectrum1D object, so we must check whether
70+
# these attributes are absent *and* whether they are present but set as None
6471
if getattr(image, 'uncertainty', None) is not None:
6572
uncertainty = image.uncertainty
6673
else:
@@ -71,8 +78,107 @@ def _parse_image(self, image, disp_axis=1):
7178
spectral_axis = getattr(image, 'spectral_axis',
7279
np.arange(img.shape[disp_axis]) * u.pix)
7380

74-
return Spectrum1D(img * unit, spectral_axis=spectral_axis,
75-
uncertainty=uncertainty, mask=mask)
81+
82+
img = Spectrum1D(img * unit, spectral_axis=spectral_axis,
83+
uncertainty=uncertainty, mask=mask)
84+
85+
return img
86+
87+
@staticmethod
88+
def _get_data_from_image(image):
89+
"""Extract data array from various input types for `image`.
90+
Retruns `np.ndarray` of image data."""
91+
92+
if isinstance(image, u.quantity.Quantity):
93+
img = image.value
94+
if isinstance(image, np.ndarray):
95+
img = image
96+
else: # NDData, including CCDData and Spectrum1D
97+
img = image.data
98+
return img
99+
100+
def _mask_and_nonfinite_data_handling(self, image, mask):
101+
"""
102+
This function handles the treatment of masked and nonfinite data,
103+
including input validation.
104+
105+
All operations in Specreduce can take in a mask for the data as
106+
part of the input NDData. Additionally, any non-finite values in the
107+
data that aren't in the user-supplied mask will be combined bitwise
108+
with the input mask.
109+
110+
There are three options currently implemented for the treatment
111+
of masked and nonfinite data - filter, omit, and zero-fill.
112+
Depending on the step, all or a subset of these three options are valid.
113+
114+
"""
115+
116+
# valid options depend on Specreduce step, and are set accordingly there
117+
# for steps that this isn't implemeted for yet, default to 'filter',
118+
# which will return unmodified input image and mask
119+
mask_treatment = getattr(self, 'mask_treatment', 'filter')
120+
121+
# make sure chosen option is valid. if _valid_mask_treatment_methods
122+
# is not an attribue, proceed with 'filter' to return back inupt data
123+
# and mask that is combined with nonfinite data.
124+
if mask_treatment is not None: # None in operations where masks aren't relevant (e.g FlatTrace)
125+
valid_mask_treatment_methods = getattr(self, '_valid_mask_treatment_methods', ['filter'])
126+
if mask_treatment not in valid_mask_treatment_methods:
127+
raise ValueError(f"`mask_treatment` must be one of {valid_mask_treatment_methods}")
128+
129+
# make sure there is always a 'mask', even when all data is unmasked and finite.
130+
if mask is not None:
131+
mask = self.image.mask
132+
# always mask any previously uncaught nonfinite values in image array
133+
# combining these with the (optional) user-provided mask on `image.mask`
134+
mask = np.logical_or(mask, ~np.isfinite(image))
135+
else:
136+
mask = ~np.isfinite(image)
137+
138+
# if mask option is the default 'filter' option, or None,
139+
# nothing needs to be done. input mask (combined with nonfinite data)
140+
# remains with data as-is.
141+
142+
if mask_treatment == 'zero-fill':
143+
# make a copy of the input image since we will be modifying it
144+
image = deepcopy(image)
145+
146+
# if mask_treatment is 'zero_fill', set masked values to zero in
147+
# image data and drop image.mask. note that this is done after
148+
# _combine_mask_with_nonfinite_from_data, so non-finite values in
149+
# data (which are now in the mask) will also be set to zero.
150+
# set masked values to zero
151+
image[mask] = 0.
152+
153+
# masked array with no masked values, so accessing image.mask works
154+
# but we don't need the actual mask anymore since data has been set to 0
155+
mask = np.zeros(image.shape)
156+
157+
elif mask_treatment == 'omit':
158+
# collapse 2d mask (after accounting for addl non-finite values in
159+
# data) to a 1d mask, along dispersion axis, to fully mask columns
160+
# that have any masked values.
161+
162+
# must have a crossdisp_axis specified to use 'omit' optoin
163+
if hasattr(self, 'crossdisp_axis'):
164+
crossdisp_axis = self.crossdisp_axis
165+
if hasattr(self, '_crossdisp_axis'):
166+
crossdisp_axis = self._crossdisp_axis
167+
168+
# create a 1d mask along crossdisp axis - if any column has a single nan,
169+
# the entire column should be masked
170+
reduced_mask = np.logical_or.reduce(mask,
171+
axis=crossdisp_axis)
172+
173+
# back to a 2D mask
174+
shape = (image.shape[0], 1) if crossdisp_axis == 0 else (1, image.shape[1])
175+
mask = np.tile(reduced_mask, shape)
176+
177+
# check for case where entire image is masked.
178+
if mask.all():
179+
raise ValueError('Image is fully masked. Check for invalid values.')
180+
181+
return image, mask
76182

77183
@staticmethod
78184
def _get_data_from_image(image):

0 commit comments

Comments
 (0)