knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 3.2, dpi = 96, out.width = "100%" ) library(ravetools) oldpar <- graphics::par(no.readonly = TRUE)
The vcg_* and mris_* families are ravetools' low-level geometry layer.
They are the building blocks that the surface pipelines are assembled from, and
they are usable directly. This article walks the whole family, grouped the way
the README table groups it: construct, measure, repair, smooth, query,
subset, and draw.
Every mesh these functions return carries the ravetools_mesh3d class, which
is an rgl-style mesh3d -- a 3 x n vertex matrix vb and a 3 x m
one-based triangle matrix it -- so plot() renders it in base R graphics
with no rgl dependency. ensure_mesh3d coerces the other common surface
formats (fs.surface from freesurferformats, ieegio_surface from ieegio,
surf.asc) into the same layout, so a FreeSurfer surface drops into any
function below.
The cheapest way to get a mesh is to make one. vcg_sphere returns a
subdivided icosphere of unit radius centered at the origin, and
plane_geometry returns a flat triangulated grid in the z = 0 plane.
sphere <- vcg_sphere(sub_division = 4) sphere plane <- plane_geometry(width = 2.6, height = 2.6, shape = c(40, 40)) plane
shape counts vertices per side, so a 40 x 40 grid is 39 x 39 cells and
twice that many triangles. Tilt the plane and lift it, and it slices through
the sphere:
tilt <- new_matrix4()$make_rotation_y(25 * pi / 180)$to_array() plane$vb <- (tilt %*% rbind(plane$vb[1:3, ], 1))[1:3, ] + c(0, 0, 0.35)
new_matrix4 is one of the in-place geometry classes (new_vector3,
new_matrix4, new_quaternion); to_array hands back the plain 4x4 matrix.
Faces alone do not carry orientation. vcg_update_normals computes per-vertex
normals, which the smoothing, projection, and ray-casting routines all read:
plane <- vcg_update_normals(plane, weight = "area") dim(plane$normals)
Three functions answer "where is this thing relative to that thing", and they trade accuracy against cost differently.
vcg_detect_collision reports, for every element of y, whether it comes
within radius of x, together with the exact minimum distance. Either side
can be a point cloud, a chain of connected line segments, or a triangular mesh,
so one call covers all nine pairings. Both sides here are meshes, so answers
come back one per triangle of y:
cut <- vcg_detect_collision(sphere, plane, radius = 0.02) cut$summary$y$unit_type sum(cut$hit_unit) head(cut$representation)
representation has one row per hit: unit is the plane triangle, index is
the vertex of that triangle nearest the sphere, distance is the true minimum
distance, and x_index is the sphere face that was hit. Keeping those triangles
gives the intersection band as a mesh in its own right:
band <- plane band$it <- plane$it[, which(cut$hit_unit), drop = FALSE] band
The radius argument makes this a proximity test rather than a contact test.
Raising it thickens the band, because more triangles fall inside the tolerance:
vapply(c(0.02, 0.1, 0.3), function(r) { sum(vcg_detect_collision(sphere, plane, radius = r)$hit_unit) }, 0L)
Line segments are the case worth knowing about, because that is how diffusion
streamlines and electrode shafts arrive. Several chains share one matrix and
are delimited by rows of NA:
streamlines <- rbind( cbind(seq(-3, 3, by = 0.5), 0, 0), c(NA, NA, NA), cbind(seq(-3, 3, by = 0.5), 5, 0) ) res <- vcg_detect_collision(sphere, streamlines, mode_y = "segments", radius = 0.1) res$hit_unit res$representation
The first chain runs through the sphere and the second passes well clear of it.
test_level controls how hard the scan works: "element" (the default)
measures every segment and reports the closest, "unit" stops at the first hit
inside each chain, and "whole" stops at the first hit anywhere and answers
only the yes-or-no question:
vcg_detect_collision(sphere, streamlines, mode_y = "segments", radius = 0.1, test_level = "whole")$collide
Finally, include_interior decides whether geometry buried inside a closed x
counts as a collision even when it never approaches the surface. The center of
the sphere is one unit away from every point of it:
center <- rbind(c(0, 0, 0)) vcg_detect_collision(sphere, center, radius = 0.1)$collide vcg_detect_collision(sphere, center, radius = 0.1, include_interior = TRUE)$collide
That test uses ray casting, so it needs x to be watertight with coherently
oriented faces -- see vcg_fix_defects below for repairing one that is not.
vcg_kdtree_nearest builds a K-D tree over target and, for each point of
query, returns the k closest target points and their distances. Unlike
vcg_detect_collision it measures to the nearest vertex, not to the surface,
so it is an approximation -- a cheaper one that is usually good enough, and the
right tool when the identity of the nearest vertex is what is wanted:
kd <- vcg_kdtree_nearest(target = sphere, query = plane, k = 1) str(kd) range(kd$distance)
Asking for more than one neighbor widens both matrices to k columns, which is
how you get a local neighborhood to average over:
kd3 <- vcg_kdtree_nearest(target = sphere, query = plane, k = 3) head(kd3$index)
Both arguments accept either a mesh or a plain n x 3 matrix, and 2D points
work as well as 3D.
vcg_raycaster shoots a ray from each origin along each direction and reports
the first face it meets. Lift the plane clear of the sphere and drop a ray
straight down from every vertex:
above <- plane$vb above[3, ] <- above[3, ] + 1.8 rays <- vcg_raycaster(sphere, ray_origin = above, ray_direction = c(0, 0, -1)) str(rays[c("has_intersection", "distance", "face_index")]) sum(rays$has_intersection)
Rays whose origin lies over the sphere's silhouette hit it; the rest report
has_intersection = FALSE, NA for distance and face_index, and an
intersection column that should be ignored. both_sides = TRUE also searches
backwards along the ray, and max_distance caps how far it travels.
The hits form a point cloud, which is a mesh with vertices and no faces:
pierce <- structure( list(vb = rays$intersection[, rays$has_intersection, drop = FALSE]), class = "mesh3d" )
Putting the three answers side by side:
col <- color_ramp_continuous(kd$distance[, 1], cmap = c("#f2f2f2", "#7fa8c9", "#2c5f8a")) graphics::par(mfrow = c(1, 2), mar = c(0.1, 0.1, 2.1, 0.1), cex.main = 0.95) plot_mesh_polygon( list(sphere, plane, band), col = list("gray55", col, "#a33a3a"), alpha = c(0.5, 0.92, 1), eye = c(3.5, -4, 2.2), up = c(0, 0, 1), zoom = 1.15, shadow_color = "white", ambient_intensity = 0.55, main = "Distance; cut faces in red" ) plot_mesh_polygon( list(sphere, pierce), col = list("gray72", "#a33a3a"), cex = 0.03, eye = c(3.5, -4, 2.2), up = c(0, 0, 1), zoom = 0.68, shadow_color = "white", ambient_intensity = 0.55, main = "Ray entry points" )
Real surfaces come from volumes. vcg_isosurface runs marching cubes over a
3D array and returns the triangulation, mapping voxel indices into RAS
through vox_to_ras:
data("left_hippocampus_mask", package = "ravetools") dim(left_hippocampus_mask) raw_mesh <- vcg_isosurface(left_hippocampus_mask) raw_mesh
mesh_from_volume wraps the same step together with re-sampling and smoothing,
which is convenient when the goal is a display surface rather than an exact
level set:
smoothed <- mesh_from_volume( left_hippocampus_mask, output_format = "rgl", threshold = 0.5, remesh = TRUE, remesh_voxel_size = 1, smooth = TRUE, verbose = FALSE ) smoothed
mris_make_surfaces is the fourth constructor. It deforms an existing surface
along the intensity gradient of a volume to produce paired white and pial
surfaces, the way a cortical reconstruction does.
Before doing anything to a mesh it is worth asking what shape it is in.
vcg_count_edge_defects counts the two things that break most algorithms:
vcg_count_edge_defects(raw_mesh)
Forty boundary edges means the marching-cubes output has holes, so it is not
closed. vcg_mesh_volume says so itself rather than quietly returning a
number that means nothing:
vcg_mesh_volume(raw_mesh) vcg_average_edge_length(raw_mesh) vcg_max_edge_length(raw_mesh)
The two edge-length measures are the ones to check before any operation with a length parameter, since they say what scale the mesh is sampled at.
mris_curvature returns the four standard curvature fields per vertex --
mean, Gaussian, and the two principal curvatures:
curv <- mris_curvature(raw_mesh) str(curv)
vcg_fix_defects merges duplicate vertices, fills holes, and reorients faces,
and it reports what it did in an info attribute:
mesh <- vcg_fix_defects(raw_mesh, verbose = FALSE) info <- attr(mesh, "info") info[c("boundary_edges_before", "boundary_edges_after", "holes_filled", "is_closed_manifold")] # the same call that warned above, now on a closed surface vcg_mesh_volume(mesh)
The mesh is a closed manifold now, so its volume is trustworthy and
include_interior collision tests will work on it. Center it for the plots that
follow:
mesh$vb[1:3, ] <- mesh$vb[1:3, ] - rowMeans(mesh$vb[1:3, ])
Four routines change the sampling. They differ in what they preserve:
uniform <- vcg_uniform_remesh(mesh, voxel_size = 1, verbose = FALSE) split <- vcg_subdivision(mesh, method = "edge") capped <- vcg_subdivide_max_edge_length(mesh, max_edge_len = 0.8) isotropic <- mris_remesh(mesh, target_edge_length = 1.5, verbose = FALSE) data.frame( method = c("input", "vcg_uniform_remesh", "vcg_subdivision", "vcg_subdivide_max_edge_length", "mris_remesh"), vertices = c(ncol(mesh$vb), ncol(uniform$vb), ncol(split$vb), ncol(capped$vb), ncol(isotropic$vb)), avg_edge = round(vapply(list(mesh, uniform, split, capped, isotropic), vcg_average_edge_length, 0), 3), max_edge = round(vapply(list(mesh, uniform, split, capped, isotropic), vcg_max_edge_length, 0), 3) )
vcg_uniform_remesh re-samples through a distance field on a voxel grid, so
it also repairs topology at the cost of exact geometry. vcg_subdivision
splits every edge, doubling resolution everywhere. vcg_subdivide_max_edge_length
splits only edges above a threshold, leaving well-sampled regions alone.
mris_remesh implements the isotropic re-meshing of Botsch and Kobbelt
(2003), driving every edge toward one target length, and is the one to reach for
when downstream code cares about triangle quality.
Two smoothers come from vcglib and one from the cortical-surface literature:
taubin <- vcg_smooth_explicit(mesh, type = "taubin", iteration = 10) implicit <- vcg_smooth_implicit(mesh, lambda = 0.2, degree = 2) fs_style <- mris_smooth(mesh, niterations = 20L) vapply(list(mesh, taubin, implicit, fs_style), vcg_mesh_volume, 0)
vcg_smooth_explicit applies a per-vertex Laplacian step repeatedly;
type = "taubin" alternates a positive and a negative step so the surface does
not shrink. vcg_smooth_implicit solves for the smoothed positions in one
sparse solve, which is stable at much larger lambda. Both preserve the vertex
count; only the positions move.
mris_inflate goes further, flattening the folds while preserving total
surface area. It also returns the sulc depth field -- how far each vertex
traveled -- which is what makes an inflated surface readable:
inflated <- mris_inflate(fs_style, n_averages = 4L, niterations = 8L, scale_brain = FALSE, verbose = FALSE) names(inflated) range(inflated$sulc)
mris_sphere continues the deformation all the way onto a sphere of
target_radius, which is the mapping that surface-based registration is built
on. How close it gets is worth checking, because the residual spread in vertex
radius is the honest measure of convergence:
spherical <- mris_sphere(fs_style, target_radius = 100, verbose = FALSE) radius <- sqrt(colSums(spherical$vb[1:3, ]^2)) c(min = min(radius), max = max(radius), cv = stats::sd(radius) / mean(radius))
Under two percent here. Both mris_inflate and mris_sphere were designed for
cortical surfaces, where the input is large, smooth, and genuinely
sphere-like; a small closed structure such as this hippocampus maps only
approximately, and raising niterations past the default makes it worse rather
than better on such an input.
Mapped side by side with mean curvature, the first two stages show what inflation preserves:
curv <- mris_curvature(fs_style) lim <- stats::quantile(abs(curv$mean), 0.95) curv_col <- color_ramp_continuous( curv$mean, clim = c(-lim, lim), cmap = c("#2c5f8a", "#f2f2f2", "#a33a3a") ) graphics::par(mfrow = c(1, 2), mar = c(0.1, 0.1, 2.1, 0.1)) plot_mesh_polygon(fs_style, col = curv_col, eye = c(0, 100, 30), up = c(0, 0, 1), zoom = 1.1, main = "Mean curvature") plot_mesh_polygon(inflated$mesh, col = curv_col, eye = c(0, 100, 30), up = c(0, 0, 1), zoom = 1.1, main = "The same, inflated")
vcg_subset_vertex keeps the vertices a logical selector marks and the faces
whose corners all survive:
selector <- mesh$vb[1, ] > 0 half <- vcg_subset_vertex(mesh, selector) c(input = ncol(mesh$vb), kept = ncol(half$vb))
vcg_mesh_patch cuts a mesh along a closed loop of waypoints. They are
snapped to the nearest vertices, the loop between them is walked, and the result
is a two-element list: the enclosed patch and everything else.
target <- vcg_uniform_remesh(vcg_sphere(), verbose = FALSE) patches <- vcg_mesh_patch(target, waypoints = diag(1, 3)) vapply(patches, function(p) ncol(p$it), 0L)
graphics::par(mar = c(0.1, 0.1, 0.1, 0.1)) plot_mesh_polygon(patches, col = list("#a33a3a", "gray70"), alpha = c(1, 0.55), eye = c(10, 10, 10), zoom = 1.2, shadow_color = "white", ambient_intensity = 0.55)
dijkstras_surface_distance walks the mesh graph from a start vertex and
returns the geodesic distance to every other vertex; surface_path then reads
the shortest path to any target back out of that result. Note that it takes the
transposed matrices -- one row per vertex and one row per face:
dist <- dijkstras_surface_distance( positions = t(mesh$vb[1:3, ]), faces = t(mesh$it), start_node = 1, face_index_start = 1 ) path <- surface_path(dist, target_node = ncol(mesh$vb)) c(vertices_on_path = length(path$path), length = max(path$distance))
Because the walk follows edges, the answer depends on the sampling -- which is
the practical reason to run mris_remesh before measuring distances across a
surface.
plot_mesh_polygon projects every triangle with an orthographic camera, shades
it by how directly it faces that camera, depth-sorts everything, and draws it in
a single polygon call. plot_mesh_dotcloud draws vertices as rim-lit dots
instead. plot() dispatches to whichever suits the mesh.
Both take a list of meshes and render them into one shared depth space, so
several surfaces compose correctly. For one mesh, col is a single color, a
character vector of one color per vertex, or any other vector, which is read as
a depth gradient; for a list of meshes, pass a list of those, one element per
mesh. alpha is one value per mesh. A mesh with no face matrix is drawn as one
small vcg_sphere per vertex, scaled by cex -- which is how the ray hits in
section 2 were rendered.
Two controls are worth knowing. mesh_clipping discards triangles whose normal
points along the camera ray, peeling the front cap off a closed surface; pair it
with side = "both" so the exposed back wall is drawn rather than culled in
turn. clipping_plane culls faces against arbitrary world-space planes instead
-- each a length-5 vector of a normal, a signed offset, and which half-space to
keep -- and clipping_plane_enabled exempts individual meshes from it, which is
how electrodes stay whole while the surface around them is cut away.
Scattering some probes inside the surface -- found with the same interior test from section 2 -- shows why the exemption matters: cut the surface open and the probes stay whole.
bbox <- apply(fs_style$vb[1:3, ], 1L, range) set.seed(1) candidates <- cbind( stats::runif(600, bbox[1, 1], bbox[2, 1]), stats::runif(600, bbox[1, 2], bbox[2, 2]), stats::runif(600, bbox[1, 3], bbox[2, 3]) ) inside <- vcg_detect_collision(fs_style, candidates, include_interior = TRUE)$hit_unit %in% TRUE probes <- structure( list(vb = t(candidates[inside, , drop = FALSE])), class = "mesh3d" ) sum(inside) eye <- c(0, 100, 30) graphics::par(mfrow = c(1, 3), mar = c(0.1, 0.1, 2.1, 0.1), cex.main = 0.95) plot_mesh_polygon(fs_style, col = "steelblue", eye = eye, up = c(0, 0, 1), zoom = 1.1, main = "Whole surface") plot_mesh_polygon( list(fs_style, probes), col = list("steelblue", "#a33a3a"), cex = 1.1, eye = eye, up = c(0, 0, 1), zoom = 1.1, clipping_plane = c(0, 1, 0, 0, -1), clipping_plane_enabled = c(TRUE, FALSE), main = "Cut open, probes exempt" ) # `plot_mesh_dotcloud` has no `main`; add the title afterwards plot_mesh_dotcloud(fs_style, col = "steelblue", eye = eye, up = c(0, 0, 1), zoom = 1.1, cex = 0.45) graphics::title(main = "plot_mesh_dotcloud")
If rgl is installed, rgl_view and rgl_call drive an interactive window
using the same meshes, and rgl_plot_normals draws the normal field:
rgl_view({ rgl_call("shade3d", mesh, col = "steelblue") rgl_call("wire3d", mesh, col = "black") })
graphics::par(oldpar)
Botsch, M, and Kobbelt, L (2003). A remeshing approach to
multiresolution modeling. Proceedings of the 2004
Eurographics/ACM SIGGRAPH Symposium on Geometry Processing, 185-192.
Fischl, B, Sereno, MI, and Dale, AM (1999). Cortical surface-based analysis
II: inflation, flattening, and a surface-based coordinate system.
NeuroImage, 9(2), 195-207.
The vcg_* functions are built on
vcglib from the Visual Computing
Lab, ISTI.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.