From 415c3e49ece57f1cc3f6a9a242cd7e1f8388e50c Mon Sep 17 00:00:00 2001 From: Chris Markiewicz Date: Thu, 14 Nov 2024 14:24:04 -0500 Subject: [PATCH 1/2] feat: Calculate axis codes from affines --- src/files/nifti.test.ts | 27 +++++++++- src/files/nifti.ts | 108 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/files/nifti.test.ts b/src/files/nifti.test.ts index df80f759..625e716c 100644 --- a/src/files/nifti.test.ts +++ b/src/files/nifti.test.ts @@ -1,8 +1,8 @@ -import { assert, assertObjectMatch } from '@std/assert' +import { assert, assertEquals, assertObjectMatch } from '@std/assert' import { FileIgnoreRules } from './ignore.ts' import { BIDSFileDeno } from './deno.ts' -import { loadHeader } from './nifti.ts' +import { loadHeader, axisCodes } from './nifti.ts' Deno.test('Test loading nifti header', async (t) => { const ignore = new FileIgnoreRules([]) @@ -73,3 +73,26 @@ Deno.test('Test loading nifti header', async (t) => { }) }) }) + +Deno.test('Test extracting axis codes', async (t) => { + await t.step('Identify RAS', async () => { + const affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] + assertEquals(axisCodes(affine), ['R', 'A', 'S']) + }) + await t.step('Identify LPS (flips)', async () => { + const affine = [[-1, 0, 0, 0], [0, -1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] + assertEquals(axisCodes(affine), ['L', 'P', 'S']) + }) + await t.step('Identify SPL (flips + swap)', async () => { + const affine = [[0, 0, -1, 0], [0, -1, 0, 0], [1, 0, 0, 0], [0, 0, 0, 1]] + assertEquals(axisCodes(affine), ['S', 'P', 'L']) + }) + await t.step('Identify SLP (flips + rotate)', async () => { + const affine = [[0, -1, 0, 0], [0, 0, -1, 0], [1, 0, 0, 0], [0, 0, 0, 1]] + assertEquals(axisCodes(affine), ['S', 'L', 'P']) + }) + await t.step('Identify ASR (rotate)', async () => { + const affine = [[0, 0, 1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]] + assertEquals(axisCodes(affine), ['A', 'S', 'R']) + }) +}) diff --git a/src/files/nifti.ts b/src/files/nifti.ts index e3d58b57..fdc3e07c 100644 --- a/src/files/nifti.ts +++ b/src/files/nifti.ts @@ -70,3 +70,111 @@ export async function loadHeader(file: BIDSFile): Promise { throw { key: 'NIFTI_HEADER_UNREADABLE' } } } + +function add(a: number[], b: number[]): number[] { + return a.map((x, i) => x + b[i]) +} + +function sub(a: number[], b: number[]): number[] { + return a.map((x, i) => x - b[i]) +} + +function scale(vec: number[], scalar: number): number[] { + return vec.map((x) => x * scalar) +} + +function dot(a: number[], b: number[]): number { + return a.map((x, i) => x * b[i]).reduce((acc, x) => acc + x, 0) +} + +function extractRotation(affine: number[][]): number[][] { + // This function is an extract of the Python function transforms3d.affines.decompose44 + // (https://github.com/matthew-brett/transforms3d/blob/6a43a98/transforms3d/affines.py#L10-L153) + // + // To explain the conventions of the s{xyz}* parameters: + // + // The upper left 3x3 of the affine is a matrix we will call RZS which can be decomposed + // + // RZS = R * Z * S + // + // where R is a 3x3 rotation matrix, Z is a diagonal matrix of scalings + // + // Z = diag([sx, xy, sz]) + // + // and S is a shear matrix with the form + // + // S = [[1, sxy, sxz], + // [0, 1, syz], + // [0, 0, 1]] + // + // Note that this function does not return scales, shears or translations, and + // does not guarantee a right-handed rotation matrix, as that is not necessary for our use. + + // Operate on columns, which are the cosines that project input coordinates onto output axes + const [cosX, cosY, cosZ] = [0, 1, 2].map((j) => [0, 1, 2].map((i) => affine[i][j])) + + const sx = Math.sqrt(dot(cosX, cosX)) + const normX = cosX.map((x) => x / sx) // Unit vector + + // Orthogonalize cosY with respect to normX + const sx_sxy = dot(normX, cosY) + const orthY = sub(cosY, scale(normX, sx_sxy)) + const sy = Math.sqrt(dot(orthY, orthY)) + const normY = orthY.map((y) => y / sy) + + // Orthogonalize cosZ with respect to normX and normY + const sx_sxz = dot(normX, cosZ) + const sy_syz = dot(normY, cosZ) + const orthZ = sub(cosZ, add(scale(normX, sx_sxz), scale(normY, sy_syz))) + const sz = Math.sqrt(dot(orthZ, orthZ)) + const normZ = orthZ.map((z) => z / sz) + + // Transposed normalized cosines + return [normX, normY, normZ] +} + +function argMax(arr: number[]): number { + return arr.reduce((acc, x, i) => (x > arr[acc] ? i : acc), 0) +} + +/** + * Identify the nearest principle axes of an image affine. + * + * Affines transform indices in a data array into mm right, anterior and superior of + * an origin in "world coordinates". If moving along an axis in the positive direction + * predominantly moves right, that axis is labeled "R". + * + * @example The identity matrix is in "RAS" orientation: + * + * # Usage + * + * ```ts + * const affine = [[1, 0, 0, 0], + * [0, 1, 0, 0], + * [0, 0, 1, 0], + * [0, 0, 0, 1]] + * + * axisCodes(affine) + * ``` + * + * # Result + * ```ts + * ['R', 'A', 'S'] + * ``` + * + * @returns character codes describing the orientation of an image affine. + */ +export function axisCodes(affine: number[][]): string[] { + // Note that rotation is transposed + const rotations = extractRotation(affine) + const maxIndices = rotations.map((row) => argMax(row.map(Math.abs))) + + // Check that indices are 0, 1 and 2 in some order + if (maxIndices.toSorted().some((idx, i) => idx !== i)) { + throw { key: 'AMBIGUOUS_AFFINE' } + } + + // Positive/negative codes for each world axis + const codes = ['RL', 'AP', 'SI'] + return maxIndices.map((idx, i) => codes[idx][rotations[i][idx] > 0 ? 0 : 1]) +} From d02cfb8b733c8dcd709b410a399305b512454388 Mon Sep 17 00:00:00 2001 From: Chris Markiewicz Date: Thu, 14 Nov 2024 14:41:22 -0500 Subject: [PATCH 2/2] rf: Remove unnecessary scaling, just orthogonalize --- src/files/nifti.ts | 74 +++++++++++++++------------------------------- 1 file changed, 24 insertions(+), 50 deletions(-) diff --git a/src/files/nifti.ts b/src/files/nifti.ts index fdc3e07c..19664d76 100644 --- a/src/files/nifti.ts +++ b/src/files/nifti.ts @@ -71,68 +71,26 @@ export async function loadHeader(file: BIDSFile): Promise { } } +/** Vector addition */ function add(a: number[], b: number[]): number[] { return a.map((x, i) => x + b[i]) } +/** Vector subtraction */ function sub(a: number[], b: number[]): number[] { return a.map((x, i) => x - b[i]) } +/** Scalar multiplication */ function scale(vec: number[], scalar: number): number[] { return vec.map((x) => x * scalar) } +/** Dot product */ function dot(a: number[], b: number[]): number { return a.map((x, i) => x * b[i]).reduce((acc, x) => acc + x, 0) } -function extractRotation(affine: number[][]): number[][] { - // This function is an extract of the Python function transforms3d.affines.decompose44 - // (https://github.com/matthew-brett/transforms3d/blob/6a43a98/transforms3d/affines.py#L10-L153) - // - // To explain the conventions of the s{xyz}* parameters: - // - // The upper left 3x3 of the affine is a matrix we will call RZS which can be decomposed - // - // RZS = R * Z * S - // - // where R is a 3x3 rotation matrix, Z is a diagonal matrix of scalings - // - // Z = diag([sx, xy, sz]) - // - // and S is a shear matrix with the form - // - // S = [[1, sxy, sxz], - // [0, 1, syz], - // [0, 0, 1]] - // - // Note that this function does not return scales, shears or translations, and - // does not guarantee a right-handed rotation matrix, as that is not necessary for our use. - - // Operate on columns, which are the cosines that project input coordinates onto output axes - const [cosX, cosY, cosZ] = [0, 1, 2].map((j) => [0, 1, 2].map((i) => affine[i][j])) - - const sx = Math.sqrt(dot(cosX, cosX)) - const normX = cosX.map((x) => x / sx) // Unit vector - - // Orthogonalize cosY with respect to normX - const sx_sxy = dot(normX, cosY) - const orthY = sub(cosY, scale(normX, sx_sxy)) - const sy = Math.sqrt(dot(orthY, orthY)) - const normY = orthY.map((y) => y / sy) - - // Orthogonalize cosZ with respect to normX and normY - const sx_sxz = dot(normX, cosZ) - const sy_syz = dot(normY, cosZ) - const orthZ = sub(cosZ, add(scale(normX, sx_sxz), scale(normY, sy_syz))) - const sz = Math.sqrt(dot(orthZ, orthZ)) - const normZ = orthZ.map((z) => z / sz) - - // Transposed normalized cosines - return [normX, normY, normZ] -} - function argMax(arr: number[]): number { return arr.reduce((acc, x, i) => (x > arr[acc] ? i : acc), 0) } @@ -165,9 +123,25 @@ function argMax(arr: number[]): number { * @returns character codes describing the orientation of an image affine. */ export function axisCodes(affine: number[][]): string[] { - // Note that rotation is transposed - const rotations = extractRotation(affine) - const maxIndices = rotations.map((row) => argMax(row.map(Math.abs))) + // This function is an extract of the Python function transforms3d.affines.decompose44 + // (https://github.com/matthew-brett/transforms3d/blob/6a43a98/transforms3d/affines.py#L10-L153) + // + // As an optimization, this only orthogonalizes the basis, + // and does not normalize to unit vectors. + + // Operate on columns, which are the cosines that project input coordinates onto output axes + const [cosX, cosY, cosZ] = [0, 1, 2].map((j) => [0, 1, 2].map((i) => affine[i][j])) + + // Orthogonalize cosY with respect to cosX + const orthY = sub(cosY, scale(cosX, dot(cosX, cosY))) + + // Orthogonalize cosZ with respect to cosX and orthY + const orthZ = sub( + cosZ, add(scale(cosX, dot(cosX, cosZ)), scale(orthY, dot(orthY, cosZ))) + ) + + const basis = [cosX, orthY, orthZ] + const maxIndices = basis.map((row) => argMax(row.map(Math.abs))) // Check that indices are 0, 1 and 2 in some order if (maxIndices.toSorted().some((idx, i) => idx !== i)) { @@ -176,5 +150,5 @@ export function axisCodes(affine: number[][]): string[] { // Positive/negative codes for each world axis const codes = ['RL', 'AP', 'SI'] - return maxIndices.map((idx, i) => codes[idx][rotations[i][idx] > 0 ? 0 : 1]) + return maxIndices.map((idx, i) => codes[idx][basis[i][idx] > 0 ? 0 : 1]) }