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 @@
-
-
"
- "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 @@
+
+
" + 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 @@ + +