Source code for fsleyes.plugins.tools.sampleline

#
# sampleline.py - The SampleLineAction
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`SampleLineAction` class, which allows
the user to open a :class:`SampleLinePanel`. The ``SampleLinePanel`` is
a FSLeyes control which allows the user to draw a line on the canvases of
an :class:`.OrthoPanel`, and plot the data along that line from the currently
selected :class:`.Image` overlay.
"""

import                  os
import                  copy

import numpy         as np
import scipy.ndimage as ndimage
import                  wx

import fsl.data.image                             as fslimage
import fsl.utils.settings                         as fslsettings
import fsl.transform.affine                       as affine
import fsleyes_widgets.widgetlist                 as widgetlist
import fsleyes_widgets.utils.status               as status
import fsleyes_props                              as props
import fsleyes.strings                            as strings
import fsleyes.tooltips                           as tooltips
import fsleyes.icons                              as fslicons
import fsleyes.actions                            as actions
import fsleyes.actions.screenshot                 as screenshot
import fsleyes.views.orthopanel                   as orthopanel
import fsleyes.controls.controlpanel              as ctrlpanel
import fsleyes.plotting                           as plotting
import fsleyes.plotting.plotcanvas                as plotcanvas
import fsleyes.plugins.profiles.samplelineprofile as samplelineprofile


[docs]def sampleAlongLine(data, start, end, resolution, order): """Samples from ``data``, along a line between ``start`` and ``end``. :arg data: 3D array :arg start: Start coordinate :arg end: End coordinate :arg resolution: Number of points to sample :arg order: Interpolation (see ``scipy.ndimage.map_coordinates``) :returns: Tuple containing: - 1D Numpy array containing the sampled values - ``(3, N)`` numpy array containing the coordinates for each sample """ start = list(start) end = list(end) shape = data.shape coords = np.linspace(start, end, resolution).T # map_coordinates doesn't take # kindly to dims of length 1 data = data.squeeze() if any(s == 1 for s in shape): drop = [i for i, s in enumerate(shape) if s == 1] mapcoords = np.delete(coords, drop, axis=0) else: mapcoords = coords # multi-channel data? if len(data.dtype) > 1: channels = [] for chan in data.dtype.fields.keys(): channels.append(data[chan]) data = channels else: data = [data] ys = [] for arr in data: ys.append(ndimage.map_coordinates(arr, mapcoords, order=order, output=np.float64)) # For multi channel data, we currently # just take the mean across all # channels, but this might change in # the future (if there is any need). if len(ys) > 1: y = np.mean(ys, axis=0) else: y = ys[0] return y, coords
[docs]class SampleLineAction(actions.ToggleControlPanelAction): """The ``SampleLineAction`` simply shows/hides a :class:`SampleLinePanel`. """
[docs] @staticmethod def supportedViews(): """The ``SampleLineAction`` is restricted for use with :class:`.OrthoPanel` views. """ return [orthopanel.OrthoPanel]
[docs] def __init__(self, overlayList, displayCtx, ortho): """Create a ``SampleLineAction``. """ super().__init__(overlayList, displayCtx, ortho, SampleLinePanel) self.__ortho = ortho self.__name = '{}_{}'.format(type(self).__name__, id(self)) 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) def __run(self): """Open/close a :class:`SampleLinePanel`. """ self.viewPanel.togglePanel(SampleLinePanel)
[docs]class SampleLineDataSeries(plotting.DataSeries): """The ``SampleLineDataSeries`` represents data that is sampled along a straight line through a 3D volume from an :class:`.Image` overlay. ``SampleLineDataSeries`` objects are created by the :class:`SampleLinePanel`. """ interp = props.Choice((0, 1, 2, 3), default=0) """How to interpolate the sampled data when it is plotted. The value is used directly as the ``order`` parameter to the ``scipy.ndimage.map_coordinates`` function. """ resolution = props.Int(minval=2, maxval=200, default=100, clamped=False) """The number of points (uniformly spaced) to sample along the line. """ normalise = props.Choice(('none', 'y', 'x', 'xy')) """Whether to normalise all plotted data along the x, or y, or both, axes. """
[docs] def __init__(self, overlay, overlayList, displayCtx, plotCanvas, index, start, end): """Create a ``SampleLineDataSeries``. :arg overlay: The :class:`.Image` overlay to sample from :arg overlayList: The :class:`.OverlayList` :arg displayCtx: A :class:`.DisplayContext` instance :arg plotCanvas: The :class:`.PlotCanvas` that is plotting this ``DataSeries``. :arg index: Volume index, for images with more than 3 dimensions (see :meth:`.NiftiOpts.index`) :arg start: Start of sampling line, in voxel coordinates :arg end: End of sampling line, in voxel coordinates """ plotting.DataSeries.__init__( self, overlay, overlayList, displayCtx, plotCanvas) self.__index = index self.__start = start self.__end = end self.__coords = None self.addListener('resolution', self.name, self.__refreshData) self.addListener('interp', self.name, self.__refreshData) self.addListener('normalise', self.name, self.__refreshData) self.label = ('{}: [{:.2f} {:.2f} {:.2f}] -> ' '[{:.2f} {:.2f} {:.2f}]'.format( overlay.name, *start, *end))
@property def coords(self): """Return a ``(3, n)`` array containing the voxel coordinates of each sampled point for the most recently generated data, or ``None`` if no data has been sampled yet. """ return self.__coords def __refreshData(self, *a): """Called when :attr:`resolution`, :attr:`interp` or :attr:`normalise` change. Re-samples the data from the image. """ overlay = self.overlay opts = self.displayCtx.getOpts(overlay) data = overlay[self.__index] resolution = self.resolution order = self.interp normalisex = 'x' in self.normalise normalisey = 'y' in self.normalise start = self.__start end = self.__end y, coords = sampleAlongLine(data, start, end, resolution, order) if normalisey: y = (y - y.min()) / (y.max() - y.min()) if normalisex: xmax = 1 else: wstart = opts.transformCoords(start, 'voxel', 'world') wend = opts.transformCoords(end, 'voxel', 'world') xmax = affine.veclength(wstart - wend)[0] x = np.linspace(0, xmax, resolution) self.__coords = coords self.setData(x, y)
[docs]class SampleLinePanel(ctrlpanel.ControlPanel): """The ``SampleLinePanel`` is a FSLeyes control which can be used in conjunction with an :class:`.OrthoPanel` view. It allows the user to draw a line on an ``OrthoPanel`` canvas, and plot the voxel intensities, along that line, from the currently selected :class:`.Image` overlay. The :class:`.SampleLineProfile` class implements user interaction, and the :class:`.PlotCanvas` class is used for plotting. """ # Used to synchronise between GUI widgets # and SampleLineDataSeries property values # The GUI widgets are bound to the most # recently added SampleLineDataSeries # instance interp = copy.copy(SampleLineDataSeries.interp) resolution = copy.copy(SampleLineDataSeries.resolution) normalise = copy.copy(SampleLineDataSeries.normalise) colour = copy.copy(plotting.DataSeries.colour) lineWidth = copy.copy(plotting.DataSeries.lineWidth) lineStyle = copy.copy(plotting.DataSeries.lineStyle)
[docs] @staticmethod def supportedViews(): """Overrides :meth:`.ControlMixin.supportedViews`. The ``SampleLinePanel`` is only intended to be added to :class:`.OrthoPanel` views. """ return [orthopanel.OrthoPanel]
[docs] @staticmethod def ignoreControl(): """Tells FSLeyes not to add the ``SampleLinePanel`` as an option to the Settings menu. Instead, the :class:`SampleLineAction` is added as an option to the Tools menu. """ return True
[docs] @staticmethod def profileCls(): """Returns the :class:`.SampleLineProfile` class, which needs to be activated in conjunction with the ``SampleLinePanel``. """ return samplelineprofile.SampleLineProfile
[docs] @staticmethod def defaultLayout(): """Returns a dictionary containing layout settings to be passed to :class:`.ViewPanel.togglePanel`. """ return {'floatPane' : True, 'floatOnly' : True}
[docs] def __init__(self, parent, overlayList, displayCtx, ortho): """Create a ``SampleLinePanel``. :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) profile = ortho.currentProfile # plot which displays the sampled data canvas = plotcanvas.PlotCanvas(self, drawFunc=self.__draw) canvas.canvas.SetMinSize((-1, 150)) self.__ortho = ortho self.__profile = profile self.__canvas = canvas self.__current = None # initial settings self.colour = '#000050' self.lineWidth = 2 canvas.legend = False # Controls which allow the user to select # interpolation/resolution, line colour, etc widgets = widgetlist.WidgetList(self) legend = props.makeWidget( widgets, canvas, 'legend', labels=strings.properties['PlotCanvas.legend']) interp = props.makeWidget( widgets, self, 'interp', labels=strings.choices[self, 'interp']) resolution = props.makeWidget( widgets, self, 'resolution', slider=True, spin=True, showLimits=False) normalise = props.makeWidget( widgets, self, 'normalise', labels=strings.choices[self, 'normalise']) colour = props.makeWidget(widgets, self, 'colour') lineWidth = props.makeWidget(widgets, self, 'lineWidth') lineStyle = props.makeWidget( widgets, self, 'lineStyle', labels=strings.choices['DataSeries.lineStyle']) widgets.AddWidget(legend, strings.labels[self, 'legend']) widgets.AddWidget(interp, strings.labels[self, 'interp']) widgets.AddWidget(resolution, strings.labels[self, 'resolution']) widgets.AddWidget(normalise, strings.labels[self, 'normalise']) widgets.AddWidget(colour, strings.labels[self, 'colour']) widgets.AddWidget(lineWidth, strings.labels[self, 'lineWidth']) widgets.AddWidget(lineStyle, strings.labels[self, 'lineStyle']) # Controls allowing the user to add/remove # sample lines from the plot to save the # data to a file, and to save a screenshot # of the plot ctrlSizer = wx.BoxSizer(wx.HORIZONTAL) screenshot = actions.ActionButton( 'screenshot', icon=fslicons.findImageFile('camera24'), tooltip=tooltips.actions['PlotPanel.screenshot']) export = actions.ActionButton( 'export', icon=fslicons.findImageFile('exportDataSeries24'), tooltip=tooltips.actions['PlotPanel.exportDataSeries']) add = actions.ActionButton( 'addDataSeries', icon=fslicons.findImageFile('add24'), tooltip=tooltips.actions[self, 'addDataSeries']) remove = actions.ActionButton( 'removeDataSeries', icon=fslicons.findImageFile('remove24'), tooltip=tooltips.actions[self, 'removeDataSeries']) screenshot = props.buildGUI(self, self, screenshot) export = props.buildGUI(self, self, export) add = props.buildGUI(self, self, add) remove = props.buildGUI(self, self, remove) ctrlSizer.Add(screenshot, flag=wx.EXPAND) ctrlSizer.Add(export, flag=wx.EXPAND) ctrlSizer.Add(add, flag=wx.EXPAND) ctrlSizer.Add(remove, flag=wx.EXPAND) # Labels which show the start and end # coordinates of the sample line in voxel # ("v") and world ("w") coordinates, and # the line length in [units] (probably mm) vfromlbl = wx.StaticText(self) vtolbl = wx.StaticText(self) wfromlbl = wx.StaticText(self) wtolbl = wx.StaticText(self) vfromval = wx.StaticText(self) vtoval = wx.StaticText(self) wfromval = wx.StaticText(self) wtoval = wx.StaticText(self) lenlbl = wx.StaticText(self) lenval = wx.StaticText(self) self.__vfromval = vfromval self.__vtoval = vtoval self.__wfromval = wfromval self.__wtoval = wtoval self.__lenval = lenval vfromlbl.SetLabel(strings.labels[self, 'voxelfrom']) vtolbl .SetLabel(strings.labels[self, 'voxelto']) wfromlbl.SetLabel(strings.labels[self, 'worldfrom']) wtolbl .SetLabel(strings.labels[self, 'worldto']) lenlbl .SetLabel(strings.labels[self, 'length']) infoSizer = wx.FlexGridSizer(3, 4, 5, 5) infoSizer.AddGrowableCol(1) infoSizer.AddGrowableCol(3) infoSizer.Add(vfromlbl) infoSizer.Add(vfromval) infoSizer.Add(vtolbl) infoSizer.Add(vtoval) infoSizer.Add(wfromlbl) infoSizer.Add(wfromval) infoSizer.Add(wtolbl) infoSizer.Add(wtoval) infoSizer.Add(lenlbl) infoSizer.Add(lenval) infoSizer.Add((1, 1)) infoSizer.Add((1, 1)) mainSizer = wx.BoxSizer(wx.VERTICAL) mainSizer.Add(ctrlSizer, flag=wx.EXPAND) mainSizer.Add(widgets, flag=wx.EXPAND) mainSizer.Add(infoSizer, flag=wx.EXPAND) mainSizer.Add(canvas.canvas, flag=wx.EXPAND, proportion=1) self.SetSizer(mainSizer) self.Layout() profile.registerHandler('LeftMouseDown', self.name, self.__onMouseDown) profile.registerHandler('LeftMouseDrag', self.name, self.__onMouseDrag) profile.registerHandler('LeftMouseUp', self.name, self.__onMouseUp) self.addListener('interp', self.name, canvas.asyncDraw) self.addListener('resolution', self.name, canvas.asyncDraw) self.addListener('normalise', self.name, canvas.asyncDraw) self.addListener('colour', self.name, canvas.asyncDraw) self.addListener('lineWidth', self.name, canvas.asyncDraw) self.addListener('lineStyle', self.name, canvas.asyncDraw)
[docs] def destroy(self): """Called when this ``SampleLinePanel`` is no longer needed. Clears references, and calls :meth:`.ControlPanel.destroy`. """ super().destroy() self.__canvas.destroy() self.__ortho = None self.__profile = None self.__canvas = None self.__current = None
@property def canvas(self): """Return a reference to the :class:`.PlotCanvas` that is used to plot sampled data from lines that the user has drawn. """ return self.__canvas
[docs] @actions.action def addDataSeries(self): """Holds/persists the most recently sampled line to the plot. """ if self.__current is not None: self.__canvas.dataSeries.append(self.__current) self.__bindToDataSeries(self.__current, False) self.__current = None
[docs] @actions.action def removeDataSeries(self): """Removes the most recently held/persisted line from the plot. """ canvas = self.__canvas if len(canvas.dataSeries) > 0: canvas.dataSeries.pop()
[docs] @actions.action def export(self): """Prompts the user to save the sampled data to a file. """ # only one series can be saved - the # user is asked to select which one if self.__current is None: series = [] else: series = [self.__current] series.extend(reversed(self.__canvas.dataSeries)) if len(series) == 0: return # ask what series, and whether they # want coordinates of sample points parent = self.GetParent() dlg = ExportSampledDataDialog(parent, series) if dlg.ShowModal() != wx.ID_OK: return series = dlg.GetSeries() saveCoords = dlg.GetCoordinates() # ask where to save the file fromDir = fslsettings.read('loadSaveOverlayDir', os.getcwd()) msg = strings.titles[self, 'savefile'] dlg = wx.FileDialog(parent, message=msg, defaultDir=fromDir, defaultFile='sample.txt', style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) if dlg.ShowModal() != wx.ID_OK: return filename = dlg.GetPath() # prepare the data if saveCoords == 'none': data = series.getData()[1] else: image = series.overlay opts = self.displayCtx.getOpts(image) samples = series.getData()[1] coords = series.coords.T data = np.zeros((len(samples), 4)) data[:, 0] = samples if saveCoords == 'voxel': data[:, 1:] = coords else: data[:, 1:] = opts.transformCoords(coords, 'voxel', 'world') # save the file errTitle = strings.titles[ self, 'exportError'] errMsg = strings.messages[self, 'exportError'] with status.reportIfError(errTitle, errMsg): np.savetxt(filename, data, fmt='%0.8f')
[docs] @actions.action def screenshot(self): """Creates and runs a :class:`.ScreenshotAction`, which propmts the user to save the plot to a file. """ screenshot.ScreenshotAction(self.overlayList, self.displayCtx, self.__canvas)()
def __bindToDataSeries(self, ds, bind=True): """Binds/unbinds the GUI widgets (:attr:`colour`, :attr:`resolution`), etc to/from the given :class:`SampleLineDataSeries` instance. """ ds.bindProps('colour', self, unbind=not bind) ds.bindProps('lineWidth', self, unbind=not bind) ds.bindProps('lineStyle', self, unbind=not bind) ds.bindProps('resolution', self, unbind=not bind) ds.bindProps('interp', self, unbind=not bind) ds.bindProps('normalise', self, unbind=not bind) def __updateInfo(self): """Called while the mouse is being dragged. Updates the information about the sampling line (e.g. start/end voxel coordinates) that is displayed at the top of the ``SampleLinePanel``. """ start = self.__profile.sampleStart end = self.__profile.sampleEnd if start is None or end is None: self.__vfromval.SetLabel('') self.__vtoval .SetLabel('') self.__wfromval.SetLabel('') self.__wtoval .SetLabel('') self.__lenval .SetLabel('') return overlay = self.displayCtx.getSelectedOverlay() opts = self.displayCtx.getOpts(overlay) image = opts.referenceImage # display world and voxel coordinates if image is not None: opts = self.displayCtx.getOpts(image) units = image.xyzUnits units = strings.nifti.get(('xyz_unit', units), '(unknown units)') ws = opts.transformCoords(start, 'display', 'world') we = opts.transformCoords(end, 'display', 'world') vs = opts.transformCoords(start, 'display', 'voxel') ve = opts.transformCoords(end, 'display', 'voxel') length = affine.veclength(ws - we)[0] vs = '[{:.2f}, {:.2f}, {:.2f}]'.format(*vs) ve = '[{:.2f}, {:.2f}, {:.2f}]'.format(*ve) ws = '[{:.2f}, {:.2f}, {:.2f}]'.format(*ws) we = '[{:.2f}, {:.2f}, {:.2f}]'.format(*we) length = f'{length:.2f} {units}' self.__vfromval.SetLabel(vs) self.__vtoval .SetLabel(ve) self.__wfromval.SetLabel(ws) self.__wtoval .SetLabel(we) self.__lenval .SetLabel(length) def __onMouseDown(self, *a): """Called on mouse down events on an :class:`.OrthoPanel` canvas. Calls :meth:`__updateInfo`. """ self.__updateInfo() def __onMouseDrag(self, *a): """Called on mouse drag events on an :class:`.OrthoPanel` canvas. Calls :meth:`__updateInfo`. """ self.__updateInfo() def __onMouseUp(self, *a): """Called on mouse up events on an :class:`.OrthoPanel` canvas. Samples and plots data from the currently selected overlay along the drawn sample line. """ start = self.__profile.sampleStart end = self.__profile.sampleEnd image = self.displayCtx.getSelectedOverlay() if start is None or end is None: return if image is None or not isinstance(image, fslimage.Image): return if self.__current is not None: self.__bindToDataSeries(self.__current, False) # Round to avoid floating point imprecision opts = self.displayCtx.getOpts(image) vstart = opts.transformCoords(start, 'display', 'voxel').round(6) vend = opts.transformCoords(end, 'display', 'voxel').round(6) series = SampleLineDataSeries(image, self.overlayList, self.displayCtx, self.__canvas, opts.index(), vstart, vend) self.__bindToDataSeries(series) self.__current = series self.__canvas.asyncDraw() def __draw(self): """Passed as the ``drawFunc`` to the :class:`.PlotCanvas`. Calls :meth:`.PlotCanvas.drawDataSeries`. """ if self.__current is None: extras = None else: extras = [self.__current] self.__canvas.drawDataSeries(extraSeries=extras, refresh=True)
[docs]class ExportSampledDataDialog(wx.Dialog): """The ``ExportSampledDataDialog`` is used by the :meth:`SampleLinePanel.export` method to ask the user which sample data they want to save, and whether they want to save the sample point coordinates to file as well as the samples themselves. """
[docs] def __init__(self, parent, series): """Create an ``ExportSampledDataDialog``. :arg parent: ``wx`` parent object :arg series: Sequence of ``SampleLineDataSeries`` instances. Must contain at least one series. If there is more than one series, the user is asked to choose one. """ title = strings.titles[self] coords = strings.choices[self, 'saveCoordinates'] seriesLabels = [s.label for s in series] coordsLabels = list(coords.values()) coords = list(coords.keys()) wx.Dialog.__init__(self, parent, title=title, style=wx.DEFAULT_DIALOG_STYLE) self.__series = series self.__coords = coords self.__coordsChoice = wx.Choice(self, choices=coordsLabels) self.__coordsChoice.SetSelection(0) coordsLabel = wx.StaticText(self) ok = wx.Button(self, id=wx.ID_OK) cancel = wx.Button(self, id=wx.ID_CANCEL) coordsLabel.SetLabel(strings.labels[self, 'coords']) ok .SetLabel(strings.labels[self, 'ok']) cancel .SetLabel(strings.labels[self, 'cancel']) if len(series) > 1: self.__seriesChoice = wx.Choice(self, choices=seriesLabels) self.__seriesChoice.SetSelection(0) seriesLabel = wx.StaticText(self) seriesLabel.SetLabel(strings.labels[self, 'series']) ok.SetDefault() ok .Bind(wx.EVT_BUTTON, self.__onOk) cancel.Bind(wx.EVT_BUTTON, self.__onCancel) buttonSizer = wx.BoxSizer(wx.HORIZONTAL) mainSizer = wx.BoxSizer(wx.VERTICAL) buttonSizer.Add((10, 10), proportion=1) buttonSizer.Add(ok) buttonSizer.Add((10, 10)) buttonSizer.Add(cancel) buttonSizer.Add((10, 10), proportion=1) mainSizer.Add((10, 10), proportion=1) if len(series) > 1: mainSizer.Add(seriesLabel, flag=wx.ALIGN_CENTRE | wx.ALL, border=10) mainSizer.Add((10, 10)) mainSizer.Add(self.__seriesChoice, flag=wx.ALIGN_CENTRE | wx.ALL, border=10) mainSizer.Add((10, 10)) mainSizer.Add(coordsLabel, flag=wx.ALIGN_CENTRE | wx.ALL, border=10) mainSizer.Add((10, 10)) mainSizer.Add(self.__coordsChoice, flag=wx.ALIGN_CENTRE | wx.ALL, border=10) mainSizer.Add((10, 10), proportion=1) mainSizer.Add(buttonSizer, flag=wx.ALIGN_CENTRE | wx.ALL, border=10) mainSizer.Add((10, 10)) self.SetSizer(mainSizer) self.Layout() self.Fit() self.CentreOnParent()
def __onOk(self, ev): """Called when the ok button is pushed. Closes the dialog. """ self.EndModal(wx.ID_OK) def __onCancel(self, ev): """Called when the cancel button is pushed. Closes the dialog. """ self.EndModal(wx.ID_CANCEL)
[docs] def GetCoordinates(self): """Return one of ``'none'``, ``'voxel'``, or ``'world'``, denoting the user's preference for saving coordinates to the file. """ idx = self.__coordsChoice.GetSelection() return self.__coords[idx]
[docs] def GetSeries(self): """Return the :class:`.SampleLineDataSeries` that was selected.""" if len(self.__series) == 1: return self.__series[0] idx = self.__seriesChoice.GetSelection() return self.__series[idx]