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 13 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
26 changes: 26 additions & 0 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 @@ -646,6 +647,26 @@ function commandsModule({
}
stateSyncService.store(storeState);
},

/**
* Toggle Image Overlays
*
* @param param0
*/
toggleImageOverlay: ({ toggledState }) => {
jbocce marked this conversation as resolved.
Show resolved Hide resolved
const { activeViewportIndex } = viewportGridService.getState();
const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex(
activeViewportIndex
);

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

// revert the state because unlike other toggle tools, this starts as enabled
toggledState
? toolGroup.setToolDisabled(ImageOverlayViewerTool.toolName)
: toolGroup.setToolEnabled(ImageOverlayViewerTool.toolName);
},
};

const definitions = {
Expand Down Expand Up @@ -775,6 +796,11 @@ function commandsModule({
storeContexts: [],
options: {},
},
toggleImageOverlay: {
jbocce marked this conversation as resolved.
Show resolved Hide resolved
commandFn: actions.toggleImageOverlay,
storeContexts: [],
jbocce marked this conversation as resolved.
Show resolved Hide resolved
options: {},
},
};

return {
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 };
307 changes: 307 additions & 0 deletions extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import { metaData, StackViewport, Types } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/core';
import { BaseTool } 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
}[];
}

const cachedStats: { [key: string]: CachedStat } = {};

/**
* compare two RGBA expression of colors.
*
* @param color1
* @param color2
* @returns
*/
const 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
*/
const 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();
};

/**
*
* @param imageId
* @param overlayMetadata
* @param color {number[]} - color (r,g,b,a), default color: gray
* @returns
*/
const getCachedStat = async (
imageId: string,
overlayMetadata: any[],
color: number[] = [127, 127, 127, 255]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no need for default color here, as the configuration has a default

): Promise<CachedStat> => {
if (
!cachedStats[imageId] ||
!isSameColor(cachedStats[imageId].color, color)
) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer less indentation. So can we early return on the opposite condition and make indentation one less?

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;
jbocce marked this conversation as resolved.
Show resolved Hide resolved

const dataUrl = 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,
};
})
);

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

return cachedStats[imageId];
};
jbocce marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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 BaseTool {
static toolName = 'ImageOverlayViewer';
wayfarer3130 marked this conversation as resolved.
Show resolved Hide resolved

_mode = 'Disabled';
_renderingViewport: any;

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

onSetToolEnabled = (): void => {
this._mode = 'Enabled';
};

onSetToolDisabled = (): void => {
this._mode = 'Disabled';
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is old implementation, now tools have access tot he this.mode I believe


renderAnnotation = (enabledElement, svgDrawingHelper) => {
wayfarer3130 marked this conversation as resolved.
Show resolved Hide resolved
// overlays are toggled off by configuration
if (this._mode !== 'Enabled') return false;

const { viewport } = enabledElement;
this._renderingViewport = viewport;

const imageId = this._getReferencedImageId(viewport);
if (!imageId) return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please follow the new eslint rule -> no same line if statement


const { overlays } = metaData.get('overlayPlaneModule', imageId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should cache this, as we hit this A LOT! so this.overlayCache.get(imageId) if not found grab from metadata and add it to the cache

// no overlays
if (!overlays || overlays.length <= 0) return;
md-prog marked this conversation as resolved.
Show resolved Hide resolved

getCachedStat(imageId, overlays, this.configuration.fillColor).then(
cachedStat => {
cachedStat.overlays.forEach(overlay => {
this._renderOverlay(enabledElement, svgDrawingHelper, overlay);
jbocce marked this conversation as resolved.
Show resolved Hide resolved
});
}
);

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;
jbocce marked this conversation as resolved.
Show resolved Hide resolved

// 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) {
_setAttributesIfNecessary(attributes, existingImageElement);
svgDrawingHelper.setNodeTouched(svgNodeHash);
} else {
const newImageElement = document.createElementNS(svgns, 'image');
_setNewAttributesIfValid(attributes, newImageElement);
svgDrawingHelper.appendNode(newImageElement, svgNodeHash);
}
return true;
}

/**
* Get viewport's referene image id,
* TODO: maybe we should add this to the BaseTool or AnnotationTool class
* as it is often used by tools.
*
* @param viewport
* @returns
*/
private _getReferencedImageId(
viewport: Types.IStackViewport | Types.IVolumeViewport
): string {
const targetId = this.getTargetId(viewport);

let referencedImageId;

if (viewport instanceof StackViewport) {
referencedImageId = targetId.split('imageId:')[1];
}

return referencedImageId;
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is already implemented in either BaseAnnotationTool or AnnotationTool


function _setAttributesIfNecessary(attributes, svgNode: SVGElement) {
Object.keys(attributes).forEach(key => {
const currentValue = svgNode.getAttribute(key);
const newValue = attributes[key];
if (newValue === undefined || newValue === '') {
svgNode.removeAttribute(key);
} else if (currentValue !== newValue) {
svgNode.setAttribute(key, newValue);
}
});
}

function _setNewAttributesIfValid(attributes, svgNode: SVGElement) {
Object.keys(attributes).forEach(key => {
const newValue = attributes[key];
if (newValue !== undefined && newValue !== '') {
svgNode.setAttribute(key, newValue);
}
});
}
jbocce marked this conversation as resolved.
Show resolved Hide resolved

export default ImageOverlayViewerTool;
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 @@ -86,6 +86,7 @@ function modeFactory({ modeConfiguration }) {
{ toolName: toolNames.CalibrationLine },
],
// enabled
enabled: [{ toolName: toolNames.ImageOverlayViewer }],
// disabled
};

Expand Down
1 change: 1 addition & 0 deletions modes/longitudinal/src/initToolGroups.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function initDefaultToolGroup(
{ toolName: toolNames.CalibrationLine },
],
// enabled
enabled: [{ toolName: toolNames.ImageOverlayViewer }],
// disabled
disabled: [{ toolName: toolNames.ReferenceLines }],
};
Expand Down
Loading