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