diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dae80e9d6fc3df091bef68599230e7cfa58d1b96..f47a711bc6f641f6aebcebbeda39ecb597f551f1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,16 +2,16 @@ This document contains the ``fslpy`` release history in reverse chronological order. -3.0.0 (Under development) -------------------------- +3.0.0 (Sunday 29th March 2020) +------------------------------ Added ^^^^^ -* New wrapper functions for the FSL :func:`.prelude` and :func:`applyxfm4D` - commands. +* New wrapper functions for the FSL :class:`.fslstats`, :func:`.prelude` and + :func:`applyxfm4D` commands. * New ``firstDot`` option to the :func:`.path.getExt`, :func:`.path.removeExt`, and :func:`.path.splitExt`, functions, offering rudimentary support for double-barrelled filenames. @@ -19,6 +19,7 @@ Added affine, which is applied to the input image before the deformation field. * New :class:`.SubmitParams` class, providing a higer level interface for cluster submission. +* New :meth:`.FileTree.load_json` and :meth:`.FileTree.save_json` methods. Changed @@ -26,6 +27,10 @@ Changed * ``fslpy`` now requires a minimum Python version of 3.7. +* The default value for the ``partial_fill`` option to :meth:`.FileTree.read` + has been changed to ``False``. Accordingly, the :class:`.FileTreeQuery` + calls the :meth:`.FileTree.partial_fill` method on the ``FileTree`` it is + given. * The :func:`.gifti.relatedFiles` function now supports files with BIDS-style naming conventions. * The :func:`.run.run` and :func:`.run.runfsl` functions now pass through any @@ -54,6 +59,8 @@ Changed * The :func:`.fileOrText` decorator has been updated to work with input values - file paths must be passed in as ``pathlib.Path`` objects, so they can be differentiated from input values. +* Loaded :class:`.Image` objects returned by :mod:`fsl.wrappers` functions + are now named according to the wrapper function argument name. Fixed @@ -69,6 +76,24 @@ Fixed a single set of coordinates. +Removed +^^^^^^^ + + +* Removed the deprecated ``.StatisticAtlas.proportions``, + ``.StatisticAtlas.coordProportions``, and + ``.StatisticAtlas.maskProportions`` methods. +* Removed the deprecated ``indexed`` option to :meth:`.Image.__init__`. +* Removed the deprecated ``.Image.resample`` method. +* Removed the deprecated ``.image.loadIndexedImageFile`` function. +* Removed the deprecatd ``.FileTreeQuery.short_names`` and + ``.Match.short_name`` properties. +* Removed the deprecated ``.idle.inIdle``, ``.idle.cancelIdle``, + ``.idle.idleReset``, ``.idle.getIdleTimeout``, and + ``.idle.setIdleTimeout`` functions. +* Removed the deprecated ``resample.calculateMatrix`` function. + + 2.8.4 (Monday 2nd March 2020) ----------------------------- diff --git a/fsl/data/atlases.py b/fsl/data/atlases.py index 28ca1825a5b3804ca4e6e5cbabad9084a1470fb4..fa30529fb12cba90cd8d97bb5f8cc27de900331f 100644 --- a/fsl/data/atlases.py +++ b/fsl/data/atlases.py @@ -58,7 +58,6 @@ import fsl.utils.image.resample as resample import fsl.transform.affine as affine import fsl.utils.notifier as notifier import fsl.utils.settings as fslsettings -import fsl.utils.deprecated as deprecated log = logging.getLogger(__name__) @@ -1085,24 +1084,6 @@ class StatisticAtlas(Atlas): return avgvals - @deprecated.deprecated('2.6.0', '3.0.0', 'Use values instead') - def proportions(self, *args, **kwargs): - """Deprecated - use :meth:`values` instead. """ - return self.values(*args, **kwargs) - - - @deprecated.deprecated('2.6.0', '3.0.0', 'Use coordValues instead') - def coordProportions(self, *args, **kwargs): - """Deprecated - use :meth:`coordValues` instead. """ - return self.coordValues(*args, **kwargs) - - - @deprecated.deprecated('2.6.0', '3.0.0', 'Use maskValues instead') - def maskProportions(self, *args, **kwargs): - """Deprecated - use :meth:`maskValues` instead. """ - return self.maskValues(*args, **kwargs) - - class ProbabilisticAtlas(StatisticAtlas): """A 4D atlas which contains one volume for each region. Each volume contains probabiliy values for one region, between 0 and 100. diff --git a/fsl/data/image.py b/fsl/data/image.py index 97fe2d72259864a9890f571ea91671e43a56659b..8cc5c62087b25ebe9f463dcc7e95b1f57f3fe484 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -52,7 +52,6 @@ import fsl.transform.affine as affine import fsl.utils.notifier as notifier import fsl.utils.memoize as memoize import fsl.utils.path as fslpath -import fsl.utils.deprecated as deprecated import fsl.utils.bids as fslbids import fsl.data.constants as constants import fsl.data.imagewrapper as imagewrapper @@ -993,7 +992,6 @@ class Image(Nifti): xform=None, loadData=True, calcRange=True, - indexed=False, threaded=False, dataSource=None, loadMeta=False, @@ -1002,7 +1000,7 @@ class Image(Nifti): :arg image: A string containing the name of an image file to load, or a :mod:`numpy` array, or a :mod:`nibabel` image - object. + object, or an ``Image``object. :arg name: A name for the image. @@ -1035,9 +1033,6 @@ class Image(Nifti): incrementally updated as more data is read from memory or disk. - :arg indexed: Deprecated. Has no effect, and will be removed in - ``fslpy`` 3.0. - :arg threaded: If ``True``, the :class:`.ImageWrapper` will use a separate thread for data range calculation. Defaults to ``False``. Ignored if ``loadData`` is ``True``. @@ -1059,12 +1054,6 @@ class Image(Nifti): nibImage = None saved = False - if indexed is not False: - warnings.warn('The indexed argument is deprecated ' - 'and has no effect', - category=DeprecationWarning, - stacklevel=2) - if loadData: threaded = False @@ -1463,14 +1452,6 @@ class Image(Nifti): self.notify(topic='saveState') - @deprecated.deprecated('2.2.0', '3.0.0', - 'Use fsl.utils.image.resample instead.') - def resample(self, *args, **kwargs): - """Deprecated - use :func:`.image.resample` instead. """ - from fsl.utils.image.resample import resample - return resample(self, *args, **kwargs) - - def __getitem__(self, sliceobj): """Access the image data with the specified ``sliceobj``. @@ -1661,9 +1642,3 @@ def defaultExt(): outputType = os.environ.get('FSLOUTPUTTYPE', 'NIFTI_GZ') return options.get(outputType, '.nii.gz') - - -@deprecated.deprecated('2.0.0', '3.0.0', 'Use nibabel.load instead.') -def loadIndexedImageFile(filename): - """Deprecated - this call is equivalent to calling ``nibabel.load``. """ - return nib.load(filename) diff --git a/fsl/utils/filetree/query.py b/fsl/utils/filetree/query.py index d371bcdf37a7d31d7ed333e602ba50f992477ade..a83982083515261bb7cefd063fe697e03d5ed698 100644 --- a/fsl/utils/filetree/query.py +++ b/fsl/utils/filetree/query.py @@ -31,8 +31,7 @@ from typing import Dict, List, Tuple import numpy as np -from fsl.utils.deprecated import deprecated -from . import FileTree +from . import FileTree log = logging.getLogger(__name__) @@ -207,15 +206,6 @@ class FileTreeQuery(object): return list(self.__templatevars.keys()) - @property - @deprecated('2.6.0', '3.0.0', 'Use templates instead') - def short_names(self) -> List[str]: - """Returns a list containing all templates of the ``FileTree`` that - are present in the directory. - """ - return self.templates - - def query(self, template, asarray=False, **variables): """Search for files of the given ``template``, which match the specified ``variables``. All hits are returned for variables @@ -292,12 +282,6 @@ class Match(object): return self.__filename - @property - @deprecated('2.6.0', '3.0.0', 'Use template instead') - def short_name(self): - return self.template - - @property def template(self): return self.__template diff --git a/fsl/utils/idle.py b/fsl/utils/idle.py index d2c93aeb08e0928df4e0dcceb84adf32dc98550f..bef726633b40f8977a3a77a429d77917f7c398b0 100644 --- a/fsl/utils/idle.py +++ b/fsl/utils/idle.py @@ -85,8 +85,6 @@ from collections import abc try: import queue except ImportError: import Queue as queue -from fsl.utils.deprecated import deprecated - log = logging.getLogger(__name__) @@ -597,36 +595,6 @@ def idleWhen(*args, **kwargs): idleLoop.idleWhen(*args, **kwargs) -@deprecated('2.7.0', '3.0.0', 'Use idleLoop.inIdle instead') -def inIdle(taskName): - """Deprecated - use ``idleLoop.inIdle`` instead. """ - return idleLoop.inIdle(taskName) - - -@deprecated('2.7.0', '3.0.0', 'Use idleLoop.cancelIdle instead') -def cancelIdle(taskName): - """Deprecated - use ``idleLoop.cancelIdle`` instead. """ - return idleLoop.cancelIdle(taskName) - - -@deprecated('2.7.0', '3.0.0', 'Use idleLoop.reset instead') -def idleReset(): - """Deprecated - use ``idleLoop.reset`` instead. """ - return idleLoop.reset() - - -@deprecated('2.7.0', '3.0.0', 'Use idleLoop.callRate instead') -def getIdleTimeout(): - """Deprecated - use ``idleLoop.callRate`` instead. """ - return idleLoop.callRate - - -@deprecated('2.7.0', '3.0.0', 'Use idleLoop.callRate instead') -def setIdleTimeout(timeout=None): - """Deprecated - use ``idleLoop.callRate`` instead. """ - idleLoop.callRate = timeout - - def block(secs, delta=0.01, until=None): """Blocks for the specified number of seconds, yielding to the main ``wx`` loop. diff --git a/fsl/utils/image/resample.py b/fsl/utils/image/resample.py index 371e87b602c60b777bc6e8587aec32b185f08e1d..f5918fad29f18ff0912968c56293ed67ac44c0ae 100644 --- a/fsl/utils/image/resample.py +++ b/fsl/utils/image/resample.py @@ -18,7 +18,6 @@ import numpy as np import scipy.ndimage as ndimage import fsl.transform.affine as affine -import fsl.utils.deprecated as deprecated def resampleToPixdims(image, newPixdims, **kwargs): @@ -282,13 +281,3 @@ def applySmoothing(data, matrix, newShape): sigma[ratio >= 1.1] *= 0.425 return ndimage.gaussian_filter(data, sigma) - - -@deprecated.deprecated('2.8.0', '3.0.0', - 'Use fsl.transform.affine.rescale instead') -def calculateMatrix(oldShape, newShape, origin): - """Deprecated - use :func:`.affine.rescale` instead. """ - xform = affine.rescale(oldShape, newShape, origin) - if np.all(np.isclose(xform, np.eye(len(oldShape) + 1))): - return None - return xform[:-1, :] diff --git a/fsl/utils/run.py b/fsl/utils/run.py index 70915cf2dea6d0adcc68cd733283382c634153d1..caacbf9761bc7c9da7e198449915ee848ce6c04b 100644 --- a/fsl/utils/run.py +++ b/fsl/utils/run.py @@ -180,13 +180,17 @@ def run(*args, **kwargs): returnStderr = kwargs.pop('stderr', False) returnExitcode = kwargs.pop('exitcode', False) submit = kwargs.pop('submit', {}) - log = kwargs.pop('log', {}) - tee = log .get('tee', False) - logStdout = log .get('stdout', None) - logStderr = log .get('stderr', None) - logCmd = log .get('cmd', None) + log = kwargs.pop('log', None) args = prepareArgs(args) + if log is None: + log = {} + + tee = log.get('tee', False) + logStdout = log.get('stdout', None) + logStderr = log.get('stderr', None) + logCmd = log.get('cmd', None) + if not bool(submit): submit = None diff --git a/fsl/utils/transform.py b/fsl/utils/transform.py deleted file mode 100644 index b3d51afb21f0e5a5a861458d4fde9e2e62daebc2..0000000000000000000000000000000000000000 --- a/fsl/utils/transform.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python -# -# transforms.py - Deprecated -# -# Author: Paul McCarthy <pauldmccarthy@gmail.com> -# -"""The ``fsl.utils.transform`` module is deprecated - use the -:mod:`fsl.transform` module instead. -""" - - -import fsl.utils.deprecated as deprecated -from fsl.transform.affine import * # noqa -from fsl.transform.flirt import (flirtMatrixToSform, # noqa - sformToFlirtMatrix) - - -deprecated.warn('fsl.utils.transform', - vin='2.4.0', - rin='3.0.0', - msg='Use the fsl.transform module instead') diff --git a/fsl/version.py b/fsl/version.py index 5078f11fae8655123c981a23615b0b47a641fd2f..34d5447e181f21be4dfed288ad18931acda71e1b 100644 --- a/fsl/version.py +++ b/fsl/version.py @@ -47,7 +47,7 @@ import re import string -__version__ = '2.9.0.dev0' +__version__ = '3.1.0.dev0' """Current version number, as a string. """ diff --git a/fsl/wrappers/__init__.py b/fsl/wrappers/__init__.py index 5e1f61e89dda468017033e20b526712d70dda4c4..87f410cb9c5475ab4542e96c074d0c04327763e1 100644 --- a/fsl/wrappers/__init__.py +++ b/fsl/wrappers/__init__.py @@ -37,12 +37,15 @@ instead. Aliases may also be used to provide a more readable interface (e.g. the :func:`.bet` function uses ``mask`` instead of ``m``). -One exception to the above is :class:`.fslmaths`, which provides a more -object-oriented interface:: +Two exceptions to the above are :class:`.fslmaths` and :class:`.fslstats`, +which provide a more object-oriented interface:: + + from fsl.wrappers import fslmaths, fslstats - from fsl.wrappers import fslmaths fslmaths('image.nii').mas('mask.nii').bin().run('output.nii') + imean, imin, imax = fslstats('image.nii').k('mask.nii').m.R.run() + Wrapper functions for commands which accept NIfTI image or numeric text files will for the most part accept either in-memory ``nibabel`` images/Numpy arrays @@ -95,6 +98,7 @@ from .fnirt import (fnirt, # noqa invwarp, convertwarp) from .fslmaths import (fslmaths,) # noqa +from .fslstats import (fslstats,) # noqa from .fugue import (fugue, # noqa prelude, sigloss) diff --git a/fsl/wrappers/fslstats.py b/fsl/wrappers/fslstats.py new file mode 100644 index 0000000000000000000000000000000000000000..c4ad2e2ddca49a83664283245bcbc8ec499cca3c --- /dev/null +++ b/fsl/wrappers/fslstats.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python +# +# fslstats.py - Wrapper for fslstats +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""This module provides the :class:`fslstats` class, which acts as a wrapper +for the ``fslstats`` command-line tool. + + +.. warning:: This wrapper function will only work with FSL 6.0.2 or newer. +""" + + +import io +import functools as ft +import numpy as np + +import fsl.data.image as fslimage +from . import wrapperutils as wutils + + +class fslstats(object): + """The ``fslstats`` class is a wrapper around the ``fslstats`` command-line + tool. It provides an object-oriented interface - options are specified by + chaining method calls and attribute accesses together. + + + .. warning:: This wrapper function will only work with FSL 6.0.2 or newer, + due to bugs in ``fslstats`` output formatting that are + present in older versions. + + + Any ``fslstats`` command-line option which does not require any arguments + (e.g. ``-r``) can be set by accessing an attribute on a ``fslstats`` + object, e.g.:: + + stats = fslstats('image.nii.gz') + stats.r + + + ``fslstats`` command-line options which do require additional arguments + (e.g. ``-k``) can be set by calling a method on an ``fslstats`` object, + e.g.:: + + stats = fslstats('image.nii.gz') + stats.k('mask.nii.gz') + + + The ``fslstats`` command can be executed via the :meth:`run` method. + Normally, the results will be returned as a scalar floating point number, + or a ``numpy`` array. Pre-options will affect the structure of the return + value - see :meth:`__init__` for details. + + + Attribute and method calls can be chained together, so a complete + ``fslstats`` call can be performed in a single line, e.g.:: + + imgmin, imgmax = fslstats('image.nii.gz').k('mask.nii.gz').r.run() + """ + + OPTIONS = { + 'robust_minmax' : 'r', + 'minmax' : 'R', + 'mean_entropy' : 'e', + 'mean_entropy_nz' : 'E', + 'volume' : 'v', + 'volume_nz' : 'V', + 'mean' : 'm', + 'mean_nz' : 'M', + 'stddev' : 's', + 'stddev_nz' : 'S', + 'smallest_roi' : 'w', + 'max_vox' : 'x', + 'min_vox' : 'X', + 'cog_mm' : 'c', + 'cog_vox' : 'C', + 'abs' : 'a', + 'zero_naninf' : 'n', + } + """This dict contains options which do not require any additional + arguments. They are set via attribute access on the ``fslstats`` + object. + """ + + + ARG_OPTIONS = { + 'lower_threshold' : 'l', + 'upper_threshold' : 'u', + 'percentile' : 'p', + 'percentile_nz' : 'P', + 'mask' : 'k', + 'diff' : 'd', + 'hist' : 'h', + 'hist_bounded' : 'H', + } + """This dict contains options which require additional arguments. + They are set via method calls on the ``fslstats`` object (with the + additional arguments passed into the method call). + """ + + + # add {shortopt : shortopt} mappings + # for all options to simplify code in + # the fslstats class + OPTIONS .update({v : v for v in OPTIONS .values()}) + ARG_OPTIONS.update({v : v for v in ARG_OPTIONS.values()}) + + + def __init__(self, + input, + t=False, + K=None, + sep_volumes=False, + index_mask=None): + """Create a ``fslstats`` object. + + If one of the ``t`` or ``K`` pre-options is set, e.g.:: + + fslstats('image_4d.nii.gz', t=True) + + or:: + + fslstats('image_4d.nii.gz', K='mask.nii.gz') + + then :meth:`run` will return a 2D ``numpy`` array of shape ``(nvols, + nvals)`` if ``t`` is set, or ``(nlabels, nvals)`` if ``K`` is set. + + If both of the ``t`` and ``K`` pre-options are set, e.g.:: + + fslstats('image_4d.nii.gz', t=True, K='mask.nii.gz') + + then the result will be a 3D numpy array of shape ``(nvols, nlabels, + nvals)``. + + If neither ``t`` or ``K`` are set, then the result will be a scalar, + or a 1D ``numpy`` array. + + :arg input: Input image - either a file name, or an + :class:`.Image` object, or a ``nibabel.Nifti1Image`` + object. + :arg t: Produce separate results for each 3D volume in the + input image. + :arg K: Produce separate results for each sub-mask within + the provided mask image. + :arg sep_volumes: Alias for ``t``. + :arg index_mask: Alias for ``K``. + """ + + if t is None: t = sep_volumes + if K is None: K = index_mask + + self.__input = input + self.__options = [] + + # pre-options must be supplied + # before input image + if t: self.__options.append( '-t') + if K is not None: self.__options.extend(('-K', K)) + + self.__options.append(input) + + + def __getattr__(self, name): + """Intercepts attribute accesses and accumulates ``fslstats`` command-line + flags accordingly. + """ + + # options which take no args + # are called as attributes + if name in fslstats.OPTIONS: + flag = fslstats.OPTIONS[name] + args = False + + # options which take args + # are called as methods + elif name in fslstats.ARG_OPTIONS: + flag = fslstats.ARG_OPTIONS[name] + args = True + else: + raise AttributeError(name) + + addFlag = ft.partial(self.__addFlag, flag) + + if args: return addFlag + else: return addFlag() + + + def __addFlag(self, flag, *args): + """Used by :meth:`__getattr__`. Add the given flag and any arguments to + the accumulated list of command-line options. + """ + self.__options.extend(('-' + flag,) + args) + return self + + + def run(self, raw=False): + """Run the ``fslstats`` command-line tool. See :meth:`__init__` for a + description of the return value. + + :arg raw: Defaults to ``False``. If ``True``, the raw standard output + and error is returned, instead of a scalar/numpy array. + + :returns: Result of ``fslstats`` as a scalar or ``numpy`` array. + """ + + # The parsing logic below will not work + # with versions of fslstats prior to fsl + # 6.0.2, due to a quirk in the output + # formatting of older versions. + + # The default behaviour of run/runfsl + # is to tee the command output streams + # to the calling process streams. We + # can disable this via log=None. + result = self.__run('fslstats', *self.__options, log=None) + + if raw: + return result.stdout + + result = np.genfromtxt(io.StringIO(result.stdout[0].strip())) + sepvols = '-t' in self.__options + lblmask = '-K' in self.__options + + # One line of output for each volume and + # for each label (with volume the slowest + # changing). Reshape to 3D. + if sepvols and lblmask: + + # We need know the number of volumes + # (or the number of labels) in order + # to know how to shape the results. + img = fslimage.Image(self.__input, loadData=False) + + if img.ndim >= 4: nvols = img.shape[3] + else: nvols = 1 + + # reshape the result into + # (nvals, nvols, nlbls) + nlbls = int(len(result) / nvols) + result = result.reshape((nvols, nlbls, -1)).squeeze() + + # Scalar - use numpy indexing weirdness + # to get our single value out. + elif result.size == 1: + result = result[()] + + return result + + + @wutils.fileOrImage() + @wutils.fslwrapper + def __run(self, *cmd): + """Run the given ``fslmaths`` command. """ + return [str(c) for c in cmd] diff --git a/fsl/wrappers/wrapperutils.py b/fsl/wrappers/wrapperutils.py index f325bca18d5f5c38990b0db57c8066746acf392a..150ef0ae979d520cfd68404ba91238af6a66eb09 100644 --- a/fsl/wrappers/wrapperutils.py +++ b/fsl/wrappers/wrapperutils.py @@ -403,18 +403,18 @@ def namedPositionals(func, args): LOAD = object() -"""Constant used by the :class:`_FileOrThing` class to indicate that an output +"""Constant used by the :class:`FileOrThing` class to indicate that an output file should be loaded into memory and returned as a Python object. """ -class _FileOrThing(object): +class FileOrThing(object): """Decorator which ensures that certain arguments which are passed into the decorated function are always passed as file names. Both positional and keyword arguments can be specified. - The ``_FileOrThing`` class is not intended to be used directly - see the + The ``FileOrThing`` class is not intended to be used directly - see the :func:`fileOrImage` and :func:`fileOrArray` decorator functions for more details. @@ -445,7 +445,7 @@ class _FileOrThing(object): **Return value** - Functions decorated with a ``_FileOrThing`` decorator will always return a + Functions decorated with a ``FileOrThing`` decorator will always return a ``dict``-like object, where the function's actual return value is accessible via an attribute called ``stdout``. All output arguments with a value of ``LOAD`` will be present as dictionary entries, with the keyword @@ -460,7 +460,7 @@ class _FileOrThing(object): The above description holds in all situations, except when an argument called ``submit`` is passed, and is set to a value which evaluates to - ``True``. In this case, the ``_FileOrThing`` decorator will pass all + ``True``. In this case, the ``FileOrThing`` decorator will pass all arguments straight through to the decorated function, and will return its return value unchanged. @@ -534,27 +534,27 @@ class _FileOrThing(object): **Using with other decorators** - ``_FileOrThing`` decorators can be chained with other ``_FileOrThing`` - decorators, and other decorators. When multiple ``_FileOrThing`` + ``FileOrThing`` decorators can be chained with other ``FileOrThing`` + decorators, and other decorators. When multiple ``FileOrThing`` decorators are used on a single function, the outputs from each decorator are merged together into a single dict-like object. - ``_FileOrThing`` decorators can be used with any other decorators + ``FileOrThing`` decorators can be used with any other decorators **as long as** they do not manipulate the return value, and as long as - the ``_FileOrThing`` decorators are adjacent to each other. + the ``FileOrThing`` decorators are adjacent to each other. """ - class _Results(dict): + class Results(dict): """A custom ``dict`` type used to return outputs from a function - decorated with ``_FileOrThing``. All outputs are stored as dictionary + decorated with ``FileOrThing``. All outputs are stored as dictionary items, with the argument name as key, and the output object (the "thing") as value. Where possible (i.e. for outputs named with a valid Python identifier), the outputs are also made accessible as attributes of - the ``_Results`` object. + the ``Results`` object. The decorated function's actual return value is accessible via the :meth:`stdout` property. @@ -562,7 +562,7 @@ class _FileOrThing(object): def __init__(self, stdout): - """Create a ``_Results`` dict. + """Create a ``Results`` dict. :arg stdout: Return value of the decorated function (typically a tuple containing the standard output and error of the @@ -593,7 +593,7 @@ class _FileOrThing(object): removeExt, *args, **kwargs): - """Initialise a ``_FileOrThing`` decorator. + """Initialise a ``FileOrThing`` decorator. :arg func: The function to be decorated. @@ -604,8 +604,10 @@ class _FileOrThing(object): arguments that were set to :data:`LOAD`. :arg load: Function which is called to load items for arguments - that were set to :data:`LOAD`. Must accept a file path - as its sole argument. + that were set to :data:`LOAD`. Must accept the + following arguments: + - the name of the argument + - path to the file to be loaded :arg removeExt: Function which can remove a file extension from a file path. @@ -618,7 +620,7 @@ class _FileOrThing(object): All other positional arguments are interpreted as the names of the arguments to the function which will be handled by this - ``_FileOrThing`` decorator. If not provided, *all* arguments passed to + ``FileOrThing`` decorator. If not provided, *all* arguments passed to the function will be handled. @@ -673,8 +675,8 @@ class _FileOrThing(object): 'or LOAD with submit=True!') return func(*args, **kwargs) - # If this _FileOrThing is being called - # by another _FileOrThing don't create + # If this FileOrThing is being called + # by another FileOrThing don't create # another working directory. We do this # sneakily, by setting an attribute on # the wrapped function which stores the @@ -734,8 +736,8 @@ class _FileOrThing(object): passed to the ``prepOut`` function specified at :meth:`__init__`. All other arguments are passed through the ``prepIn`` function. - :arg parent: ``True`` if this ``_FileOrThing`` is the first in a - chain of ``_FileOrThing`` decorators. + :arg parent: ``True`` if this ``FileOrThing`` is the first in a + chain of ``FileOrThing`` decorators. :arg workdir: Directory in which all temporary files should be stored. @@ -760,7 +762,7 @@ class _FileOrThing(object): - A dictionary of ``{ name : filename }`` mappings, for all arguments with a value of ``LOAD``. - - A dictionary ``{ filepat : replstr }`` paths, for + - A dictionary of ``{ filepat : replstr }`` paths, for all output-prefix arguments with a value of ``LOAD``. """ @@ -779,7 +781,7 @@ class _FileOrThing(object): # Prefixed outputs are only # managed by the parent - # _FileOrthing in a chain of + # FileOrthing in a chain of # FoT decorators. if not parent: prefix = None @@ -894,11 +896,11 @@ class _FileOrThing(object): def __generateResult( self, workdir, result, outprefix, outfiles, prefixes): - """Loads function outputs and returns a :class:`_Results` object. + """Loads function outputs and returns a :class:`Results` object. Called by :meth:`__call__` after the decorated function has been called. Figures out what files should be loaded, and loads them into - a ``_Results`` object. + a ``Results`` object. :arg workdir: Directory which contains the function outputs. :arg result: Function return value. @@ -909,23 +911,23 @@ class _FileOrThing(object): :arg prefixes: Dictionary containing output-prefix patterns to be loaded (see :meth:`__prepareArgs`). - :returns: A ``_Results`` object containing all loaded outputs. + :returns: A ``Results`` object containing all loaded outputs. """ - # make a _Results object to store + # make a Results object to store # the output. If we are decorating - # another _FileOrThing, the + # another FileOrThing, the # results will get merged together - # into a single _Results dict. - if not isinstance(result, _FileOrThing._Results): - result = _FileOrThing._Results(result) + # into a single Results dict. + if not isinstance(result, FileOrThing.Results): + result = FileOrThing.Results(result) # Load the LOADed outputs for oname, ofile in outfiles.items(): log.debug('Loading output %s: %s', oname, ofile) - if op.exists(ofile): oval = self.__load(ofile) + if op.exists(ofile): oval = self.__load(oname, ofile) else: oval = None result[oname] = oval @@ -953,21 +955,23 @@ class _FileOrThing(object): log.debug('Loading prefixed output %s [%s]: %s', prefPat, prefName, prefixed) + noext = self.__removeExt(prefixed) + prefPat = prefPat.replace('\\', '\\\\') + noext = re.sub('^' + prefPat, prefName, noext) + withext = re.sub('^' + prefPat, prefName, prefixed) + # if the load function returns # None, this file is probably # not of the correct type. - fval = self.__load(fullpath) + fval = self.__load(noext, fullpath) if fval is not None: - noext = self.__removeExt(prefixed) - prefPat = prefPat.replace('\\', '\\\\') - noext = re.sub('^' + prefPat, prefName, noext) + # If there is already an item in result with the # name (stripped of prefix), then instead store # the result with the full prefixed name if noext not in result: result[noext] = fval else: - withext = re.sub('^' + prefPat, prefName, prefixed) result[withext] = fval break @@ -1014,7 +1018,7 @@ def fileOrImage(*args, **kwargs): def prepOut(workdir, name, val): return op.join(workdir, '{}.nii.gz'.format(name)) - def load(path): + def load(name, path): if not fslimage.looksLikeImage(path): return None @@ -1027,7 +1031,8 @@ def fileOrImage(*args, **kwargs): # if any arguments were fsl images, # that takes precedence. if fslimage.Image in intypes: - return fslimage.Image(data, header=img.header) + return fslimage.Image(data, header=img.header, name=name) + # but if all inputs were file names, # nibabel takes precedence elif nib.nifti1.Nifti1Image in intypes or len(intypes) == 0: @@ -1039,13 +1044,13 @@ def fileOrImage(*args, **kwargs): raise RuntimeError('Cannot handle type: {}'.format(intypes)) def decorator(func): - fot = _FileOrThing(func, - prepIn, - prepOut, - load, - fslimage.removeExt, - *args, - **kwargs) + fot = FileOrThing(func, + prepIn, + prepOut, + load, + fslimage.removeExt, + *args, + **kwargs) def wrapper(*args, **kwargs): result = fot(*args, **kwargs) @@ -1076,18 +1081,18 @@ def fileOrArray(*args, **kwargs): def prepOut(workdir, name, val): return op.join(workdir, '{}.txt'.format(name)) - def load(path): + def load(_, path): try: return np.loadtxt(path) except Exception: return None def decorator(func): - fot = _FileOrThing(func, - prepIn, - prepOut, - load, - fslpath.removeExt, - *args, - **kwargs) + fot = FileOrThing(func, + prepIn, + prepOut, + load, + fslpath.removeExt, + *args, + **kwargs) def wrapper(*args, **kwargs): return fot(*args, **kwargs) @@ -1141,20 +1146,20 @@ def fileOrText(*args, **kwargs): def prepOut(workdir, name, val): return op.join(workdir, '{}.txt'.format(name)) - def load(path): + def load(_, path): try: with open(path, "r") as f: return f.read() except Exception: return None def decorator(func): - fot = _FileOrThing(func, - prepIn, - prepOut, - load, - fslpath.removeExt, - *args, - **kwargs) + fot = FileOrThing(func, + prepIn, + prepOut, + load, + fslpath.removeExt, + *args, + **kwargs) def wrapper(*args, **kwargs): return fot(*args, **kwargs) diff --git a/tests/test_scripts/test_resample_image.py b/tests/test_scripts/test_resample_image.py index ed7e944b57e2e4196bf45c10efe341ada0243c84..01063067d0836359999ec205b7e23a3d7dbcb518 100644 --- a/tests/test_scripts/test_resample_image.py +++ b/tests/test_scripts/test_resample_image.py @@ -8,7 +8,7 @@ import pytest import fsl.scripts.resample_image as resample_image -import fsl.utils.transform as transform +import fsl.transform.affine as affine from fsl.utils.tempdir import tempdir from fsl.data.image import Image @@ -22,22 +22,22 @@ def test_resample_image_shape(): resample_image.main('image resampled -s 20,20,20'.split()) res = Image('resampled') - expv2w = transform.concat( + expv2w = affine.concat( img.voxToWorldMat, - transform.scaleOffsetXform([0.5, 0.5, 0.5], 0)) + affine.scaleOffsetXform([0.5, 0.5, 0.5], 0)) assert np.all(np.isclose(res.shape, (20, 20, 20))) assert np.all(np.isclose(res.pixdim, (0.5, 0.5, 0.5))) assert np.all(np.isclose(res.voxToWorldMat, expv2w)) assert np.all(np.isclose( - np.array(transform.axisBounds(res.shape, res.voxToWorldMat)) - 0.25, - transform.axisBounds(img.shape, img.voxToWorldMat))) + np.array(affine.axisBounds(res.shape, res.voxToWorldMat)) - 0.25, + affine.axisBounds(img.shape, img.voxToWorldMat))) resample_image.main('image resampled -s 20,20,20 -o corner'.split()) res = Image('resampled') assert np.all(np.isclose( - transform.axisBounds(res.shape, res.voxToWorldMat), - transform.axisBounds(img.shape, img.voxToWorldMat))) + affine.axisBounds(res.shape, res.voxToWorldMat), + affine.axisBounds(img.shape, img.voxToWorldMat))) def test_resample_image_shape_4D(): @@ -65,9 +65,9 @@ def test_resample_image_dim(): resample_image.main('image resampled -d 0.5,0.5,0.5'.split()) res = Image('resampled') - expv2w = transform.concat( + expv2w = affine.concat( img.voxToWorldMat, - transform.scaleOffsetXform([0.5, 0.5, 0.5], 0)) + affine.scaleOffsetXform([0.5, 0.5, 0.5], 0)) assert np.all(np.isclose(res.shape, (20, 20, 20))) assert np.all(np.isclose(res.pixdim, (0.5, 0.5, 0.5))) diff --git a/tests/test_wrappers/__init__.py b/tests/test_wrappers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_wrappers/test_fslstats.py b/tests/test_wrappers/test_fslstats.py new file mode 100644 index 0000000000000000000000000000000000000000..8d20b4accaf7416a42d02c136d43129028f06fde --- /dev/null +++ b/tests/test_wrappers/test_fslstats.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python + + +import os +import os.path as op +import sys +import contextlib + +import pytest + +import numpy as np + +import fsl.utils.run as run +import fsl.utils.tempdir as tempdir +import fsl.wrappers as fw + +from .. import mockFSLDIR as mockFSLDIR_base, make_random_image + + +mock_fslstats = """ +#!{} + +shape = {{outshape}} + +import sys +import numpy as np + +data = np.random.randint(1, 10, shape) + +if len(shape) == 1: + data = data.reshape(1, -1) + +np.savetxt(sys.stdout, data, fmt='%i') +""".format(sys.executable).strip() + + +@contextlib.contextmanager +def mockFSLDIR(shape): + with mockFSLDIR_base() as fd: + fname = op.join(fd, 'bin', 'fslstats') + script = mock_fslstats.format(outshape=shape) + with open(fname, 'wt') as f: + f.write(script) + os.chmod(fname, 0o755) + yield fd + + +def test_fslstats_cmdline(): + with tempdir.tempdir(), run.dryrun(), mockFSLDIR(1) as fsldir: + + make_random_image('image') + cmd = op.join(fsldir, 'bin', 'fslstats') + + result = fw.fslstats('image').m.r.mask('mask').k('mask').r.run(True) + expected = cmd + ' image -m -r -k mask -k mask -r' + assert result[0] == expected + + result = fw.fslstats('image', t=True, K='mask').m.R.u(123).s.volume.run(True) + expected = cmd + ' -t -K mask image -m -R -u 123 -s -v' + assert result[0] == expected + + result = fw.fslstats('image', K='mask').n.V.p(1).run(True) + expected = cmd + ' -K mask image -n -V -p 1' + assert result[0] == expected + + result = fw.fslstats('image', t=True).H(10, 1, 99).d('diff').run(True) + expected = cmd + ' -t image -H 10 1 99 -d diff' + assert result[0] == expected + + # unknown option + with pytest.raises(AttributeError): + fw.fslstats('image').Q + + +def test_fslstats_result(): + with tempdir.tempdir(): + + with mockFSLDIR('(1,)') as fsldir: + result = fw.fslstats('image').run() + assert np.isscalar(result) + + with mockFSLDIR('(2,)') as fsldir: + result = fw.fslstats('image').run() + assert result.shape == (2,) + + # 3 mask lbls, 2 values + with mockFSLDIR('(3, 2)') as fsldir: + result = fw.fslstats('image', K='mask').run() + assert result.shape == (3, 2) + + # 5 vols, 2 values + with mockFSLDIR('(5, 2)') as fsldir: + result = fw.fslstats('image', t=True).run() + assert result.shape == (5, 2) + + # 5 vols, 3 mask lbls, 2 values + with mockFSLDIR('(15, 2)') as fsldir: + make_random_image('image', (10, 10, 10, 5)) + result = fw.fslstats('image', K='mask', t=True).run() + assert result.shape == (5, 3, 2) + + # -t/-K with a 3D image + with mockFSLDIR('(4,)') as fsldir: + make_random_image('image', (10, 10, 10)) + result = fw.fslstats('image', K='mask', t=True).run() + assert result.shape == (4,) + + result = fw.fslstats('image', t=True).run() + assert result.shape == (4,) + + result = fw.fslstats('image', K='mask').run() + assert result.shape == (4,) diff --git a/tests/test_wrappers.py b/tests/test_wrappers/test_wrappers.py similarity index 99% rename from tests/test_wrappers.py rename to tests/test_wrappers/test_wrappers.py index 1759672a94c96c77cbe7946d86431d63b4cd0e83..695bedfb30e585a6f2dd0c966a2165b298f99d77 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers/test_wrappers.py @@ -17,7 +17,7 @@ import fsl.utils.assertions as asrt import fsl.utils.run as run from fsl.utils.tempdir import tempdir -from . import mockFSLDIR, make_random_image +from .. import mockFSLDIR, make_random_image def checkResult(cmd, base, args, stripdir=None): diff --git a/tests/test_wrapperutils.py b/tests/test_wrappers/test_wrapperutils.py similarity index 99% rename from tests/test_wrapperutils.py rename to tests/test_wrappers/test_wrapperutils.py index c943ae5cf4d02f174793d2e25d8ddce779569d0c..e928057ff858a37b030f149782cf33400fe54e02 100644 --- a/tests/test_wrapperutils.py +++ b/tests/test_wrappers/test_wrapperutils.py @@ -27,8 +27,8 @@ import fsl.data.image as fslimage import fsl.wrappers.wrapperutils as wutils -from . import mockFSLDIR, cleardir, checkdir, testdir -from .test_run import mock_submit +from .. import mockFSLDIR, cleardir, checkdir, testdir +from ..test_run import mock_submit def test_applyArgStyle():