From dc04296cfd1c2991e964fcd891f75b89012ef4d0 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Thu, 15 Sep 2022 23:23:17 -0400 Subject: [PATCH] feat(synchronizers): Add registration for synchronizers (#2861) doc(hp):Update the sync group/hanging protocol documentation --- extensions/cornerstone/src/init.js | 22 ++-- .../SyncGroupService/SyncGroupService.ts | 101 ++++++++++++------ .../services/data/HangingProtocolService.md | 101 ++++++------------ .../contextProviders/ViewportGridProvider.tsx | 17 +-- 4 files changed, 129 insertions(+), 112 deletions(-) diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index a2a3173c7..b571abc77 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -259,6 +259,11 @@ export default async function init({ // } // }; + const newStackCallback = evt => { + const { element } = evt.detail; + utilities.stackPrefetch.enable(element); + }; + function elementEnabledHandler(evt) { const { element } = evt.detail; @@ -267,10 +272,10 @@ export default async function init({ contextMenuHandleClick ); - eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, evt => { - const { element } = evt.detail; - utilities.stackPrefetch.enable(element); - }); + eventTarget.addEventListener( + EVENTS.STACK_VIEWPORT_NEW_STACK, + newStackCallback + ); } function elementDisabledHandler(evt) { @@ -284,10 +289,11 @@ export default async function init({ contextMenuHandleClick ); - eventTarget.removeEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, evt => { - const { element } = evt.detail; - utilities.stackPrefetch.disable(element); - }); + // TODO - consider removing the callback when all elements are gone + // eventTarget.removeEventListener( + // EVENTS.STACK_VIEWPORT_NEW_STACK, + // newStackCallback + // ); } eventTarget.addEventListener( diff --git a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts index d5256f77d..e4e6a6b79 100644 --- a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts +++ b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts @@ -1,4 +1,8 @@ -import { synchronizers, SynchronizerManager } from '@cornerstonejs/tools'; +import { + synchronizers, + SynchronizerManager, + Synchronizer, +} from '@cornerstonejs/tools'; import { pubSubServiceInterface } from '@ohif/core'; @@ -6,20 +10,40 @@ const EVENTS = { TOOL_GROUP_CREATED: 'event::cornerstone::syncgroupservice:toolgroupcreated', }; +/** + * @params options - are an optional set of options associated with the first + * sync group declared. + */ +export type SyncCreator = ( + type: string, + options?: Record +) => Synchronizer; + export type SyncGroup = { type: string; - id: string; - source: boolean; - target: boolean; + id?: string; + // Source and target default to true if not specified + source?: boolean; + target?: boolean; + options?: Record; }; const POSITION = 'cameraposition'; const VOI = 'voi'; +const ZOOMPAN = 'zoompan'; + +const asSyncGroup = (syncGroup: string | SyncGroup): SyncGroup => + typeof syncGroup === 'string' ? { type: syncGroup } : syncGroup; export default class SyncGroupService { serviceManager: any; listeners: { [key: string]: (...args: any[]) => void } = {}; EVENTS: { [key: string]: string }; + synchronizerCreators: Record = { + [POSITION]: synchronizers.createCameraPositionSynchronizer, + [VOI]: synchronizers.createVOISynchronizer, + [ZOOMPAN]: synchronizers.createZoomPanSynchronizer, + }; constructor(serviceManager) { this.serviceManager = serviceManager; @@ -29,54 +53,71 @@ export default class SyncGroupService { Object.assign(this, pubSubServiceInterface); } - private _createSynchronizer(type: string, id: string) { - type = type.toLowerCase(); - if (type === POSITION) { - return synchronizers.createCameraPositionSynchronizer(id); - } else if (type === VOI) { - return synchronizers.createVOISynchronizer(id); + private _createSynchronizer( + type: string, + id: string, + options + ): Synchronizer | undefined { + const syncCreator = this.synchronizerCreators[type.toLowerCase()]; + if (syncCreator) { + return syncCreator(id, options); + } else { + console.warn('Unknown synchronizer type', type, id); } } + /** + * Creates a synchronizer type. + * @param type is the type of the synchronizer to create + * @param creator + */ + public setSynchronizer(type: string, creator: SyncCreator): void { + this.synchronizerCreators[type] = creator; + } + + protected _getOrCreateSynchronizer( + type: string, + id: string, + options: Record + ): Synchronizer | undefined { + let synchronizer = SynchronizerManager.getSynchronizer(id); + + if (!synchronizer) { + synchronizer = this._createSynchronizer(type, id, options); + } + return synchronizer; + } + public addViewportToSyncGroup( viewportId: string, renderingEngineId: string, - syncGroups?: SyncGroup[] + syncGroups?: (SyncGroup | string)[] ): void { if (!syncGroups || !syncGroups.length) { return; } syncGroups.forEach(syncGroup => { - const { type, id, target, source } = syncGroup; + const syncGroupObj = asSyncGroup(syncGroup); + const { type, target = true, source = true, options = {} } = syncGroupObj; + const { id = type } = syncGroupObj; - let synchronizer = SynchronizerManager.getSynchronizer(id); - - if (!synchronizer) { - synchronizer = this._createSynchronizer(type, id); - } + const synchronizer = this._getOrCreateSynchronizer(type, id, options); + synchronizer.setOptions(viewportId, options); + const viewportInfo = { viewportId, renderingEngineId }; if (target && source) { - synchronizer.add({ - viewportId, - renderingEngineId, - }); + synchronizer.add(viewportInfo); return; } else if (source) { - synchronizer.addSource({ - viewportId, - renderingEngineId, - }); + synchronizer.addSource(viewportInfo); } else if (target) { - synchronizer.addTarget({ - viewportId, - renderingEngineId, - }); + synchronizer.addTarget(viewportInfo); } }); } - public destroy() { + public destroy(): void { SynchronizerManager.destroy(); } diff --git a/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md b/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md index 641a6c3ee..006a55712 100644 --- a/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md +++ b/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md @@ -9,19 +9,23 @@ sidebar_label: Hanging Protocol Service `HangingProtocolService` is a migration of the `OHIF-v1` hanging protocol engine. This service handles the arrangement of the images in the viewport. In -short, the registered protocols will get matched with the Series that are -available for the series. Each protocol gets a point, and they are ranked. The +short, the registered protocols will get matched with the DisplaySets that are +available for the study. Each protocol gets a score, and they are ranked. The winning protocol gets applied and its settings run for the viewports. You can read more about hanging protocols [here](http://dicom.nema.org/dicom/Conf-2005/Day-2_Selected_Papers/B305_Morgan_HangProto_v1.pdf). In short with `OHIF-v3` hanging protocols you can: -- Define what layout of the viewport should the viewer starts with (2x2 layout) +- Define what layout of the viewport should the viewer starts with (eg 2x2 layout) - Define which series gets displayed in which position of the layout - Apply certain initial viewport settings; e.g., inverting the contrast - Enable certain tools based on what series are displayed: link prostate T2 and ADC MRI. +- Apply synchronization settings between different viewports or between setting and viewports +- Register custom synchronization settings for viewports +- Register custom attribute extractors +- Select "next display set" from the matching display sets, both on navigation and initial view ## Skeleton of A Hanging Protocol @@ -127,6 +131,7 @@ const defaultProtocol = { Let's discuss each property in depth. - `id`: unique identifier for the protocol +- `name`: Name displayed to the user to select this protocol - `protocolMatchingRules`: A list of criteria for the protocol along with the provided points for ranking. @@ -235,17 +240,6 @@ There are two events that get publish in `HangingProtocolService`: - `addCustomAttribute`: adding a custom attribute for matching. (see below) -- `addCustomViewportSetting`: adding a custom setting to a viewport (initial - `voi`). Below, we explain in detail how to add custom viewport settings via - protocol definitions. `addCustomViewportSetting` is another way to set these - settings which is exposed by API - -- `hps.applyCustomViewportSettings(viewportOptions, viewport,...args)` will run - the callback registered with addCustomViewportSetting for all custom settings - whose name matches the id of the custom viewport, with the arguments (id, value, viewport, ...args) - - - Default initialization of the modes handles running the `HangingProtocolService` ## Custom Attribute @@ -259,30 +253,25 @@ and you want to match based on it. Good news is that, in `OHIF-v3` you can define you custom attribute and use it for matching. There are various ways that you can let `HangingProtocolService` know of you -custom attribute. We will show how to add it inside the mode configuration. +custom attribute. We will show how to add it inside the an extension. This extension +also shows how to register a sync group service which can be referenced +in the sync group settings. ```js -const deafultProtocol = { - id: 'defaultProtocol', +const myCustomProtocol = { + id: 'myCustomProtocol', /** ... **/ protocolMatchingRules: [ { id: 'vSjk7NCYjtdS3XZAw', - weight: 3, - attribute: 'timepoint', + attribute: 'timepointId', constraint: { - equals: { - value: 'first', - }, + equals: 'first', }, required: false, }, ], - stages: [ - /** ... **/ - ], - numberOfPriorsReferenced: -1, -}; +... // Custom function for custom attribute const getTimePointUID = metaData => { @@ -290,45 +279,13 @@ const getTimePointUID = metaData => { return myBackEndAPI(metaData); }; -function modeFactory() { - return { - id: 'myMode', - /** .. **/ - routes: [ - { - path: 'myModeRoute', - init: async ({}) => { - const { - DicomMetadataStore, - HangingProtocolService, - } = servicesManager.services; - - const onSeriesAdded = ({ - StudyInstanceUID, - madeInClient = false, - }) => { - const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); - - // Adding custom attribute to the hangingprotocol - HangingProtocolService.addCustomAttribute( - 'timepoint', - 'timepoint', - metaData => getFirstMeasurementSeriesInstanceUID(metaData) - ); - - HangingProtocolService.run(studyMetadata, DisplaySetService.getActiveDisplaySets()); - }; - - DicomMetadataStore.subscribe( - DicomMetadataStore.EVENTS.SERIES_ADDED, - onSeriesAdded - ); - }, - }, - ], - /** ... **/ - }; -} + preRegistration: ({ + servicesManager, + }) => { + const { HangingProtocolService, SyncGroupService } = servicesManager.services; + HangingProtocolService.addCustomAttribute('timepointId', 'TimePoint ID', getTimePointUID); + SyncGroupService.setSynchronizer('initialzoompan', initialZoomPan); + } ``` ## Viewport Settings @@ -377,3 +334,15 @@ viewportSettings: [ }, ]; ``` + +## Sync Groups +The sync groups are listeners to events that synchronize viewport settings to +some other settings. There are three default/provided sync groups: `zoomPan`, +`cameraPosition` and `voi`. These are defined in the `syncGroups` array. +Additionally, other synchronization types can be created and registered on the +`SyncGroupService.setSynchronizer`, by registering a new id, and a creator method. + +The sync group service is specific to the `cornerstone-extension` because the +actual behaviour of the synchronizers is dependent on the specific viewport. +Different viewport types could redifine the same synchronizer names in +different ways appropriate to that viewport. diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.tsx b/platform/ui/src/contextProviders/ViewportGridProvider.tsx index 3ddeab2db..9f8ce3a00 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.tsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.tsx @@ -47,12 +47,13 @@ export function ViewportGridProvider({ children, service }) { return { ...state, ...{ activeViewportIndex: action.payload } }; } case 'SET_DISPLAYSET_FOR_VIEWPORT': { - const { - viewportIndex, - displaySetInstanceUIDs, - viewportOptions, - displaySetOptions, - } = action.payload; + const payload = action.payload; + const { viewportIndex, displaySetInstanceUIDs } = payload; + const viewport = state.viewports[viewportIndex]; + const viewportOptions = + payload.viewportOptions || viewport.viewportOptions || {}; + const displaySetOptions = payload.displaySetOptions || + viewport.displaySetOptions || [{}]; const viewports = state.viewports.slice(); // merge the displaySetOptions and viewportOptions and displaySetInstanceUIDs @@ -166,8 +167,8 @@ export function ViewportGridProvider({ children, service }) { ({ viewportIndex, displaySetInstanceUIDs, - viewportOptions = {}, - displaySetOptions = [{}], + viewportOptions, + displaySetOptions, }) => dispatch({ type: 'SET_DISPLAYSET_FOR_VIEWPORT',