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

Merge branch 'mnt/0.30.1' into 'v0.30'


See merge request fsl/fsleyes/fsleyes!142
parents 7d58e631 d905a4f0
......@@ -9,6 +9,28 @@ This document contains the ``fsleyes`` release history in reverse
chronological order.
0.30.1 (Wednesday 7th August 2019)
* The *Crop* tool can now be used to expand the field-of-view of an image,
in addition to cropping an image.
* The label overlay ``--lut`` command-line option will accepts colour map
* Added support for editing 2D images.
* Fixed a bug in the mesh vertex picking logic which would occur when multiple
views were open.
0.30.0 (Thursday 27th June 2019)
......@@ -27,7 +27,6 @@ environment. Standalone versions of FSLeyes can be downloaded from
FSLeyes is a `wxPython <>`_ application. If you are
on Linux, you will need to install wxPython first - head to and find the directory
......@@ -48,6 +47,12 @@ functionality)::
pip install fsleyes[extras]
As an alternate to using ``pip``, FSLeyes is also available on `conda-forge
conda install -c conda-forge fsleyes
......@@ -205,7 +205,6 @@ import warnings
from fsl.utils.platform import platform as fslplatform
import fsl.utils.settings as fslsettings
import fsleyes.version as version
import fsleyes.plugins as plugins
# The logger is assigned in
......@@ -250,7 +249,9 @@ def initialise():
global assetDir
import matplotlib as mpl
import matplotlib as mpl
import fsleyes.plugins as plugins
# implement various hacks and workarounds
......@@ -369,17 +370,6 @@ def configLogging(verbose=0, noisy=None):
if noisy is None:
noisy = []
# make numpy/matplotlib/nibabel/etc quiet
warnings.filterwarnings('ignore', module='matplotlib')
warnings.filterwarnings('ignore', module='mpl_toolkits')
warnings.filterwarnings('ignore', module='numpy')
warnings.filterwarnings('ignore', module='h5py')
warnings.filterwarnings('ignore', module='notebook')
warnings.filterwarnings('ignore', module='trimesh')
logging.getLogger('nibabel') .setLevel(logging.CRITICAL)
logging.getLogger('trimesh') .setLevel(logging.CRITICAL)
# Show deprecations if running from code
if fslplatform.frozen:
warnings.filterwarnings('ignore', category=DeprecationWarning)
......@@ -9,11 +9,11 @@ which creates a copy of the currently selected overlay.
import numpy as np
import numpy as np
import as fslimage
import fsl.utils.transform as transform
import fsl.utils.settings as fslsettings
import fsl.utils.image.roi as imgroi
import fsleyes_widgets.dialog as fsldlg
import fsleyes.strings as strings
from . import base
......@@ -202,7 +202,9 @@ def copyImage(overlayList,
voxel bounds specified in the image. Must be a sequence
of tuples, containing the low/high bounds for each voxel
dimension. For 4D images, the bounds for the fourth
dimension are optional.
dimension are optional. If ``roi`` specifies more than
three dimensions, but ``copy4D is False``, the additional
dimensions are ignored.
:arg data: If provided, is used as the image data for the new copy.
Must match the shape dictated by the other arguments
......@@ -210,52 +212,60 @@ def copyImage(overlayList,
the ``createMask`` argument is ignored.
:returns: The newly created :class:`.Image` object.
ovlIdx = overlayList.index(overlay)
opts = displayCtx.getOpts(overlay)
isROI = roi is not None
is4D = len(overlay.shape) > 3 and overlay.shape[3] > 1
if name is None:
name = '{}_copy'.format(
Note that the ``roi`` and ``copy4D`` options do not support images with
more than four dimensions.
ovlIdx = overlayList.index(overlay)
opts = displayCtx.getOpts(overlay)
is4D = len(overlay.shape) > 3
isROI = roi is not None
copy4D = copy4D and is4D
createMask = createMask and (data is None)
# Initialise the roi indices if one wasn't
# provided - we will use the indices
# regardless of whether an ROI was passed
# in or not
if roi is None:
roi = [(0, s) for s in overlay.shape]
# If the image is 4D, and an ROI of
# length 3 has been given, add some
# bounds for the fourth dimension
if is4D and copy4D and len(roi) == 3:
roi = list(roi) + [(0, overlay.shape[3])]
# If we are only supposed to copy
# the current 3D volume of a 4D
# image, adjust the ROI accordingly.
if is4D and not copy4D:
roi = list(roi[:3]) + [(opts.volume, opts.volume + 1)]
shape = [hi - lo for lo, hi in roi]
slc = tuple([slice(lo, hi) for lo, hi in roi])
if data is not None: pass
elif createMask: data = np.zeros(shape)
else: data = np.copy(overlay[slc])
# If this is an ROI, we need to add
# an offset to the image affine
if isROI:
xform = overlay.voxToWorldMat
offset = [lo for lo, hi in roi[:3]]
offset = transform.scaleOffsetXform([1, 1, 1], offset)
xform = transform.concat(xform, offset)
# Adjust the roi to index a
# specific volume if requested
if not copy4D:
roi = list(roi[:3]) + [(i, i + 1) for i in opts.index()[3:]]
xform = None
if name is None:
name = '{}_copy'.format(
# Create the copy, put it in the list
header = overlay.header.copy()
copy = fslimage.Image(data, name=name, header=header, xform=xform)
# If an ROI is not specified, we slice
# the image data, either including all
# volumes, or the currently selected volume
if not isROI:
slc = tuple(slice(lo, hi) for lo, hi in roi)
imgdata = overlay[slc]
xform = overlay.voxToWorldMat
# if an ROI is specified, we use the
# fsl.utils.image.roi module to generate
# an ROI and the adjusted voxel-to-world
# affine
roi = imgroi.roi(overlay, roi)
imgdata =
xform = roi.voxToWorldMat
if createMask:
data = np.zeros(imgdata.shape, dtype=imgdata.dtype)
elif data is None:
data = np.copy(imgdata)
copy = fslimage.Image(data,
overlayList.insert(ovlIdx + 1, copy)
......@@ -72,6 +72,7 @@ The following functions are available for managing and accessing colour maps:
......@@ -161,6 +162,7 @@ access and manage :class:`LookupTable` instances:
......@@ -178,6 +180,7 @@ for querying installed colour maps and lookup tables,
.. autosummary::
......@@ -285,7 +288,6 @@ def scanBuiltInCmaps():
cmapIDs = [op.relpath(i, basedir) for i in cmapIDs]
cmapIDs = [i.replace(op.sep, '_') for i in cmapIDs]
return cmapIDs
......@@ -537,6 +539,8 @@ def registerColourMap(cmapFile,
:arg name: Display name for the colour map. If ``None``, defaults
to the ``name``.
:returns: The key that the ``ColourMap`` was registered under.
import as mplcm
......@@ -552,14 +556,7 @@ def registerColourMap(cmapFile,
if name is None: name = key
if overlayList is None: overlayList = []
# The file could be a FSLView style VEST-LUT
if vest.looksLikeVestLutFile(cmapFile):
data = vest.loadVestLutFile(cmapFile, normalise=False)
# Or just a plain 2D text array
data = np.loadtxt(cmapFile)
data = loadColourMapFile(cmapFile)
cmap = colors.ListedColormap(data, key)
log.debug('Loading and registering custom '
......@@ -604,6 +601,8 @@ def registerColourMap(cmapFile,
prop = cls.getProp(propName)
return key
def registerLookupTable(lut,
......@@ -632,6 +631,8 @@ def registerLookupTable(lut,
:arg name: Display name for the lookup table. If ``None``, defaults
to the ``name``.
:returns: The :class:`LookupTable` object
if isinstance(lut, six.string_types): lutFile = lut
......@@ -830,6 +831,145 @@ def installLookupTable(key):
lut.installed = True
# File I/O
def fileType(fname):
"""Attempts to guess the type of ``fname``.
``fname`` is assumed to be a FSLeyes colour map or lookup table file,
or a FSLView-style VEST lookup table file.
A ``ValueError`` is raised if the file type cannot be determined.
:arg fname: Name of file to check
:returns: One of ``'vest'``, ``'cmap'``, or ``'lut'``, depending on
what the contents of ``fname`` look like.
if vest.looksLikeVestLutFile(fname):
return 'vest'
with open(fname, 'rt') as f:
line = f.readline().strip()
tkns = list(line.split())
# cmap file
if len(tkns) == 3:
[float(t) for t in tkns]
return 'cmap'
except ValueError:
# lut file
elif len(tkns) >= 4:
[float(t) for t in tkns[:4]]
return 'lut'
except ValueError:
raise ValueError('Cannot determine type of {}'.format(fname))
def loadColourMapFile(fname, aslut=False):
"""Load the given file, assumed to be a colour map.
:arg fname: FSLeyes or FSLView (VEST) colour map file
:arg aslut: If ``True``, the returned array will contain a label for
each colour, ranging from ``1`` to ``N``, where ``N`` is
the number of colours in the file.
:returns: A ``numpy`` array of shape ``(N, 3)`` containing the
RGB values for ``N`` colours. Or, if ``aslut is True``,
A ``numpy`` array of shape ``(N, 4)`` containing a
label, and the RGB values for ``N`` colours.
# The file could be a FSLView style VEST LUT
if vest.looksLikeVestLutFile(fname):
data = vest.loadVestLutFile(fname, normalise=False)
# Or just a plain 2D text array
data = np.loadtxt(fname)
if aslut:
lbls = np.arange(1, data.shape[0] + 1).reshape(-1, 1)
data = np.hstack((lbls, data))
return data
def loadLookupTableFile(fname):
"""Loads the given file, assumed to be a lookup table.
:arg fname: Name of a FSLeyes lookup table file.
:returns: A tuple containing:
- A ``numpy`` array of shape ``(N, 4)`` containing the
label and RGB values for ``N`` colours.
- A list containin the name for each label
.. note:: The provided file may also be a colour map file (see
:func:`loadColourMapFile`), in which case the labels will range
from ``1`` to ``N``, and the names will be strings containing
the label values.
# Accept cmap file, auto-generate labels/names
if fileType(fname) in ('vest', 'cmap'):
lut = loadColourMapFile(fname, aslut=True)
names = ['{}'.format(int(l)) for l in lut[:, 0]]
return lut, names
# Otherwise assume a FSLeyes lut file
with open(fname, 'rt') as f:
# Read the LUT label/colours on
# a first pass through the file
struclut = np.genfromtxt(
usecols=(0, 1, 2, 3),
'formats' : (, np.float, np.float, np.float),
'names' : ('label', 'r', 'g', 'b')})
# Save the label ordering -
# we'll use it below
order = struclut.argsort(order='label')
# Convert from a structured
# array into a regular array
lut = np.zeros((len(struclut), 4), dtype=np.float32)
lut[:, 0] = struclut['label']
lut[:, 1] = struclut['r']
lut[:, 2] = struclut['g']
lut[:, 3] = struclut['b']
# Read the names on a second pass
names = []
for i, line in enumerate(f):
tkns = line.split(None, 4)
if len(tkns) < 5: name = '{}'.format(int(lut[i, 0]))
else: name = tkns[4].strip()
# Sort by ascending label value
lut = lut[order, :]
names = [names[o] for o in order]
return lut, names
# Miscellaneous
......@@ -1198,9 +1338,9 @@ class LutLabel(props.HasProperties):
"""The hash of a ``LutLabel`` is a combination of its
value, name, and colour, but not its enabled state.
return (hash(self.value) ^
hash(self.internalName) ^
return hash( self.value) ^ \
hash( self.internalName) ^ \
def __str__(self):
......@@ -1283,8 +1423,8 @@ class LookupTable(notifier.Notifier):
self.__toParse = None
if lutFile is not None:
self.__toParse = self.__load(lutFile)
self.__savbed = True
self.__toParse = loadLookupTableFile(lutFile)
self.__saved = True
def lazyparse(func):
......@@ -1462,7 +1602,6 @@ class LookupTable(notifier.Notifier):
value = label.value
colour = label.colour
name =
tkns = [value, colour[0], colour[1], colour[2], name]
line = ' '.join(map(str, tkns))
......@@ -1474,7 +1613,7 @@ class LookupTable(notifier.Notifier):
def __parse(self, lut, names):
"""Parses ``lut``, a numpy array containing a LUT. """
labels = [LutLabel(l, name, (r, g, b), l > 0)
labels = [LutLabel(int(l), name, (r, g, b), l > 0)
for ((l, r, g, b), name) in zip(lut, names)]
self.__labels = labels
......@@ -1483,28 +1622,6 @@ class LookupTable(notifier.Notifier):
label.addGlobalListener(self.__name, self.__labelChanged)
def __load(self, lutFile):
"""Called by :meth:`__init__`. Loads a ``LookupTable`` specification
from the given file.
with open(lutFile, 'rt') as f:
lut = np.genfromtxt(
usecols=(0, 1, 2, 3),
'formats' : (, np.float, np.float, np.float),
'names' : ('label', 'r', 'g', 'b')})
names = [l.split(None, 4)[4].strip() for l in f]
order = lut.argsort(order='label')
lut = lut[order]
names = [names[o] for o in order]
return lut, names
def __labelChanged(self, value, valid, label, propName):
"""Called when the properties of any ``LutLabel`` change. Triggers
notification on the ``label`` topic.
......@@ -123,6 +123,9 @@ class DisplaySpaceWarning(fslpanel.FSLeyesPanel):
displaySpace = displayCtx.displaySpace
overlay = displayCtx.getSelectedOverlay()
if overlay is not None:
overlay = displayCtx.getOpts(overlay).referenceImage
if condition == 'overlay': show = displaySpace is overlay
elif condition == 'not overlay': show = displaySpace is not overlay
elif condition == 'world': show = displaySpace == 'world'
......@@ -130,12 +133,14 @@ class DisplaySpaceWarning(fslpanel.FSLeyesPanel):
if displaySpace == 'world':
show = False
show = overlay.sameSpace(displaySpace)
show = (overlay is not None) and \
elif condition == 'not like overlay':
if displaySpace == 'world':
show = True
show = not overlay.sameSpace(displaySpace)
show = (overlay is not None) and \
(not overlay.sameSpace(displaySpace))
show = False
......@@ -95,11 +95,11 @@ class DisplayContext(props.SyncableHasProperties):
worldLocation = props.Point(ndims=3)
"""The location property contains the currently selected 3D location (xyz)
in the world coordinate system. Whenever the :attr:`location` changes, it
gets transformed into the world coordinate system, and propagated to this
property. The location of different ``DisplayContext`` instances is
synchronised through this property.
"""The :attr:`location` property contains the currently selected 3D
location (xyz) in the current display coordinate system. Whenever the
:attr:`location` changes, it gets transformed into the world coordinate
system, and propagated to this property. The location of different
``DisplayContext`` instances is synchronised through this property.
.. note:: If any :attr:`.NiftiOpts.transform` properties have been modified
independently of the :attr:`displaySpace`, this value will be
......@@ -107,14 +107,6 @@ class DisplayContext(props.SyncableHasProperties):
vertexIndex = props.Int()
"""This property may be used to control the :attr:`location` when the
currently selected overlay is a :class:`.Mesh`, which comprises
a list of vertices. If this property is set to an index into the mesh
vertex list, the :attr:`location` will be set to that vertex.
bounds = props.Bounds(ndims=3)
"""This property contains the min/max values of a bounding box (in display
coordinates) which is big enough to contain all of the overlays in the
......@@ -358,10 +350,6 @@ class DisplayContext(props.SyncableHasProperties):
# The overlayListChanged method
# is important - check it out
......@@ -400,7 +388,6 @@ class DisplayContext(props.SyncableHasProperties):
self.removeListener('displaySpace', self.__name)
self.removeListener('location', self.__name)
self.removeListener('worldLocation', self.__name)
self.removeListener('vertexIndex', self.__name)
for g in list(self.overlayGroups):
......@@ -1180,28 +1167,6 @@ class DisplayContext(props.SyncableHasProperties):
def __vertexIndexChanged(self, *a):
"""Called when the :attr:`vertexIndex` property changes. Propagates
the new location to the :attr:`location` property.
vidx = self.vertexIndex
ovl = self.getSelectedOverlay()
# If the current overlay is a mesh, and
# the index looks valid, propagate it on
# to the location.
if isinstance(ovl, fslmesh.Mesh) and \
vidx >= 0 and \
vidx < ovl.vertices.shape[0]:
opts = self.getOpts(ovl)
loc = ovl.vertices[vidx, :]
loc = opts.transformCoords(loc, 'mesh', 'display') = loc
def __propagateLocation(self, dest):
"""Called by the :meth:`__locationChanged` and
:meth:`__worldLocationChanged` methods. The ``dest`` argument may be
......@@ -459,31 +459,31 @@ class MeshOpts(cmapopts.ColourMapOpts, fsldisplay.DisplayOpts):
return opts.getTransform(self.coordSpace, opts.transform)
def getVertex(self, xyz=None):
def getVertex(self, xyz=None, tol=1):
"""Returns an integer identifying the index of the mesh vertex that
coresponds to the given ``xyz`` location,
coresponds to the given ``xyz`` location, assumed to be specified
in the display coordinate system.
:arg xyz: Location to convert to a vertex index. If not provided, the
current :class:`.DisplayContext.location` is used.