@niivue/niivue
Version:
minimal webgl2 nifti image viewer
395 lines (377 loc) • 14.2 kB
text/typescript
import { NIFTI1, NIFTI2 } from 'nifti-reader-js'
import { mat4 } from 'gl-matrix'
import { log } from '@/logger'
import { NVUtilities } from '@/nvutilities'
import type { NVImage } from '@/nvimage'
import { isPlatformLittleEndian, NiiDataType } from '@/nvimage/utils'
/**
* @internal
* Read all occurrences of a metadata tag from the MGH image footer.
*
* MGH images can contain strings in an optional footer.
* These items are discriminated by their "tag" number.
* Tags can be used to detect indexed atlases.
* Note: Some tags (like tag 1) include a combination of ASCII and binary data.
*
* @param view - DataView representing the footer bytes
* @param offset - Byte offset to the start of the footer
* @param footerLength - Length of the footer
* @param tagToRead - Tag identifier to extract (default = 1)
* @returns Concatenated string of all matched tag contents, separated by double newlines
*/
function readTag(view: DataView, offset: number, footerLength: number, tagToRead: number = 1): string {
const end = offset + footerLength
let pos = offset
const results: string[] = []
while (pos + 12 <= end) {
const tag = view.getInt32(pos, false) // tag (little-endian)
// skip 4 bytes (padding), read 4-byte length
const length = view.getInt32(pos + 8, false) // length of data
pos += 12
if (length <= 0 || pos + length > end) {
break // corrupt or truncated footer
}
if (tag !== tagToRead) {
pos += length
continue
}
let strLen = length
let contentPos = pos
if (tagToRead === 1) {
if (pos + 4 > end) {
break
}
strLen = view.getInt32(pos, false)
contentPos += 4
}
if (strLen > 1 && contentPos + strLen <= end) {
const raw = new Uint8Array(view.buffer, contentPos, strLen)
const str = new TextDecoder('utf-8').decode(raw.slice(0, -1)) // remove null terminator
results.push(str)
}
pos += length
}
return results.join('\n\n')
}
/**
* @internal
* Optimize FreeSurfer label image data by converting from float/int32 to the smallest suitable integer type.
* Returns the raw image buffer if the input type is unsupported or values are not valid label indices.
*
* This function:
* - Handles byte-swapping for little-endian systems.
* - Ensures all values are finite integers within valid label ranges.
* - Converts data to INT32, INT16, or UINT8 to reduce memory usage when possible.
*
* @param hdr - The NIfTI header object, which will be updated in-place.
* @param imgRaw - The raw image data as an ArrayBuffer.
* @returns A possibly transformed ArrayBuffer, or the original buffer if optimization is not possible.
*/
export function optimizeFreeSurferLabels(hdr: NIFTI1 | NIFTI2, imgRaw: ArrayBuffer): ArrayBuffer {
hdr.intent_code = 1002
if (hdr.datatypeCode !== NiiDataType.DT_FLOAT32 && hdr.datatypeCode !== NiiDataType.DT_INT32) {
return imgRaw
}
// Parse input to float or int array
let img: Float32Array | Int32Array = new Float32Array(imgRaw)
if (hdr.datatypeCode === NiiDataType.DT_INT32) {
img = new Int32Array(imgRaw)
}
// Byte-swap if needed
if (isPlatformLittleEndian()) {
const u32 = new Uint32Array(imgRaw)
for (let i = 0; i < u32.length; i++) {
const val = u32[i]
u32[i] = ((val & 0x000000ff) << 24) | ((val & 0x0000ff00) << 8) | ((val & 0x00ff0000) >>> 8) | ((val & 0xff000000) >>> 24)
}
}
hdr.littleEndian = isPlatformLittleEndian()
// Validate values
let isInteger = true
let mn = Infinity
let mx = -Infinity
for (let i = 0; i < img.length; i++) {
const v = img[i]
if (!Number.isFinite(v)) {
continue
}
if (!Number.isInteger(v)) {
isInteger = false
}
if (v < mn) {
mn = v
}
if (v > mx) {
mx = v
}
}
if (!isInteger || mn < 0 || mx > 2147483647) {
log.warn(`FreeSurfer Labels must be integers in INT32 range. range ${mn}..${mx}`)
return imgRaw
}
// Optimize datatype
if (mx > 32767) {
hdr.datatypeCode = NiiDataType.DT_INT32
const out = new Int32Array(img.length)
for (let i = 0; i < img.length; i++) {
out[i] = Math.trunc(img[i])
}
return out.buffer
} else if (mx > 255) {
hdr.datatypeCode = NiiDataType.DT_INT16
hdr.numBitsPerVoxel = 16
const out = new Int16Array(img.length)
for (let i = 0; i < img.length; i++) {
out[i] = Math.trunc(img[i])
}
return out.buffer
} else {
hdr.datatypeCode = NiiDataType.DT_UINT8
hdr.numBitsPerVoxel = 8
const out = new Uint8Array(img.length)
for (let i = 0; i < img.length; i++) {
out[i] = Math.trunc(img[i])
}
return out.buffer
}
}
/**
* @internal
* Determine if an MGH file is a FreeSurfer label image by inspecting the footer.
* MGH label images often include additional metadata after the image data.
* This function checks for specific patterns in the footer to infer if the image
* represents labeled data (e.g., label2vol-generated volumes or files referencing LUTs).
*
* @param raw - The complete ArrayBuffer of the MGH file.
* @param hdr - The parsed NIfTI header, including vox_offset and datatypeCode.
* @param expectedBytes - The expected size of the image data in bytes.
* @returns A boolean indicating whether the file is likely a labeled atlas.
* @see https://niivue.com/demos/features/labels.html
*/
export function isFreeSurferLabelImage(raw: ArrayBuffer, hdr: NIFTI1 | NIFTI2, expectedBytes: number): boolean {
const remainingBytes = raw.byteLength - hdr.vox_offset
if (remainingBytes < expectedBytes) {
log.error(`MGH image data size mismatch: expected ${expectedBytes}, found ${remainingBytes}`)
return false
}
if (remainingBytes === expectedBytes) {
return false
}
// Skip the first 20 bytes (5 * float32) of the MGH footer
const footerStart = hdr.vox_offset + expectedBytes + 20
const footerLength = raw.byteLength - footerStart
if (footerLength <= 12) {
return false
}
const tag1 = readTag(new DataView(raw), footerStart, footerLength)
if (tag1.toLowerCase().endsWith('lut.txt')) {
return true
}
const tag3 = readTag(new DataView(raw), footerStart, footerLength, 3)
return tag3.includes('mri_label2vol')
}
/**
* Reads FreeSurfer MGH/MGZ format image, modifying the provided NVImage header
* and returning the raw image data buffer.
* @param nvImage - The NVImage instance whose header will be modified.
* @param buffer - ArrayBuffer containing the MGH/MGZ file data.
* @param name - optional name of image, allowing label detection.
* @returns Promise resolving to the imgRaw ArrayBuffer or null on critical error.
*/
export async function readMgh(nvImage: NVImage, buffer: ArrayBuffer, name: string = ''): Promise<ArrayBuffer | null> {
if (!nvImage.hdr) {
log.debug('readMgh called before nvImage.hdr was initialized. Creating default.')
nvImage.hdr = new NIFTI1() // Ensure header object exists
}
const hdr = nvImage.hdr
hdr.littleEndian = false
let raw = buffer
let reader = new DataView(raw)
// Decompression logic
if (raw.byteLength >= 2 && reader.getUint8(0) === 31 && reader.getUint8(1) === 139) {
try {
raw = await NVUtilities.decompressToBuffer(new Uint8Array(buffer))
reader = new DataView(raw)
} catch (err) {
log.error('Failed to decompress MGZ file.', err)
return null
}
}
if (raw.byteLength < 284) {
log.error('File too small to be a valid MGH/MGZ header.')
return null
}
// --- Read MGH Header Fields ---
const version = reader.getInt32(0, false)
const width = reader.getInt32(4, false)
const height = reader.getInt32(8, false)
const depth = reader.getInt32(12, false)
const nframes = reader.getInt32(16, false)
const mtype = reader.getInt32(20, false)
const spacingX = reader.getFloat32(30, false)
const spacingY = reader.getFloat32(34, false)
const spacingZ = reader.getFloat32(38, false)
const xr = reader.getFloat32(42, false)
const xa = reader.getFloat32(46, false)
const xs = reader.getFloat32(50, false)
const yr = reader.getFloat32(54, false)
const ya = reader.getFloat32(58, false)
const ys = reader.getFloat32(62, false)
const zr = reader.getFloat32(66, false)
const za = reader.getFloat32(70, false)
const zs = reader.getFloat32(74, false)
const cr = reader.getFloat32(78, false)
const ca = reader.getFloat32(82, false)
const cs = reader.getFloat32(86, false)
if (version !== 1 && version !== 257) {
log.warn(`Unexpected MGH version: ${version}.`)
}
if (width <= 0 || height <= 0 || depth <= 0) {
log.error(`Invalid MGH dimensions: ${width}x${height}x${depth}`)
return null
}
// Map MGH data type directly onto nvImage.hdr
switch (mtype) {
case 0:
hdr.numBitsPerVoxel = 8
hdr.datatypeCode = NiiDataType.DT_UINT8
break
case 4:
hdr.numBitsPerVoxel = 16
hdr.datatypeCode = NiiDataType.DT_INT16
break
case 1:
hdr.numBitsPerVoxel = 32
hdr.datatypeCode = NiiDataType.DT_INT32
break
case 3:
hdr.numBitsPerVoxel = 32
hdr.datatypeCode = NiiDataType.DT_FLOAT32
break
default:
log.error(`Unsupported MGH data type: ${mtype}`)
return null
}
// Set dimensions directly onto nvImage.hdr
hdr.dims[1] = width
hdr.dims[2] = height
hdr.dims[3] = depth
hdr.dims[4] = Math.max(1, nframes)
hdr.dims[0] = hdr.dims[4] > 1 ? 4 : 3
// Set pixel dimensions directly onto nvImage.hdr (using abs)
hdr.pixDims[0] = 1
hdr.pixDims[1] = Math.abs(spacingX)
hdr.pixDims[2] = Math.abs(spacingY)
hdr.pixDims[3] = Math.abs(spacingZ)
hdr.pixDims[4] = 0
hdr.sform_code = 1
hdr.qform_code = 0
hdr.sform_code = 1
const rot44 = mat4.fromValues(
xr * hdr.pixDims[1],
yr * hdr.pixDims[2],
zr * hdr.pixDims[3],
0,
xa * hdr.pixDims[1],
ya * hdr.pixDims[2],
za * hdr.pixDims[3],
0,
xs * hdr.pixDims[1],
ys * hdr.pixDims[2],
zs * hdr.pixDims[3],
0,
0,
0,
0,
1
)
const Pcrs = [hdr.dims[1] / 2.0, hdr.dims[2] / 2.0, hdr.dims[3] / 2.0, 1]
const PxyzOffset = [0, 0, 0, 0]
for (let i = 0; i < 3; i++) {
PxyzOffset[i] = 0
for (let j = 0; j < 3; j++) {
PxyzOffset[i] = PxyzOffset[i] + rot44[j + i * 4] * Pcrs[j]
}
}
hdr.affine = [
[rot44[0], rot44[1], rot44[2], cr - PxyzOffset[0]],
[rot44[4], rot44[5], rot44[6], ca - PxyzOffset[1]],
[rot44[8], rot44[9], rot44[10], cs - PxyzOffset[2]],
[0, 0, 0, 1]
]
hdr.vox_offset = 284
hdr.magic = 'n+1'
// Check data size
const nBytesPerVoxel = hdr.numBitsPerVoxel / 8
const nVoxels = width * height * depth * hdr.dims[4]
const expectedBytes = nVoxels * nBytesPerVoxel
// Return only the raw image data buffer
const imgRaw = raw.slice(hdr.vox_offset, hdr.vox_offset + expectedBytes)
// label detection based on:
// https://github.com/pwighton/mgz-optimize/blob/main/mgz_optimize.py
// option 1: detect label by version number
let isLabel = version === 257
// option 2: detect label by filename
if (!isLabel) {
const mgLabelFiles = [
'aparc.DKTatlas+aseg.deep.mg',
'aparc+aseg.mg',
'aparc.DKTatlas+aseg.mg',
'aparc.a2005s+aseg.mg',
'aparc.a2009s+aseg.mg',
'apas+head.mg',
'apas+head.samseg.mg',
'aseg.auto.mg',
'aseg.auto_noCCseg.mg',
'aseg.mg',
'aseg.presurf.hypos.mg',
'aseg.presurf.mg',
'brainstemSsLabels.v13.FSvoxelSpace.mg',
'brainstemSsLabels.v13.mg',
'ctrl_pts.mg',
'filled.auto.mg',
'filled.mg',
'gtmseg.mg',
'hypothalamic_subunits_seg.v1.mg',
'lh.hippoAmygLabels-T1.v22.CA.FSvoxelSpace.mg',
'lh.hippoAmygLabels-T1.v22.CA.mg',
'lh.hippoAmygLabels-T1.v22.FS60.FSvoxelSpace.mg',
'lh.hippoAmygLabels-T1.v22.FS60.mg',
'lh.hippoAmygLabels-T1.v22.FSvoxelSpace.mg',
'lh.hippoAmygLabels-T1.v22.HBT.FSvoxelSpace.mg',
'lh.hippoAmygLabels-T1.v22.HBT.mg',
'lh.hippoAmygLabels-T1.v22.mg',
'lh.ribbon.mg',
'mca-dura.mg',
'rh.hippoAmygLabels-T1.v22.CA.FSvoxelSpace.mg',
'rh.hippoAmygLabels-T1.v22.CA.mg',
'rh.hippoAmygLabels-T1.v22.FS60.FSvoxelSpace.mg',
'rh.hippoAmygLabels-T1.v22.FS60.mg',
'rh.hippoAmygLabels-T1.v22.FSvoxelSpace.mg',
'rh.hippoAmygLabels-T1.v22.HBT.FSvoxelSpace.mg',
'rh.hippoAmygLabels-T1.v22.HBT.mg',
'rh.hippoAmygLabels-T1.v22.mg',
'rh.ribbon.mg',
'ribbon.mg',
'synthseg.mg',
'synthseg.rca.mg',
'vsinus.mg',
'subcort.mask.1mm.mg',
'subcort.mask.mg',
'surface.defects.mg',
'ThalamicNuclei.v13.T1.FSvoxelSpace.mg',
'ThalamicNuclei.v13.T1.mg',
'wm.asegedit.mg',
'wmparc.mg'
]
isLabel = mgLabelFiles.some((label) => name.includes(label))
}
// option 3: detect label using MGH footer
if (!isLabel) {
isLabel = isFreeSurferLabelImage(raw, hdr, expectedBytes)
}
if (isLabel) {
return optimizeFreeSurferLabels(hdr, imgRaw)
}
return imgRaw
}