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.
Files changed (41) hide show
  1. mnutils/GEExam.py +495 -0
  2. mnutils/GESeries.py +1578 -0
  3. mnutils/__init__.py +54 -0
  4. mnutils/_scripts.py +177 -0
  5. mnutils/fitting/AMARES.py +831 -0
  6. mnutils/fitting/__init__.py +5 -0
  7. mnutils/matlab/__init__.py +13 -0
  8. mnutils/matlab/setup_matlab.py +116 -0
  9. mnutils/plotting/__init__.py +38 -0
  10. mnutils/plotting/_widgets/__init__.py +5 -0
  11. mnutils/plotting/_widgets/_html.py +34 -0
  12. mnutils/plotting/_widgets/_shared/__init__.py +50 -0
  13. mnutils/plotting/_widgets/_shared/dom.js +124 -0
  14. mnutils/plotting/_widgets/_shared/theme.css +140 -0
  15. mnutils/plotting/_widgets/image_grid.css +202 -0
  16. mnutils/plotting/_widgets/image_grid.js +212 -0
  17. mnutils/plotting/_widgets/image_grid.py +86 -0
  18. mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
  19. mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
  20. mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
  21. mnutils/plotting/_widgets/slice_viewer.css +38 -0
  22. mnutils/plotting/_widgets/slice_viewer.js +41 -0
  23. mnutils/plotting/_widgets/slice_viewer.py +35 -0
  24. mnutils/plotting/images.py +1399 -0
  25. mnutils/plotting/saving.py +138 -0
  26. mnutils/plotting/spectra.py +357 -0
  27. mnutils/py.typed +0 -0
  28. mnutils/testing/__init__.py +3 -0
  29. mnutils/testing/_spectra.py +338 -0
  30. mnutils/testing/synthetic.py +373 -0
  31. mnutils/utils/__init__.py +25 -0
  32. mnutils/utils/data_loaders.py +218 -0
  33. mnutils/utils/file_helpers.py +708 -0
  34. mnutils/utils/images.py +202 -0
  35. mnutils/utils/nifti.py +379 -0
  36. mnutils/utils/spectra.py +149 -0
  37. mnutils-1.2.0.dist-info/METADATA +161 -0
  38. mnutils-1.2.0.dist-info/RECORD +41 -0
  39. mnutils-1.2.0.dist-info/WHEEL +4 -0
  40. mnutils-1.2.0.dist-info/entry_points.txt +7 -0
  41. mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
@@ -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)