210 lines
6.1 KiB
JavaScript
210 lines
6.1 KiB
JavaScript
import { utilities } from '@cornerstonejs/core';
|
|
import { utilities as cstUtils } from '@cornerstonejs/tools';
|
|
import { vec3 } from 'gl-matrix';
|
|
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
|
|
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
|
|
import { expose } from 'comlink';
|
|
|
|
const createVolume = ({ dimensions, origin, direction, spacing, metadata, scalarData }) => {
|
|
const imageData = vtkImageData.newInstance();
|
|
imageData.setDimensions(dimensions);
|
|
imageData.setOrigin(origin);
|
|
imageData.setDirection(direction);
|
|
imageData.setSpacing(spacing);
|
|
|
|
const scalarArray = vtkDataArray.newInstance({
|
|
name: 'Pixels',
|
|
numberOfComponents: 1,
|
|
values: scalarData,
|
|
});
|
|
|
|
imageData.getPointData().setScalars(scalarArray);
|
|
|
|
imageData.modified();
|
|
|
|
const voxelManager = utilities.VoxelManager.createScalarVolumeVoxelManager({
|
|
scalarData,
|
|
dimensions,
|
|
numberOfComponents: 1,
|
|
});
|
|
return {
|
|
imageData,
|
|
spacing,
|
|
origin,
|
|
direction,
|
|
metadata,
|
|
voxelManager,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* This method calculates the SUV peak on a segmented ROI from a reference PET
|
|
* volume. If a rectangle annotation is provided, the peak is calculated within that
|
|
* rectangle. Otherwise, the calculation is performed on the entire volume which
|
|
* will be slower but same result.
|
|
* @param viewport Viewport to use for the calculation
|
|
* @param labelmap Labelmap from which the mask is taken
|
|
* @param referenceVolume PET volume to use for SUV calculation
|
|
* @param toolData [Optional] list of toolData to use for SUV calculation
|
|
* @param segmentIndex The index of the segment to use for masking
|
|
* @returns
|
|
*/
|
|
function calculateSuvPeak({ labelmapProps, referenceVolumeProps, annotations, segmentIndex = 1 }) {
|
|
const labelmapInfo = createVolume(labelmapProps);
|
|
const referenceInfo = createVolume(referenceVolumeProps);
|
|
|
|
if (referenceInfo.metadata.Modality !== 'PT') {
|
|
return;
|
|
}
|
|
|
|
const { dimensions, imageData: labelmapImageData } = labelmapInfo;
|
|
const { imageData: referenceVolumeImageData } = referenceInfo;
|
|
|
|
let boundsIJK;
|
|
// Todo: using the first annotation for now
|
|
if (annotations?.length && annotations[0].data?.cachedStats) {
|
|
const { projectionPoints } = annotations[0].data.cachedStats;
|
|
const pointsToUse = [].concat(...projectionPoints); // cannot use flat() because of typescript compiler right now
|
|
|
|
const rectangleCornersIJK = pointsToUse.map(world => {
|
|
const ijk = vec3.fromValues(0, 0, 0);
|
|
referenceVolumeImageData.worldToIndex(world, ijk);
|
|
return ijk;
|
|
});
|
|
|
|
boundsIJK = cstUtils.boundingBox.getBoundingBoxAroundShape(rectangleCornersIJK, dimensions);
|
|
}
|
|
|
|
let max = 0;
|
|
let maxIJK = [0, 0, 0];
|
|
let maxLPS = [0, 0, 0];
|
|
|
|
const callback = ({ pointIJK, pointLPS }) => {
|
|
const value = labelmapInfo.voxelManager.getAtIJKPoint(pointIJK);
|
|
|
|
if (value !== segmentIndex) {
|
|
return;
|
|
}
|
|
|
|
const referenceValue = referenceInfo.voxelManager.getAtIJKPoint(pointIJK);
|
|
|
|
if (referenceValue > max) {
|
|
max = referenceValue;
|
|
maxIJK = pointIJK;
|
|
maxLPS = pointLPS;
|
|
}
|
|
};
|
|
|
|
labelmapInfo.voxelManager.forEach(callback, {
|
|
boundsIJK,
|
|
imageData: labelmapImageData,
|
|
isInObject: () => true,
|
|
returnPoints: true,
|
|
});
|
|
|
|
const direction = labelmapImageData.getDirection().slice(0, 3);
|
|
|
|
/**
|
|
* 2. Find the bottom and top of the great circle for the second sphere (1cc sphere)
|
|
* V = (4/3)πr3
|
|
*/
|
|
const radius = Math.pow(1 / ((4 / 3) * Math.PI), 1 / 3) * 10;
|
|
const diameter = radius * 2;
|
|
|
|
const secondaryCircleWorld = vec3.create();
|
|
const bottomWorld = vec3.create();
|
|
const topWorld = vec3.create();
|
|
referenceVolumeImageData.indexToWorld(maxIJK, secondaryCircleWorld);
|
|
vec3.scaleAndAdd(bottomWorld, secondaryCircleWorld, direction, -diameter / 2);
|
|
vec3.scaleAndAdd(topWorld, secondaryCircleWorld, direction, diameter / 2);
|
|
const suvPeakCirclePoints = [bottomWorld, topWorld];
|
|
|
|
/**
|
|
* 3. Find the Mean and Max of the 1cc sphere centered on the suv Max of the previous
|
|
* sphere
|
|
*/
|
|
let count = 0;
|
|
let acc = 0;
|
|
const suvPeakMeanCallback = ({ value }) => {
|
|
acc += value;
|
|
count += 1;
|
|
};
|
|
|
|
cstUtils.pointInSurroundingSphereCallback(
|
|
referenceVolumeImageData,
|
|
suvPeakCirclePoints,
|
|
suvPeakMeanCallback
|
|
);
|
|
|
|
const mean = acc / count;
|
|
|
|
return {
|
|
max,
|
|
maxIJK,
|
|
maxLPS,
|
|
mean,
|
|
};
|
|
}
|
|
|
|
function calculateTMTV(labelmapProps, segmentIndex = 1) {
|
|
const labelmaps = labelmapProps.map(props => createVolume(props));
|
|
|
|
const mergedLabelmap =
|
|
labelmaps.length === 1
|
|
? labelmaps[0]
|
|
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
|
|
|
|
const { imageData, spacing } = mergedLabelmap;
|
|
const values = imageData.getPointData().getScalars().getData();
|
|
|
|
// count non-zero values inside the outputData, this would
|
|
// consider the overlapping regions to be only counted once
|
|
const numVoxels = values.reduce((acc, curr) => {
|
|
if (curr > 0) {
|
|
return acc + 1;
|
|
}
|
|
return acc;
|
|
}, 0);
|
|
|
|
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
|
|
}
|
|
|
|
function getTotalLesionGlycolysis({ labelmapProps, referenceVolumeProps }) {
|
|
const labelmaps = labelmapProps.map(props => createVolume(props));
|
|
|
|
const mergedLabelmap =
|
|
labelmaps.length === 1
|
|
? labelmaps[0]
|
|
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
|
|
|
|
// grabbing the first labelmap referenceVolume since it will be the same for all
|
|
const { spacing } = labelmaps[0];
|
|
|
|
const ptVolume = createVolume(referenceVolumeProps);
|
|
|
|
let suv = 0;
|
|
let totalLesionVoxelCount = 0;
|
|
const scalarDataLength = mergedLabelmap.voxelManager.getScalarDataLength();
|
|
for (let i = 0; i < scalarDataLength; i++) {
|
|
// if not background
|
|
if (mergedLabelmap.voxelManager.getAtIndex(i) !== 0) {
|
|
suv += ptVolume.voxelManager.getAtIndex(i);
|
|
totalLesionVoxelCount += 1;
|
|
}
|
|
}
|
|
|
|
// Average SUV for the merged labelmap
|
|
const averageSuv = suv / totalLesionVoxelCount;
|
|
|
|
// total Lesion Glycolysis [suv * ml]
|
|
return averageSuv * totalLesionVoxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3;
|
|
}
|
|
|
|
const obj = {
|
|
calculateSuvPeak,
|
|
calculateTMTV,
|
|
getTotalLesionGlycolysis,
|
|
};
|
|
|
|
expose(obj);
|