Commit 0dd2e392 authored by Paul McCarthy's avatar Paul McCarthy 🚵
Browse files

Merge branch 'bf/plotpanellimits' into 'master'

Fix an issue with plot canvas limits

See merge request fsl/fsleyes/fsleyes!246
parents 6f6fccd7 9ba422f9
Pipeline #8354 failed with stages
in 2 minutes and 53 seconds
......@@ -2,6 +2,8 @@
set -e
source /test.venv/bin/activate
pip install wheel setuptools twine
......@@ -25,7 +27,15 @@ fi
pip install dist/*.whl
pushd / > /dev/null
fsleyes -V
fsleyes render -of out1.png $projdir/fsleyes/tests/testdata/3d
pip uninstall -y fsleyes
pip install dist/*.tar.gz
pushd / > /dev/null
fsleyes -V
fsleyes render -of out2.png $projdir/fsleyes/tests/testdata/3d
pip uninstall -y fsleyes
......@@ -9,6 +9,26 @@ This document contains the ``fsleyes`` release history in reverse
chronological order.
1.0.4 (Tuesday 4th May 2021)
* Improved ortho edit mode performance on large images (!246).
* Suppressed some warning messages (!246).
* Fixed an issue with the :attr:`.PlotCanvas.limits` becoming out of sync with
the ``matplotlib.Axes`` limits (!246).
* The ``file-tree`` library is now optional (!246).
1.0.3 (Friday 23rd April 2021)
......@@ -20,7 +40,7 @@ Fixed
* Fixed an issue with the management of built-in asset files (e.g. icons,
colour maps, etc). Asset files are now located inside the ``fsleyes``
package directory.
package directory (!244).
1.0.2 (Thursday 22nd April 2021)
......@@ -31,10 +51,11 @@ Fixed
* Fixed some issues with FSLeyes plugin management.
* Fixed some issues with GL initialisations on GTK2 versions of ``wxpython``.
* Fixed some issues with FSLeyes plugin management (!242).
* Fixed some issues with GL initialisations on GTK2 versions of ``wxpython``
* New ``--annotations`` command-line option, allowing annotations to be
loaded from a file into an ortho view.
loaded from a file into an ortho view (!242).
1.0.1 (Tuesday 20th April 2021)
......@@ -45,7 +66,7 @@ Fixed
* Fixed a compatibility issue with recent versions of matplotlib.
* Fixed compatibility issues with recent versions of matplotlib (!240).
1.0.0 (Monday 19th April 2021)
......@@ -57,23 +78,23 @@ Added
* The lighting effect in the 3D view is now applied to ``volume`` overlays
(OpenGL 2.1 or newer only).
* New ``--lightDistance`` option (for 3D view), allowing the distance of
the light source from the centre of the display bounding box to be set.
(OpenGL 2.1 or newer only) (!222).
* New ``--lightDistance`` option (for 3D view), allowing the distance of the
light source from the centre of the display bounding box to be set (!222).
* New ``--noBlendByIntensity`` option, for ``volume`` overlays in the 3D view,
allowing the modulation of samples by voxel intensity to be disabled.
allowing the modulation of samples by voxel intensity to be disabled (!222).
* New ``-ixh``, ``-ixv``, ``-iyh``, ``-iyv``, ``-izh``, and ``-izv`` options,
allowing ortho canvases to be inverted vertically or horizontally.
allowing ortho canvases to be inverted vertically or horizontally (!225).
* New ``--modulateMode`` option for ``rgbvector``, ``linevector``, ``tensor``
and ``sh`` overlays, allowing modulation to be applied to either brightness
or transparency.
or transparency (!231).
* New option to copy/paste 2D selections between slices when editing a NIFTI
image (!232).
* New *annotation* panel, allowing simple shapes and text to be overlaid on
the canvases of an ortho view. Annotations can be saved to/loaded from file,
via new options in the *Tools* menu.
via new options in the *Tools* menu (!233).
* New *Sample along line* tool, allowing data from an image to be sampled
along a line and plotted.
along a line and plotted (!235).
......@@ -81,44 +102,44 @@ Changed
* Text labels drawn on GL canvases are now created using ``matplotilb`` rather
than [Free]GLUT.
than [Free]GLUT (!221).
* Removed dependence on [Free]GLUT - this means that ``fsleyes render`` can
now be used on headless systems without using ``xvfb-run``, as long as
`OSMesa <>`_ is installed.
`OSMesa <>`_ is installed (!221).
* The ``--lightPos`` command-line option (for the 3D view) has been changed to
expect three rotation values (in degrees), which specify the position of the
light source with respect to the centre of the display bounding box. This
can be combined with the new ``--lightDistance`` option to specify the
position of the light source.
position of the light source (!222).
* FSLeyes no longer ignores the ``LIBGL_ALWAYS_INDIRECT`` environment
variable (!222).
* FSLeyes attempts to determine a suitable value for ``PYOPENGL_PLATFORM``
if it is not already set.
if it is not already set (!222).
* FSLeyes should now work with both Wayland/EGL and X11/GLX builds of wxPython
on Linux.
on Linux (!222).
* The normalisation method used in the power spectrum panel has been adjusted
so that, instead of the data being normalised to unit variance before the
fourier transform, the fourier-transformed data itself is normalised to the
range [-1, 1].
range [-1, 1] (!224).
* The *Show command line for scene* option will display a warning if any
overlays are not saved.
overlays are not saved (!226).
* The :class:`.FileTreePanel` has been updated to work with the
new `file-tree <>`_ library.
new `file-tree <>`_ library (!230).
* Change to the interface for copying/pasting data between images - there is
now a single button for copying, pasting, and clearing the clipboard.
now a single button for copying, pasting, and clearing the clipboard (!232).
* :class:`.annotations.TextAnnotation` objects can now be positioned in the
display coordinate system, in addition to being positioned at pixel locations
on a canvas.
on a canvas (!232).
* Changes to the FSLeyes plugin system to ease the development of FSLeyes
controls that use custom interaction profiles, and to improve switching
between different interaction proflies.
between different interaction proflies (!234).
* The FSLeyes plugin system now supports tools which are bound to a specific
view panel.
view panel (!234).
* Many built-in FSLeyes control panels and tools have been migrated into the
FSLeyes plugin system so that they are dynamically loaded as plugins, rather
than being hard-coded.
than being hard-coded (!234).
* It is now possible to save and re-load view/control panel layouts with
plugin-provided views and control panels.
plugin-provided views and control panels (!234).
......@@ -126,21 +147,21 @@ Fixed
* Various fixes and improvements to the lighting effect on ``mesh`` overlays
in the 3D view.
in the 3D view (!222).
* When opening a ``melodic_IC.nii.gz`` file with the
``--autoDisplay'`/``-ad``, option, the ``melodic_IC`` file is now selected
by default, instead of the ``mean`` underlay.
by default, instead of the ``mean`` underlay (!219).
* Fixed a bug in image texture preparation for complex data types, when
running in a limited GL environment (e.g. VNC).
* Compatibility fixes for newer versions of Jupyter `notebook`.
running in a limited GL environment (e.g. VNC) (!220).
* Compatibility fixes for newer versions of Jupyter `notebook` (!227).
* Fixed a problem with macOS desktop integration - it should now be possible
to open a file with FSLeyes as the default application, and to drag a file
onto the icon.
* Improved stability when running under macOS+XQuartz.
onto the icon (!228).
* Improved stability when running under macOS+XQuartz (!229).
* Fixed an issue with screenshots generated by ``fsleyes render`` containing
transparent pixels.
transparent pixels (!233).
* Fixed a collision between the ``-mc`` and ``-a`` command-line options for
mesh overlays.
mesh overlays (!233).
0.34.2 (Tuesday 14th July 2020)
......@@ -311,8 +311,6 @@ def configLogging(verbose=0, noisy=None):
if noisy is None:
noisy = []
warnings.filterwarnings('default', category=DeprecationWarning)
# Set up the root logger
logFormatter = logging.Formatter('%(levelname)8.8s '
'%(filename)20.20s '
......@@ -332,6 +330,9 @@ def configLogging(verbose=0, noisy=None):
if disableLogging:
# show dep warnings
warnings.filterwarnings('default', category=DeprecationWarning)
# Now we can set up logging
if verbose == 1:
......@@ -35,7 +35,8 @@ class ToggleControlPanelAction(base.ToggleAction):
"""Create a ``ToggleControlPanelAction``.
:arg overlayList: The :class:`.OverlayList`
......@@ -46,6 +47,9 @@ class ToggleControlPanelAction(base.ToggleAction):
:arg func: The function which toggles the control panel. If
not provided, a default function is used.
:arg name: Name of this action - defaults to ``func.__name__``.
All other arguments will be passed to the
:meth:`.ViewPanel.togglePanel` method.
if func is None:
......@@ -56,6 +60,7 @@ class ToggleControlPanelAction(base.ToggleAction):
self.__viewPanel = viewPanel
self.__cpType = cpType
self.__kwargs = kwargs
# Listen for changes to the view panel layout
# so we can detect when the user closes our
......@@ -73,7 +78,10 @@ class ToggleControlPanelAction(base.ToggleAction):
"""Default action to run if a ``func`` wasn't specified. Calls
self.viewPanel.togglePanel(self.__cpType, *args, **kwargs)
......@@ -164,14 +164,16 @@ class Editor(actions.ActionProvider):
return self.__selection
def clearSelection(self):
def clearSelection(self, *args, **kwargs):
"""Clears the :class:`.selection.Selection` (see
:meth:`.selection.Selection.clearSelection`). If this ``Editor`` is
not recording all selection changes (``recordSelection=False`` in
:meth:`__init__`), the selection state before being cleared is saved
in the change history.
All arguments are passed through to :meth:`.Selection.clearSelection`.
self.__selection.clearSelection(*args, **kwargs)
if (not self.__recordSelection) and self.__recordChanges:
......@@ -445,13 +447,13 @@ class Editor(actions.ActionProvider):
opts = self.displayCtx.getOpts(image)
if isinstance(change, ValueChange):
log.debug('{}: changing image {} data - offset '
'{}, volume {}, size {}'.format(,,
log.debug('%s: changing image %s data - offset '
'%s, volume %s, size %s',,,
sliceobj = self.__makeSlice(change.offset,
......@@ -106,6 +106,13 @@ class Selection(notifier.Notifier):
self.__lastChangeOldBlock = None
self.__lastChangeNewBlock = None
# We keep track of regions of the selection
# that have been modified, as a sequence of
# (xlo, ylo, zlo, xhi, yhi, zhi) values.
# This is used by the getBoundedSelection
# method,
self.__dirty = None
if selection is None:
selection = np.zeros(image.shape[:3], dtype=np.uint8)
......@@ -256,23 +263,29 @@ class Selection(notifier.Notifier):
Returns a tuple containing the region, as a ``numpy.uint8`` array, and
the coordinates specifying its location in the full :attr:`selection`
.. warning:: This method is slow, and in many cases it may be
faster simply to access the full selection array.
xs, ys, zs = np.where(self.__selection > 0)
if len(xs) == 0:
return np.array([]).reshape((0, 0, 0)), (0, 0, 0)
xlo = int(xs.min())
ylo = int(ys.min())
zlo = int(zs.min())
xhi = int(xs.max() + 1)
yhi = int(ys.max() + 1)
zhi = int(zs.max() + 1)
# If this method is called when a dirty
# region has not been saved (e.g. after
# a call to clearSelection), we fall
# back to manually calculating it, which
# is quite slow.
if self.__dirty is None:
xs, ys, zs = self.__selection.nonzero()
if len(xs) == 0:
xlo = ylo = zlo = xhi = yhi = zhi = 0
xlo = int(xs.min())
ylo = int(ys.min())
zlo = int(zs.min())
xhi = int(xs.max() + 1)
yhi = int(ys.max() + 1)
zhi = int(zs.max() + 1)
self.__dirty = xlo, ylo, zlo, xhi, yhi, zhi
xlo, ylo, zlo, xhi, yhi, zhi = self.__dirty
selection = self.__selection[xlo:xhi, ylo:yhi, zlo:zhi]
return selection, (xlo, ylo, zlo)
......@@ -317,6 +330,15 @@ class Selection(notifier.Notifier):
if restrict is None:
self.__clear = True
# Always clear the dirty region - in
# theory we could adjust the dirty
# region by the restrict slices (if
# provided), but this is awkward to
# do. The getBoundedSelection method
# will resort to np.ndarray.nonzero
# if the dirty region is not set.
self.__dirty = None
......@@ -652,11 +674,22 @@ class Selection(notifier.Notifier):
log.debug('Updating selection ({}) block [{}:{}, {}:{}, {}:{}]'.format(
id(self), xlo, xhi, ylo, yhi, zlo, zhi))
log.debug('Updating selection (%i) block [%i:%i, %i:%i, %i:%i]',
id(self), xlo, xhi, ylo, yhi, zlo, zhi)
self.__selection[xlo:xhi, ylo:yhi, zlo:zhi] = block
# Save/update the dirty region - the region
# of the selection that has been modified
# and therefore is potentially non-zero.
if self.__dirty is None:
self.__dirty = [xlo, ylo, zlo, xhi, yhi, zhi]
self.__dirty[:3] = np.min([self.__dirty[:3],
[xlo, ylo, zlo]], axis=0)
self.__dirty[3:] = np.max([self.__dirty[3:],
[xhi, yhi, zhi]], axis=0)
self.__clear = False
......@@ -951,7 +984,7 @@ def selectLine(shape,
# Allocate a selection block
# which will contain the line
block = np.zeros(size, dtype=np.bool)
block = np.zeros(size, dtype=bool)
# Generate a voxel block
# at each point
......@@ -183,6 +183,7 @@ def main(args=None):
warnings.filterwarnings('ignore', module='trimesh')
warnings.filterwarnings('ignore', module='h5py')
warnings.filterwarnings('ignore', category=DeprecationWarning)
warnings.filterwarnings('ignore', category=UserWarning)
logging.getLogger('nibabel') .setLevel(logging.CRITICAL)
logging.getLogger('trimesh') .setLevel(logging.CRITICAL)
......@@ -596,7 +596,8 @@ class FSLeyesFrame(wx.Frame):
ctrls = [plugins.lookupControl(c) for c in ctrls]
for ctrl in ctrls:
title = plugins.pluginTitle(ctrl)
viewPanel.togglePanel(ctrl, title=title)
def refreshViewMenu(self):
......@@ -1286,8 +1287,8 @@ class FSLeyesFrame(wx.Frame):
# It's nice to explicitly clean
# up our FSLeyesPanels, otherwise
# they'll probably complain
for panel in self.__viewPanels:
for vp in self.__viewPanels:
self.__onViewPanelClose(panel=vp, displaySync=False)
# (not created) self.__overlayMenuActions
......@@ -38,7 +38,7 @@ def createImageTexture(name, image, *args, **kwargs):
else: return ImageTexture2D(name, image, *args, **kwargs)
class ImageTextureBase(object):
class ImageTextureBase:
"""Base class shared by the :class:`ImageTexture` and
:class:`ImageTexture2D` classes. Contains logic for retrieving a
specific volume from a 3D + time or 2D + time :class:`.Image`, and
......@@ -310,19 +310,17 @@ class ImageTextureBase(object):
offset[:3] = affine.transform(
offset[:3], self.texCoordXform(image.shape))
log.debug('{} data changed - refreshing part of '
'texture (offset: {}, size: {})'.format(,
offset, data.shape))
log.debug('%s data changed - refreshing part of '
'texture (offset: %s, size: %s)',, offset, data.shape)
self.patchData(data, offset)
# Otherwise (boolean array indexing) we have
# to replace the whole image texture.
log.debug('{} data changed - refreshing full '
log.debug('%s data changed - refreshing '
'full texture',
......@@ -26,7 +26,7 @@ from . import texture3d
log = logging.getLogger(__name__)
class SelectionTextureBase(object):
class SelectionTextureBase:
"""Base class shared by the :class:`SelectionTexture2D` and
:class:`SelectionTexture3D`. Manages updates from the :class:`.Selection`
......@@ -10,6 +10,7 @@
import logging
import platform
import numpy as np
import OpenGL.GL as gl
......@@ -136,10 +137,14 @@ class Texture3D(texture.Texture):
# The macOS GL driver sometimes corrupts
# the texture data if we don't generate
# mipmaps
# mipmaps. But generating mipmaps can be
# very slow, so we only enable it on macOS
if platform.system() == 'Darwin': mipmap = gl.GL_TRUE
else: mipmap = gl.GL_FALSE
# create the texture according to
# the format determined by the
......@@ -571,8 +571,8 @@ class PlotCanvas(props.HasProperties):
# Make sure the limits are ordered
# as (min, max), as they won't be
# if invertX/invertY are active.
axxlim = list(sorted(axis.get_xlim()))
axylim = list(sorted(axis.get_ylim()))
axxlim = list(sorted(self.limits.x))
axylim = list(sorted(self.limits.y))
# Here we are preparing the data for
# each data series on separate threads,
......@@ -171,6 +171,7 @@ The following functions can be used to access plugins:
......@@ -240,6 +241,17 @@ def initialise():
log.warning('Failed to load plugin file %s: %s', fname, e)
def _pluginGroup(cls : Plugin) -> Optional[str]:
"""Returns the type/group of the given plugin, one of ``'views'``,
``'controls'``, or ``'tools'``.
if issubclass(cls, viewpanel.ViewPanel): return 'views'
elif issubclass(cls, ctrlpanel.ControlPanel): return 'controls'
elif issubclass(cls, ctrlpanel.ControlToolBar): return 'controls'
elif issubclass(cls, actions.Action): return 'tools'
return None
def _loadBuiltIns():
"""Called by :func:`initialise`. Loads all bulit-in plugins, from
sub-modules of the ``fsleyes.plugins`` directory.
......@@ -394,6 +406,17 @@ def lookupTool(clsName : str) -> Tool:
return _lookupPlugin(clsName, 'tools')
def pluginTitle(plugin : Plugin) -> Optional[str]:
"""Looks and returns up the title under which the given ``plugin`` is
group = _pluginGroup(plugin)
entries = _listEntryPoints('fsleyes_{}'.format(group))
for title, cls in entries.items():
if cls is plugin:
return title
def _importModule(filename : str, modname : str) -> ModuleType:
"""Used by :func:`loadPlugin`. Imports the given Python file, setting the
module name to ``modname``.
......@@ -425,8 +448,7 @@ def _findEntryPoints(mod : ModuleType,
for name in dir(mod):
item = getattr(mod, name)
group = None
item = getattr(mod, name)
if not isinstance(item, type):
......@@ -455,11 +477,7 @@ def _findEntryPoints(mod : ModuleType,
if issubclass(item, viewpanel.ViewPanel): group = 'views'
elif issubclass(item, ctrlpanel.ControlPanel): group = 'controls'
elif issubclass(item, ctrlpanel.ControlToolBar): group = 'controls'
elif issubclass(item, actions.Action): group = 'tools'
group = _pluginGroup(item)
if group is not None:
log.debug('Found %s entry point: %s', group, name)
entryPoints['fsleyes_{}'.format(group)][name] = item
......@@ -14,7 +14,10 @@
import os.path as op
import glob
import file_tree
import file_tree
except ImportError:
file_tree = None
from .filetreepanel import FileTreePanel
from .manager import FileTreeManager
......@@ -96,6 +96,15 @@ class FileTreePanel(ctrlpanel.ControlPanel):
return {'location' : wx.RIGHT}