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.
@@ -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,7 @@
1
+ """Shared type aliases for simnibs-reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ Space = Literal["mni", "native"]
@@ -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"]