Source code for fsleyes.plugins.tools.edittransform
#
# edittransform.py - The EditTransformPanel class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`EditTransformPanel` (a.k.a. "Nudge")
class, a FSLeyes control panel which allows the user to adjust the
``voxToWorldMat`` of an :class:`.Image` overlay.
"""
import logging
import wx
import numpy as np
import fsl.data.image as fslimage
import fsl.utils.idle as idle
import fsl.transform.affine as affine
import fsleyes_props as props
import fsleyes_widgets.floatslider as fslider
import fsleyes.actions as actions
import fsleyes.views.orthopanel as orthopanel
import fsleyes.controls.controlpanel as ctrlpanel
import fsleyes.displaycontext as displaycontext
import fsleyes.strings as strings
import fsleyes.plugins.tools.applyflirtxfm as applyflirtxfm
import fsleyes.plugins.tools.saveflirtxfm as saveflirtxfm
import fsleyes.controls.displayspacewarning as dswarning
log = logging.getLogger(__name__)
[docs]class EditTransformAction(actions.ToggleControlPanelAction):
"""The ``EditTransformAction`` just toggles an
:class:`.EditTransformPanel`. It is added under the FSLeyes Tools menu.
"""
[docs] @staticmethod
def supportedViews():
"""The ``EditTransformAction`` is restricted for use with
:class:`.OrthoPanel` views.
"""
return [orthopanel.OrthoPanel]
[docs] def __init__(self, overlayList, displayCtx, ortho):
"""Create an ``EditTransformAction``.
"""
super().__init__(overlayList, displayCtx, ortho, EditTransformPanel)
self.__ortho = ortho
displayCtx.addListener('selectedOverlay', self.name,
self.__selectedOverlayChanged)
[docs] def destroy(self):
"""Called when the :class:`.OrthoPanel` that owns this action is
closed. Clears references, removes listeners, and calls the base
class ``destroy`` method.
"""
if self.destroyed:
return
self.__ortho = None
self.displayCtx.removeListener('selectedOverlay', self.name)
super().destroy()
def __selectedOverlayChanged(self, *a):
"""Called when the selected overlay changes. Enables/disables this
action (and hence the bound Tools menu item) depending on whether the
overlay is an image.
"""
ovl = self.displayCtx.getSelectedOverlay()
self.enabled = isinstance(ovl, fslimage.Image)
[docs]class EditTransformPanel(ctrlpanel.ControlPanel):
"""The :class:`EditTransformPanel` class is a FSLeyes control panel which
allows the user to adjust the ``voxToWorldMat`` of an :class:`.Image`
overlay.
Controls are provided allowing the user to construct a transformation
matrix from scales, offsets, and rotations. While the user is adjusting
the transformation, the :attr:`.NiftiOpts.displayXform` is used to
update the overlay display in real time. When the user clicks the *Apply*
button, the transformation is applied to the image's ``voxToWorldMat``
attribute.
This panel also has buttons which allow the user to load/save the
transformation matrix - they use functions in the :mod:`.applyflirtxfm`
and :mod:`.saveflirtxfm` modules to load, save, and calculate
transformation matrices. When the user loads a matrix, it is used in place
of the :attr:`.Image.voxToWorldMat` transformation.
.. note:: The effect of editing the transformation will only be visible
if the :attr:`.DisplayContext.displaySpace` is set to
``'world'``, or to some image which is not being edited. A
warning is shown at the top of the panel if the ``displaySpace``
is not set appropriately.
"""
[docs] @staticmethod
def ignoreControl():
"""Tells the FSLeyes plugin system not to add the
``EditTransformPanel`` as an option to the FSLeyes settings menu.
Instead, the :class:`EditTransformAction` action is added to the
tools menu.
"""
return True
[docs] @staticmethod
def supportedViews():
"""Overrides :meth:`.ControlMixin.supportedViews`. The
``EditTransformPanel`` is only intended to be added to
:class:`.OrthoPanel` views.
"""
return [orthopanel.OrthoPanel]
[docs] @staticmethod
def defaultLayout():
"""Returns a dictionary of arguments to be passed to the
:meth:`.ViewPanel.togglePanel` method when an ``EditTransformPanel``
is created.
"""
return dict(floatPane=True, floatOnly=True)
[docs] def __init__(self, parent, overlayList, displayCtx, ortho):
"""Create an ``EditTransformPanel``.
:arg parent: The :mod:`wx` parent object.
:arg overlayList: The :class:`.OverlayList` instance.
:arg displayCtx: The :class:`.DisplayContext` instance.
:arg ortho: The :class:`.OrthoPanel` instance.
"""
ctrlpanel.ControlPanel.__init__(
self, parent, overlayList, displayCtx, ortho)
self.__ortho = ortho
# A ref to the currently selected
# (compatible) overlay is kept here.
# The __extraXform attribute is used
# to store a FLIRT transform if the
# user has loaded one. This 'extra'
# matrix is used in place of the
# image voxToWorldMat (i.e. its sform);
# the scale/offset/ rotate transform
# defined by the widgets on this panel
# is still applied.
#
# In the future, I might allow the
# user to load/apply an arbitrary
# (non-FLIRT) transform.
self.__overlay = None
self.__extraXform = None
# When the selected overlay is changed, the
# transform settings for the previously selected
# overlay are cached in this dict, so they can be
# restored if/when the overlay is re-selected.
#
# { overlay : (scales, offsets, rotations, extraXform) }
self.__cachedXforms = {}
scArgs = {
'value' : 0,
'minValue' : 0.001,
'maxValue' : 3,
'style' : fslider.SSP_NO_LIMITS
}
offArgs = {
'value' : 0,
'minValue' : -250,
'maxValue' : 250,
'style' : fslider.SSP_NO_LIMITS
}
rotArgs = {
'value' : 0,
'minValue' : -180,
'maxValue' : 180,
'style' : 0
}
# rotate about the centre of the image,
# or the current world location
centreOpts = ['volume', 'cursor']
centreLabels = [strings.labels[self, 'centre.options'][o]
for o in centreOpts]
self.__overlayName = wx.StaticText(self)
self.__dsWarning = dswarning.DisplaySpaceWarning(
self,
overlayList,
displayCtx,
self.frame,
strings.labels[self, 'dsWarning'],
'overlay',
'world')
self.__xscale = fslider.SliderSpinPanel(self, label='X', **scArgs)
self.__yscale = fslider.SliderSpinPanel(self, label='Y', **scArgs)
self.__zscale = fslider.SliderSpinPanel(self, label='Z', **scArgs)
self.__xoffset = fslider.SliderSpinPanel(self, label='X', **offArgs)
self.__yoffset = fslider.SliderSpinPanel(self, label='Y', **offArgs)
self.__zoffset = fslider.SliderSpinPanel(self, label='Z', **offArgs)
self.__xrotate = fslider.SliderSpinPanel(self, label='X', **rotArgs)
self.__yrotate = fslider.SliderSpinPanel(self, label='Y', **rotArgs)
self.__zrotate = fslider.SliderSpinPanel(self, label='Z', **rotArgs)
self.__centre = wx.Choice(self)
self.__scaleLabel = wx.StaticText(self)
self.__offsetLabel = wx.StaticText(self)
self.__rotateLabel = wx.StaticText(self)
self.__centreLabel = wx.StaticText(self)
self.__oldXformLabel = wx.StaticText(self)
self.__oldXform = wx.StaticText(self)
self.__newXformLabel = wx.StaticText(self)
self.__newXform = wx.StaticText(self)
self.__apply = wx.Button(self)
self.__reset = wx.Button(self)
self.__loadFlirt = wx.Button(self)
self.__saveFlirt = wx.Button(self)
self.__cancel = wx.Button(self)
self.__overlayName .SetLabel(strings.labels[self, 'noOverlay'])
self.__scaleLabel .SetLabel(strings.labels[self, 'scale'])
self.__offsetLabel .SetLabel(strings.labels[self, 'offset'])
self.__rotateLabel .SetLabel(strings.labels[self, 'rotate'])
self.__centreLabel .SetLabel(strings.labels[self, 'centre'])
self.__apply .SetLabel(strings.labels[self, 'apply'])
self.__reset .SetLabel(strings.labels[self, 'reset'])
self.__loadFlirt .SetLabel(strings.labels[self, 'loadFlirt'])
self.__saveFlirt .SetLabel(strings.labels[self, 'saveFlirt'])
self.__cancel .SetLabel(strings.labels[self, 'cancel'])
self.__oldXformLabel.SetLabel(strings.labels[self, 'oldXform'])
self.__newXformLabel.SetLabel(strings.labels[self, 'newXform'])
self.__centre.Set(centreLabels)
self.__centreOpts = centreOpts
# Populate the xform labels with a
# dummy xform, so an appropriate
# minimum size will get calculated
# below
self.__formatXform(np.eye(4), self.__oldXform)
self.__formatXform(np.eye(4), self.__newXform)
self.__primarySizer = wx.BoxSizer(wx.VERTICAL)
self.__secondarySizer = wx.BoxSizer(wx.HORIZONTAL)
self.__controlSizer = wx.BoxSizer(wx.VERTICAL)
self.__xformSizer = wx.BoxSizer(wx.VERTICAL)
self.__buttonSizer = wx.BoxSizer(wx.HORIZONTAL)
self.__primarySizer .Add((1, 10), flag=wx.EXPAND)
self.__primarySizer .Add(self.__overlayName, flag=wx.CENTRE)
self.__primarySizer .Add(self.__dsWarning, flag=wx.CENTRE)
self.__primarySizer .Add((1, 10), flag=wx.EXPAND)
self.__primarySizer .Add(self.__secondarySizer)
self.__primarySizer .Add((1, 10), flag=wx.EXPAND)
self.__primarySizer .Add(self.__buttonSizer, flag=wx.EXPAND)
self.__primarySizer .Add((1, 10), flag=wx.EXPAND)
self.__secondarySizer.Add((10, 1), flag=wx.EXPAND)
self.__secondarySizer.Add(self.__controlSizer)
self.__secondarySizer.Add((10, 1), flag=wx.EXPAND)
self.__secondarySizer.Add(self.__xformSizer, flag=wx.EXPAND)
self.__secondarySizer.Add((10, 1), flag=wx.EXPAND)
self.__controlSizer.Add(self.__scaleLabel)
self.__controlSizer.Add(self.__xscale)
self.__controlSizer.Add(self.__yscale)
self.__controlSizer.Add(self.__zscale)
self.__controlSizer.Add(self.__offsetLabel)
self.__controlSizer.Add(self.__xoffset)
self.__controlSizer.Add(self.__yoffset)
self.__controlSizer.Add(self.__zoffset)
self.__controlSizer.Add(self.__rotateLabel)
self.__controlSizer.Add(self.__xrotate)
self.__controlSizer.Add(self.__yrotate)
self.__controlSizer.Add(self.__zrotate)
self.__controlSizer.Add(self.__centreLabel)
self.__controlSizer.Add(self.__centre)
self.__xformSizer.Add((1, 1), flag=wx.EXPAND, proportion=1)
self.__xformSizer.Add(self.__oldXformLabel)
self.__xformSizer.Add(self.__oldXform)
self.__xformSizer.Add((1, 1), flag=wx.EXPAND, proportion=1)
self.__xformSizer.Add(self.__newXformLabel)
self.__xformSizer.Add(self.__newXform)
self.__xformSizer.Add((1, 1), flag=wx.EXPAND, proportion=1)
self.__buttonSizer.Add((10, 1), flag=wx.EXPAND, proportion=1)
self.__buttonSizer.Add(self.__apply, flag=wx.EXPAND)
self.__buttonSizer.Add((10, 1), flag=wx.EXPAND)
self.__buttonSizer.Add(self.__reset, flag=wx.EXPAND)
self.__buttonSizer.Add((10, 1), flag=wx.EXPAND)
self.__buttonSizer.Add(self.__loadFlirt, flag=wx.EXPAND)
self.__buttonSizer.Add((10, 1), flag=wx.EXPAND)
self.__buttonSizer.Add(self.__saveFlirt, flag=wx.EXPAND)
self.__buttonSizer.Add((10, 1), flag=wx.EXPAND)
self.__buttonSizer.Add(self.__cancel, flag=wx.EXPAND)
self.__buttonSizer.Add((10, 1), flag=wx.EXPAND, proportion=1)
self.SetSizer(self.__primarySizer)
self.SetMinSize(self.__primarySizer.GetMinSize())
self.__xscale .Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__yscale .Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__zscale .Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__xoffset.Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__yoffset.Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__zoffset.Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__xrotate.Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__yrotate.Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__zrotate.Bind(fslider.EVT_SSP_VALUE, self.__xformChanged)
self.__centre .Bind(wx.EVT_CHOICE, self.__xformChanged)
self.__apply .Bind(wx.EVT_BUTTON, self.__onApply)
self.__reset .Bind(wx.EVT_BUTTON, self.__onReset)
self.__loadFlirt.Bind(wx.EVT_BUTTON, self.__onLoadFlirt)
self.__saveFlirt.Bind(wx.EVT_BUTTON, self.__onSaveFlirt)
self.__cancel .Bind(wx.EVT_BUTTON, self.__onCancel)
displayCtx .addListener('selectedOverlay',
self.name,
self.__selectedOverlayChanged)
overlayList.addListener('overlays',
self.name,
self.__selectedOverlayChanged)
self.__selectedOverlayChanged()
[docs] def destroy(self):
"""Must be called when this ``EditTransformPanel`` is no longer
needed. Removes listeners and cleans up references.
"""
self.__deregisterOverlay()
displayCtx = self.displayCtx
overlayList = self.overlayList
dsWarning = self.__dsWarning
displayCtx .removeListener('selectedOverlay', self.name)
overlayList.removeListener('overlays', self.name)
self.__ortho = None
self.__cachedXforms = None
self.__dsWarning = None
dsWarning.destroy()
ctrlpanel.ControlPanel.destroy(self)
def __registerOverlay(self, overlay):
"""Called by :meth:`__selectedOverlayChanged`. Stores a reference
to the given ``overlay``.
"""
self.__overlay = overlay
display = self.displayCtx.getDisplay(overlay)
display.addListener('name', self.name, self.__overlayNameChanged)
self.__overlayNameChanged()
def __deregisterOverlay(self):
"""Called by :meth:`__selectedOverlayChanged`. Clears references
to the most recently registered overlay.
"""
if self.__overlay is None:
return
overlay = self.__overlay
scales, offsets, rotations, centre = self.__getCurrentXformComponents()
extra = self.__extraXform
self.__cachedXforms[overlay] = (scales, offsets, rotations,
centre, extra)
self.__overlay = None
self.__extraXform = None
self.__overlayName.SetLabel(strings.labels[self, 'noOverlay'])
# Catch errors in case the
# overlay has been removed
# from the list
try:
display = self.displayCtx.getDisplay(overlay)
display.removeListener('name', self.name)
except displaycontext.InvalidOverlayError:
pass
def __overlayNameChanged(self, *a):
"""Called when the :attr:`.Display.name` of the currently selected
overlay changes. Updates the name label.
"""
display = self.displayCtx.getDisplay(self.__overlay)
label = strings.labels[self, 'overlayName'].format(display.name)
self.__overlayName.SetLabel(label)
def __selectedOverlayChanged(self, *a):
"""Called when the :attr:`.DisplayContext.selectedOverlay` or
:attr:`.OverlayList.overlays` properties change. If the newly
selected overlay is an :class:`.Image`, it is registered, and
the transform widgets reset.
"""
overlay = self.displayCtx.getSelectedOverlay()
if overlay is self.__overlay:
return
self.__deregisterOverlay()
enabled = isinstance(overlay, fslimage.Image)
self.Enable(enabled)
if not enabled:
return
self.__registerOverlay(overlay)
xform = overlay.voxToWorldMat
scales, offsets, rotations, centre, extra = self.__cachedXforms.get(
overlay, ((1, 1, 1), (0, 0, 0), (0, 0, 0), 'volume', None))
self.__extraXform = extra
self.__formatXform(xform, self.__oldXform)
# TODO Set limits based on image size?
self.__xscale .SetValue(scales[ 0])
self.__yscale .SetValue(scales[ 1])
self.__zscale .SetValue(scales[ 2])
self.__xoffset.SetValue(offsets[ 0])
self.__yoffset.SetValue(offsets[ 1])
self.__zoffset.SetValue(offsets[ 2])
self.__xrotate.SetValue(rotations[0])
self.__yrotate.SetValue(rotations[1])
self.__zrotate.SetValue(rotations[2])
self.__centre .SetSelection(self.__centreOpts.index(centre))
self.__xformChanged()
def __formatXform(self, xform, ctrl):
"""Format the given ``xform`` on the given ``wx.StaticText``
``ctrl``.
"""
text = ''
for rowi in range(xform.shape[0]):
for coli in range(xform.shape[1]):
text = text + '{: 9.2f} '.format(xform[rowi, coli])
text = text + '\n'
ctrl.SetLabel(text)
def __getCurrentXformComponents(self):
"""Returns the components of the transformation matrix defined
by the scale, offset and rotation widgets.
"""
scales = [self.__xscale .GetValue(),
self.__yscale .GetValue(),
self.__zscale .GetValue()]
offsets = [self.__xoffset.GetValue(),
self.__yoffset.GetValue(),
self.__zoffset.GetValue()]
rotations = [self.__xrotate.GetValue(),
self.__yrotate.GetValue(),
self.__zrotate.GetValue()]
centre = self.__centreOpts[self.__centre.GetSelection()]
return scales, offsets, rotations, centre
def __getCurrentXform(self):
"""Returns the current transformation matrix defined by the scale,
offset, and rotation widgets.
"""
scales, offsets, rotations, centre = self.__getCurrentXformComponents()
rotations = [r * np.pi / 180 for r in rotations]
if centre == 'volume':
# We need to figure out the centre
# of the image in world coordinates
# to define the origin of rotation.
shape = self.__overlay.shape
lo, hi = affine.axisBounds(shape, self.__overlay.voxToWorldMat)
origin = [l + (h - l) / 2.0 for h, l in zip(hi, lo)]
else:
origin = self.displayCtx.worldLocation
return affine.compose(scales, offsets, rotations, origin)
def __xformChanged(self, ev=None):
"""Called when any of the scale, offset, or rotate widgets are
modified. Updates the :attr:`.NiftiOpts.displayXform` for the
overlay currently being edited.
"""
if self.__overlay is None:
return
overlay = self.__overlay
opts = self.displayCtx.getOpts(overlay)
if self.__extraXform is None: v2wXform = overlay.voxToWorldMat
else: v2wXform = self.__extraXform
xform = self.__getCurrentXform()
xform = affine.concat(xform, v2wXform)
self.__formatXform(xform, self.__newXform)
# The NiftiOpts.displayXform is applied on
# top of the image voxToWorldMat. But our
# xform here has been constructed to replace
# the voxToWorldMat entirely. So we include
# a worldToVoxMat transform to trick the
# NiftiOpts code.
opts.displayXform = affine.concat(xform, overlay.worldToVoxMat)
def __onApply(self, ev):
"""Called when the *Apply* button is pushed. Sets the
``voxToWorldMat`` attribute of the :class:`.Image` instance being
transformed.
"""
overlay = self.__overlay
if overlay is None:
return
if self.__extraXform is None: v2wXform = overlay.voxToWorldMat
else: v2wXform = self.__extraXform
newXform = self.__getCurrentXform()
opts = self.displayCtx.getOpts(overlay)
xform = affine.concat(newXform, v2wXform)
with props.suppress(opts, 'displayXform'):
opts.displayXform = np.eye(4)
overlay.voxToWorldMat = xform
# Reset the interface, and clear any
# cached transform for this overlay
self.__deregisterOverlay()
self.__cachedXforms.pop(overlay, None)
self.__selectedOverlayChanged()
def __resetAllOverlays(self):
"""Resets the :attr:`.NiftiOpts.displayXform` matrix for
all overlays that have been modified, and clears the internal
transformation matrix cache.
This method is called by :meth:`__onReset` and :meth:`__onCancel`.
"""
reset = list(self.__cachedXforms.keys())
if self.__overlay is not None:
reset.append(self.__overlay)
self.__deregisterOverlay()
self.__cachedXforms = {}
for overlay in reset:
try:
opts = self.displayCtx.getOpts(overlay)
opts.displayXform = np.eye(4)
# In cas overlay has been removed
except displaycontext.InvalidOverlayError:
pass
def __onReset(self, ev=None):
"""Called when the *Reset* button is pushed. Resets the
transformation.
"""
self.__resetAllOverlays()
self.__selectedOverlayChanged()
def __onLoadFlirt(self, ev):
"""Called when the user clicks the *Load FLIRT transform* button.
Prompts the user to choose a FLIRT transformation matrix and reference
image, and then applies the transformation.
"""
overlay = self.__overlay
if overlay is None:
return
overlayList = self.overlayList
displayCtx = self.displayCtx
affType, matFile, refFile = applyflirtxfm.promptForFlirtFiles(
self,
overlay,
overlayList,
displayCtx)
if all((affType is None, matFile is None, refFile is None)):
return
if affType == 'flirt':
xform = applyflirtxfm.calculateTransform(
overlay,
overlayList,
displayCtx,
matFile,
refFile)
elif affType == 'v2w':
xform = np.loadtxt(matFile)
self.__extraXform = xform
self.__xformChanged()
def __onSaveFlirt(self, ev):
"""Called when the user clicks the *Save FLIRT* button. Saves the
current transformation to a FLIRT matrix file.
"""
overlay = self.__overlay
if overlay is None:
return
overlayList = self.overlayList
displayCtx = self.displayCtx
affType, matFile, refFile = applyflirtxfm.promptForFlirtFiles(
self,
overlay,
overlayList,
displayCtx,
save=True)
if all((affType is None, matFile is None, refFile is None)):
return
if self.__extraXform is None: v2wXform = overlay.voxToWorldMat
else: v2wXform = self.__extraXform
newXform = self.__getCurrentXform()
v2wXform = affine.concat(newXform, v2wXform)
if affType == 'flirt':
xform = saveflirtxfm.calculateTransform(
overlay,
overlayList,
displayCtx,
refFile,
srcXform=v2wXform)
elif affType == 'v2w':
xform = v2wXform
try:
np.savetxt(matFile, xform, fmt='%0.10f')
except Exception as e:
log.warning('Error saving FLIRT matrix: %s', e)
wx.MessageDialog(
self,
strings.messages[self, 'saveFlirt.error'].format(str(e)),
style=wx.ICON_ERROR).ShowModal()
def __onCancel(self, ev=None):
"""Called when the *Cancel* button is pushed. Resets the
:attr:`.NiftiOpts.displayXform` attribute of the overlay being
transformed, and then calls
:meth:`.OrthoPanel.toggleEditTransformPanel` to close this panel.
"""
self.__resetAllOverlays()
idle.idle(self.__ortho.togglePanel, type(self))