@niivue/niivue
Version:
minimal webgl2 nifti image viewer
676 lines (636 loc) • 19.9 kB
text/typescript
import { vec3 } from 'gl-matrix'
import { log } from '../logger.js'
import { NiftiHeader } from '../types.js'
import { LUT } from '../colortables.js'
export const isPlatformLittleEndian = (): boolean => {
// inspired by https://github.com/rii-mango/Papaya
const buffer = new ArrayBuffer(2)
new DataView(buffer).setInt16(0, 256, true)
return new Int16Array(buffer)[0] === 256
}
/**
* Enum for NIfTI datatype codes
* // https://nifti.nimh.nih.gov/pub/dist/src/niftilib/nifti1.h
*/
export enum NiiDataType {
DT_NONE = 0,
DT_BINARY = 1,
DT_UINT8 = 2,
DT_INT16 = 4,
DT_INT32 = 8,
DT_FLOAT32 = 16,
DT_COMPLEX64 = 32,
DT_FLOAT64 = 64,
DT_RGB24 = 128,
DT_INT8 = 256,
DT_UINT16 = 512,
DT_UINT32 = 768,
DT_INT64 = 1024,
DT_UINT64 = 1280,
DT_FLOAT128 = 1536,
DT_COMPLEX128 = 1792,
DT_COMPLEX256 = 2048,
DT_RGBA32 = 2304
}
/**
* Enum for NIfTI intent codes
* // https://nifti.nimh.nih.gov/pub/dist/src/niftilib/nifti1.h
*/
export enum NiiIntentCode {
NIFTI_INTENT_LABEL = 1002,
NIFTI_INTENT_VECTOR = 1007,
NIFTI_INTENT_RGB_VECTOR = 2003
}
/**
* Enum for supported image types (e.g. NII, NRRD, DICOM)
*/
export enum ImageType {
UNKNOWN = 0,
NII = 1,
DCM = 2,
DCM_MANIFEST = 3,
MIH = 4,
MIF = 5,
NHDR = 6,
NRRD = 7,
MHD = 8,
MHA = 9,
MGH = 10,
MGZ = 11,
V = 12,
V16 = 13,
VMR = 14,
HEAD = 15,
DCM_FOLDER = 16,
SRC = 17,
FIB = 18,
BMP = 19,
ZARR = 20,
NPY = 21,
NPZ = 22
}
export const NVIMAGE_TYPE = Object.freeze({
...ImageType,
parse: (ext: string) => {
let imageType: ImageType = ImageType.UNKNOWN
switch (ext.toUpperCase()) {
case '':
case 'DCM':
imageType = ImageType.DCM
break
case 'TXT':
imageType = ImageType.DCM_MANIFEST
break
case 'FZ':
case 'GQI':
case 'QSDR':
case 'FIB':
imageType = ImageType.FIB
break
case 'NII':
imageType = ImageType.NII
break
case 'MIH':
imageType = ImageType.MIH
break
case 'MIF':
imageType = ImageType.MIF
break
case 'NHDR':
imageType = ImageType.NHDR
break
case 'NRRD':
imageType = ImageType.NRRD
break
case 'MHD':
imageType = ImageType.MHD
break
case 'MHA':
imageType = ImageType.MHA
break
case 'MGH':
imageType = ImageType.MGH
break
case 'MGZ':
imageType = ImageType.MGZ
break
case 'NPY':
imageType = ImageType.NPY
break
case 'NPZ':
imageType = ImageType.NPZ
break
case 'SRC':
imageType = ImageType.SRC
break
case 'V':
imageType = ImageType.V
break
case 'V16':
imageType = ImageType.V16
break
case 'VMR':
imageType = ImageType.VMR
break
case 'HEAD':
imageType = ImageType.HEAD
break
case 'PNG':
case 'BMP':
case 'GIF':
case 'JPG':
case 'JPEG':
imageType = ImageType.BMP
break
case 'ZARR':
imageType = ImageType.ZARR
break
}
return imageType
}
})
export type ImageFromUrlOptions = {
// the resolvable URL pointing to a nifti image to load
url: string
// Allows loading formats where header and image are separate files (e.g. nifti.hdr, nifti.img)
urlImageData?: string
// headers to use in the fetch call
headers?: Record<string, string>
// a name for this image (defaults to empty)
name?: string
// a color map to use (defaults to gray)
colorMap?: string
// TODO see duplicate usage in niivue/loadDocument
colormap?: string
// the opacity for this image (defaults to 1)
opacity?: number
// minimum intensity for color brightness/contrast
cal_min?: number
// maximum intensity for color brightness/contrast
cal_max?: number
// whether or not to trust cal_min and cal_max from the nifti header (trusting results in faster loading, defaults to true)
trustCalMinMax?: boolean
// the percentile to use for setting the robust range of the display values (smart intensity setting for images with large ranges, defaults to 0.02)
percentileFrac?: number
// whether or not to use QForm over SForm constructing the NVImage instance (defaults to false)
useQFormNotSForm?: boolean
// if true, values below cal_min are shown as translucent, not transparent (defaults to false)
alphaThreshold?: boolean
// a color map to use for negative intensities
colormapNegative?: string
// backwards compatible option
colorMapNegative?: string
// minimum intensity for colormapNegative brightness/contrast (NaN for symmetrical cal_min)
cal_minNeg?: number
// maximum intensity for colormapNegative brightness/contrast (NaN for symmetrical cal_max)
cal_maxNeg?: number
// show/hide colormaps (defaults to true)
colorbarVisible?: boolean
// TODO the following fields were not documented
ignoreZeroVoxels?: boolean
imageType?: ImageType
frame4D?: number
colormapLabel?: LUT | null
pairedImgData?: null
limitFrames4D?: number
isManifest?: boolean
urlImgData?: string
buffer?: ArrayBuffer
}
// TODO centralize shared options
export type ImageFromFileOptions = {
// the file object
file: File | File[]
// a name for this image. Default is an empty string
name?: string
// a color map to use. default is gray
colormap?: string
// the opacity for this image. default is 1
opacity?: number
// Allows loading formats where header and image are separate files (e.g. nifti.hdr, nifti.img)
urlImgData?: File | null | FileSystemEntry
// minimum intensity for color brightness/contrast
cal_min?: number
// maximum intensity for color brightness/contrast
cal_max?: number
// whether or not to trust cal_min and cal_max from the nifti header (trusting results in faster loading)
trustCalMinMax?: boolean
// the percentile to use for setting the robust range of the display values (smart intensity setting for images with large ranges)
percentileFrac?: number
// whether or not to ignore zero voxels in setting the robust range of display values
ignoreZeroVoxels?: boolean
// whether or not to use QForm instead of SForm during construction
useQFormNotSForm?: boolean
// colormap negative for the image
colormapNegative?: string
// image type
imageType?: ImageType
frame4D?: number
limitFrames4D?: number
}
export type ImageFromBase64 = {
// base64 string
base64: string
// a name for this image. Default is an empty string
name?: string
// a color map to use. default is gray
colormap?: string
// the opacity for this image. default is 1
opacity?: number
// minimum intensity for color brightness/contrast
cal_min?: number
// maximum intensity for color brightness/contrast
cal_max?: number
// whether or not to trust cal_min and cal_max from the nifti header (trusting results in faster loading)
trustCalMinMax?: boolean
// the percentile to use for setting the robust range of the display values (smart intensity setting for images with large ranges)
percentileFrac?: number
// whether or not to ignore zero voxels in setting the robust range of display values
ignoreZeroVoxels?: boolean
// whether or not use QForm instead of SForm
useQFormNotSForm?: boolean
colormapNegative?: string
frame4D?: number
imageType?: ImageType
cal_minNeg?: number
cal_maxNeg?: number
colorbarVisible?: boolean
colormapLabel?: LUT | null
}
export type ImageMetadata = {
// unique if of image
id: string
// data type
datatypeCode: number
// number of columns
nx: number
// number of rows
ny: number
// number of slices
nz: number
// number of volumes
nt: number
// space between columns
dx: number
// space between rows
dy: number
// space between slices
dz: number
// time between volumes
dt: number
// bits per voxel
// TODO was documented as bpx
bpv: number
}
export const NVImageFromUrlOptions = (
url: string,
urlImageData = '',
name = '',
colormap = 'gray',
opacity = 1.0,
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,
alphaThreshold = false,
colormapLabel = null
): ImageFromUrlOptions => {
return {
url,
urlImageData,
name,
colormap,
colorMap: colormap,
opacity,
cal_min,
cal_max,
trustCalMinMax,
percentileFrac,
ignoreZeroVoxels,
useQFormNotSForm,
colormapNegative,
imageType,
cal_minNeg,
cal_maxNeg,
colorbarVisible,
frame4D,
alphaThreshold,
colormapLabel
}
}
// not included in public docs
// create NIfTI format SForm from DICOM frame of reference
export function getBestTransform(
imageDirections: number[],
voxelDimensions: number[],
imagePosition: number[]
): number[][] | null {
// https://github.com/rii-mango/Papaya/blob/782a19341af77a510d674c777b6da46afb8c65f1/src/js/volume/dicom/header-dicom.js#L605
/* Copyright (c) 2012-2015, RII-UTHSCSA
All rights reserved.
THIS PRODUCT IS NOT FOR CLINICAL USE.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
following conditions are met:
- Redistributions of source code must retain the above copyright notice, this list of conditions and the following
disclaimer.
- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided with the distribution.
- Neither the name of the RII-UTHSCSA nor the names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
const cosines = imageDirections
let m = null
if (cosines) {
const vs = {
colSize: voxelDimensions[0],
rowSize: voxelDimensions[1],
sliceSize: voxelDimensions[2]
}
const coord = imagePosition
const cosx = [cosines[0], cosines[1], cosines[2]]
const cosy = [cosines[3], cosines[4], cosines[5]]
const cosz = [
cosx[1] * cosy[2] - cosx[2] * cosy[1],
cosx[2] * cosy[0] - cosx[0] * cosy[2],
cosx[0] * cosy[1] - cosx[1] * cosy[0]
]
m = [
[cosx[0] * vs.colSize * -1, cosy[0] * vs.rowSize * -1, cosz[0] * vs.sliceSize * -1, -1 * coord[0]],
[cosx[1] * vs.colSize * -1, cosy[1] * vs.rowSize * -1, cosz[1] * vs.sliceSize * -1, -1 * coord[1]],
[cosx[2] * vs.colSize, cosy[2] * vs.rowSize, cosz[2] * vs.sliceSize, coord[2]],
[0, 0, 0, 1]
]
}
return m
}
function str2Buffer(str: string, maxLen: number = 80): number[] {
// emulate node.js Buffer.from
// remove characters than could be used for shell expansion
str = str.replace(/[`$]/g, '')
const bytes = []
const len = Math.min(maxLen, str.length)
for (let i = 0; i < len; i++) {
const char = str.charCodeAt(i)
bytes.push(char & 0xff)
}
return bytes
}
// save NIfTI header into UINT8 array for saving to disk
export function hdrToArrayBuffer(hdr: NiftiHeader, isDrawing8 = false, isInputEndian = false): Uint8Array {
const SHORT_SIZE = 2
const FLOAT32_SIZE = 4
let isLittleEndian = true
if (isInputEndian) {
isLittleEndian = hdr.littleEndian
}
const byteArray = new Uint8Array(348)
const view = new DataView(byteArray.buffer)
// sizeof_hdr
view.setInt32(0, 348, isLittleEndian)
// data_type, db_name, extents, session_error, regular are not used
// regular set to 'r' (ASCII 114) for Analyze compatibility
view.setUint8(38, 114)
// dim_info
view.setUint8(39, hdr.dim_info)
// dims
for (let i = 0; i < 8; i++) {
view.setUint16(40 + SHORT_SIZE * i, hdr.dims[i], isLittleEndian)
}
// intent_p1, intent_p2, intent_p3
view.setFloat32(56, hdr.intent_p1, isLittleEndian)
view.setFloat32(60, hdr.intent_p2, isLittleEndian)
view.setFloat32(64, hdr.intent_p3, isLittleEndian)
// intent_code, datatype, bitpix, slice_start
view.setInt16(68, hdr.intent_code, isLittleEndian)
if (isDrawing8) {
view.setInt16(70, 2, isLittleEndian) // 2 = DT_UINT8
view.setInt16(72, 8, isLittleEndian)
} else {
view.setInt16(70, hdr.datatypeCode, isLittleEndian)
view.setInt16(72, hdr.numBitsPerVoxel, isLittleEndian)
}
view.setInt16(74, hdr.slice_start, isLittleEndian)
// pixdim[8], vox_offset, scl_slope, scl_inter
for (let i = 0; i < 8; i++) {
view.setFloat32(76 + FLOAT32_SIZE * i, hdr.pixDims[i], isLittleEndian)
}
if (isDrawing8) {
view.setFloat32(108, 352, isLittleEndian)
view.setFloat32(112, 1.0, isLittleEndian)
view.setFloat32(116, 0.0, isLittleEndian)
} else {
// view.setFloat32(108, hdr.vox_offset, isLittleEndian)
view.setFloat32(108, 352, isLittleEndian)
view.setFloat32(112, hdr.scl_slope, isLittleEndian)
view.setFloat32(116, hdr.scl_inter, isLittleEndian)
}
// slice_end
view.setInt16(120, hdr.slice_end, isLittleEndian)
// slice_code, xyzt_units
view.setUint8(122, hdr.slice_code)
if (hdr.xyzt_units === 0) {
view.setUint8(123, 10)
} else {
view.setUint8(123, hdr.xyzt_units)
}
// cal_max, cal_min, slice_duration, toffset
if (isDrawing8) {
view.setFloat32(124, 0, isLittleEndian)
view.setFloat32(128, 0, isLittleEndian)
} else {
view.setFloat32(124, hdr.cal_max, isLittleEndian)
view.setFloat32(128, hdr.cal_min, isLittleEndian)
}
view.setFloat32(132, hdr.slice_duration, isLittleEndian)
view.setFloat32(136, hdr.toffset, isLittleEndian)
// glmax, glmin are unused
// descrip and aux_file
// node.js byteArray.set(Buffer.from(hdr.description), 148);
byteArray.set(str2Buffer(hdr.description), 148)
// node.js: byteArray.set(Buffer.from(hdr.aux_file), 228);
byteArray.set(str2Buffer(hdr.aux_file), 228)
// qform_code, sform_code
view.setInt16(252, hdr.qform_code, isLittleEndian)
// if sform unknown, assume NIFTI_XFORM_SCANNER_ANAT
if (hdr.sform_code < 1 || hdr.sform_code < 1) {
view.setInt16(254, 1, isLittleEndian)
} else {
view.setInt16(254, hdr.sform_code, isLittleEndian)
}
// quatern_b, quatern_c, quatern_d, qoffset_x, qoffset_y, qoffset_z, srow_x[4], srow_y[4], and srow_z[4]
view.setFloat32(256, hdr.quatern_b, isLittleEndian)
view.setFloat32(260, hdr.quatern_c, isLittleEndian)
view.setFloat32(264, hdr.quatern_d, isLittleEndian)
view.setFloat32(268, hdr.qoffset_x, isLittleEndian)
view.setFloat32(272, hdr.qoffset_y, isLittleEndian)
view.setFloat32(276, hdr.qoffset_z, isLittleEndian)
const flattened = hdr.affine.flat()
// we only want the first three rows
for (let i = 0; i < 12; i++) {
view.setFloat32(280 + FLOAT32_SIZE * i, flattened[i], isLittleEndian)
}
// node.js https://www.w3schools.com/nodejs/met_buffer_from.asp
// intent_name and magic
// node.js byteArray.set(Buffer.from(hdr.intent_name), 328);
// byteArray.set(str2Buffer(hdr.intent_name), 328)
// node.js byteArray.set(Buffer.from(hdr.magic), 344);
// byteArray.set(str2Buffer(hdr.magic), 344)
view.setInt32(344, 3222382, true) // "n+1\0"
return byteArray
// return byteArray.buffer;
}
type Extents = {
// min bounding point
min: number[]
// max bounding point
max: number[]
// point furthest from origin
furthestVertexFromOrigin: number
// origin
origin: vec3
}
export function getExtents(positions: number[], forceOriginInVolume = true): Extents {
const nV = Math.round(positions.length / 3) // each vertex has 3 components: XYZ
const origin = vec3.fromValues(0, 0, 0) // default center of rotation
const mn = vec3.create()
const mx = vec3.create()
let mxDx = 0.0
let nLoops = 1
if (forceOriginInVolume) {
nLoops = 2
} // second pass to reposition origin
for (let loop = 0; loop < nLoops; loop++) {
mxDx = 0.0
for (let i = 0; i < nV; i++) {
const v = vec3.fromValues(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2])
if (i === 0) {
vec3.copy(mn, v)
vec3.copy(mx, v)
}
vec3.min(mn, mn, v)
vec3.max(mx, mx, v)
vec3.subtract(v, v, origin)
const dx = vec3.len(v)
mxDx = Math.max(mxDx, dx)
}
if (loop + 1 >= nLoops) {
break
}
let ok = true
for (let j = 0; j < 3; ++j) {
if (mn[j] > origin[j]) {
ok = false
}
if (mx[j] < origin[j]) {
ok = false
}
}
if (ok) {
break
}
vec3.lerp(origin, mn, mx, 0.5)
log.debug('origin moved inside volume: ', origin)
}
const min = [mn[0], mn[1], mn[2]]
const max = [mx[0], mx[1], mx[2]]
const furthestVertexFromOrigin = mxDx
return { min, max, furthestVertexFromOrigin, origin }
}
export function isAffineOK(mtx: number[][]): boolean {
// A good matrix should not have any components that are not a number
// A good spatial transformation matrix should not have a row or column that is all zeros
const iOK = [false, false, false, false]
const jOK = [false, false, false, false]
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
if (isNaN(mtx[i][j])) {
return false
}
}
}
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (mtx[i][j] === 0.0) {
continue
}
iOK[i] = true
jOK[j] = true
}
}
for (let i = 0; i < 3; i++) {
if (!iOK[i]) {
return false
}
if (!jOK[i]) {
return false
}
}
return true
}
export async function uncompressStream(stream: ReadableStream<Uint8Array>): Promise<ReadableStream<Uint8Array>> {
const reader = stream.getReader()
const { done, value } = await reader.read()
// If the first read is done, return an empty stream
if (done) {
reader.releaseLock()
return new ReadableStream({
start(controller): void {
controller.close()
}
})
}
// Too short to be compressed
if (!value || value.length < 2) {
reader.releaseLock()
return new ReadableStream({
start(controller): void {
if (value) {
controller.enqueue(value)
}
controller.close()
}
})
}
const isGzip = value[0] === 31 && value[1] === 139
// Create new stream starting with the first chunk
const uncompressedStream = new ReadableStream<Uint8Array>({
async start(controller): Promise<void> {
try {
// Enqueue the first chunk we already read
controller.enqueue(value)
// Process remaining chunks
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.close()
reader.releaseLock()
break
}
controller.enqueue(value)
}
} catch (error) {
controller.error(error)
reader.releaseLock()
}
}
})
if (isGzip) {
return uncompressedStream.pipeThrough(new DecompressionStream('gzip'))
}
return uncompressedStream
}