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

Implement associations between Data layers #2761

Merged
merged 5 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
74 changes: 70 additions & 4 deletions jdaviz/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@
self.hub.subscribe(self, SubsetUpdateMessage,
handler=lambda msg: self._clear_object_cache(msg.subset.label))

# Store for associations between Data entries:
self._data_associations = self._init_data_associations()

# Subscribe to messages that result in changes to the layers
self.hub.subscribe(self, AddDataMessage,
handler=self._on_layers_changed)
Expand Down Expand Up @@ -473,9 +476,11 @@
if hasattr(msg, 'data'):
layer_name = msg.data.label
is_wcs_only = msg.data.meta.get(_wcs_only_label, False)
is_not_child = self._get_assoc_data_parent(layer_name) is None
elif hasattr(msg, 'subset'):
layer_name = msg.subset.label
is_wcs_only = False
is_not_child = True
else:
raise NotImplementedError(f"cannot recognize new layer from {msg}")

Expand All @@ -490,13 +495,25 @@
self.state.layer_icons = {**self.state.layer_icons,
layer_name: orientation_icons.get(layer_name,
wcs_only_refdata_icon)}
else:
elif is_not_child:
self.state.layer_icons = {
**self.state.layer_icons,
layer_name: alpha_index(len([ln for ln, ic in self.state.layer_icons.items()
if not ic.startswith('mdi-')]))
if not ic.startswith('mdi-') and
self._get_assoc_data_parent(ln) is None]))
}

# all remaining layers at this point have a parent:
for layer_name in self.state.layer_icons:
children_layers = self._get_assoc_data_children(layer_name)
if children_layers is not None:
parent_icon = self.state.layer_icons[layer_name]
for i, child_layer in enumerate(children_layers, start=1):
self.state.layer_icons = {
**self.state.layer_icons,
child_layer: f'{parent_icon}{i}'
}
Copy link
Member

Choose a reason for hiding this comment

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

this will update the state a bunch of times. Can we instead either build this logic into above or use a dictionary-comprehension to build all the new entries and then just apply to the state once?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea. In 00785d7, I instead gather the child layer icon updates in a different dict, and update self.state.layer_icons once at the end.


def _change_reference_data(self, new_refdata_label, viewer_id=None):
"""
Change reference data to Data with ``data_label``.
Expand Down Expand Up @@ -1251,7 +1268,7 @@

return new_state

def add_data(self, data, data_label=None, notify_done=True):
def add_data(self, data, data_label=None, notify_done=True, parent=None):
"""
Add data to the Glue ``DataCollection``.

Expand All @@ -1266,10 +1283,12 @@
The name associated with this data. If none is given, label is pulled
from the input data (if `~glue.core.data.Data`) or a generic name is
generated.
notify_done: bool
notify_done : bool
Flag controlling whether a snackbar message is set when the data is
added to the app. Set to False to avoid overwhelming the user if
lots of data is getting loaded at once.
parent : str, optional
Associate the added Data entry as the child of layer ``parent``.
"""

if not data_label and hasattr(data, "label"):
Expand All @@ -1280,6 +1299,18 @@

self.data_collection[data_label] = data

# manage associated Data entries:
self._add_assoc_data_as_parent(data_label)
if parent is not None:
# Does the parent Data have a parent? If so, raise error:
parent_of_parent = self._get_assoc_data_parent(parent)
if parent_of_parent is not None:
raise NotImplementedError('Data associations are currently supported '
'between root layers (without parents) and their '
f'children, but the proposed parent "{parent}" has '
f'parent "{parent_of_parent}".')
self._set_assoc_data_as_child(data_label, new_parent_label=parent)
Copy link
Member

Choose a reason for hiding this comment

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

do we also need to validate that parent exists in the data-collection?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure thing, done in 55d2e68.


# Send out a toast message
if notify_done:
snackbar_message = SnackbarMessage(
Expand Down Expand Up @@ -1998,6 +2029,17 @@
if layer.layer.data.label != data_label:
layer.visible = False

# if Data has children, update their visibilities to match Data:
assoc_children = self._get_assoc_data_children(data_label)
for layer in viewer.layers:
for data_label in assoc_children:
if layer.layer.data.label == data_label:
if visible and not layer.visible:
layer.visible = True
layer.update()

Check warning on line 2039 in jdaviz/app.py

View check run for this annotation

Codecov / codecov/patch

jdaviz/app.py#L2036-L2039

Added lines #L2036 - L2039 were not covered by tests
else:
layer.visible = visible

Check warning on line 2041 in jdaviz/app.py

View check run for this annotation

Codecov / codecov/patch

jdaviz/app.py#L2041

Added line #L2041 was not covered by tests

# update data menu - selected_data_items should be READ ONLY, not modified by the user/UI
selected_items = viewer_item['selected_data_items']
data_id = self._data_id_from_label(data_label)
Expand Down Expand Up @@ -2577,3 +2619,27 @@
raise KeyError(f'{name} not found in app.state.tray_items')

return tray_item

def _init_data_associations(self):
# assume all Data are parents:
data_associations = {
data.label: {'parent': None, 'children': []}
for data in self.data_collection
}
return data_associations

def _add_assoc_data_as_parent(self, data_label):
self._data_associations[data_label] = {'parent': None, 'children': []}

def _set_assoc_data_as_child(self, data_label, new_parent_label):
# Data has a new parent:
self._data_associations[data_label]['parent'] = new_parent_label
# parent has a new child:
self._data_associations[new_parent_label]['children'].append(data_label)

def _get_assoc_data_children(self, data_label):
# intentionally not recursive for now, just one generation:
return self._data_associations.get(data_label, {}).get('children', [])

def _get_assoc_data_parent(self, data_label):
return self._data_associations.get(data_label, {}).get('parent')
25 changes: 17 additions & 8 deletions jdaviz/configs/imviz/plugins/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@


@data_parser_registry("imviz-data-parser")
def parse_data(app, file_obj, ext=None, data_label=None):
def parse_data(app, file_obj, ext=None, data_label=None, parent=None):
"""Parse a data file into Imviz.

Parameters
Expand Down Expand Up @@ -74,27 +74,27 @@
else: # Assume RGB
pf = rgb2gray(im)
pf = pf[::-1, :] # Flip it
_parse_image(app, pf, data_label, ext=ext)
_parse_image(app, pf, data_label, ext=ext, parent=parent)

elif file_obj_lower.endswith('.asdf'):
try:
if HAS_ROMAN_DATAMODELS:
with rdd.open(file_obj) as pf:
_parse_image(app, pf, data_label, ext=ext)
_parse_image(app, pf, data_label, ext=ext, parent=parent)

Check warning on line 83 in jdaviz/configs/imviz/plugins/parsers.py

View check run for this annotation

Codecov / codecov/patch

jdaviz/configs/imviz/plugins/parsers.py#L83

Added line #L83 was not covered by tests
except TypeError:
# if roman_datamodels cannot parse the file, load it with asdf:
with asdf.open(file_obj) as af:
_parse_image(app, af, data_label, ext=ext)
_parse_image(app, af, data_label, ext=ext, parent=parent)

Check warning on line 87 in jdaviz/configs/imviz/plugins/parsers.py

View check run for this annotation

Codecov / codecov/patch

jdaviz/configs/imviz/plugins/parsers.py#L87

Added line #L87 was not covered by tests

elif file_obj_lower.endswith('.reg'):
# This will load DS9 regions as Subset but only if there is already data.
app._jdaviz_helper.load_regions_from_file(file_obj)

else: # Assume FITS
with fits.open(file_obj) as pf:
_parse_image(app, pf, data_label, ext=ext)
_parse_image(app, pf, data_label, ext=ext, parent=parent)
else:
_parse_image(app, file_obj, data_label, ext=ext)
_parse_image(app, file_obj, data_label, ext=ext, parent=parent)


def get_image_data_iterator(app, file_obj, data_label, ext=None):
Expand Down Expand Up @@ -168,7 +168,7 @@
return data_iter


def _parse_image(app, file_obj, data_label, ext=None):
def _parse_image(app, file_obj, data_label, ext=None, parent=None):
if app is None:
raise ValueError("app is None, cannot proceed")
if data_label is None:
Expand All @@ -186,7 +186,16 @@
data.coords.bounding_box = None
if not data.meta.get(_wcs_only_label, False):
data_label = app.return_data_label(data_label, alt_name="image_data")
app.add_data(data, data_label)

# TODO: generalize/centralize this for use in other configs too
if parent is not None and ext == 'DQ':
# nans are used to mark "good" flags in the DQ colormap, so
# convert DQ array to float to support nans:
cid = data.get_component("DQ")
data_arr = np.float32(cid.data)
data_arr[data_arr == 0] = np.nan
data.update_components({cid: data_arr})

Check warning on line 197 in jdaviz/configs/imviz/plugins/parsers.py

View check run for this annotation

Codecov / codecov/patch

jdaviz/configs/imviz/plugins/parsers.py#L194-L197

Added lines #L194 - L197 were not covered by tests
app.add_data(data, data_label, parent=parent)

# Do not link image data here. We do it at the end in Imviz.load_data()

Expand Down
18 changes: 15 additions & 3 deletions jdaviz/configs/imviz/plugins/viewers.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,16 @@ def blink_once(self, reversed=False):
# Simple blinking of images - this will make it so that only one
# layer is visible at a time and cycles through the layers.

# Exclude Subsets: They are global
# Exclude Subsets (they are global) and children via associated data

def is_parent(data):
return self.session.jdaviz_app._get_assoc_data_parent(data.label) is None

valid = [ilayer for ilayer, layer in enumerate(self.state.layers)
if layer_is_image_data(layer.layer)]
if layer_is_image_data(layer.layer) and is_parent(layer.layer)]
children = [ilayer for ilayer, layer in enumerate(self.state.layers)
if layer_is_image_data(layer.layer) and not is_parent(layer.layer)]

n_layers = len(valid)

if n_layers == 1:
Expand Down Expand Up @@ -116,7 +123,12 @@ def blink_once(self, reversed=False):
next_layer = valid[(valid.index(visible[-1]) + delta) % n_layers]
self.state.layers[next_layer].visible = True

for ilayer in (set(valid) - set([next_layer])):
# make invisible all parent layers other than the next layer:
layers_to_set_not_visible = set(valid) - set([next_layer])
# no child layers are visible by default:
layers_to_set_not_visible.update(set(children))

for ilayer in layers_to_set_not_visible:
self.state.layers[ilayer].visible = False

# We can display the active data label in Compass plugin.
Expand Down
12 changes: 12 additions & 0 deletions jdaviz/core/template_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,12 @@ def __init__(self, plugin, items, selected, viewer,
self._update_layer_items()
self.update_wcs_only_filter(only_wcs_layers)

# ignore layers that are children in associations:
def is_parent(data):
return self.app._get_assoc_data_parent(data.label) is None

self.add_filter(is_parent)

def _get_viewer(self, viewer):
# newer will likely be the viewer name in most cases, but viewer id in the case
# of additional viewers in imviz.
Expand Down Expand Up @@ -2923,6 +2929,12 @@ def __init__(self, plugin, items, selected,
# initialize items from original viewers
self._on_data_changed()

# ignore layers that are children in associations:
def is_parent(data):
return self.app._get_assoc_data_parent(data.label) is None

self.add_filter(is_parent)

def _cubeviz_include_spatial_subsets(self):
"""
Call this method to prepend spatial subsets to the list of datasets (and listen for newly
Expand Down
18 changes: 18 additions & 0 deletions jdaviz/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest

import numpy as np
from jdaviz import Application, Specviz
from jdaviz.configs.default.plugins.gaussian_smooth.gaussian_smooth import GaussianSmooth

Expand Down Expand Up @@ -170,3 +171,20 @@ def test_viewer_renaming_imviz(imviz_helper):
old_reference='non-existent',
new_reference='this-is-forbidden'
)


def test_data_associations(imviz_helper):
shape = (10, 10)

data_parent = np.ones(shape, dtype=float)
data_child = np.zeros(shape, dtype=int)

imviz_helper.load_data(data_parent, data_label='parent_data')
imviz_helper.load_data(data_child, data_label='child_data', parent='parent_data')

assert imviz_helper.app._get_assoc_data_children('parent_data') == ['child_data']
assert imviz_helper.app._get_assoc_data_parent('child_data') == 'parent_data'

with pytest.raises(NotImplementedError):
# we don't (yet) allow children of children:
imviz_helper.load_data(data_child, data_label='grandchild_data', parent='child_data')