Skip to content

Commit 93a119e

Browse files
authored
Merge branch 'master' into mnt/mark-tests-slow
2 parents 9363e52 + 3eb8c39 commit 93a119e

File tree

7 files changed

+94
-34
lines changed

7 files changed

+94
-34
lines changed

.circleci/config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ jobs:
165165

166166
test_package:
167167
docker:
168-
- image: cimg/python:3.9
168+
- image: cimg/python:3.10
169169
auth:
170170
username: $DOCKER_USER
171171
password: $DOCKER_PAT
@@ -217,7 +217,7 @@ jobs:
217217
218218
deploy_pypi:
219219
docker:
220-
- image: cimg/python:3.9
220+
- image: cimg/python:3.10
221221
auth:
222222
username: $DOCKER_USER
223223
password: $DOCKER_PAT

.github/workflows/tox.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,17 @@ jobs:
6767
needs: [cache-test-data]
6868
strategy:
6969
matrix:
70-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
70+
python-version: ["3.10", "3.11", "3.12", "3.13"]
7171
dependencies: [latest, pre]
7272
include:
73-
- python-version: "3.9"
73+
- python-version: "3.10"
7474
dependencies: min
7575
exclude:
7676
# Do not test pre-releases for versions out of SPEC0
77-
- python-version: "3.9"
78-
dependencies: pre
7977
- python-version: "3.10"
8078
dependencies: pre
79+
- python-version: "3.11"
80+
dependencies: pre
8181

8282
env:
8383
DEPENDS: ${{ matrix.dependencies }}

nitransforms/interp/bspline.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,28 @@
77
#
88
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
99
"""Interpolate with 3D tensor-product B-Spline basis."""
10+
1011
import numpy as np
1112
import nibabel as nb
1213
from scipy.sparse import csr_matrix, kron
1314

1415

1516
def _cubic_bspline(d, order=3):
16-
"""Evaluate the cubic bspline at distance d from the center."""
17+
"""Evaluate the cubic B-spline at distance ``d`` from the center."""
18+
1719
if order != 3:
1820
raise NotImplementedError
1921

20-
return np.piecewise(
21-
d,
22-
[d < 1.0, d >= 1.0],
23-
[
24-
lambda d: (4.0 - 6.0 * d ** 2 + 3.0 * d ** 3) / 6.0,
25-
lambda d: (2.0 - d) ** 3 / 6.0,
26-
],
27-
)
22+
d = np.abs(d)
23+
out = np.zeros_like(d, dtype="float32")
24+
25+
mask1 = d < 1.0
26+
mask2 = (d >= 1.0) & (d < 2.0)
27+
28+
out[mask1] = (4.0 - 6.0 * d[mask1] ** 2 + 3.0 * d[mask1] ** 3) / 6.0
29+
out[mask2] = (2.0 - d[mask2]) ** 3 / 6.0
30+
31+
return out
2832

2933

3034
def grid_bspline_weights(target_grid, ctrl_grid):

nitransforms/nonlinear.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -453,10 +453,10 @@ def map(self, x, inverse=False):
453453
>>> xfm = BSplineFieldTransform(test_dir / "someones_bspline_coefficients.nii.gz")
454454
>>> xfm.reference = test_dir / "someones_anatomy.nii.gz"
455455
>>> xfm.map([-6.5, -36., -19.5]).tolist() # doctest: +ELLIPSIS
456-
[[-6.5, -31.476097418406..., -19.5]]
456+
[[-6.5, -36.475114..., -19.5]]
457457
458458
>>> xfm.map([[-6.5, -36., -19.5], [-1., -41.5, -11.25]]).tolist() # doctest: +ELLIPSIS
459-
[[-6.5, -31.4760974184..., -19.5], [-1.0, -3.807267537712..., -11.25]]
459+
[[-6.5, -36.475114..., -19.5], [-1.0, -42.03878957..., -11.25]]
460460
461461
"""
462462
vfunc = partial(
@@ -499,18 +499,23 @@ def _map_xyz(x, reference, knots, coeffs):
499499
# Calculate the index coordinates of the point in the B-Spline grid
500500
ijk = (knots.inverse @ _as_homogeneous(x).squeeze())[:ndim]
501501

502-
# Determine the window within distance 2.0 (where the B-Spline is nonzero)
502+
# Determine the window within distance 2.0 (where the B-Spline is nonzero).
503503
# Probably this will change if the order of the B-Spline is different
504504
w_start, w_end = np.ceil(ijk - 2).astype(int), np.floor(ijk + 2).astype(int)
505-
# Generate a grid of indexes corresponding to the window
506-
nonzero_knots = tuple(
507-
[np.arange(start, end + 1) for start, end in zip(w_start, w_end)]
508-
)
505+
506+
# Generate a grid of indexes corresponding to the window, clipped to the
507+
# coefficient grid boundaries
508+
nonzero_knots = []
509+
for start, end, size in zip(w_start, w_end, knots.shape):
510+
start = max(start, 0)
511+
end = min(end, size - 1)
512+
nonzero_knots.append(np.arange(start, end + 1))
509513
nonzero_knots = tuple(np.meshgrid(*nonzero_knots, indexing="ij"))
510514
window = np.array(nonzero_knots).reshape((ndim, -1))
511515

512-
# Calculate the distance of the location w.r.t. to all voxels in window
513-
distance = window.T - ijk
516+
# Calculate the absolute distance of the location w.r.t. all voxels in
517+
# the window. Distances are expressed in knot-grid voxel units
518+
distance = np.abs(window.T - ijk)
514519
# Since this is a grid, distance only takes a few float values
515520
unique_d, indices = np.unique(distance.reshape(-1), return_inverse=True)
516521
# Calculate the B-Spline weight corresponding to the distance.

nitransforms/tests/test_nonlinear.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,56 @@ def test_densefield_oob_resampling(is_deltas):
190190
assert np.allclose(mapped[0], points[0])
191191
assert np.allclose(mapped[2], points[2])
192192
assert np.allclose(mapped[1], points[1] + 1)
193+
194+
195+
def test_bspline_map_gridpoints():
196+
"""BSpline mapping matches dense field on grid points."""
197+
ref = nb.Nifti1Image(np.zeros((5, 5, 5), dtype="uint8"), np.eye(4))
198+
coeff = nb.Nifti1Image(
199+
np.random.RandomState(0).rand(9, 9, 9, 3).astype("float32"), np.eye(4)
200+
)
201+
202+
bspline = BSplineFieldTransform(coeff, reference=ref)
203+
dense = bspline.to_field()
204+
205+
# Use a couple of voxel centers from the reference grid
206+
ijk = np.array([[1, 1, 1], [2, 3, 0]])
207+
pts = nb.affines.apply_affine(ref.affine, ijk)
208+
209+
assert np.allclose(bspline.map(pts), dense.map(pts), atol=1e-6)
210+
211+
212+
def test_bspline_map_manual():
213+
"""BSpline interpolation agrees with manual computation."""
214+
ref = nb.Nifti1Image(np.zeros((5, 5, 5), dtype="uint8"), np.eye(4))
215+
rng = np.random.RandomState(0)
216+
coeff = nb.Nifti1Image(rng.rand(9, 9, 9, 3).astype("float32"), np.eye(4))
217+
218+
bspline = BSplineFieldTransform(coeff, reference=ref)
219+
220+
from nitransforms.base import _as_homogeneous
221+
from nitransforms.interp.bspline import _cubic_bspline
222+
223+
def manual_map(x):
224+
ijk = (bspline._knots.inverse @ _as_homogeneous(x).squeeze())[:3]
225+
w_start = np.floor(ijk).astype(int) - 1
226+
w_end = w_start + 3
227+
w_start = np.maximum(w_start, 0)
228+
w_end = np.minimum(w_end, np.array(bspline._coeffs.shape[:3]) - 1)
229+
230+
window = []
231+
for i in range(w_start[0], w_end[0] + 1):
232+
for j in range(w_start[1], w_end[1] + 1):
233+
for k in range(w_start[2], w_end[2] + 1):
234+
window.append([i, j, k])
235+
window = np.array(window)
236+
237+
dist = np.abs(window - ijk)
238+
weights = _cubic_bspline(dist).prod(1)
239+
coeffs = bspline._coeffs[window[:, 0], window[:, 1], window[:, 2]]
240+
241+
return x + coeffs.T @ weights
242+
243+
pts = np.array([[1.2, 1.5, 2.0], [3.3, 1.7, 2.4]])
244+
expected = np.vstack([manual_map(p) for p in pts])
245+
assert np.allclose(bspline.map(pts), expected, atol=1e-6)

pyproject.toml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,19 @@ classifiers = [
1111
"Intended Audience :: Science/Research",
1212
"Topic :: Scientific/Engineering :: Image Recognition",
1313
"License :: OSI Approved :: BSD License",
14-
"Programming Language :: Python :: 3.9",
1514
"Programming Language :: Python :: 3.10",
1615
"Programming Language :: Python :: 3.11",
1716
"Programming Language :: Python :: 3.12",
1817
"Programming Language :: Python :: 3.13",
1918
]
2019
description = "NiTransforms -- Neuroimaging spatial transforms in Python."
2120
license = {text = "MIT License"}
22-
requires-python = ">= 3.9"
21+
requires-python = ">= 3.10"
2322
dependencies = [
24-
"numpy >= 1.21",
23+
"numpy >= 2.1",
2524
"scipy >= 1.8",
26-
"nibabel >= 4.0",
27-
"h5py >= 3.9",
25+
"nibabel >= 5.2",
26+
"h5py >= 3.11",
2827
]
2928
dynamic = ["version"]
3029

@@ -34,9 +33,9 @@ Manuscript = "https://doi.org/10.31219/osf.io/8aq7b"
3433
NiBabel = "https://github.com/nipy/nibabel/pull/656"
3534

3635
[project.optional-dependencies]
37-
niftiext = ["lxml >= 4.6"]
36+
niftiext = ["lxml >= 4.6.4"]
3837
test = [
39-
"pytest >= 6",
38+
"pytest >= 6.2.5",
4039
"pytest-cov >= 2.11",
4140
"pytest-env",
4241
"pytest-xdist >= 2.5",

tox.ini

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
requires =
33
tox>=4
44
envlist =
5-
py3{9,10,11,12,13}-latest
6-
py39-min
5+
py3{10,11,12,13}-latest
6+
py310-min
77
py3{11,12,13}-pre
88
skip_missing_interpreters = true
99

1010
# Configuration that allows us to split tests across GitHub runners effectively
1111
[gh-actions]
1212
python =
13-
3.9: py39
1413
3.10: py310
1514
3.11: py311
1615
3.12: py312

0 commit comments

Comments
 (0)