Skip to content

Commit 41e126a

Browse files
Merge pull request #550 from effigies/enh/crop
MRG: Add image slicing See https://mail.python.org/pipermail/neuroimaging/2017-August/001501.html Closes #489.
2 parents 1584b3b + 919b71a commit 41e126a

File tree

3 files changed

+276
-6
lines changed

3 files changed

+276
-6
lines changed

Changelog

+21
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@ Gerhard (SG) and Eric Larson (EL).
2424

2525
References like "pr/298" refer to github pull request numbers.
2626

27+
Upcoming Release
28+
================
29+
30+
New features
31+
------------
32+
* Image slicing for SpatialImages (pr/550) (CM)
33+
34+
Enhancements
35+
------------
36+
* Simplfiy MGHImage and add footer fields (pr/569) (CM, reviewed by MB)
37+
38+
Bug fixes
39+
---------
40+
41+
Maintenance
42+
-----------
43+
44+
API changes and deprecations
45+
----------------------------
46+
47+
2748
2.2.1 (Wednesday 22 November 2017)
2849
==================================
2950

nibabel/spatialimages.py

+124-3
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
from .filebasedimages import ImageFileError # flake8: noqa; for back-compat
141141
from .viewers import OrthoSlicer3D
142142
from .volumeutils import shape_zoom_affine
143+
from .fileslice import canonical_slicers
143144
from .deprecated import deprecate_with_version
144145
from .orientations import apply_orientation, inv_ornt_aff
145146

@@ -321,9 +322,103 @@ class ImageDataError(Exception):
321322
pass
322323

323324

325+
class SpatialFirstSlicer(object):
326+
''' Slicing interface that returns a new image with an updated affine
327+
328+
Checks that an image's first three axes are spatial
329+
'''
330+
def __init__(self, img):
331+
# Local import to avoid circular import on module load
332+
from .imageclasses import spatial_axes_first
333+
if not spatial_axes_first(img):
334+
raise ValueError("Cannot predict position of spatial axes for "
335+
"Image type " + img.__class__.__name__)
336+
self.img = img
337+
338+
def __getitem__(self, slicer):
339+
try:
340+
slicer = self.check_slicing(slicer)
341+
except ValueError as err:
342+
raise IndexError(*err.args)
343+
344+
dataobj = self.img.dataobj[slicer]
345+
if any(dim == 0 for dim in dataobj.shape):
346+
raise IndexError("Empty slice requested")
347+
348+
affine = self.slice_affine(slicer)
349+
return self.img.__class__(dataobj.copy(), affine, self.img.header)
350+
351+
def check_slicing(self, slicer, return_spatial=False):
352+
''' Canonicalize slicers and check for scalar indices in spatial dims
353+
354+
Parameters
355+
----------
356+
slicer : object
357+
something that can be used to slice an array as in
358+
``arr[sliceobj]``
359+
return_spatial : bool
360+
return only slices along spatial dimensions (x, y, z)
361+
362+
Returns
363+
-------
364+
slicer : object
365+
Validated slicer object that will slice image's `dataobj`
366+
without collapsing spatial dimensions
367+
'''
368+
slicer = canonical_slicers(slicer, self.img.shape)
369+
# We can get away with this because we've checked the image's
370+
# first three axes are spatial.
371+
# More general slicers will need to be smarter, here.
372+
spatial_slices = slicer[:3]
373+
for subslicer in spatial_slices:
374+
if subslicer is None:
375+
raise IndexError("New axis not permitted in spatial dimensions")
376+
elif isinstance(subslicer, int):
377+
raise IndexError("Scalar indices disallowed in spatial dimensions; "
378+
"Use `[x]` or `x:x+1`.")
379+
return spatial_slices if return_spatial else slicer
380+
381+
def slice_affine(self, slicer):
382+
""" Retrieve affine for current image, if sliced by a given index
383+
384+
Applies scaling if down-sampling is applied, and adjusts the intercept
385+
to account for any cropping.
386+
387+
Parameters
388+
----------
389+
slicer : object
390+
something that can be used to slice an array as in
391+
``arr[sliceobj]``
392+
393+
Returns
394+
-------
395+
affine : (4,4) ndarray
396+
Affine with updated scale and intercept
397+
"""
398+
slicer = self.check_slicing(slicer, return_spatial=True)
399+
400+
# Transform:
401+
# sx 0 0 tx
402+
# 0 sy 0 ty
403+
# 0 0 sz tz
404+
# 0 0 0 1
405+
transform = np.eye(4, dtype=int)
406+
407+
for i, subslicer in enumerate(slicer):
408+
if isinstance(subslicer, slice):
409+
if subslicer.step == 0:
410+
raise ValueError("slice step cannot be 0")
411+
transform[i, i] = subslicer.step if subslicer.step is not None else 1
412+
transform[i, 3] = subslicer.start or 0
413+
# If slicer is None, nothing to do
414+
415+
return self.img.affine.dot(transform)
416+
417+
324418
class SpatialImage(DataobjImage):
325419
''' Template class for volumetric (3D/4D) images '''
326420
header_class = SpatialHeader
421+
ImageSlicer = SpatialFirstSlicer
327422

328423
def __init__(self, dataobj, affine, header=None,
329424
extra=None, file_map=None):
@@ -461,12 +556,38 @@ def from_image(klass, img):
461556
klass.header_class.from_header(img.header),
462557
extra=img.extra.copy())
463558

559+
@property
560+
def slicer(self):
561+
""" Slicer object that returns cropped and subsampled images
562+
563+
The image is resliced in the current orientation; no rotation or
564+
resampling is performed, and no attempt is made to filter the image
565+
to avoid `aliasing`_.
566+
567+
The affine matrix is updated with the new intercept (and scales, if
568+
down-sampling is used), so that all values are found at the same RAS
569+
locations.
570+
571+
Slicing may include non-spatial dimensions.
572+
However, this method does not currently adjust the repetition time in
573+
the image header.
574+
575+
.. _aliasing: https://en.wikipedia.org/wiki/Aliasing
576+
"""
577+
return self.ImageSlicer(self)
578+
579+
464580
def __getitem__(self, idx):
465581
''' No slicing or dictionary interface for images
582+
583+
Use the slicer attribute to perform cropping and subsampling at your
584+
own risk.
466585
'''
467-
raise TypeError("Cannot slice image objects; consider slicing image "
468-
"array data with `img.dataobj[slice]` or "
469-
"`img.get_data()[slice]`")
586+
raise TypeError(
587+
"Cannot slice image objects; consider using `img.slicer[slice]` "
588+
"to generate a sliced image (see documentation for caveats) or "
589+
"slicing image array data with `img.dataobj[slice]` or "
590+
"`img.get_data()[slice]`")
470591

471592
def orthoview(self):
472593
"""Plot the image using OrthoSlicer3D

nibabel/tests/test_spatialimages.py

+131-3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from io import BytesIO
1818
from ..spatialimages import (SpatialHeader, SpatialImage, HeaderDataError,
1919
Header, ImageDataError)
20+
from ..imageclasses import spatial_axes_first
2021

2122
from unittest import TestCase
2223
from nose.tools import (assert_true, assert_false, assert_equal,
@@ -385,9 +386,10 @@ def test_get_data(self):
385386
img[0, 0, 0]
386387
# Make sure the right message gets raised:
387388
assert_equal(str(exception_manager.exception),
388-
("Cannot slice image objects; consider slicing image "
389-
"array data with `img.dataobj[slice]` or "
390-
"`img.get_data()[slice]`"))
389+
"Cannot slice image objects; consider using "
390+
"`img.slicer[slice]` to generate a sliced image (see "
391+
"documentation for caveats) or slicing image array data "
392+
"with `img.dataobj[slice]` or `img.get_data()[slice]`")
391393
assert_true(in_data is img.dataobj)
392394
out_data = img.get_data()
393395
assert_true(in_data is out_data)
@@ -411,6 +413,132 @@ def test_get_data(self):
411413
assert_false(rt_img.get_data() is out_data)
412414
assert_array_equal(rt_img.get_data(), in_data)
413415

416+
def test_slicer(self):
417+
img_klass = self.image_class
418+
in_data_template = np.arange(240, dtype=np.int16)
419+
base_affine = np.eye(4)
420+
t_axis = None
421+
for dshape in ((4, 5, 6, 2), # Time series
422+
(8, 5, 6)): # Volume
423+
in_data = in_data_template.copy().reshape(dshape)
424+
img = img_klass(in_data, base_affine.copy())
425+
426+
if not spatial_axes_first(img):
427+
with assert_raises(ValueError):
428+
img.slicer
429+
continue
430+
431+
assert_true(hasattr(img.slicer, '__getitem__'))
432+
433+
# Note spatial zooms are always first 3, even when
434+
spatial_zooms = img.header.get_zooms()[:3]
435+
436+
# Down-sample with [::2, ::2, ::2] along spatial dimensions
437+
sliceobj = [slice(None, None, 2)] * 3 + \
438+
[slice(None)] * (len(dshape) - 3)
439+
downsampled_img = img.slicer[tuple(sliceobj)]
440+
assert_array_equal(downsampled_img.header.get_zooms()[:3],
441+
np.array(spatial_zooms) * 2)
442+
443+
max4d = (hasattr(img.header, '_structarr') and
444+
'dims' in img.header._structarr.dtype.fields and
445+
img.header._structarr['dims'].shape == (4,))
446+
# Check newaxis and single-slice errors
447+
with assert_raises(IndexError):
448+
img.slicer[None]
449+
with assert_raises(IndexError):
450+
img.slicer[0]
451+
# Axes 1 and 2 are always spatial
452+
with assert_raises(IndexError):
453+
img.slicer[:, None]
454+
with assert_raises(IndexError):
455+
img.slicer[:, 0]
456+
with assert_raises(IndexError):
457+
img.slicer[:, :, None]
458+
with assert_raises(IndexError):
459+
img.slicer[:, :, 0]
460+
if len(img.shape) == 4:
461+
if max4d:
462+
with assert_raises(ValueError):
463+
img.slicer[:, :, :, None]
464+
else:
465+
# Reorder non-spatial axes
466+
assert_equal(img.slicer[:, :, :, None].shape,
467+
img.shape[:3] + (1,) + img.shape[3:])
468+
# 4D to 3D using ellipsis or slices
469+
assert_equal(img.slicer[..., 0].shape, img.shape[:-1])
470+
assert_equal(img.slicer[:, :, :, 0].shape, img.shape[:-1])
471+
else:
472+
# 3D Analyze/NIfTI/MGH to 4D
473+
assert_equal(img.slicer[:, :, :, None].shape, img.shape + (1,))
474+
if len(img.shape) == 3:
475+
# Slices exceed dimensions
476+
with assert_raises(IndexError):
477+
img.slicer[:, :, :, :, None]
478+
elif max4d:
479+
with assert_raises(ValueError):
480+
img.slicer[:, :, :, :, None]
481+
else:
482+
assert_equal(img.slicer[:, :, :, :, None].shape,
483+
img.shape + (1,))
484+
485+
# Crop by one voxel in each dimension
486+
sliced_i = img.slicer[1:]
487+
sliced_j = img.slicer[:, 1:]
488+
sliced_k = img.slicer[:, :, 1:]
489+
sliced_ijk = img.slicer[1:, 1:, 1:]
490+
491+
# No scaling change
492+
assert_array_equal(sliced_i.affine[:3, :3], img.affine[:3, :3])
493+
assert_array_equal(sliced_j.affine[:3, :3], img.affine[:3, :3])
494+
assert_array_equal(sliced_k.affine[:3, :3], img.affine[:3, :3])
495+
assert_array_equal(sliced_ijk.affine[:3, :3], img.affine[:3, :3])
496+
# Translation
497+
assert_array_equal(sliced_i.affine[:, 3], [1, 0, 0, 1])
498+
assert_array_equal(sliced_j.affine[:, 3], [0, 1, 0, 1])
499+
assert_array_equal(sliced_k.affine[:, 3], [0, 0, 1, 1])
500+
assert_array_equal(sliced_ijk.affine[:, 3], [1, 1, 1, 1])
501+
502+
# No change to affines with upper-bound slices
503+
assert_array_equal(img.slicer[:1, :1, :1].affine, img.affine)
504+
505+
# Yell about step = 0
506+
with assert_raises(ValueError):
507+
img.slicer[:, ::0]
508+
with assert_raises(ValueError):
509+
img.slicer.slice_affine((slice(None), slice(None, None, 0)))
510+
511+
# Don't permit zero-length slices
512+
with assert_raises(IndexError):
513+
img.slicer[:0]
514+
515+
# No fancy indexing
516+
with assert_raises(IndexError):
517+
img.slicer[[0]]
518+
with assert_raises(IndexError):
519+
img.slicer[[-1]]
520+
with assert_raises(IndexError):
521+
img.slicer[[0], [-1]]
522+
523+
# Check data is consistent with slicing numpy arrays
524+
slice_elems = (None, Ellipsis, 0, 1, -1, [0], [1], [-1],
525+
slice(None), slice(1), slice(-1), slice(1, -1))
526+
for n_elems in range(6):
527+
for _ in range(1 if n_elems == 0 else 10):
528+
sliceobj = tuple(
529+
np.random.choice(slice_elems, n_elems).tolist())
530+
try:
531+
sliced_img = img.slicer[sliceobj]
532+
except (IndexError, ValueError):
533+
# Only checking valid slices
534+
pass
535+
else:
536+
sliced_data = in_data[sliceobj]
537+
assert_array_equal(sliced_data, sliced_img.get_data())
538+
assert_array_equal(sliced_data, sliced_img.dataobj)
539+
assert_array_equal(sliced_data, img.dataobj[sliceobj])
540+
assert_array_equal(sliced_data, img.get_data()[sliceobj])
541+
414542
def test_api_deprecations(self):
415543

416544
class FakeImage(self.image_class):

0 commit comments

Comments
 (0)