From f011b82f325c8f09676379b5595a43ffd0dc138a Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauldmccarthy@gmail.com> Date: Thu, 1 Mar 2018 17:38:58 +0000 Subject: [PATCH] further experimentation --- fsl/wrappers/__init__.py | 50 ++-- fsl/wrappers/bet.py | 5 +- fsl/wrappers/flirt.py | 90 +++---- fsl/wrappers/wrapperutils.py | 466 +++++++++++++++++++++++++++-------- 4 files changed, 435 insertions(+), 176 deletions(-) diff --git a/fsl/wrappers/__init__.py b/fsl/wrappers/__init__.py index 3bf0f2a63..8d50b77ad 100644 --- a/fsl/wrappers/__init__.py +++ b/fsl/wrappers/__init__.py @@ -8,25 +8,31 @@ them to be called from Python. """ - -from .bet import (bet,) # noqa -from .eddy import (eddy_cuda, # noqa - topup) -from .flirt import (flirt, # noqa - invxfm, - applyxfm, - concatxfm, - mcflirt) -from .fnirt import (fnirt, # noqa - applywarp, - invwarp, - convertwarp) -from .fslmaths import (fslmaths,) # noqa -from .fugue import (fugue, # noqa - sigloss) -from .melodic import (melodic, # noqa - fsl_regfilt) -from .misc import (fslreorient2std, # noqa - fslroi, - slicer, - cluster) +from .wrapperutils import (applyArgStyle, # noqa + required, + fileOrImage, + fileOrArray, + RETURN, + SHOW_IF_TRUE, + HIDE_IF_TRUE) +from .bet import (bet,) # noqa +from .eddy import (eddy_cuda, # noqa + topup) +from .flirt import (flirt, # noqa + invxfm, + applyxfm, + concatxfm, + mcflirt) +from .fnirt import (fnirt, # noqa + applywarp, + invwarp, + convertwarp) +from .fslmaths import (fslmaths,) # noqa +from .fugue import (fugue, # noqa + sigloss) +from .melodic import (melodic, # noqa + fsl_regfilt) +from .misc import (fslreorient2std, # noqa + fslroi, + slicer, + cluster) diff --git a/fsl/wrappers/bet.py b/fsl/wrappers/bet.py index 58c4aab3b..c9de633f8 100644 --- a/fsl/wrappers/bet.py +++ b/fsl/wrappers/bet.py @@ -11,6 +11,10 @@ import fsl.utils.run as run from . import wrapperutils as wutils +def pybet(): # ?? + pass + + @wutils.fileOrImage('input', 'output') def bet(input, output, **kwargs): """Delete non-brain tissue from an image of the whole head. @@ -20,7 +24,6 @@ def bet(input, output, **kwargs): :arg mask: :arg robust: :arg fracintensity: - :arg seg: Refer to the ``bet`` command-line help for details on all arguments. """ diff --git a/fsl/wrappers/flirt.py b/fsl/wrappers/flirt.py index 9247ce455..4c777a61f 100644 --- a/fsl/wrappers/flirt.py +++ b/fsl/wrappers/flirt.py @@ -20,60 +20,34 @@ import fsl.utils.run as run import fsl.utils.assertions as asrt import fsl.data.image as fslimage +from . import wrapperutils as wutils -def flirt(src, ref, out=None, omat=None, dof=None, cost=None, wmseg=None, - init=None, schedule=None, echospacing=None, pedir=None, - fieldmap=None, fieldmapmask=None, bbrslope=None, bbrtype=None, - interp=None, refweight=None, applyisoxfm=None, usesqform=False, - nosearch=False, verbose=0): +@wutils.required('src', 'ref') +@wutils.fileOrImage('src', 'ref', 'out', 'wmseg', 'fieldmap', 'fieldmapmask') +@wutils.fileOrArray('init', 'omat', 'wmcoords', 'wmnorms') +def flirt(src, ref, **kwargs): """FLIRT (FMRIB's Linear Image Registration Tool).""" + asrt.assertIsNifti(src, ref) - asrt.assertFileExists(src, ref) - cmd = "flirt -in {0} -ref {1}".format(src, ref) - - if out is not None: - asrt.assertIsNifti(out) - cmd += " -out {0}".format(out) - if omat is not None: - cmd += " -omat {0}".format(omat) - if dof is not None: - cmd += " -dof {0}".format(dof) - if cost is not None: - cmd += " -cost {0}".format(cost) - if wmseg is not None: - asrt.assertIsNifti(wmseg) - cmd += " -wmseg {0}".format(wmseg) - if init is not None: - cmd += " -init {0}".format(init) - if schedule is not None: - cmd += " -schedule {0}".format(schedule) - if echospacing is not None: - cmd += " -echospacing {0}".format(echospacing) - if pedir is not None: - cmd += " -pedir {0}".format(pedir) - if fieldmap is not None: - cmd += " -fieldmap {0}".format(fieldmap) - if fieldmapmask is not None: - cmd += " -fieldmapmask {0}".format(fieldmapmask) - if bbrslope is not None: - cmd += " -bbrslope {0}".format(bbrslope) - if bbrtype is not None: - cmd += " -bbrtype {0}".format(bbrtype) - if interp is not None: - cmd += " -interp {0}".format(interp) - if refweight is not None: - asrt.assertIsNifti(refweight) - cmd += " -refweight {0}".format(refweight) - if applyisoxfm is not None: - cmd += " -applyisoxfm {0}".format(applyisoxfm) - if verbose is not None: - cmd += " -verbose {0}".format(verbose) - if usesqform: - cmd += " -usesqform" - if nosearch: - cmd += " -nosearch" + valmap = { + 'usesqform' : wutils.SHOW_IF_TRUE, + 'displayinit' : wutils.SHOW_IF_TRUE, + 'noresample' : wutils.SHOW_IF_TRUE, + 'forcescaling' : wutils.SHOW_IF_TRUE, + 'applyxfm' : wutils.SHOW_IF_TRUE, + 'nosearch' : wutils.SHOW_IF_TRUE, + 'noclamp' : wutils.SHOW_IF_TRUE, + 'noresampblur' : wutils.SHOW_IF_TRUE, + '2D' : wutils.SHOW_IF_TRUE, + 'v' : wutils.SHOW_IF_TRUE, + 'version' : wutils.SHOW_IF_TRUE, + 'help' : wutils.SHOW_IF_TRUE, + } + + cmd = ['flirt', '-in', src, '-ref', ref] + cmd += wutils.applyArgStyle('-', valmap=valmap, **kwargs) return run.runfsl(cmd) @@ -94,13 +68,25 @@ def applyxfm(src, ref, mat, out, interp='spline'): cmd = "flirt -init {0} -in {1} -ref {2} -applyxfm -out {3} -interp {4}" return run.runfsl(cmd.format(mat, src, ref, out, interp)) - +@wutils.required( 'inmat1', 'inmat2', 'outmat') +@wutils.fileOrArray('inmat1', 'inmat2', 'outmat') def concatxfm(inmat1, inmat2, outmat): """Tool to concatenate two FSL transformation matrices.""" + + print('inmat1', inmat1) + print('inmat2', inmat2) + print('outmat', outmat) + asrt.assertFileExists(inmat1, inmat2) - cmd = "convert_xfm -omat {0} -concat {1} {2}" - return run.runfsl(cmd.format(outmat, inmat2, inmat1)) + cmd = ['convert_xfm', + '-omat', + outmat, + '-concat', + inmat2, + inmat1] + + return run.runfsl(cmd) def mcflirt(infile, outfile, reffile=None, spline_final=True, plots=True, diff --git a/fsl/wrappers/wrapperutils.py b/fsl/wrappers/wrapperutils.py index 629845bf2..b60c57912 100644 --- a/fsl/wrappers/wrapperutils.py +++ b/fsl/wrappers/wrapperutils.py @@ -1,13 +1,26 @@ #!/usr/bin/env python # -# wrapperutils.py - +# wrapperutils.py - Functions and decorators used by the FSL wrapper +# functions. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +"""This module contains functions and decorators used by the FSL wrapper +functions. + +.. autosummary:: + :nosignatures: + + applyArgStyle + required + fileOrImage + fileOrArray +""" import os.path as op import os +import sys import inspect import tempfile import warnings @@ -16,50 +29,133 @@ import collections import six import nibabel as nib +import numpy as np import fsl.utils.tempdir as tempdir import fsl.data.image as fslimage -class _BooleanFlag(object): - def __init__(self, show): - self.show = show - def __eq__(self, other): - return type(other) == type(self) and self.show == other.show +def _update_wrapper(wrapper, wrapped, *args, **kwargs): + """Replacement for the built-in ``functools.update_wrapper``. This + implementation ensures that the wrapper function has an attribute + called ``__wrapped__``, which refers to the ``wrapped`` function. + + This behaviour is only required in Python versions < 3.4. + """ + + wrapper = functools.update_wrapper(wrapper, wrapped, *args, **kwargs) + + # Python >= 3.4 does things right + if sys.version_info[0] * 10 + sys.version_info[1] < 3.4: + wrapper.__wrapped__ = wrapped + return wrapper -SHOW_IF_TRUE = _BooleanFlag(True) -HIDE_IF_TRUE = _BooleanFlag(False) +def _unwrap(func): + """Replacement for the built-in ``inspect.unwrap`` function, which + is not present in Python versions prior to 3.4. + """ + + # Python >= 3.4 has an inspect.unwrap function + if sys.version_info[0] * 10 + sys.version_info[1] < 3.4: + return inspect.unwrap(func) + + # Otherwise we follow the __wrapped__ chain ourselves + if hasattr(func, '__wrapped__'): + return _unwrap(func.__wrapped__) + + return func + + + +SHOW_IF_TRUE = object() +"""Constant to be used in the ``valmap`` passed to the :func:`applyArgStyle` +function. + +When a ``SHOW_IF_TRUE`` argument is ``True``, it is added to the generated +command line arguments. +""" + + +HIDE_IF_TRUE = object() +"""Constant to be used in the ``valmap`` passed to the :func:`applyArgStyle` +function. + +When a ``HIDE_IF_TRUE`` argument is ``True``, it is suppressed from the +generated command line arguments. +""" def applyArgStyle(style, argmap=None, valmap=None, **kwargs): + """Turns the given ``kwargs`` into command line options. This function + is intended to be used to automatically generate command line options + from arguments passed into a Python function. + + :arg style: Controls how the ``kwargs`` are converted into command-line + options - must be one of the following: + - `'-'`: ``-name val`` + - `'--'`: ``--name val`` + - `'-='`: ``-name=val`` + - `'--='`: ``--name=val`` + + :arg argmap: Dictionary of ``{kwarg-name : cli-name}`` mappings. This can + be used if you want to use different argument names in your + Python function for the command-line options. + + :arg valmap: Dictionary of ``{cli-name : value}`` mappings. This can be + used to define specific semantics for some command-line + options. Acceptable values for ``value`` are as follows + + - :data:`SHOW_IF_TRUE` - if the argument is present, and + ``True`` in ``kwargs``, the command line option + will be added (without any arguments). + + - :data:`HIDE_IF_TRUE` - if the argument is present, and + ``False`` in ``kwargs``, the command line option + will be added (without any arguments). + + - Any other constant value. If the argument is present + in ``kwargs``, its command-line option will be + added, with the constant value as its argument. + + The argument for any options not specified in the ``valmap`` + will be converted into strings. - def fmtarg(arg, style): + :arg kwargs: Arguments to be converted into command-line options. + + :returns: A list containing the generated command-line options. + """ + + if style not in ('-', '--', '-=', '--='): + raise ValueError('Invalid style: {}'.format(style)) + + if argmap is None: argmap = {} + if valmap is None: valmap = {} + + def fmtarg(arg): if style in ('-', '-='): arg = '-{}'.format(arg) elif style in ('--', '--='): arg = '--{}'.format(arg) return arg - def fmtval(val, style=None): + def fmtval(val): if isinstance(val, collections.Sequence) and \ not isinstance(val, six.string_types): return ' '.join([str(v) for v in val]) else: return str(val) - if style not in ('-', '--', '-=', '--='): - raise ValueError('Invalid style: {}'.format(style)) - args = [] for k, v in kwargs.items(): k = argmap.get(k, k) - mapv = valmap.get(k, fmtval(v, style)) - k = fmtarg(k, style) + mapv = valmap.get(k, fmtval(v)) + k = fmtarg(k) + + if (mapv is SHOW_IF_TRUE and v) or \ + (mapv is HIDE_IF_TRUE and not v): + args.append(k) - if mapv in (SHOW_IF_TRUE, HIDE_IF_TRUE): - if v == mapv.show: - args.append(k) elif '=' in style: args.append('{}={}'.format(k, mapv)) else: @@ -72,110 +168,216 @@ def required(*reqargs): """Decorator which makes sure that all specified keyword arguments are present before calling the decorated function. """ + def decorator(func): - def wrapper(**kwargs): + def wrapper(*args, **kwargs): + kwargs = kwargs.copy() + kwargs.update(argsToKwargs(func, args)) for reqarg in reqargs: assert reqarg in kwargs return func(**kwargs) - return wrapper + return _update_wrapper(wrapper, func) + return decorator def argsToKwargs(func, args): """Given a function, and a sequence of positional arguments destined for that function, converts the positional arguments into a dict - of keyword arguments. Used by the :class:`_FileOrImage` class. + of keyword arguments. Used by the :class:`_FileOrThing` class. """ - # getfullargspec is the only way to get the names - # of positional arguments in Python 2.x. It is - # deprecated in python 3.5, but not in python 3.6. - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - spec = inspect.getfullargspec(func) + + func = _unwrap(func) + + # getargspec is the only way to get the names + # of positional arguments in Python 2.x. + if sys.version_info[0] < 3: + argnames = inspect.getargspec(func).args + + # getargspec is deprecated in python 3.x + else: + + # getfullargspec is deprecated in + # python 3.5, but not in python 3.6. + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=DeprecationWarning) + argnames = inspect.getfullargspec(func).args kwargs = collections.OrderedDict() - for name, val in zip(spec.args, args): + for name, val in zip(argnames, args): kwargs[name] = val return kwargs RETURN = object() -""" +"""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 _FileOrImage(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 + :func:`fileOrImage` and :func:`fileOrArray` decorator functions for more + details. + + These decorators are intended for functions which wrap a command-line tool, + i.e. where some inputs/outputs need to be specified as file names. + + + **Inputs** + + + Any arguments which are not of type ``Thing`` are passed through to the + decorated function unmodified. Arguments which are of type ``Thing`` are + saved to a temporary file, and the name of that file is passed to the + function. + - Inputs: - - In-memory nibabel images loaded from a file. The image is replaced with - its file name. + **Outputs** - - In-memory nibabel images. The image is saved to a temporary file, and - replaced with the temporary file's name. The file is deleted after the - function has returned. - Outputs: - - File name: The file name is passed straight through to the function. - - ``RETURN``: A temporary file name is passed to the function. After the - function has completed, the image is loaded into memory and the - temporary file is deleted. The image is returned from the function - call. + If an argument is given the special :data:`RETURN` value, it is assumed + to be an output argument. In this case, it is replaced with a temporary + file name then, after the function has completed, that file is loaded + into memory, and the value returned (along with the function's output, + and any other arguments with a value of ``RETURN``). + + + **Return value** + + + Functions decorated with a ``_FileOrThing`` decorator will always return a + tuple, where the first element is the function's actual return value. The + remainder of the tuple will contain any arguments that were given the + special ``RETURN`` value. ``None`` is returned for any ``RETURN`` + arguments corresponded to output files that were not generated by the + function. + + + **Example** + + + As an example of using the ``fileOrArray`` decorator on a function + which concatenates two files containing affine transformations, and + saves the output to a file:: + + # if atob, btoc, or output are passed + # in as arrays, they are converted to + # file names. + @fileOrArray('atob', 'btoc', 'output') + def concat(atob, btoc, output=None): + + # inputs are guaranteed to be files + atob = np.loadtxt(atob) + btoc = np.loadtxt(atoc) + + atoc = np.dot(btoc, atob) + + if output is not None: + np.savetxt(output, atoc) + + + Because we have decorated the ``concat`` function with :func:`fileToArray`, + it can be called with either file names, or Numpy arrays:: + + # All arguments are passed through + # unmodified - the output will be + # saved to a file called atoc.mat + concat('atob.txt', 'btoc.txt', 'atoc.mat') + + # The output is returned as a numpy + # array (in a tuple with the concat + # function's return value) + atoc = concat('atob.txt', 'btoc.txt', RETURN)[1] + + # The inputs are saved to temporary + # files, and those file names are + # passed to the concat function. + atoc = concat(np.diag([2, 2, 2, 0]), np.diag([3, 3, 3, 3]), RETURN)[1] """ - def __init__(self, *imgargs): - """ + def __init__(self, prepareThing, loadThing, *things): + """Initialise a ``_FileOrThing`` decorator. + + :arg prepareThing: Function which + :arg loadThing: Function which is called for arguments that + were set to :data:`RETURN`. + + :arg things: """ - self.__imgargs = imgargs + self.__prepareThing = prepareThing + self.__loadThing = loadThing + self.__things = things def __call__(self, func): - """ - """ - return functools.partial(self.__wrapper, func) + """Creates and returns the real decorator function. """ + + self.__func = func + self.__isFileOrThing = False + + if hasattr(func, '__self__'): + self.__isFileOrThing = isinstance(func.__self__, _FileOrThing) + + wrapper = functools.partial(self.__wrapper, func) + + return _update_wrapper(wrapper, func) def __wrapper(self, func, *args, **kwargs): - """ + """Function which wraps ``func``, ensuring that any arguments of + type ``Thing`` are saved to temporary files, and any arguments + with the value :data:`RETURN` are loaded and returned. """ + func = self.__func + isFileOrThing = self.__isFileOrThing + + kwargs = kwargs.copy() kwargs.update(argsToKwargs(func, args)) # Create a tempdir to store any temporary - # input/output images, but don't change + # input/output things, but don't change # into it, as file paths passed to the # function may be relative. with tempdir.tempdir(changeto=False) as td: - kwargs, infiles, outfiles = self.__prepareArgs(td, kwargs) + kwargs, infiles, outfiles = self.__prepareThings(td, kwargs) # Call the function - result = func(**kwargs) + result = func(**kwargs) - # Load the output images that + # Load the output things that # were specified as RETURN - outimgs = [] + outthings = [] for of in outfiles: # output file didn't get created if not op.exists(of): - oi = None + ot = None - # load the file, and create - # an in-memory copy (the file - # is going to get deleted) + # load the thing else: - oi = nib.load(of) - oi = nib.nifti1.Nifti1Image(oi.get_data(), None, oi.header) + ot = self.__loadThing(of) - outimgs.append(oi) + outthings.append(ot) - return tuple([result] + outimgs) + if isFileOrThing: + things = result[1:] + result = result[0] + return tuple([result] + things + outthings) + else: + return tuple([result] + outthings) - def __prepareArgs(self, workdir, kwargs): + def __prepareThings(self, workdir, kwargs): """ """ @@ -183,48 +385,110 @@ class _FileOrImage(object): infiles = [] outfiles = [] - for imgarg in self.__imgargs: + for tname in self.__things: - img = kwargs.get(imgarg, None) + tval = kwargs.get(tname, None) - # Not specified, nothing to do - if img is None: + if tval is None: continue - # This is an input image which has - # been specified as an in-memory - # nibabel image. if the image has - # a backing file, replace the image - # object with the file name. - # Otherwise, save the image out to - # a temporary file, and replace the - # image with the file name. - if isinstance(img, nib.nifti1.Nifti1Image): - imgfile = img.get_filename() - - # in-memory image - we have - # to save it out to a file - if imgfile is None: - - hd, imgfile = tempfile.mkstemp(fslimage.defaultExt()) - - os.close(hd) - img.to_filename(imgfile) - infiles.append(imgfile) - - # replace the image with its - # file name - kwargs[img] = imgfile - - # This is an output image, and the - # caller has requested that it be - # returned from the function call - # as an in-memory image. - if img == RETURN: - kwargs[imgarg] = '{}.nii.gz'.format(imgarg) - outfiles.append(imgarg) + tval, infile, outfile = self.__prepareThing(workdir, tname, tval) + + if infile is not None: infiles .append(infile) + if outfile is not None: outfiles.append(outfile) + + kwargs[tname] = tval return kwargs, infiles, outfiles -fileOrImage = _FileOrImage +def fileOrImage(*imgargs): + """Decorator which can be used to ensure that any NIfTI images are saved + to file, and output images can be loaded and returned as ``nibabel`` + image objects. + """ + + def prepareArg(workdir, name, val): + + newval = val + infile = None + outfile = None + + # This is an input image which has + # been specified as an in-memory + # nibabel image. if the image has + # a backing file, replace the image + # object with the file name. + # Otherwise, save the image out to + # a temporary file, and replace the + # image with the file name. + if isinstance(val, nib.nifti1.Nifti1Image): + imgfile = val.get_filename() + + # in-memory image - we have + # to save it out to a file + if imgfile is None: + + hd, imgfile = tempfile.mkstemp(fslimage.defaultExt()) + + os.close(hd) + val.to_filename(imgfile) + infile = imgfile + + # replace the image with its + # file name + newval = imgfile + + # This is an output image, and the + # caller has requested that it be + # returned from the function call + # as an in-memory image. + elif val == RETURN: + newval = op.join(workdir, '{}.nii.gz'.format(name)) + outfile = newval + + return newval, infile, outfile + + def loadImage(path): + # create an independent in-memory + # copy of the image file + img = nib.load(path) + return nib.nifti1.Nifti1Image(img.get_data(), None, img.header) + + return _FileOrThing(prepareArg, loadImage, *imgargs) + + +def fileOrArray(*arrargs): + """Decorator which can be used to ensure that any Numpy arrays are saved + to text files, and output files can be loaded and returned as Numpy arrays. + """ + + def prepareArg(workdir, name, val): + + newval = val + infile = None + outfile = None + + # Input has been provided as a numpy + # array - save it to a file, and + # replace the argument with the file + # name + if isinstance(val, np.ndarray): + + hd, arrfile = tempfile.mkstemp('.txt') + + os.close(hd) + + np.savetxt(arrfile, val, fmt='%0.18f') + newval = arrfile + + # This is an output, and the caller has + # requested that it be returned from the + # function call as an in-memory array. + elif val == RETURN: + newval = op.join(workdir, '{}.txt'.format(name)) + outfile = newval + + return newval, infile, outfile + + return _FileOrThing(prepareArg, np.loadtxt, *arrargs) -- GitLab