diff --git a/nipype/interfaces/base/boutiques.py b/nipype/interfaces/base/boutiques.py index 9e1cfb2ac2..ef9a20f7b5 100644 --- a/nipype/interfaces/base/boutiques.py +++ b/nipype/interfaces/base/boutiques.py @@ -1,14 +1,20 @@ # -*- coding: utf-8 -*- # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: -import os -import json + from collections import defaultdict +import glob +from itertools import chain +import json +import os +from .core import CommandLine +from .specs import DynamicTraitedSpec +from .traits_extension import ( + File, isdefined, OutputMultiPath, Str, traits, Undefined) from ... import logging -from ..base import ( - traits, File, Str, isdefined, Undefined, - DynamicTraitedSpec, CommandLine) +from ...utils.misc import trim + iflogger = logging.getLogger('nipype.interface') @@ -16,6 +22,16 @@ class BoutiqueInterface(CommandLine): """Convert Boutique specification to Nipype interface + Examples + -------- + >>> from nipype.interfaces.base import BoutiqueInterface + >>> bet = BoutiqueInterface('boutiques_fslbet.json') + >>> bet.inputs.in_file = 'structural.nii' + >>> bet.inputs.frac = 0.7 + >>> bet.inputs.out_file = 'brain_anat.nii' + >>> bet.cmdline + 'bet structural.nii brain_anat.nii -f 0.70' + >>> res = bet.run() # doctest: +SKIP """ input_spec = DynamicTraitedSpec @@ -40,40 +56,52 @@ def __init__(self, boutique_spec, **inputs): self._cmd = split_cmd[0] self._argspec = split_cmd[1] if len(split_cmd) > 1 else None - super().__init__() - self._xors = defaultdict(list) self._requires = defaultdict(list) self._one_required = defaultdict(list) - self._load_groups(boutique_spec.get('groups', [])) + # we're going to actually generate input_spec and output_spec classes + # so we want this to occur before the super().__init__() call + self._load_groups(boutique_spec.get('groups', [])) self._populate_input_spec(boutique_spec.get('inputs', [])) - #self._populate_output_spec(boutique_spec.get('output-files', [])) - self.inputs.trait_set(trait_change_notify=False, **inputs) + self._populate_output_spec(boutique_spec.get('output-files', [])) + + super().__init__() + + # now set all the traits that don't have defaults/inputs to undefined + self._set_undefined(**inputs) def _load_groups(self, groups): for group in groups: members = group['members'] - if group['all-or-none']: + if group.get('all-or-none'): for member in members: self._requires[member].extend(members) - elif group['mutually-exclusive']: + if group.get('mutually-exclusive'): for member in members: self._xors[member].extend(members) - elif group['one-is-required']: + if group.get('one-is-required'): for member in members: self._one_required[member].extend(members) + def _set_undefined(self, **inputs): + usedefault = self.inputs.traits(usedefault=True) + undefined = {k: Undefined for k in + set(self.inputs.get()) - set(usedefault)} + self.inputs.trait_set(trait_change_notify=False, **undefined) + self.inputs.trait_set(trait_change_notify=False, **inputs) + def _populate_input_spec(self, input_list): + """ Generates input specification class + """ + input_spec = {} value_keys = {} - undefined_traits = {} for input_dict in input_list: trait_name = input_dict['id'] args = [] metadata = {} # Establish trait type - typestr = input_dict['type'] if 'value-choices' in input_dict: ttype = traits.Enum @@ -85,6 +113,8 @@ def _populate_input_spec(self, input_list): ttype = traits.Int else: ttype = self.trait_map[typestr] + if typestr == 'File': + metadata['exists'] = True if 'default-value' in input_dict: nipype_key = 'default_value' @@ -100,7 +130,7 @@ def _populate_input_spec(self, input_list): metadata['usedefault'] = True if input_dict.get('list'): - if args: + if len(args) > 0: ttype = ttype(*args) args = [] metadata['trait'] = ttype @@ -146,28 +176,191 @@ def _populate_input_spec(self, input_list): []).extend(self._requires[trait_name]) if trait_name in self._xors: metadata.setdefault('xor', - []).extend(self._xor[trait_name]) + []).extend(self._xors[trait_name]) trait = ttype(*args, **metadata) - self.inputs.add_trait(trait_name, trait) - if not trait.usedefault: - undefined_traits[trait_name] = Undefined - value_keys[input_dict['value-key']] = trait_name + input_spec[trait_name] = trait + + value_keys[trait_name] = input_dict['value-key'] - self.inputs.trait_set(trait_change_notify=False, - **undefined_traits) + self.input_spec = type('{}InputSpec'.format(self._cmd.capitalize()), + (self.input_spec,), + input_spec) + + # TODO: value-keys aren't necessarily mutually exclusive; use id as key self.value_keys = value_keys + def _populate_output_spec(self, output_list): + """ Creates output specification class + """ + output_spec = {} + value_keys = {} + for output_dict in output_list: + trait_name = output_dict['id'] + ttype = traits.File + metadata = {} + args = [Undefined] + + if output_dict.get('description') is not None: + metadata['desc'] = output_dict['description'] + + metadata['exists'] = not output_dict.get('optional', True) + + if output_dict.get('list'): + args = [ttype(Undefined, **metadata)] + ttype = OutputMultiPath + + if 'command-line-flag' in output_dict: + argstr = output_dict['command-line-flag'] + argstr += output_dict.get('command-line-flag-separator', ' ') + argstr += '%s' # May be insufficient for some + metadata['argstr'] = argstr + + trait = ttype(*args, **metadata) + output_spec[trait_name] = trait + + if 'value-key' in output_dict: + value_keys[trait_name] = output_dict['value-key'] + + # reassign output spec class based on compiled outputs + self.output_spec = type('{}OutputSpec'.format(self._cmd.capitalize()), + (self.output_spec,), + output_spec) + + self.value_keys.update(value_keys) + + def _list_outputs(self): + """ Generate list of predicted outputs based on defined inputs + """ + output_list = self.boutique_spec.get('output-files', []) + outputs = self.output_spec().get() + + for n, out in enumerate([f['id'] for f in output_list]): + output_dict = output_list[n] + # get path template + stripped extensions + output_filename = output_dict['path-template'] + strip = output_dict.get('path-template-stripped-extensions', []) + + # replace all value-keys in output name + for name, valkey in self.value_keys.items(): + repl = self.inputs.trait_get().get(name) + # if input is specified, strip extensions + replace in output + if repl is not None and isdefined(repl): + for ext in strip: + repl = repl[:-len(ext)] if repl.endswith(ext) else repl + output_filename = output_filename.replace(valkey, repl) + + outputs[out] = os.path.abspath(output_filename) + + if output_dict.get('list'): + outputs[out] = [outputs[out]] + + return outputs + + def aggregate_outputs(self, runtime=None, needed_outputs=None): + """ Collate expected outputs and check for existence + """ + outputs = super().aggregate_outputs(runtime=runtime, + needed_outputs=needed_outputs) + for key, val in outputs.get().items(): + # since we can't know if the output will be generated based on the + # boutiques spec, reset all non-existent outputs to undefined + if isinstance(val, list): + # glob expected output path template and flatten list + val = list(chain.from_iterable([glob.glob(v) for v in val])) + if len(val) == 0: + val = Undefined + setattr(outputs, key, val) + else: + if not os.path.exists(val): + val = Undefined + if not os.path.exists(val): + setattr(outputs, key, Undefined) + + return outputs + def cmdline(self): + """ Prints command line with all specified arguments + """ args = self._argspec - inputs = self.inputs.trait_get() - for valkey, name in self.value_keys.items(): - spec = self.inputs.traits()[name] + inputs = {**self.inputs.trait_get(), **self._list_outputs()} + for name, valkey in self.value_keys.items(): + try: + spec = self.inputs.traits()[name] + except KeyError: + spec = self.outputs.traits()[name] value = inputs[name] - if not isdefined(value): value = '' elif spec.argstr: value = self._format_arg(name, spec, value) args = args.replace(valkey, value) return self._cmd + ' ' + args + + def help(self, returnhelp=False): + """ Prints interface help + """ + + docs = self.boutique_spec.get('description') + if docs is not None: + docstring = trim(docs).split('\n') + [''] + else: + docstring = [self.__class__.__doc__] + + allhelp = '\n'.join(docstring + + self._inputs_help() + [''] + + self._outputs_help() + [''] + + self._refs_help() + ['']) + if returnhelp: + return allhelp + else: + print(allhelp) + + def _inputs_help(self): + """ Prints description for input parameters + """ + helpstr = ['Inputs::'] + + inputs = self.input_spec() + if len(list(inputs.traits(transient=None).items())) == 0: + helpstr += ['', '\tNone'] + return helpstr + + manhelpstr = ['', '\t[Mandatory]'] + mandatory_items = inputs.traits(mandatory=True) + for name, spec in sorted(mandatory_items.items()): + manhelpstr += self.__class__._get_trait_desc(inputs, name, spec) + + opthelpstr = ['', '\t[Optional]'] + for name, spec in sorted(inputs.traits(transient=None).items()): + if name in mandatory_items: + continue + opthelpstr += self.__class__._get_trait_desc(inputs, name, spec) + + if manhelpstr: + helpstr += manhelpstr + if opthelpstr: + helpstr += opthelpstr + return helpstr + + def _outputs_help(self): + """ Prints description for output parameters + """ + helpstr = ['Outputs::', ''] + if self.output_spec: + outputs = self.output_spec() + for name, spec in sorted(outputs.traits(transient=None).items()): + helpstr += self.__class__._get_trait_desc(outputs, name, spec) + if len(helpstr) == 2: + helpstr += ['\tNone'] + return helpstr + + def _refs_help(self): + """ Prints interface references. + """ + if self.boutique_spec.get('tool-doi') is None: + return [] + + helpstr = ['References::', self.boutique_spec.get('tool-doi')] + + return helpstr diff --git a/nipype/testing/data/boutiques_fslbet.json b/nipype/testing/data/boutiques_fslbet.json new file mode 100644 index 0000000000..22e0a5f306 --- /dev/null +++ b/nipype/testing/data/boutiques_fslbet.json @@ -0,0 +1,370 @@ +{ + "tool-version": "1.0.0", + "name": "fsl_bet", + "command-line": "bet [INPUT_FILE] [OUT_FILE] [FRACTIONAL_INTENSITY] [VERTICAL_GRADIENT] [CENTER_OF_GRAVITY] [OVERLAY_FLAG] [BINARY_MASK_FLAG] [APPROX_SKULL_FLAG] [NO_SEG_OUTPUT_FLAG] [VTK_VIEW_FLAG] [HEAD_RADIUS] [THRESHOLDING_FLAG] [ROBUST_ITERS_FLAG] [RES_OPTIC_CLEANUP_FLAG] [REDUCE_BIAS_FLAG] [SLICE_PADDING_FLAG] [MASK_WHOLE_SET_FLAG] [ADD_SURFACES_FLAG] [ADD_SURFACES_T2] [VERBOSE_FLAG] [DEBUG_FLAG]", + "inputs": [ + { + "description": "Input image to be skullstripped (e.g. img.nii.gz)", + "value-key": "[INPUT_FILE]", + "type": "File", + "optional": false, + "id": "in_file", + "name": "Input file" + }, + { + "description": "Name of generated, skulltripped image (e.g. img_bet.nii.gz)", + "value-key": "[OUT_FILE]", + "type": "File", + "optional": false, + "id": "out_file", + "name": "Output file" + }, + { + "command-line-flag": "-f", + "description": "Fractional intensity threshold (0->1); default=0.5; smaller values give larger brain outline estimates", + "value-key": "[FRACTIONAL_INTENSITY]", + "type": "Number", + "maximum": 1, + "minimum": 0, + "integer": false, + "optional": true, + "id": "fractional_intensity", + "name": "Fractional intensity threshold" + }, + { + "command-line-flag": "-g", + "description": "Vertical gradient in fractional intensity threshold (-1->1); default=0; positive values give larger brain outline at bottom, smaller at top", + "value-key": "[VERTICAL_GRADIENT]", + "type": "Number", + "maximum": 1, + "minimum": -1, + "integer": false, + "optional": true, + "id": "vg_fractional_intensity", + "name": "Vertical gradient fractional intensity threshold" + }, + { + "command-line-flag": "-c", + "description": "The xyz coordinates of the center of gravity (voxels, not mm) of initial mesh surface. Must have exactly three numerical entries in the list (3-vector).", + "value-key": "[CENTER_OF_GRAVITY]", + "type": "Number", + "list": true, + "max-list-entries": 3, + "optional": true, + "id": "center_of_gravity", + "min-list-entries": 3, + "name": "Center of gravity vector" + }, + { + "command-line-flag": "-o", + "description": "Generate brain surface outline overlaid onto original image", + "value-key": "[OVERLAY_FLAG]", + "type": "Flag", + "optional": true, + "id": "overlay_flag", + "name": "Overlay flag" + }, + { + "command-line-flag": "-m", + "description": "Generate binary brain mask", + "value-key": "[BINARY_MASK_FLAG]", + "type": "Flag", + "optional": true, + "id": "binary_mask_flag", + "name": "Binary mask flag" + }, + { + "command-line-flag": "-s", + "description": "Generate rough skull image (not as clean as betsurf)", + "value-key": "[APPROX_SKULL_FLAG]", + "type": "Flag", + "optional": true, + "id": "approx_skull_flag", + "name": "Approximate skull flag" + }, + { + "command-line-flag": "-n", + "description": "Don't generate segmented brain image output", + "value-key": "[NO_SEG_OUTPUT_FLAG]", + "type": "Flag", + "optional": true, + "id": "no_seg_output_flag", + "name": "No segmented brain image flag" + }, + { + "command-line-flag": "-e", + "description": "Generate brain surface as mesh in .vtk format", + "value-key": "[VTK_VIEW_FLAG]", + "type": "Flag", + "optional": true, + "id": "vtk_mesh", + "name": "VTK format brain surface mesh flag" + }, + { + "command-line-flag": "-r", + "description": "head radius (mm not voxels); initial surface sphere is set to half of this", + "value-key": "[HEAD_RADIUS]", + "type": "Number", + "optional": true, + "id": "head_radius", + "name": "Head Radius" + }, + { + "command-line-flag": "-t", + "description": "Apply thresholding to segmented brain image and mask", + "value-key": "[THRESHOLDING_FLAG]", + "type": "Flag", + "optional": true, + "id": "thresholding_flag", + "name": "Threshold segmented image flag" + }, + { + "command-line-flag": "-R", + "description": "More robust brain center estimation, by iterating BET with a changing center-of-gravity.", + "value-key": "[ROBUST_ITERS_FLAG]", + "type": "Flag", + "optional": true, + "id": "robust_iters_flag", + "name": "Robust iterations flag" + }, + { + "command-line-flag": "-S", + "description": "This attempts to cleanup residual eye and optic nerve voxels which bet2 can sometimes leave behind. This can be useful when running SIENA or SIENAX, for example. Various stages involving standard-space masking, morphpological operations and thresholdings are combined to produce a result which can often give better results than just running bet2.", + "value-key": "[RES_OPTIC_CLEANUP_FLAG]", + "type": "Flag", + "optional": true, + "id": "residual_optic_cleanup_flag", + "name": "Residual optic cleanup flag" + }, + { + "command-line-flag": "-B", + "description": "This attempts to reduce image bias, and residual neck voxels. This can be useful when running SIENA or SIENAX, for example. Various stages involving FAST segmentation-based bias field removal and standard-space masking are combined to produce a result which can often give better results than just running bet2.", + "value-key": "[REDUCE_BIAS_FLAG]", + "type": "Flag", + "optional": true, + "id": "reduce_bias_flag", + "name": "Bias reduction flag" + }, + { + "command-line-flag": "-Z", + "description": "This can improve the brain extraction if only a few slices are present in the data (i.e., a small field of view in the Z direction). This is achieved by padding the end slices in both directions, copying the end slices several times, running bet2 and then removing the added slices.", + "value-key": "[SLICE_PADDING_FLAG]", + "type": "Flag", + "optional": true, + "id": "slice_padding_flag", + "name": "Slice padding flag" + }, + { + "command-line-flag": "-F", + "description": "This option uses bet2 to determine a brain mask on the basis of the first volume in a 4D data set, and applies this to the whole data set. This is principally intended for use on FMRI data, for example to remove eyeballs. Because it is normally important (in this application) that masking be liberal (ie that there be little risk of cutting out valid brain voxels) the -f threshold is reduced to 0.3, and also the brain mask is \"dilated\" slightly before being used.", + "value-key": "[MASK_WHOLE_SET_FLAG]", + "type": "Flag", + "optional": true, + "id": "whole_set_mask_flag", + "name": "Mask-whole-set flag" + }, + { + "command-line-flag": "-A", + "description": "This runs both bet2 and betsurf programs in order to get the additional skull and scalp surfaces created by betsurf. This involves registering to standard space in order to allow betsurf to find the standard space masks it needs.", + "value-key": "[ADD_SURFACES_FLAG]", + "type": "Flag", + "optional": true, + "id": "additional_surfaces_flag", + "name": "Additional surfaces flag" + }, + { + "command-line-flag": "-A2", + "description": "This is the same as -A except that a T2 image is also input, to further improve the estimated skull and scalp surfaces. As well as carrying out the standard space registration this also registers the T2 to the T1 input image.", + "value-key": "[ADD_SURFACES_T2]", + "type": "File", + "optional": true, + "id": "additional_surfaces_t2", + "name": "Additional surfaces with T2" + }, + { + "command-line-flag": "-v", + "description": "Switch on diagnostic messages", + "value-key": "[VERBOSE_FLAG]", + "type": "Flag", + "optional": true, + "id": "verbose_flag", + "name": "Verbose Flag" + }, + { + "command-line-flag": "-d", + "description": "Don't delete temporary intermediate images", + "value-key": "[DEBUG_FLAG]", + "type": "Flag", + "optional": true, + "id": "debug_flag", + "name": "Debug Flag" + } + ], + "schema-version": "0.5", + "groups": [ + { + "description": "Specify parameters that alter the default BET functionality", + "id": "optional_params_group", + "members": [ + "fractional_intensity", + "vg_fractional_intensity", + "center_of_gravity", + "overlay_flag", + "binary_mask_flag", + "approx_skull_flag", + "no_seg_output_flag", + "vtk_mesh", + "head_radius", + "thresholding_flag" + ], + "name": "Main Program Parameters" + }, + { + "description": "Mutually exclusive options that specify variations on how BET should be run.", + "mutually-exclusive": true, + "id": "variational_params_group", + "members": [ + "robust_iters_flag", + "residual_optic_cleanup_flag", + "reduce_bias_flag", + "slice_padding_flag", + "whole_set_mask_flag", + "additional_surfaces_flag", + "additional_surfaces_t2" + ], + "name": "Variations on Default Functionality" + }, + { + "description": "Optional miscellaneous parameters when running BET", + "id": "miscellaneous_params_group", + "members": [ + "verbose_flag", + "debug_flag" + ], + "name": "Miscellaneous Parameters" + } + ], + "output-files": [ + { + "path-template": "[OUT_FILE].nii.gz", + "description": "Default skullstripped image generated by BET", + "optional": true, + "id": "outfile", + "name": "Output mask file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_mask.nii.gz", + "description": "Binary mask file (from -m option)", + "optional": true, + "id": "binary_mask", + "name": "Output binary mask file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_overlay.nii.gz", + "description": "Overlaid brain surface onto original image", + "optional": true, + "id": "overlay_file", + "name": "Surface overlay file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_skull.nii.gz", + "description": "Approximate skull image file", + "optional": true, + "id": "approx_skull_img", + "name": "Approximate skull file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_mesh.vtk", + "description": "Mesh in VTK format", + "optional": true, + "id": "output_vtk_mesh", + "name": "VTK mesh", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_skull_mask.nii.gz", + "description": "Output mask for skull image", + "optional": true, + "id": "skull_mask", + "name": "Skull mask image", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_inskull_mask.nii.gz", + "description": "The in-skull mask file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_inskull_mask", + "name": "Output in-skull mask file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_inskull_mesh.nii.gz", + "description": "The in-skull mesh file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_inskull_mesh", + "name": "Output in-skull mesh file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_inskull_mesh.off", + "description": "The in-skull mesh .off file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_inskull_off", + "name": "Output in-skull mesh off file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_outskin_mask.nii.gz", + "description": "The out-skin mask file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_outskin_mask", + "name": "Output out-skin mask file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_outskin_mesh.nii.gz", + "description": "The out-skin mesh file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_outskin_mesh", + "name": "Output out-skin mesh file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_outskin_mesh.off", + "description": "The out-skin mesh .off file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_outskin_off", + "name": "Output out-skin mesh off file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_outskull_mask.nii.gz", + "description": "The out-skull mask file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_outskull_mask", + "name": "Output out-skull mask file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_outskull_mesh.nii.gz", + "description": "The out-skull mesh file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_outskull_mesh", + "name": "Output out-skull mesh file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + }, + { + "path-template": "[OUT_FILE]_outskull_mesh.off", + "description": "The out-skull mesh .off file from betsurf (from -A or -A2)", + "optional": true, + "id": "out_outskull_off", + "name": "Output out-skull mesh off file", + "path-template-stripped-extensions": [".nii.gz", ".nii"] + } + ], + "description": "Automated brain extraction tool for FSL" +}