init
This commit is contained in:
104
extensions/dicom-microscopy/.gitignore
vendored
Normal file
104
extensions/dicom-microscopy/.gitignore
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
11
extensions/dicom-microscopy/.prettierrc
Normal file
11
extensions/dicom-microscopy/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"proseWrap": "always",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
12
extensions/dicom-microscopy/.webpack/webpack.dev.js
Normal file
12
extensions/dicom-microscopy/.webpack/webpack.dev.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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');
|
||||
|
||||
const ENTRY = {
|
||||
app: `${SRC_DIR}/index.tsx`,
|
||||
};
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
};
|
||||
60
extensions/dicom-microscopy/.webpack/webpack.prod.js
Normal file
60
extensions/dicom-microscopy/.webpack/webpack.prod.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const webpack = require('webpack');
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
const ROOT_DIR = path.join(__dirname, '../');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
const ENTRY = {
|
||||
app: `${SRC_DIR}/index.tsx`,
|
||||
};
|
||||
|
||||
const outputName = `ohif-${pkg.name.split('/').pop()}`;
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
|
||||
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: 'ohif-extension-dicom-microscopy',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [
|
||||
/\b(vtk.js)/,
|
||||
/\b(dcmjs)/,
|
||||
/\b(gl-matrix)/,
|
||||
/^@ohif/,
|
||||
/^@cornerstonejs/,
|
||||
],
|
||||
plugins: [
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
}),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: `./dist/${outputName}.css`,
|
||||
chunkFilename: `./dist/${outputName}.css`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
2158
extensions/dicom-microscopy/CHANGELOG.md
Normal file
2158
extensions/dicom-microscopy/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
9
extensions/dicom-microscopy/LICENSE
Normal file
9
extensions/dicom-microscopy/LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 dicom-microscopy (26860200+md-prog@users.noreply.github.com)
|
||||
|
||||
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.
|
||||
11
extensions/dicom-microscopy/README.md
Normal file
11
extensions/dicom-microscopy/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OHIF extension for microscopy
|
||||
Adapter for *DICOM Microscopy Viewer* to get it integrated into OHIF Viewer.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [DICOM Microscopy Viewer](https://github.com/ImagingDataCommons/dicom-microscopy-viewer) is a Vanilla JS library for web-based visualization of DICOM VL Whole Slide Microscopy Image datasets and derived information.
|
||||
- [SLIM Viewer](https://github.com/imagingdatacommons/slim) is a single-page application for interactive visualization and annotation of digital whole slide microscopy images and derived image analysis results in standard DICOM format. The application is based on the dicom-microscopy-viewer JavaScript library and runs fully client side without any custom server components.
|
||||
|
||||
|
||||
## License
|
||||
MIT
|
||||
44
extensions/dicom-microscopy/babel.config.js
Normal file
44
extensions/dicom-microscopy/babel.config.js
Normal file
@@ -0,0 +1,44 @@
|
||||
module.exports = {
|
||||
plugins: ['@babel/plugin-proposal-class-properties'],
|
||||
env: {
|
||||
test: {
|
||||
presets: [
|
||||
[
|
||||
// TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2
|
||||
'@babel/preset-env',
|
||||
{
|
||||
modules: 'commonjs',
|
||||
debug: false,
|
||||
},
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
'@babel/preset-react',
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-proposal-object-rest-spread',
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'@babel/plugin-transform-regenerator',
|
||||
'@babel/plugin-transform-runtime',
|
||||
],
|
||||
},
|
||||
production: {
|
||||
presets: [
|
||||
// WebPack handles ES6 --> Target Syntax
|
||||
['@babel/preset-env', { modules: false }],
|
||||
'@babel/preset-react',
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
|
||||
},
|
||||
development: {
|
||||
presets: [
|
||||
// WebPack handles ES6 --> Target Syntax
|
||||
['@babel/preset-env', { modules: false }],
|
||||
'@babel/preset-react',
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
plugins: ['react-refresh/babel'],
|
||||
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
|
||||
},
|
||||
},
|
||||
};
|
||||
52
extensions/dicom-microscopy/package.json
Normal file
52
extensions/dicom-microscopy/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@ohif/extension-dicom-microscopy",
|
||||
"version": "3.9.1",
|
||||
"description": "OHIF extension for DICOM microscopy",
|
||||
"author": "Bill Wallace, md-prog",
|
||||
"license": "MIT",
|
||||
"main": "dist/ohif-extension-dicom-microscopy.umd.js",
|
||||
"files": [
|
||||
"dist/**",
|
||||
"public/**",
|
||||
"README.md"
|
||||
],
|
||||
"repository": "OHIF/Viewers",
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
],
|
||||
"module": "src/index.tsx",
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"dev:dicom-pdf": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "yarn run build",
|
||||
"start": "yarn run dev"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "3.9.1",
|
||||
"@ohif/extension-default": "3.9.1",
|
||||
"@ohif/i18n": "3.9.1",
|
||||
"@ohif/ui": "3.9.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^10.11.0",
|
||||
"react-router": "^6.23.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/codec-charls": "^1.2.3",
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "^1.2.4",
|
||||
"colormap": "^2.3",
|
||||
"mathjs": "^12.4.2"
|
||||
}
|
||||
}
|
||||
@@ -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