simnibs-reader 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.
- simnibs_reader/__init__.py +57 -0
- simnibs_reader/__later__/_0_anatomical_preparer.py +487 -0
- simnibs_reader/__later__/_field.py +26 -0
- simnibs_reader/__later__/_mesh.py +29 -0
- simnibs_reader/_typing.py +7 -0
- simnibs_reader/core/ToIntegrate_loaders-mask.py +79 -0
- simnibs_reader/core/__init__.py +7 -0
- simnibs_reader/core/_base.py +104 -0
- simnibs_reader/core/optimization.py +119 -0
- simnibs_reader/core/segmentation.py +153 -0
- simnibs_reader/core/simulation.py +155 -0
- simnibs_reader/efield/__init__.py +14 -0
- simnibs_reader/efield/accessor.py +96 -0
- simnibs_reader/efield/postprocess.py +76 -0
- simnibs_reader/efield/roi.py +432 -0
- simnibs_reader/efield/stats.py +93 -0
- simnibs_reader/io/__init__.py +6 -0
- simnibs_reader/io/export.py +88 -0
- simnibs_reader/io/nifti.py +140 -0
- simnibs_reader/tests/__init__.py +1 -0
- simnibs_reader/tests/conftest.py +6 -0
- simnibs_reader/tests/test_efield.py +3 -0
- simnibs_reader/tests/test_segmentation.py +3 -0
- simnibs_reader/tests/test_simulation.py +3 -0
- simnibs_reader-0.1.0.dist-info/METADATA +85 -0
- simnibs_reader-0.1.0.dist-info/RECORD +29 -0
- simnibs_reader-0.1.0.dist-info/WHEEL +5 -0
- simnibs_reader-0.1.0.dist-info/licenses/LICENSE +21 -0
- simnibs_reader-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
simnibs-reader
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
Lightweight reader for SimNIBS output directories.
|
|
6
|
+
|
|
7
|
+
Quick start
|
|
8
|
+
-----------
|
|
9
|
+
>>> import simnibs_reader as snr
|
|
10
|
+
>>> sim = snr.simulation("path/to/simulation_folder")
|
|
11
|
+
>>> sim.magnE.data # lazy-loaded NIfTI array
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .core.simulation import SimulationResult
|
|
17
|
+
from .core.segmentation import SegmentationResult
|
|
18
|
+
from .core.optimization import OptimizationResult
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Public factory functions ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def simulation(path: str) -> SimulationResult:
|
|
27
|
+
"""Load a SimNIBS simulation output folder.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
path : str or Path
|
|
32
|
+
Path to the simulation directory (contains ``mni_volumes/``,
|
|
33
|
+
``subject_volumes/``, ``fields_summary.txt``, etc.).
|
|
34
|
+
"""
|
|
35
|
+
return SimulationResult(path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def segmentation(path: str) -> SegmentationResult:
|
|
39
|
+
"""Load a SimNIBS head-model folder (``m2m_<subID>``).
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
path : str or Path
|
|
44
|
+
Path to the ``m2m_*`` directory produced by ``charm`` / ``headreco``.
|
|
45
|
+
"""
|
|
46
|
+
return SegmentationResult(path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def optimization(path: str) -> OptimizationResult:
|
|
50
|
+
"""Load a SimNIBS optimization / leadfield folder.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
path : str or Path
|
|
55
|
+
Path to the optimization output directory.
|
|
56
|
+
"""
|
|
57
|
+
return OptimizationResult(path)
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Target generation step — creates spherical ROI masks in MNI space.
|
|
3
|
+
|
|
4
|
+
Usage (CLI):
|
|
5
|
+
python _0_anatomical_preparer.py --config config.yaml --output /path/to/mni_target
|
|
6
|
+
|
|
7
|
+
Usage (API):
|
|
8
|
+
gen = AnatomicalPreparer(radius_mm=10.0)
|
|
9
|
+
gen.setup_mni_rois(
|
|
10
|
+
rois={"fef": {"method": "sphere", "coords": [28, -8, 54]}},
|
|
11
|
+
output_dir=Path("mni_target"),
|
|
12
|
+
)
|
|
13
|
+
gen.mask_imgs # dict[str, nib.Nifti1Image]
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
import nibabel as nib
|
|
23
|
+
import numpy as np
|
|
24
|
+
from nilearn import datasets, image
|
|
25
|
+
from nilearn.image import new_img_like
|
|
26
|
+
from scipy.ndimage import binary_fill_holes
|
|
27
|
+
|
|
28
|
+
from .._pipeline_io import (
|
|
29
|
+
save_nifti,
|
|
30
|
+
save_ants_image,
|
|
31
|
+
check_output,
|
|
32
|
+
get_t1_conform,
|
|
33
|
+
get_brainmask,
|
|
34
|
+
get_mni_tissues,
|
|
35
|
+
)
|
|
36
|
+
from .._logging import get_logger
|
|
37
|
+
|
|
38
|
+
logger = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AnatomicalPreparer:
|
|
42
|
+
"""
|
|
43
|
+
Generates spherical ROI masks in MNI space from a dict of MNI coordinates.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
reference_img_path : Path or None
|
|
48
|
+
Path to a custom MNI template. If None, uses nilearn's MNI152 1 mm.
|
|
49
|
+
radius_mm : float
|
|
50
|
+
Sphere radius in millimetres (default 10.0).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
reference_img_path: Optional[Path] = None,
|
|
56
|
+
radius_mm: float = 10.0,
|
|
57
|
+
mni_brain_mask_path: Optional[Path] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.radius_mm = radius_mm
|
|
60
|
+
self.mask_imgs: Dict[str, nib.Nifti1Image] = {}
|
|
61
|
+
self._mni_brain_mask_path = (
|
|
62
|
+
Path(mni_brain_mask_path) if mni_brain_mask_path else None
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if reference_img_path is not None:
|
|
66
|
+
logger.info(f"Loading reference image: {reference_img_path}")
|
|
67
|
+
self._template = nib.load(str(reference_img_path))
|
|
68
|
+
self._template_path = Path(reference_img_path)
|
|
69
|
+
else:
|
|
70
|
+
logger.info("Loading standard MNI152 1 mm template")
|
|
71
|
+
self._template = datasets.load_mni152_template(resolution=1)
|
|
72
|
+
self._template_path = None
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Public API
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def setup_mni_rois(
|
|
79
|
+
self,
|
|
80
|
+
rois: Dict[str, dict],
|
|
81
|
+
output_dir: Path,
|
|
82
|
+
if_exists: str = "overwrite",
|
|
83
|
+
) -> "AnatomicalPreparer":
|
|
84
|
+
"""
|
|
85
|
+
Create and save one binary mask per ROI in MNI space.
|
|
86
|
+
|
|
87
|
+
Subject-independent. Call this once before the subject loop.
|
|
88
|
+
Dispatches to :meth:`_create_sphere_mask` or :meth:`_create_parcel_mask`
|
|
89
|
+
based on the ``method`` key of each ROI definition.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
rois : dict
|
|
94
|
+
``{roi_name: roi_def}`` where ``roi_def`` is::
|
|
95
|
+
|
|
96
|
+
# sphere (MNI coordinates)
|
|
97
|
+
{"method": "sphere", "coords": [x, y, z]}
|
|
98
|
+
|
|
99
|
+
# atlas parcel
|
|
100
|
+
{"method": "atlas", "atlas": "harvard-oxford",
|
|
101
|
+
"regions": "Frontal Eye Fields"} # str or list[str]
|
|
102
|
+
|
|
103
|
+
output_dir : Path
|
|
104
|
+
Directory where ``{roi_name}_mask_space-mni.nii.gz`` files will be written.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
self
|
|
109
|
+
``self.mask_imgs`` is populated with the generated NIfTI images.
|
|
110
|
+
``self.mni_output_dir`` is set for use in subsequent ``run()`` calls.
|
|
111
|
+
"""
|
|
112
|
+
output_dir = Path(output_dir)
|
|
113
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
self.mni_output_dir = output_dir
|
|
115
|
+
logger.info(f"Generating {len(rois)} ROI mask(s) → {output_dir}")
|
|
116
|
+
|
|
117
|
+
for roi_name, roi_def in rois.items():
|
|
118
|
+
method = roi_def.method
|
|
119
|
+
if method == "sphere":
|
|
120
|
+
mask_img = self._create_sphere_mask(
|
|
121
|
+
self._template, roi_def.coords, self.radius_mm
|
|
122
|
+
)
|
|
123
|
+
elif method == "atlas":
|
|
124
|
+
mask_img = self._create_parcel_mask(
|
|
125
|
+
self._template, roi_def.atlas, roi_def.regions
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"ROI '{roi_name}': unknown method '{method}'. "
|
|
130
|
+
"Expected 'sphere' or 'atlas'."
|
|
131
|
+
)
|
|
132
|
+
out_path = output_dir / f"{roi_name}_mask_space-mni.nii.gz"
|
|
133
|
+
save_nifti(mask_img, out_path, if_exists=if_exists)
|
|
134
|
+
self.mask_imgs[roi_name] = mask_img
|
|
135
|
+
logger.info(f" ✓ {roi_name} [{method}]: {out_path.name}")
|
|
136
|
+
|
|
137
|
+
logger.info(f"{len(self.mask_imgs)} mask(s) saved to {output_dir}")
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def run(
|
|
141
|
+
self,
|
|
142
|
+
m2m_dir: Path,
|
|
143
|
+
output_dir: Path,
|
|
144
|
+
if_exists: str = "overwrite",
|
|
145
|
+
) -> "AnatomicalPreparer":
|
|
146
|
+
"""
|
|
147
|
+
Subject-level processing: skull-strip the T1.
|
|
148
|
+
|
|
149
|
+
Call once per subject inside the subject loop, consistent with
|
|
150
|
+
``Preprocessor.run()`` and ``FeatureExtractor.run()``.
|
|
151
|
+
Uses :func:`_io.get_t1_conform` and :func:`_io.get_brainmask` to
|
|
152
|
+
locate the correct files inside ``m2m_dir``.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
m2m_dir : Path
|
|
157
|
+
Path to the SimNIBS ``m2m_<subject>`` directory.
|
|
158
|
+
output_dir : Path
|
|
159
|
+
Directory where subject-space outputs will be written.
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
self
|
|
164
|
+
``self.stripped_t1_path`` is set if skull-stripping was performed.
|
|
165
|
+
"""
|
|
166
|
+
m2m_dir = Path(m2m_dir)
|
|
167
|
+
output_dir = Path(output_dir)
|
|
168
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
|
|
170
|
+
self.stripped_t1_path: Optional[Path] = None
|
|
171
|
+
self.mni_stripped_t1_path: Optional[Path] = None
|
|
172
|
+
|
|
173
|
+
# ── Subject-space skull-strip ──────────────────────────────────
|
|
174
|
+
# T1 source : m2m_<subject>/segmentation/T1_bias_corrected.nii.gz
|
|
175
|
+
# Mask : m2m_<subject>/label_prep/tissue_labeling_upsampled.nii.gz
|
|
176
|
+
# Output : subject_target/T1_subject_brain.nii.gz
|
|
177
|
+
try:
|
|
178
|
+
t1_path = get_t1_conform(m2m_dir)
|
|
179
|
+
mask_path = get_brainmask(m2m_dir)
|
|
180
|
+
self.stripped_t1_path = self._skull_strip(
|
|
181
|
+
t1_path,
|
|
182
|
+
mask_path,
|
|
183
|
+
out_path=output_dir / "T1_subject_brain.nii.gz",
|
|
184
|
+
if_exists=if_exists,
|
|
185
|
+
)
|
|
186
|
+
except FileNotFoundError as e:
|
|
187
|
+
logger.warning(f"Skull-stripping (subject) skipped — {e}")
|
|
188
|
+
|
|
189
|
+
# ── MNI-space skull-strip ───────────────────────────────────────
|
|
190
|
+
# T1 source : templates/MNI152_T1_1mm.nii.gz (self._template_path)
|
|
191
|
+
# Masque : m2m_<subject>/toMNI/final_tissues_MNI.nii.gz
|
|
192
|
+
# Output : subject_target/T1_MNI_brain.nii.gz
|
|
193
|
+
# → same space as the e-fields (*_scalar_MNI_magnE.nii.gz), no resampling.
|
|
194
|
+
try:
|
|
195
|
+
mni_tissues_path = get_mni_tissues(m2m_dir)
|
|
196
|
+
self.mni_stripped_t1_path = self._skull_strip(
|
|
197
|
+
self._template_path,
|
|
198
|
+
mni_tissues_path,
|
|
199
|
+
out_path=output_dir / "T1_MNI_brain.nii.gz",
|
|
200
|
+
if_exists=if_exists,
|
|
201
|
+
)
|
|
202
|
+
except FileNotFoundError as e:
|
|
203
|
+
logger.warning(f"Skull-stripping (MNI) skipped — {e}")
|
|
204
|
+
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
# Internal helpers
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _create_sphere_mask(
|
|
213
|
+
template_img: nib.Nifti1Image,
|
|
214
|
+
mni_coords: List[float],
|
|
215
|
+
radius_mm: float,
|
|
216
|
+
) -> nib.Nifti1Image:
|
|
217
|
+
"""Return a binary NIfTI sphere mask centred on *mni_coords*."""
|
|
218
|
+
affine = template_img.affine
|
|
219
|
+
data = np.zeros(template_img.shape, dtype=np.uint8)
|
|
220
|
+
|
|
221
|
+
# MNI → voxel coordinates
|
|
222
|
+
mni_h = np.append(mni_coords, 1)
|
|
223
|
+
vox = (np.linalg.inv(affine) @ mni_h)[:3].astype(int)
|
|
224
|
+
|
|
225
|
+
# Voxel-space radius (isotropic approximation)
|
|
226
|
+
voxel_size = np.abs(np.diag(affine)[:3]).mean()
|
|
227
|
+
radius_vox = radius_mm / voxel_size
|
|
228
|
+
|
|
229
|
+
shape = template_img.shape
|
|
230
|
+
x, y, z = np.ogrid[: shape[0], : shape[1], : shape[2]]
|
|
231
|
+
dist_sq = (x - vox[0]) ** 2 + (y - vox[1]) ** 2 + (z - vox[2]) ** 2
|
|
232
|
+
data[dist_sq <= radius_vox**2] = 1
|
|
233
|
+
|
|
234
|
+
return new_img_like(template_img, data, affine=affine)
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _create_parcel_mask(
|
|
238
|
+
template_img: nib.Nifti1Image,
|
|
239
|
+
atlas_name: str,
|
|
240
|
+
region_names: "str | List[str]",
|
|
241
|
+
) -> nib.Nifti1Image:
|
|
242
|
+
"""Return a binary NIfTI mask from one or more atlas parcels.
|
|
243
|
+
|
|
244
|
+
Resampled to ``template_img`` space if needed — mirrors
|
|
245
|
+
:meth:`_create_sphere_mask` in signature and return type.
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
template_img : nib.Nifti1Image
|
|
250
|
+
Reference image that defines the output voxel grid.
|
|
251
|
+
atlas_name : str
|
|
252
|
+
One of ``'harvard-oxford'``, ``'aal'``, ``'destrieux'``.
|
|
253
|
+
region_names : str or list of str
|
|
254
|
+
One or more atlas region labels whose union forms the mask.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
nib.Nifti1Image
|
|
259
|
+
Binary mask in ``template_img`` space.
|
|
260
|
+
"""
|
|
261
|
+
import urllib3
|
|
262
|
+
from requests.adapters import HTTPAdapter
|
|
263
|
+
from nilearn import datasets as nl_datasets
|
|
264
|
+
|
|
265
|
+
if isinstance(region_names, str):
|
|
266
|
+
region_names = [region_names]
|
|
267
|
+
|
|
268
|
+
_FETCHERS = {
|
|
269
|
+
"harvard-oxford": lambda: nl_datasets.fetch_atlas_harvard_oxford(
|
|
270
|
+
"cort-maxprob-thr25-1mm"
|
|
271
|
+
),
|
|
272
|
+
"aal": lambda: nl_datasets.fetch_atlas_aal(),
|
|
273
|
+
"destrieux": lambda: nl_datasets.fetch_atlas_destrieux_2009(),
|
|
274
|
+
}
|
|
275
|
+
if atlas_name not in _FETCHERS:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"Atlas '{atlas_name}' not supported. Available: {list(_FETCHERS)}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Disable SSL verification for atlas downloads (macOS cert issue).
|
|
281
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
282
|
+
_orig_send = HTTPAdapter.send
|
|
283
|
+
HTTPAdapter.send = lambda self, *a, **kw: _orig_send(
|
|
284
|
+
self, *a, **{**kw, "verify": False}
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
atlas_data = _FETCHERS[atlas_name]()
|
|
288
|
+
finally:
|
|
289
|
+
HTTPAdapter.send = _orig_send
|
|
290
|
+
maps = atlas_data.maps
|
|
291
|
+
atlas_img = maps if isinstance(maps, nib.Nifti1Image) else nib.load(maps)
|
|
292
|
+
raw_labels = list(atlas_data.labels)
|
|
293
|
+
|
|
294
|
+
# AAL provides a separate `indices` list; other atlases use positional indices.
|
|
295
|
+
if hasattr(atlas_data, "indices"):
|
|
296
|
+
label_map: Dict[str, int] = {
|
|
297
|
+
str(name): int(idx) for name, idx in zip(raw_labels, atlas_data.indices)
|
|
298
|
+
}
|
|
299
|
+
else:
|
|
300
|
+
label_map = {str(name): i for i, name in enumerate(raw_labels)}
|
|
301
|
+
|
|
302
|
+
atlas_array = atlas_img.get_fdata()
|
|
303
|
+
mask_data = np.zeros(atlas_array.shape, dtype=np.uint8)
|
|
304
|
+
|
|
305
|
+
for region_name in region_names:
|
|
306
|
+
if region_name not in label_map:
|
|
307
|
+
matches = [k for k in label_map if k.lower() == region_name.lower()]
|
|
308
|
+
if not matches:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
f"Region '{region_name}' not found in atlas '{atlas_name}'. "
|
|
311
|
+
f"First available labels: {list(label_map)[:10]}"
|
|
312
|
+
)
|
|
313
|
+
region_name = matches[0]
|
|
314
|
+
mask_data[np.round(atlas_array).astype(int) == label_map[region_name]] = 1
|
|
315
|
+
|
|
316
|
+
mask_img = nib.Nifti1Image(mask_data, atlas_img.affine)
|
|
317
|
+
|
|
318
|
+
# Resample to pipeline template if voxel grids differ
|
|
319
|
+
if atlas_img.shape[:3] != template_img.shape[:3] or not np.allclose(
|
|
320
|
+
atlas_img.affine, template_img.affine
|
|
321
|
+
):
|
|
322
|
+
mask_img = image.resample_to_img(
|
|
323
|
+
mask_img, template_img, interpolation="nearest"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return mask_img
|
|
327
|
+
|
|
328
|
+
def create_subject_roi_from_mni(
|
|
329
|
+
self,
|
|
330
|
+
m2m_dir: Path,
|
|
331
|
+
output_dir: Path,
|
|
332
|
+
if_exists: str = "overwrite",
|
|
333
|
+
) -> Dict[str, Path]:
|
|
334
|
+
"""
|
|
335
|
+
Warp MNI ROI masks to subject space using ANTsPy.
|
|
336
|
+
|
|
337
|
+
Parameters
|
|
338
|
+
----------
|
|
339
|
+
m2m_dir : Path
|
|
340
|
+
Path to the SimNIBS m2m_<subject> directory.
|
|
341
|
+
Must contain toMNI/MNI2Conform_nonl.nii.gz.
|
|
342
|
+
output_dir : Path
|
|
343
|
+
Directory where subject-space ROI masks will be written
|
|
344
|
+
(e.g., subject_target/).
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
Dict[str, Path]
|
|
349
|
+
{roi_name: path_to_subject_space_mask}
|
|
350
|
+
|
|
351
|
+
Raises
|
|
352
|
+
------
|
|
353
|
+
FileNotFoundError
|
|
354
|
+
If the warp field or MNI masks are missing.
|
|
355
|
+
"""
|
|
356
|
+
import ants
|
|
357
|
+
|
|
358
|
+
m2m_dir = Path(m2m_dir)
|
|
359
|
+
output_dir = Path(output_dir)
|
|
360
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
|
|
362
|
+
# ── Warp field (MNI → subject space) ──────────────────────────
|
|
363
|
+
warp_mni2conform = m2m_dir / "toMNI" / "MNI2Conform_nonl.nii.gz"
|
|
364
|
+
if not warp_mni2conform.exists():
|
|
365
|
+
raise FileNotFoundError(
|
|
366
|
+
f"MNI→Subject warp field not found: {warp_mni2conform}"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# ── Reference image (subject-space T1) ────────────────────────
|
|
370
|
+
t1_subject_path = get_t1_conform(m2m_dir)
|
|
371
|
+
fixed = ants.image_read(str(t1_subject_path))
|
|
372
|
+
|
|
373
|
+
# Load MNI masks from disk in all cases to keep behavior deterministic.
|
|
374
|
+
simnibs_output_dir = m2m_dir.parent.parent
|
|
375
|
+
mni_output_dir = getattr(
|
|
376
|
+
self, "mni_output_dir", simnibs_output_dir / "mni_target"
|
|
377
|
+
)
|
|
378
|
+
self.mni_output_dir = mni_output_dir
|
|
379
|
+
mni_mask_paths = sorted(mni_output_dir.glob("*_mask_space-mni.nii.gz"))
|
|
380
|
+
if not mni_mask_paths:
|
|
381
|
+
raise FileNotFoundError(f"No MNI ROI masks found in: {mni_output_dir}")
|
|
382
|
+
self.mask_imgs = {}
|
|
383
|
+
for p in mni_mask_paths:
|
|
384
|
+
roi_name = p.name.replace("_mask_space-mni.nii.gz", "")
|
|
385
|
+
self.mask_imgs[roi_name] = nib.load(str(p))
|
|
386
|
+
|
|
387
|
+
subject_roi_paths: Dict[str, Path] = {}
|
|
388
|
+
|
|
389
|
+
# ── Warp each MNI ROI mask to subject space ────────────────────
|
|
390
|
+
for roi_name, mni_mask_img in self.mask_imgs.items():
|
|
391
|
+
logger.info(f"Warping {roi_name} from MNI to subject space...")
|
|
392
|
+
|
|
393
|
+
# Ensure MNI mask is saved on disk (ANTsPy reads from file)
|
|
394
|
+
mni_mask_path = self.mni_output_dir / f"{roi_name}_mask_space-mni.nii.gz"
|
|
395
|
+
if not mni_mask_path.exists():
|
|
396
|
+
save_nifti(mni_mask_img, mni_mask_path)
|
|
397
|
+
|
|
398
|
+
moving = ants.image_read(str(mni_mask_path))
|
|
399
|
+
|
|
400
|
+
warped = ants.apply_transforms(
|
|
401
|
+
fixed=fixed,
|
|
402
|
+
moving=moving,
|
|
403
|
+
transformlist=[str(warp_mni2conform)],
|
|
404
|
+
interpolator="nearestNeighbor", # binary mask
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
subject_mask_path = output_dir / f"{roi_name}_mask_space-native.nii.gz"
|
|
408
|
+
if check_output(subject_mask_path, if_exists):
|
|
409
|
+
save_ants_image(warped, subject_mask_path)
|
|
410
|
+
logger.info(f" ✓ {roi_name} warped → {subject_mask_path.name}")
|
|
411
|
+
else:
|
|
412
|
+
logger.info(f" skip {roi_name} (exists): {subject_mask_path.name}")
|
|
413
|
+
subject_roi_paths[roi_name] = subject_mask_path
|
|
414
|
+
|
|
415
|
+
return subject_roi_paths
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _skull_strip(
|
|
419
|
+
t1_path: Path,
|
|
420
|
+
mask_path: Path,
|
|
421
|
+
out_path: Optional[Path] = None,
|
|
422
|
+
if_exists: str = "overwrite",
|
|
423
|
+
) -> Path:
|
|
424
|
+
"""
|
|
425
|
+
Apply a brain mask to a T1 image and save the result.
|
|
426
|
+
|
|
427
|
+
Works for both subject-space and MNI-space skull-stripping — the
|
|
428
|
+
only difference is which T1 and mask files are passed.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
t1_path : Path
|
|
433
|
+
Path to the T1 NIfTI image (or a loaded template path).
|
|
434
|
+
mask_path : Path
|
|
435
|
+
Path to the tissue-labeling NIfTI (labels 1=WM, 2=GM …).
|
|
436
|
+
out_path : Path or None
|
|
437
|
+
Explicit output path. If None, the result is saved alongside
|
|
438
|
+
the T1 as ``<T1stem>_brain.nii.gz``.
|
|
439
|
+
|
|
440
|
+
Returns
|
|
441
|
+
-------
|
|
442
|
+
Path
|
|
443
|
+
Path to the saved skull-stripped image.
|
|
444
|
+
"""
|
|
445
|
+
t1_path = Path(t1_path)
|
|
446
|
+
mask_path = Path(mask_path)
|
|
447
|
+
|
|
448
|
+
t1_img = nib.load(str(t1_path))
|
|
449
|
+
mask_img = nib.load(str(mask_path))
|
|
450
|
+
|
|
451
|
+
# Resample mask to T1 space if needed
|
|
452
|
+
if (
|
|
453
|
+
not np.allclose(mask_img.affine, t1_img.affine)
|
|
454
|
+
or mask_img.shape != t1_img.shape
|
|
455
|
+
):
|
|
456
|
+
mask_img = image.resample_to_img(mask_img, t1_img, interpolation="nearest")
|
|
457
|
+
|
|
458
|
+
mask_raw = np.asarray(mask_img.dataobj)
|
|
459
|
+
mask_labels = np.rint(np.squeeze(mask_raw)).astype(np.int16)
|
|
460
|
+
|
|
461
|
+
# SimNIBS tissue labeling convention: 1=WM, 2=GM, 3=CSF.
|
|
462
|
+
# Use only WM+GM (1, 2) — excluding CSF removes the subarachnoid
|
|
463
|
+
# halo around the cortex. binary_fill_holes restores the ventricles.
|
|
464
|
+
if np.all(np.isin(np.unique(mask_labels), [0, 1])):
|
|
465
|
+
brain_vox = mask_labels > 0
|
|
466
|
+
elif np.any(np.isin(mask_labels, [1, 2])):
|
|
467
|
+
brain_vox = np.isin(mask_labels, [1, 2])
|
|
468
|
+
else:
|
|
469
|
+
logger.warning(
|
|
470
|
+
"Brain mask: no WM/GM labels (1/2) found; falling back to >0."
|
|
471
|
+
)
|
|
472
|
+
brain_vox = mask_labels > 0
|
|
473
|
+
|
|
474
|
+
mask_data = binary_fill_holes(brain_vox).astype(t1_img.get_data_dtype())
|
|
475
|
+
|
|
476
|
+
stripped_data = np.squeeze(np.asarray(t1_img.dataobj)) * mask_data
|
|
477
|
+
stripped_img = nib.Nifti1Image(stripped_data, t1_img.affine, t1_img.header)
|
|
478
|
+
|
|
479
|
+
if out_path is None:
|
|
480
|
+
stem = t1_path.name.replace(".nii.gz", "").replace(".nii", "")
|
|
481
|
+
out_path = t1_path.parent / f"{stem}_brain.nii.gz"
|
|
482
|
+
out_path = Path(out_path)
|
|
483
|
+
save_nifti(stripped_img, out_path, if_exists=if_exists)
|
|
484
|
+
logger.info(f"Skull-stripped T1 saved → {out_path}")
|
|
485
|
+
return out_path
|
|
486
|
+
|
|
487
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
class FieldExtractor:
|
|
5
|
+
def __init__(self, mesh: MeshAccessor):
|
|
6
|
+
self._mesh = mesh
|
|
7
|
+
|
|
8
|
+
def get(self, field: str, tissue: int | str | None = None) -> np.ndarray:
|
|
9
|
+
"""
|
|
10
|
+
Extrait un champ vectoriel ou scalaire, optionnellement
|
|
11
|
+
restreint à un tissu.
|
|
12
|
+
field: 'E', 'normE', 'J', 'normJ', 'v', ...
|
|
13
|
+
"""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def to_dataframe(self, fields: list[str], tissue=None) -> pd.DataFrame:
|
|
17
|
+
"""Export pratique pour analyse statistique."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def percentile(self, field: str, p: float, tissue=None) -> float:
|
|
21
|
+
vals = self.get(field, tissue)
|
|
22
|
+
return np.percentile(vals, p)
|
|
23
|
+
|
|
24
|
+
def focality(self, field: str, threshold_pct: float = 75) -> float:
|
|
25
|
+
"""Volume de tissu au-dessus du seuil (indicateur de focalité)."""
|
|
26
|
+
...
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import meshio
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
class MeshAccessor:
|
|
5
|
+
TISSUE_TAGS = {
|
|
6
|
+
1: "white_matter",
|
|
7
|
+
2: "gray_matter",
|
|
8
|
+
3: "csf",
|
|
9
|
+
4: "skull",
|
|
10
|
+
5: "scalp",
|
|
11
|
+
6: "eye",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def __init__(self, raw_mesh):
|
|
15
|
+
self._raw = raw_mesh # objet meshio.Mesh ou simnibs.Msh
|
|
16
|
+
|
|
17
|
+
def get_tissue(self, label: int | str) -> "MeshAccessor":
|
|
18
|
+
"""Retourne un sous-mesh filtré par tissu."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def nodes(self) -> np.ndarray: # (N, 3)
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def elements(self) -> np.ndarray: # (M, 4) pour tetra
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def element_data(self) -> dict[str, np.ndarray]:
|
|
28
|
+
"""Tous les champs disponibles (E, normE, J...)."""
|
|
29
|
+
...
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import nibabel as nib
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# ── Dataset loaders ────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
class SynthStrokeDataset:
|
|
9
|
+
"""
|
|
10
|
+
Reads SynthStroke outputs from each subject folder.
|
|
11
|
+
|
|
12
|
+
space="native" : *_lesion.nii.gz + *_reslice.nii.gz
|
|
13
|
+
space="mni" : *_lesion_mni.nii.gz + *_reslice_mni.nii.gz
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, root: Path, space: str = "native"):
|
|
17
|
+
self.root = root
|
|
18
|
+
self.space = space
|
|
19
|
+
self.subjects = sorted(d.name for d in root.iterdir() if d.is_dir())
|
|
20
|
+
|
|
21
|
+
def get(self, subject: str) -> dict | None:
|
|
22
|
+
subj_dir = self.root / subject
|
|
23
|
+
if self.space == "mni":
|
|
24
|
+
masks = sorted(subj_dir.glob("*_lesion_mni.nii.gz"))
|
|
25
|
+
t1s = sorted(subj_dir.glob("*_reslice_mni.nii.gz"))
|
|
26
|
+
else:
|
|
27
|
+
# native: exclude *_mni files to avoid ambiguity
|
|
28
|
+
masks = [p for p in sorted(subj_dir.glob("*_lesion.nii.gz"))
|
|
29
|
+
if "_mni" not in p.name]
|
|
30
|
+
t1s = [p for p in sorted(subj_dir.glob("*_reslice.nii.gz"))
|
|
31
|
+
if "_mni" not in p.name]
|
|
32
|
+
if not masks or not t1s:
|
|
33
|
+
return None
|
|
34
|
+
return {
|
|
35
|
+
"subject": subject,
|
|
36
|
+
"mask_ss": nib.load(masks[0]),
|
|
37
|
+
"t1_ss": nib.load(t1s[0]),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OriginalDataset:
|
|
42
|
+
"""
|
|
43
|
+
Reads original lesion masks from the clean folder.
|
|
44
|
+
|
|
45
|
+
space="native" : <subject>/Espace_natif/lesion.nii[.gz]
|
|
46
|
+
space="mni" : <subject>/Espace_MNI/lesion_mni.nii.gz
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, root: Path, space: str = "native"):
|
|
50
|
+
self.root = root
|
|
51
|
+
self.space = space
|
|
52
|
+
self.subjects = sorted(d.name for d in root.iterdir() if d.is_dir())
|
|
53
|
+
|
|
54
|
+
def get(self, subject: str) -> dict | None:
|
|
55
|
+
if self.space == "mni":
|
|
56
|
+
mask_path = self.root / subject / "Lesion_normalisee" / "lesion.nii.gz"
|
|
57
|
+
if not mask_path.exists():
|
|
58
|
+
# try uncompressed
|
|
59
|
+
mask_path = self.root / subject / "Lesion_normalisee" / "lesion.nii"
|
|
60
|
+
if not mask_path.exists():
|
|
61
|
+
return None
|
|
62
|
+
return {
|
|
63
|
+
"subject": subject,
|
|
64
|
+
"mask_orig": nib.load(mask_path),
|
|
65
|
+
}
|
|
66
|
+
else:
|
|
67
|
+
native_dir = self.root / subject / "Espace_natif"
|
|
68
|
+
if not native_dir.exists():
|
|
69
|
+
return None
|
|
70
|
+
candidates = sorted(native_dir.glob("lesion.nii*"))
|
|
71
|
+
if not candidates:
|
|
72
|
+
candidates = sorted(native_dir.glob("*lesion*.nii*"))
|
|
73
|
+
if not candidates:
|
|
74
|
+
return None
|
|
75
|
+
return {
|
|
76
|
+
"subject": subject,
|
|
77
|
+
"mask_orig": nib.load(candidates[0]),
|
|
78
|
+
}
|
|
79
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Core result classes for SimNIBS output directories."""
|
|
2
|
+
|
|
3
|
+
from .simulation import SimulationResult
|
|
4
|
+
from .segmentation import SegmentationResult
|
|
5
|
+
from .optimization import OptimizationResult
|
|
6
|
+
|
|
7
|
+
__all__ = ["SimulationResult", "SegmentationResult", "OptimizationResult"]
|