MNutils 1.2.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.
- mnutils/GEExam.py +495 -0
- mnutils/GESeries.py +1578 -0
- mnutils/__init__.py +54 -0
- mnutils/_scripts.py +177 -0
- mnutils/fitting/AMARES.py +831 -0
- mnutils/fitting/__init__.py +5 -0
- mnutils/matlab/__init__.py +13 -0
- mnutils/matlab/setup_matlab.py +116 -0
- mnutils/plotting/__init__.py +38 -0
- mnutils/plotting/_widgets/__init__.py +5 -0
- mnutils/plotting/_widgets/_html.py +34 -0
- mnutils/plotting/_widgets/_shared/__init__.py +50 -0
- mnutils/plotting/_widgets/_shared/dom.js +124 -0
- mnutils/plotting/_widgets/_shared/theme.css +140 -0
- mnutils/plotting/_widgets/image_grid.css +202 -0
- mnutils/plotting/_widgets/image_grid.js +212 -0
- mnutils/plotting/_widgets/image_grid.py +86 -0
- mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
- mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
- mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
- mnutils/plotting/_widgets/slice_viewer.css +38 -0
- mnutils/plotting/_widgets/slice_viewer.js +41 -0
- mnutils/plotting/_widgets/slice_viewer.py +35 -0
- mnutils/plotting/images.py +1399 -0
- mnutils/plotting/saving.py +138 -0
- mnutils/plotting/spectra.py +357 -0
- mnutils/py.typed +0 -0
- mnutils/testing/__init__.py +3 -0
- mnutils/testing/_spectra.py +338 -0
- mnutils/testing/synthetic.py +373 -0
- mnutils/utils/__init__.py +25 -0
- mnutils/utils/data_loaders.py +218 -0
- mnutils/utils/file_helpers.py +708 -0
- mnutils/utils/images.py +202 -0
- mnutils/utils/nifti.py +379 -0
- mnutils/utils/spectra.py +149 -0
- mnutils-1.2.0.dist-info/METADATA +161 -0
- mnutils-1.2.0.dist-info/RECORD +41 -0
- mnutils-1.2.0.dist-info/WHEEL +4 -0
- mnutils-1.2.0.dist-info/entry_points.txt +7 -0
- mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
mnutils/utils/images.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING, cast, overload
|
|
4
|
+
|
|
5
|
+
# This block is ignored at runtime. I only want to import later to reduce overhead.
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
import HD_BET.checkpoint_download
|
|
8
|
+
import HD_BET.hd_bet_prediction
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
from pandas.compat._optional import import_optional_dependency
|
|
13
|
+
|
|
14
|
+
from . import file_helpers
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@overload
|
|
18
|
+
def extract_brain(
|
|
19
|
+
file: str | Path,
|
|
20
|
+
device: str = "cuda",
|
|
21
|
+
use_tta: bool = True,
|
|
22
|
+
save_mask: bool = True,
|
|
23
|
+
extract_brain: bool = True,
|
|
24
|
+
verbose: bool = False,
|
|
25
|
+
) -> Path: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@overload
|
|
29
|
+
def extract_brain(
|
|
30
|
+
file: list[str | Path],
|
|
31
|
+
device: str = "cuda",
|
|
32
|
+
use_tta: bool = True,
|
|
33
|
+
save_mask: bool = True,
|
|
34
|
+
extract_brain: bool = True,
|
|
35
|
+
verbose: bool = False,
|
|
36
|
+
) -> list[Path]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_brain(
|
|
40
|
+
file: str | Path | list[str | Path],
|
|
41
|
+
device: str = "cuda",
|
|
42
|
+
use_tta: bool = True,
|
|
43
|
+
save_mask: bool = True,
|
|
44
|
+
extract_brain: bool = True,
|
|
45
|
+
verbose: bool = False,
|
|
46
|
+
) -> Path | list[Path]:
|
|
47
|
+
"""Extract the brain from an image file using HD-BET.
|
|
48
|
+
|
|
49
|
+
This function processes NIfTI files or directories containing DICOM files to extract the
|
|
50
|
+
brain region using the HD-BET (HD-Brain Extraction Tool). It supports optional test-time
|
|
51
|
+
augmentation (TTA) and allows saving the brain mask and/or the brain-extracted image.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
file : str | Path | list[str | Path]
|
|
56
|
+
The path(s) to the image file(s) or directories containing DICOM files to extract the
|
|
57
|
+
brain from. If a directory is provided, it will be converted to a NIfTI file first.
|
|
58
|
+
device : str, optional
|
|
59
|
+
The device to use for processing. Default is "cuda".
|
|
60
|
+
use_tta : bool, optional
|
|
61
|
+
Whether to use test-time augmentation (TTA) during brain extraction. Default is True.
|
|
62
|
+
save_mask : bool, optional
|
|
63
|
+
Whether to save the brain mask. Default is True.
|
|
64
|
+
extract_brain : bool, optional
|
|
65
|
+
Whether to compute and save the brain-extracted image. Default is True.
|
|
66
|
+
verbose : bool, optional
|
|
67
|
+
Whether to enable verbose logging. Default is False.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
Path | list[Path]
|
|
72
|
+
The path(s) to the brain-extracted NIfTI file(s). If a single file is provided as input,
|
|
73
|
+
a single Path object is returned. If a list of files is provided, a list of Path objects
|
|
74
|
+
is returned.
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
ValueError
|
|
79
|
+
If the input file does not have a ".nii.gz" suffix or if the input is invalid.
|
|
80
|
+
ImportError
|
|
81
|
+
If the required dependencies (torch, HD-BET) are not installed.
|
|
82
|
+
|
|
83
|
+
Notes
|
|
84
|
+
-----
|
|
85
|
+
- This function requires the HD-BET library and PyTorch to be installed.
|
|
86
|
+
- If the input is a directory containing DICOM files, it will be converted to a NIfTI file
|
|
87
|
+
before processing.
|
|
88
|
+
- The function checks for existing brain-extracted files in the output directory to avoid
|
|
89
|
+
overwriting existing results.
|
|
90
|
+
- The HD-BET model parameters will be downloaded automatically if not already available.
|
|
91
|
+
|
|
92
|
+
Examples
|
|
93
|
+
--------
|
|
94
|
+
Extract the brain from a single NIfTI file:
|
|
95
|
+
>>> extract_brain("path/to/image.nii.gz")
|
|
96
|
+
Extract the brain from multiple NIfTI files:
|
|
97
|
+
>>> extract_brain(["path/to/image1.nii.gz", "path/to/image2.nii.gz"])
|
|
98
|
+
Extract the brain from a directory containing DICOM files:
|
|
99
|
+
>>> extract_brain("path/to/dicom_directory")
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
# If the the input is a folder containing dicoms first convert to nifti
|
|
103
|
+
if isinstance(file, (str, Path)):
|
|
104
|
+
single_file = True
|
|
105
|
+
file = [Path(file)]
|
|
106
|
+
else:
|
|
107
|
+
single_file = False
|
|
108
|
+
|
|
109
|
+
files: list[Path] = [Path(f) for f in file]
|
|
110
|
+
|
|
111
|
+
for idx, input_path in enumerate(files):
|
|
112
|
+
if os.path.isdir(input_path):
|
|
113
|
+
nifti_path = file_helpers.get_nifti_data(input_path, convert_dicoms=True)
|
|
114
|
+
# Replace the original path with the new nifti path
|
|
115
|
+
files[idx] = nifti_path
|
|
116
|
+
|
|
117
|
+
# type hinting
|
|
118
|
+
torch = import_optional_dependency("torch")
|
|
119
|
+
torch = cast("torch", torch)
|
|
120
|
+
hd_bet_checkpoint_download = import_optional_dependency("HD_BET.checkpoint_download")
|
|
121
|
+
hd_bet_checkpoint_download = cast("HD_BET.checkpoint_download", hd_bet_checkpoint_download)
|
|
122
|
+
hd_bet_predictor = import_optional_dependency("HD_BET.hd_bet_prediction")
|
|
123
|
+
hd_bet_predictor = cast("HD_BET.hd_bet_prediction", hd_bet_predictor)
|
|
124
|
+
|
|
125
|
+
# Start running hd_bet
|
|
126
|
+
# TODO fix issues with stdout capture
|
|
127
|
+
|
|
128
|
+
downloaded_parameters = False
|
|
129
|
+
predictor = None
|
|
130
|
+
|
|
131
|
+
output_files: list[Path] = []
|
|
132
|
+
|
|
133
|
+
for idx, input_path in enumerate(files):
|
|
134
|
+
logger.debug(
|
|
135
|
+
f"Processing file {file_helpers._relative_to_cwd(input_path)} ({idx + 1}/{len(files)})"
|
|
136
|
+
)
|
|
137
|
+
# Assert that input path has nii.gz suffix
|
|
138
|
+
if not str(input_path).endswith(".nii.gz"):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Input file {input_path} does not have a .nii.gz suffix. "
|
|
141
|
+
"Please provide a valid NIfTI file."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
output_dir = Path(input_path).parent
|
|
145
|
+
default_output_path = (
|
|
146
|
+
output_dir / f"{str(input_path.name)[:-7]}_bet.nii.gz"
|
|
147
|
+
) # Bet will be saved with _bet suffix by default
|
|
148
|
+
other_bet_niftis = file_helpers.get_nifti_file(
|
|
149
|
+
input_path.parent,
|
|
150
|
+
filter_out_keywords=[],
|
|
151
|
+
filter_in_keywords=["_bet.nii.gz"],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Check if output file already exists
|
|
155
|
+
if default_output_path.exists():
|
|
156
|
+
logger.warning(
|
|
157
|
+
f"Output file {file_helpers._relative_to_cwd(default_output_path)} "
|
|
158
|
+
f"already exists. Skipping brain extraction for "
|
|
159
|
+
f"{file_helpers._relative_to_cwd(input_path)}."
|
|
160
|
+
)
|
|
161
|
+
output_files.append(default_output_path)
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
if other_bet_niftis:
|
|
165
|
+
logger.warning(
|
|
166
|
+
f"Other brain extracted NIfTI files found in output directory "
|
|
167
|
+
f"{file_helpers._relative_to_cwd(output_dir)}. This may indicate that "
|
|
168
|
+
f"brain extraction has already been performed for this or other files. Skipping "
|
|
169
|
+
f"brain extraction for {file_helpers._relative_to_cwd(input_path)} to "
|
|
170
|
+
f"avoid overwriting existing files."
|
|
171
|
+
)
|
|
172
|
+
output_files.append(other_bet_niftis)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
if not downloaded_parameters:
|
|
176
|
+
hd_bet_checkpoint_download.maybe_download_parameters()
|
|
177
|
+
downloaded_parameters = True
|
|
178
|
+
|
|
179
|
+
if predictor is None:
|
|
180
|
+
predictor = hd_bet_predictor.get_hdbet_predictor(
|
|
181
|
+
use_tta=use_tta, device=torch.device(device), verbose=verbose
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
hd_bet_predictor.hdbet_predict(
|
|
185
|
+
str(input_path),
|
|
186
|
+
str(input_path),
|
|
187
|
+
predictor,
|
|
188
|
+
keep_brain_mask=save_mask,
|
|
189
|
+
compute_brain_extracted_image=extract_brain,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
logger.debug(
|
|
193
|
+
f"Brain extracted successfully for "
|
|
194
|
+
f"{file_helpers._relative_to_cwd(input_path)}, saved to "
|
|
195
|
+
f"{file_helpers._relative_to_cwd(default_output_path)}"
|
|
196
|
+
)
|
|
197
|
+
output_files.append(default_output_path)
|
|
198
|
+
|
|
199
|
+
if single_file:
|
|
200
|
+
return output_files[0]
|
|
201
|
+
else:
|
|
202
|
+
return output_files
|
mnutils/utils/nifti.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
import nibabel as nib
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from nibabel import affines, processing, spatialimages
|
|
11
|
+
|
|
12
|
+
from .. import GESeries
|
|
13
|
+
|
|
14
|
+
DEFAULT_PARAMS = {
|
|
15
|
+
"orientation": ("L", "P", "S"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
DEFAULT_RESAMPLE_PARAMS = {
|
|
19
|
+
"order": 0,
|
|
20
|
+
"mode": "grid-constant",
|
|
21
|
+
"cval": np.nan,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type DISPLAY_PLANES = Literal["axial", "coronal", "sagittal"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_display_affine(
|
|
28
|
+
nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
29
|
+
orientation: tuple[str, str, str] = ("L", "P", "S"),
|
|
30
|
+
) -> npt.NDArray[np.float64]:
|
|
31
|
+
"""Return the affine mapping display voxel indices (after orient_nifti) to world mm.
|
|
32
|
+
|
|
33
|
+
orient_nifti applies apply_orientation then flipud(rot90(k=1)) which equals
|
|
34
|
+
a transpose of axes 0 and 1. This function encodes both steps as an affine.
|
|
35
|
+
Use inv(get_display_affine(t1)) @ mrsi_affine to get mrsi→display mapping.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(nii, GESeries.NiiBase):
|
|
38
|
+
nii: spatialimages.SpatialImage = nii.nii
|
|
39
|
+
current_ornt = nib.orientations.io_orientation(nii.affine)
|
|
40
|
+
target_ornt = nib.orientations.axcodes2ornt(orientation)
|
|
41
|
+
transform = nib.orientations.ornt_transform(current_ornt, target_ornt)
|
|
42
|
+
reoriented = nii.as_reoriented(transform) # pyright: ignore[reportArgumentType]
|
|
43
|
+
oriented_affine = np.asarray(reoriented.affine, dtype=float)
|
|
44
|
+
# flipud(rot90(k=1, axes=(0,1))) = pure transpose of axes 0 and 1.
|
|
45
|
+
# This swaps columns 0 and 1 of the affine.
|
|
46
|
+
P = np.eye(4)
|
|
47
|
+
P[0, 0] = P[1, 1] = 0.0
|
|
48
|
+
P[0, 1] = P[1, 0] = 1.0
|
|
49
|
+
return oriented_affine @ P
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def orient_nifti(
|
|
53
|
+
nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
54
|
+
orientation: tuple[str, str, str] | None = None,
|
|
55
|
+
display_plane: DISPLAY_PLANES | None = None,
|
|
56
|
+
caching: bool = True,
|
|
57
|
+
) -> npt.NDArray[np.float64]:
|
|
58
|
+
"""Reorient and rotate a NIfTI image's data array for display.
|
|
59
|
+
|
|
60
|
+
Applies the requested axis orientation (or one derived from
|
|
61
|
+
`display_plane`), then transposes axes 0 and 1 to match the display
|
|
62
|
+
convention used elsewhere in this module.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
nii : spatialimages.SpatialImage or GESeries.NiiBase
|
|
67
|
+
The image to orient.
|
|
68
|
+
orientation : tuple[str, str, str], optional
|
|
69
|
+
Target axis codes. Defaults to `DEFAULT_PARAMS["orientation"]` if
|
|
70
|
+
neither this nor `display_plane` is given.
|
|
71
|
+
display_plane : {"axial", "coronal", "sagittal"}, optional
|
|
72
|
+
If given, overrides `orientation` with a preset for the named plane.
|
|
73
|
+
caching : bool, optional
|
|
74
|
+
Whether to cache the underlying image data (more memory, faster
|
|
75
|
+
repeat access) or not (less memory, slower repeat access).
|
|
76
|
+
Defaults to True.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
ndarray
|
|
81
|
+
The reoriented image data array.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(nii, GESeries.NiiBase):
|
|
84
|
+
nii: spatialimages.SpatialImage = nii.nii
|
|
85
|
+
if display_plane is not None:
|
|
86
|
+
match display_plane:
|
|
87
|
+
case "axial":
|
|
88
|
+
orientation = ("L", "P", "S")
|
|
89
|
+
case "coronal":
|
|
90
|
+
orientation = ("L", "I", "P")
|
|
91
|
+
case "sagittal":
|
|
92
|
+
orientation = ("I", "L", "P")
|
|
93
|
+
case _:
|
|
94
|
+
orientation = DEFAULT_PARAMS["orientation"]
|
|
95
|
+
logger.debug(f"Orienting NIfTI to {display_plane} plane. Using orientation {orientation}.")
|
|
96
|
+
|
|
97
|
+
if orientation is None:
|
|
98
|
+
logger.debug(
|
|
99
|
+
f"No orientation specified. Using default orientation: {DEFAULT_PARAMS['orientation']}."
|
|
100
|
+
)
|
|
101
|
+
orientation = DEFAULT_PARAMS["orientation"]
|
|
102
|
+
|
|
103
|
+
if caching:
|
|
104
|
+
logger.debug(
|
|
105
|
+
"Using caching for NIfTI data. This may use more memory but can speed up "
|
|
106
|
+
"processing for repeat access."
|
|
107
|
+
)
|
|
108
|
+
caching_param = "fill"
|
|
109
|
+
else:
|
|
110
|
+
logger.debug(
|
|
111
|
+
"Not using caching for NIfTI data. This may use less memory but can slow down "
|
|
112
|
+
"processing for repeat access."
|
|
113
|
+
)
|
|
114
|
+
caching_param = "unchanged"
|
|
115
|
+
|
|
116
|
+
imagesOr = nib.orientations.apply_orientation(
|
|
117
|
+
nii.get_fdata(caching=caching_param),
|
|
118
|
+
nib.orientations.axcodes2ornt(orientation),
|
|
119
|
+
)
|
|
120
|
+
images = np.flipud(np.rot90(imagesOr, k=1, axes=(0, 1)))
|
|
121
|
+
return images
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def apply_half_voxel_shift(
|
|
125
|
+
affine: npt.NDArray[np.float64], shift_z_axis: bool = False
|
|
126
|
+
) -> npt.NDArray[np.float64]:
|
|
127
|
+
"""Shift an affine by half a voxel in x/y (and optionally z). Deprecated, always raises.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
affine : ndarray
|
|
132
|
+
The affine to shift.
|
|
133
|
+
shift_z_axis : bool, optional
|
|
134
|
+
Whether to also shift the z axis. Defaults to False.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
ndarray
|
|
139
|
+
The shifted affine. Unreachable, since this function always raises.
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
DeprecationWarning
|
|
144
|
+
Always. Half voxel shifts are usually a symptom of a bad affine
|
|
145
|
+
definition; use dcm2nii to convert DICOMs and use its affine instead.
|
|
146
|
+
"""
|
|
147
|
+
raise DeprecationWarning(
|
|
148
|
+
"This function should not be used as half voxel shifts are usually bad and a "
|
|
149
|
+
"result of bad affine definition"
|
|
150
|
+
"Use dcm2nii to convert dicoms to nifits and use the resulting affine"
|
|
151
|
+
)
|
|
152
|
+
# Half voxel shift should only be in x and y directions
|
|
153
|
+
if shift_z_axis:
|
|
154
|
+
dims = 3
|
|
155
|
+
else:
|
|
156
|
+
dims = 2
|
|
157
|
+
|
|
158
|
+
shifted_affine = deepcopy(affine)
|
|
159
|
+
shift = np.diag(affine[:dims, :dims]) * 0.5
|
|
160
|
+
shifted_affine[:dims, 3] += shift
|
|
161
|
+
logger.debug(f"Applied half voxel shift of {shift} to affine.")
|
|
162
|
+
return shifted_affine
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resample_nifti(
|
|
166
|
+
source_nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
167
|
+
target_nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
168
|
+
**kwargs: Any,
|
|
169
|
+
) -> spatialimages.SpatialImage:
|
|
170
|
+
"""Resample `source_nii` into `target_nii`'s voxel grid via `nibabel.processing`.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
source_nii : spatialimages.SpatialImage or GESeries.NiiBase
|
|
175
|
+
The image to resample.
|
|
176
|
+
target_nii : spatialimages.SpatialImage or GESeries.NiiBase
|
|
177
|
+
The image whose grid to resample onto.
|
|
178
|
+
**kwargs
|
|
179
|
+
Overrides for `DEFAULT_RESAMPLE_PARAMS`, passed to
|
|
180
|
+
`nibabel.processing.resample_from_to`.
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
spatialimages.SpatialImage
|
|
185
|
+
The resampled image.
|
|
186
|
+
"""
|
|
187
|
+
if isinstance(source_nii, GESeries.NiiBase):
|
|
188
|
+
source_nii: spatialimages.SpatialImage = source_nii.nii
|
|
189
|
+
if isinstance(target_nii, GESeries.NiiBase):
|
|
190
|
+
target_nii: spatialimages.SpatialImage = target_nii.nii
|
|
191
|
+
# Resample data_nifti to base_nifti space
|
|
192
|
+
resample_kwargs = DEFAULT_RESAMPLE_PARAMS.copy()
|
|
193
|
+
resample_kwargs.update(kwargs)
|
|
194
|
+
logger.debug(f"Resampling with parameters: {resample_kwargs}")
|
|
195
|
+
|
|
196
|
+
resampled_nii: spatialimages.SpatialImage = processing.resample_from_to(
|
|
197
|
+
source_nii, target_nii, **resample_kwargs
|
|
198
|
+
)
|
|
199
|
+
return resampled_nii
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def resample_and_orient_nifti(
|
|
203
|
+
source_nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
204
|
+
target_nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
205
|
+
orientation: tuple[str, str, str] | None = DEFAULT_PARAMS["orientation"],
|
|
206
|
+
display_plane: DISPLAY_PLANES | None = None,
|
|
207
|
+
**kwargs: Any,
|
|
208
|
+
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
|
|
209
|
+
"""Resample `source_nii` onto `target_nii`'s grid, then orient both for display.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
source_nii : spatialimages.SpatialImage or GESeries.NiiBase
|
|
214
|
+
The image to resample.
|
|
215
|
+
target_nii : spatialimages.SpatialImage or GESeries.NiiBase
|
|
216
|
+
The image whose grid to resample onto.
|
|
217
|
+
orientation : tuple[str, str, str], optional
|
|
218
|
+
Target axis codes passed to `orient_nifti`. Defaults to
|
|
219
|
+
`DEFAULT_PARAMS["orientation"]`.
|
|
220
|
+
display_plane : {"axial", "coronal", "sagittal"}, optional
|
|
221
|
+
If given, overrides `orientation` with a preset for the named plane.
|
|
222
|
+
**kwargs
|
|
223
|
+
Additional keyword arguments passed to `resample_nifti`.
|
|
224
|
+
|
|
225
|
+
Returns
|
|
226
|
+
-------
|
|
227
|
+
tuple[ndarray, ndarray]
|
|
228
|
+
The oriented target image data and the oriented resampled data.
|
|
229
|
+
"""
|
|
230
|
+
if isinstance(source_nii, GESeries.NiiBase):
|
|
231
|
+
source_nii: spatialimages.SpatialImage = source_nii.nii
|
|
232
|
+
if isinstance(target_nii, GESeries.NiiBase):
|
|
233
|
+
target_nii: spatialimages.SpatialImage = target_nii.nii
|
|
234
|
+
# Resample base_nifti to target_nifti space
|
|
235
|
+
resampled_nii = resample_nifti(source_nii=source_nii, target_nii=target_nii, **kwargs)
|
|
236
|
+
# Orient the data
|
|
237
|
+
base_images = orient_nifti(target_nii, orientation, display_plane=display_plane)
|
|
238
|
+
data_images = orient_nifti(resampled_nii, orientation, display_plane=display_plane)
|
|
239
|
+
return base_images, data_images
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def downsample_to_coverage(
|
|
243
|
+
src_data: npt.NDArray | spatialimages.SpatialImage | GESeries.NiiBase,
|
|
244
|
+
tgt_data: spatialimages.SpatialImage | GESeries.NiiBase | None = None,
|
|
245
|
+
src_affine: npt.NDArray | None = None,
|
|
246
|
+
tgt_affine: npt.NDArray | None = None,
|
|
247
|
+
tgt_shape: tuple[int, int, int] | None = None,
|
|
248
|
+
super_sampling: bool = True,
|
|
249
|
+
sampling_order: int | list[int] | tuple[int, int, int] | None = None,
|
|
250
|
+
**kwargs,
|
|
251
|
+
) -> npt.NDArray[np.float32]:
|
|
252
|
+
"""Compute, per target voxel, the fraction of sub-samples that fall inside the source image.
|
|
253
|
+
|
|
254
|
+
For each target voxel, samples a supersampling grid of sub-voxel points
|
|
255
|
+
(or a single center point if `super_sampling` is False), maps them into
|
|
256
|
+
source voxel space via the affines, and averages how many land inside
|
|
257
|
+
the source image bounds.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
src_data : ndarray, spatialimages.SpatialImage, or GESeries.NiiBase
|
|
262
|
+
The source image data or image. If a plain array, `src_affine` must
|
|
263
|
+
be given.
|
|
264
|
+
tgt_data : spatialimages.SpatialImage or GESeries.NiiBase, optional
|
|
265
|
+
The target image, used to derive `tgt_affine`/`tgt_shape` if not
|
|
266
|
+
given directly.
|
|
267
|
+
src_affine : ndarray, optional
|
|
268
|
+
The source image's affine. Required if `src_data` is a plain array.
|
|
269
|
+
tgt_affine : ndarray, optional
|
|
270
|
+
The target image's affine. Required if `tgt_data` is not given.
|
|
271
|
+
tgt_shape : tuple[int, int, int], optional
|
|
272
|
+
The target image's shape. Required if `tgt_data` is not given.
|
|
273
|
+
super_sampling : bool, optional
|
|
274
|
+
Whether to sample a sub-voxel grid per target voxel (more accurate)
|
|
275
|
+
or a single center point. Defaults to True.
|
|
276
|
+
sampling_order : int, list[int], or tuple[int, int, int], optional
|
|
277
|
+
The supersampling grid size per axis. Defaults to a size derived
|
|
278
|
+
from the target-to-source voxel scale.
|
|
279
|
+
**kwargs
|
|
280
|
+
Unused, accepted for forward compatibility.
|
|
281
|
+
|
|
282
|
+
Returns
|
|
283
|
+
-------
|
|
284
|
+
ndarray
|
|
285
|
+
The coverage fraction for each target voxel, shape `tgt_shape`.
|
|
286
|
+
|
|
287
|
+
Raises
|
|
288
|
+
------
|
|
289
|
+
ValueError
|
|
290
|
+
If the source/target affines or target shape cannot be determined.
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(src_data, GESeries.NiiBase):
|
|
293
|
+
src_img = src_data.images()
|
|
294
|
+
src_affine = src_data.nii.affine
|
|
295
|
+
elif isinstance(src_data, spatialimages.SpatialImage):
|
|
296
|
+
src_img = src_data.get_fdata()
|
|
297
|
+
src_affine = src_data.affine
|
|
298
|
+
else:
|
|
299
|
+
src_img = src_data
|
|
300
|
+
|
|
301
|
+
if isinstance(tgt_data, GESeries.NiiBase):
|
|
302
|
+
tgt_affine = tgt_data.nii.affine
|
|
303
|
+
tgt_shape = tgt_data.images().shape
|
|
304
|
+
elif isinstance(tgt_data, spatialimages.SpatialImage):
|
|
305
|
+
tgt_affine = tgt_data.affine
|
|
306
|
+
tgt_shape = tgt_data.get_fdata().shape
|
|
307
|
+
|
|
308
|
+
if src_affine is None or tgt_affine is None or tgt_shape is None:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
"Source and target affines and target shape must be provided. Either pass "
|
|
311
|
+
"target data as Nifti/GESeries or provide affines and shape directly."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Generally we do not need to do a half voxel shift as our offsets center the samples
|
|
315
|
+
# within the voxels
|
|
316
|
+
# Double check by overlaying on the anatomical again
|
|
317
|
+
tgt2src_affine = np.linalg.inv(src_affine).dot(tgt_affine)
|
|
318
|
+
|
|
319
|
+
logger.debug(f"Source shape: {src_img.shape}, Target shape: {tgt_shape}")
|
|
320
|
+
logger.debug(f"Transformation from target to source affine:\n{tgt2src_affine}")
|
|
321
|
+
|
|
322
|
+
# Supersampling
|
|
323
|
+
|
|
324
|
+
if super_sampling:
|
|
325
|
+
if sampling_order is not None:
|
|
326
|
+
if isinstance(sampling_order, int):
|
|
327
|
+
s = [sampling_order] * 3
|
|
328
|
+
else:
|
|
329
|
+
s = list(sampling_order)
|
|
330
|
+
else:
|
|
331
|
+
s = np.ceil(np.diag(tgt2src_affine)[:3]).astype(int).tolist()
|
|
332
|
+
logger.debug(f"Using supersampling grid of size: {s}")
|
|
333
|
+
|
|
334
|
+
offset = np.multiply(s, [0, 0, -1])
|
|
335
|
+
logger.debug(f"Offset for sampling: {offset}")
|
|
336
|
+
# I am not entirely sure why we need to offset z direction by the sampling coefficient...
|
|
337
|
+
offsets = (np.indices(s).transpose(1, 2, 3, 0) + offset) / s
|
|
338
|
+
else:
|
|
339
|
+
logger.warning(
|
|
340
|
+
"No supersampling. This may lead to inaccurate coverage estimates, especially "
|
|
341
|
+
"for small voxels or large transformations."
|
|
342
|
+
)
|
|
343
|
+
offsets = np.array([[[[-0, -0, -0]]]]) # Does not seem to neet a offest..
|
|
344
|
+
logger.debug(f"No supersampling. Using single sample at offset: {offsets}")
|
|
345
|
+
|
|
346
|
+
# MAIN LOOP
|
|
347
|
+
|
|
348
|
+
coverage = np.zeros(tgt_shape, dtype=np.float32)
|
|
349
|
+
|
|
350
|
+
for i in range(tgt_shape[0]):
|
|
351
|
+
for j in range(tgt_shape[1]):
|
|
352
|
+
for k in range(tgt_shape[2]):
|
|
353
|
+
# --- Compute index of sub-samples within target voxel ---
|
|
354
|
+
# target index coordinate + offsets
|
|
355
|
+
tgt_idx = np.array([i, j, k])[None, None, None, :] + offsets
|
|
356
|
+
src_idx = affines.apply_affine(tgt2src_affine, tgt_idx)
|
|
357
|
+
|
|
358
|
+
# Nearest neighbor sample
|
|
359
|
+
xi = np.rint(src_idx[..., 0]).astype(int)
|
|
360
|
+
yi = np.rint(src_idx[..., 1]).astype(int)
|
|
361
|
+
zi = np.rint(src_idx[..., 2]).astype(int)
|
|
362
|
+
|
|
363
|
+
# Boundary mask
|
|
364
|
+
inside = (
|
|
365
|
+
(xi >= 0)
|
|
366
|
+
& (xi < src_img.shape[0])
|
|
367
|
+
& (yi >= 0)
|
|
368
|
+
& (yi < src_img.shape[1])
|
|
369
|
+
& (zi >= 0)
|
|
370
|
+
& (zi < src_img.shape[2])
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
samples = np.zeros_like(xi, dtype=np.float32)
|
|
374
|
+
samples[inside] = src_img[xi[inside], yi[inside], zi[inside]].astype(np.float32)
|
|
375
|
+
|
|
376
|
+
# Average over samples → coverage fraction
|
|
377
|
+
coverage[i, j, k] = samples.mean()
|
|
378
|
+
|
|
379
|
+
return coverage.astype(np.float32)
|