# Coordinate Systems and Spatial Transforms

    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`)

    sp <- NeuroSpace(
      dim     = c(64L, 64L, 40L),
      spacing = c(2, 2, 2),
      origin  = c(-90, -126, -72)
    )
    sp
    #> <NeuroSpace> [3D]
    #> ── Geometry ────────────────────────────────────────────────────────────────────
    #>   Dimensions    : 64 x 64 x 40
    #>   Spacing       : 2 x 2 x 2 mm
    #>   Origin        : -90, -126, -72
    #>   Orientation   : RAS
    #>   Voxels        : 163,840

    dim(sp)       # grid dimensions
    #> [1] 64 64 40
    spacing(sp)   # voxel sizes in mm
    #> [1] 2 2 2
    origin(sp)    # world coords of voxel (1,1,1)
    #> [1]  -90 -126  -72
    ndim(sp)      # number of spatial dimensions
    #> [1] 3

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:

    trans(sp)
    #>      [,1] [,2] [,3] [,4]
    #> [1,]    2    0    0  -90
    #> [2,]    0    2    0 -126
    #> [3,]    0    0    2  -72
    #> [4,]    0    0    0    1

The translation column (`trans(sp)[1:3, 4]`) recovers the origin, and the diagonal of the linear block recovers the voxel sizes:

    # Translation column = origin
    trans(sp)[1:3, 4]
    #> [1]  -90 -126  -72

    # Diagonal of linear block = voxel sizes
    diag(trans(sp)[1:3, 1:3])
    #> [1] 2 2 2

The inverse affine (world -> voxel) is cached and accessible via `inverse_trans(sp)`:

    inverse_trans(sp)
    #>      [,1] [,2] [,3] [,4]
    #> [1,]  0.5  0.0  0.0   45
    #> [2,]  0.0  0.5  0.0   63
    #> [3,]  0.0  0.0  0.5   36
    #> [4,]  0.0  0.0  0.0    1

### Passing an explicit affine

You can supply a full 4 × 4 affine directly to `NeuroSpace()`. neuroim2 will derive `spacing` and `origin` from it automatically:

    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
    #> [1] 3 3 4
    origin(sp_aff)    # derived from translation column
    #> [1]  -90 -126  -72

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.

The three addressing schemes and the functions that convert between them.

## Coordinate Conversions

All conversion functions accept a `NeuroSpace` (or any neuro object, via its attached space) as the first argument.

### Grid <-> linear index

    # Which linear index does grid position (10, 12, 5) map to?
    grid_to_index(sp, matrix(c(10, 12, 5), nrow = 1))
    #> [1] 17098

    # And back to grid
    index_to_grid(sp, 8394L)
    #>      [,1] [,2] [,3]
    #> [1,]   10    4    3

### 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:

    grid_to_coord(sp, matrix(c(1, 1, 1), nrow = 1))   # should equal origin(sp)
    #>      [,1] [,2] [,3]
    #> [1,]  -90 -126  -72
    grid_to_coord(sp, matrix(c(10, 12, 5), nrow = 1))
    #>      [,1] [,2] [,3]
    #> [1,]  -72 -104  -64

Multiple points are passed as a matrix with one row per point:

    pts <- matrix(c(
       1,  1,  1,
      32, 32, 20,
      64, 64, 40
    ), ncol = 3, byrow = TRUE)

    grid_to_coord(sp, pts)
    #>      [,1] [,2] [,3]
    #> [1,]  -90 -126  -72
    #> [2,]  -28  -64  -34
    #> [3,]   36    0    6

### World -> grid coordinates

    coord_to_grid(sp, c(0, 0, 0))          # near isocenter
    #> [1] 46 64 37
    coord_to_grid(sp, matrix(c(0, 0, 0,
                               10, -20, 8), ncol = 3, byrow = TRUE))
    #>      [,1] [,2] [,3]
    #> [1,]   46   64   37
    #> [2,]   51   54   41

### Convenience: linear index <-> world

    # Linear index 1 should land at the origin (pass integer)
    index_to_coord(sp, 1L)
    #>      [,1] [,2] [,3]
    #> [1,]  -89 -125  -71

    # World coord back to linear index
    coord_to_index(sp, matrix(c(-90, -126, -72), nrow = 1))
    #> [1] -2079

### A concrete round-trip

    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")
    #> index: 12345 -> grid: 57 1 4 -> world: 22 -126 -66 -> index: 10264

## 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:

    affine_to_axcodes(trans(sp))
    #> [1] "R" "A" "S"

The default axis-aligned `NeuroSpace` built above uses the nearest-anatomy convention inferred from the affine. You can also inspect the `axes` slot directly:

    axes(sp)
    #> <AxisSet3D>
    #>   Axis 1        : Left-to-Right
    #>   Axis 2        : Posterior-to-Anterior
    #>   Axis 3        : Inferior-to-Superior

### Reorienting a space

`reorient()` flips and permutes the affine to match a target orientation string:

    sp_ras <- reorient(sp, c("R", "A", "S"))
    affine_to_axcodes(trans(sp_ras))
    #> [1] "L" "P" "I"

## Creating NeuroSpaces

### From dim + spacing + origin (axis-aligned)

The simplest case: an isotropic or anisotropic grid with no rotation.

    # 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
    #> <NeuroSpace> [3D]
    #> ── Geometry ────────────────────────────────────────────────────────────────────
    #>   Dimensions    : 91 x 109 x 91
    #>   Spacing       : 2 x 2 x 2 mm
    #>   Origin        : -90, -126, -72
    #>   Orientation   : RAS
    #>   Voxels        : 902,629

### From an explicit affine

When you have a NIfTI sform/qform matrix, pass it directly:

    # 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)
    #> [1] 2.00000 2.00998 2.00250

    # diagonal is not exactly 2 mm when the image is tilted
    diag(aff_obl[1:3, 1:3])
    #> [1] 2 2 2

### 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()`:

    voxel_sizes(aff_obl)
    #> [1] 2.000000 2.009975 2.002498

## 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:

    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)
    #> [1]  64  64  40 200
    trans(sp_4d)                   # 4x4 spatial affine unchanged
    #>      [,1] [,2] [,3] [,4]
    #> [1,]    2    0    0  -90
    #> [2,]    0    2    0 -126
    #> [3,]    0    0    2  -72
    #> [4,]    0    0    0    1

### Dropping the time dimension: drop_dim

`drop_dim()` removes the last (or a named) dimension:

    sp_back <- drop_dim(sp_4d)
    dim(sp_back)
    #> [1] 64 64 40
    all.equal(trans(sp_back), trans(sp_3d))   # affine preserved
    #> [1] TRUE

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:

    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)
    #> [1] 45 54 45
    spacing(space(vol_coarse))
    #> [1] 4 4 4

### 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):

    sp_deob <- deoblique(sp_obl)
    affine_to_axcodes(trans(sp_deob))   # now axis-aligned
    #> [1] "R" "A" "S"
    obliquity(trans(sp_deob))           # near zero
    #> [1] 0 0 0

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.

    obliquity(aff_obl)        # non-zero: image is slightly tilted
    #> [1] 0.00000000 0.09966865 0.04995840
    obliquity(trans(sp_mni))  # near zero: axis-aligned
    #> [1] 0 0 0

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