diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index b57e07d77..45726c0bb 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -12,12 +12,12 @@ import { } from '@cornerstonejs/tools'; import { ServicesManager } from '@ohif/core'; -import { getEnabledElement as OHIFgetEnabledElement } from './state'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import callInputDialog from './utils/callInputDialog'; import { setColormap } from './utils/colormap/transferFunctionHelpers'; import toggleMPRHangingProtocol from './utils/mpr/toggleMPRHangingProtocol'; import toggleStackImageSync from './utils/stackSync/toggleStackImageSync'; +import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; const commandsModule = ({ servicesManager }) => { const { @@ -32,10 +32,7 @@ const commandsModule = ({ servicesManager }) => { } = (servicesManager as ServicesManager).services; function _getActiveViewportEnabledElement() { - const { activeViewportIndex } = viewportGridService.getState(); - const { element } = OHIFgetEnabledElement(activeViewportIndex) || {}; - const enabledElement = getEnabledElement(element); - return enabledElement; + return getActiveViewportEnabledElement(viewportGridService); } function _getToolGroup(toolGroupId) { diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 8c7aad4bd..8752baefe 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -12,6 +12,7 @@ import { volumeLoader, imageLoadPoolManager, Settings, + utilities as csUtilities, } from '@cornerstonejs/core'; import { Enums, utilities, ReferenceLinesTool } from '@cornerstonejs/tools'; import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader'; @@ -126,6 +127,12 @@ export default async function init({ ); hangingProtocolService.registerImageLoadStrategy('nth', nthLoader); + // add metadata providers + metaData.addProvider( + csUtilities.calibratedPixelSpacingMetadataProvider.get.bind( + csUtilities.calibratedPixelSpacingMetadataProvider + ) + ); // this provider is required for Calibration tool metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999); imageLoadPoolManager.maxNumRequests = { @@ -138,9 +145,7 @@ export default async function init({ /* Measurement Service */ const measurementServiceSource = connectToolsToMeasurementService( - measurementService, - displaySetService, - cornerstoneViewportService + servicesManager ); initCineService(cineService); diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 80dc0678b..291e02924 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -25,6 +25,8 @@ import { ReferenceLinesTool, } from '@cornerstonejs/tools'; +import CalibrationLineTool from './tools/CalibrationLineTool'; + export default function initCornerstoneTools(configuration = {}) { init(configuration); addTool(PanTool); @@ -48,6 +50,7 @@ export default function initCornerstoneTools(configuration = {}) { addTool(CrosshairsTool); addTool(SegmentationDisplayTool); addTool(ReferenceLinesTool); + addTool(CalibrationLineTool); // Modify annotation tools to use dashed lines on SR const annotationStyle = { @@ -86,6 +89,7 @@ const toolNames = { Crosshairs: CrosshairsTool.toolName, SegmentationDisplay: SegmentationDisplayTool.toolName, ReferenceLines: ReferenceLinesTool.toolName, + CalibrationLine: CalibrationLineTool.toolName, }; export { toolNames }; diff --git a/extensions/cornerstone/src/initMeasurementService.js b/extensions/cornerstone/src/initMeasurementService.js index 9ce6fc5c7..e081435ce 100644 --- a/extensions/cornerstone/src/initMeasurementService.js +++ b/extensions/cornerstone/src/initMeasurementService.js @@ -1,6 +1,8 @@ import { eventTarget } from '@cornerstonejs/core'; import { Enums, annotation } from '@cornerstonejs/tools'; -import { DicomMetadataStore, MeasurementService } from '@ohif/core'; +import { DicomMetadataStore } from '@ohif/core'; +import { toolNames } from './initCornerstoneTools'; +import { onCompletedCalibrationLine } from './tools/CalibrationLineTool'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; @@ -102,14 +104,25 @@ const initMeasurementService = ( PlanarFreehandROI.toMeasurement ); + // On the UI side, the Calibration Line tool will work almost the same as the + // Length tool + measurementService.addMapping( + csTools3DVer1MeasurementSource, + 'CalibrationLine', + Length.matchingCriteria, + Length.toAnnotation, + Length.toMeasurement + ); + return csTools3DVer1MeasurementSource; }; -const connectToolsToMeasurementService = ( - measurementService, - displaySetService, - cornerstoneViewportService -) => { +const connectToolsToMeasurementService = servicesManager => { + const { + measurementService, + displaySetService, + cornerstoneViewportService, + } = servicesManager.services; const csTools3DVer1MeasurementSource = initMeasurementService( measurementService, displaySetService, @@ -131,15 +144,38 @@ const connectToolsToMeasurementService = ( } = annotationAddedEventDetail; const { toolName } = metadata; - // To force the measurementUID be the same as the annotationUID - // Todo: this should be changed when a measurement can include multiple annotations - // in the future - annotationAddedEventDetail.uid = annotationUID; - annotationToMeasurement(toolName, annotationAddedEventDetail); + if ( + csToolsEvent.type === completedEvt && + toolName === toolNames.CalibrationLine + ) { + // show modal to input the measurement (mm) + onCompletedCalibrationLine(servicesManager, csToolsEvent) + .then( + () => { + console.log('calibration applied'); + }, + () => true + ) + .finally(() => { + // we don't need the calibration line lingering around, remove the + // annotation from the display + removeAnnotation(annotationUID); + removeMeasurement(csToolsEvent); + // this will ensure redrawing of annotations + cornerstoneViewportService.resize(); + }); + } else { + // To force the measurementUID be the same as the annotationUID + // Todo: this should be changed when a measurement can include multiple annotations + // in the future + annotationAddedEventDetail.uid = annotationUID; + annotationToMeasurement(toolName, annotationAddedEventDetail); + } } catch (error) { console.warn('Failed to update measurement:', error); } } + function updateMeasurement(csToolsEvent) { try { const annotationModifiedEventDetail = csToolsEvent.detail; diff --git a/extensions/cornerstone/src/tools/CalibrationLineTool.ts b/extensions/cornerstone/src/tools/CalibrationLineTool.ts new file mode 100644 index 000000000..27e521d46 --- /dev/null +++ b/extensions/cornerstone/src/tools/CalibrationLineTool.ts @@ -0,0 +1,134 @@ +import { metaData } from '@cornerstonejs/core'; +import { LengthTool } from '@cornerstonejs/tools'; +import { calibrateImageSpacing } from '@cornerstonejs/tools/dist/esm/utilities'; +import callInputDialog from '../utils/callInputDialog'; +import getActiveViewportEnabledElement from '../utils/getActiveViewportEnabledElement'; + +/** + * Calibration Line tool works almost the same as the + */ +class CalibrationLineTool extends LengthTool { + static toolName = 'CalibrationLine'; + + _renderingViewport: any; + _lengthToolRenderAnnotation = this.renderAnnotation; + + renderAnnotation = (enabledElement, svgDrawingHelper) => { + const { viewport } = enabledElement; + this._renderingViewport = viewport; + return this._lengthToolRenderAnnotation(enabledElement, svgDrawingHelper); + }; + + _getTextLines(data, targetId) { + const [canvasPoint1, canvasPoint2] = data.handles.points.map(p => + this._renderingViewport.worldToCanvas(p) + ); + // for display, round to 2 decimal points + const lengthPx = + Math.round(calculateLength2(canvasPoint1, canvasPoint2) * 100) / 100; + + const textLines = [`${lengthPx}px`]; + + return textLines; + } +} + +function calculateLength2(point1, point2) { + const dx = point1[0] - point2[0]; + const dy = point1[1] - point2[1]; + return Math.sqrt(dx * dx + dy * dy); +} + +function calculateLength3(pos1, pos2) { + const dx = pos1[0] - pos2[0]; + const dy = pos1[1] - pos2[1]; + const dz = pos1[2] - pos2[2]; + + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +export default CalibrationLineTool; + +export function onCompletedCalibrationLine(servicesManager, csToolsEvent) { + const { uiDialogService, viewportGridService } = servicesManager.services; + + // calculate length (mm) with the current Pixel Spacing + const annotationAddedEventDetail = csToolsEvent.detail; + const { + annotation: { metadata, data: annotationData }, + } = annotationAddedEventDetail; + const { referencedImageId: imageId } = metadata; + const enabledElement = getActiveViewportEnabledElement(viewportGridService); + const { viewport } = enabledElement; + + const length = + Math.round( + calculateLength3( + annotationData.handles.points[0], + annotationData.handles.points[1] + ) * 100 + ) / 100; + + // calculate the currently applied pixel spacing on the viewport + const calibratedPixelSpacing = metaData.get( + 'calibratedPixelSpacing', + imageId + ); + const imagePlaneModule = metaData.get('imagePlaneModule', imageId); + const currentRowPixelSpacing = + calibratedPixelSpacing?.[0] || imagePlaneModule?.rowPixelSpacing || 1; + const currentColumnPixelSpacing = + calibratedPixelSpacing?.[1] || imagePlaneModule?.columnPixelSpacing || 1; + + const adjustCalibration = newLength => { + const spacingScale = newLength / length; + const rowSpacing = spacingScale * currentRowPixelSpacing; + const colSpacing = spacingScale * currentColumnPixelSpacing; + + // trigger resize of the viewport to adjust the world/pixel mapping + calibrateImageSpacing( + imageId, + viewport.getRenderingEngine(), + rowSpacing, + colSpacing + ); + }; + + return new Promise((resolve, reject) => { + if (!uiDialogService) { + reject('UIDialogService is not initiated'); + return; + } + + callInputDialog( + uiDialogService, + { + text: '', + label: `${length}`, + }, + (value, id) => { + if (id === 'save') { + adjustCalibration(Number.parseFloat(value)); + resolve(true); + } else { + reject('cancel'); + } + }, + false, + { + dialogTitle: 'Calibration', + inputLabel: 'Actual Physical distance (mm)', + + // the input value must be a number + validateFunc: val => { + try { + const v = Number.parseFloat(val); + return !isNaN(v) && v !== 0.0; + } catch { + return false; + } + }, + } + ); + }); +} diff --git a/extensions/cornerstone/src/utils/callInputDialog.tsx b/extensions/cornerstone/src/utils/callInputDialog.tsx index fb27f60d4..bd59a7bef 100644 --- a/extensions/cornerstone/src/utils/callInputDialog.tsx +++ b/extensions/cornerstone/src/utils/callInputDialog.tsx @@ -9,23 +9,35 @@ import { Input, Dialog } from '@ohif/ui'; * @param {*} event * @param {*} callback * @param {*} isArrowAnnotateInputDialog + * @param {*} dialogConfig + * @param {string?} dialogConfig.dialogTitle - title of the input dialog + * @param {string?} dialogConfig.inputLabel - show label above the input */ function callInputDialog( uiDialogService, data, callback, - isArrowAnnotateInputDialog = true + isArrowAnnotateInputDialog = true, + dialogConfig: any = {} ) { - const dialogId = 'enter-annotation'; + const dialogId = 'dialog-enter-annotation'; const label = data ? isArrowAnnotateInputDialog ? data.text : data.label : ''; + const { + dialogTitle = 'Enter your annotation', + inputLabel = '', + validateFunc = value => true, + } = dialogConfig; const onSubmitHandler = ({ action, value }) => { switch (action.id) { case 'save': + if (typeof validateFunc === 'function' && !validateFunc(value.label)) + return; + callback(value.label, action.id); break; case 'cancel': @@ -43,7 +55,7 @@ function callInputDialog( showOverlay: true, content: Dialog, contentProps: { - title: 'Enter your annotation', + title: dialogTitle, value: { label }, noCloseButton: true, onClose: () => uiDialogService.dismiss({ id: dialogId }), @@ -61,6 +73,8 @@ function callInputDialog( type="text" id="annotation" containerClassName="mr-2" + label={inputLabel} + labelClassName="text-primary-light" value={value.label} onChange={event => { event.persist(); diff --git a/extensions/cornerstone/src/utils/getActiveViewportEnabledElement.ts b/extensions/cornerstone/src/utils/getActiveViewportEnabledElement.ts new file mode 100644 index 000000000..d188ece9a --- /dev/null +++ b/extensions/cornerstone/src/utils/getActiveViewportEnabledElement.ts @@ -0,0 +1,13 @@ +import { getEnabledElement } from '@cornerstonejs/core'; +import { IEnabledElement } from '@cornerstonejs/core/dist/esm/types'; + +import { getEnabledElement as OHIFgetEnabledElement } from '../state'; + +export default function getActiveViewportEnabledElement( + viewportGridService +): IEnabledElement { + const { activeViewportIndex } = viewportGridService.getState(); + const { element } = OHIFgetEnabledElement(activeViewportIndex) || {}; + const enabledElement = getEnabledElement(element); + return enabledElement; +} diff --git a/modes/basic-dev-mode/src/index.js b/modes/basic-dev-mode/src/index.js index 204b7b612..f3368e412 100644 --- a/modes/basic-dev-mode/src/index.js +++ b/modes/basic-dev-mode/src/index.js @@ -83,6 +83,7 @@ function modeFactory({ modeConfiguration }) { { toolName: toolNames.EllipticalROI }, { toolName: toolNames.RectangleROI }, { toolName: toolNames.StackScroll }, + { toolName: toolNames.CalibrationLine }, ], // enabled // disabled diff --git a/modes/basic-dev-mode/src/toolbarButtons.js b/modes/basic-dev-mode/src/toolbarButtons.js index ff1b652d0..4a08edbf0 100644 --- a/modes/basic-dev-mode/src/toolbarButtons.js +++ b/modes/basic-dev-mode/src/toolbarButtons.js @@ -323,6 +323,21 @@ const toolbarButtons = [ ], 'Invert Colors' ), + _createToolButton( + 'CalibrationLine', + 'tool-calibration', + 'Calibration', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'CalibrationLine', + }, + context: 'CORNERSTONE', + }, + ], + 'Calibration Line' + ), ], }, }, diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index a5af64e77..fe77aa52f 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -39,6 +39,7 @@ function initDefaultToolGroup( { toolName: toolNames.PlanarFreehandROI }, { toolName: toolNames.Magnify }, { toolName: toolNames.SegmentationDisplay }, + { toolName: toolNames.CalibrationLine }, ], // enabled // disabled diff --git a/modes/longitudinal/src/toolbarButtons.js b/modes/longitudinal/src/toolbarButtons.js index e62fc2f2e..3addb750b 100644 --- a/modes/longitudinal/src/toolbarButtons.js +++ b/modes/longitudinal/src/toolbarButtons.js @@ -555,6 +555,21 @@ const toolbarButtons = [ ], 'Rectangle' ), + _createToolButton( + 'CalibrationLine', + 'tool-calibration', + 'Calibration', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'CalibrationLine', + }, + context: 'CORNERSTONE', + }, + ], + 'Calibration Line' + ), _createActionButton( 'TagBrowser', 'list-bullets', diff --git a/platform/core/src/DICOMWeb/getModalities.js b/platform/core/src/DICOMWeb/getModalities.js index 9d124f59b..6cde6f22f 100644 --- a/platform/core/src/DICOMWeb/getModalities.js +++ b/platform/core/src/DICOMWeb/getModalities.js @@ -8,6 +8,10 @@ export default function getModalities(Modality, ModalitiesInStudy) { Value: [], }; + // Rare case, depending on the DICOM server we are using, but sometimes, + // modalities.Value is undefined or null. + modalities.Value = modalities.Value || []; + if (ModalitiesInStudy) { if (modalities.vr && modalities.vr === ModalitiesInStudy.vr) { for (let i = 0; i < ModalitiesInStudy.Value.length; i++) { diff --git a/platform/docs/docs/user-guide/viewer/toolbar.md b/platform/docs/docs/user-guide/viewer/toolbar.md index 9f226e0d6..cb982ce25 100644 --- a/platform/docs/docs/user-guide/viewer/toolbar.md +++ b/platform/docs/docs/user-guide/viewer/toolbar.md @@ -24,6 +24,7 @@ The basic viewer comes with the following default measurement tools: - Bidirectional Tool: Creates a measurement of the longest diameter (LD) and longest perpendicular diameter (LPD) in *mm* - Annotation: Used to create a qualitative marker with a freetext label - Ellipse: Measures an elliptical area in *mm2* and Hounsfield Units (HU) +- Calibration Tool: Calibrate (or override) the Pixel Spacing Attribute (Physical distance in the patient between the center of each pixel, specified by a numeric pair - adjacent row spacing (delimiter) adjacent column spacing in mm) When a measurement tool is selected from the toolbar, it becomes the `active` tool. Use the caret to expand the measurement tools and select another tool. diff --git a/platform/docs/versioned_docs/version-3.0/user-guide/viewer/toolbar.md b/platform/docs/versioned_docs/version-3.0/user-guide/viewer/toolbar.md index 9f226e0d6..cb982ce25 100644 --- a/platform/docs/versioned_docs/version-3.0/user-guide/viewer/toolbar.md +++ b/platform/docs/versioned_docs/version-3.0/user-guide/viewer/toolbar.md @@ -24,6 +24,7 @@ The basic viewer comes with the following default measurement tools: - Bidirectional Tool: Creates a measurement of the longest diameter (LD) and longest perpendicular diameter (LPD) in *mm* - Annotation: Used to create a qualitative marker with a freetext label - Ellipse: Measures an elliptical area in *mm2* and Hounsfield Units (HU) +- Calibration Tool: Calibrate (or override) the Pixel Spacing Attribute (Physical distance in the patient between the center of each pixel, specified by a numeric pair - adjacent row spacing (delimiter) adjacent column spacing in mm) When a measurement tool is selected from the toolbar, it becomes the `active` tool. Use the caret to expand the measurement tools and select another tool. diff --git a/platform/ui/src/assets/icons/tool-calibration.svg b/platform/ui/src/assets/icons/tool-calibration.svg new file mode 100644 index 000000000..0d810e510 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-calibration.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + diff --git a/platform/ui/src/components/Button/Button.tsx b/platform/ui/src/components/Button/Button.tsx index fdafc91bb..939d803fd 100644 --- a/platform/ui/src/components/Button/Button.tsx +++ b/platform/ui/src/components/Button/Button.tsx @@ -227,6 +227,7 @@ Button.propTypes = { /** Button corner roundness */ rounded: PropTypes.oneOf(['none', 'small', 'medium', 'large', 'full']), variant: PropTypes.oneOf(['text', 'outlined', 'contained', 'disabled']), + /* color prop must have all the possible keys of variants defined above */ color: PropTypes.oneOf([ 'default', 'primary', diff --git a/platform/ui/src/components/Icon/getIcon.js b/platform/ui/src/components/Icon/getIcon.js index dc43c6b3a..d1b1d886c 100644 --- a/platform/ui/src/components/Icon/getIcon.js +++ b/platform/ui/src/components/Icon/getIcon.js @@ -82,6 +82,7 @@ import toolReset from './../../assets/icons/tool-reset.svg'; import toolRectangle from './../../assets/icons/tool-rectangle.svg'; import toolFusionColor from './../../assets/icons/tool-fusion-color.svg'; import toolCreateThreshold from './../../assets/icons/tool-create-threshold.svg'; +import toolCalibration from './../../assets/icons/tool-calibration.svg'; import editPatient from './../../assets/icons/edit-patient.svg'; import panelGroupMore from './../../assets/icons/panel-group-more.svg'; import panelGroupOpenClose from './../../assets/icons/panel-group-open-close.svg'; @@ -185,6 +186,7 @@ const ICONS = { 'tool-rectangle': toolRectangle, 'tool-fusion-color': toolFusionColor, 'tool-create-threshold': toolCreateThreshold, + 'tool-calibration': toolCalibration, 'edit-patient': editPatient, 'icon-mpr': iconMPR, 'icon-next-inactive': iconNextInactive, diff --git a/platform/ui/src/components/Typography/Typography.tsx b/platform/ui/src/components/Typography/Typography.tsx index 9f86c129e..b1904b941 100644 --- a/platform/ui/src/components/Typography/Typography.tsx +++ b/platform/ui/src/components/Typography/Typography.tsx @@ -133,6 +133,7 @@ Typography.propTypes = { 'primaryActive', 'secondary', 'error', + 'primaryActive', ]), className: PropTypes.string, children: PropTypes.node,