@yibeichan/claude-skills 1.0.2
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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/cli.js +272 -0
- package/install.py +240 -0
- package/package.json +44 -0
- package/skills/bidsapp-nidm-standards/SKILL.md +202 -0
- package/skills/bidsapp-nidm-standards/references/babs_config.md +20 -0
- package/skills/bidsapp-nidm-standards/references/cli_arguments.md +76 -0
- package/skills/bidsapp-nidm-standards/references/container_patterns.md +53 -0
- package/skills/bidsapp-nidm-standards/references/nidm_integration.md +403 -0
- package/skills/bidsapp-nidm-standards/references/repo_structure.md +121 -0
- package/skills/bidsapp-nidm-standards/references/testing_patterns.md +82 -0
- package/skills/dicom2fmriprep/SKILL.md +377 -0
- package/skills/dicom2fmriprep/evals/evals.json +26 -0
- package/skills/dicom2fmriprep/references/babs-details.md +407 -0
- package/skills/dicom2fmriprep/references/fmriprep-details.md +250 -0
- package/skills/dicom2fmriprep/references/heudiconv-details.md +243 -0
- package/skills/fmri-ssm/SKILL.md +317 -0
- package/skills/fmri-ssm/references/code_templates.md +1570 -0
- package/skills/fmri-ssm/references/downstream_analysis.md +680 -0
- package/skills/fmri-ssm/references/group_inference.md +608 -0
- package/skills/fmri-ssm/references/hrf_modeling.md +447 -0
- package/skills/fmri-ssm/references/model_catalog.md +436 -0
- package/skills/fmri-ssm/references/paradigm_guide.md +406 -0
- package/skills/fmri-ssm/references/preprocessing.md +614 -0
- package/skills/fmri-ssm.zip +0 -0
- package/skills/neuroimaging-qc/SKILL.md +203 -0
- package/skills/neuroimaging-qc/references/eeg_qc.md +400 -0
- package/skills/neuroimaging-qc/references/fmri_qc.md +343 -0
- package/skills/neuroimaging-qc/references/fnirs_qc.md +430 -0
- package/skills/neuroimaging-qc/references/structural_qc.md +454 -0
- package/skills/neuroimaging-qc/scripts/parse_fmriprep_confounds.py +153 -0
- package/skills/neuroimaging-qc/scripts/parse_mriqc.py +114 -0
- package/skills/neuroimaging-qc/scripts/qc_report.py +295 -0
- package/skills/scientific-writer/SKILL.md +202 -0
- package/skills/scientific-writer/references/citation_styles.md +163 -0
- package/skills/scientific-writer/references/field_conventions.md +245 -0
- package/skills/scientific-writer/references/figures_tables.md +225 -0
- package/skills/scientific-writer/references/reporting_guidelines.md +225 -0
- package/skills.json +54 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# Structural MRI QC Reference
|
|
2
|
+
|
|
3
|
+
Comprehensive guide for QC of structural MRI data (T1w, T2w) from MRIQC, FreeSurfer, and related pipelines.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
1. [MRIQC Anatomical IQMs](#mriqc-anatomical-iqms)
|
|
7
|
+
2. [FreeSurfer QC](#freesurfer-qc)
|
|
8
|
+
3. [Registration QC](#registration-qc)
|
|
9
|
+
4. [Segmentation QC](#segmentation-qc)
|
|
10
|
+
5. [Population Considerations](#population-considerations)
|
|
11
|
+
6. [Python Examples](#python-examples)
|
|
12
|
+
|
|
13
|
+
## MRIQC Anatomical IQMs
|
|
14
|
+
|
|
15
|
+
### Complete T1w/T2w IQM List
|
|
16
|
+
|
|
17
|
+
| Metric | Description | Direction | Typical Range |
|
|
18
|
+
|--------|-------------|-----------|---------------|
|
|
19
|
+
| **Noise Metrics** ||||
|
|
20
|
+
| `snr_csf` | SNR in CSF | Higher better | 5-15 |
|
|
21
|
+
| `snr_gm` | SNR in gray matter | Higher better | 8-20 |
|
|
22
|
+
| `snr_wm` | SNR in white matter | Higher better | 10-25 |
|
|
23
|
+
| `snr_total` | Total SNR | Higher better | Varies |
|
|
24
|
+
| `cnr` | Contrast-to-noise ratio (GM/WM) | Higher better | >2.5 |
|
|
25
|
+
| **Information Metrics** ||||
|
|
26
|
+
| `cjv` | Coefficient of joint variation | Lower better | <0.5 |
|
|
27
|
+
| `efc` | Entropy focus criterion | Lower better | <0.6 |
|
|
28
|
+
| `fber` | Foreground-background energy ratio | Higher better | >100 |
|
|
29
|
+
| **Artifact Metrics** ||||
|
|
30
|
+
| `qi_1` | Artifact detection (Mortamet) | Lower better | <0.05 |
|
|
31
|
+
| `qi_2` | Artifact detection (extended) | Lower better | <0.05 |
|
|
32
|
+
| `inu_med` | INU bias median | ~1.0 | 0.9-1.1 |
|
|
33
|
+
| `inu_range` | INU bias range | Lower better | <0.3 |
|
|
34
|
+
| `wm2max` | WM to max intensity ratio | ~0.5-0.8 | Site-dependent |
|
|
35
|
+
| **Resolution/Size** ||||
|
|
36
|
+
| `fwhm_avg` | Average smoothness (mm) | Context | 2-4mm |
|
|
37
|
+
| `fwhm_x/y/z` | Directional smoothness | Context | |
|
|
38
|
+
| `size_x/y/z` | Matrix dimensions | Context | |
|
|
39
|
+
| `spacing_x/y/z` | Voxel dimensions | Context | |
|
|
40
|
+
| **Tissue Stats** ||||
|
|
41
|
+
| `summary_*_mean` | Mean intensity per tissue | Context | |
|
|
42
|
+
| `summary_*_stdv` | Stdv intensity per tissue | Context | |
|
|
43
|
+
| `summary_*_p05/p95` | 5th/95th percentiles | Context | |
|
|
44
|
+
| `tpm_overlap_*` | TPM overlap per tissue | Higher better | >0.7 |
|
|
45
|
+
| `icvs_*` | Intracranial volume fractions | Context | |
|
|
46
|
+
|
|
47
|
+
### Key Metrics for Exclusion Decisions
|
|
48
|
+
|
|
49
|
+
**Primary (always check):**
|
|
50
|
+
1. `qi_1` — artifact detection
|
|
51
|
+
2. `cnr` — tissue contrast quality
|
|
52
|
+
3. `snr_gm` / `snr_wm` — signal quality
|
|
53
|
+
4. `cjv` — GM/WM separability
|
|
54
|
+
|
|
55
|
+
**Secondary:**
|
|
56
|
+
5. `efc` — ghosting/ringing
|
|
57
|
+
6. `inu_range` — bias field severity
|
|
58
|
+
7. `fwhm_avg` — effective resolution
|
|
59
|
+
|
|
60
|
+
### Threshold Recommendations
|
|
61
|
+
|
|
62
|
+
**Note**: Absolute thresholds don't generalize well across sites/scanners. Use distribution-based outlier detection.
|
|
63
|
+
|
|
64
|
+
**General guidelines:**
|
|
65
|
+
- `qi_1` > 0.1: Potential artifacts
|
|
66
|
+
- `cnr` < 2.5: Poor tissue contrast
|
|
67
|
+
- `cjv` > 0.5: Poor GM/WM separation
|
|
68
|
+
- `efc` > 0.6: Possible ghosting
|
|
69
|
+
|
|
70
|
+
**Recommended approach:**
|
|
71
|
+
```python
|
|
72
|
+
# Flag outliers beyond 2-3 SD from sample mean
|
|
73
|
+
for metric in ['qi_1', 'cnr', 'cjv', 'efc']:
|
|
74
|
+
z_score = (value - sample_mean) / sample_std
|
|
75
|
+
if abs(z_score) > 3:
|
|
76
|
+
flag_for_review(subject)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## FreeSurfer QC
|
|
80
|
+
|
|
81
|
+
### Automated QC Metrics
|
|
82
|
+
|
|
83
|
+
**Euler number:**
|
|
84
|
+
- Topological measure of surface quality
|
|
85
|
+
- More negative = more holes/handles (worse)
|
|
86
|
+
- Threshold: Euler < -200 flagged for review (Rosen et al., 2018)
|
|
87
|
+
|
|
88
|
+
**Surface holes:**
|
|
89
|
+
- Count of topological defects
|
|
90
|
+
- Threshold: >100 holes concerning
|
|
91
|
+
|
|
92
|
+
**Estimated total intracranial volume (eTIV):**
|
|
93
|
+
- Should be within expected range for population
|
|
94
|
+
- Flag outliers (>2 SD from mean)
|
|
95
|
+
|
|
96
|
+
### Visual QC Checkpoints
|
|
97
|
+
|
|
98
|
+
**1. Skull stripping:**
|
|
99
|
+
```
|
|
100
|
+
□ Brain fully included
|
|
101
|
+
□ No skull/dura remaining
|
|
102
|
+
□ No brain tissue removed
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**2. White matter segmentation:**
|
|
106
|
+
```
|
|
107
|
+
□ WM follows gyral pattern
|
|
108
|
+
□ No WM in ventricles
|
|
109
|
+
□ No holes in WM
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**3. Pial surface:**
|
|
113
|
+
```
|
|
114
|
+
□ Follows gray matter boundary
|
|
115
|
+
□ No extension into skull/dura
|
|
116
|
+
□ No cuts into brain tissue
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**4. Subcortical segmentation:**
|
|
120
|
+
```
|
|
121
|
+
□ Symmetric structures similar size
|
|
122
|
+
□ No obvious mislabeling
|
|
123
|
+
□ Hippocampus properly delineated
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Common FreeSurfer Issues
|
|
127
|
+
|
|
128
|
+
| Issue | Visual Sign | Potential Cause |
|
|
129
|
+
|-------|-------------|-----------------|
|
|
130
|
+
| Skull stripping failure | Brain cut off or skull included | Low contrast, unusual anatomy |
|
|
131
|
+
| WM segmentation error | WM extends into GM | Motion blur, bias field |
|
|
132
|
+
| Pial surface error | Surface extends into skull | Dura enhancement, blood vessels |
|
|
133
|
+
| Subcortical mislabel | Asymmetric volumes | Poor contrast, pathology |
|
|
134
|
+
|
|
135
|
+
### FreeSurfer QC Tools
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# View segmentation overlaid on T1
|
|
139
|
+
freeview -v $SUBJECTS_DIR/$subj/mri/T1.mgz \
|
|
140
|
+
-v $SUBJECTS_DIR/$subj/mri/aseg.mgz:colormap=lut
|
|
141
|
+
|
|
142
|
+
# View surfaces
|
|
143
|
+
freeview -v $SUBJECTS_DIR/$subj/mri/T1.mgz \
|
|
144
|
+
-f $SUBJECTS_DIR/$subj/surf/lh.white:edgecolor=yellow \
|
|
145
|
+
-f $SUBJECTS_DIR/$subj/surf/lh.pial:edgecolor=red
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Registration QC
|
|
149
|
+
|
|
150
|
+
### Checking Normalization Quality
|
|
151
|
+
|
|
152
|
+
**Metrics:**
|
|
153
|
+
- Cost function value (mutual information, correlation ratio)
|
|
154
|
+
- Overlap with template (Dice, Jaccard)
|
|
155
|
+
- Landmark alignment
|
|
156
|
+
|
|
157
|
+
**Visual checks:**
|
|
158
|
+
```
|
|
159
|
+
□ Major structures aligned (ventricles, sulci)
|
|
160
|
+
□ No gross misalignment
|
|
161
|
+
□ Edges match template
|
|
162
|
+
□ Subcortical structures properly positioned
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Common Registration Failures
|
|
166
|
+
|
|
167
|
+
1. **Local minima**: Registration stuck in wrong position
|
|
168
|
+
2. **Scaling errors**: Brain too large/small
|
|
169
|
+
3. **Rotation errors**: Tilted relative to template
|
|
170
|
+
4. **Shearing**: Non-rigid distortion
|
|
171
|
+
|
|
172
|
+
### Template Considerations
|
|
173
|
+
|
|
174
|
+
**Adults:**
|
|
175
|
+
- MNI152 (most common)
|
|
176
|
+
- Colin27
|
|
177
|
+
- fsaverage (surface)
|
|
178
|
+
|
|
179
|
+
**Pediatric:**
|
|
180
|
+
- Pediatric templates (e.g., NIH Pediatric MRI Database)
|
|
181
|
+
- Age-specific templates
|
|
182
|
+
|
|
183
|
+
**Infants:**
|
|
184
|
+
- UNC infant templates
|
|
185
|
+
- MCRIB
|
|
186
|
+
- dHCP templates (Developing Human Connectome Project)
|
|
187
|
+
|
|
188
|
+
## Segmentation QC
|
|
189
|
+
|
|
190
|
+
### Tissue Segmentation Checks
|
|
191
|
+
|
|
192
|
+
**CSF:**
|
|
193
|
+
- Present in ventricles and sulci
|
|
194
|
+
- Not extending into parenchyma
|
|
195
|
+
|
|
196
|
+
**Gray matter:**
|
|
197
|
+
- Follows cortical ribbon
|
|
198
|
+
- Proper thickness (~2-4mm in adults)
|
|
199
|
+
|
|
200
|
+
**White matter:**
|
|
201
|
+
- Connected through centrum semiovale
|
|
202
|
+
- Follows expected pattern
|
|
203
|
+
|
|
204
|
+
### Volume Plausibility
|
|
205
|
+
|
|
206
|
+
**Adult brain volumes (approximate):**
|
|
207
|
+
| Structure | Typical Volume | Flag if... |
|
|
208
|
+
|-----------|---------------|------------|
|
|
209
|
+
| Total brain | 1200-1500 cm³ | <1000 or >1700 |
|
|
210
|
+
| Cerebral WM | 400-600 cm³ | Outlier |
|
|
211
|
+
| Cortical GM | 550-750 cm³ | Outlier |
|
|
212
|
+
| Hippocampus (each) | 3-4 cm³ | <2 or >5 |
|
|
213
|
+
| Lateral ventricle (each) | 7-20 cm³ | >40 (unless elderly) |
|
|
214
|
+
|
|
215
|
+
**Note**: Volumes vary with age, sex, and pathology.
|
|
216
|
+
|
|
217
|
+
## Population Considerations
|
|
218
|
+
|
|
219
|
+
### Infants
|
|
220
|
+
|
|
221
|
+
**Challenges:**
|
|
222
|
+
- Inverted T1 contrast (myelination incomplete)
|
|
223
|
+
- Rapidly changing brain size
|
|
224
|
+
- Different templates needed
|
|
225
|
+
|
|
226
|
+
**QC considerations:**
|
|
227
|
+
- Use age-appropriate templates
|
|
228
|
+
- Expect different metric distributions
|
|
229
|
+
- Visual QC more critical
|
|
230
|
+
|
|
231
|
+
### Elderly
|
|
232
|
+
|
|
233
|
+
**Considerations:**
|
|
234
|
+
- Atrophy affects volumes and segmentation
|
|
235
|
+
- WM hyperintensities affect contrast
|
|
236
|
+
- Enlarged ventricles normal
|
|
237
|
+
|
|
238
|
+
**Adjusted criteria:**
|
|
239
|
+
- Larger acceptable ventricle volumes
|
|
240
|
+
- Lower GM volumes expected
|
|
241
|
+
- Check for WMH handling
|
|
242
|
+
|
|
243
|
+
### Clinical Populations
|
|
244
|
+
|
|
245
|
+
**Considerations:**
|
|
246
|
+
- Lesions may affect segmentation
|
|
247
|
+
- Atrophy patterns disease-specific
|
|
248
|
+
- May need lesion masking
|
|
249
|
+
|
|
250
|
+
## Python Examples
|
|
251
|
+
|
|
252
|
+
### Parse MRIQC Anatomical Output
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
import pandas as pd
|
|
256
|
+
import numpy as np
|
|
257
|
+
|
|
258
|
+
def load_mriqc_anat(group_tsv_path):
|
|
259
|
+
"""Load MRIQC T1w group TSV."""
|
|
260
|
+
df = pd.read_csv(group_tsv_path, sep='\t')
|
|
261
|
+
return df
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def flag_anat_outliers(df, zscore_threshold=3):
|
|
265
|
+
"""
|
|
266
|
+
Flag subjects with outlier anatomical IQMs.
|
|
267
|
+
"""
|
|
268
|
+
metrics_to_check = ['qi_1', 'cnr', 'cjv', 'efc', 'snr_gm', 'snr_wm']
|
|
269
|
+
|
|
270
|
+
# Direction: higher is worse for these
|
|
271
|
+
higher_worse = ['qi_1', 'cjv', 'efc']
|
|
272
|
+
# Direction: lower is worse for these
|
|
273
|
+
lower_worse = ['cnr', 'snr_gm', 'snr_wm']
|
|
274
|
+
|
|
275
|
+
flags = pd.DataFrame(index=df.index)
|
|
276
|
+
|
|
277
|
+
for metric in metrics_to_check:
|
|
278
|
+
if metric not in df.columns:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
z = (df[metric] - df[metric].mean()) / df[metric].std()
|
|
282
|
+
|
|
283
|
+
if metric in higher_worse:
|
|
284
|
+
flags[f'outlier_{metric}'] = z > zscore_threshold
|
|
285
|
+
else: # lower_worse
|
|
286
|
+
flags[f'outlier_{metric}'] = z < -zscore_threshold
|
|
287
|
+
|
|
288
|
+
flags['any_outlier'] = flags.any(axis=1)
|
|
289
|
+
|
|
290
|
+
return flags
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def anat_qc_report(df, flags):
|
|
294
|
+
"""Generate anatomical QC summary."""
|
|
295
|
+
n_total = len(df)
|
|
296
|
+
n_outliers = flags['any_outlier'].sum()
|
|
297
|
+
|
|
298
|
+
report = f"""
|
|
299
|
+
Anatomical MRI QC Summary
|
|
300
|
+
=========================
|
|
301
|
+
|
|
302
|
+
Total subjects: {n_total}
|
|
303
|
+
Outliers detected: {n_outliers} ({100*n_outliers/n_total:.1f}%)
|
|
304
|
+
|
|
305
|
+
Metric distributions:
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
for col in ['qi_1', 'cnr', 'cjv', 'snr_gm']:
|
|
309
|
+
if col in df.columns:
|
|
310
|
+
report += f"\n{col}:\n"
|
|
311
|
+
report += f" Mean: {df[col].mean():.3f}\n"
|
|
312
|
+
report += f" Std: {df[col].std():.3f}\n"
|
|
313
|
+
report += f" Range: [{df[col].min():.3f}, {df[col].max():.3f}]\n"
|
|
314
|
+
|
|
315
|
+
return report
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### FreeSurfer QC Extraction
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
import os
|
|
322
|
+
import subprocess
|
|
323
|
+
|
|
324
|
+
def get_euler_number(subjects_dir, subject):
|
|
325
|
+
"""Extract Euler number from FreeSurfer output."""
|
|
326
|
+
euler_lh = None
|
|
327
|
+
euler_rh = None
|
|
328
|
+
|
|
329
|
+
# Read from log file or compute
|
|
330
|
+
log_file = os.path.join(subjects_dir, subject, 'scripts', 'recon-all.log')
|
|
331
|
+
|
|
332
|
+
if os.path.exists(log_file):
|
|
333
|
+
with open(log_file, 'r') as f:
|
|
334
|
+
for line in f:
|
|
335
|
+
if 'euler' in line.lower():
|
|
336
|
+
# Parse euler number from log
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Alternative: compute directly
|
|
340
|
+
for hemi in ['lh', 'rh']:
|
|
341
|
+
surf_path = os.path.join(subjects_dir, subject, 'surf', f'{hemi}.orig')
|
|
342
|
+
if os.path.exists(surf_path):
|
|
343
|
+
result = subprocess.run(
|
|
344
|
+
['mris_euler_number', surf_path],
|
|
345
|
+
capture_output=True, text=True
|
|
346
|
+
)
|
|
347
|
+
# Parse output
|
|
348
|
+
|
|
349
|
+
return euler_lh, euler_rh
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def get_fs_volumes(subjects_dir, subject):
|
|
353
|
+
"""Extract key volumes from FreeSurfer stats."""
|
|
354
|
+
stats_file = os.path.join(subjects_dir, subject, 'stats', 'aseg.stats')
|
|
355
|
+
|
|
356
|
+
volumes = {}
|
|
357
|
+
|
|
358
|
+
if os.path.exists(stats_file):
|
|
359
|
+
with open(stats_file, 'r') as f:
|
|
360
|
+
for line in f:
|
|
361
|
+
if line.startswith('#'):
|
|
362
|
+
continue
|
|
363
|
+
parts = line.strip().split()
|
|
364
|
+
if len(parts) >= 4:
|
|
365
|
+
struct_name = parts[4] if len(parts) > 4 else parts[0]
|
|
366
|
+
volume = float(parts[3])
|
|
367
|
+
volumes[struct_name] = volume
|
|
368
|
+
|
|
369
|
+
return volumes
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def check_fs_qc(subjects_dir, subject):
|
|
373
|
+
"""Comprehensive FreeSurfer QC check."""
|
|
374
|
+
qc_results = {
|
|
375
|
+
'subject': subject,
|
|
376
|
+
'passed': True,
|
|
377
|
+
'issues': []
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
# Check if recon-all completed
|
|
381
|
+
done_file = os.path.join(subjects_dir, subject, 'scripts', 'recon-all.done')
|
|
382
|
+
if not os.path.exists(done_file):
|
|
383
|
+
qc_results['passed'] = False
|
|
384
|
+
qc_results['issues'].append('recon-all did not complete')
|
|
385
|
+
return qc_results
|
|
386
|
+
|
|
387
|
+
# Check Euler numbers
|
|
388
|
+
euler_lh, euler_rh = get_euler_number(subjects_dir, subject)
|
|
389
|
+
if euler_lh is not None and euler_lh < -200:
|
|
390
|
+
qc_results['issues'].append(f'Low Euler LH: {euler_lh}')
|
|
391
|
+
if euler_rh is not None and euler_rh < -200:
|
|
392
|
+
qc_results['issues'].append(f'Low Euler RH: {euler_rh}')
|
|
393
|
+
|
|
394
|
+
# Check volumes
|
|
395
|
+
volumes = get_fs_volumes(subjects_dir, subject)
|
|
396
|
+
|
|
397
|
+
# Example: check for extreme hippocampal volumes
|
|
398
|
+
for hemi in ['Left', 'Right']:
|
|
399
|
+
hipp_key = f'{hemi}-Hippocampus'
|
|
400
|
+
if hipp_key in volumes:
|
|
401
|
+
vol = volumes[hipp_key]
|
|
402
|
+
if vol < 2000 or vol > 5500: # in mm³
|
|
403
|
+
qc_results['issues'].append(f'Unusual {hemi} hippocampus: {vol:.0f} mm³')
|
|
404
|
+
|
|
405
|
+
if qc_results['issues']:
|
|
406
|
+
qc_results['passed'] = False
|
|
407
|
+
|
|
408
|
+
return qc_results
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Visual QC Screenshot Generation
|
|
412
|
+
|
|
413
|
+
```python
|
|
414
|
+
def generate_qc_screenshots(subjects_dir, subject, output_dir):
|
|
415
|
+
"""Generate QC screenshots using FreeSurfer tools."""
|
|
416
|
+
import subprocess
|
|
417
|
+
|
|
418
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
419
|
+
|
|
420
|
+
# Axial slices with segmentation overlay
|
|
421
|
+
for slice_num in [80, 100, 120, 140]:
|
|
422
|
+
output_file = os.path.join(output_dir, f'{subject}_axial_{slice_num}.png')
|
|
423
|
+
cmd = [
|
|
424
|
+
'freeview',
|
|
425
|
+
'-v', f'{subjects_dir}/{subject}/mri/T1.mgz',
|
|
426
|
+
'-v', f'{subjects_dir}/{subject}/mri/aseg.mgz:colormap=lut:opacity=0.3',
|
|
427
|
+
'-slice', '0', '0', str(slice_num),
|
|
428
|
+
'-viewport', 'axial',
|
|
429
|
+
'-ss', output_file,
|
|
430
|
+
'-quit'
|
|
431
|
+
]
|
|
432
|
+
subprocess.run(cmd)
|
|
433
|
+
|
|
434
|
+
# Surface views
|
|
435
|
+
for hemi in ['lh', 'rh']:
|
|
436
|
+
for view in ['lateral', 'medial']:
|
|
437
|
+
output_file = os.path.join(output_dir, f'{subject}_{hemi}_{view}.png')
|
|
438
|
+
cmd = [
|
|
439
|
+
'freeview',
|
|
440
|
+
'-f', f'{subjects_dir}/{subject}/surf/{hemi}.pial:overlay={subjects_dir}/{subject}/surf/{hemi}.thickness',
|
|
441
|
+
'-viewport', '3d',
|
|
442
|
+
'-cam', 'azimuth', '0' if view == 'lateral' else '180',
|
|
443
|
+
'-ss', output_file,
|
|
444
|
+
'-quit'
|
|
445
|
+
]
|
|
446
|
+
subprocess.run(cmd)
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## Key References
|
|
450
|
+
|
|
451
|
+
- Esteban O et al. (2017). MRIQC: Advancing the automatic prediction of image quality in MRI from unseen sites. PLOS ONE 12(9):e0184661.
|
|
452
|
+
- Rosen AFG et al. (2018). Quantitative assessment of structural image quality. NeuroImage 169:407-418.
|
|
453
|
+
- Fischl B. (2012). FreeSurfer. NeuroImage 62(2):774-781.
|
|
454
|
+
- Klapwijk ET et al. (2019). Qoala-T: A supervised-learning tool for quality control of FreeSurfer segmented MRI data. NeuroImage 189:116-129.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Parse fMRIPrep confounds and generate subject-level QC summaries.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python parse_fmriprep_confounds.py /path/to/fmriprep/output --fd-thresh 0.5 -o qc.csv
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import numpy as np
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_confounds_files(fmriprep_dir: Path) -> list:
|
|
17
|
+
"""Find all confounds TSV files in fMRIPrep output."""
|
|
18
|
+
return list(fmriprep_dir.rglob('*_desc-confounds_timeseries.tsv'))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def summarize_confounds(confounds_path: Path, fd_thresh: float = 0.5) -> dict:
|
|
22
|
+
"""
|
|
23
|
+
Summarize a single confounds file.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
confounds_path : Path
|
|
28
|
+
Path to confounds TSV
|
|
29
|
+
fd_thresh : float
|
|
30
|
+
FD threshold for counting high-motion volumes
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
dict
|
|
35
|
+
Summary statistics
|
|
36
|
+
"""
|
|
37
|
+
df = pd.read_csv(confounds_path, sep='\t')
|
|
38
|
+
|
|
39
|
+
# Parse BIDS entities from filename
|
|
40
|
+
name = confounds_path.stem.replace('_desc-confounds_timeseries', '')
|
|
41
|
+
|
|
42
|
+
result = {'bids_name': name, 'confounds_file': str(confounds_path)}
|
|
43
|
+
|
|
44
|
+
# FD statistics
|
|
45
|
+
if 'framewise_displacement' in df.columns:
|
|
46
|
+
fd = df['framewise_displacement'].dropna()
|
|
47
|
+
result['fd_mean'] = fd.mean()
|
|
48
|
+
result['fd_max'] = fd.max()
|
|
49
|
+
result['fd_std'] = fd.std()
|
|
50
|
+
result['n_high_fd'] = (fd > fd_thresh).sum()
|
|
51
|
+
result['fd_perc'] = 100 * result['n_high_fd'] / len(fd)
|
|
52
|
+
|
|
53
|
+
# DVARS statistics
|
|
54
|
+
if 'std_dvars' in df.columns:
|
|
55
|
+
dvars = df['std_dvars'].dropna()
|
|
56
|
+
result['dvars_mean'] = dvars.mean()
|
|
57
|
+
result['dvars_max'] = dvars.max()
|
|
58
|
+
|
|
59
|
+
# Count motion outliers if present
|
|
60
|
+
outlier_cols = [c for c in df.columns if c.startswith('motion_outlier')]
|
|
61
|
+
if outlier_cols:
|
|
62
|
+
result['n_motion_outliers'] = df[outlier_cols].sum().sum()
|
|
63
|
+
|
|
64
|
+
# Non-steady state volumes
|
|
65
|
+
nss_cols = [c for c in df.columns if c.startswith('non_steady_state')]
|
|
66
|
+
result['n_non_steady_state'] = len(nss_cols)
|
|
67
|
+
|
|
68
|
+
# Total volumes
|
|
69
|
+
result['n_volumes'] = len(df)
|
|
70
|
+
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def process_fmriprep_dir(fmriprep_dir: Path, fd_thresh: float = 0.5) -> pd.DataFrame:
|
|
75
|
+
"""Process all subjects in fMRIPrep output directory."""
|
|
76
|
+
confounds_files = find_confounds_files(fmriprep_dir)
|
|
77
|
+
|
|
78
|
+
if not confounds_files:
|
|
79
|
+
raise FileNotFoundError(f"No confounds files found in {fmriprep_dir}")
|
|
80
|
+
|
|
81
|
+
summaries = []
|
|
82
|
+
for cf in confounds_files:
|
|
83
|
+
try:
|
|
84
|
+
summary = summarize_confounds(cf, fd_thresh)
|
|
85
|
+
summaries.append(summary)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"Error processing {cf}: {e}")
|
|
88
|
+
|
|
89
|
+
return pd.DataFrame(summaries)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def flag_subjects(df: pd.DataFrame, fd_mean_thresh: float, fd_perc_thresh: float) -> pd.DataFrame:
|
|
93
|
+
"""Add exclusion flags to summary DataFrame."""
|
|
94
|
+
df = df.copy()
|
|
95
|
+
|
|
96
|
+
df['flag_fd_mean'] = df['fd_mean'] > fd_mean_thresh
|
|
97
|
+
df['flag_fd_perc'] = df['fd_perc'] > fd_perc_thresh
|
|
98
|
+
|
|
99
|
+
df['exclude'] = df['flag_fd_mean'] | df['flag_fd_perc']
|
|
100
|
+
|
|
101
|
+
return df
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate_report(df: pd.DataFrame, fd_mean_thresh: float, fd_perc_thresh: float) -> str:
|
|
105
|
+
"""Generate text report."""
|
|
106
|
+
n_total = len(df)
|
|
107
|
+
n_exclude = df['exclude'].sum() if 'exclude' in df.columns else 0
|
|
108
|
+
|
|
109
|
+
report = f"""
|
|
110
|
+
fMRIPrep Confounds QC Summary
|
|
111
|
+
=============================
|
|
112
|
+
Total runs: {n_total}
|
|
113
|
+
Thresholds: fd_mean < {fd_mean_thresh}mm, fd_perc < {fd_perc_thresh}%
|
|
114
|
+
|
|
115
|
+
Exclusions: {n_exclude} ({100*n_exclude/n_total:.1f}%)
|
|
116
|
+
|
|
117
|
+
FD Statistics (all runs):
|
|
118
|
+
Mean: {df['fd_mean'].mean():.3f} mm
|
|
119
|
+
Std: {df['fd_mean'].std():.3f} mm
|
|
120
|
+
Range: [{df['fd_mean'].min():.3f}, {df['fd_mean'].max():.3f}]
|
|
121
|
+
|
|
122
|
+
High-motion volume % (all runs):
|
|
123
|
+
Mean: {df['fd_perc'].mean():.1f}%
|
|
124
|
+
Max: {df['fd_perc'].max():.1f}%
|
|
125
|
+
"""
|
|
126
|
+
return report
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main():
|
|
130
|
+
parser = argparse.ArgumentParser(description='Parse fMRIPrep confounds for QC')
|
|
131
|
+
parser.add_argument('fmriprep_dir', help='fMRIPrep output directory')
|
|
132
|
+
parser.add_argument('--fd-thresh', type=float, default=0.5, help='FD threshold (mm)')
|
|
133
|
+
parser.add_argument('--fd-mean-thresh', type=float, default=0.3, help='Max mean FD')
|
|
134
|
+
parser.add_argument('--fd-perc-thresh', type=float, default=30, help='Max FD %')
|
|
135
|
+
parser.add_argument('-o', '--output', help='Output CSV path')
|
|
136
|
+
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
|
|
139
|
+
fmriprep_dir = Path(args.fmriprep_dir)
|
|
140
|
+
|
|
141
|
+
print(f"Processing {fmriprep_dir}...")
|
|
142
|
+
df = process_fmriprep_dir(fmriprep_dir, args.fd_thresh)
|
|
143
|
+
df = flag_subjects(df, args.fd_mean_thresh, args.fd_perc_thresh)
|
|
144
|
+
|
|
145
|
+
print(generate_report(df, args.fd_mean_thresh, args.fd_perc_thresh))
|
|
146
|
+
|
|
147
|
+
if args.output:
|
|
148
|
+
df.to_csv(args.output, index=False)
|
|
149
|
+
print(f"Results saved to {args.output}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == '__main__':
|
|
153
|
+
main()
|