From f94075470e45fcfa22c50b87f0259fbabc3023de Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 20 Dec 2024 23:47:55 +0100 Subject: [PATCH] feat(LabelTool): add label tool fixes #1716 --- packages/core/src/utilities/windowLevel.ts | 4 +- .../dynamicallyAddAnnotations/index.ts | 2 + .../dynamicallyAddAnnotations/labelToolUI.ts | 81 +++ .../toolSpecificUI.ts | 4 + packages/tools/src/index.ts | 2 + .../tools/src/tools/annotation/LabelTool.ts | 539 ++++++++++++++++++ packages/tools/src/tools/index.ts | 2 + .../src/types/ToolSpecificAnnotationTypes.ts | 9 + 8 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 packages/tools/examples/dynamicallyAddAnnotations/labelToolUI.ts create mode 100644 packages/tools/src/tools/annotation/LabelTool.ts diff --git a/packages/core/src/utilities/windowLevel.ts b/packages/core/src/utilities/windowLevel.ts index 70b12a8b01..a410dfada5 100644 --- a/packages/core/src/utilities/windowLevel.ts +++ b/packages/core/src/utilities/windowLevel.ts @@ -30,8 +30,8 @@ function toWindowLevel( * * LINEAR (default): * - Uses the DICOM standard formula from C.11.2.1.2.1: - * if x <= c - 0.5 - (w-1)/2 => lower bound - * if x > c - 0.5 + (w-1)/2 => upper bound + * if x {'<='} c - 0.5 - (w-1)/2 {'=>'} lower bound + * if x {'<'} c - 0.5 + (w-1)/2 {'=>'} upper bound * * LINEAR_EXACT (C.11.2.1.3.2): * - Uses: diff --git a/packages/tools/examples/dynamicallyAddAnnotations/index.ts b/packages/tools/examples/dynamicallyAddAnnotations/index.ts index b7f817a566..721dfe1ec5 100644 --- a/packages/tools/examples/dynamicallyAddAnnotations/index.ts +++ b/packages/tools/examples/dynamicallyAddAnnotations/index.ts @@ -17,6 +17,7 @@ import { ArrowAnnotateTool, CircleROITool, EllipticalROITool, + LabelTool, LengthTool, ProbeTool, RectangleROITool, @@ -32,6 +33,7 @@ console.debug( ); const tools = [ + LabelTool, AngleTool, ArrowAnnotateTool, EllipticalROITool, diff --git a/packages/tools/examples/dynamicallyAddAnnotations/labelToolUI.ts b/packages/tools/examples/dynamicallyAddAnnotations/labelToolUI.ts new file mode 100644 index 0000000000..dda254b055 --- /dev/null +++ b/packages/tools/examples/dynamicallyAddAnnotations/labelToolUI.ts @@ -0,0 +1,81 @@ +import { getEnabledElementByViewportId, utilities } from '@cornerstonejs/core'; +import type { Point2 } from '@cornerstonejs/core/types'; +import { LabelTool } from '@cornerstonejs/tools'; +import { typeToIdMap, typeToStartIdMap, typeToEndIdMap } from './constants'; + +function getInputValue(form: HTMLFormElement, inputId: string): number { + return Number((form.querySelector(`#${inputId}`) as HTMLInputElement).value); +} + +function getCoordinates( + form: HTMLFormElement, + type: 'canvas' | 'image' +): Point2 { + const position: Point2 = [ + getInputValue(form, `${typeToStartIdMap[type]}-1`), + getInputValue(form, `${typeToStartIdMap[type]}-2`), + ]; + return position; +} + +function createFormElement(): HTMLFormElement { + const form = document.createElement('form'); + form.style.marginBottom = '10px'; + + ['canvas', 'image'].forEach((coordType) => { + form.innerHTML += ` + + + + + +
+ + +

+ `; + }); + + return form; +} + +function addButtonListeners(form: HTMLFormElement): void { + const buttons = form.querySelectorAll('button'); + buttons.forEach((button) => { + button.addEventListener('click', () => { + const [type, viewportType] = button.id.split('-') as [ + 'canvas' | 'image', + keyof typeof typeToIdMap + ]; + const enabledElement = getEnabledElementByViewportId( + typeToIdMap[viewportType] + ); + const viewport = enabledElement.viewport; + const coords = getCoordinates(form, type); + const textInput = form.querySelector(`#${type}-text`) as HTMLInputElement; + const text = textInput ? textInput.value : ''; + const currentImageId = viewport.getCurrentImageId() as string; + + const position = + type === 'image' + ? utilities.imageToWorldCoords(currentImageId, coords) + : viewport.canvasToWorld(coords); + + console.log('Adding label at:', position); + + LabelTool.hydrate(viewport.id, position, text); + }); + }); +} + +export function createLabelToolUI(): HTMLFormElement { + const form = createFormElement(); + addButtonListeners(form); + return form; +} diff --git a/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts b/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts index 250440311d..a098a345f4 100644 --- a/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts +++ b/packages/tools/examples/dynamicallyAddAnnotations/toolSpecificUI.ts @@ -6,6 +6,7 @@ import { createProbeToolUI } from './probeToolUI'; import { createRectangleROIToolUI } from './rectangleROIToolUI'; import { createCircleROIToolUI } from './circleROIToolUI'; import { createSplineROIToolUI } from './splineROIToolUI'; +import { createLabelToolUI } from './labelToolUI'; interface ToolUIConfig { toolName: string; @@ -31,6 +32,9 @@ function createToolUI(toolName: string, config: ToolUIConfig): ToolUI | null { case 'EllipticalROI': forms = [createEllipseROIToolUI()]; break; + case 'Label': + forms = [createLabelToolUI()]; + break; case 'Length': forms = [createLengthToolUI()]; break; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 0692f37e75..515fd98909 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -32,6 +32,7 @@ import { StackScrollTool, PlanarRotateTool, MIPJumpToClickTool, + LabelTool, LengthTool, HeightTool, ProbeTool, @@ -106,6 +107,7 @@ export { PlanarRotateTool, MIPJumpToClickTool, // Annotation Tools + LabelTool, LengthTool, HeightTool, CrosshairsTool, diff --git a/packages/tools/src/tools/annotation/LabelTool.ts b/packages/tools/src/tools/annotation/LabelTool.ts new file mode 100644 index 0000000000..6ee0a12513 --- /dev/null +++ b/packages/tools/src/tools/annotation/LabelTool.ts @@ -0,0 +1,539 @@ +import { Events } from '../../enums'; +import { + getEnabledElement, + utilities as csUtils, + getEnabledElementByViewportId, +} from '@cornerstonejs/core'; +import type { Types } from '@cornerstonejs/core'; + +import { AnnotationTool } from '../base'; +import { + addAnnotation, + getAnnotations, + removeAnnotation, +} from '../../stateManagement/annotation/annotationState'; + +import { drawTextBox as drawTextBoxSvg } from '../../drawingSvg'; +import { state } from '../../store/state'; +import { getViewportIdsWithToolToRender } from '../../utilities/viewportFilters'; +import triggerAnnotationRenderForViewportIds from '../../utilities/triggerAnnotationRenderForViewportIds'; +import { + triggerAnnotationCompleted, + triggerAnnotationModified, +} from '../../stateManagement/annotation/helpers/state'; + +import { + resetElementCursor, + hideElementCursor, +} from '../../cursors/elementCursor'; + +import type { + EventTypes, + PublicToolProps, + ToolProps, + SVGDrawingHelper, + Annotation, +} from '../../types'; +import type { LabelAnnotation } from '../../types/ToolSpecificAnnotationTypes'; +import type { StyleSpecifier } from '../../types/AnnotationStyle'; + +class LabelTool extends AnnotationTool { + static toolName; + + editData: { + annotation: Annotation; + viewportIdsToRender: string[]; + newAnnotation?: boolean; + hasMoved?: boolean; + } | null; + isDrawing: boolean; + isHandleOutsideImage: boolean; + + constructor( + toolProps: PublicToolProps = {}, + defaultToolProps: ToolProps = { + supportedInteractionTypes: ['Mouse', 'Touch'], + configuration: { + shadow: true, + getTextCallback, + changeTextCallback, + preventHandleOutsideImage: false, + }, + } + ) { + super(toolProps, defaultToolProps); + } + + // Not necessary for this tool but needs to be defined since it's an abstract + // method from the parent class. + isPointNearTool(): boolean { + return false; + } + + static hydrate = ( + viewportId: string, + position: Types.Point3, + text: string, + options?: { + annotationUID?: string; + } + ): LabelAnnotation => { + const enabledElement = getEnabledElementByViewportId(viewportId); + if (!enabledElement) { + return; + } + const { viewport } = enabledElement; + const FrameOfReferenceUID = viewport.getFrameOfReferenceUID(); + + const { viewPlaneNormal, viewUp } = viewport.getCamera(); + + // This is a workaround to access the protected method getReferencedImageId + // we should make those static too + const instance = new this(); + + const referencedImageId = instance.getReferencedImageId( + viewport, + position, + viewPlaneNormal, + viewUp + ); + + const annotation = { + annotationUID: options?.annotationUID || csUtils.uuidv4(), + data: { + text, + handles: { + points: [position], + }, + }, + highlighted: false, + autoGenerated: false, + invalidated: false, + isLocked: false, + isVisible: true, + metadata: { + toolName: instance.getToolName(), + viewPlaneNormal, + FrameOfReferenceUID, + referencedImageId, + ...options, + }, + }; + + addAnnotation(annotation, viewport.element); + + triggerAnnotationRenderForViewportIds([viewport.id]); + }; + + /** + * Based on the current position of the mouse and the current imageId to create + * a Length Annotation and stores it in the annotationManager + * + * @param evt - EventTypes.NormalizedMouseEventType + * @returns The annotation object. + * + */ + addNewAnnotation = ( + evt: EventTypes.InteractionEventType + ): LabelAnnotation => { + const eventDetail = evt.detail; + const { currentPoints, element } = eventDetail; + const worldPos = currentPoints.world; + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + + hideElementCursor(element); + this.isDrawing = true; + + const camera = viewport.getCamera(); + const { viewPlaneNormal, viewUp } = camera; + + const referencedImageId = this.getReferencedImageId( + viewport, + worldPos, + viewPlaneNormal, + viewUp + ); + + const FrameOfReferenceUID = viewport.getFrameOfReferenceUID(); + + const annotation = { + annotationUID: null as string, + highlighted: true, + invalidated: true, + metadata: { + toolName: this.getToolName(), + viewPlaneNormal: [...viewPlaneNormal], + viewUp: [...viewUp], + FrameOfReferenceUID, + referencedImageId, + ...viewport.getViewReference({ points: [worldPos] }), + }, + data: { + text: '', + handles: { + points: [[...worldPos], [...worldPos]], + }, + label: '', + }, + }; + + addAnnotation(annotation, element); + + const viewportIdsToRender = getViewportIdsWithToolToRender( + element, + this.getToolName() + ); + + evt.preventDefault(); + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + console.log('Annotation added:', annotation); + this.configuration.getTextCallback((text) => { + if (!text) { + removeAnnotation(annotation.annotationUID); + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + this.isDrawing = false; + return; + } + annotation.data.text = text; + + triggerAnnotationCompleted(annotation); + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + }); + + this.createMemo(element, annotation, { newAnnotation: true }); + + return annotation; + }; + + toolSelectedCallback() {} + + handleSelectedCallback( + evt: EventTypes.InteractionEventType, + annotation: LabelAnnotation + ): void { + const eventDetail = evt.detail; + const { element } = eventDetail; + + annotation.highlighted = true; + + const viewportIdsToRender = getViewportIdsWithToolToRender( + element, + this.getToolName() + ); + + // Find viewports to render on drag. + + this.editData = { + //handle, // This would be useful for other tools with more than one handle + annotation, + viewportIdsToRender, + }; + this._activateModify(element); + + hideElementCursor(element); + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + + evt.preventDefault(); + } + + _endCallback = (evt: EventTypes.InteractionEventType): void => { + const eventDetail = evt.detail; + const { element } = eventDetail; + + const { annotation, viewportIdsToRender, newAnnotation } = this.editData; + + const { viewportId, renderingEngine } = getEnabledElement(element); + + this._deactivateModify(element); + + resetElementCursor(element); + + if (newAnnotation) { + this.createMemo(element, annotation, { newAnnotation }); + } + + this.editData = null; + this.isDrawing = false; + this.doneEditMemo(); + + if ( + this.isHandleOutsideImage && + this.configuration.preventHandleOutsideImage + ) { + removeAnnotation(annotation.annotationUID); + } + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + + if (newAnnotation) { + triggerAnnotationCompleted(annotation); + } + }; + + _dragCallback = (evt: EventTypes.InteractionEventType): void => {}; + + _doneChangingTextCallback(element, annotation, updatedText): void { + annotation.data.text = updatedText; + + const enabledElement = getEnabledElement(element); + const { renderingEngine } = enabledElement; + + const viewportIdsToRender = getViewportIdsWithToolToRender( + element, + this.getToolName() + ); + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + + // Dispatching annotation modified + triggerAnnotationModified(annotation, element); + } + + cancel = (element: HTMLDivElement) => { + // If it is mid-draw or mid-modify + if (this.isDrawing) { + this.isDrawing = false; + this._deactivateModify(element); + resetElementCursor(element); + + const { annotation, viewportIdsToRender, newAnnotation } = this.editData; + const { data } = annotation; + + annotation.highlighted = false; + data.handles.activeHandleIndex = null; + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + + if (newAnnotation) { + triggerAnnotationCompleted(annotation); + } + + this.editData = null; + return annotation.annotationUID; + } + }; + + _activateModify = (element: HTMLDivElement) => { + state.isInteractingWithTool = true; + + element.addEventListener( + Events.MOUSE_UP, + this._endCallback as EventListener + ); + element.addEventListener( + Events.MOUSE_DRAG, + this._dragCallback as EventListener + ); + element.addEventListener( + Events.MOUSE_CLICK, + this._endCallback as EventListener + ); + + element.addEventListener( + Events.TOUCH_TAP, + this._endCallback as EventListener + ); + element.addEventListener( + Events.TOUCH_END, + this._endCallback as EventListener + ); + element.addEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + }; + + _deactivateModify = (element: HTMLDivElement) => { + state.isInteractingWithTool = false; + + element.removeEventListener( + Events.MOUSE_UP, + this._endCallback as EventListener + ); + element.removeEventListener( + Events.MOUSE_DRAG, + this._dragCallback as EventListener + ); + element.removeEventListener( + Events.MOUSE_CLICK, + this._endCallback as EventListener + ); + + element.removeEventListener( + Events.TOUCH_TAP, + this._endCallback as EventListener + ); + element.removeEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + element.removeEventListener( + Events.TOUCH_END, + this._endCallback as EventListener + ); + }; + + _activateDraw = (element: HTMLDivElement) => { + state.isInteractingWithTool = true; + + element.addEventListener( + Events.MOUSE_UP, + this._endCallback as EventListener + ); + element.addEventListener( + Events.MOUSE_DRAG, + this._dragCallback as EventListener + ); + element.addEventListener( + Events.MOUSE_MOVE, + this._dragCallback as EventListener + ); + element.addEventListener( + Events.MOUSE_CLICK, + this._endCallback as EventListener + ); + + element.addEventListener( + Events.TOUCH_TAP, + this._endCallback as EventListener + ); + element.addEventListener( + Events.TOUCH_END, + this._endCallback as EventListener + ); + element.addEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + }; + + _deactivateDraw = (element: HTMLDivElement) => { + state.isInteractingWithTool = false; + + element.removeEventListener( + Events.MOUSE_UP, + this._endCallback as EventListener + ); + element.removeEventListener( + Events.MOUSE_DRAG, + this._dragCallback as EventListener + ); + element.removeEventListener( + Events.MOUSE_MOVE, + this._dragCallback as EventListener + ); + element.removeEventListener( + Events.MOUSE_CLICK, + this._endCallback as EventListener + ); + + element.removeEventListener( + Events.TOUCH_TAP, + this._endCallback as EventListener + ); + element.removeEventListener( + Events.TOUCH_END, + this._endCallback as EventListener + ); + element.removeEventListener( + Events.TOUCH_DRAG, + this._dragCallback as EventListener + ); + }; + + /** + * it is used to draw the length annotation in each + * request animation frame. It calculates the updated cached statistics if + * data is invalidated and cache it. + * + * @param enabledElement - The Cornerstone's enabledElement. + * @param svgDrawingHelper - The svgDrawingHelper providing the context for drawing. + */ + renderAnnotation = ( + enabledElement: Types.IEnabledElement, + svgDrawingHelper: SVGDrawingHelper + ): boolean => { + let renderStatus = false; + const { viewport } = enabledElement; + const { element } = viewport; + + let annotations = getAnnotations(this.getToolName(), element); + + // Todo: We don't need this anymore, filtering happens in triggerAnnotationRender + if (!annotations?.length) { + return renderStatus; + } + + annotations = this.filterInteractableAnnotationsForElement( + element, + annotations + ); + + const styleSpecifier: StyleSpecifier = { + toolGroupId: this.toolGroupId, + toolName: this.getToolName(), + viewportId: enabledElement.viewport.id, + }; + + // Draw SVG + for (let i = 0; i < annotations.length; i++) { + const annotation = annotations[i] as LabelAnnotation; + const { annotationUID, data } = annotation; + const point = data.handles.points[0]; + + styleSpecifier.annotationUID = annotationUID; + + const canvasCoordinates = viewport.worldToCanvas(point); + + renderStatus = true; + + // If rendering engine has been destroyed while rendering + if (!viewport.getRenderingEngine()) { + console.warn('Rendering Engine has been destroyed'); + return renderStatus; + } + + if (!data.text) { + continue; + } + + const options = this.getLinkedTextBoxStyle(styleSpecifier, annotation); + + const textBoxUID = '1'; + drawTextBoxSvg( + svgDrawingHelper, + annotationUID, + textBoxUID, + [data.text], + canvasCoordinates, + { + ...options, + padding: 0, + } + ); + } + + return renderStatus; + }; + + _isInsideVolume(index1, index2, dimensions) { + return ( + csUtils.indexWithinDimensions(index1, dimensions) && + csUtils.indexWithinDimensions(index2, dimensions) + ); + } +} + +function getTextCallback(doneChangingTextCallback) { + return doneChangingTextCallback(prompt('Enter your annotation:')); +} + +function changeTextCallback(data, eventData, doneChangingTextCallback) { + return doneChangingTextCallback(prompt('Enter your annotation:')); +} + +LabelTool.toolName = 'Label'; +export default LabelTool; diff --git a/packages/tools/src/tools/index.ts b/packages/tools/src/tools/index.ts index 4d8e26afb2..8add0eafee 100644 --- a/packages/tools/src/tools/index.ts +++ b/packages/tools/src/tools/index.ts @@ -20,6 +20,7 @@ import VolumeRotateTool from './VolumeRotateTool'; // Annotation tools import BidirectionalTool from './annotation/BidirectionalTool'; +import LabelTool from './annotation/LabelTool'; import LengthTool from './annotation/LengthTool'; import HeightTool from './annotation/HeightTool'; import ProbeTool from './annotation/ProbeTool'; @@ -81,6 +82,7 @@ export { OverlayGridTool, SegmentationIntersectionTool, BidirectionalTool, + LabelTool, LengthTool, HeightTool, ProbeTool, diff --git a/packages/tools/src/types/ToolSpecificAnnotationTypes.ts b/packages/tools/src/types/ToolSpecificAnnotationTypes.ts index 30b3b1947e..c8a73f2d93 100644 --- a/packages/tools/src/types/ToolSpecificAnnotationTypes.ts +++ b/packages/tools/src/types/ToolSpecificAnnotationTypes.ts @@ -358,6 +358,15 @@ export interface ArrowAnnotation extends Annotation { }; } +export interface LabelAnnotation extends Annotation { + data: { + text: string; + handles: { + points: Types.Point3[]; + }; + }; +} + export interface AngleAnnotation extends Annotation { data: { handles: {