feat: Add support for labelmap seg images in any supported tsuid (also for compressed bitmap) (#5806)
* 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>
This commit is contained in:
59 files changed
+2501
-322
No files matched your search
@@ -6,6 +6,7 @@ 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();
|
||||
@@ -34,7 +35,7 @@ const commandsModule = ({
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
||||
const { segmentationService, displaySetService, viewportGridService } =
|
||||
const { segmentationService, displaySetService, viewportGridService, customizationService } =
|
||||
servicesManager.services as AppTypes.Services;
|
||||
|
||||
const actions = {
|
||||
@@ -88,13 +89,37 @@ const commandsModule = ({
|
||||
* @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 = options.predecessorImageId ?? segmentation.predecessorImageId;
|
||||
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(image => cache.getImage(image.referencedImageId));
|
||||
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 = [];
|
||||
|
||||
@@ -173,7 +198,8 @@ const commandsModule = ({
|
||||
|
||||
const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
|
||||
predecessorImageId,
|
||||
...options,
|
||||
...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride),
|
||||
...generateOptions,
|
||||
});
|
||||
|
||||
return generatedSegmentation;
|
||||
@@ -257,6 +283,8 @@ const commandsModule = ({
|
||||
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,
|
||||
@@ -289,6 +317,8 @@ const commandsModule = ({
|
||||
|
||||
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
|
||||
@@ -301,10 +331,11 @@ const commandsModule = ({
|
||||
Number(segmentIndex)
|
||||
);
|
||||
});
|
||||
const predecessorImageId = options?.predecessorImageId ?? segmentations.predecessorImageId;
|
||||
const predecessorImageId =
|
||||
contourOptions.predecessorImageId ?? segmentations.predecessorImageId;
|
||||
const dataset = await generateRTSSFromRepresentation(segmentations, {
|
||||
predecessorImageId,
|
||||
...options,
|
||||
...contourOptions,
|
||||
});
|
||||
return { dataset };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
DEFAULT_SEG_STORE_MODE,
|
||||
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
|
||||
type SegmentationMode,
|
||||
} from '../utils/segmentationConfig';
|
||||
|
||||
export type SegmentLabelCustomization = {
|
||||
enabledByDefault?: boolean;
|
||||
labelColor?: number[];
|
||||
hoverTimeout?: number;
|
||||
background?: string;
|
||||
};
|
||||
|
||||
export type SegmentationStoreCustomization = {
|
||||
defaultMode?: SegmentationMode;
|
||||
transferSyntaxUID?: string;
|
||||
};
|
||||
|
||||
/** Extension-registered defaults: Label Map SEG + RLE Lossless. */
|
||||
const segmentationCustomization = {
|
||||
'segmentation.store.defaultMode': DEFAULT_SEG_STORE_MODE,
|
||||
'segmentation.store.transferSyntaxUID': DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
|
||||
'segmentation.segmentLabel': {
|
||||
enabledByDefault: false,
|
||||
hoverTimeout: 1,
|
||||
} satisfies SegmentLabelCustomization,
|
||||
};
|
||||
|
||||
export default segmentationCustomization;
|
||||
@@ -0,0 +1,10 @@
|
||||
import segmentationCustomization from './customizations/segmentationCustomization';
|
||||
|
||||
export default function getCustomizationModule() {
|
||||
return [
|
||||
{
|
||||
name: 'default',
|
||||
value: segmentationCustomization,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,16 +1,237 @@
|
||||
import { utils, Types as OhifTypes } from '@ohif/core';
|
||||
import { utils, Types as OhifTypes, DicomMetadataStore, classes, log } from '@ohif/core';
|
||||
import i18n from '@ohif/i18n';
|
||||
import { metaData, eventTarget } from '@cornerstonejs/core';
|
||||
import { metaData, eventTarget, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
|
||||
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
|
||||
|
||||
import { SOPClassHandlerId } from './id';
|
||||
import { dicomlabToRGB } from './utils/dicomlabToRGB';
|
||||
import { getSegmentationParserType } from './utils/segmentationConfig';
|
||||
import {
|
||||
getFrameIndexFromImageId,
|
||||
isLocalSchemeImageId,
|
||||
stripFrameFromImageId,
|
||||
} from './utils/segLocalImageIds';
|
||||
|
||||
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4', '1.2.840.10008.5.1.4.1.1.66.7'];
|
||||
const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
|
||||
|
||||
const loadPromises = {};
|
||||
|
||||
const SEG_LOAD_LOG_PREFIX = '[SEG load]';
|
||||
|
||||
// Max number of SEG frames fetched/decoded concurrently by the segmentation
|
||||
// loader. Hard-coded to 16 for now; intended to become configurable (and to
|
||||
// pair with the full-instance prefetch capability) in a follow-up.
|
||||
const SEG_FRAME_DECODE_CONCURRENCY = 16;
|
||||
|
||||
|
||||
function _normalizeImageId(imageId: string | string[] | undefined): string | undefined {
|
||||
if (imageId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(imageId) ? imageId[0] : imageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a WADO-RS frame imageId (…/frames/1) into one imageId per frame.
|
||||
* Multiframe SEG is stored as separate /frames/N resources on the server.
|
||||
*/
|
||||
function getFrameImageIds(segImageId: string, numberOfFrames: number): string[] {
|
||||
const frameMatch = segImageId.match(/(.*\/frames\/)(\d+)(.*)$/);
|
||||
if (!frameMatch || numberOfFrames <= 1) {
|
||||
return [segImageId];
|
||||
}
|
||||
|
||||
const prefix = frameMatch[1];
|
||||
const suffix = frameMatch[3] || '';
|
||||
const frameImageIds: string[] = [];
|
||||
|
||||
for (let frameNumber = 1; frameNumber <= numberOfFrames; frameNumber++) {
|
||||
frameImageIds.push(`${prefix}${frameNumber}${suffix}`);
|
||||
}
|
||||
|
||||
return frameImageIds;
|
||||
}
|
||||
|
||||
function _getSegNumberOfFrames(instance: Record<string, unknown>): number {
|
||||
const fromTag = Number(instance.NumberOfFrames);
|
||||
if (fromTag > 0) {
|
||||
return fromTag;
|
||||
}
|
||||
|
||||
const perFrame = instance.PerFrameFunctionalGroupsSequence;
|
||||
if (Array.isArray(perFrame) && perFrame.length > 0) {
|
||||
return perFrame.length;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function _ensureSegImageIdMetadataRegistered(
|
||||
imageId: string | undefined,
|
||||
instance: Record<string, unknown>
|
||||
): void {
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataProvider = classes.MetadataProvider;
|
||||
|
||||
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!StudyInstanceUID || !SeriesInstanceUID || !SOPInstanceUID) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadataProvider.addImageIdToUIDs(imageId, {
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
frameNumber: getFrameIndexFromImageId(imageId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensures metadataProvider.get('instance', imageId) resolves for frame-qualified local SEG ids. */
|
||||
function _ensureSegInstanceMetadataAvailable(
|
||||
imageId: string | undefined,
|
||||
instance: Record<string, unknown>
|
||||
): void {
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
_ensureSegImageIdMetadataRegistered(imageId, instance);
|
||||
|
||||
if (metaData.get('instance', imageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const storedInstance =
|
||||
StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID
|
||||
? DicomMetadataStore.getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID)
|
||||
: undefined;
|
||||
|
||||
classes.MetadataProvider.addCustomMetadata(
|
||||
imageId,
|
||||
'instance',
|
||||
storedInstance || instance
|
||||
);
|
||||
}
|
||||
|
||||
function _getSegDataSource(extensionManager, instance: Record<string, unknown>) {
|
||||
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
let localUrl: string | undefined;
|
||||
|
||||
if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) {
|
||||
const storedInstance = DicomMetadataStore.getInstance(
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID
|
||||
);
|
||||
localUrl = storedInstance?.url as string | undefined;
|
||||
}
|
||||
|
||||
localUrl = localUrl || (instance.url as string | undefined);
|
||||
|
||||
if (localUrl && isLocalSchemeImageId(localUrl)) {
|
||||
const dicomLocal = extensionManager.getDataSources('dicomlocal');
|
||||
|
||||
if (dicomLocal?.[0]) {
|
||||
return dicomLocal[0];
|
||||
}
|
||||
}
|
||||
|
||||
return extensionManager.getActiveDataSource()[0];
|
||||
}
|
||||
|
||||
function _getSegImageIdFromInstance(
|
||||
instance: Record<string, unknown>,
|
||||
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
|
||||
): string | undefined {
|
||||
const numberOfFrames = _getSegNumberOfFrames(instance);
|
||||
const frame = numberOfFrames > 1 ? 1 : undefined;
|
||||
|
||||
return _normalizeImageId(
|
||||
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
|
||||
);
|
||||
}
|
||||
|
||||
function _resolveFrameImageIds(
|
||||
segImageIdStr: string,
|
||||
instance: Record<string, unknown>,
|
||||
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
|
||||
): string[] {
|
||||
const numberOfFrames = _getSegNumberOfFrames(instance);
|
||||
const fromFrameUrl = getFrameImageIds(segImageIdStr, numberOfFrames);
|
||||
|
||||
if (fromFrameUrl.length > 1) {
|
||||
return fromFrameUrl;
|
||||
}
|
||||
|
||||
if (numberOfFrames <= 1) {
|
||||
return [segImageIdStr];
|
||||
}
|
||||
|
||||
const frameImageIds: string[] = [];
|
||||
|
||||
for (let frame = 1; frame <= numberOfFrames; frame++) {
|
||||
const frameImageId = _normalizeImageId(
|
||||
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
|
||||
);
|
||||
|
||||
if (frameImageId) {
|
||||
frameImageIds.push(frameImageId);
|
||||
}
|
||||
}
|
||||
|
||||
return frameImageIds.length ? frameImageIds : [segImageIdStr];
|
||||
}
|
||||
|
||||
function _logSegImageIds({
|
||||
segDisplaySet,
|
||||
segImageIdStr,
|
||||
frameImageIds,
|
||||
referencedImageIds,
|
||||
}: {
|
||||
segDisplaySet: AppTypes.DisplaySet;
|
||||
segImageIdStr: string;
|
||||
frameImageIds: string[];
|
||||
referencedImageIds: string[];
|
||||
}) {
|
||||
const instance = segDisplaySet.instance as Record<string, unknown>;
|
||||
const numberOfFrames = Number(instance?.NumberOfFrames) || 1;
|
||||
|
||||
log.debug(SEG_LOAD_LOG_PREFIX, 'Loading SEG pixel data', {
|
||||
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
|
||||
SeriesInstanceUID: segDisplaySet.SeriesInstanceUID,
|
||||
SOPClassUID: segDisplaySet.SOPClassUID,
|
||||
NumberOfFrames: numberOfFrames,
|
||||
segmentCount: Object.keys(segDisplaySet.segments || {}).length,
|
||||
referencedDisplaySetInstanceUID: segDisplaySet.referencedDisplaySetInstanceUID,
|
||||
referencedImageIdCount: referencedImageIds.length,
|
||||
referencedImageIds,
|
||||
segImageIdForMetadata: segImageIdStr,
|
||||
frameImageIds,
|
||||
loadSegFramesIndividually: frameImageIds.length > 1,
|
||||
});
|
||||
}
|
||||
|
||||
function _getDisplaySetsFromSeries(
|
||||
instances,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
@@ -171,6 +392,11 @@ function _load(
|
||||
});
|
||||
});
|
||||
|
||||
// Expose the in-flight load promise so observers (e.g. the viewport service
|
||||
// waiting to attach the representation) can react to a load failure without
|
||||
// re-invoking load().
|
||||
segDisplaySet.loadingPromise = loadPromises[SOPInstanceUID];
|
||||
|
||||
return loadPromises[SOPInstanceUID];
|
||||
}
|
||||
|
||||
@@ -178,16 +404,18 @@ async function _loadSegments({
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
segDisplaySet,
|
||||
headers,
|
||||
}: withAppTypes) {
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
}: withAppTypes<{ segDisplaySet: AppTypes.DisplaySet }>) {
|
||||
const { segmentationService, uiNotificationService, customizationService } =
|
||||
servicesManager.services;
|
||||
const instance = segDisplaySet.instance as Record<string, unknown>;
|
||||
const dataSource = _getSegDataSource(extensionManager, instance);
|
||||
const segImageIdStr = _getSegImageIdFromInstance(instance, dataSource);
|
||||
|
||||
const { segmentationService, uiNotificationService } = servicesManager.services;
|
||||
|
||||
const { dicomLoaderService } = utilityModule.exports;
|
||||
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
|
||||
if (!segImageIdStr) {
|
||||
throw new Error(
|
||||
'Could not get imageId for SEG instance (no local wadouri url and getImageIdsForInstance returned nothing).'
|
||||
);
|
||||
}
|
||||
|
||||
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
|
||||
segDisplaySet.referencedDisplaySetInstanceUID
|
||||
@@ -197,31 +425,114 @@ async function _loadSegments({
|
||||
throw new Error('referencedDisplaySet is missing for SEG');
|
||||
}
|
||||
|
||||
// Prefer cached stack imageIds (multiframe SEG fix #4890), then data source expansion.
|
||||
let { imageIds } = referencedDisplaySet;
|
||||
|
||||
if (!imageIds) {
|
||||
// try images
|
||||
const { images } = referencedDisplaySet;
|
||||
imageIds = images.map(image => image.imageId);
|
||||
if (!imageIds?.length) {
|
||||
imageIds = dataSource.getImageIdsForDisplaySet?.(referencedDisplaySet);
|
||||
}
|
||||
|
||||
// Todo: what should be defaults here
|
||||
if (!imageIds?.length) {
|
||||
imageIds = (referencedDisplaySet as { images?: { imageId: string }[] }).images?.map(
|
||||
(img: { imageId: string }) => img.imageId
|
||||
);
|
||||
}
|
||||
|
||||
if (!imageIds?.length) {
|
||||
throw new Error('referencedDisplaySet has no imageIds');
|
||||
}
|
||||
|
||||
(segDisplaySet as AppTypes.DisplaySet & { referencedImageIds?: string[] }).referencedImageIds =
|
||||
imageIds;
|
||||
|
||||
if (!referencedDisplaySet.imageIds?.length) {
|
||||
referencedDisplaySet.imageIds = imageIds;
|
||||
}
|
||||
|
||||
const frameImageIds = _resolveFrameImageIds(
|
||||
segImageIdStr,
|
||||
segDisplaySet.instance as Record<string, unknown>,
|
||||
dataSource
|
||||
);
|
||||
|
||||
const segImageIdForMetadata = isLocalSchemeImageId(segImageIdStr)
|
||||
? stripFrameFromImageId(segImageIdStr)
|
||||
: segImageIdStr;
|
||||
|
||||
_logSegImageIds({
|
||||
segDisplaySet,
|
||||
segImageIdStr: segImageIdForMetadata,
|
||||
frameImageIds,
|
||||
referencedImageIds: imageIds,
|
||||
});
|
||||
|
||||
_ensureSegInstanceMetadataAvailable(segImageIdForMetadata, instance);
|
||||
frameImageIds.forEach(id => _ensureSegInstanceMetadataAvailable(id, instance));
|
||||
|
||||
const tolerance = 0.001;
|
||||
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
|
||||
const onProgress = evt => {
|
||||
const { percentComplete } = evt.detail;
|
||||
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
|
||||
percentComplete,
|
||||
});
|
||||
});
|
||||
};
|
||||
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
|
||||
|
||||
const results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer(
|
||||
imageIds,
|
||||
arrayBuffer,
|
||||
{ metadataProvider: metaData, tolerance }
|
||||
);
|
||||
// Fetch the whole SEG instance as a single Part 10 object and register its
|
||||
// per-frame compressed pixels into the Cornerstone3D frame registry, so the
|
||||
// per-frame loads below are served locally instead of one network request
|
||||
// per frame: SEG frames are so small and numerous that one bulk fetch beats
|
||||
// hundreds of tiny requests. Enabled by default; per-frame loading is the
|
||||
// exception (loadMultiframeAsPart10: false in the data source config, or the
|
||||
// cornerstone.segmentation.loadMultiframeAsPart10 customization). The
|
||||
// prefetch is awaited until it completes OR fails — deliberately no timeout:
|
||||
// a failed/unsupported instance fetch resolves quickly and falls back to
|
||||
// per-frame, while a slow large fetch is still the fastest way to all frames.
|
||||
const loadMultiframeAsPart10 =
|
||||
(dataSource?.getConfig?.()?.loadMultiframeAsPart10 as boolean | undefined) ??
|
||||
(customizationService?.getCustomization?.(
|
||||
'cornerstone.segmentation.loadMultiframeAsPart10'
|
||||
) as boolean | undefined) ??
|
||||
true;
|
||||
|
||||
let prefetch;
|
||||
if (loadMultiframeAsPart10) {
|
||||
prefetch = dataSource.retrieve?.prefetchInstanceFrames?.({
|
||||
instance,
|
||||
imageId: segImageIdForMetadata,
|
||||
});
|
||||
|
||||
if (prefetch?.done) {
|
||||
await prefetch.done;
|
||||
}
|
||||
}
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDicomSegImageId(
|
||||
imageIds,
|
||||
segImageIdForMetadata,
|
||||
{
|
||||
metadataProvider: metaData,
|
||||
tolerance,
|
||||
parserType: getSegmentationParserType(
|
||||
segDisplaySet.SOPClassUID,
|
||||
customizationService
|
||||
),
|
||||
frameImageIds,
|
||||
concurrency: SEG_FRAME_DECODE_CONCURRENCY,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
eventTarget.removeEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
|
||||
prefetch?.cancel?.();
|
||||
}
|
||||
|
||||
let usedRecommendedDisplayCIELabValue = true;
|
||||
results.segMetadata.data.forEach((data, i) => {
|
||||
const resultsTyped = results as {
|
||||
segMetadata: { data: { rgba?: number[]; RecommendedDisplayCIELabValue?: number[] }[] };
|
||||
};
|
||||
resultsTyped.segMetadata.data.forEach((data, i) => {
|
||||
if (i > 0) {
|
||||
data.rgba = data.RecommendedDisplayCIELabValue;
|
||||
|
||||
@@ -246,6 +557,17 @@ async function _loadSegments({
|
||||
}
|
||||
|
||||
Object.assign(segDisplaySet, results);
|
||||
|
||||
const labelMapImageIds = (results as { labelMapImages?: { imageId: string }[][] })
|
||||
.labelMapImages?.flat()
|
||||
.map(image => image.imageId);
|
||||
|
||||
log.debug(SEG_LOAD_LOG_PREFIX, 'SEG parse complete', {
|
||||
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
|
||||
labelMapImageCount: labelMapImageIds?.length ?? 0,
|
||||
labelMapImageIds,
|
||||
segmentIndices: Object.keys(segDisplaySet.segments || {}),
|
||||
});
|
||||
}
|
||||
|
||||
function _segmentationExists(segDisplaySet) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import getSopClassHandlerModule from './getSopClassHandlerModule';
|
||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||
import getCommandsModule from './commandsModule';
|
||||
import getCustomizationModule from './getCustomizationModule';
|
||||
import { getToolbarModule } from './getToolbarModule';
|
||||
|
||||
const Component = React.lazy(() => {
|
||||
@@ -28,6 +29,7 @@ const extension = {
|
||||
*/
|
||||
id,
|
||||
getCommandsModule,
|
||||
getCustomizationModule,
|
||||
getToolbarModule,
|
||||
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
|
||||
const ExtendedOHIFCornerstoneSEGViewport = props => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export function isLocalSchemeImageId(imageId: string): boolean {
|
||||
return /^(wadouri:|dicomfile:|dicomweb:)/.test(imageId);
|
||||
}
|
||||
|
||||
export function stripFrameFromImageId(imageId: string): string {
|
||||
const queryIndex = imageId.indexOf('?');
|
||||
if (queryIndex === -1) {
|
||||
return imageId;
|
||||
}
|
||||
|
||||
const basePath = imageId.slice(0, queryIndex);
|
||||
const rebuiltQuery = imageId
|
||||
.slice(queryIndex + 1)
|
||||
.split('&')
|
||||
.filter(param => param.split('=')[0] !== 'frame')
|
||||
.join('&');
|
||||
|
||||
return rebuiltQuery ? `${basePath}?${rebuiltQuery}` : basePath;
|
||||
}
|
||||
|
||||
export function appendFrameToImageId(baseImageId: string, frame: number): string {
|
||||
const withoutFrame = stripFrameFromImageId(baseImageId);
|
||||
const separator = withoutFrame.includes('?') ? '&' : '?';
|
||||
|
||||
return `${withoutFrame}${separator}frame=${frame}`;
|
||||
}
|
||||
|
||||
export function getFrameIndexFromImageId(imageId: string): number {
|
||||
const frameMatch = imageId.match(/(?:&|\?)frame=(\d+)/);
|
||||
|
||||
return frameMatch ? Number(frameMatch[1]) : 1;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
|
||||
export const BITMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.4';
|
||||
/** RLE Lossless — OHIF default SEG store transfer syntax. */
|
||||
export const DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.5';
|
||||
/** OHIF default SEG store mode (Label Map Segmentation SOP Class). */
|
||||
export const DEFAULT_SEG_STORE_MODE = 'labelmap' as const;
|
||||
|
||||
export type SegmentationMode = 'labelmap' | 'bitmap';
|
||||
|
||||
/**
|
||||
* Per-data-source overrides for the SEG store defaults. A data source may set
|
||||
* these under `configuration.segmentation.store` to override the values coming
|
||||
* from the `segmentation.store.*` customizations. Different back ends support
|
||||
* different SEG encodings, so the data source is allowed to win over the
|
||||
* app-wide customization default.
|
||||
*/
|
||||
export type SegmentationStoreOverride = {
|
||||
defaultMode?: SegmentationMode;
|
||||
transferSyntaxUID?: string;
|
||||
};
|
||||
|
||||
type SegmentationCustomizationReader = {
|
||||
getCustomization: (customizationId: string) => unknown;
|
||||
};
|
||||
|
||||
function getStoreDefaultMode(
|
||||
customizationService?: SegmentationCustomizationReader,
|
||||
override?: SegmentationStoreOverride
|
||||
): SegmentationMode {
|
||||
const mode =
|
||||
override?.defaultMode ??
|
||||
(customizationService?.getCustomization('segmentation.store.defaultMode') as
|
||||
| SegmentationMode
|
||||
| undefined) ??
|
||||
DEFAULT_SEG_STORE_MODE;
|
||||
|
||||
return mode === 'bitmap' ? 'bitmap' : 'labelmap';
|
||||
}
|
||||
|
||||
function getStoreTransferSyntaxUID(
|
||||
customizationService?: SegmentationCustomizationReader,
|
||||
override?: SegmentationStoreOverride
|
||||
): string {
|
||||
return (
|
||||
override?.transferSyntaxUID ??
|
||||
(customizationService?.getCustomization(
|
||||
'segmentation.store.transferSyntaxUID'
|
||||
) as string | undefined) ??
|
||||
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the parser type for loading a DICOM SEG instance.
|
||||
* Uses the instance SOP Class UID when it is a known SEG class; otherwise falls back to store defaultMode.
|
||||
*/
|
||||
export function getSegmentationParserType(
|
||||
sopClassUID: string | undefined,
|
||||
customizationService?: SegmentationCustomizationReader
|
||||
): SegmentationMode {
|
||||
if (sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) {
|
||||
return 'labelmap';
|
||||
}
|
||||
|
||||
if (sopClassUID === BITMAP_SEG_SOP_CLASS_UID) {
|
||||
return 'bitmap';
|
||||
}
|
||||
|
||||
return getStoreDefaultMode(customizationService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to @cornerstonejs/adapters generateSegmentation when exporting or storing SEG.
|
||||
*
|
||||
* Defaults to **Label Map + RLE Lossless**. Customizations (or per-data-source
|
||||
* `configuration.segmentation.store`) are only needed to opt into bitmap and/or
|
||||
* uncompressed Explicit VR Little Endian.
|
||||
*/
|
||||
export function getSegmentationSaveOptions(
|
||||
customizationService?: SegmentationCustomizationReader,
|
||||
override?: SegmentationStoreOverride
|
||||
): {
|
||||
sopClassUID: string;
|
||||
transferSyntaxUID: string;
|
||||
transferSyntaxUid: string;
|
||||
} {
|
||||
const defaultMode = getStoreDefaultMode(customizationService, override);
|
||||
const sopClassUID =
|
||||
defaultMode === 'bitmap' ? BITMAP_SEG_SOP_CLASS_UID : LABELMAP_SEG_SOP_CLASS_UID;
|
||||
const transferSyntaxUID = getStoreTransferSyntaxUID(
|
||||
customizationService,
|
||||
override
|
||||
);
|
||||
|
||||
return {
|
||||
sopClassUID,
|
||||
transferSyntaxUID,
|
||||
transferSyntaxUid: transferSyntaxUID,
|
||||
};
|
||||
}
|
||||
@@ -95,10 +95,13 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
||||
};
|
||||
|
||||
const getCornerstoneViewport = useCallback(() => {
|
||||
// Stack uses the referenced series (data[0]); SEG is applied as an overlay (data[1]).
|
||||
// Passing only the SEG display set leaves the stack with derived labelmap imageIds,
|
||||
// which are not displayable without the underlying grayscale series.
|
||||
return (
|
||||
<OHIFCornerstoneViewport
|
||||
{...props}
|
||||
displaySets={[segDisplaySet]}
|
||||
displaySets={[referencedDisplaySet, segDisplaySet]}
|
||||
viewportOptions={{
|
||||
viewportType: viewportOptions.viewportType,
|
||||
toolGroupId: toolGroupId,
|
||||
@@ -111,7 +114,14 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]);
|
||||
}, [
|
||||
viewportId,
|
||||
segDisplaySet,
|
||||
referencedDisplaySet,
|
||||
toolGroupId,
|
||||
props,
|
||||
viewportOptions,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (segIsLoading) {
|
||||
|
||||
Reference in new issue
Block a user