@niivue/niivue
Version:
minimal webgl2 nifti image viewer
253 lines (222 loc) • 7.85 kB
text/typescript
import { NiftiHeader } from '@/types'
/** Typed array types supported for voxel data */
export type TypedVoxelArray = Float32Array | Uint8Array | Int16Array | Float64Array | Uint16Array | Int32Array | Uint32Array
/**
* Options for creating a mask array
*/
export type MaskOptions = {
/** Total number of voxels */
nv: number
/** Array of mask volume images to apply */
maskImages?: TypedVoxelArray[]
/** Drawing bitmap to use as mask */
drawBitmap?: Uint8Array
/** Whether to use drawing as mask */
drawingIsMask?: boolean
/** Whether to use ROI as mask */
roiIsMask?: boolean
/** Starting voxel coordinates for ROI */
startVox?: number[]
/** Ending voxel coordinates for ROI */
endVox?: number[]
/** NIfTI header for dimension info */
hdr?: NiftiHeader
/** Pixel dimensions in RAS space */
pixDimsRAS?: number[]
}
/**
* Result from creating an elliptical ROI mask
*/
export type EllipseMaskResult = {
mask: Uint8Array
area: number
}
/**
* Result from computing descriptive statistics
*/
export type DescriptiveStats = {
mean: number
stdev: number
nvox: number
min: number
max: number
meanNot0: number
stdevNot0: number
nvoxNot0: number
minNot0: number
maxNot0: number
}
/**
* Creates an elliptical mask based on start and end voxel coordinates.
* The ellipse is drawn on a 2D plane where one dimension is constant.
*/
export function createEllipticalMask(nv: number, startVox: number[], endVox: number[], hdr: NiftiHeader, pixDimsRAS: number[]): EllipseMaskResult | null {
const mask = new Uint8Array(nv)
mask.fill(0)
// identify the constant dimension (the plane where the ellipse is drawn)
let constantDim = -1
if (startVox[0] === endVox[0]) {
constantDim = 0
} else if (startVox[1] === endVox[1]) {
constantDim = 1
} else if (startVox[2] === endVox[2]) {
constantDim = 2
} else {
console.error('Error: No constant dimension found.')
return null
}
// get the varying dimensions
const dims = [0, 1, 2]
const varDims = dims.filter((dim) => dim !== constantDim)
// compute the center of the ellipse in voxel coordinates
const centerVox: number[] = []
centerVox[constantDim] = startVox[constantDim]
centerVox[varDims[0]] = (startVox[varDims[0]] + endVox[varDims[0]]) / 2
centerVox[varDims[1]] = (startVox[varDims[1]] + endVox[varDims[1]]) / 2
// compute the radii along each varying dimension
const radiusX = Math.abs(endVox[varDims[0]] - startVox[varDims[0]]) / 2
const radiusY = Math.abs(endVox[varDims[1]] - startVox[varDims[1]]) / 2
// dimensions of the image
const xdim = hdr.dims[1]
const ydim = hdr.dims[2]
// define the ranges for the varying dimensions
const minVarDim0 = Math.max(0, Math.floor(centerVox[varDims[0]] - radiusX))
const maxVarDim0 = Math.min(hdr.dims[varDims[0] + 1] - 1, Math.ceil(centerVox[varDims[0]] + radiusX))
const minVarDim1 = Math.max(0, Math.floor(centerVox[varDims[1]] - radiusY))
const maxVarDim1 = Math.min(hdr.dims[varDims[1] + 1] - 1, Math.ceil(centerVox[varDims[1]] + radiusY))
// the constant dimension value
const constDimVal = centerVox[constantDim]
if (constDimVal < 0 || constDimVal >= hdr.dims[constantDim + 1]) {
console.error('Error: Constant dimension value is out of bounds.')
return null
}
// iterate over the varying dimensions and apply the elliptical mask
for (let i = minVarDim0; i <= maxVarDim0; i++) {
for (let j = minVarDim1; j <= maxVarDim1; j++) {
// set the voxel coordinates
const voxel: number[] = []
voxel[constantDim] = constDimVal
voxel[varDims[0]] = i
voxel[varDims[1]] = j
// calculate the normalized distances from the center
const di = (voxel[varDims[0]] - centerVox[varDims[0]]) / radiusX
const dj = (voxel[varDims[1]] - centerVox[varDims[1]]) / radiusY
// calculate the squared distance in ellipse space
const distSq = di * di + dj * dj
// check if the voxel is within the ellipse
if (distSq <= 1) {
const x = voxel[0]
const y = voxel[1]
const z = voxel[2]
const index = z * xdim * ydim + y * xdim + x
mask[index] = 1
}
}
}
// calculate the area using the ellipse area formula
const radiusX_mm = radiusX * pixDimsRAS[varDims[0] + 1]
const radiusY_mm = radiusY * pixDimsRAS[varDims[1] + 1]
const area = Math.PI * radiusX_mm * radiusY_mm
return { mask, area }
}
/**
* Creates a mask array based on the provided options.
* Returns null for area if not an ROI mask.
*/
export function createMask(options: MaskOptions): { mask: Uint8Array; area: number | null } {
const { nv, maskImages, drawBitmap, drawingIsMask, roiIsMask, startVox, endVox, hdr, pixDimsRAS } = options
const mask = new Uint8Array(nv)
mask.fill(1)
const area: number | null = null
if (maskImages && maskImages.length > 0) {
for (const imgMask of maskImages) {
if (imgMask.length !== nv) {
continue
}
for (let i = 0; i < nv; i++) {
if (imgMask[i] === 0 || isNaN(imgMask[i])) {
mask[i] = 0
}
}
}
} else if (drawingIsMask && drawBitmap) {
for (let i = 0; i < nv; i++) {
if (drawBitmap[i] === 0 || isNaN(drawBitmap[i])) {
mask[i] = 0
}
}
} else if (roiIsMask && startVox && endVox && hdr && pixDimsRAS) {
const result = createEllipticalMask(nv, startVox, endVox, hdr, pixDimsRAS)
if (!result) {
return { mask, area: null }
}
return { mask: result.mask, area: result.area }
}
return { mask, area }
}
/**
* Computes descriptive statistics for image data using Welford's online algorithm.
* This method is numerically stable for computing mean and standard deviation.
* @see https://www.johndcook.com/blog/2008/09/26/comparing-three-methods-of-computing-standard-deviation/
*/
export function computeDescriptiveStats(img: Float32Array, mask: Uint8Array): DescriptiveStats {
const nv = img.length
let k = 0
let M = 0
let S = 0
let mx = Number.NEGATIVE_INFINITY
let mn = Number.POSITIVE_INFINITY
let kNot0 = 0
let MNot0 = 0
let SNot0 = 0
for (let i = 0; i < nv; i++) {
if (mask[i] < 1) {
continue
}
const x = img[i]
k++
let Mnext = M + (x - M) / k
S = S + (x - M) * (x - Mnext)
M = Mnext
if (x === 0) {
continue
}
kNot0++
Mnext = MNot0 + (x - MNot0) / kNot0
SNot0 = SNot0 + (x - MNot0) * (x - Mnext)
MNot0 = Mnext
mn = Math.min(x, mn)
mx = Math.max(x, mx)
}
const stdev = Math.sqrt(S / (k - 1))
const stdevNot0 = Math.sqrt(SNot0 / (kNot0 - 1))
const mnNot0 = mn
const mxNot0 = mx
if (k !== kNot0) {
mn = Math.min(0, mn)
mx = Math.max(0, mx)
}
return {
mean: M,
stdev,
nvox: k,
min: mn,
max: mx,
meanNot0: MNot0,
stdevNot0,
nvoxNot0: kNot0,
minNot0: mnNot0,
maxNot0: mxNot0
}
}
/**
* Scales raw image data by slope and intercept values.
*/
export function scaleImageData(imgRaw: TypedVoxelArray, slope: number, inter: number): Float32Array {
const nv = imgRaw.length
const img = new Float32Array(nv)
for (let i = 0; i < nv; i++) {
img[i] = imgRaw[i] * slope + inter
}
return img
}