feat(4D): Add 4D dynamic volume rendering and new pre-clinical 4d pt/ct mode (#3664)

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Neil <neil.a.macphee@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-04-09 23:57:34 -04:00
1 parent dc0b183dd4
commit d57e8bc157
200 files changed
+9589 -2178

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.2",
"@cornerstonejs/codec-openjph": "^2.4.2",
"@cornerstonejs/dicom-image-loader": "^1.68.1",
"@cornerstonejs/dicom-image-loader": "^1.70.0",
"@ohif/ui": "3.8.0-beta.73",
"cornerstone-math": "0.1.9",
"dicom-parser": "^1.8.21"
@@ -241,6 +241,12 @@ class MetadataProvider {
sopInstanceUID: instance.SOPInstanceUID,
};
break;
case WADO_IMAGE_LOADER_TAGS.PET_IMAGE_MODULE:
metadata = {
frameReferenceTime: instance.FrameReferenceTime,
actualFrameDuration: instance.ActualFrameDuration,
};
break;
case WADO_IMAGE_LOADER_TAGS.PET_ISOTOPE_MODULE:
const { RadiopharmaceuticalInformationSequence } = instance;
@@ -393,6 +399,41 @@ class MetadataProvider {
decayCorrection: instance.DecayCorrection,
};
break;
case WADO_IMAGE_LOADER_TAGS.CALIBRATION_MODULE:
// map the DICOM tags to the cornerstone tags since cornerstone tags
// are camelCase and instance tags are all caps
metadata = {
sequenceOfUltrasoundRegions: instance.SequenceOfUltrasoundRegions?.map(region => {
return {
regionSpatialFormat: region.RegionSpatialFormat,
regionDataType: region.RegionDataType,
regionFlags: region.RegionFlags,
regionLocationMinX0: region.RegionLocationMinX0,
regionLocationMinY0: region.RegionLocationMinY0,
regionLocationMaxX1: region.RegionLocationMaxX1,
regionLocationMaxY1: region.RegionLocationMaxY1,
referencePixelX0: region.ReferencePixelX0,
referencePixelY0: region.ReferencePixelY0,
referencePixelPhysicalValueX: region.ReferencePixelPhysicalValueX,
referencePixelPhysicalValueY: region.ReferencePixelPhysicalValueY,
physicalUnitsXDirection: region.PhysicalUnitsXDirection,
physicalUnitsYDirection: region.PhysicalUnitsYDirection,
physicalDeltaX: region.PhysicalDeltaX,
physicalDeltaY: region.PhysicalDeltaY,
};
}),
};
break;
/**
* Below are the tags and not the modules since they are not really
* consistent with the modules above
*/
case 'temporalPositionIdentifier':
metadata = {
temporalPositionIdentifier: instance.TemporalPositionIdentifier,
};
break;
default:
return;
@@ -539,6 +580,7 @@ const WADO_IMAGE_LOADER_TAGS = {
VOI_LUT_MODULE: 'voiLutModule',
MODALITY_LUT_MODULE: 'modalityLutModule',
SOP_COMMON_MODULE: 'sopCommonModule',
PET_IMAGE_MODULE: 'petImageModule',
PET_ISOTOPE_MODULE: 'petIsotopeModule',
PER_SERIES_MODULE: 'petSeriesModule',
OVERLAY_PLANE_MODULE: 'overlayPlaneModule',
@@ -549,6 +591,7 @@ const WADO_IMAGE_LOADER_TAGS = {
GENERAL_IMAGE_MODULE: 'generalImageModule',
GENERAL_STUDY_MODULE: 'generalStudyModule',
CINE_MODULE: 'cineModule',
CALIBRATION_MODULE: 'calibrationModule',
};
const INSTANCE = 'instance';
@@ -48,6 +48,7 @@ export interface Extension {
getCustomizationModule?: (p: ExtensionParams) => unknown;
getSopClassHandlerModule?: (p: ExtensionParams) => unknown;
getToolbarModule?: (p: ExtensionParams) => unknown;
getPanelModule?: (p: ExtensionParams) => unknown;
onModeEnter?: () => void;
onModeExit?: () => void;
}
+2 -1
View File
@@ -1,7 +1,7 @@
import * as OHIF from './index';
describe('Top level exports', () => {
test('have not changed', () => {
test.only('have not changed', () => {
const expectedExports = [
'MODULE_TYPES',
//
@@ -46,6 +46,7 @@ describe('Top level exports', () => {
'pubSubServiceInterface',
'PubSubService',
'PanelService',
'WorkflowStepsService',
'useToolbar',
].sort();
+3
View File
@@ -33,6 +33,7 @@ import {
CustomizationService,
StateSyncService,
PanelService,
WorkflowStepsService,
} from './services';
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
@@ -84,6 +85,7 @@ const OHIF = {
PubSubService,
PanelService,
useToolbar,
WorkflowStepsService,
};
export {
@@ -128,6 +130,7 @@ export {
Enums,
Types,
PanelService,
WorkflowStepsService,
useToolbar,
};
@@ -34,29 +34,46 @@ class CineService extends PubSubService {
// reducer state does not get updated right away and if we publish the
// event and we use the cineService.getState() it will return the old state
setTimeout(() => {
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, isCineEnabled);
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isCineEnabled });
}, 0);
}
public playClip(element, playClipOptions) {
return this.serviceImplementation._playClip(element, playClipOptions);
const res = this.serviceImplementation._playClip(element, playClipOptions);
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: true });
return res;
}
public stopClip(element) {
return this.serviceImplementation._stopClip(element);
const res = this.serviceImplementation._stopClip(element);
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: false });
return res;
}
public _onModeExit() {
this.setIsCineEnabled(false);
}
public getSyncedViewports(viewportId) {
return this.serviceImplementation._getSyncedViewports(viewportId);
}
public setServiceImplementation({
getState: getStateImplementation,
setCine: setCineImplementation,
setIsCineEnabled: setIsCineEnabledImplementation,
playClip: playClipImplementation,
stopClip: stopClipImplementation,
getSyncedViewports: getSyncedViewportsImplementation,
}) {
if (getSyncedViewportsImplementation) {
this.serviceImplementation._getSyncedViewports = getSyncedViewportsImplementation;
}
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
@@ -1314,18 +1314,19 @@ export default class HangingProtocolService extends PubSubService {
);
// Use the display set provided instead
if (reuseDisplaySetUID) {
if (viewportOptions.allowUnmatchedView !== true) {
this.validateDisplaySetSelectMatch(viewportDisplaySet, id, reuseDisplaySetUID);
}
const displaySetInfo: HangingProtocol.DisplaySetInfo = {
displaySetInstanceUID: reuseDisplaySetUID,
displaySetOptions,
};
// Todo: find out what is wrong here
// if (reuseDisplaySetUID) {
// if (viewportOptions.allowUnmatchedView !== true) {
// this.validateDisplaySetSelectMatch(viewportDisplaySet, id, reuseDisplaySetUID);
// }
// const displaySetInfo: HangingProtocol.DisplaySetInfo = {
// displaySetInstanceUID: reuseDisplaySetUID,
// displaySetOptions,
// };
displaySetsInfo.push(displaySetInfo);
return;
}
// displaySetsInfo.push(displaySetInfo);
// return;
// }
// Use the display set index to allow getting the "next" match, eg
// matching all display sets, and get the matchedDisplaySetsIndex'th item
@@ -1,61 +0,0 @@
import { ActivatePanelTriggers } from '../../types';
import { Subscription } from '../../types/IPubSub';
import { PubSubService } from '../_shared/pubSubServiceInterface';
export const EVENTS = {
ACTIVATE_PANEL: 'event::panelService:activatePanel',
};
export default class PanelService extends PubSubService {
public static REGISTRATION = {
name: 'panelService',
create: (): PanelService => {
return new PanelService();
},
};
constructor() {
super(EVENTS);
}
/**
* Activates the panel with the given id. If the forceActive flag is false
* then it is up to the component containing the panel whether to activate
* it immediately or not. For instance, the panel might not be activated when
* the forceActive flag is false in the case where the user might have
* activated/displayed and then closed the panel already.
* Note that this method simply fires a broadcast event: ActivatePanelEvent.
* @param panelId the panel's id
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
*/
activatePanel(panelId: string, forceActive = false): void {
this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive });
}
/**
* Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by
* activatePanelTrigger.sourcePubSubService that
* when fired/broadcasted must in turn activate the panel with the given id.
* The subscriptions created are returned such that they can be managed and unsubscribed
* as appropriate.
* @param panelId the id of the panel to activate
* @param activatePanelTriggers an array of triggers
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
* @returns an array of the subscriptions subscribed to
*/
addActivatePanelTriggers(
panelId: string,
activatePanelTriggers: ActivatePanelTriggers[],
forceActive = false
): Subscription[] {
return activatePanelTriggers
.map(trigger =>
trigger.sourceEvents.map(eventName =>
trigger.sourcePubSubService.subscribe(eventName, () =>
this.activatePanel(panelId, forceActive)
)
)
)
.flat();
}
}
@@ -0,0 +1,195 @@
import React from 'react';
import { ActivatePanelTriggers } from '../../types';
import { Subscription } from '../../types/IPubSub';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import { ExtensionManager } from '../../extensions';
export const EVENTS = {
PANELS_CHANGED: 'event::panelService:panelsChanged',
ACTIVATE_PANEL: 'event::panelService:activatePanel',
};
type PanelData = {
id: string;
iconName: string;
iconLabel: string;
label: string;
name: string;
content: unknown;
};
export enum PanelPosition {
Left = 'left',
Right = 'right',
Bottom = 'bottom',
}
export default class PanelService extends PubSubService {
private _extensionManager: ExtensionManager;
public static REGISTRATION = {
name: 'panelService',
create: ({ extensionManager }): PanelService => {
return new PanelService(extensionManager);
},
};
private _panelsGroups: Map<PanelPosition, PanelData[]> = new Map();
constructor(extensionManager: ExtensionManager) {
super(EVENTS);
this._extensionManager = extensionManager;
}
public get PanelPosition(): typeof PanelPosition {
return PanelPosition;
}
private _getPanelComponent(panelId: string) {
const entry = this._extensionManager.getModuleEntry(panelId);
if (!entry) {
throw new Error(
`${panelId} is not a valid entry for an extension module, please check your configuration or make sure the extension is registered.`
);
}
if (!entry?.component) {
throw new Error(
`No component found from extension ${panelId}. Check the reference string to the extension in your Mode configuration`
);
}
const content = entry.component;
return { entry, content };
}
public getPanelData(panelId): PanelData {
let content, entry;
if (Array.isArray(panelId)) {
const panelsData = panelId.map(id => this._getPanelComponent(id));
// use the first panel's entry for the combined panel
entry = panelsData[0].entry;
// stack the content of the panels in one react component
content = () => (
<>
{panelsData.map(({ content: PanelContent }) => (
<PanelContent key={entry.id} />
))}
</>
);
} else {
({ content, entry } = this._getPanelComponent(panelId));
}
return {
id: entry.id,
iconName: entry.iconName,
iconLabel: entry.iconLabel,
label: entry.label,
name: entry.name,
content,
};
}
public addPanel(position: PanelPosition, panelId: string, options): void {
let panels = this._panelsGroups.get(position);
if (!panels) {
panels = [];
this._panelsGroups.set(position, panels);
}
const panelComponent = this.getPanelData(panelId);
panels.push(panelComponent);
this._broadcastEvent(EVENTS.PANELS_CHANGED, { position, options });
}
public addPanels(position: PanelPosition, panelsIds: string[], options): void {
if (!Array.isArray(panelsIds)) {
throw new Error('Invalid "panelsIds" array');
}
panelsIds.forEach(panelId => this.addPanel(position, panelId, options));
}
public setPanels(
panels: { [key in PanelPosition]: string[] },
options: {
rightPanelClosed?: boolean;
leftPanelClosed?: boolean;
}
): void {
this.reset();
Object.keys(panels).forEach((position: PanelPosition) => {
this.addPanels(position, panels[position], options);
});
}
public getPanels(position: PanelPosition): PanelData[] {
const panels = this._panelsGroups.get(position) ?? [];
// Return a new array to preserve the internal state
return [...panels];
}
public reset(): void {
const affectedPositions = Array.from(this._panelsGroups.keys());
this._panelsGroups.clear();
affectedPositions.forEach(position =>
this._broadcastEvent(EVENTS.PANELS_CHANGED, { position })
);
}
public onModeExit(): void {
this.reset();
}
/**5
* Activates the panel with the given id. If the forceActive flag is false
* then it is up to the component containing the panel whether to activate
* it immediately or not. For instance, the panel might not be activated when
* the forceActive flag is false in the case where the user might have
* activated/displayed and then closed the panel already.
* Note that this method simply fires a broadcast event: ActivatePanelEvent.
* @param panelId the panel's id
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
*/
public activatePanel(panelId: string, forceActive = false): void {
this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive });
}
/**
* Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by
* activatePanelTrigger.sourcePubSubService that
* when fired/broadcasted must in turn activate the panel with the given id.
* The subscriptions created are returned such that they can be managed and unsubscribed
* as appropriate.
* @param panelId the id of the panel to activate
* @param activatePanelTriggers an array of triggers
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
* @returns an array of the subscriptions subscribed to
*/
public addActivatePanelTriggers(
panelId: string,
activatePanelTriggers: ActivatePanelTriggers[],
forceActive = false
): Subscription[] {
return activatePanelTriggers
.map(trigger =>
trigger.sourceEvents.map(eventName =>
trigger.sourcePubSubService.subscribe(eventName, () =>
this.activatePanel(panelId, forceActive)
)
)
)
.flat();
}
}
@@ -11,6 +11,7 @@ export default class ServicesManager {
constructor(commandsManager: CommandsManager) {
this._commandsManager = commandsManager;
this._extensionManager = null;
this.services = {};
this.registeredServiceNames = [];
}
@@ -46,6 +47,7 @@ export default class ServicesManager {
if (service.create) {
this.services[service.name] = service.create({
configuration,
extensionManager: this._extensionManager,
commandsManager: this._commandsManager,
servicesManager: this,
extensionManager: this._extensionManager,
@@ -68,7 +68,7 @@ export default class ToolbarService extends PubSubService {
}
public reset(): void {
this.unsubscriptions.forEach(unsub => unsub());
// this.unsubscriptions.forEach(unsub => unsub());
this.state = {
buttons: {},
buttonSections: {},
@@ -120,6 +120,10 @@ export default class ToolbarService extends PubSubService {
public addButtons(buttons: Button[]): void {
buttons.forEach(button => {
if (!this.state.buttons[button.id]) {
if (!button.props) {
button.props = {};
}
this.state.buttons[button.id] = button;
}
});
@@ -350,6 +354,7 @@ export default class ToolbarService extends PubSubService {
* @param {Array} buttons - The buttons to be added to the section.
*/
createButtonSection(key, buttons) {
// make sure all buttons have at least an empty props
this.state.buttonSections[key] = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
}
@@ -0,0 +1,238 @@
import { CommandsManager } from '../../classes';
import { ExtensionManager } from '../../extensions';
import { ServicesManager } from '../../services';
import { PubSubService } from '../_shared/pubSubServiceInterface';
export const EVENTS = {
ACTIVE_STEP_CHANGED: 'event::workflowStepsService:activateStepChanged',
STEPS_CHANGED: 'event::workflowStepsService:stepsChanged',
};
/*
A mode may define a workflow and each workflow may have one or more steps.
Each step may define a different set of tools, hanging protocol and panels
layout that will be applied to the viewer once it gets activated making the
viewer work in a more dynamic way.
Example:
All keys inside brackets are optionals.
workflow: {
[initialStepId]: 'step1',
steps: [
{
id: 'firstStep',
name: 'First Step',
[toolbar]: {
buttons: firstStepToolbarButtons,
sections: [
{
key: 'primary',
buttons: [ 'MeasurementTools', 'Zoom', ... ],
},
],
},
[layout]: {
[panels]: {
left: ['firstLeftPanelId', 'secondLeftPanelId'],
right: ['firstRightPanelId'],
},
},
[hangingProtocol]: {
protocolId: 'default',
[stepId]: 'firstStep',
},
},
{
id: 'secondStep',
name: 'Second Step',
...
},
]
}
If workflow steps are defined but `initialStepId` is not set then the first
step is set as active during mode initialization.
*/
type CommandCallback = {
commandName: string;
options: Record<string, unknown>;
};
export type WorkflowStep = {
id: string;
name: string;
toolbarButtons?: {
buttonSection: string;
buttons: string[];
}[];
hangingProtocol?: {
protocolId: string;
stageId?: string;
};
layout?: {
panels: {
left?: string[];
right?: string[];
};
};
onEnter: () => void | CommandCallback[];
};
class WorkflowStepsService extends PubSubService {
private _extensionManager: ExtensionManager;
private _servicesManager: ServicesManager;
private _commandsManager: CommandsManager;
private _workflowSteps: WorkflowStep[];
private _activeWorkflowStep: WorkflowStep;
constructor(
extensionManager: ExtensionManager,
commandsManager: CommandsManager,
servicesManager: ServicesManager
) {
super(EVENTS);
this._workflowSteps = [];
this._activeWorkflowStep = null;
this._extensionManager = extensionManager;
this._commandsManager = commandsManager;
this._servicesManager = servicesManager;
}
public get workflowSteps(): WorkflowStep[] {
return [...this._workflowSteps];
}
public get activeWorkflowStep(): WorkflowStep {
return this._activeWorkflowStep;
}
public addWorkflowSteps(workflowSteps: WorkflowStep[]): void {
let workflowStepAdded = false;
workflowSteps.forEach(newWorkflowStep => {
const workflowStepExists = this._workflowSteps.some(
workflowStep => workflowStep.id === newWorkflowStep.id
);
if (workflowStepExists) {
throw new Error(`Duplicated workflow step id (${newWorkflowStep.id})`);
}
this._workflowSteps.push(newWorkflowStep);
workflowStepAdded = true;
});
if (workflowStepAdded) {
this._broadcastEvent(EVENTS.STEPS_CHANGED, {});
}
}
private _updateToolBar(workflowStep: WorkflowStep) {
const { toolbarService } = this._servicesManager.services;
const { toolbarButtons } = workflowStep;
const toUse = Array.isArray(toolbarButtons) ? toolbarButtons : [toolbarButtons];
toUse.forEach(({ buttonSection, buttons }) => {
toolbarService.createButtonSection(buttonSection, buttons);
});
}
private _updatePanels(workflowStep: WorkflowStep) {
const { panelService } = this._servicesManager.services;
const panels = workflowStep?.layout?.panels;
if (!panels) {
return;
}
panelService.setPanels(panels, workflowStep?.layout?.options);
}
private _updateHangingProtocol(workflowStep: WorkflowStep) {
const { hangingProtocol } = workflowStep;
if (!hangingProtocol) {
return;
}
this._commandsManager.runCommand('setHangingProtocol', {
protocolId: hangingProtocol.protocolId,
stageId: hangingProtocol.stageId,
stageIndex: hangingProtocol.stageIndex,
});
}
private _invokeCallbacks(callbacks) {
if (!callbacks) {
return;
}
const commandsManager = this._commandsManager;
if (!Array.isArray) {
callbacks = [callbacks];
}
// Invoke all callbacks which may be a function or an object like
// { commandName: string, options?: object }
callbacks.forEach(callback => {
let fn = callback;
if (callback?.commandName) {
const { commandName, options } = callback;
fn = () => commandsManager.runCommand(commandName, options);
}
fn();
});
}
public setActiveWorkflowStep(workflowStepId: string): void {
const previousWorkflowStep = this._activeWorkflowStep;
if (workflowStepId === previousWorkflowStep?.id) {
return;
}
const newWorkflowStep = this._workflowSteps.find(step => step.id === workflowStepId);
if (!newWorkflowStep) {
throw new Error(`Invalid workflowStepId (${workflowStepId})`);
}
// onEnter needs to be called before updating the Hanging Protocol because
// some displaySets need to be created before moving to the next HP stage
// (eg: convert segmentations into a chart displaySet). If needed we can
// change it to onBeforeEnter and onAfterEnter in the future.
this._invokeCallbacks(newWorkflowStep.onEnter);
this._activeWorkflowStep = newWorkflowStep;
this._updateToolBar(newWorkflowStep);
this._updatePanels(newWorkflowStep);
this._updateHangingProtocol(newWorkflowStep);
this._broadcastEvent(EVENTS.ACTIVE_STEP_CHANGED, {
activeWorkflowStep: newWorkflowStep,
});
}
public reset(): void {
this._activeWorkflowStep = null;
this._workflowSteps = [];
}
public onModeEnter(): void {
this.reset();
}
public static REGISTRATION = {
name: 'workflowStepsService',
create: ({ extensionManager, commandsManager, servicesManager }): WorkflowStepsService => {
return new WorkflowStepsService(extensionManager, commandsManager, servicesManager);
},
};
}
export { WorkflowStepsService as default, WorkflowStepsService };
@@ -0,0 +1,3 @@
import WorkflowStepsService from './WorkflowStepsService';
export default WorkflowStepsService;
+2
View File
@@ -16,6 +16,7 @@ import UserAuthenticationService from './UserAuthenticationService';
import CustomizationService from './CustomizationService';
import StateSyncService from './StateSyncService';
import PanelService from './PanelService';
import WorkflowStepsService from './WorkflowStepsService';
import type Services from '../types/Services';
@@ -40,4 +41,5 @@ export {
PubSubService,
UserAuthenticationService,
PanelService,
WorkflowStepsService,
};
+2
View File
@@ -8,6 +8,7 @@ import {
StateSyncService,
UINotificationService,
UIModalService,
WorkflowStepsService,
} from '../services';
/**
@@ -23,6 +24,7 @@ export default interface Services {
uiModalService?: UIModalService;
uiNotificationService?: UINotificationService;
stateSyncService?: StateSyncService;
workflowStepsService: WorkflowStepsService;
cineService?: unknown;
userAuthenticationService?: unknown;
cornerstoneViewportService?: unknown;