diff --git a/common/reviews/api/tools.api.md b/common/reviews/api/tools.api.md index 120457b08e..bce337c5ef 100644 --- a/common/reviews/api/tools.api.md +++ b/common/reviews/api/tools.api.md @@ -51,6 +51,9 @@ function addSegmentations(segmentationInputArray: SegmentationPublicInput[]): vo // @public (undocumented) export function addTool(ToolClass: any): void; +// @public (undocumented) +function addToolState(element: HTMLDivElement, data: CINETypes.ToolData): void; + // @public (undocumented) interface AngleAnnotation extends Annotation { // (undocumented) @@ -552,6 +555,23 @@ export function cancelActiveManipulations(element: HTMLDivElement): string | und // @public (undocumented) function checkAndDefineIsLockedProperty(annotation: Annotation): void; +declare namespace cine { + export { + playClip, + stopClip, + Events_2 as Events, + getToolState, + addToolState + } +} + +declare namespace CINETypes { + export { + PlayClipOptions, + ToolData + } +} + // @public (undocumented) export class CircleScissorsTool extends BaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); @@ -1375,6 +1395,14 @@ enum Events { SEGMENTATION_REPRESENTATION_REMOVED = "CORNERSTONE_TOOLS_SEGMENTATION_REPRESENTATION_REMOVED" } +// @public (undocumented) +enum Events_2 { + // (undocumented) + CLIP_STARTED = "CORNERSTONE_CINE_TOOL_STARTED", + // (undocumented) + CLIP_STOPPED = "CORNERSTONE_CINE_TOOL_STOPPED" +} + declare namespace EventTypes { export { CameraModifiedEventDetail, @@ -1642,6 +1670,9 @@ function getToolGroupSpecificConfig_2(toolGroupId: string): SegmentationRepresen // @public (undocumented) function getToolGroupsWithSegmentation(segmentationId: string): string[]; +// @public (undocumented) +function getToolState(element: HTMLDivElement): CINETypes.ToolData | undefined; + // @public (undocumented) function getViewportIdsWithToolToRender(element: HTMLDivElement, toolName: string, requireSameOrientation?: boolean): string[]; @@ -2794,6 +2825,18 @@ export class PlanarFreehandROITool extends AnnotationTool { // @public type Plane = [number, number, number, number]; +// @public (undocumented) +function playClip(element: HTMLDivElement, playClipOptions: CINETypes.PlayClipOptions): void; + +// @public (undocumented) +type PlayClipOptions = { + framesPerSecond?: number; + frameTimeVector?: number[]; + reverse?: boolean; + loop?: boolean; + frameTimeVectorSpeedMultiplier?: number; +}; + // @public type Point2 = [number, number]; @@ -3622,6 +3665,9 @@ declare namespace state_2 { } } +// @public (undocumented) +function stopClip(element: HTMLDivElement): void; + // @public (undocumented) type StyleConfig = { annotations?: { @@ -3736,6 +3782,28 @@ function throttle(func: Function, wait?: number, options?: { trailing?: boolean; }): Function; +// @public (undocumented) +interface ToolData { + // (undocumented) + framesPerSecond: number; + // (undocumented) + frameTimeVector: number[] | undefined; + // (undocumented) + ignoreFrameTimeVector: boolean; + // (undocumented) + intervalId: number | undefined; + // (undocumented) + lastFrameTimeStamp: number | undefined; + // (undocumented) + loop: boolean; + // (undocumented) + reverse: boolean; + // (undocumented) + speed: number; + // (undocumented) + usingFrameTimeVector: boolean; +} + declare namespace ToolGroupManager { export { createToolGroup, @@ -3897,7 +3965,8 @@ declare namespace Types { LabelmapTypes, SVGCursorDescriptor, SVGPoint_2 as SVGPoint, - ScrollOptions_2 as ScrollOptions + ScrollOptions_2 as ScrollOptions, + CINETypes } } export { Types } @@ -3925,7 +3994,8 @@ declare namespace utilities { pointInSurroundingSphereCallback, getAnnotationNearPoint, getAnnotationNearPointOnEnabledElement, - jumpToSlice + jumpToSlice, + cine } } export { utilities } diff --git a/packages/tools/examples/CINETool/index.ts b/packages/tools/examples/CINETool/index.ts new file mode 100644 index 0000000000..6712879eaa --- /dev/null +++ b/packages/tools/examples/CINETool/index.ts @@ -0,0 +1,179 @@ +import { + RenderingEngine, + Types, + Enums, + utilities as csUtils, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addButtonToToolbar, + addSliderToToolbar, +} 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 { + WindowLevelTool, + PanTool, + ZoomTool, + ToolGroupManager, + Enums: csToolsEnums, + utilities: csToolsUtilities, +} = cornerstoneTools; + +const { ViewportType } = Enums; +const { MouseBindings } = csToolsEnums; + +// ======== Set up page ======== // +setTitleAndDescription('CINE Tool', 'Show the usage of the CINE Tool.'); + +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 instructions = document.createElement('p'); +instructions.innerText = ` + - Click on Play Clip to start the CINE tool + - Click on Stop Clip to stop the CINE tool + - Drag the frame slider to change the frames per second rate +`; + +content.append(instructions); +// ============================= // + +const toolGroupId = 'STACK_TOOL_GROUP_ID'; +let framesPerSecond = 24; + +addButtonToToolbar({ + title: 'Play Clip', + onClick: () => { + csToolsUtilities.cine.playClip(element, { framesPerSecond }); + }, +}); + +addButtonToToolbar({ + title: 'Stop Clip', + onClick: () => { + csToolsUtilities.cine.stopClip(element); + }, +}); + +addSliderToToolbar({ + title: `Frame per second`, + range: [1, 100], + defaultValue: framesPerSecond, + onSelectedValueChange: (value) => { + csToolsUtilities.cine.stopClip(element); + framesPerSecond = Number(value); + csToolsUtilities.cine.playClip(element, { framesPerSecond }); + }, + updateLabelOnChange: (value, label) => { + label.innerText = `Frames per second: ${value}`; + }, +}); +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + // Add tools to Cornerstone3D + cornerstoneTools.addTool(WindowLevelTool); + cornerstoneTools.addTool(PanTool); + cornerstoneTools.addTool(ZoomTool); + + // Define a tool group, which defines how mouse events map to tool commands for + // Any viewport using the group + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + // Add the tools to the tool group + toolGroup.addTool(WindowLevelTool.toolName); + toolGroup.addTool(PanTool.toolName); + toolGroup.addTool(ZoomTool.toolName); + + // Set the initial state of the tools, here we set one tool active on left click. + // This means left click will draw that tool. + toolGroup.setToolActive(WindowLevelTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Left Click + }, + ], + }); + + toolGroup.setToolActive(PanTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Auxiliary, + }, + ], + }); + + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, + }, + ], + }); + + // 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://d1qmxk7r72ysft.cloudfront.net/dicomweb', + type: 'STACK', + }); + + // Instantiate a rendering engine + const renderingEngineId = 'myRenderingEngine'; + const renderingEngine = new RenderingEngine(renderingEngineId); + + // Create a stack viewport + const viewportId = 'CT_STACK'; + const viewportInput = { + viewportId, + type: ViewportType.STACK, + 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) + ); + + // Set the stack on the viewport + viewport.setStack(imageIds).then(() => { + csUtils.prefetchStack(imageIds); + }); + + // Render the image + viewport.render(); +} + +run(); diff --git a/packages/tools/src/types/CINETypes.ts b/packages/tools/src/types/CINETypes.ts new file mode 100644 index 0000000000..ca3e304070 --- /dev/null +++ b/packages/tools/src/types/CINETypes.ts @@ -0,0 +1,21 @@ +type PlayClipOptions = { + framesPerSecond?: number; + frameTimeVector?: number[]; + reverse?: boolean; + loop?: boolean; + frameTimeVectorSpeedMultiplier?: number; +}; + +interface ToolData { + intervalId: number | undefined; + framesPerSecond: number; + lastFrameTimeStamp: number | undefined; + frameTimeVector: number[] | undefined; + ignoreFrameTimeVector: boolean; + usingFrameTimeVector: boolean; + speed: number; + reverse: boolean; + loop: boolean; +} + +export type { PlayClipOptions, ToolData }; diff --git a/packages/tools/src/types/index.ts b/packages/tools/src/types/index.ts index f82a1bdf40..0b10dff697 100644 --- a/packages/tools/src/types/index.ts +++ b/packages/tools/src/types/index.ts @@ -23,6 +23,7 @@ import type { ToolProps, PublicToolProps } from './ToolProps'; import type { SVGCursorDescriptor, SVGPoint } from './CursorTypes'; import type JumpToSliceOptions from './JumpToSliceOptions'; import type ScrollOptions from './ScrollOptions'; +import type * as CINETypes from './CINETypes'; import type { Color, ColorLUT, @@ -81,4 +82,6 @@ export type { SVGPoint, // Scroll ScrollOptions, + // CINE + CINETypes, }; diff --git a/packages/tools/src/utilities/cine/events.ts b/packages/tools/src/utilities/cine/events.ts new file mode 100644 index 0000000000..37162034d7 --- /dev/null +++ b/packages/tools/src/utilities/cine/events.ts @@ -0,0 +1,9 @@ +/** + * CINE Tool Events + */ +enum Events { + CLIP_STOPPED = 'CORNERSTONE_CINE_TOOL_STOPPED', + CLIP_STARTED = 'CORNERSTONE_CINE_TOOL_STARTED', +} + +export default Events; diff --git a/packages/tools/src/utilities/cine/index.ts b/packages/tools/src/utilities/cine/index.ts new file mode 100644 index 0000000000..263e8e8152 --- /dev/null +++ b/packages/tools/src/utilities/cine/index.ts @@ -0,0 +1,5 @@ +import { playClip, stopClip } from './playClip'; +import Events from './events'; +import { getToolState, addToolState } from './state'; + +export { playClip, stopClip, Events, getToolState, addToolState }; diff --git a/packages/tools/src/utilities/cine/playClip.ts b/packages/tools/src/utilities/cine/playClip.ts new file mode 100644 index 0000000000..c4bc204eaf --- /dev/null +++ b/packages/tools/src/utilities/cine/playClip.ts @@ -0,0 +1,260 @@ +import { + utilities, + getEnabledElement, + StackViewport, +} from '@cornerstonejs/core'; +import CINE_EVENTS from './events'; +import { addToolState, getToolState } from './state'; +import { CINETypes } from '../../types'; + +const { triggerEvent } = utilities; + +/** + * Starts playing a clip or adjusts the frame rate of an already playing clip. framesPerSecond is + * optional and defaults to 30 if not specified. A negative framesPerSecond will play the clip in reverse. + * The element must be a stack of images + * @param element - HTML Element + * @param framesPerSecond - Number of frames per second + */ +function playClip( + element: HTMLDivElement, + playClipOptions: CINETypes.PlayClipOptions +): void { + let playClipTimeouts; + let playClipIsTimeVarying; + + if (element === undefined) { + throw new Error('playClip: element must not be undefined'); + } + + const enabledElement = getEnabledElement(element); + + if (!enabledElement) { + throw new Error( + 'playClip: element must be a valid Cornerstone enabled element' + ); + } + + const { viewport } = enabledElement; + + if (!(viewport instanceof StackViewport)) { + throw new Error( + 'playClip: element must be a stack viewport, volume viewport playClip not yet implemented' + ); + } + + const stackData = { + currentImageIdIndex: viewport.getCurrentImageIdIndex(), + imageIds: viewport.getImageIds(), + }; + + let playClipData = getToolState(element); + + if (!playClipData) { + playClipData = { + intervalId: undefined, + framesPerSecond: 30, + lastFrameTimeStamp: undefined, + ignoreFrameTimeVector: false, + usingFrameTimeVector: false, + frameTimeVector: playClipOptions.frameTimeVector ?? undefined, + speed: playClipOptions.frameTimeVectorSpeedMultiplier ?? 1, + reverse: playClipOptions.reverse ?? false, + loop: playClipOptions.loop ?? true, + }; + addToolState(element, playClipData); + } else { + // Make sure the specified clip is not running before any property update + _stopClipWithData(playClipData); + } + + // If a framesPerSecond is specified and is valid, update the playClipData now + if ( + playClipOptions.framesPerSecond < 0 || + playClipOptions.framesPerSecond > 0 + ) { + playClipData.framesPerSecond = Number(playClipOptions.framesPerSecond); + playClipData.reverse = playClipData.framesPerSecond < 0; + // If framesPerSecond is given, frameTimeVector will be ignored... + playClipData.ignoreFrameTimeVector = true; + } + + // Determine if frame time vector should be used instead of a fixed frame rate... + if ( + playClipData.ignoreFrameTimeVector !== true && + playClipData.frameTimeVector && + playClipData.frameTimeVector.length === stackData.imageIds.length + ) { + const { timeouts, isTimeVarying } = _getPlayClipTimeouts( + playClipData.frameTimeVector, + playClipData.speed + ); + + playClipTimeouts = timeouts; + playClipIsTimeVarying = isTimeVarying; + } + + // This function encapsulates the frame rendering logic... + const playClipAction = () => { + // Hoisting of context variables + let newImageIdIndex = stackData.currentImageIdIndex; + + const imageCount = stackData.imageIds.length; + + if (playClipData.reverse) { + newImageIdIndex--; + } else { + newImageIdIndex++; + } + + if ( + !playClipData.loop && + (newImageIdIndex < 0 || newImageIdIndex >= imageCount) + ) { + _stopClipWithData(playClipData); + const eventDetail = { + element, + }; + + triggerEvent(element, CINE_EVENTS.CLIP_STOPPED, eventDetail); + + return; + } + + // Loop around if we go outside the stack + if (newImageIdIndex >= imageCount) { + newImageIdIndex = 0; + } + + if (newImageIdIndex < 0) { + newImageIdIndex = imageCount - 1; + } + + if (newImageIdIndex !== stackData.currentImageIdIndex) { + viewport.setImageIdIndex(newImageIdIndex).then(() => { + stackData.currentImageIdIndex = newImageIdIndex; + }); + } + }; + + // If playClipTimeouts array is available, not empty and its elements are NOT uniform ... + // ... (at least one timeout is different from the others), use alternate setTimeout implementation + if ( + playClipTimeouts && + playClipTimeouts.length > 0 && + playClipIsTimeVarying + ) { + playClipData.usingFrameTimeVector = true; + playClipData.intervalId = window.setTimeout( + function playClipTimeoutHandler() { + playClipData.intervalId = window.setTimeout( + playClipTimeoutHandler, + playClipTimeouts[stackData.currentImageIdIndex] + ); + playClipAction(); + }, + 0 + ); + } else { + // ... otherwise user setInterval implementation which is much more efficient. + playClipData.usingFrameTimeVector = false; + playClipData.intervalId = window.setInterval( + playClipAction, + 1000 / Math.abs(playClipData.framesPerSecond) + ); + } + + const eventDetail = { + element, + }; + + triggerEvent(element, CINE_EVENTS.CLIP_STARTED, eventDetail); +} + +/** + * Stops an already playing clip. + * @param element - HTML Element + */ +function stopClip(element: HTMLDivElement): void { + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + + const cineToolData = getToolState(viewport.element); + + if (!cineToolData) { + return; + } + + _stopClipWithData(cineToolData); +} + +/** + * [private] Turns a Frame Time Vector (0018,1065) array into a normalized array of timeouts. Each element + * ... of the resulting array represents the amount of time each frame will remain on the screen. + * @param vector - A Frame Time Vector (0018,1065) as specified in section C.7.6.5.1.2 of DICOM standard. + * @param speed - A speed factor which will be applied to each element of the resulting array. + * @returns An array with timeouts for each animation frame. + */ +function _getPlayClipTimeouts(vector: number[], speed: number) { + let i; + let sample; + let delay; + let sum = 0; + const limit = vector.length; + const timeouts = []; + + // Initialize time varying to false + let isTimeVarying = false; + + if (typeof speed !== 'number' || speed <= 0) { + speed = 1; + } + + // First element of a frame time vector must be discarded + for (i = 1; i < limit; i++) { + // eslint-disable-next-line no-bitwise + delay = (Number(vector[i]) / speed) | 0; // Integral part only + timeouts.push(delay); + if (i === 1) { + // Use first item as a sample for comparison + sample = delay; + } else if (delay !== sample) { + isTimeVarying = true; + } + + sum += delay; + } + + if (timeouts.length > 0) { + if (isTimeVarying) { + // If it's a time varying vector, make the last item an average... + // eslint-disable-next-line no-bitwise + delay = (sum / timeouts.length) | 0; + } else { + delay = timeouts[0]; + } + + timeouts.push(delay); + } + + return { timeouts, isTimeVarying }; +} + +/** + * [private] Performs the heavy lifting of stopping an ongoing animation. + * @param playClipData - The data from playClip that needs to be stopped. + */ +function _stopClipWithData(playClipData) { + const id = playClipData.intervalId; + + if (typeof id !== 'undefined') { + playClipData.intervalId = undefined; + if (playClipData.usingFrameTimeVector) { + clearTimeout(id); + } else { + clearInterval(id); + } + } +} + +export { playClip, stopClip }; diff --git a/packages/tools/src/utilities/cine/state.ts b/packages/tools/src/utilities/cine/state.ts new file mode 100644 index 0000000000..5ac40abbe4 --- /dev/null +++ b/packages/tools/src/utilities/cine/state.ts @@ -0,0 +1,18 @@ +import { getEnabledElement } from '@cornerstonejs/core'; +import { CINETypes } from '../../types'; + +const state: Record = {}; + +function addToolState(element: HTMLDivElement, data: CINETypes.ToolData): void { + const enabledElement = getEnabledElement(element); + const { viewportId } = enabledElement; + state[viewportId] = data; +} + +function getToolState(element: HTMLDivElement): CINETypes.ToolData | undefined { + const enabledElement = getEnabledElement(element); + const { viewportId } = enabledElement; + return state[viewportId]; +} + +export { addToolState, getToolState }; diff --git a/packages/tools/src/utilities/index.ts b/packages/tools/src/utilities/index.ts index c21264a735..794ef6a1bb 100644 --- a/packages/tools/src/utilities/index.ts +++ b/packages/tools/src/utilities/index.ts @@ -23,6 +23,7 @@ import * as planar from './planar'; import * as stackScrollTool from './stackScrollTool'; import * as viewportFilters from './viewportFilters'; import * as orientation from './orientation'; +import * as cine from './cine'; // Events import { triggerEvent } from '@cornerstonejs/core'; @@ -47,4 +48,5 @@ export { getAnnotationNearPoint, getAnnotationNearPointOnEnabledElement, jumpToSlice, + cine, }; diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 2581852d55..2a83653072 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -66,10 +66,6 @@ } }, "tools-basic": { - "basic": { - "name": "Basic Usage", - "description": "Demonstrates how to set up and use Tools with Cornerstone" - }, "multipleToolGroups": { "name": "Multiple Tool Groups", "description": "Demonstrates the usage of multiple tool groups for a set of viewports." @@ -98,6 +94,14 @@ "name": "Binding Tools with Modifier Keys", "description": "Demonstrates how to bind a tool to a keyboard and mouse combination (e.g. shift+click, ctrl+click)" }, + "magnifyTool": { + "name": "Magnify Tool", + "description": "Demonstrates the usage of the magnification tool" + }, + "CINETool": { + "name": "CINE Tool", + "description": "Demonstrates the usage of the CINE tool" + }, "segmentationRendering": { "name": "Labelmap Segmentation Rendering", "description": "Demonstrates how to add a Labelmap to the viewports for rendering"