Skip to content

Commit

Permalink
Merge pull request #1823 from pllim/pong
Browse files Browse the repository at this point in the history
Imviz: Allow Subset recentering via centroiding
  • Loading branch information
pllim authored Nov 14, 2022
2 parents 61f5d79 + e7c15a4 commit f0796f2
Show file tree
Hide file tree
Showing 11 changed files with 501 additions and 33 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Imviz

- Warnings in aperture photometry plugin when using raw profile with large subsets. [#1801]

- Subset Tools plugin now allows recentering of editable spatial subset. [#1823]

Mosviz
^^^^^^

Expand Down
9 changes: 8 additions & 1 deletion docs/imviz/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,21 @@ This plugin allows you to select an existing subset to modify, or to select
:guilabel:`Create new` to create a new subset by selecting and using the region selector
in the spectrum viewer toolbar. You can also choose the operation that will be
applied by the selector tool. Note that these are synched with the subset tools
in the app-level toolbar. It does not show static regions loaded
in the app-level toolbar. It might not show some static regions loaded
via the API unless an interactive region is drawn after.

If an existing subset is selected, the parameters of the subset will also be
shown. Note that while parameters for compound regions (e.g., a subset with
multiple disjoint regions) are displayed, the logical operations joining them
(``OR``, ``AND``, etc.) are not shown.

For a simple subset in Imviz only, you can choose to recenter it based
on the selected Data. The centroid is calculated by
:attr:`photutils.aperture.ApertureStats.centroid`, which is the
center-of-mass of the data within the aperture.
No background subtraction is performed. Click :guilabel:`Recenter`
to change its parameters and move it to the calculated centroid.

For a simple subset, you can edit its parameters by changing the values
in the corresponding editable text fields. Once you have entered the new
value(s), click :guilabel:`Update` to apply. You should see the subset
Expand Down
143 changes: 140 additions & 3 deletions jdaviz/configs/default/plugins/subset_plugin/subset_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from glue.core.roi import CircularROI, EllipticalROI, RectangularROI
from glue.core.subset import RoiSubsetState, RangeSubsetState, CompositeSubsetState
from glue_jupyter.widgets.subset_mode_vuetify import SelectionModeMenu
from traitlets import List, Unicode, Bool, observe
from traitlets import Any, List, Unicode, Bool, observe

from jdaviz.core.events import SnackbarMessage
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import PluginTemplateMixin, SubsetSelect
from jdaviz.core.template_mixin import PluginTemplateMixin, DatasetSelectMixin, SubsetSelect

__all__ = ['SubsetPlugin']

Expand All @@ -23,7 +23,7 @@


@tray_registry('g-subset-plugin', label="Subset Tools")
class SubsetPlugin(PluginTemplateMixin):
class SubsetPlugin(PluginTemplateMixin, DatasetSelectMixin):
template_file = __file__, "subset_plugin.vue"
select = List([]).tag(sync=True)
subset_items = List([]).tag(sync=True)
Expand All @@ -34,6 +34,8 @@ class SubsetPlugin(PluginTemplateMixin):
subset_definitions = List([]).tag(sync=True)
has_subset_details = Bool(False).tag(sync=True)

subplugins_opened = Any().tag(sync=True)

is_editable = Bool(False).tag(sync=True)

def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -226,6 +228,141 @@ def vue_update_subset(self, *args):
self.hub.broadcast(SnackbarMessage(
f"Failed to update Subset: {repr(err)}", color='error', sender=self))

def vue_recenter_subset(self, *args):
# Composite region cannot be edited. This only works for Imviz.
if not self.is_editable or self.config != 'imviz': # no-op
raise NotImplementedError(
f'Cannot recenter: is_editable={self.is_editable}, config={self.config}')

from photutils.aperture import ApertureStats
from jdaviz.core.region_translators import regions2aperture, _get_region_from_spatial_subset

try:
reg = _get_region_from_spatial_subset(self, self.subset_selected)
aperture = regions2aperture(reg)
data = self.dataset.selected_dc_item
comp = data.get_component(data.main_components[0])
comp_data = comp.data
phot_aperstats = ApertureStats(comp_data, aperture)

# Centroid was calculated in selected data, which might or might not be
# the reference data. However, Subset is always defined w.r.t.
# the reference data, so we need to convert back.
viewer = self.app._jdaviz_helper.default_viewer
x, y, _ = viewer._get_real_xy(
data, phot_aperstats.xcentroid, phot_aperstats.ycentroid, reverse=True)
if not np.all(np.isfinite((x, y))):
raise ValueError(f'Invalid centroid ({x}, {y})')
except Exception as err:
self.set_center(self.get_center(), update=False)
self.hub.broadcast(SnackbarMessage(
f"Failed to calculate centroid: {repr(err)}", color='error', sender=self))
else:
self.set_center((x, y), update=True)

def get_center(self):
"""Return the center of the Subset.
This may or may not be the centroid obtain from data.
Returns
-------
cen : number, tuple of numbers, or `None`
The center of the Subset in ``x`` or ``(x, y)``,
depending on the Subset type, if applicable.
If Subset is not editable, this returns `None`.
Raises
------
NotImplementedError
Subset type is not supported.
"""
# Composite region cannot be edited.
if not self.is_editable: # no-op
return

subset_state = self.subset_select.selected_subset_state

if isinstance(subset_state, RoiSubsetState):
sbst_obj = subset_state.roi
if isinstance(sbst_obj, (CircularROI, EllipticalROI)):
cen = sbst_obj.get_center()
elif isinstance(sbst_obj, RectangularROI):
cen = sbst_obj.center()
else: # pragma: no cover
raise NotImplementedError(
f'Getting center of {sbst_obj.__class__} is not supported')

elif isinstance(subset_state, RangeSubsetState):
cen = (subset_state.hi - subset_state.lo) * 0.5 + subset_state.lo

else: # pragma: no cover
raise NotImplementedError(
f'Getting center of {subset_state.__class__} is not supported')

return cen

def set_center(self, new_cen, update=False):
"""Set the desired center for the selected Subset, if applicable.
If Subset is not editable, nothing is done.
Parameters
----------
new_cen : number or tuple of numbers
The new center defined either as ``x`` or ``(x, y)``,
depending on the Subset type.
update : bool
If `True`, the Subset is also moved to the new center.
Otherwise, only the relevant editable fields are updated but the
Subset is not moved.
Raises
------
NotImplementedError
Subset type is not supported.
"""
# Composite region cannot be edited, so just grab first element.
if not self.is_editable: # no-op
return

subset_state = self.subset_select.selected_subset_state

if isinstance(subset_state, RoiSubsetState):
x, y = new_cen
sbst_obj = subset_state.roi
if isinstance(sbst_obj, (CircularROI, EllipticalROI)):
self._set_value_in_subset_definition(0, "X Center", "value", x)
self._set_value_in_subset_definition(0, "Y Center", "value", y)
elif isinstance(sbst_obj, RectangularROI):
cx, cy = sbst_obj.center()
dx = x - cx
dy = y - cy
self._set_value_in_subset_definition(0, "Xmin", "value", sbst_obj.xmin + dx)
self._set_value_in_subset_definition(0, "Xmax", "value", sbst_obj.xmax + dx)
self._set_value_in_subset_definition(0, "Ymin", "value", sbst_obj.ymin + dy)
self._set_value_in_subset_definition(0, "Ymax", "value", sbst_obj.ymax + dy)
else: # pragma: no cover
raise NotImplementedError(f'Recentering of {sbst_obj.__class__} is not supported')

elif isinstance(subset_state, RangeSubsetState):
dx = new_cen - ((subset_state.hi - subset_state.lo) * 0.5 + subset_state.lo)
self._set_value_in_subset_definition(0, "Lower bound", "value", subset_state.lo + dx)
self._set_value_in_subset_definition(0, "Upper bound", "value", subset_state.hi + dx)

else: # pragma: no cover
raise NotImplementedError(
f'Getting center of {subset_state.__class__} is not supported')

if update:
self.vue_update_subset()
else:
# Force UI to update on browser without changing the subset.
tmp = self.subset_definitions
self.subset_definitions = []
self.subset_definitions = tmp

# List of JSON-like dict is nice for front-end but a pain to look up,
# so we use these helper functions.

Expand Down
23 changes: 23 additions & 0 deletions jdaviz/configs/default/plugins/subset_plugin/subset_plugin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@
</v-col>
</v-row>

<!-- Sub-plugin for recentering of spatial subset (Imviz only) -->
<v-row v-if="config=='imviz' && is_editable">
<v-expansion-panels accordion v-model="subplugins_opened">
<v-expansion-panel>
<v-expansion-panel-header >
<span style="padding: 6px">Recenter</span>
</v-expansion-panel-header>
<v-expansion-panel-content>
<plugin-dataset-select
:items="dataset_items"
:selected.sync="dataset_selected"
:show_if_single_entry="true"
label="Data"
hint="Select the data for centroiding."
/>
<v-row justify="end" no-gutters>
<v-btn color="primary" text @click="recenter_subset">Recenter</v-btn>
</v-row>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</v-row>

<!-- Composite region cannot be edited, so just grab first element. -->
<div v-if="is_editable">
<v-row v-for="(item, index2) in subset_definitions[0]"
Expand Down
28 changes: 3 additions & 25 deletions jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from jdaviz.core.custom_traitlets import FloatHandleEmpty
from jdaviz.core.events import SnackbarMessage, LinkUpdatedMessage
from jdaviz.core.region_translators import regions2aperture
from jdaviz.core.region_translators import regions2aperture, _get_region_from_spatial_subset
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import PluginTemplateMixin, DatasetSelectMixin, SubsetSelect
from jdaviz.utils import bqplot_clear_figure, PRIHDR_KEY
Expand Down Expand Up @@ -142,28 +142,6 @@ def _dataset_selected_changed(self, event={}):
# and auto-populate background, if applicable.
self._subset_selected_changed()

def _get_region_from_subset(self, subset):
for subset_grp in self.app.data_collection.subset_groups:
if subset_grp.label == subset:
for sbst in subset_grp.subsets:
if sbst.data.label == self.dataset_selected:
reg = sbst.data.get_selection_definition(
subset_id=subset, format='astropy-regions')
# Works around https://github.com/glue-viz/glue-astronomy/issues/52
# Assume it is always pixel region, not sky region. Even with multiple
# viewers, they all seem to share the same reference image even when it is
# not loaded in all the viewers, so use default viewer.
viewer = self.app._jdaviz_helper.default_viewer

x, y, _ = viewer._get_real_xy(
self.app.data_collection[self.dataset_selected],
reg.center.x, reg.center.y)
reg.center.x = x
reg.center.y = y
return reg
else:
raise ValueError(f'Subset "{subset}" not found')

def _on_subset_update(self, msg):
if self.dataset_selected == '' or self.subset_selected == '':
return
Expand All @@ -189,7 +167,7 @@ def _subset_selected_changed(self, event={}):
return

try:
self._selected_subset = self._get_region_from_subset(subset_selected)
self._selected_subset = _get_region_from_spatial_subset(self, subset_selected)
self._selected_subset.meta['label'] = subset_selected

if isinstance(self._selected_subset, CirclePixelRegion):
Expand Down Expand Up @@ -251,7 +229,7 @@ def _bg_subset_selected_changed(self, event={}):
return

try:
reg = self._get_region_from_subset(bg_subset_selected)
reg = _get_region_from_spatial_subset(self, bg_subset_selected)
self.background_value = self._calc_bg_subset_median(reg)
except Exception as e:
self.background_value = 0
Expand Down
13 changes: 10 additions & 3 deletions jdaviz/configs/imviz/plugins/viewers.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,15 @@ def on_limits_change(self, *args):
return
self.set_compass(self.state.layers[i].layer)

def _get_real_xy(self, image, x, y):
def _get_real_xy(self, image, x, y, reverse=False):
"""Return real (X, Y) position and status in case of dithering.
``coords_status`` is for ``self.label_mouseover`` coords handling only.
When `True`, it sets the coords, otherwise it resets.
``reverse=True`` is only for internal roundtripping (e.g., centroiding
in Subset Tools plugin). Never use this for coordinates display panel.
"""
if data_has_valid_wcs(image):
# Convert these to a SkyCoord via WCS - note that for other datasets
Expand All @@ -235,8 +238,12 @@ def _get_real_xy(self, image, x, y):
# Convert X,Y from reference data to the one we are actually seeing.
# world_to_pixel return scalar ndarray that we need to convert to float.
if self.get_link_type(image.label) == 'wcs':
x, y = list(map(float, image.coords.world_to_pixel(
self.state.reference_data.coords.pixel_to_world(x, y))))
if not reverse:
x, y = list(map(float, image.coords.world_to_pixel(
self.state.reference_data.coords.pixel_to_world(x, y))))
else:
x, y = list(map(float, self.state.reference_data.coords.world_to_pixel(
image.coords.pixel_to_world(x, y))))
coords_status = True
except Exception:
coords_status = False
Expand Down
64 changes: 64 additions & 0 deletions jdaviz/configs/imviz/tests/test_subset_centroid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from regions import PixCoord, CirclePixelRegion

from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_GWCS


class TestImvizSpatialSubsetCentroid(BaseImviz_WCS_GWCS):
def test_centroiding(self):
reg = CirclePixelRegion(PixCoord(2, 2), 3)
self.imviz.load_regions(reg)

plg = self.imviz.plugins['Subset Tools']
plg._obj.subset_selected = 'Subset 1'

# Since they are linked by pixels, the bright corner pixel aligns
# and nothing should change.
for data_label in ('fits_wcs[DATA]', 'gwcs[DATA]', 'no_wcs'):
plg._obj.dataset_selected = data_label
plg._obj.set_center((2, 2), update=True) # Move the Subset back first.
plg._obj.vue_recenter_subset()

# Calculate and move to centroid.
for key in ("X Center", "Y Center"):
assert plg._obj._get_value_from_subset_definition(0, key, "value") == -1
assert plg._obj._get_value_from_subset_definition(0, key, "orig") == -1

# Radius will not be touched.
for key in ("value", "orig"):
assert plg._obj._get_value_from_subset_definition(0, "Radius", key) == 3

assert plg._obj.get_center() == (-1, -1)

# FITS WCS and GWCS are rotated from each other.
# Plain array always by pixel wrt FITS WCS.
self.imviz.link_data(link_type='wcs')

plg._obj.dataset_selected = 'fits_wcs[DATA]'
plg._obj.set_center((2, 2), update=True) # Move the Subset back first.
plg._obj.vue_recenter_subset()
for key in ("X Center", "Y Center"):
assert plg._obj._get_value_from_subset_definition(0, key, "value") == -1
assert plg._obj._get_value_from_subset_definition(0, key, "orig") == -1

# GWCS does not extrapolate and this Subset is out of bounds,
# so will get NaNs and enter the exception handling logic.
plg._obj.dataset_selected = 'gwcs[DATA]'
plg._obj.set_center((2, 2), update=True) # Move the Subset back first.
plg._obj.vue_recenter_subset()
for key in ("X Center", "Y Center"):
assert plg._obj._get_value_from_subset_definition(0, key, "value") == 2
assert plg._obj._get_value_from_subset_definition(0, key, "orig") == 2

# No WCS case will be same as FITS WCS.
plg._obj.dataset_selected = 'no_wcs'
plg._obj.vue_recenter_subset()
for key in ("X Center", "Y Center"):
assert plg._obj._get_value_from_subset_definition(0, key, "value") == -1
assert plg._obj._get_value_from_subset_definition(0, key, "orig") == -1

# This ends up not getting used in production but just in case we need it
# back in the future.
plg._obj.set_center((2, 2), update=False)
for key in ("X Center", "Y Center"):
assert plg._obj._get_value_from_subset_definition(0, key, "value") == 2
assert plg._obj._get_value_from_subset_definition(0, key, "orig") == -1
Loading

0 comments on commit f0796f2

Please sign in to comment.