From acca6307bfdd06e0757bd4ef9a7d6bf0e52b434c Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Thu, 29 Mar 2018 09:25:05 +0100 Subject: [PATCH 01/42] Added implementation for a simple profile viewer with the aim to replace the spectrum tool --- glue/viewers/profile/__init__.py | 5 + glue/viewers/profile/layer_artist.py | 149 +++++++++ glue/viewers/profile/qt/__init__.py | 1 + glue/viewers/profile/qt/data_viewer.py | 42 +++ glue/viewers/profile/qt/layer_style_editor.py | 20 ++ glue/viewers/profile/qt/layer_style_editor.ui | 146 ++++++++ glue/viewers/profile/qt/options_widget.py | 28 ++ glue/viewers/profile/qt/options_widget.ui | 315 ++++++++++++++++++ glue/viewers/profile/qt/tests/__init__.py | 0 .../profile/qt/tests/test_data_viewer.py | 53 +++ glue/viewers/profile/state.py | 90 +++++ glue/viewers/profile/tests/__init__.py | 0 setup.py | 1 + 13 files changed, 850 insertions(+) create mode 100644 glue/viewers/profile/__init__.py create mode 100644 glue/viewers/profile/layer_artist.py create mode 100644 glue/viewers/profile/qt/__init__.py create mode 100644 glue/viewers/profile/qt/data_viewer.py create mode 100644 glue/viewers/profile/qt/layer_style_editor.py create mode 100644 glue/viewers/profile/qt/layer_style_editor.ui create mode 100644 glue/viewers/profile/qt/options_widget.py create mode 100644 glue/viewers/profile/qt/options_widget.ui create mode 100644 glue/viewers/profile/qt/tests/__init__.py create mode 100644 glue/viewers/profile/qt/tests/test_data_viewer.py create mode 100644 glue/viewers/profile/state.py create mode 100644 glue/viewers/profile/tests/__init__.py diff --git a/glue/viewers/profile/__init__.py b/glue/viewers/profile/__init__.py new file mode 100644 index 000000000..2d0e639fe --- /dev/null +++ b/glue/viewers/profile/__init__.py @@ -0,0 +1,5 @@ +def setup(): + from glue.config import qt_client + from .qt.data_viewer import ProfileViewer + print("HERE") + qt_client.add(ProfileViewer) diff --git a/glue/viewers/profile/layer_artist.py b/glue/viewers/profile/layer_artist.py new file mode 100644 index 000000000..e310d3991 --- /dev/null +++ b/glue/viewers/profile/layer_artist.py @@ -0,0 +1,149 @@ +from __future__ import absolute_import, division, print_function + +import numpy as np + +from glue.utils import defer_draw +from glue.core import Data +from glue.viewers.profile.state import ProfileLayerState +from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist +from glue.core.exceptions import IncompatibleAttribute + + +class ProfileLayerArtist(MatplotlibLayerArtist): + + _layer_state_cls = ProfileLayerState + + def __init__(self, axes, viewer_state, layer_state=None, layer=None): + + super(ProfileLayerArtist, self).__init__(axes, viewer_state, + layer_state=layer_state, layer=layer) + + # Watch for changes in the viewer state which would require the + # layers to be redrawn + self._viewer_state.add_global_callback(self._update_profile) + self.state.add_global_callback(self._update_profile) + + self.plot_artist = self.axes.plot([1,2,3], [3,4,5], 'k-', drawstyle='steps-mid')[0] + + self.mpl_artists = [self.plot_artist] + + self.reset_cache() + + def reset_cache(self): + self._last_viewer_state = {} + self._last_layer_state = {} + + @defer_draw + def _calculate_profile(self): + + try: + if isinstance(self.layer, Data): + data_values = self.layer[self._viewer_state.y_att] + mask = None + else: + data_values = self.layer.data[self._viewer_state.y_att].copy() + mask = self.layer.to_mask() + data_values[~mask] = np.nan + except (IncompatibleAttribute, IndexError): + self.disable_invalid_attributes(self._viewer_state.x_att) + return + else: + self.enable() + + if mask is not None and np.sum(mask) == 0: + self.plot_artist.set_data([], []) + return + + # Collapse along all dimensions except x_att + # TODO: in future we should optimize the case where the mask is much + # smaller than the data to just average the relevant 'spaxels' in the + # data rather than collapsing the whole cube. + axes = list(range(data_values.ndim)) + axes.remove(self._viewer_state.x_att.axis) + profile_values = self._viewer_state.function(data_values, axis=tuple(axes)) + profile_values[np.isnan(profile_values)] = 0. + + # Update the data values + self.plot_artist.set_data(np.arange(len(profile_values)), profile_values) + + # TODO: the following was copy/pasted from the histogram viewer, maybe + # we can find a way to avoid duplication? + + # We have to do the following to make sure that we reset the y_max as + # needed. We can't simply reset based on the maximum for this layer + # because other layers might have other values, and we also can't do: + # + # self._viewer_state.y_max = max(self._viewer_state.y_max, result[0].max()) + # + # because this would never allow y_max to get smaller. + + self.state._y_min = profile_values.min() + self.state._y_max = profile_values.max() * 1.2 + + largest_y_max = max(getattr(layer, '_y_max', 0) for layer in self._viewer_state.layers) + if largest_y_max != self._viewer_state.y_max: + self._viewer_state.y_max = largest_y_max + + smallest_y_min = min(getattr(layer, '_y_min', np.inf) for layer in self._viewer_state.layers) + if smallest_y_min != self._viewer_state.y_min: + self._viewer_state.y_min = smallest_y_min + + self.redraw() + + @defer_draw + def _update_visual_attributes(self): + + if not self.enabled: + return + + for mpl_artist in self.mpl_artists: + mpl_artist.set_visible(self.state.visible) + mpl_artist.set_zorder(self.state.zorder) + mpl_artist.set_color(self.state.color) + mpl_artist.set_alpha(self.state.alpha) + mpl_artist.set_linewidth(self.state.linewidth) + + self.redraw() + + def _update_profile(self, force=False, **kwargs): + + # TODO: we need to factor the following code into a common method. + + 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_profile 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', 'attribute', 'function')): + self._calculate_profile() + force = True # make sure scaling and visual attributes are updated + + if force or any(prop in changed for prop in ('alpha', 'color', 'zorder', 'visible', 'linewidth')): + self._update_visual_attributes() + + @defer_draw + def update(self): + self._update_profile(force=True) + self.redraw() diff --git a/glue/viewers/profile/qt/__init__.py b/glue/viewers/profile/qt/__init__.py new file mode 100644 index 000000000..e68652650 --- /dev/null +++ b/glue/viewers/profile/qt/__init__.py @@ -0,0 +1 @@ +from .data_viewer import ProfileViewer # noqa diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py new file mode 100644 index 000000000..d0211ce3e --- /dev/null +++ b/glue/viewers/profile/qt/data_viewer.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import, division, print_function + +from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar +from glue.viewers.matplotlib.qt.data_viewer import MatplotlibDataViewer +from glue.viewers.profile.qt.layer_style_editor import ProfileLayerStyleEditor +from glue.viewers.profile.layer_artist import ProfileLayerArtist +from glue.viewers.profile.qt.options_widget import ProfileOptionsWidget +from glue.viewers.profile.state import ProfileViewerState + +__all__ = ['ProfileViewer'] + + +class ProfileViewer(MatplotlibDataViewer): + + LABEL = '1D Profile' + _toolbar_cls = MatplotlibViewerToolbar + _layer_style_widget_cls = ProfileLayerStyleEditor + _state_cls = ProfileViewerState + _options_cls = ProfileOptionsWidget + _data_artist_cls = ProfileLayerArtist + _subset_artist_cls = ProfileLayerArtist + + tools = ['select:xrange'] + + def __init__(self, session, parent=None, state=None): + super(ProfileViewer, self).__init__(session, parent, state=state) + self.state.add_callback('x_att', self._update_axes) + self.state.add_callback('y_att', self._update_axes) + + def _update_axes(self, *args): + + if self.state.x_att is not None: + self.state.x_axislabel = self.state.x_att.label + + if self.state.y_att is not None: + self.state.y_axislabel = self.state.y_att.label + + self.axes.figure.canvas.draw() + + def _roi_to_subset_state(self, roi): + x_comp = self.state.x_att.parent.get_component(self.state.x_att) + return x_comp.subset_from_roi(self.state.x_att, roi, coord='x') diff --git a/glue/viewers/profile/qt/layer_style_editor.py b/glue/viewers/profile/qt/layer_style_editor.py new file mode 100644 index 000000000..da180ee5f --- /dev/null +++ b/glue/viewers/profile/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 ProfileLayerStyleEditor(QtWidgets.QWidget): + + def __init__(self, layer, parent=None): + + super(ProfileLayerStyleEditor, 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/profile/qt/layer_style_editor.ui b/glue/viewers/profile/qt/layer_style_editor.ui new file mode 100644 index 000000000..de9ab8574 --- /dev/null +++ b/glue/viewers/profile/qt/layer_style_editor.ui @@ -0,0 +1,146 @@ + + + Form + + + + 0 + 0 + 154 + 97 + + + + Form + + + + 5 + + + 5 + + + 5 + + + 5 + + + 10 + + + 5 + + + + + + 0 + 0 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 5 + + + + + + + + + 75 + true + + + + color + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 100 + + + Qt::Horizontal + + + + + + + + 75 + true + + + + opacity + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 75 + true + + + + linewidth + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + QColorBox + QLabel +
glue.utils.qt.colors
+
+
+ + +
diff --git a/glue/viewers/profile/qt/options_widget.py b/glue/viewers/profile/qt/options_widget.py new file mode 100644 index 000000000..263aed67c --- /dev/null +++ b/glue/viewers/profile/qt/options_widget.py @@ -0,0 +1,28 @@ +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, fix_tab_widget_fontsize + +__all__ = ['ProfileOptionsWidget'] + + +class ProfileOptionsWidget(QtWidgets.QWidget): + + def __init__(self, viewer_state, session, parent=None): + + super(ProfileOptionsWidget, self).__init__(parent=parent) + + self.ui = load_ui('options_widget.ui', self, + directory=os.path.dirname(__file__)) + + fix_tab_widget_fontsize(self.ui.tab_widget) + + autoconnect_callbacks_to_qt(viewer_state, self.ui) + + self.viewer_state = viewer_state + + self.session = session diff --git a/glue/viewers/profile/qt/options_widget.ui b/glue/viewers/profile/qt/options_widget.ui new file mode 100644 index 000000000..5e21a23f6 --- /dev/null +++ b/glue/viewers/profile/qt/options_widget.ui @@ -0,0 +1,315 @@ + + + Widget + + + + 0 + 0 + 269 + 418 + + + + 1D Histogram + + + + 5 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + 0 + + + + General + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + 5 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 75 + true + + + + y axis + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 75 + true + + + + x axis + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + Qt::Horizontal + + + + 40 + 5 + + + + + + + + + + + + 75 + true + + + + function + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + Limits + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + 5 + + + + + padding: 0px + + + + + + + + + + + + + + + + + + + + 75 + true + + + + x axis + + + + + + + + 75 + true + + + + y axis + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 5 + + + + + + + + log + + + true + + + + + + + log + + + true + + + + + bool_x_log + valuetext_x_max + button_flip_x + valuetext_x_min + valuetext_y_min + valuetext_y_max + bool_y_log + label_2 + label_5 + verticalSpacer + horizontalSpacer_2 + + + + Axes + + + + 5 + + + 5 + + + 5 + + + 5 + + + + + + + + + + + + + + AxesEditorWidget + QWidget +
glue.viewers.matplotlib.qt.axes_editor
+
+
+ + +
diff --git a/glue/viewers/profile/qt/tests/__init__.py b/glue/viewers/profile/qt/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/glue/viewers/profile/qt/tests/test_data_viewer.py b/glue/viewers/profile/qt/tests/test_data_viewer.py new file mode 100644 index 000000000..c7de9c80f --- /dev/null +++ b/glue/viewers/profile/qt/tests/test_data_viewer.py @@ -0,0 +1,53 @@ +# pylint: disable=I0011,W0613,W0201,W0212,E1101,E1103 + +from __future__ import absolute_import, division, print_function + +import os +from collections import Counter + +import pytest +import numpy as np + +from numpy.testing import assert_equal, assert_allclose + +from glue.core.message import SubsetUpdateMessage +from glue.core import HubListener, Data +from glue.core.roi import XRangeROI +from glue.core.subset import RangeSubsetState, CategoricalROISubsetState +from glue import core +from glue.app.qt import GlueApplication +from glue.core.component_id import ComponentID +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.app.qt.layer_tree_widget import LayerTreeWidget + +from ..data_viewer import ProfileViewer + +DATA = os.path.join(os.path.dirname(__file__), 'data') + + +class TestProfileCommon(BaseTestMatplotlibDataViewer): + def init_data(self): + return Data(label='d1', + x=np.random.random(24).reshape((3, 4, 2))) + viewer_cls = ProfileViewer + + +class TestProfileViewer(object): + + def setup_method(self, method): + + self.data = Data(label='d1', x=[3.4, 2.3, -1.1, 0.3], y=['a', 'b', 'c', 'a']) + + self.app = GlueApplication() + self.session = self.app.session + self.hub = self.session.hub + + self.data_collection = self.session.data_collection + self.data_collection.append(self.data) + + self.viewer = self.app.new_data_viewer(ProfileViewer) + + def teardown_method(self, method): + self.viewer.close() diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py new file mode 100644 index 000000000..750203da6 --- /dev/null +++ b/glue/viewers/profile/state.py @@ -0,0 +1,90 @@ +from __future__ import absolute_import, division, print_function + +import numpy as np + +from glue.external.echo import delay_callback +from glue.viewers.matplotlib.state import (MatplotlibDataViewerState, + MatplotlibLayerState, + DeferredDrawCallbackProperty as DDCProperty, + DeferredDrawSelectionCallbackProperty as DDSCProperty) +from glue.core.state_objects import StateAttributeLimitsHelper +from glue.core.data_combo_helper import ComponentIDComboHelper +from glue.utils import defer_draw + +__all__ = ['ProfileViewerState', 'ProfileLayerState'] + + +FUNCTIONS = {np.nanmean: 'Mean', + np.nanmedian: 'Median', + np.nanmin: 'Minimum', + np.nanmax: 'Maximum', + np.nansum: 'Sum'} + + +class ProfileViewerState(MatplotlibDataViewerState): + """ + A state class that includes all the attributes for a Profile viewer. + """ + + x_att = DDSCProperty(docstring='The data component to use for the x-axis ' + 'of the profile (should be a pixel component)') + + y_att = DDSCProperty(docstring='The data component to use for the y-axis ' + 'of the profile') + + function = DDSCProperty(docstring='The function to use for collapsing data') + + # TODO: add function to use + + def __init__(self, **kwargs): + + super(ProfileViewerState, self).__init__() + + self.x_lim_helper = StateAttributeLimitsHelper(self, 'x_att', lower='x_min', + upper='x_max') + + self.add_callback('layers', self._layers_changed) + + self.x_att_helper = ComponentIDComboHelper(self, 'x_att', numeric=False, categorical=False, pixel_coord=True) + self.y_att_helper = ComponentIDComboHelper(self, 'y_att', numeric=True) + + ProfileViewerState.function.set_choices(self, list(FUNCTIONS)) + ProfileViewerState.function.set_display_func(self, FUNCTIONS.get) + + self.update_from_dict(kwargs) + + def reset_limits(self): + with delay_callback(self, 'x_min', 'x_max'): + self.x_lim_helper.percentile = 100 + self.x_lim_helper.update_values(force=True) + + def _update_priority(self, name): + if name == 'layers': + return 2 + elif name.endswith(('_min', '_max')): + return 0 + else: + return 1 + + def flip_x(self): + """ + Flip the x_min/x_max limits. + """ + self.x_lim_helper.flip_limits() + + @defer_draw + def _layers_changed(self, *args): + print("X_ATT [bef]", self.x_att) + print("Y_ATT [bef]", self.y_att) + self.x_att_helper.set_multiple_data(self.layers_data) + self.y_att_helper.set_multiple_data(self.layers_data) + print("X_ATT [aft]", self.x_att) + print("Y_ATT [aft]", self.y_att) + + +class ProfileLayerState(MatplotlibLayerState): + """ + A state class that includes all the attributes for layers in a Profile plot. + """ + + linewidth = DDCProperty(1, docstring='The width of the line') diff --git a/glue/viewers/profile/tests/__init__.py b/glue/viewers/profile/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/setup.py b/setup.py index 37006fd92..e8c3163b4 100755 --- a/setup.py +++ b/setup.py @@ -79,6 +79,7 @@ def run(self): image_viewer = glue.viewers.image:setup scatter_viewer = glue.viewers.scatter:setup histogram_viewer = glue.viewers.histogram:setup +profile_viewer = glue.viewers.profile:setup table_viewer = glue.viewers.table:setup data_exporters = glue.core.data_exporters:setup fits_format = glue.io.formats.fits:setup From 64a02a052074b489ed13a63f31b6ceb762d24951 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Thu, 29 Mar 2018 11:39:47 +0100 Subject: [PATCH 02/42] Started implementing a navigation mouse mode --- glue/viewers/profile/mouse_mode.py | 38 ++++++++++++++++++++++++++ glue/viewers/profile/qt/data_viewer.py | 4 +++ glue/viewers/profile/state.py | 4 --- 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 glue/viewers/profile/mouse_mode.py diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py new file mode 100644 index 000000000..a352ddd62 --- /dev/null +++ b/glue/viewers/profile/mouse_mode.py @@ -0,0 +1,38 @@ +from glue.external.echo import CallbackProperty +from glue.core.state_objects import State +from glue.viewers.common.qt.mouse_mode import MouseMode + + +class NavigationModeState(State): + x = CallbackProperty(None) + + +class NavigateMouseMode(MouseMode): + + def __init__(self, viewer): + super(NavigateMouseMode, self).__init__(viewer) + self.state = NavigationModeState() + self.state.add_callback('x', self._update_line) + self.pressed = False + self._viewer = viewer + + def press(self, event): + self.pressed = True + if not event.inaxes: + return + self.state.x = event.xdata + + def move(self, event): + if not self.pressed or not event.inaxes: + return + self.state.x = event.xdata + + def release(self, event): + self.pressed = False + + def _update_line(self, *args): + if hasattr(self, '_line'): + self._line.set_data([self.state.x, self.state.x], [0, 1]) + else: + self._line = self._axes.axvline(0) + self._canvas.draw() diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py index d0211ce3e..60901c228 100644 --- a/glue/viewers/profile/qt/data_viewer.py +++ b/glue/viewers/profile/qt/data_viewer.py @@ -6,6 +6,9 @@ from glue.viewers.profile.layer_artist import ProfileLayerArtist from glue.viewers.profile.qt.options_widget import ProfileOptionsWidget from glue.viewers.profile.state import ProfileViewerState +from glue.viewers.profile.mouse_mode import NavigateMouseMode + +from glue.viewers.common.qt import toolbar_mode # noqa __all__ = ['ProfileViewer'] @@ -19,6 +22,7 @@ class ProfileViewer(MatplotlibDataViewer): _options_cls = ProfileOptionsWidget _data_artist_cls = ProfileLayerArtist _subset_artist_cls = ProfileLayerArtist + _default_mouse_mode_cls = NavigateMouseMode tools = ['select:xrange'] diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 750203da6..6533ff244 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -74,12 +74,8 @@ def flip_x(self): @defer_draw def _layers_changed(self, *args): - print("X_ATT [bef]", self.x_att) - print("Y_ATT [bef]", self.y_att) self.x_att_helper.set_multiple_data(self.layers_data) self.y_att_helper.set_multiple_data(self.layers_data) - print("X_ATT [aft]", self.x_att) - print("Y_ATT [aft]", self.y_att) class ProfileLayerState(MatplotlibLayerState): From f3ad77638c7ee79893514048c6193794c344caeb Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Thu, 29 Mar 2018 18:43:13 +0100 Subject: [PATCH 03/42] Implemented RangeMouseMode --- glue/viewers/profile/mouse_mode.py | 94 ++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index a352ddd62..a8b226f31 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -1,7 +1,12 @@ -from glue.external.echo import CallbackProperty +from glue.external.echo import CallbackProperty, delay_callback from glue.core.state_objects import State from glue.viewers.common.qt.mouse_mode import MouseMode +__all__ = ['NavigateMouseMode', 'RangeMouseMode'] + + +COLOR = (0.0, 0.25, 0.7) + class NavigationModeState(State): x = CallbackProperty(None) @@ -12,7 +17,7 @@ class NavigateMouseMode(MouseMode): def __init__(self, viewer): super(NavigateMouseMode, self).__init__(viewer) self.state = NavigationModeState() - self.state.add_callback('x', self._update_line) + self.state.add_callback('x', self._update_artist) self.pressed = False self._viewer = viewer @@ -30,9 +35,90 @@ def move(self, event): def release(self, event): self.pressed = False - def _update_line(self, *args): + def _update_artist(self, *args): if hasattr(self, '_line'): self._line.set_data([self.state.x, self.state.x], [0, 1]) else: - self._line = self._axes.axvline(0) + self._line = self._axes.axvline(self.state.x, color=COLOR) + self._canvas.draw() + + +class RangeModeState(State): + x_min = CallbackProperty(None) + x_max = CallbackProperty(None) + + +PICK_THRESH = 0.05 + + +class RangeMouseMode(MouseMode): + + def __init__(self, viewer): + super(RangeMouseMode, self).__init__(viewer) + self.state = RangeModeState() + self.state.add_callback('x_min', self._update_artist) + self.state.add_callback('x_max', self._update_artist) + self.pressed = False + + self.mode = None + self.move_params = None + self._viewer = viewer + + def press(self, event): + + self.pressed = True + + if not event.inaxes: + return + + x_min, x_max = self._axes.get_xlim() + x_range = abs(x_max - x_min) + + if self.state.x_min is None or self.state.x_max is None: + self.mode = 'move-x-max' + with delay_callback(self.state, 'x_min', 'x_max'): + self.state.x_min = event.xdata + self.state.x_max = event.xdata + elif abs(event.xdata - self.state.x_min) / x_range < PICK_THRESH: + self.mode = 'move-x-min' + elif abs(event.xdata - self.state.x_max) / x_range < PICK_THRESH: + self.mode = 'move-x-max' + elif (event.xdata > self.state.x_min) is (event.xdata < self.state.x_max): + self.mode = 'move' + self.move_params = (event.xdata, self.state.x_min, self.state.x_max) + else: + self.mode = 'move-x-max' + self.state.x_min = event.xdata + + def move(self, event): + + if not self.pressed or not event.inaxes: + return + + if self.mode == 'move-x-min': + self.state.x_min = event.xdata + elif self.mode == 'move-x-max': + self.state.x_max = event.xdata + elif self.mode == 'move': + orig_click, orig_x_min, orig_x_max = self.move_params + with delay_callback(self.state, 'x_min', 'x_max'): + self.state.x_min = orig_x_min + (event.xdata - orig_click) + self.state.x_max = orig_x_max + (event.xdata - orig_click) + + def release(self, event): + self.pressed = False + self.mode = None + self.move_params + + def _update_artist(self, *args): + y_min, y_max = self._axes.get_ylim() + y_mid = 0.5 * (y_min + y_max) + if hasattr(self, '_lines'): + self._lines[0].set_data([self.state.x_min, self.state.x_min], [0, 1]) + self._lines[1].set_data([self.state.x_max, self.state.x_max], [0, 1]) + self._lines[2].set_data([self.state.x_min, self.state.x_max], [y_mid, y_mid]) + else: + self._lines = (self._axes.axvline(self.state.x_min, color=COLOR), + self._axes.axvline(self.state.x_max, color=COLOR), + self._axes.plot([self.state.x_min, self.state.x_max], [y_mid, y_mid], color=COLOR)[0]) self._canvas.draw() From 88498f29e191c2f1977e630e5a52b31f56f0ca58 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Thu, 29 Mar 2018 19:38:38 +0100 Subject: [PATCH 04/42] Started implementing profile tools side panel --- glue/viewers/profile/mouse_mode.py | 26 +++ glue/viewers/profile/qt/data_viewer.py | 17 +- glue/viewers/profile/qt/profile_tools.py | 45 ++++++ glue/viewers/profile/qt/profile_tools.ui | 191 +++++++++++++++++++++++ 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 glue/viewers/profile/qt/profile_tools.py create mode 100644 glue/viewers/profile/qt/profile_tools.ui diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index a8b226f31..0b168fa71 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -42,6 +42,18 @@ def _update_artist(self, *args): self._line = self._axes.axvline(self.state.x, color=COLOR) self._canvas.draw() + def deactivate(self): + if hasattr(self, '_line'): + self._line.set_visible(False) + self._canvas.draw() + super(NavigateMouseMode, self).deactivate() + + def activate(self): + if hasattr(self, '_line'): + self._line.set_visible(True) + self._canvas.draw() + super(NavigateMouseMode, self).activate() + class RangeModeState(State): x_min = CallbackProperty(None) @@ -122,3 +134,17 @@ def _update_artist(self, *args): self._axes.axvline(self.state.x_max, color=COLOR), self._axes.plot([self.state.x_min, self.state.x_max], [y_mid, y_mid], color=COLOR)[0]) self._canvas.draw() + + def deactivate(self): + if hasattr(self, '_lines'): + for line in self._lines: + line.set_visible(False) + self._canvas.draw() + super(RangeMouseMode, self).deactivate() + + def activate(self): + if hasattr(self, '_lines'): + for line in self._lines: + line.set_visible(True) + self._canvas.draw() + super(RangeMouseMode, self).activate() diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py index 60901c228..6d7b365e3 100644 --- a/glue/viewers/profile/qt/data_viewer.py +++ b/glue/viewers/profile/qt/data_viewer.py @@ -1,12 +1,16 @@ from __future__ import absolute_import, division, print_function +from qtpy import QtWidgets +from qtpy.QtCore import Qt + from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar from glue.viewers.matplotlib.qt.data_viewer import MatplotlibDataViewer from glue.viewers.profile.qt.layer_style_editor import ProfileLayerStyleEditor from glue.viewers.profile.layer_artist import ProfileLayerArtist from glue.viewers.profile.qt.options_widget import ProfileOptionsWidget from glue.viewers.profile.state import ProfileViewerState -from glue.viewers.profile.mouse_mode import NavigateMouseMode +from glue.viewers.profile.mouse_mode import RangeMouseMode +from glue.viewers.profile.qt.profile_tools import ProfileTools from glue.viewers.common.qt import toolbar_mode # noqa @@ -22,7 +26,6 @@ class ProfileViewer(MatplotlibDataViewer): _options_cls = ProfileOptionsWidget _data_artist_cls = ProfileLayerArtist _subset_artist_cls = ProfileLayerArtist - _default_mouse_mode_cls = NavigateMouseMode tools = ['select:xrange'] @@ -30,6 +33,16 @@ def __init__(self, session, parent=None, state=None): super(ProfileViewer, self).__init__(session, parent, state=state) self.state.add_callback('x_att', self._update_axes) self.state.add_callback('y_att', self._update_axes) + self._profile_tools.enable() + + def setCentralWidget(self, widget): + self._profile_tools = ProfileTools(self) + container_widget = QtWidgets.QWidget() + container_layout = QtWidgets.QHBoxLayout() + container_widget.setLayout(container_layout) + container_layout.addWidget(widget) + container_layout.addWidget(self._profile_tools) + super(ProfileViewer, self).setCentralWidget(container_widget) def _update_axes(self, *args): diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py new file mode 100644 index 000000000..fb7ef4441 --- /dev/null +++ b/glue/viewers/profile/qt/profile_tools.py @@ -0,0 +1,45 @@ +import os + +from qtpy import QtWidgets +from glue.utils.qt import load_ui, fix_tab_widget_fontsize +from glue.viewers.profile.mouse_mode import NavigateMouseMode, RangeMouseMode + + +__all__ = ['ProfileTools'] + + +MODES = ['navigate', 'fit', 'collapse'] + + +class ProfileTools(QtWidgets.QWidget): + + def __init__(self, parent=None): + + super(ProfileTools, self).__init__(parent=parent) + + self.ui = load_ui('profile_tools.ui', self, + directory=os.path.dirname(__file__)) + + fix_tab_widget_fontsize(self.ui.tabs) + + self.viewer = parent + + def enable(self): + self.nav_mode = NavigateMouseMode(self.viewer) + self.rng_mode = RangeMouseMode(self.viewer) + self.ui.tabs.setCurrentIndex(0) + self.ui.tabs.currentChanged.connect(self._on_tab_change) + self._on_tab_change() + + @property + def mode(self): + return MODES[self.tabs.currentIndex()] + + def _on_tab_change(self, *event): + mode = self.mode + if mode == 'navigate': + self.rng_mode.deactivate() + self.nav_mode.activate() + else: + self.rng_mode.activate() + self.nav_mode.deactivate() diff --git a/glue/viewers/profile/qt/profile_tools.ui b/glue/viewers/profile/qt/profile_tools.ui new file mode 100644 index 000000000..e99aaa4e1 --- /dev/null +++ b/glue/viewers/profile/qt/profile_tools.ui @@ -0,0 +1,191 @@ + + + Form + + + + 0 + 0 + 237 + 270 + + + + Form + + + + + + 0 + + + + Navigate + + + + + + <html><head/><body><p>To <span style=" font-weight:600;">slide </span>through the cube, drag the handle or double-click</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + Fit + + + + + + + + Function: + + + + + + + + + + + + + + Settings + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Fit + + + + + + + Clear + + + + + + + + + + + + + Collapse + + + + + + + + Function: + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Collapse + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + From 5f78f87106b9818e6c81ede82d7b471a6b31ff79 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 00:07:38 +0100 Subject: [PATCH 05/42] Implemented fitting panel --- glue/core/fitters.py | 6 +- glue/viewers/common/qt/data_viewer.py | 3 + glue/viewers/profile/layer_artist.py | 31 ++--- glue/viewers/profile/mouse_mode.py | 5 + glue/viewers/profile/qt/fitters.py | 160 +++++++++++++++++++++++ glue/viewers/profile/qt/profile_tools.py | 139 +++++++++++++++++++- glue/viewers/profile/qt/profile_tools.ui | 2 +- glue/viewers/profile/state.py | 23 ++++ 8 files changed, 340 insertions(+), 29 deletions(-) create mode 100644 glue/viewers/profile/qt/fitters.py diff --git a/glue/core/fitters.py b/glue/core/fitters.py index e06b05494..b3523d4ad 100644 --- a/glue/core/fitters.py +++ b/glue/core/fitters.py @@ -42,7 +42,7 @@ def __init__(self, **params): else: setattr(self, k, v) - def plot(self, fit_result, axes, x): + def plot(self, fit_result, axes, x, linewidth=None, alpha=None, color=None): """ Plot the result of a fit. @@ -54,8 +54,8 @@ def plot(self, fit_result, axes, x): plots will not be properly cleared if this isn't provided """ y = self.predict(fit_result, x) - result = axes.plot(x, y, '#4daf4a', - lw=3, alpha=0.8, + result = axes.plot(x, y, color, + lw=linewidth, alpha=alpha, scalex=False, scaley=False) return result diff --git a/glue/viewers/common/qt/data_viewer.py b/glue/viewers/common/qt/data_viewer.py index 41210209c..5cf718313 100644 --- a/glue/viewers/common/qt/data_viewer.py +++ b/glue/viewers/common/qt/data_viewer.py @@ -74,6 +74,7 @@ class DataViewer(ViewerBase, QtWidgets.QMainWindow): """ window_closed = QtCore.Signal() + toolbar_added = QtCore.Signal() _layer_artist_container_cls = QtLayerArtistContainer _layer_style_widget_cls = None @@ -315,6 +316,8 @@ def initialize_toolbar(self): self.addToolBar(self.toolbar) + self.toolbar_added.emit() + def show_toolbars(self): """Re-enable any toolbars that were hidden with `hide_toolbars()` diff --git a/glue/viewers/profile/layer_artist.py b/glue/viewers/profile/layer_artist.py index e310d3991..896ddc96f 100644 --- a/glue/viewers/profile/layer_artist.py +++ b/glue/viewers/profile/layer_artist.py @@ -37,34 +37,19 @@ def reset_cache(self): def _calculate_profile(self): try: - if isinstance(self.layer, Data): - data_values = self.layer[self._viewer_state.y_att] - mask = None - else: - data_values = self.layer.data[self._viewer_state.y_att].copy() - mask = self.layer.to_mask() - data_values[~mask] = np.nan + x, y = self.state.get_profile() except (IncompatibleAttribute, IndexError): self.disable_invalid_attributes(self._viewer_state.x_att) return else: self.enable() - if mask is not None and np.sum(mask) == 0: - self.plot_artist.set_data([], []) - return - - # Collapse along all dimensions except x_att - # TODO: in future we should optimize the case where the mask is much - # smaller than the data to just average the relevant 'spaxels' in the - # data rather than collapsing the whole cube. - axes = list(range(data_values.ndim)) - axes.remove(self._viewer_state.x_att.axis) - profile_values = self._viewer_state.function(data_values, axis=tuple(axes)) - profile_values[np.isnan(profile_values)] = 0. - # Update the data values - self.plot_artist.set_data(np.arange(len(profile_values)), profile_values) + self.plot_artist.set_data(x, y) + self._visible_data = x, y + + if len(x) == 0: + return # TODO: the following was copy/pasted from the histogram viewer, maybe # we can find a way to avoid duplication? @@ -77,8 +62,8 @@ def _calculate_profile(self): # # because this would never allow y_max to get smaller. - self.state._y_min = profile_values.min() - self.state._y_max = profile_values.max() * 1.2 + self.state._y_min = y.min() + self.state._y_max = y.max() * 1.2 largest_y_max = max(getattr(layer, '_y_max', 0) for layer in self._viewer_state.layers) if largest_y_max != self._viewer_state.y_max: diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index 0b168fa71..381a813a5 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -56,9 +56,14 @@ def activate(self): class RangeModeState(State): + x_min = CallbackProperty(None) x_max = CallbackProperty(None) + @property + def x_range(self): + return self.x_min, self.x_max + PICK_THRESH = 0.05 diff --git a/glue/viewers/profile/qt/fitters.py b/glue/viewers/profile/qt/fitters.py new file mode 100644 index 000000000..369214a79 --- /dev/null +++ b/glue/viewers/profile/qt/fitters.py @@ -0,0 +1,160 @@ +# TODO: move to glue.core.qt + +from qtpy import QtWidgets, QtGui + +from glue.core.qt.simpleforms import build_form_item + +__all__ = ['ConstraintsWidget', 'FitSettingsWidget'] + + +class ConstraintsWidget(QtWidgets.QWidget): + + """ + A widget to display and tweak the constraints of a :class:`~glue.core.fitters.BaseFitter1D` + """ + + def __init__(self, constraints, parent=None): + """ + Parameters + ---------- + constraints : dict + The `contstraints` property of a :class:`~glue.core.fitters.BaseFitter1D` + object + parent : QtWidgets.QWidget (optional) + The parent of this widget + """ + super(ConstraintsWidget, self).__init__(parent) + self.constraints = constraints + + self.layout = QtWidgets.QGridLayout() + self.layout.setContentsMargins(2, 2, 2, 2) + self.layout.setSpacing(4) + + self.setLayout(self.layout) + + self.layout.addWidget(QtWidgets.QLabel("Estimate"), 0, 1) + self.layout.addWidget(QtWidgets.QLabel("Fixed"), 0, 2) + self.layout.addWidget(QtWidgets.QLabel("Bounded"), 0, 3) + self.layout.addWidget(QtWidgets.QLabel("Lower Bound"), 0, 4) + self.layout.addWidget(QtWidgets.QLabel("Upper Bound"), 0, 5) + + self._widgets = {} + names = sorted(list(self.constraints.keys())) + + for k in names: + row = [] + w = QtWidgets.QLabel(k) + row.append(w) + + v = QtGui.QDoubleValidator() + e = QtWidgets.QLineEdit() + e.setValidator(v) + e.setText(str(constraints[k]['value'] or '')) + row.append(e) + + w = QtWidgets.QCheckBox() + w.setChecked(constraints[k]['fixed']) + fix = w + row.append(w) + + w = QtWidgets.QCheckBox() + limits = constraints[k]['limits'] + w.setChecked(limits is not None) + bound = w + row.append(w) + + e = QtWidgets.QLineEdit() + e.setValidator(v) + if limits is not None: + e.setText(str(limits[0])) + row.append(e) + + e = QtWidgets.QLineEdit() + e.setValidator(v) + if limits is not None: + e.setText(str(limits[1])) + row.append(e) + + def unset(w): + def result(active): + if active: + w.setChecked(False) + return result + + fix.toggled.connect(unset(bound)) + bound.toggled.connect(unset(fix)) + + self._widgets[k] = row + + for i, row in enumerate(names, 1): + for j, widget in enumerate(self._widgets[row]): + self.layout.addWidget(widget, i, j) + + def settings(self, name): + """ Return the constraints for a single model parameter """ + row = self._widgets[name] + name, value, fixed, limited, lo, hi = row + value = float(value.text()) if value.text() else None + fixed = fixed.isChecked() + limited = limited.isChecked() + lo = lo.text() + hi = hi.text() + limited = limited and not ((not lo) or (not hi)) + limits = None if not limited else [float(lo), float(hi)] + return dict(value=value, fixed=fixed, limits=limits) + + def update_constraints(self, fitter): + """ Update the constraints in a :class:`~glue.core.fitters.BaseFitter1D` + based on the settings in this widget + """ + for name in self._widgets: + s = self.settings(name) + fitter.set_constraint(name, **s) + + +class FitSettingsWidget(QtWidgets.QDialog): + + def __init__(self, fitter, parent=None): + super(FitSettingsWidget, self).__init__(parent) + self.fitter = fitter + + self._build_form() + self._connect() + self.setModal(True) + + def _build_form(self): + fitter = self.fitter + + l = QtWidgets.QFormLayout() + options = fitter.options + self.widgets = {} + self.forms = {} + + for k in sorted(options): + item = build_form_item(fitter, k) + l.addRow(item.label, item.widget) + self.widgets[k] = item.widget + self.forms[k] = item # need to prevent garbage collection + + constraints = fitter.constraints + if constraints: + self.constraints = ConstraintsWidget(constraints) + l.addRow(self.constraints) + else: + self.constraints = None + + self.okcancel = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | + QtWidgets.QDialogButtonBox.Cancel) + l.addRow(self.okcancel) + self.setLayout(l) + + def _connect(self): + self.okcancel.accepted.connect(self.accept) + self.okcancel.rejected.connect(self.reject) + self.accepted.connect(self.update_fitter_from_settings) + + def update_fitter_from_settings(self): + for k, v in self.widgets.items(): + setattr(self.fitter, k, v.value()) + if self.constraints is not None: + self.constraints.update_constraints(self.fitter) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index fb7ef4441..a9e1bf02e 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -1,9 +1,15 @@ import os +import traceback -from qtpy import QtWidgets +from qtpy import QtWidgets, QtGui + +from matplotlib.colors import to_hex + +from glue.config import fit_plugin from glue.utils.qt import load_ui, fix_tab_widget_fontsize from glue.viewers.profile.mouse_mode import NavigateMouseMode, RangeMouseMode - +from glue.viewers.profile.qt.fitters import FitSettingsWidget +from glue.utils.qt import Worker __all__ = ['ProfileTools'] @@ -25,16 +31,145 @@ def __init__(self, parent=None): self.viewer = parent def enable(self): + self.nav_mode = NavigateMouseMode(self.viewer) self.rng_mode = RangeMouseMode(self.viewer) + self.ui.tabs.setCurrentIndex(0) + self.ui.tabs.currentChanged.connect(self._on_tab_change) self._on_tab_change() + self.ui.button_settings.clicked.connect(self._on_settings) + self.ui.button_fit.clicked.connect(self._on_fit) + self.ui.button_clear.clicked.connect(self._on_clear) + self.ui.button_collapse.clicked.connect(self._on_collapse) + + font = QtGui.QFont("Courier") + font.setStyleHint(font.Monospace) + self.ui.text_log.document().setDefaultFont(font) + self.ui.text_log.setLineWrapMode(self.ui.text_log.NoWrap) + + self.axes = self.viewer.axes + self.canvas = self.axes.figure.canvas + + self._fit_artists = [] + + for fitter in list(fit_plugin): + self.ui.combosel_fit_function.addItem(fitter.label, userData=fitter()) + + self._toolbar_connected = False + + self.viewer.toolbar_added.connect(self._on_toolbar_added) + + @property + def fitter(self): + # FIXME: might not work with PyQt4 + return self.ui.combosel_fit_function.currentData() + + def _on_settings(self): + d = FitSettingsWidget(self.fitter) + d.exec_() + + def _on_fit(self): + """ + Fit a model to the data + + The fitting happens on a dedicated thread, to keep the UI + responsive + """ + + x_range = self.rng_mode.state.x_range + fitter = self.fitter + + def on_success(result): + fit_results, x, y = result + report = "" + for layer_artist in fit_results: + report += ("{1}" + "".format(to_hex(layer_artist.state.color), + layer_artist.layer.label)) + report += "
" + fitter.summarize(fit_results[layer_artist], x, y) + "
" + self._report_fit(report) + self._plot_fit(fitter, fit_results, x, y) + + def on_fail(exc_info): + exc = '\n'.join(traceback.format_exception(*exc_info)) + self._report_fit("Error during fitting:\n%s" % exc) + + def on_done(): + self.ui.button_fit.setText("Fit") + self.ui.button_fit.setEnabled(True) + self.canvas.draw() + + self.ui.button_fit.setText("Running...") + self.ui.button_fit.setEnabled(False) + + w = Worker(self._fit, fitter, xlim=x_range) + w.result.connect(on_success) + w.error.connect(on_fail) + w.finished.connect(on_done) + + self._fit_worker = w # hold onto a reference + w.start() + + def _report_fit(self, report): + self.ui.text_log.document().setHtml(report) + + def _on_clear(self): + self.ui.text_log.document().setPlainText('') + self._clear_fit() + self.canvas.draw() + + def _fit(self, fitter, xlim=None): + + # We cycle through all the visible layers and get the plotted data + # for each one of them. + + results = {} + for layer in self.viewer.layers: + if layer.enabled and layer.visible: + if hasattr(layer, '_visible_data'): + x, y = layer._visible_data + if len(x) > 0: + results[layer] = fitter.build_and_fit(x, y) + + return results, x, y + + def _clear_fit(self): + for artist in self._fit_artists[:]: + print(artist) + artist.remove() + self._fit_artists.remove(artist) + + def _plot_fit(self, fitter, fit_result, x, y): + + self._clear_fit() + + for layer in fit_result: + # y_model = fitter.predict(fit_result[layer], x) + self._fit_artists.append(fitter.plot(fit_result[layer], self.axes, x, + alpha=layer.state.alpha, + linewidth=layer.state.linewidth * 0.5, + color=layer.state.color)[0]) + + self.canvas.draw() + + def _on_collapse(self): + pass + @property def mode(self): return MODES[self.tabs.currentIndex()] + def _on_toolbar_added(self, *event): + self.viewer.toolbar.tool_activated.connect(self._on_toolbar_activate) + self.viewer.toolbar.tool_deactivated.connect(self._on_tab_change) + + def _on_toolbar_activate(self, *event): + self.rng_mode.deactivate() + self.nav_mode.deactivate() + def _on_tab_change(self, *event): mode = self.mode if mode == 'navigate': diff --git a/glue/viewers/profile/qt/profile_tools.ui b/glue/viewers/profile/qt/profile_tools.ui index e99aaa4e1..f2f30a04c 100644 --- a/glue/viewers/profile/qt/profile_tools.ui +++ b/glue/viewers/profile/qt/profile_tools.ui @@ -107,7 +107,7 @@ - + diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 6533ff244..8e50c5c28 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -2,6 +2,7 @@ import numpy as np +from glue.core import Data from glue.external.echo import delay_callback from glue.viewers.matplotlib.state import (MatplotlibDataViewerState, MatplotlibLayerState, @@ -84,3 +85,25 @@ class ProfileLayerState(MatplotlibLayerState): """ linewidth = DDCProperty(1, docstring='The width of the line') + + def get_profile(self): + + if isinstance(self.layer, Data): + data_values = self.layer[self.viewer_state.y_att] + else: + data_values = self.layer.data[self.viewer_state.y_att].copy() + mask = self.layer.to_mask() + if np.sum(mask) == 0: + return [], [] + data_values[~mask] = np.nan + + # Collapse along all dimensions except x_att + # TODO: in future we should optimize the case where the mask is much + # smaller than the data to just average the relevant 'spaxels' in the + # data rather than collapsing the whole cube. + axes = list(range(data_values.ndim)) + axes.remove(self.viewer_state.x_att.axis) + profile_values = self.viewer_state.function(data_values, axis=tuple(axes)) + profile_values[np.isnan(profile_values)] = 0. + + return np.arange(len(profile_values)), profile_values From b48e1715792a62bb012b3026a5015622ff3c0ecb Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 10:38:10 +0100 Subject: [PATCH 06/42] Make the profile tools able to add themselves to the ProfileViewer --- glue/viewers/common/qt/tool.py | 10 +++--- glue/viewers/profile/qt/data_viewer.py | 13 +------- glue/viewers/profile/qt/profile_tools.py | 42 ++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/glue/viewers/common/qt/tool.py b/glue/viewers/common/qt/tool.py index 87c9ecef9..60de46a17 100644 --- a/glue/viewers/common/qt/tool.py +++ b/glue/viewers/common/qt/tool.py @@ -22,12 +22,12 @@ class Tool(object): enabled = CallbackProperty(True) - icon = None + icon = 'glue_spectrum' tool_id = None - action_text = None - tool_tip = None - status_tip = None - shortcut = None + action_text = '' + tool_tip = '' + status_tip = '' + shortcut = '' def __init__(self, viewer=None): self.viewer = viewer diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py index 6d7b365e3..7933188c1 100644 --- a/glue/viewers/profile/qt/data_viewer.py +++ b/glue/viewers/profile/qt/data_viewer.py @@ -9,7 +9,6 @@ from glue.viewers.profile.layer_artist import ProfileLayerArtist from glue.viewers.profile.qt.options_widget import ProfileOptionsWidget from glue.viewers.profile.state import ProfileViewerState -from glue.viewers.profile.mouse_mode import RangeMouseMode from glue.viewers.profile.qt.profile_tools import ProfileTools from glue.viewers.common.qt import toolbar_mode # noqa @@ -27,22 +26,12 @@ class ProfileViewer(MatplotlibDataViewer): _data_artist_cls = ProfileLayerArtist _subset_artist_cls = ProfileLayerArtist - tools = ['select:xrange'] + tools = ['select:xrange', 'profile-tools'] def __init__(self, session, parent=None, state=None): super(ProfileViewer, self).__init__(session, parent, state=state) self.state.add_callback('x_att', self._update_axes) self.state.add_callback('y_att', self._update_axes) - self._profile_tools.enable() - - def setCentralWidget(self, widget): - self._profile_tools = ProfileTools(self) - container_widget = QtWidgets.QWidget() - container_layout = QtWidgets.QHBoxLayout() - container_widget.setLayout(container_layout) - container_layout.addWidget(widget) - container_layout.addWidget(self._profile_tools) - super(ProfileViewer, self).setCentralWidget(container_widget) def _update_axes(self, *args): diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index a9e1bf02e..19408a8d6 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -1,15 +1,19 @@ import os import traceback +import numpy as np + +from qtpy.QtCore import Qt from qtpy import QtWidgets, QtGui from matplotlib.colors import to_hex -from glue.config import fit_plugin +from glue.config import fit_plugin, viewer_tool from glue.utils.qt import load_ui, fix_tab_widget_fontsize from glue.viewers.profile.mouse_mode import NavigateMouseMode, RangeMouseMode from glue.viewers.profile.qt.fitters import FitSettingsWidget from glue.utils.qt import Worker +from glue.viewers.common.qt.tool import Tool __all__ = ['ProfileTools'] @@ -17,6 +21,36 @@ MODES = ['navigate', 'fit', 'collapse'] +@viewer_tool +class ProfileTool(Tool): + + tool_id = 'profile-tools' + + def __init__(self, viewer): + super(ProfileTool, self).__init__(viewer) + self._profile_tools = ProfileTools(viewer) + container_widget = QtWidgets.QSplitter(Qt.Horizontal) + plot_widget = viewer.centralWidget() + container_widget.addWidget(plot_widget) + container_widget.addWidget(self._profile_tools) + viewer.setCentralWidget(container_widget) + # container_widget = QtWidgets.QWidget() + # container_layout = QtWidgets.QHBoxLayout() + # plot_widget = viewer.centralWidget() + # container_widget.setLayout(container_layout) + # container_layout.addWidget(plot_widget) + # container_layout.addWidget(self._profile_tools) + # viewer.setCentralWidget(container_widget) + self._profile_tools.enable() + self._profile_tools.hide() + + def activate(self): + if self._profile_tools.isVisible(): + self._profile_tools.hide() + else: + self._profile_tools.show() + + class ProfileTools(QtWidgets.QWidget): def __init__(self, parent=None): @@ -131,14 +165,16 @@ def _fit(self, fitter, xlim=None): if layer.enabled and layer.visible: if hasattr(layer, '_visible_data'): x, y = layer._visible_data + x = np.asarray(x) + y = np.asarray(y) + keep = (x >= min(xlim)) & (x <= max(xlim)) if len(x) > 0: - results[layer] = fitter.build_and_fit(x, y) + results[layer] = fitter.build_and_fit(x[keep], y[keep]) return results, x, y def _clear_fit(self): for artist in self._fit_artists[:]: - print(artist) artist.remove() self._fit_artists.remove(artist) From ccf719a676da6cc9b135933cfb83a44ba2fc96a8 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 14:32:09 +0100 Subject: [PATCH 07/42] Implementing collapsing --- glue/viewers/profile/mouse_mode.py | 4 +- glue/viewers/profile/qt/profile_tools.py | 106 +++++++++++++++++++++-- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index 381a813a5..d632af32e 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -14,14 +14,16 @@ class NavigationModeState(State): class NavigateMouseMode(MouseMode): - def __init__(self, viewer): + def __init__(self, viewer, press_callback=None): super(NavigateMouseMode, self).__init__(viewer) self.state = NavigationModeState() self.state.add_callback('x', self._update_artist) self.pressed = False self._viewer = viewer + self._press_callback = press_callback def press(self, event): + self._press_callback() self.pressed = True if not event.inaxes: return diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 19408a8d6..fe7241576 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -14,12 +14,24 @@ from glue.viewers.profile.qt.fitters import FitSettingsWidget from glue.utils.qt import Worker from glue.viewers.common.qt.tool import Tool +from glue.viewers.image.state import AggregateSlice +from glue.core.aggregate import mom1, mom2 +from glue.core import Data +from glue.viewers.image.qt import ImageViewer __all__ = ['ProfileTools'] MODES = ['navigate', 'fit', 'collapse'] +COLLAPSE_FUNCS = {np.nanmean: 'Mean', + np.nanmedian: 'Median', + np.nanmin: 'Minimum', + np.nanmax: 'Maximum', + np.nansum: 'Sum', + mom1: 'Moment 1', + mom2: 'Moment 2'} + @viewer_tool class ProfileTool(Tool): @@ -34,13 +46,6 @@ def __init__(self, viewer): container_widget.addWidget(plot_widget) container_widget.addWidget(self._profile_tools) viewer.setCentralWidget(container_widget) - # container_widget = QtWidgets.QWidget() - # container_layout = QtWidgets.QHBoxLayout() - # plot_widget = viewer.centralWidget() - # container_widget.setLayout(container_layout) - # container_layout.addWidget(plot_widget) - # container_layout.addWidget(self._profile_tools) - # viewer.setCentralWidget(container_widget) self._profile_tools.enable() self._profile_tools.hide() @@ -63,12 +68,16 @@ def __init__(self, parent=None): fix_tab_widget_fontsize(self.ui.tabs) self.viewer = parent + self.image_viewer = None def enable(self): - self.nav_mode = NavigateMouseMode(self.viewer) + self.nav_mode = NavigateMouseMode(self.viewer, + press_callback=self._on_nav_activate) self.rng_mode = RangeMouseMode(self.viewer) + self.nav_mode.state.add_callback('x', self._on_slider_change) + self.ui.tabs.setCurrentIndex(0) self.ui.tabs.currentChanged.connect(self._on_tab_change) @@ -92,6 +101,9 @@ def enable(self): for fitter in list(fit_plugin): self.ui.combosel_fit_function.addItem(fitter.label, userData=fitter()) + for func, display in COLLAPSE_FUNCS.items(): + self.ui.combosel_collapse_function.addItem(display, userData=func) + self._toolbar_connected = False self.viewer.toolbar_added.connect(self._on_toolbar_added) @@ -101,6 +113,25 @@ def fitter(self): # FIXME: might not work with PyQt4 return self.ui.combosel_fit_function.currentData() + @property + def collapse_function(self): + # FIXME: might not work with PyQt4 + return self.ui.combosel_collapse_function.currentData() + + def _on_nav_activate(self, *args): + self._nav_data = self._visible_data() + self._nav_viewers = {} + for data in self._nav_data: + self._nav_viewers[data] = self._viewers_with_data_slice(data, self.viewer.state.x_att) + + def _on_slider_change(self, *args): + x = self.nav_mode.state.x + for data in self._nav_data: + for viewer in self._nav_viewers[data]: + slices = list(viewer.state.slices) + slices[self.viewer.state.x_att.axis] = int(x) + viewer.state.slices = slices + def _on_settings(self): d = FitSettingsWidget(self.fitter) d.exec_() @@ -191,8 +222,65 @@ def _plot_fit(self, fitter, fit_result, x, y): self.canvas.draw() + def _visible_data(self): + datasets = [] + for layer_artist in self.viewer.layers: + if layer_artist.enabled and layer_artist.visible: + if isinstance(layer_artist.state.layer, Data): + datasets.append(layer_artist.state.layer) + return datasets + + def _viewers_with_data_slice(self, data, xatt): + + if self.viewer.session.application is None: + return [] + + viewers = [] + for tab in self.viewer.session.application.viewers: + for viewer in tab: + if isinstance(viewer, ImageViewer): + for layer_artist in viewer._layer_artist_container[data]: + if layer_artist.enabled and layer_artist.visible: + if len(viewer.state.slices) >= xatt.axis: + viewers.append(viewer) + return viewers + def _on_collapse(self): - pass + + func = self.collapse_function + x_range = self.rng_mode.state.x_range + imin, imax = int(x_range[0]), int(x_range[1]) + + print("on_collapse") + for data in self._visible_data(): + print("VIS", data.label) + for viewer in self._viewers_with_data_slice(data, self.viewer.state.x_att): + print(type(viewer)) + + slices = list(viewer.state.slices) + + current_slice = slices[self.viewer.state.x_att.axis] + + if isinstance(current_slice, AggregateSlice): + current_slice = current_slice.center + + slices[self.viewer.state.x_att.axis] = AggregateSlice(slice(imin, imax), + current_slice, + func) + + print(slices) + + viewer.state.slices = tuple(slices) + + # Save a local copy of the collapsed array + # for layer_state in self.viewer_state.layers: + # if layer_state.layer is self.viewer_state.reference_data: + # break + # else: + # raise Exception("Couldn't find layer corresponding to reference data") + # + # self._agg = layer_state.get_sliced_data() + # pass @property def mode(self): From f8fa1c9e37a74fbcf4c39f9706fd2f5e9ea3e157 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 15:47:57 +0100 Subject: [PATCH 08/42] Minor esthetic improvements --- glue/viewers/profile/mouse_mode.py | 14 +++++++++---- glue/viewers/profile/qt/profile_tools.py | 26 +++++++----------------- glue/viewers/profile/qt/profile_tools.ui | 9 +++++--- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index d632af32e..5e3fd9828 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -67,7 +67,7 @@ def x_range(self): return self.x_min, self.x_max -PICK_THRESH = 0.05 +PICK_THRESH = 0.02 class RangeMouseMode(MouseMode): @@ -135,11 +135,17 @@ def _update_artist(self, *args): if hasattr(self, '_lines'): self._lines[0].set_data([self.state.x_min, self.state.x_min], [0, 1]) self._lines[1].set_data([self.state.x_max, self.state.x_max], [0, 1]) - self._lines[2].set_data([self.state.x_min, self.state.x_max], [y_mid, y_mid]) + self._interval.set_xy([[self.state.x_min, 0], + [self.state.x_min, 1], + [self.state.x_max, 1], + [self.state.x_max, 0], + [self.state.x_min, 0]]) else: self._lines = (self._axes.axvline(self.state.x_min, color=COLOR), - self._axes.axvline(self.state.x_max, color=COLOR), - self._axes.plot([self.state.x_min, self.state.x_max], [y_mid, y_mid], color=COLOR)[0]) + self._axes.axvline(self.state.x_max, color=COLOR)) + self._interval = self._axes.axvspan(self.state.x_min, + self.state.x_max, + color=COLOR, alpha=0.05) self._canvas.draw() def deactivate(self): diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index fe7241576..15e754f9b 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -25,12 +25,12 @@ MODES = ['navigate', 'fit', 'collapse'] COLLAPSE_FUNCS = {np.nanmean: 'Mean', - np.nanmedian: 'Median', - np.nanmin: 'Minimum', - np.nanmax: 'Maximum', - np.nansum: 'Sum', - mom1: 'Moment 1', - mom2: 'Moment 2'} + np.nanmedian: 'Median', + np.nanmin: 'Minimum', + np.nanmax: 'Maximum', + np.nansum: 'Sum', + mom1: 'Moment 1', + mom2: 'Moment 2'} @viewer_tool @@ -249,11 +249,9 @@ def _on_collapse(self): func = self.collapse_function x_range = self.rng_mode.state.x_range - imin, imax = int(x_range[0]), int(x_range[1]) + imin, imax = int(min(x_range)), int(max(x_range)) - print("on_collapse") for data in self._visible_data(): - print("VIS", data.label) for viewer in self._viewers_with_data_slice(data, self.viewer.state.x_att): print(type(viewer)) @@ -272,16 +270,6 @@ def _on_collapse(self): viewer.state.slices = tuple(slices) - # Save a local copy of the collapsed array - # for layer_state in self.viewer_state.layers: - # if layer_state.layer is self.viewer_state.reference_data: - # break - # else: - # raise Exception("Couldn't find layer corresponding to reference data") - # - # self._agg = layer_state.get_sliced_data() - # pass - @property def mode(self): return MODES[self.tabs.currentIndex()] diff --git a/glue/viewers/profile/qt/profile_tools.ui b/glue/viewers/profile/qt/profile_tools.ui index f2f30a04c..016b11ba3 100644 --- a/glue/viewers/profile/qt/profile_tools.ui +++ b/glue/viewers/profile/qt/profile_tools.ui @@ -6,8 +6,8 @@ 0 0 - 237 - 270 + 272 + 265 @@ -27,7 +27,10 @@ - <html><head/><body><p>To <span style=" font-weight:600;">slide </span>through the cube, drag the handle or double-click</p></body></html> + <html><head/><body><p>To <span style=" font-weight:600;">slide </span>through any cubes in image viewers that show the same data as the one here, drag the handle from side to side.</p><p>Note that modifying the sliders in the image viewers will not change the slider here or in other viewers.</p></body></html> + + + Qt::AlignJustify|Qt::AlignVCenter true From 11556ba4785e168a77e881358f55edc66ec9b1f5 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 16:07:19 +0100 Subject: [PATCH 09/42] More fixes/improvements --- glue/{viewers/profile => core}/qt/fitters.py | 0 glue/viewers/profile/mouse_mode.py | 11 +++-- glue/viewers/profile/qt/profile_tools.py | 8 ++- glue/viewers/profile/qt/profile_tools.ui | 52 ++++++++++++++++++-- 4 files changed, 63 insertions(+), 8 deletions(-) rename glue/{viewers/profile => core}/qt/fitters.py (100%) diff --git a/glue/viewers/profile/qt/fitters.py b/glue/core/qt/fitters.py similarity index 100% rename from glue/viewers/profile/qt/fitters.py rename to glue/core/qt/fitters.py diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index 5e3fd9828..c36fe5dc6 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -150,14 +150,17 @@ def _update_artist(self, *args): def deactivate(self): if hasattr(self, '_lines'): - for line in self._lines: - line.set_visible(False) + self._lines[0].set_visible(False) + self._lines[1].set_visible(False) + self._interval.set_visible(False) + self._canvas.draw() super(RangeMouseMode, self).deactivate() def activate(self): if hasattr(self, '_lines'): - for line in self._lines: - line.set_visible(True) + self._lines[0].set_visible(True) + self._lines[1].set_visible(True) + self._interval.set_visible(True) self._canvas.draw() super(RangeMouseMode, self).activate() diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 15e754f9b..7be6e4fb1 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -11,7 +11,7 @@ from glue.config import fit_plugin, viewer_tool from glue.utils.qt import load_ui, fix_tab_widget_fontsize from glue.viewers.profile.mouse_mode import NavigateMouseMode, RangeMouseMode -from glue.viewers.profile.qt.fitters import FitSettingsWidget +from glue.core.qt.fitters import FitSettingsWidget from glue.utils.qt import Worker from glue.viewers.common.qt.tool import Tool from glue.viewers.image.state import AggregateSlice @@ -144,6 +144,9 @@ def _on_fit(self): responsive """ + if self.rng_mode.state.x_min is None or self.rng_mode.state.x_max is None: + return + x_range = self.rng_mode.state.x_range fitter = self.fitter @@ -247,6 +250,9 @@ def _viewers_with_data_slice(self, data, xatt): def _on_collapse(self): + if self.rng_mode.state.x_min is None or self.rng_mode.state.x_max is None: + return + func = self.collapse_function x_range = self.rng_mode.state.x_range imin, imax = int(min(x_range)), int(max(x_range)) diff --git a/glue/viewers/profile/qt/profile_tools.ui b/glue/viewers/profile/qt/profile_tools.ui index 016b11ba3..3e3b2a65b 100644 --- a/glue/viewers/profile/qt/profile_tools.ui +++ b/glue/viewers/profile/qt/profile_tools.ui @@ -6,8 +6,8 @@ 0 0 - 272 - 265 + 293 + 254 @@ -17,13 +17,26 @@ - 0 + 1 Navigate + + + + Qt::Vertical + + + + 20 + 40 + + + + @@ -57,6 +70,16 @@ Fit + + + + Click and drag in the profile to define the range over which to fit models to the data. + + + true + + + @@ -119,6 +142,29 @@ Collapse + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Click and drag in the profile to define the range over which to fit models to the data. + + + true + + + From 0e7c6626524f11c871e2f76acbfd1c44751d9d21 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 16:08:51 +0100 Subject: [PATCH 10/42] Added changelog entry --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index eeadc7f6e..90347f6fd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,10 @@ Full changelog v0.13.0 (unreleased) -------------------- +* Re-write spectrum viewer into a generic profile viewer that uses + subsets to define the areas in which to compute profiles rather + than custom ROIs. [#1635] + * Improve performance when changing visual attributes of subsets. [#1617] From 51d51e6402064cbdb3dfa241cbfaf5423561ca82 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 16:31:34 +0100 Subject: [PATCH 11/42] Avoid circular references --- glue/viewers/profile/mouse_mode.py | 5 ++--- glue/viewers/profile/qt/profile_tools.py | 7 ++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index c36fe5dc6..2b5f1d931 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -1,3 +1,5 @@ +import weakref + from glue.external.echo import CallbackProperty, delay_callback from glue.core.state_objects import State from glue.viewers.common.qt.mouse_mode import MouseMode @@ -19,7 +21,6 @@ def __init__(self, viewer, press_callback=None): self.state = NavigationModeState() self.state.add_callback('x', self._update_artist) self.pressed = False - self._viewer = viewer self._press_callback = press_callback def press(self, event): @@ -81,7 +82,6 @@ def __init__(self, viewer): self.mode = None self.move_params = None - self._viewer = viewer def press(self, event): @@ -131,7 +131,6 @@ def release(self, event): def _update_artist(self, *args): y_min, y_max = self._axes.get_ylim() - y_mid = 0.5 * (y_min + y_max) if hasattr(self, '_lines'): self._lines[0].set_data([self.state.x_min, self.state.x_min], [0, 1]) self._lines[1].set_data([self.state.x_max, self.state.x_max], [0, 1]) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 7be6e4fb1..739215a88 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -1,4 +1,5 @@ import os +import weakref import traceback import numpy as np @@ -67,9 +68,13 @@ def __init__(self, parent=None): fix_tab_widget_fontsize(self.ui.tabs) - self.viewer = parent + self._viewer = weakref.ref(parent) self.image_viewer = None + @property + def viewer(self): + return self._viewer() + def enable(self): self.nav_mode = NavigateMouseMode(self.viewer, From 0f7f330c9a4eb6123496166f6fe886235cd56631 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 17:45:41 +0100 Subject: [PATCH 12/42] Added a new tool to start the profile viewer from the image viewer --- glue/viewers/common/qt/tool.py | 10 +++++----- glue/viewers/image/qt/data_viewer.py | 5 +++-- glue/viewers/image/qt/profile_viewer_tool.py | 19 +++++++++++++++++++ glue/viewers/profile/qt/data_viewer.py | 2 +- glue/viewers/profile/qt/profile_tools.py | 7 ++++--- 5 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 glue/viewers/image/qt/profile_viewer_tool.py diff --git a/glue/viewers/common/qt/tool.py b/glue/viewers/common/qt/tool.py index 60de46a17..87c9ecef9 100644 --- a/glue/viewers/common/qt/tool.py +++ b/glue/viewers/common/qt/tool.py @@ -22,12 +22,12 @@ class Tool(object): enabled = CallbackProperty(True) - icon = 'glue_spectrum' + icon = None tool_id = None - action_text = '' - tool_tip = '' - status_tip = '' - shortcut = '' + action_text = None + tool_tip = None + status_tip = None + shortcut = None def __init__(self, viewer=None): self.viewer = viewer diff --git a/glue/viewers/image/qt/data_viewer.py b/glue/viewers/image/qt/data_viewer.py index f68fd853d..d49120858 100644 --- a/glue/viewers/image/qt/data_viewer.py +++ b/glue/viewers/image/qt/data_viewer.py @@ -25,6 +25,7 @@ # Import the mouse mode to make sure it gets registered from glue.viewers.image.contrast_mouse_mode import ContrastBiasMode # noqa +from glue.viewers.image.qt.profile_viewer_tool import ProfileViewerTool # noqa from glue.viewers.image.pixel_selection_mode import PixelSelectionTool # noqa __all__ = ['ImageViewer'] @@ -68,8 +69,8 @@ class ImageViewer(MatplotlibDataViewer): tools = ['select:rectangle', 'select:xrange', 'select:yrange', 'select:circle', - 'select:polygon', 'image:point_selection', - 'image:contrast_bias', 'save:python'] + 'select:polygon', 'image:point_selection', 'image:contrast_bias', + 'save:python', 'profile-viewer'] def __init__(self, session, parent=None, state=None): self._wcs_set = False diff --git a/glue/viewers/image/qt/profile_viewer_tool.py b/glue/viewers/image/qt/profile_viewer_tool.py new file mode 100644 index 000000000..2e02ed81a --- /dev/null +++ b/glue/viewers/image/qt/profile_viewer_tool.py @@ -0,0 +1,19 @@ +from glue.config import viewer_tool +from glue.viewers.common.qt.tool import Tool + + +@viewer_tool +class ProfileViewerTool(Tool): + + icon = 'glue_spectrum' + tool_id = 'profile-viewer' + + def __init__(self, viewer): + super(ProfileViewerTool, self).__init__(viewer) + + def activate(self): + from glue.viewers.profile.qt import ProfileViewer + profile_viewer = self.viewer.session.application.new_data_viewer(ProfileViewer) + for data in self.viewer.session.data_collection: + if data in self.viewer._layer_artist_container: + profile_viewer.add_data(data) diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py index 7933188c1..c866c286f 100644 --- a/glue/viewers/profile/qt/data_viewer.py +++ b/glue/viewers/profile/qt/data_viewer.py @@ -26,7 +26,7 @@ class ProfileViewer(MatplotlibDataViewer): _data_artist_cls = ProfileLayerArtist _subset_artist_cls = ProfileLayerArtist - tools = ['select:xrange', 'profile-tools'] + tools = ['select:xrange', 'profile-analysis'] def __init__(self, session, parent=None, state=None): super(ProfileViewer, self).__init__(session, parent, state=state) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 739215a88..bf2149bb7 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -35,12 +35,13 @@ @viewer_tool -class ProfileTool(Tool): +class ProfileAnalysisTool(Tool): - tool_id = 'profile-tools' + icon = 'glue_spectrum' + tool_id = 'profile-analysis' def __init__(self, viewer): - super(ProfileTool, self).__init__(viewer) + super(ProfileAnalysisTool, self).__init__(viewer) self._profile_tools = ProfileTools(viewer) container_widget = QtWidgets.QSplitter(Qt.Horizontal) plot_widget = viewer.centralWidget() From 0d2bd2a2d7bbb8a93cdc36d9893bdfbea13018a1 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 18:04:38 +0100 Subject: [PATCH 13/42] Moved Qt fitter-related tests --- glue/core/qt/tests/test_fitters.py | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 glue/core/qt/tests/test_fitters.py diff --git a/glue/core/qt/tests/test_fitters.py b/glue/core/qt/tests/test_fitters.py new file mode 100644 index 000000000..fe67cf528 --- /dev/null +++ b/glue/core/qt/tests/test_fitters.py @@ -0,0 +1,46 @@ +from mock import MagicMock + +from glue.core.fitters import SimpleAstropyGaussianFitter, PolynomialFitter + +from ..fitters import ConstraintsWidget, FitSettingsWidget + + +class TestConstraintsWidget(object): + + def setup_method(self, method): + self.constraints = dict(a=dict(fixed=True, value=1, limits=None)) + self.widget = ConstraintsWidget(self.constraints) + + def test_settings(self): + assert self.widget.settings('a') == dict(fixed=True, value=1, + limits=None) + + def test_update_settings(self): + self.widget._widgets['a'][2].setChecked(False) + assert self.widget.settings('a')['fixed'] is False + + def test_update_constraints(self): + self.widget._widgets['a'][2].setChecked(False) + fitter = MagicMock() + self.widget.update_constraints(fitter) + fitter.set_constraint.assert_called_once_with('a', + fixed=False, value=1, + limits=None) + + +class TestFitSettingsWidget(object): + + def test_option(self): + f = PolynomialFitter() + f.degree = 1 + w = FitSettingsWidget(f) + w.widgets['degree'].setValue(5) + w.update_fitter_from_settings() + assert f.degree == 5 + + def test_set_constraints(self): + f = SimpleAstropyGaussianFitter() + w = FitSettingsWidget(f) + w.constraints._widgets['amplitude'][2].setChecked(True) + w.update_fitter_from_settings() + assert f.constraints['amplitude']['fixed'] From 3aa30619932f362c9b04db4b15f779589c48a59d Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 18:08:56 +0100 Subject: [PATCH 14/42] Remove old spectrum plugin --- glue/plugins/tools/spectrum_tool/__init__.py | 4 - .../tools/spectrum_tool/qt/__init__.py | 1 - .../tools/spectrum_tool/qt/profile_viewer.py | 493 -------- .../spectrum_tool/qt/spectrum_fit_panel.ui | 162 --- .../tools/spectrum_tool/qt/spectrum_tool.py | 1004 ----------------- .../tools/spectrum_tool/qt/tests/__init__.py | 0 .../qt/tests/test_profile_viewer.py | 179 --- .../qt/tests/test_spectrum_tool.py | 271 ----- .../tools/spectrum_tool/tests/__init__.py | 0 setup.py | 1 - 10 files changed, 2115 deletions(-) delete mode 100644 glue/plugins/tools/spectrum_tool/__init__.py delete mode 100644 glue/plugins/tools/spectrum_tool/qt/__init__.py delete mode 100644 glue/plugins/tools/spectrum_tool/qt/profile_viewer.py delete mode 100644 glue/plugins/tools/spectrum_tool/qt/spectrum_fit_panel.ui delete mode 100644 glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py delete mode 100644 glue/plugins/tools/spectrum_tool/qt/tests/__init__.py delete mode 100644 glue/plugins/tools/spectrum_tool/qt/tests/test_profile_viewer.py delete mode 100644 glue/plugins/tools/spectrum_tool/qt/tests/test_spectrum_tool.py delete mode 100644 glue/plugins/tools/spectrum_tool/tests/__init__.py diff --git a/glue/plugins/tools/spectrum_tool/__init__.py b/glue/plugins/tools/spectrum_tool/__init__.py deleted file mode 100644 index 558f750f7..000000000 --- a/glue/plugins/tools/spectrum_tool/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -def setup(): - from glue.viewers.image.qt import ImageViewer - from glue.plugins.tools.spectrum_tool.qt import SpectrumExtractorMode # noqa - ImageViewer.tools.append('spectrum') diff --git a/glue/plugins/tools/spectrum_tool/qt/__init__.py b/glue/plugins/tools/spectrum_tool/qt/__init__.py deleted file mode 100644 index f445df571..000000000 --- a/glue/plugins/tools/spectrum_tool/qt/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .spectrum_tool import * \ No newline at end of file diff --git a/glue/plugins/tools/spectrum_tool/qt/profile_viewer.py b/glue/plugins/tools/spectrum_tool/qt/profile_viewer.py deleted file mode 100644 index a0941a678..000000000 --- a/glue/plugins/tools/spectrum_tool/qt/profile_viewer.py +++ /dev/null @@ -1,493 +0,0 @@ -from __future__ import absolute_import, division, print_function - -import numpy as np -from matplotlib.transforms import blended_transform_factory - -from glue.core.callback_property import CallbackProperty, add_callback - - -PICK_THRESH = 30 # pixel distance threshold for picking - - -class Grip(object): - - def __init__(self, viewer, artist=True): - self.viewer = viewer - self.enabled = True - - self.artist = None - if artist: - self.artist = self._artist_factory() - - def remove(self): - raise NotImplementedError() - - def _artist_factory(self): - raise NotImplementedError() - - def pick_dist(self, x, y): - """ - Return the distance, in pixels, - between a point in (x,y) data space and - the grip - """ - raise NotImplementedError() - - def dblclick(self, x, y): - """Respond to a double-click event - - Default is to ignore - """ - pass - - def select(self, x, y): - """ - Process a selection event (click) at x,y - """ - raise NotImplementedError() - - def drag(self, x, y): - """ - Process a drag to x, y - """ - raise NotImplementedError() - - def release(self): - """ - Process a release - """ - raise NotImplementedError() - - def disable(self): - self.enabled = False - if self.artist is not None: - self.artist.set_visible(False) - self.viewer.axes.figure.canvas.draw() - - def enable(self): - self.enabled = True - if self.artist is not None: - self.artist.set_visible(True) - self.viewer.axes.figure.canvas.draw() - - -class ValueGrip(Grip): - value = CallbackProperty(None) - - def __init__(self, viewer, artist=True): - super(ValueGrip, self).__init__(viewer, artist) - self._drag = False - - def _artist_factory(self): - return ValueArtist(self) - - def dblclick(self, x, y): - self.value = x - - def pick_dist(self, x, y): - xy = [[x, y], [self.value, y]] - xypix = self.viewer.axes.transData.transform(xy) - return abs(xypix[1, 0] - xypix[0, 0]) - - def select(self, x, y): - if self.pick_dist(x, y) > PICK_THRESH: - return - self._drag = True - - def drag(self, x, y): - if self._drag: - self.value = x - - def release(self): - self._drag = False - - -class RangeGrip(Grip): - range = CallbackProperty((None, None)) - - def __init__(self, viewer): - super(RangeGrip, self).__init__(viewer) - - # track state during drags - self._move = None - self._ref = None - self._refx = None - self._refnew = None - - def _artist_factory(self): - return RangeArtist(self) - - def pick_dist(self, x, y): - xy = np.array([[x, y], - [self.range[0], y], - [self.range[1], y], - [sum(self.range) / 2, y]]) - xypix = self.viewer.axes.transData.transform(xy) - dx = np.abs(xypix[1:] - xypix[0])[:, 0] - return min(dx) - - def select(self, x, y): - if self.pick_dist(x, y) > PICK_THRESH: - return self.new_select(x, y) - - cen = sum(self.range) / 2. - wid = self.range[1] - self.range[0] - if x < cen - wid / 4.: - self._move = 'left' - elif x < cen + wid / 4.: - self._move = 'center' - self._ref = self.range - self._refx = x - else: - self._move = 'right' - - def new_select(self, x, y): - """ - Begin a selection in "new range" mode. - In this mode, the previous grip position is ignored, - and the new range is defined by the select/release positions - """ - self._refnew = x - self.range = (x, x) - - def new_drag(self, x, y): - """ - Drag the selection in "new mode" - """ - if self._refnew is not None: - self._set_range(self._refnew, x) - - def drag(self, x, y): - if self._refnew is not None: - return self.new_drag(x, y) - - if self._move == 'left': - if x > self.range[1]: - self._move = 'right' - self._set_range(x, self.range[1]) - - elif self._move == 'center': - dx = (x - self._refx) - self._set_range(self._ref[0] + dx, self._ref[1] + dx) - else: - if x < self.range[0]: - self._move = 'left' - self._set_range(self.range[0], x) - - def _set_range(self, lo, hi): - self.range = min(lo, hi), max(lo, hi) - - def release(self): - self._move = None - self._ref = None - self._refx = None - self._refnew = None - - -class ValueArtist(object): - - def __init__(self, grip, **kwargs): - self.grip = grip - add_callback(grip, 'value', self._update) - ax = self.grip.viewer.axes - - kwargs.setdefault('lw', 2) - kwargs.setdefault('alpha', 0.5) - kwargs.setdefault('c', '#ffb304') - trans = blended_transform_factory(ax.transData, ax.transAxes) - self._line, = ax.plot([grip.value, grip.value], [0, 1], - transform=trans, **kwargs) - - def _update(self, value): - self._line.set_xdata([value, value]) - self._line.axes.figure.canvas.draw() - - def set_visible(self, visible): - self._line.set_visible(visible) - - -class RangeArtist(object): - - def __init__(self, grip, **kwargs): - self.grip = grip - add_callback(grip, 'range', self._update) - ax = grip.viewer.axes - trans = blended_transform_factory(ax.transData, ax.transAxes) - - kwargs.setdefault('lw', 2) - kwargs.setdefault('alpha', 0.5) - kwargs.setdefault('c', '#ffb304') - self._line, = ax.plot(self.x, self.y, transform=trans, **kwargs) - - @property - def x(self): - l, r = self.grip.range - return [l, l, l, r, r, r] - - @property - def y(self): - return [0, 1, .5, .5, 0, 1] - - def _update(self, rng): - self._line.set_xdata(self.x) - self._line.axes.figure.canvas.draw() - - def set_visible(self, visible): - self._line.set_visible(visible) - - -def _build_axes(figure): - - ax2 = figure.add_subplot(122) - ax1 = figure.add_subplot(121, sharex=ax2) - - ax1.xaxis.get_major_formatter().set_useOffset(False) - ax1.yaxis.get_major_formatter().set_useOffset(False) - ax2.xaxis.get_major_formatter().set_useOffset(False) - ax2.yaxis.get_major_formatter().set_useOffset(False) - - return ax1, ax2 - - -class ProfileViewer(object): - value_cls = ValueGrip - range_cls = RangeGrip - - def __init__(self, figure): - self.axes, self.resid_axes = _build_axes(figure) - - self._artist = None - self._resid_artist = None - self._x = self._xatt = self._y = self._yatt = None - self._resid = None - self.connect() - - self._fit_artists = [] - self.active_grip = None # which grip should receive events? - self.grips = [] - self._xlabel = '' - - def set_xlabel(self, xlabel): - self._xlabel = xlabel - - def autoscale_ylim(self): - x, y = self._x, self._y - xlim = self.axes.get_xlim() - mask = (xlim[0] <= x) & (x <= xlim[1]) - ymask = y[mask] - if ymask.size == 0: - return - - ylim = np.nan_to_num(np.array([np.nanmin(ymask), np.nanmax(ymask)])) - self.axes.set_ylim(ylim[0], ylim[1] + .05 * (ylim[1] - ylim[0])) - - if self._resid is None: - return - assert self._resid.size == y.size - - ymask = self._resid[mask] - ylim = np.nan_to_num([np.nanmin(ymask), np.nanmax(ymask)]) - diff = .05 * (ylim[1] - ylim[0]) - self.resid_axes.set_ylim(ylim[0] - diff, ylim[1] + diff) - - def _relayout(self): - if self._resid_artist is not None: - self.axes.set_position([0.1, .35, .88, .6]) - self.resid_axes.set_position([0.1, .15, .88, .2]) - self.resid_axes.set_xlabel(self._xlabel) - self.resid_axes.set_visible(True) - self.axes.set_xlabel('') - [t.set_visible(False) for t in self.axes.get_xticklabels()] - else: - self.resid_axes.set_visible(False) - self.axes.set_position([0.1, .15, .88, .83]) - self.axes.set_xlabel(self._xlabel) - [t.set_visible(True) for t in self.axes.get_xticklabels()] - - def set_profile(self, x, y, xatt=None, yatt=None, **kwargs): - """ - Set a new line profile - - :param x: X-coordinate data - :type x: array-like - - :param y: Y-coordinate data - :type y: array-like - - :param xatt: ComponentID associated with X axis - :type xatt: :class:`~glue.core.data.ComponentID` - - :param yatt: ComponentID associated with Y axis - :type yatt: :class:`~glue.core.data.ComponentID` - - Extra kwargs are passed to matplotlib.plot, to - customize plotting - - Returns the created MPL artist - """ - self.clear_fit() - self._x = np.asarray(x).ravel() - self._xatt = xatt - self._y = np.asarray(y).ravel() - self._yatt = yatt - if self._artist is not None: - self._artist.remove() - - kwargs.setdefault('drawstyle', 'steps-mid') - - self._artist = self.axes.plot(x, y, **kwargs)[0] - self._relayout() - self._redraw() - - return self._artist - - def clear_fit(self): - for a in self._fit_artists: - a.remove() - self._fit_artists = [] - if self._resid_artist is not None: - self._resid_artist.remove() - self._resid_artist = None - - def connect(self): - connect = self.axes.figure.canvas.mpl_connect - self._down_id = connect('button_press_event', self._on_down) - self._up_id = connect('button_release_event', self._on_up) - self._move_id = connect('motion_notify_event', self._on_move) - - def disconnect(self): - off = self.axes.figure.canvas.mpl_disconnect - self._down_id = off(self._down_id) - self._up_id = off(self._up_id) - self._move_id = off(self._move_id) - - def _on_down(self, event): - if not event.inaxes: - return - - if event.dblclick: - if self.active_grip is not None: - self.active_grip.dblclick(event.xdata, event.ydata) - return - - if self.active_grip is not None and self.active_grip.enabled: - self.active_grip.select(event.xdata, event.ydata) - - def _on_up(self, event): - if not event.inaxes: - return - if self.active_grip is None or not self.active_grip.enabled: - return - - self.active_grip.release() - - def _on_move(self, event): - if not event.inaxes or event.button != 1: - return - if self.active_grip is None or not self.active_grip.enabled: - return - - self.active_grip.drag(event.xdata, event.ydata) - - def _redraw(self): - self.axes.figure.canvas.draw() - - def profile_data(self, xlim=None): - if self._x is None or self._y is None: - raise ValueError("Must set profile first") - - x = self._x - y = self._y - if xlim is not None: - mask = (min(xlim) <= x) & (x <= max(xlim)) - x = x[mask] - y = y[mask] - - return x, y - - def fit(self, fitter, xlim=None): - try: - x, y = self.profile_data(xlim) - dy = None - except ValueError: - raise ValueError("Must set profile before fitting") - - result = fitter.build_and_fit(x, y) - - return result, x, y, dy - - def plot_fit(self, fitter, fit_result): - self.clear_fit() - x = self._x - y = fitter.predict(fit_result, x) - self._fit_artists = fitter.plot(fit_result, self.axes, x) - resid = self._y - y - self._resid = resid - self._resid_artist, = self.resid_axes.plot(x, resid, 'k') - self.autoscale_ylim() - self._relayout() - - def new_value_grip(self, callback=None): - """ - Create and return new ValueGrip - - :param callback: A callback function to be invoked - whenever the grip.value property changes - """ - result = self.value_cls(self) - result.value = self._center[0] - - if callback is not None: - add_callback(result, 'value', callback) - self.grips.append(result) - self.active_grip = result - return result - - def new_range_grip(self, callback=None): - """ - Create and return new RangeGrip - - :param callback: A callback function to be invoked - whenever the grip.range property changes - """ - result = self.range_cls(self) - center = self._center[0] - width = self._width - result.range = center - width / 4, center + width / 4 - - if callback is not None: - add_callback(result, 'range', callback) - - self.grips.append(result) - self.active_grip = result - - return result - - @property - def _center(self): - """Return the data coordinates of the axes center, as (x, y)""" - xy = self.axes.transAxes.transform([(.5, .5)]) - xy = self.axes.transData.inverted().transform(xy) - return tuple(xy.ravel()) - - @property - def _width(self): - """Return the X-width of axes in data units""" - xlim = self.axes.get_xlim() - return xlim[1] - xlim[0] - - def pick_grip(self, x, y): - """ - Given a coordinate in Data units, - return the enabled Grip object nearest - that point, or None if none are nearby - """ - grips = [h for h in self.grips if h.enabled] - if not grips: - return - - dist, grip = min((h.pick_dist(x, y), h) - for h in grips) - - if dist < PICK_THRESH: - return grip diff --git a/glue/plugins/tools/spectrum_tool/qt/spectrum_fit_panel.ui b/glue/plugins/tools/spectrum_tool/qt/spectrum_fit_panel.ui deleted file mode 100644 index acbaa979a..000000000 --- a/glue/plugins/tools/spectrum_tool/qt/spectrum_fit_panel.ui +++ /dev/null @@ -1,162 +0,0 @@ - - - widget - - - - 0 - 0 - 267 - 321 - - - - - 0 - 0 - - - - Form - - - - 2 - - - - - 4 - - - - - 4 - - - - - - 80 - 0 - - - - Qt::LeftToRight - - - Uncertainty - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - QComboBox::AdjustToMinimumContentsLength - - - - - - - - - 4 - - - - - - 80 - 0 - - - - Qt::LeftToRight - - - Function - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - QComboBox::AdjustToMinimumContentsLength - - - - - - - - - 3 - - - 4 - - - - - Settings - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Fit - - - - - - - Clear - - - - - - - - - - - - - - - diff --git a/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py b/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py deleted file mode 100644 index 5c331decc..000000000 --- a/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py +++ /dev/null @@ -1,1004 +0,0 @@ -from __future__ import absolute_import, division, print_function - -import os -import logging -import platform -import traceback - -import numpy as np - -from qtpy import QtCore, QtGui, QtWidgets, compat -from qtpy.QtCore import Qt - -from glue.external.six.moves import range as xrange -from glue.core.exceptions import IncompatibleAttribute -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.matplotlib.qt.toolbar import MatplotlibViewerToolbar -from glue.core.qt.mime import LAYERS_MIME_TYPE -from glue.viewers.common.qt.toolbar_mode import RoiMode -from glue.utils.qt import load_ui, get_qapp -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.matplotlib.qt.widget import MplWidget -from glue.utils import nonpartial, Pointer -from glue.utils.qt import Worker, messagebox_on_error -from glue.core.subset import RoiSubsetState -from glue.core.qt import roi as qt_roi -from .profile_viewer import ProfileViewer -from glue.viewers.image.state import AggregateSlice -from glue.core.aggregate import mom1, mom2 - - -class Extractor(object): - # Warning: - # Coordinate conversion is not well-defined if pix2world is not - # monotonic! - - @staticmethod - def abcissa(data, axis): - slc = [0 for _ in data.shape] - slc[axis] = slice(None, None) - att = data.get_world_component_id(axis) - return data[att, tuple(slc)].ravel() - - @staticmethod - def spectrum(data, attribute, roi, slc, zaxis): - - # Find the integer index of the x and y axes, which are the axes for - # which the image is shown (the ROI is drawn along these attributes) - xaxis = slc.index('x') - yaxis = slc.index('y') - - # Get the actual component IDs corresponding to these axes - xatt = data.get_pixel_component_id(xaxis) - yatt = data.get_pixel_component_id(yaxis) - - # Set up a view that does not reduce the dimensionality of the array but - # extracts 1-element slices along dimensions that are not relevant. - view = [] - for idim, dim in enumerate(slc): - if idim in (xaxis, yaxis, zaxis): - view.append(slice(None)) - else: - view.append(slice(dim, dim + 1)) - view = tuple(view) - - # We now delegate to RoiSubsetState to compute the mask based on the ROI - subset_state = RoiSubsetState(xatt=xatt, yatt=yatt, roi=roi) - mask = subset_state.to_mask(data, view=view) - - # We now extract the values that fall inside the ROI. Unfortunately, - # this returns a flat 1-d array, so we need to then reshape it to get - # an array with shape (n_spec, n_pix), where n_pix is the number of - # pixels inside the ROI - - values = data[attribute, view] - - if zaxis != 0: - values = values.swapaxes(zaxis, 0) - mask = mask.swapaxes(zaxis, 0) - - values = values[mask].reshape(data.shape[zaxis], -1) - - # We then average along the spatial dimension - spectrum = np.nanmean(values, axis=1) - - # Get the world coordinates of the spectral axis - x = Extractor.abcissa(data, zaxis) - - return x, spectrum - - @staticmethod - def world2pixel(data, axis, value): - x = Extractor.abcissa(data, axis) - if x.size > 1 and (x[1] < x[0]): - x = x[::-1] - result = x.size - np.searchsorted(x, value) - 2 - else: - result = np.searchsorted(x, value) - 1 - return np.clip(result, 0, x.size - 1) - - @staticmethod - def pixel2world(data, axis, value): - x = Extractor.abcissa(data, axis) - return x[np.clip(value, 0, x.size - 1)] - - @staticmethod - def subset_spectrum(subset, attribute, slc, zaxis): - """ - Extract a spectrum from a subset. - - This makes a mask of the subset in the **current slice**, - and extracts a tube of this shape over all slices along ``zaxis``. - In other words, the variation of the subset along ``zaxis`` is ignored, - and only the interaction of the subset and the slice is relevant. - - :param subset: A :class:`~glue.core.subset.Subset` - :param attribute: The :class:`~glue.core.data.ComponentID` to extract - :param slc: A tuple describing the slice - :param zaxis: Which axis to integrate over - """ - data = subset.data - x = Extractor.abcissa(data, zaxis) - - view = [slice(s, s + 1) - if s not in ['x', 'y'] else slice(None) - for s in slc] - - mask = np.squeeze(subset.to_mask(view)) - if slc.index('x') < slc.index('y'): - mask = mask.T - - w = np.where(mask) - view[slc.index('x')] = w[1] - view[slc.index('y')] = w[0] - - result = np.empty(x.size) - - # treat each channel separately, to reduce memory storage - for i in xrange(data.shape[zaxis]): - view[zaxis] = i - val = data[attribute, view] - result[i] = np.nansum(val) / np.isfinite(val).sum() - - y = result - - return x, y - - -class SpectrumContext(object): - - """ - Base class for different interaction contexts - """ - viewer_state = Pointer('main.viewer_state') - data = Pointer('main.data') - profile_axis = Pointer('main.profile_axis') - canvas = Pointer('main.canvas') - profile = Pointer('main.profile') - - def __init__(self, main): - - self.main = main - self.grip = None - self.panel = None - self.widget = None - - self._setup_grip() - self._setup_widget() - self._connect() - - def _setup_grip(self): - """ Create a :class:`~glue.plugins.tools.spectrum_tool.profile_viewer.Grip` object - to interact with the plot. Assign to self.grip - """ - raise NotImplementedError() - - def _setup_widget(self): - """ - Create a context-specific widget - """ - # this is the widget that is displayed to the right of the - # spectrum - raise NotImplementedError() - - def _connect(self): - """ - Attach event handlers - """ - pass - - def set_enabled(self, enabled): - self.enable() if enabled else self.disable() - - def enable(self): - if self.grip is not None: - self.grip.enable() - - def disable(self): - if self.grip is not None: - self.grip.disable() - - def recenter(self, lim): - """Re-center the grip to the given x axlis limit tuple""" - if self.grip is None: - return - if hasattr(self.grip, 'value'): - self.grip.value = sum(lim) / 2. - return - - # Range grip - cen = sum(lim) / 2 - wid = max(lim) - min(lim) - self.grip.range = cen - wid / 4, cen + wid / 4 - - -class NavContext(SpectrumContext): - - """ - Mode to set the 2D slice in the parent image widget by dragging - a handle in the spectrum - """ - - def _setup_grip(self): - def _set_state_from_grip(value): - """Update state.slices given grip value""" - if not self.main.enabled: - return - - slc = list(self.viewer_state.slices) - # state.slices stored in pixel coords - value = Extractor.world2pixel( - self.data, - self.profile_axis, value) - slc[self.profile_axis] = value - - # prevent callback bouncing. Fixes #298 - self.viewer_state.slices = tuple(slc) - - def _set_grip_from_state(slc): - """Update grip.value given state.slices""" - if not self.main.enabled: - return - - # grip.value is stored in world coordinates - val = slc[self.profile_axis] - - if isinstance(val, AggregateSlice): - val = val.center - - val = Extractor.pixel2world(self.data, self.profile_axis, val) - - # If pix2world not monotonic, this can trigger infinite recursion. - # Avoid by disabling callback loop - # XXX better to specifically ignore _set_state_from_grip - with ignore_callback(self.grip, 'value'): - self.grip.value = val - - self.grip = self.main.profile.new_value_grip() - - add_callback(self.viewer_state, 'slices', _set_grip_from_state) - add_callback(self.grip, 'value', _set_state_from_grip) - - def _connect(self): - pass - - def _setup_widget(self): - self.widget = QtWidgets.QTextEdit() - self.widget.setHtml("To slide through the cube, " - "drag the handle or double-click


" - "To make a new profile , " - "click-drag a new box in the image, or drag " - "a subset onto the plot to the left") - self.widget.setTextInteractionFlags(Qt.NoTextInteraction) - - -class CollapseContext(SpectrumContext): - """ - Mode to collapse a section of a cube into a 2D image. - - Supports several aggregations: mean, median, max, mom1, mom2 - """ - - def _setup_grip(self): - self.grip = self.main.profile.new_range_grip() - - def _setup_widget(self): - w = QtWidgets.QWidget() - l = QtWidgets.QFormLayout() - w.setLayout(l) - - combo = QtWidgets.QComboBox() - combo.addItem("Mean", userData=np.mean) - combo.addItem("Median", userData=np.median) - combo.addItem("Max", userData=np.max) - combo.addItem("Centroid", userData=mom1) - combo.addItem("Linewidth", userData=mom2) - - run = QtWidgets.QPushButton("Collapse") - save = QtWidgets.QPushButton("Save as FITS file") - - buttons = QtWidgets.QHBoxLayout() - buttons.addWidget(run) - buttons.addWidget(save) - - self._save = save - self._run = run - - l.addRow("", combo) - l.addRow("", buttons) - - self.widget = w - self._combo = combo - - self._collapsed_viewer = None - - def _connect(self): - self._run.clicked.connect(nonpartial(self._aggregate)) - self._save.clicked.connect(nonpartial(self._choose_save)) - - @property - def aggregator(self): - return self._combo.itemData(self._combo.currentIndex()) - - @property - def aggregator_label(self): - return self._combo.currentText() - - def _aggregate(self): - - func = self.aggregator - - rng = list(self.grip.range) - - rng = Extractor.world2pixel(self.data, - self.profile_axis, - rng) - rng[1] += 1 - - slices = list(self.viewer_state.slices) - - current_slice = slices[self.profile_axis] - if isinstance(current_slice, AggregateSlice): - current_slice = current_slice.center - - slices[self.profile_axis] = AggregateSlice(slice(*rng), - current_slice, - func) - - self.viewer_state.slices = tuple(slices) - - # Save a local copy of the collapsed array - for layer_state in self.viewer_state.layers: - if layer_state.layer is self.viewer_state.reference_data: - break - else: - raise Exception("Couldn't find layer corresponding to reference data") - - self._agg = layer_state.get_sliced_data() - - @messagebox_on_error("Failed to export projection") - def _choose_save(self): - - self._aggregate() - - out, _ = compat.getsavefilename(filters='FITS Files (*.fits)') - if not out: - return - - self.save_to(out) - - def save_to(self, pth): - """ - Write the projection to a file - - Parameters - ---------- - pth : str - Path to write to - """ - - from astropy.io import fits - - data = self.viewer_state.reference_data - if data is None: - raise RuntimeError("Cannot save projection -- no data to visualize") - - self._aggregate() - - # try to project wcs to 2D - wcs = getattr(data.coords, 'wcs', None) - if wcs: - try: - wcs.dropaxis(data.ndim - 1 - self.main.profile_axis) - header = wcs.to_header(True) - except Exception as e: - msg = "Could not extract 2D wcs for this data: %s" % e - logging.getLogger(__name__).warn(msg) - header = fits.Header() - else: - header = fits.Header() - - 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.viewer_state.slices)) - - header.add_history(history) - - try: - fits.writeto(pth, self._agg, header, overwrite=True) - except TypeError: - fits.writeto(pth, self._agg, header, clobber=True) - - -class ConstraintsWidget(QtWidgets.QWidget): - - """ - A widget to display and tweak the constraints of a :class:`~glue.core.fitters.BaseFitter1D` - """ - - def __init__(self, constraints, parent=None): - """ - Parameters - ---------- - constraints : dict - The `contstraints` property of a :class:`~glue.core.fitters.BaseFitter1D` - object - parent : QtWidgets.QWidget (optional) - The parent of this widget - """ - super(ConstraintsWidget, self).__init__(parent) - self.constraints = constraints - - self.layout = QtWidgets.QGridLayout() - self.layout.setContentsMargins(2, 2, 2, 2) - self.layout.setSpacing(4) - - self.setLayout(self.layout) - - self.layout.addWidget(QtWidgets.QLabel("Estimate"), 0, 1) - self.layout.addWidget(QtWidgets.QLabel("Fixed"), 0, 2) - self.layout.addWidget(QtWidgets.QLabel("Bounded"), 0, 3) - self.layout.addWidget(QtWidgets.QLabel("Lower Bound"), 0, 4) - self.layout.addWidget(QtWidgets.QLabel("Upper Bound"), 0, 5) - - self._widgets = {} - names = sorted(list(self.constraints.keys())) - - for k in names: - row = [] - w = QtWidgets.QLabel(k) - row.append(w) - - v = QtGui.QDoubleValidator() - e = QtWidgets.QLineEdit() - e.setValidator(v) - e.setText(str(constraints[k]['value'] or '')) - row.append(e) - - w = QtWidgets.QCheckBox() - w.setChecked(constraints[k]['fixed']) - fix = w - row.append(w) - - w = QtWidgets.QCheckBox() - limits = constraints[k]['limits'] - w.setChecked(limits is not None) - bound = w - row.append(w) - - e = QtWidgets.QLineEdit() - e.setValidator(v) - if limits is not None: - e.setText(str(limits[0])) - row.append(e) - - e = QtWidgets.QLineEdit() - e.setValidator(v) - if limits is not None: - e.setText(str(limits[1])) - row.append(e) - - def unset(w): - def result(active): - if active: - w.setChecked(False) - return result - - fix.toggled.connect(unset(bound)) - bound.toggled.connect(unset(fix)) - - self._widgets[k] = row - - for i, row in enumerate(names, 1): - for j, widget in enumerate(self._widgets[row]): - self.layout.addWidget(widget, i, j) - - def settings(self, name): - """ Return the constraints for a single model parameter """ - row = self._widgets[name] - name, value, fixed, limited, lo, hi = row - value = float(value.text()) if value.text() else None - fixed = fixed.isChecked() - limited = limited.isChecked() - lo = lo.text() - hi = hi.text() - limited = limited and not ((not lo) or (not hi)) - limits = None if not limited else [float(lo), float(hi)] - return dict(value=value, fixed=fixed, limits=limits) - - def update_constraints(self, fitter): - """ Update the constraints in a :class:`~glue.core.fitters.BaseFitter1D` - based on the settings in this widget - """ - for name in self._widgets: - s = self.settings(name) - fitter.set_constraint(name, **s) - - -class FitSettingsWidget(QtWidgets.QDialog): - - def __init__(self, fitter, parent=None): - super(FitSettingsWidget, self).__init__(parent) - self.fitter = fitter - - self._build_form() - self._connect() - self.setModal(True) - - def _build_form(self): - fitter = self.fitter - - l = QtWidgets.QFormLayout() - options = fitter.options - self.widgets = {} - self.forms = {} - - for k in sorted(options): - item = build_form_item(fitter, k) - l.addRow(item.label, item.widget) - self.widgets[k] = item.widget - self.forms[k] = item # need to prevent garbage collection - - constraints = fitter.constraints - if constraints: - self.constraints = ConstraintsWidget(constraints) - l.addRow(self.constraints) - else: - self.constraints = None - - self.okcancel = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | - QtWidgets.QDialogButtonBox.Cancel) - l.addRow(self.okcancel) - self.setLayout(l) - - def _connect(self): - self.okcancel.accepted.connect(self.accept) - self.okcancel.rejected.connect(self.reject) - self.accepted.connect(self.update_fitter_from_settings) - - def update_fitter_from_settings(self): - for k, v in self.widgets.items(): - setattr(self.fitter, k, v.value()) - if self.constraints is not None: - self.constraints.update_constraints(self.fitter) - - -class FitContext(SpectrumContext): - - """ - Mode to fit a range of a spectrum with a model fitter. - - Fitters are taken from user-defined fit plugins, or - :class:`~glue.core.fitters.BaseFitter1D` subclasses - """ - error = CurrentComboProperty('ui.uncertainty_combo') - fitter = CurrentComboProperty('ui.profile_combo') - - def _setup_grip(self): - self.grip = self.main.profile.new_range_grip() - - def _setup_widget(self): - self.ui = load_ui('spectrum_fit_panel.ui', None, - directory=os.path.dirname(__file__)) - self.ui.uncertainty_combo.hide() - self.ui.uncertainty_label.hide() - font = QtGui.QFont("Courier") - font.setStyleHint(font.Monospace) - self.ui.results_box.document().setDefaultFont(font) - self.ui.results_box.setLineWrapMode(self.ui.results_box.NoWrap) - self.widget = self.ui - - for fitter in list(fit_plugin): - self.ui.profile_combo.addItem(fitter.label, - userData=fitter()) - - def _edit_model_options(self): - - d = FitSettingsWidget(self.fitter) - d.exec_() - - def _connect(self): - self.ui.fit_button.clicked.connect(nonpartial(self.fit)) - self.ui.clear_button.clicked.connect(nonpartial(self.clear)) - self.ui.settings_button.clicked.connect( - nonpartial(self._edit_model_options)) - - def fit(self): - """ - Fit a model to the data - - The fitting happens on a dedicated thread, to keep the UI - responsive - """ - xlim = self.grip.range - fitter = self.fitter - - def on_success(result): - fit_result, _, _, _ = result - self._report_fit(fitter.summarize(*result)) - self.main.profile.plot_fit(fitter, fit_result) - - def on_fail(exc_info): - exc = '\n'.join(traceback.format_exception(*exc_info)) - self._report_fit("Error during fitting:\n%s" % exc) - - def on_done(): - self.ui.fit_button.setText("Fit") - self.ui.fit_button.setEnabled(True) - self.canvas.draw() - - self.ui.fit_button.setText("Running...") - self.ui.fit_button.setEnabled(False) - - w = Worker(self.main.profile.fit, fitter, xlim=xlim) - w.result.connect(on_success) - w.error.connect(on_fail) - w.finished.connect(on_done) - - self._fit_worker = w # hold onto a reference - w.start() - - def _report_fit(self, report): - self.ui.results_box.document().setPlainText(report) - - def clear(self): - self.ui.results_box.document().setPlainText('') - self.main.profile.clear_fit() - self.canvas.draw() - - -class SpectrumMainWindow(QtWidgets.QMainWindow): - - """ - The main window that the spectrum viewer is embedded in. - - Defines two signals to trigger when a subset is dropped into the window, - and when the window is closed. - """ - subset_dropped = QtCore.Signal(object) - window_closed = QtCore.Signal() - - def __init__(self, parent=None): - super(SpectrumMainWindow, self).__init__(parent=parent) - self.setAcceptDrops(True) - - def closeEvent(self, event): - self.window_closed.emit() - return super(SpectrumMainWindow, self).closeEvent(event) - - def dragEnterEvent(self, event): - if event.mimeData().hasFormat(LAYERS_MIME_TYPE): - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - layer = event.mimeData().data(LAYERS_MIME_TYPE)[0] - if isinstance(layer, Subset): - self.subset_dropped.emit(layer) - - def set_status(self, message): - sb = self.statusBar() - sb.showMessage(message) - -@viewer_tool -class SpectrumExtractorMode(RoiMode): - - """ - Lets the user select a region in an image and, when connected to a - SpectrumExtractorTool, uses this to display spectra extracted from that - position - """ - persistent = True - - icon = 'glue_spectrum' - tool_id = 'spectrum' - action_text = 'Spectrum' - tool_tip = 'Extract a spectrum from the selection' - shortcut = 'S' - - def __init__(self, viewer, **kwargs): - super(SpectrumExtractorMode, self).__init__(viewer, **kwargs) - self._roi_tool = qt_roi.QtRectangularROI(self._axes) # default - self._tool = SpectrumTool(self.viewer, self) - self._release_callback = self._tool._update_profile - self._move_callback = self._tool._move_profile - self._roi_callback = None - self.viewer.state.add_callback('reference_data', self._on_reference_data_change) - - def _on_reference_data_change(self, reference_data): - if reference_data is not None: - self.enabled = reference_data.ndim == 3 - - def menu_actions(self): - - result = [] - - a = QtWidgets.QAction('Rectangle', None) - a.triggered.connect(nonpartial(self.set_roi_tool, 'Rectangle')) - result.append(a) - - a = QtWidgets.QAction('Circle', None) - a.triggered.connect(nonpartial(self.set_roi_tool, 'Circle')) - result.append(a) - - a = QtWidgets.QAction('Polygon', None) - a.triggered.connect(nonpartial(self.set_roi_tool, 'Polygon')) - result.append(a) - - for r in result: - if self._move_callback is not None: - r.triggered.connect(nonpartial(self._move_callback, self)) - - return result - - def set_roi_tool(self, mode): - if mode is 'Rectangle': - self._roi_tool = qt_roi.QtRectangularROI(self._axes) - - if mode is 'Circle': - self._roi_tool = qt_roi.QtCircularROI(self._axes) - - if mode is 'Polygon': - self._roi_tool = qt_roi.QtPolygonalROI(self._axes) - - self._roi_tool.plot_opts.update(edgecolor='#c51b7d', - facecolor=None, - edgewidth=3, - alpha=1.0) - - def close(self): - self._tool.close() - return super(SpectrumExtractorMode, self).close() - -# TODO: refactor this so that we don't have a separate tool and mode - - -class SpectrumTool(object): - - """ - Main widget for interacting with spectra extracted from an image. - - Provides different contexts for interacting with the spectrum: - - *navigation context* lets the user set the slice in the parent image - by dragging a bar on the spectrum - *fit context* lets the user fit models to a portion of the spectrum - *collapse context* lets the users collapse a section of a cube to a 2D image - """ - - def __init__(self, image_viewer, mouse_mode): - self._relim_requested = True - - self.image_viewer = image_viewer - self.viewer_state = self.image_viewer.state - self.image_viewer.window_closed.connect(self.close) - - self._build_main_widget() - - self.profile = ProfileViewer(self.canvas.fig) - self.axes = self.profile.axes - - self.mouse_mode = mouse_mode - self._setup_toolbar() - - self._setup_ctxbar() - - self._connect() - w = self.image_viewer.session.application.add_widget(self, - label='Profile') - w.close() - - def close(self): - if hasattr(self, '_mdi_wrapper'): - self._mdi_wrapper.close() - else: - self.widget.close() - self.image_viewer = None - - @property - def enabled(self): - """Return whether the window is visible and active""" - # If the widget has been completely closed, accessing - # isVisible() can result in a 'wrapped C/C++ object' - # deleted RuntimeError. - try: - return self.widget.isVisible() - except RuntimeError: - return False - - def mdi_wrap(self): - sub = GlueMdiSubWindow() - sub.setWidget(self.widget) - self.widget.destroyed.connect(sub.close) - sub.resize(self.widget.size()) - self._mdi_wrapper = sub - return sub - - def _build_main_widget(self): - self.widget = SpectrumMainWindow() - self.widget.window_closed.connect(self.reset) - - w = QtWidgets.QWidget() - l = QtWidgets.QHBoxLayout() - l.setSpacing(2) - l.setContentsMargins(2, 2, 2, 2) - w.setLayout(l) - - mpl = MplWidget() - self.canvas = mpl.canvas - l.addWidget(mpl) - l.setStretchFactor(mpl, 5) - - self.widget.setCentralWidget(w) - - # TODO: fix hacks - w.canvas = self.canvas - self.widget.central_widget = w - - def _setup_ctxbar(self): - l = self.widget.centralWidget().layout() - self._contexts = [NavContext(self), - FitContext(self), - CollapseContext(self)] - - tabs = QtWidgets.QTabWidget(parent=self.widget) - - # The following is needed because of a bug in Qt which means that - # tab titles don't get scaled right. - if platform.system() == 'Darwin': - app = get_qapp() - app_font = app.font() - tabs.setStyleSheet('font-size: {0}px'.format(app_font.pointSize())) - - tabs.addTab(self._contexts[0].widget, 'Navigate') - tabs.addTab(self._contexts[1].widget, 'Fit') - tabs.addTab(self._contexts[2].widget, 'Collapse') - self._tabs = tabs - self._tabs.setVisible(False) - l.addWidget(tabs) - l.setStretchFactor(tabs, 0) - - def _connect(self): - - 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): - ctx.set_enabled(i == index) - if i == index: - self.profile.active_grip = ctx.grip - - self._tabs.currentChanged.connect(_on_tab_change) - _on_tab_change(self._tabs.currentIndex()) - - self.widget.subset_dropped.connect(self._extract_subset_profile) - - def _setup_toolbar(self): - - tb = MatplotlibViewerToolbar(self.widget) - - # disable ProfileViewer mouse processing during mouse modes - tb.tool_activated.connect(self.profile.disconnect) - tb.tool_deactivated.connect(self.profile.connect) - - self._menu_toggle_action = QtWidgets.QAction("Options", tb) - self._menu_toggle_action.setCheckable(True) - self._menu_toggle_action.toggled.connect(self._toggle_menu) - - tb.addAction(self._menu_toggle_action) - self.widget.addToolBar(tb) - return tb - - def _toggle_menu(self, active): - self._tabs.setVisible(active) - - def reset(self, *args): - self.hide() - self.mouse_mode.clear() - self._relim_requested = True - - @property - def data(self): - 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 - 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]) - - def _recenter_grips(self): - for ctx in self._contexts: - ctx.recenter(self.axes.get_xlim()) - - def _extract_subset_profile(self, subset): - slc = self.viewer_state.slices - try: - x, y = Extractor.subset_spectrum(subset, - self.viewer_state.display_attribute, - slc, - self.profile_axis) - except IncompatibleAttribute: - return - - self._set_profile(x, y) - - def _update_from_roi(self, roi): - data = self.data - att = self.viewer_state.layers[0].attribute - slc = self.viewer_state.wcsaxes_slice[::-1] - - if data is None or att is None: - return - - zax = self.profile_axis - - x, y = Extractor.spectrum(data, att, roi, slc, zax) - self._set_profile(x, y) - - def _update_profile(self, *args): - roi = self.mouse_mode.roi() - return self._update_from_roi(roi) - - def _move_profile(self, *args): - if self.mouse_mode._roi_tool._scrubbing: - self._update_profile(args) - - def _set_profile(self, x, y): - data = self.data - - xid = data.get_world_component_id(self.profile_axis) - units = data.get_component(xid).units - xlabel = str(xid) if units is None else '%s [%s]' % (xid, units) - - xlim = self.axes.get_xlim() - self.profile.set_xlabel(xlabel) - self.profile.set_profile(x, y, color='k') - - # relim x range if requested - if self._relim_requested: - self._relim_requested = False - self.axes.set_xlim(np.nanmin(x), np.nanmax(x)) - - # relim y range to data within the view window - self.profile.autoscale_ylim() - - if self.axes.get_xlim() != xlim: - self._recenter_grips() - - self.axes.figure.canvas.draw() - self.show() - - 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) - - def show(self): - if self.widget.isVisible(): - return - self._move_below_image_viewer() - self.widget.show() - - def hide(self): - if hasattr(self, '_mdi_wrapper'): - self._mdi_wrapper.close() - else: - self.widget.close() - - def _get_modes(self, axes): - return [self.mouse_mode] diff --git a/glue/plugins/tools/spectrum_tool/qt/tests/__init__.py b/glue/plugins/tools/spectrum_tool/qt/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/glue/plugins/tools/spectrum_tool/qt/tests/test_profile_viewer.py b/glue/plugins/tools/spectrum_tool/qt/tests/test_profile_viewer.py deleted file mode 100644 index 69ec9fa49..000000000 --- a/glue/plugins/tools/spectrum_tool/qt/tests/test_profile_viewer.py +++ /dev/null @@ -1,179 +0,0 @@ -from __future__ import absolute_import, division, print_function - -from collections import namedtuple - -import pytest -import numpy as np -from mock import MagicMock - -from ..profile_viewer import ProfileViewer -from glue.utils import renderless_figure - - -FIG = renderless_figure() -Event = namedtuple('Event', 'xdata ydata inaxes button dblclick') - - -class TestProfileViewer(object): - - def setup_method(self, method): - FIG.clf() - FIG.canvas.draw = MagicMock() - self.viewer = ProfileViewer(FIG) - self.axes = self.viewer.axes - - def test_set_profile(self): - self.viewer.set_profile([1, 2, 3], [2, 3, 4]) - self.axes.figure.canvas.draw.assert_called_once_with() - - def test_new_value_callback_fire(self): - cb = MagicMock() - s = self.viewer.new_value_grip(callback=cb) - s.value = 20 - cb.assert_called_once_with(20) - - def test_new_range_callback_fire(self): - cb = MagicMock() - s = self.viewer.new_range_grip(callback=cb) - s.range = (20, 40) - cb.assert_called_once_with((20, 40)) - - def test_pick_grip(self): - self.viewer.set_profile([1, 2, 3], [10, 20, 30]) - s = self.viewer.new_value_grip() - - s.value = 1.7 - assert self.viewer.pick_grip(1.7, 20) is s - - def test_pick_grip_false(self): - self.viewer.set_profile([1, 2, 3], [10, 20, 30]) - s = self.viewer.new_value_grip() - - s.value = 3 - assert self.viewer.pick_grip(1.7, 20) is None - - def test_pick_range_grip(self): - self.viewer.set_profile([1, 2, 3], [10, 20, 30]) - s = self.viewer.new_range_grip() - - s.range = (1.5, 2.5) - - assert self.viewer.pick_grip(1.5, 20) is s - assert self.viewer.pick_grip(2.5, 20) is s - assert self.viewer.pick_grip(1.0, 20) is None - - def test_value_drag_updates_value(self): - h = self.viewer.new_value_grip() - x2 = h.value + 10 - - self._click(h.value) - self._drag(x2) - self._release() - - assert h.value == x2 - - def test_disabled_grips_ignore_events(self): - h = self.viewer.new_value_grip() - h.value = 5 - - h.disable() - - self._click(h.value) - self._drag(10) - self._release() - - assert h.value == 5 - - def test_value_ignores_distant_picks(self): - self.viewer.set_profile([1, 2, 3], [1, 2, 3]) - h = self.viewer.new_value_grip() - h.value = 3 - - self._click(1) - self._drag(2) - self._release() - - assert h.value == 3 - - def test_range_translates_on_center_drag(self): - h = self.viewer.new_range_grip() - h.range = (1, 3) - self._click_range_center(h) - self._drag(1) - self._release() - assert h.range == (0, 2) - - def test_range_stretches_on_edge_drag(self): - h = self.viewer.new_range_grip() - h.range = (1, 3) - - self._click(1) - self._drag(2) - self._release() - assert h.range == (2, 3) - - def test_range_redefines_on_distant_drag(self): - self.viewer.set_profile([1, 2, 3], [1, 2, 3]) - h = self.viewer.new_range_grip() - h.range = (2, 2) - - self._click(1) - self._drag(1.5) - self._release() - - assert h.range == (1, 1.5) - - def test_dblclick_sets_value(self): - h = self.viewer.new_value_grip() - h.value = 1 - - self._click(1.5, double=True) - assert h.value == 1.5 - - def _click_range_center(self, grip): - x, y = sum(grip.range) / 2, 0 - self._click(x, y) - - def _click(self, x, y=0, double=False): - e = Event(xdata=x, ydata=y, inaxes=True, button=1, dblclick=double) - self.viewer._on_down(e) - - def _drag(self, x, y=0): - e = Event(xdata=x, ydata=y, inaxes=True, button=1, dblclick=False) - self.viewer._on_move(e) - - def _release(self): - e = Event(xdata=0, ydata=0, inaxes=True, button=1, dblclick=False) - self.viewer._on_up(e) - - def test_fit(self): - fitter = MagicMock() - self.viewer.set_profile([0, 1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6]) - self.viewer.fit(fitter, xlim=[1, 3]) - - args = fitter.build_and_fit.call_args[0] - np.testing.assert_array_equal(args[0], [1, 2, 3]) - np.testing.assert_array_equal(args[1], [2, 3, 4]) - - def test_fit_error_without_profile(self): - with pytest.raises(ValueError) as exc: - self.viewer.fit(None) - assert exc.value.args[0] == "Must set profile before fitting" - - def test_new_select(self): - h = self.viewer.new_range_grip() - - h.new_select(0, 1) - h.new_drag(1, 1) - h.release() - assert h.range == (0, 1) - - h.new_select(1, 1) - h.new_drag(.5, 1) - h.release() - assert h.range == (0.5, 1) - - h.new_select(.4, 1) - h.new_drag(.4, 1) - h.release() - assert h.range == (.4, .4) diff --git a/glue/plugins/tools/spectrum_tool/qt/tests/test_spectrum_tool.py b/glue/plugins/tools/spectrum_tool/qt/tests/test_spectrum_tool.py deleted file mode 100644 index 5e0fa3d5d..000000000 --- a/glue/plugins/tools/spectrum_tool/qt/tests/test_spectrum_tool.py +++ /dev/null @@ -1,271 +0,0 @@ -from __future__ import absolute_import, division, print_function - -import pytest -import numpy as np -from mock import MagicMock - -from glue.core.fitters import PolynomialFitter -from glue.core.roi import RectangularROI -from glue.core import Data, Coordinates -from glue.core.tests.util import simple_session -from glue.tests.helpers import requires_astropy -from glue.viewers.image.qt import ImageViewer - -from ..spectrum_tool import Extractor, ConstraintsWidget, FitSettingsWidget, SpectrumTool, CollapseContext - -needs_modeling = lambda x: x -try: - from glue.core.fitters import SimpleAstropyGaussianFitter -except ImportError: - needs_modeling = pytest.mark.skipif(True, reason='Needs astropy >= 0.3') - - -class MockCoordinates(Coordinates): - - def pixel2world(self, *args): - return [a * 2 for a in args] - - def world2pixel(self, *args): - return [a / 2 for a in args] - - -class BaseTestSpectrumTool: - - def setup_data(self): - self.data = Data(x=np.zeros((3, 3, 3))) - - def setup_method(self, method): - - self.setup_data() - - session = simple_session() - session.data_collection.append(self.data) - - self.image = ImageViewer(session) - self.image.add_data(self.data) - self.image.data = self.data - self.image.attribute = self.data.id['x'] - self.mode = self.image.toolbar.tools['spectrum'] - self.tool = self.mode._tool - self.tool.show = lambda *args: None - - def teardown_method(self, method): - if self.image is not None: - self.image.close() - self.image = None - if self.tool is not None: - self.tool.close() - self.tool = None - - -class TestSpectrumTool(BaseTestSpectrumTool): - - def build_spectrum(self): - roi = RectangularROI() - roi.update_limits(0, 2, 0, 2) - self.tool._update_profile() - - def test_reset_on_view_change(self): - - self.build_spectrum() - self.tool.widget = MagicMock() - self.tool.widget.isVisible.return_value = True - self.tool.hide = MagicMock() - self.image.state.x_att_world = self.data.world_component_ids[0] - assert self.tool.hide.call_count > 0 - - # For some reason we need to close and dereference the image and tool - # here (and not in teardown_method) otherwise we are left with - # references to the image viewer. - self.image.close() - self.image = None - self.tool.close() - self.tool = None - - -class Test3DExtractor(object): - - def setup_method(self, method): - self.data = Data() - self.data.coords = MockCoordinates() - self.data.add_component(np.random.random((3, 4, 5)), label='x') - self.x = self.data['x'] - - def test_abcissa(self): - expected = [0, 2, 4] - actual = Extractor.abcissa(self.data, 0) - np.testing.assert_equal(expected, actual) - - expected = [0, 2, 4, 6] - actual = Extractor.abcissa(self.data, 1) - np.testing.assert_equal(expected, actual) - - expected = [0, 2, 4, 6, 8] - actual = Extractor.abcissa(self.data, 2) - np.testing.assert_equal(expected, actual) - - def test_spectrum(self): - roi = RectangularROI() - roi.update_limits(0.5, 1.5, 2.5, 2.5) - - expected = self.x[:, 1:3, 2:3].mean(axis=1).mean(axis=1) - _, actual = Extractor.spectrum( - self.data, self.data.id['x'], roi, (0, 'x', 'y'), 0) - np.testing.assert_array_almost_equal(expected, actual) - - def test_spectrum_oob(self): - roi = RectangularROI() - roi.update_limits(-1, -1, 3, 3) - - expected = self.x[:, :3, :3].mean(axis=1).mean(axis=1) - - _, actual = Extractor.spectrum(self.data, self.data.id['x'], - roi, (0, 'x', 'y'), 0) - np.testing.assert_array_almost_equal(expected, actual) - - def test_pixel2world(self): - # p2w(x) = 2x, 0 <= x <= 2 - assert Extractor.pixel2world(self.data, 0, 1) == 2 - - # clips to boundary - assert Extractor.pixel2world(self.data, 0, -1) == 0 - assert Extractor.pixel2world(self.data, 0, 5) == 4 - - def test_world2pixel(self): - # w2p(x) = x/2, 0 <= x <= 4 - assert Extractor.world2pixel(self.data, 0, 2.01) == 1 - - # clips to boundary - assert Extractor.world2pixel(self.data, 0, -1) == 0 - assert Extractor.world2pixel(self.data, 0, 8) == 2 - - def test_extract_subset(self): - sub = self.data.new_subset() - sub.subset_state = self.data.id['x'] > .5 - slc = (0, 'y', 'x') - mask = sub.to_mask()[0] - mask = mask.reshape(-1, mask.shape[0], mask.shape[1]) - - expected = (self.x * mask).sum(axis=1).sum(axis=1) - expected /= mask.sum(axis=1).sum(axis=1) - _, actual = Extractor.subset_spectrum(sub, self.data.id['x'], - slc, 0) - np.testing.assert_array_almost_equal(expected, actual) - - -class Test4DExtractor(object): - - def setup_method(self, method): - self.data = Data() - self.data.coords = MockCoordinates() - x, y, z, w = np.mgrid[:3, :4, :5, :4] - self.data.add_component(1. * w, label='x') - - def test_extract(self): - - roi = RectangularROI() - roi.update_limits(0, 0, 2, 3) - - expected = self.data['x'][:, :2, :3, 1].mean(axis=1).mean(axis=1) - _, actual = Extractor.spectrum(self.data, self.data.id['x'], - roi, (0, 'x', 'y', 1), 0) - - np.testing.assert_array_equal(expected, actual) - - -class TestConstraintsWidget(object): - - def setup_method(self, method): - self.constraints = dict(a=dict(fixed=True, value=1, limits=None)) - self.widget = ConstraintsWidget(self.constraints) - - def test_settings(self): - assert self.widget.settings('a') == dict(fixed=True, value=1, - limits=None) - - def test_update_settings(self): - self.widget._widgets['a'][2].setChecked(False) - assert self.widget.settings('a')['fixed'] is False - - def test_update_constraints(self): - self.widget._widgets['a'][2].setChecked(False) - fitter = MagicMock() - self.widget.update_constraints(fitter) - fitter.set_constraint.assert_called_once_with('a', - fixed=False, value=1, - limits=None) - - -class TestFitSettingsWidget(object): - - def test_option(self): - f = PolynomialFitter() - f.degree = 1 - w = FitSettingsWidget(f) - w.widgets['degree'].setValue(5) - w.update_fitter_from_settings() - assert f.degree == 5 - - @needs_modeling - def test_set_constraints(self): - f = SimpleAstropyGaussianFitter() - w = FitSettingsWidget(f) - w.constraints._widgets['amplitude'][2].setChecked(True) - w.update_fitter_from_settings() - assert f.constraints['amplitude']['fixed'] - - -def test_4d_single_channel(): - - x = np.random.random((1, 7, 5, 9)) - d = Data(x=x) - slc = (0, 0, 'x', 'y') - zaxis = 1 - expected = x[0, :, :, :].mean(axis=1).mean(axis=1) - roi = RectangularROI() - roi.update_limits(-0.5, -0.5, 10.5, 10.5) - - _, actual = Extractor.spectrum(d, d.id['x'], roi, slc, zaxis) - - np.testing.assert_array_almost_equal(expected, actual) - - -@requires_astropy -class TestCollapseContext(BaseTestSpectrumTool): - - def test_collapse(self, tmpdir): - - roi = RectangularROI() - roi.update_limits(0, 2, 0, 2) - self.tool._update_profile() - - self._save(tmpdir) - - # For some reason we need to close and dereference the image and tool - # here (and not in teardown_method) otherwise we are left with - # references to the image viewer. - self.image.close() - self.image = None - self.tool.close() - self.tool = None - - def _save(self, tmpdir): - for context in self.tool._contexts: - if isinstance(context, CollapseContext): - break - else: - raise ValueError("Could not find collapse context") - - context.save_to(tmpdir.join('test.fits').strpath) - - -@requires_astropy -class TestCollapseContextWCS(TestCollapseContext): - - def setup_data(self): - from glue.core.coordinates import coordinates_from_wcs - from astropy.wcs import WCS - wcs = WCS(naxis=3) - - self.data = Data(x=np.zeros((3, 3, 3))) - self.data.coords = coordinates_from_wcs(wcs) diff --git a/glue/plugins/tools/spectrum_tool/tests/__init__.py b/glue/plugins/tools/spectrum_tool/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/setup.py b/setup.py index e8c3163b4..a761e327c 100755 --- a/setup.py +++ b/setup.py @@ -72,7 +72,6 @@ def run(self): export_d3po = glue.plugins.export_d3po:setup export_plotly = glue.plugins.exporters.plotly:setup pv_slicer = glue.plugins.tools.pv_slicer:setup -spectrum_tool = glue.plugins.tools.spectrum_tool:setup coordinate_helpers = glue.plugins.coordinate_helpers:setup spectral_cube = glue.plugins.data_factories.spectral_cube:setup dendro_viewer = glue.plugins.dendro_viewer:setup From 3d73d9862b5c3e4ed0d354a7ab8d744bfc44fbf9 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 18:15:17 +0100 Subject: [PATCH 15/42] Remove unused imports --- glue/viewers/profile/layer_artist.py | 1 - glue/viewers/profile/mouse_mode.py | 2 -- glue/viewers/profile/qt/data_viewer.py | 4 ---- 3 files changed, 7 deletions(-) diff --git a/glue/viewers/profile/layer_artist.py b/glue/viewers/profile/layer_artist.py index 896ddc96f..f52b9d43d 100644 --- a/glue/viewers/profile/layer_artist.py +++ b/glue/viewers/profile/layer_artist.py @@ -3,7 +3,6 @@ import numpy as np from glue.utils import defer_draw -from glue.core import Data from glue.viewers.profile.state import ProfileLayerState from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist from glue.core.exceptions import IncompatibleAttribute diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index 2b5f1d931..6f25d2bae 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -1,5 +1,3 @@ -import weakref - from glue.external.echo import CallbackProperty, delay_callback from glue.core.state_objects import State from glue.viewers.common.qt.mouse_mode import MouseMode diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py index c866c286f..3d2e8b0c8 100644 --- a/glue/viewers/profile/qt/data_viewer.py +++ b/glue/viewers/profile/qt/data_viewer.py @@ -1,15 +1,11 @@ from __future__ import absolute_import, division, print_function -from qtpy import QtWidgets -from qtpy.QtCore import Qt - from glue.viewers.matplotlib.qt.toolbar import MatplotlibViewerToolbar from glue.viewers.matplotlib.qt.data_viewer import MatplotlibDataViewer from glue.viewers.profile.qt.layer_style_editor import ProfileLayerStyleEditor from glue.viewers.profile.layer_artist import ProfileLayerArtist from glue.viewers.profile.qt.options_widget import ProfileOptionsWidget from glue.viewers.profile.state import ProfileViewerState -from glue.viewers.profile.qt.profile_tools import ProfileTools from glue.viewers.common.qt import toolbar_mode # noqa From ea171381d20c3e1aa7f3466abe012a70a9d9c20f Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 18:50:13 +0100 Subject: [PATCH 16/42] Added tests for the profile states --- glue/viewers/profile/state.py | 3 +- glue/viewers/profile/tests/test_state.py | 115 +++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 glue/viewers/profile/tests/test_state.py diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 8e50c5c28..b3100c00e 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -91,7 +91,8 @@ def get_profile(self): if isinstance(self.layer, Data): data_values = self.layer[self.viewer_state.y_att] else: - data_values = self.layer.data[self.viewer_state.y_att].copy() + # We need to force a copy *and* convert to float just in case + data_values = np.array(self.layer.data[self.viewer_state.y_att], dtype=float) mask = self.layer.to_mask() if np.sum(mask) == 0: return [], [] diff --git a/glue/viewers/profile/tests/test_state.py b/glue/viewers/profile/tests/test_state.py new file mode 100644 index 000000000..6a4baa7bb --- /dev/null +++ b/glue/viewers/profile/tests/test_state.py @@ -0,0 +1,115 @@ +import numpy as np +from numpy.testing import assert_allclose + +from glue.core import Data +from glue.core.tests.test_state import clone + +from ..state import ProfileViewerState, ProfileLayerState + + +class TestProfileViewerState: + + def setup_method(self, method): + self.data = Data(label='d1', x=np.arange(24).reshape((3, 4, 2))) + self.viewer_state = ProfileViewerState() + self.layer_state = ProfileLayerState(viewer_state=self.viewer_state, + layer=self.data) + self.viewer_state.layers.append(self.layer_state) + + def test_basic(self): + x, y = self.layer_state.get_profile() + assert_allclose(x, [0, 1, 2]) + assert_allclose(y, [3.5, 11.5, 19.5]) + + def test_x_att(self): + + self.viewer_state.x_att = self.data.pixel_component_ids[0] + x, y = self.layer_state.get_profile() + assert_allclose(x, [0, 1, 2]) + assert_allclose(y, [3.5, 11.5, 19.5]) + + self.viewer_state.x_att = self.data.pixel_component_ids[1] + x, y = self.layer_state.get_profile() + assert_allclose(x, [0, 1, 2, 3]) + assert_allclose(y, [8.5, 10.5, 12.5, 14.5]) + + self.viewer_state.x_att = self.data.pixel_component_ids[2] + x, y = self.layer_state.get_profile() + assert_allclose(x, [0, 1]) + assert_allclose(y, [11, 12]) + + def test_function(self): + + self.viewer_state.function = np.nanmean + x, y = self.layer_state.get_profile() + assert_allclose(y, [3.5, 11.5, 19.5]) + + self.viewer_state.function = np.nanmin + x, y = self.layer_state.get_profile() + assert_allclose(y, [0, 8, 16]) + + self.viewer_state.function = np.nanmax + x, y = self.layer_state.get_profile() + assert_allclose(y, [7, 15, 23]) + + self.viewer_state.function = np.nansum + x, y = self.layer_state.get_profile() + assert_allclose(y, [28, 92, 156]) + + self.viewer_state.function = np.nanmedian + x, y = self.layer_state.get_profile() + assert_allclose(y, [3.5, 11.5, 19.5]) + + def test_subset(self): + + subset = self.data.new_subset() + subset.subset_state = self.data.id['x'] > 10 + + self.layer_state.layer = subset + + x, y = self.layer_state.get_profile() + assert_allclose(x, [0, 1, 2]) + assert_allclose(y, [0., 13., 19.5]) + + subset.subset_state = self.data.id['x'] > 100 + + x, y = self.layer_state.get_profile() + assert len(x) == 0 + assert len(y) == 0 + + def test_clone(self): + + self.viewer_state.x_att = self.data.pixel_component_ids[1] + self.viewer_state.y_att = self.data.id['x'] + self.viewer_state.function = np.nanmedian + + self.layer_state.linewidth = 3 + + viewer_state_new = clone(self.viewer_state) + + assert viewer_state_new.x_att.label == 'Pixel Axis 1 [y]' + assert viewer_state_new.y_att.label == 'x' + assert viewer_state_new.function is np.nanmedian + + assert self.layer_state.linewidth == 3 + + def test_limits(self): + + assert self.viewer_state.x_min == 0 + assert self.viewer_state.x_max == 2 + + self.viewer_state.flip_x() + + assert self.viewer_state.x_min == 2 + assert self.viewer_state.x_max == 0 + + self.viewer_state.x_min = 1 + self.viewer_state.x_max = 1.5 + + assert self.viewer_state.x_min == 1 + assert self.viewer_state.x_max == 1.5 + + self.viewer_state.reset_limits() + + assert self.viewer_state.x_min == 0 + assert self.viewer_state.x_max == 2 From f5250ad320c345ba68d543d72e8de90cb3503221 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 18:54:14 +0100 Subject: [PATCH 17/42] Small fixes --- glue/viewers/profile/__init__.py | 1 - glue/viewers/profile/qt/data_viewer.py | 1 + glue/viewers/profile/qt/profile_tools.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/glue/viewers/profile/__init__.py b/glue/viewers/profile/__init__.py index 2d0e639fe..44388773d 100644 --- a/glue/viewers/profile/__init__.py +++ b/glue/viewers/profile/__init__.py @@ -1,5 +1,4 @@ def setup(): from glue.config import qt_client from .qt.data_viewer import ProfileViewer - print("HERE") qt_client.add(ProfileViewer) diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py index 3d2e8b0c8..f1ddeb66a 100644 --- a/glue/viewers/profile/qt/data_viewer.py +++ b/glue/viewers/profile/qt/data_viewer.py @@ -8,6 +8,7 @@ from glue.viewers.profile.state import ProfileViewerState from glue.viewers.common.qt import toolbar_mode # noqa +from glue.viewers.profile.qt.profile_tools import ProfileAnalysisTool # noqa __all__ = ['ProfileViewer'] diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index bf2149bb7..781239275 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -135,7 +135,7 @@ def _on_slider_change(self, *args): for data in self._nav_data: for viewer in self._nav_viewers[data]: slices = list(viewer.state.slices) - slices[self.viewer.state.x_att.axis] = int(x) + slices[self.viewer.state.x_att.axis] = int(round(x)) viewer.state.slices = slices def _on_settings(self): From 52410e03db6d597919d404f43ccffdfc1e14ec39 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 22:46:16 +0100 Subject: [PATCH 18/42] Added tests for mouse modes --- glue/viewers/profile/mouse_mode.py | 28 ++- glue/viewers/profile/tests/test_mouse_mode.py | 173 ++++++++++++++++++ 2 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 glue/viewers/profile/tests/test_mouse_mode.py diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index 6f25d2bae..2f2d98c73 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -19,21 +19,25 @@ def __init__(self, viewer, press_callback=None): self.state = NavigationModeState() self.state.add_callback('x', self._update_artist) self.pressed = False + self.active = False self._press_callback = press_callback def press(self, event): - self._press_callback() - self.pressed = True - if not event.inaxes: + if not self.active or not event.inaxes: return + if self._press_callback is not None: + self._press_callback() + self.pressed = True self.state.x = event.xdata def move(self, event): - if not self.pressed or not event.inaxes: + if not self.active or not self.pressed or not event.inaxes: return self.state.x = event.xdata def release(self, event): + if not self.active: + return self.pressed = False def _update_artist(self, *args): @@ -48,12 +52,14 @@ def deactivate(self): self._line.set_visible(False) self._canvas.draw() super(NavigateMouseMode, self).deactivate() + self.active = False def activate(self): if hasattr(self, '_line'): self._line.set_visible(True) self._canvas.draw() super(NavigateMouseMode, self).activate() + self.active = True class RangeModeState(State): @@ -81,13 +87,15 @@ def __init__(self, viewer): self.mode = None self.move_params = None - def press(self, event): + self.active = False - self.pressed = True + def press(self, event): - if not event.inaxes: + if not self.active or not event.inaxes: return + self.pressed = True + x_min, x_max = self._axes.get_xlim() x_range = abs(x_max - x_min) @@ -109,7 +117,7 @@ def press(self, event): def move(self, event): - if not self.pressed or not event.inaxes: + if not self.active or not self.pressed or not event.inaxes: return if self.mode == 'move-x-min': @@ -123,6 +131,8 @@ def move(self, event): self.state.x_max = orig_x_max + (event.xdata - orig_click) def release(self, event): + if not self.active: + return self.pressed = False self.mode = None self.move_params @@ -153,6 +163,7 @@ def deactivate(self): self._canvas.draw() super(RangeMouseMode, self).deactivate() + self.active = False def activate(self): if hasattr(self, '_lines'): @@ -161,3 +172,4 @@ def activate(self): self._interval.set_visible(True) self._canvas.draw() super(RangeMouseMode, self).activate() + self.active = True diff --git a/glue/viewers/profile/tests/test_mouse_mode.py b/glue/viewers/profile/tests/test_mouse_mode.py new file mode 100644 index 000000000..63c072669 --- /dev/null +++ b/glue/viewers/profile/tests/test_mouse_mode.py @@ -0,0 +1,173 @@ +from mock import MagicMock + +from matplotlib import pyplot as plt + +from ..mouse_mode import NavigateMouseMode, RangeMouseMode + + +def test_navigate_mouse_mode(): + + callback = MagicMock() + + fig = plt.figure() + ax = fig.add_subplot(1,1,1) + ax.set_xlim(0, 10) + viewer = MagicMock() + viewer.axes = ax + mode = NavigateMouseMode(viewer, press_callback=callback) + + event = MagicMock() + event.xdata = 1.5 + event.inaxes = True + mode.press(event) + assert mode.state.x is None + mode.move(event) + assert mode.state.x is None + mode.release(event) + assert mode.state.x is None + mode.activate() + mode.press(event) + assert callback.call_count == 1 + assert mode.state.x == 1.5 + event.xdata = 2.5 + mode.move(event) + assert mode.state.x == 2.5 + mode.release(event) + event.xdata = 3.5 + mode.move(event) + assert mode.state.x == 2.5 + mode.deactivate() + event.xdata = 1.5 + mode.press(event) + assert callback.call_count == 1 + assert mode.state.x == 2.5 + mode.activate() + event.xdata = 3.5 + mode.press(event) + assert callback.call_count == 2 + assert mode.state.x == 3.5 + event.inaxes = False + event.xdata = 4.5 + mode.press(event) + assert callback.call_count == 2 + assert mode.state.x == 3.5 + + +def test_range_mouse_mode(): + + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1) + ax.set_xlim(0, 10) + viewer = MagicMock() + viewer.axes = ax + mode = RangeMouseMode(viewer) + + event = MagicMock() + event.xdata = 1.5 + event.inaxes = True + + # Pressing, moving, and releasing doesn't do anything until mode is active + mode.press(event) + assert mode.state.x_min is None + assert mode.state.x_max is None + mode.move(event) + assert mode.state.x_min is None + assert mode.state.x_max is None + mode.release(event) + assert mode.state.x_min is None + assert mode.state.x_max is None + + mode.activate() + + # Click and drag then creates an interval where x_min is the first value + # that was clicked and x_max is set to the position of the mouse while + # dragging and at the point of releasing. + + mode.press(event) + assert mode.state.x_min == 1.5 + assert mode.state.x_max is 1.5 + + event.xdata = 2.5 + mode.move(event) + assert mode.state.x_min == 1.5 + assert mode.state.x_max == 2.5 + + event.xdata = 3.5 + mode.move(event) + assert mode.state.x_min == 1.5 + assert mode.state.x_max == 3.5 + + mode.release(event) + event.xdata = 4.5 + mode.move(event) + assert mode.state.x_min == 1.5 + assert mode.state.x_max == 3.5 + + # Test that we can drag the existing edges by clicking on then + + event.xdata = 1.49 + mode.press(event) + event.xdata = 1.25 + mode.move(event) + assert mode.state.x_min == 1.25 + assert mode.state.x_max == 3.5 + mode.release(event) + + event.xdata = 3.51 + mode.press(event) + event.xdata = 4.0 + mode.move(event) + assert mode.state.x_min == 1.25 + assert mode.state.x_max == 4.0 + mode.release(event) + + # Test that we can drag the entire interval by clicking inside + + event.xdata = 2 + mode.press(event) + event.xdata = 3 + mode.move(event) + assert mode.state.x_min == 2.25 + assert mode.state.x_max == 5.0 + mode.release(event) + + # Test that x_range works + + assert mode.state.x_range == (2.25, 5.0) + + # Clicking outside the range starts a new interval + + event.xdata = 6 + mode.press(event) + event.xdata = 7 + mode.move(event) + assert mode.state.x_min == 6 + assert mode.state.x_max == 7 + mode.release(event) + + # Deactivate and activate again to make sure that code for hiding/showing + # artists gets executed + + mode.deactivate() + + event.xdata = 8 + mode.press(event) + assert mode.state.x_min == 6 + assert mode.state.x_max == 7 + + mode.activate() + + event.xdata = 9 + mode.press(event) + event.xdata = 10 + mode.move(event) + assert mode.state.x_min == 9 + assert mode.state.x_max == 10 + + # Check that events outside the axes get ignored + + event.inaxes = False + event.xdata = 11 + mode.press(event) + assert mode.state.x_min == 9 + assert mode.state.x_max == 10 From 8e13832d85f960e3192d6ab0c7510165e014df30 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 22:46:26 +0100 Subject: [PATCH 19/42] Added another viewer test --- glue/viewers/profile/qt/tests/test_data_viewer.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/glue/viewers/profile/qt/tests/test_data_viewer.py b/glue/viewers/profile/qt/tests/test_data_viewer.py index c7de9c80f..ee0b3e2b1 100644 --- a/glue/viewers/profile/qt/tests/test_data_viewer.py +++ b/glue/viewers/profile/qt/tests/test_data_viewer.py @@ -38,7 +38,7 @@ class TestProfileViewer(object): def setup_method(self, method): - self.data = Data(label='d1', x=[3.4, 2.3, -1.1, 0.3], y=['a', 'b', 'c', 'a']) + self.data = Data(label='d1', x=np.arange(24).reshape((3, 4, 2))) self.app = GlueApplication() self.session = self.app.session @@ -51,3 +51,11 @@ def setup_method(self, method): def teardown_method(self, method): self.viewer.close() + + def test_functions(self): + self.viewer.add_data(self.data) + self.viewer.state.function = np.nanmean + assert len(self.viewer.layers) == 1 + layer_artist = self.viewer.layers[0] + assert_allclose(layer_artist._visible_data[0], [0, 1, 2]) + assert_allclose(layer_artist._visible_data[1], [3.5, 11.5, 19.5]) From a73191b841c9803e0262ae1a892e40b7522de51e Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 22:51:57 +0100 Subject: [PATCH 20/42] Added a test when incompatible data is present --- glue/viewers/profile/qt/tests/test_data_viewer.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/glue/viewers/profile/qt/tests/test_data_viewer.py b/glue/viewers/profile/qt/tests/test_data_viewer.py index ee0b3e2b1..2a799a376 100644 --- a/glue/viewers/profile/qt/tests/test_data_viewer.py +++ b/glue/viewers/profile/qt/tests/test_data_viewer.py @@ -59,3 +59,12 @@ def test_functions(self): layer_artist = self.viewer.layers[0] assert_allclose(layer_artist._visible_data[0], [0, 1, 2]) assert_allclose(layer_artist._visible_data[1], [3.5, 11.5, 19.5]) + + def test_incompatible(self): + self.viewer.add_data(self.data) + data2 = Data(y=np.random.random((3, 4, 2))) + self.data_collection.append(data2) + self.viewer.add_data(data2) + assert len(self.viewer.layers) == 2 + assert self.viewer.layers[0].enabled + assert not self.viewer.layers[1].enabled From 62f68497ab98f5a04f79c422523cb1bde038d9f5 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Fri, 30 Mar 2018 23:59:18 +0100 Subject: [PATCH 21/42] Started added tests for ProfileTools --- glue/viewers/profile/qt/profile_tools.py | 10 +-- .../profile/qt/tests/test_profile_tools.py | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 glue/viewers/profile/qt/tests/test_profile_tools.py diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 781239275..45899ac16 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -136,7 +136,7 @@ def _on_slider_change(self, *args): for viewer in self._nav_viewers[data]: slices = list(viewer.state.slices) slices[self.viewer.state.x_att.axis] = int(round(x)) - viewer.state.slices = slices + viewer.state.slices = tuple(slices) def _on_settings(self): d = FitSettingsWidget(self.fitter) @@ -162,7 +162,7 @@ def on_success(result): for layer_artist in fit_results: report += ("{1}" "".format(to_hex(layer_artist.state.color), - layer_artist.layer.label)) + layer_artist.layer.label)) report += "
" + fitter.summarize(fit_results[layer_artist], x, y) + "
" self._report_fit(report) self._plot_fit(fitter, fit_results, x, y) @@ -187,6 +187,9 @@ def on_done(): self._fit_worker = w # hold onto a reference w.start() + def wait_for_fit(self): + self._fit_worker.wait() + def _report_fit(self, report): self.ui.text_log.document().setHtml(report) @@ -265,7 +268,6 @@ def _on_collapse(self): for data in self._visible_data(): for viewer in self._viewers_with_data_slice(data, self.viewer.state.x_att): - print(type(viewer)) slices = list(viewer.state.slices) @@ -278,8 +280,6 @@ def _on_collapse(self): current_slice, func) - print(slices) - viewer.state.slices = tuple(slices) @property diff --git a/glue/viewers/profile/qt/tests/test_profile_tools.py b/glue/viewers/profile/qt/tests/test_profile_tools.py new file mode 100644 index 000000000..7546a6d0e --- /dev/null +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -0,0 +1,70 @@ +from __future__ import absolute_import, division, print_function + +import os +from collections import Counter + +import pytest +import numpy as np + +from numpy.testing import assert_equal, assert_allclose + +from glue.core.message import SubsetUpdateMessage +from glue.core import HubListener, Data +from glue.core.roi import XRangeROI +from glue.core.subset import RangeSubsetState, CategoricalROISubsetState +from glue import core +from glue.app.qt import GlueApplication +from glue.core.component_id import ComponentID +from glue.utils.qt import combo_as_string, get_qapp +from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer +from glue.core.state import GlueUnSerializer +from glue.app.qt.layer_tree_widget import LayerTreeWidget + +from glue.viewers.image.qt import ImageViewer +from ..data_viewer import ProfileViewer + +class TestProfileTools(object): + + def setup_method(self, method): + + self.data = Data(label='d1', x=np.arange(240).reshape((30, 4, 2))) + + self.app = GlueApplication() + self.session = self.app.session + self.hub = self.session.hub + + self.data_collection = self.session.data_collection + self.data_collection.append(self.data) + + self.viewer = self.app.new_data_viewer(ProfileViewer) + + self.viewer.toolbar.active_tool = 'profile-analysis' + + self.profile_tools = self.viewer.toolbar.tools['profile-analysis']._profile_tools + + def teardown_method(self, method): + self.viewer.close() + + def test_navigate_sync_image(self): + self.viewer.add_data(self.data) + image_viewer = self.app.new_data_viewer(ImageViewer) + image_viewer.add_data(self.data) + assert image_viewer.state.slices == (0, 0, 0) + x, y = self.viewer.axes.transData.transform([[1, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + assert image_viewer.state.slices == (1, 0, 0) + + def test_fit_polynomial(self): + # TODO: need to deterministically set to polynomial fitter + self.viewer.add_data(self.data) + self.profile_tools.ui.tabs.setCurrentIndex(1) + x, y = self.viewer.axes.transData.transform([[1, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + x, y = self.viewer.axes.transData.transform([[15, 4]])[0] + self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) + assert_allclose(self.profile_tools.rng_mode.state.x_range, (1, 15)) + self.profile_tools.ui.button_fit.click() + self.profile_tools.wait_for_fit() + app = get_qapp() + app.processEvents() + assert self.profile_tools.text_log.toPlainText().startswith('d1\nCoefficients') From 4390c5261fbb30ddaf598ff3416fd14de18a8e27 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Sat, 31 Mar 2018 11:42:40 +0100 Subject: [PATCH 22/42] Finish adding tests --- .../profile/qt/tests/test_profile_tools.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/glue/viewers/profile/qt/tests/test_profile_tools.py b/glue/viewers/profile/qt/tests/test_profile_tools.py index 7546a6d0e..5729b8d51 100644 --- a/glue/viewers/profile/qt/tests/test_profile_tools.py +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -19,6 +19,7 @@ from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer from glue.core.state import GlueUnSerializer from glue.app.qt.layer_tree_widget import LayerTreeWidget +from glue.viewers.image.state import AggregateSlice from glue.viewers.image.qt import ImageViewer from ..data_viewer import ProfileViewer @@ -68,3 +69,22 @@ def test_fit_polynomial(self): app = get_qapp() app.processEvents() assert self.profile_tools.text_log.toPlainText().startswith('d1\nCoefficients') + self.profile_tools.ui.button_clear.click() + assert self.profile_tools.text_log.toPlainText() == '' + + def test_collapse(self): + # TODO: need to deterministically set to polynomial fitter + self.viewer.add_data(self.data) + image_viewer = self.app.new_data_viewer(ImageViewer) + image_viewer.add_data(self.data) + self.profile_tools.ui.tabs.setCurrentIndex(2) + x, y = self.viewer.axes.transData.transform([[1, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + x, y = self.viewer.axes.transData.transform([[15, 4]])[0] + self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) + self.profile_tools.ui.button_collapse.click() + assert isinstance(image_viewer.state.slices[0], AggregateSlice) + assert image_viewer.state.slices[0].slice.start == 0 + assert image_viewer.state.slices[0].slice.stop == 14 + assert image_viewer.state.slices[0].center == 0 + assert image_viewer.state.slices[0].function is np.nanmean From ae28c0336b5d553f49556c0371eddc348cef313d Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Sat, 31 Mar 2018 12:53:41 +0100 Subject: [PATCH 23/42] Make function order deterministic --- glue/viewers/profile/qt/profile_tools.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 45899ac16..d6bb13756 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -1,6 +1,7 @@ import os import weakref import traceback +from collections import OrderedDict import numpy as np @@ -25,13 +26,13 @@ MODES = ['navigate', 'fit', 'collapse'] -COLLAPSE_FUNCS = {np.nanmean: 'Mean', - np.nanmedian: 'Median', - np.nanmin: 'Minimum', - np.nanmax: 'Maximum', - np.nansum: 'Sum', - mom1: 'Moment 1', - mom2: 'Moment 2'} +COLLAPSE_FUNCS = OrderedDict([(np.nanmean, 'Mean'), + (np.nanmedian, 'Median'), + (np.nanmin, 'Minimum'), + (np.nanmax, 'Maximum'), + (np.nansum, 'Sum'), + (mom1, 'Moment 1'), + (mom2, 'Moment 2')]) @viewer_tool From 237834b08b2d7f7b15c72f700912d7ceeca0c0df Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 18:26:58 +0100 Subject: [PATCH 24/42] Added a new function 'is_convertible_to_single_pixel_cid' which will help us determine whether a profile can be plotted for a given dataset --- glue/core/link_manager.py | 39 +++++++++++++++++ glue/core/tests/test_link_manager.py | 64 ++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/glue/core/link_manager.py b/glue/core/link_manager.py index ecdb4eb0b..86c2fe9f0 100644 --- a/glue/core/link_manager.py +++ b/glue/core/link_manager.py @@ -306,3 +306,42 @@ def is_equivalent_cid(data, cid1, cid2): cid2 = _find_identical_reference_cid(data, cid2) return cid1 is cid2 + + +def is_convertible_to_single_pixel_cid(data, cid): + """ + Given a dataset and a component ID, determine whether a pixel component + exists in data such that the component ID can be derived solely from the + pixel component. Returns `None` if no such pixel component ID can be found + and returns the pixel component ID if one exists. + + Parameters + ---------- + data : `~glue.core.Data` + The data in which to check for pixel components IDs + cid : `~glue.core.ComponentID` + The component ID to search for + """ + if cid in data.pixel_component_ids: + return cid + else: + try: + target_comp = data.get_component(cid) + except IncompatibleAttribute: + return None + if cid in data.world_component_ids: + if len(data.coords.dependent_axes(target_comp.axis)) == 1: + return data.pixel_component_ids[target_comp.axis] + else: + return None + else: + if isinstance(target_comp, DerivedComponent): + from_ids = [is_convertible_to_single_pixel_cid(data, cid) + for cid in target_comp.link.get_from_ids()] + if None in from_ids: + return None + else: + # Use set to get rid of duplicates + from_ids = list(set(from_ids)) + if len(from_ids) == 1: + return is_convertible_to_single_pixel_cid(data, from_ids[0]) diff --git a/glue/core/tests/test_link_manager.py b/glue/core/tests/test_link_manager.py index 750d85446..44a9b5759 100644 --- a/glue/core/tests/test_link_manager.py +++ b/glue/core/tests/test_link_manager.py @@ -5,11 +5,11 @@ import numpy as np from ..component_link import ComponentLink -from ..data import ComponentID, DerivedComponent -from ..data import Data, Component +from ..data import ComponentID, DerivedComponent, Data, Component +from ..coordinates import Coordinates from ..data_collection import DataCollection from ..link_manager import (LinkManager, accessible_links, discover_links, - find_dependents) + find_dependents, is_convertible_to_single_pixel_cid) from ..link_helpers import LinkSame comp = Component(data=np.array([1, 2, 3])) @@ -300,3 +300,61 @@ def test_remove_component_removes_links(self): assert len(d1.externally_derivable_components) == 0 assert len(d2.externally_derivable_components) == 0 + + +def test_is_convertible_to_single_pixel_cid(): + + # This tests the function is_convertible_to_single_pixel_cid, which gives + # for a given dataset the pixel component ID that can be uniquely + # transformed into the requested component ID. + + # Set up a coordinate object which has an independent first axis and + # has the second and third axes depend on each other. The transformation + # itself is irrelevant since for this function to work we only care about + # whether or not an axis is independent. + + class CustomCoordinates(Coordinates): + def dependent_axes(self, axis): + if axis == 0: + return (0,) + else: + return (1, 2) + + data1 = Data() + data1.coords = CustomCoordinates() + data1['x'] = np.ones((4, 3, 4)) + px1, py1, pz1 = data1.pixel_component_ids + wx1, wy1, wz1 = data1.world_component_ids + data1['a'] = px1 * 2 + data1['b'] = wx1 * 2 + data1['c'] = wy1 * 2 + data1['d'] = wx1 * 2 + px1 + data1['e'] = wx1 * 2 + wy1 + + # Pixel component IDs should just be returned directly + for cid in data1.pixel_component_ids: + assert is_convertible_to_single_pixel_cid(data1, cid) is cid + + # Only the first world component should return a valid pixel component + # ID since the two other world components are interlinked + assert is_convertible_to_single_pixel_cid(data1, wx1) is px1 + assert is_convertible_to_single_pixel_cid(data1, wy1) is None + assert is_convertible_to_single_pixel_cid(data1, wz1) is None + + # a and b are ultimately linked to the first pixel coordinate, whereas c + # depends on the second world coordinate which is interlinked with the third + # Finally, d is ok because it really only depends on px1 + assert is_convertible_to_single_pixel_cid(data1, data1.id['a']) is px1 + assert is_convertible_to_single_pixel_cid(data1, data1.id['b']) is px1 + assert is_convertible_to_single_pixel_cid(data1, data1.id['c']) is None + assert is_convertible_to_single_pixel_cid(data1, data1.id['d']) is px1 + assert is_convertible_to_single_pixel_cid(data1, data1.id['e']) is None + + # We now create a second dataset and set up links + data2 = Data(y=np.ones((4, 5, 6, 7)), z=np.zeros((4, 5, 6, 7))) + dc = DataCollection([data1, data2]) + dc.add_link(ComponentLink([data1.id['a'], px1], data2.id['y'], using=lambda x: 2 * x)) + dc.add_link(ComponentLink([wy1], data2.id['z'], using=lambda x: 2 * x)) + + assert is_convertible_to_single_pixel_cid(data1, data2.id['y']) is px1 + assert is_convertible_to_single_pixel_cid(data1, data2.id['z']) is None From eccdcac637be6377e716a4c4d11001627eb74206 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 18:48:05 +0100 Subject: [PATCH 25/42] Make it possible to overlay datasets with linked pixel coordinates --- glue/viewers/profile/state.py | 42 ++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index b3100c00e..8b38c4207 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -11,6 +11,8 @@ from glue.core.state_objects import StateAttributeLimitsHelper from glue.core.data_combo_helper import ComponentIDComboHelper from glue.utils import defer_draw +from glue.core.link_manager import is_convertible_to_single_pixel_cid +from glue.core.exceptions import IncompatibleAttribute __all__ = ['ProfileViewerState', 'ProfileLayerState'] @@ -46,7 +48,9 @@ def __init__(self, **kwargs): self.add_callback('layers', self._layers_changed) - self.x_att_helper = ComponentIDComboHelper(self, 'x_att', numeric=False, categorical=False, pixel_coord=True) + self.x_att_helper = ComponentIDComboHelper(self, 'x_att', + numeric=False, categorical=False, + world_coord=False, pixel_coord=True) self.y_att_helper = ComponentIDComboHelper(self, 'y_att', numeric=True) ProfileViewerState.function.set_choices(self, list(FUNCTIONS)) @@ -88,23 +92,41 @@ class ProfileLayerState(MatplotlibLayerState): def get_profile(self): + # Check what pixel axis in the current dataset x_att corresponds to + pix_cid = is_convertible_to_single_pixel_cid(self.layer, self.viewer_state.x_att) + + if pix_cid is None: + raise IncompatibleAttribute() + + # If we get here, then x_att does correspond to a single pixel axis in + # the cube, so we now prepare a list of axes to collapse over. + axes = tuple(i for i in range(self.layer.ndim) if i != pix_cid.axis) + + # We now get the y values for the data + + # TODO: in future we should optimize the case where the mask is much + # smaller than the data to just average the relevant 'spaxels' in the + # data rather than collapsing the whole cube. + if isinstance(self.layer, Data): - data_values = self.layer[self.viewer_state.y_att] + data = self.layer + data_values = data[self.viewer_state.y_att] else: # We need to force a copy *and* convert to float just in case - data_values = np.array(self.layer.data[self.viewer_state.y_att], dtype=float) + data = self.layer.data + data_values = np.array(data[self.viewer_state.y_att], dtype=float) mask = self.layer.to_mask() if np.sum(mask) == 0: return [], [] data_values[~mask] = np.nan # Collapse along all dimensions except x_att - # TODO: in future we should optimize the case where the mask is much - # smaller than the data to just average the relevant 'spaxels' in the - # data rather than collapsing the whole cube. - axes = list(range(data_values.ndim)) - axes.remove(self.viewer_state.x_att.axis) - profile_values = self.viewer_state.function(data_values, axis=tuple(axes)) + profile_values = self.viewer_state.function(data_values, axis=axes) profile_values[np.isnan(profile_values)] = 0. - return np.arange(len(profile_values)), profile_values + # Finally, we get the coordinate values for the requested axis + axis_view = [0] * data.ndim + axis_view[pix_cid.axis] = slice(None) + axis_values = data[self.viewer_state.x_att, axis_view] + + return axis_values, profile_values From 72e455daae1225e2d8082a9245a950edb6d1cf4f Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 18:52:46 +0100 Subject: [PATCH 26/42] Show world coordinates in combo box --- glue/viewers/profile/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 8b38c4207..141c9c607 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -50,7 +50,7 @@ def __init__(self, **kwargs): self.x_att_helper = ComponentIDComboHelper(self, 'x_att', numeric=False, categorical=False, - world_coord=False, pixel_coord=True) + world_coord=True, pixel_coord=True) self.y_att_helper = ComponentIDComboHelper(self, 'y_att', numeric=True) ProfileViewerState.function.set_choices(self, list(FUNCTIONS)) From 185f2dc0ac9d7e70a08831fe6bde45fe78eb0d86 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 19:10:55 +0100 Subject: [PATCH 27/42] Show warning when profile viewer is empty --- glue/core/coordinates.py | 2 +- glue/core/link_manager.py | 4 +- glue/viewers/profile/qt/options_widget.py | 16 +++++ glue/viewers/profile/qt/options_widget.ui | 76 ++++++++++++++--------- glue/viewers/profile/state.py | 4 ++ 5 files changed, 70 insertions(+), 32 deletions(-) diff --git a/glue/core/coordinates.py b/glue/core/coordinates.py index 1c487662b..980d66d2c 100644 --- a/glue/core/coordinates.py +++ b/glue/core/coordinates.py @@ -177,7 +177,7 @@ def axis_label(self, axis): return "World {}".format(axis) def dependent_axes(self, axis): - """Return a tuple of which world-axes are non-indepndent + """Return a tuple of which world-axes are non-independent from a given pixel axis The axis index is given in numpy ordering convention (note that diff --git a/glue/core/link_manager.py b/glue/core/link_manager.py index 86c2fe9f0..bccf5cdee 100644 --- a/glue/core/link_manager.py +++ b/glue/core/link_manager.py @@ -28,7 +28,7 @@ from glue.core.data import Data from glue.core.component import DerivedComponent from glue.core.exceptions import IncompatibleAttribute - +from glue.core.subset import Subset __all__ = ['accessible_links', 'discover_links', 'find_dependents', 'LinkManager', 'is_equivalent_cid'] @@ -322,6 +322,8 @@ def is_convertible_to_single_pixel_cid(data, cid): cid : `~glue.core.ComponentID` The component ID to search for """ + if isinstance(data, Subset): + data = data.data if cid in data.pixel_component_ids: return cid else: diff --git a/glue/viewers/profile/qt/options_widget.py b/glue/viewers/profile/qt/options_widget.py index 263aed67c..607ae4210 100644 --- a/glue/viewers/profile/qt/options_widget.py +++ b/glue/viewers/profile/qt/options_widget.py @@ -10,6 +10,12 @@ __all__ = ['ProfileOptionsWidget'] +WARNING_TEXT = ("Warning: the coordinate '{label}' is not aligned with pixel " + "grid for any of the datasets, so no profiles could be " + "computed. Try selecting another world coordinates or one of the " + "pixel coordinates.") + + class ProfileOptionsWidget(QtWidgets.QWidget): def __init__(self, viewer_state, session, parent=None): @@ -26,3 +32,13 @@ def __init__(self, viewer_state, session, parent=None): self.viewer_state = viewer_state self.session = session + + self.viewer_state.add_callback('x_att', self._on_attribute_change) + + def _on_attribute_change(self, *args): + for layer_state in self.viewer_state.layers: + if layer_state.independent_x_att: + self.ui.text_warning.hide() + return + self.ui.text_warning.show() + self.ui.text_warning.setText(WARNING_TEXT.format(label=self.viewer_state.x_att.label)) diff --git a/glue/viewers/profile/qt/options_widget.ui b/glue/viewers/profile/qt/options_widget.ui index 5e21a23f6..9bc94ab39 100644 --- a/glue/viewers/profile/qt/options_widget.ui +++ b/glue/viewers/profile/qt/options_widget.ui @@ -57,20 +57,7 @@ 5 - - - - Qt::Vertical - - - - 20 - 40 - - - - - + @@ -86,7 +73,27 @@ - + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + Qt::Horizontal + + + + 40 + 5 + + + + + @@ -102,30 +109,23 @@ - - - - QComboBox::AdjustToMinimumContentsLength - - - - - + + - Qt::Horizontal + Qt::Vertical - 40 - 5 + 20 + 40 - + - + @@ -141,9 +141,25 @@ - + + + + + color: rgb(255, 33, 28) + + + Warning + + + Qt::AlignCenter + + + true + + +
diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 141c9c607..7d10044ad 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -90,6 +90,10 @@ class ProfileLayerState(MatplotlibLayerState): linewidth = DDCProperty(1, docstring='The width of the line') + @property + def independent_x_att(self): + return is_convertible_to_single_pixel_cid(self.layer, self.viewer_state.x_att) is not None + def get_profile(self): # Check what pixel axis in the current dataset x_att corresponds to From 32ed0b11c3494cb266382dbcf854fa9faa6abc3e Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 19:19:47 +0100 Subject: [PATCH 28/42] Added reference_data combo --- glue/viewers/profile/qt/options_widget.ui | 105 +++++++++++++--------- glue/viewers/profile/state.py | 21 ++++- 2 files changed, 80 insertions(+), 46 deletions(-) diff --git a/glue/viewers/profile/qt/options_widget.ui b/glue/viewers/profile/qt/options_widget.ui index 9bc94ab39..8858fef4d 100644 --- a/glue/viewers/profile/qt/options_widget.ui +++ b/glue/viewers/profile/qt/options_widget.ui @@ -57,8 +57,8 @@ 5 - - + + 75 @@ -66,34 +66,30 @@ - y axis + function Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - QComboBox::AdjustToMinimumContentsLength - - - - - - Qt::Horizontal + + + color: rgb(255, 33, 28) - - - 40 - 5 - + + Warning - + + Qt::AlignCenter + + + true + + - + @@ -109,7 +105,26 @@ - + + + + + + + Qt::Horizontal + + + + 40 + 5 + + + + + + + + Qt::Vertical @@ -122,11 +137,8 @@ - - - - - + + 75 @@ -134,32 +146,39 @@ - function + y axis Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + + + QComboBox::AdjustToMinimumContentsLength + + - - - - color: rgb(255, 33, 28) - - - Warning - - - Qt::AlignCenter - - - true - + + + + + 75 + true + + + + reference + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + +
diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 7d10044ad..ed76d7e91 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -9,7 +9,7 @@ DeferredDrawCallbackProperty as DDCProperty, DeferredDrawSelectionCallbackProperty as DDSCProperty) from glue.core.state_objects import StateAttributeLimitsHelper -from glue.core.data_combo_helper import ComponentIDComboHelper +from glue.core.data_combo_helper import ManualDataComboHelper, ComponentIDComboHelper from glue.utils import defer_draw from glue.core.link_manager import is_convertible_to_single_pixel_cid from glue.core.exceptions import IncompatibleAttribute @@ -29,6 +29,11 @@ class ProfileViewerState(MatplotlibDataViewerState): A state class that includes all the attributes for a Profile viewer. """ + reference_data = DDSCProperty(docstring='The dataset that is used to define the ' + 'available pixel/world components, and ' + 'which defines the coordinate frame in ' + 'which the images are shown') + x_att = DDSCProperty(docstring='The data component to use for the x-axis ' 'of the profile (should be a pixel component)') @@ -43,10 +48,13 @@ def __init__(self, **kwargs): super(ProfileViewerState, self).__init__() + self.ref_data_helper = ManualDataComboHelper(self, 'reference_data') + self.x_lim_helper = StateAttributeLimitsHelper(self, 'x_att', lower='x_min', upper='x_max') self.add_callback('layers', self._layers_changed) + self.add_callback('reference_data', self._reference_data_changed) self.x_att_helper = ComponentIDComboHelper(self, 'x_att', numeric=False, categorical=False, @@ -58,6 +66,9 @@ def __init__(self, **kwargs): self.update_from_dict(kwargs) + def _update_combo_ref_data(self): + self.ref_data_helper.set_multiple_data(self.layers_data) + def reset_limits(self): with delay_callback(self, 'x_min', 'x_max'): self.x_lim_helper.percentile = 100 @@ -79,8 +90,12 @@ def flip_x(self): @defer_draw def _layers_changed(self, *args): - self.x_att_helper.set_multiple_data(self.layers_data) - self.y_att_helper.set_multiple_data(self.layers_data) + self._update_combo_ref_data() + + @defer_draw + def _reference_data_changed(self, *args): + self.x_att_helper.set_multiple_data([self.reference_data]) + self.y_att_helper.set_multiple_data([self.reference_data]) class ProfileLayerState(MatplotlibLayerState): From ea2644d01650abff4bf997dc55ea862d25ac0119 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 20:30:52 +0100 Subject: [PATCH 29/42] Hide missing profile values instead of using zero --- glue/viewers/profile/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index ed76d7e91..ff7fe277f 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -141,7 +141,6 @@ def get_profile(self): # Collapse along all dimensions except x_att profile_values = self.viewer_state.function(data_values, axis=axes) - profile_values[np.isnan(profile_values)] = 0. # Finally, we get the coordinate values for the requested axis axis_view = [0] * data.ndim From 1a7feabcec087a408955ec1306935368002a9a8d Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 20:37:12 +0100 Subject: [PATCH 30/42] Make StateAttributeLimitsHelper adjust return values by 0.5 when considering pixel components --- glue/core/state_objects.py | 8 ++++++++ glue/viewers/profile/layer_artist.py | 1 + 2 files changed, 9 insertions(+) diff --git a/glue/core/state_objects.py b/glue/core/state_objects.py index be6653fb7..8ecd517f4 100644 --- a/glue/core/state_objects.py +++ b/glue/core/state_objects.py @@ -8,6 +8,7 @@ from glue.external.echo import (delay_callback, CallbackProperty, HasCallbackProperties, CallbackList) from glue.core.state import saver, loader +from glue.core.component_id import PixelComponentID __all__ = ['State', 'StateAttributeCacheHelper', 'StateAttributeLimitsHelper', 'StateAttributeSingleValueHelper'] @@ -322,6 +323,13 @@ def update_values(self, force=False, use_default_modifiers=False, **properties): else: + # Shortcut if the component ID is a pixel component ID + if isinstance(self.component_id, PixelComponentID) and percentile == 100 and not log: + lower = -0.5 + upper = self.data.shape[self.component_id.axis] - 0.5 + self.set(lower=lower, upper=upper, percentile=percentile, log=log) + return + exclude = (100 - percentile) / 2. data_values = self.data_values diff --git a/glue/viewers/profile/layer_artist.py b/glue/viewers/profile/layer_artist.py index f52b9d43d..896ddc96f 100644 --- a/glue/viewers/profile/layer_artist.py +++ b/glue/viewers/profile/layer_artist.py @@ -3,6 +3,7 @@ import numpy as np from glue.utils import defer_draw +from glue.core import Data from glue.viewers.profile.state import ProfileLayerState from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist from glue.core.exceptions import IncompatibleAttribute From 754353c04ab2fe78ead6d6bc4bc71fffa3b7f258 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 20:40:36 +0100 Subject: [PATCH 31/42] Only enable slice navigator when profile tools are shown --- glue/viewers/profile/qt/profile_tools.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index d6bb13756..c04ec4a77 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -296,10 +296,14 @@ def _on_toolbar_activate(self, *event): self.nav_mode.deactivate() def _on_tab_change(self, *event): - mode = self.mode - if mode == 'navigate': - self.rng_mode.deactivate() - self.nav_mode.activate() + if self.isVisible(): + mode = self.mode + if mode == 'navigate': + self.rng_mode.deactivate() + self.nav_mode.activate() + else: + self.rng_mode.activate() + self.nav_mode.deactivate() else: - self.rng_mode.activate() + self.rng_mode.deactivate() self.nav_mode.deactivate() From 78f3ad77498216f11692ddbc3fef3a48a496be86 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 21:08:54 +0100 Subject: [PATCH 32/42] Fix tests --- glue/viewers/profile/qt/options_widget.py | 6 +++ glue/viewers/profile/qt/profile_tools.py | 38 ++++++++++++------- .../profile/qt/tests/test_profile_tools.py | 5 ++- glue/viewers/profile/state.py | 9 +++-- glue/viewers/profile/tests/test_state.py | 14 +++---- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/glue/viewers/profile/qt/options_widget.py b/glue/viewers/profile/qt/options_widget.py index 607ae4210..bb3a0557f 100644 --- a/glue/viewers/profile/qt/options_widget.py +++ b/glue/viewers/profile/qt/options_widget.py @@ -36,9 +36,15 @@ def __init__(self, viewer_state, session, parent=None): self.viewer_state.add_callback('x_att', self._on_attribute_change) def _on_attribute_change(self, *args): + + if self.viewer_state.x_att is None: + self.ui.text_warning.hide() + return + for layer_state in self.viewer_state.layers: if layer_state.independent_x_att: self.ui.text_warning.hide() return + self.ui.text_warning.show() self.ui.text_warning.setText(WARNING_TEXT.format(label=self.viewer_state.x_att.label)) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index c04ec4a77..7405cc159 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -77,6 +77,15 @@ def __init__(self, parent=None): def viewer(self): return self._viewer() + def show(self, *args): + super(ProfileTools, self).show(*args) + self._on_tab_change() + + def hide(self, *args): + super(ProfileTools, self).hide(*args) + self.rng_mode.deactivate() + self.nav_mode.deactivate() + def enable(self): self.nav_mode = NavigateMouseMode(self.viewer, @@ -88,7 +97,6 @@ def enable(self): self.ui.tabs.setCurrentIndex(0) self.ui.tabs.currentChanged.connect(self._on_tab_change) - self._on_tab_change() self.ui.button_settings.clicked.connect(self._on_settings) self.ui.button_fit.clicked.connect(self._on_fit) @@ -117,13 +125,17 @@ def enable(self): @property def fitter(self): - # FIXME: might not work with PyQt4 - return self.ui.combosel_fit_function.currentData() + try: + return self.ui.combosel_fit_function.currentData() + except AttributeError: # PYQT4 + return self.ui.combosel_fit_function.data(self.ui.combosel_fit_function.currentIndex()) @property def collapse_function(self): - # FIXME: might not work with PyQt4 - return self.ui.combosel_collapse_function.currentData() + try: + return self.ui.combosel_collapse_function.currentData() + except AttributeError: # PYQT4 + return self.ui.combosel_collapse_function.data(self.ui.combosel_collapse_function.currentIndex()) def _on_nav_activate(self, *args): self._nav_data = self._visible_data() @@ -132,7 +144,9 @@ def _on_nav_activate(self, *args): self._nav_viewers[data] = self._viewers_with_data_slice(data, self.viewer.state.x_att) def _on_slider_change(self, *args): + print("ON SLIDER CHANGE") x = self.nav_mode.state.x + print("X",x) for data in self._nav_data: for viewer in self._nav_viewers[data]: slices = list(viewer.state.slices) @@ -296,14 +310,10 @@ def _on_toolbar_activate(self, *event): self.nav_mode.deactivate() def _on_tab_change(self, *event): - if self.isVisible(): - mode = self.mode - if mode == 'navigate': - self.rng_mode.deactivate() - self.nav_mode.activate() - else: - self.rng_mode.activate() - self.nav_mode.deactivate() - else: + mode = self.mode + if mode == 'navigate': self.rng_mode.deactivate() + self.nav_mode.activate() + else: + self.rng_mode.activate() self.nav_mode.deactivate() diff --git a/glue/viewers/profile/qt/tests/test_profile_tools.py b/glue/viewers/profile/qt/tests/test_profile_tools.py index 5729b8d51..9ab1365d8 100644 --- a/glue/viewers/profile/qt/tests/test_profile_tools.py +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -24,6 +24,7 @@ from glue.viewers.image.qt import ImageViewer from ..data_viewer import ProfileViewer + class TestProfileTools(object): def setup_method(self, method): @@ -84,7 +85,7 @@ def test_collapse(self): self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) self.profile_tools.ui.button_collapse.click() assert isinstance(image_viewer.state.slices[0], AggregateSlice) - assert image_viewer.state.slices[0].slice.start == 0 - assert image_viewer.state.slices[0].slice.stop == 14 + assert image_viewer.state.slices[0].slice.start == 1 + assert image_viewer.state.slices[0].slice.stop == 15 assert image_viewer.state.slices[0].center == 0 assert image_viewer.state.slices[0].function is np.nanmean diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index ff7fe277f..43223f091 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -94,9 +94,12 @@ def _layers_changed(self, *args): @defer_draw def _reference_data_changed(self, *args): - self.x_att_helper.set_multiple_data([self.reference_data]) - self.y_att_helper.set_multiple_data([self.reference_data]) - + if self.reference_data is None: + self.x_att_helper.set_multiple_data([]) + self.y_att_helper.set_multiple_data([]) + else: + self.x_att_helper.set_multiple_data([self.reference_data]) + self.y_att_helper.set_multiple_data([self.reference_data]) class ProfileLayerState(MatplotlibLayerState): """ diff --git a/glue/viewers/profile/tests/test_state.py b/glue/viewers/profile/tests/test_state.py index 6a4baa7bb..49137a995 100644 --- a/glue/viewers/profile/tests/test_state.py +++ b/glue/viewers/profile/tests/test_state.py @@ -69,7 +69,7 @@ def test_subset(self): x, y = self.layer_state.get_profile() assert_allclose(x, [0, 1, 2]) - assert_allclose(y, [0., 13., 19.5]) + assert_allclose(y, [np.nan, 13., 19.5]) subset.subset_state = self.data.id['x'] > 100 @@ -95,13 +95,13 @@ def test_clone(self): def test_limits(self): - assert self.viewer_state.x_min == 0 - assert self.viewer_state.x_max == 2 + assert self.viewer_state.x_min == -0.5 + assert self.viewer_state.x_max == 2.5 self.viewer_state.flip_x() - assert self.viewer_state.x_min == 2 - assert self.viewer_state.x_max == 0 + assert self.viewer_state.x_min == 2.5 + assert self.viewer_state.x_max == -0.5 self.viewer_state.x_min = 1 self.viewer_state.x_max = 1.5 @@ -111,5 +111,5 @@ def test_limits(self): self.viewer_state.reset_limits() - assert self.viewer_state.x_min == 0 - assert self.viewer_state.x_max == 2 + assert self.viewer_state.x_min == -0.5 + assert self.viewer_state.x_max == 2.5 From e4d5d292287cd084888ef8436c76f831aa5244db Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 21:48:38 +0100 Subject: [PATCH 33/42] Started adding tests and fixing issues for world coordinates --- glue/viewers/profile/qt/profile_tools.py | 22 +++++++++++++++---- .../profile/qt/tests/test_profile_tools.py | 17 +++++++++++++- glue/viewers/profile/tests/test_state.py | 20 +++++++++++++++-- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 7405cc159..5ddae8cee 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -20,6 +20,7 @@ from glue.core.aggregate import mom1, mom2 from glue.core import Data from glue.viewers.image.qt import ImageViewer +from glue.core.link_manager import is_convertible_to_single_pixel_cid __all__ = ['ProfileTools'] @@ -141,16 +142,29 @@ def _on_nav_activate(self, *args): self._nav_data = self._visible_data() self._nav_viewers = {} for data in self._nav_data: - self._nav_viewers[data] = self._viewers_with_data_slice(data, self.viewer.state.x_att) + pix_cid = is_convertible_to_single_pixel_cid(data, self.viewer.state.x_att) + self._nav_viewers[data] = self._viewers_with_data_slice(data, pix_cid) def _on_slider_change(self, *args): - print("ON SLIDER CHANGE") + x = self.nav_mode.state.x - print("X",x) + for data in self._nav_data: + + if self.viewer.state.x_att in data.pixel_component_ids: + axis = self.viewer.state.x_att.axis + slc = int(round(x)) + else: + pix_cid = is_convertible_to_single_pixel_cid(data, self.viewer.state.x_att) + axis = pix_cid.axis + axis_view = [0] * data.ndim + axis_view[pix_cid.axis] = slice(None) + axis_values = data[self.viewer.state.x_att, axis_view] + slc = int(np.argmin(np.abs(axis_values - x))) + for viewer in self._nav_viewers[data]: slices = list(viewer.state.slices) - slices[self.viewer.state.x_att.axis] = int(round(x)) + slices[axis] = slc viewer.state.slices = tuple(slices) def _on_settings(self): diff --git a/glue/viewers/profile/qt/tests/test_profile_tools.py b/glue/viewers/profile/qt/tests/test_profile_tools.py index 9ab1365d8..68970edb3 100644 --- a/glue/viewers/profile/qt/tests/test_profile_tools.py +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -22,6 +22,7 @@ from glue.viewers.image.state import AggregateSlice from glue.viewers.image.qt import ImageViewer +from glue.viewers.profile.tests.test_state import SimpleCoordinates from ..data_viewer import ProfileViewer @@ -29,7 +30,9 @@ class TestProfileTools(object): def setup_method(self, method): - self.data = Data(label='d1', x=np.arange(240).reshape((30, 4, 2))) + self.data = Data(label='d1') + self.data.coords = SimpleCoordinates() + self.data['x'] = np.arange(240).reshape((30, 4, 2)) self.app = GlueApplication() self.session = self.app.session @@ -48,14 +51,26 @@ def teardown_method(self, method): self.viewer.close() def test_navigate_sync_image(self): + self.viewer.add_data(self.data) image_viewer = self.app.new_data_viewer(ImageViewer) image_viewer.add_data(self.data) assert image_viewer.state.slices == (0, 0, 0) + + self.viewer.state.x_att = self.data.pixel_component_ids[0] + x, y = self.viewer.axes.transData.transform([[1, 4]])[0] self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + self.viewer.axes.figure.canvas.button_release_event(x, y, 1) assert image_viewer.state.slices == (1, 0, 0) + self.viewer.state.x_att = self.data.world_component_ids[0] + + x, y = self.viewer.axes.transData.transform([[10, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + self.viewer.axes.figure.canvas.button_release_event(x, y, 1) + assert image_viewer.state.slices == (5, 0, 0) + def test_fit_polynomial(self): # TODO: need to deterministically set to polynomial fitter self.viewer.add_data(self.data) diff --git a/glue/viewers/profile/tests/test_state.py b/glue/viewers/profile/tests/test_state.py index 49137a995..351b168ac 100644 --- a/glue/viewers/profile/tests/test_state.py +++ b/glue/viewers/profile/tests/test_state.py @@ -1,16 +1,26 @@ import numpy as np from numpy.testing import assert_allclose -from glue.core import Data +from glue.core import Data, Coordinates from glue.core.tests.test_state import clone from ..state import ProfileViewerState, ProfileLayerState +class SimpleCoordinates(Coordinates): + + def world2pixel(self, *world): + return tuple([0.5 * w for w in world]) + + def pixel2world(self, *pixel): + return tuple([2 * p for p in pixel]) + class TestProfileViewerState: def setup_method(self, method): - self.data = Data(label='d1', x=np.arange(24).reshape((3, 4, 2))) + self.data = Data(label='d1') + self.data.coords = SimpleCoordinates() + self.data['x'] = np.arange(24).reshape((3, 4, 2)) self.viewer_state = ProfileViewerState() self.layer_state = ProfileLayerState(viewer_state=self.viewer_state, layer=self.data) @@ -21,6 +31,12 @@ def test_basic(self): assert_allclose(x, [0, 1, 2]) assert_allclose(y, [3.5, 11.5, 19.5]) + def test_basic_world(self): + self.viewer_state.x_att = self.data.world_component_ids[0] + x, y = self.layer_state.get_profile() + assert_allclose(x, [0, 2, 4]) + assert_allclose(y, [3.5, 11.5, 19.5]) + def test_x_att(self): self.viewer_state.x_att = self.data.pixel_component_ids[0] From cd415abd9ab29038e7c85b6788f803bcc0c2ded9 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 22:12:01 +0100 Subject: [PATCH 34/42] Finish fixing profile tools when used with world axes --- glue/viewers/profile/mouse_mode.py | 46 +++++++--- glue/viewers/profile/qt/profile_tools.py | 53 ++++++++---- .../profile/qt/tests/test_profile_tools.py | 85 +++++++++++++++---- 3 files changed, 137 insertions(+), 47 deletions(-) diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/mouse_mode.py index 2f2d98c73..311d836dc 100644 --- a/glue/viewers/profile/mouse_mode.py +++ b/glue/viewers/profile/mouse_mode.py @@ -42,9 +42,13 @@ def release(self, event): def _update_artist(self, *args): if hasattr(self, '_line'): - self._line.set_data([self.state.x, self.state.x], [0, 1]) + if self.state.x is None: + self._line.set_visible(False) + else: + self._line.set_data([self.state.x, self.state.x], [0, 1]) else: - self._line = self._axes.axvline(self.state.x, color=COLOR) + if self.state.x is not None: + self._line = self._axes.axvline(self.state.x, color=COLOR) self._canvas.draw() def deactivate(self): @@ -61,6 +65,9 @@ def activate(self): super(NavigateMouseMode, self).activate() self.active = True + def clear(self): + self.state.x = None + class RangeModeState(State): @@ -140,19 +147,25 @@ def release(self, event): def _update_artist(self, *args): y_min, y_max = self._axes.get_ylim() if hasattr(self, '_lines'): - self._lines[0].set_data([self.state.x_min, self.state.x_min], [0, 1]) - self._lines[1].set_data([self.state.x_max, self.state.x_max], [0, 1]) - self._interval.set_xy([[self.state.x_min, 0], - [self.state.x_min, 1], - [self.state.x_max, 1], - [self.state.x_max, 0], - [self.state.x_min, 0]]) + if self.state.x_min is None or self.state.x_max is None: + self._lines[0].set_visible(False) + self._lines[1].set_visible(False) + self._interval.set_visible(False) + else: + self._lines[0].set_data([self.state.x_min, self.state.x_min], [0, 1]) + self._lines[1].set_data([self.state.x_max, self.state.x_max], [0, 1]) + self._interval.set_xy([[self.state.x_min, 0], + [self.state.x_min, 1], + [self.state.x_max, 1], + [self.state.x_max, 0], + [self.state.x_min, 0]]) else: - self._lines = (self._axes.axvline(self.state.x_min, color=COLOR), - self._axes.axvline(self.state.x_max, color=COLOR)) - self._interval = self._axes.axvspan(self.state.x_min, - self.state.x_max, - color=COLOR, alpha=0.05) + if self.state.x_min is not None and self.state.x_max is not None: + self._lines = (self._axes.axvline(self.state.x_min, color=COLOR), + self._axes.axvline(self.state.x_max, color=COLOR)) + self._interval = self._axes.axvspan(self.state.x_min, + self.state.x_max, + color=COLOR, alpha=0.05) self._canvas.draw() def deactivate(self): @@ -173,3 +186,8 @@ def activate(self): self._canvas.draw() super(RangeMouseMode, self).activate() self.active = True + + def clear(self): + with delay_callback(self.state, 'x_min', 'x_max'): + self.state.x_min = None + self.state.x_max = None diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 5ddae8cee..8cc7b4805 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -124,6 +124,12 @@ def enable(self): self.viewer.toolbar_added.connect(self._on_toolbar_added) + self.viewer.state.add_callback('x_att', self._on_x_att_change) + + def _on_x_att_change(self, *event): + self.nav_mode.clear() + self.rng_mode.clear() + @property def fitter(self): try: @@ -149,24 +155,33 @@ def _on_slider_change(self, *args): x = self.nav_mode.state.x + if x is None: + return + for data in self._nav_data: - if self.viewer.state.x_att in data.pixel_component_ids: - axis = self.viewer.state.x_att.axis - slc = int(round(x)) - else: - pix_cid = is_convertible_to_single_pixel_cid(data, self.viewer.state.x_att) - axis = pix_cid.axis - axis_view = [0] * data.ndim - axis_view[pix_cid.axis] = slice(None) - axis_values = data[self.viewer.state.x_att, axis_view] - slc = int(np.argmin(np.abs(axis_values - x))) + axis, slc = self._get_axis_and_pixel_slice(data, x) for viewer in self._nav_viewers[data]: slices = list(viewer.state.slices) slices[axis] = slc viewer.state.slices = tuple(slices) + def _get_axis_and_pixel_slice(self, data, x): + + if self.viewer.state.x_att in data.pixel_component_ids: + axis = self.viewer.state.x_att.axis + slc = int(round(x)) + else: + pix_cid = is_convertible_to_single_pixel_cid(data, self.viewer.state.x_att) + axis = pix_cid.axis + axis_view = [0] * data.ndim + axis_view[pix_cid.axis] = slice(None) + axis_values = data[self.viewer.state.x_att, axis_view] + slc = int(np.argmin(np.abs(axis_values - x))) + + return axis, slc + def _on_settings(self): d = FitSettingsWidget(self.fitter) d.exec_() @@ -293,21 +308,27 @@ def _on_collapse(self): func = self.collapse_function x_range = self.rng_mode.state.x_range - imin, imax = int(min(x_range)), int(max(x_range)) for data in self._visible_data(): - for viewer in self._viewers_with_data_slice(data, self.viewer.state.x_att): + + pix_cid = is_convertible_to_single_pixel_cid(data, self.viewer.state.x_att) + + for viewer in self._viewers_with_data_slice(data, pix_cid): slices = list(viewer.state.slices) - current_slice = slices[self.viewer.state.x_att.axis] + # TODO: don't need to fetch axis twice + axis, imin = self._get_axis_and_pixel_slice(data, x_range[0]) + axis, imax = self._get_axis_and_pixel_slice(data, x_range[1]) + + current_slice = slices[axis] if isinstance(current_slice, AggregateSlice): current_slice = current_slice.center - slices[self.viewer.state.x_att.axis] = AggregateSlice(slice(imin, imax), - current_slice, - func) + slices[axis] = AggregateSlice(slice(imin, imax), + current_slice, + func) viewer.state.slices = tuple(slices) diff --git a/glue/viewers/profile/qt/tests/test_profile_tools.py b/glue/viewers/profile/qt/tests/test_profile_tools.py index 68970edb3..fb2b1a3f3 100644 --- a/glue/viewers/profile/qt/tests/test_profile_tools.py +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -1,24 +1,12 @@ from __future__ import absolute_import, division, print_function -import os -from collections import Counter - -import pytest import numpy as np -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_allclose -from glue.core.message import SubsetUpdateMessage -from glue.core import HubListener, Data -from glue.core.roi import XRangeROI -from glue.core.subset import RangeSubsetState, CategoricalROISubsetState -from glue import core +from glue.core import Data from glue.app.qt import GlueApplication -from glue.core.component_id import ComponentID -from glue.utils.qt import combo_as_string, get_qapp -from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer -from glue.core.state import GlueUnSerializer -from glue.app.qt.layer_tree_widget import LayerTreeWidget +from glue.utils.qt import get_qapp from glue.viewers.image.state import AggregateSlice from glue.viewers.image.qt import ImageViewer @@ -72,33 +60,96 @@ def test_navigate_sync_image(self): assert image_viewer.state.slices == (5, 0, 0) def test_fit_polynomial(self): + # TODO: need to deterministically set to polynomial fitter + self.viewer.add_data(self.data) self.profile_tools.ui.tabs.setCurrentIndex(1) + + # First try in pixel coordinates + + self.viewer.state.x_att = self.data.pixel_component_ids[0] + x, y = self.viewer.axes.transData.transform([[1, 4]])[0] self.viewer.axes.figure.canvas.button_press_event(x, y, 1) x, y = self.viewer.axes.transData.transform([[15, 4]])[0] self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) + assert_allclose(self.profile_tools.rng_mode.state.x_range, (1, 15)) + self.profile_tools.ui.button_fit.click() self.profile_tools.wait_for_fit() app = get_qapp() app.processEvents() - assert self.profile_tools.text_log.toPlainText().startswith('d1\nCoefficients') + + pixel_log = self.profile_tools.text_log.toPlainText().splitlines() + assert pixel_log[0] == 'd1' + assert pixel_log[1] == 'Coefficients:' + assert pixel_log[-2] == '8.000000e+00' + assert pixel_log[-1] == '3.500000e+00' + self.profile_tools.ui.button_clear.click() assert self.profile_tools.text_log.toPlainText() == '' + # Next, try in world coordinates + + self.viewer.state.x_att = self.data.world_component_ids[0] + + x, y = self.viewer.axes.transData.transform([[2, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + x, y = self.viewer.axes.transData.transform([[30, 4]])[0] + self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) + + assert_allclose(self.profile_tools.rng_mode.state.x_range, (2, 30)) + + self.profile_tools.ui.button_fit.click() + self.profile_tools.wait_for_fit() + app = get_qapp() + app.processEvents() + + world_log = self.profile_tools.text_log.toPlainText().splitlines() + assert world_log[0] == 'd1' + assert world_log[1] == 'Coefficients:' + assert world_log[-2] == '4.000000e+00' + assert world_log[-1] == '3.500000e+00' + def test_collapse(self): - # TODO: need to deterministically set to polynomial fitter + self.viewer.add_data(self.data) + image_viewer = self.app.new_data_viewer(ImageViewer) image_viewer.add_data(self.data) + self.profile_tools.ui.tabs.setCurrentIndex(2) + + # First try in pixel coordinates + + self.viewer.state.x_att = self.data.pixel_component_ids[0] + x, y = self.viewer.axes.transData.transform([[1, 4]])[0] self.viewer.axes.figure.canvas.button_press_event(x, y, 1) x, y = self.viewer.axes.transData.transform([[15, 4]])[0] self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) + + self.profile_tools.ui.button_collapse.click() + + assert isinstance(image_viewer.state.slices[0], AggregateSlice) + assert image_viewer.state.slices[0].slice.start == 1 + assert image_viewer.state.slices[0].slice.stop == 15 + assert image_viewer.state.slices[0].center == 0 + assert image_viewer.state.slices[0].function is np.nanmean + + # Next, try in world coordinates + + self.viewer.state.x_att = self.data.world_component_ids[0] + + x, y = self.viewer.axes.transData.transform([[2, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + x, y = self.viewer.axes.transData.transform([[30, 4]])[0] + self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) + self.profile_tools.ui.button_collapse.click() + assert isinstance(image_viewer.state.slices[0], AggregateSlice) assert image_viewer.state.slices[0].slice.start == 1 assert image_viewer.state.slices[0].slice.stop == 15 From e0e53f0cffcb60ae69b15732ad81baad445ef0c7 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 23:42:30 +0100 Subject: [PATCH 35/42] Added test of selection and enabled layers --- .../profile/qt/tests/test_data_viewer.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/glue/viewers/profile/qt/tests/test_data_viewer.py b/glue/viewers/profile/qt/tests/test_data_viewer.py index 2a799a376..49da540a4 100644 --- a/glue/viewers/profile/qt/tests/test_data_viewer.py +++ b/glue/viewers/profile/qt/tests/test_data_viewer.py @@ -21,6 +21,7 @@ from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer from glue.core.state import GlueUnSerializer from glue.app.qt.layer_tree_widget import LayerTreeWidget +from glue.viewers.profile.tests.test_state import SimpleCoordinates from ..data_viewer import ProfileViewer @@ -38,7 +39,9 @@ class TestProfileViewer(object): def setup_method(self, method): - self.data = Data(label='d1', x=np.arange(24).reshape((3, 4, 2))) + self.data = Data(label='d1') + self.data.coords = SimpleCoordinates() + self.data['x'] = np.arange(24).reshape((3, 4, 2)) self.app = GlueApplication() self.session = self.app.session @@ -68,3 +71,46 @@ def test_incompatible(self): assert len(self.viewer.layers) == 2 assert self.viewer.layers[0].enabled assert not self.viewer.layers[1].enabled + + def test_selection(self): + + self.viewer.add_data(self.data) + + self.viewer.state.x_att = self.data.pixel_component_ids[0] + + roi = XRangeROI(0.9, 2.1) + + self.viewer.apply_roi(roi) + + assert len(self.data.subsets) == 1 + assert_equal(self.data.subsets[0].to_mask()[:, 0, 0], [0, 1, 1]) + + self.viewer.state.x_att = self.data.world_component_ids[0] + + roi = XRangeROI(1.9, 3.1) + + self.viewer.apply_roi(roi) + + assert len(self.data.subsets) == 1 + assert_equal(self.data.subsets[0].to_mask()[:, 0, 0], [0, 1, 0]) + + def test_enabled_layers(self): + + data2 = Data(label='d1', y=np.arange(24).reshape((3, 4, 2))) + self.data_collection.append(data2) + + self.viewer.add_data(self.data) + self.viewer.add_data(data2) + + assert self.viewer.layers[0].enabled + assert not self.viewer.layers[0].enabled + + self.data_collection.add_link(ComponentLink([self.data.world_component_ids[0]], data2.world_component_ids[1], using=lambda x: 2 * x)) + + assert self.viewer.layers[0].enabled + assert not self.viewer.layers[0].enabled + + self.data_collection.add_link(ComponentLink([self.data.id['x']], data2.id['y'], using=lambda x: 3 * x)) + + assert self.viewer.layers[0].enabled + assert self.viewer.layers[0].enabled From 1608b3c40efca20e4583f210fc7eeb923e20a07d Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 23:48:02 +0100 Subject: [PATCH 36/42] Avoid rounding issues --- .../profile/qt/tests/test_profile_tools.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/glue/viewers/profile/qt/tests/test_profile_tools.py b/glue/viewers/profile/qt/tests/test_profile_tools.py index fb2b1a3f3..56b9a2841 100644 --- a/glue/viewers/profile/qt/tests/test_profile_tools.py +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -70,12 +70,12 @@ def test_fit_polynomial(self): self.viewer.state.x_att = self.data.pixel_component_ids[0] - x, y = self.viewer.axes.transData.transform([[1, 4]])[0] + x, y = self.viewer.axes.transData.transform([[0.9, 4]])[0] self.viewer.axes.figure.canvas.button_press_event(x, y, 1) - x, y = self.viewer.axes.transData.transform([[15, 4]])[0] + x, y = self.viewer.axes.transData.transform([[15.1, 4]])[0] self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) - assert_allclose(self.profile_tools.rng_mode.state.x_range, (1, 15)) + assert_allclose(self.profile_tools.rng_mode.state.x_range, (0.9, 15.1)) self.profile_tools.ui.button_fit.click() self.profile_tools.wait_for_fit() @@ -95,12 +95,12 @@ def test_fit_polynomial(self): self.viewer.state.x_att = self.data.world_component_ids[0] - x, y = self.viewer.axes.transData.transform([[2, 4]])[0] + x, y = self.viewer.axes.transData.transform([[1.9, 4]])[0] self.viewer.axes.figure.canvas.button_press_event(x, y, 1) - x, y = self.viewer.axes.transData.transform([[30, 4]])[0] + x, y = self.viewer.axes.transData.transform([[30.1, 4]])[0] self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) - assert_allclose(self.profile_tools.rng_mode.state.x_range, (2, 30)) + assert_allclose(self.profile_tools.rng_mode.state.x_range, (1.9, 30.1)) self.profile_tools.ui.button_fit.click() self.profile_tools.wait_for_fit() @@ -126,9 +126,9 @@ def test_collapse(self): self.viewer.state.x_att = self.data.pixel_component_ids[0] - x, y = self.viewer.axes.transData.transform([[1, 4]])[0] + x, y = self.viewer.axes.transData.transform([[0.9, 4]])[0] self.viewer.axes.figure.canvas.button_press_event(x, y, 1) - x, y = self.viewer.axes.transData.transform([[15, 4]])[0] + x, y = self.viewer.axes.transData.transform([[15.1, 4]])[0] self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) self.profile_tools.ui.button_collapse.click() @@ -143,9 +143,9 @@ def test_collapse(self): self.viewer.state.x_att = self.data.world_component_ids[0] - x, y = self.viewer.axes.transData.transform([[2, 4]])[0] + x, y = self.viewer.axes.transData.transform([[1.9, 4]])[0] self.viewer.axes.figure.canvas.button_press_event(x, y, 1) - x, y = self.viewer.axes.transData.transform([[30, 4]])[0] + x, y = self.viewer.axes.transData.transform([[30.1, 4]])[0] self.viewer.axes.figure.canvas.motion_notify_event(x, y, 1) self.profile_tools.ui.button_collapse.click() From a6830b07db391a4b1540cad8fafcc907cc6c8730 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 2 Apr 2018 23:51:14 +0100 Subject: [PATCH 37/42] Fixed test and removed unused code --- glue/core/qt/fitters.py | 2 -- .../profile/qt/tests/test_data_viewer.py | 22 ++++++------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/glue/core/qt/fitters.py b/glue/core/qt/fitters.py index 369214a79..225fef56c 100644 --- a/glue/core/qt/fitters.py +++ b/glue/core/qt/fitters.py @@ -1,5 +1,3 @@ -# TODO: move to glue.core.qt - from qtpy import QtWidgets, QtGui from glue.core.qt.simpleforms import build_form_item diff --git a/glue/viewers/profile/qt/tests/test_data_viewer.py b/glue/viewers/profile/qt/tests/test_data_viewer.py index 49da540a4..fa0d78059 100644 --- a/glue/viewers/profile/qt/tests/test_data_viewer.py +++ b/glue/viewers/profile/qt/tests/test_data_viewer.py @@ -3,24 +3,16 @@ from __future__ import absolute_import, division, print_function import os -from collections import Counter -import pytest import numpy as np from numpy.testing import assert_equal, assert_allclose -from glue.core.message import SubsetUpdateMessage -from glue.core import HubListener, Data +from glue.core import Data from glue.core.roi import XRangeROI -from glue.core.subset import RangeSubsetState, CategoricalROISubsetState -from glue import core from glue.app.qt import GlueApplication -from glue.core.component_id import ComponentID -from glue.utils.qt import combo_as_string +from glue.core.component_link import ComponentLink from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer -from glue.core.state import GlueUnSerializer -from glue.app.qt.layer_tree_widget import LayerTreeWidget from glue.viewers.profile.tests.test_state import SimpleCoordinates from ..data_viewer import ProfileViewer @@ -103,14 +95,14 @@ def test_enabled_layers(self): self.viewer.add_data(data2) assert self.viewer.layers[0].enabled - assert not self.viewer.layers[0].enabled + assert not self.viewer.layers[1].enabled - self.data_collection.add_link(ComponentLink([self.data.world_component_ids[0]], data2.world_component_ids[1], using=lambda x: 2 * x)) + self.data_collection.add_link(ComponentLink([data2.world_component_ids[1]], self.data.world_component_ids[0], using=lambda x: 2 * x)) assert self.viewer.layers[0].enabled - assert not self.viewer.layers[0].enabled + assert not self.viewer.layers[1].enabled - self.data_collection.add_link(ComponentLink([self.data.id['x']], data2.id['y'], using=lambda x: 3 * x)) + self.data_collection.add_link(ComponentLink([data2.id['y']], self.data.id['x'], using=lambda x: 3 * x)) assert self.viewer.layers[0].enabled - assert self.viewer.layers[0].enabled + assert self.viewer.layers[1].enabled From 39f990ecfc73740659916e599a8b33386531944c Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 3 Apr 2018 00:25:02 +0100 Subject: [PATCH 38/42] Make sure data components are floating-point in tests --- glue/viewers/profile/qt/tests/test_profile_tools.py | 2 +- glue/viewers/profile/tests/test_state.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/glue/viewers/profile/qt/tests/test_profile_tools.py b/glue/viewers/profile/qt/tests/test_profile_tools.py index 56b9a2841..ccd34de2c 100644 --- a/glue/viewers/profile/qt/tests/test_profile_tools.py +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -20,7 +20,7 @@ def setup_method(self, method): self.data = Data(label='d1') self.data.coords = SimpleCoordinates() - self.data['x'] = np.arange(240).reshape((30, 4, 2)) + self.data['x'] = np.arange(240).reshape((30, 4, 2)).astype(float) self.app = GlueApplication() self.session = self.app.session diff --git a/glue/viewers/profile/tests/test_state.py b/glue/viewers/profile/tests/test_state.py index 351b168ac..47600852a 100644 --- a/glue/viewers/profile/tests/test_state.py +++ b/glue/viewers/profile/tests/test_state.py @@ -20,7 +20,7 @@ class TestProfileViewerState: def setup_method(self, method): self.data = Data(label='d1') self.data.coords = SimpleCoordinates() - self.data['x'] = np.arange(24).reshape((3, 4, 2)) + self.data['x'] = np.arange(24).reshape((3, 4, 2)).astype(float) self.viewer_state = ProfileViewerState() self.layer_state = ProfileLayerState(viewer_state=self.viewer_state, layer=self.data) From 88d78770bf4b9ea999d054c86b0dec4f793fc419 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 3 Apr 2018 10:31:59 +0100 Subject: [PATCH 39/42] Move mouse_mode code inside qt subfolder --- glue/viewers/profile/{ => qt}/mouse_mode.py | 0 glue/viewers/profile/qt/profile_tools.py | 2 +- glue/viewers/profile/{ => qt}/tests/test_mouse_mode.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename glue/viewers/profile/{ => qt}/mouse_mode.py (100%) rename glue/viewers/profile/{ => qt}/tests/test_mouse_mode.py (100%) diff --git a/glue/viewers/profile/mouse_mode.py b/glue/viewers/profile/qt/mouse_mode.py similarity index 100% rename from glue/viewers/profile/mouse_mode.py rename to glue/viewers/profile/qt/mouse_mode.py diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index 8cc7b4805..e33c0ddfd 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -12,7 +12,7 @@ from glue.config import fit_plugin, viewer_tool from glue.utils.qt import load_ui, fix_tab_widget_fontsize -from glue.viewers.profile.mouse_mode import NavigateMouseMode, RangeMouseMode +from glue.viewers.profile.qt.mouse_mode import NavigateMouseMode, RangeMouseMode from glue.core.qt.fitters import FitSettingsWidget from glue.utils.qt import Worker from glue.viewers.common.qt.tool import Tool diff --git a/glue/viewers/profile/tests/test_mouse_mode.py b/glue/viewers/profile/qt/tests/test_mouse_mode.py similarity index 100% rename from glue/viewers/profile/tests/test_mouse_mode.py rename to glue/viewers/profile/qt/tests/test_mouse_mode.py From 450e5cf53f0ec9681b52e3f38a0f19eb1b263edd Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 3 Apr 2018 10:36:24 +0100 Subject: [PATCH 40/42] Fix compatibility with Matplotlib 1.5 --- glue/utils/matplotlib.py | 13 ++++++++++++- glue/utils/tests/test_matplotlib.py | 6 +++++- glue/viewers/profile/qt/profile_tools.py | 7 +++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/glue/utils/matplotlib.py b/glue/utils/matplotlib.py index 40f2bf6d4..89bac932f 100644 --- a/glue/utils/matplotlib.py +++ b/glue/utils/matplotlib.py @@ -19,7 +19,7 @@ __all__ = ['renderless_figure', 'all_artists', 'new_artists', 'remove_artists', 'get_extent', 'view_cascade', 'fast_limits', 'defer_draw', 'color2rgb', 'point_contour', 'cache_axes', 'DeferDrawMeta', - 'datetime64_to_mpl', 'mpl_to_datetime64'] + 'datetime64_to_mpl', 'mpl_to_datetime64', 'color2hex'] def renderless_figure(): @@ -192,6 +192,17 @@ def color2rgb(color): return result +def color2hex(color): + try: + from matplotlib.colors import to_hex + result = to_hex(color) + except ImportError: # MPL 1.5 + from matplotlib.colors import ColorConverter + result = ColorConverter().to_hex(color) + return result + + + def point_contour(x, y, data): """Calculate the contour that passes through (x,y) in data diff --git a/glue/utils/tests/test_matplotlib.py b/glue/utils/tests/test_matplotlib.py index 89264fc97..692bc8a83 100644 --- a/glue/utils/tests/test_matplotlib.py +++ b/glue/utils/tests/test_matplotlib.py @@ -16,7 +16,7 @@ from ..matplotlib import (point_contour, fast_limits, all_artists, new_artists, remove_artists, view_cascade, get_extent, color2rgb, defer_draw, freeze_margins, datetime64_to_mpl, - mpl_to_datetime64) + mpl_to_datetime64, color2hex) @requires_scipy @@ -134,6 +134,10 @@ def test_color2rgb(color, rgb): assert_allclose(color2rgb(color), rgb, atol=0.001) +def test_color2hex(): + assert color2hex('red') == '#ff0000' + + def test_freeze_margins(): fig = plt.figure(figsize=(4, 4)) diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py index e33c0ddfd..cbf397cec 100644 --- a/glue/viewers/profile/qt/profile_tools.py +++ b/glue/viewers/profile/qt/profile_tools.py @@ -8,8 +8,7 @@ from qtpy.QtCore import Qt from qtpy import QtWidgets, QtGui -from matplotlib.colors import to_hex - +from glue.utils import color2hex from glue.config import fit_plugin, viewer_tool from glue.utils.qt import load_ui, fix_tab_widget_fontsize from glue.viewers.profile.qt.mouse_mode import NavigateMouseMode, RangeMouseMode @@ -205,8 +204,8 @@ def on_success(result): report = "" for layer_artist in fit_results: report += ("{1}" - "".format(to_hex(layer_artist.state.color), - layer_artist.layer.label)) + "".format(color2hex(layer_artist.state.color), + layer_artist.layer.label)) report += "
" + fitter.summarize(fit_results[layer_artist], x, y) + "
" self._report_fit(report) self._plot_fit(fitter, fit_results, x, y) From 0d9ff66e5cc9929c57170703de517dac410ad885 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 3 Apr 2018 11:08:17 +0100 Subject: [PATCH 41/42] Fix failures on Windows --- glue/viewers/profile/layer_artist.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/glue/viewers/profile/layer_artist.py b/glue/viewers/profile/layer_artist.py index 896ddc96f..aefdbf052 100644 --- a/glue/viewers/profile/layer_artist.py +++ b/glue/viewers/profile/layer_artist.py @@ -44,8 +44,14 @@ def _calculate_profile(self): else: self.enable() - # Update the data values - self.plot_artist.set_data(x, y) + # Update the data values. + if len(x) > 0: + self.plot_artist.set_data(x, y) + self.plot_artist.set_visible(True) + else: + # We need to do this otherwise we get issues on Windows when + # passing an empty list to plot_artist + self.plot_artist.set_visible(False) self._visible_data = x, y if len(x) == 0: From be5f3cbd7b0127917528064ebf4fa0ca3da0a68c Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 3 Apr 2018 12:01:13 +0100 Subject: [PATCH 42/42] Use deterministic order for functions --- glue/viewers/profile/state.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 43223f091..0aa1755b2 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function +from collections import OrderedDict + import numpy as np from glue.core import Data @@ -17,11 +19,11 @@ __all__ = ['ProfileViewerState', 'ProfileLayerState'] -FUNCTIONS = {np.nanmean: 'Mean', - np.nanmedian: 'Median', - np.nanmin: 'Minimum', - np.nanmax: 'Maximum', - np.nansum: 'Sum'} +FUNCTIONS = OrderedDict([(np.nanmean, 'Mean'), + (np.nanmedian, 'Median'), + (np.nanmin, 'Minimum'), + (np.nanmax, 'Maximum'), + (np.nansum, 'Sum')]) class ProfileViewerState(MatplotlibDataViewerState):