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
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
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
4041extract_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+ }
0 commit comments