--- title: Smoothing and filtering output: rmarkdown::html_vignette: css: albers.css includes: in_header: - albers-header.inc - albers-header.html toc: yes toc_depth: 2.0 resource_files: - albers.css - albers-fonts.css - albers.js - albers-header.inc - albers-header.html - fonts params: family: red preset: interaction vignette: | %\VignetteIndexEntry{Smoothing and filtering} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} if (requireNamespace("ragg", quietly = TRUE)) knitr::opts_chunk$set(dev = "ragg_png") if (requireNamespace("systemfonts", quietly = TRUE)) albersdown::albers_register_fonts() if (requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("albersdown", quietly = TRUE)) ggplot2::theme_set(albersdown::theme_albers(family = params$family, preset = params$preset)) source("_common.R") ``` ```{r albers-classes, echo=FALSE, results='asis'} cat(sprintf( paste0( '' ), params$family, params$preset )) ``` ```{r load} library(neuroim2) ``` Filtering trades detail for noise. neuroim2 offers six filters that make the trade differently: some blur everything, some try to keep edges, one sharpens instead, and two work across time as well as space. This article shows what each does to the same image, measured rather than asserted. ```{r data} anat <- demo_anatomy() brain <- demo_anatomy_mask() spacing(anat) ``` That 1 mm matters for everything below: kernel widths are specified in millimetres, so what a filter actually does depends on `sigma` **relative to the voxel size**. On this image the two happen to coincide. Every number in this article is computed inside the brain mask. Statistics over the whole array are dominated by the 75% of voxels that are air, where every filter looks identical and every difference is invisible. ```{r inmask} inside <- which(as.vector(brain)) baseline <- as.array(anat)[inside] ``` A helper to compare slices on a shared intensity scale: ```{r panel-helper} compare <- function(..., z = 24) { imgs <- list(...) zlim <- range(vapply(imgs, function(v) range(v[, , z]), numeric(2))) op <- par(mfrow = c(1, length(imgs)), mar = c(0.5, 0.5, 2, 0.5)) for (nm in names(imgs)) { image(imgs[[nm]][, , z], main = nm, zlim = zlim, col = gray.colors(256), axes = FALSE, asp = 1 ) } par(op) } ``` ## Gaussian blur `gaussian_blur()` is the fast, unconditional baseline: every voxel becomes a weighted average of its neighbours regardless of what is there. ```{r gaussian, fig.cap = "Gaussian smoothing at two kernel widths.", fig.alt = "Three axial slices: original, lightly smoothed, heavily smoothed.", fig.height = 3.2} light <- gaussian_blur(anat, brain, fwhm = 4) heavy <- gaussian_blur(anat, brain, fwhm = 9) compare(original = anat, `FWHM 4 mm` = light, `FWHM 9 mm` = heavy) ``` Specify the width however you like: `fwhm` in millimetres, which is the unit smoothing is usually reported in, or `sigma` in millimetres, which is the same thing divided by `2 * sqrt(2 * log(2))`. Either way the kernel is sized from what you asked for, so the smoothing you get is the smoothing you specified: ```{r psf} # The point-spread function is the operator: smooth an impulse, read the width # back out. This is the only honest check that a request was honoured. psf_fwhm <- function(v, sp) { a <- as.array(v); a[a < 0] <- 0 prof <- apply(a, 1, sum); off <- (seq_along(prof) - 21) * sp mu <- sum(prof * off) / sum(prof) 2 * sqrt(2 * log(2)) * sqrt(sum(prof * (off - mu)^2) / sum(prof)) } imp_sp <- NeuroSpace(c(41L, 41L, 41L), c(2, 2, 2)) imp <- array(0, c(41, 41, 41)); imp[21, 21, 21] <- 1 imp <- NeuroVol(imp, imp_sp) c( `asked 4 mm` = psf_fwhm(gaussian_blur(imp, fwhm = 4, normalize = FALSE), 2), `asked 9 mm` = psf_fwhm(gaussian_blur(imp, fwhm = 9, normalize = FALSE), 2) ) ``` `window` is still there, and still means what it always did: the kernel support in voxels either side, which **truncates** the Gaussian. Passing it reproduces the old behaviour exactly, and that behaviour is worth seeing, because it is easy to under-smooth by a lot without noticing: ```{r sigma-window} c( `sigma 4, window 1` = psf_fwhm(gaussian_blur(imp, sigma = 4, window = 1, normalize = FALSE), 2), `sigma 4, window 2` = psf_fwhm(gaussian_blur(imp, sigma = 4, window = 2, normalize = FALSE), 2), `sigma 4, derived` = psf_fwhm(gaussian_blur(imp, sigma = 4, normalize = FALSE), 2), `requested` = 2 * sqrt(2 * log(2)) * 4 ) ``` A `sigma` of 4 mm inside `window = 1` delivers under 4 mm FWHM where 9.4 mm was asked for, and because `window` counts voxels while `sigma` is in millimetres, the shortfall depends on the voxel size — the same call smooths a 1 mm structural and a 3 mm EPI differently. Leave `window` alone unless you have a specific reason to clip the kernel; `truncate` (default 4 standard deviations) is the knob for trading accuracy against speed. Handing `gaussian_blur()` a `NeuroVec` smooths every volume with the same kernel, and does it in one compiled call over the whole run rather than a loop in R — so pass the run, do not loop over `x[[i]]` yourself. Volumes are smoothed in parallel; `RcppParallel::setThreadOptions()` sets how many at a time. ## Edge-preserving filters Gaussian blur does not know where anatomy stops. `guided_filter()` fits local linear models, smoothing within regions and leaving boundaries alone. `bilateral_filter()` weights neighbours by intensity similarity as well as distance, so voxels across a tissue boundary contribute little. One parameter needs care. `bilateral_filter()`'s `intensity_sigma` is a *multiple of the image's own standard deviation*, but `guided_filter()`'s `epsilon` is **absolute, in squared intensity units**. On an image scaled 0 to 9533 an `epsilon` of 0.5 is indistinguishable from zero; it has to be scaled to the data. ```{r edge-preserving, fig.cap = "Gaussian blur against two edge-preserving filters at matched support.", fig.alt = "Four axial slices comparing original, Gaussian, guided and bilateral filtering.", fig.height = 3.2} sd_in <- sd(baseline) guided <- guided_filter(anat, radius = 1, epsilon = (0.6 * sd_in)^2) bilateral <- bilateral_filter(anat, brain, spatial_sigma = 2, intensity_sigma = 1, window = 1) compare(original = anat, gaussian = light, guided = guided, bilateral = bilateral) ``` All three now use a one-voxel radius, so the comparison is about method rather than support. The question an edge-preserving filter has to answer is: **how much variance can you remove per unit of distortion?** ```{r edge-numbers} score <- function(x) { v <- as.array(x)[inside] removed <- 1 - var(v) / var(baseline) distortion <- 1 - cor(v, baseline) c(sd = sd(v), removed = removed, distortion = distortion, ratio = removed / distortion) } round(rbind( original = score(anat), gaussian = score(light), guided = score(guided), bilateral = score(bilateral) ), 3) ``` `bilateral_filter()` removes a third of the variance for a twentieth of the distortion — an exchange rate three times better than the Gaussian's, and that ratio is what "edge preserving" buys you. `gaussian_blur()` removes more variance outright but pays the most per unit for it. `guided_filter()` is the weakest of the three at this `epsilon`: it barely smooths, and the little it does costs about what the Gaussian charges. Raise `epsilon` towards `sd_in^2` to get more out of it. Note that a plain whole-array `sd()` would have told you the opposite story. Outside the mask these images are nearly constant, so filters that flatten air look like strong denoisers when scored over the full volume. ## Sharpening `laplace_enhance()` runs the other way, amplifying detail with a multi-scale non-local scheme. It is a display and detection aid, not a denoiser. ```{r sharpen, fig.cap = "Laplacian enhancement increases local contrast rather than reducing it.", fig.alt = "Two axial slices: original and sharpened.", fig.height = 3.4} sharp <- laplace_enhance(anat, brain, k = 2, patch_size = 3, search_radius = 1, h = 0.7) compare(original = anat, enhanced = sharp) ``` Pass the mask, whose second argument position is easy to skip. It is what keeps the enhancement off the background — and the background is where over-enhancement shows first: ```{r sharpen-numbers} outside <- which(as.vector(brain) == 0) sharp_arr <- as.array(sharp) round(rbind( original = c(inside = sd(baseline), outside = sd(as.array(anat)[outside])), enhanced = c(sd(sharp_arr[inside]), sd(sharp_arr[outside])) ), 2) ``` Contrast inside the brain rises by more than half, which is the point, and the noise floor outside is untouched because the mask excluded it. Drop the mask and that second column climbs instead. The output does go negative on a non-negative image, so clip before writing anything downstream: ```{r sharpen-range} range(sharp_arr) ``` ## Filtering across time Two filters work on a `NeuroVec`, over space and time together. Both need a series with real temporal structure: ```{r bold} bold_mask <- demo_mask() bold <- demo_bold(n_time = 40) attr(bold, "TR") ``` `bilateral_filter_4d()` extends the bilateral idea along the time axis. `temporal_spacing` is the units of that axis, so it must be your TR — here read straight off the simulated series rather than assumed: ```{r bilat4d} bf4d <- bilateral_filter_4d( bold, bold_mask, spatial_sigma = 4, intensity_sigma = 1, temporal_sigma = 1, spatial_window = 1, temporal_window = 1, temporal_spacing = attr(bold, "TR") ) ``` `cgb_filter()` decides which voxels belong together by **correlating their time courses** rather than comparing intensities. It builds a sparse graph — spatial proximity times time-series affinity — and diffuses over it, which is the right model for functional data where two voxels in a network should smooth together even if their raw intensities differ. ```{r cgb} out <- cgb_filter( bold, mask = bold_mask, spatial_sigma = 5, window = 1, corr_map = "power", corr_param = 2, topk = 16, passes = 1, lambda = 1, return_graph = TRUE ) ``` Both should reduce per-voxel noise. Measure on a **random** sample of in-mask voxels — taking the first 300 by linear index gives you a contiguous patch in the bottom slice, where support is truncated and neighbours are trivially correlated: ```{r fourd-numbers} set.seed(1) sample_idx <- sample(which(as.vector(bold_mask) > 0), 300) noise <- function(x) mean(apply(as.matrix(x)[sample_idx, ], 1, sd)) c(raw = noise(bold), bilateral_4d = noise(bf4d), cgb = noise(out$result)) ``` `return_graph = TRUE` kept the graph, so further passes cost nothing to set up: ```{r cgb-reuse} c( raw = noise(bold), pass_1 = noise(out$result), pass_2 = noise(cgb_smooth(bold, out$graph, passes = 2)) ) ``` Building the graph is the expensive part of `cgb_filter()`; `cgb_smooth()` reuses it, which is how you explore `passes` and `lambda` without paying for it each time. ## Choosing | Want | Use | Watch out for | |:--|:--|:--| | A fast, predictable baseline | `gaussian_blur()` | give `fwhm` or `sigma`; setting `window` truncates the kernel | | Denoise but keep boundaries | `bilateral_filter()` | `intensity_sigma` scales with the image SD | | Same, local-linear formulation | `guided_filter()` | `epsilon` is absolute, in intensity squared | | More visible detail, not less | `laplace_enhance()` | raises the noise floor; can go negative | | Joint space and time denoising | `bilateral_filter_4d()` | `temporal_spacing` must be your TR | | Smoothing that follows networks | `cgb_filter()` | needs real temporal structure | ## Where to go next - `vignette("visualization")` — inspecting the results of any of these - `vignette("resampling-and-orientation")` — changing the grid rather than the values - `?gaussian_blur`, `?guided_filter`, `?cgb_filter`, `?cgb_smooth`