Skip to content

Commit 917afab

Browse files
authored
Merge pull request #947 from effigies/enh/nib-roi
ENH: Add nib-roi command to crop (maybe flip) axes
2 parents 7ce84f3 + 4623e12 commit 917afab

File tree

3 files changed

+241
-0
lines changed

3 files changed

+241
-0
lines changed

nibabel/cmdline/roi.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import sys
2+
import os
3+
import argparse
4+
import nibabel as nb
5+
6+
7+
def lossless_slice(img, slicers):
8+
if not nb.imageclasses.spatial_axes_first(img):
9+
raise ValueError("Cannot slice an image that is not known to have spatial axes first")
10+
11+
scaling = hasattr(img.header, 'set_slope_inter')
12+
13+
data = img.dataobj._get_unscaled(slicers) if scaling else img.dataobj[slicers]
14+
roi_img = img.__class__(data, affine=img.slicer.slice_affine(slicers), header=img.header)
15+
16+
if scaling:
17+
roi_img.header.set_slope_inter(img.dataobj.slope, img.dataobj.inter)
18+
return roi_img
19+
20+
21+
def parse_slice(crop, allow_step=True):
22+
if crop is None:
23+
return slice(None)
24+
start, stop, *extra = [int(val) if val else None for val in crop.split(":")]
25+
if len(extra) > 1:
26+
raise ValueError(f"Cannot parse specification: {crop}")
27+
if not allow_step and extra and extra[0] not in (1, None):
28+
raise ValueError(f"Step entry not permitted: {crop}")
29+
30+
step = extra[0] if extra else None
31+
if step not in (1, -1, None):
32+
raise ValueError(f"Downsampling is not supported: {crop}")
33+
34+
return slice(start, stop, step)
35+
36+
37+
def sanitize(args):
38+
# Argparse likes to treat "-1:..." as a flag
39+
return [f' {arg}' if arg[0] == '-' and ":" in arg else arg
40+
for arg in args]
41+
42+
43+
def main(args=None):
44+
if args is None:
45+
args = sys.argv[1:]
46+
parser = argparse.ArgumentParser(
47+
description="Crop images to a region of interest",
48+
epilog="If a start or stop value is omitted, the start or end of the axis is assumed.")
49+
parser.add_argument('--version', action='version', version=nb.__version__)
50+
parser.add_argument("-i", metavar="I1:I2[:-1]",
51+
help="Start/stop [flip] along first axis (0-indexed)")
52+
parser.add_argument("-j", metavar="J1:J2[:-1]",
53+
help="Start/stop [flip] along second axis (0-indexed)")
54+
parser.add_argument("-k", metavar="K1:K2[:-1]",
55+
help="Start/stop [flip] along third axis (0-indexed)")
56+
parser.add_argument("-t", metavar="T1:T2", help="Start/stop along fourth axis (0-indexed)")
57+
parser.add_argument("in_file", help="Image file to crop")
58+
parser.add_argument("out_file", help="Output file name")
59+
60+
opts = parser.parse_args(args=sanitize(args))
61+
62+
try:
63+
islice = parse_slice(opts.i)
64+
jslice = parse_slice(opts.j)
65+
kslice = parse_slice(opts.k)
66+
tslice = parse_slice(opts.t, allow_step=False)
67+
except ValueError as err:
68+
print(f"Could not parse input arguments. Reason follows.\n{err}")
69+
return 1
70+
71+
kwargs = {}
72+
if os.path.realpath(opts.in_file) == os.path.realpath(opts.out_file):
73+
kwargs['mmap'] = False
74+
img = nb.load(opts.in_file, **kwargs)
75+
76+
slicers = (islice, jslice, kslice, tslice)[:img.ndim]
77+
expected_shape = nb.fileslice.predict_shape(slicers, img.shape)
78+
if any(dim == 0 for dim in expected_shape):
79+
print(f"Cannot take zero-length slices. Predicted shape {expected_shape}.")
80+
return 1
81+
82+
try:
83+
sliced_img = lossless_slice(img, slicers)
84+
except Exception:
85+
print("Could not slice image. Full traceback follows.")
86+
raise
87+
nb.save(sliced_img, opts.out_file)
88+
return 0

nibabel/cmdline/tests/test_roi.py

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import os
2+
import numpy as np
3+
import nibabel as nb
4+
from nibabel.cmdline.roi import lossless_slice, parse_slice, main
5+
from nibabel.testing import data_path
6+
7+
import unittest
8+
from unittest import mock
9+
import pytest
10+
11+
12+
def test_parse_slice():
13+
assert parse_slice(None) == slice(None)
14+
assert parse_slice("1:5") == slice(1, 5)
15+
assert parse_slice("1:") == slice(1, None)
16+
assert parse_slice(":5") == slice(None, 5)
17+
assert parse_slice(":-1") == slice(None, -1)
18+
assert parse_slice("-5:-1") == slice(-5, -1)
19+
assert parse_slice("1:5:") == slice(1, 5, None)
20+
assert parse_slice("1::") == slice(1, None, None)
21+
assert parse_slice(":5:") == slice(None, 5, None)
22+
assert parse_slice(":-1:") == slice(None, -1, None)
23+
assert parse_slice("-5:-1:") == slice(-5, -1, None)
24+
assert parse_slice("1:5:1") == slice(1, 5, 1)
25+
assert parse_slice("1::1") == slice(1, None, 1)
26+
assert parse_slice(":5:1") == slice(None, 5, 1)
27+
assert parse_slice(":-1:1") == slice(None, -1, 1)
28+
assert parse_slice("-5:-1:1") == slice(-5, -1, 1)
29+
assert parse_slice("5:1:-1") == slice(5, 1, -1)
30+
assert parse_slice(":1:-1") == slice(None, 1, -1)
31+
assert parse_slice("5::-1") == slice(5, None, -1)
32+
assert parse_slice("-1::-1") == slice(-1, None, -1)
33+
assert parse_slice("-1:-5:-1") == slice(-1, -5, -1)
34+
35+
# Max of start:stop:step
36+
with pytest.raises(ValueError):
37+
parse_slice("1:2:3:4")
38+
# Integers only
39+
with pytest.raises(ValueError):
40+
parse_slice("abc:2:3")
41+
with pytest.raises(ValueError):
42+
parse_slice("1.2:2:3")
43+
# Unit steps only
44+
with pytest.raises(ValueError):
45+
parse_slice("1:5:2")
46+
47+
48+
def test_parse_slice_disallow_step():
49+
# Permit steps of 1
50+
assert parse_slice("1:5", False) == slice(1, 5)
51+
assert parse_slice("1:5:", False) == slice(1, 5)
52+
assert parse_slice("1:5:1", False) == slice(1, 5, 1)
53+
# Disable other steps
54+
with pytest.raises(ValueError):
55+
parse_slice("1:5:-1", False)
56+
with pytest.raises(ValueError):
57+
parse_slice("1:5:-2", False)
58+
59+
60+
def test_lossless_slice_unknown_axes():
61+
img = nb.load(os.path.join(data_path, 'minc1_4d.mnc'))
62+
with pytest.raises(ValueError):
63+
lossless_slice(img, (slice(None), slice(None), slice(None)))
64+
65+
66+
def test_lossless_slice_scaling(tmp_path):
67+
fname = tmp_path / 'image.nii'
68+
img = nb.Nifti1Image(np.random.uniform(-20000, 20000, (5, 5, 5, 5)), affine=np.eye(4))
69+
img.header.set_data_dtype("int16")
70+
img.to_filename(fname)
71+
img1 = nb.load(fname)
72+
sliced_fname = tmp_path / 'sliced.nii'
73+
lossless_slice(img1, (slice(None), slice(None), slice(2, 4))).to_filename(sliced_fname)
74+
img2 = nb.load(sliced_fname)
75+
76+
assert np.array_equal(img1.get_fdata()[:, :, 2:4], img2.get_fdata())
77+
assert np.array_equal(img1.dataobj.get_unscaled()[:, :, 2:4], img2.dataobj.get_unscaled())
78+
assert img1.dataobj.slope == img2.dataobj.slope
79+
assert img1.dataobj.inter == img2.dataobj.inter
80+
81+
82+
def test_lossless_slice_noscaling(tmp_path):
83+
fname = tmp_path / 'image.mgh'
84+
img = nb.MGHImage(np.random.uniform(-20000, 20000, (5, 5, 5, 5)).astype("float32"),
85+
affine=np.eye(4))
86+
img.to_filename(fname)
87+
img1 = nb.load(fname)
88+
sliced_fname = tmp_path / 'sliced.mgh'
89+
lossless_slice(img1, (slice(None), slice(None), slice(2, 4))).to_filename(sliced_fname)
90+
img2 = nb.load(sliced_fname)
91+
92+
assert np.array_equal(img1.get_fdata()[:, :, 2:4], img2.get_fdata())
93+
assert np.array_equal(img1.dataobj.get_unscaled()[:, :, 2:4], img2.dataobj.get_unscaled())
94+
assert img1.dataobj.slope == img2.dataobj.slope
95+
assert img1.dataobj.inter == img2.dataobj.inter
96+
97+
98+
@pytest.mark.parametrize("inplace", (True, False))
99+
def test_nib_roi(tmp_path, inplace):
100+
in_file = os.path.join(data_path, 'functional.nii')
101+
out_file = str(tmp_path / 'sliced.nii')
102+
in_img = nb.load(in_file)
103+
104+
if inplace:
105+
in_img.to_filename(out_file)
106+
in_file = out_file
107+
108+
retval = main([in_file, out_file, '-i', '1:-1', '-j', '-1:1:-1', '-k', '::', '-t', ':5'])
109+
assert retval == 0
110+
111+
out_img = nb.load(out_file)
112+
in_data = in_img.dataobj[:]
113+
in_sliced = in_img.slicer[1:-1, -1:1:-1, :, :5]
114+
assert out_img.shape == in_sliced.shape
115+
assert np.array_equal(in_data[1:-1, -1:1:-1, :, :5], out_img.dataobj)
116+
assert np.allclose(in_sliced.dataobj, out_img.dataobj)
117+
assert np.allclose(in_sliced.affine, out_img.affine)
118+
119+
120+
@pytest.mark.parametrize("args, errmsg", (
121+
(("-i", "1:1"), "Cannot take zero-length slice"),
122+
(("-j", "1::2"), "Downsampling is not supported"),
123+
(("-t", "5::-1"), "Step entry not permitted"),
124+
))
125+
def test_nib_roi_bad_slices(capsys, args, errmsg):
126+
in_file = os.path.join(data_path, 'functional.nii')
127+
128+
retval = main([in_file, os.devnull, *args])
129+
assert retval != 0
130+
captured = capsys.readouterr()
131+
assert errmsg in captured.out
132+
133+
134+
def test_entrypoint(capsys):
135+
# Check that we handle missing args as expected
136+
with mock.patch("sys.argv", ["nib-roi", "--help"]):
137+
try:
138+
retval = main()
139+
except SystemExit:
140+
pass
141+
else:
142+
assert False, "argparse exits on --help. If changing to another parser, update test."
143+
captured = capsys.readouterr()
144+
assert captured.out.startswith("usage: nib-roi")
145+
146+
147+
def test_nib_roi_unknown_axes(capsys):
148+
in_file = os.path.join(data_path, 'minc1_4d.mnc')
149+
with pytest.raises(ValueError):
150+
main([in_file, os.devnull, "-i", ":"])
151+
captured = capsys.readouterr()
152+
assert "Could not slice image." in captured.out

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ console_scripts =
7777
nib-nifti-dx=nibabel.cmdline.nifti_dx:main
7878
nib-tck2trk=nibabel.cmdline.tck2trk:main
7979
nib-trk2tck=nibabel.cmdline.trk2tck:main
80+
nib-roi=nibabel.cmdline.roi:main
8081
parrec2nii=nibabel.cmdline.parrec2nii:main
8182

8283
[options.package_data]

0 commit comments

Comments
 (0)