feat: explicit layout apply via HangingProtocolService (#2936)

* feat: add more types to HP service

* change hp to have apply protocol

* feat: mpr initial work

* apply protocol by id

* feat: mode should be able to apply protocol directly

* fix: wrong inheritance for viewports drag and drop

* apply review comments
This commit is contained in:
Alireza 2022-09-20 23:40:26 -04:00 committed by GitHub
parent fb9744a817
commit 5f480ff746
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 705 additions and 226 deletions

View File

@ -22,6 +22,20 @@ function areEqual(prevProps, nextProps) {
return false;
}
if (
prevProps.viewportOptions.orientation !==
nextProps.viewportOptions.orientation
) {
return false;
}
if (
prevProps.viewportOptions.viewportType !==
nextProps.viewportOptions.viewportType
) {
return false;
}
const prevDisplaySets = prevProps.displaySets[0];
const nextDisplaySets = nextProps.displaySets[0];

View File

@ -258,8 +258,14 @@ function _getInstanceNumberFromVolume(
const isAcquisitionPlane = vec3.length(cross) < EPSILON;
if (isAcquisitionPlane) {
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
}
const { instanceNumber } =
metaData.get('generalImageModule', imageIds[imageIndex]) || {};
metaData.get('generalImageModule', imageId) || {};
return parseInt(instanceNumber);
}
}

View File

@ -11,6 +11,8 @@ import {
segmentation,
utilities as csToolsUtils,
} from '@cornerstonejs/tools';
import { Types } from '@ohif/core';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { getEnabledElement as OHIFgetEnabledElement } from './state';
@ -26,6 +28,9 @@ const commandsModule = ({ servicesManager }) => {
UIDialogService,
CornerstoneViewportService,
SegmentationService,
DisplaySetService,
HangingProtocolService,
UINotificationService,
} = servicesManager.services;
function _getActiveViewportEnabledElement() {
@ -415,6 +420,9 @@ const commandsModule = ({ servicesManager }) => {
(activeViewportIndex - 1 + viewports.length) % viewports.length;
ViewportGridService.setActiveViewportIndex(nextViewportIndex);
},
setHangingProtocol: ({ protocolId }) => {
HangingProtocolService.setProtocol(protocolId);
},
};
const definitions = {
@ -537,6 +545,11 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: {},
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
storeContexts: [],
options: {},
},
};
return {

View File

@ -0,0 +1,146 @@
import { Types } from '@ohif/core';
const MPRHangingProtocolGenerator: Types.HangingProtocol.ProtocolGenerator = ({
servicesManager,
commandsManager,
}) => {
const {
ViewportGridService,
UINotificationService,
DisplaySetService,
} = servicesManager.services;
const { activeViewportIndex, viewports } = ViewportGridService.getState();
const viewportDisplaySetInstanceUIDs =
viewports[activeViewportIndex].displaySetInstanceUIDs;
if (
!viewportDisplaySetInstanceUIDs ||
!viewportDisplaySetInstanceUIDs.length
) {
return;
}
const displaySetsToHang = viewportDisplaySetInstanceUIDs.map(
displaySetInstanceUID => {
const displaySet = DisplaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
return displaySet;
}
);
if (displaySetsToHang.some(ds => !ds.isReconstructable)) {
UINotificationService.show({
title: 'Multiplanar reconstruction (MPR) ',
message:
'Cannot create MPR for this series since it is not reconstructable.',
type: 'warning',
displayTime: 3000,
});
return;
}
const matchingDisplaySets = {};
displaySetsToHang.forEach(displaySet => {
const {
displaySetInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
} = displaySet;
matchingDisplaySets[displaySetInstanceUID] = {
displaySetInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
} as Types.HangingProtocol.DisplaySetMatchDetails;
});
const hpViewports: Types.HangingProtocol.Viewport[] = [
'axial',
'sagittal',
'coronal',
].map(viewportOrientation => {
return {
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: viewportOrientation,
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: viewportDisplaySetInstanceUIDs.map(displaySetInstanceUID => {
return {
id: displaySetInstanceUID,
};
}),
};
});
const protocol = {
id: 'mpr',
stages: [
{
id: 'mprStage',
name: 'mpr',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 3,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 3,
height: 1,
},
{
x: 1 / 3,
y: 0,
width: 1 / 3,
height: 1,
},
{
x: 2 / 3,
y: 0,
width: 1 / 3,
height: 1,
},
],
},
},
displaySets: [],
viewports: hpViewports,
},
],
};
return {
protocol,
matchingDisplaySets,
};
};
function getHangingProtocolModule() {
return [
{
id: 'mpr',
protocol: MPRHangingProtocolGenerator,
},
];
}
export default getHangingProtocolModule;

View File

@ -9,6 +9,7 @@ import {
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import init from './init.js';
import commandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import ToolGroupService from './services/ToolGroupService';
import SyncGroupService from './services/SyncGroupService';
import { toolNames } from './initCornerstoneTools';
@ -70,6 +71,7 @@ const cornerstoneExtension = {
servicesManager.registerService(SyncGroupService(servicesManager));
await init({ servicesManager, commandsManager, configuration, appConfig });
},
getHangingProtocolModule,
getViewportModule({ servicesManager, commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => {
// const onNewImageHandler = jumpData => {

View File

@ -14,7 +14,7 @@ const defaultProtocol = {
id: 'hYbmMy3b7pz7GLiaT',
name: 'default',
viewportStructure: {
type: 'grid',
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
@ -56,8 +56,8 @@ const defaultProtocol = {
function getHangingProtocolModule() {
return [
{
name: 'default',
protocols: [defaultProtocol],
id: defaultProtocol.id,
protocol: defaultProtocol,
},
];
}

View File

@ -2,9 +2,9 @@ import getDataSourcesModule from './getDataSourcesModule.js';
import getLayoutTemplateModule from './getLayoutTemplateModule.js';
import getPanelModule from './getPanelModule';
import getSopClassHandlerModule from './getSopClassHandlerModule.js';
import getHangingProtocolModule from './getHangingProtocolModule.js';
import getToolbarModule from './getToolbarModule';
import commandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
import { id } from './id.js';
import init from './init';
@ -18,9 +18,9 @@ const defaultExtension = {
init({ servicesManager, configuration });
},
getDataSourcesModule,
getHangingProtocolModule,
getLayoutTemplateModule,
getPanelModule,
getHangingProtocolModule,
getSopClassHandlerModule,
getToolbarModule,
getCommandsModule({ servicesManager, commandsManager }) {

View File

@ -1,5 +1,5 @@
const ptCT = {
id: 'tmtv-pt-ct',
id: 'ptCT',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'Default',
@ -15,6 +15,15 @@ const ptCT = {
],
imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter"
protocolMatchingRules: [
{
id: 'wauZK2QNEfDPwcAQo',
weight: 1,
attribute: 'ModalitiesInStudy',
constraint: {
contains: ['CT', 'PT'],
},
required: false,
},
{
id: 'wauZK2QNEfDPwcAQo',
weight: 1,
@ -632,8 +641,8 @@ const ptCT = {
function getHangingProtocolModule() {
return [
{
name: 'ptCT',
protocols: [ptCT],
id: ptCT.id,
protocol: ptCT,
},
];
}

View File

@ -10,7 +10,6 @@ const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG'];
const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
hangingProtocols: '@ohif/extension-default.hangingProtocolModule.default',
};
const tracked = {
@ -103,6 +102,7 @@ function modeFactory() {
'Pan',
'Capture',
'Layout',
'MPR',
'MoreTools',
]);
},
@ -169,7 +169,8 @@ function modeFactory() {
},
],
extensions: extensionDependencies,
hangingProtocols: [ohif.hangingProtocols],
// Default protocol gets self-registered by default in the init
hangingProtocol: 'default',
// Order is important in sop class handlers when two handlers both use
// the same sop class under different situations. In that case, the more
// general handler needs to come last. For this case, the dicomvideo must

View File

@ -1,7 +1,8 @@
function initDefaultToolGroup(
extensionManager,
ToolGroupService,
commandsManager
commandsManager,
toolGroupId
) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
@ -57,7 +58,6 @@ function initDefaultToolGroup(
},
};
const toolGroupId = 'default';
ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, toolsConfig);
}
@ -140,8 +140,19 @@ function initSRToolGroup(extensionManager, ToolGroupService, commandsManager) {
}
function initToolGroups(extensionManager, ToolGroupService, commandsManager) {
initDefaultToolGroup(extensionManager, ToolGroupService, commandsManager);
initDefaultToolGroup(
extensionManager,
ToolGroupService,
commandsManager,
'default'
);
initSRToolGroup(extensionManager, ToolGroupService, commandsManager);
initDefaultToolGroup(
extensionManager,
ToolGroupService,
commandsManager,
'mpr'
);
}
export default initToolGroups;

View File

@ -290,6 +290,23 @@ const toolbarButtons = [
columns: 3,
},
},
// Todo: MPR not ready yet for SEG support, not activating it now
{
id: 'MPR',
type: 'ohif.action',
props: {
icon: 'old-play',
label: 'MPR',
type: 'action',
commands: [
{
commandName: 'setHangingProtocol',
commandOptions: { protocolId: 'mpr' },
context: 'CORNERSTONE',
},
],
},
},
// More...
{
id: 'MoreTools',

View File

@ -8,7 +8,6 @@ import setFusionActiveVolume from './utils/setFusionActiveVolume.js';
const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
hangingProtocols: '@ohif/extension-default.hangingProtocolModule.default',
measurements: '@ohif/extension-default.panelModule.measure',
thumbnailList: '@ohif/extension-default.panelModule.seriesList',
};
@ -188,7 +187,7 @@ function modeFactory({ modeConfiguration }) {
},
],
extensions: extensionDependencies,
hangingProtocols: [tmtv.hangingProtocols],
hangingProtocol: 'ptCT',
sopClassHandlers: [ohif.sopClassHandler],
hotkeys: [...hotkeys.defaults.hotkeyBindings],
};

View File

@ -43,12 +43,10 @@ export default class ExtensionManager {
const {
MeasurementService,
ViewportGridService,
HangingProtocolService,
} = _servicesManager.services;
MeasurementService.clearMeasurements();
ViewportGridService.reset();
HangingProtocolService.reset();
registeredExtensionIds.forEach(extensionId => {
const onModeEnter = _extensionLifeCycleHooks.onModeEnter[extensionId];
@ -74,12 +72,10 @@ export default class ExtensionManager {
const {
MeasurementService,
ViewportGridService,
HangingProtocolService,
} = _servicesManager.services;
MeasurementService.clearMeasurements();
ViewportGridService.reset();
HangingProtocolService.reset();
registeredExtensionIds.forEach(extensionId => {
const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId];
@ -196,6 +192,8 @@ export default class ExtensionManager {
dataSources
);
break;
case MODULE_TYPES.HANGING_PROTOCOL:
this._initHangingProtocolsModule(extensionModule, extensionId);
case MODULE_TYPES.TOOLBAR:
case MODULE_TYPES.VIEWPORT:
case MODULE_TYPES.PANEL:
@ -203,7 +201,6 @@ export default class ExtensionManager {
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
case MODULE_TYPES.UTILITY:
case MODULE_TYPES.HANGING_PROTOCOL:
// Default for most extension points,
// Just adds each entry ready for consumption by mode.
extensionModule.forEach(element => {
@ -287,6 +284,13 @@ export default class ExtensionManager {
}
};
_initHangingProtocolsModule = (extensionModule, extensionId) => {
const { HangingProtocolService } = this._servicesManager.services;
extensionModule.forEach(({ id, protocol }) => {
HangingProtocolService.addProtocol(id, protocol);
});
};
_initDataSourcesModule(extensionModule, extensionId, dataSources = []) {
const { UserAuthenticationService } = this._servicesManager.services;

View File

@ -18,6 +18,9 @@ describe('ExtensionManager.js', () => {
services: {
// Required for DataSource Module initiation
UserAuthenticationService: jest.fn(),
HangingProtocolService: {
addProtocol: jest.fn(),
},
},
};
appConfig = {

View File

@ -2,6 +2,7 @@ interface IDisplaySet {
displaySetInstanceUID: string;
StudyInstanceUID: string;
SeriesInstanceUID?: string;
SeriesNumber?: string;
}
export default IDisplaySet;

View File

@ -127,7 +127,7 @@ describe('HangingProtocolService', () => {
let initialScaling;
beforeAll(() => {
hps.addProtocols([testProtocol]);
hps.addProtocol(testProtocol.id, testProtocol);
});
it('has one protocol', () => {
@ -137,11 +137,7 @@ describe('HangingProtocolService', () => {
describe('run', () => {
it('matches best image match', () => {
hps.run({ studies: [studyMatch], displaySets: studyMatchDisplaySets });
const {
hpAlreadyApplied,
viewportMatchDetails,
displaySetMatchDetails,
} = hps.getMatchDetails();
const { hpAlreadyApplied, viewportMatchDetails } = hps.getMatchDetails();
expect(hpAlreadyApplied).toMatchObject([false]);
expect(viewportMatchDetails.length).toBe(1);
expect(viewportMatchDetails[0]).toMatchObject({

View File

@ -3,6 +3,7 @@ import sortBy from '../../utils/sortBy';
import ProtocolEngine from './ProtocolEngine';
import StudyMetadata from '../../types/StudyMetadata';
import IDisplaySet from '../DisplaySetService/IDisplaySet';
import { HangingProtocol } from '../../types';
const EVENTS = {
STAGE_CHANGE: 'event::hanging_protocol_stage_change',
@ -12,51 +13,24 @@ const EVENTS = {
'event::hanging_protocol_custom_image_load_performed',
};
type ViewportOptions = {
orientation: string;
toolGroupId: string;
viewportId: string;
viewportType: string;
initialImageOptions: Record<string, unknown>;
syncGroups: Record<string, unknown>;
};
type ViewportMatchDetails = {
viewportOptions: ViewportOptions;
displaySetsInfo: {
SeriesInstanceUID: string;
displaySetInstanceUID: string;
displaySetOptions: Record<string, unknown>;
};
};
type DisplaySetMatchDetails = {
SeriesInstanceUID: string;
StudyInstanceUID: string;
displaySetInstanceUID: string;
matchDetails: any;
matchingScore: number;
sortingInfo: any;
};
type HangingProtocolMatchDetails = {
displaySetMatchDetails: Map<string, DisplaySetMatchDetails>;
viewportMatchDetails: ViewportMatchDetails[];
hpAlreadyApplied: boolean[];
};
type Protocol = HangingProtocol.Protocol | HangingProtocol.ProtocolGenerator;
class HangingProtocolService {
studies: StudyMetadata[];
protocols: Record<string, unknown>[];
protocol: Record<string, unknown>;
// stores all the protocols (object or function that returns an object) in a map
protocols: Map<string, Protocol>;
// the current protocol that is being applied to the viewports in object format
protocol: HangingProtocol.Protocol;
stage: number;
_commandsManager: Record<string, unknown>;
_servicesManager: Record<string, unknown>;
protocolEngine: ProtocolEngine;
hpAlreadyApplied: boolean[] = [];
customViewportSettings = [];
displaySets: IDisplaySet[] = [];
activeStudy: Record<string, unknown>;
debugLogging: false;
EVENTS: { [key: string]: string };
customAttributeRetrievalCallbacks = {
NumberOfStudyRelatedSeries: {
@ -89,17 +63,21 @@ class HangingProtocolService {
* DisplaySetId is the id defined in the hangingProtocol object itself
* and match is an object that contains information about
*/
displaySetMatchDetails: Map<string, DisplaySetMatchDetails> = new Map();
displaySetMatchDetails: Map<
string,
HangingProtocol.DisplaySetMatchDetails
> = new Map();
/**
* An array that contains for each viewport (viewportIndex) specified in the
* hanging protocol, an object of the form
*/
viewportMatchDetails = [] as ViewportMatchDetails[];
viewportMatchDetails = [] as HangingProtocol.ViewportMatchDetails[];
constructor(commandsManager) {
constructor(commandsManager, servicesManager) {
this._commandsManager = commandsManager;
this.protocols = [];
this._servicesManager = servicesManager;
this.protocols = new Map();
this.protocolEngine = undefined;
this.protocol = undefined;
this.stage = undefined;
@ -116,13 +94,17 @@ class HangingProtocolService {
public reset() {
this.studies = [];
this.protocols = [];
this.protocols = new Map();
this.hpAlreadyApplied = [];
this.viewportMatchDetails = [];
// this.ProtocolEngine.reset()
}
public getMatchDetails(): HangingProtocolMatchDetails {
public getDefaultProtocol(): HangingProtocol.Protocol {
return this.getProtocolById('default');
}
public getMatchDetails(): HangingProtocol.HangingProtocolMatchDetails {
return {
viewportMatchDetails: this.viewportMatchDetails,
displaySetMatchDetails: this.displaySetMatchDetails,
@ -130,16 +112,76 @@ class HangingProtocolService {
};
}
public getProtocols() {
return this.protocols;
/**
* It loops over the protocols map object, and checks whether the protocol
* is a function, if so, it executes it and returns the result as a protocol object
* otherwise it returns the protocol object itself
*
* @returns all the hanging protocol registered in the HangingProtocolService
*/
public getProtocols(): HangingProtocol.Protocol[] {
// this.protocols is a map of protocols with the protocol id as the key
// and the protocol or a function that returns a protocol as the value
const protocols = [];
// @ts-ignore
for (const protocolId of this.protocols.keys()) {
const protocol = this.getProtocolById(protocolId);
if (protocol) {
protocols.push(protocol);
}
}
return protocols;
}
public addProtocols(protocols) {
protocols.forEach(protocol => {
if (this.protocols.indexOf(protocol) === -1) {
this.protocols.push(this._validateProtocol(protocol));
/**
* Returns the protocol with the given id, it will get the protocol from the
* protocols map object and if it is a function, it will execute it and return
* the result as a protocol object
*
* @param protocolId - the id of the protocol
* @returns protocol - the protocol with the given id
*/
public getProtocolById(id: string): HangingProtocol.Protocol {
const protocol = this.protocols.get(id);
if (protocol instanceof Function) {
try {
const { protocol: generatedProtocol } = this._getProtocolFromGenerator(
protocol
);
return generatedProtocol;
} catch (error) {
console.warn(
`Error while executing protocol generator for protocol ${id}: ${error}`
);
}
});
} else {
return protocol;
}
}
/**
* It adds a protocol to the protocols map object. If a protocol with the given
* id already exists, warn the user and overwrite it.
*
* @param {string} protocolId - The id of the protocol.
* @param {Protocol} protocol - Protocol - This is the protocol that you want to
* add to the protocol manager.
*/
public addProtocol(protocolId: string, protocol: Protocol): void {
if (this.protocols.has(protocolId)) {
console.warn(
`A protocol with id ${protocolId} already exists. It will be overwritten.`
);
}
if (!(protocol instanceof Function)) {
protocol = this._validateProtocol(protocol as HangingProtocol.Protocol);
}
this.protocols.set(protocolId, protocol);
}
/**
@ -156,28 +198,28 @@ class HangingProtocolService {
* @param protocol is a specific protocol to apply.
* @returns
*/
public run({ studies, displaySets, activeStudy }, protocol) {
public run({ studies, displaySets, activeStudy }, protocolId) {
this.studies = [...studies];
this.displaySets = displaySets;
this.activeStudy = activeStudy || studies[0];
this.protocolEngine = new ProtocolEngine(
this.protocols,
this.getProtocols(),
this.customAttributeRetrievalCallbacks
);
// if there is no pre-defined protocol
if (!protocol || protocol.id === undefined) {
const matchedProtocol = this.protocolEngine.run({
studies: this.studies,
activeStudy,
displaySets,
});
this._setProtocol(matchedProtocol);
if (protocolId) {
const protocol = this.getProtocolById(protocolId);
this._setProtocol(protocol);
return;
}
this._setProtocol(protocol);
const matchedProtocol = this.protocolEngine.run({
studies: this.studies,
activeStudy,
displaySets,
});
this._setProtocol(matchedProtocol);
}
/**
@ -185,7 +227,7 @@ class HangingProtocolService {
* and its callback has been added to the HangingProtocolService
* @returns {boolean} true
*/
public hasCustomImageLoadStrategy() {
public hasCustomImageLoadStrategy(): boolean {
return (
this.activeImageLoadStrategyName !== null &&
this.registeredImageLoadStrategies[
@ -194,7 +236,7 @@ class HangingProtocolService {
);
}
public getCustomImageLoadPerformed() {
public getCustomImageLoadPerformed(): boolean {
return this.customImageLoadPerformed;
}
@ -203,13 +245,13 @@ class HangingProtocolService {
* @param {string} name strategy name
* @param {Function} callback image loader callback
*/
public registerImageLoadStrategy(name, callback) {
public registerImageLoadStrategy(name, callback): void {
if (callback instanceof Function && name) {
this.registeredImageLoadStrategies[name] = callback;
}
}
public setHangingProtocolAppliedForViewport(i) {
public setHangingProtocolAppliedForViewport(i): void {
this.hpAlreadyApplied[i] = true;
}
@ -222,7 +264,12 @@ class HangingProtocolService {
* @param callback The function used to calculate the attribute value from the other attributes at its level (e.g. study/series/image)
* @param options to add to the "this" object for the custom attribute retriever
*/
public addCustomAttribute(attributeId, attributeName, callback, options) {
public addCustomAttribute(
attributeId: string,
attributeName: string,
callback: (metadata: any) => any,
options: Record<string, any> = {}
): void {
this.customAttributeRetrievalCallbacks[attributeId] = {
...options,
id: attributeId,
@ -234,7 +281,7 @@ class HangingProtocolService {
/**
* Switches to the next protocol stage in the display set sequence
*/
public nextProtocolStage() {
public nextProtocolStage(): void {
console.log('ProtocolEngine::nextProtocolStage');
if (!this._setCurrentProtocolStage(1)) {
@ -245,7 +292,7 @@ class HangingProtocolService {
/**
* Switches to the previous protocol stage in the display set sequence
*/
public previousProtocolStage() {
public previousProtocolStage(): void {
console.log('ProtocolEngine::previousProtocolStage');
if (!this._setCurrentProtocolStage(-1)) {
@ -257,7 +304,7 @@ class HangingProtocolService {
* Executes the callback function for the custom loading strategy for the images
* if no strategy is set, the default strategy is used
*/
runImageLoadStrategy(data) {
runImageLoadStrategy(data): void {
const loader = this.registeredImageLoadStrategies[
this.activeImageLoadStrategyName
];
@ -277,8 +324,14 @@ class HangingProtocolService {
this._broadcastChange(this.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED, loadedData);
}
_validateProtocol(protocol) {
_validateProtocol(
protocol: HangingProtocol.Protocol
): HangingProtocol.Protocol {
protocol.id = protocol.id || protocol.name;
const defaultViewportOptions = {
toolGroupId: 'default',
viewportType: 'stack',
};
// Automatically compute some number of attributes if they
// aren't present. Makes defining new HPs easier.
protocol.name = protocol.name || protocol.id;
@ -292,13 +345,14 @@ class HangingProtocolService {
for (let i = 0; i < rows * columns; i++) {
stage.viewports.push({
viewportOptions: {},
viewportOptions: defaultViewportOptions,
displaySets: [],
});
}
} else {
stage.viewports.forEach(viewport => {
viewport.viewportOptions = viewport.viewportOptions || {};
viewport.viewportOptions =
viewport.viewportOptions || defaultViewportOptions;
if (!viewport.displaySets) {
viewport.displaySets = [];
} else {
@ -313,9 +367,53 @@ class HangingProtocolService {
return protocol;
}
_setProtocol(protocol) {
// TODO: Add proper Protocol class to validate the protocols
// which are entered manually
/**
* It applied the protocol to the current studies and display sets based on the
* protocolId that is provided.
* @param protocolId - name of the protocol to be set
* @param protocol - protocol object (optional), if not provided, the protocol
* will be retrieved from the list of protocols by its name
* @param matchingDisplaySets - predefined display sets to be used for the protocol
*/
public setProtocol(
protocolId: string,
protocol?: HangingProtocol.Protocol,
matchingDisplaySets?: Record<string, HangingProtocol.DisplaySetMatchDetails>
): void {
if (!protocol) {
const foundProtocol = this.protocols.get(protocolId);
if (!foundProtocol) {
console.warn(
`HangingProtocolService::setProtocol - protocol ${protocolId} not found`
);
return;
}
if (foundProtocol instanceof Function) {
try {
({ protocol, matchingDisplaySets } = this._getProtocolFromGenerator(
foundProtocol
));
} catch (error) {
console.warn(
`HangingProtocolService::setProtocol - protocol ${protocolId} failed to execute`,
error
);
return;
}
} else {
protocol = foundProtocol;
}
}
this._setProtocol(protocol, matchingDisplaySets);
}
private _setProtocol(
protocol: HangingProtocol.Protocol,
matchingDisplaySets?: Record<string, HangingProtocol.DisplaySetMatchDetails>
): void {
this.stage = 0;
this.protocol = protocol;
const { imageLoadStrategy } = protocol;
@ -328,7 +426,7 @@ class HangingProtocolService {
this.activeImageLoadStrategyName = imageLoadStrategy;
}
}
this._updateViewports();
this._updateViewports(matchingDisplaySets);
this._broadcastChange(this.EVENTS.PROTOCOL_CHANGED, {
viewportMatchDetails: this.viewportMatchDetails,
@ -362,19 +460,50 @@ class HangingProtocolService {
return this.protocol.stages[this.stage];
}
private _getProtocolFromGenerator(
protocolGenerator: HangingProtocol.ProtocolGenerator
): {
protocol: HangingProtocol.Protocol;
matchingDisplaySets: Record<string, HangingProtocol.DisplaySetMatchDetails>;
} {
const { protocol, matchingDisplaySets } = protocolGenerator({
servicesManager: this._servicesManager,
commandsManager: this._commandsManager,
});
const validatedProtocol = this._validateProtocol(protocol);
return {
protocol: validatedProtocol,
matchingDisplaySets,
};
}
/**
* Updates the viewports with the selected protocol stage.
*/
_updateViewports() {
_updateViewports(
matchingDisplaySets?: Record<string, HangingProtocol.DisplaySetMatchDetails>
): void {
// Make sure we have an active protocol with a non-empty array of display sets
if (!this._getNumProtocolStages()) {
console.log('No protocol stages - nothing to display');
return;
}
// each time we are updating the viewports, we need to reset the
// matching applied
this.hpAlreadyApplied = [];
// reset displaySetMatchDetails
this.displaySetMatchDetails = new Map();
if (matchingDisplaySets) {
this.displaySetMatchDetails = new Map(
Object.entries(matchingDisplaySets)
);
}
// Retrieve the current stage
const stageModel = this._getCurrentStageModel();
@ -392,7 +521,7 @@ class HangingProtocolService {
}
this.customImageLoadPerformed = false;
const { type: layoutType } = stageModel.viewportStructure;
const { layoutType } = stageModel.viewportStructure;
// Retrieve the properties associated with the current display set's viewport structure template
// If no such layout properties exist, stop here.
@ -412,14 +541,19 @@ class HangingProtocolService {
});
// Matching the displaySets
for (const displaySet of stageModel.displaySets) {
// skip matching if already matched
if (this.displaySetMatchDetails.has(displaySet.id)) {
continue;
}
stageModel.displaySets.forEach(displaySet => {
const { bestMatch, matchingScores } = this._matchImages(displaySet);
this.displaySetMatchDetails.set(displaySet.id, bestMatch);
if (bestMatch) {
bestMatch.matchingScores = matchingScores;
}
});
}
// Loop through each viewport
stageModel.viewports.forEach((viewport, viewportIndex) => {
@ -430,6 +564,8 @@ class HangingProtocolService {
// but it is a info to locate the displaySet from the displaySetService
const displaySetsInfo = [];
viewport.displaySets.forEach(
// Todo: why do we have displaySetIndex here? It is not used in the protocol
// definition
({ id, displaySetIndex = 0, options: displaySetOptions }) => {
const viewportDisplaySetMain = this.displaySetMatchDetails.get(id);
// Use the display set index to allow getting the "next" match, eg
@ -445,7 +581,7 @@ class HangingProtocolService {
displaySetInstanceUID,
} = viewportDisplaySet;
const displaySetInfo = {
const displaySetInfo: HangingProtocol.DisplaySetInfo = {
SeriesInstanceUID,
displaySetInstanceUID,
displaySetOptions,
@ -524,6 +660,7 @@ class HangingProtocolService {
const seriesMatchDetails = this.protocolEngine.findMatch(
displaySet,
seriesMatchingRules,
// Todo: why we have images here since the matching type does not have it
{ studies: this.studies, instance: displaySet.images?.[0] }
);

View File

@ -2,7 +2,7 @@ import HangingProtocolService from './HangingProtocolService';
export default {
name: 'HangingProtocolService',
create: ({ configuration = {}, commandsManager }) => {
return new HangingProtocolService(commandsManager);
create: ({ configuration = {}, commandsManager, servicesManager }) => {
return new HangingProtocolService(commandsManager, servicesManager);
},
};

View File

@ -37,6 +37,7 @@ export default class ServicesManager {
this.services[service.name] = service.create({
configuration,
commandsManager: this._commandsManager,
servicesManager: this,
});
} else {
log.warn(`Service create factory function not defined. Exiting early.`);

View File

@ -4,10 +4,15 @@ import log from '../log.js';
jest.mock('./../log.js');
describe('ServicesManager.js', () => {
let servicesManager;
let servicesManager, commandsManager;
beforeEach(() => {
servicesManager = new ServicesManager();
commandsManager = {
createContext: jest.fn(),
getContext: jest.fn(),
registerCommand: jest.fn(),
};
servicesManager = new ServicesManager(commandsManager);
log.warn.mockClear();
jest.clearAllMocks();
});
@ -90,9 +95,9 @@ describe('ServicesManager.js', () => {
servicesManager.registerService(fakeService, configuration);
expect(fakeService.create.mock.calls[0][0]).toEqual({
configuration,
});
expect(fakeService.create.mock.calls[0][0].configuration.config).toBe(
configuration.config
);
});
});
});

View File

@ -0,0 +1,140 @@
type DisplaySetInfo = {
SeriesInstanceUID: string;
displaySetInstanceUID: string;
displaySetOptions: Record<string, unknown>;
};
type ViewportMatchDetails = {
viewportOptions: ViewportOptions;
displaySetsInfo: DisplaySetInfo[];
};
type DisplaySetMatchDetails = {
SeriesInstanceUID: string;
StudyInstanceUID: string;
displaySetInstanceUID: string;
matchDetails?: any;
matchingScores?: any[];
sortingInfo?: any;
};
type HangingProtocolMatchDetails = {
displaySetMatchDetails: Map<string, DisplaySetMatchDetails>;
viewportMatchDetails: ViewportMatchDetails[];
hpAlreadyApplied: boolean[];
};
type MatchingRule = {
id: string;
weight: number;
attribute: string;
constraint: Record<string, unknown>;
required: boolean;
};
type ViewportLayoutOptions = {
x: number;
y: number;
width: number;
height: number;
};
type ViewportStructure = {
layoutType: string;
properties: {
rows: number;
columns: number;
layoutOptions: ViewportLayoutOptions[];
};
};
type DisplaySet = {
id: string;
imageMatchingRules: MatchingRule[];
seriesMatchingRules: MatchingRule[];
studyMatchingRules: MatchingRule[];
};
type SyncGroup = {
type: string;
id: string;
source?: boolean
target?: boolean
}
type initialImageOptions = {
index?: number;
preset? : string; // todo: type more
}
type ViewportOptions = {
toolGroupId: string;
viewportType: string;
id?: string;
orientation?: string;
viewportId?: string;
initialImageOptions?: initialImageOptions;
syncGroups?: SyncGroup[];
customViewportProps? : Record<string, unknown>;
};
type DisplaySetOptions = {
id: string;
options?: Record<string, unknown>;
};
type Viewport = {
viewportOptions: ViewportOptions;
displaySets: DisplaySetOptions[];
};
type ProtocolStage = {
id: string;
name: string;
viewportStructure: ViewportStructure;
displaySets: DisplaySet[];
viewports: Viewport[];
createdDate?: string;
};
type Protocol = {
// Mandatory
id: string;
stages: ProtocolStage[];
// Optional
locked?: boolean;
hasUpdatedPriorsInformation?: boolean;
name?: string;
createdDate?: string;
modifiedDate?: string;
availableTo?: Record<string, unknown>;
editableBy?: Record<string, unknown>;
toolGroupIds?: string[];
imageLoadStrategy?: string; // Todo: this should be types specifically
protocolMatchingRules?: MatchingRule[];
numberOfPriorsReferenced?: number;
};
type ProtocolGenerator = ({servicesManager: any, commandsManager: any}) => {
protocol: Protocol;
matchingDisplaySets: any;
};
export type {
ProtocolGenerator,
ViewportOptions,
ViewportMatchDetails,
DisplaySetMatchDetails,
HangingProtocolMatchDetails,
Protocol,
ProtocolStage,
Viewport,
DisplaySet,
ViewportStructure,
ViewportLayoutOptions,
DisplaySetOptions,
MatchingRule,
SyncGroup,
initialImageOptions,
DisplaySetInfo
};

View File

@ -6,4 +6,12 @@ import {
import Consumer from './Consumer';
export { StudyMetadata, SeriesMetadata, InstanceMetadata, Consumer };
import * as HangingProtocol from './HangingProtocol';
export type {
HangingProtocol,
StudyMetadata,
SeriesMetadata,
InstanceMetadata,
Consumer,
};

View File

@ -28,7 +28,7 @@ const deafultProtocol = {
id: 'hYbmMy3b7pz7GLiaT',
name: 'default',
viewportStructure: {
type: 'grid',
layoutType: 'grid',
properties: {
rows: 1,
columns: 2,
@ -64,8 +64,8 @@ const deafultProtocol = {
function getHangingProtocolModule() {
return [
{
name: hangingProtocolName,
protocols: [defaultProtocol],
id: 'default',
protocol: deafultProtocol,
},
];
}

View File

@ -149,8 +149,8 @@ async function defaultRouteInit({
});
DicomMetadataStore.subscribe('seriesAdded', ({ StudyInstanceUID }) => {
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
HangingProtocolService.run(studyMetadata);
const studyMetadata = // get study metadata and displaySets
HangingProtocolService.run({studies, displaySets, activeStudy});
});
return unsubscriptions;

View File

@ -8,20 +8,32 @@ sidebar_label: Hanging Protocol Service
## Overview
`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
engine along with various improvements and fixes.
This service handles the arrangement of the images in the viewport. In
short, the registered protocols will get matched with the DisplaySets that are
available. 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:
In `OHIF-v3` hanging protocols you can:
- Define what layout of the viewport should the viewer starts with (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.
- Specify the type of the viewport and its orientation
- Define which displaySets gets displayed in which viewport of the layout
- Apply certain initial viewport settings; e.g., inverting the contrast, jumping to a specific slice, etc.
- Add specific synchronization rules for the viewports
## Protocols
A protocol can be an object or a function that returns an object (protocol generator).
Each protocol can get added to the `HangingProtocolService` by using the `addProtocol` method given
an `id` and the protocol itself. As an example, the `default` protocol is an object, while
the 'mpr' protocol is a function that returns the protocol (the reason for this is that the
`mpr` protocol needs to be generated based on the active viewport).
All protocols are stored in the `HangingProtocolService` using their `id` as the key, and
the protocol itself as the value.
## Events
@ -31,20 +43,21 @@ There are two events that get publish in `HangingProtocolService`:
| ------------ | -------------------------------------------------------------------- |
| NEW_LAYOUT | Fires when a new layout is requested by the `HangingProtocolService` |
| STAGE_CHANGE | Fires when the the stage is changed in the hanging protocols |
| PROTOCOL_CHANGED | Fires when the the protocol is changed in the hanging protocols |
## API
- `getState`: returns an array: `[matchDetails, hpAlreadyApplied]`:
- `getMatchDetails`: returns an object which contains the details of the
matching for the viewports, displaysets and whether the protocol is
applied to the viewport or not
- `matchDetails`: matching details for the series
- `hpAlreadyApplied`: An array which tracks whether HPServices has been
applied on each viewport.
- `addProtocols`: adds provided protocols to the list of registered protocols
- `addProtocol`: adds provided protocol to the list of registered protocols
for matching
- `run(studyMetaData, protocol)`: runs the HPService with the provided
studyMetaData and optional protocol. If protocol is not given, HP Matching
- `run({studies, activeStudy, displaySets }, protocolId)`: runs the HPService with the provided
studyMetaData and optional protocolId. If protocol is not given, HP Matching
engine will search all the registered protocols for the best matching one
based on the constraints.
@ -52,6 +65,8 @@ There are two events that get publish in `HangingProtocolService`:
Default initialization of the modes handles running the `HangingProtocolService`
## Custom Attribute
In some situations, you might want to match based on a custom attribute and not the DICOM tags. For instance,
if you have assigned a `timepointId` to each study, and you want to match based on it.
@ -129,50 +144,3 @@ function modeFactory() {
};
}
```
## Viewport Settings
You can define custom settings to be applied to each viewport. There are two
types of settings:
- `viewport settings`: Currently we support two viewport settings
- `voi`: applying an initial `voi` by setting the windowWidth and windowCenter
- `inverted`: inverting the viewport color (e.g., for PET images)
- `props settings`: Running commands after the first render; e.g., enabling the
link tool
Examples of each settings are :
```js
viewportSettings: [
{
options: {
itemId: 'SyncScroll',
interactionType: 'toggle',
commandName: 'toggleSynchronizer',
commandOptions: { toggledState: true },
},
commandName: 'setToolActive',
type: 'props',
},
];
```
and
```js
viewportSettings: [
{
options: {
voi: {
windowWidth: 400,
windowCenter: 40,
},
},
commandName: '',
type: 'viewport',
},
];
```

View File

@ -58,8 +58,7 @@ async function defaultRouteInit({
const { unsubscribe: seriesAddedUnsubscribe } = DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.SERIES_ADDED,
({ StudyInstanceUID }) => {
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
HangingProtocolService.run(studyMetadata);
HangingProtocolService.run({studies, displaySets, activeStudy});
}
);
unsubscriptions.push(seriesAddedUnsubscribe);
@ -89,11 +88,6 @@ export default function ModeRoute(/**..**/) {
extensionManager.onModeEnter();
mode?.onModeEnter({ servicesManager, extensionManager });
hangingProtocols.forEach(extentionProtocols => {
const { protocols } = extensionManager.getModuleEntry(extentionProtocols);
HangingProtocolService.addProtocols(protocols);
});
const setupRouteInit = async () => {
if (route.init) {
return await route.init(/**...**/);

View File

@ -49,11 +49,18 @@ export function ViewportGridProvider({ children, service }) {
case 'SET_DISPLAYSET_FOR_VIEWPORT': {
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 || [{}];
// Note: there should be no inheritance happening at this level,
// we can't assume the new displaySet can inherit the previous
// displaySet's or viewportOptions at all. For instance, dragging
// and dropping a SEG/RT displaySet without any viewportOptions
// or displaySetOptions should not inherit the previous displaySet's
// which might have been a PDF Viewport. The viewport itself
// will deal with inheritance if required. Here is just a simple
// provider.
const viewportOptions = payload.viewportOptions || {};
const displaySetOptions = payload.displaySetOptions || [{}];
const viewports = state.viewports.slice();
// merge the displaySetOptions and viewportOptions and displaySetInstanceUIDs

View File

@ -17,7 +17,10 @@ import Compose from './Compose';
* @param props.dataSource to read the data from
* @returns array of subscriptions to cancel
*/
function defaultRouteInit({ servicesManager, studyInstanceUIDs, dataSource }) {
function defaultRouteInit(
{ servicesManager, studyInstanceUIDs, dataSource },
hangingProtocol
) {
const {
DisplaySetService,
HangingProtocolService,
@ -75,8 +78,12 @@ function defaultRouteInit({ servicesManager, studyInstanceUIDs, dataSource }) {
// study being displayed, and is thus the "active" study.
const activeStudy = studies[0];
// run the hanging protocol matching service on the displaySets
HangingProtocolService.run({ studies, activeStudy, displaySets });
// run the hanging protocol matching on the displaySets with the predefined
// hanging protocol in the mode configuration
HangingProtocolService.run(
{ studies, activeStudy, displaySets },
hangingProtocol
);
});
return unsubscriptions;
@ -107,12 +114,9 @@ export default function ModeRoute({
locationRef.current = location;
}
const {
DisplaySetService,
HangingProtocolService,
} = servicesManager.services;
const { DisplaySetService } = servicesManager.services;
const { extensions, sopClassHandlers, hotkeys, hangingProtocols } = mode;
const { extensions, sopClassHandlers, hotkeys, hangingProtocol } = mode;
if (dataSourceName === undefined) {
dataSourceName = extensionManager.defaultDataSourceName;
@ -239,34 +243,28 @@ export default function ModeRoute({
});
mode?.onModeEnter({ servicesManager, extensionManager, commandsManager });
// Adding hanging protocols of extensions after onModeEnter since
// it will reset the protocols
hangingProtocols.forEach(extensionProtocols => {
const hangingProtocolModule = extensionManager.getModuleEntry(
extensionProtocols
);
if (hangingProtocolModule?.protocols) {
HangingProtocolService.addProtocols(hangingProtocolModule.protocols);
}
});
const setupRouteInit = async () => {
if (route.init) {
return await route.init({
servicesManager,
extensionManager,
hotkeysManager,
studyInstanceUIDs,
dataSource,
});
return await route.init(
{
servicesManager,
extensionManager,
hotkeysManager,
studyInstanceUIDs,
dataSource,
},
hangingProtocol
);
}
return defaultRouteInit({
servicesManager,
studyInstanceUIDs,
dataSource,
});
return defaultRouteInit(
{
servicesManager,
studyInstanceUIDs,
dataSource,
},
hangingProtocol
);
};
let unsubscriptions;
@ -291,7 +289,6 @@ export default function ModeRoute({
hotkeysManager,
studyInstanceUIDs,
refresh,
hangingProtocols,
]);
const renderLayoutData = props => {