---
title: Coordinate Systems and Spatial Transforms
output: rmarkdown::html_vignette
vignette: |
%\VignetteIndexEntry{Coordinate Systems and Spatial Transforms}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
params:
family: red
preset: homage
css: albers.css
resource_files:
- albers.css
- albers.js
includes:
in_header: |-
---
```{r setup, include = FALSE}
if (requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("albersdown", quietly = TRUE)) ggplot2::theme_set(albersdown::theme_albers(family = params$family, preset = params$preset))
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>",
message = FALSE,
warning = FALSE
)
```
```{r load-pkg}
library(neuroim2)
```
Every neuro object in neuroim2 — a `NeuroVol`, a `NeuroVec`, an ROI — carries a
`NeuroSpace` that answers one question: *where in the scanner room does this
voxel sit?* Get that mapping right and anatomical coordinates, atlas overlays,
and multi-subject registration all fall into place. Get it wrong and your
activations end up in the wrong hemisphere.
This vignette builds the mental model from the ground up: what a `NeuroSpace`
is, how the affine transform works, and how to move fluently between voxel
indices, grid coordinates, and millimetre world coordinates.
---
## What is a NeuroSpace?
A `NeuroSpace` is the spatial reference frame attached to every neuro image. It
bundles:
- **Grid dimensions** — how many voxels along each axis (`dim`)
- **Voxel spacing** — physical size of each voxel in millimetres (`spacing`)
- **Origin** — world coordinates of voxel `(1, 1, 1)` in mm (`origin`)
- **Affine transform** — the 4 × 4 matrix that maps voxel indices to mm (`trans`)
- **Axis orientation** — which anatomical direction each image axis points (`axes`)
```{r create-space}
sp <- NeuroSpace(
dim = c(64L, 64L, 40L),
spacing = c(2, 2, 2),
origin = c(-90, -126, -72)
)
sp
```
```{r inspect-space}
dim(sp) # grid dimensions
spacing(sp) # voxel sizes in mm
origin(sp) # world coords of voxel (1,1,1)
ndim(sp) # number of spatial dimensions
```
The `NeuroSpace` is also the bridge to any volume or time-series built on top of
it. Call `space(x)` on any neuro object to retrieve it.
---
## The Affine Transform
The affine transform is a 4 × 4 homogeneous matrix stored in `trans(sp)`. It
maps a voxel position (expressed as a zero-based column vector) to millimetre
world coordinates:
```
[x_mm] [M t] [i]
[y_mm] = [ [j]
[z_mm] 0 1] [k]
[ 1 ] [1]
```
where `M` is the 3 × 3 linear part (encodes spacing, rotation, and possible
shear) and `t` is the 3-element translation (the origin in mm).
For an axis-aligned image built from `spacing` and `origin`, `M` is diagonal:
```{r show-trans}
trans(sp)
```
The translation column (`trans(sp)[1:3, 4]`) recovers the origin, and the
diagonal of the linear block recovers the voxel sizes:
```{r decompose-affine}
# Translation column = origin
trans(sp)[1:3, 4]
# Diagonal of linear block = voxel sizes
diag(trans(sp)[1:3, 1:3])
```
The inverse affine (world -> voxel) is cached and accessible via
`inverse_trans(sp)`:
```{r inverse-trans}
inverse_trans(sp)
```
### Passing an explicit affine
You can supply a full 4 × 4 affine directly to `NeuroSpace()`. neuroim2 will
derive `spacing` and `origin` from it automatically:
```{r explicit-affine}
aff <- diag(c(3, 3, 4, 1)) # 3 × 3 × 4 mm voxels
aff[1:3, 4] <- c(-90, -126, -72) # origin
sp_aff <- NeuroSpace(dim = c(60L, 60L, 35L), trans = aff)
spacing(sp_aff) # derived from column norms of linear block
origin(sp_aff) # derived from translation column
```
When the affine is oblique (rotated relative to the scanner axes), `spacing()`
returns the column norms of the linear block — the true physical voxel sizes —
while the diagonal of the linear block would be smaller. See the *Oblique
affines* section below.
---
## Voxel vs World Coordinates
neuroim2 uses **1-based grid indices** everywhere, matching R's array
conventions. The affine convention is therefore:
> `grid_to_coord` subtracts 1 from each 1-based index before applying the
> affine, placing voxel `(1, 1, 1)` at the origin.
There are two flavours of voxel addressing:
| Address type | Range | Description |
|:-------------|:------|:------------|
| Linear index | `1 ... prod(dim)` | Single integer; column-major (as in R arrays) |
| Grid index | `(1...d1, 1...d2, 1...d3)` | 3-tuple of 1-based integers |
And one world-coordinate system: millimetres, defined by the affine.
```{r coord-systems-diagram, echo=FALSE, fig.cap="The three addressing schemes and the functions that convert between them.", fig.width=5.5, fig.height=2.2}
op <- par(mar = c(0, 0, 0, 0))
plot.new()
plot.window(xlim = c(0, 10), ylim = c(0, 3))
# Boxes
rect(0.2, 0.8, 2.8, 2.2, col = "#dce8f5", border = "#3a7abf", lwd = 1.5)
rect(3.7, 0.8, 6.3, 2.2, col = "#dce8f5", border = "#3a7abf", lwd = 1.5)
rect(7.2, 0.8, 9.8, 2.2, col = "#dce8f5", border = "#3a7abf", lwd = 1.5)
text(1.5, 1.7, "Linear\nindex", cex = 0.85, font = 2)
text(1.5, 1.15, "1 ... prod(dim)", cex = 0.68, col = "#555555")
text(5.0, 1.7, "Grid\nindex", cex = 0.85, font = 2)
text(5.0, 1.15, "(i, j, k) 1-based", cex = 0.68, col = "#555555")
text(8.5, 1.7, "World\ncoords", cex = 0.85, font = 2)
text(8.5, 1.15, "x, y, z mm", cex = 0.68, col = "#555555")
# Arrows and labels
arrows(2.85, 1.5, 3.65, 1.5, length = 0.08, lwd = 1.4, col = "#3a7abf")
arrows(3.65, 1.3, 2.85, 1.3, length = 0.08, lwd = 1.4, col = "#888888")
text(3.25, 1.75, "index_to_grid", cex = 0.58, col = "#3a7abf")
text(3.25, 1.05, "grid_to_index", cex = 0.58, col = "#888888")
arrows(6.35, 1.5, 7.15, 1.5, length = 0.08, lwd = 1.4, col = "#3a7abf")
arrows(7.15, 1.3, 6.35, 1.3, length = 0.08, lwd = 1.4, col = "#888888")
text(6.75, 1.75, "grid_to_coord", cex = 0.58, col = "#3a7abf")
text(6.75, 1.05, "coord_to_grid", cex = 0.58, col = "#888888")
# Shortcut arrows below
arrows(2.85, 0.9, 7.15, 0.9, length = 0.08, lwd = 1.2, col = "#3a7abf", lty = 2)
arrows(7.15, 0.7, 2.85, 0.7, length = 0.08, lwd = 1.2, col = "#888888", lty = 2)
text(5.0, 1.0, "index_to_coord", cex = 0.55, col = "#3a7abf")
text(5.0, 0.6, "coord_to_index", cex = 0.55, col = "#888888")
par(op)
```
---
## Coordinate Conversions
All conversion functions accept a `NeuroSpace` (or any neuro object, via its
attached space) as the first argument.
### Grid <-> linear index
```{r grid-index}
# Which linear index does grid position (10, 12, 5) map to?
grid_to_index(sp, matrix(c(10, 12, 5), nrow = 1))
# And back to grid
index_to_grid(sp, 8394L)
```
### Grid -> world coordinates
`grid_to_coord` subtracts 1 from the 1-based grid before applying the affine,
so grid `(1, 1, 1)` maps exactly to the origin:
```{r grid-to-coord}
grid_to_coord(sp, matrix(c(1, 1, 1), nrow = 1)) # should equal origin(sp)
grid_to_coord(sp, matrix(c(10, 12, 5), nrow = 1))
```
Multiple points are passed as a matrix with one row per point:
```{r grid-to-coord-multi}
pts <- matrix(c(
1, 1, 1,
32, 32, 20,
64, 64, 40
), ncol = 3, byrow = TRUE)
grid_to_coord(sp, pts)
```
### World -> grid coordinates
```{r coord-to-grid}
coord_to_grid(sp, c(0, 0, 0)) # near isocenter
coord_to_grid(sp, matrix(c(0, 0, 0,
10, -20, 8), ncol = 3, byrow = TRUE))
```
### Convenience: linear index <-> world
```{r index-coord}
# Linear index 1 should land at the origin (pass integer)
index_to_coord(sp, 1L)
# World coord back to linear index
coord_to_index(sp, matrix(c(-90, -126, -72), nrow = 1))
```
### A concrete round-trip
```{r roundtrip}
idx <- 12345L
grid <- index_to_grid(sp, idx)
world <- grid_to_coord(sp, grid)
back <- coord_to_index(sp, world)
cat("index:", idx, "-> grid:", grid, "-> world:", round(world, 2),
"-> index:", back, "\n")
```
---
## Orientation Codes
Neuroimaging images can be stored in many orientations. The **orientation code**
(also called axis codes or RAS codes) tells you which anatomical direction each
image axis points:
| Letter | Anatomical direction |
|:------:|:---------------------|
| R | increasing -> Right |
| L | increasing -> Left |
| A | increasing -> Anterior |
| P | increasing -> Posterior |
| S | increasing -> Superior |
| I | increasing -> Inferior |
A code of `"RAS"` means: axis 1 runs left-to-right, axis 2 runs
posterior-to-anterior, axis 3 runs inferior-to-superior. This is the standard
NIfTI/MNI convention. `"LPI"` (sometimes called "neurological") reverses all
three.
`affine_to_axcodes()` reads the orientation directly from the affine matrix:
```{r axcodes}
affine_to_axcodes(trans(sp))
```
The default axis-aligned `NeuroSpace` built above uses the nearest-anatomy
convention inferred from the affine. You can also inspect the `axes` slot
directly:
```{r axes-slot}
axes(sp)
```
### Reorienting a space
`reorient()` flips and permutes the affine to match a target orientation string:
```{r reorient}
sp_ras <- reorient(sp, c("R", "A", "S"))
affine_to_axcodes(trans(sp_ras))
```
---
## Creating NeuroSpaces
### From dim + spacing + origin (axis-aligned)
The simplest case: an isotropic or anisotropic grid with no rotation.
```{r create-simple}
# 2 mm isotropic, MNI-ish origin
sp_mni <- NeuroSpace(
dim = c(91L, 109L, 91L),
spacing = c(2, 2, 2),
origin = c(-90, -126, -72)
)
sp_mni
```
### From an explicit affine
When you have a NIfTI sform/qform matrix, pass it directly:
```{r create-from-affine}
# Oblique affine: slight off-diagonal terms (scanner tilt)
aff_obl <- matrix(c(
2.0, 0.2, 0.0, -90,
0.0, 2.0, 0.1, -126,
0.0, 0.0, 2.0, -72,
0.0, 0.0, 0.0, 1
), nrow = 4, byrow = TRUE)
sp_obl <- NeuroSpace(dim = c(91L, 109L, 91L), trans = aff_obl)
# spacing() returns column norms — the true physical voxel sizes
spacing(sp_obl)
# diagonal is not exactly 2 mm when the image is tilted
diag(aff_obl[1:3, 1:3])
```
### When does spacing() differ from the affine diagonal?
`spacing()` always returns the column norms of the 3 × 3 linear block — the
physical length of each voxel edge. For a pure diagonal affine these equal the
diagonal entries. For a rotated or oblique affine they differ. Always use
`spacing()` for physical voxel sizes; never rely on the diagonal directly.
You can also compute voxel sizes from any affine matrix with `voxel_sizes()`:
```{r voxel-sizes}
voxel_sizes(aff_obl)
```
---
## Dimension Manipulation
### Adding a time dimension: add_dim
`add_dim()` extends a 3D `NeuroSpace` to 4D by appending a new dimension. The
spatial affine is preserved unchanged:
```{r add-dim}
sp_3d <- NeuroSpace(c(64L, 64L, 40L), spacing = c(2, 2, 2),
origin = c(-90, -126, -72))
sp_4d <- add_dim(sp_3d, 200) # 200 time points
dim(sp_4d)
trans(sp_4d) # 4x4 spatial affine unchanged
```
### Dropping the time dimension: drop_dim
`drop_dim()` removes the last (or a named) dimension:
```{r drop-dim}
sp_back <- drop_dim(sp_4d)
dim(sp_back)
all.equal(trans(sp_back), trans(sp_3d)) # affine preserved
```
This round-trip is used internally whenever a 4D volume is sliced to a single
time point.
---
## Resampling and Reorientation
### resample — change voxel size, keep geometry
`resample()` resamples a `NeuroVol` into a target space (or another volume's
space). To demonstrate, build a small volume and resample it to a coarser grid:
```{r resample-space}
vol_mni <- NeuroVol(array(rnorm(prod(dim(sp_mni))), dim(sp_mni)), sp_mni)
sp_coarse <- NeuroSpace(c(45L, 54L, 45L), spacing = c(4, 4, 4),
origin = origin(sp_mni))
vol_coarse <- resample(vol_mni, sp_coarse)
dim(vol_coarse)
spacing(space(vol_coarse))
```
### deoblique — remove scanner tilt
Many scanners acquire data with a slight tilt relative to the MNI axes. The
resulting affine has non-zero off-diagonal elements (an *oblique* affine).
`deoblique()` builds an axis-aligned output space that encloses the original
field of view, using the minimum input voxel size by default (AFNI-style):
```{r deoblique}
sp_deob <- deoblique(sp_obl)
affine_to_axcodes(trans(sp_deob)) # now axis-aligned
obliquity(trans(sp_deob)) # near zero
```
When passed a `NeuroVol`, `deoblique()` also resamples the image data into the
new space.
---
## Common Gotchas
**1. Oblique affines and spacing()**
If `diag(trans(sp)[1:3, 1:3])` does not match `spacing(sp)`, the affine is
oblique. Use `obliquity(trans(sp))` to quantify the tilt angle (in radians) per
axis.
```{r gotcha-obliquity}
obliquity(aff_obl) # non-zero: image is slightly tilted
obliquity(trans(sp_mni)) # near zero: axis-aligned
```
**2. 1-based grid indices**
R uses 1-based indexing. `grid_to_coord()` handles this by subtracting 1 before
applying the affine, so voxel `(1, 1, 1)` lands at `origin(sp)`. If you ever
use raw affine arithmetic, remember to subtract 1 from your 1-based grid
coordinates first.
**3. NIfTI sform vs qform**
NIfTI files store two possible affines (sform and qform). `read_vol()` and
`read_vec()` follow the NIfTI priority rules: sform_code > 0 -> use sform;
otherwise use qform. The resulting `trans(space(img))` reflects whichever was
chosen. You can inspect the header with `read_header()` if you need to see both.
**4. Float32 precision**
NIfTI stores affine coefficients as 32-bit floats. neuroim2 rounds the affine
to 7 significant figures on construction (`signif(trans, 7)`) to match this
precision. Round-trip coordinates through `index_to_coord` / `coord_to_index`
may therefore show sub-voxel floating-point noise at the ~0.001 mm level.
---
## Quick Reference
| Function | Input -> Output | Typical use |
|:---------|:---------------|:------------|
| `grid_to_index(sp, mat)` | grid -> linear index | looking up voxel data |
| `index_to_grid(sp, idx)` | linear index -> grid | converting R array subscripts |
| `grid_to_coord(sp, mat)` | grid -> mm | overlay on anatomy |
| `coord_to_grid(sp, mat)` | mm -> grid | atlas lookup |
| `index_to_coord(sp, idx)` | linear index -> mm | shortcut past grid |
| `coord_to_index(sp, mat)` | mm -> linear index | mask extraction |
| `affine_to_axcodes(aff)` | affine -> `"RAS"` etc. | orientation check |
| `voxel_sizes(aff)` | affine -> mm vector | physical voxel size |
| `obliquity(aff)` | affine -> radians | tilt check |
| `add_dim(sp, n)` | 3D space -> 4D space | attach time axis |
| `drop_dim(sp)` | 4D space -> 3D space | strip time axis |
| `resample(sp, spacing)` | space -> resampled space | change resolution |
| `reorient(sp, codes)` | space -> reoriented space | standardise orientation |
| `deoblique(sp)` | oblique space -> aligned space | remove scanner tilt |
## Where to go next
- `vignette("ImageVolumes")` — creating and manipulating `NeuroVol` objects
- `vignette("NeuroVector")` — working with 4D time-series (`NeuroVec`)
- `vignette("Resampling")` — image resampling in depth
- `vignette("regionOfInterest")` — ROI construction and coordinate-based queries
- `?NeuroSpace`, `?affine_to_axcodes`, `?deoblique` — function reference pages