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 authored and GitHub committed 2022-09-20 23:40:26 -04:00
1 parent fb9744a817
commit 5f480ff746
28 files changed
+705 -226

No files matched your search

@@ -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;
@@ -18,6 +18,9 @@ describe('ExtensionManager.js', () => {
services: {
// Required for DataSource Module initiation
UserAuthenticationService: jest.fn(),
HangingProtocolService: {
addProtocol: jest.fn(),
},
},
};
appConfig = {
@@ -2,6 +2,7 @@ interface IDisplaySet {
displaySetInstanceUID: string;
StudyInstanceUID: string;
SeriesInstanceUID?: string;
SeriesNumber?: string;
}
export default IDisplaySet;
@@ -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({
@@ -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] }
);
@@ -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);
},
};
@@ -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.`);
@@ -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
);
});
});
});
+140
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
};
+9 -1
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,
};