UNPKG

@niivue/niivue

Version:

minimal webgl2 nifti image viewer

1,255 lines (1,222 loc) 168 kB
import { mat4, vec4, vec3 } from 'gl-matrix' import { log } from '@/logger' import { NVUtilities, Zip } from '@/nvutilities' import { ColorMap, LUT, cmapper } from '@/colortables' import { NiivueObject3D } from '@/niivue-object3D' import { NVMesh, NVMeshLayer, NVMeshLayerDefaults } from '@/nvmesh' import { ANNOT, DefaultMeshType, GII, // Layer, MGH, MZ3, SmpMap, TCK, TRACT, TRK, TT, TRX, VTK, X3D, XmlTag, AnyNumberArray, ValuesArray } from '@/nvmesh-types' 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 /** * Assemble dpg from a map-of-groups into a ValuesArray ordered by groups[]. * Missing group data or tags are padded with NaN to maintain group alignment. * * @param dpgMap - map from groupId -> ValuesArray (entries for that group) * @param groups - ValuesArray describing groups; defines the result ordering * @returns ValuesArray - one entry per unique tag found across all groups * @throws Error if "groups" is empty or missing */ static assembleDpgFromMap(dpgMap: Record<string, ValuesArray>, groups: ValuesArray): ValuesArray { if (!Array.isArray(groups) || groups.length === 0) { throw new Error('assembleDpgFromMap: "groups" is empty or missing; cannot assemble dpg.') } // 1. Identify all unique tags across all existing groups in dpgMap const allTags = new Set<string>() for (const gid in dpgMap) { dpgMap[gid].forEach((entry) => allTags.add(entry.id)) } const result: ValuesArray = [] // 2. For each unique tag found in the dpgMap... for (const tag of allTags) { const perGroupArrays: Float32Array[] = [] let totalLen = 0 // 3. Iterate through every group defined in the TRX header for (let gi = 0; gi < groups.length; gi++) { const gid = String(groups[gi].id) const entries = dpgMap[gid] || [] // Handle missing group in map const entry = entries.find((e) => e.id === tag) if (entry) { // Use actual data if it exists const vals = entry.vals instanceof Float32Array ? entry.vals : Float32Array.from(entry.vals as Iterable<number>) perGroupArrays.push(vals) totalLen += vals.length } else { // If tag is missing for this group, fill with NaN (assuming scalar dpg) // Note: TRX dpg is typically 1 value per group. const fallback = new Float32Array([NaN]) perGroupArrays.push(fallback) totalLen += fallback.length } } // 4. Concatenate results for the tag const merged = new Float32Array(totalLen) let off = 0 for (const arr of perGroupArrays) { merged.set(arr, off) off += arr.length } result.push({ id: tag, vals: merged }) } return result } // 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 dpgMap: Record<string, ValuesArray> = {} const groups = [] 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 dname = parts.slice(-3)[0] // my.trx/dpg/ARC_L/z.float32 -> dpg 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')) { groups.push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } if (dname.includes('dpg')) { const key = String(pname) // be defensive; ensure key is a string if (!dpgMap[key]) { dpgMap[key] = [] } dpgMap[key].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') } let dpg = [] if (groups.length > 0 && dpgMap && Object.keys(dpgMap).length > 0) { dpg = this.assembleDpgFromMap(dpgMap, groups) } offsetPt0[noff] = npt / 3 // solve fence post problem, offset for final streamline return { pts, offsetPt0: new Uint32Array(offsetPt0), dpg, dps, dpv, groups, header } } // readTRX() // issue1426 MRtrix data per streamline as ASCII text static readTXT(buffer: ArrayBuffer, n_count = 0): Float32Array { // Decode ASCII (or UTF-8) bytes into a string const text = new TextDecoder('utf-8').decode(buffer) // Split into lines, handling any line endings: \r\n, \r, or \n const lines = text.split(/\r?\n|\r/).filter((l) => l.trim().length > 0) // If n_count not specified, use number of lines if (n_count <= 0) { n_count = lines.length } const vals = new Float32Array(n_count) // Parse each line into a float for (let i = 0; i < n_count && i < lines.length; i++) { const v = parseFloat(lines[i].trim()) vals[i] = Number.isFinite(v) ? v : 0.0 } return vals } // 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 > 3 || hdr_sz !== 1000 || magic !== 1128354388) { throw new Error(`Not a valid TRK file expected version ≤3 (${vers}), header size 1000 (${hdr_sz}) and magic of 1128354388 (${magic})`) } 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) { // for streamlines, we can only read .tsf and txt files // 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('.') } } if (ext === 'TXT') { const n_count = nvmesh.offsetPt0.length - 1 const vals = NVMeshLoaders.readTXT(buffer, n_count) if (vals.length !== n_count) { throw new Error(`TXT file has ${vals.length} items, expected one per streamline (${n_count}).`) } if (!nvmesh.dps) { nvmesh.dps = [] } const mn = vals.reduce((acc, current) => Math.min(acc, current)) const mx = vals.reduce((acc, current) => Math.max(acc, current)) nvmesh.dps.push({ id: tag, vals: Float32Array.from(vals.slice()), global_min: mn, global_max: mx, cal_min: mn, cal_max: mx }) return layer } if (ext === 'NII') { const vals = (await NVMeshLoaders.readNII(buffer, nvmesh.pts, '')) as Float32Array // const npt = nvmesh.pts.length / 3 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 }) // console.log(`>>> ${tag} got ${vals.length} expected ${nvmesh.pts.length / 3}`) return layer } if (ext !== 'TSF') { throw new Error('readLayer for streamlines only supports TSF and TXT files.') } const npt = nvmesh.pts.length / 3 // 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, nvmesh.pts, 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 sig