Skip to content

img.slicer produces a wrong affine for negative starts and reversed slices #1533

Description

@Arthur031221

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:

  1. 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.

  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions