diff --git a/CHANGES.md b/CHANGES.md index 123176005..c765e68c8 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] 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/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/core/link_manager.py b/glue/core/link_manager.py index ecdb4eb0b..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'] @@ -306,3 +306,44 @@ 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 isinstance(data, Subset): + data = data.data + 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/qt/fitters.py b/glue/core/qt/fitters.py new file mode 100644 index 000000000..225fef56c --- /dev/null +++ b/glue/core/qt/fitters.py @@ -0,0 +1,158 @@ +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/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'] 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/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 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/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/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/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/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/__init__.py b/glue/viewers/profile/__init__.py new file mode 100644 index 000000000..44388773d --- /dev/null +++ b/glue/viewers/profile/__init__.py @@ -0,0 +1,4 @@ +def setup(): + from glue.config import qt_client + from .qt.data_viewer import ProfileViewer + 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..aefdbf052 --- /dev/null +++ b/glue/viewers/profile/layer_artist.py @@ -0,0 +1,140 @@ +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: + x, y = self.state.get_profile() + except (IncompatibleAttribute, IndexError): + self.disable_invalid_attributes(self._viewer_state.x_att) + return + else: + self.enable() + + # 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: + return + + # 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 = 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: + 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..f1ddeb66a --- /dev/null +++ b/glue/viewers/profile/qt/data_viewer.py @@ -0,0 +1,45 @@ +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 + +from glue.viewers.common.qt import toolbar_mode # noqa +from glue.viewers.profile.qt.profile_tools import ProfileAnalysisTool # noqa + +__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', 'profile-analysis'] + + 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/mouse_mode.py b/glue/viewers/profile/qt/mouse_mode.py new file mode 100644 index 000000000..311d836dc --- /dev/null +++ b/glue/viewers/profile/qt/mouse_mode.py @@ -0,0 +1,193 @@ +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) + + +class NavigateMouseMode(MouseMode): + + 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.active = False + self._press_callback = press_callback + + def press(self, event): + 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.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): + if hasattr(self, '_line'): + 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: + if self.state.x is not None: + 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() + 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 + + def clear(self): + self.state.x = None + + +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.02 + + +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.active = False + + def press(self, event): + + 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) + + 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.active or 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): + if not self.active: + return + self.pressed = False + self.mode = None + self.move_params + + def _update_artist(self, *args): + y_min, y_max = self._axes.get_ylim() + if hasattr(self, '_lines'): + 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: + 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): + if hasattr(self, '_lines'): + self._lines[0].set_visible(False) + self._lines[1].set_visible(False) + self._interval.set_visible(False) + + self._canvas.draw() + super(RangeMouseMode, self).deactivate() + self.active = False + + def activate(self): + if hasattr(self, '_lines'): + self._lines[0].set_visible(True) + self._lines[1].set_visible(True) + self._interval.set_visible(True) + 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/options_widget.py b/glue/viewers/profile/qt/options_widget.py new file mode 100644 index 000000000..bb3a0557f --- /dev/null +++ b/glue/viewers/profile/qt/options_widget.py @@ -0,0 +1,50 @@ +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'] + + +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): + + 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 + + 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/options_widget.ui b/glue/viewers/profile/qt/options_widget.ui new file mode 100644 index 000000000..8858fef4d --- /dev/null +++ b/glue/viewers/profile/qt/options_widget.ui @@ -0,0 +1,350 @@ + + + Widget + + + + 0 + 0 + 269 + 418 + + + + 1D Histogram + + + + 5 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + 0 + + + + General + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + 5 + + + + + + 75 + true + + + + function + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + color: rgb(255, 33, 28) + + + Warning + + + Qt::AlignCenter + + + true + + + + + + + + 75 + true + + + + x axis + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Qt::Horizontal + + + + 40 + 5 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 75 + true + + + + y axis + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + + 75 + true + + + + reference + + + 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/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py new file mode 100644 index 000000000..cbf397cec --- /dev/null +++ b/glue/viewers/profile/qt/profile_tools.py @@ -0,0 +1,353 @@ +import os +import weakref +import traceback +from collections import OrderedDict + +import numpy as np + +from qtpy.QtCore import Qt +from qtpy import QtWidgets, QtGui + +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 +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 +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'] + + +MODES = ['navigate', 'fit', 'collapse'] + +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 +class ProfileAnalysisTool(Tool): + + icon = 'glue_spectrum' + tool_id = 'profile-analysis' + + def __init__(self, viewer): + super(ProfileAnalysisTool, 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) + 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): + + 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 = weakref.ref(parent) + self.image_viewer = None + + @property + 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, + 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) + + 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()) + + 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) + + 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: + 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): + 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() + self._nav_viewers = {} + for data in self._nav_data: + 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): + + x = self.nav_mode.state.x + + if x is None: + return + + for data in self._nav_data: + + 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_() + + def _on_fit(self): + """ + Fit a model to the data + + The fitting happens on a dedicated thread, to keep the UI + 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 + + def on_success(result): + fit_results, x, y = result + report = "" + for layer_artist in fit_results: + report += ("{1}" + "".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) + + 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 wait_for_fit(self): + self._fit_worker.wait() + + 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 + 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[keep], y[keep]) + + return results, x, y + + def _clear_fit(self): + for artist in self._fit_artists[:]: + 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 _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): + + 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 + + for data in self._visible_data(): + + 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) + + # 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[axis] = AggregateSlice(slice(imin, imax), + current_slice, + func) + + viewer.state.slices = tuple(slices) + + @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': + 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..3e3b2a65b --- /dev/null +++ b/glue/viewers/profile/qt/profile_tools.ui @@ -0,0 +1,240 @@ + + + Form + + + + 0 + 0 + 293 + 254 + + + + Form + + + + + + 1 + + + + Navigate + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + <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 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + Fit + + + + + + Click and drag in the profile to define the range over which to fit models to the data. + + + true + + + + + + + + + Function: + + + + + + + + + + + + + + Settings + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Fit + + + + + + + Clear + + + + + + + + + + + + + Collapse + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Click and drag in the profile to define the range over which to fit models to the data. + + + true + + + + + + + + + Function: + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Collapse + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + diff --git a/glue/plugins/tools/spectrum_tool/qt/tests/__init__.py b/glue/viewers/profile/qt/tests/__init__.py similarity index 100% rename from glue/plugins/tools/spectrum_tool/qt/tests/__init__.py rename to glue/viewers/profile/qt/tests/__init__.py 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..fa0d78059 --- /dev/null +++ b/glue/viewers/profile/qt/tests/test_data_viewer.py @@ -0,0 +1,108 @@ +# pylint: disable=I0011,W0613,W0201,W0212,E1101,E1103 + +from __future__ import absolute_import, division, print_function + +import os + +import numpy as np + +from numpy.testing import assert_equal, assert_allclose + +from glue.core import Data +from glue.core.roi import XRangeROI +from glue.app.qt import GlueApplication +from glue.core.component_link import ComponentLink +from glue.viewers.matplotlib.qt.tests.test_data_viewer import BaseTestMatplotlibDataViewer +from glue.viewers.profile.tests.test_state import SimpleCoordinates + +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') + self.data.coords = SimpleCoordinates() + self.data['x'] = np.arange(24).reshape((3, 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) + + 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]) + + 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 + + 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[1].enabled + + 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[1].enabled + + 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[1].enabled diff --git a/glue/viewers/profile/qt/tests/test_mouse_mode.py b/glue/viewers/profile/qt/tests/test_mouse_mode.py new file mode 100644 index 000000000..63c072669 --- /dev/null +++ b/glue/viewers/profile/qt/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 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..ccd34de2c --- /dev/null +++ b/glue/viewers/profile/qt/tests/test_profile_tools.py @@ -0,0 +1,157 @@ +from __future__ import absolute_import, division, print_function + +import numpy as np + +from numpy.testing import assert_allclose + +from glue.core import Data +from glue.app.qt import GlueApplication +from glue.utils.qt import get_qapp +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 + + +class TestProfileTools(object): + + def setup_method(self, method): + + self.data = Data(label='d1') + self.data.coords = SimpleCoordinates() + self.data['x'] = np.arange(240).reshape((30, 4, 2)).astype(float) + + 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) + + 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) + 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([[0.9, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + 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, (0.9, 15.1)) + + self.profile_tools.ui.button_fit.click() + self.profile_tools.wait_for_fit() + app = get_qapp() + app.processEvents() + + 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([[1.9, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + 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, (1.9, 30.1)) + + 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): + + 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([[0.9, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + 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() + + 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([[1.9, 4]])[0] + self.viewer.axes.figure.canvas.button_press_event(x, y, 1) + 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() + + 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 diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py new file mode 100644 index 000000000..0aa1755b2 --- /dev/null +++ b/glue/viewers/profile/state.py @@ -0,0 +1,155 @@ +from __future__ import absolute_import, division, print_function + +from collections import OrderedDict + +import numpy as np + +from glue.core import Data +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 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 + +__all__ = ['ProfileViewerState', 'ProfileLayerState'] + + +FUNCTIONS = OrderedDict([(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. + """ + + 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)') + + 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.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, + world_coord=True, 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 _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 + 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): + self._update_combo_ref_data() + + @defer_draw + def _reference_data_changed(self, *args): + 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): + """ + A state class that includes all the attributes for layers in a Profile plot. + """ + + 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 + 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 = 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 = 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 + profile_values = self.viewer_state.function(data_values, axis=axes) + + # 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 diff --git a/glue/plugins/tools/spectrum_tool/tests/__init__.py b/glue/viewers/profile/tests/__init__.py similarity index 100% rename from glue/plugins/tools/spectrum_tool/tests/__init__.py rename to glue/viewers/profile/tests/__init__.py diff --git a/glue/viewers/profile/tests/test_state.py b/glue/viewers/profile/tests/test_state.py new file mode 100644 index 000000000..47600852a --- /dev/null +++ b/glue/viewers/profile/tests/test_state.py @@ -0,0 +1,131 @@ +import numpy as np +from numpy.testing import assert_allclose + +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') + self.data.coords = SimpleCoordinates() + 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) + 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_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] + 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, [np.nan, 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.5 + assert self.viewer_state.x_max == 2.5 + + self.viewer_state.flip_x() + + 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 + + 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.5 + assert self.viewer_state.x_max == 2.5 diff --git a/setup.py b/setup.py index 37006fd92..a761e327c 100755 --- a/setup.py +++ b/setup.py @@ -72,13 +72,13 @@ 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 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