How a voxel gets an (x,y,z) position
Tonight · ~30 min · read + a numeric example · energy: medium · setup: none
So the array has no location. How does a voxel acquire one? This lesson builds
the affine matrix — the linear map from integer voxel index (i, j, k) to
physical patient coordinate (x, y, z) — and works it through real numbers. Once
you can write the affine, slice ordering, resampling and CT↔SEG comparison all
become arithmetic.
The anchor: where is voxel (0,0,0)?
The patient-coordinate position of a voxel is determined by three things you
already met: origin, spacing, and direction. The first voxel
(0,0,0) sits at the origin. To reach any other voxel you step along the three
axes, each step scaled by that axis’s spacing and pointed along that axis’s
direction. String those steps together and you have the affine.
The geometry, in DICOM’s words
This is the durable reference for pixel index ↔ patient coordinate mapping. It follows the DICOM Image Plane Module (PS3.3 C.7.6.2.1.1).
DICOM pixel indices use (i, j) with:
i= column index (0 … Columns−1), increasing left→right along a row.j= row index (0 … Rows−1), increasing top→bottom along a column.
ImageOrientationPatient (0020,0037) = six direction cosines:
- first triplet
[rx, ry, rz]= the row direction = direction of increasingi; - second triplet
[cx, cy, cz]= the column direction = direction of increasingj.
PixelSpacing (0028,0030) = [row spacing, column spacing]:
- first value = row spacing = distance between adjacent rows = step when row
index
jincreases by 1; - second value = column spacing = distance between adjacent columns = step when
column index
iincreases by 1.
The cross-coupling is the trap (we devote the next lesson to it): the row direction cosine pairs with the column spacing, and vice versa.
The in-plane equation
For a single frame, the patient coordinate of the pixel at (i, j) is:
P(i, j) = P0 + i · (column spacing) · [rx, ry, rz] # move along a row (i increases) + j · (row spacing) · [cx, cy, cz] # move along a column (j increases)where P0 = ImagePositionPatient (0020,0032) of the first (top-left) pixel of
this frame. The slice normal adds the third axis (next lesson but one).
A worked numeric example
Take a simple axis-aligned axial slice (the common case), so the row points along +x and the column along +y:
ImageOrientationPatient = [1, 0, 0, 0, 1, 0] # row=+x, col=+yPixelSpacing = [0.8, 0.8] # [row spacing, col spacing] mmImagePositionPatient = [-200, -150, 42.0] # P0, LPS mmWhere is the pixel at column i = 100, row j = 200?
P(100, 200) = [-200, -150, 42.0] + 100 · 0.8 · [1, 0, 0] = [+80, 0, 0] + 200 · 0.8 · [0, 1, 0] = [ 0, +160, 0] ------------------------------- = [-120, +10, 42.0] mm (LPS)Notice the z-component is unchanged (42.0) — moving within a slice never changes
z. To change z you move to a different slice, which the slice normal handles
(lesson 4).
Bridge
Read that result as: “voxel index (100, 200) on this slice is the physical point
120 mm to the right of and 10 mm posterior of the origin, at height 42 mm.” The
array index became a millimetre position in the patient. That conversion is the
affine doing its one job. Every “is this mask in the right place?” question reduces
to applying this map and comparing coordinates.
The affine as a matrix
Pack origin, spacing and direction into one 4×4 matrix A (homogeneous
coordinates) and the map becomes a single multiply:
[ x ] [ D·diag(s) | origin ] [ i ][ y ] = [ | ] · [ j ][ z ] [ | ] [ k ][ 1 ] [ 0 0 0 | 1 ] [ 1 ]where D is the 3×3 direction-cosine matrix (columns = axis directions) and
diag(s) scales each axis by its spacing. This is exactly what
TRACE-CT’s build_affine() constructs from
IOP/IPP/spacing, and what SimpleITK carries internally as
GetOrigin()/GetSpacing()/GetDirection().
What can go wrong even with the right affine
- Oblique volumes. If the gantry was tilted, the direction matrix is not identity and the axes are not aligned with +x/+y/+z. Apply the full matrix; do not assume “axial means identity”.
- Per-slice origin changes. Each slice has its own
ImagePositionPatient. The in-plane affine is shared across a regular series, butP0differs per slice — that is precisely the information lesson 4 uses to order slices. - LPS vs RAS. DICOM and SimpleITK are LPS; Slicer is RAS. Convert at the boundary or you mirror the patient.
Stop and think — then reveal
Using the example above, what is the patient coordinate of the origin voxel
(i=0, j=0)? And by how many millimetres does the position change when i
increases by 1?
(0,0) is just P0 = [-200, -150, 42.0] — the origin is literally the position
of the first pixel. Increasing i by 1 adds 1 · (column spacing) · row direction = 1 · 0.8 · [1,0,0] = [0.8, 0, 0], i.e. 0.8 mm along +x. That 0.8 mm is the
column spacing. So “spacing” is not an abstract metadata field — it is the physical
size of one voxel, and you have just derived it from the geometry.
What to retain
- The affine maps voxel index
(i,j,k)→ patient(x,y,z)viaorigin + spacing × direction. - In-plane:
P(i,j) = P0 + i·colSpacing·rowDir + j·rowSpacing·colDir. The cross-coupling (row direction with column spacing) is the trap of lesson 3. - The origin is the physical position of voxel
(0,0,0); spacing is the physical size of one voxel; direction points the axes. Spacing is derivable from the geometry, not just a tag to copy. - Pack the three into a
4×4affine and the whole map is one matrix multiply — whatSimpleITKcarries and whatbuild_affine()constructs.
Next: the cross-coupling trap in full — the DICOM row/column trap — and how NumPy and SimpleITK index the same voxel in opposite orders.