* feat: Update to use pnpm (#6031) [simulated squash-merge] * feat: load DICOM SEG images via imageLoader * feat: load DICOM SEG images via imageLoader * PR review fixes * Test fragment of compressed multiframes * fix: Removed dependencies incorrectly * Update frame as a targetted change to avoid stripping remaining params * lock * Update test to agree with the missing representation branch * test(contour): contour color change coverage (#6042) * test(contour): contour interactions delete segment (#6069) --------- Co-authored-by: Bill Wallace <wayfarer3130@gmail.com> * chore(version): Update package versions to 3.13.0-beta.98 [skip ci] * feat(testing): OHIF Test Agent Skills (#5993) * chore(version): Update package versions to 3.13.0-beta.99 [skip ci] * test(contour): Add the ContourSegmentToggleLock.spec.ts test file to test contour locking (#6072) * chore(version): Update package versions to 3.13.0-beta.100 [skip ci] * chore(testing): Flock lock for playwright tests; Pin node version for OHIF; add more workers (#6099) * update Cypress apt deps for Ubuntu Noble (drop libgconf-2-4, libasound2→libasound2t64) * chore(version): Update package versions to 3.13.0-beta.101 [skip ci] * Debug fixes * perf(seg): pass explicit frame decode concurrency (16) to SEG loader Pass an explicit concurrency value (SEG_FRAME_DECODE_CONCURRENCY = 16) into createFromDicomSegImageId rather than relying on the adapter default, so the SEG frame fetch/decode parallelism is set at the call site and is ready to become configurable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(behaviours): add behaviours section + multiframe Part 10 prefetch proposal Start a "Behaviours" docs section for documenting how the system and UI behave end-to-end (observed behaviours, design proposals, and failure modes), with an index README and a Docusaurus category. First entry is the proposal for loading a multiframe SEG as a single Part 10 instance: prefetch the whole instance (gated by loadMultiframeAsPart10RaceTimeMs), parse it with dcmjs (handling multipart/related vs raw DICOM), and register the per-frame compressed pixels into the Cornerstone3D core image cache (the single uniform frame registry) so the per-frame load path is served locally while the standard decode path is unchanged. Best-effort: falls back to per-frame fetches on any failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pre-cache the image data using a full part10 file for performance * Add customizations to specify type of segmentation save * Add clearcache of the cacheData * Bump @cornerstonejs/* pins 5.1.3 -> 5.4.10 to match libs/@cornerstonejs base libs/@cornerstonejs (fix/use-imageLoader-for-seg) is based on the released cs3d 5.4.10 (merge-base with origin/main is the 5.4.10 version bump), so pin OHIF to that release. The local branch changes still reach the app via the cs3d:link symlinks; these pins keep the lockfile/npm fallback aligned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix seg OOM * fix: file meta garbage element, bogus NumberOfFrames, unguarded source-map-loader - dicomWriter: drop the naturalized meta.TransferSyntaxUID assignment that dcmjs wrote as a garbage (0000,0000) element into every saved file's meta group (download / clipboard / local wadouri blob / local store); keep the hex 00020010 assignment, which is required for string-form _meta fallbacks. - registerNaturalizedDatasetForLocalWadouri: keep the computed frame count local so single-frame IODs (SR, RTSTRUCT) no longer gain a bogus NumberOfFrames element in their serialized form. - rsbuild.config: resolve source-map-loader opportunistically (it is not a project dependency; the rule only serves the gitignored cs3d-link workflow) so fresh clones no longer crash at config load. - Regression tests for both writer fixes (mutation-checked). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * PR review comment fixes * Update versions --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: diattamo <mmddiatta@gmail.com> Co-authored-by: ohif-bot <danny.ri.brown+ohif-bot@gmail.com> Co-authored-by: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com>
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import dcmjs from 'dcmjs';
|
|
import { classes, Types, utils } from '@ohif/core';
|
|
import { cache, metaData } from '@cornerstonejs/core';
|
|
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
|
|
import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
|
|
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
|
|
|
|
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
|
|
import { getSegmentationSaveOptions } from './utils/segmentationConfig';
|
|
|
|
const getTargetViewport = ({ viewportId, viewportGridService }) => {
|
|
const { viewports, activeViewportId } = viewportGridService.getState();
|
|
const targetViewportId = viewportId || activeViewportId;
|
|
|
|
const viewport = viewports.get(targetViewportId);
|
|
|
|
return viewport;
|
|
};
|
|
|
|
const {
|
|
Cornerstone3D: {
|
|
Segmentation: { generateSegmentation },
|
|
},
|
|
} = adaptersSEG;
|
|
|
|
const {
|
|
Cornerstone3D: {
|
|
RTSS: { generateRTSSFromRepresentation },
|
|
},
|
|
} = adaptersRT;
|
|
|
|
|
|
const commandsModule = ({
|
|
servicesManager,
|
|
extensionManager,
|
|
commandsManager,
|
|
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
|
const { segmentationService, displaySetService, viewportGridService, customizationService } =
|
|
servicesManager.services as AppTypes.Services;
|
|
|
|
const actions = {
|
|
/**
|
|
* Loads segmentations for a specified viewport.
|
|
* The function prepares the viewport for rendering, then loads the segmentation details.
|
|
* Additionally, if the segmentation has scalar data, it is set for the corresponding label map volume.
|
|
*
|
|
* @param {Object} params - Parameters for the function.
|
|
* @param params.segmentations - Array of segmentations to be loaded.
|
|
* @param params.viewportId - the target viewport ID.
|
|
*
|
|
*/
|
|
loadSegmentationsForViewport: async ({ segmentations, viewportId }) => {
|
|
// Todo: handle adding more than one segmentation
|
|
const viewport = getTargetViewport({ viewportId, viewportGridService });
|
|
const displaySetInstanceUID = viewport.displaySetInstanceUIDs[0];
|
|
|
|
const segmentation = segmentations[0];
|
|
const segmentationId = segmentation.segmentationId;
|
|
const label = segmentation.config.label;
|
|
const segments = segmentation.config.segments;
|
|
|
|
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
|
|
|
await segmentationService.createLabelmapForDisplaySet(displaySet, {
|
|
segmentationId,
|
|
segments,
|
|
label,
|
|
});
|
|
|
|
segmentationService.addOrUpdateSegmentation(segmentation);
|
|
|
|
await segmentationService.addSegmentationRepresentation(viewport.viewportId, {
|
|
segmentationId,
|
|
});
|
|
|
|
return segmentationId;
|
|
},
|
|
/**
|
|
* Generates a segmentation from a given segmentation ID.
|
|
* This function retrieves the associated segmentation and
|
|
* its referenced volume, extracts label maps from the
|
|
* segmentation volume, and produces segmentation data
|
|
* alongside associated metadata.
|
|
*
|
|
* @param {Object} params - Parameters for the function.
|
|
* @param params.segmentationId - ID of the segmentation to be generated.
|
|
* @param params.options - Optional configuration for the generation process.
|
|
*
|
|
* @returns Returns the generated segmentation data.
|
|
*/
|
|
generateSegmentation: ({ segmentationId, options = {} }) => {
|
|
// `dataSource` (a data source name) is consumed here to resolve the store
|
|
// overrides; it must not be forwarded to the adapter's generateSegmentation.
|
|
const { dataSource: dataSourceName, ...generateOptions } = options;
|
|
const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId);
|
|
const predecessorImageId =
|
|
generateOptions.predecessorImageId ?? segmentation.predecessorImageId;
|
|
|
|
// A data source may override the app-wide `segmentation.store.*` defaults
|
|
// via `configuration.segmentation.store` (different back ends support
|
|
// different SEG encodings). Use the named target data source when storing,
|
|
// otherwise the active one (e.g. download).
|
|
const dataSourceDefinition = dataSourceName
|
|
? extensionManager.getDataSourceDefinition(dataSourceName)
|
|
: extensionManager.getActiveDataSourceDefinition();
|
|
const dataSourceStoreOverride = dataSourceDefinition?.configuration?.segmentation?.store;
|
|
|
|
const { imageIds } = segmentation.representationData.Labelmap;
|
|
|
|
const segImages = imageIds.map(imageId => cache.getImage(imageId));
|
|
const referencedImages = segImages.map((segImage, sliceIndex) => {
|
|
const referencedImage = cache.getImage(segImage.referencedImageId);
|
|
|
|
if (!referencedImage) {
|
|
throw new Error(
|
|
`Referenced source image not in cache for segmentation slice ${sliceIndex} ` +
|
|
`(referencedImageId: ${segImage.referencedImageId}). Ensure the referenced series is fully loaded before storing.`
|
|
);
|
|
}
|
|
|
|
return referencedImage;
|
|
});
|
|
|
|
const labelmaps2D = [];
|
|
|
|
let z = 0;
|
|
|
|
for (const segImage of segImages) {
|
|
const segmentsOnLabelmap = new Set();
|
|
const pixelData = segImage.getPixelData();
|
|
const { rows, columns } = segImage;
|
|
|
|
// Use a single pass through the pixel data
|
|
for (let i = 0; i < pixelData.length; i++) {
|
|
const segment = pixelData[i];
|
|
if (segment !== 0) {
|
|
segmentsOnLabelmap.add(segment);
|
|
}
|
|
}
|
|
|
|
labelmaps2D[z++] = {
|
|
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
|
|
pixelData,
|
|
rows,
|
|
columns,
|
|
};
|
|
}
|
|
|
|
const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap);
|
|
|
|
const labelmap3D = {
|
|
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
|
|
metadata: [],
|
|
labelmaps2D,
|
|
};
|
|
|
|
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
|
|
const representations = segmentationService.getRepresentationsForSegmentation(segmentationId);
|
|
|
|
Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => {
|
|
// segmentation service already has a color for each segment
|
|
if (!segment) {
|
|
return;
|
|
}
|
|
|
|
const { label } = segment;
|
|
|
|
const firstRepresentation = representations[0];
|
|
const color = segmentationService.getSegmentColor(
|
|
firstRepresentation.viewportId,
|
|
segmentationId,
|
|
segment.segmentIndex
|
|
);
|
|
|
|
const RecommendedDisplayCIELabValue = dcmjs.data.Colors.rgb2DICOMLAB(
|
|
color.slice(0, 3).map(value => value / 255)
|
|
).map(value => Math.round(value));
|
|
|
|
const segmentMetadata = {
|
|
SegmentNumber: segmentIndex.toString(),
|
|
SegmentLabel: label,
|
|
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
|
|
SegmentAlgorithmName: segment?.algorithmName || 'OHIF Brush',
|
|
RecommendedDisplayCIELabValue,
|
|
SegmentedPropertyCategoryCodeSequence: {
|
|
CodeValue: 'T-D0050',
|
|
CodingSchemeDesignator: 'SRT',
|
|
CodeMeaning: 'Tissue',
|
|
},
|
|
SegmentedPropertyTypeCodeSequence: {
|
|
CodeValue: 'T-D0050',
|
|
CodingSchemeDesignator: 'SRT',
|
|
CodeMeaning: 'Tissue',
|
|
},
|
|
};
|
|
labelmap3D.metadata[segmentIndex] = segmentMetadata;
|
|
});
|
|
|
|
const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
|
|
predecessorImageId,
|
|
...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride),
|
|
...generateOptions,
|
|
});
|
|
|
|
return generatedSegmentation;
|
|
},
|
|
/**
|
|
* Downloads a segmentation based on the provided segmentation ID.
|
|
* This function retrieves the associated segmentation and
|
|
* uses it to generate the corresponding DICOM dataset, which
|
|
* is then downloaded with an appropriate filename.
|
|
*
|
|
* @param {Object} params - Parameters for the function.
|
|
* @param params.segmentationId - ID of the segmentation to be downloaded.
|
|
*
|
|
*/
|
|
downloadSegmentation: ({ segmentationId }) => {
|
|
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
|
|
const generatedSegmentation = actions.generateSegmentation({
|
|
segmentationId,
|
|
});
|
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
|
dataSource: 'download',
|
|
defaultFileName: `${segmentationInOHIF.label}.dcm`,
|
|
});
|
|
storeFn(generatedSegmentation.dataset);
|
|
},
|
|
/**
|
|
* Stores a segmentation based on the provided segmentationId into a specified data source.
|
|
* The SeriesDescription is derived from user input or defaults to the segmentation label,
|
|
* and in its absence, defaults to 'Research Derived Series'.
|
|
*
|
|
* @param {Object} params - Parameters for the function.
|
|
* @param params.segmentationId - ID of the segmentation to be stored.
|
|
* @param params.dataSource - Data source where the generated segmentation will be stored.
|
|
*
|
|
* @returns {Object|void} Returns the naturalized report if successfully stored,
|
|
* otherwise throws an error.
|
|
*/
|
|
storeSegmentation: async ({ segmentationId, dataSource, modality = 'SEG' }) => {
|
|
const segmentation = segmentationService.getSegmentation(segmentationId);
|
|
|
|
if (!segmentation) {
|
|
throw new Error('No segmentation found');
|
|
}
|
|
|
|
const { label, predecessorImageId } = segmentation;
|
|
|
|
const {
|
|
value: reportName,
|
|
dataSourceName,
|
|
series,
|
|
priorSeriesNumber,
|
|
action,
|
|
} = await createReportDialogPrompt({
|
|
servicesManager,
|
|
extensionManager,
|
|
predecessorImageId,
|
|
title: 'Store Segmentation',
|
|
modality,
|
|
enableDownload: true,
|
|
});
|
|
|
|
if (action !== PROMPT_RESPONSES.CREATE_REPORT) {
|
|
return;
|
|
}
|
|
|
|
const defaultFileName =
|
|
modality === 'RTSTRUCT'
|
|
? `rtss-${segmentationId}.dcm`
|
|
: `${label || 'segmentation'}.dcm`;
|
|
|
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
|
dataSource: dataSourceName,
|
|
defaultFileName,
|
|
});
|
|
|
|
if (!storeFn) {
|
|
throw new Error(`No valid store for dataSource: ${dataSourceName}`);
|
|
}
|
|
|
|
try {
|
|
const args = {
|
|
segmentationId,
|
|
options: {
|
|
// Resolve store overrides against the data source we are storing into.
|
|
dataSource: dataSourceName,
|
|
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
|
|
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
|
|
predecessorImageId: series,
|
|
},
|
|
};
|
|
const generatedDataAsync =
|
|
(modality === 'SEG' && actions.generateSegmentation(args)) ||
|
|
(modality === 'RTSTRUCT' && actions.generateContour(args));
|
|
const generatedData = await generatedDataAsync;
|
|
|
|
if (!generatedData?.dataset) {
|
|
throw new Error('Error during segmentation generation');
|
|
}
|
|
|
|
const { dataset: naturalizedReport } = generatedData;
|
|
|
|
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
|
|
if (naturalizedReport.StudyID === 'No Study ID') {
|
|
naturalizedReport.StudyID = '';
|
|
}
|
|
|
|
await storeFn(naturalizedReport, {});
|
|
|
|
return naturalizedReport;
|
|
} catch (error) {
|
|
console.debug('Error storing segmentation:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
generateContour: async args => {
|
|
const { segmentationId, options } = args;
|
|
// `dataSource` is only used by the SEG store path; keep it out of the RTSS options.
|
|
const { dataSource: _dataSource, ...contourOptions } = options ?? {};
|
|
const segmentations = segmentationService.getSegmentation(segmentationId);
|
|
|
|
// inject colors to the segmentIndex
|
|
const firstRepresentation =
|
|
segmentationService.getRepresentationsForSegmentation(segmentationId)[0];
|
|
Object.entries(segmentations.segments).forEach(([segmentIndex, segment]) => {
|
|
segment.color = segmentationService.getSegmentColor(
|
|
firstRepresentation.viewportId,
|
|
segmentationId,
|
|
Number(segmentIndex)
|
|
);
|
|
});
|
|
const predecessorImageId =
|
|
contourOptions.predecessorImageId ?? segmentations.predecessorImageId;
|
|
const dataset = await generateRTSSFromRepresentation(segmentations, {
|
|
predecessorImageId,
|
|
...contourOptions,
|
|
});
|
|
return { dataset };
|
|
},
|
|
|
|
/**
|
|
* Downloads an RTSS instance from a segmentation or contour
|
|
* representation.
|
|
*/
|
|
downloadRTSS: async args => {
|
|
const { dataset } = await actions.generateContour(args);
|
|
const { InstanceNumber: instanceNumber = 1, SeriesInstanceUID: seriesUID } = dataset;
|
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
|
dataSource: 'download',
|
|
defaultFileName: `rtss-${seriesUID}-${instanceNumber}.dcm`,
|
|
});
|
|
await storeFn(dataset);
|
|
},
|
|
|
|
toggleActiveSegmentationUtility: ({ itemId: buttonId }) => {
|
|
const { uiState, setUIState } = useUIStateStore.getState();
|
|
const isButtonActive = uiState['activeSegmentationUtility'] === buttonId;
|
|
console.log('toggleActiveSegmentationUtility', isButtonActive, buttonId);
|
|
// if the button is active, clear the active segmentation utility
|
|
if (isButtonActive) {
|
|
setUIState('activeSegmentationUtility', null);
|
|
} else {
|
|
setUIState('activeSegmentationUtility', buttonId);
|
|
}
|
|
},
|
|
};
|
|
|
|
const definitions = {
|
|
loadSegmentationsForViewport: actions.loadSegmentationsForViewport,
|
|
generateSegmentation: actions.generateSegmentation,
|
|
downloadSegmentation: actions.downloadSegmentation,
|
|
storeSegmentation: actions.storeSegmentation,
|
|
downloadRTSS: actions.downloadRTSS,
|
|
toggleActiveSegmentationUtility: actions.toggleActiveSegmentationUtility,
|
|
};
|
|
|
|
return {
|
|
actions,
|
|
definitions,
|
|
defaultContext: 'SEGMENTATION',
|
|
};
|
|
};
|
|
|
|
export default commandsModule;
|