perf(segmentation): make segmentation read 3x faster (#3577)
This commit is contained in:
1 parent
ec76ea24dd
commit
156c1ba60b
17 files changed
+187
-315
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import vtkMath from '@kitware/vtk.js/Common/Core/Math';
|
||||
|
||||
import { utils } from '@ohif/core';
|
||||
import {
|
||||
metaData,
|
||||
cache,
|
||||
triggerEvent,
|
||||
eventTarget,
|
||||
} from '@cornerstonejs/core';
|
||||
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
|
||||
|
||||
import { SOPClassHandlerId } from './id';
|
||||
import dcmjs from 'dcmjs';
|
||||
|
||||
const { DicomMessage, DicomMetaDictionary } = dcmjs.data;
|
||||
import { dicomlabToRGB } from './utils/dicomlabToRGB';
|
||||
|
||||
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4'];
|
||||
|
||||
@@ -122,13 +125,12 @@ function _load(segDisplaySet, servicesManager, extensionManager, headers) {
|
||||
!segDisplaySet.segments ||
|
||||
Object.keys(segDisplaySet.segments).length === 0
|
||||
) {
|
||||
const segments = await _loadSegments(
|
||||
await _loadSegments({
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
segDisplaySet,
|
||||
headers
|
||||
);
|
||||
|
||||
segDisplaySet.segments = segments;
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const suppressEvents = true;
|
||||
@@ -147,28 +149,65 @@ function _load(segDisplaySet, servicesManager, extensionManager, headers) {
|
||||
return loadPromises[SOPInstanceUID];
|
||||
}
|
||||
|
||||
async function _loadSegments(extensionManager, segDisplaySet, headers) {
|
||||
async function _loadSegments({
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
segDisplaySet,
|
||||
headers,
|
||||
}) {
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
|
||||
const { segmentationService } = servicesManager.services;
|
||||
|
||||
const { dicomLoaderService } = utilityModule.exports;
|
||||
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
|
||||
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(
|
||||
segDisplaySet,
|
||||
null,
|
||||
headers
|
||||
);
|
||||
|
||||
const dicomData = DicomMessage.readFile(segArrayBuffer);
|
||||
const dataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict);
|
||||
dataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta);
|
||||
const cachedReferencedVolume = cache.getVolume(
|
||||
segDisplaySet.referencedVolumeId
|
||||
);
|
||||
|
||||
if (!Array.isArray(dataset.SegmentSequence)) {
|
||||
dataset.SegmentSequence = [dataset.SegmentSequence];
|
||||
if (!cachedReferencedVolume) {
|
||||
throw new Error(
|
||||
'Referenced Volume is missing for the SEG, and stack viewport SEG is not supported yet'
|
||||
);
|
||||
}
|
||||
|
||||
const segments = _getSegments(dataset);
|
||||
return segments;
|
||||
const { imageIds } = cachedReferencedVolume;
|
||||
|
||||
// Todo: what should be defaults here
|
||||
const tolerance = 0.001;
|
||||
const skipOverlapping = true;
|
||||
|
||||
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
|
||||
const { percentComplete } = evt.detail;
|
||||
segmentationService._broadcastEvent(
|
||||
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
|
||||
{
|
||||
percentComplete,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const results = await adaptersSEG.Cornerstone3D.Segmentation.generateToolState(
|
||||
imageIds,
|
||||
arrayBuffer,
|
||||
metaData,
|
||||
{ skipOverlapping, tolerance, eventTarget, triggerEvent }
|
||||
);
|
||||
|
||||
results.segMetadata.data.forEach((data, i) => {
|
||||
if (i > 0) {
|
||||
data.rgba = dicomlabToRGB(data.RecommendedDisplayCIELabValue);
|
||||
}
|
||||
});
|
||||
|
||||
Object.assign(segDisplaySet, results);
|
||||
}
|
||||
|
||||
function _segmentationExists(segDisplaySet, segmentationService) {
|
||||
@@ -178,114 +217,6 @@ function _segmentationExists(segDisplaySet, segmentationService) {
|
||||
);
|
||||
}
|
||||
|
||||
function _getPixelData(dataset, segments) {
|
||||
let frameSize = Math.ceil((dataset.Rows * dataset.Columns) / 8);
|
||||
let nextOffset = 0;
|
||||
|
||||
Object.keys(segments).forEach(segmentKey => {
|
||||
const segment = segments[segmentKey];
|
||||
segment.numberOfFrames = segment.functionalGroups.length;
|
||||
segment.size = segment.numberOfFrames * frameSize;
|
||||
segment.offset = nextOffset;
|
||||
nextOffset = segment.offset + segment.size;
|
||||
const packedSegment = dataset.PixelData[0].slice(
|
||||
segment.offset,
|
||||
nextOffset
|
||||
);
|
||||
|
||||
segment.pixelData = dcmjs.data.BitArray.unpack(packedSegment);
|
||||
segment.geometry = geometryFromFunctionalGroups(
|
||||
dataset,
|
||||
segment.functionalGroups
|
||||
);
|
||||
});
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function geometryFromFunctionalGroups(dataset, perFrame) {
|
||||
let pixelMeasures =
|
||||
dataset.SharedFunctionalGroupsSequence.PixelMeasuresSequence;
|
||||
let planeOrientation =
|
||||
dataset.SharedFunctionalGroupsSequence.PlaneOrientationSequence;
|
||||
let planePosition = perFrame[0].PlanePositionSequence; // TODO: assume sorted frames!
|
||||
|
||||
const geometry = {};
|
||||
|
||||
// NB: DICOM PixelSpacing is defined as Row then Column,
|
||||
// unlike ImageOrientationPatient
|
||||
let spacingBetweenSlices = pixelMeasures.SpacingBetweenSlices;
|
||||
if (!spacingBetweenSlices) {
|
||||
if (pixelMeasures.SliceThickness) {
|
||||
console.log('Using SliceThickness as SpacingBetweenSlices');
|
||||
spacingBetweenSlices = pixelMeasures.SliceThickness;
|
||||
}
|
||||
}
|
||||
geometry.spacing = [
|
||||
pixelMeasures.PixelSpacing[1],
|
||||
pixelMeasures.PixelSpacing[0],
|
||||
spacingBetweenSlices,
|
||||
].map(Number);
|
||||
|
||||
geometry.dimensions = [dataset.Columns, dataset.Rows, perFrame.length].map(
|
||||
Number
|
||||
);
|
||||
|
||||
let orientation = planeOrientation.ImageOrientationPatient.map(Number);
|
||||
const columnStepToPatient = orientation.slice(0, 3);
|
||||
const rowStepToPatient = orientation.slice(3, 6);
|
||||
geometry.planeNormal = [];
|
||||
vtkMath.cross(columnStepToPatient, rowStepToPatient, geometry.planeNormal);
|
||||
|
||||
let firstPosition = perFrame[0].PlanePositionSequence.ImagePositionPatient.map(
|
||||
Number
|
||||
);
|
||||
let lastPosition = perFrame[
|
||||
perFrame.length - 1
|
||||
].PlanePositionSequence.ImagePositionPatient.map(Number);
|
||||
geometry.sliceStep = [];
|
||||
vtkMath.subtract(lastPosition, firstPosition, geometry.sliceStep);
|
||||
vtkMath.normalize(geometry.sliceStep);
|
||||
geometry.direction = columnStepToPatient
|
||||
.concat(rowStepToPatient)
|
||||
.concat(geometry.sliceStep);
|
||||
geometry.origin = planePosition.ImagePositionPatient.map(Number);
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function _getSegments(dataset) {
|
||||
const segments = {};
|
||||
|
||||
dataset.SegmentSequence.forEach(segment => {
|
||||
const cielab = segment.RecommendedDisplayCIELabValue;
|
||||
const rgba = dcmjs.data.Colors.dicomlab2RGB(cielab).map(x =>
|
||||
Math.round(x * 255)
|
||||
);
|
||||
|
||||
rgba.push(255);
|
||||
const segmentNumber = segment.SegmentNumber;
|
||||
|
||||
segments[segmentNumber] = {
|
||||
color: rgba,
|
||||
functionalGroups: [],
|
||||
offset: null,
|
||||
size: null,
|
||||
pixelData: null,
|
||||
label: segment.SegmentLabel,
|
||||
};
|
||||
});
|
||||
|
||||
// make a list of functional groups per segment
|
||||
dataset.PerFrameFunctionalGroupsSequence.forEach(functionalGroup => {
|
||||
const segmentNumber =
|
||||
functionalGroup.SegmentIdentificationSequence.ReferencedSegmentNumber;
|
||||
segments[segmentNumber].functionalGroups.push(functionalGroup);
|
||||
});
|
||||
|
||||
return _getPixelData(dataset, segments);
|
||||
}
|
||||
|
||||
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
|
||||
const getDisplaySetsFromSeries = instances => {
|
||||
return _getDisplaySetsFromSeries(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
|
||||
/**
|
||||
* Converts a CIELAB color to an RGB color using the dcmjs library.
|
||||
* @param cielab - The CIELAB color to convert.
|
||||
* @returns The RGB color as an array of three integers between 0 and 255.
|
||||
*/
|
||||
function dicomlabToRGB(cielab: number[]): number[] {
|
||||
const rgb = dcmjs.data.Colors.dicomlab2RGB(cielab).map(x =>
|
||||
Math.round(x * 255)
|
||||
);
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
export { dicomlabToRGB };
|
||||
@@ -317,7 +317,6 @@ function OHIFCornerstoneSEGViewport(props) {
|
||||
|
||||
setIsHydrated(isHydrated);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewportActionBar
|
||||
@@ -344,10 +343,13 @@ function OHIFCornerstoneSEGViewport(props) {
|
||||
patientSex: PatientSex || '',
|
||||
patientAge: PatientAge || '',
|
||||
MRN: PatientID || '',
|
||||
thickness: SliceThickness ? `${parseFloat(SliceThickness).toFixed(2)}mm` : '',
|
||||
thickness: SliceThickness
|
||||
? utils.roundNumber(SliceThickness, 2)
|
||||
: '',
|
||||
thicknessUnits: SliceThickness !== undefined ? 'mm' : '',
|
||||
spacing:
|
||||
SpacingBetweenSlices !== undefined
|
||||
? `${parseFloat(SpacingBetweenSlices).toFixed(2)}mm`
|
||||
? utils.roundNumber(SpacingBetweenSlices, 2)
|
||||
: '',
|
||||
scanner: ManufacturerModelName || '',
|
||||
},
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/adapters": "^1.7.1",
|
||||
"@cornerstonejs/core": "^1.7.1",
|
||||
"@cornerstonejs/tools": "^1.7.1",
|
||||
"@cornerstonejs/adapters": "^1.9.3",
|
||||
"@cornerstonejs/core": "^1.9.3",
|
||||
"@cornerstonejs/tools": "^1.9.3",
|
||||
"classnames": "^2.3.2"
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "^1.2.2",
|
||||
"@cornerstonejs/codec-openjph": "^2.4.2",
|
||||
"@cornerstonejs/dicom-image-loader": "^1.7.1",
|
||||
"@cornerstonejs/dicom-image-loader": "^1.9.3",
|
||||
"@ohif/core": "3.7.0-beta.42",
|
||||
"@ohif/ui": "3.7.0-beta.42",
|
||||
"dcmjs": "^0.29.6",
|
||||
@@ -52,10 +52,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/adapters": "^1.7.1",
|
||||
"@cornerstonejs/core": "^1.7.1",
|
||||
"@cornerstonejs/streaming-image-volume-loader": "^1.7.1",
|
||||
"@cornerstonejs/tools": "^1.7.1",
|
||||
"@cornerstonejs/adapters": "^1.9.3",
|
||||
"@cornerstonejs/core": "^1.9.3",
|
||||
"@cornerstonejs/streaming-image-volume-loader": "^1.9.3",
|
||||
"@cornerstonejs/tools": "^1.9.3",
|
||||
"@kitware/vtk.js": "27.3.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lodash.debounce": "4.0.8",
|
||||
|
||||
@@ -563,11 +563,11 @@ class SegmentationService extends PubSubService {
|
||||
);
|
||||
}
|
||||
|
||||
const { segments, referencedVolumeId } = segDisplaySet;
|
||||
const { labelmapBufferArray, referencedVolumeId } = segDisplaySet;
|
||||
|
||||
if (!segments || !referencedVolumeId) {
|
||||
if (!labelmapBufferArray || !referencedVolumeId) {
|
||||
throw new Error(
|
||||
'To create the segmentation from SEG displaySet, the displaySet should be loaded first, you can perform segDisplaySet.load() before calling this method.'
|
||||
'No labelmapBufferArray or referencedVolumeId found for the SEG displaySet'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -593,88 +593,27 @@ class SegmentationService extends PubSubService {
|
||||
},
|
||||
}
|
||||
);
|
||||
const [rows, columns] = derivedVolume.dimensions;
|
||||
const derivedVolumeScalarData = derivedVolume.getScalarData();
|
||||
|
||||
const { imageIds } = referencedVolume;
|
||||
const sopUIDImageIdIndexMap = imageIds.reduce((acc, imageId, index) => {
|
||||
const { sopInstanceUid } = metaData.get('generalImageModule', imageId);
|
||||
acc[sopInstanceUid] = index;
|
||||
return acc;
|
||||
}, {} as { [sopUID: string]: number });
|
||||
const segmentsInfo = segDisplaySet.segMetadata.data;
|
||||
derivedVolumeScalarData.set(new Uint8Array(labelmapBufferArray[0]));
|
||||
|
||||
const numSegments = Object.keys(segments).length;
|
||||
// Note: ideally we could use the TypedArray set method, but since each
|
||||
// slice can have multiple segments, we need to loop over each slice and
|
||||
// set the segment value for each segment.
|
||||
let overlappingSegments = false;
|
||||
|
||||
const _segmentInfoUpdate = (segmentInfo, segmentIndex) => {
|
||||
const { pixelData: segPixelData } = segmentInfo;
|
||||
|
||||
let segmentX = 0;
|
||||
let segmentY = 0;
|
||||
let segmentZ = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const [
|
||||
functionalGroupIndex,
|
||||
functionalGroup,
|
||||
] of segmentInfo.functionalGroups.entries()) {
|
||||
const {
|
||||
ReferencedSOPInstanceUID,
|
||||
} = functionalGroup.DerivationImageSequence.SourceImageSequence;
|
||||
|
||||
const imageIdIndex = sopUIDImageIdIndexMap[ReferencedSOPInstanceUID];
|
||||
|
||||
if (imageIdIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = rows * columns;
|
||||
|
||||
// we need a faster way to get the pixel data for the current
|
||||
// functional group, which we use typed array view
|
||||
|
||||
const functionGroupPixelData = new Uint8Array(
|
||||
segPixelData.buffer,
|
||||
functionalGroupIndex * step,
|
||||
step
|
||||
);
|
||||
|
||||
const functionalGroupStartIndex = imageIdIndex * step;
|
||||
const functionalGroupEndIndex = (imageIdIndex + 1) * step;
|
||||
|
||||
// Note: this for loop is not optimized, since DICOM SEG stores
|
||||
// each segment as a separate labelmap so if there is a slice
|
||||
// that has multiple segments, we will have to loop over each
|
||||
// segment and we cannot use the TypedArray set method.
|
||||
for (
|
||||
let i = functionalGroupStartIndex, j = 0;
|
||||
i < functionalGroupEndIndex;
|
||||
i++, j++
|
||||
) {
|
||||
if (functionGroupPixelData[j] !== 0) {
|
||||
if (derivedVolumeScalarData[i] !== 0) {
|
||||
overlappingSegments = true;
|
||||
}
|
||||
|
||||
derivedVolumeScalarData[i] = segmentIndex;
|
||||
|
||||
// centroid calculations
|
||||
segmentX += i % columns;
|
||||
segmentY += Math.floor(i / columns) % rows;
|
||||
segmentZ += Math.floor(i / (columns * rows));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
segmentation.segments = segmentsInfo.map((segmentInfo, segmentIndex) => {
|
||||
if (segmentIndex === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// centroid calculations
|
||||
const x = Math.floor(segmentX / count);
|
||||
const y = Math.floor(segmentY / count);
|
||||
const z = Math.floor(segmentZ / count);
|
||||
const {
|
||||
SegmentedPropertyCategoryCodeSequence,
|
||||
SegmentNumber,
|
||||
SegmentLabel,
|
||||
SegmentAlgorithmType,
|
||||
SegmentAlgorithmName,
|
||||
SegmentedPropertyTypeCodeSequence,
|
||||
rgba,
|
||||
} = segmentInfo;
|
||||
|
||||
const { x, y, z } = segDisplaySet.centroids.get(segmentIndex);
|
||||
const centerWorld = derivedVolume.imageData.indexToWorld([x, y, z]);
|
||||
|
||||
segmentation.cachedStats = {
|
||||
@@ -691,65 +630,31 @@ class SegmentationService extends PubSubService {
|
||||
},
|
||||
};
|
||||
|
||||
const numInitialized = Object.keys(segmentation.cachedStats.segmentCenter)
|
||||
.length;
|
||||
|
||||
// Calculate percentage completed
|
||||
const percentComplete = Math.round((numInitialized / numSegments) * 100);
|
||||
|
||||
this._broadcastEvent(EVENTS.SEGMENT_LOADING_COMPLETE, {
|
||||
percentComplete,
|
||||
numSegments: numSegments,
|
||||
});
|
||||
};
|
||||
|
||||
const promiseArray = [];
|
||||
|
||||
for (const segmentIndex in segments) {
|
||||
const segmentInfo = segments[segmentIndex];
|
||||
|
||||
// Important: we need a non-blocking way to update the segmentation
|
||||
// state, otherwise the UI will freeze and the user will not be able
|
||||
// to interact with the app or progress bars will not be updated.
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
_segmentInfoUpdate(segmentInfo, segmentIndex);
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
promiseArray.push(promise);
|
||||
}
|
||||
|
||||
await Promise.all(promiseArray);
|
||||
|
||||
segmentation.segmentCount = Object.keys(segments).length;
|
||||
segmentation.segments = [null]; // segment 0
|
||||
|
||||
Object.keys(segments).forEach(segmentIndex => {
|
||||
const segmentInfo = segments[segmentIndex];
|
||||
const segIndex = Number(segmentIndex);
|
||||
|
||||
segmentation.segments[segIndex] = {
|
||||
label: segmentInfo.label || `Segment ${segIndex}`,
|
||||
segmentIndex: Number(segmentIndex),
|
||||
color: [
|
||||
segmentInfo.color[0],
|
||||
segmentInfo.color[1],
|
||||
segmentInfo.color[2],
|
||||
],
|
||||
opacity: segmentInfo.color[3],
|
||||
return {
|
||||
label: SegmentLabel || `Segment ${SegmentNumber}`,
|
||||
segmentIndex: Number(SegmentNumber),
|
||||
category: SegmentedPropertyCategoryCodeSequence
|
||||
? SegmentedPropertyCategoryCodeSequence.CodeMeaning
|
||||
: '',
|
||||
type: SegmentedPropertyTypeCodeSequence
|
||||
? SegmentedPropertyTypeCodeSequence.CodeMeaning
|
||||
: '',
|
||||
algorithmType: SegmentAlgorithmType,
|
||||
algorithmName: SegmentAlgorithmName,
|
||||
color: rgba,
|
||||
opacity: 255,
|
||||
isVisible: true,
|
||||
isLocked: false,
|
||||
};
|
||||
});
|
||||
|
||||
segmentation.segmentCount = segmentsInfo.length - 1;
|
||||
|
||||
segDisplaySet.isLoaded = true;
|
||||
|
||||
this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, {
|
||||
segmentationId,
|
||||
segDisplaySet,
|
||||
overlappingSegments,
|
||||
});
|
||||
|
||||
return this.addOrUpdateSegmentation(segmentation, suppressEvents);
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
"start": "yarn run dev"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cornerstonejs/core": "^1.7.1",
|
||||
"@cornerstonejs/tools": "^1.7.1",
|
||||
"@cornerstonejs/core": "^1.9.3",
|
||||
"@cornerstonejs/tools": "^1.9.3",
|
||||
"@ohif/core": "3.7.0-beta.42",
|
||||
"@ohif/extension-cornerstone-dicom-sr": "3.7.0-beta.42",
|
||||
"@ohif/ui": "3.7.0-beta.42",
|
||||
|
||||
Reference in new issue
Block a user