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
@@ -38,11 +38,11 @@
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "1.3.0",
|
||||
"@cornerstonejs/codec-openjph": "2.4.7",
|
||||
"@cornerstonejs/dicom-image-loader": "5.1.3",
|
||||
"@cornerstonejs/dicom-image-loader": "5.4.12",
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
"@ohif/ui": "workspace:*",
|
||||
"dcmjs": "0.49.4",
|
||||
"dcmjs": "0.52.0",
|
||||
"dicom-parser": "1.8.21",
|
||||
"hammerjs": "2.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
@@ -52,13 +52,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.1.3",
|
||||
"@cornerstonejs/ai": "5.1.3",
|
||||
"@cornerstonejs/core": "5.1.3",
|
||||
"@cornerstonejs/labelmap-interpolation": "5.1.3",
|
||||
"@cornerstonejs/metadata": "5.1.3",
|
||||
"@cornerstonejs/polymorphic-segmentation": "5.1.3",
|
||||
"@cornerstonejs/tools": "5.1.3",
|
||||
"@cornerstonejs/adapters": "5.4.12",
|
||||
"@cornerstonejs/ai": "5.4.12",
|
||||
"@cornerstonejs/core": "5.4.12",
|
||||
"@cornerstonejs/labelmap-interpolation": "5.4.12",
|
||||
"@cornerstonejs/metadata": "5.4.12",
|
||||
"@cornerstonejs/polymorphic-segmentation": "5.4.12",
|
||||
"@cornerstonejs/tools": "5.4.12",
|
||||
"@icr/polyseg-wasm": "0.4.0",
|
||||
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
|
||||
"@kitware/vtk.js": "35.5.3",
|
||||
|
||||
@@ -350,7 +350,10 @@ function _getInstanceNumberFromVolume(
|
||||
const imageId = imageIds[imageIndex];
|
||||
|
||||
if (!imageId) {
|
||||
return {};
|
||||
// No image at this index (e.g. a single-image volume scrolled out of
|
||||
// range). Return undefined so the overlay falls back to the slice count
|
||||
// instead of rendering an empty object as "[object Object]".
|
||||
return;
|
||||
}
|
||||
|
||||
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
|
||||
|
||||
@@ -1982,8 +1982,15 @@ function commandsModule({
|
||||
* Use it before initializing the toolGroup with the tools.
|
||||
*/
|
||||
initializeSegmentLabelTool: ({ tools }) => {
|
||||
const appConfig = extensionManager.appConfig;
|
||||
const segmentLabelConfig = appConfig.segmentation?.segmentLabel;
|
||||
const { customizationService } = servicesManager.services;
|
||||
const segmentLabelConfig = customizationService.getCustomization(
|
||||
'segmentation.segmentLabel'
|
||||
) as {
|
||||
enabledByDefault?: boolean;
|
||||
labelColor?: number[];
|
||||
hoverTimeout?: number;
|
||||
background?: string;
|
||||
};
|
||||
|
||||
if (segmentLabelConfig?.enabledByDefault) {
|
||||
const activeTools = tools?.active ?? [];
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
imageRetrievalPoolManager,
|
||||
} from '@cornerstonejs/core';
|
||||
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
||||
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
|
||||
import { Types } from '@ohif/core';
|
||||
import Enums from './enums';
|
||||
|
||||
@@ -169,6 +170,11 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
imageRetrievalPoolManager.clearRequestStack(type);
|
||||
});
|
||||
|
||||
// Release the typed metadata registry (NATURALIZED instances registered via
|
||||
// prefetchPart10Instance hold full compressed Part 10 buffers that live
|
||||
// outside the size-capped cornerstone image cache)
|
||||
csMetadataUtilities.clearCacheData();
|
||||
|
||||
cineService.setIsCineEnabled(false);
|
||||
|
||||
enabledElementReset();
|
||||
|
||||
@@ -1799,6 +1799,10 @@ describe('SegmentationService', () => {
|
||||
const segmentationId = 'segmentationId';
|
||||
const segmentationData = {
|
||||
segmentationId,
|
||||
representation: {
|
||||
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||
data: {},
|
||||
},
|
||||
config: {
|
||||
label: 'Segmentation 1',
|
||||
},
|
||||
|
||||
@@ -302,7 +302,15 @@ class SegmentationService extends PubSubService {
|
||||
}
|
||||
): Promise<void> {
|
||||
const segmentation = this.getSegmentation(segmentationId);
|
||||
if (segmentation && !segmentation.predecessorImageId && predecessorImageId) {
|
||||
|
||||
if (!segmentation) {
|
||||
console.warn(
|
||||
`addSegmentationRepresentation: segmentation "${segmentationId}" is not in state yet`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!segmentation.predecessorImageId && predecessorImageId) {
|
||||
segmentation.predecessorImageId = predecessorImageId;
|
||||
}
|
||||
const csViewport = this.getAndValidateViewport(viewportId);
|
||||
@@ -488,10 +496,22 @@ class SegmentationService extends PubSubService {
|
||||
throw new Error('No instances were provided for the referenced display set of the SEG');
|
||||
}
|
||||
|
||||
const imageIds = images.map(image => image.imageId);
|
||||
// Use the same imageIds as SEG parse (_loadSegments stores these on segDisplaySet).
|
||||
const imageIds =
|
||||
segDisplaySet.referencedImageIds ||
|
||||
(referencedDisplaySet.imageIds as string[] | undefined) ||
|
||||
images.map(image => image.imageId);
|
||||
|
||||
if (!imageIds?.length) {
|
||||
throw new Error('referencedDisplaySet has no imageIds for SEG');
|
||||
}
|
||||
const derivedImages = labelMapImages?.flat();
|
||||
const derivedImageIds = derivedImages.map(image => image.imageId);
|
||||
|
||||
// Note: instance runtime props (frameNumber, imageId, url, ...) are
|
||||
// intentionally non-enumerable, so this spread deliberately does NOT copy
|
||||
// them — frameNumber must not be carried onto these derived image entries.
|
||||
// Read such props off the original instance, never off a copy.
|
||||
segDisplaySet.images = derivedImages.map(image => ({
|
||||
...image,
|
||||
...metaData.get('instance', image.referencedImageId),
|
||||
@@ -572,11 +592,6 @@ class SegmentationService extends PubSubService {
|
||||
const colorLUTIndex = addColorLUT(colorLUT);
|
||||
this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex);
|
||||
|
||||
this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, {
|
||||
segmentationId,
|
||||
segDisplaySet,
|
||||
});
|
||||
|
||||
const seg: cstTypes.SegmentationPublicInput = {
|
||||
segmentationId,
|
||||
representation: {
|
||||
@@ -596,8 +611,18 @@ class SegmentationService extends PubSubService {
|
||||
|
||||
segDisplaySet.isLoaded = true;
|
||||
|
||||
// Add the segmentation to cornerstone state BEFORE broadcasting that loading is
|
||||
// complete. Subscribers (e.g. CornerstoneViewportService) react synchronously and
|
||||
// call addSegmentationRepresentation, which now early-returns when the segmentation
|
||||
// is not yet in cornerstone state. Broadcasting first would make that guard always
|
||||
// fire on initial load, silently preventing the representation from being attached.
|
||||
this.addOrUpdateSegmentation(seg);
|
||||
|
||||
this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, {
|
||||
segmentationId,
|
||||
segDisplaySet,
|
||||
});
|
||||
|
||||
return segmentationId;
|
||||
}
|
||||
|
||||
@@ -772,9 +797,16 @@ class SegmentationService extends PubSubService {
|
||||
if (existingSegmentation) {
|
||||
// Update the existing segmentation
|
||||
this.updateSegmentationInSource(segmentationId, data as Partial<cstTypes.Segmentation>);
|
||||
} else {
|
||||
} else if (
|
||||
'representation' in data &&
|
||||
(data as cstTypes.SegmentationPublicInput).representation
|
||||
) {
|
||||
// Add a new segmentation
|
||||
this.addSegmentationToSource(data as cstTypes.SegmentationPublicInput);
|
||||
} else {
|
||||
console.warn(
|
||||
`addOrUpdateSegmentation: skipping add for ${segmentationId} — missing representation`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,7 +1628,13 @@ class SegmentationService extends PubSubService {
|
||||
|
||||
private determineViewportAndSegmentationType(csViewport, segmentation) {
|
||||
const isVolumeViewport = isVolumeViewportType(csViewport);
|
||||
const isVolumeSegmentation = 'volumeId' in segmentation.representationData[LABELMAP];
|
||||
const labelmapData = segmentation?.representationData?.[LABELMAP];
|
||||
|
||||
if (!labelmapData) {
|
||||
return { isVolumeViewport, isVolumeSegmentation: false };
|
||||
}
|
||||
|
||||
const isVolumeSegmentation = 'volumeId' in labelmapData;
|
||||
return { isVolumeViewport, isVolumeSegmentation };
|
||||
}
|
||||
|
||||
|
||||
+115
-17
@@ -1294,24 +1294,122 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
? csToolsEnums.SegmentationRepresentations.Labelmap
|
||||
: csToolsEnums.SegmentationRepresentations.Contour;
|
||||
|
||||
const { predecessorImageId } = displaySet;
|
||||
const segmentationRepresentationPromise = segmentationService.addSegmentationRepresentation(
|
||||
viewport.id,
|
||||
{
|
||||
segmentationId,
|
||||
predecessorImageId,
|
||||
type: representationType,
|
||||
config: {
|
||||
blendMode:
|
||||
viewport?.getBlendMode?.() === 1
|
||||
? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND
|
||||
: undefined,
|
||||
},
|
||||
const applyRepresentation = () => {
|
||||
const { predecessorImageId } = displaySet;
|
||||
const segmentationRepresentationPromise =
|
||||
segmentationService.addSegmentationRepresentation(viewport.id, {
|
||||
segmentationId,
|
||||
predecessorImageId,
|
||||
type: representationType,
|
||||
config: {
|
||||
blendMode:
|
||||
viewport?.getBlendMode?.() === 1
|
||||
? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
this.storePresentation({ viewportId: viewport.id });
|
||||
return segmentationRepresentationPromise;
|
||||
};
|
||||
|
||||
// SEG overlay is registered during stack setup, but cornerstone segmentation state
|
||||
// is created in displaySet.load() (async). Wait until it exists before adding representation.
|
||||
if (displaySet.Modality === 'SEG') {
|
||||
if (segmentationService.getSegmentation(segmentationId)) {
|
||||
return applyRepresentation();
|
||||
}
|
||||
);
|
||||
// store the segmentation presentation id in the viewport info
|
||||
this.storePresentation({ viewportId: viewport.id });
|
||||
return segmentationRepresentationPromise;
|
||||
|
||||
// Bound the wait so a failed/aborted SEG load (where SEGMENTATION_LOADING_COMPLETE
|
||||
// never fires) cannot leave this promise — and the viewport setup awaiting it —
|
||||
// hanging indefinitely.
|
||||
const SEG_LOADING_TIMEOUT_MS = 120000;
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
let settled = false;
|
||||
let timeoutId;
|
||||
|
||||
// Final resolution — extra calls are harmless (resolve is a no-op after
|
||||
// the first), which lets the timeout stay armed while a representation
|
||||
// apply is in flight and still bound it.
|
||||
const finish = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Give up without applying (load failure / timeout before the event).
|
||||
const settleWithoutApply = () => {
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
finish();
|
||||
};
|
||||
|
||||
const { unsubscribe } = segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
|
||||
async (evt: { segDisplaySet?: OhifTypes.DisplaySet }) => {
|
||||
if (settled || evt.segDisplaySet?.displaySetInstanceUID !== segmentationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedupe immediately, but resolve only after the representation is
|
||||
// applied: awaiters continue into rotation/flip/render and broadcast
|
||||
// VIEWPORT_DATA_CHANGED (hanging-protocol callbacks, toolbar, e2e
|
||||
// readiness), which must not observe a viewport whose overlay
|
||||
// doesn't exist yet. applyRepresentation can be genuinely async
|
||||
// (stack→volume viewport conversion). The timeout is deliberately
|
||||
// NOT cleared here — a hung apply stays bounded.
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
|
||||
try {
|
||||
await applyRepresentation();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to apply segmentation representation for "${segmentationId}":`,
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Stop waiting immediately if the SEG load itself fails — otherwise the
|
||||
// loading-complete event never fires and we would idle until the timeout.
|
||||
const loadingPromise = (displaySet as { loadingPromise?: Promise<unknown> })
|
||||
.loadingPromise;
|
||||
loadingPromise?.catch(error => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Segmentation "${segmentationId}" failed to load; skipping representation setup.`,
|
||||
error
|
||||
);
|
||||
settleWithoutApply();
|
||||
});
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (settled) {
|
||||
// The load completed but applyRepresentation is hung — resolve so
|
||||
// viewport setup is never blocked past the bound (best-effort
|
||||
// semantics; the apply may still land later).
|
||||
console.warn(
|
||||
`Timed out applying segmentation representation for "${segmentationId}"; resolving viewport readiness anyway.`
|
||||
);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Timed out waiting for segmentation "${segmentationId}" to load; skipping representation setup.`
|
||||
);
|
||||
settleWithoutApply();
|
||||
}, SEG_LOADING_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
return applyRepresentation();
|
||||
}
|
||||
|
||||
private async _addOverlayRepresentations(
|
||||
|
||||
@@ -53,10 +53,14 @@ export function setupSegmentationDataModifiedHandler({
|
||||
});
|
||||
|
||||
if (!isUnsubscribed && updatedSegmentation) {
|
||||
segmentationService.addOrUpdateSegmentation({
|
||||
segmentationId,
|
||||
segments: updatedSegmentation.segments,
|
||||
});
|
||||
const existingSegmentation = segmentationService.getSegmentation(segmentationId);
|
||||
|
||||
if (existingSegmentation) {
|
||||
segmentationService.addOrUpdateSegmentation({
|
||||
segmentationId,
|
||||
segments: updatedSegmentation.segments,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
1000
|
||||
|
||||
Reference in new issue
Block a user