diff --git a/common/reviews/api/ai.api.md b/common/reviews/api/ai.api.md index f7e0984b46..0f65370275 100644 --- a/common/reviews/api/ai.api.md +++ b/common/reviews/api/ai.api.md @@ -34,13 +34,17 @@ export class ONNXSegmentationController { promptAnnotationTypes: any; models: any; modelName: any; - previewToolType: string; + islandFillOptions: any; }); // (undocumented) + acceptPreview(element: any): void; + // (undocumented) protected annotationModifiedListener: (_event?: any) => void; // (undocumented) protected annotationsNeedUpdating: boolean; // (undocumented) + static BoxPrompt: string; + // (undocumented) protected boxRadius: number; // (undocumented) cacheImageEncodings(current?: any, offset?: number, length?: number): any; @@ -96,6 +100,11 @@ export class ONNXSegmentationController { // (undocumented) protected isGpuInUse: boolean; // (undocumented) + protected islandFillOptions: { + maxInternalRemove: number; + fillInternalEdge: boolean; + }; + // (undocumented) protected load(): Promise; // (undocumented) loadModels(models: any, imageSession?: any): Promise; @@ -149,19 +158,23 @@ export class ONNXSegmentationController { // (undocumented) modelWidth: number; // (undocumented) - protected previewToolType: string; + protected pCutoff: number; // (undocumented) protected promptAnnotationTypes: string[]; // (undocumented) + rejectPreview(element: any): void; + // (undocumented) restoreImageEncoding(session: any, imageId: any): Promise; // (undocumented) protected runDecode(): Promise; // (undocumented) + setPCutoff(cutoff: number): void; + // (undocumented) protected sharedImageEncoding: any; // (undocumented) storeImageEncoding(session: any, imageId: any, data: any): Promise; // (undocumented) - protected tool: any; + tool: any; // (undocumented) tryLoad(options?: { resetImage: boolean; diff --git a/common/reviews/api/tools.api.md b/common/reviews/api/tools.api.md index 428684dcdb..420e097d12 100644 --- a/common/reviews/api/tools.api.md +++ b/common/reviews/api/tools.api.md @@ -556,7 +556,9 @@ export abstract class AnnotationTool extends AnnotationDisplayTool { // (undocumented) abstract isPointNearTool(element: HTMLDivElement, annotation: Annotation, canvasCoords: Types_2.Point2, proximity: number, interactionType: string): boolean; // (undocumented) - isSuvScaled(viewport: Types_2.IStackViewport | Types_2.IVolumeViewport, targetId: string, imageId?: string): boolean; + static isSuvScaled(viewport: Types_2.IStackViewport | Types_2.IVolumeViewport, targetId: string, imageId?: string): boolean; + // (undocumented) + isSuvScaled: typeof AnnotationTool.isSuvScaled; // (undocumented) mouseMoveCallback: (evt: EventTypes_2.MouseMoveEventType, filteredAnnotations?: Annotations) => boolean; // (undocumented) @@ -891,8 +893,6 @@ export class BrushTool extends LabelmapBaseTool { // (undocumented) previewCallback: () => void; // (undocumented) - prg: any; - // (undocumented) renderAnnotation(enabledElement: Types_2.IEnabledElement, svgDrawingHelper: SVGDrawingHelper): void; // (undocumented) static toolName: any; @@ -2482,6 +2482,15 @@ function extractWindowLevelRegionToolData(viewport: any): { color: any; }; +// @public (undocumented) +const fillInsideCircle: (enabledElement: any, operationData: any) => unknown; + +// @public (undocumented) +const fillInsideRectangle: (enabledElement: any, operationData: any) => unknown; + +// @public (undocumented) +function fillOutsideCircle(): void; + // @public (undocumented) function filterAnnotationsForDisplay(viewport: Types_2.IViewport, annotations: Annotations, filterOptions?: Types_2.ReferenceCompatibleOptions): Annotations; @@ -2901,18 +2910,12 @@ declare namespace growCut { // @public (undocumented) type GrowCutBoundingBoxOptions = GrowCutOptions & { - positiveSeedValue?: number; - negativeSeedValue?: number; negativePixelRange?: [number, number]; positivePixelRange?: [number, number]; }; // @public (undocumented) type GrowCutOneClickOptions = GrowCutOptions & { - positiveSeedValue?: number; - negativeSeedValue?: number; - positiveSeedVariance?: number; - negativeSeedVariance?: number; subVolumePaddingPercentage?: number | [number, number, number]; subVolumeMinPadding?: number | [number, number, number]; }; @@ -3071,6 +3074,9 @@ type InteractionStartType = Types_2.CustomEventType // @public (undocumented) type InteractionTypes = 'Mouse' | 'Touch'; +// @public (undocumented) +function internalAddRepresentationData({ segmentationId, type, data, }: AddRepresentationData): void; + // @public (undocumented) type InterpolationROIAnnotation = ContourAnnotation & ContourSegmentationAnnotationData & { metadata: { @@ -3147,6 +3153,36 @@ interface ISculptToolShape { updateToolSize(canvasCoords: Types_2.Point2, viewport: Types_2.IViewport, activeAnnotation: ContourAnnotation): void; } +// @public (undocumented) +class IslandRemoval { + constructor(options?: { + maxInternalRemove?: number; + fillInternalEdge?: boolean; + }); + // (undocumented) + static covers(rle: any, row: any): boolean; + // (undocumented) + fillSegments: (index: number) => boolean; + // (undocumented) + floodFillSegmentIsland(): number; + // (undocumented) + initialize(viewport: any, segmentationVoxels: any, options: any): boolean; + // (undocumented) + previewSegmentIndex: number; + // (undocumented) + previewVoxelManager: Types_2.VoxelManager; + // (undocumented) + removeExternalIslands(): void; + // (undocumented) + removeInternalIslands(): number[]; + // (undocumented) + segmentIndex: number; + // (undocumented) + segmentSet: Types_2.RLEVoxelMap; + // (undocumented) + selectedPoints: Types_2.Point3[]; +} + // @public (undocumented) function isObject(value: any): boolean; @@ -3336,6 +3372,173 @@ type KeyUpEventDetail = KeyDownEventDetail; // @public (undocumented) type KeyUpEventType = Types_2.CustomEventType; +// @public (undocumented) +export class LabelmapBaseTool extends BaseTool { + constructor(toolProps: any, defaultToolProps: any); + // (undocumented) + acceptPreview(element?: HTMLDivElement): void; + // (undocumented) + addPreview(element?: HTMLDivElement, options?: { + acceptReject: boolean; + }): unknown; + // (undocumented) + createEditData(element: any): { + volumeId: string; + referencedVolumeId: any; + segmentsLocked: number[] | []; + imageId?: undefined; + override?: undefined; + } | { + imageId: string; + segmentsLocked: number[] | []; + override: { + voxelManager: Types_2.IVoxelManager | Types_2.IVoxelManager; + imageData: vtkImageData; + }; + volumeId?: undefined; + referencedVolumeId?: undefined; + } | { + imageId: string; + segmentsLocked: number[] | []; + volumeId?: undefined; + referencedVolumeId?: undefined; + override?: undefined; + }; + // (undocumented) + protected createHoverData(element: any, centerCanvas?: any): { + brushCursor: { + metadata: { + viewPlaneNormal: Types_2.Point3; + viewUp: Types_2.Point3; + FrameOfReferenceUID: string; + referencedImageId: string; + toolName: string; + segmentColor: Types_2.Color; + }; + data: {}; + }; + centerCanvas: any; + segmentIndex: number; + viewport: Types_2.IStackViewport | VolumeViewport; + segmentationId: string; + segmentColor: Types_2.Color; + viewportIdsToRender: string[]; + }; + // (undocumented) + createMemo(segmentId: string, segmentationVoxelManager: any, preview: any): LabelmapMemo.LabelmapMemo; + // (undocumented) + protected _editData: { + override: { + voxelManager: Types_2.IVoxelManager; + imageData: vtkImageData; + }; + segmentsLocked: number[]; + imageId?: string; + imageIds?: string[]; + volumeId?: string; + referencedVolumeId?: string; + } | null; + // (undocumented) + protected getActiveSegmentationData(viewport: any): { + segmentIndex: number; + segmentationId: string; + segmentColor: Types_2.Color; + }; + // (undocumented) + protected getOperationData(element?: any): { + points: any; + segmentIndex: number; + previewColors: any; + viewPlaneNormal: any; + toolGroupId: string; + segmentationId: string; + viewUp: any; + strategySpecificConfiguration: any; + preview: unknown; + createMemo: (segmentId: string, segmentationVoxelManager: any, preview: any) => LabelmapMemo.LabelmapMemo; + override: { + voxelManager: Types_2.IVoxelManager; + imageData: vtkImageData; + }; + segmentsLocked: number[]; + imageId?: string; + imageIds?: string[]; + volumeId?: string; + referencedVolumeId?: string; + } | { + points: any; + segmentIndex: number; + previewColors: any; + viewPlaneNormal: any; + toolGroupId: string; + segmentationId: string; + viewUp: any; + strategySpecificConfiguration: any; + preview: unknown; + createMemo: (segmentId: string, segmentationVoxelManager: any, preview: any) => LabelmapMemo.LabelmapMemo; + volumeId: string; + referencedVolumeId: any; + segmentsLocked: number[] | []; + imageId?: undefined; + override?: undefined; + } | { + points: any; + segmentIndex: number; + previewColors: any; + viewPlaneNormal: any; + toolGroupId: string; + segmentationId: string; + viewUp: any; + strategySpecificConfiguration: any; + preview: unknown; + createMemo: (segmentId: string, segmentationVoxelManager: any, preview: any) => LabelmapMemo.LabelmapMemo; + imageId: string; + segmentsLocked: number[] | []; + override: { + voxelManager: Types_2.IVoxelManager | Types_2.IVoxelManager; + imageData: vtkImageData; + }; + volumeId?: undefined; + referencedVolumeId?: undefined; + } | { + points: any; + segmentIndex: number; + previewColors: any; + viewPlaneNormal: any; + toolGroupId: string; + segmentationId: string; + viewUp: any; + strategySpecificConfiguration: any; + preview: unknown; + createMemo: (segmentId: string, segmentationVoxelManager: any, preview: any) => LabelmapMemo.LabelmapMemo; + imageId: string; + segmentsLocked: number[] | []; + volumeId?: undefined; + referencedVolumeId?: undefined; + override?: undefined; + }; + // (undocumented) + protected _hoverData?: { + brushCursor: any; + segmentationId: string; + segmentIndex: number; + segmentColor: [number, number, number, number]; + viewportIdsToRender: string[]; + centerCanvas?: Array; + viewport: Types_2.IViewport; + }; + // (undocumented) + static previewData?: PreviewData; + // (undocumented) + protected get _previewData(): PreviewData; + // (undocumented) + rejectPreview(element?: HTMLDivElement): void; + // (undocumented) + static viewportContoursToLabelmap(viewport: Types_2.IViewport, options?: { + removeContours: boolean; + }): void; +} + declare namespace LabelmapMemo { export { createLabelmapMemo, @@ -3779,6 +3982,10 @@ type NamedStatistics = { name: 'circumference'; }; pointsInShape?: Types_2.IPointsManager; + maxIJKs?: Array<{ + value: number; + pointIJK: Types_2.Point3; + }>; array: Statistics[]; }; @@ -3797,6 +4004,26 @@ type NormalizedMouseEventType = Types_2.CustomEventType; // @public (undocumented) type NormalizedTouchEventType = Types_2.CustomEventType; +// @public (undocumented) +function normalizeViewportPlane(viewport: Types_2.IViewport, boundsIJK: Types_2.BoundsIJK): { + toIJK: any; + boundsIJKPrime: any; + fromIJK: any; + error: string; +} | { + boundsIJKPrime: any; + toIJK: (ijkPrime: any) => any; + fromIJK: (ijk: any) => any; + type: string; + error?: undefined; +} | { + boundsIJKPrime: any; + toIJK: ([j, k, i]: [any, any, any]) => any[]; + fromIJK: ([i, j, k]: [any, any, any]) => any[]; + type: string; + error?: undefined; +}; + declare namespace orientation_2 { export { getOrientationStringLPS, @@ -4662,7 +4889,9 @@ export class ReferenceLinesTool extends AnnotationDisplayTool { export class RegionSegmentPlusTool extends GrowCutBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) - protected getGrowCutLabelmap(): Promise; + protected getGrowCutLabelmap(growCutData: any): Promise; + // (undocumented) + protected getRemoveIslandData(growCutData: RegionSegmentPlusToolData): RemoveIslandData; // (undocumented) protected growCutData: RegionSegmentPlusToolData | null; // (undocumented) @@ -4675,7 +4904,7 @@ export class RegionSegmentPlusTool extends GrowCutBaseTool { export class RegionSegmentTool extends GrowCutBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) - protected getGrowCutLabelmap(): Promise; + protected getGrowCutLabelmap(growCutData: any): Promise; // (undocumented) protected growCutData: RegionSegmentToolData | null; // (undocumented) @@ -4933,7 +5162,9 @@ declare namespace segmentation { helpers, polySegManager as polySeg, removeSegment, - getLabelmapImageIds + getLabelmapImageIds, + internalAddRepresentationData as addRepresentationData, + strategies } } export { segmentation } @@ -4963,7 +5194,8 @@ declare namespace segmentation_2 { getHoveredContourSegmentationAnnotation, getBrushToolInstances, growCut, - LabelmapMemo + LabelmapMemo, + IslandRemoval } } @@ -5140,7 +5372,7 @@ declare namespace selection { } // @public (undocumented) -function setActiveSegmentation(viewportId: string, segmentationId: string, suppressEvent?: boolean): void; +function setActiveSegmentation(viewportId: string, segmentationId: string): void; // @public (undocumented) function setActiveSegmentIndex(segmentationId: string, segmentIndex: number): void; @@ -5492,6 +5724,15 @@ type Statistics = { // @public (undocumented) function stopClip(element: HTMLDivElement, options?: StopClipOptions): void; +declare namespace strategies { + export { + fillInsideRectangle, + thresholdInsideRectangle, + fillInsideCircle, + fillOutsideCircle + } +} + // @public (undocumented) enum StrategyCallbacks { // (undocumented) @@ -5686,6 +5927,9 @@ type TextBoxHandle = { worldPosition: Types_2.Point3; }; +// @public (undocumented) +const thresholdInsideRectangle: (enabledElement: any, operationData: any) => unknown; + // @public (undocumented) function thresholdSegmentationByRange(segmentationVolume: Types_2.IImageVolume, segmentationIndex: number, thresholdVolumeInformation: ThresholdInformation[], overlapType: number): Types_2.IImageVolume; @@ -6296,7 +6540,9 @@ declare namespace utilities { contourSegmentation, annotationHydration, getClosestImageIdForStackViewport, - pointInSurroundingSphereCallback + pointInSurroundingSphereCallback, + normalizeViewportPlane, + IslandRemoval } } export { utilities } @@ -6475,13 +6721,21 @@ class VolumetricCalculator extends BasicStatsCalculator_2 { spacing?: number; unit?: string; }): NamedStatistics; + // (undocumented) + static statsCallback(data: { + value: number | Types_2.RGB; + pointLPS?: Types_2.Point3; + pointIJK?: Types_2.Point3; + }): void; } // @public (undocumented) export class WholeBodySegmentTool extends GrowCutBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) - protected getGrowCutLabelmap(): Promise; + protected getGrowCutLabelmap(growCutData: any): Promise; + // (undocumented) + protected getRemoveIslandData(): RemoveIslandData; // (undocumented) protected growCutData: WholeBodySegmentToolData | null; // (undocumented) diff --git a/jest.config.base.js b/jest.config.base.js index 208b35794e..944a17e4b7 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -7,7 +7,7 @@ module.exports = { // roots: ['/src'], testMatch: ['/test/**/*.jest.js'], testPathIgnorePatterns: ['/node_modules/'], - testEnvironment: 'jsdom', + testEnvironment: require.resolve('./utils/fixJSDOMJest.js'), moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], transformIgnorePatterns: ['/node_modules/(?!@kitware/.*)'], moduleNameMapper: { diff --git a/packages/ai/examples/SAMClientSide/index.ts b/packages/ai/examples/SAMClientSide/index.ts index 0b499b1692..9bef791ee0 100644 --- a/packages/ai/examples/SAMClientSide/index.ts +++ b/packages/ai/examples/SAMClientSide/index.ts @@ -1,4 +1,3 @@ -import type { Types } from '@cornerstonejs/core'; import { Enums, RenderingEngine, @@ -8,56 +7,27 @@ import { } from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; import { - addButtonToToolbar, - addDropdownToToolbar, createImageIdsAndCacheMetaData, initDemo, + addDropdownToToolbar, + addButtonToToolbar, setTitleAndDescription, - addManipulationBindings, getLocalUrl, - addSegmentIndexDropdown, - labelmapTools, - annotationTools, - addSliderToToolbar, } from '../../../../utils/demo/helpers'; - import { ONNXSegmentationController } from '@cornerstonejs/ai'; -// This is for debugging purposes -console.warn( - 'Click on index.ts to open source code for this example --------->' +const { ViewportType, OrientationAxis } = Enums; +const { MouseBindings, SegmentationRepresentations, Events } = + cornerstoneTools.Enums; +const { segmentation } = cornerstoneTools; + +setTitleAndDescription( + 'Basic Single-Viewport AI Segmentation', + 'This example demonstrates a simplified setup of a single viewport that can switch between stack and sagittal views. It includes minimal AI segmentation tools (MarkerInclude, MarkerExclude, BoxPrompt) and basic navigation tools (Pan, Zoom, Stack Scroll). Logging is also retained to show decoding and inference times.' ); -const { - ToolGroupManager, - Enums: csToolsEnums, - segmentation, -} = cornerstoneTools; - -const configuration = { - preview: { - enabled: true, - previewColors: { - 0: [255, 255, 255, 128], - 1: [0, 255, 255, 255], - }, - }, -}; +// Logging elements and function const logs = []; - -addSliderToToolbar({ - title: 'P Cutoff', - step: 1, - range: [1, 255], - defaultValue: 64, - onSelectedValueChange: (value) => { - ai.setPCutoff(value); - }, -}); - -/** - * Log to the specified logger. - */ function mlLogger(logName, ...args) { console.log(logName, ...args); const element = document.getElementById(logName); @@ -75,6 +45,7 @@ function mlLogger(logName, ...args) { element.innerText = args.join(' '); } +// Model configuration for segmentation const models = { sam_b: [ { @@ -93,134 +64,95 @@ const models = { }; const ai = new ONNXSegmentationController({ - listeners: [mlLogger], models, modelName: 'sam_b', + listeners: [mlLogger], }); -const { ViewportType, Events } = Enums; -const { KeyboardBindings, MouseBindings } = csToolsEnums; -const { style: toolStyle } = cornerstoneTools.annotation.config; +const toolGroupId = 'DEFAULT_TOOLGROUP_ID'; +const renderingEngineId = 'myRenderingEngine'; const volumeId = 'volumeId'; -// Define various constants for the tool definition -const toolGroupId = 'DEFAULT_TOOLGROUP_ID'; -const volumeToolGroupId = 'VOLUME_TOOLGROUP_ID'; - -const segmentationId = `SEGMENTATION_ID`; -// Stores information on whether the AI data is encoded in cache -let cached; -let toolForPreview; - -const toolMap = new Map(annotationTools); -const defaultTool = ONNXSegmentationController.MarkerInclude; -toolMap.set(defaultTool, { - baseTool: cornerstoneTools.ProbeTool.toolName, - configuration: { - ...configuration, +let renderingEngine; +let viewport; +let activeViewport; +const currentViewportType = ViewportType.STACK; + +// Tools to include: MarkerInclude, MarkerExclude, BoxPrompt, plus pan/zoom/scroll +const MarkerIncludeToolName = ONNXSegmentationController.MarkerInclude; +const MarkerExcludeToolName = ONNXSegmentationController.MarkerExclude; +const BoxPromptToolName = ONNXSegmentationController.BoxPrompt; + +// Add the base tools we need +cornerstoneTools.addTool(cornerstoneTools.PanTool); +cornerstoneTools.addTool(cornerstoneTools.ZoomTool); +cornerstoneTools.addTool(cornerstoneTools.StackScrollTool); +cornerstoneTools.addTool(cornerstoneTools.ProbeTool); // Needed as a base for MarkerInclude/Exclude +cornerstoneTools.addTool(cornerstoneTools.RectangleROITool); // Base for BoxPrompt + +// Create a tool group and add the needed tools +const toolGroup = + cornerstoneTools.ToolGroupManager.createToolGroup(toolGroupId); + +toolGroup.addTool(cornerstoneTools.ZoomTool.toolName); +toolGroup.addTool(cornerstoneTools.StackScrollTool.toolName); +toolGroup.addTool(cornerstoneTools.PanTool.toolName); +// MarkerInclude - a probe variant +toolGroup.addToolInstance( + MarkerIncludeToolName, + cornerstoneTools.ProbeTool.toolName, + { getTextLines: () => null, - }, + } +); +toolGroup.setToolActive(MarkerIncludeToolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], }); -toolStyle.getDefaultToolStyles()[defaultTool] = { color: 'blue' }; -const excludeTool = ONNXSegmentationController.MarkerExclude; -toolMap.set(excludeTool, { - baseTool: cornerstoneTools.ProbeTool.toolName, - bindings: [{ mouseButton: MouseBindings.Secondary }], - configuration: { - ...configuration, +// MarkerExclude - a probe variant with right-click +toolGroup.addToolInstance( + MarkerExcludeToolName, + cornerstoneTools.ProbeTool.toolName, + { getTextLines: () => null, - }, -}); -toolStyle.getDefaultToolStyles()[excludeTool] = { - color: 'pink', - colorSelected: 'red', -}; - -for (const [key, value] of labelmapTools.toolMap) { - toolMap.set(key, value); -} - -toolMap.set(cornerstoneTools.ZoomTool.toolName, { - bindings: [ - { - mouseButton: MouseBindings.Auxiliary, - modifierKey: KeyboardBindings.Ctrl, - }, - ], -}); - -// ======== Set up page ======== // - -setTitleAndDescription( - 'Segmentation AI', - 'Here we demonstrate how to use various predictive AI/ML techniques to aid your segmentation. ' + - 'The default model here uses "MarkerInclude" and "MarkerExclude" as segmentation AI prompts ' + - 'for the Segment Anything Model to use to generate a segmentation of the area of interest. ' + - 'Then, these prompts can be copied to the next image by pressing the "n" key to interpolate ' + - 'markers on the current slice onto the next slice.' + } ); -const { canvas, canvasMask } = ai; - -const size = `24vw`; -const content = document.getElementById('content'); - -addButtonToToolbar({ - title: 'Clear', - onClick: () => { - ai.clear(activeViewport); - viewport.render(); - }, +// BoxPrompt - a rectangle ROI variant with Ctrl+click +toolGroup.addToolInstance( + BoxPromptToolName, + cornerstoneTools.RectangleROITool.toolName, + { + getTextLines: () => null, + } +); +toolGroup.setToolActive(BoxPromptToolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], }); -const viewportGrid = document.createElement('div'); -let renderingEngine; -let viewport, volumeViewport, activeViewport; - -viewportGrid.style.width = '99vw'; - -const viewportId = 'Stack'; -const viewportIds = [viewportId, 'AXIAL', 'SAGITAL', 'CORONAL']; - -const elements = []; -for (const id of viewportIds) { - const el = document.createElement('div'); - elements.push(el); - el.oncontextmenu = () => false; - el.id = id; - - Object.assign(el.style, { - width: size, - height: size, - display: 'inline-block', - }); - viewportGrid.appendChild(el); -} -const [element0, element1, element2, element3] = elements; -// Uncomment these to show the canvas/mask overlays separately -// viewportGrid.appendChild(canvas); -// viewportGrid.appendChild(canvasMask); - -Object.assign(canvas.style, { - width: size, - height: size, - display: 'inline-block', - background: 'red', +// Pan (middle or Ctrl+drag) +toolGroup.setToolActive(cornerstoneTools.PanTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Auxiliary }], }); -Object.assign(canvasMask.style, { - width: size, - height: size, - display: 'inline-block', - background: 'black', +// Zoom (right mouse) +toolGroup.setToolActive(cornerstoneTools.ZoomTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Secondary }], }); -// viewportGrid.appendChild(canvas); -// viewportGrid.appendChild(canvasMask); +// Stack Scroll (mouse wheel or Alt+drag) +toolGroup.setToolActive(cornerstoneTools.StackScrollTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }], +}); -content.appendChild(viewportGrid); +const content = document.getElementById('content'); +const viewportContainer = document.createElement('div'); +viewportContainer.style.width = '512px'; +viewportContainer.style.height = '512px'; +viewportContainer.style.position = 'relative'; +content.appendChild(viewportContainer); +// Logging elements on the page const encoderLatency = document.createElement('div'); encoderLatency.id = 'encoder'; content.appendChild(encoderLatency); @@ -233,118 +165,82 @@ const logDiv = document.createElement('div'); logDiv.id = 'status'; content.appendChild(logDiv); -// ============================= // -addDropdownToToolbar({ - options: { map: toolMap, defaultValue: defaultTool }, - toolGroupId: [toolGroupId, volumeToolGroupId], +// disable context menu +viewportContainer.oncontextmenu = () => false; + +addButtonToToolbar({ + title: 'Clear', + onClick: () => { + ai.clear(activeViewport); + viewport.render(); + }, }); -addSegmentIndexDropdown(segmentationId); +const viewportId = 'CURRENT_VIEWPORT'; addDropdownToToolbar({ options: { - values: viewportIds, + values: [MarkerIncludeToolName, MarkerExcludeToolName, BoxPromptToolName], + defaultValue: MarkerIncludeToolName, }, - onSelectedValueChange: (value) => { - activeViewport = renderingEngine.getViewport(value); - ai.initViewport(activeViewport); - }, -}); - -addButtonToToolbar({ - title: 'Cache', - onClick: () => { - if (cached !== undefined) { - return; - } - cached = false; - ai.cacheImageEncodings(); + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + + // Disable all AI tools first + toolGroup.setToolDisabled(MarkerIncludeToolName); + toolGroup.setToolDisabled(MarkerExcludeToolName); + toolGroup.setToolDisabled(BoxPromptToolName); + + // Enable the selected tool + toolGroup.setToolActive(name, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); }, }); -/** - * Runs the demo - */ -async function run() { - // Get the load started here, as it can take a while. - ai.initModel(); +const segmentationId = 'segmentationId'; - // Init Cornerstone and related libraries +async function updateViewport() { await initDemo(); - // Define tool groups to add the segmentation display tool to - const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); - addManipulationBindings(toolGroup, { toolMap }); - // The threshold circle has preview turned on by default, so use it as the - // tool to get/apply previews with. - toolForPreview = toolGroup.getToolInstance('ThresholdCircle'); - - const volumeToolGroup = ToolGroupManager.createToolGroup(volumeToolGroupId); - addManipulationBindings(volumeToolGroup, { toolMap }); - - // Get Cornerstone imageIds and fetch metadata into RAM - const imageIdsFull = await createImageIdsAndCacheMetaData({ - StudyInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', - SeriesInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', - wadoRsRoot: - getLocalUrl() || 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', - }); + if (renderingEngine) { + // renderingEngine.destroy(); + segmentation.removeAllSegmentationRepresentations(); + segmentation.removeAllSegmentations(); + } - const imageIds = imageIdsFull.reverse(); // .slice(35, 45); - // Instantiate a rendering engine - const renderingEngineId = 'myRenderingEngine'; renderingEngine = new RenderingEngine(renderingEngineId); - // Create the viewports - const viewportInputArray = [ - { - viewportId: viewportId, - type: ViewportType.STACK, - element: element0, - defaultOptions: { - background: [0.2, 0, 0.2], - }, - }, - { - viewportId: viewportIds[1], - type: ViewportType.ORTHOGRAPHIC, - element: element1, - defaultOptions: { - orientation: Enums.OrientationAxis.AXIAL, - background: [0.2, 0.2, 0], - }, - }, - { - viewportId: viewportIds[2], - type: ViewportType.ORTHOGRAPHIC, - element: element2, - defaultOptions: { - orientation: Enums.OrientationAxis.SAGITTAL, - background: [0, 0.2, 0], - }, - }, - { - viewportId: viewportIds[3], - type: ViewportType.ORTHOGRAPHIC, - element: element3, - defaultOptions: { - orientation: Enums.OrientationAxis.CORONAL, - background: [0.2, 0, 0.2], - }, - }, - ]; + const viewportInput = { + viewportId, + element: viewportContainer, + type: currentViewportType, + defaultOptions: {}, + }; + + if (currentViewportType === ViewportType.ORTHOGRAPHIC) { + viewportInput.defaultOptions.orientation = OrientationAxis.SAGITTAL; + } - renderingEngine.setViewports(viewportInputArray); + renderingEngine.setViewports([viewportInput]); toolGroup.addViewport(viewportId, renderingEngineId); - volumeToolGroup.addViewport(viewportIds[1], renderingEngineId); - volumeToolGroup.addViewport(viewportIds[2], renderingEngineId); - volumeToolGroup.addViewport(viewportIds[3], renderingEngineId); - // Get the stack viewport that was created - viewport = renderingEngine.getViewport(viewportId); - activeViewport = viewport; + const imageIds = await createAndLoadData(); + + if (currentViewportType === ViewportType.STACK) { + viewport = renderingEngine.getViewport(viewportId); + await viewport.setStack(imageIds); + viewport.render(); + } else { + // For sagittal, create volume and set it + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + volume.load(); + await setVolumesForViewports(renderingEngine, [{ volumeId }], [viewportId]); + viewport = renderingEngine.getViewport(viewportId); + viewport.render(); + } // Add a segmentation that will contains the contour annotations const segmentationImages = @@ -352,35 +248,11 @@ async function run() { const segmentationImageIds = segmentationImages.map((image) => image.imageId); - // Set the stack on the viewport - await viewport.setStack(imageIds); - viewport.setOptions(ONNXSegmentationController.viewportOptions); - - // This init model is waiting for completion, whereas the earlier one just - // starts loading in the background. - await ai.initModel(); - // Connect the default viewport here to start things off - requires the initModel to be done - ai.initViewport(viewport, toolForPreview); - - const volume = await volumeLoader.createAndCacheVolume(volumeId, { - imageIds, - }); - - volume.load(); - - await setVolumesForViewports( - renderingEngine, - [{ volumeId }], - viewportIds.slice(1) - ); - - // Render the image - segmentation.addSegmentations([ { segmentationId, representation: { - type: csToolsEnums.SegmentationRepresentations.Labelmap, + type: SegmentationRepresentations.Labelmap, data: { imageIds: segmentationImageIds, }, @@ -389,32 +261,40 @@ async function run() { ]); const segMap = { - [viewportIds[0]]: [{ segmentationId }], - [viewportIds[1]]: [{ segmentationId }], - [viewportIds[2]]: [{ segmentationId }], - [viewportIds[3]]: [{ segmentationId }], + [viewport.id]: [{ segmentationId }], }; // Create a segmentation representation associated to the toolGroupId await segmentation.addLabelmapRepresentationToViewportMap(segMap); - elements.forEach((element) => - element.addEventListener(csToolsEnums.Events.KEY_DOWN, (evt) => { - const { key } = evt.detail; - const { element } = activeViewport; - if (key === 'Escape') { - cornerstoneTools.cancelActiveManipulations(element); - toolForPreview.rejectPreview(element); - } else if (key === 'Enter') { - toolForPreview.acceptPreview(element); - } else if (key === 'n') { - ai.interpolateScroll(activeViewport, 1); - } - }) - ); - - // volumeViewport.setViewReference(viewport.getViewReference()); - // volumeViewport.setViewPresentation(viewport.getViewPresentation()); + activeViewport = viewport; + await ai.initModel(); + ai.initViewport(viewport); + + viewport.element.addEventListener(Events.KEY_DOWN, (evt) => { + const { key } = evt.detail; + const { element } = activeViewport; + if (key === 'Escape') { + cornerstoneTools.cancelActiveManipulations(element); + ai.rejectPreview(element); + } else if (key === 'Enter') { + ai.acceptPreview(element); + } else if (key === 'n') { + ai.interpolateScroll(activeViewport, 1); + } + }); +} + +async function createAndLoadData() { + const imageIdsFull = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: + getLocalUrl() || 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + }); + return imageIdsFull.reverse(); } -run(); +updateViewport(); diff --git a/packages/ai/src/ONNXSegmentationController.ts b/packages/ai/src/ONNXSegmentationController.ts index f2c915ee34..52ea3e64d0 100644 --- a/packages/ai/src/ONNXSegmentationController.ts +++ b/packages/ai/src/ONNXSegmentationController.ts @@ -3,6 +3,14 @@ import { utilities, eventTarget, Enums } from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; import type { Types as cstTypes } from '@cornerstonejs/tools'; +import { + segmentation as cstSegmentation, + LabelmapBaseTool, +} from '@cornerstonejs/tools'; + +const { strategies } = cstSegmentation; +const { fillInsideCircle } = strategies; + // @ts-ignore import ort from 'onnxruntime-web/webgpu'; import { vec3 } from 'gl-matrix'; @@ -14,6 +22,7 @@ const { Events: toolsEvents } = cornerstoneTools.Enums; const { segmentation } = cornerstoneTools; const { filterAnnotationsForDisplay } = cornerstoneTools.utilities.planar; +const { IslandRemoval } = cornerstoneTools.utilities; const { triggerSegmentationDataModified } = segmentation.triggerSegmentationEvents; @@ -115,6 +124,8 @@ export default class ONNXSegmentationController { public static MarkerInclude = 'MarkerInclude'; /** Default name for a tool for exclusion points */ public static MarkerExclude = 'MarkerExclude'; + /** Default name for a tool for box prompt */ + public static BoxPrompt = 'BoxPrompt'; /** Some viewport options for loadImageToCanvas */ public static viewportOptions = { @@ -139,6 +150,7 @@ export default class ONNXSegmentationController { modelWidth = 1024; modelHeight = 1024; + tool; /** * Defines the URL endpoints and render sizes/setup for the various models that * can be used. @@ -184,12 +196,12 @@ export default class ONNXSegmentationController { private config; private points = []; private labels = []; + private worldPoints = new Array(); private loadingAI: Promise; protected viewport; protected excludeTool = ONNXSegmentationController.MarkerExclude; - protected tool; protected currentImage; private listeners = [console.log]; protected desiredImage = { @@ -210,10 +222,25 @@ export default class ONNXSegmentationController { protected promptAnnotationTypes = [ ONNXSegmentationController.MarkerInclude, ONNXSegmentationController.MarkerExclude, + ONNXSegmentationController.BoxPrompt, ]; - /** The type name of the preview tool used for the accept/reject labelmap preview */ - protected previewToolType = 'ThresholdCircle'; + /** + * Fill internal islands by size, and consider islands at the edge to + * be included as internal. + */ + protected islandFillOptions = { + maxInternalRemove: 16, + fillInternalEdge: true, + }; + + /** + * The p cutoff to apply, values p and above are included. + * The values are pixel values, so 0 means everything, while 255 means + * only certainly included. A value of 64 seems reasonable as it omits low probability areas, + * but most areas are >190 in actual practice. + */ + protected pCutoff = 64; /** * Configure the ML Controller. No parameters are required, and will default * to the basic set of controls using MarkerInclude/Exclude and the default SAM @@ -237,7 +264,7 @@ export default class ONNXSegmentationController { promptAnnotationTypes: null, models: null, modelName: null, - previewToolType: 'ThresholdCircle', + islandFillOptions: undefined, } ) { if (options.listeners) { @@ -251,8 +278,9 @@ export default class ONNXSegmentationController { if (options.models) { Object.assign(ONNXSegmentationController.MODELS, options.models); } - this.previewToolType = options.previewToolType || this.previewToolType; this.config = this.getConfig(options.modelName); + this.islandFillOptions = + options.islandFillOptions ?? this.islandFillOptions; } /** @@ -268,14 +296,20 @@ export default class ONNXSegmentationController { return this.loadingAI; } + public setPCutoff(cutoff: number) { + this.pCutoff = cutoff; + this.annotationsNeedUpdating = true; + this.tryLoad(); + } + /** - * Connects a viewport up to get anotations and updates + * Connects a viewport up to get annotations and updates * Note that only one viewport at a time is permitted as the model needs to * load data about the active viewport. This method will disconnect a previous * viewport automatically. * * The viewport must have a labelmap segmentation registered, as well as a - * tool which extendds LabelmapBaseTool to use for setting the preview view + * tool which extends LabelmapBaseTool to use for setting the preview view * once the decode is completed. This is provided as toolForPreview * * @param viewport - a viewport to listen for annotations and rendered events @@ -289,14 +323,31 @@ export default class ONNXSegmentationController { } this.currentImage = null; this.viewport = viewport; - const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroupForViewport( - viewport.id, - viewport.getRenderingEngine()?.id + + const brushInstance = new LabelmapBaseTool( + {}, + { + configuration: { + strategies: { + FILL_INSIDE_CIRCLE: fillInsideCircle, + }, + activeStrategy: 'FILL_INSIDE_CIRCLE', + preview: { + enabled: true, + previewColors: { + 0: [255, 255, 255, 128], + 1: [0, 255, 255, 192], + 2: [255, 0, 255, 255], + }, + }, + }, + } ); - this.tool = toolGroup.getToolInstance(this.previewToolType); + + this.tool = brushInstance; desiredImage.imageId = - viewport.getCurrentImageId() || viewport.getReferenceId(); + viewport.getCurrentImageId?.() || viewport.getViewReferenceId(); if (desiredImage.imageId.startsWith('volumeId:')) { desiredImage.sampleImageId = viewport.getImageIds( viewport.getVolumeId() @@ -324,6 +375,14 @@ export default class ONNXSegmentationController { } } + public acceptPreview(element) { + this.tool.acceptPreview(element); + } + + public rejectPreview(element) { + this.tool.rejectPreview(element); + } + /** * The interpolateScroll checks to see if there are any annotations on the * current image in the specified viewport, and if so, scrolls in the given @@ -339,6 +398,7 @@ export default class ONNXSegmentationController { */ public async interpolateScroll(viewport = this.viewport, dir = 1) { const { element } = viewport; + this.tool.acceptPreview(element); const promptAnnotations = this.getPromptAnnotations(viewport); @@ -428,7 +488,7 @@ export default class ONNXSegmentationController { protected viewportRenderedListener = (_event) => { const { viewport, currentImage, desiredImage } = this; desiredImage.imageId = - viewport.getCurrentImageId() || viewport.getReferenceId(); + viewport.getCurrentImageId() || viewport.getViewReferenceId(); desiredImage.imageIndex = viewport.getCurrentImageIdIndex(); if (!desiredImage.imageId) { return; @@ -541,6 +601,7 @@ export default class ONNXSegmentationController { this.getPromptAnnotations(viewport).forEach((annotation) => annotationState.removeAnnotation(annotation.annotationUID) ); + this.tool.rejectPreview(this.viewport.element); } /** @@ -575,7 +636,8 @@ export default class ONNXSegmentationController { return this.cacheImageEncodings(current, offset, length); } const imageId = - view.referencedImageId || viewport.getReferenceId({ sliceIndex: index }); + view.referencedImageId || + viewport.getViewReferenceId({ sliceIndex: index }); if (!imageEncodings.has(imageId)) { // Try loading from storage await this.loadStorageImageEncoding(current, imageId, index); @@ -744,7 +806,7 @@ export default class ONNXSegmentationController { const { viewport, desiredImage } = this; if (!desiredImage.imageId || options.resetImage) { desiredImage.imageId = - viewport.getCurrentImageId() || viewport.getReferenceId(); + viewport.getCurrentImageId() || viewport.getViewReferenceId(); this.currentImage = null; } // Always use session 0 for the current session @@ -795,20 +857,34 @@ export default class ONNXSegmentationController { ) { return; } - const currentAnnotations = this.getPromptAnnotations(); + const promptAnnotations = this.getPromptAnnotations(); this.annotationsNeedUpdating = false; this.points = []; this.labels = []; - if (!currentAnnotations?.length) { + this.worldPoints = []; + + if (!promptAnnotations?.length) { return; } - for (const annotation of currentAnnotations) { + for (const annotation of promptAnnotations) { const handle = annotation.data.handles.points[0]; const point = this.mapAnnotationPoint(handle); - const label = annotation.metadata.toolName === this.excludeTool ? 0 : 1; - this.points.push(point[0]); - this.points.push(point[1]); - this.labels.push(label); + this.points.push(...point); + if ( + annotation.metadata.toolName === ONNXSegmentationController.BoxPrompt + ) { + // 2 and 3 are the codes for the handles on a box prompt + this.labels.push(2, 3); + this.points.push( + ...this.mapAnnotationPoint(annotation.data.handles.points[3]) + ); + } else { + const label = annotation.metadata.toolName === this.excludeTool ? 0 : 1; + if (label) { + this.worldPoints.push(handle); + } + this.labels.push(label); + } } this.runDecode(); } @@ -901,7 +977,7 @@ export default class ONNXSegmentationController { createLabelmap(mask, canvasPosition, _points, _labels) { const { canvas, viewport } = this; const preview = this.tool.addPreview(viewport.element); - const { previewSegmentIndex, memo, segmentationId } = preview; + const { previewSegmentIndex, memo, segmentationId, segmentIndex } = preview; const previewVoxelManager = memo?.voxelManager || preview.previewVoxelManager; const { dimensions } = previewVoxelManager; @@ -934,13 +1010,34 @@ export default class ONNXSegmentationController { // 4 values - RGBA - per pixel const maskIndex = 4 * (i + j * this.maxWidth); const v = data[maskIndex]; - if (v > 0) { + if (v > this.pCutoff) { previewVoxelManager.setAtIJKPoint(ijkPoint, previewSegmentIndex); } else { previewVoxelManager.setAtIJKPoint(ijkPoint, null); } } } + + const voxelManager = + previewVoxelManager.sourceVoxelManager || previewVoxelManager; + + if (this.islandFillOptions) { + const islandRemoval = new IslandRemoval(this.islandFillOptions); + if ( + islandRemoval.initialize(viewport, voxelManager, { + previewSegmentIndex, + segmentIndex, + points: this.worldPoints.map((point) => + imageData.worldToIndex(point).map(Math.round) + ), + }) + ) { + islandRemoval.floodFillSegmentIsland(); + islandRemoval.removeExternalIslands(); + islandRemoval.removeInternalIslands(); + } + } + triggerSegmentationDataModified(segmentationId); } @@ -981,14 +1078,7 @@ export default class ONNXSegmentationController { const session = useSession.decoder; const feed = feedForSam(emb, points, labels); - const start = performance.now(); const res = await session.run(feed); - this.log( - Loggers.Decoder, - `decoder ${useSession.sessionIndex} ${( - performance.now() - start - ).toFixed(1)} ms` - ); for (let i = 0; i < points.length; i += 2) { const label = labels[i / 2]; @@ -1188,8 +1278,6 @@ export default class ONNXSegmentationController { const pair = vars[i].split('='); if (pair[0] in config) { config[pair[0]] = decodeURIComponent(pair[1]); - } else if (pair[0].length > 0) { - throw new Error('unknown argument: ' + pair[0]); } } config.threads = parseInt(String(config.threads)); diff --git a/packages/core/examples/dynamicVolume/index.ts b/packages/core/examples/dynamicVolume/index.ts index fee0e56b4b..2101a01712 100644 --- a/packages/core/examples/dynamicVolume/index.ts +++ b/packages/core/examples/dynamicVolume/index.ts @@ -9,7 +9,6 @@ import { initDemo, createImageIdsAndCacheMetaData, setTitleAndDescription, - setPetTransferFunctionForVolumeActor, addSliderToToolbar, addDropdownToToolbar, } from '../../../../utils/demo/helpers'; diff --git a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts index e9a01ab10e..3dac7d4057 100644 --- a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts +++ b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts @@ -182,7 +182,7 @@ async function getVOIFromMiddleSliceMinMax( // Get the min and max pixel values of the middle slice let { min, max } = image.voxelManager.getMinMax(); - if (min.length > 1) { + if (min?.length > 1) { min = Math.min(...min); max = Math.max(...max); } diff --git a/packages/core/src/utilities/createPositionCallback.ts b/packages/core/src/utilities/createPositionCallback.ts index bcea6a9ddf..623b92a742 100644 --- a/packages/core/src/utilities/createPositionCallback.ts +++ b/packages/core/src/utilities/createPositionCallback.ts @@ -52,10 +52,10 @@ export function createPositionCallback(imageData) { vec3.add(scaled, worldPosStart, vec3.scale(scaled, rowStep, i)) as Point3 ); } - for (let j = 0; j < dimensions[0]; j++) { + for (let j = 0; j < dimensions[1]; j++) { positionJ.push(vec3.scale(scaled, columnStep, j) as Point3); } - for (let k = 0; k < dimensions[0]; k++) { + for (let k = 0; k < dimensions[2]; k++) { positionK.push(vec3.scale(scaled, scanAxisStep, k) as Point3); } diff --git a/packages/tools/examples/labelmapEditWithContour/index.ts b/packages/tools/examples/labelmapEditWithContour/index.ts new file mode 100644 index 0000000000..27814f1402 --- /dev/null +++ b/packages/tools/examples/labelmapEditWithContour/index.ts @@ -0,0 +1,281 @@ +import type { Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + setVolumesForViewports, + volumeLoader, + getEnabledElement, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addDropdownToToolbar, + getLocalUrl, + addButtonToToolbar, +} from '../../../../utils/demo/helpers'; +import * as cornerstone from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { fillVolumeLabelmapWithMockData } from '../../../../utils/test/fillVolumeLabelmapWithMockData'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { + ToolGroupManager, + Enums: csToolsEnums, + segmentation, +} = cornerstoneTools; + +const { MouseBindings } = csToolsEnums; +const { ViewportType } = Enums; + +// Define a unique id for the volume +const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use +const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id +const segmentationId = 'MY_SEGMENTATION_ID'; +const toolGroupId = 'MY_TOOLGROUP_ID'; + +// ======== Set up page ======== // +setTitleAndDescription( + 'Labelmap Edit With Contour', + 'Here we demonstrate editing of a labelmap with contour tools. Start inside the ' + + 'labelmap area to extend it, and have the contour extend outside. Then hit e to edit ' + + 'the labelmap data' +); + +const size = '32vw'; +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); + +viewportGrid.style.display = 'flex'; +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'row'; + +const element1 = document.createElement('div'); +const element2 = document.createElement('div'); +const element3 = document.createElement('div'); +const elements = [element1, element2, element3]; +for (const el of elements) { + el.style.width = size; + el.style.height = '50vh'; + el.oncontextmenu = (e) => e.preventDefault(); + viewportGrid.appendChild(el); +} + +content.appendChild(viewportGrid); + +const instructions = document.createElement('p'); +instructions.innerText = ` + Hover - show preview of segmentation tool + Left drag to extend preview + Left Click (or enter) to accept preview + Reject preview by button (or esc) + Hover outside of region to reset to hovered over segment index + Shift Left - zoom, Ctrl Left - Pan, Alt Left - Stack Scroll + `; + +content.append(instructions); + +// ============================= // +addDropdownToToolbar({ + options: { values: ['1', '2', '3'], defaultValue: '1' }, + labelText: 'Segment', + onSelectedValueChange: (segmentIndex) => { + segmentation.segmentIndex.setActiveSegmentIndex( + segmentationId, + Number(segmentIndex) + ); + }, +}); + +addButtonToToolbar({ + title: 'Reject Preview', + onClick: () => { + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + const activeName = toolGroup.getActivePrimaryMouseButtonTool(); + const brush = toolGroup.getToolInstance(activeName); + brush.rejectPreview?.(element1); + }, +}); + +// ============================= // + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + cornerstoneTools.addTool( + cornerstoneTools.PlanarFreehandContourSegmentationTool + ); + + // Define tool groups to add the segmentation display tool to + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + toolGroup.addTool( + cornerstoneTools.PlanarFreehandContourSegmentationTool.toolName + ); + + toolGroup.setToolActive( + cornerstoneTools.PlanarFreehandContourSegmentationTool.toolName, + { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Left Click + }, + ], + } + ); + + // Get Cornerstone imageIds for the source data and fetch metadata into RAM + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: + getLocalUrl() || 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + }); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + // Add some segmentations based on the source data volume + // Create a segmentation of the same resolution as the source data + await volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + volumeId: segmentationId, + }); + + fillVolumeLabelmapWithMockData({ + volumeId: segmentationId, + cornerstone, + }); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId, + representation: { + // The type of segmentation + type: csToolsEnums.SegmentationRepresentations.Labelmap, + // The actual segmentation data, in the case of labelmap this is a + // reference to the source volume of the segmentation. + data: { + volumeId: segmentationId, + }, + }, + }, + ]); + + segmentation.addRepresentationData({ + segmentationId, + type: csToolsEnums.SegmentationRepresentations.Contour, + data: {}, + }); + + // Instantiate a rendering engine + const renderingEngineId = 'myRenderingEngine'; + const renderingEngine = new RenderingEngine(renderingEngineId); + + // Create the viewports + const viewportId1 = 'CT_AXIAL'; + const viewportId2 = 'CT_SAGITTAL'; + const viewportId3 = 'CT_CORONAL'; + + const viewportInputArray = [ + { + viewportId: viewportId1, + type: ViewportType.ORTHOGRAPHIC, + element: element1, + defaultOptions: { + orientation: Enums.OrientationAxis.AXIAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId2, + type: ViewportType.ORTHOGRAPHIC, + element: element2, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId3, + type: ViewportType.ORTHOGRAPHIC, + element: element3, + defaultOptions: { + orientation: Enums.OrientationAxis.CORONAL, + background: [0, 0, 0], + }, + }, + ]; + + renderingEngine.setViewports(viewportInputArray); + + toolGroup.addViewport(viewportId1, renderingEngineId); + toolGroup.addViewport(viewportId2, renderingEngineId); + toolGroup.addViewport(viewportId3, renderingEngineId); + + // Set the volume to load + volume.load(); + + // Set volumes on the viewports + await setVolumesForViewports( + renderingEngine, + [{ volumeId }], + [viewportId1, viewportId2, viewportId3] + ); + + const segMap = (segmentationId: string) => ({ + [viewportId1]: [ + { + segmentationId, + }, + ], + [viewportId2]: [ + { + segmentationId, + }, + ], + [viewportId3]: [ + { + segmentationId, + }, + ], + }); + + await segmentation.addLabelmapRepresentationToViewportMap( + segMap(segmentationId) + ); + await segmentation.addContourRepresentationToViewportMap( + segMap(segmentationId) + ); + + // Render the image + renderingEngine.render(); + + elements.forEach((element) => + element.addEventListener(csToolsEnums.Events.KEY_DOWN, (evt) => { + const { key, element } = evt.detail; + if (key === 'Escape') { + console.warn('Hello reject current bindings'); + cornerstoneTools.cancelActiveManipulations(element); + } else if (key === 'Enter') { + const { viewport } = getEnabledElement(element); + cornerstoneTools.BrushTool.viewportContoursToLabelmap(viewport); + } + }) + ); +} + +run(); diff --git a/packages/tools/examples/regionSegment/index.ts b/packages/tools/examples/regionSegment/index.ts index 45bc7e0e00..3d74c2437f 100644 --- a/packages/tools/examples/regionSegment/index.ts +++ b/packages/tools/examples/regionSegment/index.ts @@ -96,44 +96,54 @@ content.appendChild(info); // ==[ Toolbar ]================================================================ +addButtonToToolbar({ + title: 'Shrink', + onClick: async () => { + toolGroup.getToolInstance(RegionSegmentTool.toolName).shrink(); + }, +}); + +addButtonToToolbar({ + title: 'Expand', + onClick: async () => { + toolGroup.getToolInstance(RegionSegmentTool.toolName).expand(); + }, +}); + addButtonToToolbar({ title: 'Clear segmentation', onClick: async () => { const labelmapVolume = cache.getVolume(segmentationId); - labelmapVolume.voxelManager.clear(); + labelmapVolume?.voxelManager?.clear(); + }, +}); - segmentation.triggerSegmentationEvents.triggerSegmentationDataModified( - segmentationId - ); +addSliderToToolbar({ + title: 'Positive threshold (50%)', + range: [0, 100], + defaultValue: 50, + onSelectedValueChange: (value: string) => { + updateSeedVariancesConfig({ positiveSeedVariance: value }); + }, + updateLabelOnChange: (value: string, label: HTMLElement) => { + label.innerHTML = `Positive threshold (${value}%)`; }, }); -// addSliderToToolbar({ -// title: 'Positive threshold (50%)', -// range: [0, 100], -// defaultValue: 50, -// onSelectedValueChange: (value: string) => { -// updateSeedVariancesConfig({ positiveSeedVariance: value }); -// }, -// updateLabelOnChange: (value: string, label: HTMLElement) => { -// label.innerHTML = `Positive threshold (${value}%)`; -// }, -// }); - -// addSliderToToolbar({ -// title: 'Negative threshold (90%)', -// range: [0, 100], -// defaultValue: 90, -// label: { -// html: 'test', -// }, -// onSelectedValueChange: (value: string) => { -// updateSeedVariancesConfig({ negativeSeedVariance: value }); -// }, -// updateLabelOnChange: (value: string, label: HTMLElement) => { -// label.innerHTML = `Negative threshold (${value}%)`; -// }, -// }); +addSliderToToolbar({ + title: 'Negative threshold (90%)', + range: [0, 100], + defaultValue: 90, + label: { + html: 'test', + }, + onSelectedValueChange: (value: string) => { + updateSeedVariancesConfig({ negativeSeedVariance: value }); + }, + updateLabelOnChange: (value: string, label: HTMLElement) => { + label.innerHTML = `Negative threshold (${value}%)`; + }, +}); // ============================================================================= diff --git a/packages/tools/examples/regionSegmentPlus/index.ts b/packages/tools/examples/regionSegmentPlus/index.ts index 719cdc303c..521ef21eae 100644 --- a/packages/tools/examples/regionSegmentPlus/index.ts +++ b/packages/tools/examples/regionSegmentPlus/index.ts @@ -13,6 +13,7 @@ import { createInfoSection, addManipulationBindings, addButtonToToolbar, + addSliderToToolbar, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -26,6 +27,7 @@ const { segmentation, ToolGroupManager, Enums: csToolsEnums, + utilities: cstUtils, } = cornerstoneTools; const { ViewportType } = Enums; @@ -39,6 +41,7 @@ const viewportIdCoronal = 'CT_VOLUME_CORONAL'; const viewportIdSagittal = 'CT_VOLUME_SAGITTAL'; const segmentationId = 'MY_SEGMENTATION_ID'; const toolGroupId = 'STACK_TOOL_GROUP_ID'; +let toolGroup; // ======== Set up page ======== // setTitleAndDescription( @@ -82,23 +85,88 @@ content.appendChild(info); // ==[ Toolbar ]================================================================ +addButtonToToolbar({ + title: 'Shrink', + onClick: async () => { + toolGroup.getToolInstance(RegionSegmentPlusTool.toolName).shrink(); + }, +}); + +addButtonToToolbar({ + title: 'Expand', + onClick: async () => { + toolGroup.getToolInstance(RegionSegmentPlusTool.toolName).expand(); + }, +}); + addButtonToToolbar({ title: 'Clear segmentation', onClick: async () => { const labelmapVolume = cache.getVolume(segmentationId); - labelmapVolume.voxelManager.clear(); + const voxelManager = labelmapVolume.voxelManager; + + voxelManager.clear(); segmentation.triggerSegmentationEvents.triggerSegmentationDataModified( segmentationId ); }, }); +addSliderToToolbar({ + title: 'Positive threshold (40%)', + range: [0, 100], + defaultValue: 40, + label: { + html: 'test', + }, + onSelectedValueChange: (value: string) => { + updateSeedVariancesConfig({ positiveSeedVariance: value }); + }, + updateLabelOnChange: (value: string, label: HTMLElement) => { + label.innerHTML = `Positive threshold (${value}%)`; + }, +}); + +addSliderToToolbar({ + title: 'Negative threshold (90%)', + range: [0, 100], + defaultValue: 90, + label: { + html: 'test', + }, + onSelectedValueChange: (value: string) => { + updateSeedVariancesConfig({ negativeSeedVariance: value }); + }, + updateLabelOnChange: (value: string, label: HTMLElement) => { + label.innerHTML = `Negative threshold (${value}%)`; + }, +}); // ============================================================================= +const updateSeedVariancesConfig = cstUtils.throttle( + ({ positiveSeedVariance, negativeSeedVariance }) => { + const toolInstance = toolGroup.getToolInstance( + RegionSegmentPlusTool.toolName + ); + const { configuration: config } = toolInstance; + + if (positiveSeedVariance !== undefined) { + config.positiveSeedVariance = Number(positiveSeedVariance) / 100; + } + + if (negativeSeedVariance !== undefined) { + config.negativeSeedVariance = Number(negativeSeedVariance) / 100; + } + + toolInstance.refresh(); + }, + 1000 +); + async function addSegmentationsToState() { // Create a segmentation of the same resolution as the source data - volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + await volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { volumeId: segmentationId, }); @@ -129,7 +197,7 @@ async function run() { // Define a tool group, which defines how mouse events map to tool commands for // Any viewport using the group - const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + toolGroup = ToolGroupManager.createToolGroup(toolGroupId); // Add the tools to the tool group toolGroup.addTool(RegionSegmentPlusTool.toolName); @@ -148,6 +216,7 @@ async function run() { // Get Cornerstone imageIds and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ + // PT StudyInstanceUID: '1.3.6.1.4.1.14519.5.2.1.7009.2403.871108593056125491804754960339', SeriesInstanceUID: @@ -216,32 +285,12 @@ async function run() { toolGroup.addViewport(viewportIdSagittal, renderingEngineId); const segMap = { - [viewportIdAxial]: [ - { - segmentationId, - }, - ], - [viewportIdCoronal]: [ - { - segmentationId, - }, - ], - [viewportIdSagittal]: [ - { - segmentationId, - }, - ], + [viewportIdAxial]: [{ segmentationId }], + [viewportIdCoronal]: [{ segmentationId }], + [viewportIdSagittal]: [{ segmentationId }], }; - // Add the segmentation representation to the toolgroup - await segmentation.addLabelmapRepresentationToViewportMap(segMap); - - const viewport = renderingEngine.getViewport( - viewportIdCoronal - ) as Types.IVolumeViewport; - viewport.setProperties({ - interpolationType: Enums.InterpolationType.NEAREST, - }); + await segmentation.addLabelmapRepresentationToViewportMap(segMap); } run(); diff --git a/packages/tools/examples/volumeAnnotationTools/index.ts b/packages/tools/examples/volumeAnnotationTools/index.ts index 087f3e86f8..2e91a3f428 100644 --- a/packages/tools/examples/volumeAnnotationTools/index.ts +++ b/packages/tools/examples/volumeAnnotationTools/index.ts @@ -8,13 +8,11 @@ import { } from '@cornerstonejs/core'; import { initDemo, - createImageIdsAndCacheMetaData, setTitleAndDescription, addManipulationBindings, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; import { - encodeVolumeIdInfo, fakeImageLoader, fakeVolumeLoader, } from '../../../../utils/test/testUtils'; @@ -37,7 +35,7 @@ const { MouseBindings } = csToolsEnums; // Define a unique id for the volume const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use -// const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id +const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id // ======== Set up page ======== // setTitleAndDescription( @@ -80,17 +78,6 @@ instructions.innerText = content.append(instructions); // ============================= // -const volumeId = encodeVolumeIdInfo({ - loader: 'fakeVolumeLoader', - name: 'volumeURI', - rows: 100, - columns: 100, - slices: 10, - xSpacing: 1, - ySpacing: 1, - zSpacing: 1, -}); - /** * Runs the demo */ diff --git a/packages/tools/examples/wholeBodySegment/index.ts b/packages/tools/examples/wholeBodySegment/index.ts index aac9262115..fc8d40578b 100644 --- a/packages/tools/examples/wholeBodySegment/index.ts +++ b/packages/tools/examples/wholeBodySegment/index.ts @@ -2,49 +2,56 @@ import type { Types } from '@cornerstonejs/core'; import { RenderingEngine, Enums, - volumeLoader, setVolumesForViewports, + volumeLoader, + getRenderingEngine, cache, } from '@cornerstonejs/core'; import { initDemo, createImageIdsAndCacheMetaData, setTitleAndDescription, - createInfoSection, - addManipulationBindings, + setPetTransferFunctionForVolumeActor, addButtonToToolbar, + createInfoSection, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; -// This is for debugging purposes -console.warn( - 'Click on index.ts to open source code for this example --------->' -); - -const DEFAULT_SEGMENT_CONFIG = { - fillAlpha: 0.1, - outlineOpacity: 1, - outlineWidthActive: 3, -}; - const { - WholeBodySegmentTool, - segmentation, ToolGroupManager, Enums: csToolsEnums, + PanTool, + ZoomTool, + StackScrollTool, + synchronizers, + WholeBodySegmentTool, + segmentation, } = cornerstoneTools; -const { ViewportType } = Enums; const { MouseBindings } = csToolsEnums; -const volumeName = 'PT_VOLUME_ID'; // Id of the volume less loader prefix -const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use -const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id +const { ViewportType } = Enums; + +let renderingEngine; +const wadoRsRoot = 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb'; +const StudyInstanceUID = + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463'; const renderingEngineId = 'myRenderingEngine'; -const viewportIdAxial = 'CT_VOLUME_AXIAL'; -const viewportIdCoronal = 'CT_VOLUME_CORONAL'; -const viewportIdSagittal = 'CT_VOLUME_SAGITTAL'; +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use +const ctVolumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix +const ctVolumeId = `${volumeLoaderScheme}:${ctVolumeName}`; // VolumeId with loader id + volume id +const ptVolumeName = 'PT_VOLUME_ID'; +const ptVolumeId = `${volumeLoaderScheme}:${ptVolumeName}`; +const ctToolGroupId = 'CT_TOOLGROUP_ID'; +const ptToolGroupId = 'PT_TOOLGROUP_ID'; +let ctImageIds; +let ptImageIds; +let ctVolume; +let ptVolume; +const viewportIds = { + CT: { AXIAL: 'CT_AXIAL', SAGITTAL: 'CT_SAGITTAL' }, + PT: { AXIAL: 'PT_AXIAL', SAGITTAL: 'PT_SAGITTAL' }, +}; const segmentationId = 'MY_SEGMENTATION_ID'; -const toolGroupId = 'STACK_TOOL_GROUP_ID'; // ======== Set up page ======== // setTitleAndDescription( @@ -52,47 +59,13 @@ setTitleAndDescription( 'Demonstrates how to segment the whole body of a region selected by the user that is processed in the gpu (grow cut algorithm)' ); -const viewportGrid = document.createElement('div'); - -viewportGrid.style.display = 'grid'; -viewportGrid.style.gridTemplateColumns = `repeat(3, 33%)`; - -const content = document.getElementById('content'); - -content.appendChild(viewportGrid); - -// prettier-ignore -createInfoSection(content) - .addInstruction('Click on any viewport and drag up/down to select a region of the image') - .addInstruction('Wait for a few seconds to get the whole-body segmented') - -const elementAxial = document.createElement('div'); -const elementCoronal = document.createElement('div'); -const elementSagittal = document.createElement('div'); - -// Disable right click context menu so we can have right click tools -elementAxial.oncontextmenu = (e) => e.preventDefault(); -elementCoronal.oncontextmenu = (e) => e.preventDefault(); -elementSagittal.oncontextmenu = (e) => e.preventDefault(); - -elementAxial.style.height = '500px'; -elementCoronal.style.height = '500px'; -elementSagittal.style.height = '500px'; - -viewportGrid.appendChild(elementAxial); -viewportGrid.appendChild(elementCoronal); -viewportGrid.appendChild(elementSagittal); - -const info = document.createElement('div'); -content.appendChild(info); - -// ==[ Toolbar ]================================================================ - addButtonToToolbar({ title: 'Clear segmentation', onClick: async () => { const labelmapVolume = cache.getVolume(segmentationId); - labelmapVolume.voxelManager.clear(); + const voxelManager = labelmapVolume.voxelManager; + + voxelManager.clear(); segmentation.triggerSegmentationEvents.triggerSegmentationDataModified( segmentationId @@ -100,145 +73,268 @@ addButtonToToolbar({ }, }); -// ============================================================================= +const resizeObserver = new ResizeObserver(() => { + renderingEngine = getRenderingEngine(renderingEngineId); -async function addSegmentationsToState() { - // Create a segmentation of the same resolution as the source data - volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { - volumeId: segmentationId, - }); + if (renderingEngine) { + renderingEngine.resize(true, false); + } +}); - // Add the segmentations to state - segmentation.addSegmentations([ - { - segmentationId, - representation: { - type: csToolsEnums.SegmentationRepresentations.Labelmap, - data: { - volumeId: segmentationId, - referencedVolumeId: volumeId, - }, - }, - }, - ]); -} +const viewportGrid = document.createElement('div'); -/** - * Runs the demo - */ -async function run() { - // Init Cornerstone and related libraries - await initDemo(); +viewportGrid.style.display = 'grid'; +viewportGrid.style.gridTemplateRows = `50% 50%`; +viewportGrid.style.gridTemplateColumns = `33% 33% 33%`; +viewportGrid.style.width = '98vw'; +viewportGrid.style.height = '60vh'; + +const content = document.getElementById('content'); + +content.appendChild(viewportGrid); +const element1_1 = document.createElement('div'); +const element1_2 = document.createElement('div'); +const element1_3 = document.createElement('div'); +const element2_1 = document.createElement('div'); +const element2_2 = document.createElement('div'); +const element2_3 = document.createElement('div'); + +viewportGrid.appendChild(element1_1); +viewportGrid.appendChild(element1_2); +viewportGrid.appendChild(element1_3); +viewportGrid.appendChild(element2_1); +viewportGrid.appendChild(element2_2); +viewportGrid.appendChild(element2_3); + +const elements = [ + element1_1, + element1_2, + element1_3, + element2_1, + element2_2, + element2_3, +]; + +elements.forEach((element) => { + element.style.width = '100%'; + element.style.height = '100%'; + + // Disable right click context menu so we can have right click tools + element.oncontextmenu = (e) => e.preventDefault(); + + resizeObserver.observe(element); +}); + +// prettier-ignore +createInfoSection(content) + .addInstruction('Click on any viewport and drag up/down to select a region of the image') + .addInstruction('Wait for a few seconds to get the whole-body segmented') + +// ============================= // + +async function setUpToolGroups() { // Add tools to Cornerstone3D + cornerstoneTools.addTool(PanTool); + cornerstoneTools.addTool(ZoomTool); + cornerstoneTools.addTool(StackScrollTool); cornerstoneTools.addTool(WholeBodySegmentTool); - // Define a tool group, which defines how mouse events map to tool commands for - // Any viewport using the group - const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + // Define tool groups for the main 9 viewports. + // Crosshairs currently only supports 3 viewports for a toolgroup due to the + // way it is constructed, but its configuration input allows us to synchronize + // multiple sets of 3 viewports. + const ctToolGroup = ToolGroupManager.createToolGroup(ctToolGroupId); + const ptToolGroup = ToolGroupManager.createToolGroup(ptToolGroupId); + + ctToolGroup.addViewport(viewportIds.CT.AXIAL, renderingEngineId); + ctToolGroup.addViewport(viewportIds.CT.SAGITTAL, renderingEngineId); + ptToolGroup.addViewport(viewportIds.PT.AXIAL, renderingEngineId); + ptToolGroup.addViewport(viewportIds.PT.SAGITTAL, renderingEngineId); + + // Manipulation Tools + for (const toolGroup of [ctToolGroup, ptToolGroup]) { + toolGroup.addTool(PanTool.toolName); + toolGroup.addTool(ZoomTool.toolName); + toolGroup.addTool(StackScrollTool.toolName); + toolGroup.addTool(WholeBodySegmentTool.toolName); + + toolGroup.setToolActive(WholeBodySegmentTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Left Click + }, + ], + }); + toolGroup.setToolActive(PanTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Auxiliary, // Middle Click + }, + ], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, // Right Click + }, + ], + }); - // Add the tools to the tool group - toolGroup.addTool(WholeBodySegmentTool.toolName); + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Wheel, + }, + ], + }); + } +} - // Set the initial state of the tools, here we set one tool active on left click. - // This means left click will draw that tool. - toolGroup.setToolActive(WholeBodySegmentTool.toolName, { - bindings: [ - { - mouseButton: MouseBindings.Primary, // Left Click - }, - ], +function getPtImageIds() { + return createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.879445243400782656317561081015', + wadoRsRoot, }); - - addManipulationBindings(toolGroup); - - // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ - StudyInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', +} +function getCtImageIds() { + return createImageIdsAndCacheMetaData({ + StudyInstanceUID, SeriesInstanceUID: '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', - wadoRsRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', - }); - - // Define a volume in memory - const volume = await volumeLoader.createAndCacheVolume(volumeId, { - imageIds, + wadoRsRoot, }); +} - addSegmentationsToState(); - - // Instantiate a rendering engine - const renderingEngine = new RenderingEngine(renderingEngineId); +async function setUpDisplay() { + // Create the viewports - // Create a stack viewport const viewportInputArray = [ { - viewportId: viewportIdAxial, + viewportId: viewportIds.CT.AXIAL, type: ViewportType.ORTHOGRAPHIC, - element: elementAxial, + element: element1_1, defaultOptions: { orientation: Enums.OrientationAxis.AXIAL, - background: [0, 0, 0], }, }, { - viewportId: viewportIdCoronal, + viewportId: viewportIds.CT.SAGITTAL, + type: ViewportType.ORTHOGRAPHIC, + element: element1_2, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + }, + }, + { + viewportId: viewportIds.PT.AXIAL, type: ViewportType.ORTHOGRAPHIC, - element: elementCoronal, + element: element2_1, defaultOptions: { - orientation: Enums.OrientationAxis.CORONAL, - background: [0, 0, 0], + orientation: Enums.OrientationAxis.AXIAL, + background: [1, 1, 1], }, }, { - viewportId: viewportIdSagittal, + viewportId: viewportIds.PT.SAGITTAL, type: ViewportType.ORTHOGRAPHIC, - element: elementSagittal, + element: element2_2, defaultOptions: { orientation: Enums.OrientationAxis.SAGITTAL, - background: [0, 0, 0], + background: [1, 1, 1], }, }, ]; renderingEngine.setViewports(viewportInputArray); - volume.load(); + // Set the volumes to load + ptVolume.load(); + ctVolume.load(); + // Set volumes on the viewports await setVolumesForViewports( renderingEngine, [ { - volumeId: volumeId, + volumeId: ctVolumeId, }, ], - [viewportIdAxial, viewportIdCoronal, viewportIdSagittal] + [viewportIds.CT.AXIAL, viewportIds.CT.SAGITTAL] ); - // Set the tool group on the viewport - toolGroup.addViewport(viewportIdAxial, renderingEngineId); - toolGroup.addViewport(viewportIdCoronal, renderingEngineId); - toolGroup.addViewport(viewportIdSagittal, renderingEngineId); - - const segMap = { - [viewportIdAxial]: [ - { - segmentationId, - }, - ], - [viewportIdCoronal]: [ + await setVolumesForViewports( + renderingEngine, + [ { - segmentationId, + volumeId: ptVolumeId, + callback: setPetTransferFunctionForVolumeActor, }, ], - [viewportIdSagittal]: [ - { - segmentationId, + [viewportIds.PT.AXIAL, viewportIds.PT.SAGITTAL] + ); + + // Render the viewports + renderingEngine.render(); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + // Instantiate a rendering engine + renderingEngine = new RenderingEngine(renderingEngineId); + // Get Cornerstone imageIds and fetch metadata into RAM + ctImageIds = await getCtImageIds(); + + ptImageIds = await getPtImageIds(); + + // Define a volume in memory + ctVolume = await volumeLoader.createAndCacheVolume(ctVolumeId, { + imageIds: ctImageIds, + }); + // Define a volume in memory + ptVolume = await volumeLoader.createAndCacheVolume(ptVolumeId, { + imageIds: ptImageIds, + }); + + // Create a segmentation of the same resolution as the source data + await volumeLoader.createAndCacheDerivedLabelmapVolume(ctVolumeId, { + volumeId: segmentationId, + }); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId, + representation: { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + data: { + volumeId: segmentationId, + referencedVolumeId: ctVolumeId, + }, }, - ], + }, + ]); + + // Display needs to be set up first so that we have viewport to reference for tools and synchronizers. + await setUpDisplay(); + + // Tools and synchronizers can be set up in any order. + await setUpToolGroups(); + + const segMap = { + [viewportIds.CT.AXIAL]: [{ segmentationId }], + [viewportIds.CT.SAGITTAL]: [{ segmentationId }], + [viewportIds.PT.AXIAL]: [{ segmentationId }], + [viewportIds.PT.SAGITTAL]: [{ segmentationId }], }; - // Add the segmentation representation to the toolgroup + await segmentation.addLabelmapRepresentationToViewportMap(segMap); } diff --git a/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForKeyboardEvents.ts b/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForKeyboardEvents.ts index 5b54857733..b9efe4192f 100644 --- a/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForKeyboardEvents.ts +++ b/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForKeyboardEvents.ts @@ -39,7 +39,7 @@ export default function getToolsWithModesForKeyboardEvent( // eslint-disable-next-line @typescript-eslint/no-explicit-any const action = actions.find((action: any) => - action.bindings.some((binding) => binding.key === key) + action.bindings?.some((binding) => binding.key === key) ); if (action) { diff --git a/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForMouseEvent.ts b/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForMouseEvent.ts index 91929906fd..f56675c09c 100644 --- a/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForMouseEvent.ts +++ b/packages/tools/src/eventDispatchers/shared/getToolsWithActionsForMouseEvent.ts @@ -47,7 +47,7 @@ export default function getToolsWithActionsForMouseEvent( const action = actions.find( // eslint-disable-next-line @typescript-eslint/no-explicit-any (action: any) => - action.bindings.length && + action.bindings?.length && action.bindings.some( (binding) => binding.mouseButton === mouseButton && diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 5f0c623231..2bf3bc60f9 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -75,6 +75,7 @@ import { RegionSegmentPlusTool, RegionSegmentTool, WholeBodySegmentTool, + LabelmapBaseTool, } from './tools'; import VideoRedactionTool from './tools/annotation/VideoRedactionTool'; @@ -170,4 +171,5 @@ export { RegionSegmentPlusTool, RegionSegmentTool, WholeBodySegmentTool, + LabelmapBaseTool, }; diff --git a/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts b/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts index 5cc1bffc72..ff10b3133d 100644 --- a/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts +++ b/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts @@ -135,25 +135,6 @@ export default class SegmentationStateManager { * segmentationStateManager.updateSegmentation('seg1', { label: 'newLabel' }); * ``` */ - // updateSegmentation( - // segmentationId: string, - // payload: Partial - // ): void { - // this.updateState((state) => { - // const segmentation = state.segmentations.find( - // (segmentation) => segmentation.segmentationId === segmentationId - // ); - // if (!segmentation) { - // return; - // } - // state.segmentations = state.segmentations.map((segmentation) => { - // if (segmentation.segmentationId === segmentationId) { - // return { ...segmentation, ...payload }; - // } - // return segmentation; - // }); - // }); - // } updateSegmentation( segmentationId: string, payload: Partial diff --git a/packages/tools/src/stateManagement/segmentation/activeSegmentation.ts b/packages/tools/src/stateManagement/segmentation/activeSegmentation.ts index eb353c9a9c..614cf24817 100644 --- a/packages/tools/src/stateManagement/segmentation/activeSegmentation.ts +++ b/packages/tools/src/stateManagement/segmentation/activeSegmentation.ts @@ -19,8 +19,7 @@ function getActiveSegmentation(viewportId: string): Segmentation { */ function setActiveSegmentation( viewportId: string, - segmentationId: string, - suppressEvent: boolean = false + segmentationId: string ): void { _setActiveSegmentation(viewportId, segmentationId); } diff --git a/packages/tools/src/stateManagement/segmentation/index.ts b/packages/tools/src/stateManagement/segmentation/index.ts index 0f73a71110..6542417cb7 100644 --- a/packages/tools/src/stateManagement/segmentation/index.ts +++ b/packages/tools/src/stateManagement/segmentation/index.ts @@ -18,6 +18,7 @@ import { } from './addSegmentationRepresentationsToViewport'; import { addSegmentations } from './addSegmentations'; +import addRepresentationData from './internalAddRepresentationData'; import { updateSegmentations } from './updateSegmentations'; import * as activeSegmentation from './activeSegmentation'; import * as segmentLocking from './segmentLocking'; @@ -32,6 +33,7 @@ import { clearSegmentValue } from './helpers/clearSegmentValue'; import { convertVolumeToStackLabelmap } from './helpers/computeStackLabelmapFromVolume'; import { removeSegment } from './removeSegment'; import { getLabelmapImageIds } from './getLabelmapImageIds'; +import * as strategies from './../../tools/segmentation/strategies'; import { removeAllSegmentations, @@ -75,4 +77,6 @@ export { polySegManager as polySeg, removeSegment, getLabelmapImageIds, + addRepresentationData, + strategies, }; diff --git a/packages/tools/src/stateManagement/segmentation/polySeg/Contour/utils/createAndAddContourSegmentationsFromClippedSurfaces.ts b/packages/tools/src/stateManagement/segmentation/polySeg/Contour/utils/createAndAddContourSegmentationsFromClippedSurfaces.ts index 6f24674e1c..3c4f626bb1 100644 --- a/packages/tools/src/stateManagement/segmentation/polySeg/Contour/utils/createAndAddContourSegmentationsFromClippedSurfaces.ts +++ b/packages/tools/src/stateManagement/segmentation/polySeg/Contour/utils/createAndAddContourSegmentationsFromClippedSurfaces.ts @@ -72,7 +72,7 @@ export function createAndAddContourSegmentationsFromClippedSurfaces( addAnnotation(contourSegmentationAnnotation, viewport.element); - const currentSet = annotationUIDsMap.get(segmentIndex) || new Set(); + const currentSet = annotationUIDsMap?.get(segmentIndex) || new Set(); currentSet.add(contourSegmentationAnnotation.annotationUID); annotationUIDsMap.set(segmentIndex, currentSet); } diff --git a/packages/tools/src/tools/annotation/RegionSegmentPlusTool.ts b/packages/tools/src/tools/annotation/RegionSegmentPlusTool.ts index 655802f580..a8273633c9 100644 --- a/packages/tools/src/tools/annotation/RegionSegmentPlusTool.ts +++ b/packages/tools/src/tools/annotation/RegionSegmentPlusTool.ts @@ -1,11 +1,13 @@ -import { getRenderingEngine } from '@cornerstonejs/core'; +import { getRenderingEngine, utilities as csUtils } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import type { EventTypes, PublicToolProps, ToolProps } from '../../types'; import { growCut } from '../../utilities/segmentation'; -import type { GrowCutOneClickOptions as RegionSegmentPlusOptions } from '../../utilities/segmentation/growCut'; import GrowCutBaseTool from '../base/GrowCutBaseTool'; -import type { GrowCutToolData } from '../base/GrowCutBaseTool'; +import type { + GrowCutToolData, + RemoveIslandData, +} from '../base/GrowCutBaseTool'; type RegionSegmentPlusToolData = GrowCutToolData & { worldPoint: Types.Point3; @@ -23,6 +25,12 @@ class RegionSegmentPlusTool extends GrowCutBaseTool { positiveSeedVariance: 0.4, negativeSeedVariance: 0.9, subVolumePaddingPercentage: 0.1, + islandRemoval: { + /** + * Enable/disable island removal + */ + enabled: true, + }, }, } ) { @@ -36,43 +44,57 @@ class RegionSegmentPlusTool extends GrowCutBaseTool { const { currentPoints } = eventData; const { world: worldPoint } = currentPoints; - super.preMouseDownCallback(evt); + await super.preMouseDownCallback(evt); + + this.growCutData = csUtils.deepMerge(this.growCutData, { + worldPoint, + islandRemoval: { + worldIslandPoints: [worldPoint], + }, + }); + this.growCutData.worldPoint = worldPoint; + this.growCutData.islandRemoval = { + worldIslandPoints: [worldPoint], + }; this.runGrowCut(); return true; } - protected async getGrowCutLabelmap(): Promise { + protected getRemoveIslandData( + growCutData: RegionSegmentPlusToolData + ): RemoveIslandData { + const { worldPoint } = growCutData; + + return { + worldIslandPoints: [worldPoint], + }; + } + + protected async getGrowCutLabelmap(growCutData): Promise { const { - segmentation: { segmentIndex, referencedVolumeId }, + segmentation: { referencedVolumeId }, renderingEngineId, viewportId, worldPoint, - } = this.growCutData; + options, + } = growCutData; const renderingEngine = getRenderingEngine(renderingEngineId); const viewport = renderingEngine.getViewport(viewportId); - const { - positiveSeedVariance, - negativeSeedVariance, + const { subVolumePaddingPercentage } = this.configuration; + const mergedOptions = { + ...options, subVolumePaddingPercentage, - } = this.configuration; - - const options: RegionSegmentPlusOptions = { - positiveSeedValue: segmentIndex, - negativeSeedValue: 255, - positiveSeedVariance: positiveSeedVariance, - negativeSeedVariance: negativeSeedVariance, - subVolumePaddingPercentage: subVolumePaddingPercentage, }; - return await growCut.runOneClickGrowCut( + return growCut.runOneClickGrowCut( referencedVolumeId, worldPoint, viewport, - options + mergedOptions ); } } diff --git a/packages/tools/src/tools/annotation/RegionSegmentTool.ts b/packages/tools/src/tools/annotation/RegionSegmentTool.ts index d1b0045873..d1f50cd69a 100644 --- a/packages/tools/src/tools/annotation/RegionSegmentTool.ts +++ b/packages/tools/src/tools/annotation/RegionSegmentTool.ts @@ -38,7 +38,10 @@ class RegionSegmentTool extends GrowCutBaseTool { toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse', 'Touch'], - configuration: {}, + configuration: { + positiveSeedVariance: 0.5, + negativeSeedVariance: 0.9, + }, } ) { super(toolProps, defaultToolProps); @@ -53,7 +56,7 @@ class RegionSegmentTool extends GrowCutBaseTool { const enabledElement = getEnabledElement(element); const { viewport, renderingEngine } = enabledElement; - super.preMouseDownCallback(evt); + await super.preMouseDownCallback(evt); Object.assign(this.growCutData, { circleCenterPoint: worldPoint, @@ -94,14 +97,15 @@ class RegionSegmentTool extends GrowCutBaseTool { triggerAnnotationRenderForViewportUIDs([viewport.id]); }; - protected async getGrowCutLabelmap(): Promise { + protected async getGrowCutLabelmap(growCutData): Promise { const { - segmentation: { segmentIndex, referencedVolumeId }, + segmentation: { referencedVolumeId }, renderingEngineId, viewportId, circleCenterPoint, circleBorderPoint, - } = this.growCutData; + options, + } = growCutData; const renderingEngine = getRenderingEngine(renderingEngineId); const viewport = renderingEngine.getViewport(viewportId); const worldCircleRadius = vec3.len( @@ -116,10 +120,6 @@ class RegionSegmentTool extends GrowCutBaseTool { center: circleCenterPoint, radius: worldCircleRadius, }; - const options: GrowCutSphereOptions = { - positiveSeedValue: segmentIndex, - negativeSeedValue: 255, - }; return growCut.runGrowCutForSphere( referencedVolumeId, diff --git a/packages/tools/src/tools/annotation/SplineROITool.ts b/packages/tools/src/tools/annotation/SplineROITool.ts index 5be140c79a..9b13128359 100644 --- a/packages/tools/src/tools/annotation/SplineROITool.ts +++ b/packages/tools/src/tools/annotation/SplineROITool.ts @@ -438,13 +438,20 @@ class SplineROITool extends ContourSegmentationBaseTool { this.doneEditMemo(); const eventDetail = evt.detail; - const { currentPoints } = eventDetail; + const { currentPoints, element } = eventDetail; const { canvas: canvasPoint, world: worldPoint } = currentPoints; let closeContour = data.handles.points.length >= 2 && doubleClick; let addNewPoint = true; + if (data.handles.points.length) { + this.createMemo(element, annotation, { + newAnnotation: data.handles.points.length === 1, + }); + } + // Check if user clicked on the first point to close the curve if (data.handles.points.length >= 3) { + this.createMemo(element, annotation); const { instance: spline } = data.spline; const closestControlPoint = spline.getClosestControlPointWithinDistance( canvasPoint, diff --git a/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts b/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts index 23ef96e088..a21a95cbaa 100644 --- a/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts +++ b/packages/tools/src/tools/annotation/WholeBodySegmentTool.ts @@ -24,9 +24,17 @@ import type { import triggerAnnotationRenderForViewportUIDs from '../../utilities/triggerAnnotationRenderForViewportIds'; import { growCut } from '../../utilities/segmentation'; import type { GrowCutBoundingBoxOptions } from '../../utilities/segmentation/growCut'; -import type { GrowCutToolData } from '../base/GrowCutBaseTool'; +import type { + GrowCutToolData, + RemoveIslandData, +} from '../base/GrowCutBaseTool'; import GrowCutBaseTool from '../base/GrowCutBaseTool'; +// Positive and negative threshold/range (defaults to CT hounsfield ranges) +// https://www.sciencedirect.com/topics/medicine-and-dentistry/hounsfield-scale +const NEGATIVE_PIXEL_RANGE = [-Infinity, -995]; +const POSITIVE_PIXEL_RANGE = [0, 1900]; +const ISLAND_PIXEL_RANGE = [1000, 1900]; const { transformWorldToIndex, transformIndexToWorld } = csUtils; type HorizontalLine = [Types.Point3, Types.Point3]; @@ -43,7 +51,29 @@ class WholeBodySegmentTool extends GrowCutBaseTool { toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse', 'Touch'], - configuration: {}, + configuration: { + /** + * Range that shall be used to set voxel as positive seeds + */ + positivePixelRange: POSITIVE_PIXEL_RANGE, + /** + * Range that shall be used to set voxel as negative seeds + */ + negativePixelRange: NEGATIVE_PIXEL_RANGE, + islandRemoval: { + /** + * Enable/disable island removal + */ + enabled: true, + /** + * Range that shall be used to set a voxel as a valid island voxel. In + * this case all island that contains a valid voxel shall not be removed + * after running removeIsland() method. This is useful to do not add the + * bed the segmented data. + */ + islandPixelRange: ISLAND_PIXEL_RANGE, + }, + }, } ) { super(toolProps, defaultToolProps); @@ -62,7 +92,7 @@ class WholeBodySegmentTool extends GrowCutBaseTool { worldPoint ); - super.preMouseDownCallback(evt); + await super.preMouseDownCallback(evt); this.growCutData.horizontalLines = [linePoints, linePoints]; this._activateDraw(element); @@ -157,13 +187,13 @@ class WholeBodySegmentTool extends GrowCutBaseTool { ); } - protected async getGrowCutLabelmap(): Promise { + protected async getGrowCutLabelmap(growCutData): Promise { const { segmentation: { segmentIndex, referencedVolumeId }, renderingEngineId, viewportId, horizontalLines, - } = this.growCutData; + } = growCutData; const renderingEngine = getRenderingEngine(renderingEngineId); const viewport = renderingEngine.getViewport(viewportId); const [line1, line2] = horizontalLines; @@ -189,9 +219,12 @@ class WholeBodySegmentTool extends GrowCutBaseTool { }, }; + const config = this.configuration; const options: GrowCutBoundingBoxOptions = { positiveSeedValue: segmentIndex, negativeSeedValue: 255, + negativePixelRange: config.negativePixelRange, + positivePixelRange: config.positivePixelRange, }; return growCut.runGrowCutForBoundingBox( @@ -201,6 +234,46 @@ class WholeBodySegmentTool extends GrowCutBaseTool { ); } + protected getRemoveIslandData(): RemoveIslandData { + const { + segmentation: { segmentIndex, referencedVolumeId, labelmapVolumeId }, + } = this.growCutData; + const referencedVolume = cache.getVolume(referencedVolumeId); + const labelmapVolume = cache.getVolume(labelmapVolumeId); + const referencedVolumeData = + referencedVolume.voxelManager.getCompleteScalarDataArray(); + const labelmapData = + labelmapVolume.voxelManager.getCompleteScalarDataArray(); + const { islandPixelRange } = this.configuration.islandRemoval; + const islandPointIndexes = []; + + // Working with a range is not accurate enough because it may also include + // the bed in case a high pixel intensity is found on it. It may also not + // include the expected region in case there are no high density bones in + // the expected island. + // + // TODO: improve how it looks for pixels in the islands that need to be segmented + for (let i = 0, len = labelmapData.length; i < len; i++) { + if (labelmapData[i] !== segmentIndex) { + continue; + } + + // Keep all islands that has at least one pixel in the `islandPixelRange` range + const pixelValue = referencedVolumeData[i]; + + if ( + pixelValue >= islandPixelRange[0] && + pixelValue <= islandPixelRange[1] + ) { + islandPointIndexes.push(i); + } + } + + return { + islandPointIndexes, + }; + } + private _activateDraw(element: HTMLDivElement): void { // @ts-expect-error element.addEventListener(Events.MOUSE_UP, this._endCallback); @@ -354,6 +427,6 @@ class WholeBodySegmentTool extends GrowCutBaseTool { } } -WholeBodySegmentTool.toolName = 'WholeBodySegmentTool'; +WholeBodySegmentTool.toolName = 'WholeBodySegment'; export default WholeBodySegmentTool; diff --git a/packages/tools/src/tools/base/AnnotationTool.ts b/packages/tools/src/tools/base/AnnotationTool.ts index cb7dbfdcae..346801a0c7 100644 --- a/packages/tools/src/tools/base/AnnotationTool.ts +++ b/packages/tools/src/tools/base/AnnotationTool.ts @@ -383,7 +383,7 @@ abstract class AnnotationTool extends AnnotationDisplayTool { * @param imageId - The annotation imageId * @returns */ - isSuvScaled( + public static isSuvScaled( viewport: Types.IStackViewport | Types.IVolumeViewport, targetId: string, imageId?: string @@ -398,6 +398,8 @@ abstract class AnnotationTool extends AnnotationDisplayTool { return typeof scalingModule?.suvbw === 'number'; } + isSuvScaled = AnnotationTool.isSuvScaled; + /** * Get the style that will be applied to all annotations such as length, cobb * angle, arrow annotate, etc. when rendered on a canvas or svg layer @@ -558,8 +560,10 @@ abstract class AnnotationTool extends AnnotationDisplayTool { annotation, deleting ); + const { viewport } = getEnabledElement(element) || {}; + viewport?.setViewReference(annotation.metadata); if (state.deleting === true) { - // Handle undeletion - note the state of deleting is internally + // Handle un deletion - note the state of deleting is internally // true/false/undefined to mean delete/re-create as these are opposite actions. state.deleting = false; Object.assign(annotation.data, state.data); @@ -585,7 +589,7 @@ abstract class AnnotationTool extends AnnotationDisplayTool { state.data = newState.data; addAnnotation(annotation, element); setAnnotationSelected(annotation.annotationUID, true); - getEnabledElement(element)?.viewport.render(); + viewport?.render(); return; } if (state.deleting === false) { @@ -595,7 +599,7 @@ abstract class AnnotationTool extends AnnotationDisplayTool { state.data = newState.data; setAnnotationSelected(annotation.annotationUID); removeAnnotation(annotation.annotationUID); - getEnabledElement(element)?.viewport.render(); + viewport?.render(); return; } const currentAnnotation = getAnnotation(annotationUID); diff --git a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts index c47aac2fb5..fb5cca9e93 100644 --- a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts +++ b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts @@ -36,6 +36,8 @@ import { getSVGStyleForSegment } from '../../utilities/segmentation/getSVGStyleF * and unregister contour segmentation annotations. */ abstract class ContourSegmentationBaseTool extends ContourBaseTool { + static PreviewSegmentIndex = 255; + constructor(toolProps: PublicToolProps, defaultToolProps: ToolProps) { super(toolProps, defaultToolProps); if (this.configuration.interpolation?.enabled) { diff --git a/packages/tools/src/tools/base/GrowCutBaseTool.ts b/packages/tools/src/tools/base/GrowCutBaseTool.ts index 3236e7ad97..bba3c8e0f2 100644 --- a/packages/tools/src/tools/base/GrowCutBaseTool.ts +++ b/packages/tools/src/tools/base/GrowCutBaseTool.ts @@ -2,8 +2,10 @@ import { getEnabledElement, utilities as csUtils, cache, + getRenderingEngine, + type Types, + StackViewport, } from '@cornerstonejs/core'; -import type { Types } from '@cornerstonejs/core'; import { BaseTool } from '../base'; import { SegmentationRepresentations } from '../../enums'; import type { @@ -21,6 +23,7 @@ import { triggerSegmentationDataModified } from '../../stateManagement/segmentat import type { LabelmapSegmentationDataVolume } from '../../types/LabelmapTypes'; import { getSVGStyleForSegment } from '../../utilities/segmentation/getSVGStyleForSegment'; +import IslandRemoval from '../../utilities/segmentation/islandRemoval'; const { transformWorldToIndex, transformIndexToWorld } = csUtils; @@ -34,17 +37,51 @@ type GrowCutToolData = { labelmapVolumeId: string; referencedVolumeId: string; }; + islandRemoval?: { + worldIslandPoints: Types.Point3[]; + }; viewportId: string; renderingEngineId: string; }; +/** + * Island removal data which currently includes only coordinates from islands + * that should not be removed by `IslandRemoval` class. Coordinates my be provided + * in world space, index space or theirs indices in the data array. + */ +type RemoveIslandData = { + // Coordinates in world space from islands that should not be removed + worldIslandPoints?: Types.Point3[]; + // Coordinates in index space from islands that should not be removed + ijkIslandPoints?: Types.Point3[]; + // Coordinate indices from islands that should not be removed + islandPointIndexes?: number[]; +}; + class GrowCutBaseTool extends BaseTool { static toolName; - protected growCutData: GrowCutToolData | null; + private static lastGrowCutCommand = null; constructor(toolProps: PublicToolProps, defaultToolProps: ToolProps) { - super(toolProps, defaultToolProps); + const baseToolProps = csUtils.deepMerge( + { + configuration: { + positiveSeedVariance: 0.1, + negativeSeedVariance: 0.9, + shrinkExpandIncrement: 0.05, + islandRemoval: { + /** + * Enable/disable island removal + */ + enabled: false, + }, + }, + }, + defaultToolProps + ); + + super(toolProps, baseToolProps); } async preMouseDownCallback( @@ -62,7 +99,7 @@ class GrowCutBaseTool extends BaseTool { segmentIndex, labelmapVolumeId, referencedVolumeId, - } = this.getLabelmapSegmentationData(viewport); + } = await this.getLabelmapSegmentationData(viewport); if (!this._isOrthogonalView(viewport, referencedVolumeId)) { throw new Error('Oblique view is not supported yet'); @@ -88,23 +125,83 @@ class GrowCutBaseTool extends BaseTool { return true; } - protected async getGrowCutLabelmap(): Promise { + public shrink() { + this._runLastCommand({ + shrinkExpandAmount: -this.configuration.shrinkExpandIncrement, + }); + } + + public expand() { + this._runLastCommand({ + shrinkExpandAmount: this.configuration.shrinkExpandIncrement, + }); + } + + public refresh() { + this._runLastCommand(); + } + + protected async getGrowCutLabelmap( + _growCutData: GrowCutToolData + ): Promise { throw new Error('Not implemented'); } protected async runGrowCut() { + const { growCutData, configuration: config } = this; const { segmentation: { segmentationId, segmentIndex, labelmapVolumeId }, - } = this.growCutData; + } = growCutData; + + const hasSeedVarianceData = + config.positiveSeedVariance !== undefined && + config.negativeSeedVariance !== undefined; + const labelmap = cache.getVolume(labelmapVolumeId); - const growcutLabelmap = await this.getGrowCutLabelmap(); + let shrinkExpandValue = 0; - this.applyGrowCutLabelmap( - segmentationId, - segmentIndex, - labelmap, - growcutLabelmap - ); + const growCutCommand = async ({ shrinkExpandAmount = 0 } = {}) => { + const { positiveSeedVariance, negativeSeedVariance } = config; + let newPositiveSeedVariance = undefined; + let newNegativeSeedVariance = undefined; + + shrinkExpandValue += shrinkExpandAmount; + + if (hasSeedVarianceData) { + newPositiveSeedVariance = positiveSeedVariance + shrinkExpandValue; + newNegativeSeedVariance = negativeSeedVariance + shrinkExpandValue; + } + + const updatedGrowCutData = Object.assign({}, growCutData, { + options: { + positiveSeedValue: segmentIndex, + negativeSeedValue: 255, + positiveSeedVariance: newPositiveSeedVariance, + negativeSeedVariance: newNegativeSeedVariance, + }, + }); + + const growcutLabelmap = await this.getGrowCutLabelmap(updatedGrowCutData); + + this.applyGrowCutLabelmap( + segmentationId, + segmentIndex, + labelmap, + growcutLabelmap + ); + + this._removeIslands(growCutData); + }; + + // run and store the command for later execution + await growCutCommand(); + + // Only growcut with seed variance data can shrink/expand + if (hasSeedVarianceData) { + GrowCutBaseTool.lastGrowCutCommand = growCutCommand; + } + + this.growCutData = null; } protected applyGrowCutLabelmap( @@ -115,8 +212,7 @@ class GrowCutBaseTool extends BaseTool { ) { const srcLabelmapData = sourceLabelmap.voxelManager.getCompleteScalarDataArray(); - const targetLabelmapData = - targetLabelmap.voxelManager.getCompleteScalarDataArray() as Types.PixelDataTypedArray; + const tgtVoxelManager = targetLabelmap.voxelManager; const [srcColumns, srcRows, srcNumSlices] = sourceLabelmap.dimensions; const [tgtColumns, tgtRows] = targetLabelmap.dimensions; @@ -134,7 +230,7 @@ class GrowCutBaseTool extends BaseTool { // - from world space to volume index space // // TODO: create a matrix that coverts the coordinates from sub-volume - // index space to volume index space without getting into world space. + // index space to volume index space without getting into world space const srcRowIJK: Types.Point3 = [0, srcRow, srcSlice]; const rowVoxelWorld = transformIndexToWorld( sourceLabelmap.imageData, @@ -150,41 +246,49 @@ class GrowCutBaseTool extends BaseTool { tgtColumn + tgtRow * tgtColumns + tgtSlice * tgtPixelsPerSlice; for (let column = 0; column < srcColumns; column++) { - targetLabelmapData[tgtOffset + column] = + const labelmapValue = srcLabelmapData[srcOffset + column] === segmentIndex ? segmentIndex : 0; + + tgtVoxelManager.setAtIndex(tgtOffset + column, labelmapValue); } } } - targetLabelmap.voxelManager.setCompleteScalarDataArray(targetLabelmapData); - triggerSegmentationDataModified(segmentationId); } - protected getSegmentStyle({ segmentationId, viewportId, segmentIndex }) { - return getSVGStyleForSegment({ - segmentationId, - segmentIndex, - viewportId, - }); + private _runLastCommand({ shrinkExpandAmount = 0 } = {}) { + const cmd = GrowCutBaseTool.lastGrowCutCommand; + + if (cmd) { + cmd({ shrinkExpandAmount }); + } } - protected getLabelmapSegmentationData(viewport: Types.IViewport) { - const { segmentationId } = activeSegmentation.getActiveSegmentation( - viewport.id - ); + protected async getLabelmapSegmentationData(viewport: Types.IViewport) { + const activeSeg = activeSegmentation.getActiveSegmentation(viewport.id); + + if (!activeSeg) { + throw new Error('No active segmentation found'); + } + + const { segmentationId } = activeSeg; + const segmentIndex = segmentIndexController.getActiveSegmentIndex(segmentationId); const { representationData } = segmentationState.getSegmentation(segmentationId); const labelmapData = representationData[SegmentationRepresentations.Labelmap]; - const { volumeId: labelmapVolumeId, referencedVolumeId } = labelmapData as LabelmapSegmentationDataVolume; + if (!labelmapVolumeId) { + throw new Error('Labelmap volume id not found - not implemented'); + } + return { segmentationId, segmentIndex, @@ -212,9 +316,103 @@ class GrowCutBaseTool extends BaseTool { csUtils.isEqual(Math.abs(vec[2]), 1) ); } + + protected getRemoveIslandData( + _growCutData: GrowCutToolData + ): RemoveIslandData { + // Child class with island removal enabled needs to override this method + return; + } + + private _removeIslands(growCutData: GrowCutToolData) { + const { islandRemoval: config } = this.configuration; + + if (!config.enabled) { + return; + } + + const { + segmentation: { segmentIndex, labelmapVolumeId }, + renderingEngineId, + viewportId, + } = growCutData; + + const labelmap = cache.getVolume(labelmapVolumeId); + const removeIslandData = this.getRemoveIslandData(growCutData); + + if (!removeIslandData) { + return; + } + + const [width, height] = labelmap.dimensions; + const numPixelsPerSlice = width * height; + const { worldIslandPoints = [], islandPointIndexes = [] } = + removeIslandData; + let ijkIslandPoints = [...(removeIslandData?.ijkIslandPoints ?? [])]; + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport(viewportId); + const { voxelManager } = labelmap; + const islandRemoval = new IslandRemoval(); + + ijkIslandPoints = ijkIslandPoints.concat( + worldIslandPoints.map((worldPoint) => + transformWorldToIndex(labelmap.imageData, worldPoint) + ) + ); + + ijkIslandPoints = ijkIslandPoints.concat( + islandPointIndexes.map((pointIndex) => { + const x = pointIndex % width; + const y = Math.floor(pointIndex / width) % height; + const z = Math.floor(pointIndex / numPixelsPerSlice); + + return [x, y, z]; + }) + ); + + islandRemoval.initialize(viewport, voxelManager, { + points: ijkIslandPoints, + previewSegmentIndex: segmentIndex, + segmentIndex, + }); + + islandRemoval.floodFillSegmentIsland(); + islandRemoval.removeExternalIslands(); + islandRemoval.removeInternalIslands(); + } + + protected getSegmentStyle({ segmentationId, viewportId, segmentIndex }) { + return getSVGStyleForSegment({ + segmentationId, + segmentIndex, + viewportId, + }); + } + + // protected getLabelmapSegmentationData(viewport: Types.IViewport) { + // const { segmentationId } = activeSegmentation.getActiveSegmentation( + // viewport.id + // ); + // const segmentIndex = + // segmentIndexController.getActiveSegmentIndex(segmentationId); + // const { representationData } = + // segmentationState.getSegmentation(segmentationId); + // const labelmapData = + // representationData[SegmentationRepresentations.Labelmap]; + + // const { volumeId: labelmapVolumeId, referencedVolumeId } = + // labelmapData as LabelmapSegmentationDataVolume; + + // return { + // segmentationId, + // segmentIndex, + // labelmapVolumeId, + // referencedVolumeId, + // }; + // } } GrowCutBaseTool.toolName = 'GrowCutBaseTool'; export default GrowCutBaseTool; -export type { GrowCutToolData }; +export type { GrowCutToolData, RemoveIslandData }; diff --git a/packages/tools/src/tools/index.ts b/packages/tools/src/tools/index.ts index 8c529412fb..4d8e26afb2 100644 --- a/packages/tools/src/tools/index.ts +++ b/packages/tools/src/tools/index.ts @@ -43,6 +43,7 @@ import AnnotationEraserTool from './AnnotationEraserTool'; import RegionSegmentTool from './annotation/RegionSegmentTool'; import RegionSegmentPlusTool from './annotation/RegionSegmentPlusTool'; import WholeBodySegmentTool from './annotation/WholeBodySegmentTool'; +import LabelmapBaseTool from './segmentation/LabelmapBaseTool'; // Segmentation Tools import RectangleScissorsTool from './segmentation/RectangleScissorsTool'; @@ -56,6 +57,8 @@ import PaintFillTool from './segmentation/PaintFillTool'; import OrientationMarkerTool from './OrientationMarkerTool'; import SegmentSelectTool from './segmentation/SegmentSelectTool'; +import * as strategies from './segmentation/strategies'; + export { // ~~ BASE BaseTool, @@ -118,4 +121,6 @@ export { RegionSegmentTool, RegionSegmentPlusTool, WholeBodySegmentTool, + LabelmapBaseTool, + strategies, }; diff --git a/packages/tools/src/tools/segmentation/BrushTool.ts b/packages/tools/src/tools/segmentation/BrushTool.ts index ae77c78ca9..6a627e6649 100644 --- a/packages/tools/src/tools/segmentation/BrushTool.ts +++ b/packages/tools/src/tools/segmentation/BrushTool.ts @@ -35,7 +35,6 @@ import LabelmapBaseTool from './LabelmapBaseTool'; class BrushTool extends LabelmapBaseTool { static toolName; - prg; constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { diff --git a/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts index f2b6724f80..5693a71693 100644 --- a/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts +++ b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts @@ -23,6 +23,14 @@ import { getSegmentIndexColor } from '../../stateManagement/segmentation/config/ import { getActiveSegmentIndex } from '../../stateManagement/segmentation/getActiveSegmentIndex'; import { StrategyCallbacks } from '../../enums'; import * as LabelmapMemo from '../../utilities/segmentation/createLabelmapMemo'; +import { + getAllAnnotations, + removeAnnotation, +} from '../../stateManagement/annotation/annotationState'; +import { filterAnnotationsForDisplay } from '../../utilities/planar'; +import { isPointInsidePolyline3D } from '../../utilities/math/polyline'; +import { triggerSegmentationDataModified } from '../../stateManagement/segmentation/triggerSegmentationEvents'; +import { fillInsideCircle } from './strategies'; /** * A type for preview data/information, used to setup previews on hover, or @@ -65,6 +73,7 @@ export default class LabelmapBaseTool extends BaseTool { volumeId?: string; // volume labelmap referencedVolumeId?: string; } | null; + protected _hoverData?: { // eslint-disable-next-line @typescript-eslint/no-explicit-any brushCursor: any; @@ -76,7 +85,7 @@ export default class LabelmapBaseTool extends BaseTool { viewport: Types.IViewport; }; - protected _previewData?: PreviewData = { + public static previewData?: PreviewData = { preview: null, element: null, timerStart: 0, @@ -89,6 +98,11 @@ export default class LabelmapBaseTool extends BaseTool { super(toolProps, defaultToolProps); } + // Gets a shared preview data + protected get _previewData() { + return LabelmapBaseTool.previewData; + } + /** * Creates a labelmap memo instance, which is a partially created memo * object that stores the changes made to the labelmap rather than the @@ -315,7 +329,7 @@ export default class LabelmapBaseTool extends BaseTool { segmentIndex, previewColors: this.configuration.preview?.enabled || this._previewData.preview - ? this.configuration.preview.previewColors + ? this.configuration.preview?.previewColors : null, viewPlaneNormal, toolGroupId: this.toolGroupId, @@ -337,6 +351,7 @@ export default class LabelmapBaseTool extends BaseTool { element = this._previewData.element, options?: { acceptReject: boolean } ) { + const { _previewData } = this; const acceptReject = options?.acceptReject; if (acceptReject === true) { this.acceptPreview(element); @@ -344,13 +359,13 @@ export default class LabelmapBaseTool extends BaseTool { this.rejectPreview(element); } const enabledElement = getEnabledElement(element); - this._previewData.preview = this.applyActiveStrategyCallback( + _previewData.preview = this.applyActiveStrategyCallback( enabledElement, this.getOperationData(element), StrategyCallbacks.AddPreview ); - this._previewData.isDrag = true; - return this._previewData.preview; + _previewData.isDrag = true; + return _previewData.preview; } /** @@ -392,4 +407,118 @@ export default class LabelmapBaseTool extends BaseTool { // Store the edit memo too this.doneEditMemo(); } + + /** + * This function converts contours on this view into labelmap data, using the + * handle[0] state + */ + public static viewportContoursToLabelmap( + viewport: Types.IViewport, + options?: { removeContours: boolean } + ) { + const removeContours = options?.removeContours ?? true; + const annotations = getAllAnnotations(); + const viewAnnotations = filterAnnotationsForDisplay(viewport, annotations); + if (!viewAnnotations?.length) { + return; + } + const contourAnnotations = viewAnnotations.filter( + // @ts-expect-error + (annotation) => annotation.data.contour?.polyline?.length + ); + if (!contourAnnotations.length) { + return; + } + + const brushInstance = new LabelmapBaseTool( + {}, + { + configuration: { + strategies: { + FILL_INSIDE_CIRCLE: fillInsideCircle, + }, + activeStrategy: 'FILL_INSIDE_CIRCLE', + }, + } + ); + const preview = brushInstance.addPreview(viewport.element); + + // @ts-expect-error + const { memo, segmentationId } = preview; + // @ts-expect-error + const previewVoxels = memo?.voxelManager || preview.previewVoxelManager; + const segmentationVoxels = + previewVoxels.sourceVoxelManager || previewVoxels; + const { dimensions } = previewVoxels; + + // Create an undo history for the operation + // Iterate through the canvas space in canvas index coordinates + const imageData = viewport + .getDefaultActor() + .actor.getMapper() + .getInputData(); + + for (const annotation of contourAnnotations) { + const boundsIJK = [ + [Infinity, -Infinity], + [Infinity, -Infinity], + [Infinity, -Infinity], + ]; + + // @ts-expect-error + const { polyline } = annotation.data.contour; + for (const point of polyline) { + const indexPoint = imageData.worldToIndex(point); + indexPoint.forEach((v, idx) => { + boundsIJK[idx][0] = Math.min(boundsIJK[idx][0], v); + boundsIJK[idx][1] = Math.max(boundsIJK[idx][1], v); + }); + } + + boundsIJK.forEach((bound, idx) => { + bound[0] = Math.round(Math.max(0, bound[0])); + bound[1] = Math.round(Math.min(dimensions[idx] - 1, bound[1])); + }); + + const activeIndex = getActiveSegmentIndex(segmentationId); + const startPoint = annotation.data.handles?.[0] || polyline[0]; + const startIndex = imageData.worldToIndex(startPoint).map(Math.round); + const startValue = segmentationVoxels.getAtIJKPoint(startIndex) || 0; + let hasZeroIndex = false; + let hasPositiveIndex = false; + for (const polyPoint of polyline) { + const polyIndex = imageData.worldToIndex(polyPoint).map(Math.round); + const polyValue = segmentationVoxels.getAtIJKPoint(polyIndex); + if (polyValue === startValue) { + hasZeroIndex = true; + } else if (polyValue >= 0) { + hasPositiveIndex = true; + } + } + const hasBoth = hasZeroIndex && hasPositiveIndex; + const segmentIndex = hasBoth + ? startValue + : startValue === 0 + ? activeIndex + : 0; + for (let i = boundsIJK[0][0]; i <= boundsIJK[0][1]; i++) { + for (let j = boundsIJK[1][0]; j <= boundsIJK[1][1]; j++) { + for (let k = boundsIJK[2][0]; k <= boundsIJK[2][1]; k++) { + const worldPoint = imageData.indexToWorld([i, j, k]); + const isContained = isPointInsidePolyline3D(worldPoint, polyline); + if (isContained) { + previewVoxels.setAtIJK(i, j, k, segmentIndex); + } + } + } + } + + if (removeContours) { + removeAnnotation(annotation.annotationUID); + } + } + + const slices = previewVoxels.getArrayOfModifiedSlices(); + triggerSegmentationDataModified(segmentationId, slices); + } } diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/index.ts b/packages/tools/src/tools/segmentation/strategies/compositions/index.ts index 22687a9c75..eed42b7a5f 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/index.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/index.ts @@ -1,7 +1,7 @@ import determineSegmentIndex from './determineSegmentIndex'; import dynamicThreshold from './dynamicThreshold'; import erase from './erase'; -import islandRemoval from './islandRemoval'; +import islandRemoval from './islandRemovalComposition'; import preview from './preview'; import regionFill from './regionFill'; import setValue from './setValue'; diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/islandRemoval.ts b/packages/tools/src/tools/segmentation/strategies/compositions/islandRemoval.ts deleted file mode 100644 index 159fdb95cc..0000000000 --- a/packages/tools/src/tools/segmentation/strategies/compositions/islandRemoval.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { utilities } from '@cornerstonejs/core'; -import type { Types } from '@cornerstonejs/core'; -import type { InitializedOperationData } from '../BrushStrategy'; -import { triggerSegmentationDataModified } from '../../../../stateManagement/segmentation/triggerSegmentationEvents'; -import StrategyCallbacks from '../../../../enums/StrategyCallbacks'; -import normalizeViewportPlane from '../utils/normalizeViewportPlane'; - -const { RLEVoxelMap } = utilities; - -// The maximum size of a dimension on an image in DICOM -// Note, does not work for whole slide imaging -const MAX_IMAGE_SIZE = 65535; - -export enum SegmentationEnum { - // Segment means it is in the segment or preview of interest - SEGMENT = 1, - // Island means it is connected to a selected point - ISLAND = 2, - // Interior means it is inside the island, or possibly inside - INTERIOR = 3, - // Exterior means it is outside the island - EXTERIOR = 4, - // Exterior small means it is small enough to flood it - INTERIOR_SMALL = -5, - // Interior test means the segment is being size tested. - INTERIOR_TEST = -6, -} - -/** - * Removes external islands and fills internal islands. - * External islands are areas of preview which are not connected via fill or - * preview colours to the clicked/dragged over points. - * Internal islands are areas of non-preview which are entirely surrounded by - * colours connected to the clicked/dragged over points. - */ -export default { - [StrategyCallbacks.OnInteractionEnd]: ( - operationData: InitializedOperationData - ) => { - const { strategySpecificConfiguration, previewSegmentIndex, segmentIndex } = - operationData; - - if ( - !strategySpecificConfiguration.THRESHOLD || - segmentIndex === null || - previewSegmentIndex === undefined - ) { - return; - } - - const segmentSet = createSegmentSet(operationData); - if (!segmentSet) { - return; - } - const externalRemoved = removeExternalIslands(operationData, segmentSet); - if (externalRemoved === undefined) { - // Nothing to remove - return; - } - const arrayOfSlices = removeInternalIslands(operationData, segmentSet); - if (!arrayOfSlices) { - return; - } - - triggerSegmentationDataModified( - operationData.segmentationId, - arrayOfSlices, - previewSegmentIndex - ); - }, -}; - -/** - * Creates a segment set - an RLE based map of points to segment data. - * This function returns the data in the appropriate planar orientation according - * to the view, with SegmentationEnum.SEGMENT set for any point within the segment, - * either preview or base segment colour. - * - * Returns undefined if the data is invalid for some reason. - */ -export function createSegmentSet(operationData: InitializedOperationData) { - const { - segmentationVoxelManager, - previewSegmentIndex, - previewVoxelManager, - segmentIndex, - viewport, - } = operationData; - - const clickedPoints = previewVoxelManager.getPoints(); - if (!clickedPoints?.length) { - return; - } - // Ensure the bounds includes the clicked points, otherwise the fill - // fails. - const boundsIJK = previewVoxelManager - .getBoundsIJK() - .map((bound, i) => [ - Math.min(bound[0], ...clickedPoints.map((point) => point[i])), - Math.max(bound[1], ...clickedPoints.map((point) => point[i])), - ]) as Types.BoundsIJK; - - if (boundsIJK.find((it) => it[0] < 0 || it[1] > MAX_IMAGE_SIZE)) { - // Nothing done, so just skip this - return; - } - - // First get the set of points which are directly connected to the points - // that the user clicked on/dragged over. - const { toIJK, fromIJK, boundsIJKPrime, error } = normalizeViewportPlane( - viewport, - boundsIJK - ); - - if (error) { - console.warn( - 'Not performing island removal for planes not orthogonal to acquisition plane', - error - ); - return; - } - - const [width, height, depth] = fromIJK(segmentationVoxelManager.dimensions); - const floodedSet = new RLEVoxelMap(width, height, depth); - - // Returns true for new colour, and false otherwise - const getter = (i, j, k) => { - const index = segmentationVoxelManager.toIndex(toIJK([i, j, k])); - const oldVal = segmentationVoxelManager.getAtIndex(index); - if (oldVal === previewSegmentIndex || oldVal === segmentIndex) { - // Values are initially false for indexed values. - return SegmentationEnum.SEGMENT; - } - }; - floodedSet.fillFrom(getter, boundsIJKPrime); - floodedSet.normalizer = { toIJK, fromIJK, boundsIJKPrime }; - return floodedSet; -} - -/** - * Handle islands which are internal to the flood fill - these are points which - * are surrounded entirely by the filled area. - * Start by getting the island map - that is, the output from the previous - * external island removal. Then, mark all the points in between two islands - * as being "Interior". The set of points marked interior is within a boundary - * point on the left and right, but may still be open above or below. To - * test that, perform a flood fill on the interior points, and see if it is - * entirely contained ('covered') on the top and bottom. - * Note this is done in a planar fashion, that is one plane at a time, but - * covering all planes that have interior data. That removes islands that - * are interior to the currently displayed view to be handled. - */ -function removeInternalIslands( - operationData: InitializedOperationData, - floodedSet -) { - const { height, normalizer } = floodedSet; - const { toIJK } = normalizer; - const { previewVoxelManager, previewSegmentIndex } = operationData; - - floodedSet.forEachRow((baseIndex, row) => { - let lastRle; - for (const rle of [...row]) { - if (rle.value !== SegmentationEnum.ISLAND) { - continue; - } - if (!lastRle) { - lastRle = rle; - continue; - } - for (let iPrime = lastRle.end; iPrime < rle.start; iPrime++) { - floodedSet.set(baseIndex + iPrime, SegmentationEnum.INTERIOR); - } - lastRle = rle; - } - }); - // Next, remove the island sets which are adjacent to an opening - floodedSet.forEach((baseIndex, rle) => { - if (rle.value !== SegmentationEnum.INTERIOR) { - // Already filled/handled - return; - } - const [, jPrime, kPrime] = floodedSet.toIJK(baseIndex); - const rowPrev = jPrime > 0 ? floodedSet.getRun(jPrime - 1, kPrime) : null; - const rowNext = - jPrime + 1 < height ? floodedSet.getRun(jPrime + 1, kPrime) : null; - const prevCovers = covers(rle, rowPrev); - const nextCovers = covers(rle, rowNext); - if (rle.end - rle.start > 2 && (!prevCovers || !nextCovers)) { - floodedSet.floodFill( - rle.start, - jPrime, - kPrime, - SegmentationEnum.EXTERIOR, - { singlePlane: true } - ); - } - }); - - // Finally, for all the islands, fill them in with the preview colour as - // they are now internal - floodedSet.forEach((baseIndex, rle) => { - if (rle.value !== SegmentationEnum.INTERIOR) { - return; - } - for (let iPrime = rle.start; iPrime < rle.end; iPrime++) { - const clearPoint = toIJK(floodedSet.toIJK(baseIndex + iPrime)); - previewVoxelManager.setAtIJKPoint(clearPoint, previewSegmentIndex); - } - }); - return previewVoxelManager.getArrayOfModifiedSlices(); -} - -/** - * This part removes external islands. External islands are regions of voxels which - * are not connected to the selected/click points. The algorithm is to - * start with all of the clicked points, performing a flood fill along all - * sections that are within the given segment, replacing the "SEGMENT" - * indicator with a new "ISLAND" indicator. Then, every point in the - * preview that is not marked as ISLAND is now external and can be reset to - * the value it had before the flood fill was initiated. - */ -function removeExternalIslands( - operationData: InitializedOperationData, - floodedSet -) { - const { previewVoxelManager } = operationData; - const { toIJK, fromIJK } = floodedSet.normalizer; - const clickedPoints = previewVoxelManager.getPoints(); - - // Just used to count up how many points got filled. - let floodedCount = 0; - - // First mark everything as island that is connected to a start point - clickedPoints.forEach((clickedPoint) => { - const ijkPrime = fromIJK(clickedPoint); - const index = floodedSet.toIndex(ijkPrime); - const [iPrime, jPrime, kPrime] = ijkPrime; - if (floodedSet.get(index) === SegmentationEnum.SEGMENT) { - floodedCount += floodedSet.floodFill( - iPrime, - jPrime, - kPrime, - SegmentationEnum.ISLAND - ); - } - }); - - if (floodedCount === 0) { - return; - } - // Next, iterate over all points which were set to a new value in the preview - // For everything NOT connected to something in set of clicked points, - // remove it from the preview. - - const callback = (index, rle) => { - const [, jPrime, kPrime] = floodedSet.toIJK(index); - if (rle.value !== SegmentationEnum.ISLAND) { - for (let iPrime = rle.start; iPrime < rle.end; iPrime++) { - const clearPoint = toIJK([iPrime, jPrime, kPrime]); - // preview voxel manager knows to reset on null - previewVoxelManager.setAtIJKPoint(clearPoint, null); - } - } - }; - - floodedSet.forEach(callback, { rowModified: true }); - - return floodedCount; -} - -/** - * Determine if the rle `[start...end)` is covered by row completely, by which - * it is meant that the row has RLE elements from the start to the end of the - * RLE section, matching every index i in the start to end. - */ -export function covers(rle, row) { - if (!row) { - return false; - } - let { start } = rle; - const { end } = rle; - for (const rowRle of row) { - if (start >= rowRle.start && start < rowRle.end) { - start = rowRle.end; - if (start >= end) { - return true; - } - } - } - return false; -} diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/islandRemovalComposition.ts b/packages/tools/src/tools/segmentation/strategies/compositions/islandRemovalComposition.ts new file mode 100644 index 0000000000..a3a7cf90aa --- /dev/null +++ b/packages/tools/src/tools/segmentation/strategies/compositions/islandRemovalComposition.ts @@ -0,0 +1,54 @@ +import type { InitializedOperationData } from '../BrushStrategy'; +import { triggerSegmentationDataModified } from '../../../../stateManagement/segmentation/triggerSegmentationEvents'; +import StrategyCallbacks from '../../../../enums/StrategyCallbacks'; +import IslandRemoval from '../../../../utilities/segmentation/islandRemoval'; + +/** + * Removes external islands and fills internal islands. + * External islands are areas of preview which are not connected via fill or + * preview colours to the clicked/dragged over points. + * Internal islands are areas of non-preview which are entirely surrounded by + * colours connected to the clicked/dragged over points. + */ +export default { + [StrategyCallbacks.OnInteractionEnd]: ( + operationData: InitializedOperationData + ) => { + const { + strategySpecificConfiguration, + previewSegmentIndex, + segmentIndex, + viewport, + previewVoxelManager, + segmentationVoxelManager, + } = operationData; + + if (!strategySpecificConfiguration.THRESHOLD || segmentIndex === null) { + return; + } + + const islandRemoval = new IslandRemoval(); + const voxelManager = previewVoxelManager ?? segmentationVoxelManager; + if ( + !islandRemoval.initialize(viewport, voxelManager, { + previewSegmentIndex, + segmentIndex, + }) + ) { + return; + } + islandRemoval.floodFillSegmentIsland(); + islandRemoval.removeExternalIslands(); + islandRemoval.removeInternalIslands(); + const arrayOfSlices = voxelManager.getArrayOfModifiedSlices(); + if (!arrayOfSlices) { + return; + } + + triggerSegmentationDataModified( + operationData.segmentationId, + arrayOfSlices, + previewSegmentIndex + ); + }, +}; diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/labelmapStatistics.ts b/packages/tools/src/tools/segmentation/strategies/compositions/labelmapStatistics.ts index fc66df8677..f14bc7738c 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/labelmapStatistics.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/labelmapStatistics.ts @@ -3,6 +3,13 @@ import type { InitializedOperationData } from '../BrushStrategy'; import VolumetricCalculator from '../../../../utilities/segmentation/VolumetricCalculator'; import { getActiveSegmentIndex } from '../../../../stateManagement/segmentation/getActiveSegmentIndex'; import { getStrategyData } from '../utils/getStrategyData'; +import { utilities, type Types } from '@cornerstonejs/core'; +import { getPixelValueUnits } from '../../../../utilities/getPixelValueUnits'; +import { AnnotationTool } from '../../../base'; +import { isViewportPreScaled } from '../../../../utilities/viewport/isViewportPreScaled'; + +// Radius for a volume of 10, eg 1 cm^3 = 1000 mm^3 +const radiusForVol1 = Math.pow((3 * 1000) / (4 * Math.PI), 1 / 3); /** * Compute basic labelmap segmentation statistics. @@ -46,9 +53,106 @@ export default { return; } const imageValue = imageVoxelManager.getAtIJKPoint(pointIJK); - VolumetricCalculator.statsCallback({ value: imageValue }); + VolumetricCalculator.statsCallback({ value: imageValue, pointIJK }); }); + const targetId = viewport.getReferenceId(); + const modalityUnitOptions = { + isPreScaled: isViewportPreScaled(viewport, targetId), + isSuvScaled: AnnotationTool.isSuvScaled( + viewport, + targetId, + viewport.getCurrentImageId() + ), + }; + + const imageData = (viewport as Types.IVolumeViewport).getImageData(); + const unit = getPixelValueUnits( + imageData.metadata.Modality, + viewport.getCurrentImageId(), + modalityUnitOptions + ); + + const stats = VolumetricCalculator.getStatistics({ spacing, unit }); + const { maxIJKs } = stats; + if (!maxIJKs?.length) { + return stats; + } + + // The calculation isn't very good at setting units + stats.mean.unit = unit; + stats.max.unit = unit; + stats.min.unit = unit; - return VolumetricCalculator.getStatistics({ spacing }); + if (unit !== 'SUV') { + return; + } + + // Get the IJK rounded radius, not using less than 1, and using the + // radius for the spacing given the desired mm spacing of 10 + // Add 10% to the radius to account for whole pixel in/out issues + const radiusIJK = spacing.map((s) => + Math.max(1, Math.round((1.1 * radiusForVol1) / s)) + ); + for (const testMax of maxIJKs) { + const testStats = getSphereStats( + testMax, + radiusIJK, + segmentationImageData, + imageVoxelManager, + spacing + ); + if (!testStats) { + continue; + } + const { mean } = testStats; + // @ts-expect-error - TODO: fix this + if (!stats.peakValue || stats.peakValue.value <= mean.value) { + // @ts-expect-error - TODO: fix this + stats.peakValue = { + name: 'peakValue', + label: 'Peak Value', + value: mean.value, + unit, + }; + } + } + + return stats; }, }; + +/** + * Gets the statistics for a 1 cm^3 sphere centered on radiusIJK. + * Assumes the segmentation and pixel data are co-incident. + */ +function getSphereStats(testMax, radiusIJK, segData, imageVoxels, spacing) { + const { pointIJK: centerIJK } = testMax; + const boundsIJK = centerIJK.map((ijk, idx) => [ + ijk - radiusIJK[idx], + ijk + radiusIJK[idx], + ]); + const testFunction = (_pointLPS, pointIJK) => { + const i = (pointIJK[0] - centerIJK[0]) / radiusIJK[0]; + const j = (pointIJK[1] - centerIJK[1]) / radiusIJK[1]; + const k = (pointIJK[2] - centerIJK[2]) / radiusIJK[2]; + const radius = i * i + j * j + k * k; + return radius <= 1; + }; + const statsFunction = ({ pointIJK, pointLPS }) => { + const value = imageVoxels.getAtIJKPoint(pointIJK); + if (value === undefined) { + return; + } + VolumetricCalculator.statsCallback({ value, pointLPS, pointIJK }); + }; + VolumetricCalculator.statsInit({ storePointData: false }); + // pointInShapeCallback(segData, testFunction, statsFunction, boundsIJK); + + utilities.pointInShapeCallback(segData, { + pointInShapeFn: testFunction, + callback: statsFunction, + boundsIJK, + }); + + return VolumetricCalculator.getStatistics({ spacing }); +} diff --git a/packages/tools/src/types/CalculatorTypes.ts b/packages/tools/src/types/CalculatorTypes.ts index 49aba1720d..e78be70576 100644 --- a/packages/tools/src/types/CalculatorTypes.ts +++ b/packages/tools/src/types/CalculatorTypes.ts @@ -17,6 +17,11 @@ type NamedStatistics = { volume?: Statistics & { name: 'volume' }; circumference?: Statistics & { name: 'circumference' }; pointsInShape?: Types.IPointsManager; + /** + * A set of stats callback arguments containing maximum values. + * This can be used to test peak intensities in the areas. + */ + maxIJKs?: Array<{ value: number; pointIJK: Types.Point3 }>; array: Statistics[]; }; diff --git a/packages/tools/src/utilities/contourSegmentation/addContourSegmentationAnnotation.ts b/packages/tools/src/utilities/contourSegmentation/addContourSegmentationAnnotation.ts index c8e8474a4c..4a5429b0e6 100644 --- a/packages/tools/src/utilities/contourSegmentation/addContourSegmentationAnnotation.ts +++ b/packages/tools/src/utilities/contourSegmentation/addContourSegmentationAnnotation.ts @@ -25,9 +25,13 @@ export function addContourSegmentationAnnotation( segmentation.representationData.Contour = { annotationUIDsMap: new Map() }; } - const { annotationUIDsMap } = segmentation.representationData.Contour; + let { annotationUIDsMap } = segmentation.representationData.Contour; - let annotationsUIDsSet = annotationUIDsMap.get(segmentIndex); + if (!annotationUIDsMap) { + annotationUIDsMap = new Map(); + } + + let annotationsUIDsSet = annotationUIDsMap?.get(segmentIndex); if (!annotationsUIDsSet) { annotationsUIDsSet = new Set(); diff --git a/packages/tools/src/utilities/index.ts b/packages/tools/src/utilities/index.ts index 2bb958265e..d23be0455c 100644 --- a/packages/tools/src/utilities/index.ts +++ b/packages/tools/src/utilities/index.ts @@ -48,6 +48,8 @@ import * as voi from './voi'; import * as contourSegmentation from './contourSegmentation'; import { pointInSurroundingSphereCallback } from './pointInSurroundingSphereCallback'; const roundNumber = utilities.roundNumber; +import normalizeViewportPlane from './normalizeViewportPlane'; +import IslandRemoval from './segmentation/islandRemoval'; export { math, @@ -90,4 +92,6 @@ export { annotationHydration, getClosestImageIdForStackViewport, pointInSurroundingSphereCallback, + normalizeViewportPlane, + IslandRemoval, }; diff --git a/packages/tools/src/tools/segmentation/strategies/utils/normalizeViewportPlane.ts b/packages/tools/src/utilities/normalizeViewportPlane.ts similarity index 94% rename from packages/tools/src/tools/segmentation/strategies/utils/normalizeViewportPlane.ts rename to packages/tools/src/utilities/normalizeViewportPlane.ts index 13502a32bb..6546c10081 100644 --- a/packages/tools/src/tools/segmentation/strategies/utils/normalizeViewportPlane.ts +++ b/packages/tools/src/utilities/normalizeViewportPlane.ts @@ -28,6 +28,9 @@ const ikMapping = { * the underlying view space. * As well, the function returns a dimension for the total view space that * corresponds to a `[0,dimension)` index for the given bounds. + * + * Basically, this allows creating a view or even instantiating a non-acquisition + * plane representation of an MPR view. */ export default function normalizeViewportPlane( viewport: Types.IViewport, diff --git a/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts b/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts index fe9c8dc056..0b1689b3dd 100644 --- a/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts +++ b/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts @@ -83,7 +83,9 @@ export default function filterAnnotationsWithinSlice( for (const annotation of annotationsWithParallelNormals) { const data = annotation.data; - const point = data.handles.points[0]; + + // @ts-expect-error + const point = data.handles.points[0] || data.contour?.polyline[0]; if (!annotation.isVisible) { continue; @@ -101,7 +103,7 @@ export default function filterAnnotationsWithinSlice( // should just be included. if (!point) { annotationsWithinSlice.push(annotation); - return; + continue; } vec3.sub(dir, focalPoint, point); diff --git a/packages/tools/src/utilities/segmentation/VolumetricCalculator.ts b/packages/tools/src/utilities/segmentation/VolumetricCalculator.ts index cc1607fb7a..e0573ab0a7 100644 --- a/packages/tools/src/utilities/segmentation/VolumetricCalculator.ts +++ b/packages/tools/src/utilities/segmentation/VolumetricCalculator.ts @@ -1,11 +1,19 @@ +import type { Types } from '@cornerstonejs/core'; import type { NamedStatistics } from '../../types'; import { BasicStatsCalculator } from '../math/basic'; +const TEST_MAX_LOCATIONS = 10; + /** * A basic stats calculator for volumetric data, generally for use with * segmentations. */ export default class VolumetricCalculator extends BasicStatsCalculator { + /** + * maxIJKs is a list of possible peak value locations based + * on the statsCallback calls with the largest TEST_MAX_LOCATIONS values. + */ + private static maxIJKs = []; public static getStatistics(options: { spacing?: number; unit?: string; @@ -27,7 +35,45 @@ export default class VolumetricCalculator extends BasicStatsCalculator { unit: volumeUnit, name: 'volume', }; + stats.maxIJKs = this.maxIJKs; + stats.array.push(stats.volume); + // Reset all the calculated values to agree with the BasicStatsCalculator API + this.maxIJKs = []; + return stats; } + + /** + * Calculate the basic stats, and then start collecting locations of peak values. + */ + public static statsCallback(data: { + value: number | Types.RGB; + pointLPS?: Types.Point3; + pointIJK?: Types.Point3; + }) { + BasicStatsCalculator.statsCallback(data); + const { value } = data; + const { maxIJKs } = this; + const { length } = maxIJKs; + if ( + typeof value !== 'number' || + (length >= TEST_MAX_LOCATIONS && value < maxIJKs[0].value) + ) { + return; + } + if (!length || value >= maxIJKs[length - 1].value) { + maxIJKs.push(data); + } else { + for (let i = 0; i < length; i++) { + if (value <= maxIJKs[i].value) { + maxIJKs.splice(i, 0, data); + break; + } + } + } + if (length >= TEST_MAX_LOCATIONS) { + maxIJKs.splice(0, 1); + } + } } diff --git a/packages/tools/src/utilities/segmentation/growCut/runGrowCutForBoundingBox.ts b/packages/tools/src/utilities/segmentation/growCut/runGrowCutForBoundingBox.ts index 9a3bab7fd4..855f52ea59 100644 --- a/packages/tools/src/utilities/segmentation/growCut/runGrowCutForBoundingBox.ts +++ b/packages/tools/src/utilities/segmentation/growCut/runGrowCutForBoundingBox.ts @@ -7,6 +7,7 @@ const POSITIVE_SEED_VALUE = 254; const NEGATIVE_SEED_VALUE = 255; // Positive and negative threshold/range (defaults to CT hounsfield ranges) +// //www.sciencedirect.com/topics/medicine-and-dentistry/hounsfield-scale const NEGATIVE_PIXEL_RANGE = [-Infinity, -995]; const POSITIVE_PIXEL_RANGE = [0, 1900]; @@ -18,8 +19,6 @@ type BoundingBoxInfo = { }; type GrowCutBoundingBoxOptions = GrowCutOptions & { - positiveSeedValue?: number; - negativeSeedValue?: number; negativePixelRange?: [number, number]; positivePixelRange?: [number, number]; }; diff --git a/packages/tools/src/utilities/segmentation/growCut/runOneClickGrowCut.ts b/packages/tools/src/utilities/segmentation/growCut/runOneClickGrowCut.ts index 50d2338cac..f4255b2386 100644 --- a/packages/tools/src/utilities/segmentation/growCut/runOneClickGrowCut.ts +++ b/packages/tools/src/utilities/segmentation/growCut/runOneClickGrowCut.ts @@ -14,10 +14,6 @@ const SUBVOLUME_PADDING_PERCENTAGE = 0.2; const SUBVOLUME_MIN_PADDING = 5; type GrowCutOneClickOptions = GrowCutOptions & { - positiveSeedValue?: number; - negativeSeedValue?: number; - positiveSeedVariance?: number; - negativeSeedVariance?: number; subVolumePaddingPercentage?: number | [number, number, number]; subVolumeMinPadding?: number | [number, number, number]; }; diff --git a/packages/tools/src/utilities/segmentation/index.ts b/packages/tools/src/utilities/segmentation/index.ts index 801dd33ecc..1352a952a3 100644 --- a/packages/tools/src/utilities/segmentation/index.ts +++ b/packages/tools/src/utilities/segmentation/index.ts @@ -28,6 +28,7 @@ import { getHoveredContourSegmentationAnnotation } from './getHoveredContourSegm import { getBrushToolInstances } from './getBrushToolInstances'; import * as growCut from './growCut'; import * as LabelmapMemo from './createLabelmapMemo'; +import IslandRemoval from './islandRemoval'; export { thresholdVolumeByRange, @@ -54,4 +55,5 @@ export { getBrushToolInstances, growCut, LabelmapMemo, + IslandRemoval, }; diff --git a/packages/tools/src/utilities/segmentation/islandRemoval.ts b/packages/tools/src/utilities/segmentation/islandRemoval.ts new file mode 100644 index 0000000000..b9d9652c2f --- /dev/null +++ b/packages/tools/src/utilities/segmentation/islandRemoval.ts @@ -0,0 +1,383 @@ +import { utilities } from '@cornerstonejs/core'; +import type { Types } from '@cornerstonejs/core'; +import normalizeViewportPlane from '../normalizeViewportPlane'; + +const { RLEVoxelMap, VoxelManager } = utilities; + +// The maximum size of a dimension on an image in DICOM +// Note, does not work for whole slide imaging +const MAX_IMAGE_SIZE = 65535; + +export enum SegmentationEnum { + // Segment means it is in the segment or preview of interest + SEGMENT = -1, + // Island means it is connected to a selected point + ISLAND = -2, + // Interior means it is inside the island, or possibly inside + INTERIOR = -3, + // Exterior means it is outside the island + EXTERIOR = -4, + // Exterior small means it is small enough to flood it + INTERIOR_SMALL = -5, + // Interior test means the segment is being size tested. + INTERIOR_TEST = -6, +} + +/** + * This class has the fill island, with various options being available. + * + * The usage of this class is to: + * 1. Get the viewport where a labelmap segmentation has been created with + * some data containing islands created. + * 2. Initialize the instance of this class using the initialize method, + * providing it the viewport, the segmentation voxel manager and some options + * 3. Generate the updated island classification using `floodFillSegmentIsland` + * 4. For external island removal, call the `removeExternalIslands`. + * * External islands are segmentation data which not connected to the central + * selected points. + * * External island removal should be done before internal island removal for performance + * 5. For internal island removal, call the 'removeInternalIslands' + * * Internal islands are entirely surrounded sections of non-segment marked areas + * 6. Trigger a segmentation data updated on the originally provided segmentation voxel manager + * set of slices. + */ +export default class IslandRemoval { + /** + * The segment set is a set that categorizes points in the segmentation + * as belonging to one of the categories in SegmentationEnum. Undefined + * here means that it is a non-relevant segment index. + * Note this is an RLEVoxelMap for efficiency of storage and running + * fill algorithms, as it is expected that the classes will have fairly long + * runs. + */ + segmentSet: Types.RLEVoxelMap; + segmentIndex: number; + fillSegments: (index: number) => boolean; + previewVoxelManager: Types.VoxelManager; + previewSegmentIndex: number; + /** + * The selected points are the points that have been directly identified as + * belonging to the segmentation set, either via user selection or some other + * process that identifies this set of points as being definitely inside the + * island. + */ + selectedPoints: Types.Point3[]; + + // Options to control the fill + /** Fill out to edges, assuming they are smaller in size than maxInternalRemove */ + private fillInternalEdge = false; + /** Maximum internal size of an island to remove */ + private maxInternalRemove = 128; + + constructor(options?: { + maxInternalRemove?: number; + fillInternalEdge?: boolean; + }) { + this.maxInternalRemove = + options?.maxInternalRemove ?? this.maxInternalRemove; + this.fillInternalEdge = options?.fillInternalEdge ?? this.fillInternalEdge; + } + + /** + * Initializes the island fill. This is used by providing a viewport + * that is currently display the segment points of interest, plus a voxel manager + * that is either a segmentation voxel manager or a preview voxel manager, and + * a set of options for things like the segment indices to fill/apply to. + * + * The `floodFillSegmentIsland` is an additional initialization piece that + * internally records additional information on the flood fill. + * + * Returns undefined if the data is invalid for some reason. + * + * @param viewport - showing the current orientation view of an image with the + * desired labelmap to have island removal applied. + * @param segmentationVoxels - the voxel manager for the segmentation labelmap. + * * Can be a preview voxel manager or just a basic voxel manager on the segmentation, or + * an RLE history voxel manager for using with undo/redo. + * * May contain getPoints that is the set of starting points which mark + * individual islands + * @param options - contains options on how to apply the island removal + * * points - the selected points to start the island removal from + * * segmentIndex - the segment index for the final color segmentation + * * If there is no previewSegmentIndex, then the segment index will be + * used for all operations, otherwise a preview will be updated, filling + * it with the preview segment index. + * * previewSegmentIndex - the preview segment index. + * * Allows for showing a preview of the changes. + * * Omit to perform non-preview displays of segmentation voxels. + * * Should be 255 typically + * + */ + initialize(viewport, segmentationVoxels, options) { + const hasSource = !!segmentationVoxels.sourceVoxelManager; + const segmentationVoxelManager = hasSource + ? segmentationVoxels.sourceVoxelManager + : segmentationVoxels; + const previewVoxelManager = hasSource + ? segmentationVoxels + : VoxelManager.createRLEHistoryVoxelManager(segmentationVoxelManager); + + const { segmentIndex = 1, previewSegmentIndex = 1 } = options; + + const clickedPoints = options.points || previewVoxelManager.getPoints(); + if (!clickedPoints?.length) { + return; + } + + // Ensure the bounds includes the clicked points, otherwise the fill + // fails. + const boundsIJK = previewVoxelManager + .getBoundsIJK() + .map((bound, i) => [ + Math.min(bound[0], ...clickedPoints.map((point) => point[i])), + Math.max(bound[1], ...clickedPoints.map((point) => point[i])), + ]) as Types.BoundsIJK; + + if (boundsIJK.find((it) => it[0] < 0 || it[1] > MAX_IMAGE_SIZE)) { + // Nothing done, so just skip this + return; + } + + // First get the set of points which are directly connected to the points + // that the user clicked on/dragged over. + const { toIJK, fromIJK, boundsIJKPrime, error } = normalizeViewportPlane( + viewport, + boundsIJK + ); + + if (error) { + console.warn( + 'Not performing island removal for planes not orthogonal to acquisition plane', + error + ); + return; + } + + const [width, height, depth] = fromIJK(segmentationVoxelManager.dimensions); + const segmentSet = new RLEVoxelMap(width, height, depth); + + // Returns true for new colour, and false otherwise + const getter = (i, j, k) => { + const index = segmentationVoxelManager.toIndex(toIJK([i, j, k])); + const oldVal = segmentationVoxelManager.getAtIndex(index); + if (oldVal === previewSegmentIndex || oldVal === segmentIndex) { + // Values are initially false for indexed values. + return SegmentationEnum.SEGMENT; + } + }; + segmentSet.fillFrom(getter, boundsIJKPrime); + segmentSet.normalizer = { toIJK, fromIJK, boundsIJKPrime }; + this.segmentSet = segmentSet; + this.previewVoxelManager = previewVoxelManager; + this.segmentIndex = segmentIndex; + this.previewSegmentIndex = previewSegmentIndex ?? segmentIndex; + this.selectedPoints = clickedPoints; + + return true; + } + + /** + * This operation starts a flood fill on the set of points that were selected + * (typically by clicking on them or hovering over them in some way, but other + * options are possible). All of the selected points are marked as SEGMENT, + * and then all the flood fill points that planar connected to them are marked + * as being ISLAND points. Then, this is repeated for planes in both normal and + * anti-normal directions for the points which were so marked (this is done + * internally to the floodFill algorithm). + * + * This results in a set of points in the volume which are connected to the + * points originally selected, thus an island point, where the island is the island + * containing the selected points. + * + * The return value is the number of such points selected. + */ + public floodFillSegmentIsland() { + const { selectedPoints: clickedPoints, segmentSet } = this; + // Just used to count up how many points got filled. + let floodedCount = 0; + const { fromIJK } = segmentSet.normalizer; + + // First mark everything as island that is connected to a start point + clickedPoints.forEach((clickedPoint) => { + const ijkPrime = fromIJK(clickedPoint); + const index = segmentSet.toIndex(ijkPrime); + const [iPrime, jPrime, kPrime] = ijkPrime; + if (segmentSet.get(index) === SegmentationEnum.SEGMENT) { + floodedCount += segmentSet.floodFill( + iPrime, + jPrime, + kPrime, + SegmentationEnum.ISLAND + ); + } + }); + + return floodedCount; + } + + /** + * This part removes external islands. External islands are regions of voxels which + * are not connected to the selected/click points. The algorithm is to + * start with all of the clicked points, performing a flood fill along all + * sections that are within the given segment, replacing the "SEGMENT" + * indicator with a new "ISLAND" indicator. Then, every point in the + * preview that is not marked as ISLAND is now external and can be reset to + * the value it had before the flood fill was initiated. + */ + public removeExternalIslands() { + const { previewVoxelManager, segmentSet } = this; + const { toIJK } = segmentSet.normalizer; + + // Next, iterate over all points which were set to a new value in the preview + // For everything NOT connected to something in set of clicked points, + // remove it from the preview. + + const callback = (index, rle) => { + const [, jPrime, kPrime] = segmentSet.toIJK(index); + if (rle.value !== SegmentationEnum.ISLAND) { + for (let iPrime = rle.start; iPrime < rle.end; iPrime++) { + const clearPoint = toIJK([iPrime, jPrime, kPrime]); + const v = previewVoxelManager.getAtIJKPoint(clearPoint); + // preview voxel manager knows to reset on null if it has a preview + // value, but need to clear to 0 for non-preview points as those + // will be undefined in the preview voxel manager. + previewVoxelManager.setAtIJKPoint( + clearPoint, + v === undefined ? 0 : null + ); + } + } + }; + + segmentSet.forEach(callback, { rowModified: true }); + } + + /** + * Handle islands which are internal to the flood fill - these are points which + * are surrounded entirely by the filled area. + * Start by getting the island map - that is, the output from the previous + * external island removal. Then, mark all the points in between two islands + * as being "Interior". The set of points marked interior is within a boundary + * point on the left and right, but may still be open above or below. To + * test that, perform a flood fill on the interior points, and see if it is + * entirely contained ('covered') on the top and bottom. + * Note this is done in a planar fashion, that is one plane at a time, but + * covering all planes that have interior data. That removes islands that + * are interior to the currently displayed view to be handled. + */ + public removeInternalIslands() { + const { segmentSet, previewVoxelManager, previewSegmentIndex } = this; + const { height, normalizer, width } = segmentSet; + const { toIJK } = normalizer; + + segmentSet.forEachRow((baseIndex, row) => { + let lastRle; + for (const rle of [...row]) { + if (rle.value !== SegmentationEnum.ISLAND) { + continue; + } + if (!lastRle) { + if (this.fillInternalEdge && rle.start > 0) { + for (let iPrime = 0; iPrime < rle.start; iPrime++) { + segmentSet.set(baseIndex + iPrime, SegmentationEnum.INTERIOR); + } + } + lastRle = rle; + continue; + } + for (let iPrime = lastRle.end; iPrime < rle.start; iPrime++) { + segmentSet.set(baseIndex + iPrime, SegmentationEnum.INTERIOR); + } + lastRle = rle; + } + if (this.fillInternalEdge && lastRle?.end < width) { + for (let iPrime = lastRle.end; iPrime < width; iPrime++) { + segmentSet.set(baseIndex + iPrime, SegmentationEnum.INTERIOR); + } + } + }); + // Next, remove the island sets which are adjacent to an opening + segmentSet.forEach((baseIndex, rle) => { + if (rle.value !== SegmentationEnum.INTERIOR) { + // Already filled/handled + return; + } + const [, jPrime, kPrime] = segmentSet.toIJK(baseIndex); + const rowPrev = jPrime > 0 ? segmentSet.getRun(jPrime - 1, kPrime) : null; + const rowNext = + jPrime + 1 < height ? segmentSet.getRun(jPrime + 1, kPrime) : null; + const isLast = jPrime === height - 1; + const isFirst = jPrime === 0; + const prevCovers = + IslandRemoval.covers(rle, rowPrev) || + (isFirst && this.fillInternalEdge); + const nextCovers = + IslandRemoval.covers(rle, rowNext) || (isLast && this.fillInternalEdge); + if (rle.end - rle.start > 2 && (!prevCovers || !nextCovers)) { + segmentSet.floodFill( + rle.start, + jPrime, + kPrime, + SegmentationEnum.EXTERIOR, + { singlePlane: true } + ); + } + }); + + // Measure the size of all the interior islands, marking them as exterior + // when they are too big, or as INTERIOR_SMALL otherwise + segmentSet.forEach((baseIndex, rle) => { + if (rle.value !== SegmentationEnum.INTERIOR) { + // Already filled/handled + return; + } + const [, jPrime, kPrime] = segmentSet.toIJK(baseIndex); + const size = segmentSet.floodFill( + rle.start, + jPrime, + kPrime, + SegmentationEnum.INTERIOR_TEST + ); + const isBig = size > this.maxInternalRemove; + const newType = isBig + ? SegmentationEnum.EXTERIOR + : SegmentationEnum.INTERIOR_SMALL; + segmentSet.floodFill(rle.start, jPrime, kPrime, newType); + }); + + // Finally, for all the islands, fill them in with the preview colour as + // they are now internal + segmentSet.forEach((baseIndex, rle) => { + if (rle.value !== SegmentationEnum.INTERIOR_SMALL) { + return; + } + for (let iPrime = rle.start; iPrime < rle.end; iPrime++) { + const clearPoint = toIJK(segmentSet.toIJK(baseIndex + iPrime)); + previewVoxelManager.setAtIJKPoint(clearPoint, previewSegmentIndex); + } + }); + return previewVoxelManager.getArrayOfModifiedSlices(); + } + + /** + * Determine if the rle `[start...end)` is covered by row completely, by which + * it is meant that the row has RLE elements from the start to the end of the + * RLE section, matching every index i in the start to end. + */ + public static covers(rle, row) { + if (!row) { + return false; + } + let { start } = rle; + const { end } = rle; + for (const rowRle of row) { + if (start >= rowRle.start && start < rowRle.end) { + start = rowRle.end; + if (start >= end) { + return true; + } + } + } + return false; + } +} diff --git a/packages/tools/test/utilities/calculator.jest.js b/packages/tools/test/utilities/calculator.jest.js index ca3447ca8a..30670d26af 100644 --- a/packages/tools/test/utilities/calculator.jest.js +++ b/packages/tools/test/utilities/calculator.jest.js @@ -1,5 +1,5 @@ import { BasicStatsCalculator } from '../../src/utilities/math/basic'; -import { VolumetricCalculator } from '../../src/utilities/segmentation/VolumetricCalculator'; +import VolumetricCalculator from '../../src/utilities/segmentation/VolumetricCalculator'; import { describe, it, expect } from '@jest/globals'; @@ -56,4 +56,89 @@ describe('Calculator', function () { expect(stats.pointsInShape.length).toBe(count); }); }); + + describe('VolumetricCalculator', () => { + it('should work with no points', () => { + const stats = VolumetricCalculator.getStatistics({ spacing: [1, 1] }); + expect(stats.volume.value).toBeNaN(); + expect(stats.count.value).toBe(0); + expect(stats.min.value).toBe(Infinity); + }); + + it('should not remember pointsLPS list', () => { + VolumetricCalculator.statsInit({ noPointsCollection: true }); + for (let value = 0; value < count; value++) { + VolumetricCalculator.statsCallback({ + value, + pointLPS: [value, value, value], + }); + } + const stats = VolumetricCalculator.getStatistics({ spacing: [1, 1] }); + expect(stats.pointsInShape).toBeNull(); + }); + + it('should find top value locations for points', () => { + VolumetricCalculator.statsInit({ noPointsCollection: true }); + const bigCount = 25; + for (let value = 0; value < bigCount; value++) { + VolumetricCalculator.statsCallback({ + value, + pointIJK: [value, value, value], + }); + } + const stats = VolumetricCalculator.getStatistics({ spacing: [1, 1] }); + expect(stats.maxIJKs.length).toBe(10); + const values = stats.maxIJKs.map((it) => it.value); + expect(values[0]).toBe(15); + expect(values[9]).toBe(24); + }); + + it('should find reversed duplicated values', () => { + VolumetricCalculator.statsInit({ noPointsCollection: true }); + const bigCount = 25; + for (let value = 0; value < bigCount; value++) { + VolumetricCalculator.statsCallback({ + value, + pointIJK: [value, value, value], + }); + } + for (let value = bigCount - 1; value >= 0; value--) { + VolumetricCalculator.statsCallback({ + value, + pointIJK: [-value, value, value], + }); + } + const stats = VolumetricCalculator.getStatistics({ spacing: [1, 1] }); + expect(stats.maxIJKs.length).toBe(10); + const values = stats.maxIJKs.map((it) => it.value); + expect(values[0]).toBe(20); + expect(values[9]).toBe(24); + expect(values[1]).toBe(20); + expect(values[8]).toBe(24); + }); + + it('should find duplicated values', () => { + VolumetricCalculator.statsInit({ noPointsCollection: true }); + const bigCount = 25; + for (let value = 0; value < bigCount; value++) { + VolumetricCalculator.statsCallback({ + value, + pointIJK: [value, value, value], + }); + } + for (let value = 0; value < bigCount; value++) { + VolumetricCalculator.statsCallback({ + value, + pointIJK: [-value, value, value], + }); + } + const stats = VolumetricCalculator.getStatistics({ spacing: [1, 1] }); + expect(stats.maxIJKs.length).toBe(10); + const values = stats.maxIJKs.map((it) => it.value); + expect(values[0]).toBe(20); + expect(values[9]).toBe(24); + expect(values[1]).toBe(20); + expect(values[8]).toBe(24); + }); + }); }); diff --git a/packages/tools/test/utilities/segmentation/islandRemoval.jest.js b/packages/tools/test/utilities/segmentation/islandRemoval.jest.js new file mode 100644 index 0000000000..21a440d7b0 --- /dev/null +++ b/packages/tools/test/utilities/segmentation/islandRemoval.jest.js @@ -0,0 +1,200 @@ +import { + RenderingEngine, + utilities, + Enums, + cache, + volumeLoader, + setUseCPURendering, +} from '@cornerstonejs/core'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + beforeAll, +} from '@jest/globals'; +import IslandRemoval from '../../../src/utilities/segmentation/islandRemoval'; + +const { OrientationAxis, ViewportType } = Enums; +const { VoxelManager } = utilities; + +const dimensions = [32, 32, 5]; +const [width, height, depth] = dimensions; +const imageSize = width * height * depth; +const viewportId = 'viewportId'; +const renderingEngineId = 'renderingEngineId'; +const volumeId = 'volumeId'; + +describe('IslandRemove', function () { + let islandRemoval, segmentationVoxels, renderingEngine, viewport; + let previewVoxels; + + beforeAll(() => { + window.devicePixelRatio = 1; + setUseCPURendering(true); + }); + + beforeEach(() => { + cache.purgeCache(); + islandRemoval = new IslandRemoval(); + const scalarData = new Uint8Array(imageSize); + + const volume = volumeLoader.createLocalVolume(volumeId, { + dimensions, + spacing: [1, 1, 1], + scalarData, + direction: [1, 0, 0, 0, 1, 0, 0, 0, 1], + origin: [0, 0, 0], + }); + + segmentationVoxels = volume.voxelManager; + + renderingEngine = new RenderingEngine(renderingEngineId); + createViewport(renderingEngine, OrientationAxis.ACQUISITION, width, height); + viewport = renderingEngine.getViewport(viewportId); + previewVoxels = + VoxelManager.createRLEHistoryVoxelManager(segmentationVoxels); + }); + + afterEach(() => { + cache.purgeCache(); + renderingEngine?.destroy(); + }); + + it('fills center', () => { + expect(islandRemoval).not.toBeUndefined(); + const x = 10; + const y = 10; + const z = 2; + const w = 5; + const h = 5; + + createBox(segmentationVoxels, 1, x, y, z, w, h); + const initialized = islandRemoval.initialize(viewport, segmentationVoxels, { + segmentIndex: 1, + points: [[x, y, z]], + }); + expect(initialized).toBe(true); + const floodedCount = islandRemoval.floodFillSegmentIsland(); + expect(floodedCount).toBe((w - 1) * (h - 1)); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(1); + expect(segmentationVoxels.getAtIJK(x + 1, y + 1, z)).toBe(0); + islandRemoval.removeInternalIslands(); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(1); + expect(segmentationVoxels.getAtIJK(x + 1, y + 1, z)).toBe(1); + }); + + it('fills center preview', () => { + expect(islandRemoval).not.toBeUndefined(); + const x = 10; + const y = 10; + const z = 2; + const w = 5; + const h = 5; + const previewSegmentIndex = 255; + const segmentIndex = 1; + + createBox(previewVoxels, previewSegmentIndex, x, y, z, w, h); + const initialized = islandRemoval.initialize(viewport, previewVoxels, { + segmentIndex, + previewSegmentIndex, + points: [[x, y, z]], + }); + expect(initialized).toBe(true); + const floodedCount = islandRemoval.floodFillSegmentIsland(); + expect(floodedCount).toBe((w - 1) * (h - 1)); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(255); + expect(segmentationVoxels.getAtIJK(x + 1, y + 1, z)).toBe(0); + islandRemoval.removeInternalIslands(); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(255); + expect(segmentationVoxels.getAtIJK(x + 1, y + 1, z)).toBe(255); + }); + + it('deletes externals', () => { + expect(islandRemoval).not.toBeUndefined(); + const x = 10; + const y = 10; + const z = 2; + const w = 5; + const h = 5; + + createBox(segmentationVoxels, 1, x, y, z, w, h); + createBox(segmentationVoxels, 1, x - 5, y, z, 2, 2); + + const initialized = islandRemoval.initialize(viewport, segmentationVoxels, { + segmentIndex: 1, + points: [[x, y, z]], + }); + expect(initialized).toBe(true); + const floodedCount = islandRemoval.floodFillSegmentIsland(); + expect(floodedCount).toBe((w - 1) * (h - 1)); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(1); + expect(segmentationVoxels.getAtIJK(x - 5, y, z)).toBe(1); + islandRemoval.removeExternalIslands(); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(1); + expect(segmentationVoxels.getAtIJK(x - 5, y, z)).toBe(0); + }); + + it('deletes preview externals', () => { + const x = 10; + const y = 10; + const z = 2; + const w = 5; + const h = 5; + const previewSegmentIndex = 255; + const segmentIndex = 1; + + createBox(previewVoxels, previewSegmentIndex, x, y, z, w, h); + createBox(previewVoxels, previewSegmentIndex, x - 5, y, z, 2, 2); + + const initialized = islandRemoval.initialize(viewport, segmentationVoxels, { + segmentIndex, + previewSegmentIndex, + points: [ + [x, y, z], + [x + 1, y, z], + [x, y + 1, z], + ], + }); + expect(initialized).toBe(true); + const floodedCount = islandRemoval.floodFillSegmentIsland(); + expect(floodedCount).toBe((w - 1) * (h - 1)); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(previewSegmentIndex); + expect(segmentationVoxels.getAtIJK(x - 5, y, z)).toBe(previewSegmentIndex); + islandRemoval.removeExternalIslands(); + expect(segmentationVoxels.getAtIJK(x, y + 1, z)).toBe(previewSegmentIndex); + expect(segmentationVoxels.getAtIJK(x - 5, y, z)).toBe(0); + }); +}); + +function createBox(voxels, segmentIndex, x = 10, y = 10, z = 2, w = 5, h = 5) { + for (let dx = 0; dx < w; dx++) { + voxels.setAtIJK(x + dx, y, z, segmentIndex); + voxels.setAtIJK(x + dx, y + h - 1, z, segmentIndex); + } + for (let dy = 0; dy < h; dy++) { + voxels.setAtIJK(x, y + dy, z, segmentIndex); + voxels.setAtIJK(x + w - 1, y + dy, z, segmentIndex); + } +} + +function createViewport(renderingEngine, _orientation, width, height) { + const element = document.createElement('div'); + + element.style.width = `${width}px`; + element.style.height = `${height}px`; + document.body.appendChild(element); + + renderingEngine.setViewports([ + { + viewportId: viewportId, + type: ViewportType.STACK, + element, + defaultOptions: { + background: [1, 0, 1], // pinkish background + }, + }, + ]); + return element; +} diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index b798166993..5758ce150d 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -410,6 +410,10 @@ "sculptorTool": { "name": "sculptorTool Tool", "description": "Demonstrates how to have similar brush tool effects on the contour" + }, + "labelmapEditWithContour": { + "name": "Labelmap Editing with Contour", + "description": "Use contour tools to edit the labelmap" } }, "advanced-gpu-segmentation": { diff --git a/utils/demo/helpers/addToggleButtonToToolbar.ts b/utils/demo/helpers/addToggleButtonToToolbar.ts index 265f0b4c45..5642eb7060 100644 --- a/utils/demo/helpers/addToggleButtonToToolbar.ts +++ b/utils/demo/helpers/addToggleButtonToToolbar.ts @@ -36,4 +36,6 @@ export default function addToggleButtonToToolbar({ container = container ?? document.getElementById('demo-toolbar'); container.append(button); + + return button; } diff --git a/utils/fixJSDOMJest.js b/utils/fixJSDOMJest.js new file mode 100644 index 0000000000..7ef475750e --- /dev/null +++ b/utils/fixJSDOMJest.js @@ -0,0 +1,11 @@ +import JSDOMEnvironment from 'jest-environment-jsdom'; + +// https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string +export default class FixJSDOMEnvironment extends JSDOMEnvironment { + constructor(...args) { + super(...args); + + // FIXME https://github.com/jsdom/jsdom/issues/3363 + this.global.structuredClone = structuredClone; + } +}