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.
- simcortexpp/__init__.py +0 -0
- simcortexpp/cli/__init__.py +0 -0
- simcortexpp/cli/main.py +81 -0
- simcortexpp/configs/__init__.py +0 -0
- simcortexpp/configs/deform/__init__.py +0 -0
- simcortexpp/configs/deform/eval.yaml +34 -0
- simcortexpp/configs/deform/inference.yaml +60 -0
- simcortexpp/configs/deform/train.yaml +98 -0
- simcortexpp/configs/initsurf/__init__.py +0 -0
- simcortexpp/configs/initsurf/generate.yaml +50 -0
- simcortexpp/configs/seg/__init__.py +0 -0
- simcortexpp/configs/seg/eval.yaml +31 -0
- simcortexpp/configs/seg/inference.yaml +35 -0
- simcortexpp/configs/seg/train.yaml +42 -0
- simcortexpp/deform/__init__.py +0 -0
- simcortexpp/deform/data/__init__.py +0 -0
- simcortexpp/deform/data/dataloader.py +268 -0
- simcortexpp/deform/eval.py +347 -0
- simcortexpp/deform/inference.py +244 -0
- simcortexpp/deform/models/__init__.py +0 -0
- simcortexpp/deform/models/surfdeform.py +356 -0
- simcortexpp/deform/train.py +1173 -0
- simcortexpp/deform/utils/__init__.py +0 -0
- simcortexpp/deform/utils/coords.py +90 -0
- simcortexpp/initsurf/__init__.py +0 -0
- simcortexpp/initsurf/generate.py +354 -0
- simcortexpp/initsurf/paths.py +19 -0
- simcortexpp/preproc/__init__.py +0 -0
- simcortexpp/preproc/fs_to_mni.py +696 -0
- simcortexpp/seg/__init__.py +0 -0
- simcortexpp/seg/data/__init__.py +0 -0
- simcortexpp/seg/data/dataloader.py +328 -0
- simcortexpp/seg/eval.py +248 -0
- simcortexpp/seg/inference.py +291 -0
- simcortexpp/seg/models/__init__.py +0 -0
- simcortexpp/seg/models/unet.py +63 -0
- simcortexpp/seg/train.py +432 -0
- simcortexpp/utils/__init__.py +0 -0
- simcortexpp/utils/tca.py +298 -0
- simcortexpp-0.1.0.dist-info/METADATA +334 -0
- simcortexpp-0.1.0.dist-info/RECORD +44 -0
- simcortexpp-0.1.0.dist-info/WHEEL +5 -0
- simcortexpp-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|