Skip to content

Commit 10eae34

Browse files
Add t2w by default and handle reticulate fallback when python directory is not writable; draft of calculate_motion_outliers
1 parent 6693eda commit 10eae34

5 files changed

Lines changed: 486 additions & 33 deletions

File tree

NEWS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
# BrainGnomes 0.7-5
22

3+
* Add `calculate_motion_outliers` function to calculate motion outliers in a BIDS dataset
34
* Use the `scratch_directory` for postprocessing images to avoid collisions and ensure that intermediates do not clog the output folder
5+
* Check that python packages directory is writable prior to attempting to resample a stereotaxic template to an image; fall back to
6+
a managed `reticulate` environment if not.
7+
* More robust postprocess logging (fallback log directory if requested location is unavailable) and clearer reporting of retained
8+
volumes during confound regression.
9+
* bugfix: 0 values for temporal filter cutoffs now disable the corresponding low/high-pass filter components.
410
* bugfix: more complete handling of cases where confound calculate/regress is enabled, but no columns are specified.
11+
* bugfix: ROI extraction now writes empty connectivity outputs (with warnings) when all ROIs are dropped after filtering.
12+
* bugfix: add T2w to template pre-fetch so that fmriprep does not try to obtain this when users have T2w images
13+
* bugfix: correct regex in postprocessing step substitution when using user-specified order
14+
* bugfix: prevent spurious failure files for fMRIPrep/AROMA jobs that return non-zero exit codes despite successful completion
515

616
# BrainGnomes 0.7-4
717

R/motion_outliers.R

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
#' Summarize framewise displacement outliers across runs
2+
#'
3+
#' Calculates the percentage of framewise displacement (FD) values that exceed
4+
#' one or more thresholds for each run (confounds file) in a project. When
5+
#' requested, FD is recomputed after filtering the motion parameters (notch or
6+
#' low-pass) to provide filtered outlier percentages alongside the unfiltered
7+
#' values. This helper is intended for interactive use and is not part of the
8+
#' postprocessing stream.
9+
#'
10+
#' @param scfg Optional project configuration object produced by
11+
#' `load_project()` or `setup_project()`. If provided, the fMRIPrep directory
12+
#' is taken from `scfg$metadata$fmriprep_directory`.
13+
#' @param input_dir Optional directory to search for fMRIPrep confounds files.
14+
#' Ignored when `confounds_files` is provided.
15+
#' @param confounds_files Optional character vector of confounds TSV files to
16+
#' summarize directly. If supplied, no directory search is performed.
17+
#' @param thresholds Numeric vector of FD thresholds (in mm). Percentages are
18+
#' returned for each threshold with column names like `fd_gt_0p5`.
19+
#' @param include_filtered Logical; if `TRUE`, recompute FD after filtering the
20+
#' motion parameters and include filtered outlier percentages (columns prefixed
21+
#' with `fd_filt_`).
22+
#' @param filter_method Filtering strategy when `include_filtered = TRUE`.
23+
#' Either `"notch"` (band-stop, in breaths per minute) or `"lowpass"` (Hz).
24+
#' @param tr Repetition time in seconds, required when `include_filtered = TRUE`.
25+
#' @param band_stop_min Lower notch stop-band bound in breaths per minute
26+
#' (required for `filter_method = "notch"`).
27+
#' @param band_stop_max Upper notch stop-band bound in breaths per minute
28+
#' (required for `filter_method = "notch"`).
29+
#' @param low_pass_hz Low-pass cutoff in Hz (required for
30+
#' `filter_method = "lowpass"`).
31+
#' @param filter_order Integer filter order for low-pass filtering (default 2).
32+
#' @param motion_cols Motion parameter columns used to recompute FD.
33+
#' @param rot_units Rotation unit for motion parameters (`"rad"` or `"deg"`).
34+
#'
35+
#' @return A data.frame with subject, session, confounds file location, max FD,
36+
#' and outlier percentages for each threshold (filtered columns are included
37+
#' when requested).
38+
#'
39+
#' @examples
40+
#' \dontrun{
41+
#' scfg <- load_project("/path/to/project_config.yaml")
42+
#' out <- calculate_motion_outliers(scfg = scfg, thresholds = c(0.3, 0.5))
43+
#'
44+
#' out_filt <- calculate_motion_outliers(
45+
#' scfg = scfg,
46+
#' thresholds = c(0.3, 0.5),
47+
#' include_filtered = TRUE,
48+
#' filter_method = "notch",
49+
#' tr = 2,
50+
#' band_stop_min = 18,
51+
#' band_stop_max = 24
52+
#' )
53+
#' }
54+
#'
55+
#' @importFrom checkmate assert_class assert_directory_exists assert_character
56+
#' @importFrom checkmate assert_numeric assert_flag assert_number
57+
#' @importFrom checkmate assert_integerish
58+
#' @importFrom data.table fread
59+
#' @export
60+
calculate_motion_outliers <- function(scfg = NULL,
61+
input_dir = NULL,
62+
confounds_files = NULL,
63+
thresholds = 0.3,
64+
include_filtered = FALSE,
65+
filter_method = c("notch", "lowpass"),
66+
tr = NULL,
67+
band_stop_min = NULL,
68+
band_stop_max = NULL,
69+
low_pass_hz = NULL,
70+
filter_order = 2L,
71+
motion_cols = c("rot_x", "rot_y", "rot_z", "trans_x", "trans_y", "trans_z"),
72+
rot_units = c("rad", "deg")) {
73+
if (!is.null(confounds_files)) {
74+
checkmate::assert_character(confounds_files, min.len = 1L, any.missing = FALSE)
75+
missing <- confounds_files[!file.exists(confounds_files)]
76+
if (length(missing) > 0L) {
77+
stop("Confounds files not found: ", paste(missing, collapse = ", "))
78+
}
79+
} else {
80+
if (is.null(input_dir)) {
81+
if (is.null(scfg)) {
82+
stop("Provide one of confounds_files, input_dir, or scfg.")
83+
}
84+
checkmate::assert_class(scfg, "bg_project_cfg")
85+
input_dir <- scfg$metadata$fmriprep_directory
86+
}
87+
checkmate::assert_directory_exists(input_dir)
88+
confounds_files <- list.files(
89+
input_dir,
90+
pattern = "_desc-confounds_(timeseries|regressors)\\.tsv(\\.gz)?$",
91+
recursive = TRUE,
92+
full.names = TRUE,
93+
ignore.case = TRUE
94+
)
95+
}
96+
97+
thresholds <- unique(as.numeric(thresholds))
98+
checkmate::assert_numeric(thresholds, any.missing = FALSE, lower = 0, min.len = 1L)
99+
100+
include_filtered <- isTRUE(include_filtered)
101+
checkmate::assert_flag(include_filtered)
102+
rot_units <- match.arg(rot_units)
103+
104+
if (include_filtered) {
105+
filter_method <- match.arg(filter_method)
106+
checkmate::assert_number(tr, lower = 0.01)
107+
if (filter_method == "notch") {
108+
checkmate::assert_number(band_stop_min, lower = 0)
109+
checkmate::assert_number(band_stop_max, lower = 0)
110+
} else {
111+
checkmate::assert_number(low_pass_hz, lower = 0)
112+
checkmate::assert_integerish(filter_order, len = 1L, lower = 2L)
113+
}
114+
}
115+
116+
format_threshold <- function(x) {
117+
label <- format(x, trim = TRUE, scientific = FALSE)
118+
label <- gsub("\\.", "p", label)
119+
label
120+
}
121+
122+
calc_pct <- function(x, thr) {
123+
n_ok <- sum(!is.na(x))
124+
if (n_ok == 0L) return(NA_real_)
125+
mean(x > thr, na.rm = TRUE) * 100
126+
}
127+
128+
calc_max <- function(x) {
129+
if (length(x) == 0L || all(is.na(x))) return(NA_real_)
130+
max(x, na.rm = TRUE)
131+
}
132+
133+
empty_out <- function() {
134+
base <- data.frame(
135+
subject = character(),
136+
session = character(),
137+
confounds_file = character(),
138+
fd_max = numeric(),
139+
stringsAsFactors = FALSE
140+
)
141+
labels <- vapply(thresholds, format_threshold, character(1))
142+
for (lbl in labels) {
143+
base[[paste0("fd_gt_", lbl)]] <- numeric()
144+
}
145+
if (include_filtered) {
146+
base[["fd_filt_max"]] <- numeric()
147+
for (lbl in labels) {
148+
base[[paste0("fd_filt_gt_", lbl)]] <- numeric()
149+
}
150+
}
151+
base
152+
}
153+
154+
confounds_files <- sort(unique(confounds_files))
155+
if (length(confounds_files) == 0L) {
156+
warning("No confounds files found to summarize.", call. = FALSE)
157+
return(empty_out())
158+
}
159+
160+
lowpass_filter_motion <- function(df) {
161+
if (!requireNamespace("signal", quietly = TRUE)) {
162+
stop("The 'signal' package must be installed for low-pass filtering.")
163+
}
164+
fs <- 1 / tr
165+
nyq <- fs / 2
166+
if (low_pass_hz <= 0) {
167+
stop("low_pass_hz must be greater than 0.")
168+
}
169+
if (low_pass_hz >= nyq) {
170+
stop("low_pass_hz must be less than the Nyquist frequency (", signif(nyq, 4), " Hz).")
171+
}
172+
coeffs <- signal::butter(filter_order, low_pass_hz / nyq, type = "low")
173+
for (col_name in motion_cols) {
174+
series <- as.numeric(df[[col_name]])
175+
if (anyNA(series)) series[is.na(series)] <- 0
176+
df[[col_name]] <- filtfilt_cpp(
177+
series,
178+
b = coeffs$b,
179+
a = coeffs$a,
180+
padlen = -1L,
181+
padtype = "constant",
182+
use_zi = TRUE
183+
)
184+
}
185+
df
186+
}
187+
188+
labels <- vapply(thresholds, format_threshold, character(1))
189+
res <- lapply(confounds_files, function(cf) {
190+
confounds <- data.table::fread(cf, showProgress = FALSE, na.strings = c("n/a", "NA", "NaN"))
191+
if (!is.data.frame(confounds)) confounds <- as.data.frame(confounds)
192+
193+
bids_info <- as.list(extract_bids_info(cf))
194+
motion_ok <- all(motion_cols %in% names(confounds))
195+
fd <- NULL
196+
if ("framewise_displacement" %in% names(confounds)) {
197+
fd <- suppressWarnings(as.numeric(confounds$framewise_displacement))
198+
}
199+
200+
if (is.null(fd) || length(fd) == 0L || (motion_ok && all(is.na(fd)))) {
201+
if (motion_ok) {
202+
fd <- framewise_displacement(
203+
motion = confounds[, motion_cols, drop = FALSE],
204+
columns = motion_cols,
205+
rot_units = rot_units
206+
)
207+
} else {
208+
fd <- rep(NA_real_, nrow(confounds))
209+
}
210+
}
211+
212+
row <- list(
213+
subject = bids_info$subject,
214+
session = bids_info$session,
215+
confounds_file = cf,
216+
fd_max = calc_max(fd)
217+
)
218+
for (ii in seq_along(thresholds)) {
219+
row[[paste0("fd_gt_", labels[[ii]])]] <- calc_pct(fd, thresholds[[ii]])
220+
}
221+
222+
if (include_filtered) {
223+
if (!motion_ok) {
224+
filtered_fd <- rep(NA_real_, length(fd))
225+
} else if (filter_method == "notch") {
226+
filtered <- notch_filter(
227+
confounds_df = confounds,
228+
tr = tr,
229+
band_stop_min = band_stop_min,
230+
band_stop_max = band_stop_max,
231+
columns = motion_cols,
232+
add_poly = FALSE,
233+
out_file = NULL,
234+
padtype = "constant",
235+
padlen = NULL,
236+
use_zi = TRUE,
237+
lg = NULL
238+
)
239+
filtered_fd <- framewise_displacement(
240+
motion = filtered[, motion_cols, drop = FALSE],
241+
columns = motion_cols,
242+
rot_units = rot_units
243+
)
244+
} else {
245+
filtered <- lowpass_filter_motion(confounds)
246+
filtered_fd <- framewise_displacement(
247+
motion = filtered[, motion_cols, drop = FALSE],
248+
columns = motion_cols,
249+
rot_units = rot_units
250+
)
251+
}
252+
253+
row[["fd_filt_max"]] <- calc_max(filtered_fd)
254+
for (ii in seq_along(thresholds)) {
255+
row[[paste0("fd_filt_gt_", labels[[ii]])]] <- calc_pct(filtered_fd, thresholds[[ii]])
256+
}
257+
}
258+
259+
as.data.frame(row, stringsAsFactors = FALSE)
260+
})
261+
262+
do.call(rbind, res)
263+
}

0 commit comments

Comments
 (0)