Commit ed1893bc authored by Paul McCarthy's avatar Paul McCarthy 🚵
Browse files

Merge branch 'enh/loc-labels' into 'master'

Enh/loc labels

See merge request fsl/fsleyes/fsleyes!314
parents d71df6fa 9e702521
......@@ -25,6 +25,11 @@ Added
make regions with high intensity more transparent (!311).
* New ``--index`` command-line option for ``volume`` overlays, allowing
the indices for all non-spatial dimensions to be specified (!304).
* New option to display the coordinates for the current location on the
canvases of an ortho view (available on the command-line via
``--showLocation``) (!314).
* New option to control the location cursor width on ortho/lightbox views
(available on the command-line via ``--cursorWidth``) (!314).
Changed
......
......@@ -118,6 +118,7 @@ class CanvasSettingsPanel(ctrlpanel.SettingsPanel):
sceneOptsProps = collections.OrderedDict((
('showCursor', props.Widget('showCursor')),
('cursorWidth', props.Widget('cursorWidth', spin=False)),
('bgColour', props.Widget('bgColour')),
('fgColour', props.Widget('fgColour')),
('cursorColour', props.Widget('cursorColour')),
......@@ -164,12 +165,15 @@ class CanvasSettingsPanel(ctrlpanel.SettingsPanel):
('layout',
props.Widget('layout',
labels=strings.choices['OrthoOpts.layout'])),
('zoom', props.Widget('zoom', showLimits=False)),
('showLabels', props.Widget('showLabels')),
('cursorGap', props.Widget('cursorGap')),
('showXCanvas', props.Widget('showXCanvas')),
('showYCanvas', props.Widget('showYCanvas')),
('showZCanvas', props.Widget('showZCanvas'))))
('zoom', props.Widget('zoom', showLimits=False)),
('showLabels', props.Widget('showLabels')),
('showLocation',
props.Widget('showLocation',
labels=strings.choices['OrthoOpts.showLocation'])),
('cursorGap', props.Widget('cursorGap')),
('showXCanvas', props.Widget('showXCanvas')),
('showYCanvas', props.Widget('showYCanvas')),
('showZCanvas', props.Widget('showZCanvas'))))
lightBoxOptsProps = collections.OrderedDict((
('zax',
......
......@@ -71,6 +71,9 @@ class SliceCanvasOpts(props.HasProperties):
voxel).
"""
cursorWidth = props.Real(minval=1, default=1, maxval=10)
"""Width in pixels (approx) of the location cursor. """
zax = props.Choice((0, 1, 2),
alternates=[['x', 'X'], ['y', 'Y'], ['z', 'Z']],
......
......@@ -56,6 +56,12 @@ class OrthoOpts(sceneopts.SceneOpts):
"""
showLocation = props.Choice(('no', 'X', 'Y', 'Z'))
"""If not ``'no'`` labels showing the current location, in voxel and
world coordinatees, will be shown on the selected canvas.
"""
layout = props.Choice(('horizontal', 'vertical', 'grid'))
"""How should we lay out each of the three canvases?"""
......
......@@ -33,6 +33,7 @@ class SceneOpts(props.HasProperties):
showCursor = copy.copy(canvasopts.SliceCanvasOpts.showCursor)
cursorWidth = copy.copy(canvasopts.SliceCanvasOpts.cursorWidth)
zoom = copy.copy(canvasopts.SliceCanvasOpts.zoom)
bgColour = copy.copy(canvasopts.SliceCanvasOpts.bgColour)
cursorColour = copy.copy(canvasopts.SliceCanvasOpts.cursorColour)
......
......@@ -740,8 +740,8 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
annot = self.getAnnotations()
kwargs = {
'colour' : opts.cursorColour,
'width' : 1
'colour' : opts.cursorColour,
'lineWidth' : opts.cursorWidth
}
annot.line(*xverts[0], *xverts[1], **kwargs)
......
......@@ -5,29 +5,41 @@
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`OrthoLabels` class, which manages
anatomical orientation labels for an :class:`.OrthoPanel`.
anatomical and location labels for an :class:`.OrthoPanel`.
This logic is independent from the :class:`.OrthoPanel` so it can be used in
off-screen rendering (see :mod:`.render`).
"""
import fsl.data.image as fslimage
import fsl.data.constants as constants
class OrthoLabels:
"""The ``OrthoLabels`` class manages anatomical orientation labels which
are displayed on a set of three :class:`.SliceCanvas` instances, one for
each plane in the display coordinate system, typically within an
:class:`.OrthoPanel`.
"""The ``OrthoLabels`` class manages anatomical orientation and location
labels which are displayed on a set of three :class:`.SliceCanvas`
instances, one for each plane in the display coordinate system, typically
within an :class:`.OrthoPanel`.
The ``OrthoLabels`` class uses :class:`.annotations.Text` annotations,
showing the user the anatomical orientation of the display on each
canvas. These labels are only shown if the currently selected overlay (as
dicated by the :attr:`.DisplayContext.selectedOverlay` property) is a
:class:`.Image` instance, **or** the
:meth:`.DisplayOpts.referenceImage` property for the currently selected
overlay returns an :class:`.Image` instance.
showing the user:
- the anatomical orientation of the display on each canvas.
- the current location on a selected csanvas.
Anatomical labels can be toggled on and off via the
:attr:`.OrthoOpts.showLabels` property, and location via the
:attr:`.OrthoOpts.showLocation` priperty.
Anatomical labels are only shown if the currently selected overlay (as
dictated by the :attr:`.DisplayContext.selectedOverlay` property) is a
:class:`.Image` instance, **or** the :meth:`.DisplayOpts.referenceImage`
property for the currently selected overlay returns an :class:`.Image`
instance.
If the currently selected overlay is an :class:`.Image`, both voxel and
world coordinates are shown. Otherwise only world coordinates are shown.
"""
......@@ -53,14 +65,14 @@ class OrthoLabels:
# labels is a list of dicts, one
# for each canvas, containing Text
# annotations to show anatomical
# orientation
# orientation and location
annots = [{} for c in canvases]
self.__canvases = canvases
self.__annots = annots
# Create the Text annotations
for side in ('left', 'right', 'top', 'bottom'):
for side in ('left', 'right', 'top', 'bottom', 'location'):
for canvas, cannots in zip(canvases, annots):
annot = canvas.getAnnotations()
cannots[side] = annot.text('', 0, 0, hold=True)
......@@ -68,31 +80,36 @@ class OrthoLabels:
# Initialise the display properties
# of each Text annotation
for cannots in annots:
cannots['left'] .halign = 'left'
cannots['right'] .halign = 'right'
cannots['top'] .halign = 'centre'
cannots['bottom'].halign = 'centre'
cannots['left'] .valign = 'centre'
cannots['right'] .valign = 'centre'
cannots['top'] .valign = 'top'
cannots['bottom'].valign = 'bottom'
cannots['left'] .x = 0.0
cannots['left'] .y = 0.5
cannots['right'] .x = 1.0
cannots['right'] .y = 0.5
cannots['bottom'].x = 0.5
cannots['bottom'].y = 0.0
cannots['top'] .x = 0.5
cannots['top'] .y = 1.0
cannots['left'] .halign = 'left'
cannots['right'] .halign = 'right'
cannots['top'] .halign = 'centre'
cannots['bottom'] .halign = 'centre'
cannots['location'].halign = 'left'
cannots['left'] .valign = 'centre'
cannots['right'] .valign = 'centre'
cannots['top'] .valign = 'top'
cannots['bottom'] .valign = 'bottom'
cannots['location'].valign = 'top'
cannots['left'] .x = 0.0
cannots['left'] .y = 0.5
cannots['right'] .x = 1.0
cannots['right'] .y = 0.5
cannots['bottom']. x = 0.5
cannots['bottom'] .y = 0.0
cannots['top'] .x = 0.5
cannots['top'] .y = 1.0
cannots['location'].x = 0.0
cannots['location'].y = 1.0
# Keep cannots 5 pixels away
# from the canvas edges
cannots['left'] .off = ( 5, 0)
cannots['right'] .off = (-5, 0)
cannots['top'] .off = ( 0, -5)
cannots['bottom'].off = ( 0, 5)
cannots['left'] .off = ( 5, 0)
cannots['right'] .off = (-5, 0)
cannots['top'] .off = ( 0, -5)
cannots['bottom'] .off = ( 0, 5)
cannots['location'].off = ( 5, -5)
# Add listeners to properties
# that need to trigger a label
......@@ -104,22 +121,28 @@ class OrthoLabels:
# a panel refresh occurs (where
# the latter is managed by the
# OrthoPanel).
refreshArgs = {
labelArgs = {
'name' : name,
'callback' : self.__refreshLabels,
'callback' : self.refreshLabels,
'immediate' : True
}
anatomyArgs = dict(labelArgs)
anatomyArgs['callback'] = self.refreshAnatomy
locationArgs = dict(labelArgs)
locationArgs['callback'] = self.refreshLocation
for c in canvases:
c.opts.addListener('invertX', **refreshArgs)
c.opts.addListener('invertY', **refreshArgs)
orthoOpts .addListener('showLabels', **refreshArgs)
orthoOpts .addListener('labelSize', **refreshArgs)
orthoOpts .addListener('fgColour', **refreshArgs)
displayCtx .addListener('selectedOverlay', **refreshArgs)
displayCtx .addListener('displaySpace', **refreshArgs)
displayCtx .addListener('radioOrientation', **refreshArgs)
c.opts.addListener('invertX', **anatomyArgs)
c.opts.addListener('invertY', **anatomyArgs)
orthoOpts .addListener('showLabels', **labelArgs)
orthoOpts .addListener('labelSize', **labelArgs)
orthoOpts .addListener('fgColour', **labelArgs)
displayCtx .addListener('selectedOverlay', **labelArgs)
displayCtx .addListener('displaySpace', **labelArgs)
displayCtx .addListener('radioOrientation', **anatomyArgs)
orthoOpts .addListener('showLocation', **locationArgs)
displayCtx .addListener('location', **locationArgs)
overlayList.addListener('overlays', name, self.__overlayListChanged)
......@@ -142,11 +165,13 @@ class OrthoLabels:
self.__annots = None
orthoOpts .removeListener('showLabels', name)
orthoOpts .removeListener('showLocation', name)
orthoOpts .removeListener('labelSize', name)
orthoOpts .removeListener('fgColour', name)
displayCtx .removeListener('selectedOverlay', name)
displayCtx .removeListener('displaySpace', name)
displayCtx .removeListener('radioOrientation', name)
displayCtx .removeListener('location', name)
overlayList.removeListener('overlays', name)
for c in canvases:
......@@ -166,9 +191,12 @@ class OrthoLabels:
text.destroy()
def refreshLabels(self):
"""Forces the label annotations to be refreshed."""
self.__refreshLabels()
def refreshLabels(self, *a):
"""Forces the orientation and location annotations to be refreshed.
All arguments are ignored.
"""
self.refreshAnatomy()
self.refreshLocation()
def __overlayListChanged(self, *a):
......@@ -185,7 +213,7 @@ class OrthoLabels:
# overlay bounds change
opts.addListener('bounds',
self.__name,
self.__refreshLabels,
self.refreshLabels,
overwrite=True)
# When the list becomes empty, or
......@@ -195,26 +223,66 @@ class OrthoLabels:
# will thus not get called. So we call
# it here.
if len(self.__overlayList) in (0, 1):
self.__refreshLabels()
self.refreshLabels()
def refreshLocation(self, *a):
"""Refreshs the label displaying the current cursor location. """
displayCtx = self.__displayCtx
sopts = self.__orthoOpts
annots = self.__annots
overlay = displayCtx.getSelectedOverlay()
ref = displayCtx.getReferenceImage(overlay)
opts = None
wx, wy, wz = displayCtx.worldLocation
if overlay is None:
return
def __refreshLabels(self, *a):
for cannots, canvas in zip(annots, 'XYZ'):
showLoc = sopts.showLocation == canvas
cannots['location'].enabled = showLoc
if sopts.showLocation == 'no':
return
if sopts.showLocation == 'X': locLbl = annots[0]['location']
elif sopts.showLocation == 'Y': locLbl = annots[1]['location']
elif sopts.showLocation == 'Z': locLbl = annots[2]['location']
if ref is None:
locstr = f'{wx:0.2f} {wy:0.2f} {wz:0.2f}'
else:
opts = displayCtx.getOpts(ref)
vx, vy, vz = opts.getVoxel()
locstr = f'{wx:0.2f} {wy:0.2f} {wz:0.2f}' + \
f'\n[voxel {vx} {vy} {vz}]'
locLbl.fontSize = sopts.labelSize
locLbl.colour = sopts.fgColour
locLbl.text = locstr
def refreshAnatomy(self, *a):
"""Updates the attributes of the :class:`.Text` anatomical orientation
annotations on each :class:`.SliceCanvas`.
"""
displayCtx = self.__displayCtx
sopts = self.__orthoOpts
canvases = self.__canvases
annots = self.__annots
overlay = displayCtx.getSelectedOverlay()
showLabels = sopts.showLabels and (overlay is not None)
canvases = self.__canvases
annots = self.__annots
for cannots in annots:
for text in cannots.values():
text.enabled = sopts.showLabels and (overlay is not None)
for cannots, canvas in zip(annots, 'XYZ'):
cannots['left'] .enabled = showLabels
cannots['right'] .enabled = showLabels
cannots['top'] .enabled = showLabels
cannots['bottom'] .enabled = showLabels
if not sopts.showLabels or overlay is None:
if not showLabels:
return
opts = displayCtx.getOpts(overlay)
......@@ -224,9 +292,8 @@ class OrthoLabels:
labels, orients = opts.getLabels()
xlo, ylo, zlo, xhi, yhi, zhi = labels
vertOrient = len(xlo) > 1
fontSize = sopts.labelSize
fgColour = tuple(sopts.fgColour)
fontSize = sopts.labelSize
fgColour = tuple(sopts.fgColour)
# If any axis orientation is unknown, make
# the foreground colour red, to highlight
......
......@@ -156,6 +156,7 @@ class SliceCanvas:
opts.addListener('displayBounds', self.name, self.Refresh)
opts.addListener('bgColour', self.name, self.Refresh)
opts.addListener('cursorColour', self.name, self.Refresh)
opts.addListener('cursorWidth', self.name, self.Refresh)
opts.addListener('showCursor', self.name, self.Refresh)
opts.addListener('cursorGap', self.name, self.Refresh)
opts.addListener('invertX', self.name, self.Refresh)
......@@ -1160,8 +1161,8 @@ class SliceCanvas:
lines.append((x, yhigh, x, ymax))
kwargs = {
'colour' : copts.cursorColour,
'width' : 1
'colour' : copts.cursorColour,
'lineWidth' : copts.cursorWidth
}
for line in lines:
......
......@@ -223,6 +223,7 @@ class Text:
fontSize=self.fontSize,
fgColour=self.colour,
bgColour=self.bgColour,
halign=self.halign,
alpha=self.alpha)
bmp = np.flipud(bmp).transpose([2, 1, 0])
self.__bitmap = bmp
......
......@@ -418,6 +418,7 @@ OPTIONS = td.TypeDict({
# names of properties on them.
'SceneOpts' : ['showCursor',
'cursorWidth',
'bgColour',
'fgColour',
'cursorColour',
......@@ -433,6 +434,7 @@ OPTIONS = td.TypeDict({
'zzoom',
'cursorGap',
'showLabels',
'showLocation',
'layout',
'showXCanvas',
'showYCanvas',
......@@ -793,6 +795,7 @@ ARGUMENTS = td.TypeDict({
'SceneOpts.colourBarLabelSide' : ('cbs', 'colourBarLabelSide', True),
'SceneOpts.colourBarSize' : ('cbi', 'colourBarSize', True),
'SceneOpts.showCursor' : ('hc', 'hideCursor', False),
'SceneOpts.cursorWidth' : ('cw', 'cursorWidth', True),
'SceneOpts.highDpi' : ('hd', 'highDpi', False),
'SceneOpts.movieSyncRefresh' : ('ms', 'movieSync', False),
'SceneOpts.labelSize' : ('ls', 'labelSize', True),
......@@ -806,6 +809,7 @@ ARGUMENTS = td.TypeDict({
'OrthoOpts.showYCanvas' : ('yh', 'hidey', False),
'OrthoOpts.showZCanvas' : ('zh', 'hidez', False),
'OrthoOpts.showLabels' : ('hl', 'hideLabels', False),
'OrthoOpts.showLocation' : ('sl', 'showLocation', True),
'OrthoOpts.xcentre' : ('xc', 'xcentre', True),
'OrthoOpts.ycentre' : ('yc', 'ycentre', True),
'OrthoOpts.zcentre' : ('zc', 'zcentre', True),
......@@ -1046,6 +1050,7 @@ HELP = td.TypeDict({
'SceneOpts.showCursor' : 'Do not display the green cursor '
'highlighting the current location',
'SceneOpts.cursorWidth' : 'Location cursor thickness',
'SceneOpts.bgColour' : 'Canvas background colour (0-1)',
'SceneOpts.fgColour' : 'Canvas foreground colour (0-1)',
'SceneOpts.cursorColour' : 'Cursor location colour (0-1)',
......@@ -1062,15 +1067,16 @@ HELP = td.TypeDict({
'SceneOpts.labelSize' : 'Orientation/colour bar label font size '
'(4-96, default: 12)',
'OrthoOpts.xzoom' : 'X canvas zoom (100-5000, default: 100)',
'OrthoOpts.yzoom' : 'Y canvas zoom (100-5000, default: 100)',
'OrthoOpts.zzoom' : 'Z canvas zoom (100-5000, default: 100)',
'OrthoOpts.cursorGap' : 'Show a gap at the cursor centre',
'OrthoOpts.layout' : 'Canvas layout',
'OrthoOpts.showXCanvas' : 'Hide the X canvas',
'OrthoOpts.showYCanvas' : 'Hide the Y canvas',
'OrthoOpts.showZCanvas' : 'Hide the Z canvas',
'OrthoOpts.showLabels' : 'Hide orientation labels',
'OrthoOpts.xzoom' : 'X canvas zoom (100-5000, default: 100)',
'OrthoOpts.yzoom' : 'Y canvas zoom (100-5000, default: 100)',
'OrthoOpts.zzoom' : 'Z canvas zoom (100-5000, default: 100)',
'OrthoOpts.cursorGap' : 'Show a gap at the cursor centre',
'OrthoOpts.layout' : 'Canvas layout',
'OrthoOpts.showXCanvas' : 'Hide the X canvas',
'OrthoOpts.showYCanvas' : 'Hide the Y canvas',
'OrthoOpts.showZCanvas' : 'Hide the Z canvas',
'OrthoOpts.showLabels' : 'Hide orientation labels',
'OrthoOpts.showLocation' : 'Show cursor location coordinates',
'OrthoOpts.invertXHorixontal' :
'Invert the X canvas along the horizontal axis',
'OrthoOpts.invertXVertical' :
......@@ -1312,6 +1318,9 @@ HELP = td.TypeDict({
'\'white\' (default), \'black\', or '
'\'transparent\'.',
'ComplexOpts.component' :
'Component to display - real (default), imaginary, magnitude, or phase.',
'TractogramOpts.colourBy' :
'NIFTI image, or file containing per-vertex/streamline scalar values '
'for colouring, or name of a a per-vertex/streamline data set contained '
......
......@@ -439,7 +439,7 @@ class FileTypePanel(elb.EditableListBox):
toggle = wx.CheckBox(self)
toggle.SetMinSize(toggle.GetBestSize())
self.Append(ftype, extraWidget=toggle)
toggle.Bind(wx.EVT_CHECKBOX, self.__onToggle)
toggle.Bind(wx.EVT_CHECKBOX, self.onToggle)
def GetFileTypes(self):
......@@ -456,7 +456,7 @@ class FileTypePanel(elb.EditableListBox):
return active
def __onToggle(self, ev):
def onToggle(self, ev):
"""Called when a file type is toggled. Calls the
:meth:`FileTreePanel.Update` method.
"""
......
......@@ -453,6 +453,7 @@ def createLightBoxCanvas(namespace,
opts = canvas.opts
opts.showCursor = sceneOpts.showCursor
opts.cursorWidth = sceneOpts.cursorWidth
opts.bgColour = sceneOpts.bgColour
opts.cursorColour = sceneOpts.cursorColour
opts.zax = sceneOpts.zax
......@@ -552,6 +553,7 @@ def createOrthoCanvases(namespace,
opts = c.opts
opts.showCursor = sceneOpts.showCursor
opts.cursorColour = sceneOpts.cursorColour
opts.cursorWidth = sceneOpts.cursorWidth
opts.cursorGap = sceneOpts.cursorGap
opts.bgColour = sceneOpts.bgColour
opts.invertX = invertx
......
......@@ -1019,6 +1019,7 @@ properties = TypeDict({
'CanvasPanel.profile' : 'Mode',
'SceneOpts.showCursor' : 'Show location cursor',
'SceneOpts.cursorWidth' : 'Location cursor width',
'SceneOpts.cursorGap' : 'Show gap at cursor centre',
'SceneOpts.bgColour' : 'Background colour',
'SceneOpts.fgColour' : 'Foreground colour',
......@@ -1037,10 +1038,11 @@ properties = TypeDict({
'LightBoxOpts.sliceSpacing' : 'Slice spacing',
'LightBoxOpts.zrange' : 'Z range',
'OrthoOpts.showXCanvas' : 'Show X canvas',
'OrthoOpts.showYCanvas' : 'Show Y canvas',
'OrthoOpts.showZCanvas' : 'Show Z canvas',
'OrthoOpts.showLabels' : 'Show labels',
'OrthoOpts.showXCanvas' : 'Show X canvas',
'OrthoOpts.showYCanvas' : 'Show Y canvas',
'OrthoOpts.showZCanvas' : 'Show Z canvas',
'OrthoOpts.showLabels' : 'Show labels',
'OrthoOpts.showLocation' : 'Show location',
'OrthoOpts.layout' : 'Layout',
'OrthoOpts.xzoom' : 'X zoom',
......@@ -1307,6 +1309,12 @@ choices = TypeDict({
'vertical' : 'Vertical',
'grid' : 'Grid'},
'OrthoOpts.showLocation' : {'no' : 'No',
'X' : 'On X canvas',
'Y' : 'On Y canvas',
'Z' : 'On Z canvas'},
'OrthoEditProfile.mode' : {'nav' : 'Navigate',
'sel' : 'Draw/select',
'desel' : 'Erase/deselect',
......
......@@ -44,3 +44,18 @@ def _test_filetreepanel(ortho, overlayList, displayCtx):
'session' : '*',
'hemi' : '*',
'surf' : '*'}
# toggle on the T1w, check that
# file list is correctly generated
ftpanel.fileTypePanel.GetWidgets()[0].SetValue(True)
ftpanel.fileTypePanel.onToggle(None)
realYield()
grid = ftpanel.fileListPanel.GridContents()
expect = [['session', 'subject', 'Notes', 'T1w'],
['1', '01', '', 'x'],
['1', '02', '', 'x'],
['1', '03', '', 'x'],
['2', '01', '', 'x'],
['2', '02', '', 'x'],
['2', '03', '', 'x']]
assert grid == expect
......@@ -65,6 +65,10 @@ cli_tests = """
-nr 10 -nc 5 3d.nii.gz
-nr 5 -nc 10 3d.nii.gz
-nr 10 -nc 10 3d.nii.gz
# cursor width
-cw 5 3d
-cw 10 3d
"""
......
......@@ -71,6 +71,17 @@ mesh_ref mesh_l_thal.vtk -mc 0 1 0 -r mesh_ref -a 50
-ixh -ixv -iyh -iyv -izh -izv 3d.nii.gz
-a annotations.txt 3d
# show location
-sl X -ls 24 3d
-sl X 3d
-sl Y 3d
-sl Z 3d
-sl Z mesh_l_thal.vtk -mc 1 0 0
# cursor width
-cw 5 3d
-cw 10 3d
"""
......