UNPKG

@niivue/niivue

Version:

minimal webgl2 nifti image viewer

1,367 lines (1,325 loc) 132 kB
import { NIFTI1, NIFTI2, NIFTIEXTENSION, readHeaderAsync } from 'nifti-reader-js' import { mat3, mat4, vec3, vec4 } from 'gl-matrix' import { v4 as uuidv4 } from '@lukeed/uuid' import { Gunzip } from 'fflate' import { ColorMap, LUT, cmapper } from '../colortables.js' import { log } from '../logger.js' import { NVUtilities, Zip } from '../nvutilities.js' import { ImageFromBase64, ImageFromFileOptions, ImageFromUrlOptions, ImageMetadata, ImageType, NVIMAGE_TYPE, NiiDataType, NiiIntentCode, NVImageFromUrlOptions, hdrToArrayBuffer, isAffineOK, isPlatformLittleEndian, uncompressStream } from './utils.js' import * as ImageWriter from './ImageWriter.js' import * as VolumeUtils from './VolumeUtils.js' import * as ImageReaders from './ImageReaders/index.js' export * from './utils.js' export type TypedVoxelArray = Float32Array | Uint8Array | Int16Array | Float64Array | Uint16Array /** * a NVImage encapsulates some images data and provides methods to query and operate on images */ export class NVImage { name: string id: string url?: string headers?: Record<string, string> _colormap: string _opacity: number percentileFrac: number ignoreZeroVoxels: boolean trustCalMinMax: boolean colormapNegative: string // TODO see niivue/loadDocument colormapLabel: LUT | null colormapInvert?: boolean nFrame4D?: number frame4D: number // indexed from 0! nTotalFrame4D?: number cal_minNeg: number cal_maxNeg: number colorbarVisible = true modulationImage: number | null = null modulateAlpha = 0 // if !=0, mod transparency with expon power |Alpha| // TODO this is some Daikon internal thing // eslint-disable-next-line @typescript-eslint/no-explicit-any series: any = [] // for concatenating dicom images nVox3D?: number oblique_angle?: number maxShearDeg?: number useQFormNotSForm: boolean colormapType?: number pixDims?: number[] matRAS?: mat4 pixDimsRAS?: number[] obliqueRAS?: mat4 dimsRAS?: number[] permRAS?: number[] img2RASstep?: number[] img2RASstart?: number[] toRAS?: mat4 toRASvox?: mat4 frac2mm?: mat4 frac2mmOrtho?: mat4 extentsMinOrtho?: number[] extentsMaxOrtho?: number[] mm2ortho?: mat4 hdr: NIFTI1 | NIFTI2 | null = null imageType?: ImageType img?: TypedVoxelArray imaginary?: Float32Array // only for complex data v1?: Float32Array // only for FIB files fileObject?: File | File[] dims?: number[] onColormapChange: (img: NVImage) => void = () => {} onOpacityChange: (img: NVImage) => void = () => {} mm000?: vec3 mm100?: vec3 mm010?: vec3 mm001?: vec3 cal_min?: number cal_max?: number robust_min?: number robust_max?: number global_min?: number global_max?: number // TODO referenced by niivue/loadVolumes urlImgData?: string isManifest?: boolean limitFrames4D?: number /** * * @param dataBuffer - an array buffer of image data to load (there are also methods that abstract this more. See loadFromUrl, and loadFromFile) * @param name - a name for this image. Default is an empty string * @param colormap - a color map to use. default is gray * @param opacity - the opacity for this image. default is 1 * @param pairedImgData - Allows loading formats where header and image are separate files (e.g. nifti.hdr, nifti.img) * @param cal_min - minimum intensity for color brightness/contrast * @param cal_max - maximum intensity for color brightness/contrast * @param trustCalMinMax - whether or not to trust cal_min and cal_max from the nifti header (trusting results in faster loading) * @param percentileFrac - the percentile to use for setting the robust range of the display values (smart intensity setting for images with large ranges) * @param ignoreZeroVoxels - whether or not to ignore zero voxels in setting the robust range of display values * @param useQFormNotSForm - give precedence to QForm (Quaternion) or SForm (Matrix) * @param colormapNegative - a color map to use for symmetrical negative intensities * @param frame4D - volume displayed, 0 indexed, must be less than nFrame4D * * FIXME the following params are documented but not included in the actual constructor * @param onColormapChange - callback for color map change * @param onOpacityChange -callback for color map change * * TODO the following parameters were not documented * @param imageType - TODO * @param cal_minNeg - TODO * @param cal_maxNeg - TODO * @param colorbarVisible - TODO * @param colormapLabel - TODO */ constructor( // can be an array of Typed arrays or just a typed array. If an array of Typed arrays then it is assumed you are loading DICOM (perhaps the only real use case?) dataBuffer: ArrayBuffer | ArrayBuffer[] | ArrayBufferLike | null = null, name = '', colormap = 'gray', opacity = 1.0, pairedImgData: ArrayBuffer | null = null, cal_min = NaN, cal_max = NaN, trustCalMinMax = true, percentileFrac = 0.02, ignoreZeroVoxels = false, // TODO this was marked as true by default in the docs! useQFormNotSForm = false, colormapNegative = '', frame4D = 0, imageType = NVIMAGE_TYPE.UNKNOWN, cal_minNeg = NaN, cal_maxNeg = NaN, colorbarVisible = true, colormapLabel: LUT | null = null, colormapType = 0 ) { this.init( dataBuffer, name, colormap, opacity, pairedImgData, cal_min, cal_max, trustCalMinMax, percentileFrac, ignoreZeroVoxels, useQFormNotSForm, colormapNegative, frame4D, imageType, cal_minNeg, cal_maxNeg, colorbarVisible, colormapLabel, colormapType ) } // eslint-disable-next-line @typescript-eslint/no-unused-vars init( // can be an array of Typed arrays or just a typed array. If an array of Typed arrays then it is assumed you are loading DICOM (perhaps the only real use case?) dataBuffer: ArrayBuffer | ArrayBuffer[] | ArrayBufferLike | null = null, name = '', colormap = 'gray', opacity = 1.0, _pairedImgData: ArrayBuffer | null = null, cal_min = NaN, cal_max = NaN, trustCalMinMax = true, percentileFrac = 0.02, ignoreZeroVoxels = false, useQFormNotSForm = false, colormapNegative = '', frame4D = 0, imageType = NVIMAGE_TYPE.UNKNOWN, cal_minNeg = NaN, cal_maxNeg = NaN, colorbarVisible = true, colormapLabel: LUT | null = null, colormapType = 0, imgRaw: ArrayBuffer | ArrayBufferLike | null = null ): void { this.name = name this.imageType = imageType this.id = uuidv4() this._colormap = colormap this._opacity = opacity > 1.0 ? 1.0 : opacity // make sure opacity can't be initialized greater than 1 see: #107 and #117 on github this.percentileFrac = percentileFrac this.ignoreZeroVoxels = ignoreZeroVoxels this.trustCalMinMax = trustCalMinMax this.colormapNegative = colormapNegative this.colormapLabel = colormapLabel this.frame4D = frame4D // indexed from 0! this.cal_minNeg = cal_minNeg this.cal_maxNeg = cal_maxNeg this.colorbarVisible = colorbarVisible this.colormapType = colormapType // COLORMAP_TYPE MIN_TO_MAX // TODO this was missing this.useQFormNotSForm = useQFormNotSForm // Added to support zerosLike // TODO this line causes an absurd amount of handling undefined fields - it would probably be better to isolate this as a separate class. if (!dataBuffer) { return } if (this.hdr && typeof this.hdr.magic === 'number') { this.hdr.magic = 'n+1' } // fix for issue 481, where magic is set to the number 1 rather than a string this.nFrame4D = 1 if (this.hdr) { for (let i = 4; i < 7; i++) { if (this.hdr.dims[i] > 1) { this.nFrame4D *= this.hdr.dims[i] } } } this.frame4D = Math.min(this.frame4D, this.nFrame4D - 1) this.nTotalFrame4D = this.nFrame4D if (!this.hdr || !imgRaw) { return } this.nVox3D = this.hdr.dims[1] * this.hdr.dims[2] * this.hdr.dims[3] const bytesPerVol = this.nVox3D * (this.hdr.numBitsPerVoxel / 8) const nVol4D = imgRaw.byteLength / bytesPerVol if (nVol4D !== this.nFrame4D) { if (nVol4D > 0 && nVol4D * bytesPerVol === imgRaw.byteLength) { log.debug('Loading the first ' + nVol4D + ' of ' + this.nFrame4D + ' volumes') } else { log.warn('This header does not match voxel data', this.hdr, imgRaw.byteLength) } this.nFrame4D = nVol4D } // n.b. NIfTI standard says "NIFTI_INTENT_RGB_VECTOR" should be RGBA, but FSL only stores RGB if ( (this.hdr.intent_code === NiiIntentCode.NIFTI_INTENT_VECTOR || this.hdr.intent_code === NiiIntentCode.NIFTI_INTENT_RGB_VECTOR) && this.nFrame4D === 3 && this.hdr.datatypeCode === NiiDataType.DT_FLOAT32 ) { // change data from float32 to rgba32 imgRaw = this.float32V1asRGBA(new Float32Array(imgRaw)).buffer as ArrayBuffer } // NIFTI_INTENT_VECTOR: this is a RGB tensor if (this.hdr.pixDims[1] === 0.0 || this.hdr.pixDims[2] === 0.0 || this.hdr.pixDims[3] === 0.0) { log.error('pixDims not plausible', this.hdr) } if (isNaN(this.hdr.scl_slope) || this.hdr.scl_slope === 0.0) { this.hdr.scl_slope = 1.0 } // https://github.com/nipreps/fmriprep/issues/2507 if (isNaN(this.hdr.scl_inter)) { this.hdr.scl_inter = 0.0 } let affineOK = isAffineOK(this.hdr.affine) if (useQFormNotSForm || !affineOK || this.hdr.qform_code > this.hdr.sform_code) { log.debug('spatial transform based on QForm') // https://github.com/rii-mango/NIFTI-Reader-JS/blob/6908287bf99eb3bc4795c1591d3e80129da1e2f6/src/nifti1.js#L238 // Define a, b, c, d for coding convenience const b = this.hdr.quatern_b const c = this.hdr.quatern_c const d = this.hdr.quatern_d // quatern_a is a parameter in quaternion [a, b, c, d], which is required in affine calculation (METHOD 2) // mentioned in the nifti1.h file // It can be calculated by a = sqrt(1.0-(b*b+c*c+d*d)) const a = Math.sqrt(1.0 - (Math.pow(b, 2) + Math.pow(c, 2) + Math.pow(d, 2))) const qfac = this.hdr.pixDims[0] === 0 ? 1 : this.hdr.pixDims[0] const quatern_R = [ [a * a + b * b - c * c - d * d, 2 * b * c - 2 * a * d, 2 * b * d + 2 * a * c], [2 * b * c + 2 * a * d, a * a + c * c - b * b - d * d, 2 * c * d - 2 * a * b], [2 * b * d - 2 * a * c, 2 * c * d + 2 * a * b, a * a + d * d - c * c - b * b] ] const affine = this.hdr.affine for (let ctrOut = 0; ctrOut < 3; ctrOut += 1) { for (let ctrIn = 0; ctrIn < 3; ctrIn += 1) { affine[ctrOut][ctrIn] = quatern_R[ctrOut][ctrIn] * this.hdr.pixDims[ctrIn + 1] if (ctrIn === 2) { affine[ctrOut][ctrIn] *= qfac } } } // The last row of affine matrix is the offset vector affine[0][3] = this.hdr.qoffset_x affine[1][3] = this.hdr.qoffset_y affine[2][3] = this.hdr.qoffset_z this.hdr.affine = affine } affineOK = isAffineOK(this.hdr.affine) if (!affineOK) { log.debug('Defective NIfTI: spatial transform does not make sense') let x = this.hdr.pixDims[1] let y = this.hdr.pixDims[2] let z = this.hdr.pixDims[3] if (isNaN(x) || x === 0.0) { x = 1.0 } if (isNaN(y) || y === 0.0) { y = 1.0 } if (isNaN(z) || z === 0.0) { z = 1.0 } this.hdr.pixDims[1] = x this.hdr.pixDims[2] = y this.hdr.pixDims[3] = z const affine = [ [x, 0, 0, 0], [0, y, 0, 0], [0, 0, z, 0], [0, 0, 0, 1] ] this.hdr.affine = affine } // defective affine // swap data if foreign endian: if ( this.hdr.datatypeCode !== NiiDataType.DT_RGB24 && this.hdr.datatypeCode !== NiiDataType.DT_RGBA32 && this.hdr.littleEndian !== isPlatformLittleEndian() && this.hdr.numBitsPerVoxel > 8 ) { if (this.hdr.numBitsPerVoxel === 16) { // inspired by https://github.com/rii-mango/Papaya const u16 = new Uint16Array(imgRaw) for (let i = 0; i < u16.length; i++) { const val = u16[i] u16[i] = ((((val & 0xff) << 8) | ((val >> 8) & 0xff)) << 16) >> 16 // since JS uses 32-bit when bit shifting } } else if (this.hdr.numBitsPerVoxel === 32) { // inspired by https://github.com/rii-mango/Papaya const u32 = new Uint32Array(imgRaw) for (let i = 0; i < u32.length; i++) { const val = u32[i] u32[i] = ((val & 0xff) << 24) | ((val & 0xff00) << 8) | ((val >> 8) & 0xff00) | ((val >> 24) & 0xff) } } else if (this.hdr.numBitsPerVoxel === 64) { // inspired by MIT licensed code: https://github.com/rochars/endianness const numBytesPerVoxel = this.hdr.numBitsPerVoxel / 8 const u8 = new Uint8Array(imgRaw) for (let index = 0; index < u8.length; index += numBytesPerVoxel) { let offset = numBytesPerVoxel - 1 for (let x = 0; x < offset; x++) { const theByte = u8[index + x] u8[index + x] = u8[index + offset] u8[index + offset] = theByte offset-- } } } // if 64-bits } // swap byte order switch (this.hdr.datatypeCode) { case NiiDataType.DT_UINT8: this.img = new Uint8Array(imgRaw) break case NiiDataType.DT_INT16: this.img = new Int16Array(imgRaw) break case NiiDataType.DT_FLOAT32: this.img = new Float32Array(imgRaw) break case NiiDataType.DT_FLOAT64: this.img = new Float64Array(imgRaw) break case NiiDataType.DT_RGB24: this.img = new Uint8Array(imgRaw) break case NiiDataType.DT_UINT16: this.img = new Uint16Array(imgRaw) break case NiiDataType.DT_RGBA32: this.img = new Uint8Array(imgRaw) break case NiiDataType.DT_INT8: { const i8 = new Int8Array(imgRaw) const vx8 = i8.length this.img = new Int16Array(vx8) for (let i = 0; i < vx8; i++) { this.img[i] = i8[i] } this.hdr.datatypeCode = NiiDataType.DT_INT16 this.hdr.numBitsPerVoxel = 16 break } case NiiDataType.DT_BINARY: { const nvox = this.hdr.dims[1] * this.hdr.dims[2] * Math.max(1, this.hdr.dims[3]) * Math.max(1, this.hdr.dims[4]) const img1 = new Uint8Array(imgRaw) this.img = new Uint8Array(nvox) const lut = new Uint8Array(8) for (let i = 0; i < 8; i++) { lut[i] = Math.pow(2, i) } let i1 = -1 for (let i = 0; i < nvox; i++) { const bit = i % 8 if (bit === 0) { i1++ } if ((img1[i1] & lut[bit]) !== 0) { this.img[i] = 1 } } this.hdr.datatypeCode = NiiDataType.DT_UINT8 this.hdr.numBitsPerVoxel = 8 break } case NiiDataType.DT_UINT32: { const u32 = new Uint32Array(imgRaw) const vx32 = u32.length this.img = new Float64Array(vx32) for (let i = 0; i < vx32 - 1; i++) { this.img[i] = u32[i] } this.hdr.datatypeCode = NiiDataType.DT_FLOAT64 break } case NiiDataType.DT_INT32: { const i32 = new Int32Array(imgRaw) const vxi32 = i32.length this.img = new Float64Array(vxi32) for (let i = 0; i < vxi32 - 1; i++) { this.img[i] = i32[i] } this.hdr.datatypeCode = NiiDataType.DT_FLOAT64 break } case NiiDataType.DT_INT64: { const i64 = new BigInt64Array(imgRaw) const vx = i64.length this.img = new Float64Array(vx) for (let i = 0; i < vx - 1; i++) { this.img[i] = Number(i64[i]) } this.hdr.datatypeCode = NiiDataType.DT_FLOAT64 break } case NiiDataType.DT_COMPLEX64: { // saved as real/imaginary pairs: show real following fsleyes/MRIcroGL convention const f32 = new Float32Array(imgRaw) const nvx = Math.floor(f32.length / 2) this.imaginary = new Float32Array(nvx) this.img = new Float32Array(nvx) let r = 0 for (let i = 0; i < nvx - 1; i++) { this.img[i] = f32[r] this.imaginary[i] = f32[r + 1] r += 2 } this.hdr.datatypeCode = NiiDataType.DT_FLOAT32 break } default: throw new Error('datatype ' + this.hdr.datatypeCode + ' not supported') } this.calculateRAS() if (!isNaN(cal_min)) { this.hdr.cal_min = cal_min } if (!isNaN(cal_max)) { this.hdr.cal_max = cal_max } this.calMinMax() } static async new( // can be an array of Typed arrays or just a typed array. If an array of Typed arrays then it is assumed you are loading DICOM (perhaps the only real use case?) dataBuffer: ArrayBuffer | ArrayBuffer[] | ArrayBufferLike | null = null, name = '', colormap = 'gray', opacity = 1.0, pairedImgData: ArrayBuffer | null = null, cal_min = NaN, cal_max = NaN, trustCalMinMax = true, percentileFrac = 0.02, ignoreZeroVoxels = false, useQFormNotSForm = false, colormapNegative = '', frame4D = 0, imageType = NVIMAGE_TYPE.UNKNOWN, cal_minNeg = NaN, cal_maxNeg = NaN, colorbarVisible = true, colormapLabel: LUT | null = null, colormapType = 0 ): Promise<NVImage> { const newImg = new NVImage() const re = /(?:\.([^.]+))?$/ let ext = re.exec(name)![1] || '' // TODO ! guaranteed? ext = ext.toUpperCase() if (ext === 'GZ') { ext = re.exec(name.slice(0, -3))![1] // img.trk.gz -> img.trk ext = ext.toUpperCase() } let imgRaw: ArrayBufferLike | Uint8Array | null = null if (imageType === NVIMAGE_TYPE.UNKNOWN) { imageType = NVIMAGE_TYPE.parse(ext) } if (dataBuffer instanceof ArrayBuffer && dataBuffer.byteLength >= 2 && imageType === NVIMAGE_TYPE.DCM) { // unknown extension defaults to DICOM, which starts `dcm` // since NIfTI1 is popular, lets make sure the filename has not been mangled const u8s = new Uint8Array(dataBuffer) // Create a view of the buffer const isNifti1 = (u8s[0] === 92 && u8s[1] === 1) || (u8s[1] === 92 && u8s[0] === 1) if (isNifti1) { imageType = NVIMAGE_TYPE.NII } } newImg.imageType = imageType switch (imageType) { case NVIMAGE_TYPE.DCM_FOLDER: case NVIMAGE_TYPE.DCM_MANIFEST: case NVIMAGE_TYPE.DCM: return case NVIMAGE_TYPE.FIB: ;[imgRaw, newImg.v1] = await newImg.readFIB(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.MIH: case NVIMAGE_TYPE.MIF: imgRaw = await newImg.readMIF(dataBuffer as ArrayBuffer, pairedImgData) // detached break case NVIMAGE_TYPE.NHDR: case NVIMAGE_TYPE.NRRD: imgRaw = await ImageReaders.Nrrd.readNrrd(newImg, dataBuffer as ArrayBuffer) if (imgRaw === null) { throw new Error(`Failed to parse NHDR/NRRD file ${name}`) } break case NVIMAGE_TYPE.MHD: case NVIMAGE_TYPE.MHA: imgRaw = await newImg.readMHA(dataBuffer as ArrayBuffer, pairedImgData) break case NVIMAGE_TYPE.MGH: case NVIMAGE_TYPE.MGZ: imgRaw = await ImageReaders.Mgh.readMgh(newImg, dataBuffer as ArrayBuffer) if (imgRaw === null) { throw new Error(`Failed to parse MGH/MGZ file ${name}`) } break case NVIMAGE_TYPE.SRC: imgRaw = await newImg.readSRC(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.V: imgRaw = newImg.readECAT(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.V16: imgRaw = newImg.readV16(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.VMR: imgRaw = newImg.readVMR(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.HEAD: imgRaw = await newImg.readHEAD(dataBuffer as ArrayBuffer, pairedImgData) // paired = .BRIK break case NVIMAGE_TYPE.BMP: imgRaw = await newImg.readBMP(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.NPY: imgRaw = await newImg.readNPY(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.NPZ: imgRaw = await newImg.readNPZ(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.ZARR: // imgRaw = await newImg.readZARR(dataBuffer as ArrayBuffer, zarrData) throw new Error('Image type ZARR not (yet) supported') case NVIMAGE_TYPE.NII: imgRaw = await ImageReaders.Nii.readNifti(newImg, dataBuffer as ArrayBuffer) if (imgRaw === null) { throw new Error(`Failed to parse NIfTI file ${name}.`) } break default: throw new Error('Image type not supported') } newImg.init( dataBuffer, name, colormap, opacity, pairedImgData, cal_min, cal_max, trustCalMinMax, percentileFrac, ignoreZeroVoxels, useQFormNotSForm, colormapNegative, frame4D, imageType, cal_minNeg, cal_maxNeg, colorbarVisible, colormapLabel, colormapType, imgRaw ) return newImg } // not included in public docs // detect difference between voxel grid and world space // https://github.com/afni/afni/blob/25e77d564f2c67ff480fa99a7b8e48ec2d9a89fc/src/thd_coords.c#L717 computeObliqueAngle(mtx44: mat4): number { const mtx = mat4.clone(mtx44) mat4.transpose(mtx, mtx44) const dxtmp = Math.sqrt(mtx[0] * mtx[0] + mtx[1] * mtx[1] + mtx[2] * mtx[2]) const xmax = Math.max(Math.max(Math.abs(mtx[0]), Math.abs(mtx[1])), Math.abs(mtx[2])) / dxtmp const dytmp = Math.sqrt(mtx[4] * mtx[4] + mtx[5] * mtx[5] + mtx[6] * mtx[6]) const ymax = Math.max(Math.max(Math.abs(mtx[4]), Math.abs(mtx[5])), Math.abs(mtx[6])) / dytmp const dztmp = Math.sqrt(mtx[8] * mtx[8] + mtx[9] * mtx[9] + mtx[10] * mtx[10]) const zmax = Math.max(Math.max(Math.abs(mtx[8]), Math.abs(mtx[9])), Math.abs(mtx[10])) / dztmp const fig_merit = Math.min(Math.min(xmax, ymax), zmax) let oblique_angle = Math.abs((Math.acos(fig_merit) * 180.0) / 3.141592653) if (oblique_angle > 0.01) { log.warn('Warning voxels not aligned with world space: ' + oblique_angle + ' degrees from plumb.\n') } else { oblique_angle = 0.0 } return oblique_angle } float32V1asRGBA(inImg: Float32Array): Uint8Array { if (inImg.length !== this.nVox3D * 3) { log.warn('float32V1asRGBA() expects ' + this.nVox3D * 3 + 'voxels, got ', +inImg.length) } const f32 = inImg.slice() // Note we will use RGBA rather than RGB and use least significant bits to store vector polarity // this allows a single bitmap to store BOTH (unsigned) color magnitude and signed vector direction this.hdr.datatypeCode = NiiDataType.DT_RGBA32 this.nFrame4D = 1 for (let i = 4; i < 7; i++) { this.hdr.dims[i] = 1 } this.hdr.dims[0] = 3 // 3D const imgRaw = new Uint8Array(this.nVox3D * 4) //* 3 for RGB let mx = 1.0 for (let i = 0; i < this.nVox3D * 3; i++) { // n.b. NaN values created by dwi2tensor and tensor2metric tensors.mif -vector v1.mif if (isNaN(f32[i])) { continue } mx = Math.max(mx, Math.abs(f32[i])) } const slope = 255 / mx const nVox3D2 = this.nVox3D * 2 let j = 0 for (let i = 0; i < this.nVox3D; i++) { // n.b. it is really necessary to overwrite imgRaw with a new datatype mid-method const x = f32[i] const y = f32[i + this.nVox3D] const z = f32[i + nVox3D2] ;(imgRaw as Uint8Array)[j] = Math.abs(x * slope) ;(imgRaw as Uint8Array)[j + 1] = Math.abs(y * slope) ;(imgRaw as Uint8Array)[j + 2] = Math.abs(z * slope) const xNeg = Number(x > 0) * 1 const yNeg = Number(y > 0) * 2 const zNeg = Number(z > 0) * 4 let alpha = 248 + xNeg + yNeg + zNeg if (Math.abs(x) + Math.abs(y) + Math.abs(z) < 0.1) { alpha = 0 } ;(imgRaw as Uint8Array)[j + 3] = alpha j += 4 } return imgRaw } loadImgV1(isFlipX: boolean = false, isFlipY: boolean = false, isFlipZ: boolean = false): boolean { let v1 = this.v1 if (!v1 && this.nFrame4D === 3 && this.img.constructor === Float32Array) { v1 = this.img.slice() } if (!v1) { log.warn('Image does not have V1 data') return false } if (isFlipX) { for (let i = 0; i < this.nVox3D; i++) { v1[i] = -v1[i] } } if (isFlipY) { for (let i = this.nVox3D; i < 2 * this.nVox3D; i++) { v1[i] = -v1[i] } } if (isFlipZ) { for (let i = 2 * this.nVox3D; i < 3 * this.nVox3D; i++) { v1[i] = -v1[i] } } this.img = this.float32V1asRGBA(v1) return true } // not included in public docs // detect difference between voxel grid and world space calculateOblique(): void { if (!this.matRAS) { throw new Error('matRAS not defined') } if (this.pixDimsRAS === undefined) { throw new Error('pixDimsRAS not defined') } if (!this.dimsRAS) { throw new Error('dimsRAS not defined') } this.oblique_angle = this.computeObliqueAngle(this.matRAS) const LPI = this.vox2mm([0.0, 0.0, 0.0], this.matRAS) const X1mm = this.vox2mm([1.0 / this.pixDimsRAS[1], 0.0, 0.0], this.matRAS) const Y1mm = this.vox2mm([0.0, 1.0 / this.pixDimsRAS[2], 0.0], this.matRAS) const Z1mm = this.vox2mm([0.0, 0.0, 1.0 / this.pixDimsRAS[3]], this.matRAS) vec3.subtract(X1mm, X1mm, LPI) vec3.subtract(Y1mm, Y1mm, LPI) vec3.subtract(Z1mm, Z1mm, LPI) const oblique = mat4.fromValues( X1mm[0], X1mm[1], X1mm[2], 0, Y1mm[0], Y1mm[1], Y1mm[2], 0, Z1mm[0], Z1mm[1], Z1mm[2], 0, 0, 0, 0, 1 ) this.obliqueRAS = mat4.clone(oblique) const XY = Math.abs(90 - vec3.angle(X1mm, Y1mm) * (180 / Math.PI)) const XZ = Math.abs(90 - vec3.angle(X1mm, Z1mm) * (180 / Math.PI)) const YZ = Math.abs(90 - vec3.angle(Y1mm, Z1mm) * (180 / Math.PI)) this.maxShearDeg = Math.max(Math.max(XY, XZ), YZ) if (this.maxShearDeg > 0.1) { log.warn('Warning: voxels are rhomboidal, maximum shear is %f degrees.', this.maxShearDeg) } // compute a matrix to transform vectors from factional space to mm: const dim = vec4.fromValues(this.dimsRAS[1], this.dimsRAS[2], this.dimsRAS[3], 1) const sform = mat4.clone(this.matRAS) mat4.transpose(sform, sform) const shim = vec4.fromValues(-0.5, -0.5, -0.5, 0) // bitmap with 5 voxels scaled 0..1, voxel centers are 0.1,0.3,0.5,0.7,0.9 mat4.translate(sform, sform, vec3.fromValues(shim[0], shim[1], shim[2])) // mat.mat4.scale(sform, sform, dim); sform[0] *= dim[0] sform[1] *= dim[0] sform[2] *= dim[0] sform[4] *= dim[1] sform[5] *= dim[1] sform[6] *= dim[1] sform[8] *= dim[2] sform[9] *= dim[2] sform[10] *= dim[2] this.frac2mm = mat4.clone(sform) const pixdimX = this.pixDimsRAS[1] // vec3.length(X1mm); const pixdimY = this.pixDimsRAS[2] // vec3.length(Y1mm); const pixdimZ = this.pixDimsRAS[3] // vec3.length(Z1mm); // orthographic view const oform = mat4.clone(sform) oform[0] = pixdimX * dim[0] oform[1] = 0 oform[2] = 0 oform[4] = 0 oform[5] = pixdimY * dim[1] oform[6] = 0 oform[8] = 0 oform[9] = 0 oform[10] = pixdimZ * dim[2] const originVoxel = this.mm2vox([0, 0, 0], true) // set matrix translation for distance from origin oform[12] = (-originVoxel[0] - 0.5) * pixdimX oform[13] = (-originVoxel[1] - 0.5) * pixdimY oform[14] = (-originVoxel[2] - 0.5) * pixdimZ this.frac2mmOrtho = mat4.clone(oform) this.extentsMinOrtho = [oform[12], oform[13], oform[14]] this.extentsMaxOrtho = [oform[0] + oform[12], oform[5] + oform[13], oform[10] + oform[14]] this.mm2ortho = mat4.create() mat4.invert(this.mm2ortho, oblique) } // not included in public docs // convert AFNI head/brik space to NIfTI format // https://github.com/afni/afni/blob/d6997e71f2b625ac1199460576d48f3136dac62c/src/thd_niftiwrite.c#L315 THD_daxes_to_NIFTI(xyzDelta: number[], xyzOrigin: number[], orientSpecific: number[]): void { const hdr = this.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] } // not included in public docs // determine spacing voxel centers (rows, columns, slices) SetPixDimFromSForm(): void { if (!this.hdr) { throw new Error('hdr not defined') } const m = this.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 = this.vox2mm([0, 0, 0], mat) const mm100 = this.vox2mm([1, 0, 0], mat) vec3.subtract(mm100, mm100, mm000) const mm010 = this.vox2mm([0, 1, 0], mat) vec3.subtract(mm010, mm010, mm000) const mm001 = this.vox2mm([0, 0, 1], mat) vec3.subtract(mm001, mm001, mm000) this.hdr.pixDims[1] = vec3.length(mm100) this.hdr.pixDims[2] = vec3.length(mm010) this.hdr.pixDims[3] = vec3.length(mm001) } // not included in public docs // read DICOM format image and treat it like a NIfTI // ----------------- // readDICOM(buf: ArrayBuffer | ArrayBuffer[]): ArrayBuffer { // this.series = new daikon.Series() // // parse DICOM file // if (Array.isArray(buf)) { // for (let i = 0; i < buf.length; i++) { // const dataview = new DataView(buf[i]) // const image = daikon.Series.parseImage(dataview) // if (image === null) { // log.error(daikon.Series.parserError) // } else if (image.hasPixelData()) { // // if it's part of the same series, add it // if (this.series.images.length === 0 || image.getSeriesId() === this.series.images[0].getSeriesId()) { // this.series.addImage(image) // } // } // if hasPixelData // } // for i // } else { // // not a dicom folder drop // const image = daikon.Series.parseImage(new DataView(buf)) // if (image === null) { // log.error(daikon.Series.parserError) // } else if (image.hasPixelData()) { // // if it's part of the same series, add it // if (this.series.images.length === 0 || image.getSeriesId() === this.series.images[0].getSeriesId()) { // this.series.addImage(image) // } // } // } // // order the image files, determines number of frames, etc. // this.series.buildSeries() // // output some header info // this.hdr = new nifti.NIFTI1() // const hdr = this.hdr // hdr.scl_inter = 0 // hdr.scl_slope = 1 // if (this.series.images[0].getDataScaleIntercept()) { // hdr.scl_inter = this.series.images[0].getDataScaleIntercept() // } // if (this.series.images[0].getDataScaleSlope()) { // hdr.scl_slope = this.series.images[0].getDataScaleSlope() // } // hdr.dims = [3, 1, 1, 1, 0, 0, 0, 0] // hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] // hdr.dims[1] = this.series.images[0].getCols() // hdr.dims[2] = this.series.images[0].getRows() // hdr.dims[3] = this.series.images[0].getNumberOfFrames() // if (this.series.images.length > 1) { // if (hdr.dims[3] > 1) { // log.debug('To Do: multiple slices per file and multiple files (XA30 DWI)') // } // hdr.dims[3] = this.series.images.length // } // const rc = this.series.images[0].getPixelSpacing() // TODO: order? // hdr.pixDims[1] = rc[0] // hdr.pixDims[2] = rc[1] // if (this.series.images.length > 1) { // // Multiple slices. The depth of a pixel is the physical distance between offsets. This is not the same as slice // // spacing for tilted slices (skew). // const p0 = vec3.fromValues(...(this.series.images[0].getImagePosition() as [number, number, number])) // const p1 = vec3.fromValues(...(this.series.images[1].getImagePosition() as [number, number, number])) // const n = vec3.fromValues(0, 0, 0) // vec3.subtract(n, p0, p1) // hdr.pixDims[3] = vec3.length(n) // } else { // // Single slice. Use the slice thickness as pixel depth. // hdr.pixDims[3] = this.series.images[0].getSliceThickness() // } // hdr.pixDims[4] = this.series.images[0].getTR() / 1000.0 // msec -> sec // const dt = this.series.images[0].getDataType() // 2=int,3=uint,4=float, // const bpv = this.series.images[0].getBitsAllocated() // hdr.numBitsPerVoxel = bpv // this.hdr.littleEndian = this.series.images[0].littleEndian // if (bpv === 8 && dt === 2) { // hdr.datatypeCode = NiiDataType.DT_INT8 // } else if (bpv === 8 && dt === 3) { // hdr.datatypeCode = NiiDataType.DT_UINT8 // } else if (bpv === 16 && dt === 2) { // hdr.datatypeCode = NiiDataType.DT_INT16 // } else if (bpv === 16 && dt === 3) { // hdr.datatypeCode = NiiDataType.DT_UINT16 // } else if (bpv === 32 && dt === 2) { // hdr.datatypeCode = NiiDataType.DT_INT32 // } else if (bpv === 32 && dt === 3) { // hdr.datatypeCode = NiiDataType.DT_UINT32 // } else if (bpv === 32 && dt === 4) { // hdr.datatypeCode = NiiDataType.DT_FLOAT32 // } else if (bpv === 64 && dt === 4) { // hdr.datatypeCode = NiiDataType.DT_FLOAT64 // } else if (bpv === 1) { // hdr.datatypeCode = NiiDataType.DT_BINARY // } else { // log.warn('Unsupported DICOM format: ' + dt + ' ' + bpv) // } // const voxelDimensions = hdr.pixDims.slice(1, 4) // const m = getBestTransform( // this.series.images[0].getImageDirections(), // voxelDimensions, // this.series.images[0].getImagePosition() // ) // if (m) { // hdr.sform_code = 1 // hdr.affine = [ // [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]], // [0, 0, 0, 1] // ] // } // let data // let length = this.series.validatePixelDataLength(this.series.images[0]) // const buffer = new Uint8Array(new ArrayBuffer(length * this.series.images.length)) // // implementation copied from: // // https://github.com/rii-mango/Daikon/blob/bbe08bad9758dfbdf31ca22fb79048c7bad85706/src/series.js#L496 // for (let i = 0; i < this.series.images.length; i++) { // if (this.series.isMosaic) { // data = this.series.getMosaicData(this.series.images[i], this.series.images[i].getPixelDataBytes()) // } else { // data = this.series.images[i].getPixelDataBytes() // } // length = this.series.validatePixelDataLength(this.series.images[i]) // this.series.images[i].clearPixelData() // buffer.set(new Uint8Array(data, 0, length), length * i) // } // for images.length // return buffer.buffer // } // readDICOM() // ----------------------- // not included in public docs // read ECAT7 format image // https://github.com/openneuropet/PET2BIDS/tree/28aae3fab22309047d36d867c624cd629c921ca6/ecat_validation/ecat_info readECAT(buffer: ArrayBuffer): ArrayBuffer { this.hdr = new NIFTI1() const hdr = this.hdr hdr.dims = [3, 1, 1, 1, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] const reader = new DataView(buffer) const signature = reader.getInt32(0, false) // "MATR" const filetype = reader.getInt16(50, false) if (signature !== 1296127058 || filetype < 1 || filetype > 14) { throw new Error('Not a valid ECAT file') } // list header, starts at 512 bytes: int32_t hdr[4], r[31][4]; let pos = 512 // 512=main header, 4*32-bit hdr let vols = 0 const frame_duration = [] let rawImg = new Float32Array() while (true) { // read 512 block lists const hdr0 = reader.getInt32(pos, false) const hdr3 = reader.getInt32(pos + 12, false) if (hdr0 + hdr3 !== 31) { break } let lpos = pos + 20 // skip hdr and read slice offset (r[0][1]) let r = 0 let voloffset = 0 while (r < 31) { // r[0][1]...r[30][1] voloffset = reader.getInt32(lpos, false) lpos += 16 // e.g. r[0][1] to r[1][1] if (voloffset === 0) { break } r++ let ipos = voloffset * 512 // image start position const spos = ipos - 512 // subheader for matrix image, immediately before image const data_type = reader.getUint16(spos, false) hdr.dims[1] = reader.getUint16(spos + 4, false) hdr.dims[2] = reader.getUint16(spos + 6, false) hdr.dims[3] = reader.getUint16(spos + 8, false) const scale_factor = reader.getFloat32(spos + 26, false) hdr.pixDims[1] = reader.getFloat32(spos + 34, false) * 10.0 // cm -> mm hdr.pixDims[2] = reader.getFloat32(spos + 38, false) * 10.0 // cm -> mm hdr.pixDims[3] = reader.getFloat32(spos + 42, false) * 10.0 // cm -> mm hdr.pixDims[4] = reader.getUint32(spos + 46, false) / 1000.0 // ms -> sec frame_duration.push(hdr.pixDims[4]) const nvox3D = hdr.dims[1] * hdr.dims[2] * hdr.dims[3] const newImg = new Float32Array(nvox3D) // convert to float32 as scale varies if (data_type === 1) { // uint8 for (let i = 0; i < nvox3D; i++) { newImg[i] = reader.getUint8(ipos) * scale_factor ipos++ } } else if (data_type === 6) { // uint16 for (let i = 0; i < nvox3D; i++) { newImg[i] = reader.getUint16(ipos, false) * scale_factor ipos += 2 } } else if (data_type === 7) { // uint32 for (let i = 0; i < nvox3D; i++) { newImg[i] = reader.getUint32(ipos, false) * scale_factor ipos += 4 } } else { log.warn('Unknown ECAT data type ' + data_type) } const prevImg = rawImg.slice(0) rawImg = new Float32Array(prevImg.length + newImg.length) rawImg.set(prevImg) rawImg.set(newImg, prevImg.length) vols++ } if (voloffset === 0) { break } pos += 512 // possible to have multiple 512-byte lists of images } hdr.dims[4] = vols hdr.pixDims[4] = frame_duration[0] if (vols > 1) { hdr.dims[0] = 4 let isFDvaries = false for (let i = 0; i < vols; i++) { if (frame_duration[i] !== frame_duration[0]) { isFDvaries = true } } if (isFDvaries) { log.warn('Frame durations vary') } } hdr.sform_code = 1 hdr.affine = [ [-hdr.pixDims[1], 0, 0, (hdr.dims[1] - 2) * 0.5 * hdr.pixDims[1]], [0, -hdr.pixDims[2], 0, (hdr.dims[2] - 2) * 0.5 * hdr.pixDims[2]], [0, 0, -hdr.pixDims[3], (hdr.dims[3] - 2) * 0.5 * hdr.pixDims[3]], [0, 0, 0, 1] ] hdr.numBitsPerVoxel = 32 hdr.datatypeCode = NiiDataType.DT_FLOAT32 return rawImg.buffer as ArrayBuffer } // readECAT() readV16(buffer: ArrayBuffer): ArrayBuffer { this.hdr = new NIFTI1() const hdr = this.hdr hdr.dims = [3, 1, 1, 1, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] const reader = new DataView(buffer) hdr.dims[1] = reader.getUint16(0, true) hdr.dims[2] = reader.getUint16(2, true) hdr.dims[3] = reader.getUint16(4, true) const nBytes = 2 * hdr.dims[1] * hdr.dims[2] * hdr.dims[3] if (nBytes + 6 !== buffer.byteLength) { log.warn('This does not look like a valid BrainVoyager V16 file') } hdr.numBitsPerVoxel = 16 hdr.datatypeCode = NiiDataType.DT_UINT16 log.warn('Warning: V16 files have no spatial transforms') hdr.affine = [ [0, 0, -hdr.pixDims[1], (hdr.dims[1] - 2) * 0.5 * hdr.pixDims[1]], [-hdr.pixDims[2], 0, 0, (hdr.dims[2] - 2) * 0.5 * hdr.pixDims[2]], [0, -hdr.pixDims[3], 0, (hdr.dims[3] - 2) * 0.5 * hdr.pixDims[3]], [0, 0, 0, 1] ] hdr.littleEndian = true return buffer.slice(6) } // readV16() async readNPY(buffer: ArrayBuffer): Promise<ArrayBuffer> { // Helper function to determine byte size per element function getTypeSize(dtype: string): number { const typeMap: Record<string, number> = { '|b1': 1, // Boolean '<i1': 1, // Int8 '<u1': 1, // UInt8 '<i2': 2, // Int16 '<u2': 2, // UInt16 '<i4': 4, // Int32 '<u4': 4, // UInt32 '<f4': 4, // Float32 '<f8': 8 // Float64 } return typeMap[dtype] ?? 1 } // Helper function to determine NIfTI datatype code function getDataTypeCode(dtype: string): number { const typeMap: Record<string, number> = { '|b1': 2, // DT_BINARY '<i1': 256, // DT_INT8 '<u1': 2, // DT_UINT8 '<i2': 4, // DT_INT16 '<u2': 512, // DT_UINT16 '<i4': 8, // DT_INT32 '<u4': 768, // DT_UINT32 '<f4': 16, // DT_FLOAT32 '<f8': 64 // DT_FLOAT64 } return typeMap[dtype] ?? 16 // Default to FLOAT32 } const dv = new DataView(buffer) // Verify magic number const magicBytes = [dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3), dv.getUint8(4), dv.getUint8(5)] // Expected magic number: [0x93, 0x4E, 0x55, 0x4D, 0x50, 0x59] ('\x93NUMPY') const expectedMagic = [0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59] if (!magicBytes.every((byte, i) => byte === expectedMagic[i])) { throw new Error('Not a valid NPY file: Magic number mismatch') } // Extract version and header length // const _version = dv.getUint8(6) // const _minorVersion = dv.getUint8(7) const headerLen = dv.getUint16(8, true) // Little-endian // Decode header as ASCII string const headerText = new TextDecoder('utf-8').decode(buffer.slice(10, 10 + headerLen)) // Extract shape from header const shapeMatch = headerText.match(/'shape': \((.*?)\)/) if (!shapeMatch) { throw new Error('Invalid NPY header: Shape not found') } const shape = shapeMatch[1] .split(',') .map((s) => s.trim()) .filter((s) => s !== '') .map(Number) // Determine data type (assumes '|b1' (bool), '<f4' (float32), etc.) const dtypeMatch = headerText.match(/'descr': '([^']+)'/) if (!dtypeMatch) { throw new Error('Invalid NPY header: Data type not found') } const dtype = dtypeMatch[1] // Compute number of elements const numElements = shape.reduce((a, b) => a * b, 1) // Extract data start position const dataStart = 10 + headerLen // Read data as an ArrayBuffer const dataBuffer = buffer.slice(dataStart, dataStart + numElements * getTypeSize(dtype)) // Interpret as 2D/3D data const width = shape.length > 0 ? shape[shape.length - 1] : 1 const height = shape.length > 1 ? shape[shape.length - 2] : 1 const slices = shape.length > 2 ? shape[shape.length - 3] : 1 // Create NIFTI header this.hdr = new NIFTI1() const hdr = this.hdr hdr.dims = [3, width, height, slices, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] hdr.affine = [ [hdr.pixDims[1], 0, 0, -(hdr.dims[1] - 2) * 0.5 * hdr.pixDims[1]], [0, -hdr.pixDims[2], 0, (hdr.dims[2] - 2) * 0.5 * hdr.pixDims[2]], [0, 0, -hdr.pixDims[3], (hdr.dims[3] - 2) * 0.5 * hdr.pixDims[3]], [0, 0, 0, 1] ] hdr.numBitsPerVoxel = getTypeSize(dtype) * 8 hdr.datatypeCode = getDataTypeCode(dtype) return dataBuffer } async readNPZ(buffer: ArrayBuffer): Promise<ArrayBuffer> { // todo: a single NPZ file can contain multiple NPY images const zip = new Zip(buffer) for (let i = 0; i < zip.entries.length; i++) { const entry = zip.entries[i] if (entry.fileName.toLowerCase().endsWith('.npy')) { const data = await entry.extract() return await this.readNPY(data.buffer as ArrayBuffer) } } } async imageDataFromArrayBuffer(buffer: ArrayBuffer): Promise<ImageData> { return new Promise<ImageData>((resolve, reject): void => { const blob = new Blob([buffer]) // Convert ArrayBuffer to Blob const url = URL.createObjectURL(blob) // Create a Blob URL const img = new Image() img.crossOrigin = 'Anonymous' // Allow CORS if needed img.src = url img.onload = (): void => { URL.revokeObjectURL(url) // Clean up the object URL const canvas = document.createElement('canvas') canvas.width = img.width canvas.height = img.height const ctx = canvas.getContext('2d') if (!ctx) { reject(new Error('Failed to get 2D context')) return } ctx.drawImage(img, 0, 0) resolve(ctx.getImageData(0, 0, img.width, img.height)) } img.onerror = (err): void => { URL.revokeObjectURL(url) // Ensure cleanup on error reject(err) } }) } async readBMP(buffer: ArrayBuffer): Promise<ArrayBuffer> { const imageData = await this.imageDataFromArrayBuffer(buffer) const { width, height, data } = imageData this.hdr = new NIFTI1() const hdr = this.hdr hdr.dims = [3, width, height, 1, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] hdr.affine = [ [hdr.pixDims[1], 0, 0, -(hdr.dims[1] - 2) * 0.5 * hdr.pixDims[1]], [0, -hdr.pixDims[2], 0, (hdr.dims[2] - 2) * 0.5 * hdr.pixDims[2]], [0, 0, -hdr.pixDims[3], (hdr.dims[3] - 2) * 0.5 * hdr.pixDims[3]], [0, 0, 0, 1] ] hdr.numBitsPerVoxel = 8 hdr.datatypeCode = NiiDataType.DT_RGBA32 let isGrayscale = true for (let i = 0; i < data.length; i += 4) { if (data[i] !== data[i + 1] || data[i] !== data[i + 2]) { isGrayscale = false break } } if (isGrayscale) { hdr.datatypeCode = NiiDataType.DT_UINT8 const grayscaleData = new Uint8Array(width * height) for (let i = 0, j = 0; i < data.length; i += 4, j++) { grayscaleData[j] = data[i] } return grayscaleData.buffer } return data.buffer as ArrayBuffer } // not included in public docs // read brainvoyager format VMR image // https://support.brainvoyager.com/brainvoyager/automation-development/84-file-formats/343-developer-guide-2-6-the-format-of-vmr-files readVMR(buffer: ArrayBuffer): ArrayBuffer { this.hdr = new NIFTI1() const hdr = this.hdr hdr.dims = [3, 1, 1, 1, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] const reader = new DataView(buffer) const version = reader.getUint16(0, true) if (version !== 4) { log.warn('Not a valid version 4 VMR image') } hdr.dims[1] = reader.getUint16(2, true) hdr.dims[2] = reader.getUint16(4, true) hdr.dims[3] = reader.getUint16(6, true) const nBytes = hdr.dims[1] * hdr.dims[2] * hdr.dims[3] if (version >= 4) { let pos = 8 + nBytes // offset to post header // let xoff = reader.getUint16(pos, true); // let yoff = reader.getUint16(pos + 2, true); // let zoff = reader.getUint16(pos + 4, true); // let framingCube = reader.getUint16(pos + 6, true); // let posInfo = reader.getUint32(pos + 8, true); // let coordSys = reader.getUint32(pos + 12, true); // let XmmStart = reader.getFloat32(pos + 16, true); // let YmmStart = reader.getFloat32(pos + 20, true); // let ZmmStart = reader.getFloat32(pos + 24, true); // let XmmEnd = reader.getFloat32(pos + 28, true); // let YmmEnd = reader.getFloat32(pos + 32, true); // let ZmmEnd = reader.getFloat32(pos + 36, true); // let Xsl = reader.getFloat32(pos + 40, true); // let Ysl = reader.getFloat32(pos + 44, true); // let Zsl = reader.getFloat32(pos + 48, true); // let colDirX = reader.getFloat32(pos + 52, true); // let colDirY = reader.getFloat32(pos + 56, true); // let colDirZ = reader.getFloat32(pos + 60, true); // let nRow = reader.getUint32(pos + 64