UNPKG

@niivue/niivue

Version:

minimal webgl2 nifti image viewer

1,546 lines (1,520 loc) 128 kB
import { mat4, vec4, vec3 } from 'gl-matrix' import { log } from './logger.js' import { NVUtilities, Zip } from './nvutilities.js' import { ColorMap, LUT, cmapper } from './colortables.js' import { NiivueObject3D } from './niivue-object3D.js' import { NVMesh, NVMeshLayer, NVMeshLayerDefaults } from './nvmesh.js' import { ANNOT, DefaultMeshType, GII, // Layer, MGH, MZ3, SmpMap, TCK, TRACT, TRK, TT, TRX, VTK, X3D, XmlTag, AnyNumberArray } from './nvmesh-types.js' const utiltiesLogger = log /** * Class to load different mesh formats * @ignore */ export class NVMeshLoaders { // read undocumented AFNI tract.niml format streamlines static readTRACT(buffer: ArrayBuffer): TRACT { const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be niml.tract: bytes = ' + len) } const reader = new DataView(buffer) const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { // read until right angle bracket ">" while (pos < len && bytes[pos] !== 60) { pos++ } // start with "<" const startPos = pos while (pos < len && bytes[pos] !== 62) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)).trim() } let line = readStr() // 1st line: signature '<network' function readNumericTag(TagName: string): number { // Tag 'Dim1' will return 3 for Dim1="3" const pos = line.indexOf(TagName) if (pos < 0) { return 0 } const spos = line.indexOf('"', pos) + 1 const epos = line.indexOf('"', spos) const str = line.slice(spos, epos) return parseInt(str) } const n_tracts = readNumericTag('N_tracts=') if (!line.startsWith('<network') || n_tracts < 1) { log.warn('This is not a valid niml.tract file ' + line) } let npt = 0 const offsetPt0 = [] offsetPt0.push(npt) // 1st streamline starts at 0 const pts = [] const bundleTags = [] for (let t = 0; t < n_tracts; t++) { line = readStr() // <tracts ... const new_tracts = readNumericTag('ni_dimen=') const bundleTag = readNumericTag('Bundle_Tag=') const isLittleEndian = line.includes('binary.lsbfirst') for (let i = 0; i < new_tracts; i++) { // let id = reader.getUint32(pos, isLittleEndian); pos += 4 const new_pts = reader.getUint32(pos, isLittleEndian) / 3 pos += 4 for (let j = 0; j < new_pts; j++) { pts.push(reader.getFloat32(pos, isLittleEndian)) pos += 4 pts.push(-reader.getFloat32(pos, isLittleEndian)) pos += 4 pts.push(reader.getFloat32(pos, isLittleEndian)) pos += 4 } npt += new_pts offsetPt0.push(npt) bundleTags.push(bundleTag) // each streamline associated with tract } line = readStr() // </tracts> } const dps = [] dps.push({ id: 'tract', vals: Float32Array.from(bundleTags) }) return { pts: new Float32Array(pts), offsetPt0: new Uint32Array(offsetPt0), dps } } // readTRACT() // https://dsi-studio.labsolver.org/doc/cli_data.html // https://brain.labsolver.org/hcp_trk_atlas.html static async readTT(buffer: ArrayBuffer): Promise<TT> { // Read a Matlab V4 file, n.b. does not support modern versions // https://www.mathworks.com/help/pdf_doc/matlab/matfile_format.pdf let offsetPt0 = new Uint32Array(0) let pts = new Float32Array(0) const mat = await NVUtilities.readMatV4(buffer) if (!('trans_to_mni' in mat)) { throw new Error("TT format file must have 'trans_to_mni'") } if (!('voxel_size' in mat)) { throw new Error("TT format file must have 'voxel_size'") } if (!('track' in mat)) { throw new Error("TT format file must have 'track'") } let trans_to_mni = mat4.create() const m = mat.trans_to_mni trans_to_mni = mat4.fromValues( m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15] ) mat4.transpose(trans_to_mni, trans_to_mni) // unlike TRK, TT uses voxel centers, not voxel corners function parse_tt( track: Float64Array | Float32Array | Uint32Array | Uint16Array | Uint8Array | Int32Array | Int16Array | Int8Array ): void { const dv = new DataView(track.buffer) const pos = [] let nvert3 = 0 let i = 0 while (i < track.length) { pos.push(i) const newpts = dv.getUint32(i, true) i = i + newpts + 13 nvert3 += newpts } offsetPt0 = new Uint32Array(pos.length + 1) pts = new Float32Array(nvert3) let npt = 0 for (let i = 0; i < pos.length; i++) { offsetPt0[i] = npt / 3 let p = pos[i] const sz = dv.getUint32(p, true) / 3 let x = dv.getInt32(p + 4, true) let y = dv.getInt32(p + 8, true) let z = dv.getInt32(p + 12, true) p += 16 pts[npt++] = x pts[npt++] = y pts[npt++] = z for (let j = 2; j <= sz; j++) { x = x + dv.getInt8(p++) y = y + dv.getInt8(p++) z = z + dv.getInt8(p++) pts[npt++] = x pts[npt++] = y pts[npt++] = z } } // for each streamline for (let i = 0; i < npt; i++) { pts[i] = pts[i] / 32.0 } let v = 0 for (let i = 0; i < npt / 3; i++) { const pos = vec4.fromValues(pts[v], pts[v + 1], pts[v + 2], 1) vec4.transformMat4(pos, pos, trans_to_mni) pts[v++] = pos[0] pts[v++] = pos[1] pts[v++] = pos[2] } offsetPt0[pos.length] = npt / 3 // solve fence post problem, offset for final streamline } // parse_tt() parse_tt(mat.track) return { pts, offsetPt0 } } // readTT // read TRX format tractogram // https://github.com/tee-ar-ex/trx-spec/blob/master/specifications.md static async readTRX(buffer: ArrayBuffer): Promise<TRX> { // Javascript does not support float16, so we convert to float32 // https://stackoverflow.com/questions/5678432/decompressing-half-precision-floats-in-javascript function decodeFloat16(binary: number): number { 'use strict' const exponent = (binary & 0x7c00) >> 10 const fraction = binary & 0x03ff return ( (binary >> 15 ? -1 : 1) * (exponent ? exponent === 0x1f ? fraction ? NaN : Infinity : Math.pow(2, exponent - 15) * (1 + fraction / 0x400) : 6.103515625e-5 * (fraction / 0x400)) ) } // decodeFloat16() let noff = 0 let npt = 0 let pts = new Float32Array([]) const offsetPt0: number[] = [] const dpg = [] const dps = [] const dpv = [] let header = [] let isOverflowUint64 = false const zip = new Zip(buffer) for (let i = 0; i < zip.entries.length; i++) { const entry = zip.entries[i] if (entry.uncompressedSize === 0) { continue // e.g. folder } const parts = entry.fileName.split('/') const fname = parts.slice(-1)[0] // my.trx/dpv/fx.float32 -> fx.float32 if (fname.startsWith('.')) { continue } const pname = parts.slice(-2)[0] // my.trx/dpv/fx.float32 -> dpv const tag = fname.split('.')[0] // "positions.3.float16 -> "positions" const data = await entry.extract() // const data = await NVUtilities.zipInflate(buffer, entry.startsAt, entry.compressedSize, entry.uncompressedSize, entry.compressionMethod ) // console.log(`entry ${pname} ${fname} ${tag} : ${data.length}`) if (fname.includes('header.json')) { const jsonString = new TextDecoder().decode(data) header = JSON.parse(jsonString) continue } // next read arrays for all possible datatypes: int8/16/32/64 uint8/16/32/64 float16/32/64 let nval = 0 let vals: AnyNumberArray = [] if (fname.endsWith('.uint64') || fname.endsWith('.int64')) { // javascript does not have 64-bit integers! read lower 32-bits // note for signed int64 we only read unsigned bytes // for both signed and unsigned, generate an error if any value is out of bounds // one alternative might be to convert to 64-bit double that has a flintmax of 2^53. nval = data.length / 8 // 8 bytes per 64bit input vals = new Uint32Array(nval) const u32 = new Uint32Array(data.buffer) let j = 0 for (let i = 0; i < nval; i++) { vals[i] = u32[j] if (u32[j + 1] !== 0) { isOverflowUint64 = true } j += 2 } } else if (fname.endsWith('.uint32')) { vals = new Uint32Array(data.buffer) } else if (fname.endsWith('.uint16')) { vals = new Uint16Array(data.buffer) } else if (fname.endsWith('.uint8')) { vals = new Uint8Array(data.buffer) } else if (fname.endsWith('.int32')) { vals = new Int32Array(data.buffer) } else if (fname.endsWith('.int16')) { vals = new Int16Array(data.buffer) } else if (fname.endsWith('.int8')) { vals = new Int8Array(data.buffer) } else if (fname.endsWith('.float64')) { vals = new Float64Array(data.buffer) } else if (fname.endsWith('.float32')) { vals = new Float32Array(data.buffer) } else if (fname.endsWith('.float16')) { // javascript does not have 16-bit floats! Convert to 32-bits nval = data.length / 2 // 2 bytes per 16bit input vals = new Float32Array(nval) const u16 = new Uint16Array(data.buffer) const lut = new Float32Array(65536) for (let i = 0; i < 65536; i++) { lut[i] = decodeFloat16(i) } for (let i = 0; i < nval; i++) { vals[i] = lut[u16[i]] } } else { continue } // not a data array nval = vals.length // next: read data_per_group if (pname.includes('groups')) { dpg.push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } // next: read data_per_vertex if (pname.includes('dpv')) { dpv.push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } // next: read data_per_streamline if (pname.includes('dps')) { dps.push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } // Next: read offsets: Always uint64 if (fname.startsWith('offsets.')) { // javascript does not have 64-bit integers! read lower 32-bits noff = nval // 8 bytes per 64bit input // we need to solve the fence post problem, so we can not use slice for (let i = 0; i < nval; i++) { offsetPt0[i] = vals[i] } } if (fname.startsWith('positions.3.')) { npt = nval // 4 bytes per 32bit input pts = new Float32Array(vals) } } if (noff === 0 || npt === 0) { throw new Error('Failure reading TRX format (no offsets or points).') } if (isOverflowUint64) { // TODO use BigInt throw new Error('Too many vertices: JavaScript does not support 64 bit integers') } offsetPt0[noff] = npt / 3 // solve fence post problem, offset for final streamline return { pts, offsetPt0: new Uint32Array(offsetPt0), dpg, dps, dpv, header } } // readTRX() // read mrtrix tsf format Track Scalar Files - these are are DPV // https://mrtrix.readthedocs.io/en/dev/getting_started/image_data.html#track-scalar-file-format-tsf static readTSF(buffer: ArrayBuffer, n_vert = 0): Float32Array { const vals = new Float32Array(n_vert) const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be TSF: bytes = ' + len) } const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: signature 'mrtrix tracks' if (!line.includes('mrtrix track scalars')) { throw new Error('Not a valid TSF file') } let offset = -1 // "file: offset" is REQUIRED while (pos < len && !line.includes('END')) { line = readStr() if (line.toLowerCase().startsWith('file:')) { offset = parseInt(line.split(' ').pop()!) } if (line.toLowerCase().startsWith('datatype:') && !line.endsWith('Float32LE')) { throw new Error('Only supports TSF files with Float32LE') } } if (offset < 20) { throw new Error('Not a valid TSF file (missing file offset)') } pos = offset const reader = new DataView(buffer) // read and transform vertex positions let npt = 0 while (pos + 4 <= len && npt < n_vert) { const ptx = reader.getFloat32(pos, true) pos += 4 if (!isFinite(ptx)) { // both NaN and Infinity are not finite if (!isNaN(ptx)) { // terminate if infinity break } } else { vals[npt++] = ptx } } return vals } // readTSF // read mrtrix tck format streamlines // https://mrtrix.readthedocs.io/en/latest/getting_started/image_data.html#tracks-file-format-tck static readTCK(buffer: ArrayBuffer): TCK { const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be TCK: bytes = ' + len) } const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: signature 'mrtrix tracks' if (!line.includes('mrtrix tracks')) { throw new Error('Not a valid TCK file') } let offset = -1 // "file: offset" is REQUIRED while (pos < len && !line.includes('END')) { line = readStr() if (line.toLowerCase().startsWith('file:')) { offset = parseInt(line.split(' ').pop()!) } } if (offset < 20) { throw new Error('Not a valid TCK file (missing file offset)') } pos = offset const reader = new DataView(buffer) // read and transform vertex positions let npt = 0 // over-provision offset array to store number of segments let offsetPt0 = new Uint32Array(len / (4 * 4)) let noffset = 0 // over-provision points array to store vertex positions let npt3 = 0 let pts = new Float32Array(len / 4) offsetPt0[0] = 0 // 1st streamline starts at 0 while (pos + 12 < len) { const ptx = reader.getFloat32(pos, true) pos += 4 const pty = reader.getFloat32(pos, true) pos += 4 const ptz = reader.getFloat32(pos, true) pos += 4 if (!isFinite(ptx)) { // both NaN and Infinity are not finite offsetPt0[noffset++] = npt if (!isNaN(ptx)) { // terminate if infinity break } } else { pts[npt3++] = ptx pts[npt3++] = pty pts[npt3++] = ptz npt++ } } // resize offset/vertex arrays that were initially over-provisioned pts = pts.slice(0, npt3) offsetPt0 = offsetPt0.slice(0, noffset) return { pts, offsetPt0 } } // readTCK() // not included in public docs // read trackvis trk format streamlines // http://trackvis.org/docs/?subsect=fileformat static async readTRK(buffer: ArrayBuffer): Promise<TRK> { // https://brain.labsolver.org/hcp_trk_atlas.html // https://github.com/xtk/X/tree/master/io // in practice, always little endian let reader = new DataView(buffer) let magic = reader.getUint32(0, true) // 'TRAC' if (magic !== 1128354388) { // e.g. TRK.gz let raw if (magic === 4247762216) { // e.g. TRK.zstd // raw = fzstd.decompress(new Uint8Array(buffer)); // raw = new Uint8Array(raw); throw new Error('zstd TRK decompression is not supported') } else { raw = await NVUtilities.decompress(new Uint8Array(buffer)) } buffer = raw.buffer reader = new DataView(buffer) magic = reader.getUint32(0, true) // 'TRAC' } const vers = reader.getUint32(992, true) // 2 const hdr_sz = reader.getUint32(996, true) // 1000 if (vers > 2 || hdr_sz !== 1000 || magic !== 1128354388) { throw new Error('Not a valid TRK file') } const n_scalars = reader.getInt16(36, true) const dpv = [] // data_per_vertex for (let i = 0; i < n_scalars; i++) { const arr = new Uint8Array(buffer.slice(38 + i * 20, 58 + i * 20)) const str = new TextDecoder().decode(arr).split('\0').shift() dpv.push({ id: str!.trim(), // TODO can we guarantee this? vals: [] as number[] }) } const voxel_sizeX = reader.getFloat32(12, true) const voxel_sizeY = reader.getFloat32(16, true) const voxel_sizeZ = reader.getFloat32(20, true) const zoomMat = mat4.fromValues( 1 / voxel_sizeX, 0, 0, -0.5, 0, 1 / voxel_sizeY, 0, -0.5, 0, 0, 1 / voxel_sizeZ, -0.5, 0, 0, 0, 1 ) const n_properties = reader.getInt16(238, true) const dps = [] // data_per_streamline for (let i = 0; i < n_properties; i++) { const arr = new Uint8Array(buffer.slice(240 + i * 20, 260 + i * 20)) const str = new TextDecoder().decode(arr).split('\0').shift() dps.push({ id: str!.trim(), // TODO can we guarantee this? vals: [] as number[] }) } const mat = mat4.create() for (let i = 0; i < 16; i++) { mat[i] = reader.getFloat32(440 + i * 4, true) } if (mat[15] === 0.0) { // vox_to_ras[3][3] is 0, it means the matrix is not recorded log.warn('TRK vox_to_ras not set') mat4.identity(mat) } const vox2mmMat = mat4.create() mat4.mul(vox2mmMat, zoomMat, mat) let i32 = null let f32 = null i32 = new Int32Array(buffer.slice(hdr_sz)) f32 = new Float32Array(i32.buffer) const ntracks = i32.length if (ntracks < 1) { throw new Error('Empty TRK file.') } // read and transform vertex positions let i = 0 let npt = 0 // pre-allocate and over-provision offset array let offsetPt0 = new Uint32Array(i32.length / 4) let noffset = 0 // pre-allocate and over-provision vertex positions array let pts = new Float32Array(i32.length) let npt3 = 0 while (i < ntracks) { const n_pts = i32[i] i = i + 1 // read 1 32-bit integer for number of points in this streamline offsetPt0[noffset++] = npt for (let j = 0; j < n_pts; j++) { const ptx = f32[i + 0] const pty = f32[i + 1] const ptz = f32[i + 2] i += 3 // read 3 32-bit floats for XYZ position pts[npt3++] = ptx * vox2mmMat[0] + pty * vox2mmMat[1] + ptz * vox2mmMat[2] + vox2mmMat[3] pts[npt3++] = ptx * vox2mmMat[4] + pty * vox2mmMat[5] + ptz * vox2mmMat[6] + vox2mmMat[7] pts[npt3++] = ptx * vox2mmMat[8] + pty * vox2mmMat[9] + ptz * vox2mmMat[10] + vox2mmMat[11] if (n_scalars > 0) { for (let s = 0; s < n_scalars; s++) { dpv[s].vals.push(f32[i]) i++ } } npt++ } // for j: each point in streamline if (n_properties > 0) { for (let j = 0; j < n_properties; j++) { dps[j].vals.push(f32[i]) i++ } } } // for each streamline: while i < n_count // output uses static float32 not dynamic number[] const dps32 = [] // data_per_streamline for (let i = 0; i < dps.length; i++) { dps32.push({ id: dps[i].id, vals: Float32Array.from(dps[i].vals) }) } const dpv32 = [] for (let i = 0; i < dpv.length; i++) { dpv32.push({ id: dpv[i].id, vals: Float32Array.from(dpv[i].vals) }) } // add 'first index' as if one more line was added (fence post problem) offsetPt0[noffset++] = npt // resize offset/vertex arrays that were initially over-provisioned pts = pts.slice(0, npt3) offsetPt0 = offsetPt0.slice(0, noffset) return { pts, offsetPt0, dps: dps32, dpv: dpv32 } } // readTRK() // read legacy VTK text format file static readTxtVTK(buffer: ArrayBuffer): VTK { const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) const lines = txt.split('\n') const n = lines.length if (n < 7 || !lines[0].startsWith('# vtk DataFile')) { throw new Error('Invalid VTK image') } if (!lines[2].startsWith('ASCII')) { throw new Error('Not ASCII VTK mesh') } let pos = 3 while (lines[pos].length < 1) { pos++ } // skip blank lines if (!lines[pos].includes('POLYDATA')) { throw new Error('Not ASCII VTK polydata') } pos++ while (lines[pos].length < 1) { pos++ } // skip blank lines if (!lines[pos].startsWith('POINTS')) { throw new Error('Not VTK POINTS') } let items = lines[pos].trim().split(/\s+/) const nvert = parseInt(items[1]) // POINTS 10261 float const nvert3 = nvert * 3 const positions = new Float32Array(nvert * 3) let v = 0 while (v < nvert * 3) { pos++ const str = lines[pos].trim() const pts = str.trim().split(/\s+/) for (let i = 0; i < pts.length; i++) { if (v >= nvert3) { break } positions[v] = parseFloat(pts[i]) v++ } } const tris = [] pos++ while (lines[pos].length < 1) { pos++ } // skip blank lines if (lines[pos].startsWith('METADATA')) { while (lines[pos].length > 1) { pos++ } // skip until blank line pos++ } items = lines[pos].trim().split(/\s+/) pos++ if (items[0].includes('LINES')) { const n_count = parseInt(items[1]) if (n_count < 1) { throw new Error('Corrupted VTK ASCII') } let str = lines[pos].trim() const offsetPt0 = [] let pts: number[] = [] if (str.startsWith('OFFSETS')) { // 'new' line style https://discourse.vtk.org/t/upcoming-changes-to-vtkcellarray/2066 pos++ let c = 0 while (c < n_count) { str = lines[pos].trim() pos++ const items = str.trim().split(/\s+/) for (let i = 0; i < items.length; i++) { offsetPt0[c] = parseInt(items[i]) c++ if (c >= n_count) { break } } // for each line } // while offset array not filled pts = Array.from(positions) } else { // classic line style https://www.visitusers.org/index.php?title=ASCII_VTK_Files let npt = 0 offsetPt0[0] = 0 // 1st streamline starts at 0 let asciiInts: number[] = [] let asciiIntsPos = 0 function lineToInts(): void { // VTK can save one array across multiple ASCII lines str = lines[pos].trim() const items = str.trim().split(/\s+/) asciiInts = [] for (let i = 0; i < items.length; i++) { asciiInts.push(parseInt(items[i])) } asciiIntsPos = 0 pos++ } lineToInts() for (let c = 0; c < n_count; c++) { if (asciiIntsPos >= asciiInts.length) { lineToInts() } const numPoints = asciiInts[asciiIntsPos++] npt += numPoints offsetPt0[c + 1] = npt for (let i = 0; i < numPoints; i++) { if (asciiIntsPos >= asciiInts.length) { lineToInts() } const idx = asciiInts[asciiIntsPos++] * 3 pts.push(positions[idx + 0]) // X pts.push(positions[idx + 1]) // Y pts.push(positions[idx + 2]) // Z } // for numPoints: number of segments in streamline } // for n_count: number of streamlines } return { pts: Float32Array.from(pts), offsetPt0: Uint32Array.from(offsetPt0) } } else if (items[0].includes('TRIANGLE_STRIPS')) { const nstrip = parseInt(items[1]) for (let i = 0; i < nstrip; i++) { const str = lines[pos].trim() pos++ const vs = str.trim().split(/\s+/) const ntri = parseInt(vs[0]) - 2 // -2 as triangle strip is creates pts - 2 faces let k = 1 for (let t = 0; t < ntri; t++) { if (t % 2) { // preserve winding order tris.push(parseInt(vs[k + 2])) tris.push(parseInt(vs[k + 1])) tris.push(parseInt(vs[k])) } else { tris.push(parseInt(vs[k])) tris.push(parseInt(vs[k + 1])) tris.push(parseInt(vs[k + 2])) } k += 1 } // for each triangle } // for each strip } else if (items[0].includes('POLYGONS')) { const npoly = parseInt(items[1]) for (let i = 0; i < npoly; i++) { const str = lines[pos].trim() pos++ const vs = str.trim().split(/\s+/) const ntri = parseInt(vs[0]) - 2 // e.g. 3 for triangle const fx = parseInt(vs[1]) let fy = parseInt(vs[2]) for (let t = 0; t < ntri; t++) { const fz = parseInt(vs[3 + t]) tris.push(fx) tris.push(fy) tris.push(fz) fy = fz } } } else { throw new Error('Unsupported ASCII VTK datatype ' + items[0]) } const indices = new Uint32Array(tris) return { positions, indices } } // readTxtVTK() // read mesh overlay to influence vertex colors static async readLayer( name: string = '', buffer: ArrayBuffer, nvmesh: NVMesh, opacity = 0.5, colormap = 'warm', colormapNegative = 'winter', useNegativeCmap = false, cal_min: number | null = null, cal_max: number | null = null, outlineBorder = 0 ): Promise<NVMeshLayer | undefined> { const layer: NVMeshLayer = { ...NVMeshLayerDefaults, colormapInvert: false, colormapType: 0, // COLORMAP_TYPE.MIN_TO_MAX isTransparentBelowCalMin: true, isAdditiveBlend: false, colorbarVisible: true, colormapLabel: null } const isReadColortables = true const re = /(?:\.([^.]+))?$/ let ext = re.exec(name)![1] // TODO can we guarantee this? ext = ext.toUpperCase() if (ext === 'GZ') { ext = re.exec(name.slice(0, -3))![1] // img.trk.gz -> img.trk ext = ext.toUpperCase() } const n_vert = nvmesh.vertexCount / 3 // each vertex has XYZ component if (nvmesh.offsetPt0) { if (ext !== 'TSF') { throw new Error('readLayer for streamlines only supports TSF files.') } const npt = nvmesh.pts.length / 3 // typescript hell commences for one liner // const tag = name.split('/')!.pop()!.split('.')!.slice(0, -1).join('.')! const splitResult = name.split('/') let tag = 'Unknown' if (splitResult.length > 1) { const tag1 = splitResult.pop() if (tag1) { tag = tag.split('.').slice(0, -1).join('.') } } // return to readable javascript const vals = NVMeshLoaders.readTSF(buffer, npt) if (!nvmesh.dpv) { nvmesh.dpv = [] } const mn = vals.reduce((acc, current) => Math.min(acc, current)) const mx = vals.reduce((acc, current) => Math.max(acc, current)) nvmesh.dpv.push({ id: tag, vals: Float32Array.from(vals.slice()), global_min: mn, global_max: mx, cal_min: mn, cal_max: mx }) return layer } if (n_vert < 3) { log.error('n_vert < 3 in layer') return } if (ext === 'MZ3') { const obj = await NVMeshLoaders.readMZ3(buffer, n_vert) layer.values = obj.scalars if ('colormapLabel' in obj) { layer.colormapLabel = obj.colormapLabel } } else if (ext === 'ANNOT') { if (!isReadColortables) { // TODO: bogus ANNOT return type layer.values = NVMeshLoaders.readANNOT(buffer, n_vert) as unknown as Float32Array } else { const obj = NVMeshLoaders.readANNOT(buffer, n_vert, true) if (!(obj instanceof Uint32Array)) { layer.values = obj.scalars layer.colormapLabel = obj.colormapLabel } // unable to decode colormapLabel else { layer.values = obj } } } else if (ext === 'CRV' || ext === 'CURV' || ext === 'THICKNESS' || ext === 'AREA') { layer.values = NVMeshLoaders.readCURV(buffer, n_vert) layer.isTransparentBelowCalMin = false } else if (ext === 'GII') { const obj = await NVMeshLoaders.readGII(buffer, n_vert) layer.values = obj.scalars // colormapLabel layer.colormapLabel = obj.colormapLabel } else if (ext === 'MGH' || ext === 'MGZ') { if (!isReadColortables) { layer.values = (await NVMeshLoaders.readMGH(buffer, n_vert)) as number[] } else { const obj = await NVMeshLoaders.readMGH(buffer, n_vert, true) if ('scalars' in obj) { layer.values = obj.scalars layer.colormapLabel = obj.colormapLabel } // unable to decode colormapLabel else { layer.values = obj } } } else if (ext === 'NII') { layer.values = (await NVMeshLoaders.readNII(buffer, n_vert, nvmesh.anatomicalStructurePrimary)) as Float32Array } else if (ext === 'SMP') { layer.values = await NVMeshLoaders.readSMP(buffer, n_vert) } else if (ext === 'STC') { layer.values = NVMeshLoaders.readSTC(buffer, n_vert) } else if (NVMeshLoaders.isCurv(buffer)) { // Unknown layer overlay format - hail mary assume FreeSurfer layer.values = NVMeshLoaders.readCURV(buffer, n_vert) layer.isTransparentBelowCalMin = false } else { log.warn('Unknown layer overlay format ' + name) return layer } if (!layer.values) { log.error('no values in layer') return } layer.nFrame4D = layer.values.length / n_vert layer.frame4D = 0 layer.outlineBorder = outlineBorder // determine global min..max let mn = layer.values[0] let mx = layer.values[0] for (let i = 0; i < layer.values.length; i++) { mn = Math.min(mn, layer.values[i]) mx = Math.max(mx, layer.values[i]) } layer.global_min = mn layer.global_max = mx layer.cal_min = cal_min || 0 if (!cal_min) { layer.cal_min = mn } layer.cal_max = cal_max || 0 if (!cal_max) { layer.cal_max = mx } layer.cal_minNeg = NaN layer.cal_maxNeg = NaN layer.opacity = opacity layer.colormap = colormap layer.colormapNegative = colormapNegative layer.useNegativeCmap = useNegativeCmap return layer } // readLayer() // read brainvoyager smp format file // https://support.brainvoyager.com/brainvoyager/automation-development/84-file-formats/40-the-format-of-smp-files static async readSMP(buffer: ArrayBuffer, n_vert: number): Promise<Float32Array> { const len = buffer.byteLength let reader = new DataView(buffer) let vers = reader.getUint16(0, true) if (vers > 5) { // assume gzip const raw = await NVUtilities.decompress(new Uint8Array(buffer)) reader = new DataView(raw.buffer) vers = reader.getUint16(0, true) buffer = raw.buffer } if (vers > 5) { log.error('Unsupported or invalid BrainVoyager SMP version ' + vers) } const nvert = reader.getUint32(2, true) if (nvert !== n_vert) { log.error('SMP file has ' + nvert + ' vertices, background mesh has ' + n_vert) } const nMaps = reader.getUint16(6, true) const scalars = new Float32Array(nvert * nMaps) const maps = [] let pos = 9 function readStr(): string { const startPos = pos while (pos < len && reader.getUint8(pos) !== 0) { pos++ } pos++ // skip null termination return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } // readStr: read variable length string // read Name of SRF const _filenameSRF = readStr() for (let i = 0; i < nMaps; i++) { const m: Partial<SmpMap> = {} m.mapType = reader.getUint32(pos, true) pos += 4 // Read additional values only if a lag map if (vers >= 3 && m.mapType === 3) { m.nLags = reader.getUint32(pos, true) pos += 4 m.mnLag = reader.getUint32(pos, true) pos += 4 m.mxLag = reader.getUint32(pos, true) pos += 4 m.ccOverlay = reader.getUint32(pos, true) pos += 4 } m.clusterSize = reader.getUint32(pos, true) pos += 4 m.clusterCheck = reader.getUint8(pos) pos += 1 m.critThresh = reader.getFloat32(pos, true) pos += 4 m.maxThresh = reader.getFloat32(pos, true) pos += 4 if (vers >= 4) { m.includeValuesGreaterThreshMax = reader.getUint32(pos, true) pos += 4 } m.df1 = reader.getUint32(pos, true) pos += 4 m.df2 = reader.getUint32(pos, true) pos += 4 if (vers >= 5) { m.posNegFlag = reader.getUint32(pos, true) pos += 4 } else { m.posNegFlag = 3 } m.cortexBonferroni = reader.getUint32(pos, true) pos += 4 m.posMinRGB = [0, 0, 0] m.posMaxRGB = [0, 0, 0] m.negMinRGB = [0, 0, 0] m.negMaxRGB = [0, 0, 0] if (vers >= 2) { m.posMinRGB[0] = reader.getUint8(pos) pos++ m.posMinRGB[1] = reader.getUint8(pos) pos++ m.posMinRGB[2] = reader.getUint8(pos) pos++ m.posMaxRGB[0] = reader.getUint8(pos) pos++ m.posMaxRGB[1] = reader.getUint8(pos) pos++ m.posMaxRGB[2] = reader.getUint8(pos) pos++ if (vers >= 4) { m.negMinRGB[0] = reader.getUint8(pos) pos++ m.negMinRGB[1] = reader.getUint8(pos) pos++ m.negMinRGB[2] = reader.getUint8(pos) pos++ m.negMaxRGB[0] = reader.getUint8(pos) pos++ m.negMaxRGB[1] = reader.getUint8(pos) pos++ m.negMaxRGB[2] = reader.getUint8(pos) pos++ } // vers >= 4 m.enableSMPColor = reader.getUint8(pos) pos++ if (vers >= 4) { m.lut = readStr() } m.colorAlpha = reader.getFloat32(pos, true) pos += 4 } // vers >= 2 m.name = readStr() const scalarsNew = new Float32Array(buffer, pos, nvert) scalars.set(scalarsNew, i * nvert) pos += nvert * 4 maps.push(m) } // for i to nMaps return scalars } // readSMP() // read mne stc format file, not to be confused with brainvoyager stc format // https://github.com/mne-tools/mne-python/blob/main/mne/source_estimate.py#L211-L365 static readSTC(buffer: ArrayBuffer, n_vert: number): Float32Array { // https://github.com/fahsuanlin/fhlin_toolbox/blob/400cb73cda4880d9ad7841d9dd68e4e9762976bf/codes/inverse_read_stc.m // let len = buffer.byteLength; const reader = new DataView(buffer) // first 12 bytes are header // let epoch_begin_latency = reader.getFloat32(0, false); // let sample_period = reader.getFloat32(4, false); const n_vertex = reader.getInt32(8, false) if (n_vertex !== n_vert) { throw new Error('Overlay has ' + n_vertex + ' vertices, expected ' + n_vert) } // next 4*n_vertex bytes are vertex IDS let pos = 12 + n_vertex * 4 // next 4 bytes reports number of volumes/time points const n_time = reader.getUint32(pos, false) pos += 4 const f32 = new Float32Array(n_time * n_vertex) // reading all floats with .slice() would be faster, but lets handle endian-ness for (let i = 0; i < n_time * n_vertex; i++) { f32[i] = reader.getFloat32(pos, false) pos += 4 } return f32 } // readSTC() static isCurv(buffer: ArrayBuffer): boolean { const view = new DataView(buffer) // ArrayBuffer to dataview // ALWAYS big endian const sig0 = view.getUint8(0) const sig1 = view.getUint8(1) const sig2 = view.getUint8(2) if (sig0 !== 255 || sig1 !== 255 || sig2 !== 255) { utiltiesLogger.debug('Unable to recognize file type: does not appear to be FreeSurfer format.') return false } return true } // read freesurfer curv big-endian format // https://github.com/bonilhamusclab/MRIcroS/blob/master/%2BfileUtils/%2Bpial/readPial.m // http://www.grahamwideman.com/gw/brain/fs/surfacefileformats.htm static readCURV(buffer: ArrayBuffer, n_vert: number): Float32Array { const view = new DataView(buffer) // ArrayBuffer to dataview // ALWAYS big endian const sig0 = view.getUint8(0) const sig1 = view.getUint8(1) const sig2 = view.getUint8(2) const n_vertex = view.getUint32(3, false) // let num_f = view.getUint32(7, false); const n_time = view.getUint32(11, false) if (sig0 !== 255 || sig1 !== 255 || sig2 !== 255) { utiltiesLogger.debug('Unable to recognize file type: does not appear to be FreeSurfer format.') } if (n_vert !== n_vertex) { throw new Error('CURV file has different number of vertices ( ' + n_vertex + ')than mesh (' + n_vert + ')') } if (buffer.byteLength < 15 + 4 * n_vertex * n_time) { throw new Error('CURV file smaller than specified') } const f32 = new Float32Array(n_time * n_vertex) let pos = 15 // reading all floats with .slice() would be faster, but lets handle endian-ness for (let i = 0; i < n_time * n_vertex; i++) { f32[i] = view.getFloat32(pos, false) pos += 4 } let mn = f32[0] let mx = f32[0] for (let i = 0; i < f32.length; i++) { mn = Math.min(mn, f32[i]) mx = Math.max(mx, f32[i]) } // normalize const scale = 1.0 / (mx - mn) for (let i = 0; i < f32.length; i++) { f32[i] = 1.0 - (f32[i] - mn) * scale } return f32 } // readCURV() // read freesurfer Annotation file provides vertex colors // https://surfer.nmr.mgh.harvard.edu/fswiki/LabelsClutsAnnotationFiles static readANNOT(buffer: ArrayBuffer, n_vert: number, isReadColortables = false): ANNOT { const view = new DataView(buffer) // ArrayBuffer to dataview // ALWAYS big endian const n_vertex = view.getUint32(0, false) const n_vertexDecimated = this.decimateLayerVertices(n_vertex, n_vert) if (n_vert !== n_vertexDecimated) { throw new Error('ANNOT file has different number of vertices than mesh') } if (buffer.byteLength < 4 + 8 * n_vertex) { throw new Error('ANNOT file smaller than specified') } let pos = 0 // reading all floats with .slice() would be faster, but lets handle endian-ness const rgba32 = new Uint32Array(n_vertex) for (let i = 0; i < n_vertex; i++) { const idx = view.getUint32((pos += 4), false) rgba32[idx] = view.getUint32((pos += 4), false) } if (!isReadColortables) { // only read label colors, ignore labels return rgba32 } let tag = 0 try { tag = view.getInt32((pos += 4), false) } catch (error) { return rgba32 } const TAG_OLD_COLORTABLE = 1 if (tag !== TAG_OLD_COLORTABLE) { // undocumented old format return rgba32 } const ctabversion = view.getInt32((pos += 4), false) if (ctabversion > 0) { // undocumented old format return rgba32 } const maxstruc = view.getInt32((pos += 4), false) const len = view.getInt32((pos += 4), false) pos += len const num_entries = view.getInt32((pos += 4), false) if (num_entries < 1) { // undocumented old format return rgba32 } // preallocate lookuptable const LUT = { R: Array(maxstruc).fill(0), G: Array(maxstruc).fill(0), B: Array(maxstruc).fill(0), A: Array(maxstruc).fill(0), I: Array(maxstruc).fill(0), labels: Array(maxstruc).fill('') } for (let i = 0; i < num_entries; i++) { const struc = view.getInt32((pos += 4), false) const labelLen = view.getInt32((pos += 4), false) pos += 4 let txt = '' for (let c = 0; c < labelLen; c++) { const val = view.getUint8(pos++) if (val === 0) { break } txt += String.fromCharCode(val) } pos -= 4 const R = view.getInt32((pos += 4), false) const G = view.getInt32((pos += 4), false) const B = view.getInt32((pos += 4), false) const A = view.getInt32((pos += 4), false) if (struc < 0 || struc >= maxstruc) { log.warn('annot entry out of range') continue } LUT.R[struc] = R LUT.G[struc] = G LUT.B[struc] = B LUT.A[struc] = A LUT.I[struc] = (A << 24) + (B << 16) + (G << 8) + R LUT.labels[struc] = txt } const scalars = new Float32Array(n_vertex) scalars.fill(-1) let nError = 0 for (let i = 0; i < n_vert; i++) { const RGB = rgba32[i] for (let c = 0; c < maxstruc; c++) { if (LUT.I[c] === RGB) { scalars[i] = c break } } // for c if (scalars[i] < 0) { nError++ scalars[i] = 0 } } if (nError > 0) { log.error(`annot vertex colors do not match ${nError} of ${n_vertex} vertices.`) } for (let i = 0; i < maxstruc; i++) { LUT.I[i] = i } const colormapLabel = cmapper.makeLabelLut(LUT) return { scalars, colormapLabel } } // readANNOT() // read BrainNet viewer format // https://www.nitrc.org/projects/bnv/ static readNV(buffer: ArrayBuffer): DefaultMeshType { // n.b. clockwise triangle winding, indexed from 1 const len = buffer.byteLength const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let nvert = 0 // 173404 346804 let ntri = 0 let v = 0 let t = 0 let positions: Float32Array let indices: Uint32Array while (pos < len) { const line = readStr() if (line.startsWith('#')) { continue } const items = line.trim().split(/\s+/) if (nvert < 1) { nvert = parseInt(items[0]) positions = new Float32Array(nvert * 3) continue } if (v < nvert * 3) { positions![v] = parseFloat(items[0]) positions![v + 1] = parseFloat(items[1]) positions![v + 2] = parseFloat(items[2]) v += 3 continue } if (ntri < 1) { ntri = parseInt(items[0]) indices = new Uint32Array(ntri * 3) continue } if (t >= ntri * 3) { break } indices![t + 2] = parseInt(items[0]) - 1 indices![t + 1] = parseInt(items[1]) - 1 indices![t + 0] = parseInt(items[2]) - 1 t += 3 } return { positions: positions!, indices: indices! } } // readNV() // read ASCII Patch File format // https://afni.nimh.nih.gov/pub/dist/doc/htmldoc/demos/Bootcamp/CD.html#cd // http://www.grahamwideman.com/gw/brain/fs/surfacefileformats.htm static readASC(buffer: ArrayBuffer): DefaultMeshType { const len = buffer.byteLength const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: '#!ascii version of lh.pial' if (!line.startsWith('#!ascii')) { log.warn('Invalid ASC mesh') } line = readStr() // 1st line: signature let items = line.trim().split(/\s+/) const nvert = parseInt(items[0]) // 173404 346804 const ntri = parseInt(items[1]) const positions = new Float32Array(nvert * 3) let j = 0 for (let i = 0; i < nvert; i++) { line = readStr() // 1st line: signature items = line.trim().split(/\s+/) positions[j] = parseFloat(items[0]) positions[j + 1] = parseFloat(items[1]) positions[j + 2] = parseFloat(items[2]) j += 3 } const indices = new Uint32Array(ntri * 3) j = 0 for (let i = 0; i < ntri; i++) { line = readStr() // 1st line: signature items = line.trim().split(/\s+/) indices[j] = parseInt(items[0]) indices[j + 1] = parseInt(items[1]) indices[j + 2] = parseInt(items[2]) j += 3 } return { positions, indices } } // readASC() // read legacy VTK format static readVTK(buffer: ArrayBuffer): VTK { const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be VTK: bytes = ' + buffer.byteLength) } const bytes = new Uint8Array(buffer) let pos = 0 function readStr(isSkipBlank = true): string { if (isSkipBlank) { while (pos < len && bytes[pos] === 10) { pos++ } } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: signature if (!line.startsWith('# vtk DataFile')) { throw new Error('Invalid VTK mesh') } line = readStr(false) // 2nd line comment, n.b. MRtrix stores empty line line = readStr() // 3rd line ASCII/BINARY if (line.startsWith('ASCII')) { return NVMeshLoaders.readTxtVTK(buffer) } else if (!line.startsWith('BINARY')) { throw new Error('Invalid VTK image, expected ASCII or BINARY ' + line) } line = readStr() // 5th line "DATASET POLYDATA" if (!line.includes('POLYDATA')) { throw new Error('Only able to read VTK POLYDATA ' + line) } line = readStr() // 6th line "POINTS 10261 float" if (!line.includes('POINTS') || (!line.includes('double') && !line.includes('float'))) { log.warn('Only able to read VTK float or double POINTS' + line) } const isFloat64 = line.includes('double') let items = line.trim().split(/\s+/) const nvert = parseInt(items[1]) // POINTS 10261 float const nvert3 = nvert * 3 const positions = new Float32Array(nvert3) const reader = new DataView(buffer) if (isFloat64) { for (let i = 0; i < nvert3; i++) { positions[i] = reader.getFloat64(pos, false) pos += 8 } } else { for (let i = 0; i < nvert3; i++) { positions[i] = reader.getFloat32(pos, false) pos += 4 } } line = readStr() // Type, "LINES 11885 " items = line.trim().split(/\s+/) const tris = [] if (items[0].includes('LINES')) { const n_count = parseInt(items[1]) // tractogaphy data: detect if borked by DiPy const posOK = pos line = readStr() // borked files "OFFSETS vtktypeint64" if (line.startsWith('OFFSETS')) { let isInt64 = false if (line.includes('int64')) { isInt64 = true } const offsetPt0 = new Uint32Array(n_count) if (isInt64) { let isOverflowInt32 = false for (let c = 0; c < n_count; c++) { let idx = reader.getInt32(pos, false) if (idx !== 0) { isOverflowInt32 = true } pos += 4 idx = reader.getInt32(pos, false) pos += 4 offsetPt0[c] = idx } if (isOverflowInt32) { log.warn('int32 overflow: JavaScript does not support int64') } } else { for (let c = 0; c < n_count; c++) { const idx = reader.getInt32(pos, false) pos += 4 offsetPt0[c] = idx } } const pts = positions return { pts, offsetPt0 } } pos = posOK // valid VTK file let npt = 0 const offsetPt0 = [] const pts = [] offsetPt0.push(npt) // 1st streamline starts at 0 for (let c = 0; c < n_count; c++) { const numPoints = reader.getInt32(pos, false) pos += 4 npt += numPoints offsetPt0.push(npt) for (let i = 0; i < numPoints; i++) { const idx = reader.getInt32(pos, false) * 3 pos += 4 pts.push(positions[idx + 0]) p