import { vec3 } from 'gl-matrix' import { log } from '@/logger' import { NiftiHeader } from '@/types' import { LUT } from '@/colortables' 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, HDR = 23 } 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 'HDR': 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 // 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 // Zarr chunked loading options zarrLevel?: number zarrMaxVolumeSize?: number zarrChannel?: number /** Convert OME spatial units to millimeters for NIfTI compatibility (default: true) */ zarrConvertUnits?: boolean /** World-space center [x, y, z] in mm where the zarr volume center should be positioned */ zarrCenterMM?: [number, number, number] } // 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, hdr.vox_offset, 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): Promise> { 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({ async start(controller): Promise { 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 }