diff --git a/CHANGES.md b/CHANGES.md index 3dd694fb6..262257cd6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,10 +11,10 @@ v0.11.0 (unreleased) references, which in turn caused some callback functions to not be cleaned up. [#1281] -* Rewrote the histogram viewer to use the new state infrastructure. This - significantly simplifies the actual histogram viewer code both in terms - of number of lines and in terms of the number of connections/callbacks - that need to be set up manually. [#1278] +* Rewrote the histogram, scatter, and image viewers to use the new state + infrastructure. This significantly simplifies the actual histogram viewer code + both in terms of number of lines and in terms of the number of + connections/callbacks that need to be set up manually. [#1278, #1289] v0.10.4 (2017-05-23) -------------------- diff --git a/doc/customizing_guide/toolbar.rst b/doc/customizing_guide/toolbar.rst index f2bc6d641..c222ce6f4 100644 --- a/doc/customizing_guide/toolbar.rst +++ b/doc/customizing_guide/toolbar.rst @@ -187,7 +187,7 @@ we defined above). There are currently two main classes available for toolbars: * :class:`~glue.viewers.common.qt.toolbar.BasicToolbar`: this is the most basic kind of toolbar - it comes with no tools by default. -* :class:`~glue.viewers.common.qt.mpl_toolbar.MatplotlibViewerToolbar`: this is +* :class:`~glue.viewers.matplotlib.qt.toolbar.MatplotlibViewerToolbar`: this is a subclass of :class:`~glue.viewers.common.qt.toolbar.BasicToolbar` that includes the standard Matplotlib buttons by default (home, zoom, pan, etc.). This toolbar can only be used if your data viewer includes a Matplotlib canvas diff --git a/doc/developer_guide/api.rst b/doc/developer_guide/api.rst index ad8fa65ac..adbf82446 100644 --- a/doc/developer_guide/api.rst +++ b/doc/developer_guide/api.rst @@ -108,7 +108,7 @@ Viewers :no-inheritance-diagram: :inherited-members: -.. automodapi:: glue.viewers.common.qt.mpl_toolbar +.. automodapi:: glue.viewers.matplotlib.qt.toolbar :no-inheritance-diagram: :inherited-members: diff --git a/doc/python_guide/data_viewer_options.rst b/doc/python_guide/data_viewer_options.rst index 778c046e3..d1765d7ea 100644 --- a/doc/python_guide/data_viewer_options.rst +++ b/doc/python_guide/data_viewer_options.rst @@ -9,7 +9,7 @@ For example:: from glue.core import Data, DataCollection from glue.app.qt.application import GlueApplication - from glue.viewers.scatter.qt import ScatterWidget + from glue.viewers.scatter.qt import ScatterViewer import numpy as np # create some data @@ -20,7 +20,7 @@ For example:: ga = GlueApplication(dc) # plot x vs y, flip the x axis, log-scale y axis - scatter = ga.new_data_viewer(ScatterWidget) + scatter = ga.new_data_viewer(ScatterViewer) scatter.add_data(d) scatter.xatt = d.id['x'] scatter.yatt = d.id['y'] @@ -38,21 +38,21 @@ Here are the settings associated with each data viewer: .. currentmodule:: glue.viewers.scatter.qt.viewer_widget -:class:`Scatter Plots ` +:class:`Scatter Plots ` -------------------------------------- .. autosummary:: - ~ScatterWidget.xlog - ~ScatterWidget.ylog - ~ScatterWidget.xflip - ~ScatterWidget.yflip - ~ScatterWidget.xmin - ~ScatterWidget.xmax - ~ScatterWidget.ymin - ~ScatterWidget.ymax - ~ScatterWidget.hidden - ~ScatterWidget.xatt - ~ScatterWidget.yatt + ~ScatterViewer.xlog + ~ScatterViewer.ylog + ~ScatterViewer.xflip + ~ScatterViewer.yflip + ~ScatterViewer.xmin + ~ScatterViewer.xmax + ~ScatterViewer.ymin + ~ScatterViewer.ymax + ~ScatterViewer.hidden + ~ScatterViewer.xatt + ~ScatterViewer.yatt .. currentmodule:: glue.viewers.image.qt.viewer_widget diff --git a/glue/app/qt/application.py b/glue/app/qt/application.py index 8eee2d344..c7477a7ba 100644 --- a/glue/app/qt/application.py +++ b/glue/app/qt/application.py @@ -23,10 +23,10 @@ from glue.app.qt.mdi_area import GlueMdiArea, GlueMdiSubWindow from glue.app.qt.layer_tree_widget import PlotAction, LayerTreeWidget from glue.app.qt.preferences import PreferencesDialog -from glue.viewers.common.qt.mpl_widget import defer_draw +from glue.viewers.matplotlib.qt.widget import defer_draw from glue.viewers.common.qt.data_viewer import DataViewer -from glue.viewers.image.qt import ImageWidget -from glue.viewers.scatter.qt import ScatterWidget +from glue.viewers.scatter.qt import ScatterViewer +from glue.viewers.image_new.qt import ImageViewer from glue.utils import nonpartial from glue.utils.qt import (pick_class, GlueTabBar, set_cursor_cm, messagebox_on_error, load_ui) @@ -675,10 +675,10 @@ def choose_new_data_viewer(self, data=None): from glue.config import qt_client - if data and data.ndim == 1 and ScatterWidget in qt_client.members: - default = ScatterWidget - elif data and data.ndim > 1 and ImageWidget in qt_client.members: - default = ImageWidget + if data and data.ndim == 1 and ScatterViewer in qt_client.members: + default = ScatterViewer + elif data and data.ndim > 1 and ImageViewer in qt_client.members: + default = ImageViewer else: default = None diff --git a/glue/app/qt/tests/test_application.py b/glue/app/qt/tests/test_application.py index 22d5fad3b..a316ba2ce 100644 --- a/glue/app/qt/tests/test_application.py +++ b/glue/app/qt/tests/test_application.py @@ -21,7 +21,7 @@ from glue.tests.helpers import requires_ipython from glue.utils.qt import process_dialog from glue.viewers.image.qt import ImageWidget -from glue.viewers.scatter.qt import ScatterWidget +from glue.viewers.scatter.qt import ScatterViewer from glue.viewers.histogram.qt import HistogramViewer @@ -136,7 +136,7 @@ def test_new_data_viewer_cancel(self): def test_new_data_viewer(self): with patch('glue.app.qt.application.pick_class') as pc: - pc.return_value = ScatterWidget + pc.return_value = ScatterViewer ct = len(self.app.current_tab.subWindowList()) @@ -144,12 +144,12 @@ def test_new_data_viewer(self): assert len(self.app.current_tab.subWindowList()) == ct + 1 def test_move(self): - viewer = self.app.new_data_viewer(ScatterWidget) + viewer = self.app.new_data_viewer(ScatterViewer) viewer.move(10, 20) assert viewer.position == (10, 20) def test_resize(self): - viewer = self.app.new_data_viewer(ScatterWidget) + viewer = self.app.new_data_viewer(ScatterViewer) viewer.viewer_size = (100, 200) assert viewer.viewer_size == (100, 200) @@ -164,7 +164,7 @@ def test_new_data_defaults(self): self.app.choose_new_data_viewer(data=d1) args, kwargs = pc.call_args - assert kwargs['default'] is ScatterWidget + assert kwargs['default'] is ScatterViewer self.app.choose_new_data_viewer(data=d2) args, kwargs = pc.call_args @@ -297,7 +297,7 @@ def test_scatter_viewer(self): d = Data(label='x', x=[1, 2, 3, 4, 5], y=[2, 3, 4, 5, 6]) dc = DataCollection([d]) app = GlueApplication(dc) - w = app.new_data_viewer(ScatterWidget, data=d) + w = app.new_data_viewer(ScatterViewer, data=d) self.check_clone(app) s1 = dc.new_subset_group() diff --git a/glue/app/qt/tests/test_preferences.py b/glue/app/qt/tests/test_preferences.py index 75bb7e979..6553b4b97 100644 --- a/glue/app/qt/tests/test_preferences.py +++ b/glue/app/qt/tests/test_preferences.py @@ -10,7 +10,7 @@ from qtpy import QtWidgets from glue.app.qt.preferences import PreferencesDialog from glue.app.qt import GlueApplication -from glue.viewers.scatter.qt import ScatterWidget +from glue.viewers.scatter.qt import ScatterViewer from glue.viewers.image.qt import ImageWidget from glue.viewers.histogram.qt import HistogramViewer from glue.plugins.dendro_viewer.qt.viewer_widget import DendroWidget @@ -307,7 +307,7 @@ def test_foreground_background_settings(): # Make sure that settings change existing viewers, so we create a bunch of # viewers here. - scatter1 = app.new_data_viewer(ScatterWidget) + scatter1 = app.new_data_viewer(ScatterViewer) scatter1.add_data(d_1d) image1 = app.new_data_viewer(ImageWidget) @@ -354,7 +354,7 @@ def test_foreground_background_settings(): # Now make sure that new viewers also inherit these settings - scatter2 = app.new_data_viewer(ScatterWidget) + scatter2 = app.new_data_viewer(ScatterViewer) scatter2.add_data(d_1d) image2 = app.new_data_viewer(ImageWidget) diff --git a/glue/core/component_id.py b/glue/core/component_id.py index c77fe36b6..770dd565c 100644 --- a/glue/core/component_id.py +++ b/glue/core/component_id.py @@ -8,7 +8,7 @@ from glue.core.subset import InequalitySubsetState -__all__ = ['PixelComponentID', 'ComponentID', 'PixelComponentID', 'ComponentIDDict', 'ComponentIDList'] +__all__ = ['ComponentID', 'PixelComponentID', 'ComponentIDDict', 'ComponentIDList'] # access to ComponentIDs via .item[name] diff --git a/glue/core/data.py b/glue/core/data.py index 9e7637a71..cf0077c2b 100644 --- a/glue/core/data.py +++ b/glue/core/data.py @@ -63,7 +63,7 @@ class Data(object): See also: :ref:`data_tutorial` """ - def __init__(self, label="", **kwargs): + def __init__(self, label="", coords=None, **kwargs): """ :param label: label for data @@ -72,7 +72,7 @@ def __init__(self, label="", **kwargs): Extra array-like keywords are extracted into components """ # Coordinate conversion object - self.coords = Coordinates() + self.coords = coords or Coordinates() self._shape = () # Components diff --git a/glue/core/layer_artist.py b/glue/core/layer_artist.py index 6d003ec2a..d2a23cc8c 100644 --- a/glue/core/layer_artist.py +++ b/glue/core/layer_artist.py @@ -294,15 +294,8 @@ def _duplicate(self, artist): return True return False - def _check_duplicate(self, artist): - """Raise an error if this artist is a duplicate""" - if self._duplicate(artist): - raise ValueError("Already have an artist for this type " - "and data") - def append(self, artist): """Add a LayerArtist to this collection""" - self._check_duplicate(artist) self.artists.append(artist) artist.zorder = max(a.zorder for a in self.artists) + 1 self._notify() diff --git a/glue/core/qt/data_combo_helper.py b/glue/core/qt/data_combo_helper.py index d57045bb3..f9eea824d 100644 --- a/glue/core/qt/data_combo_helper.py +++ b/glue/core/qt/data_combo_helper.py @@ -41,16 +41,23 @@ class ComponentIDComboHelper(HubListener): Show numeric components categorical : bool, optional Show categorical components + pixel_coord : bool, optional + Show pixel coordinate components + world_coord : bool, optional + Show world coordinate components """ def __init__(self, component_id_combo, data_collection=None, data=None, - visible=True, numeric=True, categorical=True, default_index=0): + visible=True, numeric=True, categorical=True, + pixel_coord=False, world_coord=False, default_index=0,): super(ComponentIDComboHelper, self).__init__() self._visible = visible self._numeric = numeric self._categorical = categorical + self._pixel_coord = pixel_coord + self._world_coord = world_coord self._component_id_combo = component_id_combo if data is None: @@ -103,6 +110,24 @@ def categorical(self, value): self._categorical = value self.refresh() + @property + def pixel_coord(self): + return self._pixel_coord + + @pixel_coord.setter + def pixel_coord(self, value): + self._pixel_coord = value + self.refresh() + + @property + def world_coord(self): + return self._world_coord + + @world_coord.setter + def world_coord(self, value): + self._world_coord = value + self.refresh() + def append_data(self, data, refresh=True): if self._manual_data: @@ -187,7 +212,10 @@ def refresh(self): component_ids = [] for cid in all_component_ids: comp = data.get_component(cid) - if (comp.numeric and self.numeric) or (comp.categorical and self.categorical): + if ((comp.numeric and self.numeric) or + (comp.categorical and self.categorical) or + (cid in data.pixel_component_ids and self.pixel_coord) or + (cid in data.world_component_ids and self.world_coord)): component_ids.append(cid) label_data.extend([(cid.label, cid) for cid in component_ids]) diff --git a/glue/core/qt/layer_artist_model.py b/glue/core/qt/layer_artist_model.py index ed5bee6c6..b69fe0d67 100644 --- a/glue/core/qt/layer_artist_model.py +++ b/glue/core/qt/layer_artist_model.py @@ -337,7 +337,6 @@ def on_selection_change(self, layer_artist): self.layer_options_layout.setCurrentWidget(self.empty) - class QtLayerArtistContainer(LayerArtistContainer): """A subclass of LayerArtistContainer that dispatches to a @@ -351,7 +350,6 @@ def __init__(self): self.model.modelReset.connect(nonpartial(self._notify)) def append(self, artist): - self._check_duplicate(artist) self.model.add_artist(0, artist) artist.zorder = max(a.zorder for a in self.artists) + 1 assert self.artists[0] is artist diff --git a/glue/core/state_objects.py b/glue/core/state_objects.py index cd8311e9e..6c6bae852 100644 --- a/glue/core/state_objects.py +++ b/glue/core/state_objects.py @@ -276,7 +276,7 @@ def update_values(self, use_default_modifiers=False, **properties): percentile = self.percentile or 100 log = self.log or False - if percentile == 'Custom' or self.data is None: + if percentile == 'Custom' or not hasattr(self, 'data') or self.data is None: self.set(percentile=percentile, log=log) @@ -359,8 +359,6 @@ def __init__(self, *args, **kwargs): else: self._common_n_bin = None - print(self._cache) - def _apply_common_n_bin(self): for att in self._cache: cmp = self.data.get_component(att) diff --git a/glue/core/state_path_patches.txt b/glue/core/state_path_patches.txt index 7cb6039a9..3f7a287ac 100644 --- a/glue/core/state_path_patches.txt +++ b/glue/core/state_path_patches.txt @@ -10,9 +10,14 @@ glue.clients.layer_artist.ScatterLayerArtist -> glue.viewers.scatter.layer_artis glue.clients.layer_artist.ImageLayerArtist -> glue.viewers.image.layer_artist.ImageLayerArtist glue.clients.layer_artist.HistogramLayerArtist -> glue.viewers.histogram.layer_artist.HistogramLayerArtist glue.clients.ds9norm.DS9Normalize -> glue.viewers.image.ds9norm.DS9Normalize -glue.qt.widgets.scatter_widget.ScatterWidget -> glue.viewers.scatter.qt.ScatterWidget +glue.qt.widgets.scatter_widget.ScatterWidget -> glue.viewers.scatter.qt.ScatterViewer glue.qt.widgets.image_widget.ImageWidget -> glue.viewers.image.qt.ImageWidget glue.qt.widgets.histogram_widget.HistogramWidget -> glue.viewers.histogram.qt.data_viewer.HistogramViewer glue.qt.glue_application.GlueApplication -> glue.app.qt.application.GlueApplication glue_vispy_viewers.common.toolbar.PatchedElementSubsetState -> glue.core.subset.ElementSubsetState glue.viewers.histogram.qt.viewer_widget.HistogramWidget -> glue.viewers.histogram.qt.data_viewer.HistogramViewer +glue.viewers.scatter.qt.viewer_widget.ScatterWidget -> glue.viewers.scatter.qt.data_viewer.ScatterViewer +glue.viewers.image.qt.viewer_widget.ImageWidget -> glue.viewers.image_new.qt.data_viewer.ImageViewer +glue.viewers.image.layer_artist.ImageLayerArtist -> glue.viewers.image_new.layer_artist.ImageLayerArtist +glue.viewers.image.layer_artist.SubsetImageLayerArtist -> glue.viewers.image_new.layer_artist.ImageSubsetLayerArtist +glue.viewers.image.ds9norm.DS9Normalize -> glue.viewers.image_new.compat.DS9Compat diff --git a/glue/external/echo/callback_container.py b/glue/external/echo/callback_container.py index 0e8e7d77b..1c4c0c0b3 100644 --- a/glue/external/echo/callback_container.py +++ b/glue/external/echo/callback_container.py @@ -17,7 +17,7 @@ class CallbackContainer(object): def __init__(self): self.callbacks = [] - def _wrap(self, value): + def _wrap(self, value, priority=0): """ Given a function/method, this will automatically wrap a method using weakref to avoid circular references. @@ -33,7 +33,12 @@ def _wrap(self, value): # and instance. value = (weakref.ref(value.__func__), - weakref.ref(value.__self__, self._auto_remove)) + weakref.ref(value.__self__, self._auto_remove), + priority) + + else: + + value = (value, priority) return value @@ -47,21 +52,25 @@ def _auto_remove(self, method_instance): def __contains__(self, value): if self.is_bound_method(value): for callback in self.callbacks[:]: - if isinstance(callback, tuple) and value.__func__ is callback[0]() and value.__self__ is callback[1](): + if len(callback) == 3 and value.__func__ is callback[0]() and value.__self__ is callback[1](): return True else: return False else: - return value in self.callbacks + for callback in self.callbacks[:]: + if len(callback) == 2 and value.__func__ is callback[0]: + return True + else: + return False def __iter__(self): - for callback in self.callbacks: - if isinstance(callback, tuple): + for callback in sorted(self.callbacks, key=lambda x: x[-1], reverse=True): + if len(callback) == 3: func = callback[0]() inst = callback[1]() yield partial(func, inst) else: - yield callback + yield callback[0] def __len__(self): return len(self.callbacks) @@ -70,14 +79,15 @@ def __len__(self): def is_bound_method(func): return hasattr(func, '__func__') and getattr(func, '__self__', None) is not None - def append(self, value): - self.callbacks.append(self._wrap(value)) + def append(self, value, priority=0): + self.callbacks.append(self._wrap(value, priority=priority)) def remove(self, value): if self.is_bound_method(value): for callback in self.callbacks[:]: - if isinstance(callback, tuple) and value.__func__ is callback[0]() and value.__self__ is callback[1](): + if len(callback) == 3 and value.__func__ is callback[0]() and value.__self__ is callback[1](): self.callbacks.remove(callback) else: - if value in self.callbacks: - self.callbacks.remove(value) + for callback in self.callbacks[:]: + if len(callback) == 2 and value is callback[0]: + self.callbacks.remove(callback) diff --git a/glue/external/echo/core.py b/glue/external/echo/core.py index 2de632b6d..983c04847 100644 --- a/glue/external/echo/core.py +++ b/glue/external/echo/core.py @@ -1,7 +1,8 @@ from __future__ import absolute_import, division, print_function -from contextlib import contextmanager +import weakref from weakref import WeakKeyDictionary +from contextlib import contextmanager from .callback_container import CallbackContainer @@ -120,7 +121,7 @@ def enable(self, instance): """ self._disabled[instance] = False - def add_callback(self, instance, func, echo_old=False): + def add_callback(self, instance, func, echo_old=False, priority=0): """ Add a callback to a specific instance that manages this property @@ -134,12 +135,15 @@ def add_callback(self, instance, func, echo_old=False): If `True`, the callback function will be invoked with both the old and new values of the property, as ``func(old, new)``. If `False` (the default), will be invoked as ``func(new)`` + priority : int, optional + This can optionally be used to force a certain order of execution of + callbacks. """ if echo_old: - self._2arg_callbacks.setdefault(instance, CallbackContainer()).append(func) + self._2arg_callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) else: - self._callbacks.setdefault(instance, CallbackContainer()).append(func) + self._callbacks.setdefault(instance, CallbackContainer()).append(func, priority=priority) def remove_callback(self, instance, func): """ @@ -195,7 +199,7 @@ def __setattr__(self, attribute, value): if self.is_callback_property(attribute): self.notify_global(**{attribute: value}) - def add_callback(self, name, callback, echo_old=False): + def add_callback(self, name, callback, echo_old=False, priority=0): """ Add a callback that gets triggered when a callback property of the class changes. @@ -210,10 +214,13 @@ class changes. If `True`, the callback function will be invoked with both the old and new values of the property, as ``callback(old, new)``. If `False` (the default), will be invoked as ``callback(new)`` + priority : int, optional + This can optionally be used to force a certain order of execution of + callbacks. """ if self.is_callback_property(name): prop = getattr(type(self), name) - prop.add_callback(self, callback, echo_old=echo_old) + prop.add_callback(self, callback, echo_old=echo_old, priority=priority) else: raise TypeError("attribute '{0}' is not a callback property".format(name)) @@ -270,7 +277,7 @@ def iter_callback_properties(self): yield name, getattr(type(self), name) -def add_callback(instance, prop, callback, echo_old=False): +def add_callback(instance, prop, callback, echo_old=False, priority=0): """ Attach a callback function to a property in an instance @@ -286,6 +293,9 @@ def add_callback(instance, prop, callback, echo_old=False): If `True`, the callback function will be invoked with both the old and new values of the property, as ``func(old, new)``. If `False` (the default), will be invoked as ``func(new)`` + priority : int, optional + This can optionally be used to force a certain order of execution of + callbacks. Examples -------- @@ -305,7 +315,7 @@ def callback(value): p = getattr(type(instance), prop) if not isinstance(p, CallbackProperty): raise TypeError("%s is not a CallbackProperty" % prop) - p.add_callback(instance, callback, echo_old=echo_old) + p.add_callback(instance, callback, echo_old=echo_old, priority=priority) def remove_callback(instance, prop, callback): @@ -472,10 +482,10 @@ class keep_in_sync(object): def __init__(self, instance1, prop1, instance2, prop2): - self.instance1 = instance1 + self.instance1 = weakref.ref(instance1, self.disable_syncing) self.prop1 = prop1 - self.instance2 = instance2 + self.instance2 = weakref.ref(instance2, self.disable_syncing) self.prop2 = prop2 self._syncing = False @@ -485,19 +495,21 @@ def __init__(self, instance1, prop1, instance2, prop2): def prop1_from_prop2(self, value): if not self._syncing: self._syncing = True - setattr(self.instance1, self.prop1, getattr(self.instance2, self.prop2)) + setattr(self.instance1(), self.prop1, getattr(self.instance2(), self.prop2)) self._syncing = False def prop2_from_prop1(self, value): if not self._syncing: self._syncing = True - setattr(self.instance2, self.prop2, getattr(self.instance1, self.prop1)) + setattr(self.instance2(), self.prop2, getattr(self.instance1(), self.prop1)) self._syncing = False - def enable_syncing(self): - add_callback(self.instance1, self.prop1, self.prop2_from_prop1) - add_callback(self.instance2, self.prop2, self.prop1_from_prop2) + def enable_syncing(self, *args): + add_callback(self.instance1(), self.prop1, self.prop2_from_prop1) + add_callback(self.instance2(), self.prop2, self.prop1_from_prop2) - def disable_syncing(self): - remove_callback(self.instance1, self.prop1, self.prop2_from_prop1) - remove_callback(self.instance2, self.prop2, self.prop1_from_prop2) + def disable_syncing(self, *args): + if self.instance1() is not None: + remove_callback(self.instance1(), self.prop1, self.prop2_from_prop1) + if self.instance2() is not None: + remove_callback(self.instance2(), self.prop2, self.prop1_from_prop2) diff --git a/glue/external/echo/qt/connect.py b/glue/external/echo/qt/connect.py index f7cdf225c..7b5883ebe 100644 --- a/glue/external/echo/qt/connect.py +++ b/glue/external/echo/qt/connect.py @@ -277,8 +277,8 @@ def _find_combo_text(widget, value): Raises a ValueError if data is not found """ - i = widget.findText(value) - if i == -1: - raise ValueError("%s not found in combo box" % value) + for idx in range(widget.count()): + if widget.itemText(idx) == value: + return idx else: - return i + raise ValueError("%s not found in combo box" % value) diff --git a/glue/external/modest_image.py b/glue/external/modest_image.py index ef257b944..77ec41a72 100644 --- a/glue/external/modest_image.py +++ b/glue/external/modest_image.py @@ -234,13 +234,14 @@ def imshow(axes, X, cmap=None, norm=None, aspect=None, if vmin is not None or vmax is not None: im.set_clim(vmin, vmax) elif norm is None: - im.autoscale_None() + # im.autoscale_None() + pass im.set_url(url) # update ax.dataLim, and, if autoscaling, set viewLim # to tightly fit the image, regardless of dataLim. - im.set_extent(im.get_extent()) + # im.set_extent(im.get_extent()) axes.images.append(im) im._remove_method = lambda h: axes.images.remove(h) diff --git a/glue/plugins/dendro_viewer/qt/viewer_widget.py b/glue/plugins/dendro_viewer/qt/viewer_widget.py index 7e49a56d3..266a2c015 100644 --- a/glue/plugins/dendro_viewer/qt/viewer_widget.py +++ b/glue/plugins/dendro_viewer/qt/viewer_widget.py @@ -5,13 +5,13 @@ from qtpy import QtWidgets from glue import core from glue.plugins.dendro_viewer.client import DendroClient -from glue.viewers.common.qt.mpl_toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar from glue.viewers.common.qt.mouse_mode import PickMode from glue.utils.qt import load_ui from glue.utils.qt.widget_properties import (ButtonProperty, CurrentComboProperty, connect_bool_button, connect_current_combo) from glue.viewers.common.qt.data_viewer import DataViewer -from glue.viewers.common.qt.mpl_widget import MplWidget, defer_draw +from glue.viewers.matplotlib.qt.widget import MplWidget, defer_draw from glue.utils import nonpartial diff --git a/glue/plugins/export_d3po.py b/glue/plugins/export_d3po.py index 692056c76..5847b3dc5 100644 --- a/glue/plugins/export_d3po.py +++ b/glue/plugins/export_d3po.py @@ -59,7 +59,7 @@ def save_scatter(plot, index): """ Convert a single glue scatter plot to a D3PO plot :param plot: Glue scatter plot - :class:`~glue.viewers.scatter.qt.ScatterWidget` + :class:`~glue.viewers.scatter.qt.ScatterViewer` :param index: 1D index of plot on the page :type index: int @@ -303,10 +303,10 @@ def setup(): """ try: - from glue.viewers.scatter.qt import ScatterWidget + from glue.viewers.scatter.qt import ScatterViewer from glue.viewers.histogram.qt import HistogramViewer except ImportError: pass else: - DISPATCH[ScatterWidget] = save_scatter + DISPATCH[ScatterViewer] = save_scatter DISPATCH[HistogramViewer] = save_histogram diff --git a/glue/plugins/exporters/plotly/export_plotly.py b/glue/plugins/exporters/plotly/export_plotly.py index 7a71c8414..8cdeb82a4 100644 --- a/glue/plugins/exporters/plotly/export_plotly.py +++ b/glue/plugins/exporters/plotly/export_plotly.py @@ -259,7 +259,7 @@ def can_save_plotly(application): if hasattr(viewer, '__plotly__'): continue - if not isinstance(viewer, (ScatterWidget, HistogramViewer)): + if not isinstance(viewer, (ScatterViewer, HistogramViewer)): raise ValueError("Plotly Export cannot handle viewer: %s" % type(viewer)) @@ -303,10 +303,10 @@ def save_plotly(application): DISPATCH = {} try: - from glue.viewers.scatter.qt import ScatterWidget + from glue.viewers.scatter.qt import ScatterViewer from glue.viewers.histogram.qt import HistogramViewer except ImportError: pass else: - DISPATCH[ScatterWidget] = export_scatter + DISPATCH[ScatterViewer] = export_scatter DISPATCH[HistogramViewer] = export_histogram diff --git a/glue/plugins/exporters/plotly/tests/test_plotly.py b/glue/plugins/exporters/plotly/tests/test_plotly.py index 457daef22..6d3e09e73 100644 --- a/glue/plugins/exporters/plotly/tests/test_plotly.py +++ b/glue/plugins/exporters/plotly/tests/test_plotly.py @@ -9,7 +9,7 @@ pytest.importorskip('qtpy') from glue.app.qt import GlueApplication -from glue.viewers.scatter.qt import ScatterWidget +from glue.viewers.scatter.qt import ScatterViewer from glue.viewers.histogram.qt import HistogramViewer from ..export_plotly import build_plotly_call @@ -29,7 +29,7 @@ def test_scatter(self): d.style.markersize = 6 d.style.color = '#ff0000' d.style.alpha = .4 - v = app.new_data_viewer(ScatterWidget, data=d) + v = app.new_data_viewer(ScatterViewer, data=d) v.xatt = d.id['y'] v.yatt = d.id['x'] @@ -55,7 +55,7 @@ def test_scatter_subset(self): s.subset_state = d.id['x'] > 1 s.style.marker = 's' - v = app.new_data_viewer(ScatterWidget, data=d) + v = app.new_data_viewer(ScatterViewer, data=d) v.xatt = d.id['x'] v.yatt = d.id['x'] @@ -71,7 +71,7 @@ def test_axes(self): app = self.app - v = app.new_data_viewer(ScatterWidget, data=self.data) + v = app.new_data_viewer(ScatterViewer, data=self.data) v.xlog = True v.xmin = 10 diff --git a/glue/plugins/tools/pv_slicer/__init__.py b/glue/plugins/tools/pv_slicer/__init__.py index 6c42d1024..2f3c285f1 100644 --- a/glue/plugins/tools/pv_slicer/__init__.py +++ b/glue/plugins/tools/pv_slicer/__init__.py @@ -1,4 +1,4 @@ def setup(): - from glue.viewers.image.qt import ImageWidget - from glue.plugins.tools.pv_slicer.qt import PVSlicerMode - ImageWidget.tools.append('slice') + from glue.viewers.image_new.qt import ImageViewer + from glue.plugins.tools.pv_slicer.qt import PVSlicerMode # noqa + ImageViewer.tools.append('slice') diff --git a/glue/plugins/tools/pv_slicer/qt/pv_slicer.py b/glue/plugins/tools/pv_slicer/qt/pv_slicer.py index 70240a032..409b8905f 100644 --- a/glue/plugins/tools/pv_slicer/qt/pv_slicer.py +++ b/glue/plugins/tools/pv_slicer/qt/pv_slicer.py @@ -3,8 +3,8 @@ import numpy as np from glue.viewers.common.qt.mouse_mode import PathMode -from glue.viewers.image.qt import StandaloneImageWidget -from glue.viewers.common.qt.mpl_widget import defer_draw +from glue.viewers.image_new.qt import StandaloneImageViewer +from glue.viewers.matplotlib.qt.widget import defer_draw from glue.external.echo import add_callback from glue.config import viewer_tool @@ -22,14 +22,9 @@ class PVSlicerMode(PathMode): def __init__(self, viewer, **kwargs): super(PVSlicerMode, self).__init__(viewer, **kwargs) - add_callback(viewer.client, 'display_data', self._display_data_hook) self._roi_callback = self._extract_callback self._slice_widget = None - def _display_data_hook(self, data): - if data is not None: - self.enabled = data.ndim == 3 - def _clear_path(self): self.clear() @@ -41,12 +36,12 @@ def _extract_callback(self, mode): self._build_from_vertices(vx, vy) def _build_from_vertices(self, vx, vy): - pv_slice, x, y, wcs = _slice_from_path(vx, vy, self.viewer.data, - self.viewer.attribute, - self.viewer.slice) + pv_slice, x, y, wcs = _slice_from_path(vx, vy, self.viewer.state.reference_data, + self.viewer.state.layers[0].attribute, + self.viewer.state.wcsaxes_slice[::-1]) if self._slice_widget is None: self._slice_widget = PVSliceWidget(image=pv_slice, wcs=wcs, - image_client=self.viewer.client, + image_viewer=self.viewer, x=x, y=y, interpolation='nearest') self.viewer._session.application.add_widget(self._slice_widget, label='Custom Slice') @@ -57,7 +52,7 @@ def _build_from_vertices(self, vx, vy): result = self._slice_widget result.axes.set_xlabel("Position Along Slice") - result.axes.set_ylabel(_slice_label(self.viewer.data, self.viewer.slice)) + result.axes.set_ylabel(_slice_label(self.viewer.state.reference_data, self.viewer.state.wcsaxes_slice[::-1])) result.show() @@ -67,20 +62,20 @@ def close(self): return super(PVSlicerMode, self).close() -class PVSliceWidget(StandaloneImageWidget): +class PVSliceWidget(StandaloneImageViewer): """ A standalone image widget with extra interactivity for PV slices """ - def __init__(self, image=None, wcs=None, image_client=None, + def __init__(self, image=None, wcs=None, image_viewer=None, x=None, y=None, **kwargs): """ :param image: 2D Numpy array representing the PV Slice :param wcs: WCS for the PV slice - :param image_client: Parent ImageClient this was extracted from + :param image_viewer: Parent ImageViewer this was extracted from :param kwargs: Extra keywords are passed to imshow """ self._crosshairs = None - self._parent = image_client + self._parent = image_viewer super(PVSliceWidget, self).__init__(image=image, wcs=wcs, **kwargs) conn = self.axes.figure.canvas.mpl_connect self._down_id = conn('button_press_event', self._on_click) @@ -103,20 +98,21 @@ def _format_coord(self, x, y): # xyz -> data pixel coords # accounts for fact that image might be shown transposed/rotated s = list(self._slc) - idx = _slice_index(self._parent.display_data, self._slc) + idx = _slice_index(self._parent.state.reference_data, self._slc) s[s.index('x')] = pix[0] s[s.index('y')] = pix[1] s[idx] = pix[2] - labels = self._parent.coordinate_labels(s) - return ' '.join(labels) + # labels = self._parent.coordinate_labels(s) + # return ' '.join(labels) + return '' def set_image(self, image=None, wcs=None, x=None, y=None, **kwargs): super(PVSliceWidget, self).set_image(image=image, wcs=wcs, **kwargs) self._axes.set_aspect('auto') self._axes.set_xlim(-0.5, image.shape[1] - 0.5) self._axes.set_ylim(-0.5, image.shape[0] - 0.5) - self._slc = self._parent.slice + self._slc = self._parent.state.slices self._x = x self._y = y @@ -125,14 +121,14 @@ def _sync_slice(self, event): s = list(self._slc) # XXX breaks if display_data changes _, _, z = self._pos_in_parent(event) - s[_slice_index(self._parent.display_data, s)] = z - self._parent.slice = tuple(s) + s[_slice_index(self._parent.state.reference_data, s)] = z + self._parent.state.slices = tuple(s) @defer_draw def _draw_crosshairs(self, event): - - x, y, _ = self._pos_in_parent(event) - self._parent.show_crosshairs(x, y) + pass + # x, y, _ = self._pos_in_parent(event) + # self._parent.show_crosshairs(x, y) @defer_draw def _on_move(self, event): diff --git a/glue/plugins/tools/spectrum_tool/__init__.py b/glue/plugins/tools/spectrum_tool/__init__.py index 2f24dd89e..a9799272c 100644 --- a/glue/plugins/tools/spectrum_tool/__init__.py +++ b/glue/plugins/tools/spectrum_tool/__init__.py @@ -1,4 +1,5 @@ def setup(): - from glue.viewers.image.qt import ImageWidget - from glue.plugins.tools.spectrum_tool.qt import SpectrumExtractorMode - ImageWidget.tools.append('spectrum') + from glue.viewers.image_new.qt import ImageViewer + from glue.plugins.tools.spectrum_tool.qt import SpectrumExtractorMode # noqa + print(ImageViewer.tools) + ImageViewer.tools.append('spectrum') diff --git a/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py b/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py index 302b8731f..9ad169be7 100644 --- a/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py +++ b/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py @@ -15,14 +15,14 @@ from glue.core import Subset from glue.core.callback_property import add_callback, ignore_callback from glue.config import fit_plugin, viewer_tool -from glue.viewers.common.qt.mpl_toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar from glue.core.qt.mime import LAYERS_MIME_TYPE from glue.viewers.common.qt.mouse_mode import RoiMode from glue.utils.qt import load_ui from glue.core.qt.simpleforms import build_form_item from glue.utils.qt.widget_properties import CurrentComboProperty from glue.app.qt.mdi_area import GlueMdiSubWindow -from glue.viewers.common.qt.mpl_widget import MplWidget +from glue.viewers.matplotlib.qt.widget import MplWidget from glue.utils import nonpartial, Pointer from glue.utils.qt import Worker, messagebox_on_error from glue.core import roi as core_roi @@ -154,7 +154,7 @@ class SpectrumContext(object): """ Base class for different interaction contexts """ - client = Pointer('main.client') + viewer_state = Pointer('main.viewer_state') data = Pointer('main.data') profile_axis = Pointer('main.profile_axis') canvas = Pointer('main.canvas') @@ -224,13 +224,13 @@ class NavContext(SpectrumContext): """ def _setup_grip(self): - def _set_client_from_grip(value): - """Update client.slice given grip value""" + def _set_state_from_grip(value): + """Update state.slices given grip value""" if not self.main.enabled: return - slc = list(self.client.slice) - # client.slice stored in pixel coords + slc = list(self.viewer_state.slices) + # state.slices stored in pixel coords value = Extractor.world2pixel( self.data, self.profile_axis, value) @@ -238,10 +238,10 @@ def _set_client_from_grip(value): # prevent callback bouncing. Fixes #298 with ignore_callback(self.grip, 'value'): - self.client.slice = tuple(slc) + self.viewer_state.slices = tuple(slc) - def _set_grip_from_client(slc): - """Update grip.value given client.slice""" + def _set_grip_from_state(slc): + """Update grip.value given state.slices""" if not self.main.enabled: return @@ -251,14 +251,14 @@ def _set_grip_from_client(slc): # If pix2world not monotonic, this can trigger infinite recursion. # Avoid by disabling callback loop - # XXX better to specifically ignore _set_client_from_grip - with ignore_callback(self.client, 'slice'): + # XXX better to specifically ignore _set_state_from_grip + with ignore_callback(self.viewer_state, 'slices'): self.grip.value = val self.grip = self.main.profile.new_value_grip() - add_callback(self.client, 'slice', _set_grip_from_client) - add_callback(self.grip, 'value', _set_client_from_grip) + add_callback(self.viewer_state, 'slices', _set_grip_from_state) + add_callback(self.grip, 'value', _set_state_from_grip) def _connect(self): pass @@ -333,12 +333,13 @@ def _aggregate(self): self.profile_axis, rng) - agg = Aggregate(self.data, self.client.display_attribute, - self.main.profile_axis, self.client.slice, rng) + agg = Aggregate(self.data, self.viewer_state.layers[0].attribute, + self.main.profile_axis, self.viewer_state.slices, rng) im = func(agg) self._agg = im - self.client.override_image(im) + # TODO: reinstate this? + # self.client.override_image(im) @messagebox_on_error("Failed to export projection") def _choose_save(self): @@ -361,7 +362,7 @@ def save_to(self, pth): from astropy.io import fits - data = self.client.display_data + data = self.viewer_state.reference_data if data is None: raise RuntimeError("Cannot save projection -- no data to visualize") @@ -382,7 +383,7 @@ def save_to(self, pth): lo, hi = self.grip.range history = ('Created by Glue. %s projection over channels %i-%i of axis %i. Slice=%s' % - (self.aggregator_label, lo, hi, self.main.profile_axis, self.client.slice)) + (self.aggregator_label, lo, hi, self.main.profile_axis, self.viewer_state.slices)) header.add_history(history) @@ -683,11 +684,6 @@ def __init__(self, viewer, **kwargs): self._release_callback = self._tool._update_profile self._move_callback = self._tool._move_profile self._roi_callback = None - add_callback(viewer.client, 'display_data', self._display_data_hook) - - def _display_data_hook(self, data): - if data is not None: - self.enabled = data.ndim == 3 def menu_actions(self): @@ -746,13 +742,14 @@ class SpectrumTool(object): *collapse context* lets the users collapse a section of a cube to a 2D image """ - def __init__(self, image_widget, mouse_mode): + def __init__(self, image_viewer, mouse_mode): self._relim_requested = True - self.image_widget = image_widget + self.image_viewer = image_viewer + self.viewer_state = self.image_viewer.state + self._build_main_widget() - self.client = self.image_widget.client self.profile = ProfileViewer(self.canvas.fig) self.axes = self.profile.axes @@ -762,7 +759,7 @@ def __init__(self, image_widget, mouse_mode): self._setup_ctxbar() self._connect() - w = self.image_widget.session.application.add_widget(self, + w = self.image_viewer.session.application.add_widget(self, label='Profile') w.close() @@ -822,9 +819,10 @@ def _setup_ctxbar(self): l.setStretchFactor(tabs, 0) def _connect(self): - add_callback(self.client, 'slice', - self._check_invalidate, - echo_old=True) + add_callback(self.viewer_state, 'x_att', + self.reset) + add_callback(self.viewer_state, 'y_att', + self.reset) def _on_tab_change(index): for i, ctx in enumerate(self._contexts): @@ -868,20 +866,23 @@ def _check_invalidate(self, slc_old, slc_new): slc_old.index('y') != slc_new.index('y')): self.reset() - def reset(self): + def reset(self, *args): self.hide() self.mouse_mode.clear() self._relim_requested = True @property def data(self): - return self.client.display_data + return self.viewer_state.reference_data @property def profile_axis(self): # XXX make this settable # defaults to the non-xy axis with the most channels - slc = self.client.slice + try: + slc = self.viewer_state.wcsaxes_slice[::-1] + except AttributeError: + return None candidates = [i for i, s in enumerate(slc) if s not in ['x', 'y']] return max(candidates, key=lambda i: self.data.shape[i]) @@ -890,10 +891,10 @@ def _recenter_grips(self): ctx.recenter(self.axes.get_xlim()) def _extract_subset_profile(self, subset): - slc = self.client.slice + slc = self.viewer_state.slices try: x, y = Extractor.subset_spectrum(subset, - self.client.display_attribute, + self.viewer_state.display_attribute, slc, self.profile_axis) except IncompatibleAttribute: @@ -903,8 +904,8 @@ def _extract_subset_profile(self, subset): def _update_from_roi(self, roi): data = self.data - att = self.client.display_attribute - slc = self.client.slice + att = self.viewer_state.layers[0].attribute + slc = self.viewer_state.wcsaxes_slice[::-1] if data is None or att is None: return @@ -947,8 +948,8 @@ def _set_profile(self, x, y): self.axes.figure.canvas.draw() self.show() - def _move_below_image_widget(self): - rect = self.image_widget.frameGeometry() + def _move_below_image_viewer(self): + rect = self.image_viewer.frameGeometry() pos = rect.bottomLeft() self._mdi_wrapper.setGeometry(pos.x(), pos.y(), rect.width(), 300) @@ -956,7 +957,7 @@ def _move_below_image_widget(self): def show(self): if self.widget.isVisible(): return - self._move_below_image_widget() + self._move_below_image_viewer() self.widget.show() def hide(self): @@ -964,7 +965,3 @@ def hide(self): def _get_modes(self, axes): return [self.mouse_mode] - - def _display_data_hook(self, data): - if data is not None: - self.mouse_mode.enabled = data.ndim > 2 diff --git a/glue/tests/test_config.py b/glue/tests/test_config.py index 670f941fd..6d1cc601d 100644 --- a/glue/tests/test_config.py +++ b/glue/tests/test_config.py @@ -8,11 +8,11 @@ def test_default_clients(): from glue.viewers.image.qt import ImageWidget - from glue.viewers.scatter.qt import ScatterWidget + from glue.viewers.scatter.qt import ScatterViewer from glue.viewers.histogram.qt import HistogramViewer assert ImageWidget in qt_client - assert ScatterWidget in qt_client + assert ScatterViewer in qt_client assert HistogramViewer in qt_client diff --git a/glue/utils/qt/helpers.py b/glue/utils/qt/helpers.py index 0fd047bc4..539979bdd 100644 --- a/glue/utils/qt/helpers.py +++ b/glue/utils/qt/helpers.py @@ -54,6 +54,10 @@ def update_combobox(combo, labeldata, default_index=0): if data is current or data == current: index = i combo.blockSignals(False) + + if default_index < 0: + default_index = combo.count() + default_index + if index is None: index = min(default_index, combo.count() - 1) combo.setCurrentIndex(index) diff --git a/glue/viewers/common/qt/data_slice_widget.py b/glue/viewers/common/qt/data_slice_widget.py index 0c6181a2d..55e408757 100644 --- a/glue/viewers/common/qt/data_slice_widget.py +++ b/glue/viewers/common/qt/data_slice_widget.py @@ -2,163 +2,127 @@ import os -from functools import partial -from collections import Counter - import numpy as np -from glue.core import Coordinates from qtpy import QtCore, QtWidgets from glue.utils.qt import load_ui -from glue.utils.qt.widget_properties import (TextProperty, - ButtonProperty, - ValueProperty, - CurrentComboProperty) from glue.utils import nonpartial from glue.icons.qt import get_icon +from glue.core.state_objects import State, CallbackProperty +from glue.external.echo.qt import autoconnect_callbacks_to_qt -class SliceWidget(QtWidgets.QWidget): +class SliceState(State): + + label = CallbackProperty() + slider_label = CallbackProperty() + slider_unit = CallbackProperty() + slice_center = CallbackProperty() + use_world = CallbackProperty() - label = TextProperty('_ui_label') - slider_label = TextProperty('_ui_slider.label') - slider_unit = TextProperty('_ui_slider.text_unit') - slice_center = ValueProperty('_ui_slider.slider') - mode = CurrentComboProperty('_ui_mode') - use_world = ButtonProperty('_ui_slider.checkbox_world') + +class SliceWidget(QtWidgets.QWidget): slice_changed = QtCore.Signal(int) - mode_changed = QtCore.Signal(str) def __init__(self, label='', world=None, lo=0, hi=10, - parent=None, aggregation=None, world_unit=None, + parent=None, world_unit=None, world_warning=False): super(SliceWidget, self).__init__(parent) - if aggregation is not None: - raise NotImplemented("Aggregation option not implemented") + self.state = SliceState() + self.state.label = label + self.state.slice_center = (lo + hi) // 2 self._world = np.asarray(world) self._world_warning = world_warning self._world_unit = world_unit - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(3, 1, 3, 1) - layout.setSpacing(0) + self.ui = load_ui('data_slice_widget.ui', self, + directory=os.path.dirname(__file__)) - top = QtWidgets.QHBoxLayout() - top.setContentsMargins(3, 3, 3, 3) - label = QtWidgets.QLabel(label) - top.addWidget(label) + autoconnect_callbacks_to_qt(self.state, self.ui) - mode = QtWidgets.QComboBox() - mode.addItem('x', 'x') - mode.addItem('y', 'y') - mode.addItem('slice', 'slice') - mode.currentIndexChanged.connect(lambda x: - self.mode_changed.emit(self.mode)) - mode.currentIndexChanged.connect(self._update_mode) - top.addWidget(mode) - - layout.addLayout(top) - - slider = load_ui('data_slice_widget.ui', None, - directory=os.path.dirname(__file__)) - self._ui_slider = slider - - font = slider.label_warning.font() + font = self.text_warning.font() font.setPointSize(font.pointSize() * 0.75) - slider.label_warning.setFont(font) - - slider.button_first.setStyleSheet('border: 0px') - slider.button_first.setIcon(get_icon('playback_first')) - slider.button_prev.setStyleSheet('border: 0px') - slider.button_prev.setIcon(get_icon('playback_prev')) - slider.button_back.setStyleSheet('border: 0px') - slider.button_back.setIcon(get_icon('playback_back')) - slider.button_stop.setStyleSheet('border: 0px') - slider.button_stop.setIcon(get_icon('playback_stop')) - slider.button_forw.setStyleSheet('border: 0px') - slider.button_forw.setIcon(get_icon('playback_forw')) - slider.button_next.setStyleSheet('border: 0px') - slider.button_next.setIcon(get_icon('playback_next')) - slider.button_last.setStyleSheet('border: 0px') - slider.button_last.setIcon(get_icon('playback_last')) - - slider.slider.setMinimum(lo) - slider.slider.setMaximum(hi) - slider.slider.setValue((lo + hi) / 2) - slider.slider.valueChanged.connect(lambda x: - self.slice_changed.emit(self.mode)) - slider.slider.valueChanged.connect(nonpartial(self.set_label_from_slider)) - - slider.label.setMinimumWidth(80) - slider.label.setText(str(slider.slider.value())) - slider.label.editingFinished.connect(nonpartial(self.set_slider_from_label)) + self.text_warning.setFont(font) + + self.button_first.setStyleSheet('border: 0px') + self.button_first.setIcon(get_icon('playback_first')) + self.button_prev.setStyleSheet('border: 0px') + self.button_prev.setIcon(get_icon('playback_prev')) + self.button_back.setStyleSheet('border: 0px') + self.button_back.setIcon(get_icon('playback_back')) + self.button_stop.setStyleSheet('border: 0px') + self.button_stop.setIcon(get_icon('playback_stop')) + self.button_forw.setStyleSheet('border: 0px') + self.button_forw.setIcon(get_icon('playback_forw')) + self.button_next.setStyleSheet('border: 0px') + self.button_next.setIcon(get_icon('playback_next')) + self.button_last.setStyleSheet('border: 0px') + self.button_last.setIcon(get_icon('playback_last')) + + self.value_slice_center.setMinimum(lo) + self.value_slice_center.setMaximum(hi) + self.value_slice_center.valueChanged.connect(nonpartial(self.set_label_from_slider)) + + self.valuetext_slider_label.setMinimumWidth(80) + self.state.slider_label = self.value_slice_center.value() + self.valuetext_slider_label.editingFinished.connect(nonpartial(self.set_slider_from_label)) self._play_timer = QtCore.QTimer() self._play_timer.setInterval(500) self._play_timer.timeout.connect(nonpartial(self._play_slice)) - slider.button_first.clicked.connect(nonpartial(self._browse_slice, 'first')) - slider.button_prev.clicked.connect(nonpartial(self._browse_slice, 'prev')) - slider.button_back.clicked.connect(nonpartial(self._adjust_play, 'back')) - slider.button_stop.clicked.connect(nonpartial(self._adjust_play, 'stop')) - slider.button_forw.clicked.connect(nonpartial(self._adjust_play, 'forw')) - slider.button_next.clicked.connect(nonpartial(self._browse_slice, 'next')) - slider.button_last.clicked.connect(nonpartial(self._browse_slice, 'last')) + self.button_first.clicked.connect(nonpartial(self._browse_slice, 'first')) + self.button_prev.clicked.connect(nonpartial(self._browse_slice, 'prev')) + self.button_back.clicked.connect(nonpartial(self._adjust_play, 'back')) + self.button_stop.clicked.connect(nonpartial(self._adjust_play, 'stop')) + self.button_forw.clicked.connect(nonpartial(self._adjust_play, 'forw')) + self.button_next.clicked.connect(nonpartial(self._browse_slice, 'next')) + self.button_last.clicked.connect(nonpartial(self._browse_slice, 'last')) - slider.checkbox_world.toggled.connect(nonpartial(self.set_label_from_slider)) + self.bool_use_world.toggled.connect(nonpartial(self.set_label_from_slider)) if world is None: - self.use_world = False - slider.checkbox_world.hide() + self.state.use_world = False + self.bool_use_world.hide() else: - self.use_world = not world_warning + self.state.use_world = not world_warning if world_unit: - self.slider_unit = world_unit + self.state.slider_unit = world_unit else: - self.slider_unit = '' - - layout.addWidget(slider) - - self.setLayout(layout) - - self._ui_label = label - self._ui_mode = mode - self._update_mode() - self._frozen = False + self.state.slider_unit = '' self._play_speed = 0 self.set_label_from_slider() def set_label_from_slider(self): - value = self._ui_slider.slider.value() - if self.use_world: - text = str(self._world[value]) + value = self.state.slice_center + if self.state.use_world: + value = self._world[value] if self._world_warning: - self._ui_slider.label_warning.show() + self.text_warning.show() else: - self._ui_slider.label_warning.hide() - self.slider_unit = self._world_unit + self.text_warning.hide() + self.state.slider_unit = self._world_unit else: - text = str(value) - self._ui_slider.label_warning.hide() - self.slider_unit = '' - self._ui_slider.label.setText(text) + self.text_warning.hide() + self.state.slider_unit = '' + self.state.slider_label = value def set_slider_from_label(self): - text = self._ui_slider.label.text() - if self.use_world: + text = self.valuetext_slider_label.text() + if self.state.use_world: # Don't want to assume world is sorted, pick closest value value = np.argmin(np.abs(self._world - float(text))) - self._ui_slider.label.setText(str(self._world[value])) + self.state.slider_label = self._world[value] else: value = int(text) - self._ui_slider.slider.setValue(value) + self.value_slice_center.setValue(value) def _adjust_play(self, action): if action == 'stop': @@ -187,9 +151,9 @@ def _play_slice(self): def _browse_slice(self, action, play=False): - imin = self._ui_slider.slider.minimum() - imax = self._ui_slider.slider.maximum() - value = self._ui_slider.slider.value() + imin = self.value_slice_center.minimum() + imax = self.value_slice_center.maximum() + value = self.value_slice_center.value() # If this was not called from _play_slice, we should stop the # animation. @@ -211,193 +175,8 @@ def _browse_slice(self, action, play=False): else: raise ValueError("Action should be one of first/prev/next/last") - self._ui_slider.slider.setValue(value) - - def _update_mode(self, *args): - if self.mode != 'slice': - self._ui_slider.hide() - self._adjust_play('stop') - else: - self._ui_slider.show() - - def freeze(self): - self.mode = 'slice' - self._ui_mode.setEnabled(False) - self._ui_slider.hide() - self._frozen = True - - @property - def frozen(self): - return self._frozen - - -class DataSlice(QtWidgets.QWidget): - - """ - A DatSlice widget provides an inteface for selection - slices through an N-dimensional dataset - - QtCore.Signals - ------- - slice_changed : triggered when the slice through the data changes - """ - slice_changed = QtCore.Signal() + self.value_slice_center.setValue(value) - def __init__(self, data=None, parent=None): - """ - :param data: :class:`~glue.core.data.Data` instance, or None - """ - super(DataSlice, self).__init__(parent) - self._slices = [] - self._data = None - - layout = QtWidgets.QVBoxLayout() - layout.setSpacing(4) - layout.setContentsMargins(0, 3, 0, 3) - self.layout = layout - self.setLayout(layout) - self.set_data(data) - - @property - def ndim(self): - return len(self.shape) - - @property - def shape(self): - return tuple() if self._data is None else self._data.shape - - def _clear(self): - for _ in range(self.layout.count()): - self.layout.takeAt(0) - - for s in self._slices: - s.close() - - self._slices = [] - - def set_data(self, data): - """ - Change datasets - - :parm data: :class:`~glue.core.data.Data` instance - """ - - # remove old widgets - self._clear() - - self._data = data - - if data is None or data.ndim < 3: - return - - # create slider widget for each dimension... - for i, s in enumerate(data.shape): - - # TODO: For now we simply pass a single set of world coordinates, - # but we will need to generalize this in future. We deliberately - # check the type of data.coords here since we want to treat - # subclasses differently. - if type(data.coords) != Coordinates: - world = data.coords.world_axis(data, i) - world_unit = data.coords.world_axis_unit(i) - world_warning = len(data.coords.dependent_axes(i)) > 1 - else: - world = None - world_unit = None - world_warning = False - - slider = SliceWidget(data.get_world_component_id(i).label, - hi=s - 1, world=world, world_unit=world_unit, - world_warning=world_warning) - - if i == self.ndim - 1: - slider.mode = 'x' - elif i == self.ndim - 2: - slider.mode = 'y' - else: - slider.mode = 'slice' - self._slices.append(slider) - - # save ref to prevent PySide segfault - self.__on_slice = partial(self._on_slice, i) - self.__on_mode = partial(self._on_mode, i) - - slider.slice_changed.connect(self.__on_slice) - slider.mode_changed.connect(self.__on_mode) - if s == 1: - slider.freeze() - - # ... and add to the layout - for s in self._slices[::-1]: - self.layout.addWidget(s) - if s is not self._slices[0]: - line = QtWidgets.QFrame() - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - self.layout.addWidget(line) - s.show() # this somehow fixes #342 - - self.layout.addStretch(5) - - def _on_slice(self, index, slice_val): - self.slice_changed.emit() - - def _on_mode(self, index, mode_index): - s = self.slice - - def isok(ss): - # valid slice description: 'x' and 'y' both appear - c = Counter(ss) - return c['x'] == 1 and c['y'] == 1 - - if isok(s): - self.slice_changed.emit() - return - - for i in range(len(s)): - if i == index: - continue - if self._slices[i].frozen: - continue - - for mode in 'x', 'y', 'slice': - if self._slices[i].mode == mode: - continue - - ss = list(s) - ss[i] = mode - if isok(ss): - self._slices[i].mode = mode - return - - else: - raise RuntimeError("Corrupted Data Slice") - - @property - def slice(self): - """ - A description of the slice through the dataset - - A tuple of lenght equal to the dimensionality of the data - - Each element is an integer, 'x', or 'y' - 'x' and 'y' indicate the horizontal and vertical orientation - of the slice - """ - if self.ndim < 3: - return {0: tuple(), 1: ('x',), 2: ('y', 'x')}[self.ndim] - - return tuple(s.mode if s.mode != 'slice' else s.slice_center - for s in self._slices) - - @slice.setter - def slice(self, value): - for v, s in zip(value, self._slices): - if v in ['x', 'y']: - s.mode = v - else: - s.mode = 'slice' - s.slice_center = v if __name__ == "__main__": @@ -405,7 +184,10 @@ def slice(self, value): app = get_qapp() - widget = SliceWidget() + widget = SliceWidget(label='BANANA') + widget.show() + + widget = SliceWidget(world=[1, 2, 3, 4, 5, 6, 7], lo=1, hi=7) widget.show() app.exec_() diff --git a/glue/viewers/common/qt/data_slice_widget.ui b/glue/viewers/common/qt/data_slice_widget.ui index eb9ca354a..4eae29ff2 100644 --- a/glue/viewers/common/qt/data_slice_widget.ui +++ b/glue/viewers/common/qt/data_slice_widget.ui @@ -6,8 +6,8 @@ 0 0 - 294 - 88 + 310 + 107 @@ -17,16 +17,32 @@ 0 - + 0 + + 0 + + + 0 + + + 0 + + + + + Main label + + + 10 - + Qt::Horizontal @@ -181,7 +197,7 @@ - + 50 @@ -194,7 +210,7 @@ - + TextLabel @@ -218,7 +234,7 @@ - + Show real coordinates for slices @@ -246,7 +262,7 @@ - + color: rgb(255, 33, 28) diff --git a/glue/viewers/common/qt/data_viewer.py b/glue/viewers/common/qt/data_viewer.py index 8149b9c32..448549927 100644 --- a/glue/viewers/common/qt/data_viewer.py +++ b/glue/viewers/common/qt/data_viewer.py @@ -78,6 +78,10 @@ def __init__(self, session, parent=None): self._layer_artist_container.on_empty(lambda: self.close(warn=False)) self._layer_artist_container.on_changed(self.update_window_title) + @property + def selected_layer(self): + return self._view.layer_list.current_artist() + def remove_layer(self, layer): self._layer_artist_container.pop(layer) diff --git a/glue/viewers/common/qt/tests/test_data_viewer.py b/glue/viewers/common/qt/tests/test_data_viewer.py index c3da11b1a..fa3f25e43 100644 --- a/glue/viewers/common/qt/tests/test_data_viewer.py +++ b/glue/viewers/common/qt/tests/test_data_viewer.py @@ -12,7 +12,7 @@ from ..data_viewer import DataViewer from glue.viewers.histogram.qt import HistogramViewer from glue.viewers.image.qt import ImageWidget -from glue.viewers.scatter.qt import ScatterWidget +from glue.viewers.scatter.qt import ScatterViewer # TODO: We should maybe consider running these tests for all @@ -44,7 +44,7 @@ def test_single_draw_call_on_create(self): app = GlueApplication(dc) try: - from glue.viewers.common.qt.mpl_widget import MplCanvas + from glue.viewers.matplotlib.qt.widget import MplCanvas draw = MplCanvas.draw MplCanvas.draw = MagicMock() @@ -95,7 +95,7 @@ def test_viewer_size(self, tmpdir): class TestDataViewerScatter(BaseTestDataViewer): - widget_cls = ScatterWidget + widget_cls = ScatterViewer class TestDataViewerImage(BaseTestDataViewer): diff --git a/glue/viewers/common/qt/tests/test_toolbar.py b/glue/viewers/common/qt/tests/test_toolbar.py index 36f49585b..cd33b4f06 100644 --- a/glue/viewers/common/qt/tests/test_toolbar.py +++ b/glue/viewers/common/qt/tests/test_toolbar.py @@ -7,7 +7,7 @@ from qtpy import QtWidgets from glue.config import viewer_tool -from glue.viewers.common.qt.mpl_widget import MplWidget +from glue.viewers.matplotlib.qt.widget import MplWidget from glue.viewers.common.qt.data_viewer import DataViewer from glue.viewers.common.qt.tool import Tool from glue.viewers.common.qt.toolbar import BasicToolbar diff --git a/glue/viewers/common/viz_client.py b/glue/viewers/common/viz_client.py index d4d8fa7e3..7ec856d8b 100644 --- a/glue/viewers/common/viz_client.py +++ b/glue/viewers/common/viz_client.py @@ -186,10 +186,10 @@ def init_mpl(figure=None, axes=None, wcs=False, axes_factory=None): _axes = WCSAxesSubplot(_figure, 111) _figure.add_axes(_axes) else: - if axes_factory is not None: - _axes = axes_factory(_figure) - else: + if axes_factory is None: _axes = _figure.add_subplot(1, 1, 1) + else: + _axes = axes_factory(_figure) freeze_margins(_axes, margins=[1, 0.25, 0.50, 0.25]) diff --git a/glue/viewers/custom/qt/custom_viewer.py b/glue/viewers/custom/qt/custom_viewer.py index 8e103ca62..014dee474 100644 --- a/glue/viewers/custom/qt/custom_viewer.py +++ b/glue/viewers/custom/qt/custom_viewer.py @@ -88,11 +88,11 @@ def plot_data(self, x, checked, axes): from glue.viewers.common.viz_client import GenericMplClient -from glue.viewers.common.qt.mpl_widget import MplWidget +from glue.viewers.matplotlib.qt.widget import MplWidget from glue.viewers.common.qt.data_viewer import DataViewer from glue.utils.qt.widget_properties import (ValueProperty, ButtonProperty, CurrentComboProperty) -from glue.viewers.common.qt.mpl_toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar from glue.viewers.common.qt.mouse_mode import PolyMode, RectangleMode __all__ = ["AttributeInfo", "ViewerState", "UserDefinedFunction", diff --git a/glue/viewers/histogram/compat.py b/glue/viewers/histogram/compat.py index 0d82d12c5..b7acf17e5 100644 --- a/glue/viewers/histogram/compat.py +++ b/glue/viewers/histogram/compat.py @@ -6,7 +6,7 @@ STATE_CLASS['HistogramLayerArtist'] = HistogramLayerState -def update_viewer_state(rec, context): +def update_histogram_viewer_state(rec, context): """ Given viewer session information, make sure the session information is compatible with the current version of the viewers, and if not, update diff --git a/glue/viewers/histogram/layer_artist.py b/glue/viewers/histogram/layer_artist.py index 7de6abb42..9bd602be6 100644 --- a/glue/viewers/histogram/layer_artist.py +++ b/glue/viewers/histogram/layer_artist.py @@ -5,7 +5,7 @@ from glue.utils import defer_draw from glue.viewers.histogram.state import HistogramLayerState -from glue.viewers.common.mpl_layer_artist import MatplotlibLayerArtist +from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist from glue.core.exceptions import IncompatibleAttribute diff --git a/glue/viewers/histogram/qt/data_viewer.py b/glue/viewers/histogram/qt/data_viewer.py index 166740895..7f484f69d 100644 --- a/glue/viewers/histogram/qt/data_viewer.py +++ b/glue/viewers/histogram/qt/data_viewer.py @@ -1,19 +1,19 @@ from __future__ import absolute_import, division, print_function from glue.utils import nonpartial -from glue.viewers.common.qt.mpl_toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar from glue.core.edit_subset_mode import EditSubsetMode from glue.core import Data from glue.core.util import update_ticks from glue.core.roi import RangeROI from glue.utils import defer_draw -from glue.viewers.common.qt.mpl_data_viewer import MatplotlibDataViewer +from glue.viewers.matplotlib.qt.data_viewer import MatplotlibDataViewer from glue.viewers.histogram.qt.layer_style_editor import HistogramLayerStyleEditor from glue.viewers.histogram.layer_artist import HistogramLayerArtist from glue.viewers.histogram.qt.options_widget import HistogramOptionsWidget from glue.viewers.histogram.state import HistogramViewerState -from glue.viewers.histogram.compat import update_viewer_state +from glue.viewers.histogram.compat import update_histogram_viewer_state from glue.core.state import lookup_class_with_patches @@ -30,6 +30,8 @@ class HistogramViewer(MatplotlibDataViewer): _data_artist_cls = HistogramLayerArtist _subset_artist_cls = HistogramLayerArtist + update_viewer_state = update_histogram_viewer_state + tools = ['select:xrange'] def __init__(self, session, parent=None): @@ -92,38 +94,3 @@ def apply_roi(self, roi): mode = EditSubsetMode() mode.update(self._data, subset_state, focus_data=layer_artist.layer) - - def __gluestate__(self, context): - return dict(state=self.state.__gluestate__(context), - session=context.id(self._session), - size=self.viewer_size, - pos=self.position, - layers=list(map(context.do, self.layers)), - _protocol=1) - - @classmethod - @defer_draw - def __setgluestate__(cls, rec, context): - - if rec.get('_protocol', 0) < 1: - update_viewer_state(rec, context) - - session = context.object(rec['session']) - viewer = cls(session) - viewer.register_to_hub(session.hub) - viewer.viewer_size = rec['size'] - x, y = rec['pos'] - viewer.move(x=x, y=y) - - viewer_state = HistogramViewerState.__setgluestate__(rec['state'], context) - - viewer.state.update_from_state(viewer_state) - - # Restore layer artists - for l in rec['layers']: - cls = lookup_class_with_patches(l.pop('_type')) - layer_state = context.object(l['state']) - layer_artist = cls(viewer.axes, viewer.state, layer_state=layer_state) - viewer._layer_artist_container.append(layer_artist) - - return viewer diff --git a/glue/viewers/histogram/qt/tests/test_viewer_widget.py b/glue/viewers/histogram/qt/tests/test_viewer_widget.py index 1cc663975..11ab9235b 100644 --- a/glue/viewers/histogram/qt/tests/test_viewer_widget.py +++ b/glue/viewers/histogram/qt/tests/test_viewer_widget.py @@ -15,7 +15,7 @@ from glue.core.component_id import ComponentID from glue.core.tests.util import simple_session from glue.utils.qt import combo_as_string -from glue.viewers.common.qt.tests.test_mpl_data_viewer import BaseTestMatplotlibDataViewer +from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer from glue.core.state import GlueUnSerializer from ..data_viewer import HistogramViewer @@ -117,9 +117,9 @@ def test_remove_data(self): def test_update_component_updates_title(self): self.viewer.add_data(self.data) - self.viewer.windowTitle() == 'x' + assert self.viewer.windowTitle() == '1D Histogram' self.viewer.state.x_att = self.data.id['y'] - self.viewer.windowTitle() == 'y' + assert self.viewer.windowTitle() == '1D Histogram' def test_combo_updates_with_component_add(self): self.viewer.add_data(self.data) diff --git a/glue/viewers/histogram/state.py b/glue/viewers/histogram/state.py index cc2b36010..4e599715a 100644 --- a/glue/viewers/histogram/state.py +++ b/glue/viewers/histogram/state.py @@ -4,7 +4,7 @@ from glue.core import Data -from glue.viewers.common.mpl_state import (MatplotlibDataViewerState, +from glue.viewers.matplotlib.state import (MatplotlibDataViewerState, MatplotlibLayerState, DeferredDrawCallbackProperty) from glue.core.state_objects import (StateAttributeLimitsHelper, diff --git a/glue/viewers/image/qt/tests/baseline/test_resample_on_zoom.png b/glue/viewers/image/qt/tests/baseline/test_resample_on_zoom.png deleted file mode 100644 index 2466c6311..000000000 Binary files a/glue/viewers/image/qt/tests/baseline/test_resample_on_zoom.png and /dev/null differ diff --git a/glue/viewers/image/qt/viewer_widget.py b/glue/viewers/image/qt/viewer_widget.py index fda0bd497..351d04aa6 100644 --- a/glue/viewers/image/qt/viewer_widget.py +++ b/glue/viewers/image/qt/viewer_widget.py @@ -10,14 +10,14 @@ from glue.config import viewer_tool from glue.viewers.image.ds9norm import DS9Normalize from glue.viewers.image.client import MplImageClient -from glue.viewers.common.qt.mpl_toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar from glue.viewers.common.qt.mouse_mode import (RectangleMode, CircleMode, PolyMode, ContrastMode) from glue.icons.qt import get_icon from glue.utils.qt.widget_properties import CurrentComboProperty, ButtonProperty, connect_current_combo, _find_combo_data from glue.viewers.common.qt.data_slice_widget import DataSlice from glue.viewers.common.qt.data_viewer import DataViewer -from glue.viewers.common.qt.mpl_widget import MplWidget, defer_draw +from glue.viewers.matplotlib.qt.widget import MplWidget, defer_draw from glue.utils import nonpartial, Pointer from glue.utils.qt import cmap2pixmap, update_combobox, load_ui from glue.viewers.common.qt.tool import Tool diff --git a/glue/viewers/image_new/__init__.py b/glue/viewers/image_new/__init__.py new file mode 100644 index 000000000..10588622c --- /dev/null +++ b/glue/viewers/image_new/__init__.py @@ -0,0 +1,4 @@ +def setup(): + from glue.config import qt_client + from .qt.data_viewer import ImageViewer + qt_client.add(ImageViewer) diff --git a/glue/viewers/image_new/compat.py b/glue/viewers/image_new/compat.py new file mode 100644 index 000000000..8b2a1401a --- /dev/null +++ b/glue/viewers/image_new/compat.py @@ -0,0 +1,124 @@ +import uuid + +import numpy as np +from glue.viewers.image_new.state import ImageLayerState, ImageSubsetLayerState +from glue.viewers.scatter.state import ScatterLayerState + +STATE_CLASS = {} +STATE_CLASS['ImageLayerArtist'] = ImageLayerState +STATE_CLASS['ScatterLayerArtist'] = ScatterLayerState +STATE_CLASS['SubsetImageLayerArtist'] = ImageSubsetLayerState + + +class DS9Compat(object): + + @classmethod + def __setgluestate__(cls, rec, context): + result = cls() + for k, v in rec.items(): + setattr(result, k, v) + return result + + +def update_image_viewer_state(rec, context): + """ + Given viewer session information, make sure the session information is + compatible with the current version of the viewers, and if not, update + the session information in-place. + """ + + if '_protocol' not in rec: + + # Note that files saved with protocol < 1 have bin settings saved per + # layer but they were always restricted to be the same, so we can just + # use the settings from the first layer + + rec['state'] = {} + rec['state']['values'] = {} + + # TODO: could generalize this into a mapping + properties = rec.pop('properties') + viewer_state = rec['state']['values'] + viewer_state['color_mode'] = 'st__Colormaps' + viewer_state['reference_data'] = properties['data'] + + data = context.object(properties['data']) + + # TODO: add an id method to unserializer + + x_index = properties['slice'].index('x') + y_index = properties['slice'].index('y') + + viewer_state['x_att_world'] = str(uuid.uuid4()) + context.register_object(viewer_state['x_att_world'], data.world_component_ids[x_index]) + + viewer_state['y_att_world'] = str(uuid.uuid4()) + context.register_object(viewer_state['y_att_world'], data.world_component_ids[y_index]) + + viewer_state['x_att'] = str(uuid.uuid4()) + context.register_object(viewer_state['x_att'], data.pixel_component_ids[x_index]) + + viewer_state['y_att'] = str(uuid.uuid4()) + context.register_object(viewer_state['y_att'], data.pixel_component_ids[y_index]) + + viewer_state['x_min'] = 0 + viewer_state['x_max'] = data.shape[0] + viewer_state['y_min'] = 0 + viewer_state['y_max'] = data.shape[1] + + # Slicing with cubes + viewer_state['slices'] = [s if np.isreal(s) else 0 for s in properties['slice']] + + # RGB mode + for layer in rec['layers'][:]: + if layer['_type'].split('.')[-1] == 'RGBImageLayerArtist': + for icolor, color in enumerate('rgb'): + new_layer = {} + new_layer['_type'] = 'glue.viewers.image_new.layer_artist.ImageLayerArtist' + new_layer['layer'] = layer['layer'] + new_layer['attribute'] = layer[color] + new_layer['norm'] = layer[color + 'norm'] + new_layer['zorder'] = layer['zorder'] + new_layer['visible'] = layer['color_visible'][icolor] + new_layer['color'] = color + rec['layers'].append(new_layer) + rec['layers'].remove(layer) + viewer_state['color_mode'] = 'st__One color per layer' + + layer_states = [] + + for layer in rec['layers']: + + state_id = str(uuid.uuid4()) + state_cls = STATE_CLASS[layer['_type'].split('.')[-1]] + state = state_cls(layer=context.object(layer.pop('layer'))) + for prop in ('visible', 'zorder'): + value = layer.pop(prop) + value = context.object(value) + setattr(state, prop, value) + if 'attribute' in layer: + state.attribute = context.object(layer['attribute']) + else: + state.attribute = context.object(properties['attribute']) + if 'norm' in layer: + norm = context.object(layer['norm']) + state.bias = norm.bias + state.contrast = norm.contrast + state.stretch = norm.stretch + if norm.clip_hi is not None: + state.percentile = norm.clip_hi + else: + if norm.vmax is not None: + state.v_min = norm.vmin + state.v_max = norm.vmax + state.percentile = 'Custom' + if 'color' in layer: + state.global_sync = False + state.color = layer['color'] + context.register_object(state_id, state) + layer['state'] = state_id + layer_states.append(state) + + list_id = str(uuid.uuid4()) + context.register_object(list_id, layer_states) + rec['state']['values']['layers'] = list_id diff --git a/glue/viewers/image_new/composite_array.py b/glue/viewers/image_new/composite_array.py new file mode 100644 index 000000000..83c7448c5 --- /dev/null +++ b/glue/viewers/image_new/composite_array.py @@ -0,0 +1,153 @@ +# This artist can be used to deal with the sampling of the data as well as any +# RGB blending. + +from __future__ import absolute_import + +import numpy as np + +from matplotlib.transforms import TransformedBbox +from matplotlib.colors import ColorConverter, Colormap +from astropy.visualization import (LinearStretch, SqrtStretch, AsinhStretch, + LogStretch, ManualInterval, ContrastBiasStretch) + +__all__ = ['CompositeArray'] + +COLOR_CONVERTER = ColorConverter() + +STRETCHES = { + 'linear': LinearStretch, + 'sqrt': SqrtStretch, + 'arcsinh': AsinhStretch, + 'log': LogStretch +} + +class CompositeArray(object): + + def __init__(self, ax, **kwargs): + + self.axes = ax + + # We keep a dictionary of layers. The key should be the UUID of the + # layer artist, and the values should be dictionaries that contain + # 'zorder', 'visible', 'array', 'color', and 'alpha'. + self.layers = {} + + self._first = True + + # ax.set_ylim((df[y].min(), df[y].max())) + # ax.set_xlim((df[x].min(), df[x].max())) + # self.set_array([[1, 1], [1, 1]]) + + def allocate(self, uuid): + self.layers[uuid] = {'zorder': 0, + 'visible': True, + 'array': None, + 'color': '0.5', + 'alpha': 1, + 'clim': (0, 1), + 'contrast': 1, + 'bias': 0.5, + 'stretch': 'linear'} + + def deallocate(self, uuid): + self.layers.pop(uuid) + if len(self.layers) == 0: + self.shape = None + + def set(self, uuid, **kwargs): + for key, value in kwargs.items(): + if key not in self.layers[uuid]: + raise KeyError("Unknown key: {0}".format(key)) + else: + self.layers[uuid][key] = value + + @property + def shape(self): + if len(self.layers) > 0: + first_layer = list(self.layers.values())[0] + if callable(first_layer['array']): + array = first_layer['array']() + else: + array = first_layer['array'] + return array.shape + else: + return None + + def __getitem__(self, view): + + img = None + visible_layers = 0 + + for uuid in sorted(self.layers, key=lambda x: self.layers[x]['zorder']): + + layer = self.layers[uuid] + + if not layer['visible']: + continue + + interval = ManualInterval(*layer['clim']) + contrast_bias = ContrastBiasStretch(layer['contrast'], layer['bias']) + + if callable(layer['array']): + array = layer['array']() + else: + array = layer['array'] + + array_sub = array[view] + if np.isscalar(array_sub): + scalar = True + array_sub = np.atleast_2d(array_sub) + else: + scalar = False + + data = STRETCHES[layer['stretch']]()(contrast_bias(interval(array_sub))) + + if isinstance(layer['color'], Colormap): + + if img is None: + img = np.ones(data.shape + (4,)) + + # Compute colormapped image + plane = layer['color'](data) + + # Use traditional alpha compositing + img *= (1 - layer['alpha']) + plane = plane * layer['alpha'] + + else: + + if img is None: + img = np.zeros(data.shape + (4,)) + + # Get color and pre-multiply by alpha values + color = COLOR_CONVERTER.to_rgba_array(layer['color'])[0] + color *= layer['alpha'] + + plane = data[:, :, np.newaxis] * color + plane[:, :, 3] = 1 + + visible_layers += 1 + + if scalar: + plane = plane[0, 0] + + img += plane + + if img is None: + img = np.zeros(self.shape + (4,)) + + img = np.clip(img, 0, 1) + + return img + + @property + def dtype(self): + return np.float + + @property + def ndim(self): + return 2 + + @property + def size(self): + return np.product(self.shape) diff --git a/glue/viewers/image_new/contrast_mouse_mode.py b/glue/viewers/image_new/contrast_mouse_mode.py new file mode 100644 index 000000000..8da79dd44 --- /dev/null +++ b/glue/viewers/image_new/contrast_mouse_mode.py @@ -0,0 +1,48 @@ +# New contrast/bias mode that operates on viewers with state objects + +from __future__ import absolute_import, division, print_function + +import os + +from qtpy import QtGui, QtWidgets +from glue.core.callback_property import CallbackProperty +from glue.core import roi +from glue.core.qt import roi as qt_roi +from glue.utils.qt import get_qapp +from glue.utils import nonpartial +from glue.utils.qt import load_ui, cmap2pixmap +from glue.viewers.common.qt.tool import Tool, CheckableTool +from glue.config import viewer_tool +from glue.viewers.common.qt.mouse_mode import MouseMode + + +@viewer_tool +class ContrastBiasMode(MouseMode): + """ + Uses right mouse button drags to set bias and contrast, DS9-style. The + horizontal position of the mouse sets the bias, the vertical position sets + the contrast. + """ + + icon = 'glue_contrast' + tool_id = 'image:contrast_bias' + action_text = 'Contrast/Bias' + tool_tip = 'Adjust the bias/contrast' + + def move(self, event): + """ + Update bias and contrast on Right Mouse button drag. + """ + + if event.button != 3: # RMB drag only + return + + x, y = event.x, event.y + dx, dy = self._axes.figure.canvas.get_width_height() + x = 1.0 * x / dx + y = 1.0 * y / dy + + self.viewer.selected_layer.state.bias = x + self.viewer.selected_layer.state.contrast = (1 - y) * 10 + + super(ContrastBiasMode, self).move(event) diff --git a/glue/viewers/image_new/layer_artist.py b/glue/viewers/image_new/layer_artist.py new file mode 100644 index 000000000..7b3eb5da6 --- /dev/null +++ b/glue/viewers/image_new/layer_artist.py @@ -0,0 +1,245 @@ +from __future__ import absolute_import, division, print_function + +import uuid +import numpy as np + +from glue.utils import defer_draw + +from glue.viewers.image_new.state import ImageLayerState +from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist +from glue.core.exceptions import IncompatibleAttribute +from glue.utils import color2rgb + + +class ImageLayerArtist(MatplotlibLayerArtist): + + _layer_state_cls = ImageLayerState + + def __init__(self, axes, viewer_state, layer_state=None, layer=None): + + super(ImageLayerArtist, self).__init__(axes, viewer_state, + layer_state=layer_state, layer=layer) + + self.reset_cache() + + # Watch for changes in the viewer state which would require the + # layers to be redrawn + self._viewer_state.add_global_callback(self._update_image) + self.state.add_global_callback(self._update_image) + + # TODO: following is temporary + self.state.data_collection = self._viewer_state.data_collection + self.data_collection = self._viewer_state.data_collection + + # We use a custom object to deal with the compositing of images, and we + # store it as a private attribute of the axes to make sure it is + # accessible for all layer artists. + self.uuid = str(uuid.uuid4()) + self.composite = self.axes._composite + self.composite.allocate(self.uuid) + self.composite.set(self.uuid, array=self.get_image_data) + self.composite_image = self.axes._composite_image + + def reset_cache(self): + self._last_viewer_state = {} + self._last_layer_state = {} + + def get_image_data(self): + + try: + image = self.layer[self.state.attribute] + except (IncompatibleAttribute, IndexError): + # The following includes a call to self.clear() + self.disable_invalid_attributes(self.state.attribute) + image = np.zeros(self.layer.shape) + # TODO: Is this enough? + else: + self._enabled = True + + slices, transpose = self._viewer_state.numpy_slice_and_transpose + image = image[slices] + if transpose: + image = image.transpose() + + return image + + def _update_image_data(self): + self.composite_image.invalidate_cache() + self.redraw() + + @defer_draw + def _update_visual_attributes(self): + + if self._viewer_state.color_mode == 'Colormaps': + color = self.state.cmap + else: + color = self.state.color + + self.composite.set(self.uuid, + clim=(self.state.v_min, self.state.v_max), + visible=self.state.visible, + zorder=self.state.zorder, + color=color, + contrast=self.state.contrast, + bias=self.state.bias, + alpha=self.state.alpha, + stretch=self.state.stretch) + + self.composite_image.invalidate_cache() + + self.redraw() + + def _update_image(self, force=False, **kwargs): + + if (self.state.attribute is None or + self.state.layer is None): + return + + # Figure out which attributes are different from before. Ideally we shouldn't + # need this but currently this method is called multiple times if an + # attribute is changed due to x_att changing then hist_x_min, hist_x_max, etc. + # If we can solve this so that _update_histogram is really only called once + # then we could consider simplifying this. Until then, we manually keep track + # of which properties have changed. + + + changed = set() + + if not force: + + for key, value in self._viewer_state.as_dict().items(): + if value != self._last_viewer_state.get(key, None): + changed.add(key) + + for key, value in self.state.as_dict().items(): + if value != self._last_layer_state.get(key, None): + changed.add(key) + + self._last_viewer_state.update(self._viewer_state.as_dict()) + self._last_layer_state.update(self.state.as_dict()) + + if force or any(prop in changed for prop in ('layer', 'attribute', 'slices', 'x_att', 'y_att')): + self._update_image_data() + force = True # make sure scaling and visual attributes are updated + + if force or any(prop in changed for prop in ('v_min', 'v_max', 'contrast', 'bias', 'alpha', 'color_mode', 'cmap', 'color', 'zorder', 'visible', 'stretch')): + self._update_visual_attributes() + + @defer_draw + def update(self): + + self._update_image(force=True) + + # Reset the axes stack so that pressing the home button doesn't go back + # to a previous irrelevant view. + self.axes.figure.canvas.toolbar.update() + + self.redraw() + + +class ImageSubsetLayerArtist(MatplotlibLayerArtist): + + _layer_state_cls = ImageLayerState + + def __init__(self, axes, viewer_state, layer_state=None, layer=None): + + super(ImageSubsetLayerArtist, self).__init__(axes, viewer_state, + layer_state=layer_state, layer=layer) + + self.reset_cache() + + # Watch for changes in the viewer state which would require the + # layers to be redrawn + self._viewer_state.add_global_callback(self._update_image) + self.state.add_global_callback(self._update_image) + + # TODO: following is temporary + self.state.data_collection = self._viewer_state.data_collection + self.data_collection = self._viewer_state.data_collection + + self.mpl_image = self.axes.imshow([[0.]], + origin='lower', interpolation='nearest', + vmin=0, vmax=1) + + def reset_cache(self): + self._last_viewer_state = {} + self._last_layer_state = {} + + def _get_image_data(self): + + view, transpose = self._viewer_state.numpy_slice_and_transpose + + mask = self.layer.to_mask(view=view) + + if transpose: + mask = mask.transpose() + + r, g, b = color2rgb(self.state.color) + mask = np.dstack((r * mask, g * mask, b * mask, mask * .5)) + mask = (255 * mask).astype(np.uint8) + + return mask + + def _update_image_data(self): + data = self._get_image_data() + self.mpl_image.set_data(data) + self.mpl_image.set_extent([-0.5, data.shape[1] - 0.5, -0.5, data.shape[0] - 0.5]) + self.redraw() + + @defer_draw + def _update_visual_attributes(self): + + # TODO: deal with color using a colormap instead of having to change data + + self.mpl_image.set_visible(self.state.visible) + self.mpl_image.set_zorder(self.state.zorder) + self.mpl_image.set_alpha(self.state.alpha) + + self.redraw() + + def _update_image(self, force=False, **kwargs): + + if self.state.layer is None: + return + + # Figure out which attributes are different from before. Ideally we shouldn't + # need this but currently this method is called multiple times if an + # attribute is changed due to x_att changing then hist_x_min, hist_x_max, etc. + # If we can solve this so that _update_histogram is really only called once + # then we could consider simplifying this. Until then, we manually keep track + # of which properties have changed. + + changed = set() + + if not force: + + for key, value in self._viewer_state.as_dict().items(): + if value != self._last_viewer_state.get(key, None): + changed.add(key) + + for key, value in self.state.as_dict().items(): + if value != self._last_layer_state.get(key, None): + changed.add(key) + + self._last_viewer_state.update(self._viewer_state.as_dict()) + self._last_layer_state.update(self.state.as_dict()) + + if force or any(prop in changed for prop in ('layer', 'attribute', 'color', 'x_att', 'y_att', 'slices')): + self._update_image_data() + force = True # make sure scaling and visual attributes are updated + + if force or any(prop in changed for prop in ('zorder', 'visible', 'alpha')): + self._update_visual_attributes() + + @defer_draw + def update(self): + + # TODO: determine why this gets called when changing the transparency slider + + self._update_image(force=True) + + # Reset the axes stack so that pressing the home button doesn't go back + # to a previous irrelevant view. + self.axes.figure.canvas.toolbar.update() + + self.redraw() diff --git a/glue/viewers/image_new/qt/__init__.py b/glue/viewers/image_new/qt/__init__.py new file mode 100644 index 000000000..a1bfad99c --- /dev/null +++ b/glue/viewers/image_new/qt/__init__.py @@ -0,0 +1,2 @@ +from .data_viewer import ImageViewer +from .standalone_image_viewer import StandaloneImageViewer diff --git a/glue/viewers/image_new/qt/data_viewer.py b/glue/viewers/image_new/qt/data_viewer.py new file mode 100644 index 000000000..774bb2cb6 --- /dev/null +++ b/glue/viewers/image_new/qt/data_viewer.py @@ -0,0 +1,136 @@ +from __future__ import absolute_import, division, print_function + +from astropy.wcs import WCS + +from qtpy.QtWidgets import QMessageBox + +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar +from glue.core.edit_subset_mode import EditSubsetMode +from glue.core import Data +from glue.utils import defer_draw + +from glue.core.coordinates import WCSCoordinates +from glue.viewers.matplotlib.qt.data_viewer import MatplotlibDataViewer +from glue.viewers.scatter.qt.layer_style_editor import ScatterLayerStyleEditor +from glue.viewers.scatter.layer_artist import ScatterLayerArtist +from glue.viewers.image_new.qt.layer_style_editor import ImageLayerStyleEditor +from glue.viewers.image_new.qt.layer_style_editor_subset import ImageLayerSubsetStyleEditor +from glue.viewers.image_new.layer_artist import ImageLayerArtist, ImageSubsetLayerArtist +from glue.viewers.image_new.qt.options_widget import ImageOptionsWidget +from glue.viewers.image_new.state import ImageViewerState +from glue.viewers.image_new.compat import update_image_viewer_state + +from glue.external.modest_image import imshow +from glue.viewers.image_new.composite_array import CompositeArray + +# Import the mouse mode to make sure it gets registered +from glue.viewers.image_new.contrast_mouse_mode import ContrastBiasMode # noqa + +__all__ = ['ImageViewer'] + + +class ImageViewer(MatplotlibDataViewer): + + LABEL = '2D Image' + _toolbar_cls = MatplotlibViewerToolbar + _layer_style_widget_cls = {ImageLayerArtist: ImageLayerStyleEditor, + ImageSubsetLayerArtist: ImageLayerSubsetStyleEditor, + ScatterLayerArtist: ScatterLayerStyleEditor} + _state_cls = ImageViewerState + _options_cls = ImageOptionsWidget + + update_viewer_state = update_image_viewer_state + + # NOTE: _data_artist_cls and _subset_artist_cls are implemented as methods + + tools = ['select:rectangle', 'select:xrange', + 'select:yrange', 'select:circle', + 'select:polygon', 'image:contrast_bias'] + + def __init__(self, session, parent=None): + super(ImageViewer, self).__init__(session, parent=parent, wcs=True) + self.axes.set_adjustable('datalim') + self.state.add_callback('aspect', self._set_aspect) + self.state.add_callback('x_att', self._set_wcs) + self.state.add_callback('y_att', self._set_wcs) + self.state.add_callback('slices', self._set_wcs) + self.state.add_callback('reference_data', self._set_wcs) + self.axes._composite = CompositeArray(self.axes) + self.axes._composite_image = imshow(self.axes, self.axes._composite, + origin='lower', interpolation='nearest') + + @defer_draw + def _update_axes(self, *args): + + if self.state.x_att_world is not None: + self.axes.set_xlabel(self.state.x_att_world.label) + + if self.state.y_att_world is not None: + self.axes.set_ylabel(self.state.y_att_world.label) + + self.axes.figure.canvas.draw() + + def _set_aspect(self, *args): + self.axes.set_aspect(self.state.aspect) + self.axes.figure.canvas.draw() + + def _set_wcs(self, *args): + if self.state.x_att is None or self.state.y_att is None or self.state.reference_data is None: + return + ref_coords = self.state.reference_data.coords + if isinstance(ref_coords, WCSCoordinates): + self.axes.reset_wcs(ref_coords.wcs, slices=self.state.wcsaxes_slice) + self._update_axes() + + def apply_roi(self, roi): + + # TODO: move this to state class? + + # TODO: add back command stack here so as to be able to undo? + # cmd = command.ApplyROI(client=self.client, roi=roi) + # self._session.command_stack.do(cmd) + + # TODO Does subset get applied to all data or just visible data? + + for layer_artist in self._layer_artist_container: + + if not isinstance(layer_artist.layer, Data): + continue + + x_comp = layer_artist.layer.get_component(self.state.x_att) + y_comp = layer_artist.layer.get_component(self.state.y_att) + + subset_state = x_comp.subset_from_roi(self.state.x_att, roi, + other_comp=y_comp, + other_att=self.state.y_att, + coord='x') + + if self.state.single_slice_subset: + for i in range(self.state.reference_data.ndim): + pid = self.state.reference_data.pixel_component_ids[i] + if pid is not self.state.x_att and pid is not self.state.y_att: + current_slice = self.state.slices[i] + subset_state = subset_state & (pid == current_slice) + + mode = EditSubsetMode() + mode.update(self._data, subset_state, focus_data=layer_artist.layer) + + def _scatter_artist(self, axes, state, layer=None): + if len(self._layer_artist_container) == 0: + QMessageBox.critical(self, "Error", "Can only add a scatter plot " + "overlay once an image is present", + buttons=QMessageBox.Ok) + return None + return ScatterLayerArtist(axes, state, layer=layer) + + def _data_artist_cls(self, axes, state, layer=None): + if layer.ndim == 1: + return self._scatter_artist(axes, state, layer=layer) + else: + return ImageLayerArtist(axes, state, layer=layer) + + def _subset_artist_cls(self, axes, state, layer=None): + if layer.ndim == 1: + return self._scatter_artist(axes, state, layer=layer) + else: + return ImageSubsetLayerArtist(axes, state, layer=layer) diff --git a/glue/viewers/image_new/qt/layer_style_editor.py b/glue/viewers/image_new/qt/layer_style_editor.py new file mode 100644 index 000000000..41624d9a4 --- /dev/null +++ b/glue/viewers/image_new/qt/layer_style_editor.py @@ -0,0 +1,59 @@ +import os + +from qtpy import QtWidgets + +from astropy.visualization import (LinearStretch, SqrtStretch, + LogStretch, AsinhStretch) + +from glue.external.echo.qt import autoconnect_callbacks_to_qt +from glue.utils.qt import load_ui, update_combobox +from glue.core.qt.data_combo_helper import ComponentIDComboHelper + + +class ImageLayerStyleEditor(QtWidgets.QWidget): + + def __init__(self, layer, parent=None): + + super(ImageLayerStyleEditor, self).__init__(parent=parent) + + self.ui = load_ui('layer_style_editor.ui', self, + directory=os.path.dirname(__file__)) + + connect_kwargs = {'alpha': dict(value_range=(0, 1)), + 'contrast': dict(value_range=(-5, 15)), + 'bias': dict(value_range=(-2, 3))} + + percentiles = [('Min/Max', 100), + ('99.5%', 99.5), + ('99%', 99), + ('95%', 95), + ('90%', 90), + ('Custom', 'Custom')] + + update_combobox(self.ui.combodata_percentile, percentiles) + + stretches = [('Linear', 'linear'), + ('Square Root', 'sqrt'), + ('Arcsinh', 'arcsinh'), + ('Logarithmic', 'log')] + + update_combobox(self.ui.combodata_stretch, stretches) + + self.attribute_helper = ComponentIDComboHelper(self.ui.combodata_attribute, + layer.data_collection) + + self.attribute_helper.append_data(layer.layer) + + autoconnect_callbacks_to_qt(layer.state, self.ui, connect_kwargs) + + layer._viewer_state.add_callback('color_mode', self._update_color_mode) + + self._update_color_mode(layer._viewer_state.color_mode) + + def _update_color_mode(self, color_mode): + if color_mode == 'Colormaps': + self.ui.color_color.hide() + self.ui.combodata_cmap.show() + else: + self.ui.color_color.show() + self.ui.combodata_cmap.hide() diff --git a/glue/viewers/image_new/qt/layer_style_editor.ui b/glue/viewers/image_new/qt/layer_style_editor.ui new file mode 100644 index 000000000..05030636a --- /dev/null +++ b/glue/viewers/image_new/qt/layer_style_editor.ui @@ -0,0 +1,251 @@ + + + Form + + + + 0 + 0 + 305 + 263 + + + + Form + + + + 5 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + + + + + 75 + true + + + + attribute + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + 100 + + + Qt::Horizontal + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + + + + + + + 75 + true + + + + limits + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Horizontal + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + 75 + true + + + + stretch + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + + + + + + + + 75 + true + + + + transparency + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 75 + true + + + + contrast + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 14 + + + + padding: 0px + + + ⇄ + + + + + + + + + + + 75 + true + + + + bias + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + Sync globally with data + + + + + + + + QColorBox + QLabel +
glue.utils.qt.colors
+
+ + QColormapCombo + QComboBox +
glue.utils.qt.colors
+
+
+ + +
diff --git a/glue/viewers/image_new/qt/layer_style_editor_subset.py b/glue/viewers/image_new/qt/layer_style_editor_subset.py new file mode 100644 index 000000000..44536d809 --- /dev/null +++ b/glue/viewers/image_new/qt/layer_style_editor_subset.py @@ -0,0 +1,20 @@ +import os + +from qtpy import QtWidgets + +from glue.external.echo.qt import autoconnect_callbacks_to_qt +from glue.utils.qt import load_ui + + +class ImageLayerSubsetStyleEditor(QtWidgets.QWidget): + + def __init__(self, layer, parent=None): + + super(ImageLayerSubsetStyleEditor, self).__init__(parent=parent) + + self.ui = load_ui('layer_style_editor_subset.ui', self, + directory=os.path.dirname(__file__)) + + connect_kwargs = {'alpha': dict(value_range=(0, 1))} + + autoconnect_callbacks_to_qt(layer.state, self.ui, connect_kwargs) diff --git a/glue/viewers/scatter/qt/layer_style_widget.ui b/glue/viewers/image_new/qt/layer_style_editor_subset.ui similarity index 50% rename from glue/viewers/scatter/qt/layer_style_widget.ui rename to glue/viewers/image_new/qt/layer_style_editor_subset.ui index 3d805cab4..3d3356f89 100644 --- a/glue/viewers/scatter/qt/layer_style_widget.ui +++ b/glue/viewers/image_new/qt/layer_style_editor_subset.ui @@ -6,48 +6,22 @@ 0 0 - 237 - 100 + 234 + 55
- - - 0 - 100 - - Form - 10 + 5 - 0 + 5 - - - - Color: - - - - - - - - - - 100 - - - Qt::Horizontal - - - - - + + 0 @@ -59,60 +33,49 @@ - - - - Symbol: + + + + 100 - - - - - - Alpha: + + Qt::Horizontal - - - - true + + + + + 75 + true + - - - 0 - 0 - + + transparency - - - 50 - 0 - + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - Size: + + + + + 75 + true + - - - - - - Qt::Horizontal + + color - - - 40 - 20 - + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - + - + Qt::Vertical diff --git a/glue/viewers/image_new/qt/options_widget.py b/glue/viewers/image_new/qt/options_widget.py new file mode 100644 index 000000000..4690fa085 --- /dev/null +++ b/glue/viewers/image_new/qt/options_widget.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import, division, print_function + +import os + +from qtpy import QtWidgets + +from glue.external.echo.qt import autoconnect_callbacks_to_qt +from glue.utils.qt import load_ui +from glue.core.qt.data_combo_helper import ComponentIDComboHelper +from glue.viewers.image_new.qt.slice_widget import MultiSliceWidgetHelper + +__all__ = ['ImageOptionsWidget'] + + +class ImageOptionsWidget(QtWidgets.QWidget): + + def __init__(self, viewer_state, session, parent=None): + + super(ImageOptionsWidget, self).__init__(parent=parent) + + self.ui = load_ui('options_widget.ui', self, + directory=os.path.dirname(__file__)) + + viewer_state.add_callback('reference_data', self._update_combo_data) + + self.ui.combodata_aspect.addItem("Square Pixels", userData='equal') + self.ui.combodata_aspect.addItem("Automatic", userData='auto') + self.ui.combodata_aspect.setCurrentIndex(0) + + self.ui.combotext_color_mode.addItem("Colormaps") + self.ui.combotext_color_mode.addItem("One color per layer") + + autoconnect_callbacks_to_qt(viewer_state, self.ui) + + self.x_att_helper = ComponentIDComboHelper(self.ui.combodata_x_att_world, + session.data_collection, + numeric=False, categorical=False, + visible=False, world_coord=True, default_index=-1) + + self.y_att_helper = ComponentIDComboHelper(self.ui.combodata_y_att_world, + session.data_collection, + numeric=False, categorical=False, + visible=False, world_coord=True, default_index=-2) + + self.viewer_state = viewer_state + + self.slice_helper = MultiSliceWidgetHelper(viewer_state=self.viewer_state, + widget=self.ui.slice_tab) + + def _update_combo_data(self, *args): + self.x_att_helper.set_multiple_data([self.viewer_state.reference_data]) + self.y_att_helper.set_multiple_data([self.viewer_state.reference_data]) diff --git a/glue/viewers/image_new/qt/options_widget.ui b/glue/viewers/image_new/qt/options_widget.ui new file mode 100644 index 000000000..5909aa51a --- /dev/null +++ b/glue/viewers/image_new/qt/options_widget.ui @@ -0,0 +1,226 @@ + + + Widget + + + + 0 + 0 + 273 + 259 + + + + 2D Image + + + + 5 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + 0 + + + + Limits + + + + 5 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + + 75 + true + + + + pixels: + + + + + + + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + + 75 + true + + + + y axis + + + + + + + + 14 + + + + padding: 0px + + + ⇄ + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 14 + + + + padding: 0px + + + ⇄ + + + + + + + + + + + + + min/max: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 75 + true + + + + mode: + + + + + + + min/max: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + + 75 + true + + + + x axis + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + Subset only on current slice + + + + + + + + Slicing + + + + + + + + + + + diff --git a/glue/viewers/image_new/qt/slice_widget.py b/glue/viewers/image_new/qt/slice_widget.py new file mode 100644 index 000000000..3d87b695b --- /dev/null +++ b/glue/viewers/image_new/qt/slice_widget.py @@ -0,0 +1,129 @@ +from __future__ import absolute_import, division, print_function + +import numpy as np + +from glue.core.coordinates import Coordinates +from glue.viewers.common.qt.data_slice_widget import SliceWidget +from glue.utils.decorators import avoid_circular + +__all__ = ['MultiSliceWidgetHelper'] + + +class MultiSliceWidgetHelper(object): + + def __init__(self, viewer_state=None, widget=None): + + self.widget = widget + self.viewer_state = viewer_state + + self.layout = widget.layout() + self.layout.setSpacing(4) + self.layout.setContentsMargins(0, 3, 0, 3) + + self.viewer_state.add_callback('x_att', self.sync_sliders_from_state) + self.viewer_state.add_callback('y_att', self.sync_sliders_from_state) + self.viewer_state.add_callback('slices', self.sync_sliders_from_state) + self.viewer_state.add_callback('reference_data', self.sync_sliders_from_state) + + self._sliders = [] + + self.sync_sliders_from_state() + + @property + def data(self): + return self.viewer_state.reference_data + + def _clear(self): + + for _ in range(self.layout.count()): + self.layout.takeAt(0) + + for s in self._sliders: + s.close() + + self._slices = [] + + @avoid_circular + def sync_state_from_sliders(self, *args): + slices = [] + for i, slider in enumerate(self._sliders): + slices.append(slider.state.slice_center) + self.viewer_state.slices = tuple(slices) + + @avoid_circular + def sync_sliders_from_state(self, *args): + + if self.data is None or self.viewer_state.x_att is None or self.viewer_state.y_att is None: + return + + if self.viewer_state.x_att is self.viewer_state.y_att: + return + + # TODO: figure out why there are no current circular calls (normally + # we should need to add @avoid_circular) + + # Update number of sliders if needed + if self.data.ndim != len(self._sliders): + self._clear() + for i in range(self.data.ndim): + + # TODO: For now we simply pass a single set of world coordinates, + # but we will need to generalize this in future. We deliberately + # check the type of data.coords here since we want to treat + # subclasses differently. + if type(self.data.coords) != Coordinates: + world = self.data.coords.world_axis(self.data, i) + world_unit = self.data.coords.world_axis_unit(i) + world_warning = len(self.data.coords.dependent_axes(i)) > 1 + else: + world = None + world_unit = None + world_warning = False + + slider = SliceWidget(self.data.get_world_component_id(i).label, + hi=self.data.shape[i] - 1, world=world, + world_unit=world_unit, world_warning=world_warning) + + self.slider_state = slider.state + self.slider_state.add_callback('slice_center', self.sync_state_from_sliders) + self._sliders.append(slider) + self.layout.addWidget(slider) + + # Disable sliders that correspond to visible axes and sync position + + for i, slider in enumerate(self._sliders): + if i == self.viewer_state.x_att.axis or i == self.viewer_state.y_att.axis: + slider.setEnabled(False) + else: + slider.setEnabled(True) + slider.state.slice_center = self.viewer_state.slices[i] + + +if __name__ == "__main__": + + from glue.core import Data + from glue.utils.qt import get_qapp + from glue.external.echo import CallbackProperty + from glue.core.state_objects import State + + app = get_qapp() + + class FakeViewerState(State): + x_att = CallbackProperty() + y_att = CallbackProperty() + reference_data = CallbackProperty() + slices = CallbackProperty() + + viewer_state = FakeViewerState() + + data = Data(x=np.random.random((3, 50, 20, 5, 3))) + + viewer_state.reference_data = data + viewer_state.x_att = data.get_pixel_component_id(0) + viewer_state.y_att = data.get_pixel_component_id(3) + viewer_state.slices = [0] * 5 + + widget = MultiSliceWidget(viewer_state) + widget.show() + + app.exec_() diff --git a/glue/viewers/image_new/qt/standalone_image_viewer.py b/glue/viewers/image_new/qt/standalone_image_viewer.py new file mode 100644 index 000000000..a5591c227 --- /dev/null +++ b/glue/viewers/image_new/qt/standalone_image_viewer.py @@ -0,0 +1,141 @@ +from __future__ import absolute_import, division, print_function + +from qtpy import QtCore, QtWidgets + +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.qt.widget import MplWidget + +from glue.external.modest_image import imshow + +# Import the mouse mode to make sure it gets registered +from glue.viewers.image_new.contrast_mouse_mode import ContrastBiasMode # noqa + +__all__ = ['StandaloneImageViewer'] + + +class StandaloneImageViewer(QtWidgets.QMainWindow): + """ + A simplified image viewer, without any brushing or linking, + but with the ability to adjust contrast and resample. + """ + window_closed = QtCore.Signal() + _toolbar_cls = MatplotlibViewerToolbar + tools = ['image:contrast_bias', 'image:colormap'] + + def __init__(self, image=None, wcs=None, parent=None, **kwargs): + """ + :param image: Image to display (2D numpy array) + :param parent: Parent widget (optional) + + :param kwargs: Extra keywords to pass to imshow + """ + super(StandaloneImageViewer, self).__init__(parent) + + self.central_widget = MplWidget() + self.setCentralWidget(self.central_widget) + self._setup_axes() + + self._im = None + + self.initialize_toolbar() + + if image is not None: + self.set_image(image=image, wcs=wcs, **kwargs) + + def _setup_axes(self): + from glue.viewers.common.viz_client import init_mpl + _, self._axes = init_mpl(self.central_widget.canvas.fig, axes=None, wcs=True) + self._axes.set_aspect('equal', adjustable='datalim') + + def set_image(self, image=None, wcs=None, **kwargs): + """ + Update the image shown in the widget + """ + if self._im is not None: + self._im.remove() + self._im = None + + kwargs.setdefault('origin', 'upper') + + if wcs is not None: + # In the following we force the color and linewith of the WCSAxes + # frame to be restored after calling reset_wcs. This can be removed + # once we support Astropy 1.3.1 or later. + color = self._axes.coords.frame.get_color() + linewidth = self._axes.coords.frame.get_linewidth() + self._axes.reset_wcs(wcs) + self._axes.coords.frame.set_color(color) + self._axes.coords.frame.set_linewidth(linewidth) + del color, linewidth + + self._im = imshow(self._axes, image, cmap='gray', **kwargs) + self._im_array = image + self._wcs = wcs + self._redraw() + + @property + def axes(self): + """ + The Matplolib axes object for this figure + """ + return self._axes + + def show(self): + super(StandaloneImageViewer, self).show() + self._redraw() + + def _redraw(self): + self.central_widget.canvas.draw() + + def set_cmap(self, cmap): + self._im.set_cmap(cmap) + self._redraw() + + def mdi_wrap(self): + """ + Embed this widget in a GlueMdiSubWindow + """ + from glue.app.qt.mdi_area import GlueMdiSubWindow + sub = GlueMdiSubWindow() + sub.setWidget(self) + self.destroyed.connect(sub.close) + self.window_closed.connect(sub.close) + sub.resize(self.size()) + self._mdi_wrapper = sub + + return sub + + def closeEvent(self, event): + self.window_closed.emit() + return super(StandaloneImageViewer, self).closeEvent(event) + + def _set_norm(self, mode): + """ Use the `ContrastMouseMode` to adjust the transfer function """ + clip_lo, clip_hi = mode.get_clip_percentile() + vmin, vmax = mode.get_vmin_vmax() + stretch = mode.stretch + self._norm.clip_lo = clip_lo + self._norm.clip_hi = clip_hi + self._norm.stretch = stretch + self._norm.bias = mode.bias + self._norm.contrast = mode.contrast + self._norm.vmin = vmin + self._norm.vmax = vmax + self._im.set_norm(self._norm) + self._redraw() + + def initialize_toolbar(self): + + # TODO: remove once Python 2 is no longer supported - see below for + # simpler code. + + from glue.config import viewer_tool + + self.toolbar = self._toolbar_cls(self) + + for tool_id in self.tools: + mode_cls = viewer_tool.members[tool_id] + mode = mode_cls(self) + self.toolbar.add_tool(mode) + + self.addToolBar(self.toolbar) diff --git a/glue/viewers/scatter/tests/__init__.py b/glue/viewers/image_new/qt/tests/__init__.py similarity index 100% rename from glue/viewers/scatter/tests/__init__.py rename to glue/viewers/image_new/qt/tests/__init__.py diff --git a/glue/viewers/image_new/qt/tests/baseline/test_resample_on_zoom.png b/glue/viewers/image_new/qt/tests/baseline/test_resample_on_zoom.png new file mode 100644 index 000000000..e1c507966 Binary files /dev/null and b/glue/viewers/image_new/qt/tests/baseline/test_resample_on_zoom.png differ diff --git a/glue/viewers/image_new/qt/tests/data/image_cube_v0.glu b/glue/viewers/image_new/qt/tests/data/image_cube_v0.glu new file mode 100644 index 000000000..4a5671bf4 --- /dev/null +++ b/glue/viewers/image_new/qt/tests/data/image_cube_v0.glu @@ -0,0 +1,410 @@ +{ + "Component": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGk4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDMsIDQsIDIsIDUpLCB9ICAgIAoAAAAAAAAAAAEAAAAAAAAAAgAAAAAAAAADAAAAAAAAAAQAAAAAAAAABQAAAAAAAAAGAAAAAAAAAAcAAAAAAAAACAAAAAAAAAAJAAAAAAAAAAoAAAAAAAAACwAAAAAAAAAMAAAAAAAAAA0AAAAAAAAADgAAAAAAAAAPAAAAAAAAABAAAAAAAAAAEQAAAAAAAAASAAAAAAAAABMAAAAAAAAAFAAAAAAAAAAVAAAAAAAAABYAAAAAAAAAFwAAAAAAAAAYAAAAAAAAABkAAAAAAAAAGgAAAAAAAAAbAAAAAAAAABwAAAAAAAAAHQAAAAAAAAAeAAAAAAAAAB8AAAAAAAAAIAAAAAAAAAAhAAAAAAAAACIAAAAAAAAAIwAAAAAAAAAkAAAAAAAAACUAAAAAAAAAJgAAAAAAAAAnAAAAAAAAACgAAAAAAAAAKQAAAAAAAAAqAAAAAAAAACsAAAAAAAAALAAAAAAAAAAtAAAAAAAAAC4AAAAAAAAALwAAAAAAAAAwAAAAAAAAADEAAAAAAAAAMgAAAAAAAAAzAAAAAAAAADQAAAAAAAAANQAAAAAAAAA2AAAAAAAAADcAAAAAAAAAOAAAAAAAAAA5AAAAAAAAADoAAAAAAAAAOwAAAAAAAAA8AAAAAAAAAD0AAAAAAAAAPgAAAAAAAAA/AAAAAAAAAEAAAAAAAAAAQQAAAAAAAABCAAAAAAAAAEMAAAAAAAAARAAAAAAAAABFAAAAAAAAAEYAAAAAAAAARwAAAAAAAABIAAAAAAAAAEkAAAAAAAAASgAAAAAAAABLAAAAAAAAAEwAAAAAAAAATQAAAAAAAABOAAAAAAAAAE8AAAAAAAAAUAAAAAAAAABRAAAAAAAAAFIAAAAAAAAAUwAAAAAAAABUAAAAAAAAAFUAAAAAAAAAVgAAAAAAAABXAAAAAAAAAFgAAAAAAAAAWQAAAAAAAABaAAAAAAAAAFsAAAAAAAAAXAAAAAAAAABdAAAAAAAAAF4AAAAAAAAAXwAAAAAAAABgAAAAAAAAAGEAAAAAAAAAYgAAAAAAAABjAAAAAAAAAGQAAAAAAAAAZQAAAAAAAABmAAAAAAAAAGcAAAAAAAAAaAAAAAAAAABpAAAAAAAAAGoAAAAAAAAAawAAAAAAAABsAAAAAAAAAG0AAAAAAAAAbgAAAAAAAABvAAAAAAAAAHAAAAAAAAAAcQAAAAAAAAByAAAAAAAAAHMAAAAAAAAAdAAAAAAAAAB1AAAAAAAAAHYAAAAAAAAAdwAAAAAAAAA=" + }, + "units": "" + }, + "CoordinateComponent": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": false + }, + "CoordinateComponentLink": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1", + "World 2", + "World 3" + ], + "index": 1, + "pix2world": false, + "to": [ + "Pixel Axis 1" + ] + }, + "CoordinateComponentLink_0": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0", + "Pixel Axis 1", + "Pixel Axis 2", + "Pixel Axis 3" + ], + "index": 2, + "pix2world": true, + "to": [ + "World 2" + ] + }, + "CoordinateComponentLink_1": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1", + "World 2", + "World 3" + ], + "index": 2, + "pix2world": false, + "to": [ + "Pixel Axis 2" + ] + }, + "CoordinateComponentLink_2": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0", + "Pixel Axis 1", + "Pixel Axis 2", + "Pixel Axis 3" + ], + "index": 3, + "pix2world": true, + "to": [ + "World 3" + ] + }, + "CoordinateComponentLink_3": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1", + "World 2", + "World 3" + ], + "index": 3, + "pix2world": false, + "to": [ + "Pixel Axis 3" + ] + }, + "CoordinateComponentLink_4": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0", + "Pixel Axis 1", + "Pixel Axis 2", + "Pixel Axis 3" + ], + "index": 0, + "pix2world": true, + "to": [ + "World 0" + ] + }, + "CoordinateComponentLink_5": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1", + "World 2", + "World 3" + ], + "index": 0, + "pix2world": false, + "to": [ + "Pixel Axis 0" + ] + }, + "CoordinateComponentLink_6": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0", + "Pixel Axis 1", + "Pixel Axis 2", + "Pixel Axis 3" + ], + "index": 1, + "pix2world": true, + "to": [ + "World 1" + ] + }, + "CoordinateComponent_0": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 1, + "world": false + }, + "CoordinateComponent_1": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 2, + "world": false + }, + "CoordinateComponent_2": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 3, + "world": false + }, + "CoordinateComponent_3": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": true + }, + "CoordinateComponent_4": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 1, + "world": true + }, + "CoordinateComponent_5": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 2, + "world": true + }, + "CoordinateComponent_6": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 3, + "world": true + }, + "Coordinates": { + "_type": "glue.core.coordinates.Coordinates" + }, + "DS9Normalize": { + "_type": "glue.viewers.image.ds9norm.DS9Normalize", + "bias": 0.5, + "clip_hi": 100, + "clip_lo": 0, + "contrast": 1.0, + "stretch": "linear", + "vmax": null, + "vmin": null + }, + "DataCollection": { + "_protocol": 3, + "_type": "glue.core.data_collection.DataCollection", + "cids": [ + "array_0", + "Pixel Axis 0", + "Pixel Axis 1", + "Pixel Axis 2", + "Pixel Axis 3", + "World 0", + "World 1", + "World 2", + "World 3" + ], + "components": [ + "Component", + "CoordinateComponent", + "CoordinateComponent_0", + "CoordinateComponent_1", + "CoordinateComponent_2", + "CoordinateComponent_3", + "CoordinateComponent_4", + "CoordinateComponent_5", + "CoordinateComponent_6" + ], + "data": [ + "array" + ], + "groups": [], + "links": [ + "CoordinateComponentLink", + "CoordinateComponentLink_0", + "CoordinateComponentLink_1", + "CoordinateComponentLink_2", + "CoordinateComponentLink_3", + "CoordinateComponentLink_4", + "CoordinateComponentLink_5", + "CoordinateComponentLink_6" + ], + "subset_group_count": 0 + }, + "ImageWidget": { + "_type": "glue.viewers.image.qt.viewer_widget.ImageWidget", + "layers": [ + { + "_type": "glue.viewers.image.layer_artist.ImageLayerArtist", + "layer": "array", + "norm": "DS9Normalize", + "visible": true, + "zorder": 1 + } + ], + "pos": [ + 0, + 0 + ], + "properties": { + "attribute": "array_0", + "batt": null, + "data": "array", + "gatt": null, + "ratt": null, + "rgb_mode": false, + "rgb_viz": [ + true, + true, + true + ], + "slice": [ + 2, + "y", + "x", + 1 + ] + }, + "session": "Session", + "size": [ + 731, + 529 + ] + }, + "Pixel Axis 0": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 0, + "hidden": true, + "label": "Pixel Axis 0" + }, + "Pixel Axis 1": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 1, + "hidden": true, + "label": "Pixel Axis 1" + }, + "Pixel Axis 2": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 2, + "hidden": true, + "label": "Pixel Axis 2" + }, + "Pixel Axis 3": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 3, + "hidden": true, + "label": "Pixel Axis 3" + }, + "Session": { + "_type": "glue.core.session.Session" + }, + "World 0": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 0" + }, + "World 1": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 1" + }, + "World 2": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 2" + }, + "World 3": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 3" + }, + "__main__": { + "_type": "glue.app.qt.application.GlueApplication", + "data": "DataCollection", + "plugins": [ + "glue.plugins.tools.pv_slicer", + "glue.viewers.histogram", + "glue.viewers.table", + "glue_vispy_viewers.volume", + "glue.plugins.exporters.plotly", + "glue.plugins.export_d3po", + "glue.viewers.image", + "glue.plugins.tools.spectrum_tool", + "glue_vispy_viewers.scatter", + "glue.viewers.scatter", + "glue.plugins.coordinate_helpers", + "glue.core.data_exporters" + ], + "session": "Session", + "tab_names": [ + "Tab 1" + ], + "viewers": [ + [ + "ImageWidget" + ] + ] + }, + "array": { + "_key_joins": [], + "_protocol": 5, + "_type": "glue.core.data.Data", + "components": [ + [ + "array_0", + "Component" + ], + [ + "Pixel Axis 0", + "CoordinateComponent" + ], + [ + "Pixel Axis 1", + "CoordinateComponent_0" + ], + [ + "Pixel Axis 2", + "CoordinateComponent_1" + ], + [ + "Pixel Axis 3", + "CoordinateComponent_2" + ], + [ + "World 0", + "CoordinateComponent_3" + ], + [ + "World 1", + "CoordinateComponent_4" + ], + [ + "World 2", + "CoordinateComponent_5" + ], + [ + "World 3", + "CoordinateComponent_6" + ] + ], + "coords": "Coordinates", + "label": "array", + "primary_owner": [ + "array_0", + "Pixel Axis 0", + "Pixel Axis 1", + "Pixel Axis 2", + "Pixel Axis 3", + "World 0", + "World 1", + "World 2", + "World 3" + ], + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.8, + "color": "0.35", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 3 + }, + "subsets": [], + "uuid": "5e2335ce-5613-4fb1-aa58-1cf342f7b755" + }, + "array_0": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "array" + } +} \ No newline at end of file diff --git a/glue/viewers/image_new/qt/tests/data/image_rgb_v0.glu b/glue/viewers/image_new/qt/tests/data/image_rgb_v0.glu new file mode 100644 index 000000000..167ce8a1e --- /dev/null +++ b/glue/viewers/image_new/qt/tests/data/image_rgb_v0.glu @@ -0,0 +1,337 @@ +{ + "Component": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGk4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDIsIDIpLCB9ICAgICAgICAgIAoAAAAAAAAAAAEAAAAAAAAAAgAAAAAAAAADAAAAAAAAAA==" + }, + "units": "" + }, + "Component_0": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGk4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDIsIDIpLCB9ICAgICAgICAgIAoIAAAAAAAAAAkAAAAAAAAACgAAAAAAAAALAAAAAAAAAA==" + }, + "units": "" + }, + "Component_1": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGk4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDIsIDIpLCB9ICAgICAgICAgIAoEAAAAAAAAAAUAAAAAAAAABgAAAAAAAAAHAAAAAAAAAA==" + }, + "units": "" + }, + "CoordinateComponent": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": false + }, + "CoordinateComponentLink": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]" + ], + "index": 0, + "pix2world": true, + "to": [ + "World 0" + ] + }, + "CoordinateComponentLink_0": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1" + ], + "index": 1, + "pix2world": false, + "to": [ + "Pixel Axis 1 [x]" + ] + }, + "CoordinateComponentLink_1": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1" + ], + "index": 0, + "pix2world": false, + "to": [ + "Pixel Axis 0 [y]" + ] + }, + "CoordinateComponentLink_2": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]" + ], + "index": 1, + "pix2world": true, + "to": [ + "World 1" + ] + }, + "CoordinateComponent_0": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 1, + "world": false + }, + "CoordinateComponent_1": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": true + }, + "CoordinateComponent_2": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 1, + "world": true + }, + "Coordinates": { + "_type": "glue.core.coordinates.Coordinates" + }, + "DS9Normalize": { + "_type": "glue.viewers.image.ds9norm.DS9Normalize", + "bias": 0.5, + "clip_hi": null, + "clip_lo": null, + "contrast": 1.0, + "stretch": "arcsinh", + "vmax": 3, + "vmin": 0 + }, + "DS9Normalize_0": { + "_type": "glue.viewers.image.ds9norm.DS9Normalize", + "bias": 0.5, + "clip_hi": 99, + "clip_lo": 1, + "contrast": 1.0, + "stretch": "linear", + "vmax": null, + "vmin": null + }, + "DS9Normalize_1": { + "_type": "glue.viewers.image.ds9norm.DS9Normalize", + "bias": 0.5, + "clip_hi": null, + "clip_lo": null, + "contrast": 1.0, + "stretch": "linear", + "vmax": 5.0, + "vmin": -5.0 + }, + "DataCollection": { + "_protocol": 3, + "_type": "glue.core.data_collection.DataCollection", + "cids": [ + "a", + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]", + "World 0", + "World 1", + "c", + "b" + ], + "components": [ + "Component", + "CoordinateComponent", + "CoordinateComponent_0", + "CoordinateComponent_1", + "CoordinateComponent_2", + "Component_0", + "Component_1" + ], + "data": [ + "rgbcube" + ], + "groups": [], + "links": [ + "CoordinateComponentLink", + "CoordinateComponentLink_0", + "CoordinateComponentLink_1", + "CoordinateComponentLink_2" + ], + "subset_group_count": 0 + }, + "ImageWidget": { + "_type": "glue.viewers.image.qt.viewer_widget.ImageWidget", + "layers": [ + { + "_type": "glue.viewers.image.layer_artist.RGBImageLayerArtist", + "b": "b", + "bnorm": "DS9Normalize", + "color_visible": [ + true, + false, + true + ], + "g": "c", + "gnorm": "DS9Normalize_0", + "layer": "rgbcube", + "norm": "DS9Normalize_0", + "r": "a", + "rnorm": "DS9Normalize_1", + "visible": true, + "zorder": 1 + } + ], + "pos": [ + 0, + 0 + ], + "properties": { + "attribute": "a", + "batt": "b", + "data": "rgbcube", + "gatt": "c", + "ratt": "a", + "rgb_mode": true, + "rgb_viz": [ + true, + false, + true + ], + "slice": [ + "y", + "x" + ] + }, + "session": "Session", + "size": [ + 600, + 400 + ] + }, + "Pixel Axis 0 [y]": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 0, + "hidden": true, + "label": "Pixel Axis 0 [y]" + }, + "Pixel Axis 1 [x]": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 1, + "hidden": true, + "label": "Pixel Axis 1 [x]" + }, + "Session": { + "_type": "glue.core.session.Session" + }, + "World 0": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 0" + }, + "World 1": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 1" + }, + "__main__": { + "_type": "glue.app.qt.application.GlueApplication", + "data": "DataCollection", + "plugins": [ + "glue.plugins.tools.pv_slicer", + "glue.viewers.histogram", + "glue.viewers.table", + "glue_vispy_viewers.volume", + "glue.plugins.exporters.plotly", + "glue.plugins.export_d3po", + "glue.viewers.image", + "glue.plugins.tools.spectrum_tool", + "glue_vispy_viewers.scatter", + "glue.viewers.scatter", + "glue.plugins.coordinate_helpers", + "glue.core.data_exporters" + ], + "session": "Session", + "tab_names": [ + "Tab 1" + ], + "viewers": [ + [ + "ImageWidget" + ] + ] + }, + "a": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "a" + }, + "b": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "b" + }, + "c": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "c" + }, + "rgbcube": { + "_key_joins": [], + "_protocol": 5, + "_type": "glue.core.data.Data", + "components": [ + [ + "a", + "Component" + ], + [ + "Pixel Axis 0 [y]", + "CoordinateComponent" + ], + [ + "Pixel Axis 1 [x]", + "CoordinateComponent_0" + ], + [ + "World 0", + "CoordinateComponent_1" + ], + [ + "World 1", + "CoordinateComponent_2" + ], + [ + "c", + "Component_0" + ], + [ + "b", + "Component_1" + ] + ], + "coords": "Coordinates", + "label": "rgbcube", + "primary_owner": [ + "a", + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]", + "World 0", + "World 1", + "c", + "b" + ], + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.8, + "color": "0.35", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 3 + }, + "subsets": [], + "uuid": "d2fdde54-ab42-4370-9670-4b9906da51f2" + } +} \ No newline at end of file diff --git a/glue/viewers/image_new/qt/tests/data/image_v0.glu b/glue/viewers/image_new/qt/tests/data/image_v0.glu new file mode 100644 index 000000000..7c1716413 --- /dev/null +++ b/glue/viewers/image_new/qt/tests/data/image_v0.glu @@ -0,0 +1,724 @@ +{ + "Component": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGk4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDIsIDIpLCB9ICAgICAgICAgIAoBAAAAAAAAAAIAAAAAAAAAAwAAAAAAAAAEAAAAAAAAAA==" + }, + "units": "" + }, + "ComponentLink": { + "_type": "glue.core.component_link.ComponentLink", + "frm": [ + "World 0" + ], + "hidden": false, + "inverse": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + }, + "to": [ + "a" + ], + "using": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + } + }, + "ComponentLink_0": { + "_type": "glue.core.component_link.ComponentLink", + "frm": [ + "World 1" + ], + "hidden": false, + "inverse": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + }, + "to": [ + "b" + ], + "using": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + } + }, + "ComponentLink_1": { + "_type": "glue.core.component_link.ComponentLink", + "frm": [ + "b" + ], + "hidden": false, + "inverse": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + }, + "to": [ + "World 1" + ], + "using": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + } + }, + "ComponentLink_2": { + "_type": "glue.core.component_link.ComponentLink", + "frm": [ + "a" + ], + "hidden": false, + "inverse": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + }, + "to": [ + "World 0" + ], + "using": { + "_type": "types.FunctionType", + "function": "glue.core.component_link.identity" + } + }, + "Component_0": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGY4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDIsKSwgfSAgICAgICAgICAgIAoAAAAAAAD4PwAAAAAAAABA" + }, + "units": "" + }, + "Component_1": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGY4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDIsKSwgfSAgICAgICAgICAgIAoAAAAAAADgPwAAAAAAAABA" + }, + "units": "" + }, + "CoordinateComponent": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": false + }, + "CoordinateComponentLink": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]" + ], + "index": 0, + "pix2world": true, + "to": [ + "World 0" + ] + }, + "CoordinateComponentLink_0": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates_0", + "frm": [ + "World 0_0" + ], + "index": 0, + "pix2world": false, + "to": [ + "Pixel Axis 0 [x]" + ] + }, + "CoordinateComponentLink_1": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]" + ], + "index": 1, + "pix2world": true, + "to": [ + "World 1" + ] + }, + "CoordinateComponentLink_2": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1" + ], + "index": 1, + "pix2world": false, + "to": [ + "Pixel Axis 1 [x]" + ] + }, + "CoordinateComponentLink_3": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0", + "World 1" + ], + "index": 0, + "pix2world": false, + "to": [ + "Pixel Axis 0 [y]" + ] + }, + "CoordinateComponentLink_4": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates_0", + "frm": [ + "Pixel Axis 0 [x]" + ], + "index": 0, + "pix2world": true, + "to": [ + "World 0_0" + ] + }, + "CoordinateComponent_0": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 1, + "world": false + }, + "CoordinateComponent_1": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": true + }, + "CoordinateComponent_2": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 1, + "world": true + }, + "CoordinateComponent_3": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": false + }, + "CoordinateComponent_4": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": true + }, + "Coordinates": { + "_type": "glue.core.coordinates.Coordinates" + }, + "Coordinates_0": { + "_type": "glue.core.coordinates.Coordinates" + }, + "DS9Normalize": { + "_type": "glue.viewers.image.ds9norm.DS9Normalize", + "bias": 0.5, + "clip_hi": 99.0, + "clip_lo": 1.0, + "contrast": 1.0, + "stretch": "sqrt", + "vmax": null, + "vmin": null + }, + "DS9Normalize_0": { + "_type": "glue.viewers.image.ds9norm.DS9Normalize", + "bias": 0.5, + "clip_hi": null, + "clip_lo": null, + "contrast": 1.0, + "stretch": "linear", + "vmax": 2.0, + "vmin": -2.0 + }, + "DS9Normalize_1": { + "_type": "glue.viewers.image.ds9norm.DS9Normalize", + "bias": 0.5, + "clip_hi": null, + "clip_lo": null, + "contrast": 1.0, + "stretch": "arcsinh", + "vmax": 4, + "vmin": 1 + }, + "DataCollection": { + "_protocol": 3, + "_type": "glue.core.data_collection.DataCollection", + "cids": [ + "data1_0", + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]", + "World 0", + "World 1", + "a", + "b", + "a", + "Pixel Axis 0 [x]", + "World 0_0", + "b", + "Pixel Axis 1 [x]", + "World 1", + "Pixel Axis 0 [y]", + "World 0" + ], + "components": [ + "Component", + "CoordinateComponent", + "CoordinateComponent_0", + "CoordinateComponent_1", + "CoordinateComponent_2", + "DerivedComponent", + "DerivedComponent_0", + "Component_0", + "CoordinateComponent_3", + "CoordinateComponent_4", + "Component_1", + "DerivedComponent_1", + "DerivedComponent_2", + "DerivedComponent_3", + "DerivedComponent_4" + ], + "data": [ + "data1", + "data2" + ], + "groups": [ + "Subset 1" + ], + "links": [ + "CoordinateComponentLink", + "CoordinateComponentLink_0", + "CoordinateComponentLink_1", + "ComponentLink", + "CoordinateComponentLink_2", + "CoordinateComponentLink_3", + "ComponentLink_0", + "CoordinateComponentLink_4" + ], + "subset_group_count": 1 + }, + "DerivedComponent": { + "_type": "glue.core.component.DerivedComponent", + "link": "ComponentLink" + }, + "DerivedComponent_0": { + "_type": "glue.core.component.DerivedComponent", + "link": "ComponentLink_0" + }, + "DerivedComponent_1": { + "_type": "glue.core.component.DerivedComponent", + "link": "CoordinateComponentLink_2" + }, + "DerivedComponent_2": { + "_type": "glue.core.component.DerivedComponent", + "link": "ComponentLink_1" + }, + "DerivedComponent_3": { + "_type": "glue.core.component.DerivedComponent", + "link": "CoordinateComponentLink_3" + }, + "DerivedComponent_4": { + "_type": "glue.core.component.DerivedComponent", + "link": "ComponentLink_2" + }, + "ImageWidget": { + "_type": "glue.viewers.image.qt.viewer_widget.ImageWidget", + "layers": [ + { + "_type": "glue.viewers.image.layer_artist.ImageLayerArtist", + "layer": "data1", + "norm": "DS9Normalize", + "visible": true, + "zorder": 1 + }, + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "data2", + "visible": true, + "xatt": "Pixel Axis 1 [x]", + "yatt": "Pixel Axis 0 [y]", + "zorder": 2 + }, + { + "_type": "glue.viewers.image.layer_artist.SubsetImageLayerArtist", + "layer": "Subset 1_0", + "visible": false, + "zorder": 3 + } + ], + "pos": [ + -1, + 1 + ], + "properties": { + "attribute": "data1_0", + "batt": null, + "data": "data1", + "gatt": null, + "ratt": null, + "rgb_mode": false, + "rgb_viz": [ + true, + true, + true + ], + "slice": [ + "y", + "x" + ] + }, + "session": "Session", + "size": [ + 568, + 490 + ] + }, + "ImageWidget_0": { + "_type": "glue.viewers.image.qt.viewer_widget.ImageWidget", + "layers": [ + { + "_type": "glue.viewers.image.layer_artist.ImageLayerArtist", + "layer": "data1", + "norm": "DS9Normalize_1", + "visible": true, + "zorder": 1 + }, + { + "_type": "glue.viewers.image.layer_artist.SubsetImageLayerArtist", + "layer": "Subset 1_0", + "visible": true, + "zorder": 2 + } + ], + "pos": [ + 568, + 1 + ], + "properties": { + "attribute": "data1_0", + "batt": null, + "data": "data1", + "gatt": null, + "ratt": null, + "rgb_mode": false, + "rgb_viz": [ + true, + true, + true + ], + "slice": [ + "y", + "x" + ] + }, + "session": "Session", + "size": [ + 606, + 489 + ] + }, + "ImageWidget_1": { + "_type": "glue.viewers.image.qt.viewer_widget.ImageWidget", + "layers": [ + { + "_type": "glue.viewers.image.layer_artist.ImageLayerArtist", + "layer": "data1", + "norm": "DS9Normalize_0", + "visible": true, + "zorder": 1 + }, + { + "_type": "glue.viewers.image.layer_artist.SubsetImageLayerArtist", + "layer": "Subset 1_0", + "visible": true, + "zorder": 2 + } + ], + "pos": [ + 568, + 487 + ], + "properties": { + "attribute": "data1_0", + "batt": null, + "data": "data1", + "gatt": null, + "ratt": null, + "rgb_mode": false, + "rgb_viz": [ + true, + true, + true + ], + "slice": [ + "y", + "x" + ] + }, + "session": "Session", + "size": [ + 600, + 400 + ] + }, + "Pixel Axis 0 [x]": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 0, + "hidden": true, + "label": "Pixel Axis 0 [x]" + }, + "Pixel Axis 0 [y]": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 0, + "hidden": true, + "label": "Pixel Axis 0 [y]" + }, + "Pixel Axis 1 [x]": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 1, + "hidden": true, + "label": "Pixel Axis 1 [x]" + }, + "PolygonalROI": { + "_type": "glue.core.roi.PolygonalROI", + "vx": [ + 0.7380952380952381, + 1.847619047619048, + 1.847619047619048, + 0.7380952380952381, + 0.7380952380952381 + ], + "vy": [ + 0.5866666666666664, + 0.5866666666666664, + 1.7666666666666662, + 1.7666666666666662, + 0.5866666666666664 + ] + }, + "RoiSubsetState": { + "_type": "glue.core.subset.RoiSubsetState", + "roi": "PolygonalROI", + "xatt": "Pixel Axis 1 [x]", + "yatt": "Pixel Axis 0 [y]" + }, + "Session": { + "_type": "glue.core.session.Session" + }, + "Subset 1": { + "_type": "glue.core.subset_group.SubsetGroup", + "label": "Subset 1", + "state": "RoiSubsetState", + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.5, + "color": "#e31a1c", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 7 + }, + "subsets": [ + "Subset 1_0", + "Subset 1_1" + ] + }, + "Subset 1_0": { + "_type": "glue.core.subset_group.GroupedSubset", + "group": "Subset 1", + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.5, + "color": "#e31a1c", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 7 + } + }, + "Subset 1_1": { + "_type": "glue.core.subset_group.GroupedSubset", + "group": "Subset 1", + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.5, + "color": "#e31a1c", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 7 + } + }, + "World 0": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 0" + }, + "World 0_0": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 0" + }, + "World 1": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 1" + }, + "__main__": { + "_type": "glue.app.qt.application.GlueApplication", + "data": "DataCollection", + "plugins": [ + "glue.plugins.tools.pv_slicer", + "glue.viewers.histogram", + "glue.viewers.table", + "glue_vispy_viewers.volume", + "glue.plugins.exporters.plotly", + "glue.plugins.export_d3po", + "glue.viewers.image", + "glue.plugins.tools.spectrum_tool", + "glue_vispy_viewers.scatter", + "glue.viewers.scatter", + "glue.plugins.coordinate_helpers", + "glue.core.data_exporters" + ], + "session": "Session", + "tab_names": [ + "Tab 1" + ], + "viewers": [ + [ + "ImageWidget", + "ImageWidget_0", + "ImageWidget_1" + ] + ] + }, + "a": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "a" + }, + "b": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "b" + }, + "data1": { + "_key_joins": [], + "_protocol": 5, + "_type": "glue.core.data.Data", + "components": [ + [ + "data1_0", + "Component" + ], + [ + "Pixel Axis 0 [y]", + "CoordinateComponent" + ], + [ + "Pixel Axis 1 [x]", + "CoordinateComponent_0" + ], + [ + "World 0", + "CoordinateComponent_1" + ], + [ + "World 1", + "CoordinateComponent_2" + ], + [ + "a", + "DerivedComponent" + ], + [ + "b", + "DerivedComponent_0" + ] + ], + "coords": "Coordinates", + "label": "data1", + "primary_owner": [ + "data1_0", + "Pixel Axis 0 [y]", + "Pixel Axis 1 [x]", + "World 0", + "World 1" + ], + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.8, + "color": "0.35", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 3 + }, + "subsets": [ + "Subset 1_0" + ], + "uuid": "daad10a3-6ad4-4dd4-841a-ca2071dbe467" + }, + "data1_0": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "data1" + }, + "data2": { + "_key_joins": [], + "_protocol": 5, + "_type": "glue.core.data.Data", + "components": [ + [ + "a", + "Component_0" + ], + [ + "Pixel Axis 0 [x]", + "CoordinateComponent_3" + ], + [ + "World 0_0", + "CoordinateComponent_4" + ], + [ + "b", + "Component_1" + ], + [ + "Pixel Axis 1 [x]", + "DerivedComponent_1" + ], + [ + "World 1", + "DerivedComponent_2" + ], + [ + "Pixel Axis 0 [y]", + "DerivedComponent_3" + ], + [ + "World 0", + "DerivedComponent_4" + ] + ], + "coords": "Coordinates_0", + "label": "data2", + "primary_owner": [ + "a", + "Pixel Axis 0 [x]", + "World 0_0", + "b" + ], + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 1.0, + "color": "#e60010", + "linestyle": "solid", + "linewidth": 1, + "marker": "^", + "markersize": 3 + }, + "subsets": [ + "Subset 1_1" + ], + "uuid": "e0521c8f-3f02-41b8-8746-a71aa9ae5f49" + } +} diff --git a/glue/viewers/image/qt/tests/test_regression.py b/glue/viewers/image_new/qt/tests/test_regression.py similarity index 68% rename from glue/viewers/image/qt/tests/test_regression.py rename to glue/viewers/image_new/qt/tests/test_regression.py index e6c4b6408..82022c045 100644 --- a/glue/viewers/image/qt/tests/test_regression.py +++ b/glue/viewers/image_new/qt/tests/test_regression.py @@ -4,7 +4,7 @@ import numpy as np from glue.core import Data -from glue.viewers.image.qt import ImageWidget +from glue.viewers.image_new.qt import ImageViewer from glue.core.tests.util import simple_session from glue.tests.helpers import requires_matplotlib_ge_14 @@ -25,14 +25,16 @@ def test_resample_on_zoom(): data = Data(x=np.random.random((2048, 2048)), label='image') session.data_collection.append(data) - image = ImageWidget(session=session) + image = ImageViewer(session=session) image.add_data(data) image.show() + device_ratio = image.axes.figure.canvas.devicePixelRatio() + image.axes.figure.canvas.key_press_event('o') - image.axes.figure.canvas.button_press_event(200, 200, 1) - image.axes.figure.canvas.motion_notify_event(400, 210) - image.axes.figure.canvas.button_release_event(400, 210, 1) + image.axes.figure.canvas.button_press_event(200 * device_ratio, 200 * device_ratio, 1) + image.axes.figure.canvas.motion_notify_event(400 * device_ratio, 210 * device_ratio) + image.axes.figure.canvas.button_release_event(400 * device_ratio, 210 * device_ratio, 1) return image.axes.figure diff --git a/glue/viewers/image_new/qt/tests/test_viewer_widget.py b/glue/viewers/image_new/qt/tests/test_viewer_widget.py new file mode 100644 index 000000000..099b49345 --- /dev/null +++ b/glue/viewers/image_new/qt/tests/test_viewer_widget.py @@ -0,0 +1,340 @@ +# pylint: disable=I0011,W0613,W0201,W0212,E1101,E1103 + +from __future__ import absolute_import, division, print_function + +import os + +import pytest + +import numpy as np +from numpy.testing import assert_allclose + +from glue.core import Data, Coordinates +from glue.core.roi import RectangularROI +from glue.core.subset import RoiSubsetState, AndState +from glue import core +from glue.core.component_id import ComponentID +from glue.core.tests.util import simple_session +from glue.utils.qt import combo_as_string +from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer +from glue.core.state import GlueUnSerializer +from glue.viewers.image_new.state import ImageLayerState, ImageSubsetLayerState +from glue.viewers.scatter.state import ScatterLayerState + +from ..data_viewer import ImageViewer + +DATA = os.path.join(os.path.dirname(__file__), 'data') + + +class TestImageCommon(BaseTestMatplotlibDataViewer): + + def init_data(self): + return Data(label='d1', x=np.arange(12).reshape((3, 4)), y=np.ones((3, 4))) + + viewer_cls = ImageViewer + + @pytest.mark.skip() + def test_double_add_ignored(self): + pass + + +class MyCoords(Coordinates): + def axis_label(self, i): + return ['Banana', 'Apple'][i] + + +class TestImageViewer(object): + + def setup_method(self, method): + + self.coords = MyCoords() + self.image1 = Data(label='image1', x=[[1, 2], [3, 4]], y=[[4, 5], [2, 3]]) + self.image2 = Data(label='image2', a=[[3, 3], [2, 2]], b=[[4, 4], [3, 2]], coords=self.coords) + self.catalog = Data(label='catalog', c=[1, 3, 2], d=[4, 3, 3]) + + self.session = simple_session() + self.hub = self.session.hub + + self.data_collection = self.session.data_collection + self.data_collection.append(self.image1) + self.data_collection.append(self.image2) + self.data_collection.append(self.catalog) + + self.viewer = ImageViewer(self.session) + + self.data_collection.register_to_hub(self.hub) + self.viewer.register_to_hub(self.hub) + + self.options_widget = self.viewer.options_widget() + + + def teardown_method(self, method): + self.viewer.close() + + def test_basic(self): + + # Check defaults when we add data + + self.viewer.add_data(self.image1) + + assert combo_as_string(self.options_widget.ui.combodata_x_att_world) == 'World 0:World 1' + assert combo_as_string(self.options_widget.ui.combodata_x_att_world) == 'World 0:World 1' + + assert self.viewer.axes.get_xlabel() == 'World 1' + assert self.viewer.state.x_att_world is self.image1.id['World 1'] + assert self.viewer.state.x_att is self.image1.pixel_component_ids[1] + # TODO: make sure limits are deterministic then update this + # assert self.viewer.state.x_min == -0.5 + # assert self.viewer.state.x_max == +1.5 + + assert self.viewer.axes.get_ylabel() == 'World 0' + assert self.viewer.state.y_att_world is self.image1.id['World 0'] + assert self.viewer.state.y_att is self.image1.pixel_component_ids[0] + # TODO: make sure limits are deterministic then update this + # assert self.viewer.state.y_min == -0.5 + # assert self.viewer.state.y_max == +1.5 + + assert not self.viewer.state.x_log + assert not self.viewer.state.y_log + + assert len(self.viewer.state.layers) == 1 + + def test_custom_coords(self): + + # Check defaults when we add data with coordinates + + self.viewer.add_data(self.image2) + + assert combo_as_string(self.options_widget.ui.combodata_x_att_world) == 'Banana:Apple' + assert combo_as_string(self.options_widget.ui.combodata_x_att_world) == 'Banana:Apple' + + assert self.viewer.axes.get_xlabel() == 'Apple' + assert self.viewer.state.x_att_world is self.image2.id['Apple'] + assert self.viewer.state.x_att is self.image2.pixel_component_ids[1] + assert self.viewer.axes.get_ylabel() == 'Banana' + assert self.viewer.state.y_att_world is self.image2.id['Banana'] + assert self.viewer.state.y_att is self.image2.pixel_component_ids[0] + + def test_flip(self): + + self.viewer.add_data(self.image1) + + x_min_start = self.viewer.state.x_min + x_max_start = self.viewer.state.x_max + + self.options_widget.button_flip_x.click() + + assert self.viewer.state.x_min == x_max_start + assert self.viewer.state.x_max == x_min_start + + y_min_start = self.viewer.state.y_min + y_max_start = self.viewer.state.y_max + + self.options_widget.button_flip_y.click() + + assert self.viewer.state.y_min == y_max_start + assert self.viewer.state.y_max == y_min_start + + def test_combo_updates_with_component_add(self): + self.viewer.add_data(self.image1) + self.image1.add_component([[9, 9], [8, 8]], 'z') + assert self.viewer.state.x_att_world is self.image1.id['World 1'] + assert self.viewer.state.y_att_world is self.image1.id['World 0'] + # TODO: there should be an easier way to do this + layer_style_editor = self.viewer._view.layout_style_widgets[self.viewer.layers[0]] + assert combo_as_string(layer_style_editor.ui.combodata_attribute) == 'x:y:z' + + def test_apply_roi(self): + + self.viewer.add_data(self.image1) + + roi = RectangularROI(0.4, 1.6, -0.6, 0.6) + + assert len(self.viewer.layers) == 1 + + self.viewer.apply_roi(roi) + + assert len(self.viewer.layers) == 2 + assert len(self.image1.subsets) == 1 + + assert_allclose(self.image1.subsets[0].to_mask(), [[0, 1], [0, 0]]) + + state = self.image1.subsets[0].subset_state + assert isinstance(state, RoiSubsetState) + + def test_identical(self): + + # Check what happens if we set both attributes to the same coordinates + + self.viewer.add_data(self.image2) + + assert self.viewer.state.x_att_world is self.image2.id['Apple'] + assert self.viewer.state.y_att_world is self.image2.id['Banana'] + + self.viewer.state.y_att_world = self.image2.id['Apple'] + + assert self.viewer.state.x_att_world is self.image2.id['Banana'] + assert self.viewer.state.y_att_world is self.image2.id['Apple'] + + self.viewer.state.x_att_world = self.image2.id['Apple'] + + assert self.viewer.state.x_att_world is self.image2.id['Apple'] + assert self.viewer.state.y_att_world is self.image2.id['Banana'] + + +class TestSessions(object): + + @pytest.mark.parametrize('protocol', [0]) + def test_session_back_compat(self, protocol): + + filename = os.path.join(DATA, 'image_v{0}.glu'.format(protocol)) + + with open(filename, 'r') as f: + session = f.read() + + state = GlueUnSerializer.loads(session) + + ga = state.object('__main__') + + dc = ga.session.data_collection + + assert len(dc) == 2 + + assert dc[0].label == 'data1' + assert dc[1].label == 'data2' + + delta = 0.3419913419913423 + + viewer1 = ga.viewers[0][0] + + assert len(viewer1.state.layers) == 3 + + assert viewer1.state.x_att_world is dc[0].id['World 1'] + assert viewer1.state.y_att_world is dc[0].id['World 0'] + + assert_allclose(viewer1.state.x_min, -delta) + assert_allclose(viewer1.state.x_max, 2 + delta) + assert_allclose(viewer1.state.y_min, 0) + assert_allclose(viewer1.state.y_max, 2) + + layer_state = viewer1.state.layers[0] + assert isinstance(layer_state, ImageLayerState) + assert layer_state.visible + assert layer_state.bias == 0.5 + assert layer_state.contrast == 1.0 + assert layer_state.stretch == 'sqrt' + assert layer_state.percentile == 99 + + layer_state = viewer1.state.layers[1] + assert isinstance(layer_state, ScatterLayerState) + assert layer_state.visible + + layer_state = viewer1.state.layers[2] + assert isinstance(layer_state, ImageSubsetLayerState) + assert not layer_state.visible + + viewer2 = ga.viewers[0][1] + + assert len(viewer2.state.layers) == 2 + + assert viewer2.state.x_att_world is dc[0].id['World 1'] + assert viewer2.state.y_att_world is dc[0].id['World 0'] + + assert_allclose(viewer2.state.x_min, -delta) + assert_allclose(viewer2.state.x_max, 2 + delta) + assert_allclose(viewer2.state.y_min, 0) + assert_allclose(viewer2.state.y_max, 2) + + layer_state = viewer2.state.layers[0] + assert layer_state.visible + assert layer_state.stretch == 'arcsinh' + assert layer_state.v_min == 1 + assert layer_state.v_max == 4 + + layer_state = viewer2.state.layers[1] + assert layer_state.visible + + viewer3 = ga.viewers[0][2] + + assert len(viewer3.state.layers) == 2 + + assert viewer3.state.x_att_world is dc[0].id['World 1'] + assert viewer3.state.y_att_world is dc[0].id['World 0'] + + assert_allclose(viewer3.state.x_min, -delta) + assert_allclose(viewer3.state.x_max, 2 + delta) + assert_allclose(viewer3.state.y_min, 0) + assert_allclose(viewer3.state.y_max, 2) + + layer_state = viewer3.state.layers[0] + assert layer_state.visible + assert layer_state.stretch == 'linear' + assert layer_state.v_min == -2 + assert layer_state.v_max == 2 + + layer_state = viewer3.state.layers[1] + assert layer_state.visible + + @pytest.mark.parametrize('protocol', [0]) + def test_session_cube_back_compat(self, protocol): + + filename = os.path.join(DATA, 'image_cube_v{0}.glu'.format(protocol)) + + with open(filename, 'r') as f: + session = f.read() + + state = GlueUnSerializer.loads(session) + + ga = state.object('__main__') + + dc = ga.session.data_collection + + assert len(dc) == 1 + + assert dc[0].label == 'array' + + viewer1 = ga.viewers[0][0] + + assert len(viewer1.state.layers) == 1 + + assert viewer1.state.x_att_world is dc[0].id['World 2'] + assert viewer1.state.y_att_world is dc[0].id['World 1'] + assert viewer1.state.slices == [2, 0, 0, 1] + + @pytest.mark.parametrize('protocol', [0]) + def test_session_rgb_back_compat(self, protocol): + + filename = os.path.join(DATA, 'image_rgb_v{0}.glu'.format(protocol)) + + with open(filename, 'r') as f: + session = f.read() + + state = GlueUnSerializer.loads(session) + + ga = state.object('__main__') + + dc = ga.session.data_collection + + assert len(dc) == 1 + + assert dc[0].label == 'rgbcube' + + viewer1 = ga.viewers[0][0] + + assert len(viewer1.state.layers) == 3 + assert viewer1.state.color_mode == 'One color per layer' + + layer_state = viewer1.state.layers[0] + assert layer_state.visible + assert layer_state.attribute.label == 'a' + assert layer_state.color == 'r' + + layer_state = viewer1.state.layers[1] + assert not layer_state.visible + assert layer_state.attribute.label == 'c' + assert layer_state.color == 'g' + + layer_state = viewer1.state.layers[2] + assert layer_state.visible + assert layer_state.attribute.label == 'b' + assert layer_state.color == 'b' diff --git a/glue/viewers/image_new/state.py b/glue/viewers/image_new/state.py new file mode 100644 index 000000000..49229ff30 --- /dev/null +++ b/glue/viewers/image_new/state.py @@ -0,0 +1,190 @@ +from __future__ import absolute_import, division, print_function + +from glue.core import Data +from glue.config import colormaps +from glue.viewers.matplotlib.state import (MatplotlibDataViewerState, + MatplotlibLayerState, + DeferredDrawCallbackProperty) +from glue.core.state_objects import StateAttributeLimitsHelper +from glue.utils import defer_draw + +__all__ = ['ImageViewerState', 'ImageLayerState'] + + +class ImageViewerState(MatplotlibDataViewerState): + + x_att = DeferredDrawCallbackProperty() + y_att = DeferredDrawCallbackProperty() + x_att_world = DeferredDrawCallbackProperty() + y_att_world = DeferredDrawCallbackProperty() + aspect = DeferredDrawCallbackProperty('equal') + reference_data = DeferredDrawCallbackProperty() + slices = DeferredDrawCallbackProperty() + color_mode = DeferredDrawCallbackProperty('Colormaps') + single_slice_subset = DeferredDrawCallbackProperty(False) + + def __init__(self, **kwargs): + + super(ImageViewerState, self).__init__(**kwargs) + + self.add_callback('x_att_world', self._update_x_att, priority=500) + self.add_callback('y_att_world', self._update_y_att, priority=500) + + self.limits_cache = {} + + self.x_att_helper = StateAttributeLimitsHelper(self, attribute='x_att', + lower='x_min', upper='x_max', + limits_cache=self.limits_cache) + + self.y_att_helper = StateAttributeLimitsHelper(self, attribute='y_att', + lower='y_min', upper='y_max', + limits_cache=self.limits_cache) + + self.add_callback('reference_data', self.set_default_slices) + self.add_callback('layers', self.set_reference_data) + + self.add_callback('x_att_world', self._on_xatt_world_change, priority=1000) + self.add_callback('y_att_world', self._on_yatt_world_change, priority=1000) + + def update_priority(self, name): + if name == 'layers': + return 3 + elif name == 'reference_data': + return 2 + elif name.endswith(('_min', '_max')): + return 0 + else: + return 1 + + @defer_draw + def _update_x_att(self, *args): + index = self.reference_data.world_component_ids.index(self.x_att_world) + self.x_att = self.reference_data.pixel_component_ids[index] + + @defer_draw + def _update_y_att(self, *args): + index = self.reference_data.world_component_ids.index(self.y_att_world) + self.y_att = self.reference_data.pixel_component_ids[index] + + @defer_draw + def _on_xatt_world_change(self, *args): + if self.x_att_world == self.y_att_world: + world_ids = self.reference_data.world_component_ids + if self.x_att_world == world_ids[-1]: + self.y_att_world = world_ids[-2] + else: + self.y_att_world = world_ids[-1] + + @defer_draw + def _on_yatt_world_change(self, *args): + if self.y_att_world == self.x_att_world: + world_ids = self.reference_data.world_component_ids + if self.y_att_world == world_ids[-1]: + self.x_att_world = world_ids[-2] + else: + self.x_att_world = world_ids[-1] + + def set_reference_data(self, *args): + # TODO: make sure this doesn't get called for changes *in* the layers + # for list callbacks maybe just have an event for length change in list + if self.reference_data is None: + for layer in self.layers: + if isinstance(layer.layer, Data): + self.reference_data = layer.layer + return + + def set_default_slices(self, *args): + # Need to make sure this gets called immediately when reference_data is changed + if self.reference_data is None: + self.slices = () + else: + self.slices = (0,) * self.reference_data.ndim + + @property + def numpy_slice_and_transpose(self): + if self.reference_data is None: + return None + slices = [] + for i in range(self.reference_data.ndim): + if i == self.x_att.axis or i == self.y_att.axis: + slices.append(slice(None)) + else: + slices.append(self.slices[i]) + transpose = self.y_att.axis > self.x_att.axis + return slices, transpose + + @property + def wcsaxes_slice(self): + if self.reference_data is None: + return None + slices = [] + for i in range(self.reference_data.ndim): + if i == self.x_att.axis: + slices.append('x') + elif i == self.y_att.axis: + slices.append('y') + else: + slices.append(self.slices[i]) + return slices[::-1] + + def flip_x(self): + self.x_att_helper.flip_limits() + + def flip_y(self): + self.y_att_helper.flip_limits() + + +class ImageLayerState(MatplotlibLayerState): + + attribute = DeferredDrawCallbackProperty() + v_min = DeferredDrawCallbackProperty() + v_max = DeferredDrawCallbackProperty() + percentile = DeferredDrawCallbackProperty(100) + contrast = DeferredDrawCallbackProperty(1) + bias = DeferredDrawCallbackProperty(0.5) + cmap = DeferredDrawCallbackProperty() + stretch = DeferredDrawCallbackProperty('linear') + global_sync = DeferredDrawCallbackProperty(True) + + def __init__(self, **kwargs): + super(ImageLayerState, self).__init__(**kwargs) + self.attribute_helper = StateAttributeLimitsHelper(self, attribute='attribute', + percentile='percentile', + lower='v_min', upper='v_max') + if self.cmap is None: + self.cmap = colormaps.members[0][1] + + self.add_callback('global_sync', self._update_syncing) + self.add_callback('layer', self._update_attribute) + + self._update_syncing() + self._update_attribute() + + def _update_attribute(self, *args): + if self.layer is not None: + self.attribute = self.layer.visible_components[0] + + def update_priority(self, name): + if name == 'layer': + return 3 + elif name == 'attribute': + return 2 + elif name.endswith(('_min', '_max')): + return 0 + else: + return 1 + + def _update_syncing(self, *args): + if self.global_sync: + self._sync_color.enable_syncing() + self._sync_alpha.enable_syncing() + else: + self._sync_color.disable_syncing() + self._sync_alpha.disable_syncing() + + def flip_limits(self): + self.attribute_helper.flip_limits() + + +class ImageSubsetLayerState(MatplotlibLayerState): + pass diff --git a/glue/viewers/matplotlib/__init__.py b/glue/viewers/matplotlib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/glue/viewers/common/mpl_layer_artist.py b/glue/viewers/matplotlib/layer_artist.py similarity index 87% rename from glue/viewers/common/mpl_layer_artist.py rename to glue/viewers/matplotlib/layer_artist.py index 097ec75e4..3f3ba05f3 100644 --- a/glue/viewers/common/mpl_layer_artist.py +++ b/glue/viewers/matplotlib/layer_artist.py @@ -2,7 +2,7 @@ from glue.external.echo import keep_in_sync from glue.core.layer_artist import LayerArtistBase -from glue.viewers.common.mpl_state import DeferredDrawCallbackProperty +from glue.viewers.matplotlib.state import DeferredDrawCallbackProperty # TODO: should use the built-in class for this, though we don't need # the _sync_style method, so just re-define here for now. @@ -34,8 +34,8 @@ def __init__(self, axes, viewer_state, layer_state=None, layer=None): self.zorder = self.state.zorder self.visible = self.state.visible - keep_in_sync(self, 'zorder', self.state, 'zorder') - keep_in_sync(self, 'visible', self.state, 'visible') + self._sync_zorder = keep_in_sync(self, 'zorder', self.state, 'zorder') + self._sync_visible = keep_in_sync(self, 'visible', self.state, 'visible') def clear(self): for artist in self.mpl_artists: diff --git a/glue/viewers/matplotlib/qt/__init__.py b/glue/viewers/matplotlib/qt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/glue/viewers/common/qt/mpl_data_viewer.py b/glue/viewers/matplotlib/qt/data_viewer.py similarity index 80% rename from glue/viewers/common/qt/mpl_data_viewer.py rename to glue/viewers/matplotlib/qt/data_viewer.py index dc8f458a2..2d1b98575 100644 --- a/glue/viewers/common/qt/mpl_data_viewer.py +++ b/glue/viewers/matplotlib/qt/data_viewer.py @@ -1,16 +1,19 @@ from __future__ import absolute_import, division, print_function +from qtpy.QtCore import Qt + from glue.viewers.common.qt.data_viewer import DataViewer -from glue.viewers.common.qt.mpl_widget import MplWidget +from glue.viewers.matplotlib.qt.widget import MplWidget from glue.viewers.common.viz_client import init_mpl, update_appearance_from_settings from glue.external.echo import add_callback from glue.utils import nonpartial, defer_draw from glue.utils.decorators import avoid_circular -from glue.viewers.common.qt.mpl_toolbar import MatplotlibViewerToolbar -from glue.viewers.common.mpl_state import MatplotlibDataViewerState +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.state import MatplotlibDataViewerState from glue.core import message as msg from glue.core import Data from glue.core.exceptions import IncompatibleDataException +from glue.core.state import lookup_class_with_patches __all__ = ['MatplotlibDataViewer'] @@ -20,7 +23,7 @@ class MatplotlibDataViewer(DataViewer): _toolbar_cls = MatplotlibViewerToolbar _state_cls = MatplotlibDataViewerState - def __init__(self, session, parent=None): + def __init__(self, session, parent=None, wcs=None): super(MatplotlibDataViewer, self).__init__(session, parent) @@ -31,7 +34,7 @@ def __init__(self, session, parent=None): # TODO: shouldn't have to do this self.central_widget = self.mpl_widget - self.figure, self._axes = init_mpl(self.mpl_widget.canvas.fig) + self.figure, self._axes = init_mpl(self.mpl_widget.canvas.fig, wcs=wcs) # Set up the state which will contain everything needed to represent # the current state of the viewer @@ -67,6 +70,11 @@ def __init__(self, session, parent=None): # need to keep the layer_artist_container in sync self.state.add_callback('layers', nonpartial(self._sync_layer_artist_container)) + self.central_widget.resize(600, 400) + self.resize(self.central_widget.size()) + self.statusBar().setSizeGripEnabled(False) + self.setFocusPolicy(Qt.StrongFocus) + def _sync_state_layers(self): # Remove layer state objects that no longer have a matching layer for layer_state in self.state.layers: @@ -106,14 +114,18 @@ def axes(self): @defer_draw def add_data(self, data): - if data in self._layer_artist_container: - return True + # if data in self._layer_artist_container: + # return True if data not in self.session.data_collection: raise IncompatibleDataException("Data not in DataCollection") # Create layer artist and add to container layer = self._data_artist_cls(self._axes, self.state, layer=data) + + if layer is None: + return False + self._layer_artist_container.append(layer) layer.update() @@ -219,3 +231,40 @@ def _update_appearance_from_settings(self, message): def unregister(self, hub): super(MatplotlibDataViewer, self).unregister(hub) hub.unsubscribe_all(self) + + def __gluestate__(self, context): + return dict(state=self.state.__gluestate__(context), + session=context.id(self._session), + size=self.viewer_size, + pos=self.position, + layers=list(map(context.do, self.layers)), + _protocol=1) + + def update_viewer_state(rec, context): + pass + + @classmethod + @defer_draw + def __setgluestate__(cls, rec, context): + + if rec.get('_protocol', 0) < 1: + cls.update_viewer_state(rec, context) + + session = context.object(rec['session']) + viewer = cls(session) + viewer.register_to_hub(session.hub) + viewer.viewer_size = rec['size'] + x, y = rec['pos'] + viewer.move(x=x, y=y) + + viewer_state = cls._state_cls.__setgluestate__(rec['state'], context) + viewer.state.update_from_state(viewer_state) + + # Restore layer artists + for l in rec['layers']: + cls = lookup_class_with_patches(l.pop('_type')) + layer_state = context.object(l['state']) + layer_artist = cls(viewer.axes, viewer.state, layer_state=layer_state) + viewer._layer_artist_container.append(layer_artist) + + return viewer diff --git a/glue/viewers/matplotlib/qt/tests/__init__.py b/glue/viewers/matplotlib/qt/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/glue/viewers/common/qt/tests/test_mpl_data_viewer.py b/glue/viewers/matplotlib/qt/tests/test_data_viewer.py similarity index 99% rename from glue/viewers/common/qt/tests/test_mpl_data_viewer.py rename to glue/viewers/matplotlib/qt/tests/test_data_viewer.py index 9587c73a6..a64ab7e45 100644 --- a/glue/viewers/common/qt/tests/test_mpl_data_viewer.py +++ b/glue/viewers/matplotlib/qt/tests/test_data_viewer.py @@ -272,6 +272,9 @@ def test_limits_sync(self): viewer_state = self.viewer.state axes = self.viewer.axes + if axes.get_adjustable() == 'datalim': + pytest.xfail() + # Make sure that the viewer state and matplotlib viewer limits and log # settings are in sync. We start by modifying the state and making sure # that the axes follow. diff --git a/glue/viewers/common/qt/mpl_toolbar.py b/glue/viewers/matplotlib/qt/toolbar.py similarity index 100% rename from glue/viewers/common/qt/mpl_toolbar.py rename to glue/viewers/matplotlib/qt/toolbar.py diff --git a/glue/viewers/common/qt/mpl_widget.py b/glue/viewers/matplotlib/qt/widget.py similarity index 100% rename from glue/viewers/common/qt/mpl_widget.py rename to glue/viewers/matplotlib/qt/widget.py diff --git a/glue/viewers/common/mpl_state.py b/glue/viewers/matplotlib/state.py similarity index 100% rename from glue/viewers/common/mpl_state.py rename to glue/viewers/matplotlib/state.py diff --git a/glue/viewers/scatter/__init__.py b/glue/viewers/scatter/__init__.py index 739cfd230..5796e542e 100644 --- a/glue/viewers/scatter/__init__.py +++ b/glue/viewers/scatter/__init__.py @@ -1,4 +1,4 @@ def setup(): from glue.config import qt_client - from .qt import ScatterWidget - qt_client.add(ScatterWidget) + from .qt.data_viewer import ScatterViewer + qt_client.add(ScatterViewer) diff --git a/glue/viewers/scatter/client.py b/glue/viewers/scatter/client.py deleted file mode 100644 index 521808501..000000000 --- a/glue/viewers/scatter/client.py +++ /dev/null @@ -1,487 +0,0 @@ -from __future__ import absolute_import, division, print_function - -from functools import partial - -import numpy as np - -from glue.core.callback_property import (CallbackProperty, add_callback, - delay_callback) -from glue.core.message import ComponentReplacedMessage, SettingsChangeMessage -from glue.core.edit_subset_mode import EditSubsetMode -from glue.core.roi import RectangularROI -from glue.core.subset import RangeSubsetState, CategoricalROISubsetState, AndState -from glue.core.data import Data, IncompatibleAttribute, ComponentID -from glue.core.client import Client -from glue.core.layer_artist import LayerArtistContainer -from glue.core.state import lookup_class_with_patches -from glue.core.util import relim, update_ticks, visible_limits - -from glue.viewers.common.viz_client import init_mpl, update_appearance_from_settings - -from .layer_artist import ScatterLayerArtist - - -class ScatterClient(Client): - - """ - A client class that uses matplotlib to visualize tables as scatter plots. - """ - xmin = CallbackProperty(0) - xmax = CallbackProperty(1) - ymin = CallbackProperty(0) - ymax = CallbackProperty(1) - ylog = CallbackProperty(False) - xlog = CallbackProperty(False) - yflip = CallbackProperty(False) - xflip = CallbackProperty(False) - xatt = CallbackProperty() - yatt = CallbackProperty() - jitter = CallbackProperty() - - def __init__(self, data=None, figure=None, axes=None, - layer_artist_container=None): - """ - Create a new ScatterClient object - - :param data: :class:`~glue.core.data.DataCollection` to use - - :param figure: - Which matplotlib figure instance to draw to. One will be created if - not provided - - :param axes: - Which matplotlib axes instance to use. Will be created if necessary - """ - Client.__init__(self, data=data) - figure, axes = init_mpl(figure, axes) - self.artists = layer_artist_container - if self.artists is None: - self.artists = LayerArtistContainer() - - self._layer_updated = False # debugging - self._xset = False - self._yset = False - self.axes = axes - - self._connect() - self._set_limits() - - def is_layer_present(self, layer): - """ True if layer is plotted """ - return layer in self.artists - - def get_layer_order(self, layer): - """If layer exists as a single artist, return its zorder. - Otherwise, return None""" - artists = self.artists[layer] - if len(artists) == 1: - return artists[0].zorder - else: - return None - - @property - def layer_count(self): - return len(self.artists) - - def _connect(self): - add_callback(self, 'xlog', self._set_xlog) - add_callback(self, 'ylog', self._set_ylog) - - add_callback(self, 'xflip', self._set_limits) - add_callback(self, 'yflip', self._set_limits) - add_callback(self, 'xmin', self._set_limits) - add_callback(self, 'xmax', self._set_limits) - add_callback(self, 'ymin', self._set_limits) - add_callback(self, 'ymax', self._set_limits) - add_callback(self, 'xatt', partial(self._set_xydata, 'x')) - add_callback(self, 'yatt', partial(self._set_xydata, 'y')) - add_callback(self, 'jitter', self._jitter) - self.axes.figure.canvas.mpl_connect('draw_event', - lambda x: self._pull_properties()) - - def _set_limits(self, *args): - - xlim = min(self.xmin, self.xmax), max(self.xmin, self.xmax) - if self.xflip: - xlim = xlim[::-1] - ylim = min(self.ymin, self.ymax), max(self.ymin, self.ymax) - if self.yflip: - ylim = ylim[::-1] - - xold = self.axes.get_xlim() - yold = self.axes.get_ylim() - self.axes.set_xlim(xlim) - self.axes.set_ylim(ylim) - if xlim != xold or ylim != yold: - self._redraw() - - def plottable_attributes(self, layer, show_hidden=False): - data = layer.data - comp = data.components if show_hidden else data.visible_components - return [c for c in comp if - data.get_component(c).numeric - or data.get_component(c).categorical] - - def add_layer(self, layer): - """ Adds a new visual layer to a client, to display either a dataset - or a subset. Updates both the client data structure and the - plot. - - Returns the created layer artist - - :param layer: the layer to add - :type layer: :class:`~glue.core.data.Data` or :class:`~glue.core.subset.Subset` - """ - if layer.data not in self.data: - raise TypeError("Layer not in data collection") - if layer in self.artists: - return self.artists[layer][0] - - result = ScatterLayerArtist(layer, self.axes) - self.artists.append(result) - self._update_layer(layer) - self._ensure_subsets_added(layer) - return result - - def _ensure_subsets_added(self, layer): - if not isinstance(layer, Data): - return - for subset in layer.subsets: - self.add_layer(subset) - - def _visible_limits(self, axis): - """Return the min-max visible data boundaries for given axis""" - return visible_limits(self.artists, axis) - - def _snap_xlim(self): - """ - Reset the plotted x rng to show all the data - """ - is_log = self.xlog - rng = self._visible_limits(0) - if rng is None: - return - rng = relim(rng[0], rng[1], is_log) - if self.xflip: - rng = rng[::-1] - self.axes.set_xlim(rng) - self._pull_properties() - - def _snap_ylim(self): - """ - Reset the plotted y rng to show all the data - """ - rng = [np.infty, -np.infty] - is_log = self.ylog - - rng = self._visible_limits(1) - if rng is None: - return - rng = relim(rng[0], rng[1], is_log) - - if self.yflip: - rng = rng[::-1] - self.axes.set_ylim(rng) - self._pull_properties() - - def snap(self): - """Rescale axes to fit the data""" - self._snap_xlim() - self._snap_ylim() - self._redraw() - - def set_visible(self, layer, state): - """ Toggle a layer's visibility - - :param layer: which layer to modify - :type layer: class:`~glue.core.data.Data` or :class:`~glue.coret.Subset` - - :param state: True to show. false to hide - :type state: boolean - """ - if layer not in self.artists: - return - for a in self.artists[layer]: - a.visible = state - self._redraw() - - def is_visible(self, layer): - if layer not in self.artists: - return False - return any(a.visible for a in self.artists[layer]) - - def _set_xydata(self, coord, attribute, snap=True): - """ Redefine which components get assigned to the x/y axes - - :param coord: 'x' or 'y' - Which axis to reassign - :param attribute: - Which attribute of the data to use. - :type attribute: core.data.ComponentID - :param snap: - If True, will rescale x/y axes to fit the data - :type snap: bool - """ - - if coord not in ('x', 'y'): - raise TypeError("coord must be one of x,y") - if not isinstance(attribute, ComponentID): - raise TypeError("attribute must be a ComponentID") - - # update coordinates of data and subsets - if coord == 'x': - new_add = not self._xset - self.xatt = attribute - self._xset = self.xatt is not None - elif coord == 'y': - new_add = not self._yset - self.yatt = attribute - self._yset = self.yatt is not None - - # update plots - list(map(self._update_layer, self.artists.layers)) - - if coord == 'x' and snap: - self._snap_xlim() - if new_add: - self._snap_ylim() - elif coord == 'y' and snap: - self._snap_ylim() - if new_add: - self._snap_xlim() - - self._update_axis_labels() - self._pull_properties() - self._redraw() - - def apply_roi(self, roi): - # every editable subset is updated - # using specified ROI - - for x_comp, y_comp in zip(self._get_data_components('x'), - self._get_data_components('y')): - subset_state = x_comp.subset_from_roi(self.xatt, roi, - other_comp=y_comp, - other_att=self.yatt, - coord='x') - mode = EditSubsetMode() - visible = [d for d in self._data if self.is_visible(d)] - focus = visible[0] if len(visible) > 0 else None - mode.update(self._data, subset_state, focus_data=focus) - - def _set_xlog(self, state): - """ Set the x axis scaling - - :param state: - The new scaling for the x axis - :type state: string ('log' or 'linear') - """ - mode = 'log' if state else 'linear' - lim = self.axes.get_xlim() - self.axes.set_xscale(mode) - - # Rescale if switching to log with negative bounds - if state and min(lim) <= 0: - self._snap_xlim() - - self._redraw() - - def _set_ylog(self, state): - """ Set the y axis scaling - - :param state: The new scaling for the y axis - :type state: string ('log' or 'linear') - """ - mode = 'log' if state else 'linear' - lim = self.axes.get_ylim() - self.axes.set_yscale(mode) - # Rescale if switching to log with negative bounds - if state and min(lim) <= 0: - self._snap_ylim() - - self._redraw() - - def _remove_data(self, message): - """Process DataCollectionDeleteMessage""" - for s in message.data.subsets: - self.delete_layer(s) - self.delete_layer(message.data) - - def _remove_subset(self, message): - self.delete_layer(message.subset) - - def delete_layer(self, layer): - if layer not in self.artists: - return - self.artists.pop(layer) - self._redraw() - assert not self.is_layer_present(layer) - - def _update_data(self, message): - data = message.sender - self._update_layer(data) - - def _numerical_data_changed(self, message): - data = message.sender - self._update_layer(data, force=True) - for s in data.subsets: - self._update_layer(s, force=True) - - def _redraw(self): - self.axes.figure.canvas.draw() - - def _jitter(self, *args): - - for attribute in [self.xatt, self.yatt]: - if attribute is not None: - for data in self.data: - try: - comp = data.get_component(attribute) - comp.jitter(method=self.jitter) - except (IncompatibleAttribute, NotImplementedError): - continue - - def _update_axis_labels(self, *args): - self.axes.set_xlabel(self.xatt) - self.axes.set_ylabel(self.yatt) - if self.xatt is not None: - update_ticks(self.axes, 'x', - list(self._get_data_components('x')), - self.xlog) - - if self.yatt is not None: - update_ticks(self.axes, 'y', - list(self._get_data_components('y')), - self.ylog) - - def _add_subset(self, message): - subset = message.sender - # only add subset if data layer present - if subset.data not in self.artists: - return - subset.do_broadcast(False) - self.add_layer(subset) - subset.do_broadcast(True) - - def add_data(self, data): - result = self.add_layer(data) - for subset in data.subsets: - self.add_layer(subset) - return result - - @property - def data(self): - """The data objects in the scatter plot""" - return list(self._data) - - def _get_attribute(self, coord): - if coord == 'x': - return self.xatt - elif coord == 'y': - return self.yatt - else: - raise TypeError('coord must be x or y') - - def _get_data_components(self, coord): - """ Returns the components for each dataset for x and y axes. - """ - - attribute = self._get_attribute(coord) - - for data in self._data: - try: - yield data.get_component(attribute) - except IncompatibleAttribute: - pass - - def _check_categorical(self, attribute): - """ A simple function to figure out if an attribute is categorical. - :param attribute: a core.Data.ComponentID - :return: True iff the attribute represents a CategoricalComponent - """ - - for data in self._data: - try: - comp = data.get_component(attribute) - if comp.categorical: - return True - except IncompatibleAttribute: - pass - return False - - def _update_subset(self, message): - self._update_layer(message.sender) - - def restore_layers(self, layers, context): - """ Re-generate a list of plot layers from a glue-serialized list""" - for l in layers: - cls = lookup_class_with_patches(l.pop('_type')) - if cls != ScatterLayerArtist: - raise ValueError("Scatter client cannot restore layer of type " - "%s" % cls) - props = dict((k, context.object(v)) for k, v in l.items()) - layer = self.add_layer(props['layer']) - layer.properties = props - - def _update_layer(self, layer, force=False): - """ Update both the style and data for the requested layer""" - if self.xatt is None or self.yatt is None: - return - - if layer not in self.artists: - return - - self._layer_updated = True - for art in self.artists[layer]: - art.xatt = self.xatt - art.yatt = self.yatt - art.force_update() if force else art.update() - self._redraw() - - def _pull_properties(self): - xlim = self.axes.get_xlim() - ylim = self.axes.get_ylim() - xsc = self.axes.get_xscale() - ysc = self.axes.get_yscale() - - xflip = (xlim[1] < xlim[0]) - yflip = (ylim[1] < ylim[0]) - - with delay_callback(self, 'xmin', 'xmax', 'xflip', 'xlog'): - self.xmin = min(xlim) - self.xmax = max(xlim) - self.xflip = xflip - self.xlog = (xsc == 'log') - - with delay_callback(self, 'ymin', 'ymax', 'yflip', 'ylog'): - self.ymin = min(ylim) - self.ymax = max(ylim) - self.yflip = yflip - self.ylog = (ysc == 'log') - - def _on_component_replace(self, msg): - old = msg.old - new = msg.new - - if self.xatt is old: - self.xatt = new - if self.yatt is old: - self.yatt = new - - def register_to_hub(self, hub): - - super(ScatterClient, self).register_to_hub(hub) - - hub.subscribe(self, ComponentReplacedMessage, self._on_component_replace) - - def is_appearance_settings(msg): - return ('BACKGROUND_COLOR' in msg.settings - or 'FOREGROUND_COLOR' in msg.settings) - - hub.subscribe(self, SettingsChangeMessage, - self._update_appearance_from_settings, - filter=is_appearance_settings) - - def _update_appearance_from_settings(self, message): - update_appearance_from_settings(self.axes) - self._redraw() diff --git a/glue/viewers/scatter/compat.py b/glue/viewers/scatter/compat.py new file mode 100644 index 000000000..366200844 --- /dev/null +++ b/glue/viewers/scatter/compat.py @@ -0,0 +1,57 @@ +import uuid + +from .state import ScatterLayerState + +STATE_CLASS = {} +STATE_CLASS['ScatterLayerArtist'] = ScatterLayerState + + +def update_scatter_viewer_state(rec, context): + """ + Given viewer session information, make sure the session information is + compatible with the current version of the viewers, and if not, update + the session information in-place. + """ + + if '_protocol' not in rec: + + # Note that files saved with protocol < 1 have bin settings saved per + # layer but they were always restricted to be the same, so we can just + # use the settings from the first layer + + rec['state'] = {} + rec['state']['values'] = {} + + # TODO: could generalize this into a mapping + properties = rec.pop('properties') + viewer_state = rec['state']['values'] + viewer_state['x_min'] = properties['xmin'] + viewer_state['x_max'] = properties['xmax'] + viewer_state['y_min'] = properties['ymin'] + viewer_state['y_max'] = properties['ymax'] + viewer_state['x_log'] = properties['xlog'] + viewer_state['y_log'] = properties['ylog'] + viewer_state['x_att'] = properties['xatt'] + viewer_state['y_att'] = properties['yatt'] + + layer_states = [] + + for layer in rec['layers']: + state_id = str(uuid.uuid4()) + state_cls = STATE_CLASS[layer['_type'].split('.')[-1]] + state = state_cls(layer=context.object(layer.pop('layer'))) + for prop in ('visible', 'zorder'): + value = layer.pop(prop) + value = context.object(value) + setattr(state, prop, value) + context.register_object(state_id, state) + layer['state'] = state_id + layer_states.append(state) + layer.pop('lo', None) + layer.pop('hi', None) + layer.pop('nbins', None) + layer.pop('xlog', None) + + list_id = str(uuid.uuid4()) + context.register_object(list_id, layer_states) + rec['state']['values']['layers'] = list_id diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index 195b36d60..016f6e50d 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -1,91 +1,116 @@ from __future__ import absolute_import, division, print_function -from abc import ABCMeta, abstractproperty, abstractmethod +from glue.utils import defer_draw -import numpy as np - -from glue.external import six -from glue.core.subset import Subset +from glue.viewers.scatter.state import ScatterLayerState +from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist from glue.core.exceptions import IncompatibleAttribute -from glue.core.layer_artist import MatplotlibLayerArtist, ChangedTrigger -__all__ = ['ScatterLayerArtist'] +class ScatterLayerArtist(MatplotlibLayerArtist): + _layer_state_cls = ScatterLayerState -@six.add_metaclass(ABCMeta) -class ScatterLayerBase(object): + def __init__(self, axes, viewer_state, layer_state=None, layer=None): - # which ComponentID to assign to X axis - xatt = abstractproperty() + super(ScatterLayerArtist, self).__init__(axes, viewer_state, + layer_state=layer_state, layer=layer) - # which ComponentID to assign to Y axis - yatt = abstractproperty() + # Watch for changes in the viewer state which would require the + # layers to be redrawn + self._viewer_state.add_global_callback(self._update_scatter) + self.state.add_global_callback(self._update_scatter) - @abstractmethod - def get_data(self): - """ - Returns - ------- - array - The scatterpoint data as an (N, 2) array - """ - pass + # TODO: following is temporary + self.state.data_collection = self._viewer_state.data_collection + self.data_collection = self._viewer_state.data_collection + self.mpl_artists = self.axes.plot([], [], 'o', mec='none') -class ScatterLayerArtist(MatplotlibLayerArtist, ScatterLayerBase): - xatt = ChangedTrigger() - yatt = ChangedTrigger() - _property_set = MatplotlibLayerArtist._property_set + ['xatt', 'yatt'] + self.reset_cache() - def __init__(self, layer, ax): - super(ScatterLayerArtist, self).__init__(layer, ax) - self.emphasis = None # an optional SubsetState of emphasized points + def reset_cache(self): + self._last_viewer_state = {} + self._last_layer_state = {} - def _recalc(self): - self.clear() - assert len(self.artists) == 0 + def _update_scatter_data(self): try: - x = self.layer[self.xatt].ravel() - y = self.layer[self.yatt].ravel() - except IncompatibleAttribute as exc: - self.disable_invalid_attributes(*exc.args) - return False - - self.artists = self._axes.plot(x, y) - return True - - def update(self, view=None, transpose=False): - self._check_subset_state_changed() - - if self._changed: # erase and make a new artist - if not self._recalc(): # no need to update style - return - self._changed = False - - has_emph = False - if self.emphasis is not None: - try: - s = Subset(self.layer.data) - s.subset_state = self.emphasis - if hasattr(self.layer, 'subset_state'): - s.subset_state &= self.layer.subset_state - x = s[self.xatt].ravel() - y = s[self.yatt].ravel() - self.artists.extend(self._axes.plot(x, y)) - has_emph = True - except IncompatibleAttribute: - pass - - self._sync_style() - if has_emph: - self.artists[-1].set_mec('green') - self.artists[-1].set_mew(2) - self.artists[-1].set_alpha(1) - - def get_data(self): + x = self.layer[self._viewer_state.x_att].ravel() + except (IncompatibleAttribute, IndexError): + # The following includes a call to self.clear() + self.disable_invalid_attributes(self._viewer_state.x_att) + return + else: + self._enabled = True + try: - return self.layer[self.xatt].ravel(), self.layer[self.yatt].ravel() - except IncompatibleAttribute: - return np.array([]), np.array([]) + y = self.layer[self._viewer_state.y_att].ravel() + except (IncompatibleAttribute, IndexError): + # The following includes a call to self.clear() + self.disable_invalid_attributes(self._viewer_state.y_att) + return + else: + self._enabled = True + + self.mpl_artists[0].set_data(x, y) + + @defer_draw + def _update_visual_attributes(self): + + for mpl_artist in self.mpl_artists: + mpl_artist.set_visible(self.state.visible) + mpl_artist.set_zorder(self.state.zorder) + mpl_artist.set_markeredgecolor('none') + mpl_artist.set_markerfacecolor(self.state.color) + mpl_artist.set_markersize(self.state.size) + mpl_artist.set_alpha(self.state.alpha) + + self.redraw() + + def _update_scatter(self, force=False, **kwargs): + + if (self._viewer_state.x_att is None or + self._viewer_state.y_att is None or + self.state.layer is None): + return + + # Figure out which attributes are different from before. Ideally we shouldn't + # need this but currently this method is called multiple times if an + # attribute is changed due to x_att changing then hist_x_min, hist_x_max, etc. + # If we can solve this so that _update_histogram is really only called once + # then we could consider simplifying this. Until then, we manually keep track + # of which properties have changed. + + changed = set() + + if not force: + + for key, value in self._viewer_state.as_dict().items(): + if value != self._last_viewer_state.get(key, None): + changed.add(key) + + for key, value in self.state.as_dict().items(): + if value != self._last_layer_state.get(key, None): + changed.add(key) + + self._last_viewer_state.update(self._viewer_state.as_dict()) + self._last_layer_state.update(self.state.as_dict()) + + if force or any(prop in changed for prop in ('layer', 'x_att', 'y_att')): + self._update_scatter_data() + force = True # make sure scaling and visual attributes are updated + + if force or any(prop in changed for prop in ('size', 'alpha', 'color', 'zorder', 'visible')): + self._update_visual_attributes() + + @defer_draw + def update(self): + + self._update_scatter(force=True) + + # Reset the axes stack so that pressing the home button doesn't go back + # to a previous irrelevant view. + self.axes.figure.canvas.toolbar.update() + + self.redraw() diff --git a/glue/viewers/scatter/layer_style_editor.ui b/glue/viewers/scatter/layer_style_editor.ui new file mode 100644 index 000000000..b2f763e14 --- /dev/null +++ b/glue/viewers/scatter/layer_style_editor.ui @@ -0,0 +1,65 @@ + + + Form + + + + 0 + 0 + 253 + 109 + + + + Form + + + + 5 + + + + + Qt::Horizontal + + + + + + + + 75 + true + + + + Transparency: + + + + + + + + + + + + + + 12 + + + + Link properties with other similar layers + + + true + + + + + + + + diff --git a/glue/viewers/scatter/qt/__init__.py b/glue/viewers/scatter/qt/__init__.py index b02ea40bc..d753ee0bc 100644 --- a/glue/viewers/scatter/qt/__init__.py +++ b/glue/viewers/scatter/qt/__init__.py @@ -1 +1 @@ -from .viewer_widget import * \ No newline at end of file +from .data_viewer import ScatterViewer diff --git a/glue/viewers/scatter/qt/data_viewer.py b/glue/viewers/scatter/qt/data_viewer.py new file mode 100644 index 000000000..e3336a47c --- /dev/null +++ b/glue/viewers/scatter/qt/data_viewer.py @@ -0,0 +1,92 @@ +from __future__ import absolute_import, division, print_function + +from glue.utils import nonpartial +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar +from glue.core.edit_subset_mode import EditSubsetMode +from glue.core import Data +from glue.core.util import update_ticks +from glue.utils import defer_draw + +from glue.viewers.matplotlib.qt.data_viewer import MatplotlibDataViewer +from glue.viewers.scatter.qt.layer_style_editor import ScatterLayerStyleEditor +from glue.viewers.scatter.layer_artist import ScatterLayerArtist +from glue.viewers.scatter.qt.options_widget import ScatterOptionsWidget +from glue.viewers.scatter.state import ScatterViewerState +from glue.viewers.scatter.compat import update_scatter_viewer_state + +__all__ = ['ScatterViewer'] + + +class ScatterViewer(MatplotlibDataViewer): + + LABEL = '2D Scatter' + _toolbar_cls = MatplotlibViewerToolbar + _layer_style_widget_cls = ScatterLayerStyleEditor + _state_cls = ScatterViewerState + _options_cls = ScatterOptionsWidget + _data_artist_cls = ScatterLayerArtist + _subset_artist_cls = ScatterLayerArtist + + update_viewer_state = update_scatter_viewer_state + + tools = ['select:rectangle', 'select:xrange', + 'select:yrange', 'select:circle', + 'select:polygon'] + + def __init__(self, session, parent=None): + super(ScatterViewer, self).__init__(session, parent) + self.state.add_callback('x_att', nonpartial(self._update_axes)) + self.state.add_callback('y_att', nonpartial(self._update_axes)) + self.state.add_callback('x_log', nonpartial(self._update_axes)) + self.state.add_callback('y_log', nonpartial(self._update_axes)) + + @defer_draw + def _update_axes(self): + + if self.state.x_att is not None: + + # Update ticks, which sets the labels to categories if components are categorical + update_ticks(self.axes, 'x', self.state._get_x_components(), False) + + if self.state.x_log: + self.axes.set_xlabel('Log ' + self.state.x_att.label) + else: + self.axes.set_xlabel(self.state.x_att.label) + + if self.state.y_att is not None: + + # Update ticks, which sets the labels to categories if components are categorical + update_ticks(self.axes, 'y', self.state._get_y_components(), False) + + if self.state.y_log: + self.axes.set_ylabel('Log ' + self.state.y_att.label) + else: + self.axes.set_ylabel(self.state.y_att.label) + + self.axes.figure.canvas.draw() + + def apply_roi(self, roi): + + # TODO: move this to state class? + + # TODO: add back command stack here so as to be able to undo? + # cmd = command.ApplyROI(client=self.client, roi=roi) + # self._session.command_stack.do(cmd) + + # TODO Does subset get applied to all data or just visible data? + + for layer_artist in self._layer_artist_container: + + if not isinstance(layer_artist.layer, Data): + continue + + x_comp = layer_artist.layer.get_component(self.state.x_att) + y_comp = layer_artist.layer.get_component(self.state.y_att) + + subset_state = x_comp.subset_from_roi(self.state.x_att, roi, + other_comp=y_comp, + other_att=self.state.y_att, + coord='x') + + mode = EditSubsetMode() + mode.update(self._data, subset_state, focus_data=layer_artist.layer) diff --git a/glue/viewers/scatter/qt/layer_style_editor.py b/glue/viewers/scatter/qt/layer_style_editor.py new file mode 100644 index 000000000..0dde543b1 --- /dev/null +++ b/glue/viewers/scatter/qt/layer_style_editor.py @@ -0,0 +1,20 @@ +import os + +from qtpy import QtWidgets + +from glue.external.echo.qt import autoconnect_callbacks_to_qt +from glue.utils.qt import load_ui + + +class ScatterLayerStyleEditor(QtWidgets.QWidget): + + def __init__(self, layer, parent=None): + + super(ScatterLayerStyleEditor, self).__init__(parent=parent) + + self.ui = load_ui('layer_style_editor.ui', self, + directory=os.path.dirname(__file__)) + + connect_kwargs = {'alpha': dict(value_range=(0, 1))} + + autoconnect_callbacks_to_qt(layer.state, self.ui, connect_kwargs) diff --git a/glue/viewers/scatter/qt/layer_style_editor.ui b/glue/viewers/scatter/qt/layer_style_editor.ui new file mode 100644 index 000000000..110090a7e --- /dev/null +++ b/glue/viewers/scatter/qt/layer_style_editor.ui @@ -0,0 +1,105 @@ + + + Form + + + + 0 + 0 + 157 + 91 + + + + Form + + + + 5 + + + + + 100 + + + Qt::Horizontal + + + + + + + + 75 + true + + + + color + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + + + + + + + + 75 + true + + + + transparency: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 75 + true + + + + size + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + QColorBox + QLabel +
glue.utils.qt.colors
+
+
+ + +
diff --git a/glue/viewers/scatter/qt/layer_style_widget.py b/glue/viewers/scatter/qt/layer_style_widget.py deleted file mode 100644 index c270b27c2..000000000 --- a/glue/viewers/scatter/qt/layer_style_widget.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import absolute_import, division, print_function - -import os - -from qtpy import QtCore, QtWidgets -from glue.utils.qt import get_qapp -from glue.utils.qt import load_ui, connect_color -from glue.utils.qt.widget_properties import CurrentComboProperty, ValueProperty, connect_value, connect_current_combo -from glue.icons.qt import POINT_ICONS, symbol_icon - - -class ScatterLayerStyleWidget(QtWidgets.QWidget): - - size = ValueProperty('ui.value_size') - symbol = CurrentComboProperty('ui.combo_symbol') - alpha = ValueProperty('ui.slider_alpha', value_range=(0, 1)) - - def __init__(self, layer_artist): - - super(ScatterLayerStyleWidget, self).__init__() - - self.ui = load_ui('layer_style_widget.ui', self, - directory=os.path.dirname(__file__)) - - self._setup_symbol_combo() - - self.layer = layer_artist.layer - - # Set up connections - self._connect_global() - - # Set initial values - self.symbol = self.layer.style.marker - self.size = self.layer.style.markersize - self.ui.label_color.setColor(self.layer.style.color) - self.alpha = self.layer.style.alpha - - def _connect_global(self): - connect_current_combo(self.layer.style, 'marker', self.ui.combo_symbol) - connect_value(self.layer.style, 'markersize', self.ui.value_size) - connect_color(self.layer.style, 'color', self.ui.label_color) - connect_value(self.layer.style, 'alpha', self.ui.slider_alpha, value_range=(0, 1)) - - def _setup_symbol_combo(self): - self._symbols = list(POINT_ICONS.keys()) - for idx, symbol in enumerate(self._symbols): - icon = symbol_icon(symbol) - self.ui.combo_symbol.addItem(icon, '', userData=symbol) - self.ui.combo_symbol.setIconSize(QtCore.QSize(16, 16)) - -if __name__ == "__main__": - app = get_qapp() - options = ScatterLayerStyleWidget() - options.show() - app.exec_() diff --git a/glue/viewers/scatter/qt/options_widget.py b/glue/viewers/scatter/qt/options_widget.py new file mode 100644 index 000000000..1550f66b3 --- /dev/null +++ b/glue/viewers/scatter/qt/options_widget.py @@ -0,0 +1,56 @@ +from __future__ import absolute_import, division, print_function + +import os + +from qtpy import QtWidgets + +from glue.external.echo.qt import autoconnect_callbacks_to_qt +from glue.core import Data, Subset +from glue.utils.qt import load_ui +from glue.core.qt.data_combo_helper import ComponentIDComboHelper + +__all__ = ['ScatterOptionsWidget'] + + +class ScatterOptionsWidget(QtWidgets.QWidget): + + def __init__(self, viewer_state, session, parent=None): + + super(ScatterOptionsWidget, self).__init__(parent=parent) + + self.ui = load_ui('options_widget.ui', self, + directory=os.path.dirname(__file__)) + + autoconnect_callbacks_to_qt(viewer_state, self.ui) + + viewer_state.add_callback('layers', self._update_combo_data) + + self.x_att_helper = ComponentIDComboHelper(self.ui.combodata_x_att, + session.data_collection, + default_index=0) + + self.y_att_helper = ComponentIDComboHelper(self.ui.combodata_y_att, + session.data_collection, + default_index=1) + + self.viewer_state = viewer_state + + def reset_limits(self): + self.viewer_state.reset_limits() + + def _update_combo_data(self, *args): + + layers = [] + + for layer_state in self.viewer_state.layers: + if isinstance(layer_state.layer, Data): + if layer_state.layer not in layers: + layers.append(layer_state.layer) + + for layer_state in self.viewer_state.layers: + if isinstance(layer_state.layer, Subset) and layer_state.layer.data not in layers: + if layer_state.layer not in layers: + layers.append(layer_state.layer) + + self.x_att_helper.set_multiple_data(layers) + self.y_att_helper.set_multiple_data(layers) diff --git a/glue/viewers/scatter/qt/options_widget.ui b/glue/viewers/scatter/qt/options_widget.ui index d024bac04..5f9fb63c3 100644 --- a/glue/viewers/scatter/qt/options_widget.ui +++ b/glue/viewers/scatter/qt/options_widget.ui @@ -1,344 +1,200 @@ - ScatterWidget - + Widget + 0 0 - 287 - 230 + 192 + 151 - - - 3 - 3 - - - - - 555 - 500 - - - - Qt::StrongFocus - - Scatter Plot - - - + 2D Scatter - - - 3 + + + 5 - - 2 + + 5 - - 2 - - - 4 - - - - - - 4 - - - 0 - - - 0 - - - 0 - - - 10 - - - - - 8 - - - - - x axis - - - - - - - - 0 - 0 - - - - Set which attribute is plotted on the x axis - - - QComboBox::AdjustToMinimumContentsLength - - - - - - - Toggle on/off log scaling on the x axis - - - log - - - - - - - Flip/unflip the order of the x axis - - - flip - - - - - - - - - 8 - - - - - y axis - - - - - - - - 0 - 0 - - - - Set which attribute is plotted on the y axis - - - QComboBox::AdjustToMinimumContentsLength - - - - - - - Toggle on/off log scaling on the y axis - - - log - - - - - - - Flip/unflip the order of the y axis - - - flip - - - - - - - - - - - Rescale plot limits to fit data - - - Auto scale - - - - - - - Swap what's plotted on the x and y axes - - - Swap Axes - - - - - - - - - show hidden attributes - - - - - - - QFrame::Sunken - - - 2 - - - 0 - - - Qt::Horizontal - - - - - - - QFrame::NoFrame - - - QFrame::Sunken - - - Plot Limits - - - Qt::AlignCenter - - - - - - - - - - 0 - 0 - - - - - 40 - 0 - - - - x min - - - - - - - - - - - 0 - 0 - - - - - 40 - 0 - - - - x max - - - - - - - - - - - - - - - 0 - 0 - - - - - 40 - 0 - - - - y min - - - - - - - - - - - 0 - 0 - - - - - 40 - 0 - - - - y max - - - - - - - - - - - - Qt::Vertical - - - - 1 - 1 - - - - - + + + + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + + 75 + true + + + + y axis + + + + + + + log + + + true + + + + + + + + 75 + true + + + + x axis + + + + + + + log + + + true + + + + + + + + 14 + + + + padding: 0px + + + ⇄ + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + + 14 + + + + padding: 0px + + + ⇄ + + + + + + + + min/max: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + min/max: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Reset Limits + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + diff --git a/glue/viewers/scatter/qt/tests/data/scatter_v0.glu b/glue/viewers/scatter/qt/tests/data/scatter_v0.glu new file mode 100644 index 000000000..74e520951 --- /dev/null +++ b/glue/viewers/scatter/qt/tests/data/scatter_v0.glu @@ -0,0 +1,449 @@ +{ + "Component": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGY4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDMsKSwgfSAgICAgICAgICAgIAoAAAAAAADwP5qZmZmZmck/AAAAAAAA8L8=" + }, + "units": "" + }, + "Component_0": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGk4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDMsKSwgfSAgICAgICAgICAgIAoDAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAA=" + }, + "units": "" + }, + "Component_1": { + "_type": "glue.core.component.Component", + "data": { + "_type": "numpy.ndarray", + "data": "k05VTVBZAQBGAHsnZGVzY3InOiAnPGY4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDMsKSwgfSAgICAgICAgICAgIAoAAAAAAAAQQJqZmZmZmdk/AAAAAAAAFEA=" + }, + "units": "" + }, + "CoordinateComponent": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": false + }, + "CoordinateComponentLink": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "World 0" + ], + "index": 0, + "pix2world": false, + "to": [ + "Pixel Axis 0 [x]" + ] + }, + "CoordinateComponentLink_0": { + "_type": "glue.core.component_link.CoordinateComponentLink", + "coords": "Coordinates", + "frm": [ + "Pixel Axis 0 [x]" + ], + "index": 0, + "pix2world": true, + "to": [ + "World 0" + ] + }, + "CoordinateComponent_0": { + "_type": "glue.core.component.CoordinateComponent", + "axis": 0, + "world": true + }, + "Coordinates": { + "_type": "glue.core.coordinates.Coordinates" + }, + "DataCollection": { + "_protocol": 3, + "_type": "glue.core.data_collection.DataCollection", + "cids": [ + "a", + "Pixel Axis 0 [x]", + "World 0", + "b", + "c" + ], + "components": [ + "Component", + "CoordinateComponent", + "CoordinateComponent_0", + "Component_0", + "Component_1" + ], + "data": [ + "basic" + ], + "groups": [ + "Subset 1_0", + "Subset 2_0" + ], + "links": [ + "CoordinateComponentLink", + "CoordinateComponentLink_0" + ], + "subset_group_count": 2 + }, + "Pixel Axis 0 [x]": { + "_type": "glue.core.component_id.PixelComponentID", + "axis": 0, + "hidden": true, + "label": "Pixel Axis 0 [x]" + }, + "PolygonalROI": { + "_type": "glue.core.roi.PolygonalROI", + "vx": [ + 0.010048309178743997, + 0.22106280193236705, + 0.22106280193236705, + 0.010048309178743997, + 0.010048309178743997 + ], + "vy": [ + 1.9849289099526066, + 1.9849289099526066, + 2.167298578199052, + 2.167298578199052, + 1.9849289099526066 + ] + }, + "PolygonalROI_0": { + "_type": "glue.core.roi.PolygonalROI", + "vx": [ + -1.04, + 0.4521739130434783, + 0.4521739130434783, + -1.04, + -1.04 + ], + "vy": [ + 1.9997156398104265, + 1.9997156398104265, + 2.2806635071090047, + 2.2806635071090047, + 1.9997156398104265 + ] + }, + "RoiSubsetState": { + "_type": "glue.core.subset.RoiSubsetState", + "roi": "PolygonalROI", + "xatt": "a", + "yatt": "b" + }, + "RoiSubsetState_0": { + "_type": "glue.core.subset.RoiSubsetState", + "roi": "PolygonalROI_0", + "xatt": "a", + "yatt": "b" + }, + "ScatterWidget": { + "_type": "glue.viewers.scatter.qt.viewer_widget.ScatterWidget", + "layers": [ + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "basic", + "visible": true, + "xatt": "a", + "yatt": "b", + "zorder": 1 + }, + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "Subset 1", + "visible": true, + "xatt": "a", + "yatt": "b", + "zorder": 2 + }, + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "Subset 2", + "visible": true, + "xatt": "a", + "yatt": "b", + "zorder": 3 + } + ], + "pos": [ + 0, + 0 + ], + "properties": { + "hidden": false, + "xatt": "a", + "xflip": false, + "xlog": false, + "xmax": 1.04, + "xmin": -1.04, + "yatt": "b", + "yflip": false, + "ylog": false, + "ymax": 3.02, + "ymin": 1.98 + }, + "session": "Session", + "size": [ + 600, + 400 + ] + }, + "ScatterWidget_0": { + "_type": "glue.viewers.scatter.qt.viewer_widget.ScatterWidget", + "layers": [ + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "basic", + "visible": true, + "xatt": "a", + "yatt": "c", + "zorder": 1 + }, + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "Subset 1", + "visible": false, + "xatt": "a", + "yatt": "c", + "zorder": 2 + }, + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "Subset 2", + "visible": true, + "xatt": "a", + "yatt": "c", + "zorder": 3 + } + ], + "pos": [ + 600, + 0 + ], + "properties": { + "hidden": false, + "xatt": "a", + "xflip": false, + "xlog": true, + "xmax": 1.05, + "xmin": 9.5e-06, + "yatt": "c", + "yflip": false, + "ylog": true, + "ymax": 5.25, + "ymin": 0.38 + }, + "session": "Session", + "size": [ + 600, + 400 + ] + }, + "ScatterWidget_1": { + "_type": "glue.viewers.scatter.qt.viewer_widget.ScatterWidget", + "layers": [ + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "basic", + "visible": true, + "xatt": "b", + "yatt": "a", + "zorder": 1 + }, + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "Subset 1", + "visible": true, + "xatt": "b", + "yatt": "a", + "zorder": 2 + }, + { + "_type": "glue.viewers.scatter.layer_artist.ScatterLayerArtist", + "layer": "Subset 2", + "visible": false, + "xatt": "b", + "yatt": "a", + "zorder": 3 + } + ], + "pos": [ + 0, + 400 + ], + "properties": { + "hidden": false, + "xatt": "b", + "xflip": false, + "xlog": false, + "xmax": 5.0, + "xmin": 0.0, + "yatt": "a", + "yflip": true, + "ylog": false, + "ymax": 5.0, + "ymin": -5.0 + }, + "session": "Session", + "size": [ + 600, + 400 + ] + }, + "Session": { + "_type": "glue.core.session.Session" + }, + "Subset 1": { + "_type": "glue.core.subset_group.GroupedSubset", + "group": "Subset 1_0", + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.5, + "color": "#e31a1c", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 7 + } + }, + "Subset 1_0": { + "_type": "glue.core.subset_group.SubsetGroup", + "label": "Subset 1", + "state": "RoiSubsetState", + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.5, + "color": "#e31a1c", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 7 + }, + "subsets": [ + "Subset 1" + ] + }, + "Subset 2": { + "_type": "glue.core.subset_group.GroupedSubset", + "group": "Subset 2_0", + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.5, + "color": "#33a02c", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 7 + } + }, + "Subset 2_0": { + "_type": "glue.core.subset_group.SubsetGroup", + "label": "Subset 2", + "state": "RoiSubsetState_0", + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.5, + "color": "#33a02c", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 7 + }, + "subsets": [ + "Subset 2" + ] + }, + "World 0": { + "_type": "glue.core.component_id.ComponentID", + "hidden": true, + "label": "World 0" + }, + "__main__": { + "_type": "glue.app.qt.application.GlueApplication", + "data": "DataCollection", + "plugins": [ + "glue.viewers.scatter" + ], + "session": "Session", + "tab_names": [ + "Tab 1" + ], + "viewers": [ + [ + "ScatterWidget", + "ScatterWidget_0", + "ScatterWidget_1" + ] + ] + }, + "a": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "a" + }, + "b": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "b" + }, + "basic": { + "_key_joins": [], + "_protocol": 5, + "_type": "glue.core.data.Data", + "components": [ + [ + "a", + "Component" + ], + [ + "Pixel Axis 0 [x]", + "CoordinateComponent" + ], + [ + "World 0", + "CoordinateComponent_0" + ], + [ + "b", + "Component_0" + ], + [ + "c", + "Component_1" + ] + ], + "coords": "Coordinates", + "label": "basic", + "primary_owner": [ + "a", + "Pixel Axis 0 [x]", + "World 0", + "b", + "c" + ], + "style": { + "_type": "glue.core.visual.VisualAttributes", + "alpha": 0.8, + "color": "0.35", + "linestyle": "solid", + "linewidth": 1, + "marker": "o", + "markersize": 3 + }, + "subsets": [ + "Subset 1", + "Subset 2" + ], + "uuid": "dde080dc-a65a-4988-bed9-c0cb90ff91a8" + }, + "c": { + "_type": "glue.core.component_id.ComponentID", + "hidden": false, + "label": "c" + } +} \ No newline at end of file diff --git a/glue/viewers/scatter/qt/tests/test_viewer_widget.py b/glue/viewers/scatter/qt/tests/test_viewer_widget.py index a4eb27b46..980702db5 100644 --- a/glue/viewers/scatter/qt/tests/test_viewer_widget.py +++ b/glue/viewers/scatter/qt/tests/test_viewer_widget.py @@ -2,344 +2,303 @@ from __future__ import absolute_import, division, print_function -from distutils.version import LooseVersion # pylint:disable=W0611 +import os import pytest -from mock import patch -from matplotlib import __version__ as mpl_version # pylint:disable=W0611 +from numpy.testing import assert_allclose + +from glue.core import Data +from glue.core.roi import RectangularROI +from glue.core.subset import RoiSubsetState, AndState from glue import core +from glue.core.component_id import ComponentID from glue.core.tests.util import simple_session -from glue.viewers.common.qt.mpl_widget import MplCanvas +from glue.utils.qt import combo_as_string +from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer +from glue.core.state import GlueUnSerializer + +from ..data_viewer import ScatterViewer + +DATA = os.path.join(os.path.dirname(__file__), 'data') -from ..viewer_widget import ScatterWidget +class TestScatterCommon(BaseTestMatplotlibDataViewer): + def init_data(self): + return Data(label='d1', x=[3.4, 2.3, -1.1, 0.3], y=['a', 'b', 'c', 'a']) + viewer_cls = ScatterViewer -class TestScatterWidget(object): + +class TestScatterViewer(object): def setup_method(self, method): - s = simple_session() - self.hub = s.hub - self.d1 = core.Data(x=[1, 2, 3], y=[2, 3, 4], - z=[3, 4, 5], w=[4, 5, 6]) - self.d1.label = 'd1' - self.d2 = core.Data(x=[1, 2, 3], y=[2, 3, 4], - z=[3, 4, 5], w=[4, 5, 6]) - self.d2.label = 'd2' - self.data = [self.d1, self.d2] - self.collect = s.data_collection - self.collect.append(self.data) - self.widget = ScatterWidget(s) - self.session = s - self.connect_to_hub() + + self.data = Data(label='d1', x=[3.4, 2.3, -1.1, 0.3], + y=[3.2, 3.3, 3.4, 3.5], z=['a', 'b', 'c', 'a']) + self.data_2d = Data(label='d2', a=[[1, 2], [3, 4]], b=[[5, 6], [7, 8]]) + + self.session = simple_session() + self.hub = self.session.hub + + self.data_collection = self.session.data_collection + self.data_collection.append(self.data) + self.data_collection.append(self.data_2d) + + self.viewer = ScatterViewer(self.session) + + self.data_collection.register_to_hub(self.hub) + self.viewer.register_to_hub(self.hub) def teardown_method(self, method): - self.assert_widget_synced() - - def assert_widget_synced(self): - cl = self.widget.client - w = self.widget - assert abs(w.xmin - cl.xmin) < 1e-3 - assert abs(w.xmax - cl.xmax) < 1e-3 - assert w.xlog == cl.xlog - assert w.ylog == cl.ylog - assert w.xflip == cl.xflip - assert w.yflip == cl.yflip - assert abs(w.ymin - cl.ymin) < 1e-3 - assert abs(w.ymax - cl.ymax) < 1e-3 - - def connect_to_hub(self): - self.widget.register_to_hub(self.hub) - self.collect.register_to_hub(self.hub) - - def add_layer_via_hub(self): - layer = self.data[0] - layer.label = 'Test Layer' - self.collect.append(layer) - return layer - - def add_layer_via_method(self, index=0): - layer = self.data[index] - self.widget.add_data(layer) - return layer - - def plot_data(self, layer): - """ Return the data bounds for a given layer (data or subset) - Output format: [xmin, xmax], [ymin, ymax] - """ - client = self.widget.client - x, y = client.artists[layer][0].get_data() - assert x.size > 0 - assert y.size > 0 - xmin = x.min() - xmax = x.max() - ymin = y.min() - ymax = y.max() - return [xmin, xmax], [ymin, ymax] - - def plot_limits(self): - """ Return the plot limits - Output format [xmin, xmax], [ymin, ymax] - """ - ax = self.widget.client.axes - xlim = ax.get_xlim() - ylim = ax.get_ylim() - return xlim, ylim - - def assert_layer_inside_limits(self, layer): - """Assert that points of a layer are within plot limits """ - xydata = self.plot_data(layer) - xylimits = self.plot_limits() - assert xydata[0][0] >= xylimits[0][0] - assert xydata[1][0] >= xylimits[1][0] - assert xydata[0][1] <= xylimits[0][1] - assert xydata[1][1] <= xylimits[1][1] - - def is_layer_present(self, layer): - return self.widget.client.is_layer_present(layer) - - def is_layer_visible(self, layer): - return self.widget.client.is_visible(layer) - - def test_rescaled_on_init(self): - layer = self.add_layer_via_method() - self.assert_layer_inside_limits(layer) - - def test_hub_data_add_is_ignored(self): - layer = self.add_layer_via_hub() - assert not self.widget.client.is_layer_present(layer) - - def test_valid_add_data_via_method(self): - layer = self.add_layer_via_method() - assert self.is_layer_present(layer) - - def test_add_first_data_updates_combos(self): - self.add_layer_via_method() - xatt = str(self.widget.ui.xAxisComboBox.currentText()) - yatt = str(self.widget.ui.yAxisComboBox.currentText()) - assert xatt is not None - assert yatt is not None - - def test_flip_x(self): - self.add_layer_via_method() - self.widget.xflip = True - assert self.widget.client.xflip - self.widget.xflip = False - assert not self.widget.client.xflip - - def test_flip_y(self): - self.add_layer_via_method() - self.widget.yflip = True - assert self.widget.client.yflip - self.widget.yflip = False - assert not self.widget.client.yflip - - def test_log_x(self): - self.add_layer_via_method() - self.widget.xlog = True - assert self.widget.client.xlog - self.widget.xlog = False - assert not self.widget.client.xlog - - def test_log_y(self): - self.widget.ylog = True - assert self.widget.client.ylog - self.widget.ylog = False - assert not self.widget.client.ylog - - def test_double_add_ignored(self): - self.add_layer_via_method() - nobj = self.widget.ui.xAxisComboBox.count() - self.add_layer_via_method() - assert self.widget.ui.xAxisComboBox.count() == nobj - - def test_subsets_dont_duplicate_fields(self): - layer = self.add_layer_via_method() - nobj = self.widget.ui.xAxisComboBox.count() - subset = layer.new_subset() - subset.register() - assert self.widget.ui.xAxisComboBox.count() == nobj - - def test_correct_title_single_data(self): - ct = self.widget.client.layer_count - assert ct == 0 - layer = self.add_layer_via_method() - ct = self.widget.client.layer_count - assert ct == 1 - assert len(layer.label) > 0 - assert self.widget.windowTitle() == layer.label - - def test_title_updates_with_label_change(self): - layer = self.add_layer_via_method() - assert layer.hub is self.hub - layer.label = "changed label" - assert self.widget.windowTitle() == layer.label - - def test_title_updates_with_second_data(self): - l1 = self.add_layer_via_method(0) - l2 = self.add_layer_via_method(1) - expected = '%s | %s' % (l1.label, l2.label) - self.widget.windowTitle() == expected - - def test_second_data_add_preserves_plot_variables(self): - self.add_layer_via_method(0) - self.widget.ui.xAxisComboBox.setCurrentIndex(3) - self.widget.ui.yAxisComboBox.setCurrentIndex(2) - self.add_layer_via_method(1) - - assert self.widget.ui.xAxisComboBox.currentIndex() == 3 - assert self.widget.ui.yAxisComboBox.currentIndex() == 2 - - def test_set_limits(self): - self.add_layer_via_method(0) - w = self.widget - c = self.widget.client - ax = self.widget.client.axes - - print(w.xmin, w.xmax, w.ymin, w.ymax) - print(c.xmin, c.xmax, c.ymin, c.ymax) - print(ax.get_xlim(), ax.get_ylim()) - - self.widget.xmax = 20 - print(w.xmin, w.xmax, w.ymin, w.ymax) - print(c.xmin, c.xmax, c.ymin, c.ymax) - print(ax.get_xlim(), ax.get_ylim()) - - self.widget.xmin = 10 - print(w.xmin, w.xmax, w.ymin, w.ymax) - print(c.xmin, c.xmax, c.ymin, c.ymax) - print(ax.get_xlim(), ax.get_ylim()) - - self.widget.ymax = 40 - print(w.xmin, w.xmax, w.ymin, w.ymax) - print(c.xmin, c.xmax, c.ymin, c.ymax) - print(ax.get_xlim(), ax.get_ylim()) - - self.widget.ymin = 30 - print(w.xmin, w.xmax, w.ymin, w.ymax) - print(c.xmin, c.xmax, c.ymin, c.ymax) - print(ax.get_xlim(), ax.get_ylim()) - - assert self.widget.client.axes.get_xlim() == (10, 20) - assert self.widget.client.axes.get_ylim() == (30, 40) - assert float(self.widget.ui.xmin.text()) == 10 - assert float(self.widget.ui.xmax.text()) == 20 - assert float(self.widget.ui.ymin.text()) == 30 - assert float(self.widget.ui.ymax.text()) == 40 - - def test_widget_props_synced_with_client(self): - - self.widget.client.xmax = 100 - assert self.widget.xmax == 100 - self.widget.client.ymax = 200 - assert self.widget.ymax == 200 - - self.widget.client.xmin = 10 - assert self.widget.xmin == 10 - - self.widget.client.ymin = 30 - assert self.widget.ymin == 30 - - @pytest.mark.xfail("LooseVersion(mpl_version) <= LooseVersion('1.1.0')") - def test_labels_sync_with_plot_limits(self): - """For some reason, manually calling draw() doesnt trigger the - draw_event in MPL 1.1.0. Ths functionality nevertheless seems - to work when actually using Glue""" - - self.add_layer_via_method(0) - self.widget.client.axes.set_xlim((3, 4)) - self.widget.client.axes.set_ylim((5, 6)) - - # call MPL draw to force render, not Glue draw - super(MplCanvas, self.widget.client.axes.figure.canvas).draw() - - assert float(self.widget.ui.xmin.text()) == 3 - assert float(self.widget.ui.xmax.text()) == 4 - assert float(self.widget.ui.ymin.text()) == 5 - assert float(self.widget.ui.ymax.text()) == 6 - - def assert_component_present(self, label): - ui = self.widget.ui - for combo in [ui.xAxisComboBox, ui.yAxisComboBox]: - atts = [combo.itemText(i) for i in range(combo.count())] - assert label in atts - - def test_component_change_syncs_with_combo(self): - l1 = self.add_layer_via_method() - l1.add_component(l1[l1.components[0]], 'testing') - self.assert_component_present('testing') - - def test_swap_axes(self): - self.add_layer_via_method() - cl = self.widget.client - cl.xlog, cl.xflip = True, True - cl.ylog, cl.yflip = False, False - - x, y = cl.xatt, cl.yatt - - self.widget.ui.swapAxes.click() - assert (cl.xlog, cl.xflip) == (False, False) - assert (cl.ylog, cl.yflip) == (True, True) - assert (cl.xatt, cl.yatt) == (y, x) - - def test_hidden(self): - self.add_layer_via_method() - xcombo = self.widget.ui.xAxisComboBox - - self.widget.hidden = False - assert xcombo.count() == 4 - self.widget.hidden = True - assert xcombo.count() == 6 - self.widget.hidden = False - assert xcombo.count() == 4 - - def test_add_subset_preserves_plot_variables(self): - self.add_layer_via_method(0) - print(self.widget.client.layer_count) - - self.widget.ui.xAxisComboBox.setCurrentIndex(3) - self.widget.ui.yAxisComboBox.setCurrentIndex(2) - assert self.widget.ui.xAxisComboBox.currentIndex() == 3 - assert self.widget.ui.yAxisComboBox.currentIndex() == 2 - - s = self.data[1].new_subset(label='new') - self.widget.add_subset(s) - - assert self.widget.ui.xAxisComboBox.currentIndex() == 3 - assert self.widget.ui.yAxisComboBox.currentIndex() == 2 - - def test_title_synced_if_data_removed(self): - # regression test for #517 - n0 = self.widget.windowTitle() - self.add_layer_via_method(0) - n1 = self.widget.windowTitle() - assert n1 != n0 - l2 = self.add_layer_via_method(1) - n2 = self.widget.windowTitle() - assert n2 != n1 - self.widget.remove_layer(l2) - assert self.widget.windowTitle() == n1 + self.viewer.close() + + def test_basic(self): + + viewer_state = self.viewer.state + + # Check defaults when we add data + self.viewer.add_data(self.data) + + assert combo_as_string(self.viewer.options_widget().ui.combodata_x_att) == 'x:y:z' + assert combo_as_string(self.viewer.options_widget().ui.combodata_y_att) == 'x:y:z' + + assert viewer_state.x_att is self.data.id['x'] + assert viewer_state.x_min == -1.1 + assert viewer_state.x_max == 3.4 + + assert viewer_state.y_att is self.data.id['y'] + assert viewer_state.y_min == 3.2 + assert viewer_state.y_max == 3.5 + + assert not viewer_state.x_log + assert not viewer_state.y_log + + assert len(viewer_state.layers) == 1 + + # Change to categorical component and check new values + + viewer_state.y_att = self.data.id['z'] + + assert viewer_state.x_att is self.data.id['x'] + assert viewer_state.x_min == -1.1 + assert viewer_state.x_max == 3.4 + + assert viewer_state.y_att is self.data.id['z'] + assert viewer_state.y_min == -0.5 + assert viewer_state.y_max == 2.5 + + assert not viewer_state.x_log + assert not viewer_state.y_log + + def test_flip(self): + + viewer_state = self.viewer.state + + self.viewer.add_data(self.data) + + assert viewer_state.x_min == -1.1 + assert viewer_state.x_max == 3.4 + + self.viewer.options_widget().button_flip_x.click() + + assert viewer_state.x_min == 3.4 + assert viewer_state.x_max == -1.1 + + assert viewer_state.y_min == 3.2 + assert viewer_state.y_max == 3.5 + + self.viewer.options_widget().button_flip_y.click() + + assert viewer_state.y_min == 3.5 + assert viewer_state.y_max == 3.2 + + def test_remove_data(self): + self.viewer.add_data(self.data) + assert combo_as_string(self.viewer.options_widget().ui.combodata_x_att) == 'x:y:z' + assert combo_as_string(self.viewer.options_widget().ui.combodata_y_att) == 'x:y:z' + self.data_collection.remove(self.data) + assert combo_as_string(self.viewer.options_widget().ui.combodata_x_att) == '' + assert combo_as_string(self.viewer.options_widget().ui.combodata_y_att) == '' + + def test_update_component_updates_title(self): + self.viewer.add_data(self.data) + assert self.viewer.windowTitle() == '2D Scatter' + self.viewer.state.x_att = self.data.id['y'] + assert self.viewer.windowTitle() == '2D Scatter' + + def test_combo_updates_with_component_add(self): + self.viewer.add_data(self.data) + self.data.add_component([3, 4, 1, 2], 'a') + assert self.viewer.state.x_att is self.data.id['x'] + assert self.viewer.state.y_att is self.data.id['y'] + assert combo_as_string(self.viewer.options_widget().ui.combodata_x_att) == 'x:y:z:a' + assert combo_as_string(self.viewer.options_widget().ui.combodata_y_att) == 'x:y:z:a' + + def test_nonnumeric_first_component(self): + # regression test for #208. Shouldn't complain if + # first component is non-numerical + data = core.Data() + data.add_component(['a', 'b', 'c'], label='c1') + data.add_component([1, 2, 3], label='c2') + self.data_collection.append(data) + self.viewer.add_data(data) + + def test_apply_roi(self): + + self.viewer.add_data(self.data) + + roi = RectangularROI(0, 3, 3.25, 3.45) + + assert len(self.viewer.layers) == 1 + + self.viewer.apply_roi(roi) + + assert len(self.viewer.layers) == 2 + assert len(self.data.subsets) == 1 + + assert_allclose(self.data.subsets[0].to_mask(), [0, 1, 0, 0]) + + state = self.data.subsets[0].subset_state + assert isinstance(state, RoiSubsetState) + + def test_apply_roi_categorical(self): + + viewer_state = self.viewer.state + + self.viewer.add_data(self.data) + + viewer_state.y_att = self.data.id['z'] + + roi = RectangularROI(0, 3, -0.4, 0.3) + + assert len(self.viewer.layers) == 1 + + self.viewer.apply_roi(roi) + + assert len(self.viewer.layers) == 2 + assert len(self.data.subsets) == 1 + + assert_allclose(self.data.subsets[0].to_mask(), [0, 0, 0, 1]) + + state = self.data.subsets[0].subset_state + assert isinstance(state, AndState) + + def test_axes_labels(self): + + viewer_state = self.viewer.state + + self.viewer.add_data(self.data) + + assert self.viewer.axes.get_xlabel() == 'x' + assert self.viewer.axes.get_ylabel() == 'y' + + viewer_state.x_log = True + + assert self.viewer.axes.get_xlabel() == 'Log x' + assert self.viewer.axes.get_ylabel() == 'y' + + viewer_state.x_att = self.data.id['y'] + + assert self.viewer.axes.get_xlabel() == 'y' + assert self.viewer.axes.get_ylabel() == 'y' + + viewer_state.y_log = True + + assert self.viewer.axes.get_xlabel() == 'y' + assert self.viewer.axes.get_ylabel() == 'Log y' + + def test_component_replaced(self): + + # regression test for 508 - if a component ID is replaced, we should + # make sure that the component ID is selected if the old component ID + # was selected + + self.viewer.add_data(self.data) + self.viewer.state.x_att = self.data.components[0] + test = ComponentID('test') + self.data.update_id(self.viewer.state.x_att, test) + assert self.viewer.state.x_att is test + assert combo_as_string(self.viewer.options_widget().ui.combodata_x_att) == 'test:y:z' + + @pytest.mark.parametrize('protocol', [0]) + def test_session_back_compat(self, protocol): + + filename = os.path.join(DATA, 'scatter_v{0}.glu'.format(protocol)) + + with open(filename, 'r') as f: + session = f.read() + + state = GlueUnSerializer.loads(session) + + ga = state.object('__main__') + + dc = ga.session.data_collection + + assert len(dc) == 1 + + assert dc[0].label == 'basic' + + viewer1 = ga.viewers[0][0] + assert len(viewer1.state.layers) == 3 + assert viewer1.state.x_att is dc[0].id['a'] + assert viewer1.state.y_att is dc[0].id['b'] + assert_allclose(viewer1.state.x_min, -1.04) + assert_allclose(viewer1.state.x_max, 1.04) + assert_allclose(viewer1.state.y_min, 1.98) + assert_allclose(viewer1.state.y_max, 3.02) + assert not viewer1.state.x_log + assert not viewer1.state.y_log + assert viewer1.state.layers[0].visible + assert viewer1.state.layers[1].visible + assert viewer1.state.layers[2].visible + + viewer2 = ga.viewers[0][1] + assert len(viewer2.state.layers) == 3 + assert viewer2.state.x_att is dc[0].id['a'] + assert viewer2.state.y_att is dc[0].id['c'] + assert_allclose(viewer2.state.x_min, 9.5e-6) + assert_allclose(viewer2.state.x_max, 1.05) + assert_allclose(viewer2.state.y_min, 0.38) + assert_allclose(viewer2.state.y_max, 5.25) + assert viewer2.state.x_log + assert viewer2.state.y_log + assert viewer2.state.layers[0].visible + assert not viewer2.state.layers[1].visible + assert viewer2.state.layers[2].visible + + viewer3 = ga.viewers[0][2] + assert len(viewer3.state.layers) == 3 + assert viewer3.state.x_att is dc[0].id['b'] + assert viewer3.state.y_att is dc[0].id['a'] + assert_allclose(viewer3.state.x_min, 0) + assert_allclose(viewer3.state.x_max, 5) + assert_allclose(viewer3.state.y_min, -5) + assert_allclose(viewer3.state.y_max, 5) + assert not viewer3.state.x_log + assert not viewer3.state.y_log + assert viewer3.state.layers[0].visible + assert viewer3.state.layers[1].visible + assert not viewer3.state.layers[2].visible def test_save_svg(self, tmpdir): # Regression test for a bug in AxesCache that caused SVG saving to # fail (because renderer.buffer_rgba did not exist) + self.viewer.add_data(self.data) filename = tmpdir.join('test.svg').strpath - self.widget.client.axes.figure.savefig(filename) + self.viewer.axes.figure.savefig(filename) + + def test_2d(self): + viewer_state = self.viewer.state -class TestDrawCount(TestScatterWidget): + self.viewer.add_data(self.data_2d) - def patch_draw(self): - return patch('glue.viewers.common.qt.mpl_widget.MplCanvas.draw') + assert viewer_state.x_att is self.data_2d.id['a'] + assert viewer_state.x_min == 1 + assert viewer_state.x_max == 4 - def test_xatt_redraws_once(self): - self.add_layer_via_method() - with self.patch_draw() as draw: - self.widget.yatt = self.widget.xatt - assert draw.call_count == 1 + assert viewer_state.y_att is self.data_2d.id['b'] + assert viewer_state.y_min == 5 + assert viewer_state.y_max == 8 - def test_swap_redraws_once(self): - self.add_layer_via_method() - with self.patch_draw() as draw: - self.widget.swap_axes() - assert draw.call_count == 1 + assert len(self.viewer.layers[0].mpl_artists) == 1 diff --git a/glue/viewers/scatter/qt/viewer_widget.py b/glue/viewers/scatter/qt/viewer_widget.py deleted file mode 100644 index b4c90d9af..000000000 --- a/glue/viewers/scatter/qt/viewer_widget.py +++ /dev/null @@ -1,281 +0,0 @@ -from __future__ import absolute_import, division, print_function - -import os - -from qtpy.QtCore import Qt -from qtpy import QtWidgets -from glue import core -from glue.viewers.scatter.client import ScatterClient -from glue.viewers.common.qt.mpl_toolbar import MatplotlibViewerToolbar -from glue.viewers.common.qt.mouse_mode import (RectangleMode, CircleMode, - PolyMode, HRangeMode, VRangeMode) -from glue.utils.qt import load_ui -from glue.viewers.common.qt.data_viewer import DataViewer -from glue.viewers.common.qt.mpl_widget import MplWidget, defer_draw -from glue.viewers.scatter.qt.layer_style_widget import ScatterLayerStyleWidget -from glue.viewers.scatter.layer_artist import ScatterLayerArtist -from glue.utils import nonpartial, cache_axes -from glue.utils.qt.widget_properties import (ButtonProperty, FloatLineProperty, - CurrentComboProperty, - connect_bool_button, connect_float_edit) - -__all__ = ['ScatterWidget'] - -WARN_SLOW = 1000000 # max number of points which render quickly - - -class ScatterWidget(DataViewer): - - """ - An interactive scatter plot. - """ - - LABEL = "2D Scatter Plot" - _property_set = DataViewer._property_set + \ - 'xlog ylog xflip yflip hidden xatt yatt xmin xmax ymin ymax'.split() - - xlog = ButtonProperty('ui.xLogCheckBox', 'log scaling on x axis?') - ylog = ButtonProperty('ui.yLogCheckBox', 'log scaling on y axis?') - xflip = ButtonProperty('ui.xFlipCheckBox', 'invert the x axis?') - yflip = ButtonProperty('ui.yFlipCheckBox', 'invert the y axis?') - xmin = FloatLineProperty('ui.xmin', 'Lower x limit of plot') - xmax = FloatLineProperty('ui.xmax', 'Upper x limit of plot') - ymin = FloatLineProperty('ui.ymin', 'Lower y limit of plot') - ymax = FloatLineProperty('ui.ymax', 'Upper y limit of plot') - hidden = ButtonProperty('ui.hidden_attributes', 'Show hidden attributes') - xatt = CurrentComboProperty('ui.xAxisComboBox', - 'Attribute to plot on x axis') - yatt = CurrentComboProperty('ui.yAxisComboBox', - 'Attribute to plot on y axis') - - _layer_style_widget_cls = {ScatterLayerArtist: ScatterLayerStyleWidget} - - _toolbar_cls = MatplotlibViewerToolbar - tools = ['select:rectangle', 'select:xrange', 'select:yrange', 'select:circle', 'select:polygon'] - - def __init__(self, session, parent=None): - - super(ScatterWidget, self).__init__(session, parent) - - self.central_widget = MplWidget() - self.setCentralWidget(self.central_widget) - - self.option_widget = QtWidgets.QWidget() - self.ui = load_ui('options_widget.ui', self.option_widget, - directory=os.path.dirname(__file__)) - - self._tweak_geometry() - - self.client = ScatterClient(self._data, - self.central_widget.canvas.fig, - layer_artist_container=self._layer_artist_container) - - self._connect() - self.unique_fields = set() - self.statusBar().setSizeGripEnabled(False) - self.setFocusPolicy(Qt.StrongFocus) - - def initialize_toolbar(self): - super(ScatterWidget, self).initialize_toolbar() - cache_axes(self.client.axes, self.toolbar) - - def _tweak_geometry(self): - self.central_widget.resize(600, 400) - self.resize(self.central_widget.size()) - - def _connect(self): - ui = self.ui - cl = self.client - - connect_bool_button(cl, 'xlog', ui.xLogCheckBox) - connect_bool_button(cl, 'ylog', ui.yLogCheckBox) - connect_bool_button(cl, 'xflip', ui.xFlipCheckBox) - connect_bool_button(cl, 'yflip', ui.yFlipCheckBox) - - ui.xAxisComboBox.currentIndexChanged.connect(self.update_xatt) - ui.yAxisComboBox.currentIndexChanged.connect(self.update_yatt) - ui.hidden_attributes.toggled.connect(lambda x: self._update_combos()) - ui.swapAxes.clicked.connect(nonpartial(self.swap_axes)) - ui.snapLimits.clicked.connect(cl.snap) - - connect_float_edit(cl, 'xmin', ui.xmin) - connect_float_edit(cl, 'xmax', ui.xmax) - connect_float_edit(cl, 'ymin', ui.ymin) - connect_float_edit(cl, 'ymax', ui.ymax) - - @defer_draw - def _update_combos(self): - """ Update contents of combo boxes """ - - # have to be careful here, since client and/or widget - # are potentially out of sync - - layer_ids = [] - - # show hidden attributes if needed - if ((self.client.xatt and self.client.xatt.hidden) or - (self.client.yatt and self.client.yatt.hidden)): - self.hidden = True - - # determine which components to put in combos - for l in self.client.data: - if not self.client.is_layer_present(l): - continue - for lid in self.client.plottable_attributes( - l, show_hidden=self.hidden): - if lid not in layer_ids: - layer_ids.append(lid) - - oldx = self.xatt - oldy = self.yatt - newx = self.client.xatt or oldx - newy = self.client.yatt or oldy - - for combo, target in zip([self.ui.xAxisComboBox, self.ui.yAxisComboBox], - [newx, newy]): - combo.blockSignals(True) - combo.clear() - - if not layer_ids: # empty component list - continue - - # populate - for lid in layer_ids: - combo.addItem(lid.label, userData=lid) - - idx = layer_ids.index(target) if target in layer_ids else 0 - combo.setCurrentIndex(idx) - - combo.blockSignals(False) - - # ensure client and widget synced - self.client.xatt = self.xatt - self.client.lyatt = self.yatt - - @defer_draw - def add_data(self, data): - """Add a new data set to the widget - - :returns: True if the addition was expected, False otherwise - """ - if self.client.is_layer_present(data): - return - - if data.size > WARN_SLOW and not self._confirm_large_data(data): - return False - - first_layer = self.client.layer_count == 0 - - self.client.add_data(data) - self._update_combos() - - if first_layer: # forces both x and y axes to be rescaled - self.update_xatt(None) - self.update_yatt(None) - - self.ui.xAxisComboBox.setCurrentIndex(0) - if len(data.visible_components) > 1: - self.ui.yAxisComboBox.setCurrentIndex(1) - else: - self.ui.yAxisComboBox.setCurrentIndex(0) - - self.update_window_title() - return True - - @defer_draw - def add_subset(self, subset): - """Add a subset to the widget - - :returns: True if the addition was accepted, False otherwise - """ - if self.client.is_layer_present(subset): - return - - data = subset.data - if data.size > WARN_SLOW and not self._confirm_large_data(data): - return False - - first_layer = self.client.layer_count == 0 - - self.client.add_layer(subset) - self._update_combos() - - if first_layer: # forces both x and y axes to be rescaled - self.update_xatt(None) - self.update_yatt(None) - - self.ui.xAxisComboBox.setCurrentIndex(0) - if len(data.visible_components) > 1: - self.ui.yAxisComboBox.setCurrentIndex(1) - else: - self.ui.yAxisComboBox.setCurrentIndex(0) - - self.update_window_title() - return True - - def register_to_hub(self, hub): - super(ScatterWidget, self).register_to_hub(hub) - self.client.register_to_hub(hub) - hub.subscribe(self, core.message.DataUpdateMessage, - nonpartial(self._sync_labels)) - hub.subscribe(self, core.message.ComponentsChangedMessage, - nonpartial(self._update_combos)) - hub.subscribe(self, core.message.ComponentReplacedMessage, - self._on_component_replace) - - def _on_component_replace(self, msg): - # let client update its state first - self.client._on_component_replace(msg) - self._update_combos() - - def unregister(self, hub): - super(ScatterWidget, self).unregister(hub) - hub.unsubscribe_all(self.client) - hub.unsubscribe_all(self) - - @defer_draw - def swap_axes(self): - xid = self.ui.xAxisComboBox.currentIndex() - yid = self.ui.yAxisComboBox.currentIndex() - xlog = self.ui.xLogCheckBox.isChecked() - ylog = self.ui.yLogCheckBox.isChecked() - xflip = self.ui.xFlipCheckBox.isChecked() - yflip = self.ui.yFlipCheckBox.isChecked() - - self.ui.xAxisComboBox.setCurrentIndex(yid) - self.ui.yAxisComboBox.setCurrentIndex(xid) - self.ui.xLogCheckBox.setChecked(ylog) - self.ui.yLogCheckBox.setChecked(xlog) - self.ui.xFlipCheckBox.setChecked(yflip) - self.ui.yFlipCheckBox.setChecked(xflip) - - @defer_draw - def update_xatt(self, index): - component_id = self.xatt - self.client.xatt = component_id - - @defer_draw - def update_yatt(self, index): - component_id = self.yatt - self.client.yatt = component_id - - @property - def window_title(self): - data = self.client.data - label = ', '.join([d.label for d in data if - self.client.is_visible(d)]) - return label - - def _sync_labels(self): - self.update_window_title() - - def options_widget(self): - return self.option_widget - - @defer_draw - def restore_layers(self, rec, context): - self.client.restore_layers(rec, context) - self._update_combos() - # manually force client attributes to sync - self.update_xatt(None) - self.update_yatt(None) diff --git a/glue/viewers/scatter/state.py b/glue/viewers/scatter/state.py new file mode 100644 index 000000000..bc8608fa2 --- /dev/null +++ b/glue/viewers/scatter/state.py @@ -0,0 +1,76 @@ +from __future__ import absolute_import, division, print_function + +from glue.core import Data + +from glue.viewers.matplotlib.state import (MatplotlibDataViewerState, + MatplotlibLayerState, + DeferredDrawCallbackProperty) +from glue.core.state_objects import StateAttributeLimitsHelper +from glue.external.echo import keep_in_sync + +__all__ = ['ScatterViewerState', 'ScatterLayerState'] + + +class ScatterViewerState(MatplotlibDataViewerState): + + x_att = DeferredDrawCallbackProperty() + y_att = DeferredDrawCallbackProperty() + + def __init__(self, **kwargs): + + super(ScatterViewerState, self).__init__(**kwargs) + + self.limits_cache = {} + + self.x_att_helper = StateAttributeLimitsHelper(self, attribute='x_att', + lower='x_min', upper='x_max', + log='x_log', + limits_cache=self.limits_cache) + + self.y_att_helper = StateAttributeLimitsHelper(self, attribute='y_att', + lower='y_min', upper='y_max', + log='y_log', + limits_cache=self.limits_cache) + + def update_priority(self, name): + if name == 'layers': + return 2 + elif name.endswith(('_min', '_max')): + return 0 + else: + return 1 + + def flip_x(self): + self.x_att_helper.flip_limits() + + def flip_y(self): + self.y_att_helper.flip_limits() + + def _get_x_components(self): + return self._get_components(self.x_att) + + def _get_y_components(self): + return self._get_components(self.y_att) + + def _get_components(self, cid): + # Construct list of components over all layers + components = [] + for layer_state in self.layers: + if isinstance(layer_state.layer, Data): + components.append(layer_state.layer.get_component(cid)) + else: + components.append(layer_state.layer.data.get_component(cid)) + return components + + +class ScatterLayerState(MatplotlibLayerState): + + size = DeferredDrawCallbackProperty() + + def __init__(self, viewer_state=None, **kwargs): + + super(ScatterLayerState, self).__init__(viewer_state=viewer_state, **kwargs) + + self.size = self.layer.style.markersize + + self._sync_size = keep_in_sync(self, 'size', self.layer.style, 'markersize') diff --git a/glue/viewers/scatter/tests/test_client.py b/glue/viewers/scatter/tests/test_client.py deleted file mode 100644 index e356b7543..000000000 --- a/glue/viewers/scatter/tests/test_client.py +++ /dev/null @@ -1,755 +0,0 @@ -# pylint: disable=I0011,W0613,W0201,W0212,E1101,E1103 - -from __future__ import absolute_import, division, print_function - -from timeit import timeit -from functools import partial - -import pytest -import numpy as np -from mock import MagicMock -from matplotlib.ticker import AutoLocator, MaxNLocator, LogLocator -from matplotlib.ticker import LogFormatterMathtext, ScalarFormatter, FuncFormatter - -from glue.core.edit_subset_mode import EditSubsetMode -from glue.core.component_id import ComponentID -from glue.core.component import Component, CategoricalComponent -from glue.core.data_collection import DataCollection -from glue.core.data import Data -from glue.core.roi import RectangularROI, XRangeROI, YRangeROI -from glue.core.subset import (RangeSubsetState, CategoricalROISubsetState, - AndState) -from glue.tests import example_data -from glue.utils import renderless_figure - -from ..client import ScatterClient - - -# share matplotlib instance, and disable rendering, for speed -FIGURE = renderless_figure() - - -class TestScatterClient(object): - - def setup_method(self, method): - self.data = example_data.test_data() - self.ids = [self.data[0].find_component_id('a'), - self.data[0].find_component_id('b'), - self.data[1].find_component_id('c'), - self.data[1].find_component_id('d')] - self.roi_limits = (0.5, 0.5, 1.5, 1.5) - self.roi_points = (np.array([1]), np.array([1])) - self.collect = DataCollection() - EditSubsetMode().data_collection = self.collect - - self.hub = self.collect.hub - - FIGURE.clf() - axes = FIGURE.add_subplot(111) - self.client = ScatterClient(self.collect, axes=axes) - - self.connect() - - def teardown_method(self, methdod): - self.assert_properties_correct() - self.assert_axes_ticks_correct() - - def assert_properties_correct(self): - ax = self.client.axes - cl = self.client - xlim = ax.get_xlim() - ylim = ax.get_ylim() - assert abs(cl.xmin - min(xlim)) < 1e-2 - assert abs(cl.xmax - max(xlim)) < 1e-2 - assert abs(cl.ymin - min(ylim)) < 1e-2 - assert abs(cl.ymax - max(ylim)) < 1e-2 - assert cl.xflip == (xlim[1] < xlim[0]) - assert cl.yflip == (ylim[1] < ylim[0]) - assert cl.xlog == (ax.get_xscale() == 'log') - assert cl.ylog == (ax.get_yscale() == 'log') - assert (self.client.xatt is None) or isinstance( - self.client.xatt, ComponentID) - assert (self.client.yatt is None) or isinstance( - self.client.yatt, ComponentID) - - def check_ticks(self, axis, is_log, is_cat): - locator = axis.get_major_locator() - formatter = axis.get_major_formatter() - if is_log: - assert isinstance(locator, LogLocator) - assert isinstance(formatter, LogFormatterMathtext) - elif is_cat: - assert isinstance(locator, MaxNLocator) - assert isinstance(formatter, FuncFormatter) - else: - assert isinstance(locator, AutoLocator) - assert isinstance(formatter, ScalarFormatter) - - def assert_axes_ticks_correct(self): - ax = self.client.axes - client = self.client - if client.xatt is not None: - self.check_ticks(ax.xaxis, - client.xlog, - client._check_categorical(client.xatt)) - if client.yatt is not None: - self.check_ticks(ax.yaxis, - client.ylog, - client._check_categorical(client.yatt)) - - def plot_data(self, layer): - """ Return the data bounds for a given layer (data or subset) - Output format: [xmin, xmax], [ymin, ymax] - """ - client = self.client - x, y = client.artists[layer][0].get_data() - xmin = x.min() - xmax = x.max() - ymin = y.min() - ymax = y.max() - return [xmin, xmax], [ymin, ymax] - - def plot_limits(self): - """ Return the plot limits - Output format [xmin, xmax], [ymin, ymax] - """ - ax = self.client.axes - xlim = ax.get_xlim() - ylim = ax.get_ylim() - return (min(xlim), max(xlim)), (min(ylim), max(ylim)) - - def assert_layer_inside_limits(self, layer): - """Assert that points of a layer are within plot limits """ - xydata = self.plot_data(layer) - xylimits = self.plot_limits() - assert xydata[0][0] >= xylimits[0][0] - assert xydata[1][0] >= xylimits[1][0] - assert xydata[0][1] <= xylimits[0][1] - assert xydata[1][1] <= xylimits[1][1] - - def setup_2d_data(self): - d = Data(x=[[1, 2], [3, 4]], y=[[2, 4], [6, 8]]) - self.collect.append(d) - self.client.add_layer(d) - self.client.xatt = d.id['x'] - self.client.yatt = d.id['y'] - return d - - def add_data(self, data=None): - if data is None: - data = self.data[0] - data.edit_subset = data.new_subset() - self.collect.append(data) - self.client.add_data(data) - return data - - def add_data_and_attributes(self): - data = self.add_data() - data.edit_subset = data.new_subset() - self.client.xatt = self.ids[0] - self.client.yatt = self.ids[1] - return data - - def is_first_in_front(self, front, back): - z1 = self.client.get_layer_order(front) - z2 = self.client.get_layer_order(back) - return z1 > z2 - - def connect(self): - self.client.register_to_hub(self.hub) - self.collect.register_to_hub(self.hub) - - def layer_drawn(self, layer): - return self.client.is_layer_present(layer) and \ - all(a.enabled and a.visible for a in self.client.artists[layer]) - - def layer_data_correct(self, layer, x, y): - xx, yy = self.client.artists[layer][0].get_data() - if max(abs(xx - x)) > .01: - return False - if max(abs(yy - y)) > .01: - return False - return True - - def test_empty_on_creation(self): - for d in self.data: - assert not self.client.is_layer_present(d) - - def test_add_external_data_raises_exception(self): - data = Data() - with pytest.raises(TypeError) as exc: - self.client.add_data(data) - assert exc.value.args[0] == "Layer not in data collection" - - def test_valid_add(self): - self.add_data() - assert self.client.is_layer_present(self.data[0]) - - def test_axis_labels_sync_with_setters(self): - self.add_data() - self.client.xatt = self.ids[1] - assert self.client.axes.get_xlabel() == self.ids[1].label - self.client.yatt = self.ids[0] - assert self.client.axes.get_ylabel() == self.ids[0].label - - def test_setters_require_componentID(self): - self.add_data() - with pytest.raises(TypeError): - self.client.xatt = self.ids[1]._label - self.client.xatt = self.ids[1] - - def test_logs(self): - self.add_data() - self.client.xlog = True - assert self.client.axes.get_xscale() == 'log' - - self.client.xlog = False - assert self.client.axes.get_xscale() == 'linear' - - self.client.ylog = True - assert self.client.axes.get_yscale() == 'log' - - self.client.ylog = False - assert self.client.axes.get_yscale() == 'linear' - - def test_flips(self): - self.add_data() - - self.client.xflip = True - self.assert_flips(True, False) - - self.client.xflip = False - self.assert_flips(False, False) - - self.client.yflip = True - self.assert_flips(False, True) - - self.client.yflip = False - self.assert_flips(False, False) - - def assert_flips(self, xflip, yflip): - ax = self.client.axes - xlim = ax.get_xlim() - ylim = ax.get_ylim() - assert (xlim[1] < xlim[0]) == xflip - assert (ylim[1] < ylim[0]) == yflip - - def test_double_add(self): - n0 = len(self.client.axes.lines) - layer = self.add_data_and_attributes() - # data present - assert len(self.client.axes.lines) == n0 + 1 + len(layer.subsets) - layer = self.add_data() - # data still present - assert len(self.client.axes.lines) == n0 + 1 + len(layer.subsets) - - def test_data_updates_propagate(self): - layer = self.add_data_and_attributes() - assert self.layer_drawn(layer) - self.client._layer_updated = False - layer.style.color = 'k' - assert self.client._layer_updated - - def test_data_removal(self): - layer = self.add_data() - subset = layer.new_subset() - self.collect.remove(layer) - assert not self.client.is_layer_present(layer) - assert not self.client.is_layer_present(subset) - - def test_add_subset_while_connected(self): - layer = self.add_data() - subset = layer.new_subset() - assert self.client.is_layer_present(subset) - - def test_subset_removal(self): - layer = self.add_data() - subset = layer.new_subset() - assert self.client.is_layer_present(layer) - subset.delete() - assert not self.client.is_layer_present(subset) - - def test_subset_removal_removes_from_plot(self): - layer = self.add_data_and_attributes() - subset = layer.new_subset() - ct0 = len(self.client.axes.lines) - subset.delete() - assert len(self.client.axes.lines) == ct0 - 1 - - def test_add_subset_to_untracked_data(self): - subset = self.data[0].new_subset() - assert not self.client.is_layer_present(subset) - - def test_valid_plot_data(self): - layer = self.add_data_and_attributes() - x = layer[self.ids[0]] - y = layer[self.ids[1]] - assert self.layer_data_correct(layer, x, y) - - def test_attribute_update_plot_data(self): - layer = self.add_data_and_attributes() - x = layer[self.ids[0]] - y = layer[self.ids[0]] - self.client.yatt = self.ids[0] - assert self.layer_data_correct(layer, x, y) - - def test_invalid_plot(self): - layer = self.add_data_and_attributes() - assert self.layer_drawn(layer) - c = ComponentID('bad id') - self.client.xatt = c - assert not self.layer_drawn(layer) - self.client.xatt = self.ids[0] - - def test_redraw_called_on_invalid_plot(self): - """ Plot should be updated when given invalid data, - to sync layers' disabled/invisible states""" - ctr = MagicMock() - layer = self.add_data_and_attributes() - assert self.layer_drawn(layer) - c = ComponentID('bad id') - self.client._redraw = ctr - ct0 = ctr.call_count - self.client.xatt = c - ct1 = ctr.call_count - ncall = ct1 - ct0 - expected = len(self.client.artists) - assert ncall >= expected - self.client.xatt = self.ids[0] - - def test_two_incompatible_data(self): - d0 = self.add_data(self.data[0]) - d1 = self.add_data(self.data[1]) - self.client.xatt = self.ids[0] - self.client.yatt = self.ids[1] - x = d0[self.ids[0]] - y = d0[self.ids[1]] - assert self.layer_drawn(d0) - assert self.layer_data_correct(d0, x, y) - assert not self.layer_drawn(d1) - - self.client.xatt = self.ids[2] - self.client.yatt = self.ids[3] - x = d1[self.ids[2]] - y = d1[self.ids[3]] - assert self.layer_drawn(d1) - assert self.layer_data_correct(d1, x, y) - assert not self.layer_drawn(d0) - - def test_subsets_connect_with_data(self): - data = self.data[0] - s1 = data.new_subset() - s2 = data.new_subset() - self.collect.append(data) - self.client.add_data(data) - assert self.client.is_layer_present(s1) - assert self.client.is_layer_present(s2) - assert self.client.is_layer_present(data) - - # should also work with add_layer - self.collect.remove(data) - assert data not in self.collect - assert not self.client.is_layer_present(s1) - self.collect.append(data) - self.client.add_layer(data) - assert self.client.is_layer_present(s1) - - def test_edit_subset_connect_with_data(self): - data = self.add_data() - assert self.client.is_layer_present(data.edit_subset) - - def test_edit_subset_removed_with_data(self): - data = self.add_data() - self.collect.remove(data) - assert not self.client.is_layer_present(data.edit_subset) - - def test_apply_roi(self): - data = self.add_data_and_attributes() - roi = RectangularROI() - roi.update_limits(*self.roi_limits) - x, y = self.roi_points - self.client.apply_roi(roi) - assert self.layer_data_correct(data.edit_subset, x, y) - - def test_apply_roi_adds_on_empty(self): - data = self.add_data_and_attributes() - data._subsets = [] - data.edit_subset = None - roi = RectangularROI() - roi.update_limits(*self.roi_limits) - x, y = self.roi_points - self.client.apply_roi(roi) - assert data.edit_subset is not None - - def test_apply_roi_applies_to_all_editable_subsets(self): - d1 = self.add_data_and_attributes() - d2 = self.add_data() - state1 = d1.edit_subset.subset_state - state2 = d2.edit_subset.subset_state - roi = RectangularROI() - roi.update_limits(*self.roi_limits) - x, y = self.roi_points - self.client.apply_roi(roi) - assert d1.edit_subset.subset_state is not state1 - assert d1.edit_subset.subset_state is not state2 - - def test_apply_roi_doesnt_add_if_any_selection(self): - d1 = self.add_data_and_attributes() - d2 = self.add_data() - d1.edit_subset = None - d2.edit_subset = d2.new_subset() - ct = len(d1.subsets) - roi = RectangularROI() - roi.update_limits(*self.roi_limits) - x, y = self.roi_points - self.client.apply_roi(roi) - assert len(d1.subsets) == ct - - def test_subsets_drawn_over_data(self): - data = self.add_data_and_attributes() - subset = data.new_subset() - assert self.is_first_in_front(subset, data) - - def test_log_sticky(self): - self.add_data_and_attributes() - self.assert_logs(False, False) - - self.client.xlog = True - self.client.ylog = True - self.assert_logs(True, True) - - self.client.xatt = self.ids[1] - self.client.yatt = self.ids[0] - self.assert_logs(True, True) - - def test_log_ticks(self): - # regression test for 354 - self.add_data_and_attributes() - self.assert_logs(False, False) - - self.client.xlog = True - - self.client.yatt = self.ids[0] - - self.assert_logs(True, False) - assert not isinstance(self.client.axes.yaxis.get_major_locator(), - LogLocator) - - def assert_logs(self, xlog, ylog): - ax = self.client.axes - assert ax.get_xscale() == ('log' if xlog else 'linear') - assert ax.get_yscale() == ('log' if ylog else 'linear') - - def test_flip_sticky(self): - self.add_data_and_attributes() - self.client.xflip = True - self.assert_flips(True, False) - self.client.xatt = self.ids[1] - self.assert_flips(True, False) - self.client.xatt = self.ids[0] - self.assert_flips(True, False) - - def test_visibility_sticky(self): - data = self.add_data_and_attributes() - roi = RectangularROI() - roi.update_limits(*self.roi_limits) - assert self.client.is_visible(data.edit_subset) - self.client.apply_roi(roi) - self.client.set_visible(data.edit_subset, False) - assert not self.client.is_visible(data.edit_subset) - self.client.apply_roi(roi) - assert not self.client.is_visible(data.edit_subset) - - def test_2d_data(self): - """Should be abple to plot 2d data""" - data = self.setup_2d_data() - assert self.layer_data_correct(data, [1, 2, 3, 4], [2, 4, 6, 8]) - - def test_2d_data_limits_with_subset(self): - """visible limits should work with subsets and 2d data""" - d = self.setup_2d_data() - state = d.id['x'] > 2 - s = d.new_subset() - s.subset_state = state - assert self.client._visible_limits(0) == (1, 4) - assert self.client._visible_limits(1) == (2, 8) - - def test_limits_nans(self): - d = Data() - x = Component(np.array([[1, 2], [np.nan, 4]])) - y = Component(np.array([[2, 4], [np.nan, 8]])) - xid = d.add_component(x, 'x') - yid = d.add_component(y, 'y') - self.collect.append(d) - self.client.add_layer(d) - self.client.xatt = xid - self.client.yatt = yid - - assert self.client._visible_limits(0) == (1, 4) - assert self.client._visible_limits(1) == (2, 8) - - def test_limits_inf(self): - d = Data() - x = Component(np.array([[1, 2], [np.infty, 4]])) - y = Component(np.array([[2, 4], [-np.infty, 8]])) - xid = d.add_component(x, 'x') - yid = d.add_component(y, 'y') - self.collect.append(d) - self.client.add_layer(d) - self.client.xatt = xid - self.client.yatt = yid - - assert self.client._visible_limits(0) == (1, 4) - assert self.client._visible_limits(1) == (2, 8) - - def test_xlog_relimits_if_negative(self): - self.add_data_and_attributes() - self.client.xflip = False - self.client.xlog = False - - self.client.axes.set_xlim(-5, 5) - self.client.xlog = True - assert self.client.axes.get_xlim()[0] > .9 - - def test_ylog_relimits_if_negative(self): - self.add_data_and_attributes() - self.client.yflip = False - self.client.ylog = False - self.client.axes.set_ylim(-5, 5) - - self.client.ylog = True - assert self.client.axes.get_ylim()[0] > .9 - - def test_subset_added_only_if_data_layer_present(self): - self.collect.append(self.data[0]) - assert self.data[0] not in self.client.artists - s = self.data[0].new_subset() - assert s not in self.client.artists - - def test_pull_properties(self): - ax = self.client.axes - ax.set_xlim(6, 5) - ax.set_ylim(8, 7) - ax.set_xscale('log') - ax.set_yscale('log') - - self.client._pull_properties() - self.assert_properties_correct() - - def test_rescaled_on_init(self): - layer = self.setup_2d_data() - self.assert_layer_inside_limits(layer) - - def test_set_limits(self): - self.client.xmin = 3 - self.client.xmax = 4 - self.client.ymin = 5 - self.client.ymax = 6 - ax = self.client.axes - xlim = ax.get_xlim() - ylim = ax.get_ylim() - - assert xlim[0] == self.client.xmin - assert xlim[1] == self.client.xmax - assert ylim[0] == self.client.ymin - assert ylim[1] == self.client.ymax - - def test_ignore_duplicate_updates(self): - """Need not create new artist on every draw. Enforce this""" - layer = self.setup_2d_data() - - m = MagicMock() - self.client.artists[layer][0].clear = m - - self.client._update_layer(layer) - ct0 = m.call_count - - self.client._update_layer(layer) - ct1 = m.call_count - - assert ct1 == ct0 - - def test_range_rois_preserved(self): - data = self.add_data_and_attributes() - assert self.client.xatt is not self.client.yatt - - roi = XRangeROI() - roi.set_range(1, 2) - self.client.apply_roi(roi) - assert isinstance(data.edit_subset.subset_state, - RangeSubsetState) - assert data.edit_subset.subset_state.att == self.client.xatt - - roi = RectangularROI() - roi = YRangeROI() - roi.set_range(1, 2) - self.client.apply_roi(roi) - assert data.edit_subset.subset_state.att == self.client.yatt - - def test_component_replaced(self): - # regression test for #508 - data = self.add_data_and_attributes() - test = ComponentID('test') - data.update_id(self.client.xatt, test) - assert self.client.xatt is test - - -class TestCategoricalScatterClient(TestScatterClient): - - def setup_method(self, method): - self.data = example_data.test_categorical_data() - self.ids = [self.data[0].find_component_id('x1'), - self.data[0].find_component_id('y1'), - self.data[1].find_component_id('x2'), - self.data[1].find_component_id('y2')] - self.roi_limits = (0.5, 0.5, 4, 4) - self.roi_points = (np.array([1]), np.array([3])) - self.collect = DataCollection() - self.hub = self.collect.hub - - FIGURE.clf() - axes = FIGURE.add_subplot(111) - self.client = ScatterClient(self.collect, axes=axes) - - self.connect() - - def test_get_category_tick(self): - - self.add_data() - self.client.xatt = self.ids[0] - self.client.yatt = self.ids[0] - axes = self.client.axes - xformat = axes.xaxis.get_major_formatter() - yformat = axes.yaxis.get_major_formatter() - - xlabels = [xformat.format_data(pos) for pos in range(2)] - ylabels = [yformat.format_data(pos) for pos in range(2)] - assert xlabels == ['a', 'b'] - assert ylabels == ['a', 'b'] - - def test_axis_labels_sync_with_setters(self): - layer = self.add_data() - self.client.xatt = self.ids[0] - assert self.client.axes.get_xlabel() == self.ids[0].label - self.client.yatt = self.ids[1] - assert self.client.axes.get_ylabel() == self.ids[1].label - - def test_jitter_with_setter_change(self): - - grab_data = lambda client: client.data[0][client.xatt].copy() - layer = self.add_data() - self.client.xatt = self.ids[0] - self.client.yatt = self.ids[1] - orig_data = grab_data(self.client) - self.client.jitter = None - np.testing.assert_equal(orig_data, grab_data(self.client)) - self.client.jitter = 'uniform' - delta = np.abs(orig_data - grab_data(self.client)) - assert np.all((delta > 0) & (delta < 1)) - self.client.jitter = None - np.testing.assert_equal(orig_data, grab_data(self.client)) - - def test_ticks_go_back_after_changing(self): - """ If you change to a categorical axis and then change back - to a numeric, the axis ticks should fix themselves properly. - """ - data = Data() - data.add_component(Component(np.arange(100)), 'y') - data.add_component( - CategoricalComponent(['a'] * 50 + ['b'] * 50), 'xcat') - data.add_component(Component(2 * np.arange(100)), 'xcont') - - self.add_data(data=data) - self.client.yatt = data.find_component_id('y') - self.client.xatt = data.find_component_id('xcat') - self.check_ticks(self.client.axes.xaxis, False, True) - self.check_ticks(self.client.axes.yaxis, False, False) - - self.client.xatt = data.find_component_id('xcont') - self.check_ticks(self.client.axes.yaxis, False, False) - self.check_ticks(self.client.axes.xaxis, False, False) - - def test_high_cardinatility_timing(self): - - card = 50000 - data = Data() - card_data = [str(num) for num in range(card)] - data.add_component(Component(np.arange(card * 5)), 'y') - data.add_component( - CategoricalComponent(np.repeat([card_data], 5)), 'xcat') - self.add_data(data) - comp = data.find_component_id('xcat') - timer_func = partial(self.client._set_xydata, 'x', comp) - - timer = timeit(timer_func, number=1) - assert timer < 3 # this is set for Travis speed - - def test_apply_roi(self): - data = self.add_data_and_attributes() - roi = RectangularROI() - roi.update_limits(*self.roi_limits) - x, y = self.roi_points - self.client.apply_roi(roi) - - def test_range_rois_preserved(self): - data = self.add_data_and_attributes() - assert self.client.xatt is not self.client.yatt - - roi = XRangeROI() - roi.set_range(1, 2) - self.client.apply_roi(roi) - assert isinstance(data.edit_subset.subset_state, - CategoricalROISubsetState) - assert data.edit_subset.subset_state.att == self.client.xatt - - roi = YRangeROI() - roi.set_range(1, 2) - self.client.apply_roi(roi) - assert isinstance(data.edit_subset.subset_state, - RangeSubsetState) - assert data.edit_subset.subset_state.att == self.client.yatt - roi = RectangularROI(xmin=1, xmax=2, ymin=1, ymax=2) - - self.client.apply_roi(roi) - assert isinstance(data.edit_subset.subset_state, - AndState) - - @pytest.mark.parametrize(('roi_limits', 'mask'), [((0, -0.1, 10, 0.1), [0, 0, 0]), - ((0, 0.9, 10, 1.1), [1, 0, 0]), - ((0, 1.9, 10, 2.1), [0, 1, 0]), - ((0, 2.9, 10, 3.1), [0, 0, 1]), - ((0, 0.9, 10, 3.1), [1, 1, 1]), - ((-0.1, -1, 0.1, 5), [1, 1, 0]), - ((0.9, -1, 1.1, 5), [0, 0, 1]), - ((-0.1, 0.9, 1.1, 3.1), [1, 1, 1])]) - def test_apply_roi_results(self, roi_limits, mask): - # Regression test for glue-viz/glue#718 - data = self.add_data_and_attributes() - roi = RectangularROI() - roi.update_limits(*roi_limits) - x, y = self.roi_points - self.client.apply_roi(roi) - np.testing.assert_equal(data.edit_subset.to_mask(), mask) - - # REMOVED TESTS - def test_invalid_plot(self): - """ This fails because the axis ticks shouldn't reset after - invalid plot. Current testing logic can't cope with this.""" - pass - - def test_redraw_called_on_invalid_plot(self): - """ This fails because the axis ticks shouldn't reset after - invalid plot. Current testing logic can't cope with this.""" - pass - - def test_xlog_relimits_if_negative(self): - """ Log-based tests don't make sense here.""" - pass - - def test_log_sticky(self): - """ Log-based tests don't make sense here.""" - pass - - def test_logs(self): - """ Log-based tests don't make sense here.""" - pass diff --git a/glue/viewers/scatter/tests/test_layer_artist.py b/glue/viewers/scatter/tests/test_layer_artist.py deleted file mode 100644 index a1b157e9d..000000000 --- a/glue/viewers/scatter/tests/test_layer_artist.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import absolute_import, division, print_function - -from glue.core import Data -from glue.utils import renderless_figure - -from ..layer_artist import ScatterLayerArtist - -FIGURE = renderless_figure() - - -class TestScatterArtist(object): - - def setup_method(self, method): - self.ax = FIGURE.add_subplot(111) - - def test_emphasis_compatible_with_data(self): - # regression test for issue 249 - d = Data(x=[1, 2, 3]) - s = ScatterLayerArtist(d, self.ax) - s.xatt = d.id['x'] - s.yatt = d.id['x'] - s.emphasis = d.id['x'] > 1 - - s.update() diff --git a/setup.py b/setup.py index 7a2cc9a92..742c6dbb5 100755 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ def run(self): coordinate_helpers = glue.plugins.coordinate_helpers:setup spectral_cube = glue.plugins.data_factories.spectral_cube:setup dendro_viewer = glue.plugins.dendro_viewer:setup -image_viewer = glue.viewers.image:setup +image_viewer = glue.viewers.image_new:setup scatter_viewer = glue.viewers.scatter:setup histogram_viewer = glue.viewers.histogram:setup table_viewer = glue.viewers.table:setup