@niivue/niivue
Version:
minimal webgl2 nifti image viewer
453 lines (416 loc) • 15 kB
text/typescript
import { vec3 } from 'gl-matrix'
import { NVMesh, MeshType } from './nvmesh.js'
import { NVUtilities } from './nvutilities.js'
import { NiivueObject3D } from './niivue-object3D.js'
import { NVMeshUtilities } from './nvmesh-utilities.js'
import { cmapper } from './colortables.js'
import { NVLabel3D, LabelTextAlignment, LabelLineTerminator } from './nvlabel.js'
import { Connectome, ConnectomeOptions, LegacyConnectome, NVConnectomeEdge, NVConnectomeNode } from './types.js'
import { log } from './logger.js'
const defaultOptions: ConnectomeOptions = {
name: 'untitled connectome',
nodeColormap: 'warm',
nodeColormapNegative: 'winter',
nodeMinColor: 0,
nodeMaxColor: 4,
nodeScale: 3,
edgeColormap: 'warm',
edgeColormapNegative: 'winter',
edgeMin: 2,
edgeMax: 6,
edgeScale: 1,
legendLineThickness: 0
}
export type FreeSurferConnectome = {
data_type: string
points: Array<{
comments?: Array<{
text: string
}>
coordinates: {
x: number
y: number
z: number
}
}>
}
/**
* Represents a connectome
*/
export class NVConnectome extends NVMesh {
gl: WebGL2RenderingContext
nodesChanged: EventTarget
constructor(gl: WebGL2RenderingContext, connectome: LegacyConnectome) {
super(new Float32Array([]), new Uint32Array([]), connectome.name, new Uint8Array([]), 1.0, true, gl, connectome)
this.gl = gl
// this.nodes = connectome.nodes;
// this.edges = connectome.edges;
// this.options = { ...defaultOptions, ...connectome };
this.type = MeshType.CONNECTOME
if (this.nodes) {
this.updateLabels()
}
this.nodesChanged = new EventTarget()
}
static convertLegacyConnectome(json: LegacyConnectome): Connectome {
const connectome: Connectome = { nodes: [], edges: [], ...defaultOptions }
for (const prop in json) {
if (prop in defaultOptions) {
const key = prop as keyof ConnectomeOptions
// @ts-expect-error -- this will work, as both extend ConnectomeOptions
connectome[key] = json[key]
}
}
const nodes = json.nodes
for (let i = 0; i < nodes.names.length; i++) {
connectome.nodes.push({
name: nodes.names[i],
x: nodes.X[i],
y: nodes.Y[i],
z: nodes.Z[i],
colorValue: nodes.Color[i],
sizeValue: nodes.Size[i]
})
}
for (let i = 0; i < nodes.names.length - 1; i++) {
for (let j = i + 1; j < nodes.names.length; j++) {
const colorValue = json.edges[i * nodes.names.length + j]
connectome.edges.push({
first: i,
second: j,
colorValue
})
}
}
return connectome
}
static convertFreeSurferConnectome(json: FreeSurferConnectome, colormap = 'warm'): Connectome {
let isValid = true
if (!('data_type' in json)) {
isValid = false
} else if (json.data_type !== 'fs_pointset') {
isValid = false
}
if (!('points' in json)) {
isValid = false
}
if (!isValid) {
throw Error('not a valid FreeSurfer json pointset')
}
const nodes = json.points.map((p) => ({
name: Array.isArray(p.comments) && p.comments.length > 0 && 'text' in p.comments[0] ? p.comments[0].text : '',
x: p.coordinates.x,
y: p.coordinates.y,
z: p.coordinates.z,
colorValue: 1,
sizeValue: 1,
metadata: p.comments
}))
const connectome = {
...defaultOptions,
nodeColormap: colormap,
edgeColormap: colormap,
nodes,
edges: []
}
return connectome
}
updateLabels(): void {
const nodes = this.nodes as NVConnectomeNode[]
if (nodes && nodes.length > 0) {
// largest node
const largest = (nodes as NVConnectomeNode[]).reduce((a, b) => (a.sizeValue > b.sizeValue ? a : b)).sizeValue
let min, max
// Determine the minimum color value
if (typeof this.nodeMinColor !== 'undefined' && isFinite(this.nodeMinColor)) {
min = this.nodeMinColor
} else {
min = nodes[0].colorValue // Initialize min to the first node's colorValue
for (let i = 1; i < nodes.length; i++) {
if (nodes[i].colorValue < min) {
min = nodes[i].colorValue
}
}
}
// Determine the maximum color value
if (typeof this.nodeMaxColor !== 'undefined' && isFinite(this.nodeMaxColor)) {
max = this.nodeMaxColor
} else {
max = nodes[0].colorValue // Initialize max to the first node's colorValue
for (let i = 1; i < nodes.length; i++) {
if (nodes[i].colorValue > max) {
max = nodes[i].colorValue
}
}
}
const lut = cmapper.colormap(this.nodeColormap, this.colormapInvert)
const lutNeg = cmapper.colormap(this.nodeColormapNegative, this.colormapInvert)
const hasNeg = 'nodeColormapNegative' in this
const legendLineThickness = this.legendLineThickness ? this.legendLineThickness : 0.0
for (let i = 0; i < nodes.length; i++) {
let color = nodes[i].colorValue
let isNeg = false
if (hasNeg && color < 0) {
isNeg = true
color = -color
}
if (min < max) {
if (color < min) {
log.warn('color value lower than min')
continue
}
color = (color - min) / (max - min)
} else {
color = 1.0
}
color = Math.round(Math.max(Math.min(255, color * 255))) * 4
let rgba = [lut[color], lut[color + 1], lut[color + 2], 255]
if (isNeg) {
rgba = [lutNeg[color], lutNeg[color + 1], lutNeg[color + 2], 255]
}
rgba = rgba.map((c) => c / 255)
log.debug('adding label for ', nodes[i])
nodes[i].label = new NVLabel3D(
nodes[i].name,
{
textColor: rgba,
bulletScale: nodes[i].sizeValue / largest,
bulletColor: rgba,
lineWidth: legendLineThickness,
lineColor: rgba,
textScale: 1.0,
textAlignment: LabelTextAlignment.LEFT,
lineTerminator: LabelLineTerminator.NONE
},
[nodes[i].x, nodes[i].y, nodes[i].z]
)
log.debug('label for node:', nodes[i].label)
}
}
}
addConnectomeNode(node: NVConnectomeNode): void {
log.debug('adding node', node)
if (!this.nodes) {
throw new Error('nodes not defined')
}
;(this.nodes as NVConnectomeNode[]).push(node)
this.updateLabels()
this.nodesChanged.dispatchEvent(new CustomEvent('nodeAdded', { detail: { node } }))
}
deleteConnectomeNode(node: NVConnectomeNode): void {
// delete any connected edges
const index = (this.nodes as NVConnectomeNode[]).indexOf(node)
const edges = this.edges as NVConnectomeEdge[]
if (edges) {
this.edges = edges.filter((e) => e.first !== index && e.second !== index)
}
this.nodes = (this.nodes as NVConnectomeNode[]).filter((n) => n !== node)
this.updateLabels()
this.updateConnectome(this.gl)
this.nodesChanged.dispatchEvent(new CustomEvent('nodeDeleted', { detail: { node } }))
}
updateConnectomeNodeByIndex(index: number, updatedNode: NVConnectomeNode): void {
;(this.nodes as NVConnectomeNode[])[index] = updatedNode
this.updateLabels()
this.updateConnectome(this.gl)
this.nodesChanged.dispatchEvent(new CustomEvent('nodeChanged', { detail: { node: updatedNode } }))
}
updateConnectomeNodeByPoint(point: [number, number, number], updatedNode: NVConnectomeNode): void {
// TODO this was updating nodes in this.connectome.nodes
const nodes = this.nodes as NVConnectomeNode[]
if (!nodes) {
throw new Error('Node to update does not exist')
}
const node = nodes.find((node) => NVUtilities.arraysAreEqual([node.x, node.y, node.z], point))
if (!node) {
throw new Error(`Node with point ${point} to update does not exist`)
}
const index = nodes.findIndex((n) => n === node)
this.updateConnectomeNodeByIndex(index, updatedNode)
}
addConnectomeEdge(first: number, second: number, colorValue: number): NVConnectomeEdge {
const edges = this.edges as NVConnectomeEdge[]
let edge = edges.find((f) => (f.first === first || f.second === first) && f.first + f.second === first + second)
if (edge) {
return edge
}
edge = { first, second, colorValue }
edges.push(edge)
this.updateConnectome(this.gl)
return edge
}
deleteConnectomeEdge(first: number, second: number): NVConnectomeEdge {
const edges = this.edges as NVConnectomeEdge[]
const edge = edges.find((f) => (f.first === first || f.first === second) && f.first + f.second === first + second)
if (edge) {
this.edges = edges.filter((e) => e !== edge)
} else {
throw new Error(`edge between ${first} and ${second} not found`)
}
this.updateConnectome(this.gl)
return edge
}
findClosestConnectomeNode(point: number[], distance: number): NVConnectomeNode | null {
const nodes = this.nodes as NVConnectomeNode[]
if (!nodes || nodes.length === 0) {
return null
}
const closeNodes = nodes
.map((n, i) => ({
node: n,
distance: Math.sqrt(Math.pow(n.x - point[0], 2) + Math.pow(n.y - point[1], 2) + Math.pow(n.z - point[2], 2)),
index: i
}))
.filter((n) => n.distance < distance)
.sort((a, b) => a.distance - b.distance)
if (closeNodes.length > 0) {
return closeNodes[0].node
} else {
return null
}
}
updateConnectome(gl: WebGL2RenderingContext): void {
const tris: number[] = []
const pts: number[] = []
const rgba255: number[] = []
let lut = cmapper.colormap(this.nodeColormap, this.colormapInvert)
let lutNeg = cmapper.colormap(this.nodeColormapNegative, this.colormapInvert)
let hasNeg = 'nodeColormapNegative' in this
// issue1080 we can have nodes without edges, so edgeMin/Max need not be defined
if (this.nodeMinColor === undefined) {
this.nodeMinColor = NaN
}
if (this.nodeMaxColor === undefined) {
this.nodeMaxColor = NaN
}
// issue1080 we can have nodes without edges, so edgeMin/Max need not be defined
if (this.edgeMin === undefined) {
this.edgeMin = NaN
}
if (this.edgeMax === undefined) {
this.edgeMax = NaN
}
let min = this.nodeMinColor
let max = this.nodeMaxColor
if (!isFinite(min) || !isFinite(min)) {
const nodes = this.nodes as NVConnectomeNode[]
min = nodes[0].colorValue
max = nodes[0].colorValue
for (let i = 0; i < nodes.length; i++) {
min = Math.min(min, nodes[i].colorValue)
max = Math.max(max, nodes[i].colorValue)
}
}
// TODO these statements can be removed once the node types are cleaned up
const nodes = this.nodes as NVConnectomeNode[]
const nNode = nodes.length
for (let i = 0; i < nNode; i++) {
const radius = nodes[i].sizeValue * this.nodeScale
if (radius <= 0.0) {
continue
}
let color = nodes[i].colorValue
let isNeg = false
if (hasNeg && color < 0) {
isNeg = true
color = -color
}
if (min < max) {
if (color < min) {
continue
}
color = (color - min) / (max - min)
} else {
color = 1.0
}
color = Math.round(Math.max(Math.min(255, color * 255))) * 4
let rgba = [lut[color], lut[color + 1], lut[color + 2], 255]
if (isNeg) {
rgba = [lutNeg[color], lutNeg[color + 1], lutNeg[color + 2], 255]
}
const pt = vec3.fromValues(nodes[i].x, nodes[i].y, nodes[i].z)
NiivueObject3D.makeColoredSphere(pts, tris, rgba255, radius, pt, rgba)
}
lut = cmapper.colormap(this.edgeColormap, this.colormapInvert)
lutNeg = cmapper.colormap(this.edgeColormapNegative, this.colormapInvert)
hasNeg = 'edgeColormapNegative' in this
// TODO fix edge types
const edges = this.edges as NVConnectomeEdge[]
if (edges !== undefined && edges.length > 0) {
min = this.edgeMin
max = this.edgeMax
// issue 1080: autodetect range
if (!isFinite(min) || !isFinite(min)) {
min = edges[0].colorValue
max = edges[0].colorValue
for (let i = 0; i < edges.length; i++) {
min = Math.min(min, edges[i].colorValue)
max = Math.max(max, edges[i].colorValue)
}
}
for (const edge of edges) {
let color = edge.colorValue
const isNeg = hasNeg && color < 0
if (isNeg) {
color = -color
}
const radius = color * this.edgeScale
if (radius <= 0) {
continue
}
if (min < max) {
if (color < min) {
continue
}
color = (color - min) / (max - min)
} else {
color = 1.0
}
color = Math.round(Math.max(Math.min(255, color * 255))) * 4
let rgba = [lut[color], lut[color + 1], lut[color + 2], 255]
if (isNeg) {
rgba = [lutNeg[color], lutNeg[color + 1], lutNeg[color + 2], 255]
}
const pti = vec3.fromValues(nodes[edge.first].x, nodes[edge.first].y, nodes[edge.first].z)
const ptj = vec3.fromValues(nodes[edge.second].x, nodes[edge.second].y, nodes[edge.second].z)
NiivueObject3D.makeColoredCylinder(pts, tris, rgba255, pti, ptj, radius, rgba)
}
}
const pts32 = new Float32Array(pts)
const tris32 = new Uint32Array(tris)
// calculate spatial extent of connectome: user adjusting node sizes may influence size
const obj = NVMeshUtilities.getExtents(pts32)
this.furthestVertexFromOrigin = obj.mxDx
this.extentsMin = obj.extentsMin
this.extentsMax = obj.extentsMax
const posNormClr = this.generatePosNormClr(pts32, tris32, new Uint8Array(rgba255))
// generate webGL buffers and vao
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Uint32Array.from(tris32), gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, Float32Array.from(posNormClr), gl.STATIC_DRAW)
this.indexCount = tris.length
}
updateMesh(gl: WebGL2RenderingContext): void {
this.updateConnectome(gl)
this.updateLabels()
}
json(): Connectome {
const json: Partial<Connectome> = {}
for (const prop in this) {
if (prop in defaultOptions || prop === 'nodes' || prop === 'edges') {
// @ts-expect-error this is not very ethical; returning every field explicitly would probably be better
json[prop as keyof Connectome] = this[prop]
}
}
return json as Connectome
}
/**
* Factory method to create connectome from options
*/
static async loadConnectomeFromUrl(gl: WebGL2RenderingContext, url: string): Promise<NVConnectome> {
const response = await fetch(url)
const json = await response.json()
return new NVConnectome(gl, json)
}
}