Motion Quality Control and Framewise Displacement Summaries
Michael Hallquist
31 Aug 2026
Source:vignettes/motion_qc.Rmd
motion_qc.RmdOverview
Framewise displacement (FD) reduces the change in six rigid-body
motion parameters between consecutive volumes to one displacement value
in millimetres. calculate_motion_outliers() summarizes an
fMRIPrep confounds file as one row per BOLD run, including maximum FD,
mean FD, and the percentage of volumes above one or more user-supplied
thresholds.
This is an interactive quality-control helper. It reads confounds files and can write a run-level summary table, but it does not submit jobs, change a project configuration, create censor files, or alter BOLD data. Later sections explain how its summaries can inform a separately configured scrubbing policy.
Choose the confounds files
There are three ways to select input files:
# Search the fMRIPrep directory recorded in a project configuration.
scfg <- load_project("/project/my_study")
qc <- calculate_motion_outliers(scfg = scfg)
# Search a directory recursively.
qc <- calculate_motion_outliers(input_dir = "/project/my_study/data_fmriprep")
# Summarize an explicit set of files.
qc <- calculate_motion_outliers(confounds_files = c(file1, file2))An explicit confounds_files vector takes precedence.
Directory searches match fMRIPrep files ending in
_desc-confounds_timeseries.tsv or
_desc-confounds_regressors.tsv, including gzip-compressed
variants. Subject, session, task, and run identifiers are parsed from
each BIDS-style filename.
A reproducible example
The following synthetic files make this vignette executable without study data, a scheduler, or a container. They contain a slow component, an oscillation in a respiratory-frequency band, and isolated motion changes. The demonstration FD column uses the same 50 mm rotational radius used by the helper when it must recompute raw FD.
demo_dir <- tempfile("braingnomes_motion_qc_")
dir.create(demo_dir)
make_demo_confounds <- function(subject, spike_size) {
n_volumes <- 120L
volume <- seq_len(n_volumes)
respiration <- sin(2 * pi * volume / 4)
slow_drift <- sin(2 * pi * volume / 48)
motion <- data.frame(
rot_x = 0.0015 * respiration + 0.0005 * slow_drift,
rot_y = 0.0010 * cos(2 * pi * volume / 4),
rot_z = 0.0004 * slow_drift,
trans_x = 0.06 * respiration + 0.02 * slow_drift,
trans_y = 0.03 * cos(2 * pi * volume / 4),
trans_z = 0.01 * slow_drift
)
motion$trans_x[c(45L, 88L)] <-
motion$trans_x[c(45L, 88L)] + c(spike_size, -spike_size)
differences <- rbind(0, diff(as.matrix(motion)))
motion$framewise_displacement <-
rowSums(abs(differences[, 1:3, drop = FALSE])) * 50 +
rowSums(abs(differences[, 4:6, drop = FALSE]))
output <- file.path(
demo_dir,
sprintf(
"sub-%s_ses-01_task-rest_run-01_desc-confounds_timeseries.tsv",
subject
)
)
utils::write.table(
motion,
file = output,
sep = "\t",
quote = FALSE,
row.names = FALSE,
na = "n/a"
)
output
}
demo_files <- c(
make_demo_confounds("01", spike_size = 0.35),
make_demo_confounds("02", spike_size = 0.80)
)
basename(demo_files)
#> [1] "sub-01_ses-01_task-rest_run-01_desc-confounds_timeseries.tsv"
#> [2] "sub-02_ses-01_task-rest_run-01_desc-confounds_timeseries.tsv"Raw FD threshold summaries
Supply several thresholds when you want to inspect the distribution
without committing to one rule. Thresholds are in millimetres, and
comparisons are strictly greater than the requested value. Thus,
fd_gt_0p5 is the percentage of non-missing FD observations
for which FD > 0.5, not FD >= 0.5.
raw_qc <- calculate_motion_outliers(
confounds_files = demo_files,
thresholds = c(0.2, 0.3, 0.5)
)
raw_qc[, c(
"subject", "session", "task", "run", "fd_max", "fd_mean",
"fd_gt_0p2", "fd_gt_0p3", "fd_gt_0p5"
)]
#> subject session task run fd_max fd_mean fd_gt_0p2 fd_gt_0p3 fd_gt_0p5
#> 1 01 01 rest 01 0.5737987 0.2262741 99.16667 3.333333 2.500000
#> 2 02 01 rest 01 1.0237987 0.2412741 99.16667 3.333333 3.333333The unfiltered columns have these meanings:
-
fd_maxandfd_meansummarize the available per-volume FD values. -
fd_gt_<threshold>reports the percentage above each threshold; decimal points becomepso the names remain syntactically convenient. -
confounds_filepreserves the source path for audit and follow-up. - BIDS identifier columns make it straightforward to join the table to other run-level metadata.
When a source framewise_displacement column exists, it
is used for these raw summaries. If it is missing or entirely
unavailable, BrainGnomes recomputes FD when all six canonical rotation
and translation columns are present. If neither source is usable, raw FD
statistics are NA.
Compare raw and motion-filtered FD
Respiration can introduce oscillation into rigid-body motion
estimates and therefore into FD. Set
include_filtered = TRUE to retain the raw summaries and
also recompute FD after filtering all six motion parameters. The
filtered columns start with fd_filt_.
For a notch filter, specify the stop band in breaths per minute (BPM):
filtered_qc <- calculate_motion_outliers(
confounds_files = demo_files,
thresholds = c(0.2, 0.3, 0.5),
include_filtered = TRUE,
filter_method = "notch",
tr = 1,
bandstop_min_bpm = 12,
bandstop_max_bpm = 18,
filter_order = 4L
)
filtered_qc[, c(
"subject", "fd_max", "fd_filt_max", "fd_gt_0p5",
"fd_filt_gt_0p5"
)]
#> subject fd_max fd_filt_max fd_gt_0p5 fd_filt_gt_0p5
#> 1 01 0.5737987 0.3004677 2.500000 0.000000
#> 2 02 1.0237987 0.6754552 3.333333 3.333333For a low-pass filter, supply the cutoff in hertz:
lowpass_qc <- calculate_motion_outliers(
scfg = scfg,
thresholds = c(0.3, 0.5),
include_filtered = TRUE,
filter_method = "lowpass",
tr = 0.8,
low_pass_hz = 0.1,
filter_order = 2L
)Raw and filtered FD answer different questions. Filtering can help separate motion-parameter oscillation in a targeted frequency range from abrupt changes, but filtered FD is not automatically a better measure for every acquisition or analysis. Inspect both, record the filter settings, and establish the study’s decision rule before applying exclusions.
What happens when filtering cannot be applied?
The helper never copies raw FD into the filtered columns. If the six
motion columns are missing or the requested filter is invalid for the
sampling rate, the fd_filt_* columns remain present but
contain NA. This distinguishes an unavailable filtered
measurement from a run with genuinely low filtered motion.
The next example requests a 12–18 BPM notch band for a TR of 4 seconds. That band cannot be represented usefully at this sampling rate, so filtering is skipped:
skipped_qc <- suppressWarnings(calculate_motion_outliers(
confounds_files = demo_files[1],
thresholds = 0.5,
include_filtered = TRUE,
filter_method = "notch",
tr = 4,
bandstop_min_bpm = 12,
bandstop_max_bpm = 18
))
skipped_qc[, c("fd_max", "fd_gt_0p5", "fd_filt_max", "fd_filt_gt_0p5")]
#> fd_max fd_gt_0p5 fd_filt_max fd_filt_gt_0p5
#> 1 0.5737987 2.5 NA NA
stopifnot(all(is.na(skipped_qc[grep("^fd_filt_", names(skipped_qc))])))Treat these NA values as a QC finding. Check the TR,
filter cutoffs, filter order, and presence of all six motion columns
before using filtered results.
Export a QC or exclusion table
Use output_file to write the returned run-level summary
while calculating it. Files ending in .csv or
.csv.gz are comma-separated; other supported names,
including .tsv and .tsv.gz, are
tab-separated.
summary_file <- file.path(demo_dir, "motion_qc_summary.tsv")
exported_qc <- calculate_motion_outliers(
confounds_files = demo_files,
thresholds = c(0.2, 0.3, 0.5),
output_file = summary_file
)
file.exists(summary_file)
#> [1] TRUE
utils::read.delim(summary_file, check.names = FALSE)
#> subject session task run
#> 1 1 1 rest 1
#> 2 2 1 rest 1
#> confounds_file
#> 1 /tmp/RtmpBGphR3/braingnomes_motion_qc_22911350ce90/sub-01_ses-01_task-rest_run-01_desc-confounds_timeseries.tsv
#> 2 /tmp/RtmpBGphR3/braingnomes_motion_qc_22911350ce90/sub-02_ses-01_task-rest_run-01_desc-confounds_timeseries.tsv
#> fd_max fd_mean fd_gt_0p2 fd_gt_0p3 fd_gt_0p5
#> 1 0.5737987 0.2262741 99.16667 3.333333 2.500000
#> 2 1.0237987 0.2412741 99.16667 3.333333 3.333333calculate_motion_outliers() deliberately does not decide
which runs to exclude. Add a decision column only after choosing and
documenting a protocol. The rule below is illustrative, not a
recommended universal threshold:
example_percent_limit <- 5
exclusion_table <- raw_qc
exclusion_table$exclude_example <-
exclusion_table$fd_gt_0p5 > example_percent_limit
exclusion_table$reason_example <- ifelse(
exclusion_table$exclude_example,
sprintf("more than %s%% of volumes have FD > 0.5 mm", example_percent_limit),
""
)
exclusion_file <- file.path(demo_dir, "motion_exclusion_example.tsv")
utils::write.table(
exclusion_table,
file = exclusion_file,
sep = "\t",
quote = FALSE,
row.names = FALSE,
na = "NA"
)
exclusion_table[, c("subject", "fd_gt_0p5", "exclude_example", "reason_example")]
#> subject fd_gt_0p5 exclude_example reason_example
#> 1 01 2.500000 FALSE
#> 2 02 3.333333 FALSEKeep the continuous measurements even when a binary decision is required. They make sensitivity analyses and later review possible. A practical exclusion table may also include usable duration, task performance, imaging artifacts, and a manual-review field; FD should not be the only evidence considered by default.
Relationship to postprocessing scrubbing
Run-level QC and volume-level scrubbing are related but distinct:
-
calculate_motion_outliers()reports how much of each run exceeds a threshold. - A postprocessing
scrubbing$expressionidentifies the individual volumes that receive censor or spike-regressor treatment. - Scrubbing can interpolate flagged volumes, append spike regressors, remove volumes, or combine these actions. The QC helper performs none of them.
For example, a postprocessing stream might contain:
postprocess:
resting_state:
motion_filter:
enable: true
filter_type: notch
bandstop_min_bpm: 12
bandstop_max_bpm: 18
scrubbing:
enable: true
expression: "framewise_displacement > 0.5"
add_to_confounds: true
interpolate: true
apply: falseWhen motion filtering is enabled for a stream, BrainGnomes filters
the motion parameters and recomputes FD before evaluating motion-based
scrubbing by default. To preview comparable summaries, use the same TR,
filter type, cutoffs, order, rotation units, and threshold in
calculate_motion_outliers(). Two details require
attention:
- The standalone helper’s
low_pass_hzis in hertz, whereas the stream settingmotion_filter$lowpass_bpmis in BPM. Convert withlow_pass_hz = lowpass_bpm / 60. - The helper uses a 50 mm head radius when recomputing FD. That matches the postprocessing default, but a stream with a non-default scrubbing head radius will not produce identical filtered FD values.
The Postprocessing Walkthrough explains interpolation, spike regressors, physical volume removal, confound processing, and the order of these operations in more detail.
Suggested QC workflow
- Summarize several plausible raw thresholds without setting exclusions.
- If respiration-related contamination is a concern, rerun the summary with acquisition-appropriate filtering and inspect raw and filtered columns together.
- Investigate any
NAfiltered metrics rather than treating them as zero. - Join the run-level table to other QC evidence and document the exclusion rule before creating a final decision column.
- Configure scrubbing separately when individual volumes need treatment, and ensure its FD and filter settings match the policy evaluated during QC.