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 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)