Skip to content

Commit

Permalink
Aperture photometry in Cubeviz (#2666)
Browse files Browse the repository at this point in the history
* Implement Cubeviz aperture photometry.

Add tests and documentation.

Attach WCS to some Cubeviz 2D products, where it is needed but missing.

Minor clean-ups.

* Add slice info to aper phot plugin
for cubeviz

* Clarify slice field in aper phot

Co-authored-by: Kyle Conroy <[email protected]>

* Fix plugin data menu bug

* Implement slice in output table
and do not display slice for cubeviz 2D data,
as requested.

* Add doc for new slice column
for Cubeviz phot table.

* Fix table GUI for slice wavelength
and display slice wavelength instead of index in read-only text field.

* Display slice_wave in plot and text results

---------

Co-authored-by: Kyle Conroy <[email protected]>
  • Loading branch information
pllim and kecnry authored Feb 2, 2024
1 parent 62231d8 commit 9373a9e
Show file tree
Hide file tree
Showing 19 changed files with 1,046 additions and 49 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Cubeviz

- Spectral extraction plugin re-organized into subsections to be more consistent with specviz2d. [#2676]

- New aperture photometry plugin that can perform aperture photometry on selected cube slice. [#2666]

Imviz
^^^^^

Expand Down
23 changes: 23 additions & 0 deletions docs/cubeviz/export_data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,26 @@ Markers Table
All mouseover information in the :ref:`markers plugin <markers-plugin>` can be exported to an
:ref:`astropy table <astropy:astropy-table>`
by calling :meth:`~jdaviz.core.template_mixin.TableMixin.export_table` (see :ref:`plugin-apis`).


.. _cubeviz_export_photometry:

Aperture Photometry
===================

Cubeviz can export photometry output table like Imviz:

.. code-block:: python
results = cubeviz.get_aperture_photometry_results()
.. seealso::

:ref:`Imviz Aperture Photometry <imviz_export_photometry>`
Imviz documentation describing exporting of aperture photometry results in Jdaviz.

In addition to the columns that :ref:`Imviz Aperture Photometry <imviz_export_photometry>`
would provide, the table from Cubeviz has this extra column after ``data_label``:

* ``slice_wave``: Wavelength value at the selected slice of the cube used for computation.
If a 2D data (e.g., collapsed cube) is selected, the value would be NaN instead.
13 changes: 13 additions & 0 deletions docs/cubeviz/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,19 @@ Click :guilabel:`EXTRACT` to produce a new 1D spectrum dataset
from the spectral cube, which has uncertainties propagated by
`astropy.nddata <https://docs.astropy.org/en/stable/nddata/nddata.html>`_.

.. _cubeviz-aper-phot:

Aperture Photometry
===================

Cubeviz allows aperture photometry on some 3D and 2D data, as long as they
have valid flux units. For 3D data, the current :ref:`slice` is used.

.. seealso::

:ref:`Imviz Aperture Photometry <aper-phot-simple>`
Imviz documentation describing the concept of aperture photometry in Jdaviz.

.. _cubeviz-export-plot:

Export Plot
Expand Down
8 changes: 1 addition & 7 deletions jdaviz/components/plugin_dataset_select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<div class="single-line" style="width: 100%">
<v-chip v-if="multiselect" style="width: calc(100% - 10px)">
<span>
<j-layer-viewer-icon v-if="data.item.icon" span_style="margin-right: 4px" :icon="data.item.icon" :prevent_invert_if_dark="true" :is_wcs_only="isWCSOnlyLayer()"></j-layer-viewer-icon>
<j-layer-viewer-icon v-if="data.item.icon" span_style="margin-right: 4px" :icon="data.item.icon" :prevent_invert_if_dark="true"></j-layer-viewer-icon>
{{ data.item.label }}
</span>
</v-chip>
Expand Down Expand Up @@ -64,12 +64,6 @@
<script>
module.exports = {
props: ['items', 'selected', 'label', 'hint', 'rules', 'show_if_single_entry', 'multiselect'],
methods: {
isWCSOnlyLayer(item) {
const wcsOnly = Object.keys(this.$props.viewer.wcs_only_layers).includes(item.name)
return wcsOnly
},
}
};
</script>

Expand Down
1 change: 1 addition & 0 deletions jdaviz/configs/cubeviz/cubeviz.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ tray:
- specviz-line-analysis
- cubeviz-moment-maps
- cubeviz-spectral-extraction
- imviz-aper-phot-simple
- g-export-plot
viewer_area:
- container: col
Expand Down
14 changes: 14 additions & 0 deletions jdaviz/configs/cubeviz/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,20 @@ def get_data(self, data_label=None, spatial_subset=None, spectral_subset=None, f
spectral_subset=spectral_subset, function=function,
cls=cls, use_display_units=use_display_units)

# Need this method for Imviz Aperture Photometry plugin.

def get_aperture_photometry_results(self):
"""Return aperture photometry results, if any.
Results are calculated using :ref:`cubeviz-aper-phot` plugin.
Returns
-------
results : `~astropy.table.QTable` or `None`
Photometry results if available or `None` otherwise.
"""
return self.plugins['Aperture Photometry']._obj.export_table()


def layer_is_cube_image_data(layer):
return isinstance(layer, BaseData) and layer.ndim in (2, 3)
Expand Down
10 changes: 9 additions & 1 deletion jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,15 @@ def calculate_moment(self, add_data=True):
# Need transpose to align JWST mirror shape: This is because specutils
# arrange the array shape to be (nx, ny, nz) but 2D visualization
# assumes (ny, nx) as per row-major convention.
data_wcs = getattr(cube.wcs, 'celestial', None)

# Extract 2D WCS from input cube.
data = self.dataset.selected_dc_item
# Similar to coords_info logic.
if '_orig_spec' in getattr(data, 'meta', {}):
w = data.meta['_orig_spec'].wcs
else:
w = data.coords
data_wcs = getattr(w, 'celestial', None)
if data_wcs:
data_wcs = data_wcs.swapaxes(0, 1) # We also transpose WCS to match.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def test_user_api(cubeviz_helper, spectrum1d_cube):
assert len(mm._obj.continuum_marks['center'].x) > 0

mom_sub = mm.calculate_moment()
assert isinstance(mom_sub.wcs, WCS)

assert mom != mom_sub

Expand All @@ -45,7 +46,7 @@ def test_user_api(cubeviz_helper, spectrum1d_cube):
mm.calculate_moment()


def test_moment_calculation(cubeviz_helper, spectrum1d_cube, tmpdir):
def test_moment_calculation(cubeviz_helper, spectrum1d_cube, tmp_path):
dc = cubeviz_helper.app.data_collection
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="No observer defined on WCS.*")
Expand Down Expand Up @@ -110,7 +111,7 @@ def test_moment_calculation(cubeviz_helper, spectrum1d_cube, tmpdir):
"204.9998877673 27.0001000000 (deg)")

assert mm._obj.filename == 'moment0_test_FLUX.fits' # Auto-populated on calculate.
mm._obj.filename = str(tmpdir.join(mm._obj.filename)) # But we want it in tmpdir for testing.
mm._obj.filename = str(tmp_path / mm._obj.filename) # But we want it in tmp_path for testing.
mm._obj.vue_save_as_fits()
assert os.path.isfile(mm._obj.filename)

Expand All @@ -134,7 +135,7 @@ def test_moment_calculation(cubeviz_helper, spectrum1d_cube, tmpdir):
"204.9998877673 27.0001000000 (deg)")


def test_moment_velocity_calculation(cubeviz_helper, spectrum1d_cube, tmpdir):
def test_moment_velocity_calculation(cubeviz_helper, spectrum1d_cube):
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="No observer defined on WCS.*")
cubeviz_helper.load_data(spectrum1d_cube, data_label='test')
Expand Down
1 change: 1 addition & 0 deletions jdaviz/configs/cubeviz/plugins/slice/slice.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def _on_slider_updated(self, event):

self.hub.broadcast(SliceWavelengthUpdatedMessage(slice=value,
wavelength=self.wavelength,
wavelength_unit=self.wavelength_unit,
sender=self))

def vue_goto_first(self, *args):
Expand Down
184 changes: 184 additions & 0 deletions jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_aperphot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import numpy as np
import pytest
from astropy import units as u
from astropy.tests.helper import assert_quantity_allclose
from astropy.utils.exceptions import AstropyUserWarning
from numpy.testing import assert_allclose
from regions import RectanglePixelRegion, PixCoord


def test_cubeviz_aperphot_cube_orig_flux(cubeviz_helper, image_cube_hdu_obj_microns):
cubeviz_helper.load_data(image_cube_hdu_obj_microns, data_label="test")
flux_unit = u.Unit("1E-17 erg*s^-1*cm^-2*Angstrom^-1")

aper = RectanglePixelRegion(center=PixCoord(x=1, y=2), width=3, height=5)
cubeviz_helper.load_regions(aper)

# Make sure MASK is not an option even when shown in viewer.
cubeviz_helper.app.add_data_to_viewer("flux-viewer", "test[MASK]", visible=True)

plg = cubeviz_helper.plugins["Aperture Photometry"]._obj
assert plg.dataset.labels == ["test[FLUX]", "test[ERR]"]
assert plg.cube_slice == "4.894e+00 um"

plg.dataset_selected = "test[FLUX]"
plg.aperture_selected = "Subset 1"
plg.vue_do_aper_phot()
row = cubeviz_helper.get_aperture_photometry_results()[0]

# Basically, we should recover the input rectangle here.
assert_allclose(row["xcenter"], 1 * u.pix)
assert_allclose(row["ycenter"], 2 * u.pix)
sky = row["sky_center"]
assert_allclose(sky.ra.deg, 205.43985906934287)
assert_allclose(sky.dec.deg, 27.003490103642033)
assert_allclose(row["sum"], 75 * flux_unit) # 3 (w) x 5 (h) x 5 (v)
assert_allclose(row["sum_aper_area"], 15 * (u.pix * u.pix)) # 3 (w) x 5 (h)
assert_allclose(row["mean"], 5 * flux_unit)
assert_quantity_allclose(row["slice_wave"], 4.894499866699333 * u.um)

# Move slider and make sure it recomputes for a new slice automatically.
cube_slice_plg = cubeviz_helper.plugins["Slice"]._obj
cube_slice_plg.slice = 0
plg.vue_do_aper_phot()
row = cubeviz_helper.get_aperture_photometry_results()[1]

# Same rectangle but different slice value.
assert_allclose(row["xcenter"], 1 * u.pix)
assert_allclose(row["ycenter"], 2 * u.pix)
sky = row["sky_center"]
assert_allclose(sky.ra.deg, 205.43985906934287)
assert_allclose(sky.dec.deg, 27.003490103642033)
assert_allclose(row["sum"], 15 * flux_unit) # 3 (w) x 5 (h) x 1 (v)
assert_allclose(row["sum_aper_area"], 15 * (u.pix * u.pix)) # 3 (w) x 5 (h)
assert_allclose(row["mean"], 1 * flux_unit)
assert_quantity_allclose(row["slice_wave"], 4.8904998665093435 * u.um)

# We continue on with test_cubeviz_aperphot_generated_2d_collapse here
# because we want to make sure the result would append properly between 3D and 2D.
collapse_plg = cubeviz_helper.plugins["Collapse"]._obj
collapse_plg.vue_collapse()

# Need this to make it available for photometry data drop-down.
cubeviz_helper.app.add_data_to_viewer("uncert-viewer", "test[FLUX] collapsed")

plg = cubeviz_helper.plugins["Aperture Photometry"]._obj
plg.dataset_selected = "test[FLUX] collapsed"
plg.aperture_selected = "Subset 1"
plg.vue_do_aper_phot()
row = cubeviz_helper.get_aperture_photometry_results()[2]

# Basically, we should recover the input rectangle here.
assert_allclose(row["xcenter"], 1 * u.pix)
assert_allclose(row["ycenter"], 2 * u.pix)
sky = row["sky_center"]
assert_allclose(sky.ra.deg, 205.43985906934287)
assert_allclose(sky.dec.deg, 27.003490103642033)
assert_allclose(row["sum"], 540 * flux_unit) # 3 (w) x 5 (h) x 36 (v)
assert_allclose(row["sum_aper_area"], 15 * (u.pix * u.pix)) # 3 (w) x 5 (h)
assert_allclose(row["mean"], 36 * flux_unit)
assert np.isnan(row["slice_wave"])


def test_cubeviz_aperphot_generated_2d_moment(cubeviz_helper, image_cube_hdu_obj_microns):
cubeviz_helper.load_data(image_cube_hdu_obj_microns, data_label="test")
flux_unit = u.Unit("1E-17 erg*s^-1*cm^-2*Angstrom^-1")

moment_plg = cubeviz_helper.plugins["Moment Maps"]
_ = moment_plg.calculate_moment()

# Need this to make it available for photometry data drop-down.
cubeviz_helper.app.add_data_to_viewer("uncert-viewer", "test[FLUX] moment 0")

aper = RectanglePixelRegion(center=PixCoord(x=1, y=2), width=3, height=5)
cubeviz_helper.load_regions(aper)

plg = cubeviz_helper.plugins["Aperture Photometry"]._obj
plg.dataset_selected = "test[FLUX] moment 0"
plg.aperture_selected = "Subset 1"
plg.vue_do_aper_phot()
row = cubeviz_helper.get_aperture_photometry_results()[0]

# Basically, we should recover the input rectangle here.
assert_allclose(row["xcenter"], 1 * u.pix)
assert_allclose(row["ycenter"], 2 * u.pix)
sky = row["sky_center"]
assert_allclose(sky.ra.deg, 205.43985906934287)
assert_allclose(sky.dec.deg, 27.003490103642033)
assert_allclose(row["sum"], 540 * flux_unit) # 3 (w) x 5 (h) x 36 (v)
assert_allclose(row["sum_aper_area"], 15 * (u.pix * u.pix)) # 3 (w) x 5 (h)
assert_allclose(row["mean"], 36 * flux_unit)
assert np.isnan(row["slice_wave"])

# Moment 1 has no compatible unit, so should not be available for photometry.
moment_plg.n_moment = 1
moment_plg.reference_wavelength = 5
_ = moment_plg.calculate_moment()
m1_lbl = "test[FLUX] moment 1"
cubeviz_helper.app.add_data_to_viewer("uncert-viewer", m1_lbl)
assert (m1_lbl in cubeviz_helper.app.data_collection.labels and
m1_lbl not in plg.dataset.choices)


def test_cubeviz_aperphot_generated_3d_gaussian_smooth(cubeviz_helper, image_cube_hdu_obj_microns):
cubeviz_helper.load_data(image_cube_hdu_obj_microns, data_label="test")
flux_unit = u.Unit("1E-17 erg*s^-1*cm^-2*Angstrom^-1")

gauss_plg = cubeviz_helper.plugins["Gaussian Smooth"]._obj
gauss_plg.mode_selected = "Spatial"
with pytest.warns(AstropyUserWarning, match="The following attributes were set on the data"):
_ = gauss_plg.smooth()

# Need this to make it available for photometry data drop-down.
cubeviz_helper.app.add_data_to_viewer("uncert-viewer", "test[FLUX] spatial-smooth stddev-1.0")

aper = RectanglePixelRegion(center=PixCoord(x=1, y=2), width=3, height=5)
cubeviz_helper.load_regions(aper)

plg = cubeviz_helper.plugins["Aperture Photometry"]._obj
plg.dataset_selected = "test[FLUX] spatial-smooth stddev-1.0"
plg.aperture_selected = "Subset 1"
plg.vue_do_aper_phot()
row = cubeviz_helper.get_aperture_photometry_results()[0]

# Basically, we should recover the input rectangle here.
assert_allclose(row["xcenter"], 1 * u.pix)
assert_allclose(row["ycenter"], 2 * u.pix)
sky = row["sky_center"]
assert_allclose(sky.ra.deg, 205.43985906934287)
assert_allclose(sky.dec.deg, 27.003490103642033)
assert_allclose(row["sum"], 48.54973 * flux_unit) # 3 (w) x 5 (h) x <5 (v)
assert_allclose(row["sum_aper_area"], 15 * (u.pix * u.pix)) # 3 (w) x 5 (h)
assert_allclose(row["mean"], 3.236648941040039 * flux_unit)
assert_quantity_allclose(row["slice_wave"], 4.894499866699333 * u.um)


def test_cubeviz_aperphot_cube_orig_flux_mjysr(cubeviz_helper, spectrum1d_cube_custom_fluxunit):
cube = spectrum1d_cube_custom_fluxunit(fluxunit=u.MJy / u.sr)
cubeviz_helper.load_data(cube, data_label="test")

aper = RectanglePixelRegion(center=PixCoord(x=1, y=3), width=1, height=1)
bg = RectanglePixelRegion(center=PixCoord(x=0, y=2), width=1, height=1)
cubeviz_helper.load_regions([aper, bg])

plg = cubeviz_helper.plugins["Aperture Photometry"]._obj
plg.dataset_selected = "test[FLUX]"
plg.aperture_selected = "Subset 1"
plg.background_selected = "Subset 2"

# Make sure per steradian is handled properly.
assert_allclose(plg.pixel_area, 0.01)
assert_allclose(plg.flux_scaling, 0.003631)

plg.vue_do_aper_phot()
row = cubeviz_helper.get_aperture_photometry_results()[0]

# Basically, we should recover the input rectangle here, minus background.
assert_allclose(row["xcenter"], 1 * u.pix)
assert_allclose(row["ycenter"], 3 * u.pix)
assert_allclose(row["sum"], 1.1752215e-12 * u.MJy) # (15 - 10) MJy/sr x 2.3504431e-13 sr
assert_allclose(row["sum_aper_area"], 1 * (u.pix * u.pix))
assert_allclose(row["pixarea_tot"], 2.350443053909789e-13 * u.sr)
assert_allclose(row["aperture_sum_mag"], 23.72476627732448 * u.mag)
assert_allclose(row["mean"], 5 * (u.MJy / u.sr))
assert_quantity_allclose(row["slice_wave"], 0.46236 * u.um)
2 changes: 1 addition & 1 deletion jdaviz/configs/cubeviz/plugins/tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def test_fits_image_hdu_with_microns(image_cube_hdu_obj_microns, cubeviz_helper)
flux_unit_str = "erg / (Angstrom cm2 s)"
else:
flux_unit_str = "erg / (Angstrom s cm2)"
assert label_mouseover.as_text() == (f'Pixel x=00.0 y=00.0 Value +1.00000e+00 1e-17 {flux_unit_str}', # noqa
assert label_mouseover.as_text() == (f'Pixel x=00.0 y=00.0 Value +5.00000e+00 1e-17 {flux_unit_str}', # noqa
'World 13h41m45.5759s +27d00m12.3044s (ICRS)',
'205.4398995981 27.0034178810 (deg)') # noqa
unc_viewer = cubeviz_helper.app.get_viewer('uncert-viewer')
Expand Down
Loading

0 comments on commit 9373a9e

Please sign in to comment.