Skip to content

Commit 357a7cf

Browse files
committed
brainprep/workflow/sulcire: add morphologists workflows.
1 parent c14f40d commit 357a7cf

10 files changed

Lines changed: 1001 additions & 1 deletion

File tree

brainprep/interfaces/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
scale,
4545
)
4646
from .morphologist import (
47+
morphologist_morphometry,
4748
morphologist_wf,
4849
)
4950
from .mriqc import (
@@ -65,6 +66,7 @@
6566
mean_correlation,
6667
mriqc_metrics,
6768
network_entropy,
69+
sulcirec_metrics,
6870
vbm_metrics,
6971
)
7072
from .utils import (
@@ -118,6 +120,7 @@
118120
"reorient",
119121
"scale",
120122
"subject_level_qa",
123+
"sulcirec_metrics",
121124
"ungzfile",
122125
"vbm_metrics",
123126
"write_catbatch",
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
##########################################################################
2+
# NSAp - Copyright (C) CEA, 2021 - 2025
3+
# Distributed under the terms of the CeCILL-B license, as published by
4+
# the CEA-CNRS-INRIA. Refer to the LICENSE file or to
5+
# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html
6+
# for details.
7+
##########################################################################
8+
9+
10+
"""
11+
Morphologist functions.
12+
"""
13+
14+
import shutil
15+
from pathlib import Path
16+
17+
import pandas as pd
18+
19+
from ..decorators import (
20+
CoerceparamsHook,
21+
CommandLineWrapperHook,
22+
LogRuntimeHook,
23+
OutputdirHook,
24+
PythonWrapperHook,
25+
SignatureHook,
26+
step,
27+
)
28+
from ..typing import (
29+
Directory,
30+
File,
31+
)
32+
from ..utils import (
33+
parse_bids_keys,
34+
print_warn,
35+
)
36+
37+
38+
@step(
39+
hooks=[
40+
CoerceparamsHook(),
41+
OutputdirHook(),
42+
LogRuntimeHook(
43+
bunched=False
44+
),
45+
SignatureHook(),
46+
]
47+
)
48+
def morphologist_wf(
49+
t1_file: File,
50+
output_dir: Directory,
51+
workspace_dir: Directory,
52+
entities: dict) -> tuple[list[File], File]:
53+
"""
54+
Sulci reconstruction and identification using morphologist.
55+
56+
Parameters
57+
----------
58+
t1_file : File
59+
Path to the input T1w image file.
60+
output_dir : Directory
61+
Directory where the prep-processing related outputs will be saved.
62+
workspace_dir: Directory
63+
Working directory with the workspace of the current processing, and
64+
where subject specific data are symlinked.
65+
entities : dict
66+
A dictionary of parsed BIDS entities including modality.*
67+
68+
Returns
69+
-------
70+
sulci_graphs_files : list[File]
71+
Left and right hemispheres sulci.
72+
qc_file : File
73+
QC TSV file.
74+
"""
75+
morphologist_cmd(
76+
t1_file,
77+
output_dir,
78+
workspace_dir,
79+
entities,
80+
)
81+
return morphologist_move(
82+
output_dir,
83+
workspace_dir,
84+
entities,
85+
)
86+
87+
88+
@step(
89+
hooks=[
90+
CoerceparamsHook(),
91+
OutputdirHook(),
92+
LogRuntimeHook(
93+
bunched=False
94+
),
95+
CommandLineWrapperHook(),
96+
SignatureHook(),
97+
]
98+
)
99+
def morphologist_cmd(
100+
t1_file: File,
101+
output_dir: Directory,
102+
workspace_dir: Directory,
103+
entities: dict) -> list[str]:
104+
"""
105+
Morphologist command wrapper.
106+
107+
Parameters
108+
----------
109+
t1_file : File
110+
Path to the input T1w image file.
111+
output_dir : Directory
112+
Directory where the prep-processing related outputs will be saved.
113+
workspace_dir: Directory
114+
Working directory with the workspace of the current processing, and
115+
where subject specific data are symlinked.
116+
entities : dict
117+
A dictionary of parsed BIDS entities including modality.
118+
119+
Returns
120+
-------
121+
command : list[str]
122+
Pre-processing command-line.
123+
"""
124+
output_dir = output_dir / f"run-{entities['run']}"
125+
output_dir.mkdir(parents=True, exist_ok=True)
126+
127+
command = [
128+
"morphologist-wrapper",
129+
str(t1_file),
130+
str(workspace_dir),
131+
"--subject", entities["sub"],
132+
]
133+
134+
return command
135+
136+
137+
@step(
138+
hooks=[
139+
CoerceparamsHook(),
140+
LogRuntimeHook(
141+
bunched=False
142+
),
143+
PythonWrapperHook(),
144+
SignatureHook(),
145+
]
146+
)
147+
def morphologist_move(
148+
output_dir: Directory,
149+
workspace_dir: Directory,
150+
entities: dict,
151+
dryrun: bool = False) -> tuple[list[File], File]:
152+
"""
153+
Morpholigist validation.
154+
155+
Move Morphologist outputs (QC file and run directory) from the workspace
156+
into the final BIDS derivatives structure, and return the expected output
157+
file paths.
158+
159+
Parameters
160+
----------
161+
output_dir : Directory
162+
Directory where the prep-processing related outputs will be saved.
163+
workspace_dir: Directory
164+
Working directory with the workspace of the current processing, and
165+
where subject specific data are symlinked.
166+
entities : dict
167+
A dictionary of parsed BIDS entities including modality.
168+
dryrun : bool
169+
If True, skip actual computation and file writing. Default False.
170+
171+
Returns
172+
-------
173+
sulci_graphs_files : list[File]
174+
Left and right hemispheres sulci.
175+
qc_file : File
176+
QC TSV file.
177+
"""
178+
run_output_dir = output_dir / f"run-{entities['run']}"
179+
qc_file = (
180+
run_output_dir /
181+
"qc.tsv"
182+
)
183+
subject, session, run = entities["sub"], entities["ses"], entities["run"]
184+
sulci_graphs_files = [(
185+
run_output_dir /
186+
"anat" /
187+
"folds" /
188+
"3.1" /
189+
f'sub-{subject}_ses-{session}_run-{run}_hemi-{hemi}.arg')
190+
for hemi in ["L", "R"]
191+
]
192+
193+
if dryrun:
194+
return (sulci_graphs_files, qc_file)
195+
196+
qc_src_file = _get_single_match(
197+
workspace_dir,
198+
"morphologist-*/qc/qc.tsv",
199+
"'qc.tsv' file"
200+
)
201+
shutil.move(qc_src_file, qc_file)
202+
203+
run_src_dir = _get_single_match(
204+
workspace_dir,
205+
"morphologist-*/sub-*/ses-*/run-*",
206+
"'run-*' directory"
207+
)
208+
replacements = [
209+
("ana-0", "anat"),
210+
("ses-0", f"ses-{session}"),
211+
("run-0", f"run-{run}"),
212+
(f"sub-\"{subject}\"", f"sub-{subject}"),
213+
]
214+
for path in run_src_dir.rglob("*"):
215+
rel = str(path.relative_to(run_src_dir))
216+
for old, new in replacements:
217+
rel = rel.replace(old, new)
218+
target = run_output_dir / rel
219+
if path.is_file():
220+
if path.suffix == ".minf":
221+
continue
222+
target.parent.mkdir(parents=True, exist_ok=True)
223+
shutil.copy2(path, target)
224+
225+
return (sulci_graphs_files, qc_file)
226+
227+
228+
def _get_single_match(
229+
workspace_dir: Directory,
230+
pattern: str,
231+
error_label: str) -> Path:
232+
"""
233+
Return a single filesystem match for a glob pattern inside the
234+
workspace derivatives directory.
235+
236+
Parameters
237+
----------
238+
workspace_dir : Directory
239+
Base workspace directory containing a 'derivatives' subdirectory.
240+
pattern : str
241+
Glob pattern relative to 'derivatives' used to locate the file or
242+
directory.
243+
error_label : str
244+
Human-readable label used in error messages.
245+
246+
Returns
247+
-------
248+
Path
249+
The unique matched path.
250+
251+
Raises
252+
------
253+
FileNotFoundError
254+
If no match is found.
255+
RuntimeError
256+
If more than one match is found.
257+
"""
258+
derivatives = workspace_dir / "derivatives"
259+
matches = list(derivatives.glob(pattern))
260+
261+
if not matches:
262+
raise FileNotFoundError(f"No {error_label} found (pattern: {pattern})")
263+
264+
if len(matches) > 1:
265+
raise RuntimeError(
266+
f"Multiple {error_label} found ({len(matches)} matches): {matches}"
267+
)
268+
269+
return matches[0]
270+
271+
272+
@step(
273+
hooks=[
274+
CoerceparamsHook(),
275+
OutputdirHook(
276+
morphometry=True
277+
),
278+
LogRuntimeHook(
279+
bunched=False
280+
),
281+
PythonWrapperHook(),
282+
SignatureHook(),
283+
]
284+
)
285+
def morphologist_morphometry(
286+
output_dir: Directory,
287+
dryrun: bool = False) -> list[File]:
288+
"""
289+
Extract ROI-based morphometry features and global tissue volumes from
290+
morphologist outputs.
291+
292+
This function parses morphologist `.csv` ROI statistics. It generates two
293+
TSV files for sulcal and brain volumes morphometries.
294+
295+
Parameters
296+
----------
297+
output_dir : Directory
298+
Working directory containing the outputs.
299+
dryrun : bool
300+
If True, skip actual computation and file writing. Default False.
301+
302+
Returns
303+
-------
304+
morphometry_files : list[File]
305+
TSV files containing ROI-based sulcal and brain volumes morphometries.
306+
"""
307+
morphometry_files = [
308+
output_dir / f"sulcal_morphologist_roi.tsv",
309+
output_dir / f"brain_volumes_morphologist_roi.tsv",
310+
]
311+
312+
if dryrun:
313+
return (morphometry_files, )
314+
315+
patterns = [
316+
("sub-*/ses-*/run-*/anat/folds/3.1/sul-0_auto/sub-*_ses-*_run-*_sul-0_"
317+
"auto_sulcal_morphometry.csv"),
318+
("sub-*/ses-*/run-*/anat/segmentation/sub-*_ses-*_run-*_sul-0_brain_"
319+
"volumes.csv"),
320+
]
321+
for pattern, output_file in zip(patterns, morphometry_files, strict=True):
322+
csv_files = list((output_dir.parent / "subjects").glob(pattern))
323+
if len(csv_files) == 0:
324+
print_warn(f"No data found: {pattern}")
325+
continue
326+
entities = [
327+
parse_bids_keys(path)
328+
for path in csv_files
329+
]
330+
data = []
331+
for info, path in zip(entities, csv_files, strict=True):
332+
df = pd.read_csv(path, sep=";")
333+
if "subject" in df:
334+
df.drop(columns=["subject"], inplace=True)
335+
if len(df) > 1:
336+
df.drop(columns=["sulcus"], inplace=True)
337+
df = df.set_index(["label", "side"]).stack()
338+
df.index = [
339+
f"{sulcus}_{hemi}_{metric}"
340+
for (sulcus, hemi, metric) in df.index
341+
]
342+
df = df.to_frame().T
343+
df.insert(0, "participant_id", info["sub"])
344+
df.insert(1, "session", info["ses"])
345+
df.insert(2, "run", info["run"])
346+
data.append(df)
347+
df = pd.concat(data)
348+
df.sort_values(by=["participant_id", "session", "run"], inplace=True)
349+
df.to_csv(
350+
output_file,
351+
index=False,
352+
sep="\t",
353+
)
354+
355+
return (morphometry_files, )

brainprep/workflow/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
brainprep_sbm,
3333
)
3434
from .sulcirec import (
35+
brainprep_group_sulcirec,
3536
brainprep_sulcirec,
3637
)
3738
from .vbm import (
@@ -48,6 +49,7 @@
4849
"brainprep_group_quality_assurance",
4950
"brainprep_group_quasiraw",
5051
"brainprep_group_sbm",
52+
"brainprep_group_sulcirec",
5153
"brainprep_group_vbm",
5254
"brainprep_longitudinal_sbm",
5355
"brainprep_longitudinal_vbm",

0 commit comments

Comments
 (0)