simcortexpp 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.
Files changed (44) hide show
  1. simcortexpp/__init__.py +0 -0
  2. simcortexpp/cli/__init__.py +0 -0
  3. simcortexpp/cli/main.py +81 -0
  4. simcortexpp/configs/__init__.py +0 -0
  5. simcortexpp/configs/deform/__init__.py +0 -0
  6. simcortexpp/configs/deform/eval.yaml +34 -0
  7. simcortexpp/configs/deform/inference.yaml +60 -0
  8. simcortexpp/configs/deform/train.yaml +98 -0
  9. simcortexpp/configs/initsurf/__init__.py +0 -0
  10. simcortexpp/configs/initsurf/generate.yaml +50 -0
  11. simcortexpp/configs/seg/__init__.py +0 -0
  12. simcortexpp/configs/seg/eval.yaml +31 -0
  13. simcortexpp/configs/seg/inference.yaml +35 -0
  14. simcortexpp/configs/seg/train.yaml +42 -0
  15. simcortexpp/deform/__init__.py +0 -0
  16. simcortexpp/deform/data/__init__.py +0 -0
  17. simcortexpp/deform/data/dataloader.py +268 -0
  18. simcortexpp/deform/eval.py +347 -0
  19. simcortexpp/deform/inference.py +244 -0
  20. simcortexpp/deform/models/__init__.py +0 -0
  21. simcortexpp/deform/models/surfdeform.py +356 -0
  22. simcortexpp/deform/train.py +1173 -0
  23. simcortexpp/deform/utils/__init__.py +0 -0
  24. simcortexpp/deform/utils/coords.py +90 -0
  25. simcortexpp/initsurf/__init__.py +0 -0
  26. simcortexpp/initsurf/generate.py +354 -0
  27. simcortexpp/initsurf/paths.py +19 -0
  28. simcortexpp/preproc/__init__.py +0 -0
  29. simcortexpp/preproc/fs_to_mni.py +696 -0
  30. simcortexpp/seg/__init__.py +0 -0
  31. simcortexpp/seg/data/__init__.py +0 -0
  32. simcortexpp/seg/data/dataloader.py +328 -0
  33. simcortexpp/seg/eval.py +248 -0
  34. simcortexpp/seg/inference.py +291 -0
  35. simcortexpp/seg/models/__init__.py +0 -0
  36. simcortexpp/seg/models/unet.py +63 -0
  37. simcortexpp/seg/train.py +432 -0
  38. simcortexpp/utils/__init__.py +0 -0
  39. simcortexpp/utils/tca.py +298 -0
  40. simcortexpp-0.1.0.dist-info/METADATA +334 -0
  41. simcortexpp-0.1.0.dist-info/RECORD +44 -0
  42. simcortexpp-0.1.0.dist-info/WHEEL +5 -0
  43. simcortexpp-0.1.0.dist-info/entry_points.txt +2 -0
  44. simcortexpp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,696 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SimCortexPP Preprocessing: FreeSurfer -> MNI (BIDS-Derivatives-inspired outputs)
4
+
5
+ Outputs (example):
6
+ <OUT_DERIV_ROOT>/
7
+ dataset_description.json
8
+ sub-100307/
9
+ ses-01/
10
+ anat/
11
+ sub-100307_ses-01_desc-preproc_T1w.nii.gz
12
+ sub-100307_ses-01_space-MNI152_desc-preproc_T1w.nii.gz
13
+ sub-100307_ses-01_desc-aseg_dseg.nii.gz
14
+ sub-100307_ses-01_space-MNI152_desc-aseg_dseg.nii.gz
15
+ sub-100307_ses-01_desc-aparc+aseg_dseg.nii.gz
16
+ sub-100307_ses-01_space-MNI152_desc-aparc+aseg_dseg.nii.gz
17
+ sub-100307_ses-01_desc-filled_T1w.nii.gz
18
+ sub-100307_ses-01_space-MNI152_desc-filled_T1w.nii.gz
19
+ sub-100307_ses-01_from-T1w_to-MNI152_mode-image_xfm.txt
20
+ surfaces/
21
+ sub-100307_ses-01_hemi-L_white.surf.ply
22
+ sub-100307_ses-01_space-MNI152_hemi-L_white.surf.ply
23
+ sub-100307_ses-01_hemi-L_pial.surf.ply
24
+ sub-100307_ses-01_space-MNI152_hemi-L_pial.surf.ply
25
+ ...
26
+
27
+ Requirements:
28
+ - Python: numpy, nibabel, typer, trimesh (only for decimation)
29
+ - External commands:
30
+ - reg_aladin, reg_resample (NiftyReg)
31
+
32
+ Notes:
33
+ - Surface reading is done via nibabel (no mris_convert needed).
34
+ - If a canonical FreeSurfer surface is missing (e.g., lh.pial), we fall back to lh.pial.T1
35
+ and convert coordinates from scanner RAS -> tkr RAS using T1.mgz header matrices.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import logging
42
+ import os
43
+ import shutil
44
+ import subprocess
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import Optional, Sequence
48
+
49
+ import numpy as np
50
+ import nibabel as nib
51
+ from nibabel.freesurfer.io import read_geometry
52
+ import typer
53
+
54
+ # trimesh is optional unless --decimate is used
55
+ try:
56
+ import trimesh # type: ignore
57
+ except Exception:
58
+ trimesh = None
59
+
60
+
61
+ APP_NAME = "SimCortexPP-Preproc"
62
+ __version__ = "0.1"
63
+ PIPELINE_NAME = f"scpp-preproc-{__version__}"
64
+
65
+ app = typer.Typer(add_completion=False, invoke_without_command=True, help="FreeSurfer -> MNI preprocessing.")
66
+
67
+
68
+ # -------------------------
69
+ # Logging
70
+ # -------------------------
71
+ def setup_logger(verbosity: int = 0, log_file: Optional[Path] = None) -> logging.Logger:
72
+ level = logging.INFO if verbosity == 0 else logging.DEBUG
73
+ logger = logging.getLogger("scpp-preproc")
74
+ logger.setLevel(level)
75
+ logger.handlers.clear()
76
+
77
+ fmt = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
78
+
79
+ sh = logging.StreamHandler()
80
+ sh.setLevel(level)
81
+ sh.setFormatter(fmt)
82
+ logger.addHandler(sh)
83
+
84
+ if log_file is not None:
85
+ log_file.parent.mkdir(parents=True, exist_ok=True)
86
+ fh = logging.FileHandler(str(log_file))
87
+ fh.setLevel(level)
88
+ fh.setFormatter(fmt)
89
+ logger.addHandler(fh)
90
+
91
+ return logger
92
+
93
+
94
+ # -------------------------
95
+ # Small helpers
96
+ # -------------------------
97
+ def strip_prefix(x: str, prefix: str) -> str:
98
+ return x[len(prefix):] if x.startswith(prefix) else x
99
+
100
+
101
+ def bids_sub_id(label: str) -> str:
102
+ return f"sub-{strip_prefix(label, 'sub-')}"
103
+
104
+
105
+ def bids_ses_id(label: str) -> str:
106
+ return f"ses-{strip_prefix(label, 'ses-')}"
107
+
108
+
109
+ def safe_mkdir(p: Path) -> None:
110
+ p.mkdir(parents=True, exist_ok=True)
111
+
112
+
113
+ def require_bin(name: str) -> None:
114
+ if shutil.which(name) is None:
115
+ raise FileNotFoundError(f"Required executable not found in PATH: {name}")
116
+
117
+
118
+ def run_cmd(cmd: list[str], logger: logging.Logger) -> None:
119
+ logger.debug("Running: %s", " ".join(cmd))
120
+ p = subprocess.run(cmd, capture_output=True, text=True)
121
+ if p.returncode != 0:
122
+ raise RuntimeError(
123
+ f"Command failed (exit={p.returncode}): {' '.join(cmd)}\n"
124
+ f"STDOUT:\n{p.stdout}\nSTDERR:\n{p.stderr}"
125
+ )
126
+ if p.stdout.strip():
127
+ logger.debug("STDOUT: %s", p.stdout.strip())
128
+ if p.stderr.strip():
129
+ logger.debug("STDERR: %s", p.stderr.strip())
130
+
131
+
132
+ def apply_affine(aff: np.ndarray, pts: np.ndarray) -> np.ndarray:
133
+ pts = np.asarray(pts, dtype=np.float64)
134
+ ones = np.ones((pts.shape[0], 1), dtype=np.float64)
135
+ h = np.concatenate([pts, ones], axis=1)
136
+ out = (h @ aff.T)[:, :3]
137
+ return out
138
+
139
+
140
+ def inside_fraction(vertices_world: np.ndarray, ref_img: nib.Nifti1Image) -> float:
141
+ vox = nib.affines.apply_affine(np.linalg.inv(ref_img.affine), vertices_world)
142
+ shape = np.array(ref_img.shape[:3], dtype=np.float64)
143
+ inside = np.all((vox >= 0) & (vox < shape), axis=1)
144
+ return float(np.mean(inside))
145
+
146
+
147
+ def read_affine_txt(path: Path) -> np.ndarray:
148
+ arr = np.loadtxt(str(path))
149
+ if arr.shape != (4, 4):
150
+ raise ValueError(f"Unexpected affine shape in {path}: {arr.shape}")
151
+ return arr.astype(np.float64)
152
+
153
+
154
+ def write_dataset_description(out_root: Path) -> None:
155
+ dd = {
156
+ "Name": PIPELINE_NAME,
157
+ "BIDSVersion": "1.4.0",
158
+ "DatasetType": "derivative",
159
+ "GeneratedBy": [
160
+ {
161
+ "Name": APP_NAME,
162
+ "Version": __version__,
163
+ "Description": "FreeSurfer-derived preprocessing: export volumes/surfaces and affine normalize to MNI.",
164
+ }
165
+ ],
166
+ }
167
+ f = out_root / "dataset_description.json"
168
+ if not f.exists():
169
+ f.write_text(json.dumps(dd, indent=2) + "\n")
170
+
171
+
172
+ def save_like_ply_ascii(path: Path, vertices: np.ndarray, faces: np.ndarray) -> None:
173
+ """
174
+ Minimal, robust ASCII PLY writer (triangles).
175
+ """
176
+ v = np.asarray(vertices, dtype=np.float64)
177
+ f = np.asarray(faces, dtype=np.int64)
178
+ if f.ndim != 2 or f.shape[1] != 3:
179
+ raise ValueError("Faces must be (M, 3) triangles")
180
+
181
+ with path.open("w") as fp:
182
+ fp.write("ply\n")
183
+ fp.write("format ascii 1.0\n")
184
+ fp.write(f"element vertex {v.shape[0]}\n")
185
+ fp.write("property float x\n")
186
+ fp.write("property float y\n")
187
+ fp.write("property float z\n")
188
+ fp.write(f"element face {f.shape[0]}\n")
189
+ fp.write("property list uchar int vertex_indices\n")
190
+ fp.write("end_header\n")
191
+ for i in range(v.shape[0]):
192
+ fp.write(f"{v[i,0]:.6f} {v[i,1]:.6f} {v[i,2]:.6f}\n")
193
+ for j in range(f.shape[0]):
194
+ fp.write(f"3 {f[j,0]} {f[j,1]} {f[j,2]}\n")
195
+
196
+
197
+ def decimate_mesh(vertices: np.ndarray, faces: np.ndarray, ratio: float) -> tuple[np.ndarray, np.ndarray]:
198
+ """
199
+ Decimate mesh using fast_simplification directly.
200
+ ratio: Target fraction of faces to KEEP (e.g. 0.3).
201
+ """
202
+ try:
203
+ import fast_simplification
204
+ except ImportError:
205
+ raise RuntimeError("fast_simplification is required. Run: pip install fast-simplification")
206
+
207
+ # Calculate reduction amount (amount to REMOVE)
208
+ # If ratio is 0.3 (keep 30%), we reduce by 0.7 (remove 70%)
209
+ target_reduction = 1.0 - float(ratio)
210
+
211
+ # Safety clamp to ensure it's always between 0 and 1
212
+ target_reduction = max(0.0, min(0.999, target_reduction))
213
+
214
+ # Run simplification directly
215
+ v_new, f_new = fast_simplification.simplify(
216
+ vertices,
217
+ faces,
218
+ target_reduction=target_reduction
219
+ )
220
+
221
+ return v_new, f_new
222
+
223
+
224
+ # -------------------------
225
+ # FreeSurfer subject discovery
226
+ # -------------------------
227
+ def is_fs_subject_dir(d: Path) -> bool:
228
+ return d.is_dir() and (d / "surf").is_dir() and (d / "mri").is_dir()
229
+
230
+
231
+ def discover_subjects(fs_root: Path) -> list[str]:
232
+ """
233
+ Prefer sub-* naming; otherwise include all directories that look like FS subjects.
234
+ """
235
+ subs = [d.name for d in fs_root.iterdir() if d.is_dir() and d.name.startswith("sub-") and is_fs_subject_dir(d)]
236
+ subs.sort()
237
+ if subs:
238
+ return subs
239
+
240
+ # fallback: classic SUBJECTS_DIR style (e.g., 100307)
241
+ subs = [d.name for d in fs_root.iterdir() if is_fs_subject_dir(d)]
242
+ subs.sort()
243
+ return subs
244
+
245
+
246
+ def find_fs_subject_dir(fs_root: Path, sub_label: str) -> Path:
247
+ """
248
+ Accept either sub-XXXX or XXXX.
249
+ """
250
+ sub = bids_sub_id(sub_label)
251
+ cand1 = fs_root / sub
252
+ if is_fs_subject_dir(cand1):
253
+ return cand1
254
+
255
+ cand2 = fs_root / strip_prefix(sub_label, "sub-")
256
+ if is_fs_subject_dir(cand2):
257
+ return cand2
258
+
259
+ # if user passed a directory name already existing
260
+ cand3 = fs_root / sub_label
261
+ if is_fs_subject_dir(cand3):
262
+ return cand3
263
+
264
+ raise FileNotFoundError(f"Could not find FreeSurfer subject directory for {sub_label} under {fs_root}")
265
+
266
+
267
+ # -------------------------
268
+ # Surface reading with fallback
269
+ # -------------------------
270
+ @dataclass(frozen=True)
271
+ class SurfaceCandidate:
272
+ path: Path
273
+ is_scanner_ras: bool # True for *.T1 surfaces
274
+
275
+
276
+ def surface_candidates(fs_surf_dir: Path, fs_hemi: str, name: str) -> list[SurfaceCandidate]:
277
+ """
278
+ Canonical FS surfaces are typically:
279
+ lh.white, lh.pial, rh.white, rh.pial
280
+
281
+ Some datasets may instead contain:
282
+ lh.pial.T1, rh.pial.T1
283
+ """
284
+ cands: list[SurfaceCandidate] = []
285
+ base = fs_surf_dir / f"{fs_hemi}.{name}"
286
+ if base.exists():
287
+ cands.append(SurfaceCandidate(base, is_scanner_ras=False))
288
+
289
+ # Fallback for missing pial surfaces (and sometimes others): *.T1
290
+ t1 = fs_surf_dir / f"{fs_hemi}.{name}.T1"
291
+ if t1.exists():
292
+ cands.append(SurfaceCandidate(t1, is_scanner_ras=True))
293
+
294
+ return cands
295
+
296
+
297
+ def scanner_to_tkr_affine(t1_mgz: nib.spatialimages.SpatialImage) -> np.ndarray:
298
+ """
299
+ Build affine that maps scanner RAS -> tkr RAS using T1.mgz header:
300
+ v_tkr = vox2ras_tkr @ inv(vox2ras) @ v_scanner
301
+ """
302
+ hdr = t1_mgz.header
303
+ # these are MGHHeader methods
304
+ vox2ras = hdr.get_vox2ras()
305
+ vox2ras_tkr = hdr.get_vox2ras_tkr()
306
+ return vox2ras_tkr @ np.linalg.inv(vox2ras)
307
+
308
+
309
+ def read_fs_surface_vertices_faces(
310
+ fs_surf_dir: Path,
311
+ fs_hemi: str,
312
+ surf_name: str,
313
+ t1_mgz: nib.spatialimages.SpatialImage,
314
+ logger: logging.Logger,
315
+ stem: str,
316
+ ) -> Optional[tuple[np.ndarray, np.ndarray, Path]]:
317
+
318
+ cands = surface_candidates(fs_surf_dir, fs_hemi, surf_name)
319
+ if not cands:
320
+ return None
321
+
322
+ cand = cands[0]
323
+
324
+ temp_scanner = cand.path.with_suffix(".tmp_scanner")
325
+ try:
326
+ subprocess.run(["mris_convert", "--to-scanner", str(cand.path), str(temp_scanner)],
327
+ check=True, capture_output=True)
328
+
329
+ v, f = read_geometry(str(temp_scanner))
330
+
331
+ if temp_scanner.exists():
332
+ temp_scanner.unlink()
333
+
334
+ except Exception as e:
335
+ logger.error("[%s] Error aligning surface %s: %s", stem, cand.path.name, e)
336
+ v, f = read_geometry(str(cand.path))
337
+ hdr = t1_mgz.header
338
+ M = hdr.get_vox2ras_tkr()
339
+ M_inv = np.linalg.inv(M)
340
+ v2w = hdr.get_vox2ras()
341
+ full_m = v2w @ M_inv
342
+ v = apply_affine(full_m, v)
343
+
344
+ return np.asarray(v, dtype=np.float64), np.asarray(f, dtype=np.int64), cand.path
345
+
346
+ # -------------------------
347
+ # Core per-subject processing
348
+ # -------------------------
349
+ def process_one(
350
+ *,
351
+ fs_root: Path,
352
+ out_root: Path,
353
+ participant_label: str,
354
+ session_label: str,
355
+ mni_template: Path,
356
+ space: str,
357
+ surface_names: Sequence[str],
358
+ hemis: Sequence[str],
359
+ write_aparc_aseg: bool,
360
+ write_filled: bool,
361
+ decimate_ratio: Optional[float],
362
+ skip_existing: bool,
363
+ affine_for_surfaces: Optional[str], # forward|inverse|None(auto)
364
+ strict_surfaces: bool,
365
+ logger: logging.Logger,
366
+ ) -> None:
367
+ sub = bids_sub_id(participant_label)
368
+ ses = bids_ses_id(session_label)
369
+ stem = f"{sub}_{ses}"
370
+
371
+ fs_sub_dir = find_fs_subject_dir(fs_root, sub)
372
+ fs_mri = fs_sub_dir / "mri"
373
+ fs_surf = fs_sub_dir / "surf"
374
+
375
+ out_sub = out_root / sub / ses
376
+ out_anat = out_sub / "anat"
377
+ out_surfaces = out_sub / "surfaces"
378
+ safe_mkdir(out_anat)
379
+ safe_mkdir(out_surfaces)
380
+
381
+ # Output paths
382
+ f_t1_native = out_anat / f"{stem}_desc-preproc_T1w.nii.gz"
383
+ f_t1_mni = out_anat / f"{stem}_space-{space}_desc-preproc_T1w.nii.gz"
384
+ f_aff = out_anat / f"{stem}_from-T1w_to-{space}_mode-image_xfm.txt"
385
+
386
+ f_aseg_native = out_anat / f"{stem}_desc-aseg_dseg.nii.gz"
387
+ f_aseg_mni = out_anat / f"{stem}_space-{space}_desc-aseg_dseg.nii.gz"
388
+
389
+ f_aparc_native = out_anat / f"{stem}_desc-aparc+aseg_dseg.nii.gz"
390
+ f_aparc_mni = out_anat / f"{stem}_space-{space}_desc-aparc+aseg_dseg.nii.gz"
391
+
392
+ f_filled_native = out_anat / f"{stem}_desc-filled_T1w.nii.gz"
393
+ f_filled_mni = out_anat / f"{stem}_space-{space}_desc-filled_T1w.nii.gz"
394
+
395
+ # Load T1.mgz once (needed for surface fallback conversion too)
396
+ t1_mgz_path = fs_mri / "T1.mgz"
397
+ if not t1_mgz_path.exists():
398
+ raise FileNotFoundError(f"Missing {t1_mgz_path}")
399
+ t1_mgz = nib.load(str(t1_mgz_path))
400
+
401
+ # 1) Export volumes (mgz -> nii.gz)
402
+ if (not f_t1_native.exists()) or (not skip_existing):
403
+ logger.info("[%s] Export T1.mgz -> %s", stem, f_t1_native.name)
404
+ nib.save(t1_mgz, str(f_t1_native))
405
+
406
+ aseg_mgz = fs_mri / "aseg.mgz"
407
+ if (not f_aseg_native.exists()) or (not skip_existing):
408
+ if not aseg_mgz.exists():
409
+ raise FileNotFoundError(f"Missing {aseg_mgz}")
410
+ logger.info("[%s] Export aseg.mgz -> %s", stem, f_aseg_native.name)
411
+ nib.save(nib.load(str(aseg_mgz)), str(f_aseg_native))
412
+
413
+ if write_aparc_aseg:
414
+ aparc_mgz = fs_mri / "aparc+aseg.mgz"
415
+ if aparc_mgz.exists() and ((not f_aparc_native.exists()) or (not skip_existing)):
416
+ logger.info("[%s] Export aparc+aseg.mgz -> %s", stem, f_aparc_native.name)
417
+ nib.save(nib.load(str(aparc_mgz)), str(f_aparc_native))
418
+
419
+ if write_filled:
420
+ filled_mgz = fs_mri / "filled.mgz"
421
+ if filled_mgz.exists() and ((not f_filled_native.exists()) or (not skip_existing)):
422
+ logger.info("[%s] Export filled.mgz -> %s", stem, f_filled_native.name)
423
+ nib.save(nib.load(str(filled_mgz)), str(f_filled_native))
424
+
425
+ # 2) Register T1 to template (affine)
426
+ if (not f_t1_mni.exists()) or (not f_aff.exists()) or (not skip_existing):
427
+ logger.info("[%s] reg_aladin: T1w -> space-%s (affine)", stem, space)
428
+ run_cmd(
429
+ [
430
+ "reg_aladin",
431
+ "-ref",
432
+ str(mni_template),
433
+ "-flo",
434
+ str(f_t1_native),
435
+ "-res",
436
+ str(f_t1_mni),
437
+ "-aff",
438
+ str(f_aff),
439
+ ],
440
+ logger=logger,
441
+ )
442
+
443
+ # 3) Resample labels / masks
444
+ if (not f_aseg_mni.exists()) or (not skip_existing):
445
+ logger.info("[%s] reg_resample: aseg -> space-%s (NN)", stem, space)
446
+ run_cmd(
447
+ [
448
+ "reg_resample",
449
+ "-ref",
450
+ str(mni_template),
451
+ "-flo",
452
+ str(f_aseg_native),
453
+ "-res",
454
+ str(f_aseg_mni),
455
+ "-aff",
456
+ str(f_aff),
457
+ "-inter",
458
+ "0",
459
+ ],
460
+ logger=logger,
461
+ )
462
+
463
+ if write_aparc_aseg and f_aparc_native.exists():
464
+ if (not f_aparc_mni.exists()) or (not skip_existing):
465
+ logger.info("[%s] reg_resample: aparc+aseg -> space-%s (NN)", stem, space)
466
+ run_cmd(
467
+ [
468
+ "reg_resample",
469
+ "-ref",
470
+ str(mni_template),
471
+ "-flo",
472
+ str(f_aparc_native),
473
+ "-res",
474
+ str(f_aparc_mni),
475
+ "-aff",
476
+ str(f_aff),
477
+ "-inter",
478
+ "0",
479
+ ],
480
+ logger=logger,
481
+ )
482
+
483
+ if write_filled and f_filled_native.exists():
484
+ if (not f_filled_mni.exists()) or (not skip_existing):
485
+ logger.info("[%s] reg_resample: filled -> space-%s (linear)", stem, space)
486
+ run_cmd(
487
+ [
488
+ "reg_resample",
489
+ "-ref",
490
+ str(mni_template),
491
+ "-flo",
492
+ str(f_filled_native),
493
+ "-res",
494
+ str(f_filled_mni),
495
+ "-aff",
496
+ str(f_aff),
497
+ "-inter",
498
+ "1",
499
+ ],
500
+ logger=logger,
501
+ )
502
+
503
+ # 4) Prepare surface transform (choose ONCE per subject)
504
+ aff = read_affine_txt(f_aff)
505
+ inv_aff = np.linalg.inv(aff)
506
+ ref_img = nib.load(str(f_t1_mni))
507
+
508
+ # Pick a representative surface (prefer lh.white, then rh.white, then anything available)
509
+ rep_vertices = None
510
+ for hemi in ("L", "R"):
511
+ fs_hemi = "lh" if hemi == "L" else "rh"
512
+ rep = read_fs_surface_vertices_faces(fs_surf, fs_hemi, "white", t1_mgz, logger, stem)
513
+ if rep is not None:
514
+ rep_vertices = rep[0]
515
+ break
516
+
517
+ def choose_subject_surface_affine() -> np.ndarray:
518
+ if affine_for_surfaces == "forward":
519
+ return aff
520
+ if affine_for_surfaces == "inverse":
521
+ return inv_aff
522
+ if rep_vertices is None:
523
+ # no representative surface -> default to forward
524
+ logger.warning("[%s] No representative surface found for auto affine; defaulting to forward.", stem)
525
+ return aff
526
+
527
+ fwd_inside = inside_fraction(apply_affine(aff, rep_vertices), ref_img)
528
+ inv_inside = inside_fraction(apply_affine(inv_aff, rep_vertices), ref_img)
529
+ logger.debug("[%s] Subject surface inside fraction: forward=%.4f inverse=%.4f", stem, fwd_inside, inv_inside)
530
+ return aff if fwd_inside >= inv_inside else inv_aff
531
+
532
+ surf_aff = choose_subject_surface_affine()
533
+
534
+ # 5) Export surfaces (native + template)
535
+ for hemi in hemis:
536
+ hemi_u = hemi.upper()
537
+ if hemi_u not in ("L", "R"):
538
+ raise ValueError("hemi must be L or R")
539
+ fs_hemi = "lh" if hemi_u == "L" else "rh"
540
+
541
+ for sname in surface_names:
542
+ sname = sname.strip()
543
+ rep = read_fs_surface_vertices_faces(fs_surf, fs_hemi, sname, t1_mgz, logger, stem)
544
+
545
+ if rep is None:
546
+ msg = f"[{stem}] Missing surface: {fs_hemi}.{sname} (and no .T1 fallback)"
547
+ if strict_surfaces:
548
+ raise FileNotFoundError(msg)
549
+ logger.warning(msg)
550
+ continue
551
+
552
+ v_native, f_native, used_path = rep
553
+
554
+ out_native = out_surfaces / f"{stem}_hemi-{hemi_u}_{sname}.surf.ply"
555
+ out_mni = out_surfaces / f"{stem}_space-{space}_hemi-{hemi_u}_{sname}.surf.ply"
556
+
557
+ if (not out_native.exists()) or (not skip_existing):
558
+ logger.info("[%s] Export surface (native): %s", stem, out_native.name)
559
+ save_like_ply_ascii(out_native, v_native, f_native)
560
+
561
+ if (not out_mni.exists()) or (not skip_existing):
562
+ logger.info("[%s] Write surface (space-%s): %s", stem, space, out_mni.name)
563
+ v_mni = apply_affine(surf_aff, v_native)
564
+ save_like_ply_ascii(out_mni, v_mni, f_native)
565
+
566
+ if decimate_ratio is not None:
567
+ tag = str(decimate_ratio).replace(".", "p")
568
+ out_native_dec = out_surfaces / f"{stem}_desc-decim{tag}_hemi-{hemi_u}_{sname}.surf.ply"
569
+ out_mni_dec = out_surfaces / f"{stem}_space-{space}_desc-decim{tag}_hemi-{hemi_u}_{sname}.surf.ply"
570
+
571
+ if (not out_native_dec.exists()) or (not skip_existing):
572
+ logger.info("[%s] Decimate native (%.3f): %s", stem, decimate_ratio, out_native_dec.name)
573
+ v_dec, f_dec = decimate_mesh(v_native, f_native, decimate_ratio)
574
+ save_like_ply_ascii(out_native_dec, v_dec, f_dec)
575
+ else:
576
+ # If already exists, still need f_dec for MNI decimation; reload via simple parse is annoying.
577
+ # We'll just recompute decimation (fast enough compared to registration).
578
+ v_dec, f_dec = decimate_mesh(v_native, f_native, decimate_ratio)
579
+
580
+ if (not out_mni_dec.exists()) or (not skip_existing):
581
+ logger.info("[%s] Decimate MNI (%.3f): %s", stem, decimate_ratio, out_mni_dec.name)
582
+ v_dec_mni = apply_affine(surf_aff, v_dec)
583
+ save_like_ply_ascii(out_mni_dec, v_dec_mni, f_dec)
584
+
585
+
586
+ # -------------------------
587
+ # CLI (single entry)
588
+ # -------------------------
589
+ @app.callback()
590
+ def main(
591
+ freesurfer_root: Path = typer.Option(..., "--freesurfer-root", exists=True, file_okay=False, dir_okay=True),
592
+ out_deriv_root: Path = typer.Option(..., "--out-deriv-root"),
593
+ mni_template: Path = typer.Option(..., "--mni-template", exists=True, file_okay=True, dir_okay=False),
594
+ participant_label: Optional[list[str]] = typer.Option(
595
+ None,
596
+ "--participant-label",
597
+ "-p",
598
+ help="If omitted, all FS subjects found under --freesurfer-root will be processed.",
599
+ ),
600
+ session_label: str = typer.Option("01", "--session-label", "-s"),
601
+ space: str = typer.Option("MNI152", "--space"),
602
+ hemi: list[str] = typer.Option(["L", "R"], "--hemi"),
603
+ surface: list[str] = typer.Option(["white", "pial"], "--surface"),
604
+ with_aparc_aseg: bool = typer.Option(True, "--with-aparc-aseg/--no-aparc-aseg"),
605
+ with_filled: bool = typer.Option(True, "--with-filled/--no-filled"),
606
+ decimate: Optional[float] = typer.Option(None, "--decimate", min=0.01, max=1.0),
607
+ overwrite: bool = typer.Option(False, "--overwrite", help="Recompute outputs even if files exist."),
608
+ affine_for_surfaces: Optional[str] = typer.Option(
609
+ None,
610
+ "--affine-for-surfaces",
611
+ help="Force surface affine direction: forward|inverse. Default: auto (choose once per subject).",
612
+ ),
613
+ strict_surfaces: bool = typer.Option(
614
+ False,
615
+ "--strict-surfaces",
616
+ help="If set, fail the subject if any requested surface is missing (including .T1 fallback missing).",
617
+ ),
618
+ start: Optional[int] = typer.Option(None, "--start", help="Process subjects from this index (0-based) after sorting."),
619
+ stop: Optional[int] = typer.Option(None, "--stop", help="Stop before this index (0-based) after sorting."),
620
+ log_file: Optional[Path] = typer.Option(None, "--log-file", help="Optional path to write logs (e.g., pipeline.log)."),
621
+ verbosity: int = typer.Option(0, "-v", count=True),
622
+ ):
623
+ """
624
+ Example (all subjects):
625
+ module load freesurfer
626
+ module load niftyreg
627
+ python scripts/preprocess_fs_to_mni_bidsderiv.py \\
628
+ --freesurfer-root /path/to/freesurfer-7.4.1 \\
629
+ --out-deriv-root /path/to/derivatives/scpp-preproc-0.1 \\
630
+ --mni-template /path/to/MNI152_T1_1mm.nii.gz \\
631
+ --decimate 0.3 -v \\
632
+ --log-file pipeline.log
633
+ """
634
+ logger = setup_logger(verbosity, log_file=log_file)
635
+
636
+ require_bin("reg_aladin")
637
+ require_bin("reg_resample")
638
+
639
+ if affine_for_surfaces not in (None, "forward", "inverse"):
640
+ raise typer.BadParameter("--affine-for-surfaces must be one of: forward, inverse")
641
+
642
+ safe_mkdir(out_deriv_root)
643
+ write_dataset_description(out_deriv_root)
644
+
645
+ # subject list
646
+ if participant_label is None or len(participant_label) == 0:
647
+ logger.info("No --participant-label provided. Discovering subjects in %s", freesurfer_root)
648
+ subjects = discover_subjects(freesurfer_root)
649
+ logger.info("Discovered %d subject(s).", len(subjects))
650
+ else:
651
+ subjects = [bids_sub_id(x) for x in participant_label]
652
+
653
+ subjects = sorted(subjects)
654
+ if start is not None or stop is not None:
655
+ subjects = subjects[start:stop]
656
+ logger.info("After slicing (--start/--stop), processing %d subject(s).", len(subjects))
657
+
658
+ if not subjects:
659
+ logger.error("No subjects to process.")
660
+ raise typer.Exit(code=1)
661
+
662
+ skip_existing = not overwrite
663
+
664
+ failed: list[str] = []
665
+ for s in subjects:
666
+ try:
667
+ process_one(
668
+ fs_root=freesurfer_root,
669
+ out_root=out_deriv_root,
670
+ participant_label=s,
671
+ session_label=session_label,
672
+ mni_template=mni_template,
673
+ space=space,
674
+ surface_names=surface,
675
+ hemis=hemi,
676
+ write_aparc_aseg=with_aparc_aseg,
677
+ write_filled=with_filled,
678
+ decimate_ratio=decimate,
679
+ skip_existing=skip_existing,
680
+ affine_for_surfaces=affine_for_surfaces,
681
+ strict_surfaces=strict_surfaces,
682
+ logger=logger,
683
+ )
684
+ except Exception as e:
685
+ failed.append(s)
686
+ logger.error("[%s] FAILED: %s", s, e)
687
+
688
+ if failed:
689
+ logger.error("Done with failures (%d): %s", len(failed), ", ".join(failed))
690
+ raise typer.Exit(code=1)
691
+
692
+ logger.info("Done. Outputs written under: %s", out_deriv_root)
693
+
694
+
695
+ if __name__ == "__main__":
696
+ app()
File without changes
File without changes