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

Merge branch 'master' into 'master'

Various updates

See merge request !13
parents d5848584 5ca76f76
Pipeline #412 canceled with stage
in 3 minutes and 57 seconds
# Keeping this commented out until CI is working
#
# test:2.7:
# image: python2.7
# script:
# - cat requirements.txt | xargs -n 1 pip install
# - python setup.py test
test:2.7:
image: fsleyes-py27
script:
- cat requirements.txt | xargs -n 1 pip install
- pip install scipy
- pip install coverage
- su -s /bin/bash -c "xvfb-run python setup.py test" nobody
- coverage report -m
# test:3.5:
# image: python3.5
# script:
# - cat requirements.txt | xargs -n 1 pip install
# - python setup.py test
test:3.6:
image: fsleyes-py36
script:
- cat requirements.txt | xargs -n 1 pip install
- pip install scipy
- pip install coverage
- su -s /bin/bash -c "xvfb-run python setup.py test" nobody
- coverage report -m
......@@ -16,16 +16,17 @@ Development model
developers are free to choose their own development workflow in their own
repositories.
- A separate branch is created for each release. Hotfixes may be added to
these release branches.
- Merge requests will not be accepted unless:
- All existing tests pass (or have been updated as needed).
- New tests have been written to cover newly added features.
- Code coverage is as close to 100% as possible.
- Coding conventions are adhered to (unless there is good reason not to).
- A separate branch is created for each release. Hotfixes may be added to
these release branches. Hotfixes should be merged into the master branch,
and then cherry-picked onto the release branch(es).
Version number
--------------
......@@ -40,13 +41,16 @@ numbers::
- The ``patch`` number is incremented on bugfixes and minor
(backwards-compatible) changes.
- The ``minor`` number is incremented on feature additions and/or
backwards-compatible changes.
- The ``major`` number is incremented on major feature additions, and
backwards-incompatible changes.
Additionally, a single letter (``a``, ``b``, ``c``, etc) may be appended
to the version number, indicating a hotfix release.
Testing
-------
......@@ -78,7 +82,7 @@ Configure your text editor to use:
- `flake8 <http://flake8.pycqa.org/en/latest/>`_: This checks your code for
adherence to the `PEP8 <https://www.python.org/dev/peps/pep-0008/>`_ coding
standard.
- `pylint <https://www.pylint.org/>`_: This checks that your code follows
other good conventions.
......
......@@ -308,6 +308,58 @@ class AtlasRegistry(notifier.Notifier):
fslsettings.write('fsl.data.atlases', atlases)
class AtlasLabel(object):
"""The ``AtlasLabel`` class is used by the :class:`AtlasDescription` class
as a container object used for storing atlas label information.
An ``AtlasLabel`` instance contains the following attributes:
========= ==============================================================
``name`` Region name
``index`` For probabilistic atlases, the volume index into the 4D atlas
image that corresponds to this region. For label atlases, the
value of voxels that are in this region. For summary images of
probabilistic atlases, add 1 to this value to get the
corresponding voxel values.
``x`` X coordinate of the region in world space
``y`` Y coordinate of the region in world space
``z`` Z coordinate of the region in world space
========= ==============================================================
.. note:: The ``x``, ``y`` and ``z`` label coordinates are pre-calculated
centre-of-gravity coordinates, as listed in the atlas xml file.
They are in the coordinate system defined by the transformation
matrix for the first image in the ``images`` list of the atlas
XML file (typically MNI152 space).
"""
def __init__(self, name, index, x, y, z):
self.name = name
self.index = index
self.x = x
self.y = y
self.z = z
def __eq__(self, other):
"""Compares the ``index`` of this ``AtlasLabel`` with another.
"""
return self.index == other.index
def __neq__(self, other):
"""Compares the ``index`` of this ``AtlasLabel`` with another.
"""
return self.index != other.index
def __lt__(self, other):
"""Compares this ``AtlasLabel`` with another by their ``index``
attribute.
"""
return self.index < other.index
class AtlasDescription(object):
"""An ``AtlasDescription`` instance parses and stores the information
stored in the FSL XML file that describes a single FSL atlas. An XML
......@@ -393,30 +445,9 @@ class AtlasDescription(object):
``numpy`` arrays), one for each image in ``images``,
defining the voxel to world coordinate transformations.
``labels`` A list of ``AtlasLabel`` objects, describing each
``labels`` A list of :class`AtlasLabel` objects, describing each
region / label in the atlas.
================= ======================================================
Each ``AtlasLabel`` instance in the ``labels`` list contains the
following attributes:
========= ==============================================================
``name`` Region name
``index`` For probabilistic atlases, the volume index into the 4D atlas
image that corresponds to this region. For label atlases, the
value of voxels that are in this region. For summary images of
probabilistic atlases, add 1 to this value to get the
corresponding voxel values.
``x`` X coordinate of the region in world space
``y`` Y coordinate of the region in world space
``z`` Z coordinate of the region in world space
========= ==============================================================
.. note:: The ``x``, ``y`` and ``z`` label coordinates are pre-calculated
centre-of-gravity coordinates, as listed in the atlas xml file.
They are in the coordinate system defined by the transformation
matrix for the first image in the ``images`` list.(typically
MNI152 space).
"""
......@@ -471,11 +502,6 @@ class AtlasDescription(object):
self.pixdims .append(i.pixdim[:3])
self.xforms .append(i.voxToWorldMat)
# A container object used for
# storing atlas label information
class AtlasLabel(object):
pass
labels = data.findall('label')
self.labels = []
......@@ -488,14 +514,14 @@ class AtlasDescription(object):
for i, label in enumerate(labels):
al = AtlasLabel()
al.name = label.text
al.index = int( label.attrib['index'])
al.x = float(label.attrib['x'])
al.y = float(label.attrib['y'])
al.z = float(label.attrib['z'])
name = label.text
index = int( label.attrib['index'])
x = float(label.attrib['x'])
y = float(label.attrib['y'])
z = float(label.attrib['z'])
al = AtlasLabel(name, index, x, y, z)
coords[i] = (al.x, al.y, al.z)
coords[i] = (x, y, z)
self.labels.append(al)
......@@ -507,7 +533,6 @@ class AtlasDescription(object):
# Update the coordinates
# in our label objects
for i, label in enumerate(self.labels):
label.x, label.y, label.z = coords[i]
......
......@@ -34,6 +34,7 @@ and file names:
import os
import os.path as op
import string
import logging
import six
......@@ -290,6 +291,33 @@ class Nifti(notifier.Notifier):
return origShape, shape, pixdims
def strval(self, key):
"""Returns the specified NIFTI header field, converted to a python
string, correctly null-terminated, and with non-printable characters
removed.
This method is used to sanitise some NIFTI header fields. The default
Python behaviour for converting a sequence of bytes to a string is to
strip all termination characters (bytes with value of ``0x00``) from
the end of the sequence.
This default behaviour does not handle the case where a sequence of
bytes which did contain a long string is subsequently overwritten with
a shorter string - the short string will be terminated, but that
termination character will be followed by the remainder of the
original string.
"""
val = self.header[key]
try: val = bytes(val).partition(b'\0')[0]
except: val = bytes(val)
val = val.decode('ascii')
return ''.join([c for c in val if c in string.printable]).strip()
@property
def niftiVersion(self):
"""Returns the NIFTI file version:
......@@ -662,7 +690,8 @@ class Image(Nifti):
loadData=True,
calcRange=True,
indexed=False,
threaded=False):
threaded=False,
**kwargs):
"""Create an ``Image`` object with the given image data or file name.
:arg image: A string containing the name of an image file to load,
......@@ -706,6 +735,9 @@ class Image(Nifti):
:arg threaded: If ``True``, the :class:`.ImageWrapper` will use a
separate thread for data range calculation. Defaults
to ``False``. Ignored if ``loadData`` is ``True``.
All other arguments are passed through to the ``nibabel.load`` function
(if it is called).
"""
nibImage = None
......@@ -735,7 +767,7 @@ class Image(Nifti):
# Otherwise we let nibabel
# manage the file reference(s)
else:
nibImage = nib.load(image)
nibImage = nib.load(image, **kwargs)
dataSource = image
......
......@@ -84,27 +84,26 @@ def isWidgetAlive(widget):
"""Returns ``True`` if the given ``wx.Window`` object is "alive" (i.e.
has not been destroyed), ``False`` otherwise. Works in both wxPython
and wxPython/Phoenix.
.. warning:: Don't try to test whether a ``wx.MenuItem`` has been
destroyed, as it will probably result in segmentation
faults. Check the parent ``wx.Menu`` instead.
"""
import wx
if platform.wxFlavour == WX_PHOENIX:
return bool(widget)
elif platform.wxFlavour == WX_PYTHON:
try:
# GetId seems to be available on all wx
# objects, despite not being documented.
#
# I was originally calling IsEnabled,
# but this causes segfaults if called
# on a wx.MenuItem from within an
# event handler on that menu item!
widget.GetId()
return True
except wx.PyDeadObjectError:
return False
if platform.wxFlavour == platform.WX_PHOENIX:
excType = RuntimeError
elif platform.wxFlavour == platform.WX_PYTHON:
excType = wx.PyDeadObjectError
try:
widget.GetParent()
return True
except excType:
return False
class Platform(notifier.Notifier):
......@@ -146,6 +145,7 @@ class Platform(notifier.Notifier):
self.isWidgetAlive = isWidgetAlive
self.__inSSHSession = False
self.__inVNCSession = False
self.__glVersion = None
self.__glRenderer = None
self.__glIsSoftware = None
......@@ -163,17 +163,14 @@ class Platform(notifier.Notifier):
except ImportError:
self.__canHaveGui = False
# If one of the SSH_/VNC environment
# variables is set, then we're probably
# running over SSH/VNC.
sshVars = ['SSH_CLIENT', 'SSH_TTY']
vncVars = ['VNCDESKTOP', 'X2GO_SESSION', 'NXSESSIONID']
# If one of the SSH_ environment
# variables is set, and we're
# not running in a VNC session,
# then we're probably running
# over SSH.
inSSH = 'SSH_CLIENT' in os.environ or \
'SSH_TTY' in os.environ
inVNC = 'VNCDESKTOP' in os.environ
self.__inSSHSession = inSSH and not inVNC
self.__inSSHSession = any(s in os.environ for s in sshVars)
self.__inVNCSession = any(v in os.environ for v in vncVars)
@property
......@@ -220,6 +217,19 @@ class Platform(notifier.Notifier):
return self.__inSSHSession
@property
def inVNCSession(self):
"""``True`` if this application is running over a VNC (or similar)
session, ``False`` otherwise. Currently, the following remote desktop
environments are detected:
- VNC
- x2go
- NoMachine
"""
return self.__inVNCSession
@property
def wxPlatform(self):
"""One of :data:`WX_UNKNOWN`, :data:`WX_MAC_COCOA`,
......@@ -353,9 +363,6 @@ class Platform(notifier.Notifier):
# necessary.
self.__glIsSoftware = any((
'software' in value,
'mesa' in value,
'gallium' in value,
'llvmpipe' in value,
'chromium' in value,
))
......
......@@ -373,9 +373,9 @@ class Settings(object):
with open(configFile, 'rb') as f:
return pickle.load(f)
except:
log.warning('Unable to load stored {} configuration file '
'{}'.format(self.__configID, configFile),
exc_info=True)
log.debug('Unable to load stored {} configuration file '
'{}'.format(self.__configID, configFile),
exc_info=True)
return {}
......
......@@ -2,4 +2,4 @@ six>=1.10.0,<2.0
numpy>=1.11.1,<2.0
nibabel>=2.1,<3.0
indexed_gzip>=0.3.3,<0.4
wxPython>=3.0.2.0,<=4.0
wxPython>=3.0.2.0,<=4.0.0a2
......@@ -102,11 +102,11 @@ setup(
install_requires=install_requires,
setup_requires=['pytest-runner'],
tests_require=['pytest',
'mock',
tests_require=['mock',
'pytest-cov',
'pytest-html',
'pytest-runner'],
'pytest-runner',
'pytest'],
test_suite='tests',
cmdclass={'doc' : doc},
......
......@@ -10,7 +10,9 @@ import os.path as op
import subprocess as sp
import numpy as np
import nibabel as nib
import mock
import pytest
import fsl.utils.callfsl as callfsl
......@@ -24,6 +26,17 @@ def setup_module():
raise Exception('FSLDIR is not set - callfsl tests cannot be run')
# mock subprocess.check_output command
# which expects 'fslstats -m filename'
# or 'fslinfo ...'
def mock_check_output(args):
if args[0].endswith('fslinfo'):
return 'info'
img = nib.load(args[-2])
return str(img.get_data().mean())
def test_callfsl():
with tests.testdir() as testdir:
......@@ -35,12 +48,16 @@ def test_callfsl():
# Pass a single string
cmd = 'fslstats {} -m'.format(fname)
result = callfsl.callFSL(cmd)
assert np.isclose(float(result), img.mean())
# Or pass a list of args
result = callfsl.callFSL(*cmd.split())
assert np.isclose(float(result), img.mean())
with mock.patch('fsl.utils.callfsl.sp.check_output',
mock_check_output):
result = callfsl.callFSL(cmd)
assert np.isclose(float(result), img.mean())
# Or pass a list of args
result = callfsl.callFSL(*cmd.split())
assert np.isclose(float(result), img.mean())
# Bad commands
badcmds = ['fslblob', 'fslstats notafile']
......@@ -51,7 +68,9 @@ def test_callfsl():
# No FSL - should crash
cmd = 'fslinfo {}'.format(fname)
callfsl.callFSL(cmd)
with mock.patch('fsl.utils.callfsl.sp.check_output',
mock_check_output):
callfsl.callFSL(cmd)
fslplatform.fsldir = None
with pytest.raises(Exception):
callfsl.callFSL(cmd)
......@@ -56,7 +56,7 @@ def make_image(filename=None,
xform = np.eye(4)
for i, p in enumerate(pixdims):
xform[i, i] = p
data = np.array(np.random.random(dims) * 100, dtype=dtype)
if imgtype == 0: img = nib.AnalyzeImage(data, xform, hdr)
......@@ -68,7 +68,7 @@ def make_image(filename=None,
if op.splitext(filename)[1] == '':
if imgtype == 0: filename = '{}.img'.format(filename)
else: filename = '{}.nii'.format(filename)
nib.save(img, filename)
return img
......@@ -95,7 +95,7 @@ def test_load():
'ambiguous.img',
'ambiguous.img.gz',
'notnifti.nii.gz']
shouldPass = ['compressed',
'compressed.nii.gz',
......@@ -121,12 +121,12 @@ def test_load():
testdir = tempfile.mkdtemp()
for f in toCreate:
if f.startswith('notnifti'):
make_dummy_file(op.join(testdir, f))
else:
make_random_image(op.join(testdir, f))
# Not raising an error means the test passes
try:
for fname in shouldPass:
......@@ -164,7 +164,7 @@ def test_create():
assert img.niftiVersion == 1
for imgType in [0, 1, 2]:
nimg = make_image(imgtype=imgType, pixdims=(5, 6, 7))
nhdr = nimg.header
......@@ -189,13 +189,13 @@ def test_create():
img = fslimage.Image(nimg)
assert img.niftiVersion == imgtype
assert np.all(np.isclose(img.pixdim, (2, 3, 4)))
finally:
shutil.rmtree(testdir)
def test_bad_create():
class BadThing(object):
pass
......@@ -204,7 +204,7 @@ def test_bad_create():
fslimage.Image(
np.random.random((10, 10, 10)),
header=BadThing())
# Bad data
with pytest.raises(Exception):
fslimage.Image(BadThing())
......@@ -221,18 +221,18 @@ def test_bad_create():
with pytest.raises(Exception):
fslimage.Image(np.random.random(10, 10, 10),
xform=np.eye(3))
with pytest.raises(Exception):
fslimage.Image(np.random.random(10, 10, 10),
xform=np.eye(5))
def test_Image_atts_analyze(): _test_Image_atts(0)
def test_Image_atts_analyze(): _test_Image_atts(0)
def test_Image_atts_nifti1(): _test_Image_atts(1)
def test_Image_atts_nifti2(): _test_Image_atts(2)
def _test_Image_atts(imgtype):
"""Test that basic Nifti/Image attributes are correct. """
testdir = tempfile.mkdtemp()
allowedExts = fslimage.ALLOWED_EXTENSIONS
fileGroups = fslimage.FILE_GROUPS
......@@ -259,17 +259,17 @@ def _test_Image_atts(imgtype):
tests = it.product(dims, pixdims, dtypes)
tests = list(tests)
paths = ['test{:03d}'.format(i) for i in range(len(tests))]
for path, atts in zip(paths, tests):
dims, pixdims, dtype = atts
ndims = len(dims)
pixdims = pixdims[:ndims]
pixdims = pixdims[:ndims]
path = op.abspath(op.join(testdir, path))
make_image(path, imgtype, dims, pixdims, dtype)
try:
for path, atts in zip(paths, tests):
......@@ -295,12 +295,12 @@ def _test_Image_atts(imgtype):
fileGroups=fileGroups)
finally:
shutil.rmtree(testdir)
def test_Image_atts2_analyze(): _test_Image_atts2(0)
def test_Image_atts2_analyze(): _test_Image_atts2(0)
def test_Image_atts2_nifti1(): _test_Image_atts2(1)
def test_Image_atts2_nifti2(): _test_Image_atts2(2)
def _test_Image_atts2(imgtype):
# See fsl.utils.constants for the meanings of these codes
xyzUnits = [0, 1, 2, 3]
timeUnits = [8, 16, 24, 32, 40, 48]
......@@ -392,7 +392,7 @@ def test_addExt():
for path in toCreate:
path = op.abspath(op.join(testdir, path))
make_random_image(path)
make_random_image(path)
try:
for path, mustExist, expected in tests:
......@@ -456,14 +456,14 @@ def test_defaultExt():
os.environ['FSLOUTPUTTYPE'] = o
assert fslimage.defaultExt() == e
def test_Image_orientation_analyze_neuro(): _test_Image_orientation(0, 'neuro')
def test_Image_orientation_analyze_radio(): _test_Image_orientation(0, 'radio')
def test_Image_orientation_nifti1_neuro(): _test_Image_orientation(1, 'neuro')
def test_Image_orientation_nifti1_radio(): _test_Image_orientation(1, 'radio')
def test_Image_orientation_nifti2_neuro(): _test_Image_orientation(2, 'neuro')
def test_Image_orientation_nifti2_radio(): _test_Image_orientation(2, 'radio')
def test_Image_orientation_nifti2_radio(): _test_Image_orientation(2, 'radio')
def _test_Image_orientation(imgtype, voxorient):
"""Test the Nifti.isNeurological and Nifti.getOrien