@niivue/niivue
Version:
minimal webgl2 nifti image viewer
1,367 lines (1,325 loc) • 132 kB
text/typescript
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