@niivue/niivue
Version:
minimal webgl2 nifti image viewer
261 lines (254 loc) • 9.99 kB
text/typescript
import { NIFTI1, NIFTIEXTENSION } from 'nifti-reader-js'
import { mat4, vec3, vec4 } from 'gl-matrix'
import { log } from '@/logger'
import type { NVImage } from '@/nvimage'
import { NiiDataType } from '@/nvimage/utils'
import { NVUtilities } from '@/nvutilities'
/**
* Helper function to convert voxel location (row, column slice, indexed from 0) to world space.
* @param XYZ - Voxel coordinates [x, y, z]
* @param mtx - 4x4 transformation matrix
* @returns World space coordinates as vec3
*/
function vox2mm(XYZ: number[], mtx: mat4): vec3 {
const sform = mat4.clone(mtx)
mat4.transpose(sform, sform)
const pos = vec4.fromValues(XYZ[0], XYZ[1], XYZ[2], 1)
vec4.transformMat4(pos, pos, sform)
const pos3 = vec3.fromValues(pos[0], pos[1], pos[2])
return pos3
}
/**
* Helper function to determine spacing voxel centers (rows, columns, slices).
* @param nvImage - The NVImage instance whose header will be modified
*/
function setPixDimFromSForm(nvImage: NVImage): void {
if (!nvImage.hdr) {
throw new Error('hdr not defined')
}
const m = nvImage.hdr.affine
const mat = mat4.fromValues(m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2], m[3][3])
const mm000 = vox2mm([0, 0, 0], mat)
const mm100 = vox2mm([1, 0, 0], mat)
vec3.subtract(mm100, mm100, mm000)
const mm010 = vox2mm([0, 1, 0], mat)
vec3.subtract(mm010, mm010, mm000)
const mm001 = vox2mm([0, 0, 1], mat)
vec3.subtract(mm001, mm001, mm000)
nvImage.hdr.pixDims[1] = vec3.length(mm100)
nvImage.hdr.pixDims[2] = vec3.length(mm010)
nvImage.hdr.pixDims[3] = vec3.length(mm001)
}
/**
* Helper function to convert AFNI head/brik space to NIfTI format.
*
* Based on AFNI source code:
* https://github.com/afni/afni/blob/d6997e71f2b625ac1199460576d48f3136dac62c/src/thd_niftiwrite.c#L315
*
* @param nvImage - The NVImage instance whose header will be modified
* @param xyzDelta - Voxel spacing in each dimension
* @param xyzOrigin - Origin coordinates
* @param orientSpecific - Orientation codes for each axis
*/
function THD_daxes_to_NIFTI(nvImage: NVImage, xyzDelta: number[], xyzOrigin: number[], orientSpecific: number[]): void {
const hdr = nvImage.hdr
if (hdr === null) {
throw new Error('HDR is not set')
}
hdr.sform_code = 2
const ORIENT_xyz = 'xxyyzzg' // note strings indexed from 0!
let nif_x_axnum = -1
let nif_y_axnum = -1
let nif_z_axnum = -1
const axcode = ['x', 'y', 'z']
axcode[0] = ORIENT_xyz[orientSpecific[0]]
axcode[1] = ORIENT_xyz[orientSpecific[1]]
axcode[2] = ORIENT_xyz[orientSpecific[2]]
const axstep = xyzDelta.slice(0, 3)
const axstart = xyzOrigin.slice(0, 3)
for (let ii = 0; ii < 3; ii++) {
if (axcode[ii] === 'x') {
nif_x_axnum = ii
} else if (axcode[ii] === 'y') {
nif_y_axnum = ii
} else {
nif_z_axnum = ii
}
}
if (nif_x_axnum < 0 || nif_y_axnum < 0 || nif_z_axnum < 0) {
return
} // not assigned
if (nif_x_axnum === nif_y_axnum || nif_x_axnum === nif_z_axnum || nif_y_axnum === nif_z_axnum) {
return
} // not assigned
hdr.pixDims[1] = Math.abs(axstep[0])
hdr.pixDims[2] = Math.abs(axstep[1])
hdr.pixDims[3] = Math.abs(axstep[2])
hdr.affine = [
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
]
hdr.affine[0][nif_x_axnum] = -axstep[nif_x_axnum]
hdr.affine[1][nif_y_axnum] = -axstep[nif_y_axnum]
hdr.affine[2][nif_z_axnum] = axstep[nif_z_axnum]
hdr.affine[0][3] = -axstart[nif_x_axnum]
hdr.affine[1][3] = -axstart[nif_y_axnum]
hdr.affine[2][3] = axstart[nif_z_axnum]
}
/**
* Reads AFNI HEAD/BRIK format image, modifying the provided NVImage header
* and returning the raw image data buffer.
*
* HEAD files contain the header information as text attributes.
* BRIK files contain the binary image data.
*
* Format specification: https://afni.nimh.nih.gov/pub/dist/doc/program_help/README.attributes.html
*
* @param nvImage - The NVImage instance whose header will be modified.
* @param dataBuffer - ArrayBuffer containing the HEAD file data.
* @param pairedImgData - ArrayBuffer containing the BRIK file data (required).
* @returns Promise resolving to ArrayBuffer containing the image data.
* @throws Error if pairedImgData is not provided.
*/
export async function readHEAD(nvImage: NVImage, dataBuffer: ArrayBuffer, pairedImgData: ArrayBuffer | null): Promise<ArrayBuffer> {
nvImage.hdr = new NIFTI1()
const hdr = nvImage.hdr
hdr.dims[0] = 3
hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0]
let orientSpecific = [0, 0, 0]
let xyzOrigin = [0, 0, 0]
let xyzDelta = [1, 1, 1]
const txt = new TextDecoder().decode(dataBuffer)
const lines = txt.split(/\r?\n/)
// embed entire AFNI HEAD text as NIfTI extension
const mod = (dataBuffer.byteLength + 8) % 16
const len = dataBuffer.byteLength + (16 - mod)
log.debug(dataBuffer.byteLength, 'len', len)
const extBuffer = new ArrayBuffer(len)
new Uint8Array(extBuffer).set(new Uint8Array(dataBuffer))
const newExtension = new NIFTIEXTENSION(len + 8, 42, extBuffer, true)
hdr.addExtension(newExtension)
hdr.extensionCode = 42
hdr.extensionFlag[0] = 1
hdr.extensionSize = len + 8
// Done creating an extension
const nlines = lines.length
let i = 0
let hasIJK_TO_DICOM_REAL = false
while (i < nlines) {
let line = lines[i] // e.g. 'type = string-attribute'
i++
if (!line.startsWith('type')) {
continue
} // n.b. white space varies, "type =" vs "type ="
const isInt = line.includes('integer-attribute')
const isFloat = line.includes('float-attribute')
line = lines[i] // e.g. 'name = IDCODE_DATE'
i++
if (!line.startsWith('name')) {
continue
}
let items: Array<string | number> = line.split('= ')
const key = items[1] // e.g. 'IDCODE_DATE'
line = lines[i] // e.g. 'count = 5'
i++
items = line.split('= ')
let count = parseInt(items[1] as string) // e.g. '5'
if (count < 1) {
continue
}
line = lines[i] // e.g. ''LSB_FIRST~'
i++
items = line.trim().split(/\s+/)
if (isFloat || isInt) {
// read arrays written on multiple lines
while (items.length < count) {
line = lines[i] // e.g. ''LSB_FIRST~'
i++
const items2 = line.trim().split(/\s+/)
items.push(...items2)
}
for (let j = 0; j < count; j++) {
items[j] = parseFloat(items[j] as string)
}
}
switch (key) {
case 'BYTEORDER_STRING':
if ((items[0] as string).includes('LSB_FIRST')) {
hdr.littleEndian = true
} else if ((items[0] as string).includes('MSB_FIRST')) {
hdr.littleEndian = false
}
break
case 'BRICK_TYPES':
{
hdr.dims[4] = count
const datatype = parseInt(items[0] as string)
if (datatype === 0) {
hdr.numBitsPerVoxel = 8
hdr.datatypeCode = NiiDataType.DT_UINT8
} else if (datatype === 1) {
hdr.numBitsPerVoxel = 16
hdr.datatypeCode = NiiDataType.DT_INT16
} else if (datatype === 3) {
hdr.numBitsPerVoxel = 32
hdr.datatypeCode = NiiDataType.DT_FLOAT32
} else {
log.warn('Unknown BRICK_TYPES ', datatype)
}
}
break
case 'IJK_TO_DICOM_REAL':
if (count < 12) {
break
}
hasIJK_TO_DICOM_REAL = true
hdr.sform_code = 2
// note DICOM space is LPS while NIfTI is RAS
hdr.affine = [
[-items[0], -items[1], -items[2], -items[3]],
[-items[4], -items[5], -items[6], -items[7]],
// TODO don't reuse items for numeric values
[items[8] as number, items[9] as number, items[10] as number, items[11] as number],
[0, 0, 0, 1]
]
break
case 'DATASET_DIMENSIONS':
count = Math.max(count, 3)
for (let j = 0; j < count; j++) {
hdr.dims[j + 1] = items[j] as number
}
break
case 'ORIENT_SPECIFIC':
orientSpecific = items as number[]
break
case 'ORIGIN':
xyzOrigin = items as number[]
break
case 'DELTA':
xyzDelta = items as number[]
break
case 'TAXIS_FLOATS':
hdr.pixDims[4] = items[0] as number
break
default:
log.warn('Unknown:', key)
} // read item
} // read all lines
if (!hasIJK_TO_DICOM_REAL) {
THD_daxes_to_NIFTI(nvImage, xyzDelta, xyzOrigin, orientSpecific)
} else {
setPixDimFromSForm(nvImage)
}
const nBytes = (hdr.numBitsPerVoxel / 8) * hdr.dims[1] * hdr.dims[2] * hdr.dims[3] * hdr.dims[4]
if (!pairedImgData) {
throw new Error('pairedImgData not set')
}
if (pairedImgData.byteLength < nBytes) {
// n.b. npm run dev implicitly extracts gz, npm run demo does not!
return await NVUtilities.decompressToBuffer(new Uint8Array(pairedImgData))
}
return pairedImgData.slice(0)
}