UNPKG

@niivue/niivue

Version:

minimal webgl2 nifti image viewer

1,357 lines (1,323 loc) 135 kB
import * as nifti from 'nifti-reader-js' import daikon from 'daikon' import { mat3, mat4, vec3, vec4 } from 'gl-matrix' import { Decompress, decompressSync, gzipSync } from 'fflate/browser' import { v4 as uuidv4 } from '@lukeed/uuid' import { ColorMap, LUT, cmapper } from '../colortables.js' import { NiivueObject3D } from '../niivue-object3D.js' import { log } from '../logger.js' import { NVUtilities } from '../nvutilities.js' import { ImageFromBase64, ImageFromFileOptions, ImageFromUrlOptions, ImageMetadata, ImageType, NVIMAGE_TYPE, NiiDataType, NiiIntentCode, NVImageFromUrlOptions, getBestTransform, getExtents, hdrToArrayBuffer, isAffineOK, isPlatformLittleEndian } from './utils.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 alphaThreshold?: 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: nifti.NIFTI1 | nifti.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[] | 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 ) { this.name = name 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 // 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 } 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) } this.imageType = imageType switch (imageType) { case NVIMAGE_TYPE.DCM_FOLDER: case NVIMAGE_TYPE.DCM_MANIFEST: case NVIMAGE_TYPE.DCM: imgRaw = this.readDICOM(dataBuffer) break case NVIMAGE_TYPE.FIB: ;[imgRaw, this.v1] = this.readFIB(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.MIH: case NVIMAGE_TYPE.MIF: imgRaw = this.readMIF(dataBuffer as ArrayBuffer, pairedImgData) // detached break case NVIMAGE_TYPE.NHDR: case NVIMAGE_TYPE.NRRD: imgRaw = this.readNRRD(dataBuffer as ArrayBuffer, pairedImgData) // detached break case NVIMAGE_TYPE.MHD: case NVIMAGE_TYPE.MHA: imgRaw = this.readMHA(dataBuffer as ArrayBuffer, pairedImgData) break case NVIMAGE_TYPE.MGH: case NVIMAGE_TYPE.MGZ: imgRaw = this.readMGH(dataBuffer as ArrayBuffer) // to do: pairedImgData break case NVIMAGE_TYPE.SRC: imgRaw = this.readSRC(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.V: imgRaw = this.readECAT(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.V16: imgRaw = this.readV16(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.VMR: imgRaw = this.readVMR(dataBuffer as ArrayBuffer) break case NVIMAGE_TYPE.HEAD: imgRaw = this.readHEAD(dataBuffer as ArrayBuffer, pairedImgData) // paired = .BRIK break case NVIMAGE_TYPE.NII: this.hdr = nifti.readHeader(dataBuffer as ArrayBuffer) if (this.hdr !== null) { if (this.hdr.cal_min === 0 && this.hdr.cal_max === 255) { this.hdr.cal_max = 0.0 } if (nifti.isCompressed(dataBuffer as ArrayBuffer)) { imgRaw = nifti.readImage(this.hdr, nifti.decompress(dataBuffer as ArrayBuffer)) } else { imgRaw = nifti.readImage(this.hdr, dataBuffer as ArrayBuffer) } } break default: throw new Error('Image type not supported') } 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)) } // 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 covenience 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() } // 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 nifti.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 } // readECAT() readV16(buffer: ArrayBuffer): ArrayBuffer { this.hdr = new nifti.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() // 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 nifti.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, true); // let nCol = reader.getUint32(pos + 68, true); // let FOVrow = reader.getFloat32(pos + 72, true); // let FOVcol = reader.getFloat32(pos + 76, true); // let sliceThickness = reader.getFloat32(pos + 80, true); // let gapThickness = reader.getFloat32(pos + 84, true); const nSpatialTransforms = reader.getUint32(pos + 88, true) pos = pos + 92 if (nSpatialTransforms > 0) { const len = buffer.byteLength for (let i = 0; i < nSpatialTransforms; i++) { // read variable length name name... while (pos < len && reader.getUint8(pos) !== 0) { pos++ } pos++ // let typ = reader.getUint32(pos, true); pos += 4 // read variable length name name... while (pos < len && reader.getUint8(pos) !== 0) { pos++ } pos++ const nValues = reader.getUint32(pos, true) pos += 4 for (let j = 0; j < nValues; j++) { pos += 4 } } } // let LRconv = reader.getUint8(pos); // let ref = reader.getUint8(pos + 1); hdr.pixDims[1] = reader.getFloat32(pos + 2, true) hdr.pixDims[2] = reader.getFloat32(pos + 6, true) hdr.pixDims[3] = reader.getFloat32(pos + 10, true) // let isVer = reader.getUint8(pos + 14); // let isTal = reader.getUint8(pos + 15); // let minInten = reader.getInt32(pos + 16, true); // let meanInten = reader.getInt32(pos + 20, true); // let maxInten = reader.getInt32(pos + 24, true); } log.warn('Warning: VMR spatial transform not implemented') // if (XmmStart === XmmEnd) { // https://brainvoyager.com/bv/sampledata/index.html?? 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] ] // } log.debug(hdr) hdr.numBitsPerVoxel = 8 hdr.datatypeCode = NiiDataType.DT_UINT8 return buffer.slice(8, 8 + nBytes) } // readVMR() // not included in public docs // read FreeSurfer MGH format image readMGH(buffer: ArrayBuffer): ArrayBuffer { this.hdr = new nifti.NIFTI1() const hdr = this.hdr hdr.littleEndian = false // MGH always big ending hdr.dims = [3, 1, 1, 1, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] let raw = buffer let reader = new DataView(raw) if (reader.getUint8(0) === 31 && reader.getUint8(1) === 139) { const raw8 = decompressSync(new Uint8Array(buffer)) raw = raw8.buffer reader = new DataView(raw) } 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) // let dof = reader.getInt32(24, false); // let goodRASFlag = reader.getInt16(28, 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 || mtype < 0 || mtype > 4) { log.warn('Not a valid MGH file') } if (mtype === 0) { hdr.numBitsPerVoxel = 8 hdr.datatypeCode = NiiDataType.DT_UINT8 } else if (mtype === 4) { hdr.numBitsPerVoxel = 16 hdr.datatypeCode = NiiDataType.DT_INT16 } else if (mtype === 1) { hdr.numBitsPerVoxel = 32 hdr.datatypeCode = NiiDataType.DT_INT32 } else if (mtype === 3) { hdr.numBitsPerVoxel = 32 hdr.datatypeCode = NiiDataType.DT_FLOAT32 } hdr.dims[1] = width hdr.dims[2] = height hdr.dims[3] = depth hdr.dims[4] = nframes if (nframes > 1) { hdr.dims[0] = 4 } hdr.pixDims[1] = spacingX hdr.pixDims[2] = spacingY hdr.pixDims[3] = spacingZ hdr.vox_offset = 284 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] ] const nBytes = hdr.dims[1] * hdr.dims[2] * hdr.dims[3] * hdr.dims[4] * (hdr.numBitsPerVoxel / 8) return raw.slice(hdr.vox_offset, hdr.vox_offset + nBytes) } // readMGH() // not included in public docs // read DSI-Studio FIB format image // https://dsi-studio.labsolver.org/doc/cli_data.html readFIB(buffer: ArrayBuffer): [ArrayBuffer, Float32Array] { this.hdr = new nifti.NIFTI1() const hdr = this.hdr hdr.littleEndian = false // MGH always big ending hdr.dims = [3, 1, 1, 1, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] const mat = NVUtilities.readMatV4(buffer) if (!('dimension' in mat) || !('dti_fa' in mat)) { throw new Error('Not a valid DSIstudio FIB file') } const hasV1 = 'index0' in mat && 'index1' in mat && 'index2' in mat // const hasV1 = false hdr.numBitsPerVoxel = 32 hdr.datatypeCode = NiiDataType.DT_FLOAT32 hdr.dims[1] = mat.dimension[0] hdr.dims[2] = mat.dimension[1] hdr.dims[3] = mat.dimension[2] hdr.dims[4] = 1 hdr.pixDims[1] = mat.voxel_size[0] hdr.pixDims[2] = mat.voxel_size[1] hdr.pixDims[3] = mat.voxel_size[2] hdr.sform_code = 1 const xmm = (hdr.dims[1] - 1) * 0.5 * hdr.pixDims[1] const ymm = (hdr.dims[2] - 1) * 0.5 * hdr.pixDims[2] const zmm = (hdr.dims[3] - 1) * 0.5 * hdr.pixDims[3] hdr.affine = [ [hdr.pixDims[1], 0, 0, -xmm], [0, -hdr.pixDims[2], 0, ymm], [0, 0, hdr.pixDims[2], -zmm], [0, 0, 0, 1] ] hdr.littleEndian = true const nVox3D = hdr.dims[1] * hdr.dims[2] * hdr.dims[3] const nBytes3D = nVox3D * Math.ceil(hdr.numBitsPerVoxel / 8) const nBytes = nBytes3D * hdr.dims[4] const buff8v1 = new Uint8Array(new ArrayBuffer(nVox3D * 4 * 3)) // 4=Float32, 3=x,y,z if (hasV1) { // read directions, stored as index const nvox = hdr.dims[1] * hdr.dims[2] * hdr.dims[3] const dir0 = new Float32Array(nvox) const dir1 = new Float32Array(nvox) const dir2 = new Float32Array(nvox) const idxs = mat.index0 const dirs = mat.odf_vertices for (let i = 0; i < nvox; i++) { const idx = idxs[i] * 3 dir0[i] = dirs[idx + 0] dir1[i] = dirs[idx + 1] dir2[i] = -dirs[idx + 2] } buff8v1.set(new Uint8Array(dir0.buffer, dir0.byteOffset, dir0.byteLength), 0 * nBytes3D) buff8v1.set(new Uint8Array(dir1.buffer, dir1.byteOffset, dir1.byteLength), 1 * nBytes3D) buff8v1.set(new Uint8Array(dir2.buffer, dir2.byteOffset, dir2.byteLength), 2 * nBytes3D) } const buff8 = new Uint8Array(new ArrayBuffer(nBytes)) // read FA const arrFA = Float32Array.from(mat.dti_fa) const imgFA = new Uint8Array(arrFA.buffer, arrFA.byteOffset, arrFA.byteLength) buff8.set(imgFA, 0) if ('report' in mat) { hdr.description = new TextDecoder().decode(mat.report.subarray(0, Math.min(79, mat.report.byteLength))) } return [buff8.buffer, new Float32Array(buff8v1.buffer)] } // readFIB() // not included in public docs // read DSI-Studio SRC format image // https://dsi-studio.labsolver.org/doc/cli_data.html readSRC(buffer: ArrayBuffer): ArrayBuffer { this.hdr = new nifti.NIFTI1() const hdr = this.hdr hdr.littleEndian = false // MGH always big ending hdr.dims = [3, 1, 1, 1, 0, 0, 0, 0] hdr.pixDims = [1, 1, 1, 1, 1, 0, 0, 0] const mat = NVUtilities.readMatV4(buffer) if (!('dimension' in mat) || !('image0' in mat)) { throw new Error('Not a valid DSIstudio SRC file') } let n = 0 let len = 0 for (const [key, value] of Object.entries(mat)) { if (!key.startsWith('image')) { continue } if (n === 0) { len = value.length } else if (len !== value.length) { len = -1 } if (value.constructor !== Uint16Array) { throw new Error('DSIstudio SRC files always use Uint16 datatype') } n++ } if (len < 1 || n < 1) { throw new Error('SRC file not valid DSI Studio data. The image(s) should have the same length') } hdr.numBitsPerVoxel = 16 hdr.datatypeCode = NiiDataType.DT_UINT16 hdr.dims[1] = mat.dimension[0] hdr.dims[2] = mat.dimension[1] hdr.dims[3] = mat.dimension[2] hdr.dims[4] = n if (hdr.dims[4] > 1) { hdr.dims[0] = 4 } hdr.pixDims[1] = mat.voxel_size[0] hdr.pixDims[2] = mat.voxel_size[1] hdr.pixDims[3] = mat.voxel_size[2] hdr.sform_code = 1 const xmm = (hdr.dims[1] - 1) * 0.5 * hdr.pixDims[1] const ymm = (hdr.dims[2] - 1) * 0.5 * hdr.pixDims[2] const zmm = (hdr.dims