Merge pull request #4 from OHIF/v3-stable

V3 stable
This commit is contained in:
Salim Kanoun 2023-05-18 17:35:16 +02:00 committed by GitHub
commit bedc379e97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
369 changed files with 21400 additions and 12314 deletions

View File

@ -2,7 +2,7 @@ version: '3.5'
services:
orthanc:
image: jodogne/orthanc-plugins:1.5.7
image: jodogne/orthanc-plugins:1.11.0
hostname: orthanc
volumes:
# Config

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: https://giving.massgeneral.org/ohif

View File

@ -1,33 +0,0 @@
---
name: "\U0001F41B Bug report"
about: Create a report to help us improve
title: ''
labels: 'Community: Report :bug:, Awaiting Reproduction, Triage :white_flag:'
assignees: ''
---
> **Before Creating an issue**
>
> - Are you running the latest version?
> - Are you reporting to the correct repository?
> - Did you search existing issues?
## Bug Report
### Describe the Bug
_A clear and concise description of what the bug is._
### What steps can we follow to reproduce the bug?
1. First step
2. Second step
3. ...
```js
Please use code blocks to show formatted errors or code snippets
```
> :warning: Reports we cannot reproduce are at risk of being marked stale and
> closed. The more information you can provide, the more likely we are to look
> into and address your issue.

View File

@ -1,25 +0,0 @@
---
name: "\U0001F680 Feature request"
about: Suggest an idea for this project
title: ''
labels: 'Community: Request :hand:, Triage :white_flag:'
assignees: ''
---
> :hand: Many people requests features. Tell us why yours is important to the
> community. How does it add value? Why _this feature_?
>
> Is your request very specific to your needs? Consider
> [contributing it](https://docs.ohif.org/contributing.html) yourself! Or reach
> out to a community member that offers
> [consulting services](https://docs.ohif.org/help.html#paid--commercial).
## Request
**What feature or change would you like to see made?**
...
**Why should we prioritize this feature?**
...

View File

@ -1,18 +0,0 @@
---
name: "\U0001F917 Support Question"
about: "I have a question \U0001F4AC"
title: ''
labels: 'Community: Question :question:, Triage :white_flag:'
assignees: ''
---
> :hand: We are a small team with limited resources. Your question is much more
> likely to be answered if it is
> [a good question](https://stackoverflow.com/help/how-to-ask)
**Description**
Questions can often be answered by our documentation. Unable to find an answer
in our docs? We'll try to help. In the meantime, if you answer your own
question, please respond with the answer here so that others may benefit as
well. Better yet, open a PR to expand our docs ^\_^

86
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@ -0,0 +1,86 @@
name: 'Bug report'
description: Create a report to help us improve
title: '[Bug] '
labels: ['Community: Report :bug:', 'Awaiting Reproduction']
body:
- type: markdown
attributes:
value: |
👋 Hello, and thank you for contributing to our project! Your support is greatly appreciated.
🔍 Before proceeding, please make sure to read our [Rules of Conduct](https://github.com/OHIF/Viewers/blob/master/CODE_OF_CONDUCT.md) and familiarize yourself with our [development process](https://v3-docs.ohif.org/development/our-process).
❓ If you're here to seek general support or ask a question, we encourage you to visit our [community discussion board](https://community.ohif.org/)
🐞 For bug reports, please complete the following template in as much detail as possible. This will help us reproduce and address the issue efficiently.
🧪 Finally, ensure that you're using the latest version of the software and check if your issue has already been reported to avoid duplicates.
- type: textarea
id: bug_description
attributes:
label: Describe the Bug
description: 'A clear and concise description of what the bug is.'
validations:
required: true
- type: textarea
id: reproduction_steps
attributes:
label: Steps to Reproduce
description: 'Please describe the steps to reproduce the issue.'
placeholder: "1. First step\n2. Second step\n3. ..."
validations:
required: true
- type: textarea
id: current_behavior
attributes:
label: The current behavior
description:
'A clear and concise description of what happens instead of the expected
behavior.'
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: The expected behavior
description:
'A clear and concise description of what you expected to happen.'
validations:
required: true
- type: input
id: os
attributes:
label: 'OS'
description: 'Your operating system.'
placeholder: 'e.g., Windows 10, macOS 10.15.4'
validations:
required: true
- type: input
id: node-version
attributes:
label: 'Node version'
description: 'Your Node.js version.'
placeholder: 'e.g., 16.14.0'
validations:
required: true
- type: input
id: browser
attributes:
label: 'Browser'
description: 'Your browser.'
placeholder: 'e.g., Chrome 83.0.4103.116, Firefox 77.0.1, Safari 13.1.1'
validations:
required: true
- type: markdown
attributes:
value: >
> :warning: Reports we cannot reproduce are at risk of being marked
stale and > closed. The more information you can provide, the more
likely we are to look > into and address your issue.

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 🤗 Support Question
url: https://community.ohif.org/
about: Please use our forum if you have questions or need help.

View File

@ -0,0 +1,36 @@
name: Feature request
description: Create a feature request
labels: ['Community: Request :hand:']
title: '[Feature Request] '
body:
- type: markdown
attributes:
value: |
👋 Hello and thank you for your interest in our project!
🔍 Before you proceed, please read our [Rules of Conduct](https://github.com/OHIF/Viewers/blob/master/CODE_OF_CONDUCT.md).
🚀 If your request is specific to your needs, consider contributing it yourself! Read our [contributing guides](https://v3-docs.ohif.org/development/contributing) to get started.
🖊️ Please provide as much detail as possible for your feature request. Mock-up screenshots, workflow or logic flow diagrams are very helpful. Discuss how your requested feature would interact with existing features.
⏱️ Lastly, tell us why we should prioritize your feature. What impact would it have?
- type: textarea
attributes:
label: 'What feature or change would you like to see made?'
description:
'Please include as much detail as possible including possibly mock up
screen shots, workflow or logic flow diagrams etc.'
placeholder: '...'
validations:
required: true
- type: textarea
attributes:
label: 'Why should we prioritize this feature?'
description:
'Discuss if and how the requested feature interacts with existing
features.'
placeholder: '...'
validations:
required: true

View File

@ -1,14 +1,91 @@
### PR Checklist
- [ ] Brief description of changes
- [ ] Links to any relevant issues
- [ ] Required status checks are passing
- [ ] User cases if changes impact the user's experience
- [ ] `@mention` a maintainer to request a review
<!-- Do Not Delete This! pr_template -->
<!-- Please read our Rules of Conduct: https://github.com/OHIF/Viewers/blob/master/CODE_OF_CONDUCT.md -->
<!-- 🕮 Read our guide about our Contributing Guide here https://v3-docs.ohif.org/development/contributing -->
<!-- :hand: Thank you for starting this amazing contribution! -->
<!--
Links
-->
⚠️⚠️ Please make sure the checklist section below is complete before submitting your PR.
To complete the checklist, add an 'x' to each item: [] -> [x]
(PRs that do not have all the checkboxes marked will not be approved)
-->
### Context
<!--
Provide a clear explanation of the reasoning behind this change, such as:
- A link to the issue being addressed, using the format "Fixes #ISSUE_NUMBER"
- An image showing the issue or problem being addressed (if not already in the issue)
- Error logs or callStacks to help with the understanding of the problem (if not already in the issue)
-->
### Changes & Results
<!--
List all the changes that have been done, such as:
- Add new components
- Remove old components
- Update dependencies
What are the effects of this change?
- Before vs After
- Screenshots / GIFs / Videos
-->
### Testing
<!--
Describe how we can test your changes.
- open a URL
- visit a page
- click on a button
- etc.
-->
### Checklist
#### PR
<!--
https://semantic-release.gitbook.io/semantic-release/#how-does-it-work
Examples:
Please note the letter casing in the provided examples (upper or lower).
- feat(MeasurementService): add ...
- fix(Toolbar): fix ...
- docs(Readme): update ...
- style(Whitespace): fix ...
- refactor(ExtensionManager): ...
- test(HangingProtocol): Add test ...
- chore(git): update ...
- perf(VolumeLoader): ...
You don't need to have each commit within the Pull Request follow the rule,
but the PR title must comply with it, as it will be used as the commit message
after the commits are squashed.
-->
- [] My Pull Request title is descriptive, accurate and follows the
semantic-release format and guidelines.
#### Code
- [] My code has been well-documented (function documentation, inline comments,
etc.)
#### Public Documentation Updates
<!-- https://v3-docs.ohif.org/ -->
- [] The documentation page has been updated as necessary for any public API
additions or removals.
#### Tested Environment
- [] OS: <!--[e.g. Windows 10, macOS 10.15.4]-->
- [] Node version: <!--[e.g. 16.14.0]-->
- [] Browser:
<!--[e.g. Chrome 83.0.4103.116, Firefox 77.0.1, Safari 13.1.1]-->
<!-- prettier-ignore-start -->
[blog]: https://circleci.com/blog/triggering-trusted-ci-jobs-on-untrusted-forks/

14
.github/stale.yml vendored
View File

@ -2,25 +2,19 @@
# https://github.com/apps/stale
#
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 21
daysUntilStale: 180
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
daysUntilClose: 60
# Issues with these labels will never be considered stale
exemptLabels:
- 'Story :raised_hands:'
- 'Bug: Verified :bug:'
- 'Task: CI/Tooling :robot:'
- 'Task: Docs 📖'
- 'Task: Docs :book:'
- 'Task: Refactor :hammer_and_wrench:'
- 'Task: Tests :microscope:'
- 'PR: Awaiting Review 👀'
- 'Triage :white_flag:'
- 'Extension: Discussion'
- 'Announcement 🎉'
- 'IDC:priority'
- 'IDC:candidate'
- 'IDC:collaboration'
- 'Community: Request :hand:'
- 'Community: Report :bug:'
# Label to use when marking an issue as stale
staleLabel: 'Stale :baguette_bread:'
# Comment to post when marking an issue as stale. Set to `false` to disable

View File

@ -25,13 +25,15 @@ function transpileJavaScript(mode) {
// These are packages that are not transpiled to our lowest supported
// JS version (currently ES5). Most of these leverage ES6+ features,
// that we need to transpile to a different syntax.
exclude,
exclude: [/(codecs)/, /(dicomicc)/, exclude],
loader: 'babel-loader',
options: {
// Find babel.config.js in monorepo root
// https://babeljs.io/docs/en/options#rootmode
rootMode: 'upward',
envName: mode,
cacheCompression: false,
cacheDirectory: true,
},
};
}

View File

@ -77,7 +77,11 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
cache: {
type: 'filesystem',
},
module: {
noParse: [/(codec)/, /(dicomicc)/],
rules: [
transpileJavaScriptRule(mode),
loadWebWorkersRule,
@ -89,6 +93,10 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
},
},
cssToJavaScript,
{
test: /\.wasm/,
type: 'asset/resource',
},
], //.concat(vtkRules),
},
resolve: {
@ -103,8 +111,10 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
'@hooks': path.resolve(__dirname, '../platform/viewer/src/hooks'),
'@routes': path.resolve(__dirname, '../platform/viewer/src/routes'),
'@state': path.resolve(__dirname, '../platform/viewer/src/state'),
'cornerstone-wado-image-loader':
'cornerstone-wado-image-loader/dist/dynamic-import/cornerstoneWADOImageLoader.min.js',
'dicom-microscopy-viewer':
'dicom-microscopy-viewer/dist/dynamic-import/dicomMicroscopyViewer.min.js',
'@cornerstonejs/dicom-image-loader':
'@cornerstonejs/dicom-image-loader/dist/dynamic-import/cornerstoneDICOMImageLoader.min.js',
},
// Which directories to search when resolving modules
modules: [

149
DATACITATION.md Normal file
View File

@ -0,0 +1,149 @@
# OHIF public demo data sets
The OHIF Viewer's public demo page, available at https://viewer.ohif.org/, uses publicly anonymized demo datasets.
These datasets were mostly obtained from the [NIH NCI Imaging Data Commons](https://datacommons.cancer.gov/repository/imaging-data-commons)
and [NIH NCI TCIA](https://www.cancerimagingarchive.net/). Before listing the datasets,
we would like to extend a special thank you to all groups who have made their datasets publicly available.
Without them, we would not have been able to create this demo page.
Please find below the list of datasets used on the demo page, along with their respective citations.
## Platforms
### NIH NCI IDC
- Fedorov, A., Longabaugh, W.J., Pot, D., Clunie, D.A., Pieper, S., Aerts, H.J., Homeyer, A., Lewis, R., Akbarzadeh, A., Bontempi, D. and Clifford, W., 2021. NCI imaging data commons. Cancer research, 81(16), p.4188.
### NIH NCI TCIA
- Clark, K., Vendt, B., Smith, K., Freymann, J., Kirby, J., Koppel, P., Moore, S., Phillips, S., Maffitt, D., Pringle, M., Tarbox, L., & Prior, F. (2013). The Cancer Imaging Archive (TCIA): Maintaining and Operating a Public Information Repository. Journal of Digital Imaging, 26(6), 10451057. https://doi.org/10.1007/s10278-013-9622-7
## Datasets
Below you can find the StudyInstanceUID of the studies that are used in the demo page along with their citations.
### 1.3.6.1.4.1.14519.5.2.1.267424821384663813780850856506829388886
Segmentation of Vestibular Schwannoma from Magnetic Resonance Imaging: An Open Annotated Dataset and Baseline Algorithm (Vestibular-Schwannoma-SEG)
- Shapey, J., Kujawa, A., Dorent, R., Wang, G., Bisdas, S., Dimitriadis, A., Grishchuck, D., Paddick, I., Kitchen, N., Bradford, R., Saeed, S., Ourselin, S., & Vercauteren, T. (2021). Segmentation of Vestibular Schwannoma from Magnetic Resonance Imaging: An Open Annotated Dataset and Baseline Algorithm [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/TCIA.9YTJ-5Q73
- Shapey, J., Kujawa, A., Dorent, R., Wang, G., Dimitriadis, A., Grishchuk, D., Paddick, I., Kitchen, N., Bradford, R., Saeed, S. R., Bisdas, S., Ourselin, S., & Vercauteren, T. (2021). Segmentation of vestibular schwannoma from MRI, an open annotated dataset and baseline algorithm. In Scientific Data (Vol. 8, Issue 1). Springer Science and Business Media LLC. https://doi.org/10.1038/s41597-021-01064-w
### 1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463
### 1.3.6.1.4.1.14519.5.2.1.7009.2403.871108593056125491804754960339
ACRIN-NSCLC-FDG-PET (ACRIN 6668)
- Kinahan, P., Muzi, M., Bialecki, B., Herman, B., & Coombs, L. (2019). Data from the ACRIN 6668 Trial NSCLC-FDG-PET (Version 2) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/tcia.2019.30ilqfcl
- Machtay, M., Duan, F., Siegel, B. A., Snyder, B. S., Gorelick, J. J., Reddin, J. S., Munden, R., Johnson, D. W., Wilf, L. H., DeNittis, A., Sherwin, N., Cho, K. H., Kim, S., Videtic, G., Neumann, D. R., Komaki, R., Macapinlac, H., Bradley, J. D., & Alavi, A. (2013). Prediction of Survival by [18F]Fluorodeoxyglucose Positron Emission Tomography in Patients With Locally Advanced NonSmall-Cell Lung Cancer Undergoing Definitive Chemoradiation Therapy: Results of the ACRIN 6668/RTOG 0235 Trial. In Journal of Clinical Oncology (Vol. 31, Issue 30, pp. 38233830). American Society of Clinical Oncology (ASCO). https://doi.org/10.1200/jco.2012.47.5947
### 2.25.103659964951665749659160840573802789777
The Cancer Genome Atlas Glioblastoma Multiforme Collection (TCGA-GBM)
- Scarpace, L., Mikkelsen, T., Cha, S., Rao, S., Tekchandani, S., Gutman, D., Saltz, J. H., Erickson, B. J., Pedano, N., Flanders, A. E., Barnholtz-Sloan, J., Ostrom, Q., Barboriak, D., & Pierce, L. J. (2016). The Cancer Genome Atlas Glioblastoma Multiforme Collection (TCGA-GBM) (Version 4) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/K9/TCIA.2016.RNYFUYE9
### 1.3.6.1.4.1.14519.5.2.1.256467663913010332776401703474716742458
Abdominal or pelvic enhanced CT images within 10 days before surgery of 230 patients with stage II colorectal cancer (StageII-Colorectal-CT)
- Tong T., Li M. (2022) Abdominal or pelvic enhanced CT images within 10 days before surgery of 230 patients with stage II colorectal cancer (StageII-Colorectal-CT) [Dataset]. The Cancer Imaging Archive. DOI: https://doi.org/10.7937/p5k5-tg43
- Li, M., Gong, J., Bao, Y., Huang, D., Peng, J., & Tong, T. (2022). Special issue “The advance of solid tumor research in China”: Prognosis prediction for stage II colorectal cancer by fusing computed tomography radiomics and deeplearning features of primary lesions and peripheral lymph nodes. In International Journal of Cancer. Wiley. https://doi.org/10.1002/ijc.34053
### 1.3.6.1.4.1.14519.5.2.1.3023.4024.215308722288168917637555384485
The Cancer Genome Atlas Sarcoma Collection (TCGA-SARC)
- Roche, C., Bonaccio, E., & Filippini, J. (2016). The Cancer Genome Atlas Sarcoma Collection (TCGA-SARC) (Version 3) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/K9/TCIA.2016.CX6YLSUX
### 1.3.6.1.4.1.14519.5.2.1.4792.2001.105216574054253895819671475627
BREAST-DIAGNOSIS
- Bloch, B. Nicolas, Jain, Ashali, & Jaffe, C. Carl. (2015). BREAST-DIAGNOSIS [Data set]. The Cancer Imaging Archive. http://doi.org/10.7937/K9/TCIA.2015.SDNRQXXR
### 1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785
Multimodality annotated HCC cases with and without advanced imaging segmentation (HCC-TACE-Seg)
- Moawad, A. W., Fuentes, D., Morshid, A., Khalaf, A. M., Elmohr, M. M., Abusaif, A., Hazle, J. D., Kaseb, A. O., Hassan, M., Mahvash, A., Szklaruk, J., Qayyom, A., & Elsayes, K. (2021). Multimodality annotated HCC cases with and without advanced imaging segmentation [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/TCIA.5FNA-0924
- Morshid, A., Elsayes, K. M., Khalaf, A. M., Elmohr, M. M., Yu, J., Kaseb, A. O., Hassan, M., Mahvash, A., Wang, Z., Hazle, J. D., & Fuentes, D. (2019). A Machine Learning Model to Predict Hepatocellular Carcinoma Response to Transcatheter Arterial Chemoembolization. Radiology: Artificial Intelligence, 1(5), e180021. https://doi.org/10.1148/ryai.2019180021
### 1.3.6.1.4.1.14519.5.2.1.1188.2803.137585363493444318569098508293
Ultrasound data of a variety of liver masses (B-mode-and-CEUS-Liver)
- Eisenbrey, J., Lyshchik, A., & Wessner, C. (2021). Ultrasound data of a variety of liver masses [Data set]. The Cancer Imaging Archive. DOI: https://doi.org/10.7937/TCIA.2021.v4z7-tc39
### 1.3.6.1.4.1.32722.99.99.62087908186665265759322018723889952421
NSCLC-Radiomics
- Aerts, H. J. W. L., Wee, L., Rios Velazquez, E., Leijenaar, R. T. H., Parmar, C., Grossmann, P., Carvalho, S., Bussink, J., Monshouwer, R., Haibe-Kains, B., Rietveld, D., Hoebers, F., Rietbergen, M. M., Leemans, C. R., Dekker, A., Quackenbush, J., Gillies, R. J., Lambin, P. (2019). Data From NSCLC-Radiomics (version 4) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/K9/TCIA.2015.PF0M9REI
- Aerts, H. J. W. L., Velazquez, E. R., Leijenaar, R. T. H., Parmar, C., Grossmann, P., Carvalho, S., Bussink, J., Monshouwer, R., Haibe-Kains, B., Rietveld, D., Hoebers, F., Rietbergen, M. M., Leemans, C. R., Dekker, A., Quackenbush, J., Gillies, R. J., Lambin, P. (2014, June 3). Decoding tumour phenotype by noninvasive imaging using a quantitative radiomics approach. Nature Communications. Nature Publishing Group. https://doi.org/10.1038/ncomms5006 (link)
### 1.3.6.1.4.1.14519.5.2.1.3671.4754.298665348758363466150039312520
QIN-PROSTATE-Repeatability
- Fedorov, A; Schwier, M; Clunie, D; Herz, C; Pieper, S; Kikinis, R; Tempany, C; Fennessy, F. (2018). Data From QIN-PROSTATE-Repeatability. The Cancer Imaging Archive. DOI: 10.7937/K9/TCIA.2018.MR1CKGND
- Fedorov A, Vangel MG, Tempany CM, Fennessy FM. Multiparametric Magnetic Resonance Imaging of the Prostate: Repeatability of Volume and Apparent Diffusion Coefficient Quantification. Investigative Radiology. 52, 538546 (2017). DOI: 10.1097/RLI.0000000000000382
- Fedorov, A., Schwier, M., Clunie, D., Herz, C., Pieper, S., Kikinis,R., Tempany, C. & Fennessy, F. An annotated test-retest collection of prostate multiparametric MRI. Scientific Data 5, 180281 (2018). DOI:
### 2.25.141277760791347900862109212450152067508
The Clinical Proteomic Tumor Analysis Consortium Clear Cell Renal Cell Carcinoma Collection (CPTAC-CCRCC)
- National Cancer Institute Clinical Proteomic Tumor Analysis Consortium (CPTAC). (2018). The Clinical Proteomic Tumor Analysis Consortium Clear Cell Renal Cell Carcinoma Collection (CPTAC-CCRCC) (Version 10) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/K9/TCIA.2018.OBLAMN27
- The CPTAC program requests that publications using data from this program include the following statement: “Data used in this publication were generated by the National Cancer Institute Clinical Proteomic Tumor Analysis Consortium (CPTAC).”
### 2.25.275741864483510678566144889372061815320
National Lung Screening Trial
- National Lung Screening Trial Research Team. (2013). Data from the National Lung Screening Trial (NLST) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/TCIA.HMQ8-J677
- National Lung Screening Trial Research Team*; Aberle DR, Adams AM, Berg CD, Black WC, Clapp JD, Fagerstrom RM, Gareen IF, Gatsonis C, Marcus PM, Sicks JD (2011). Reduced Lung-Cancer Mortality with Low-Dose Computed Tomographic Screening. New England Journal of Medicine, 365(5), 395409. https://doi.org/10.1056/nejmoa1102873
### 1.3.6.1.4.1.14519.5.2.1.99.1071.26968527900428638961173806140069
Stony Brook University COVID-19 Positive Cases (COVID-19-NY-SBU)
- Saltz, J., Saltz, M., Prasanna, P., Moffitt, R., Hajagos, J., Bremer, E., Balsamo, J., & Kurc, T. (2021). Stony Brook University COVID-19 Positive Cases [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/TCIA.BBAG-2923
### 2.16.840.1.114362.1.11972228.22789312658.616067305.306.2
https://data.kitware.com/

View File

@ -1,4 +1,6 @@
// https://babeljs.io/docs/en/options#babelrcroots
const { extendDefaultPlugins } = require('svgo');
module.exports = {
babelrcRoots: ['./platform/*', './extensions/*', './modes/*'],
presets: [
@ -7,7 +9,19 @@ module.exports = {
'@babel/preset-typescript',
],
plugins: [
'inline-react-svg',
[
'inline-react-svg',
{
svgo: {
plugins: extendDefaultPlugins([
{
name: 'removeViewBox',
active: false,
},
]),
},
},
],
['@babel/plugin-proposal-class-properties', { loose: true }],
'@babel/plugin-transform-typescript',
['@babel/plugin-proposal-private-property-in-object', { loose: true }],

View File

@ -0,0 +1,8 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
};

View File

@ -0,0 +1,63 @@
const path = require('path');
const pkg = require('../package.json');
const outputFile = 'index.umd.js';
const rootDir = path.resolve(__dirname, '../');
const outputFolder = path.join(__dirname, `../dist/umd/${pkg.name}/`);
// Todo: add ESM build for the extension in addition to umd build
const config = {
mode: 'production',
entry: rootDir + '/' + pkg.module,
devtool: 'source-map',
output: {
path: outputFolder,
filename: outputFile,
library: pkg.name,
libraryTarget: 'umd',
chunkFilename: '[name].chunk.js',
umdNamedDefine: true,
globalObject: "typeof self !== 'undefined' ? self : this",
},
externals: [
{
react: {
root: 'React',
commonjs2: 'react',
commonjs: 'react',
amd: 'react',
},
'@ohif/core': {
commonjs2: '@ohif/core',
commonjs: '@ohif/core',
amd: '@ohif/core',
root: '@ohif/core',
},
'@ohif/ui': {
commonjs2: '@ohif/ui',
commonjs: '@ohif/ui',
amd: '@ohif/ui',
root: '@ohif/ui',
},
},
],
module: {
rules: [
{
test: /(\.jsx|\.js|\.tsx|\.ts)$/,
loader: 'babel-loader',
exclude: /(node_modules|bower_components)/,
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
],
},
resolve: {
modules: [path.resolve('./node_modules'), path.resolve('./src')],
extensions: ['.json', '.js', '.jsx', '.tsx', '.ts'],
},
};
module.exports = config;

View File

@ -0,0 +1,20 @@
MIT License
Copyright (c) 2023 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,13 @@
# dicom-rt
## Description
DICOM RT read workflow. This extension will allow you to load a DICOM RTSS image
and display it in OHIF.
## Author
OHIF
## License
MIT

View File

@ -0,0 +1,44 @@
module.exports = {
plugins: ['inline-react-svg', '@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-hot-loader/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
};

View File

@ -0,0 +1,71 @@
{
"name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.0.0",
"description": "DICOM RT read workflow",
"author": "OHIF",
"license": "MIT",
"main": "dist/umd/@ohif/dicom-rt/index.umd.js",
"module": "src/index.tsx",
"files": [
"dist/**",
"public/**",
"README.md"
],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"dev:dicom-seg": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/extension-default": "^3.0.0",
"@ohif/extension-cornerstone": "^3.0.0",
"@ohif/i18n": "^1.0.0",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^10.11.0",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"webpack": "^5.50.0",
"webpack-merge": "^5.7.3"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"react-color": "^2.19.3"
},
"devDependencies": {
"@babel/core": "^7.21.4",
"@babel/plugin-proposal-class-properties": "^7.5.0",
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-transform-arrow-functions": "^7.2.0",
"@babel/plugin-transform-regenerator": "^7.16.7",
"@babel/plugin-transform-runtime": "^7.17.0",
"babel-plugin-inline-react-svg": "^2.0.1",
"@babel/preset-env": "^7.5.0",
"@babel/preset-react": "^7.16.7",
"babel-eslint": "^8.0.3",
"babel-loader": "^8.0.0-beta.4",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^10.2.0",
"cross-env": "^7.0.3",
"dotenv": "^14.1.0",
"eslint": "^5.0.1",
"eslint-loader": "^2.0.0",
"webpack": "^5.50.0",
"webpack-merge": "^5.7.3",
"webpack-cli": "^4.7.2"
}
}

View File

@ -0,0 +1,207 @@
import { utils } from '@ohif/core';
import { SOPClassHandlerId } from './id';
import loadRTStruct from './loadRTStruct';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.481.3'];
let loadPromises = {};
function _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
) {
const instance = instances[0];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPClassUID,
wadoRoot,
wadoUri,
wadoUriRoot,
} = instance;
const displaySet = {
Modality: 'RTSTRUCT',
loading: false,
isReconstructable: false, // by default for now since it is a volumetric SEG currently
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
isDerivedDisplaySet: true,
isLoaded: false,
isHydrated: false,
structureSet: null,
sopClassUids,
instance,
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
};
let referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (
instance.ReferencedFrameOfReferenceSequence &&
!instance.ReferencedSeriesSequence
) {
instance.ReferencedSeriesSequence = _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
instance.ReferencedFrameOfReferenceSequence
);
referencedSeriesSequence = instance.ReferencedSeriesSequence;
}
if (!referencedSeriesSequence) {
throw new Error('ReferencedSeriesSequence is missing for the RTSTRUCT');
}
const referencedSeries = referencedSeriesSequence[0];
displaySet.referencedImages =
instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
displaySet.getReferenceDisplaySet = () => {
const { DisplaySetService } = servicesManager.services;
const referencedDisplaySets = DisplaySetService.getDisplaySetsForSeries(
displaySet.referencedSeriesInstanceUID
);
if (!referencedDisplaySets || referencedDisplaySets.length === 0) {
throw new Error('Referenced DisplaySet is missing for the RT');
}
const referencedDisplaySet = referencedDisplaySets[0];
displaySet.referencedDisplaySetInstanceUID =
referencedDisplaySet.displaySetInstanceUID;
return referencedDisplaySet;
};
displaySet.load = ({ headers }) =>
_load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
function _load(rtDisplaySet, servicesManager, extensionManager, headers) {
const { SOPInstanceUID } = rtDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(rtDisplaySet.loading || rtDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID] &&
_segmentationExistsInCache(rtDisplaySet, segmentationService)
) {
return loadPromises[SOPInstanceUID];
}
rtDisplaySet.loading = true;
// We don't want to fire multiple loads, so we'll wait for the first to finish
// and also return the same promise to any other callers.
loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => {
if (!rtDisplaySet.structureSet) {
const structureSet = await loadRTStruct(
extensionManager,
rtDisplaySet,
rtDisplaySet.getReferenceDisplaySet(),
headers
);
rtDisplaySet.structureSet = structureSet;
}
const suppressEvents = true;
segmentationService
.createSegmentationForRTDisplaySet(rtDisplaySet, null, suppressEvents)
.then(() => {
rtDisplaySet.loading = false;
resolve();
})
.catch(error => {
rtDisplaySet.loading = false;
reject(error);
});
});
return loadPromises[SOPInstanceUID];
}
function _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
ReferencedFrameOfReferenceSequence
) {
const ReferencedSeriesSequence = [];
ReferencedFrameOfReferenceSequence.forEach(referencedFrameOfReference => {
const { RTReferencedStudySequence } = referencedFrameOfReference;
RTReferencedStudySequence.forEach(rtReferencedStudy => {
const { RTReferencedSeriesSequence } = rtReferencedStudy;
RTReferencedSeriesSequence.forEach(rtReferencedSeries => {
const ReferencedInstanceSequence = [];
const { ContourImageSequence, SeriesInstanceUID } = rtReferencedSeries;
ContourImageSequence.forEach(contourImage => {
ReferencedInstanceSequence.push({
ReferencedSOPInstanceUID: contourImage.ReferencedSOPInstanceUID,
ReferencedSOPClassUID: contourImage.ReferencedSOPClassUID,
});
});
const referencedSeries = {
SeriesInstanceUID,
ReferencedInstanceSequence,
};
ReferencedSeriesSequence.push(referencedSeries);
});
});
});
return ReferencedSeriesSequence;
}
function _segmentationExistsInCache(rtDisplaySet, segmentationService) {
// Todo: fix this
return false;
// This should be abstracted with the CornerstoneCacheService
const rtContourId = rtDisplaySet.displaySetInstanceUID;
const contour = segmentationService.getContour(rtContourId);
return contour !== undefined;
}
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
return [
{
name: 'dicom-rt',
sopClassUids,
getDisplaySetsFromSeries: instances => {
return _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
);
},
},
];
}
export default getSopClassHandlerModule;

View File

@ -0,0 +1,7 @@
import packageJson from '../package.json';
const id = packageJson.name;
const SOPClassHandlerName = 'dicom-rt';
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
export { id, SOPClassHandlerId, SOPClassHandlerName };

View File

@ -0,0 +1,61 @@
import { id } from './id';
import React from 'react';
import { Types } from '@ohif/core';
import getSopClassHandlerModule from './getSopClassHandlerModule';
const Component = React.lazy(() => {
return import(
/* webpackPrefetch: true */ './viewports/OHIFCornerstoneRTViewport'
);
});
const OHIFCornerstoneRTViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.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,
/**
* PanelModule should provide a list of panels that will be available in OHIF
* for Modes to consume and render. Each panel is defined by a {name,
* iconName, iconLabel, label, component} object. Example of a panel module
* is the StudyBrowserPanel that is provided by the default extension in OHIF.
*/
getViewportModule({
servicesManager,
extensionManager,
}: Types.Extensions.ExtensionParams) {
const ExtendedOHIFCornerstoneRTViewport = props => {
return (
<OHIFCornerstoneRTViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
{...props}
/>
);
};
return [{ name: 'dicom-rt', component: ExtendedOHIFCornerstoneRTViewport }];
},
/**
* 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,
};
export default extension;

View File

@ -0,0 +1,318 @@
import dcmjs from 'dcmjs';
const { DicomMessage, DicomMetaDictionary } = dcmjs.data;
const dicomlab2RGB = dcmjs.data.Colors.dicomlab2RGB;
async function checkAndLoadContourData(instance, datasource) {
if (!instance || !instance.ROIContourSequence) {
return Promise.reject('Invalid instance object or ROIContourSequence');
}
const promises = [];
let counter = 0;
for (const ROIContour of instance.ROIContourSequence) {
if (!ROIContour || !ROIContour.ContourSequence) {
return Promise.reject('Invalid ROIContour or ContourSequence');
}
for (const Contour of ROIContour.ContourSequence) {
if (!Contour || !Contour.ContourData) {
return Promise.reject('Invalid Contour or ContourData');
}
const contourData = Contour.ContourData;
counter++;
if (Array.isArray(contourData)) {
promises.push(Promise.resolve(contourData));
} else if (contourData && contourData.BulkDataURI) {
const bulkDataURI = contourData.BulkDataURI;
if (
!datasource ||
!datasource.retrieve ||
!datasource.retrieve.bulkDataURI
) {
return Promise.reject(
'Invalid datasource object or retrieve function'
);
}
const bulkDataPromise = datasource.retrieve.bulkDataURI({
BulkDataURI: bulkDataURI,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
SOPInstanceUID: instance.SOPInstanceUID,
});
promises.push(bulkDataPromise);
} else {
return Promise.reject(`Invalid ContourData: ${contourData}`);
}
}
}
const flattenedPromises = promises.flat();
const resolvedPromises = await Promise.allSettled(flattenedPromises);
// Modify contourData and replace it in its corresponding ROIContourSequence's Contour's contourData
let index = 0;
instance.ROIContourSequence.forEach((ROIContour, roiIndex) => {
ROIContour.ContourSequence.forEach((Contour, contourIndex) => {
const promise = resolvedPromises[index++];
if (promise.status === 'fulfilled') {
const uint8Array = new Uint8Array(promise.value);
const textDecoder = new TextDecoder();
const dataUint8Array = textDecoder.decode(uint8Array);
if (
typeof dataUint8Array === 'string' &&
dataUint8Array.includes('\\')
) {
const numSlashes = (dataUint8Array.match(/\\/g) || []).length;
let startIndex = 0;
let endIndex = dataUint8Array.indexOf('\\', startIndex);
let numbersParsed = 0;
const ContourData = [];
while (numbersParsed !== numSlashes + 1) {
const str = dataUint8Array.substring(startIndex, endIndex);
let value = parseFloat(str);
ContourData.push(value);
startIndex = endIndex + 1;
endIndex = dataUint8Array.indexOf('\\', startIndex);
endIndex === -1 ? (endIndex = dataUint8Array.length) : endIndex;
numbersParsed++;
}
Contour.ContourData = ContourData;
} else {
Contour.ContourData = [];
}
} else {
console.error(promise.reason);
}
});
});
}
export default async function loadRTStruct(
extensionManager,
rtStructDisplaySet,
referencedDisplaySet,
headers
) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const dataSource = extensionManager.getActiveDataSource()[0];
const { useBulkDataURI } = dataSource.getConfig?.() || {};
const { dicomLoaderService } = utilityModule.exports;
const imageIdSopInstanceUidPairs = _getImageIdSopInstanceUidPairsForDisplaySet(
referencedDisplaySet
);
// Set here is loading is asynchronous.
// If this function throws its set back to false.
rtStructDisplaySet.isLoaded = true;
let instance = rtStructDisplaySet.instance;
if (!useBulkDataURI) {
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
rtStructDisplaySet,
null,
headers
);
const dicomData = DicomMessage.readFile(segArrayBuffer);
const rtStructDataset = DicomMetaDictionary.naturalizeDataset(
dicomData.dict
);
rtStructDataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta);
instance = rtStructDataset;
} else {
await checkAndLoadContourData(instance, dataSource);
}
const {
StructureSetROISequence,
ROIContourSequence,
RTROIObservationsSequence,
} = instance;
// Define our structure set entry and add it to the rtstruct module state.
const structureSet = {
StructureSetLabel: instance.StructureSetLabel,
SeriesInstanceUID: instance.SeriesInstanceUID,
ROIContours: [],
visible: true,
};
for (let i = 0; i < ROIContourSequence.length; i++) {
const ROIContour = ROIContourSequence[i];
const { ContourSequence } = ROIContour;
if (!ContourSequence) {
continue;
}
const isSupported = false;
const ContourSequenceArray = _toArray(ContourSequence);
const contourPoints = [];
for (let c = 0; c < ContourSequenceArray.length; c++) {
const {
ContourImageSequence,
ContourData,
NumberOfContourPoints,
ContourGeometricType,
} = ContourSequenceArray[c];
const sopInstanceUID = ContourImageSequence.ReferencedSOPInstanceUID;
const imageId = _getImageId(imageIdSopInstanceUidPairs, sopInstanceUID);
if (!imageId) {
continue;
}
let isSupported = false;
const points = [];
for (let p = 0; p < NumberOfContourPoints * 3; p += 3) {
points.push({
x: ContourData[p],
y: ContourData[p + 1],
z: ContourData[p + 2],
});
}
switch (ContourGeometricType) {
case 'CLOSED_PLANAR':
case 'OPEN_PLANAR':
case 'POINT':
isSupported = true;
break;
default:
continue;
}
contourPoints.push({
numberOfPoints: NumberOfContourPoints,
points,
type: ContourGeometricType,
isSupported,
});
}
_setROIContourMetadata(
structureSet,
StructureSetROISequence,
RTROIObservationsSequence,
ROIContour,
contourPoints,
isSupported
);
}
return structureSet;
}
const _getImageId = (imageIdSopInstanceUidPairs, sopInstanceUID) => {
const imageIdSopInstanceUidPairsEntry = imageIdSopInstanceUidPairs.find(
imageIdSopInstanceUidPairsEntry =>
imageIdSopInstanceUidPairsEntry.sopInstanceUID === sopInstanceUID
);
return imageIdSopInstanceUidPairsEntry
? imageIdSopInstanceUidPairsEntry.imageId
: null;
};
function _getImageIdSopInstanceUidPairsForDisplaySet(referencedDisplaySet) {
return referencedDisplaySet.images.map(image => {
return {
imageId: image.imageId,
sopInstanceUID: image.SOPInstanceUID,
};
});
}
function _setROIContourMetadata(
structureSet,
StructureSetROISequence,
RTROIObservationsSequence,
ROIContour,
contourPoints,
isSupported
) {
const StructureSetROI = StructureSetROISequence.find(
structureSetROI =>
structureSetROI.ROINumber === ROIContour.ReferencedROINumber
);
const ROIContourData = {
ROINumber: StructureSetROI.ROINumber,
ROIName: StructureSetROI.ROIName,
ROIGenerationAlgorithm: StructureSetROI.ROIGenerationAlgorithm,
ROIDescription: StructureSetROI.ROIDescription,
isSupported,
contourPoints,
visible: true,
};
_setROIContourDataColor(ROIContour, ROIContourData);
if (RTROIObservationsSequence) {
// If present, add additional RTROIObservations metadata.
_setROIContourRTROIObservations(
ROIContourData,
RTROIObservationsSequence,
ROIContour.ReferencedROINumber
);
}
structureSet.ROIContours.push(ROIContourData);
}
function _setROIContourDataColor(ROIContour, ROIContourData) {
let { ROIDisplayColor, RecommendedDisplayCIELabValue } = ROIContour;
if (!ROIDisplayColor && RecommendedDisplayCIELabValue) {
// If ROIDisplayColor is absent, try using the RecommendedDisplayCIELabValue color.
ROIDisplayColor = dicomlab2RGB(RecommendedDisplayCIELabValue);
}
if (ROIDisplayColor) {
ROIContourData.colorArray = [...ROIDisplayColor];
}
}
function _setROIContourRTROIObservations(
ROIContourData,
RTROIObservationsSequence,
ROINumber
) {
const RTROIObservations = RTROIObservationsSequence.find(
RTROIObservations => RTROIObservations.ReferencedROINumber === ROINumber
);
if (RTROIObservations) {
// Deep copy so we don't keep the reference to the dcmjs dataset entry.
const {
ObservationNumber,
ROIObservationDescription,
RTROIInterpretedType,
ROIInterpreter,
} = RTROIObservations;
ROIContourData.RTROIObservations = {
ObservationNumber,
ROIObservationDescription,
RTROIInterpretedType,
ROIInterpreter,
};
}
}
function _toArray(objOrArray) {
return Array.isArray(objOrArray) ? objOrArray : [objOrArray];
}

View File

@ -0,0 +1,70 @@
async function _hydrateRTDisplaySet({
rtDisplaySet,
viewportIndex,
servicesManager,
}) {
const {
segmentationService,
hangingProtocolService,
viewportGridService,
} = servicesManager.services;
const displaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID;
let segmentationId = null;
// We need the hydration to notify panels about the new segmentation added
const suppressEvents = false;
segmentationId = await segmentationService.createSegmentationForRTDisplaySet(
rtDisplaySet,
segmentationId,
suppressEvents
);
segmentationService.hydrateSegmentation(rtDisplaySet.displaySetInstanceUID);
const { viewports } = viewportGridService.getState();
const updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
viewportIndex,
displaySetInstanceUID
);
viewportGridService.setDisplaySetsForViewports(updatedViewports);
// Todo: fix this after we have a better way for stack viewport segmentations
// check every viewport in the viewports to see if the displaySetInstanceUID
// is being displayed, if so we need to update the viewport to use volume viewport
// (if already is not using it) since Cornerstone3D currently only supports
// volume viewport for segmentation
viewports.forEach((viewport, index) => {
if (index === viewportIndex) {
return;
}
const shouldDisplaySeg = segmentationService.shouldRenderSegmentation(
viewport.displaySetInstanceUIDs,
rtDisplaySet.displaySetInstanceUID
);
if (shouldDisplaySeg) {
updatedViewports.push({
viewportIndex: index,
displaySetInstanceUIDs: viewport.displaySetInstanceUIDs,
viewportOptions: {
initialImageOptions: {
preset: 'middle',
},
},
});
}
});
// Do the entire update at once
viewportGridService.setDisplaySetsForViewports(updatedViewports);
return true;
}
export default _hydrateRTDisplaySet;

View File

@ -0,0 +1,12 @@
function createRTToolGroupAndAddTools(
ToolGroupService,
customizationService,
toolGroupId
) {
const { tools } =
customizationService.get('cornerstone.overlayViewportTools') ?? {};
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {});
}
export default createRTToolGroupAndAddTools;

View File

@ -0,0 +1,70 @@
import hydrateRTDisplaySet from './_hydrateRT';
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
HYDRATE_SEG: 5,
};
function promptHydrateRT({
servicesManager,
rtDisplaySet,
viewportIndex,
toolGroupId = 'default',
}) {
const { uiViewportDialogService } = servicesManager.services;
return new Promise(async function(resolve, reject) {
const promptResult = await _askHydrate(
uiViewportDialogService,
viewportIndex
);
if (promptResult === RESPONSE.HYDRATE_SEG) {
const isHydrated = await hydrateRTDisplaySet({
rtDisplaySet,
viewportIndex,
toolGroupId,
servicesManager,
});
resolve(isHydrated);
}
});
}
function _askHydrate(uiViewportDialogService, viewportIndex) {
return new Promise(function(resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const actions = [
{
type: 'secondary',
text: 'No',
value: RESPONSE.CANCEL,
},
{
type: 'primary',
text: 'Yes',
value: RESPONSE.HYDRATE_SEG,
},
];
const onSubmit = result => {
uiViewportDialogService.hide();
resolve(result);
};
uiViewportDialogService.show({
viewportIndex,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
});
});
}
export default promptHydrateRT;

View File

@ -0,0 +1,399 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import OHIF, { utils } from '@ohif/core';
import {
ViewportActionBar,
useViewportGrid,
LoadingIndicatorTotalPercent,
} from '@ohif/ui';
import _hydrateRTdisplaySet from '../utils/_hydrateRT';
import promptHydrateRT from '../utils/promptHydrateRT';
import _getStatusComponent from './_getStatusComponent';
import createRTToolGroupAndAddTools from '../utils/initRTToolGroup';
import _hydrateRTDisplaySet from '../utils/_hydrateRT';
const { formatDate } = utils;
const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup';
function OHIFCornerstoneRTViewport(props) {
const {
children,
displaySets,
viewportOptions,
viewportIndex,
viewportLabel,
servicesManager,
extensionManager,
} = props;
const {
displaySetService,
toolGroupService,
segmentationService,
uiNotificationService,
customizationService,
} = servicesManager.services;
const toolGroupId = `${RT_TOOLGROUP_BASE_NAME}-${viewportIndex}`;
// RT viewport will always have a single display set
if (displaySets.length > 1) {
throw new Error('RT viewport should only have a single display set');
}
const rtDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
// States
const [isToolGroupCreated, setToolGroupCreated] = useState(false);
const [selectedSegment, setSelectedSegment] = useState(1);
// Hydration means that the RT is opened and segments are loaded into the
// segmentation panel, and RT is also rendered on any viewport that is in the
// same frameOfReferenceUID as the referencedSeriesUID of the RT. However,
// loading basically means RT loading over network and bit unpacking of the
// RT data.
const [isHydrated, setIsHydrated] = useState(rtDisplaySet.isHydrated);
const [rtIsLoading, setRtIsLoading] = useState(!rtDisplaySet.isLoaded);
const [element, setElement] = useState(null);
const [processingProgress, setProcessingProgress] = useState({
percentComplete: null,
totalSegments: null,
});
// refs
const referencedDisplaySetRef = useRef(null);
const { viewports, activeViewportIndex } = viewportGrid;
const referencedDisplaySet = rtDisplaySet.getReferenceDisplaySet();
const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata(
referencedDisplaySet
);
referencedDisplaySetRef.current = {
displaySet: referencedDisplaySet,
metadata: referencedDisplaySetMetadata,
};
/**
* OnElementEnabled callback which is called after the cornerstoneExtension
* has enabled the element. Note: we delegate all the image rendering to
* cornerstoneExtension, so we don't need to do anything here regarding
* the image rendering, element enabling etc.
*/
const onElementEnabled = evt => {
setElement(evt.detail.element);
};
const onElementDisabled = () => {
setElement(null);
};
const getCornerstoneViewport = useCallback(() => {
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
const {
displaySet: referencedDisplaySet,
} = referencedDisplaySetRef.current;
// Todo: jump to the center of the first segment
return (
<Component
{...props}
displaySets={[referencedDisplaySet, rtDisplaySet]}
viewportOptions={{
viewportType: 'volume',
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
}}
onElementEnabled={onElementEnabled}
onElementDisabled={onElementDisabled}
></Component>
);
}, [viewportIndex, rtDisplaySet, toolGroupId]);
const onSegmentChange = useCallback(
direction => {
direction = direction === 'left' ? -1 : 1;
const segmentationId = rtDisplaySet.displaySetInstanceUID;
const segmentation = segmentationService.getSegmentation(segmentationId);
const { segments } = segmentation;
const numberOfSegments = Object.keys(segments).length;
let newSelectedSegmentIndex = selectedSegment + direction;
// Segment 0 is always background
if (newSelectedSegmentIndex >= numberOfSegments - 1) {
newSelectedSegmentIndex = 1;
} else if (newSelectedSegmentIndex === 0) {
newSelectedSegmentIndex = numberOfSegments - 1;
}
segmentationService.jumpToSegmentCenter(
segmentationId,
newSelectedSegmentIndex,
toolGroupId
);
setSelectedSegment(newSelectedSegmentIndex);
},
[selectedSegment]
);
useEffect(() => {
if (rtIsLoading) {
return;
}
promptHydrateRT({
servicesManager,
viewportIndex,
rtDisplaySet,
}).then(isHydrated => {
if (isHydrated) {
setIsHydrated(true);
}
});
}, [servicesManager, viewportIndex, rtDisplaySet, rtIsLoading]);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (
evt.rtDisplaySet.displaySetInstanceUID ===
rtDisplaySet.displaySetInstanceUID
) {
setRtIsLoading(false);
}
if (evt.overlappingSegments) {
uiNotificationService.show({
title: 'Overlapping Segments',
message:
'Overlapping segments detected which is not currently supported',
type: 'warning',
});
}
}
);
return () => {
unsubscribe();
};
}, [rtDisplaySet]);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
({ percentComplete, numSegments }) => {
setProcessingProgress({
percentComplete,
totalSegments: numSegments,
});
}
);
return () => {
unsubscribe();
};
}, [rtDisplaySet]);
/**
Cleanup the SEG viewport when the viewport is destroyed
*/
useEffect(() => {
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports[activeViewportIndex];
if (
displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)
) {
viewportGridService.setDisplaySetsForViewport({
viewportIndex: activeViewportIndex,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, []);
useEffect(() => {
let toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (toolGroup) {
return;
}
toolGroup = createRTToolGroupAndAddTools(
toolGroupService,
customizationService,
toolGroupId
);
setToolGroupCreated(true);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentationFromToolGroup(
toolGroupId
);
toolGroupService.destroyToolGroup(toolGroupId);
};
}, []);
useEffect(() => {
setIsHydrated(rtDisplaySet.isHydrated);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentationFromToolGroup(
toolGroupId
);
referencedDisplaySetRef.current = null;
};
}, [rtDisplaySet]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
if (
!referencedDisplaySetRef.current ||
referencedDisplaySet.displaySetInstanceUID !==
referencedDisplaySetRef.current.displaySet.displaySetInstanceUID
) {
return null;
}
if (children && children.length) {
childrenWithProps = children.map((child, index) => {
return (
child &&
React.cloneElement(child, {
viewportIndex,
key: index,
})
);
});
}
const {
PatientID,
PatientName,
PatientSex,
PatientAge,
SliceThickness,
ManufacturerModelName,
StudyDate,
SeriesDescription,
SpacingBetweenSlices,
SeriesNumber,
} = referencedDisplaySetRef.current.metadata;
const onStatusClick = async () => {
const isHydrated = await _hydrateRTDisplaySet({
rtDisplaySet,
viewportIndex,
servicesManager,
});
setIsHydrated(isHydrated);
};
return (
<>
<ViewportActionBar
onDoubleClick={evt => {
evt.stopPropagation();
evt.preventDefault();
}}
onArrowsClick={onSegmentChange}
getStatusComponent={() => {
return _getStatusComponent({
isHydrated,
onStatusClick,
});
}}
studyData={{
label: viewportLabel,
useAltStyling: true,
studyDate: formatDate(StudyDate),
currentSeries: SeriesNumber,
seriesDescription: `RT Viewport ${SeriesDescription}`,
patientInformation: {
patientName: PatientName
? OHIF.utils.formatPN(PatientName.Alphabetic)
: '',
patientSex: PatientSex || '',
patientAge: PatientAge || '',
MRN: PatientID || '',
thickness: SliceThickness ? `${SliceThickness.toFixed(2)}mm` : '',
spacing:
SpacingBetweenSlices !== undefined
? `${SpacingBetweenSlices.toFixed(2)}mm`
: '',
scanner: ManufacturerModelName || '',
},
}}
/>
<div className="relative flex flex-row w-full h-full overflow-hidden">
{rtIsLoading && (
<LoadingIndicatorTotalPercent
className="w-full h-full"
totalNumbers={processingProgress.totalSegments}
percentComplete={processingProgress.percentComplete}
loadingText="Loading RTSTRUCT..."
/>
)}
{getCornerstoneViewport()}
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstoneRTViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportIndex: PropTypes.number.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
customProps: PropTypes.object,
};
OHIFCornerstoneRTViewport.defaultProps = {
customProps: {},
};
function _getReferencedDisplaySetMetadata(referencedDisplaySet) {
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
PatientID: image0.PatientID,
PatientName: image0.PatientName,
PatientSex: image0.PatientSex,
PatientAge: image0.PatientAge,
SliceThickness: image0.SliceThickness,
StudyDate: image0.StudyDate,
SeriesDescription: image0.SeriesDescription,
SeriesInstanceUID: image0.SeriesInstanceUID,
SeriesNumber: image0.SeriesNumber,
ManufacturerModelName: image0.ManufacturerModelName,
SpacingBetweenSlices: image0.SpacingBetweenSlices,
};
return referencedDisplaySetMetadata;
}
export default OHIFCornerstoneRTViewport;

View File

@ -0,0 +1,54 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, Tooltip } from '@ohif/ui';
export default function _getStatusComponent({ isHydrated, onStatusClick }) {
let ToolTipMessage = null;
let StatusIcon = null;
const { t } = useTranslation('Common');
const loadStr = t('LOAD');
switch (isHydrated) {
case true:
StatusIcon = () => <Icon name="status-alert" />;
ToolTipMessage = () => (
<div>This Segmentation is loaded in the segmentation panel</div>
);
break;
case false:
StatusIcon = () => <Icon name="status-untracked" />;
ToolTipMessage = () => <div>Click LOAD to load RTSTRUCT.</div>;
}
const StatusArea = () => (
<div className="flex h-6 leading-6 cursor-default text-sm text-white">
<div className="min-w-[45px] flex items-center p-1 rounded-l-xl rounded-r bg-customgray-100">
<StatusIcon />
<span className="ml-1">RTSTRUCT</span>
</div>
{!isHydrated && (
<div
className="ml-1 px-1.5 rounded cursor-pointer hover:text-black bg-primary-main hover:bg-primary-light"
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
onMouseUp={onStatusClick}
>
{loadStr}
</div>
)}
</div>
);
return (
<>
{ToolTipMessage && (
<Tooltip content={<ToolTipMessage />} position="bottom-left">
<StatusArea />
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 Open Health Imaging Foundation
Copyright (c) 2023 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in

View File

@ -35,7 +35,7 @@
"prop-types": "^15.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^10.11.0",
"react-i18next": "^12.2.2",
"react-router": "^6.8.1",
"react-router-dom": "^6.8.1",
"webpack": "^5.50.0",
@ -46,16 +46,16 @@
"react-color": "^2.19.3"
},
"devDependencies": {
"@babel/core": "^7.5.0",
"@babel/core": "^7.21.4",
"@babel/plugin-proposal-class-properties": "^7.5.0",
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-transform-arrow-functions": "^7.2.0",
"@babel/plugin-transform-regenerator": "^7.4.5",
"@babel/plugin-transform-runtime": "^7.5.0",
"@babel/plugin-transform-regenerator": "^7.16.7",
"@babel/plugin-transform-runtime": "^7.17.0",
"babel-plugin-inline-react-svg": "^2.0.1",
"@babel/preset-env": "^7.5.0",
"@babel/preset-react": "^7.0.0",
"@babel/preset-react": "^7.16.7",
"babel-eslint": "^8.0.3",
"babel-loader": "^8.0.0-beta.4",
"clean-webpack-plugin": "^4.0.0",

View File

@ -0,0 +1,78 @@
import { Types } from '@ohif/core';
const segProtocol: Types.HangingProtocol.Protocol = {
id: '@ohif/seg',
// Don't store this hanging protocol as it applies to the currently active
// display set by default
// cacheId: null,
hasUpdatedPriorsInformation: false,
name: 'Segmentations',
// Just apply this one when specifically listed
protocolMatchingRules: [],
toolGroupIds: ['default'],
// -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with
// 0 or more priors.
numberOfPriorsReferenced: 0,
// Default viewport is used to define the viewport when
// additional viewports are added using the layout tool
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'segDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
displaySetSelectors: {
segDisplaySetId: {
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: 'SEG',
},
},
],
},
},
stages: [
{
name: 'Segmentations',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: { allowUnmatchedView: true },
displaySets: [
{
id: 'segDisplaySetId',
},
],
},
],
},
],
};
function getHangingProtocolModule() {
return [
{
name: segProtocol.id,
protocol: segProtocol,
},
];
}
export default getHangingProtocolModule;
export { segProtocol };

View File

@ -53,9 +53,11 @@ function _getDisplaySetsFromSeries(
segments: {},
sopClassUids,
instance,
instances: [instance],
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
@ -101,9 +103,12 @@ function _getDisplaySetsFromSeries(
function _load(segDisplaySet, servicesManager, extensionManager, headers) {
const { SOPInstanceUID } = segDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(segDisplaySet.loading || segDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID]
loadPromises[SOPInstanceUID] &&
_segmentationExists(segDisplaySet, segmentationService)
) {
return loadPromises[SOPInstanceUID];
}
@ -113,12 +118,6 @@ function _load(segDisplaySet, servicesManager, extensionManager, headers) {
// We don't want to fire multiple loads, so we'll wait for the first to finish
// and also return the same promise to any other callers.
loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => {
const { segmentationService } = servicesManager.services;
if (_segmentationExistsInCache(segDisplaySet, segmentationService)) {
return;
}
if (
!segDisplaySet.segments ||
Object.keys(segDisplaySet.segments).length === 0
@ -133,11 +132,8 @@ function _load(segDisplaySet, servicesManager, extensionManager, headers) {
}
const suppressEvents = true;
segmentationService.createSegmentationForSEGDisplaySet(
segDisplaySet,
null,
suppressEvents
)
segmentationService
.createSegmentationForSEGDisplaySet(segDisplaySet, null, suppressEvents)
.then(() => {
segDisplaySet.loading = false;
resolve();
@ -175,12 +171,11 @@ async function _loadSegments(extensionManager, segDisplaySet, headers) {
return segments;
}
function _segmentationExistsInCache(segDisplaySet, segmentationService) {
function _segmentationExists(segDisplaySet, segmentationService) {
// This should be abstracted with the CornerstoneCacheService
const labelmapVolumeId = segDisplaySet.displaySetInstanceUID;
const segVolume = segmentationService.getLabelmapVolume(labelmapVolumeId);
return segVolume !== undefined;
return segmentationService.getSegmentation(
segDisplaySet.displaySetInstanceUID
);
}
function _getPixelData(dataset, segments) {

View File

@ -3,8 +3,11 @@ import React from 'react';
import { Types } from '@ohif/core';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getSopClassHandlerModule, {
protocols,
} from './getSopClassHandlerModule';
import PanelSegmentation from './panels/PanelSegmentation';
import getHangingProtocolModule from './getHangingProtocolModule';
const Component = React.lazy(() => {
return import(
@ -31,23 +34,16 @@ const extension = {
id,
/**
* Perform any pre-registration tasks here. This is called before the extension
* is registered. Usually we run tasks such as: configuring the libraries
* (e.g. cornerstone, cornerstoneTools, ...) or registering any services that
* this extension is providing.
*/
preRegistration: ({
servicesManager,
commandsManager,
configuration = {},
}) => {},
/**
* PanelModule should provide a list of panels that will be available in OHIF
* for Modes to consume and render. Each panel is defined by a {name,
* iconName, iconLabel, label, component} object. Example of a panel module
* is the StudyBrowserPanel that is provided by the default extension in OHIF.
*/
getPanelModule: ({ servicesManager, commandsManager, extensionManager }): Types.Panel[] => {
getPanelModule: ({
servicesManager,
commandsManager,
extensionManager,
}): Types.Panel[] => {
const wrappedPanelSegmentation = () => {
return (
<PanelSegmentation
@ -68,6 +64,7 @@ const extension = {
},
];
},
getViewportModule({ servicesManager, extensionManager }) {
const ExtendedOHIFCornerstoneSEGViewport = props => {
return (
@ -90,6 +87,11 @@ const extension = {
* Examples include the default sop class handler provided by the default extension
*/
getSopClassHandlerModule,
getHangingProtocolModule,
};
export default extension;
// Export the protocols separately to allow for extending it at compile time
// in other modules
export { protocols };

View File

@ -9,20 +9,13 @@ export default function PanelSegmentation({
servicesManager,
commandsManager,
}) {
const {
segmentationService,
uiDialogService,
viewportGridService,
toolGroupService,
cornerstoneViewportService,
} = servicesManager.services;
const { segmentationService, uiDialogService } = servicesManager.services;
const { t } = useTranslation('PanelSegmentation');
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const [
initialSegmentationConfigurations,
setInitialSegmentationConfigurations,
] = useState(segmentationService.getConfiguration());
const [segmentationConfiguration, setSegmentationConfiguration] = useState(
segmentationService.getConfiguration()
);
const [segmentations, setSegmentations] = useState(() =>
segmentationService.getSegmentations()
@ -62,6 +55,7 @@ export default function PanelSegmentation({
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
setSegmentationConfiguration(segmentationService.getConfiguration());
});
subscriptions.push(unsubscribe);
});
@ -184,7 +178,7 @@ export default function PanelSegmentation({
segmentationService.toggleSegmentationVisibility(segmentationId);
};
const setSegmentationConfiguration = useCallback(
const _setSegmentationConfiguration = useCallback(
(segmentationId, key, value) => {
segmentationService.setConfiguration({
segmentationId,
@ -214,54 +208,51 @@ export default function PanelSegmentation({
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
onToggleMinimizeSegmentation={onToggleMinimizeSegmentation}
segmentationConfig={{
initialConfig: initialSegmentationConfigurations,
usePercentage: true,
}}
segmentationConfig={{ initialConfig: segmentationConfiguration }}
setRenderOutline={value =>
setSegmentationConfiguration(
_setSegmentationConfiguration(
selectedSegmentationId,
'renderOutline',
value
)
}
setOutlineOpacityActive={value =>
setSegmentationConfiguration(
_setSegmentationConfiguration(
selectedSegmentationId,
'outlineOpacity',
value
)
}
setRenderFill={value =>
setSegmentationConfiguration(
_setSegmentationConfiguration(
selectedSegmentationId,
'renderFill',
value
)
}
setRenderInactiveSegmentations={value =>
setSegmentationConfiguration(
_setSegmentationConfiguration(
selectedSegmentationId,
'renderInactiveSegmentations',
value
)
}
setOutlineWidthActive={value =>
setSegmentationConfiguration(
_setSegmentationConfiguration(
selectedSegmentationId,
'outlineWidthActive',
value
)
}
setFillAlpha={value =>
setSegmentationConfiguration(
_setSegmentationConfiguration(
selectedSegmentationId,
'fillAlpha',
value
)
}
setFillAlphaInactive={value =>
setSegmentationConfiguration(
_setSegmentationConfiguration(
selectedSegmentationId,
'fillAlphaInactive',
value

View File

@ -1,5 +1,3 @@
import React, { useReducer } from 'react';
// Todo: use defaults in cs3d
const initialState = {
renderOutline: true,

View File

@ -1,34 +1,12 @@
function createSEGToolGroupAndAddTools(
toolGroupService,
toolGroupId,
extensionManager
ToolGroupService,
customizationService,
toolGroupId
) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
);
const { tools } =
customizationService.get('cornerstone.overlayViewportTools') ?? {};
const { toolNames, Enums } = utilityModule.exports;
const tools = {
active: [
{
toolName: toolNames.WindowLevel,
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
},
{
toolName: toolNames.Pan,
bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }],
},
{
toolName: toolNames.Zoom,
bindings: [{ mouseButton: Enums.MouseBindings.Secondary }],
},
{ toolName: toolNames.StackScrollMouseWheel, bindings: [] },
],
enabled: [{ toolName: toolNames.SegmentationDisplay }],
};
return toolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {});
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {});
}
export default createSEGToolGroupAndAddTools;

View File

@ -3,7 +3,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import OHIF, { utils } from '@ohif/core';
import {
LoadingIndicatorProgress, Notification, useViewportDialog, useViewportGrid, ViewportActionBar
LoadingIndicatorTotalPercent,
useViewportGrid,
ViewportActionBar,
} from '@ohif/ui';
import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup';
import promptHydrateSEG from '../utils/promptHydrateSEG';
@ -31,6 +33,7 @@ function OHIFCornerstoneSEGViewport(props) {
toolGroupService,
segmentationService,
uiNotificationService,
customizationService,
} = servicesManager.services;
const toolGroupId = `${SEG_TOOLGROUP_BASE_NAME}-${viewportIndex}`;
@ -43,7 +46,6 @@ function OHIFCornerstoneSEGViewport(props) {
const segDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
const [viewportDialogState, viewportDialogApi] = useViewportDialog();
// States
const [isToolGroupCreated, setToolGroupCreated] = useState(false);
@ -58,7 +60,7 @@ function OHIFCornerstoneSEGViewport(props) {
const [segIsLoading, setSegIsLoading] = useState(!segDisplaySet.isLoaded);
const [element, setElement] = useState(null);
const [processingProgress, setProcessingProgress] = useState({
segmentIndex: 1,
percentComplete: null,
totalSegments: null,
});
@ -129,6 +131,8 @@ function OHIFCornerstoneSEGViewport(props) {
let newSelectedSegmentIndex = selectedSegment + direction;
// Segment 0 is always background
if (newSelectedSegmentIndex > numberOfSegments - 1) {
newSelectedSegmentIndex = 1;
} else if (newSelectedSegmentIndex === 0) {
@ -163,7 +167,7 @@ function OHIFCornerstoneSEGViewport(props) {
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_PIXEL_DATA_CREATED,
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (
evt.segDisplaySet.displaySetInstanceUID ===
@ -190,10 +194,10 @@ function OHIFCornerstoneSEGViewport(props) {
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENT_PIXEL_DATA_CREATED,
({ segmentIndex, numSegments }) => {
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
({ percentComplete, numSegments }) => {
setProcessingProgress({
segmentIndex,
percentComplete,
totalSegments: numSegments,
});
}
@ -239,8 +243,8 @@ function OHIFCornerstoneSEGViewport(props) {
// only, and does NOT interfere with currently displayed segmentations.
toolGroup = createSEGToolGroupAndAddTools(
toolGroupService,
toolGroupId,
extensionManager
customizationService,
toolGroupId
);
setToolGroupCreated(true);
@ -351,42 +355,14 @@ function OHIFCornerstoneSEGViewport(props) {
<div className="relative flex flex-row w-full h-full overflow-hidden">
{segIsLoading && (
<LoadingIndicatorProgress
<LoadingIndicatorTotalPercent
className="w-full h-full"
progress={
processingProgress.totalSegments !== null
? ((processingProgress.segmentIndex + 1) /
processingProgress.totalSegments) *
100
: null
}
textBlock={
!processingProgress.totalSegments ? (
<span className="text-white text-sm">Loading SEG ...</span>
) : (
<span className="text-white text-sm flex items-baseline space-x-2">
<div>Loading Segment</div>
<div className="w-3">{`${processingProgress.segmentIndex}`}</div>
<div>/</div>
<div>{`${processingProgress.totalSegments}`}</div>
</span>
)
}
totalNumbers={processingProgress.totalSegments}
percentComplete={processingProgress.percentComplete}
loadingText="Loading SEG..."
/>
)}
{getCornerstoneViewport()}
<div className="absolute w-full">
{viewportDialogState.viewportIndex === viewportIndex && (
<Notification
id="viewport-notification"
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
onOutsideClick={viewportDialogState.onOutsideClick}
/>
)}
</div>
{childrenWithProps}
</div>
</>

View File

@ -32,11 +32,11 @@
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/extension-cornerstone": "^3.0.0",
"@ohif/core": ">=3.0.0",
"@ohif/extension-cornerstone": ">=3.0.0",
"@ohif/extension-measurement-tracking": "^3.0.0",
"@ohif/ui": "^2.0.0",
"dcmjs": "^0.29.4",
"dcmjs": "^0.29.5",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
@ -46,7 +46,7 @@
"@babel/runtime": "^7.20.13",
"classnames": "^2.3.2",
"@cornerstonejs/adapters": "^0.6.0",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/tools": "^0.60.1"
"@cornerstonejs/core": "^0.47.1",
"@cornerstonejs/tools": "^0.67.4"
}
}

View File

@ -1,6 +1,6 @@
import { metaData, utilities } from '@cornerstonejs/core';
import OHIF from '@ohif/core';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import dcmjs from 'dcmjs';
import { adaptersSR } from '@cornerstonejs/adapters';
@ -41,7 +41,6 @@ const _generateReport = (
if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192';
}
return dataset;
};
@ -104,7 +103,18 @@ const commandsModule = ({}) => {
additionalFindingTypes,
options
);
const { StudyInstanceUID } = naturalizedReport;
const { StudyInstanceUID, ContentSequence } = naturalizedReport;
// The content sequence has 5 or more elements, of which
// the `[4]` element contains the annotation data, so this is
// checking that there is some annotation data present.
if (!ContentSequence?.[4].ContentSequence?.length) {
console.log(
'naturalizedReport missing imaging content',
naturalizedReport
);
throw new Error('Invalid report, no content');
}
await dataSource.store.dicom(naturalizedReport);
@ -112,6 +122,11 @@ const commandsModule = ({}) => {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
} catch (error) {
console.warn(error);

View File

@ -0,0 +1,78 @@
import { Types } from '@ohif/core';
const srProtocol: Types.HangingProtocol.Protocol = {
id: '@ohif/sr',
// Don't store this hanging protocol as it applies to the currently active
// display set by default
// cacheId: null,
hasUpdatedPriorsInformation: false,
name: 'SR Key Images',
// Just apply this one when specifically listed
protocolMatchingRules: [],
toolGroupIds: ['default'],
// -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with
// 0 or more priors.
numberOfPriorsReferenced: 0,
// Default viewport is used to define the viewport when
// additional viewports are added using the layout tool
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'srDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
displaySetSelectors: {
srDisplaySetId: {
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: 'SR',
},
},
],
},
},
stages: [
{
name: 'SR Key Images',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: { allowUnmatchedView: true },
displaySets: [
{
id: 'srDisplaySetId',
},
],
},
],
},
],
};
function getHangingProtocolModule() {
return [
{
name: srProtocol.id,
protocol: srProtocol,
},
];
}
export default getHangingProtocolModule;
export { srProtocol };

View File

@ -1,12 +1,15 @@
import { SOPClassHandlerName, SOPClassHandlerId } from './id';
import { utils, classes } from '@ohif/core';
import { utils, classes, DisplaySetService, Types } from '@ohif/core';
import addMeasurement from './utils/addMeasurement';
import isRehydratable from './utils/isRehydratable';
import { adaptersSR } from '@cornerstonejs/adapters';
type InstanceMetadata = Types.InstanceMetadata;
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
const { ImageSet, MetadataProvider: metadataProvider } = classes;
// TODO ->
// Add SR thumbnail
// Make viewport
@ -22,6 +25,17 @@ const sopClassUids = [
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
const validateSameStudyUID = (uid: string, instances): void => {
instances.forEach(it => {
if (it.StudyInstanceUID !== uid) {
console.warn('Not all instances have the same UID', uid, it);
throw new Error(
`Instances ${it.SOPInstanceUID} does not belong to ${uid}`
);
}
});
};
const CodeNameCodeSequenceValues = {
ImagingMeasurementReport: '126000',
ImageLibrary: '111028',
@ -50,15 +64,31 @@ const RELATIONSHIP_TYPE = {
const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT';
/**
* Adds instances to the DICOM SR series, rather than creating a new
* series, so that as SR's are saved, they append to the series, and the
* key image display set gets updated as well, containing just the new series.
* @param instances is a list of instances from THIS series that are not
* in this DICOM SR Display Set already.
*/
function addInstances(
instances: InstanceMetadata[],
displaySetService: DisplaySetService
) {
this.instances.push(...instances);
utils.sortStudyInstances(this.instances);
// The last instance is the newest one, so is the one most interesting.
// Eventually, the SR viewer should have the ability to choose which SR
// gets loaded, and to navigate among them.
this.instance = this.instances[this.instances.length - 1];
this.isLoaded = false;
return this;
}
/**
* DICOM SR SOP Class Handler
* For all referenced images in the TID 1500/300 sections, add an image to the
* display (this is TODO - it is not the actual behaviour below unfortunately)
*
* This will only display and rehydrate the latest DICOM SR in the given series
* It would be possible to add the ability to view older series rehydrations
* in the future.
*
* display.
* @param instances is a set of instances all from the same series
* @param servicesManager is the services that can be used for creating
* @returns The list of display sets created for the given instances object
@ -74,6 +104,9 @@ function _getDisplaySetsFromSeries(
}
utils.sortStudyInstances(instances);
// The last instance is the newest one, so is the one most interesting.
// Eventually, the SR viewer should have the ability to choose which SR
// gets loaded, and to navigate among them.
const instance = instances[instances.length - 1];
const {
@ -86,15 +119,20 @@ function _getDisplaySetsFromSeries(
ConceptNameCodeSequence,
SOPClassUID,
} = instance;
validateSameStudyUID(instance.StudyInstanceUID, instances);
if (
!ConceptNameCodeSequence ||
ConceptNameCodeSequence.CodeValue !==
CodeNameCodeSequenceValues.ImagingMeasurementReport
CodeNameCodeSequenceValues.ImagingMeasurementReport
) {
console.log(
'Only support Imaging Measurement Report SRs (TID1500) for this renderer.'
);
servicesManager.services.uiNotificationService.show({
title: 'DICOM SR',
message:
'OHIF only supports TID1500 Imaging Measurement Report Structured Reports. The SR youre trying to view is not supported.',
type: 'warning',
duration: 6000,
});
return [];
}
@ -111,14 +149,13 @@ function _getDisplaySetsFromSeries(
SOPClassHandlerId,
SOPClassUID,
instances,
// Others is a historical value used for instances which is deprecated and will be removed
others: instances,
referencedImages: null,
measurements: null,
isDerivedDisplaySet: true,
isLoaded: false,
sopClassUids,
instance,
addInstances,
};
displaySet.load = () => _load(displaySet, servicesManager, extensionManager);
@ -327,7 +364,7 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) {
trackingUniqueIdentifier => {
const mergedContentSequence =
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
trackingUniqueIdentifier
];
const measurement = _processMeasurement(mergedContentSequence);
@ -367,7 +404,7 @@ function _getMergedContentSequencesByTrackingUniqueIdentifiers(
if (
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
trackingUniqueIdentifier
] === undefined
) {
// Add the full ContentSequence
@ -473,18 +510,18 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
CodeNameCodeSequenceValues.TrackingIdentifier
);
const Finding = mergedContentSequence.find(
const finding = mergedContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.Finding
);
const FindingSites = mergedContentSequence.filter(
const findingSites = mergedContentSequence.filter(
item =>
item.ConceptNameCodeSequence.CodingSchemeDesignator ===
CodingSchemeDesignators.SRT &&
CodingSchemeDesignators.SRT &&
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.FindingSite
CodeNameCodeSequenceValues.FindingSite
);
const measurement = {
@ -496,28 +533,28 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
};
if (
Finding &&
finding &&
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
Finding.ConceptCodeSequence.CodingSchemeDesignator
finding.ConceptCodeSequence.CodingSchemeDesignator
) &&
Finding.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
finding.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
) {
measurement.labels.push({
label: CORNERSTONE_FREETEXT_CODE_VALUE,
value: Finding.ConceptCodeSequence.CodeMeaning,
value: finding.ConceptCodeSequence.CodeMeaning,
});
}
// TODO -> Eventually hopefully support SNOMED or some proper code library, just free text for now.
if (FindingSites.length) {
const cornerstoneFreeTextFindingSite = FindingSites.find(
if (findingSites.length) {
const cornerstoneFreeTextFindingSite = findingSites.find(
FindingSite =>
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
FindingSite.ConceptCodeSequence.CodingSchemeDesignator
) &&
FindingSite.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
CodeNameCodeSequenceValues.CornerstoneFreeText
);
if (cornerstoneFreeTextFindingSite) {
@ -633,17 +670,16 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
_getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => {
const { ReferencedSOPSequence } = item;
if (!ReferencedSOPSequence) return;
for (const ref of _getSequenceAsArray(ReferencedSOPSequence)) {
if (ref.ReferencedSOPClassUID) {
const { ReferencedSOPClassUID, ReferencedSOPInstanceUID } = ref;
if (item.hasOwnProperty('ReferencedSOPClassUID')) {
const {
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
} = ReferencedSOPSequence;
referencedImages.push({
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
});
referencedImages.push({
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
});
}
}
});
@ -651,6 +687,7 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
}
function _getSequenceAsArray(sequence) {
if (!sequence) return [];
return Array.isArray(sequence) ? sequence : [sequence];
}

View File

@ -1,11 +1,15 @@
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getHangingProtocolModule, {
srProtocol,
} from './getHangingProtocolModule';
import onModeEnter from './onModeEnter';
import commandsModule from './commandsModule';
import init from './init';
import getCommandsModule from './commandsModule';
import preRegistration from './init';
import { id } from './id.js';
import toolNames from './tools/toolNames';
import hydrateStructuredReport from './utils/hydrateStructuredReport';
import createReferencedImageDisplaySet from './utils/createReferencedImageDisplaySet';
const Component = React.lazy(() => {
return import(
@ -31,9 +35,7 @@ const dicomSRExtension = {
id,
onModeEnter,
preRegistration({ servicesManager, configuration = {} }) {
init({ servicesManager, configuration });
},
preRegistration,
/**
*
@ -54,14 +56,9 @@ const dicomSRExtension = {
return [{ name: 'dicom-sr', component: ExtendedOHIFCornerstoneSRViewport }];
},
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
return commandsModule({
servicesManager,
commandsManager,
extensionManager,
});
},
getCommandsModule,
getSopClassHandlerModule,
// Include dynmically computed values such as toolNames not known till instantiation
getUtilityModule({ servicesManager }) {
return [
{
@ -75,4 +72,6 @@ const dicomSRExtension = {
};
export default dicomSRExtension;
export { hydrateStructuredReport };
// Put static exports here so they can be type checked
export { hydrateStructuredReport, createReferencedImageDisplaySet, srProtocol };

View File

@ -6,6 +6,7 @@ import {
BidirectionalTool,
CobbAngleTool,
EllipticalROITool,
CircleROITool,
LengthTool,
PlanarFreehandROITool,
} from '@cornerstonejs/tools';
@ -24,6 +25,7 @@ export default function init({
addToolInstance(toolNames.SRLength, LengthTool, {});
addToolInstance(toolNames.SRBidirectional, BidirectionalTool);
addToolInstance(toolNames.SREllipticalROI, EllipticalROITool);
addToolInstance(toolNames.SRCircleROI, CircleROITool);
addToolInstance(toolNames.SRArrowAnnotate, ArrowAnnotateTool);
addToolInstance(toolNames.SRAngle, AngleTool);
// TODO - fix the SR display of Cobb Angle, as it joins the two lines
@ -40,6 +42,7 @@ export default function init({
SRLength: dashedLine,
SRBidirectional: dashedLine,
SREllipticalROI: dashedLine,
SRCircleROI: dashedLine,
SRArrowAnnotate: dashedLine,
SRCobbAngle: dashedLine,
SRAngle: dashedLine,

View File

@ -4,7 +4,7 @@ export default function onModeEnter({ servicesManager }) {
const { displaySetService } = servicesManager.services;
const displaySetCache = displaySetService.getDisplaySetCache();
const srDisplaySets = displaySetCache.filter(
const srDisplaySets = [...displaySetCache.values()].filter(
ds => ds.SOPClassHandlerId === SOPClassHandlerId
);

View File

@ -307,13 +307,26 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
const ellipsePointsWorld = data;
const rotation = viewport.getRotation();
canvasCoordinates = ellipsePointsWorld.map(p =>
viewport.worldToCanvas(p)
);
const canvasCorners = <Array<Types.Point2>>(
utilities.math.ellipse.getCanvasEllipseCorners(canvasCoordinates)
);
let canvasCorners;
if (rotation == 90 || rotation == 270) {
canvasCorners = <Array<Types.Point2>>(
utilities.math.ellipse.getCanvasEllipseCorners([
canvasCoordinates[2],
canvasCoordinates[3],
canvasCoordinates[0],
canvasCoordinates[1],
])
);
} else {
canvasCorners = <Array<Types.Point2>>(
utilities.math.ellipse.getCanvasEllipseCorners(canvasCoordinates)
);
}
const lineUID = `${index}`;
drawing.drawEllipse(

View File

@ -5,6 +5,7 @@ const toolNames = {
SRLength: 'SRLength',
SRBidirectional: 'SRBidirectional',
SREllipticalROI: 'SREllipticalROI',
SRCircleROI: 'SRCircleROI',
SRArrowAnnotate: 'SRArrowAnnotate',
SRAngle: 'SRAngle',
SRCobbAngle: 'SRCobbAngle',

View File

@ -0,0 +1,90 @@
import { DisplaySetService, classes } from '@ohif/core';
const ImageSet = classes.ImageSet;
const findInstance = (measurement, displaySetService: DisplaySetService) => {
const { displaySetInstanceUID, ReferencedSOPInstanceUID: sopUid } =
measurement;
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
if (!referencedDisplaySet.images) return;
return referencedDisplaySet.images.find(it => it.SOPInstanceUID === sopUid);
};
/** Finds references to display sets inside the measurements
* contained within the provided display set.
* @return an array of instances referenced.
*/
const findReferencedInstances = (
displaySetService: DisplaySetService,
displaySet
) => {
const instances = [];
const instanceById = {};
for (const measurement of displaySet.measurements) {
const { imageId } = measurement;
if (!imageId) continue;
if (instanceById[imageId]) continue;
const instance = findInstance(measurement, displaySetService);
if (!instance) {
console.log('Measurement', measurement, 'had no instances found');
continue;
}
instanceById[imageId] = instance;
instances.push(instance);
}
return instances;
};
/**
* Creates a new display set containing a single image instance for each
* referenced image.
*
* @param displaySetService
* @param displaySet - containing measurements referencing images.
* @returns A new (registered/active) display set containing the referenced images
*/
const createReferencedImageDisplaySet = (displaySetService, displaySet) => {
const instances = findReferencedInstances(displaySetService, displaySet);
// This will be a member function of the created image set
const updateInstances = function() {
this.images.splice(
0,
this.images.length,
...findReferencedInstances(displaySetService, displaySet)
);
this.numImageFrames = this.images.length;
};
const imageSet = new ImageSet(instances);
const instance = instances[0];
imageSet.setAttributes({
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
SeriesDate: instance.SeriesDate,
SeriesTime: instance.SeriesTime,
SeriesInstanceUID: imageSet.uid,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesNumber: instance.SeriesNumber || 0,
SOPClassUID: instance.SOPClassUID,
SeriesDescription: `${displaySet.SeriesDescription} KO ${displaySet.instance.SeriesNumber}`,
Modality: 'KO',
isMultiFrame: false,
numImageFrames: instances.length,
SOPClassHandlerId: `@ohif/extension-default.sopClassHandlerModule.stack`,
isReconstructable: false,
// This object is made of multiple instances from other series
isCompositeStack: true,
madeInClient: true,
excludeFromThumbnailBrowser: true,
updateInstances,
});
displaySetService.addDisplaySets(imageSet);
return imageSet;
};
export default createReferencedImageDisplaySet;

View File

@ -2,16 +2,10 @@ import PropTypes from 'prop-types';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import OHIF, { utils, ServicesManager, ExtensionManager } from '@ohif/core';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import {
Icon,
Notification,
Tooltip,
useViewportDialog,
useViewportGrid,
ViewportActionBar,
} from '@ohif/ui';
import { Icon, Tooltip, useViewportGrid, ViewportActionBar } from '@ohif/ui';
import hydrateStructuredReport from '../utils/hydrateStructuredReport';
const { formatDate } = utils;
@ -27,8 +21,8 @@ function OHIFCornerstoneSRViewport(props) {
dataSource,
displaySets,
viewportIndex,
viewportOptions,
viewportLabel,
viewportOptions,
servicesManager,
extensionManager,
} = props;
@ -47,7 +41,6 @@ function OHIFCornerstoneSRViewport(props) {
const srDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
const [viewportDialogState, viewportDialogApi] = useViewportDialog();
const [measurementSelected, setMeasurementSelected] = useState(0);
const [measurementCount, setMeasurementCount] = useState(1);
const [activeImageDisplaySetData, setActiveImageDisplaySetData] = useState(
@ -57,7 +50,6 @@ function OHIFCornerstoneSRViewport(props) {
referencedDisplaySetMetadata,
setReferencedDisplaySetMetadata,
] = useState(null);
const [isHydrated, setIsHydrated] = useState(srDisplaySet.isHydrated);
const [element, setElement] = useState(null);
const { viewports, activeViewportIndex } = viewportGrid;
@ -91,10 +83,12 @@ function OHIFCornerstoneSRViewport(props) {
SeriesInstanceUIDs[0]
);
if (displaySets.length) {
viewportGridService.setDisplaySetsForViewport({
viewportIndex: activeViewportIndex,
displaySetInstanceUIDs: [displaySets[0].displaySetInstanceUID],
});
viewportGridService.setDisplaySetsForViewports([
{
viewportIndex: activeViewportIndex,
displaySetInstanceUIDs: [displaySets[0].displaySetInstanceUID],
},
]);
}
};
}
@ -215,9 +209,19 @@ function OHIFCornerstoneSRViewport(props) {
// should be passed second since we don't want SR displaySet to
// override the activeImageDisplaySetData
displaySets={[activeImageDisplaySetData]}
// It is possible that there is a hanging protocol applying viewportOptions
// for the SR, so inherit the viewport options
// TODO: Ensure the viewport options are set correctly with respect to
// stack etc, in the incoming viewport options.
viewportOptions={{
...viewportOptions,
toolGroupId: `${SR_TOOLGROUP_BASE_NAME}`,
// viewportType should not be required, as the stack type should be
// required already in order to view SR, but sometimes segmentation
// views set the viewport type without fixing the allowed display
viewportType: 'stack',
// The positionIds for the viewport aren't meaningful for the child display sets
positionIds: null,
}}
onElementEnabled={onElementEnabled}
initialImageIndex={initialImageIndex}
@ -289,8 +293,6 @@ function OHIFCornerstoneSRViewport(props) {
if (!srDisplaySet.isLoaded) {
srDisplaySet.load();
}
setIsHydrated(srDisplaySet.isHydrated);
const numMeasurements = srDisplaySet.measurements.length;
setMeasurementCount(numMeasurements);
}, [srDisplaySet]);
@ -378,6 +380,7 @@ function OHIFCornerstoneSRViewport(props) {
label: viewportLabel,
useAltStyling: true,
studyDate: formatDate(StudyDate),
currentSeries: SeriesNumber,
seriesDescription: SeriesDescription || '',
patientInformation: {
patientName: PatientName
@ -398,17 +401,6 @@ function OHIFCornerstoneSRViewport(props) {
<div className="relative flex flex-row w-full h-full overflow-hidden">
{getCornerstoneViewport()}
<div className="absolute w-full">
{viewportDialogState.viewportIndex === viewportIndex && (
<Notification
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
onOutsideClick={viewportDialogState.onOutsideClick}
/>
)}
</div>
{childrenWithProps}
</div>
</>
@ -420,8 +412,10 @@ OHIFCornerstoneSRViewport.propTypes = {
viewportIndex: PropTypes.number.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
customProps: PropTypes.object,
viewportOptions: PropTypes.object,
viewportLabel: PropTypes.string,
servicesManager: PropTypes.instanceOf(ServicesManager).isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};

View File

@ -7,6 +7,11 @@
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.tsx",
"types": "src/types/index.ts",
"exports": {
".": "./src/index.tsx",
"./types": "./src/types/index.ts"
},
"engines": {
"node": ">=10",
"npm": ">=6",
@ -29,9 +34,13 @@
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/ui": "^2.0.0",
"cornerstone-wado-image-loader": "^4.13.0",
"dcmjs": "^0.29.4",
"dicom-parser": "^1.8.9",
"@cornerstonejs/dicom-image-loader": "^0.6.6",
"@cornerstonejs/codec-charls": "^1.2.3",
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.2",
"@cornerstonejs/codec-openjph": "^2.4.2",
"dcmjs": "^0.29.6",
"dicom-parser": "^1.8.21",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^17.0.2",
@ -44,10 +53,10 @@
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^0.6.0",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/streaming-image-volume-loader": "^0.16.0",
"@cornerstonejs/tools": "^0.60.1",
"@kitware/vtk.js": "26.5.6",
"@cornerstonejs/core": "^0.47.1",
"@cornerstonejs/streaming-image-volume-loader": "^0.20.4",
"@cornerstonejs/tools": "^0.67.4",
"@kitware/vtk.js": "27.3.1",
"html2canvas": "^1.4.1",
"lodash.debounce": "4.0.8",
"lodash.merge": "^4.6.2",

View File

@ -8,22 +8,35 @@ import {
getEnabledElement,
StackViewport,
utilities as csUtils,
CONSTANTS,
} from '@cornerstonejs/core';
import { MeasurementService } from '@ohif/core';
import {
CinePlayer,
useCine,
useViewportGrid,
Notification,
useViewportDialog,
} from '@ohif/ui';
import {
IStackViewport,
IVolumeViewport,
} from '@cornerstonejs/core/dist/esm/types';
import { setEnabledElement } from '../state';
import './OHIFCornerstoneViewport.css';
import CornerstoneOverlays from './Overlays/CornerstoneOverlays';
import {
IStackViewport,
IVolumeViewport,
} from '@cornerstonejs/core/dist/esm/types';
import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
import { CinePlayer, useCine, useViewportGrid } from '@ohif/ui';
import CornerstoneServices from '../types/CornerstoneServices';
const STACK = 'stack';
/**
* Caches the jump to measurement operation, so that if display set is shown,
* it can jump to the measurement.
*/
let cacheJumpToMeasurementEvent;
function areEqual(prevProps, nextProps) {
if (nextProps.needsRerendering) {
return false;
@ -129,7 +142,9 @@ const OHIFCornerstoneViewport = React.memo(props => {
cornerstoneCacheService,
viewportGridService,
stateSyncService,
} = servicesManager.services;
} = servicesManager.services as CornerstoneServices;
const [viewportDialogState] = useViewportDialog();
const cineHandler = () => {
if (!cines || !cines[viewportIndex] || !enabledVPElement) {
@ -390,8 +405,9 @@ const OHIFCornerstoneViewport = React.memo(props => {
initialImageIndex
);
storePresentation();
// The presentation state will have been stored previously by closing
// a viewport. Otherwise, this viewport will be unchanged and the
// presentation information will be directly carried over.
const {
lutPresentationStore,
positionPresentationStore,
@ -403,6 +419,13 @@ const OHIFCornerstoneViewport = React.memo(props => {
lutPresentation:
lutPresentationStore[presentationIds?.lutPresentationId],
};
let measurement;
if (cacheJumpToMeasurementEvent?.viewportIndex === viewportIndex) {
measurement = cacheJumpToMeasurementEvent.measurement;
// Delete the position presentation so that viewport navigates direct
presentations.positionPresentation = null;
cacheJumpToMeasurementEvent = null;
}
cornerstoneViewportService.setViewportData(
viewportIndex,
@ -411,6 +434,10 @@ const OHIFCornerstoneViewport = React.memo(props => {
displaySetOptions,
presentations
);
if (measurement) {
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
}
};
loadViewportData();
@ -453,50 +480,64 @@ const OHIFCornerstoneViewport = React.memo(props => {
}, [displaySets, elementRef, viewportIndex]);
return (
<div className="viewport-wrapper">
<ReactResizeDetector
handleWidth
handleHeight
skipOnMount={true} // Todo: make these configurable
refreshMode={'debounce'}
refreshRate={200} // transition amount in side panel
onResize={onResize}
targetRef={elementRef.current}
/>
<div
className="cornerstone-viewport-element"
style={{ height: '100%', width: '100%' }}
onContextMenu={e => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
ref={elementRef}
></div>
<CornerstoneOverlays
viewportIndex={viewportIndex}
toolbarService={toolbarService}
element={elementRef.current}
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
{isCineEnabled && (
<CinePlayer
className="absolute left-1/2 -translate-x-1/2 bottom-3"
isPlaying={isPlaying}
onClose={handleCineClose}
onPlayPauseChange={isPlaying =>
cineService.setCine({
id: activeViewportIndex,
isPlaying,
})
}
onFrameRateChange={frameRate =>
cineService.setCine({
id: activeViewportIndex,
frameRate,
})
}
<React.Fragment>
<div className="viewport-wrapper">
<ReactResizeDetector
handleWidth
handleHeight
skipOnMount={true} // Todo: make these configurable
refreshMode={'debounce'}
refreshRate={200} // transition amount in side panel
onResize={onResize}
targetRef={elementRef.current}
/>
)}
</div>
<div
className="cornerstone-viewport-element"
style={{ height: '100%', width: '100%' }}
onContextMenu={e => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
ref={elementRef}
></div>
<CornerstoneOverlays
viewportIndex={viewportIndex}
toolBarService={toolbarService}
element={elementRef.current}
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
{isCineEnabled && (
<CinePlayer
className="absolute left-1/2 -translate-x-1/2 bottom-3"
isPlaying={isPlaying}
onClose={handleCineClose}
onPlayPauseChange={isPlaying =>
cineService.setCine({
id: activeViewportIndex,
isPlaying,
})
}
onFrameRateChange={frameRate =>
cineService.setCine({
id: activeViewportIndex,
frameRate,
})
}
/>
)}
</div>
<div className="absolute w-full">
{viewportDialogState.viewportIndex === viewportIndex && (
<Notification
id="viewport-notification"
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
onOutsideClick={viewportDialogState.onOutsideClick}
/>
)}
</div>
</React.Fragment>
);
}, areEqual);
@ -513,22 +554,31 @@ function _subscribeToJumpToMeasurementEvents(
displaySet => displaySet.displaySetInstanceUID
);
const { unsubscribe } = measurementService.subscribe(
measurementService.EVENTS.JUMP_TO_MEASUREMENT,
({ measurement }) => {
if (!measurement) return;
// Jump the the measurement if the viewport contains the displaySetUID (fusion)
if (displaysUIDs.includes(measurement.displaySetInstanceUID)) {
_jumpToMeasurement(
measurement,
elementRef,
viewportIndex,
measurementService,
displaySetService,
viewportGridService,
cornerstoneViewportService
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
props => {
cacheJumpToMeasurementEvent = props;
const { viewportIndex: jumpIndex, measurement, isConsumed } = props;
if (!measurement || isConsumed) return;
if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) {
// Decide on which viewport should handle this
cacheJumpToMeasurementEvent.cornerstoneViewport = cornerstoneViewportService.getViewportIndexToJump(
jumpIndex,
measurement.displaySetInstanceUID,
{ referencedImageId: measurement.referencedImageId }
);
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportIndex) {
return;
}
_jumpToMeasurement(
measurement,
elementRef,
viewportIndex,
measurementService,
displaySetService,
viewportGridService,
cornerstoneViewportService
);
}
);
@ -545,21 +595,20 @@ function _checkForCachedJumpToMeasurementEvents(
viewportGridService,
cornerstoneViewportService
) {
if (!cacheJumpToMeasurementEvent) return;
if (cacheJumpToMeasurementEvent.isConsumed) {
cacheJumpToMeasurementEvent = null;
return;
}
const displaysUIDs = displaySets.map(
displaySet => displaySet.displaySetInstanceUID
);
if (!displaysUIDs?.length) return;
const measurementIdToJumpTo = measurementService.getJumpToMeasurement(
viewportIndex
);
if (measurementIdToJumpTo && elementRef) {
// Jump to measurement if the measurement exists
const measurement = measurementService.getMeasurement(
measurementIdToJumpTo
);
if (displaysUIDs.includes(measurement.displaySetInstanceUID)) {
// Jump to measurement if the measurement exists
const { measurement } = cacheJumpToMeasurementEvent;
if (measurement && elementRef) {
if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) {
_jumpToMeasurement(
measurement,
elementRef,
@ -587,6 +636,7 @@ function _jumpToMeasurement(
if (!SOPInstanceUID) {
console.warn('cannot jump in a non-acquisition plane measurements yet');
return;
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
@ -599,15 +649,17 @@ function _jumpToMeasurement(
viewportGridService.setActiveViewportIndex(viewportIndex);
const enableElement = getEnabledElement(targetElement);
const enabledElement = getEnabledElement(targetElement);
const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
if (enableElement) {
if (enabledElement) {
// See how the jumpToSlice() of Cornerstone3D deals with imageIdx param.
const viewport = enableElement.viewport as IStackViewport | IVolumeViewport;
const viewport = enabledElement.viewport as
| IStackViewport
| IVolumeViewport;
let imageIdIndex = 0;
let viewportCameraDirectionMatch = true;
@ -627,19 +679,19 @@ function _jumpToMeasurement(
} else {
// for volume viewport we can't rely on the imageIdIndex since it can be
// a reconstructed view that doesn't match the original slice numbers etc.
const { viewPlaneNormal } = measurement.metadata;
const { viewPlaneNormal: measurementViewPlane } = measurement.metadata;
imageIdIndex = referencedDisplaySet.images.findIndex(
i => i.SOPInstanceUID === SOPInstanceUID
);
const { orientation } = viewportInfo.getViewportOptions();
const { viewPlaneNormal: viewportViewPlane } = viewport.getCamera();
// should compare abs for both planes since the direction can be flipped
if (
orientation &&
viewPlaneNormal &&
measurementViewPlane &&
!csUtils.isEqual(
CONSTANTS.MPR_CAMERA_VALUES[orientation]?.viewPlaneNormal,
viewPlaneNormal
measurementViewPlane.map(Math.abs),
viewportViewPlane.map(Math.abs)
)
) {
viewportCameraDirectionMatch = false;
@ -656,7 +708,8 @@ function _jumpToMeasurement(
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
// Jump to measurement consumed, remove.
measurementService.removeJumpToMeasurement(viewportIndex);
cacheJumpToMeasurementEvent?.consume?.();
cacheJumpToMeasurementEvent = null;
}
}

View File

@ -10,16 +10,19 @@ import {
utilities as cstUtils,
ReferenceLinesTool,
} from '@cornerstonejs/tools';
import { ServicesManager } from '@ohif/core';
import { Types as OhifTypes } from '@ohif/core';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import callInputDialog from './utils/callInputDialog';
import { setColormap } from './utils/colormap/transferFunctionHelpers';
import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import { CornerstoneServices } from './types';
function commandsModule({ servicesManager, commandsManager }) {
function commandsModule({
servicesManager,
commandsManager,
}: OhifTypes.Extensions.ExtensionParams): OhifTypes.Extensions.CommandsModule {
const {
viewportGridService,
toolGroupService,
@ -28,51 +31,14 @@ function commandsModule({ servicesManager, commandsManager }) {
uiDialogService,
cornerstoneViewportService,
uiNotificationService,
customizationService,
measurementService,
hangingProtocolService,
} = (servicesManager as ServicesManager).services;
} = servicesManager.services as CornerstoneServices;
const { measurementServiceSource } = this;
function _getActiveViewportEnabledElement() {
return getActiveViewportEnabledElement(viewportGridService);
}
function _getToolGroup(toolGroupId) {
let toolGroupIdToUse = toolGroupId;
if (!toolGroupIdToUse) {
// Use the active viewport's tool group if no tool group id is provided
const enabledElement = _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
}
const { renderingEngineId, viewportId } = enabledElement;
const toolGroup = ToolGroupManager.getToolGroupForViewport(
viewportId,
renderingEngineId
);
if (!toolGroup) {
console.warn(
'No tool group found for viewportId:',
viewportId,
'and renderingEngineId:',
renderingEngineId
);
return;
}
toolGroupIdToUse = toolGroup.id;
}
const toolGroup = toolGroupService.getToolGroup(toolGroupIdToUse);
return toolGroup;
}
const actions = {
/**
* Generates the selector props for the context menu, specific to
@ -127,6 +93,36 @@ function commandsModule({ servicesManager, commandsManager }) {
cstUtils.getAnnotationNearPoint(element, canvasCoordinates)
);
},
getNearbyAnnotation({ element, canvasCoordinates }) {
const nearbyToolData = actions.getNearbyToolData({
nearbyToolData: null,
element,
canvasCoordinates,
});
const isAnnotation = toolName => {
const enabledElement = getEnabledElement(element);
if (!enabledElement) {
return;
}
const { renderingEngineId, viewportId } = enabledElement;
const toolGroup = ToolGroupManager.getToolGroupForViewport(
viewportId,
renderingEngineId
);
const toolInstance = toolGroup.getToolInstance(toolName);
return toolInstance?.constructor?.isAnnotation ?? true;
};
return nearbyToolData?.metadata?.toolName &&
isAnnotation(nearbyToolData.metadata.toolName)
? nearbyToolData
: null;
},
// Measurement tool commands:
@ -298,7 +294,7 @@ function commandsModule({ servicesManager, commandsManager }) {
setToolActive: ({ toolName, toolGroupId = null }) => {
if (toolName === 'Crosshairs') {
const activeViewportToolGroup = _getToolGroup(null);
const activeViewportToolGroup = toolGroupService.getToolGroup(null);
if (!activeViewportToolGroup._toolInstances.Crosshairs) {
uiNotificationService.show({
@ -317,7 +313,7 @@ function commandsModule({ servicesManager, commandsManager }) {
viewports: [],
};
const toolGroup = _getToolGroup(toolGroupId);
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
const toolGroupViewportIds = toolGroup?.getViewportIds?.();
// if toolGroup has been destroyed, or its viewports have been removed
@ -372,6 +368,21 @@ function commandsModule({ servicesManager, commandsManager }) {
},
showDownloadViewportModal: () => {
const { activeViewportIndex } = viewportGridService.getState();
if (
!cornerstoneViewportService.getCornerstoneViewportByIndex(
activeViewportIndex
)
) {
// Cannot download a non-cornerstone viewport (image).
uiNotificationService.show({
title: 'Download Image',
message: 'Image cannot be downloaded',
type: 'error',
});
return;
}
const { uiModalService } = servicesManager.services;
if (uiModalService) {
@ -491,33 +502,22 @@ function commandsModule({ servicesManager, commandsManager }) {
}
}
},
firstImage: () => {
// Get current active viewport (return if none active)
const enabledElement = _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
}
const { viewport } = enabledElement;
// Check viewport is supported
if (
viewport! instanceof StackViewport &&
viewport! instanceof VolumeViewport
) {
throw new Error('Unsupported viewport type');
}
// Set slice to first slice
const options = { imageIndex: 0 };
cstUtils.jumpToSlice(viewport.element, options);
},
lastImage: () => {
/** Jumps the active viewport or the specified one to the given slice index */
jumpToImage: ({ imageIndex, viewport: gridViewport }): void => {
// Get current active viewport (return if none active)
const enabledElement = _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
let viewport;
if (!gridViewport) {
const enabledElement = _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
}
viewport = enabledElement.viewport;
} else {
viewport = cornerstoneViewportService.getCornerstoneViewport(
gridViewport.id
);
}
const { viewport } = enabledElement;
// Get number of slices
// -> Copied from cornerstone3D jumpToSlice\_getImageSliceData()
@ -532,8 +532,14 @@ function commandsModule({ servicesManager, commandsManager }) {
throw new Error('Unsupported viewport type');
}
const jumpIndex =
imageIndex < 0 ? numberOfSlices + imageIndex : imageIndex;
if (jumpIndex >= numberOfSlices || jumpIndex < 0) {
throw new Error(`Can't jump to ${imageIndex}`);
}
// Set slice to last slice
const options = { imageIndex: numberOfSlices - 1 };
const options = { imageIndex: jumpIndex };
cstUtils.jumpToSlice(viewport.element, options);
},
scroll: ({ direction }) => {
@ -564,9 +570,9 @@ function commandsModule({ servicesManager, commandsManager }) {
return actorEntry.uid.includes(displaySetInstanceUID);
});
const { actor: volumeActor } = actorEntry;
const { actor: volumeActor, uid: volumeId } = actorEntry;
setColormap(volumeActor, colormap);
viewport.setProperties({ colormap, volumeActor }, volumeId);
if (immediate) {
viewport.render();
@ -632,148 +638,109 @@ function commandsModule({ servicesManager, commandsManager }) {
getNearbyToolData: {
commandFn: actions.getNearbyToolData,
},
getNearbyAnnotation: {
commandFn: actions.getNearbyAnnotation,
storeContexts: [],
options: {},
},
deleteMeasurement: {
commandFn: actions.deleteMeasurement,
storeContexts: [],
options: {},
},
setMeasurementLabel: {
commandFn: actions.setMeasurementLabel,
storeContexts: [],
options: {},
},
updateMeasurement: {
commandFn: actions.updateMeasurement,
storeContexts: [],
options: {},
},
setWindowLevel: {
commandFn: actions.setWindowLevel,
storeContexts: [],
options: {},
},
toolbarServiceRecordInteraction: {
commandFn: actions.toolbarServiceRecordInteraction,
storeContexts: [],
options: {},
},
setToolActive: {
commandFn: actions.setToolActive,
storeContexts: [],
options: {},
},
rotateViewportCW: {
commandFn: actions.rotateViewport,
storeContexts: [],
options: { rotation: 90 },
},
rotateViewportCCW: {
commandFn: actions.rotateViewport,
storeContexts: [],
options: { rotation: -90 },
},
incrementActiveViewport: {
commandFn: actions.incrementActiveViewport,
storeContexts: [],
},
decrementActiveViewport: {
commandFn: actions.decrementActiveViewport,
storeContexts: [],
},
flipViewportHorizontal: {
commandFn: actions.flipViewportHorizontal,
storeContexts: [],
options: {},
},
flipViewportVertical: {
commandFn: actions.flipViewportVertical,
storeContexts: [],
options: {},
},
invertViewport: {
commandFn: actions.invertViewport,
storeContexts: [],
options: {},
},
resetViewport: {
commandFn: actions.resetViewport,
storeContexts: [],
options: {},
},
scaleUpViewport: {
commandFn: actions.scaleViewport,
storeContexts: [],
options: { direction: 1 },
},
scaleDownViewport: {
commandFn: actions.scaleViewport,
storeContexts: [],
options: { direction: -1 },
},
fitViewportToWindow: {
commandFn: actions.scaleViewport,
storeContexts: [],
options: { direction: 0 },
},
nextImage: {
commandFn: actions.scroll,
storeContexts: [],
options: { direction: 1 },
},
previousImage: {
commandFn: actions.scroll,
storeContexts: [],
options: { direction: -1 },
},
firstImage: {
commandFn: actions.firstImage,
storeContexts: [],
options: {},
commandFn: actions.jumpToImage,
options: { imageIndex: 0 },
},
lastImage: {
commandFn: actions.lastImage,
storeContexts: [],
options: {},
commandFn: actions.jumpToImage,
options: { imageIndex: -1 },
},
jumpToImage: {
commandFn: actions.jumpToImage,
},
showDownloadViewportModal: {
commandFn: actions.showDownloadViewportModal,
storeContexts: [],
options: {},
},
toggleCine: {
commandFn: actions.toggleCine,
storeContexts: [],
options: {},
},
arrowTextCallback: {
commandFn: actions.arrowTextCallback,
storeContexts: [],
options: {},
},
setViewportActive: {
commandFn: actions.setViewportActive,
storeContexts: [],
options: {},
},
setViewportColormap: {
commandFn: actions.setViewportColormap,
storeContexts: [],
options: {},
},
toggleStackImageSync: {
commandFn: actions.toggleStackImageSync,
storeContexts: [],
options: {},
},
toggleReferenceLines: {
commandFn: actions.toggleReferenceLines,
storeContexts: [],
options: {},
},
};

View File

@ -0,0 +1,6 @@
.dicom-upload-drop-area-border-dash {
background-image: repeating-linear-gradient(to right, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%), repeating-linear-gradient(to right, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%), repeating-linear-gradient(to bottom, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%), repeating-linear-gradient(to bottom, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%);
background-position: left top, left bottom, left top, right top;
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
background-size: 20px 3px, 20px 3px, 3px 20px, 3px 20px;
}

View File

@ -0,0 +1,116 @@
import React, { useCallback, useState } from 'react';
import { ReactElement } from 'react';
import Dropzone from 'react-dropzone';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import DicomFileUploader from '../../utils/DicomFileUploader';
import DicomUploadProgress from './DicomUploadProgress';
import { Button } from '@ohif/ui';
import './DicomUpload.css';
type DicomUploadProps = {
dataSource;
onComplete: () => void;
onStarted: () => void;
};
function DicomUpload({
dataSource,
onComplete,
onStarted,
}: DicomUploadProps): ReactElement {
const baseClassNames = 'min-h-[480px] flex flex-col bg-black select-none';
const [dicomFileUploaderArr, setDicomFileUploaderArr] = useState([]);
const onDrop = useCallback(async acceptedFiles => {
onStarted();
setDicomFileUploaderArr(
acceptedFiles.map(file => new DicomFileUploader(file, dataSource))
);
}, []);
const getDropZoneComponent = (): ReactElement => {
return (
<Dropzone
onDrop={acceptedFiles => {
onDrop(acceptedFiles);
}}
noClick
>
{({ getRootProps }) => (
<div
{...getRootProps()}
className="m-5 dicom-upload-drop-area-border-dash flex flex-col items-center justify-center h-full"
>
<div className="flex gap-3">
<Dropzone onDrop={onDrop} noDrag>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<Button
variant="contained"
color="primary"
disabled={false}
onClick={() => {}}
>
{'Add files'}
<input {...getInputProps()} />
</Button>
</div>
)}
</Dropzone>
<Dropzone onDrop={onDrop} noDrag>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<Button
variant="contained"
color="primaryDark"
border="primaryActive"
disabled={false}
onClick={() => {}}
>
{'Add folder'}
<input
{...getInputProps()}
webkitdirectory="true"
mozdirectory="true"
/>
</Button>
</div>
)}
</Dropzone>
</div>
<div className="pt-5">or drag images or folders here</div>
<div className="pt-3 text-aqua-pale text-lg">
(DICOM files supported)
</div>
</div>
)}
</Dropzone>
);
};
return (
<>
{dicomFileUploaderArr.length ? (
<div className={classNames('h-[calc(100vh-300px)]', baseClassNames)}>
<DicomUploadProgress
dicomFileUploaderArr={Array.from(dicomFileUploaderArr)}
onComplete={onComplete}
/>
</div>
) : (
<div className={classNames('h-[480px]', baseClassNames)}>
{getDropZoneComponent()}
</div>
)}
</>
);
}
DicomUpload.propTypes = {
dataSource: PropTypes.object.isRequired,
onComplete: PropTypes.func.isRequired,
onStarted: PropTypes.func.isRequired,
};
export default DicomUpload;

View File

@ -0,0 +1,436 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
ReactElement,
} from 'react';
import PropTypes from 'prop-types';
import { Button, Icon, ProgressLoadingBar } from '@ohif/ui';
import DicomFileUploader, {
EVENTS,
UploadStatus,
DicomFileUploaderProgressEvent,
UploadRejection,
} from '../../utils/DicomFileUploader';
import DicomUploadProgressItem from './DicomUploadProgressItem';
import classNames from 'classnames';
type DicomUploadProgressProps = {
dicomFileUploaderArr: DicomFileUploader[];
onComplete: () => void;
};
const ONE_SECOND = 1000;
const ONE_MINUTE = ONE_SECOND * 60;
const ONE_HOUR = ONE_MINUTE * 60;
// The base/initial interval time length used to calculate the
// rate of the upload and in turn estimate the
// the amount of time remaining for the upload. This is the length
// of the very first interval to get a reasonable estimate on screen in
// a reasonable amount of time. The length of each interval after the first
// is based on the upload rate calculated. Faster rates use this base interval
// length. Slower rates below UPLOAD_RATE_THRESHOLD get longer interval times
// to obtain more accurate upload rates.
const BASE_INTERVAL_TIME = 15000;
// The upload rate threshold to determine the length of the interval to
// calculate the upload rate.
const UPLOAD_RATE_THRESHOLD = 75;
const NO_WRAP_ELLIPSIS_CLASS_NAMES =
'text-ellipsis whitespace-nowrap overflow-hidden';
function DicomUploadProgress({
dicomFileUploaderArr,
onComplete,
}: DicomUploadProgressProps): ReactElement {
const [totalUploadSize] = useState(
dicomFileUploaderArr.reduce(
(acc, fileUploader) => acc + fileUploader.getFileSize(),
0
)
);
const currentUploadSizeRef = useRef<number>(0);
const uploadRateRef = useRef(0);
const [timeRemaining, setTimeRemaining] = useState<number>(null);
const [percentComplete, setPercentComplete] = useState(0);
const [numFilesCompleted, setNumFilesCompleted] = useState(0);
const [numFails, setNumFails] = useState(0);
const [showFailedOnly, setShowFailedOnly] = useState(false);
const progressBarContainerRef = useRef<HTMLElement>();
/**
* The effect for measuring and setting the current upload rate. This is
* done by measuring the amount of data uploaded in a set interval time.
*/
useEffect(() => {
let timeoutId: NodeJS.Timeout;
// The amount of data already uploaded at the start of the interval.
let intervalStartUploadSize = 0;
// The starting time of the interval.
let intervalStartTime = Date.now();
const setUploadRateRef = () => {
const uploadSizeFromStartOfInterval =
currentUploadSizeRef.current - intervalStartUploadSize;
const now = Date.now();
const timeSinceStartOfInterval = now - intervalStartTime;
// Calculate and set the upload rate (ref)
uploadRateRef.current =
uploadSizeFromStartOfInterval / timeSinceStartOfInterval;
// Reset the interval starting values.
intervalStartUploadSize = currentUploadSizeRef.current;
intervalStartTime = now;
// Only start a new interval if there is more to upload.
if (totalUploadSize - currentUploadSizeRef.current > 0) {
if (uploadRateRef.current >= UPLOAD_RATE_THRESHOLD) {
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
} else {
// The current upload rate is relatively slow, so use a larger
// time interval to get a better upload rate estimate.
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME * 2);
}
}
};
// The very first interval is just the base time interval length.
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
return () => {
clearTimeout(timeoutId);
};
}, []);
/**
* The effect for: updating the overall percentage complete; setting the
* estimated time remaining; updating the number of files uploaded; and
* detecting if any error has occurred.
*/
useEffect(() => {
let currentTimeRemaining = null;
// For each uploader, listen for the progress percentage complete and
// add promise catch/finally callbacks to detect errors and count number
// of uploads complete.
const subscriptions = dicomFileUploaderArr.map(fileUploader => {
let currentFileUploadSize = 0;
const updateProgress = (percentComplete: number) => {
const previousFileUploadSize = currentFileUploadSize;
currentFileUploadSize = Math.round(
(percentComplete / 100) * fileUploader.getFileSize()
);
currentUploadSizeRef.current = Math.min(
totalUploadSize,
currentUploadSizeRef.current -
previousFileUploadSize +
currentFileUploadSize
);
setPercentComplete(
(currentUploadSizeRef.current / totalUploadSize) * 100
);
if (uploadRateRef.current !== 0) {
const uploadSizeRemaining =
totalUploadSize - currentUploadSizeRef.current;
const timeRemaining = Math.round(
uploadSizeRemaining / uploadRateRef.current
);
if (currentTimeRemaining === null) {
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
return;
}
// Do not show an increase in the time remaining by two seconds or minutes
// so as to prevent jumping the time remaining up and down constantly
// due to rounding, inaccuracies in the estimate and slight variations
// in upload rates over time.
if (timeRemaining < ONE_MINUTE) {
const currentSecondsRemaining = Math.ceil(
currentTimeRemaining / ONE_SECOND
);
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
const delta = secondsRemaining - currentSecondsRemaining;
if (delta < 0 || delta > 2) {
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
}
return;
}
if (timeRemaining < ONE_HOUR) {
const currentMinutesRemaining = Math.ceil(
currentTimeRemaining / ONE_MINUTE
);
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
const delta = minutesRemaining - currentMinutesRemaining;
if (delta < 0 || delta > 2) {
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
}
return;
}
// Hours remaining...
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
}
};
const progressCallback = (
progressEvent: DicomFileUploaderProgressEvent
) => {
updateProgress(progressEvent.percentComplete);
};
// Use the uploader promise to flag any error and count the number of
// uploads completed.
fileUploader
.load()
.catch((rejection: UploadRejection) => {
if (rejection.status === UploadStatus.Failed) {
setNumFails(numFails => numFails + 1);
}
})
.finally(() => {
// If any error occurred, the percent complete progress stops firing
// but this call to updateProgress nicely puts all finished uploads at 100%.
updateProgress(100);
setNumFilesCompleted(numCompleted => numCompleted + 1);
});
return fileUploader.subscribe(EVENTS.PROGRESS, progressCallback);
});
return () => {
subscriptions.forEach(subscription => subscription.unsubscribe());
};
}, []);
const cancelAllUploads = useCallback(async () => {
for (const dicomFileUploader of dicomFileUploaderArr) {
// Important: we need a non-blocking way to cancel every upload,
// otherwise the UI will freeze and the user will not be able
// to interact with the app and progress will not be updated.
const promise = new Promise<void>((resolve, reject) => {
setTimeout(() => {
dicomFileUploader.cancel();
resolve();
}, 0);
});
}
}, []);
const getFormattedTimeRemaining = useCallback((): string => {
if (timeRemaining == null) {
return '';
}
if (timeRemaining < ONE_MINUTE) {
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
return `${secondsRemaining} ${
secondsRemaining === 1 ? 'second' : 'seconds'
}`;
}
if (timeRemaining < ONE_HOUR) {
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
return `${minutesRemaining} ${
minutesRemaining === 1 ? 'minute' : 'minutes'
}`;
}
const hoursRemaining = Math.ceil(timeRemaining / ONE_HOUR);
return `${hoursRemaining} ${hoursRemaining === 1 ? 'hour' : 'hours'}`;
}, [timeRemaining]);
const getPercentCompleteRounded = useCallback(
() => Math.min(100, Math.round(percentComplete)),
[percentComplete]
);
/**
* Determines if the progress bar should show the infinite animation or not.
* Show the infinite animation for progress less than 1% AND if less than
* one pixel of the progress bar would be displayed.
*/
const showInfiniteProgressBar = useCallback((): boolean => {
return (
getPercentCompleteRounded() < 1 &&
(progressBarContainerRef?.current?.offsetWidth ?? 0) *
(percentComplete / 100) <
1
);
}, [getPercentCompleteRounded, percentComplete]);
/**
* Gets the css style for the 'n of m' (files completed) text. The only css attribute
* of the style is width such that the 'n of m' is always a fixed width and thus
* as each file completes uploading the text on screen does not constantly shift
* left and right.
*/
const getNofMFilesStyle = useCallback(() => {
// the number of digits accounts for the digits being on each side of the ' of '
const numDigits = 2 * dicomFileUploaderArr.length.toString().length;
// the number of digits + 2 spaces and 2 characters for ' of '
const numChars = numDigits + 4;
return { width: `${numChars}ch` };
}, []);
const getNumCompletedAndTimeRemainingComponent = (): ReactElement => {
return (
<div className="text-lg px-1 pb-4 h-14 flex bg-primary-dark items-center">
{numFilesCompleted === dicomFileUploaderArr.length ? (
<>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{`${
dicomFileUploaderArr.length
} ${
dicomFileUploaderArr.length > 1 ? 'files' : 'file'
} completed.`}</span>
<Button
variant="contained"
color="primary"
disabled={false}
className="ml-auto"
onClick={onComplete}
>
{'Close'}
</Button>
</>
) : (
<>
<span
style={getNofMFilesStyle()}
className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES, 'text-end')}
>
{`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}&nbsp;
</span>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
{' files completed.'}&nbsp;
</span>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
{timeRemaining
? `Less than ${getFormattedTimeRemaining()} remaining. `
: ''}
</span>
<span
className={classNames(
NO_WRAP_ELLIPSIS_CLASS_NAMES,
'cursor-pointer text-primary-active hover:text-primary-light active:text-aqua-pale ml-auto'
)}
onClick={cancelAllUploads}
>
Cancel All Uploads
</span>
</>
)}
</div>
);
};
const getShowFailedOnlyIconComponent = (): ReactElement => {
return (
<div className="ml-auto flex justify-center w-6">
{numFails > 0 && (
<div
onClick={() =>
setShowFailedOnly(currentShowFailedOnly => !currentShowFailedOnly)
}
>
<Icon className="cursor-pointer" name="icon-status-alert"></Icon>
</div>
)}
</div>
);
};
const getPercentCompleteComponent = (): ReactElement => {
return (
<div className="overflow-y-scroll ohif-scrollbar px-2 border-b border-secondary-light">
<div className="flex w-full p-2.5 items-center min-h-14">
{numFilesCompleted === dicomFileUploaderArr.length ? (
<>
<div className="text-xl text-primary-light">
{numFails > 0
? `Completed with ${numFails} ${
numFails > 1 ? 'errors' : 'error'
}!`
: 'Completed!'}
</div>
{getShowFailedOnlyIconComponent()}
</>
) : (
<>
<div ref={progressBarContainerRef} className="flex-grow">
<ProgressLoadingBar
progress={
showInfiniteProgressBar()
? undefined
: Math.min(100, percentComplete)
}
></ProgressLoadingBar>
</div>
<div className="w-24 ml-1 flex items-center">
<div className="w-10 text-right">{`${getPercentCompleteRounded()}%`}</div>
{getShowFailedOnlyIconComponent()}
</div>
</>
)}
</div>
</div>
);
};
return (
<div className="flex flex-col grow">
{getNumCompletedAndTimeRemainingComponent()}
<div className="flex flex-col bg-black text-lg overflow-hidden grow">
{getPercentCompleteComponent()}
<div className="overflow-y-scroll ohif-scrollbar px-2 grow h-1">
{dicomFileUploaderArr
.filter(
dicomFileUploader =>
!showFailedOnly ||
dicomFileUploader.getStatus() === UploadStatus.Failed
)
.map(dicomFileUploader => (
<DicomUploadProgressItem
key={dicomFileUploader.getFileId()}
dicomFileUploader={dicomFileUploader}
/>
))}
</div>
</div>
</div>
);
}
DicomUploadProgress.propTypes = {
dicomFileUploaderArr: PropTypes.arrayOf(
PropTypes.instanceOf(DicomFileUploader)
).isRequired,
onComplete: PropTypes.func.isRequired,
};
export default DicomUploadProgress;

View File

@ -0,0 +1,117 @@
import React, {
ReactElement,
memo,
useCallback,
useEffect,
useState,
} from 'react';
import PropTypes from 'prop-types';
import DicomFileUploader, {
DicomFileUploaderProgressEvent,
EVENTS,
UploadRejection,
UploadStatus,
} from '../../utils/DicomFileUploader';
import { Icon } from '@ohif/ui';
type DicomUploadProgressItemProps = {
dicomFileUploader: DicomFileUploader;
};
// eslint-disable-next-line react/display-name
const DicomUploadProgressItem = memo(
({ dicomFileUploader }: DicomUploadProgressItemProps): ReactElement => {
const [percentComplete, setPercentComplete] = useState(
dicomFileUploader.getPercentComplete()
);
const [failedReason, setFailedReason] = useState('');
const [status, setStatus] = useState(dicomFileUploader.getStatus());
console.info(`${dicomFileUploader.getFileId()}`);
const isComplete = useCallback(() => {
return (
status === UploadStatus.Failed ||
status === UploadStatus.Cancelled ||
status === UploadStatus.Success
);
}, [status]);
useEffect(() => {
const progressSubscription = dicomFileUploader.subscribe(
EVENTS.PROGRESS,
(dicomFileUploaderProgressEvent: DicomFileUploaderProgressEvent) => {
setPercentComplete(dicomFileUploaderProgressEvent.percentComplete);
}
);
dicomFileUploader
.load()
.catch((reason: UploadRejection) => {
setStatus(reason.status);
setFailedReason(reason.message ?? '');
})
.finally(() => setStatus(dicomFileUploader.getStatus()));
return () => progressSubscription.unsubscribe();
}, []);
const cancelUpload = useCallback(() => {
dicomFileUploader.cancel();
}, []);
const getStatusIcon = (): ReactElement => {
switch (dicomFileUploader.getStatus()) {
case UploadStatus.Success:
return (
<Icon name="status-tracked" className="text-primary-light"></Icon>
);
case UploadStatus.InProgress:
return <Icon name="icon-transferring"></Icon>;
case UploadStatus.Failed:
return <Icon name="icon-alert-small"></Icon>;
case UploadStatus.Cancelled:
return <Icon name="icon-alert-outline"></Icon>;
default:
return <></>;
}
};
return (
<div className="flex w-full p-2.5 text-lg min-h-14 items-center border-b border-secondary-light overflow-hidden">
<div className="flex flex-col gap-1 self-top w-0 grow shrink">
<div className="flex gap-4">
<div className="flex w-6 justify-center items-center shrink-0">
{getStatusIcon()}
</div>
<div className="text-ellipsis whitespace-nowrap overflow-hidden">
{dicomFileUploader.getFileName()}
</div>
</div>
{failedReason && <div className="pl-10">{failedReason}</div>}
</div>
<div className="w-24 flex items-center">
{!isComplete() && (
<>
{dicomFileUploader.getStatus() === UploadStatus.InProgress && (
<div className="w-10 text-right">{percentComplete}%</div>
)}
<div className="flex cursor-pointer ml-auto">
<Icon
className="w-6 h-6 self-center text-primary-active"
name="close"
onClick={cancelUpload}
/>
</div>
</>
)}
</div>
</div>
);
}
);
DicomUploadProgressItem.propTypes = {
dicomFileUploader: PropTypes.instanceOf(DicomFileUploader).isRequired,
};
export default DicomUploadProgressItem;

View File

@ -0,0 +1,45 @@
import { Enums } from '@cornerstonejs/tools';
import { toolNames } from './initCornerstoneTools';
import DicomUpload from './components/DicomUpload/DicomUpload';
const tools = {
active: [
{
toolName: toolNames.WindowLevel,
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
},
{
toolName: toolNames.Pan,
bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }],
},
{
toolName: toolNames.Zoom,
bindings: [{ mouseButton: Enums.MouseBindings.Secondary }],
},
{ toolName: toolNames.StackScrollMouseWheel, bindings: [] },
],
enabled: [{ toolName: toolNames.SegmentationDisplay }],
};
function getCustomizationModule() {
return [
{
name: 'cornerstoneDicomUploadComponent',
value: {
id: 'dicomUploadComponent',
component: DicomUpload,
},
},
{
name: 'default',
value: [
{
id: 'cornerstone.overlayViewportTools',
tools,
},
],
},
];
}
export default getCustomizationModule;

View File

@ -1,11 +1,12 @@
import { Types } from '@ohif/core';
const mpr: Types.HangingProtocol.Protocol = {
id: 'mpr',
name: 'Multi-Planar Reconstruction',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'mpr',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2023-02-17',
createdDate: '2021-02-23',
modifiedDate: '2023-04-03',
availableTo: {},
editableBy: {},
// Unknown number of priors referenced - so just match any study
@ -244,7 +245,7 @@ const mprAnd3DVolumeViewport = {
{
id: 'mprDisplaySet',
options: {
presetName: 'CT-Bone',
displayPreset: 'CT-Bone',
},
},
],
@ -303,7 +304,7 @@ const mprAnd3DVolumeViewport = {
function getHangingProtocolModule() {
return [
{
name: 'mpr',
name: mpr.id,
protocol: mpr,
},
{

View File

@ -7,26 +7,29 @@ import {
imageRetrievalPoolManager,
} from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { Types } from '@ohif/core';
import { ServicesManager, Types } from '@ohif/core';
import init from './init';
import getCustomizationModule from './getCustomizationModule';
import getCommandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import ToolGroupService from './services/ToolGroupService';
import SyncGroupService from './services/SyncGroupService';
import SegmentationService from './services/SegmentationService';
import CornerstoneCacheService from './services/CornerstoneCacheService';
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
import * as CornerstoneExtensionTypes from './types';
import { toolNames } from './initCornerstoneTools';
import { getEnabledElement, reset as enabledElementReset } from './state';
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
import dicomLoaderService from './utils/dicomLoaderService';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import { registerColormap } from './utils/colormap/transferFunctionHelpers';
import { id } from './id';
import * as csWADOImageLoader from './initWADOImageLoader.js';
import { measurementMappingUtils } from './utils/measurementServiceMappings';
import { PublicViewportOptions } from './services/ViewportService/Viewport';
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
const Component = React.lazy(() => {
return import(
@ -72,18 +75,11 @@ const cornerstoneExtension: Types.Extensions.Extension = {
props: Types.Extensions.ExtensionParams
): Promise<void> {
const { servicesManager } = props;
// Todo: we should be consistent with how services get registered. Use REGISTRATION static method for all
servicesManager.registerService(
CornerstoneViewportService(servicesManager)
);
servicesManager.registerService(
ToolGroupService.REGISTRATION(servicesManager)
);
servicesManager.registerService(SyncGroupService(servicesManager));
servicesManager.registerService(SegmentationService(servicesManager));
servicesManager.registerService(
CornerstoneCacheService.REGISTRATION(servicesManager)
);
servicesManager.registerService(CornerstoneViewportService.REGISTRATION);
servicesManager.registerService(ToolGroupService.REGISTRATION);
servicesManager.registerService(SyncGroupService.REGISTRATION);
servicesManager.registerService(SegmentationService.REGISTRATION);
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
return init.call(this, props);
},
@ -99,7 +95,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
return (
<OHIFCornerstoneViewport
{...props}
ToolbarService={toolbarService}
toolbarService={toolbarService}
servicesManager={servicesManager}
commandsManager={commandsManager}
/>
@ -114,6 +110,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
];
},
getCommandsModule,
getCustomizationModule,
getUtilityModule({ servicesManager }) {
return [
{
@ -124,7 +121,6 @@ const cornerstoneExtension: Types.Extensions.Extension = {
},
getEnabledElement,
dicomLoaderService,
registerColormap,
},
},
{
@ -145,5 +141,5 @@ const cornerstoneExtension: Types.Extensions.Extension = {
};
export type { PublicViewportOptions };
export { measurementMappingUtils };
export { measurementMappingUtils, CornerstoneExtensionTypes, toolNames , getActiveViewportEnabledElement};
export default cornerstoneExtension;

View File

@ -1,4 +1,4 @@
import OHIF from '@ohif/core';
import OHIF, { Types } from '@ohif/core';
import React from 'react';
import * as cornerstone from '@cornerstonejs/core';
@ -36,6 +36,7 @@ window.cornerstoneTools = cornerstoneTools;
export default async function init({
servicesManager,
commandsManager,
extensionManager,
configuration,
appConfig,
}: Types.Extensions.ExtensionParams): Promise<void> {
@ -83,6 +84,8 @@ export default async function init({
} = servicesManager.services;
window.services = servicesManager.services;
window.extensionManager = extensionManager;
window.commandsManager = commandsManager;
if (
appConfig.showWarningMessageForCrossOrigin &&

View File

@ -9,6 +9,7 @@ import {
LengthTool,
RectangleROITool,
EllipticalROITool,
CircleROITool,
BidirectionalTool,
ArrowAnnotateTool,
DragProbeTool,
@ -29,6 +30,9 @@ import {
import CalibrationLineTool from './tools/CalibrationLineTool';
export default function initCornerstoneTools(configuration = {}) {
CrosshairsTool.isAnnotation = false;
ReferenceLinesTool.isAnnotation = false;
init(configuration);
addTool(PanTool);
addTool(WindowLevelTool);
@ -41,6 +45,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(LengthTool);
addTool(RectangleROITool);
addTool(EllipticalROITool);
addTool(CircleROITool);
addTool(BidirectionalTool);
addTool(ArrowAnnotateTool);
addTool(DragProbeTool);
@ -83,6 +88,7 @@ const toolNames = {
Probe: ProbeTool.toolName,
RectangleROI: RectangleROITool.toolName,
EllipticalROI: EllipticalROITool.toolName,
CircleROI: CircleROITool.toolName,
Bidirectional: BidirectionalTool.toolName,
Angle: AngleTool.toolName,
CobbAngle: CobbAngleTool.toolName,

View File

@ -24,6 +24,7 @@ const initMeasurementService = (
Length,
Bidirectional,
EllipticalROI,
CircleROI,
ArrowAnnotate,
Angle,
CobbAngle,
@ -64,6 +65,14 @@ const initMeasurementService = (
EllipticalROI.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'CircleROI',
CircleROI.matchingCriteria,
CircleROI.toAnnotation,
CircleROI.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'ArrowAnnotate',

View File

@ -1,9 +1,9 @@
import * as cornerstone from '@cornerstonejs/core';
import { volumeLoader } from '@cornerstonejs/core';
import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader';
import cornerstoneWADOImageLoader, {
import dicomImageLoader, {
webWorkerManager,
} from 'cornerstone-wado-image-loader';
} from '@cornerstonejs/dicom-image-loader';
import dicomParser from 'dicom-parser';
import { errorHandler } from '@ohif/core';
@ -28,7 +28,7 @@ function initWebWorkers(appConfig) {
};
if (!initialized) {
cornerstoneWADOImageLoader.webWorkerManager.initialize(config);
dicomImageLoader.webWorkerManager.initialize(config);
initialized = true;
}
}
@ -37,18 +37,18 @@ export default function initWADOImageLoader(
userAuthenticationService,
appConfig
) {
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.dicomParser = dicomParser;
dicomImageLoader.external.cornerstone = cornerstone;
dicomImageLoader.external.dicomParser = dicomParser;
registerVolumeLoader(
'cornerstoneStreamingImageVolume',
cornerstoneStreamingImageVolumeLoader
);
cornerstoneWADOImageLoader.configure({
dicomImageLoader.configure({
decodeConfig: {
// !! IMPORTANT !!
// We should set this flag to false, since, by default cornerstone-wado-image-loader
// We should set this flag to false, since, by default @cornerstonejs/dicom-image-loader
// will convert everything to integers (to be able to work with cornerstone-2d).
// Until the default is set to true (which is the case for cornerstone3D),
// we should set this flag to false.

View File

@ -1,5 +1,10 @@
import { ServicesManager } from '@ohif/core';
import { cache as cs3DCache, Enums, volumeLoader } from '@cornerstonejs/core';
import { ServicesManager, Types } from '@ohif/core';
import {
cache as cs3DCache,
Enums,
volumeLoader,
utilities as utils,
} from '@cornerstonejs/core';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import {
@ -10,20 +15,21 @@ import {
const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
class CornerstoneCacheService {
static REGISTRATION = (serviceManager: ServicesManager) => {
return {
name: 'cornerstoneCacheService',
altName: 'CornerstoneCacheService',
create: ({ configuration = {} }) => {
return new CornerstoneCacheService(serviceManager);
},
};
static REGISTRATION = {
name: 'cornerstoneCacheService',
altName: 'CornerstoneCacheService',
create: ({
servicesManager,
}: Types.Extensions.ExtensionParams): CornerstoneCacheService => {
return new CornerstoneCacheService(servicesManager);
},
};
stackImageIds: Map<string, string[]> = new Map();
volumeImageIds: Map<string, string[]> = new Map();
readonly servicesManager: ServicesManager;
constructor(servicesManager) {
constructor(servicesManager: ServicesManager) {
this.servicesManager = servicesManager;
}
@ -90,7 +96,10 @@ class CornerstoneCacheService {
displaySetService
) {
if (viewportData.viewportType === Enums.ViewportType.STACK) {
throw new Error('Invalidation of StackViewport is not supported yet');
return this._getCornerstoneStackImageIds(
displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID),
dataSource
);
}
// Todo: grab the volume and get the id from the viewport itself
@ -100,6 +109,7 @@ class CornerstoneCacheService {
if (volume) {
cs3DCache.removeVolumeLoadObject(volumeId);
this.volumeImageIds.delete(volumeId);
}
const displaySets = viewportData.data.map(({ displaySetInstanceUID }) =>
@ -133,13 +143,18 @@ class CornerstoneCacheService {
this.stackImageIds.set(displaySet.displaySetInstanceUID, stackImageIds);
}
const { displaySetInstanceUID, StudyInstanceUID } = displaySet;
const {
displaySetInstanceUID,
StudyInstanceUID,
isCompositeStack,
} = displaySet;
const StackViewportData: StackViewportData = {
viewportType,
data: {
StudyInstanceUID,
displaySetInstanceUID,
isCompositeStack,
imageIds: stackImageIds,
},
};

View File

@ -0,0 +1,38 @@
/**
* Maps a DICOM RT Struct ROI Contour to a RTStruct data that can be used
* in Segmentation Service
*
* @param structureSet - A DICOM RT Struct ROI Contour
* @param rtDisplaySetUID - A CornerstoneTools DisplaySet UID
* @returns An array of object that includes data, id, segmentIndex, color
* and geometry Id
*/
export function mapROIContoursToRTStructData(
structureSet: unknown,
rtDisplaySetUID: unknown
) {
return structureSet.ROIContours.map(
({ contourPoints, ROINumber, ROIName, colorArray }) => {
const data = contourPoints.map(({ points, ...rest }) => {
const newPoints = points.map(({ x, y, z }) => {
return [x, y, z];
});
return {
...rest,
points: newPoints,
};
});
const id = ROIName || ROINumber;
return {
data,
id,
segmentIndex: ROINumber,
color: colorArray,
geometryId: `${rtDisplaySetUID}:${id}:segmentIndex-${ROINumber}`,
};
}
);
}

View File

@ -29,6 +29,8 @@ type Segmentation = {
colorLUTIndex: number;
// if segmentation contains any data (often calculated from labelmap)
cachedStats: Record<string, any>;
// displaySetInstanceUID
displaySetInstanceUID: string;
// displayText is the text that is displayed on the segmentation panel (often derived from the data)
displayText?: string[];
// the id of the segmentation
@ -45,44 +47,22 @@ type Segmentation = {
segments: Array<Segment>;
// the set of segments that are locked
segmentsLocked: Array<number>;
// the segmentation representation type
type: csToolsEnums.SegmentationRepresentations;
// if labelmap, the id of the volume that the labelmap is associated with
volumeId?: string;
// whether the segmentation is hydrated or not (non-hydrated SEG -> temporary segmentation for display in SEG Viewport
// but hydrated SEG -> segmentation that is persisted in the store)
hydrated: boolean;
};
// Schema to generate a segmentation
type SegmentationSchema = {
// active segment index for the segmentation
activeSegmentIndex: number;
// statistics that are derived from the segmentation
cachedStats: Record<string, any>;
// the displayText for the segmentation in the panels
displayText?: string[];
// segmentation id
id: string;
// displaySetInstanceUID
displaySetInstanceUID: string;
// segmentation label
label: string;
// segment indices that are locked for the segmentation
segmentsLocked: Array<number>;
// the type of the segmentation (e.g., Labelmap etc.)
type: csToolsEnums.SegmentationRepresentations;
// the volume id of the volume that the labelmap is associated with, this only exists for the labelmap representation
volumeId: string;
// the referenced volumeURI for the segmentation
referencedVolumeURI: string;
// whether the segmentation is hydrated or not (non-hydrated SEG -> temporary segmentation for display in SEG Viewport
// but hydrated SEG -> segmentation that is persisted in the store)
hydrated: boolean;
// the number of segments in the segmentation
segmentCount: number;
// the array of segments with their details
segments: Array<Segment>;
// the segmentation representation data
representationData: SegmentationRepresentationData;
};
export { SegmentationConfig, Segment, Segmentation, SegmentationSchema };
type LabelmapSegmentationData = {
volumeId: string;
referencedVolumeId?: string;
};
type SegmentationRepresentationData = {
LABELMAP?: LabelmapSegmentationData;
};
export { SegmentationConfig, Segment, Segmentation };

View File

@ -1,11 +1,3 @@
import SegmentationService from './SegmentationService';
export default function ExtendedSegmentationService(servicesManager) {
return {
name: 'segmentationService',
altName: 'SegmentationService',
create: ({ configuration = {} }) => {
return new SegmentationService({ servicesManager });
},
};
}
export default SegmentationService;

View File

@ -4,7 +4,7 @@ import {
Synchronizer,
} from '@cornerstonejs/tools';
import { pubSubServiceInterface } from '@ohif/core';
import { pubSubServiceInterface, Types, ServicesManager } from '@ohif/core';
const EVENTS = {
TOOL_GROUP_CREATED: 'event::cornerstone::syncgroupservice:toolgroupcreated',
@ -37,7 +37,17 @@ const asSyncGroup = (syncGroup: string | SyncGroup): SyncGroup =>
typeof syncGroup === 'string' ? { type: syncGroup } : syncGroup;
export default class SyncGroupService {
serviceManager: any;
static REGISTRATION = {
name: 'syncGroupService',
altName: 'SyncGroupService',
create: ({
servicesManager,
}: Types.Extensions.ExtensionParams): SyncGroupService => {
return new SyncGroupService(servicesManager);
},
};
servicesManager: ServicesManager;
listeners: { [key: string]: (...args: any[]) => void } = {};
EVENTS: { [key: string]: string };
synchronizerCreators: Record<string, SyncCreator> = {
@ -47,8 +57,8 @@ export default class SyncGroupService {
[STACKIMAGE]: synchronizers.createStackImageSynchronizer,
};
constructor(serviceManager) {
this.serviceManager = serviceManager;
constructor(serviceManager: ServicesManager) {
this.servicesManager = serviceManager;
this.listeners = {};
this.EVENTS = EVENTS;
//

View File

@ -1,11 +1,3 @@
import SyncGroupService from './SyncGroupService';
export default function ExtendedSyncGroupService(serviceManager) {
return {
altName: 'SyncGroupService',
name: 'syncGroupService',
create: ({ configuration = {} }) => {
return new SyncGroupService(serviceManager);
},
};
}
export default SyncGroupService;

View File

@ -1,6 +1,7 @@
import { ToolGroupManager, Enums, Types } from '@cornerstonejs/tools';
import { pubSubServiceInterface } from '@ohif/core';
import { Types as OhifTypes, pubSubServiceInterface } from '@ohif/core';
import getActiveViewportEnabledElement from '../../utils/getActiveViewportEnabledElement';
const EVENTS = {
VIEWPORT_ADDED: 'event::cornerstone::toolgroupservice:viewportadded',
@ -20,14 +21,14 @@ type Tools = {
};
export default class ToolGroupService {
public static REGISTRATION = serviceManager => {
return {
name: 'toolGroupService',
altName: 'ToolGroupService',
create: ({ configuration = {} }) => {
return new ToolGroupService(serviceManager);
},
};
public static REGISTRATION = {
name: 'toolGroupService',
altName: 'ToolGroupService',
create: ({
servicesManager,
}: OhifTypes.Extensions.ExtensionParams): ToolGroupService => {
return new ToolGroupService(servicesManager);
},
};
serviceManager: any;
@ -39,20 +40,60 @@ export default class ToolGroupService {
EVENTS: { [key: string]: string };
constructor(serviceManager) {
const { cornerstoneViewportService } = serviceManager.services;
const {
cornerstoneViewportService,
viewportGridService,
} = serviceManager.services;
this.cornerstoneViewportService = cornerstoneViewportService;
this.viewportGridService = viewportGridService;
this.listeners = {};
this.EVENTS = EVENTS;
Object.assign(this, pubSubServiceInterface);
}
onModeExit() {
this.destroy();
}
/**
* Returns the cornerstone ToolGroup for a given toolGroup UID
* @param {string} toolGroupId - The toolGroup uid
* @returns {IToolGroup} - The toolGroup
* Retrieves a tool group from the ToolGroupManager by tool group ID.
* If no tool group ID is provided, it retrieves the tool group of the active viewport.
* @param toolGroupId - Optional ID of the tool group to retrieve.
* @returns The tool group or undefined if it is not found.
*/
public getToolGroup(toolGroupId: string): Types.IToolGroup | void {
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
public getToolGroup(toolGroupId?: string): Types.IToolGroup | void {
let toolGroupIdToUse = toolGroupId;
if (!toolGroupIdToUse) {
// Use the active viewport's tool group if no tool group id is provided
const enabledElement = getActiveViewportEnabledElement(
this.viewportGridService
);
if (!enabledElement) {
return;
}
const { renderingEngineId, viewportId } = enabledElement;
const toolGroup = ToolGroupManager.getToolGroupForViewport(
viewportId,
renderingEngineId
);
if (!toolGroup) {
console.warn(
'No tool group found for viewportId:',
viewportId,
'and renderingEngineId:',
renderingEngineId
);
return;
}
toolGroupIdToUse = toolGroup.id;
}
const toolGroup = ToolGroupManager.getToolGroup(toolGroupIdToUse);
return toolGroup;
}

View File

@ -1,4 +1,5 @@
import { PubSubService } from '@ohif/core';
import { PubSubService, ServicesManager } from '@ohif/core';
import * as OhifTypes from '@ohif/core/types';
import {
RenderingEngine,
StackViewport,
@ -10,9 +11,13 @@ import {
cache,
utilities,
CONSTANTS,
Enums as csEnums,
} from '@cornerstonejs/core';
import { utilities as csToolsUtils } from '@cornerstonejs/tools';
import {
utilities as csToolsUtils,
Enums as csToolsEnums,
} from '@cornerstonejs/tools';
import { IViewportService } from './IViewportService';
import { RENDERING_ENGINE_ID } from './constants';
import ViewportInfo, {
@ -25,10 +30,6 @@ import {
VolumeViewportData,
} from '../../types/CornerstoneCacheService';
import { Presentation, Presentations } from '../../types/Presentation';
import {
setColormap,
setLowerUpperColorTransferFunction,
} from '../../utils/colormap/transferFunctionHelpers';
import JumpPresets from '../../utils/JumpPresets';
@ -43,6 +44,16 @@ const EVENTS = {
*/
class CornerstoneViewportService extends PubSubService
implements IViewportService {
static REGISTRATION = {
name: 'cornerstoneViewportService',
altName: 'CornerstoneViewportService',
create: ({
servicesManager,
}: OhifTypes.Extensions.ExtensionParams): CornerstoneViewportService => {
return new CornerstoneViewportService(servicesManager);
},
};
renderingEngine: Types.IRenderingEngine | null;
viewportsInfo: Map<number, ViewportInfo> = new Map();
viewportsById: Map<string, ViewportInfo> = new Map();
@ -55,7 +66,7 @@ class CornerstoneViewportService extends PubSubService
resizeRefreshMode: 'debounce';
servicesManager = null;
constructor(servicesManager) {
constructor(servicesManager: ServicesManager) {
super(EVENTS);
this.renderingEngine = null;
this.viewportGridResizeObserver = null;
@ -71,7 +82,7 @@ class CornerstoneViewportService extends PubSubService
viewportIndex: number,
viewportOptions: PublicViewportOptions,
elementRef: HTMLDivElement
) {
): void {
// Use the provided viewportId
// Not providing a viewportId is frowned upon because it does weird things
// on moving them around, but it does mostly work.
@ -375,7 +386,7 @@ class CornerstoneViewportService extends PubSubService
initialImageIndexToUse === null
) {
initialImageIndexToUse =
this._getInitialImageIndexForStackViewport(viewportInfo, imageIds) || 0;
this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0;
}
const properties = { ...presentations.lutPresentation?.properties };
@ -401,7 +412,7 @@ class CornerstoneViewportService extends PubSubService
});
}
private _getInitialImageIndexForStackViewport(
private _getInitialImageIndexForViewport(
viewportInfo: ViewportInfo,
imageIds?: string[]
): number {
@ -412,7 +423,29 @@ class CornerstoneViewportService extends PubSubService
}
const { index, preset } = initialImageOptions;
return this._getInitialImageIndex(imageIds.length, index, preset);
const viewportType = viewportInfo.getViewportType();
let numberOfSlices;
if (viewportType === csEnums.ViewportType.STACK) {
numberOfSlices = imageIds.length;
} else if (viewportType === csEnums.ViewportType.ORTHOGRAPHIC) {
const viewport = this.getCornerstoneViewport(
viewportInfo.getViewportId()
);
const imageSliceData = csUtils.getImageSliceDataForVolumeViewport(
viewport
);
if (!imageSliceData) {
return;
}
({ numberOfSlices } = imageSliceData);
} else {
return;
}
return this._getInitialImageIndex(numberOfSlices, index, preset);
}
_getInitialImageIndex(
@ -488,16 +521,9 @@ class CornerstoneViewportService extends PubSubService
const displaySetOptions = displaySetOptionsArray[index];
const { volumeId } = volume;
const voiCallbacks = this._getVOICallbacks(volumeId, displaySetOptions);
const callback = ({ volumeActor }) => {
voiCallbacks.forEach(callback => callback(volumeActor));
};
volumeInputArray.push({
imageIds,
volumeId,
callback,
blendMode: displaySetOptions.blendMode,
slabThickness: this._getSlabThickness(displaySetOptions, volumeId),
});
@ -537,120 +563,77 @@ class CornerstoneViewportService extends PubSubService
) {
const {
displaySetService,
segmentationService,
toolGroupService,
} = this.servicesManager.services;
const viewportInfo = this.getViewportInfo(viewport.id);
const displaySetOptions = viewportInfo.getDisplaySetOptions();
// Todo: use presentations states
const volumesProperties = volumeInputArray.map((volumeInput, index) => {
const { volumeId } = volumeInput;
const displaySetOption = displaySetOptions[index];
const { voi, voiInverted, colormap, displayPreset } = displaySetOption;
const properties = {};
if (voi && (voi.windowWidth || voi.windowCenter)) {
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
voi.windowWidth,
voi.windowCenter
);
properties.voiRange = { lower, upper };
}
if (voiInverted !== undefined) {
properties.invert = voiInverted;
}
if (colormap !== undefined) {
properties.colormap = colormap;
}
if (displayPreset !== undefined) {
properties.preset = displayPreset;
}
return { properties, volumeId };
});
await viewport.setVolumes(volumeInputArray);
volumesProperties.forEach(({ properties, volumeId }) => {
viewport.setProperties(properties, volumeId);
});
this.setPresentations(viewport, presentations);
// load any secondary displaySets
const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id);
const segDisplaySet = displaySetInstanceUIDs
// can be SEG or RTSTRUCT for now
const overlayDisplaySet = displaySetInstanceUIDs
.map(displaySetService.getDisplaySetByUID)
.find(displaySet => displaySet && displaySet.Modality === 'SEG');
.find(displaySet => displaySet?.isOverlayDisplaySet);
if (segDisplaySet) {
const { referencedVolumeId } = segDisplaySet;
const referencedVolume = cache.getVolume(referencedVolumeId);
const segmentationId = segDisplaySet.displaySetInstanceUID;
const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id);
if (referencedVolume) {
segmentationService.addSegmentationRepresentationToToolGroup(
toolGroup.id,
segmentationId
);
}
if (overlayDisplaySet) {
this.addOverlayRepresentationForDisplaySet(overlayDisplaySet, viewport);
} else {
const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id);
const toolGroupSegmentationRepresentations =
segmentationService.getSegmentationRepresentationsForToolGroup(
toolGroup.id
) || [];
// csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id);
// If the displaySet is not a SEG displaySet we assume it is a primary displaySet
// and we can look into hydrated segmentations to check if any of them are
// associated with the primary displaySet
// get segmentations only returns the hydrated segmentations
const segmentations = segmentationService.getSegmentations();
for (const segmentation of segmentations) {
// if there is already a segmentation representation for this segmentation
// for this toolGroup, don't bother at all
if (
toolGroupSegmentationRepresentations.find(
representation => representation.segmentationId === segmentation.id
)
) {
continue;
}
// otherwise, check if the hydrated segmentations are in the same FOR
// as the primary displaySet, if so add the representation (since it was not there)
const { id: segDisplaySetInstanceUID } = segmentation;
const segFrameOfReferenceUID = this._getFrameOfReferenceUID(
segDisplaySetInstanceUID
);
let shouldDisplaySeg = false;
for (const displaySetInstanceUID of displaySetInstanceUIDs) {
const primaryFrameOfReferenceUID = this._getFrameOfReferenceUID(
displaySetInstanceUID
);
if (segFrameOfReferenceUID === primaryFrameOfReferenceUID) {
shouldDisplaySeg = true;
break;
}
}
if (shouldDisplaySeg) {
const toolGroup = toolGroupService.getToolGroupForViewport(
viewport.id
);
segmentationService.addSegmentationRepresentationToToolGroup(
toolGroup.id,
segmentation.id
);
}
}
}
const viewportInfo = this.getViewportInfo(viewport.id);
if (!viewportInfo) {
console.warn('Viewport info not defined for', viewport.id);
this._addSegmentationRepresentationToToolGroupIfNecessary(
displaySetInstanceUIDs,
viewport
);
}
const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id);
csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id);
const initialImageOptions = viewportInfo.getInitialImageOptions();
if (
initialImageOptions &&
(initialImageOptions.preset !== undefined ||
initialImageOptions.index !== undefined)
) {
const { index, preset } = initialImageOptions;
const { numberOfSlices } = csUtils.getImageSliceDataForVolumeViewport(
viewport
);
const imageIndex = this._getInitialImageIndex(
numberOfSlices,
index,
preset
);
const imageIndex = this._getInitialImageIndexForViewport(viewportInfo);
if (imageIndex !== undefined) {
csToolsUtils.jumpToSlice(viewport.element, {
imageIndex,
});
@ -659,6 +642,96 @@ class CornerstoneViewportService extends PubSubService
viewport.render();
}
private _addSegmentationRepresentationToToolGroupIfNecessary(
displaySetInstanceUIDs: string[],
viewport: any
) {
const {
segmentationService,
toolGroupService,
} = this.servicesManager.services;
const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id);
// this only returns hydrated segmentations
const segmentations = segmentationService.getSegmentations();
for (const segmentation of segmentations) {
const toolGroupSegmentationRepresentations =
segmentationService.getSegmentationRepresentationsForToolGroup(
toolGroup.id
) || [];
// if there is already a segmentation representation for this segmentation
// for this toolGroup, don't bother at all
const isSegmentationInToolGroup = toolGroupSegmentationRepresentations.find(
representation => representation.segmentationId === segmentation.id
);
if (isSegmentationInToolGroup) {
continue;
}
// otherwise, check if the hydrated segmentations are in the same FOR
// as the primary displaySet, if so add the representation (since it was not there)
const { id: segDisplaySetInstanceUID, type } = segmentation;
const segFrameOfReferenceUID = this._getFrameOfReferenceUID(
segDisplaySetInstanceUID
);
let shouldDisplaySeg = false;
for (const displaySetInstanceUID of displaySetInstanceUIDs) {
const primaryFrameOfReferenceUID = this._getFrameOfReferenceUID(
displaySetInstanceUID
);
if (segFrameOfReferenceUID === primaryFrameOfReferenceUID) {
shouldDisplaySeg = true;
break;
}
}
if (!shouldDisplaySeg) {
return;
}
segmentationService.addSegmentationRepresentationToToolGroup(
toolGroup.id,
segmentation.id,
false, // already hydrated,
segmentation.type
);
}
}
private addOverlayRepresentationForDisplaySet(
displaySet: any,
viewport: any
) {
const {
segmentationService,
toolGroupService,
} = this.servicesManager.services;
const { referencedVolumeId } = displaySet;
const segmentationId = displaySet.displaySetInstanceUID;
const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id);
const representationType =
referencedVolumeId && cache.getVolume(referencedVolumeId) !== undefined
? csToolsEnums.SegmentationRepresentations.Labelmap
: csToolsEnums.SegmentationRepresentations.Contour;
segmentationService.addSegmentationRepresentationToToolGroup(
toolGroup.id,
segmentationId,
false,
representationType
);
}
// Todo: keepCamera is an interim solution until we have a better solution for
// keeping the camera position when the viewport data is changed
public updateViewport(
@ -689,50 +762,6 @@ class CornerstoneViewportService extends PubSubService
}
}
_getVOICallbacks(volumeId, displaySetOptions) {
const {
voi,
voiInverted: inverted,
colormap,
presetName,
} = displaySetOptions;
const voiCallbackArray = [];
// If colormap is set, use it to set the color transfer function
if (colormap) {
voiCallbackArray.push(volumeActor => setColormap(volumeActor, colormap));
}
if (voi instanceof Object && voi.windowWidth && voi.windowCenter) {
const { windowWidth, windowCenter } = voi;
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
windowWidth,
windowCenter
);
voiCallbackArray.push(volumeActor =>
setLowerUpperColorTransferFunction({
volumeActor,
lower,
upper,
inverted,
})
);
}
if (presetName) {
voiCallbackArray.push(volumeActor => {
utilities.applyPreset(
volumeActor,
CONSTANTS.VIEWPORT_PRESETS.find(preset => {
return preset.name === presetName;
})
);
});
}
return voiCallbackArray;
}
_setDisplaySets(
viewport: StackViewport | VolumeViewport,
viewportData: StackViewportData | VolumeViewportData,
@ -848,21 +877,44 @@ class CornerstoneViewportService extends PubSubService
return instance.FrameOfReferenceUID;
}
if (displaySet.Modality === 'RTSTRUCT') {
const { instance } = displaySet;
return instance.ReferencedFrameOfReferenceSequence.FrameOfReferenceUID;
}
const { images } = displaySet;
if (images && images.length) {
return images[0].FrameOfReferenceUID;
}
}
/**
* Looks through the viewports to see if the specified measurement can be
* displayed in one of the viewports.
*
* @param measurement
* The measurement that is desired to view.
* @param activeViewportIndex - the index that was active at the time the jump
* was initiated.
* @return the viewportIndex to display the given measurement
*/
public getViewportIndexToJump(
activeViewportIndex: number,
displaySetInstanceUID: string,
cameraProps: unknown
): number {
const viewportInfo = this.viewportsInfo.get(activeViewportIndex);
const { referencedImageId } = cameraProps;
if (viewportInfo?.contains(displaySetInstanceUID, referencedImageId)) {
return activeViewportIndex;
}
return (
[...this.viewportsById.values()].find(viewportInfo =>
viewportInfo.contains(displaySetInstanceUID, referencedImageId)
)?.viewportIndex ?? -1
);
}
}
export default function CornerstoneViewportServiceRegistration(serviceManager) {
return {
name: 'cornerstoneViewportService',
altName: 'CornerstoneViewportService',
create: ({ configuration = {} }) => {
return new CornerstoneViewportService(serviceManager);
},
};
}
export { CornerstoneViewportService, CornerstoneViewportServiceRegistration };
export default CornerstoneViewportService;

View File

@ -63,7 +63,7 @@ export type PublicDisplaySetOptions = {
blendMode?: string;
slabThickness?: number;
colormap?: string;
presetName?: string;
displayPreset?: string;
};
export type DisplaySetOptions = {
@ -73,7 +73,7 @@ export type DisplaySetOptions = {
blendMode?: Enums.BlendModes;
slabThickness?: number;
colormap?: string;
presetName?: string;
displayPreset?: string;
};
type VOI = {
@ -88,6 +88,20 @@ export type DisplaySet = {
const STACK = 'stack';
const DEFAULT_TOOLGROUP_ID = 'default';
// Return true if the data contains the given display set UID OR the imageId
// if it is a composite object.
const dataContains = (
data,
displaySetUID: string,
imageId?: string
): boolean => {
if (data.displaySetInstanceUID === displaySetUID) return true;
if (imageId && data.isCompositeStack && data.imageIds) {
return !!data.imageIds.find(dataId => dataId === imageId);
}
return false;
};
class ViewportInfo {
private viewportId = '';
private viewportIndex: number;
@ -104,6 +118,21 @@ class ViewportInfo {
this.setPublicDisplaySetOptions([{}]);
}
/**
* Return true if the viewport contains the given display set UID,
* OR if it is a composite stack and contains the given imageId
*/
public contains(displaySetUID: string, imageId: string): boolean {
if (!this.viewportData?.data) return false;
if (this.viewportData.data.length) {
return !!this.viewportData.data.find(data =>
dataContains(data, displaySetUID, imageId)
);
}
return dataContains(this.viewportData.data, displaySetUID, imageId);
}
public destroy = (): void => {
this.element = null;
this.viewportData = null;
@ -203,6 +232,10 @@ class ViewportInfo {
orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation);
}
if (!toolGroupId) {
toolGroupId = DEFAULT_TOOLGROUP_ID;
}
this.setViewportOptions({
...viewportOptionsEntry,
viewportId: this.viewportId,
@ -228,7 +261,8 @@ class ViewportInfo {
}
public getSyncGroups(): SyncGroup[] {
return this.viewportOptions.syncGroups || [];
this.viewportOptions.syncGroups ||= [];
return this.viewportOptions.syncGroups;
}
public getDisplaySetOptions(): Array<DisplaySetOptions> {
@ -281,7 +315,7 @@ class ViewportInfo {
colormap: option.colormap,
slabThickness: option.slabThickness,
blendMode,
presetName: option.presetName,
displayPreset: option.displayPreset,
});
});

View File

@ -1,12 +1,12 @@
import {
Enums,
Types,
} from '@cornerstonejs/core';
import { Enums, Types } from '@cornerstonejs/core';
type StackData = {
StudyInstanceUID: string;
displaySetInstanceUID: string;
// A composite stack is one created from other display sets - kind of like
// madeInClient, but specific to indicating that the imageIds can come from
// different series or even studies.
isCompositeStack?: boolean;
imageIds: string[];
frameRate?: number;
isClip?: boolean;
@ -20,19 +20,14 @@ type VolumeData = {
imageIds?: string[];
};
type StackViewportData = {
type StackViewportData = {
viewportType: Enums.ViewportType;
data: StackData;
};
type VolumeViewportData = {
type VolumeViewportData = {
viewportType: Enums.ViewportType;
data: VolumeData[];
};
export type {
StackViewportData,
VolumeViewportData,
StackData,
VolumeData,
};
export type { StackViewportData, VolumeViewportData, StackData, VolumeData };

View File

@ -0,0 +1,16 @@
import { Types } from '@ohif/core';
import ToolGroupService from './services/ToolGroupService';
import SyncGroupService from './services/SyncGroupService';
import SegmentationService from './services/SegmentationService';
import CornerstoneCacheService from './services/CornerstoneCacheService';
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
interface CornerstoneServices extends Types.Services {
cornerstoneViewportService: CornerstoneViewportService;
toolGroupService: ToolGroupService;
syncGroupService: SyncGroupService;
segmentationService: SegmentationService;
cornerstoneCacheService: CornerstoneCacheService;
}
export default CornerstoneServices;

View File

@ -1,5 +1,7 @@
import * as CornerstoneCacheService from './CornerstoneCacheService'
import CornerstoneServices from './CornerstoneServices';
export type {
CornerstoneCacheService
CornerstoneCacheService,
CornerstoneServices,
}

View File

@ -217,7 +217,7 @@ const CornerstoneViewportDownloadForm = ({
);
// add the viewport to the toolGroup
toolGroup.addViewport(downloadViewportId);
toolGroup.addViewport(downloadViewportId, renderingEngineId);
Object.keys(toolGroup._toolInstances).forEach(toolName => {
// make all tools Enabled so that they can not be interacted with

View File

@ -0,0 +1,220 @@
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import { PubSubService } from '@ohif/core';
export const EVENTS = {
PROGRESS: 'event:DicomFileUploader:progress',
};
export interface DicomFileUploaderEvent {
fileId: number;
}
export interface DicomFileUploaderProgressEvent extends DicomFileUploaderEvent {
percentComplete: number;
}
export enum UploadStatus {
NotStarted,
InProgress,
Success,
Failed,
Cancelled,
}
type CancelOrFailed = UploadStatus.Cancelled | UploadStatus.Failed;
export class UploadRejection {
message: string;
status: CancelOrFailed;
constructor(status: CancelOrFailed, message: string) {
this.message = message;
this.status = status;
}
}
export default class DicomFileUploader extends PubSubService {
private _file;
private _fileId;
private _dataSource;
private _loadPromise;
private _abortController = new AbortController();
private _status: UploadStatus = UploadStatus.NotStarted;
private _percentComplete = 0;
constructor(file, dataSource) {
super(EVENTS);
this._file = file;
this._fileId = dicomImageLoader.wadouri.fileManager.add(file);
this._dataSource = dataSource;
}
getFileId(): string {
return this._fileId;
}
getFileName(): string {
return this._file.name;
}
getFileSize(): number {
return this._file.size;
}
cancel(): void {
this._abortController.abort();
}
getStatus(): UploadStatus {
return this._status;
}
getPercentComplete(): number {
return this._percentComplete;
}
async load(): Promise<void> {
if (this._loadPromise) {
// Already started loading, return the load promise.
return this._loadPromise;
}
this._loadPromise = new Promise<void>((resolve, reject) => {
// The upload listeners: fire progress events and/or settle the promise.
const uploadCallbacks = {
progress: evt => {
if (!evt.lengthComputable) {
// Progress computation is not possible.
return;
}
this._status = UploadStatus.InProgress;
this._percentComplete = Math.round((100 * evt.loaded) / evt.total);
this._broadcastEvent(EVENTS.PROGRESS, {
fileId: this._fileId,
percentComplete: this._percentComplete,
});
},
timeout: () => {
this._reject(
reject,
new UploadRejection(UploadStatus.Failed, 'The request timed out.')
);
},
abort: () => {
this._reject(
reject,
new UploadRejection(UploadStatus.Cancelled, 'Cancelled')
);
},
error: () => {
this._reject(
reject,
new UploadRejection(UploadStatus.Failed, 'The request failed.')
);
},
};
// First try to load the file.
dicomImageLoader.wadouri
.loadFileRequest(this._fileId)
.then(dicomFile => {
if (this._abortController.signal.aborted) {
this._reject(
reject,
new UploadRejection(UploadStatus.Cancelled, 'Cancelled')
);
return;
}
if (!this._checkDicomFile(dicomFile)) {
// The file is not DICOM
this._reject(
reject,
new UploadRejection(
UploadStatus.Failed,
'Not a valid DICOM file.'
)
);
return;
}
const request = new XMLHttpRequest();
this._addRequestCallbacks(request, uploadCallbacks);
// Do the actual upload by supplying the DICOM file and upload callbacks/listeners.
return this._dataSource.store
.dicom(dicomFile, request)
.then(() => {
this._status = UploadStatus.Success;
resolve();
})
.catch(reason => {
this._reject(reject, reason);
});
})
.catch(reason => {
this._reject(reject, reason);
});
});
return this._loadPromise;
}
private _isRejected(): boolean {
return (
this._status === UploadStatus.Failed ||
this._status === UploadStatus.Cancelled
);
}
private _reject(reject: (reason?: any) => void, reason: any) {
if (this._isRejected()) {
return;
}
if (reason instanceof UploadRejection) {
this._status = reason.status;
reject(reason);
return;
}
this._status = UploadStatus.Failed;
if (reason.message) {
reject(new UploadRejection(UploadStatus.Failed, reason.message));
return;
}
reject(new UploadRejection(UploadStatus.Failed, reason));
}
private _addRequestCallbacks(request: XMLHttpRequest, uploadCallbacks) {
const abortCallback = () => request.abort();
this._abortController.signal.addEventListener('abort', abortCallback);
for (const [eventName, callback] of Object.entries(uploadCallbacks)) {
request.upload.addEventListener(eventName, callback);
}
const cleanUpCallback = () => {
this._abortController.signal.removeEventListener('abort', abortCallback);
for (const [eventName, callback] of Object.entries(uploadCallbacks)) {
request.upload.removeEventListener(eventName, callback);
}
request.removeEventListener('loadend', cleanUpCallback);
};
request.addEventListener('loadend', cleanUpCallback);
}
private _checkDicomFile(arrayBuffer: ArrayBuffer) {
if (arrayBuffer.length <= 132) return false;
const arr = new Uint8Array(arrayBuffer.slice(128, 132));
// bytes from 128 to 132 must be "DICM"
return Array.from('DICM').every((char, i) => char.charCodeAt(0) === arr[i]);
}
}

View File

@ -1,139 +0,0 @@
import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
import presets from './presets';
// get preset from preset name
function getPreset(name) {
return presets.find(preset => preset.name === name);
}
// Todo: we should be able to register a new preset via the utilityModule
// of the cornerstone extension
export default function applyPreset(actor, presetName) {
const preset = getPreset(presetName);
// Create color transfer function
const colorTransferArray = preset.colorTransfer
.split(' ')
.splice(1)
.map(parseFloat);
const { shiftRange } = getShiftRange(colorTransferArray);
let min = shiftRange[0];
const width = shiftRange[1] - shiftRange[0];
const cfun = vtkColorTransferFunction.newInstance();
const normColorTransferValuePoints = [];
for (let i = 0; i < colorTransferArray.length; i += 4) {
let value = colorTransferArray[i];
const r = colorTransferArray[i + 1];
const g = colorTransferArray[i + 2];
const b = colorTransferArray[i + 3];
value = (value - min) / width;
normColorTransferValuePoints.push([value, r, g, b]);
}
applyPointsToRGBFunction(normColorTransferValuePoints, shiftRange, cfun);
actor.getProperty().setRGBTransferFunction(0, cfun);
// Create scalar opacity function
const scalarOpacityArray = preset.scalarOpacity
.split(' ')
.splice(1)
.map(parseFloat);
const ofun = vtkPiecewiseFunction.newInstance();
const normPoints = [];
for (let i = 0; i < scalarOpacityArray.length; i += 2) {
let value = scalarOpacityArray[i];
const opacity = scalarOpacityArray[i + 1];
value = (value - min) / width;
normPoints.push([value, opacity]);
}
applyPointsToPiecewiseFunction(normPoints, shiftRange, ofun);
actor.getProperty().setScalarOpacity(0, ofun);
const [
gradientMinValue,
gradientMinOpacity,
gradientMaxValue,
gradientMaxOpacity,
] = preset.gradientOpacity
.split(' ')
.splice(1)
.map(parseFloat);
actor.getProperty().setUseGradientOpacity(0, true);
actor.getProperty().setGradientOpacityMinimumValue(0, gradientMinValue);
actor.getProperty().setGradientOpacityMinimumOpacity(0, gradientMinOpacity);
actor.getProperty().setGradientOpacityMaximumValue(0, gradientMaxValue);
actor.getProperty().setGradientOpacityMaximumOpacity(0, gradientMaxOpacity);
if (preset.interpolation === '1') {
actor.getProperty().setInterpolationTypeToFastLinear();
//actor.getProperty().setInterpolationTypeToLinear()
}
const ambient = parseFloat(preset.ambient);
//const shade = preset.shade === '1'
const diffuse = parseFloat(preset.diffuse);
const specular = parseFloat(preset.specular);
const specularPower = parseFloat(preset.specularPower);
//actor.getProperty().setShade(shade)
actor.getProperty().setAmbient(ambient);
actor.getProperty().setDiffuse(diffuse);
actor.getProperty().setSpecular(specular);
actor.getProperty().setSpecularPower(specularPower);
}
function getShiftRange(colorTransferArray) {
// Credit to paraview-glance
// https://github.com/Kitware/paraview-glance/blob/3fec8eeff31e9c19ad5b6bff8e7159bd745e2ba9/src/components/controls/ColorBy/script.js#L133
// shift range is original rgb/opacity range centered around 0
let min = Infinity;
let max = -Infinity;
for (let i = 0; i < colorTransferArray.length; i += 4) {
min = Math.min(min, colorTransferArray[i]);
max = Math.max(max, colorTransferArray[i]);
}
const center = (max - min) / 2;
return {
shiftRange: [-center, center],
min,
max,
};
}
function applyPointsToRGBFunction(points, range, cfun) {
const width = range[1] - range[0];
const rescaled = points.map(([x, r, g, b]) => [
x * width + range[0],
r,
g,
b,
]);
cfun.removeAllPoints();
rescaled.forEach(([x, r, g, b]) => cfun.addRGBPoint(x, r, g, b));
return rescaled;
}
function applyPointsToPiecewiseFunction(points, range, pwf) {
const width = range[1] - range[0];
const rescaled = points.map(([x, y]) => [x * width + range[0], y]);
pwf.removeAllPoints();
rescaled.forEach(([x, y]) => pwf.addPoint(x, y));
return rescaled;
}

View File

@ -1,29 +0,0 @@
// https://www.slicer.org/w/index.php/Slicer3:2010_GenericAnatomyColors#Lookup_table
const colors = [
{
integerLabel: 0,
textLabel: 'background',
color: [0, 0, 0, 0],
},
{
integerLabel: 1,
textLabel: 'tissue',
// color: [255, 174, 128, 255],
color: [255, 0, 0, 255],
},
{
integerLabel: 2,
textLabel: 'bone',
// color: [241, 214, 145, 255],
color: [0, 255, 0, 255],
},
{
integerLabel: 3,
textLabel: 'skin',
// color: [177, 122, 101, 255],
color: [0, 0, 255, 255],
},
// ....
];
export default colors;

View File

@ -1,435 +0,0 @@
const presets = [
{
name: 'CT-AAA',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'12 -3024 0 143.556 0 166.222 0.686275 214.389 0.696078 419.736 0.833333 3071 0.803922',
id: 'vtkMRMLVolumePropertyNode1',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'24 -3024 0 0 0 143.556 0.615686 0.356863 0.184314 166.222 0.882353 0.603922 0.290196 214.389 1 1 1 419.736 1 0.937033 0.954531 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '143.556 419.736',
},
{
name: 'CT-AAA2',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'16 -3024 0 129.542 0 145.244 0.166667 157.02 0.5 169.918 0.627451 395.575 0.8125 1578.73 0.8125 3071 0.8125',
id: 'vtkMRMLVolumePropertyNode2',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'32 -3024 0 0 0 129.542 0.54902 0.25098 0.14902 145.244 0.6 0.627451 0.843137 157.02 0.890196 0.47451 0.6 169.918 0.992157 0.870588 0.392157 395.575 1 0.886275 0.658824 1578.73 1 0.829256 0.957922 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '0 1600',
},
{
name: 'CT-Bone',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity: '8 -3024 0 -16.4458 0 641.385 0.715686 3071 0.705882',
id: 'vtkMRMLVolumePropertyNode3',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'16 -3024 0 0 0 -16.4458 0.729412 0.254902 0.301961 641.385 0.905882 0.815686 0.552941 3071 1 1 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '-16.4458 641.385',
},
{
name: 'CT-Bones',
gradientOpacity: '4 0 1 985.12 1',
specularPower: '1',
scalarOpacity: '8 -1000 0 152.19 0 278.93 0.190476 952 0.2',
id: 'vtkMRMLVolumePropertyNode4',
specular: '0',
shade: '1',
ambient: '0.2',
colorTransfer:
'20 -1000 0.3 0.3 1 -488 0.3 1 0.3 463.28 1 0 0 659.15 1 0.912535 0.0374849 953 1 0.3 0.3',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '152.19 952',
},
{
name: 'CT-Cardiac',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'12 -3024 0 -77.6875 0 94.9518 0.285714 179.052 0.553571 260.439 0.848214 3071 0.875',
id: 'vtkMRMLVolumePropertyNode5',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'24 -3024 0 0 0 -77.6875 0.54902 0.25098 0.14902 94.9518 0.882353 0.603922 0.290196 179.052 1 0.937033 0.954531 260.439 0.615686 0 0 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '-77.6875 260.439',
},
{
name: 'CT-Cardiac2',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'12 -3024 0 42.8964 0 163.488 0.428571 277.642 0.776786 1587 0.754902 3071 0.754902',
id: 'vtkMRMLVolumePropertyNode6',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'24 -3024 0 0 0 42.8964 0.54902 0.25098 0.14902 163.488 0.917647 0.639216 0.0588235 277.642 1 0.878431 0.623529 1587 1 1 1 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '42.8964 1587',
},
{
name: 'CT-Cardiac3',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'14 -3024 0 -86.9767 0 45.3791 0.169643 139.919 0.589286 347.907 0.607143 1224.16 0.607143 3071 0.616071',
id: 'vtkMRMLVolumePropertyNode7',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'28 -3024 0 0 0 -86.9767 0 0.25098 1 45.3791 1 0 0 139.919 1 0.894893 0.894893 347.907 1 1 0.25098 1224.16 1 1 1 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '-86.9767 1224.16',
},
{
name: 'CT-Chest-Contrast-Enhanced',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'10 -3024 0 67.0106 0 251.105 0.446429 439.291 0.625 3071 0.616071',
id: 'vtkMRMLVolumePropertyNode8',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'20 -3024 0 0 0 67.0106 0.54902 0.25098 0.14902 251.105 0.882353 0.603922 0.290196 439.291 1 0.937033 0.954531 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '67.0106 439.291',
},
{
name: 'CT-Chest-Vessels',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity:
'10 -3024 0 -1278.35 0 22.8277 0.428571 439.291 0.625 3071 0.616071',
id: 'vtkMRMLVolumePropertyNode9',
specular: '0',
shade: '1',
ambient: '0.2',
colorTransfer:
'20 -3024 0 0 0 -1278.35 0.54902 0.25098 0.14902 22.8277 0.882353 0.603922 0.290196 439.291 1 0.937033 0.954531 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '-1278.35 439.291',
},
{
name: 'CT-Coronary-Arteries',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity:
'12 -2048 0 136.47 0 159.215 0.258929 318.43 0.571429 478.693 0.776786 3661 1',
id: 'vtkMRMLVolumePropertyNode10',
specular: '0',
shade: '0',
ambient: '0.2',
colorTransfer:
'24 -2048 0 0 0 136.47 0 0 0 159.215 0.159804 0.159804 0.159804 318.43 0.764706 0.764706 0.764706 478.693 1 1 1 3661 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '136.47 478.693',
},
{
name: 'CT-Coronary-Arteries-2',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity:
'14 -2048 0 142.677 0 145.016 0.116071 192.174 0.5625 217.24 0.776786 384.347 0.830357 3661 0.830357',
id: 'vtkMRMLVolumePropertyNode11',
specular: '0',
shade: '1',
ambient: '0.2',
colorTransfer:
'28 -2048 0 0 0 142.677 0 0 0 145.016 0.615686 0 0.0156863 192.174 0.909804 0.454902 0 217.24 0.972549 0.807843 0.611765 384.347 0.909804 0.909804 1 3661 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '142.677 384.347',
},
{
name: 'CT-Coronary-Arteries-3',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'14 -2048 0 128.643 0 129.982 0.0982143 173.636 0.669643 255.884 0.857143 584.878 0.866071 3661 1',
id: 'vtkMRMLVolumePropertyNode12',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'28 -2048 0 0 0 128.643 0 0 0 129.982 0.615686 0 0.0156863 173.636 0.909804 0.454902 0 255.884 0.886275 0.886275 0.886275 584.878 0.968627 0.968627 0.968627 3661 1 1 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '128.643 584.878',
},
{
name: 'CT-Cropped-Volume-Bone',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity: '10 -2048 0 -451 0 -450 1 1050 1 3661 1',
id: 'vtkMRMLVolumePropertyNode13',
specular: '0',
shade: '0',
ambient: '0.2',
colorTransfer:
'20 -2048 0 0 0 -451 0 0 0 -450 0.0556356 0.0556356 0.0556356 1050 1 1 1 3661 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '-451 1050',
},
{
name: 'CT-Fat',
gradientOpacity: '6 0 1 985.12 1 988 1',
specularPower: '1',
scalarOpacity: '14 -1000 0 -100 0 -99 0.15 -60 0.15 -59 0 101.2 0 952 0',
id: 'vtkMRMLVolumePropertyNode14',
specular: '0',
references: '0',
shade: '0',
ambient: '0.2',
colorTransfer:
'36 -1000 0.3 0.3 1 -497.5 0.3 1 0.3 -99 0 0 1 -76.946 0 1 0 -65.481 0.835431 0.888889 0.0165387 83.89 1 0 0 463.28 1 0 0 659.15 1 0.912535 0.0374849 2952 1 0.300267 0.299886',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '-100 101.2',
},
{
name: 'CT-Liver-Vasculature',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity:
'14 -2048 0 149.113 0 157.884 0.482143 339.96 0.660714 388.526 0.830357 1197.95 0.839286 3661 0.848214',
id: 'vtkMRMLVolumePropertyNode15',
specular: '0',
shade: '0',
ambient: '0.2',
colorTransfer:
'28 -2048 0 0 0 149.113 0 0 0 157.884 0.501961 0.25098 0 339.96 0.695386 0.59603 0.36886 388.526 0.854902 0.85098 0.827451 1197.95 1 1 1 3661 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '149.113 1197.95',
},
{
name: 'CT-Lung',
gradientOpacity: '6 0 1 985.12 1 988 1',
specularPower: '1',
scalarOpacity: '12 -1000 0 -600 0 -599 0.15 -400 0.15 -399 0 2952 0',
id: 'vtkMRMLVolumePropertyNode16',
specular: '0',
references: '0',
shade: '1',
ambient: '0.2',
colorTransfer:
'24 -1000 0.3 0.3 1 -600 0 0 1 -530 0.134704 0.781726 0.0724558 -460 0.929244 1 0.109473 -400 0.888889 0.254949 0.0240258 2952 1 0.3 0.3',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '-600 -399',
},
{
name: 'CT-MIP',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity: '8 -3024 0 -637.62 0 700 1 3071 1',
id: 'vtkMRMLVolumePropertyNode17',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer: '16 -3024 0 0 0 -637.62 1 1 1 700 1 1 1 3071 1 1 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '-637.62 700',
},
{
name: 'CT-Muscle',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity:
'10 -3024 0 -155.407 0 217.641 0.676471 419.736 0.833333 3071 0.803922',
id: 'vtkMRMLVolumePropertyNode18',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'20 -3024 0 0 0 -155.407 0.54902 0.25098 0.14902 217.641 0.882353 0.603922 0.290196 419.736 1 0.937033 0.954531 3071 0.827451 0.658824 1',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '-155.407 419.736',
},
{
name: 'CT-Pulmonary-Arteries',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity:
'14 -2048 0 -568.625 0 -364.081 0.0714286 -244.813 0.401786 18.2775 0.607143 447.798 0.830357 3592.73 0.839286',
id: 'vtkMRMLVolumePropertyNode19',
specular: '0',
shade: '1',
ambient: '0.2',
colorTransfer:
'28 -2048 0 0 0 -568.625 0 0 0 -364.081 0.396078 0.301961 0.180392 -244.813 0.611765 0.352941 0.0705882 18.2775 0.843137 0.0156863 0.156863 447.798 0.752941 0.752941 0.752941 3592.73 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '-568.625 447.798',
},
{
name: 'CT-Soft-Tissue',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity: '10 -2048 0 -167.01 0 -160 1 240 1 3661 1',
id: 'vtkMRMLVolumePropertyNode20',
specular: '0',
shade: '0',
ambient: '0.2',
colorTransfer:
'20 -2048 0 0 0 -167.01 0 0 0 -160 0.0556356 0.0556356 0.0556356 240 1 1 1 3661 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '-167.01 240',
},
{
name: 'CT-Air',
gradientOpacity: '4 0 1 255 1',
specularPower: '10',
scalarOpacity: '8 -3024 0.705882 -900.0 0.715686 -500.0 0 3071 0',
id: 'vtkMRMLVolumePropertyNode21',
specular: '0.2',
shade: '1',
ambient: '0.1',
colorTransfer:
'16 -3024 1 1 1 -900.0 0.2 1.0 1.0 -500.0 0.3 0.3 1.0 3071 0 0 0 ',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '-1200.0 -200.0',
},
{
name: 'MR-Angio',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity:
'12 -2048 0 151.354 0 158.279 0.4375 190.112 0.580357 200.873 0.732143 3661 0.741071',
id: 'vtkMRMLVolumePropertyNode22',
specular: '0',
shade: '1',
ambient: '0.2',
colorTransfer:
'24 -2048 0 0 0 151.354 0 0 0 158.279 0.74902 0.376471 0 190.112 1 0.866667 0.733333 200.873 0.937255 0.937255 0.937255 3661 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '151.354 200.873',
},
{
name: 'MR-Default',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity: '12 0 0 20 0 40 0.15 120 0.3 220 0.375 1024 0.5',
id: 'vtkMRMLVolumePropertyNode23',
specular: '0',
shade: '1',
ambient: '0.2',
colorTransfer:
'24 0 0 0 0 20 0.168627 0 0 40 0.403922 0.145098 0.0784314 120 0.780392 0.607843 0.380392 220 0.847059 0.835294 0.788235 1024 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '0 220',
},
{
name: 'MR-MIP',
gradientOpacity: '4 0 1 255 1',
specularPower: '1',
scalarOpacity: '8 0 0 98.3725 0 416.637 1 2800 1',
id: 'vtkMRMLVolumePropertyNode24',
specular: '0',
shade: '0',
ambient: '0.2',
colorTransfer: '16 0 1 1 1 98.3725 1 1 1 416.637 1 1 1 2800 1 1 1',
selectable: 'true',
diffuse: '1',
interpolation: '1',
effectiveRange: '0 416.637',
},
{
name: 'MR-T2-Brain',
gradientOpacity: '4 0 1 160.25 1',
specularPower: '40',
scalarOpacity: '10 0 0 36.05 0 218.302 0.171429 412.406 1 641 1',
id: 'vtkMRMLVolumePropertyNode25',
specular: '0.5',
shade: '1',
ambient: '0.3',
colorTransfer:
'16 0 0 0 0 98.7223 0.956863 0.839216 0.192157 412.406 0 0.592157 0.807843 641 1 1 1',
selectable: 'true',
diffuse: '0.6',
interpolation: '1',
effectiveRange: '0 412.406',
},
{
name: 'DTI-FA-Brain',
gradientOpacity: '4 0 1 0.9950 1',
specularPower: '40',
scalarOpacity:
'16 0 0 0 0 0.3501 0.0158 0.49379 0.7619 0.6419 1 0.9920 1 0.9950 0 0.9950 0',
id: 'vtkMRMLVolumePropertyNode26',
specular: '0.5',
shade: '1',
ambient: '0.3',
colorTransfer:
'28 0 1 0 0 0 1 0 0 0.24974 0.4941 1 0 0.49949 0 0.9882 1 0.7492 0.51764 0 1 0.9950 1 0 0 0.9950 1 0 0',
selectable: 'true',
diffuse: '0.9',
interpolation: '1',
effectiveRange: '0 1',
},
];
export default presets;

View File

@ -1,93 +0,0 @@
import { cache, utilities } from '@cornerstonejs/core';
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
const colormaps = {};
export function registerColormap(colormap) {
colormaps[colormap.Name] = colormap;
}
function setColorTransferFunctionFromVolumeMetadata({
volumeActor,
volumeId,
inverted,
}) {
let lower, upper, windowWidth, windowCenter;
if (volumeId) {
const volume = cache.getVolume(volumeId);
const voiLutModule = volume.metadata.voiLut[0];
if (voiLutModule) {
windowWidth = voiLutModule.windowWidth;
windowCenter = voiLutModule.windowCenter;
}
} else {
windowWidth = 400;
windowCenter = 40;
}
if (windowWidth == undefined || windowCenter === undefined) {
// Set to something so we can window level it manually.
lower = 200;
upper = 400;
} else {
lower = windowCenter - windowWidth / 2.0;
upper = windowCenter + windowWidth / 2.0;
}
setLowerUpperColorTransferFunction({ volumeActor, lower, upper, inverted });
}
function setLowerUpperColorTransferFunction({
volumeActor,
lower,
upper,
inverted,
}) {
volumeActor
.getProperty()
.getRGBTransferFunction(0)
.setMappingRange(lower, upper);
if (inverted) {
utilities.invertRgbTransferFunction(
volumeActor.getProperty().getRGBTransferFunction(0)
);
}
}
function setColormap(volumeActor, colormap) {
const mapper = volumeActor.getMapper();
mapper.setSampleDistance(1.0);
const cfun = vtkColorTransferFunction.newInstance();
// if we have a custom colormap, use it
let preset;
if (colormaps[colormap]) {
preset = colormaps[colormap];
} else {
preset = vtkColorMaps.getPresetByName(colormap);
}
cfun.applyColorMap(preset);
cfun.setMappingRange(0, 5);
volumeActor.getProperty().setRGBTransferFunction(0, cfun);
// Create scalar opacity function
const ofun = vtkPiecewiseFunction.newInstance();
ofun.addPoint(0, 0.0);
ofun.addPoint(0.1, 0.9);
ofun.addPoint(5, 1.0);
volumeActor.getProperty().setScalarOpacity(0, ofun);
}
export {
setColormap,
setColorTransferFunctionFromVolumeMetadata,
setLowerUpperColorTransferFunction,
};

View File

@ -1,5 +1,5 @@
import { imageLoader } from '@cornerstonejs/core';
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import { api } from 'dicomweb-client';
import { DICOMWeb, errorHandler } from '@ohif/core';
@ -114,7 +114,7 @@ class DicomLoaderService {
}
if (!someInvalidStrings(imageId)) {
return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId);
return dicomImageLoader.wadouri.loadFileRequest(imageId);
}
}

View File

@ -11,7 +11,7 @@ export const findNearbyToolData = (commandsManager, evt) => {
}
const { element, currentPoints } = evt.detail;
return commandsManager.runCommand(
'getNearbyToolData',
'getNearbyAnnotation',
{
element,
canvasCoordinates: currentPoints?.canvas,

View File

@ -0,0 +1,228 @@
import SUPPORTED_TOOLS from './constants/supportedTools';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
import getModalityUnit from './utils/getModalityUnit';
import { utils } from '@ohif/core';
const CircleROI = {
toAnnotation: measurement => {},
toMeasurement: (
csToolsEventDetail,
DisplaySetService,
CornerstoneViewportService,
getValueTypeFromToolType
) => {
const { annotation, viewportId } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
if (!metadata || !data) {
console.warn('Length tool: Missing metadata or data');
return null;
}
const { toolName, referencedImageId, FrameOfReferenceUID } = metadata;
const validToolType = SUPPORTED_TOOLS.includes(toolName);
if (!validToolType) {
throw new Error('Tool not supported');
}
const {
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
} = getSOPInstanceAttributes(
referencedImageId,
CornerstoneViewportService,
viewportId
);
let displaySet;
if (SOPInstanceUID) {
displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
displaySet = DisplaySetService.getDisplaySetsForSeries(SeriesInstanceUID);
}
const { points } = data.handles;
const mappedAnnotations = getMappedAnnotations(
annotation,
DisplaySetService
);
const displayText = getDisplayText(mappedAnnotations, displaySet);
const getReport = () =>
_getReport(mappedAnnotations, points, FrameOfReferenceUID);
return {
uid: annotationUID,
SOPInstanceUID,
FrameOfReferenceUID,
points,
metadata,
referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID,
frameNumber: mappedAnnotations[0]?.frameNumber || 1,
toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: data.label,
displayText: displayText,
data: data.cachedStats,
type: getValueTypeFromToolType(toolName),
getReport,
};
},
};
function getMappedAnnotations(annotation, DisplaySetService) {
const { metadata, data } = annotation;
const { cachedStats } = data;
const { referencedImageId } = metadata;
const targets = Object.keys(cachedStats);
if (!targets.length) {
return [];
}
const annotations = [];
Object.keys(cachedStats).forEach(targetId => {
const targetStats = cachedStats[targetId];
if (!referencedImageId) {
// Todo: Non-acquisition plane measurement mapping not supported yet
throw new Error(
'Non-acquisition plane measurement mapping not supported'
);
}
const {
SOPInstanceUID,
SeriesInstanceUID,
frameNumber,
} = getSOPInstanceAttributes(referencedImageId);
const displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID,
frameNumber
);
const { SeriesNumber } = displaySet;
const { mean, stdDev, max, area, Modality } = targetStats;
const unit = getModalityUnit(Modality);
annotations.push({
SeriesInstanceUID,
SOPInstanceUID,
SeriesNumber,
frameNumber,
Modality,
unit,
mean,
stdDev,
max,
area,
});
});
return annotations;
}
/*
This function is used to convert the measurement data to a format that is
suitable for the report generation (e.g. for the csv report). The report
returns a list of columns and corresponding values.
*/
function _getReport(mappedAnnotations, points, FrameOfReferenceUID) {
const columns = [];
const values = [];
// Add Type
columns.push('AnnotationType');
values.push('Cornerstone:CircleROI');
mappedAnnotations.forEach(annotation => {
const { mean, stdDev, max, area, unit } = annotation;
if (!mean || !unit || !max || !area) {
return;
}
columns.push(
`max (${unit})`,
`mean (${unit})`,
`std (${unit})`,
`area (mm2)`
);
values.push(max, mean, stdDev, area);
});
if (FrameOfReferenceUID) {
columns.push('FrameOfReferenceUID');
values.push(FrameOfReferenceUID);
}
if (points) {
columns.push('points');
// points has the form of [[x1, y1, z1], [x2, y2, z2], ...]
// convert it to string of [[x1 y1 z1];[x2 y2 z2];...]
// so that it can be used in the csv report
values.push(points.map(p => p.join(' ')).join(';'));
}
return {
columns,
values,
};
}
function getDisplayText(mappedAnnotations, displaySet) {
if (!mappedAnnotations || !mappedAnnotations.length) {
return '';
}
const displayText = [];
// Area is the same for all series
const { area, SOPInstanceUID, frameNumber } = mappedAnnotations[0];
const instance = displaySet.images.find(
image => image.SOPInstanceUID === SOPInstanceUID
);
let InstanceNumber;
if (instance) {
InstanceNumber = instance.InstanceNumber;
}
const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : '';
const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : '';
// Area sometimes becomes undefined if `preventHandleOutsideImage` is off.
const roundedArea = utils.roundNumber(area || 0, 2);
displayText.push(`${roundedArea} mm<sup>2</sup>`);
// Todo: we need a better UI for displaying all these information
mappedAnnotations.forEach(mappedAnnotation => {
const { unit, max, SeriesNumber } = mappedAnnotation;
let maxStr = '';
if (max) {
const roundedMax = utils.roundNumber(max, 2);
maxStr = `Max: ${roundedMax} <small>${unit}</small> `;
}
const str = `${maxStr}(S:${SeriesNumber}${instanceText}${frameText})`;
if (!displayText.includes(str)) {
displayText.push(str);
}
});
return displayText;
}
export default CircleROI;

View File

@ -1,6 +1,6 @@
import SUPPORTED_TOOLS from './constants/supportedTools';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
import { displaySetService, utils } from '@ohif/core';
import { utils } from '@ohif/core';
const Length = {
toAnnotation: measurement => {},
@ -32,15 +32,12 @@ const Length = {
throw new Error('Tool not supported');
}
const {
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
} = getSOPInstanceAttributes(
referencedImageId,
cornerstoneViewportService,
viewportId
);
const { SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID } =
getSOPInstanceAttributes(
referencedImageId,
cornerstoneViewportService,
viewportId
);
let displaySet;
@ -104,11 +101,8 @@ function getMappedAnnotations(annotation, displaySetService) {
);
}
const {
SOPInstanceUID,
SeriesInstanceUID,
frameNumber,
} = getSOPInstanceAttributes(referencedImageId);
const { SOPInstanceUID, SeriesInstanceUID, frameNumber } =
getSOPInstanceAttributes(referencedImageId);
const displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
@ -179,12 +173,8 @@ function getDisplayText(mappedAnnotations, displaySet) {
const displayText = [];
// Area is the same for all series
const {
length,
SeriesNumber,
SOPInstanceUID,
frameNumber,
} = mappedAnnotations[0];
const { length, SeriesNumber, SOPInstanceUID, frameNumber } =
mappedAnnotations[0];
const instance = displaySet.images.find(
image => image.SOPInstanceUID === SOPInstanceUID
@ -198,6 +188,7 @@ function getDisplayText(mappedAnnotations, displaySet) {
const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : '';
const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : '';
if (length === null || length === undefined) return displayText;
const roundedLength = utils.roundNumber(length, 2);
displayText.push(
`${roundedLength} mm (S: ${SeriesNumber}${instanceText}${frameText})`

View File

@ -4,7 +4,7 @@ import getModalityUnit from './utils/getModalityUnit';
import { utils } from '@ohif/core';
const RectangleROI = {
toAnnotation: measurement => { },
toAnnotation: measurement => {},
toMeasurement: (
csToolsEventDetail,
DisplaySetService,
@ -26,15 +26,12 @@ const RectangleROI = {
throw new Error('Tool not supported');
}
const {
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
} = getSOPInstanceAttributes(
referencedImageId,
CornerstoneViewportService,
viewportId
);
const { SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID } =
getSOPInstanceAttributes(
referencedImageId,
CornerstoneViewportService,
viewportId
);
let displaySet;
@ -99,11 +96,8 @@ function getMappedAnnotations(annotation, DisplaySetService) {
);
}
const {
SOPInstanceUID,
SeriesInstanceUID,
frameNumber,
} = getSOPInstanceAttributes(referencedImageId);
const { SOPInstanceUID, SeriesInstanceUID, frameNumber } =
getSOPInstanceAttributes(referencedImageId);
const displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
@ -143,7 +137,7 @@ function _getReport(mappedAnnotations, points, FrameOfReferenceUID) {
// Add Type
columns.push('AnnotationType');
values.push('Cornerstone:EllipticalROI');
values.push('Cornerstone:RectangleROI');
mappedAnnotations.forEach(annotation => {
const { mean, stdDev, max, area, unit } = annotation;

View File

@ -1,6 +1,7 @@
export default [
'Length',
'EllipticalROI',
'CircleROI',
'Bidirectional',
'ArrowAnnotate',
'Angle',

View File

@ -2,6 +2,7 @@ import { MeasurementService } from '@ohif/core';
import Length from './Length';
import Bidirectional from './Bidirectional';
import EllipticalROI from './EllipticalROI';
import CircleROI from './CircleROI';
import ArrowAnnotate from './ArrowAnnotate';
import CobbAngle from './CobbAngle';
import Angle from './Angle';
@ -25,6 +26,7 @@ const measurementServiceMappingsFactory = (
const {
POLYLINE,
ELLIPSE,
CIRCLE,
RECTANGLE,
BIDIRECTIONAL,
POINT,
@ -37,6 +39,7 @@ const measurementServiceMappingsFactory = (
const TOOL_TYPE_TO_VALUE_TYPE = {
Length: POLYLINE,
EllipticalROI: ELLIPSE,
CircleROI: CIRCLE,
RectangleROI: RECTANGLE,
PlanarFreehandROI: POLYLINE,
Bidirectional: BIDIRECTIONAL,
@ -104,6 +107,22 @@ const measurementServiceMappingsFactory = (
],
},
CircleROI: {
toAnnotation: CircleROI.toAnnotation,
toMeasurement: csToolsAnnotation =>
CircleROI.toMeasurement(
csToolsAnnotation,
displaySetService,
cornerstoneViewportService,
_getValueTypeFromToolType
),
matchingCriteria: [
{
valueType: MeasurementService.VALUE_TYPES.CIRCLE,
},
],
},
RectangleROI: {
toAnnotation: RectangleROI.toAnnotation,
toMeasurement: csToolsAnnotation =>

View File

@ -3,39 +3,16 @@ import * as cornerstone from '@cornerstonejs/core';
/**
* It checks if the imageId is provided then it uses it to query
* the metadata and get the SOPInstanceUID, SeriesInstanceUID and StudyInstanceUID.
* If the imageId is not provided then it uses the sceneUID to get the viewports
* inside the scene and then it checks each viewport to find the one that has
* acquisition plane view, and uses the currentImageId of the viewport to
* query the metadata and get UIDs.
* If the imageId is not provided then undefined is returned.
* @param {string} imageId The image id of the referenced image
* @param {string} sceneUID The scene UID of the measurement tool
* @returns
*/
export default function getSOPInstanceAttributes(
imageId,
cornerstoneViewportService,
viewportId
) {
export default function getSOPInstanceAttributes(imageId) {
if (imageId) {
return _getUIDFromImageID(imageId);
}
// Todo: implement for volume viewports and use the referencedSeriesInstanceUID
// if no imageId => measurement is not in the acquisition plane
// const metadata = getUIDFromScene(cornerstoneViewportService, viewportId);
// if (!metadata) {
// throw new Error('Not viewport with imageId found');
// }
// // Since the series and study UID is derived from another viewport in the
// // same scene, we cannot include the SOPInstanceUID
// return {
// SOPInstanceUID: null,
// SeriesInstanceUID: metadata.SeriesInstanceUID,
// StudyInstanceUID: metadata.StudyInstanceUID,
// };
}
function _getUIDFromImageID(imageId) {
@ -48,23 +25,3 @@ function _getUIDFromImageID(imageId) {
frameNumber: instance.frameNumber || 1,
};
}
// function getUIDFromScene(cornerstoneViewportService) {
// const renderingEngine = cornerstoneViewportService.getRenderingEngine();
// const scene = renderingEngine.getScene(sceneUID);
// const viewportUIDs = scene.getViewportIds();
// if (viewportUIDs.length === 0) {
// throw new Error('No viewport found in scene');
// }
// for (let i = 0; i < viewportUIDs.length; i++) {
// const vp = renderingEngine.getViewport(viewportUIDs[i]);
// const imageId = vp.getCurrentImageId();
// if (imageId) {
// return _getUIDFromImageID(imageId);
// }
// }
// }

View File

@ -20,3 +20,17 @@ export function easeInOutBell(x: number, baseline: number): number {
return (- 4 * Math.pow(2 * x - 2, 3)) * alpha + baseline;
}
}
/**
* A reversed bell curved function that starts from 1 and goes to baseline and
* come back to 1 again. It uses ease in out quadratic for css transition
* timing function for each side of the curve.
*
* @param {number} x - The current time, in the range [0, 1].
* @param {number} baseline - The baseline value to start from and return to.
* @returns the value of the transition at time x.
*/
export function reverseEaseInOutBell(x: number, baseline: number): number {
const y = easeInOutBell(x, baseline);
return -y + 1 + baseline;
}

View File

@ -32,12 +32,12 @@
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/i18n": "^1.0.0",
"dcmjs": "^0.29.4",
"dicomweb-client": "^0.8.4",
"dcmjs": "^0.29.5",
"dicomweb-client": "^0.10.2",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^10.11.0",
"react-i18next": "^12.2.2",
"webpack": "^5.50.0",
"webpack-merge": "^5.7.3"
},

View File

@ -1,5 +1,5 @@
import ContextMenuController from './ContextMenuController';
import ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import defaultContextMenu from './defaultContextMenu';
import * as CustomizeableContextMenuTypes from './types';

View File

@ -1,4 +1,4 @@
import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import { DicomMetadataStore, IWebApiDataSource, utils } from '@ohif/core';
import OHIF from '@ohif/core';
import dcmjs from 'dcmjs';
@ -75,7 +75,7 @@ function createDicomLocalApi(dicomLocalConfig) {
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
study.series.forEach(aSeries => {
numInstances += aSeries.instances.length;
modalities.add(aSeries.Modality);
modalities.add(aSeries.instances[0].Modality);
});
// first instance in the first series
@ -87,7 +87,7 @@ function createDicomLocalApi(dicomLocalConfig) {
date: firstInstance.StudyDate,
description: firstInstance.StudyDescription,
mrn: firstInstance.PatientID,
patientName: { Alphabetic: firstInstance.PatientName },
patientName: utils.formatPN(firstInstance.PatientName),
studyInstanceUid: firstInstance.StudyInstanceUID,
time: firstInstance.StudyTime,
//
@ -103,9 +103,20 @@ function createDicomLocalApi(dicomLocalConfig) {
},
},
series: {
// mapParams: mapParams.bind(),
search: () => {
console.debug(' DICOMLocal QUERY SERIES SEARCH');
search: studyInstanceUID => {
const study = DicomMetadataStore.getStudy(studyInstanceUID);
return study.series.map(aSeries => {
const firstInstance = aSeries?.instances[0];
return {
studyInstanceUid: studyInstanceUID,
seriesInstanceUid: firstInstance.SeriesInstanceUID,
modality: firstInstance.Modality,
seriesNumber: firstInstance.SeriesNumber,
seriesDate: firstInstance.SeriesDate,
numSeriesInstances: aSeries.instances.length,
description: firstInstance.SeriesDescription,
};
});
},
},
instances: {
@ -115,6 +126,18 @@ function createDicomLocalApi(dicomLocalConfig) {
},
},
retrieve: {
directURL: params => {
const { instance, tag, defaultType } = params;
const value = instance[tag];
if (value instanceof Array && value[0] instanceof ArrayBuffer) {
return URL.createObjectURL(
new Blob([value[0]], {
type: defaultType,
})
);
}
},
series: {
metadata: async ({ StudyInstanceUID, madeInClient = false } = {}) => {
if (!StudyInstanceUID) {
@ -188,6 +211,7 @@ function createDicomLocalApi(dicomLocalConfig) {
displaySet.images.forEach(instance => {
const NumberOfFrames = instance.NumberOfFrames;
if (NumberOfFrames > 1) {
// in multiframe we start at frame 1
for (let i = 1; i <= NumberOfFrames; i++) {
const imageId = this.getImageIdsForInstance({
instance,

View File

@ -22,8 +22,8 @@ import {
retrieveStudyMetadata,
deleteStudyMetadataPromise,
} from './retrieveStudyMetadata.js';
import StaticWadoClient from './utils/StaticWadoClient.js';
import getDirectURL from '../utils/getDirectURL.js';
import StaticWadoClient from './utils/StaticWadoClient';
import getDirectURL from '../utils/getDirectURL';
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
@ -61,6 +61,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
singlepart,
} = dicomWebConfig;
const dicomWebConfigCopy = JSON.parse(JSON.stringify(dicomWebConfig));
const qidoConfig = {
url: qidoRoot,
staticWado,
@ -90,7 +92,9 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
const implementation = {
initialize: ({ params, query }) => {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params;
const queryStudyInstanceUIDs = query.getAll('StudyInstanceUIDs');
const queryStudyInstanceUIDs = utils.splitComma(
query.getAll('StudyInstanceUIDs')
);
const StudyInstanceUIDs =
(queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) ||
@ -174,7 +178,18 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/
directURL: params => {
return getDirectURL(wadoRoot, params);
return getDirectURL({ wadoRoot, singlepart }, params);
},
bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => {
const options = {
multipart: false,
BulkDataURI,
StudyInstanceUID,
};
return qidoDicomWebClient.retrieveBulkData(options).then(val => {
const ret = (val && val[0]) || undefined;
return ret;
});
},
series: {
metadata: async ({
@ -217,36 +232,47 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
},
store: {
dicom: async dataset => {
dicom: async (dataset, request) => {
const headers = userAuthenticationService.getAuthorizationHeader();
if (headers) {
wadoDicomWebClient.headers = headers;
}
const meta = {
FileMetaInformationVersion:
dataset._meta.FileMetaInformationVersion.Value,
MediaStorageSOPClassUID: dataset.SOPClassUID,
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN,
ImplementationClassUID,
ImplementationVersionName,
};
if (dataset instanceof ArrayBuffer) {
const options = {
datasets: [dataset],
request,
};
const denaturalized = denaturalizeDataset(meta);
const dicomDict = new DicomDict(denaturalized);
await wadoDicomWebClient.storeInstances(options);
} else {
const meta = {
FileMetaInformationVersion:
dataset._meta.FileMetaInformationVersion.Value,
MediaStorageSOPClassUID: dataset.SOPClassUID,
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN,
ImplementationClassUID,
ImplementationVersionName,
};
dicomDict.dict = denaturalizeDataset(dataset);
const denaturalized = denaturalizeDataset(meta);
const dicomDict = new DicomDict(denaturalized);
const part10Buffer = dicomDict.write();
dicomDict.dict = denaturalizeDataset(dataset);
const options = {
datasets: [part10Buffer],
};
const part10Buffer = dicomDict.write();
await wadoDicomWebClient.storeInstances(options);
const options = {
datasets: [part10Buffer],
request,
};
await wadoDicomWebClient.storeInstances(options);
}
},
},
_retrieveSeriesMetadataSync: async (
StudyInstanceUID,
filters,
@ -368,7 +394,13 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
};
// Todo: this needs to be from wado dicom web client
return qidoDicomWebClient.retrieveBulkData(options).then(val => {
const ret = (val && val[0]) || undefined;
// There are DICOM PDF cases where the first ArrayBuffer in the array is
// the bulk data and DICOM video cases where the second ArrayBuffer is
// the bulk data. Here we play it safe and do a find.
const ret =
(val instanceof Array &&
val.find(arrayBuffer => arrayBuffer?.byteLength)) ||
undefined;
value.Value = ret;
return ret;
});
@ -469,6 +501,9 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
});
return imageIds;
},
getConfig() {
return dicomWebConfigCopy;
},
};
if (supportsReject) {

View File

@ -9,18 +9,19 @@ import { api } from 'dicomweb-client';
*/
export default class StaticWadoClient extends api.DICOMwebClient {
static studyFilterKeys = {
StudyInstanceUID: '0020000D',
PatientName: '00100010',
studyinstanceuid: '0020000D',
patientname: '00100010',
'00100020': 'mrn',
StudyDescription: '00081030',
StudyDate: '00080020',
ModalitiesInStudy: '00080061',
AccessionNumber: '00080050',
studydescription: '00081030',
studydate: '00080020',
modalitiesinstudy: '00080061',
accessionnumber: '00080050',
};
static seriesFilterKeys = {
SeriesInstanceUID: '0020000E',
SeriesNumber: '00200011',
seriesinstanceuid: '0020000E',
seriesnumber: '00200011',
modality: '00080060',
};
constructor(qidoConfig) {
@ -37,15 +38,18 @@ export default class StaticWadoClient extends api.DICOMwebClient {
async searchForStudies(options) {
if (!this.staticWado) return super.searchForStudies(options);
let searchResult = await super.searchForStudies(options);
const searchResult = await super.searchForStudies(options);
const { queryParams } = options;
if (!queryParams) return searchResult;
const lowerParams = this.toLowerParams(queryParams);
const filtered = searchResult.filter(study => {
for (const key of Object.keys(StaticWadoClient.studyFilterKeys)) {
if (
!this.filterItem(
key,
queryParams,
lowerParams,
study,
StaticWadoClient.studyFilterKeys
)
@ -61,16 +65,18 @@ export default class StaticWadoClient extends api.DICOMwebClient {
async searchForSeries(options) {
if (!this.staticWado) return super.searchForSeries(options);
let searchResult = await super.searchForSeries(options);
const searchResult = await super.searchForSeries(options);
const { queryParams } = options;
if (!queryParams) return searchResult;
const filtered = searchResult.filter(study => {
const lowerParams = this.toLowerParams(queryParams);
const filtered = searchResult.filter(series => {
for (const key of Object.keys(StaticWadoClient.seriesFilterKeys)) {
if (
!this.filterItem(
key,
queryParams,
study,
lowerParams,
series,
StaticWadoClient.seriesFilterKeys
)
) {
@ -136,13 +142,13 @@ export default class StaticWadoClient extends api.DICOMwebClient {
/**
* Filters the return list by the query parameters.
*
* @param {*} key
* @param {*} queryParams
* @param anyCaseKey - a possible search key
* @param queryParams -
* @param {*} study
* @param {*} sourceFilterMap
* @returns
*/
filterItem(key, queryParams, study, sourceFilterMap) {
filterItem(key: string, queryParams, study, sourceFilterMap) {
const altKey = sourceFilterMap[key] || key;
if (!queryParams) return true;
const testValue = queryParams[key] || queryParams[altKey];
@ -153,6 +159,15 @@ export default class StaticWadoClient extends api.DICOMwebClient {
return this.compareDateRange(testValue, valueElem.Value[0]);
}
const value = valueElem.Value;
return this.compareValues(testValue, value) && true;
return this.compareValues(testValue, value);
}
/** Converts the query parameters to lower case query parameters */
toLowerParams(queryParams: Record<string, unknown>): Record<string, unknown> {
const lowerParams = {};
Object.entries(queryParams).forEach(([key, value]) => {
lowerParams[key.toLowerCase()] = value;
});
return lowerParams;
}
}

View File

@ -0,0 +1,84 @@
import { IWebApiDataSource } from '@ohif/core';
import { createDicomWebApi } from '../DicomWebDataSource/index';
/**
* This datasource is initialized with a url that returns a JSON object with a
* dicomWeb datasource configuration array present in a "servers" object.
*
* Only the first array item is parsed, if there are multiple items in the
* dicomWeb configuration array
*
*/
function createDicomWebProxyApi(
dicomWebProxyConfig,
UserAuthenticationService
) {
const { name } = dicomWebProxyConfig;
let dicomWebDelegate = undefined;
const implementation = {
initialize: async ({ params, query }) => {
let studyInstanceUIDs = [];
// there seem to be a couple of variations of the case for this parameter
const queryStudyInstanceUIDs =
query.get('studyInstanceUIDs') || query.get('studyInstanceUids');
if (!queryStudyInstanceUIDs) {
throw new Error(`No studyInstanceUids in request for '${name}'`);
}
const url = query.get('url');
if (!url) {
throw new Error(`No url for '${name}'`);
} else {
const response = await fetch(url);
let data = await response.json();
if (!data.servers?.dicomWeb?.[0]) {
throw new Error('Invalid configuration returned by url');
}
dicomWebDelegate = createDicomWebApi(
data.servers.dicomWeb[0],
UserAuthenticationService
);
studyInstanceUIDs = queryStudyInstanceUIDs.split(';');
}
return studyInstanceUIDs;
},
query: {
studies: {
search: params => dicomWebDelegate.query.studies.search(params),
},
series: {
search: (...args) => dicomWebDelegate.query.series.search(...args),
},
instances: {
search: (studyInstanceUid, queryParameters) =>
dicomWebDelegate.query.instances.search(
studyInstanceUid,
queryParameters
),
},
},
retrieve: {
directURL: (...args) => dicomWebDelegate.retrieve.directURL(...args),
series: {
metadata: (...args) =>
dicomWebDelegate.retrieve.series.metadata(...args),
},
},
store: {
dicom: (...args) => dicomWebDelegate.store(...args),
},
deleteStudyMetadataPromise: (...args) =>
dicomWebDelegate.deleteStudyMetadataPromise(...args),
getImageIdsForDisplaySet: (...args) =>
dicomWebDelegate.getImageIdsForDisplaySet(...args),
getImageIdsForInstance: (...args) =>
dicomWebDelegate.getImageIdsForInstance(...args),
};
return IWebApiDataSource.create(implementation);
}
export { createDicomWebProxyApi };

Some files were not shown because too many files have changed in this diff Show More