bifti 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bifti/__init__.py +43 -0
- bifti/_backend.py +110 -0
- bifti/_nifti_write.py +112 -0
- bifti/loader.py +456 -0
- bifti/phantom.py +373 -0
- bifti/py.typed +0 -0
- bifti/registry.py +209 -0
- bifti/resample.py +334 -0
- bifti-0.1.0.dist-info/METADATA +190 -0
- bifti-0.1.0.dist-info/RECORD +12 -0
- bifti-0.1.0.dist-info/WHEEL +4 -0
- bifti-0.1.0.dist-info/licenses/LICENSE +21 -0
bifti/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from .phantom import (
|
|
2
|
+
BiftiPhantom,
|
|
3
|
+
BiftiTissue,
|
|
4
|
+
NiftiMapping,
|
|
5
|
+
NiftiRef,
|
|
6
|
+
PATIENT_POSITIONS,
|
|
7
|
+
Patient,
|
|
8
|
+
PhantomSystem,
|
|
9
|
+
PhantomUnits,
|
|
10
|
+
ResliceTo,
|
|
11
|
+
patient_to_scanner,
|
|
12
|
+
)
|
|
13
|
+
from .loader import (
|
|
14
|
+
NumpyPhantom,
|
|
15
|
+
NumpyTissue,
|
|
16
|
+
to_scanner_affine,
|
|
17
|
+
)
|
|
18
|
+
from .registry import (
|
|
19
|
+
flatten_phantoms,
|
|
20
|
+
load_catalog,
|
|
21
|
+
load_registry,
|
|
22
|
+
load_registry_phantom,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BiftiPhantom",
|
|
27
|
+
"BiftiTissue",
|
|
28
|
+
"NiftiMapping",
|
|
29
|
+
"NiftiRef",
|
|
30
|
+
"PATIENT_POSITIONS",
|
|
31
|
+
"Patient",
|
|
32
|
+
"PhantomSystem",
|
|
33
|
+
"PhantomUnits",
|
|
34
|
+
"ResliceTo",
|
|
35
|
+
"patient_to_scanner",
|
|
36
|
+
"NumpyPhantom",
|
|
37
|
+
"NumpyTissue",
|
|
38
|
+
"to_scanner_affine",
|
|
39
|
+
"flatten_phantoms",
|
|
40
|
+
"load_catalog",
|
|
41
|
+
"load_registry",
|
|
42
|
+
"load_registry_phantom",
|
|
43
|
+
]
|
bifti/_backend.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Array backend for resampling: torch when it is installed, NumPy otherwise.
|
|
2
|
+
|
|
3
|
+
The separable resampling path (see :mod:`bifti.resample`) is three matrix contractions, so
|
|
4
|
+
it maps straight onto whichever array library is available. Torch is used whenever it can be
|
|
5
|
+
imported - *not* only when CUDA is present, since torch is faster than NumPy on CPU too and
|
|
6
|
+
falls back to the same code path either way.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import warnings
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Backend:
|
|
19
|
+
"""The handful of array operations resampling needs."""
|
|
20
|
+
|
|
21
|
+
name = "numpy"
|
|
22
|
+
|
|
23
|
+
def asarray(self, x):
|
|
24
|
+
return np.asarray(x, dtype=np.float64)
|
|
25
|
+
|
|
26
|
+
def to_numpy(self, x) -> np.ndarray:
|
|
27
|
+
return np.asarray(x, dtype=np.float64)
|
|
28
|
+
|
|
29
|
+
def tensordot(self, a, b, axes):
|
|
30
|
+
return np.tensordot(a, b, axes=axes)
|
|
31
|
+
|
|
32
|
+
def moveaxis(self, x, source, destination):
|
|
33
|
+
return np.moveaxis(x, source, destination)
|
|
34
|
+
|
|
35
|
+
def transpose(self, x, axes):
|
|
36
|
+
return np.transpose(x, axes)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TorchBackend(Backend):
|
|
40
|
+
"""Torch-backed contractions, on CUDA when available.
|
|
41
|
+
|
|
42
|
+
Uses float64 on CPU so results match the NumPy backend exactly, and float32 on CUDA
|
|
43
|
+
where float64 throughput is heavily penalised. That makes the GPU path accurate to
|
|
44
|
+
roughly 1e-6 relative rather than to machine precision - far below the accuracy of the
|
|
45
|
+
phantom data itself.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
name = "torch"
|
|
49
|
+
|
|
50
|
+
def __init__(self, torch):
|
|
51
|
+
self._torch = torch
|
|
52
|
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
53
|
+
self.dtype = torch.float32 if self.device.type == "cuda" else torch.float64
|
|
54
|
+
|
|
55
|
+
def asarray(self, x):
|
|
56
|
+
if self._torch.is_tensor(x):
|
|
57
|
+
return x.to(device=self.device, dtype=self.dtype)
|
|
58
|
+
return self._torch.as_tensor(
|
|
59
|
+
np.ascontiguousarray(x, dtype=np.float64),
|
|
60
|
+
dtype=self.dtype,
|
|
61
|
+
device=self.device,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def to_numpy(self, x) -> np.ndarray:
|
|
65
|
+
if self._torch.is_tensor(x):
|
|
66
|
+
return x.detach().to("cpu", self._torch.float64).numpy()
|
|
67
|
+
return np.asarray(x, dtype=np.float64)
|
|
68
|
+
|
|
69
|
+
def tensordot(self, a, b, axes):
|
|
70
|
+
return self._torch.tensordot(a, b, dims=axes)
|
|
71
|
+
|
|
72
|
+
def moveaxis(self, x, source, destination):
|
|
73
|
+
return self._torch.movedim(x, source, destination)
|
|
74
|
+
|
|
75
|
+
def transpose(self, x, axes):
|
|
76
|
+
return self._torch.permute(x, tuple(int(a) for a in axes))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_torch_backend() -> Backend | None:
|
|
80
|
+
try:
|
|
81
|
+
import torch
|
|
82
|
+
except ImportError:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
return TorchBackend(torch)
|
|
86
|
+
except Exception as exc: # pragma: no cover - torch present but unusable
|
|
87
|
+
warnings.warn(f"torch is installed but unusable ({exc}); falling back to NumPy")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@lru_cache(maxsize=None)
|
|
92
|
+
def get_backend(name: str | None = None) -> Backend:
|
|
93
|
+
"""The array backend to resample with.
|
|
94
|
+
|
|
95
|
+
``name`` (or ``$BIFTI_RESAMPLE_BACKEND``) may be ``"auto"`` (the default), ``"numpy"``
|
|
96
|
+
or ``"torch"``. ``"torch"`` raises if torch is missing; ``"auto"`` quietly falls back.
|
|
97
|
+
"""
|
|
98
|
+
name = (name or os.environ.get("BIFTI_RESAMPLE_BACKEND", "auto")).strip().lower()
|
|
99
|
+
if name == "numpy":
|
|
100
|
+
return Backend()
|
|
101
|
+
if name == "torch":
|
|
102
|
+
backend = _load_torch_backend()
|
|
103
|
+
if backend is None:
|
|
104
|
+
raise ImportError("BIFTI_RESAMPLE_BACKEND=torch but torch is not installed")
|
|
105
|
+
return backend
|
|
106
|
+
if name != "auto":
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"invalid resampling backend {name!r} (use auto, numpy or torch)"
|
|
109
|
+
)
|
|
110
|
+
return _load_torch_backend() or Backend()
|
bifti/_nifti_write.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Deterministic NIfTI-1 writer, the same bytes as the Rust, Julia and MATLAB writers.
|
|
2
|
+
|
|
3
|
+
A phantom directory is hashed as a whole, so the output must be reproducible: fixed
|
|
4
|
+
header fields, little-endian, no extensions, and a gzip header without mtime or file name.
|
|
5
|
+
nibabel is not used for writing because it would put its own defaults (scaling NaNs,
|
|
6
|
+
a ``descrip`` string, the gzip mtime) into the file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import struct
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
HEADER_SIZE = 348
|
|
18
|
+
VOX_OFFSET = 352 # header plus the 4-byte (empty) extension flag
|
|
19
|
+
GZIP_LEVEL = 6
|
|
20
|
+
GZIP_OS_UNKNOWN = 255
|
|
21
|
+
|
|
22
|
+
# numpy dtype -> (NIfTI datatype code, bitpix)
|
|
23
|
+
_DATATYPES: dict[np.dtype, tuple[int, int]] = {
|
|
24
|
+
np.dtype("u1"): (2, 8),
|
|
25
|
+
np.dtype("<i2"): (4, 16),
|
|
26
|
+
np.dtype("<i4"): (8, 32),
|
|
27
|
+
np.dtype("<f4"): (16, 32),
|
|
28
|
+
np.dtype("<c8"): (32, 64),
|
|
29
|
+
np.dtype("<f8"): (64, 64),
|
|
30
|
+
np.dtype("i1"): (256, 8),
|
|
31
|
+
np.dtype("<u2"): (512, 16),
|
|
32
|
+
np.dtype("<u4"): (768, 32),
|
|
33
|
+
np.dtype("<i8"): (1024, 64),
|
|
34
|
+
np.dtype("<u8"): (1280, 64),
|
|
35
|
+
np.dtype("<c16"): (1792, 128),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _stack_dtype(volumes: list[np.ndarray]) -> np.dtype:
|
|
40
|
+
"""The common little-endian dtype: kept if all agree, else float64 / complex128."""
|
|
41
|
+
kinds = {v.dtype.newbyteorder("<") for v in volumes}
|
|
42
|
+
if len(kinds) == 1:
|
|
43
|
+
(dtype,) = kinds
|
|
44
|
+
if dtype not in _DATATYPES:
|
|
45
|
+
raise ValueError(f"cannot write NIfTI data of type {dtype}")
|
|
46
|
+
return dtype
|
|
47
|
+
if any(v.dtype.kind == "c" for v in volumes):
|
|
48
|
+
return np.dtype("<c16")
|
|
49
|
+
return np.dtype("<f8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_header(shape, n_volumes: int, affine, dtype: np.dtype) -> bytes:
|
|
53
|
+
"""The 352 bytes before the voxel data.
|
|
54
|
+
|
|
55
|
+
Orientation is stored in the sform only (``sform_code = 2``, ``qform_code = 0``), as
|
|
56
|
+
``../../NIFTI.md`` requires.
|
|
57
|
+
"""
|
|
58
|
+
affine = np.asarray(affine, dtype=np.float64)[:3]
|
|
59
|
+
datatype, bitpix = _DATATYPES[dtype]
|
|
60
|
+
header = bytearray(VOX_OFFSET)
|
|
61
|
+
struct.pack_into("<i", header, 0, HEADER_SIZE)
|
|
62
|
+
struct.pack_into("<8h", header, 40, 4, *shape, n_volumes, 1, 1, 1)
|
|
63
|
+
struct.pack_into("<hh", header, 70, datatype, bitpix)
|
|
64
|
+
voxel_sizes = np.linalg.norm(affine[:, :3], axis=0)
|
|
65
|
+
struct.pack_into("<5f", header, 76, 1.0, *voxel_sizes.astype(np.float32), 1.0)
|
|
66
|
+
struct.pack_into("<f", header, 108, float(VOX_OFFSET)) # vox_offset
|
|
67
|
+
struct.pack_into("<f", header, 112, 1.0) # scl_slope
|
|
68
|
+
header[123] = 2 # xyzt_units: millimetres
|
|
69
|
+
struct.pack_into("<hh", header, 252, 0, 2) # qform_code, sform_code
|
|
70
|
+
struct.pack_into("<12f", header, 280, *affine.astype(np.float32).ravel())
|
|
71
|
+
header[344:348] = b"n+1\0"
|
|
72
|
+
return bytes(header)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write_stack(path: Path | str, volumes: list[np.ndarray], affine) -> list[int]:
|
|
76
|
+
"""Write 3-D ``volumes`` as one 4-D NIfTI-1 file and return each one's 4th-dim index.
|
|
77
|
+
|
|
78
|
+
The volumes share ``affine`` and shape. Volumes with identical stored bytes share an
|
|
79
|
+
index and are written once. A path ending in ``.gz`` is gzip-compressed.
|
|
80
|
+
"""
|
|
81
|
+
path = Path(path)
|
|
82
|
+
if not volumes:
|
|
83
|
+
raise ValueError(f"{path}: no volumes to write")
|
|
84
|
+
shape = volumes[0].shape
|
|
85
|
+
if len(shape) != 3 or any(v.shape != shape for v in volumes):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"{path}: volumes must all be 3-D and share a shape, got "
|
|
88
|
+
f"{sorted({v.shape for v in volumes})}"
|
|
89
|
+
)
|
|
90
|
+
dtype = _stack_dtype(volumes)
|
|
91
|
+
|
|
92
|
+
stored: dict[bytes, int] = {}
|
|
93
|
+
slots = []
|
|
94
|
+
for volume in volumes:
|
|
95
|
+
# NIfTI stores voxels x-fastest, which is Fortran order.
|
|
96
|
+
data = np.asarray(volume).astype(dtype, copy=False).ravel(order="F").tobytes()
|
|
97
|
+
slots.append(stored.setdefault(data, len(stored)))
|
|
98
|
+
|
|
99
|
+
payload = b"".join(stored) # dicts keep insertion order = slot order
|
|
100
|
+
blob = build_header(shape, len(stored), affine, dtype) + payload
|
|
101
|
+
if path.suffix == ".gz":
|
|
102
|
+
compressed = bytearray(gzip.compress(blob, compresslevel=GZIP_LEVEL, mtime=0))
|
|
103
|
+
compressed[9] = GZIP_OS_UNKNOWN # zlib writes the host's OS code
|
|
104
|
+
blob = bytes(compressed)
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_bytes(blob)
|
|
107
|
+
return slots
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def save_nifti(path: Path | str, volume: np.ndarray, affine) -> None:
|
|
111
|
+
"""Write one 3-D volume as a NIfTI-1 file with a singleton 4th dimension."""
|
|
112
|
+
write_stack(path, [volume], affine)
|