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 { 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 = 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) }