diff --git a/doc/fsl.fsleyes.editor.rst b/doc/fsl.fsleyes.editor.rst index 62d946c8269071f6d1b530b2751156c19d9df691..f38c93d8e293d9798ee6698bfc06a8d7f1aeddb4 100644 --- a/doc/fsl.fsleyes.editor.rst +++ b/doc/fsl.fsleyes.editor.rst @@ -1,17 +1,6 @@ fsl.fsleyes.editor package ========================== -Submodules ----------- - -.. toctree:: - - fsl.fsleyes.editor.editor - fsl.fsleyes.editor.selection - -Module contents ---------------- - .. automodule:: fsl.fsleyes.editor :members: :undoc-members: diff --git a/doc/images/editor.png b/doc/images/editor.png new file mode 100644 index 0000000000000000000000000000000000000000..0d6216b498267cdc093f26e4d9b1d7fc83a3e53f Binary files /dev/null and b/doc/images/editor.png differ diff --git a/fsl/fsleyes/editor/editor.py b/fsl/fsleyes/editor/editor.py index ee0b92d867428c4718cc0afec139edceeb52610d..f3ff2c9133c1ccacce369c42ea9e4a81391a9cda 100644 --- a/fsl/fsleyes/editor/editor.py +++ b/fsl/fsleyes/editor/editor.py @@ -1,12 +1,15 @@ #!/usr/bin/env python # -# editor.py - +# editor.py - The Editor class. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +"""This module provides the :class:`Editor` class, which provides +functionality to edit the data in an :class:`.Image` overlay. +""" + import logging -log = logging.getLogger(__name__) import collections @@ -18,36 +21,55 @@ import selection import fsl.data.image as fslimage -class ValueChange(object): - def __init__(self, overlay, volume, offset, oldVals, newVals): - self.overlay = overlay - self.volume = volume - self.offset = offset - self.oldVals = oldVals - self.newVals = newVals +log = logging.getLogger(__name__) -class SelectionChange(object): - def __init__(self, overlay, offset, oldSelection, newSelection): - self.overlay = overlay - self.offset = offset - self.oldSelection = oldSelection - self.newSelection = newSelection +class Editor(props.HasProperties): + """The ``Editor`` class provides functionality to edit the data of an + :class:`.Image` overlay. + An ``Editor`` instance uses a :class:`.Selection` object which allows + voxel selections to be made, and keeps track of all changes to the + selection and image. -class Editor(props.HasProperties): + + .. autosummary:: + :nosignatures: + + getSelection + fillSelection + createMaskFromSelection + createROIFromSelection + + Undo/redo and change groups. + + .. autosummary:: + :nosignatures: + + undo + redo + startChangeGroup + endChangeGroup + """ canUndo = props.Boolean(default=False) + """ + """ + + canRedo = props.Boolean(default=False) + """ + """ + def __init__(self, overlayList, displayCtx): - self._name = '{}_{}'.format(self.__class__.__name__, - id(self)) - self._overlayList = overlayList - self._displayCtx = displayCtx - self._selection = None - self._currentOverlay = None + self.__name = '{}_{}'.format(self.__class__.__name__, + id(self)) + self.__overlayList = overlayList + self.__displayCtx = displayCtx + self.__selection = None + self.__currentOverlay = None # A list of state objects, providing # records of what has been done. The @@ -57,18 +79,18 @@ class Editor(props.HasProperties): # everything after the doneIndex # represents states which have been # undone. - self._doneList = [] - self._doneIndex = -1 - self._inGroup = False + self.__doneList = [] + self.__doneIndex = -1 + self.__inGroup = False - self._displayCtx .addListener('selectedOverlay', - self._name, - self._selectedOverlayChanged) - self._overlayList.addListener('overlays', - self._name, - self._selectedOverlayChanged) + self.__displayCtx .addListener('selectedOverlay', + self.__name, + self.__selectedOverlayChanged) + self.__overlayList.addListener('overlays', + self.__name, + self.__selectedOverlayChanged) - self._selectedOverlayChanged() + self.__selectedOverlayChanged() log.memory('{}.init ({})'.format(type(self).__name__, id(self))) @@ -78,76 +100,33 @@ class Editor(props.HasProperties): def destroy(self): - self._displayCtx .removeListener('selectedOverlay', self._name) - self._overlayList.removeListener('overlays', self._name) + self.__displayCtx .removeListener('selectedOverlay', self.__name) + self.__overlayList.removeListener('overlays', self.__name) - if self._selection is not None: - self._selection.removeListener('selection', self._name) - - self._overlayList = None - self._displayCtx = None - self._selection = None - self._currentOverlay = None - self._doneList = None - - - def _selectedOverlayChanged(self, *a): - overlay = self._displayCtx.getSelectedOverlay() - - if self._currentOverlay == overlay: - return - - if overlay is None: - self._currentOverlay = None - self._selection = None - return - - display = self._displayCtx.getDisplay(overlay) - - if not isinstance(overlay, fslimage.Image) or \ - display.overlayType != 'volume': - self._currentOverlay = None - self._selection = None - return - - if self._selection is not None: - oldSel = self._selection.transferSelection( - overlay, display) - else: - oldSel = None - - self._currentOverlay = overlay - self._selection = selection.Selection(overlay, - display, - oldSel) - - self._selection.addListener('selection', - self._name, - self._selectionChanged) - - - def _selectionChanged(self, *a): + if self.__selection is not None: + self.__selection.removeListener('selection', self.__name) - old, new, offset = self._selection.getLastChange() - - change = SelectionChange(self._currentOverlay, offset, old, new) - self._changeMade(change) + self.__overlayList = None + self.__displayCtx = None + self.__selection = None + self.__currentOverlay = None + self.__doneList = None def getSelection(self): - return self._selection + return self.__selection def fillSelection(self, newVals): - overlay = self._currentOverlay + overlay = self.__currentOverlay if overlay is None: return - opts = self._displayCtx.getOpts(overlay) + opts = self.__displayCtx.getOpts(overlay) - selectBlock, offset = self._selection.getBoundedSelection() + selectBlock, offset = self.__selection.getBoundedSelection() if not isinstance(newVals, collections.Sequence): nv = np.zeros(selectBlock.shape, dtype=np.float32) @@ -174,99 +153,183 @@ class Editor(props.HasProperties): oldVals = np.array(oldVals) change = ValueChange(overlay, opts.volume, offset, oldVals, newVals) - self._applyChange(change) - self._changeMade( change) + self.__applyChange(change) + self.__changeMade( change) + + + def createMaskFromSelection(self): + + overlay = self.__currentOverlay + if overlay is None: + return + + overlayIdx = self.__overlayList.index(overlay) + mask = np.array(self.__selection.selection, dtype=np.uint8) + header = overlay.nibImage.get_header() + name = '{}_mask'.format(overlay.name) + + roiImage = fslimage.Image(mask, name=name, header=header) + self.__overlayList.insert(overlayIdx + 1, roiImage) + + + def createROIFromSelection(self): + + overlay = self.__currentOverlay + if overlay is None: + return + + overlayIdx = self.__overlayList.index(overlay) + opts = self.__displayCtx.getDisplay(overlay) + + roi = np.zeros(overlay.shape[:3], dtype=overlay.data.dtype) + selection = self.__selection.selection > 0 + + if len(overlay.shape) == 3: + roi[selection] = overlay.data[selection] + elif len(overlay.shape) == 4: + roi[selection] = overlay.data[:, :, :, opts.volume][selection] + else: + raise RuntimeError('Only 3D and 4D images are currently supported') + + header = overlay.nibImage.get_header() + name = '{}_roi'.format(overlay.name) + + roiImage = fslimage.Image(roi, name=name, header=header) + self.__overlayList.insert(overlayIdx + 1, roiImage) def startChangeGroup(self): - del self._doneList[self._doneIndex + 1:] + del self.__doneList[self.__doneIndex + 1:] - self._inGroup = True - self._doneIndex += 1 - self._doneList.append([]) + self.__inGroup = True + self.__doneIndex += 1 + self.__doneList.append([]) log.debug('Starting change group - merging subsequent ' - 'changes at index {} of {}'.format(self._doneIndex, - len(self._doneList))) + 'changes at index {} of {}'.format(self.__doneIndex, + len(self.__doneList))) def endChangeGroup(self): - self._inGroup = False + self.__inGroup = False log.debug('Ending change group at {} of {}'.format( - self._doneIndex, len(self._doneList))) - - - def _changeMade(self, change): - - if self._inGroup: - self._doneList[self._doneIndex].append(change) - else: - del self._doneList[self._doneIndex + 1:] - self._doneList.append(change) - self._doneIndex += 1 - - self.canUndo = True - self.canRedo = False - - log.debug('New change ({} of {})'.format(self._doneIndex, - len(self._doneList))) + self.__doneIndex, len(self.__doneList))) def undo(self): - if self._doneIndex == -1: + if self.__doneIndex == -1: return - log.debug('Undo change {} of {}'.format(self._doneIndex, - len(self._doneList))) + log.debug('Undo change {} of {}'.format(self.__doneIndex, + len(self.__doneList))) - change = self._doneList[self._doneIndex] + change = self.__doneList[self.__doneIndex] if not isinstance(change, collections.Sequence): change = [change] for c in reversed(change): - self._revertChange(c) + self.__revertChange(c) - self._doneIndex -= 1 + self.__doneIndex -= 1 - self._inGroup = False + self.__inGroup = False self.canRedo = True - if self._doneIndex == -1: + if self.__doneIndex == -1: self.canUndo = False def redo(self): - if self._doneIndex == len(self._doneList) - 1: + if self.__doneIndex == len(self.__doneList) - 1: return - log.debug('Redo change {} of {}'.format(self._doneIndex + 1, - len(self._doneList))) + log.debug('Redo change {} of {}'.format(self.__doneIndex + 1, + len(self.__doneList))) - change = self._doneList[self._doneIndex + 1] + change = self.__doneList[self.__doneIndex + 1] if not isinstance(change, collections.Sequence): change = [change] for c in change: - self._applyChange(c) + self.__applyChange(c) - self._doneIndex += 1 + self.__doneIndex += 1 - self._inGroup = False + self.__inGroup = False self.canUndo = True - if self._doneIndex == len(self._doneList) - 1: + if self.__doneIndex == len(self.__doneList) - 1: self.canRedo = False - def _applyChange(self, change): + def __selectedOverlayChanged(self, *a): + overlay = self.__displayCtx.getSelectedOverlay() + + if self.__currentOverlay == overlay: + return + + if overlay is None: + self.__currentOverlay = None + self.__selection = None + return + + display = self.__displayCtx.getDisplay(overlay) + + if not isinstance(overlay, fslimage.Image) or \ + display.overlayType != 'volume': + self.__currentOverlay = None + self.__selection = None + return + + if self.__selection is not None: + oldSel = self.__selection.transferSelection( + overlay, display) + else: + oldSel = None + + self.__currentOverlay = overlay + self.__selection = selection.Selection(overlay, + display, + oldSel) + + self.__selection.addListener('selection', + self.__name, + self.__selectionChanged) + + + def __selectionChanged(self, *a): + + old, new, offset = self.__selection.getLastChange() + + change = SelectionChange(self.__currentOverlay, offset, old, new) + self.__changeMade(change) + + + def __changeMade(self, change): + + if self.__inGroup: + self.__doneList[self.__doneIndex].append(change) + else: + del self.__doneList[self.__doneIndex + 1:] + self.__doneList.append(change) + self.__doneIndex += 1 + + self.canUndo = True + self.canRedo = False + + log.debug('New change ({} of {})'.format(self.__doneIndex, + len(self.__doneList))) + + + def __applyChange(self, change): overlay = change.overlay - opts = self._displayCtx.getOpts(overlay) + opts = self.__displayCtx.getOpts(overlay) if overlay.is4DImage(): volume = opts.volume else: volume = None - self._displayCtx.selectOverlay(overlay) + self.__displayCtx.selectOverlay(overlay) if isinstance(change, ValueChange): log.debug('Changing image data - offset ' @@ -275,17 +338,17 @@ class Editor(props.HasProperties): change.overlay.applyChange(change.offset, change.newVals, volume) elif isinstance(change, SelectionChange): - self._selection.disableListener('selection', self._name) - self._selection.setSelection(change.newSelection, change.offset) - self._selection.enableListener('selection', self._name) + self.__selection.disableListener('selection', self.__name) + self.__selection.setSelection(change.newSelection, change.offset) + self.__selection.enableListener('selection', self.__name) - def _revertChange(self, change): + def __revertChange(self, change): overlay = change.overlay - opts = self._displayCtx.getOpts(overlay) + opts = self.__displayCtx.getOpts(overlay) - self._displayCtx.selectOverlay(overlay) + self.__displayCtx.selectOverlay(overlay) if overlay.is4DImage(): volume = opts.volume else: volume = None @@ -297,47 +360,51 @@ class Editor(props.HasProperties): change.overlay.applyChange(change.offset, change.oldVals, volume) elif isinstance(change, SelectionChange): - self._selection.disableListener('selection', self._name) - self._selection.setSelection(change.oldSelection, change.offset) - self._selection.enableListener('selection', self._name) + self.__selection.disableListener('selection', self.__name) + self.__selection.setSelection(change.oldSelection, change.offset) + self.__selection.enableListener('selection', self.__name) - def createMaskFromSelection(self): - - overlay = self._currentOverlay - if overlay is None: - return - - overlayIdx = self._overlayList.index(overlay) - mask = np.array(self._selection.selection, dtype=np.uint8) - header = overlay.nibImage.get_header() - name = '{}_mask'.format(overlay.name) - - roiImage = fslimage.Image(mask, name=name, header=header) - self._overlayList.insert(overlayIdx + 1, roiImage) - - - def createROIFromSelection(self): - - overlay = self._currentOverlay - if overlay is None: - return +class ValueChange(object): + """Represents a change which has been made to the data for an + :class:`.Image` instance. Stores the location, the old values, + and the new values. + """ - overlayIdx = self._overlayList.index(overlay) - opts = self._displayCtx.getDisplay(overlay) + + def __init__(self, overlay, volume, offset, oldVals, newVals): + """Create a ``ValueChange``. + + :arg overlay: The :class:`.Image` instance. + :arg volume: Volume index, if ``overlay`` is 4D. + :arg offset: Location (voxel coordinates) of the change. + :arg oldVals: A ``numpy`` array containing the old image values. + :arg newVals: A ``numpy`` array containing the new image values. + """ - roi = np.zeros(overlay.shape[:3], dtype=overlay.data.dtype) - selection = self._selection.selection > 0 + self.overlay = overlay + self.volume = volume + self.offset = offset + self.oldVals = oldVals + self.newVals = newVals - if len(overlay.shape) == 3: - roi[selection] = overlay.data[selection] - elif len(overlay.shape) == 4: - roi[selection] = overlay.data[:, :, :, opts.volume][selection] - else: - raise RuntimeError('Only 3D and 4D images are currently supported') - header = overlay.nibImage.get_header() - name = '{}_roi'.format(overlay.name) +class SelectionChange(object): + """Represents a change which has been made to a :class:`.Selection` + instance. Stores the location, the old selection, and the new selection. + """ - roiImage = fslimage.Image(roi, name=name, header=header) - self._overlayList.insert(overlayIdx + 1, roiImage) + + def __init__(self, overlay, offset, oldSelection, newSelection): + """Create a ``SelectionChange``. + + :arg overlay: The :class:`.Image` instance. + :arg offset: Location (voxel coordinates) of the change. + :arg oldSelection: A ``numpy`` array containing the old selection. + :arg newSelection: A ``numpy`` array containing the new selection. + """ + + self.overlay = overlay + self.offset = offset + self.oldSelection = oldSelection + self.newSelection = newSelection diff --git a/fsl/fsleyes/editor/selection.py b/fsl/fsleyes/editor/selection.py index 2dea7bcc3c49931ff563b667339f61c1574151a7..5b4f0fd63911f2ef3bd80242d91b06ecd9c2c846 100644 --- a/fsl/fsleyes/editor/selection.py +++ b/fsl/fsleyes/editor/selection.py @@ -1,11 +1,11 @@ #!/usr/bin/env python # -# selection.py - Provides the Selection class, which represents a -# selection on a 3D image. +# selection.py - The Selection class. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""This module provides the :class:`Selection` class. +"""This module provides the :class:`Selection` class, which represents a +selection of voxels in a 3D :class:`.Image`. """ import logging @@ -22,190 +22,255 @@ log = logging.getLogger(__name__) class Selection(props.HasProperties): - - - selection = props.Object() + """The ``Selection`` class represents a selection of voxels in a 3D + :class:`.Image`. The selection is stored as a ``numpy`` mask array, + the same shape as the image. Methods are available to query and update + the selection. - def __init__(self, image, display, selection=None): - self._image = image - self._display = display - self._opts = display.getDisplayOpts() - self._lastChangeOffset = None - self._lastChangeOldBlock = None - self._lastChangeNewBlock = None + Changes to a ``Selection`` can be made through *blocks*, which are 3D + cuboid regions. The following methods allow a block to be + selected/deselected, where the block is specified by a voxel coordinate, + and a block size: - if selection is None: - selection = np.zeros(image.shape[:3], dtype=np.uint8) - elif selection.shape != image.shape[:3] or \ - selection.dtype != np.uint8: - raise ValueError('Incompatible selection array: {} ({})'.format( - selection.shape, - selection.dtype)) + .. autosummary:: + :nosignatures: - self.selection = selection + selectBlock + deselectBlock - log.memory('{}.init ({})'.format(type(self).__name__, id(self))) + The following methods offer more fine grained control over selection + blocks - with these methods, you pass in a block that you have created + yourself, and an offset into the selection, specifying its location: - def __del__(self): - log.memory('{}.del ({})'.format(type(self).__name__, id(self))) + .. autosummary:: + :nosignatures: + + setSelection + replaceSelection + addToSelection + removeFromSelection - def getSelectionSize(self): - return self.selection.sum() + A third approach to making a selection is provided by the + :meth:`selectByValue` method, which allows a selection to be made + in a manner similar to a *bucket fill* technique found in any image + editor. + A ``Selection`` object keeps track of the most recent change made + through any of the above methods. The most recent change can be retrieved + through the :meth:`getLastChange` method. - def getBoundedSelection(self): - - xs, ys, zs = np.where(self.selection > 0) - xlo = xs.min() - ylo = ys.min() - zlo = zs.min() - xhi = xs.max() + 1 - yhi = ys.max() + 1 - zhi = zs.max() + 1 - - selection = self.selection[xlo:xhi, ylo:yhi, zlo:zhi] + Finally, the ``Selection`` class offers a few other methods for + convenience: + + .. autosummary:: + :nosignatures: - return selection, (xlo, ylo, zlo) + getSelectionSize + clearSelection + getBoundedSelection + getIndices + generateBlock + """ - def transferSelection(self, destImg, destDisplay): - srcImg = self._image - srcOpts = self._opts - destOpts = destDisplay.getDisplayOpts() + selection = props.Object() + """The ``numpy`` mask array containing the current selection is stored + in a :class:`props.Object`, so that listeners can register to be notified + whenever it changes. - if srcOpts.transform not in ('id', 'pixdim'): - raise RuntimeError('Unsupported transform for {}: {}'.format( - srcImg, srcOpts.transform)) - if destOpts.transform not in ('id', 'pixdim'): - raise RuntimeError('Unsupported transform for {}: {}'.format( - destImg, destOpts.transform)) + .. warning:: Do not modify the selection directly through this attribute - + use the ``Selection`` instance methods + (e.g. :meth:`setSelection`) instead. If you modify the + selection directly through this attribute, the + :meth:`getLastChange` method will break. + """ - srcShape = srcImg .shape[:3] - destShape = destImg.shape[:3] - - if srcOpts .transform == 'id': srcDims = (1.0, 1.0, 1.0) - elif srcOpts .transform == 'pixdim': srcDims = srcImg.pixdim[:3] - if destOpts.transform == 'id': destDims = (1.0, 1.0, 1.0) - elif destOpts.transform == 'pixdim': destDims = destImg.pixdim[:3] + + def __init__(self, image, display, selection=None): + """Create a ``Selection`` instance. - srcXferShape = np.zeros(3, dtype=np.float32) - destXferShape = np.zeros(3, dtype=np.float32) + :arg image: The :class:`.Image` instance associated with this + ``Selection``. + + :arg display: The :class:`.Display` instance for the ``image``. + + :arg selection: Selection array. If not provided, one is created. + Must be a ``numpy.uint8`` array with the same shape + as ``image``. This array is *not* copied. + """ + + self.__image = image + self.__display = display + self.__opts = display.getDisplayOpts() + self.__lastChangeOffset = None + self.__lastChangeOldBlock = None + self.__lastChangeNewBlock = None - # Figure out the shape, of the area - # to be copied, in source image voxels, - # and in destination image voxels - for i in range(3): + if selection is None: + selection = np.zeros(image.shape[:3], dtype=np.uint8) + + elif selection.shape != image.shape[:3] or \ + selection.dtype != np.uint8: + raise ValueError('Incompatible selection array: {} ({})'.format( + selection.shape, selection.dtype)) - srcSize = float(srcShape[ i] * srcDims[ i]) - destSize = float(destShape[i] * destDims[i]) - xferSize = min(srcSize, destSize) + self.selection = selection - srcXferShape[ i] = int(round(xferSize / srcDims[ i])) - destXferShape[i] = int(round(xferSize / destDims[i])) + log.memory('{}.init ({})'.format(type(self).__name__, id(self))) - xferred = np.zeros(destShape, dtype=np.uint8) - srcx, srcy, srcz = map(int, srcXferShape) - destx, desty, destz = map(int, destXferShape) + def __del__(self): + """Prints a log message.""" + log.memory('{}.del ({})'.format(type(self).__name__, id(self))) - zoomFactor = destXferShape / srcXferShape - zoomed = ndiint.zoom( - self.selection[:srcx, :srcy, :srcz], - zoomFactor, - order=0) - if zoomed.shape == (destx, desty, destz): - xferred[:destx, :desty, :destz] = zoomed - - return xferred + def selectBlock(self, voxel, blockSize, axes=(0, 1, 2)): + """Selects the block (sets all voxels to 1) specified by the given + voxel and block size. - def _updateSelectionBlock(self, block, offset): + :arg voxel: Starting voxel coordinates of the block. + :arg blockSize: Size of the block along each axis. + :arg axes: Limit the block to the specified axes. - block = np.array(block, dtype=np.uint8) + """ + self.addToSelection(*self.generateBlock(voxel, + blockSize, + self.selection.shape, + axes)) - if block.size == 0: - return + + def deselectBlock(self, voxel, blockSize, axes=(0, 1, 2)): + """De-selects the block (sets all voxels to 0) specified by the given + voxel and block size. - if offset is None: - offset = (0, 0, 0) + :arg voxel: Starting voxel coordinates of the block. + :arg blockSize: Size of the block along each axis. + :arg axes: Limit the block to the specified axes. + """ + self.removeFromSelection(*self.generateBlock(voxel, + blockSize, + self.selection.shape, + axes)) - xlo, ylo, zlo = offset + + def setSelection(self, block, offset): + """Copies the given ``block`` into the selection, starting at + ``offset``. - xhi = xlo + block.shape[0] - yhi = ylo + block.shape[1] - zhi = zlo + block.shape[2] + :arg block: A ``numpy.uint8`` array containing a selection. + :arg offset: Voxel coordinates specifying the block location. + """ + self.__updateSelectionBlock(block, offset) + + + def replaceSelection(self, block, offset): + """Clears the entire selection, then copies the given ``block`` + into the selection, starting at ``offset``. + + :arg block: A ``numpy.uint8`` array containing a selection. + :arg offset: Voxel coordinates specifying the block location. + """ + self.clearSelection() + self.__updateSelectionBlock(block, offset) - self._lastChangeOffset = offset - self._lastChangeOldBlock = np.array(self.selection[xlo:xhi, - ylo:yhi, - zlo:zhi]) - self._lastChangeNewBlock = np.array(block) + + def addToSelection(self, block, offset): + """Adds the selection (via a boolean OR operation) in the given + ``block`` to the current selection, starting at ``offset``. - log.debug('Updating selection ({}) block [{}:{}, {}:{}, {}:{}]'.format( - id(self), xlo, xhi, ylo, yhi, zlo, zhi)) + :arg block: A ``numpy.uint8`` array containing a selection. + :arg offset: Voxel coordinates specifying the block location. + """ + existing = self.__getSelectionBlock(block.shape, offset) + block = np.logical_or(block, existing) + + self.__updateSelectionBlock(block, offset) - self.selection[xlo:xhi, ylo:yhi, zlo:zhi] = block - self.notify('selection') + def removeFromSelection(self, block, offset): + """Clears all voxels in the selection where the values in ``block`` + are non-zero. - def _getSelectionBlock(self, size, offset): - - xlo, ylo, zlo = offset - xhi, yhi, zhi = size - - xhi = xlo + size[0] - yhi = ylo + size[1] - zhi = zlo + size[2] + :arg block: A ``numpy.uint8`` array containing a selection. + :arg offset: Voxel coordinates specifying the block location. + """ + existing = self.__getSelectionBlock(block.shape, offset) + existing[block != 0] = False + self.__updateSelectionBlock(existing, offset) - return np.array(self.selection[xlo:xhi, ylo:yhi, zlo:zhi]) + + def getSelectionSize(self): + """Returns the number of voxels that are currently selected. """ + return self.selection.sum() - def replaceSelection(self, block, offset): - self.clearSelection() - self._updateSelectionBlock(block, offset) + def getBoundedSelection(self): + """Extracts the smallest region from the :attr:`selection` which + contains all selected voxels. + Returns a tuple containing the region, as a ``numpy.uint8`` array, and + the coordinates specifying its location in the full :attr:`selection` + array. + """ - def setSelection(self, block, offset): - self._updateSelectionBlock(block, offset) + xs, ys, zs = np.where(self.selection > 0) - - def addToSelection(self, block, offset): - existing = self._getSelectionBlock(block.shape, offset) - block = np.logical_or(block, existing) - self._updateSelectionBlock(block, offset) + xlo = xs.min() + ylo = ys.min() + zlo = zs.min() + xhi = xs.max() + 1 + yhi = ys.max() + 1 + zhi = zs.max() + 1 + selection = self.selection[xlo:xhi, ylo:yhi, zlo:zhi] - def removeFromSelection(self, block, offset): - existing = self._getSelectionBlock(block.shape, offset) - existing[block != 0] = False - self._updateSelectionBlock(existing, offset) + return selection, (xlo, ylo, zlo) def clearSelection(self): + """Clears (sets to 0) the entire selection. """ log.debug('Clearing selection ({})'.format(id(self))) - self._lastChangeOffset = [0, 0, 0] - self._lastChangeOldBlock = np.array(self.selection) - self.selection[:] = False - self._lastChangeNewBlock = np.array(self.selection) + self.__lastChangeOffset = [0, 0, 0] + self.__lastChangeOldBlock = np.array(self.selection) + self.selection[:] = False + self.__lastChangeNewBlock = np.array(self.selection) self.notify('selection') def getLastChange(self): - return (self._lastChangeOldBlock, - self._lastChangeNewBlock, - self._lastChangeOffset) + """Returns the most recent change made to this ``Selection``. + + A tuple is returned, containing the following: + + - A ``numpy.uint8`` array containing the old block value + - A ``numpy.uint8`` array containing the new block value + - Voxel coordinates denoting the block location in the full + :attr:`selection` array. + """ + return (self.__lastChangeOldBlock, + self.__lastChangeNewBlock, + self.__lastChangeOffset) def getIndices(self, restrict=None): + """Returns a :math:`N \\times 3` array which contains the + coordinates of all voxels that are currently selected. + + If the ``restrict`` argument is not provided, the entire + selection image is searched. + + :arg restrict: A ``slice`` object specifying a sub-set of the + full selection to consider. + """ if restrict is None: selection = self.selection else: selection = self.selection[restrict] @@ -223,60 +288,38 @@ class Selection(props.HasProperties): return result - - @classmethod - def generateBlock(cls, voxel, blockSize, shape, axes=(0, 1, 2)): - - if blockSize == 1: - return np.array([True], dtype=np.uint8).reshape(1, 1, 1), voxel - - blockLo = [v - int(np.floor((blockSize - 1) / 2.0)) for v in voxel] - blockHi = [v + int(np.ceil(( blockSize - 1) / 2.0)) for v in voxel] - - for i in range(3): - if i not in axes: - blockLo[i] = voxel[i] - blockHi[i] = voxel[i] + 1 - else: - blockLo[i] = max(blockLo[i], 0) - blockHi[i] = min(blockHi[i] + 1, shape[i]) - - if blockHi[i] <= blockLo[i]: - return np.ones((0, 0, 0), dtype=np.uint8), voxel - - block = np.ones((blockHi[0] - blockLo[0], - blockHi[1] - blockLo[1], - blockHi[2] - blockLo[2]), dtype=np.uint8) - - offset = blockLo - - return block, offset - - - def selectBlock(self, voxel, blockSize, axes=(0, 1, 2)): - self.addToSelection(*self.generateBlock(voxel, - blockSize, - self.selection.shape, - axes)) - - - def deselectBlock(self, voxel, blockSize, axes=(0, 1, 2)): - self.removeFromSelection(*self.generateBlock(voxel, - blockSize, - self.selection.shape, - axes)) - def selectByValue(self, seedLoc, precision=None, searchRadius=None, local=False): - - if len(self._image.shape) == 3: - data = self._image.data - elif len(self._image.shape) == 4: - data = self._image.data[:, :, :, self._opts.volume] + """A *bucket fill* style selection routine. Given a seed location, + finds all voxels which have a value similar to that of that location. + The current selection is replaced with all voxels that were found. + + :arg seedLoc: Voxel coordinates specifying the seed location + + :arg precision: Voxels which have a value that is less than + ``precision`` from the seed location value will + be selected. + + :arg searchRadius: May be either a single value, or a sequence of + three values - one for each axis. If provided, + the search is limited to an ellipse, centred on + the seed location, with the specified + ``searchRadius`` (in voxels). If not provided, + the search will cover the entire image space. + + :arg local: If ``True``, a voxel will only be selected if it + is adjacent to an already selected voxel (using + 8-neighbour connectivity). + """ + + if len(self.__image.shape) == 3: + data = self.__image.data + elif len(self.__image.shape) == 4: + data = self.__image.data[:, :, :, self.__opts.volume] else: raise RuntimeError('Only 3D and 4D images are currently supported') @@ -361,10 +404,186 @@ class Selection(props.HasProperties): # # If local is not True, any same or similar # values are part of the selection - # if local: hits, _ = ndimeas.label(hits) seedLabel = hits[seedLoc[0], seedLoc[1], seedLoc[2]] hits = hits == seedLabel self.replaceSelection(hits, searchOffset) + + + def transferSelection(self, destImg, destDisplay): + """Re-samples the current selection into the destination image + space. + + Each ``Selection`` instance is in terms of a specific :class:`.Image` + instance, which has a specific dimensionality. In order to apply + a ``Selection`` which is in terms of one ``Image``, the selection + array needs to be re-sampled. + + .. todo:: This method will need to be re-written with the introduction + of **GedMode**. + + :arg destImg: The :class:`.Image` that the selection is to be + transferred to. + + :arg destDisplay: The :class:`.Display` instance associated with + ``destImg``. + + :returns: a new ``numpy.uint8`` array, suitable for creating a new + ``Selection`` object for use with the given ``destImg``. + """ + + srcImg = self.__image + srcOpts = self.__opts + destOpts = destDisplay.getDisplayOpts() + + if srcOpts.transform not in ('id', 'pixdim'): + raise RuntimeError('Unsupported transform for {}: {}'.format( + srcImg, srcOpts.transform)) + if destOpts.transform not in ('id', 'pixdim'): + raise RuntimeError('Unsupported transform for {}: {}'.format( + destImg, destOpts.transform)) + + srcShape = srcImg .shape[:3] + destShape = destImg.shape[:3] + + if srcOpts .transform == 'id': srcDims = (1.0, 1.0, 1.0) + elif srcOpts .transform == 'pixdim': srcDims = srcImg.pixdim[:3] + if destOpts.transform == 'id': destDims = (1.0, 1.0, 1.0) + elif destOpts.transform == 'pixdim': destDims = destImg.pixdim[:3] + + srcXferShape = np.zeros(3, dtype=np.float32) + destXferShape = np.zeros(3, dtype=np.float32) + + # Figure out the shape, of the area + # to be copied, in source image voxels, + # and in destination image voxels + for i in range(3): + + srcSize = float(srcShape[ i] * srcDims[ i]) + destSize = float(destShape[i] * destDims[i]) + xferSize = min(srcSize, destSize) + + srcXferShape[ i] = int(round(xferSize / srcDims[ i])) + destXferShape[i] = int(round(xferSize / destDims[i])) + + xferred = np.zeros(destShape, dtype=np.uint8) + + srcx, srcy, srcz = map(int, srcXferShape) + destx, desty, destz = map(int, destXferShape) + + zoomFactor = destXferShape / srcXferShape + zoomed = ndiint.zoom( + self.selection[:srcx, :srcy, :srcz], + zoomFactor, + order=0) + + if zoomed.shape == (destx, desty, destz): + xferred[:destx, :desty, :destz] = zoomed + + return xferred + + + def __updateSelectionBlock(self, block, offset): + """Replaces the current selection at the specified ``offset`` with the + given ``block``. + + The old values for the block are stored, and can be retrieved via the + :meth:`getLastChange` method. + + :arg block: A ``numpy.uint8`` array containing the new selection + values. + + :arg offset: Voxel coordinates specifying the location of ``block``. + """ + + block = np.array(block, dtype=np.uint8) + + if block.size == 0: + return + + if offset is None: + offset = (0, 0, 0) + + xlo, ylo, zlo = offset + + xhi = xlo + block.shape[0] + yhi = ylo + block.shape[1] + zhi = zlo + block.shape[2] + + self.__lastChangeOffset = offset + self.__lastChangeOldBlock = np.array(self.selection[xlo:xhi, + ylo:yhi, + zlo:zhi]) + self.__lastChangeNewBlock = np.array(block) + + log.debug('Updating selection ({}) block [{}:{}, {}:{}, {}:{}]'.format( + id(self), xlo, xhi, ylo, yhi, zlo, zhi)) + + self.selection[xlo:xhi, ylo:yhi, zlo:zhi] = block + self.notify('selection') + + + def __getSelectionBlock(self, size, offset): + """Extracts a block from the selection image starting from the + specified ``offset``, and of the specified ``size``. + """ + + xlo, ylo, zlo = offset + xhi, yhi, zhi = size + + xhi = xlo + size[0] + yhi = ylo + size[1] + zhi = zlo + size[2] + + return np.array(self.selection[xlo:xhi, ylo:yhi, zlo:zhi]) + + + @classmethod + def generateBlock(cls, voxel, blockSize, shape, axes=(0, 1, 2)): + """Convenience method to generates a square/cube of ones, with the + specified voxel at its centre, to fit in an image of the given shape. + + If the specified voxel would result in part of the block being located + outside of the image shape, the block is truncated to fit inside + the image bounds. + + :arg voxel: Coordinates of the voxel around which the block is to + be centred. + + :arg blockSize: Desired width/height/depth + + :arg shape: Shape of the image in which the block is to be located. + + :arg axes: Axes along which the block is to be located. + + :returns: A tuple containing the block - a ``numpy.uint8`` array + filled with ones, and an offset specifying the block + location within an image of the specified ``shape``. + """ + + if blockSize == 1: + return np.array([True], dtype=np.uint8).reshape(1, 1, 1), voxel + + blockLo = [v - int(np.floor((blockSize - 1) / 2.0)) for v in voxel] + blockHi = [v + int(np.ceil(( blockSize - 1) / 2.0)) for v in voxel] + + for i in range(3): + if i not in axes: + blockLo[i] = voxel[i] + blockHi[i] = voxel[i] + 1 + else: + blockLo[i] = max(blockLo[i], 0) + blockHi[i] = min(blockHi[i] + 1, shape[i]) + + if blockHi[i] <= blockLo[i]: + return np.ones((0, 0, 0), dtype=np.uint8), voxel + + block = np.ones((blockHi[0] - blockLo[0], + blockHi[1] - blockLo[1], + blockHi[2] - blockLo[2]), dtype=np.uint8) + + offset = blockLo + + return block, offset