Skip to content

Commit

Permalink
fix input for background.traces, raise error in FlatTrace for negativ…
Browse files Browse the repository at this point in the history
…e trace

.

.

.

..

,

.

.

code style

.
  • Loading branch information
cshanahan1 committed Sep 18, 2024
1 parent 6b8e995 commit 12eb705
Show file tree
Hide file tree
Showing 8 changed files with 902 additions and 124 deletions.
7 changes: 6 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ Bug Fixes
peaks. Previously for Gaussian, the entire fit failed. [#205, #206]

- Fixed input of `traces` in `Background`. Added a condition to 'FlatTrace' that
trace position must be a positive number. [#211]

- Fix in FitTrace to set fully-masked column bin peaks to NaN. Previously, for
peak_method='max' these were set to 0.0, and for peak_method='centroid' they
were set to the number of rows in the image, biasing the final fit to all bin
peaks. [#206]


Other changes
^^^^^^^^^^^^^
Expand Down
90 changes: 76 additions & 14 deletions specreduce/background.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ class Background(_ImageParser):
----------
image : `~astropy.nddata.NDData`-like or array-like
image with 2-D spectral image data
traces : trace, int, float (single or list)
traces : List, `tracing.Trace`, int, float
Individual or list of trace object(s) (or integers/floats to define
FlatTraces) to extract the background. If None, a FlatTrace at the
FlatTraces) to extract the background. If None, a `FlatTrace` at the
center of the image (according to `disp_axis`) will be used.
width : float
width of extraction aperture in pixels
Expand All @@ -44,8 +44,22 @@ class Background(_ImageParser):
pixels.
disp_axis : int
dispersion axis
[default: 1]
crossdisp_axis : int
cross-dispersion axis
[default: 0]
mask_treatment : string, optional
The method for handling masked or non-finite data. Choice of `filter`,
`omit`, or `zero-fill`. If `filter` is chosen, masked and non-finite
data will not contribute to the background statistic that is calculated
in each column along `disp_axis`. If `omit` is chosen, columns along
disp_axis with any masked/non-finite data values will be fully masked
(i.e, 2D mask is collapsed to 1D and applied). If `zero-fill` is chosen,
masked/non-finite data will be replaced with 0.0 in the input image,
and the mask will then be dropped. For all three options, the input mask
(optional on input NDData object) will be combined with a mask generated
from any non-finite values in the image data.
[default: ``filter``]
"""
# required so numpy won't call __rsub__ on individual elements
# https://stackoverflow.com/a/58409215
Expand All @@ -57,6 +71,8 @@ class Background(_ImageParser):
statistic: str = 'average'
disp_axis: int = 1
crossdisp_axis: int = 0
mask_treatment: str = 'filter'
_valid_mask_treatment_methods = ('filter', 'omit', 'zero-fill')

# TO-DO: update bkg_array with Spectrum1D alternative (is bkg_image enough?)
bkg_array = deprecated_attribute('bkg_array', '1.3')
Expand All @@ -82,9 +98,29 @@ def __post_init__(self):
dispersion axis
crossdisp_axis : int
cross-dispersion axis
mask_treatment : string
The method for handling masked or non-finite data. Choice of `filter`,
`omit`, or `zero-fill`. If `filter` is chosen, masked/non-finite data
will be filtered during the fit to each bin/column (along disp. axis) to
find the peak. If `omit` is chosen, columns along disp_axis with any
masked/non-finite data values will be fully masked (i.e, 2D mask is
collapsed to 1D and applied). If `zero-fill` is chosen, masked/non-finite
data will be replaced with 0.0 in the input image, and the mask will then
be dropped. For all three options, the input mask (optional on input
NDData object) will be combined with a mask generated from any non-finite
values in the image data.
"""

# Parse image, including masked/nonfinite data handling based on
# choice of `mask_treatment`. Any uncaught nonfinte data values will be
# masked as well. Returns a Spectrum1D.
self.image = self._parse_image(self.image)

# always work with masked array, even if there is no masked
# or nonfinite data, in case padding is needed. if not, mask will be
# dropped at the end and a regular array will be returned.
img = np.ma.masked_array(self.image.data, self.image.mask)

Check warning on line 122 in specreduce/background.py

View check run for this annotation

Codecov / codecov/patch

specreduce/background.py#L122

Added line #L122 was not covered by tests

if self.width < 0:
raise ValueError("width must be positive")
if self.width == 0:
Expand All @@ -95,9 +131,13 @@ def __post_init__(self):

bkg_wimage = np.zeros_like(self.image.data, dtype=np.float64)
for trace in self.traces:
# note: ArrayTrace can have masked values, but if it does a MaskedArray
# will be returned so this should be reflected in the window size here
# (i.e, np.nanmax is not required.)
windows_max = trace.trace.data.max() + self.width/2
windows_min = trace.trace.data.min() - self.width/2
if windows_max >= self.image.shape[self.crossdisp_axis]:

if windows_max > self.image.shape[self.crossdisp_axis]:

Check warning on line 140 in specreduce/background.py

View check run for this annotation

Codecov / codecov/patch

specreduce/background.py#L140

Added line #L140 was not covered by tests
warnings.warn("background window extends beyond image boundaries " +
f"({windows_max} >= {self.image.shape[self.crossdisp_axis]})")
if windows_min < 0:
Expand All @@ -115,27 +155,26 @@ def __post_init__(self):
raise ValueError("background regions overlapped")
if np.any(np.sum(bkg_wimage, axis=self.crossdisp_axis) == 0):
raise ValueError("background window does not remain in bounds across entire dispersion axis") # noqa
# check if image contained within background window is fully-nonfinite and raise an error
if np.all(img.mask[bkg_wimage > 0]):
raise ValueError("Image is fully masked within background window determined by `width`.") # noqa

Check warning on line 160 in specreduce/background.py

View check run for this annotation

Codecov / codecov/patch

specreduce/background.py#L159-L160

Added lines #L159 - L160 were not covered by tests

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

self.bkg_wimage = bkg_wimage

# mask user-highlighted and invalid values (if any) before taking stats
or_mask = (np.logical_or(~np.isfinite(self.image.data), self.image.mask)
if self.image.mask is not None
else ~np.isfinite(self.image.data))

if self.statistic == 'average':
image_ma = np.ma.masked_array(self.image.data, mask=or_mask)
self._bkg_array = np.ma.average(image_ma,
self._bkg_array = np.ma.average(img,

Check warning on line 169 in specreduce/background.py

View check run for this annotation

Codecov / codecov/patch

specreduce/background.py#L169

Added line #L169 was not covered by tests
weights=self.bkg_wimage,
axis=self.crossdisp_axis).data
axis=self.crossdisp_axis)

elif self.statistic == 'median':
med_mask = np.logical_or(self.bkg_wimage == 0, or_mask)
image_ma = np.ma.masked_array(self.image.data, mask=med_mask)
self._bkg_array = np.ma.median(image_ma, axis=self.crossdisp_axis).data
# combine where background weight image is 0 with image masked (which already
# accounts for non-finite data that wasn't already masked)
img.mask = np.logical_or(self.bkg_wimage == 0, self.image.mask)
self._bkg_array = np.ma.median(img, axis=self.crossdisp_axis)

Check warning on line 177 in specreduce/background.py

View check run for this annotation

Codecov / codecov/patch

specreduce/background.py#L176-L177

Added lines #L176 - L177 were not covered by tests
else:
raise ValueError("statistic must be 'average' or 'median'")

Expand Down Expand Up @@ -204,7 +243,19 @@ def two_sided(cls, image, trace_object, separation, **kwargs):
dispersion axis
crossdisp_axis : int
cross-dispersion axis
mask_treatment : string
The method for handling masked or non-finite data. Choice of `filter`,
`omit`, or `zero-fill`. If `filter` is chosen, masked/non-finite data
will be filtered during the fit to each bin/column (along disp. axis) to
find the peak. If `omit` is chosen, columns along disp_axis with any
masked/non-finite data values will be fully masked (i.e, 2D mask is
collapsed to 1D and applied). If `zero-fill` is chosen, masked/non-finite
data will be replaced with 0.0 in the input image, and the mask will then
be dropped. For all three options, the input mask (optional on input
NDData object) will be combined with a mask generated from any non-finite
values in the image data.
"""

image = _ImageParser._get_data_from_image(image) if image is not None else cls.image
kwargs['traces'] = [trace_object-separation, trace_object+separation]
return cls(image=image, **kwargs)
Expand Down Expand Up @@ -241,6 +292,17 @@ def one_sided(cls, image, trace_object, separation, **kwargs):
dispersion axis
crossdisp_axis : int
cross-dispersion axis
mask_treatment : string
The method for handling masked or non-finite data. Choice of `filter`,
`omit`, or `zero-fill`. If `filter` is chosen, masked/non-finite data
will be filtered during the fit to each bin/column (along disp. axis) to
find the peak. If `omit` is chosen, columns along disp_axis with any
masked/non-finite data values will be fully masked (i.e, 2D mask is
collapsed to 1D and applied). If `zero-fill` is chosen, masked/non-finite
data will be replaced with 0.0 in the input image, and the mask will then
be dropped. For all three options, the input mask (optional on input
NDData object) will be combined with a mask generated from any non-finite
values in the image data.
"""
image = _ImageParser._get_data_from_image(image) if image is not None else cls.image
kwargs['traces'] = [trace_object+separation]
Expand Down
120 changes: 111 additions & 9 deletions specreduce/core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst

from copy import deepcopy
import inspect
from dataclasses import dataclass

Expand Down Expand Up @@ -67,14 +68,20 @@ def _get_data_from_image(image, disp_axis=1):
else: # NDData, including CCDData and Spectrum1D
img = image.data

# mask and uncertainty are set as None when they aren't specified upon
# creating a Spectrum1D object, so we must check whether these
# attributes are absent *and* whether they are present but set as None
if getattr(image, 'mask', None) is not None:
mask = image.mask
else:
mask = ~np.isfinite(img)
mask = getattr(image, 'mask', None)

Check warning on line 71 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L71

Added line #L71 was not covered by tests

# next, handle masked and nonfinite data in image.
# A mask will be created from any nonfinite image data, and combined
# with any additional 'mask' passed in. If image is being parsed within
# a specreduce operation that has 'mask_treatment' options, this will be
# handled as well. Note that input data may be modified if a fill value
# is chosen to handle masked data. The returned image will always have
# `image.mask` even if there are no nonfinte or masked values.
img, mask = self._mask_and_nonfinite_data_handling(image=img, mask=mask)

Check warning on line 80 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L80

Added line #L80 was not covered by tests

# mask (handled above) and uncertainty are set as None when they aren't
# specified upon creating a Spectrum1D object, so we must check whether
# these attributes are absent *and* whether they are present but set as None
if getattr(image, 'uncertainty', None) is not None:
uncertainty = image.uncertainty
else:
Expand All @@ -85,11 +92,106 @@ def _get_data_from_image(image, disp_axis=1):
spectral_axis = getattr(image, 'spectral_axis',
np.arange(img.shape[disp_axis]) * u.pix)

return Spectrum1D(img * unit, spectral_axis=spectral_axis,
uncertainty=uncertainty, mask=mask)
img = Spectrum1D(img * unit, spectral_axis=spectral_axis,

Check warning on line 95 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L95

Added line #L95 was not covered by tests
uncertainty=uncertainty, mask=mask)

return img

Check warning on line 98 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L98

Added line #L98 was not covered by tests

@staticmethod
def _get_data_from_image(image):
"""Extract data array from various input types for `image`.
Retruns `np.ndarray` of image data."""

if isinstance(image, u.quantity.Quantity):
img = image.value

Check warning on line 106 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L106

Added line #L106 was not covered by tests
if isinstance(image, np.ndarray):
img = image

Check warning on line 108 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L108

Added line #L108 was not covered by tests
else: # NDData, including CCDData and Spectrum1D
img = image.data
return img

def _mask_and_nonfinite_data_handling(self, image, mask):
"""
This function handles the treatment of masked and nonfinite data,
including input validation.
All operations in Specreduce can take in a mask for the data as
part of the input NDData. Additionally, any non-finite values in the
data that aren't in the user-supplied mask will be combined bitwise
with the input mask.
There are three options currently implemented for the treatment
of masked and nonfinite data - filter, omit, and zero-fill.
Depending on the step, all or a subset of these three options are valid.
"""

# valid options depend on Specreduce step, and are set accordingly there
# for steps that this isn't implemeted for yet, default to 'filter',
# which will return unmodified input image and mask
mask_treatment = getattr(self, 'mask_treatment', 'filter')

Check warning on line 132 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L132

Added line #L132 was not covered by tests

# make sure chosen option is valid. if _valid_mask_treatment_methods
# is not an attribue, proceed with 'filter' to return back inupt data
# and mask that is combined with nonfinite data.
if mask_treatment is not None: # None in operations where masks aren't relevant (FlatTrace)
valid_mask_treatment_methods = getattr(self, '_valid_mask_treatment_methods', ['filter']) # noqa
if mask_treatment not in valid_mask_treatment_methods:
raise ValueError(f"`mask_treatment` must be one of {valid_mask_treatment_methods}")

Check warning on line 140 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L137-L140

Added lines #L137 - L140 were not covered by tests

# make sure there is always a 'mask', even when all data is unmasked and finite.
if mask is not None:
mask = self.image.mask

Check warning on line 144 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L143-L144

Added lines #L143 - L144 were not covered by tests
# always mask any previously uncaught nonfinite values in image array
# combining these with the (optional) user-provided mask on `image.mask`
mask = np.logical_or(mask, ~np.isfinite(image))

Check warning on line 147 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L147

Added line #L147 was not covered by tests
else:
mask = ~np.isfinite(image)

Check warning on line 149 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L149

Added line #L149 was not covered by tests

# if mask option is the default 'filter' option, or None,
# nothing needs to be done. input mask (combined with nonfinite data)
# remains with data as-is.

if mask_treatment == 'zero-fill':

Check warning on line 155 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L155

Added line #L155 was not covered by tests
# make a copy of the input image since we will be modifying it
image = deepcopy(image)

Check warning on line 157 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L157

Added line #L157 was not covered by tests

# if mask_treatment is 'zero_fill', set masked values to zero in
# image data and drop image.mask. note that this is done after
# _combine_mask_with_nonfinite_from_data, so non-finite values in
# data (which are now in the mask) will also be set to zero.
# set masked values to zero
image[mask] = 0.

Check warning on line 164 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L164

Added line #L164 was not covered by tests

# masked array with no masked values, so accessing image.mask works
# but we don't need the actual mask anymore since data has been set to 0
mask = np.zeros(image.shape)

Check warning on line 168 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L168

Added line #L168 was not covered by tests

elif mask_treatment == 'omit':

Check warning on line 170 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L170

Added line #L170 was not covered by tests
# collapse 2d mask (after accounting for addl non-finite values in
# data) to a 1d mask, along dispersion axis, to fully mask columns
# that have any masked values.

# must have a crossdisp_axis specified to use 'omit' optoin
if hasattr(self, 'crossdisp_axis'):
crossdisp_axis = self.crossdisp_axis
if hasattr(self, '_crossdisp_axis'):
crossdisp_axis = self._crossdisp_axis

Check warning on line 179 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L176-L179

Added lines #L176 - L179 were not covered by tests

# create a 1d mask along crossdisp axis - if any column has a single nan,
# the entire column should be masked
reduced_mask = np.logical_or.reduce(mask,

Check warning on line 183 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L183

Added line #L183 was not covered by tests
axis=crossdisp_axis)

# back to a 2D mask
shape = (image.shape[0], 1) if crossdisp_axis == 0 else (1, image.shape[1])
mask = np.tile(reduced_mask, shape)

Check warning on line 188 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L187-L188

Added lines #L187 - L188 were not covered by tests

# check for case where entire image is masked.
if mask.all():
raise ValueError('Image is fully masked. Check for invalid values.')

Check warning on line 192 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L191-L192

Added lines #L191 - L192 were not covered by tests

return image, mask

Check warning on line 194 in specreduce/core.py

View check run for this annotation

Codecov / codecov/patch

specreduce/core.py#L194

Added line #L194 was not covered by tests

@dataclass
class SpecreduceOperation(_ImageParser):
Expand Down
Loading

0 comments on commit 12eb705

Please sign in to comment.