Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: command line interface #55

Merged
merged 11 commits into from
Dec 10, 2019
2 changes: 1 addition & 1 deletion nitransforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ def apply(self, spatialimage, reference=None,
order : int, optional
The order of the spline interpolation, default is 3.
The order has to be in the range 0-5.
mode : {'reflect', 'constant', 'nearest', 'mirror', 'wrap'}, optional
mode : {'constant', 'reflect', 'nearest', 'mirror', 'wrap'}, optional
Determines how the input image is extended when the resamplings overflows
a border. Default is 'constant'.
cval : float, optional
Expand Down
134 changes: 134 additions & 0 deletions nitransforms/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
from argparse import ArgumentParser, RawDescriptionHelpFormatter
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

click is pretty lightweight - @effigies do you think would be some resistance in nibabel to migrate nib-ls and other command line scripts I might not be aware of?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, nibabel has tried to avoid mandatory dependencies besides numpy. You might get some pushback on click, but might not, given it doesn't have any dependencies of its own.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've made the main() allow supplemental args to be passed in to argparse.ArgumentParser.parse_args() - hopefully that is sufficient for testing purposes

import os
from textwrap import dedent


from .linear import load as linload
from .nonlinear import load as nlinload


def cli_apply(pargs):
"""
Apply a transformation to an image, resampling on the reference.

Sample usage:

$ nt apply xform.fsl moving.nii.gz --ref reference.nii.gz --out moved.nii.gz

$ nt apply warp.nii.gz moving.nii.gz --fmt afni --nonlinear

"""
fmt = pargs.fmt or pargs.transform.split('.')[-1]
if fmt in ('tfm', 'mat', 'h5', 'x5'):
fmt = 'itk'
elif fmt == 'lta':
fmt = 'fs'

if fmt not in ('fs', 'itk', 'fsl', 'afni', 'x5'):
raise ValueError(
"Cannot determine transformation format, manually set format with the `--fmt` flag"
)

if pargs.nonlinear:
xfm = nlinload(pargs.transform, fmt=fmt)
else:
xfm = linload(pargs.transform, fmt=fmt)

# ensure a reference is set
xfm.reference = pargs.ref or pargs.moving

moved = xfm.apply(
pargs.moving,
order=pargs.order,
mode=pargs.mode,
cval=pargs.cval,
prefilter=pargs.prefilter
)
moved.to_filename(
pargs.out or "nt_{}".format(os.path.basename(pargs.moving))
)


def get_parser():
desc = dedent("""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not argparse compose a good description for commands?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i find it's nice to have a little blurb describing the subparsers at the top level (argparse doesn't do this)

NiTransforms command-line utility.

Commands:

apply Apply a transformation to an image

For command specific information, use 'nt <command> -h'.
""")

parser = ArgumentParser(
description=desc, formatter_class=RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest='command')

def _add_subparser(name, description):
subp = subparsers.add_parser(
name,
description=dedent(description),
formatter_class=RawDescriptionHelpFormatter,
)
return subp

applyp = _add_subparser('apply', cli_apply.__doc__)
applyp.set_defaults(func=cli_apply)
applyp.add_argument('transform', help='The transform file')
applyp.add_argument(
'moving', help='The image containing the data to be resampled'
)
applyp.add_argument('--ref', help='The reference space to resample onto')
applyp.add_argument(
'--fmt',
choices=('itk', 'fsl', 'afni', 'fs', 'x5'),
help='Format of transformation. If no option is passed, nitransforms will '
'estimate based on the transformation file extension.'
)
applyp.add_argument(
'--out', help="The transformed image. If not set, will be set to `nt_{moving}`"
)
applyp.add_argument(
'--nonlinear', action='store_true', help='Transformation is nonlinear (default: False)'
)
applykwargs = applyp.add_argument_group('Apply customization')
applykwargs.add_argument(
'--order',
type=int,
default=3,
choices=range(6),
help='The order of the spline transformation (default: 3)'
)
applykwargs.add_argument(
'--mode',
choices=('constant', 'reflect', 'nearest', 'mirror', 'wrap'),
default='constant',
help='Determines how the input image is extended when the resampling overflows a border '
'(default: constant)'
)
applykwargs.add_argument(
'--cval',
type=float,
default=0.0,
help='Constant used when using "constant" mode (default: 0.0)'
)
applykwargs.add_argument(
'--prefilter',
action='store_false',
help="Determines if the image's data array is prefiltered with a spline filter before "
"interpolation (default: True)"
)
return parser, subparsers


def main(pargs=None):
parser, subparsers = get_parser()
pargs = parser.parse_args(pargs)

try:
pargs.func(pargs)
except Exception as e:
subparser = subparsers.choices[pargs.command]
subparser.print_help()
raise(e)
68 changes: 68 additions & 0 deletions nitransforms/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from textwrap import dedent

import pytest

from ..cli import cli_apply, main as ntcli


def test_cli(capsys):
# empty command
with pytest.raises(SystemExit):
ntcli()
# invalid command
with pytest.raises(SystemExit):
ntcli(['idk'])

with pytest.raises(SystemExit) as sysexit:
ntcli(['-h'])
console = capsys.readouterr()
assert sysexit.value.code == 0
# possible commands
assert r"{apply}" in console.out

with pytest.raises(SystemExit):
ntcli(['apply', '-h'])
console = capsys.readouterr()
assert dedent(cli_apply.__doc__) in console.out
assert sysexit.value.code == 0


def test_apply_linear(tmpdir, data_path, get_testdata):
tmpdir.chdir()
img = 'img.nii.gz'
get_testdata['RAS'].to_filename(img)
lin_xform = str(data_path / 'affine-RAS.itk.tfm')
lin_xform2 = str(data_path / 'affine-RAS.fs.lta')

# unknown transformation format
with pytest.raises(ValueError):
ntcli(['apply', 'unsupported.xform', 'img.nii.gz'])

# linear transform arguments
output = tmpdir / 'nt_img.nii.gz'
ntcli(['apply', lin_xform, img, '--ref', img])
assert output.check()
output.remove()
ntcli(['apply', lin_xform2, img, '--ref', img])
assert output.check()


def test_apply_nl(tmpdir, data_path):
tmpdir.chdir()
img = str(data_path / 'tpl-OASIS30ANTs_T1w.nii.gz')
nl_xform = str(data_path / 'ds-005_sub-01_from-OASIS_to-T1_warp_afni.nii.gz')

nlargs = ['apply', nl_xform, img]
# format not specified
with pytest.raises(ValueError):
ntcli(nlargs)

nlargs.extend(['--fmt', 'afni'])
# no linear afni support
with pytest.raises(NotImplementedError):
ntcli(nlargs)

output = 'moved_from_warp.nii.gz'
nlargs.extend(['--nonlinear', '--out', output])
ntcli(nlargs)
assert (tmpdir / output).check()
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ tests =
all =
%(test)s

[options.entry_points]
console_scripts =
nt = nitransforms.cli:main

[flake8]
max-line-length = 100
ignore = D100,D101,D102,D103,D104,D105,D200,D201,D202,D204,D205,D208,D209,D210,D300,D301,D400,D401,D403,E24,E121,E123,E126,E226,E266,E402,E704,E731,F821,I100,I101,I201,N802,N803,N804,N806,W503,W504,W605