Skip to content

Commit 18985eb

Browse files
tamsinrogersnx10
andauthored
Add module docstrings Fix #7 (#38)
* init docstrings * Add some more context --------- Co-authored-by: Florian Rupprecht <33600480+nx10@users.noreply.github.com>
1 parent 95999a8 commit 18985eb

12 files changed

Lines changed: 137 additions & 65 deletions

File tree

rbc_qc_metrics_guide.md

Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ import numpy as np
5353
def calculate_FD_J_from_rms(rms_file):
5454
"""
5555
Calculate FD-Jenkinson from MCFLIRT *_rel.rms output.
56-
56+
5757
Parameters
5858
----------
5959
rms_file : str
6060
Path to *_rel.rms file from MCFLIRT
61-
61+
6262
Returns
6363
-------
6464
fdj : np.ndarray
@@ -82,29 +82,29 @@ import numpy as np
8282
def calculate_FD_P(motion_params_file):
8383
"""
8484
Calculate FD-Power from 6-parameter motion file.
85-
85+
8686
Parameters
8787
----------
8888
motion_params_file : str
8989
Path to motion parameters file (6 columns: 3 rotation, 3 translation)
9090
Format: [rot_x, rot_y, rot_z, trans_x, trans_y, trans_z] per row
91-
91+
9292
Returns
9393
-------
9494
fd : np.ndarray
9595
Framewise displacement array
9696
"""
9797
motion_params = np.genfromtxt(motion_params_file).T
98-
98+
9999
# Rotations (first 3 params) - convert to mm using 50mm sphere radius
100100
rotations = np.transpose(np.abs(np.diff(motion_params[0:3, :])))
101101
# Translations (last 3 params)
102102
translations = np.transpose(np.abs(np.diff(motion_params[3:6, :])))
103-
103+
104104
# FD = sum of translations + (50mm * pi/180) * sum of rotations
105105
fd = np.sum(translations, axis=1) + (50 * np.pi / 180) * np.sum(rotations, axis=1)
106106
fd = np.insert(fd, 0, 0) # Prepend 0 for first volume
107-
107+
108108
return fd
109109
```
110110

@@ -118,12 +118,12 @@ import numpy as np
118118
def calculate_rms_motion(motion_params_file):
119119
"""
120120
Calculate RMS of translation parameters.
121-
121+
122122
Parameters
123123
----------
124124
motion_params_file : str
125125
Path to motion parameters (6 columns)
126-
126+
127127
Returns
128128
-------
129129
mean_rms : float
@@ -132,10 +132,10 @@ def calculate_rms_motion(motion_params_file):
132132
Maximum RMS motion
133133
"""
134134
mot = np.genfromtxt(motion_params_file).T
135-
135+
136136
# RMS of translation parameters (columns 3, 4, 5 for x, y, z translation)
137137
rms = np.sqrt(mot[3]**2 + mot[4]**2 + mot[5]**2)
138-
138+
139139
return np.mean(rms), np.max(rms)
140140
```
141141

@@ -156,14 +156,14 @@ import nibabel as nib
156156
def calculate_DVARS(func_file, mask_file):
157157
"""
158158
Calculate DVARS (temporal derivative of RMS variance).
159-
159+
160160
Parameters
161161
----------
162162
func_file : str
163163
Path to 4D functional image
164164
mask_file : str
165165
Path to brain mask
166-
166+
167167
Returns
168168
-------
169169
dvars : np.ndarray
@@ -173,22 +173,22 @@ def calculate_DVARS(func_file, mask_file):
173173
"""
174174
func_data = nib.load(func_file).get_fdata().astype(np.float32)
175175
mask_data = nib.load(mask_file).get_fdata().astype(bool)
176-
176+
177177
# Temporal difference (derivative)
178178
diff_data = np.diff(func_data, axis=3)
179-
179+
180180
# Square of differences
181181
squared_diff = np.square(diff_data)
182-
182+
183183
# Apply mask
184184
masked_data = squared_diff[mask_data]
185-
185+
186186
# RMS across voxels for each timepoint
187187
dvars = np.sqrt(np.mean(masked_data, axis=0))
188-
188+
189189
# Prepend 0 for first volume
190190
dvars = np.insert(dvars, 0, 0)
191-
191+
192192
return dvars, np.mean(dvars)
193193
```
194194

@@ -207,33 +207,33 @@ import numpy as np
207207
def calculate_motion_dvars_correlation(dvars_file, fdj_file):
208208
"""
209209
Calculate correlation between DVARS and FD-Jenkinson.
210-
210+
211211
Note: DVARS has N-1 values (no derivative for first volume),
212212
so we correlate with FD[1:] (skipping first FD value).
213-
213+
214214
Parameters
215215
----------
216216
dvars_file : str
217217
Path to DVARS 1D file
218218
fdj_file : str
219219
Path to FD-Jenkinson 1D file
220-
220+
221221
Returns
222222
-------
223223
correlation : float
224224
Pearson correlation coefficient
225225
"""
226226
dvars = np.loadtxt(dvars_file)
227227
fdj = np.loadtxt(fdj_file)
228-
228+
229229
# DVARS is 1 shorter than FD (or both have leading 0)
230230
# If both have leading 0, skip it
231231
if len(dvars) == len(fdj):
232232
dvars = dvars[1:]
233233
fdj = fdj[1:]
234234
elif len(dvars) == len(fdj) - 1:
235235
fdj = fdj[1:]
236-
236+
237237
return np.corrcoef(dvars, fdj)[0, 1]
238238
```
239239

@@ -261,21 +261,21 @@ import nibabel as nib
261261
def dice_coefficient(mask1_file, mask2_file):
262262
"""
263263
Calculate Dice coefficient between two binary masks.
264-
264+
265265
DC = 2|A ∩ B| / (|A| + |B|)
266-
266+
267267
Range: 0 (no overlap) to 1 (perfect overlap)
268268
"""
269269
mask1 = nib.load(mask1_file).get_fdata().astype(bool)
270270
mask2 = nib.load(mask2_file).get_fdata().astype(bool)
271-
271+
272272
intersection = np.count_nonzero(mask1 & mask2)
273273
size1 = np.count_nonzero(mask1)
274274
size2 = np.count_nonzero(mask2)
275-
275+
276276
if size1 + size2 == 0:
277277
return 0.0
278-
278+
279279
return 2.0 * intersection / (size1 + size2)
280280
```
281281

@@ -285,20 +285,20 @@ def dice_coefficient(mask1_file, mask2_file):
285285
def jaccard_coefficient(mask1_file, mask2_file):
286286
"""
287287
Calculate Jaccard coefficient between two binary masks.
288-
288+
289289
JC = |A ∩ B| / |A ∪ B|
290-
290+
291291
Range: 0 (no overlap) to 1 (perfect overlap)
292292
"""
293293
mask1 = nib.load(mask1_file).get_fdata().astype(bool)
294294
mask2 = nib.load(mask2_file).get_fdata().astype(bool)
295-
295+
296296
intersection = np.count_nonzero(mask1 & mask2)
297297
union = np.count_nonzero(mask1 | mask2)
298-
298+
299299
if union == 0:
300300
return 0.0
301-
301+
302302
return intersection / union
303303
```
304304

@@ -311,7 +311,7 @@ def cross_correlation(mask1_file, mask2_file):
311311
"""
312312
mask1 = nib.load(mask1_file).get_fdata().astype(bool).flatten()
313313
mask2 = nib.load(mask2_file).get_fdata().astype(bool).flatten()
314-
314+
315315
return np.corrcoef(mask1, mask2)[0, 1]
316316
```
317317

@@ -321,18 +321,18 @@ def cross_correlation(mask1_file, mask2_file):
321321
def coverage(mask1_file, mask2_file):
322322
"""
323323
Calculate coverage: intersection / smaller mask.
324-
324+
325325
Measures how much of the smaller mask is covered by the overlap.
326326
"""
327327
mask1 = nib.load(mask1_file).get_fdata().astype(bool)
328328
mask2 = nib.load(mask2_file).get_fdata().astype(bool)
329-
329+
330330
intersection = np.count_nonzero(mask1 & mask2)
331331
smaller_size = min(np.count_nonzero(mask1), np.count_nonzero(mask2))
332-
332+
333333
if smaller_size == 0:
334334
return 0.0
335-
335+
336336
return intersection / smaller_size
337337
```
338338

@@ -348,7 +348,7 @@ Number of volumes flagged for censoring based on motion/DVARS thresholds.
348348
def count_censored_volumes(fd, dvars, fd_threshold=0.2, dvars_threshold=None):
349349
"""
350350
Count volumes exceeding motion thresholds.
351-
351+
352352
Parameters
353353
----------
354354
fd : np.ndarray
@@ -359,7 +359,7 @@ def count_censored_volumes(fd, dvars, fd_threshold=0.2, dvars_threshold=None):
359359
FD threshold (RBC uses 0.2 mm)
360360
dvars_threshold : float, optional
361361
DVARS threshold
362-
362+
363363
Returns
364364
-------
365365
n_censored : int
@@ -368,10 +368,10 @@ def count_censored_volumes(fd, dvars, fd_threshold=0.2, dvars_threshold=None):
368368
Indices of censored volumes
369369
"""
370370
censor_mask = fd > fd_threshold
371-
371+
372372
if dvars_threshold is not None:
373373
censor_mask |= dvars > dvars_threshold
374-
374+
375375
return np.sum(censor_mask), np.where(censor_mask)[0].tolist()
376376
```
377377

@@ -407,7 +407,7 @@ def generate_xcp_qc(
407407
):
408408
"""
409409
Generate XCP-style QC metrics TSV.
410-
410+
411411
Returns
412412
-------
413413
qc_dict : dict
@@ -416,46 +416,46 @@ def generate_xcp_qc(
416416
# Motion metrics
417417
fdj = np.loadtxt(fd_jenkinson_file)
418418
mean_fd = np.mean(fdj)
419-
419+
420420
mot = np.genfromtxt(motion_params_file).T
421421
rms = np.sqrt(mot[3]**2 + mot[4]**2 + mot[5]**2)
422-
422+
423423
# DVARS
424424
dvars_init = np.loadtxt(dvars_before_file)
425425
dvars_final = np.loadtxt(dvars_after_file)
426-
426+
427427
# Motion-DVARS correlation
428428
def dvcorr(dvars, fd):
429429
if len(dvars) == len(fd):
430430
return np.corrcoef(dvars[1:], fd[1:])[0, 1]
431431
return np.corrcoef(dvars, fd[1:])[0, 1]
432-
432+
433433
# Registration metrics
434434
def load_mask(f):
435435
return nib.load(f).get_fdata().astype(bool)
436-
436+
437437
def dice(m1, m2):
438438
inter = np.count_nonzero(m1 & m2)
439439
return 2.0 * inter / (np.count_nonzero(m1) + np.count_nonzero(m2))
440-
440+
441441
def jaccard(m1, m2):
442442
return np.count_nonzero(m1 & m2) / np.count_nonzero(m1 | m2)
443-
443+
444444
def crosscorr(m1, m2):
445445
return np.corrcoef(m1.flatten(), m2.flatten())[0, 1]
446-
446+
447447
def coverage(m1, m2):
448448
inter = np.count_nonzero(m1 & m2)
449449
return inter / min(np.count_nonzero(m1), np.count_nonzero(m2))
450-
450+
451451
# Load masks
452452
coreg_m1, coreg_m2 = load_mask(bold2t1w_mask), load_mask(t1w_mask)
453453
norm_m1, norm_m2 = load_mask(bold2template_mask), load_mask(template_mask)
454-
454+
455455
# Volume counts
456456
orig_nvols = nib.load(original_func).shape[3]
457457
final_nvols = nib.load(final_func).shape[3]
458-
458+
459459
return {
460460
'sub': sub, 'ses': ses, 'task': task, 'run': run,
461461
'desc': desc, 'regressors': regressors, 'space': space,
@@ -496,22 +496,22 @@ From the RBC paper (Shafiei et al., Neuron 2025):
496496
def passes_rbc_qc(qc_dict, fd_timeseries):
497497
"""
498498
Apply RBC recommended QC thresholds.
499-
499+
500500
Parameters
501501
----------
502502
qc_dict : dict
503503
Output from generate_xcp_qc
504504
fd_timeseries : np.ndarray
505505
Full FD timeseries (for median calculation)
506-
506+
507507
Returns
508508
-------
509509
passes : bool
510510
True if scan passes QC
511511
"""
512512
median_fd = np.median(fd_timeseries)
513513
norm_cc = qc_dict['normCrossCorr']
514-
514+
515515
return (median_fd <= 0.2) and (norm_cc >= 0.8)
516516
```
517517

0 commit comments

Comments
 (0)