Skip to content

Commit 7975907

Browse files
committed
feat: Calculate axis codes from affines
1 parent 116e7cb commit 7975907

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([])
@@ -54,3 +54,26 @@ Deno.test('Test loading nifti header', async (t) => {
5454
assertObjectMatch(error, { key: 'NIFTI_HEADER_UNREADABLE' })
5555
})
5656
})
57+
58+
Deno.test('Test extracting axis codes', async (t) => {
59+
await t.step('Identify RAS', async () => {
60+
const affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
61+
assertEquals(axisCodes(affine), ['R', 'A', 'S'])
62+
})
63+
await t.step('Identify LPS (flips)', async () => {
64+
const affine = [[-1, 0, 0, 0], [0, -1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
65+
assertEquals(axisCodes(affine), ['L', 'P', 'S'])
66+
})
67+
await t.step('Identify SPL (flips + swap)', async () => {
68+
const affine = [[0, 0, -1, 0], [0, -1, 0, 0], [1, 0, 0, 0], [0, 0, 0, 1]]
69+
assertEquals(axisCodes(affine), ['S', 'P', 'L'])
70+
})
71+
await t.step('Identify SLP (flips + rotate)', async () => {
72+
const affine = [[0, -1, 0, 0], [0, 0, -1, 0], [1, 0, 0, 0], [0, 0, 0, 1]]
73+
assertEquals(axisCodes(affine), ['S', 'L', 'P'])
74+
})
75+
await t.step('Identify ASR (rotate)', async () => {
76+
const affine = [[0, 0, 1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]]
77+
assertEquals(axisCodes(affine), ['A', 'S', 'R'])
78+
})
79+
})

src/files/nifti.ts

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

0 commit comments

Comments
 (0)