feat(Calibration Tool): add a calibration tool that can manually recalibrate an image and measurements on it (#3136)

* minor bug fix - reading modalities

* react error log fix - button props

* react warn fix - Typography color prop

* calibration tool without persistence

* added toolbar button for calibration tool

* icon change for calibration tool

* important bug fix for calibration - length and calibration rate calculation

* fix weird autocorrection

* [fix] bugs after merging upstream

* [refactor] conform to the new service names
This commit is contained in:
md-prog 2023-02-22 21:01:36 -05:00 committed by GitHub
parent 7a36363735
commit b15988359f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 288 additions and 22 deletions

View File

@ -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) {

View File

@ -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);

View File

@ -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 };

View File

@ -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;

View File

@ -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;
}
},
}
);
});
}

View File

@ -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();

View File

@ -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;
}

View File

@ -83,6 +83,7 @@ function modeFactory({ modeConfiguration }) {
{ toolName: toolNames.EllipticalROI },
{ toolName: toolNames.RectangleROI },
{ toolName: toolNames.StackScroll },
{ toolName: toolNames.CalibrationLine },
],
// enabled
// disabled

View File

@ -323,6 +323,21 @@ const toolbarButtons = [
],
'Invert Colors'
),
_createToolButton(
'CalibrationLine',
'tool-calibration',
'Calibration',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'CalibrationLine',
},
context: 'CORNERSTONE',
},
],
'Calibration Line'
),
],
},
},

View File

@ -39,6 +39,7 @@ function initDefaultToolGroup(
{ toolName: toolNames.PlanarFreehandROI },
{ toolName: toolNames.Magnify },
{ toolName: toolNames.SegmentationDisplay },
{ toolName: toolNames.CalibrationLine },
],
// enabled
// disabled

View File

@ -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',

View File

@ -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++) {

View File

@ -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 *mm<sup>2</sup>* 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.
<!-- We should add a smaller screenshot for each measurement tool. Maybe have a matrix with 4 identical sized screenshots in a box? Also we should make sure the screenshots have more realistic measurements -->

View File

@ -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 *mm<sup>2</sup>* 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.
<!-- We should add a smaller screenshot for each measurement tool. Maybe have a matrix with 4 identical sized screenshots in a box? Also we should make sure the screenshots have more realistic measurements -->

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="25px" height="25px" viewBox="0 0 25 25" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="color" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="measure-calibrate" transform="translate(0.500000, 4.000000)" stroke="currentColor">
<g id="calibrate" transform="translate(0.500000, 11.500000)" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1.25">
<polyline id="Path" points="20 6 23 3 20 0"></polyline>
<line x1="1" y1="3" x2="22" y2="3" id="Path"></line>
<polyline id="Path" points="3 6 0 3 3 0"></polyline>
</g>
<rect id="Rectangle" stroke-width="1.25" x="0" y="0" width="24" height="6" rx="2"></rect>
<line x1="4.5" y1="5.5" x2="4.5" y2="3.5" id="Line-5" stroke-linecap="round"></line>
<line x1="7.5" y1="5.5" x2="7.5" y2="3.5" id="Line-5" stroke-linecap="round"></line>
<line x1="10.5" y1="5.5" x2="10.5" y2="3.5" id="Line-5" stroke-linecap="round"></line>
<line x1="13.5" y1="5.5" x2="13.5" y2="3.5" id="Line-5" stroke-linecap="round"></line>
<line x1="16.5" y1="5.5" x2="16.5" y2="3.5" id="Line-5" stroke-linecap="round"></line>
<line x1="19.5" y1="5.5" x2="19.5" y2="3.5" id="Line-5" stroke-linecap="round"></line>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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',

View File

@ -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,

View File

@ -133,6 +133,7 @@ Typography.propTypes = {
'primaryActive',
'secondary',
'error',
'primaryActive',
]),
className: PropTypes.string,
children: PropTypes.node,