init
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
365
extensions/dicom-microscopy/src/DicomMicroscopyViewport.css
Normal file
365
extensions/dicom-microscopy/src/DicomMicroscopyViewport.css
Normal 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;
|
||||
}
|
||||
314
extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx
Normal file
314
extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
}
|
||||
157
extensions/dicom-microscopy/src/getCommandsModule.ts
Normal file
157
extensions/dicom-microscopy/src/getCommandsModule.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
42
extensions/dicom-microscopy/src/getPanelModule.tsx
Normal file
42
extensions/dicom-microscopy/src/getPanelModule.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
11
extensions/dicom-microscopy/src/helpers/formatDICOMDate.js
Normal file
11
extensions/dicom-microscopy/src/helpers/formatDICOMDate.js
Normal 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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
17
extensions/dicom-microscopy/src/helpers/formatDICOMTime.js
Normal file
17
extensions/dicom-microscopy/src/helpers/formatDICOMTime.js
Normal 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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
15
extensions/dicom-microscopy/src/helpers/index.js
Normal file
15
extensions/dicom-microscopy/src/helpers/index.js
Normal 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;
|
||||
3
extensions/dicom-microscopy/src/helpers/isValidNumber.js
Normal file
3
extensions/dicom-microscopy/src/helpers/isValidNumber.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function isValidNumber(value) {
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
}
|
||||
5
extensions/dicom-microscopy/src/id.js
Normal file
5
extensions/dicom-microscopy/src/id.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const id = packageJson.name;
|
||||
|
||||
export { id };
|
||||
164
extensions/dicom-microscopy/src/index.tsx
Normal file
164
extensions/dicom-microscopy/src/index.tsx
Normal 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;
|
||||
647
extensions/dicom-microscopy/src/services/MicroscopyService.ts
Normal file
647
extensions/dicom-microscopy/src/services/MicroscopyService.ts
Normal 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 };
|
||||
464
extensions/dicom-microscopy/src/tools/viewerManager.js
Normal file
464
extensions/dicom-microscopy/src/tools/viewerManager.js
Normal 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;
|
||||
11
extensions/dicom-microscopy/src/types/AppTypes.ts
Normal file
11
extensions/dicom-microscopy/src/types/AppTypes.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
186
extensions/dicom-microscopy/src/utils/RoiAnnotation.js
Normal file
186
extensions/dicom-microscopy/src/utils/RoiAnnotation.js
Normal 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;
|
||||
15
extensions/dicom-microscopy/src/utils/areaOfPolygon.js
Normal file
15
extensions/dicom-microscopy/src/utils/areaOfPolygon.js
Normal 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);
|
||||
}
|
||||
192
extensions/dicom-microscopy/src/utils/constructSR.ts
Normal file
192
extensions/dicom-microscopy/src/utils/constructSR.ts
Normal 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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
14
extensions/dicom-microscopy/src/utils/dcmCodeValues.js
Normal file
14
extensions/dicom-microscopy/src/utils/dcmCodeValues.js
Normal 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;
|
||||
81
extensions/dicom-microscopy/src/utils/dicomWebClient.ts
Normal file
81
extensions/dicom-microscopy/src/utils/dicomWebClient.ts
Normal 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;
|
||||
}
|
||||
32
extensions/dicom-microscopy/src/utils/getSourceDisplaySet.js
Normal file
32
extensions/dicom-microscopy/src/utils/getSourceDisplaySet.js
Normal 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;
|
||||
}
|
||||
184
extensions/dicom-microscopy/src/utils/loadSR.ts
Normal file
184
extensions/dicom-microscopy/src/utils/loadSR.ts
Normal 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;
|
||||
}
|
||||
12
extensions/dicom-microscopy/src/utils/saveByteArray.ts
Normal file
12
extensions/dicom-microscopy/src/utils/saveByteArray.ts
Normal 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();
|
||||
}
|
||||
48
extensions/dicom-microscopy/src/utils/styles.js
Normal file
48
extensions/dicom-microscopy/src/utils/styles.js
Normal 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;
|
||||
3
extensions/dicom-microscopy/src/utils/toArray.js
Normal file
3
extensions/dicom-microscopy/src/utils/toArray.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function toArray(item) {
|
||||
return Array.isArray(item) ? item : [item];
|
||||
}
|
||||
Reference in New Issue
Block a user