SpatialFirstSlicer.slice_affine takes the slice's start and puts it straight into the translation column of the voxel-to-voxel transform:
# nibabel/spatialimages.py:459
transform[i, i] = subslicer.step if subslicer.step is not None else 1
transform[i, 3] = subslicer.start or 0
That holds only when start is a non-negative integer and the step is positive. Two cases break it:
-
A negative start is written verbatim. img.slicer[-2:] on a 10-voxel axis puts -2 into the affine where voxel 8 belongs. check_slicing runs the slicer through canonical_slicers, which normalises negative integer indices but leaves start and stop inside slice objects untouched.
-
A negative step with no explicit start. The first voxel is then n-1, but subslicer.start or 0 yields 0, so img.slicer[::-1] gets the origin wrong.
Either way the returned image holds exactly the right data with an affine that places it somewhere else in world space. Nothing warns or raises.
Reproduction
import numpy as np
import nibabel as nib
from nibabel.affines import apply_affine
shape = (10, 4, 4)
data = np.arange(int(np.prod(shape)), dtype=np.int32).reshape(shape)
affine = np.diag([-2.0, 2.0, 2.0, 1.0])
affine[:3, 3] = [90.0, -126.0, -72.0]
img = nib.Nifti1Image(data, affine)
lookup = {int(v): ijk for ijk, v in np.ndenumerate(data)}
def check(label, sl):
simg = img.slicer[sl]
sdata = np.asanyarray(simg.dataobj)
worst = 0.0
for ijk, v in np.ndenumerate(sdata):
got = apply_affine(simg.affine, ijk)
want = apply_affine(img.affine, lookup[int(v)])
worst = max(worst, float(np.max(np.abs(got - want))))
print(f' {"ok " if worst < 1e-9 else "FAIL"} {label:<20} max RAS+ error {worst:7.3f} mm')
return sdata
a = check('img.slicer[8:]', (slice(8, None),))
b = check('img.slicer[-2:]', (slice(-2, None),))
print(' identical data:', np.array_equal(a, b))
c = check('img.slicer[9::-1]', (slice(9, None, -1),))
d = check('img.slicer[::-1]', (slice(None, None, -1),))
e = check('img.slicer[-1::-1]', (slice(-1, None, -1),))
print(' identical data:', np.array_equal(c, d) and np.array_equal(d, e))
Output on main (4704d904b):
ok img.slicer[8:] max RAS+ error 0.000 mm
FAIL img.slicer[-2:] max RAS+ error 20.000 mm
identical data: True
ok img.slicer[9::-1] max RAS+ error 0.000 mm
FAIL img.slicer[::-1] max RAS+ error 18.000 mm
FAIL img.slicer[-1::-1] max RAS+ error 20.000 mm
identical data: True
img.slicer[2:-2] and img.slicer[::2] both pass, so a negative stop is fine. It is the start that goes wrong.
This also reaches nib-roi, which advertises -i I1:I2[:-1] Start/stop [flip] along first axis and ships a sanitize() helper so argparse will accept negative starts.
Why this looks like a bug rather than intended behaviour
Three spellings of the same operation return byte-identical data and three different affines. Only img.slicer[9::-1] matches ground truth. The same goes for img.slicer[8:] against img.slicer[-2:].
The documented contract is RAS+ invariance. From doc/source/nibabel_images.rst:
The slicer attribute provides an array-slicing interface to produce new images with an appropriately adjusted header, such that the data at a given RAS+ location is unchanged.
and slice_affine's own docstring says it "adjusts the intercept to account for any cropping".
The worked example further down that same page is affected. Under the heading "an image can be flipped along an axis, maintaining an appropriate affine matrix", it runs ras = img.slicer[::-1] and prints an X translation of 117.86, which is the same value as the unflipped image. The correct value is -136.14, so the affine printed in the docs is off by 254 mm.
Why the test suite does not catch it
nibabel/cmdline/tests/test_roi.py does exercise -j -1:1:-1, which has both a negative start and a negative step, but it asserts np.allclose(in_sliced.affine, out_img.affine). That compares the implementation against itself, so it passes either way.
The randomised slicing test in test_spatialimages.py checks data equality only and never looks at the affine, and its slice_elems list has no negative start and no negative step. The affine assertions nearby use non-negative starts.
Suggested fix
Resolve each spatial subslicer against its axis length before building the transform, so subslicer.indices(self.img.shape[i]) supplies a concrete start. That keeps the change local.
Making canonical_slicers normalise negative bounds inside slice objects would fix it further upstream, and its docstring arguably promises that already, but it would also change predict_shape and fileslice, so it seems like the riskier option.
The doctest at doc/source/nibabel_images.rst needs updating in the same change, since the affine it prints is the current incorrect value.
I have a regression test ready that asserts, voxel by voxel, that simg.affine @ [i,j,k,1] equals img.affine @ [orig_i,orig_j,orig_k,1], parametrised over the equivalent spellings above. It fails on the negative-start and bare ::-1 cases today. Happy to open a PR if this approach looks right to you.
Versions
Reproduced on main at 4704d904b, and the same line is unchanged in 5.3.2, 5.2.1 and 4.0.2. It has been there since slicer landed in #550.
nibabel 5.5.0.dev117+g4704d904b
numpy 2.5.1
Python 3.12
SpatialFirstSlicer.slice_affinetakes the slice'sstartand puts it straight into the translation column of the voxel-to-voxel transform:That holds only when
startis a non-negative integer and the step is positive. Two cases break it:A negative start is written verbatim.
img.slicer[-2:]on a 10-voxel axis puts-2into the affine where voxel8belongs.check_slicingruns the slicer throughcanonical_slicers, which normalises negative integer indices but leavesstartandstopinsidesliceobjects untouched.A negative step with no explicit start. The first voxel is then
n-1, butsubslicer.start or 0yields0, soimg.slicer[::-1]gets the origin wrong.Either way the returned image holds exactly the right data with an affine that places it somewhere else in world space. Nothing warns or raises.
Reproduction
Output on
main(4704d904b):img.slicer[2:-2]andimg.slicer[::2]both pass, so a negative stop is fine. It is the start that goes wrong.This also reaches
nib-roi, which advertises-i I1:I2[:-1] Start/stop [flip] along first axisand ships asanitize()helper so argparse will accept negative starts.Why this looks like a bug rather than intended behaviour
Three spellings of the same operation return byte-identical data and three different affines. Only
img.slicer[9::-1]matches ground truth. The same goes forimg.slicer[8:]againstimg.slicer[-2:].The documented contract is RAS+ invariance. From
doc/source/nibabel_images.rst:and
slice_affine's own docstring says it "adjusts the intercept to account for any cropping".The worked example further down that same page is affected. Under the heading "an image can be flipped along an axis, maintaining an appropriate affine matrix", it runs
ras = img.slicer[::-1]and prints an X translation of117.86, which is the same value as the unflipped image. The correct value is-136.14, so the affine printed in the docs is off by 254 mm.Why the test suite does not catch it
nibabel/cmdline/tests/test_roi.pydoes exercise-j -1:1:-1, which has both a negative start and a negative step, but it assertsnp.allclose(in_sliced.affine, out_img.affine). That compares the implementation against itself, so it passes either way.The randomised slicing test in
test_spatialimages.pychecks data equality only and never looks at the affine, and itsslice_elemslist has no negative start and no negative step. The affine assertions nearby use non-negative starts.Suggested fix
Resolve each spatial subslicer against its axis length before building the transform, so
subslicer.indices(self.img.shape[i])supplies a concrete start. That keeps the change local.Making
canonical_slicersnormalise negative bounds insidesliceobjects would fix it further upstream, and its docstring arguably promises that already, but it would also changepredict_shapeandfileslice, so it seems like the riskier option.The doctest at
doc/source/nibabel_images.rstneeds updating in the same change, since the affine it prints is the current incorrect value.I have a regression test ready that asserts, voxel by voxel, that
simg.affine @ [i,j,k,1]equalsimg.affine @ [orig_i,orig_j,orig_k,1], parametrised over the equivalent spellings above. It fails on the negative-start and bare::-1cases today. Happy to open a PR if this approach looks right to you.Versions
Reproduced on
mainat4704d904b, and the same line is unchanged in 5.3.2, 5.2.1 and 4.0.2. It has been there sinceslicerlanded in #550.