Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ImageOverlayViewerTool) - add ImageOverlayViewer tool that can render image overlay (pixel overlay) of the DICOM images #3163

Merged
merged 17 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions extensions/cornerstone-dicom-sr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.11.4",
"@cornerstonejs/core": "^1.11.4",
"@cornerstonejs/tools": "^1.11.4",
"@cornerstonejs/adapters": "^1.13.2",
"@cornerstonejs/core": "^1.13.2",
"@cornerstonejs/tools": "^1.13.2",
"classnames": "^2.3.2"
}
}
10 changes: 5 additions & 5 deletions extensions/cornerstone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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.11.4",
"@cornerstonejs/dicom-image-loader": "^1.13.2",
"@ohif/core": "3.7.0-beta.64",
"@ohif/ui": "3.7.0-beta.64",
"dcmjs": "^0.29.6",
Expand All @@ -52,10 +52,10 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.11.4",
"@cornerstonejs/core": "^1.11.4",
"@cornerstonejs/streaming-image-volume-loader": "^1.11.4",
"@cornerstonejs/tools": "^1.11.4",
"@cornerstonejs/adapters": "^1.13.2",
"@cornerstonejs/core": "^1.13.2",
"@cornerstonejs/streaming-image-volume-loader": "^1.13.2",
"@cornerstonejs/tools": "^1.13.2",
"@kitware/vtk.js": "27.3.1",
"html2canvas": "^1.4.1",
"lodash.debounce": "4.0.8",
Expand Down
22 changes: 13 additions & 9 deletions extensions/cornerstone/src/commandsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import { CornerstoneServices } from './types';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';

function commandsModule({
servicesManager,
Expand Down Expand Up @@ -266,7 +267,7 @@ function commandsModule({
toolbarService.recordInteraction(props);
},

setToolActive: ({ toolName, toolGroupId = null }) => {
setToolActive: ({ toolName, toolGroupId = null, toggledState }) => {
if (toolName === 'Crosshairs') {
const activeViewportToolGroup = toolGroupService.getToolGroup(null);

Expand Down Expand Up @@ -327,6 +328,14 @@ function commandsModule({
toolGroup.setToolPassive(activeToolName);
}
}

// If there is a toggle state, then simply set the enabled/disabled state without
// setting the tool active.
if (toggledState != null) {
toggledState ? toolGroup.setToolEnabled(toolName) : toolGroup.setToolDisabled(toolName);
return;
}

// Set the new toolName to be active
toolGroup.setToolActive(toolName, {
bindings: [
Expand Down Expand Up @@ -547,25 +556,20 @@ function commandsModule({
toggledState,
});
},
toggleReferenceLines: ({ toggledState }) => {
setSourceViewportForReferenceLinesTool: ({ toggledState }) => {
const { activeViewportId } = viewportGridService.getState();
const viewportInfo = cornerstoneViewportService.getViewportInfo(activeViewportId);

const viewportId = viewportInfo.getViewportId();
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);

if (!toggledState) {
toolGroup.setToolDisabled(ReferenceLinesTool.toolName);
}

toolGroup.setToolConfiguration(
ReferenceLinesTool.toolName,
{
sourceViewportId: viewportId,
},
true // overwrite
);
toolGroup.setToolEnabled(ReferenceLinesTool.toolName);
},
storePresentation: ({ viewportId }) => {
cornerstoneViewportService.storePresentation({ viewportId });
Expand Down Expand Up @@ -692,8 +696,8 @@ function commandsModule({
toggleStackImageSync: {
commandFn: actions.toggleStackImageSync,
},
toggleReferenceLines: {
commandFn: actions.toggleReferenceLines,
setSourceViewportForReferenceLinesTool: {
commandFn: actions.setSourceViewportForReferenceLinesTool,
},
storePresentation: {
commandFn: actions.storePresentation,
Expand Down
3 changes: 3 additions & 0 deletions extensions/cornerstone/src/initCornerstoneTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from '@cornerstonejs/tools';

import CalibrationLineTool from './tools/CalibrationLineTool';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';

export default function initCornerstoneTools(configuration = {}) {
CrosshairsTool.isAnnotation = false;
Expand Down Expand Up @@ -58,6 +59,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(ReferenceLinesTool);
addTool(CalibrationLineTool);
addTool(TrackballRotateTool);
addTool(ImageOverlayViewerTool);

// Modify annotation tools to use dashed lines on SR
const annotationStyle = {
Expand Down Expand Up @@ -99,6 +101,7 @@ const toolNames = {
ReferenceLines: ReferenceLinesTool.toolName,
CalibrationLine: CalibrationLineTool.toolName,
TrackballRotateTool: TrackballRotateTool.toolName,
ImageOverlayViewer: ImageOverlayViewerTool.toolName,
};

export { toolNames };
247 changes: 247 additions & 0 deletions extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { metaData } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/core';
import { AnnotationDisplayTool, drawing } from '@cornerstonejs/tools';
import { guid } from '@ohif/core/src/utils';

interface CachedStat {
color: number[]; // [r, g, b, a]
overlays: {
// ...overlayPlaneModule
_id: string;
type: 'G' | 'R'; // G for Graphics, R for ROI
color?: number[]; // Rendered color [r, g, b, a]
dataUrl?: string; // Rendered image in Data URL expression
}[];
}

/**
* Image Overlay Viewer tool is not a traditional tool that requires user interactin.
* But it is used to display Pixel Overlays. And it will provide toggling capability.
*
* The documentation for Overlay Plane Module of DICOM can be found in [C.9.2 of
* Part-3 of DICOM standard](https://dicom.nema.org/medical/dicom/2018b/output/chtml/part03/sect_C.9.2.html)
*
* Image Overlay rendered by this tool can be toggled on and off using
* toolGroup.setToolEnabled() and toolGroup.setToolDisabled()
*/
class ImageOverlayViewerTool extends AnnotationDisplayTool {
static toolName = 'ImageOverlayViewer';
wayfarer3130 marked this conversation as resolved.
Show resolved Hide resolved
private _cachedOverlayMetadata: Map<string, any[]> = new Map();
private _cachedStats: { [key: string]: CachedStat } = {};

constructor(
toolProps = {},
defaultToolProps = {
supportedInteractionTypes: [],
configuration: {
fillColor: [255, 127, 127, 255],
},
}
) {
super(toolProps, defaultToolProps);
}

onSetToolDisabled = (): void => {
this._cachedStats = {};
this._cachedOverlayMetadata = new Map();
};

renderAnnotation = (enabledElement, svgDrawingHelper) => {
wayfarer3130 marked this conversation as resolved.
Show resolved Hide resolved
const { viewport } = enabledElement;

const imageId = this.getReferencedImageId(viewport);
if (!imageId) {
return;
}

const overlays =
this._cachedOverlayMetadata.get(imageId) ??
metaData.get('overlayPlaneModule', imageId)?.overlays;

// no overlays
if (!overlays?.length) {
return;
}

this._cachedOverlayMetadata.set(imageId, overlays);

this._getCachedStat(imageId, overlays, this.configuration.fillColor).then(cachedStat => {
cachedStat.overlays.forEach(overlay => {
this._renderOverlay(enabledElement, svgDrawingHelper, overlay);
});
});

return true;
};

/**
* Render to DOM
*
* @param enabledElement
* @param svgDrawingHelper
* @param overlayData
* @returns
*/
private _renderOverlay(enabledElement, svgDrawingHelper, overlayData) {
const { viewport } = enabledElement;
const imageId = this.getReferencedImageId(viewport);
if (!imageId) {
return;
}

// Decide the rendering position of the overlay image on the current canvas
const { _id, columns: width, rows: height, x, y } = overlayData;
const overlayTopLeftWorldPos = utilities.imageToWorldCoords(imageId, [
x - 1, // Remind that top-left corner's (x, y) is be (1, 1)
y - 1,
]);
const overlayTopLeftOnCanvas = viewport.worldToCanvas(overlayTopLeftWorldPos);
const overlayBottomRightWorldPos = utilities.imageToWorldCoords(imageId, [width, height]);
const overlayBottomRightOnCanvas = viewport.worldToCanvas(overlayBottomRightWorldPos);

// add image to the annotations svg layer
const svgns = 'http://www.w3.org/2000/svg';
const svgNodeHash = `image-overlay-${_id}`;
const existingImageElement = svgDrawingHelper.getSvgNode(svgNodeHash);

const attributes = {
'data-id': svgNodeHash,
width: overlayBottomRightOnCanvas[0] - overlayTopLeftOnCanvas[0],
height: overlayBottomRightOnCanvas[1] - overlayTopLeftOnCanvas[1],
x: overlayTopLeftOnCanvas[0],
y: overlayTopLeftOnCanvas[1],
href: overlayData.dataUrl,
};

if (
isNaN(attributes.x) ||
isNaN(attributes.y) ||
isNaN(attributes.width) ||
isNaN(attributes.height)
) {
console.warn('Invalid rendering attribute for image overlay', attributes['data-id']);
return false;
}

if (existingImageElement) {
drawing.setAttributesIfNecessary(attributes, existingImageElement);
svgDrawingHelper.setNodeTouched(svgNodeHash);
} else {
const newImageElement = document.createElementNS(svgns, 'image');
drawing.setNewAttributesIfValid(attributes, newImageElement);
svgDrawingHelper.appendNode(newImageElement, svgNodeHash);
}
return true;
}

private async _getCachedStat(
imageId: string,
overlayMetadata: any[],
color: number[]
): Promise<CachedStat> {
if (this._cachedStats[imageId] && this._isSameColor(this._cachedStats[imageId].color, color)) {
return this._cachedStats[imageId];
}

const overlays = await Promise.all(
overlayMetadata
.filter(overlay => overlay.pixelData)
.map(async (overlay, idx) => {
let pixelData = null;
if (overlay.pixelData.Value) {
pixelData = overlay.pixelData.Value;
} else if (overlay.pixelData.retrieveBulkData) {
pixelData = await overlay.pixelData.retrieveBulkData();
}

if (!pixelData) {
return;
}

const dataUrl = this._renderOverlayToDataUrl(
{ width: overlay.columns, height: overlay.rows },
color,
pixelData
);

return {
...overlay,
_id: guid(),
dataUrl, // this will be a data url expression of the rendered image
color,
};
})
);

this._cachedStats[imageId] = {
color: color,
overlays: overlays.filter(overlay => overlay),
};

return this._cachedStats[imageId];
}

/**
* compare two RGBA expression of colors.
*
* @param color1
* @param color2
* @returns
*/
private _isSameColor(color1: number[], color2: number[]) {
return (
color1 &&
color2 &&
color1[0] === color2[0] &&
color1[1] === color2[1] &&
color1[2] === color2[2] &&
color1[3] === color2[3]
);
}

/**
* pixelData of overlayPlane module is an array of bits corresponding
* to each of the underlying pixels of the image.
* Let's create pixel data from bit array of overlay data
*
* @param pixelDataRaw
* @param color
* @returns
*/
private _renderOverlayToDataUrl({ width, height }, color, pixelDataRaw) {
const pixelDataView = new DataView(pixelDataRaw);
const totalBits = width * height;

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;

const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, width, height); // make it transparent
ctx.globalCompositeOperation = 'copy';

const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0, bitIdx = 0, byteIdx = 0; i < totalBits; i++) {
if (pixelDataView.getUint8(byteIdx) & (1 << bitIdx)) {
data[i * 4] = color[0];
data[i * 4 + 1] = color[1];
data[i * 4 + 2] = color[2];
data[i * 4 + 3] = color[3];
}

// next bit, byte
if (bitIdx >= 7) {
bitIdx = 0;
byteIdx++;
} else {
bitIdx++;
}
}
ctx.putImageData(imageData, 0, 0);

return canvas.toDataURL();
}
}

export default ImageOverlayViewerTool;
4 changes: 2 additions & 2 deletions extensions/measurement-tracking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "^1.11.4",
"@cornerstonejs/tools": "^1.11.4",
"@cornerstonejs/core": "^1.13.2",
"@cornerstonejs/tools": "^1.13.2",
"@ohif/core": "3.7.0-beta.64",
"@ohif/extension-cornerstone-dicom-sr": "3.7.0-beta.64",
"@ohif/ui": "3.7.0-beta.64",
Expand Down
1 change: 1 addition & 0 deletions modes/basic-dev-mode/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ function modeFactory({ modeConfiguration }) {
{ toolName: toolNames.CalibrationLine },
],
// enabled
enabled: [{ toolName: toolNames.ImageOverlayViewer }],
// disabled
};

Expand Down
Loading