feat: cornerstone3D stack and volume viewports (#2787)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: cs3d tools and toolGroups (#20)

* add more tools to work with cs3d mode

* fix hotkeys

* add stack manager usage for stach viewports

* add image scrollbar

* wip viewport overlay

* fix toAnnotation schema for tools

* fix the unnecessary size change that triggered resize

* hanging protocol improvement to allow unmatched errors

* study description matching for hanging protocol

* fix the displaysetOptions to work

* fix handle the active tool when a new viewport is added

* fix separate toolGroups for mode

* apply review comments

* apply review comments

* yarn lock

* feat: overlay component (#21)

* fix default displayset options

* add viewport overlay

* apply review comments

* feat: loading and orientation indicators (#22)

* add loading indicator

* add orientation marker initial work

* apply review comments

* fix: orientation markers (#23)

* finished the orientation markers

* fix various broken cypress tests

* apply review comments

* update yarn lock

* feat: re-working measurement tracking mode with cornerstone3d (#2805)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: Add Measurement tracking mode with cs3D (#2789)

* feat: first render for cornerstone3d tracked viewport

* make tool active work

* wip for SR extension

* renamed dicom sr to cornerstone dicom sr

* remove cornerstone from dicom pdf and video

* move dicom sr logic to sr extension

* feat: Add hydration for the length tool

* fix SR display tool for length using cs3d

* fix default config

* fix: various bugs with sr viewport and tracking

* fix promptying to continue tracking for when SR is created

* feat: add keep trackign of unique identifiers

* fix hydration for same imageIds

* feat: Add SR toolGroup creation on modeEnter

* feat: remove the need for separate mapper for SR hydration

* add SR display for ellipse

* handle hydration of elliptical ROI tool

* remove cornerstone extension

* add arrow mapping

* feat: Add ArrowAnnotate SR display and hydration

* apply review comments

* apply review comments

* move viewport labels to the viewportData

* apply review comments

* fix: integration cypress tests with cornerstone3D and add CINE tool (#2795)

* fix: integration cypress tests with cornerstone3D

* revert to addOrUpdate as it makes more sense

* fix local drag and drop

* fix tests

* move dicomLoaderService to cornerstone extension

* fix various import bugs

* fix bug for local PT series

* fix various unit tests

* bump cs3d versions

* add angle and magnify tool

* bump dependencies to avoid broken peerDeps

* feat: add initial work for capture using cs3d

* feat: show annotations on the image capture

* feat: add svg layer export

* feat: Add CINE Tool

* feat: remove unnecessary viewport rendering for cine state changes

Co-authored-by: Erik Ziegler <erik.sweed@gmail.com>

* docs: modify and improve documentation (#2800)

* cleanup docs versionings

* feat: Add all version explanations

* version docs for 3.0

* wip for changing docs

* wip for updated docs

* add utility module documentation

* fix demo with nohoisting of history

* add slides and video to resources

* apply review comments

* fix: drag and drop SR into SR viewport (#2803)

* update yarn lock

Co-authored-by: Erik <erik.sweed@gmail.com>

* update pathnames to match v3-stable

* update the e2e pathname

* fix: various bugs with regard to tracking workflow (#2811)

* fix: various issues with measurement panel

* fix: update default tool style for annotations

* fix: annotatoin label getting removed

* feat: Add backward compatibility for SR hydration with legacy cornerstone

* fix: cursors and ellipse ROI max style

* fix: ArrowAnnotate SRDisplay

* apply review comments

* bump package versions

* fix: bugin rehydration of SR

* fix: e2e tests

* fix active viewport thickness and arrowTool ui

* add readme for measurement tracking

* use uploaded image for readme

* add back images

* try to fix e2e test

* fix: window level presets hotkeys

* Update README.md

* update yarn lock

* feat: volume api and TMTV mode (#2817)

* feat: volumeAPI and TMTV mode

* feat: use cs3d cache service to obtain viewportData

* wip for volume api

* wip: fusion viewport

* feat: add blend mode option

* wip for image scrollbar

* fix drag and drop thumbnail

* wip for image scrollbar for voluems

* fix: element mismatch bug for scroll

* feat: Add image scrollbar to volumes

* feat: add syncGroups to volume api

* feat: add tmtv mode initial setup

* feat: add initial image options for the stack viewports

* feat: Add custom load strategy for volume viewports via HP

* apply review comments

fix: Jump presets cs3d (#2812)

* feat: Add JumpPreset to OHIF for Cornerstone3D

* fix: accessing viewport service from servicesManager

feat: volume API and TMTV mode (#2814)

* fix: do not display overlays on mip viewports

* feat: add optional disableCommands for toggle buttons

* fix: toggleCrossharis

* feat: config the crosshairs

* feat: add PetSUV Panel for changing metadata

* feat: initial work for the rectangleROIThreshold panel

* wip: measurement service

* roi threshold working

* feat: Add displayText to segmentations

* feat: add remove segmentation

* feat: add csv export

* feat: add RT export for annotations in tmtv mode

* fix: fusion to use pt in tmtv mode and measuremet mappings

* fix: various bugs

* apply review comments

* apply review comments

* feat: add fusion color maps

* add readme to tmtv mode

* Update README.md

* fix: try to fix build

* fix unit tests

* Update README.md

* feat: add about to the panel

* fix: changing strategy in roi panel

* apply review comments

* update package versions

* wip for stackPrefetch

* fix: cornerstone3d hydration and renaming (#2818)

* update readme

* renamed cornerstone extension

* wip for renaming cornerstone3D variables

* wip for renaming cornerstone3D variables

* wip for fixing bugs for SR viewport

* fix: jumpToMeasurement and initial label after hydration

* fix: fileName capitalization

* feat: use the new prefetch stack in the cs3d (#2820)

* feat: use the new prefetch stack in the cs3d

* use viewport scroll api for stack viewport

* fix cine stop when scrollbar changes

* feat: use new prefetch events

* fix loading state to not show repeatedly

* fix: various bugs for tmtv mode thresholding and new icons (#2823)

* feat: make tmtv mode available in worklist

* feat: add new icons for tmtv mode

* feat: add fusion color icon

* fix: parallel scale calculation

* fix: bump Cornerstone to get large image support working

* fix: Fix issues with CPU viewport flipping, including config files

* fix: bump cornerstone to fix magnify tool

* fix: Bump Cornerstone version to fix resetCamera issue in StackViewport

* ci: Add _headers file to enable CORS headers for Netlify Drag/Drop deploys

* fix: WADO-URI was not working. PET Metadata was coming from the wrong place. Fixed some minor React errors

* feat(OHIF):Allow modes and extensions to be added after commpile time. (#2838)

Also works with the compile time add that the existing cli uses, so that both build types work.
Eric and I agreed this doesn't change existing functionality, but is almost entirely build issues/fixes.

* bump: dependency versions to fix hydration bugs (#2848)

* bump: dcmjs version to fix hydration bugs

* try to fix tests

* bump dependency versions

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2022-07-27 12:39:04 -04:00
1 parent 86dcaf6293
commit bd1c38c00c
668 files changed
+41669 -13410

No files matched your search

@@ -0,0 +1,8 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
};
@@ -0,0 +1,49 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const ROOT_DIR = path.join(__dirname, './../');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'OHIFExtCornerstone',
libraryTarget: 'umd',
libraryExport: 'default',
filename: pkg.main,
},
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
new MiniCssExtractPlugin({
filename: './dist/[name].css',
chunkFilename: './dist/[id].css',
}),
],
});
};
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Whitespace-only changes.
@@ -0,0 +1 @@
module.exports = require('../../babel.config.js');
@@ -0,0 +1,51 @@
{
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.0.0",
"description": "OHIF extension for an SR Cornerstone Viewport",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.tsx",
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"keywords": [
"ohif-extension"
],
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"dev:cornerstone": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"start": "yarn run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/ui": "^2.0.0",
"dcmjs": "^0.24.5",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"@ohif/extension-cornerstone": "^3.0.0",
"@ohif/extension-measurement-tracking": "^3.0.0"
},
"dependencies": {
"@babel/runtime": "7.16.3",
"classnames": "^2.2.6",
"@cornerstonejs/core": "^0.13.11",
"@cornerstonejs/tools": "^0.20.15"
}
}
@@ -0,0 +1,143 @@
import { metaData, utilities } from '@cornerstonejs/core';
import OHIF from '@ohif/core';
import dcmjs from 'dcmjs';
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
const { MeasurementReport } = dcmjs.adapters.Cornerstone3D;
const { log } = OHIF;
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
*
*/
const _generateReport = (
measurementData,
additionalFindingTypes,
options = {}
) => {
const filteredToolState = getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
);
const report = MeasurementReport.generateReport(
filteredToolState,
metaData,
utilities.worldToImageCoords
);
const { dataset } = report;
// Add in top level series options
Object.assign(dataset, options);
return dataset;
};
const commandsModule = ({}) => {
const actions = {
/**
*
* @param measurementData An array of measurements from the measurements service
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* as opposed to Finding Sites.
* that you wish to serialize.
*/
downloadReport: ({
measurementData,
additionalFindingTypes,
options = {},
}) => {
const srDataset = actions.generateReport(
measurementData,
additionalFindingTypes,
options
);
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
//Create a URL for the binary.
var objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
},
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param dataSource The dataSource that you wish to use to persist the data.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* @return The naturalized report
*/
storeMeasurements: async ({
measurementData,
dataSource,
additionalFindingTypes,
options = {},
}) => {
// TODO -> Eventually use the measurements directly and not the dcmjs adapter,
// But it is good enough for now whilst we only have cornerstone as a datasource.
log.info('[DICOMSR] storeMeasurements');
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
log.error(
'[DICOMSR] datasource has no dataSource.store.dicom endpoint!'
);
return Promise.reject({});
}
try {
const naturalizedReport = _generateReport(
measurementData,
additionalFindingTypes,
options
);
const { StudyInstanceUID } = naturalizedReport;
await dataSource.store.dicom(naturalizedReport);
if (StudyInstanceUID) {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
return naturalizedReport;
} catch (error) {
console.warn(error);
log.error(
`[DICOMSR] Error while saving the measurements: ${error.message}`
);
throw new Error(
error.message || 'Error while saving the measurements.'
);
}
},
};
const definitions = {
downloadReport: {
commandFn: actions.downloadReport,
storeContexts: [],
options: {},
},
storeMeasurements: {
commandFn: actions.storeMeasurements,
storeContexts: [],
options: {},
},
};
return {
actions,
definitions,
defaultContext: 'CORNERSTONE_STRUCTURED_REPORT',
};
};
export default commandsModule;
@@ -0,0 +1,7 @@
export default {
POINT: 'POINT',
MULTIPOINT: 'MULTIPOINT',
POLYLINE: 'POLYLINE',
CIRCLE: 'CIRCLE',
ELLIPSE: 'ELLIPSE',
};
@@ -0,0 +1,615 @@
import { SOPClassHandlerName, SOPClassHandlerId } from './id';
import { utils, classes } from '@ohif/core';
import addMeasurement from './utils/addMeasurement';
import isRehydratable from './utils/isRehydratable';
import { adapters } from 'dcmjs';
const { CodeScheme: Cornerstone3DCodeScheme } = adapters.Cornerstone3D;
const { ImageSet } = classes;
// TODO ->
// Add SR thumbnail
// Make viewport
// Get stacks from referenced displayInstanceUID and load into wrapped CornerStone viewport.
const sopClassUids = [
'1.2.840.10008.5.1.4.1.1.88.11', //BASIC_TEXT_SR:
'1.2.840.10008.5.1.4.1.1.88.22', //ENHANCED_SR:
'1.2.840.10008.5.1.4.1.1.88.33', //COMPREHENSIVE_SR:
];
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
const CodeNameCodeSequenceValues = {
ImagingMeasurementReport: '126000',
ImageLibrary: '111028',
ImagingMeasurements: '126010',
MeasurementGroup: '125007',
ImageLibraryGroup: '126200',
TrackingUniqueIdentifier: '112040',
TrackingIdentifier: '112039',
Finding: '121071',
FindingSite: 'G-C0E3', // SRT
CornerstoneFreeText: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT, //
};
const CodingSchemeDesignators = {
SRT: 'SRT',
CornerstoneCodeSchemes: [
Cornerstone3DCodeScheme.CodingSchemeDesignator,
'CST4',
],
};
const RELATIONSHIP_TYPE = {
INFERRED_FROM: 'INFERRED FROM',
};
const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT';
/**
* Basic SOPClassHandler:
* - For all Image types that are stackable, create
* a displaySet with a stack of images
*
* @param {Array} sopClassHandlerModules List of SOP Class Modules
* @param {SeriesMetadata} series The series metadata object from which the display sets will be created
* @returns {Array} The list of display sets created for the given series object
*/
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 instance = instances[0];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
ConceptNameCodeSequence,
} = instance;
if (
!ConceptNameCodeSequence ||
ConceptNameCodeSequence.CodeValue !==
CodeNameCodeSequenceValues.ImagingMeasurementReport
) {
console.warn(
'Only support Imaging Measurement Report SRs (TID1500) for now'
);
return [];
}
const displaySet = {
//plugin: id,
Modality: 'SR',
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
referencedImages: null,
measurements: null,
isDerivedDisplaySet: true,
isLoaded: false,
sopClassUids,
instance,
};
displaySet.load = () => _load(displaySet, servicesManager, extensionManager);
return [displaySet];
}
function _load(displaySet, servicesManager, extensionManager) {
const { DisplaySetService, MeasurementService } = servicesManager.services;
const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0];
const { ContentSequence } = displaySet.instance;
displaySet.referencedImages = _getReferencedImagesList(ContentSequence);
displaySet.measurements = _getMeasurements(ContentSequence);
const mappings = MeasurementService.getSourceMappings(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
displaySet.isHydrated = false;
displaySet.isRehydratable = isRehydratable(displaySet, mappings);
displaySet.isLoaded = true;
// Check currently added displaySets and add measurements if the sources exist.
DisplaySetService.activeDisplaySets.forEach(activeDisplaySet => {
_checkIfCanAddMeasurementsToDisplaySet(
displaySet,
activeDisplaySet,
dataSource
);
});
// Subscribe to new displaySets as the source may come in after.
DisplaySetService.subscribe(
DisplaySetService.EVENTS.DISPLAY_SETS_ADDED,
data => {
const { displaySetsAdded } = data;
// If there are still some measurements that have not yet been loaded into cornerstone,
// See if we can load them onto any of the new displaySets.
displaySetsAdded.forEach(newDisplaySet => {
_checkIfCanAddMeasurementsToDisplaySet(
displaySet,
newDisplaySet,
dataSource
);
});
}
);
}
function _checkIfCanAddMeasurementsToDisplaySet(
srDisplaySet,
newDisplaySet,
dataSource
) {
let unloadedMeasurements = srDisplaySet.measurements.filter(
measurement => measurement.loaded === false
);
if (unloadedMeasurements.length === 0) {
// All already loaded!
return;
}
if (!newDisplaySet instanceof ImageSet) {
// This also filters out _this_ displaySet, as it is not an ImageSet.
return;
}
const { sopClassUids, images } = newDisplaySet;
// Check if any have the newDisplaySet is the correct SOPClass.
unloadedMeasurements = unloadedMeasurements.filter(measurement =>
measurement.coords.some(coord =>
sopClassUids.includes(coord.ReferencedSOPSequence.ReferencedSOPClassUID)
)
);
if (unloadedMeasurements.length === 0) {
// New displaySet isn't the correct SOPClass, so can't contain the referenced images.
return;
}
const SOPInstanceUIDs = [];
unloadedMeasurements.forEach(measurement => {
const { coords } = measurement;
coords.forEach(coord => {
const SOPInstanceUID =
coord.ReferencedSOPSequence.ReferencedSOPInstanceUID;
if (!SOPInstanceUIDs.includes(SOPInstanceUID)) {
SOPInstanceUIDs.push(SOPInstanceUID);
}
});
});
const imageIdsForDisplaySet = dataSource.getImageIdsForDisplaySet(
newDisplaySet
);
for (let i = 0; i < images.length; i++) {
if (!unloadedMeasurements.length) {
// All measurements loaded.
break;
}
const image = images[i];
const { SOPInstanceUID } = image;
if (SOPInstanceUIDs.includes(SOPInstanceUID)) {
const imageId = imageIdsForDisplaySet[i];
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
const measurement = unloadedMeasurements[j];
if (_measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID)) {
addMeasurement(
measurement,
imageId,
newDisplaySet.displaySetInstanceUID
);
unloadedMeasurements.splice(j, 1);
}
}
}
}
}
function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID) {
const { coords } = measurement;
for (let j = 0; j < coords.length; j++) {
const coord = coords[j];
const { ReferencedSOPInstanceUID } = coord.ReferencedSOPSequence;
if (ReferencedSOPInstanceUID === SOPInstanceUID) {
return true;
}
}
}
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
);
};
return [
{
name: SOPClassHandlerName,
sopClassUids,
getDisplaySetsFromSeries,
},
];
}
function _getMeasurements(ImagingMeasurementReportContentSequence) {
const ImagingMeasurements = ImagingMeasurementReportContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.ImagingMeasurements
);
const MeasurementGroups = _getSequenceAsArray(
ImagingMeasurements.ContentSequence
).filter(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.MeasurementGroup
);
const mergedContentSequencesByTrackingUniqueIdentifiers = _getMergedContentSequencesByTrackingUniqueIdentifiers(
MeasurementGroups
);
let measurements = [];
Object.keys(mergedContentSequencesByTrackingUniqueIdentifiers).forEach(
trackingUniqueIdentifier => {
const mergedContentSequence =
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
];
const measurement = _processMeasurement(mergedContentSequence);
if (measurement) {
measurements.push(measurement);
}
}
);
return measurements;
}
function _getMergedContentSequencesByTrackingUniqueIdentifiers(
MeasurementGroups
) {
const mergedContentSequencesByTrackingUniqueIdentifiers = {};
MeasurementGroups.forEach(MeasurementGroup => {
const ContentSequence = _getSequenceAsArray(
MeasurementGroup.ContentSequence
);
const TrackingUniqueIdentifierItem = ContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.TrackingUniqueIdentifier
);
if (!TrackingUniqueIdentifierItem) {
console.warn(
'No Tracking Unique Identifier, skipping ambiguous measurement.'
);
}
const trackingUniqueIdentifier = TrackingUniqueIdentifierItem.UID;
if (
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
] === undefined
) {
// Add the full ContentSequence
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
] = [...ContentSequence];
} else {
// Add the ContentSequence minus the tracking identifier, as we have this
// Information in the merged ContentSequence anyway.
ContentSequence.forEach(item => {
if (
item.ConceptNameCodeSequence.CodeValue !==
CodeNameCodeSequenceValues.TrackingUniqueIdentifier
) {
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
].push(item);
}
});
}
});
return mergedContentSequencesByTrackingUniqueIdentifiers;
}
function _processMeasurement(mergedContentSequence) {
if (
mergedContentSequence.some(
group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D'
)
) {
return _processTID1410Measurement(mergedContentSequence);
}
return _processNonGeometricallyDefinedMeasurement(mergedContentSequence);
}
function _processTID1410Measurement(mergedContentSequence) {
// Need to deal with TID 1410 style measurements, which will have a SCOORD or SCOORD3D at the top level,
// And non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D
// TODO -> Look at RelationshipType => Contains means
const graphicItem = mergedContentSequence.find(
group => group.ValueType === 'SCOORD'
);
const UIDREFContentItem = mergedContentSequence.find(
group => group.ValueType === 'UIDREF'
);
const TrackingIdentifierContentItem = mergedContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.TrackingIdentifier
);
if (!graphicItem) {
console.warn(
`graphic ValueType ${graphicItem.ValueType} not currently supported, skipping annotation.`
);
return;
}
const NUMContentItems = mergedContentSequence.filter(
group => group.ValueType === 'NUM'
);
const measurement = {
loaded: false,
labels: [],
coords: [_getCoordsFromSCOORDOrSCOORD3D(graphicItem)],
TrackingUniqueIdentifier: UIDREFContentItem.UID,
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
};
NUMContentItems.forEach(item => {
const { ConceptNameCodeSequence, MeasuredValueSequence } = item;
if (MeasuredValueSequence) {
measurement.labels.push(
_getLabelFromMeasuredValueSequence(
ConceptNameCodeSequence,
MeasuredValueSequence
)
);
}
});
return measurement;
}
function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
const NUMContentItems = mergedContentSequence.filter(
group => group.ValueType === 'NUM'
);
const UIDREFContentItem = mergedContentSequence.find(
group => group.ValueType === 'UIDREF'
);
const TrackingIdentifierContentItem = mergedContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.TrackingIdentifier
);
const Finding = mergedContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.Finding
);
const FindingSites = mergedContentSequence.filter(
item =>
item.ConceptNameCodeSequence.CodingSchemeDesignator ===
CodingSchemeDesignators.SRT &&
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.FindingSite
);
const measurement = {
loaded: false,
labels: [],
coords: [],
TrackingUniqueIdentifier: UIDREFContentItem.UID,
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
};
if (
Finding &&
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
Finding.ConceptCodeSequence.CodingSchemeDesignator
) &&
Finding.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
) {
measurement.labels.push({
label: CORNERSTONE_FREETEXT_CODE_VALUE,
value: Finding.ConceptCodeSequence.CodeMeaning,
});
}
// TODO -> Eventually hopefully support SNOMED or some proper code library, just free text for now.
if (FindingSites.length) {
const cornerstoneFreeTextFindingSite = FindingSites.find(
FindingSite =>
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
FindingSite.ConceptCodeSequence.CodingSchemeDesignator
) &&
FindingSite.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
);
if (cornerstoneFreeTextFindingSite) {
measurement.labels.push({
label: CORNERSTONE_FREETEXT_CODE_VALUE,
value: cornerstoneFreeTextFindingSite.ConceptCodeSequence.CodeMeaning,
});
}
}
NUMContentItems.forEach(item => {
const {
ConceptNameCodeSequence,
ContentSequence,
MeasuredValueSequence,
} = item;
const { ValueType } = ContentSequence;
if (!ValueType === 'SCOORD') {
console.warn(
`Graphic ${ValueType} not currently supported, skipping annotation.`
);
return;
}
const coords = _getCoordsFromSCOORDOrSCOORD3D(ContentSequence);
if (coords) {
measurement.coords.push(coords);
}
if (MeasuredValueSequence) {
measurement.labels.push(
_getLabelFromMeasuredValueSequence(
ConceptNameCodeSequence,
MeasuredValueSequence
)
);
}
});
return measurement;
}
function _getCoordsFromSCOORDOrSCOORD3D(item) {
const { ValueType, RelationshipType, GraphicType, GraphicData } = item;
if (RelationshipType !== RELATIONSHIP_TYPE.INFERRED_FROM) {
console.warn(
`Relationshiptype === ${RelationshipType}. Cannot deal with NON TID-1400 SCOORD group with RelationshipType !== "INFERRED FROM."`
);
return;
}
const coords = { ValueType, GraphicType, GraphicData };
// ContentSequence has length of 1 as RelationshipType === 'INFERRED FROM'
if (ValueType === 'SCOORD') {
const { ReferencedSOPSequence } = item.ContentSequence;
coords.ReferencedSOPSequence = ReferencedSOPSequence;
} else if (ValueType === 'SCOORD3D') {
const { ReferencedFrameOfReferenceSequence } = item.ContentSequence;
coords.ReferencedFrameOfReferenceSequence = ReferencedFrameOfReferenceSequence;
}
return coords;
}
function _getLabelFromMeasuredValueSequence(
ConceptNameCodeSequence,
MeasuredValueSequence
) {
const { CodeMeaning } = ConceptNameCodeSequence;
const { NumericValue, MeasurementUnitsCodeSequence } = MeasuredValueSequence;
const { CodeValue } = MeasurementUnitsCodeSequence;
const formatedNumericValue = NumericValue
? Number(NumericValue).toFixed(1)
: '';
return {
label: CodeMeaning,
value: `${formatedNumericValue} ${CodeValue}`,
}; // E.g. Long Axis: 31.0 mm
}
function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
const ImageLibrary = ImagingMeasurementReportContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.ImageLibrary
);
const ImageLibraryGroup = _getSequenceAsArray(
ImageLibrary.ContentSequence
).find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.ImageLibraryGroup
);
const referencedImages = [];
_getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => {
const { ReferencedSOPSequence } = item;
const {
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
} = ReferencedSOPSequence;
referencedImages.push({ ReferencedSOPClassUID, ReferencedSOPInstanceUID });
});
return referencedImages;
}
function _getSequenceAsArray(sequence) {
return Array.isArray(sequence) ? sequence : [sequence];
}
export default getSopClassHandlerModule;
@@ -0,0 +1,8 @@
import packageJson from '../package.json';
const id = packageJson.name;
const SOPClassHandlerName = 'dicom-sr';
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
export { SOPClassHandlerName, SOPClassHandlerId, id };
@@ -0,0 +1,76 @@
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import onModeEnter from './onModeEnter';
import commandsModule from './commandsModule';
import init from './init';
import { id } from './id.js';
import toolNames from './tools/toolNames';
const Component = React.lazy(() => {
return import(
/* webpackPrefetch: true */ './viewports/OHIFCornerstoneSRViewport'
);
});
const OHIFCornerstoneSRViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};
/**
*
*/
const dicomSRExtension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
onModeEnter,
preRegistration({ servicesManager, configuration = {} }) {
init({ servicesManager, configuration });
},
/**
*
*
* @param {object} [configuration={}]
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
*/
getViewportModule({ servicesManager, extensionManager }) {
const ExtendedOHIFCornerstoneSRViewport = props => {
return (
<OHIFCornerstoneSRViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
{...props}
/>
);
};
return [{ name: 'dicom-sr', component: ExtendedOHIFCornerstoneSRViewport }];
},
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
return commandsModule({
servicesManager,
commandsManager,
extensionManager,
});
},
getSopClassHandlerModule,
getUtilityModule({ servicesManager }) {
return [
{
name: 'tools',
exports: {
toolNames,
},
},
];
},
};
export default dicomSRExtension;
@@ -0,0 +1,29 @@
import { addTool, annotation } from '@cornerstonejs/tools';
import DICOMSRDisplayTool from './tools/DICOMSRDisplayTool';
import SRLengthTool from './tools/tools/SRLength';
import SRBidirectionalTool from './tools/tools/SRBidirectional';
import SREllipticalROITool from './tools/tools/SREllipticalROI';
import SRArrowAnnotateTool from './tools/tools/SRArrowAnnotate';
/**
* @param {object} configuration
*/
export default function init({ configuration = {} }) {
addTool(DICOMSRDisplayTool);
addTool(SRLengthTool);
addTool(SRBidirectionalTool);
addTool(SREllipticalROITool);
addTool(SRArrowAnnotateTool);
// Modify annotation tools to use dashed lines on SR
const dashedLine = {
lineDash: '4,4',
};
annotation.config.style.setToolGroupToolStyles('SRToolGroup', {
[SRLengthTool.toolName]: dashedLine,
[SRBidirectionalTool.toolName]: dashedLine,
[SREllipticalROITool.toolName]: dashedLine,
[SRArrowAnnotateTool.toolName]: dashedLine,
global: {},
});
}
@@ -0,0 +1,15 @@
import { SOPClassHandlerId } from './id';
export default function onModeEnter({ servicesManager }) {
const { DisplaySetService } = servicesManager.services;
const displaySetCache = DisplaySetService.getDisplaySetCache();
const srDisplaySets = displaySetCache.filter(
ds => ds.SOPClassHandlerId === SOPClassHandlerId
);
srDisplaySets.forEach(ds => {
// New mode route, allow SRs to be hydrated again
ds.isHydrated = false;
});
}
@@ -0,0 +1,388 @@
import { Types, metaData, utilities as csUtils } from '@cornerstonejs/core';
import {
AnnotationTool,
annotation,
drawing,
utilities,
Types as cs3DToolsTypes,
} from '@cornerstonejs/tools';
import { getTrackingUniqueIdentifiersForElement } from './modules/dicomSRModule';
import SCOORD_TYPES from '../constants/scoordTypes';
export default class DICOMSRDisplayTool extends AnnotationTool {
static toolName = 'DICOMSRDisplay';
constructor(
toolProps = {},
defaultToolProps = {
configuration: {},
}
) {
super(toolProps, defaultToolProps);
}
_getTextBoxLinesFromLabels(labels) {
// TODO -> max 3 for now (label + shortAxis + longAxis), need a generic solution for this!
const labelLength = Math.min(labels.length, 3);
const lines = [];
for (let i = 0; i < labelLength; i++) {
const labelEntry = labels[i];
lines.push(`${_labelToShorthand(labelEntry.label)}${labelEntry.value}`);
}
return lines;
}
// This tool should not inherit from AnnotationTool and we should not need
// to add the following lines.
isPointNearTool = () => null;
getHandleNearImagePoint = () => null;
renderAnnotation = (
enabledElement: Types.IEnabledElement,
svgDrawingHelper: any
): void => {
const { viewport } = enabledElement;
const { element } = viewport;
let annotations = annotation.state.getAnnotations(
element,
this.getToolName()
);
// Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
if (!annotations?.length) {
return;
}
annotations = this.filterInteractableAnnotationsForElement(
element,
annotations
);
if (!annotations?.length) {
return;
}
const trackingUniqueIdentifiersForElement = getTrackingUniqueIdentifiersForElement(
element
);
const {
activeIndex,
trackingUniqueIdentifiers,
} = trackingUniqueIdentifiersForElement;
const activeTrackingUniqueIdentifier =
trackingUniqueIdentifiers[activeIndex];
// Filter toolData to only render the data for the active SR.
const filteredAnnotations = annotations.filter(annotation =>
trackingUniqueIdentifiers.includes(
annotation.data?.cachedStats?.TrackingUniqueIdentifier
)
);
if (!viewport._actors?.size) {
return;
}
const styleSpecifier: cs3DToolsTypes.AnnotationStyle.StyleSpecifier = {
toolGroupId: this.toolGroupId,
toolName: this.getToolName(),
viewportId: enabledElement.viewport.id,
};
for (let i = 0; i < filteredAnnotations.length; i++) {
const annotation = filteredAnnotations[i];
const annotationUID = annotation.annotationUID;
const { renderableData } = annotation.data.cachedStats;
const { label, cachedStats } = annotation.data;
const { referencedImageId } = annotation.metadata;
styleSpecifier.annotationUID = annotationUID;
const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotation);
const lineDash = this.getStyle('lineDash', styleSpecifier, annotation);
const color =
cachedStats.TrackingUniqueIdentifier === activeTrackingUniqueIdentifier
? 'rgb(0, 255, 0)'
: this.getStyle('color', styleSpecifier, annotation);
const options = {
color,
lineDash,
lineWidth,
};
Object.keys(renderableData).forEach(GraphicType => {
const renderableDataForGraphicType = renderableData[GraphicType];
let renderMethod;
switch (GraphicType) {
case SCOORD_TYPES.POINT:
renderMethod = this.renderPoint;
break;
case SCOORD_TYPES.MULTIPOINT:
renderMethod = this.renderMultipoint;
break;
case SCOORD_TYPES.POLYLINE:
renderMethod = this.renderPolyLine;
break;
case SCOORD_TYPES.CIRCLE:
renderMethod = this.renderEllipse;
break;
case SCOORD_TYPES.ELLIPSE:
renderMethod = this.renderEllipse;
break;
default:
throw new Error(`Unsupported GraphicType: ${GraphicType}`);
}
const canvasCoordinates = renderMethod(
svgDrawingHelper,
viewport,
renderableDataForGraphicType,
annotationUID,
referencedImageId,
options
);
if (!canvasCoordinates) {
return;
}
const textLines = this._getTextBoxLinesFromLabels(label);
let canvasCornersToUseForTextBox = canvasCoordinates;
if (GraphicType === SCOORD_TYPES.ELLIPSE) {
canvasCornersToUseForTextBox = utilities.math.ellipse.getCanvasEllipseCorners(
canvasCoordinates
);
}
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(
canvasCornersToUseForTextBox
);
annotation.data.handles.textBox.worldPosition = viewport.canvasToWorld(
canvasTextBoxCoords
);
const textBoxPosition = viewport.worldToCanvas(
annotation.data.handles.textBox.worldPosition
);
const textBoxUID = '1';
const textBoxOptions = this.getLinkedTextBoxStyle(
styleSpecifier,
annotation
);
const boundingBox = drawing.drawLinkedTextBox(
svgDrawingHelper,
annotationUID,
textBoxUID,
textLines,
textBoxPosition,
canvasCoordinates,
{},
{
...textBoxOptions,
color,
}
);
const { x: left, y: top, width, height } = boundingBox;
annotation.data.handles.textBox.worldBoundingBox = {
topLeft: viewport.canvasToWorld([left, top]),
topRight: viewport.canvasToWorld([left + width, top]),
bottomLeft: viewport.canvasToWorld([left, top + height]),
bottomRight: viewport.canvasToWorld([left + width, top + height]),
};
});
}
};
renderPolyLine(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
// Todo: this needs to use the drawPolyLine from cs3D since it is implemented
// now, before it was implemented with a loop over drawLine which is hacky
let canvasCoordinates;
renderableData.map((data, index) => {
canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
if (canvasCoordinates.length === 2) {
const lineUID = `${index}`;
drawing.drawLine(
svgDrawingHelper,
annotationUID,
lineUID,
canvasCoordinates[0],
canvasCoordinates[1],
{
color: options.color,
width: options.lineWidth,
}
);
} else {
throw new Error('Drawing polyline for SR not yet implemented');
}
});
return canvasCoordinates; // used for drawing textBox
}
renderMultipoint(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
let canvasCoordinates;
renderableData.map((data, index) => {
canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
const handleGroupUID = '0';
drawing.drawHandles(
svgDrawingHelper,
annotationUID,
handleGroupUID,
canvasCoordinates,
{
color: options.color,
}
);
});
}
renderPoint(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
const canvasCoordinates = [];
renderableData.map((data, index) => {
const point = data[0];
// This gives us one point for arrow
canvasCoordinates.push(viewport.worldToCanvas(point));
// We get the other point for the arrow by using the image size
const imagePixelModule = metaData.get(
'imagePixelModule',
referencedImageId
);
let xOffset = 10;
let yOffset = 10;
if (imagePixelModule) {
const { columns, rows } = imagePixelModule;
xOffset = columns / 10;
yOffset = rows / 10;
}
const imagePoint = csUtils.worldToImageCoords(referencedImageId, point);
const arrowEnd = csUtils.imageToWorldCoords(referencedImageId, [
imagePoint[0] + xOffset,
imagePoint[1] + yOffset,
]);
canvasCoordinates.push(viewport.worldToCanvas(arrowEnd));
const arrowUID = `${index}`;
// Todo: handle drawing probe as probe, currently we are drawing it as an arrow
drawing.drawArrow(
svgDrawingHelper,
annotationUID,
arrowUID,
canvasCoordinates[1],
canvasCoordinates[0],
{
color: options.color,
width: options.lineWidth,
}
);
});
return canvasCoordinates; // used for drawing textBox
}
renderEllipse(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
let canvasCoordinates;
renderableData.map((data, index) => {
if (data.length === 0) {
// since oblique ellipse is not supported for hydration right now
// we just return
return;
}
const ellipsePointsWorld = data;
canvasCoordinates = ellipsePointsWorld.map(p =>
viewport.worldToCanvas(p)
);
const canvasCorners = <Array<Types.Point2>>(
utilities.math.ellipse.getCanvasEllipseCorners(canvasCoordinates)
);
const lineUID = `${index}`;
drawing.drawEllipse(
svgDrawingHelper,
annotationUID,
lineUID,
canvasCorners[0],
canvasCorners[1],
{
color: options.color,
width: options.lineWidth,
}
);
});
return canvasCoordinates;
}
}
const SHORT_HAND_MAP = {
'Short Axis': 'W ',
'Long Axis': 'L ',
AREA: 'Area ',
Length: '',
CORNERSTONEFREETEXT: '',
};
function _labelToShorthand(label) {
const shortHand = SHORT_HAND_MAP[label];
if (shortHand !== undefined) {
return shortHand;
}
return label;
}
@@ -0,0 +1,64 @@
import { getEnabledElement } from '@cornerstonejs/core';
const state = {
TrackingUniqueIdentifier: null,
trackingIdentifiersByViewportId: {},
};
/**
* This file is being used to store the per-viewport state of the SR tools,
* Since, all the toolStates are added to the cornerstoneTools, when displaying the SRTools,
* if there are two viewports rendering the same imageId, we don't want to show
* the same SR annotation twice on irrelevant viewport, hence, we are storing the state
* of the SR tools in state here, so that we can filter them later.
*/
function setTrackingUniqueIdentifiersForElement(
element,
trackingUniqueIdentifiers,
activeIndex = 0
) {
const enabledElement = getEnabledElement(element);
const { viewport } = enabledElement;
state.trackingIdentifiersByViewportId[viewport.id] = {
trackingUniqueIdentifiers,
activeIndex,
};
}
function setActiveTrackingUniqueIdentifierForElement(
element,
TrackingUniqueIdentifier
) {
const enabledElement = getEnabledElement(element);
const { viewport } = enabledElement;
const trackingIdentifiersForElement =
state.trackingIdentifiersByViewportId[viewport.id];
if (trackingIdentifiersForElement) {
const activeIndex = trackingIdentifiersForElement.trackingUniqueIdentifiers.findIndex(
tuid => tuid === TrackingUniqueIdentifier
);
trackingIdentifiersForElement.activeIndex = activeIndex;
}
}
function getTrackingUniqueIdentifiersForElement(element) {
const enabledElement = getEnabledElement(element);
const { viewport } = enabledElement;
if (state.trackingIdentifiersByViewportId[viewport.id]) {
return state.trackingIdentifiersByViewportId[viewport.id];
}
return { trackingUniqueIdentifiers: [] };
}
export {
setTrackingUniqueIdentifiersForElement,
setActiveTrackingUniqueIdentifierForElement,
getTrackingUniqueIdentifiersForElement,
};
@@ -0,0 +1,15 @@
import DICOMSRDisplayTool from './DICOMSRDisplayTool';
import SRLengthTool from './tools/SRLength';
import SRBidirectional from './tools/SRBidirectional';
import SREllipticalROI from './tools/SREllipticalROI';
import SRArrowAnnotate from './tools/SRArrowAnnotate';
const toolNames = {
DICOMSRDisplay: DICOMSRDisplayTool.toolName,
SRLength: SRLengthTool.toolName,
SRBidirectional: SRBidirectional.toolName,
SREllipticalROI: SREllipticalROI.toolName,
SRArrowAnnotate: SRArrowAnnotate.toolName,
};
export default toolNames;
@@ -0,0 +1,16 @@
import { ArrowAnnotateTool } from '@cornerstonejs/tools';
/**
* The reason we are extending ArrowAnnotateTool is to create a new tool for SR
* viewport which basically has a different name. This is done since Cornerstone
* has shifted from creating tool instances for each annotation, and we have ArrowAnnotate
* mappers at the MeasurementService, so if we didn't do this, we would be mapping
* the SR annotation to the MeasurementService (since there is a ArrowAnnotateTool mapper),
* but with extending and renaming it, there is not mapper for SRArrowAnnotateTool; hence
* no mapping; hence no new measurement, just temporary ones for the SR viewport.
*/
class SRArrowAnnotateTool extends ArrowAnnotateTool {
static toolName = 'SRArrowAnnotate';
}
export default SRArrowAnnotateTool;
@@ -0,0 +1,16 @@
import { BidirectionalTool } from '@cornerstonejs/tools';
/**
* The reason we are extending BidirectionalTool is to create a new tool for SR
* viewport which basically has a different name. This is done since Cornerstone
* has shifted from creating tool instances for each annotation, and we have Bidirectional
* mappers at the MeasurementService, so if we didn't do this, we would be mapping
* the SR annotation to the MeasurementService (since there is a BidirectionalTool mapper),
* but with extending and renaming it, there is not mapper for SRBidirectionalTool; hence
* no mapping; hence no new measurement, just temporary ones for the SR viewport.
*/
class SRBidirectional extends BidirectionalTool {
static toolName = 'SRBidirectional';
}
export default SRBidirectional;
@@ -0,0 +1,16 @@
import { EllipticalROITool } from '@cornerstonejs/tools';
/**
* The reason we are extending EllipticalROITool is to create a new tool for SR
* viewport which basically has a different name. This is done since Cornerstone
* has shifted from creating tool instances for each annotation, and we have EllipticalROI
* mappers at the MeasurementService, so if we didn't do this, we would be mapping
* the SR annotation to the MeasurementService (since there is a EllipticalROITool mapper),
* but with extending and renaming it, there is not mapper for SREllipticalROITool; hence
* no mapping; hence no new measurement, just temporary ones for the SR viewport.
*/
class SREllipticalROI extends EllipticalROITool {
static toolName = 'SREllipticalROI';
}
export default SREllipticalROI;
@@ -0,0 +1,16 @@
import { LengthTool } from '@cornerstonejs/tools';
/**
* The reason we are extending LengthTool is to create a new tool for SR
* viewport which basically has a different name. This is done since Cornerstone
* has shifted from creating tool instances for each annotation, and we have Length
* mappers at the MeasurementService, so if we didn't do this, we would be mapping
* the SR annotation to the MeasurementService (since there is a LengthTool mapper),
* but with extending and renaming it, there is not mapper for SRLengthTool; hence
* no mapping; hence no new measurement, just temporary ones for the SR viewport.
*/
class SRLengthTool extends LengthTool {
static toolName = 'SRLength';
}
export default SRLengthTool;
@@ -0,0 +1,244 @@
import { vec3 } from 'gl-matrix';
import { Types, annotation } from '@cornerstonejs/tools';
import { metaData, utilities, Types as csTypes } from '@cornerstonejs/core';
import toolNames from '../tools/toolNames';
import SCOORD_TYPES from '../constants/scoordTypes';
const EPSILON = 1e-4;
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
export default function addMeasurement(
measurement,
imageId,
displaySetInstanceUID
) {
// TODO -> Render rotated ellipse .
const toolName = toolNames.DICOMSRDisplay;
const measurementData = {
TrackingUniqueIdentifier: measurement.TrackingUniqueIdentifier,
renderableData: {},
labels: measurement.labels,
imageId,
};
measurement.coords.forEach(coord => {
const { GraphicType, GraphicData } = coord;
if (measurementData.renderableData[GraphicType] === undefined) {
measurementData.renderableData[GraphicType] = [];
}
measurementData.renderableData[GraphicType].push(
_getRenderableData(
GraphicType,
GraphicData,
imageId,
measurement.TrackingIdentifier
)
);
});
// Use the metadata provider to grab its imagePlaneModule metadata
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
const annotationManager = annotation.state.getDefaultAnnotationManager();
const SRAnnotation: Types.Annotation = {
annotationUID: measurement.TrackingUniqueIdentifier,
metadata: {
FrameOfReferenceUID: imagePlaneModule.frameOfReferenceUID,
toolName: toolName,
referencedImageId: imageId,
},
data: {
label: measurement.labels,
handles: {
textBox: {},
},
cachedStats: {
TrackingUniqueIdentifier: measurementData.TrackingUniqueIdentifier,
renderableData: measurementData.renderableData,
},
},
};
annotationManager.addAnnotation(SRAnnotation);
measurement.loaded = true;
measurement.imageId = imageId;
measurement.displaySetInstanceUID = displaySetInstanceUID;
// Remove the unneeded coord now its processed, but keep the SOPInstanceUID.
// NOTE: We assume that each SCOORD in the MeasurementGroup maps onto one frame,
// It'd be super weird if it didn't anyway as a SCOORD.
measurement.ReferencedSOPInstanceUID =
measurement.coords[0].ReferencedSOPSequence.ReferencedSOPInstanceUID;
delete measurement.coords;
}
function _getRenderableData(
GraphicType,
GraphicData,
imageId,
TrackingIdentifier
) {
const [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
let renderableData: csTypes.Point3[];
switch (GraphicType) {
case SCOORD_TYPES.POINT:
case SCOORD_TYPES.MULTIPOINT:
case SCOORD_TYPES.POLYLINE:
renderableData = [];
for (let i = 0; i < GraphicData.length; i += 2) {
const worldPos = utilities.imageToWorldCoords(imageId, [
GraphicData[i],
GraphicData[i + 1],
]);
renderableData.push(worldPos);
}
break;
case SCOORD_TYPES.CIRCLE: {
const pointsWorld = [];
for (let i = 0; i < GraphicData.length; i += 2) {
const worldPos = utilities.imageToWorldCoords(imageId, [
GraphicData[i],
GraphicData[i + 1],
]);
pointsWorld.push(worldPos);
}
// We do not have an explicit draw circle svg helper in Cornerstone3D at
// this time, but we can use the ellipse svg helper to draw a circle, so
// here we reshape the data for that purpose.
const center = pointsWorld[0];
const onPerimeter = pointsWorld[1];
const radius = vec3.distance(center, onPerimeter);
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
if (!imagePlaneModule) {
throw new Error('No imagePlaneModule found');
}
const {
columnCosines,
rowCosines,
}: {
columnCosines: csTypes.Point3;
rowCosines: csTypes.Point3;
} = imagePlaneModule;
// we need to get major/minor axis (which are both the same size major = minor)
// first axisStart
const firstAxisStart = vec3.create();
vec3.scaleAndAdd(firstAxisStart, center, columnCosines, radius);
const firstAxisEnd = vec3.create();
vec3.scaleAndAdd(firstAxisEnd, center, columnCosines, -radius);
// second axisStart
const secondAxisStart = vec3.create();
vec3.scaleAndAdd(secondAxisStart, center, rowCosines, radius);
const secondAxisEnd = vec3.create();
vec3.scaleAndAdd(secondAxisEnd, center, rowCosines, -radius);
renderableData = [
firstAxisStart as csTypes.Point3,
firstAxisEnd as csTypes.Point3,
secondAxisStart as csTypes.Point3,
secondAxisEnd as csTypes.Point3,
];
break;
}
case SCOORD_TYPES.ELLIPSE: {
// GraphicData is ordered as [majorAxisStartX, majorAxisStartY, majorAxisEndX, majorAxisEndY, minorAxisStartX, minorAxisStartY, minorAxisEndX, minorAxisEndY]
// But Cornerstone3D points are ordered as top, bottom, left, right for the
// ellipse so we need to identify if the majorAxis is horizontal or vertical
// and then choose the correct points to use for the ellipse.
const pointsWorld: csTypes.Point3[] = [];
for (let i = 0; i < GraphicData.length; i += 2) {
const worldPos = utilities.imageToWorldCoords(imageId, [
GraphicData[i],
GraphicData[i + 1],
]);
pointsWorld.push(worldPos);
}
const majorAxisStart = vec3.fromValues(...pointsWorld[0]);
const majorAxisEnd = vec3.fromValues(...pointsWorld[1]);
const minorAxisStart = vec3.fromValues(...pointsWorld[2]);
const minorAxisEnd = vec3.fromValues(...pointsWorld[3]);
const majorAxisVec = vec3.create();
vec3.sub(majorAxisVec, majorAxisEnd, majorAxisStart);
// normalize majorAxisVec to avoid scaling issues
vec3.normalize(majorAxisVec, majorAxisVec);
const minorAxisVec = vec3.create();
vec3.sub(minorAxisVec, minorAxisEnd, minorAxisStart);
vec3.normalize(minorAxisVec, minorAxisVec);
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
if (!imagePlaneModule) {
throw new Error('imageId does not have imagePlaneModule metadata');
}
const {
columnCosines,
}: { columnCosines: csTypes.Point3 } = imagePlaneModule;
// find which axis is parallel to the columnCosines
const columnCosinesVec = vec3.fromValues(...columnCosines);
const projectedMajorAxisOnColVec = Math.abs(
vec3.dot(columnCosinesVec, majorAxisVec)
);
const projectedMinorAxisOnColVec = Math.abs(
vec3.dot(columnCosinesVec, minorAxisVec)
);
const absoluteOfMajorDotProduct = Math.abs(projectedMajorAxisOnColVec);
const absoluteOfMinorDotProduct = Math.abs(projectedMinorAxisOnColVec);
renderableData = [];
if (Math.abs(absoluteOfMajorDotProduct - 1) < EPSILON) {
renderableData = [
pointsWorld[0],
pointsWorld[1],
pointsWorld[2],
pointsWorld[3],
];
} else if (Math.abs(absoluteOfMinorDotProduct - 1) < EPSILON) {
renderableData = [
pointsWorld[2],
pointsWorld[3],
pointsWorld[0],
pointsWorld[1],
];
} else {
console.warn('OBLIQUE ELLIPSE NOT YET SUPPORTED');
}
break;
}
default:
console.warn('Unsupported GraphicType:', GraphicType);
}
return renderableData;
}
@@ -0,0 +1,25 @@
/**
* Should Find the requested instance metadata into the displaySets and return
*
* @param {Array} displaySets - List of displaySets
* @param {string} SOPInstanceUID - sopInstanceUID to look for
* @returns {Object} - instance metadata found
*/
const findInstanceMetadataBySopInstanceUID = (displaySets, SOPInstanceUID) => {
let instanceFound;
displaySets.find(displaySet => {
if (!displaySet.images) return false;
instanceFound = displaySet.images.find(
instanceMetadata =>
instanceMetadata.getSOPInstanceUID() === SOPInstanceUID
);
return !!instanceFound;
});
return instanceFound;
};
export default findInstanceMetadataBySopInstanceUID;
@@ -0,0 +1,67 @@
/**
* Should find the most recent Structured Report metadata
*
* @param {Array} studies
* @returns {Object} Series
*/
const findMostRecentStructuredReport = studies => {
let mostRecentStructuredReport;
studies.forEach(study => {
const allSeries = study.getSeries ? study.getSeries() : [];
allSeries.forEach(series => {
// Skip series that may not have instances yet
// This can happen if we have retrieved just the initial
// details about the series via QIDO-RS, but not the full metadata
if (!series.instances.length) {
return;
}
if (isStructuredReportSeries(series)) {
if (
!mostRecentStructuredReport ||
compareSeriesDate(series, mostRecentStructuredReport)
) {
mostRecentStructuredReport = series;
}
}
});
});
return mostRecentStructuredReport;
};
/**
* Checks if series sopClassUID matches with the supported Structured Reports sopClassUID
*
* @param {Object} series - Series metadata
* @returns {boolean}
*/
const isStructuredReportSeries = series => {
const supportedSopClassUIDs = [
'1.2.840.10008.5.1.4.1.1.88.22',
'1.2.840.10008.5.1.4.1.1.11.1',
];
const firstInstance = series.getFirstInstance();
const SOPClassUID = firstInstance.getData().metadata.SOPClassUID;
return supportedSopClassUIDs.includes(SOPClassUID);
};
/**
* Checkes if series1 is newer than series2
*
* @param {Object} series1 - Series Metadata 1
* @param {Object} series2 - Series Metadata 2
* @returns {boolean} true/false if series1 is newer than series2
*/
const compareSeriesDate = (series1, series2) => {
return (
series1._data.SeriesDate > series2._data.SeriesDate ||
(series1._data.SeriesDate === series2._data.SeriesDate &&
series1._data.SeriesTime > series2._data.SeriesTime)
);
};
export default findMostRecentStructuredReport;
@@ -0,0 +1,19 @@
/**
* Retrieve a list of all displaySets of all studies
*
* @param {Object} studies - List of studies loaded into the viewer
* @returns {Object} List of DisplaySets
*/
const getAllDisplaySets = studies => {
let allDisplaySets = [];
studies.forEach(study => {
if (study.getDisplaySets) {
allDisplaySets = allDisplaySets.concat(study.getDisplaySets());
}
});
return allDisplaySets;
};
export default getAllDisplaySets;
@@ -0,0 +1,110 @@
import OHIF from '@ohif/core';
import { annotation } from '@cornerstonejs/tools';
const { log } = OHIF;
function getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
) {
const filteredToolState = {};
function addToFilteredToolState(annotation, toolType) {
if (!annotation.metadata?.referencedImageId) {
log.warn(
`[DICOMSR] No referencedImageId found for ${toolType} ${annotation.id}`
);
return;
}
const imageId = annotation.metadata.referencedImageId;
if (!filteredToolState[imageId]) {
filteredToolState[imageId] = {};
}
const imageIdSpecificToolState = filteredToolState[imageId];
if (!imageIdSpecificToolState[toolType]) {
imageIdSpecificToolState[toolType] = {
data: [],
};
}
const measurementDataI = measurementData.find(
md => md.uid === annotation.annotationUID
);
const toolData = imageIdSpecificToolState[toolType].data;
let finding;
const findingSites = [];
// NOTE -> We use the CORNERSTONEJS coding schemeDesignator which we have
// defined in the dcmjs adapters
if (measurementDataI.label) {
if (additionalFindingTypes.includes(toolType)) {
finding = {
CodeValue: 'CORNERSTONEFREETEXT',
CodingSchemeDesignator: 'CORNERSTONEJS',
CodeMeaning: measurementDataI.label,
};
} else {
findingSites.push({
CodeValue: 'CORNERSTONEFREETEXT',
CodingSchemeDesignator: 'CORNERSTONEJS',
CodeMeaning: measurementDataI.label,
});
}
}
const measurement = Object.assign({}, annotation, {
finding,
findingSites,
});
toolData.push(measurement);
}
const uidFilter = measurementData.map(md => md.uid);
const uids = uidFilter.slice();
const annotationManager = annotation.state.getDefaultAnnotationManager();
const framesOfReference = annotationManager.getFramesOfReference();
for (let i = 0; i < framesOfReference.length; i++) {
const frameOfReference = framesOfReference[i];
const frameOfReferenceAnnotations = annotationManager.getFrameOfReferenceAnnotations(
frameOfReference
);
const toolTypes = Object.keys(frameOfReferenceAnnotations);
for (let j = 0; j < toolTypes.length; j++) {
const toolType = toolTypes[j];
const annotations = frameOfReferenceAnnotations[toolType];
if (annotations) {
for (let k = 0; k < annotations.length; k++) {
const annotation = annotations[k];
const uidIndex = uids.findIndex(
uid => uid === annotation.annotationUID
);
if (uidIndex !== -1) {
addToFilteredToolState(annotation, toolType);
uids.splice(uidIndex, 1);
if (!uids.length) {
return filteredToolState;
}
}
}
}
}
}
return filteredToolState;
}
export default getFilteredCornerstoneToolState;
@@ -0,0 +1,60 @@
import { adapters } from 'dcmjs';
const cornerstoneAdapters = adapters.Cornerstone3D;
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
const CORNERSTONE_3D_TAG = cornerstoneAdapters.CORNERSTONE_3D_TAG;
/**
* Checks if the given `displaySet`can be rehydrated into the `MeasurementService`.
*
* @param {object} displaySet The SR `displaySet` to check.
* @param {object[]} mappings The CornerstoneTools 4 mappings to the `MeasurementService`.
* @returns {boolean} True if the SR can be rehydrated into the `MeasurementService`.
*/
export default function isRehydratable(displaySet, mappings) {
if (!mappings || !mappings.length) {
return false;
}
const mappingDefinitions = mappings.map(m => m.annotationType);
const { measurements } = displaySet;
const adapterKeys = Object.keys(cornerstoneAdapters).filter(
adapterKey =>
typeof cornerstoneAdapters[adapterKey]
.isValidCornerstoneTrackingIdentifier === 'function'
);
const adapters = [];
adapterKeys.forEach(key => {
if (mappingDefinitions.includes(key)) {
// Must have both a dcmjs adapter and a MeasurementService
// Definition in order to be a candidate for import.
adapters.push(cornerstoneAdapters[key]);
}
});
for (let i = 0; i < measurements.length; i++) {
const TrackingIdentifier = measurements[i].TrackingIdentifier;
const hydratable = adapters.some(adapter => {
let [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
if (supportedLegacyCornerstoneTags.includes(cornerstoneTag)) {
cornerstoneTag = CORNERSTONE_3D_TAG;
}
const mappedTrackingIdentifier = `${cornerstoneTag}:${toolName}`;
return adapter.isValidCornerstoneTrackingIdentifier(
mappedTrackingIdentifier
);
});
if (hydratable) {
return true;
}
}
return false;
}
@@ -0,0 +1,14 @@
import dcmjs from 'dcmjs';
/**
* Checks if dcmjs has support to determined tool
*
* @param {string} toolName
* @returns {boolean}
*/
const isToolSupported = toolName => {
const adapter = dcmjs.adapters.Cornerstone3D;
return !!adapter[toolName];
};
export default isToolSupported;
@@ -0,0 +1,452 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import OHIF, { utils } from '@ohif/core';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import {
Notification,
ViewportActionBar,
useViewportGrid,
useViewportDialog,
} from '@ohif/ui';
const { formatDate } = utils;
const MEASUREMENT_TRACKING_EXTENSION_ID =
'@ohif/extension-measurement-tracking';
const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup';
function OHIFCornerstoneSRViewport(props) {
const {
children,
dataSource,
displaySets,
viewportIndex,
viewportLabel,
servicesManager,
extensionManager,
} = props;
const {
DisplaySetService,
CornerstoneViewportService,
} = servicesManager.services;
// SR viewport will always have a single display set
if (displaySets.length > 1) {
throw new Error('SR viewport should only have a single display set');
}
const srDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
const [viewportDialogState, viewportDialogApi] = useViewportDialog();
const [measurementSelected, setMeasurementSelected] = useState(0);
const [measurementCount, setMeasurementCount] = useState(1);
const [activeImageDisplaySetData, setActiveImageDisplaySetData] = useState(
null
);
const [
referencedDisplaySetMetadata,
setReferencedDisplaySetMetadata,
] = useState(null);
const [isHydrated, setIsHydrated] = useState(srDisplaySet.isHydrated);
const [element, setElement] = useState(null);
const { viewports, activeViewportIndex } = viewportGrid;
// Optional hook into tracking extension, if present.
let trackedMeasurements;
let sendTrackedMeasurementsEvent;
const hasMeasurementTrackingExtension = extensionManager.registeredExtensionIds.includes(
MEASUREMENT_TRACKING_EXTENSION_ID
);
// TODO: this is a hook that fails if we register/de-register
if (hasMeasurementTrackingExtension) {
const contextModule = extensionManager.getModuleEntry(
'@ohif/extension-measurement-tracking.contextModule.TrackedMeasurementsContext'
);
const useTrackedMeasurements = () => useContext(contextModule.context);
[
trackedMeasurements,
sendTrackedMeasurementsEvent,
] = useTrackedMeasurements();
}
/**
* Store the tracking identifiers per viewport in order to be able to
* show the SR measurements on the referenced image on the correct viewport,
* when multiple viewports are used.
*/
const setTrackingIdentifiers = useCallback(
measurementSelected => {
const { measurements } = srDisplaySet;
setTrackingUniqueIdentifiersForElement(
element,
measurements.map(measurement => measurement.TrackingUniqueIdentifier),
measurementSelected
);
},
[element, measurementSelected, srDisplaySet]
);
/**
* OnElementEnabled callback which is called after the cornerstoneExtension
* has enabled the element. Note: we delegate all the image rendering to
* cornerstoneExtension, so we don't need to do anything here regarding
* the image rendering, element enabling etc.
*/
const onElementEnabled = evt => {
setElement(evt.detail.element);
};
const updateViewport = useCallback(
newMeasurementSelected => {
const {
StudyInstanceUID,
displaySetInstanceUID,
sopClassUids,
} = srDisplaySet;
if (!StudyInstanceUID || !displaySetInstanceUID) {
return;
}
if (sopClassUids && sopClassUids.length > 1) {
// Todo: what happens if there are multiple SOP Classes? Why we are
// not throwing an error?
console.warn(
'More than one SOPClassUID in the same series is not yet supported.'
);
}
_getViewportReferencedDisplaySetData(
srDisplaySet,
newMeasurementSelected,
DisplaySetService
).then(({ referencedDisplaySet, referencedDisplaySetMetadata }) => {
setMeasurementSelected(newMeasurementSelected);
setActiveImageDisplaySetData(referencedDisplaySet);
setReferencedDisplaySetMetadata(referencedDisplaySetMetadata);
if (
referencedDisplaySet.displaySetInstanceUID ===
activeImageDisplaySetData?.displaySetInstanceUID
) {
const { measurements } = srDisplaySet;
// it means that we have a new referenced display set, and the
// imageIdIndex will handle it by updating the viewport, but if they
// are the same we just need to use MeasurementService to jump to the
// new measurement
const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
const csViewport = CornerstoneViewportService.getCornerstoneViewport(
viewportInfo.getViewportId()
);
const imageIds = csViewport.getImageIds();
const imageIdIndex = imageIds.indexOf(
measurements[newMeasurementSelected].imageId
);
if (imageIdIndex !== -1) {
csViewport.setImageIdIndex(imageIdIndex);
}
}
});
},
[dataSource, srDisplaySet, activeImageDisplaySetData, viewportIndex]
);
const getCornerstoneViewport = useCallback(() => {
if (!activeImageDisplaySetData) {
return null;
}
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
const { measurements } = srDisplaySet;
const measurement = measurements[measurementSelected];
if (!measurement) {
return null;
}
const initialImageIndex = activeImageDisplaySetData.images.findIndex(
image => image.imageId === measurement.imageId
);
return (
<Component
{...props}
// should be passed second since we don't want SR displaySet to
// override the activeImageDisplaySetData
displaySets={[activeImageDisplaySetData]}
viewportOptions={{
toolGroupId: `${SR_TOOLGROUP_BASE_NAME}`,
}}
onElementEnabled={onElementEnabled}
initialImageIndex={initialImageIndex}
></Component>
);
}, [activeImageDisplaySetData, viewportIndex, measurementSelected]);
const onMeasurementChange = useCallback(
direction => {
let newMeasurementSelected = measurementSelected;
if (direction === 'right') {
newMeasurementSelected++;
if (newMeasurementSelected >= measurementCount) {
newMeasurementSelected = 0;
}
} else {
newMeasurementSelected--;
if (newMeasurementSelected < 0) {
newMeasurementSelected = measurementCount - 1;
}
}
setTrackingIdentifiers(newMeasurementSelected);
updateViewport(newMeasurementSelected);
},
[
measurementSelected,
measurementCount,
updateViewport,
setTrackingIdentifiers,
]
);
/**
Cleanup the SR viewport when the viewport is destroyed
*/
useEffect(() => {
const onDisplaySetsRemovedSubscription = DisplaySetService.subscribe(
DisplaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports[activeViewportIndex];
if (
displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)
) {
viewportGridService.setDisplaySetsForViewport({
viewportIndex: activeViewportIndex,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, []);
/**
* Loading the measurements from the SR viewport, which goes through the
* isHydratable check, the outcome for the isHydrated state here is always FALSE
* since we don't do the hydration here. Todo: can't we just set it as false? why
* we are changing the state here? isHydrated is always false at this stage, and
* if it is hydrated we don't event use the SR viewport.
*/
useEffect(() => {
if (!srDisplaySet.isLoaded) {
srDisplaySet.load();
}
setIsHydrated(srDisplaySet.isHydrated);
const numMeasurements = srDisplaySet.measurements.length;
setMeasurementCount(numMeasurements);
}, [srDisplaySet]);
/**
* Hook to update the tracking identifiers when the selected measurement changes or
* the element changes
*/
useEffect(() => {
if (!element || !srDisplaySet.isLoaded) {
return;
}
setTrackingIdentifiers(measurementSelected);
}, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]);
/**
* Todo: what is this, not sure what it does regarding the react aspect,
* it is updating a local variable? which is not state.
*/
let isLocked = trackedMeasurements?.context?.trackedSeries?.length > 0;
useEffect(() => {
isLocked = trackedMeasurements?.context?.trackedSeries?.length > 0;
}, [trackedMeasurements]);
/**
* Data fetching for the SR displaySet, which updates the measurements and
* also gets the referenced image displaySet that SR is based on.
*/
useEffect(() => {
updateViewport(measurementSelected);
}, [dataSource, srDisplaySet]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
if (!activeImageDisplaySetData || !referencedDisplaySetMetadata) {
return null;
}
if (children && children.length) {
childrenWithProps = children.map((child, index) => {
return (
child &&
React.cloneElement(child, {
viewportIndex,
key: index,
})
);
});
}
const { Modality } = srDisplaySet;
const {
PatientID,
PatientName,
PatientSex,
PatientAge,
SliceThickness,
ManufacturerModelName,
StudyDate,
SeriesDescription,
SpacingBetweenSlices,
SeriesNumber,
} = referencedDisplaySetMetadata;
// TODO -> disabled double click for now: onDoubleClick={_onDoubleClick}
return (
<>
<ViewportActionBar
onDoubleClick={evt => {
evt.stopPropagation();
evt.preventDefault();
}}
onPillClick={() => {
sendTrackedMeasurementsEvent('RESTORE_PROMPT_HYDRATE_SR', {
displaySetInstanceUID: srDisplaySet.displaySetInstanceUID,
viewportIndex,
});
}}
onSeriesChange={onMeasurementChange}
studyData={{
label: viewportLabel,
useAltStyling: true,
isTracked: false,
isLocked,
isRehydratable: srDisplaySet.isRehydratable,
isHydrated,
studyDate: formatDate(StudyDate),
currentSeries: SeriesNumber,
seriesDescription: SeriesDescription,
modality: Modality,
patientInformation: {
patientName: PatientName
? OHIF.utils.formatPN(PatientName.Alphabetic)
: '',
patientSex: PatientSex || '',
patientAge: PatientAge || '',
MRN: PatientID || '',
thickness: SliceThickness ? `${SliceThickness.toFixed(2)}mm` : '',
spacing:
SpacingBetweenSlices !== undefined
? `${SpacingBetweenSlices.toFixed(2)}mm`
: '',
scanner: ManufacturerModelName || '',
},
}}
/>
<div className="relative flex flex-row w-full h-full overflow-hidden">
{getCornerstoneViewport()}
<div className="absolute w-full">
{viewportDialogState.viewportIndex === viewportIndex && (
<Notification
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
onOutsideClick={viewportDialogState.onOutsideClick}
/>
)}
</div>
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstoneSRViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportIndex: PropTypes.number.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
customProps: PropTypes.object,
};
OHIFCornerstoneSRViewport.defaultProps = {
customProps: {},
};
async function _getViewportReferencedDisplaySetData(
displaySet,
measurementSelected,
DisplaySetService
) {
const { measurements } = displaySet;
const measurement = measurements[measurementSelected];
const { displaySetInstanceUID } = measurement;
const referencedDisplaySet = DisplaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
PatientID: image0.PatientID,
PatientName: image0.PatientName,
PatientSex: image0.PatientSex,
PatientAge: image0.PatientAge,
SliceThickness: image0.SliceThickness,
StudyDate: image0.StudyDate,
SeriesDescription: image0.SeriesDescription,
SeriesInstanceUID: image0.SeriesInstanceUID,
SeriesNumber: image0.SeriesNumber,
ManufacturerModelName: image0.ManufacturerModelName,
SpacingBetweenSlices: image0.SpacingBetweenSlices,
};
return { referencedDisplaySetMetadata, referencedDisplaySet };
}
// function _onDoubleClick() {
// const cancelActiveManipulatorsForElement = cornerstoneTools.getModule(
// 'manipulatorState'
// ).setters.cancelActiveManipulatorsForElement;
// const enabledElements = cornerstoneTools.store.state.enabledElements;
// enabledElements.forEach(element => {
// cancelActiveManipulatorsForElement(element);
// });
// }
export default OHIFCornerstoneSRViewport;