Skip to content

Commit 393aab4

Browse files
authored
Merge pull request #78 from inhuszar/fix/numpy2x
NumPy 2.x compatibility fixes for image interpolation and I/O
2 parents b19e2dd + 8aa4a5f commit 393aab4

6 files changed

Lines changed: 91 additions & 11 deletions

File tree

setup.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env python
22

3+
import os
34
import re
45
import pathlib
56
import platform
@@ -16,12 +17,16 @@ class bdist_wheel_abi3(bdist_wheel): # noqa: D101
1617
def get_tag(self): # noqa: D102
1718
python, abi, plat = super().get_tag()
1819

20+
# Only meaningful when we are *actually* building an abi3 wheel.
21+
# We target cp311 as the minimum CPython that supports the features
22+
# this project relies on for limited API mode.
1923
if python.startswith("cp"):
2024
return "cp311", "abi3", plat
2125

2226
return python, abi, plat
2327

2428

29+
2530
requirements = [
2631
'numpy',
2732
'scipy',
@@ -50,8 +55,10 @@ def get_tag(self): # noqa: D102
5055
)
5156
macros = []
5257
setup_opts = {}
53-
if sys.version_info.minor >= 11 and platform.python_implementation() == "CPython":
54-
# Can create an abi3 wheel (typed memoryviews first available in 3.11)!
58+
59+
ABI3 = (os.environ.get("SURFA_ABI3", "0") == "1")
60+
61+
if ABI3 and sys.version_info >= (3, 11) and platform.python_implementation() == "CPython":
5562
ext_opts["define_macros"].append(("Py_LIMITED_API", "0x030B0000"))
5663
ext_opts["py_limited_api"] = True
5764
setup_opts["cmdclass"] = {"bdist_wheel": bdist_wheel_abi3}
@@ -63,6 +70,7 @@ def get_tag(self): # noqa: D102
6370
# since we interface the c stuff with numpy, it's another hard
6471
# requirement at build-time
6572
import numpy as np
73+
6674
include_dirs = [np.get_include()]
6775

6876
# extract the current version
@@ -97,7 +105,7 @@ def get_tag(self): # noqa: D102
97105
packages=packages,
98106
ext_modules=extensions,
99107
include_dirs=include_dirs,
100-
package_data={'': ['*.pyx'], '': ['*.h']},
108+
package_data={'': ['*.pyx', '*.h']},
101109
install_requires=requirements,
102110
classifiers=[
103111
'Development Status :: 3 - Alpha',

surfa/image/framed.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,18 @@ def reorient(self, orientation, copy=True, inplace=False):
509509
for i in range(self.basedim):
510510
if world_axes_src[i] != world_axes_trg[i]:
511511
data = np.swapaxes(data, world_axes_src[i], world_axes_trg[i])
512-
swapped_axis_idx = np.where(world_axes_src == world_axes_trg[i])
513-
world_axes_src[swapped_axis_idx], world_axes_src[i] = world_axes_src[i], world_axes_src[swapped_axis_idx]
512+
# NumPy 2.x no longer supports mixed scalar/sequence tuple-assignment
513+
# with np.where tuple indexing here, so resolve a scalar index first.
514+
swapped_axis_idx = np.where(world_axes_src == world_axes_trg[i])[0]
515+
if swapped_axis_idx.size == 0:
516+
raise RuntimeError(
517+
"Failed to resolve source axis during reorientation."
518+
)
519+
swapped_axis = int(swapped_axis_idx[0])
520+
world_axes_src[swapped_axis], world_axes_src[i] = (
521+
world_axes_src[i],
522+
world_axes_src[swapped_axis],
523+
)
514524

515525
# align directions
516526
dot_products = np.sum(affine[:3, :3] * trg_matrix[:3, :3], axis=0)

surfa/image/interp.pyx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,10 @@ def interpolate(source, target_shape, method, affine=None, disp=None, fill=0):
9393
# ensure correct byteorder
9494
# TODO maybe this should be done at read-time?
9595
swap_byteorder = sys.byteorder == 'little' and '>' or '<'
96-
if (source.dtype.byteorder == swap_byteorder):
97-
source = source.byteswap().view(source.dtype.newbyteorder())
98-
96+
if source.dtype.byteorder == swap_byteorder:
97+
# NumPy 2 removed ndarray.newbyteorder(); use a dtype view instead.
98+
source = source.byteswap().view(source.dtype.newbyteorder('='))
99+
99100
# a few types aren't supported, so let's just convert to float and convert back if necessary
100101
unsupported_dtype = None
101102
if source.dtype in (np.bool_,):

surfa/io/framed.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
import gzip
33
import numpy as np
44

5+
# NumPy compatibility: prefer the numpy boolean scalar dtype object.
6+
# `np.bool_` exists in both NumPy 1.x and 2.x; keep a fallback for safety.
7+
if hasattr(np, 'bool_'):
8+
_np_bool_dtype = np.bool_ # NumPy 1.x
9+
else:
10+
_np_bool_dtype = np.bool # NumPy 2.x
11+
512
from surfa import Volume
613
from surfa import Slice
714
from surfa import Overlay
@@ -407,7 +414,7 @@ def save(self, arr, filename, intent=FramedArrayIntents.mri):
407414

408415
# determine supported dtype to save as (order here is very important)
409416
type_map = {
410-
np.bool_: 0,
417+
_np_bool_dtype: 0,
411418
np.uint8: 0,
412419
np.int32: 1,
413420
np.floating: 3,
@@ -670,7 +677,7 @@ def save(self, arr, filename, intent=FramedArrayIntents.mri):
670677

671678
# convert to a valid output type (for now this is only bool but there are probably more)
672679
type_map = {
673-
np.bool_: np.uint8,
680+
_np_bool_dtype: np.uint8,
674681
}
675682
dtype_id = next((i for dt, i in type_map.items() if np.issubdtype(arr.dtype, dt)), None)
676683
data = arr.data if dtype_id is None else arr.data.astype(dtype_id)

surfa/io/fsnifti1extension.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import numpy as np
22

3+
# NumPy compatibility: handle np.int_ removal in NumPy 2.0
4+
# In NumPy 1.x, np.int_ is an alias for the platform's native int type
5+
# In NumPy 2.x, np.int_ was removed; use np.integer for type checking
6+
if hasattr(np, 'int_'):
7+
_np_int_types = (np.int_, np.integer) # NumPy 1.x: check both for safety
8+
else:
9+
_np_int_types = (np.integer,) # NumPy 2.x: only np.integer exists
10+
311
from surfa.io import fsio
412
from surfa.io import utils as iou
513
from surfa.transform.geometry import ImageGeometry
@@ -566,7 +574,7 @@ def _from_framedimage(self, image):
566574
self.version = 1
567575
self.intent = image.metadata.get('intent', FramedArrayIntents.mri)
568576
self.dof = 1
569-
if isinstance(self.intent, np.int_):
577+
if isinstance(self.intent, _np_int_types):
570578
self.intent = self.intent.item() # convert numpy int to python int
571579

572580
if self.intent == FramedArrayIntents.warpmap:

test/test_numpy_compat.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import sys
2+
3+
import numpy as np
4+
import pytest
5+
6+
7+
sf = pytest.importorskip('surfa')
8+
9+
10+
def _non_native_float32(data):
11+
byteorder = '>' if sys.byteorder == 'little' else '<'
12+
return np.asarray(data, dtype=np.float32).astype(np.dtype(np.float32).newbyteorder(byteorder), copy=True)
13+
14+
15+
def _assert_machine_precision_equal(a, b):
16+
assert a.shape == b.shape
17+
assert a.dtype == b.dtype
18+
assert np.issubdtype(a.dtype, np.floating)
19+
20+
scale = max(
21+
float(np.max(np.abs(a))),
22+
float(np.max(np.abs(b))),
23+
1.0,
24+
)
25+
atol = np.finfo(a.dtype).eps * scale
26+
assert np.allclose(a, b, rtol=0.0, atol=atol), f'max abs diff={np.max(np.abs(a - b))}, atol={atol}'
27+
28+
29+
@pytest.mark.parametrize('method', ['linear', 'nearest'])
30+
def test_resize_non_native_endian_matches_native(method):
31+
rng = np.random.default_rng(123456)
32+
data_native = rng.normal(loc=0.1, scale=1.7, size=(9, 7, 5, 2)).astype(np.float32)
33+
data_non_native = _non_native_float32(data_native)
34+
35+
vol_native = sf.Volume(data_native)
36+
vol_non_native = sf.Volume(data_non_native)
37+
38+
assert vol_non_native.framed_data.dtype.byteorder not in ('=', '|')
39+
40+
voxsize = np.array([1.3, 0.9, 1.7], dtype=np.float32)
41+
resized_native = vol_native.resize(voxsize, method=method)
42+
resized_non_native = vol_non_native.resize(voxsize, method=method)
43+
44+
assert resized_native.framed_data.shape == resized_non_native.framed_data.shape
45+
assert resized_native.framed_data.dtype == resized_non_native.framed_data.dtype
46+
_assert_machine_precision_equal(resized_native.framed_data, resized_non_native.framed_data)

0 commit comments

Comments
 (0)