feat: Automate tracking prompts for a less disruptive annotation workflow (#4822)

Co-authored-by: Devu Jayalekshmi <devu.jayalekshmi@trenser.com>
This commit is contained in:
nithin-trenserandDevu Jayalekshmi authored and GitHub committed 2025-04-04 12:02:13 -04:00
1 parent c0391cea1b
commit 54fded554e
42 files changed
+948 -70

No files matched your search

@@ -3,14 +3,19 @@ import PropTypes from 'prop-types';
import { Machine } from 'xstate';
import { useMachine } from '@xstate/react';
import { useViewportGrid } from '@ohif/ui-next';
import { promptLabelAnnotation, promptSaveReport } from '@ohif/extension-default';
import { machineConfiguration, defaultOptions, RESPONSE } from './measurementTrackingMachine';
import promptBeginTracking from './promptBeginTracking';
import promptTrackNewSeries from './promptTrackNewSeries';
import promptTrackNewStudy from './promptTrackNewStudy';
import promptHydrateStructuredReport from './promptHydrateStructuredReport';
import { measurementTrackingMode } from './promptBeginTracking';
import hydrateStructuredReport from './hydrateStructuredReport';
import { useAppConfig } from '@state';
import {
promptBeginTrackingWrapper,
promptHydrateStructuredReportWrapper,
promptTrackNewSeriesWrapper,
promptTrackNewStudyWrapper,
promptLabelAnnotationWrapper,
promptSaveReportWrapper,
promptHasDirtyAnnotationsWrapper,
} from './promptWrapperFunctions';
const TrackedMeasurementsContext = React.createContext();
TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext';
@@ -35,7 +40,8 @@ function TrackedMeasurementsContextProvider(
const machineOptions = Object.assign({}, defaultOptions);
machineOptions.actions = Object.assign({}, machineOptions.actions, {
jumpToFirstMeasurementInActiveViewport: (ctx, evt) => {
const { trackedStudy, trackedSeries, activeViewportId } = ctx;
const { trackedStudy, trackedSeries } = ctx;
const { viewportId: activeViewportId } = evt.data;
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
@@ -82,7 +88,8 @@ function TrackedMeasurementsContextProvider(
},
jumpToSameImageInActiveViewport: (ctx, evt) => {
const { trackedStudy, trackedSeries, activeViewportId } = ctx;
const { trackedStudy, trackedSeries } = ctx;
const { viewportId: activeViewportId } = evt.data;
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
@@ -143,31 +150,64 @@ function TrackedMeasurementsContextProvider(
for (let i = 0; i < measurementIds.length; i++) {
measurementService.remove(measurementIds[i]);
}
measurementService.setIsMeasurementDeletedIndividually(false);
},
clearDisplaySetHydratedState: (ctx, evt) => {
const { displaySetInstanceUID } = evt.data ?? evt;
const displaysets = displaySetService.getActiveDisplaySets();
displaysets?.forEach(displayset => {
if (
displayset.Modality === 'SR' &&
displayset.displaySetInstanceUID !== displaySetInstanceUID &&
displayset.isHydrated
) {
displayset.isHydrated = false;
displayset.isLoaded = false;
}
});
},
updatedViewports: (ctx, evt) => {
const { hangingProtocolService } = servicesManager.services;
const { displaySetInstanceUID, viewportId } = evt.data ?? evt;
const updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
viewportId,
displaySetInstanceUID
);
viewportGridService.setDisplaySetsForViewports(updatedViewports);
},
});
machineOptions.services = Object.assign({}, machineOptions.services, {
promptBeginTracking: promptBeginTracking.bind(null, {
promptBeginTracking: promptBeginTrackingWrapper.bind(null, {
servicesManager,
extensionManager,
appConfig,
}),
promptTrackNewSeries: promptTrackNewSeries.bind(null, {
promptTrackNewSeries: promptTrackNewSeriesWrapper.bind(null, {
servicesManager,
extensionManager,
appConfig,
}),
promptTrackNewStudy: promptTrackNewStudy.bind(null, {
promptTrackNewStudy: promptTrackNewStudyWrapper.bind(null, {
servicesManager,
extensionManager,
appConfig,
}),
promptSaveReport: promptSaveReport.bind(null, {
promptSaveReport: promptSaveReportWrapper.bind(null, {
servicesManager,
commandsManager,
extensionManager,
appConfig,
}),
promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, {
promptHydrateStructuredReport: promptHydrateStructuredReportWrapper.bind(null, {
servicesManager,
extensionManager,
commandsManager,
appConfig,
}),
promptHasDirtyAnnotations: promptHasDirtyAnnotationsWrapper.bind(null, {
servicesManager,
extensionManager,
commandsManager,
@@ -179,7 +219,7 @@ function TrackedMeasurementsContextProvider(
commandsManager,
appConfig,
}),
promptLabelAnnotation: promptLabelAnnotation.bind(null, {
promptLabelAnnotation: promptLabelAnnotationWrapper.bind(null, {
servicesManager,
extensionManager,
commandsManager,
@@ -194,6 +234,25 @@ function TrackedMeasurementsContextProvider(
const labelConfig = customizationService.getCustomization('measurementLabels');
return evt.data && evt.data.userResponse === RESPONSE.NO_NEVER && labelConfig?.labelOnMeasure;
},
isSimplifiedConfig: (ctx, evt, condMeta) => {
return appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED;
},
simplifiedAndLoadSR: (ctx, evt, condMeta) => {
return (
appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED &&
evt.data.isBackupSave === false
);
},
hasDirtyAndSimplified: (ctx, evt, condMeta) => {
const measurements = measurementService.getMeasurements();
const hasDirtyMeasurements =
measurements.some(measurement => measurement.isDirty) ||
(measurements.length && measurementService.getIsMeasurementDeletedIndividually());
return (
appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED &&
hasDirtyMeasurements
);
},
});
// TODO: IMPROVE
@@ -266,7 +325,8 @@ function TrackedMeasurementsContextProvider(
// load function added by our sopClassHandler module
if (
displaySet.SOPClassHandlerId === SR_SOPCLASSHANDLERID &&
displaySet.isRehydratable === true
displaySet.isRehydratable === true &&
!displaySet.isHydrated
) {
const params = {
displaySetInstanceUID: displaySet.displaySetInstanceUID,
@@ -122,13 +122,27 @@ const machineConfiguration = {
UNTRACK_SERIES: [
{
target: 'tracking',
actions: ['removeTrackedSeries', 'setIsDirty'],
actions: ['removeTrackedSeries', 'setIsDirty', 'clearDisplaySetHydratedState'],
cond: 'hasRemainingTrackedSeries',
},
{
target: 'idle',
},
],
UNTRACK_ALL: [
{
target: 'tracking',
actions: [
'clearContext',
'setIsDirtyToClean',
'clearDisplaySetHydratedState',
'clearAllMeasurements',
],
},
{
target: 'idle',
},
],
SET_TRACKED_SERIES: [
{
target: 'tracking',
@@ -146,6 +160,15 @@ const machineConfiguration = {
target: 'tracking',
},
],
CHECK_DIRTY: {
target: 'promptHasDirtyAnnotations',
cond: 'hasDirtyAndSimplified',
},
PROMPT_HYDRATE_SR: {
target: 'promptHydrateStructuredReport',
cond: 'isSimplifiedConfig',
actions: ['clearAllMeasurements', 'clearDisplaySetHydratedState'],
},
},
},
promptTrackNewSeries: {
@@ -214,6 +237,16 @@ const machineConfiguration = {
invoke: {
src: 'promptSaveReport',
onDone: [
{
target: 'tracking',
actions: [
'clearAllMeasurements',
'clearDisplaySetHydratedState',
'setIsDirty',
'updatedViewports',
],
cond: 'simplifiedAndLoadSR',
},
// "clicked the save button"
// - should clear all measurements
// - show DICOM SR
@@ -312,6 +345,28 @@ const machineConfiguration = {
],
},
},
promptHasDirtyAnnotations: {
invoke: {
src: 'promptHasDirtyAnnotations',
onDone: [
{
target: 'tracking',
actions: [
'clearAllMeasurements',
'clearDisplaySetHydratedState',
'setIsDirty',
'updatedViewports',
],
cond: 'shouldSetStudyAndSeries',
},
{
target: 'promptSaveReport',
cond: 'shouldPromptSaveReport',
},
{ target: 'tracking' },
],
},
},
},
strict: true,
};
@@ -9,17 +9,29 @@ const RESPONSE = {
SET_STUDY_AND_SERIES: 3,
};
export const measurementTrackingMode = {
STANDARD: 'standard',
SIMPLIFIED: 'simplified',
NONE: 'none',
};
function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) {
const { uiViewportDialogService } = servicesManager.services;
const { uiViewportDialogService, customizationService } = servicesManager.services;
const appConfig = extensionManager._appConfig;
// When the state change happens after a promise, the state machine sends the retult in evt.data;
// In case of direct transition to the state, the state machine sends the data in evt;
const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
let promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.SET_STUDY_AND_SERIES
: await _askTrackMeasurements(uiViewportDialogService, viewportId);
const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
const noTrackingMode = appConfig?.measurementTrackingMode === measurementTrackingMode.NONE;
let promptResult;
promptResult = noTrackingMode
? RESPONSE.NO_NEVER
: standardMode
? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
resolve({
userResponse: promptResult,
@@ -30,9 +42,11 @@ function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) {
});
}
function _askTrackMeasurements(uiViewportDialogService, viewportId) {
function _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message = i18n.t('MeasurementTable:Track measurements for this series?');
const message = customizationService.getCustomization(
'viewportNotification.beginTrackingMessage'
);
const actions = [
{
id: 'prompt-begin-tracking-cancel',
@@ -0,0 +1,81 @@
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
CREATE_REPORT: 1,
ADD_SERIES: 2,
SET_STUDY_AND_SERIES: 3,
NO_NOT_FOR_SERIES: 4,
};
function promptHasDirtyAnnotations({ servicesManager }: withAppTypes, ctx, evt) {
const { viewportId, displaySetInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
const { uiViewportDialogService, customizationService } = servicesManager.services;
const promptResult = await _askSaveDiscardOrCancel(
uiViewportDialogService,
customizationService,
viewportId
);
resolve({
displaySetInstanceUID,
userResponse: promptResult,
viewportId,
isBackupSave: false,
});
});
}
function _askSaveDiscardOrCancel(
UIViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message = customizationService.getCustomization(
'viewportNotification.discardDirtyMessage'
);
const actions = [
{ id: 'cancel', type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
{
id: 'discard-existing',
type: 'secondary',
text: 'No, discard existing',
value: RESPONSE.SET_STUDY_AND_SERIES,
},
{
id: 'save-existing',
type: 'primary',
text: 'Yes',
value: RESPONSE.CREATE_REPORT,
},
];
const onSubmit = result => {
UIViewportDialogService.hide();
resolve(result);
};
UIViewportDialogService.show({
viewportId,
id: 'measurement-tracking-prompt-dirty-measurement',
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
UIViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
const action = actions.find(action => action.id === 'save-existing');
onSubmit(action.value);
}
},
});
});
}
export default promptHasDirtyAnnotations;
@@ -1,5 +1,6 @@
import { hydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr';
import { ButtonEnums } from '@ohif/ui';
import { measurementTrackingMode } from './promptBeginTracking';
const RESPONSE = {
NO_NEVER: -1,
@@ -16,13 +17,16 @@ function promptHydrateStructuredReport(
ctx,
evt
) {
const { uiViewportDialogService, displaySetService } = servicesManager.services;
const { uiViewportDialogService, displaySetService, customizationService } =
servicesManager.services;
const { viewportId, displaySetInstanceUID } = evt;
const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_REPORT
: await _askTrackMeasurements(uiViewportDialogService, viewportId);
const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
const promptResult = standardMode
? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.HYDRATE_REPORT;
// Need to do action here... So we can set state...
let StudyInstanceUID, SeriesInstanceUIDs;
@@ -49,9 +53,9 @@ function promptHydrateStructuredReport(
});
}
function _askTrackMeasurements(uiViewportDialogService, viewportId) {
function _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to continue tracking measurements for this study?';
const message = customizationService.getCustomization('viewportNotification.hydrateSRMessage');
const actions = [
{
id: 'no-hydrate',
@@ -1,4 +1,5 @@
import { ButtonEnums } from '@ohif/ui';
import { measurementTrackingMode } from './promptBeginTracking';
const RESPONSE = {
NO_NEVER: -1,
@@ -10,17 +11,22 @@ const RESPONSE = {
};
function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) {
const { UIViewportDialogService } = servicesManager.services;
const { UIViewportDialogService, customizationService } = servicesManager.services;
// When the state change happens after a promise, the state machine sends the retult in evt.data;
// In case of direct transition to the state, the state machine sends the data in evt;
const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
let promptResult = await _askShouldAddMeasurements(UIViewportDialogService, viewportId);
const appConfig = extensionManager._appConfig;
const showPrompt = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
let promptResult = showPrompt
? await _askShouldAddMeasurements(UIViewportDialogService, customizationService, viewportId)
: RESPONSE.ADD_SERIES;
if (promptResult === RESPONSE.CREATE_REPORT) {
promptResult = ctx.isDirty
? await _askSaveDiscardOrCancel(UIViewportDialogService, viewportId)
? await _askSaveDiscardOrCancel(UIViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
}
@@ -34,9 +40,11 @@ function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) {
});
}
function _askShouldAddMeasurements(uiViewportDialogService, viewportId) {
function _askShouldAddMeasurements(uiViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to add this measurement to the existing report?';
const message = customizationService.getCustomization(
'viewportNotification.trackNewSeriesMessage'
);
const actions = [
{
type: ButtonEnums.type.secondary,
@@ -73,10 +81,12 @@ function _askShouldAddMeasurements(uiViewportDialogService, viewportId) {
});
}
function _askSaveDiscardOrCancel(UIViewportDialogService, viewportId) {
function _askSaveDiscardOrCancel(UIViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message =
'You have existing tracked measurements. What would you like to do with your existing tracked measurements?';
const message = customizationService.getCustomization(
'viewportNotification.discardSeriesMessage'
);
const actions = [
{ type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
{
@@ -1,4 +1,5 @@
import i18n from 'i18next';
import { measurementTrackingMode } from './promptBeginTracking';
const RESPONSE = {
NO_NEVER: -1,
@@ -10,18 +11,26 @@ const RESPONSE = {
};
function promptTrackNewStudy({ servicesManager, extensionManager }: withAppTypes, ctx, evt) {
const { uiViewportDialogService } = servicesManager.services;
const { uiViewportDialogService, customizationService } = servicesManager.services;
// When the state change happens after a promise, the state machine sends the retult in evt.data;
// In case of direct transition to the state, the state machine sends the data in evt;
const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
let promptResult = await _askTrackMeasurements(uiViewportDialogService, viewportId);
const appConfig = extensionManager._appConfig;
const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
const simplifiedMode =
appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED;
let promptResult = standardMode
? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
if (promptResult === RESPONSE.SET_STUDY_AND_SERIES) {
promptResult = ctx.isDirty
? await _askSaveDiscardOrCancel(uiViewportDialogService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
promptResult =
ctx.isDirty && (standardMode || simplifiedMode)
? await _askSaveDiscardOrCancel(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
}
resolve({
@@ -36,10 +45,13 @@ function promptTrackNewStudy({ servicesManager, extensionManager }: withAppTypes
function _askTrackMeasurements(
UIViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message = i18n.t('MeasurementTable:Track measurements for this series?');
const message = customizationService.getCustomization(
'viewportNotification.trackNewStudyMessage'
);
const actions = [
{ type: 'cancel', text: i18n.t('MeasurementTable:No'), value: RESPONSE.CANCEL },
{
@@ -80,11 +92,13 @@ function _askTrackMeasurements(
function _askSaveDiscardOrCancel(
UIViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message =
'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?';
const message = customizationService.getCustomization(
'viewportNotification.discardStudyMessage'
);
const actions = [
{ type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
{
@@ -0,0 +1,83 @@
const promptBeginTrackingWrapper = ({ servicesManager, extensionManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptBeginTracking = customizationService.getCustomization(
'measurement.promptBeginTracking'
);
return promptBeginTracking({ servicesManager, extensionManager }, ctx, evt);
};
const promptHydrateStructuredReportWrapper = (
{ servicesManager, extensionManager, commandsManager, appConfig },
ctx,
evt
) => {
const { customizationService } = servicesManager.services;
const promptHydrateStructuredReport = customizationService.getCustomization(
'measurement.promptHydrateStructuredReport'
);
return promptHydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager, appConfig },
ctx,
evt
);
};
const promptTrackNewSeriesWrapper = ({ servicesManager, extensionManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptTrackNewSeries = customizationService.getCustomization(
'measurement.promptTrackNewSeries'
);
return promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt);
};
const promptTrackNewStudyWrapper = ({ servicesManager, extensionManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptTrackNewStudy = customizationService.getCustomization(
'measurement.promptTrackNewStudy'
);
return promptTrackNewStudy({ servicesManager, extensionManager }, ctx, evt);
};
const promptLabelAnnotationWrapper = ({ servicesManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptLabelAnnotation = customizationService.getCustomization(
'measurement.promptLabelAnnotation'
);
return promptLabelAnnotation({ servicesManager }, ctx, evt);
};
const promptSaveReportWrapper = (
{ servicesManager, commandsManager, extensionManager },
ctx,
evt
) => {
const { customizationService } = servicesManager.services;
const promptSaveReport = customizationService.getCustomization('measurement.promptSaveReport');
return promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt);
};
const promptHasDirtyAnnotationsWrapper = (
{ servicesManager, commandsManager, extensionManager },
ctx,
evt
) => {
const { customizationService } = servicesManager.services;
const promptHasDirtyAnnotations = customizationService.getCustomization(
'measurement.promptHasDirtyAnnotations'
);
return promptHasDirtyAnnotations(
{ servicesManager, commandsManager, extensionManager },
ctx,
evt
);
};
export {
promptBeginTrackingWrapper,
promptHydrateStructuredReportWrapper,
promptTrackNewSeriesWrapper,
promptTrackNewStudyWrapper,
promptLabelAnnotationWrapper,
promptSaveReportWrapper,
promptHasDirtyAnnotationsWrapper,
};