This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
import OHIF, { DicomMetadataStore } from '@ohif/core';
import loadSR from './utils/loadSR';
import toArray from './utils/toArray';
import DCM_CODE_VALUES from './utils/dcmCodeValues';
import getSourceDisplaySet from './utils/getSourceDisplaySet';
const { utils } = OHIF;
const SOP_CLASS_UIDS = {
COMPREHENSIVE_3D_SR: '1.2.840.10008.5.1.4.1.1.88.34',
};
const SOPClassHandlerId =
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler';
function _getReferencedFrameOfReferenceUID(naturalizedDataset) {
const { ContentSequence } = naturalizedDataset;
const imagingMeasurementsContentItem = ContentSequence.find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.IMAGING_MEASUREMENTS
);
const firstMeasurementGroupContentItem = toArray(
imagingMeasurementsContentItem.ContentSequence
).find(ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.MEASUREMENT_GROUP);
const imageRegionContentItem = toArray(firstMeasurementGroupContentItem.ContentSequence).find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.IMAGE_REGION
);
return imageRegionContentItem.ReferencedFrameOfReferenceUID;
}
function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const { displaySetService, microscopyService } = servicesManager.services;
const instance = instances[0];
// TODO ! Consumption of DICOMMicroscopySRSOPClassHandler to a derived dataset or normal dataset?
// TODO -> Easy to swap this to a "non-derived" displaySet, but unfortunately need to put it in a different extension.
const naturalizedDataset = DicomMetadataStore.getSeries(
instance.StudyInstanceUID,
instance.SeriesInstanceUID
).instances[0];
const ReferencedFrameOfReferenceUID = _getReferencedFrameOfReferenceUID(naturalizedDataset);
const {
FrameOfReferenceUID,
SeriesDescription,
ContentDate,
ContentTime,
SeriesNumber,
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SOPClassUID,
} = instance;
const displaySet = {
plugin: 'microscopy',
Modality: 'SR',
altImageText: 'Microscopy SR',
displaySetInstanceUID: utils.guid(),
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
ReferencedFrameOfReferenceUID,
SOPClassHandlerId,
SOPClassUID,
SeriesDescription,
// Map the content date/time to the series date/time, these are only used for filtering.
SeriesDate: ContentDate,
SeriesTime: ContentTime,
SeriesNumber,
instance,
metadata: naturalizedDataset,
isDerived: true,
isLoading: false,
isLoaded: false,
loadError: false,
};
displaySet.load = function (referencedDisplaySet) {
return loadSR(microscopyService, displaySet, referencedDisplaySet).catch(error => {
displaySet.isLoaded = false;
displaySet.loadError = true;
throw new Error(error);
});
};
displaySet.getSourceDisplaySet = function () {
let allDisplaySets = [];
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
studyMetadata.series.forEach(series => {
const displaySets = displaySetService.getDisplaySetsForSeries(series.SeriesInstanceUID);
allDisplaySets = allDisplaySets.concat(displaySets);
});
return getSourceDisplaySet(allDisplaySets, displaySet);
};
return [displaySet];
}
export default function getDicomMicroscopySRSopClassHandler({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return {
name: 'DicomMicroscopySRSopClassHandler',
sopClassUids: [SOP_CLASS_UIDS.COMPREHENSIVE_3D_SR],
getDisplaySetsFromSeries,
};
}

View File

@@ -0,0 +1,365 @@
.DicomMicroscopyViewer {
--ol-partial-background-color: rgba(127, 127, 127, 0.7);
--ol-foreground-color: #000000;
--ol-subtle-foreground-color: #000;
--ol-subtle-background-color: rgba(78, 78, 78, 0.5);
}
.DicomMicroscopyViewer .ol-box {
box-sizing: border-box;
border-radius: 2px;
border: 1.5px solid var(--ol-background-color);
background-color: var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-mouse-position {
top: 8px;
right: 8px;
position: absolute;
}
.DicomMicroscopyViewer .ol-scale-line {
background: var(--ol-partial-background-color);
border-radius: 4px;
bottom: 8px;
left: 8px;
padding: 2px;
position: absolute;
}
.DicomMicroscopyViewer .ol-scale-line-inner {
border: 1px solid var(--ol-subtle-foreground-color);
border-top: none;
color: var(--ol-foreground-color);
font-size: 10px;
text-align: center;
margin: 1px;
will-change: contents, width;
transition: all 0.25s;
}
.DicomMicroscopyViewer .ol-scale-bar {
position: absolute;
bottom: 8px;
left: 8px;
}
.DicomMicroscopyViewer .ol-scale-bar-inner {
display: flex;
}
.DicomMicroscopyViewer .ol-scale-step-marker {
width: 1px;
height: 15px;
background-color: var(--ol-foreground-color);
float: right;
z-index: 10;
}
.DicomMicroscopyViewer .ol-scale-step-text {
position: absolute;
bottom: -5px;
font-size: 10px;
z-index: 11;
color: var(--ol-foreground-color);
text-shadow:
-1.5px 0 var(--ol-partial-background-color),
0 1.5px var(--ol-partial-background-color),
1.5px 0 var(--ol-partial-background-color),
0 -1.5px var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-scale-text {
position: absolute;
font-size: 12px;
text-align: center;
bottom: 25px;
color: var(--ol-foreground-color);
text-shadow:
-1.5px 0 var(--ol-partial-background-color),
0 1.5px var(--ol-partial-background-color),
1.5px 0 var(--ol-partial-background-color),
0 -1.5px var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-scale-singlebar {
position: relative;
height: 10px;
z-index: 9;
box-sizing: border-box;
border: 1px solid var(--ol-foreground-color);
}
.DicomMicroscopyViewer .ol-scale-singlebar-even {
background-color: var(--ol-subtle-foreground-color);
}
.DicomMicroscopyViewer .ol-scale-singlebar-odd {
background-color: var(--ol-background-color);
}
.DicomMicroscopyViewer .ol-unsupported {
display: none;
}
.DicomMicroscopyViewer .ol-viewport,
.DicomMicroscopyViewer .ol-unselectable {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.DicomMicroscopyViewer .ol-viewport canvas {
all: unset;
}
.DicomMicroscopyViewer .ol-selectable {
-webkit-touch-callout: default;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
.DicomMicroscopyViewer .ol-grabbing {
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
.DicomMicroscopyViewer .ol-grab {
cursor: move;
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.DicomMicroscopyViewer .ol-control {
position: absolute;
background-color: var(--ol-subtle-background-color);
border-radius: 4px;
}
.DicomMicroscopyViewer .ol-zoom {
top: 0.5em;
left: 0.5em;
}
.DicomMicroscopyViewer .ol-rotate {
top: 0.5em;
right: 0.5em;
transition:
opacity 0.25s linear,
visibility 0s linear;
}
.DicomMicroscopyViewer .ol-rotate.ol-hidden {
opacity: 0;
visibility: hidden;
transition:
opacity 0.25s linear,
visibility 0s linear 0.25s;
}
.DicomMicroscopyViewer .ol-zoom-extent {
top: 4.643em;
left: 0.5em;
}
.DicomMicroscopyViewer .ol-full-screen {
right: 0.5em;
top: 0.5em;
}
.DicomMicroscopyViewer .ol-control button {
display: block;
margin: 1px;
padding: 0;
color: var(--ol-subtle-foreground-color);
font-weight: bold;
text-decoration: none;
font-size: inherit;
text-align: center;
height: 1.375em;
width: 1.375em;
line-height: 0.4em;
background-color: var(--ol-background-color);
border: none;
border-radius: 2px;
}
.DicomMicroscopyViewer .ol-control button::-moz-focus-inner {
border: none;
padding: 0;
}
.DicomMicroscopyViewer .ol-zoom-extent button {
line-height: 1.4em;
}
.DicomMicroscopyViewer .ol-compass {
display: block;
font-weight: normal;
will-change: transform;
}
.DicomMicroscopyViewer .ol-touch .ol-control button {
font-size: 1.5em;
}
.DicomMicroscopyViewer .ol-touch .ol-zoom-extent {
top: 5.5em;
}
.DicomMicroscopyViewer .ol-control button:hover,
.DicomMicroscopyViewer .ol-control button:focus {
text-decoration: none;
outline: 1px solid var(--ol-subtle-foreground-color);
color: var(--ol-foreground-color);
}
.DicomMicroscopyViewer .ol-zoom .ol-zoom-in {
border-radius: 2px 2px 0 0;
}
.DicomMicroscopyViewer .ol-zoom .ol-zoom-out {
border-radius: 0 0 2px 2px;
}
.DicomMicroscopyViewer .ol-attribution {
text-align: right;
bottom: 0.5em;
right: 0.5em;
max-width: calc(100% - 1.3em);
display: flex;
flex-flow: row-reverse;
align-items: center;
}
.DicomMicroscopyViewer .ol-attribution a {
color: var(--ol-subtle-foreground-color);
text-decoration: none;
}
.DicomMicroscopyViewer .ol-attribution ul {
margin: 0;
padding: 1px 0.5em;
color: var(--ol-foreground-color);
text-shadow: 0 0 2px var(--ol-background-color);
font-size: 12px;
}
.DicomMicroscopyViewer .ol-attribution li {
display: inline;
list-style: none;
}
.DicomMicroscopyViewer .ol-attribution li:not(:last-child):after {
content: ' ';
}
.DicomMicroscopyViewer .ol-attribution img {
max-height: 2em;
max-width: inherit;
vertical-align: middle;
}
.DicomMicroscopyViewer .ol-attribution button {
flex-shrink: 0;
}
.DicomMicroscopyViewer .ol-attribution.ol-collapsed ul {
display: none;
}
.DicomMicroscopyViewer .ol-attribution:not(.ol-collapsed) {
background: var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible {
bottom: 0;
right: 0;
border-radius: 4px 0 0;
}
.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible img {
margin-top: -0.2em;
max-height: 1.6em;
}
.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible button {
display: none;
}
.DicomMicroscopyViewer .ol-zoomslider {
top: 4.5em;
left: 0.5em;
height: 200px;
}
.DicomMicroscopyViewer .ol-zoomslider button {
position: relative;
height: 10px;
}
.DicomMicroscopyViewer .ol-touch .ol-zoomslider {
top: 5.5em;
}
.DicomMicroscopyViewer .ol-overviewmap {
left: 0.5em;
bottom: 0.5em;
}
.DicomMicroscopyViewer .ol-overviewmap.ol-uncollapsible {
bottom: 0;
left: 0;
border-radius: 0 4px 0 0;
}
.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-map,
.DicomMicroscopyViewer .ol-overviewmap button {
display: block;
}
.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-map {
border: 1px solid var(--ol-subtle-foreground-color);
height: 150px;
width: 150px;
}
.DicomMicroscopyViewer .ol-overviewmap:not(.ol-collapsed) button {
bottom: 0;
left: 0;
position: absolute;
}
.DicomMicroscopyViewer .ol-overviewmap.ol-collapsed .ol-overviewmap-map,
.DicomMicroscopyViewer .ol-overviewmap.ol-uncollapsible button {
display: none;
}
.DicomMicroscopyViewer .ol-overviewmap:not(.ol-collapsed) {
background: var(--ol-subtle-background-color);
}
.DicomMicroscopyViewer .ol-overviewmap-box {
border: 0.5px dotted var(--ol-subtle-foreground-color);
}
.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-box:hover {
cursor: move;
}
@layout-header-background: #007ea3;
@primary-color: #007ea3;
@processing-color: #8cb8c6;
@success-color: #3f9c35;
@warning-color: #eeaf30;
@error-color: #96172e;
@font-size-base: 14px;
.DicomMicroscopyViewer .ol-tooltip {
font-size: 16px !important;
}

View File

@@ -0,0 +1,314 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { LoadingIndicatorProgress } from '@ohif/ui';
import { cleanDenaturalizedDataset } from '@ohif/extension-default';
import './DicomMicroscopyViewport.css';
import ViewportOverlay from './components/ViewportOverlay';
import getDicomWebClient from './utils/dicomWebClient';
import dcmjs from 'dcmjs';
import MicroscopyService from './services/MicroscopyService';
class DicomMicroscopyViewport extends Component {
state = {
error: null as any,
isLoaded: false,
};
microscopyService: MicroscopyService;
viewer: any = null; // dicom-microscopy-viewer instance
managedViewer: any = null; // managed wrapper of microscopy-dicom extension
container = React.createRef();
overlayElement = React.createRef();
constructor(props: any) {
super(props);
const { microscopyService } = this.props.servicesManager.services;
this.microscopyService = microscopyService;
}
static propTypes = {
viewportData: PropTypes.object,
activeViewportId: PropTypes.string,
setViewportActive: PropTypes.func,
// props from OHIF Viewport Grid
displaySets: PropTypes.array,
viewportId: PropTypes.string,
viewportLabel: PropTypes.string,
dataSource: PropTypes.object,
viewportOptions: PropTypes.object,
displaySetOptions: PropTypes.array,
// other props from wrapping component
servicesManager: PropTypes.object,
extensionManager: PropTypes.object,
commandsManager: PropTypes.object,
resizeRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.any })]),
};
/**
* Get the nearest ROI from the mouse click point
*
* @param event
* @param autoselect
* @returns
*/
getNearbyROI(event: Event, autoselect = true) {
const symbols = Object.getOwnPropertySymbols(this.viewer);
const _drawingSource = symbols.find(p => p.description === 'drawingSource');
const _pyramid = symbols.find(p => p.description === 'pyramid');
const _map = symbols.find(p => p.description === 'map');
const _affine = symbols.find(p => p.description === 'affine');
const feature = this.viewer[_drawingSource].getClosestFeatureToCoordinate(
this.viewer[_map].getEventCoordinate(event)
);
if (!feature) {
return null;
}
const roiAnnotation = this.viewer._getROIFromFeature(
feature,
this.viewer[_pyramid].metadata,
this.viewer[_affine]
);
if (roiAnnotation && autoselect) {
this.microscopyService.selectAnnotation(roiAnnotation);
}
return roiAnnotation;
}
// install the microscopy renderer into the web page.
// you should only do this once.
async installOpenLayersRenderer(container, displaySet) {
const loadViewer = async metadata => {
const dicomMicroscopyModule = await this.microscopyService.importDicomMicroscopyViewer();
const { viewer: DicomMicroscopyViewer, metadata: metadataUtils } = dicomMicroscopyModule;
const microscopyViewer = DicomMicroscopyViewer.VolumeImageViewer;
const client = getDicomWebClient({
extensionManager: this.props.extensionManager,
servicesManager: this.props.servicesManager,
});
// Parse, format, and filter metadata
const volumeImages: any[] = [];
/**
* This block of code is the original way of loading DICOM into dicom-microscopy-viewer
* as in their documentation.
* But we have the metadata already loaded by our loaders.
* As the metadata for microscopy DIOM files tends to be big and we don't
* want to double load it, below we have the mechanism to reconstruct the
* DICOM JSON structure (denaturalized) from naturalized metadata.
* (NOTE: Our loaders cache only naturalized metadata, not the denaturalized.)
*/
// {
// const retrieveOptions = {
// studyInstanceUID: metadata[0].StudyInstanceUID,
// seriesInstanceUID: metadata[0].SeriesInstanceUID,
// };
// metadata = await client.retrieveSeriesMetadata(retrieveOptions);
// // Parse, format, and filter metadata
// metadata.forEach(m => {
// if (
// volumeImages.length > 0 &&
// m['00200052'].Value[0] != volumeImages[0].FrameOfReferenceUID
// ) {
// console.warn(
// 'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values',
// m['00200052'].Value[0]
// );
// m['00200052'].Value[0] = volumeImages[0].FrameOfReferenceUID;
// }
// NOTE: depending on different data source, image.ImageType sometimes
// is a string, not a string array.
// m['00080008'] = transformImageTypeUnnaturalized(m['00080008']);
// const image = new metadataUtils.VLWholeSlideMicroscopyImage({
// metadata: m,
// });
// const imageFlavor = image.ImageType[2];
// if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') {
// volumeImages.push(image);
// }
// });
// }
metadata.forEach(m => {
// NOTE: depending on different data source, image.ImageType sometimes
// is a string, not a string array.
m.ImageType = typeof m.ImageType === 'string' ? m.ImageType.split('\\') : m.ImageType;
const inst = cleanDenaturalizedDataset(
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m),
{
StudyInstanceUID: m.StudyInstanceUID,
SeriesInstanceUID: m.SeriesInstanceUID,
dataSourceConfig: this.props.dataSource.getConfig(),
}
);
if (!inst['00480105']) {
// Optical Path Sequence, no OpticalPathIdentifier?
// NOTE: this is actually a not-well formatted DICOM VL Whole Slide Microscopy Image.
inst['00480105'] = {
vr: 'SQ',
Value: [
{
'00480106': {
vr: 'SH',
Value: ['1'],
},
},
],
};
}
const image = new metadataUtils.VLWholeSlideMicroscopyImage({
metadata: inst,
});
const imageFlavor = image.ImageType[2];
if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') {
volumeImages.push(image);
}
});
// format metadata for microscopy-viewer
const options = {
client,
metadata: volumeImages,
retrieveRendered: false,
controls: ['overview', 'position'],
};
this.viewer = new microscopyViewer(options);
if (this.overlayElement && this.overlayElement.current && this.viewer.addViewportOverlay) {
this.viewer.addViewportOverlay({
element: this.overlayElement.current,
coordinates: [0, 0], // TODO: dicom-microscopy-viewer documentation says this can be false to be automatically, but it is not.
navigate: true,
className: 'OpenLayersOverlay',
});
}
this.viewer.render({ container });
const { StudyInstanceUID, SeriesInstanceUID } = displaySet;
this.managedViewer = this.microscopyService.addViewer(
this.viewer,
this.props.viewportId,
container,
StudyInstanceUID,
SeriesInstanceUID
);
this.managedViewer.addContextMenuCallback((event: Event) => {
// TODO: refactor this after Bill's changes on ContextMenu feature get merged
// const roiAnnotationNearBy = this.getNearbyROI(event);
});
};
this.microscopyService.clearAnnotations();
let smDisplaySet = displaySet;
if (displaySet.Modality === 'SR') {
// for SR displaySet, let's load the actual image displaySet
smDisplaySet = displaySet.getSourceDisplaySet();
}
console.log('Loading viewer metadata', smDisplaySet);
await loadViewer(smDisplaySet.others);
if (displaySet.Modality === 'SR') {
displaySet.load(smDisplaySet);
}
}
componentDidMount() {
const { displaySets, viewportOptions } = this.props;
// Todo-rename: this is always getting the 0
const displaySet = displaySets[0];
this.installOpenLayersRenderer(this.container.current, displaySet).then(() => {
this.setState({ isLoaded: true });
});
}
componentDidUpdate(prevProps: Readonly<{}>, prevState: Readonly<{}>, snapshot?: any): void {
if (this.managedViewer && prevProps.displaySets !== this.props.displaySets) {
const { displaySets } = this.props;
const displaySet = displaySets[0];
this.microscopyService.clearAnnotations();
// loading SR
if (displaySet.Modality === 'SR') {
const referencedDisplaySet = displaySet.getSourceDisplaySet();
displaySet.load(referencedDisplaySet);
}
}
}
componentWillUnmount() {
this.microscopyService.removeViewer(this.viewer);
}
setViewportActiveHandler = () => {
const { setViewportActive, viewportId, activeViewportId } = this.props;
if (viewportId !== activeViewportId) {
setViewportActive(viewportId);
}
};
render() {
const style = { width: '100%', height: '100%' };
const displaySet = this.props.displaySets[0];
const firstInstance = displaySet.firstInstance || displaySet.instance;
return (
<div
className={'DicomMicroscopyViewer'}
style={style}
onClick={this.setViewportActiveHandler}
>
<div style={{ ...style, display: 'none' }}>
<div style={{ ...style }} ref={this.overlayElement}>
<div style={{ position: 'relative', height: '100%', width: '100%' }}>
{displaySet && firstInstance.imageId && (
<ViewportOverlay
displaySet={displaySet}
instance={displaySet.instance}
metadata={displaySet.metadata}
/>
)}
</div>
</div>
</div>
{this.state.error ? (
<h2>{JSON.stringify(this.state.error)}</h2>
) : (
<div
style={style}
ref={(ref: any) => {
this.container.current = ref;
this.props.resizeRef.current = ref;
}}
/>
)}
{this.state.isLoaded ? null : (
<LoadingIndicatorProgress className={'h-full w-full bg-black'} />
)}
</div>
);
}
}
export default DicomMicroscopyViewport;

View File

@@ -0,0 +1,351 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { ExtensionManager, CommandsManager, DicomMetadataStore } from '@ohif/core';
import { MeasurementTable } from '@ohif/ui';
import { withTranslation, WithTranslation } from 'react-i18next';
import { EVENTS as MicroscopyEvents } from '../../services/MicroscopyService';
import dcmjs from 'dcmjs';
import { callInputDialog } from '@ohif/extension-default';
import constructSR from '../../utils/constructSR';
import { saveByteArray } from '../../utils/saveByteArray';
import { Separator } from '@ohif/ui-next';
let saving = false;
const { datasetToBuffer } = dcmjs.data;
const formatArea = area => {
let mult = 1;
let unit = 'mm';
if (area > 1000000) {
unit = 'm';
mult = 1 / 1000000;
} else if (area < 1) {
unit = 'μm';
mult = 1000000;
}
return `${(area * mult).toFixed(2)} ${unit}²`;
};
const formatLength = (length, unit) => {
let mult = 1;
if (unit == 'km' || (!unit && length > 1000000)) {
unit = 'km';
mult = 1 / 1000000;
} else if (unit == 'm' || (!unit && length > 1000)) {
unit = 'm';
mult = 1 / 1000;
} else if (unit == 'μm' || (!unit && length < 1)) {
unit = 'μm';
mult = 1000;
} else if (unit && unit != 'mm') {
throw new Error(`Unknown length unit ${unit}`);
} else {
unit = 'mm';
}
return `${(length * mult).toFixed(2)} ${unit}`;
};
interface IMicroscopyPanelProps extends WithTranslation {
viewports: PropTypes.array;
activeViewportId: PropTypes.string;
//
onSaveComplete?: PropTypes.func; // callback when successfully saved annotations
onRejectComplete?: PropTypes.func; // callback when rejected annotations
//
servicesManager: AppTypes.ServicesManager;
extensionManager: ExtensionManager;
commandsManager: CommandsManager;
}
/**
* Microscopy Measurements Panel Component
*
* @param props
* @returns
*/
function MicroscopyPanel(props: IMicroscopyPanelProps) {
const { microscopyService } = props.servicesManager.services;
const [studyInstanceUID, setStudyInstanceUID] = useState(null as string | null);
const [roiAnnotations, setRoiAnnotations] = useState([] as any[]);
const [selectedAnnotation, setSelectedAnnotation] = useState(null as any);
const { servicesManager, extensionManager } = props;
const { uiDialogService, displaySetService } = servicesManager.services;
useEffect(() => {
const viewport = props.viewports.get(props.activeViewportId);
if (viewport?.displaySetInstanceUIDs[0]) {
const displaySet = displaySetService.getDisplaySetByUID(viewport.displaySetInstanceUIDs[0]);
if (displaySet) {
setStudyInstanceUID(displaySet.StudyInstanceUID);
}
}
}, [props.viewports, props.activeViewportId]);
useEffect(() => {
const onAnnotationUpdated = () => {
const roiAnnotations = microscopyService.getAnnotationsForStudy(studyInstanceUID);
setRoiAnnotations(roiAnnotations);
};
const onAnnotationSelected = () => {
const selectedAnnotation = microscopyService.getSelectedAnnotation();
setSelectedAnnotation(selectedAnnotation);
};
const onAnnotationRemoved = () => {
onAnnotationUpdated();
};
const { unsubscribe: unsubscribeAnnotationUpdated } = microscopyService.subscribe(
MicroscopyEvents.ANNOTATION_UPDATED,
onAnnotationUpdated
);
const { unsubscribe: unsubscribeAnnotationSelected } = microscopyService.subscribe(
MicroscopyEvents.ANNOTATION_SELECTED,
onAnnotationSelected
);
const { unsubscribe: unsubscribeAnnotationRemoved } = microscopyService.subscribe(
MicroscopyEvents.ANNOTATION_REMOVED,
onAnnotationRemoved
);
onAnnotationUpdated();
onAnnotationSelected();
// on unload unsubscribe from events
return () => {
unsubscribeAnnotationUpdated();
unsubscribeAnnotationSelected();
unsubscribeAnnotationRemoved();
};
}, [studyInstanceUID]);
/**
* On clicking "Save Annotations" button, prompt an input modal for the
* new series' description, and continue to save.
*
* @returns
*/
const promptSave = () => {
const annotations = microscopyService.getAnnotationsForStudy(studyInstanceUID);
if (!annotations || saving) {
return;
}
callInputDialog({
uiDialogService,
title: 'Enter description of the Series',
defaultValue: '',
callback: (value: string, action: string) => {
switch (action) {
case 'save': {
saveFunction(value);
}
}
},
});
};
const getAllDisplaySets = (studyMetadata: any) => {
let allDisplaySets = [] as any[];
studyMetadata.series.forEach((series: any) => {
const displaySets = displaySetService.getDisplaySetsForSeries(series.SeriesInstanceUID);
allDisplaySets = allDisplaySets.concat(displaySets);
});
return allDisplaySets;
};
/**
* Save annotations as a series
*
* @param SeriesDescription - series description
* @returns
*/
const saveFunction = async (SeriesDescription: string) => {
const dataSource = extensionManager.getActiveDataSource()[0];
const { onSaveComplete } = props;
const annotations = microscopyService.getAnnotationsForStudy(studyInstanceUID);
saving = true;
// There is only one viewer possible for one study,
// Since once study contains multiple resolution levels (series) of one whole
// Slide image.
const studyMetadata = DicomMetadataStore.getStudy(studyInstanceUID);
const displaySets = getAllDisplaySets(studyMetadata);
const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM');
// Get the next available series number after 4700.
const dsWithMetadata = displaySets.filter(
ds => ds.metadata && ds.metadata.SeriesNumber && typeof ds.metadata.SeriesNumber === 'number'
);
// Generate next series number
const seriesNumbers = dsWithMetadata.map(ds => ds.metadata.SeriesNumber);
const maxSeriesNumber = Math.max(...seriesNumbers, 4700);
const SeriesNumber = maxSeriesNumber + 1;
const { instance: metadata } = smDisplaySet;
// construct SR dataset
const dataset = constructSR(metadata, { SeriesDescription, SeriesNumber }, annotations);
// Save in DICOM format
try {
if (dataSource) {
if (dataSource.wadoRoot == 'saveDicom') {
// download as DICOM file
const part10Buffer = datasetToBuffer(dataset);
saveByteArray(part10Buffer, `sr-microscopy.dcm`);
} else {
// Save into Web Data source
const { StudyInstanceUID } = dataset;
await dataSource.store.dicom(dataset);
if (StudyInstanceUID) {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
}
onSaveComplete({
title: 'SR Saved',
message: 'Measurements downloaded successfully',
type: 'success',
});
} else {
console.error('Server unspecified');
}
} catch (error) {
onSaveComplete({
title: 'SR Save Failed',
message: error.message || error.toString(),
type: 'error',
});
} finally {
saving = false;
}
};
/**
* On clicking "Reject annotations" button
*/
const onDeleteCurrentSRHandler = async () => {
try {
const activeViewport = props.viewports[props.activeViewportId];
const { StudyInstanceUID } = activeViewport;
// TODO: studies?
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
const lastDerivedDisplaySet = study.derivedDisplaySets.sort((ds1: any, ds2: any) => {
const dateTime1 = Number(`${ds1.SeriesDate}${ds1.SeriesTime}`);
const dateTime2 = Number(`${ds2.SeriesDate}${ds2.SeriesTime}`);
return dateTime1 > dateTime2;
})[study.derivedDisplaySets.length - 1];
// TODO: use dataSource.reject.dicom()
// await DICOMSR.rejectMeasurements(
// study.wadoRoot,
// lastDerivedDisplaySet.StudyInstanceUID,
// lastDerivedDisplaySet.SeriesInstanceUID
// );
props.onRejectComplete({
title: 'Report rejected',
message: 'Latest report rejected successfully',
type: 'success',
});
} catch (error) {
props.onRejectComplete({
title: 'Failed to reject report',
message: error.message,
type: 'error',
});
}
};
/**
* Handler for clicking event of an annotation item.
*
* @param param0
*/
const onMeasurementItemClickHandler = ({ uid }: { uid: string }) => {
const roiAnnotation = microscopyService.getAnnotation(uid);
microscopyService.selectAnnotation(roiAnnotation);
microscopyService.focusAnnotation(roiAnnotation, props.activeViewportId);
};
/**
* Handler for "Edit" action of an annotation item
* @param param0
*/
const onMeasurementItemEditHandler = ({ uid, isActive }: { uid: string; isActive: boolean }) => {
props.commandsManager.runCommand('setLabel', { uid }, 'MICROSCOPY');
};
const onMeasurementDeleteHandler = ({ uid, isActive }: { uid: string; isActive: boolean }) => {
const roiAnnotation = microscopyService.getAnnotation(uid);
microscopyService.removeAnnotation(roiAnnotation);
};
// Convert ROI annotations managed by microscopyService into our
// own format for display
const data = roiAnnotations.map((roiAnnotation, index) => {
const label = roiAnnotation.getDetailedLabel();
const area = roiAnnotation.getArea();
const length = roiAnnotation.getLength();
const shortAxisLength = roiAnnotation.roiGraphic.properties.shortAxisLength;
const isSelected: boolean = selectedAnnotation === roiAnnotation;
// other events
const { uid } = roiAnnotation;
// display text
const displayText = [];
if (area !== undefined) {
displayText.push(formatArea(area));
} else if (length !== undefined) {
displayText.push(
shortAxisLength
? `${formatLength(length, 'μm')} x ${formatLength(shortAxisLength, 'μm')}`
: `${formatLength(length, 'μm')}`
);
}
// convert to measurementItem format compatible with <MeasurementTable /> component
return {
uid,
index,
label,
isActive: isSelected,
displayText,
roiAnnotation,
};
});
return (
<>
<div
className="ohif-scrollbar overflow-y-auto overflow-x-hidden"
data-cy={'measurements-panel'}
>
<MeasurementTable
title="Measurements"
servicesManager={props.servicesManager}
data={data}
onClick={onMeasurementItemClickHandler}
onEdit={onMeasurementItemEditHandler}
onDelete={onMeasurementDeleteHandler}
/>
</div>
</>
);
}
const connectedMicroscopyPanel = withTranslation(['MicroscopyTable', 'Common'])(MicroscopyPanel);
export default connectedMicroscopyPanel;

View File

@@ -0,0 +1,87 @@
.DicomMicroscopyViewer .OpenLayersOverlay {
height: 100%;
width: 100%;
display: block !important;
pointer-events: none !important;
}
.DicomMicroscopyViewer .text-primary-light {
font-size: 14px;
color: yellow;
font-weight: normal;
}
.DicomMicroscopyViewer .text-primary-light span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 300px;
/* text-shadow: 0px 1px 1px rgba(225, 225, 225, 0.6),
0px 1px 1px rgba(225, 225, 225, 0.6),
1px 1px 3px rgba(225, 225, 225, 0.9),
1px 1px 3px rgba(225, 225, 225, 0.9),
1px 1px 3px rgba(225, 225, 225, 0.9),
1px 1px 3px rgba(225, 225, 225, 0.9); */
}
.DicomMicroscopyViewer .absolute {
position: absolute;
}
.DicomMicroscopyViewer .flex {
display: flex;
}
.DicomMicroscopyViewer .flex-row {
flex-direction: row;
}
.DicomMicroscopyViewer .flex-col {
flex-direction: column;
}
.DicomMicroscopyViewer .pointer-events-none {
pointer-events: none;
}
.DicomMicroscopyViewer .left-viewport-scrollbar {
left: 0.5rem;
}
.DicomMicroscopyViewer .right-viewport-scrollbar {
right: 1.3rem;
}
.DicomMicroscopyViewer .top-viewport {
top: 0.5rem;
}
.DicomMicroscopyViewer .bottom-viewport {
bottom: 0.5rem;
}
.DicomMicroscopyViewer .bottom-viewport.left-viewport {
bottom: 0.5rem;
left: calc(0.5rem + 250px);
}
.DicomMicroscopyViewer .right-viewport-scrollbar .flex {
justify-content: end;
}
.DicomMicroscopyViewer .microscopy-viewport-overlay {
padding: 0.5rem 1rem;
background: rgba(0, 0, 0, 0.5);
max-width: 40%;
}
.DicomMicroscopyViewer .microscopy-viewport-overlay .flex {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.DicomMicroscopyViewer .top-viewport .flex span:not(.font-light) {
flex-shrink: 0;
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
import classnames from 'classnames';
import listComponentGenerator from './listComponentGenerator';
import './ViewportOverlay.css';
import { formatDICOMDate, formatDICOMTime, formatNumberPrecision, formatPN } from './utils';
interface OverlayItem {
id: string;
title: string;
value?: (props: any) => string;
condition?: (props: any) => boolean;
contents?: (props: any) => { className: string; value: any };
generator?: (props: any) => any;
}
/**
*
* @param {*} config is a configuration object that defines four lists of elements,
* one topLeft, topRight, bottomLeft, bottomRight contents.
* @param {*} extensionManager is used to load the image data.
* @returns
*/
export const generateFromConfig = ({
topLeft = [],
topRight = [],
bottomLeft = [],
bottomRight = [],
itemGenerator = () => {},
}: {
topLeft?: OverlayItem[];
topRight?: OverlayItem[];
bottomLeft?: OverlayItem[];
bottomRight?: OverlayItem[];
itemGenerator?: (props: any) => any;
}) => {
return (props: any) => {
const topLeftClass = 'top-viewport left-viewport text-primary-light';
const topRightClass = 'top-viewport right-viewport-scrollbar text-primary-light';
const bottomRightClass = 'bottom-viewport right-viewport-scrollbar text-primary-light';
const bottomLeftClass = 'bottom-viewport left-viewport text-primary-light';
const overlay = 'absolute pointer-events-none microscopy-viewport-overlay';
return (
<>
{topLeft && topLeft.length > 0 && (
<div data-cy={'viewport-overlay-top-left'} className={classnames(overlay, topLeftClass)}>
{listComponentGenerator({ ...props, list: topLeft, itemGenerator })}
</div>
)}
{topRight && topRight.length > 0 && (
<div
data-cy={'viewport-overlay-top-right'}
className={classnames(overlay, topRightClass)}
>
{listComponentGenerator({
...props,
list: topRight,
itemGenerator,
})}
</div>
)}
{bottomRight && bottomRight.length > 0 && (
<div
data-cy={'viewport-overlay-bottom-right'}
className={classnames(overlay, bottomRightClass)}
>
{listComponentGenerator({
...props,
list: bottomRight,
itemGenerator,
})}
</div>
)}
{bottomLeft && bottomLeft.length > 0 && (
<div
data-cy={'viewport-overlay-bottom-left'}
className={classnames(overlay, bottomLeftClass)}
>
{listComponentGenerator({
...props,
list: bottomLeft,
itemGenerator,
})}
</div>
)}
</>
);
};
};
const itemGenerator = (props: any) => {
const { item } = props;
const { title, value: valueFunc, condition, contents } = item;
props.image = { ...props.image, ...props.metadata };
props.formatDate = formatDICOMDate;
props.formatTime = formatDICOMTime;
props.formatPN = formatPN;
props.formatNumberPrecision = formatNumberPrecision;
if (condition && !condition(props)) {
return null;
}
if (!contents && !valueFunc) {
return null;
}
const value = valueFunc && valueFunc(props);
const contentsValue = (contents && contents(props)) || [
{ className: 'mr-1', value: title },
{ classname: 'mr-1 font-light', value },
];
return (
<div key={item.id} className="flex flex-row">
{contentsValue.map((content, idx) => (
<span key={idx} className={content.className}>
{content.value}
</span>
))}
</div>
);
};
export default generateFromConfig({});

View File

@@ -0,0 +1,18 @@
const listComponentGenerator = props => {
const { list, itemGenerator } = props;
if (!list) {
return;
}
return list.map(item => {
if (!item) {
return;
}
const generator = item.generator || itemGenerator;
if (!generator) {
throw new Error(`No generator for ${item}`);
}
return generator({ ...props, item });
});
};
export default listComponentGenerator;

View File

@@ -0,0 +1,95 @@
import moment from 'moment';
import * as cornerstone from '@cornerstonejs/core';
/**
* Checks if value is valid.
*
* @param {number} value
* @returns {boolean} is valid.
*/
export function isValidNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
/**
* Formats number precision.
*
* @param {number} number
* @param {number} precision
* @returns {number} formatted number.
*/
export function formatNumberPrecision(number, precision) {
if (number !== null) {
return parseFloat(number).toFixed(precision);
}
}
/**
* Formats DICOM date.
*
* @param {string} date
* @param {string} strFormat
* @returns {string} formatted date.
*/
export function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
return moment(date, 'YYYYMMDD').format(strFormat);
}
/**
* DICOM Time is stored as HHmmss.SSS, where:
* HH 24 hour time:
* m mm 0..59 Minutes
* s ss 0..59 Seconds
* S SS SSS 0..999 Fractional seconds
*
* Goal: '24:12:12'
*
* @param {*} time
* @param {string} strFormat
* @returns {string} formatted name.
*/
export function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}
/**
* Formats a patient name for display purposes
*
* @param {string} name
* @returns {string} formatted name.
*/
export function formatPN(name) {
if (!name) {
return;
}
// Convert the first ^ to a ', '. String.replace() only affects
// the first appearance of the character.
const commaBetweenFirstAndLast = name.replace('^', ', ');
// Replace any remaining '^' characters with spaces
const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' ');
// Trim any extraneous whitespace
return cleaned.trim();
}
/**
* Gets compression type
*
* @param {number} imageId
* @returns {string} compression type.
*/
export function getCompression(imageId) {
const generalImageModule = cornerstone.metaData.get('generalImageModule', imageId) || {};
const { lossyImageCompression, lossyImageCompressionRatio, lossyImageCompressionMethod } =
generalImageModule;
if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') {
const compressionMethod = lossyImageCompressionMethod || 'Lossy: ';
const compressionRatio = formatNumberPrecision(lossyImageCompressionRatio, 2);
return compressionMethod + compressionRatio + ' : 1';
}
return 'Lossless / Uncompressed';
}

View File

@@ -0,0 +1,157 @@
import { CommandsManager, ExtensionManager } from '@ohif/core';
import { callInputDialog } from '@ohif/extension-default';
import styles from './utils/styles';
export default function getCommandsModule({
servicesManager,
commandsManager,
extensionManager,
}: {
servicesManager: AppTypes.ServicesManager;
commandsManager: CommandsManager;
extensionManager: ExtensionManager;
}) {
const { viewportGridService, uiDialogService, microscopyService } = servicesManager.services;
const actions = {
// Measurement tool commands:
deleteMeasurement: ({ uid }) => {
if (uid) {
const roiAnnotation = microscopyService.getAnnotation(uid);
if (roiAnnotation) {
microscopyService.removeAnnotation(roiAnnotation);
}
}
},
setLabel: ({ uid }) => {
const roiAnnotation = microscopyService.getAnnotation(uid);
callInputDialog({
uiDialogService,
defaultValue: '',
callback: (value: string, action: string) => {
switch (action) {
case 'save': {
roiAnnotation.setLabel(value);
microscopyService.triggerRelabel(roiAnnotation);
}
}
},
});
},
setToolActive: ({ toolName, toolGroupId = 'MICROSCOPY' }) => {
const dragPanOnMiddle = [
'dragPan',
{
bindings: {
mouseButtons: ['middle'],
},
},
];
const dragZoomOnRight = [
'dragZoom',
{
bindings: {
mouseButtons: ['right'],
},
},
];
if (
['line', 'box', 'circle', 'point', 'polygon', 'freehandpolygon', 'freehandline'].indexOf(
toolName
) >= 0
) {
// TODO: read from configuration
const options = {
geometryType: toolName,
vertexEnabled: true,
styleOptions: styles.default,
bindings: {
mouseButtons: ['left'],
},
} as any;
if ('line' === toolName) {
options.minPoints = 2;
options.maxPoints = 2;
} else if ('point' === toolName) {
delete options.styleOptions;
delete options.vertexEnabled;
}
microscopyService.activateInteractions([
['draw', options],
dragPanOnMiddle,
dragZoomOnRight,
]);
} else if (toolName == 'dragPan') {
microscopyService.activateInteractions([
[
'dragPan',
{
bindings: {
mouseButtons: ['left', 'middle'],
},
},
],
dragZoomOnRight,
]);
} else {
microscopyService.activateInteractions([
[
toolName,
{
bindings: {
mouseButtons: ['left'],
},
},
],
dragPanOnMiddle,
dragZoomOnRight,
]);
}
},
toggleOverlays: () => {
// overlay
const overlays = document.getElementsByClassName('microscopy-viewport-overlay');
let onoff = false; // true if this will toggle on
for (let i = 0; i < overlays.length; i++) {
if (i === 0) {
onoff = overlays.item(0).classList.contains('hidden');
}
overlays.item(i).classList.toggle('hidden');
}
// overview
const { activeViewportId } = viewportGridService.getState();
microscopyService.toggleOverviewMap(activeViewportId);
},
toggleAnnotations: () => {
microscopyService.toggleROIsVisibility();
},
};
const definitions = {
deleteMeasurement: {
commandFn: actions.deleteMeasurement,
},
setLabel: {
commandFn: actions.setLabel,
},
setToolActive: {
commandFn: actions.setToolActive,
},
toggleOverlays: {
commandFn: actions.toggleOverlays,
},
toggleAnnotations: {
commandFn: actions.toggleAnnotations,
},
};
return {
actions,
definitions,
defaultContext: 'MICROSCOPY',
};
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import type { Types } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui';
import MicroscopyPanel from './components/MicroscopyPanel/MicroscopyPanel';
// TODO:
// - No loading UI exists yet
// - cancel promises when component is destroyed
// - show errors in UI for thumbnails if promise fails
export default function getPanelModule({
commandsManager,
extensionManager,
servicesManager,
}: Types.Extensions.ExtensionParams) {
const wrappedMeasurementPanel = ({}) => {
const [{ activeViewportId, viewports }] = useViewportGrid();
return (
<MicroscopyPanel
viewports={viewports}
activeViewportId={activeViewportId}
onSaveComplete={() => {}}
onRejectComplete={() => {}}
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
return [
{
name: 'measure',
iconName: 'tab-linear',
iconLabel: 'Measure',
label: 'Measurements',
secondaryLabel: 'Measurements',
component: wrappedMeasurementPanel,
},
];
}

View File

@@ -0,0 +1,11 @@
import moment from 'moment';
/**
* Formats DICOM date.
*
* @param {string} date
* @param {string} strFormat
*/
export default function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
return moment(date, 'YYYYMMDD').format(strFormat);
}

View File

@@ -0,0 +1,9 @@
import formatDICOMDate from './formatDICOMDate';
describe('formatDICOMDate', () => {
it('should format DICOM date string', () => {
const date = '20180916';
const formattedDate = formatDICOMDate(date);
expect(formattedDate).toEqual('Sep 16, 2018');
});
});

View File

@@ -0,0 +1,23 @@
/**
* Formats a patient name for display purposes.
*
* @param {string} name DICOM patient name string
* @returns {string} formatted name
*/
export default function formatDICOMPatientName(name) {
if (typeof name !== 'string') {
return;
}
/**
* Convert the first ^ to a ', '. String.replace() only affects
* the first appearance of the character.
*/
const commaBetweenFirstAndLast = name.replace('^', ', ');
/** Replace any remaining '^' characters with spaces */
const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' ');
/** Trim any extraneous whitespace */
return cleaned.trim();
}

View File

@@ -0,0 +1,17 @@
import formatDICOMPatientName from './formatDICOMPatientName';
describe('formatDICOMPatientName', () => {
it('should format DICOM patient name correctly', () => {
const patientName = 'Blackford^Test';
const formattedPatientName = formatDICOMPatientName(patientName);
expect(formattedPatientName).toEqual('Blackford, Test');
});
it('should return undefined it input is not a string', () => {
expect(formatDICOMPatientName(123)).toEqual(undefined);
expect(formatDICOMPatientName(null)).toEqual(undefined);
expect(formatDICOMPatientName(undefined)).toEqual(undefined);
expect(formatDICOMPatientName(false)).toEqual(undefined);
expect(formatDICOMPatientName([])).toEqual(undefined);
});
});

View File

@@ -0,0 +1,17 @@
import moment from 'moment';
/**
* DICOM Time is stored as HHmmss.SSS, where:
* HH 24 hour time:
* m mm 0..59 Minutes
* s ss 0..59 Seconds
* S SS SSS 0..999 Fractional seconds
*
* Goal: '24:12:12'
*
* @param {*} time
* @param {string} strFormat
*/
export default function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}

View File

@@ -0,0 +1,9 @@
import formatDICOMTime from './formatDICOMTime';
describe('formatDICOMTime', () => {
it('should format DICOM time string', () => {
const time = '101300.000';
const formattedTime = formatDICOMTime(time);
expect(formattedTime).toEqual('10:13:00');
});
});

View File

@@ -0,0 +1,9 @@
/**
* Formats a number to a fixed precision.
*
* @param {number} number
* @param {number} precision
*/
export default function formatNumberPrecision(number, precision) {
return Number(parseFloat(number).toFixed(precision));
}

View File

@@ -0,0 +1,9 @@
import formatNumberPrecision from './formatNumberPrecision';
describe('formatNumberPrecision', () => {
it('should format number precision', () => {
const number = 0.229387;
const formattedNumber = formatNumberPrecision(number, 2);
expect(formattedNumber).toEqual(0.23);
});
});

View File

@@ -0,0 +1,15 @@
import formatDICOMPatientName from './formatDICOMPatientName';
import formatDICOMDate from './formatDICOMDate';
import formatDICOMTime from './formatDICOMTime';
import formatNumberPrecision from './formatNumberPrecision';
import isValidNumber from './isValidNumber';
const helpers = {
formatDICOMPatientName,
formatDICOMDate,
formatDICOMTime,
formatNumberPrecision,
isValidNumber,
};
export default helpers;

View File

@@ -0,0 +1,3 @@
export default function isValidNumber(value) {
return typeof value === 'number' && !isNaN(value);
}

View File

@@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };

View File

@@ -0,0 +1,164 @@
import { id } from './id';
import React, { Suspense, useMemo } from 'react';
import getPanelModule from './getPanelModule';
import getCommandsModule from './getCommandsModule';
import { Types } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui';
import getDicomMicroscopySRSopClassHandler from './DicomMicroscopySRSopClassHandler';
import MicroscopyService from './services/MicroscopyService';
import { useResizeDetector } from 'react-resize-detector';
import debounce from 'lodash.debounce';
const Component = React.lazy(() => {
return import('./DicomMicroscopyViewport');
});
const MicroscopyViewport = props => {
return (
<Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</Suspense>
);
};
/**
* You can remove any of the following modules if you don't need them.
*/
const extension: Types.Extensions.Extension = {
/**
* Only required property. Should be a unique value across all extensions.
* You ID can be anything you want, but it should be unique.
*/
id,
async preRegistration({ servicesManager }) {
servicesManager.registerService(MicroscopyService.REGISTRATION(servicesManager));
},
/**
* ViewportModule should provide a list of viewports that will be available in OHIF
* for Modes to consume and use in the viewports. Each viewport is defined by
* {name, component} object. Example of a viewport module is the CornerstoneViewport
* that is provided by the Cornerstone extension in OHIF.
*/
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
/**
*
* @param props {*}
* @param props.displaySets
* @param props.viewportId
* @param props.viewportLabel
* @param props.dataSource
* @param props.viewportOptions
* @param props.displaySetOptions
* @returns
*/
const ExtendedMicroscopyViewport = props => {
const { viewportOptions } = props;
const [viewportGrid, viewportGridService] = useViewportGrid();
const { activeViewportId } = viewportGrid;
const displaySetsKey = useMemo(() => {
return props.displaySets.map(ds => ds.displaySetInstanceUID).join('-');
}, [props.displaySets]);
const onResize = debounce(() => {
const { microscopyService } = servicesManager.services;
const managedViewer = microscopyService.getAllManagedViewers();
if (managedViewer && managedViewer.length > 0) {
managedViewer[0].viewer.resize();
}
}, 100);
const { ref: resizeRef } = useResizeDetector({
onResize,
handleHeight: true,
handleWidth: true,
});
return (
<MicroscopyViewport
key={displaySetsKey}
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
activeViewportId={activeViewportId}
setViewportActive={(viewportId: string) => {
viewportGridService.setActiveViewportId(viewportId);
}}
viewportData={viewportOptions}
resizeRef={resizeRef}
{...props}
/>
);
};
return [
{
name: 'microscopy-dicom',
component: ExtendedMicroscopyViewport,
},
];
},
getToolbarModule({ servicesManager }) {
return [
{
name: 'evaluate.microscopyTool',
evaluate: ({ button }) => {
const { microscopyService } = servicesManager.services;
const activeInteractions = microscopyService.getActiveInteractions();
if (!activeInteractions) {
return false;
}
const isPrimaryActive = activeInteractions.find(interactions => {
const sameMouseButton = interactions[1].bindings.mouseButtons.includes('left');
if (!sameMouseButton) {
return false;
}
const notDraw = interactions[0] !== 'draw';
// there seems to be a custom logic for draw tool for some reason
return notDraw
? interactions[0] === button.id
: interactions[1].geometryType === button.id;
});
return {
disabled: false,
className: isPrimaryActive
? '!text-black bg-primary-light'
: '!text-common-bright hover:!bg-primary-dark hover:!text-primary-light',
// Todo: isActive right now is used for nested buttons where the primary
// button needs to be fully rounded (vs partial rounded) when active
// otherwise it does not have any other use
isActive: isPrimaryActive,
};
},
},
];
},
/**
* SopClassHandlerModule should provide a list of sop class handlers that will be
* available in OHIF for Modes to consume and use to create displaySets from Series.
* Each sop class handler is defined by a { name, sopClassUids, getDisplaySetsFromSeries}.
* Examples include the default sop class handler provided by the default extension
*/
getSopClassHandlerModule(params) {
return [getDicomMicroscopySRSopClassHandler(params)];
},
getPanelModule,
getCommandsModule,
};
export default extension;

View File

@@ -0,0 +1,647 @@
import ViewerManager, { EVENTS as ViewerEvents } from '../tools/viewerManager';
import RoiAnnotation, { EVENTS as AnnotationEvents } from '../utils/RoiAnnotation';
import styles from '../utils/styles';
import { DicomMetadataStore, PubSubService } from '@ohif/core';
const EVENTS = {
ANNOTATION_UPDATED: 'annotationUpdated',
ANNOTATION_SELECTED: 'annotationSelected',
ANNOTATION_REMOVED: 'annotationRemoved',
RELABEL: 'relabel',
DELETE: 'delete',
};
/**
* MicroscopyService is responsible to manage multiple third-party API's
* microscopy viewers expose methods to manage the interaction with these
* viewers and handle their ROI graphics to create, remove and modify the
* ROI annotations relevant to the application
*/
export default class MicroscopyService extends PubSubService {
public static REGISTRATION = servicesManager => {
return {
name: 'microscopyService',
altName: 'MicroscopyService',
create: (props) => {
return new MicroscopyService(props);
},
};
};
servicesManager: any;
managedViewers = new Set();
roiUids = new Set();
annotations = {};
selectedAnnotation = null;
pendingFocus = false;
constructor({ servicesManager, extensionManager }) {
super(EVENTS);
this.servicesManager = servicesManager;
this.peerImport = extensionManager.appConfig.peerImport;
this._onRoiAdded = this._onRoiAdded.bind(this);
this._onRoiModified = this._onRoiModified.bind(this);
this._onRoiRemoved = this._onRoiRemoved.bind(this);
this._onRoiUpdated = this._onRoiUpdated.bind(this);
this._onRoiSelected = this._onRoiSelected.bind(this);
this.isROIsVisible = true;
}
/**
* Clears all the annotations and managed viewers, setting the manager state
* to its initial state
*/
clear() {
this.managedViewers.forEach(managedViewer => managedViewer.destroy());
this.managedViewers.clear();
for (const key in this.annotations) {
delete this.annotations[key];
}
this.roiUids.clear();
this.selectedAnnotation = null;
this.pendingFocus = false;
}
clearAnnotations() {
Object.keys(this.annotations).forEach(uid => {
this.removeAnnotation(this.annotations[uid]);
});
}
public importDicomMicroscopyViewer(): Promise<any> {
return this.peerImport("dicom-microscopy-viewer");
}
/**
* Observes when a ROI graphic is added, creating the correspondent annotation
* with the current graphic and view state.
* Creates a subscription for label updating for the created annotation and
* publishes an ANNOTATION_UPDATED event when it happens.
* Also triggers the relabel process after the graphic is placed.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The added ROI graphic object
* @param {ViewerManager} data.managedViewer The origin viewer for the event
*/
_onRoiAdded(data) {
const { roiGraphic, managedViewer, label } = data;
const { studyInstanceUID, seriesInstanceUID } = managedViewer;
const viewState = managedViewer.getViewState();
const roiAnnotation = new RoiAnnotation(
roiGraphic,
studyInstanceUID,
seriesInstanceUID,
'',
viewState
);
this.roiUids.add(roiGraphic.uid);
this.annotations[roiGraphic.uid] = roiAnnotation;
roiAnnotation.subscribe(AnnotationEvents.LABEL_UPDATED, () => {
this._broadcastEvent(EVENTS.ANNOTATION_UPDATED, roiAnnotation);
});
if (label !== undefined) {
roiAnnotation.setLabel(label);
} else {
const onRelabel = item =>
managedViewer.updateROIProperties({
uid: roiGraphic.uid,
properties: { label: item.label, finding: item.finding },
});
this.triggerRelabel(roiAnnotation, true, onRelabel);
}
}
/**
* Observes when a ROI graphic is modified, updating the correspondent
* annotation with the current graphic and view state.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The modified ROI graphic object
*/
_onRoiModified(data) {
const { roiGraphic, managedViewer } = data;
const roiAnnotation = this.getAnnotation(roiGraphic.uid);
if (!roiAnnotation) {
return;
}
roiAnnotation.setRoiGraphic(roiGraphic);
roiAnnotation.setViewState(managedViewer.getViewState());
}
/**
* Observes when a ROI graphic is removed, reflecting the removal in the
* annotations' state.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The removed ROI graphic object
*/
_onRoiRemoved(data) {
const { roiGraphic } = data;
this.roiUids.delete(roiGraphic.uid);
this.annotations[roiGraphic.uid].destroy();
delete this.annotations[roiGraphic.uid];
this._broadcastEvent(EVENTS.ANNOTATION_REMOVED, roiGraphic);
}
/**
* Observes any changes on ROI graphics and synchronize all the managed
* viewers to reflect those changes.
* Also publishes an ANNOTATION_UPDATED event to notify the subscribers.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The added ROI graphic object
* @param {ViewerManager} data.managedViewer The origin viewer for the event
*/
_onRoiUpdated(data) {
const { roiGraphic, managedViewer } = data;
this.synchronizeViewers(managedViewer);
this._broadcastEvent(EVENTS.ANNOTATION_UPDATED, this.getAnnotation(roiGraphic.uid));
}
/**
* Observes when an ROI is selected.
* Also publishes an ANNOTATION_SELECTED event to notify the subscribers.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The added ROI graphic object
* @param {ViewerManager} data.managedViewer The origin viewer for the event
*/
_onRoiSelected(data) {
const { roiGraphic } = data;
const selectedAnnotation = this.getAnnotation(roiGraphic.uid);
if (selectedAnnotation && selectedAnnotation !== this.getSelectedAnnotation()) {
if (this.selectedAnnotation) {
this.clearSelection();
}
this.selectedAnnotation = selectedAnnotation;
this._broadcastEvent(EVENTS.ANNOTATION_SELECTED, selectedAnnotation);
}
}
/**
* Creates the subscriptions for the managed viewer being added
*
* @param {ViewerManager} managedViewer The viewer being added
*/
_addManagedViewerSubscriptions(managedViewer) {
managedViewer._roiAddedSubscription = managedViewer.subscribe(
ViewerEvents.ADDED,
this._onRoiAdded
);
managedViewer._roiModifiedSubscription = managedViewer.subscribe(
ViewerEvents.MODIFIED,
this._onRoiModified
);
managedViewer._roiRemovedSubscription = managedViewer.subscribe(
ViewerEvents.REMOVED,
this._onRoiRemoved
);
managedViewer._roiUpdatedSubscription = managedViewer.subscribe(
ViewerEvents.UPDATED,
this._onRoiUpdated
);
managedViewer._roiSelectedSubscription = managedViewer.subscribe(
ViewerEvents.UPDATED,
this._onRoiSelected
);
}
/**
* Removes the subscriptions for the managed viewer being removed
*
* @param {ViewerManager} managedViewer The viewer being removed
*/
_removeManagedViewerSubscriptions(managedViewer) {
managedViewer._roiAddedSubscription && managedViewer._roiAddedSubscription.unsubscribe();
managedViewer._roiModifiedSubscription && managedViewer._roiModifiedSubscription.unsubscribe();
managedViewer._roiRemovedSubscription && managedViewer._roiRemovedSubscription.unsubscribe();
managedViewer._roiUpdatedSubscription && managedViewer._roiUpdatedSubscription.unsubscribe();
managedViewer._roiSelectedSubscription && managedViewer._roiSelectedSubscription.unsubscribe();
managedViewer._roiAddedSubscription = null;
managedViewer._roiModifiedSubscription = null;
managedViewer._roiRemovedSubscription = null;
managedViewer._roiUpdatedSubscription = null;
managedViewer._roiSelectedSubscription = null;
}
/**
* Returns the managed viewers that are displaying the image with the given
* study and series UIDs
*
* @param {String} studyInstanceUID UID for the study
* @param {String} seriesInstanceUID UID for the series
*
* @returns {Array} The managed viewers for the given series UID
*/
_getManagedViewersForSeries(studyInstanceUID, seriesInstanceUID) {
const filter = managedViewer =>
managedViewer.studyInstanceUID === studyInstanceUID &&
managedViewer.seriesInstanceUID === seriesInstanceUID;
return Array.from(this.managedViewers).filter(filter);
}
/**
* Returns the managed viewers that are displaying the image with the given
* study UID
*
* @param {String} studyInstanceUID UID for the study
*
* @returns {Array} The managed viewers for the given series UID
*/
getManagedViewersForStudy(studyInstanceUID) {
const filter = managedViewer => managedViewer.studyInstanceUID === studyInstanceUID;
return Array.from(this.managedViewers).filter(filter);
}
/**
* Restores the created annotations for the viewer being added
*
* @param {ViewerManager} managedViewer The viewer being added
*/
_restoreAnnotations(managedViewer) {
const { studyInstanceUID, seriesInstanceUID } = managedViewer;
const annotations = this.getAnnotationsForSeries(studyInstanceUID, seriesInstanceUID);
annotations.forEach(roiAnnotation => {
managedViewer.addRoiGraphic(roiAnnotation.roiGraphic);
});
}
/**
* Creates a managed viewer instance for the given third-party API's viewer.
* Restores existing annotations for the given study/series.
* Adds event subscriptions for the viewer being added.
* Focuses the selected annotation when the viewer is being loaded into the
* active viewport.
*
* @param viewer - Third-party viewer API's object to be managed
* @param viewportId - The viewport Id where the viewer will be loaded
* @param container - The DOM element where it will be rendered
* @param studyInstanceUID - The study UID of the loaded image
* @param seriesInstanceUID - The series UID of the loaded image
* @param displaySets - All displaySets related to the same StudyInstanceUID
*
* @returns {ViewerManager} managed viewer
*/
addViewer(viewer, viewportId, container, studyInstanceUID, seriesInstanceUID) {
const managedViewer = new ViewerManager(
viewer,
viewportId,
container,
studyInstanceUID,
seriesInstanceUID
);
this._restoreAnnotations(managedViewer);
viewer._manager = managedViewer;
this.managedViewers.add(managedViewer);
// this._potentiallyLoadSR(studyInstanceUID, displaySets);
this._addManagedViewerSubscriptions(managedViewer);
if (this.pendingFocus) {
this.pendingFocus = false;
this.focusAnnotation(this.selectedAnnotation, viewportId);
}
return managedViewer;
}
_potentiallyLoadSR(StudyInstanceUID, displaySets) {
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM');
const { FrameOfReferenceUID, othersFrameOfReferenceUID } = smDisplaySet;
if (!studyMetadata) {
return;
}
let derivedDisplaySets = FrameOfReferenceUID
? displaySets.filter(
ds =>
ds.ReferencedFrameOfReferenceUID === FrameOfReferenceUID ||
// sometimes each depth instance has the different FrameOfReferenceID
othersFrameOfReferenceUID.includes(ds.ReferencedFrameOfReferenceUID)
)
: [];
if (!derivedDisplaySets.length) {
return;
}
derivedDisplaySets = derivedDisplaySets.filter(ds => ds.Modality === 'SR');
if (derivedDisplaySets.some(ds => ds.isLoaded === true)) {
// Don't auto load
return;
}
// find most recent and load it.
let recentDateTime = 0;
let recentDisplaySet = derivedDisplaySets[0];
derivedDisplaySets.forEach(ds => {
const dateTime = Number(`${ds.SeriesDate}${ds.SeriesTime}`);
if (dateTime > recentDateTime) {
recentDateTime = dateTime;
recentDisplaySet = ds;
}
});
recentDisplaySet.isLoading = true;
recentDisplaySet.load(smDisplaySet);
}
/**
* Removes the given third-party viewer API's object from the managed viewers
* and clears all its event subscriptions
*
* @param {Object} viewer Third-party viewer API's object to be removed
*/
removeViewer(viewer) {
const managedViewer = viewer._manager;
this._removeManagedViewerSubscriptions(managedViewer);
managedViewer.destroy();
this.managedViewers.delete(managedViewer);
}
/**
* Toggle ROIs visibility
*/
toggleROIsVisibility() {
this.isROIsVisible ? this.hideROIs() : this.showROIs;
this.isROIsVisible = !this.isROIsVisible;
}
/**
* Hide all ROIs
*/
hideROIs() {
this.managedViewers.forEach(mv => mv.hideROIs());
}
/** Show all ROIs */
showROIs() {
this.managedViewers.forEach(mv => mv.showROIs());
}
/**
* Returns a RoiAnnotation instance for the given ROI UID
*
* @param {String} uid UID of the annotation
*
* @returns {RoiAnnotation} The RoiAnnotation instance found for the given UID
*/
getAnnotation(uid) {
return this.annotations[uid];
}
/**
* Returns all the RoiAnnotation instances being managed
*
* @returns {Array} All RoiAnnotation instances
*/
getAnnotations() {
const annotations = [];
Object.keys(this.annotations).forEach(uid => {
annotations.push(this.getAnnotation(uid));
});
return annotations;
}
/**
* Returns the RoiAnnotation instances registered with the given study UID
*
* @param {String} studyInstanceUID UID for the study
*/
getAnnotationsForStudy(studyInstanceUID) {
const filter = a => a.studyInstanceUID === studyInstanceUID;
return this.getAnnotations().filter(filter);
}
/**
* Returns the RoiAnnotation instances registered with the given study and
* series UIDs
*
* @param {String} studyInstanceUID UID for the study
* @param {String} seriesInstanceUID UID for the series
*/
getAnnotationsForSeries(studyInstanceUID, seriesInstanceUID) {
const filter = annotation =>
annotation.studyInstanceUID === studyInstanceUID &&
annotation.seriesInstanceUID === seriesInstanceUID;
return this.getAnnotations().filter(filter);
}
/**
* Returns the selected RoiAnnotation instance or null if none is selected
*
* @returns {RoiAnnotation} The selected RoiAnnotation instance
*/
getSelectedAnnotation() {
return this.selectedAnnotation;
}
/**
* Clear current RoiAnnotation selection
*/
clearSelection() {
if (this.selectedAnnotation) {
this.setROIStyle(this.selectedAnnotation.uid, {
stroke: {
color: '#00ff00',
},
});
}
this.selectedAnnotation = null;
}
/**
* Selects the given RoiAnnotation instance, publishing an ANNOTATION_SELECTED
* event to notify all the subscribers
*
* @param {RoiAnnotation} roiAnnotation The instance to be selected
*/
selectAnnotation(roiAnnotation) {
if (this.selectedAnnotation) {
this.clearSelection();
}
this.selectedAnnotation = roiAnnotation;
this._broadcastEvent(EVENTS.ANNOTATION_SELECTED, roiAnnotation);
this.setROIStyle(roiAnnotation.uid, styles.active);
}
/**
* Toggles overview map
*
* @param viewportId The active viewport index
* @returns {void}
*/
toggleOverviewMap(viewportId) {
const managedViewers = Array.from(this.managedViewers);
const managedViewer = managedViewers.find(mv => mv.viewportId === viewportId);
if (managedViewer) {
managedViewer.toggleOverviewMap();
}
}
/**
* Removes a RoiAnnotation instance from the managed annotations and reflects
* its removal on all third-party viewers being managed
*
* @param {RoiAnnotation} roiAnnotation The instance to be removed
*/
removeAnnotation(roiAnnotation) {
const { uid, studyInstanceUID, seriesInstanceUID } = roiAnnotation;
const filter = managedViewer =>
managedViewer.studyInstanceUID === studyInstanceUID &&
managedViewer.seriesInstanceUID === seriesInstanceUID;
const managedViewers = Array.from(this.managedViewers).filter(filter);
managedViewers.forEach(managedViewer => managedViewer.removeRoiGraphic(uid));
if (this.annotations[uid]) {
this.roiUids.delete(uid);
this.annotations[uid].destroy();
delete this.annotations[uid];
this._broadcastEvent(EVENTS.ANNOTATION_REMOVED, roiAnnotation);
}
}
/**
* Focus the given RoiAnnotation instance by changing the OpenLayers' Map view
* state of the managed viewer with the given viewport index.
* If the image for the given annotation is not yet loaded into the viewport,
* it will set a pendingFocus flag to true in order to perform the focus when
* the managed viewer instance is created.
*
* @param {RoiAnnotation} roiAnnotation RoiAnnotation instance to be focused
* @param {string} viewportId Index of the viewport to focus
*/
focusAnnotation(roiAnnotation, viewportId) {
const filter = mv => mv.viewportId === viewportId;
const managedViewer = Array.from(this.managedViewers).find(filter);
if (managedViewer) {
managedViewer.setViewStateByExtent(roiAnnotation);
} else {
this.pendingFocus = true;
}
}
/**
* Synchronize the ROI graphics for all the managed viewers that has the same
* series UID of the given managed viewer
*
* @param {ViewerManager} baseManagedViewer Reference managed viewer
*/
synchronizeViewers(baseManagedViewer) {
const { studyInstanceUID, seriesInstanceUID } = baseManagedViewer;
const managedViewers = this._getManagedViewersForSeries(studyInstanceUID, seriesInstanceUID);
// Prevent infinite loops arrising from updates.
managedViewers.forEach(managedViewer => this._removeManagedViewerSubscriptions(managedViewer));
managedViewers.forEach(managedViewer => {
if (managedViewer === baseManagedViewer) {
return;
}
const annotations = this.getAnnotationsForSeries(studyInstanceUID, seriesInstanceUID);
managedViewer.clearRoiGraphics();
annotations.forEach(roiAnnotation => {
managedViewer.addRoiGraphic(roiAnnotation.roiGraphic);
});
});
managedViewers.forEach(managedViewer => this._addManagedViewerSubscriptions(managedViewer));
}
/**
* Activates interactions across all the viewers being managed
*
* @param {Array} interactions interactions
*/
activateInteractions(interactions) {
this.managedViewers.forEach(mv => mv.activateInteractions(interactions));
this.activeInteractions = interactions;
}
getActiveInteractions() {
return this.activeInteractions;
}
/**
* Triggers the relabelling process for the given RoiAnnotation instance, by
* publishing the RELABEL event to notify the subscribers
*
* @param {RoiAnnotation} roiAnnotation The instance to be relabelled
* @param {boolean} newAnnotation Whether the annotation is newly drawn (so it deletes on cancel).
*/
triggerRelabel(roiAnnotation, newAnnotation = false, onRelabel) {
if (!onRelabel) {
onRelabel = ({ label }) =>
this.managedViewers.forEach(mv =>
mv.updateROIProperties({
uid: roiAnnotation.uid,
properties: { label },
})
);
}
this._broadcastEvent(EVENTS.RELABEL, {
roiAnnotation,
deleteCallback: () => this.removeAnnotation(roiAnnotation),
successCallback: onRelabel,
newAnnotation,
});
}
/**
* Triggers the deletion process for the given RoiAnnotation instance, by
* publishing the DELETE event to notify the subscribers
*
* @param {RoiAnnotation} roiAnnotation The instance to be deleted
*/
triggerDelete(roiAnnotation) {
this._broadcastEvent(EVENTS.DELETE, roiAnnotation);
}
/**
* Set ROI style for all managed viewers
*
* @param {string} uid The ROI uid that will be styled
* @param {object} styleOptions - Style options
* @param {object*} styleOptions.stroke - Style options for the outline of the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the outline
* @param {number} styleOptions.stroke.width - Width of the outline
* @param {object*} styleOptions.fill - Style options for body the geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
* @param {object*} styleOptions.image - Style options for image
*/
setROIStyle(uid, styleOptions) {
this.managedViewers.forEach(mv => mv.setROIStyle(uid, styleOptions));
}
/**
* Get all managed viewers
*
* @returns {Array} managedViewers
*/
getAllManagedViewers() {
return Array.from(this.managedViewers);
}
}
export { EVENTS };

View File

@@ -0,0 +1,464 @@
import coordinateFormatScoord3d2Geometry from '../utils/coordinateFormatScoord3d2Geometry';
import styles from '../utils/styles';
import { PubSubService } from '@ohif/core';
// Events from the third-party viewer
const ApiEvents = {
/** Triggered when a ROI was added. */
ROI_ADDED: 'dicommicroscopyviewer_roi_added',
/** Triggered when a ROI was modified. */
ROI_MODIFIED: 'dicommicroscopyviewer_roi_modified',
/** Triggered when a ROI was removed. */
ROI_REMOVED: 'dicommicroscopyviewer_roi_removed',
/** Triggered when a ROI was drawn. */
ROI_DRAWN: `dicommicroscopyviewer_roi_drawn`,
/** Triggered when a ROI was selected. */
ROI_SELECTED: `dicommicroscopyviewer_roi_selected`,
/** Triggered when a viewport move has started. */
MOVE_STARTED: `dicommicroscopyviewer_move_started`,
/** Triggered when a viewport move has ended. */
MOVE_ENDED: `dicommicroscopyviewer_move_ended`,
/** Triggered when a loading of data has started. */
LOADING_STARTED: `dicommicroscopyviewer_loading_started`,
/** Triggered when a loading of data has ended. */
LOADING_ENDED: `dicommicroscopyviewer_loading_ended`,
/** Triggered when an error occurs during loading of data. */
LOADING_ERROR: `dicommicroscopyviewer_loading_error`,
/* Triggered when the loading of an image tile has started. */
FRAME_LOADING_STARTED: `dicommicroscopyviewer_frame_loading_started`,
/* Triggered when the loading of an image tile has ended. */
FRAME_LOADING_ENDED: `dicommicroscopyviewer_frame_loading_ended`,
/* Triggered when the error occurs during loading of an image tile. */
FRAME_LOADING_ERROR: `dicommicroscopyviewer_frame_loading_ended`,
};
const EVENTS = {
ADDED: 'added',
MODIFIED: 'modified',
REMOVED: 'removed',
UPDATED: 'updated',
SELECTED: 'selected',
};
/**
* ViewerManager encapsulates the complexity of the third-party viewer and
* expose only the features/behaviors that are relevant to the application
*/
class ViewerManager extends PubSubService {
constructor(viewer, viewportId, container, studyInstanceUID, seriesInstanceUID) {
super(EVENTS);
this.viewer = viewer;
this.viewportId = viewportId;
this.container = container;
this.studyInstanceUID = studyInstanceUID;
this.seriesInstanceUID = seriesInstanceUID;
this.onRoiAdded = this.roiAddedHandler.bind(this);
this.onRoiModified = this.roiModifiedHandler.bind(this);
this.onRoiRemoved = this.roiRemovedHandler.bind(this);
this.onRoiSelected = this.roiSelectedHandler.bind(this);
this.contextMenuCallback = () => {};
// init symbols
const symbols = Object.getOwnPropertySymbols(this.viewer);
this._drawingSource = symbols.find(p => p.description === 'drawingSource');
this._pyramid = symbols.find(p => p.description === 'pyramid');
this._map = symbols.find(p => p.description === 'map');
this._affine = symbols.find(p => p.description === 'affine');
this.registerEvents();
this.activateDefaultInteractions();
}
addContextMenuCallback(callback) {
this.contextMenuCallback = callback;
}
/**
* Destroys this managed viewer instance, clearing all the event handlers
*/
destroy() {
this.unregisterEvents();
}
/**
* This is to overrides the _broadcastEvent method of PubSubService and always
* send the ROI graphic object and this managed viewer instance.
* Due to the way that PubSubService is written, the same name override of the
* function doesn't work.
*
* @param {String} key key Subscription key
* @param {Object} roiGraphic ROI graphic object created by the third-party API
*/
publish(key, roiGraphic) {
this._broadcastEvent(key, {
roiGraphic,
managedViewer: this,
});
}
/**
* Registers all the relevant event handlers for the third-party API
*/
registerEvents() {
this.container.addEventListener(ApiEvents.ROI_ADDED, this.onRoiAdded);
this.container.addEventListener(ApiEvents.ROI_MODIFIED, this.onRoiModified);
this.container.addEventListener(ApiEvents.ROI_REMOVED, this.onRoiRemoved);
this.container.addEventListener(ApiEvents.ROI_SELECTED, this.onRoiSelected);
}
/**
* Clears all the relevant event handlers for the third-party API
*/
unregisterEvents() {
this.container.removeEventListener(ApiEvents.ROI_ADDED, this.onRoiAdded);
this.container.removeEventListener(ApiEvents.ROI_MODIFIED, this.onRoiModified);
this.container.removeEventListener(ApiEvents.ROI_REMOVED, this.onRoiRemoved);
this.container.removeEventListener(ApiEvents.ROI_SELECTED, this.onRoiSelected);
}
/**
* Handles the ROI_ADDED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiAddedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.ADDED, roiGraphic);
this.publish(EVENTS.UPDATED, roiGraphic);
}
/**
* Handles the ROI_MODIFIED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiModifiedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.MODIFIED, roiGraphic);
this.publish(EVENTS.UPDATED, roiGraphic);
}
/**
* Handles the ROI_REMOVED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiRemovedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.REMOVED, roiGraphic);
this.publish(EVENTS.UPDATED, roiGraphic);
}
/**
* Handles the ROI_SELECTED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiSelectedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.SELECTED, roiGraphic);
}
/**
* Run the given callback operation without triggering any events for this
* instance, so subscribers will not be affected
*
* @param {Function} callback Callback that will run sinlently
*/
runSilently(callback) {
this.unregisterEvents();
callback();
this.registerEvents();
}
/**
* Removes all the ROI graphics from the third-party API
*/
clearRoiGraphics() {
this.runSilently(() => this.viewer.removeAllROIs());
}
showROIs() {
this.viewer.showROIs();
}
hideROIs() {
this.viewer.hideROIs();
}
/**
* Adds the given ROI graphic into the third-party API
*
* @param {Object} roiGraphic ROI graphic object to be added
*/
addRoiGraphic(roiGraphic) {
this.runSilently(() => this.viewer.addROI(roiGraphic, styles.default));
}
/**
* Adds the given ROI graphic into the third-party API, and also add a label.
* Used for importing from SR.
*
* @param {Object} roiGraphic ROI graphic object to be added.
* @param {String} label The label of the annotation.
*/
addRoiGraphicWithLabel(roiGraphic, label) {
// NOTE: Dicom Microscopy Viewer will override styles for "Text" evaluations
// to hide all other geometries, we are not going to use its label.
// if (label) {
// if (!roiGraphic.properties) roiGraphic.properties = {};
// roiGraphic.properties.label = label;
// }
this.runSilently(() => this.viewer.addROI(roiGraphic, styles.default));
this._broadcastEvent(EVENTS.ADDED, {
roiGraphic,
managedViewer: this,
label,
});
}
/**
* Sets ROI style
*
* @param {String} uid ROI graphic UID to be styled
* @param {object} styleOptions - Style options
* @param {object} styleOptions.stroke - Style options for the outline of the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the outline
* @param {number} styleOptions.stroke.width - Width of the outline
* @param {object} styleOptions.fill - Style options for body the geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
* @param {object} styleOptions.image - Style options for image
*/
setROIStyle(uid, styleOptions) {
this.viewer.setROIStyle(uid, styleOptions);
}
/**
* Removes the ROI graphic with the given UID from the third-party API
*
* @param {String} uid ROI graphic UID to be removed
*/
removeRoiGraphic(uid) {
this.viewer.removeROI(uid);
}
/**
* Update properties of regions of interest.
*
* @param {object} roi - ROI to be updated
* @param {string} roi.uid - Unique identifier of the region of interest
* @param {object} roi.properties - ROI properties
* @returns {void}
*/
updateROIProperties({ uid, properties }) {
this.viewer.updateROI({ uid, properties });
}
/**
* Toggles overview map
*
* @returns {void}
*/
toggleOverviewMap() {
this.viewer.toggleOverviewMap();
}
/**
* Activates the viewer default interactions
* @returns {void}
*/
activateDefaultInteractions() {
/** Disable browser's native context menu inside the canvas */
document.querySelector('.DicomMicroscopyViewer').addEventListener(
'contextmenu',
event => {
event.preventDefault();
// comment out when context menu for microscopy is enabled
// if (typeof this.contextMenuCallback === 'function') {
// this.contextMenuCallback(event);
// }
},
false
);
const defaultInteractions = [
[
'dragPan',
{
bindings: {
mouseButtons: ['middle'],
},
},
],
[
'dragZoom',
{
bindings: {
mouseButtons: ['right'],
},
},
],
['modify', {}],
];
this.activateInteractions(defaultInteractions);
}
/**
* Activates interactions
* @param {Array} interactions Interactions to be activated
* @returns {void}
*/
activateInteractions(interactions) {
const interactionsMap = {
draw: activate => (activate ? 'activateDrawInteraction' : 'deactivateDrawInteraction'),
modify: activate => (activate ? 'activateModifyInteraction' : 'deactivateModifyInteraction'),
translate: activate =>
activate ? 'activateTranslateInteraction' : 'deactivateTranslateInteraction',
snap: activate => (activate ? 'activateSnapInteraction' : 'deactivateSnapInteraction'),
dragPan: activate =>
activate ? 'activateDragPanInteraction' : 'deactivateDragPanInteraction',
dragZoom: activate =>
activate ? 'activateDragZoomInteraction' : 'deactivateDragZoomInteraction',
select: activate => (activate ? 'activateSelectInteraction' : 'deactivateSelectInteraction'),
};
const availableInteractionsName = Object.keys(interactionsMap);
availableInteractionsName.forEach(availableInteractionName => {
const interaction = interactions.find(
interaction => interaction[0] === availableInteractionName
);
if (!interaction) {
const deactivateInteractionMethod = interactionsMap[availableInteractionName](false);
this.viewer[deactivateInteractionMethod]();
} else {
const [name, config] = interaction;
const activateInteractionMethod = interactionsMap[name](true);
this.viewer[activateInteractionMethod](config);
}
});
}
/**
* Accesses the internals of third-party API and returns the OpenLayers Map
*
* @returns {Object} OpenLayers Map component instance
*/
_getMapView() {
const map = this._getMap();
return map.getView();
}
_getMap() {
const symbols = Object.getOwnPropertySymbols(this.viewer);
const _map = symbols.find(s => String(s) === 'Symbol(map)');
window['map'] = this.viewer[_map];
return this.viewer[_map];
}
/**
* Returns the current state for the OpenLayers View
*
* @returns {Object} Current view state
*/
getViewState() {
const view = this._getMapView();
return {
center: view.getCenter(),
resolution: view.getResolution(),
zoom: view.getZoom(),
};
}
/**
* Sets the current state for the OpenLayers View
*
* @param {Object} viewState View state to be applied
*/
setViewState(viewState) {
const view = this._getMapView();
view.setZoom(viewState.zoom);
view.setResolution(viewState.resolution);
view.setCenter(viewState.center);
}
setViewStateByExtent(roiAnnotation) {
const coordinates = roiAnnotation.getCoordinates();
if (Array.isArray(coordinates[0]) && !coordinates[2]) {
this._jumpToPolyline(coordinates);
} else if (Array.isArray(coordinates[0])) {
this._jumpToPolygonOrEllipse(coordinates);
} else {
this._jumpToPoint(coordinates);
}
}
_jumpToPoint(coord) {
const pyramid = this.viewer[this._pyramid].metadata;
const mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid);
const view = this._getMapView();
view.setCenter(mappedCoord);
}
_jumpToPolyline(coord) {
const pyramid = this.viewer[this._pyramid].metadata;
const mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid);
const view = this._getMapView();
const x = mappedCoord[0];
const y = mappedCoord[1];
const xab = (x[0] + y[0]) / 2;
const yab = (x[1] + y[1]) / 2;
const midpoint = [xab, yab];
view.setCenter(midpoint);
}
_jumpToPolygonOrEllipse(coordinates) {
const pyramid = this.viewer[this._pyramid].metadata;
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
coordinates.forEach(coord => {
let mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid);
const [x, y] = mappedCoord;
if (x < minX) {
minX = x;
} else if (x > maxX) {
maxX = x;
}
if (y < minY) {
minY = y;
} else if (y > maxY) {
maxY = y;
}
});
const width = maxX - minX;
const height = maxY - minY;
minX -= 0.5 * width;
maxX += 0.5 * width;
minY -= 0.5 * height;
maxY += 0.5 * height;
const map = this._getMap();
map.getView().fit([minX, minY, maxX, maxY], map.getSize());
}
}
export { EVENTS };
export default ViewerManager;

View File

@@ -0,0 +1,11 @@
/* eslint-disable @typescript-eslint/no-namespace */
import MicroscopyServiceType from '../services/MicroscopyService';
declare global {
namespace AppTypes {
export type MicroscopyService = MicroscopyServiceType;
export interface Services {
microscopyService?: MicroscopyServiceType;
}
}
}

View File

@@ -0,0 +1,5 @@
// We need to define a UID for this extension as a device, and it should be the same for all saves:
const uid = '2.25.285241207697168520771311899641885187923';
export default uid;

View File

@@ -0,0 +1,186 @@
import areaOfPolygon from './areaOfPolygon';
import { PubSubService } from '@ohif/core';
const EVENTS = {
LABEL_UPDATED: 'labelUpdated',
GRAPHIC_UPDATED: 'graphicUpdated',
VIEW_UPDATED: 'viewUpdated',
REMOVED: 'removed',
};
/**
* Represents a single annotation for the Microscopy Viewer
*/
class RoiAnnotation extends PubSubService {
constructor(roiGraphic, studyInstanceUID, seriesInstanceUID, label = '', viewState = null) {
super(EVENTS);
this.uid = roiGraphic.uid;
this.roiGraphic = roiGraphic;
this.studyInstanceUID = studyInstanceUID;
this.seriesInstanceUID = seriesInstanceUID;
this.label = label;
this.viewState = viewState;
this.setMeasurements(roiGraphic);
}
getScoord3d() {
const roiGraphic = this.roiGraphic;
const roiGraphicSymbols = Object.getOwnPropertySymbols(roiGraphic);
const _scoord3d = roiGraphicSymbols.find(s => String(s) === 'Symbol(scoord3d)');
return roiGraphic[_scoord3d];
}
getCoordinates() {
const scoord3d = this.getScoord3d();
const scoord3dSymbols = Object.getOwnPropertySymbols(scoord3d);
const _coordinates = scoord3dSymbols.find(s => String(s) === 'Symbol(coordinates)');
const coordinates = scoord3d[_coordinates];
return coordinates;
}
/**
* When called will trigger the REMOVED event
*/
destroy() {
this._broadcastEvent(EVENTS.REMOVED, this);
}
/**
* Updates the ROI graphic for the annotation and triggers the GRAPHIC_UPDATED
* event
*
* @param {Object} roiGraphic
*/
setRoiGraphic(roiGraphic) {
this.roiGraphic = roiGraphic;
this.setMeasurements();
this._broadcastEvent(EVENTS.GRAPHIC_UPDATED, this);
}
/**
* Update ROI measurement values based on its scoord3d coordinates.
*
* @returns {void}
*/
setMeasurements() {
const type = this.roiGraphic.scoord3d.graphicType;
const coordinates = this.roiGraphic.scoord3d.graphicData;
switch (type) {
case 'ELLIPSE':
// This is a circle so only need one side
const point1 = coordinates[0];
const point2 = coordinates[1];
let xLength2 = point2[0] - point1[0];
let yLength2 = point2[1] - point1[1];
xLength2 *= xLength2;
yLength2 *= yLength2;
const length = Math.sqrt(xLength2 + yLength2);
const radius = length / 2;
const areaEllipse = Math.PI * radius * radius;
this._area = areaEllipse;
this._length = undefined;
break;
case 'POLYGON':
const areaPolygon = areaOfPolygon(coordinates);
this._area = areaPolygon;
this._length = undefined;
break;
case 'POINT':
this._area = undefined;
this._length = undefined;
break;
case 'POLYLINE':
let len = 0;
for (let i = 1; i < coordinates.length; i++) {
const p1 = coordinates[i - 1];
const p2 = coordinates[i];
let xLen = p2[0] - p1[0];
let yLen = p2[1] - p1[1];
xLen *= xLen;
yLen *= yLen;
len += Math.sqrt(xLen + yLen);
}
this._area = undefined;
this._length = len;
break;
}
}
/**
* Update the OpenLayer Map's view state for the annotation and triggers the
* VIEW_UPDATED event
*
* @param {Object} viewState The new view state for the annotation
*/
setViewState(viewState) {
this.viewState = viewState;
this._broadcastEvent(EVENTS.VIEW_UPDATED, this);
}
/**
* Update the label for the annotation and triggers the LABEL_UPDATED event
*
* @param {String} label New label for the annotation
*/
setLabel(label, finding) {
this.label = label || (finding && finding.CodeMeaning);
this.finding = finding || {
CodingSchemeDesignator: '@ohif/extension-dicom-microscopy',
CodeValue: label,
CodeMeaning: label,
};
this._broadcastEvent(EVENTS.LABEL_UPDATED, this);
}
/**
* Returns the geometry type of the annotation concatenated with the label
* defined for the annotation.
* Difference with getDetailedLabel() is that this will return empty string for empty
* label.
*
* @returns {String} Text with geometry type and label
*/
getLabel() {
const label = this.label ? `${this.label}` : '';
return label;
}
/**
* Returns the geometry type of the annotation concatenated with the label
* defined for the annotation
*
* @returns {String} Text with geometry type and label
*/
getDetailedLabel() {
const label = this.label ? `${this.label}` : '(empty)';
return label;
}
getLength() {
return this._length;
}
getArea() {
return this._area;
}
}
export { EVENTS };
export default RoiAnnotation;

View File

@@ -0,0 +1,15 @@
export default function areaOfPolygon(coordinates) {
// Shoelace algorithm.
const n = coordinates.length;
let area = 0.0;
let j = n - 1;
for (let i = 0; i < n; i++) {
area += (coordinates[j][0] + coordinates[i][0]) * (coordinates[j][1] - coordinates[i][1]);
j = i; // j is previous vertex to i
}
// Return absolute value of half the sum
// (The value is halved as we are summing up triangles, not rectangles).
return Math.abs(area / 2.0);
}

View File

@@ -0,0 +1,192 @@
import dcmjs from 'dcmjs';
import DEVICE_OBSERVER_UID from './DEVICE_OBSERVER_UID';
/**
*
* @param {*} metadata - Microscopy Image instance metadata
* @param {*} SeriesDescription - SR description
* @param {*} annotations - Annotations
*
* @return Comprehensive3DSR dataset
*/
export default function constructSR(metadata, { SeriesDescription, SeriesNumber }, annotations) {
// Handle malformed data
if (!metadata.SpecimenDescriptionSequence) {
metadata.SpecimenDescriptionSequence = {
SpecimenUID: metadata.SeriesInstanceUID,
SpecimenIdentifier: metadata.SeriesDescription,
};
}
const { SpecimenDescriptionSequence } = metadata;
// construct Comprehensive3DSR dataset
const observationContext = new dcmjs.sr.templates.ObservationContext({
observerPersonContext: new dcmjs.sr.templates.ObserverContext({
observerType: new dcmjs.sr.coding.CodedConcept({
value: '121006',
schemeDesignator: 'DCM',
meaning: 'Person',
}),
observerIdentifyingAttributes: new dcmjs.sr.templates.PersonObserverIdentifyingAttributes({
name: '@ohif/extension-dicom-microscopy',
}),
}),
observerDeviceContext: new dcmjs.sr.templates.ObserverContext({
observerType: new dcmjs.sr.coding.CodedConcept({
value: '121007',
schemeDesignator: 'DCM',
meaning: 'Device',
}),
observerIdentifyingAttributes: new dcmjs.sr.templates.DeviceObserverIdentifyingAttributes({
uid: DEVICE_OBSERVER_UID,
}),
}),
subjectContext: new dcmjs.sr.templates.SubjectContext({
subjectClass: new dcmjs.sr.coding.CodedConcept({
value: '121027',
schemeDesignator: 'DCM',
meaning: 'Specimen',
}),
subjectClassSpecificContext: new dcmjs.sr.templates.SubjectContextSpecimen({
uid: SpecimenDescriptionSequence.SpecimenUID,
identifier: SpecimenDescriptionSequence.SpecimenIdentifier || metadata.SeriesInstanceUID,
containerIdentifier: metadata.ContainerIdentifier || metadata.SeriesInstanceUID,
}),
}),
});
const imagingMeasurements = [];
for (let i = 0; i < annotations.length; i++) {
const { roiGraphic: roi, label } = annotations[i];
let { measurements, evaluations, marker, presentationState } = roi.properties;
console.log('[SR] storing marker...', marker);
console.log('[SR] storing measurements...', measurements);
console.log('[SR] storing evaluations...', evaluations);
console.log('[SR] storing presentation state...', presentationState);
if (presentationState) {
presentationState.marker = marker;
}
/** Avoid incompatibility with dcmjs */
measurements = measurements.map((measurement: any) => {
const ConceptName = Array.isArray(measurement.ConceptNameCodeSequence)
? measurement.ConceptNameCodeSequence[0]
: measurement.ConceptNameCodeSequence;
const MeasuredValue = Array.isArray(measurement.MeasuredValueSequence)
? measurement.MeasuredValueSequence[0]
: measurement.MeasuredValueSequence;
const MeasuredValueUnits = Array.isArray(MeasuredValue.MeasurementUnitsCodeSequence)
? MeasuredValue.MeasurementUnitsCodeSequence[0]
: MeasuredValue.MeasurementUnitsCodeSequence;
return new dcmjs.sr.valueTypes.NumContentItem({
name: new dcmjs.sr.coding.CodedConcept({
meaning: ConceptName.CodeMeaning,
value: ConceptName.CodeValue,
schemeDesignator: ConceptName.CodingSchemeDesignator,
}),
value: MeasuredValue.NumericValue,
unit: new dcmjs.sr.coding.CodedConcept({
value: MeasuredValueUnits.CodeValue,
meaning: MeasuredValueUnits.CodeMeaning,
schemeDesignator: MeasuredValueUnits.CodingSchemeDesignator,
}),
});
});
/** Avoid incompatibility with dcmjs */
evaluations = evaluations.map((evaluation: any) => {
const ConceptName = Array.isArray(evaluation.ConceptNameCodeSequence)
? evaluation.ConceptNameCodeSequence[0]
: evaluation.ConceptNameCodeSequence;
return new dcmjs.sr.valueTypes.TextContentItem({
name: new dcmjs.sr.coding.CodedConcept({
value: ConceptName.CodeValue,
meaning: ConceptName.CodeMeaning,
schemeDesignator: ConceptName.CodingSchemeDesignator,
}),
value: evaluation.TextValue,
relationshipType: evaluation.RelationshipType,
});
});
const identifier = `ROI #${i + 1}`;
const group = new dcmjs.sr.templates.PlanarROIMeasurementsAndQualitativeEvaluations({
trackingIdentifier: new dcmjs.sr.templates.TrackingIdentifier({
uid: roi.uid,
identifier: presentationState
? identifier.concat(`(${JSON.stringify(presentationState)})`)
: identifier,
}),
referencedRegion: new dcmjs.sr.contentItems.ImageRegion3D({
graphicType: roi.scoord3d.graphicType,
graphicData: roi.scoord3d.graphicData,
frameOfReferenceUID: roi.scoord3d.frameOfReferenceUID,
}),
findingType: new dcmjs.sr.coding.CodedConcept({
value: label,
schemeDesignator: '@ohif/extension-dicom-microscopy',
meaning: 'FREETEXT',
}),
/** Evaluations will conflict with current tracking identifier */
/** qualitativeEvaluations: evaluations, */
measurements,
});
imagingMeasurements.push(...group);
}
const measurementReport = new dcmjs.sr.templates.MeasurementReport({
languageOfContentItemAndDescendants: new dcmjs.sr.templates.LanguageOfContentItemAndDescendants(
{}
),
observationContext,
procedureReported: new dcmjs.sr.coding.CodedConcept({
value: '112703',
schemeDesignator: 'DCM',
meaning: 'Whole Slide Imaging',
}),
imagingMeasurements,
});
const dataset = new dcmjs.sr.documents.Comprehensive3DSR({
content: measurementReport[0],
evidence: [metadata],
seriesInstanceUID: dcmjs.data.DicomMetaDictionary.uid(),
seriesNumber: SeriesNumber,
seriesDescription: SeriesDescription || 'Whole slide imaging structured report',
sopInstanceUID: dcmjs.data.DicomMetaDictionary.uid(),
instanceNumber: 1,
manufacturer: 'dcmjs-org',
});
dataset.SpecificCharacterSet = 'ISO_IR 192';
const fileMetaInformationVersionArray = new Uint8Array(2);
fileMetaInformationVersionArray[1] = 1;
dataset._meta = {
FileMetaInformationVersion: {
Value: [fileMetaInformationVersionArray.buffer], // TODO
vr: 'OB',
},
MediaStorageSOPClassUID: dataset.sopClassUID,
MediaStorageSOPInstanceUID: dataset.sopInstanceUID,
TransferSyntaxUID: {
Value: ['1.2.840.10008.1.2.1'],
vr: 'UI',
},
ImplementationClassUID: {
Value: [dcmjs.data.DicomMetaDictionary.uid()],
vr: 'UI',
},
ImplementationVersionName: {
Value: ['@ohif/extension-dicom-microscopy'],
vr: 'SH',
},
};
return dataset;
}

View File

@@ -0,0 +1,109 @@
import { inv, multiply } from 'mathjs';
// TODO -> This is pulled out of some internal logic from Dicom Microscopy Viewer,
// We should likely just expose this there.
export default function coordinateFormatScoord3d2Geometry(coordinates, pyramid) {
let transform = false;
if (!Array.isArray(coordinates[0])) {
coordinates = [coordinates];
transform = true;
}
const metadata = pyramid[pyramid.length - 1];
const orientation = metadata.ImageOrientationSlide;
const spacing = _getPixelSpacing(metadata);
const origin = metadata.TotalPixelMatrixOriginSequence[0];
const offset = [
Number(origin.XOffsetInSlideCoordinateSystem),
Number(origin.YOffsetInSlideCoordinateSystem),
];
coordinates = coordinates.map(c => {
const slideCoord = [c[0], c[1]];
const pixelCoord = mapSlideCoord2PixelCoord({
offset,
orientation,
spacing,
point: slideCoord,
});
return [pixelCoord[0], -(pixelCoord[1] + 1), 0];
});
if (transform) {
return coordinates[0];
}
return coordinates;
}
function _getPixelSpacing(metadata) {
if (metadata.PixelSpacing) {
return metadata.PixelSpacing;
}
const functionalGroup = metadata.SharedFunctionalGroupsSequence[0];
const pixelMeasures = functionalGroup.PixelMeasuresSequence[0];
return pixelMeasures.PixelSpacing;
}
function mapSlideCoord2PixelCoord(options) {
// X and Y Offset in Slide Coordinate System
if (!('offset' in options)) {
throw new Error('Option "offset" is required.');
}
if (!Array.isArray(options.offset)) {
throw new Error('Option "offset" must be an array.');
}
if (options.offset.length !== 2) {
throw new Error('Option "offset" must be an array with 2 elements.');
}
const offset = options.offset;
// Image Orientation Slide with direction cosines for Row and Column direction
if (!('orientation' in options)) {
throw new Error('Option "orientation" is required.');
}
if (!Array.isArray(options.orientation)) {
throw new Error('Option "orientation" must be an array.');
}
if (options.orientation.length !== 6) {
throw new Error('Option "orientation" must be an array with 6 elements.');
}
const orientation = options.orientation;
// Pixel Spacing along the Row and Column direction
if (!('spacing' in options)) {
throw new Error('Option "spacing" is required.');
}
if (!Array.isArray(options.spacing)) {
throw new Error('Option "spacing" must be an array.');
}
if (options.spacing.length !== 2) {
throw new Error('Option "spacing" must be an array with 2 elements.');
}
const spacing = options.spacing;
// X and Y coordinate in the Slide Coordinate System
if (!('point' in options)) {
throw new Error('Option "point" is required.');
}
if (!Array.isArray(options.point)) {
throw new Error('Option "point" must be an array.');
}
if (options.point.length !== 2) {
throw new Error('Option "point" must be an array with 2 elements.');
}
const point = options.point;
const m = [
[orientation[0] * spacing[1], orientation[3] * spacing[0], offset[0]],
[orientation[1] * spacing[1], orientation[4] * spacing[0], offset[1]],
[0, 0, 1],
];
const mInverted = inv(m);
const vSlide = [[point[0]], [point[1]], [1]];
const vImage = multiply(mInverted, vSlide);
const row = Number(vImage[1][0].toFixed(4));
const col = Number(vImage[0][0].toFixed(4));
return [col, row];
}

View File

@@ -0,0 +1,14 @@
const DCM_CODE_VALUES = {
IMAGING_MEASUREMENTS: '126010',
MEASUREMENT_GROUP: '125007',
IMAGE_REGION: '111030',
FINDING: '121071',
TRACKING_UNIQUE_IDENTIFIER: '112039',
LENGTH: '410668003',
AREA: '42798000',
SHORT_AXIS: 'G-A186',
LONG_AXIS: 'G-A185',
ELLIPSE_AREA: 'G-D7FE', // TODO: Remove this
};
export default DCM_CODE_VALUES;

View File

@@ -0,0 +1,81 @@
import { errorHandler, DicomMetadataStore } from '@ohif/core';
import { StaticWadoClient } from '@ohif/extension-default';
/**
* create a DICOMwebClient object to be used by Dicom Microscopy Viewer
*
* Referenced the code from `/extensions/default/src/DicomWebDataSource/index.js`
*
* @param param0
* @returns
*/
export default function getDicomWebClient({ extensionManager, servicesManager }: withAppTypes) {
const dataSourceConfig = window.config.dataSources.find(
ds => ds.sourceName === extensionManager.activeDataSource
);
const { userAuthenticationService } = servicesManager.services;
const { wadoRoot, staticWado, singlepart } = dataSourceConfig.configuration;
const wadoConfig = {
url: wadoRoot || '/dicomlocal',
staticWado,
singlepart,
headers: userAuthenticationService.getAuthorizationHeader(),
errorInterceptor: errorHandler.getHTTPErrorHandler(),
};
const client = new StaticWadoClient(wadoConfig);
client.wadoURL = wadoConfig.url;
if (extensionManager.activeDataSource === 'dicomlocal') {
/**
* For local data source, override the retrieveInstanceFrames() method of the
* dicomweb-client to retrieve image data from memory cached metadata.
* Other methods of the client doesn't matter, as we are feeding the DMV
* with the series metadata already.
*
* @param {Object} options
* @param {String} options.studyInstanceUID - Study Instance UID
* @param {String} options.seriesInstanceUID - Series Instance UID
* @param {String} options.sopInstanceUID - SOP Instance UID
* @param {String} options.frameNumbers - One-based indices of Frame Items
* @param {Object} [options.queryParams] - HTTP query parameters
* @returns {ArrayBuffer[]} Rendered Frame Items as byte arrays
*/
//
client.retrieveInstanceFrames = async options => {
if (!('studyInstanceUID' in options)) {
throw new Error('Study Instance UID is required for retrieval of instance frames');
}
if (!('seriesInstanceUID' in options)) {
throw new Error('Series Instance UID is required for retrieval of instance frames');
}
if (!('sopInstanceUID' in options)) {
throw new Error('SOP Instance UID is required for retrieval of instance frames');
}
if (!('frameNumbers' in options)) {
throw new Error('frame numbers are required for retrieval of instance frames');
}
console.log(
`retrieve frames ${options.frameNumbers.toString()} of instance ${options.sopInstanceUID}`
);
const instance = DicomMetadataStore.getInstance(
options.studyInstanceUID,
options.seriesInstanceUID,
options.sopInstanceUID
);
const frameNumbers = Array.isArray(options.frameNumbers)
? options.frameNumbers
: options.frameNumbers.split(',');
return frameNumbers.map(fr =>
Array.isArray(instance.PixelData) ? instance.PixelData[+fr - 1] : instance.PixelData
);
};
}
return client;
}

View File

@@ -0,0 +1,32 @@
/**
* Get referenced SM displaySet from SR displaySet
*
* @param {*} allDisplaySets
* @param {*} microscopySRDisplaySet
* @returns
*/
export default function getSourceDisplaySet(allDisplaySets, microscopySRDisplaySet) {
const { ReferencedFrameOfReferenceUID } = microscopySRDisplaySet;
const otherDisplaySets = allDisplaySets.filter(
ds => ds.displaySetInstanceUID !== microscopySRDisplaySet.displaySetInstanceUID
);
const referencedDisplaySet = otherDisplaySets.find(
displaySet =>
displaySet.Modality === 'SM' &&
(displaySet.FrameOfReferenceUID === ReferencedFrameOfReferenceUID ||
// sometimes each depth instance has the different FrameOfReferenceID
displaySet.othersFrameOfReferenceUID.includes(ReferencedFrameOfReferenceUID))
);
if (!referencedDisplaySet && otherDisplaySets.length >= 1) {
console.warn(
'No display set with FrameOfReferenceUID',
ReferencedFrameOfReferenceUID,
'single series, assuming data error, defaulting to only series.'
);
return otherDisplaySets.find(displaySet => displaySet.Modality === 'SM');
}
return referencedDisplaySet;
}

View File

@@ -0,0 +1,184 @@
import dcmjs from 'dcmjs';
import DCM_CODE_VALUES from './dcmCodeValues';
import toArray from './toArray';
const MeasurementReport = dcmjs.adapters.DICOMMicroscopyViewer.MeasurementReport;
// Define as async so that it returns a promise, expected by the ViewportGrid
export default async function loadSR(
microscopyService,
microscopySRDisplaySet,
referencedDisplaySet
) {
const naturalizedDataset = microscopySRDisplaySet.metadata;
const { StudyInstanceUID, FrameOfReferenceUID } = referencedDisplaySet;
const managedViewers = microscopyService.getManagedViewersForStudy(StudyInstanceUID);
if (!managedViewers || !managedViewers.length) {
return;
}
microscopySRDisplaySet.isLoaded = true;
const { rois, labels } = await _getROIsFromToolState(microscopyService, naturalizedDataset, FrameOfReferenceUID);
const managedViewer = managedViewers[0];
for (let i = 0; i < rois.length; i++) {
// NOTE: When saving Microscopy SR, we are attaching identifier property
// to each ROI, and when read for display, it is coming in as "TEXT"
// evaluation.
// As the Dicom Microscopy Viewer will override styles for "Text" evaluations
// to hide all other geometries, we are going to manually remove that
// evaluation item.
const roi = rois[i];
const roiSymbols = Object.getOwnPropertySymbols(roi);
const _properties = roiSymbols.find(s => s.description === 'properties');
const properties = roi[_properties];
properties['evaluations'] = [];
managedViewer.addRoiGraphicWithLabel(roi, labels[i]);
}
}
async function _getROIsFromToolState(microscopyService, naturalizedDataset, FrameOfReferenceUID) {
const toolState = MeasurementReport.generateToolState(naturalizedDataset);
const tools = Object.getOwnPropertyNames(toolState);
// Does a dynamic import to prevent webpack from rebuilding the library
const DICOMMicroscopyViewer = await microscopyService.importDicomMicroscopyViewer();
const measurementGroupContentItems = _getMeasurementGroups(naturalizedDataset);
const rois = [];
const labels = [];
tools.forEach(t => {
const toolSpecificToolState = toolState[t];
let scoord3d;
const capsToolType = t.toUpperCase();
const measurementGroupContentItemsForTool = measurementGroupContentItems.filter(mg => {
const imageRegionContentItem = toArray(mg.ContentSequence).find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.IMAGE_REGION
);
return imageRegionContentItem.GraphicType === capsToolType;
});
toolSpecificToolState.forEach((coordinates, index) => {
const properties = {};
const options = {
coordinates,
frameOfReferenceUID: FrameOfReferenceUID,
};
if (t === 'Polygon') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Polygon(options);
} else if (t === 'Polyline') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Polyline(options);
} else if (t === 'Point') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Point(options);
} else if (t === 'Ellipse') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Ellipse(options);
} else {
throw new Error('Unsupported tool type');
}
const measurementGroup = measurementGroupContentItemsForTool[index];
const findingGroup = toArray(measurementGroup.ContentSequence).find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.FINDING
);
const trackingGroup = toArray(measurementGroup.ContentSequence).find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.TRACKING_UNIQUE_IDENTIFIER
);
/**
* Extract presentation state from tracking identifier.
* Currently is stored in SR but should be stored in its tags.
*/
if (trackingGroup) {
const regExp = /\(([^)]+)\)/;
const matches = regExp.exec(trackingGroup.TextValue);
if (matches && matches[1]) {
properties.presentationState = JSON.parse(matches[1]);
properties.marker = properties.presentationState.marker;
}
}
let measurements = toArray(measurementGroup.ContentSequence).filter(ci =>
[
DCM_CODE_VALUES.LENGTH,
DCM_CODE_VALUES.AREA,
DCM_CODE_VALUES.SHORT_AXIS,
DCM_CODE_VALUES.LONG_AXIS,
DCM_CODE_VALUES.ELLIPSE_AREA,
].includes(ci.ConceptNameCodeSequence.CodeValue)
);
let evaluations = toArray(measurementGroup.ContentSequence).filter(ci =>
[DCM_CODE_VALUES.TRACKING_UNIQUE_IDENTIFIER].includes(ci.ConceptNameCodeSequence.CodeValue)
);
/**
* TODO: Resolve bug in DCMJS.
* ConceptNameCodeSequence should be a sequence with only one item.
*/
evaluations = evaluations.map(evaluation => {
const e = { ...evaluation };
e.ConceptNameCodeSequence = toArray(e.ConceptNameCodeSequence);
return e;
});
/**
* TODO: Resolve bug in DCMJS.
* ConceptNameCodeSequence should be a sequence with only one item.
*/
measurements = measurements.map(measurement => {
const m = { ...measurement };
m.ConceptNameCodeSequence = toArray(m.ConceptNameCodeSequence);
return m;
});
if (measurements && measurements.length) {
properties.measurements = measurements;
console.log('[SR] retrieving measurements...', measurements);
}
if (evaluations && evaluations.length) {
properties.evaluations = evaluations;
console.log('[SR] retrieving evaluations...', evaluations);
}
const roi = new DICOMMicroscopyViewer.roi.ROI({ scoord3d, properties });
rois.push(roi);
if (findingGroup) {
labels.push(findingGroup.ConceptCodeSequence.CodeValue);
} else {
labels.push('');
}
});
});
return { rois, labels };
}
function _getMeasurementGroups(naturalizedDataset) {
const { ContentSequence } = naturalizedDataset;
const imagingMeasurementsContentItem = ContentSequence.find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.IMAGING_MEASUREMENTS
);
const measurementGroupContentItems = toArray(
imagingMeasurementsContentItem.ContentSequence
).filter(ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.MEASUREMENT_GROUP);
return measurementGroupContentItems;
}

View File

@@ -0,0 +1,12 @@
/**
* Trigger file download from an array buffer
* @param buffer
* @param filename
*/
export function saveByteArray(buffer: ArrayBuffer, filename: string) {
const blob = new Blob([buffer], { type: 'application/dicom' });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
link.click();
}

View File

@@ -0,0 +1,48 @@
const defaultFill = {
color: 'rgba(255,255,255,0.4)',
};
const emptyFill = {
color: 'rgba(255,255,255,0.0)',
};
const defaultStroke = {
color: 'rgb(0,255,0)',
width: 1.5,
};
const activeStroke = {
color: 'rgb(255,255,0)',
width: 1.5,
};
const defaultStyle = {
image: {
circle: {
fill: defaultFill,
stroke: activeStroke,
radius: 5,
},
},
fill: defaultFill,
stroke: activeStroke,
};
const emptyStyle = {
image: {
circle: {
fill: emptyFill,
stroke: defaultStroke,
radius: 5,
},
},
fill: emptyFill,
stroke: defaultStroke,
};
const styles = {
active: defaultStyle,
default: emptyStyle,
};
export default styles;

View File

@@ -0,0 +1,3 @@
export default function toArray(item) {
return Array.isArray(item) ? item : [item];
}