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,