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