Skip to content

Commit 60ef0df

Browse files
extract rois supports percentage or proportion inputs; prompt_input allows for explicit empty return types; brain_mask
is prompted for during roi extraction
1 parent 94e4780 commit 60ef0df

11 files changed

Lines changed: 451 additions & 97 deletions

‎DESCRIPTION‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: BrainGnomes
22
Title: An R package for preprocessing and analyzing fMRI data
3-
Version: 0.7-1
4-
Date: 2025-09-24
3+
Version: 0.7-2
4+
Date: 2025-09-28
55
Authors@R:
66
person("Michael", "Hallquist", email="michael.hallquist@gmail.com", role = c("aut", "cre"))
77
Description: This R package handles preprocessing of fMRI data on HPC clusters.

‎NEWS.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
# BrainGnomes 0.7-2
2+
3+
* Allow `min_vox_per_roi` to be specified as a percentage or proportion of atlas voxels during ROI extraction
4+
* ROI extraction allows for an optional brain mask that is applied to the atlas and time series data
5+
* Add ability to set an optional field to NA (empty) in `prompt_input` when a default is provided
6+
* Support notch filtering of motion parameters prior to calculation of framewise_displacement
7+
* Support different head sizes for calculation of framewise displacement
8+
19
# BrainGnomes 0.7-1
210

11+
Released 2025-09-24
12+
313
* UI/UX improvements to ask query enable/disable for postprocessing and ROI extraction during edit_project
414
* Do not ask about ROI extraction details if no postprocessing streams are defined
515
* Corrected validation of band-pass cutoffs and clarified temporal filtering prompts/documentation

‎R/extract_rois.R‎

Lines changed: 159 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
#' the input timeseries.
1010
#'
1111
#' @param bold_file Path to a 4D NIfTI file containing postprocessed BOLD data.
12-
#' @param atlas_files Character vector of atlas NIfTI files with integer
13-
#' ROI labels.
12+
#' @param atlas_files Character vector of atlas NIfTI files with integer ROI labels.
1413
#' @param out_dir Directory where output files should be written.
1514
#' @param log_file If not `NULL`, the log file to which details should be written.
1615
#' @param cor_method Correlation method(s) to use when computing functional
@@ -19,12 +18,14 @@
1918
#' computation. Multiple methods may be supplied.
2019
#' @param roi_reduce Method used to summarize voxel time series within each
2120
#' ROI. Options are "mean" (default), "median", "pca", or "huber".
22-
#' @param brain_mask Optional brain mask NIfTI file. If \code{NULL}, a mask
23-
#' is generated by excluding voxels with zero variance across time.
24-
#' @param min_vox_per_roi The minimum number of voxels required for an ROI to
25-
#' be extracted and entered into correlations. If the ROI is smaller than this,
26-
#' it show up as NA in the outputs, keeping the dimensionality of the connectivity
27-
#' matrix consistent across inputs. Default: `5`
21+
#' @param mask_file Optional path to a mask NIfTI file. Voxels outside of this mask
22+
#' are excluded from ROI extraction and connectivity calculation. Note that
23+
#' constant and zero voxels are always automatically removed by extract_rois.
24+
#' @param min_vox_per_roi Minimum ROI size requirement. Supply a positive integer
25+
#' to require at least that many ROI voxels survive masking and are non-zero, or provide
26+
#' a proportion (e.g., `0.8`) or percentage string (e.g., `80%`) to require that fraction of
27+
#' the ROI voxels to remain. ROIs failing this check are set to `NA`, preserving
28+
#' consistent ROI matrix size. Default: `5`.
2829
#' @param save_ts If `TRUE`, save the ROI time series (aggregated using `roi_reduce` method)
2930
#' to `_timeseries.tsv`. files. Useful for running external analyses on the ROIs. Default: `TRUE`.
3031
#' @param rtoz If `TRUE`, using Fisher's z (aka atanh) transformation on correlations to make them
@@ -35,24 +36,28 @@
3536
#' @return A named list. Each element corresponds to an atlas and contains
3637
#' paths to the written timeseries (\code{timeseries}) and correlation
3738
#' matrix (\code{correlation}, or \code{NULL} if not computed).
38-
#' @importFrom checkmate assert_file_exists assert_character assert_directory_exists assert_integerish assert_flag
39+
#' @importFrom checkmate assert_file_exists assert_character assert_directory_exists assert_flag
3940
#' @export
4041
extract_rois <- function(bold_file, atlas_files, out_dir, log_file = NULL,
4142
cor_method = c("pearson", "spearman", "kendall", "cor.shrink"),
4243
roi_reduce = c("mean", "median", "pca", "huber"),
43-
brain_mask = NULL, min_vox_per_roi = 5, save_ts = TRUE, rtoz = FALSE,
44+
mask_file = NULL, min_vox_per_roi = 5, save_ts = TRUE, rtoz = FALSE,
4445
overwrite = FALSE) {
4546
checkmate::assert_file_exists(bold_file)
4647
checkmate::assert_character(atlas_files, any.missing = FALSE, min.len = 1)
4748
checkmate::assert_directory_exists(out_dir, access = "w")
4849
cor_method <- match.arg(cor_method, several.ok = TRUE)
4950
roi_reduce <- match.arg(roi_reduce)
50-
checkmate::assert_integerish(min_vox_per_roi, len = 1L, lower = 1L)
51+
checkmate::assert_string(mask_file, null.ok = TRUE, na.ok = TRUE)
52+
if (isTRUE(is.na(mask_file[1L]))) mask_file <- NULL
53+
54+
min_vox_spec <- parse_min_vox_per_roi(min_vox_per_roi)
5155
checkmate::assert_flag(save_ts)
5256
checkmate::assert_flag(rtoz)
57+
checkmate::assert_flag(overwrite)
5358

5459
lg <- lgr::get_logger_glue("extract_rois")
55-
lg$config(NULL)
60+
lg$config(NULL) # reset logger object to clear any appender files
5661
if (!is.null(log_file)) lg$add_appender(lgr::AppenderFile$new(log_file), name = "extract_logger")
5762

5863
# Read 4D NIfTI
@@ -64,16 +69,41 @@ extract_rois <- function(bold_file, atlas_files, out_dir, log_file = NULL,
6469
n_time <- dim_img[4]
6570
mat <- matrix(bold_img, prod(dim_img[1:3]), n_time)
6671

67-
# Determine brain mask
68-
if (is.null(brain_mask)) {
69-
# if no mask provided, remove zero voxels and constant. Check all(v==0) because it's faster than var()
70-
brain_mask_vec <- apply(mat, 1, function(v) !all(v == 0) && stats::var(v) > 0)
71-
} else if (checkmate::test_file_exists(brain_mask)) {
72-
brain_mask_vec <- as.vector(RNifti::readNifti(brain_mask)) > 0
73-
} else {
74-
to_log(lg, "fatal", "brain_mask must be a valid NIfTI file or NULL")
72+
# Start with BOLD-derived mask that drops constant voxels or those with NAs.
73+
# Use the !all(zero) to screen out 0 voxels because is it faster than computing the variance
74+
mask_vec <- apply(mat, 1L, function(v) {
75+
var_ts <- stats::var(v)
76+
!anyNA(v) && # no NAs
77+
!all(abs(v) < 2 * .Machine$double.eps) && # not all zero
78+
!is.na(var_ts) && # variance is defined
79+
var_ts > 2 * .Machine$double.eps # variance is positive
80+
})
81+
82+
# Handle user-specified mask, if provided
83+
if (!is.null(mask_file)) {
84+
if (!checkmate::test_file_exists(mask_file)) {
85+
to_log(lg, "fatal", "mask_file must be a valid NIfTI file or NULL")
86+
}
87+
88+
mask_img <- RNifti::readNifti(mask_file)
89+
mask_dims <- dim(mask_img)
90+
if (length(mask_dims) > 3L) mask_dims <- mask_dims[1:3]
91+
if (!identical(mask_dims, dim_img[1:3])) {
92+
to_log(lg, "fatal", "Mask dimensions {paste(mask_dims, collapse = 'x')} do not match BOLD grid {paste(dim_img[1:3], collapse = 'x')}")
93+
}
94+
95+
provided_mask_vec <- as.vector(mask_img > 0)
96+
if (length(provided_mask_vec) != length(mask_vec)) {
97+
to_log(lg, "fatal", "Mask voxel count ({length(provided_mask_vec)}) does not match BOLD grid ({length(mask_vec)})")
98+
}
99+
100+
# intersect mask file with internal automask (for 0/constant voxels)
101+
mask_vec <- mask_vec & provided_mask_vec
75102
}
76103

104+
mask_vec[is.na(mask_vec)] <- FALSE
105+
mask_vec <- as.logical(mask_vec)
106+
77107
compute_correlation <- !is.null(cor_method) && length(cor_method) > 0L
78108
bids_info <- as.list(extract_bids_info(bold_file))
79109
sub_id <- bids_info$subject
@@ -100,35 +130,32 @@ extract_rois <- function(bold_file, atlas_files, out_dir, log_file = NULL,
100130
}
101131

102132
atlas_vec <- as.vector(atlas_img)
133+
103134
if (!checkmate::test_integerish(atlas_vec, tol = 1e-6)) stop("Atlas ", atlas, " contains non-integer labels (outside tolerance).")
104-
roi_vals <- sort(unique(atlas_vec[atlas_vec > 0]))
135+
roi_vals <- sort(unique(atlas_vec[atlas_vec > 0 & mask_vec]))
105136

106137
# ROI reduction
107138
ts_mat <- sapply(roi_vals, function(lbl) {
108-
vox <- which(atlas_vec == lbl & brain_mask_vec)
109-
if (length(vox) < min_vox_per_roi) {
110-
to_log(lg, "info", "Fewer than {min_vox_per_roi} voxels in ROI {lbl}. Dropping")
139+
roi_voxels <- sum(atlas_vec == lbl)
140+
required_vox <- compute_min_vox_required(min_vox_spec, roi_voxels)
141+
roi_idx <- which((atlas_vec == lbl) & mask_vec) # get voxel positions for this ROI in the brain mask
142+
if (length(roi_idx) < required_vox) {
143+
req_txt <- as.character(format_min_vox_requirement(min_vox_spec, roi_voxels))
144+
to_log(lg, "info", "ROI {lbl} has {length(roi_idx)} usable voxels but requires {req_txt}. Dropping")
111145
rep(NA_real_, n_time)
112146
} else {
113-
vals <- mat[vox, , drop = FALSE]
114-
bad <- apply(vals, 1, function(ts) any(is.na(ts)) || all(ts == 0) || stats::var(ts) == 0)
115-
if (sum(!bad) < min_vox_per_roi) {
116-
to_log(lg, "info", "Fewer than {min_vox_per_roi} good time series in ROI {lbl}. Dropping")
117-
rep(NA_real_, n_time)
118-
} else {
119-
roivox <- t(vals[!bad, , drop = FALSE]) # time x voxels
120-
if (roi_reduce == "pca") {
121-
pc <- stats::prcomp(roivox, scale. = TRUE)$x[, 1]
122-
mn <- rowMeans(roivox) # because direction of eigenvector is arbitrary, ensure it scales positively with the mean
123-
if (stats::cor(pc, mn) < 0) pc <- -pc
124-
pc
125-
} else if (roi_reduce == "median") {
126-
apply(roivox, 1, median)
127-
} else if (roi_reduce == "huber") {
128-
apply(roivox, 1, function(x) huber(x)$mu)
129-
} else { # mean
130-
rowMeans(roivox)
131-
}
147+
roi_vox <- t(mat[roi_idx, , drop = FALSE]) # time x voxels
148+
if (roi_reduce == "pca") {
149+
pc <- stats::prcomp(roi_vox, scale. = TRUE)$x[, 1]
150+
mn <- rowMeans(roi_vox) # because direction of eigenvector is arbitrary, ensure it scales positively with the mean
151+
if (stats::cor(pc, mn) < 0) pc <- -pc
152+
pc
153+
} else if (roi_reduce == "median") {
154+
apply(roi_vox, 1, median)
155+
} else if (roi_reduce == "huber") {
156+
apply(roi_vox, 1, function(x) huber(x)$mu)
157+
} else { # mean
158+
rowMeans(roi_vox)
132159
}
133160
}
134161
})
@@ -275,4 +302,92 @@ huber <- function(y, k = 1.5, tol = 1.0e-6) {
275302
mu <- mu1
276303
}
277304
list(mu = mu, s = s)
278-
}
305+
}
306+
307+
308+
309+
#' Compute ROI voxel number requirement given a parsed specification
310+
#' @param roi_voxels the number of voxels in an ROI to be potentially extracted
311+
#' @keywords internal
312+
#' @noRd
313+
compute_min_vox_required <- function(spec, roi_voxels) {
314+
checkmate::assert_list(spec, any.missing = FALSE)
315+
checkmate::assert_number(roi_voxels, lower = 0, finite = TRUE)
316+
317+
if (spec$type == "count") {
318+
req <- spec$value
319+
} else if (spec$type == "fraction") {
320+
req <- ceiling(spec$value * roi_voxels)
321+
} else {
322+
stop("Unknown specification type for min_vox_per_roi", call. = FALSE)
323+
}
324+
325+
req <- as.integer(req)
326+
if (is.na(req) || req < 1L) req <- 1L
327+
return(req)
328+
}
329+
330+
#' Internal utilities shared across ROI extraction functions.
331+
#' @keywords internal
332+
#' @noRd
333+
parse_min_vox_per_roi <- function(spec) {
334+
if (length(spec) != 1L) {
335+
stop("min_vox_per_roi must be a single value", call. = FALSE)
336+
}
337+
338+
if (is.na(spec)) {
339+
stop("min_vox_per_roi cannot be NA", call. = FALSE)
340+
}
341+
342+
# Numeric input: allow integer counts or proportions in (0, 1]
343+
if (is.numeric(spec)) {
344+
if (spec >= 1 && checkmate::test_integerish(spec, tol = 1e-8)) {
345+
return(list(type = "count", value = as.integer(round(spec))))
346+
} else if (spec > 0 && spec <= 1) {
347+
return(list(type = "fraction", value = as.numeric(spec)))
348+
}
349+
}
350+
351+
if (is.character(spec)) {
352+
trimmed <- trimws(spec)
353+
if (trimmed == "") stop("min_vox_per_roi cannot be an empty string", call. = FALSE)
354+
355+
if (grepl("%$", trimmed)) {
356+
pct <- suppressWarnings(as.numeric(sub("%$", "", trimmed)))
357+
if (is.na(pct)) stop("Percentage min_vox_per_roi must contain a valid number before '%'", call. = FALSE)
358+
frac <- pct / 100
359+
if (frac <= 0 || frac > 1) stop("Percentage min_vox_per_roi must be between 0% and 100%", call. = FALSE)
360+
return(list(type = "fraction", value = frac))
361+
}
362+
363+
# Handle numeric strings recursively
364+
num <- suppressWarnings(as.numeric(trimmed))
365+
if (!is.na(num)) {
366+
return(parse_min_vox_per_roi(num))
367+
}
368+
}
369+
370+
stop("min_vox_per_roi must be a positive integer, a proportion in (0, 1], or a percentage string such as '80%'", call. = FALSE)
371+
}
372+
373+
374+
375+
#' Format min_vox_per_roi requirement for display or storage
376+
#' @keywords internal
377+
#' @noRd
378+
format_min_vox_requirement <- function(spec, roi_voxels = NULL, digits = 1) {
379+
checkmate::assert_list(spec, any.missing = FALSE)
380+
checkmate::assert_number(digits, lower = 0, finite = TRUE)
381+
382+
if (spec$type == "count") {
383+
return(glue::glue("{spec$value} voxels"))
384+
}
385+
386+
pct <- round(spec$value * 100, digits)
387+
if (is.null(roi_voxels)) {
388+
return(glue::glue("{pct}% of ROI voxels"))
389+
}
390+
391+
req <- compute_min_vox_required(spec, roi_voxels)
392+
return(glue::glue("{pct}% of {roi_voxels} voxels (>= {req})"))
393+
}

‎R/setup_extract.R‎

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#' @keywords internal
22
manage_extract_streams <- function(scfg, allow_empty = FALSE) {
33
extract_field_list <- function() {
4-
c("input_streams", "atlases", "roi_reduce", "correlation/method", "rtoz")
4+
c("input_streams", "atlases", "mask_file", "roi_reduce", "correlation/method", "rtoz")
55
}
66

77
show_val <- function(val) {
@@ -191,7 +191,7 @@ setup_extract_stream <- function(scfg, fields = NULL, stream_name = NULL) {
191191

192192
if (is.null(fields)) {
193193
fields <- c(
194-
"extract_rois/input_streams", "extract_rois/atlases", "extract_rois/roi_reduce", "extract_rois/save_ts",
194+
"extract_rois/input_streams", "extract_rois/atlases", "extract_rois/mask_file", "extract_rois/roi_reduce", "extract_rois/save_ts",
195195
"extract_rois/correlation/method", "extract_rois/rtoz", "extract_rois/min_vox_per_roi"
196196
)
197197
}
@@ -206,11 +206,25 @@ setup_extract_stream <- function(scfg, fields = NULL, stream_name = NULL) {
206206
}
207207

208208
if ("extract_rois/atlases" %in% fields) {
209-
atlas <- prompt_input("Atlas NIfTI file(s) (comma separated):", type = "character")
210-
atlas <- trimws(strsplit(atlas, ",")[[1]])
209+
atlas <- prompt_input("Atlas NIfTI file(s) (separate using commas for multiple):", type = "character", split="\\s*,\\s*")
211210
excfg$atlases <- atlas
212211
}
213212

213+
if ("extract_rois/mask_file" %in% fields) {
214+
mask_file <- prompt_input(
215+
instruct = glue("\n
216+
ROI extraction always removes constant and zero voxels from its calculations.
217+
In addition, you may provide a mask file that only retains voxels in the mask
218+
for ROI extraction and connectivity calculation. This mask is applied in addition
219+
to masking done in postprocessing, if requested, as well as the automatic removal
220+
of constant/zero voxels.
221+
"),
222+
prompt = "Mask file for ROI extraction",
223+
type = "character", required = FALSE,
224+
default = excfg$mask_file
225+
)
226+
}
227+
214228
if ("extract_rois/roi_reduce" %in% fields) {
215229
reduce <- select_list_safe(c("mean", "median", "pca", "huber"),
216230
multiple = FALSE, title = "ROI reduction method")
@@ -258,16 +272,52 @@ setup_extract_stream <- function(scfg, fields = NULL, stream_name = NULL) {
258272
}
259273

260274
if ("extract_rois/min_vox_per_roi" %in% fields) {
261-
excfg$min_vox_per_roi <- prompt_input(instruct = glue("
262-
Sometimes smaller ROIs, especially near the edge of the brain, may not have enough voxels to yield
263-
a reasonable average time series. In this case, it's often best to exclude these ROIs, rather than
264-
include a noisy estimate. How many voxels must an ROI have to be extracted (and entered into correlation)?
265-
Note that ROIs less than this number will be set to NA in the output, but will still be labeled correctly rather
266-
than being dropped altogether."),
267-
268-
"Enter the minimum number of voxels for valid ROIs:", type = "integer", lower = 1L, default=5L)
275+
default_value <- if (is.null(excfg$min_vox_per_roi)) "5" else as.character(excfg$min_vox_per_roi)
276+
repeat {
277+
response <- prompt_input(
278+
instruct = glue("
279+
Sometimes smaller ROIs, especially near the edge of the brain, may not have enough voxels to yield
280+
a reasonable average time series. In this case, it's often best to exclude these ROIs, rather than
281+
include a noisy estimate. ROIs failing this threshold will be set to NA in outputs, preserving labeling.
282+
You can enter an absolute voxel count (e.g., 5) or a percentage/proportion (e.g., '80%' or 0.8) of atlas voxels."),
283+
prompt = "Enter the minimum voxel requirement for valid ROIs:", type = "character", len = 1L,
284+
default = default_value
285+
)
286+
287+
response <- trimws(response)
288+
parsed <- tryCatch(parse_min_vox_per_roi(response), error = function(e) {
289+
cat(conditionMessage(e), "\n")
290+
NULL
291+
})
292+
293+
if (!is.null(parsed)) {
294+
excfg$min_vox_per_roi <- canonicalize_min_vox_per_roi(parsed)
295+
break
296+
}
297+
298+
default_value <- response
299+
}
269300
}
270301

271302
scfg$extract_rois[[stream_name]] <- excfg
272303
return(scfg)
273304
}
305+
306+
307+
#' Convert a parsed specification to a canonical configuration value
308+
#' @keywords internal
309+
#' @noRd
310+
canonicalize_min_vox_per_roi <- function(spec, digits = 1) {
311+
checkmate::assert_list(spec, any.missing = FALSE)
312+
313+
if (spec$type == "count") return(spec$value)
314+
315+
pct <- round(spec$value * 100, digits)
316+
fmt <- if (abs(pct - round(pct)) < .Machine$double.eps^0.5) {
317+
as.integer(round(pct))
318+
} else {
319+
pct
320+
}
321+
322+
return(paste0(fmt, "%"))
323+
}

0 commit comments

Comments
 (0)