@@ -53,12 +53,12 @@ import numpy as np
5353def 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
8282def 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
118118def 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
156156def 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
207207def 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
261261def 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):
285285def 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):
321321def 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.
348348def 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):
496496def 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