Skip to content

Commit fd3da41

Browse files
authored
Merge pull request #112 from effigies/feat/axiscodes
feat: Extract image orientation from NIfTI header
2 parents 2d45432 + fd9c392 commit fd3da41

File tree

8 files changed

+268
-3
lines changed

8 files changed

+268
-3
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<!--
2+
A new scriv changelog fragment.
3+
4+
Uncomment the section that is right (remove the HTML comment wrapper).
5+
For top level release notes, leave all the headers commented out.
6+
-->
7+
8+
### Added
9+
10+
- Added support for extracting image orientation from NIfTI headers,
11+
added to the BIDS schema in 1.10.1.
12+
13+
<!--
14+
### Changed
15+
16+
- A bullet item for the Changed category.
17+
18+
-->
19+
<!--
20+
### Fixed
21+
22+
- A bullet item for the Fixed category.
23+
24+
-->
25+
<!--
26+
### Deprecated
27+
28+
- A bullet item for the Deprecated category.
29+
30+
-->
31+
<!--
32+
### Removed
33+
34+
- A bullet item for the Removed category.
35+
36+
-->
37+
<!--
38+
### Security
39+
40+
- A bullet item for the Security category.
41+
42+
-->
43+
<!--
44+
### Infrastructure
45+
46+
- A bullet item for the Infrastructure category.
47+
48+
-->

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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,91 @@ export async function loadHeader(file: BIDSFile): Promise<NiftiHeader> {
6565
},
6666
qform_code: header.qform_code,
6767
sform_code: header.sform_code,
68+
axis_codes: axisCodes(header.affine),
6869
} as NiftiHeader
6970
} catch (err) {
7071
throw { code: 'NIFTI_HEADER_UNREADABLE' }
7172
}
7273
}
74+
75+
/** Vector addition */
76+
function add(a: number[], b: number[]): number[] {
77+
return a.map((x, i) => x + b[i])
78+
}
79+
80+
/** Vector subtraction */
81+
function sub(a: number[], b: number[]): number[] {
82+
return a.map((x, i) => x - b[i])
83+
}
84+
85+
/** Scalar multiplication */
86+
function scale(vec: number[], scalar: number): number[] {
87+
return vec.map((x) => x * scalar)
88+
}
89+
90+
/** Dot product */
91+
function dot(a: number[], b: number[]): number {
92+
return a.map((x, i) => x * b[i]).reduce((acc, x) => acc + x, 0)
93+
}
94+
95+
function argMax(arr: number[]): number {
96+
return arr.reduce((acc, x, i) => (x > arr[acc] ? i : acc), 0)
97+
}
98+
99+
/**
100+
* Identify the nearest principle axes of an image affine.
101+
*
102+
* Affines transform indices in a data array into mm right, anterior and superior of
103+
* an origin in "world coordinates". If moving along an axis in the positive direction
104+
* predominantly moves right, that axis is labeled "R".
105+
*
106+
* @example The identity matrix is in "RAS" orientation:
107+
*
108+
* # Usage
109+
*
110+
* ```ts
111+
* const affine = [[1, 0, 0, 0],
112+
* [0, 1, 0, 0],
113+
* [0, 0, 1, 0],
114+
* [0, 0, 0, 1]]
115+
*
116+
* axisCodes(affine)
117+
* ```
118+
*
119+
* # Result
120+
* ```ts
121+
* ['R', 'A', 'S']
122+
* ```
123+
*
124+
* @returns character codes describing the orientation of an image affine.
125+
*/
126+
export function axisCodes(affine: number[][]): string[] {
127+
// This function is an extract of the Python function transforms3d.affines.decompose44
128+
// (https://github.com/matthew-brett/transforms3d/blob/6a43a98/transforms3d/affines.py#L10-L153)
129+
//
130+
// As an optimization, this only orthogonalizes the basis,
131+
// and does not normalize to unit vectors.
132+
133+
// Operate on columns, which are the cosines that project input coordinates onto output axes
134+
const [cosX, cosY, cosZ] = [0, 1, 2].map((j) => [0, 1, 2].map((i) => affine[i][j]))
135+
136+
// Orthogonalize cosY with respect to cosX
137+
const orthY = sub(cosY, scale(cosX, dot(cosX, cosY)))
138+
139+
// Orthogonalize cosZ with respect to cosX and orthY
140+
const orthZ = sub(
141+
cosZ, add(scale(cosX, dot(cosX, cosZ)), scale(orthY, dot(orthY, cosZ)))
142+
)
143+
144+
const basis = [cosX, orthY, orthZ]
145+
const maxIndices = basis.map((row) => argMax(row.map(Math.abs)))
146+
147+
// Check that indices are 0, 1 and 2 in some order
148+
if (maxIndices.toSorted().some((idx, i) => idx !== i)) {
149+
throw { key: 'AMBIGUOUS_AFFINE' }
150+
}
151+
152+
// Positive/negative codes for each world axis
153+
const codes = ['RL', 'AP', 'SI']
154+
return maxIndices.map((idx, i) => codes[idx][basis[i][idx] > 0 ? 0 : 1])
155+
}

src/schema/applyRules.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ function mapEvalCheck(statements: string[], context: BIDSContext): boolean {
146146
* Classic rules interpreted like selectors. Examples in specification:
147147
* schema/rules/checks/*
148148
*/
149-
function evalRuleChecks(
149+
export function evalRuleChecks(
150150
rule: GenericRule,
151151
context: BIDSContext,
152152
schema: GenericSchema,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { assertEquals, assertObjectMatch } from '@std/assert'
2+
import type { BIDSFile, FileTree } from '../../types/filetree.ts'
3+
import type { GenericSchema, GenericRule } from '../../types/schema.ts'
4+
import { loadHeader } from '../../files/nifti.ts'
5+
import { BIDSFileDeno } from '../../files/deno.ts'
6+
import { BIDSContextDataset, BIDSContext } from '../../schema/context.ts'
7+
import { loadSchema } from '../../setup/loadSchema.ts'
8+
import { evalRuleChecks } from '../../schema/applyRules.ts'
9+
// import { applyRules } from '../../schema/applyRules.ts'
10+
import type { Context, NiftiHeader } from '@bids/schema/context'
11+
import type { Schema } from '@bids/schema/metaschema'
12+
13+
import { expressionFunctions } from '../../schema/expressionLanguage.ts'
14+
15+
function prepContext(header: NiftiHeader, dir: string, pedir: string): BIDSContext {
16+
const fullContext = {
17+
dataset: new BIDSContextDataset({}),
18+
nifti_header: header,
19+
entities: {direction: dir},
20+
sidecar: {PhaseEncodingDirection: pedir},
21+
} as unknown as BIDSContext
22+
Object.assign(fullContext, expressionFunctions)
23+
return fullContext
24+
}
25+
26+
Deno.test('Test NIFTI-specific rules', async (t) => {
27+
const RAS = await loadHeader(new BIDSFileDeno('', 'tests/data/RAS.nii.gz'))
28+
const SPL = await loadHeader(new BIDSFileDeno('', 'tests/data/SPL.nii.gz'))
29+
const AIR = await loadHeader(new BIDSFileDeno('', 'tests/data/AIR.nii.gz'))
30+
31+
const schema = await loadSchema() as Schema
32+
const NiftiPEDir = schema.rules?.checks?.nifti?.NiftiPEDir as GenericRule
33+
34+
await t.step('Test reading NIfTI axis codes' , async () => {
35+
assertEquals(RAS.axis_codes, ['R', 'A', 'S'])
36+
assertEquals(SPL.axis_codes, ['S', 'P', 'L'])
37+
assertEquals(AIR.axis_codes, ['A', 'I', 'R'])
38+
})
39+
40+
await t.step('Test rules.checks.nifti.NiftiPEDir' , async () => {
41+
const schemaPath = 'rules.checks.nifti.NiftiPEDir'
42+
43+
let context = prepContext(RAS, 'PA', 'j')
44+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
45+
assertEquals(context.dataset.issues.get({}).length, 0)
46+
context = prepContext(RAS, 'AP', 'j-')
47+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
48+
assertEquals(context.dataset.issues.get({}).length, 0)
49+
context = prepContext(RAS, 'LR', 'i')
50+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
51+
assertEquals(context.dataset.issues.get({}).length, 0)
52+
context = prepContext(RAS, 'RL', 'i-')
53+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
54+
assertEquals(context.dataset.issues.get({}).length, 0)
55+
context = prepContext(RAS, 'IS', 'k')
56+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
57+
assertEquals(context.dataset.issues.get({}).length, 0)
58+
context = prepContext(RAS, 'SI', 'k-')
59+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
60+
assertEquals(context.dataset.issues.get({}).length, 0)
61+
62+
// Common flips
63+
context = prepContext(RAS, 'AP', 'j')
64+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
65+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
66+
context = prepContext(RAS, 'PA', 'j-')
67+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
68+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
69+
context = prepContext(RAS, 'RL', 'i')
70+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
71+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
72+
context = prepContext(RAS, 'LR', 'i-')
73+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
74+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
75+
76+
// Wrong axes
77+
context = prepContext(RAS, 'PA', 'i')
78+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
79+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
80+
context = prepContext(RAS, 'AP', 'k')
81+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
82+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
83+
context = prepContext(RAS, 'LR', 'j')
84+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
85+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
86+
context = prepContext(RAS, 'RL', 'k-')
87+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
88+
assertEquals(context.dataset.issues.get({code: 'NIFTI_PE_DIRECTION_CONSISTENCY' }).length, 1)
89+
90+
// A couple checks on SPL and AIR
91+
context = prepContext(SPL, 'IS', 'i')
92+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
93+
assertEquals(context.dataset.issues.get({}).length, 0)
94+
context = prepContext(SPL, 'AP', 'j')
95+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
96+
assertEquals(context.dataset.issues.get({}).length, 0)
97+
context = prepContext(SPL, 'RL', 'k')
98+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
99+
assertEquals(context.dataset.issues.get({}).length, 0)
100+
101+
context = prepContext(AIR, 'IS', 'j-')
102+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
103+
assertEquals(context.dataset.issues.get({}).length, 0)
104+
context = prepContext(AIR, 'AP', 'i-')
105+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
106+
assertEquals(context.dataset.issues.get({}).length, 0)
107+
context = prepContext(AIR, 'RL', 'k-')
108+
evalRuleChecks(NiftiPEDir, context, {} as GenericSchema, schemaPath)
109+
assertEquals(context.dataset.issues.get({}).length, 0)
110+
})
111+
})

tests/data/AIR.nii.gz

81 Bytes
Binary file not shown.

tests/data/RAS.nii.gz

67 Bytes
Binary file not shown.

tests/data/SPL.nii.gz

80 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)