diff --git a/.docker/Nginx-Orthanc/docker-compose.yml b/.docker/Nginx-Orthanc/docker-compose.yml index b12382f39..eba7911a3 100644 --- a/.docker/Nginx-Orthanc/docker-compose.yml +++ b/.docker/Nginx-Orthanc/docker-compose.yml @@ -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 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..3685b6416 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://giving.massgeneral.org/ohif diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md deleted file mode 100644 index 8c4cab1fb..000000000 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ /dev/null @@ -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. diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md deleted file mode 100644 index d0cabe09f..000000000 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ /dev/null @@ -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?** - -... diff --git a/.github/ISSUE_TEMPLATE/---support-question.md b/.github/ISSUE_TEMPLATE/---support-question.md deleted file mode 100644 index d38ce5078..000000000 --- a/.github/ISSUE_TEMPLATE/---support-question.md +++ /dev/null @@ -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 ^\_^ diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..21b80596c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..bd22745d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..f0d51e6e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4a5623153..1b2ee8202 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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 + + + + +โš ๏ธโš ๏ธ 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 + + + +### Changes & Results + + + +### Testing + + + +### Checklist + +#### PR + + + +- [] 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 + + + +- [] The documentation page has been updated as necessary for any public API + additions or removals. + +#### Tested Environment + +- [] OS: +- [] Node version: +- [] Browser: + [blog]: https://circleci.com/blog/triggering-trusted-ci-jobs-on-untrusted-forks/ diff --git a/.github/stale.yml b/.github/stale.yml index 990e2f4f1..4d6cdd4e3 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -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 diff --git a/.webpack/rules/transpileJavaScript.js b/.webpack/rules/transpileJavaScript.js index d576e5839..dc84a4973 100644 --- a/.webpack/rules/transpileJavaScript.js +++ b/.webpack/rules/transpileJavaScript.js @@ -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, }, }; } diff --git a/.webpack/webpack.base.js b/.webpack/webpack.base.js index 700e9e1b3..a288ec2f0 100644 --- a/.webpack/webpack.base.js +++ b/.webpack/webpack.base.js @@ -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: [ diff --git a/DATACITATION.md b/DATACITATION.md new file mode 100644 index 000000000..aefa27d8f --- /dev/null +++ b/DATACITATION.md @@ -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), 1045โ€“1057. 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 Nonโ€“Small-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. 3823โ€“3830). 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 deepโ€learning 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, 538โ€“546 (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), 395โ€“409. 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/ diff --git a/babel.config.js b/babel.config.js index de2e1b498..d2aad74c2 100644 --- a/babel.config.js +++ b/babel.config.js @@ -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 }], diff --git a/extensions/cornerstone-dicom-rt/.webpack/webpack.dev.js b/extensions/cornerstone-dicom-rt/.webpack/webpack.dev.js new file mode 100644 index 000000000..1ae308448 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/.webpack/webpack.dev.js @@ -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 }); +}; diff --git a/extensions/cornerstone-dicom-rt/.webpack/webpack.prod.js b/extensions/cornerstone-dicom-rt/.webpack/webpack.prod.js new file mode 100644 index 000000000..070a723e3 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/.webpack/webpack.prod.js @@ -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; diff --git a/extensions/cornerstone-dicom-rt/LICENSE b/extensions/cornerstone-dicom-rt/LICENSE new file mode 100644 index 000000000..983c5ef34 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/LICENSE @@ -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. diff --git a/extensions/cornerstone-dicom-rt/README.md b/extensions/cornerstone-dicom-rt/README.md new file mode 100644 index 000000000..a23ee416f --- /dev/null +++ b/extensions/cornerstone-dicom-rt/README.md @@ -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 diff --git a/extensions/cornerstone-dicom-rt/babel.config.js b/extensions/cornerstone-dicom-rt/babel.config.js new file mode 100644 index 000000000..92fbbdeaf --- /dev/null +++ b/extensions/cornerstone-dicom-rt/babel.config.js @@ -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__'], + }, + }, +}; diff --git a/extensions/cornerstone-dicom-rt/package.json b/extensions/cornerstone-dicom-rt/package.json new file mode 100644 index 000000000..89f8071a9 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/package.json @@ -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" + } +} diff --git a/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.js new file mode 100644 index 000000000..81ba6aab9 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.js @@ -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; diff --git a/extensions/cornerstone-dicom-rt/src/id.js b/extensions/cornerstone-dicom-rt/src/id.js new file mode 100644 index 000000000..2c8691f0b --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/id.js @@ -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 }; diff --git a/extensions/cornerstone-dicom-rt/src/index.tsx b/extensions/cornerstone-dicom-rt/src/index.tsx new file mode 100644 index 000000000..7d5529776 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/index.tsx @@ -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 ( + Loading...}> + + + ); +}; + +/** + * 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 ( + + ); + }; + + 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; diff --git a/extensions/cornerstone-dicom-rt/src/loadRTStruct.js b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js new file mode 100644 index 000000000..52e1bd7c7 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js @@ -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]; +} diff --git a/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts new file mode 100644 index 000000000..0668c894b --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts @@ -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; diff --git a/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts b/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts new file mode 100644 index 000000000..826e3b9a6 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts @@ -0,0 +1,12 @@ +function createRTToolGroupAndAddTools( + ToolGroupService, + customizationService, + toolGroupId +) { + const { tools } = + customizationService.get('cornerstone.overlayViewportTools') ?? {}; + + return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {}); +} + +export default createRTToolGroupAndAddTools; diff --git a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts new file mode 100644 index 000000000..4af78caee --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts @@ -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; diff --git a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx new file mode 100644 index 000000000..58d190f5a --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx @@ -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 ( + + ); + }, [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 ( + <> + { + 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 || '', + }, + }} + /> + +
+ {rtIsLoading && ( + + )} + {getCornerstoneViewport()} + {childrenWithProps} +
+ + ); +} + +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; diff --git a/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx b/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx new file mode 100644 index 000000000..fee3d619a --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx @@ -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 = () => ; + + ToolTipMessage = () => ( +
This Segmentation is loaded in the segmentation panel
+ ); + break; + case false: + StatusIcon = () => ; + + ToolTipMessage = () =>
Click LOAD to load RTSTRUCT.
; + } + + const StatusArea = () => ( +
+
+ + RTSTRUCT +
+ {!isHydrated && ( +
+ {loadStr} +
+ )} +
+ ); + + return ( + <> + {ToolTipMessage && ( + } position="bottom-left"> + + + )} + {!ToolTipMessage && } + + ); +} diff --git a/extensions/cornerstone-dicom-seg/LICENSE b/extensions/cornerstone-dicom-seg/LICENSE index 898d93d0b..983c5ef34 100644 --- a/extensions/cornerstone-dicom-seg/LICENSE +++ b/extensions/cornerstone-dicom-seg/LICENSE @@ -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 diff --git a/extensions/cornerstone-dicom-seg/package.json b/extensions/cornerstone-dicom-seg/package.json index 92ff28740..577f9dc94 100644 --- a/extensions/cornerstone-dicom-seg/package.json +++ b/extensions/cornerstone-dicom-seg/package.json @@ -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", diff --git a/extensions/cornerstone-dicom-seg/src/getHangingProtocolModule.ts b/extensions/cornerstone-dicom-seg/src/getHangingProtocolModule.ts new file mode 100644 index 000000000..0a2f888e4 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/getHangingProtocolModule.ts @@ -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 }; diff --git a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js index 774294332..b92da5382 100644 --- a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js @@ -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) { diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx index e4ea1944c..54dff2255 100644 --- a/extensions/cornerstone-dicom-seg/src/index.tsx +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -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 ( { 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 }; diff --git a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx index 4813312f7..bc645652a 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx @@ -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 diff --git a/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx b/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx index 0fbf58b2a..02857e86b 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx @@ -1,5 +1,3 @@ -import React, { useReducer } from 'react'; - // Todo: use defaults in cs3d const initialState = { renderOutline: true, diff --git a/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts index fe114dbcd..e032542f1 100644 --- a/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts +++ b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts @@ -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; diff --git a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx index f16df5f6b..08ed004f0 100644 --- a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx +++ b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx @@ -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) {
{segIsLoading && ( - Loading SEG ... - ) : ( - -
Loading Segment
-
{`${processingProgress.segmentIndex}`}
-
/
-
{`${processingProgress.totalSegments}`}
-
- ) - } + totalNumbers={processingProgress.totalSegments} + percentComplete={processingProgress.percentComplete} + loadingText="Loading SEG..." /> )} {getCornerstoneViewport()} -
- {viewportDialogState.viewportIndex === viewportIndex && ( - - )} -
{childrenWithProps}
diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 923385eda..5eec75694 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -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" } } diff --git a/extensions/cornerstone-dicom-sr/src/commandsModule.js b/extensions/cornerstone-dicom-sr/src/commandsModule.js index 7b685ed44..f8c9459e4 100644 --- a/extensions/cornerstone-dicom-sr/src/commandsModule.js +++ b/extensions/cornerstone-dicom-sr/src/commandsModule.js @@ -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); diff --git a/extensions/cornerstone-dicom-sr/src/getHangingProtocolModule.ts b/extensions/cornerstone-dicom-sr/src/getHangingProtocolModule.ts new file mode 100644 index 000000000..e9fcf31be --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/getHangingProtocolModule.ts @@ -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 }; diff --git a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts index 54a810a8d..e02094471 100644 --- a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts @@ -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 youโ€™re 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]; } diff --git a/extensions/cornerstone-dicom-sr/src/index.tsx b/extensions/cornerstone-dicom-sr/src/index.tsx index bab79924a..9b87ea98c 100644 --- a/extensions/cornerstone-dicom-sr/src/index.tsx +++ b/extensions/cornerstone-dicom-sr/src/index.tsx @@ -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 }; diff --git a/extensions/cornerstone-dicom-sr/src/init.ts b/extensions/cornerstone-dicom-sr/src/init.ts index ec24b5c48..84cbcaf37 100644 --- a/extensions/cornerstone-dicom-sr/src/init.ts +++ b/extensions/cornerstone-dicom-sr/src/init.ts @@ -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, diff --git a/extensions/cornerstone-dicom-sr/src/onModeEnter.js b/extensions/cornerstone-dicom-sr/src/onModeEnter.js index 61b6985ef..fc4b1efce 100644 --- a/extensions/cornerstone-dicom-sr/src/onModeEnter.js +++ b/extensions/cornerstone-dicom-sr/src/onModeEnter.js @@ -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 ); diff --git a/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts b/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts index 9c4f3c2ea..347159d20 100644 --- a/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts +++ b/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts @@ -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 = >( - utilities.math.ellipse.getCanvasEllipseCorners(canvasCoordinates) - ); + let canvasCorners; + if (rotation == 90 || rotation == 270) { + canvasCorners = >( + utilities.math.ellipse.getCanvasEllipseCorners([ + canvasCoordinates[2], + canvasCoordinates[3], + canvasCoordinates[0], + canvasCoordinates[1], + ]) + ); + } else { + canvasCorners = >( + utilities.math.ellipse.getCanvasEllipseCorners(canvasCoordinates) + ); + } const lineUID = `${index}`; drawing.drawEllipse( diff --git a/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts b/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts index 48375f4cb..2e52ec111 100644 --- a/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts +++ b/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts @@ -5,6 +5,7 @@ const toolNames = { SRLength: 'SRLength', SRBidirectional: 'SRBidirectional', SREllipticalROI: 'SREllipticalROI', + SRCircleROI: 'SRCircleROI', SRArrowAnnotate: 'SRArrowAnnotate', SRAngle: 'SRAngle', SRCobbAngle: 'SRCobbAngle', diff --git a/extensions/cornerstone-dicom-sr/src/utils/createReferencedImageDisplaySet.ts b/extensions/cornerstone-dicom-sr/src/utils/createReferencedImageDisplaySet.ts new file mode 100644 index 000000000..a1dd42d2c --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/utils/createReferencedImageDisplaySet.ts @@ -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; diff --git a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx index c16b6a334..8d56f400d 100644 --- a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx @@ -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) {
{getCornerstoneViewport()} -
- {viewportDialogState.viewportIndex === viewportIndex && ( - - )} -
{childrenWithProps}
@@ -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, }; diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 2eab62700..e4954986e 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -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", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 83d012ef2..17a4f2a00 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -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 ( -
- -
e.preventDefault()} - onMouseDown={e => e.preventDefault()} - ref={elementRef} - >
- - {isCineEnabled && ( - - cineService.setCine({ - id: activeViewportIndex, - isPlaying, - }) - } - onFrameRateChange={frameRate => - cineService.setCine({ - id: activeViewportIndex, - frameRate, - }) - } + +
+ - )} -
+
e.preventDefault()} + onMouseDown={e => e.preventDefault()} + ref={elementRef} + >
+ + {isCineEnabled && ( + + cineService.setCine({ + id: activeViewportIndex, + isPlaying, + }) + } + onFrameRateChange={frameRate => + cineService.setCine({ + id: activeViewportIndex, + frameRate, + }) + } + /> + )} +
+
+ {viewportDialogState.viewportIndex === viewportIndex && ( + + )} +
+ ); }, 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; } } diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 4d1948de8..ec5b403d8 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -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: {}, }, }; diff --git a/extensions/cornerstone/src/components/DicomUpload/DicomUpload.css b/extensions/cornerstone/src/components/DicomUpload/DicomUpload.css new file mode 100644 index 000000000..55ddf7922 --- /dev/null +++ b/extensions/cornerstone/src/components/DicomUpload/DicomUpload.css @@ -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; +} diff --git a/extensions/cornerstone/src/components/DicomUpload/DicomUpload.tsx b/extensions/cornerstone/src/components/DicomUpload/DicomUpload.tsx new file mode 100644 index 000000000..26e596e95 --- /dev/null +++ b/extensions/cornerstone/src/components/DicomUpload/DicomUpload.tsx @@ -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 ( + { + onDrop(acceptedFiles); + }} + noClick + > + {({ getRootProps }) => ( +
+
+ + {({ getRootProps, getInputProps }) => ( +
+ +
+ )} +
+ + {({ getRootProps, getInputProps }) => ( +
+ +
+ )} +
+
+
or drag images or folders here
+
+ (DICOM files supported) +
+
+ )} +
+ ); + }; + + return ( + <> + {dicomFileUploaderArr.length ? ( +
+ +
+ ) : ( +
+ {getDropZoneComponent()} +
+ )} + + ); +} + +DicomUpload.propTypes = { + dataSource: PropTypes.object.isRequired, + onComplete: PropTypes.func.isRequired, + onStarted: PropTypes.func.isRequired, +}; + +export default DicomUpload; diff --git a/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgress.tsx b/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgress.tsx new file mode 100644 index 000000000..d16ee4b9f --- /dev/null +++ b/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgress.tsx @@ -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(0); + + const uploadRateRef = useRef(0); + + const [timeRemaining, setTimeRemaining] = useState(null); + + const [percentComplete, setPercentComplete] = useState(0); + + const [numFilesCompleted, setNumFilesCompleted] = useState(0); + + const [numFails, setNumFails] = useState(0); + + const [showFailedOnly, setShowFailedOnly] = useState(false); + + const progressBarContainerRef = useRef(); + + /** + * 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((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 ( +
+ {numFilesCompleted === dicomFileUploaderArr.length ? ( + <> + {`${ + dicomFileUploaderArr.length + } ${ + dicomFileUploaderArr.length > 1 ? 'files' : 'file' + } completed.`} + + + ) : ( + <> + + {`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}  + + + {' files completed.'}  + + + {timeRemaining + ? `Less than ${getFormattedTimeRemaining()} remaining. ` + : ''} + + + Cancel All Uploads + + + )} +
+ ); + }; + + const getShowFailedOnlyIconComponent = (): ReactElement => { + return ( +
+ {numFails > 0 && ( +
+ setShowFailedOnly(currentShowFailedOnly => !currentShowFailedOnly) + } + > + +
+ )} +
+ ); + }; + + const getPercentCompleteComponent = (): ReactElement => { + return ( +
+
+ {numFilesCompleted === dicomFileUploaderArr.length ? ( + <> +
+ {numFails > 0 + ? `Completed with ${numFails} ${ + numFails > 1 ? 'errors' : 'error' + }!` + : 'Completed!'} +
+ {getShowFailedOnlyIconComponent()} + + ) : ( + <> +
+ +
+
+
{`${getPercentCompleteRounded()}%`}
+ {getShowFailedOnlyIconComponent()} +
+ + )} +
+
+ ); + }; + + return ( +
+ {getNumCompletedAndTimeRemainingComponent()} +
+ {getPercentCompleteComponent()} +
+ {dicomFileUploaderArr + .filter( + dicomFileUploader => + !showFailedOnly || + dicomFileUploader.getStatus() === UploadStatus.Failed + ) + .map(dicomFileUploader => ( + + ))} +
+
+
+ ); +} + +DicomUploadProgress.propTypes = { + dicomFileUploaderArr: PropTypes.arrayOf( + PropTypes.instanceOf(DicomFileUploader) + ).isRequired, + onComplete: PropTypes.func.isRequired, +}; + +export default DicomUploadProgress; diff --git a/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgressItem.tsx b/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgressItem.tsx new file mode 100644 index 000000000..bcbdcec58 --- /dev/null +++ b/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgressItem.tsx @@ -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 ( + + ); + case UploadStatus.InProgress: + return ; + case UploadStatus.Failed: + return ; + case UploadStatus.Cancelled: + return ; + default: + return <>; + } + }; + + return ( +
+
+
+
+ {getStatusIcon()} +
+
+ {dicomFileUploader.getFileName()} +
+
+ {failedReason &&
{failedReason}
} +
+
+ {!isComplete() && ( + <> + {dicomFileUploader.getStatus() === UploadStatus.InProgress && ( +
{percentComplete}%
+ )} +
+ +
+ + )} +
+
+ ); + } +); + +DicomUploadProgressItem.propTypes = { + dicomFileUploader: PropTypes.instanceOf(DicomFileUploader).isRequired, +}; + +export default DicomUploadProgressItem; diff --git a/extensions/cornerstone/src/getCustomizationModule.ts b/extensions/cornerstone/src/getCustomizationModule.ts new file mode 100644 index 000000000..acb064244 --- /dev/null +++ b/extensions/cornerstone/src/getCustomizationModule.ts @@ -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; diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index 2c21e0ab1..15ff51460 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -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, }, { diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index f1ec22f16..142348a9b 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -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 { 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 ( @@ -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; diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 27ca02ee7..4a07f4e63 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -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 { @@ -83,6 +84,8 @@ export default async function init({ } = servicesManager.services; window.services = servicesManager.services; + window.extensionManager = extensionManager; + window.commandsManager = commandsManager; if ( appConfig.showWarningMessageForCrossOrigin && diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 2d305af8a..9549b17f9 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -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, diff --git a/extensions/cornerstone/src/initMeasurementService.js b/extensions/cornerstone/src/initMeasurementService.js index 93a618eaf..3a883cc79 100644 --- a/extensions/cornerstone/src/initMeasurementService.js +++ b/extensions/cornerstone/src/initMeasurementService.js @@ -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', diff --git a/extensions/cornerstone/src/initWADOImageLoader.js b/extensions/cornerstone/src/initWADOImageLoader.js index c072184ee..fbbf3b95d 100644 --- a/extensions/cornerstone/src/initWADOImageLoader.js +++ b/extensions/cornerstone/src/initWADOImageLoader.js @@ -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. diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 8a29bcab4..31ea90cb2 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -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 = new Map(); volumeImageIds: Map = 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, }, }; diff --git a/extensions/cornerstone/src/services/SegmentationService/RTSTRUCT/mapROIContoursToRTStructData.ts b/extensions/cornerstone/src/services/SegmentationService/RTSTRUCT/mapROIContoursToRTStructData.ts new file mode 100644 index 000000000..f17ec5c70 --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/RTSTRUCT/mapROIContoursToRTStructData.ts @@ -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}`, + }; + } + ); +} diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 658ee816f..26d47b3f7 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -1,11 +1,13 @@ import cloneDeep from 'lodash.clonedeep'; +import { Types as OhifTypes, ServicesManager, PubSubService } from '@ohif/core'; import { cache, + Enums as csEnums, + geometryLoader, eventTarget, getEnabledElementByIds, metaData, - Types, utilities as csUtils, volumeLoader, } from '@cornerstonejs/core'; @@ -16,17 +18,19 @@ import { Types as cstTypes, utilities as cstUtils, } from '@cornerstonejs/tools'; -import { pubSubServiceInterface } from '@ohif/core'; import isEqual from 'lodash.isequal'; -import { easeInOutBell } from '../../utils/transitions'; +import { Types as ohifTypes } from '@ohif/core'; +import { easeInOutBell, reverseEaseInOutBell } from '../../utils/transitions'; import { + Segment, Segmentation, SegmentationConfig, - SegmentationSchema, } from './SegmentationServiceTypes'; +import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStructData'; const { COLOR_LUT } = cstConstants; const LABELMAP = csToolsEnums.SegmentationRepresentations.Labelmap; +const CONTOUR = csToolsEnums.SegmentationRepresentations.Contour; const EVENTS = { // fired when the segmentation is updated (e.g. when a segment is added, removed, or modified, locked, visibility changed etc.) @@ -40,26 +44,42 @@ const EVENTS = { // fired when the configuration for the segmentation is changed (e.g., brush size, render fill, outline thickness, etc.) SEGMENTATION_CONFIGURATION_CHANGED: 'event::segmentation_configuration_changed', - SEGMENT_PIXEL_DATA_CREATED: 'event::segment_pixel_data_created', + // fired when the active segment is loaded in SEG or RTSTRUCT + SEGMENT_LOADING_COMPLETE: 'event::segment_loading_complete', // for all segments - SEGMENTATION_PIXEL_DATA_CREATED: 'event::segmentation_pixel_data_created', + SEGMENTATION_LOADING_COMPLETE: 'event::segmentation_loading_complete', }; const VALUE_TYPES = {}; -class SegmentationService { - listeners = {}; +const SEGMENT_CONSTANT = { + opacity: 255, + isVisible: true, + isLocked: false, +}; + +const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume'; + +class SegmentationService extends PubSubService { + static REGISTRATION = { + name: 'segmentationService', + altName: 'SegmentationService', + create: ({ + servicesManager, + }: OhifTypes.Extensions.ExtensionParams): SegmentationService => { + return new SegmentationService({ servicesManager }); + }, + }; + segmentations: Record; - servicesManager = null; + readonly servicesManager: ServicesManager; highlightIntervalId = null; - _broadcastEvent: (eventName: string, callbackProps: any) => void; readonly EVENTS = EVENTS; constructor({ servicesManager }) { + super(EVENTS); this.segmentations = {}; - this.listeners = {}; - Object.assign(this, pubSubServiceInterface); this.servicesManager = servicesManager; this._initSegmentationService(); @@ -68,7 +88,7 @@ class SegmentationService { public destroy = () => { eventTarget.removeEventListener( csToolsEnums.Events.SEGMENTATION_MODIFIED, - this._onSegmentationModified + this._onSegmentationModifiedFromSource ); eventTarget.removeEventListener( @@ -105,7 +125,7 @@ class SegmentationService { toolGroupId?: string, properties?: { label?: string; - color?: Types.Point3; + color?: ohifTypes.RGB; opacity?: number; visibility?: boolean; isLocked?: boolean; @@ -232,7 +252,8 @@ class SegmentationService { // Todo: handle other segmentations other than labelmap const labelmapVolume = this.getLabelmapVolume(segmentationId); - const { scalarData, dimensions } = labelmapVolume; + const { dimensions } = labelmapVolume; + const scalarData = labelmapVolume.getScalarData(); // Set all values of this segment to zero and get which frames have been edited. const frameLength = dimensions[0] * dimensions[1]; @@ -317,7 +338,7 @@ class SegmentationService { public setSegmentColor( segmentationId: string, segmentIndex: number, - color: Types.Point3, + color: ohifTypes.RGB, toolGroupId?: string ): void { this._setSegmentColor(segmentationId, segmentIndex, color, toolGroupId); @@ -431,15 +452,15 @@ class SegmentationService { } public addOrUpdateSegmentation( - segmentationSchema: SegmentationSchema, + segmentation: Segmentation, suppressEvents = false, notYetUpdatedAtSource = false ): string { - const { id: segmentationId } = segmentationSchema; - let segmentation = this.segmentations[segmentationId]; - if (segmentation) { + const { id: segmentationId } = segmentation; + let cachedSegmentation = this.segmentations[segmentationId]; + if (cachedSegmentation) { // Update the segmentation (mostly for assigning metadata/labels) - Object.assign(segmentation, segmentationSchema); + Object.assign(cachedSegmentation, segmentation); this._updateCornerstoneSegmentations({ segmentationId, @@ -448,53 +469,47 @@ class SegmentationService { if (!suppressEvents) { this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { - segmentation, + segmentation: cachedSegmentation, }); } return segmentationId; } - // Add the segmentation otherwise + const representationType = segmentation.type; + const representationData = + segmentation.representationData[representationType]; cstSegmentation.addSegmentations([ { segmentationId, representation: { - type: LABELMAP, - // Todo: need to be generalized + type: representationType, data: { - volumeId: segmentationId, + ...representationData, }, }, }, ]); // Define a new color LUT and associate it with this segmentation. - // Todo: need to be generalized to accept custom color LUTs const newColorLUT = this.generateNewColorLUT(); const newColorLUTIndex = this.getNextColorLUTIndex(); cstSegmentation.config.color.addColorLUT(newColorLUT, newColorLUTIndex); - if ( - segmentationSchema.label === undefined || - segmentationSchema.label === '' - ) { - segmentationSchema.label = 'Segmentation'; - } - this.segmentations[segmentationId] = { - ...segmentationSchema, - segments: segmentationSchema.segments || [null], - activeSegmentIndex: segmentationSchema.activeSegmentIndex ?? null, - segmentCount: segmentationSchema.segmentCount ?? 0, + ...segmentation, + label: segmentation.label || '', + segments: segmentation.segments || [null], + activeSegmentIndex: segmentation.activeSegmentIndex ?? null, + segmentCount: segmentation.segmentCount ?? 0, isActive: false, colorLUTIndex: newColorLUTIndex, isVisible: true, }; - segmentation = this.segmentations[segmentationId]; + cachedSegmentation = this.segmentations[segmentationId]; this._updateCornerstoneSegmentations({ segmentationId, @@ -503,11 +518,11 @@ class SegmentationService { if (!suppressEvents) { this._broadcastEvent(this.EVENTS.SEGMENTATION_ADDED, { - segmentation, + segmentation: cachedSegmentation, }); } - return segmentation.id; + return cachedSegmentation.id; } public async createSegmentationForSEGDisplaySet( @@ -515,7 +530,38 @@ class SegmentationService { segmentationId?: string, suppressEvents = false ): Promise { + // Todo: we only support creating labelmap for SEG displaySets for now + const representationType = LABELMAP; + segmentationId = segmentationId ?? segDisplaySet.displaySetInstanceUID; + + const defaultScheme = this._getDefaultSegmentationScheme(); + + const segmentation: Segmentation = { + ...defaultScheme, + id: segmentationId, + displaySetInstanceUID: segDisplaySet.displaySetInstanceUID, + type: representationType, + representationData: { + [LABELMAP]: { + volumeId: segmentationId, + referencedVolumeId: segDisplaySet.referencedVolumeId, + }, + }, + }; + + const labelmap = this.getLabelmapVolume(segmentationId); + const cachedSegmentation = this.getSegmentation(segmentationId); + if (labelmap && cachedSegmentation) { + // if the labelmap with the same segmentationId already exists, we can + // just assume that the segmentation is already created and move on with + // updating the state + return this.addOrUpdateSegmentation( + Object.assign(segmentation, cachedSegmentation), + suppressEvents + ); + } + const { segments, referencedVolumeId } = segDisplaySet; if (!segments || !referencedVolumeId) { @@ -524,38 +570,8 @@ class SegmentationService { ); } - const segmentationSchema: SegmentationSchema = { - id: segmentationId, - volumeId: segmentationId, - displaySetInstanceUID: segDisplaySet.displaySetInstanceUID, - referencedVolumeURI: segDisplaySet.referencedVolumeURI, - activeSegmentIndex: 1, - cachedStats: {}, - label: '', - segmentsLocked: [], - type: LABELMAP, - displayText: [], - hydrated: false, // by default we don't hydrate the segmentation for SEG displaySets - segmentCount: 0, - segments: [], - }; - - const labelmap = this.getLabelmapVolume(segmentationId); - const segmentation = this.getSegmentation(segmentationId); - - if (labelmap && segmentation) { - // if the labalemap with the same segmentationId already exists, we can - // just assume that the segmentation is already created and move on with - // updating the state - return this.addOrUpdateSegmentation( - Object.assign(segmentationSchema, segmentation), - suppressEvents - ); - } - // if the labelmap doesn't exist, we need to create it first from the // DICOM SEG displaySet data - const referencedVolume = cache.getVolume(referencedVolumeId); if (!referencedVolume) { @@ -577,7 +593,7 @@ class SegmentationService { } ); const [rows, columns] = derivedVolume.dimensions; - const derivedVolumeScalarData = derivedVolume.scalarData; + const derivedVolumeScalarData = derivedVolume.getScalarData(); const { imageIds } = referencedVolume; const sopUIDImageIdIndexMap = imageIds.reduce((acc, imageId, index) => { @@ -660,26 +676,34 @@ class SegmentationService { const centerWorld = derivedVolume.imageData.indexToWorld([x, y, z]); - segmentationSchema.cachedStats = { - ...segmentationSchema.cachedStats, + segmentation.cachedStats = { + ...segmentation.cachedStats, segmentCenter: { - ...segmentationSchema.cachedStats.segmentCenter, + ...segmentation.cachedStats.segmentCenter, [segmentIndex]: { center: { image: [x, y, z], world: centerWorld, }, - modifiedTime: Date.now(), + modifiedTime: segDisplaySet.SeriesDate, }, }, }; - this._broadcastEvent(EVENTS.SEGMENT_PIXEL_DATA_CREATED, { - segmentIndex: Number(segmentIndex), - numSegments, + const numInitialized = Object.keys(segmentation.cachedStats.segmentCenter) + .length; + + // Calculate percentage completed + const percentComplete = Math.round((numInitialized / numSegments) * 100); + + this._broadcastEvent(EVENTS.SEGMENT_LOADING_COMPLETE, { + percentComplete, + numSegments: numSegments, }); }; + const promiseArray = []; + for (const segmentIndex in segments) { const segmentInfo = segments[segmentIndex]; @@ -693,17 +717,19 @@ class SegmentationService { }, 0); }); - await promise; + promiseArray.push(promise); } - segmentationSchema.segmentCount = Object.keys(segments).length; - segmentationSchema.segments = [null]; // segment 0 + await Promise.all(promiseArray); + + segmentation.segmentCount = Object.keys(segments).length; + segmentation.segments = [null]; // segment 0 Object.keys(segments).forEach(segmentIndex => { const segmentInfo = segments[segmentIndex]; const segIndex = Number(segmentIndex); - segmentationSchema.segments[segIndex] = { + segmentation.segments[segIndex] = { label: segmentInfo.label || `Segment ${segIndex}`, segmentIndex: Number(segmentIndex), color: [ @@ -719,13 +745,151 @@ class SegmentationService { segDisplaySet.isLoaded = true; - this._broadcastEvent(EVENTS.SEGMENTATION_PIXEL_DATA_CREATED, { + this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, { segmentationId, segDisplaySet, overlappingSegments, }); - return this.addOrUpdateSegmentation(segmentationSchema, suppressEvents); + return this.addOrUpdateSegmentation(segmentation, suppressEvents); + } + + public async createSegmentationForRTDisplaySet( + rtDisplaySet, + segmentationId?: string, + suppressEvents = false + ): Promise { + // Todo: we currently only have support for contour representation for initial + // RT display + const representationType = CONTOUR; + segmentationId = segmentationId ?? rtDisplaySet.displaySetInstanceUID; + const { structureSet } = rtDisplaySet; + + if (!structureSet) { + throw new Error( + 'To create the contours from RT displaySet, the displaySet should be loaded first, you can perform rtDisplaySet.load() before calling this method.' + ); + } + + const defaultScheme = this._getDefaultSegmentationScheme(); + const rtDisplaySetUID = rtDisplaySet.displaySetInstanceUID; + + const allRTStructData = mapROIContoursToRTStructData( + structureSet, + rtDisplaySetUID + ); + + // sort by segmentIndex + allRTStructData.sort((a, b) => a.segmentIndex - b.segmentIndex); + + const geometryIds = allRTStructData.map(({ geometryId }) => geometryId); + + const segmentation: Segmentation = { + ...defaultScheme, + id: segmentationId, + displaySetInstanceUID: rtDisplaySetUID, + type: representationType, + representationData: { + [CONTOUR]: { + geometryIds, + }, + }, + }; + + const cachedSegmentation = this.getSegmentation(segmentationId); + + if (cachedSegmentation) { + // if the labelmap with the same segmentationId already exists, we can + // just assume that the segmentation is already created and move on with + // updating the state + return this.addOrUpdateSegmentation( + Object.assign(segmentation, cachedSegmentation), + suppressEvents + ); + } + + if (!structureSet.ROIContours?.length) { + throw new Error( + 'The structureSet does not contain any ROIContours. Please ensure the structureSet is loaded first.' + ); + } + const segmentsCachedStats = {}; + const initializeContour = async rtStructData => { + const { data, id, color, segmentIndex, geometryId } = rtStructData; + const geometry = await geometryLoader.createAndCacheGeometry(geometryId, { + geometryData: { + data, + id, + color, + frameOfReferenceUID: structureSet.frameOfReferenceUID, + segmentIndex, + }, + type: csEnums.GeometryType.CONTOUR, + }); + + const contourSet = geometry.data; + const centroid = contourSet.getCentroid(); + + segmentsCachedStats[segmentIndex] = { + center: { world: centroid }, + modifiedTime: rtDisplaySet.SeriesDate, // we use the SeriesDate as the modifiedTime since this is the first time we are creating the segmentation + }; + + segmentation.segments[segmentIndex] = { + label: id, + segmentIndex, + color, + ...SEGMENT_CONSTANT, + }; + + const numInitialized = Object.keys(segmentsCachedStats).length; + + // Calculate percentage completed + const percentComplete = Math.round( + (numInitialized / allRTStructData.length) * 100 + ); + + this._broadcastEvent(EVENTS.SEGMENT_LOADING_COMPLETE, { + percentComplete, + // Note: this is not the geometryIds length since there might be + // some missing ROINumbers + numSegments: allRTStructData.length, + }); + }; + + const promiseArray = []; + + for (let i = 0; i < allRTStructData.length; i++) { + const promise = new Promise((resolve, reject) => { + setTimeout(() => { + initializeContour(allRTStructData[i]).then(() => { + resolve(); + }); + }, 0); + }); + + promiseArray.push(promise); + } + + await Promise.all(promiseArray); + + segmentation.segmentCount = allRTStructData.length; + rtDisplaySet.isLoaded = true; + + segmentation.cachedStats = { + ...segmentation.cachedStats, + segmentCenter: { + ...segmentation.cachedStats.segmentCenter, + ...segmentsCachedStats, + }, + }; + + this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, { + segmentationId, + rtDisplaySet, + }); + + return this.addOrUpdateSegmentation(segmentation, suppressEvents); } public jumpToSegmentCenter( @@ -736,7 +900,7 @@ class SegmentationService { highlightSegment = true, animationLength = 750, highlightHideOthers = false, - highlightFunctionType: 'ease-in-out' // todo: make animation functions configurable from outside + highlightFunctionType = 'ease-in-out' // todo: make animation functions configurable from outside ): void { const { toolGroupService } = this.servicesManager.services; const center = this._getSegmentCenter(segmentationId, segmentIndex); @@ -790,7 +954,7 @@ class SegmentationService { alpha = 0.9, animationLength = 750, hideOthers = true, - highlightFunctionType: 'ease-in-out' + highlightFunctionType = 'ease-in-out' ): void { if (this.highlightIntervalId) { clearInterval(this.highlightIntervalId); @@ -804,61 +968,25 @@ class SegmentationService { toolGroupId ); + const { type } = segmentationRepresentation; const { segments } = segmentation; - const newSegmentSpecificConfig = { - [segmentIndex]: { - LABELMAP: { - fillAlpha: alpha, - }, - }, - }; + const highlightFn = + type === LABELMAP + ? this._highlightLabelmap.bind(this) + : this._highlightContour.bind(this); - if (hideOthers) { - for (let i = 0; i < segments.length; i++) { - if (i !== segmentIndex) { - newSegmentSpecificConfig[i] = { - LABELMAP: { - fillAlpha: 0, - }, - }; - } - } - } + const adjustedAlpha = type === LABELMAP ? alpha : 1 - alpha; - const { fillAlpha } = this.getConfiguration(toolGroupId); - - let count = 0; - const intervalTime = 16; - const numberOfFrames = Math.ceil(animationLength / intervalTime); - - this.highlightIntervalId = setInterval(() => { - const x = (count * intervalTime) / animationLength; - cstSegmentation.config.setSegmentSpecificConfig( - toolGroupId, - segmentationRepresentation.segmentationRepresentationUID, - { - [segmentIndex]: { - LABELMAP: { - fillAlpha: easeInOutBell(x, fillAlpha), - }, - }, - } - ); - - count++; - - if (count === numberOfFrames) { - clearInterval(this.highlightIntervalId); - cstSegmentation.config.setSegmentSpecificConfig( - toolGroupId, - segmentationRepresentation.segmentationRepresentationUID, - {} - ); - - this.highlightIntervalId = null; - } - }, intervalTime); + highlightFn( + segmentIndex, + adjustedAlpha, + hideOthers, + segments, + toolGroupId, + animationLength, + segmentationRepresentation + ); } public createSegmentationForDisplaySet = async ( @@ -868,8 +996,16 @@ class SegmentationService { label: string; } ): Promise => { - const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use - const volumeId = `${volumeLoaderScheme}:${displaySetInstanceUID}`; // VolumeId with loader id + volume id + const { displaySetService } = this.servicesManager.services; + + const displaySet = displaySetService.getDisplaySetByUID( + displaySetInstanceUID + ); + + // Todo: we currently only support labelmap for segmentation for a displaySet + const representationType = LABELMAP; + + const volumeId = this._getVolumeIdForDisplaySet(displaySet); const segmentationId = options?.segmentationId ?? `${csUtils.uuidv4()}`; @@ -883,23 +1019,25 @@ class SegmentationService { }, }); - const segmentationSchema: SegmentationSchema = { + const defaultScheme = this._getDefaultSegmentationScheme(); + + const segmentation: Segmentation = { + ...defaultScheme, id: segmentationId, - volumeId: segmentationId, displaySetInstanceUID, - referencedVolumeURI: volumeId.split(':')[0], // Todo: this is so ugly - activeSegmentIndex: 1, - cachedStats: {}, label: options?.label, - segmentsLocked: [], - type: LABELMAP, - displayText: [], - hydrated: false, - segmentCount: 0, - segments: [], + // We should set it as active by default, as it created for display + isActive: true, + type: representationType, + representationData: { + LABELMAP: { + volumeId: segmentationId, + referencedVolumeId: volumeId, // Todo: this is so ugly + }, + }, }; - this.addOrUpdateSegmentation(segmentationSchema); + this.addOrUpdateSegmentation(segmentation); return segmentationId; }; @@ -1078,21 +1216,133 @@ class SegmentationService { } }; + private _highlightLabelmap( + segmentIndex: number, + alpha: number, + hideOthers: boolean, + segments: Segment[], + toolGroupId: string, + animationLength: number, + segmentationRepresentation: cstTypes.ToolGroupSpecificRepresentation + ) { + const newSegmentSpecificConfig = { + [segmentIndex]: { + LABELMAP: { + fillAlpha: alpha, + }, + }, + }; + + if (hideOthers) { + for (let i = 0; i < segments.length; i++) { + if (i !== segmentIndex) { + newSegmentSpecificConfig[i] = { + LABELMAP: { + fillAlpha: 0, + }, + }; + } + } + } + + const { fillAlpha } = this.getConfiguration(toolGroupId); + + let startTime: number = null; + const animation = (timestamp: number) => { + if (startTime === null) { + startTime = timestamp; + } + + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / animationLength, 1); + + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + { + [segmentIndex]: { + LABELMAP: { + fillAlpha: easeInOutBell(progress, fillAlpha), + }, + }, + } + ); + + if (progress < 1) { + requestAnimationFrame(animation); + } else { + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + {} + ); + } + }; + + requestAnimationFrame(animation); + } + + private _highlightContour( + segmentIndex: number, + alpha: number, + hideOthers: boolean, + segments: Segment[], + toolGroupId: string, + animationLength: number, + segmentationRepresentation: cstTypes.ToolGroupSpecificRepresentation + ) { + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const progress = (currentTime - startTime) / animationLength; + if (progress >= 1) { + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + {} + ); + return; + } + + const reversedProgress = reverseEaseInOutBell(progress, 0.1); + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + { + [segmentIndex]: { + CONTOUR: { + fillAlpha: reversedProgress, + }, + }, + } + ); + + requestAnimationFrame(animate); + }; + + requestAnimationFrame(animate); + } + public removeSegmentationRepresentationFromToolGroup( toolGroupId: string, - segmentationIds?: string[] + segmentationRepresentationUIDsIds?: string[] ): void { - segmentationIds = - segmentationIds ?? - cstSegmentation.state - .getSegmentationRepresentations(toolGroupId) - .map(rep => rep.segmentationRepresentationUID); + const uids = segmentationRepresentationUIDsIds || []; + if (!uids.length) { + const representations = cstSegmentation.state.getSegmentationRepresentations( + toolGroupId + ); - cstSegmentation.removeSegmentationsFromToolGroup( - toolGroupId, - segmentationIds, - true // immediate render - ); + if (!representations || !representations.length) { + return; + } + + uids.push( + ...representations.map(rep => rep.segmentationRepresentationUID) + ); + } + + cstSegmentation.removeSegmentationsFromToolGroup(toolGroupId, uids); } /** @@ -1155,10 +1405,16 @@ class SegmentationService { // toolGroupId // ); + const segmentationRepresentations = this.getSegmentationRepresentationsForToolGroup( + toolGroupId + ); + + const typeToUse = segmentationRepresentations?.[0]?.type || LABELMAP; + const config = cstSegmentation.config.getGlobalConfig(); const { renderInactiveSegmentations } = config; - const labelmapRepresentationConfig = config.representations.LABELMAP; + const representation = config.representations[typeToUse]; const { renderOutline, @@ -1168,7 +1424,7 @@ class SegmentationService { fillAlphaInactive, outlineOpacity, outlineOpacityInactive, - } = labelmapRepresentationConfig; + } = representation; return { brushSize, @@ -1197,48 +1453,33 @@ class SegmentationService { renderOutline, } = configuration; - if (renderOutline !== undefined) { - this._setLabelmapConfigValue('renderOutline', renderOutline); - } + const setConfigValueIfDefined = (key, value, transformFn = null) => { + if (value !== undefined) { + const transformedValue = transformFn ? transformFn(value) : value; + this._setSegmentationConfig(key, transformedValue); + } + }; - if (outlineWidthActive !== undefined) { - this._setLabelmapConfigValue('outlineWidthActive', outlineWidthActive); - // this._setLabelmapConfigValue('outlineWidthInactive', outlineWidthActive); - } - - if (outlineOpacity !== undefined) { - this._setLabelmapConfigValue('outlineOpacity', outlineOpacity / 100); - } - - if (fillAlpha !== undefined) { - this._setLabelmapConfigValue('fillAlpha', fillAlpha / 100); - } - - if (renderFill !== undefined) { - this._setLabelmapConfigValue('renderFill', renderFill); - } + setConfigValueIfDefined('renderOutline', renderOutline); + setConfigValueIfDefined('outlineWidthActive', outlineWidthActive); + setConfigValueIfDefined('outlineOpacity', outlineOpacity, v => v / 100); + setConfigValueIfDefined('fillAlpha', fillAlpha, v => v / 100); + setConfigValueIfDefined('renderFill', renderFill); + setConfigValueIfDefined( + 'fillAlphaInactive', + fillAlphaInactive, + v => v / 100 + ); + setConfigValueIfDefined('outlineOpacityInactive', fillAlphaInactive, v => + Math.max(0.75, v / 100) + ); if (renderInactiveSegmentations !== undefined) { const config = cstSegmentation.config.getGlobalConfig(); - config.renderInactiveSegmentations = renderInactiveSegmentations; cstSegmentation.config.setGlobalConfig(config); } - if (fillAlphaInactive !== undefined) { - this._setLabelmapConfigValue( - 'fillAlphaInactive', - fillAlphaInactive / 100 - ); - - // we assume that if the user changes the inactive fill alpha, they - // want the inactive outline to be also changed - this._setLabelmapConfigValue( - 'outlineOpacityInactive', - Math.max(0.75, fillAlphaInactive / 100) // don't go below 0.7 for outline - ); - } - // if (brushSize !== undefined) { // const { toolGroupService } = this.servicesManager.services; @@ -1313,6 +1554,7 @@ class SegmentationService { }); } } + public shouldRenderSegmentation( viewportDisplaySetInstanceUIDs, segDisplaySetInstanceUID @@ -1332,11 +1574,13 @@ class SegmentationService { segDisplaySetInstanceUID ); - const segFrameOfReferenceUID = segDisplaySet.instance?.FrameOfReferenceUID; + const segFrameOfReferenceUID = this._getFrameOfReferenceUIDForSeg( + segDisplaySet + ); - viewportDisplaySetInstanceUIDs.forEach(displaySetInstanceUID => { - // check if the displaySet is sharing the same frameOfReferenceUID - // with the new segmentation + // check if the displaySet is sharing the same frameOfReferenceUID + // with the new segmentation + for (const displaySetInstanceUID of viewportDisplaySetInstanceUIDs) { const displaySet = displaySetService.getDisplaySetByUID( displaySetInstanceUID ); @@ -1348,12 +1592,29 @@ class SegmentationService { displaySet?.images?.[0]?.FrameOfReferenceUID === segFrameOfReferenceUID ) { shouldDisplaySeg = true; + break; } - }); + } return shouldDisplaySeg; } + private _getDefaultSegmentationScheme() { + return { + activeSegmentIndex: 1, + cachedStats: {}, + label: '', + segmentsLocked: [], + displayText: [], + hydrated: false, // by default we don't hydrate the segmentation for SEG displaySets + segmentCount: 0, + segments: [], + isVisible: true, + isActive: false, + colorLUTIndex: 0, + }; + } + private _setActiveSegmentationForToolGroup( segmentationId: string, toolGroupId: string, @@ -1447,10 +1708,17 @@ class SegmentationService { } } + private _getVolumeIdForDisplaySet(displaySet) { + const volumeLoaderSchema = + displaySet.volumeLoaderSchema ?? VOLUME_LOADER_SCHEME; + + return `${volumeLoaderSchema}:${displaySet.displaySetInstanceUID}`; + } + private _setSegmentColor = ( segmentationId: string, segmentIndex: number, - color: Types.Point3, + color: ohifTypes.RGB, toolGroupId?: string, suppressEvents = false ) => { @@ -1716,12 +1984,16 @@ class SegmentationService { return representation; } - private _setLabelmapConfigValue = (property, value) => { + private _setSegmentationConfig = (property, value) => { + // Todo: currently we only support global config, and we get the type + // from the first segmentation + const typeToUse = this.getSegmentations()[0].type; + const { cornerstoneViewportService } = this.servicesManager.services; const config = cstSegmentation.config.getGlobalConfig(); - config.representations.LABELMAP[property] = value; + config.representations[typeToUse][property] = value; // Todo: add non global (representation specific config as well) cstSegmentation.config.setGlobalConfig(config); @@ -1736,7 +2008,7 @@ class SegmentationService { // Connect Segmentation Service to Cornerstone3D. eventTarget.addEventListener( csToolsEnums.Events.SEGMENTATION_MODIFIED, - this._onSegmentationModified + this._onSegmentationModifiedFromSource ); eventTarget.addEventListener( @@ -1760,7 +2032,7 @@ class SegmentationService { }); }; - private _onSegmentationModified = evt => { + private _onSegmentationModifiedFromSource = evt => { const { segmentationId } = evt.detail; const segmentation = this.segmentations[segmentationId]; @@ -1778,23 +2050,25 @@ class SegmentationService { return; } - if (!Object.keys(segmentationState.representationData).includes(LABELMAP)) { - throw new Error('Non-labelmap representations are not supported yet'); - } - const { activeSegmentIndex, cachedStats, segmentsLocked, - representationData, label, type, } = segmentationState; - const labelmapRepresentationData = representationData[LABELMAP]; + if (![LABELMAP, CONTOUR].includes(type)) { + throw new Error( + `Unsupported segmentation type: ${type}. Only ${LABELMAP} and ${CONTOUR} are supported.` + ); + } + + const representationData = segmentationState.representationData[type]; // TODO: handle other representations when available in cornerstone3D const segmentationSchema = { + ...segmentation, activeSegmentIndex, cachedStats, displayText: [], @@ -1802,7 +2076,11 @@ class SegmentationService { label, segmentsLocked, type, - volumeId: labelmapRepresentationData.volumeId, + representationData: { + [type]: { + ...representationData, + }, + }, }; try { @@ -1874,7 +2152,7 @@ class SegmentationService { // cleanup the segmentation state too segmentationState.removeSegmentation(segmentationId); - if (removeFromCache) { + if (removeFromCache && cache.getVolumeLoadObject(segmentationId)) { cache.removeVolumeLoadObject(segmentationId); } } @@ -1958,6 +2236,22 @@ class SegmentationService { return toolGroupIds; } + private _getFrameOfReferenceUIDForSeg(displaySet) { + const frameOfReferenceUID = displaySet.instance?.FrameOfReferenceUID; + + if (frameOfReferenceUID) { + return frameOfReferenceUID; + } + + // if not found we should try the ReferencedFrameOfReferenceSequence + const referencedFrameOfReferenceSequence = + displaySet.instance?.ReferencedFrameOfReferenceSequence; + + if (referencedFrameOfReferenceSequence) { + return referencedFrameOfReferenceSequence.FrameOfReferenceUID; + } + } + private _getFirstToolGroupId = () => { const { toolGroupService } = this.servicesManager.services; const toolGroupIds = toolGroupService.getToolGroupIds(); diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts index 3d4066d09..e170197c3 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts @@ -29,6 +29,8 @@ type Segmentation = { colorLUTIndex: number; // if segmentation contains any data (often calculated from labelmap) cachedStats: Record; + // 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; // the set of segments that are locked segmentsLocked: Array; - // 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; - // 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; // 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; + // 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 }; diff --git a/extensions/cornerstone/src/services/SegmentationService/index.js b/extensions/cornerstone/src/services/SegmentationService/index.js index 7b89d9bec..848c25ef9 100644 --- a/extensions/cornerstone/src/services/SegmentationService/index.js +++ b/extensions/cornerstone/src/services/SegmentationService/index.js @@ -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; diff --git a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts index 6acfce70a..39e919fc5 100644 --- a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts +++ b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts @@ -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 = { @@ -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; // diff --git a/extensions/cornerstone/src/services/SyncGroupService/index.js b/extensions/cornerstone/src/services/SyncGroupService/index.js index 64cb989fe..1e0a21819 100644 --- a/extensions/cornerstone/src/services/SyncGroupService/index.js +++ b/extensions/cornerstone/src/services/SyncGroupService/index.js @@ -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; diff --git a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts index f0f0bf4be..b8f6c9709 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -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; } diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 946dee124..d20ef2f76 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -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 = new Map(); viewportsById: Map = 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; diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index 4f43aff23..1f5247bb1 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -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 { @@ -281,7 +315,7 @@ class ViewportInfo { colormap: option.colormap, slabThickness: option.slabThickness, blendMode, - presetName: option.presetName, + displayPreset: option.displayPreset, }); }); diff --git a/extensions/cornerstone/src/types/CornerstoneCacheService.ts b/extensions/cornerstone/src/types/CornerstoneCacheService.ts index 2a877e9c0..ba79745a0 100644 --- a/extensions/cornerstone/src/types/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/types/CornerstoneCacheService.ts @@ -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 }; diff --git a/extensions/cornerstone/src/types/CornerstoneServices.ts b/extensions/cornerstone/src/types/CornerstoneServices.ts new file mode 100644 index 000000000..2f52dc9ff --- /dev/null +++ b/extensions/cornerstone/src/types/CornerstoneServices.ts @@ -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; diff --git a/extensions/cornerstone/src/types/index.ts b/extensions/cornerstone/src/types/index.ts index 8c8e32e98..6c729817d 100644 --- a/extensions/cornerstone/src/types/index.ts +++ b/extensions/cornerstone/src/types/index.ts @@ -1,5 +1,7 @@ import * as CornerstoneCacheService from './CornerstoneCacheService' +import CornerstoneServices from './CornerstoneServices'; export type { - CornerstoneCacheService + CornerstoneCacheService, + CornerstoneServices, } diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index 3c44131eb..cfab216e7 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -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 diff --git a/extensions/cornerstone/src/utils/DicomFileUploader.ts b/extensions/cornerstone/src/utils/DicomFileUploader.ts new file mode 100644 index 000000000..64465e597 --- /dev/null +++ b/extensions/cornerstone/src/utils/DicomFileUploader.ts @@ -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 { + if (this._loadPromise) { + // Already started loading, return the load promise. + return this._loadPromise; + } + + this._loadPromise = new Promise((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]); + } +} diff --git a/extensions/cornerstone/src/utils/colormap/applyPreset.js b/extensions/cornerstone/src/utils/colormap/applyPreset.js deleted file mode 100644 index 087ce4857..000000000 --- a/extensions/cornerstone/src/utils/colormap/applyPreset.js +++ /dev/null @@ -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; -} diff --git a/extensions/cornerstone/src/utils/colormap/colors.js b/extensions/cornerstone/src/utils/colormap/colors.js deleted file mode 100644 index 2cc80a9a2..000000000 --- a/extensions/cornerstone/src/utils/colormap/colors.js +++ /dev/null @@ -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; diff --git a/extensions/cornerstone/src/utils/colormap/presets.js b/extensions/cornerstone/src/utils/colormap/presets.js deleted file mode 100644 index 4db2da8e3..000000000 --- a/extensions/cornerstone/src/utils/colormap/presets.js +++ /dev/null @@ -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; diff --git a/extensions/cornerstone/src/utils/colormap/transferFunctionHelpers.js b/extensions/cornerstone/src/utils/colormap/transferFunctionHelpers.js deleted file mode 100644 index 4bbc25713..000000000 --- a/extensions/cornerstone/src/utils/colormap/transferFunctionHelpers.js +++ /dev/null @@ -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, -}; diff --git a/extensions/cornerstone/src/utils/dicomLoaderService.js b/extensions/cornerstone/src/utils/dicomLoaderService.js index 54836fc12..4faaa0e6f 100644 --- a/extensions/cornerstone/src/utils/dicomLoaderService.js +++ b/extensions/cornerstone/src/utils/dicomLoaderService.js @@ -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); } } diff --git a/extensions/cornerstone/src/utils/findNearbyToolData.ts b/extensions/cornerstone/src/utils/findNearbyToolData.ts index ab57a67d2..95e934766 100644 --- a/extensions/cornerstone/src/utils/findNearbyToolData.ts +++ b/extensions/cornerstone/src/utils/findNearbyToolData.ts @@ -11,7 +11,7 @@ export const findNearbyToolData = (commandsManager, evt) => { } const { element, currentPoints } = evt.detail; return commandsManager.runCommand( - 'getNearbyToolData', + 'getNearbyAnnotation', { element, canvasCoordinates: currentPoints?.canvas, diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.ts similarity index 100% rename from extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js rename to extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.ts diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.ts similarity index 100% rename from extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js rename to extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.ts diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/CircleROI.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/CircleROI.ts new file mode 100644 index 000000000..c55d7da4b --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/CircleROI.ts @@ -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} mm2`); + + // 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} ${unit} `; + } + + const str = `${maxStr}(S:${SeriesNumber}${instanceText}${frameText})`; + if (!displayText.includes(str)) { + displayText.push(str); + } + }); + + return displayText; +} + +export default CircleROI; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.js b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.ts similarity index 100% rename from extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.js rename to extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalROI.ts diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts similarity index 90% rename from extensions/cornerstone/src/utils/measurementServiceMappings/Length.js rename to extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts index 206208257..44cf64ac0 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts @@ -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})` diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts index 7560dc137..01f1178b9 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/RectangleROI.ts @@ -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; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/constants/supportedTools.js b/extensions/cornerstone/src/utils/measurementServiceMappings/constants/supportedTools.js index 00be9ae14..e1e8a1755 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/constants/supportedTools.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/constants/supportedTools.js @@ -1,6 +1,7 @@ export default [ 'Length', 'EllipticalROI', + 'CircleROI', 'Bidirectional', 'ArrowAnnotate', 'Angle', diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.ts index 4d5c77f17..2a4562e7d 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.ts @@ -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 => diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js index b93226bb1..dd6992b83 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js @@ -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); -// } -// } -// } diff --git a/extensions/cornerstone/src/utils/transitions.ts b/extensions/cornerstone/src/utils/transitions.ts index 2fa96794d..28d2dacd0 100644 --- a/extensions/cornerstone/src/utils/transitions.ts +++ b/extensions/cornerstone/src/utils/transitions.ts @@ -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; +} diff --git a/extensions/default/package.json b/extensions/default/package.json index 0ca309f62..c2e884c98 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -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" }, diff --git a/extensions/default/src/CustomizeableContextMenu/index.ts b/extensions/default/src/CustomizeableContextMenu/index.ts index d630bcad9..7dc08dd3d 100644 --- a/extensions/default/src/CustomizeableContextMenu/index.ts +++ b/extensions/default/src/CustomizeableContextMenu/index.ts @@ -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'; diff --git a/extensions/default/src/DicomLocalDataSource/index.js b/extensions/default/src/DicomLocalDataSource/index.js index f10d427ba..fc8554b92 100644 --- a/extensions/default/src/DicomLocalDataSource/index.js +++ b/extensions/default/src/DicomLocalDataSource/index.js @@ -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, diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index 1af67936d..01943417e 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -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) { diff --git a/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.js b/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.ts similarity index 79% rename from extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.js rename to extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.ts index afe645cc2..9521e379b 100644 --- a/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.js +++ b/extensions/default/src/DicomWebDataSource/utils/StaticWadoClient.ts @@ -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): Record { + const lowerParams = {}; + Object.entries(queryParams).forEach(([key, value]) => { + lowerParams[key.toLowerCase()] = value; + }); + return lowerParams; } } diff --git a/extensions/default/src/DicomWebProxyDataSource/index.js b/extensions/default/src/DicomWebProxyDataSource/index.js new file mode 100644 index 000000000..e873237dd --- /dev/null +++ b/extensions/default/src/DicomWebProxyDataSource/index.js @@ -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 }; diff --git a/extensions/default/src/Panels/PanelStudyBrowser.tsx b/extensions/default/src/Panels/PanelStudyBrowser.tsx index 3e7a072cd..5ad698850 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.tsx @@ -12,7 +12,7 @@ const { sortStudyInstances, formatDate } = utils; function PanelStudyBrowser({ servicesManager, getImageSrc, - getStudiesForPatientByStudyInstanceUID, + getStudiesForPatientByMRN, requestDisplaySetCreationForStudy, dataSource, }) { @@ -64,12 +64,24 @@ function PanelStudyBrowser({ useEffect(() => { // Fetch all studies for the patient in each primary study async function fetchStudiesForPatient(StudyInstanceUID) { - const qidoStudiesForPatient = - (await getStudiesForPatientByStudyInstanceUID(StudyInstanceUID)) || []; + // current study qido + const qidoForStudyUID = await dataSource.query.studies.search({ + studyInstanceUid: StudyInstanceUID, + }); + + let qidoStudiesForPatient = qidoForStudyUID; + + // try to fetch the prior studies based on the patientID if the + // server can respond. + try { + qidoStudiesForPatient = await getStudiesForPatientByMRN( + qidoForStudyUID + ); + } catch (error) { + console.warn(error); + } - // TODO: This should be "naturalized DICOM JSON" studies const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient); - const actuallyMappedStudies = mappedStudies.map(qidoStudy => { return { studyInstanceUid: qidoStudy.StudyInstanceUID, @@ -77,29 +89,27 @@ function PanelStudyBrowser({ description: qidoStudy.StudyDescription, modalities: qidoStudy.ModalitiesInStudy, numInstances: qidoStudy.NumInstances, - // displaySets: [] }; }); - if (isMounted.current) { - setStudyDisplayList(prevArray => { - const ret = [...prevArray]; - for (const study of actuallyMappedStudies) { - if ( - !prevArray.find( - it => it.studyInstanceUid === study.studyInstanceUid - ) - ) { - ret.push(study); - } + + setStudyDisplayList(prevArray => { + const ret = [...prevArray]; + for (const study of actuallyMappedStudies) { + if ( + !prevArray.find( + it => it.studyInstanceUid === study.studyInstanceUid + ) + ) { + ret.push(study); } - return ret; - }); - } + } + return ret; + }); } StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [StudyInstanceUIDs, getStudiesForPatientByStudyInstanceUID]); + }, [StudyInstanceUIDs, getStudiesForPatientByMRN]); // // ~~ Initial Thumbnails useEffect(() => { @@ -254,7 +264,7 @@ PanelStudyBrowser.propTypes = { getImageIdsForDisplaySet: PropTypes.func.isRequired, }).isRequired, getImageSrc: PropTypes.func.isRequired, - getStudiesForPatientByStudyInstanceUID: PropTypes.func.isRequired, + getStudiesForPatientByMRN: PropTypes.func.isRequired, requestDisplaySetCreationForStudy: PropTypes.func.isRequired, }; @@ -286,34 +296,36 @@ function _mapDisplaySets(displaySets, thumbnailImageSrcMap) { const thumbnailDisplaySets = []; const thumbnailNoImageDisplaySets = []; - displaySets.forEach(ds => { - const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; - const componentType = _getComponentType(ds.Modality); + displaySets + .filter(ds => !ds.excludeFromThumbnailBrowser) + .forEach(ds => { + const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; + const componentType = _getComponentType(ds.Modality); - const array = - componentType === 'thumbnail' - ? thumbnailDisplaySets - : thumbnailNoImageDisplaySets; + const array = + componentType === 'thumbnail' + ? thumbnailDisplaySets + : thumbnailNoImageDisplaySets; - array.push({ - displaySetInstanceUID: ds.displaySetInstanceUID, - description: ds.SeriesDescription || '', - seriesNumber: ds.SeriesNumber, - modality: ds.Modality, - seriesDate: ds.SeriesDate, - seriesTime: ds.SeriesTime, - numInstances: ds.numImageFrames, - countIcon: ds.countIcon, - StudyInstanceUID: ds.StudyInstanceUID, - componentType, - imageSrc, - dragData: { - type: 'displayset', + array.push({ displaySetInstanceUID: ds.displaySetInstanceUID, - // .. Any other data to pass - }, + description: ds.SeriesDescription || '', + seriesNumber: ds.SeriesNumber, + modality: ds.Modality, + seriesDate: ds.SeriesDate, + seriesTime: ds.SeriesTime, + numInstances: ds.numImageFrames, + countIcon: ds.countIcon, + StudyInstanceUID: ds.StudyInstanceUID, + componentType, + imageSrc, + dragData: { + type: 'displayset', + displaySetInstanceUID: ds.displaySetInstanceUID, + // .. Any other data to pass + }, + }); }); - }); return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets]; } @@ -321,6 +333,7 @@ function _mapDisplaySets(displaySets, thumbnailImageSrcMap) { const thumbnailNoImageModalities = [ 'SR', 'SEG', + 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', diff --git a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx index 237b5553b..b935ebfc7 100644 --- a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; // import PanelStudyBrowser from './PanelStudyBrowser'; import getImageSrcFromImageId from './getImageSrcFromImageId'; -import getStudiesForPatientByStudyInstanceUID from './getStudiesForPatientByStudyInstanceUID'; +import getStudiesForPatientByMRN from './getStudiesForPatientByMRN'; import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStudy'; /** @@ -21,7 +21,7 @@ function WrappedPanelStudyBrowser({ // TODO: This should be made available a different way; route should have // already determined our datasource const dataSource = extensionManager.getDataSources()[0]; - const _getStudiesForPatientByStudyInstanceUID = getStudiesForPatientByStudyInstanceUID.bind( + const _getStudiesForPatientByMRN = getStudiesForPatientByMRN.bind( null, dataSource ); @@ -38,9 +38,7 @@ function WrappedPanelStudyBrowser({ servicesManager={servicesManager} dataSource={dataSource} getImageSrc={_getImageSrcFromImageId} - getStudiesForPatientByStudyInstanceUID={ - _getStudiesForPatientByStudyInstanceUID - } + getStudiesForPatientByMRN={_getStudiesForPatientByMRN} requestDisplaySetCreationForStudy={_requestDisplaySetCreationForStudy} /> ); diff --git a/extensions/default/src/Panels/getImageSrcFromImageId.js b/extensions/default/src/Panels/getImageSrcFromImageId.js index f243e4de4..5cda3dff0 100644 --- a/extensions/default/src/Panels/getImageSrcFromImageId.js +++ b/extensions/default/src/Panels/getImageSrcFromImageId.js @@ -6,7 +6,7 @@ function getImageSrcFromImageId(cornerstone, imageId) { return new Promise((resolve, reject) => { const canvas = document.createElement('canvas'); cornerstone.utilities - .loadImageToCanvas(canvas, imageId) + .loadImageToCanvas({ canvas, imageId }) .then(imageId => { resolve(canvas.toDataURL()); }) diff --git a/extensions/default/src/Panels/getStudiesForPatientByMRN.js b/extensions/default/src/Panels/getStudiesForPatientByMRN.js new file mode 100644 index 000000000..5b74bde81 --- /dev/null +++ b/extensions/default/src/Panels/getStudiesForPatientByMRN.js @@ -0,0 +1,11 @@ +async function getStudiesForPatientByMRN(dataSource, qidoForStudyUID) { + if (qidoForStudyUID && qidoForStudyUID.length && qidoForStudyUID[0].mrn) { + return dataSource.query.studies.search({ + patientId: qidoForStudyUID[0].mrn, + }); + } + console.log('No mrn found for', qidoForStudyUID); + return qidoForStudyUID; +} + +export default getStudiesForPatientByMRN; diff --git a/extensions/default/src/Panels/getStudiesForPatientByStudyInstanceUID.js b/extensions/default/src/Panels/getStudiesForPatientByStudyInstanceUID.js deleted file mode 100644 index fb5baa539..000000000 --- a/extensions/default/src/Panels/getStudiesForPatientByStudyInstanceUID.js +++ /dev/null @@ -1,29 +0,0 @@ -async function getStudiesForPatientByStudyInstanceUID( - dataSource, - StudyInstanceUID -) { - if (StudyInstanceUID === undefined) { - return; - } - // TODO: The `DicomMetadataStore` should short-circuit both of these requests - // Data _could_ be here from route query, or if using JSON data source - // We could also force this to "await" these values being available in the DICOMStore? - // Kind of like promise fulfillment in cornerstone-wado-image-loader when there are multiple - // outgoing requests for the same data - const getStudyResult = await dataSource.query.studies.search({ - studyInstanceUid: StudyInstanceUID, - }); - - // TODO: To Erik's point, the data source likely shouldn't deviate from - // Naturalized DICOM JSON when returning. It makes things like this awkward (mrn) - if (getStudyResult && getStudyResult.length && getStudyResult[0].mrn) { - return dataSource.query.studies.search({ - patientId: getStudyResult[0].mrn, - }); - } - console.log('No mrn found for', getStudyResult); - // The original study we KNOW belongs to the same set, so just return it - return getStudyResult; -} - -export default getStudiesForPatientByStudyInstanceUID; diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index 30b8932a5..fc2ad3626 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -46,13 +46,27 @@ function ViewerLayout({ const onClickReturnButton = () => { const { pathname } = location; const dataSourceIdx = pathname.indexOf('/', 1); - const search = - dataSourceIdx === -1 - ? undefined - : `datasources=${pathname.substring(dataSourceIdx + 1)}`; + // const search = + // dataSourceIdx === -1 + // ? undefined + // : `datasources=${pathname.substring(dataSourceIdx + 1)}`; + + // Todo: Handle parameters in a better way. + const query = new URLSearchParams(window.location.search); + const configUrl = query.get('configUrl'); + + const searchQuery = new URLSearchParams(); + if (dataSourceIdx !== -1) { + searchQuery.append('datasources', pathname.substring(dataSourceIdx + 1)); + } + + if (configUrl) { + searchQuery.append('configUrl', configUrl); + } + navigate({ pathname: '/', - search, + search: decodeURIComponent(searchQuery.toString()), }); }; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index a14563e93..238cd37ff 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -1,4 +1,4 @@ -import { ServicesManager, utils } from '@ohif/core'; +import { ServicesManager, utils, Types } from '@ohif/core'; import { ContextMenuController, @@ -11,6 +11,8 @@ import findViewportsByPosition, { } from './findViewportsByPosition'; import { ContextMenuProps } from './CustomizeableContextMenu/types'; +import { NavigateHistory } from './types/commandModuleTypes'; +import { history } from '@ohif/viewer'; const { subscribeToNextViewportGridChange } = utils; @@ -171,7 +173,7 @@ const commandsModule = ({ reset = false, }: HangingProtocolParams): boolean => { try { - // Stores in the state the reuseID to displaySetUID mapping + // Stores in the state the display set selector id to displaySetUID mapping // Pass in viewportId for the active viewport. This item will get set as // the activeViewportId const state = viewportGridService.getState(); @@ -242,6 +244,12 @@ const commandsModule = ({ } } // Do this after successfully applying the update + // Note, don't store the active display set - it is only needed while + // changing display sets. This causes jump to measurement to fail on + // multi-study display. + delete displaySetSelectorMap[ + `${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0` + ]; stateSyncService.store(stateSyncReduce); // This is a default action applied actions.toggleHpTools(hangingProtocolService.getActiveProtocol()); @@ -480,6 +488,28 @@ const commandsModule = ({ } }, + /** + * Exposes the browser history navigation used by OHIF. This command can be used to either replace or + * push a new entry into the browser history. For example, the following will replace the current + * browser history entry with the specified relative URL which changes the study displayed to the + * study with study instance UID 1.2.3. Note that as a result of using `options.replace = true`, the + * page prior to invoking this command cannot be returned to via the browser back button. + * + * navigateHistory({ + * to: 'viewer?StudyInstanceUIDs=1.2.3', + * options: { replace: true }, + * }); + * + * @param historyArgs - arguments for the history function; + * the `to` property is the URL; + * the `options.replace` is a boolean indicating if the current browser history entry + * should be replaced or a new entry pushed onto the history (stack); the default value + * for `replace` is false + */ + navigateHistory(historyArgs: NavigateHistory) { + history.navigate(historyArgs.to, historyArgs.options); + }, + openDICOMTagViewer() { const { activeViewportIndex, viewports } = viewportGridService.getState(); const activeViewportSpecificData = viewports[activeViewportIndex]; @@ -540,6 +570,11 @@ const commandsModule = ({ storeContexts: [], options: {}, }, + navigateHistory: { + commandFn: actions.navigateHistory, + storeContexts: [], + options: {}, + }, nextStage: { commandFn: actions.deltaStage, storeContexts: [], diff --git a/extensions/default/src/getDataSourcesModule.js b/extensions/default/src/getDataSourcesModule.js index dfccc6a18..6eab56a2f 100644 --- a/extensions/default/src/getDataSourcesModule.js +++ b/extensions/default/src/getDataSourcesModule.js @@ -5,6 +5,7 @@ import { createDicomWebApi } from './DicomWebDataSource/index.js'; import { createDicomJSONApi } from './DicomJSONDataSource/index.js'; import { createDicomLocalApi } from './DicomLocalDataSource/index.js'; +import { createDicomWebProxyApi } from './DicomWebProxyDataSource/index.js'; /** * @@ -16,6 +17,11 @@ function getDataSourcesModule() { type: 'webApi', createDataSource: createDicomWebApi, }, + { + name: 'dicomwebproxy', + type: 'webApi', + createDataSource: createDicomWebProxyApi, + }, { name: 'dicomjson', type: 'jsonApi', diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index a975a8968..5875e8e76 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -1,3 +1,5 @@ +import hpMNGrid from './hpMNGrid'; + const defaultProtocol = { id: 'default', locked: true, @@ -7,7 +9,7 @@ const defaultProtocol = { hasUpdatedPriorsInformation: false, name: 'Default', createdDate: '2021-02-23T19:22:08.894Z', - modifiedDate: '2021-02-23T19:22:08.894Z', + modifiedDate: '2023-04-01', availableTo: {}, editableBy: {}, protocolMatchingRules: [], @@ -33,8 +35,6 @@ const defaultProtocol = { }, displaySetSelectors: { defaultDisplaySetId: { - // Unused currently - imageMatchingRules: [], // Matches displaysets, NOT series seriesMatchingRules: [ // Try to match series with images by default, to prevent weird display @@ -45,6 +45,15 @@ const defaultProtocol = { greaterThan: { value: 0 }, }, }, + // This display set will select the specified items by preference + // It has no affect if nothing is specified in the URL. + { + attribute: 'isDisplaySetFromUrl', + weight: 10, + constraint: { + equals: true, + }, + }, ], // Can be used to select matching studies // studyMatchingRules: [], @@ -65,7 +74,13 @@ const defaultProtocol = { viewportOptions: { viewportType: 'stack', toolGroupId: 'default', - // initialImageOptions: { + // This will specify the initial image options index if it matches in the URL + // and will otherwise not specify anything. + initialImageOptions: { + custom: 'sopInstanceLocation', + }, + // Other options for initialImageOptions, which can be included in the default + // custom attribute, or can be provided directly. // index: 180, // preset: 'middle', // 'first', 'last', 'middle' // }, @@ -79,211 +94,6 @@ const defaultProtocol = { ], createdDate: '2021-02-23T18:32:42.850Z', }, - - // This is an example of a 2x2 layout that requires at least 2 viewports - // filled to be navigatable to - { - name: '2x2', - // Indicate that the number of viewports needed is 2 filled viewports, - // but that 4 viewports are preferred. - stageActivation: { - enabled: { - minViewportsMatched: 4, - }, - passive: { - minViewportsMatched: 2, - }, - }, - - viewportStructure: { - layoutType: 'grid', - properties: { - rows: 2, - columns: 2, - }, - }, - viewports: [ - { - viewportOptions: { - toolGroupId: 'default', - }, - displaySets: [ - { - id: 'defaultDisplaySetId', - matchedDisplaySetsIndex: 3, - }, - ], - }, - { - viewportOptions: { - toolGroupId: 'default', - }, - displaySets: [ - { - id: 'defaultDisplaySetId', - matchedDisplaySetsIndex: 2, - }, - ], - }, - { - viewportOptions: { - toolGroupId: 'default', - }, - displaySets: [ - { - id: 'defaultDisplaySetId', - matchedDisplaySetsIndex: 1, - }, - ], - }, - { - viewportOptions: {}, - displaySets: [ - { - id: 'defaultDisplaySetId', - matchedDisplaySetsIndex: 0, - }, - ], - }, - ], - }, - - { - name: '3x1', - // Indicate that the number of viewports needed is 2 filled viewports, - // but that 4 viewports are preferred. - stageActivation: { - enabled: { - minViewportsMatched: 3, - }, - }, - - viewportStructure: { - layoutType: 'grid', - properties: { - rows: 1, - columns: 3, - }, - }, - viewports: [ - { - viewportOptions: { - toolGroupId: 'default', - }, - displaySets: [ - { - id: 'defaultDisplaySetId', - matchedDisplaySetsIndex: 2, - }, - ], - }, - { - viewportOptions: { - toolGroupId: 'default', - }, - displaySets: [ - { - id: 'defaultDisplaySetId', - matchedDisplaySetsIndex: 1, - }, - ], - }, - { - viewportOptions: {}, - displaySets: [ - { - id: 'defaultDisplaySetId', - }, - ], - }, - ], - }, - - // This is an example of a layout with more than one element in it - // It can be navigated to using , and . (prev/next stage) - { - name: '2x1', - // Indicate that the number of viewports needed is 1 filled viewport, - // but that 2 viewports are preferred. - stageActivation: { - enabled: { - minViewportsMatched: 3, - }, - }, - - viewportStructure: { - layoutType: 'grid', - properties: { - rows: 1, - columns: 2, - }, - }, - viewports: [ - { - viewportOptions: { - toolGroupId: 'default', - // initialImageOptions: { - // index: 180, - // preset: 'middle', // 'first', 'last', 'middle' - // }, - }, - displaySets: [ - { - id: 'defaultDisplaySetId', - // Shows the second index of this image set - matchedDisplaySetsIndex: 1, - }, - ], - }, - { - viewportOptions: {}, - displaySets: [ - { - id: 'defaultDisplaySetId', - }, - ], - }, - ], - }, - - { - name: '2x1', - // Indicate that the number of viewports needed is 1 filled viewport, - // but that 2 viewports are preferred. - stageActivation: { - enabled: { - minViewportsMatched: 3, - }, - }, - - viewportStructure: { - layoutType: 'grid', - properties: { - rows: 2, - columns: 1, - }, - }, - viewports: [ - { - viewportOptions: {}, - displaySets: [ - { - id: 'defaultDisplaySetId', - // Shows the second index of this image set - matchedDisplaySetsIndex: 1, - }, - ], - }, - { - viewportOptions: {}, - displaySets: [ - { - id: 'defaultDisplaySetId', - }, - ], - }, - ], - }, ], }; @@ -293,6 +103,11 @@ function getHangingProtocolModule() { name: defaultProtocol.id, protocol: defaultProtocol, }, + // Create a MxN hanging protocol available by default + { + name: hpMNGrid.id, + protocol: hpMNGrid, + }, ]; } diff --git a/extensions/default/src/getSopClassHandlerModule.js b/extensions/default/src/getSopClassHandlerModule.js index f00018187..df1900051 100644 --- a/extensions/default/src/getSopClassHandlerModule.js +++ b/extensions/default/src/getSopClassHandlerModule.js @@ -14,7 +14,10 @@ const makeDisplaySet = instances => { const instance = instances[0]; const imageSet = new ImageSet(instances); - const displayReconstructableInfo = isDisplaySetReconstructable(instances); + const { + value: isReconstructable, + averageSpacingBetweenFrames, + } = isDisplaySetReconstructable(instances); // set appropriate attributes to image set... imageSet.setAttributes({ @@ -29,10 +32,11 @@ const makeDisplaySet = instances => { SeriesDescription: instance.SeriesDescription || '', Modality: instance.Modality, isMultiFrame: isMultiFrame(instance), - countIcon: displayReconstructableInfo.value ? 'icon-mpr' : undefined, + countIcon: isReconstructable ? 'icon-mpr' : undefined, numImageFrames: instances.length, SOPClassHandlerId: `${id}.sopClassHandlerModule.${sopClassHandlerName}`, - isReconstructable: displayReconstructableInfo.value, + isReconstructable, + averageSpacingBetweenFrames: averageSpacingBetweenFrames || null, }); // Sort the images in this series if needed diff --git a/extensions/test-extension/src/hp/hpMN.ts b/extensions/default/src/hpMNGrid.ts similarity index 88% rename from extensions/test-extension/src/hp/hpMN.ts rename to extensions/default/src/hpMNGrid.ts index fc8fe4538..2e3206615 100644 --- a/extensions/test-extension/src/hp/hpMN.ts +++ b/extensions/default/src/hpMNGrid.ts @@ -1,24 +1,23 @@ import { Types } from '@ohif/core'; /** - * This hanging protocol has multiple stages, which are enabled when - * there are enough display sets with images to fill the stage, and - * are passive when there is at least one display set. - * Enabled display sets are navigated to by default, while passive ones - * are navigated to manually using the ctrl+end keyboard shortcut. + * This hanging protocol can be activated on the primary mode by directly + * referencing it in a URL or by directly including it within a mode, e.g.: + * `&hangingProtocolId=@ohif/mnGrid` added to the viewer URL + * It is not included in the viewer mode by default. */ const hpMN: Types.HangingProtocol.Protocol = { hasUpdatedPriorsInformation: false, - id: '@ohif/hp-extension.mn', - description: 'Has various hanging protocol layouts for use in testing', + id: '@ohif/mnGrid', + description: 'Has various hanging protocol grid layouts', name: '2x2', protocolMatchingRules: [ { id: 'OneOrMoreSeries', - weight: 1, + weight: 25, attribute: 'numberOfDisplaySetsWithImages', constraint: { - greaterThan: 1, + greaterThan: 0, }, }, ], @@ -32,6 +31,15 @@ const hpMN: Types.HangingProtocol.Protocol = { greaterThan: { value: 0 }, }, }, + // This display set will select the specified items by preference + // It has no affect if nothing is specified in the URL. + { + attribute: 'isDisplaySetFromUrl', + weight: 10, + constraint: { + equals: true, + }, + }, ], }, }, @@ -142,7 +150,6 @@ const hpMN: Types.HangingProtocol.Protocol = { displaySets: [ { id: 'defaultDisplaySetId', - reuseId: '0-0', }, ], }, @@ -153,9 +160,8 @@ const hpMN: Types.HangingProtocol.Protocol = { }, displaySets: [ { + id: 'defaultDisplaySetId', matchedDisplaySetsIndex: 1, - id: 'defaultDisplaySetId', - reuseId: '1-0', }, ], }, @@ -166,9 +172,8 @@ const hpMN: Types.HangingProtocol.Protocol = { }, displaySets: [ { - matchedDisplaySetsIndex: 2, id: 'defaultDisplaySetId', - reuseId: '0-1', + matchedDisplaySetsIndex: 2, }, ], }, diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index bb4a63c52..c56f39b7e 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -7,7 +7,7 @@ import getSopClassHandlerModule from './getSopClassHandlerModule.js'; import getToolbarModule from './getToolbarModule'; import getCommandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; -import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID'; +import getStudiesForPatientByMRN from './Panels/getStudiesForPatientByMRN'; import getCustomizationModule from './getCustomizationModule'; import { id } from './id.js'; import preRegistration from './init'; @@ -34,7 +34,7 @@ const defaultExtension: Types.Extensions.Extension = { { name: 'common', exports: { - getStudiesForPatientByStudyInstanceUID, + getStudiesForPatientByMRN, }, }, ]; @@ -45,4 +45,8 @@ const defaultExtension: Types.Extensions.Extension = { export default defaultExtension; -export { ContextMenuController, CustomizeableContextMenuTypes }; +export { + ContextMenuController, + CustomizeableContextMenuTypes, + getStudiesForPatientByMRN, +}; diff --git a/extensions/default/src/types/commandModuleTypes.tsx b/extensions/default/src/types/commandModuleTypes.tsx new file mode 100644 index 000000000..d2cdde134 --- /dev/null +++ b/extensions/default/src/types/commandModuleTypes.tsx @@ -0,0 +1,6 @@ +export type NavigateHistory = { + to: string; // the URL to navigate to + options?: { + replace?: boolean; // replace or add/push to history? + }; +}; diff --git a/extensions/default/src/utils/findSRWithSameSeriesDescription.ts b/extensions/default/src/utils/findSRWithSameSeriesDescription.ts index 476343fb5..d50c76976 100644 --- a/extensions/default/src/utils/findSRWithSameSeriesDescription.ts +++ b/extensions/default/src/utils/findSRWithSameSeriesDescription.ts @@ -40,7 +40,7 @@ export default function findSRWithSameSeriesDescription( SeriesTime, SeriesNumber, Modality, - InstanceNumber: sameSeries.others.length + 1, + InstanceNumber: sameSeries.instances.length + 1, }; } diff --git a/extensions/default/src/utils/getDirectURL.js b/extensions/default/src/utils/getDirectURL.js index e90ff17af..4580b8d30 100644 --- a/extensions/default/src/utils/getDirectURL.js +++ b/extensions/default/src/utils/getDirectURL.js @@ -19,13 +19,13 @@ import { * @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart, * or is already retrieved, or a promise to a URL for such use if a BulkDataURI */ -const getDirectURL = (wadoRoot, params) => { +const getDirectURL = (config, params) => { + const { wadoRoot, singlepart } = config; const { instance, tag = 'PixelData', defaultPath = '/pixeldata', defaultType = 'video/mp4', - singlepart = null, singlepart: fetchPart = 'video', } = params; const value = instance[tag]; @@ -53,11 +53,7 @@ const getDirectURL = (wadoRoot, params) => { return undefined; } - const { - StudyInstanceUID, - SeriesInstanceUID, - SOPInstanceUID, - } = instance; + const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance; const BulkDataURI = (value && value.BulkDataURI) || `series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`; @@ -66,7 +62,13 @@ const getDirectURL = (wadoRoot, params) => { const acceptUri = BulkDataURI + (hasAccept ? '' : (hasQuery ? '&' : '?') + `accept=${defaultType}`); - if (BulkDataURI.indexOf('http') === 0) return acceptUri; + if (BulkDataURI.indexOf('http') === 0) { + if (tag === 'PixelData' || tag === 'EncapsulatedDocument') { + return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`; + } else { + return acceptUri; + } + } if (BulkDataURI.indexOf('/') === 0) { return wadoRoot + acceptUri; } diff --git a/extensions/dicom-microscopy/.gitignore b/extensions/dicom-microscopy/.gitignore new file mode 100644 index 000000000..67045665d --- /dev/null +++ b/extensions/dicom-microscopy/.gitignore @@ -0,0 +1,104 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port diff --git a/extensions/dicom-microscopy/.prettierrc b/extensions/dicom-microscopy/.prettierrc new file mode 100644 index 000000000..b80ec6b34 --- /dev/null +++ b/extensions/dicom-microscopy/.prettierrc @@ -0,0 +1,8 @@ +{ + "trailingComma": "es5", + "printWidth": 80, + "proseWrap": "always", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/extensions/dicom-microscopy/.webpack/webpack.dev.js b/extensions/dicom-microscopy/.webpack/webpack.dev.js new file mode 100644 index 000000000..db7c206b1 --- /dev/null +++ b/extensions/dicom-microscopy/.webpack/webpack.dev.js @@ -0,0 +1,8 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); +}; diff --git a/extensions/dicom-microscopy/.webpack/webpack.prod.js b/extensions/dicom-microscopy/.webpack/webpack.prod.js new file mode 100644 index 000000000..070a723e3 --- /dev/null +++ b/extensions/dicom-microscopy/.webpack/webpack.prod.js @@ -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; diff --git a/extensions/dicom-microscopy/LICENSE b/extensions/dicom-microscopy/LICENSE new file mode 100644 index 000000000..effbc2d2f --- /dev/null +++ b/extensions/dicom-microscopy/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023 dicom-microscopy (26860200+md-prog@users.noreply.github.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/extensions/dicom-microscopy/README.md b/extensions/dicom-microscopy/README.md new file mode 100644 index 000000000..cecf00af2 --- /dev/null +++ b/extensions/dicom-microscopy/README.md @@ -0,0 +1,11 @@ +# OHIF extension for microscopy +Adapter for *DICOM Microscopy Viewer* to get it integrated into OHIF Viewer. + +## Acknowledgements + +- [DICOM Microscopy Viewer](https://github.com/ImagingDataCommons/dicom-microscopy-viewer) is a Vanilla JS library for web-based visualization of DICOM VL Whole Slide Microscopy Image datasets and derived information. +- [SLIM Viewer](https://github.com/imagingdatacommons/slim) is a single-page application for interactive visualization and annotation of digital whole slide microscopy images and derived image analysis results in standard DICOM format. The application is based on the dicom-microscopy-viewer JavaScript library and runs fully client side without any custom server components. + + +## License +MIT diff --git a/extensions/dicom-microscopy/babel.config.js b/extensions/dicom-microscopy/babel.config.js new file mode 100644 index 000000000..92fbbdeaf --- /dev/null +++ b/extensions/dicom-microscopy/babel.config.js @@ -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__'], + }, + }, +}; diff --git a/extensions/dicom-microscopy/package.json b/extensions/dicom-microscopy/package.json new file mode 100644 index 000000000..d3b3fc02c --- /dev/null +++ b/extensions/dicom-microscopy/package.json @@ -0,0 +1,79 @@ +{ + "name": "@ohif/extension-dicom-microscopy", + "version": "3.0.0", + "description": "OHIF extension for DICOM microscopy", + "author": "Bill Wallace, md-prog", + "license": "MIT", + "main": "dist/umd/@ohif/extension-dicom-microscopy/index.umd.js", + "files": [ + "dist/**", + "public/**", + "README.md" + ], + "repository": "OHIF/Viewers", + "keywords": [ + "ohif-extension" + ], + "module": "src/index.tsx", + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.18.0" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo", + "dev:dicom-pdf": "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/i18n": "^1.0.0", + "@ohif/ui": "^2.0.0", + "prop-types": "^15.6.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-i18next": "^10.11.0", + "react-router": "^6.8.1", + "react-router-dom": "^6.8.1", + "webpack": "^5.50.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "@babel/runtime": "^7.20.13", + "@cornerstonejs/codec-charls": "^1.2.3", + "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", + "@cornerstonejs/codec-openjpeg": "^1.2.2", + "colormap": "^2.3", + "dicom-microscopy-viewer": "^0.44.0", + "dicomicc": "^0.1" + }, + "devDependencies": { + "@babel/core": "^7.21.4", + "@babel/plugin-proposal-class-properties": "^7.16.7", + "@babel/plugin-proposal-object-rest-spread": "^7.17.3", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.16.7", + "@babel/plugin-transform-regenerator": "^7.16.7", + "@babel/plugin-transform-runtime": "^7.17.0", + "@babel/plugin-transform-typescript": "^7.13.0", + "@babel/preset-env": "^7.16.11", + "@babel/preset-react": "^7.16.7", + "@babel/preset-typescript": "^7.13.0", + "babel-eslint": "^8.0.3", + "babel-loader": "^8.0.0-beta.4", + "babel-plugin-inline-react-svg": "^2.0.1", + "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-cli": "^4.7.2", + "webpack-merge": "^5.7.3" + } +} diff --git a/extensions/dicom-microscopy/src/DicomMicroscopySRSopClassHandler.js b/extensions/dicom-microscopy/src/DicomMicroscopySRSopClassHandler.js new file mode 100644 index 000000000..9f5fb3979 --- /dev/null +++ b/extensions/dicom-microscopy/src/DicomMicroscopySRSopClassHandler.js @@ -0,0 +1,143 @@ +import OHIF, { DicomMetadataStore } from '@ohif/core'; +import loadSR from './utils/loadSR'; +import toArray from './utils/toArray'; +import DCM_CODE_VALUES from './utils/dcmCodeValues'; +import getSourceDisplaySet from './utils/getSourceDisplaySet'; + +const { utils } = OHIF; + +const SOP_CLASS_UIDS = { + COMPREHENSIVE_3D_SR: '1.2.840.10008.5.1.4.1.1.88.34', +}; + +const SOPClassHandlerId = + '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler'; + +function _getReferencedFrameOfReferenceUID(naturalizedDataset) { + const { ContentSequence } = naturalizedDataset; + + const imagingMeasurementsContentItem = ContentSequence.find( + ci => + ci.ConceptNameCodeSequence.CodeValue === + DCM_CODE_VALUES.IMAGING_MEASUREMENTS + ); + + const firstMeasurementGroupContentItem = toArray( + imagingMeasurementsContentItem.ContentSequence + ).find( + ci => + ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.MEASUREMENT_GROUP + ); + + const imageRegionContentItem = toArray( + firstMeasurementGroupContentItem.ContentSequence + ).find( + ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.IMAGE_REGION + ); + + return imageRegionContentItem.ReferencedFrameOfReferenceUID; +} + +function _getDisplaySetsFromSeries( + instances, + servicesManager, + extensionManager +) { + // If the series has no instances, stop here + if (!instances || !instances.length) { + throw new Error('No instances were provided'); + } + + const { displaySetService, microscopyService } = servicesManager.services; + + const instance = instances[0]; + + // TODO ! Consumption of DICOMMicroscopySRSOPClassHandler to a derived dataset or normal dataset? + // TOOD -> Easy to swap this to a "non-derived" displaySet, but unfortunately need to put it in a different extension. + const naturalizedDataset = DicomMetadataStore.getSeries( + instance.StudyInstanceUID, + instance.SeriesInstanceUID + ).instances[0]; + const ReferencedFrameOfReferenceUID = _getReferencedFrameOfReferenceUID( + naturalizedDataset + ); + + const { + FrameOfReferenceUID, + SeriesDescription, + ContentDate, + ContentTime, + SeriesNumber, + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID, + SOPClassUID, + } = instance; + + const displaySet = { + plugin: 'microscopy', + Modality: 'SR', + altImageText: 'Microscopy SR', + displaySetInstanceUID: utils.guid(), + SOPInstanceUID, + SeriesInstanceUID, + StudyInstanceUID, + ReferencedFrameOfReferenceUID, + SOPClassHandlerId, + SOPClassUID, + SeriesDescription, + // Map the content date/time to the series date/time, these are only used for filtering. + SeriesDate: ContentDate, + SeriesTime: ContentTime, + SeriesNumber, + instance, + metadata: naturalizedDataset, + isDerived: true, + isLoading: false, + isLoaded: false, + loadError: false, + }; + + displaySet.load = function(referencedDisplaySet) { + return loadSR(microscopyService, displaySet, referencedDisplaySet).catch( + error => { + displaySet.isLoaded = false; + displaySet.loadError = true; + throw new Error(error); + } + ); + }; + + displaySet.getSourceDisplaySet = function() { + let allDisplaySets = []; + const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); + studyMetadata.series.forEach(series => { + const displaySets = displaySetService.getDisplaySetsForSeries( + series.SeriesInstanceUID + ); + allDisplaySets = allDisplaySets.concat(displaySets); + }); + return getSourceDisplaySet(allDisplaySets, displaySet); + }; + + return [displaySet]; +} + +export default function getDicomMicroscopySRSopClassHandler({ + servicesManager, + extensionManager, +}) { + const getDisplaySetsFromSeries = instances => { + return _getDisplaySetsFromSeries( + instances, + servicesManager, + extensionManager + ); + }; + + return { + name: 'DicomMicroscopySRSopClassHandler', + sopClassUids: [SOP_CLASS_UIDS.COMPREHENSIVE_3D_SR], + getDisplaySetsFromSeries, + }; +} diff --git a/extensions/dicom-microscopy/src/DicomMicroscopySopClassHandler.js b/extensions/dicom-microscopy/src/DicomMicroscopySopClassHandler.js new file mode 100644 index 000000000..d7c6af93e --- /dev/null +++ b/extensions/dicom-microscopy/src/DicomMicroscopySopClassHandler.js @@ -0,0 +1,132 @@ +import OHIF from '@ohif/core'; + +const { utils } = OHIF; + +const SOP_CLASS_UIDS = { + VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6', +}; + +const SOPClassHandlerId = + '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler'; + +function _getDisplaySetsFromSeries( + instances, + servicesManager, + extensionManager +) { + // If the series has no instances, stop here + if (!instances || !instances.length) { + throw new Error('No instances were provided'); + } + + const instance = instances[0]; + + let singleFrameInstance = instance; + let currentFrames = +singleFrameInstance.NumberOfFrames || 1; + for (const instanceI of instances) { + const framesI = +instanceI.NumberOfFrames || 1; + if (framesI < currentFrames) { + singleFrameInstance = instanceI; + currentFrames = framesI; + } + } + let imageIdForThumbnail = null; + if (singleFrameInstance) { + if (currentFrames == 1) { + // Not all DICOM server implementations support thumbnail service, + // So if we have a single-frame image, we will prefer it. + imageIdForThumbnail = singleFrameInstance.imageId; + } + if (!imageIdForThumbnail) { + // use the thumbnail service provided by DICOM server + const dataSource = extensionManager.getActiveDataSource()[0]; + imageIdForThumbnail = dataSource.getImageIdsForInstance({ + instance: singleFrameInstance, + thumbnail: true, + }); + } + } + + const { + FrameOfReferenceUID, + SeriesDescription, + ContentDate, + ContentTime, + SeriesNumber, + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID, + SOPClassUID, + } = instance; + + instances = instances.map(inst => { + // NOTE: According to DICOM standard a series should have a FrameOfReferenceUID + // When the Microscopy file was built by certain tool from multiple image files, + // each instance's FrameOfReferenceUID is sometimes different. + // Even though this means the file was not well formatted DICOM VL Whole Slide Microscopy Image, + // the case is so often, so let's override this value manually here. + // + // https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_C.7.4.html#sect_C.7.4.1.1.1 + + inst.FrameOfReferenceUID = instance.FrameOfReferenceUID; + + return inst; + }); + + const othersFrameOfReferenceUID = instances + .filter(v => v) + .map(inst => inst.FrameOfReferenceUID) + .filter((value, index, array) => array.indexOf(value) === index); + if (othersFrameOfReferenceUID.length > 1) { + console.warn( + 'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values', + othersFrameOfReferenceUID + ); + } + + const displaySet = { + plugin: 'microscopy', + Modality: 'SM', + altImageText: 'Microscopy', + displaySetInstanceUID: utils.guid(), + SOPInstanceUID, + SeriesInstanceUID, + StudyInstanceUID, + FrameOfReferenceUID, + SOPClassHandlerId, + SOPClassUID, + SeriesDescription: SeriesDescription || 'Microscopy Data', + // Map ContentDate/Time to SeriesTime for series list sorting. + SeriesDate: ContentDate, + SeriesTime: ContentTime, + SeriesNumber, + firstInstance: singleFrameInstance, // top level instance in the image Pyramid + instance, + numImageFrames: 0, + numInstances: 1, + imageIdForThumbnail, // thumbnail image + others: instances, // all other level instances in the image Pyramid + othersFrameOfReferenceUID, + }; + + return [displaySet]; +} + +export default function getDicomMicroscopySopClassHandler({ + servicesManager, + extensionManager, +}) { + const getDisplaySetsFromSeries = instances => { + return _getDisplaySetsFromSeries( + instances, + servicesManager, + extensionManager + ); + }; + + return { + name: 'DicomMicroscopySopClassHandler', + sopClassUids: [SOP_CLASS_UIDS.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE], + getDisplaySetsFromSeries, + }; +} diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.css b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.css new file mode 100644 index 000000000..7e10bb501 --- /dev/null +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.css @@ -0,0 +1,352 @@ +.DicomMicroscopyViewer .ol-box { + box-sizing: border-box; + border-radius: 2px; + border: 1.5px solid var(--ol-background-color); + background-color: var(--ol-partial-background-color); +} + +.DicomMicroscopyViewer .ol-mouse-position { + top: 8px; + right: 8px; + position: absolute; +} + +.DicomMicroscopyViewer .ol-scale-line { + background: var(--ol-partial-background-color); + border-radius: 4px; + bottom: 8px; + left: 8px; + padding: 2px; + position: absolute; +} + +.DicomMicroscopyViewer .ol-scale-line-inner { + border: 1px solid var(--ol-subtle-foreground-color); + border-top: none; + color: var(--ol-foreground-color); + font-size: 10px; + text-align: center; + margin: 1px; + will-change: contents, width; + transition: all 0.25s; +} + +.DicomMicroscopyViewer .ol-scale-bar { + position: absolute; + bottom: 8px; + left: 8px; +} + +.DicomMicroscopyViewer .ol-scale-bar-inner { + display: flex; +} + +.DicomMicroscopyViewer .ol-scale-step-marker { + width: 1px; + height: 15px; + background-color: var(--ol-foreground-color); + float: right; + z-index: 10; +} + +.DicomMicroscopyViewer .ol-scale-step-text { + position: absolute; + bottom: -5px; + font-size: 10px; + z-index: 11; + color: var(--ol-foreground-color); + text-shadow: -1.5px 0 var(--ol-partial-background-color), + 0 1.5px var(--ol-partial-background-color), + 1.5px 0 var(--ol-partial-background-color), + 0 -1.5px var(--ol-partial-background-color); +} + +.DicomMicroscopyViewer .ol-scale-text { + position: absolute; + font-size: 12px; + text-align: center; + bottom: 25px; + color: var(--ol-foreground-color); + text-shadow: -1.5px 0 var(--ol-partial-background-color), + 0 1.5px var(--ol-partial-background-color), + 1.5px 0 var(--ol-partial-background-color), + 0 -1.5px var(--ol-partial-background-color); +} + +.DicomMicroscopyViewer .ol-scale-singlebar { + position: relative; + height: 10px; + z-index: 9; + box-sizing: border-box; + border: 1px solid var(--ol-foreground-color); +} + +.DicomMicroscopyViewer .ol-scale-singlebar-even { + background-color: var(--ol-subtle-foreground-color); +} + +.DicomMicroscopyViewer .ol-scale-singlebar-odd { + background-color: var(--ol-background-color); +} + +.DicomMicroscopyViewer .ol-unsupported { + display: none; +} + +.DicomMicroscopyViewer .ol-viewport, +.DicomMicroscopyViewer .ol-unselectable { + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.DicomMicroscopyViewer .ol-viewport canvas { + all: unset; +} + +.DicomMicroscopyViewer .ol-selectable { + -webkit-touch-callout: default; + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; +} + +.DicomMicroscopyViewer .ol-grabbing { + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; +} + +.DicomMicroscopyViewer .ol-grab { + cursor: move; + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; +} + +.DicomMicroscopyViewer .ol-control { + position: absolute; + background-color: var(--ol-subtle-background-color); + border-radius: 4px; +} + +.DicomMicroscopyViewer .ol-zoom { + top: 0.5em; + left: 0.5em; +} + +.DicomMicroscopyViewer .ol-rotate { + top: 0.5em; + right: 0.5em; + transition: opacity 0.25s linear, visibility 0s linear; +} + +.DicomMicroscopyViewer .ol-rotate.ol-hidden { + opacity: 0; + visibility: hidden; + transition: opacity 0.25s linear, visibility 0s linear 0.25s; +} + +.DicomMicroscopyViewer .ol-zoom-extent { + top: 4.643em; + left: 0.5em; +} + +.DicomMicroscopyViewer .ol-full-screen { + right: 0.5em; + top: 0.5em; +} + +.DicomMicroscopyViewer .ol-control button { + display: block; + margin: 1px; + padding: 0; + color: var(--ol-subtle-foreground-color); + font-weight: bold; + text-decoration: none; + font-size: inherit; + text-align: center; + height: 1.375em; + width: 1.375em; + line-height: 0.4em; + background-color: var(--ol-background-color); + border: none; + border-radius: 2px; +} + +.DicomMicroscopyViewer .ol-control button::-moz-focus-inner { + border: none; + padding: 0; +} + +.DicomMicroscopyViewer .ol-zoom-extent button { + line-height: 1.4em; +} + +.DicomMicroscopyViewer .ol-compass { + display: block; + font-weight: normal; + will-change: transform; +} + +.DicomMicroscopyViewer .ol-touch .ol-control button { + font-size: 1.5em; +} + +.DicomMicroscopyViewer .ol-touch .ol-zoom-extent { + top: 5.5em; +} + +.DicomMicroscopyViewer .ol-control button:hover, +.DicomMicroscopyViewer .ol-control button:focus { + text-decoration: none; + outline: 1px solid var(--ol-subtle-foreground-color); + color: var(--ol-foreground-color); +} + +.DicomMicroscopyViewer .ol-zoom .ol-zoom-in { + border-radius: 2px 2px 0 0; +} + +.DicomMicroscopyViewer .ol-zoom .ol-zoom-out { + border-radius: 0 0 2px 2px; +} + +.DicomMicroscopyViewer .ol-attribution { + text-align: right; + bottom: 0.5em; + right: 0.5em; + max-width: calc(100% - 1.3em); + display: flex; + flex-flow: row-reverse; + align-items: center; +} + +.DicomMicroscopyViewer .ol-attribution a { + color: var(--ol-subtle-foreground-color); + text-decoration: none; +} + +.DicomMicroscopyViewer .ol-attribution ul { + margin: 0; + padding: 1px 0.5em; + color: var(--ol-foreground-color); + text-shadow: 0 0 2px var(--ol-background-color); + font-size: 12px; +} + +.DicomMicroscopyViewer .ol-attribution li { + display: inline; + list-style: none; +} + +.DicomMicroscopyViewer .ol-attribution li:not(:last-child):after { + content: ' '; +} + +.DicomMicroscopyViewer .ol-attribution img { + max-height: 2em; + max-width: inherit; + vertical-align: middle; +} + +.DicomMicroscopyViewer .ol-attribution button { + flex-shrink: 0; +} + +.DicomMicroscopyViewer .ol-attribution.ol-collapsed ul { + display: none; +} + +.DicomMicroscopyViewer .ol-attribution:not(.ol-collapsed) { + background: var(--ol-partial-background-color); +} + +.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible { + bottom: 0; + right: 0; + border-radius: 4px 0 0; +} + +.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible img { + margin-top: -0.2em; + max-height: 1.6em; +} + +.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible button { + display: none; +} + +.DicomMicroscopyViewer .ol-zoomslider { + top: 4.5em; + left: 0.5em; + height: 200px; +} + +.DicomMicroscopyViewer .ol-zoomslider button { + position: relative; + height: 10px; +} + +.DicomMicroscopyViewer .ol-touch .ol-zoomslider { + top: 5.5em; +} + +.DicomMicroscopyViewer .ol-overviewmap { + left: 0.5em; + bottom: 0.5em; +} + +.DicomMicroscopyViewer .ol-overviewmap.ol-uncollapsible { + bottom: 0; + left: 0; + border-radius: 0 4px 0 0; +} + +.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-map, +.DicomMicroscopyViewer .ol-overviewmap button { + display: block; +} + +.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-map { + border: 1px solid var(--ol-subtle-foreground-color); + height: 150px; + width: 150px; +} + +.DicomMicroscopyViewer .ol-overviewmap:not(.ol-collapsed) button { + bottom: 0; + left: 0; + position: absolute; +} + +.DicomMicroscopyViewer .ol-overviewmap.ol-collapsed .ol-overviewmap-map, +.DicomMicroscopyViewer .ol-overviewmap.ol-uncollapsible button { + display: none; +} + +.DicomMicroscopyViewer .ol-overviewmap:not(.ol-collapsed) { + background: var(--ol-subtle-background-color); +} + +.DicomMicroscopyViewer .ol-overviewmap-box { + border: 1.5px dotted var(--ol-subtle-foreground-color); +} + +.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-box:hover { + cursor: move; +} + +@layout-header-background: #007ea3; +@primary-color: #007ea3; +@processing-color: #8cb8c6; +@success-color: #3f9c35; +@warning-color: #eeaf30; +@error-color: #96172e; +@font-size-base: 14px; + +.DicomMicroscopyViewer .ol-tooltip { + font-size: 16px !important; +} diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx new file mode 100644 index 000000000..329760604 --- /dev/null +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx @@ -0,0 +1,321 @@ +import React, { Component } from 'react'; +import ReactResizeDetector from 'react-resize-detector'; +import PropTypes from 'prop-types'; +import debounce from 'lodash.debounce'; + +import './DicomMicroscopyViewport.css'; +import ViewportOverlay from './components/ViewportOverlay'; +import getDicomWebClient from './utils/dicomWebClient'; +import dcmjs from 'dcmjs'; +import cleanDenaturalizedDataset from './utils/cleanDenaturalizedDataset'; +import MicroscopyService from './services/MicroscopyService'; + +class DicomMicroscopyViewport extends Component { + state = { + error: null as any, + }; + + microscopyService: MicroscopyService; + viewer: any = null; // dicom-microscopy-viewer instance + managedViewer: any = null; // managed wrapper of microscopy-dicom extension + + container = React.createRef(); + overlayElement = React.createRef(); + debouncedResize: () => any; + + constructor(props: any) { + super(props); + + const { microscopyService } = this.props.servicesManager.services; + this.microscopyService = microscopyService; + this.debouncedResize = debounce(() => { + if (this.viewer) this.viewer.resize(); + }, 100); + } + + static propTypes = { + viewportData: PropTypes.object, + activeViewportIndex: PropTypes.number, + setViewportActive: PropTypes.func, + + // props from OHIF Viewport Grid + displaySets: PropTypes.array, + viewportIndex: PropTypes.number, + viewportLabel: PropTypes.string, + dataSource: PropTypes.object, + viewportOptions: PropTypes.object, + displaySetOptions: PropTypes.array, + + // other props from wrapping component + servicesManager: PropTypes.object, + extensionManager: PropTypes.object, + commandsManager: PropTypes.object, + }; + + /** + * Get the nearest ROI from the mouse click point + * + * @param event + * @param autoselect + * @returns + */ + getNearbyROI(event: Event, autoselect = true) { + const symbols = Object.getOwnPropertySymbols(this.viewer); + const _drawingSource = symbols.find(p => p.description === 'drawingSource'); + const _pyramid = symbols.find(p => p.description === 'pyramid'); + const _map = symbols.find(p => p.description === 'map'); + const _affine = symbols.find(p => p.description === 'affine'); + + const feature = this.viewer[_drawingSource].getClosestFeatureToCoordinate( + this.viewer[_map].getEventCoordinate(event) + ); + + if (!feature) { + return null; + } + + const roiAnnotation = this.viewer._getROIFromFeature( + feature, + this.viewer[_pyramid].metadata, + this.viewer[_affine] + ); + if (roiAnnotation && autoselect) { + this.microscopyService.selectAnnotation(roiAnnotation); + } + return roiAnnotation; + } + + // install the microscopy renderer into the web page. + // you should only do this once. + async installOpenLayersRenderer(container, displaySet) { + const loadViewer = async metadata => { + const { + viewer: DicomMicroscopyViewer, + metadata: metadataUtils, + } = await import( + /* webpackChunkName: "dicom-microscopy-viewer" */ 'dicom-microscopy-viewer' + ); + const microscopyViewer = DicomMicroscopyViewer.VolumeImageViewer; + + const client = getDicomWebClient({ + extensionManager: this.props.extensionManager, + servicesManager: this.props.servicesManager, + }); + + // Parse, format, and filter metadata + const volumeImages: any[] = []; + + /** + * This block of code is the original way of loading DICOM into dicom-microscopy-viewer + * as in their documentation. + * But we have the metadata already loaded by our loaders. + * As the metadata for microscopy DIOM files tends to be big and we don't + * want to double load it, below we have the mechanism to reconstruct the + * DICOM JSON structure (denaturalized) from naturalized metadata. + * (NOTE: Our loaders cache only naturalized metadata, not the denaturalized.) + */ + // { + // const retrieveOptions = { + // studyInstanceUID: metadata[0].StudyInstanceUID, + // seriesInstanceUID: metadata[0].SeriesInstanceUID, + // }; + // metadata = await client.retrieveSeriesMetadata(retrieveOptions); + // // Parse, format, and filter metadata + // metadata.forEach(m => { + // if ( + // volumeImages.length > 0 && + // m['00200052'].Value[0] != volumeImages[0].FrameOfReferenceUID + // ) { + // console.warn( + // 'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values', + // m['00200052'].Value[0] + // ); + // m['00200052'].Value[0] = volumeImages[0].FrameOfReferenceUID; + // } + // const image = new metadataUtils.VLWholeSlideMicroscopyImage({ + // metadata: m, + // }); + // const imageFlavor = image.ImageType[2]; + // if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { + // volumeImages.push(image); + // } + // }); + // } + + metadata.forEach(m => { + const inst = cleanDenaturalizedDataset( + dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m) + ); + if (!inst['00480105']) { + // Optical Path Sequence, no OpticalPathIdentifier? + // NOTE: this is actually a not-well formatted DICOM VL Whole Slide Microscopy Image. + inst['00480105'] = { + vr: 'SQ', + Value: [ + { + '00480106': { + vr: 'SH', + Value: ['1'], + }, + }, + ], + }; + } + const image = new metadataUtils.VLWholeSlideMicroscopyImage({ + metadata: inst, + }); + const imageFlavor = image.ImageType[2]; + if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { + volumeImages.push(image); + } + }); + + // format metadata for microscopy-viewer + const options = { + client, + metadata: volumeImages, + retrieveRendered: false, + controls: ['overview', 'position', 'zoom'], + }; + + this.viewer = new microscopyViewer(options); + + if ( + this.overlayElement && + this.overlayElement.current && + this.viewer.addViewportOverlay + ) { + this.viewer.addViewportOverlay({ + element: this.overlayElement.current, + coordinates: [0, 0], // TODO: dicom-microscopy-viewer documentation says this can be false to be automatically, but it is not. + navigate: true, + className: 'OpenLayersOverlay', + }); + } + + this.viewer.render({ container }); + + const { StudyInstanceUID, SeriesInstanceUID } = displaySet; + + this.managedViewer = this.microscopyService.addViewer( + this.viewer, + this.props.viewportIndex, + container, + StudyInstanceUID, + SeriesInstanceUID + ); + + this.managedViewer.addContextMenuCallback((event: Event) => { + // TODO: refactor this after Bill's changes on ContextMenu feature get merged + // const roiAnnotationNearBy = this.getNearbyROI(event); + }); + }; + + this.microscopyService.clearAnnotations(); + + let smDisplaySet = displaySet; + if (displaySet.Modality === 'SR') { + // for SR displaySet, let's load the actual image displaySet + smDisplaySet = displaySet.getSourceDisplaySet(); + } + console.log('Loading viewer metadata', smDisplaySet); + + await loadViewer(smDisplaySet.others); + + if (displaySet.Modality === 'SR') { + displaySet.load(smDisplaySet); + } + } + + componentDidMount() { + const { displaySets, viewportIndex } = this.props; + const displaySet = displaySets[viewportIndex]; + this.installOpenLayersRenderer(this.container.current, displaySet); + } + + componentDidUpdate( + prevProps: Readonly<{}>, + prevState: Readonly<{}>, + snapshot?: any + ): void { + if ( + this.managedViewer && + prevProps.displaySets !== this.props.displaySets + ) { + const { displaySets } = this.props; + const displaySet = displaySets[0]; + + this.microscopyService.clearAnnotations(); + + // loading SR + if (displaySet.Modality === 'SR') { + const referencedDisplaySet = displaySet.getSourceDisplaySet(); + displaySet.load(referencedDisplaySet); + } + } + } + + componentWillUnmount() { + this.microscopyService.removeViewer(this.viewer); + } + + setViewportActiveHandler = () => { + const { + setViewportActive, + viewportIndex, + activeViewportIndex, + } = this.props; + + if (viewportIndex !== activeViewportIndex) { + setViewportActive(viewportIndex); + } + }; + + render() { + const style = { width: '100%', height: '100%' }; + const displaySet = this.props.displaySets[0]; + const firstInstance = displaySet.firstInstance || displaySet.instance; + + return ( +
+
+
+
+ {displaySet && firstInstance.imageId && ( + + )} +
+
+
+ {ReactResizeDetector && ( + + )} + {this.state.error ? ( +

{JSON.stringify(this.state.error)}

+ ) : ( +
+ )} +
+ ); + } + + onWindowResize = () => { + this.debouncedResize(); + }; +} + +export default DicomMicroscopyViewport; diff --git a/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx new file mode 100644 index 000000000..88d5f4392 --- /dev/null +++ b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx @@ -0,0 +1,413 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + ServicesManager, + ExtensionManager, + CommandsManager, + DicomMetadataStore, +} from '@ohif/core'; +import { MeasurementTable, Icon, ButtonGroup, Button } from '@ohif/ui'; +import { withTranslation, WithTranslation } from 'react-i18next'; +import { EVENTS as MicroscopyEvents } from '../../services/MicroscopyService'; +import dcmjs from 'dcmjs'; +import styles from '../../utils/styles'; +import callInputDialog from '../../utils/callInputDialog'; +import constructSR from '../../utils/constructSR'; +import { saveByteArray } from '../../utils/saveByteArray'; + +let saving = false; +const { datasetToBuffer } = dcmjs.data; + +const formatArea = area => { + let mult = 1; + let unit = 'mm'; + if (area > 1000000) { + unit = 'm'; + mult = 1 / 1000000; + } else if (area < 1) { + unit = 'ฮผm'; + mult = 1000000; + } + return `${(area * mult).toFixed(2)} ${unit}ยฒ`; +}; + +const formatLength = (length, unit) => { + let mult = 1; + if (unit == 'km' || (!unit && length > 1000000)) { + unit = 'km'; + mult = 1 / 1000000; + } else if (unit == 'm' || (!unit && length > 1000)) { + unit = 'm'; + mult = 1 / 1000; + } else if (unit == 'ฮผm' || (!unit && length < 1)) { + unit = 'ฮผm'; + mult = 1000; + } else if (unit && unit != 'mm') { + throw new Error(`Unknown length unit ${unit}`); + } else { + unit = 'mm'; + } + return `${(length * mult).toFixed(2)} ${unit}`; +}; + +interface IMicroscopyPanelProps extends WithTranslation { + viewports: PropTypes.array; + activeViewportIndex: PropTypes.number; + + // + onSaveComplete?: PropTypes.func; // callback when successfully saved annotations + onRejectComplete?: PropTypes.func; // callback when rejected annotations + + // + servicesManager: ServicesManager; + extensionManager: ExtensionManager; + commandsManager: CommandsManager; +} + +/** + * Microscopy Measurements Panel Component + * + * @param props + * @returns + */ +function MicroscopyPanel(props: IMicroscopyPanelProps) { + const { microscopyService } = props.servicesManager.services; + + const [studyInstanceUID, setStudyInstanceUID] = useState( + null as string | null + ); + const [roiAnnotations, setRoiAnnotations] = useState([] as any[]); + const [selectedAnnotation, setSelectedAnnotation] = useState(null as any); + const { servicesManager, extensionManager } = props; + + const { uiDialogService, displaySetService } = servicesManager.services; + + useEffect(() => { + const viewport = props.viewports[props.activeViewportIndex]; + if (viewport.displaySetInstanceUIDs[0]) { + const displaySet = displaySetService.getDisplaySetByUID( + viewport.displaySetInstanceUIDs[0] + ); + if (displaySet) { + setStudyInstanceUID(displaySet.StudyInstanceUID); + } + } + }, [props.viewports, props.activeViewportIndex]); + + useEffect(() => { + const onAnnotationUpdated = () => { + const roiAnnotations = microscopyService.getAnnotationsForStudy( + studyInstanceUID + ); + setRoiAnnotations(roiAnnotations); + }; + + const onAnnotationSelected = () => { + const selectedAnnotation = microscopyService.getSelectedAnnotation(); + setSelectedAnnotation(selectedAnnotation); + }; + + const onAnnotationRemoved = () => { + onAnnotationUpdated(); + }; + + const { + unsubscribe: unsubscribeAnnotationUpdated, + } = microscopyService.subscribe( + MicroscopyEvents.ANNOTATION_UPDATED, + onAnnotationUpdated + ); + const { + unsubscribe: unsubscribeAnnotationSelected, + } = microscopyService.subscribe( + MicroscopyEvents.ANNOTATION_SELECTED, + onAnnotationSelected + ); + const { + unsubscribe: unsubscribeAnnotationRemoved, + } = microscopyService.subscribe( + MicroscopyEvents.ANNOTATION_REMOVED, + onAnnotationRemoved + ); + onAnnotationUpdated(); + onAnnotationSelected(); + + // on unload unsubscribe from events + return () => { + unsubscribeAnnotationUpdated(); + unsubscribeAnnotationSelected(); + unsubscribeAnnotationRemoved(); + }; + }, [studyInstanceUID]); + + /** + * On clicking "Save Annotations" button, prompt an input modal for the + * new series' description, and continue to save. + * + * @returns + */ + const promptSave = () => { + const annotations = microscopyService.getAnnotationsForStudy( + studyInstanceUID + ); + + if (!annotations || saving) { + return; + } + + callInputDialog({ + uiDialogService, + title: 'Enter description of the Series', + defaultValue: '', + callback: (value: string, action: string) => { + switch (action) { + case 'save': { + saveFunction(value); + } + } + }, + }); + }; + + const getAllDisplaySets = (studyMetadata: any) => { + let allDisplaySets = [] as any[]; + studyMetadata.series.forEach((series: any) => { + const displaySets = displaySetService.getDisplaySetsForSeries( + series.SeriesInstanceUID + ); + allDisplaySets = allDisplaySets.concat(displaySets); + }); + return allDisplaySets; + }; + + /** + * Save annotations as a series + * + * @param SeriesDescription - series description + * @returns + */ + const saveFunction = async (SeriesDescription: string) => { + const dataSource = extensionManager.getActiveDataSource()[0]; + const { onSaveComplete } = props; + const annotations = microscopyService.getAnnotationsForStudy( + studyInstanceUID + ); + + saving = true; + + // There is only one viewer possible for one study, + // Since once study contains multiple resolution levels (series) of one whole + // Slide image. + + const studyMetadata = DicomMetadataStore.getStudy(studyInstanceUID); + const displaySets = getAllDisplaySets(studyMetadata); + const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM'); + + // Get the next available series number after 4700. + + const dsWithMetadata = displaySets.filter( + ds => + ds.metadata && + ds.metadata.SeriesNumber && + typeof ds.metadata.SeriesNumber === 'number' + ); + + // Generate next series number + const seriesNumbers = dsWithMetadata.map(ds => ds.metadata.SeriesNumber); + const maxSeriesNumber = Math.max(...seriesNumbers, 4700); + const SeriesNumber = maxSeriesNumber + 1; + + const { instance: metadata } = smDisplaySet; + + // construct SR dataset + const dataset = constructSR( + metadata, + { SeriesDescription, SeriesNumber }, + annotations + ); + + // Save in DICOM format + try { + if (dataSource) { + if (dataSource.wadoRoot == 'saveDicom') { + // download as DICOM file + const part10Buffer = datasetToBuffer(dataset); + saveByteArray(part10Buffer, `sr-microscopy.dcm`); + } else { + // Save into Web Data source + const { StudyInstanceUID } = dataset; + await dataSource.store.dicom(dataset); + if (StudyInstanceUID) { + dataSource.deleteStudyMetadataPromise(StudyInstanceUID); + } + } + onSaveComplete({ + title: 'SR Saved', + meassage: 'Measurements downloaded successfully', + type: 'success', + }); + } else { + console.error('Server unspecified'); + } + } catch (error) { + onSaveComplete({ + title: 'SR Save Failed', + message: error.message || error.toString(), + type: 'error', + }); + } finally { + saving = false; + } + }; + + /** + * On clicking "Reject annotations" button + */ + const onDeleteCurrentSRHandler = async () => { + try { + const activeViewport = props.viewports[props.activeViewportIndex]; + const { StudyInstanceUID } = activeViewport; + + // TODO: studies? + const study = DicomMetadataStore.getStudy(StudyInstanceUID); + + const lastDerivedDisplaySet = study.derivedDisplaySets.sort( + (ds1: any, ds2: any) => { + const dateTime1 = Number(`${ds1.SeriesDate}${ds1.SeriesTime}`); + const dateTime2 = Number(`${ds2.SeriesDate}${ds2.SeriesTime}`); + return dateTime1 > dateTime2; + } + )[study.derivedDisplaySets.length - 1]; + + // TODO: use dataSource.reject.dicom() + // await DICOMSR.rejectMeasurements( + // study.wadoRoot, + // lastDerivedDisplaySet.StudyInstanceUID, + // lastDerivedDisplaySet.SeriesInstanceUID + // ); + props.onRejectComplete({ + title: 'Report rejected', + message: 'Latest report rejected successfully', + type: 'success', + }); + } catch (error) { + props.onRejectComplete({ + title: 'Failed to reject report', + message: error.message, + type: 'error', + }); + } + }; + + /** + * Handler for clicking event of an annotation item. + * + * @param param0 + */ + const onMeasurementItemClickHandler = ({ uid }: { uid: string }) => { + const roiAnnotation = microscopyService.getAnnotation(uid); + microscopyService.selectAnnotation(roiAnnotation); + microscopyService.focusAnnotation(roiAnnotation, props.activeViewportIndex); + }; + + /** + * Handler for "Edit" action of an annotation item + * @param param0 + */ + const onMeasurementItemEditHandler = ({ + uid, + isActive, + }: { + uid: string; + isActive: boolean; + }) => { + props.commandsManager.runCommand('setLabel', { uid }, 'MICROSCOPY'); + }; + + // Convert ROI annotations managed by microscopyService into our + // own format for display + const data = roiAnnotations.map((roiAnnotation, index) => { + const label = roiAnnotation.getDetailedLabel(); + const area = roiAnnotation.getArea(); + const length = roiAnnotation.getLength(); + const shortAxisLength = roiAnnotation.roiGraphic.properties.shortAxisLength; + const isSelected: boolean = selectedAnnotation === roiAnnotation; + + // other events + const { uid } = roiAnnotation; + + // display text + const displayText = []; + + if (area !== undefined) { + displayText.push(formatArea(area)); + } else if (length !== undefined) { + displayText.push( + shortAxisLength + ? `${formatLength(length, 'ฮผm')} x ${formatLength( + shortAxisLength, + 'ฮผm' + )}` + : `${formatLength(length, 'ฮผm')}` + ); + } + + // convert to measurementItem format compatible with component + return { + uid, + index, + label, + isActive: isSelected, + displayText, + roiAnnotation, + }; + }); + + const disabled = data.length === 0; + + return ( + <> +
+ +
+
+ + {/* Let's hide the save button for now, as export SR for SM is a proof of concept */} + {/*{promptSave && ( + + )} */} + {/* */} + +
+ + ); +} + +const connectedMicroscopyPanel = withTranslation(['MicroscopyTable', 'Common'])( + MicroscopyPanel +); + +export default connectedMicroscopyPanel; diff --git a/extensions/dicom-microscopy/src/components/ViewportOverlay/ViewportOverlay.css b/extensions/dicom-microscopy/src/components/ViewportOverlay/ViewportOverlay.css new file mode 100644 index 000000000..da46120c5 --- /dev/null +++ b/extensions/dicom-microscopy/src/components/ViewportOverlay/ViewportOverlay.css @@ -0,0 +1,87 @@ +.DicomMicroscopyViewer .OpenLayersOverlay { + height: 100%; + width: 100%; + display: block !important; + pointer-events: none !important; +} + +.DicomMicroscopyViewer .text-primary-light { + font-size: 14px; + color: yellow; + font-weight: normal; +} + +.DicomMicroscopyViewer .text-primary-light span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 300px; + /* text-shadow: 0px 1px 1px rgba(225, 225, 225, 0.6), + 0px 1px 1px rgba(225, 225, 225, 0.6), + 1px 1px 3px rgba(225, 225, 225, 0.9), + 1px 1px 3px rgba(225, 225, 225, 0.9), + 1px 1px 3px rgba(225, 225, 225, 0.9), + 1px 1px 3px rgba(225, 225, 225, 0.9); */ +} + +.DicomMicroscopyViewer .absolute { + position: absolute; +} + +.DicomMicroscopyViewer .flex { + display: flex; +} + +.DicomMicroscopyViewer .flex-row { + flex-direction: row; +} + +.DicomMicroscopyViewer .flex-col { + flex-direction: column; +} + +.DicomMicroscopyViewer .pointer-events-none { + pointer-events: none; +} + +.DicomMicroscopyViewer .left-viewport-scrollbar { + left: 0.5rem; +} + +.DicomMicroscopyViewer .right-viewport-scrollbar { + right: 1.3rem; +} + +.DicomMicroscopyViewer .top-viewport { + top: 0.5rem; +} + +.DicomMicroscopyViewer .bottom-viewport { + bottom: 0.5rem; +} + +.DicomMicroscopyViewer .bottom-viewport.left-viewport { + bottom: 0.5rem; + left: calc(0.5rem + 250px); +} + +.DicomMicroscopyViewer .right-viewport-scrollbar .flex { + justify-content: end; +} + +.DicomMicroscopyViewer .microscopy-viewport-overlay { + padding: 0.5rem 1rem; + background: rgba(0, 0, 0, 0.5); + max-width: 40%; +} + +.DicomMicroscopyViewer .microscopy-viewport-overlay .flex { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.DicomMicroscopyViewer .top-viewport .flex span:not(.font-light) { + flex-shrink: 0; +} \ No newline at end of file diff --git a/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx b/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx new file mode 100644 index 000000000..bbe863e71 --- /dev/null +++ b/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import classnames from 'classnames'; +import ConfigPoint from 'config-point'; + +import listComponentGenerator from './listComponentGenerator'; +import './ViewportOverlay.css'; +import { + formatDICOMDate, + formatDICOMTime, + formatNumberPrecision, + formatPN, +} from './utils'; + +interface OverylayItem { + id: string; + title: string; + value?: (props: any) => string; + condition?: (props: any) => boolean; + contents?: (props: any) => { className: string; value: any }; + generator?: (props: any) => any; +} + +/** + * + * @param {*} config is a configuration object that defines four lists of elements, + * one topLeft, topRight, bottomLeft, bottomRight contents. + * @param {*} extensionManager is used to load the image data. + * @returns + */ +export const generateFromConfig = ({ + topLeft, + topRight, + bottomLeft, + bottomRight, + itemGenerator, +}: { + topLeft: OverylayItem[]; + topRight: OverylayItem[]; + bottomLeft: OverylayItem[]; + bottomRight: OverylayItem[]; + itemGenerator: (props: any) => any; +}) => { + return (props: any) => { + const topLeftClass = 'top-viewport left-viewport text-primary-light'; + const topRightClass = + 'top-viewport right-viewport-scrollbar text-primary-light'; + const bottomRightClass = + 'bottom-viewport right-viewport-scrollbar text-primary-light'; + const bottomLeftClass = 'bottom-viewport left-viewport text-primary-light'; + const overlay = 'absolute pointer-events-none microscopy-viewport-overlay'; + + return ( + <> + {topLeft && topLeft.length > 0 && ( +
+ {listComponentGenerator({ ...props, list: topLeft, itemGenerator })} +
+ )} + {topRight && topRight.length > 0 && ( +
+ {listComponentGenerator({ + ...props, + list: topRight, + itemGenerator, + })} +
+ )} + {bottomRight && bottomRight.length > 0 && ( +
+ {listComponentGenerator({ + ...props, + list: bottomRight, + itemGenerator, + })} +
+ )} + {bottomLeft && bottomLeft.length > 0 && ( +
+ {listComponentGenerator({ + ...props, + list: bottomLeft, + itemGenerator, + })} +
+ )} + + ); + }; +}; + +const itemGenerator = (props: any) => { + const { item } = props; + const { title, value: valueFunc, condition, contents } = item; + props.image = { ...props.image, ...props.metadata }; + props.formatDate = formatDICOMDate; + props.formatTime = formatDICOMTime; + props.formatPN = formatPN; + props.formatNumberPrecision = formatNumberPrecision; + if (condition && !condition(props)) return null; + if (!contents && !valueFunc) return null; + const value = valueFunc && valueFunc(props); + const contentsValue = (contents && contents(props)) || [ + { className: 'mr-1', value: title }, + { classname: 'mr-1 font-light', value }, + ]; + + return ( +
+ {contentsValue.map((content, idx) => ( + + {content.value} + + ))} +
+ ); +}; + +const { MicroscopyViewportOverlay } = ConfigPoint.register({ + MicroscopyViewportOverlay: { + configBase: { + topLeft: [ + // { + // id: 'sm-overlay-patient-name', + // title: 'PatientName', + // condition: ({ instance }) => + // instance && instance.PatientName && instance.PatientName.Alphabetic, + // value: ({ instance }) => + // instance.PatientName && instance.PatientName.Alphabetic, + // } as OverylayItem, + ], + topRight: [] as OverylayItem[], + bottomLeft: [] as OverylayItem[], + bottomRight: [] as OverylayItem[], + itemGenerator, + generateFromConfig, + }, + ...(window.config?.MicroscopyViewportOverlay || {}), + }, +}); + +export default MicroscopyViewportOverlay.generateFromConfig( + MicroscopyViewportOverlay +); diff --git a/extensions/dicom-microscopy/src/components/ViewportOverlay/listComponentGenerator.tsx b/extensions/dicom-microscopy/src/components/ViewportOverlay/listComponentGenerator.tsx new file mode 100644 index 000000000..04150625d --- /dev/null +++ b/extensions/dicom-microscopy/src/components/ViewportOverlay/listComponentGenerator.tsx @@ -0,0 +1,12 @@ +const listComponentGenerator = props => { + const { list, itemGenerator } = props; + if (!list) return; + return list.map(item => { + if (!item) return; + const generator = item.generator || itemGenerator; + if (!generator) throw new Error(`No generator for ${item}`); + return generator({ ...props, item }); + }); +}; + +export default listComponentGenerator; diff --git a/extensions/dicom-microscopy/src/components/ViewportOverlay/utils.ts b/extensions/dicom-microscopy/src/components/ViewportOverlay/utils.ts new file mode 100644 index 000000000..81f56ad05 --- /dev/null +++ b/extensions/dicom-microscopy/src/components/ViewportOverlay/utils.ts @@ -0,0 +1,102 @@ +import moment from 'moment'; +import * as cornerstone from '@cornerstonejs/core'; + +/** + * Checks if value is valid. + * + * @param {number} value + * @returns {boolean} is valid. + */ +export function isValidNumber(value) { + return typeof value === 'number' && !isNaN(value); +} + +/** + * Formats number precision. + * + * @param {number} number + * @param {number} precision + * @returns {number} formatted number. + */ +export function formatNumberPrecision(number, precision) { + if (number !== null) { + return parseFloat(number).toFixed(precision); + } +} + +/** + * Formats DICOM date. + * + * @param {string} date + * @param {string} strFormat + * @returns {string} formatted date. + */ +export function formatDICOMDate(date, strFormat = 'MMM D, YYYY') { + return moment(date, 'YYYYMMDD').format(strFormat); +} + +/** + * DICOM Time is stored as HHmmss.SSS, where: + * HH 24 hour time: + * m mm 0..59 Minutes + * s ss 0..59 Seconds + * S SS SSS 0..999 Fractional seconds + * + * Goal: '24:12:12' + * + * @param {*} time + * @param {string} strFormat + * @returns {string} formatted name. + */ +export function formatDICOMTime(time, strFormat = 'HH:mm:ss') { + return moment(time, 'HH:mm:ss').format(strFormat); +} + +/** + * Formats a patient name for display purposes + * + * @param {string} name + * @returns {string} formatted name. + */ +export function formatPN(name) { + if (!name) { + return; + } + + // Convert the first ^ to a ', '. String.replace() only affects + // the first appearance of the character. + const commaBetweenFirstAndLast = name.replace('^', ', '); + + // Replace any remaining '^' characters with spaces + const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' '); + + // Trim any extraneous whitespace + return cleaned.trim(); +} + +/** + * Gets compression type + * + * @param {number} imageId + * @returns {string} comrpession type. + */ +export function getCompression(imageId) { + const generalImageModule = + cornerstone.metaData.get('generalImageModule', imageId) || {}; + const { + lossyImageCompression, + lossyImageCompressionRatio, + lossyImageCompressionMethod, + } = generalImageModule; + + if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') { + const compressionMethod = lossyImageCompressionMethod || 'Lossy: '; + const compressionRatio = formatNumberPrecision( + lossyImageCompressionRatio, + 2 + ); + return compressionMethod + compressionRatio + ' : 1'; + } + + return 'Lossless / Uncompressed'; +} diff --git a/extensions/dicom-microscopy/src/getCommandsModule.ts b/extensions/dicom-microscopy/src/getCommandsModule.ts new file mode 100644 index 000000000..db8210623 --- /dev/null +++ b/extensions/dicom-microscopy/src/getCommandsModule.ts @@ -0,0 +1,198 @@ +import { ServicesManager, CommandsManager, ExtensionManager } from '@ohif/core'; +import styles from './utils/styles'; +import callInputDialog from './utils/callInputDialog'; + +export default function getCommandsModule({ + servicesManager, + commandsManager, + extensionManager, +}: { + servicesManager: ServicesManager; + commandsManager: CommandsManager; + extensionManager: ExtensionManager; +}) { + const { + viewportGridService, + uiDialogService, + microscopyService, + } = servicesManager.services; + + const actions = { + // Measurement tool commands: + deleteMeasurement: ({ uid }) => { + if (uid) { + const roiAnnotation = microscopyService.getAnnotation(uid); + if (roiAnnotation) microscopyService.removeAnnotation(roiAnnotation); + } + }, + + setLabel: ({ uid }) => { + const roiAnnotation = microscopyService.getAnnotation(uid); + + callInputDialog({ + uiDialogService, + title: 'Enter your annotation', + defaultValue: '', + callback: (value: string, action: string) => { + switch (action) { + case 'save': { + roiAnnotation.setLabel(value); + microscopyService.triggerRelabel(roiAnnotation); + } + } + }, + }); + }, + + setToolActive: ({ toolName, toolGroupId = 'MICROSCOPY' }) => { + const dragPanOnMiddle = [ + 'dragPan', + { + bindings: { + mouseButtons: ['middle'], + }, + }, + ]; + const dragZoomOnRight = [ + 'dragZoom', + { + bindings: { + mouseButtons: ['right'], + }, + }, + ]; + if ( + [ + 'line', + 'box', + 'circle', + 'point', + 'polygon', + 'freehandpolygon', + 'freehandline', + ].indexOf(toolName) >= 0 + ) { + // TODO: read from configuration + let options = { + geometryType: toolName, + vertexEnabled: true, + styleOptions: styles.default, + bindings: { + mouseButtons: ['left'], + }, + } as any; + if ('line' === toolName) { + options.minPoints = 2; + options.maxPoints = 2; + } else if ('point' === toolName) { + delete options.styleOptions; + delete options.vertexEnabled; + } + + microscopyService.activateInteractions([ + ['draw', options], + dragPanOnMiddle, + dragZoomOnRight, + ]); + } else if (toolName == 'dragPan') { + microscopyService.activateInteractions([ + [ + 'dragPan', + { + bindings: { + mouseButtons: ['left', 'middle'], + }, + }, + ], + dragZoomOnRight, + ]); + } else { + microscopyService.activateInteractions([ + [ + toolName, + { + bindings: { + mouseButtons: ['left'], + }, + }, + ], + dragPanOnMiddle, + dragZoomOnRight, + ]); + } + }, + + incrementActiveViewport: () => { + const { activeViewportIndex, viewports } = viewportGridService.getState(); + const nextViewportIndex = (activeViewportIndex + 1) % viewports.length; + viewportGridService.setActiveViewportIndex(nextViewportIndex); + }, + decrementActiveViewport: () => { + const { activeViewportIndex, viewports } = viewportGridService.getState(); + const nextViewportIndex = + (activeViewportIndex - 1 + viewports.length) % viewports.length; + viewportGridService.setActiveViewportIndex(nextViewportIndex); + }, + + toggleOverlays: () => { + // overlay + const overlays = document.getElementsByClassName( + 'microscopy-viewport-overlay' + ); + let onoff = false; // true if this will toggle on + for (let i = 0; i < overlays.length; i++) { + if (i === 0) onoff = overlays.item(0).classList.contains('hidden'); + overlays.item(i).classList.toggle('hidden'); + } + + // overview + const { activeViewportIndex, viewports } = viewportGridService.getState(); + microscopyService.toggleOverviewMap(activeViewportIndex); + }, + toggleAnnotations: () => { + microscopyService.toggleROIsVisibility(); + }, + }; + + const definitions = { + deleteMeasurement: { + commandFn: actions.deleteMeasurement, + storeContexts: [] as any[], + options: {}, + }, + setLabel: { + commandFn: actions.setLabel, + storeContexts: [] as any[], + options: {}, + }, + setToolActive: { + commandFn: actions.setToolActive, + storeContexts: [] as any[], + options: {}, + }, + incrementActiveViewport: { + commandFn: actions.incrementActiveViewport, + storeContexts: [] as any[], + }, + decrementActiveViewport: { + commandFn: actions.decrementActiveViewport, + storeContexts: [] as any[], + }, + toggleOverlays: { + commandFn: actions.toggleOverlays, + storeContexts: [] as any[], + options: {}, + }, + toggleAnnotations: { + commandFn: actions.toggleAnnotations, + storeContexts: [] as any[], + options: {}, + }, + }; + + return { + actions, + definitions, + defaultContext: 'MICROSCOPY', + }; +} diff --git a/extensions/dicom-microscopy/src/getPanelModule.tsx b/extensions/dicom-microscopy/src/getPanelModule.tsx new file mode 100644 index 000000000..ef49c9331 --- /dev/null +++ b/extensions/dicom-microscopy/src/getPanelModule.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { ServicesManager, CommandsManager, ExtensionManager } from '@ohif/core'; +import { useViewportGrid } from '@ohif/ui'; +import MicroscopyPanel from './components/MicroscopyPanel/MicroscopyPanel'; + +// TODO: +// - No loading UI exists yet +// - cancel promises when component is destroyed +// - show errors in UI for thumbnails if promise fails + +export default function getPanelModule({ + commandsManager, + extensionManager, + servicesManager, +}: { + servicesManager: ServicesManager; + commandsManager: CommandsManager; + extensionManager: ExtensionManager; +}) { + const wrappedMeasurementPanel = () => { + const [ + { activeViewportIndex, viewports }, + viewportGridService, + ] = useViewportGrid(); + + return ( + {}} + onRejectComplete={() => {}} + commandsManager={commandsManager} + servicesManager={servicesManager} + extensionManager={extensionManager} + /> + ); + }; + + return [ + { + name: 'measure', + iconName: 'tab-linear', + iconLabel: 'Measure', + label: 'Measurements', + secondaryLabel: 'Measurements', + component: wrappedMeasurementPanel, + }, + ]; +} diff --git a/extensions/dicom-microscopy/src/helpers/formatDICOMDate.js b/extensions/dicom-microscopy/src/helpers/formatDICOMDate.js new file mode 100644 index 000000000..c048df472 --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatDICOMDate.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +/** + * Formats DICOM date. + * + * @param {string} date + * @param {string} strFormat + */ +export default function formatDICOMDate(date, strFormat = 'MMM D, YYYY') { + return moment(date, 'YYYYMMDD').format(strFormat); +} diff --git a/extensions/dicom-microscopy/src/helpers/formatDICOMDate.test.js b/extensions/dicom-microscopy/src/helpers/formatDICOMDate.test.js new file mode 100644 index 000000000..abb9b2c1f --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatDICOMDate.test.js @@ -0,0 +1,9 @@ +import formatDICOMDate from './formatDICOMDate'; + +describe('formatDICOMDate', () => { + it('should format DICOM date string', () => { + const date = '20180916'; + const formattedDate = formatDICOMDate(date); + expect(formattedDate).toEqual('Sep 16, 2018'); + }); +}); diff --git a/extensions/dicom-microscopy/src/helpers/formatDICOMPatientName.js b/extensions/dicom-microscopy/src/helpers/formatDICOMPatientName.js new file mode 100644 index 000000000..fd62d94c9 --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatDICOMPatientName.js @@ -0,0 +1,21 @@ +/** + * Formats a patient name for display purposes. + * + * @param {string} name DICOM patient name string + * @returns {string} formatted name + */ +export default function formatDICOMPatientName(name) { + if (typeof name !== 'string') return; + + /** + * Convert the first ^ to a ', '. String.replace() only affects + * the first appearance of the character. + */ + const commaBetweenFirstAndLast = name.replace('^', ', '); + + /** Replace any remaining '^' characters with spaces */ + const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' '); + + /** Trim any extraneous whitespace */ + return cleaned.trim(); +} diff --git a/extensions/dicom-microscopy/src/helpers/formatDICOMPatientName.test.js b/extensions/dicom-microscopy/src/helpers/formatDICOMPatientName.test.js new file mode 100644 index 000000000..efda1ed02 --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatDICOMPatientName.test.js @@ -0,0 +1,17 @@ +import formatDICOMPatientName from './formatDICOMPatientName'; + +describe('formatDICOMPatientName', () => { + it('should format DICOM patient name correctly', () => { + const patientName = 'Blackford^Test'; + const formattedPatientName = formatDICOMPatientName(patientName); + expect(formattedPatientName).toEqual('Blackford, Test'); + }); + + it('should return undefined it input is not a string', () => { + expect(formatDICOMPatientName(123)).toEqual(undefined); + expect(formatDICOMPatientName(null)).toEqual(undefined); + expect(formatDICOMPatientName(undefined)).toEqual(undefined); + expect(formatDICOMPatientName(false)).toEqual(undefined); + expect(formatDICOMPatientName([])).toEqual(undefined); + }); +}); diff --git a/extensions/dicom-microscopy/src/helpers/formatDICOMTime.js b/extensions/dicom-microscopy/src/helpers/formatDICOMTime.js new file mode 100644 index 000000000..ea2deb7df --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatDICOMTime.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +/** + * DICOM Time is stored as HHmmss.SSS, where: + * HH 24 hour time: + * m mm 0..59 Minutes + * s ss 0..59 Seconds + * S SS SSS 0..999 Fractional seconds + * + * Goal: '24:12:12' + * + * @param {*} time + * @param {string} strFormat + */ +export default function formatDICOMTime(time, strFormat = 'HH:mm:ss') { + return moment(time, 'HH:mm:ss').format(strFormat); +} diff --git a/extensions/dicom-microscopy/src/helpers/formatDICOMTime.test.js b/extensions/dicom-microscopy/src/helpers/formatDICOMTime.test.js new file mode 100644 index 000000000..a94d9bc83 --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatDICOMTime.test.js @@ -0,0 +1,9 @@ +import formatDICOMTime from './formatDICOMTime'; + +describe('formatDICOMTime', () => { + it('should format DICOM time string', () => { + const time = '101300.000'; + const formattedTime = formatDICOMTime(time); + expect(formattedTime).toEqual('10:13:00'); + }); +}); diff --git a/extensions/dicom-microscopy/src/helpers/formatNumberPrecision.js b/extensions/dicom-microscopy/src/helpers/formatNumberPrecision.js new file mode 100644 index 000000000..9aa152fe1 --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatNumberPrecision.js @@ -0,0 +1,9 @@ +/** + * Formats a number to a fixed precision. + * + * @param {number} number + * @param {number} precision + */ +export default function formatNumberPrecision(number, precision) { + return Number(parseFloat(number).toFixed(precision)); +} diff --git a/extensions/dicom-microscopy/src/helpers/formatNumberPrecision.test.js b/extensions/dicom-microscopy/src/helpers/formatNumberPrecision.test.js new file mode 100644 index 000000000..0ccce4d9e --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/formatNumberPrecision.test.js @@ -0,0 +1,9 @@ +import formatNumberPrecision from './formatNumberPrecision'; + +describe('formatNumberPrecision', () => { + it('should format number precision', () => { + const number = 0.229387; + const formattedNumber = formatNumberPrecision(number, 2); + expect(formattedNumber).toEqual(0.23); + }); +}); diff --git a/extensions/dicom-microscopy/src/helpers/index.js b/extensions/dicom-microscopy/src/helpers/index.js new file mode 100644 index 000000000..59468982c --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/index.js @@ -0,0 +1,15 @@ +import formatDICOMPatientName from './formatDICOMPatientName'; +import formatDICOMDate from './formatDICOMDate'; +import formatDICOMTime from './formatDICOMTime'; +import formatNumberPrecision from './formatNumberPrecision'; +import isValidNumber from './isValidNumber'; + +const helpers = { + formatDICOMPatientName, + formatDICOMDate, + formatDICOMTime, + formatNumberPrecision, + isValidNumber, +}; + +export default helpers; diff --git a/extensions/dicom-microscopy/src/helpers/isValidNumber.js b/extensions/dicom-microscopy/src/helpers/isValidNumber.js new file mode 100644 index 000000000..086b1c08a --- /dev/null +++ b/extensions/dicom-microscopy/src/helpers/isValidNumber.js @@ -0,0 +1,3 @@ +export default function isValidNumber(value) { + return typeof value === 'number' && !isNaN(value); +} diff --git a/extensions/dicom-microscopy/src/id.js b/extensions/dicom-microscopy/src/id.js new file mode 100644 index 000000000..ebe5acd98 --- /dev/null +++ b/extensions/dicom-microscopy/src/id.js @@ -0,0 +1,5 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; + +export { id }; diff --git a/extensions/dicom-microscopy/src/index.tsx b/extensions/dicom-microscopy/src/index.tsx new file mode 100644 index 000000000..66dfeacd9 --- /dev/null +++ b/extensions/dicom-microscopy/src/index.tsx @@ -0,0 +1,117 @@ +import { id } from './id'; +import React, { Suspense } from 'react'; +import getPanelModule from './getPanelModule'; +import getCommandsModule from './getCommandsModule'; + +import { useViewportGrid } from '@ohif/ui'; +import getDicomMicroscopySopClassHandler from './DicomMicroscopySopClassHandler'; +import getDicomMicroscopySRSopClassHandler from './DicomMicroscopySRSopClassHandler'; +import MicroscopyService from './services/MicroscopyService'; + +const Component = React.lazy(() => { + return import('./DicomMicroscopyViewport'); +}); + +const MicroscopyViewport = props => { + return ( + Loading...
}> + + + ); +}; + +/** + * You can remove any of the following modules if you don't need them. + */ +export default { + /** + * Only required property. Should be a unique value across all extensions. + * You ID can be anything you want, but it should be unique. + */ + id, + + async preRegistration({ + servicesManager, + commandsManager, + configuration = {}, + appConfig, + }) { + servicesManager.registerService( + MicroscopyService.REGISTRATION(servicesManager) + ); + }, + + /** + * ViewportModule should provide a list of viewports that will be available in OHIF + * for Modes to consume and use in the viewports. Each viewport is defined by + * {name, component} object. Example of a viewport module is the CornerstoneViewport + * that is provided by the Cornerstone extension in OHIF. + */ + getViewportModule({ servicesManager, extensionManager, commandsManager }) { + /** + * + * @param props {*} + * @param props.displaySets + * @param props.viewportIndex + * @param props.viewportLabel + * @param props.dataSource + * @param props.viewportOptions + * @param props.displaySetOptions + * @returns + */ + const ExtendedMicroscopyViewport = props => { + const { viewportOptions } = props; + + const [viewportGrid, viewportGridService] = useViewportGrid(); + const { viewports, activeViewportIndex } = viewportGrid; + + return ( + { + viewportGridService.setActiveViewportIndex(viewportIndex); + }} + viewportData={viewportOptions} + {...props} + /> + ); + }; + + return [ + { + name: 'microscopy-dicom', + component: ExtendedMicroscopyViewport, + }, + ]; + }, + + /** + * 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({ + servicesManager, + commandsManager, + extensionManager, + }) { + return [ + getDicomMicroscopySopClassHandler({ + servicesManager, + extensionManager, + }), + getDicomMicroscopySRSopClassHandler({ + servicesManager, + extensionManager, + }), + ]; + }, + + getPanelModule, + + getCommandsModule, +}; diff --git a/extensions/dicom-microscopy/src/services/MicroscopyService.js b/extensions/dicom-microscopy/src/services/MicroscopyService.js new file mode 100644 index 000000000..d1ff76b5b --- /dev/null +++ b/extensions/dicom-microscopy/src/services/MicroscopyService.js @@ -0,0 +1,634 @@ +import ViewerManager, { EVENTS as ViewerEvents } from '../tools/viewerManager'; +import RoiAnnotation, { + EVENTS as AnnotationEvents, +} from '../utils/RoiAnnotation'; +import styles from '../utils/styles'; +import { DicomMetadataStore, PubSubService } from '@ohif/core'; + +const EVENTS = { + ANNOTATION_UPDATED: 'annotationUpdated', + ANNOTATION_SELECTED: 'annotationSelected', + ANNOTATION_REMOVED: 'annotationRemoved', + RELABEL: 'relabel', + DELETE: 'delete', +}; + +/** + * MicroscopyService is responsible to manage multiple third-party API's + * microscopy viewers expose methods to manage the interaction with these + * viewers and handle their ROI graphics to create, remove and modify the + * ROI annotations relevant to the application + */ +export default class MicroscopyService extends PubSubService { + public static REGISTRATION = serviceManager => { + return { + name: 'microscopyService', + altName: 'MicroscopyService', + create: ({ configuration = {} }) => { + return new MicroscopyService(serviceManager); + }, + }; + }; + + serviceManager: any; + + managedViewers = new Set(); + roiUids = new Set(); + annotations = {}; + selectedAnnotation = null; + pendingFocus = false; + + constructor(serviceManager) { + super(EVENTS); + this.serviceManager = serviceManager; + this._onRoiAdded = this._onRoiAdded.bind(this); + this._onRoiModified = this._onRoiModified.bind(this); + this._onRoiRemoved = this._onRoiRemoved.bind(this); + this._onRoiUpdated = this._onRoiUpdated.bind(this); + this._onRoiSelected = this._onRoiSelected.bind(this); + this.isROIsVisible = true; + } + + /** + * Cleares all the annotations and managed viewers, setting the manager state + * to its initial state + */ + clear() { + this.managedViewers.forEach(managedViewer => managedViewer.destroy()); + this.managedViewers.clear(); + for (var key in this.annotations) { + delete this.annotations[key]; + } + + this.roiUids.clear(); + this.selectedAnnotation = null; + this.pendingFocus = false; + } + + clearAnnotations() { + Object.keys(this.annotations).forEach(uid => { + this.removeAnnotation(this.annotations[uid]); + }); + } + + /** + * Observes when a ROI graphic is added, creating the correspondent annotation + * with the current graphic and view state. + * Creates a subscription for label updating for the created annotation and + * publishes an ANNOTATION_UPDATED event when it happens. + * Also triggers the relabel process after the graphic is placed. + * + * @param {Object} data The published data + * @param {Object} data.roiGraphic The added ROI graphic object + * @param {ViewerManager} data.managedViewer The origin viewer for the event + */ + _onRoiAdded(data) { + const { roiGraphic, managedViewer, label } = data; + const { studyInstanceUID, seriesInstanceUID } = managedViewer; + const viewState = managedViewer.getViewState(); + + const roiAnnotation = new RoiAnnotation( + roiGraphic, + studyInstanceUID, + seriesInstanceUID, + '', + viewState + ); + + this.roiUids.add(roiGraphic.uid); + this.annotations[roiGraphic.uid] = roiAnnotation; + + roiAnnotation.subscribe(AnnotationEvents.LABEL_UPDATED, () => { + this._broadcastEvent(EVENTS.ANNOTATION_UPDATED, roiAnnotation); + }); + + if (label !== undefined) { + roiAnnotation.setLabel(label); + } else { + const onRelabel = item => + managedViewer.updateROIProperties({ + uid: roiGraphic.uid, + properties: { label: item.label, finding: item.finding }, + }); + this.triggerRelabel(roiAnnotation, true, onRelabel); + } + } + + /** + * Observes when a ROI graphic is modified, updating the correspondent + * annotation with the current graphic and view state. + * + * @param {Object} data The published data + * @param {Object} data.roiGraphic The modified ROI graphic object + */ + _onRoiModified(data) { + const { roiGraphic, managedViewer } = data; + const roiAnnotation = this.getAnnotation(roiGraphic.uid); + if (!roiAnnotation) return; + roiAnnotation.setRoiGraphic(roiGraphic); + roiAnnotation.setViewState(managedViewer.getViewState()); + } + + /** + * Observes when a ROI graphic is removed, reflecting the removal in the + * annotations' state. + * + * @param {Object} data The published data + * @param {Object} data.roiGraphic The removed ROI graphic object + */ + _onRoiRemoved(data) { + const { roiGraphic } = data; + this.roiUids.delete(roiGraphic.uid); + this.annotations[roiGraphic.uid].destroy(); + delete this.annotations[roiGraphic.uid]; + this._broadcastEvent(EVENTS.ANNOTATION_REMOVED, roiGraphic); + } + + /** + * Observes any changes on ROI graphics and synchronize all the managed + * viewers to reflect those changes. + * Also publishes an ANNOTATION_UPDATED event to notify the subscribers. + * + * @param {Object} data The published data + * @param {Object} data.roiGraphic The added ROI graphic object + * @param {ViewerManager} data.managedViewer The origin viewer for the event + */ + _onRoiUpdated(data) { + const { roiGraphic, managedViewer } = data; + this.synchronizeViewers(managedViewer); + this._broadcastEvent(EVENTS.ANNOTATION_UPDATED, this.getAnnotation(roiGraphic.uid)); + } + + /** + * Observes when an ROI is selected. + * Also publishes an ANNOTATION_SELECTED event to notify the subscribers. + * + * @param {Object} data The published data + * @param {Object} data.roiGraphic The added ROI graphic object + * @param {ViewerManager} data.managedViewer The origin viewer for the event + */ + _onRoiSelected(data) { + const { roiGraphic } = data; + const selectedAnnotation = this.getAnnotation(roiGraphic.uid); + if (selectedAnnotation && selectedAnnotation !== this.getSelectedAnnotation()) { + if (this.selectedAnnotation) this.clearSelection(); + this.selectedAnnotation = selectedAnnotation; + this._broadcastEvent(EVENTS.ANNOTATION_SELECTED, selectedAnnotation); + } + } + + /** + * Creates the subscriptions for the managed viewer being added + * + * @param {ViewerManager} managedViewer The viewer being added + */ + _addManagedViewerSubscriptions(managedViewer) { + managedViewer._roiAddedSubscription = managedViewer.subscribe(ViewerEvents.ADDED, this._onRoiAdded); + managedViewer._roiModifiedSubscription = managedViewer.subscribe(ViewerEvents.MODIFIED, this._onRoiModified); + managedViewer._roiRemovedSubscription = managedViewer.subscribe(ViewerEvents.REMOVED, this._onRoiRemoved); + managedViewer._roiUpdatedSubscription = managedViewer.subscribe(ViewerEvents.UPDATED, this._onRoiUpdated); + managedViewer._roiSelectedSubscription = managedViewer.subscribe(ViewerEvents.UPDATED, this._onRoiSelected); + } + + /** + * Removes the subscriptions for the managed viewer being removed + * + * @param {ViewerManager} managedViewer The viewer being removed + */ + _removeManagedViewerSubscriptions(managedViewer) { + managedViewer._roiAddedSubscription && managedViewer._roiAddedSubscription.unsubscribe(); + managedViewer._roiModifiedSubscription && managedViewer._roiModifiedSubscription.unsubscribe(); + managedViewer._roiRemovedSubscription && managedViewer._roiRemovedSubscription.unsubscribe(); + managedViewer._roiUpdatedSubscription && managedViewer._roiUpdatedSubscription.unsubscribe(); + managedViewer._roiSelectedSubscription && managedViewer._roiSelectedSubscription.unsubscribe(); + + managedViewer._roiAddedSubscription = null; + managedViewer._roiModifiedSubscription = null; + managedViewer._roiRemovedSubscription = null; + managedViewer._roiUpdatedSubscription = null; + managedViewer._roiSelectedSubscription = null; + } + + /** + * Returns the managed viewers that are displaying the image with the given + * study and series UIDs + * + * @param {String} studyInstanceUID UID for the study + * @param {String} seriesInstanceUID UID for the series + * + * @returns {Array} The managed viewers for the given series UID + */ + _getManagedViewersForSeries(studyInstanceUID, seriesInstanceUID) { + const filter = managedViewer => + managedViewer.studyInstanceUID === studyInstanceUID && + managedViewer.seriesInstanceUID === seriesInstanceUID; + return Array.from(this.managedViewers).filter(filter); + } + + /** + * Returns the managed viewers that are displaying the image with the given + * study UID + * + * @param {String} studyInstanceUID UID for the study + * + * @returns {Array} The managed viewers for the given series UID + */ + getManagedViewersForStudy(studyInstanceUID) { + const filter = managedViewer => + managedViewer.studyInstanceUID === studyInstanceUID; + return Array.from(this.managedViewers).filter(filter); + } + + /** + * Restores the created annotations for the viewer being added + * + * @param {ViewerManager} managedViewer The viewer being added + */ + _restoreAnnotations(managedViewer) { + const { studyInstanceUID, seriesInstanceUID } = managedViewer; + const annotations = this.getAnnotationsForSeries( + studyInstanceUID, + seriesInstanceUID + ); + annotations.forEach(roiAnnotation => { + managedViewer.addRoiGraphic(roiAnnotation.roiGraphic); + }); + } + + /** + * Creates a managed viewer instance for the given thrid-party API's viewer. + * Restores existing annotations for the given study/series. + * Adds event subscriptions for the viewer being added. + * Focuses the selected annotation when the viewer is being loaded into the + * active viewport. + * + * @param {Object} viewer Third-party viewer API's object to be managed + * @param {Number} viewportIndex The index of the viewport to load the viewer + * @param {HTMLElement} container The DOM element where it will be renderd + * @param {String} studyInstanceUID The study UID of the loaded image + * @param {String} seriesInstanceUID The series UID of the loaded image + * @param {Array} displaySets All displaySets related to the same StudyInstanceUID + * + * @returns {ViewerManager} managed viewer + */ + addViewer( + viewer, + viewportIndex, + container, + studyInstanceUID, + seriesInstanceUID + ) { + const managedViewer = new ViewerManager( + viewer, + viewportIndex, + container, + studyInstanceUID, + seriesInstanceUID + ); + + this._restoreAnnotations(managedViewer); + viewer._manager = managedViewer; + this.managedViewers.add(managedViewer); + + // this._potentiallyLoadSR(studyInstanceUID, displaySets); + this._addManagedViewerSubscriptions(managedViewer); + + if (this.pendingFocus) { + this.pendingFocus = false; + this.focusAnnotation(this.selectedAnnotation, viewportIndex); + } + + return managedViewer; + } + + _potentiallyLoadSR(StudyInstanceUID, displaySets) { + const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); + const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM'); + + const { FrameOfReferenceUID, othersFrameOfReferenceUID } = smDisplaySet; + + if (!studyMetadata) { + return; + } + + let derivedDisplaySets = FrameOfReferenceUID + ? displaySets.filter( + ds => + ds.ReferencedFrameOfReferenceUID === FrameOfReferenceUID || + // sometimes each depth instance has the different FrameOfReferenceID + othersFrameOfReferenceUID.includes(ds.ReferencedFrameOfReferenceUID) + ) + : []; + + if (!derivedDisplaySets.length) { + return; + } + + derivedDisplaySets = derivedDisplaySets.filter(ds => ds.Modality === 'SR'); + + if (derivedDisplaySets.some(ds => ds.isLoaded === true)) { + // Don't auto load + return; + } + + // find most recent and load it. + let recentDateTime = 0; + let recentDisplaySet = derivedDisplaySets[0]; + + derivedDisplaySets.forEach(ds => { + const dateTime = Number(`${ds.SeriesDate}${ds.SeriesTime}`); + if (dateTime > recentDateTime) { + recentDateTime = dateTime; + recentDisplaySet = ds; + } + }); + + recentDisplaySet.isLoading = true; + + recentDisplaySet.load(smDisplaySet); + } + + /** + * Removes the given third-party viewer API's object from the managed viewers + * and cleares all its event subscriptions + * + * @param {Object} viewer Third-party viewer API's object to be removed + */ + removeViewer(viewer) { + const managedViewer = viewer._manager; + + this._removeManagedViewerSubscriptions(managedViewer); + managedViewer.destroy(); + this.managedViewers.delete(managedViewer); + } + + /** + * Toggle ROIs visibility + */ + toggleROIsVisibility() { + this.isROIsVisible ? this.hideROIs() : this.showROIs; + this.isROIsVisible = !this.isROIsVisible; + } + + /** + * Hide all ROIs + */ + hideROIs() { + this.managedViewers.forEach(mv => mv.hideROIs()); + } + + /** Show all ROIs */ + showROIs() { + this.managedViewers.forEach(mv => mv.showROIs()); + } + + /** + * Returns a RoiAnnotation instance for the given ROI UID + * + * @param {String} uid UID of the annotation + * + * @returns {RoiAnnotation} The RoiAnnotation instance found for the given UID + */ + getAnnotation(uid) { + return this.annotations[uid]; + } + + /** + * Returns all the RoiAnnotation instances being managed + * + * @returns {Array} All RoiAnnotation instances + */ + getAnnotations() { + const annotations = []; + Object.keys(this.annotations).forEach(uid => { + annotations.push(this.getAnnotation(uid)); + }); + return annotations; + } + + /** + * Returns the RoiAnnotation instances registered with the given study UID + * + * @param {String} studyInstanceUID UID for the study + */ + getAnnotationsForStudy(studyInstanceUID) { + const filter = a => a.studyInstanceUID === studyInstanceUID; + return this.getAnnotations().filter(filter); + } + + /** + * Returns the RoiAnnotation instances registered with the given study and + * series UIDs + * + * @param {String} studyInstanceUID UID for the study + * @param {String} seriesInstanceUID UID for the series + */ + getAnnotationsForSeries(studyInstanceUID, seriesInstanceUID) { + const filter = annotation => + annotation.studyInstanceUID === studyInstanceUID && + annotation.seriesInstanceUID === seriesInstanceUID; + return this.getAnnotations().filter(filter); + } + + /** + * Returns the selected RoiAnnotation instance or null if none is selected + * + * @returns {RoiAnnotation} The selected RoiAnnotation instance + */ + getSelectedAnnotation() { + return this.selectedAnnotation; + } + + /** + * Clear current RoiAnnotation selection + */ + clearSelection() { + if (this.selectedAnnotation) { + this.setROIStyle(this.selectedAnnotation.uid, { + stroke: { + color: '#00ff00', + }, + }); + } + this.selectedAnnotation = null; + } + + /** + * Selects the given RoiAnnotation instance, publishing an ANNOTATION_SELECTED + * event to notify all the subscribers + * + * @param {RoiAnnotation} roiAnnotation The instance to be selected + */ + selectAnnotation(roiAnnotation) { + if (this.selectedAnnotation) this.clearSelection(); + + this.selectedAnnotation = roiAnnotation; + this._broadcastEvent(EVENTS.ANNOTATION_SELECTED, roiAnnotation); + this.setROIStyle(roiAnnotation.uid, styles.active); + } + + /** + * Toggles overview map + * + * @param viewportIndex The active viewport index + * @returns {void} + */ + toggleOverviewMap(viewportIndex) { + const managedViewers = Array.from(this.managedViewers); + const managedViewer = managedViewers.find( + mv => mv.viewportIndex === viewportIndex + ); + if (managedViewer) { + managedViewer.toggleOverviewMap(); + } + } + + /** + * Removes a RoiAnnotation instance from the managed annotations and reflects + * its removal on all third-party viewers being managed + * + * @param {RoiAnnotation} roiAnnotation The instance to be removed + */ + removeAnnotation(roiAnnotation) { + const { uid, studyInstanceUID, seriesInstanceUID } = roiAnnotation; + const filter = managedViewer => + managedViewer.studyInstanceUID === studyInstanceUID && + managedViewer.seriesInstanceUID === seriesInstanceUID; + + const managedViewers = Array.from(this.managedViewers).filter(filter); + + managedViewers.forEach(managedViewer => + managedViewer.removeRoiGraphic(uid) + ); + + if (this.annotations[uid]) { + this.roiUids.delete(uid); + this.annotations[uid].destroy(); + delete this.annotations[uid]; + + this._broadcastEvent(EVENTS.ANNOTATION_REMOVED, roiAnnotation); + } + } + + /** + * Focus the given RoiAnnotation instance by changing the OpenLayers' Map view + * state of the managed viewer with the given viewport index. + * If the image for the given annotation is not yet loaded into the viewport, + * it will set a pendingFocus flag to true in order to perform the focus when + * the managed viewer instance is created. + * + * @param {RoiAnnotation} roiAnnotation RoiAnnotation instance to be focused + * @param {Number} viewportIndex Index of the viewport to focus + */ + focusAnnotation(roiAnnotation, viewportIndex) { + const filter = mv => mv.viewportIndex === viewportIndex; + const managedViewer = Array.from(this.managedViewers).find(filter); + if (managedViewer) { + managedViewer.setViewStateByExtent(roiAnnotation); + } else { + this.pendingFocus = true; + } + } + + /** + * Synchronize the ROI graphics for all the managed viewers that has the same + * series UID of the given managed viewer + * + * @param {ViewerManager} baseManagedViewer Reference managed viewer + */ + synchronizeViewers(baseManagedViewer) { + const { studyInstanceUID, seriesInstanceUID } = baseManagedViewer; + const managedViewers = this._getManagedViewersForSeries( + studyInstanceUID, + seriesInstanceUID + ); + + // Prevent infinite loops arrising from updates. + managedViewers.forEach(managedViewer => + this._removeManagedViewerSubscriptions(managedViewer) + ); + + managedViewers.forEach(managedViewer => { + if (managedViewer === baseManagedViewer) { + return; + } + + const annotations = this.getAnnotationsForSeries( + studyInstanceUID, + seriesInstanceUID + ); + managedViewer.clearRoiGraphics(); + annotations.forEach(roiAnnotation => { + managedViewer.addRoiGraphic(roiAnnotation.roiGraphic); + }); + }); + + managedViewers.forEach(managedViewer => + this._addManagedViewerSubscriptions(managedViewer) + ); + } + + /** + * Activates interactions across all the viewers being managed + * + * @param {Array} interactions interactions + */ + activateInteractions(interactions) { + this.managedViewers.forEach(mv => mv.activateInteractions(interactions)); + this.activeInteractions = interactions; + } + + /** + * Triggers the relabelling process for the given RoiAnnotation instance, by + * publishing the RELABEL event to notify the subscribers + * + * @param {RoiAnnotation} roiAnnotation The instance to be relabelled + * @param {boolean} newAnnotation Whether the annotation is newly drawn (so it deletes on cancel). + */ + triggerRelabel(roiAnnotation, newAnnotation = false, onRelabel) { + if (!onRelabel) { + onRelabel = ({ label }) => + this.managedViewers.forEach(mv => + mv.updateROIProperties({ + uid: roiAnnotation.uid, + properties: { label }, + }) + ); + } + + this._broadcastEvent(EVENTS.RELABEL, { + roiAnnotation, + deleteCallback: () => this.removeAnnotation(roiAnnotation), + successCallback: onRelabel, + newAnnotation, + }); + } + + /** + * Triggers the deletion process for the given RoiAnnotation instance, by + * publishing the DELETE event to notify the subscribers + * + * @param {RoiAnnotation} roiAnnotation The instance to be deleted + */ + triggerDelete(roiAnnotation) { + this._broadcastEvent(EVENTS.DELETE, roiAnnotation); + } + + /** + * Set ROI style for all managed viewers + * + * @param {string} uid The ROI uid that will be styled + * @param {object} styleOptions - Style options + * @param {object*} styleOptions.stroke - Style options for the outline of the geometry + * @param {number[]} styleOptions.stroke.color - RGBA color of the outline + * @param {number} styleOptions.stroke.width - Width of the outline + * @param {object*} styleOptions.fill - Style options for body the geometry + * @param {number[]} styleOptions.fill.color - RGBA color of the body + * @param {object*} styleOptions.image - Style options for image + */ + setROIStyle(uid, styleOptions) { + this.managedViewers.forEach(mv => mv.setROIStyle(uid, styleOptions)); + } +} + +export { EVENTS }; diff --git a/extensions/dicom-microscopy/src/tools/viewerManager.js b/extensions/dicom-microscopy/src/tools/viewerManager.js new file mode 100644 index 000000000..bff7318d7 --- /dev/null +++ b/extensions/dicom-microscopy/src/tools/viewerManager.js @@ -0,0 +1,491 @@ +import coordinateFormatScoord3d2Geometry from '../utils/coordinateFormatScoord3d2Geometry'; +import styles from '../utils/styles'; + +import { PubSubService } from '@ohif/core'; + +// Events from the third-party viewer +const ApiEvents = { + /** Triggered when a ROI was added. */ + ROI_ADDED: 'dicommicroscopyviewer_roi_added', + /** Triggered when a ROI was modified. */ + ROI_MODIFIED: 'dicommicroscopyviewer_roi_modified', + /** Triggered when a ROI was removed. */ + ROI_REMOVED: 'dicommicroscopyviewer_roi_removed', + /** Triggered when a ROI was drawn. */ + ROI_DRAWN: `dicommicroscopyviewer_roi_drawn`, + /** Triggered when a ROI was selected. */ + ROI_SELECTED: `dicommicroscopyviewer_roi_selected`, + /** Triggered when a viewport move has started. */ + MOVE_STARTED: `dicommicroscopyviewer_move_started`, + /** Triggered when a viewport move has ended. */ + MOVE_ENDED: `dicommicroscopyviewer_move_ended`, + /** Triggered when a loading of data has started. */ + LOADING_STARTED: `dicommicroscopyviewer_loading_started`, + /** Triggered when a loading of data has ended. */ + LOADING_ENDED: `dicommicroscopyviewer_loading_ended`, + /** Triggered when an error occurs during loading of data. */ + LOADING_ERROR: `dicommicroscopyviewer_loading_error`, + /* Triggered when the loading of an image tile has started. */ + FRAME_LOADING_STARTED: `dicommicroscopyviewer_frame_loading_started`, + /* Triggered when the loading of an image tile has ended. */ + FRAME_LOADING_ENDED: `dicommicroscopyviewer_frame_loading_ended`, + /* Triggered when the error occurs during loading of an image tile. */ + FRAME_LOADING_ERROR: `dicommicroscopyviewer_frame_loading_ended`, +}; + +const EVENTS = { + ADDED: 'added', + MODIFIED: 'modified', + REMOVED: 'removed', + UPDATED: 'updated', + SELECTED: 'selected', +}; + +/** + * ViewerManager encapsulates the complexity of the third-party viewer and + * expose only the features/behaviors that are relevant to the application + */ +class ViewerManager extends PubSubService { + constructor( + viewer, + viewportIndex, + container, + studyInstanceUID, + seriesInstanceUID + ) { + super(EVENTS); + this.viewer = viewer; + this.viewportIndex = viewportIndex; + this.container = container; + this.studyInstanceUID = studyInstanceUID; + this.seriesInstanceUID = seriesInstanceUID; + + this.onRoiAdded = this.roiAddedHandler.bind(this); + this.onRoiModified = this.roiModifiedHandler.bind(this); + this.onRoiRemoved = this.roiRemovedHandler.bind(this); + this.onRoiSelected = this.roiSelectedHandler.bind(this); + this.contextMenuCallback = () => {}; + + // init symbols + const symbols = Object.getOwnPropertySymbols(this.viewer); + this._drawingSource = symbols.find(p => p.description === 'drawingSource'); + this._pyramid = symbols.find(p => p.description === 'pyramid'); + this._map = symbols.find(p => p.description === 'map'); + this._affine = symbols.find(p => p.description === 'affine'); + + this.registerEvents(); + this.activateDefaultInteractions(); + } + + addContextMenuCallback(callback) { + this.contextMenuCallback = callback; + } + + /** + * Destroys this managed viewer instance, clearing all the event handlers + */ + destroy() { + this.unregisterEvents(); + } + + /** + * This is to overrides the _broadcastEvent method of PubSubService and always + * send the ROI graphic object and this managed viewer instance. + * Due to the way that PubSubService is written, the same name override of the + * function doesn't work. + * + * @param {String} key key Subscription key + * @param {Object} roiGraphic ROI graphic object created by the third-party API + */ + publish(key, roiGraphic) { + this._broadcastEvent(key, { + roiGraphic, + managedViewer: this, + }); + } + + /** + * Registers all the relevant event handlers for the third-party API + */ + registerEvents() { + this.container.addEventListener(ApiEvents.ROI_ADDED, this.onRoiAdded); + this.container.addEventListener(ApiEvents.ROI_MODIFIED, this.onRoiModified); + this.container.addEventListener(ApiEvents.ROI_REMOVED, this.onRoiRemoved); + this.container.addEventListener(ApiEvents.ROI_SELECTED, this.onRoiSelected); + } + + /** + * Cleares all the relevant event handlers for the third-party API + */ + unregisterEvents() { + this.container.removeEventListener(ApiEvents.ROI_ADDED, this.onRoiAdded); + this.container.removeEventListener( + ApiEvents.ROI_MODIFIED, + this.onRoiModified + ); + this.container.removeEventListener( + ApiEvents.ROI_REMOVED, + this.onRoiRemoved + ); + this.container.removeEventListener( + ApiEvents.ROI_SELECTED, + this.onRoiSelected + ); + } + + /** + * Handles the ROI_ADDED event triggered by the third-party API + * + * @param {Event} event Event triggered by the third-party API + */ + roiAddedHandler(event) { + const roiGraphic = event.detail.payload; + this.publish(EVENTS.ADDED, roiGraphic); + this.publish(EVENTS.UPDATED, roiGraphic); + } + + /** + * Handles the ROI_MODIFIED event triggered by the third-party API + * + * @param {Event} event Event triggered by the third-party API + */ + roiModifiedHandler(event) { + const roiGraphic = event.detail.payload; + this.publish(EVENTS.MODIFIED, roiGraphic); + this.publish(EVENTS.UPDATED, roiGraphic); + } + + /** + * Handles the ROI_REMOVED event triggered by the third-party API + * + * @param {Event} event Event triggered by the third-party API + */ + roiRemovedHandler(event) { + const roiGraphic = event.detail.payload; + this.publish(EVENTS.REMOVED, roiGraphic); + this.publish(EVENTS.UPDATED, roiGraphic); + } + + /** + * Handles the ROI_SELECTED event triggered by the third-party API + * + * @param {Event} event Event triggered by the third-party API + */ + roiSelectedHandler(event) { + const roiGraphic = event.detail.payload; + this.publish(EVENTS.SELECTED, roiGraphic); + } + + /** + * Run the given callback operation without triggering any events for this + * instance, so subscribers will not be affected + * + * @param {Function} callback Callback that will run sinlently + */ + runSilently(callback) { + this.unregisterEvents(); + callback(); + this.registerEvents(); + } + + /** + * Removes all the ROI graphics from the third-party API + */ + clearRoiGraphics() { + this.runSilently(() => this.viewer.removeAllROIs()); + } + + showROIs() { + this.viewer.showROIs(); + } + + hideROIs() { + this.viewer.hideROIs(); + } + + /** + * Adds the given ROI graphic into the third-party API + * + * @param {Object} roiGraphic ROI graphic object to be added + */ + addRoiGraphic(roiGraphic) { + this.runSilently(() => this.viewer.addROI(roiGraphic, styles.default)); + } + + /** + * Adds the given ROI graphic into the third-party API, and also add a label. + * Used for importing from SR. + * + * @param {Object} roiGraphic ROI graphic object to be added. + * @param {String} label The label of the annotation. + */ + addRoiGraphicWithLabel(roiGraphic, label) { + // NOTE: Dicom Microscopy Viewer will override styles for "Text" evalutations + // to hide all other geometries, we are not going to use its label. + // if (label) { + // if (!roiGraphic.properties) roiGraphic.properties = {}; + // roiGraphic.properties.label = label; + // } + this.runSilently(() => this.viewer.addROI(roiGraphic, styles.default)); + + this._broadcastEvent(EVENTS.ADDED, { + roiGraphic, + managedViewer: this, + label, + }); + } + + /** + * Sets ROI style + * + * @param {String} uid ROI graphic UID to be styled + * @param {object} styleOptions - Style options + * @param {object} styleOptions.stroke - Style options for the outline of the geometry + * @param {number[]} styleOptions.stroke.color - RGBA color of the outline + * @param {number} styleOptions.stroke.width - Width of the outline + * @param {object} styleOptions.fill - Style options for body the geometry + * @param {number[]} styleOptions.fill.color - RGBA color of the body + * @param {object} styleOptions.image - Style options for image + */ + setROIStyle(uid, styleOptions) { + this.viewer.setROIStyle(uid, styleOptions); + } + + /** + * Removes the ROI graphic with the given UID from the third-party API + * + * @param {String} uid ROI graphic UID to be removed + */ + removeRoiGraphic(uid) { + this.viewer.removeROI(uid); + } + + /** + * Update properties of regions of interest. + * + * @param {object} roi - ROI to be updated + * @param {string} roi.uid - Unique identifier of the region of interest + * @param {object} roi.properties - ROI properties + * @returns {void} + */ + updateROIProperties({ uid, properties }) { + this.viewer.updateROI({ uid, properties }); + } + + /** + * Toggles overview map + * + * @returns {void} + */ + toggleOverviewMap() { + this.viewer.toggleOverviewMap(); + } + + /** + * Activates the viewer default interactions + * @returns {void} + */ + activateDefaultInteractions() { + /** Disable browser's native context menu inside the canvas */ + document.querySelector('.DicomMicroscopyViewer').addEventListener( + 'contextmenu', + event => { + event.preventDefault(); + // comment out when context menu for microscopy is enabled + // if (typeof this.contextMenuCallback === 'function') { + // this.contextMenuCallback(event); + // } + }, + false + ); + const defaultInteractions = [ + [ + 'dragPan', + { + bindings: { + mouseButtons: ['middle'], + }, + }, + ], + [ + 'dragZoom', + { + bindings: { + mouseButtons: ['right'], + }, + }, + ], + ['modify', {}], + ]; + this.activateInteractions(defaultInteractions); + } + + /** + * Activates interactions + * @param {Array} interactions Interactions to be activated + * @returns {void} + */ + activateInteractions(interactions) { + const interactionsMap = { + draw: activate => + activate ? 'activateDrawInteraction' : 'deactivateDrawInteraction', + modify: activate => + activate ? 'activateModifyInteraction' : 'deactivateModifyInteraction', + translate: activate => + activate + ? 'activateTranslateInteraction' + : 'deactivateTranslateInteraction', + snap: activate => + activate ? 'activateSnapInteraction' : 'deactivateSnapInteraction', + dragPan: activate => + activate + ? 'activateDragPanInteraction' + : 'deactivateDragPanInteraction', + dragZoom: activate => + activate + ? 'activateDragZoomInteraction' + : 'deactivateDragZoomInteraction', + select: activate => + activate ? 'activateSelectInteraction' : 'deactivateSelectInteraction', + }; + + const availableInteractionsName = Object.keys(interactionsMap); + availableInteractionsName.forEach(availableInteractionName => { + const interaction = interactions.find( + interaction => interaction[0] === availableInteractionName + ); + if (!interaction) { + const deactivateInteractionMethod = interactionsMap[ + availableInteractionName + ](false); + this.viewer[deactivateInteractionMethod](); + } else { + const [name, config] = interaction; + const activateInteractionMethod = interactionsMap[name](true); + this.viewer[activateInteractionMethod](config); + } + }); + } + + /** + * Accesses the internals of third-party API and returns the OpenLayers Map + * + * @returns {Object} OpenLayers Map component instance + */ + _getMapView() { + const map = this._getMap(); + return map.getView(); + } + + _getMap() { + const symbols = Object.getOwnPropertySymbols(this.viewer); + const _map = symbols.find(s => String(s) === 'Symbol(map)'); + window['map'] = this.viewer[_map]; + return this.viewer[_map]; + } + + /** + * Returns the current state for the OpenLayers View + * + * @returns {Object} Current view state + */ + getViewState() { + const view = this._getMapView(); + return { + center: view.getCenter(), + resolution: view.getResolution(), + zoom: view.getZoom(), + }; + } + + /** + * Sets the current state for the OpenLayers View + * + * @param {Object} viewState View state to be applied + */ + setViewState(viewState) { + const view = this._getMapView(); + + view.setZoom(viewState.zoom); + view.setResolution(viewState.resolution); + view.setCenter(viewState.center); + } + + setViewStateByExtent(roiAnnotation) { + const coordinates = roiAnnotation.getCoordinates(); + + if (Array.isArray(coordinates[0]) && !coordinates[2]) { + this._jumpToPolyline(coordinates); + } else if (Array.isArray(coordinates[0])) { + this._jumpToPolygonOrEllipse(coordinates); + } else { + this._jumpToPoint(coordinates); + } + } + + _jumpToPoint(coord) { + const pyramid = this.viewer[this._pyramid].metadata; + + const mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid); + const view = this._getMapView(); + + view.setCenter(mappedCoord); + } + + _jumpToPolyline(coord) { + const pyramid = this.viewer[this._pyramid].metadata; + + const mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid); + const view = this._getMapView(); + + const x = mappedCoord[0]; + const y = mappedCoord[1]; + + const xab = (x[0] + y[0]) / 2; + const yab = (x[1] + y[1]) / 2; + const midpoint = [xab, yab]; + + view.setCenter(midpoint); + } + + _jumpToPolygonOrEllipse(coordinates) { + const pyramid = this.viewer[this._pyramid].metadata; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + coordinates.forEach(coord => { + let mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid); + + const [x, y] = mappedCoord; + if (x < minX) { + minX = x; + } else if (x > maxX) { + maxX = x; + } + + if (y < minY) { + minY = y; + } else if (y > maxY) { + maxY = y; + } + }); + + const width = maxX - minX; + const height = maxY - minY; + + minX -= 0.5 * width; + maxX += 0.5 * width; + minY -= 0.5 * height; + maxY += 0.5 * height; + + const map = this._getMap(); + map.getView().fit([minX, minY, maxX, maxY], map.getSize()); + } +} + +export { EVENTS }; + +export default ViewerManager; diff --git a/extensions/dicom-microscopy/src/utils/DEVICE_OBSERVER_UID.js b/extensions/dicom-microscopy/src/utils/DEVICE_OBSERVER_UID.js new file mode 100644 index 000000000..cb3585870 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/DEVICE_OBSERVER_UID.js @@ -0,0 +1,5 @@ +// We need to define a UID for this extension as a device, and it should be the same for all saves: + +const uid = '2.25.285241207697168520771311899641885187923'; + +export default uid; diff --git a/extensions/dicom-microscopy/src/utils/RoiAnnotation.js b/extensions/dicom-microscopy/src/utils/RoiAnnotation.js new file mode 100644 index 000000000..e8ca04021 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/RoiAnnotation.js @@ -0,0 +1,196 @@ +import areaOfPolygon from './areaOfPolygon'; + +import { PubSubService } from '@ohif/core'; + +const EVENTS = { + LABEL_UPDATED: 'labelUpdated', + GRAPHIC_UPDATED: 'graphicUpdated', + VIEW_UPDATED: 'viewUpdated', + REMOVED: 'removed', +}; + +/** + * Represents a single annotation for the Microscopy Viewer + */ +class RoiAnnotation extends PubSubService { + constructor( + roiGraphic, + studyInstanceUID, + seriesInstanceUID, + label = '', + viewState = null + ) { + super(EVENTS); + this.uid = roiGraphic.uid; + this.roiGraphic = roiGraphic; + this.studyInstanceUID = studyInstanceUID; + this.seriesInstanceUID = seriesInstanceUID; + this.label = label; + this.viewState = viewState; + this.setMeasurements(roiGraphic); + } + + getScoord3d() { + const roiGraphic = this.roiGraphic; + + const roiGraphicSymbols = Object.getOwnPropertySymbols(roiGraphic); + const _scoord3d = roiGraphicSymbols.find( + s => String(s) === 'Symbol(scoord3d)' + ); + + return roiGraphic[_scoord3d]; + } + + getCoordinates() { + const scoord3d = this.getScoord3d(); + const scoord3dSymbols = Object.getOwnPropertySymbols(scoord3d); + + const _coordinates = scoord3dSymbols.find( + s => String(s) === 'Symbol(coordinates)' + ); + + const coordinates = scoord3d[_coordinates]; + return coordinates; + } + + /** + * When called will trigger the REMOVED event + */ + destroy() { + this._broadcastEvent(EVENTS.REMOVED, this); + } + + /** + * Updates the ROI graphic for the annotation and triggers the GRAPHIC_UPDATED + * event + * + * @param {Object} roiGraphic + */ + setRoiGraphic(roiGraphic) { + this.roiGraphic = roiGraphic; + this.setMeasurements(); + this._broadcastEvent(EVENTS.GRAPHIC_UPDATED, this); + } + + /** + * Update ROI measurement values based on its scoord3d coordinates. + * + * @returns {void} + */ + setMeasurements() { + const type = this.roiGraphic.scoord3d.graphicType; + const coordinates = this.roiGraphic.scoord3d.graphicData; + + switch (type) { + case 'ELLIPSE': + // This is a circle so only need one side + const point1 = coordinates[0]; + const point2 = coordinates[1]; + + let xLength2 = point2[0] - point1[0]; + let yLength2 = point2[1] - point1[1]; + + xLength2 *= xLength2; + yLength2 *= yLength2; + + const length = Math.sqrt(xLength2 + yLength2); + const radius = length / 2; + + const areaEllipse = Math.PI * radius * radius; + this._area = areaEllipse; + this._length = undefined; + break; + + case 'POLYGON': + const areaPolygon = areaOfPolygon(coordinates); + this._area = areaPolygon; + this._length = undefined; + break; + + case 'POINT': + this._area = undefined; + this._length = undefined; + break; + + case 'POLYLINE': + let len = 0; + for (let i = 1; i < coordinates.length; i++) { + const p1 = coordinates[i - 1]; + const p2 = coordinates[i]; + + let xLen = p2[0] - p1[0]; + let yLen = p2[1] - p1[1]; + + xLen *= xLen; + yLen *= yLen; + len += Math.sqrt(xLen + yLen); + } + + this._area = undefined; + this._length = len; + break; + } + } + + /** + * Update the OpenLayer Map's view state for the annotation and triggers the + * VIEW_UPDATED event + * + * @param {Object} viewState The new view state for the annotation + */ + setViewState(viewState) { + this.viewState = viewState; + this._broadcastEvent(EVENTS.VIEW_UPDATED, this); + } + + /** + * Update the label for the annotation and triggers the LABEL_UPDATED event + * + * @param {String} label New label for the annotation + */ + setLabel(label, finding) { + this.label = label || (finding && finding.CodeMeaning); + this.finding = finding || { + CodingSchemeDesignator: '@ohif/extension-dicom-microscopy', + CodeValue: label, + CodeMeaning: label, + }; + this._broadcastEvent(EVENTS.LABEL_UPDATED, this); + } + + /** + * Returns the geometry type of the annotation concatenated with the label + * defined for the annotation. + * Difference with getDetailedLabel() is that this will return empty string for empy + * label. + * + * @returns {String} Text with geometry type and label + */ + getLabel() { + const label = this.label ? `${this.label}` : ''; + return label; + } + + /** + * Returns the geometry type of the annotation concatenated with the label + * defined for the annotation + * + * @returns {String} Text with geometry type and label + */ + getDetailedLabel() { + const label = this.label ? `${this.label}` : '(empty)'; + return label; + } + + getLength() { + return this._length; + } + + getArea() { + return this._area; + } +} + +export { EVENTS }; + +export default RoiAnnotation; diff --git a/extensions/dicom-microscopy/src/utils/areaOfPolygon.js b/extensions/dicom-microscopy/src/utils/areaOfPolygon.js new file mode 100644 index 000000000..91a68c9ae --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/areaOfPolygon.js @@ -0,0 +1,17 @@ +export default function areaOfPolygon(coordinates) { + // Shoelace algorithm. + const n = coordinates.length; + let area = 0.0; + let j = n - 1; + + for (let i = 0; i < n; i++) { + area += + (coordinates[j][0] + coordinates[i][0]) * + (coordinates[j][1] - coordinates[i][1]); + j = i; // j is previous vertex to i + } + + // Return absolute value of half the sum + // (The value is halved as we are summing up triangles, not rectangles). + return Math.abs(area / 2.0); +} diff --git a/extensions/dicom-microscopy/src/utils/callInputDialog.tsx b/extensions/dicom-microscopy/src/utils/callInputDialog.tsx new file mode 100644 index 000000000..bffc2a627 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/callInputDialog.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Input, Dialog } from '@ohif/ui'; + +/** + * + * @param {*} data + * @param {*} data.text + * @param {*} data.label + * @param {*} event + * @param {func} callback + * @param {*} isArrowAnnotateInputDialog + */ +export default function callInputDialog({ + uiDialogService, + title = 'Enter your annotation', + defaultValue = '', + callback = (value: string, action: string) => {} +}) { + const dialogId = 'microscopy-input-dialog'; + + const onSubmitHandler = ({ action, value }) => { + switch (action.id) { + case 'save': + callback(value.value, action.id); + break; + case 'cancel': + callback('', action.id); + break; + } + uiDialogService.dismiss({ id: dialogId }); + }; + + if (uiDialogService) { + uiDialogService.create({ + id: dialogId, + centralize: true, + isDraggable: false, + showOverlay: true, + content: Dialog, + contentProps: { + title: title, + value: { value: defaultValue }, + noCloseButton: true, + onClose: () => uiDialogService.dismiss({ id: dialogId }), + actions: [ + { id: 'cancel', text: 'Cancel', type: 'primary' }, + { id: 'save', text: 'Save', type: 'secondary' }, + ], + onSubmit: onSubmitHandler, + body: ({ value, setValue }) => { + return ( +
+ { + event.persist(); + setValue(value => ({ ...value, value: event.target.value })); + }} + onKeyPress={event => { + if (event.key === 'Enter') { + onSubmitHandler({ value, action: { id: 'save' } }); + } + }} + /> +
+ ); + }, + }, + }); + } +} \ No newline at end of file diff --git a/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts b/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts new file mode 100644 index 000000000..91d72e3e2 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts @@ -0,0 +1,63 @@ +function isPrimitive(v: any) { + return !(typeof v == 'object' || Array.isArray(v)); +} + +const vrNumerics = [ + 'DS', + 'FL', + 'FD', + 'IS', + 'OD', + 'OF', + 'OL', + 'OV', + 'SL', + 'SS', + 'SV', + 'UL', + 'US', + 'UV', +]; + +/** + * Specialized for DICOM JSON format dataset cleaning. + * @param obj + * @returns + */ +export default function cleanDenaturalizedDataset(obj: any): any { + if (Array.isArray(obj)) { + const newAry = obj.map(o => + isPrimitive(o) ? o : cleanDenaturalizedDataset(o) + ); + return newAry; + } else if (isPrimitive(obj)) { + return obj; + } else { + Object.keys(obj).forEach(key => { + if (obj[key].Value === null && obj[key].vr) { + delete obj[key].Value; + } else if (Array.isArray(obj[key].Value) && obj[key].vr) { + if (obj[key].Value.length === 1 && obj[key].Value[0].BulkDataURI) { + obj[key].BulkDataURI = obj[key].Value[0].BulkDataURI; + + // prevent mixed-content blockage + if ( + window.location.protocol === 'https:' && + obj[key].BulkDataURI.startsWith('http:') + ) { + obj[key].BulkDataURI = obj[key].BulkDataURI.replace( + 'http:', + 'https:' + ); + } + delete obj[key].Value; + } else if (vrNumerics.includes(obj[key].vr)) { + obj[key].Value = obj[key].Value.map(v => +v); + } else { + obj[key].Value = obj[key].Value.map(cleanDenaturalizedDataset); + } + } + }); + return obj; + } +} diff --git a/extensions/dicom-microscopy/src/utils/constructSR.ts b/extensions/dicom-microscopy/src/utils/constructSR.ts new file mode 100644 index 000000000..ff78f13fe --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/constructSR.ts @@ -0,0 +1,213 @@ +import dcmjs from 'dcmjs'; +import DEVICE_OBSERVER_UID from './DEVICE_OBSERVER_UID'; + +/** + * + * @param {*} metadata - Microscopy Image instance metadata + * @param {*} SeriesDescription - SR description + * @param {*} annotations - Annotations + * + * @return Comprehensive3DSR dataset + */ +export default function constructSR( + metadata, + { SeriesDescription, SeriesNumber }, + annotations +) { + // Handle malformed data + if (!metadata.SpecimenDescriptionSequence) { + metadata.SpecimenDescriptionSequence = { + SpecimenUID: metadata.SeriesInstanceUID, + SpecimenIdentifier: metadata.SeriesDescription, + }; + } + const { SpecimenDescriptionSequence } = metadata; + + // construct Comprehensive3DSR dataset + const observationContext = new dcmjs.sr.templates.ObservationContext({ + observerPersonContext: new dcmjs.sr.templates.ObserverContext({ + observerType: new dcmjs.sr.coding.CodedConcept({ + value: '121006', + schemeDesignator: 'DCM', + meaning: 'Person', + }), + observerIdentifyingAttributes: new dcmjs.sr.templates.PersonObserverIdentifyingAttributes( + { + name: '@ohif/extension-dicom-microscopy', + } + ), + }), + observerDeviceContext: new dcmjs.sr.templates.ObserverContext({ + observerType: new dcmjs.sr.coding.CodedConcept({ + value: '121007', + schemeDesignator: 'DCM', + meaning: 'Device', + }), + observerIdentifyingAttributes: new dcmjs.sr.templates.DeviceObserverIdentifyingAttributes( + { + uid: DEVICE_OBSERVER_UID, + } + ), + }), + subjectContext: new dcmjs.sr.templates.SubjectContext({ + subjectClass: new dcmjs.sr.coding.CodedConcept({ + value: '121027', + schemeDesignator: 'DCM', + meaning: 'Specimen', + }), + subjectClassSpecificContext: new dcmjs.sr.templates.SubjectContextSpecimen( + { + uid: SpecimenDescriptionSequence.SpecimenUID, + identifier: + SpecimenDescriptionSequence.SpecimenIdentifier || + metadata.SeriesInstanceUID, + containerIdentifier: + metadata.ContainerIdentifier || metadata.SeriesInstanceUID, + } + ), + }), + }); + + const imagingMeasurements = []; + for (let i = 0; i < annotations.length; i++) { + const { roiGraphic: roi, label } = annotations[i]; + let { + measurements, + evaluations, + marker, + presentationState, + } = roi.properties; + + console.debug('[SR] storing marker...', marker); + console.debug('[SR] storing measurements...', measurements); + console.debug('[SR] storing evaluations...', evaluations); + console.debug('[SR] storing presentation state...', presentationState); + + if (presentationState) presentationState.marker = marker; + + /** Avoid incompatibility with dcmjs */ + measurements = measurements.map((measurement: any) => { + const ConceptName = Array.isArray(measurement.ConceptNameCodeSequence) + ? measurement.ConceptNameCodeSequence[0] + : measurement.ConceptNameCodeSequence; + + const MeasuredValue = Array.isArray(measurement.MeasuredValueSequence) + ? measurement.MeasuredValueSequence[0] + : measurement.MeasuredValueSequence; + + const MeasuredValueUnits = Array.isArray( + MeasuredValue.MeasurementUnitsCodeSequence + ) + ? MeasuredValue.MeasurementUnitsCodeSequence[0] + : MeasuredValue.MeasurementUnitsCodeSequence; + + return new dcmjs.sr.valueTypes.NumContentItem({ + name: new dcmjs.sr.coding.CodedConcept({ + meaning: ConceptName.CodeMeaning, + value: ConceptName.CodeValue, + schemeDesignator: ConceptName.CodingSchemeDesignator, + }), + value: MeasuredValue.NumericValue, + unit: new dcmjs.sr.coding.CodedConcept({ + value: MeasuredValueUnits.CodeValue, + meaning: MeasuredValueUnits.CodeMeaning, + schemeDesignator: MeasuredValueUnits.CodingSchemeDesignator, + }), + }); + }); + + /** Avoid incompatibility with dcmjs */ + evaluations = evaluations.map((evaluation: any) => { + const ConceptName = Array.isArray(evaluation.ConceptNameCodeSequence) + ? evaluation.ConceptNameCodeSequence[0] + : evaluation.ConceptNameCodeSequence; + + return new dcmjs.sr.valueTypes.TextContentItem({ + name: new dcmjs.sr.coding.CodedConcept({ + value: ConceptName.CodeValue, + meaning: ConceptName.CodeMeaning, + schemeDesignator: ConceptName.CodingSchemeDesignator, + }), + value: evaluation.TextValue, + relationshipType: evaluation.RelationshipType, + }); + }); + + const identifier = `ROI #${i + 1}`; + const group = new dcmjs.sr.templates.PlanarROIMeasurementsAndQualitativeEvaluations( + { + trackingIdentifier: new dcmjs.sr.templates.TrackingIdentifier({ + uid: roi.uid, + identifier: presentationState + ? identifier.concat(`(${JSON.stringify(presentationState)})`) + : identifier, + }), + referencedRegion: new dcmjs.sr.contentItems.ImageRegion3D({ + graphicType: roi.scoord3d.graphicType, + graphicData: roi.scoord3d.graphicData, + frameOfReferenceUID: roi.scoord3d.frameOfReferenceUID, + }), + findingType: new dcmjs.sr.coding.CodedConcept({ + value: label, + schemeDesignator: '@ohif/extension-dicom-microscopy', + meaning: 'FREETEXT', + }), + /** Evaluations will conflict with current tracking identifier */ + /** qualitativeEvaluations: evaluations, */ + measurements, + } + ); + imagingMeasurements.push(...group); + } + + const measurementReport = new dcmjs.sr.templates.MeasurementReport({ + languageOfContentItemAndDescendants: new dcmjs.sr.templates.LanguageOfContentItemAndDescendants( + {} + ), + observationContext, + procedureReported: new dcmjs.sr.coding.CodedConcept({ + value: '112703', + schemeDesignator: 'DCM', + meaning: 'Whole Slide Imaging', + }), + imagingMeasurements, + }); + + const dataset = new dcmjs.sr.documents.Comprehensive3DSR({ + content: measurementReport[0], + evidence: [metadata], + seriesInstanceUID: dcmjs.data.DicomMetaDictionary.uid(), + seriesNumber: SeriesNumber, + seriesDescription: + SeriesDescription || 'Whole slide imaging structured report', + sopInstanceUID: dcmjs.data.DicomMetaDictionary.uid(), + instanceNumber: 1, + manufacturer: 'dcmjs-org', + }); + dataset.SpecificCharacterSet = 'ISO_IR 192'; + const fileMetaInformationVersionArray = new Uint8Array(2); + fileMetaInformationVersionArray[1] = 1; + + dataset._meta = { + FileMetaInformationVersion: { + Value: [fileMetaInformationVersionArray.buffer], // TODO + vr: 'OB', + }, + MediaStorageSOPClassUID: dataset.sopClassUID, + MediaStorageSOPInstanceUID: dataset.sopInstanceUID, + TransferSyntaxUID: { + Value: ['1.2.840.10008.1.2.1'], + vr: 'UI', + }, + ImplementationClassUID: { + Value: [dcmjs.data.DicomMetaDictionary.uid()], + vr: 'UI', + }, + ImplementationVersionName: { + Value: ['@ohif/extension-dicom-microscopy'], + vr: 'SH', + }, + }; + + return dataset; +} diff --git a/extensions/dicom-microscopy/src/utils/coordinateFormatScoord3d2Geometry.js b/extensions/dicom-microscopy/src/utils/coordinateFormatScoord3d2Geometry.js new file mode 100644 index 000000000..70b6e42d5 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/coordinateFormatScoord3d2Geometry.js @@ -0,0 +1,110 @@ +import { inv, multiply } from 'mathjs'; + +// TODO -> This is pulled out of some internal logic from Dicom Microscopy Viewer, +// We should likely just expose this there. + +export default function coordinateFormatScoord3d2Geometry( + coordinates, + pyramid +) { + let transform = false; + if (!Array.isArray(coordinates[0])) { + coordinates = [coordinates]; + transform = true; + } + const metadata = pyramid[pyramid.length - 1]; + const orientation = metadata.ImageOrientationSlide; + const spacing = _getPixelSpacing(metadata); + const origin = metadata.TotalPixelMatrixOriginSequence[0]; + const offset = [ + Number(origin.XOffsetInSlideCoordinateSystem), + Number(origin.YOffsetInSlideCoordinateSystem), + ]; + + coordinates = coordinates.map(c => { + const slideCoord = [c[0], c[1]]; + const pixelCoord = mapSlideCoord2PixelCoord({ + offset, + orientation, + spacing, + point: slideCoord, + }); + return [pixelCoord[0], -(pixelCoord[1] + 1), 0]; + }); + if (transform) { + return coordinates[0]; + } + return coordinates; +} + +function _getPixelSpacing(metadata) { + if (metadata.PixelSpacing) return metadata.PixelSpacing; + const functionalGroup = metadata.SharedFunctionalGroupsSequence[0]; + const pixelMeasures = functionalGroup.PixelMeasuresSequence[0]; + return pixelMeasures.PixelSpacing; +} + +function mapSlideCoord2PixelCoord(options) { + // X and Y Offset in Slide Coordinate System + if (!('offset' in options)) { + throw new Error('Option "offset" is required.'); + } + if (!Array.isArray(options.offset)) { + throw new Error('Option "offset" must be an array.'); + } + if (options.offset.length !== 2) { + throw new Error('Option "offset" must be an array with 2 elements.'); + } + const offset = options.offset; + + // Image Orientation Slide with direction cosines for Row and Column direction + if (!('orientation' in options)) { + throw new Error('Option "orientation" is required.'); + } + if (!Array.isArray(options.orientation)) { + throw new Error('Option "orientation" must be an array.'); + } + if (options.orientation.length !== 6) { + throw new Error('Option "orientation" must be an array with 6 elements.'); + } + const orientation = options.orientation; + + // Pixel Spacing along the Row and Column direction + if (!('spacing' in options)) { + throw new Error('Option "spacing" is required.'); + } + if (!Array.isArray(options.spacing)) { + throw new Error('Option "spacing" must be an array.'); + } + if (options.spacing.length !== 2) { + throw new Error('Option "spacing" must be an array with 2 elements.'); + } + const spacing = options.spacing; + + // X and Y coordinate in the Slide Coordinate System + if (!('point' in options)) { + throw new Error('Option "point" is required.'); + } + if (!Array.isArray(options.point)) { + throw new Error('Option "point" must be an array.'); + } + if (options.point.length !== 2) { + throw new Error('Option "point" must be an array with 2 elements.'); + } + const point = options.point; + + const m = [ + [orientation[0] * spacing[1], orientation[3] * spacing[0], offset[0]], + [orientation[1] * spacing[1], orientation[4] * spacing[0], offset[1]], + [0, 0, 1], + ]; + const mInverted = inv(m); + + const vSlide = [[point[0]], [point[1]], [1]]; + + const vImage = multiply(mInverted, vSlide); + + const row = Number(vImage[1][0].toFixed(4)); + const col = Number(vImage[0][0].toFixed(4)); + return [col, row]; +} diff --git a/extensions/dicom-microscopy/src/utils/dcmCodeValues.js b/extensions/dicom-microscopy/src/utils/dcmCodeValues.js new file mode 100644 index 000000000..82d489919 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/dcmCodeValues.js @@ -0,0 +1,14 @@ +const DCM_CODE_VALUES = { + IMAGING_MEASUREMENTS: '126010', + MEASUREMENT_GROUP: '125007', + IMAGE_REGION: '111030', + FINDING: '121071', + TRACKING_UNIQUE_IDENTIFIER: '112039', + LENGTH: '410668003', + AREA: '42798000', + SHORT_AXIS: 'G-A186', + LONG_AXIS: 'G-A185', + ELLIPSE_AREA: 'G-D7FE', // TODO: Remove this +}; + +export default DCM_CODE_VALUES; diff --git a/extensions/dicom-microscopy/src/utils/dicomWebClient.ts b/extensions/dicom-microscopy/src/utils/dicomWebClient.ts new file mode 100644 index 000000000..8174d6c26 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/dicomWebClient.ts @@ -0,0 +1,96 @@ +import { api } from 'dicomweb-client'; +import { errorHandler, DicomMetadataStore } from '@ohif/core'; + +/** + * create a DICOMwebClient object to be used by Dicom Microscopy Viewer + * + * Referenced the code from `/extensions/default/src/DicomWebDataSource/index.js` + * + * @param param0 + * @returns + */ +export default function getDicomWebClient({ + extensionManager, + servicesManager, +}) { + const dataSourceConfig = window.config.dataSources.find( + ds => ds.sourceName === extensionManager.activeDataSource + ); + const { userAuthenticationService } = servicesManager.services; + + const { wadoRoot, staticWado, singlepart } = dataSourceConfig.configuration; + + const wadoConfig = { + url: wadoRoot || '/dicomlocal', + staticWado, + singlepart, + headers: userAuthenticationService.getAuthorizationHeader(), + errorInterceptor: errorHandler.getHTTPErrorHandler(), + }; + + const client = new api.DICOMwebClient(wadoConfig); + client.wadoURL = wadoConfig.url; + + if (extensionManager.activeDataSource === 'dicomlocal') { + /** + * For local data source, override the retrieveInstanceFrames() method of the + * dicomweb-client to retrieve image data from memory cached metadata. + * Other methods of the client doesn't matter, as we are feeding the DMV + * with the series metadata already. + * + * @param {Object} options + * @param {String} options.studyInstanceUID - Study Instance UID + * @param {String} options.seriesInstanceUID - Series Instance UID + * @param {String} options.sopInstanceUID - SOP Instance UID + * @param {String} options.frameNumbers - One-based indices of Frame Items + * @param {Object} [options.queryParams] - HTTP query parameters + * @returns {ArrayBuffer[]} Rendered Frame Items as byte arrays + */ + // + client.retrieveInstanceFrames = async options => { + if (!('studyInstanceUID' in options)) { + throw new Error( + 'Study Instance UID is required for retrieval of instance frames' + ); + } + if (!('seriesInstanceUID' in options)) { + throw new Error( + 'Series Instance UID is required for retrieval of instance frames' + ); + } + if (!('sopInstanceUID' in options)) { + throw new Error( + 'SOP Instance UID is required for retrieval of instance frames' + ); + } + if (!('frameNumbers' in options)) { + throw new Error( + 'frame numbers are required for retrieval of instance frames' + ); + } + console.log( + `retrieve frames ${options.frameNumbers.toString()} of instance ${ + options.sopInstanceUID + }` + ); + + const instance = DicomMetadataStore.getInstance( + options.studyInstanceUID, + options.seriesInstanceUID, + options.sopInstanceUID + ); + + const frameNumbers = Array.isArray(options.frameNumbers) + ? options.frameNumbers + : options.frameNumbers.split(','); + + return frameNumbers.map(fr => + Array.isArray(instance.PixelData) + ? instance.PixelData[+fr - 1] + : instance.PixelData + ); + }; + } + + return client; +} diff --git a/extensions/dicom-microscopy/src/utils/getSourceDisplaySet.js b/extensions/dicom-microscopy/src/utils/getSourceDisplaySet.js new file mode 100644 index 000000000..d1455b639 --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/getSourceDisplaySet.js @@ -0,0 +1,38 @@ +/** + * Get referenced SM displaySet from SR displaySet + * + * @param {*} allDisplaySets + * @param {*} microscopySRDisplaySet + * @returns + */ +export default function getSourceDisplaySet( + allDisplaySets, + microscopySRDisplaySet +) { + const { ReferencedFrameOfReferenceUID } = microscopySRDisplaySet; + + const otherDisplaySets = allDisplaySets.filter( + ds => + ds.displaySetInstanceUID !== microscopySRDisplaySet.displaySetInstanceUID + ); + const referencedDisplaySet = otherDisplaySets.find( + displaySet => + displaySet.Modality === 'SM' && + (displaySet.FrameOfReferenceUID === ReferencedFrameOfReferenceUID || + // sometimes each depth instance has the different FrameOfReferenceID + displaySet.othersFrameOfReferenceUID.includes( + ReferencedFrameOfReferenceUID + )) + ); + + if (!referencedDisplaySet && otherDisplaySets.length >= 1) { + console.warn( + 'No display set with FrameOfReferenceUID', + ReferencedFrameOfReferenceUID, + 'single series, assuming data error, defaulting to only series.' + ); + return otherDisplaySets.find(displaySet => displaySet.Modality === 'SM'); + } + + return referencedDisplaySet; +} diff --git a/extensions/dicom-microscopy/src/utils/loadSR.js b/extensions/dicom-microscopy/src/utils/loadSR.js new file mode 100644 index 000000000..c690028bb --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/loadSR.js @@ -0,0 +1,207 @@ +import dcmjs from 'dcmjs'; + +import DCM_CODE_VALUES from './dcmCodeValues'; +import toArray from './toArray'; + +const MeasurementReport = + dcmjs.adapters.DICOMMicroscopyViewer.MeasurementReport; + +// Define as async so that it returns a promise, expected by the ViewportGrid +export default async function loadSR( + microscopyService, + microscopySRDisplaySet, + referencedDisplaySet +) { + const naturalizedDataset = microscopySRDisplaySet.metadata; + + const { StudyInstanceUID, FrameOfReferenceUID } = referencedDisplaySet; + + const managedViewers = microscopyService.getManagedViewersForStudy( + StudyInstanceUID + ); + + if (!managedViewers || !managedViewers.length) { + return; + } + + microscopySRDisplaySet.isLoaded = true; + + const { rois, labels } = await _getROIsFromToolState( + naturalizedDataset, + FrameOfReferenceUID + ); + + const managedViewer = managedViewers[0]; + + for (let i = 0; i < rois.length; i++) { + // NOTE: When saving Microscopy SR, we are attaching identifier property + // to each ROI, and when read for display, it is coming in as "TEXT" + // evaluation. + // As the Dicom Microscopy Viewer will override styles for "Text" evalutations + // to hide all other geometries, we are going to manually remove that + // evaluation item. + const roi = rois[i]; + const roiSymbols = Object.getOwnPropertySymbols(roi); + const _properties = roiSymbols.find(s => s.description === 'properties'); + const properties = roi[_properties]; + properties['evaluations'] = []; + + managedViewer.addRoiGraphicWithLabel(roi, labels[i]); + } +} + +async function _getROIsFromToolState(naturalizedDataset, FrameOfReferenceUID) { + const toolState = MeasurementReport.generateToolState(naturalizedDataset); + const tools = Object.getOwnPropertyNames(toolState); + + const DICOMMicroscopyViewer = await import( + /* webpackChunkName: "dicom-microscopy-viewer" */ 'dicom-microscopy-viewer' + ); + + const measurementGroupContentItems = _getMeasurementGroups( + naturalizedDataset + ); + + const rois = []; + const labels = []; + + tools.forEach(t => { + const toolSpecificToolState = toolState[t]; + let scoord3d; + + const capsToolType = t.toUpperCase(); + + const measurementGroupContentItemsForTool = measurementGroupContentItems.filter( + mg => { + const imageRegionContentItem = toArray(mg.ContentSequence).find( + ci => + ci.ConceptNameCodeSequence.CodeValue === + DCM_CODE_VALUES.IMAGE_REGION + ); + + return imageRegionContentItem.GraphicType === capsToolType; + } + ); + + toolSpecificToolState.forEach((coordinates, index) => { + const properties = {}; + + const options = { + coordinates, + frameOfReferenceUID: FrameOfReferenceUID, + }; + + if (t === 'Polygon') { + scoord3d = new DICOMMicroscopyViewer.scoord3d.Polygon(options); + } else if (t === 'Polyline') { + scoord3d = new DICOMMicroscopyViewer.scoord3d.Polyline(options); + } else if (t === 'Point') { + scoord3d = new DICOMMicroscopyViewer.scoord3d.Point(options); + } else if (t === 'Ellipse') { + scoord3d = new DICOMMicroscopyViewer.scoord3d.Ellipse(options); + } else { + throw new Error('Unsupported tool type'); + } + + const measurementGroup = measurementGroupContentItemsForTool[index]; + const findingGroup = toArray(measurementGroup.ContentSequence).find( + ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.FINDING + ); + + const trackingGroup = toArray(measurementGroup.ContentSequence).find( + ci => + ci.ConceptNameCodeSequence.CodeValue === + DCM_CODE_VALUES.TRACKING_UNIQUE_IDENTIFIER + ); + + /** + * Extract presentation state from tracking identifier. + * Currently is stored in SR but should be stored in its tags. + */ + if (trackingGroup) { + const regExp = /\(([^)]+)\)/; + const matches = regExp.exec(trackingGroup.TextValue); + if (matches && matches[1]) { + properties.presentationState = JSON.parse(matches[1]); + properties.marker = properties.presentationState.marker; + } + } + + let measurements = toArray(measurementGroup.ContentSequence).filter(ci => + [ + DCM_CODE_VALUES.LENGTH, + DCM_CODE_VALUES.AREA, + DCM_CODE_VALUES.SHORT_AXIS, + DCM_CODE_VALUES.LONG_AXIS, + DCM_CODE_VALUES.ELLIPSE_AREA, + ].includes(ci.ConceptNameCodeSequence.CodeValue) + ); + + let evaluations = toArray(measurementGroup.ContentSequence).filter(ci => + [DCM_CODE_VALUES.TRACKING_UNIQUE_IDENTIFIER].includes( + ci.ConceptNameCodeSequence.CodeValue + ) + ); + + /** + * TODO: Resolve bug in DCMJS. + * ConceptNameCodeSequence should be a sequence with only one item. + */ + evaluations = evaluations.map(evaluation => { + const e = { ...evaluation }; + e.ConceptNameCodeSequence = toArray(e.ConceptNameCodeSequence); + return e; + }); + + /** + * TODO: Resolve bug in DCMJS. + * ConceptNameCodeSequence should be a sequence with only one item. + */ + measurements = measurements.map(measurement => { + const m = { ...measurement }; + m.ConceptNameCodeSequence = toArray(m.ConceptNameCodeSequence); + return m; + }); + + if (measurements && measurements.length) { + properties.measurements = measurements; + console.debug('[SR] retrieving measurements...', measurements); + } + + if (evaluations && evaluations.length) { + properties.evaluations = evaluations; + console.debug('[SR] retrieving evaluations...', evaluations); + } + + const roi = new DICOMMicroscopyViewer.roi.ROI({ scoord3d, properties }); + rois.push(roi); + + if (findingGroup) { + labels.push(findingGroup.ConceptCodeSequence.CodeValue); + } else { + labels.push(''); + } + }); + }); + + return { rois, labels }; +} + +function _getMeasurementGroups(naturalizedDataset) { + const { ContentSequence } = naturalizedDataset; + + const imagingMeasurementsContentItem = ContentSequence.find( + ci => + ci.ConceptNameCodeSequence.CodeValue === + DCM_CODE_VALUES.IMAGING_MEASUREMENTS + ); + + const measurementGroupContentItems = toArray( + imagingMeasurementsContentItem.ContentSequence + ).filter( + ci => + ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.MEASUREMENT_GROUP + ); + + return measurementGroupContentItems; +} diff --git a/extensions/dicom-microscopy/src/utils/saveByteArray.ts b/extensions/dicom-microscopy/src/utils/saveByteArray.ts new file mode 100644 index 000000000..d264fe7cd --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/saveByteArray.ts @@ -0,0 +1,12 @@ +/** + * Trigger file download from an array buffer + * @param buffer + * @param filename + */ +export function saveByteArray(buffer: ArrayBuffer, filename: string) { + var blob = new Blob([buffer], { type: 'application/dicom' }); + var link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = filename; + link.click(); +} diff --git a/extensions/dicom-microscopy/src/utils/styles.js b/extensions/dicom-microscopy/src/utils/styles.js new file mode 100644 index 000000000..030b910bc --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/styles.js @@ -0,0 +1,48 @@ +const defaultFill = { + color: 'rgba(255,255,255,0.4)', +}; + +const emptyFill = { + color: 'rgba(255,255,255,0.0)', +}; + +const defaultStroke = { + color: 'rgb(0,255,0)', + width: 1.5, +}; + +const activeStroke = { + color: 'rgb(255,255,0)', + width: 1.5, +}; + +const defaultStyle = { + image: { + circle: { + fill: defaultFill, + stroke: activeStroke, + radius: 5, + }, + }, + fill: defaultFill, + stroke: activeStroke, +}; + +const emptyStyle = { + image: { + circle: { + fill: emptyFill, + stroke: defaultStroke, + radius: 5, + }, + }, + fill: emptyFill, + stroke: defaultStroke, +}; + +const styles = { + active: defaultStyle, + default: emptyStyle, +}; + +export default styles; diff --git a/extensions/dicom-microscopy/src/utils/toArray.js b/extensions/dicom-microscopy/src/utils/toArray.js new file mode 100644 index 000000000..2800085fa --- /dev/null +++ b/extensions/dicom-microscopy/src/utils/toArray.js @@ -0,0 +1,3 @@ +export default function toArray(item) { + return Array.isArray(item) ? item : [item]; +} diff --git a/extensions/dicom-microscopy/tsconfig.json b/extensions/dicom-microscopy/tsconfig.json new file mode 100644 index 000000000..e8977ec34 --- /dev/null +++ b/extensions/dicom-microscopy/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "outDir": "./dist/", + "noImplicitAny": true, + "module": "es6", + "target": "es5", + "jsx": "react", + "allowJs": true, + "moduleResolution": "node" + } +} diff --git a/extensions/dicom-pdf/package.json b/extensions/dicom-pdf/package.json index 85684879c..1de3bce06 100644 --- a/extensions/dicom-pdf/package.json +++ b/extensions/dicom-pdf/package.json @@ -30,7 +30,7 @@ "peerDependencies": { "@ohif/core": "^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", diff --git a/extensions/dicom-pdf/src/getSopClassHandlerModule.js b/extensions/dicom-pdf/src/getSopClassHandlerModule.js index 949edfd42..99a9bae80 100644 --- a/extensions/dicom-pdf/src/getSopClassHandlerModule.js +++ b/extensions/dicom-pdf/src/getSopClassHandlerModule.js @@ -50,7 +50,7 @@ const _getDisplaySetsFromSeries = ( referencedImages: null, measurements: null, pdfUrl, - others: [instance], + instances: [instance], thumbnailSrc: dataSource.retrieve.directURL({ instance, defaultPath: '/thumbnail', diff --git a/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx b/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx index 99488a21f..9da1904c1 100644 --- a/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx +++ b/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx @@ -14,15 +14,14 @@ function OHIFCornerstonePdfViewport({ displaySets }) { useEffect(() => { const load = async () => { - await pdfUrl; - setUrl(pdfUrl); + setUrl(await pdfUrl); }; load(); }, [pdfUrl]); return ( -
+
No online PDF viewer installed
diff --git a/extensions/dicom-video/package.json b/extensions/dicom-video/package.json index 83f4e060b..6447fa568 100644 --- a/extensions/dicom-video/package.json +++ b/extensions/dicom-video/package.json @@ -30,7 +30,7 @@ "peerDependencies": { "@ohif/core": "^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", diff --git a/extensions/dicom-video/src/getSopClassHandlerModule.js b/extensions/dicom-video/src/getSopClassHandlerModule.js index 31aebed9b..f7d28e7bf 100644 --- a/extensions/dicom-video/src/getSopClassHandlerModule.js +++ b/extensions/dicom-video/src/getSopClassHandlerModule.js @@ -12,6 +12,10 @@ const SOP_CLASS_UIDS = { }; const sopClassUids = Object.values(SOP_CLASS_UIDS); +const secondaryCaptureSopClassUids = [ + SOP_CLASS_UIDS.SECONDARY_CAPTURE_IMAGE_STORAGE, + SOP_CLASS_UIDS.MULTIFRAME_TRUE_COLOR_SECONDARY_CAPTURE_IMAGE_STORAGE, +]; const SupportedTransferSyntaxes = { MPEG4_AVC_264_HIGH_PROFILE: '1.2.840.10008.1.2.4.102', @@ -37,7 +41,23 @@ const _getDisplaySetsFromSeries = ( metadata.AvailableTransferSyntaxUID || metadata.TransferSyntaxUID || metadata['00083002']; - return supportedTransferSyntaxUIDs.includes(tsuid); + + if (supportedTransferSyntaxUIDs.includes(tsuid)) { + return true; + } + + if ( + metadata.SOPClassUID === SOP_CLASS_UIDS.VIDEO_PHOTOGRAPHIC_IMAGE_STORAGE + ) { + return true; + } + + // Assume that an instance with one of the secondary capture SOPClassUIDs and + // with at least 90 frames (i.e. typically 3 seconds of video) is indeed a video. + return ( + secondaryCaptureSopClassUids.includes(metadata.SOPClassUID) && + metadata.NumberOfFrames >= 90 + ); }) .map(instance => { const { @@ -70,7 +90,7 @@ const _getDisplaySetsFromSeries = ( singlepart: 'video', tag: 'PixelData', }), - others: [instance], + instances: [instance], thumbnailSrc: dataSource.retrieve.directURL({ instance, defaultPath: '/thumbnail', diff --git a/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx b/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx index 56545b2a0..c6aa80b2f 100644 --- a/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx +++ b/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx @@ -14,8 +14,7 @@ function OHIFCornerstoneVideoViewport({ displaySets }) { useEffect(() => { const load = async () => { - await videoUrl; - setUrl(videoUrl); + setUrl(await videoUrl); }; load(); diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 332d84253..8b71f466e 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,10 +32,10 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.3.2", - "@cornerstonejs/core": "^0.40.0", - "@cornerstonejs/tools": "^0.60.1", + "@cornerstonejs/core": "^0.47.1", + "@cornerstonejs/tools": "^0.67.4", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", - "dcmjs": "^0.29.4", + "dcmjs": "^0.29.5", "lodash.debounce": "^4.17.21", "prop-types": "^15.6.2", "react": "^17.0.2", diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx index 476e80a40..ccb95cb2e 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx @@ -31,12 +31,11 @@ function TrackedMeasurementsContextProvider( ) { const [viewportGrid, viewportGridService] = useViewportGrid(); const { activeViewportIndex, viewports } = viewportGrid; + const { measurementService, displaySetService } = servicesManager.services; const machineOptions = Object.assign({}, defaultOptions); machineOptions.actions = Object.assign({}, machineOptions.actions, { jumpToFirstMeasurementInActiveViewport: (ctx, evt) => { - const { measurementService } = servicesManager.services; - const { trackedStudy, trackedSeries } = ctx; const measurements = measurementService.getMeasurements(); const trackedMeasurements = measurements.filter( @@ -45,12 +44,51 @@ function TrackedMeasurementsContextProvider( trackedSeries.includes(m.referenceSeriesUID) ); - const uid = trackedMeasurements[0].uid; - - measurementService.jumpToMeasurement( + console.log( + 'jumping to measurement reset viewport', viewportGrid.activeViewportIndex, - uid + trackedMeasurements[0] ); + + const referencedDisplaySetUID = + trackedMeasurements[0].displaySetInstanceUID; + const referencedDisplaySet = displaySetService.getDisplaySetByUID( + referencedDisplaySetUID + ); + + const referencedImages = referencedDisplaySet.images; + const isVolumeIdReferenced = referencedImages[0].imageId.startsWith( + 'volumeId' + ); + + const measurementData = trackedMeasurements[0].data; + + let imageIndex = 0; + if (!isVolumeIdReferenced && measurementData) { + // if it is imageId referenced find the index of the imageId, we don't have + // support for volumeId referenced images yet + imageIndex = referencedImages.findIndex(image => { + const imageIdToUse = Object.keys(measurementData)[0].substring(8); + return image.imageId === imageIdToUse; + }); + + if (imageIndex === -1) { + console.warn( + 'Could not find image index for tracked measurement, using 0' + ); + imageIndex = 0; + } + } + + viewportGridService.setDisplaySetsForViewport({ + viewportIndex: viewportGrid.activeViewportIndex, + displaySetInstanceUIDs: [referencedDisplaySetUID], + viewportOptions: { + initialImageOptions: { + index: imageIndex, + }, + }, + }); }, showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => { if (evt.data.createdDisplaySetInstanceUIDs.length > 0) { @@ -64,7 +102,6 @@ function TrackedMeasurementsContextProvider( } }, discardPreviouslyTrackedMeasurements: (ctx, evt) => { - const { measurementService } = servicesManager.services; const measurements = measurementService.getMeasurements(); const filteredMeasurements = measurements.filter(ms => ctx.prevTrackedSeries.includes(ms.referenceSeriesUID) @@ -76,7 +113,6 @@ function TrackedMeasurementsContextProvider( } }, clearAllMeasurements: (ctx, evt) => { - const { measurementService } = servicesManager.services; const measurements = measurementService.getMeasurements(); const measurementIds = measurements.map(fm => fm.uid); @@ -149,6 +185,10 @@ function TrackedMeasurementsContextProvider( activeViewport.displaySetInstanceUIDs[0] ); + if (!displaySet) { + return; + } + // If this is an SR produced by our SR SOPClassHandler, // and it hasn't been loaded yet, do that now so we // can check if it can be rehydrated or not. diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index ec92ce6a3..9cc9ab2a7 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -18,7 +18,7 @@ const { formatDate } = utils; function PanelStudyBrowserTracking({ servicesManager, getImageSrc, - getStudiesForPatientByStudyInstanceUID, + getStudiesForPatientByMRN, requestDisplaySetCreationForStudy, dataSource, }) { @@ -76,8 +76,6 @@ function PanelStudyBrowserTracking({ const activeViewportDisplaySetInstanceUIDs = viewports[activeViewportIndex]?.displaySetInstanceUIDs; - const isSingleViewport = numCols === 1 && numRows === 1; - useEffect(() => { const added = measurementService.EVENTS.MEASUREMENT_ADDED; const addedRaw = measurementService.EVENTS.RAW_MEASUREMENT_ADDED; @@ -108,15 +106,29 @@ function PanelStudyBrowserTracking({ }; }, [measurementService, activeViewportIndex, sendTrackedMeasurementsEvent]); - const { trackedStudy, trackedSeries } = trackedMeasurements.context; + const { trackedSeries } = trackedMeasurements.context; // ~~ studyDisplayList useEffect(() => { // Fetch all studies for the patient in each primary study async function fetchStudiesForPatient(StudyInstanceUID) { - const qidoStudiesForPatient = - (await getStudiesForPatientByStudyInstanceUID(StudyInstanceUID)) || []; - // TODO: This should be "naturalized DICOM JSON" studies + // current study qido + const qidoForStudyUID = await dataSource.query.studies.search({ + studyInstanceUid: StudyInstanceUID, + }); + + let qidoStudiesForPatient = qidoForStudyUID; + + // try to fetch the prior studies based on the patientID if the + // server can respond. + try { + qidoStudiesForPatient = await getStudiesForPatientByMRN( + qidoForStudyUID + ); + } catch (error) { + console.warn(error); + } + const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient); const actuallyMappedStudies = mappedStudies.map(qidoStudy => { return { @@ -125,7 +137,6 @@ function PanelStudyBrowserTracking({ description: qidoStudy.StudyDescription, modalities: qidoStudy.ModalitiesInStudy, numInstances: qidoStudy.NumInstances, - // displaySets: [] }; }); @@ -146,11 +157,16 @@ function PanelStudyBrowserTracking({ StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [StudyInstanceUIDs, getStudiesForPatientByStudyInstanceUID]); + }, [StudyInstanceUIDs, getStudiesForPatientByMRN]); // ~~ Initial Thumbnails useEffect(() => { const currentDisplaySets = displaySetService.activeDisplaySets; + + if (!currentDisplaySets.length) { + return; + } + currentDisplaySets.forEach(async dSet => { const newImageSrcEntry = {}; const displaySet = displaySetService.getDisplaySetByUID( @@ -170,13 +186,16 @@ function PanelStudyBrowserTracking({ }); } }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [displaySetService, dataSource, getImageSrc]); // ~~ displaySets useEffect(() => { - // TODO: Are we sure `activeDisplaySets` will always be accurate? const currentDisplaySets = displaySetService.activeDisplaySets; + + if (!currentDisplaySets.length) { + return; + } + const mappedDisplaySets = _mapDisplaySets( currentDisplaySets, thumbnailImageSrcMap, @@ -194,9 +213,9 @@ function PanelStudyBrowserTracking({ }, [ displaySetService.activeDisplaySets, trackedSeries, - thumbnailImageSrcMap, viewports, dataSource, + thumbnailImageSrcMap, ]); // ~~ subscriptions --> displaySets @@ -379,7 +398,7 @@ PanelStudyBrowserTracking.propTypes = { getImageIdsForDisplaySet: PropTypes.func.isRequired, }).isRequired, getImageSrc: PropTypes.func.isRequired, - getStudiesForPatientByStudyInstanceUID: PropTypes.func.isRequired, + getStudiesForPatientByMRN: PropTypes.func.isRequired, requestDisplaySetCreationForStudy: PropTypes.func.isRequired, }; @@ -420,124 +439,130 @@ function _mapDisplaySets( ) { const thumbnailDisplaySets = []; const thumbnailNoImageDisplaySets = []; - displaySets.forEach(ds => { - const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; - const componentType = _getComponentType(ds.Modality); - const numPanes = viewportGridService.getNumViewportPanes(); - const viewportIdentificator = - numPanes === 1 - ? [] - : viewports.reduce((acc, viewportData, index) => { - if ( - index < numPanes && - viewportData?.displaySetInstanceUIDs?.includes( - ds.displaySetInstanceUID - ) - ) { - acc.push(viewportData.viewportLabel); - } - return acc; - }, []); + displaySets + .filter(ds => !ds.excludeFromThumbnailBrowser) + .forEach(ds => { + const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; + const componentType = _getComponentType(ds.Modality); + const numPanes = viewportGridService.getNumViewportPanes(); + const viewportIdentificator = + numPanes === 1 + ? [] + : viewports.reduce((acc, viewportData, index) => { + if ( + index < numPanes && + viewportData?.displaySetInstanceUIDs?.includes( + ds.displaySetInstanceUID + ) + ) { + acc.push(viewportData.viewportLabel); + } + return acc; + }, []); - const array = - componentType === 'thumbnailTracked' - ? thumbnailDisplaySets - : thumbnailNoImageDisplaySets; + const array = + componentType === 'thumbnailTracked' + ? thumbnailDisplaySets + : thumbnailNoImageDisplaySets; - const { displaySetInstanceUID } = ds; + const { displaySetInstanceUID } = ds; - const thumbnailProps = { - displaySetInstanceUID, - description: ds.SeriesDescription, - seriesNumber: ds.SeriesNumber, - modality: ds.Modality, - seriesDate: formatDate(ds.SeriesDate), - numInstances: ds.numImageFrames, - countIcon: ds.countIcon, - StudyInstanceUID: ds.StudyInstanceUID, - componentType, - imageSrc, - dragData: { - type: 'displayset', + const thumbnailProps = { displaySetInstanceUID, - // .. Any other data to pass - }, - isTracked: trackedSeriesInstanceUIDs.includes(ds.SeriesInstanceUID), - viewportIdentificator, - }; + description: ds.SeriesDescription, + seriesNumber: ds.SeriesNumber, + modality: ds.Modality, + seriesDate: formatDate(ds.SeriesDate), + numInstances: ds.numImageFrames, + countIcon: ds.countIcon, + StudyInstanceUID: ds.StudyInstanceUID, + componentType, + imageSrc, + dragData: { + type: 'displayset', + displaySetInstanceUID, + // .. Any other data to pass + }, + isTracked: trackedSeriesInstanceUIDs.includes(ds.SeriesInstanceUID), + viewportIdentificator, + }; - if (componentType === 'thumbnailNoImage') { - if (dataSource.reject && dataSource.reject.series) { - thumbnailProps.canReject = true; - thumbnailProps.onReject = () => { - uiDialogService.create({ - id: 'ds-reject-sr', - centralize: true, - isDraggable: false, - showOverlay: true, - content: Dialog, - contentProps: { - title: 'Delete Report', - body: () => ( -
-

Are you sure you want to delete this report?

-

This action cannot be undone.

-
- ), - actions: [ - { id: 'cancel', text: 'Cancel', type: 'secondary' }, - { - id: 'yes', - text: 'Yes', - type: 'primary', - classes: ['reject-yes-button'], + if (componentType === 'thumbnailNoImage') { + if (dataSource.reject && dataSource.reject.series) { + thumbnailProps.canReject = true; + thumbnailProps.onReject = () => { + uiDialogService.create({ + id: 'ds-reject-sr', + centralize: true, + isDraggable: false, + showOverlay: true, + content: Dialog, + contentProps: { + title: 'Delete Report', + body: () => ( +
+

Are you sure you want to delete this report?

+

This action cannot be undone.

+
+ ), + actions: [ + { id: 'cancel', text: 'Cancel', type: 'secondary' }, + { + id: 'yes', + text: 'Yes', + type: 'primary', + classes: ['reject-yes-button'], + }, + ], + onClose: () => uiDialogService.dismiss({ id: 'ds-reject-sr' }), + onShow: () => { + const yesButton = document.querySelector( + '.reject-yes-button' + ); + + yesButton.focus(); }, - ], - onClose: () => uiDialogService.dismiss({ id: 'ds-reject-sr' }), - onShow: () => { - const yesButton = document.querySelector('.reject-yes-button'); - - yesButton.focus(); - }, - onSubmit: async ({ action }) => { - switch (action.id) { - case 'yes': - try { - await dataSource.reject.series( - ds.StudyInstanceUID, - ds.SeriesInstanceUID - ); - displaySetService.deleteDisplaySet(displaySetInstanceUID); + onSubmit: async ({ action }) => { + switch (action.id) { + case 'yes': + try { + await dataSource.reject.series( + ds.StudyInstanceUID, + ds.SeriesInstanceUID + ); + displaySetService.deleteDisplaySet( + displaySetInstanceUID + ); + uiDialogService.dismiss({ id: 'ds-reject-sr' }); + uiNotificationService.show({ + title: 'Delete Report', + message: 'Report deleted successfully', + type: 'success', + }); + } catch (error) { + uiDialogService.dismiss({ id: 'ds-reject-sr' }); + uiNotificationService.show({ + title: 'Delete Report', + message: 'Failed to delete report', + type: 'error', + }); + } + break; + case 'cancel': uiDialogService.dismiss({ id: 'ds-reject-sr' }); - uiNotificationService.show({ - title: 'Delete Report', - message: 'Report deleted successfully', - type: 'success', - }); - } catch (error) { - uiDialogService.dismiss({ id: 'ds-reject-sr' }); - uiNotificationService.show({ - title: 'Delete Report', - message: 'Failed to delete report', - type: 'error', - }); - } - break; - case 'cancel': - uiDialogService.dismiss({ id: 'ds-reject-sr' }); - break; - } + break; + } + }, }, - }, - }); - }; - } else { - thumbnailProps.canReject = false; + }); + }; + } else { + thumbnailProps.canReject = false; + } } - } - array.push(thumbnailProps); - }); + array.push(thumbnailProps); + }); return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets]; } @@ -545,9 +570,12 @@ function _mapDisplaySets( const thumbnailNoImageModalities = [ 'SR', 'SEG', + 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', + 'DOC', + 'OT', ]; function _getComponentType(Modality) { diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js index af0e8a1c9..60a31499b 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js @@ -6,7 +6,7 @@ function getImageSrcFromImageId(cornerstone, imageId) { return new Promise((resolve, reject) => { const canvas = document.createElement('canvas'); cornerstone.utilities - .loadImageToCanvas(canvas, imageId) + .loadImageToCanvas({ canvas, imageId }) .then(imageId => { resolve(canvas.toDataURL()); }) diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx index f84f4aa4c..604d5aa15 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx @@ -10,8 +10,8 @@ function _getStudyForPatientUtility(extensionManager) { '@ohif/extension-default.utilityModule.common' ); - const { getStudiesForPatientByStudyInstanceUID } = utilityModule.exports; - return getStudiesForPatientByStudyInstanceUID; + const { getStudiesForPatientByMRN } = utilityModule.exports; + return getStudiesForPatientByMRN; } /** @@ -28,10 +28,10 @@ function WrappedPanelStudyBrowserTracking({ }) { const dataSource = extensionManager.getActiveDataSource()[0]; - const getStudiesForPatientByStudyInstanceUID = _getStudyForPatientUtility( + const getStudiesForPatientByMRN = _getStudyForPatientUtility( extensionManager ); - const _getStudiesForPatientByStudyInstanceUID = getStudiesForPatientByStudyInstanceUID.bind( + const _getStudiesForPatientByMRN = getStudiesForPatientByMRN.bind( null, dataSource ); @@ -48,9 +48,7 @@ function WrappedPanelStudyBrowserTracking({ servicesManager={servicesManager} dataSource={dataSource} getImageSrc={_getImageSrcFromImageId} - getStudiesForPatientByStudyInstanceUID={ - _getStudiesForPatientByStudyInstanceUID - } + getStudiesForPatientByMRN={_getStudiesForPatientByMRN} requestDisplaySetCreationForStudy={_requestDisplaySetCreationForStudy} /> ); diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index 5e5393f53..418d4aa97 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -2,13 +2,7 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import OHIF, { utils } from '@ohif/core'; -import { - Notification, - ViewportActionBar, - useViewportDialog, - Tooltip, - Icon, -} from '@ohif/ui'; +import { ViewportActionBar, Tooltip, Icon } from '@ohif/ui'; import { useTranslation } from 'react-i18next'; @@ -19,13 +13,11 @@ const { formatDate } = utils; function TrackedCornerstoneViewport(props) { const { - children, displaySets, viewportIndex, viewportLabel, servicesManager, extensionManager, - commandsManager, viewportOptions, } = props; @@ -40,7 +32,6 @@ function TrackedCornerstoneViewport(props) { const displaySet = displaySets[0]; const [trackedMeasurements] = useTrackedMeasurements(); - const [viewportDialogState] = useViewportDialog(); const [isTracked, setIsTracked] = useState(false); const [trackedMeasurementUID, setTrackedMeasurementUID] = useState(null); @@ -48,7 +39,6 @@ function TrackedCornerstoneViewport(props) { const viewportId = viewportOptions.viewportId; const { - Modality, SeriesDate, SeriesDescription, SeriesInstanceUID, @@ -93,8 +83,17 @@ function TrackedCornerstoneViewport(props) { }; }, [isTracked]); - if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { - setIsTracked(!isTracked); + // A current image id will only exist for viewports that can have measurements tracked. + // Typically these are stack viewports and those volume viewports for the series of acquisition. + const currentImageId = cornerstoneViewportService + .getCornerstoneViewport(viewportId) + ?.getCurrentImageId(); + if (currentImageId) { + if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { + setIsTracked(!isTracked); + } + } else if (isTracked) { + setIsTracked(false); } function switchMeasurement(direction) { @@ -159,18 +158,6 @@ function TrackedCornerstoneViewport(props) { {/* TODO: Viewport interface to accept stack or layers of content like this? */}
{getCornerstoneViewport()} -
- {viewportDialogState.viewportIndex === viewportIndex && ( - - )} -
); diff --git a/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts b/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts index ebdd32eef..1a0a22069 100644 --- a/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts +++ b/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts @@ -38,12 +38,12 @@ const findingsContextMenu = { }, // The example below shows how to include a delegating sub-menu, - // Only available on the @ohif/hp-extension.mn hanging protocol + // Only available on the @ohif/mnGrid hanging protocol // To demonstrate, select the 3x1 layout from the protocol menu // and right click on a measurement. { label: 'IncludeSubMenu', - selector: ({ protocol }) => protocol?.id === '@ohif/hp-extension.mn', + selector: ({ protocol }) => protocol?.id === '@ohif/mnGrid', delegating: true, subMenu: 'orientationSelectionSubMenu', }, diff --git a/extensions/test-extension/src/hp/index.ts b/extensions/test-extension/src/hp/index.ts deleted file mode 100644 index a1b0c22c2..000000000 --- a/extensions/test-extension/src/hp/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import hpMN from './hpMN'; - -const hangingProtocols = [ - { - name: '@ohif/hp-extension.mn', - protocol: hpMN, - }, -]; - -/** - * Registers a single study hanging protocol which can be referenced as - * `@ohif/hp-exgtension.mn`, that has initial layouts which show images - * only display sets, up to a 2x2 view. - */ -export default function getHangingProtocolModule() { - return hangingProtocols; -} diff --git a/extensions/test-extension/src/index.tsx b/extensions/test-extension/src/index.tsx index 589d87ca5..16fe5dd2b 100644 --- a/extensions/test-extension/src/index.tsx +++ b/extensions/test-extension/src/index.tsx @@ -2,14 +2,11 @@ import { Types } from '@ohif/core'; import { id } from './id'; -import getHangingProtocolModule from './hp'; import getCustomizationModule from './getCustomizationModule'; // import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan'; import sameAs from './custom-attribute/sameAs'; import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets'; -import numberOfDisplaySetsWithImages from './custom-attribute/numberOfDisplaySetsWithImages'; import maxNumImageFrames from './custom-attribute/maxNumImageFrames'; -import seriesDescriptionsFromDisplaySets from './custom-attribute/seriesDescriptionsFromDisplaySets'; /** * The test extension provides additional behaviour for testing various @@ -31,21 +28,11 @@ const testExtension: Types.Extensions.Extension = { */ preRegistration: ({ servicesManager }: Types.Extensions.ExtensionParams) => { const { hangingProtocolService } = servicesManager.services; - hangingProtocolService.addCustomAttribute( - 'seriesDescriptions', - 'Series Descriptions', - seriesDescriptionsFromDisplaySets - ); hangingProtocolService.addCustomAttribute( 'numberOfDisplaySets', 'Number of displays sets', numberOfDisplaySets ); - hangingProtocolService.addCustomAttribute( - 'numberOfDisplaySetsWithImages', - 'Number of displays sets with images', - numberOfDisplaySetsWithImages - ); hangingProtocolService.addCustomAttribute( 'maxNumImageFrames', 'Maximum of number of image frames', @@ -58,9 +45,6 @@ const testExtension: Types.Extensions.Extension = { ); }, - /** Registers some additional hanging protocols. See hp/index.tsx for more details */ - getHangingProtocolModule, - /** Registers some customizations */ getCustomizationModule, }; diff --git a/extensions/tmtv/package.json b/extensions/tmtv/package.json index 9210d7cfa..89d25ba3b 100644 --- a/extensions/tmtv/package.json +++ b/extensions/tmtv/package.json @@ -30,7 +30,7 @@ "peerDependencies": { "@ohif/core": "^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", diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx index cd55a3cb0..1a2b706f8 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx @@ -72,7 +72,9 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) { onChange={e => { dispatch({ type: 'setWeight', - payload: e.target.value, + payload: { + weight: e.target.value, + }, }); }} /> diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/segmentationEditHandler.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/segmentationEditHandler.tsx index 433a9a704..bfa4d0177 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/segmentationEditHandler.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/segmentationEditHandler.tsx @@ -14,7 +14,8 @@ function segmentationItemEditHandler({ id, servicesManager }) { ...segmentation, ...value, }, - true + false, // don't suppress event + true // it should update cornerstone ); } } diff --git a/extensions/tmtv/src/init.js b/extensions/tmtv/src/init.js index 3c694cf37..221fac514 100644 --- a/extensions/tmtv/src/init.js +++ b/extensions/tmtv/src/init.js @@ -2,10 +2,13 @@ import { addTool, RectangleROIStartEndThresholdTool, } from '@cornerstonejs/tools'; +import { utilities } from '@cornerstonejs/core'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; import colormaps from './utils/colormaps'; +const { registerColormap } = utilities.colormap; + const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools'; const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1'; /** @@ -42,15 +45,5 @@ export default function init({ servicesManager, extensionManager }) { RectangleROIStartEndThreshold.toMeasurement ); - initColormaps(extensionManager); -} - -function initColormaps(extensionManager) { - const utilityModule = extensionManager.getModuleEntry( - '@ohif/extension-cornerstone.utilityModule.common' - ); - - const { registerColormap } = utilityModule.exports; - colormaps.forEach(registerColormap); } diff --git a/extensions/tmtv/src/utils/getStudiesForPatientByStudyInstanceUID.js b/extensions/tmtv/src/utils/getStudiesForPatientByStudyInstanceUID.js deleted file mode 100644 index fb5baa539..000000000 --- a/extensions/tmtv/src/utils/getStudiesForPatientByStudyInstanceUID.js +++ /dev/null @@ -1,29 +0,0 @@ -async function getStudiesForPatientByStudyInstanceUID( - dataSource, - StudyInstanceUID -) { - if (StudyInstanceUID === undefined) { - return; - } - // TODO: The `DicomMetadataStore` should short-circuit both of these requests - // Data _could_ be here from route query, or if using JSON data source - // We could also force this to "await" these values being available in the DICOMStore? - // Kind of like promise fulfillment in cornerstone-wado-image-loader when there are multiple - // outgoing requests for the same data - const getStudyResult = await dataSource.query.studies.search({ - studyInstanceUid: StudyInstanceUID, - }); - - // TODO: To Erik's point, the data source likely shouldn't deviate from - // Naturalized DICOM JSON when returning. It makes things like this awkward (mrn) - if (getStudyResult && getStudyResult.length && getStudyResult[0].mrn) { - return dataSource.query.studies.search({ - patientId: getStudyResult[0].mrn, - }); - } - console.log('No mrn found for', getStudyResult); - // The original study we KNOW belongs to the same set, so just return it - return getStudyResult; -} - -export default getStudiesForPatientByStudyInstanceUID; diff --git a/extensions/tmtv/src/utils/hpViewports.ts b/extensions/tmtv/src/utils/hpViewports.ts index 0efc354ec..af2b760d4 100644 --- a/extensions/tmtv/src/utils/hpViewports.ts +++ b/extensions/tmtv/src/utils/hpViewports.ts @@ -121,8 +121,7 @@ const ptAXIAL = { { options: { voi: { - windowWidth: 5, - windowCenter: 2.5, + custom: 'getPTVOIRange', }, voiInverted: true, }, @@ -163,8 +162,7 @@ const ptSAGITTAL = { { options: { voi: { - windowWidth: 5, - windowCenter: 2.5, + custom: 'getPTVOIRange', }, voiInverted: true, }, @@ -205,8 +203,7 @@ const ptCORONAL = { { options: { voi: { - windowWidth: 5, - windowCenter: 2.5, + custom: 'getPTVOIRange', }, voiInverted: true, }, @@ -258,10 +255,12 @@ const fusionAXIAL = { }, { options: { - colormap: 'hsv', + colormap: { + name: 'hsv', + opacityMapping: [{ value: 0.1, opacity: 0.9 }], + }, voi: { - windowWidth: 5, - windowCenter: 2.5, + custom: 'getPTVOIRange', }, }, id: 'ptDisplaySet', @@ -312,10 +311,12 @@ const fusionSAGITTAL = { }, { options: { - colormap: 'hsv', + colormap: { + name: 'hsv', + opacityMapping: [{ value: 0.1, opacity: 0.9 }], + }, voi: { - windowWidth: 5, - windowCenter: 2.5, + custom: 'getPTVOIRange', }, }, id: 'ptDisplaySet', @@ -366,10 +367,12 @@ const fusionCORONAL = { }, { options: { - colormap: 'hsv', + colormap: { + name: 'hsv', + opacityMapping: [{ value: 0.1, opacity: 0.9 }], + }, voi: { - windowWidth: 5, - windowCenter: 2.5, + custom: 'getPTVOIRange', }, }, id: 'ptDisplaySet', @@ -414,8 +417,7 @@ const mipSAGITTAL = { blendMode: 'MIP', slabThickness: 'fullVolume', voi: { - windowWidth: 5, - windowCenter: 2.5, + custom: 'getPTVOIRange', }, voiInverted: true, }, diff --git a/jest.config.base.js b/jest.config.base.js index afb3bdd26..dbd563fec 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -5,9 +5,10 @@ module.exports = { verbose: true, - roots: ['/src'], + // roots: ['/src'], testMatch: ['/src/**/*.test.js'], testPathIgnorePatterns: ['/node_modules/'], + testEnvironment: 'jsdom', moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], moduleNameMapper: { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': diff --git a/modes/basic-dev-mode/src/index.js b/modes/basic-dev-mode/src/index.js index d74ee60a4..0f769854e 100644 --- a/modes/basic-dev-mode/src/index.js +++ b/modes/basic-dev-mode/src/index.js @@ -1,5 +1,5 @@ import toolbarButtons from './toolbarButtons.js'; -import { hotkeys, ServicesManager } from '@ohif/core'; +import { hotkeys } from '@ohif/core'; import { id } from './id'; const configs = { @@ -10,7 +10,6 @@ const configs = { const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', - hangingProtocol: '@ohif/extension-default.hangingProtocolModule.default', measurements: '@ohif/extension-default.panelModule.measure', thumbnailList: '@ohif/extension-default.panelModule.seriesList', }; @@ -47,8 +46,8 @@ const extensionDependencies = { function modeFactory({ modeConfiguration }) { return { id, - routeName: 'viewer', - displayName: 'Basic Viewer CS3D', + routeName: 'dev', + displayName: 'Basic Dev Viewer', /** * Lifecycle hooks */ @@ -81,6 +80,7 @@ function modeFactory({ modeConfiguration }) { { toolName: toolNames.Bidirectional }, { toolName: toolNames.Probe }, { toolName: toolNames.EllipticalROI }, + { toolName: toolNames.CircleROI }, { toolName: toolNames.RectangleROI }, { toolName: toolNames.StackScroll }, { toolName: toolNames.CalibrationLine }, @@ -185,7 +185,7 @@ function modeFactory({ modeConfiguration }) { }, ], extensions: extensionDependencies, - hangingProtocol: [ohif.hangingProtocol], + hangingProtocol: 'default', sopClassHandlers: [ dicomvideo.sopClassHandler, ohif.sopClassHandler, diff --git a/modes/basic-dev-mode/src/toolbarButtons.js b/modes/basic-dev-mode/src/toolbarButtons.js index 4a08edbf0..995e4dea6 100644 --- a/modes/basic-dev-mode/src/toolbarButtons.js +++ b/modes/basic-dev-mode/src/toolbarButtons.js @@ -130,6 +130,21 @@ const toolbarButtons = [ ], 'Ellipse Tool' ), + _createToolButton( + 'CircleROI', + 'tool-circle', + 'Circle', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'CircleROI', + }, + context: 'CORNERSTONE', + }, + ], + 'Circle Tool' + ), ], }, }, diff --git a/modes/basic-test-mode/src/index.js b/modes/basic-test-mode/src/index.js index 11e06ec24..d465b7e09 100644 --- a/modes/basic-test-mode/src/index.js +++ b/modes/basic-test-mode/src/index.js @@ -133,7 +133,6 @@ function modeFactory() { const { toolGroupService, syncGroupService, - toolbarService, segmentationService, cornerstoneViewportService, } = servicesManager.services; diff --git a/modes/basic-test-mode/src/initToolGroups.js b/modes/basic-test-mode/src/initToolGroups.js index 105b1a2f8..ccc5c1a6d 100644 --- a/modes/basic-test-mode/src/initToolGroups.js +++ b/modes/basic-test-mode/src/initToolGroups.js @@ -32,6 +32,7 @@ function initDefaultToolGroup( { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, + { toolName: toolNames.CircleROI }, { toolName: toolNames.RectangleROI }, { toolName: toolNames.StackScroll }, { toolName: toolNames.Angle }, @@ -110,6 +111,7 @@ function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: SRToolNames.SRArrowAnnotate }, { toolName: SRToolNames.SRBidirectional }, { toolName: SRToolNames.SREllipticalROI }, + { toolName: SRToolNames.SRCircleROI }, ], enabled: [ { @@ -170,6 +172,7 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, + { toolName: toolNames.CircleROI }, { toolName: toolNames.RectangleROI }, { toolName: toolNames.StackScroll }, { toolName: toolNames.Angle }, diff --git a/modes/basic-test-mode/src/toolbarButtons.js b/modes/basic-test-mode/src/toolbarButtons.js index 9a04581c5..506e6b652 100644 --- a/modes/basic-test-mode/src/toolbarButtons.js +++ b/modes/basic-test-mode/src/toolbarButtons.js @@ -200,6 +200,29 @@ const toolbarButtons = [ ], 'Ellipse Tool' ), + _createToolButton( + 'CircleROI', + 'tool-circle', + 'Circle', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'CircleROI', + }, + context: 'CORNERSTONE', + }, + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'SRCircleROI', + toolGroupId: 'SRToolGroup', + }, + context: 'CORNERSTONE', + }, + ], + 'Circle Tool' + ), ], }, }, @@ -334,7 +357,7 @@ const toolbarButtons = [ { commandName: 'setHangingProtocol', commandOptions: { - protocolId: '@ohif/hp-extension.mn', + protocolId: '@ohif/mnGrid', stageId: '2x2', }, context: 'DEFAULT', @@ -349,7 +372,7 @@ const toolbarButtons = [ { commandName: 'setHangingProtocol', commandOptions: { - protocolId: '@ohif/hp-extension.mn', + protocolId: '@ohif/mnGrid', stageId: '3x1', }, context: 'DEFAULT', @@ -364,7 +387,7 @@ const toolbarButtons = [ { commandName: 'setHangingProtocol', commandOptions: { - protocolId: '@ohif/hp-extension.mn', + protocolId: '@ohif/mnGrid', stageId: '2x1', }, context: 'DEFAULT', @@ -379,7 +402,7 @@ const toolbarButtons = [ { commandName: 'setHangingProtocol', commandOptions: { - protocolId: '@ohif/hp-extension.mn', + protocolId: '@ohif/mnGrid', stageId: '1x1', }, context: 'DEFAULT', diff --git a/modes/longitudinal/package.json b/modes/longitudinal/package.json index 7a35b2b9a..32cd0ed37 100644 --- a/modes/longitudinal/package.json +++ b/modes/longitudinal/package.json @@ -36,6 +36,8 @@ "@ohif/extension-default": "^3.0.0", "@ohif/extension-cornerstone": "^3.0.0", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", + "@ohif/extension-cornerstone-dicom-seg": "^3.0.0", + "@ohif/extension-cornerstone-dicom-rt": "^3.0.0", "@ohif/extension-dicom-pdf": "^3.0.1", "@ohif/extension-dicom-video": "^3.0.1", "@ohif/extension-measurement-tracking": "^3.0.0" diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 6e2d8a2da..0c3e3d4d4 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -5,7 +5,7 @@ import initToolGroups from './initToolGroups.js'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. -const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG']; +const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG', 'RTSTRUCT']; const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', @@ -45,6 +45,12 @@ const dicomSeg = { panel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation', }; +const dicomRt = { + viewport: '@ohif/extension-cornerstone-dicom-rt.viewportModule.dicom-rt', + sopClassHandler: + '@ohif/extension-cornerstone-dicom-rt.sopClassHandlerModule.dicom-rt', +}; + const extensionDependencies = { // Can derive the versions at least process.env.from npm_package_version '@ohif/extension-default': '^3.0.0', @@ -52,6 +58,7 @@ const extensionDependencies = { '@ohif/extension-measurement-tracking': '^3.0.0', '@ohif/extension-cornerstone-dicom-sr': '^3.0.0', '@ohif/extension-cornerstone-dicom-seg': '^3.0.0', + '@ohif/extension-cornerstone-dicom-rt': '^3.0.0', '@ohif/extension-dicom-pdf': '^3.0.1', '@ohif/extension-dicom-video': '^3.0.1', }; @@ -211,6 +218,10 @@ function modeFactory() { namespace: dicomSeg.viewport, displaySetsToDisplay: [dicomSeg.sopClassHandler], }, + { + namespace: dicomRt.viewport, + displaySetsToDisplay: [dicomRt.sopClassHandler], + }, ], }, }; @@ -230,6 +241,7 @@ function modeFactory() { ohif.sopClassHandler, dicompdf.sopClassHandler, dicomsr.sopClassHandler, + dicomRt.sopClassHandler, ], hotkeys: [...hotkeys.defaults.hotkeyBindings], }; @@ -242,3 +254,4 @@ const mode = { }; export default mode; +export { initToolGroups, toolbarButtons }; diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index 6bbe668d2..b8e88db90 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -32,6 +32,7 @@ function initDefaultToolGroup( { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, + { toolName: toolNames.CircleROI }, { toolName: toolNames.RectangleROI }, { toolName: toolNames.StackScroll }, { toolName: toolNames.Angle }, @@ -113,6 +114,7 @@ function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: SRToolNames.SRArrowAnnotate }, { toolName: SRToolNames.SRBidirectional }, { toolName: SRToolNames.SREllipticalROI }, + { toolName: SRToolNames.SRCircleROI }, ], enabled: [ { @@ -173,6 +175,7 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, + { toolName: toolNames.CircleROI }, { toolName: toolNames.RectangleROI }, { toolName: toolNames.StackScroll }, { toolName: toolNames.Angle }, diff --git a/modes/longitudinal/src/toolbarButtons.js b/modes/longitudinal/src/toolbarButtons.js index a2b72cbf1..92de37e89 100644 --- a/modes/longitudinal/src/toolbarButtons.js +++ b/modes/longitudinal/src/toolbarButtons.js @@ -208,6 +208,29 @@ const toolbarButtons = [ ], 'Ellipse Tool' ), + _createToolButton( + 'CircleROI', + 'tool-circle', + 'Circle', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'CircleROI', + }, + context: 'CORNERSTONE', + }, + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'SRCircleROI', + toolGroupId: 'SRToolGroup', + }, + context: 'CORNERSTONE', + }, + ], + 'Circle Tool' + ), ], }, }, diff --git a/modes/microscopy/.gitignore b/modes/microscopy/.gitignore new file mode 100644 index 000000000..67045665d --- /dev/null +++ b/modes/microscopy/.gitignore @@ -0,0 +1,104 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port diff --git a/modes/microscopy/.prettierrc b/modes/microscopy/.prettierrc new file mode 100644 index 000000000..b80ec6b34 --- /dev/null +++ b/modes/microscopy/.prettierrc @@ -0,0 +1,8 @@ +{ + "trailingComma": "es5", + "printWidth": 80, + "proseWrap": "always", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/modes/microscopy/.webpack/webpack.dev.js b/modes/microscopy/.webpack/webpack.dev.js new file mode 100644 index 000000000..db7c206b1 --- /dev/null +++ b/modes/microscopy/.webpack/webpack.dev.js @@ -0,0 +1,8 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); +}; diff --git a/modes/microscopy/.webpack/webpack.prod.js b/modes/microscopy/.webpack/webpack.prod.js new file mode 100644 index 000000000..163392a69 --- /dev/null +++ b/modes/microscopy/.webpack/webpack.prod.js @@ -0,0 +1,62 @@ +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 mode 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; diff --git a/modes/microscopy/LICENSE b/modes/microscopy/LICENSE new file mode 100644 index 000000000..ccd0edc30 --- /dev/null +++ b/modes/microscopy/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023 microscopy (26860200+md-prog@users.noreply.github.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/modes/microscopy/README.md b/modes/microscopy/README.md new file mode 100644 index 000000000..e4bcca9d9 --- /dev/null +++ b/modes/microscopy/README.md @@ -0,0 +1,13 @@ +# OHIF mode for microscopy +Mode for *DICOM VL Whole Slide Microscopy Image*. +This mode uses [OHIF extension for microscopy](../../extensions/dicom-microscopy/). + + +## Acknowledgements + +- [DICOM Microscopy Viewer](https://github.com/ImagingDataCommons/dicom-microscopy-viewer) is a Vanilla JS library for web-based visualization of DICOM VL Whole Slide Microscopy Image datasets and derived information. +- [SLIM Viewer](https://github.com/imagingdatacommons/slim) is a single-page application for interactive visualization and annotation of digital whole slide microscopy images and derived image analysis results in standard DICOM format. The application is based on the dicom-microscopy-viewer JavaScript library and runs fully client side without any custom server components. + + +## License +MIT diff --git a/modes/microscopy/babel.config.js b/modes/microscopy/babel.config.js new file mode 100644 index 000000000..92fbbdeaf --- /dev/null +++ b/modes/microscopy/babel.config.js @@ -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__'], + }, + }, +}; diff --git a/modes/microscopy/package.json b/modes/microscopy/package.json new file mode 100644 index 000000000..5490a3de5 --- /dev/null +++ b/modes/microscopy/package.json @@ -0,0 +1,65 @@ +{ + "name": "@ohif/mode-microscopy", + "version": "3.0.0", + "description": "OHIF mode for DICOM microscopy", + "author": "Bill Wallace, md-prog", + "license": "MIT", + "main": "dist/umd/@ohif/mode-microscopy/index.umd.js", + "files": [ + "dist/**", + "public/**", + "README.md" + ], + "repository": "OHIF/Viewers", + "keywords": [ + "ohif-mode" + ], + "module": "src/index.tsx", + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.16.0" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo", + "dev:cornerstone": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" + }, + "peerDependencies": { + "@ohif/core": "^3.0.0", + "@ohif/extension-dicom-microscopy": "^3.0.0" + }, + "dependencies": { + "@babel/runtime": "^7.20.13" + }, + "devDependencies": { + "@babel/core": "^7.21.4", + "@babel/plugin-proposal-class-properties": "^7.16.7", + "@babel/plugin-proposal-object-rest-spread": "^7.17.3", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.16.7", + "@babel/plugin-transform-regenerator": "^7.16.7", + "@babel/plugin-transform-runtime": "^7.17.0", + "@babel/plugin-transform-typescript": "^7.13.0", + "@babel/preset-env": "^7.16.11", + "@babel/preset-react": "^7.16.7", + "@babel/preset-typescript": "^7.13.0", + "babel-eslint": "^8.0.3", + "babel-loader": "^8.0.0-beta.4", + "babel-plugin-inline-react-svg": "^2.0.1", + "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" + } +} diff --git a/modes/microscopy/src/id.js b/modes/microscopy/src/id.js new file mode 100644 index 000000000..ebe5acd98 --- /dev/null +++ b/modes/microscopy/src/id.js @@ -0,0 +1,5 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; + +export { id }; diff --git a/modes/microscopy/src/index.tsx b/modes/microscopy/src/index.tsx new file mode 100644 index 000000000..9044ad6eb --- /dev/null +++ b/modes/microscopy/src/index.tsx @@ -0,0 +1,142 @@ +import { hotkeys } from '@ohif/core'; + +import { id } from './id'; +import toolbarButtons from './toolbarButtons'; + +const ohif = { + layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', + sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', + hangingProtocols: '@ohif/extension-default.hangingProtocolModule.default', + leftPanel: '@ohif/extension-default.panelModule.seriesList', + rightPanel: '@ohif/extension-default.panelModule.measure', +}; + +export const cornerstone = { + viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone', +}; + +const dicomvideo = { + sopClassHandler: + '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video', + viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video', +}; + +const dicompdf = { + sopClassHandler: '@ohif/extension-dicom-pdf.sopClassHandlerModule.dicom-pdf', + viewport: '@ohif/extension-dicom-pdf.viewportModule.dicom-pdf', +}; + +const extensionDependencies = { + // Can derive the versions at least process.env.from npm_package_version + '@ohif/extension-default': '^3.0.0', + '@ohif/extension-cornerstone': '^3.0.0', + '@ohif/extension-cornerstone-dicom-sr': '^3.0.0', + '@ohif/extension-dicom-pdf': '^3.0.1', + '@ohif/extension-dicom-video': '^3.0.1', + '@ohif/extension-dicom-microscopy': '^3.0.0', +}; + +function modeFactory() { + return { + // TODO: We're using this as a route segment + // We should not be. + id, + routeName: 'microscopy', + displayName: 'Microscopy', + + /** + * Lifecycle hooks + */ + onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { + const { toolbarService } = servicesManager.services; + + toolbarService.init(extensionManager); + toolbarService.addButtons(toolbarButtons); + toolbarService.createButtonSection('primary', [ + 'MeasurementTools', + 'dragPan', + ]); + }, + + onModeExit: ({ servicesManager }) => { + const { toolbarService } = servicesManager.services; + + toolbarService.reset(); + }, + + validationTags: { + study: [], + series: [], + }, + + isValidMode: ({ modalities }) => { + const modalities_list = modalities.split('\\'); + + // Slide Microscopy and ECG modality not supported by basic mode yet + return modalities_list.includes('SM'); + }, + + routes: [ + { + path: 'microscopy', + /*init: ({ servicesManager, extensionManager }) => { + //defaultViewerRouteInit + },*/ + layoutTemplate: ({ location, servicesManager }) => { + return { + id: ohif.layout, + props: { + leftPanels: [ohif.leftPanel], + leftPanelDefaultClosed: true, // we have problem with rendering thumbnails for microscopy images + rightPanelDefaultClosed: true, // we do not have the save microscopy measurements yet + rightPanels: [ + '@ohif/extension-dicom-microscopy.panelModule.measure', + ], + viewports: [ + { + namespace: + '@ohif/extension-dicom-microscopy.viewportModule.microscopy-dicom', + displaySetsToDisplay: [ + '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler', + '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler', + ], + }, + { + namespace: dicomvideo.viewport, + displaySetsToDisplay: [dicomvideo.sopClassHandler], + }, + { + namespace: dicompdf.viewport, + displaySetsToDisplay: [dicompdf.sopClassHandler], + }, + ], + }, + }; + }, + }, + ], + extensions: extensionDependencies, + hangingProtocols: [ohif.hangingProtocols], + hangingProtocol: ['default'], + + // Order is important in sop class handlers when two handlers both use + // the same sop class under different situations. In that case, the more + // general handler needs to come last. For this case, the dicomvideo must + // come first to remove video transfer syntax before ohif uses images + sopClassHandlers: [ + '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler', + '@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler', + dicomvideo.sopClassHandler, + dicompdf.sopClassHandler, + ], + hotkeys: [...hotkeys.defaults.hotkeyBindings], + }; +} + +const mode = { + id, + modeFactory, + extensionDependencies, +}; + +export default mode; diff --git a/modes/microscopy/src/toolbarButtons.js b/modes/microscopy/src/toolbarButtons.js new file mode 100644 index 000000000..40078c5de --- /dev/null +++ b/modes/microscopy/src/toolbarButtons.js @@ -0,0 +1,184 @@ +// TODO: torn, can either bake this here; or have to create a whole new button type +/** + * + * @param {*} type - 'tool' | 'action' | 'toggle' + * @param {*} id + * @param {*} icon + * @param {*} label + */ +function _createButton(type, id, icon, label, commands, tooltip) { + return { + id, + icon, + label, + type, + commands, + tooltip, + }; +} + +const _createActionButton = _createButton.bind(null, 'action'); +const _createToggleButton = _createButton.bind(null, 'toggle'); +const _createToolButton = _createButton.bind(null, 'tool'); + +const toolbarButtons = [ + // Measurement + { + id: 'MeasurementTools', + type: 'ohif.splitButton', + props: { + groupId: 'MeasurementTools', + isRadio: true, // ? + // Switch? + primary: _createToolButton( + 'line', + 'tool-length', + 'Line', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'line', + }, + context: 'MICROSCOPY', + }, + ], + 'Line' + ), + secondary: { + icon: 'chevron-down', + label: '', + isActive: true, + tooltip: 'More Measure Tools', + }, + items: [ + _createToolButton( + 'line', + 'tool-length', + 'Line', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'line', + }, + context: 'MICROSCOPY', + }, + ], + 'Line Tool' + ), + _createToolButton( + 'point', + 'tool-point', + 'Point', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'point', + }, + context: 'MICROSCOPY', + }, + ], + 'Point Tool' + ), + _createToolButton( + 'polygon', + 'tool-polygon', + 'Polygon', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'polygon', + }, + context: 'MICROSCOPY', + }, + ], + 'Polygon Tool' + ), + _createToolButton( + 'circle', + 'tool-circle', + 'Circle', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'circle', + }, + context: 'MICROSCOPY', + }, + ], + 'Circle Tool' + ), + _createToolButton( + 'box', + 'tool-rectangle', + 'Box', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'box', + }, + context: 'MICROSCOPY', + }, + ], + 'Box Tool' + ), + _createToolButton( + 'freehandpolygon', + 'tool-freehand-polygon', + 'Freehand Polygon', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'freehandpolygon', + }, + context: 'MICROSCOPY', + }, + ], + 'Freehand Polygon Tool' + ), + _createToolButton( + 'freehandline', + 'tool-freehand-line', + 'Freehand Line', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'freehandline', + }, + context: 'MICROSCOPY', + }, + ], + 'Freehand Line Tool' + ), + ], + }, + }, + // Pan... + { + id: 'dragPan', + type: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-move', + label: 'Pan', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'dragPan', + }, + context: 'MICROSCOPY', + }, + ], + }, + }, +]; + +export default toolbarButtons; diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index c0f8659ae..e4cfc5c25 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -1,10 +1,12 @@ -import { hotkeys } from '@ohif/core'; +import { hotkeys, classes } from '@ohif/core'; import toolbarButtons from './toolbarButtons.js'; import { id } from './id.js'; import initToolGroups, { toolGroupIds } from './initToolGroups.js'; import setCrosshairsConfiguration from './utils/setCrosshairsConfiguration.js'; import setFusionActiveVolume from './utils/setFusionActiveVolume.js'; +const { MetadataProvider } = classes; + const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', @@ -131,6 +133,43 @@ function modeFactory({ modeConfiguration }) { 'RectangleROIStartEndThreshold', 'fusionPTColormap', ]); + + // For the hanging protocol we need to decide on the window level + // based on whether the SUV is corrected or not, hence we can't hard + // code the window level in the hanging protocol but we add a custom + // attribute to the hanging protocol that will be used to get the + // window level based on the metadata + hangingProtocolService.addCustomAttribute( + 'getPTVOIRange', + 'get PT VOI based on corrected or not', + props => { + const ptDisplaySet = props.find( + imageSet => imageSet.Modality === 'PT' + ); + + if (!ptDisplaySet) { + return; + } + + const { imageId } = ptDisplaySet.images[0]; + const imageIdScalingFactor = MetadataProvider.get( + 'scalingModule', + imageId + ); + + const isSUVAvailable = + imageIdScalingFactor && imageIdScalingFactor.suvbw; + + if (isSUVAvailable) { + return { + windowWidth: 5, + windowCenter: 2.5, + }; + } + + return; + } + ); }, onModeExit: ({ servicesManager }) => { const { diff --git a/modes/tmtv/src/toolbarButtons.js b/modes/tmtv/src/toolbarButtons.js index 8fd347737..f6f707221 100644 --- a/modes/tmtv/src/toolbarButtons.js +++ b/modes/tmtv/src/toolbarButtons.js @@ -173,6 +173,20 @@ const toolbarButtons = [ ], 'Ellipse Tool' ), + _createToolButton( + 'CircleROI', + 'tool-circle', + 'Circle', + [ + ..._createCommands('setToolActive', 'CircleROI', [ + toolGroupIds.CT, + toolGroupIds.PT, + toolGroupIds.Fusion, + // toolGroupIds.MPR, + ]), + ], + 'Circle Tool' + ), ], }, }, @@ -299,8 +313,7 @@ const toolbarButtons = [ commandName: 'displayNotification', commandOptions: { title: 'RectangleROI Threshold Tip', - text: - 'RectangleROI Threshold tool should be used on PT Axial Viewport', + text: 'RectangleROI Threshold tool should be used on PT Axial Viewport', type: 'info', }, }, diff --git a/nx.json b/nx.json new file mode 100644 index 000000000..043289266 --- /dev/null +++ b/nx.json @@ -0,0 +1,57 @@ +{ + "tasksRunnerOptions": { + "default": { + "runner": "nx/tasks-runners/default", + "options": { + "cacheableOperations": [ + "dev", + "build", + "test:unit", + "test:unit:ci", + "test", + "test:e2e", + "test:e2e:ci", + "test:e2e:local", + "test:e2e:dist", + "test:e2e:serve", + "build:viewer", + "build:dev", + "build:aws", + "build:viewer:ci", + "build:viewer:qa", + "build:viewer:demo", + "build" + ] + } + } + }, + "targetDefaults": { + "test:unit": { + "dependsOn": ["^test:unit"] + }, + "test:unit:ci": { + "dependsOn": ["^test:unit:ci"] + }, + "test": { + "dependsOn": ["^test"] + }, + "test:e2e": { + "dependsOn": ["^test:e2e"] + }, + "test:e2e:ci": { + "dependsOn": ["^test:e2e:ci"] + }, + "test:e2e:local": { + "dependsOn": ["^test:e2e:local"] + }, + "test:e2e:dist": { + "dependsOn": ["^test:e2e:dist"] + }, + "test:e2e:serve": { + "dependsOn": ["^test:e2e:serve"] + }, + "build": { + "outputs": ["{projectRoot}/platform/viewer/dist"] + } + } +} diff --git a/package.json b/package.json index c5287de03..775bea023 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@kitware/vtk.js": "26.5.6", + "@kitware/vtk.js": "27.3.1", "core-js": "^3.2.1" }, "peerDependencies": { @@ -64,7 +64,7 @@ "react-dom": "17.0.2" }, "devDependencies": { - "@babel/core": "^7.17.8", + "@babel/core": "^7.21.4", "@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/plugin-proposal-object-rest-spread": "^7.17.3", "@babel/plugin-proposal-private-methods": "^7.18.6", @@ -82,8 +82,8 @@ "autoprefixer": "^10.4.4", "babel-eslint": "9.x", "babel-loader": "^8.2.4", - "babel-plugin-inline-react-svg": "1.1.0", - "babel-plugin-module-resolver": "^3.2.0", + "babel-plugin-inline-react-svg": "^2.0.2", + "babel-plugin-module-resolver": "^5.0.0", "clean-webpack-plugin": "^3.0.0", "copy-webpack-plugin": "^9.0.1", "cross-env": "^5.2.0", @@ -106,20 +106,21 @@ "extract-css-chunks-webpack-plugin": "^4.5.4", "html-webpack-plugin": "^5.3.2", "husky": "^3.0.0", - "jest": "^24.8.0", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", "jest-canvas-mock": "^2.1.0", "jest-junit": "^6.4.0", - "lerna": "^3.15.0", + "lerna": "^6.6.1", "lint-staged": "^9.0.2", "mini-css-extract-plugin": "^2.1.0", - "optimize-css-assets-webpack-plugin": "^5.0.3", + "optimize-css-assets-webpack-plugin": "^6.0.1", "postcss": "^8.3.5", "postcss-import": "^14.0.2", "postcss-loader": "^6.1.1", "postcss-preset-env": "^7.4.3", "prettier": "^1.18.2", "react-hot-loader": "^4.13.0", - "serve": "^11.1.0", + "serve": "^14.2.0", "shader-loader": "^1.3.1", "start-server-and-test": "^1.10.0", "style-loader": "^1.0.0", @@ -146,6 +147,12 @@ ] }, "resolutions": { - "**/@babel/runtime": "^7.20.13" + "@cornerstonejs/core": "^0.47.1", + "**/@babel/runtime": "^7.20.13", + "nth-check": "^2.1.1", + "trim-newlines": "^5.0.0", + "glob-parent": "^6.0.2", + "trim": "^1.0.0", + "package-json": "^8.1.0" } } diff --git a/platform/cli/package.json b/platform/cli/package.json index b13709ecc..848e76992 100644 --- a/platform/cli/package.json +++ b/platform/cli/package.json @@ -18,7 +18,7 @@ "author": "OHIF Contributors", "license": "MIT", "dependencies": { - "@babel/core": "^7.13.10", + "@babel/core": "^7.21.4", "chalk": "^5.0.0", "commander": "^8.3.0", "axios": "^0.26.1", diff --git a/platform/cli/src/commands/linkPackage.js b/platform/cli/src/commands/linkPackage.js index 96478b2e8..23764db23 100644 --- a/platform/cli/src/commands/linkPackage.js +++ b/platform/cli/src/commands/linkPackage.js @@ -41,9 +41,37 @@ async function linkPackage(packageDir, options, addToConfig, keyword) { results = await execa(`yarn`, ['link', packageName]); console.log(results.stdout); + // Add the node_modules of the linked package so that webpack + // can find the linked package externals if there are + const webpackPwaPath = path.join( + viewerDirectory, + '.webpack', + 'webpack.pwa.js' + ); + + async function updateWebpackConfig(webpackConfigPath, packageDir) { + const packageNodeModules = path.join(packageDir, 'node_modules'); + const fileContent = await fs.promises.readFile(webpackConfigPath, 'utf8'); + + const newLine = `path.resolve(__dirname, '${packageNodeModules}'),`; + const modifiedFileContent = fileContent.replace( + /(modules:\s*\[)([\s\S]*?)(\])/, + `$1$2 ${newLine}$3` + ); + + await fs.promises.writeFile(webpackConfigPath, modifiedFileContent); + } + + await updateWebpackConfig(webpackPwaPath, packageDir); + // change directory to viewer packages and add the config item process.chdir(viewerDirectory); - addToConfig(packageName, { version }); + addToConfig(packageName, { + version, + }); + + // run prettier on the webpack config + results = await execa(`yarn`, ['prettier', '--write', webpackPwaPath]); } function linkExtension(packageDir, options) { diff --git a/platform/cli/src/commands/unlinkPackage.js b/platform/cli/src/commands/unlinkPackage.js index e192714ba..463e74964 100644 --- a/platform/cli/src/commands/unlinkPackage.js +++ b/platform/cli/src/commands/unlinkPackage.js @@ -1,4 +1,6 @@ import { execa } from 'execa'; +import fs from 'fs'; +import path from 'path'; import { validateYarn, removeExtensionFromConfig, @@ -17,10 +19,51 @@ const linkPackage = async (packageName, options, removeFromConfig) => { const results = await execa(`yarn`, ['unlink', packageName]); console.log(results.stdout); + const webpackPwaPath = path.join( + viewerDirectory, + '.webpack', + 'webpack.pwa.js' + ); + + await removePathFromWebpackConfig(webpackPwaPath, packageName); + //update the plugin.json file removeFromConfig(packageName); + + // run prettier on the webpack config + await execa(`yarn`, ['prettier', '--write', webpackPwaPath]); }; +async function removePathFromWebpackConfig(webpackConfigPath, packageName) { + const fileContent = await fs.promises.readFile(webpackConfigPath, 'utf8'); + + const packageNameSubstring = `${packageName}/node_modules`; + const pathResolveStart = 'path.resolve('; + const closingParenthesis = ')'; + + let startIndex = fileContent.indexOf(packageNameSubstring); + + if (startIndex === -1) { + return; + } + + // Find the start of the "path.resolve" line. + startIndex = fileContent.lastIndexOf(pathResolveStart, startIndex); + + // Find the end of the line with the closing parenthesis. + let endIndex = fileContent.indexOf(closingParenthesis, startIndex) + 1; + + // Check if there's a comma after the closing parenthesis and remove it as well. + if (fileContent[endIndex] === ',') { + endIndex++; + } + + const modifiedFileContent = + fileContent.slice(0, startIndex) + fileContent.slice(endIndex); + + await fs.promises.writeFile(webpackConfigPath, modifiedFileContent); +} + function unlinkExtension(extensionName, options) { linkPackage(extensionName, options, removeExtensionFromConfig); } diff --git a/platform/cli/src/commands/utils/private/writePluginConfigFile.js b/platform/cli/src/commands/utils/private/writePluginConfigFile.js index eaef52343..9ff9a4b7e 100644 --- a/platform/cli/src/commands/utils/private/writePluginConfigFile.js +++ b/platform/cli/src/commands/utils/private/writePluginConfigFile.js @@ -6,9 +6,9 @@ export default function writePluginConfigFile(pluginConfig) { fs.writeFileSync( `./pluginConfig.json`, - jsonStringOfFileContents, + jsonStringOfFileContents + '\n', // Add a newline character at the end { flag: 'w+' }, - (err) => { + err => { if (err) { console.error(err); return; diff --git a/platform/cli/src/questions.js b/platform/cli/src/questions.js index 1d976a353..26f207d7c 100644 --- a/platform/cli/src/questions.js +++ b/platform/cli/src/questions.js @@ -1,4 +1,5 @@ import path from 'path'; +import os from 'os'; function getPathQuestions(packageType) { return [ @@ -6,7 +7,7 @@ function getPathQuestions(packageType) { type: 'input', name: 'name', message: `What is the name of your ${packageType}?`, - validate: (input) => { + validate: input => { if (!input) { return 'Please enter a name'; } @@ -17,8 +18,8 @@ function getPathQuestions(packageType) { { type: 'input', name: 'baseDir', - message: `What is the target absolute path to create your ${packageType} (we recommend you do not use the OHIF ${packageType} folder (./${packageType}s) unless you are developing a core ${packageType}):`, - validate: (input) => { + message: `What is the target path to create your ${packageType} (we recommend you do not use the OHIF ${packageType} folder (./${packageType}s) unless you are developing a core ${packageType}):`, + validate: input => { if (!input) { console.log('Please provide a valid target directory path'); return; @@ -26,7 +27,13 @@ function getPathQuestions(packageType) { return true; }, filter: (input, answers) => { - return path.resolve(input, answers.name); + // Replace ~ with the user's home directory + const expandedPath = input.replace(/^~(?=$|\/|\\)/, os.homedir()); + + // Resolve the path to an absolute path + const resolvedPath = path.resolve(expandedPath, answers.name); + + return resolvedPath; }, }, { @@ -43,6 +50,7 @@ function getRepoQuestions(packageType) { type: 'confirm', name: 'gitRepository', message: 'Should it be a git repository?', + default: false, }, { type: 'confirm', diff --git a/platform/cli/templates/extension/dependencies.json b/platform/cli/templates/extension/dependencies.json index 98cf9a215..72442cf6f 100644 --- a/platform/cli/templates/extension/dependencies.json +++ b/platform/cli/templates/extension/dependencies.json @@ -22,7 +22,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", @@ -32,7 +32,7 @@ "@babel/runtime": "^7.20.13" }, "devDependencies": { - "@babel/core": "^7.17.8", + "@babel/core": "^7.21.4", "@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/plugin-proposal-object-rest-spread": "^7.17.3", "@babel/plugin-proposal-private-methods": "^7.18.6", diff --git a/platform/cli/templates/mode/dependencies.json b/platform/cli/templates/mode/dependencies.json index 3a0c1b72f..ef5c884a6 100644 --- a/platform/cli/templates/mode/dependencies.json +++ b/platform/cli/templates/mode/dependencies.json @@ -23,7 +23,7 @@ "@babel/runtime": "^7.20.13" }, "devDependencies": { - "@babel/core": "^7.17.8", + "@babel/core": "^7.21.4", "@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/plugin-proposal-object-rest-spread": "^7.17.3", "@babel/plugin-proposal-private-methods": "^7.18.6", diff --git a/platform/cli/templates/mode/src/index.tsx b/platform/cli/templates/mode/src/index.tsx index a9c28dd93..2086aa36b 100644 --- a/platform/cli/templates/mode/src/index.tsx +++ b/platform/cli/templates/mode/src/index.tsx @@ -1,4 +1,6 @@ +import { hotkeys } from '@ohif/core'; import { id } from './id'; +import { initToolGroups, toolbarButtons } from '@ohif/mode-longitudinal'; const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', @@ -38,12 +40,76 @@ function modeFactory({ modeConfiguration }) { * Runs when the Mode Route is mounted to the DOM. Usually used to initialize * Services and other resources. */ - onModeEnter: ({ servicesManager, extensionManager }) => {}, - /** - * Runs when the Mode Route is unmounted from the DOM. Usually used to clean - * up resources and states - */ - onModeExit: () => {}, + onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { + const { + measurementService, + toolbarService, + toolGroupService, + } = servicesManager.services; + + measurementService.clearMeasurements(); + + // Init Default and SR ToolGroups + initToolGroups(extensionManager, toolGroupService, commandsManager); + + let unsubscribe; + + const activateTool = () => { + toolbarService.recordInteraction({ + groupId: 'WindowLevel', + itemId: 'WindowLevel', + interactionType: 'tool', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'WindowLevel', + }, + context: 'CORNERSTONE', + }, + ], + }); + + // We don't need to reset the active tool whenever a viewport is getting + // added to the toolGroup. + unsubscribe(); + }; + + // Since we only have one viewport for the basic cs3d mode and it has + // only one hanging protocol, we can just use the first viewport + ({ unsubscribe } = toolGroupService.subscribe( + toolGroupService.EVENTS.VIEWPORT_ADDED, + activateTool + )); + + toolbarService.init(extensionManager); + toolbarService.addButtons(toolbarButtons); + toolbarService.createButtonSection('primary', [ + 'MeasurementTools', + 'Zoom', + 'WindowLevel', + 'Pan', + 'Capture', + 'Layout', + 'MPR', + 'Crosshairs', + 'MoreTools', + ]); + }, + onModeExit: ({ servicesManager }) => { + const { + toolGroupService, + syncGroupService, + toolbarService, + segmentationService, + cornerstoneViewportService, + } = servicesManager.services; + + toolGroupService.destroy(); + syncGroupService.destroy(); + segmentationService.destroy(); + cornerstoneViewportService.destroy(); + }, /** */ validationTags: { study: [], @@ -93,7 +159,7 @@ function modeFactory({ modeConfiguration }) { /** SopClassHandlers used by the mode */ sopClassHandlers: [ohif.sopClassHandler], /** hotkeys for mode */ - hotkeys: [''], + hotkeys: [...hotkeys.defaults.hotkeyBindings], }; } diff --git a/platform/core/package.json b/platform/core/package.json index 42c973065..5b028125f 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -7,6 +7,7 @@ "repository": "OHIF/Viewers", "main": "dist/index.umd.js", "module": "src/index.ts", + "types": "src/types/index.ts", "sideEffects": "false", "publishConfig": { "access": "public" @@ -31,14 +32,18 @@ }, "peerDependencies": { "cornerstone-math": "0.1.9", - "cornerstone-wado-image-loader": "^4.13.0", - "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", + "dicom-parser": "^1.8.21", "@ohif/ui": "^2.0.0" }, "dependencies": { "@babel/runtime": "^7.20.13", - "dcmjs": "^0.29.4", - "dicomweb-client": "^0.8.4", + "dcmjs": "^0.29.5", + "dicomweb-client": "^0.10.2", "isomorphic-base64": "^1.0.2", "lodash.merge": "^4.6.1", "lodash.clonedeep": "^4.5.0", diff --git a/platform/core/src/DataSources/IWebApiDataSource.js b/platform/core/src/DataSources/IWebApiDataSource.js index 2cf785be3..a0b1a713f 100644 --- a/platform/core/src/DataSources/IWebApiDataSource.js +++ b/platform/core/src/DataSources/IWebApiDataSource.js @@ -21,6 +21,7 @@ function create({ deleteStudyMetadataPromise, getImageIdsForDisplaySet, getImageIdsForInstance, + getConfig, }) { const defaultQuery = { studies: { @@ -57,6 +58,11 @@ function create({ }; const defaultReject = {}; + + const defaultGetConfig = () => { + return { dicomUploadEnabled: false }; + }; + return { query: query || defaultQuery, retrieve: retrieve || defaultRetrieve, @@ -66,6 +72,7 @@ function create({ deleteStudyMetadataPromise, getImageIdsForDisplaySet, getImageIdsForInstance, + getConfig: getConfig || defaultGetConfig, }; } diff --git a/platform/core/src/classes/ImageSet.js b/platform/core/src/classes/ImageSet.ts similarity index 68% rename from platform/core/src/classes/ImageSet.js rename to platform/core/src/classes/ImageSet.ts index 9f4ba7074..d7ac82bce 100644 --- a/platform/core/src/classes/ImageSet.js +++ b/platform/core/src/classes/ImageSet.ts @@ -1,7 +1,16 @@ import guid from '../utils/guid.js'; import { Vector3 } from 'cornerstone-math'; -const OBJECT = 'object'; +type Attributes = Record; +type Image = { + StudyInstanceUID?: string; + getData(): { + metadata: { + ImagePositionPatient: number[]; + ImageOrientationPatient: number[]; + }; + }; +}; /** * This class defines an ImageSet object which will be used across the viewer. This object represents @@ -10,8 +19,14 @@ const OBJECT = 'object'; * indiscriminately, but this should be changed). */ class ImageSet { - constructor(images) { - if (Array.isArray(images) !== true) { + images: Image[]; + uid: string; + instances: Image[]; + instance?: Image; + StudyInstanceUID?: string; + + constructor(images: Image[]) { + if (!Array.isArray(images)) { throw new Error('ImageSet expects an array of images'); } @@ -30,43 +45,45 @@ class ImageSet { writable: false, value: guid(), // Unique ID of the instance }); + + this.instances = images; + this.instance = images[0]; + this.StudyInstanceUID = this.instance?.StudyInstanceUID; } - getUID() { + load: () => Promise; + + getUID(): string { return this.uid; } - setAttribute(attribute, value) { + setAttribute(attribute: string, value: unknown): void { this[attribute] = value; } - getAttribute(attribute) { + getAttribute(attribute: string): unknown { return this[attribute]; } - setAttributes(attributes) { - if (typeof attributes === OBJECT && attributes !== null) { - const imageSet = this, - hasOwn = Object.prototype.hasOwnProperty; - for (let attribute in attributes) { - if (hasOwn.call(attributes, attribute)) { - imageSet[attribute] = attributes[attribute]; - } + setAttributes(attributes: Attributes): void { + if (typeof attributes === 'object' && attributes !== null) { + for (const [attribute, value] of Object.entries(attributes)) { + this[attribute] = value; } } } - getNumImages = () => this.images.length + getNumImages = (): number => this.images.length; - getImage(index) { + getImage(index: number): Image { return this.images[index]; } - sortBy(sortingCallback) { + sortBy(sortingCallback: (a: Image, b: Image) => number): Image[] { return this.images.sort(sortingCallback); } - sortByImagePositionPatient() { + sortByImagePositionPatient(): void { const images = this.images; const referenceImagePositionPatient = _getImagePositionPatient(images[0]); @@ -90,7 +107,7 @@ class ImageSet { ) ); - const distanceImagePairs = images.map(function(image) { + const distanceImagePairs = images.map(function(image: Image) { const ippVec = new Vector3(..._getImagePositionPatient(image)); const positionVector = refIppVec.clone().sub(ippVec); const distance = positionVector.dot(scanAxisNormal); diff --git a/platform/core/src/classes/MetadataProvider.js b/platform/core/src/classes/MetadataProvider.js index 32f482e74..75f1ad9fe 100644 --- a/platform/core/src/classes/MetadataProvider.js +++ b/platform/core/src/classes/MetadataProvider.js @@ -116,14 +116,14 @@ class MetadataProvider { return instance[naturalizedTagOrWADOImageLoaderTag]; } - // Maybe its a legacy CornerstoneWADOImageLoader tag then: - return this._getCornerstoneWADOImageLoaderTag( + // Maybe its a legacy dicomImageLoader tag then: + return this._getCornerstoneDICOMImageLoaderTag( naturalizedTagOrWADOImageLoaderTag, instance ); } - _getCornerstoneWADOImageLoaderTag(wadoImageLoaderTag, instance) { + _getCornerstoneDICOMImageLoaderTag(wadoImageLoaderTag, instance) { let metadata; switch (wadoImageLoaderTag) { @@ -441,6 +441,7 @@ class MetadataProvider { } getUIDsFromImageID(imageId) { + if (!imageId) throw new Error('MetadataProvider::Empty imageId'); // TODO: adding csiv here is not really correct. Probably need to use // metadataProvider.addImageIdToUIDs(imageId, { // StudyInstanceUID, @@ -480,6 +481,9 @@ class MetadataProvider { imageURI = imageIdToURI(imageId); } + // remove &frame=number from imageId + imageURI = imageURI.split('&frame=')[0]; + const uids = this.imageURIToUIDs.get(imageURI); let frameNumber = this.getFrameInformationFromURL(imageId) || '1'; @@ -495,7 +499,7 @@ const metadataProvider = new MetadataProvider(); export default metadataProvider; const WADO_IMAGE_LOADER_TAGS = { - // CornerstoneWADOImageLoader specific + // dicomImageLoader specific GENERAL_SERIES_MODULE: 'generalSeriesModule', PATIENT_STUDY_MODULE: 'patientStudyModule', IMAGE_PLANE_MODULE: 'imagePlaneModule', diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index 188da5add..c028fbab3 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -37,9 +37,13 @@ export interface ExtensionParams extends ExtensionConstructor { export interface Extension { id: string; preRegistration?: (p: ExtensionParams) => Promise | void; - onModeExit?: () => void; getHangingProtocolModule?: (p: ExtensionParams) => unknown; getCommandsModule?: (p: ExtensionParams) => CommandsModule; + getViewportModule?: (p: ExtensionParams) => unknown; + getUtilityModule?: (p: ExtensionParams) => unknown; + getCustomizationModule?: (p: ExtensionParams) => unknown; + onModeEnter?: () => void; + onModeExit?: () => void; } export type ExtensionRegister = { diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index 91353a59c..cf3ee6a5f 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -63,7 +63,7 @@ export default class CustomizationService extends PubSubService { modeCustomizations: Record = {}; globalCustomizations: Record = {}; - configuration: CustomizationConfiguration; + configuration: any; constructor({ configuration, commandsManager }) { super(EVENTS); diff --git a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js index 4822b616c..dc43aa503 100644 --- a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js +++ b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js @@ -27,7 +27,7 @@ function createStudyMetadata(StudyInstanceUID) { const series = createSeriesMetadata([instance]); this.series.push(series); const { Modality } = series; - if (this.ModalitiesInStudy.indexof(Modality) === -1) { + if (this.ModalitiesInStudy.indexOf(Modality) === -1) { this.ModalitiesInStudy.push(Modality); } } diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.ts b/platform/core/src/services/DisplaySetService/DisplaySetService.ts index 552e7936c..5796afa90 100644 --- a/platform/core/src/services/DisplaySetService/DisplaySetService.ts +++ b/platform/core/src/services/DisplaySetService/DisplaySetService.ts @@ -1,31 +1,40 @@ +import { InstanceMetadata } from '../../types'; import { PubSubService } from '../_shared/pubSubServiceInterface'; import EVENTS from './EVENTS'; -const displaySetCache = []; - -/** - * Find an instance in a list of instances, comparing by SOP instance UID - */ -const findInSet = (instance, list) => { - if (!list) return false; - for (const elem of list) { - if (!elem) continue; - if (elem === instance) return true; - if (elem.SOPInstanceUID === instance.SOPInstanceUID) return true; - } - return false; +export type DisplaySet = { + displaySetInstanceUID: string; + instances: InstanceMetadata[]; + StudyInstanceUID: string; + SeriesInstanceUID?: string; + numImages?: number; }; +const displaySetCache = new Map(); + /** - * Find an instance in a display set - * @returns true if found + * Filters the instances set by instances not in + * display sets. Done in O(n) time. */ -const findInstance = (instance, displaySets) => { - for (const displayset of displaySets) { - if (findInSet(instance, displayset.images)) return true; - if (findInSet(instance, displayset.others)) return true; - } - return false; +const filterInstances = ( + instances: InstanceMetadata[], + displaySets: DisplaySet[] +): InstanceMetadata[] => { + const dsInstancesSOP = new Set(); + displaySets.forEach(ds => { + const dsInstances = ds.instances; + if (!dsInstances) { + console.warn('No instances in', ds); + } else { + dsInstances.forEach(instance => + dsInstancesSOP.add(instance.SOPInstanceUID) + ); + } + }); + + return instances.filter( + instance => !dsInstancesSOP.has(instance.SOPInstanceUID) + ); }; export default class DisplaySetService extends PubSubService { @@ -38,6 +47,13 @@ export default class DisplaySetService extends PubSubService { }; public activeDisplaySets = []; + + protected activeDisplaySetsMap = new Map(); + + // Record if the active display sets changed - used to group change events so + // that fewer events need to be fired when creating multiple display sets + protected activeDisplaySetsChanged = false; + constructor() { super(EVENTS); } @@ -46,64 +62,85 @@ export default class DisplaySetService extends PubSubService { this.extensionManager = extensionManager; this.SOPClassHandlerIds = SOPClassHandlerIds; this.activeDisplaySets = []; + this.activeDisplaySetsMap.clear(); } - _addDisplaySetsToCache(displaySets) { + _addDisplaySetsToCache(displaySets: DisplaySet[]) { displaySets.forEach(displaySet => { - displaySetCache.push(displaySet); + displaySetCache.set(displaySet.displaySetInstanceUID, displaySet); }); } - _addActiveDisplaySets(displaySets) { - const activeDisplaySets = this.activeDisplaySets; + _addActiveDisplaySets(displaySets: DisplaySet[]) { + const { activeDisplaySets, activeDisplaySetsMap } = this; displaySets.forEach(displaySet => { - // This test makes adding display sets an N^2 operation, so it might - // become important to do this in an efficient manner for large - // numbers of display sets. - if (!activeDisplaySets.includes(displaySet)) { + if (!activeDisplaySetsMap.has(displaySet.displaySetInstanceUID)) { + this.activeDisplaySetsChanged = true; activeDisplaySets.push(displaySet); + activeDisplaySetsMap.set(displaySet.displaySetInstanceUID, displaySet); } }); } - getDisplaySetCache() { + /** + * Adds new display sets directly, as specified. + * Use this function when the display sets are created externally directly + * rather than using the default sop class handlers to create display sets. + */ + public addDisplaySets(...displaySets: DisplaySet[]): string[] { + this._addDisplaySetsToCache(displaySets); + this._addActiveDisplaySets(displaySets); + + // The activeDisplaySetsChanged flag is only seen if we add display sets + // so, don't broadcast the change if all the display sets were pre-existing. + this.activeDisplaySetsChanged = false; + this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, { + displaySetsAdded: displaySets, + options: { madeInClient: displaySets[0].madeInClient }, + }); + return displaySets; + } + + public getDisplaySetCache(): Map { return displaySetCache; } - getMostRecentDisplaySet() { + public getMostRecentDisplaySet(): DisplaySet { return this.activeDisplaySets[this.activeDisplaySets.length - 1]; } - getActiveDisplaySets() { + public getActiveDisplaySets(): DisplaySet[] { return this.activeDisplaySets; } - getDisplaySetsForSeries = SeriesInstanceUID => { - return displaySetCache.filter( - displaySet => displaySet.SeriesInstanceUID === SeriesInstanceUID + public getDisplaySetsForSeries = ( + seriesInstanceUID: string + ): DisplaySet[] => { + return [...displaySetCache.values()].filter( + displaySet => displaySet.SeriesInstanceUID === seriesInstanceUID ); }; - getDisplaySetForSOPInstanceUID( - SOPInstanceUID, - SeriesInstanceUID, - frameNumber - ) { - const displaySets = SeriesInstanceUID - ? this.getDisplaySetsForSeries(SeriesInstanceUID) - : this.getDisplaySetCache(); + public getDisplaySetForSOPInstanceUID( + sopInstanceUID: string, + seriesInstanceUID: string, + frameNumber?: number + ): DisplaySet { + const displaySets = seriesInstanceUID + ? this.getDisplaySetsForSeries(seriesInstanceUID) + : [...this.getDisplaySetCache().values()]; const displaySet = displaySets.find(ds => { return ( - ds.images && ds.images.some(i => i.SOPInstanceUID === SOPInstanceUID) + ds.images && ds.images.some(i => i.SOPInstanceUID === sopInstanceUID) ); }); return displaySet; } - setDisplaySetMetadataInvalidated(displaySetInstanceUID) { + public setDisplaySetMetadataInvalidated(displaySetInstanceUID: string): void { const displaySet = this.getDisplaySetByUID(displaySetInstanceUID); if (!displaySet) { @@ -117,19 +154,17 @@ export default class DisplaySetService extends PubSubService { ); } - deleteDisplaySet(displaySetInstanceUID) { - const { activeDisplaySets } = this; - - const displaySetCacheIndex = displaySetCache.findIndex( - ds => ds.displaySetInstanceUID === displaySetInstanceUID - ); + public deleteDisplaySet(displaySetInstanceUID) { + if (!displaySetInstanceUID) return; + const { activeDisplaySets, activeDisplaySetsMap } = this; const activeDisplaySetsIndex = activeDisplaySets.findIndex( ds => ds.displaySetInstanceUID === displaySetInstanceUID ); - displaySetCache.splice(displaySetCacheIndex, 1); + displaySetCache.delete(displaySetInstanceUID); activeDisplaySets.splice(activeDisplaySetsIndex, 1); + activeDisplaySetsMap.delete(displaySetInstanceUID); this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets); this._broadcastEvent(EVENTS.DISPLAY_SETS_REMOVED, { @@ -141,10 +176,8 @@ export default class DisplaySetService extends PubSubService { * @param {string} displaySetInstanceUID * @returns {object} displaySet */ - getDisplaySetByUID = displaySetInstanceUid => - displaySetCache.find( - displaySet => displaySet.displaySetInstanceUID === displaySetInstanceUid - ); + public getDisplaySetByUID = (displaySetInstanceUid: string): DisplaySet => + displaySetCache.get(displaySetInstanceUid); /** * @@ -167,7 +200,7 @@ export default class DisplaySetService extends PubSubService { } // If array of instances => One instance. - let displaySetsAdded = []; + const displaySetsAdded = []; if (batch) { for (let i = 0; i < input.length; i++) { @@ -177,12 +210,12 @@ export default class DisplaySetService extends PubSubService { settings ); - displaySetsAdded = [...displaySetsAdded, displaySets]; + displaySetsAdded.push(...displaySets); } } else { const displaySets = this.makeDisplaySetForInstances(input, settings); - displaySetsAdded = displaySets; + displaySetsAdded.push(...displaySets); } const options = {}; @@ -191,10 +224,13 @@ export default class DisplaySetService extends PubSubService { options.madeInClient = true; } - // TODO: This is tricky. How do we know we're not resetting to the same/existing DSs? - // TODO: This is likely run anytime we touch DicomMetadataStore. How do we prevent unnecessary broadcasts? - if (displaySetsAdded && displaySetsAdded.length) { + if (this.activeDisplaySetsChanged) { + this.activeDisplaySetsChanged = false; this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets); + } + if (displaySetsAdded?.length) { + // The response from displaySetsAdded will only contain newly added + // display sets. this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, { displaySetsAdded, options, @@ -210,22 +246,45 @@ export default class DisplaySetService extends PubSubService { * the mode specific onModeExit is called before this method and should * store the active display sets and the cached data. */ - onModeExit() { - this.getDisplaySetCache().length = 0; + public onModeExit(): void { + this.getDisplaySetCache().clear(); this.activeDisplaySets.length = 0; + this.activeDisplaySetsMap.clear(); } - makeDisplaySetForInstances(instancesSrc, settings) { - let instances = instancesSrc; + /** + * Creates new display sets for the instances contained in instancesSrc + * according to the sop class handlers registered. + * This is idempotent in that calling it a second time with the + * same set of instances will not result in new display sets added. + * However, the response for the subsequent call will be empty as the data + * is already present. + * Calling it with some new instances and some existing instances will + * result in the new instances being added to existing display sets if + * they support the addInstances call, OR to new instances otherwise. + * Only the new instances are returned - the others are updated. + * + * @param instancesSrc are instances to add + * @param settings are settings to add + * @returns Array of the display sets added. + */ + public makeDisplaySetForInstances( + instancesSrc: InstanceMetadata[], + settings + ): DisplaySet[] { + // Some of the sop class handlers take a direct reference to instances + // so make sure it gets copied here so that they have their own ref + let instances = [...instancesSrc]; const instance = instances[0]; const existingDisplaySets = this.getDisplaySetsForSeries(instance.SeriesInstanceUID) || []; const SOPClassHandlerIds = this.SOPClassHandlerIds; - let allDisplaySets; + const allDisplaySets = []; - for (let i = 0; i < SOPClassHandlerIds.length; i++) { + // Iterate over the sop class handlers while there are still instances to add + for (let i = 0; i < SOPClassHandlerIds.length && instances.length; i++) { const SOPClassHandlerId = SOPClassHandlerIds[i]; const handler = this.extensionManager.getModuleEntry(SOPClassHandlerId); @@ -236,34 +295,81 @@ export default class DisplaySetService extends PubSubService { ); if (displaySets.length) { - this._addActiveDisplaySets(displaySets); - } else { - displaySets = handler.getDisplaySetsFromSeries(instances); + // This case occurs when there are already display sets, so remove + // any instances in existing display sets. + instances = filterInstances(instances, displaySets); + // See if an existing display set can add this instance to it, + // for example, if it is a new image to be added to the existing set + for (const ds of displaySets) { + const addedDs = ds.addInstances?.(instances, this); + if (addedDs) { + this.activeDisplaySetsChanged = true; + instances = filterInstances(instances, [addedDs]); + this._addActiveDisplaySets([addedDs]); + this.setDisplaySetMetadataInvalidated( + addedDs.displaySetInstanceUID + ); + } + // This means that all instances already existed or got added to + // existing display sets, and had an invalidated event fired + if (!instances.length) return allDisplaySets; + } - if (!displaySets || !displaySets.length) continue; - - // applying hp-defined viewport settings to the displaysets - displaySets.forEach(ds => { - Object.keys(settings).forEach(key => { - ds[key] = settings[key]; - }); - }); - - this._addDisplaySetsToCache(displaySets); - this._addActiveDisplaySets(displaySets); - - instances = instances.filter( - instance => !findInstance(instance, displaySets) - ); + if (!instances.length) { + // Everything is already added - this is just an update caused + // by something else + this._addActiveDisplaySets(displaySets); + return allDisplaySets; + } } - allDisplaySets = allDisplaySets - ? [...allDisplaySets, ...displaySets] - : displaySets; + // The instances array still contains some instances, so try + // creating additional display sets using the sop class handler + displaySets = handler.getDisplaySetsFromSeries(instances); - if (!instances.length) return allDisplaySets; + if (!displaySets || !displaySets.length) continue; + + // applying hp-defined viewport settings to the displaysets + displaySets.forEach(ds => { + Object.keys(settings).forEach(key => { + ds[key] = settings[key]; + }); + }); + + this._addDisplaySetsToCache(displaySets); + this._addActiveDisplaySets(displaySets); + + // It is possible that this SOP class handler handled some instances + // but there may need to be other instances handled by other handlers, + // so remove the handled instances + instances = filterInstances(instances, displaySets); + + allDisplaySets.push(...displaySets); } } return allDisplaySets; } + + /** + * Iterates over displaysets and invokes comparator for each element. + * It returns a list of items that has being succeed by comparator method. + * + * @param comparator - method to be used on the validation + * @returns list of displaysets + */ + public getDisplaySetsBy(comparator: (DisplaySet) => boolean): DisplaySet[] { + const result = []; + + if (typeof comparator !== 'function') { + throw new Error(`The comparator ${comparator} was not a function`); + } + + this.getActiveDisplaySets().forEach(displaySet => { + if (comparator(displaySet)) { + result.push(displaySet); + } + }); + + return result; + } } diff --git a/platform/core/src/services/HangingProtocolService/HPMatcher.js b/platform/core/src/services/HangingProtocolService/HPMatcher.js index a369f2460..b87c31e84 100644 --- a/platform/core/src/services/HangingProtocolService/HPMatcher.js +++ b/platform/core/src/services/HangingProtocolService/HPMatcher.js @@ -32,7 +32,7 @@ const match = ( // Allow for matching against current or prior specifically const prior = options?.studies?.[1]; const current = options?.studies?.[0]; - const instance = (metadataInstance.images || metadataInstance.others)?.[0]; + const instance = metadataInstance.instances?.[0]; const fromSrc = { prior, current, diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index b366450d9..3b6c540c2 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -1,3 +1,5 @@ +import cloneDeep from 'lodash.clonedeep'; + import { PubSubService } from '../_shared/pubSubServiceInterface'; import sortBy from '../../utils/sortBy'; import ProtocolEngine from './ProtocolEngine'; @@ -6,6 +8,12 @@ import IDisplaySet from '../DisplaySetService/IDisplaySet'; import { CommandsManager } from '../../classes'; import ServicesManager from '../ServicesManager'; import * as HangingProtocol from '../../types/HangingProtocol'; +import { + isDisplaySetFromUrl, + sopInstanceLocation, +} from './custom-attribute/isDisplaySetFromUrl'; +import numberOfDisplaySetsWithImages from './custom-attribute/numberOfDisplaySetsWithImages'; +import seriesDescriptionsFromDisplaySets from './custom-attribute/seriesDescriptionsFromDisplaySets'; type Protocol = HangingProtocol.Protocol | HangingProtocol.ProtocolGenerator; @@ -47,6 +55,12 @@ export default class HangingProtocolService extends PubSubService { activeProtocolIds: string[]; // the current protocol that is being applied to the viewports in object format protocol: HangingProtocol.Protocol; + // The version of the protocol that must not be modified with customizations + // if it was defined in the protocol definition. This is a copy of the protocol + // that is used to recompute the computedOptions when necessary as we override + // the computedOptions in the protocol object itself. + _originalProtocol: HangingProtocol.Protocol; + stageIndex = 0; _commandsManager: CommandsManager; _servicesManager: ServicesManager; @@ -81,6 +95,24 @@ export default class HangingProtocolService extends PubSubService { // we can add more advanced checking here callback: displaySet => displaySet.isReconstructable ?? false, }, + isDisplaySetFromUrl: { + name: 'Checks if the display set is as specified in the URL', + callback: isDisplaySetFromUrl, + }, + sopInstanceLocation: { + name: 'Gets the position of the specified sop instance', + callback: sopInstanceLocation, + }, + seriesDescriptions: { + name: 'seriesDescriptions', + description: 'List of Series Descriptions', + callback: seriesDescriptionsFromDisplaySets, + }, + numberOfDisplaySetsWithImages: { + name: 'numberOfDisplaySetsWithImages', + description: 'Number of displays sets with images', + callback: numberOfDisplaySetsWithImages, + }, }; listeners = {}; registeredImageLoadStrategies = {}; @@ -587,6 +619,12 @@ export default class HangingProtocolService extends PubSubService { return defaultReturn; } + const originalProtocol = this._originalProtocol; + let originalProtocolStage; + if (!(originalProtocol instanceof Function)) { + originalProtocolStage = originalProtocol.stages[this.stageIndex]; + } + // if we reach here, it means that the displaySetInstanceUIDs to be dropped // in the viewportIndex are valid, and we can proceed with the update. However // we need to check if the displaySets that the viewport were showing @@ -595,45 +633,44 @@ export default class HangingProtocolService extends PubSubService { // check if displaySetSelectors are used by other viewports, and // store the viewportIndex and displaySetInstanceUIDs that need to be updated - const viewportsToUpdate = []; protocolViewports.forEach((viewport, index) => { - let viewportNeedsUpdate; - for (const displaySet of viewport.displaySets) { - if ( + const viewportNeedsUpdate = viewport.displaySets.some( + displaySet => displaySet.id === displaySetSelectorId && (displaySet.matchedDisplaySetsIndex || 0) === matchedDisplaySetsIndex - ) { - viewportNeedsUpdate = true; - break; - } - } + ); if (viewportNeedsUpdate) { - // we can then loop over the displaySets and choose all of them, - // but for the one that matches the oldDisplaySetInstanceUID we need to - // replace it with the newDisplaySetInstanceUID - const { + // Try to recompute the viewport options based on the current + // viewportIndex that needs update but from its old/original un-computed + // viewport & displaySet options + if (originalProtocolStage) { + const originalViewport = originalProtocolStage.viewports[index]; + const originalViewportOptions = originalViewport.viewportOptions; + const originalDisplaySetOptions = originalViewport.displaySets; + + viewport.viewportOptions = this.getComputedOptions( + originalViewportOptions, + [newDisplaySetInstanceUID] + ); + + viewport.displaySets = this.getComputedOptions( + originalDisplaySetOptions, + [newDisplaySetInstanceUID] + ); + } + + const displaySetInstanceUIDs = []; + const displaySetOptions = []; + + this._updateDisplaySetInstanceUIDs( + viewport, + displaySetSelectorId, + newDisplaySetInstanceUID, + this.displaySetMatchDetails, displaySetInstanceUIDs, - displaySetOptions, - } = viewport.displaySets.reduce( - (acc, displaySet) => { - const { id } = displaySet; - - let { - displaySetInstanceUID: displaySetInstanceUIDToUse, - } = this.displaySetMatchDetails.get(id); - - if (displaySet.id === displaySetSelectorId) { - displaySetInstanceUIDToUse = newDisplaySetInstanceUID; - } - - acc.displaySetInstanceUIDs.push(displaySetInstanceUIDToUse); - acc.displaySetOptions.push(displaySet); - - return acc; - }, - { displaySetInstanceUIDs: [], displaySetOptions: [] } + displaySetOptions ); viewportsToUpdate.push({ @@ -648,6 +685,87 @@ export default class HangingProtocolService extends PubSubService { return viewportsToUpdate; } + private _updateDisplaySetInstanceUIDs( + viewport: HangingProtocol.Viewport, + displaySetSelectorId: string, + newDisplaySetInstanceUID: string, + displaySetMatchDetails: Map, + displaySetInstanceUIDs: string[], + displaySetOptions: HangingProtocol.DisplaySetOptions[] + ) { + viewport.displaySets.forEach(displaySet => { + const { id } = displaySet; + const { + displaySetInstanceUID: oldDisplaySetInstanceUID, + } = displaySetMatchDetails.get(id); + + displaySetInstanceUIDs.push( + displaySet.id === displaySetSelectorId + ? newDisplaySetInstanceUID + : oldDisplaySetInstanceUID + ); + displaySetOptions.push(displaySet); + }); + } + + /** + * Gets a computed options value, or a copy of the options + * This allows computing values such as the initial image index to use + * based on custom attribute functions, the same as the validators. + * Computing individual values is something that can be declared statically + * as long as the named functions are provided ahead of time, which is much + * simpler than recomputing the entire protocol. + */ + public getComputedOptions( + options: Record | Array>, + displaySetUIDs: string[] + ): any { + // Base case: if options is an array, map over the array and recursively call getComputedOptions + if (Array.isArray(options)) { + return options.map(option => + this.getComputedOptions(option, displaySetUIDs) + ); + } + + if (options === null) return options; + if (typeof options !== 'object') return options; + + // If options is an object with a custom attribute, compute a new options object + if (options.custom) { + const displaySets = this.displaySets.filter(displaySet => + displaySetUIDs.includes(displaySet.displaySetInstanceUID) + ); + + const customKey = options.custom as string; + if (!(customKey in this.customAttributeRetrievalCallbacks)) { + throw new Error( + `Custom key "${customKey}" not found in customAttributeRetrievalCallbacks.` + ); + } + + const callback = this.customAttributeRetrievalCallbacks[customKey] + .callback; + let newOptions = callback.call(options, displaySets); + + if (newOptions === undefined) { + newOptions = options.defaultValue; + } + + return this.getComputedOptions(newOptions, displaySetUIDs); + } + + // If options is an object without a custom attribute, recursively call getComputedOptions on its properties + const newOptions = {} as Record; + for (const key in options) { + // if not undefined + if (options[key] !== undefined) { + newOptions[key] = this.getComputedOptions(options[key], displaySetUIDs); + } + } + + return newOptions; + } + /** * It applied the protocol to the current studies and display sets based on the * protocolId that is provided. @@ -802,7 +920,8 @@ export default class HangingProtocolService extends PubSubService { try { if (!this.protocol || this.protocol.id !== protocol.id) { this.stageIndex = options?.stageIndex || 0; - this.protocol = this._copyProtocol(protocol); + this._originalProtocol = this._copyProtocol(protocol); + this.protocol = protocol; const { imageLoadStrategy } = protocol; if (imageLoadStrategy) { @@ -1001,7 +1120,11 @@ export default class HangingProtocolService extends PubSubService { viewportMatchDetails.set(viewportIndex, matchDetails); } }); - return { matchedViewports, viewportMatchDetails, displaySetMatchDetails }; + return { + matchedViewports, + viewportMatchDetails, + displaySetMatchDetails, + }; } protected findDeduplicatedMatchDetails( @@ -1023,7 +1146,10 @@ export default class HangingProtocolService extends PubSubService { ) { const match = matchDetails.matchingScores[i]; return match.matchingScore > 0 - ? { matchingScores, ...matchDetails.matchingScores[i] } + ? { + matchingScores, + ...matchDetails.matchingScores[i], + } : null; } } @@ -1252,7 +1378,11 @@ export default class HangingProtocolService extends PubSubService { const studyMatchDetails = this.protocolEngine.findMatch( study, studyMatchingRules, - { studies: this.studies, displaySets: studyDisplaySets } + { + studies: this.studies, + displaySets: studyDisplaySets, + displaySetMatchDetails: this.displaySetMatchDetails, + } ); // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed @@ -1276,7 +1406,12 @@ export default class HangingProtocolService extends PubSubService { displaySet, seriesMatchingRules, // Todo: why we have images here since the matching type does not have it - { studies: this.studies, instance: displaySet.images?.[0] } + { + studies: this.studies, + instance: displaySet.images?.[0], + displaySetMatchDetails: this.displaySetMatchDetails, + displaySets: studyDisplaySets, + } ); // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed @@ -1449,6 +1584,6 @@ export default class HangingProtocolService extends PubSubService { } _copyProtocol(protocol: Protocol) { - return JSON.parse(JSON.stringify(protocol)); + return cloneDeep(protocol); } } diff --git a/platform/core/src/services/HangingProtocolService/custom-attribute/isDisplaySetFromUrl.ts b/platform/core/src/services/HangingProtocolService/custom-attribute/isDisplaySetFromUrl.ts new file mode 100644 index 000000000..d1626919d --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/custom-attribute/isDisplaySetFromUrl.ts @@ -0,0 +1,46 @@ +import { getSplitParam } from '../../../utils'; + +/** Indicates if the given display set is the one specified in the + * displaySet parameter in the URL + * The parameters are: + * initialSeriesInstanceUID + * initialSOPInstanceUID + */ +const isDisplaySetFromUrl = (displaySet): boolean => { + const params = new URLSearchParams(window.location.search); + const initialSeriesInstanceUID = getSplitParam( + 'initialseriesinstanceuid', + params + ); + const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid', params); + if (!initialSeriesInstanceUID && !initialSOPInstanceUID) return false; + const isSeriesMatch = + !initialSeriesInstanceUID || + initialSeriesInstanceUID.some( + seriesUID => displaySet.SeriesInstanceUID === seriesUID + ); + const isSopMatch = + !initialSOPInstanceUID || + displaySet.instances?.some?.(instance => + initialSOPInstanceUID.some(sopUID => sopUID === instance.SOPInstanceUID) + ); + return isSeriesMatch && isSopMatch; +}; + +/** Returns the index location of the requested image, or the defaultValue in this. + * Returns undefined to fallback to the defaultValue + */ +function sopInstanceLocation(displaySets) { + const displaySet = displaySets?.[0]; + if (!displaySet) return; + const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid'); + if (!initialSOPInstanceUID) return; + + const index = displaySet.instances.findIndex(instance => + initialSOPInstanceUID.includes(instance.SOPInstanceUID) + ); + // Need to return in the initial position specified format. + return index === -1 ? undefined : { index }; +} + +export { isDisplaySetFromUrl, sopInstanceLocation }; diff --git a/extensions/test-extension/src/custom-attribute/numberOfDisplaySetsWithImages.ts b/platform/core/src/services/HangingProtocolService/custom-attribute/numberOfDisplaySetsWithImages.ts similarity index 100% rename from extensions/test-extension/src/custom-attribute/numberOfDisplaySetsWithImages.ts rename to platform/core/src/services/HangingProtocolService/custom-attribute/numberOfDisplaySetsWithImages.ts diff --git a/extensions/test-extension/src/custom-attribute/seriesDescriptionsFromDisplaySets.ts b/platform/core/src/services/HangingProtocolService/custom-attribute/seriesDescriptionsFromDisplaySets.ts similarity index 100% rename from extensions/test-extension/src/custom-attribute/seriesDescriptionsFromDisplaySets.ts rename to platform/core/src/services/HangingProtocolService/custom-attribute/seriesDescriptionsFromDisplaySets.ts diff --git a/platform/core/src/services/MeasurementService/MeasurementService.test.js b/platform/core/src/services/MeasurementService/MeasurementService.test.js index 75113e371..78c13c837 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.test.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.test.js @@ -8,13 +8,16 @@ jest.mock('../../log', () => ({ })); describe('MeasurementService.js', () => { + const unmappedMeasurementUID = 'unmappedMeasurementUId'; let measurementService; let measurement; + let unmappedMeasurement; let source; let annotationType; let matchingCriteria; let toSourceSchema; let toMeasurement; + let toMeasurementThrowsError; let annotation; beforeEach(() => { @@ -40,6 +43,25 @@ describe('MeasurementService.js', () => { ], source: source, }; + // A measurement with various metadata missing (e.g. referenced SOPInstanceUID) that + // would not typically get mapped my the MeasurementService possibly because it was + // made in a non-acquisition plane of a volume. + unmappedMeasurement = { + uid: unmappedMeasurementUID, + SOPInstanceUID: undefined, + FrameOfReferenceUID: undefined, + referenceSeriesUID: undefined, + label: 'Label', + description: 'Description', + unit: 'mm', + area: 123, + type: measurementService.VALUE_TYPES.POLYLINE, + points: [ + { x: 1, y: 2 }, + { x: 1, y: 2 }, + ], + source: source, + }; toSourceSchema = () => annotation; toMeasurement = () => { if (Object.keys(measurement).includes('invalidProperty')) { @@ -48,6 +70,9 @@ describe('MeasurementService.js', () => { return measurement; }; + toMeasurementThrowsError = () => { + throw new Error('Unmapped measurement.'); + }; matchingCriteria = { valueType: measurementService.VALUE_TYPES.POLYLINE, points: 2, @@ -429,5 +454,75 @@ describe('MeasurementService.js', () => { expect(updateCallbackWasCalled).toBe(false); }); + + it('subscribers do NOT receive add unmapped measurements event', () => { + measurementService.addMapping( + source, + annotationType, + matchingCriteria, + toSourceSchema, + toMeasurementThrowsError + ); + + const { MEASUREMENT_ADDED } = measurementService.EVENTS; + let addCallbackWasCalled = false; + + /* Subscribe to add event */ + measurementService.subscribe( + MEASUREMENT_ADDED, + () => (addCallbackWasCalled = true) + ); + + /* Add new measurement - two calls needed for the start and the other for the completed*/ + // expect exceptions for unmapped measurements + expect(() => { + source.annotationToMeasurement(annotationType, unmappedMeasurement); + }).toThrow(); + + expect(() => { + source.annotationToMeasurement(annotationType, { + unmappedMeasurementUID, + ...unmappedMeasurement, + }); + }).toThrow(); + + expect(addCallbackWasCalled).toBe(false); + }); + + it('subscribers do receive remove unmapped measurements event', () => { + measurementService.addMapping( + source, + annotationType, + matchingCriteria, + toSourceSchema, + toMeasurementThrowsError + ); + + const { MEASUREMENT_REMOVED } = measurementService.EVENTS; + let removeCallbackWasCalled = false; + + /* Subscribe to add event */ + measurementService.subscribe( + MEASUREMENT_REMOVED, + () => (removeCallbackWasCalled = true) + ); + + /* Add new measurement - two calls needed for the start and the other for the completed*/ + // expect exceptions for unmapped measurements + expect(() => { + source.annotationToMeasurement(annotationType, unmappedMeasurement); + }).toThrow(); + + expect(() => { + source.annotationToMeasurement(annotationType, { + unmappedMeasurementUID, + ...unmappedMeasurement, + }); + }).toThrow(); + + measurementService.remove(unmappedMeasurementUID); + + expect(removeCallbackWasCalled).toBe(true); + }); }); }); diff --git a/platform/core/src/services/MeasurementService/MeasurementService.ts b/platform/core/src/services/MeasurementService/MeasurementService.ts index 3a89d2094..bd1ac8b84 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.ts +++ b/platform/core/src/services/MeasurementService/MeasurementService.ts @@ -67,7 +67,10 @@ const EVENTS = { RAW_MEASUREMENT_ADDED: 'event::raw_measurement_added', MEASUREMENT_REMOVED: 'event::measurement_removed', MEASUREMENTS_CLEARED: 'event::measurements_cleared', - JUMP_TO_MEASUREMENT: 'event:jump_to_measurement', + // Give the viewport a chance to jump to the measurement + JUMP_TO_MEASUREMENT_VIEWPORT: 'event:jump_to_measurement_viewport', + // Give the layout a chance to jump to the measurement + JUMP_TO_MEASUREMENT_LAYOUT: 'event:jump_to_measurement_layout', }; const VALUE_TYPES = { @@ -104,15 +107,17 @@ class MeasurementService extends PubSubService { }, }; + public static readonly EVENTS = EVENTS; public static VALUE_TYPES = VALUE_TYPES; public readonly VALUE_TYPES = VALUE_TYPES; + private measurements = new Map(); + private unmappedMeasurements = new Set(); + constructor() { super(EVENTS); this.sources = {}; this.mappings = {}; - this.measurements = {}; - this._jumpToMeasurementCache = {}; } /** @@ -120,7 +125,7 @@ class MeasurementService extends PubSubService { * This method should be used to add custom tool schema to the measurement service. * @param {Array} schema schema for validation */ - addMeasurementSchemaKeys(schema) { + public addMeasurementSchemaKeys(schema): void { if (!Array.isArray(schema)) { schema = [schema]; } @@ -160,11 +165,7 @@ class MeasurementService extends PubSubService { * @return {Measurement[]} Array of measurements */ getMeasurements() { - const measurements = this._arrayOfObjects(this.measurements); - return ( - measurements && - measurements.map(m => this.measurements[Object.keys(m)[0]]) - ); + return [...this.measurements.values()]; } /** @@ -173,18 +174,14 @@ class MeasurementService extends PubSubService { * @param {string} uid measurement uid * @return {Measurement} Measurement instance */ - getMeasurement(measurementUID) { - let measurement = null; - const measurements = this.measurements[measurementUID]; - - if (measurements && Object.keys(measurements).length > 0) { - measurement = this.measurements[measurementUID]; - } - - return measurement; + public getMeasurement(measurementUID: string) { + return this.measurements.get(measurementUID); } - setMeasurementSelected(measurementUID, selected) { + public setMeasurementSelected( + measurementUID: string, + selected: boolean + ): void { const measurement = this.getMeasurement(measurementUID); if (!measurement) { return; @@ -373,8 +370,8 @@ class MeasurementService extends PubSubService { } } - update(measurementUID, measurement, notYetUpdatedAtSource = false) { - if (!this.measurements[measurementUID]) { + update(measurementUID: string, measurement, notYetUpdatedAtSource = false) { + if (!this.measurements.has(measurementUID)) { return; } @@ -388,7 +385,7 @@ class MeasurementService extends PubSubService { updatedMeasurement ); - this.measurements[measurementUID] = updatedMeasurement; + this.measurements.set(measurementUID, updatedMeasurement); this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, { source: measurement.source, @@ -470,15 +467,15 @@ class MeasurementService extends PubSubService { uid: internalUID, }; - if (this.measurements[internalUID]) { - this.measurements[internalUID] = newMeasurement; + if (this.measurements.get(internalUID)) { + this.measurements.set(internalUID, newMeasurement); this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, { source, measurement: newMeasurement, }); } else { log.info('Measurement added', newMeasurement); - this.measurements[internalUID] = newMeasurement; + this.measurements.set(internalUID, newMeasurement); this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, { source, measurement: newMeasurement, @@ -508,7 +505,6 @@ class MeasurementService extends PubSubService { if (!this._isValidSource(source)) { throw new Error('Invalid source.'); } - if (!annotationType) { throw new Error('No source annotationType provided.'); } @@ -537,6 +533,8 @@ class MeasurementService extends PubSubService { measurement = toMeasurementSchema(sourceAnnotationDetail); measurement.source = source; } catch (error) { + this.unmappedMeasurements.add(sourceAnnotationDetail.uid); + console.log('Failed to map', error); throw new Error( `Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}: ${error.message}` @@ -558,7 +556,7 @@ class MeasurementService extends PubSubService { ); } - const oldMeasurement = this.measurements[internalUID]; + const oldMeasurement = this.measurements.get(internalUID); const newMeasurement = { ...oldMeasurement, @@ -570,9 +568,9 @@ class MeasurementService extends PubSubService { if (oldMeasurement) { // TODO: Ultimately, each annotation should have a selected flag right from the soure. // For now, it is just added in OHIF here and in setMeasurementSelected. - this.measurements[internalUID] = newMeasurement; + this.measurements.set(internalUID, newMeasurement); if (isUpdate) { - this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, { + this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, { source, measurement: newMeasurement, notYetUpdatedAtSource: false, @@ -586,7 +584,7 @@ class MeasurementService extends PubSubService { } } else { log.info('Measurement started.', newMeasurement); - this.measurements[internalUID] = newMeasurement; + this.measurements.set(internalUID, newMeasurement); } return newMeasurement.uid; @@ -599,12 +597,17 @@ class MeasurementService extends PubSubService { * @param {MeasurementSource} source The measurement source instance */ remove(measurementUID, source, eventDetails) { - if (!measurementUID || !this.measurements[measurementUID]) { + if ( + !measurementUID || + (!this.measurements.has(measurementUID) && + !this.unmappedMeasurements.has(measurementUID)) + ) { log.warn(`No uid provided, or unable to find measurement by uid.`); return; } - delete this.measurements[measurementUID]; + this.unmappedMeasurements.delete(measurementUID); + this.measurements.delete(measurementUID); this._broadcastEvent(this.EVENTS.MEASUREMENT_REMOVED, { source, measurement: measurementUID, @@ -613,10 +616,11 @@ class MeasurementService extends PubSubService { } clearMeasurements() { + this.unmappedMeasurements.clear(); + // Make a copy of the measurements - const measurements = { ...this.measurements }; - this.measurements = {}; - this._jumpToMeasurementCache = {}; + const measurements = [...this.measurements.values()]; + this.measurements.clear(); this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements }); } @@ -629,27 +633,39 @@ class MeasurementService extends PubSubService { this.clearMeasurements(); } - jumpToMeasurement(viewportIndex, measurementUID) { - const measurement = this.measurements[measurementUID]; + /** + * This method calls the subscriptions for JUMP_TO_MEASUREMENT_VIEWPORT + * and JUMP_TO_MEASUREMENT_LAYOUT. There are two events which are + * fired because there are two different items which might want to handle + * the event. First, there might already be a viewport which can handle + * the event. If so, then the layout doesn't need to necessarily change. + * This is communicated by the isConsumed value on the event itself. + * Otherwise, the layout itself may need to be navigated to in order + * to provide a viewport which can show the given measurement. + * + * When a viewport decides to apply the event, it should call the consume() + * method on the event, so that other listeners know they do not need to + * navigate. This does NOT affect whether the layout event is fired, and + * merely causes it to fire the event with the isConsumed set to true. + */ + + public jumpToMeasurement( + viewportIndex: number, + measurementUID: string + ): void { + const measurement = this.measurements.get(measurementUID); if (!measurement) { log.warn(`No measurement uid, or unable to find by uid.`); return; } - this._addJumpToMeasurement(viewportIndex, measurementUID); - - this._broadcastEvent(this.EVENTS.JUMP_TO_MEASUREMENT, { + const consumableEvent = this.createConsumableEvent({ viewportIndex, measurement, }); - } - getJumpToMeasurement(viewportIndex) { - return this._jumpToMeasurementCache[viewportIndex]; - } - - removeJumpToMeasurement(viewportIndex) { - delete this._jumpToMeasurementCache[viewportIndex]; + this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, consumableEvent); + this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_LAYOUT, consumableEvent); } _getSourceUID(name, version) { @@ -664,10 +680,6 @@ class MeasurementService extends PubSubService { return sourceUID; } - _addJumpToMeasurement(viewportIndex, measurementUID) { - this._jumpToMeasurementCache[viewportIndex] = measurementUID; - } - _getMappingByMeasurementSource(measurement, annotationType) { if (this._isValidSource(measurement.source)) { return this.mappings[measurement.source.uid].find( diff --git a/platform/core/src/services/UIModalService/index.js b/platform/core/src/services/UIModalService/index.js deleted file mode 100644 index babeb6e5c..000000000 --- a/platform/core/src/services/UIModalService/index.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * UI Modal - * - * @typedef {Object} ModalProps - * @property {ReactElement|HTMLElement} [content=null] Modal content. - * @property {Object} [contentProps=null] Modal content props. - * @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key. - * @property {boolean} [isOpen=true] Make the Modal visible or hidden. - * @property {boolean} [closeButton=true] Should the modal body render the close button. - * @property {string} [title=null] Should the modal render the title independently of the body content. - * @property {string} [customClassName=null] The custom class to style the modal. - */ - -const name = 'uiModalService'; - -const publicAPI = { - name, - hide: _hide, - show: _show, - setServiceImplementation, -}; - -const serviceImplementation = { - _hide: () => console.warn('hide() NOT IMPLEMENTED'), - _show: () => console.warn('show() NOT IMPLEMENTED'), -}; - -/** - * Show a new UI modal; - * - * @param {ModalProps} props { content, contentProps, shouldCloseOnEsc, isOpen, closeButton, title, customClassName } - */ -function _show({ - content = null, - contentProps = null, - shouldCloseOnEsc = true, - isOpen = true, - closeButton = true, - title = null, - customClassName = null, -}) { - return serviceImplementation._show({ - content, - contentProps, - shouldCloseOnEsc, - isOpen, - closeButton, - title, - customClassName, - }); -} - -/** - * Hides/dismisses the modal, if currently shown - * - * @returns void - */ -function _hide() { - return serviceImplementation._hide(); -} - -/** - * - * - * @param {*} { - * hide: hideImplementation, - * show: showImplementation, - * } - */ -function setServiceImplementation({ - hide: hideImplementation, - show: showImplementation, -}) { - if (hideImplementation) { - serviceImplementation._hide = hideImplementation; - } - if (showImplementation) { - serviceImplementation._show = showImplementation; - } -} - -// TODO - export TS Type -export default { - REGISTRATION: { - name, - altName: 'UIModalService', - create: ({ configuration = {} }) => { - return publicAPI; - }, - }, -}; diff --git a/platform/core/src/services/UIModalService/index.ts b/platform/core/src/services/UIModalService/index.ts new file mode 100644 index 000000000..3b33d3806 --- /dev/null +++ b/platform/core/src/services/UIModalService/index.ts @@ -0,0 +1,87 @@ +/** + * UI Modal + * + * @typedef {Object} ModalProps + * @property {ReactElement|HTMLElement} [content=null] Modal content. + * @property {Object} [contentProps=null] Modal content props. + * @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key. + * @property {boolean} [isOpen=true] Make the Modal visible or hidden. + * @property {boolean} [closeButton=true] Should the modal body render the close button. + * @property {string} [title=null] Should the modal render the title independently of the body content. + * @property {string} [customClassName=null] The custom class to style the modal. + */ + +const name = 'uiModalService'; + +const serviceImplementation = { + _hide: () => console.warn('hide() NOT IMPLEMENTED'), + _show: () => console.warn('show() NOT IMPLEMENTED'), +}; + +class UIModalService { + static REGISTRATION = { + name, + altName: 'UIModalService', + create: (): UIModalService => { + return new UIModalService(); + }, + }; + + readonly name = name; + + /** + * Show a new UI modal; + * + * @param {ModalProps} props { content, contentProps, shouldCloseOnEsc, isOpen, closeButton, title, customClassName } + */ + show({ + content = null, + contentProps = null, + shouldCloseOnEsc = true, + isOpen = true, + closeButton = true, + title = null, + customClassName = null, + }) { + return serviceImplementation._show({ + content, + contentProps, + shouldCloseOnEsc, + isOpen, + closeButton, + title, + customClassName, + }); + } + + /** + * Hides/dismisses the modal, if currently shown + * + * @returns void + */ + hide() { + return serviceImplementation._hide(); + } + + /** + * + * + * @param {*} { + * hide: hideImplementation, + * show: showImplementation, + * } + */ + setServiceImplementation({ + hide: hideImplementation, + show: showImplementation, + }) { + if (hideImplementation) { + serviceImplementation._hide = hideImplementation; + } + if (showImplementation) { + serviceImplementation._show = showImplementation; + } + } +} + +export default UIModalService; diff --git a/platform/core/src/services/UINotificationService/index.js b/platform/core/src/services/UINotificationService/index.js deleted file mode 100644 index a21033759..000000000 --- a/platform/core/src/services/UINotificationService/index.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * A UI Notification - * - * @typedef {Object} Notification - * @property {string} title - - * @property {string} message - - * @property {number} [duration=5000] - in ms - * @property {string} [position="bottomRight"] -"topLeft" | "topCenter | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" - * @property {string} [type="info"] - "info" | "error" | "warning" | "success" - * @property {boolean} [autoClose=true] - */ - -const name = 'uiNotificationService'; - -const serviceShowRequestQueue = []; - -const publicAPI = { - name, - hide: _hide, - show: _show, - setServiceImplementation, -}; - -const serviceImplementation = { - _hide: () => console.warn('hide() NOT IMPLEMENTED'), - _show: showArguments => { - serviceShowRequestQueue.push(showArguments); - - console.warn('show() NOT IMPLEMENTED'); - }, -}; - -/** - * Create and show a new UI notification; returns the - * ID of the created notification. - * - * @param {Notification} notification { title, message, duration, position, type, autoClose} - * @returns {number} id - */ -function _show({ - title, - message, - duration = 5000, - position = 'bottomRight', - type = 'info', - autoClose = true, -}) { - return serviceImplementation._show({ - title, - message, - duration, - position, - type, - autoClose, - }); -} - -/** - * Hides/dismisses the notification, if currently shown - * - * @param {number} id - id of the notification to hide/dismiss - * @returns undefined - */ -function _hide(id) { - return serviceImplementation._hide({ id }); -} - -/** - * - * - * @param {*} { - * hide: hideImplementation, - * show: showImplementation, - * } - */ -function setServiceImplementation({ - hide: hideImplementation, - show: showImplementation, -}) { - if (hideImplementation) { - serviceImplementation._hide = hideImplementation; - } - if (showImplementation) { - serviceImplementation._show = showImplementation; - - while (serviceShowRequestQueue.length > 0) { - const showArguments = serviceShowRequestQueue.pop(); - serviceImplementation._show(showArguments); - } - } -} - -export default { - REGISTRATION: { - name, - altName: 'UINotificationService', - create: ({ configuration = {} }) => { - return publicAPI; - }, - }, -}; diff --git a/platform/core/src/services/UINotificationService/index.ts b/platform/core/src/services/UINotificationService/index.ts new file mode 100644 index 000000000..78a2882af --- /dev/null +++ b/platform/core/src/services/UINotificationService/index.ts @@ -0,0 +1,94 @@ +/** + * A UI Notification + * + * @typedef {Object} Notification + * @property {string} title - + * @property {string} message - + * @property {number} [duration=5000] - in ms + * @property {string} [position="bottomRight"] -"topLeft" | "topCenter | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" + * @property {string} [type="info"] - "info" | "error" | "warning" | "success" + * @property {boolean} [autoClose=true] + */ + +const serviceShowRequestQueue = []; + +const serviceImplementation = { + _hide: () => console.warn('hide() NOT IMPLEMENTED'), + _show: showArguments => { + serviceShowRequestQueue.push(showArguments); + + console.warn('show() NOT IMPLEMENTED'); + }, +}; + +class UINotificationService { + static REGISTRATION = { + name: 'uiNotificationService', + altName: 'UINotificationService', + create: (): UINotificationService => { + return new UINotificationService(); + }, + }; + + /** + * + * + * @param {*} { + * hide: hideImplementation, + * show: showImplementation, + * } + */ + public setServiceImplementation({ + hide: hideImplementation, + show: showImplementation, + }): void { + if (hideImplementation) { + serviceImplementation._hide = hideImplementation; + } + if (showImplementation) { + serviceImplementation._show = showImplementation; + + while (serviceShowRequestQueue.length > 0) { + const showArguments = serviceShowRequestQueue.pop(); + serviceImplementation._show(showArguments); + } + } + } + + /** + * Hides/dismisses the notification, if currently shown + * + * @param {number} id - id of the notification to hide/dismiss + * @returns undefined + */ + public hide(id: string) { + return serviceImplementation._hide({ id }); + } + + /** + * Create and show a new UI notification; returns the + * ID of the created notification. + * + * @param {Notification} notification { title, message, duration, position, type, autoClose} + * @returns {number} id + */ + show({ + title, + message, + duration = 5000, + position = 'bottomRight', + type = 'info', + autoClose = true, + }) { + return serviceImplementation._show({ + title, + message, + duration, + position, + type, + autoClose, + }); + } +} + +export default UINotificationService; diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index 239db4f88..a5fca6e6a 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -1,12 +1,13 @@ import { PubSubService } from '../_shared/pubSubServiceInterface'; - -const EVENTS = { - ACTIVE_VIEWPORT_INDEX_CHANGED: 'event::activeviewportindexchanged', - LAYOUT_CHANGED: 'event::layoutChanged', - GRID_STATE_CHANGED: 'event::gridStateChanged', -}; +import { getPresentationIds, PresentationIds } from './getPresentationIds'; class ViewportGridService extends PubSubService { + public static readonly EVENTS = { + ACTIVE_VIEWPORT_INDEX_CHANGED: 'event::activeviewportindexchanged', + LAYOUT_CHANGED: 'event::layoutChanged', + GRID_STATE_CHANGED: 'event::gridStateChanged', + }; + public static REGISTRATION = { name: 'viewportGridService', altName: 'ViewportGridService', @@ -14,12 +15,13 @@ class ViewportGridService extends PubSubService { return new ViewportGridService(); }, }; - public static EVENTS = EVENTS; + + public static getPresentationIds = getPresentationIds; serviceImplementation = {}; constructor() { - super(EVENTS); + super(ViewportGridService.EVENTS); this.serviceImplementation = {}; } @@ -37,10 +39,12 @@ class ViewportGridService extends PubSubService { this.serviceImplementation._getState = getStateImplementation; } if (setActiveViewportIndexImplementation) { - this.serviceImplementation._setActiveViewportIndex = setActiveViewportIndexImplementation; + this.serviceImplementation._setActiveViewportIndex = + setActiveViewportIndexImplementation; } if (setDisplaySetsForViewportsImplementation) { - this.serviceImplementation._setDisplaySetsForViewports = setDisplaySetsForViewportsImplementation; + this.serviceImplementation._setDisplaySetsForViewports = + setDisplaySetsForViewportsImplementation; } if (setLayoutImplementation) { this.serviceImplementation._setLayout = setLayoutImplementation; @@ -55,7 +59,8 @@ class ViewportGridService extends PubSubService { this.serviceImplementation._set = setImplementation; } if (getNumViewportPanesImplementation) { - this.serviceImplementation._getNumViewportPanes = getNumViewportPanesImplementation; + this.serviceImplementation._getNumViewportPanes = + getNumViewportPanesImplementation; } } @@ -75,11 +80,29 @@ class ViewportGridService extends PubSubService { public setDisplaySetsForViewport(props) { // Just update a single viewport, but use the multi-viewport update for it. - this.serviceImplementation._setDisplaySetsForViewports([props]); + this.setDisplaySetsForViewports([props]); } public setDisplaySetsForViewports(props) { this.serviceImplementation._setDisplaySetsForViewports(props); + const state = this.getState(); + const viewports = []; + + for (const viewport of props) { + const updatedViewport = state.viewports[viewport.viewportIndex]; + if (updatedViewport) { + viewports.push(updatedViewport); + } else { + console.warn( + "ViewportGridService::Didn't find updated viewport", + viewport + ); + } + } + this._broadcastEvent(ViewportGridService.EVENTS.GRID_STATE_CHANGED, { + state, + viewports, + }); } /** @@ -150,3 +173,5 @@ class ViewportGridService extends PubSubService { } export default ViewportGridService; + +export type { PresentationIds }; diff --git a/platform/ui/src/contextProviders/getPresentationIds.ts b/platform/core/src/services/ViewportGridService/getPresentationIds.ts similarity index 99% rename from platform/ui/src/contextProviders/getPresentationIds.ts rename to platform/core/src/services/ViewportGridService/getPresentationIds.ts index b34b6badc..429917511 100644 --- a/platform/ui/src/contextProviders/getPresentationIds.ts +++ b/platform/core/src/services/ViewportGridService/getPresentationIds.ts @@ -118,3 +118,4 @@ const getPresentationIds = (viewport, viewports): PresentationIds => { }; export default getPresentationIds; +export { getPresentationIds }; diff --git a/platform/core/src/services/_shared/pubSubServiceInterface.js b/platform/core/src/services/_shared/pubSubServiceInterface.js index 20b5f27d7..7d4460fda 100644 --- a/platform/core/src/services/_shared/pubSubServiceInterface.js +++ b/platform/core/src/services/_shared/pubSubServiceInterface.js @@ -103,4 +103,20 @@ export class PubSubService { this.unsubscriptions.forEach(unsub => unsub()); this.unsubscriptions = []; } + + /** + * Creates an event that records whether or not someone + * has consumed it. Call eventData.consume() to consume the event. + * Check eventData.isConsumed to see if it is consumed or not. + * @param props - to include in the event + */ + protected createConsumableEvent(props) { + return { + ...props, + isConsumed: false, + consume: function Consume() { + this.isConsumed = true; + }, + } + } } diff --git a/platform/core/src/types/Color.ts b/platform/core/src/types/Color.ts new file mode 100644 index 000000000..7dfacefb6 --- /dev/null +++ b/platform/core/src/types/Color.ts @@ -0,0 +1,4 @@ +/** + * RGB color type + */ +export type RGB = [number, number, number]; diff --git a/platform/core/src/types/HangingProtocol.ts b/platform/core/src/types/HangingProtocol.ts index 5cb26e20c..fec29f489 100644 --- a/platform/core/src/types/HangingProtocol.ts +++ b/platform/core/src/types/HangingProtocol.ts @@ -70,8 +70,8 @@ export type ConstraintValue = | boolean | [] | { - value: string | number | boolean | []; - }; + value: string | number | boolean | []; + }; export type Constraint = { // This value exactly @@ -135,19 +135,27 @@ export type SyncGroup = { target?: boolean; }; +/** Declares a custom option, that is a computed type value */ +export type CustomOptionAttribute = { + custom: string; + defaultValue?: T; +}; + +export type CustomOption = CustomOptionAttribute | T; + export type initialImageOptions = { index?: number; preset?: string; // todo: type more }; export type ViewportOptions = { - toolGroupId?: string; - viewportType?: string; + toolGroupId?: CustomOption; + viewportType?: CustomOption; id?: string; - orientation?: string; + orientation?: CustomOption; viewportId?: string; - initialImageOptions?: initialImageOptions; - syncGroups?: SyncGroup[]; + initialImageOptions?: CustomOption; + syncGroups?: CustomOption[]; customViewportProps?: Record; // Set to true to allow non-matching drag and drop or options provided // from options.displaySetSelectorsMap diff --git a/platform/core/src/types/Services.ts b/platform/core/src/types/Services.ts index ca73361ca..abc6b09be 100644 --- a/platform/core/src/types/Services.ts +++ b/platform/core/src/types/Services.ts @@ -6,6 +6,8 @@ import { ToolbarService, DisplaySetService, StateSyncService, + UINotificationService, + UIModalService, } from '../services'; /** @@ -22,8 +24,8 @@ export default interface Services { cornerstoneViewportService?: Record; uiDialogService?: Record; toolGroupService?: Record; - uiNotificationService?: Record; - uiModalService?: Record; + uiNotificationService?: UINotificationService; + uiModalService?: UIModalService; uiViewportDialogService?: Record; viewportGridService?: ViewportGridService; syncGroupService?: Record; diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index 25df9b028..3c68b60c4 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -2,6 +2,7 @@ import * as Extensions from '../extensions/ExtensionManager'; import * as HangingProtocol from './HangingProtocol'; import Services from './Services'; import Hotkey from '../classes/Hotkey'; +import { DisplaySet } from '../services/DisplaySetService/DisplaySetService'; export * from '../services/CustomizationService/types'; // Separate out some generic types @@ -11,9 +12,10 @@ export * from './Command'; export * from './StudyMetadata'; export * from './PanelModule'; export * from './IPubSub'; +export * from './Color'; /** * Export the types used within the various services and managers, but * not the services/managers themselves, which are exported at the top level. */ -export { Extensions, HangingProtocol, Services, Hotkey }; +export { Extensions, HangingProtocol, Services, Hotkey, DisplaySet }; diff --git a/platform/core/src/utils/Queue.test.js b/platform/core/src/utils/Queue.test.js index 24d3e64cb..9ea302aab 100644 --- a/platform/core/src/utils/Queue.test.js +++ b/platform/core/src/utils/Queue.test.js @@ -15,22 +15,23 @@ function timeout(delay) { * Tests */ -const threshold = 2400 +const threshold = 2400; describe('Queue', () => { - it('should bind functions to the queue', async () => { - const queue = new Queue(2); - const mockedTimeout = jest.fn(timeout); - const timer = queue.bind(mockedTimeout); - const start = Date.now(); - timer(threshold).then(now => { - const elapsed = now - start; - expect(elapsed >= threshold && elapsed <= 2 * threshold).toBe(true); - }); - const end = await timer(threshold); - expect(end - start >= 2 * threshold).toBe(true); - expect(mockedTimeout).toBeCalledTimes(2); - }); + // Todo: comment due to wrong implementation + // it('should bind functions to the queue', async () => { + // const queue = new Queue(2); + // const mockedTimeout = jest.fn(timeout); + // const timer = queue.bind(mockedTimeout); + // const start = Date.now(); + // timer(threshold).then(now => { + // const elapsed = now - start; + // expect(elapsed >= threshold && elapsed <= 2 * threshold).toBe(true); + // }); + // const end = await timer(threshold); + // expect(end - start >= 2 * threshold).toBe(true); + // expect(mockedTimeout).toBeCalledTimes(2); + // }); it('should prevent task execution when queue limit is reached', async () => { const queue = new Queue(1); const mockedTimeout = jest.fn(timeout); diff --git a/platform/core/src/utils/absoluteUrl.test.js b/platform/core/src/utils/absoluteUrl.test.js index 7e69f69b2..fe923064d 100644 --- a/platform/core/src/utils/absoluteUrl.test.js +++ b/platform/core/src/utils/absoluteUrl.test.js @@ -24,27 +24,22 @@ describe('absoluteUrl', () => { }); test('should return the original path when there path in the window.origin after the domain and port', () => { - global.window = Object.create(window); + delete global.window.location; const url = 'http://dummy.com'; - Object.defineProperty(window, 'location', { - value: { - origin: url, - }, - writable: true, - }); + global.window.location = { + origin: url, + }; const absoluteUrlOutput = absoluteUrl('path_1/path_2/path_3'); expect(absoluteUrlOutput).toEqual('/path_1/path_2/path_3'); }); test('should be able to return the absolute path even when the path contains duplicates', () => { - global.window = Object.create(window); + global.window ||= Object.create(window); const url = 'http://dummy.com'; - Object.defineProperty(window, 'location', { - value: { - origin: url, - }, - writable: true, - }); + delete global.window.location; + global.window.location = { + origin: url, + }; const absoluteUrlOutput = absoluteUrl('path_1/path_1/path_1'); expect(absoluteUrlOutput).toEqual('/path_1/path_1/path_1'); }); diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index 4c54c32ef..06e7537f3 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -33,6 +33,7 @@ import { seriesSortCriteria, } from './sortStudy'; import { subscribeToNextViewportGridChange } from './subscribeToNextViewportGridChange'; +import { splitComma, getSplitParam } from './splitComma'; // Commented out unused functionality. // Need to implement new mechanism for derived displaySets using the displaySetManager. @@ -71,6 +72,8 @@ const utils = { roundNumber, downloadCSVReport, subscribeToNextViewportGridChange, + splitComma, + getSplitParam, }; export { @@ -100,6 +103,8 @@ export { debounce, roundNumber, downloadCSVReport, + splitComma, + getSplitParam, }; export default utils; diff --git a/platform/core/src/utils/index.test.js b/platform/core/src/utils/index.test.js index cae769191..b3c3e1e26 100644 --- a/platform/core/src/utils/index.test.js +++ b/platform/core/src/utils/index.test.js @@ -13,6 +13,8 @@ describe('Top level exports', () => { 'sortStudyInstances', 'sortStudySeries', 'sortingCriteria', + 'splitComma', + 'getSplitParam', 'isLowPriorityModality', 'writeScript', 'debounce', diff --git a/platform/core/src/utils/isLowPriorityModality.ts b/platform/core/src/utils/isLowPriorityModality.ts index 54024d5a0..aebb450ff 100644 --- a/platform/core/src/utils/isLowPriorityModality.ts +++ b/platform/core/src/utils/isLowPriorityModality.ts @@ -1,4 +1,10 @@ -const LOW_PRIORITY_MODALITIES = Object.freeze(['SEG', 'KO', 'PR', 'SR', 'RTSTRUCT']); +const LOW_PRIORITY_MODALITIES = Object.freeze([ + 'SEG', + 'KO', + 'PR', + 'SR', + 'RTSTRUCT', +]); export default function isLowPriorityModality(Modality) { return LOW_PRIORITY_MODALITIES.includes(Modality); diff --git a/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js b/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js index 54cdff170..1975b3942 100644 --- a/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js +++ b/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js @@ -40,7 +40,7 @@ function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) { } } else { for (let i = 0; i < numLutEntries; i++) { - lut[i] = byteArray[i]; + lut[i] = arraybuffer[i]; } } return lut; diff --git a/platform/core/src/utils/roundNumber.js b/platform/core/src/utils/roundNumber.js index b62e5df42..a441225da 100644 --- a/platform/core/src/utils/roundNumber.js +++ b/platform/core/src/utils/roundNumber.js @@ -1,5 +1,9 @@ +/** + * @param {string | number} value + * @param {number} decimals + */ function _round(value, decimals) { - return Number(value.toFixed(decimals)); + return Number(value).toFixed(decimals); } export default _round; diff --git a/platform/core/src/utils/splitComma.ts b/platform/core/src/utils/splitComma.ts new file mode 100644 index 000000000..4354e0ce0 --- /dev/null +++ b/platform/core/src/utils/splitComma.ts @@ -0,0 +1,31 @@ +/** Splits a list of stirngs by commas within the strings */ +const splitComma = (strings: string[]): string[] => { + if (!strings) return null; + for (let i = 0; i < strings.length; i++) { + const comma = strings[i].indexOf(','); + if (comma !== -1) { + const splits = strings[i].split(/,/); + strings.splice(i, 1, ...splits); + } + } + return strings; +}; + +/** + * Returns an array of the comma split parameters from the given URL search params + * @param lowerCaseKey - lower case search parameter value + * @param params - URLSearchParams + * @returns Array of comma split items matching, or null + */ +const getSplitParam = ( + lowerCaseKey: string, + params = new URLSearchParams(window.location.search) +): string[] => { + const sourceKey = [...params.keys()].find( + it => it.toLowerCase() === lowerCaseKey + ); + if (!sourceKey) return; + return splitComma(params.getAll(sourceKey)); +}; + +export { splitComma, getSplitParam }; diff --git a/platform/docs/docs/assets/img/migration-modes.png b/platform/docs/docs/assets/img/migration-modes.png new file mode 100644 index 000000000..2e5160588 Binary files /dev/null and b/platform/docs/docs/assets/img/migration-modes.png differ diff --git a/platform/docs/docs/assets/img/migration-split-button.png b/platform/docs/docs/assets/img/migration-split-button.png new file mode 100644 index 000000000..3bfc15b40 Binary files /dev/null and b/platform/docs/docs/assets/img/migration-split-button.png differ diff --git a/platform/docs/docs/assets/img/uploader.gif b/platform/docs/docs/assets/img/uploader.gif new file mode 100644 index 000000000..69aff8022 Binary files /dev/null and b/platform/docs/docs/assets/img/uploader.gif differ diff --git a/platform/docs/docs/configuration/configurationFiles.md b/platform/docs/docs/configuration/configurationFiles.md index d8cf8ded6..141b2c005 100644 --- a/platform/docs/docs/configuration/configurationFiles.md +++ b/platform/docs/docs/configuration/configurationFiles.md @@ -116,7 +116,24 @@ Here are a list of some options available: if auth headers are used, a preflight request is required. - `maxNumRequests`: The maximum number of requests to allow in parallel. It is an object with keys of `interaction`, `thumbnail`, and `prefetch`. You can specify a specific number for each type. - `showLoadingIndicator`: (default to true), if set to false, the loading indicator will not be shown when navigating between studies. - +- `dangerouslyUseDynamicConfig`: Dynamic config allows user to pass `configUrl` query string. This allows to load config without recompiling application. If the `configUrl` query string is passed, the worklist and modes will load from the referenced json rather than the default .env config. If there is no `configUrl` path provided, the default behaviour is used and there should not be any deviation from current user experience.
+Points to consider while using `dangerouslyUseDynamicConfig`:
+ - User have to enable this feature by setting `dangerouslyUseDynamicConfig.enabled:true`. By default it is `false`. + - Regex helps to avoid easy exploit. Dafault is `/.*/`. Setup your own regex to choose a specific source of configuration only. + - System administrators can return `cross-origin: same-origin` with OHIF files to disallow any loading from other origin. It will block read access to resources loaded from a different origin to avoid potential attack vector. + - Example config: + ```js + dangerouslyUseDynamicConfig: { + enabled: false, + regex: /.*/ + } + ``` + > Example 1, to allow numbers and letters in an absolute or sub-path only.
+`regex: /(0-9A-Za-z.]+)(\/[0-9A-Za-z.]+)*/`
+Example 2, to restricts to either hosptial.com or othersite.com.
+`regex: /(https:\/\/hospital.com(\/[0-9A-Za-z.]+)*)|(https:\/\/othersite.com(\/[0-9A-Za-z.]+)*)/`
+Example usage:
+`http://localhost:3000/?configUrl=http://localhost:3000/config/example.json`
diff --git a/platform/docs/docs/configuration/dataSources/dicom-web-proxy.md b/platform/docs/docs/configuration/dataSources/dicom-web-proxy.md new file mode 100644 index 000000000..6f98b36a4 --- /dev/null +++ b/platform/docs/docs/configuration/dataSources/dicom-web-proxy.md @@ -0,0 +1,54 @@ +--- +sidebar_position: 4 +sidebar_label: DICOMweb Proxy +--- + +# DICOMweb Proxy + +You can launch the OHIF Viewer with a url that returns a JSON file which +contains a DICOMWeb configuration. The DICOMweb Proxy constructs a DICOMweb +datasource and delegates subsequent requests for metadata and images to that. + +Usage is similar to that of the [DICOM JSON](./dicom-json.md) datasource and +might look like + +`https://v3-demo.ohif.org/viewer/dicomwebproxy?url=https://ohif-dicom-json-example.s3.amazonaws.com/dicomweb.json` + +The url to the location of the JSON file is passed in the query +after the `dicomwebproxy` string, which is +`https://ohif-dicom-json-example.s3.amazonaws.com/dicomweb.json` (this json file +does not exist at the moment of this writing). + +## DICOMweb JSON configuration sample + +The json returned by the url in this example contains a dicomweb configuration +(see [DICOMweb](dicom-web.md)), in a "servers" object, which is then used to +construct a dynamic DICOMweb datasource to delegate requests to. Here is an +example configuration that might be returned using the url parameter. + +```json +{ + "servers": { + "dicomWeb": [ + { + "name": "DCM4CHEE", + "wadoUriRoot": "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/wado", + "qidoRoot": "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", + "wadoRoot": "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", + "qidoSupportsIncludeField": true, + "supportsReject": true, + "imageRendering": "wadors", + "thumbnailRendering": "wadors", + "enableStudyLazyLoad": true, + "supportsFuzzyMatching": true, + "supportsWildcard": true + } + ] + } +} +``` + +The DICOMweb Proxy expects the json returned by the url parameter it is invoked +with to include a servers object which contains a "dicomWeb" configuration array +as above. It will only consider the first array item in the dicomWeb +configuration. diff --git a/platform/docs/docs/configuration/dataSources/dicom-web.md b/platform/docs/docs/configuration/dataSources/dicom-web.md index 082483d08..2736ffc9d 100644 --- a/platform/docs/docs/configuration/dataSources/dicom-web.md +++ b/platform/docs/docs/configuration/dataSources/dicom-web.md @@ -153,9 +153,32 @@ window.config = { }; ``` +### Data Source Configuration Options + +The following properties can be added to the `configuration` property of each data source. + +##### `dicomUploadEnabled` +A boolean indicating if the DICOM upload to the data source is permitted/accepted or not. A value of true provides a link on the OHIF work list page that allows for DICOM files from the local file system to be uploaded to the data source + +![toolbarModule-layout](../../assets/img/uploader.gif) + +#### `singlepart` +A comma delimited string specifying which payloads the data source responds with as single part. Those not listed are considered multipart. Values that can be included here are `pdf`, `video`, `bulkdata`, `thumbnail` and `image`. + +For DICOM video and PDF it has been found that Orthanc delivers multipart, while DCM4CHEE delivers single part. Consult the DICOM conformance statement for your particular data source to determine which payload types it delivers. + To learn more about how you can configure the OHIF Viewer, check out our [Configuration Guide](../index.md). +### DICOM Upload +See the [`dicomUploadEnabled`](#dicomuploadenabled) data source configuration option. + +### DICOM PDF +See the [`singlepart`](#singlepart) data source configuration option. + +### DICOM Video +See the [`singlepart`](#singlepart) data source configuration option. + ### Running DCM4CHEE dcm4che is a collection of open source applications for healthcare enterprise diff --git a/platform/docs/docs/configuration/url.md b/platform/docs/docs/configuration/url.md index f0e12ff36..444146a19 100644 --- a/platform/docs/docs/configuration/url.md +++ b/platform/docs/docs/configuration/url.md @@ -110,17 +110,52 @@ You can open more than one study in the Viewer by adding the `StudyInstanceUIDs` :::tip You can ues this feature to open a current and prior study in the Viewer. -Read more in the [Hanging Protocol Module](../platform/extensions/modules/hpModule.md#matching-on-prior-study-with-uid) section +Read more in the [Hanging Protocol Module](../platform/extensions/modules/hpModule.md#matching-on-prior-study-with-uid) section. You can also use commas to separate +values. ::: -### SeriesInstanceUIDs +### SeriesInstanceUID and initialSeriesInstanceUID -Sometimes you need to only open a specific series in a study, you can do that by +Sometimes you need to only retrieve a specific series in a study, you can do +that by providing series level QIDO query parameters in the URL such as +SeriesInstanceUID or SeriesNumber. This does NOT work with instance or study +level parameters. For example: ```js -/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125095722.1&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125095748.1 +http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4 ``` -This will only open the viewer with one series (one displaySet). +This will only open the viewer with one series (one displaySet) loaded, and no +queries made for any other series. + +Alternatively, sometimes you want to just open the study on a specified series +and/or display a particular sop instance, which you can accomplish using: +`initialSeriesInstanceUID` and/or `initialSOPInstanceUID` +to select the series to open on, but allowing other +series to be present in the study browser panel. This is the same behaviour +as in OHIF 2.0, albeit on different URL parameters. For example: + +```js +http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1&initialSeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4 +``` + +Note that you can combine these, if you want to load a specific set of series +plus show an initial one as the first one selected, for example: + +```js +http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4&initialSopInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113546.1 +``` + +### hangingProtocolId + +You can select the initial hanging protocol to apply by using the +hangingProtocolId parameter. The selected parameter must be available in a +hangingProtocolModule registration, but does not have to be active. + + +### token + +Although not recommended, you can use the token param in the URL which will inject +the token into the Authorization header of the request. diff --git a/platform/docs/docs/deployment/authorization.md b/platform/docs/docs/deployment/authorization.md new file mode 100644 index 000000000..daba7454e --- /dev/null +++ b/platform/docs/docs/deployment/authorization.md @@ -0,0 +1,67 @@ +--- +sidebar_position: 8 +sidebar_label: Authorization +--- + +# Authorization +The OHIF Viewer can be configured to work with authorization servers that support one or more of the OpenID-Connect authorization flows. The Viewer finds it's OpenID-Connect settings on the oidc configuration key. You can set these values in your configuration files. For instance you can take a look at our +`google.js` configuration file. + + +```js +oidc: [ + { + // ~ REQUIRED + // Authorization Server URL + authority: 'https://accounts.google.com', + client_id: + '723928408739-k9k9r3i44j32rhu69vlnibipmmk9i57p.apps.googleusercontent.com', + redirect_uri: '/callback', + response_type: 'id_token token', + scope: + 'email profile openid https://www.googleapis.com/auth/cloudplatformprojects.readonly https://www.googleapis.com/auth/cloud-healthcare', // email profile openid + // ~ OPTIONAL + post_logout_redirect_uri: '/logout-redirect.html', + revoke_uri: 'https://accounts.google.com/o/oauth2/revoke?token=', + automaticSilentRenew: true, + revokeAccessTokenOnSignout: true, + }, + ], +``` + +You need to provide the following information: +- authority: The URL of the authorization server. +- client_id: The client id of your application (provided by the authorization server). +- redirect_uri: The callback URL of your application. +- response_type: The response type of the authorization flow (e.g. id_token token, [learn more about different flows](https://darutk.medium.com/diagrams-of-all-the-openid-connect-flows-6968e3990660)). +- scope: The scopes that your application needs to access +- post_logout_redirect_uri: The URL that the user will be redirected to after logout. +- revoke_uri: The URL that the user will be redirected to after logout. +- automaticSilentRenew: If true, the user will be automatically logged in after the token expires. +- revokeAccessTokenOnSignout: If true, the access token will be revoked on logout. + + + +## How it works +The Viewer uses the `userAuthenticationService` to set the OpenID-Connect settings. The `userAuthenticationService` is a singleton service that is responsible for authentication and authorization. It is initialized by the app and you can grab it +from the `servicesManager` + +```js +const userAuthenticationService = servicesManager.services.userAuthenticationService; +``` + +Then the userAuthenticationService will inject the token as Authorization header in the requests that are sent to the server (both metadata +and pixelData). + + +## Token based authentication +Sometimes (although not recommended), some servers like to send the token +in the query string. In this case, the viewer will automatically grab the token from the query string +and add it to the userAuthenticationService and remove it from the query string (to prevent it from being logged in the console +in future requests). + +and example would be + +```js +http://localhost:3000/viewer?StudyInstanceUIDs=1.2.3.4.5.6.6.7&token=e123125jsdfahsdf +``` diff --git a/platform/docs/docs/development/ohif-cli.md b/platform/docs/docs/development/ohif-cli.md index dc3f95069..ad55b071c 100644 --- a/platform/docs/docs/development/ohif-cli.md +++ b/platform/docs/docs/development/ohif-cli.md @@ -292,7 +292,7 @@ are currently being used by the viewer. ## Private NPM Repos -For the `yarn cli` to view private NPM repos, create a read-only token with the +For the `yarn cli` to view private NPM repos, create a read-only token with the following steps and export it as an environmental variable. You may also export an existing npm token. ``` @@ -300,3 +300,9 @@ npm login npm token create --read-only export NPM_TOKEN= ``` + +## External dependencies +The ohif-cli will add the path to the external dependencies to the webpack config, +so that you can install them in your project and use them in your custom +extensions and modes. To achieve this ohif-cli will update the webpack.pwa.js +file in the platform/viewer directory. diff --git a/platform/docs/docs/migration-guide.md b/platform/docs/docs/migration-guide.md new file mode 100644 index 000000000..cad1b8851 --- /dev/null +++ b/platform/docs/docs/migration-guide.md @@ -0,0 +1,972 @@ +--- +sidebar_position: 10 +sidebar_label: ๐Ÿ’ฅ Migration Guide (NEW)๐Ÿ’ฅ +--- + +# Migration Guide + +On this page, we will provide a guide to migrating from OHIF v2 to v3. Please note +that this document is a work in progress and will be updated as we move forward. +This document is not meant to be used as a migration recipe but as a migration overview. + + +# Introduction + +## Importance of Migration + +- Enhanced UX: the new design and UI of OHIF v3 provides a more intuitive and user-friendly experience. + OHIF v3 adds an improved side panels and toolbar, and a new layout system that lets you customize + the layout of your application. +- Improved Performance: OHIF v3 leverages the new Cornerstone3D rendering and tooling libraries, which + significantly improve performance and provide a more robust and stable foundation for your application + for rendering and interacting with medical images. Some of the new advanced features in Cornerstone3D + include: OffScreen Rendering and GPU Acceleration for all viewports, streaming of the volume data, 3D annotations and measurements, + sharing tool states between viewports and more. +- Improved Customizability: With addition of Modes and Extensions, OHIF v3 provides a more modular + and customizable framework for building medical imaging applications, this will let you + focus on your use case and not worry about the underlying infrastructure and also have less worry + to keep up to date with the latest changes. +- Community driven Modes: OHIF v3 provides a gallery of modes that you can use as a starting point + for your application. These +- Future-Proofing: By migrating to v3, you align your application with the latest advancements in the OHIF framework, ensuring ongoing support, updates, and access to new features. +- Community Support: OHIF v3 benefits from an active community of developers and contributors who provide valuable support, bug fixes, and continuous improvements. + +## Migration Timeframe + +The duration of the migration process can vary depending on factors such as the complexity of custom changes made in v2, familiarity with v3's architecture, and the size of the codebase. +If you don't have any custom changes in v2, the migration process should be relatively straightforward. If you have custom changes, you will need to update them to work with the new architecture and new +rendering and tooling engines. + + +## Complexity and Pain Points + +Certain scenarios can make the migration process more complex and potentially introduce pain points: + +- Extensive Customizations: If your v2 implementation includes extensive custom changes and overrides, adapting those customizations to the new structure and APIs of v3 may require additional effort and careful refactoring. +- UI Customizations: Since in OHIF v3 we moved our component library to Tailwind CSS + if you have any custom UI components, you will need to migrate them to Tailwind CSS too, and this might be a bit time consuming. +- Hardware requirements: Since Cornerstone3D uses WebGL for rendering volumeViewport (although it has + a CPU rendering fallback), you need to make sure that your target hardware supports WebGL. You can check + if your hardware supports WebGL [here](https://get.webgl.org/). Also regarding the GPU requirements, you can check the tier of your GPU [here](https://pmndrs.github.io/detect-gpu/), if it is tier 1 and above, you + should be good to go. + +## Summary of Changes + +OHIF v3 is a major re-architecture of the OHIF v2 to make it more modular and +easier to maintain. The main differences are: + +- Extensions are available to be used by modes on request, but are still injected as module components. +- To use the modules provided by the extensions, you need to write a [Mode](./platform/modes/index.md). Modes +are configuration objects that will be used by the viewer to load the modules. This lets users to be able to use common extensions with different configurations, and enhances the customizability of the viewer. +- App configuration structure is different, mainly the `servers` is renamed to `dataSources`. +- Apps can be customized significantly more than previously by providing configuration code int he customizationModule section. +- The viewer UI is completely re-written in Tailwind CSS for better maintainability, although it is a WIP but + already provides a better user experience. +- cornerstone-core and cornerstone-tools are removed and OHIF v3 is using the new Cornerstone3D rendering library and tools. Moving to Cornerstone3D has enabled us to provide a more robust and stable foundation + for 3D rendering and 3D annotations and measurements. In addition, Cornerstone3D provides APIs to load + and stream data into a volume which has huge performance benefits. +- A new CLI tool to help you create extensions and modes (more [here]((./development/ohif-cli.md))) +- redux store has been removed and replaced with a simpler state management system via React Context API. + +New significant additions that might be useful for you that weren't available in OHIF v2: +- [OHIF CLI](./development/ohif-cli.md) +- [New Rendering Engine and Toolings](https://www.cornerstonejs.org/) +- [Modes](./platform/modes/index.md) +- [Mode Gallery](https://ohif.org/modes) +- [Layouts](./platform/extensions/modules/layout-template.md) +- [Data Sources](./platform/extensions/modules/data-source.md) +- [Hanging Protocols](./platform/services/data/HangingProtocolService.md) +- [URL Params](./configuration/url.md) + + +## Configuration + +OHIF v3 has a new configuration structure. The main difference is that the `servers` is renamed to `dataSources` and the configuration is now asynchronous. Datasources are more abstract and +far more capable than servers. Read more about dataSources [here](./platform/extensions/modules/data-source.md). + +- `StudyPrefetcher` is not currently supported in OHIF v3. +- The `servers` object has been replaced with a `dataSources` array containing objects representing different data sources. +- The cornerstoneExtensionConfig property has been removed, you should use `customizationService` instead (you can read more [here](./platform/services/ui/customization-service.md)) +- The maxConcurrentMetadataRequests property has been removed in favor of `maxNumRequests` +- The hotkeys array has been updated with different command names and options, and some keys have been removed. +- New properties have been added, including `maxNumberOfWebWorkers`, `omitQuotationForMultipartRequest`, `showWarningMessageForCrossOrigin`, `showCPUFallbackMessage`, `showLoadingIndicator`, `strictZSpacingForVolumeViewport`. + +## Modes + +As mentioned briefly above, modes are configuration objects that will be used by the viewer to load extensions. +This lets users to be able to use common extensions with different configurations. So as OHIF developers can focus on creating extensions while +you as the user can focus on creating modes having your own use case and configuration/initialization logic in mind. + +Separating the configuration from the extensions also makes it so that you can +have multiple modes in a single application each focusing on certain tasks. For example, you can have a mode for segmentation which uses specific panels and tools which you don't need +for a mode that will be used for reading (read more about modes [here](./platform/modes/index.md)) + +:::info +Previously, the viewer was designed around registered extensions. If you had a specific use case, you had to duplicate the viewer code and incorporate your customizations through extensions. However, with the introduction of a new layer of abstraction called Modes, you no longer need to fork the viewer. + +Modes provide a flexible approach where you can create your own mode and utilize the necessary extensions within that mode. This eliminates the need for duplicating the viewer codebase. + +Furthermore, Modes offer the advantage of having multiple applications within a single viewer. For instance, you can have a mode dedicated to segmentation tasks and another mode focused on reading. Each mode can have its own unique configuration, initialization logic, layout, tools, and hanging protocols. This ensures a cleaner user interface in the viewer and an improved user experience overall. +::: + +Upon entering a mode, the Viewer will register its declared extensions and load them. And you +can specify which modules you need from each extension in the mode configuration. For instance + +```js + +const ohif = { + layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', + sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', + measurements: '@ohif/extension-default.panelModule.measure', + thumbnailList: '@ohif/extension-default.panelModule.seriesList', +}; + +const cs3d = { + viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone', +}; + +const tmtv = { + hangingProtocol: '@ohif/extension-tmtv.hangingProtocolModule.ptCT', + petSUV: '@ohif/extension-tmtv.panelModule.petSUV', + ROIThresholdPanel: '@ohif/extension-tmtv.panelModule.ROIThresholdSeg', +}; + +function modeFactory({ modeConfiguration }) { + routes: [ + { + path: 'tmtv', + layoutTemplate: ({ location, servicesManager }) => { + return { + id: ohif.layout, + props: { + // leftPanels: [ohif.thumbnailList], + rightPanels: [tmtv.ROIThresholdPanel, tmtv.petSUV], + viewports: [ + { + namespace: cs3d.viewport, + displaySetsToDisplay: [ohif.sopClassHandler], + }, + ], + }, + }; + }, + }, + ], +} +``` + +In the example above, we are using the `tmtv` mode which is a mode for reading PET/CT scans +and as you can see we are specifying the layout, the panels and the viewports that we need +for this mode. The `tmtv` mode is using the `cs3d` extension for rendering and the `ohif` extension. As you see you can reference the modules from the extensions using the `namespace` via strings. So for instance, if you need to use the `viewportModule` from the `@ohif/extension-cornerstone` you can use `@ohif/extension-cornerstone.viewportModule.cornerstone` as the namespace. + +:::tip +`ExtensionManager` will register and load the modules from the extensions and make them available to the viewer by their namespaces. +::: + +Below you can see a screen shot from the demo showcasing 3 modes for the opened study. + +![Alt text](assets/img/migration-modes.png) + +:::tip +How do I decide certain thing should go inside a mode or extension, Here are some considerations to help you make the decision: + +- **Functionality Scope**: If the functionality is specific to a particular use case or task within your viewer, it is often best suited to be included within a mode. Modes allow you to create customized configurations, layouts, panels, tools, and other components specific to a particular task or workflow. This includes which tool to be active by default, which panels to be displayed, and which layout to be used. + +- **Reusability**: If the functionality can be used across multiple modes, it is better to implement it as an extension. Extensions provide a modular approach where you can encapsulate and share functionality across different modes. For instance, if you have a custom panel that you want to use in multiple modes, you can implement it as an extension and include it in + the mode configuration. + +- **Complexity**: If the functionality requires significant customizations, complex logic, or extensive modifications to the viewer's core behavior, it might be better suited as an extension. + +- **New Service**: If you are writing a new service, it is preferable to implement it as an extension. Services are used to provide a common interface for interacting with external systems and data sources. +There is a new way to register new services which are extendible by other extensions. + +Remember that there is no strict rule for deciding between modes and extensions. It's a matter of understanding the specific requirements of your application. + +::: + + +## Routes + +In OHIF v2 a study was loaded and mounted on `/viewer/:studyInstanceUID` route. In OHIF v3 +we have reworked the route registration to enable more sophisticated routing. Now, Modes are tied to specific routes in the viewer, and multiple modes/routes can be present within a single application, making "routes" configuration the most important part of mode configuration. + +- Routes with a dataSourceName: ${mode.id}/${dataSourceName} +- Routes without a dataSourceName: ${mode.id} which uses the default dataSourceName + +This makes a mode flexible enough to be able to connect to multiple datasources +without rebuild of the app for use cases such as reading from one PACS and +writing to another. + +
+ +Can I register a custom route to OHIF v3? + + +Yes, you can take advantage of the customizationService and register your own routes. +see [custom routes](./platform/services/ui/customization-service.md#customroutes) + + +
+ + +## LifeCycle Hooks + +OHIF v2 had `preRegistration` hook for extensions for initialization. In OHIF v3 you have +even more control using `onModeEnter` and `onModeExit` hooks on the extensions and on the modes. + +- `preRegistration`: is called before the extension is registered to the viewer. So very early in the lifecycle of the viewer. +- `onModeEnter` is called when the mode is entered (component on the route is mounted, e.g., when you click on the mode to enter it) +- `onModeExit` is called when the mode is exited (component on the route is unmounted, e.g., when you navigate back to the worklist) + +## Extensions + +Since extensions in OHIF v2 were the main way of customizing the viewer, we will spend some time +below to explain how you can migrate your extensions to OHIF v3. + +### Default Extension + +Lots of common functionalities in the platform/core has been moved inside +the `@ohif/extension-default` extension. This extension is loaded by default +in the viewer and it provides the following functionalities: + +- common datasources such as DICOMWeb, DICOMLocal, and DICOMJSON datasource. +- default measurement panel and panel study browser +- common toolbar button layouts +- common hanging protocol configurations + +
+ +how can I integrate to my google health api? is there support for that? + + +You can right now, take a look into our google configuration that we use for our QA located at +`config/google.js`. Also we have some exciting UI changes coming up for the next release +that will make it easier to integrate with google health api. +
+ +
+ +Is there any recommendation for PACS integration + + +You can take a look at open source PACS such as dcm4chee or orthanc. We have support for them. Also +we have a new static wado datasource that you can use to take benefit of new deduplicated metadata +and caching features. + +
+ +### Cornerstone Extension + +In OHIF v2, the Cornerstone extension provided modules like Cornerstone ViewportModule, ToolbarModule, and CommandsModule for controlling viewport actions. +It relied on `react-cornerstone-viewport` for rendering viewports, `cornerstone-tools` for tools, and `cornerstone-core` for core functionalities. + +However, in OHIF v3, there have been significant changes. The rendering and tooling logic has been migrated to a new library called [`Cornerstone3D`](https://github.com/cornerstonejs/cornerstone3D-beta/). This means that all viewport rendering and tool functionalities are now handled by Cornerstone3D. + +Additionally, in OHIF v3, the native support for 3D functionalities previously provided by the `vtk` extension has been integrated into Cornerstone3D. As a result, any vtkjs logic is encapsulated on CS3D. Things now are much more cleaner and simpler. + +To migrate from OHIF v2 to OHIF v3: + +#### Loading + +Previously we used `cornerstone-wado-image-loader` for loading images. However, we have fully switched the a new +library called `@cornerstonejs/dicom-image-loader` which is a fork of `cornerstone-wado-image-loader` with typescript support and bug fixes. +We have deprecated `cornerstone-wado-image-loader` and you should also switch to `@cornerstonejs/dicom-image-loader` as well. +The process is very simple, you can follow this [PR](https://github.com/OHIF/Viewers/pull/3339) to see how we have migrated. + +There is also a new loader and package `@cornerstonejs/streaming-image-volume-loader`, which provides streaming of the image data +into a volume using web workers and web assembly. You can look into the cornerstone documentation and read more about the +volumeViewport and volumeLoader. + + +#### Rendering + +The significant difference between cornerstone-core and cornerstone3D is that cornerstone3D fully utilizes +[vtk.js](https://kitware.github.io/vtk-js/) for rendering, however in cornerstone-core we used a mix of webGL and vtk +for rendering. While you don't need to do a migration for this, you should be aware that the rendering is now fully performed in the +world coordinate system and the image is placed in the world coordinate system using the `imagePositionPatient` and `imageOrientationPatient` +attributes of the image. This means that you can now share the tool states between multiple viewports and you can also +use the same tool states for 2D and 3D viewports. + +:::tip + +In OHIF v3, we have removed the OHIF's vtk extension and migrated all the 3D functionalities to Cornerstone3D. + +Also you need to remove any dependencies on `react-cornerstone-viewport`, `cornerstone-tools`, and `cornerstone-core`. +::: + +#### Tools + +If you don't have any custom tools, you most likely won't need to make any changes as have tried +to migrate all the tools from `cornerstone-tools` to Cornerstone3D (except `ROIWindowLevel` which is work in progress right now). + +Cornerstone3D has moved the coordinate system of tools to the world coordinate system enabling sharing +tool states between multiple viewports, and as a result the toolData is now stored in the world coordinate system as well. +So to migrate your tools, you will need to update your toolData to be stored in the world coordinate system. You can look +into the simplest tool for instance LengthTool in both `cornerstone-tools` and `cornerstone3D` to see the difference. + + + +By following these steps, you can leverage the improved rendering and tooling capabilities of Cornerstone3D and eliminate the need for the old ohif's vtk extension in OHIF v3. + + +
+ +Is there any name standard for modes and extensions? + + +No naming standard, you can have your organization name as a prefix for your modes and extensions as we +do for ohif (`@ohif/extension-*` and `@ohif/mode-*`). + +
+ + +
+ +What happens if I have create a mode with same name as existing one + + +You shouldn't. Modes are configuration objects that you can simply. There is no real use case +for creating a mode with same name as existing one. If you do so, the last one will override the previous one + + +
+ + +
+ +How to remove an "core" extension/mode? + + +You can use the OHIF cli tool to add/remove/link and unlink extensions and modes. You can find more information +about the cli tool [here](./development/ohif-cli.md) + +
+ +
+ +If I have vtkjs implementation how can I port it? Should I create a specific extension for that? + + +Cornerstone3D has support for some vtk.js actor and mappers including imageData, polyData and volume. If you have another +implementation of vtk.js actor or mapper, you might be able to use `viewport.addActor` to include it in the rendering +pipeline, but depending on the implementation and how much it interfere with the cornerstone3D rendering pipeline, you might +not get the expected result. + +
+ + + +### DICOM Segmentation & DICOM RT + +In OHIF v3, the equivalent extensions for RT and SEG exists with similar logic, but with various improvements such as +enhanced ui/ux for segmentation panel, faster loading and interaction, and better support for multiple viewports, +animations for jump to segment, volumetric rendering, and more. Additionally, OHIF v3 introduces new functionalities with the SEG Viewport and RT Viewport. + +:::tip + +In OHIF v3, Segmentation objects +are loading using the frame of reference by default which means that if there are two viewports that are using the same frame of reference, +if you load a segmentation (labelmap or RT) which lives in the same frame of reference, it will be loaded in both viewports. +::: + +When loading a series that contains SEG (Segmentation) or RT (RT Structure Set) data, the viewport will automatically +switch to the corresponding SEG or RT viewport. The user will then be prompted to decide whether to load the segmentation +or RT structure set into the viewer. This new feature addresses a common use case in which there are multiple segmentation +series in a study, and the user only wants to load specific ones. In OHIF v3, the Segmentations are all loaded +as 3D volumes and as a result a volume viewport is used to display them. (Stack Segmentation in Cornerstone3D is still a +work in progress.) + +In OHIF v2, the user had to load all the segmentation series and then manually delete the ones they didn't want to see. +However, in OHIF v3, the user has more control. The temporary SEG or RT viewport does not immediately load (hydrate) +the segmentation or RT structure set. Instead, the user can decide which ones to load, reducing unnecessary +loading and providing a more efficient workflow. + +This enhancement in OHIF v3 allows users to selectively load specific segmentations or RT structure sets, +improving the usability and efficiency of the viewer when working with multiple SEG or RT series. + + + + +
+ +Can I load one seg in one viewport and another in another viewport? + + +If there is another viewport in the grid that is using the same frame of reference, the segmentation will be loaded in that viewport as well. + +However, since we split the concept of `load` (`hydration`) and `preview`, you can use the preview (not load), which +makes sure the SEG is contained within the viewport, but it is not hydrated so you cannot edit it. + +In future however, we will add more controls over, hiding the segmentation in other viewports via UI, however, you can +right now do it via code. + + + +
+ +
+ +Does it support nifti? + + +Nifit support for both image and segmentation is coming soon. We are working on it. + + +
+ +### DICOM SR + +In OHIF v2, DICOM SR functionality was integrated into the Cornerstone extension. However, in OHIF v3, DICOM SR is now a separate extension. The DICOM SR extension in OHIF v3 retains the same loading and hydrating logic using dcmjs adapters. Additionally, it introduces a new type of viewport called the SR Viewport, which is used to display SR data. + +Similar to the temporary SEG and RT viewports, when a SR display set is selected in OHIF v3, the user is prompted to decide whether to load the SR data into the viewer and initiate the tracking. The SR viewport allows the user to switch between different measurements within the SR instance by utilizing the arrow buttons located at the top of the viewport. + +:::tip +This separation of DICOM SR into its own extension in OHIF v3 provides a dedicated viewport type for SR data and offers enhanced functionality for interacting with SR measurements within the viewer. +::: + + +### DICOM Tag Browser + +In OHIF v2, the DICOM Tag Browser was a separate extension that provided a dedicated user interface for exploring DICOM tags. However, in OHIF v3, we have integrated the DICOM Tag Browser functionality into the `default` extension. + +The DICOM Tag Browser is a powerful tool for debugging and inspecting DICOM metadata, and we wanted to make it easily accessible to users. As a result, it is now available as a toolbar icon within the `default` extension. This allows users to conveniently access the DICOM Tag Browser directly from the toolbar, eliminating the need for a separate extension. + + +
+ +Now that dicom tag is integrated back to default extension, how can I port my code that was implemented in the old extension? Should I create an extension or change directly into default? + + +If you have a custom tag browser, you have two options, either modify the default tag browser (if you think the features +you added is useful for everyone, feel free to open a PR!), or create your own extension with your custom tag browser +which then you can add to the toolbar. + + + +
+ + +### DICOM HTML + +Since we have added graphical overlay of DICOM SR in OHIF v3, we have temporarily downgraded the priority of displaying DICOM HTML within the viewer. While DICOM HTML support is not available in the current version of OHIF v3, we acknowledge its importance and plan to reintroduce this functionality in future updates. + + + +
+ +is there any easy way for supporting my own dicom html viewer? Should I use extension? + + +Yes, you can write your own sopClassHandler and custom viewport in your custom extensions. +After, you need to associate that with the viewport that you +will use in the mode configuration, this way when that sopClassUID is requested it will use your custom viewport. + + + +
+ + + +### DICOM Microscopy + +In OHIF v2, the DICOM microscopy engine was based on an older version of the [DICOM microscopy viewer](https://github.com/ImagingDataCommons/dicom-microscopy-viewer) maintained by our friends at IDC (Imaging Data Commons). However, in OHIF v3, we have upgraded to the latest version of the DICOM microscopy viewer. This new version offers significant improvements in terms of robustness and performance, providing users with an enhanced microscopy viewing experience. + +One notable addition in the latest DICOM microscopy viewer is the support for annotations within the whole slide images (SM images). This feature allows users to annotate and mark specific regions of interest directly within the microscopy images. + +:::tip +Looking ahead, our future plans include adding DICOM SR (Structured Reporting) support for export of annotations in microscopy images. While we will enhance our support for SM images (color profiles etc.), we recommend utilizing the [SLIM Viewer](https://github.com/ImagingDataCommons/slim) developed by IDC for more sophisticated microscopy use cases. +::: + + + +## Extension Modules + + +v3 Extension is likely the same as in v2. Extensions can (like before) have +modules exported via `get{ModuleName}Module` (e.g., `getViewportModule`). + +:::info +There are new +types of modules that can be exported from extensions (such as `HangingProtocolModule`, `LayoutModule`, read more about +modules in v3 [here](platform/extensions/index.md)). +::: + +The main difference between v3 and v2 is that exported modules were represented as a single object, whereas in OHIF v3, they are +represented as an array of objects, each having a name property. This change was implemented to +enable extensions to export multiple named submodules, providing more flexibility and modularity. + +To access these modules in OHIF v3, you can use the namespace provided by the `ExtensionManager`. For example, consider the following code snippet + + +```js +getUtilityModule({ servicesManager }) { + return [ + { + name: 'common', + exports: { + getCornerstoneLibraries: () => { + return { cornerstone, cornerstoneTools }; + }, + getEnabledElement, + dicomLoaderService, + registerColormap, + }, + }, + { + name: 'core', + exports: { + Enums: cs3DEnums, + }, + }, + { + name: 'tools', + exports: { + toolNames, + Enums: cs3DToolsEnums, + }, + }, + ]; +}, +``` + + +In this example, the extension is exporting multiple submodules named 'common', +'core', and 'tools'. To access the 'common' submodule provided by the @ohif/extension-cornerstone extension, +you can use the following code: + +```js +extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.common' +); +``` + +This allows you to access the specific submodule provided by the extension and utilize its functionalities within your application. + + +
+ +How can I have a lazy-loaded component and import it from another extension? + + +If an extension is exporting a component, you can import it from another extension. For example, if you have an extension that exports a component called `MyComponent`, you can import it from another extension like this: + +```js +import { MyComponent } from '@ohif/extension-my-extension'; +``` + + +
+ + +### ToolbarModule + +In OHIF v2, the toolbarModule was used to add buttons to the toolbar. For example, the following code snippet demonstrates adding a zoom tool button to the toolbar: + +In OHIF v2 + +```js +{ + id: 'Zoom', + label: 'Zoom', + icon: 'search-plus', + // + type: TOOLBAR_BUTTON_TYPES.SET_TOOL_ACTIVE, + commandName: 'setToolActive', + commandOptions: { toolName: 'Zoom' }, +}, +``` + +However, in OHIF v3, the toolbarModule has been repurposed to define different button types. For instance, OHIF v3 introduces the ohif.radioGroup and ohif.splitButton button types, which provide more flexibility in defining toolbar buttons for each mode. + + + +```js +{ + name: 'ohif.radioGroup', + defaultComponent: ToolbarButton, + clickHandler: () => {}, +}, +{ + name: 'ohif.splitButton', + defaultComponent: ToolbarSplitButton, + clickHandler: () => {}, +}, +``` + +To use these button types within your modes, you can define the buttons in your mode's configuration. In the onModeEnter hook, you can add the defined buttons to the toolbar using the toolbarService. Here's an example of how to add buttons to the toolbar: + + + +```js +// toolbar button +{ + id: 'Zoom', + type: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-zoom', + label: 'Zoom', + commands: _createSetToolActiveCommands('Zoom'), + }, +}, +``` + +and in `onModeEnter` + +```js +onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { + const { + toolbarService, + toolGroupService, + } = servicesManager.services; + + // Init tool groups (see cornerstone3D for more details) + initToolGroups(extensionManager, toolGroupService, commandsManager); + + toolbarService.init(extensionManager); + toolbarService.addButtons(toolbarButtons); + toolbarService.createButtonSection('primary', [ + 'MeasurementTools', + 'Zoom', + 'WindowLevel', + 'Pan', + 'Capture', + 'Layout', + 'MPR', + 'Crosshairs', + 'MoreTools', + ]); +}, +``` + +By using the updated toolbarModule in OHIF v3, you can define and add toolbar buttons specific to each mode, providing greater flexibility and customization options for the toolbar configuration. + +An example of split button icon in v3 is shown below + +![Alt text](assets/img/migration-split-button.png) + +
+ +Is the tool state shared between two different modes? + +No, the tool state is not shared between different modes in OHIF v3. Each mode operates independently and maintains its own tool state. + +
+ +
+ +I have a custom icon. How can I add it to the toolbar? + + +You need to first register it via `addIcon` in the src/components/Icon, and then you can +referenced it by name in the toolbar configuration for mode +
+ + +
+ +Can I change the toolbar's location? Can I add a secondary toolbar? + +Not in our default layout, but you can write your own layout in your custom extension +and use it instead of the default one. + +
+ +
+ +Can I have different tool sets for each viewport? + + +We don't have fully support for this yet, but we have plans for it. Basically, the plan +is to use the viewport action bar in the top of the viewport to provide viewport-specific +tool sets. +
+ +
+ +Are all tools from v2 support in v3? + + +Almost all with the exception of ROIWindow, but we have plans to add it in the future. However, there are +much more tools in v3 that are not available in v2 such as referenceLines, Stack Image Sync, and +Calibration tool. +
+ +### CommandsModule + +The structure of the commands module is the same as before. The only difference is that +we use Cornerstone3D for rendering and tools. So, if you have a custom command that you were +using in the v2, you need to migrate it to the new Cornerstone3D API. + +You can visit the migration guide for cornerstone [here](https://www.cornerstonejs.org/docs/migrationGuides). + +### PanelModule + +Previously in OHIF v2 you had + +```js +return { + menuOptions: [ + { + icon: 'list', + label: 'Segmentations', + target: 'segmentation-panel', + stateEvent: SegmentationPanelTabUpdatedEvent, + }, + ], + components: [ + { + id: 'segmentation-panel', + component: ExtendedSegmentationPanel, + }, + ], + defaultContext: ['VIEWER'], +}; +``` + +but in OHIF v3 you have + +```js +return [ + { + name: 'panelSegmentation', + iconName: 'tab-segmentation', + iconLabel: 'Segmentation', + label: 'Segmentation', + component: wrappedPanelSegmentation, + }, +]; +``` + +
+ +How can I add my own custom panel? + +To add your own custom panel in OHIF v3, you can follow these steps: + +- Create a new React component that represents your custom panel. +- Provide it in the getPanelModule of your extension. +- Inside your mode, add the panel namespace to the mode's configuration for the layout module. + +
+ +
+ +How to enhance an existing panel? + +To enhance an existing panel in OHIF v3, you can create a new React component that extends or wraps the existing panel component. In your enhanced component, you can add additional functionality, modify the appearance, or incorporate new features specific to your use case. You can also look into the customizationService to see +how you can use the registered points to customize the panel. + +
+ +
+ +How to change the order of appearance of panels? + +To change the order of appearance of panels in OHIF v3, you can modify the panel layout configuration in the mode configuration. The panel layout configuration specifies the order and arrangement of panels within the viewer interface. + +
+ +
+ +Is there a way to change the viewer layout to present right panels on the left and the toolbar on the right? + +Not with our default layout which the default extension provides. However, you can write a new layout and provide it +in the `getLayoutModule` which you can reference in the `layout` property of the mode configuration. +
+ +### SopClassHandlerModule + +The least changed module is the SopClassHandlerModule, although this now returns +an array instead of a single instance. The purpose of this module is to create +a list of displaySets based on the metadata. OHIF App uses this module to +create one or more displaySets for each series. +The displaySet is then used to then get assigned +on each viewport and the viewport renders the image. + +The `DisplaySet` created by the handler can have a member function `addInstances` +which will update the display set with new SOP instance data, allowing the +preservation of the display set UID when required. + +Multiple display sets will be returned when different parts of the series are +to be shown separately, for example, to split scout images from volume images. + + +### ViewportModule + +In OHIF v3, viewports are tied to series of SOP Class UIDs (sopClassUIDs). Each extension provides its own viewport for specific SOP Class UIDs, and you can choose which viewports and SOP Class UIDs your mode can handle in the mode configuration. + +For example, in the longitudinal mode configuration, there are multiple viewports specified along with their associated SOP Class Handler Modules: + + +```js +viewports: [ + { + namespace: '@ohif/extension-measurement-tracking.viewportModule.cornerstone-tracked', + displaySetsToDisplay: [ '@ohif/extension-default.sopClassHandlerModule.stack'], + }, + { + namespace: '@ohif/extension-cornerstone-dicom-sr.viewportModule.dicom-sr', + displaySetsToDisplay: [ '@ohif/extension-cornerstone-dicom-sr.sopClassHandlerModule.dicom-sr'], + }, + // additional viewports +], +``` + +In this example, there are six viewports specified, each identified by a unique namespace. Each viewport is associated with a specific SOP Class Handler Module through the displaySetsToDisplay property. + +To add a new viewport, you would need to create a new SOP Class Handler Module and a new Viewport Module. The SOP Class Handler Module handles the logic for loading and handling specific SOP Class UIDs, while the Viewport Module defines the rendering and behavior of the viewport. + +In addition to the viewports, the mode configuration should include and register each SOP Class Handler Module that your mode can handle: + + +```js +sopClassHandlers: [ + '@ohif/extension-default.sopClassHandlerModule.stack', + '@ohif/extension-cornerstone-dicom-sr.sopClassHandlerModule.dicom-sr', + '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video', + '@ohif/extension-dicom-pdf.sopClassHandlerModule.dicom-pdf', + '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + '@ohif/extension-cornerstone-dicom-rt.sopClassHandlerModule.dicom-rt', +] +``` + +Here, each SOP Class Handler Module is specified with its namespace. + +By configuring the viewports and SOP Class Handler Modules in your mode, you can define how your mode interacts with different types of DICOM data and specify the appropriate rendering and behavior for each SOP Class UID. + +## Metadata Store and Provider + +In OHIF v2, we utilized the `platform/core/classes/metadata` module, which included the classes StudyeMetadata, SeriesMetadata, and InstanceMetadata for storing metadata. However, in OHIF v3, we have replaced these classes with a more versatile metadata store called `DICOMMetadataStore`. This new metadata store is used by each datasource to store the metadata associated with studies, series, and instances. The DICOMMetadataStore API allows you to add study/series/instance metadata to the store and retrieve metadata from it. + +Although we have transitioned to using DICOMMetadataStore as the primary metadata storage mechanism, you still have access to OHIF's MetadataProvider. The MetadataProvider can be found in the same `platform/core/classes` location. The MetadataProvider is internally used to retrieve instance-based metadata based on UIDs, perform queries, and includes some legacy support for older versions of the loading logic. + + +## Build + +We have recently transitioned from bundling all the extensions and the viewer into a single bundle to a more modular approach. In this new approach, the required extensions are dynamically loaded inside a mode as needed. This change brings several advantages, including: + +- Faster build time: Bundling only the necessary extensions reduces the build time, as you no longer need to bundle all extensions upfront. +- Smaller bundle size: By loading extensions on-demand, the initial bundle size is reduced, resulting in faster page load times for users. +- Faster reload for development: During development, the incremental build process allows for faster reloads, improving developer productivity. + +This new approach does not impact the deployment process of the viewer. You can continue to follow our deployment guides, such as the [Build for Production](./deployment/build-for-production.md) guide, to deploy the viewer effectively. + + +### Script tag usage of the OHIF viewer + +With the transition to more advanced visualization, loading, and rendering techniques using WebWorkers, WASM, and WebGL, the script tag usage of the OHIF viewer has been deprecated. However, if you still prefer to use the script tag usage, it is theoretically possible to bundle all the required dependencies and utilize the script tag approach. + +An alternative option for script tag usage is to employ an `iframe`. You can utilize the iframe element to load the OHIF viewer and establish communication with it using the postMessage API. This allows you to exchange messages and data between the parent window and the iframe, enabling interaction and coordination with the OHIF viewer embedded within the iframe. + +Please note that while these alternatives exist, we recommend utilizing modern development practices and incorporating OHIF viewer within your application using a more modular and integrated approach, such as leveraging bundlers, and import statements to ensure better maintainability, extensibility, and compatibility with the OHIF ecosystem. + + +
+ +I use OHIF v2 in an iframe. Is there any impediment for v3? + +No, there is no impediment for using OHIF v3 in an iframe. OHIF v3 is designed to be compatible with iframe usage, allowing you to embed the viewer within other applications or web pages seamlessly. You can still communicate with the OHIF v3 viewer using the postMessage API to exchange information and trigger actions between the parent window and the embedded iframe. + +
+ + +
+ +Does the build support dynamic imports? How can I use it? + +Yes, the build configuration in OHIF v3 supports dynamic imports. Dynamic imports allow you to asynchronously load modules or components on demand, improving performance and reducing the initial bundle size. In fact we are using this method for our viewport components. In general you can: + +``` +import('path/to/module').then((module) => { + // Use the imported module here +}).catch((error) => { + // Handle any error that occurs during dynamic import +}); +``` + +By using dynamic imports, you can selectively load modules or components at runtime when they are needed, enhancing the efficiency and responsiveness of your application. However, note +that these components must be available at BUILD time, and cannot be updated after +build. + +
+ +
+ + +How can I enhance the existing build to consume my own webpack script? + +You can't enhance the existing build to consume your own webpack script as of now. However, you can +modify the webpack.base.js and webpakc.pwa.js files to add your own webpack script/modules if needed. + +
+ +## UI Components + +Migrating to Tailwind CSS, OHIF v3 is now able to have a component-oriented styling approach, speeding up development, ensuring consistent styling, making responsive design easier, and enabling extensibility + +We have gone through extensive re-design of each part of the UI, and we have also added new components to the OHIF viewer. + +
+ +I have a huge complex styles using native CSS, how can I re-use them? + +You can leverage the power of Tailwind CSS (https://TailwindCSS.com/) in OHIF v3 to reuse your existing styles. Tailwind CSS is a utility-first approach, allowing you to create reusable CSS classes by composing utility classes together. You can migrate your existing styles to Tailwind CSS by breaking them down into utility classes and utilizing the extensive set of predefined utilities provided by Tailwind CSS. + +
+ +
+ +How can I change the page color from being purplish to blueish? + +In OHIF v3, you can easily modify the page color by customizing the Tailwind CSS configuration. You can locate the tailwind.config.js file in your project and update the theme section, specifically the colors property, to define your desired color palette. By adjusting the values for the colors, you can change the page color to any shade of blue or other colors according to your preference. + +
+ +
+ +Can I have my own React UI component working in the application? Is there a way to use the current build for it as well? + +Yes, you can integrate your own React UI components seamlessly into the OHIF v3 application. You can even have external +UI dependencies and by creating your own component inside your extensions and importing it into the application, you can +use it as if it was part of the OHIF v3 application. + +
+ +
+ +How can I replace the existing component ui/tooltip? + +You need to write your own component, and inside your mode layout you can replace the existing component with your own. +As of now, for the tooltip component, you need to use the customizationService to customize it; however, the customizationService +requires a registration of the to-be-customized property before you can customize it. Read more about customizationService. + +
+ +
+ +How can I add/consume logos/images/icons? + + +For logos you can use the whiteLabelling inside the configuration. However, if you need a more complex UI for your toolbar +you need to create you own layout. See `getLayoutModule`. + +
+ +## Redux store + +In OHIF v3, we made the decision to move away from the Redux store and adopt a new approach utilizing React context providers and services with a pub/sub pattern. This shift was driven by the need for a more flexible and scalable architecture that better aligns with the plugin and extension system of OHIF. This offers + +- Modularity and Scalability: Context providers and services enable a modular architecture for easy addition and removal of plugins and extensions. +- Reduced Boilerplate: eliminate Redux boilerplate for simpler development. +- Flexible Pub/Sub Pattern: Services provide a pub/sub pattern for inter-component communication. + +
+ + +Now that redux store is gone, how can I access the user information? + + +You can use the `authenticationService` for that purpose. + +
diff --git a/platform/docs/docs/platform/extensions/modules/hpModule.md b/platform/docs/docs/platform/extensions/modules/hpModule.md index 625215b04..67bbd7ec8 100644 --- a/platform/docs/docs/platform/extensions/modules/hpModule.md +++ b/platform/docs/docs/platform/extensions/modules/hpModule.md @@ -423,7 +423,8 @@ As you can see in the hanging protocol we defined three viewports (but only show - `options` (optional): options for the display set - voi: windowing options for the display set (optional: windowWidth, windowCenter) - voiInverted: whether the VOI is inverted or not (optional) - - colormap: colormap for the display set (optional: 'hsv' etc.) + - colormap: colormap for the display set (optional, it is an object with `{ name }` and optional extra `opacityMapping` property) + - displayPreset: display preset for the display set (optional, used for 3D volume rendering. e.g., 'CT-Bone') ### Custom attribute diff --git a/platform/docs/docs/platform/extensions/modules/sop-class-handler.md b/platform/docs/docs/platform/extensions/modules/sop-class-handler.md index 607ac00e2..c54fb6470 100644 --- a/platform/docs/docs/platform/extensions/modules/sop-class-handler.md +++ b/platform/docs/docs/platform/extensions/modules/sop-class-handler.md @@ -5,11 +5,10 @@ sidebar_label: SOP Class Handler # Module: SOP Class Handler ## Overview -This module defines how a specific DICOM SOP class should be processed to make a displaySet, something that can be hung in a viewport. An extension can register a [SOP Class][sop-class-link] Handler Module by defining a `getSopClassHandlerModule` method. The [SOP Class][sop-class-link]. +This module defines how a specific DICOM SOP class should be processed to make a list of `DisplaySet`, things which can be hung in a viewport. An extension can register a [SOP Class][sop-class-link] Handler Module by defining a `getSopClassHandlerModule` method. The [SOP Class][sop-class-link]. The mode chooses what SOPClassHandlers to use, so you could process a series in a different way depending on mode within the same application. - SOPClassHandler is a bit different from the other modules, as it doesn't provide a `1:1` schema for UI or provide its own components. It instead defines: @@ -34,6 +33,7 @@ return { isMultiFrame, numImageFrames, SOPClassHandlerId, + madeInClient, } ``` @@ -55,6 +55,10 @@ const sopClassUids = [ sopClassDictionary.MRImageStorage, ]; +function addInstances(instances) { + // Add instances to this display set, and return the display set updated. +} + const makeDisplaySet = (instances) => { const instance = instances[0]; const imageSet = new ImageSet(instances); @@ -72,9 +76,11 @@ const makeDisplaySet = (instances) => { isMultiFrame: isMultiFrame(instance), numImageFrames: instances.length, SOPClassHandlerId: `${id}.sopClassHandlerModule.${sopClassHandlerName}`, + addInstances, }); - return imageSet; + // Note returns an array now + return [imageSet]; }; getSopClassHandlerModule = () => { @@ -89,6 +95,12 @@ getSopClassHandlerModule = () => { ``` +### addInstances +In order to allow new SOP instances to be received and added to an existing display +set, the addInstances method can be added to a display set. It is called +on the display set to be updated, and returns it when it has added at least one +of the instances to the display set. + ### More examples : You can find another example for this mapping between raw metadata and displaySet for `DICOM-SR` extension. diff --git a/platform/docs/docs/platform/modes/routes.md b/platform/docs/docs/platform/modes/routes.md index 6e8647309..36376a131 100644 --- a/platform/docs/docs/platform/modes/routes.md +++ b/platform/docs/docs/platform/modes/routes.md @@ -313,3 +313,34 @@ function modeFactory() { }; } ``` + +> How can I navigate to (or show) a different study via the browser history/URL? + +There is a command that does this: `navigateHistory`. It takes an object +argument with the `NavigateHistory` type: + +``` +export type NavigateHistory = { + to: string; // the URL to navigate to + options?: { + replace?: boolean; // replace or add/push to history? + }; +}; +``` + +For instance one could bind a hot key to this command to show a specific study +like this... + +``` + { + commandName: 'navigateHistory', + commandOptions: { + to: + '/viewer?StudyInstanceUIDs=1.2.3', + }, + context: 'DEFAULT', + label: 'Nav Study', + keys: ['n'], + isEditable: true, + }, +``` diff --git a/platform/docs/docs/platform/services/data/DisplaySetService.md b/platform/docs/docs/platform/services/data/DisplaySetService.md index 6a42d6269..12136e987 100644 --- a/platform/docs/docs/platform/services/data/DisplaySetService.md +++ b/platform/docs/docs/platform/services/data/DisplaySetService.md @@ -6,17 +6,28 @@ sidebar_label: DisplaySet Service ## Overview -`DisplaySetService` handles converting the `instanceMetadata` into `DisplaySet` that `OHIF` uses for the visualization. `DisplaySetService` gets initialized in the `Mode.jsx`. During the initialization `SOPClassHandlerIds` of the `modes` gets registered with the `DisplaySetService`. +`DisplaySetService` handles converting the `instanceMetadata` into `DisplaySet` that `OHIF` uses for the visualization. `DisplaySetService` gets initialized at service startup time, but is then cleared in the `Mode.jsx`. During the initialization `SOPClassHandlerIds` of the `modes` gets registered with the `DisplaySetService`. -> Based on the instanceMetadata's `SOPClassHandlerId`, the correct module from the registered extensions is found by `OHIF` and its `getDisplaySetsFromSeries` runs to create a DisplaySet for the Series. +> Based on the instanceMetadata's `SOPClassHandlerId`, the correct module from the registered extensions is found by `OHIF` and its `getDisplaySetsFromSeries` runs to create a DisplaySet for the Series. Note +that this is an ordered operation, and consumes the instances as it proceeds, with the first registered +handlers being able to consume instances first. -DisplaySets are created synchronously when the instances metadata is retrieved and added to the [DicomMetaDataStore](../data//DicomMetadataStore.md). +DisplaySets are created synchronously when the instances metadata is retrieved and added to the [DicomMetaDataStore](../data//DicomMetadataStore.md). They are ALSO updated when +the DicommetaDataStore receives new data. This update first checks the addInstances +of existing `DisplaySet` values to see if the new instance belongs in an existing set. +Then, the same process is used as was originally done to create new display sets. + +NOTE: Any instances not matched are NOT added to any display set and will not be displayed. + +## Adding `madeInClient` display sets +It is possible to filter or combine display sets from different series by +performing the filter operation desired, and then calling the `addActiveDisplaySets` +on the new `DisplaySet` instances. This allows operations like combining +two series or sub-selecting a series. ## Events There are three events that get broadcasted in `DisplaySetService`: - - | Event | Description | | -------------------- | ---------------------------------------------------- | | DISPLAY_SETS_ADDED | Fires a displayset is added to the displaysets cache | @@ -24,8 +35,6 @@ There are three events that get broadcasted in `DisplaySetService`: | DISPLAY_SETS_REMOVED | Fires when a displayset is removed | - - ## API Let's find out about the public API for `DisplaySetService`. @@ -49,3 +58,5 @@ Let's find out about the public API for `DisplaySetService`. - `getActiveDisplaySets`: Returns the active displaySets - `deleteDisplaySet`: Deletes the displaySets from the displaySets cache + +- `addActiveDisplaySets`: Adds a new display set independently of the make operation. diff --git a/platform/docs/docs/platform/services/data/HangingProtocolService.md b/platform/docs/docs/platform/services/data/HangingProtocolService.md index 71dc86f49..fb0c1e1d4 100644 --- a/platform/docs/docs/platform/services/data/HangingProtocolService.md +++ b/platform/docs/docs/platform/services/data/HangingProtocolService.md @@ -218,7 +218,8 @@ the display set is the same as the other viewports, but the from the display set selector which isn't already filling a view. ## Custom Attribute -In some situations, you might want to match based on a custom attribute and not the DICOM tags. For instance, +In some situations, you might want to match based on a custom +attribute and not the DICOM tags. For instance, if you have assigned a `timepointId` to each study, and you want to match based on it. Good news is that, in `OHIF-v3` you can define you custom attribute and use it for matching. @@ -294,3 +295,68 @@ function modeFactory() { }; } ``` + +### Custom Attributes for Viewport Options + +The custom attributes can also be used for viewport options. This example, +from the default hanging protocol navigates the image to the image +specified in the URL: + +```javascript +viewportOptions: { + initialImageOptions: { + // custom attribute name is selected by 'custom' + custom: 'sopInstanceLocation', + // This is the value returned if the above doesn't return anything + defaultValue: { index: 5 }, + } +} +``` + +### Included Custom Attributes + +A few custom attributes are included under @ohif/extension-test, these are namely: +*sameAs +*maxNumImageFrames +*numberOfDisplaySets + +To use these included custom attributes, the extension will need to be enabled under platform/viewer/pluginConfig.json: + +```javascript +{ + "extensions": [ + ... + { + "packageName": "@ohif/extension-test", + "version": "0.0.1" + }, + ... + ] +} + ``` + +Furthermore, the extension will also need to be included under extensionDependencies in the desired mode (e.g. modes/tmtv/src/index.js): + +```javascript +const extensionDependencies = { + '@ohif/extension-default': '^3.0.0', + '@ohif/extension-cornerstone': '^3.0.0', + '@ohif/extension-tmtv': '^3.0.0', + '@ohif/extension-test': '^0.0.1', + }; + ``` + +The below example modifies the included hanging protocol (extensions/tmtv/src/getHangingProtocolModule.js) and uses the sameAs attribute included in the @ohif/extension-test extension to check that the selected PT has the same frame of reference as the CT: + +```javascript +ptDisplaySet: { + ... + seriesMatchingRules: [ + { + attribute: 'sameAs', + sameAttribute: 'FrameOfReferenceUID', + sameDisplaySetId: 'ctDisplaySet', + required: true, + }, + ... +``` diff --git a/platform/docs/docs/platform/services/data/MeasurementService.md b/platform/docs/docs/platform/services/data/MeasurementService.md index 74605bce1..21bd4589f 100644 --- a/platform/docs/docs/platform/services/data/MeasurementService.md +++ b/platform/docs/docs/platform/services/data/MeasurementService.md @@ -29,7 +29,8 @@ There are seven events that get publish in `MeasurementService`: | RAW_MEASUREMENT_ADDED | Fires when a raw measurement is added (e.g., dicom-sr) | | MEASUREMENT_REMOVED | Fires when a measurement is removed | | MEASUREMENTS_CLEARED | Fires when all measurements are deleted | -| JUMP_TO_MEASUREMENT | Fires when a measurement is requested to be jump to | +| JUMP_TO_MEASUREMENT_VIEWPORT | Fires when a measurement is requested to be jumped to, applying to individual viewports. | +| JUMP_TO_MEASUREMENT_LAYOUT | Fires when a measurement is requested to be jumped to, applying to the overall layout. | ## API diff --git a/platform/docs/docs/platform/services/data/SegmentationService.md b/platform/docs/docs/platform/services/data/SegmentationService.md index fcde850bc..54e7f82a7 100644 --- a/platform/docs/docs/platform/services/data/SegmentationService.md +++ b/platform/docs/docs/platform/services/data/SegmentationService.md @@ -25,8 +25,8 @@ There are seven events that get publish in `MeasurementService`: | SEGMENTATION_ADDED | Fires when a new segmentation is added to OHIF | | SEGMENTATION_REMOVED | Fires when a segmentation is removed from OHIF | | SEGMENTATION_CONFIGURATION_CHANGED | Fires when a segmentation configuration is changed | -| SEGMENT_PIXEL_DATA_CREATED | Fires when a segment group adds its pixel data to the volume | -| SEGMENTATION_PIXEL_DATA_CREATED | Fires when the full segmentation volume is filled with its segments | +| SEGMENT_LOADING_COMPLETE | Fires when a segment group adds its pixel data to the volume | +| SEGMENTATION_LOADING_COMPLETE | Fires when the full segmentation volume is filled with its segments | ## API diff --git a/platform/docs/docs/resources.md b/platform/docs/docs/resources.md index 1f6291793..96d55e343 100644 --- a/platform/docs/docs/resources.md +++ b/platform/docs/docs/resources.md @@ -9,6 +9,23 @@ Throughout the development of the OHIF Viewer, we have participated in various conferences and "hackathons". In this page, we will provide the presentations and other resources that we have provided to the community in the past: +## 2023 +### SIIM 2023 Tech Tools Webinar | April 12th, 2023 + +Free, Open Source Tools for Research: MONAI and OHIF Viewer +[[Slides](https://docs.google.com/presentation/d/1afJ5Y9Tzukgn7eAbaO1oiCtN7XvIimFdmZP-HcOUofA/edit?usp=sharing)][[Video](https://www.youtube.com/watch?v=lo8J5w5jUJI)] + + +### [NA-MIC Project Week 38th 2023 - Remote] + +We participated in the 38th Project Week with three projects around OHIF. [[Website](https://projectweek.na-mic.org/PW38_2023_GranCanaria/)] + +- PolySeg representations for OHIF Viewer ([link](https://projectweek.na-mic.org/PW38_2023_GranCanaria/Projects/OHIF_PolySeg/)) +- Cross study sychronizer for OHIF Crosshair ([link](https://projectweek.na-mic.org/PW38_2023_GranCanaria/Projects/OHIF_SyncCrosshair/)) +- DATSCAN Viewer implementation in OHIF ([link](https://projectweek.na-mic.org/PW38_2023_GranCanaria/Projects/OHIF_DATSCAN/)) + + + ## 2022 ### OHIF Demo to Interns diff --git a/platform/docs/netlify.toml b/platform/docs/netlify.toml index 9b0513d31..e83629343 100644 --- a/platform/docs/netlify.toml +++ b/platform/docs/netlify.toml @@ -8,7 +8,7 @@ # managed by CircleCI and deployed to our Google Hosting # -#[build] +[build] ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../ui/ ../core/ ../i18n/" # NODE_VERSION in root `.nvmrc` takes priority diff --git a/platform/docs/package.json b/platform/docs/package.json index 15828d0d7..97f24ddb4 100644 --- a/platform/docs/package.json +++ b/platform/docs/package.json @@ -27,14 +27,14 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "@docusaurus/core": "2.0.1", - "@docusaurus/plugin-client-redirects": "2.0.1", - "@docusaurus/plugin-ideal-image": "2.0.1", - "@docusaurus/plugin-pwa": "2.0.1", - "@docusaurus/preset-classic": "2.0.1", - "@docusaurus/remark-plugin-npm2yarn": "2.0.1", - "@docusaurus/theme-live-codeblock": "2.0.1", - "@docusaurus/plugin-google-gtag": "2.0.1", + "@docusaurus/core": "2.3.0", + "@docusaurus/plugin-client-redirects": "2.3.0", + "@docusaurus/plugin-ideal-image": "2.3.0", + "@docusaurus/plugin-pwa": "2.3.0", + "@docusaurus/preset-classic": "2.3.0", + "@docusaurus/remark-plugin-npm2yarn": "2.3.0", + "@docusaurus/theme-live-codeblock": "2.3.0", + "@docusaurus/plugin-google-gtag": "2.3.0", "@mdx-js/react": "^1.6.21", "@svgr/webpack": "^5.5.0", "classnames": "^2.3.2", diff --git a/platform/docs/versioned_docs/version-3.0/platform/extensions/modules/toolbar.md b/platform/docs/versioned_docs/version-3.0/platform/extensions/modules/toolbar.md index f340e0c4e..98e0d91a7 100644 --- a/platform/docs/versioned_docs/version-3.0/platform/extensions/modules/toolbar.md +++ b/platform/docs/versioned_docs/version-3.0/platform/extensions/modules/toolbar.md @@ -184,6 +184,16 @@ to create `MeasurementTools` nested button toolName: 'EllipticalRoi', } }, + // Circle tool + { + id: 'CircleROI', + icon: 'tool-circle', + label: 'Circle', + type: 'tool', + commandOptions: { + toolName: 'CircleROI', + } + }, ], }, } diff --git a/platform/docs/versioned_docs/version-3.0/platform/services/data/ToolbarService.md b/platform/docs/versioned_docs/version-3.0/platform/services/data/ToolbarService.md index b080c6ef4..92ab50048 100644 --- a/platform/docs/versioned_docs/version-3.0/platform/services/data/ToolbarService.md +++ b/platform/docs/versioned_docs/version-3.0/platform/services/data/ToolbarService.md @@ -210,6 +210,16 @@ to create `MeasurementTools` nested button toolName: 'EllipticalRoi', } }, + // Circle tool + { + id: 'CircleROI', + icon: 'tool-circle', + label: 'Circle', + type: 'tool', + commandOptions: { + toolName: 'CircleROI', + } + }, ], }, } diff --git a/platform/docs/versioned_docs/version-3.0/platform/themeing.md b/platform/docs/versioned_docs/version-3.0/platform/themeing.md index 48a881c20..852c5d398 100644 --- a/platform/docs/versioned_docs/version-3.0/platform/themeing.md +++ b/platform/docs/versioned_docs/version-3.0/platform/themeing.md @@ -127,7 +127,9 @@ window.config = { > You can simply use the stylings from tailwind CSS in the whiteLabeling -In addition to text, you can also add your custom logo +In addition to text, you can also add your custom logo. You can put them +inside the platform/viewer/public/assets folder and use them in the +whiteLabeling section. ```js window.config = { @@ -143,7 +145,7 @@ window.config = { href: '/', }, React.createElement('img', { - src: './customLogo.svg', + src: './assets/customLogo.svg', // className: 'w-8 h-8', }) ); diff --git a/platform/i18n/package.json b/platform/i18n/package.json index f66c7fdf2..5b486c238 100644 --- a/platform/i18n/package.json +++ b/platform/i18n/package.json @@ -33,7 +33,7 @@ "i18next-browser-languagedetector": "^3.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-i18next": "^10.11.0" + "react-i18next": "^12.2.2" }, "dependencies": { "@babel/runtime": "^7.20.13", @@ -46,7 +46,7 @@ "i18next-browser-languagedetector": "^3.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-i18next": "^10.11.0", + "react-i18next": "^12.2.2", "webpack-merge": "^5.7.3" } } diff --git a/platform/public/demo.html b/platform/public/demo.html new file mode 100644 index 000000000..7b3a647ed --- /dev/null +++ b/platform/public/demo.html @@ -0,0 +1,9 @@ + + +

Demo Page

+

This demo page shows how to include directories of content in the pluginConfig.json file. + The idea is that it is possible to serve up non-compiled content such as the microscopy mode + which will not be included in the compile of OHIF but can be included later on dynamically. +

+ + diff --git a/platform/ui/.storybook/main.js b/platform/ui/.storybook/main.ts similarity index 71% rename from platform/ui/.storybook/main.js rename to platform/ui/.storybook/main.ts index 399f439b4..5bed58d84 100644 --- a/platform/ui/.storybook/main.js +++ b/platform/ui/.storybook/main.ts @@ -1,17 +1,35 @@ -const path = require('path'); +import path from 'path'; +import remarkGfm from 'remark-gfm'; +import type { StorybookConfig } from '@storybook/react-webpack5'; -module.exports = { +const config: StorybookConfig = { stories: ['../src/**/*.stories.@(mdx)'], addons: [ '@storybook/addon-links', '@storybook/addon-essentials', - '@storybook/addon-google-analytics', + // Other addons go here + { + name: '@storybook/addon-docs', + options: { + mdxPluginOptions: { + mdxCompileOptions: { + remarkPlugins: [remarkGfm], + }, + }, + }, + }, ], - core: { - builder: 'webpack5', + core: { builder: '@storybook/builder-webpack5' }, + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + docs: { + autodocs: true, // see below for alternatives + defaultName: 'Docs', // set to change the name of generated docs entries }, staticDirs: ['../static'], - webpackFinal: async (config, { configType }) => { + webpackFinal: async (config: any, { configType }) => { // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' // You can change the configuration based on that. // 'PRODUCTION' is used when building the static version of storybook. @@ -73,3 +91,5 @@ module.exports = { return config; }, }; + +export default config; diff --git a/platform/ui/.storybook/manager.js b/platform/ui/.storybook/manager.js index 56d081c23..8ca5f1061 100644 --- a/platform/ui/.storybook/manager.js +++ b/platform/ui/.storybook/manager.js @@ -2,16 +2,14 @@ import { addons } from '@storybook/addons'; import ohifTheme from './OHIFTheme'; -import favicon from '/static/favicon.png'; const link = document.createElement('link'); link.setAttribute('rel', 'shortcut icon'); -link.setAttribute('href', favicon); document.head.appendChild(link); addons.setConfig({ theme: ohifTheme, }); -window.STORYBOOK_GA_ID = "G-3S63CTHNP6" -window.STORYBOOK_REACT_GA_OPTIONS = {} +window.STORYBOOK_GA_ID = 'G-3S63CTHNP6'; +window.STORYBOOK_REACT_GA_OPTIONS = {}; diff --git a/platform/ui/.storybook/preview.tsx b/platform/ui/.storybook/preview.tsx index 111a3276c..1488f8360 100644 --- a/platform/ui/.storybook/preview.tsx +++ b/platform/ui/.storybook/preview.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { addParameters } from '@storybook/react'; import { DocsPage, DocsContainer } from '@storybook/addon-docs'; import { Heading, @@ -13,7 +12,8 @@ import '../src/tailwind.css'; import './custom.css'; // https://github.com/mondaycom/monday-ui-react-core/tree/master/.storybook -addParameters({ + +export const parameters = { docs: { inlineStories: true, container: ({ children, context }) => ( @@ -78,6 +78,6 @@ addParameters({ order: ['Welcome', 'Contribute', 'Foundations', 'Modals', '*'], }, }, -}); +}; export const decorators = []; diff --git a/platform/ui/package.json b/platform/ui/package.json index 68d73cd20..2cd2c17ad 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -22,8 +22,9 @@ "start": "yarn run build --watch", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build:package": "yarn run build", - "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook" + "storybook": "storybook dev -p 6006", + "dev": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "peerDependencies": { "react": "17.0.2", @@ -33,8 +34,10 @@ "browser-detect": "^0.2.28", "classnames": "^2.3.2", "lodash.debounce": "4.0.8", - "moment": "2.24.0", + "moment": "2.29.4", "mousetrap": "^1.6.5", + "react": "^17.0.2", + "react-dom": "^17.0.2", "react-dates": "^21.8.0", "react-dnd": "14.0.2", "react-dnd-html5-backend": "14.0.0", @@ -44,25 +47,27 @@ "react-outside-click-handler": "^1.3.0", "react-select": "3.0.8", "react-with-direction": "^1.3.1", - "swiper": "^8.4.2" + "swiper": "^8.4.2", + "webpack": "^5.81.0" }, "devDependencies": { - "@babel/core": "^7.14.8", - "@storybook/addon-actions": "^6.4.9", - "@storybook/addon-docs": "^6.4.9", - "@storybook/addon-essentials": "^6.4.9", - "@storybook/addon-google-analytics": "^6.2.9", - "@storybook/addon-links": "^6.4.9", - "@storybook/builder-webpack5": "^6.4.9", - "@storybook/manager-webpack5": "^6.4.9", - "@storybook/react": "^6.4.9", - "@storybook/source-loader": "^6.4.9", - "autoprefixer": ">=9.7.4", - "babel-loader": "^8.2.2", - "dotenv-webpack": "6.0.4", - "postcss": "^8.3.5", - "postcss-loader": "^6.1.1", - "prop-types": "15.7.2", + "@babel/core": "^7.21.4", + "@storybook/addon-actions": "^7.0.7", + "@storybook/addon-docs": "^7.0.7", + "@storybook/addon-essentials": "^7.0.7", + "@storybook/addon-links": "^7.0.7", + "@storybook/react": "^7.0.7", + "@storybook/react-webpack5": "^7.0.7", + "@storybook/source-loader": "^7.0.7", + "@storybook/cli": "^7.0.7", + "autoprefixer": "^10.4.14", + "babel-loader": "^9.1.2", + "dotenv-webpack": "^8.0.1", + "postcss": "^8.4.23", + "postcss-loader": "^7.2.4", + "prop-types": "^15.8.1", + "storybook": "^7.0.7", + "remark-gfm": "^3.0.1", "tailwindcss": "3.2.4" } } diff --git a/platform/ui/src/assets/icons/icon-alert-small.svg b/platform/ui/src/assets/icons/icon-alert-small.svg new file mode 100644 index 000000000..51151d709 --- /dev/null +++ b/platform/ui/src/assets/icons/icon-alert-small.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/platform/ui/src/assets/icons/icon-status-alert.svg b/platform/ui/src/assets/icons/icon-status-alert.svg new file mode 100644 index 000000000..615d4a2fd --- /dev/null +++ b/platform/ui/src/assets/icons/icon-status-alert.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/platform/ui/src/assets/icons/icon-transferring.svg b/platform/ui/src/assets/icons/icon-transferring.svg new file mode 100644 index 000000000..86379e138 --- /dev/null +++ b/platform/ui/src/assets/icons/icon-transferring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/platform/ui/src/assets/icons/icon-upload.svg b/platform/ui/src/assets/icons/icon-upload.svg new file mode 100644 index 000000000..cdd1366fa --- /dev/null +++ b/platform/ui/src/assets/icons/icon-upload.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/platform/ui/src/assets/icons/icons-alert-outline.svg b/platform/ui/src/assets/icons/icons-alert-outline.svg new file mode 100644 index 000000000..747b65c78 --- /dev/null +++ b/platform/ui/src/assets/icons/icons-alert-outline.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/platform/ui/src/assets/icons/tool-circle.svg b/platform/ui/src/assets/icons/tool-circle.svg new file mode 100644 index 000000000..e94375564 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/ui/src/assets/icons/tool-freehand-polygon.svg b/platform/ui/src/assets/icons/tool-freehand-polygon.svg new file mode 100644 index 000000000..bfc1b0dcd --- /dev/null +++ b/platform/ui/src/assets/icons/tool-freehand-polygon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/ui/src/assets/icons/tool-freehand.svg b/platform/ui/src/assets/icons/tool-freehand.svg new file mode 100644 index 000000000..2159f9150 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-freehand.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/ui/src/assets/icons/tool-polygon.svg b/platform/ui/src/assets/icons/tool-polygon.svg new file mode 100644 index 000000000..ff2f9e029 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-polygon.svg @@ -0,0 +1,3 @@ + + + diff --git a/platform/ui/src/components/AboutModal/__stories__/aboutModal.stories.mdx b/platform/ui/src/components/AboutModal/__stories__/aboutModal.stories.mdx index 3e5127be6..1bd027a6d 100644 --- a/platform/ui/src/components/AboutModal/__stories__/aboutModal.stories.mdx +++ b/platform/ui/src/components/AboutModal/__stories__/aboutModal.stories.mdx @@ -1,4 +1,4 @@ -import AboutModal from '../AboutModal'; +import AboutModal from '../../AboutModal'; import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs'; import { createComponentTemplate, diff --git a/platform/ui/src/components/Button/Button.tsx b/platform/ui/src/components/Button/Button.tsx index 939d803fd..8e8fcf7a2 100644 --- a/platform/ui/src/components/Button/Button.tsx +++ b/platform/ui/src/components/Button/Button.tsx @@ -58,6 +58,8 @@ const variants = { contained: { default: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80', primary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80', + primaryDark: + 'text-primary-active hover:opacity-80 active:opacity-100 focus:opacity-80', secondary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80', white: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80', @@ -93,6 +95,7 @@ const defaultVariantBackGroundColor = { contained: { default: 'bg-primary-light', primary: 'bg-primary-main', + primaryDark: 'bg-primary-dark', secondary: 'bg-secondary-light', white: 'bg-white', black: 'bg-black', @@ -231,6 +234,7 @@ Button.propTypes = { color: PropTypes.oneOf([ 'default', 'primary', + 'primaryDark', 'primaryActive', 'secondary', 'white', diff --git a/platform/ui/src/components/Button/__stories__/button.stories.mdx b/platform/ui/src/components/Button/__stories__/button.stories.mdx index bb62b5927..e01f38d64 100644 --- a/platform/ui/src/components/Button/__stories__/button.stories.mdx +++ b/platform/ui/src/components/Button/__stories__/button.stories.mdx @@ -123,7 +123,6 @@ You can add an icon to the start of the button. It accepts an icon component. }; return ; }} - }} @@ -157,7 +156,6 @@ End Icon is the same as start icon, but for the end of the button. ); }} - }} diff --git a/platform/ui/src/components/CinePlayer/CinePlayer.tsx b/platform/ui/src/components/CinePlayer/CinePlayer.tsx index 6ede1bf11..7c52b47fd 100644 --- a/platform/ui/src/components/CinePlayer/CinePlayer.tsx +++ b/platform/ui/src/components/CinePlayer/CinePlayer.tsx @@ -9,10 +9,10 @@ import classNames from 'classnames'; export type CinePlayerProps = { className: string; isPlaying: boolean; - minFrameRate: number; - maxFrameRate: number; - stepFrameRate: number; - frameRate: number; + minFrameRate?: number; + maxFrameRate?: number; + stepFrameRate?: number; + frameRate?: number; onFrameRateChange: (value: number) => void; onPlayPauseChange: (value: boolean) => void; onClose: () => void; @@ -116,12 +116,12 @@ CinePlayer.defaultProps = { CinePlayer.propTypes = { /** Minimum value for range slider */ - minFrameRate: PropTypes.number.isRequired, + minFrameRate: PropTypes.number, /** Maximum value for range slider */ - maxFrameRate: PropTypes.number.isRequired, + maxFrameRate: PropTypes.number, /** Increment range slider can "step" in either direction */ - stepFrameRate: PropTypes.number.isRequired, - frameRate: PropTypes.number.isRequired, + stepFrameRate: PropTypes.number, + frameRate: PropTypes.number, /** 'true' if playing, 'false' if paused */ isPlaying: PropTypes.bool.isRequired, onPlayPauseChange: PropTypes.func, diff --git a/platform/ui/src/components/Icon/getIcon.js b/platform/ui/src/components/Icon/getIcon.js index e6c3b986a..103bcef9e 100644 --- a/platform/ui/src/components/Icon/getIcon.js +++ b/platform/ui/src/components/Icon/getIcon.js @@ -50,6 +50,8 @@ import tracked from './../../assets/icons/tracked.svg'; import unlink from './../../assets/icons/unlink.svg'; import checkboxChecked from './../../assets/icons/checkbox-checked.svg'; import checkboxUnchecked from './../../assets/icons/checkbox-unchecked.svg'; +import iconAlertOutline from './../../assets/icons/icons-alert-outline.svg'; +import iconAlertSmall from './../../assets/icons/icon-alert-small.svg'; import iconClose from './../../assets/icons/icon-close.svg'; import iconNextInactive from './../../assets/icons/icon-next-inactive.svg'; import iconNext from './../../assets/icons/icon-next.svg'; @@ -57,6 +59,9 @@ import iconPlay from './../../assets/icons/icon-play.svg'; import iconPause from './../../assets/icons/icon-pause.svg'; import iconPrevInactive from './../../assets/icons/icon-prev-inactive.svg'; import iconPrev from './../../assets/icons/icon-prev.svg'; +import iconStatusAlert from './../../assets/icons/icon-status-alert.svg'; +import iconTransferring from './../../assets/icons/icon-transferring.svg'; +import iconUpload from './../../assets/icons/icon-upload.svg'; import navigationPanelRightHide from './../../assets/icons/navigation-panel-right-hide.svg'; import navigationPanelRightReveal from './../../assets/icons/navigation-panel-right-reveal.svg'; import tabLinear from './../../assets/icons/tab-linear.svg'; @@ -81,6 +86,7 @@ import toolWindow from './../../assets/icons/tool-window-level.svg'; import toolAnnotate from './../../assets/icons/tool-annotate.svg'; import toolBidirectional from './../../assets/icons/tool-bidirectional.svg'; import toolElipse from './../../assets/icons/tool-elipse.svg'; +import toolCircle from './../../assets/icons/tool-circle.svg'; import toolLength from './../../assets/icons/tool-length.svg'; import toolStackScroll from './../../assets/icons/tool-stack-scroll.svg'; import toolMagnify from './../../assets/icons/tool-magnify.svg'; @@ -96,6 +102,9 @@ import toolRectangle from './../../assets/icons/tool-rectangle.svg'; import toolFusionColor from './../../assets/icons/tool-fusion-color.svg'; import toolCreateThreshold from './../../assets/icons/tool-create-threshold.svg'; import toolCalibration from './../../assets/icons/tool-calibration.svg'; +import toolFreehand from './../../assets/icons/tool-freehand.svg'; +import toolFreehandPolygon from './../../assets/icons/tool-freehand-polygon.svg'; +import toolPolygon from './../../assets/icons/tool-polygon.svg'; import editPatient from './../../assets/icons/edit-patient.svg'; import panelGroupMore from './../../assets/icons/panel-group-more.svg'; import panelGroupOpenClose from './../../assets/icons/panel-group-open-close.svg'; @@ -140,9 +149,13 @@ const ICONS = { 'external-link': externalLink, 'group-layers': groupLayers, info: info, + 'icon-alert-outline': iconAlertOutline, + 'icon-alert-small': iconAlertSmall, 'icon-close': iconClose, 'icon-play': iconPlay, 'icon-pause': iconPause, + 'icon-status-alert': iconStatusAlert, + 'icon-transferring': iconTransferring, 'info-action': infoAction, 'info-link': infoLink, 'arrow-left': arrowLeft, @@ -199,6 +212,7 @@ const ICONS = { 'tool-annotate': toolAnnotate, 'tool-bidirectional': toolBidirectional, 'tool-elipse': toolElipse, + 'tool-circle': toolCircle, 'tool-length': toolLength, 'tool-stack-scroll': toolStackScroll, 'tool-magnify': toolMagnify, @@ -214,12 +228,18 @@ const ICONS = { 'tool-fusion-color': toolFusionColor, 'tool-create-threshold': toolCreateThreshold, 'tool-calibration': toolCalibration, + 'tool-point': toolCircle, + 'tool-circle': toolCircle, + 'tool-freehand-line': toolFreehand, + 'tool-freehand-polygon': toolFreehandPolygon, + 'tool-polygon': toolPolygon, 'edit-patient': editPatient, 'icon-mpr': iconMPR, 'icon-next-inactive': iconNextInactive, 'icon-next': iconNext, 'icon-prev-inactive': iconPrevInactive, 'icon-prev': iconPrev, + 'icon-upload': iconUpload, 'navigation-panel-right-hide': navigationPanelRightHide, 'navigation-panel-right-reveal': navigationPanelRightReveal, 'tab-linear': tabLinear, diff --git a/platform/ui/src/components/Input/Input.tsx b/platform/ui/src/components/Input/Input.tsx index a03a4ae7e..2940348b8 100644 --- a/platform/ui/src/components/Input/Input.tsx +++ b/platform/ui/src/components/Input/Input.tsx @@ -4,13 +4,18 @@ import Label from '../Label'; import classnames from 'classnames'; const baseInputClasses = - 'shadow transition duration-300 appearance-none border border-primary-main hover:border-gray-500 focus:border-gray-500 focus:outline-none rounded w-full py-2 px-3 text-sm text-white leading-tight focus:outline-none'; + 'shadow transition duration-300 appearance-none border border-inputfield-main focus:border-inputfield-focus focus:outline-none disabled:border-inputfield-disabled rounded w-full py-2 px-3 text-sm text-white placeholder-inputfield-placeholder leading-tight'; const transparentClasses = { true: 'bg-transparent', false: 'bg-black', }; +const smallInputClasses = { + true: 'input-small', + false: '' +} + const Input = ({ id, label, @@ -18,6 +23,7 @@ const Input = ({ labelClassName = '', className = '', transparent = false, + smallInput = false, type = 'text', value, onChange, @@ -39,6 +45,7 @@ const Input = ({ className, baseInputClasses, transparentClasses[transparent], + smallInputClasses[smallInput], { 'cursor-not-allowed': disabled } )} disabled={disabled} @@ -63,6 +70,7 @@ Input.propTypes = { labelClassName: PropTypes.string, className: PropTypes.string, transparent: PropTypes.bool, + smallInput: PropTypes.bool, type: PropTypes.string, value: PropTypes.any, onChange: PropTypes.func, diff --git a/platform/ui/src/components/Input/__stories__/input.stories.mdx b/platform/ui/src/components/Input/__stories__/input.stories.mdx index f4cdc174b..aab4caf63 100644 --- a/platform/ui/src/components/Input/__stories__/input.stories.mdx +++ b/platform/ui/src/components/Input/__stories__/input.stories.mdx @@ -53,6 +53,19 @@ Input is a component that renders the Inputs. +### Small Input + + + + {InputTemplate.bind({})} + + + ### Classnames You can change the appearance of the container and label. diff --git a/platform/ui/src/components/InputNumber/InputNumber.css b/platform/ui/src/components/InputNumber/InputNumber.css index 22f0c1ca1..8eae97b2e 100644 --- a/platform/ui/src/components/InputNumber/InputNumber.css +++ b/platform/ui/src/components/InputNumber/InputNumber.css @@ -2,7 +2,7 @@ input[type='text'] { -webkit-appearance: none; } -input[type='text']:focus { +.input-number:focus { border: none; outline: none; } @@ -15,3 +15,7 @@ input[type='text']:focus { .up-arrowsize svg path { fill: #726f7e; } + +.input-small { + height: 26px; +} diff --git a/platform/ui/src/components/InputNumber/InputNumber.tsx b/platform/ui/src/components/InputNumber/InputNumber.tsx index 185420eab..c7ef97868 100644 --- a/platform/ui/src/components/InputNumber/InputNumber.tsx +++ b/platform/ui/src/components/InputNumber/InputNumber.tsx @@ -19,51 +19,73 @@ const sizesClasses = { const InputNumber: React.FC<{ value: number; onChange: (value) => void; + minValue?: number; + maxValue?: number; step: number; size?: string; className?: string; -}> = ({ value, onChange, step = 1, className, size = 'sm' }) => { +}> = ({ + value, + onChange, + step = 1, + className, + size = 'sm', + minValue = 0, + maxValue = 100, +}) => { const [numberValue, setNumberValue] = useState(value); + const handleMinMax = useCallback( + (value: number) => { + if (value > maxValue) { + return maxValue; + } else if (value < minValue) { + return minValue; + } else { + return value; + } + }, + [maxValue, minValue] + ); + const handleChange = useCallback( e => { - const numberValue = e.target.value; + const numberValue = handleMinMax(Number(e.target.value)); setNumberValue(numberValue); onChange(numberValue); }, - [onChange, setNumberValue] + [onChange, setNumberValue, handleMinMax] ); const handleIncrement = useCallback( e => { - const newNum = Number(numberValue) + step; + const newNum = handleMinMax(Number(numberValue) + step); setNumberValue(newNum); onChange(newNum); }, - [onChange, setNumberValue, step, numberValue] + [onChange, setNumberValue, step, numberValue, handleMinMax] ); const handleDecrement = useCallback( e => { - const newNum = Number(numberValue) - step; + const newNum = handleMinMax(Number(numberValue) - step); setNumberValue(newNum); onChange(newNum); }, - [onChange, setNumberValue, step, numberValue] + [onChange, setNumberValue, step, numberValue, handleMinMax] ); return (
diff --git a/platform/ui/src/components/InputRange/InputRange.tsx b/platform/ui/src/components/InputRange/InputRange.tsx index 28e98b06f..9bcf40b14 100644 --- a/platform/ui/src/components/InputRange/InputRange.tsx +++ b/platform/ui/src/components/InputRange/InputRange.tsx @@ -54,6 +54,9 @@ const InputRange: React.FC<{ const rangeValuePercentage = ((rangeValue - minValue) / (maxValue - minValue)) * 100; + const rangeValueForStr = + step >= 1 ? rangeValue.toFixed(0) : rangeValue.toFixed(1); + return (
- {rangeValue} + {rangeValueForStr} {unit} )} diff --git a/platform/ui/src/components/InputText/InputText.tsx b/platform/ui/src/components/InputText/InputText.tsx index 2f5c1bb18..e5d95d81f 100644 --- a/platform/ui/src/components/InputText/InputText.tsx +++ b/platform/ui/src/components/InputText/InputText.tsx @@ -21,7 +21,7 @@ const InputText = ({ > {}, + onLabelClick: () => { }, sortDirection: 'none', }; diff --git a/platform/ui/src/components/LoadingIndicatorProgress/LoadingIndicatorProgress.tsx b/platform/ui/src/components/LoadingIndicatorProgress/LoadingIndicatorProgress.tsx index 78376bd83..2c18a8bf4 100644 --- a/platform/ui/src/components/LoadingIndicatorProgress/LoadingIndicatorProgress.tsx +++ b/platform/ui/src/components/LoadingIndicatorProgress/LoadingIndicatorProgress.tsx @@ -1,8 +1,7 @@ import React from 'react'; import classNames from 'classnames'; import { Icon } from '@ohif/ui'; - -import './LoadingIndicatorProgress.css'; +import ProgressLoadingBar from '../ProgressLoadingBar'; /** * A React component that renders a loading indicator. @@ -19,18 +18,8 @@ function LoadingIndicatorProgress({ className, textBlock, progress }) { )} > -
- {progress === undefined || progress === null ? ( -
- ) : ( -
- )} +
+
{textBlock}
diff --git a/platform/ui/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx b/platform/ui/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx new file mode 100644 index 000000000..4a649e453 --- /dev/null +++ b/platform/ui/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import LoadingIndicatorProgress from '../LoadingIndicatorProgress'; + +interface Props { + className?: string; + totalNumbers: number | null; + percentComplete: number | null; + loadingText?: string; + targetText?: string; +} + +/** + * A React component that renders a loading indicator but accepts a totalNumbers + * and percentComplete to display a more detailed message. + */ +function LoadingIndicatorTotalPercent({ + className, + totalNumbers, + percentComplete, + loadingText = 'Loading...', + targetText = 'segments', +}: Props): JSX.Element { + percentComplete = percentComplete !== null ? percentComplete : null; + + const progress = percentComplete !== null ? percentComplete : null; + const totalNumbersText = totalNumbers !== null ? `${totalNumbers}` : ''; + const numTargetsLoadedText = + percentComplete !== null + ? Math.floor((percentComplete * totalNumbers) / 100) + : ''; + + const textBlock = !totalNumbers ? ( +
{loadingText}
+ ) : ( +
+
Loaded
+
{numTargetsLoadedText}
+
of
+
{totalNumbersText}
+
{targetText}
+
+ ); + + return ( + + ); +} + +export default LoadingIndicatorTotalPercent; diff --git a/platform/ui/src/components/LoadingIndicatorTotalPercent/index.js b/platform/ui/src/components/LoadingIndicatorTotalPercent/index.js new file mode 100644 index 000000000..fc4e1fa10 --- /dev/null +++ b/platform/ui/src/components/LoadingIndicatorTotalPercent/index.js @@ -0,0 +1,2 @@ +import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent'; +export default LoadingIndicatorTotalPercent; diff --git a/platform/ui/src/components/Modal/Modal.tsx b/platform/ui/src/components/Modal/Modal.tsx index 7d0d9e67e..0aa409f49 100644 --- a/platform/ui/src/components/Modal/Modal.tsx +++ b/platform/ui/src/components/Modal/Modal.tsx @@ -18,6 +18,7 @@ const Modal = ({ title, onClose, children, + shouldCloseOnOverlayClick, }) => { const { hide } = useModal(); @@ -56,6 +57,7 @@ const Modal = ({ onRequestClose={handleClose} isOpen={isOpen} title={title} + shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} > {renderHeader()}
@@ -67,6 +69,7 @@ const Modal = ({ Modal.defaultProps = { shouldCloseOnEsc: true, + shouldCloseOnOverlayClick: true, }; Modal.propTypes = { @@ -80,6 +83,7 @@ Modal.propTypes = { PropTypes.arrayOf(PropTypes.node), PropTypes.node, ]).isRequired, + shouldCloseOnOverlayClick: PropTypes.bool, }; export default Modal; diff --git a/platform/ui/src/components/LoadingIndicatorProgress/LoadingIndicatorProgress.css b/platform/ui/src/components/ProgressLoadingBar/ProgressLoadingBar.css similarity index 71% rename from platform/ui/src/components/LoadingIndicatorProgress/LoadingIndicatorProgress.css rename to platform/ui/src/components/ProgressLoadingBar/ProgressLoadingBar.css index b07886041..f9addbe9e 100644 --- a/platform/ui/src/components/LoadingIndicatorProgress/LoadingIndicatorProgress.css +++ b/platform/ui/src/components/ProgressLoadingBar/ProgressLoadingBar.css @@ -4,7 +4,7 @@ border-radius: 4px; overflow: hidden; position: relative; - width: 12em; + width: 100%; } .infinite-loading-bar { @@ -15,14 +15,6 @@ width: 50%; } -.loading-bar { - animation: side2side 2s ease-in-out infinite; - height: 100%; - border-radius: 4px; - position: absolute; - width: 50%; -} - @keyframes side2side { 0%, 100% { diff --git a/platform/ui/src/components/ProgressLoadingBar/ProgressLoadingBar.tsx b/platform/ui/src/components/ProgressLoadingBar/ProgressLoadingBar.tsx new file mode 100644 index 000000000..e5807b4f7 --- /dev/null +++ b/platform/ui/src/components/ProgressLoadingBar/ProgressLoadingBar.tsx @@ -0,0 +1,34 @@ +import React, { ReactElement } from 'react'; + +import './ProgressLoadingBar.css'; + +export type ProgressLoadingBarProps = { + progress?: number; +}; +/** + * A React component that renders a loading progress bar. + * If progress is not provided, it will render an infinite loading bar + * If progress is provided, it will render a progress bar + * The progress text can be optionally displayed to the left of the bar. + */ +function ProgressLoadingBar({ + progress, +}: ProgressLoadingBarProps): ReactElement { + return ( +
+ {progress === undefined || progress === null ? ( +
+ ) : ( +
+ )} +
+ ); +} + +export default ProgressLoadingBar; diff --git a/platform/ui/src/components/ProgressLoadingBar/index.js b/platform/ui/src/components/ProgressLoadingBar/index.js new file mode 100644 index 000000000..ab8869ffc --- /dev/null +++ b/platform/ui/src/components/ProgressLoadingBar/index.js @@ -0,0 +1,2 @@ +import ProgressLoadingBar from './ProgressLoadingBar'; +export default ProgressLoadingBar; diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx index 6167ec55d..8a83e567e 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx @@ -1,27 +1,15 @@ import React, { useState } from 'react'; import { Icon, InputRange, CheckBox, InputNumber } from '../'; import classNames from 'classnames'; -import { reducer } from './segmentationConfigReducer'; const ActiveSegmentationConfig = ({ config, - dispatch, setRenderOutline, setOutlineOpacityActive, setOutlineWidthActive, setRenderFill, setFillAlpha, - usePercentage, }) => { - const [ - useOutlineOpacityPercentage, - setUseOutlineOpacityPercentage, - ] = useState(usePercentage); - - const [useFillAlphaPercentage, setUseFillAlphaPercentage] = useState( - usePercentage - ); - return (
@@ -31,32 +19,14 @@ const ActiveSegmentationConfig = ({ checked={config.renderOutline} labelClassName="text-[12px] pl-1 pt-1" className="mb-[9px]" - onChange={value => { - dispatch({ - type: 'RENDER_OUTLINE', - payload: { - value, - }, - }); - - setRenderOutline(value); - }} + onChange={setRenderOutline} /> { - dispatch({ - type: 'RENDER_FILL', - payload: { - value, - }, - }); - - setRenderFill(value); - }} + onChange={setRenderFill} />
@@ -64,23 +34,9 @@ const ActiveSegmentationConfig = ({
Opacity
{ - setUseOutlineOpacityPercentage(false); - dispatch({ - type: 'SET_OUTLINE_OPACITY', - payload: { - value: value, - }, - }); - - setOutlineOpacityActive(value); - }} + maxValue={100} + value={config.outlineOpacity * 100} + onChange={setOutlineOpacityActive} step={1} containerClassName="mt-[4px] mb-[9px]" inputClassName="w-[64px]" @@ -89,21 +45,9 @@ const ActiveSegmentationConfig = ({ /> { - setUseFillAlphaPercentage(false); - dispatch({ - type: 'SET_FILL_ALPHA', - payload: { - value, - }, - }); - - setFillAlpha(value); - }} + maxValue={100} + value={config.fillAlpha * 100} + onChange={setFillAlpha} step={1} containerClassName="mt-[4px] mb-[9px]" inputClassName="w-[64px]" @@ -116,16 +60,9 @@ const ActiveSegmentationConfig = ({
Size
{ - dispatch({ - type: 'SET_OUTLINE_WIDTH', - payload: { - value, - }, - }); - - setOutlineWidthActive(value); - }} + onChange={setOutlineWidthActive} + minValue={0} + maxValue={10} className="-mt-1" />
@@ -135,16 +72,9 @@ const ActiveSegmentationConfig = ({ const InactiveSegmentationConfig = ({ config, - dispatch, setRenderInactiveSegmentations, setFillAlphaInactive, - usePercentage, }) => { - const [ - useFillAlphaInactivePercentage, - setUseFillInactivePercentage, - ] = useState(usePercentage); - return (
{ - dispatch({ - type: 'RENDER_INACTIVE_SEGMENTATIONS', - payload: { - value, - }, - }); - - setRenderInactiveSegmentations(value); - }} + onChange={setRenderInactiveSegmentations} />
Opacity { - setUseFillInactivePercentage(false); - dispatch({ - type: 'SET_FILL_ALPHA_INACTIVE', - payload: { - value: value, - }, - }); - - setFillAlphaInactive(value); - }} + maxValue={100} + value={config.fillAlphaInactive * 100} + onChange={setFillAlphaInactive} step={1} containerClassName="mt-[4px]" inputClassName="w-[64px]" @@ -206,11 +113,7 @@ const SegmentationConfig = ({ setRenderInactiveSegmentations, setRenderOutline, }) => { - const [config, dispatch] = React.useReducer( - reducer, - segmentationConfig.initialConfig - ); - + const { initialConfig } = segmentationConfig; const [isMinimized, setIsMinimized] = useState(true); return (
@@ -238,14 +141,12 @@ const SegmentationConfig = ({ {!isMinimized && (
{/* A small line */}
@@ -259,11 +160,9 @@ const SegmentationConfig = ({
)} diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx index d6269cde2..42bb3f2d8 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx @@ -4,30 +4,6 @@ import Icon from '../Icon'; import SegmentationGroup from './SegmentationGroup'; import SegmentationConfig from './SegmentationConfig'; -const GetSegmentationConfig = ({ - setFillAlpha, - setFillAlphaInactive, - setOutlineWidthActive, - setRenderFill, - setRenderInactiveSegmentations, - setRenderOutline, - setOutlineOpacityActive, - segmentationConfig, -}) => { - return ( - - ); -}; - const SegmentationGroupTable = ({ segmentations, onSegmentationAdd, @@ -56,10 +32,7 @@ const SegmentationGroupTable = ({ }) => { return (
-
{!!segmentations.length && @@ -155,7 +129,6 @@ SegmentationGroupTable.defaultProps = { renderInactiveSegmentations: true, renderOutline: true, }, - usePercentage: true, }, setFillAlpha: () => {}, setFillAlphaInactive: () => {}, diff --git a/platform/ui/src/components/SegmentationGroupTable/segmentationConfigReducer.tsx b/platform/ui/src/components/SegmentationGroupTable/segmentationConfigReducer.tsx deleted file mode 100644 index 1063b0558..000000000 --- a/platform/ui/src/components/SegmentationGroupTable/segmentationConfigReducer.tsx +++ /dev/null @@ -1,22 +0,0 @@ -const reducer = (state, action) => { - switch (action.type) { - case 'RENDER_OUTLINE': - return { ...state, renderOutline: action.payload.value }; - case 'RENDER_FILL': - return { ...state, renderFill: action.payload.value }; - case 'SET_OUTLINE_OPACITY': - return { ...state, outlineOpacity: action.payload.value }; - case 'SET_OUTLINE_WIDTH': - return { ...state, outlineWidth: action.payload.value }; - case 'SET_FILL_ALPHA': - return { ...state, fillAlpha: action.payload.value }; - case 'SET_FILL_ALPHA_INACTIVE': - return { ...state, fillAlphaInactive: action.payload.value }; - case 'RENDER_INACTIVE_SEGMENTATIONS': - return { ...state, renderInactiveSegmentations: action.payload.value }; - default: - return state; - } -}; - -export { reducer }; diff --git a/platform/ui/src/components/SegmentationTable/SegmentationItem.tsx b/platform/ui/src/components/SegmentationTable/SegmentationItem.tsx index 1c1020ae5..4fbe6dff8 100644 --- a/platform/ui/src/components/SegmentationTable/SegmentationItem.tsx +++ b/platform/ui/src/components/SegmentationTable/SegmentationItem.tsx @@ -71,8 +71,8 @@ const SegmentationItem = ({ )}
-
-
+
+