Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add masked subset support #2462

Merged
merged 3 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ New Features

- Data color cycler and marker color updates for increased accessibility. [#2453]

- Add support for ``MultiMaskSubsetState`` in ``viz.app.get_subsets()`` and in
the Subset Plugin [#2462]

Cubeviz
^^^^^^^

Expand Down
33 changes: 28 additions & 5 deletions jdaviz/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
from jdaviz.core.registries import (tool_registry, tray_registry, viewer_registry,
data_parser_registry)
from jdaviz.core.tools import ICON_DIR
from jdaviz.utils import SnackbarQueue, alpha_index
from jdaviz.utils import SnackbarQueue, alpha_index, MultiMaskSubsetState
from ipypopout import PopoutButton

__all__ = ['Application', 'ALL_JDAVIZ_CONFIGS']
Expand Down Expand Up @@ -972,10 +972,15 @@ def get_subsets(self, subset_name=None, spectral_only=False,
subset_region = self._get_range_subset_bounds(subset.subset_state,
simplify_spectral,
use_display_units)
elif isinstance(subset.subset_state, MultiMaskSubsetState):
subset_region = self._get_multi_mask_subset_definition(subset.subset_state)
else:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What else might still fall under this else? Can it return any reasonable default in that case or raise an error instead? I just worry about future cases where nothing getting returned could result in unintentionally missing something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a preference between reasonable default and raise error? I lean towards default but I can see the merits of either one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think default is probably fine for this case, even if it doesn't contain any information besides the class name or something, that is still more instructive than a missing entry.

# subset.subset_state can be an instance of MaskSubsetState
# or something else we do not know how to handle
all_subsets[label] = None
# subset.subset_state can be an instance of something else
# we do not know how to handle yet
all_subsets[label] = [{"name": subset.subset_state.__class__.__name__,
"glue_state": subset.subset_state.__class__.__name__,
"region": None,
"subset_state": subset.subset_state}]
continue

# Is the subset spectral, spatial, temporal?
Expand All @@ -997,6 +1002,7 @@ def get_subsets(self, subset_name=None, spectral_only=False,
else:
all_subsets[label] = subset_region
elif not spectral_only and not spatial_only:
# General else statement if no type was specified
if object_only and not isinstance(subset_region, SpectralRegion):
all_subsets[label] = [reg['region'] for reg in subset_region]
else:
Expand Down Expand Up @@ -1068,6 +1074,12 @@ def _get_range_subset_bounds(self, subset_state,
"subset_state": subset_state}]
return spec_region

def _get_multi_mask_subset_definition(self, subset_state):
return [{"name": subset_state.__class__.__name__,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you're explicitly wrapping the dict in a list here. What was the reason for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for this is that MultiMask could be one region of many in a composite subset. To return all that information, we use a list of dictionaries which contain information on those subregions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, I missed (or forgot) that get_subsets() always returns a list of dictionaries now, even the subset is a single region. As long as it's consistent, carry on nothing to see here 😄

"glue_state": subset_state.__class__.__name__,
"region": subset_state.total_masked_first_data(),
"subset_state": subset_state}]

def _get_roi_subset_definition(self, subset_state):
# TODO: Imviz: Return sky region if link type is WCS.
roi_as_region = roi_subset_state_to_region(subset_state)
Expand All @@ -1088,6 +1100,15 @@ def get_sub_regions(self, subset_state, simplify_spectral=True, use_display_unit
if isinstance(one, list) and "glue_state" in one[0]:
one[0]["glue_state"] = subset_state.__class__.__name__

if (isinstance(one, list)
and isinstance(one[0]["subset_state"], MultiMaskSubsetState)
and simplify_spectral):
return two
elif (isinstance(two, list)
and isinstance(two[0]["subset_state"], MultiMaskSubsetState)
and simplify_spectral):
return one

if isinstance(subset_state.state2, InvertState):
# This covers the REMOVE subset mode

Expand Down Expand Up @@ -1206,13 +1227,15 @@ def get_sub_regions(self, subset_state, simplify_spectral=True, use_display_unit
simplify_spectral, use_display_units)
elif subset_state is not None:
# This is the leaf node of the glue subset state tree where
# a subset_state is either ROI or Range.
# a subset_state is either ROI, Range, or MultiMask.
if isinstance(subset_state, RoiSubsetState):
return self._get_roi_subset_definition(subset_state)

elif isinstance(subset_state, RangeSubsetState):
return self._get_range_subset_bounds(subset_state,
simplify_spectral, use_display_units)
elif isinstance(subset_state, MultiMaskSubsetState):
return self._get_multi_mask_subset_definition(subset_state)

def _get_display_unit(self, axis):
if self._jdaviz_helper is None or self._jdaviz_helper.plugins.get('Unit Conversion') is None: # noqa
Expand Down
26 changes: 24 additions & 2 deletions jdaviz/configs/default/plugins/subset_plugin/subset_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import PluginTemplateMixin, DatasetSelectMixin, SubsetSelect
from jdaviz.core.tools import ICON_DIR
from jdaviz.utils import MultiMaskSubsetState

__all__ = ['SubsetPlugin']

Expand Down Expand Up @@ -51,6 +52,7 @@ class SubsetPlugin(PluginTemplateMixin, DatasetSelectMixin):
multiselect = Bool(False).tag(sync=True)
is_centerable = Bool(False).tag(sync=True)
can_simplify = Bool(False).tag(sync=True)
can_freeze = Bool(False).tag(sync=True)

icon_replace = Unicode(read_icon(os.path.join(icon_path("glue_replace", icon_format="svg")), 'svg+xml')).tag(sync=True) # noqa
icon_or = Unicode(read_icon(os.path.join(icon_path("glue_or", icon_format="svg")), 'svg+xml')).tag(sync=True) # noqa
Expand Down Expand Up @@ -227,6 +229,13 @@ def _unpack_get_subsets_for_ui(self):
{"name": "Upper bound", "att": "hi", "value": hi.value,
"orig": hi.value, "unit": str(hi.unit)}]
subset_type = "Range"

elif isinstance(subset_state, MultiMaskSubsetState):
total_masked = subset_state.total_masked_first_data()
subset_definition = [{"name": "Masked values", "att": "masked",
"value": total_masked,
"orig": total_masked}]
subset_type = "Mask"
if len(subset_definition) > 0:
# Note: .append() does not work for List traitlet.
self.subset_definitions = self.subset_definitions + [subset_definition]
Expand All @@ -236,8 +245,11 @@ def _unpack_get_subsets_for_ui(self):

simplifiable_states = set(['AndState', 'XorState', 'AndNotState'])
# Check if the subset has more than one subregion, is a range subset type, and
# uses one of the states that can be simplified.
if (len(self.subset_states) > 1 and isinstance(self.subset_states[0], RangeSubsetState)
# uses one of the states that can be simplified. Mask subset types cannot be simplified
# so subsets contained that are skipped.
if 'Mask' in self.subset_types:
self.can_simplify = False
elif (len(self.subset_states) > 1 and isinstance(self.subset_states[0], RangeSubsetState)
and len(simplifiable_states - set(self.glue_state_types)) < 3):
self.can_simplify = True
elif (len(self.subset_states) > 1 and isinstance(self.subset_states[0], RangeSubsetState)
Expand All @@ -258,6 +270,16 @@ def _get_subset_definition(self, *args):

self._unpack_get_subsets_for_ui()

def vue_freeze_subset(self, *args):
sgs = {sg.label: sg for sg in self.app.data_collection.subset_groups}
sg = sgs.get(self.subset_selected)

masks = {}
for data in self.app.data_collection:
masks[data.uuid] = sg.subset_state.to_mask(data)

sg.subset_state = MultiMaskSubsetState(masks)

def vue_simplify_subset(self, *args):
if self.multiselect:
self.hub.broadcast(SnackbarMessage("Cannot simplify spectral subset "
Expand Down
21 changes: 12 additions & 9 deletions jdaviz/configs/default/plugins/subset_plugin/subset_plugin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@
</v-row>

<v-row v-for="(item, index2) in region" class="row-no-outside-padding">
<v-text-field v-if="item.name === 'Parent'"
<v-text-field v-if="item.name === 'Parent' || item.name === 'Masked values'"
:label="item.name"
:value="item.value"
style="padding-top: 0px; margin-top: 0px"
:readonly="true"
hint="Subset was defined with respect to this reference data (read-only)"
:hint="item.name === 'Parent' ? 'Subset was defined with respect to this reference data (read-only)' : 'Number of elements included by mask'"
></v-text-field>
<v-text-field v-if="item.name !== 'Parent'"
<v-text-field v-if="item.name !== 'Parent' && item.name !== 'Masked values'"
:label="item.name"
v-model.number="item.value"
type="number"
Expand All @@ -112,11 +112,14 @@
</v-row>
</div>

<v-row v-if="!multiselect" justify="end">
<j-tooltip v-if="can_simplify" tooltipcontent="Convert composite subset to use only add mode to connect subregions">
<v-btn color="primary" text @click="simplify_subset">Simplify</v-btn>
</j-tooltip>
<v-btn color="primary" text @click="update_subset">Update</v-btn>
</v-row>
<v-row v-if="!multiselect" justify="end" no-gutters>
<j-tooltip v-if="can_freeze" tooltipcontent="Freeze subset to a mask on the underlying data entries">
<v-btn color="primary" text @click="freeze_subset">Freeze</v-btn>
</j-tooltip>
<j-tooltip tooltipcontent="Convert composite subset to use only add mode to connect subregions">
<v-btn :disabled="!can_simplify" color="primary" text @click="simplify_subset">Simplify</v-btn>
</j-tooltip>
<v-btn color="primary" text @click="update_subset">Update</v-btn>
</v-row>
</j-tray-plugin>
</template>
50 changes: 49 additions & 1 deletion jdaviz/tests/test_subsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from specutils import SpectralRegion, Spectrum1D

from jdaviz.core.marks import ShadowSpatialSpectral
from jdaviz.utils import get_subset_type
from jdaviz.utils import get_subset_type, MultiMaskSubsetState


def test_region_from_subset_2d(cubeviz_helper):
Expand Down Expand Up @@ -765,3 +765,51 @@ def test_only_overlapping_in_specviz2d(specviz2d_helper, mos_spectrum2d):
reg = specviz2d_helper.app.get_subsets("Subset 1")
assert reg[0].lower.value == 6400 and reg[0].upper.value == 7400
assert reg[1].lower.value == 7600 and reg[1].upper.value == 7800


def test_multi_mask_subset(specviz_helper, spectrum1d):
specviz_helper.load_data(spectrum1d)
viewer = specviz_helper.app.get_viewer(specviz_helper._default_spectrum_viewer_reference_name)

viewer.apply_roi(XRangeROI(6200, 6800))

plugin = specviz_helper.app.get_tray_item_from_name("g-subset-plugin")
plugin.can_freeze = True
plugin.vue_freeze_subset()

reg = specviz_helper.app.get_subsets()
assert reg["Subset 1"][0]["region"] == 3
assert isinstance(reg["Subset 1"][0]["subset_state"], MultiMaskSubsetState)

specviz_helper.app.session.edit_subset_mode.mode = OrMode
viewer.apply_roi(XRangeROI(7200, 7600))

# Simplify subset ignores Mask subsets
reg = specviz_helper.app.get_subsets()
assert (reg["Subset 1"].lower.value == 7200
and reg["Subset 1"].upper.value == 7600)

# If we set simplify to False, we see all subregions
reg = specviz_helper.app.get_subsets(simplify_spectral=False)
assert (reg["Subset 1"][1]["region"].lower.value == 7200
and reg["Subset 1"][1]["region"].upper.value == 7600)
assert reg["Subset 1"][0]["region"] == 3
assert plugin.can_simplify is False

# If we freeze again, all subregions become a Mask subset object
plugin.vue_freeze_subset()
reg = specviz_helper.app.get_subsets()
assert reg["Subset 1"][0]["region"] == 5

# When freezing an AndNot state, the number of mask values should decrease
specviz_helper.app.session.edit_subset_mode.mode = AndNotMode
viewer.apply_roi(XRangeROI(6600, 7200))

reg = specviz_helper.app.get_subsets(simplify_spectral=False)
assert (reg["Subset 1"][1]["region"].lower.value == 6600
and reg["Subset 1"][1]["region"].upper.value == 7200)
assert reg["Subset 1"][0]["region"] == 5

plugin.vue_freeze_subset()
reg = specviz_helper.app.get_subsets()
assert reg["Subset 1"][0]["region"] == 4
46 changes: 45 additions & 1 deletion jdaviz/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import time
import threading
from collections import deque
import numpy as np

from astropy.io import fits
from astropy.utils import minversion
from ipyvue import watch
from glue.core.exceptions import IncompatibleAttribute
from glue.config import settings
from glue.core.subset import RangeSubsetState, RoiSubsetState
from glue.core.subset import SubsetState, RangeSubsetState, RoiSubsetState


__all__ = ['SnackbarQueue', 'enable_hot_reloading', 'bqplot_clear_figure',
Expand Down Expand Up @@ -305,3 +307,45 @@ def get_subset_type(subset):
return 'spectral'
else:
return None


class MultiMaskSubsetState(SubsetState):
"""
A subset state that can include a different mask for different datasets.
Adopted from https://github.com/glue-viz/glue/pull/2415
Parameters
----------
masks : dict
A dictionary mapping data UUIDs to boolean arrays with the same
dimensions as the data arrays.
"""

def __init__(self, masks=None):
super(MultiMaskSubsetState, self).__init__()
self._masks = masks

def to_mask(self, data, view=None):
if data.uuid in self._masks:
mask = self._masks[data.uuid]
if view is not None:
mask = mask[view]
return mask
else:
raise IncompatibleAttribute()

def copy(self):
return MultiMaskSubsetState(masks=self._masks)

def __gluestate__(self, context):
serialized = {key: context.do(value) for key, value in self._masks.items()}
return {'masks': serialized}

def total_masked_first_data(self):
first_data = next(iter(self._masks))
return len(np.where(self._masks[first_data])[0])

@classmethod
def __setgluestate__(cls, rec, context):
masks = {key: context.object(value) for key, value in rec['masks'].items()}
return cls(masks=masks)