diff --git a/common/reviews/api/core.api.md b/common/reviews/api/core.api.md index f76338ef7c..8d1b74d6b3 100644 --- a/common/reviews/api/core.api.md +++ b/common/reviews/api/core.api.md @@ -699,6 +699,9 @@ function decimate(list: Array, interleave: number, offset?: number): nu // @public (undocumented) const deepMerge: (target?: {}, source?: {}, optionsArgument?: any) => any; +// @public (undocumented) +const DefaultHistoryMemo: HistoryMemo; + // @public (undocumented) type DisplayArea = { imageArea?: [number, number]; @@ -767,7 +770,8 @@ declare namespace Enums { ViewportStatus, VideoEnums, MetadataModules, - ImageQualityStatus + ImageQualityStatus, + VoxelManagerEnum } } export { Enums } @@ -1096,6 +1100,30 @@ export function getWebWorkerManager(): any; // @public (undocumented) function hasNaNValues(input: number[] | number): boolean; +// @public (undocumented) +class HistoryMemo { + constructor(label?: string, size?: number); + // (undocumented) + readonly label: any; + // (undocumented) + push(item: Memo | Memoable): Memo; + // (undocumented) + redo(items?: number): void; + // (undocumented) + get size(): any; + // (undocumented) + undo(items?: number): void; +} + +declare namespace HistoryMemo_2 { + export { + Memo, + Memoable, + HistoryMemo, + DefaultHistoryMemo + } +} + // @public (undocumented) interface ICache { // (undocumented) @@ -1872,6 +1900,8 @@ export class ImageVolume implements IImageVolume { // (undocumented) readonly volumeId: string; // (undocumented) + voxelManager?: VoxelManager | VoxelManager; + // (undocumented) vtkOpenGLTexture: any; } @@ -1899,6 +1929,8 @@ interface ImageVolumeProps extends VolumeProps { imageIds: Array; // (undocumented) referencedImageIds?: Array; + // (undocumented) + voxelManager?: VoxelManager | VoxelManager; } // @public (undocumented) @@ -2401,6 +2433,17 @@ function makeVolumeMetadata(imageIds: Array): Metadata; // @public (undocumented) type Mat3 = [number, number, number, number, number, number, number, number, number] | Float32Array; +// @public (undocumented) +type Memo = { + restoreMemo: (undo?: boolean) => void; + commitMemo?: () => boolean; +}; + +// @public (undocumented) +type Memoable = { + createMemo: () => Memo; +}; + // @public (undocumented) type Metadata = { BitsAllocated: number; @@ -2517,7 +2560,7 @@ function performCacheOptimizationForVolume(volume: any): void; type PixelDataTypedArray = Float32Array | Int16Array | Uint16Array | Uint8Array | Int8Array | Uint8ClampedArray; // @public (undocumented) -type PixelDataTypedArrayString = 'Float32Array' | 'Int16Array' | 'Uint16Array' | 'Uint8Array' | 'Int8Array' | 'Uint8ClampedArray'; +type PixelDataTypedArrayString = 'Float32Array' | 'Int16Array' | 'Uint16Array' | 'Uint8Array' | 'Int8Array' | 'Uint8ClampedArray' | 'none'; declare namespace planar { export { @@ -2556,7 +2599,7 @@ class PointsManager { // (undocumented) static create2(initialSize?: number): PointsManager; // (undocumented) - static create3(initialSize?: number): PointsManager; + static create3(initialSize?: number, points?: Point3[]): PointsManager; // (undocumented) data: Float32Array; // (undocumented) @@ -2574,6 +2617,8 @@ class PointsManager { // (undocumented) getPointArray(index: number): T; // (undocumented) + getTypedArray(): Float32Array; + // (undocumented) protected grow(additionalSize?: number, growSize?: number): void; // (undocumented) growSize: number; @@ -2845,6 +2890,77 @@ export interface RetrieveStage { // @public (undocumented) type RGB = [number, number, number]; +// @public (undocumented) +class RLEVoxelMap { + constructor(width: number, height: number, depth?: number); + // (undocumented) + clear(): void; + // (undocumented) + static copyMap(destination: RLEVoxelMap, source: RLEVoxelMap): void; + // (undocumented) + defaultValue: T; + // (undocumented) + delete(index: number): void; + // (undocumented) + protected depth: number; + // (undocumented) + fillFrom(getter: (i: number, j: number, k: number) => T, boundsIJK: BoundsIJK): void; + // (undocumented) + findAdjacents(item: [RLERun, number, number, Point3[]?], { diagonals, planar, singlePlane }: { + diagonals?: boolean; + planar?: boolean; + singlePlane?: boolean; + }): any[]; + // (undocumented) + protected findIndex(row: RLERun[], i: number): number; + // (undocumented) + floodFill(i: number, j: number, k: number, value: T, options?: { + planar?: boolean; + diagonals?: boolean; + singlePlane?: boolean; + }): number; + // (undocumented) + forEach(callback: any, options?: { + rowModified?: boolean; + }): void; + // (undocumented) + forEachRow(callback: any): void; + // (undocumented) + get: (index: number) => T; + // (undocumented) + getPixelData(k?: number, pixelData?: PixelDataTypedArray): PixelDataTypedArray; + // (undocumented) + protected getRLE(i: number, j: number, k?: number): RLERun; + // (undocumented) + getRun: (j: number, k: number) => RLERun[]; + // (undocumented) + has(index: number): boolean; + // (undocumented) + protected height: number; + // (undocumented) + protected jMultiple: number; + // (undocumented) + keys(): number[]; + // (undocumented) + protected kMultiple: number; + // (undocumented) + normalizer: PlaneNormalizer; + // (undocumented) + protected numComps: number; + // (undocumented) + pixelDataConstructor: Uint8ArrayConstructor; + // (undocumented) + protected rows: Map[]>; + // (undocumented) + set: (index: number, value: T) => void; + // (undocumented) + toIJK(index: number): Point3; + // (undocumented) + toIndex([i, j, k]: Point3): number; + // (undocumented) + protected width: number; +} + // @public (undocumented) function roundNumber(value: string | number | (string | number)[], precision?: number): string; @@ -3259,6 +3375,10 @@ declare namespace Types { IImage, IImageData, IImageCalibration, + Memo, + HistoryMemo, + VoxelManager, + RLEVoxelMap, CPUIImageData, CPUImageData, EventTypes, @@ -3426,9 +3546,11 @@ declare namespace utilities { isValidVolume, metadataProvider_2 as genericMetadataProvider, isVideoTransferSyntax, + HistoryMemo_2 as HistoryMemo, + generateVolumePropsFromImageIds, getBufferConfiguration, VoxelManager, - generateVolumePropsFromImageIds, + RLEVoxelMap, convertStackToVolumeViewport, convertVolumeToStackViewport, cacheUtils, @@ -4106,7 +4228,7 @@ class VoxelManager { // (undocumented) boundsIJK: BoundsIJK; // (undocumented) - clear(): void; + clear(clearScalar?: boolean): void; // (undocumented) static createHistoryVoxelManager(sourceVoxelManager: VoxelManager): VoxelManager; // (undocumented) @@ -4118,6 +4240,8 @@ class VoxelManager { // (undocumented) static createRGBVolumeVoxelManager(dimensions: Point3, scalarData: any, numComponents: any): VoxelManager; // (undocumented) + static createRLEHistoryVoxelManager(sourceVoxelManager: VoxelManager): VoxelManager; + // (undocumented) static createRLEVoxelManager(dimensions: Point3): VoxelManager; // (undocumented) static createVolumeVoxelManager(dimensions: Point3, scalarData: any, numComponents?: number): VoxelManager | VoxelManager; @@ -4150,12 +4274,16 @@ class VoxelManager { // (undocumented) map: Map | RLEVoxelMap; // (undocumented) + mapForEach(callback: any, options?: any): void; + // (undocumented) modifiedSlices: Set; // (undocumented) numComps: number; // (undocumented) points: Set; // (undocumented) + rleForEach(callback: any, options?: any): void; + // (undocumented) scalarData: PixelDataTypedArray; // (undocumented) _set: (index: number, v: T) => boolean | void; @@ -4175,6 +4303,14 @@ class VoxelManager { width: number; } +// @public (undocumented) +enum VoxelManagerEnum { + // (undocumented) + RLE = "RLE", + // (undocumented) + Volume = "Volume" +} + declare namespace windowLevel { export { toWindowLevel, diff --git a/common/reviews/api/tools.api.md b/common/reviews/api/tools.api.md index 8ce26cf2ca..f37291eb22 100644 --- a/common/reviews/api/tools.api.md +++ b/common/reviews/api/tools.api.md @@ -5,6 +5,7 @@ ```ts import { Corners } from '@kitware/vtk.js/Interaction/Widgets/OrientationMarkerWidget/Constants'; +import { default as default_2 } from '@itk-wasm/morphological-contour-interpolation/dist/morphological-contour-interpolation-options'; import type { GetGPUTier } from 'detect-gpu'; import { IColorMapPreset } from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps'; import { IStackViewport as IStackViewport_2 } from 'packages/core/dist/types/types'; @@ -15,7 +16,7 @@ import type { TierResult } from 'detect-gpu'; import { vec3 } from 'gl-matrix'; import type vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; import vtkAnnotatedCubeActor from '@kitware/vtk.js/Rendering/Core/AnnotatedCubeActor'; -import { vtkColorTransferFunction } from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import { vtkImageData } from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; import type { vtkPiecewiseFunction } from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; @@ -297,7 +298,7 @@ type AnnotationCompletedEventType = Types_2.CustomEventType void; + }; + // (undocumented) + protected static createAnnotationState(annotation: Annotation, deleting?: boolean): { + annotationUID: string; + data: any; + deleting: boolean; + }; + // (undocumented) + protected createMemo(element: any, annotation: any, options?: any): void; + // (undocumented) protected getAnnotationStyle(context: { annotation: Annotation; styleSpecifier: StyleSpecifier; @@ -640,18 +656,28 @@ export abstract class BaseTool implements IBaseTool { // (undocumented) applyActiveStrategy(enabledElement: Types_2.IEnabledElement, operationData: unknown): any; // (undocumented) - applyActiveStrategyCallback(enabledElement: Types_2.IEnabledElement, operationData: unknown, callbackType: StrategyCallbacks | string): any; + applyActiveStrategyCallback(enabledElement: Types_2.IEnabledElement, operationData: unknown, callbackType: StrategyCallbacks | string, ...extraArgs: any[]): any; // (undocumented) configuration: Record; // (undocumented) + static createZoomPanMemo(viewport: any): { + restoreMemo: () => void; + }; + // (undocumented) + doneEditMemo(): void; + // (undocumented) protected getTargetId(viewport: Types_2.IViewport): string | undefined; // (undocumented) protected getTargetIdImage(targetId: string, renderingEngine: Types_2.IRenderingEngine): Types_2.IImageData | Types_2.CPUIImageData | Types_2.IImageVolume; // (undocumented) getToolName(): string; // (undocumented) + protected memo: utilities_2.HistoryMemo.Memo; + // (undocumented) mode: ToolModes; // (undocumented) + redo(): void; + // (undocumented) setActiveStrategy(strategyName: string): void; // (undocumented) setConfiguration(newConfiguration: Record): void; @@ -661,6 +687,8 @@ export abstract class BaseTool implements IBaseTool { toolGroupId: string; // (undocumented) static toolName: any; + // (undocumented) + undo(): void; } declare namespace BasicStatsCalculator { @@ -673,11 +701,18 @@ declare namespace BasicStatsCalculator { // @public (undocumented) class BasicStatsCalculator_2 extends Calculator { // (undocumented) - static getStatistics: () => NamedStatistics; + static getStatistics: (options?: { + unit: string; + }) => NamedStatistics; // (undocumented) - static statsCallback: ({ value: newValue }: { + static statsCallback: ({ value: newValue, pointLPS }: { value: any; + pointLPS?: any; }) => void; + // (undocumented) + static statsInit(options: { + noPointsCollection: boolean; + }): void; } // @public (undocumented) @@ -801,7 +836,7 @@ declare namespace boundingBox { type BoundsIJK_2 = Types_2.BoundsIJK; // @public (undocumented) -export class BrushTool extends BaseTool { +export class BrushTool extends LabelmapBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) acceptPreview(element?: HTMLDivElement): void; @@ -831,12 +866,18 @@ export class BrushTool extends BaseTool { viewUp: any; strategySpecificConfiguration: any; preview: unknown; + configuration: Record; + createMemo: (segmentId: string, segmentationVoxelManager: any, preview: any) => LabelmapMemo_2; segmentsLocked: number[]; imageIdReferenceMap?: Map; volumeId?: string; referencedVolumeId?: string; }; // (undocumented) + getStatistics(element: any, segmentIndices?: any): any; + // (undocumented) + interpolate(element: any, config: any): void; + // (undocumented) invalidateBrushCursor(): void; // (undocumented) mouseMoveCallback: (evt: EventTypes_2.InteractionEventType) => void; @@ -900,6 +941,8 @@ enum ChangeTypes { // (undocumented) HandlesUpdated = "HandlesUpdated", // (undocumented) + History = "History", + // (undocumented) InitialSetup = "InitialSetup", // (undocumented) Interaction = "Interaction", @@ -1154,7 +1197,7 @@ export class CircleROITool extends AnnotationTool { } // @public (undocumented) -export class CircleScissorsTool extends BaseTool { +export class CircleScissorsTool extends LabelmapBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) _activateDraw: (element: any) => void; @@ -1166,9 +1209,10 @@ export class CircleScissorsTool extends BaseTool { editData: { annotation: any; segmentIndex: number; - volumeId: string; - referencedVolumeId: string; - imageIdReferenceMap: Map; + volumeId?: string; + segmentationId?: string; + referencedVolumeId?: string; + imageIdReferenceMap?: Map; segmentsLocked: number[]; segmentColor: [number, number, number, number]; viewportIdsToRender: string[]; @@ -1178,6 +1222,7 @@ export class CircleScissorsTool extends BaseTool { hasMoved?: boolean; centerCanvas?: Array; segmentationRepresentationUID?: string; + memo?: LabelmapMemo_2; } | null; // (undocumented) _endCallback: (evt: EventTypes_2.InteractionEventType) => void; @@ -1297,6 +1342,8 @@ export class CobbAngleTool extends AnnotationTool { isNearSecondLine: boolean; }; // (undocumented) + _dragCallback: (evt: EventTypes_2.MouseDragEventType | EventTypes_2.MouseMoveEventType) => void; + // (undocumented) editData: { annotation: any; viewportIdsToRender: string[]; @@ -1308,6 +1355,8 @@ export class CobbAngleTool extends AnnotationTool { isNearSecondLine?: boolean; } | null; // (undocumented) + _endCallback: (evt: EventTypes_2.MouseUpEventType | EventTypes_2.MouseClickEventType) => void; + // (undocumented) getArcsStartEndPoints: ({ firstLine, secondLine, mid1, mid2, }: { firstLine: any; secondLine: any; @@ -1334,10 +1383,6 @@ export class CobbAngleTool extends AnnotationTool { // (undocumented) mouseDragCallback: any; // (undocumented) - _mouseDragCallback: (evt: EventTypes_2.MouseDragEventType | EventTypes_2.MouseMoveEventType) => void; - // (undocumented) - _mouseUpCallback: (evt: EventTypes_2.MouseUpEventType | EventTypes_2.MouseClickEventType) => void; - // (undocumented) renderAnnotation: (enabledElement: Types_2.IEnabledElement, svgDrawingHelper: SVGDrawingHelper) => boolean; // (undocumented) _throttledCalculateCachedStats: any; @@ -1644,6 +1689,23 @@ function createImageIdReferenceMap(imageIdsArray: string[], segmentationImageIds // @public (undocumented) function createImageSliceSynchronizer(synchronizerName: string): Synchronizer; +// @public (undocumented) +function createLabelmapMemo(segmentationId: string, segmentationVoxelManager: Types_2.VoxelManager, preview?: InitializedOperationData): { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: utilities_2.VoxelManager; + voxelManager: utilities_2.VoxelManager; + memo: LabelmapMemo_2; + preview: InitializedOperationData; +} | { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: utilities_2.VoxelManager; + voxelManager: utilities_2.VoxelManager; +}; + // @public (undocumented) function createLabelmapVolumeForViewport(input: { viewportId: string; @@ -1669,6 +1731,26 @@ function createMergedLabelmapForIndex(labelmaps: Array, se // @public (undocumented) function createPresentationViewSynchronizer(synchronizerName: string): Synchronizer; +// @public (undocumented) +function createPreviewMemo(segmentationId: string, preview: InitializedOperationData): { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: utilities_2.VoxelManager; + voxelManager: utilities_2.VoxelManager; + memo: LabelmapMemo_2; + preview: InitializedOperationData; +}; + +// @public (undocumented) +function createRleMemo(segmentationId: string, segmentationVoxelManager: Types_2.VoxelManager): { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: utilities_2.VoxelManager; + voxelManager: utilities_2.VoxelManager; +}; + // @public (undocumented) const createStackImageSynchronizer: typeof createImageSliceSynchronizer; @@ -1818,7 +1900,7 @@ function debounce(func: Function, wait?: number, options?: { }): Function; // @public (undocumented) -function decimate(polyline: Types_2.Point2[], epsilon?: number): Types_2.Point2[]; +function decimate_2(polyline: Types_2.Point2[], epsilon?: number): Types_2.Point2[]; // @public (undocumented) const _default: { @@ -2365,12 +2447,13 @@ type FloodFillOptions = { onBoundary?: (x: number, y: number, z?: number) => void; equals?: (a: any, b: any) => boolean; diagonals?: boolean; + bounds?: Map; + filter?: (point: any) => boolean; }; // @public (undocumented) type FloodFillResult = { flooded: Types_2.Point2[] | Types_2.Point3[]; - boundaries: Types_2.Point2[] | Types_2.Point3[]; }; // @public (undocumented) @@ -3108,6 +3191,25 @@ type LabelmapConfig = { outlineOpacityInactive?: number; }; +declare namespace LabelmapMemo { + export { + createLabelmapMemo, + restoreMemo, + createRleMemo, + createPreviewMemo, + LabelmapMemo_2 as LabelmapMemo + } +} + +// @public (undocumented) +type LabelmapMemo_2 = Types_2.Memo & { + segmentationVoxelManager: Types_2.VoxelManager; + voxelManager: Types_2.VoxelManager; + redoVoxelManager?: Types_2.VoxelManager; + undoVoxelManager?: Types_2.VoxelManager; + memo?: LabelmapMemo_2; +}; + // @public (undocumented) type LabelmapRenderingConfig = { cfun?: vtkColorTransferFunction; @@ -3145,6 +3247,7 @@ type LabelmapToolOperationData = { points: Types_2.Point3[]; preview: any; toolGroupId: string; + createMemo: (segmentId: any, segmentVoxels: any, previewVoxels?: any, previewMemo?: any) => LabelmapMemo_2; }; // @public (undocumented) @@ -3283,6 +3386,8 @@ export class LivewireContourTool extends ContourSegmentationBaseTool { // (undocumented) cancel: (element: HTMLDivElement) => string; // (undocumented) + cancelInProgress(element: any, config: any, evt: any): void; + // (undocumented) protected clearEditData(): void; // (undocumented) protected createAnnotation(evt: EventTypes_2.InteractionEventType): ContourAnnotation; @@ -3344,8 +3449,6 @@ export class LivewireContourTool extends ContourSegmentationBaseTool { // (undocumented) triggerChangeEvent: (annotation: LivewireContourAnnotation, enabledElement: Types_2.IEnabledElement, changeType?: ChangeTypes, contourHoleProcessingEnabled?: boolean) => void; // (undocumented) - undo(element: any, config: any, evt: any): void; - // (undocumented) protected updateAnnotation(livewirePath: LivewirePath): void; } @@ -3538,6 +3641,9 @@ type NamedStatistics = { max: Statistics & { name: 'max'; }; + min: Statistics & { + name: 'min'; + }; stdDev: Statistics & { name: 'stdDev'; }; @@ -3557,6 +3663,7 @@ type NamedStatistics = { name: 'circumferance'; }; array: Statistics[]; + pointsInShape?: Types_2.PointsManager; }; // @public (undocumented) @@ -3731,7 +3838,7 @@ export class PanTool extends BaseTool { touchDragCallback(evt: EventTypes_2.InteractionEventType): void; } -declare namespace planar { +declare namespace planar_2 { export { _default as default, filterAnnotationsWithinSlice, @@ -3867,7 +3974,7 @@ const pointCanProjectOnLine: (p: Types_2.Point2, p1: Types_2.Point2, p2: Types_2 function pointInEllipse(ellipse: any, pointLPS: any, inverts?: Inverts): boolean; // @public (undocumented) -function pointInShapeCallback(imageData: vtkImageData | Types_2.CPUImageData, pointInShapeFn: ShapeFnCriteria, callback?: PointInShapeCallback, boundsIJK?: BoundsIJK_2): Array; +function pointInShapeCallback(imageData: vtkImageData | Types_2.CPUImageData, pointInShapeFn: ShapeFnCriteria, callback: PointInShapeCallback, boundsIJK?: BoundsIJK_2): void; // @public (undocumented) function pointInSurroundingSphereCallback(imageData: vtkImageData, circlePoints: [Types_2.Point3, Types_2.Point3], callback: PointInShapeCallback, viewport?: Types_2.IVolumeViewport): void; @@ -3898,7 +4005,7 @@ declare namespace polyline { getNormal3, getNormal2, intersectPolyline, - decimate, + decimate_2 as decimate, getFirstLineSegmentIntersectionIndexes, getLineSegmentIntersectionsIndexes, getLineSegmentIntersectionsCoordinates, @@ -4289,7 +4396,7 @@ declare namespace rectangleROITool { } // @public (undocumented) -export class RectangleScissorsTool extends BaseTool { +export class RectangleScissorsTool extends LabelmapBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) _activateDraw: (element: any) => void; @@ -4491,7 +4598,10 @@ function resetAnnotationManager(): void; function resetElementCursor(element: HTMLDivElement): void; // @public (undocumented) -const roundNumber: typeof utilities_2.roundNumber; +function restoreMemo(isUndo?: boolean): void; + +// @public (undocumented) +const roundNumber_2: typeof utilities_2.roundNumber; // @public (undocumented) interface ScaleOverlayAnnotation extends Annotation { @@ -4613,6 +4723,7 @@ declare namespace segmentation_2 { isValidRepresentationConfig, getDefaultRepresentationConfig, createLabelmapVolumeForViewport, + LabelmapMemo, rectangleROIThresholdVolumeByRange, triggerSegmentationRender, floodFill, @@ -4620,6 +4731,7 @@ declare namespace segmentation_2 { setBrushSizeForToolGroup, getBrushThresholdForToolGroup, setBrushThresholdForToolGroup, + VolumetricCalculator, thresholdSegmentationByRange, createImageIdReferenceMap, contourAndFindLargestBidirectional, @@ -4637,6 +4749,7 @@ declare namespace segmentation_2 { type SegmentationDataModifiedEventDetail = { segmentationId: string; modifiedSlicesToUse?: number[]; + segmentIndex?: number; }; // @public (undocumented) @@ -4899,7 +5012,7 @@ function showAllAnnotations(): void; function smoothAnnotation(enabledElement: Types_2.IEnabledElement, annotation: PlanarFreehandROIAnnotation, knotsRatioPercentage: number): boolean; // @public (undocumented) -export class SphereScissorsTool extends BaseTool { +export class SphereScissorsTool extends LabelmapBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) _activateDraw: (element: any) => void; @@ -5200,10 +5313,14 @@ enum StrategyCallbacks { // (undocumented) Fill = "fill", // (undocumented) + GetStatistics = "getStatistics", + // (undocumented) Initialize = "initialize", // (undocumented) INTERNAL_setValue = "setValue", // (undocumented) + Interpolate = "interpolate", + // (undocumented) OnInteractionEnd = "onInteractionEnd", // (undocumented) OnInteractionStart = "onInteractionStart", @@ -5693,7 +5810,7 @@ function triggerAnnotationRenderForViewportIds(renderingEngine: Types_2.IRenderi function triggerEvent(el: EventTarget, type: string, detail?: unknown): boolean; // @public (undocumented) -function triggerSegmentationDataModified(segmentationId: string, modifiedSlicesToUse?: number[]): void; +function triggerSegmentationDataModified(segmentationId: string, modifiedSlicesToUse?: number[], segmentIndex?: number): void; declare namespace triggerSegmentationEvents { export { @@ -5917,7 +6034,7 @@ function updateContourPolyline(annotation: ContourAnnotation, polylineData: { declare namespace utilities { export { math, - planar, + planar_2 as planar, viewportFilters, drawing_2 as drawing, debounce, @@ -5952,7 +6069,7 @@ declare namespace utilities { stackPrefetch, stackContextPrefetch, scroll_2 as scroll, - roundNumber, + roundNumber_2 as roundNumber, pointToString, polyDataUtils, voi, @@ -6021,6 +6138,8 @@ export class VideoRedactionTool extends AnnotationTool { hasMoved?: boolean; } | null; // (undocumented) + _endCallback: (evt: any) => void; + // (undocumented) getHandleNearImagePoint: (element: any, annotation: any, canvasCoords: any, proximity: any) => any; // (undocumented) _getImageVolumeFromTargetUID(targetUID: any, renderingEngine: any): { @@ -6051,8 +6170,6 @@ export class VideoRedactionTool extends AnnotationTool { // (undocumented) _mouseDragCallback: (evt: any) => void; // (undocumented) - _mouseUpCallback: (evt: any) => void; - // (undocumented) renderAnnotation: (enabledElement: Types_2.IEnabledElement, svgDrawingHelper: SVGDrawingHelper) => boolean; // (undocumented) _throttledCalculateCachedStats: any; @@ -6146,6 +6263,15 @@ type VolumeScrollOutOfBoundsEventDetail = { // @public (undocumented) type VolumeScrollOutOfBoundsEventType = Types_2.CustomEventType; +// @public (undocumented) +class VolumetricCalculator extends BasicStatsCalculator_2 { + // (undocumented) + static getStatistics(options: { + spacing?: number; + unit?: string; + }): NamedStatistics; +} + // @public (undocumented) export class WindowLevelTool extends BaseTool { constructor(toolProps?: {}, defaultToolProps?: { diff --git a/packages/core/examples/multiVolumeCanvasToWorld/index.ts b/packages/core/examples/multiVolumeCanvasToWorld/index.ts index 2a598ad55e..a1e50e152b 100644 --- a/packages/core/examples/multiVolumeCanvasToWorld/index.ts +++ b/packages/core/examples/multiVolumeCanvasToWorld/index.ts @@ -171,7 +171,7 @@ async function run() { Math.floor(evt.clientX - rect.left), Math.floor(evt.clientY - rect.top), ]; - // Convert canvas coordiantes to world coordinates + // Convert canvas coordinates to world coordinates const worldPos = viewport.canvasToWorld(canvasPos); canvasPosElement.innerText = `canvas: (${canvasPos[0]}, ${canvasPos[1]})`; diff --git a/packages/core/src/RenderingEngine/vtkClasses/vtkStreamingOpenGLVolumeMapper.js b/packages/core/src/RenderingEngine/vtkClasses/vtkStreamingOpenGLVolumeMapper.js index 542e2f4ee9..1c1e72e3be 100644 --- a/packages/core/src/RenderingEngine/vtkClasses/vtkStreamingOpenGLVolumeMapper.js +++ b/packages/core/src/RenderingEngine/vtkClasses/vtkStreamingOpenGLVolumeMapper.js @@ -33,7 +33,7 @@ function vtkStreamingOpenGLVolumeMapper(publicAPI, model) { return; } - const scalars = image.getPointData() && image.getPointData().getScalars(); + const scalars = image.getPointData()?.getScalars(); if (!scalars) { return; } diff --git a/packages/core/src/cache/classes/ImageVolume.ts b/packages/core/src/cache/classes/ImageVolume.ts index 76449ff920..b86a40c854 100644 --- a/packages/core/src/cache/classes/ImageVolume.ts +++ b/packages/core/src/cache/classes/ImageVolume.ts @@ -7,7 +7,7 @@ import { imageIdToURI, } from '../../utilities'; import { vtkStreamingOpenGLTexture } from '../../RenderingEngine/vtkClasses'; -import { +import type { Metadata, Point3, IImageVolume, @@ -16,9 +16,11 @@ import { ImageVolumeProps, IImage, IImageLoadObject, + RGB, } from '../../types'; import cache from '../cache'; import * as metaData from '../../metaData'; +import type VoxelManager from '../../utilities/VoxelManager'; /** The base class for volume data. It includes the volume metadata * and the volume data along with the loading status. @@ -80,6 +82,8 @@ export class ImageVolume implements IImageVolume { hasPixelSpacing: boolean; /** Property to store additional information */ additionalDetails?: Record; + /** Store a voxel manager to access scalar data */ + voxelManager?: VoxelManager | VoxelManager; constructor(props: ImageVolumeProps) { const { @@ -97,6 +101,7 @@ export class ImageVolume implements IImageVolume { metadata, referencedImageIds, additionalDetails, + voxelManager, } = props; this.imageIds = imageIds; @@ -108,6 +113,7 @@ export class ImageVolume implements IImageVolume { this.direction = direction; this.scalarData = scalarData; this.sizeInBytes = sizeInBytes; + this.voxelManager = voxelManager; this.vtkOpenGLTexture = vtkStreamingOpenGLTexture.newInstance(); this.numVoxels = this.dimensions[0] * this.dimensions[1] * this.dimensions[2]; @@ -191,6 +197,9 @@ export class ImageVolume implements IImageVolume { if (isTypedArray(this.scalarData)) { return this.scalarData; } + if (!this.scalarData) { + return null; + } throw new Error('Unknown scalar data type'); } diff --git a/packages/core/src/enums/VoxelManagerEnum.ts b/packages/core/src/enums/VoxelManagerEnum.ts new file mode 100644 index 0000000000..d96d817581 --- /dev/null +++ b/packages/core/src/enums/VoxelManagerEnum.ts @@ -0,0 +1,29 @@ +/** + * The voxel manager enum is used to select from various voxel managers. + * This allows different representations of the underlying imaging data for + * a volume or image slice. Some representations are better for some types of + * operations, or are required to support specific sizes of operation data. + */ +enum VoxelManagerEnum { + /** + * The RLE Voxel manager defines rows within a volume as a set of Run Length + * encoded values, where all the values between successive i indices on the + * same row/slice have the given value. + * This is very efficient when there are long runs on i values all having the + * same value, as is typical in many segmentations. + * It is also allows for multi-valued segmentations, for example, having + * segments 1 and 3 for a single run. Note that such segmentations need to + * be converted to simple segmentations for actual display. + */ + RLE = 'RLE', + + /** + * The volume voxel manager represents data in a TypeArray that is pixel selection first, + * column second, row third, and finally slice number. This is the same representation + * as Image data used in ITK and VTK. + * This requires a full pixel data TypedArray instance. + */ + Volume = 'Volume', +} + +export default VoxelManagerEnum; diff --git a/packages/core/src/enums/index.ts b/packages/core/src/enums/index.ts index 9792df0315..bb10abf7c5 100644 --- a/packages/core/src/enums/index.ts +++ b/packages/core/src/enums/index.ts @@ -14,6 +14,7 @@ import ViewportStatus from './ViewportStatus'; import ImageQualityStatus from './ImageQualityStatus'; import * as VideoEnums from './VideoEnums'; import MetadataModules from './MetadataModules'; +import VoxelManagerEnum from './VoxelManagerEnum'; export { Events, @@ -32,4 +33,5 @@ export { VideoEnums, MetadataModules, ImageQualityStatus, + VoxelManagerEnum, }; diff --git a/packages/core/src/loaders/volumeLoader.ts b/packages/core/src/loaders/volumeLoader.ts index bf41dc836c..929bcd9291 100644 --- a/packages/core/src/loaders/volumeLoader.ts +++ b/packages/core/src/loaders/volumeLoader.ts @@ -8,8 +8,10 @@ import cloneDeep from 'lodash.clonedeep'; import { ImageVolume } from '../cache/classes/ImageVolume'; import cache from '../cache/cache'; import Events from '../enums/Events'; +import VoxelManagerEnum from '../enums/VoxelManagerEnum'; import eventTarget from '../eventTarget'; import triggerEvent from '../utilities/triggerEvent'; +import VoxelManager from '../utilities/VoxelManager'; import { generateVolumePropsFromImageIds, getBufferConfiguration, @@ -43,6 +45,12 @@ interface DerivedVolumeOptions { type: PixelDataTypedArrayString; sharedArrayBuffer?: boolean; }; + /** + * Use a voxel representation of the specified type. + * This allows efficient representation of the data to be selected and then + * treated as though all the representations were equivalent. + */ + voxelRepresentation?: VoxelManagerEnum; } interface LocalVolumeOptions { metadata: Metadata; @@ -291,7 +299,8 @@ export async function createAndCacheDerivedVolume( } let { volumeId } = options; - const { targetBuffer } = options; + const { targetBuffer, voxelRepresentation } = options; + const { type } = targetBuffer; if (volumeId === undefined) { volumeId = uuidv4(); @@ -311,6 +320,8 @@ export async function createAndCacheDerivedVolume( name: 'Pixels', numberOfComponents: 1, values: volumeScalarData, + size: numBytes, + dataType: !type || type === 'none' ? 'Uint8Array' : type, }); const derivedImageData = vtkImageData.newInstance(); @@ -321,6 +332,19 @@ export async function createAndCacheDerivedVolume( derivedImageData.setOrigin(origin); derivedImageData.getPointData().setScalars(scalarArray); + const internalScalarData = derivedImageData + .getPointData() + .getScalars() + .getData() as PixelDataTypedArray; + + const voxelManager = + (voxelRepresentation === VoxelManagerEnum.RLE && + VoxelManager.createRLEVoxelManager(dimensions)) || + (VoxelManager.createVolumeVoxelManager( + dimensions, + internalScalarData, + 1 + ) as VoxelManager); const derivedVolume = new ImageVolume({ volumeId, metadata: cloneDeep(metadata), @@ -329,7 +353,8 @@ export async function createAndCacheDerivedVolume( origin, direction, imageData: derivedImageData, - scalarData: volumeScalarData, + scalarData: internalScalarData, + voxelManager, sizeInBytes: numBytes, imageIds: [], referencedVolumeId, @@ -578,6 +603,7 @@ export async function createAndCacheDerivedSegmentationVolume( ...options, targetBuffer: { type: 'Uint8Array', + ...options?.targetBuffer, }, }); } @@ -624,6 +650,9 @@ function generateVolumeScalarData( ) { const { useNorm16Texture } = getConfiguration().rendering; + if (targetBuffer?.type === 'none') { + return { volumeScalarData: null, numBytes: scalarLength }; + } const { TypedArrayConstructor, numBytes } = getBufferConfiguration( targetBuffer?.type, scalarLength, diff --git a/packages/core/src/types/ImageVolumeProps.ts b/packages/core/src/types/ImageVolumeProps.ts index 7960580943..5e00bea60f 100644 --- a/packages/core/src/types/ImageVolumeProps.ts +++ b/packages/core/src/types/ImageVolumeProps.ts @@ -1,4 +1,6 @@ -import { VolumeProps } from '.'; +import type { VolumeProps } from '.'; +import type VoxelManager from '../utilities/VoxelManager'; +import type Point3 from './Point3'; /** * ImageVolume which is considered a special case of a Volume, which is @@ -10,6 +12,8 @@ interface ImageVolumeProps extends VolumeProps { imageIds: Array; /** if the volume is created from a stack, the imageIds of the stack */ referencedImageIds?: Array; + /** A voxel manager for this data */ + voxelManager?: VoxelManager | VoxelManager; } export { ImageVolumeProps }; diff --git a/packages/core/src/types/PixelDataTypedArray.ts b/packages/core/src/types/PixelDataTypedArray.ts index 3079399869..d8f607d9b4 100644 --- a/packages/core/src/types/PixelDataTypedArray.ts +++ b/packages/core/src/types/PixelDataTypedArray.ts @@ -12,4 +12,6 @@ export type PixelDataTypedArrayString = | 'Uint16Array' | 'Uint8Array' | 'Int8Array' - | 'Uint8ClampedArray'; + | 'Uint8ClampedArray' + // Used to not create an array object + | 'none'; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 2659c7d97f..a72040df85 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -93,6 +93,9 @@ import type ICachedGeometry from './ICachedGeometry'; import type { IContourSet } from './IContourSet'; import type { IContour } from './IContour'; import type RGB from './RGB'; +import type { Memo, HistoryMemo } from '../utilities/historyMemo'; +import type { VoxelManager } from '../utilities/VoxelManager'; +import type RLEVoxelMap from '../utilities/RLEVoxelMap'; import { ColormapPublic, ColormapRegistration } from './Colormap'; import type { ViewportProperties } from './ViewportProperties'; import type { @@ -152,6 +155,10 @@ export type { IImage, IImageData, IImageCalibration, + Memo, + HistoryMemo, + VoxelManager, + RLEVoxelMap, CPUIImageData, CPUImageData, EventTypes, diff --git a/packages/core/src/utilities/PointsManager.ts b/packages/core/src/utilities/PointsManager.ts index 9b5fbd96b0..a9683bd38e 100644 --- a/packages/core/src/utilities/PointsManager.ts +++ b/packages/core/src/utilities/PointsManager.ts @@ -153,6 +153,14 @@ export default class PointsManager { } } + /** + * Gets the raw underlying data - note this can change. Use for fast calculations + * on a fully filled array. + */ + public getTypedArray() { + return this.data; + } + /** * Push a new point onto this arrays object */ @@ -246,9 +254,19 @@ export default class PointsManager { /** * Create a PointsManager instance with available capacity of initialSize + * + * @param initialSize - the starting size of the underlying array, however, it will still + * be empty of actual data initially. + * @param points - a set of points to add to the points array. Makes it easy to copy + * a set of points into a PointsManager. */ - public static create3(initialSize = 128) { - return new PointsManager({ initialSize, dimensions: 3 }); + public static create3(initialSize = 128, points?: Point3[]) { + initialSize = Math.max(initialSize, points?.length || 0); + const newPoints = new PointsManager({ initialSize, dimensions: 3 }); + if (points) { + points.forEach((point) => newPoints.push(point)); + } + return newPoints; } /** diff --git a/packages/core/src/utilities/RLEVoxelMap.ts b/packages/core/src/utilities/RLEVoxelMap.ts index 7dbdd10157..624ccc30af 100644 --- a/packages/core/src/utilities/RLEVoxelMap.ts +++ b/packages/core/src/utilities/RLEVoxelMap.ts @@ -1,3 +1,5 @@ +import type Point3 from '../types/Point3'; +import type BoundsIJK from '../types/BoundsIJK'; import { PixelDataTypedArray } from '../types'; /** @@ -11,6 +13,45 @@ export type RLERun = { end: number; }; +/** + * Performs adjacent flood fill in all directions, for a true flood fill + */ +const ADJACENT_ALL = [ + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], + [0, 0, 1], +]; + +const ADJACENT_SINGLE_PLANE = [ + [0, -1, 0], + [0, 1, 0], +]; + +/** + * Adjacent in and out do a flood fill in only one of depth (in or out) directions. + * That improves the performance, as well as looks much nicer for many flood operations. + */ +const ADJACENT_IN = [ + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], +]; +const ADJACENT_OUT = [ + [0, -1, 0], + [0, 1, 0], + [0, 0, 1], +]; + +/** + * A type that has converts to and from an integer plane representation. + */ +export type PlaneNormalizer = { + toIJK: (ijkPrime: Point3) => Point3; + fromIJK: (ijk: Point3) => Point3; + boundsIJKPrime: BoundsIJK; +}; + /** * RLE based implementation of a voxel map. * This can be used as single or multi-plane, as the underlying indexes are @@ -18,6 +59,7 @@ export type RLERun = { * incrementing for all rows in the multi-plane voxel. */ export default class RLEVoxelMap { + public normalizer: PlaneNormalizer; /** * The rows for the voxel map is a map from the j index location (or for * volumes, `j + k*height`) to a list of RLE runs. That is, each entry in @@ -52,13 +94,25 @@ export default class RLEVoxelMap { * default value for unset values. * Set to 0 by default, but any maps where 0 not in T should update this value. */ - public defaultValue: T = 0 as unknown as T; + public defaultValue: T; /** * The constructor for creating pixel data. */ public pixelDataConstructor = Uint8Array; + /** + * Copies the data in source into the map. + */ + public static copyMap( + destination: RLEVoxelMap, + source: RLEVoxelMap + ) { + for (const [index, row] of source.rows) { + destination.rows.set(index, structuredClone(row)); + } + } + constructor(width: number, height: number, depth = 1) { this.width = width; this.height = height; @@ -79,9 +133,20 @@ export default class RLEVoxelMap { const i = index % this.jMultiple; const j = (index - i) / this.jMultiple; const rle = this.getRLE(i, j); - return rle?.value || this.defaultValue; + return rle?.value ?? this.defaultValue; }; + public toIJK(index: number): Point3 { + const i = index % this.jMultiple; + const j = ((index - i) / this.jMultiple) % this.height; + const k = Math.floor(index / this.kMultiple); + return [i, j, k]; + } + + public toIndex([i, j, k]: Point3) { + return i + k * this.kMultiple + j * this.jMultiple; + } + /** * Gets a list of RLERun values which specify the data on the row j * This allows applying or modifying the run directly. See CanvasActor @@ -97,6 +162,61 @@ export default class RLEVoxelMap { return i >= rle?.start ? rle : undefined; } + /** + * Indicate if the map has the given value + */ + public has(index: number): boolean { + const i = index % this.jMultiple; + const j = (index - i) / this.jMultiple; + const rle = this.getRLE(i, j); + return rle?.value !== undefined; + } + + /** + * Delete any value at the given index; + */ + public delete(index: number) { + const i = index % this.width; + const j = (index - i) / this.width; + const row = this.rows.get(j); + if (!row) { + return; + } + const rleIndex = this.findIndex(row, i); + const rle = row[rleIndex]; + if (!rle || rle.start > i) { + // Value not in RLE, so no need to delete + return; + } + if (rle.end === i + 1) { + // Value at end, so decrease the length. + // This also handles hte case of the value at the beginning and deleting + // the final value in the RLE + rle.end--; + if (rle.start >= rle.end) { + // Last value in the RLE + row.splice(rleIndex, 1); + if (!row.length) { + this.rows.delete(j); + } + } + return; + } + if (rle.start === i) { + // Not the only value, otherwise this is checked by the previous code + rle.start++; + return; + } + // Need to split the rle since the value occurs in the middle. + const newRle = { + value: rle.value, + start: i + 1, + end: rle.end, + }; + rle.end = i; + row.splice(rleIndex + 1, 0, newRle); + } + /** * Finds the index in the row that i is contained in, OR that i would be * before. That is, the rle value for the returned index in that row @@ -114,6 +234,28 @@ export default class RLEVoxelMap { return row.length; } + /** + * For each RLE element, call the given callback + */ + public forEach(callback, options?: { rowModified?: boolean }) { + const rowModified = options?.rowModified; + for (const [baseIndex, row] of this.rows) { + const rowToUse = rowModified ? [...row] : row; + for (const rle of rowToUse) { + callback(baseIndex * this.width, rle, row); + } + } + } + + /** + * For each row, call the callback with the base index and the row data + */ + public forEachRow(callback) { + for (const [baseIndex, row] of this.rows) { + callback(baseIndex * this.width, row); + } + } + /** * Gets the run for the given j,k indices. This is used to allow fast access * to runs for data for things like rendering entire rows of data. @@ -297,6 +439,168 @@ export default class RLEVoxelMap { } return pixelData; } + + /** + * Performs a flood fill on the RLE values at the given position, replacing + * the current value with the new value (which must be different) + * Note that this is, by default, a planar fill, which will fill each plane + * given the starting point, in a true flood fill fashion, but then not + * re-fill the given plane. + * + * @param i,j,k - starting point to fill from, as integer indices into + * the voxel volume. These are converted internally to RLE indices + * @param value - to replace the existing value with. Must be different from + * the starting value. + * @param options - to control the flood. + * * planar means to flood the current k plane entirely, and then use the + * points from the current plane as seed points in the k+1 and k-1 planes, + * but not returning to the current plane + * * singlePlane is just a single k plane, not filling any other planes + * * diagonals means to use the diagonally adjacent points. + */ + public floodFill( + i: number, + j: number, + k: number, + value: T, + options?: { planar?: boolean; diagonals?: boolean; singlePlane?: boolean } + ): number { + const rle = this.getRLE(i, j, k); + if (!rle) { + throw new Error(`Initial point ${i},${j},${k} isn't in the RLE`); + } + const stack = [[rle, j, k]]; + const replaceValue = rle.value; + if (replaceValue === value) { + throw new Error( + `source (${replaceValue}) and destination (${value}) are identical` + ); + } + return this.flood(stack, replaceValue, value, options); + } + + /** + * Performs a flood fill on the stack. + * + * @param stack - list of points/rle runs to try filling + * @param sourceValue - the value that is being replaced in the flood + * @param value - the destination value for the flood + * @param options - see floodFill + */ + private flood(stack, sourceValue, value, options) { + let sum = 0; + const { + planar = true, + diagonals = true, + singlePlane = false, + } = options || {}; + const childOptions = { planar, diagonals, singlePlane }; + while (stack.length) { + const top = stack.pop(); + const [current] = top; + if (current.value !== sourceValue) { + continue; + } + current.value = value; + sum += current.end - current.start; + const adjacents = this.findAdjacents(top, childOptions).filter( + (adjacent) => adjacent && adjacent[0].value === sourceValue + ); + stack.push(...adjacents); + } + return sum; + } + + /** + * Fills an RLE from a given getter result, skipping undefined values only. + * @param getter - a function taking i,j,k values (indices) and returning the new + * value at the given point. + * @param boundsIJK - a set of boundary values to flood up to and including both values. + */ + public fillFrom( + getter: (i: number, j: number, k: number) => T, + boundsIJK: BoundsIJK + ) { + for (let k = boundsIJK[2][0]; k <= boundsIJK[2][1]; k++) { + for (let j = boundsIJK[1][0]; j <= boundsIJK[1][1]; j++) { + let rle; + let row; + for (let i = boundsIJK[0][0]; i <= boundsIJK[0][1]; i++) { + const value = getter(i, j, k); + if (value === undefined) { + rle = undefined; + continue; + } + if (!row) { + row = []; + this.rows.set(j + k * this.height, row); + } + if (rle && rle.value !== value) { + rle = undefined; + } + if (!rle) { + rle = { start: i, end: i, value }; + row.push(rle); + } + rle.end++; + } + } + } + } + + /** + * Finds adjacent RLE runs, in all directions. + * The planar value (true by default) does plane at a time fills. + * @param item - an RLE being sepecified to find adjacent values for + * @param options - see floodFill + */ + public findAdjacents( + item: [RLERun, number, number, Point3[]?], + { diagonals = true, planar = true, singlePlane = false } + ) { + const [rle, j, k, adjacentsDelta] = item; + const { start, end } = rle; + const leftRle = start > 0 && this.getRLE(start - 1, j, k); + const rightRle = end < this.width && this.getRLE(end, j, k); + const range = diagonals + ? [start > 0 ? start - 1 : start, end < this.width ? end + 1 : end] + : [start, end]; + const adjacents = []; + if (leftRle) { + adjacents.push([leftRle, j, k]); + } + if (rightRle) { + adjacents.push([rightRle, j, k]); + } + for (const delta of adjacentsDelta || + (singlePlane ? ADJACENT_SINGLE_PLANE : ADJACENT_ALL)) { + const [, delta1, delta2] = delta; + const testJ = delta1 + j; + const testK = delta2 + k; + if (testJ < 0 || testJ >= this.height) { + continue; + } + if (testK < 0 || testK >= this.depth) { + continue; + } + const row = this.getRun(testJ, testK); + if (!row) { + continue; + } + for (const testRle of row) { + const newAdjacentDelta = + adjacentsDelta || + (singlePlane && ADJACENT_SINGLE_PLANE) || + (planar && delta2 > 0 && ADJACENT_OUT) || + (planar && delta2 < 0 && ADJACENT_IN) || + ADJACENT_ALL; + if (!(testRle.end <= range[0] || testRle.start >= range[1])) { + adjacents.push([testRle, testJ, testK, newAdjacentDelta]); + } + } + } + return adjacents; + } } // This is some code to allow debugging RLE maps diff --git a/packages/core/src/utilities/VoxelManager.ts b/packages/core/src/utilities/VoxelManager.ts index f59315c776..b51b6e6460 100644 --- a/packages/core/src/utilities/VoxelManager.ts +++ b/packages/core/src/utilities/VoxelManager.ts @@ -168,33 +168,70 @@ export default class VoxelManager { /** * Iterate over the points within the bounds, or the modified points if recorded. + * @param callback - a callback to call with `value, index, pointIJK` for + * every point in the scalar data, map or rle map depending on the VoxelManager + * type. + * @param options - has an optional isWIthinObject to test to see if hte callback + * should be called or not. */ public forEach = (callback, options?) => { const boundsIJK = options?.boundsIJK || this.getBoundsIJK(); const { isWithinObject } = options || {}; if (this.map) { - // Optimize this for only values in the map - for (const index of this.map.keys()) { - const pointIJK = this.toIJK(index); - const value = this._get(index); - const callbackArguments = { value, index, pointIJK }; - if (isWithinObject?.(callbackArguments) === false) { - continue; + if (this.map instanceof RLEVoxelMap) { + return this.rleForEach(callback, options); + } + return this.mapForEach(callback, options); + } + + for (let k = boundsIJK[2][0]; k <= boundsIJK[2][1]; k++) { + const kIndex = k * this.frameSize; + for (let j = boundsIJK[1][0]; j <= boundsIJK[1][1]; j++) { + const jIndex = kIndex + j * this.width; + for ( + let i = boundsIJK[0][0], index = jIndex + i; + i <= boundsIJK[0][1]; + i++, index++ + ) { + const value = this.getAtIndex(index); + const callbackArguments = { value, index, pointIJK: [i, j, k] }; + if (isWithinObject?.(callbackArguments) === false) { + continue; + } + callback(callbackArguments); } - callback(callbackArguments); } - } else { - for (let k = boundsIJK[2][0]; k <= boundsIJK[2][1]; k++) { - const kIndex = k * this.frameSize; - for (let j = boundsIJK[1][0]; j <= boundsIJK[1][1]; j++) { - const jIndex = kIndex + j * this.width; - for ( - let i = boundsIJK[0][0], index = jIndex + i; - i <= boundsIJK[0][1]; - i++, index++ - ) { - const value = this.getAtIndex(index); - const callbackArguments = { value, index, pointIJK: [i, j, k] }; + } + }; + + /** + * Foreach callback optimized for RLE testing + * @param callback - a callback to call with `value, index, pointIJK` for + * every point in the rle map (see the rle map for callbacks that work at + * the row or rle level, as those can be faster/more efficient) + * @param options - has an optional isWIthinObject to test to see if hte callback + * should be called or not. + */ + public rleForEach(callback, options?) { + const boundsIJK = options?.boundsIJK || this.getBoundsIJK(); + const { isWithinObject } = options || {}; + const map = this.map as RLEVoxelMap; + map.defaultValue = undefined; + for (let k = boundsIJK[2][0]; k <= boundsIJK[2][1]; k++) { + for (let j = boundsIJK[1][0]; j <= boundsIJK[1][1]; j++) { + const row = map.getRun(j, k); + if (!row) { + continue; + } + for (const rle of row) { + const { start, end, value } = rle; + const baseIndex = this.toIndex([0, j, k]); + for (let i = start; i < end; i++) { + const callbackArguments = { + value, + index: baseIndex + i, + pointIJK: [i, j, k], + }; if (isWithinObject?.(callbackArguments) === false) { continue; } @@ -203,22 +240,47 @@ export default class VoxelManager { } } } - }; + } /** - * Clears any map specific data, as wellas the modified slices, points and - * bounds. + * Foreach callback optimized for basic map callbacks. + * + * @param callback - a callback to call with `value, index, pointIJK` for + * every point in the map. + * @param options - has an optional isWIthinObject to test to see if hte callback + * should be called or not. */ - public clear() { - if (this.map) { - this.map.clear(); + public mapForEach(callback, options?) { + const { isWithinObject } = options || {}; + // Optimize this for only values in the map + for (const index of this.map.keys()) { + const pointIJK = this.toIJK(index); + const value = this._get(index); + const callbackArguments = { value, index, pointIJK }; + if (isWithinObject?.(callbackArguments) === false) { + continue; + } + callback(callbackArguments); } + } + + /** + * Clears any map specific data, as well as the modified slices, points and + * bounds and sets scalar data to 0. + * + * @param clearScalar - set to true to clear any underlying scalar data to 0 + */ + public clear(clearScalar = false) { + this.map?.clear(); this.boundsIJK.map((bound) => { bound[0] = Infinity; bound[1] = -Infinity; }); this.modifiedSlices.clear(); this.points?.clear(); + if (clearScalar) { + this.scalarData?.fill(0); + } } /** @@ -393,6 +455,42 @@ export default class VoxelManager { return voxelManager; } + /** + * Creates a history remembering voxel manager, based on the RLE endpoint + * rather than a map endpoint. + * This will remember the original values in the voxels, and will apply the + * update to the underlying source voxel manager. + */ + public static createRLEHistoryVoxelManager( + sourceVoxelManager: VoxelManager + ): VoxelManager { + const { dimensions } = sourceVoxelManager; + const map = new RLEVoxelMap(dimensions[0], dimensions[1], dimensions[2]); + const voxelManager = new VoxelManager( + dimensions, + (index) => map.get(index), + function (index, v) { + const originalV = map.get(index); + if (originalV === undefined) { + const oldV = this.sourceVoxelManager.getAtIndex(index); + if (oldV === v || oldV === undefined || v === null) { + // No-op + return false; + } + map.set(index, oldV); + } else if (v === originalV || v === null) { + map.delete(index); + v = originalV; + } + this.sourceVoxelManager.setAtIndex(index, v); + } + ); + voxelManager.map = map; + voxelManager.scalarData = sourceVoxelManager.scalarData; + voxelManager.sourceVoxelManager = sourceVoxelManager; + return voxelManager; + } + /** * Creates a lazy voxel manager that will create an image plane as required * for each slice of a volume as it gets changed. This can be used to @@ -403,7 +501,7 @@ export default class VoxelManager { planeFactory: (width: number, height: number) => T ): VoxelManager { const map = new Map(); - const [width, height, depth] = dimensions; + const [width, height, _depth] = dimensions; const planeSize = width * height; const voxelManager = new VoxelManager( diff --git a/packages/core/src/utilities/historyMemo/index.ts b/packages/core/src/utilities/historyMemo/index.ts new file mode 100644 index 0000000000..37afa6812e --- /dev/null +++ b/packages/core/src/utilities/historyMemo/index.ts @@ -0,0 +1,113 @@ +export type Memo = { + /** + * This restores memo state. It is an undo if undo is true, or a redo if it + * is false. + */ + restoreMemo: (undo?: boolean) => void; + + /** + * An optional function that will be called to commit any changes that have + * occurred in a memo. This allows recording changes that are ongoing to a memo + * and then being able to undo them without having to record the entire state at the + * time the memo is initially created. See createLabelmapMemo for an example + * use. + * + * @return true if this memo contains any data, if so it should go on the memo ring + * after the commit is completed. + */ + commitMemo?: () => boolean; +}; + +/** + * This is a function which can be implemented to create a memo and then pass + * the implementing class instead of a new memo itself. + */ +export type Memoable = { + createMemo: () => Memo; +}; + +/** + * historyMemo is a set of history of memos of tool state. That is, it remembers + * what has been applied to various images. + */ + +export class HistoryMemo { + public readonly label; + + private _size; + private position = -1; + private redoAvailable = 0; + private undoAvailable = 0; + private ring = new Array(); + + constructor(label = 'Tools', size = 50) { + this.label = label; + this._size = size; + } + + /** The number of items that can be stored in the history */ + public get size() { + return this._size; + } + + /** + * Undoes up to the given number of items off the ring + */ + public undo(items = 1) { + while (items > 0 && this.undoAvailable > 0) { + const item = this.ring[this.position]; + item.restoreMemo(true); + items--; + this.redoAvailable++; + this.undoAvailable--; + this.position = (this.position - 1 + this.size) % this.size; + } + } + + /** + * Redoes up to the given number of items, adding them to the top of the ring. + */ + public redo(items = 1) { + while (items > 0 && this.redoAvailable > 0) { + const newPosition = (this.position + 1) % this.size; + const item = this.ring[newPosition]; + item.restoreMemo(false); + items--; + this.position = newPosition; + this.undoAvailable++; + this.redoAvailable--; + } + } + + /** + * Pushes a new memo onto the ring. This will remove all redoable items + * from the ring if a memo was pushed. Ignores undefined or null items. + */ + public push(item: Memo | Memoable) { + if (!item) { + // No-op for not provided items + return; + } + const memo = (item as Memo).restoreMemo + ? (item as Memo) + : (item as Memoable).createMemo?.(); + if (!memo) { + return; + } + this.redoAvailable = 0; + if (this.undoAvailable < this._size) { + this.undoAvailable++; + } + this.position = (this.position + 1) % this._size; + this.ring[this.position] = memo; + return memo; + } +} + +/** + * The default HistoryMemo is a shared history state that can be used for + * any undo/redo memo items. + */ +const DefaultHistoryMemo = new HistoryMemo(); + +export { DefaultHistoryMemo }; diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index 78d6119ed9..c54ea24a2d 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -28,6 +28,7 @@ import getViewportsWithVolumeId from './getViewportsWithVolumeId'; import transformWorldToIndex from './transformWorldToIndex'; import transformIndexToWorld from './transformIndexToWorld'; import loadImageToCanvas from './loadImageToCanvas'; +import * as HistoryMemo from './historyMemo'; import renderToCanvasCPU from './renderToCanvasCPU'; import renderToCanvasGPU from './renderToCanvasGPU'; import worldToImageCoords from './worldToImageCoords'; @@ -66,6 +67,7 @@ import { generateVolumePropsFromImageIds } from './generateVolumePropsFromImageI import { convertStackToVolumeViewport } from './convertStackToVolumeViewport'; import { convertVolumeToStackViewport } from './convertVolumeToStackViewport'; import VoxelManager from './VoxelManager'; +import RLEVoxelMap from './RLEVoxelMap'; import roundNumber, { roundToPrecision } from './roundNumber'; import convertToGrayscale from './convertToGrayscale'; import getViewportImageIds from './getViewportImageIds'; @@ -149,9 +151,11 @@ export { isValidVolume, genericMetadataProvider, isVideoTransferSyntax, + HistoryMemo, + generateVolumePropsFromImageIds, getBufferConfiguration, VoxelManager, - generateVolumePropsFromImageIds, + RLEVoxelMap, convertStackToVolumeViewport, convertVolumeToStackViewport, cacheUtils, diff --git a/packages/core/test/utilities/historyMemo.jest.js b/packages/core/test/utilities/historyMemo.jest.js new file mode 100644 index 0000000000..09ba58834d --- /dev/null +++ b/packages/core/test/utilities/historyMemo.jest.js @@ -0,0 +1,29 @@ +import { DefaultHistoryMemo } from '../../src/utilities/historyMemo'; + +import { describe, it, expect } from '@jest/globals'; + +let state = { + testState: 0, + createMemo: () => createMemo(state.testState), +}; + +function createMemo(rememberState) { + return { + restoreMemo: () => { + const currentState = state.testState; + state.testState = rememberState; + rememberState = currentState; + }, + }; +} + +describe('HistoryMemo', function () { + it('Simple state remembering', () => { + DefaultHistoryMemo.push(state); + state.testState = 1; + DefaultHistoryMemo.undo(); + expect(state.testState).toBe(0); + DefaultHistoryMemo.redo(); + expect(state.testState).toBe(1); + }); +}); diff --git a/packages/tools/examples/labelmapInterpolation/index.ts b/packages/tools/examples/labelmapInterpolation/index.ts new file mode 100644 index 0000000000..bdb6148024 --- /dev/null +++ b/packages/tools/examples/labelmapInterpolation/index.ts @@ -0,0 +1,456 @@ +import { + RenderingEngine, + Types, + Enums, + setVolumesForViewports, + volumeLoader, + ProgressiveRetrieveImages, + utilities, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addDropdownToToolbar, + addSliderToToolbar, + setCtTransferFunctionForVolumeActor, + getLocalUrl, + addButtonToToolbar, + addManipulationBindings, +} 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 { + SegmentationDisplayTool, + ToolGroupManager, + Enums: csToolsEnums, + segmentation, + RectangleScissorsTool, + SphereScissorsTool, + CircleScissorsTool, + BrushTool, + PaintFillTool, + utilities: cstUtils, +} = cornerstoneTools; + +const { MouseBindings } = csToolsEnums; +const { ViewportType } = Enums; +const { segmentation: segmentationUtils } = cstUtils; + +// 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 Interpolation', + 'Here we demonstrate interpolation between slices for labelmaps' +); + +const size = '500px'; +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'); +element1.style.width = size; +element1.style.height = size; +element2.style.width = size; +element2.style.height = size; +element3.style.width = size; +element3.style.height = size; + +// Disable right click context menu so we can have right click tools +element1.oncontextmenu = (e) => e.preventDefault(); +element2.oncontextmenu = (e) => e.preventDefault(); +element3.oncontextmenu = (e) => e.preventDefault(); + +viewportGrid.appendChild(element1); +viewportGrid.appendChild(element2); +viewportGrid.appendChild(element3); + +content.appendChild(viewportGrid); + +const instructions = document.createElement('p'); +instructions.innerText = ` + Use the labelmap tools in the normal way. Note preview is turned off for those + tools to simplify initial segment creation. +
Segments are interpolated BETWEEN slices, so you need to create two or more + segments of the same segment index on slices in a viewport separated by at least + one empty segment. + Press e for extended interpolation. This will interpolate segments which don't + overlap (assuming the segments were drawn on the same slice). + Press i for interpolation of overlapping segments - that is, the segment must + overlap if drawn on the same slice to interpolate between them. This is a good choice + for multiple segments. + Accept the interpolation by hitting enter, or reject with escape. + `; + +content.append(instructions); + +const interpolationTools = new Map(); +const preview = { + enabled: false, +}; +const configuration = { + preview, + strategySpecificConfiguration: { + useCenterSegmentIndex: true, + }, +}; +const thresholdOptions = new Map(); +thresholdOptions.set('Dynamic Radius 0', { isDynamic: true, dynamicRadius: 0 }); +thresholdOptions.set('Dynamic Radius 1', { isDynamic: true, dynamicRadius: 1 }); +thresholdOptions.set('Dynamic Radius 3', { isDynamic: true, dynamicRadius: 3 }); +thresholdOptions.set('Use Existing Threshold', { + isDynamic: false, + dynamicRadius: 5, +}); +thresholdOptions.set('CT Fat: (-150, -70)', { + threshold: [-150, -70], + isDynamic: false, +}); +thresholdOptions.set('CT Bone: (200, 1000)', { + threshold: [200, 1000], + isDynamic: false, +}); +const defaultThresholdOption = [...thresholdOptions.keys()][2]; +const thresholdArgs = thresholdOptions.get(defaultThresholdOption); + +interpolationTools.set('CircularBrush', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'FILL_INSIDE_CIRCLE', + }, +}); + +interpolationTools.set('ThresholdCircle', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'THRESHOLD_INSIDE_CIRCLE', + strategySpecificConfiguration: { + ...configuration.strategySpecificConfiguration, + THRESHOLD: { ...thresholdArgs }, + }, + }, +}); + +interpolationTools.set('ThresholdSphereIsland', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'THRESHOLD_INSIDE_SPHERE_ISLAND', + strategySpecificConfiguration: { + ...configuration.strategySpecificConfiguration, + THRESHOLD: { ...thresholdArgs }, + }, + }, +}); + +interpolationTools.set('ThresholdSphere', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'THRESHOLD_INSIDE_SPHERE', + strategySpecificConfiguration: { + ...configuration.strategySpecificConfiguration, + THRESHOLD: { ...thresholdArgs }, + }, + }, +}); + +interpolationTools.set('CircularEraser', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'ERASE_INSIDE_CIRCLE', + }, +}); + +interpolationTools.set('SphereBrush', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'FILL_INSIDE_SPHERE', + }, +}); +interpolationTools.set('SphereEraser', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'ERASE_INSIDE_SPHERE', + }, +}); +interpolationTools.set('ScissorsEraser', { + baseTool: SphereScissorsTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'ERASE_INSIDE', + }, +}); + +const optionsValues = [ + ...interpolationTools.keys(), + RectangleScissorsTool.toolName, + CircleScissorsTool.toolName, + SphereScissorsTool.toolName, + PaintFillTool.toolName, +]; + +// ============================= // +addDropdownToToolbar({ + options: { values: optionsValues, defaultValue: BrushTool.toolName }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + // Set the currently active tool disabled + const toolName = toolGroup.getActivePrimaryMouseButtonTool(); + + if (toolName) { + toolGroup.setToolDisabled(toolName); + } + + toolGroup.setToolActive(name, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + }, +}); + +addDropdownToToolbar({ + options: { + values: Array.from(thresholdOptions.keys()), + defaultValue: defaultThresholdOption, + }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + + const thresholdArgs = thresholdOptions.get(name); + + segmentationUtils.setBrushThresholdForToolGroup( + toolGroupId, + thresholdArgs.threshold, + thresholdArgs + ); + }, +}); + +addSliderToToolbar({ + title: 'Brush Size', + range: [5, 100], + defaultValue: 25, + onSelectedValueChange: (valueAsStringOrNumber) => { + const value = Number(valueAsStringOrNumber); + segmentationUtils.setBrushSizeForToolGroup(toolGroupId, value); + }, +}); + +// ============================= // +addDropdownToToolbar({ + options: { values: ['1', '2', '3'], defaultValue: '1' }, + labelText: 'Segment', + onSelectedValueChange: (segmentIndex) => { + segmentation.segmentIndex.setActiveSegmentIndex( + segmentationId, + Number(segmentIndex) + ); + }, +}); + +addButtonToToolbar({ + title: 'Reject Preview/Interpolation', + onClick: () => { + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + const activeName = toolGroup.getActivePrimaryMouseButtonTool(); + const brush = toolGroup.getToolInstance(activeName); + brush.rejectPreview?.(element1); + }, +}); + +// ============================= // + +async function addSegmentationsToState() { + // Create a segmentation of the same resolution as the source data + await volumeLoader.createAndCacheDerivedSegmentationVolume(volumeId, { + volumeId: segmentationId, + // The following doesn't quite work yet + // TODO, allow RLE to be used instead of scalars. + // targetBuffer: { type: 'none' }, + // voxelRepresentation: 'rleVoxelManager', + }); + + // 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, + }, + }, + }, + ]); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + utilities.imageRetrieveMetadataProvider.add( + 'volume', + ProgressiveRetrieveImages.interleavedRetrieveStages + ); + + // Add tools to Cornerstone3D + cornerstoneTools.addTool(SegmentationDisplayTool); + cornerstoneTools.addTool(RectangleScissorsTool); + cornerstoneTools.addTool(CircleScissorsTool); + cornerstoneTools.addTool(SphereScissorsTool); + cornerstoneTools.addTool(PaintFillTool); + cornerstoneTools.addTool(BrushTool); + + // Define tool groups to add the segmentation display tool to + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + // Manipulation Tools + addManipulationBindings(toolGroup); + + // Segmentation Tools + toolGroup.addTool(SegmentationDisplayTool.toolName); + toolGroup.addTool(RectangleScissorsTool.toolName); + toolGroup.addTool(CircleScissorsTool.toolName); + toolGroup.addTool(SphereScissorsTool.toolName); + toolGroup.addTool(PaintFillTool.toolName); + toolGroup.addTool(BrushTool.toolName); + + for (const [toolName, config] of interpolationTools.entries()) { + if (config.baseTool) { + toolGroup.addToolInstance( + toolName, + config.baseTool, + config.configuration + ); + } else { + toolGroup.addTool(toolName, config.configuration); + } + if (config.passive) { + // This can be applied during add/remove contours + toolGroup.setToolPassive(toolName); + } + } + + toolGroup.setToolEnabled(SegmentationDisplayTool.toolName); + + toolGroup.setToolActive(interpolationTools.keys().next().value, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + // 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 + await addSegmentationsToState(); + + // 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, callback: setCtTransferFunctionForVolumeActor }], + [viewportId1, viewportId2, viewportId3] + ); + + // Add the segmentation representation to the toolgroup + await segmentation.addSegmentationRepresentations(toolGroupId, [ + { + segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + ]); + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 1); + + // Render the image + renderingEngine.renderViewports([viewportId1, viewportId2, viewportId3]); +} + +run(); diff --git a/packages/tools/examples/labelmapSegmentationDynamicThreshold/index.ts b/packages/tools/examples/labelmapSegmentationDynamicThreshold/index.ts index 03f31b640d..9cf0b8f506 100644 --- a/packages/tools/examples/labelmapSegmentationDynamicThreshold/index.ts +++ b/packages/tools/examples/labelmapSegmentationDynamicThreshold/index.ts @@ -128,6 +128,7 @@ addDropdownToToolbar({ addSliderToToolbar({ title: 'Brush Size', range: [5, 50], + range: [5, 100], defaultValue: 25, onSelectedValueChange: (valueAsStringOrNumber) => { const value = Number(valueAsStringOrNumber); diff --git a/packages/tools/examples/labelmapStatistics/index.ts b/packages/tools/examples/labelmapStatistics/index.ts new file mode 100644 index 0000000000..773d87250c --- /dev/null +++ b/packages/tools/examples/labelmapStatistics/index.ts @@ -0,0 +1,549 @@ +import { + RenderingEngine, + Types, + Enums, + setVolumesForViewports, + volumeLoader, + ProgressiveRetrieveImages, + utilities, + eventTarget, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addDropdownToToolbar, + addSliderToToolbar, + setCtTransferFunctionForVolumeActor, + getLocalUrl, + addButtonToToolbar, + addManipulationBindings, +} 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 { + SegmentationDisplayTool, + ToolGroupManager, + Enums: csToolsEnums, + segmentation, + RectangleScissorsTool, + SphereScissorsTool, + CircleScissorsTool, + BrushTool, + PaintFillTool, + utilities: cstUtils, +} = cornerstoneTools; + +const { MouseBindings, Events } = csToolsEnums; +const { ViewportType } = Enums; +const { segmentation: segmentationUtils, roundNumber } = cstUtils; + +// 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'; +const viewports = []; + +const DEFAULT_BRUSH_SIZE = 10; + +// ======== Set up page ======== // +setTitleAndDescription( + 'Labelmap Segmentation Statistics', + 'Here we demonstrate calculating labelmap statistics' +); + +const size = '500px'; +const content = document.getElementById('content'); + +const statsGrid = document.createElement('div'); +statsGrid.style.display = 'flex'; +statsGrid.style.display = 'flex'; +statsGrid.style.flexDirection = 'row'; +statsGrid.style.fontSize = 'smaller'; + +const statsIds = ['statsCurrent', 'statsPreview', 'statsCombined']; +const statsStyle = { + width: '20em', + height: '10em', +}; + +for (const statsId of statsIds) { + const statsDiv = document.createElement('div'); + statsDiv.id = statsId; + statsDiv.innerText = statsId; + Object.assign(statsDiv.style, statsStyle); + statsGrid.appendChild(statsDiv); +} + +content.appendChild(statsGrid); + +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'); +element1.style.width = size; +element1.style.height = size; +element2.style.width = size; +element2.style.height = size; +element3.style.width = size; +element3.style.height = size; + +// Disable right click context menu so we can have right click tools +element1.oncontextmenu = (e) => e.preventDefault(); +element2.oncontextmenu = (e) => e.preventDefault(); +element3.oncontextmenu = (e) => e.preventDefault(); + +viewportGrid.appendChild(element1); +viewportGrid.appendChild(element2); +viewportGrid.appendChild(element3); + +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); + +const interpolationTools = new Map(); +const previewColors = { + 0: [255, 255, 255, 128], + 1: [0, 255, 255, 192], + 2: [255, 0, 255, 255], +}; +const preview = { + enabled: true, + previewColors, +}; +const configuration = { + preview, + strategySpecificConfiguration: { + useCenterSegmentIndex: true, + }, +}; +const thresholdOptions = new Map(); +thresholdOptions.set('Dynamic Radius 0', { isDynamic: true, dynamicRadius: 0 }); +thresholdOptions.set('Dynamic Radius 1', { isDynamic: true, dynamicRadius: 1 }); +thresholdOptions.set('Dynamic Radius 3', { isDynamic: true, dynamicRadius: 3 }); +thresholdOptions.set('Dynamic Radius 5', { isDynamic: true, dynamicRadius: 5 }); +thresholdOptions.set('Use Existing Threshold', { + isDynamic: false, + dynamicRadius: 5, +}); +thresholdOptions.set('CT Fat: (-150, -70)', { + threshold: [-150, -70], + isDynamic: false, +}); +thresholdOptions.set('CT Bone: (200, 1000)', { + threshold: [200, 1000], + isDynamic: false, +}); +const defaultThresholdOption = [...thresholdOptions.keys()][2]; +const thresholdArgs = thresholdOptions.get(defaultThresholdOption); + +interpolationTools.set('CircularBrush', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'FILL_INSIDE_CIRCLE', + }, +}); + +interpolationTools.set('ThresholdSphereIsland', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'THRESHOLD_INSIDE_SPHERE_WITH_ISLAND_REMOVAL', + strategySpecificConfiguration: { + ...configuration.strategySpecificConfiguration, + THRESHOLD: { ...thresholdArgs }, + }, + }, +}); + +interpolationTools.set('ThresholdCircle', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'THRESHOLD_INSIDE_CIRCLE', + strategySpecificConfiguration: { + ...configuration.strategySpecificConfiguration, + THRESHOLD: { ...thresholdArgs }, + }, + }, +}); + +interpolationTools.set('ThresholdSphere', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'THRESHOLD_INSIDE_SPHERE', + strategySpecificConfiguration: { + ...configuration.strategySpecificConfiguration, + THRESHOLD: { ...thresholdArgs }, + }, + }, +}); + +interpolationTools.set('CircularEraser', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'ERASE_INSIDE_CIRCLE', + }, +}); + +interpolationTools.set('SphereBrush', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'FILL_INSIDE_SPHERE', + }, +}); +interpolationTools.set('SphereEraser', { + baseTool: BrushTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'ERASE_INSIDE_SPHERE', + }, +}); +interpolationTools.set('ScissorsEraser', { + baseTool: SphereScissorsTool.toolName, + configuration: { + ...configuration, + activeStrategy: 'ERASE_INSIDE', + }, +}); + +const optionsValues = [ + ...interpolationTools.keys(), + RectangleScissorsTool.toolName, + CircleScissorsTool.toolName, + SphereScissorsTool.toolName, + PaintFillTool.toolName, +]; + +// ============================= // +addDropdownToToolbar({ + options: { values: optionsValues, defaultValue: BrushTool.toolName }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + // Set the currently active tool disabled + const toolName = toolGroup.getActivePrimaryMouseButtonTool(); + + if (toolName) { + toolGroup.setToolDisabled(toolName); + } + + toolGroup.setToolActive(name, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + }, +}); + +addDropdownToToolbar({ + options: { + values: Array.from(thresholdOptions.keys()), + defaultValue: defaultThresholdOption, + }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + + const thresholdArgs = thresholdOptions.get(name); + + segmentationUtils.setBrushThresholdForToolGroup( + toolGroupId, + thresholdArgs.threshold, + thresholdArgs + ); + }, +}); + +addSliderToToolbar({ + title: 'Brush Size', + range: [5, 100], + defaultValue: DEFAULT_BRUSH_SIZE, + onSelectedValueChange: (valueAsStringOrNumber) => { + const value = Number(valueAsStringOrNumber); + segmentationUtils.setBrushSizeForToolGroup(toolGroupId, value); + }, +}); + +// ============================= // +addDropdownToToolbar({ + options: { values: ['1', '2', '3'], defaultValue: '1' }, + labelText: 'Segment', + onSelectedValueChange: (segmentIndex) => { + segmentation.segmentIndex.setActiveSegmentIndex( + segmentationId, + Number(segmentIndex) + ); + }, +}); + +addButtonToToolbar({ + title: 'Statistics 1,2,3', + onClick: () => calculateStatistics(statsIds[2], [1, 2, 3]), +}); + +function displayStat(stat) { + if (!stat) { + return; + } + return `${stat.label || stat.name}: ${roundNumber(stat.value)} ${ + stat.unit ? stat.unit : '' + }`; +} + +function calculateStatistics(id, indices) { + const [viewport] = viewports; + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + const activeName = toolGroup.getActivePrimaryMouseButtonTool(); + const brush = toolGroup.getToolInstance(activeName); + const stats = brush.getStatistics(viewport.element, { indices }); + const items = [`Statistics on ${indices.join(', ')}`]; + stats.count.label = 'Voxels'; + const lesionGlycolysis = { + name: 'Lesion Glycolysis', + value: stats.volume.value * stats.stdDev.value, + unit: 'HU \xB7 mm \xb3', + }; + stats.stdDev.label = 'SUV'; + items.push( + displayStat(stats.volume), + displayStat(stats.count), + displayStat(stats.stdDev), + displayStat(lesionGlycolysis), + displayStat(stats.mean), + displayStat(stats.max), + displayStat(stats.min) + ); + const statsDiv = document.getElementById(id); + statsDiv.innerHTML = items.map((span) => `${span}
\n`).join('\n'); +} + +let timeoutId; + +function segmentationModifiedCallback(evt) { + const { detail } = evt; + if (!detail) { + return; + } + if (timeoutId) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + const { segmentIndex } = detail; + if (!segmentIndex) { + // Both undefined and 0 segment indices are returns + return; + } + const statsId = statsIds[segmentIndex === 255 ? 1 : 0]; + + window.setTimeout(() => { + timeoutId = null; + calculateStatistics(statsId, [segmentIndex]); + }, 100); +} + +// ============================= // + +async function addSegmentationsToState() { + // Create a segmentation of the same resolution as the source data + await volumeLoader.createAndCacheDerivedSegmentationVolume(volumeId, { + volumeId: segmentationId, + // The following doesn't quite work yet + // TODO, allow RLE to be used instead of scalars. + // targetBuffer: { type: 'none' }, + // voxelRepresentation: VoxelManagerEnum.RLE, + }); + + // 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, + }, + }, + }, + ]); + + eventTarget.addEventListener( + Events.SEGMENTATION_DATA_MODIFIED, + segmentationModifiedCallback + ); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + utilities.imageRetrieveMetadataProvider.add( + 'volume', + ProgressiveRetrieveImages.interleavedRetrieveStages + ); + + // Add tools to Cornerstone3D + cornerstoneTools.addTool(SegmentationDisplayTool); + cornerstoneTools.addTool(RectangleScissorsTool); + cornerstoneTools.addTool(CircleScissorsTool); + cornerstoneTools.addTool(SphereScissorsTool); + cornerstoneTools.addTool(PaintFillTool); + cornerstoneTools.addTool(BrushTool); + + // Define tool groups to add the segmentation display tool to + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + // Manipulation Tools + addManipulationBindings(toolGroup); + + // Segmentation Tools + toolGroup.addTool(SegmentationDisplayTool.toolName); + toolGroup.addTool(RectangleScissorsTool.toolName); + toolGroup.addTool(CircleScissorsTool.toolName); + toolGroup.addTool(SphereScissorsTool.toolName); + toolGroup.addTool(PaintFillTool.toolName); + toolGroup.addTool(BrushTool.toolName); + + for (const [toolName, config] of interpolationTools.entries()) { + if (config.baseTool) { + toolGroup.addToolInstance( + toolName, + config.baseTool, + config.configuration + ); + } else { + toolGroup.addTool(toolName, config.configuration); + } + if (config.passive) { + // This can be applied during add/remove contours + toolGroup.setToolPassive(toolName); + } + } + + toolGroup.setToolEnabled(SegmentationDisplayTool.toolName); + + toolGroup.setToolActive(interpolationTools.keys().next().value, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + // 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 + await addSegmentationsToState(); + + // 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); + + viewports.push(...renderingEngine.getViewports()); + + // Set the volume to load + volume.load(); + + // Set volumes on the viewports + await setVolumesForViewports( + renderingEngine, + [{ volumeId, callback: setCtTransferFunctionForVolumeActor }], + [viewportId1, viewportId2, viewportId3] + ); + + // Add the segmentation representation to the toolgroup + await segmentation.addSegmentationRepresentations(toolGroupId, [ + { + segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + ]); + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 1); + + segmentationUtils.setBrushSizeForToolGroup(toolGroupId, DEFAULT_BRUSH_SIZE); + + // Render the image + renderingEngine.renderViewports([viewportId1, viewportId2, viewportId3]); +} + +run(); diff --git a/packages/tools/examples/toolHistory/index.ts b/packages/tools/examples/toolHistory/index.ts new file mode 100644 index 0000000000..d0f296f730 --- /dev/null +++ b/packages/tools/examples/toolHistory/index.ts @@ -0,0 +1,328 @@ +import { + RenderingEngine, + Types, + Enums, + getRenderingEngine, + volumeLoader, + setVolumesForViewports, + eventTarget, + utilities as csUtils, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addDropdownToToolbar, + addButtonToToolbar, + annotationTools, + labelmapTools, + contourTools, + addManipulationBindings, +} from '../../../../utils/demo/helpers'; +import * as cornerstoneTools from '@cornerstonejs/tools'; + +const { segmentation } = cornerstoneTools; +const { SegmentationDisplayTool } = cornerstoneTools; +const { MouseBindings } = cornerstoneTools.Enums; +const { DefaultHistoryMemo } = csUtils.HistoryMemo; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { + ToolGroupManager, + Enums: csToolsEnums, + AnnotationTool, +} = cornerstoneTools; + +const { ViewportType } = Enums; +const renderingEngineId = 'myRenderingEngine'; +const viewportId = 'CT_STACK'; +const labelmapSegmentationId = 'labelmapSegmentationId'; +const contourSegmentationId = 'contourSegmentationId'; +const defaultTool = 'ThresholdCircle'; + +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 toolMap = new Map(annotationTools); +for (const [key, value] of labelmapTools.toolMap) { + toolMap.set(key, value); +} +for (const [key, value] of contourTools.toolMap) { + toolMap.set(key, value); +} + +// ======== Set up page ======== // +setTitleAndDescription('Tool History', 'Demonstrate undo/redo on tools'); + +const content = document.getElementById('content'); +const element = document.createElement('div'); + +// Disable right click context menu so we can have right click tools +element.oncontextmenu = (e) => e.preventDefault(); + +element.id = 'cornerstone-element'; +element.style.width = '500px'; +element.style.height = '500px'; + +content.appendChild(element); + +const info = document.createElement('div'); +content.appendChild(info); + +const instructions = document.createElement('p'); +instructions.innerText = ` +Left Click to use selected tool +z to undo, y to redo +`; +info.appendChild(instructions); + +// ============================= // + +const toolGroupId = 'STACK_TOOL_GROUP_ID'; + +const cancelToolDrawing = (evt) => { + const { element, key } = evt.detail; + if (key === 'Escape') { + cornerstoneTools.cancelActiveManipulations(element); + } +}; + +element.addEventListener(csToolsEnums.Events.KEY_DOWN, (evt) => { + cancelToolDrawing(evt); +}); + +addDropdownToToolbar({ + options: { map: toolMap, defaultValue: defaultTool }, + onSelectedValueChange: (newSelectedToolName, data) => { + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + // Set the old tool passive + const selectedToolName = toolGroup.getActivePrimaryMouseButtonTool(); + if (selectedToolName) { + toolGroup.setToolPassive(selectedToolName); + } + + // Set the new tool active + toolGroup.setToolActive(newSelectedToolName as string, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Left Click + }, + ], + }); + const isContour = + data?.segmentationType === + csToolsEnums.SegmentationRepresentations.Contour; + segmentation.activeSegmentation.setActiveSegmentationRepresentation( + toolGroupId, + isContour + ? segmentationRepresentationUIDs[1] + : segmentationRepresentationUIDs[0] + ); + }, +}); + +addDropdownToToolbar({ + options: { values: ['1', '2', '3'], defaultValue: '1' }, + labelText: 'Segment', + onSelectedValueChange: (segmentIndex) => { + segmentation.segmentIndex.setActiveSegmentIndex( + labelmapSegmentationId, + Number(segmentIndex) + ); + segmentation.segmentIndex.setActiveSegmentIndex( + contourSegmentationId, + Number(segmentIndex) + ); + }, +}); + +let selectedAnnotationUID; + +function annotationModifiedListener(evt) { + selectedAnnotationUID = + evt.detail.annotation?.annotationUID || + evt.detail.annotationUID || + evt.detail.added?.[0]; +} + +function getActiveAnnotation() { + return cornerstoneTools.annotation.state.getAnnotation(selectedAnnotationUID); +} + +addButtonToToolbar({ + id: 'Undo', + title: 'Undo', + onClick() { + DefaultHistoryMemo.undo(); + }, +}); + +addButtonToToolbar({ + id: 'Redo', + title: 'Redo', + onClick() { + DefaultHistoryMemo.redo(); + }, +}); + +addButtonToToolbar({ + id: 'Delete', + title: 'Delete Annotation', + onClick() { + const annotation = getActiveAnnotation(); + if (annotation) { + // Note that delete needs to have a memo created for it, as the underlying + // state manager doesn't record this directly. + // The deleting flag is set to true meaning that this annotation is about + // to be deleted (but is NOT yet deleted). + AnnotationTool.createAnnotationMemo(element, annotation, { + deleting: true, + }); + cornerstoneTools.annotation.state.removeAnnotation( + annotation.annotationUID + ); + getRenderingEngine(renderingEngineId).render(); + } + }, +}); + +function addAnnotationListeners() { + const { Events: toolsEvents } = csToolsEnums; + eventTarget.addEventListener( + toolsEvents.ANNOTATION_SELECTION_CHANGE, + annotationModifiedListener + ); + eventTarget.addEventListener( + toolsEvents.ANNOTATION_MODIFIED, + annotationModifiedListener + ); + eventTarget.addEventListener( + toolsEvents.ANNOTATION_COMPLETED, + annotationModifiedListener + ); + eventTarget.addEventListener( + toolsEvents.ANNOTATION_REMOVED, + annotationModifiedListener + ); +} + +let segmentationRepresentationUIDs; + +async function addSegmentationsToState() { + // Create a segmentation of the same resolution as the source data + await volumeLoader.createAndCacheDerivedSegmentationVolume(volumeId, { + volumeId: labelmapSegmentationId, + }); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId: labelmapSegmentationId, + 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: labelmapSegmentationId, + }, + }, + }, + { + segmentationId: contourSegmentationId, + representation: { + type: csToolsEnums.SegmentationRepresentations.Contour, + }, + }, + ]); + + segmentationRepresentationUIDs = + await segmentation.addSegmentationRepresentations(toolGroupId, [ + { + segmentationId: labelmapSegmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + { + segmentationId: contourSegmentationId, + type: csToolsEnums.SegmentationRepresentations.Contour, + }, + ]); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + // Define a tool group, which defines how mouse events map to tool commands for + // Any viewport using the group + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + addManipulationBindings(toolGroup, { toolMap }); + cornerstoneTools.addTool(SegmentationDisplayTool); + toolGroup.addTool(SegmentationDisplayTool.toolName); + + // 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', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + }); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + await addSegmentationsToState(); + + // Instantiate a rendering engine + const renderingEngine = new RenderingEngine(renderingEngineId); + + // Create a stack viewport + const viewportInput = { + viewportId, + type: ViewportType.ORTHOGRAPHIC, + element, + defaultOptions: { + background: [0.2, 0, 0.2], + }, + }; + + renderingEngine.enableElement(viewportInput); + + // Set the tool group on the viewport + toolGroup.addViewport(viewportId, renderingEngineId); + + // Get the stack viewport that was created + const viewport = ( + renderingEngine.getViewport(viewportId) + ); + volume.load(); + + // Set volumes on the viewports + await setVolumesForViewports(renderingEngine, [{ volumeId }], [viewportId]); + + segmentation.segmentIndex.setActiveSegmentIndex(labelmapSegmentationId, 1); + // Sets the labelmap as the active segmentation - should really check the default + // tool to see which one to select, but for now this is ok + segmentation.activeSegmentation.setActiveSegmentationRepresentation( + toolGroupId, + segmentationRepresentationUIDs[0] + ); + + // Render the image + viewport.render(); + addAnnotationListeners(); +} + +run(); diff --git a/packages/tools/examples/videoTools/index.ts b/packages/tools/examples/videoTools/index.ts index a172e3d231..f7a60dcc96 100644 --- a/packages/tools/examples/videoTools/index.ts +++ b/packages/tools/examples/videoTools/index.ts @@ -24,22 +24,9 @@ console.warn( ); const { - LengthTool, - KeyImageTool, - ProbeTool, - RectangleROITool, - EllipticalROITool, - CircleROITool, - BidirectionalTool, - AngleTool, - CobbAngleTool, - ArrowAnnotateTool, - PlanarFreehandROITool, - LivewireContourTool, - - VideoRedactionTool, ToolGroupManager, Enums: csToolsEnums, + AnnotationTool, } = cornerstoneTools; const { ViewportType } = Enums; @@ -120,6 +107,9 @@ addButtonToToolbar({ onClick() { const annotation = getActiveAnnotation(); if (annotation) { + AnnotationTool.createAnnotationMemo(element, annotation, { + deleting: true, + }); cornerstoneTools.annotation.state.removeAnnotation( annotation.annotationUID ); diff --git a/packages/tools/package.json b/packages/tools/package.json index ae481a5670..1d8d09ee96 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -31,6 +31,7 @@ "dependencies": { "@cornerstonejs/core": "^1.66.12", "@icr/polyseg-wasm": "0.4.0", + "@itk-wasm/morphological-contour-interpolation": "1.0.1", "@types/offscreencanvas": "2019.7.3", "comlink": "^4.4.1", "lodash.clonedeep": "4.5.0", diff --git a/packages/tools/src/enums/ChangeTypes.ts b/packages/tools/src/enums/ChangeTypes.ts index 45271177d7..d88e11b1bd 100644 --- a/packages/tools/src/enums/ChangeTypes.ts +++ b/packages/tools/src/enums/ChangeTypes.ts @@ -31,6 +31,10 @@ enum ChangeTypes { * Occurs when an interpolation result is updated with more tool specific data. */ InterpolationUpdated = 'InterpolationUpdated', + /** + * Occurs when an annotation is changed do to an undo or redo. + */ + History = 'History', } export default ChangeTypes; diff --git a/packages/tools/src/enums/StrategyCallbacks.ts b/packages/tools/src/enums/StrategyCallbacks.ts index 7c13b83a42..d31b09448e 100644 --- a/packages/tools/src/enums/StrategyCallbacks.ts +++ b/packages/tools/src/enums/StrategyCallbacks.ts @@ -48,8 +48,19 @@ enum StrategyCallbacks { // Internal Details INTERNAL_setValue = 'setValue', + /** + * Interpolation between segments, for example, to fill in a set of segments + * between two slices some distance apart one can use the interpolate command. + * This will fill in segments on the intervening slices, interpolating between + * the two remote segments. + */ + Interpolate = 'interpolate', + /** inner circle size */ ComputeInnerCircleRadius = 'computeInnerCircleRadius', + + /** Compute statistics on this instance */ + GetStatistics = 'getStatistics', } export default StrategyCallbacks; diff --git a/packages/tools/src/stateManagement/annotation/annotationState.ts b/packages/tools/src/stateManagement/annotation/annotationState.ts index fe683d630a..87a2fb6faf 100644 --- a/packages/tools/src/stateManagement/annotation/annotationState.ts +++ b/packages/tools/src/stateManagement/annotation/annotationState.ts @@ -203,6 +203,11 @@ function getNumberOfAnnotations( /** * Remove the annotation by UID of the annotation. + * + * Note - the annotation state is NOT preserved here in the HistoryMemo state. + * If you wish to preserve the state, you must call the create annotation memo + * BEFORE removing the annotation, and pass the deleting: true flag to it. + * * @param annotationUID - The unique identifier for the annotation. */ function removeAnnotation(annotationUID: string): void { diff --git a/packages/tools/src/stateManagement/segmentation/triggerSegmentationEvents.ts b/packages/tools/src/stateManagement/segmentation/triggerSegmentationEvents.ts index 56cbd73306..2c9241f8cc 100644 --- a/packages/tools/src/stateManagement/segmentation/triggerSegmentationEvents.ts +++ b/packages/tools/src/stateManagement/segmentation/triggerSegmentationEvents.ts @@ -133,15 +133,19 @@ function triggerSegmentationModified(segmentationId?: string): void { /** * Trigger an event that a segmentation data has been modified - * @param segmentationId - The Id of segmentation + * @param segmentIndex - the primary segment index modified. This can + * be set to a value that the user is actively using - that doesn't + * mean other segments aren't touched, just that the specified one is primary. */ function triggerSegmentationDataModified( segmentationId: string, - modifiedSlicesToUse?: number[] + modifiedSlicesToUse?: number[], + segmentIndex?: number ): void { const eventDetail: SegmentationDataModifiedEventDetail = { segmentationId, modifiedSlicesToUse, + segmentIndex, }; // set it to dirty to force the next call to getUniqueSegmentIndices to diff --git a/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts b/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts index d947bd633a..136a3b35e0 100644 --- a/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts +++ b/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts @@ -41,7 +41,7 @@ export default function filterToolsWithAnnotationsForElement( ); } - if (annotations.length > 0) { + if (annotations?.length > 0) { result.push({ tool, annotations }); } } diff --git a/packages/tools/src/synchronizers/callbacks/voiSyncCallback.ts b/packages/tools/src/synchronizers/callbacks/voiSyncCallback.ts index 6b0e62fd7e..394f23eda7 100644 --- a/packages/tools/src/synchronizers/callbacks/voiSyncCallback.ts +++ b/packages/tools/src/synchronizers/callbacks/voiSyncCallback.ts @@ -60,4 +60,4 @@ export default function voiSyncCallback( } tViewport.render(); -} \ No newline at end of file +} diff --git a/packages/tools/src/tools/AnnotationEraserTool.ts b/packages/tools/src/tools/AnnotationEraserTool.ts index 5f4d56e05f..24da80ebee 100644 --- a/packages/tools/src/tools/AnnotationEraserTool.ts +++ b/packages/tools/src/tools/AnnotationEraserTool.ts @@ -1,8 +1,9 @@ -import { BaseTool } from './base'; +import { BaseTool, AnnotationTool } from './base'; import { EventTypes, PublicToolProps, ToolProps } from '../types'; import { ToolGroupManager } from '../store'; import { getAnnotations, + getAnnotation, removeAnnotation, } from '../stateManagement/annotation/annotationState'; import { setAnnotationSelected } from '../stateManagement/annotation/annotationSelection'; @@ -56,16 +57,16 @@ class AnnotationEraserTool extends BaseTool { const annotations = getAnnotations(toolName, element); - if (!annotations) { - continue; - } - const interactableAnnotations = toolInstance.filterInteractableAnnotationsForElement( element, annotations ); + if (!interactableAnnotations) { + continue; + } + for (const annotation of interactableAnnotations) { if ( toolInstance.isPointNearTool( @@ -83,6 +84,10 @@ class AnnotationEraserTool extends BaseTool { for (const annotationUID of annotationsToRemove) { setAnnotationSelected(annotationUID); + const annotation = getAnnotation(annotationUID); + AnnotationTool.createAnnotationMemo(element, annotation, { + deleting: true, + }); removeAnnotation(annotationUID); } diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index 7ae1d8a2e8..9649b11509 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -1,6 +1,6 @@ import { vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; -import { getEnabledElement, Types } from '@cornerstonejs/core'; +import { getEnabledElement, Types, utilities } from '@cornerstonejs/core'; import { BaseTool } from './base'; import { EventTypes, PublicToolProps, ToolProps } from '../types'; diff --git a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts index 372cf3a4f0..38e5483e1a 100644 --- a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts +++ b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts @@ -18,7 +18,6 @@ import { } from '../../drawingSvg'; import { state } from '../../store'; import { getViewportIdsWithToolToRender } from '../../utilities/viewportFilters'; -import { getTextBoxCoordsCanvas } from '../../utilities/drawing'; import triggerAnnotationRenderForViewportIds from '../../utilities/triggerAnnotationRenderForViewportIds'; import { triggerAnnotationCompleted, @@ -36,7 +35,6 @@ import { TextBoxHandle, PublicToolProps, ToolProps, - InteractionTypes, SVGDrawingHelper, } from '../../types'; import { ArrowAnnotation } from '../../types/ToolSpecificAnnotationTypes'; @@ -332,6 +330,9 @@ class ArrowAnnotateTool extends AnnotationTool { triggerAnnotationCompleted(annotation); + // This is only new if it wasn't already memoed + this.createMemo(element, annotation, { newAnnotation: !!this.memo }); + triggerAnnotationRenderForViewportIds( renderingEngine, viewportIdsToRender @@ -341,6 +342,7 @@ class ArrowAnnotateTool extends AnnotationTool { triggerAnnotationModified(annotation, element); } + this.doneEditMemo(); this.editData = null; this.isDrawing = false; }; @@ -350,8 +352,15 @@ class ArrowAnnotateTool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; if (movingTextBox) { diff --git a/packages/tools/src/tools/annotation/BidirectionalTool.ts b/packages/tools/src/tools/annotation/BidirectionalTool.ts index b7964a7aa9..264afc7255 100644 --- a/packages/tools/src/tools/annotation/BidirectionalTool.ts +++ b/packages/tools/src/tools/annotation/BidirectionalTool.ts @@ -401,6 +401,7 @@ class BidirectionalTool extends AnnotationTool { if (newAnnotation && !hasMoved) { return; } + this.doneEditMemo(); data.handles.activeHandleIndex = null; @@ -499,7 +500,10 @@ class BidirectionalTool extends AnnotationTool { const enabledElement = getEnabledElement(element); const { renderingEngine, viewport } = enabledElement; const { worldToCanvas } = viewport; - const { annotation, viewportIdsToRender, handleIndex } = this.editData; + const { annotation, viewportIdsToRender, handleIndex, newAnnotation } = + this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; const worldPos = currentPoints.world; @@ -582,8 +586,15 @@ class BidirectionalTool extends AnnotationTool { const { element } = eventDetail; const enabledElement = getEnabledElement(element); const { renderingEngine } = enabledElement; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; if (movingTextBox) { const { deltaPoints } = eventDetail; diff --git a/packages/tools/src/tools/annotation/CircleROITool.ts b/packages/tools/src/tools/annotation/CircleROITool.ts index efba98d01e..23ca2ef26e 100644 --- a/packages/tools/src/tools/annotation/CircleROITool.ts +++ b/packages/tools/src/tools/annotation/CircleROITool.ts @@ -374,6 +374,8 @@ class CircleROITool extends AnnotationTool { return; } + this.doneEditMemo(); + // Circle ROI tool should reset its highlight to false on mouse up (as opposed // to other tools that keep it highlighted until the user moves. The reason // is that we use top-left and bottom-right handles to define the circle, @@ -416,7 +418,9 @@ class CircleROITool extends AnnotationTool { const { canvasToWorld } = viewport; ////// - const { annotation, viewportIdsToRender } = this.editData; + const { annotation, viewportIdsToRender, newAnnotation } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; data.handles.points = [ @@ -436,9 +440,15 @@ class CircleROITool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); if (movingTextBox) { const { deltaPoints } = eventDetail; @@ -965,7 +975,7 @@ class CircleROITool extends AnnotationTool { modalityUnitOptions ); - const pointsInShape = pointInShapeCallback( + pointInShapeCallback( imageData, (pointLPS) => pointInEllipse(ellipseObj, pointLPS, { @@ -982,9 +992,9 @@ class CircleROITool extends AnnotationTool { area, mean: stats.mean?.value, max: stats.max?.value, + pointsInShape: stats.pointsInShape.points, stdDev: stats.stdDev?.value, statsArray: stats.array, - pointsInShape: pointsInShape, isEmptyArea, areaUnit: getCalibratedAreaUnits(null, image), radius: worldWidth / 2 / scale, diff --git a/packages/tools/src/tools/annotation/CobbAngleTool.ts b/packages/tools/src/tools/annotation/CobbAngleTool.ts index ef883973c6..ca13be834c 100644 --- a/packages/tools/src/tools/annotation/CobbAngleTool.ts +++ b/packages/tools/src/tools/annotation/CobbAngleTool.ts @@ -303,7 +303,7 @@ class CobbAngleTool extends AnnotationTool { evt.preventDefault(); } - _mouseUpCallback = ( + _endCallback = ( evt: EventTypes.MouseUpEventType | EventTypes.MouseClickEventType ) => { const eventDetail = evt.detail; @@ -319,6 +319,8 @@ class CobbAngleTool extends AnnotationTool { return; } + this.doneEditMemo(); + // If preventing new measurement means we are in the middle of an existing measurement // we shouldn't deactivate modify or draw if (this.angleStartedNotYetCompleted && data.handles.points.length < 4) { @@ -402,7 +404,7 @@ class CobbAngleTool extends AnnotationTool { this.editData.handleIndex = data.handles.points.length - 1; }; - _mouseDragCallback = ( + _dragCallback = ( evt: EventTypes.MouseDragEventType | EventTypes.MouseMoveEventType ) => { this.isDrawing = true; @@ -416,8 +418,10 @@ class CobbAngleTool extends AnnotationTool { movingTextBox, isNearFirstLine, isNearSecondLine, + newAnnotation, } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); if (movingTextBox) { // Drag mode - moving text box @@ -517,15 +521,15 @@ class CobbAngleTool extends AnnotationTool { element.addEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.addEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.addEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); // element.addEventListener(Events.TOUCH_END, this._mouseUpCallback) @@ -537,15 +541,15 @@ class CobbAngleTool extends AnnotationTool { element.removeEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.removeEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.removeEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); // element.removeEventListener(Events.TOUCH_END, this._mouseUpCallback) @@ -557,19 +561,19 @@ class CobbAngleTool extends AnnotationTool { element.addEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.addEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.addEventListener( Events.MOUSE_MOVE, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.addEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.addEventListener( Events.MOUSE_DOWN, @@ -585,19 +589,19 @@ class CobbAngleTool extends AnnotationTool { element.removeEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.removeEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.removeEventListener( Events.MOUSE_MOVE, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.removeEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.removeEventListener( Events.MOUSE_DOWN, diff --git a/packages/tools/src/tools/annotation/EllipticalROITool.ts b/packages/tools/src/tools/annotation/EllipticalROITool.ts index 449e188075..f63299782d 100644 --- a/packages/tools/src/tools/annotation/EllipticalROITool.ts +++ b/packages/tools/src/tools/annotation/EllipticalROITool.ts @@ -432,6 +432,8 @@ class EllipticalROITool extends AnnotationTool { return; } + this.doneEditMemo(); + // Elliptical ROI tool should reset its highlight to false on mouse up (as opposed // to other tools that keep it highlighted until the user moves. The reason // is that we use top-left and bottom-right handles to define the ellipse, @@ -474,7 +476,10 @@ class EllipticalROITool extends AnnotationTool { const { canvasToWorld } = viewport; ////// - const { annotation, viewportIdsToRender, centerWorld } = this.editData; + const { annotation, viewportIdsToRender, centerWorld, newAnnotation } = + this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const centerCanvas = viewport.worldToCanvas(centerWorld as Types.Point3); const { data } = annotation; @@ -506,8 +511,15 @@ class EllipticalROITool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; if (movingTextBox) { @@ -1087,7 +1099,7 @@ class EllipticalROITool extends AnnotationTool { modalityUnitOptions ); - const pointsInShape = pointInShapeCallback( + pointInShapeCallback( imageData, (pointLPS) => pointInEllipse(ellipseObj, pointLPS, { fast: true }), this.configuration.statsCalculator.statsCallback, @@ -1101,9 +1113,9 @@ class EllipticalROITool extends AnnotationTool { area, mean: stats.mean?.value, max: stats.max?.value, + pointsInShape: stats.pointsInShape.points, stdDev: stats.stdDev?.value, statsArray: stats.array, - pointsInShape, isEmptyArea, areaUnit: getCalibratedAreaUnits(null, image), modalityUnit, diff --git a/packages/tools/src/tools/annotation/KeyImageTool.ts b/packages/tools/src/tools/annotation/KeyImageTool.ts index eb7391adce..0d9ab75687 100644 --- a/packages/tools/src/tools/annotation/KeyImageTool.ts +++ b/packages/tools/src/tools/annotation/KeyImageTool.ts @@ -123,6 +123,7 @@ class KeyImageTool extends AnnotationTool { viewportIdsToRender ); }); + this.createMemo(element, annotation, { newAnnotation: true }); return annotation; }; @@ -188,6 +189,7 @@ class KeyImageTool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; + this.doneEditMemo(); this._deactivateModify(element); resetElementCursor(element); }; @@ -220,6 +222,7 @@ class KeyImageTool extends AnnotationTool { } const annotation = clickedAnnotation as Annotation; + this.createMemo(element, annotation); this.configuration.changeTextCallback( clickedAnnotation, @@ -228,6 +231,8 @@ class KeyImageTool extends AnnotationTool { ); this.isDrawing = false; + // Need an extra done edit here because the double click doesn't call end + this.doneEditMemo(); // This double click was handled and the dialogue was displayed. // No need for any other listener to handle it too - stopImmediatePropagation diff --git a/packages/tools/src/tools/annotation/LengthTool.ts b/packages/tools/src/tools/annotation/LengthTool.ts index 0a05fa1197..eacf922832 100644 --- a/packages/tools/src/tools/annotation/LengthTool.ts +++ b/packages/tools/src/tools/annotation/LengthTool.ts @@ -106,6 +106,18 @@ class LengthTool extends AnnotationTool { configuration: { preventHandleOutsideImage: false, getTextLines: defaultGetTextLines, + actions: { + // TODO - bind globally - but here is actually pretty good as it + // is almost always active. + undo: { + method: 'undo', + bindings: [{ key: 'z' }], + }, + redo: { + method: 'redo', + bindings: [{ key: 'y' }], + }, + }, }, } ) { @@ -359,6 +371,7 @@ class LengthTool extends AnnotationTool { } triggerAnnotationRenderForViewportIds(renderingEngine, viewportIdsToRender); + this.doneEditMemo(); if (newAnnotation) { triggerAnnotationCompleted(annotation); @@ -373,10 +386,17 @@ class LengthTool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); + if (movingTextBox) { // Drag mode - moving text box const { deltaPoints } = eventDetail as EventTypes.MouseDragEventDetail; diff --git a/packages/tools/src/tools/annotation/LivewireContourTool.ts b/packages/tools/src/tools/annotation/LivewireContourTool.ts index a297ed9d9a..e1a06543d6 100644 --- a/packages/tools/src/tools/annotation/LivewireContourTool.ts +++ b/packages/tools/src/tools/annotation/LivewireContourTool.ts @@ -122,8 +122,8 @@ class LivewireContourTool extends ContourSegmentationBaseTool { }, actions: { - undo: { - method: 'undo', + cancelInProgress: { + method: 'cancelInProgress', bindings: [ { key: 'Escape', @@ -425,6 +425,8 @@ class LivewireContourTool extends ContourSegmentationBaseTool { } = this.editData; const { data } = annotation; + this.doneEditMemo(); + data.handles.activeHandleIndex = null; this._deactivateModify(element); @@ -496,8 +498,13 @@ class LivewireContourTool extends ContourSegmentationBaseTool { private _mouseDownCallback = (evt: EventTypes.InteractionEventType): void => { const doubleClick = evt.type === Events.MOUSE_DOUBLE_CLICK; - const { annotation, viewportIdsToRender, worldToSlice, sliceToWorld } = - this.editData; + const { + annotation, + viewportIdsToRender, + worldToSlice, + sliceToWorld, + newAnnotation, + } = this.editData; if (this.editData.closed) { return; @@ -513,6 +520,13 @@ class LivewireContourTool extends ContourSegmentationBaseTool { const controlPoints = this.editData.currentPath.getControlPoints(); let closePath = controlPoints.length >= 2 && doubleClick; + // There is a new point being added/changed, and we want that in a separate + // memo to allow undoing it, so need to call the done edit an extra time here. + this.doneEditMemo(); + this.createMemo(element, annotation, { + newAnnotation: newAnnotation && controlPoints.length === 1, + }); + // Check if user clicked on the first point to close the curve if (controlPoints.length >= 2) { const closestHandlePoint = { @@ -730,7 +744,9 @@ class LivewireContourTool extends ContourSegmentationBaseTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex } = this.editData; + const { annotation, viewportIdsToRender, handleIndex, newAnnotation } = + this.editData; + this.createMemo(element, annotation, { newAnnotation }); if (handleIndex === undefined) { // Drag mode - moving object console.warn('No drag implemented for livewire'); @@ -769,7 +785,7 @@ class LivewireContourTool extends ContourSegmentationBaseTool { triggerAnnotationRenderForViewportIds(renderingEngine, viewportIdsToRender); - this.editData = null; + this.doneEditMemo(); this.scissors = null; return annotation.annotationUID; }; @@ -863,9 +879,9 @@ class LivewireContourTool extends ContourSegmentationBaseTool { * Eventually this is to be replaced with a proper undo, once that framework * is available. */ - public undo(element, config, evt) { + public cancelInProgress(element, config, evt) { if (!this.editData) { - // TODO - proper undo + this.undo(); return; } this._endCallback(evt, true); diff --git a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts index 743a92b45f..cb24fff8ab 100644 --- a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts +++ b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts @@ -777,9 +777,9 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool { let curRow = 0; let intersections = []; let intersectionCounter = 0; - const pointsInShape = pointInShapeCallback( + pointInShapeCallback( imageData, - (pointLPS, pointIJK) => { + (pointLPS) => { let result = true; const point = viewport.worldToCanvas(pointLPS); if (point[1] != curRow) { @@ -839,7 +839,8 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool { max: stats.max?.value, stdDev: stats.stdDev?.value, statsArray: stats.array, - pointsInShape: pointsInShape, + pointsInShape: stats.pointsInShape.points, + areaUnit: getCalibratedAreaUnits(null, image), modalityUnit, }; diff --git a/packages/tools/src/tools/annotation/ProbeTool.ts b/packages/tools/src/tools/annotation/ProbeTool.ts index 3025733169..c5aed02789 100644 --- a/packages/tools/src/tools/annotation/ProbeTool.ts +++ b/packages/tools/src/tools/annotation/ProbeTool.ts @@ -43,10 +43,7 @@ import { } from '../../types'; import { ProbeAnnotation } from '../../types/ToolSpecificAnnotationTypes'; import { StyleSpecifier } from '../../types/AnnotationStyle'; -import { - ModalityUnitOptions, - getModalityUnit, -} from '../../utilities/getModalityUnit'; +import { getModalityUnit } from '../../utilities/getModalityUnit'; import { isViewportPreScaled } from '../../utilities/viewport/isViewportPreScaled'; const { transformWorldToIndex } = csUtils; @@ -286,8 +283,13 @@ class ProbeTool extends AnnotationTool { resetElementCursor(element); + if (newAnnotation) { + this.createMemo(element, annotation, { newAnnotation }); + } + this.editData = null; this.isDrawing = false; + this.doneEditMemo(); if ( this.isHandleOutsideImage && @@ -309,9 +311,11 @@ class ProbeTool extends AnnotationTool { const { currentPoints, element } = eventDetail; const worldPos = currentPoints.world; - const { annotation, viewportIdsToRender } = this.editData; + const { annotation, viewportIdsToRender, newAnnotation } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); + data.handles.points[0] = [...worldPos]; annotation.invalidated = true; diff --git a/packages/tools/src/tools/annotation/RectangleROITool.ts b/packages/tools/src/tools/annotation/RectangleROITool.ts index a74874039c..cdc8aacb9d 100644 --- a/packages/tools/src/tools/annotation/RectangleROITool.ts +++ b/packages/tools/src/tools/annotation/RectangleROITool.ts @@ -366,6 +366,7 @@ class RectangleROITool extends AnnotationTool { this._deactivateDraw(element); resetElementCursor(element); + this.doneEditMemo(); const { renderingEngine } = getEnabledElement(element); @@ -392,10 +393,17 @@ class RectangleROITool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); + if (movingTextBox) { // Drag mode - Move the text boxes world position const { deltaPoints } = eventDetail as EventTypes.MouseDragEventDetail; @@ -878,8 +886,6 @@ class RectangleROITool extends AnnotationTool { } const { dimensions, imageData, metadata } = image; - const scalarData = - 'getScalarData' in image ? image.getScalarData() : image.scalarData; const worldPos1Index = transformWorldToIndex(imageData, worldPos1); @@ -942,7 +948,7 @@ class RectangleROITool extends AnnotationTool { modalityUnitOptions ); - const pointsInShape = pointInShapeCallback( + pointInShapeCallback( imageData, () => true, this.configuration.statsCalculator.statsCallback, @@ -958,7 +964,8 @@ class RectangleROITool extends AnnotationTool { stdDev: stats.stdDev?.value, max: stats.max?.value, statsArray: stats.array, - pointsInShape: pointsInShape, + pointsInShape: stats.pointsInShape.points, + areaUnit: getCalibratedAreaUnits(null, image), modalityUnit, }; diff --git a/packages/tools/src/tools/annotation/SplineROITool.ts b/packages/tools/src/tools/annotation/SplineROITool.ts index e74e95eef9..f8f67a27d6 100644 --- a/packages/tools/src/tools/annotation/SplineROITool.ts +++ b/packages/tools/src/tools/annotation/SplineROITool.ts @@ -375,6 +375,7 @@ class SplineROITool extends ContourSegmentationBaseTool { triggerAnnotationRenderForViewportIds(renderingEngine, viewportIdsToRender); + this.doneEditMemo(); this.editData = null; this.isDrawing = false; }; @@ -434,6 +435,10 @@ class SplineROITool extends ContourSegmentationBaseTool { return; } + // Ensure new changes are captured in a new memo - otherwise some types of + // changes get merged when an endCallback is missed. + this.doneEditMemo(); + const eventDetail = evt.detail; const { element } = eventDetail; const { currentPoints } = eventDetail; @@ -477,10 +482,17 @@ class SplineROITool extends ContourSegmentationBaseTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); + if (movingTextBox) { // Drag mode - moving text box const { deltaPoints } = eventDetail as EventTypes.MouseDragEventDetail; diff --git a/packages/tools/src/tools/annotation/VideoRedactionTool.ts b/packages/tools/src/tools/annotation/VideoRedactionTool.ts index 24e2a804db..9463a7b15c 100644 --- a/packages/tools/src/tools/annotation/VideoRedactionTool.ts +++ b/packages/tools/src/tools/annotation/VideoRedactionTool.ts @@ -270,7 +270,7 @@ class VideoRedactionTool extends AnnotationTool { evt.preventDefault(); }; - _mouseUpCallback = (evt) => { + _endCallback = (evt) => { const eventData = evt.detail; const { element } = eventData; @@ -281,6 +281,7 @@ class VideoRedactionTool extends AnnotationTool { if (newAnnotation && !hasMoved) { return; } + this.doneEditMemo(); data.active = false; data.handles.activeHandleIndex = null; @@ -315,7 +316,9 @@ class VideoRedactionTool extends AnnotationTool { const eventData = evt.detail; const { element } = eventData; - const { annotation, viewportUIDsToRender, handleIndex } = this.editData; + const { annotation, viewportUIDsToRender, handleIndex, newAnnotation } = + this.editData; + this.createMemo(element, annotation, { newAnnotation }); const { data } = annotation; if (handleIndex === undefined) { @@ -442,12 +445,12 @@ class VideoRedactionTool extends AnnotationTool { _activateDraw = (element) => { state.isInteractingWithTool = true; - element.addEventListener(Events.MOUSE_UP, this._mouseUpCallback); + element.addEventListener(Events.MOUSE_UP, this._endCallback); element.addEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); element.addEventListener(Events.MOUSE_MOVE, this._mouseDragCallback); - element.addEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.addEventListener(Events.MOUSE_CLICK, this._endCallback); - element.addEventListener(Events.TOUCH_END, this._mouseUpCallback); + element.addEventListener(Events.TOUCH_END, this._endCallback); element.addEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); }; @@ -457,12 +460,12 @@ class VideoRedactionTool extends AnnotationTool { _deactivateDraw = (element) => { state.isInteractingWithTool = false; - element.removeEventListener(Events.MOUSE_UP, this._mouseUpCallback); + element.removeEventListener(Events.MOUSE_UP, this._endCallback); element.removeEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); element.removeEventListener(Events.MOUSE_MOVE, this._mouseDragCallback); - element.removeEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.removeEventListener(Events.MOUSE_CLICK, this._endCallback); - element.removeEventListener(Events.TOUCH_END, this._mouseUpCallback); + element.removeEventListener(Events.TOUCH_END, this._endCallback); element.removeEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); }; @@ -472,11 +475,11 @@ class VideoRedactionTool extends AnnotationTool { _activateModify = (element) => { state.isInteractingWithTool = true; - element.addEventListener(Events.MOUSE_UP, this._mouseUpCallback); + element.addEventListener(Events.MOUSE_UP, this._endCallback); element.addEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); - element.addEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.addEventListener(Events.MOUSE_CLICK, this._endCallback); - element.addEventListener(Events.TOUCH_END, this._mouseUpCallback); + element.addEventListener(Events.TOUCH_END, this._endCallback); element.addEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); }; @@ -486,11 +489,11 @@ class VideoRedactionTool extends AnnotationTool { _deactivateModify = (element) => { state.isInteractingWithTool = false; - element.removeEventListener(Events.MOUSE_UP, this._mouseUpCallback); + element.removeEventListener(Events.MOUSE_UP, this._endCallback); element.removeEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); - element.removeEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.removeEventListener(Events.MOUSE_CLICK, this._endCallback); - element.removeEventListener(Events.TOUCH_END, this._mouseUpCallback); + element.removeEventListener(Events.TOUCH_END, this._endCallback); element.removeEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); }; diff --git a/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts b/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts index 8ae336f275..50aadb0096 100644 --- a/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts +++ b/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts @@ -18,6 +18,7 @@ import { import triggerAnnotationRenderForViewportIds from '../../../utilities/triggerAnnotationRenderForViewportIds'; import updateContourPolyline from '../../../utilities/contours/updateContourPolyline'; import { triggerAnnotationModified } from '../../../stateManagement/annotation/helpers/state'; +import { AnnotationTool } from '../../base'; const { getSubPixelSpacingAndXYDirections, addCanvasPointsToArray, getArea } = polyline; @@ -56,6 +57,7 @@ function activateClosedContourEdit( editCanvasPoints: [canvasPos], startCrossingIndex: undefined, editIndex: 0, + annotation, }; this.commonData = { @@ -149,7 +151,9 @@ function mouseDragClosedContourEditCallback( const { renderingEngine, viewport } = enabledElement; const { viewportIdsToRender, xDir, yDir, spacing } = this.commonData; - const { editIndex, editCanvasPoints, startCrossingIndex } = this.editData; + const { editIndex, editCanvasPoints, startCrossingIndex, annotation } = + this.editData; + this.createMemo(element, annotation); const lastCanvasPoint = editCanvasPoints[editCanvasPoints.length - 1]; const lastWorldPoint = viewport.canvasToWorld(lastCanvasPoint); @@ -250,6 +254,7 @@ function finishEditAndStartNewEdit(evt: EventTypes.InteractionEventType): void { startCrossingIndex: undefined, editIndex: 0, snapIndex: undefined, + annotation, }; triggerAnnotationRenderForViewportIds(renderingEngine, viewportIdsToRender); @@ -441,6 +446,7 @@ function completeClosedContourEdit(element: HTMLDivElement) { const { viewport, renderingEngine } = enabledElement; const { annotation, viewportIdsToRender } = this.commonData; + this.doneEditMemo(); const { fusedCanvasPoints, prevCanvasPoints } = this.editData; if (fusedCanvasPoints) { diff --git a/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts b/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts index 04900f6507..4ee7b423ba 100644 --- a/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts +++ b/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts @@ -55,6 +55,7 @@ function activateDraw( canvasPoints: [canvasPos], polylineIndex: 0, contourHoleProcessingEnabled, + newAnnotation: true, }; this.commonData = { @@ -113,7 +114,8 @@ function mouseDragDrawCallback(evt: EventTypes.InteractionEventType): void { spacing, movingTextBox, } = this.commonData; - const { polylineIndex, canvasPoints } = this.drawData; + const { polylineIndex, canvasPoints, newAnnotation } = this.drawData; + this.createMemo(element, annotation, { newAnnotation }); const lastCanvasPoint = canvasPoints[canvasPoints.length - 1]; const lastWorldPoint = viewport.canvasToWorld(lastCanvasPoint); @@ -181,6 +183,8 @@ function mouseUpDrawCallback(evt: EventTypes.InteractionEventType): void { const lastPoint = canvasPoints[canvasPoints.length - 1]; const eventDetail = evt.detail; const { element } = eventDetail; + this.doneEditMemo(); + this.drawData.newAnnotation = false; if ( allowOpenContours && diff --git a/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts b/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts index 0d20855cf3..3de0c4ca76 100644 --- a/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts +++ b/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts @@ -37,6 +37,8 @@ function activateOpenContourEdit( const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; + this.doneEditMemo(); + const prevCanvasPoints = annotation.data.contour.polyline.map( viewport.worldToCanvas ); @@ -149,6 +151,8 @@ function mouseDragOpenContourEditCallback( const worldPosDiff = vec3.create(); + this.createMemo(element, this.commonData.annotation); + vec3.subtract(worldPosDiff, worldPos, lastWorldPoint); const xDist = Math.abs(vec3.dot(worldPosDiff, xDir)); @@ -238,6 +242,7 @@ function openContourEditOverwriteEnd( this.isEditingOpen = false; this.editData = undefined; this.commonData = undefined; + this.doneEditMemo(); // Jump to a normal line edit now. this.deactivateOpenContourEdit(element); @@ -551,6 +556,7 @@ function completeOpenContourEdit(element: HTMLDivElement) { const { viewport, renderingEngine } = enabledElement; const { annotation, viewportIdsToRender } = this.commonData; + this.doneEditMemo(); const { fusedCanvasPoints, prevCanvasPoints } = this.editData; if (fusedCanvasPoints) { diff --git a/packages/tools/src/tools/base/AnnotationDisplayTool.ts b/packages/tools/src/tools/base/AnnotationDisplayTool.ts index 6a7633d9c2..79e69fd0df 100644 --- a/packages/tools/src/tools/base/AnnotationDisplayTool.ts +++ b/packages/tools/src/tools/base/AnnotationDisplayTool.ts @@ -59,9 +59,11 @@ abstract class AnnotationDisplayTool extends BaseTool { filterInteractableAnnotationsForElement( element: HTMLDivElement, annotations: Annotations - ): Annotations | undefined { + ): Annotations { if (!annotations || !annotations.length) { - return; + // Some tools don't check the return value, so return an empty array + // Which is in fact the correct value here. + return []; } const enabledElement = getEnabledElement(element); diff --git a/packages/tools/src/tools/base/AnnotationTool.ts b/packages/tools/src/tools/base/AnnotationTool.ts index 717dc7f3c9..c2d25bccc6 100644 --- a/packages/tools/src/tools/base/AnnotationTool.ts +++ b/packages/tools/src/tools/base/AnnotationTool.ts @@ -21,9 +21,20 @@ import { ToolProps, PublicToolProps, } from '../../types'; -import { addAnnotation } from '../../stateManagement/annotation/annotationState'; +import { + addAnnotation, + removeAnnotation, + getAnnotation, +} from '../../stateManagement/annotation/annotationState'; import { StyleSpecifier } from '../../types/AnnotationStyle'; import { triggerAnnotationModified } from '../../stateManagement/annotation/helpers/state'; +import ChangeTypes from '../../enums/ChangeTypes'; +import { setAnnotationSelected } from '../../stateManagement/annotation/annotationSelection'; +import { addContourSegmentationAnnotation } from '../../utilities/contourSegmentation'; +import type { ContourSegmentationAnnotation } from '../../types'; + +const { DefaultHistoryMemo } = csUtils.HistoryMemo; +const { PointsManager } = csUtils; /** * Abstract class for tools which create and display annotations on the @@ -453,6 +464,149 @@ abstract class AnnotationTool extends AnnotationDisplayTool { return true; } } + + /** + * Creates an annotation state copy to allow storing the current state of + * an annotation. This class has knowledge about the contour and spline + * implementations in order to copy the contour object efficiently, and to + * allow copying the spline object (which has member variables etc). + * + * @param annotation - the annotation to create a clone of + * @param deleting - a flag to indicate that this object is about to be deleted (deleting true), + * or was just created (deleting false), or neither (deleting undefined). + * @returns state information for the given annotation. + */ + protected static createAnnotationState( + annotation: Annotation, + deleting?: boolean + ) { + const { data, annotationUID } = annotation; + const cloneData: any = { + ...data, + cachedStats: {}, + }; + delete cloneData.contour; + delete cloneData.spline; + const state = { + annotationUID, + data: structuredClone(cloneData), + deleting, + }; + const { contour } = data; + if (contour) { + state.data.contour = { + ...contour, + polyline: null, + pointsManager: PointsManager.create3( + contour.polyline.length, + contour.polyline + ), + }; + } + + return state; + } + + /** + * Creates an annotation memo storing the current data state on the given + * annotation object. This will store/recover handles data, text box and contour + * data, and if the options are set for deletion, will apply that correctly. + * + * @param element - that the annotation is shown on. + * @param annotation - to store a memo for the current state. + * @param options - whether the annotation is being created (newAnnotation) or + * is in the process of being deleted (`deleting`) + * * Note the naming on deleting is to indicate the deletion is in progress, + * as the createAnnotationMemo needs to be called BEFORE the annotation + * is actually deleted. + * * deleting with a value of false is the same as newAnnotation=true, + * as it is simply the opposite direction. Use undefined for both + * newAnnotation and deleting for non-create/delete operations. + * @returns Memo containing the annotation data. + */ + public static createAnnotationMemo( + element, + annotation: Annotation, + options?: { newAnnotation?: boolean; deleting?: boolean } + ) { + if (!annotation) { + return; + } + const { newAnnotation, deleting = newAnnotation ? false : undefined } = + options || {}; + const { annotationUID } = annotation; + const state = AnnotationTool.createAnnotationState(annotation, deleting); + + const annotationMemo = { + restoreMemo: () => { + const newState = AnnotationTool.createAnnotationState( + annotation, + deleting + ); + if (state.deleting === true) { + // Handle undeletion - 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); + if (annotation.data.contour) { + annotation.data.contour.polyline = + state.data.contour.pointsManager.points; + delete annotation.data.contour.pointsManager; + if (annotation.data.segmentation) { + addContourSegmentationAnnotation( + annotation as ContourSegmentationAnnotation + ); + } + } + state.data = newState.data; + addAnnotation(annotation, element); + setAnnotationSelected(annotation.annotationUID, true); + getEnabledElement(element)?.viewport.render(); + return; + } + if (state.deleting === false) { + // Handle deletion (undo of creation) + state.deleting = true; + // Use the current state as the restore state. + state.data = newState.data; + setAnnotationSelected(annotation.annotationUID); + removeAnnotation(annotation.annotationUID); + getEnabledElement(element)?.viewport.render(); + return; + } + const currentAnnotation = getAnnotation(annotationUID); + if (!currentAnnotation) { + console.warn('No current annotation'); + return; + } + Object.assign(currentAnnotation.data, state.data); + if (currentAnnotation.data.contour) { + currentAnnotation.data.contour.polyline = + state.data.contour.pointsManager.points; + } + state.data = newState.data; + currentAnnotation.invalidated = true; + triggerAnnotationModified( + currentAnnotation, + element, + ChangeTypes.History + ); + }, + }; + DefaultHistoryMemo.push(annotationMemo); + return annotationMemo; + } + + /** + * Creates a memo on the given annotation. + */ + protected createMemo(element, annotation, options?) { + this.memo ||= AnnotationTool.createAnnotationMemo( + element, + annotation, + options + ); + } } AnnotationTool.toolName = 'AnnotationTool'; diff --git a/packages/tools/src/tools/base/BaseTool.ts b/packages/tools/src/tools/base/BaseTool.ts index 5206162489..358757fc97 100644 --- a/packages/tools/src/tools/base/BaseTool.ts +++ b/packages/tools/src/tools/base/BaseTool.ts @@ -4,6 +4,8 @@ import ToolModes from '../../enums/ToolModes'; import StrategyCallbacks from '../../enums/StrategyCallbacks'; import { InteractionTypes, ToolProps, PublicToolProps } from '../../types'; +const { DefaultHistoryMemo } = utilities.HistoryMemo; + export interface IBaseTool { /** ToolGroup ID the tool instance belongs to */ toolGroupId: string; @@ -35,6 +37,11 @@ abstract class BaseTool implements IBaseTool { public toolGroupId: string; /** Tool Mode - Active/Passive/Enabled/Disabled/ */ public mode: ToolModes; + /** + * A memo recording the starting state of a tool. This will be updated + * as changes are made, and reflects the fact that a memo has been created. + */ + protected memo: utilities.HistoryMemo.Memo; constructor(toolProps: PublicToolProps, defaultToolProps: ToolProps) { const initialProps = utilities.deepMerge(defaultToolProps, toolProps); @@ -101,8 +108,9 @@ abstract class BaseTool implements IBaseTool { public applyActiveStrategyCallback( enabledElement: Types.IEnabledElement, operationData: unknown, - callbackType: StrategyCallbacks | string - ): any { + callbackType: StrategyCallbacks | string, + ...extraArgs + ) { const { strategies, activeStrategy } = this.configuration; if (!strategies[activeStrategy]) { @@ -114,7 +122,8 @@ abstract class BaseTool implements IBaseTool { return strategies[activeStrategy][callbackType]?.call( this, enabledElement, - operationData + operationData, + ...extraArgs ); } @@ -259,6 +268,71 @@ abstract class BaseTool implements IBaseTool { } throw new Error('getTargetId: viewport must have a getTargetId method'); } + + /** + * Undoes an action + */ + public undo() { + // It is possible a user has started another action here, so ensure that one + // gets completed/stored correctly. Normally this only occurs if the user + // starts an undo while dragging. + this.doneEditMemo(); + DefaultHistoryMemo.undo(); + } + + /** + * Redo an action (undo the undo) + */ + public redo() { + DefaultHistoryMemo.redo(); + } + + /** + * Creates a zoom/pan memo that remembers the original zoom/pan position for + * the given viewport. + */ + public static createZoomPanMemo(viewport) { + // TODO - move this to view callback as a utility + const state = { + pan: viewport.getPan(), + zoom: viewport.getZoom(), + }; + const zoomPanMemo = { + restoreMemo: () => { + const currentPan = viewport.getPan(); + const currentZoom = viewport.getZoom(); + viewport.setZoom(state.zoom); + viewport.setPan(state.pan); + viewport.render(); + state.pan = currentPan; + state.zoom = currentZoom; + }, + }; + DefaultHistoryMemo.push(zoomPanMemo); + return zoomPanMemo; + } + + /** + * This clears and edit memo storage to allow for further history functions + * to be called. Calls the complete function if present, and pushes the + * memo to the history memo stack. + * + * This should be called when a tool has finished making a change which should be + * separated from future/other changes in terms of the history. + * Usually that means on endCallback (mouse up), but some tools also make changes + * on the initial creation of an object or have alternate flows and the doneEditMemo + * has to be called on mouse down or other initiation events to ensure that new + * changes are correctly recorded. + * + * If the tool has no end callback, then the doneEditMemo is called from the + * pre mouse down callback. See ZoomTool for an example of this usage. + */ + public doneEditMemo() { + if (this.memo?.commitMemo?.()) { + DefaultHistoryMemo.push(this.memo); + } + this.memo = null; + } } // Note: this is a workaround since terser plugin does not support static blocks diff --git a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts index 733e416da3..9674280eb4 100644 --- a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts +++ b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts @@ -307,10 +307,7 @@ abstract class ContourSegmentationBaseTool extends ContourBaseTool { return; } - if ( - segmentationState.getSegmentationRepresentations(this.toolGroupId) - .length > 1 - ) { + if (validSegmentationRepresentations.length > 1) { console.warn( 'Multiple segmentation representations detected for this tool group. The first one will be used.' ); diff --git a/packages/tools/src/tools/segmentation/BrushTool.ts b/packages/tools/src/tools/segmentation/BrushTool.ts index 2679a96f76..b846d60e93 100644 --- a/packages/tools/src/tools/segmentation/BrushTool.ts +++ b/packages/tools/src/tools/segmentation/BrushTool.ts @@ -8,10 +8,10 @@ import type { EventTypes, SVGDrawingHelper, } from '../../types'; -import { BaseTool } from '../base'; import { fillInsideSphere, thresholdInsideSphere, + thresholdInsideSphereIsland, } from './strategies/fillSphere'; import { eraseInsideSphere } from './strategies/eraseSphere'; import { @@ -44,27 +44,12 @@ import { LabelmapSegmentationDataStack, } from '../../types/LabelmapTypes'; import { isVolumeSegmentation } from './strategies/utils/stackVolumeCheck'; - -/** - * A type for preview data/information, used to setup previews on hover, or - * maintain the preview information. - */ -export type PreviewData = { - /** - * The preview data returned from the strategy - */ - preview: unknown; - timer?: number; - timerStart: number; - startPoint: Types.Point2; - element: HTMLDivElement; - isDrag: boolean; -}; +import LabelmapBaseTool from './LabelmapBaseTool'; /** * @public */ -class BrushTool extends BaseTool { +class BrushTool extends LabelmapBaseTool { static toolName; private _editData: { segmentsLocked: number[]; // @@ -83,28 +68,43 @@ class BrushTool extends BaseTool { centerCanvas?: Array; }; - private _previewData?: PreviewData = { - preview: null, - element: null, - timerStart: 0, - timer: null, - startPoint: [NaN, NaN], - isDrag: false, - }; - constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { strategies: { + /** Perform fill of the active segment index inside a (2d) circle */ FILL_INSIDE_CIRCLE: fillInsideCircle, + /** Erase (to 0) inside a circle */ ERASE_INSIDE_CIRCLE: eraseInsideCircle, + /** Fill a 3d sphere with the active segment index */ FILL_INSIDE_SPHERE: fillInsideSphere, + /** Erase inside a 3d sphere, clearing any segment index (to 0) */ ERASE_INSIDE_SPHERE: eraseInsideSphere, + /** + * Threshold inside a circle, either with a dynamic threshold value + * based on the voxels in a 2d plane around the center click. + * Performs island removal. + */ THRESHOLD_INSIDE_CIRCLE: thresholdInsideCircle, + /** + * Threshold inside a sphere, either dynamic or pre-configured. + * For dynamic, base the threshold on a 2d CIRCLE around the center click. + * Do not perform island removal (this may be slow) + * Users may see delays dragging the sphere for large radius values and + * for complex mixtures of texture. + */ THRESHOLD_INSIDE_SPHERE: thresholdInsideSphere, + /** + * Threshold inside a sphere, but also include island removal. + * The current implementation of this is fairly fast now, but users may + * see delays when island removal occurs on large sections of the volume. + */ + THRESHOLD_INSIDE_SPHERE_WITH_ISLAND_REMOVAL: + thresholdInsideSphereIsland, }, + strategySpecificConfiguration: { THRESHOLD: { threshold: [-150, -70], // E.g. CT Fat // Only used during threshold strategies. @@ -136,13 +136,47 @@ class BrushTool extends BaseTool { }, ], }, - [StrategyCallbacks.RejectPreview]: { - method: StrategyCallbacks.RejectPreview, + /** + * Pressing the i key will interpolate between labelmap slices, + * without distance or extent extrapolation. This has to be used + * on overlapping segments. That is, for two slices (axial, sagital or coronal), + * with intervening slices, where the segments occupy the same area + * of the image, an interpolation between the two segments will + * created on intervening slices. + */ + [StrategyCallbacks.Interpolate]: { + method: StrategyCallbacks.Interpolate, + bindings: [ + { + key: 'i', + }, + ], + configuration: { + useBallStructuringElement: true, + // noHeuristicAlignment: true, + noUseDistanceTransform: true, + noUseExtrapolation: true, + }, + }, + /** + * Pressing the e key will interpolate with the full set of defaults, + * that includes some distance/extent extrapolation, so can interpolate + * between SLIGHTLY non-overlapping labelmaps. That is, if you have + * segments on two slices, and the slices occupy almost the same + * are on the two different slices, then the slices in between will + * be interpolated. If the position is quite different, it may create + * an interpolation, but it won't be a very useful one as it will be + * two separate interpolations going to an empty space. + */ + interpolateExtrapolation: { + method: StrategyCallbacks.Interpolate, bindings: [ { - key: 'Escape', + key: 'e', }, ], + // Morphological interpolation config + configuration: {}, }, }, }, @@ -508,9 +542,10 @@ class BrushTool extends BaseTool { ...editData, points: data?.handles?.points, segmentIndex, - previewColors: this.configuration.preview.enabled - ? this.configuration.preview.previewColors - : null, + previewColors: + this.configuration.preview?.enabled || this._previewData.preview + ? this.configuration.preview.previewColors + : null, viewPlaneNormal, toolGroupId: this.toolGroupId, segmentationId, @@ -520,6 +555,8 @@ class BrushTool extends BaseTool { this.configuration.strategySpecificConfiguration, // Provide the preview information so that data can be used directly preview: this._previewData?.preview, + configuration: this.configuration, + createMemo: this.createMemo.bind(this), }; return operationData; } @@ -615,6 +652,8 @@ class BrushTool extends BaseTool { this.applyActiveStrategy(enabledElement, operationData); } + this.doneEditMemo(); + this._deactivateDraw(element); resetElementCursor(element); @@ -634,6 +673,21 @@ class BrushTool extends BaseTool { } }; + public getStatistics(element, segmentIndices?) { + if (!element) { + return; + } + const enabledElement = getEnabledElement(element); + const stats = this.applyActiveStrategyCallback( + enabledElement, + this.getOperationData(element), + StrategyCallbacks.GetStatistics, + segmentIndices + ); + + return stats; + } + /** * Cancels any preview view being shown, resetting any segments being shown. */ @@ -655,9 +709,10 @@ class BrushTool extends BaseTool { * Accepts a preview, marking it as the active segment. */ public acceptPreview(element = this._previewData.element) { - if (!element) { + if (!element || !this._previewData?.preview) { return; } + this.doneEditMemo(); const enabledElement = getEnabledElement(element); this.applyActiveStrategyCallback( @@ -667,6 +722,26 @@ class BrushTool extends BaseTool { ); this._previewData.isDrag = false; this._previewData.preview = null; + // Store the edit memo too + this.doneEditMemo(); + } + + /** + * Performs interpolation on the active segment index + */ + public interpolate(element, config) { + if (!element) { + return; + } + const enabledElement = getEnabledElement(element); + + this._previewData.preview = this.applyActiveStrategyCallback( + enabledElement, + this.getOperationData(element), + StrategyCallbacks.Interpolate, + config.configuration + ); + this._previewData.isDrag = true; } /** diff --git a/packages/tools/src/tools/segmentation/CircleROIStartEndThresholdTool.ts b/packages/tools/src/tools/segmentation/CircleROIStartEndThresholdTool.ts index 5760e25857..aa80feeb54 100644 --- a/packages/tools/src/tools/segmentation/CircleROIStartEndThresholdTool.ts +++ b/packages/tools/src/tools/segmentation/CircleROIStartEndThresholdTool.ts @@ -539,16 +539,16 @@ class CircleROIStartEndThresholdTool extends CircleROITool { zRadius: Math.abs(topLeftWorld[2] - bottomRightWorld[2]) / 2, }; - const pointsInShape = pointInShapeCallback( + const points = []; + + pointInShapeCallback( imageData, //@ts-ignore (pointLPS) => pointInEllipse(ellipseObj, pointLPS), - null, + ({ pointLPS }) => points.push(pointLPS.slice()), boundsIJK ); - - //@ts-ignore - pointsInsideVolume.push(pointsInShape); + pointsInsideVolume.push(points); } } data.cachedStats.pointsInVolume = pointsInsideVolume; diff --git a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts index 25c0f25101..e92f1b0f78 100644 --- a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts @@ -1,7 +1,6 @@ import { cache, getEnabledElement } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; -import { BaseTool } from '../base'; import { PublicToolProps, ToolProps, @@ -32,6 +31,8 @@ import { LabelmapSegmentationDataVolume, } from '../../types/LabelmapTypes'; import { isVolumeSegmentation } from './strategies/utils/stackVolumeCheck'; +import LabelmapBaseTool from './LabelmapBaseTool'; +import type { LabelmapMemo } from '../../utilities/segmentation/createLabelmapMemo'; /** * Tool for manipulating segmentation data by drawing a circle. It acts on the @@ -40,15 +41,16 @@ import { isVolumeSegmentation } from './strategies/utils/stackVolumeCheck'; * for the segmentation to modify. You can use SegmentationModule to set the active * segmentation and segmentIndex. */ -class CircleScissorsTool extends BaseTool { +class CircleScissorsTool extends LabelmapBaseTool { static toolName; editData: { annotation: any; segmentIndex: number; // - volumeId: string; - referencedVolumeId: string; - imageIdReferenceMap: Map; + volumeId?: string; + segmentationId?: string; + referencedVolumeId?: string; + imageIdReferenceMap?: Map; // segmentsLocked: number[]; segmentColor: [number, number, number, number]; @@ -59,6 +61,7 @@ class CircleScissorsTool extends BaseTool { hasMoved?: boolean; centerCanvas?: Array; segmentationRepresentationUID?: string; + memo?: LabelmapMemo; } | null; isDrawing: boolean; isHandleOutsideImage: boolean; @@ -167,8 +170,8 @@ class CircleScissorsTool extends BaseTool { this.editData = { annotation, - centerCanvas: canvasPos, segmentIndex, + centerCanvas: canvasPos, segmentationId, segmentsLocked, segmentColor, @@ -178,7 +181,7 @@ class CircleScissorsTool extends BaseTool { newAnnotation: true, hasMoved: false, segmentationRepresentationUID, - } as any; + }; if ( isVolumeSegmentation(labelmapData as LabelmapSegmentationData, viewport) @@ -271,7 +274,6 @@ class CircleScissorsTool extends BaseTool { if (newAnnotation && !hasMoved) { return; } - data.handles.activeHandleIndex = null; this._deactivateDraw(element); @@ -286,12 +288,15 @@ class CircleScissorsTool extends BaseTool { viewPlaneNormal, viewUp, strategySpecificConfiguration: {}, + createMemo: this.createMemo.bind(this), }; this.editData = null; this.isDrawing = false; this.applyActiveStrategy(enabledElement, operationData); + + this.doneEditMemo(); }; /** diff --git a/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts new file mode 100644 index 0000000000..bf90d6cb05 --- /dev/null +++ b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts @@ -0,0 +1,64 @@ +import { utilities } from '@cornerstonejs/core'; +import type { Types } from '@cornerstonejs/core'; +import { LabelmapMemo } from '../../utilities/segmentation'; +import { BaseTool } from '../base'; + +const { DefaultHistoryMemo } = utilities.HistoryMemo; + +/** + * A type for preview data/information, used to setup previews on hover, or + * maintain the preview information. + */ +export type PreviewData = { + /** + * The preview data returned from the strategy + */ + preview: unknown; + /** A timer id to allow cancelling the timer */ + timer?: number; + /** The start time for the timer, to allow showing preview after a given length of time */ + timerStart: number; + /** + * The starting point where the use clicked down on, used to cancel preview + * on drag, but preserve it if the user moves the mouse tiny amounts accidentally. + */ + startPoint: Types.Point2; + element: HTMLDivElement; + /** + * Record if this is a drag preview, that is, a preview which is being extended + * by the user dragging to view more area. + */ + isDrag: boolean; +}; + +/** + * Labelmap tool containing shared functionality for labelmap tools. + */ +export default class LabelmapBaseTool extends BaseTool { + protected _previewData?: PreviewData = { + preview: null, + element: null, + timerStart: 0, + timer: null, + startPoint: [NaN, NaN], + isDrag: false, + }; + + constructor(toolProps, defaultToolProps) { + super(toolProps, defaultToolProps); + } + + /** + * Creates a labelmap memo instance, which is a partially created memo + * object that stores the changes made to the labelmap rather than the + * initial state. This memo is then committed once done so that the + */ + public createMemo(segmentId: string, segmentationVoxelManager, preview) { + this.memo ||= LabelmapMemo.createLabelmapMemo( + segmentId, + segmentationVoxelManager, + preview + ); + return this.memo as LabelmapMemo.LabelmapMemo; + } +} diff --git a/packages/tools/src/tools/segmentation/PaintFillTool.ts b/packages/tools/src/tools/segmentation/PaintFillTool.ts index 0ff18e526e..3d78f9e341 100644 --- a/packages/tools/src/tools/segmentation/PaintFillTool.ts +++ b/packages/tools/src/tools/segmentation/PaintFillTool.ts @@ -95,6 +95,8 @@ class PaintFillTool extends BaseTool { let scalarData: Types.PixelDataTypedArray; let index: Types.Point3; + this.doneEditMemo(); + if (isVolumeSegmentation(labelmapData, viewport)) { const { volumeId } = representationData[ type @@ -194,7 +196,8 @@ class PaintFillTool extends BaseTool { fixedDimensionValue: number, floodFillResult: FloodFillResult ): number[] => { - const { boundaries } = floodFillResult; + // TODO - call the boundary function as it proceeds + const { flooded: boundaries } = floodFillResult; if (fixedDimension === 2) { return [fixedDimensionValue]; diff --git a/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts b/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts index 3759b56650..beb50332a8 100644 --- a/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts +++ b/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts @@ -379,15 +379,15 @@ class RectangleROIStartEndThresholdTool extends RectangleROITool { [kMin, kMax], ] as [Types.Point2, Types.Point2, Types.Point2]; - const pointsInShape = pointInShapeCallback( + const points = []; + pointInShapeCallback( imageData, () => true, - null, + ({ pointLPS }) => points.push(pointLPS.slice()), boundsIJK ); - //@ts-ignore - pointsInsideVolume.push(pointsInShape); + pointsInsideVolume.push(points); } } data.cachedStats.pointsInVolume = pointsInsideVolume; diff --git a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts index 34fcd7a1e3..653c834481 100644 --- a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts @@ -1,7 +1,6 @@ -import { cache, getEnabledElement, StackViewport } from '@cornerstonejs/core'; +import { cache, getEnabledElement } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; -import { BaseTool } from '../base'; import { PublicToolProps, ToolProps, @@ -34,6 +33,7 @@ import { import { getSegmentation } from '../../stateManagement/segmentation/segmentationState'; import { LabelmapSegmentationData } from '../../types/LabelmapTypes'; import { isVolumeSegmentation } from './strategies/utils/stackVolumeCheck'; +import LabelmapBaseTool from './LabelmapBaseTool'; /** * Tool for manipulating segmentation data by drawing a rectangle. It acts on the @@ -42,7 +42,7 @@ import { isVolumeSegmentation } from './strategies/utils/stackVolumeCheck'; * for the segmentation to modify. You can use SegmentationModule to set the active * segmentation and segmentIndex. */ -class RectangleScissorsTool extends BaseTool { +class RectangleScissorsTool extends LabelmapBaseTool { static toolName; _throttledCalculateCachedStats: any; editData: { @@ -310,12 +310,16 @@ class RectangleScissorsTool extends BaseTool { const operationData = { ...this.editData, points: data.handles.points, + strategySpecificConfiguration: {}, + createMemo: this.createMemo.bind(this), }; this.editData = null; this.isDrawing = false; this.applyActiveStrategy(enabledElement, operationData); + + this.doneEditMemo(); }; /** diff --git a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts index 179968fc17..61891aab9d 100644 --- a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts @@ -33,6 +33,7 @@ import { LabelmapSegmentationDataStack, } from '../../types/LabelmapTypes'; import { isVolumeSegmentation } from './strategies/utils/stackVolumeCheck'; +import LabelmapBaseTool from './LabelmapBaseTool'; /** * Tool for manipulating segmentation data by drawing a sphere in 3d space. It acts on the * active Segmentation on the viewport (enabled element) and requires an active @@ -41,7 +42,7 @@ import { isVolumeSegmentation } from './strategies/utils/stackVolumeCheck'; * segmentation and segmentIndex. Todo: sphere scissor has some memory problem which * lead to ui blocking behavior that needs to be fixed. */ -class SphereScissorsTool extends BaseTool { +class SphereScissorsTool extends LabelmapBaseTool { static toolName; editData: { annotation: any; @@ -98,6 +99,7 @@ class SphereScissorsTool extends BaseTool { return; } + this.doneEditMemo(); const eventDetail = evt.detail; const { currentPoints, element } = eventDetail; const worldPos = currentPoints.world; @@ -290,12 +292,15 @@ class SphereScissorsTool extends BaseTool { segmentsLocked, viewPlaneNormal, viewUp, + createMemo: this.createMemo.bind(this), }; this.editData = null; this.isDrawing = false; this.applyActiveStrategy(enabledElement, operationData); + + this.doneEditMemo(); }; /** diff --git a/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts b/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts index 8d2f3990b3..7836ec1dd4 100644 --- a/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts +++ b/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts @@ -11,6 +11,7 @@ import type { LabelmapToolOperationDataVolume, } from '../../../types/LabelmapToolOperationData'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { LabelmapMemo } from '../../../utilities/segmentation/createLabelmapMemo'; const { VoxelManager } = csUtils; @@ -35,6 +36,7 @@ export type InitializedOperationData = LabelmapToolOperationDataAny & { brushStrategy: BrushStrategy; configuration?: Record; + memo?: LabelmapMemo; }; export type StrategyFunction = ( @@ -96,6 +98,10 @@ export default class BrushStrategy { [StrategyCallbacks.CreateIsInThreshold]: addSingletonMethod( StrategyCallbacks.CreateIsInThreshold ), + [StrategyCallbacks.Interpolate]: addListMethod( + StrategyCallbacks.Interpolate, + StrategyCallbacks.Initialize + ), [StrategyCallbacks.AcceptPreview]: addListMethod( StrategyCallbacks.AcceptPreview, StrategyCallbacks.Initialize @@ -114,6 +120,9 @@ export default class BrushStrategy { [StrategyCallbacks.ComputeInnerCircleRadius]: addListMethod( StrategyCallbacks.ComputeInnerCircleRadius ), + [StrategyCallbacks.GetStatistics]: addSingletonMethod( + StrategyCallbacks.GetStatistics + ), // Add other exposed fields below // initializers is exposed on the function to allow extension of the composition object compositions: null, @@ -187,15 +196,20 @@ export default class BrushStrategy { segmentationVoxelManager, previewVoxelManager, previewSegmentIndex, + segmentIndex, } = initializedData; + const isPreview = + previewSegmentIndex && previewVoxelManager.modifiedSlices.size; + triggerSegmentationDataModified( initializedData.segmentationId, - segmentationVoxelManager.getArrayOfSlices() + segmentationVoxelManager.getArrayOfSlices(), + isPreview ? previewSegmentIndex : segmentIndex ); // We are only previewing if there is a preview index, and there is at // least one slice modified - if (!previewSegmentIndex || !previewVoxelManager.modifiedSlices.size) { + if (!isPreview) { return null; } // Use the original initialized data set to preserve preview info @@ -242,7 +256,7 @@ export default class BrushStrategy { } = data; const previewVoxelManager = operationData.preview?.previewVoxelManager || - VoxelManager.createHistoryVoxelManager(segmentationVoxelManager); + VoxelManager.createRLEHistoryVoxelManager(segmentationVoxelManager); const previewEnabled = !!operationData.previewColors; const previewSegmentIndex = previewEnabled ? 255 : undefined; @@ -335,6 +349,12 @@ export default class BrushStrategy { operationData: LabelmapToolOperationDataAny ) => unknown; + /** Interpolate the labelmaps */ + public interpolate: ( + enabledElement: Types.IEnabledElement, + operationData: LabelmapToolOperationDataAny + ) => unknown; + /** * Over-written by the strategy composition. */ @@ -355,19 +375,22 @@ function addListMethod(name: string, createInitialized?: string) { brushStrategy[listName] ||= []; brushStrategy[listName].push(func); brushStrategy[name] ||= createInitialized - ? (enabledElement, operationData) => { + ? (enabledElement, operationData, ...args) => { const initializedData = brushStrategy[createInitialized]( enabledElement, operationData, name ); - brushStrategy[listName].forEach((func) => - func.call(brushStrategy, initializedData) - ); + let returnValue; + brushStrategy[listName].forEach((func) => { + const value = func.call(brushStrategy, initializedData, ...args); + returnValue ||= value; + }); + return returnValue; } - : (operationData) => { + : (operationData, ...args) => { brushStrategy[listName].forEach((func) => - func.call(brushStrategy, operationData) + func.call(brushStrategy, operationData, ...args) ); }; }; @@ -383,11 +406,11 @@ function addSingletonMethod(name: string, isInitialized = true) { } brushStrategy[name] = isInitialized ? func - : (enabledElement, operationData) => { + : (enabledElement, operationData, ...args) => { // Store the enabled element in the operation data so we can use single // argument calls operationData.enabledElement = enabledElement; - return func.call(brushStrategy, operationData); + return func.call(brushStrategy, operationData, ...args); }; }; } diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/dynamicThreshold.ts b/packages/tools/src/tools/segmentation/strategies/compositions/dynamicThreshold.ts index f8025a555b..e0da6a0e5d 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/dynamicThreshold.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/dynamicThreshold.ts @@ -21,6 +21,7 @@ export default { segmentationVoxelManager: segmentationVoxelManager, imageVoxelManager: imageVoxelManager, segmentIndex, + viewport, } = operationData; const { THRESHOLD } = strategySpecificConfiguration; @@ -37,6 +38,8 @@ export default { const { boundsIJK } = segmentationVoxelManager; const { threshold: oldThreshold, dynamicRadius = 0 } = THRESHOLD; const useDelta = oldThreshold ? 0 : dynamicRadius; + const { viewPlaneNormal } = viewport.getCamera(); + const nestedBounds = boundsIJK.map((ijk, idx) => { const [min, max] = ijk; return [ @@ -44,10 +47,25 @@ export default { Math.min(max, centerIJK[idx] + useDelta), ]; }) as BoundsIJK; + // Squash the bounds to the plane in view when it is orthogonal, or close + // to orthogonal to one of the bounding planes. + // Otherwise just use the full area for now. + if (Math.abs(viewPlaneNormal[0]) > 0.8) { + nestedBounds[0] = [centerIJK[0], centerIJK[0]]; + } else if (Math.abs(viewPlaneNormal[1]) > 0.8) { + nestedBounds[1] = [centerIJK[1], centerIJK[1]]; + } else if (Math.abs(viewPlaneNormal[2]) > 0.8) { + nestedBounds[2] = [centerIJK[2], centerIJK[2]]; + } const threshold = oldThreshold || [Infinity, -Infinity]; // TODO - threshold on all three values separately - const callback = ({ value }) => { + const useDeltaSqr = useDelta * useDelta; + const callback = ({ value, pointIJK }) => { + const distance = vec3.sqrDist(centerIJK, pointIJK); + if (distance > useDeltaSqr) { + return; + } const gray = Array.isArray(value) ? vec3.len(value as any) : value; threshold[0] = Math.min(gray, threshold[0]); threshold[1] = Math.max(gray, threshold[1]); @@ -105,7 +123,9 @@ export default { strategySpecificConfiguration[activeStrategy] = {}; } + // Add a couple of pixels to the radius to make it more obvious what is + // included. strategySpecificConfiguration[activeStrategy].dynamicRadiusInCanvas = - dynamicRadiusInCanvas; + 3 + dynamicRadiusInCanvas; }, }; diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/index.ts b/packages/tools/src/tools/segmentation/strategies/compositions/index.ts index 56542944ca..74dbe6ace7 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/index.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/index.ts @@ -3,9 +3,11 @@ import dynamicThreshold from './dynamicThreshold'; import erase from './erase'; import islandRemoval from './islandRemoval'; import preview from './preview'; +import labelmapInterpolation from './labelmapInterpolation'; import regionFill from './regionFill'; import setValue from './setValue'; import threshold from './threshold'; +import labelmapStatistics from './labelmapStatistics'; export default { determineSegmentIndex, @@ -13,7 +15,9 @@ export default { erase, islandRemoval, preview, + labelmapInterpolation, regionFill, setValue, threshold, + labelmapStatistics, }; diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/islandRemoval.ts b/packages/tools/src/tools/segmentation/strategies/compositions/islandRemoval.ts index ba36d090f4..789ae23552 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/islandRemoval.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/islandRemoval.ts @@ -1,7 +1,26 @@ +import { utilities } from '@cornerstonejs/core'; +import type { Types } from '@cornerstonejs/core'; import type { InitializedOperationData } from '../BrushStrategy'; -import floodFill from '../../../../utilities/segmentation/floodFill'; 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, +} /** * Removes external islands and fills internal islands. @@ -14,165 +33,256 @@ export default { [StrategyCallbacks.OnInteractionEnd]: ( operationData: InitializedOperationData ) => { - const { - previewVoxelManager: previewVoxelManager, - segmentationVoxelManager: segmentationVoxelManager, - strategySpecificConfiguration, - previewSegmentIndex, - segmentIndex, - } = operationData; - - if (!strategySpecificConfiguration.THRESHOLD || segmentIndex === null) { + const { strategySpecificConfiguration, previewSegmentIndex, segmentIndex } = + operationData; + + if ( + !strategySpecificConfiguration.THRESHOLD || + segmentIndex === null || + previewSegmentIndex === undefined + ) { return; } - const clickedPoints = previewVoxelManager.getPoints(); - if (!clickedPoints?.length) { + const segmentSet = createSegmentSet(operationData); + if (!segmentSet) { return; } - - if (previewSegmentIndex === undefined) { + const externalRemoved = removeExternalIslands(operationData, segmentSet); + if (externalRemoved === undefined) { + // Nothing to remove 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])), - ]); - - if (boundsIJK.find((it) => it[0] < 0 || it[1] > 65535)) { - // Nothing done, so just skip this + const arrayOfSlices = removeInternalIslands(operationData, segmentSet); + if (!arrayOfSlices) { return; } - const floodedSet = new Set(); - // Returns true for new colour, and false otherwise - const getter = (i, j, k) => { - if ( - i < boundsIJK[0][0] || - i > boundsIJK[0][1] || - j < boundsIJK[1][0] || - j > boundsIJK[1][1] || - k < boundsIJK[2][0] || - k > boundsIJK[2][1] - ) { - return -1; - } - const index = segmentationVoxelManager.toIndex([i, j, k]); - if (floodedSet.has(index)) { - // Values already flooded - return -2; - } - const oldVal = segmentationVoxelManager.getAtIndex(index); - const isIn = - oldVal === previewSegmentIndex || oldVal === segmentIndex ? 1 : 0; - if (!isIn) { - segmentationVoxelManager.addPoint(index); - } - // 1 is values that are preview/segment index, 0 is everything else - return isIn; - }; + triggerSegmentationDataModified( + operationData.segmentationId, + arrayOfSlices, + previewSegmentIndex + ); + }, +}; - let floodedCount = 0; +/** + * 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); - const onFlood = (i, j, k) => { - const index = segmentationVoxelManager.toIndex([i, j, k]); - if (floodedSet.has(index)) { - return; + // 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; } - // Fill this point with an indicator that this point is connected - previewVoxelManager.setAtIJK(i, j, k, previewSegmentIndex); - floodedSet.add(index); - floodedCount++; - }; - clickedPoints.forEach((clickedPoint) => { - // @ts-ignore - need to ignore the spread appication to array params - if (getter(...clickedPoint) === 1) { - floodFill(getter, clickedPoint, { - onFlood, - diagonals: true, - }); + if (!lastRle) { + lastRle = rle; + continue; } - }); - - let clearedCount = 0; - let previewCount = 0; - - const callback = ({ index, pointIJK, value: trackValue }) => { - const value = segmentationVoxelManager.getAtIndex(index); - if (floodedSet.has(index)) { - previewCount++; - const newValue = - trackValue === segmentIndex ? segmentIndex : previewSegmentIndex; - previewVoxelManager.setAtIJKPoint(pointIJK, newValue); - } else if (value === previewSegmentIndex) { - clearedCount++; - const newValue = trackValue ?? 0; - previewVoxelManager.setAtIJKPoint(pointIJK, newValue); + for (let iPrime = lastRle.end; iPrime < rle.start; iPrime++) { + floodedSet.set(baseIndex + iPrime, SegmentationEnum.INTERIOR); } - }; - - previewVoxelManager.forEach(callback, {}); - - if (floodedCount - previewCount !== 0) { - console.warn( - 'There were flooded=', - floodedCount, - 'cleared=', - clearedCount, - 'preview count=', - previewCount, - 'not handled', - floodedCount - previewCount + 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 } ); } - const islandMap = new Set(segmentationVoxelManager.points || []); - floodedSet.clear(); + }); - for (const index of islandMap.keys()) { - if (floodedSet.has(index)) { - continue; - } - let isInternal = true; - const internalSet = new Set(); - const onFloodInternal = (i, j, k) => { - const floodIndex = previewVoxelManager.toIndex([i, j, k]); - floodedSet.add(floodIndex); - if ( - (boundsIJK[0][0] !== boundsIJK[0][1] && - (i === boundsIJK[0][0] || i === boundsIJK[0][1])) || - (boundsIJK[1][0] !== boundsIJK[1][1] && - (j === boundsIJK[1][0] || j === boundsIJK[1][1])) || - (boundsIJK[2][0] !== boundsIJK[2][1] && - (k === boundsIJK[2][0] || k === boundsIJK[2][1])) - ) { - isInternal = false; - } - if (isInternal) { - internalSet.add(floodIndex); - } - }; - const pointIJK = previewVoxelManager.toIJK(index); - if (getter(...pointIJK) !== 0) { - continue; + // 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.getArrayOfSlices(); +} + +/** + * 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); } - floodFill(getter, pointIJK, { - onFlood: onFloodInternal, - diagonals: false, - }); - if (isInternal) { - for (const index of internalSet) { - previewVoxelManager.setAtIndex(index, previewSegmentIndex); - } + } + }; + + 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; } } - triggerSegmentationDataModified( - operationData.segmentationId, - previewVoxelManager.getArrayOfSlices() - ); - }, -}; + } + return false; +} diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/labelmapInterpolation.ts b/packages/tools/src/tools/segmentation/strategies/compositions/labelmapInterpolation.ts new file mode 100644 index 0000000000..30a7249c54 --- /dev/null +++ b/packages/tools/src/tools/segmentation/strategies/compositions/labelmapInterpolation.ts @@ -0,0 +1,76 @@ +import { + MorphologicalContourInterpolationOptions, + morphologicalContourInterpolation, +} from '@itk-wasm/morphological-contour-interpolation'; +import { utilities } from '@cornerstonejs/core'; +import type { InitializedOperationData } from '../BrushStrategy'; +import StrategyCallbacks from '../../../../enums/StrategyCallbacks'; +import getItkImage from '../utils/getItkImage'; +import { triggerSegmentationDataModified } from '../../../../stateManagement/segmentation/triggerSegmentationEvents'; +import PreviewMethods from './preview'; + +const { VoxelManager } = utilities; + +/** + * Adds an isWithinThreshold to the operation data that checks that the + * image value is within threshold[0]...threshold[1] + * No-op if threshold not defined. + */ +export default { + [StrategyCallbacks.Interpolate]: ( + operationData: InitializedOperationData, + configuration: MorphologicalContourInterpolationOptions + ) => { + const { + segmentationImageData, + segmentIndex, + preview, + segmentationVoxelManager, + previewSegmentIndex, + previewVoxelManager, + } = operationData; + + if (preview) { + // Mark everything as segment index value so the interpolation works + const callback = ({ index }) => { + segmentationVoxelManager.setAtIndex(index, segmentIndex); + }; + previewVoxelManager.forEach(callback); + } + const inputImage = getItkImage(segmentationImageData, 'interpolation'); + const outputPromise = morphologicalContourInterpolation(inputImage, { + ...configuration, + label: segmentIndex, + webWorker: false, + }); + outputPromise.then((value) => { + const { outputImage } = value; + const updateVoxelManager = VoxelManager.createVolumeVoxelManager( + segmentationVoxelManager.dimensions, + outputImage.data + ); + const previewColors = operationData.configuration?.preview?.previewColors; + const assignIndex = + previewSegmentIndex ?? (previewColors ? 255 : segmentIndex); + // Reset the colors - needs operation data set to do this + operationData.previewColors ||= previewColors; + operationData.previewSegmentIndex ||= previewColors ? 255 : undefined; + PreviewMethods[StrategyCallbacks.Initialize](operationData); + + updateVoxelManager.forEach(({ value, index }) => { + const origValue = segmentationVoxelManager.getAtIndex(index); + if (origValue === value) { + return; + } + previewVoxelManager.setAtIndex(index, assignIndex); + }); + + triggerSegmentationDataModified( + operationData.segmentationId, + previewVoxelManager.getArrayOfSlices(), + assignIndex + ); + }); + return operationData; + }, +}; diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/labelmapStatistics.ts b/packages/tools/src/tools/segmentation/strategies/compositions/labelmapStatistics.ts new file mode 100644 index 0000000000..424eda6273 --- /dev/null +++ b/packages/tools/src/tools/segmentation/strategies/compositions/labelmapStatistics.ts @@ -0,0 +1,51 @@ +import StrategyCallbacks from '../../../../enums/StrategyCallbacks'; +import type { InitializedOperationData } from '../BrushStrategy'; +import { VolumetricCalculator } from '../../../../utilities/segmentation'; +import { segmentIndex } from '../../../../stateManagement/segmentation'; +import { getStrategyData } from '../utils/getStrategyData'; + +/** + * Compute basic labelmap segmentation statistics. + */ +export default { + [StrategyCallbacks.GetStatistics]: function ( + enabledElement, + operationData: InitializedOperationData, + options?: { indices?: number | number[] } + ) { + const { viewport } = enabledElement; + let { indices } = options; + const { segmentationId } = operationData; + if (!indices) { + indices = [segmentIndex.getActiveSegmentIndex(segmentationId)]; + } else if (!Array.isArray(indices)) { + // Include the preview index + indices = [indices, 255]; + } + const indicesArr = indices as number[]; + + const { + segmentationVoxelManager, + imageVoxelManager, + segmentationImageData, + } = getStrategyData({ + operationData, + viewport, + }); + + const spacing = segmentationImageData.getSpacing(); + // Turning this off more than doubles the speed of the stats collection... + VolumetricCalculator.statsInit({ noPointsCollection: true }); + + segmentationVoxelManager.forEach((voxel) => { + const { value, pointIJK } = voxel; + if (indicesArr.indexOf(value) === -1) { + return; + } + const imageValue = imageVoxelManager.getAtIJKPoint(pointIJK); + VolumetricCalculator.statsCallback({ value: imageValue }); + }); + + return VolumetricCalculator.getStatistics({ spacing }); + }, +}; diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts b/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts index c623363dcf..e8c8e6925c 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts @@ -46,8 +46,14 @@ export default { previewSegmentIndex, previewColors, preview, + segmentationId, + segmentationVoxelManager, } = operationData; - if (previewColors === undefined) { + if (previewColors === undefined || !previewSegmentIndex) { + operationData.memo = operationData.createMemo( + segmentationId, + segmentationVoxelManager + ); return; } if (preview) { @@ -57,8 +63,15 @@ export default { operationData.previewVoxelManager = preview.previewVoxelManager; } - if (segmentIndex === null || !previewSegmentIndex) { - // Null means to reset the value, so we don't change the preview colour + // TODO - figure out how to use memo combined with preview. + // operationData.memo = operationData.createMemo( + // segmentationId, + // segmentationVoxelManager, + // preview || operationData + // ); + + if (segmentIndex === null) { + // Null means to reset the value, so we don't change the preview colour, return; } @@ -71,7 +84,9 @@ export default { if (!configColor && !segmentColor) { return; } - const previewColor = configColor || segmentColor.map((it) => it * 0.9); + const previewColor = + configColor || + segmentColor.map((it, idx) => (idx === 3 ? 64 : Math.round(it * 0.9))); segmentationConfig.color.setColorForSegmentIndex( toolGroupId, segmentationRepresentationUID, @@ -85,41 +100,51 @@ export default { ) => { const { segmentationVoxelManager: segmentationVoxelManager, - previewVoxelManager: previewVoxelManager, + previewVoxelManager, previewSegmentIndex, preview, + segmentationId, } = operationData; if (previewSegmentIndex === undefined) { return; } const segmentIndex = preview?.segmentIndex ?? operationData.segmentIndex; - const tracking = previewVoxelManager; - if (!tracking || tracking.modifiedSlices.size === 0) { + if (!previewVoxelManager || previewVoxelManager.modifiedSlices.size === 0) { return; } - const callback = ({ index }) => { + // TODO - figure out a better option for undo/redo of preview + const memo = operationData.createMemo( + segmentationId, + segmentationVoxelManager + ); + operationData.memo = memo; + const { voxelManager } = memo; + + const callback = ({ index, value }) => { const oldValue = segmentationVoxelManager.getAtIndex(index); if (oldValue === previewSegmentIndex) { - segmentationVoxelManager.setAtIndex(index, segmentIndex); + // First restore the segmentation voxel manager + segmentationVoxelManager.setAtIndex(index, value); + // Then set it to the final value so that the memo voxel manager has + // the correct values. + voxelManager.setAtIndex(index, segmentIndex); } }; - tracking.forEach(callback, {}); + previewVoxelManager.forEach(callback, {}); triggerSegmentationDataModified( operationData.segmentationId, - tracking.getArrayOfSlices() + previewVoxelManager.getArrayOfSlices(), + preview.segmentIndex ); - tracking.clear(); + previewVoxelManager.clear(); }, [StrategyCallbacks.RejectPreview]: ( operationData: InitializedOperationData ) => { - const { - previewVoxelManager: previewVoxelManager, - segmentationVoxelManager: segmentationVoxelManager, - } = operationData; + const { previewVoxelManager, segmentationVoxelManager } = operationData; if (previewVoxelManager.modifiedSlices.size === 0) { return; } @@ -129,9 +154,12 @@ export default { }; previewVoxelManager.forEach(callback); + // Primarily rejects back to zero, so use 0 as the segment index - even + // if sometimes it modifies the data to other values on reject. triggerSegmentationDataModified( operationData.segmentationId, - previewVoxelManager.getArrayOfSlices() + previewVoxelManager.getArrayOfSlices(), + 0 ); previewVoxelManager.clear(); }, diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts b/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts index b60473e678..490344da19 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts @@ -14,8 +14,8 @@ export default { segmentsLocked, segmentationImageData, segmentationVoxelManager: segmentationVoxelManager, - previewVoxelManager: previewVoxelManager, - imageVoxelManager: imageVoxelManager, + previewVoxelManager, + imageVoxelManager, brushStrategy, centerIJK, } = operationData; diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts b/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts index df43a1df68..59e3b7cd26 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts @@ -16,10 +16,12 @@ export default { const { segmentsLocked, segmentIndex, - previewVoxelManager: previewVoxelManager, + memo, previewSegmentIndex, - segmentationVoxelManager: segmentationVoxelManager, + segmentationVoxelManager, } = operationData; + const previewVoxelManager = + memo?.voxelManager || operationData.previewVoxelManager; const existingValue = segmentationVoxelManager.getAtIndex(index); if (segmentIndex === null) { const oldValue = previewVoxelManager.getAtIndex(index); diff --git a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts index 5cc4c54f71..e6794102af 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts @@ -126,7 +126,9 @@ const CIRCLE_STRATEGY = new BrushStrategy( compositions.setValue, initializeCircle, compositions.determineSegmentIndex, - compositions.preview + compositions.preview, + compositions.labelmapStatistics, + compositions.labelmapInterpolation ); const CIRCLE_THRESHOLD_STRATEGY = new BrushStrategy( @@ -138,7 +140,9 @@ const CIRCLE_THRESHOLD_STRATEGY = new BrushStrategy( compositions.dynamicThreshold, compositions.threshold, compositions.preview, - compositions.islandRemoval + compositions.islandRemoval, + compositions.labelmapStatistics, + compositions.labelmapInterpolation ); /** diff --git a/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts b/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts index 37412b45fb..c0c6bfb4b2 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts @@ -1,51 +1,64 @@ -import { utilities as csUtils, StackViewport } from '@cornerstonejs/core'; +import { vec3 } from 'gl-matrix'; +import { BaseVolumeViewport, utilities as csUtils } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import { getBoundingBoxAroundShapeIJK, getBoundingBoxAroundShapeWorld, } from '../../../utilities/boundingBox'; -import { pointInShapeCallback } from '../../../utilities'; -import { triggerSegmentationDataModified } from '../../../stateManagement/segmentation/triggerSegmentationEvents'; -import { LabelmapToolOperationData } from '../../../types'; -import { getStrategyData } from './utils/getStrategyData'; +import BrushStrategy from './BrushStrategy'; +import type { Composition, InitializedOperationData } from './BrushStrategy'; +import { StrategyCallbacks } from '../../../enums'; +import compositions from './compositions'; import { isAxisAlignedRectangle } from '../../../utilities/rectangleROITool/isAxisAlignedRectangle'; const { transformWorldToIndex } = csUtils; -type OperationData = LabelmapToolOperationData & { - points: [Types.Point3, Types.Point3, Types.Point3, Types.Point3]; -}; +const initializeRectangle = { + [StrategyCallbacks.Initialize]: (operationData: InitializedOperationData) => { + const { + points, // bottom, top, left, right + imageVoxelManager: imageVoxelManager, + viewport, + segmentationImageData, + segmentationVoxelManager: segmentationVoxelManager, + } = operationData; + + // Happens on a preview setup + if (!points) { + return; + } + // Average the points to get the center of the ellipse + const center = vec3.fromValues(0, 0, 0); + points.forEach((point) => { + vec3.add(center, center, point); + }); + vec3.scale(center, center, 1 / points.length); + + operationData.centerWorld = center as Types.Point3; + operationData.centerIJK = transformWorldToIndex( + segmentationImageData, + center as Types.Point3 + ); + + // 2. Find the extent of the ellipse (circle) in IJK index space of the image + + const { boundsIJK, pointInShapeFn } = createPointInRectangle( + viewport, + points, + segmentationImageData + ); + segmentationVoxelManager.boundsIJK = boundsIJK; + imageVoxelManager.isInObject = pointInShapeFn; + }, +} as Composition; /** - * For each point in the bounding box around the rectangle, if the point is inside - * the rectangle, set the scalar value to the segmentIndex - * @param toolGroupId - string - * @param operationData - OperationData - * @param inside - boolean + * Creates a function that tells the user if the provided point in LPS space + * is inside the rectangle. + * */ -// Todo: why we have another constraintFn? in addition to the one in the operationData? -function fillRectangle( - enabledElement: Types.IEnabledElement, - operationData: OperationData, - inside = true -): void { - const { points, segmentsLocked, segmentIndex, segmentationId } = - operationData; - - const { viewport } = enabledElement; - const strategyData = getStrategyData({ - operationData, - viewport: enabledElement.viewport, - }); - - if (!strategyData) { - console.warn('No data found for fillRectangle'); - return; - } - - const { segmentationImageData, segmentationScalarData } = strategyData; - +function createPointInRectangle(viewport, points, segmentationImageData) { let rectangleCornersIJK = points.map((world) => { return transformWorldToIndex(segmentationImageData, world); }); @@ -62,11 +75,11 @@ function fillRectangle( segmentationImageData.getDimensions() ); - const isStackViewport = viewport instanceof StackViewport; + const isVolumeViewport = viewport instanceof BaseVolumeViewport; // Are we working with 2D rectangle in axis aligned viewport view or not const isAligned = - isStackViewport || isAxisAlignedRectangle(rectangleCornersIJK); + !isVolumeViewport || isAxisAlignedRectangle(rectangleCornersIJK); const direction = segmentationImageData.getDirection(); const spacing = segmentationImageData.getSpacing(); @@ -105,46 +118,53 @@ function fillRectangle( return xInside && yInside && zInside; }; - const callback = ({ value, index }) => { - if (segmentsLocked.includes(value)) { - return; - } - - segmentationScalarData[index] = segmentIndex; - }; - - pointInShapeCallback( - segmentationImageData, - pointInShapeFn, - callback, - boundsIJK - ); - - triggerSegmentationDataModified(segmentationId); + return { boundsIJK, pointInShapeFn }; } +const RECTANGLE_STRATEGY = new BrushStrategy( + 'Rectangle', + compositions.regionFill, + compositions.setValue, + initializeRectangle, + compositions.determineSegmentIndex, + compositions.preview, + compositions.labelmapStatistics, + compositions.labelmapInterpolation +); + +const RECTANGLE_THRESHOLD_STRATEGY = new BrushStrategy( + 'RectangleThreshold', + compositions.regionFill, + compositions.setValue, + initializeRectangle, + compositions.determineSegmentIndex, + compositions.dynamicThreshold, + compositions.threshold, + compositions.preview, + compositions.islandRemoval, + compositions.labelmapStatistics, + compositions.labelmapInterpolation +); + /** - * Fill the inside of a rectangle - * @param toolGroupId - The unique identifier of the tool group. - * @param operationData - The data that will be used to create the - * new rectangle. + * Fill inside the circular region segment inside the segmentation defined by the operationData. + * It fills the segmentation pixels inside the defined circle. + * @param enabledElement - The element for which the segment is being erased. + * @param operationData - EraseOperationData */ -export function fillInsideRectangle( - enabledElement: Types.IEnabledElement, - operationData: OperationData -): void { - fillRectangle(enabledElement, operationData, true); -} +const fillInsideRectangle = RECTANGLE_STRATEGY.strategyFunction; /** - * Fill the area outside of a rectangle for the toolGroupId and segmentationRepresentationUID. - * @param toolGroupId - The unique identifier of the tool group. - * @param operationData - The data that will be used to create the - * new rectangle. + * Fill inside the circular region segment inside the segmentation defined by the operationData. + * It fills the segmentation pixels inside the defined circle. + * @param enabledElement - The element for which the segment is being erased. + * @param operationData - EraseOperationData */ -export function fillOutsideRectangle( - enabledElement: Types.IEnabledElement, - operationData: OperationData -): void { - fillRectangle(enabledElement, operationData, false); -} +const thresholdInsideRectangle = RECTANGLE_THRESHOLD_STRATEGY.strategyFunction; + +export { + RECTANGLE_STRATEGY, + RECTANGLE_THRESHOLD_STRATEGY, + fillInsideRectangle, + thresholdInsideRectangle, +}; diff --git a/packages/tools/src/tools/segmentation/strategies/fillSphere.ts b/packages/tools/src/tools/segmentation/strategies/fillSphere.ts index 1d80643161..361c4fb16f 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillSphere.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillSphere.ts @@ -70,7 +70,8 @@ const SPHERE_STRATEGY = new BrushStrategy( compositions.setValue, sphereComposition, compositions.determineSegmentIndex, - compositions.preview + compositions.preview, + compositions.labelmapStatistics ); /** @@ -82,6 +83,13 @@ const SPHERE_STRATEGY = new BrushStrategy( const fillInsideSphere = SPHERE_STRATEGY.strategyFunction; const SPHERE_THRESHOLD_STRATEGY = new BrushStrategy( + 'SphereThreshold', + ...SPHERE_STRATEGY.compositions, + compositions.dynamicThreshold, + compositions.threshold +); + +const SPHERE_THRESHOLD_STRATEGY_ISLAND = new BrushStrategy( 'SphereThreshold', ...SPHERE_STRATEGY.compositions, compositions.dynamicThreshold, @@ -97,6 +105,8 @@ const SPHERE_THRESHOLD_STRATEGY = new BrushStrategy( */ const thresholdInsideSphere = SPHERE_THRESHOLD_STRATEGY.strategyFunction; +const thresholdInsideSphereIsland = + SPHERE_THRESHOLD_STRATEGY_ISLAND.strategyFunction; /** * Fill outside a sphere with the given segment index in the given operation data. The @@ -108,4 +118,9 @@ export function fillOutsideSphere(): void { throw new Error('fill outside sphere not implemented'); } -export { fillInsideSphere, thresholdInsideSphere, SPHERE_STRATEGY }; +export { + fillInsideSphere, + thresholdInsideSphere, + SPHERE_STRATEGY, + thresholdInsideSphereIsland, +}; diff --git a/packages/tools/src/tools/segmentation/strategies/index.ts b/packages/tools/src/tools/segmentation/strategies/index.ts index 6bad5970dd..38fba88dc5 100644 --- a/packages/tools/src/tools/segmentation/strategies/index.ts +++ b/packages/tools/src/tools/segmentation/strategies/index.ts @@ -1,9 +1,9 @@ -import { fillInsideRectangle, fillOutsideRectangle } from './fillRectangle'; +import { fillInsideRectangle, thresholdInsideRectangle } from './fillRectangle'; import { fillInsideCircle, fillOutsideCircle } from './fillCircle'; export { fillInsideRectangle, - fillOutsideRectangle, fillInsideCircle, fillOutsideCircle, + thresholdInsideRectangle, }; diff --git a/packages/tools/src/tools/segmentation/strategies/utils/getItkImage.ts b/packages/tools/src/tools/segmentation/strategies/utils/getItkImage.ts new file mode 100644 index 0000000000..a0093e561d --- /dev/null +++ b/packages/tools/src/tools/segmentation/strategies/utils/getItkImage.ts @@ -0,0 +1,66 @@ +import { + Image, + ImageType, + IntTypes, + FloatTypes, + PixelTypes, + Metadata, +} from 'itk-wasm'; + +const dataTypesMap = { + Int8: IntTypes.Int8, + UInt8: IntTypes.UInt8, + Int16: IntTypes.Int16, + UInt16: IntTypes.UInt16, + Int32: IntTypes.Int32, + UInt32: IntTypes.UInt32, + Int64: IntTypes.Int64, + UInt64: IntTypes.UInt64, + Float32: FloatTypes.Float32, + Float64: FloatTypes.Float64, +}; + +/** + * Get the ITK Image from the image data + * + * @param viewportId - Viewport Id + * @param imageName - Any random name that shall be set in the image + * @returns An ITK Image that can be used as fixed or moving image + */ +export default function getItkImage(imageData, imageName?: string): Image { + const pointData = imageData.getPointData(); + const scalars = pointData.getScalars(); + const dimensions = imageData.getDimensions(); + const origin = imageData.getOrigin(); + const spacing = imageData.getSpacing(); + const directionArray = imageData.getDirection(); + const direction = new Float64Array(directionArray); + const numComponents = pointData.getNumberOfComponents(); + const dataType = scalars + .getDataType() + .replace(/^Ui/, 'UI') + .replace(/Array$/, ''); + const metadata: Metadata = undefined; + const scalarData = scalars.getData(); + const imageType: ImageType = new ImageType( + dimensions.length, + dataTypesMap[dataType], + PixelTypes.Scalar, + numComponents + ); + + const image = new Image(imageType); + + image.name = imageName; + image.origin = origin; + image.spacing = spacing; + image.direction = direction; + image.size = dimensions; + image.metadata = metadata; + image.data = scalarData; + + // image.data = new scalarData.constructor(scalarData.length); + // image.data.set(scalarData, 0); + + return image; +} diff --git a/packages/tools/src/tools/segmentation/strategies/utils/normalizeViewportPlane.ts b/packages/tools/src/tools/segmentation/strategies/utils/normalizeViewportPlane.ts new file mode 100644 index 0000000000..13502a32bb --- /dev/null +++ b/packages/tools/src/tools/segmentation/strategies/utils/normalizeViewportPlane.ts @@ -0,0 +1,59 @@ +import type { Types } from '@cornerstonejs/core'; +import { BaseVolumeViewport, utilities } from '@cornerstonejs/core'; + +const { isEqual } = utilities; + +const acquisitionMapping = { + toIJK: (ijkPrime) => ijkPrime, + fromIJK: (ijk) => ijk, + type: 'acquistion', +}; + +const jkMapping = { + toIJK: ([j, k, i]) => [i, j, k], + fromIJK: ([i, j, k]) => [j, k, i], + type: 'jk', +}; + +const ikMapping = { + toIJK: ([i, k, j]) => [i, j, k], + fromIJK: ([i, j, k]) => [i, k, j], + type: 'ik', +}; + +/** + * This function returns a set of functions that normalize the viewport plane + * into `i', j', k'` from the image space `i,j,k` such that + * `i', j'` are within viewport indices corresponding to 1 pixel distance on + * 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. + */ +export default function normalizeViewportPlane( + viewport: Types.IViewport, + boundsIJK: Types.BoundsIJK +) { + if (!(viewport instanceof BaseVolumeViewport)) { + // This is the case for acquisition plane, which includes all non-volume viewports: + return { ...acquisitionMapping, boundsIJKPrime: boundsIJK }; + } + + const { viewPlaneNormal } = viewport.getCamera(); + // This doesn't really handle non-coplanar views, but it sort of works even for those, so leave it for now. + const mapping = + (isEqual(Math.abs(viewPlaneNormal[0]), 1) && jkMapping) || + (isEqual(Math.abs(viewPlaneNormal[1]), 1) && ikMapping) || + (isEqual(Math.abs(viewPlaneNormal[2]), 1) && acquisitionMapping); + if (!mapping) { + // Non-orthogonal to acquisition plane isn't handled, but doesn't prevent + // options from working, so return an error indicator. + return { + toIJK: null, + boundsIJKPrime: null, + fromIJK: null, + error: `Only mappings orthogonal to acquisition plane are permitted, but requested ${viewPlaneNormal}`, + }; + } + + return { ...mapping, boundsIJKPrime: mapping.fromIJK(boundsIJK) }; +} diff --git a/packages/tools/src/types/CalculatorTypes.ts b/packages/tools/src/types/CalculatorTypes.ts index 78814d3540..b4d99efc5c 100644 --- a/packages/tools/src/types/CalculatorTypes.ts +++ b/packages/tools/src/types/CalculatorTypes.ts @@ -1,3 +1,5 @@ +import type { Types } from '@cornerstonejs/core'; + type Statistics = { name: string; label?: string; @@ -8,6 +10,7 @@ type Statistics = { type NamedStatistics = { mean: Statistics & { name: 'mean' }; max: Statistics & { name: 'max' }; + min: Statistics & { name: 'min' }; stdDev: Statistics & { name: 'stdDev' }; stdDevWithSumSquare: Statistics & { name: 'stdDevWithSumSquare' }; count: Statistics & { name: 'count' }; @@ -15,6 +18,8 @@ type NamedStatistics = { volume?: Statistics & { name: 'volume' }; circumferance?: Statistics & { name: 'circumferance' }; array: Statistics[]; + /** The array of points that this statistic is calculated on. */ + pointsInShape?: Types.PointsManager; }; export type { Statistics, NamedStatistics }; diff --git a/packages/tools/src/types/EventTypes.ts b/packages/tools/src/types/EventTypes.ts index 94da7ca655..7cdc7da0ed 100644 --- a/packages/tools/src/types/EventTypes.ts +++ b/packages/tools/src/types/EventTypes.ts @@ -231,6 +231,11 @@ type SegmentationDataModifiedEventDetail = { /** array of slice indices in a labelmap which have been modified */ // TODO: This is labelmap-specific and needs to be a labelmap-specific event modifiedSlicesToUse?: number[]; + /** + * The segment index being modified as a primary action - other segments + * indices may also be modified as a side affect of the primary change. + */ + segmentIndex?: number; }; /** diff --git a/packages/tools/src/types/FloodFillTypes.ts b/packages/tools/src/types/FloodFillTypes.ts index 826b24e9bb..509975058c 100644 --- a/packages/tools/src/types/FloodFillTypes.ts +++ b/packages/tools/src/types/FloodFillTypes.ts @@ -2,7 +2,6 @@ import { Types } from '@cornerstonejs/core'; type FloodFillResult = { flooded: Types.Point2[] | Types.Point3[]; - boundaries: Types.Point2[] | Types.Point3[]; }; type FloodFillGetter3D = (x: number, y: number, z: number) => unknown; @@ -14,6 +13,9 @@ type FloodFillOptions = { onBoundary?: (x: number, y: number, z?: number) => void; equals?: (a, b) => boolean; // Equality operation for your datastructure. Defaults to a === b. diagonals?: boolean; // Whether to flood fill across diagonals. Default false. + bounds?: Map; //Store the bounds + // Return false to exclude + filter?: (point) => boolean; }; export { FloodFillResult, FloodFillGetter, FloodFillOptions }; diff --git a/packages/tools/src/types/LabelmapToolOperationData.ts b/packages/tools/src/types/LabelmapToolOperationData.ts index cd1d92dff6..bd85733fcc 100644 --- a/packages/tools/src/types/LabelmapToolOperationData.ts +++ b/packages/tools/src/types/LabelmapToolOperationData.ts @@ -1,9 +1,10 @@ import type { Types } from '@cornerstonejs/core'; -import { +import type { LabelmapSegmentationDataStack, LabelmapSegmentationDataVolume, } from './LabelmapTypes'; +import type { LabelmapMemo } from '../utilities/segmentation/createLabelmapMemo'; type LabelmapToolOperationData = { segmentationId: string; @@ -26,6 +27,16 @@ type LabelmapToolOperationData = { */ preview: any; toolGroupId: string; + /** + * Creates a labelmap memo, given the preview information and segment voxels. + * May return an already existing one when used for extension. + */ + createMemo: ( + segmentId, + segmentVoxels, + previewVoxels?, + previewMemo? + ) => LabelmapMemo; }; type LabelmapToolOperationDataStack = LabelmapToolOperationData & diff --git a/packages/tools/src/utilities/math/basic/BasicStatsCalculator.ts b/packages/tools/src/utilities/math/basic/BasicStatsCalculator.ts index cdabbf9493..9bb3dbb951 100644 --- a/packages/tools/src/utilities/math/basic/BasicStatsCalculator.ts +++ b/packages/tools/src/utilities/math/basic/BasicStatsCalculator.ts @@ -1,28 +1,42 @@ -import { NamedStatistics, Statistics } from '../../../types'; +import { utilities } from '@cornerstonejs/core'; +import { NamedStatistics } from '../../../types'; import Calculator from './Calculator'; +const { PointsManager } = utilities; + export default class BasicStatsCalculator extends Calculator { private static max = [-Infinity]; + private static min = [-Infinity]; private static sum = [0]; private static sumSquares = [0]; private static squaredDiffSum = [0]; private static count = 0; + // Collect the points to be returned + private static pointsInShape = PointsManager.create3(1024); + + public static statsInit(options: { noPointsCollection: boolean }) { + if (options.noPointsCollection) { + this.pointsInShape = null; + } + } /** * This callback is used when we verify if the point is in the annotion drawn so we can get every point * in the shape to calculate the statistics * @param value of the point in the shape of the annotation */ - static statsCallback = ({ value: newValue }): void => { + static statsCallback = ({ value: newValue, pointLPS = null }): void => { if ( Array.isArray(newValue) && newValue.length > 1 && this.max.length === 1 ) { this.max.push(this.max[0], this.max[0]); + this.min.push(this.min[0], this.min[0]); this.sum.push(this.sum[0], this.sum[0]); this.sumSquares.push(this.sumSquares[0], this.sumSquares[0]); this.squaredDiffSum.push(this.squaredDiffSum[0], this.squaredDiffSum[0]); + this.pointsInShape?.push(pointLPS); } const newArray = Array.isArray(newValue) ? newValue : [newValue]; @@ -31,6 +45,9 @@ export default class BasicStatsCalculator extends Calculator { this.max.forEach( (it, idx) => (this.max[idx] = Math.max(it, newArray[idx])) ); + this.min.forEach( + (it, idx) => (this.min[idx] = Math.min(it, newArray[idx])) + ); this.sum.map((it, idx) => (this.sum[idx] += newArray[idx])); this.sumSquares.map( (it, idx) => (this.sumSquares[idx] += newArray[idx] ** 2) @@ -42,6 +59,7 @@ export default class BasicStatsCalculator extends Calculator { 2 )) ); + this.pointsInShape = PointsManager.create3(1024); }; /** @@ -54,7 +72,7 @@ export default class BasicStatsCalculator extends Calculator { * array : An array of hte above values, in order. */ - static getStatistics = (): NamedStatistics => { + static getStatistics = (options?: { unit: string }): NamedStatistics => { const mean = this.sum.map((sum) => sum / this.count); const stdDev = this.squaredDiffSum.map((squaredDiffSum) => Math.sqrt(squaredDiffSum / this.count) @@ -63,29 +81,37 @@ export default class BasicStatsCalculator extends Calculator { Math.sqrt(this.sumSquares[idx] / this.count - mean[idx] ** 2) ); + const unit = options?.unit || null; + const named: NamedStatistics = { max: { name: 'max', label: 'Max Pixel', value: singleArrayAsNumber(this.max), - unit: null, + unit, + }, + min: { + name: 'min', + label: 'Min Pixel', + value: singleArrayAsNumber(this.min), + unit, }, mean: { name: 'mean', label: 'Mean Pixel', value: singleArrayAsNumber(mean), - unit: null, + unit, }, stdDev: { name: 'stdDev', label: 'Standard Deviation', value: singleArrayAsNumber(stdDev), - unit: null, + unit, }, stdDevWithSumSquare: { name: 'stdDevWithSumSquare', value: singleArrayAsNumber(stdDevWithSumSquare), - unit: null, + unit, }, count: { name: 'count', @@ -93,6 +119,7 @@ export default class BasicStatsCalculator extends Calculator { value: this.count, unit: null, }, + pointsInShape: this.pointsInShape, array: [], }; named.array.push( @@ -104,6 +131,7 @@ export default class BasicStatsCalculator extends Calculator { ); this.max = [-Infinity]; + this.min = [Infinity]; this.sum = [0]; this.sumSquares = [0]; this.squaredDiffSum = [0]; diff --git a/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts b/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts index b8f10be722..8e9a5c00c9 100644 --- a/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts +++ b/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts @@ -20,6 +20,7 @@ type PlanarFreehandROIEditData = { // The index on the prevCanvasPoints that the edit line should snap to in the // edit preview. snapIndex?: number; + newAnnotation: boolean; }; type PlanarFreehandROICommonData = { diff --git a/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts b/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts index 66d9152cd3..8dd52bb9b5 100644 --- a/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts +++ b/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts @@ -97,6 +97,12 @@ export default function filterAnnotationsWithinSlice( const dir = vec3.create(); + // If the handles has no values, eg a key image or other annotation, it + // should just be included. + if (!point) { + annotationsWithinSlice.push(annotation); + return; + } vec3.sub(dir, focalPoint, point); const dot = vec3.dot(dir, viewPlaneNormal); diff --git a/packages/tools/src/utilities/pointInShapeCallback.ts b/packages/tools/src/utilities/pointInShapeCallback.ts index b401f0c36e..a4d0608514 100644 --- a/packages/tools/src/utilities/pointInShapeCallback.ts +++ b/packages/tools/src/utilities/pointInShapeCallback.ts @@ -1,8 +1,10 @@ import { vec3 } from 'gl-matrix'; -import type { Types } from '@cornerstonejs/core'; +import { Types, utilities } from '@cornerstonejs/core'; import type { vtkImageData } from '@kitware/vtk.js/Common/DataModel/ImageData'; import BoundsIJK from '../types/BoundsIJK'; +const { PointsManager } = utilities; + export type PointInShape = { value: number; index: number; @@ -18,18 +20,99 @@ export type PointInShapeCallback = ({ }: { value: number; index: number; - pointIJK: vec3; - pointLPS: vec3; + pointIJK: Types.Point3; + pointLPS: Types.Point3; }) => void; export type ShapeFnCriteria = (pointLPS: vec3, pointIJK: vec3) => boolean; +/** + * Returns a function that takes an ijk position and efficiently returns + * the world position. Only works for integer ijk, AND values within the bounds. + * The position array is re-used, so don't preserve it/compare for different + * values, although you can provide an instance position to copy into. + * + * This function is safe to use out of order, and is stable in terms of calculations. + */ +export function createPositionCallback(imageData) { + const currentPos = vec3.create(); + const dimensions = imageData.getDimensions(); + const positionI = PointsManager.create3(dimensions[0]); + const positionJ = PointsManager.create3(dimensions[1]); + const positionK = PointsManager.create3(dimensions[2]); + + const direction = imageData.getDirection(); + const rowCosines = direction.slice(0, 3); + const columnCosines = direction.slice(3, 6); + const scanAxisNormal = direction.slice(6, 9); + + const spacing = imageData.getSpacing(); + const [rowSpacing, columnSpacing, scanAxisSpacing] = spacing; + + // @ts-ignore will be fixed in vtk-master + const worldPosStart = imageData.indexToWorld([0, 0, 0]); + + const rowStep = vec3.fromValues( + rowCosines[0] * rowSpacing, + rowCosines[1] * rowSpacing, + rowCosines[2] * rowSpacing + ); + + const columnStep = vec3.fromValues( + columnCosines[0] * columnSpacing, + columnCosines[1] * columnSpacing, + columnCosines[2] * columnSpacing + ); + + const scanAxisStep = vec3.fromValues( + scanAxisNormal[0] * scanAxisSpacing, + scanAxisNormal[1] * scanAxisSpacing, + scanAxisNormal[2] * scanAxisSpacing + ); + + const scaled = vec3.create(); + // Add the world position start to the I component so we don't need to add it + for (let i = 0; i < dimensions[0]; i++) { + positionI.push( + vec3.add( + scaled, + worldPosStart, + vec3.scale(scaled, rowStep, i) + ) as Types.Point3 + ); + } + for (let j = 0; j < dimensions[0]; j++) { + positionJ.push(vec3.scale(scaled, columnStep, j) as Types.Point3); + } + for (let k = 0; k < dimensions[0]; k++) { + positionK.push(vec3.scale(scaled, scanAxisStep, k) as Types.Point3); + } + + const dataI = positionI.getTypedArray(); + const dataJ = positionJ.getTypedArray(); + const dataK = positionK.getTypedArray(); + + return (ijk, destPoint = currentPos) => { + const [i, j, k] = ijk; + const offsetI = i * 3; + const offsetJ = j * 3; + const offsetK = k * 3; + destPoint[0] = dataI[offsetI] + dataJ[offsetJ] + dataK[offsetK]; + destPoint[1] = dataI[offsetI + 1] + dataJ[offsetJ + 1] + dataK[offsetK + 1]; + destPoint[2] = dataI[offsetI + 2] + dataJ[offsetJ + 2] + dataK[offsetK + 2]; + return destPoint as Types.Point3; + }; +} + /** * For each point in the image (If boundsIJK is not provided, otherwise, for each * point in the provided bounding box), It runs the provided callback IF the point * passes the provided criteria to be inside the shape (which is defined by the * provided pointInShapeFn) * + * You must record points in the callback function if you wish to have an array + * of the called points. + * * @param imageData - The image data object. * @param dimensions - The dimensions of the image. * @param pointInShapeFn - A function that takes a point in LPS space and returns @@ -41,9 +124,9 @@ export type ShapeFnCriteria = (pointLPS: vec3, pointIJK: vec3) => boolean; export default function pointInShapeCallback( imageData: vtkImageData | Types.CPUImageData, pointInShapeFn: ShapeFnCriteria, - callback?: PointInShapeCallback, + callback: PointInShapeCallback, boundsIJK?: BoundsIJK -): Array { +) { let iMin, iMax, jMin, jMax, kMin, kMax; let scalarData; @@ -72,36 +155,8 @@ export default function pointInShapeCallback( [[iMin, iMax], [jMin, jMax], [kMin, kMax]] = boundsIJK; } - const start = vec3.fromValues(iMin, jMin, kMin); - - const direction = imageData.getDirection(); - const rowCosines = direction.slice(0, 3); - const columnCosines = direction.slice(3, 6); - const scanAxisNormal = direction.slice(6, 9); - - const spacing = imageData.getSpacing(); - const [rowSpacing, columnSpacing, scanAxisSpacing] = spacing; - - // @ts-ignore will be fixed in vtk-master - const worldPosStart = imageData.indexToWorld(start); - - const rowStep = vec3.fromValues( - rowCosines[0] * rowSpacing, - rowCosines[1] * rowSpacing, - rowCosines[2] * rowSpacing - ); - - const columnStep = vec3.fromValues( - columnCosines[0] * columnSpacing, - columnCosines[1] * columnSpacing, - columnCosines[2] * columnSpacing - ); - - const scanAxisStep = vec3.fromValues( - scanAxisNormal[0] * scanAxisSpacing, - scanAxisNormal[1] * scanAxisSpacing, - scanAxisNormal[2] * scanAxisSpacing - ); + const indexToWorld = createPositionCallback(imageData); + const pointIJK = [0, 0, 0] as Types.Point3; const xMultiple = numComps || @@ -109,22 +164,21 @@ export default function pointInShapeCallback( const yMultiple = dimensions[0] * xMultiple; const zMultiple = dimensions[1] * yMultiple; - const pointsInShape: Array = []; - - const currentPos = vec3.clone(worldPosStart); - for (let k = kMin; k <= kMax; k++) { - const startPosJ = vec3.clone(currentPos); + pointIJK[2] = k; + const indexK = k * zMultiple; for (let j = jMin; j <= jMax; j++) { - const startPosI = vec3.clone(currentPos); + pointIJK[1] = j; + const indexJK = indexK + j * yMultiple; for (let i = iMin; i <= iMax; i++) { - const pointIJK: Types.Point3 = [i, j, k]; + pointIJK[0] = i; + const pointLPS = indexToWorld(pointIJK); // The current world position (pointLPS) is now in currentPos - if (pointInShapeFn(currentPos as Types.Point3, pointIJK)) { - const index = k * zMultiple + j * yMultiple + i * xMultiple; + if (pointInShapeFn(pointLPS, pointIJK)) { + const index = indexJK + i * xMultiple; let value; if (xMultiple > 2) { value = [ @@ -136,30 +190,9 @@ export default function pointInShapeCallback( value = scalarData[index]; } - pointsInShape.push({ - value, - index, - pointIJK, - pointLPS: currentPos.slice(), - }); - if (callback) { - callback({ value, index, pointIJK, pointLPS: currentPos }); - } + callback({ value, index, pointIJK, pointLPS }); } - - // Increment currentPos by rowStep for the next iteration - vec3.add(currentPos, currentPos, rowStep); } - - // Reset currentPos to the start of the next J line and increment by columnStep - vec3.copy(currentPos, startPosI); - vec3.add(currentPos, currentPos, columnStep); } - - // Reset currentPos to the start of the next K slice and increment by scanAxisStep - vec3.copy(currentPos, startPosJ); - vec3.add(currentPos, currentPos, scanAxisStep); } - - return pointsInShape; } diff --git a/packages/tools/src/utilities/segmentation/VolumetricCalculator.ts b/packages/tools/src/utilities/segmentation/VolumetricCalculator.ts new file mode 100644 index 0000000000..36932a7424 --- /dev/null +++ b/packages/tools/src/utilities/segmentation/VolumetricCalculator.ts @@ -0,0 +1,31 @@ +import { NamedStatistics } from '../../types'; +import { BasicStatsCalculator } from '../math/basic'; + +/** + * A basic stats calculator for volumetric data, generally for use with + * segmentations. + */ +export default class VolumetricCalculator extends BasicStatsCalculator { + public static getStatistics(options: { + spacing?: number; + unit?: string; + }): NamedStatistics { + const { spacing } = options; + // Get the basic units + const stats = BasicStatsCalculator.getStatistics(); + + // Add the volumetric units + const volumeUnit = spacing ? 'mm\xb3' : 'voxels\xb3'; + const volumeScale = spacing ? spacing[0] * spacing[1] * spacing[2] : 1; + + stats.volume = { + value: Array.isArray(stats.count.value) + ? stats.count.value.map((v) => v * volumeScale) + : stats.count.value * volumeScale, + unit: volumeUnit, + name: 'volume', + }; + stats.array.push(stats.volume); + return stats; + } +} diff --git a/packages/tools/src/utilities/segmentation/createLabelmapMemo.ts b/packages/tools/src/utilities/segmentation/createLabelmapMemo.ts new file mode 100644 index 0000000000..a5ca4a904f --- /dev/null +++ b/packages/tools/src/utilities/segmentation/createLabelmapMemo.ts @@ -0,0 +1,133 @@ +import { utilities } from '@cornerstonejs/core'; +import { triggerSegmentationDataModified } from '../../stateManagement/segmentation/triggerSegmentationEvents'; +import type { Types } from '@cornerstonejs/core'; +import { InitializedOperationData } from '../../tools/segmentation/strategies/BrushStrategy'; + +const { VoxelManager, RLEVoxelMap } = utilities; + +/** + * The labelmap memo state, extending from the base Memo state + */ +export type LabelmapMemo = Types.Memo & { + /** The base segmentation voxel manager */ + segmentationVoxelManager: Types.VoxelManager; + /** The history remembering voxel manager */ + voxelManager: Types.VoxelManager; + /** The redo and undo voxel managers */ + redoVoxelManager?: Types.VoxelManager; + undoVoxelManager?: Types.VoxelManager; + memo?: LabelmapMemo; +}; + +/** + * Creates a labelmap memo instance. Does not push it to the + * stack, which is handled externally. + */ +export function createLabelmapMemo( + segmentationId: string, + segmentationVoxelManager: Types.VoxelManager, + preview?: InitializedOperationData +) { + return preview + ? createPreviewMemo(segmentationId, preview) + : createRleMemo(segmentationId, segmentationVoxelManager); +} + +/** + * A restore memo function. This simply copies either the redo or the base + * voxel manager data to the segmentation state and triggers segmentation data + * modified. + */ +export function restoreMemo(isUndo?: boolean) { + const { segmentationVoxelManager, undoVoxelManager, redoVoxelManager } = this; + const useVoxelManager = + isUndo === false ? redoVoxelManager : undoVoxelManager; + useVoxelManager.forEach(({ value, pointIJK }) => { + segmentationVoxelManager.setAtIJKPoint(pointIJK, value); + }); + const slices = useVoxelManager.getArrayOfSlices(); + triggerSegmentationDataModified(this.segmentationId, slices); +} + +/** + * Creates an RLE memo state that stores additional changes to the voxel + * map. + */ +export function createRleMemo( + segmentationId: string, + segmentationVoxelManager: Types.VoxelManager +) { + const voxelManager = VoxelManager.createRLEHistoryVoxelManager( + segmentationVoxelManager + ); + const state = { + segmentationId, + restoreMemo, + commitMemo, + segmentationVoxelManager, + voxelManager, + }; + return state; +} + +/** + * Creates a preview memo. + */ +export function createPreviewMemo( + segmentationId: string, + preview: InitializedOperationData +) { + const { + memo: previewMemo, + segmentationVoxelManager, + previewVoxelManager, + } = preview; + + const state = { + segmentationId, + restoreMemo, + commitMemo, + segmentationVoxelManager, + voxelManager: previewVoxelManager, + memo: previewMemo, + preview, + }; + return state; +} + +/** + * This is a member function of a memo that causes the completion of the + * storage - that is, it copies the RLE data and creates a reverse RLE map + */ +function commitMemo() { + if (this.redoVoxelManager) { + return true; + } + if (!this.voxelManager.modifiedSlices.size) { + return false; + } + const { segmentationVoxelManager } = this; + const undoVoxelManager = VoxelManager.createRLEHistoryVoxelManager( + segmentationVoxelManager + ); + RLEVoxelMap.copyMap( + undoVoxelManager.map as Types.RLEVoxelMap, + this.voxelManager.map + ); + for (const key of this.voxelManager.modifiedSlices.keys()) { + undoVoxelManager.modifiedSlices.add(key); + } + this.undoVoxelManager = undoVoxelManager; + const redoVoxelManager = VoxelManager.createRLEVoxelManager( + this.segmentationVoxelManager.dimensions + ); + this.redoVoxelManager = redoVoxelManager; + undoVoxelManager.forEach(({ index, pointIJK, value }) => { + const currentValue = segmentationVoxelManager.getAtIJKPoint(pointIJK); + if (currentValue === value) { + return; + } + redoVoxelManager.setAtIndex(index, currentValue); + }); + return true; +} diff --git a/packages/tools/src/utilities/segmentation/floodFill.ts b/packages/tools/src/utilities/segmentation/floodFill.ts index 71f6151aeb..76e07bddd0 100644 --- a/packages/tools/src/utilities/segmentation/floodFill.ts +++ b/packages/tools/src/utilities/segmentation/floodFill.ts @@ -24,6 +24,10 @@ import { Types } from '@cornerstonejs/core'; * @param options.equals - An optional equality method for your datastructure. * Default is simply value1 = value2. * @param options.diagonals - Whether you allow flooding through diagonals. Defaults to false. + * @param options.bounds - An optional min/max value bounds in the form boundsIJK. Allows controlling + * the fill to a single plane. + * @param options.filter - An optional filter function to include/exclude points. + * If the filter returns false, then the point is excluded. * * @returns Flood fill results */ @@ -35,13 +39,14 @@ function floodFill( const onFlood = options.onFlood; const onBoundary = options.onBoundary; const equals = options.equals; + const filter = options.filter; const diagonals = options.diagonals || false; const startNode = get(seed); const permutations = prunedPermutations(); const stack = []; const flooded = []; const visits = new Set(); - const bounds = new Map(); + const bounds = options.bounds; stack.push({ currentArgs: seed }); @@ -51,7 +56,6 @@ function floodFill( return { flooded, - boundaries: boundaries(), }; function flood(job) { @@ -108,7 +112,7 @@ function floodFill( // Use an integer key value for checking visited, since JavaScript does not // provide a generic hash key indexed hash map. const iKey = x + 32768 + 65536 * (y + 32768 + 65536 * (z + 32768)); - bounds.set(iKey, prevArgs); + bounds?.set(iKey, prevArgs); if (onBoundary) { //@ts-ignore onBoundary(...prevArgs); @@ -123,6 +127,12 @@ function floodFill( for (let j = 0; j < getArgs.length; j += 1) { nextArgs[j] += perm[j]; } + if (filter?.(nextArgs) === false) { + continue; + } + if (visited(nextArgs)) { + continue; + } stack.push({ currentArgs: nextArgs, @@ -174,17 +184,16 @@ function floodFill( return perms; } - function boundaries() { + function boundaries(): Types.Point2[] | Types.Point3[] { + if (!bounds) { + throw new Error('bounds not recorded'); + } const array = Array.from(bounds.values()); array.reverse(); - return array; + return array as Types.Point2[] | Types.Point3[]; } } -function defaultEquals(a, b) { - return a === b; -} - function countNonZeroes(array) { let count = 0; diff --git a/packages/tools/src/utilities/segmentation/index.ts b/packages/tools/src/utilities/segmentation/index.ts index 1c58f1b675..40a370ecb9 100644 --- a/packages/tools/src/utilities/segmentation/index.ts +++ b/packages/tools/src/utilities/segmentation/index.ts @@ -14,10 +14,12 @@ import { getBrushThresholdForToolGroup, setBrushThresholdForToolGroup, } from './brushThresholdForToolGroup'; +import VolumetricCalculator from './VolumetricCalculator'; import thresholdSegmentationByRange from './thresholdSegmentationByRange'; import { createImageIdReferenceMap } from './createImageIdReferenceMap'; import contourAndFindLargestBidirectional from './contourAndFindLargestBidirectional'; import createBidirectionalToolData from './createBidirectionalToolData'; +import * as LabelmapMemo from './createLabelmapMemo'; import segmentContourAction from './segmentContourAction'; import { invalidateBrushCursor } from './invalidateBrushCursor'; import { getUniqueSegmentIndices } from './getUniqueSegmentIndices'; @@ -31,6 +33,7 @@ export { isValidRepresentationConfig, getDefaultRepresentationConfig, createLabelmapVolumeForViewport, + LabelmapMemo, rectangleROIThresholdVolumeByRange, triggerSegmentationRender, floodFill, @@ -38,6 +41,7 @@ export { setBrushSizeForToolGroup, getBrushThresholdForToolGroup, setBrushThresholdForToolGroup, + VolumetricCalculator, thresholdSegmentationByRange, createImageIdReferenceMap, contourAndFindLargestBidirectional, diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 9ccb61aa8c..4827253668 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -312,6 +312,10 @@ "name": "Segmentation Tools (Labelmap) - Brush, Scissors", "description": "Demonstrates how to use manual segmentation tools to modify the segmentation data" }, + "labelmapStatistics": { + "name": "Labelmap Statistics", + "description": "Show labelmap statistics" + }, "labelmapSegmentationDynamicThreshold": { "name": "Labelmap Segmentation Dynamic Threshold and Preview", "description": "Demonstrates how to use dynamic threshold with prevview to modify the segmentation data" diff --git a/utils/demo/helpers/contourTools.ts b/utils/demo/helpers/contourTools.ts index e176a92a4f..f906401c19 100644 --- a/utils/demo/helpers/contourTools.ts +++ b/utils/demo/helpers/contourTools.ts @@ -4,8 +4,11 @@ const { SplineContourSegmentationTool, LivewireContourSegmentationTool, PlanarFreehandContourSegmentationTool, + Enums, } = cornerstoneTools; +const { SegmentationRepresentations } = Enums; + const toolMap = new Map(); const interpolationConfiguration = { @@ -23,20 +26,24 @@ const interpolationConfiguration = { toolMap.set(PlanarFreehandContourSegmentationTool.toolName, { tool: PlanarFreehandContourSegmentationTool, + segmentationType: SegmentationRepresentations.Contour, }); toolMap.set(LivewireContourSegmentationTool.toolName, { tool: LivewireContourSegmentationTool, + segmentationType: SegmentationRepresentations.Contour, }); toolMap.set('CatmullRomSplineROI', { tool: SplineContourSegmentationTool, + segmentationType: SegmentationRepresentations.Contour, baseTool: SplineContourSegmentationTool.toolName, configuration: { splineType: SplineContourSegmentationTool.SplineTypes.CatmullRom, }, }); toolMap.set('LinearSplineROI', { + segmentationType: SegmentationRepresentations.Contour, baseTool: SplineContourSegmentationTool.toolName, configuration: { splineType: SplineContourSegmentationTool.SplineTypes.Linear, @@ -44,6 +51,7 @@ toolMap.set('LinearSplineROI', { }); toolMap.set('BSplineROI', { + segmentationType: SegmentationRepresentations.Contour, baseTool: SplineContourSegmentationTool.toolName, configuration: { splineType: SplineContourSegmentationTool.SplineTypes.BSpline, @@ -51,14 +59,17 @@ toolMap.set('BSplineROI', { }); toolMap.set('FreeformInterpolation', { + segmentationType: SegmentationRepresentations.Contour, baseTool: PlanarFreehandContourSegmentationTool.toolName, configuration: interpolationConfiguration, }); toolMap.set('SplineInterpolation', { + segmentationType: SegmentationRepresentations.Contour, baseTool: SplineContourSegmentationTool.toolName, configuration: interpolationConfiguration, }); toolMap.set('LivewireInterpolation', { + segmentationType: SegmentationRepresentations.Contour, baseTool: LivewireContourSegmentationTool.toolName, configuration: interpolationConfiguration, }); diff --git a/utils/demo/helpers/labelmapTools.ts b/utils/demo/helpers/labelmapTools.ts index e68b481e8c..35550888e7 100644 --- a/utils/demo/helpers/labelmapTools.ts +++ b/utils/demo/helpers/labelmapTools.ts @@ -22,6 +22,14 @@ const configuration = { useCenterSegmentIndex: true, }, }; + +const configurationNoPreview = { + preview: { enabled: false, previewColors }, + strategySpecificConfiguration: { + useCenterSegmentIndex: true, + }, +}; + const thresholdOptions = new Map(); thresholdOptions.set('Dynamic Radius 0', { isDynamic: true, dynamicRadius: 0 }); thresholdOptions.set('Dynamic Radius 1', { isDynamic: true, dynamicRadius: 1 }); @@ -57,10 +65,22 @@ toolMap.set('ThresholdCircle', { }, }); -toolMap.set('CircularBrush', { +toolMap.set('ThresholdSphere', { baseTool: BrushTool.toolName, configuration: { ...configuration, + activeStrategy: 'THRESHOLD_INSIDE_SPHERE_WITH_ISLAND_REMOVAL', + strategySpecificConfiguration: { + ...configuration.strategySpecificConfiguration, + THRESHOLD: { ...thresholdArgs }, + }, + }, +}); + +toolMap.set('CircularBrush', { + baseTool: BrushTool.toolName, + configuration: { + ...configurationNoPreview, activeStrategy: 'FILL_INSIDE_CIRCLE', }, }); @@ -68,7 +88,7 @@ toolMap.set('CircularBrush', { toolMap.set('CircularEraser', { baseTool: BrushTool.toolName, configuration: { - ...configuration, + ...configurationNoPreview, activeStrategy: 'ERASE_INSIDE_CIRCLE', }, }); @@ -76,28 +96,28 @@ toolMap.set('CircularEraser', { toolMap.set('SphereBrush', { baseTool: BrushTool.toolName, configuration: { - ...configuration, + ...configurationNoPreview, activeStrategy: 'FILL_INSIDE_SPHERE', }, }); toolMap.set('SphereEraser', { baseTool: BrushTool.toolName, configuration: { - ...configuration, + ...configurationNoPreview, activeStrategy: 'ERASE_INSIDE_SPHERE', }, }); toolMap.set(RectangleScissorsTool.toolName, { tool: RectangleScissorsTool }); toolMap.set(CircleScissorsTool.toolName, { tool: CircleScissorsTool }); toolMap.set(SphereScissorsTool.toolName, { tool: SphereScissorsTool }); -toolMap.set('ScissorsEraser', { +toolMap.set('SphereScissorsEraser', { baseTool: SphereScissorsTool.toolName, configuration: { - ...configuration, + ...configurationNoPreview, activeStrategy: 'ERASE_INSIDE', }, }); -toolMap.set(PaintFillTool.toolName, {}); +toolMap.set(PaintFillTool.toolName, { tool: PaintFillTool }); const labelmapTools = { toolMap, diff --git a/yarn.lock b/yarn.lock index 14c302c97b..832a455c7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2805,6 +2805,52 @@ resolved "https://registry.yarnpkg.com/@import-maps/resolve/-/resolve-1.0.1.tgz#1e9fcadcf23aa0822256a329aabca241879d37c9" integrity sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA== +"@ipld/car@^5.1.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@ipld/car/-/car-5.3.0.tgz#0e131ca660bdd3f1eb73517641d5c23840bf067a" + integrity sha512-OB8LVvJeVAFFGluNIkZeDZ/aGeoekFKsuIvNT9I5sJIb5WekQuW5+lekjQ7Z7mZ7DBKuke/kI4jBT1j0/akU1w== + dependencies: + "@ipld/dag-cbor" "^9.0.7" + cborg "^4.0.5" + multiformats "^13.0.0" + varint "^6.0.0" + +"@ipld/dag-cbor@^9.0.0", "@ipld/dag-cbor@^9.0.7": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@ipld/dag-cbor/-/dag-cbor-9.2.0.tgz#3a3f0bee02d7e1c2f15582e896843d5b00fbba9f" + integrity sha512-N14oMy0q4gM6OuZkIpisKe0JBSjf1Jb39VI+7jMLiWX9124u1Z3Fdj/Tag1NA0cVxxqWDh0CqsjcVfOKtelPDA== + dependencies: + cborg "^4.0.0" + multiformats "^13.1.0" + +"@ipld/dag-json@^10.0.1", "@ipld/dag-json@^10.1.7": + version "10.2.0" + resolved "https://registry.yarnpkg.com/@ipld/dag-json/-/dag-json-10.2.0.tgz#32468182ce510284aae75a07e33b3a0da284994e" + integrity sha512-O9YLUrl3d3WbVz7v1WkajFkyfOLEe2Fep+wor4fgVe0ywxzrivrj437NiPcVyB+2EDdFn/Q7tCHFf8YVhDf8ZA== + dependencies: + cborg "^4.0.0" + multiformats "^13.1.0" + +"@ipld/dag-pb@^4.0.0", "@ipld/dag-pb@^4.0.2": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ipld/dag-pb/-/dag-pb-4.1.0.tgz#4ebec92eeb9e8f317b8ef971221c6dac7b12b302" + integrity sha512-LJU451Drqs5zjFm7jI4Hs3kHlilOqkjcSfPiQgVsZnWaYb2C7YdfhnclrVn/X+ucKejlU9BL3+gXFCZUXkMuCg== + dependencies: + multiformats "^13.1.0" + +"@ipld/unixfs@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@ipld/unixfs/-/unixfs-3.0.0.tgz#7139755eedcf468d654d3c254d14cfcb647c70ba" + integrity sha512-Tj3/BPOlnemcZQ2ETIZAO8hqAs9KNzWyX5J9+JCL9jDwvYwjxeYjqJ3v+9DusNvTBmJhZnGVP6ijUHrsuOLp+g== + dependencies: + "@ipld/dag-pb" "^4.0.0" + "@multiformats/murmur3" "^2.1.3" + "@perma/map" "^1.0.2" + actor "^2.3.1" + multiformats "^13.0.1" + protobufjs "^7.1.2" + rabin-rs "^2.1.0" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -2838,6 +2884,25 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== +"@itk-wasm/dam@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@itk-wasm/dam/-/dam-1.1.1.tgz#794f01975cf8942315cc56261338787eb432dcab" + integrity sha512-7+9L3lrLMKF4y6B6qjs8GqfbpxT0waOJUM14NdMNEA6M+BoBS8fdHREhQHo2s7QMA5O7I+Jv7m+dyqlisGnbdQ== + dependencies: + axios "^1.4.0" + commander "^10.0.1" + decompress "^4.2.1" + files-from-path "^1.0.0" + ipfs-car "^1.0.0" + tar "^6.1.13" + +"@itk-wasm/morphological-contour-interpolation@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@itk-wasm/morphological-contour-interpolation/-/morphological-contour-interpolation-1.0.1.tgz#c2fcfdc593df85001276918e8ef684ba0b73a3c9" + integrity sha512-wxLB4nX6CiWpNQyTWC7oeFXogiZbtmSuLhyAtY66sM0SEnMoOcAuSX2+osPcOo13rfYnHLA02uQiICp8hvUGwA== + dependencies: + itk-wasm "1.0.0-b.165" + "@jest/console@^29.7.0": version "29.7.0" resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" @@ -3329,6 +3394,38 @@ resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz#c3ec604a0b54b9a9b87e9735dfc59e1a5da6a5fb" integrity sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug== +"@multiformats/blake2@^1.0.13": + version "1.0.13" + resolved "https://registry.yarnpkg.com/@multiformats/blake2/-/blake2-1.0.13.tgz#ab0c471da28df55eb7c16339e25ba9571259c2d7" + integrity sha512-T1Kzya0wjj85CaVeRSpJ858EnSvW1pw94GSitxYf84VsNdv5XYbJ6QG8y26Ft1bVALzrUCmqkQrR53QHSyu6RA== + dependencies: + blakejs "^1.1.1" + multiformats "^9.5.4" + +"@multiformats/murmur3@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@multiformats/murmur3/-/murmur3-1.1.3.tgz#70349166992e5f981f1ddff0200fa775b2bf6606" + integrity sha512-wAPLUErGR8g6Lt+bAZn6218k9YQPym+sjszsXL6o4zfxbA22P+gxWZuuD9wDbwL55xrKO5idpcuQUX7/E3oHcw== + dependencies: + multiformats "^9.5.4" + murmurhash3js-revisited "^3.0.0" + +"@multiformats/murmur3@^2.0.0", "@multiformats/murmur3@^2.1.0", "@multiformats/murmur3@^2.1.3": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@multiformats/murmur3/-/murmur3-2.1.8.tgz#81c1c15b6391109f3febfca4b3205196615a04e9" + integrity sha512-6vId1C46ra3R1sbJUOFCZnsUIveR9oF20yhPmAFxPm0JfrX3/ZRCgP3YDrBzlGoEppOXnA9czHeYc0T9mB6hbA== + dependencies: + multiformats "^13.0.0" + murmurhash3js-revisited "^3.0.0" + +"@multiformats/sha3@^2.0.15": + version "2.0.17" + resolved "https://registry.yarnpkg.com/@multiformats/sha3/-/sha3-2.0.17.tgz#bcfd2d6d7c44a61ced79a2c4dd45acd9ebbfb8d7" + integrity sha512-7ik6pk178qLO2cpNucgf48UnAOBMkq/2H92DP4SprZOJqM9zqbVaKS7XyYW6UvhRsDJ3wi921fYv1ihTtQHLtA== + dependencies: + js-sha3 "^0.8.0" + multiformats "^9.5.4" + "@netlify/binary-info@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@netlify/binary-info/-/binary-info-1.0.0.tgz#cd0d86fb783fb03e52067f0cd284865e57be86c8" @@ -4578,6 +4675,14 @@ node-addon-api "^3.2.1" node-gyp-build "^4.3.0" +"@perma/map@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@perma/map/-/map-1.0.3.tgz#c80021c9626276298c69a44dec6a4e041bbd47f3" + integrity sha512-Bf5njk0fnJGTFE2ETntq0N1oJ6YdCPIpTDn3R3KYZJQdeYSOCNL7mBrFlGnbqav8YQhJA/p81pvHINX9vAtHkQ== + dependencies: + "@multiformats/murmur3" "^2.1.0" + murmurhash3js-revisited "^3.0.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -5014,6 +5119,11 @@ dependencies: defer-to-connect "^2.0.1" +"@thewtex/zstddec@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@thewtex/zstddec/-/zstddec-0.2.0.tgz#a56ba37dac0a2c2dfc4481da231df3bf2fff8740" + integrity sha512-lIS+smrfa48WGlDVQSQSm0jBnwVp5XmfGJWU9q0J0fRFY9ohzK4s27Zg2SFMb1NWMp9RiANAdK+/q86EBGWR1Q== + "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -5158,6 +5268,11 @@ "@types/got" "^9" "@types/node" "*" +"@types/emscripten@^1.39.10": + version "1.39.10" + resolved "https://registry.yarnpkg.com/@types/emscripten/-/emscripten-1.39.10.tgz#da6e58a6171b46a41d3694f812d845d515c77e18" + integrity sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw== + "@types/emscripten@^1.39.6": version "1.39.9" resolved "https://registry.yarnpkg.com/@types/emscripten/-/emscripten-1.39.9.tgz#cbe73a8d153fc714a2e3177fbda2d7332d45efa7" @@ -5756,6 +5871,17 @@ resolved "https://registry.yarnpkg.com/@vscode/codicons/-/codicons-0.0.32.tgz#9e27de90d509c69762b073719ba3bf46c3cd2530" integrity sha512-3lgSTWhAzzWN/EPURoY4ZDBEA80OPmnaknNujA3qnI4Iu7AONWd9xF3iE4L+4prIe8E3TUnLQ4pxoaFTEEZNwg== +"@web3-storage/car-block-validator@^1.0.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@web3-storage/car-block-validator/-/car-block-validator-1.2.0.tgz#533ea6b22059606ed19e906434a50c0a16c07283" + integrity sha512-KKQ/M5WtpH/JlkX+bQYKzdG4azmSF495T7vpewje2xh7MBh1d94/BLblxCcLM/larWvXDxOkbAyTTdlECAAuUw== + dependencies: + "@multiformats/blake2" "^1.0.13" + "@multiformats/murmur3" "^1.1.3" + "@multiformats/sha3" "^2.0.15" + multiformats "9.9.0" + uint8arrays "^3.1.1" + "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" @@ -6003,6 +6129,11 @@ acorn@^8.1.0, acorn@^8.8.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== +actor@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/actor/-/actor-2.3.1.tgz#80ce158bb41338a0c38863bddf0947c1850b6e20" + integrity sha512-ST/3wnvcP2tKDXnum7nLCLXm+/rsf8vPocXH2Fre6D8FQwNkGDd4JEitBlXj007VQJfiGYRQvXqwOBZVi+JtRg== + add-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" @@ -6654,6 +6785,15 @@ axios@^1.0.0, axios@^1.1.3: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.4.0, axios@^1.6.2: + version "1.6.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" + integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== + dependencies: + follow-redirects "^1.15.4" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.1.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -6941,6 +7081,11 @@ bl@^5.0.0: inherits "^2.0.4" readable-stream "^3.4.0" +blakejs@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/blakejs/-/blakejs-1.2.1.tgz#5057e4206eadb4a97f7c0b6e197a505042fc3814" + integrity sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ== + bluebird@^3.5.5, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -7473,6 +7618,11 @@ catharsis@^0.9.0: dependencies: lodash "^4.17.15" +cborg@^4.0.0, cborg@^4.0.5: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cborg/-/cborg-4.1.0.tgz#6c194d0d315975c37b358501351e8fec1f99e864" + integrity sha512-hbWI4lRY0SdkTBbAH1STpY60rqR1gqGz4XaGZ6BXxncqCaAAOtmg2UNLA/6AJ8WG+p14J5P9t7Ul8f0u2ZLOhg== + ccount@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" @@ -7996,6 +8146,11 @@ commander@^10.0.0, commander@^10.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== +commander@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906" + integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== + commander@^2.20.0, commander@^2.8.1, commander@^2.9.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -9897,6 +10052,11 @@ err-code@^2.0.2: resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== +err-code@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/err-code/-/err-code-3.0.1.tgz#a444c7b992705f2b120ee320b09972eef331c920" + integrity sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -10989,6 +11149,13 @@ filenamify@^3.0.0: strip-outer "^1.0.0" trim-repeated "^1.0.0" +files-from-path@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/files-from-path/-/files-from-path-1.0.4.tgz#5652b6226a8080f76edbd3977f9e8df356a6d088" + integrity sha512-sMNIVdpRh1uCSIaat3qnM3E6aA1C5FVn5/B16z8sN3gIMjZPkxtVCorkEL07xTcCIxVwTXzjU1Ota7Wif6RfQQ== + dependencies: + graceful-fs "^4.2.10" + filesize@^8.0.6: version "8.0.7" resolved "https://registry.yarnpkg.com/filesize/-/filesize-8.0.7.tgz#695e70d80f4e47012c132d57a059e80c6b580bd8" @@ -11172,6 +11339,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.7, follow-redirects@^1.15.0, fol resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== +follow-redirects@^1.15.4: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -11325,6 +11497,15 @@ fs-extra@^11.1.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-extra@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" @@ -11747,7 +11928,7 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl once "^1.3.0" path-is-absolute "^1.0.0" -glob@^8.0.0, glob@^8.0.1, glob@^8.0.3: +glob@^8.0.0, glob@^8.0.1, glob@^8.0.3, glob@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== @@ -11987,6 +12168,14 @@ hammerjs@^2.0.8: resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" integrity sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ== +hamt-sharding@^3.0.0: + version "3.0.6" + resolved "https://registry.yarnpkg.com/hamt-sharding/-/hamt-sharding-3.0.6.tgz#3643107a3021af66ac95684aec87b196add5ba57" + integrity sha512-nZeamxfymIWLpVcAN0CRrb7uVq3hCOGj9IcL6NMA6VVCVWqj+h9Jo/SmaWuS92AEDf1thmHsM5D5c70hM3j2Tg== + dependencies: + sparse-array "^1.3.1" + uint8arrays "^5.0.1" + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -12753,6 +12942,19 @@ inquirer@^8.2.4: through "^2.3.6" wrap-ansi "^6.0.1" +interface-blockstore@^5.0.0: + version "5.2.10" + resolved "https://registry.yarnpkg.com/interface-blockstore/-/interface-blockstore-5.2.10.tgz#b01101dd70eda2ab713cc00a492921949934c861" + integrity sha512-9K48hTvBCGsKVD3pF4ILgDcf+W2P/gq0oxLcsHGB6E6W6nDutYkzR+7k7bCs9REHrBEfKzcVDEKieiuNM9WRZg== + dependencies: + interface-store "^5.0.0" + multiformats "^13.0.1" + +interface-store@^5.0.0: + version "5.1.8" + resolved "https://registry.yarnpkg.com/interface-store/-/interface-store-5.1.8.tgz#94bf867d165b5c904cccf09adeba215a5b0f459e" + integrity sha512-7na81Uxkl0vqk0CBPO5PvyTkdaJBaezwUJGsMOz7riPOq0rJt+7W31iaopaMICWea/iykUsvNlPx/Tc+MxC3/w== + internal-slot@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.6.tgz#37e756098c4911c5e912b8edbf71ed3aa116f930" @@ -12812,6 +13014,55 @@ ipaddr.js@^2.0.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.1.0.tgz#2119bc447ff8c257753b196fc5f1ce08a4cdf39f" integrity sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ== +ipfs-car@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/ipfs-car/-/ipfs-car-1.2.0.tgz#141f6e8dd398a5889ff7d6ec5b657a1ee0185334" + integrity sha512-A++1UesxqwfNv14NmFxr4MHi+vD9rR6SWr87MU9o0315Mzqys48pEefL8rlCAA9cw2qKYeT/ZPYVtqIMAr6U1Q== + dependencies: + "@ipld/car" "^5.1.0" + "@ipld/dag-cbor" "^9.0.0" + "@ipld/dag-json" "^10.0.1" + "@ipld/dag-pb" "^4.0.2" + "@ipld/unixfs" "^3.0.0" + "@web3-storage/car-block-validator" "^1.0.1" + files-from-path "^1.0.0" + ipfs-unixfs-exporter "^13.0.1" + multiformats "^13.0.1" + sade "^1.8.1" + varint "^6.0.0" + +ipfs-unixfs-exporter@^13.0.1: + version "13.5.0" + resolved "https://registry.yarnpkg.com/ipfs-unixfs-exporter/-/ipfs-unixfs-exporter-13.5.0.tgz#48fafb272489cc2bf05757c16f3f44fa241ee038" + integrity sha512-s1eWXzoyhQFNEAB1p+QE3adjhW+lBdgpORmmjiCLiruHs5z7T5zsAgRVcWpM8LWYhq2flRtJHObb7Hg73J+oLQ== + dependencies: + "@ipld/dag-cbor" "^9.0.0" + "@ipld/dag-json" "^10.1.7" + "@ipld/dag-pb" "^4.0.0" + "@multiformats/murmur3" "^2.0.0" + err-code "^3.0.1" + hamt-sharding "^3.0.0" + interface-blockstore "^5.0.0" + ipfs-unixfs "^11.0.0" + it-filter "^3.0.2" + it-last "^3.0.2" + it-map "^3.0.3" + it-parallel "^3.0.0" + it-pipe "^3.0.1" + it-pushable "^3.1.0" + multiformats "^13.0.0" + p-queue "^8.0.1" + progress-events "^1.0.0" + +ipfs-unixfs@^11.0.0: + version "11.1.3" + resolved "https://registry.yarnpkg.com/ipfs-unixfs/-/ipfs-unixfs-11.1.3.tgz#b53f36d8d34022516d6cfead4305839712c1dab2" + integrity sha512-sy6Koojwm/EcM8yvDlycRYA89C8wIcLcGTMMpqnCPUtqTCdl+JxsuPNCBgAu7tmO8Nipm7Tv7f0g/erxTGKKRA== + dependencies: + err-code "^3.0.1" + protons-runtime "^5.0.0" + uint8arraylist "^2.4.3" + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -13481,6 +13732,82 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" +it-filter@^3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/it-filter/-/it-filter-3.0.4.tgz#f8af5919ca7fc72f718edb3e7c0d71581aa149c6" + integrity sha512-e0sz+st4sudK/zH6GZ/gRTRP8A/ADuJFCYDmRgMbZvR79y5+v4ZXav850bBZk5wL9zXaYZFxS1v/6Qi+Vjwh5g== + dependencies: + it-peekable "^3.0.0" + +it-last@^3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/it-last/-/it-last-3.0.4.tgz#2b107f8032329bd896d2555abd9fc23c304695e8" + integrity sha512-Ns+KTsQWhs0KCvfv5X3Ck3lpoYxHcp4zUp4d+AOdmC8cXXqDuoZqAjfWhgCbxJubXyIYWdfE2nRcfWqgvZHP8Q== + +it-map@^3.0.3: + version "3.0.5" + resolved "https://registry.yarnpkg.com/it-map/-/it-map-3.0.5.tgz#30b1e1324cdb4aaadba29cd989485168d1dc4136" + integrity sha512-hB0TDXo/h4KSJJDSRLgAPmDroiXP6Fx1ck4Bzl3US9hHfZweTKsuiP0y4gXuTMcJlS6vj0bb+f70rhkD47ZA3w== + dependencies: + it-peekable "^3.0.0" + +it-merge@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/it-merge/-/it-merge-3.0.3.tgz#c7d407c8e0473accf7f9958ce2e0f60276002e84" + integrity sha512-FYVU15KC5pb/GQX1Ims+lee8d4pdqGVCpWr0lkNj8o4xuNo7jY71k6GuEiWdP+T7W1bJqewSxX5yoTy5yZpRVA== + dependencies: + it-pushable "^3.2.0" + +it-parallel@^3.0.0: + version "3.0.6" + resolved "https://registry.yarnpkg.com/it-parallel/-/it-parallel-3.0.6.tgz#d8f9efa56dac5f960545b3a148d2ca171694d228" + integrity sha512-i7UM7I9LTkDJw3YIqXHFAPZX6CWYzGc+X3irdNrVExI4vPazrJdI7t5OqrSVN8CONXLAunCiqaSV/zZRbQR56A== + dependencies: + p-defer "^4.0.0" + +it-peekable@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/it-peekable/-/it-peekable-3.0.3.tgz#5f5741f34f3acd5735804f40d198652c54a3d8c1" + integrity sha512-Wx21JX/rMzTEl9flx3DGHuPV1KQFGOl8uoKfQtmZHgPQtGb89eQ6RyVd82h3HuP9Ghpt0WgBDlmmdWeHXqyx7w== + +it-pipe@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/it-pipe/-/it-pipe-3.0.1.tgz#b25720df82f4c558a8532602b5fbc37bbe4e7ba5" + integrity sha512-sIoNrQl1qSRg2seYSBH/3QxWhJFn9PKYvOf/bHdtCBF0bnghey44VyASsWzn5dAx0DCDDABq1hZIuzKmtBZmKA== + dependencies: + it-merge "^3.0.0" + it-pushable "^3.1.2" + it-stream-types "^2.0.1" + +it-pushable@^3.1.0, it-pushable@^3.1.2, it-pushable@^3.2.0: + version "3.2.3" + resolved "https://registry.yarnpkg.com/it-pushable/-/it-pushable-3.2.3.tgz#e2b80aed90cfbcd54b620c0a0785e546d4e5f334" + integrity sha512-gzYnXYK8Y5t5b/BnJUr7glfQLO4U5vyb05gPx/TyTw+4Bv1zM9gFk4YsOrnulWefMewlphCjKkakFvj1y99Tcg== + dependencies: + p-defer "^4.0.0" + +it-stream-types@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/it-stream-types/-/it-stream-types-2.0.1.tgz#69cb4d7e79e707b8257a8997e02751ccb6c3af32" + integrity sha512-6DmOs5r7ERDbvS4q8yLKENcj6Yecr7QQTqWApbZdfAUTEC947d+PEha7PCqhm//9oxaLYL7TWRekwhoXl2s6fg== + +itk-wasm@1.0.0-b.165: + version "1.0.0-b.165" + resolved "https://registry.yarnpkg.com/itk-wasm/-/itk-wasm-1.0.0-b.165.tgz#a5ba3c6aec94fe371ac297ecd3b22a4f85cc6566" + integrity sha512-Rq0+AL2BuRqcFh9r3h69pB3nt84tw/gTxnI6GjIOYw5Bfah9qO+C8hRJpD8aWNBa5o3wNHRAMVlp0pt/L4K8gg== + dependencies: + "@itk-wasm/dam" "^1.1.1" + "@thewtex/zstddec" "^0.2.0" + "@types/emscripten" "^1.39.10" + axios "^1.6.2" + comlink "^4.4.1" + commander "^11.1.0" + fs-extra "^11.2.0" + glob "^8.1.0" + markdown-table "^3.0.3" + mime-types "^2.1.35" + wasm-feature-detect "^1.6.1" + jackspeak@^2.3.5: version "2.3.6" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" @@ -14003,6 +14330,11 @@ joycon@^3.0.1: resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== +js-sha3@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" + integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== + js-string-escape@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" @@ -15273,6 +15605,11 @@ markdown-it@^12.3.2: mdurl "^1.0.1" uc.micro "^1.0.5" +markdown-table@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" + integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== + marked@^4.0.10, marked@^4.0.16, marked@^4.2.12, marked@^4.2.4, marked@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" @@ -15516,7 +15853,7 @@ mime-types@2.1.18: dependencies: mime-db "~1.33.0" -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -15888,6 +16225,11 @@ move-file@^3.0.0: dependencies: path-exists "^5.0.0" +mri@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" + integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== + mrmime@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" @@ -15916,6 +16258,16 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" +multiformats@9.9.0, multiformats@^9.4.2, multiformats@^9.5.4: + version "9.9.0" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" + integrity sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg== + +multiformats@^13.0.0, multiformats@^13.0.1, multiformats@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.1.0.tgz#5aa9d2175108a448fc3bdb54ba8a3d0b6cab3ac3" + integrity sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ== + multimatch@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" @@ -15936,6 +16288,11 @@ multiparty@^4.2.1: safe-buffer "5.2.1" uid-safe "2.1.5" +murmurhash3js-revisited@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/murmurhash3js-revisited/-/murmurhash3js-revisited-3.0.0.tgz#6bd36e25de8f73394222adc6e41fa3fac08a5869" + integrity sha512-/sF3ee6zvScXMb1XFJ8gDsSnY+X8PbOyjIuBhtgis10W2Jx4ZjIhikUCIF9c4gpJxVnQIsPAFrSwTCuAjicP6g== + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -16949,6 +17306,11 @@ p-cancelable@^3.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" integrity sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw== +p-defer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-4.0.0.tgz#8082770aeeb10eb6b408abe91866738741ddd5d2" + integrity sha512-Vb3QRvQ0Y5XnF40ZUWW7JfLogicVh/EnA5gBIvKDJoYpeI82+1E3AlB9yOcKFS0AhHrWVnAQO39fbR0G99IVEQ== + p-event@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/p-event/-/p-event-2.3.1.tgz#596279ef169ab2c3e0cae88c1cfbb08079993ef6" @@ -17094,6 +17456,14 @@ p-queue@6.6.2: eventemitter3 "^4.0.4" p-timeout "^3.2.0" +p-queue@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-8.0.1.tgz#718b7f83836922ef213ddec263ff4223ce70bef8" + integrity sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA== + dependencies: + eventemitter3 "^5.0.1" + p-timeout "^6.1.2" + p-reduce@2.1.0, p-reduce@^2.0.0, p-reduce@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" @@ -17139,7 +17509,7 @@ p-timeout@^5.0.0, p-timeout@^5.0.2: resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-5.1.0.tgz#b3c691cf4415138ce2d9cfe071dba11f0fee085b" integrity sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew== -p-timeout@^6.0.0: +p-timeout@^6.0.0, p-timeout@^6.1.2: version "6.1.2" resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.2.tgz#22b8d8a78abf5e103030211c5fc6dee1166a6aa5" integrity sha512-UbD77BuZ9Bc9aABo74gfXhNvzC9Tx7SxtHSh1fxvx3jTLLYvmVhiQZZrJzqqU0jKbN32kb5VOKiLEQI/3bIjgQ== @@ -18521,6 +18891,11 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== +progress-events@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/progress-events/-/progress-events-1.0.0.tgz#34f5e8fdb5dae3561837b22672d1e02277bb2109" + integrity sha512-zIB6QDrSbPfRg+33FZalluFIowkbV5Xh1xSuetjG+rlC5he6u2dc6VQJ0TbMdlN3R1RHdpOqxEFMKTnQ+itUwA== + progress@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -18615,6 +18990,15 @@ protocols@^2.0.0, protocols@^2.0.1: resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.1.tgz#8f155da3fc0f32644e83c5782c8e8212ccf70a86" integrity sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q== +protons-runtime@^5.0.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/protons-runtime/-/protons-runtime-5.4.0.tgz#2751ce22cae6c35eebba89acfd9d783419ae3726" + integrity sha512-XfA++W/WlQOSyjUyuF5lgYBfXZUEMP01Oh1C2dSwZAlF2e/ZrMRPfWonXj6BGM+o8Xciv7w0tsRMKYwYEuQvaw== + dependencies: + uint8-varint "^2.0.2" + uint8arraylist "^2.4.3" + uint8arrays "^5.0.1" + proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -18808,6 +19192,11 @@ quote-unquote@^1.0.0: resolved "https://registry.yarnpkg.com/quote-unquote/-/quote-unquote-1.0.0.tgz#67a9a77148effeaf81a4d428404a710baaac8a0b" integrity sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg== +rabin-rs@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/rabin-rs/-/rabin-rs-2.1.0.tgz#87b4f2dea7ca69380f1fa6b9e476d99380e7dc96" + integrity sha512-5y72gAXPzIBsAMHcpxZP8eMDuDT98qMP1BqSDHRbHkJJXEgWIN1lA47LxUqzsK6jknOJtgfkQr9v+7qMlFDm6g== + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" @@ -19805,6 +20194,13 @@ rxjs@^7.5.4, rxjs@^7.5.5: dependencies: tslib "^2.1.0" +sade@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" + integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== + dependencies: + mri "^1.1.0" + safe-array-concat@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c" @@ -20532,6 +20928,11 @@ spark-md5@3.0.2: resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== +sparse-array@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/sparse-array/-/sparse-array-1.3.2.tgz#0e1a8b71706d356bc916fe754ff496d450ec20b0" + integrity sha512-ZT711fePGn3+kQyLuv1fpd3rNSkNF8vd5Kv2D+qnOANeyKs3fx6bUMGWRPvgTTcYV64QMqZKZwcuaQSP3AZ0tg== + spdx-correct@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c" @@ -21269,7 +21670,7 @@ tar@6.1.11: mkdirp "^1.0.3" yallist "^4.0.0" -tar@^6.1.11, tar@^6.1.2: +tar@^6.1.11, tar@^6.1.13, tar@^6.1.2: version "6.2.0" resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73" integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ== @@ -21921,6 +22322,35 @@ uid-safe@2.1.5: dependencies: random-bytes "~1.0.0" +uint8-varint@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/uint8-varint/-/uint8-varint-2.0.4.tgz#85be52b3849eb30f2c3640a2df8a14364180affb" + integrity sha512-FwpTa7ZGA/f/EssWAb5/YV6pHgVF1fViKdW8cWaEarjB8t7NyofSWBdOTyFPaGuUG4gx3v1O3PQ8etsiOs3lcw== + dependencies: + uint8arraylist "^2.0.0" + uint8arrays "^5.0.0" + +uint8arraylist@^2.0.0, uint8arraylist@^2.4.3: + version "2.4.8" + resolved "https://registry.yarnpkg.com/uint8arraylist/-/uint8arraylist-2.4.8.tgz#5a4d17f4defd77799cb38e93fd5db0f0dceddc12" + integrity sha512-vc1PlGOzglLF0eae1M8mLRTBivsvrGsdmJ5RbK3e+QRvRLOZfZhQROTwH/OfyF3+ZVUg9/8hE8bmKP2CvP9quQ== + dependencies: + uint8arrays "^5.0.1" + +uint8arrays@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-3.1.1.tgz#2d8762acce159ccd9936057572dade9459f65ae0" + integrity sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg== + dependencies: + multiformats "^9.4.2" + +uint8arrays@^5.0.0, uint8arrays@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-5.0.2.tgz#f05479bcd521d37c2e7710b24132a460b0ac80e3" + integrity sha512-S0GaeR+orZt7LaqzTRs4ZP8QqzAauJ+0d4xvP2lJTA99jIkKsE2FgDs4tGF/K/z5O9I/2W5Yvrh7IuqNeYH+0Q== + dependencies: + multiformats "^13.0.0" + ulid@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/ulid/-/ulid-2.3.0.tgz#93063522771a9774121a84d126ecd3eb9804071f" @@ -22426,6 +22856,11 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== +varint@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/varint/-/varint-6.0.0.tgz#9881eb0ce8feaea6512439d19ddf84bf551661d0" + integrity sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -22522,6 +22957,11 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +wasm-feature-detect@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/wasm-feature-detect/-/wasm-feature-detect-1.6.1.tgz#21c7c35f9b233d71d2948d4a8b3e2098c452b940" + integrity sha512-R1i9ED8UlLu/foILNB1ck9XS63vdtqU/tP1MCugVekETp/ySCrBZRk5I/zI67cI1wlQYeSonNm1PLjDHZDNg6g== + watchpack@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"