diff --git a/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js b/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js index 98b79160ad..246e46f325 100644 --- a/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js +++ b/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js @@ -69,6 +69,14 @@ export function generateSegmentation( return fillSegmentation(segmentation, inputLabelmaps3D, userOptions); } +/** + * The segment index of a voxel value of one labelmap3D. A value that is not in + * `labelmap3D.labelToSegmentIndex` is its own segment index. + */ +export function getSegmentIndexForLabel(labelmap3D, labelValue) { + return labelmap3D?.labelToSegmentIndex?.[labelValue] ?? labelValue; +} + /** * The set of non-zero segment values actually present in a frame's pixel data, * collected in a single pass. `segmentsOnLabelmap` can be stale, so presence is @@ -98,6 +106,12 @@ function segmentsPresentInFrame(pixelData) { * * @param segmentation - The segmentation object to be filled. * @param inputLabelmaps3D - An array of 3D labelmaps, or a single 3D labelmap. + * The voxels of a labelmap3D hold label values. `labelToSegmentIndex` maps a + * label value to its segment index, for a layer that stores a segment under + * another label value; a value that is not in the map is its own segment + * index. `segmentsOnLabelmap` of each labelmap2D lists label values. Two + * label values with the same segment index, in one labelmap3D or in two, + * give one SEG segment. * @param userOptions - Optional configuration settings. Will override the default options. * - `transferSyntaxUid` — the output transfer syntax. Defaults to RLE Lossless * (`1.2.840.10008.1.2.5`); Explicit VR Little Endian @@ -128,8 +142,8 @@ export function fillSegmentation( ? inputLabelmaps3D : [inputLabelmaps3D]; - const referencedFramesPerLabelmap = []; const frameDescriptors = []; + const entriesBySegmentIndex = new Map(); for ( let labelmapIndex = 0; @@ -139,77 +153,57 @@ export function fillSegmentation( const labelmap3D = labelmaps3D[labelmapIndex]; const { labelmaps2D, metadata: segmentMetadata } = labelmap3D; - const referencedFramesPerSegment = []; - - for (let i = 1; i < segmentMetadata.length; i++) { - if (segmentMetadata[i]) { - referencedFramesPerSegment[i] = []; - } - } - for (let i = 0; i < labelmaps2D.length; i++) { const labelmap2D = labelmaps2D[i]; - if (!labelmap2D?.pixelData) { + if (!labelmap2D) { continue; } const { segmentsOnLabelmap } = labelmap2D; - const presentSegments = segmentsPresentInFrame(labelmap2D.pixelData); - segmentsOnLabelmap.forEach((segmentIndex) => { + segmentsPresentInFrame(labelmap2D.pixelData).forEach((labelValue) => { + const segmentIndex = getSegmentIndexForLabel(labelmap3D, labelValue); + + if ( + !segmentMetadata[segmentIndex] || + !segmentsOnLabelmap.includes(labelValue) + ) { + return; + } + + if (!entriesBySegmentIndex.has(segmentIndex)) { + entriesBySegmentIndex.set(segmentIndex, { + segmentIndex, + metadata: segmentMetadata[segmentIndex], + sources: [], + frameIndices: new Set(), + }); + } + const entry = entriesBySegmentIndex.get(segmentIndex); if ( - segmentIndex !== 0 && - segmentMetadata[segmentIndex] && - referencedFramesPerSegment[segmentIndex] && - presentSegments.has(segmentIndex) + !entry.sources.some( + (source) => + source.labelmap3D === labelmap3D && + source.labelValue === labelValue + ) ) { - referencedFramesPerSegment[segmentIndex].push(i); + entry.sources.push({ labelmap3D, labelValue }); } + entry.frameIndices.add(i); }); } - - referencedFramesPerLabelmap[labelmapIndex] = referencedFramesPerSegment; } - let numberOfFrames = 0; - - for ( - let labelmapIndex = 0; - labelmapIndex < labelmaps3D.length; - labelmapIndex++ - ) { - const referencedFramesPerSegment = - referencedFramesPerLabelmap[labelmapIndex]; - const labelmap3D = labelmaps3D[labelmapIndex]; - const { metadata: segmentMetadata } = labelmap3D; - - for ( - let segmentIndex = 1; - segmentIndex < referencedFramesPerSegment.length; - segmentIndex++ - ) { - const referencedFrameIndicies = referencedFramesPerSegment[segmentIndex]; - - if (!referencedFrameIndicies?.length || !segmentMetadata[segmentIndex]) { - continue; - } - - const labelmaps = _getLabelmapsFromReferencedFrameIndicies( - labelmap3D, - referencedFrameIndicies - ); - - if ( - !labelmaps.length || - !labelmaps.some((frame) => frame?.length && frame.some((v) => v !== 0)) - ) { - continue; - } + // dcmjs numbers the segments in the order they are added. + const segmentEntries = [...entriesBySegmentIndex.values()].sort( + (a, b) => a.segmentIndex - b.segmentIndex + ); - numberOfFrames += referencedFrameIndicies.length; - } - } + const numberOfFrames = segmentEntries.reduce( + (total, entry) => total + entry.frameIndices.size, + 0 + ); if (numberOfFrames === 0) { throw new Error('No non-empty segmentation frames found for SEG export'); @@ -217,60 +211,39 @@ export function fillSegmentation( segmentation.setNumberOfFrames(numberOfFrames); - for ( - let labelmapIndex = 0; - labelmapIndex < labelmaps3D.length; - labelmapIndex++ - ) { - const referencedFramesPerSegment = - referencedFramesPerLabelmap[labelmapIndex]; - - const labelmap3D = labelmaps3D[labelmapIndex]; - const { metadata: segmentMetadata } = labelmap3D; + const firstSegmentNumber = + (segmentation.dataset.SegmentSequence?.length ?? 0) + 1; - for ( - let segmentIndex = 1; - segmentIndex < referencedFramesPerSegment.length; - segmentIndex++ - ) { - const referencedFrameIndicies = referencedFramesPerSegment[segmentIndex]; - - if (!referencedFrameIndicies?.length || !segmentMetadata[segmentIndex]) { - continue; - } + segmentEntries.forEach((entry, entryIndex) => { + const referencedFrameIndicies = [...entry.frameIndices].sort( + (a, b) => a - b + ); + const { labelmaps, labelValue } = _getSegmentLabelmaps( + entry.sources, + referencedFrameIndicies + ); - const labelmaps = _getLabelmapsFromReferencedFrameIndicies( - labelmap3D, - referencedFrameIndicies - ); + // Frame numbers start from 1. + const referencedFrameNumbers = referencedFrameIndicies.map( + (element) => element + 1 + ); - if ( - !labelmaps.length || - !labelmaps.some((frame) => frame?.length && frame.some((v) => v !== 0)) - ) { - continue; - } + segmentation.addSegmentFromLabelmap( + entry.metadata, + labelmaps, + labelValue, + referencedFrameNumbers + ); - // Frame numbers start from 1. - const referencedFrameNumbers = referencedFrameIndicies.map( - (element) => element + 1 - ); + const referencedSegmentNumber = firstSegmentNumber + entryIndex; - referencedFrameNumbers.forEach((frameNumber) => { - frameDescriptors.push({ - referencedSegmentNumber: segmentIndex, - sourceFrameIndex: frameNumber - 1, - }); + referencedFrameNumbers.forEach((frameNumber) => { + frameDescriptors.push({ + referencedSegmentNumber, + sourceFrameIndex: frameNumber - 1, }); - - segmentation.addSegmentFromLabelmap( - segmentMetadata[segmentIndex], - labelmaps, - segmentIndex, - referencedFrameNumbers - ); - } - } + }); + }); if (frameDescriptors.length && images?.length && metadata) { // Every SEG frame must reference a resolvable source SOP Instance UID. @@ -380,6 +353,44 @@ export function fillSegmentation( return segmentation; } +/** + * The frames of one SEG segment and the value that marks the segment in them. + * A segment with one source uses the frames of its labelmap3D. A segment with + * more sources gets new binary frames, with 1 where any source has its label + * value. + */ +function _getSegmentLabelmaps(sources, referencedFrameIndicies) { + if (sources.length === 1) { + const [{ labelmap3D, labelValue }] = sources; + return { + labelmaps: _getLabelmapsFromReferencedFrameIndicies( + labelmap3D, + referencedFrameIndicies + ), + labelValue, + }; + } + + const labelmaps = referencedFrameIndicies.map((frameIndex) => { + let merged; + sources.forEach(({ labelmap3D, labelValue }) => { + const pixelData = labelmap3D.labelmaps2D[frameIndex]?.pixelData; + if (!pixelData) { + return; + } + merged ||= new Uint8Array(pixelData.length); + for (let i = 0; i < pixelData.length; i++) { + if (pixelData[i] === labelValue) { + merged[i] = 1; + } + } + }); + return merged; + }); + + return { labelmaps, labelValue: 1 }; +} + export function _getLabelmapsFromReferencedFrameIndicies( labelmap3D, referencedFrameIndicies diff --git a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts index 39bbc20417..4c160d6c1e 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts @@ -1,6 +1,9 @@ import { normalizers, derivations } from 'dcmjs'; import { Enums } from '@cornerstonejs/core'; -import { fillSegmentation as fillBitmapSegmentation } from '../../Cornerstone/Segmentation_4X'; +import { + fillSegmentation as fillBitmapSegmentation, + getSegmentIndexForLabel, +} from '../../Cornerstone/Segmentation_4X'; import { encodeFramesToTransferSyntax, EXPLICIT_VR_LITTLE_ENDIAN_TRANSFER_SYNTAX_UID, @@ -137,8 +140,11 @@ function maxSegmentValue(labelmap3DArray, frameIndices: number[]): number { return; } for (let i = 0; i < pixelData.length; i++) { - if (pixelData[i] > max) { - max = pixelData[i]; + const segmentIndex = pixelData[i] + ? getSegmentIndexForLabel(labelmap3D, pixelData[i]) + : 0; + if (segmentIndex > max) { + max = segmentIndex; } } }); @@ -271,10 +277,10 @@ function fillLabelmapSegmentation( } const len = Math.min(source.length, frameLength); for (let i = 0; i < len; i++) { - const value = source[i]; - if (value === 0) { + if (source[i] === 0) { continue; } + const value = getSegmentIndexForLabel(labelmap3D, source[i]); if (frame[i] !== 0 && frame[i] !== value && !overlapWarned) { cs3dLogger.warn( 'generateSegmentation: overlapping labelmap segments detected on the ' + @@ -417,6 +423,9 @@ function fillLabelmapSegmentation( * * @param images - An array of the cornerstone image objects, which includes imageId and metadata * @param labelmaps - An array of the 3D Volumes that contain the segmentation data. + * Each labelmap3D can have `labelToSegmentIndex`, the map from a voxel value + * to the segment index, for a layer that stores a segment under another label + * value. The export applies the map and does not copy the voxels. * @param options.sopClassUID - Output SEG SOP Class UID. Defaults to BINARY * Segmentation (`1.2.840.10008.5.1.4.1.1.66.4`) for broad PACS/viewer * compatibility. Pass the Label Map Segmentation UID diff --git a/packages/adapters/test/labelmapLayerLabelValues.jest.js b/packages/adapters/test/labelmapLayerLabelValues.jest.js new file mode 100644 index 0000000000..9394f42f5b --- /dev/null +++ b/packages/adapters/test/labelmapLayerLabelValues.jest.js @@ -0,0 +1,120 @@ +import { describe, it, expect } from '@jest/globals'; + +const { + generateSegmentation, +} = require('../src/adapters/Cornerstone3D/Segmentation/generateSegmentation'); +const { + LABELMAP_SEG_SOP_CLASS_UID, + makeReferencedStack, + buildLabelmap3D, + firstItem, +} = require('./helpers/segRoundTrip'); + +const SLICE_COUNT = 4; + +// prettier-ignore +const ROW_STROKE = [ + 0, 0, 0, 0, + 1, 1, 1, 1, + 0, 0, 0, 0, + 0, 0, 0, 0 +]; +// prettier-ignore +const COLUMN_STROKE = [ + 0, 1, 0, 0, + 0, 1, 0, 0, + 0, 1, 0, 0, + 0, 1, 0, 0 +]; + +/** Segment metadata for segments 1 and 2, as a consumer gives it to each layer. */ +function withSegments(labelmap3D) { + const template = labelmap3D.metadata[1]; + labelmap3D.metadata = [ + undefined, + { ...template, SegmentNumber: '1', SegmentLabel: 'Segment 1' }, + { ...template, SegmentNumber: '2', SegmentLabel: 'Segment 2' }, + ]; + return labelmap3D; +} + +describe('labelmap layers that store a segment under another label value', () => { + it('writes the segment of an overlap layer under its own segment in a binary SEG', () => { + const stack = makeReferencedStack(SLICE_COUNT, { zDirection: -1 }); + const sharedLayer = withSegments( + buildLabelmap3D(SLICE_COUNT, { 1: ROW_STROKE }) + ); + // A private overlap layer: segment 2 is stored as label value 1. + const overlapLayer = withSegments( + buildLabelmap3D(SLICE_COUNT, { 1: COLUMN_STROKE }) + ); + overlapLayer.labelToSegmentIndex = { 1: 2 }; + + const { dataset } = generateSegmentation( + stack.images, + [sharedLayer, overlapLayer], + stack.metadataProvider + ); + + const segments = [].concat(dataset.SegmentSequence); + expect(segments.map((segment) => segment.SegmentLabel)).toEqual([ + 'Segment 1', + 'Segment 2', + ]); + const referencedSegmentNumbers = + dataset.PerFrameFunctionalGroupsSequence.map((group) => + Number( + firstItem(group.SegmentIdentificationSequence).ReferencedSegmentNumber + ) + ); + expect(referencedSegmentNumbers).toEqual([1, 2]); + }); + + it('writes one SEG segment when two label values have the same segment index', () => { + const stack = makeReferencedStack(SLICE_COUNT); + // Label value 1 is bound to segment 2, and label value 2 has no binding, + // so it is also segment 2. + const overlapLayer = withSegments( + buildLabelmap3D(SLICE_COUNT, { + 1: COLUMN_STROKE, + 2: COLUMN_STROKE.map((value) => value * 2), + }) + ); + overlapLayer.labelToSegmentIndex = { 1: 2 }; + + const { dataset } = generateSegmentation( + stack.images, + overlapLayer, + stack.metadataProvider + ); + + const segments = [].concat(dataset.SegmentSequence); + expect(segments.map((segment) => segment.SegmentLabel)).toEqual([ + 'Segment 2', + ]); + expect(dataset.PerFrameFunctionalGroupsSequence).toHaveLength(2); + }); + + it('writes the segment index, not the label value, in a LABELMAP SEG', () => { + const stack = makeReferencedStack(SLICE_COUNT); + // The only layer is a private layer: segment 2 is stored as label value 1. + const overlapLayer = withSegments( + buildLabelmap3D(SLICE_COUNT, { 1: COLUMN_STROKE }) + ); + overlapLayer.labelToSegmentIndex = { 1: 2 }; + + const { dataset } = generateSegmentation( + stack.images, + overlapLayer, + stack.metadataProvider, + { sopClassUID: LABELMAP_SEG_SOP_CLASS_UID } + ); + + const frame = new Uint8Array( + [].concat(dataset.PixelData)[0], + 0, + COLUMN_STROKE.length + ); + expect(Array.from(frame)).toEqual(COLUMN_STROKE.map((value) => value * 2)); + }); +}); diff --git a/packages/docs/docs/concepts/cornerstone-tools/segmentation/locking.md b/packages/docs/docs/concepts/cornerstone-tools/segmentation/locking.md index 119c2a4e8f..72123f3eab 100644 --- a/packages/docs/docs/concepts/cornerstone-tools/segmentation/locking.md +++ b/packages/docs/docs/concepts/cornerstone-tools/segmentation/locking.md @@ -81,3 +81,63 @@ segmentation.locking.setSegmentIndexLocked('segmentation1', 1, false); ``` Note that the locking state applies to the segmentation as a whole, not to specific representations or viewports. If a segment is locked, it will be locked across all viewports and representations. + +## Locked segments stop the brush + +The brush, the threshold brush and the scissors do not change the voxels of a +locked segment, on any layer of the segmentation. The painted segment stops at +the edge of the locked segment, and a locked segment never causes a new +labelmap layer. + +## Protected segments and overlap + +The `overwriteMode` setting of the segmentation configuration protects the +segments that the brush does not overwrite: + +| `overwriteMode` | Segments that the brush overwrites | Protected segments | +| ----------------- | ---------------------------------- | --------------------------- | +| `'all'` (default) | all unlocked segments | none | +| `'visible'` | visible unlocked segments | hidden segments | +| `'none'` | none | all other unlocked segments | + +```js +import { init } from '@cornerstonejs/tools'; + +init({ + segmentation: { overwriteMode: 'none' }, +}); +``` + +When a stroke would overwrite a protected segment that is not locked, the brush +moves the painted segment to a new labelmap layer, and the two segments overlap. +The protected segment keeps its voxels. The move and the stroke are one undo +step. + +## Overlapping segments + +Two segments can overlap only when they are on different labelmap layers. To put +a segment on a layer of its own before a stroke, call +`segmentation.helpers.moveSegmentToPrivateLabelmap`: + +```js +import { segmentation } from '@cornerstonejs/tools'; + +const labelmapId = segmentation.helpers.moveSegmentToPrivateLabelmap( + segmentationId, + segmentIndex, + { recordHistory: true } +); +``` + +- The function moves the voxels and the binding of the segment to a new layer, + and returns the id of that layer. +- If the segment is already the only segment on its layer, the segment stays on + that layer. +- With `recordHistory: true`, the move is one undo step. Undo puts the segment + back on its previous layer. +- The function triggers `SEGMENTATION_DATA_MODIFIED`. + +After the move, a stroke of that segment can paint over the other segments, +unless they are locked. Use `overwriteMode: 'none'` to keep the voxels of the +other segments under the overlap. The brush uses the same move when a stroke +would overwrite a protected segment. diff --git a/packages/docs/docs/migration-guides/5x/1-migration-notes.md b/packages/docs/docs/migration-guides/5x/1-migration-notes.md index be5760f433..a5d62cc63c 100644 --- a/packages/docs/docs/migration-guides/5x/1-migration-notes.md +++ b/packages/docs/docs/migration-guides/5x/1-migration-notes.md @@ -469,3 +469,40 @@ image of any encapsulated syntax takes the direct fragment path. other. - If you special-cased `1.2.840.10008.1.2.4.140` as JPEG XL anywhere in your own code, change it to the `.110`/`.111`/`.112` block. + +## Locked segments stop the brush, and do not cause an overlap + +### What Changed + +Earlier 5.x releases moved the painted segment to a new private labelmap layer +when a stroke touched a segment that the brush could not overwrite. This +occurred for a locked segment, and for a segment that `overwriteMode` (`'none'` +or `'visible'`) protects. The painted segment then overlapped the locked +segment. + +Now the brush, the threshold brush and the scissors skip the voxels of a locked +segment on any layer of the segmentation, and a locked segment never causes a +new layer. This is the 4.x behavior for locked segments. + +A protected segment that is not locked still causes the move to a new layer, as +before. The move and the stroke are one undo step. + +An application can also move a segment before a stroke with +`segmentation.helpers.moveSegmentToPrivateLabelmap(segmentationId, segmentIndex, { recordHistory })`. +See +[Segment Locking](../../concepts/cornerstone-tools/segmentation/locking.md#overlapping-segments). + +The SEG adapter now reads `labelmap3D.labelToSegmentIndex`, so a private layer +that stores a segment under a different label value exports under the correct +segment number. `segmentation.helpers.getLabelToSegmentIndex(segmentation, labelmapId)` +gives that map for one layer. + +### Migration Guidance + +- If your application relied on a locked segment to create an overlap, unlock + the segment, or call `segmentation.helpers.moveSegmentToPrivateLabelmap` for + the painted segment first. +- `beginLabelmapEditTransaction` accepts `segmentsLocked`. A locked segment in + that list does not cause a move. +- If you export SEG from a multi-layer labelmap, set `labelToSegmentIndex` on + each `labelmap3D` instead of a remap of the voxel values in your own code. diff --git a/packages/tools/src/config.ts b/packages/tools/src/config.ts index 68ec1caec0..d444dfea3c 100644 --- a/packages/tools/src/config.ts +++ b/packages/tools/src/config.ts @@ -103,6 +103,10 @@ type SegmentationConfig = { * - all: erase all other unlocked segments under the edit footprint * - visible: erase only visible unlocked segments * - none: do not erase other segments, allowing overlap + * + * A segment that is not overwritten keeps its voxels: when a stroke would + * overwrite it, the edited segment moves to a new labelmap layer. A locked + * segment never causes that move; the brush skips its voxels. */ overwriteMode?: 'all' | 'visible' | 'none'; }; diff --git a/packages/tools/src/stateManagement/segmentation/helpers/moveSegmentToPrivateLabelmap.ts b/packages/tools/src/stateManagement/segmentation/helpers/moveSegmentToPrivateLabelmap.ts new file mode 100644 index 0000000000..a14f349384 --- /dev/null +++ b/packages/tools/src/stateManagement/segmentation/helpers/moveSegmentToPrivateLabelmap.ts @@ -0,0 +1,54 @@ +import { utilities } from '@cornerstonejs/core'; +import { getSegmentation } from '../getSegmentation'; +import { triggerSegmentationDataModified } from '../triggerSegmentationEvents'; +import { moveSegmentToPrivateLabelmap as moveSegmentToPrivateLayer } from '../labelmapModel/privateLabelmap'; + +const { DefaultHistoryMemo } = utilities.HistoryMemo; + +/** + * Moves a segment to a new labelmap layer that holds only that segment. After + * the move, the segment can overlap the other segments of the segmentation. + * The brush never moves a segment by itself: an application calls this + * function when the user asks for an overlapping segment. + * + * @param segmentationId - The unique identifier of the segmentation. + * @param segmentIndex - The index of the segment to move. + * @param options - Optional. recordHistory: when true, record the move in + * history, so that undo puts the segment back on its previous layer. + * @returns The id of the layer that holds the segment, or undefined when the + * segment has no layer. When the segment is already alone on its layer, the + * segment stays on that layer. + */ +export function moveSegmentToPrivateLabelmap( + segmentationId: string, + segmentIndex: number, + options?: { recordHistory?: boolean } +): string | undefined { + const segmentation = getSegmentation(segmentationId); + if (!segmentation?.representationData.Labelmap) { + return; + } + + const layer = moveSegmentToPrivateLayer(segmentation, segmentIndex, { + moveStepCallback: (step) => { + if (!options?.recordHistory) { + return; + } + + DefaultHistoryMemo.push({ + restoreMemo: (undo) => { + if (undo) { + step.undo(); + } else { + step.redo(); + } + triggerSegmentationDataModified(segmentationId); + }, + }); + }, + }); + + triggerSegmentationDataModified(segmentationId); + + return layer?.labelmapId; +} diff --git a/packages/tools/src/stateManagement/segmentation/index.ts b/packages/tools/src/stateManagement/segmentation/index.ts index bb5447f683..ea243bbd80 100644 --- a/packages/tools/src/stateManagement/segmentation/index.ts +++ b/packages/tools/src/stateManagement/segmentation/index.ts @@ -30,7 +30,13 @@ import * as utilities from './utilities'; import { convertStackToVolumeLabelmap } from './helpers/convertStackToVolumeLabelmap'; import { computeVolumeLabelmapFromStack } from './helpers/computeVolumeLabelmapFromStack'; import { clearSegmentValue } from './helpers/clearSegmentValue'; +import { moveSegmentToPrivateLabelmap } from './helpers/moveSegmentToPrivateLabelmap'; import { convertVolumeToStackLabelmap } from './helpers/computeStackLabelmapFromVolume'; +import { + getLabelToSegmentIndex, + getLabelValueForSegment, + getSegmentIndexForLabelValue, +} from './labelmapModel'; import { removeSegment } from './removeSegment'; import { getLabelmapImageIds } from './getLabelmapImageIds'; import * as strategies from './../../tools/segmentation/strategies'; @@ -57,6 +63,10 @@ const helpers = { convertStackToVolumeLabelmap, computeVolumeLabelmapFromStack, convertVolumeToStackLabelmap, + getLabelToSegmentIndex, + getLabelValueForSegment, + getSegmentIndexForLabelValue, + moveSegmentToPrivateLabelmap, }; export { diff --git a/packages/tools/src/stateManagement/segmentation/labelmapModel/index.ts b/packages/tools/src/stateManagement/segmentation/labelmapModel/index.ts index 74d97cfac6..a2d7a5da14 100644 --- a/packages/tools/src/stateManagement/segmentation/labelmapModel/index.ts +++ b/packages/tools/src/stateManagement/segmentation/labelmapModel/index.ts @@ -24,6 +24,7 @@ export { } from './labelmapLegacyAdapter'; export { getLabelmapForSegment, + getLabelToSegmentIndex, getLabelValueForSegment, getSegmentBinding, getSegmentIndexForLabelValue, @@ -41,8 +42,8 @@ export { collectCrossLayerEraseBindings, eraseLabelmapEditTransactionOverwrites, getLabelmapLayerImageId, + getLockedVoxelsOnOtherLayers, getProtectedSegmentIndicesForLayer, - hasProtectedSegmentOverwrite, resolveLabelmapLayerEditTarget, } from './labelmapEditTransaction'; export type { @@ -50,6 +51,7 @@ export type { EraseLabelmapEditTransactionOptions, LabelmapEditTransaction, LabelmapLayerEditTarget, + LockedLabelmapVoxels, ResolveLabelmapLayerEditTargetOptions, } from './labelmapEditTransaction'; export { default as LabelmapImageReferenceResolver } from './labelmapImageReferenceResolver'; diff --git a/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.spec.ts b/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.spec.ts index a5fc69f2e7..e43177e2e3 100644 --- a/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.spec.ts +++ b/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.spec.ts @@ -21,9 +21,15 @@ jest.mock('@cornerstonejs/core', () => ({ }, })); +jest.mock('../getSegmentation', () => ({ getSegmentation: jest.fn() })); + +import setValueComposition from '../../../tools/segmentation/strategies/compositions/setValue'; +import StrategyCallbacks from '../../../enums/StrategyCallbacks'; +import { getSegmentation } from '../getSegmentation'; import { beginLabelmapEditTransaction, eraseLabelmapEditTransactionOverwrites, + getLockedVoxelsOnOtherLayers, } from './labelmapEditTransaction'; import { registerLabelmap } from './labelmapLayerStore'; import { setSegmentBinding } from './labelmapSegmentBindings'; @@ -117,9 +123,48 @@ const imageData = { indexToWorld: (point: Types.Point3) => point, } as vtkImageData; +function createPrivateLayerMover(privateLayer: LabelmapLayer) { + return jest.fn((targetSegmentation: Segmentation, segmentIndex: number) => { + registerLabelmap(targetSegmentation, privateLayer); + setSegmentBinding(targetSegmentation, segmentIndex, { + labelmapId: privateLayer.labelmapId, + labelValue: 1, + }); + + return privateLayer; + }); +} + +/** Applies the brush setValue to every voxel, as a stroke that covers them. */ +function paintAllVoxels( + segmentation: Segmentation, + transaction: ReturnType, + segmentsLocked: number[], + segmentationVoxelManager: ReturnType +) { + const operationData = { + segmentationId: segmentation.segmentationId, + segmentIndex: transaction.segmentIndex, + labelValue: transaction.labelValue, + labelmapId: transaction.labelmapId, + labelmapEditTransaction: transaction, + segmentsLocked, + segmentationVoxelManager, + memo: { voxelManager: segmentationVoxelManager }, + }; + + segmentationVoxelManager.values.forEach((value, index) => + setValueComposition[StrategyCallbacks.INTERNAL_setValue]( + operationData as never, + { value, index } + ) + ); +} + describe('labelmap edit transactions', () => { beforeEach(() => { jest.clearAllMocks(); + mockCache.getImage.mockReset(); }); it('moves a segment to a private layer before editing over a protected sibling', () => { @@ -185,6 +230,111 @@ describe('labelmap edit transactions', () => { expect(transaction.protectedSegmentIndices).toEqual([]); }); + it('does not move the segment for a locked segment, and does not paint its voxels', () => { + const segmentation = createSegmentation(); + (getSegmentation as jest.Mock).mockReturnValue(segmentation); + const segmentationVoxelManager = createVoxelManager([0, 2, 0]); + const moveSegmentToPrivateLabelmap = createPrivateLayerMover({ + labelmapId: 'privateLayer', + storageKind: 'stack', + imageIds: ['private-image'], + }); + + const transaction = beginLabelmapEditTransaction(segmentation, { + segmentIndex: 1, + overwriteSegmentIndices: [], + segmentsLocked: [2], + segmentationVoxelManager, + segmentationImageData: imageData, + isInObject: () => true, + moveSegmentToPrivateLabelmap, + }); + paintAllVoxels(segmentation, transaction, [2], segmentationVoxelManager); + + expect(moveSegmentToPrivateLabelmap).not.toHaveBeenCalled(); + expect(transaction.labelmapId).toBe('layerA'); + expect( + Object.keys(segmentation.representationData.Labelmap.labelmaps) + ).toEqual(['layerA', 'layerB']); + expect(segmentationVoxelManager.values).toEqual([1, 2, 1]); + }); + + it('moves the segment for an unlocked protected segment, and keeps the voxels of a locked segment on the other layer', () => { + const segmentation = createSegmentation(); + (getSegmentation as jest.Mock).mockReturnValue(segmentation); + const { Labelmap } = segmentation.representationData; + Labelmap.labelmaps.layerA.labelToSegmentIndex[4] = 4; + Labelmap.segmentBindings[4] = { labelmapId: 'layerA', labelValue: 4 }; + // Segment 2 is locked, segment 4 is protected but not locked. + const layerAVoxels = createVoxelManager([2, 4, 0]); + const privateVoxels = createVoxelManager([0, 0, 0]); + mockCache.getImage.mockImplementation((imageId: string) => + imageId === 'layerA-image' ? { voxelManager: layerAVoxels } : undefined + ); + const privateLayer: LabelmapLayer = { + labelmapId: 'privateLayer', + storageKind: 'stack', + imageIds: ['private-image'], + }; + const moveSegmentToPrivateLabelmap = createPrivateLayerMover(privateLayer); + + const transaction = beginLabelmapEditTransaction(segmentation, { + segmentIndex: 1, + overwriteSegmentIndices: [], + segmentsLocked: [2], + segmentationVoxelManager: layerAVoxels, + segmentationImageData: imageData, + isInObject: () => true, + moveSegmentToPrivateLabelmap, + }); + transaction.lockedVoxelsOnOtherLayers = getLockedVoxelsOnOtherLayers( + segmentation, + transaction.labelmapId, + [2], + { imageId: 'private-image', sourceLayer: transaction.activeLayer } + ); + paintAllVoxels(segmentation, transaction, [2], privateVoxels); + + expect(moveSegmentToPrivateLabelmap).toHaveBeenCalledTimes(1); + expect(transaction.labelmapId).toBe('privateLayer'); + expect(privateVoxels.values).toEqual([0, 1, 1]); + expect(layerAVoxels.values).toEqual([2, 4, 0]); + }); + + it('reads the locked layer image of the same referenced image, when the stack layers list them in another order', () => { + const segmentation = createSegmentation(); + const privateLayer: LabelmapLayer = { + labelmapId: 'privateLayer', + storageKind: 'stack', + imageIds: ['private-0', 'private-1'], + referencedImageIds: ['ref-0', 'ref-1'], + }; + registerLabelmap(segmentation, privateLayer); + const { layerA } = segmentation.representationData.Labelmap.labelmaps; + layerA.imageIds = ['layerA-1', 'layerA-0']; + layerA.referencedImageIds = ['ref-1', 'ref-0']; + const layerAImages = { + 'layerA-0': createVoxelManager([2, 0]), + 'layerA-1': createVoxelManager([0, 0]), + }; + mockCache.getImage.mockImplementation((imageId: string) => + layerAImages[imageId] + ? { voxelManager: layerAImages[imageId] } + : undefined + ); + + const lockedVoxels = getLockedVoxelsOnOtherLayers( + segmentation, + 'privateLayer', + [2], + { imageId: 'private-0', sourceLayer: privateLayer } + ); + + expect(lockedVoxels).toHaveLength(1); + expect(lockedVoxels[0].voxelManager).toBe(layerAImages['layerA-0']); + expect([...lockedVoxels[0].labelValues]).toEqual([2]); + }); + it('collects cross-layer erase bindings after a protected overwrite move', () => { const segmentation = createSegmentation(); const segmentationVoxelManager = createVoxelManager([2]); diff --git a/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.ts b/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.ts index 6f6e829e26..8002f4e8ec 100644 --- a/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.ts +++ b/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.ts @@ -7,6 +7,10 @@ import type { SegmentLabelmapBindingState, } from '../../../types/LabelmapTypes'; import { getLabelmap, getOrCreateLabelmapVolume } from './labelmapLayerStore'; +import { + getLabelmapImageIdsForReferencedImageId, + getReferencedImageIdForImageIndex, +} from './labelmapImageIdMapping'; import { getSegmentBinding, getSegmentIndexForLabelValue, @@ -32,6 +36,9 @@ type MoveSegmentToPrivateLabelmap = ( type BeginLabelmapEditTransactionOptions = { segmentIndex: number; overwriteSegmentIndices?: number[]; + /** Locked segments. The edit skips their voxels, so they never cause a + * move of the edited segment to a private layer. */ + segmentsLocked?: number[]; segmentationVoxelManager?: Types.IVoxelManager; segmentationImageData?: vtkImageData; isInObject?: (point: Types.Point3) => boolean; @@ -53,6 +60,15 @@ type LabelmapEditTransaction = { * (layer registration + bulk voxel move + binding change), for recording * on the stroke's memo. */ moveStep?: LabelmapRestoreStep; + /** The locked segments on layers other than the edited layer. The edit + * skips a voxel that one of these layers holds. */ + lockedVoxelsOnOtherLayers?: LockedLabelmapVoxels[]; +}; + +/** The voxels of the locked segments on one layer. */ +type LockedLabelmapVoxels = { + voxelManager: Types.IVoxelManager; + labelValues: Set; }; type ResolveLabelmapLayerEditTargetOptions = { @@ -202,10 +218,15 @@ function beginLabelmapEditTransaction( overwriteSegmentIndices ); + // A locked segment means "do not paint here": setValue skips its voxels, + // so it is not an overlap and does not move the segment to a private layer. + const segmentsLocked = options.segmentsLocked ?? []; const shouldMoveSegment = hasProtectedSegmentOverwrite( segmentation, labelmapId, - protectedSegmentIndices, + protectedSegmentIndices.filter( + (protectedSegmentIndex) => !segmentsLocked.includes(protectedSegmentIndex) + ), options.segmentationVoxelManager, { segmentationImageData: options.segmentationImageData, @@ -258,6 +279,84 @@ function beginLabelmapEditTransaction( }; } +/** + * Finds the locked segments that are on a layer other than `labelmapId`. An + * edit of `labelmapId` reads the voxels of these layers, so that it does not + * paint over a locked segment after a move to a private layer. + */ +function getLockedVoxelsOnOtherLayers( + segmentation: Segmentation, + labelmapId: string | undefined, + segmentsLocked: number[] = [], + targetOptions: ResolveLabelmapLayerEditTargetOptions = {} +): LockedLabelmapVoxels[] { + const labelValuesByLayer = new Map>(); + + for (const lockedSegmentIndex of segmentsLocked) { + const binding = getSegmentBinding(segmentation, lockedSegmentIndex); + if (!binding || binding.labelmapId === labelmapId) { + continue; + } + + if (!labelValuesByLayer.has(binding.labelmapId)) { + labelValuesByLayer.set(binding.labelmapId, new Set()); + } + labelValuesByLayer.get(binding.labelmapId).add(binding.labelValue); + } + + // A stack layer can list the referenced images in another order than the + // edited layer, so its image is found by the referenced image, not by index. + const { sourceLayer: editedLayer, imageId: editedImageId } = targetOptions; + const editedImageIndex = editedLayer?.imageIds?.indexOf(editedImageId) ?? -1; + const referencedImageId = + editedLayer?.referencedImageIds && editedImageIndex >= 0 + ? getReferencedImageIdForImageIndex(editedLayer, editedImageIndex) + : undefined; + const isVolumeEdit = + targetOptions.preferVolume || + targetOptions.viewport instanceof BaseVolumeViewport; + + const lockedVoxels: LockedLabelmapVoxels[] = []; + labelValuesByLayer.forEach((labelValues, lockedLabelmapId) => { + const layer = getLabelmap(segmentation, lockedLabelmapId); + if (!layer) { + return; + } + + let layerTargetOptions = targetOptions; + if ( + !isVolumeEdit && + !layer.volumeId && + referencedImageId && + layer.referencedImageIds + ) { + const [lockedImageId] = getLabelmapImageIdsForReferencedImageId( + layer, + referencedImageId + ); + if (!lockedImageId) { + // The layer has no image for this slice, so nothing is locked here. + return; + } + layerTargetOptions = { + ...targetOptions, + imageId: lockedImageId, + sourceLayer: layer, + }; + } + + const { voxelManager } = resolveLabelmapLayerEditTarget( + layer, + layerTargetOptions + ); + if (voxelManager) { + lockedVoxels.push({ voxelManager, labelValues }); + } + }); + + return lockedVoxels; +} + function getViewportImageIds(viewport?: Types.IViewport): string[] { const stackViewport = viewport as Types.IStackViewport; @@ -475,6 +574,7 @@ export { collectCrossLayerEraseBindings, eraseLabelmapEditTransactionOverwrites, getLabelmapLayerImageId, + getLockedVoxelsOnOtherLayers, getProtectedSegmentIndicesForLayer, hasProtectedSegmentOverwrite, resolveLabelmapLayerEditTarget, @@ -485,5 +585,6 @@ export type { EraseLabelmapEditTransactionOptions, LabelmapEditTransaction, LabelmapLayerEditTarget, + LockedLabelmapVoxels, ResolveLabelmapLayerEditTargetOptions, }; diff --git a/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapSegmentBindings.ts b/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapSegmentBindings.ts index 2156a78c00..bb2d083371 100644 --- a/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapSegmentBindings.ts +++ b/packages/tools/src/stateManagement/segmentation/labelmapModel/labelmapSegmentBindings.ts @@ -72,6 +72,20 @@ function getSegmentIndexForLabelValue( return layer.labelToSegmentIndex?.[labelValue] ?? labelValue; } +/** + * A copy of the label value to segment index map of one layer, to map many + * voxels at a time. A label value that is not in the map is its own segment + * index. + */ +function getLabelToSegmentIndex( + segmentation: Segmentation, + labelmapId: string +): Record { + return { + ...(getLabelmap(segmentation, labelmapId)?.labelToSegmentIndex ?? {}), + }; +} + function getLabelValueForSegment( segmentation: Segmentation, segmentIndex: number @@ -129,6 +143,7 @@ function removeSegmentBinding( export { getLabelmapForSegment, + getLabelToSegmentIndex, getLabelValueForSegment, getSegmentBinding, getSegmentIndexForLabelValue, diff --git a/packages/tools/src/stateManagement/segmentation/labelmapModel/privateLabelmap.spec.ts b/packages/tools/src/stateManagement/segmentation/labelmapModel/privateLabelmap.spec.ts index 585f07b3e3..aac6b6bf7a 100644 --- a/packages/tools/src/stateManagement/segmentation/labelmapModel/privateLabelmap.spec.ts +++ b/packages/tools/src/stateManagement/segmentation/labelmapModel/privateLabelmap.spec.ts @@ -26,7 +26,13 @@ jest.mock('../triggerSegmentationEvents', () => ({ triggerSegmentationDataModified: jest.fn(), })); +jest.mock('../getSegmentation', () => ({ getSegmentation: jest.fn() })); + +import { utilities } from '@cornerstonejs/core'; import { moveSegmentToPrivateLabelmap } from './privateLabelmap'; +import { moveSegmentToPrivateLabelmap as moveSegmentToNewLayer } from '../helpers/moveSegmentToPrivateLabelmap'; +import { getSegmentation } from '../getSegmentation'; +import { triggerSegmentationDataModified } from '../triggerSegmentationEvents'; import { beginLabelmapEditTransaction, eraseLabelmapEditTransactionOverwrites, @@ -279,6 +285,38 @@ describe('overlap undo/redo bookkeeping', () => { }); }); + describe('segmentation.helpers.moveSegmentToPrivateLabelmap', () => { + it('moves a segment to a new layer on request, and undo puts it back', () => { + const segmentation = createSegmentation(); + (getSegmentation as jest.Mock).mockReturnValue(segmentation); + const layerAVoxels = createVoxelManager([1, 2, 2, 0]); + wireImages({ 'layerA-image': layerAVoxels }); + const { DefaultHistoryMemo } = utilities.HistoryMemo; + + const labelmapId = moveSegmentToNewLayer('segmentation', 2, { + recordHistory: true, + }); + + expect(labelmapId).not.toBe('layerA'); + expect(getSegmentBinding(segmentation, 2)).toEqual({ + labelmapId, + labelValue: 1, + }); + expect(layerAVoxels.values).toEqual([1, 0, 0, 0]); + expect(triggerSegmentationDataModified).toHaveBeenCalledWith( + 'segmentation' + ); + + DefaultHistoryMemo.undo(); + expect(getSegmentBinding(segmentation, 2)).toEqual({ + labelmapId: 'layerA', + labelValue: 2, + }); + expect(layerAVoxels.values).toEqual([1, 2, 2, 0]); + expect(getLabelmap(segmentation, labelmapId)).toBeUndefined(); + }); + }); + describe('cross-layer erase recording', () => { it('reports erased voxels through crossLayerEraseCallback so they can be restored', () => { const segmentation = createSegmentation(); diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts b/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts index b80aff0908..5fee4bc93d 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts @@ -18,7 +18,6 @@ import { forEachBrushFillVoxel } from '../utils/brushVoxelSlab'; export default { [StrategyCallbacks.Fill]: (operationData: InitializedOperationData) => { const { - segmentsLocked, segmentationImageData, segmentationVoxelManager, brushStrategy, @@ -31,8 +30,8 @@ export default { const callback = isWithinThreshold ? (data) => { - const { value, index } = data; - if (segmentsLocked.includes(value) || !isWithinThreshold(index)) { + // setValue skips locked segments by segment index, not label value. + if (!isWithinThreshold(data.index)) { return; } setValue(operationData, data); diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts b/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts index 340fd82603..66a83e5a9e 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts @@ -26,6 +26,7 @@ export default { labelValue, labelmapId, segmentationId, + labelmapEditTransaction, } = operationData; const existingValue = segmentationVoxelManager.getAtIndex(index); @@ -40,6 +41,18 @@ export default { return; } + // A locked segment on another layer of the segmentation also stops the + // brush, for example after a move of the edited segment to a private layer. + if ( + writeValue && + labelmapEditTransaction?.lockedVoxelsOnOtherLayers?.some( + ({ voxelManager, labelValues }) => + labelValues.has(voxelManager.getAtIndex(index)) + ) + ) { + return; + } + if ( !centerSegmentIndexInfo && existingValue === (labelValue ?? segmentIndex) diff --git a/packages/tools/src/tools/segmentation/strategies/utils/labelmapOverlap.ts b/packages/tools/src/tools/segmentation/strategies/utils/labelmapOverlap.ts index dee48f4503..e59c8c44a6 100644 --- a/packages/tools/src/tools/segmentation/strategies/utils/labelmapOverlap.ts +++ b/packages/tools/src/tools/segmentation/strategies/utils/labelmapOverlap.ts @@ -1,6 +1,7 @@ import { getSegmentation } from '../../../../stateManagement/segmentation/getSegmentation'; import { beginLabelmapEditTransaction, + getLockedVoxelsOnOtherLayers, resolveLabelmapLayerEditTarget, } from '../../../../stateManagement/segmentation/helpers/labelmapSegmentationState'; import type { InitializedOperationData } from '../BrushStrategy'; @@ -21,6 +22,7 @@ function prepareOverlapOperationData( const transaction = beginLabelmapEditTransaction(segmentation, { segmentIndex: operationData.segmentIndex, overwriteSegmentIndices: operationData.overwriteSegmentIndices, + segmentsLocked: operationData.segmentsLocked, segmentationVoxelManager: operationData.segmentationVoxelManager, segmentationImageData: operationData.segmentationImageData, isInObject: operationData.isInObject, @@ -51,6 +53,19 @@ function prepareOverlapOperationData( operationData.segmentationVoxelManager = target.voxelManager; } } + + // After a move, the locked segments are on other layers than the edited + // layer. setValue reads those layers too. + transaction.lockedVoxelsOnOtherLayers = getLockedVoxelsOnOtherLayers( + segmentation, + transaction.labelmapId, + operationData.segmentsLocked, + { + viewport: operationData.viewport, + imageId: operationData.imageId, + sourceLayer: transaction.activeLayer, + } + ); } export { eraseCrossLayerOverwrites, prepareOverlapOperationData }; diff --git a/packages/tools/src/tools/segmentation/strategies/utils/segmentSeparation.ts b/packages/tools/src/tools/segmentation/strategies/utils/segmentSeparation.ts deleted file mode 100644 index 5de013c537..0000000000 --- a/packages/tools/src/tools/segmentation/strategies/utils/segmentSeparation.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { getSegmentation } from '../../../../stateManagement/segmentation/getSegmentation'; -import { - beginLabelmapEditTransaction, - resolveLabelmapLayerEditTarget, -} from '../../../../stateManagement/segmentation/helpers/labelmapSegmentationState'; -import type { InitializedOperationData } from '../BrushStrategy'; - -function separateSegmentIfNeeded( - operationData: InitializedOperationData -): void { - const segmentation = getSegmentation(operationData.segmentationId); - - if (!segmentation || !operationData.segmentIndex) { - return; - } - - const transaction = beginLabelmapEditTransaction(segmentation, { - segmentIndex: operationData.segmentIndex, - overwriteSegmentIndices: operationData.overwriteSegmentIndices, - segmentationVoxelManager: operationData.segmentationVoxelManager, - segmentationImageData: operationData.segmentationImageData, - isInObject: operationData.isInObject, - isInObjectBoundsIJK: operationData.isInObjectBoundsIJK, - }); - - operationData.labelmapEditTransaction = transaction; - operationData.labelmapId = transaction.labelmapId; - operationData.labelValue = transaction.labelValue; - operationData.crossLayerEraseBindings = transaction.crossLayerEraseBindings; - - if (!transaction.movedSegment || !transaction.activeLayer) { - return; - } - - const target = resolveLabelmapLayerEditTarget(transaction.activeLayer, { - viewport: operationData.viewport, - imageId: operationData.imageId, - sourceLayer: transaction.sourceLayer, - }); - - if (target.imageId) { - operationData.imageId = target.imageId; - } - - if (target.imageData) { - operationData.segmentationImageData = target.imageData; - } - - if (target.voxelManager) { - operationData.segmentationVoxelManager = target.voxelManager; - } -} - -export { separateSegmentIfNeeded };