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:
authored and GitHub committed 2026-07-09 20:54:23 -04:00
1 parent b996070583
commit dbb8b1526e
59 files changed
+2501 -322

No files matched your search

+9 -9
View File
@@ -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) || {};
+9 -2
View File
@@ -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 ?? [];
+6
View File
@@ -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 };
}
@@ -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