Source code for fsleyes.plugins.profiles.orthocropprofile

#
# orthocropprofile.py - The OrthoCropProfile class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`OrthoCropProfile` class, an interaction
:class:`.Profile` for :class:`.OrthoPanel` views, which is used by the
:class:`.CropImagePanel`.
"""


import logging

import          wx
import numpy as np

import fsl.data.image                     as fslimage
import fsl.utils.run                      as fslrun
import fsl.utils.tempdir                  as tempdir
from   fsl.utils.platform import platform as fslplatform
import fsleyes_props                      as props
import fsleyes.actions                    as actions
import fsleyes.gl.annotations             as annotations
import fsleyes.profiles.orthoviewprofile  as orthoviewprofile


log = logging.getLogger(__name__)


[docs]class OrthoCropProfile(orthoviewprofile.OrthoViewProfile): """The ``OrthoCropProfile`` class is a :class:`.Profile` for the :class:`.OrthoPanel` class, which allows the user to define a cropping region for :class:`.Image` overlays. Ther ``OrthoCropProfile`` displays a cropping, or ROI, region on the ``OrthoPanel`` canvases, relative to the for the currently selected image, using :class:`.Rect` annotations. Mouse handlers are also defined, allowing the user to adjust the box. Once the user has selected a cropping region, the related :class:`.CropImagePanel` allows him/her to create a cropped copy of the image. The ``OrthoCropProfile`` class defines one mode, in addition to those inherited from the :class:`.OrthoViewProfile` class: ======== =================================================== ``crop`` Clicking and dragging allows the user to change the boundaries of a cropping region. ======== =================================================== .. note:: The crop overlay will only be shown if the :attr:`.DisplayContext.displaySpace` is set to the currently selected overlay. The :class:`.CropImagePanel` uses a :class:`.DisplaySpaceWarning` to inform the user. """ cropBox = props.Bounds(ndims=3, real=False, minDistance=1, clamped=False) """This property keeps track of the current low/high limits of the cropping region, in the voxel coordinate system of the currently selected overlay. """
[docs] @staticmethod def tempModes(): """Returns the temporary mode map for the ``OrthoCropProfile``, which controls the use of modifier keys to temporarily enter other interaction modes. """ return { ('crop', wx.WXK_SHIFT) : 'nav', ('crop', wx.WXK_CONTROL) : 'zoom', ('crop', wx.WXK_ALT) : 'pan', ('crop', (wx.WXK_CONTROL, wx.WXK_SHIFT)) : 'slice'}
[docs] @staticmethod def altHandlers(): """Returns the alternate handlers map, which allows event handlers defined in one mode to be re-used whilst in another mode. """ return {('crop', 'MiddleMouseDrag') : ('pan', 'LeftMouseDrag')}
[docs] def __init__(self, viewPanel, overlayList, displayCtx): """Create an ``OrthoCropProfile``. :arg viewPanel: An :class:`.OrthoPanel` instance. :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. """ orthoviewprofile.OrthoViewProfile.__init__( self, viewPanel, overlayList, displayCtx, ['crop']) self.mode = 'crop' # The currently selected overlay, # and the one for which the cropping # box is being shown/modified. self.__overlay = None # A cache of { overlay : cropBox } # which stores the last cropping # box for a given overlay. This # is used to cache boxes if the # user selects a different overlay # while the crop profile is active. self.__cachedCrops = {} # axis: one of 0, 1, or 2 (X, Y, or Z) - # the voxel axis of the crop box # that is being adjusted # # limits: one of 0 or 1 (lo or hi) - the # low/high limit of the crop box # that is being adjusted # # These fields are set when the # user is dragging a crop box # boundary self.__dragAxis = None self.__dragLimit = None self.__xcanvas = viewPanel.getXCanvas() self.__ycanvas = viewPanel.getYCanvas() self.__zcanvas = viewPanel.getZCanvas() # A rectangle is displayed on # each of the canvases, showing # the current cropping box. self.__xrect = annotations.Rect(self.__xcanvas.getAnnotations(), 0, 0, 0, 0, colour=(0.3, 0.3, 1.0), alpha=30) self.__yrect = annotations.Rect(self.__ycanvas.getAnnotations(), 0, 0, 0, 0, colour=(0.3, 0.3, 1.0), alpha=30) self.__zrect = annotations.Rect(self.__zcanvas.getAnnotations(), 0, 0, 0, 0, colour=(0.3, 0.3, 1.0), alpha=30) displayCtx .addListener('displaySpace', self.name, self.__displaySpaceChanged) displayCtx .addListener('selectedOverlay', self.name, self.__selectedOverlayChanged) overlayList.addListener('overlays', self.name, self.__selectedOverlayChanged) self .addListener('cropBox', self.name, self.__cropBoxChanged) self.__xcanvas.getAnnotations().obj(self.__xrect, hold=True) self.__ycanvas.getAnnotations().obj(self.__yrect, hold=True) self.__zcanvas.getAnnotations().obj(self.__zrect, hold=True) self.robustfov.enabled = fslplatform.fsldir is not None self.__selectedOverlayChanged()
[docs] def destroy(self): """Must be called when this ``OrthoCropProfile`` is no longer needed. Removes property listeners and does some other clean up. """ self.overlayList.removeListener('overlays', self.name) self.displayCtx .removeListener('selectedOverlay', self.name) self.displayCtx .removeListener('displaySpace', self.name) self .removeListener('cropBox', self.name) self.__xcanvas.getAnnotations().dequeue(self.__xrect, hold=True) self.__ycanvas.getAnnotations().dequeue(self.__yrect, hold=True) self.__zcanvas.getAnnotations().dequeue(self.__zrect, hold=True) orthoviewprofile.OrthoViewProfile.destroy(self)
[docs] @actions.action def robustfov(self): """Call ``robustfov`` for the current overlay and set the :attr:`cropBox` based on the result. """ overlay = self.__overlay if overlay is None: return try: with tempdir.tempdir(): # in-memory image - create and # save a copy - use a copy # otherwise the original will # think that is has been saved # to disk. if overlay.dataSource is None: overlay = fslimage.Image(overlay) overlay.save('image') result = fslrun.runfsl('robustfov', '-i', overlay.dataSource) # robustfov returns two lines, the last # of which contains the limits, as: # # xmin xlen ymin ylen zmin zlen limits = list(result.strip().split('\n')[-1].split()) limits = [float(l) for l in limits] # Convert the lens to maxes limits[1] += limits[0] limits[3] += limits[2] limits[5] += limits[4] self.cropBox[:] = limits except Exception as e: log.warning('Call to robustfov failed: {}'.format(str(e)))
def __deregisterOverlay(self): """Called by :meth:`__selectedOverlayChanged`. Clears references associated with the previously selected overlay, if necessary. """ if self.__overlay is None: return self.__cachedCrops[self.__overlay] = list(self.cropBox) self.__overlay = None def __registerOverlay(self, overlay): """Called by :meth:`__selectedOverlayChanged`. Sets up references associated with the given (newly selected) overlay. """ self.__overlay = overlay def __selectedOverlayChanged(self, *a): """Called when the :attr:`.DisplayContext.selectedOverlay` changes. If the overlay is a :class:`.Image` instance, it is set as the :attr:`.DisplayContext.displaySpace` reference, and the :attr:`cropBox` is configured to be relative to the newly selected overlay. """ overlay = self.displayCtx.getSelectedOverlay() if overlay is self.__overlay: return self.__deregisterOverlay() enabled = isinstance(overlay, fslimage.Image) self.__xrect.enabled = enabled self.__yrect.enabled = enabled self.__zrect.enabled = enabled if not enabled: return self.__registerOverlay(overlay) shape = overlay.shape[:3] crop = self.__cachedCrops.get(overlay, None) if crop is None: crop = [0, shape[0], 0, shape[1], 0, shape[2]] with props.suppress(self, 'cropBox', notify=True): self.cropBox.xmin = 0 self.cropBox.ymin = 0 self.cropBox.zmin = 0 self.cropBox.xmax = shape[0] self.cropBox.ymax = shape[1] self.cropBox.zmax = shape[2] self.cropBox = crop def __displaySpaceChanged(self, *a): """Called when the :attr:`.DisplayContext.displaySpace` changes. Resets the :attr:`cropBox`. """ cropBox = self.cropBox cropBox.xlo = self.cropBox.xmin cropBox.ylo = self.cropBox.ymin cropBox.zlo = self.cropBox.zmin cropBox.xhi = self.cropBox.xmax cropBox.yhi = self.cropBox.ymax cropBox.zhi = self.cropBox.zmax def __cropBoxChanged(self, *a): """Called when the :attr:`cropBox` changes. Updates the :class:`.Rect` annotations on the :class:`.OrthoPanel` canvases. """ xlo, xhi = self.cropBox.x ylo, yhi = self.cropBox.y zlo, zhi = self.cropBox.z xlo -= 0.5 ylo -= 0.5 zlo -= 0.5 xhi -= 0.5 yhi -= 0.5 zhi -= 0.5 coords = np.array([ [xlo, ylo, zlo], [xlo, ylo, zhi], [xlo, yhi, zlo], [xlo, yhi, zhi], [xhi, ylo, zlo], [xhi, ylo, zhi], [xhi, yhi, zlo], [xhi, yhi, zhi]]) opts = self.displayCtx.getOpts(self.__overlay) coords = opts.transformCoords(coords, 'voxel', 'display') mins = coords.min(axis=0) maxs = coords.max(axis=0) pads = (maxs - mins) * 0.01 self.__xrect.x = mins[1] self.__xrect.y = mins[2] self.__xrect.w = maxs[1] - mins[1] self.__xrect.h = maxs[2] - mins[2] self.__xrect.zmin = mins[0] - pads[0] self.__xrect.zmax = maxs[0] + pads[0] self.__yrect.x = mins[0] self.__yrect.y = mins[2] self.__yrect.w = maxs[0] - mins[0] self.__yrect.h = maxs[2] - mins[2] self.__yrect.zmin = mins[1] - pads[1] self.__yrect.zmax = maxs[1] + pads[1] self.__zrect.x = mins[0] self.__zrect.y = mins[1] self.__zrect.w = maxs[0] - mins[0] self.__zrect.h = maxs[1] - mins[1] self.__zrect.zmin = mins[2] - pads[2] self.__zrect.zmax = maxs[2] + pads[2] # TODO Don't do this if you don't need to self.__xcanvas.Refresh() self.__ycanvas.Refresh() self.__zcanvas.Refresh() def __getVoxel(self, overlay, canvasPos): """Called by the mouse down/drag handlers. Figures out the voxel in the currently selected overlay which corresponds to the given canvas position. """ vox = self.displayCtx.getOpts(overlay).getVoxel( canvasPos, clip=False, vround=False) return np.ceil(vox)
[docs] def _cropModeLeftMouseDown(self, ev, canvas, mousePos, canvasPos): """Called on mouse down events. Calculates the nearest crop box boundary to the mouse click, adjusts the boundary accordingly, and saves the boundary/axis information for subsequent drag events (see :meth:`_cropModeLeftMouseDrag`). """ copts = canvas.opts overlay = self.__overlay if overlay is None: return # What canvas was the click on? if copts.zax == 0: hax, vax = 1, 2 elif copts.zax == 1: hax, vax = 0, 2 elif copts.zax == 2: hax, vax = 0, 1 # Figure out the distances from # the mouse click to each crop # box boundary on the clicked # canvas vox = self.__getVoxel(overlay, canvasPos) hlo, hhi = self.cropBox.getLo(hax), self.cropBox.getHi(hax) vlo, vhi = self.cropBox.getLo(vax), self.cropBox.getHi(vax) # We compare the click voxel # coords with each of the x/y # lo/hi crop box boundaries boundaries = np.array([ [hlo, vox[vax]], [hhi, vox[vax]], [vox[hax], vlo], [vox[hax], vhi]]) # As the display space is (should be) set # to this overlay, the display coordinate # system is equivalent to the scaled # voxel coordinate system of the # overlay. So we can just multiply the # 2D voxel coordinates by the # corresponding pixdims to get the # distances in the display coordinate # system. pixdim = overlay.pixdim[:3] scVox = [vox[hax] * pixdim[hax], vox[vax] * pixdim[vax]] boundaries[:, 0] = boundaries[:, 0] * pixdim[hax] boundaries[:, 1] = boundaries[:, 1] * pixdim[vax] # Calculate distance from click to # crop boundaries, and figure out # the screen axis (x/y) and limit # (lo/hi) to be dragged. dists = (boundaries - scVox) ** 2 dists = np.sqrt(np.sum(dists, axis=1)) axis, limit = np.unravel_index(np.argmin(dists), (2, 2)) voxAxis = [hax, vax][axis] axis = int(axis) limit = int(limit) # Save these for the mouse drag handler self.__dragAxis = voxAxis self.__dragLimit = limit # Update the crop box and location with props.suppress(self, 'cropBox', notify=True): self.cropBox.setLimit(voxAxis, limit, vox[voxAxis]) self.displayCtx.location = canvasPos
[docs] def _cropModeLeftMouseDrag(self, ev, canvas, mousePos, canvasPos): """Called on left mouse drags. Updates the :attr:`cropBox` boudary which was clicked on (see :meth:`_cropModeLeftMouseDown`), so it follows the mouse location. """ if self.__overlay is None or self.__dragAxis is None: return box = self.cropBox axis = self.__dragAxis limit = self.__dragLimit oppLimit = 1 - limit vox = self.__getVoxel(self.__overlay, canvasPos) newval = vox[axis] oppval = box.getLimit(axis, oppLimit) if limit == 0 and newval >= oppval: newval = oppval - 1 elif limit == 1 and newval <= oppval: newval = oppval + 1 with props.suppress(self, 'cropBox', notify=True): self.cropBox.setLimit(axis, limit, newval) self.displayCtx.location = canvasPos
[docs] def _cropModeLeftMouseUp(self, ev, canvas, mousePos, canvasPos): """Called on left mouse up events. Clears references used by the mouse down/drag handlers. """ if self.__overlay is None or self.__dragAxis is None: return self.__dragAxis = None self.__dragLimit = None