Source code for fsleyes.controls.edittransformpanel
#
# edittransformpanel.py - The EditTransformPanel class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`EditTransformPanel` 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.controls.controlpanel as ctrlpanel
import fsleyes.displaycontext as displaycontext
import fsleyes.strings as strings
import fsleyes.actions.applyflirtxfm as applyflirtxfm
import fsleyes.actions.saveflirtxfm as saveflirtxfm
import fsleyes.controls.displayspacewarning as dswarning
log = logging.getLogger(__name__)
[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] def __init__(self, parent, overlayList, displayCtx, frame, ortho):
"""Create an ``EditTransformPanel``.
:arg parent: The :mod:`wx` parent object.
:arg overlayList: The :class:`.OverlayList` instance.
:arg displayCtx: The :class:`.DisplayContext` instance.
:arg frame: The :class:`.FSLeyesFrame` instance.
:arg ortho: The :class:`.OrthoPanel` instance.
"""
ctrlpanel.ControlPanel.__init__(
self, parent, overlayList, displayCtx, frame)
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,
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)
[docs] @staticmethod
def supportedViews():
"""Overrides :meth:`.ControlMixin.supportedViews`. The
``EditTransformPanel`` is only intended to be added to
:class:`.OrthoPanel` views.
"""
from fsleyes.views.orthopanel import OrthoPanel
return [OrthoPanel]
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.warn('Error saving FLIRT matrix: {}'.format(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.toggleEditTransformPanel)