diff --git a/CHANGES.md b/CHANGES.md
index 100b7bed0..38b305194 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -10,6 +10,8 @@ v1.3.0 (unreleased)
* Remove bundled version of pvextractor and include it as a proper
dependency. [#2252]
+* Remove ``StandaloneImageViewer`` class. [#1663]
+
v1.2.4 (2022-01-27)
-------------------
diff --git a/glue/app/qt/application.py b/glue/app/qt/application.py
index e7466e165..66a55a160 100644
--- a/glue/app/qt/application.py
+++ b/glue/app/qt/application.py
@@ -487,6 +487,9 @@ def _artihmetic_dialog(self, *event):
dialog = ArithmeticEditorWidget(self.data_collection)
dialog.exec_()
+ def selected_layers(self):
+ return self._layer_widget.selected_layers()
+
def _on_data_collection_change(self, *event):
self._button_save_data.setEnabled(len(self.data_collection) > 0)
self._button_link_data.setEnabled(len(self.data_collection) > 1)
diff --git a/glue/core/data.py b/glue/core/data.py
index 92e7bdd4e..48b2b780b 100644
--- a/glue/core/data.py
+++ b/glue/core/data.py
@@ -528,7 +528,7 @@ def compute_histogram(self, cids, weights=None, range=None, bins=None, log=None,
raise NotImplementedError()
def compute_fixed_resolution_buffer(self, bounds, target_data=None, target_cid=None,
- subset_state=None, broadcast=True):
+ subset_state=None, broadcast=True, cache_id=None):
"""
Get a fixed-resolution buffer.
diff --git a/glue/icons/convert.sh b/glue/icons/convert.sh
index 3aa416053..e8b3f5797 100755
--- a/glue/icons/convert.sh
+++ b/glue/icons/convert.sh
@@ -3,7 +3,7 @@
for input in *.svg
do
output=${input/svg/png}
- inkscape --without-gui --export-png=$PWD/$output --export-width=125 --export-height=125 $PWD/$input
+ inkscape --without-gui --export-file=$PWD/$output --export-type=png --export-width=125 --export-height=125 $PWD/$input
done
convert -flop playback_forw.png playback_back.png
diff --git a/glue/icons/glue_path.png b/glue/icons/glue_path.png
new file mode 100644
index 000000000..af18638f0
Binary files /dev/null and b/glue/icons/glue_path.png differ
diff --git a/glue/icons/glue_path.svg b/glue/icons/glue_path.svg
new file mode 100644
index 000000000..c96ee0e26
--- /dev/null
+++ b/glue/icons/glue_path.svg
@@ -0,0 +1,101 @@
+
+
diff --git a/glue/icons/glue_slice.png b/glue/icons/glue_slice.png
index f1714443a..7d047fe51 100644
Binary files a/glue/icons/glue_slice.png and b/glue/icons/glue_slice.png differ
diff --git a/glue/icons/glue_slice.svg b/glue/icons/glue_slice.svg
index 13ad4bf4f..ad5f7c873 100644
--- a/glue/icons/glue_slice.svg
+++ b/glue/icons/glue_slice.svg
@@ -1,18 +1,39 @@
-
-
\ No newline at end of file
+ style="fill:none;stroke:#c1343e;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" />
diff --git a/glue/plugins/tools/pv_slicer/pv_sliced_data.py b/glue/plugins/tools/pv_slicer/pv_sliced_data.py
new file mode 100644
index 000000000..b237ceae5
--- /dev/null
+++ b/glue/plugins/tools/pv_slicer/pv_sliced_data.py
@@ -0,0 +1,213 @@
+import numpy as np
+
+from glue.core.data_derived import DerivedData
+from glue.core.message import NumericalDataChangedMessage
+
+__all__ = ['PVSlicedData']
+
+
+def sample_points(x, y, spacing=1):
+
+ # Code adapted from pvextractor
+
+ # Find the distance interval between all pairs of points
+ dx = np.diff(x)
+ dy = np.diff(y)
+ dd = np.hypot(dx, dy)
+
+ # Find the total displacement along the broken curve
+ d = np.hstack([0., np.cumsum(dd)])
+
+ # Figure out the number of points to sample, and stop short of the
+ # last point.
+ n_points = int(np.floor(d[-1] / spacing))
+
+ if n_points == 0:
+ raise ValueError("Path is shorter than spacing")
+
+ d_sampled = np.linspace(0., n_points * spacing, n_points + 1)
+
+ x_sampled = np.interp(d_sampled, d, x)
+ y_sampled = np.interp(d_sampled, d, y)
+
+ return x_sampled, y_sampled
+
+
+class PVSlicedData(DerivedData):
+ """
+ A dataset where two dimensions have been replaced with one using a path.
+
+ The extra dimension is added as the last dimension
+ """
+
+ def __init__(self, original_data, cid_x, x, cid_y, y, label=''):
+ super(DerivedData, self).__init__()
+ self.original_data = original_data
+ self.cid_x = cid_x
+ self.cid_y = cid_y
+ self.set_xy(x, y)
+ self.sliced_dims = (cid_x.axis, cid_y.axis)
+ self._label = label
+
+ def set_xy(self, x, y):
+ x, y = sample_points(x, y)
+ self.x = x
+ self.y = y
+ if self.original_data.hub:
+ msg = NumericalDataChangedMessage(self)
+ self.original_data.hub.broadcast(msg)
+
+ @property
+ def label(self):
+ return self._label
+
+ def _without_sliced(self, iterable):
+ return [x for ix, x in enumerate(iterable) if ix not in self.sliced_dims]
+
+ @property
+ def shape(self):
+ return tuple(self._without_sliced(self.original_data.shape) + [len(self.x)])
+
+ @property
+ def main_components(self):
+ return self.original_data.main_components
+
+ def get_kind(self, cid):
+ return self.original_data.get_kind(cid)
+
+ def _get_pix_coords(self, view=None):
+
+ pix_coords = []
+
+ advanced_indexing = view is not None and isinstance(view[0], np.ndarray)
+
+ idim_current = -1
+
+ for idim in range(self.original_data.ndim):
+
+ if idim == self.cid_x.axis:
+ pix = self.x
+ idim_current = self.ndim - 1
+ elif idim == self.cid_y.axis:
+ pix = self.y
+ idim_current = self.ndim - 1
+ else:
+ pix = np.arange(self.original_data.shape[idim])
+ idim_current += 1
+
+ if view is not None and len(view) > idim_current:
+ pix = pix[view[idim_current]]
+
+ pix_coords.append(pix)
+
+ if not advanced_indexing:
+ pix_coords = np.meshgrid(*pix_coords, indexing='ij', copy=False)
+
+ shape = pix_coords[0].shape
+
+ keep = np.ones(shape, dtype=bool)
+ for idim in range(self.original_data.ndim):
+ keep &= (pix_coords[idim] >= 0) & (pix_coords[idim] < self.original_data.shape[idim])
+
+ pix_coords = [x[keep].astype(int) for x in pix_coords]
+
+ return pix_coords, keep, shape
+
+ def get_data(self, cid, view=None):
+
+ if cid in self.pixel_component_ids:
+ return super().get_data(cid, view)
+
+ pix_coords, keep, shape = self._get_pix_coords(view=view)
+ result = np.zeros(shape)
+ result[keep] = self.original_data.get_data(cid, view=pix_coords)
+
+ return result
+
+ def get_mask(self, subset_state, view=None):
+
+ if view is None:
+ view = Ellipsis
+
+ pix_coords, keep, shape = self._get_pix_coords(view=view)
+ result = np.zeros(shape)
+ result[keep] = self.original_data.get_mask(subset_state, view=pix_coords)
+
+ return result
+
+ def compute_statistic(self, *args, view=None, **kwargs):
+ pix_coords, _, _ = self._get_pix_coords(view=view)
+ return self.original_data.compute_statistic(*args, view=pix_coords, **kwargs)
+
+ def compute_histogram(self, *args, **kwargs):
+ return self.original_data.compute_histogram(*args, **kwargs)
+
+ def compute_fixed_resolution_buffer(self, bounds, target_data=None, target_cid=None,
+ subset_state=None, broadcast=True, cache_id=None):
+
+ from glue.core.fixed_resolution_buffer import compute_fixed_resolution_buffer
+
+ # First check that the target data is also a PVSlicedData
+ # TODO: also check it's actually for the same path
+
+ if not isinstance(target_data, PVSlicedData):
+ raise TypeError('target_data has to be a PVSlicedData')
+
+ if len(bounds) != len(self.shape):
+ raise ValueError('bounds should have {0} elements'.format(len(self.shape)))
+
+ # Now translate the bounds so that we replace the path with the
+ # pixel coordinates in the target dataset
+
+
+ # The last item of bounds is the pixel offset in the target PV slice
+ path_pixel_offset_target = np.linspace(*bounds[-1])
+
+ # Translate this to a relative offset along the path
+ path_pixel_offset_target_relative = path_pixel_offset_target / self.shape[-1]
+
+ # Find the pixel coordinates in the current dataset
+ x = np.interp(path_pixel_offset_target_relative,
+ np.linspace(0., 1., len(self.x)),
+ self.x)
+ y = np.interp(path_pixel_offset_target_relative,
+ np.linspace(0., 1., len(self.y)),
+ self.y)
+
+ # Create new bouds list
+
+ new_bounds = []
+ idim_current = 0
+
+ slices = []
+
+ for idim in range(self.original_data.ndim):
+
+ if idim == self.cid_x.axis:
+ ixmax = int(np.ceil(np.max(x)))
+ bound = (0, ixmax, ixmax + 1)
+ slices.append(np.round(x).astype(int))
+ elif idim == self.cid_y.axis:
+ iymax = int(np.ceil(np.max(y)))
+ bound = (0, iymax, iymax + 1)
+ slices.append(np.round(y).astype(int))
+ else:
+ bound = bounds[idim_current]
+ idim_current += 1
+ slices.append(slice(None))
+
+ new_bounds.append(bound)
+
+ # TODO: For now we extract a cube and then slice it, but it would be
+ # more efficient if bounds could include 1-d arrays.
+
+ # Now compute the fixed resolution buffer using the original datasets
+ result = compute_fixed_resolution_buffer(self.original_data, new_bounds,
+ target_data=target_data.original_data,
+ target_cid=target_cid,
+ subset_state=subset_state,
+ cache_id=cache_id)
+
+ result = result[tuple(slices)]
+
+ return result
diff --git a/glue/plugins/tools/pv_slicer/qt/__init__.py b/glue/plugins/tools/pv_slicer/qt/__init__.py
index 3ebb8e8d5..c029e0fa9 100644
--- a/glue/plugins/tools/pv_slicer/qt/__init__.py
+++ b/glue/plugins/tools/pv_slicer/qt/__init__.py
@@ -5,3 +5,4 @@ def setup():
from glue.viewers.image.qt import ImageViewer
from glue.plugins.tools.pv_slicer.qt import PVSlicerMode # noqa
ImageViewer.tools.append('slice')
+ ImageViewer.tools.append('pv:crosshair')
diff --git a/glue/plugins/tools/pv_slicer/qt/pv_slicer.py b/glue/plugins/tools/pv_slicer/qt/pv_slicer.py
index 76df4b647..b33c79617 100644
--- a/glue/plugins/tools/pv_slicer/qt/pv_slicer.py
+++ b/glue/plugins/tools/pv_slicer/qt/pv_slicer.py
@@ -1,10 +1,15 @@
import numpy as np
+from matplotlib.lines import Line2D
+
+from glue.core import Data
+from glue.core.coordinates import coordinates_from_wcs
+from glue.core.coordinate_helpers import axis_label
from glue.viewers.matplotlib.toolbar_mode import PathMode
-from glue.viewers.image.qt import StandaloneImageViewer
from glue.config import viewer_tool
-from glue.utils import defer_draw
-from glue.core.coordinate_helpers import axis_label
+from glue.viewers.matplotlib.toolbar_mode import ToolbarModeBase
+from glue.viewers.image.qt import ImageViewer
+from glue.plugins.tools.pv_slicer.pv_sliced_data import PVSlicedData
@viewer_tool
@@ -24,10 +29,12 @@ def __init__(self, viewer, **kwargs):
self._roi_callback = self._extract_callback
self._slice_widget = None
self.viewer.state.add_callback('reference_data', self._on_reference_data_change)
+ self.open_viewer = True
+ 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 _on_reference_data_change(self, *args):
+ if self.viewer.state.reference_data is not None:
+ self.enabled = self.viewer.state.reference_data.ndim == 3
def _clear_path(self):
self.viewer.hide_crosshairs()
@@ -37,215 +44,150 @@ def _extract_callback(self, mode):
"""
Extract a PV-like slice, given a path traced on the widget
"""
+
vx, vy = mode.roi().to_polygon()
- self._build_from_vertices(vx, vy)
-
- def _build_from_vertices(self, vx, vy):
- pv_slice, x, y, wcs = _slice_from_path(vx, vy, self.viewer.state.reference_data,
- self.viewer.state.layers[0].attribute,
- self.viewer.state.wcsaxes_slice[::-1])
- if self._slice_widget is None:
- self._slice_widget = PVSliceWidget(image=pv_slice, wcs=wcs,
- image_viewer=self.viewer,
- x=x, y=y, interpolation='nearest')
- self.viewer._session.application.add_widget(self._slice_widget,
- label='Custom Slice')
- self._slice_widget.window_closed.connect(self._clear_path)
- else:
- self._slice_widget.set_image(image=pv_slice, wcs=wcs,
- x=x, y=y, interpolation='nearest')
- result = self._slice_widget
- result.axes.set_xlabel("Position along path")
- if wcs is None:
- result.axes.set_ylabel("Cube slice index")
+ if self.open_viewer:
+ viewer = self.viewer.session.application.new_data_viewer(ImageViewer)
+ self.open_viewer = False
else:
- result.axes.set_ylabel(_slice_label(self.viewer.state.reference_data,
- self.viewer.state.wcsaxes_slice[::-1]))
+ viewer = None
- result.show()
+ for layer_state in self.viewer.state.layers:
- def close(self):
- if self._slice_widget:
- self._slice_widget.close()
- return super(PVSlicerMode, self).close()
+ data = layer_state.layer
+ if isinstance(data, Data):
-class PVSliceWidget(StandaloneImageViewer):
+ # TODO: need to generalize this for non-xy cuts
- """ A standalone image widget with extra interactivity for PV slices """
+ for pvdata in self.viewer.session.data_collection:
+ if isinstance(pvdata, PVSlicedData):
+ if pvdata.original_data is data:
+ pvdata.original_data = data
+ pvdata.x_att = self.viewer.state.x_att
+ pvdata.y_att = self.viewer.state.y_att
+ pvdata.set_xy(vx, vy)
+ break
+ else:
+ pvdata = PVSlicedData(data,
+ self.viewer.state.x_att, vx,
+ self.viewer.state.y_att, vy,
+ label=data.label + " [slice]")
+ pvdata.parent_viewer = self.viewer
+ self.viewer.session.data_collection.append(pvdata)
- def __init__(self, image=None, wcs=None, image_viewer=None,
- x=None, y=None, **kwargs):
- """
- :param image: 2D Numpy array representing the PV Slice
- :param wcs: WCS for the PV slice
- :param image_viewer: Parent ImageViewer this was extracted from
- :param kwargs: Extra keywords are passed to imshow
- """
- self._crosshairs = None
- self._parent = image_viewer
- super(PVSliceWidget, self).__init__(image=image, wcs=wcs, **kwargs)
- conn = self.axes.figure.canvas.mpl_connect
- self._down_id = conn('button_press_event', self._on_click)
- self._move_id = conn('motion_notify_event', self._on_move)
- self.axes.format_coord = self._format_coord
- self._x = x
- self._y = y
- self._parent.state.add_callback('x_att', self.reset)
- self._parent.state.add_callback('y_att', self.reset)
-
- def _format_coord(self, x, y):
- """
- Return a formatted location label for the taskbar
+ else:
- :param x: x pixel location in slice array
- :param y: y pixel location in slice array
- """
+ # For now don't do anything with the subsets, adding the data
+ # will automatically add all subsets. In future we could try
+ # and only add subsets that were shown in the original viewer.
+ continue
- # xy -> xyz in image view
- pix = self._pos_in_parent(xdata=x, ydata=y)
-
- # xyz -> data pixel coords
- # accounts for fact that image might be shown transposed/rotated
- s = list(self._slc)
- idx = _slice_index(self._parent.state.reference_data, self._slc)
- s[s.index('x')] = pix[0]
- s[s.index('y')] = pix[1]
- s[idx] = pix[2]
-
- # labels = self._parent.coordinate_labels(s)
- # return ' '.join(labels)
- return ''
-
- def set_image(self, image=None, wcs=None, x=None, y=None, **kwargs):
- super(PVSliceWidget, self).set_image(image=image, wcs=wcs, **kwargs)
- self._axes.set_aspect('auto')
- self._axes.set_xlim(-0.5, image.shape[1] - 0.5)
- self._axes.set_ylim(-0.5, image.shape[0] - 0.5)
- self._slc = self._parent.state.wcsaxes_slice[::-1]
- self._x = x
- self._y = y
-
- @defer_draw
- def _sync_slice(self, event):
- s = list(self._slc)
- # XXX breaks if display_data changes
- _, _, z = self._pos_in_parent(event)
- s[_slice_index(self._parent.state.reference_data, s)] = int(z)
- self._parent.state.slices = tuple(s)
-
- @defer_draw
- def _draw_crosshairs(self, event):
- x, y, _ = self._pos_in_parent(event)
- self._parent.show_crosshairs(x, y)
-
- @defer_draw
- def _on_move(self, event):
- if not event.button:
- return
+ if viewer is not None:
- if not event.inaxes or event.canvas.toolbar.mode != '':
- return
+ viewer.add_data(pvdata)
- self._sync_slice(event)
- self._draw_crosshairs(event)
+ # Copy over visual state from original layer, such as color,
+ # attribute and so on.
+ pvstate = layer_state.as_dict()
+ pvstate.pop('layer')
- def _pos_in_parent(self, event=None, xdata=None, ydata=None):
+ # Find layer state to update in new viewer - this might not be
+ # the last layer if subsets are present
+ for new_layer_state in viewer.state.layers[::-1]:
+ if new_layer_state.layer is pvdata:
+ new_layer_state.update_from_dict(pvstate)
+ break
- if event is not None:
- xdata = event.xdata
- ydata = event.ydata
+ if viewer is not None:
- # Find position slice where cursor is
- ind = int(round(np.clip(xdata, 0, self._im_array.shape[1] - 1)))
+ viewer.state.aspect = 'auto'
+ viewer.state.color_mode = self.viewer.state.color_mode
+ viewer.state.reset_limits()
- # Find pixel coordinate in input image for this slice
- x = self._x[ind]
- y = self._y[ind]
- # The 3-rd coordinate in the input WCS is simply the second
- # coordinate in the PV slice.
- z = ydata
- return x, y, z
+@viewer_tool
+class PVLinkCursorMode(ToolbarModeBase):
+ """
+ Selects pixel under mouse cursor.
+ """
- def _on_click(self, event):
- if not event.inaxes or event.canvas.toolbar.mode != '':
- return
- self._sync_slice(event)
- self._draw_crosshairs(event)
+ icon = "glue_path"
+ tool_id = 'pv:crosshair'
+ action_text = 'Show position on original path'
+ tool_tip = 'Click and drag to show position of cursor on original slice.'
+ status_tip = 'Click and drag to show position of cursor on original slice.'
+
+ _pressed = False
- def reset(self, *args):
- self.close()
+ def __init__(self, *args, **kwargs):
+ super(PVLinkCursorMode, self).__init__(*args, **kwargs)
+ self._move_callback = self._on_move
+ self._press_callback = self._on_press
+ self._release_callback = self._on_release
+ self._active = False
+ self.viewer.state.add_callback('reference_data', self._on_reference_data_change)
+ self._on_reference_data_change()
+ def _on_reference_data_change(self, *args):
+ self.enabled = isinstance(self.viewer.state.reference_data, PVSlicedData)
+ self.data = self.viewer.state.reference_data
-def _slice_from_path(x, y, data, attribute, slc):
- """
- Extract a PV-like slice from a cube
-
- :param x: An array of x values to extract (pixel units)
- :param y: An array of y values to extract (pixel units)
- :param data: :class:`~glue.core.data.Data`
- :param attribute: :claass:`~glue.core.data.Component`
- :param slc: orientation of the image widget that `pts` are defined on
-
- :returns: (slice, x, y)
- slice is a 2D Numpy array, corresponding to a "PV ribbon"
- cutout from the cube
- x and y are the resampled points along which the
- ribbon is extracted
-
- :note: For >3D cubes, the "V-axis" of the PV slice is the longest
- cube axis ignoring the x/y axes of `slc`
- """
- from pvextractor import Path, extract_pv_slice
- p = Path(list(zip(x, y)))
+ def activate(self):
+ self._line = Line2D(self.data.x, self.data.y, zorder=1000, color='#669dff',
+ alpha=0.6, lw=2)
+ self.data.parent_viewer.axes.add_line(self._line)
+ self._crosshair = self.data.parent_viewer.axes.plot([], [], '+', ms=12,
+ mfc='none', mec='#669dff',
+ mew=1, zorder=100)[0]
+ self.data.parent_viewer.figure.canvas.draw()
+ super().activate()
- cube = data[attribute]
- dims = list(range(data.ndim))
- s = list(slc)
- ind = _slice_index(data, slc)
+ def deactivate(self):
+ self._line.remove()
+ self._crosshair.remove()
+ self.data.parent_viewer.figure.canvas.draw()
+ super().deactivate()
- from astropy.wcs import WCS
+ def _on_press(self, mode):
+ self._active = True
- if isinstance(data.coords, WCS):
- cube_wcs = data.coords
- else:
- cube_wcs = None
+ def _on_release(self, mode):
+ self._active = False
- # transpose cube to (z, y, x, )
- def _swap(x, s, i, j):
- x[i], x[j] = x[j], x[i]
- s[i], s[j] = s[j], s[i]
+ def _on_move(self, mode):
- _swap(dims, s, ind, 0)
- _swap(dims, s, s.index('y'), 1)
- _swap(dims, s, s.index('x'), 2)
+ if not self._active:
+ return
+
+ # Find position of click in the image viewer
+ xdata, ydata = self._event_xdata, self._event_ydata
- cube = cube.transpose(dims)
+ if xdata is None or ydata is None:
+ return
- if cube_wcs is not None:
- cube_wcs = cube_wcs.sub([data.ndim - nx for nx in dims[::-1]])
+ # TODO: Make this robust in case the axes have been swapped
- # slice down from >3D to 3D if needed
- s = tuple([slice(None)] * 3 + [slc[d] for d in dims[3:]])
- cube = cube[s]
+ # Find position slice where cursor is
+ ind = int(round(np.clip(xdata, 0, self.data.shape[1] - 1)))
- # sample cube
- spacing = 1 # pixel
- x, y = [np.round(_x).astype(int) for _x in p.sample_points(spacing)]
+ # Find pixel coordinate in input image for this slice
+ x = self.data.x[ind]
+ y = self.data.y[ind]
- try:
- result = extract_pv_slice(cube, path=p, wcs=cube_wcs, order=0)
- wcs = WCS(result.header)
- except Exception: # sometimes pvextractor complains due to wcs. Try to recover
- result = extract_pv_slice(cube, path=p, wcs=None, order=0)
- wcs = None
+ # The 3-rd coordinate in the input WCS is simply the second
+ # coordinate in the PV slice.
+ z = ydata
- data = result.data
+ self._crosshair.set_xdata([x])
+ self._crosshair.set_ydata([y])
+ self.data.parent_viewer.figure.canvas.draw()
- return data, x, y, wcs
+ s = list(self.data.parent_viewer.state.wcsaxes_slice[::-1])
+ s[_slice_index(self.data.parent_viewer.state.reference_data, s)] = int(z)
+ self.data.parent_viewer.state.slices = tuple(s)
def _slice_index(data, slc):
diff --git a/glue/plugins/tools/pv_slicer/qt/tests/test_pv_slicer.py b/glue/plugins/tools/pv_slicer/qt/tests/test_pv_slicer.py
index 2dc6d0364..05ced141b 100644
--- a/glue/plugins/tools/pv_slicer/qt/tests/test_pv_slicer.py
+++ b/glue/plugins/tools/pv_slicer/qt/tests/test_pv_slicer.py
@@ -4,44 +4,48 @@
from glue.core import Data
from glue.core.coordinates import IdentityCoordinates
-from glue.viewers.image.qt import StandaloneImageViewer, ImageViewer
-from glue.tests.helpers import requires_astropy, requires_scipy
from glue.app.qt import GlueApplication
-from glue.utils.qt import process_events
+from glue.tests.helpers import requires_astropy, requires_scipy
+from glue.viewers.image.qt.data_viewer import ImageViewer
-from ..pv_slicer import _slice_from_path, _slice_label, _slice_index, PVSliceWidget
+from ..pv_slicer import _slice_label, _slice_index
-@requires_astropy
-@requires_scipy
-class TestSliceExtraction(object):
+class TestPVSlicerMode(object):
def setup_method(self, method):
- self.x = np.random.random((2, 3, 4))
- self.d = Data(x=self.x)
+ self.app = GlueApplication()
+ self.data_collection = self.app.data_collection
+ self.data = Data(x=np.arange(6000).reshape((10, 20, 30)))
+ self.data_collection.append(self.data)
+ self.viewer = self.app.new_data_viewer(ImageViewer, data=self.data)
+
+ def teardown_method(self, method):
+ self.viewer = None
+ self.app.close()
+ self.app = None
+
+ def test_plain(self):
- def test_constant_y(self):
+ self.viewer.toolbar.active_tool = 'slice'
+ tool = self.viewer.toolbar.active_tool # should now be set to PVSlicerMode
+
+ roi = MagicMock()
+ roi.to_polygon.return_value = [1, 10, 12], [2, 13, 14]
+
+ mode = MagicMock()
+ mode.roi.return_value = roi
- slc = (0, 'y', 'x')
- x = [-0.5, 3.5]
- y = [0, 0]
- s = _slice_from_path(x, y, self.d, 'x', slc)[0]
- assert_allclose(s, self.x[:, 0, :])
+ tool._extract_callback(mode)
- def test_constant_x(self):
+ assert len(self.data_collection) == 2
+ assert self.data_collection[1].shape == (10, 17)
- slc = (0, 'y', 'x')
- y = [-0.5, 2.5]
- x = [0, 0]
- s = _slice_from_path(x, y, self.d, 'x', slc)[0]
- assert_allclose(s, self.x[:, :, 0])
+ # If we do it again it should just update the existing slice dataset in-place
- def test_transpose(self):
- slc = (0, 'x', 'y')
- y = [-0.5, 3.5]
- x = [0, 0]
- s = _slice_from_path(x, y, self.d, 'x', slc)[0]
- assert_allclose(s, self.x[:, 0, :])
+ roi.to_polygon.return_value = [1, 10, 12], [2, 13, 14]
+
+ tool._extract_callback(mode)
def test_slice_label():
@@ -62,93 +66,3 @@ def test_slice_index():
d = Data(x=np.zeros((2, 3, 4)))
assert _slice_index(d, (0, 'y', 'x')) == 0
assert _slice_index(d, ('y', 0, 'x')) == 1
-
-
-class TestStandaloneImageViewer(object):
-
- def setup_method(self, method):
- im = np.random.random((3, 3))
- self.w = StandaloneImageViewer(im)
-
- def teardown_method(self, method):
- self.w.close()
-
- def test_set_cmap(self):
- cm_mode = self.w.toolbar.tools['image:colormap']
- act = cm_mode.menu_actions()[1]
- act.trigger()
- assert self.w._composite.layers['image']['color'] is act.cmap
-
- def test_double_set_image(self):
- assert len(self.w._axes.images) == 1
- self.w.set_image(np.zeros((3, 3)))
- assert len(self.w._axes.images) == 1
-
-
-class MockImageViewer(object):
-
- def __init__(self, slice, data):
- self.slice = slice
- self.data = data
- self.wcs = None
- self.state = MagicMock()
-
-
-class TestPVSliceWidget(object):
-
- def setup_method(self, method):
-
- self.d = Data(x=np.zeros((2, 3, 4)))
- self.slc = (0, 'y', 'x')
- self.image = MockImageViewer(self.slc, self.d)
- self.w = PVSliceWidget(image=np.zeros((3, 4)), wcs=None, image_viewer=self.image)
-
- def teardown_method(self, method):
- self.w.close()
-
- def test_basic(self):
- pass
-
-
-class TestPVSliceTool(object):
-
- def setup_method(self, method):
- self.cube = Data(label='cube', x=np.arange(1000).reshape((5, 10, 20)))
- self.application = GlueApplication()
- self.application.data_collection.append(self.cube)
- self.viewer = self.application.new_data_viewer(ImageViewer)
- self.viewer.add_data(self.cube)
-
- def teardown_method(self, method):
- self.viewer.close()
- self.viewer = None
- self.application.close()
- self.application = None
-
- @requires_astropy
- @requires_scipy
- def test_basic(self):
-
- self.viewer.toolbar.active_tool = 'slice'
-
- self.viewer.axes.figure.canvas.draw()
- process_events()
-
- 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([[7.2, 6.6]])[0]
- self.viewer.axes.figure.canvas.button_press_event(x, y, 1)
-
- process_events()
-
- assert len(self.application.tab().subWindowList()) == 1
-
- self.viewer.axes.figure.canvas.key_press_event('enter')
-
- process_events()
-
- assert len(self.application.tab().subWindowList()) == 2
-
- pv_widget = self.application.tab().subWindowList()[1].widget()
- assert pv_widget._x.shape == (6,)
- assert pv_widget._y.shape == (6,)
diff --git a/glue/plugins/tools/pv_slicer/tests/test_pv_sliced_data.py b/glue/plugins/tools/pv_slicer/tests/test_pv_sliced_data.py
new file mode 100644
index 000000000..8b1771c71
--- /dev/null
+++ b/glue/plugins/tools/pv_slicer/tests/test_pv_sliced_data.py
@@ -0,0 +1,48 @@
+import numpy as np
+from glue.core import Data, DataCollection
+from glue.core.coordinates import AffineCoordinates, IdentityCoordinates
+from glue.plugins.tools.pv_slicer.pv_sliced_data import PVSlicedData
+from glue.core.link_helpers import LinkSame
+
+
+class TestPVSlicedData:
+
+ def setup_method(self, method):
+ matrix = np.array([[2, 0, 0, 0], [0, 2, 0, 0], [0, 0, 2, 0], [0, 0, 0, 1]])
+
+ self.data1 = Data(x=np.arange(120).reshape((6, 5, 4)), coords=AffineCoordinates(matrix))
+ self.data2 = Data(y=np.arange(120).reshape((6, 5, 4)), coords=IdentityCoordinates(n_dim=3))
+
+ self.dc = DataCollection([self.data1, self.data2])
+
+ self.dc.add_link(LinkSame(self.data1.world_component_ids[0],
+ self.data2.world_component_ids[0]))
+
+ self.dc.add_link(LinkSame(self.data1.world_component_ids[1],
+ self.data2.world_component_ids[1]))
+
+ self.dc.add_link(LinkSame(self.data1.world_component_ids[2],
+ self.data2.world_component_ids[2]))
+
+ # TODO: the paths in the next two PVSlicedData objects are meant to
+ # be the same conceptually. We should make sure we formalize this with
+ # a UUID. Also should use proper links.
+
+ x1 = [0, 2, 5]
+ y1 = [1, 2, 3]
+
+ self.pvdata1 = PVSlicedData(self.data1,
+ self.data1.pixel_component_ids[1], y1,
+ self.data1.pixel_component_ids[2], x1)
+
+ x2, y2, _ = self.data2.coords.world_to_pixel_values(*self.data1.coords.pixel_to_world_values(x1, y1, 0))
+
+ self.pvdata2 = PVSlicedData(self.data2,
+ self.data2.pixel_component_ids[1], y2,
+ self.data2.pixel_component_ids[2], x2)
+
+ def test_fixed_resolution_buffer_linked(self):
+ result = self.pvdata1.compute_fixed_resolution_buffer(bounds=[(0, 5, 15), (0, 6, 20)],
+ target_data=self.pvdata2,
+ target_cid=self.data1.id['x'])
+ assert result.shape == (15, 20)
diff --git a/glue/viewers/common/qt/toolbar.py b/glue/viewers/common/qt/toolbar.py
index e7c51b124..3b85a3120 100644
--- a/glue/viewers/common/qt/toolbar.py
+++ b/glue/viewers/common/qt/toolbar.py
@@ -201,6 +201,7 @@ def toggle(state):
action.setEnabled(state)
add_callback(tool, 'enabled', toggle)
+ toggle(tool.enabled)
self.tools[tool.tool_id] = tool
diff --git a/glue/viewers/image/qt/__init__.py b/glue/viewers/image/qt/__init__.py
index 63a0b2170..9f21af648 100644
--- a/glue/viewers/image/qt/__init__.py
+++ b/glue/viewers/image/qt/__init__.py
@@ -1,5 +1,4 @@
from .data_viewer import ImageViewer # noqa
-from .standalone_image_viewer import StandaloneImageViewer # noqa
def setup():
diff --git a/glue/viewers/image/qt/data_viewer.py b/glue/viewers/image/qt/data_viewer.py
index c78a443f0..1dd3b3c45 100644
--- a/glue/viewers/image/qt/data_viewer.py
+++ b/glue/viewers/image/qt/data_viewer.py
@@ -36,8 +36,7 @@ class ImageViewer(MatplotlibImageMixin, MatplotlibDataViewer):
# we override get_data_layer_artist and get_subset_layer_artist for
# more advanced logic.
- tools = ['select:rectangle', 'select:xrange',
- 'select:yrange', 'select:circle',
+ tools = ['select:rectangle', 'select:circle',
'select:polygon', 'image:point_selection', 'image:contrast_bias',
'profile-viewer']
diff --git a/glue/viewers/image/qt/profile_viewer_tool.py b/glue/viewers/image/qt/profile_viewer_tool.py
index 48b110aab..18abeb5c7 100644
--- a/glue/viewers/image/qt/profile_viewer_tool.py
+++ b/glue/viewers/image/qt/profile_viewer_tool.py
@@ -10,6 +10,15 @@ class ProfileViewerTool(Tool):
icon = 'glue_spectrum'
tool_id = 'profile-viewer'
+ def __init__(self, viewer):
+ super(ProfileViewerTool, self).__init__(viewer)
+ self.profile_viewers = []
+ 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
+
@property
def profile_viewers_exist(self):
from glue.viewers.profile.qt import ProfileViewer
diff --git a/glue/viewers/image/qt/standalone_image_viewer.py b/glue/viewers/image/qt/standalone_image_viewer.py
deleted file mode 100644
index 740ad1446..000000000
--- a/glue/viewers/image/qt/standalone_image_viewer.py
+++ /dev/null
@@ -1,168 +0,0 @@
-import numpy as np
-
-from qtpy import QtCore, QtWidgets
-
-from glue.config import colormaps
-from glue.viewers.common.qt.toolbar import BasicToolbar
-from glue.viewers.matplotlib.qt.widget import MplWidget
-from glue.viewers.image.composite_array import CompositeArray
-from glue.external.modest_image import imshow
-from glue.utils import defer_draw
-
-from glue.viewers.matplotlib.mpl_axes import init_mpl
-
-# Import the mouse mode to make sure it gets registered
-from glue.viewers.image.qt.contrast_mouse_mode import ContrastBiasMode # noqa
-
-__all__ = ['StandaloneImageViewer']
-
-
-class StandaloneImageViewer(QtWidgets.QMainWindow):
- """
- A simplified image viewer, without any brushing or linking,
- but with the ability to adjust contrast and resample.
- """
- window_closed = QtCore.Signal()
- _toolbar_cls = BasicToolbar
- tools = ['image:contrast', 'image:colormap']
-
- def __init__(self, image=None, wcs=None, parent=None, **kwargs):
- """
- :param image: Image to display (2D numpy array)
- :param parent: Parent widget (optional)
-
- :param kwargs: Extra keywords to pass to imshow
- """
- super(StandaloneImageViewer, self).__init__(parent)
-
- self.central_widget = MplWidget()
- self.setCentralWidget(self.central_widget)
- self._setup_axes()
-
- self._composite = CompositeArray()
- self._composite.allocate('image')
-
- self._im = None
-
- self.initialize_toolbar()
-
- if image is not None:
- self.set_image(image=image, wcs=wcs, **kwargs)
-
- def _setup_axes(self):
- _, self._axes = init_mpl(self.central_widget.canvas.fig, axes=None, wcs=True)
- self._axes.set_aspect('equal', adjustable='datalim')
-
- @defer_draw
- def set_image(self, image=None, wcs=None, **kwargs):
- """
- Update the image shown in the widget
- """
- if self._im is not None:
- self._im.remove()
- self._im = None
-
- kwargs.setdefault('origin', 'upper')
-
- self._composite.set('image', array=image, color=colormaps.members[0][1])
- self._im = imshow(self._axes, self._composite, **kwargs)
- self._im_array = image
- self._set_norm(self._contrast_mode)
-
- if 'extent' in kwargs:
- self.axes.set_xlim(kwargs['extent'][:2])
- self.axes.set_ylim(kwargs['extent'][2:])
- else:
- ny, nx = image.shape
- self.axes.set_xlim(-0.5, nx - 0.5)
- self.axes.set_ylim(-0.5, ny - 0.5)
-
- # FIXME: for a reason I don't quite understand, dataLim doesn't
- # get updated immediately here, which means that there are then
- # issues in the first draw of the image (the limits are such that
- # only part of the image is shown). We just set dataLim manually
- # to avoid this issue. This is also done in ImageViewer.
- self.axes.dataLim.intervalx = self.axes.get_xlim()
- self.axes.dataLim.intervaly = self.axes.get_ylim()
-
- self._redraw()
-
- @property
- def axes(self):
- """
- The Matplotlib axes object for this figure
- """
- return self._axes
-
- def show(self):
- super(StandaloneImageViewer, self).show()
- self._redraw()
-
- def _redraw(self):
- self.central_widget.canvas.draw_idle()
-
- def set_cmap(self, cmap):
- self._composite.set('image', color=cmap)
- self._im.invalidate_cache()
- self._redraw()
-
- def mdi_wrap(self):
- """
- Embed this widget in a GlueMdiSubWindow
- """
- from glue.app.qt.mdi_area import GlueMdiSubWindow
- sub = GlueMdiSubWindow()
- sub.setWidget(self)
- self.destroyed.connect(sub.close)
- self.window_closed.connect(sub.close)
- sub.resize(self.size())
- self._mdi_wrapper = sub
- return sub
-
- def closeEvent(self, event):
- if self._im is not None:
- self._im.remove()
- self._im = None
- self.window_closed.emit()
- return super(StandaloneImageViewer, self).closeEvent(event)
-
- def _set_norm(self, mode):
- """
- Use the `ContrastMouseMode` to adjust the transfer function
- """
-
- pmin, pmax = mode.get_clip_percentile()
-
- if pmin is None:
- clim = mode.get_vmin_vmax()
- else:
- clim = (np.nanpercentile(self._im_array, pmin),
- np.nanpercentile(self._im_array, pmax))
-
- stretch = mode.stretch
- self._composite.set('image', clim=clim, stretch=stretch,
- bias=mode.bias, contrast=mode.contrast)
-
- self._im.invalidate_cache()
- self._redraw()
-
- def initialize_toolbar(self):
-
- from glue.config import viewer_tool
-
- self.toolbar = self._toolbar_cls(self)
-
- for tool_id in self.tools:
- mode_cls = viewer_tool.members[tool_id]
- if tool_id == 'image:contrast':
- mode = mode_cls(self, move_callback=self._set_norm)
- self._contrast_mode = mode
- else:
- mode = mode_cls(self)
- self.toolbar.add_tool(mode)
-
- self.addToolBar(self.toolbar)
-
- def set_status(self, message):
- sb = self.statusBar()
- sb.showMessage(message)
diff --git a/glue/viewers/matplotlib/mouse_mode.py b/glue/viewers/matplotlib/mouse_mode.py
index 332cff52e..37b133c0a 100644
--- a/glue/viewers/matplotlib/mouse_mode.py
+++ b/glue/viewers/matplotlib/mouse_mode.py
@@ -135,4 +135,4 @@ def deactivate(self):
"""
for connection in self._connections:
self._canvas.mpl_disconnect(connection)
- self._connections = []
+ self._connections[:] = []
diff --git a/glue/viewers/matplotlib/toolbar_mode.py b/glue/viewers/matplotlib/toolbar_mode.py
index 31926bd2c..1b39effa3 100644
--- a/glue/viewers/matplotlib/toolbar_mode.py
+++ b/glue/viewers/matplotlib/toolbar_mode.py
@@ -81,6 +81,13 @@ def activate(self):
self._roi_tool._sync_patch()
super(RoiModeBase, self).activate()
+ def deactivate(self):
+ self._roi_tool.reset()
+ self.clear()
+ if self.viewer is not None:
+ self.viewer.figure.canvas.draw()
+ super(RoiModeBase, self).deactivate()
+
def roi(self):
"""
The ROI defined by this mouse mode
@@ -200,7 +207,7 @@ def move(self, event):
if event.button is not None and self._roi_tool.active():
self._roi_tool.update_selection(event)
self._last_event = event
- super(ClickRoiMode, self).move(event)
+ super(ClickRoiMode, self).move(event)
def key(self, event):
if event.key == 'enter':
@@ -245,10 +252,10 @@ def __init__(self, viewer, **kwargs):
super(PathMode, self).__init__(viewer, **kwargs)
self._roi_tool = roi.MplPathROI(self._axes)
- self._roi_tool.plot_opts.update(color='#de2d26',
+ self._roi_tool.plot_opts.update(color='#669dff',
fill=False,
- linewidth=3,
- alpha=0.4)
+ linewidth=2,
+ alpha=0.6)
@viewer_tool