|
| 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