Most brain imaging analyses eventually need to move between two resolutions: vertex-level (one value per surface point, typically 32k–164k per hemisphere) and parcel-level (one value per brain region, typically 50–400). A cortical thickness map is vertex-level. A table of regional volumes from the Desikan-Killiany atlas is parcel-level. Comparing the two requires converting one to match the other.
neuromapr provides tools for this conversion—aggregating vertices into parcels, mapping parcel values back to vertices, and computing parcel centroids on the cortical surface.
Vertices to parcels
Suppose you have a vertex-level brain map and a parcellation that
assigns each vertex to a region. The parcellation is an integer vector
where each vertex gets a label, with 0 or NA
marking the medial wall (vertices that belong to no region).
set.seed(42)
n_vertices <- 1000
vertex_data <- rnorm(n_vertices)
labels <- sample(
c(0, 1:10), n_vertices,
replace = TRUE, prob = c(0.1, rep(0.09, 10))
)
parcel_values <- vertices_to_parcels(vertex_data, labels)
parcel_values
#> 1 2 3 4 5 6
#> -0.001235255 0.142132938 -0.055766033 -0.162060178 0.027356454 -0.019617141
#> 7 8 9 10
#> -0.114211335 -0.047980452 -0.108683119 0.018359997Each parcel gets the mean of its constituent vertices. Medial wall vertices (label 0) are excluded automatically.
The summary function is configurable:
parcel_sd <- vertices_to_parcels(
vertex_data, labels, summary_func = sd
)
parcel_sd
#> 1 2 3 4 5 6 7 8
#> 1.0683513 1.0550975 0.9958556 1.0257398 1.0019210 0.8117328 0.9765293 1.0108265
#> 9 10
#> 1.0698179 0.9825964Parcels back to vertices
The reverse operation paints parcel-level values back onto the vertex mesh. Every vertex gets its parcel’s value; medial wall vertices get a fill value.
vertex_filled <- parcels_to_vertices(parcel_values, labels)
head(vertex_filled, 20)
#> [1] -0.001235255 0.027356454 -0.047980452 0.018359997 0.018359997
#> [6] -0.108683119 -0.047980452 0.018359997 0.142132938 -0.162060178
#> [11] -0.114211335 0.142132938 -0.055766033 -0.047980452 NA
#> [16] -0.108683119 -0.114211335 -0.047980452 -0.108683119 -0.001235255Vertices with label 0 receive NA by default. You can
change the fill:
vertex_filled_zero <- parcels_to_vertices(
parcel_values, labels, fill = 0
)
sum(vertex_filled_zero == 0)
#> [1] 95The high-level wrappers
parcellate() and unparcellate() add
file-reading convenience on top of the low-level functions. If your data
lives on disk as GIFTI files, pass paths directly:
parcel_vals <- parcellate(
"cortical_thickness.func.gii",
"aparc.label.gii"
)The function reads the data file, reads the parcellation labels,
validates that they match in length, and calls
vertices_to_parcels() underneath.
When working with vectors already in memory, the wrappers still work—they skip the file-reading step:
parcellated <- parcellate(vertex_data, labels)
unparcellated <- unparcellate(parcellated, labels)
all.equal(
parcels_to_vertices(parcel_values, labels),
unparcellated
)
#> [1] TRUEThe roundtrip is not lossless—parcellation is lossy aggregation. But
unparcellate(parcellate(x)) gives you parcel means
broadcast back to vertex positions, which is the expected behaviour.
Parcel centroids
Some analyses need to know where each parcel lives in 3D space—for
example, to compute a parcel-level distance matrix.
get_parcel_centroids() offers three methods for finding the
representative point of each parcel.
Average centroid
Take the mean x, y, z coordinates of all vertices in the parcel. Fast, but the resulting point may not lie on the actual cortical surface.
centroids_avg <- get_parcel_centroids(
vertices, labels, method = "average"
)
head(centroids_avg)
#> [,1] [,2] [,3]
#> 1 0.11396575 0.016773018 -0.065814398
#> 2 0.02388696 -0.035682772 -0.020913744
#> 3 0.07952778 0.101717196 0.091118296
#> 4 -0.08853466 -0.052836153 -0.058780127
#> 5 0.01771226 0.044216347 0.046963635
#> 6 -0.03578315 -0.004064188 -0.008321535Surface centroid
Find the vertex closest to the average centroid. The result is always an actual vertex on the surface, which matters when centroids must respect the cortical geometry.
centroids_surf <- get_parcel_centroids(
vertices, labels, method = "surface"
)
head(centroids_surf)
#> [,1] [,2] [,3]
#> 1 -0.08089878 0.26659246 0.1365766
#> 2 -0.12375518 -0.01086631 0.1036001
#> 3 0.38467702 -0.10467462 -0.1120929
#> 4 -0.58301662 0.13731157 -0.1416120
#> 5 -0.40958087 0.15919765 -0.1902452
#> 6 -0.47109369 -0.34759792 0.1752621Geodesic centroid
The most principled approach: find the vertex that minimizes the sum of geodesic (shortest-path-on-surface) distances to all other vertices in the parcel. This requires the face matrix of the surface mesh and the igraph package.
centroids_geo <- get_parcel_centroids(
vertices, labels,
method = "geodesic",
faces = faces
)Geodesic centroids are computationally expensive—they compute pairwise shortest paths within each parcel. For large parcellations on high-resolution meshes, the surface centroid is a practical approximation.
Building a parcel-level distance matrix
A common use case: you have parcellated data and need a distance matrix for null model generation. Parcel centroids get you there.
This distance matrix feeds directly into
generate_nulls():
parcel_map <- rnorm(nrow(centroids_avg))
nulls <- generate_nulls(
parcel_map,
method = "moran",
distmat = parcel_distmat,
n_perm = 100L,
seed = 1
)
nulls
#>
#> ── Null Distribution
#> • Method: moran
#> • Permutations: 100
#> • Observations: 10Parcellation and null models together
The parcel spin methods (baum and
cornblath) combine parcellation with spin-based null
generation. They need both spherical coordinates and the parcellation
vector:
n_lh <- 680
n_rh <- 680
sphere_coords <- list(
lh = matrix(rnorm(n_lh * 3), ncol = 3),
rh = matrix(rnorm(n_rh * 3), ncol = 3)
)
parcellation <- rep(1:68, each = 20)
parcel_data <- rnorm(68)
nulls_baum <- null_baum(
parcel_data, sphere_coords, parcellation,
n_perm = 50L, seed = 1
)
nulls_baum
#>
#> ── Null Distribution
#> • Method: baum
#> • Permutations: 50
#> • Observations: 68The rotation happens at vertex resolution, but the output is parcel-level: one surrogate value per parcel per permutation. This is the right approach when your analysis variable is measured at the parcel level but you want the null model to respect vertex-level spatial structure.
Practical considerations
Parcellation size matters for null models. With
30–50 parcels, distance-based methods like burt2020 and
moran work well. With hundreds of parcels (e.g., Schaefer
400), spin-based methods become more attractive because they leverage
the full vertex-level spatial structure.
Medial wall handling. All parcellation functions
treat label 0 and NA as medial wall. The
cornblath null model handles medial wall vertices
gracefully during spin-based reassignment. If your parcellation has
substantial medial wall coverage, prefer cornblath over
baum.
The summary function determines what you are
testing. Using mean (the default) tests whether
the average value in each region relates to another variable. Using
sd would test whether within-region variability relates to
it. Choose deliberately.
References
Markello RD, Hansen JY, Liu Z-Q, et al. (2022). neuromaps: structural and functional interpretation of brain maps. Nature Methods, 19, 1472–1480. doi:10.1038/s41592-022-01625-w