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 @@ + +image/svg+xml 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 @@ - - image/svg+xml \ 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