diff --git a/CHANGES.rst b/CHANGES.rst index 59f231ed95..d01591c79d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,6 +16,8 @@ Cubeviz the label of the flux cube) as well as to several plugins: model fitting, gaussian smooth, line analysis, and moment maps. [#2827] +- Background subtraction support within Spectral Extraction. [#2859] + Imviz ^^^^^ @@ -49,6 +51,8 @@ Cubeviz ``wavelength_unit`` (use ``value_unit``), ``show_wavelength`` (use ``show_value``), ``slice`` (use ``value``). [#2878] +- Spectral Extraction: renamed ``collapse_to_spectrum(...)`` to ``extract(...)``. [#2859] + Imviz ^^^^^ diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py index 528b172490..32e50c2814 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py @@ -6,26 +6,24 @@ from astropy.nddata import ( NDDataArray, StdDevUncertainty ) +from functools import cached_property from traitlets import Any, Bool, Dict, Float, List, Unicode, observe -from photutils.aperture import CircularAperture, EllipticalAperture, RectangularAperture from jdaviz.core.custom_traitlets import FloatHandleEmpty from jdaviz.core.events import SnackbarMessage, SliceValueUpdatedMessage -from jdaviz.core.marks import SpectralExtractionPreview +from jdaviz.core.marks import PluginLine from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetSelectMixin, SelectPluginComponent, ApertureSubsetSelectMixin, ApertureSubsetSelect, - AddResultsMixin, + AddResults, AddResultsMixin, skip_if_no_updates_since_last_active, skip_if_not_tray_instance, with_spinner, with_temp_disable) from jdaviz.core.user_api import PluginUserApi -from jdaviz.core.region_translators import regions2aperture from jdaviz.configs.cubeviz.plugins.parsers import _return_spectrum_with_correct_units -from jdaviz.configs.cubeviz.plugins.viewers import CubevizProfileView __all__ = ['SpectralExtraction'] @@ -45,24 +43,34 @@ class SpectralExtraction(PluginTemplateMixin, ApertureSubsetSelectMixin, * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` - * ``aperture`` (:class:`~jdaviz.core.template_mixin.SubsetSelect`): + * ``aperture`` (:class:`~jdaviz.core.template_mixin.ApertureSubsetSelect`): Subset to use for the spectral extraction, or ``Entire Cube``. - * ``add_results`` (:class:`~jdaviz.core.template_mixin.AddResults`) - * :meth:`collapse` * ``wavelength_dependent``: - When true, the cone_aperture method will be used to determine the mask. + Whether the ``aperture`` should be considered wavelength-dependent. The cone is defined + to intersect ``aperture`` at ``reference_spectral_value``. * ``reference_spectral_value``: The wavelength that will be used to calculate the radius of the cone through the cube. + * ``background`` (:class:`~jdaviz.comre.template_mixin.ApertureSubsetSelect`): + Subset to use for background subtraction, or ``None``. + * ``bg_wavelength_dependent``: + Whether the ``background`` aperture should be considered wavelength-dependent (requires + ``wavelength_dependent`` to also be set to ``True``). The cone is defined + to intersect ``background`` at ``reference_spectral_value``. + * ```bg_spec_per_spaxel``: + Whether to normalize the background per spaxel when calling ``extract_bg_spectrum``. + Otherwise, the spectrum will be scaled by the ratio between the + areas of the aperture and the background aperture. Only applicable if ``function`` is 'Sum'. + * ``bg_spec_add_results`` (:class:`~jdaviz.core.template_mixin.AddResults`) + * :meth:`extract_bg_spectrum` * ``aperture_method`` (:class:`~jdaviz.core.template_mixin.SelectPluginComponent`): - Extract spectrum using an aperture masking method in place of the subset mask. + Method to use for extracting spectrum (and background, if applicable). + * ``add_results`` (:class:`~jdaviz.core.template_mixin.AddResults`) + * :meth:`collapse` """ template_file = __file__, "spectral_extraction.vue" uses_active_status = Bool(True).tag(sync=True) show_live_preview = Bool(True).tag(sync=True) - # feature flag for background cone support - dev_bg_support = Bool(False).tag(sync=True) # when enabling: add entries to docstring - active_step = Unicode().tag(sync=True) wavelength_dependent = Bool(False).tag(sync=True) @@ -75,6 +83,16 @@ class SpectralExtraction(PluginTemplateMixin, ApertureSubsetSelectMixin, bg_scale_factor = Float(1).tag(sync=True) bg_wavelength_dependent = Bool(False).tag(sync=True) + bg_spec_per_spaxel = Bool(False).tag(sync=True) + bg_spec_results_label = Unicode().tag(sync=True) + bg_spec_results_label_default = Unicode().tag(sync=True) + bg_spec_results_label_auto = Bool(True).tag(sync=True) + bg_spec_results_label_invalid_msg = Unicode('').tag(sync=True) + bg_spec_results_label_overwrite = Bool().tag(sync=True) + bg_spec_add_to_viewer_items = List().tag(sync=True) + bg_spec_add_to_viewer_selected = Unicode().tag(sync=True) + bg_spec_spinner = Bool(False).tag(sync=True) + function_items = List().tag(sync=True) function_selected = Unicode('Sum').tag(sync=True) filename = Unicode().tag(sync=True) @@ -123,6 +141,16 @@ def __init__(self, *args, **kwargs): multiselect=None, default_text='None') + self.bg_spec_add_results = AddResults(self, 'bg_spec_results_label', + 'bg_spec_results_label_default', + 'bg_spec_results_label_auto', + 'bg_spec_results_label_invalid_msg', + 'bg_spec_results_label_overwrite', + 'bg_spec_add_to_viewer_items', + 'bg_spec_add_to_viewer_selected') + self.bg_spec_add_results.viewer.filters = ['is_spectrum_viewer'] + self.bg_spec_results_label_default = 'background-spectrum' + self.function = SelectPluginComponent( self, items='function_items', @@ -161,11 +189,11 @@ def __init__(self, *args, **kwargs): @property def user_api(self): expose = ['dataset', 'function', 'aperture', - 'add_results', 'collapse_to_spectrum', + 'background', 'bg_wavelength_dependent', + 'bg_spec_per_spaxel', 'bg_spec_add_results', 'extract_bg_spectrum', + 'add_results', 'extract', 'wavelength_dependent', 'reference_spectral_value', 'aperture_method'] - if self.dev_bg_support: - expose += ['background', 'bg_wavelength_dependent'] return PluginUserApi(self, expose=expose) @@ -174,16 +202,17 @@ def live_update_subscriptions(self): return {'data': ('dataset',), 'subset': ('aperture', 'background')} def __call__(self, add_data=True): - return self.collapse_to_spectrum(add_data=add_data) + return self.extract(add_data=add_data) @property def slice_display_unit_name(self): return 'spectral' - @observe('active_step') + @observe('active_step', 'is_active') def _active_step_changed(self, *args): self.aperture._set_mark_visiblities(self.active_step in ('', 'ap', 'ext')) self.background._set_mark_visiblities(self.active_step == 'bg') + self.marks['bg_spec'].visible = self.active_step == 'bg' @property def slice_plugin(self): @@ -269,69 +298,96 @@ def _update_aperture_method_on_function_change(self, *args): else: self.conflicting_aperture_and_function = False - @with_spinner() - def collapse_to_spectrum(self, add_data=True, **kwargs): - """ - Collapse over the spectral axis. - - Parameters - ---------- - add_data : bool - Whether to load the resulting data back into the application according to - ``add_results``. - kwargs : dict - Additional keyword arguments passed to the NDDataArray collapse operation. - Examples include ``propagate_uncertainties`` and ``operation_ignores_mask``. - """ - if self.conflicting_aperture_and_function: - raise ValueError(self.conflicting_aperture_error_message) + @property + def spectral_cube(self): + return self.dataset.selected_dc_item - spectral_cube = self.dataset.selected_dc_item + @property + def uncert_cube(self): if self.dataset.selected == self._app._jdaviz_helper._loaded_flux_cube.label: - uncert_cube = self._app._jdaviz_helper._loaded_uncert_cube + return self._app._jdaviz_helper._loaded_uncert_cube else: # TODO: allow selecting or associating an uncertainty cube? - uncert_cube = None - uncertainties = None - selected_func = self.function_selected.lower() + return None + @property + def spectral_display_unit(self): + return astropy.units.Unit(self.app._get_display_unit(self.slice_display_unit_name)) + + @property + def aperture_weight_mask(self): + # Exact slice mask of cone or cylindrical aperture through the cube. `weight_mask` is + # a 3D array with fractions of each pixel within an aperture at each + # wavelength, on the range [0, 1]. + if self.aperture.selected == self.aperture.default_text: + # Entire Cube + return np.ones_like(self.dataset.selected_obj.flux.value) + return self.aperture.get_mask(self.dataset.selected_obj, + self.aperture_method_selected, + self.spectral_display_unit, + self.reference_spectral_value if self.wavelength_dependent else None) # noqa + + @property + def bg_weight_mask(self): + if self.background.selected == self.background.default_text: + # NO background + return np.zeros_like(self.dataset.selected_obj.flux.value) + return self.background.get_mask(self.dataset.selected_obj, + self.aperture_method_selected, + self.spectral_display_unit, + self.reference_spectral_value if self.bg_wavelength_dependent else None) # noqa + + @property + def aperture_area_along_spectral(self): + # Weight mask summed along the spatial axes so that we get area of the aperture, in pixels, + # as a function of wavelength. + return np.sum(self.aperture_weight_mask, axis=(0, 1)) + + @property + def bg_area_along_spectral(self): + return np.sum(self.bg_weight_mask, axis=(0, 1)) + + def _extract_from_aperture(self, spectral_cube, uncert_cube, aperture, + weight_mask, wavelength_dependent, + selected_func, **kwargs): # This plugin collapses over the *spatial axes* (optionally over a spatial subset, # defaults to ``No Subset``). Since the Cubeviz parser puts the fluxes # and uncertainties in different glue Data objects, we translate the spectral # cube and its uncertainties into separate NDDataArrays, then combine them: - if self.aperture.selected != self.aperture.default_text: + if not isinstance(aperture, ApertureSubsetSelect): + raise ValueError("aperture must be an ApertureSubsetSelect object") + if aperture.selected != aperture.default_text: nddata = spectral_cube.get_subset_object( - subset_id=self.aperture.selected, cls=NDDataArray + subset_id=aperture.selected, cls=NDDataArray ) if uncert_cube: uncertainties = uncert_cube.get_subset_object( - subset_id=self.aperture.selected, cls=StdDevUncertainty + subset_id=aperture.selected, cls=StdDevUncertainty ) - # Exact slice mask of cone or cylindrical aperture through the cube. `shape_mask` is - # a 3D array with fractions of each pixel within an aperture at each - # wavelength, on the range [0, 1]. - shape_mask = self.get_aperture() + else: + uncertainties = None if self.aperture_method_selected.lower() == 'center': flux = nddata.data << nddata.unit else: # exact (min/max not allowed here) # Apply the fractional pixel array to the flux cube - flux = (shape_mask * nddata.data) << nddata.unit + flux = (weight_mask * nddata.data) << nddata.unit # Boolean cube which is True outside of the aperture # (i.e., the numpy boolean mask convention) - mask = np.isclose(shape_mask, 0) + mask = np.isclose(weight_mask, 0) # composite subset masks are in `nddata.mask`: - if nddata.mask is not None and np.all(shape_mask == 0): + if nddata.mask is not None and np.all(weight_mask == 0): mask &= nddata.mask else: nddata = spectral_cube.get_object(cls=NDDataArray) if uncert_cube: uncertainties = uncert_cube.get_object(cls=StdDevUncertainty) + else: + uncertainties = None flux = nddata.data << nddata.unit mask = nddata.mask - shape_mask = np.ones_like(nddata.data) # Use the spectral coordinate from the WCS: if '_orig_spec' in spectral_cube.meta: wcs = spectral_cube.meta['_orig_spec'].wcs.spectral @@ -363,7 +419,7 @@ def collapse_to_spectrum(self, add_data=True, **kwargs): # Then normalize the flux based on the fractional pixel array flux_for_mean = (collapsed_sum_for_mean.data / - np.sum(shape_mask, axis=spatial_axes)) << nddata_reshaped.unit + np.sum(weight_mask, axis=spatial_axes)) << nddata_reshaped.unit # Combine that information into a new NDDataArray collapsed_nddata = NDDataArray(flux_for_mean, mask=collapsed_as_mean.mask, uncertainty=collapsed_as_mean.uncertainty, @@ -393,20 +449,53 @@ def collapse_to_spectrum(self, add_data=True, **kwargs): uncertainty=uncertainty, mask=mask ) + return collapsed_spec + + @with_spinner() + def extract(self, return_bg=False, add_data=True, **kwargs): + """ + Extract the spectrum from the data cube according to the plugin inputs. + + Parameters + ---------- + return_bg : bool, optional + Whether to also return the spectrum of the background, if applicable. + add_data : bool, optional + Whether to load the resulting data back into the application according to + ``add_results``. + kwargs : dict + Additional keyword arguments passed to the NDDataArray collapse operation. + Examples include ``propagate_uncertainties`` and ``operation_ignores_mask``. + """ + if self.conflicting_aperture_and_function: + raise ValueError(self.conflicting_aperture_error_message) + if self.aperture.selected == self.background.selected: + raise ValueError("aperture and background cannot be set to the same subset") + + selected_func = self.function_selected.lower() + spec = self._extract_from_aperture(self.spectral_cube, self.uncert_cube, + self.aperture, self.aperture_weight_mask, + self.wavelength_dependent, + selected_func, **kwargs) + + bg_spec = self.extract_bg_spectrum(add_data=False, bg_spec_per_spaxel=False) + if bg_spec is not None: + spec = spec - bg_spec + + # per https://jwst-docs.stsci.edu/jwst-near-infrared-camera/nircam-performance/nircam-absolute-flux-calibration-and-zeropoints # noqa + pix_scale_factor = self.aperture.scale_factor * self.spectral_cube.meta.get('PIXAR_SR', 1.0) + spec.meta['_pixel_scale_factor'] = pix_scale_factor + # stuff for exporting to file - self.extracted_spec = collapsed_spec + self.extracted_spec = spec self.extracted_spec_available = True fname_label = self.dataset_selected.replace("[", "_").replace("]", "") self.filename = f"extracted_{selected_func}_{fname_label}.fits" - # per https://jwst-docs.stsci.edu/jwst-near-infrared-camera/nircam-performance/nircam-absolute-flux-calibration-and-zeropoints # noqa - pix_scale_factor = self.aperture.scale_factor * spectral_cube.meta.get('PIXAR_SR', 1.0) - collapsed_spec.meta['_pixel_scale_factor'] = pix_scale_factor - if add_data: if default_color := self.aperture.selected_item.get('color', None): - collapsed_spec.meta['_default_color'] = default_color - self.add_results.add_results_from_plugin(collapsed_spec) + spec.meta['_default_color'] = default_color + self.add_results.add_results_from_plugin(spec) snackbar_message = SnackbarMessage( "Spectrum extracted successfully.", @@ -414,82 +503,62 @@ def collapse_to_spectrum(self, add_data=True, **kwargs): sender=self) self.hub.broadcast(snackbar_message) - return collapsed_spec + if return_bg: + return spec, bg_spec + return spec - def get_aperture(self): - # Retrieve flux cube and create an array to represent the cone mask - flux_cube = self.dataset.selected_obj - display_unit = astropy.units.Unit(self.app._get_display_unit(self.slice_display_unit_name)) - - # if subset is a composite subset, skip the other logic: - if self.aperture.is_composite: - [subset_group] = [ - subset_group for subset_group in self.app.data_collection.subset_groups - if subset_group.label == self.aperture_selected] - mask_weights = subset_group.subsets[0].to_mask().astype(np.float32) - return mask_weights - - # Center is reverse coordinates - center = (self.aperture.selected_spatial_region.center.y, - self.aperture.selected_spatial_region.center.x) - aperture = regions2aperture(self.aperture.selected_spatial_region) - aperture.positions = center - - im_shape = (flux_cube.shape[0], flux_cube.shape[1]) - aper_method = self.aperture_method_selected.lower() - if self.wavelength_dependent: - # Cone aperture - if display_unit.physical_type != 'length': - raise ValueError( - f'Spectral axis unit physical type is {display_unit.physical_type}, ' - 'must be length for cone aperture') - - fac = flux_cube.spectral_axis.to_value(display_unit) / self.reference_spectral_value - - # TODO: Use flux_cube.spectral_axis.to_value(display_unit) when we have unit conversion. - if isinstance(aperture, CircularAperture): - radii = fac * aperture.r # radius - elif isinstance(aperture, EllipticalAperture): - radii = fac * aperture.a # semimajor axis - radii_b = fac * aperture.b # semiminor axis - elif isinstance(aperture, RectangularAperture): - radii = fac * aperture.w # full width - radii_h = fac * aperture.h # full height - else: - raise NotImplementedError(f"{aperture.__class__.__name__} is not supported") - - mask_weights = np.zeros(flux_cube.shape, dtype=np.float32) - - # Loop through cube and create cone aperture at each wavelength. Then convert that to a - # weight array using the selected aperture method, and add it to a weight cube. - for index, cone_r in enumerate(radii): - if isinstance(aperture, CircularAperture): - aperture.r = cone_r - elif isinstance(aperture, EllipticalAperture): - aperture.a = cone_r - aperture.b = radii_b[index] - else: # RectangularAperture - aperture.w = cone_r - aperture.h = radii_h[index] - - slice_mask = aperture.to_mask(method=aper_method).to_image(im_shape) - # Add slice mask to fractional pixel array - mask_weights[:, :, index] = slice_mask + @with_spinner('bg_spec_spinner') + def extract_bg_spectrum(self, add_data=False, **kwargs): + """ + Create a background 1D spectrum from the input parameters defined in the plugin. + + If ``function`` is 'sum', then the value is scaled by the relative ratios of the area + (along the spectral axis) of ``aperture`` to ``background``. + + Parameters + ---------- + add_data : bool + Whether to add the resulting spectrum to the application, according to the options + defined in the plugin. + kwargs : dict + Additional keyword arguments passed to the NDDataArray collapse operation. + Examples include ``propagate_uncertainties`` and ``operation_ignores_mask``. + """ + # allow internal calls to override the behavior of the bg_spec_per_spaxel traitlet + bg_spec_per_spaxel = kwargs.pop('bg_spec_per_spaxel', self.bg_spec_per_spaxel) + if self.background.selected != self.background.default_text: + bg_spec = self._extract_from_aperture(self.spectral_cube, self.uncert_cube, + self.background, self.bg_weight_mask, + self.bg_wavelength_dependent, + self.function_selected.lower(), **kwargs) + if self.function_selected.lower() == 'sum': + if bg_spec_per_spaxel: + bg_spec *= 1 / self.bg_area_along_spectral + else: + # then scale according to aperture areas across the spectral axis (allowing for + # independent wavelength-dependence btwn the aperture and background) + bg_spec *= self.aperture_area_along_spectral / self.bg_area_along_spectral else: - # Cylindrical aperture - slice_mask = aperture.to_mask(method=aper_method).to_image(im_shape) - # Turn 2D slice_mask into 3D array that is the same shape as the flux cube - mask_weights = np.stack([slice_mask] * len(flux_cube.spectral_axis), axis=2) - return mask_weights + bg_spec = None + + if add_data: + if bg_spec is None: + raise ValueError(f"Background is set to {self.background.selected}") + self.bg_spec_add_results.add_results_from_plugin(bg_spec, replace=False) + + return bg_spec def vue_spectral_extraction(self, *args, **kwargs): try: - self.collapse_to_spectrum(add_data=True) + self.extract(add_data=True) except Exception as e: self.hub.broadcast(SnackbarMessage( f"Extraction failed: {repr(e)}", sender=self, color="error")) + def vue_create_bg_spec(self, *args, **kwargs): + self.extract_bg_spectrum(add_data=True) + def vue_save_as_fits(self, *args): self._save_extracted_spec_to_fits() @@ -543,28 +612,21 @@ def _set_default_results_label(self, event={}): else: self.results_label_default = f"Spectrum ({self.aperture_selected}, {self.function_selected.lower()})" # noqa - @property + @cached_property def marks(self): if not self._tray_instance: return {} - marks = {} - for id, viewer in self.app._viewer_store.items(): - if not isinstance(viewer, CubevizProfileView): - continue - for mark in viewer.figure.marks: - if isinstance(mark, SpectralExtractionPreview): - marks[id] = mark - break - else: - mark = SpectralExtractionPreview(viewer, visible=self.is_active) - viewer.figure.marks = viewer.figure.marks + [mark] - marks[id] = mark + sv = self.spectrum_viewer + marks = {'spec': PluginLine(sv, visible=self.is_active), + 'bg_spec': PluginLine(sv, + line_style='dotted', + visible=self.is_active and self.active_step == 'bg')} + sv.figure.marks = sv.figure.marks + [marks['spec'], marks['bg_spec']] return marks def _clear_marks(self): for mark in self.marks.values(): if mark.visible: - mark.clear() mark.visible = False @observe('is_active', 'show_live_preview') @@ -577,12 +639,13 @@ def _toggle_marks(self, event={}): # then the marks themselves need to be updated self._live_update(event) - @observe('aperture_selected', 'function_selected', - 'wavelength_dependent', 'reference_spectral_value', + @observe('dataset_selected', 'aperture_selected', 'bg_selected', + 'wavelength_dependent', 'bg_wavelength_dependent', 'reference_spectral_value', + 'function_selected', 'aperture_method_selected', 'previews_temp_disabled') @skip_if_no_updates_since_last_active() - @with_temp_disable(timeout=0.3) + @with_temp_disable(timeout=0.4) def _live_update(self, event={}): if not self._tray_instance: return @@ -590,16 +653,18 @@ def _live_update(self, event={}): self._clear_marks() return - if event.get('name', '') not in ('is_active', 'show_live_preview'): - # mark visibility hasn't been handled yet - self._toggle_marks() - try: - sp = self.collapse_to_spectrum(add_data=False) - except Exception: + sp, bg_spec = self.extract(return_bg=True, add_data=False) + except (ValueError, Exception): self._clear_marks() return - for mark in self.marks.values(): - mark.update_xy(sp.spectral_axis.value, sp.flux.value) - mark.visible = True + self.marks['spec'].update_xy(sp.spectral_axis.value, sp.flux.value) + self.marks['spec'].visible = True + + if bg_spec is None: + self.marks['bg_spec'].clear() + self.marks['bg_spec'].visible = False + else: + self.marks['bg_spec'].update_xy(bg_spec.spectral_axis.value, bg_spec.flux.value) + self.marks['bg_spec'].visible = self.active_step == 'bg' diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue index 02af53af5e..00a974d757 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.vue @@ -85,7 +85,7 @@ -
+
Background
+ + + + + + Export Background Spectrum + + + + + + + + + + +
diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py b/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py index bda139f53d..f2aad8c433 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py @@ -30,7 +30,7 @@ def test_version_after_nddata_update(cubeviz_helper, spectrum1d_cube_with_uncert collapsed_cube_nddata = spectral_cube.sum(axis=(0, 1)) # return NDDataArray # Collapse the spectral cube using the methods in jdaviz: - collapsed_cube_s1d = plg.collapse_to_spectrum(add_data=False) # returns Spectrum1D + collapsed_cube_s1d = plg.extract(add_data=False) # returns Spectrum1D assert plg._obj.disabled_msg == '' assert isinstance(spectral_cube, NDDataArray) @@ -80,7 +80,7 @@ def test_gauss_smooth_before_spec_extract(cubeviz_helper, spectrum1d_cube_with_u expected_uncert = 2 extract_plugin.aperture = 'Subset 1' - collapsed_spec = extract_plugin.collapse_to_spectrum() + collapsed_spec = extract_plugin.extract() # this single pixel has two wavelengths, and all uncertainties are unity # irrespective of which collapse function is applied: @@ -89,7 +89,7 @@ def test_gauss_smooth_before_spec_extract(cubeviz_helper, spectrum1d_cube_with_u # this two-pixel region has four unmasked data points per wavelength: extract_plugin.aperture = 'Subset 2' - collapsed_spec_2 = extract_plugin.collapse_to_spectrum() + collapsed_spec_2 = extract_plugin.extract() assert_array_equal(collapsed_spec_2.uncertainty.array, expected_uncert) @@ -120,7 +120,7 @@ def test_subset( # single pixel region: plg.aperture = 'Subset 1' - collapsed_spec_1 = plg.collapse_to_spectrum() + collapsed_spec_1 = plg.extract() # this single pixel has two wavelengths, and all uncertainties are unity # irrespective of which collapse function is applied: @@ -129,7 +129,7 @@ def test_subset( # this two-pixel region has four unmasked data points per wavelength: plg.aperture = 'Subset 2' - collapsed_spec_2 = plg.collapse_to_spectrum() + collapsed_spec_2 = plg.extract() assert_array_equal(collapsed_spec_2.uncertainty.array, expected_uncert) @@ -251,13 +251,13 @@ def test_cone_aperture_with_different_methods(cubeviz_helper, spectrum1d_cube_la extract_plg.wavelength_dependent = True extract_plg.function = 'Sum' - collapsed_spec = extract_plg.collapse_to_spectrum() + collapsed_spec = extract_plg.extract() assert_allclose(collapsed_spec.flux.value[1000:1010], expected_flux_1000, rtol=1e-6) assert_allclose(collapsed_spec.flux.value[2400:2410], expected_flux_2400, rtol=1e-6) extract_plg.function = 'Mean' - collapsed_spec_mean = extract_plg.collapse_to_spectrum() + collapsed_spec_mean = extract_plg.extract() assert_allclose(collapsed_spec_mean.flux.value, 1) @@ -283,12 +283,12 @@ def test_cylindrical_aperture_with_different_methods(cubeviz_helper, spectrum1d_ extract_plg.wavelength_dependent = False extract_plg.function = 'Sum' - collapsed_spec = extract_plg.collapse_to_spectrum() + collapsed_spec = extract_plg.extract() assert_allclose(collapsed_spec.flux.value, expected_flux_wav) extract_plg.function = 'Mean' - collapsed_spec_mean = extract_plg.collapse_to_spectrum() + collapsed_spec_mean = extract_plg.extract() assert_allclose(collapsed_spec_mean.flux.value, 1) @@ -304,7 +304,7 @@ def test_rectangle_aperture_with_exact(cubeviz_helper, spectrum1d_cube_largest): extract_plg.aperture_method.selected = "Exact" extract_plg.wavelength_dependent = True extract_plg.function = 'Sum' - collapsed_spec = extract_plg.collapse_to_spectrum() + collapsed_spec = extract_plg.extract() # The extracted spectrum has "steps" (aliased) but perhaps that is due to # how photutils is extracting a boxy aperture. There is still a slope. @@ -313,11 +313,44 @@ def test_rectangle_aperture_with_exact(cubeviz_helper, spectrum1d_cube_largest): assert_allclose(collapsed_spec.flux.value[::301], expected_flux_step) extract_plg.wavelength_dependent = False - collapsed_spec = extract_plg.collapse_to_spectrum() + collapsed_spec = extract_plg.extract() assert_allclose(collapsed_spec.flux.value, 16) # 4 x 4 +def test_background_subtraction(cubeviz_helper, spectrum1d_cube_largest): + cubeviz_helper.load_data(spectrum1d_cube_largest) + center = PixCoord(5, 10) + cubeviz_helper.load_regions([ + CirclePixelRegion(center, radius=2.5), + EllipsePixelRegion(center, width=5, height=5)]) + + extract_plg = cubeviz_helper.plugins['Spectral Extraction'] + with extract_plg.as_active(): + extract_plg.aperture = 'Subset 1' + spec_no_bg = extract_plg.extract() + + extract_plg.background = 'Subset 2' + + # test visiblity of background aperture and preview based on "active step" + assert extract_plg.background.marks[0].visible + assert not extract_plg._obj.marks['bg_spec'].visible + extract_plg._obj.active_step = 'ap' + assert not extract_plg.background.marks[0].visible + assert not extract_plg._obj.marks['bg_spec'].visible + extract_plg._obj.active_step = 'bg' + assert extract_plg.background.marks[0].visible + assert extract_plg._obj.marks['bg_spec'].visible + + bg_spec = extract_plg.extract_bg_spectrum() + extract_plg.bg_spec_per_spaxel = True + bg_spec_normed = extract_plg.extract_bg_spectrum() + assert np.all(bg_spec_normed.flux.value < bg_spec.flux.value) + spec = extract_plg.extract() + + assert np.allclose(spec.flux, spec_no_bg.flux - bg_spec.flux) + + def test_cone_and_cylinder_errors(cubeviz_helper, spectrum1d_cube_largest): cubeviz_helper.load_data(spectrum1d_cube_largest) center = PixCoord(5, 10) @@ -333,16 +366,16 @@ def test_cone_and_cylinder_errors(cubeviz_helper, spectrum1d_cube_largest): extract_plg.function = 'Min' with pytest.raises(ValueError, match=extract_plg._obj.conflicting_aperture_error_message): - extract_plg.collapse_to_spectrum() + extract_plg.extract() extract_plg.function = 'Max' with pytest.raises(ValueError, match=extract_plg._obj.conflicting_aperture_error_message): - extract_plg.collapse_to_spectrum() + extract_plg.extract() extract_plg.function = 'Sum' extract_plg.aperture = 'Subset 2' with pytest.raises(NotImplementedError, match=".* is not supported"): - extract_plg.collapse_to_spectrum() + extract_plg.extract() def test_cone_aperture_with_frequency_units(cubeviz_helper, spectral_cube_wcs): @@ -358,19 +391,19 @@ def test_cone_aperture_with_frequency_units(cubeviz_helper, spectral_cube_wcs): extract_plg.function = 'Sum' with pytest.raises(ValueError, match="Spectral axis unit physical type is"): - extract_plg.collapse_to_spectrum() + extract_plg.extract() def test_cube_extraction_with_nan(cubeviz_helper, image_cube_hdu_obj): image_cube_hdu_obj[1].data[:, :2, :2] = np.nan cubeviz_helper.load_data(image_cube_hdu_obj, data_label="with_nan") extract_plg = cubeviz_helper.plugins['Spectral Extraction'] - sp = extract_plg.collapse_to_spectrum() # Default settings (sum) + sp = extract_plg.extract() # Default settings (sum) assert_allclose(sp.flux.value, 96) # (10 x 10) - 4 cubeviz_helper.load_regions(RectanglePixelRegion(PixCoord(1.5, 1.5), width=4, height=4)) extract_plg.aperture = 'Subset 1' - sp_subset = extract_plg.collapse_to_spectrum() # Default settings but on Subset + sp_subset = extract_plg.extract() # Default settings but on Subset assert_allclose(sp_subset.flux.value, 12) # (4 x 4) - 4 @@ -383,7 +416,7 @@ def test_autoupdate_results(cubeviz_helper, spectrum1d_cube_largest): extract_plg.aperture = 'Subset 1' extract_plg.add_results.label = 'extracted' extract_plg.add_results._obj.auto_update_result = True - _ = extract_plg.collapse_to_spectrum() + _ = extract_plg.extract() # orig_med_flux = np.median(cubeviz_helper.get_data('extracted').flux) @@ -445,10 +478,10 @@ def test_extraction_composite_subset(cubeviz_helper, spectrum1d_cube): flux_viewer.apply_roi(upper_aperture) spec_extr_plugin.aperture_selected = 'Subset 1' - spectrum_1 = spec_extr_plugin.collapse_to_spectrum() + spectrum_1 = spec_extr_plugin.extract() spec_extr_plugin.aperture_selected = 'Subset 2' - spectrum_2 = spec_extr_plugin.collapse_to_spectrum() + spectrum_2 = spec_extr_plugin.extract() subset_plugin.subset_selected = 'Create New' rectangle = RectangularROI(-0.5, 1.5, -0.5, 3.5) @@ -465,7 +498,7 @@ def test_extraction_composite_subset(cubeviz_helper, spectrum1d_cube): assert spec_extr_plugin.aperture.is_composite - spectrum_3 = spec_extr_plugin.collapse_to_spectrum() + spectrum_3 = spec_extr_plugin.extract() np.testing.assert_allclose( (spectrum_1 + spectrum_2).flux.value, diff --git a/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py b/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py index 82307c6136..117ebb704d 100644 --- a/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py +++ b/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py @@ -153,7 +153,7 @@ def test_unit_translation(cubeviz_helper): # all spectra will pass through spectral extraction, # this will store a scale factor for use in translations. - collapsed_spec = extract_plg.collapse_to_spectrum() + collapsed_spec = extract_plg.extract() # test that the scale factor was set assert collapsed_spec.meta['_pixel_scale_factor'] != 1 @@ -224,7 +224,7 @@ def test_sb_unit_conversion(cubeviz_helper): extract_plg.wavelength_dependent = True extract_plg.function = 'Sum' extract_plg.reference_spectral_value = 0.000001 - extract_plg.collapse_to_spectrum() + extract_plg.extract() uc_plg._obj.show_translator = True uc_plg._obj.flux_or_sb_selected = 'Flux' diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index d38dac8a4a..0286c0dac9 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -17,7 +17,7 @@ 'LineAnalysisContinuum', 'LineAnalysisContinuumCenter', 'LineAnalysisContinuumLeft', 'LineAnalysisContinuumRight', 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel', 'MarkersMark', 'FootprintOverlay', - 'ApertureMark', 'SpectralExtractionPreview'] + 'ApertureMark'] accent_color = "#c75d2c" @@ -612,11 +612,6 @@ def __init__(self, viewer, id, **kwargs): super().__init__(viewer, **kwargs) -class SpectralExtractionPreview(PluginLine): - def __init__(self, viewer, **kwargs): - super().__init__(viewer, **kwargs) - - class HistogramMark(Lines): def __init__(self, min_max_value, scales, **kwargs): # Vertical line in LinearScale diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index f1cbc5dede..1fcb318365 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -29,6 +29,7 @@ from glue_jupyter.bqplot.image import BqplotImageView from glue_jupyter.registries import viewer_registry from glue_jupyter.widgets.linked_dropdown import get_choices as _get_glue_choices +from photutils.aperture import CircularAperture, EllipticalAperture, RectangularAperture from regions import PixelRegion from specutils import Spectrum1D from specutils.manipulation import extract_region @@ -47,13 +48,13 @@ PluginTableAddedMessage, PluginTableModifiedMessage, PluginPlotAddedMessage, PluginPlotModifiedMessage, GlobalDisplayUnitChanged) - from jdaviz.core.marks import (LineAnalysisContinuum, LineAnalysisContinuumCenter, LineAnalysisContinuumLeft, LineAnalysisContinuumRight, ShadowLine, ApertureMark) -from jdaviz.core.region_translators import regions2roi, _get_region_from_spatial_subset +from jdaviz.core.region_translators import (regions2roi, regions2aperture, + _get_region_from_spatial_subset) from jdaviz.core.tools import ICON_DIR from jdaviz.core.user_api import UserApiWrapper, PluginUserApi from jdaviz.core.registries import tray_registry @@ -2437,6 +2438,69 @@ def _update_mark_coords(self, *args): continue mark.x, mark.y = x_coords, y_coords + def get_mask(self, flux_cube, aperture_method, + spectral_display_unit, reference_spectral_value=None): + # if subset is a composite subset, skip the other logic: + if self.is_composite: + [subset_group] = [ + subset_group for subset_group in self.app.data_collection.subset_groups + if subset_group.label == self.selected] + mask_weights = subset_group.subsets[0].to_mask().astype(np.float32) + return mask_weights + + # Center is reverse coordinates + center = (self.selected_spatial_region.center.y, + self.selected_spatial_region.center.x) + aperture = regions2aperture(self.selected_spatial_region) + aperture.positions = center + + im_shape = (flux_cube.shape[0], flux_cube.shape[1]) + aperture_method = aperture_method.lower() + if reference_spectral_value is not None: + # wavelength-dependent (cone aperture) + if spectral_display_unit.physical_type != 'length': + raise ValueError( + f'Spectral axis unit physical type is {spectral_display_unit.physical_type}, ' + 'must be length for cone aperture') + + fac = flux_cube.spectral_axis.to_value(spectral_display_unit) / reference_spectral_value + + # TODO: Use flux_cube.spectral_axis.to_value(display_unit) when we have unit conversion. + if isinstance(aperture, CircularAperture): + radii = fac * aperture.r # radius + elif isinstance(aperture, EllipticalAperture): + radii = fac * aperture.a # semimajor axis + radii_b = fac * aperture.b # semiminor axis + elif isinstance(aperture, RectangularAperture): + radii = fac * aperture.w # full width + radii_h = fac * aperture.h # full height + else: + raise NotImplementedError(f"{aperture.__class__.__name__} is not supported") + + mask_weights = np.zeros(flux_cube.shape, dtype=np.float32) + + # Loop through cube and create cone aperture at each wavelength. Then convert that to a + # weight array using the selected aperture method, and add it to a weight cube. + for index, cone_r in enumerate(radii): + if isinstance(aperture, CircularAperture): + aperture.r = cone_r + elif isinstance(aperture, EllipticalAperture): + aperture.a = cone_r + aperture.b = radii_b[index] + else: # RectangularAperture + aperture.w = cone_r + aperture.h = radii_h[index] + + slice_mask = aperture.to_mask(method=aperture_method).to_image(im_shape) + # Add slice mask to fractional pixel array + mask_weights[:, :, index] = slice_mask + else: + # Cylindrical aperture + slice_mask = aperture.to_mask(method=aperture_method).to_image(im_shape) + # Turn 2D slice_mask into 3D array that is the same shape as the flux cube + mask_weights = np.stack([slice_mask] * len(flux_cube.spectral_axis), axis=2) + return mask_weights + class ApertureSubsetSelectMixin(VuetifyTemplate, HubListener): """ diff --git a/jdaviz/tests/test_app.py b/jdaviz/tests/test_app.py index 9e923ab301..3188a01d0b 100644 --- a/jdaviz/tests/test_app.py +++ b/jdaviz/tests/test_app.py @@ -220,7 +220,7 @@ def test_to_unit(cubeviz_helper): # set so pixel scale factor != 1 extract_plg.reference_spectral_value = 0.000001 - extract_plg.collapse_to_spectrum() + extract_plg.extract() cid = cubeviz_helper.app.data_collection[0].data.find_component_id('flux') data = cubeviz_helper.app.data_collection[-1].data