Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fe408d8
fix(seg): save the segment of an overlap labelmap layer under its own…
TFRadicalImaging Oct 14, 2026
7696586
fix(seg): map overlap layer labels from segment bindings without full…
wayfarer3130 Oct 14, 2026
e1560c0
fix(seg): keep unbound label values like the renderer, and tidy the r…
wayfarer3130 Oct 14, 2026
5406f67
test(seg): add unit tests for the labelmap layer remap
wayfarer3130 Oct 14, 2026
766b017
docs(seg): note the follow-ups for cornerstone3D#2988 in comments
wayfarer3130 Oct 14, 2026
2ab0e90
fix(seg): enable the SEG export when only an overlap layer has data
wayfarer3130 Oct 14, 2026
278cdb8
Merge branch 'master' into fix/seg-export-overlap-layer-segment-index
wayfarer3130 Oct 14, 2026
3f8b44d
fix(seg): export with the label map of each layer, and drop label val…
TFRadicalImaging Oct 15, 2026
279ba71
perf(segmentation): scan the labelmap voxels for the export option on…
TFRadicalImaging Oct 15, 2026
7cac7fe
test(seg): anchor the @ohif module mappers of the jest config
TFRadicalImaging Oct 15, 2026
b94669e
test(seg): move the segment to an overlap layer with the cornerstone3…
TFRadicalImaging Oct 15, 2026
ef069aa
chore(deps): update cornerstone3D to 5.12.0
wayfarer3130 Oct 15, 2026
040e3f2
fix(seg): export the label map of each layer with the SEG adapter, an…
wayfarer3130 Oct 15, 2026
d3418b8
fix(segmentation): do not keep the voxel scan result while a labelmap…
wayfarer3130 Oct 15, 2026
7f8896b
test(seg): move the segment to an overlap layer with the cornerstone3…
wayfarer3130 Oct 15, 2026
a53fbc3
fix(segmentation): scan the top-level image ids when the only labelma…
TFRadicalImaging Oct 15, 2026
8db99c8
refactor(segmentation): share the single-layer export source between …
wayfarer3130 Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions extensions/cornerstone-dicom-pmap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.10.3",
"@cornerstonejs/core": "5.10.3",
"@cornerstonejs/adapters": "5.12.0",
"@cornerstonejs/core": "5.12.0",
"@kitware/vtk.js": "36.4.1"
},
"devDependencies": {
Expand Down
12 changes: 12 additions & 0 deletions extensions/cornerstone-dicom-seg/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const base = require('../../jest.config.base.js');

module.exports = {
...base,
moduleNameMapper: {
...base.moduleNameMapper,
// Deep imports already name `src`, so they must be matched before the
// catch-all below appends a second one.
'^@ohif/([^/]+)/src/(.*)$': '<rootDir>/../../platform/$1/src/$2',
'^@ohif/([^/]+)$': '<rootDir>/../../platform/$1/src',
},
};
8 changes: 5 additions & 3 deletions extensions/cornerstone-dicom-seg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
"dev:dicom-seg": "pnpm run dev",
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js",
"build:package-1": "pnpm run build",
"start": "pnpm run dev"
"start": "pnpm run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "workspace:*",
Expand All @@ -40,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.10.3",
"@cornerstonejs/core": "5.10.3",
"@cornerstonejs/adapters": "5.12.0",
"@cornerstonejs/core": "5.12.0",
"@kitware/vtk.js": "36.4.1"
},
"devDependencies": {
Expand Down
74 changes: 52 additions & 22 deletions extensions/cornerstone-dicom-seg/src/commandsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ 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 { utils as cornerstoneUtils } from '@ohif/extension-cornerstone';

import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
import {
getSegmentationSaveOptions,
LABELMAP_SEG_SOP_CLASS_UID,
BITMAP_SEG_SOP_CLASS_UID,
} from './utils/segmentationConfig';
import {
getExportedLabelValues,
getExportLabelToSegmentIndex,
type LabelToSegmentIndex,
} from './utils/labelmapLayerSegmentIndices';

const getTargetViewport = ({ viewportId, viewportGridService }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
Expand Down Expand Up @@ -111,11 +117,16 @@ const commandsModule = ({
const labelmapData = segmentation.representationData.Labelmap;

// Build a labelmap3D (one labelmaps2D entry per source slice) from a list of
// derived labelmap image ids. When `referencedImageIds` is supplied (the
// multi-layer/overlap path) each frame is indexed by its source slice so the
// layers align to the same frames; otherwise frames are sequential (the legacy
// single-layer behavior, kept byte-identical).
const buildLabelmap3D = (segImageIds: string[], metadata, referencedImageIds?: string[]) => {
// derived labelmap image ids. The voxels keep their label values, and the
// adapter maps them with `labelToSegmentIndex`. When `referencedImageIds` is
// supplied (the multi-layer/overlap path) each frame is indexed by its source
// slice so the layers align to the same frames; otherwise frames are sequential.
const buildLabelmap3D = (
segImageIds: string[],
metadata,
referencedImageIds?: string[],
labelToSegmentIndex?: LabelToSegmentIndex
) => {
const segImages = segImageIds.map(imageId => cache.getImage(imageId));
const labelmaps2D = [];

Expand All @@ -126,20 +137,9 @@ const commandsModule = ({
: undefined;

let z = 0;
const unboundLabels = new Set<number>();

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);
}
}

const frameIndex = referencedFrameIndexById
? (referencedFrameIndexById.get(segImage.referencedImageId) ?? -1)
: z++;
Expand All @@ -148,14 +148,32 @@ const commandsModule = ({
continue;
}

const pixelData = segImage.getPixelData();
const segmentsOnLabelmap = getExportedLabelValues(
pixelData,
labelToSegmentIndex,
unboundLabels
);
// A slice with only unbound label values would give an empty LABELMAP frame.
if (!segmentsOnLabelmap.length) {
continue;
}
const { rows, columns } = segImage;

labelmaps2D[frameIndex] = {
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
segmentsOnLabelmap,
pixelData,
rows,
columns,
};
}

unboundLabels.forEach(labelValue =>
console.warn(
`Label value ${labelValue} of segmentation ${segmentationId} has no segment. The SEG export does not include its voxels.`
)
);

const allSegmentsOnLabelmap = labelmaps2D
.filter(Boolean)
.map(labelmap => labelmap.segmentsOnLabelmap);
Expand All @@ -164,6 +182,7 @@ const commandsModule = ({
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
metadata,
labelmaps2D,
labelToSegmentIndex: getExportLabelToSegmentIndex(labelToSegmentIndex, unboundLabels),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
};

Expand Down Expand Up @@ -215,7 +234,7 @@ const commandsModule = ({
// source series, so cornerstone writes overlapping segments as separate frames
// that reference the same source slice (the DICOM SEG overlap encoding). The
// cs3D adapter's fillSegmentation accepts an array of labelmap3D for exactly
// this. Single-layer SEGs keep the original single-labelmap3D path unchanged.
// this. A single-layer SEG uses one labelmap3D with sequential frames.
const layers = labelmapData.labelmaps ? Object.values(labelmapData.labelmaps) : undefined;

// The referenced source images must be fully loaded (in cache) before we can
Expand All @@ -240,15 +259,26 @@ const commandsModule = ({
layers[0].referencedImageIds ?? labelmapData.referencedImageIds ?? [];
referencedImages = referencedImageIds.map(resolveReferencedImage);
labelmaps3D = layers.map(layer =>
buildLabelmap3D(layer.imageIds ?? [], metadata, referencedImageIds)
buildLabelmap3D(
layer.imageIds ?? [],
metadata,
referencedImageIds,
layer.labelToSegmentIndex
)
);
} else {
const { imageIds } = labelmapData;
const { imageIds, labelToSegmentIndex, layerWithoutImageIds } =
cornerstoneUtils.getSingleLayerExportSource(labelmapData);
if (layerWithoutImageIds) {
console.warn(
`Labelmap layer ${layerWithoutImageIds.labelmapId} has no image ids. The SEG export uses the segmentation image ids with the label map of that layer.`
);
}
const segImages = imageIds.map(imageId => cache.getImage(imageId));
referencedImages = segImages.map((image, sliceIndex) =>
resolveReferencedImage(image.referencedImageId, sliceIndex)
);
labelmaps3D = buildLabelmap3D(imageIds, metadata);
labelmaps3D = buildLabelmap3D(imageIds, metadata, undefined, labelToSegmentIndex);
}

// `...generateOptions` has the last word on the predecessor: the store
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
getExportedLabelValues,
getExportLabelToSegmentIndex,
} from './labelmapLayerSegmentIndices';

// Segment 2 is in a private layer under label value 1.
const privateLabelToSegmentIndex = { 1: 2 };

describe('getExportedLabelValues', () => {
it('keeps every label value when the layer has no label map', () => {
expect(getExportedLabelValues(new Uint8Array([0, 1, 3, 3]))).toEqual([1, 3]);
});

it('does not export a label value that has no segment, and reports it', () => {
const unboundLabels = new Set<number>();

const labelValues = getExportedLabelValues(
new Uint8Array([1, 5, 5]),
privateLabelToSegmentIndex,
unboundLabels
);

expect(labelValues).toEqual([1]);
expect(Array.from(unboundLabels)).toEqual([5]);
});
});

describe('getExportLabelToSegmentIndex', () => {
it('maps a label value that has no segment to 0, so the adapter does not export it', () => {
expect(getExportLabelToSegmentIndex(privateLabelToSegmentIndex, new Set([5]))).toEqual({
1: 2,
5: 0,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/** The map from a label value in the voxels of a labelmap layer to the segment index. */
export type LabelToSegmentIndex = { [labelValue: number]: number };

/**
* Gets the label values of one labelmap slice that the SEG export includes, in
* one pass. The result is the `segmentsOnLabelmap` of a labelmap2D, which the
* SEG adapter reads as label values.
*
* cs3D stores a segment in a private overlap layer under a label value that is
* not its segment index (usually 1), and keeps the map in `labelToSegmentIndex`
* of each layer. A label value that has no entry in that map belongs to no
* segment: it is not returned, and it is added to `unboundLabels`. Without a
* map, every label value is its own segment index.
*
* @param pixelData - The voxel values of the labelmap slice.
* @param labelToSegmentIndex - The label map of the layer, or undefined.
* @param unboundLabels - Gets each label value that has no entry in the map.
*/
export function getExportedLabelValues(
pixelData,
labelToSegmentIndex?: LabelToSegmentIndex,
unboundLabels?: Set<number>
): number[] {
const labelValues = new Set<number>();
for (let i = 0; i < pixelData.length; i++) {
const labelValue = pixelData[i];
if (labelValue !== 0) {
labelValues.add(labelValue);
}
}
if (!labelToSegmentIndex) {
return Array.from(labelValues);
}
return Array.from(labelValues).filter(labelValue => {
if (labelToSegmentIndex[labelValue]) {
return true;
}
unboundLabels?.add(labelValue);
return false;
});
}

/**
* Gets the `labelToSegmentIndex` of a labelmap3D for the SEG adapter.
*
* The adapter reads a label value that is not in the map as its own segment
* index. The export must not include a label value that has no segment, so the
* map gives segment index 0 (no segment) to each such label value.
*/
export function getExportLabelToSegmentIndex(
labelToSegmentIndex: LabelToSegmentIndex | undefined,
unboundLabels: Set<number>
): LabelToSegmentIndex | undefined {
if (!labelToSegmentIndex) {
return undefined;
}
const exportMap = { ...labelToSegmentIndex };
unboundLabels.forEach(labelValue => {
exportMap[labelValue] = 0;
});
return exportMap;
}
6 changes: 3 additions & 3 deletions extensions/cornerstone-dicom-sr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.10.3",
"@cornerstonejs/core": "5.10.3",
"@cornerstonejs/tools": "5.10.3",
"@cornerstonejs/adapters": "5.12.0",
"@cornerstonejs/core": "5.12.0",
"@cornerstonejs/tools": "5.12.0",
"classnames": "2.5.1"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions extensions/cornerstone-dynamic-volume/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "5.10.3",
"@cornerstonejs/tools": "5.10.3",
"@cornerstonejs/core": "5.12.0",
"@cornerstonejs/tools": "5.12.0",
"classnames": "2.5.1"
},
"devDependencies": {
Expand Down
16 changes: 8 additions & 8 deletions extensions/cornerstone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.4",
"@cornerstonejs/codec-openjpeg": "1.3.2",
"@cornerstonejs/codec-openjph": "2.4.9",
"@cornerstonejs/dicom-image-loader": "5.10.3",
"@cornerstonejs/dicom-image-loader": "5.12.0",
"@ohif/core": "workspace:*",
"@ohif/extension-default": "workspace:*",
"dcmjs": "0.52.0",
Expand All @@ -49,13 +49,13 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.10.3",
"@cornerstonejs/ai": "5.10.3",
"@cornerstonejs/core": "5.10.3",
"@cornerstonejs/labelmap-interpolation": "5.10.3",
"@cornerstonejs/metadata": "5.10.3",
"@cornerstonejs/polymorphic-segmentation": "5.10.3",
"@cornerstonejs/tools": "5.10.3",
"@cornerstonejs/adapters": "5.12.0",
"@cornerstonejs/ai": "5.12.0",
"@cornerstonejs/core": "5.12.0",
"@cornerstonejs/labelmap-interpolation": "5.12.0",
"@cornerstonejs/metadata": "5.12.0",
"@cornerstonejs/polymorphic-segmentation": "5.12.0",
"@cornerstonejs/tools": "5.12.0",
"@icr/polyseg-wasm": "0.4.0",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "36.4.1",
Expand Down
2 changes: 2 additions & 0 deletions extensions/cornerstone/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import promptHydrationDialog, {
} from './promptHydrationDialog';
import { getCenterExtent } from './getCenterExtent';
import { createSegmentationForViewport } from './createSegmentationForViewport';
import { getSingleLayerExportSource } from './segmentationExportUtils';

const utils = {
handleSegmentChange,
Expand All @@ -20,6 +21,7 @@ const utils = {
promptHydrationDialog,
getCenterExtent,
createSegmentationForViewport,
getSingleLayerExportSource,
};

export type { HydrationDialogProps, HydrationCallback, HydrationSRResult };
Expand Down
Loading
Loading