Skip to content

Commit b2bd8b6

Browse files
committed
feat: Calculate axis codes from affines
1 parent b45febf commit b2bd8b6

File tree

2 files changed

+133
-2
lines changed

2 files changed

+133
-2
lines changed

src/files/nifti.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { assert, assertObjectMatch } from '@std/assert'
1+
import { assert, assertEquals, assertObjectMatch } from '@std/assert'
22
import { FileIgnoreRules } from './ignore.ts'
33
import { BIDSFileDeno } from './deno.ts'
44

5-
import { loadHeader } from './nifti.ts'
5+
import { loadHeader, axisCodes } from './nifti.ts'
66

77
Deno.test('Test loading nifti header', async (t) => {
88
const ignore = new FileIgnoreRules([])
@@ -73,3 +73,26 @@ Deno.test('Test loading nifti header', async (t) => {
7373
})
7474
})
7575
})
76+
77+
Deno.test('Test extracting axis codes', async (t) => {
78+
await t.step('Identify RAS', async () => {
79+
const affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
80+
assertEquals(axisCodes(affine), ['R', 'A', 'S'])
81+
})
82+
await t.step('Identify LPS (flips)', async () => {
83+
const affine = [[-1, 0, 0, 0], [0, -1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
84+
assertEquals(axisCodes(affine), ['L', 'P', 'S'])
85+
})
86+
await t.step('Identify SPL (flips + swap)', async () => {
87+
const affine = [[0, 0, -1, 0], [0, -1, 0, 0], [1, 0, 0, 0], [0, 0, 0, 1]]
88+
assertEquals(axisCodes(affine), ['S', 'P', 'L'])
89+
})
90+
await t.step('Identify SLP (flips + rotate)', async () => {
91+
const affine = [[0, -1, 0, 0], [0, 0, -1, 0], [1, 0, 0, 0], [0, 0, 0, 1]]
92+
assertEquals(axisCodes(affine), ['S', 'L', 'P'])
93+
})
94+
await t.step('Identify ASR (rotate)', async () => {
95+
const affine = [[0, 0, 1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]]
96+
assertEquals(axisCodes(affine), ['A', 'S', 'R'])
97+
})
98+
})

src/files/nifti.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,111 @@ export async function loadHeader(file: BIDSFile): Promise<NiftiHeader> {
7070
throw { code: 'NIFTI_HEADER_UNREADABLE' }
7171
}
7272
}
73+
74+
function add(a: number[], b: number[]): number[] {
75+
return a.map((x, i) => x + b[i])
76+
}
77+
78+
function sub(a: number[], b: number[]): number[] {
79+
return a.map((x, i) => x - b[i])
80+
}
81+
82+
function scale(vec: number[], scalar: number): number[] {
83+
return vec.map((x) => x * scalar)
84+
}
85+
86+
function dot(a: number[], b: number[]): number {
87+
return a.map((x, i) => x * b[i]).reduce((acc, x) => acc + x, 0)
88+
}
89+
90+
function extractRotation(affine: number[][]): number[][] {
91+
// This function is an extract of the Python function transforms3d.affines.decompose44
92+
// (https://github.com/matthew-brett/transforms3d/blob/6a43a98/transforms3d/affines.py#L10-L153)
93+
//
94+
// To explain the conventions of the s{xyz}* parameters:
95+
//
96+
// The upper left 3x3 of the affine is a matrix we will call RZS which can be decomposed
97+
//
98+
// RZS = R * Z * S
99+
//
100+
// where R is a 3x3 rotation matrix, Z is a diagonal matrix of scalings
101+
//
102+
// Z = diag([sx, xy, sz])
103+
//
104+
// and S is a shear matrix with the form
105+
//
106+
// S = [[1, sxy, sxz],
107+
// [0, 1, syz],
108+
// [0, 0, 1]]
109+
//
110+
// Note that this function does not return scales, shears or translations, and
111+
// does not guarantee a right-handed rotation matrix, as that is not necessary for our use.
112+
113+
// Operate on columns, which are the cosines that project input coordinates onto output axes
114+
const [cosX, cosY, cosZ] = [0, 1, 2].map((j) => [0, 1, 2].map((i) => affine[i][j]))
115+
116+
const sx = Math.sqrt(dot(cosX, cosX))
117+
const normX = cosX.map((x) => x / sx) // Unit vector
118+
119+
// Orthogonalize cosY with respect to normX
120+
const sx_sxy = dot(normX, cosY)
121+
const orthY = sub(cosY, scale(normX, sx_sxy))
122+
const sy = Math.sqrt(dot(orthY, orthY))
123+
const normY = orthY.map((y) => y / sy)
124+
125+
// Orthogonalize cosZ with respect to normX and normY
126+
const sx_sxz = dot(normX, cosZ)
127+
const sy_syz = dot(normY, cosZ)
128+
const orthZ = sub(cosZ, add(scale(normX, sx_sxz), scale(normY, sy_syz)))
129+
const sz = Math.sqrt(dot(orthZ, orthZ))
130+
const normZ = orthZ.map((z) => z / sz)
131+
132+
// Transposed normalized cosines
133+
return [normX, normY, normZ]
134+
}
135+
136+
function argMax(arr: number[]): number {
137+
return arr.reduce((acc, x, i) => (x > arr[acc] ? i : acc), 0)
138+
}
139+
140+
/**
141+
* Identify the nearest principle axes of an image affine.
142+
*
143+
* Affines transform indices in a data array into mm right, anterior and superior of
144+
* an origin in "world coordinates". If moving along an axis in the positive direction
145+
* predominantly moves right, that axis is labeled "R".
146+
*
147+
* @example The identity matrix is in "RAS" orientation:
148+
*
149+
* # Usage
150+
*
151+
* ```ts
152+
* const affine = [[1, 0, 0, 0],
153+
* [0, 1, 0, 0],
154+
* [0, 0, 1, 0],
155+
* [0, 0, 0, 1]]
156+
*
157+
* axisCodes(affine)
158+
* ```
159+
*
160+
* # Result
161+
* ```ts
162+
* ['R', 'A', 'S']
163+
* ```
164+
*
165+
* @returns character codes describing the orientation of an image affine.
166+
*/
167+
export function axisCodes(affine: number[][]): string[] {
168+
// Note that rotation is transposed
169+
const rotations = extractRotation(affine)
170+
const maxIndices = rotations.map((row) => argMax(row.map(Math.abs)))
171+
172+
// Check that indices are 0, 1 and 2 in some order
173+
if (maxIndices.toSorted().some((idx, i) => idx !== i)) {
174+
throw { key: 'AMBIGUOUS_AFFINE' }
175+
}
176+
177+
// Positive/negative codes for each world axis
178+
const codes = ['RL', 'AP', 'SI']
179+
return maxIndices.map((idx, i) => codes[idx][rotations[i][idx] > 0 ? 0 : 1])
180+
}

0 commit comments

Comments
 (0)