Compare commits

..
Author SHA1 Message Date
ohif-bot 19bdcbb453 chore(version): Update package versions [skip ci] 2024-04-29 16:49:17 +00:00
ohif-bot 0386c29480 chore(version): version.json [skip ci] 2024-04-29 16:49:07 +00:00
Alireza 1dcdc9dabe feat(v3p8): advanced 3D layouts and 4d data support (#4071) 2024-04-29 12:36:12 -04:00
Alireza feaebd7f61 Merge branch 'release' of github.com:OHIF/Viewers into candidate-3p8-rc-6 2024-04-29 12:16:39 -04:00
ohif-bot df32a05a4d chore(version): Update package versions [skip ci] 2023-10-11 16:59:05 +00:00
ohif-bot e205bf5c4e chore(version): version.json [skip ci] 2023-10-11 16:58:56 +00:00
Alireza 5ddf8a1602 feat(v3p7): segmentation mode and cloud server configuration (#3720) 2023-10-11 12:49:12 -04:00
Alireza 83fccad46b Merge branch 'release' of github.com:OHIF/Viewers into candidate-3p7-rc-5 2023-10-11 12:18:10 -04:00
ohif-bot 72ec0bffb5 chore(version): Update package versions [skip ci] 2023-06-07 20:01:22 +00:00
ohif-bot 82c6c3c353 chore(version): version.json [skip ci] 2023-06-07 20:01:18 +00:00
Alireza 1d38fe30a4 feat(app): modal headers and fix storybook (#3460) 2023-06-07 15:54:08 -04:00
Alireza 666964b6d7 Merge branch 'release' of github.com:OHIF/Viewers into release-fore-newsletter 2023-06-07 13:26:13 -04:00
ohif-bot fa4ee5c559 chore(version): Update package versions [skip ci] 2023-06-07 00:14:02 +00:00
ohif-bot cd58560e9c chore(version): version.json [skip ci] 2023-06-07 00:13:59 +00:00
Alireza 16afa740b6 feat(app): change ohif viewer to ohif app (#3451) 2023-06-06 20:06:31 -04:00
Alireza 6715ad9bd6 chore(build): try to build demo and docs 2023-06-01 08:13:36 -04:00
3251 changed files with 73799 additions and 355441 deletions

No files matched your search

+305 -312
View File
@@ -1,43 +1,71 @@
version: 2.1
### ABOUT
#
# This configuration powers our Circleci.io integration
#
# Note:
# Netlify works independently from this configuration to
# create pull request previews and to update `https://docs.ohif.org`
###
## https://github.com/cypress-io/circleci-orb
##
orbs:
codecov: codecov/codecov@1.0.5
cypress: cypress-io/cypress@3.4.2
cypress: cypress-io/cypress@3.3.1
executors:
cypress-custom:
description: |
Single Docker container used to run Cypress Tests
docker:
- image: cimg/node:<< parameters.node-version >>-browsers
parameters:
node-version:
default: '18.16.1'
description: |
The version of Node to run your tests with.
type: string
defaults: &defaults
docker:
- image: cimg/node:20.19.0
- image: cimg/node:18.18
environment:
TERM: xterm
TERM: xterm # Enable colors in term
QUICK_BUILD: true
working_directory: ~/repo
commands:
install_bun:
steps:
- run:
name: Install Bun
command: |
curl -fsSL https://bun.sh/install | bash -s "bun-v1.2.23"
echo 'export BUN_INSTALL="$HOME/.bun"' >> $BASH_ENV
echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> $BASH_ENV
source $BASH_ENV
jobs:
###
# Workflow: PR_CHECKS
###
UNIT_TESTS:
<<: *defaults
resource_class: large
steps:
- install_bun
# Update yarn
- run: yarn -v
- run: node --version
# Checkout code and ALL Git Tags
- checkout
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
# when lock file changes, use increasingly general patterns to restore cache
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --no-save
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache ## Cache yarn and Cypress
key: yarn-packages-{{ checksum "yarn.lock" }}
# RUN TESTS
- run:
name: 'JavaScript Test Suite'
command: bun run test:unit:ci
command: yarn run test:unit:ci
# platform/app
- run:
name: 'VIEWER: Combine report output'
@@ -65,23 +93,35 @@ jobs:
file: '/home/circleci/repo/platform/core/coverage/reports'
flags: 'core'
###
# Workflow: DEPLOY
###
BUILD:
<<: *defaults
resource_class: large
steps:
# Checkout code and ALL Git Tags
- checkout
- install_bun
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
# when lock file changes, use increasingly general patterns to restore cache
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --no-save
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache ## Cache yarn and Cypress
key: yarn-packages-{{ checksum "yarn.lock" }}
# Build & Test
- run:
name: 'Perform the versioning before build'
command: bun ./version.mjs
command: node ./version.mjs
- run:
name: 'Build the OHIF Viewer'
command: bun run build
command: yarn run build
no_output_timeout: 45m
- run:
name: 'Upload SourceMaps, Send Deploy Notification'
@@ -103,18 +143,29 @@ jobs:
- commit.txt
- version.json
# just to make sure later on we can publish them
BUILD_PACKAGES_QUICK:
<<: *defaults
resource_class: large
steps:
- install_bun
- run: yarn -v
# Checkout code and ALL Git Tags
- checkout
- attach_workspace:
at: ~/repo
# Use increasingly general patterns to restore cache
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --frozen-lockfile
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache/yarn
key: yarn-packages-{{ checksum "yarn.lock" }}
- run:
name: Avoid hosts unknown for github
command: |
@@ -126,27 +177,44 @@ jobs:
- run:
name: Authenticate with NPM registry
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
- run:
name: Increase the event emitter limit
command: |
node ./increaseEventEmitterLimit.mjs
- run:
name: build half of the packages (to avoid out of memory in circleci)
command: |
bun run build:package-all
yarn run build:package-all
- run:
name: build the other half of the packages
command: |
bun run build:package-all-1
yarn run build:package-all-1
###
# Workflow: RELEASE
###
NPM_PUBLISH:
<<: *defaults
resource_class: large
steps:
- install_bun
- run: yarn -v
# Checkout code and ALL Git Tags
- checkout
- attach_workspace:
at: ~/repo
# Use increasingly general patterns to restore cache
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --no-save
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache/yarn
key: yarn-packages-{{ checksum "yarn.lock" }}
- run:
name: Avoid hosts unknown for github
command: |
@@ -158,14 +226,18 @@ jobs:
- run:
name: Authenticate with NPM registry
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
- run:
name: Increase the event emitter limit
command: |
node ./increaseEventEmitterLimit.mjs
- run:
name: build half of the packages (to avoid out of memory in circleci)
command: |
bun run build:package-all
yarn run build:package-all
- run:
name: build the other half of the packages
command: |
bun run build:package-all-1
yarn run build:package-all-1
- run:
name: increase min time out
command: |
@@ -177,14 +249,14 @@ jobs:
- run:
name: publish package versions
command: |
bun ./publish-version.mjs
node ./publish-version.mjs
- run:
name: Again set the NPM registry (was deleted in the version script)
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
- run:
name: publish package dist
command: |
bun ./publish-package.mjs
node ./publish-package.mjs
- persist_to_workspace:
root: ~/repo
paths:
@@ -192,14 +264,13 @@ jobs:
DOCKER_RELEASE_PUBLISH:
<<: *defaults
resource_class: large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for amd64
name: Build and push Docker image from the release branch
command: |
# This file will exist if a new version was published by
# our command in the previous job.
@@ -213,61 +284,22 @@ jobs:
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our amd64 image, auth, and push
docker build --platform linux/amd64 --tag ohif/app:$IMAGE_VERSION_FULL-amd64 --tag ohif/app:latest-amd64 .
# Build our image, auth, and push
docker build --tag ohif/app:$IMAGE_VERSION_FULL --tag ohif/app:latest .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-amd64
docker push ohif/app:latest-amd64
docker push ohif/app:$IMAGE_VERSION_FULL
docker push ohif/app:latest
fi
- persist_to_workspace:
root: ~/repo
paths:
- .
DOCKER_RELEASE_PUBLISH_ARM:
<<: *defaults
resource_class: arm.large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for arm64 (Release)
command: |
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
exit 0
else
# Remove npm config
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our arm64 image, auth, and push
docker build --platform linux/arm64 --tag ohif/app:$IMAGE_VERSION_FULL-arm64 --tag ohif/app:latest-arm64 .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-arm64
docker push ohif/app:latest-arm64
fi
- persist_to_workspace:
root: ~/repo
paths:
- .
DOCKER_BETA_PUBLISH:
<<: *defaults
resource_class: large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for amd64 (Beta)
name: Build and push Docker image from the master branch (beta releases)
command: |
echo $(ls -l)
@@ -279,217 +311,107 @@ jobs:
else
echo "Building and pushing Docker image from the master branch (beta releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our amd64 image, auth, and push
docker build --platform linux/amd64 --tag ohif/app:$IMAGE_VERSION_FULL-amd64 --tag ohif/app:latest-beta-amd64 .
# Build our image, auth, and push
echo "starting docker build"
docker build --tag ohif/app:$IMAGE_VERSION_FULL .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-amd64
docker push ohif/app:latest-beta-amd64
echo "starting docker push"
docker push ohif/app:$IMAGE_VERSION_FULL
fi
DOCKER_BETA_PUBLISH_ARM:
<<: *defaults
resource_class: arm.large
# This is copied from the Cypress orb since the default for cypress/run is node 16 and
# we migrated to 18
CYPRESS_CUSTOM_RUN:
description: |
A single, complete job to run Cypress end-to-end tests in your application.
executor: cypress-custom
parallelism: << parameters.parallelism >>
parameters:
cypress-cache-key:
default: cypress-cache-{{ arch }}-{{ checksum "package.json" }}
description: Cache key used to cache the Cypress binary.
type: string
cypress-cache-path:
default: ~/.cache/Cypress
description: |
By default, this will cache the '~/.cache/Cypress' directory so that the Cypress binary is cached. You can override this by providing your own cache path.
type: string
cypress-command:
default: npx cypress run
description: Command used to run your Cypress tests
type: string
include-branch-in-node-cache-key:
default: false
description: |
If true, this cache will only apply to runs within the same branch. (Adds -{{ .Branch }}- to the node cache key)
type: boolean
install-browsers:
default: false
description: |
Cypress runs by default in the Electron browser. Use this flag to install additional browsers to run your tests in.
This is only needed if you are passing the `--browser` flag in your `cypress-command`.
This parameter leverages the `circleci/browser-tools` orb and includes Chrome and FireFox.
If you need additional browser support you can set this to false and use an executor with a docker image
that includes the browsers of your choosing. See https://hub.docker.com/r/cypress/browsers/tags
type: boolean
install-command:
default: ''
description: Overrides the default NPM command (npm ci)
type: string
node-cache-version:
default: v1
description:
Change the default node cache version if you need to clear the cache for any reason.
type: string
package-manager:
default: npm
description: Select the default node package manager to use. NPM v5+ Required.
enum:
- npm
- yarn
- yarn-berry
type: enum
parallelism:
default: 1
description: |
Number of Circle machines to use for load balancing, min 1
(requires `parallel` and `record` flags in your `cypress-command`)
type: integer
post-install:
default: ''
description: |
Additional commands to run after running install but before verifying Cypress and saving cache.
type: string
start-command:
default: ''
description: Command used to start your local dev server for Cypress to tests against
type: string
working-directory:
default: ''
description: Directory containing package.json
type: string
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for arm64 (Beta)
command: |
echo $(ls -l)
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
echo "don't have version txt"
exit 0
else
echo "Building and pushing ARM64 Docker image from the master branch (beta releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our arm64 image, auth, and push
docker build --platform linux/arm64 --tag ohif/app:$IMAGE_VERSION_FULL-arm64 --tag ohif/app:latest-beta-arm64 .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-arm64
docker push ohif/app:latest-beta-arm64
fi
CYPRESS:
<<: *defaults
resource_class: large
parallelism: 8
steps:
- install_bun
- run:
name: Install System Dependencies
command: |
sudo apt-get update
sudo apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6
- run:
name: Start Xvfb
command: Xvfb :99 -screen 0 1920x1080x24 &
background: true
- run:
name: Export Display Variable
command: export DISPLAY=:99
- cypress/install:
install-command: yarn install --frozen-lockfile --no-save
cypress-cache-key: << parameters.cypress-cache-key >>
cypress-cache-path: << parameters.cypress-cache-path >>
include-branch-in-node-cache-key: << parameters.include-branch-in-node-cache-key >>
install-browsers: << parameters.install-browsers >>
install-command: << parameters.install-command >>
node-cache-version: << parameters.node-cache-version >>
package-manager: << parameters.package-manager >>
post-install: << parameters.post-install >>
working-directory: << parameters.working-directory >>
- cypress/run-tests:
cypress-command: |
npx wait-on@latest http://localhost:3000 && cd platform/app && npx cypress run --record --parallel
start-command: yarn run test:data && yarn run test:e2e:serve
SECURITY_AUDIT:
<<: *defaults
resource_class: large
steps:
- install_bun
- checkout
- run:
name: 'Security Audit - High Risk Vulnerabilities'
command: |
git fetch origin master 2>/dev/null || true
BASE_REF=$(git merge-base HEAD origin/master 2>/dev/null)
if [[ -z "$BASE_REF" ]]; then
echo "Could not determine base ref (e.g. shallow clone or no origin/master), skipping security audit."
exit 0
fi
CHANGED_FILES=$(git diff --name-only origin/master...HEAD 2>/dev/null || echo "")
if ! echo "$CHANGED_FILES" | grep -qx 'bun.lock'; then
echo "⏭️ bun.lock unchanged - skipping security audit."
exit 0
fi
echo "🔍 bun.lock changed - running bun audit for security vulnerabilities..."
echo "Checking for HIGH-RISK vulnerabilities..."
# Define ignored vulnerabilities with comments
IGNORED_VULNS=(
"GHSA-3ppc-4f35-3m26" # CVE-2026-26996 - minimatch via itk-wasm and glob is safe because it does NOT use the CLI
# CVE-2026-26996 - minimatch via other packages are strictly for building and CI/CD purposes; no user supplied expressions are passed to minimatch
"GHSA-7r86-cg39-jmmj" # CVE-2026-27903 - minimatch same as above
"GHSA-23c5-xmqv-rm74" # CVE-2026-27904 - minimatch same as above
"GHSA-c2c7-rcm5-vvqj" # CVE-2026-33671 - picomatch is generally used for development and CI/CD purposes
)
# Build ignore flags
IGNORE_FLAGS=""
for vuln in "${IGNORED_VULNS[@]}"; do
IGNORE_FLAGS="$IGNORE_FLAGS --ignore=$vuln"
done
if bun audit $IGNORE_FLAGS --audit-level high; then
echo "✅ No high-risk vulnerabilities found"
echo "🎉 Security audit passed!"
exit 0
else
echo ""
echo "❌ HIGH-RISK VULNERABILITIES DETECTED!"
echo "======================================"
echo ""
echo "🔧 To fix these issues:"
echo " 1. Run: bun audit"
echo " 2. Review the vulnerability details"
echo " 3. Update affected packages to secure versions"
echo " 4. Test your changes"
echo " 5. Re-run: bun audit --audit-level high"
echo ""
echo "📋 Full audit report:"
bun audit $IGNORE_FLAGS --audit-level low || true
echo ""
echo "❌ This build cannot proceed until high-risk vulnerabilities are resolved."
exit 1
fi
DOCKER_MULTIARCH_MANIFEST:
<<: *defaults
resource_class: large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Create and push multi-architecture manifest (Release)
command: |
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
exit 0
else
echo "Building and pushing multi-architecture manifest from the master branch (release releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
# Create and push manifest for specific version
docker manifest create ohif/app:$IMAGE_VERSION_FULL \
--amend ohif/app:$IMAGE_VERSION_FULL-amd64 \
--amend ohif/app:$IMAGE_VERSION_FULL-arm64
docker manifest push ohif/app:$IMAGE_VERSION_FULL
# Create and push manifest for "latest" tag
docker manifest create ohif/app:latest \
--amend ohif/app:latest-amd64 \
--amend ohif/app:latest-arm64
docker manifest push ohif/app:latest
fi
DOCKER_BETA_MULTIARCH_MANIFEST:
<<: *defaults
resource_class: large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Create and push multi-architecture manifest (Beta)
command: |
echo $(ls -l)
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
exit 0
else
echo "Building and pushing multi-architecture manifest from the master branch (beta releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
# Create and push manifest for specific beta version
docker manifest create ohif/app:$IMAGE_VERSION_FULL \
--amend ohif/app:$IMAGE_VERSION_FULL-amd64 \
--amend ohif/app:$IMAGE_VERSION_FULL-arm64
docker manifest push ohif/app:$IMAGE_VERSION_FULL
# Create and push manifest for "latest-beta" tag
docker manifest create ohif/app:latest-beta \
--amend ohif/app:latest-beta-amd64 \
--amend ohif/app:latest-beta-arm64
docker manifest push ohif/app:latest-beta
fi
cypress-command: << parameters.cypress-command >>
start-command: << parameters.start-command >>
working-directory: << parameters.working-directory >>
workflows:
PR_CHECKS:
@@ -498,60 +420,131 @@ workflows:
filters:
branches:
ignore: master
- UNIT_TESTS
- CYPRESS:
- UNIT_TESTS:
requires:
- BUILD_PACKAGES_QUICK
- CYPRESS_CUSTOM_RUN:
name: 'Cypress Tests'
context: cypress
- SECURITY_AUDIT:
filters:
branches:
ignore: master
matrix:
parameters:
start-command:
- yarn run test:data && yarn run test:e2e:serve
install-browsers:
- true
cypress-command:
- 'npx wait-on@latest http://localhost:3000 && cd platform/app && npx cypress run
--record --browser chrome --parallel'
package-manager:
- 'yarn'
cypress-cache-key:
- 'yarn-packages-{{ checksum "yarn.lock" }}'
cypress-cache-path:
- '~/.cache/Cypress'
requires:
- BUILD_PACKAGES_QUICK
# viewer-dev.ohif.org
# PR_OPTIONAL_VISUAL_TESTS:
# jobs:
# - AWAIT_APPROVAL:
# type: approval
# # Update hub.docker.org
# - cypress/run:
# name: 'Generate Percy Snapshots'
# executor: cypress/browsers-chrome76
# browser: chrome
# pre-steps:
# - run: 'rm -rf ~/.yarn && yarn -v && yarn global add wait-on'
# yarn: true
# store_artifacts: false
# working_directory: platform/app
# build:
# yarn test:data && npx cross-env QUICK_BUILD=true APP_CONFIG=config/dicomweb-server.js
# yarn run build
# # start server --> verify running --> percy + chrome + cypress
# command: yarn run test:e2e:dist
# cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
# no-workspace: true # Don't persist workspace
# post-steps:
# - store_artifacts:
# path: platform/app/cypress/screenshots
# - store_artifacts:
# path: platform/app/cypress/videos
# requires:
# - AWAIT_APPROVAL
# Our master branch deploys to viewer-dev.ohif.org, the viewer.ohif.org is
# deployed from the release branch which is more stable and less frequently updated.
DEPLOY_MASTER:
jobs:
- BUILD:
filters:
branches:
only: master
# - HOLD_FOR_APPROVAL:
# type: approval
# requires:
# - BUILD
- NPM_PUBLISH:
requires:
# - HOLD_FOR_APPROVAL
- BUILD
- DOCKER_BETA_PUBLISH:
requires:
- NPM_PUBLISH
- DOCKER_BETA_PUBLISH_ARM:
requires:
- DOCKER_BETA_PUBLISH
- DOCKER_BETA_MULTIARCH_MANIFEST:
requires:
- DOCKER_BETA_PUBLISH_ARM
# viewer.ohif.org
# Our release branch deploys to viewer.ohif.org and is more stable and less
# frequently updated after being tested in the staging environment.
DEPLOY_RELEASE:
jobs:
- BUILD:
filters:
branches:
only: /^release\/.*/
- HOLD_FOR_APPROVAL:
type: approval
requires:
- BUILD
only: release
- NPM_PUBLISH:
requires:
- HOLD_FOR_APPROVAL
- BUILD
- DOCKER_RELEASE_PUBLISH:
requires:
- NPM_PUBLISH
- DOCKER_RELEASE_PUBLISH_ARM:
requires:
- DOCKER_RELEASE_PUBLISH
- DOCKER_MULTIARCH_MANIFEST:
requires:
- DOCKER_RELEASE_PUBLISH_ARM
###
# Unit and E2E tests have already run for PR_CHECKS
# Re-running should not gain us any confidence here
###
# RELEASE:
# jobs:
# - NPM_PUBLISH:
# filters:
# branches:
# only: master
# - DOCS_PUBLISH:
# filters:
# branches:
# only: master
# # Update base branch snapshots
# # and record a Cypress dashboard test run
# - cypress/run:
# name: 'Generate Percy Snapshots'
# executor: cypress/browsers-chrome76
# browser: chrome
# pre-steps:
# - run: 'rm -rf ~/.yarn && npm i -g yarn && yarn -v && yarn global
# add wait-on' # Use yarn latest
# yarn: true
# store_artifacts: false
# working_directory: platform/app
# build:
# npx cross-env QUICK_BUILD=true APP_CONFIG=config/e2e.js yarn run
# build
# # start server --> verify running --> percy + chrome + cypress
# command: yarn run test:e2e:dist
# cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
# no-workspace: true # Don't persist workspace
# post-steps:
# - store_artifacts:
# path: platform/app/cypress/screenshots
# - store_artifacts:
# path: platform/app/cypress/videos
# - store_test_results:
# path: platform/app/cypress/results
# filters:
# branches:
# only: master
# - DOCKER_MASTER_PUBLISH:
# requires:
# - NPM_PUBLISH
+3 -4
View File
@@ -1,13 +1,12 @@
server {
gzip_static always;
gzip_proxied expired no-cache no-store private auth;
gunzip on;
listen ${PORT} default_server;
listen [::]:${PORT} default_server;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ ${PUBLIC_URL}index.html;
try_files $uri $uri/ /index.html;
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
add_header Cross-Origin-Resource-Policy same-origin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -7,6 +7,8 @@ server {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
add_header Cross-Origin-Resource-Policy same-origin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+6 -24
View File
@@ -2,34 +2,16 @@
if [ -n "$SSL_PORT" ]
then
envsubst '${SSL_PORT}:${PORT}' < /usr/src/default.ssl.conf.template | envsubst '${PUBLIC_URL}' > /etc/nginx/conf.d/default.conf
envsubst '${SSL_PORT}:${PORT}' < /usr/src/default.ssl.conf.template > /etc/nginx/conf.d/default.conf
else
envsubst '${PORT}:${PUBLIC_URL}' < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf
envsubst '${PORT}' < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf
fi
if [ -n "$APP_CONFIG" ]; then
echo "$APP_CONFIG" > /usr/share/nginx/html${PUBLIC_URL}app-config.js
echo "Using custom APP_CONFIG environment variable"
else
echo "Not using custom APP_CONFIG"
if [ -n "$APP_CONFIG" ]
then
echo "$APP_CONFIG" > /usr/share/nginx/html/app-config.js
fi
if [ -f /usr/share/nginx/html${PUBLIC_URL}app-config.js ]; then
if [ -s /usr/share/nginx/html${PUBLIC_URL}app-config.js ]; then
echo "Detected non-empty app-config.js. Ensuring .gz file is updated..."
rm -f /usr/share/nginx/html${PUBLIC_URL}app-config.js.gz
gzip /usr/share/nginx/html${PUBLIC_URL}app-config.js
touch /usr/share/nginx/html${PUBLIC_URL}app-config.js
echo "Compressed app-config.js to app-config.js.gz"
else
echo "app-config.js is empty. Skipping compression."
fi
else
echo "No app-config.js file found. Skipping compression."
fi
if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
then
# If CLIENT_ID is specified, use the google.js configuration with the modified ID
@@ -58,6 +40,6 @@ if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
cp /usr/share/nginx/html/google.js /usr/share/nginx/html/app-config.js
fi
echo "Starting Nginx to serve the OHIF Viewer on ${PUBLIC_URL}"
echo "Starting Nginx to serve the OHIF Viewer..."
exec "$@"
-4
View File
@@ -1,4 +0,0 @@
find platform/app/dist -name "*.js" -exec gzip -9 "{}" \; -exec touch "{}" \;
find platform/app/dist -name "*.map" -exec gzip -9 "{}" \; -exec touch "{}" \;
find platform/app/dist -name "*.css" -exec gzip -9 "{}" \; -exec touch "{}" \;
find platform/app/dist -name "*.svg" -exec gzip -9 "{}" \; -exec touch "{}" \;
+4 -4
View File
@@ -8,11 +8,11 @@
# have this file as their .dockerignore.
# Output
**/dist/
**/build/
dist/
build/
# Dependencies
**/node_modules/
node_modules/
# Root
README.md
@@ -33,5 +33,5 @@ dockerfile
.scripts/
.vscode/
coverage/
platform/docs/
docs/
testdata/
+1
View File
@@ -0,0 +1 @@
PERCY_TOKEN=<your token here>
+1 -2
View File
@@ -18,8 +18,7 @@
},
"rules": {
// Enforce consistent brace style for all control statements for readability
"curly": "error",
"import/no-anonymous-default-export": "off"
"curly": "error"
},
"globals": {
"cy": true,
-23
View File
@@ -1,23 +0,0 @@
version: 2
enable-beta-ecosystems: true
updates:
- package-ecosystem: 'bun'
# Disable all pull requests for bun version updates.
open-pull-requests-limit: 0
directory: '/'
schedule:
interval: 'daily'
labels: ['dependencies']
commit-message:
prefix: 'chore'
include: 'scope'
- package-ecosystem: 'npm'
# Disable all pull requests for npm version updates.
open-pull-requests-limit: 0
directory: '/'
schedule:
interval: 'daily'
labels: ['dependencies']
commit-message:
prefix: 'chore'
include: 'scope'
+21 -22
View File
@@ -51,29 +51,28 @@ body:
validations:
required: true
- type: textarea
id: system_info
- type: input
id: os
attributes:
label: 'System Information'
description: 'Please run the following command in your terminal and paste the output:'
placeholder: |
Run: npx envinfo --system --binaries --browsers
Then paste the output here. It should look something like:
System:
OS: Windows 10 10.0.19042
CPU: (8) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
Memory: 15.89 GB / 31.74 GB
Shell: 1.0.0 - C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe
Binaries:
Node: 20.18.1 - C:\Program Files\nodejs\node.EXE
Yarn: 1.22.22 - C:\Users\user\AppData\Roaming\npm\yarn.CMD
npm: 10.8.2 - C:\Program Files\nodejs\npm.CMD
Browsers:
Chrome: 83.0.4103.116
Edge: Spartan (44.19041.1266.0), Chromium (83.0.478.58)
Firefox: 77.0.1
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., 18.16.1'
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
-110
View File
@@ -1,110 +0,0 @@
name: Build and Deploy Docs
on:
push:
branches: [master]
env:
ACTIONS_STEP_DEBUG: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy-docs:
timeout-minutes: 60
runs-on: ubuntu-latest
# Need permissions to read actions and pull requests
permissions:
actions: read
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.23
- uses: actions/setup-node@v4
with:
node-version: 20 # Or your desired Node version
- name: Install root dependencies
run: bun install --frozen-lockfile
# Removed Playwright tests and coverage generation steps
- name: Find PR and associated workflow run
id: find_pr_run
env:
GH_TOKEN: ${{ github.token }}
MERGE_COMMIT_SHA: ${{ github.sha }}
run: |
# Find the PR associated with the merge commit SHA
# Note: This relies on the merge commit being directly pushed to main
PR_DATA=$(gh pr list --state merged --search "$MERGE_COMMIT_SHA" --json number,headRefOid --jq '.[0]')
if [ -z "$PR_DATA" ]; then
echo "Could not find merged PR for commit $MERGE_COMMIT_SHA."
# Decide how to handle - fail, or maybe generate coverage now?
# For now, let's fail.
exit 1
fi
PR_HEAD_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid')
PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number')
echo "Found PR Number: $PR_NUMBER"
echo "Found PR Head SHA: $PR_HEAD_SHA"
# Find the latest workflow run ID for the playwright workflow on the PR head commit
# Uses the workflow file name 'playwright.yml'
# Remove the --status success flag to find any run
RUN_ID=$(gh run list --workflow playwright.yml --commit "$PR_HEAD_SHA" --event pull_request --json databaseId --jq '.[0].databaseId')
if [ -z "$RUN_ID" ]; then
echo "Could not find any 'playwright.yml' run for PR $PR_NUMBER (Head SHA: $PR_HEAD_SHA)."
# Decide how to handle - maybe try finding the artifact from the merge commit run if that exists?
# For now, let's fail.
exit 1
fi
echo "Found Run ID: $RUN_ID"
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
- name: Download coverage artifact from PR run
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p ./coverage-artifact
gh run download ${{ steps.find_pr_run.outputs.run_id }} -n coverage-report-pr --dir ./coverage-artifact
# Check if download was successful (e.g., check if files exist)
if [ ! -f ./coverage-artifact/base.css ]; then
echo "Failed to download or find expected files in artifact 'coverage-report-pr' from run ${{ steps.find_pr_run.outputs.run_id }}."
exit 1
fi
echo "Artifact downloaded successfully."
- name: Install docs dependencies
run: cd platform/docs && bun install
- name: Copy coverage to docs static directory
run: |
# Copy files from the downloaded artifact directory
mkdir -p platform/docs/static/coverage
cp -r ./coverage-artifact/* platform/docs/static/coverage/
# Copy specific asset files from the downloaded artifact root to static root
cp ./coverage-artifact/base.css platform/docs/static/
cp ./coverage-artifact/block-navigation.js platform/docs/static/
cp ./coverage-artifact/prettify.css platform/docs/static/
cp ./coverage-artifact/prettify.js platform/docs/static/
cp ./coverage-artifact/favicon.png platform/docs/static/
cp ./coverage-artifact/sort-arrow-sprite.png platform/docs/static/
cp ./coverage-artifact/sorter.js platform/docs/static/
- name: Build docs
run: cd platform/docs && bun run build
- name: Deploy to Netlify
run: |
cd platform/docs
npx netlify-cli deploy --dir=./build --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
-98
View File
@@ -1,98 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
pull_request:
branches: [ "main" ]
schedule:
- cron: '15 1 * * 5'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
-209
View File
@@ -1,209 +0,0 @@
name: Playwright Tests
on:
pull_request:
branches: [master, release/*]
workflow_dispatch:
inputs:
cs3d_ref:
description: >-
CS3D branch (e.g. main, origin:feat/foo) or version (e.g. 4.18.2, 4.19+, 4.x). Only used
when ohif-integration label is present or via workflow_dispatch.
required: false
default: '4.19+'
permissions:
contents: read
pull-requests: read
issues: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
playwright-tests:
timeout-minutes: 120
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.23
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install Yarn
run: npm install -g yarn@1.22.22
# ── CS3D integration: detect label and ref type ──────────────────────
- name: Check for CS3D integration label
id: cs3d-check
run: bash .scripts/ci/cs3d-check-integration.sh
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
CS3D_REF_INPUT: ${{ github.event.inputs.cs3d_ref || '4.19+' }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
- name: Detect CS3D ref type
id: cs3d-ref
if: steps.cs3d-check.outputs.enabled == 'true'
run: |
REF="${CS3D_REF}"
if [[ "$REF" =~ ^[0-9]+\.[0-9x]+\+?(\.[0-9x]+)?(-[a-zA-Z0-9._]+)?$ ]]; then
echo "type=version" >> "$GITHUB_OUTPUT"
RESOLVED=$(node .scripts/cs3d-resolve-version.mjs "$REF")
echo "version=$RESOLVED" >> "$GITHUB_OUTPUT"
echo "::notice::CS3D version: $REF -> $RESOLVED"
else
echo "type=branch" >> "$GITHUB_OUTPUT"
echo "::notice::CS3D branch: $REF"
fi
env:
CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }}
# ── CS3D branch path: clone and build before OHIF install ───────────
- name: Clone CS3D
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
run: |
REF="${CS3D_REF}"
if [[ "$REF" == *:* ]]; then
REPO="https://github.com/${REF%%:*}/cornerstone3D.git"
BRANCH="${REF#*:}"
else
REPO="https://github.com/cornerstonejs/cornerstone3D.git"
BRANCH="$REF"
fi
echo "::notice::Cloning CS3D from $REPO branch $BRANCH"
git clone --depth 1 --branch "$BRANCH" "$REPO" libs/@cornerstonejs
env:
CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }}
- name: Install & Build CS3D
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
working-directory: libs/@cornerstonejs
run: bun install --frozen-lockfile && bun run build:esm
# ── Common: install OHIF dependencies ───────────────────────────────
- name: Install dependencies
run: bun install --frozen-lockfile
# ── CS3D branch path: link packages after OHIF install ──────────────
- name: Link CS3D packages
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
working-directory: libs/@cornerstonejs
run: node scripts/link-ohif-cornerstone-node-modules.mjs "$GITHUB_WORKSPACE"
# ── CS3D version path: update versions after OHIF install ───────────
- name: Set CS3D version
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'version'
run: |
node .scripts/cs3d-set-version.mjs "${CS3D_VERSION}"
bun install --config=./bunfig.update-lockfile.toml
env:
CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }}
# ── Common: run tests ───────────────────────────────────────────────
- name: Install Playwright browsers
run: npx playwright install
- name: Run Playwright tests
run: |
export NODE_OPTIONS="--max_old_space_size=10192"
bun run test:e2e:coverage
# ── Common: collect test results and coverage ───────────────────────
- name: Create directory of test results
if: ${{ !cancelled() }}
run: |
mkdir -p packaged-test-results
cp -r ./tests/test-results packaged-test-results/ || true
cp -r ./tests/playwright-report packaged-test-results/ || true
- name: Upload directory of test results artifact
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: packaged-test-results/
retention-days: 5
- name: create the coverage report
run: |
bun nyc report --reporter=lcov --reporter=text
- name: Upload the coverage report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-report-pr
path: coverage
retention-days: 3
# ── CS3D: build and deploy preview to Netlify ───────────────────────
- name: Log build context (OHIF/CS3D branch and version for build diagnosis)
if: steps.cs3d-check.outputs.enabled == 'true'
run: |
if [[ "$CS3D_REF_TYPE" == "branch" ]]; then
echo "::notice::Build type: ohif-downstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: branch ${{ steps.cs3d-check.outputs.cs3d_ref }}"
else
echo "::notice::Build type: ohif-upstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: version ${{ steps.cs3d-ref.outputs.version }}"
fi
node .scripts/log-build-context.mjs
env:
BUILD_TYPE: ${{ steps.cs3d-ref.outputs.type == 'branch' && 'ohif-downstream' || 'ohif-upstream' }}
CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }}
- name: Build OHIF viewer (CS3D preview)
if: steps.cs3d-check.outputs.enabled == 'true'
run: bun run build:ci
- name: Deploy CS3D preview to Netlify
if: steps.cs3d-check.outputs.enabled == 'true'
run: |
RESULT=$(npx netlify-cli deploy --dir=platform/app/dist --alias="cs3d-pr-${PR_NUM}" --json --filter=@ohif/app) || {
echo "::error::Netlify deploy command failed"
exit 1
}
URL=$(echo "$RESULT" | jq -r '.deploy_url')
if [[ -z "$URL" || "$URL" == "null" ]]; then
echo "::error::Netlify deploy did not return a valid URL"
echo "$RESULT"
exit 1
fi
echo "::notice::CS3D preview deployed: $URL"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
PR_NUM: ${{ github.event.pull_request.number || 'manual' }}
# ── CS3D: log results ───────────────────────────────────────────────
- name: Log CS3D build used
if: steps.cs3d-check.outputs.enabled == 'true'
run: |
if [[ "$CS3D_REF_TYPE" == "branch" ]]; then
echo "::notice::CS3D integration PASSED with branch ${CS3D_REF} (linked from libs/@cornerstonejs)"
else
echo "::notice::CS3D integration PASSED with @cornerstonejs/*@${CS3D_VERSION}"
fi
env:
CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }}
CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }}
CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }}
# ── Separate job: block merge when using a CS3D branch ─────────────
cs3d-branch-merge-guard:
name: "CS3D Branch Merge Guard"
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check for CS3D branch usage
run: bash .scripts/ci/cs3d-branch-merge-guard.sh
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
CS3D_REF_INPUT: ${{ github.event.inputs.cs3d_ref || '4.19+' }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
+3 -30
View File
@@ -1,7 +1,6 @@
# Packages
node_modules
.cursor/
.nyc_output/
# Output
build
dist
@@ -11,9 +10,6 @@ junit.xml
coverage/
.docz/
.yarn/
.nx/
addOns/yarn.lock
playwright-report/
# YALC (for Erik)
.yalc
@@ -37,39 +33,16 @@ docker/dcm4che/dcm4che-arc
# Cypress test results
videos/
screenshots/
# Locize settings
.locize
# autogenerated files
platform/app/src/pluginImports.js
CLAUDE.md
/Viewers.iml
platform/app/.recipes/Nginx-Dcm4Chee/logs/*
platform/app/.recipes/Nginx-Dcm4Che/dcm4che/dcm4che-arc/*
platform/app/.recipes/OpenResty-Orthanc/logs/*
.vercel
.vs
# PlayWright
node_modules/
tests/test-results/
tests/playwright-report/
/blob-report/
/playwright/.cache/
**/.claude/settings.local.json
# cornerstone3D local linking
libs/
# Backup files
*~
# cornerstone3D local linking
libs/
link-cs3d.js
unlink-cs3d.js
auth.json
+1 -1
View File
@@ -19,7 +19,7 @@ echo 'Web application built and copied'
# Build && Move Docusaurus Output (for the docs themselves)
cd platform/docs
yarn install --frozen-lockfile
yarn install
yarn run build
cd ../..
mkdir -p ./.netlify/www/docs
+1 -1
View File
@@ -10,6 +10,6 @@
"deploy": "netlify deploy --prod --dir ./../platform/app/dist"
},
"devDependencies": {
"netlify-cli": "2.21.0"
"netlify-cli": "^2.21.0"
}
}
+1 -1
View File
@@ -1 +1 @@
20.9.0
18.16.1
-24
View File
@@ -1,24 +0,0 @@
{
"extends": "@istanbuljs/nyc-config-typescript",
"instrument": true,
"sourceMap": true,
"cache": false,
"all": true,
"include": [
"platform/*/src/**/*.ts",
"platform/*/src/**/*.js",
"extensions/*/src/**/*.ts",
"extensions/*/src/**/*.js",
"modes/*/src/**/*.ts",
"modes/*/src/**/*.js"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/test/**",
"**/tests/**",
"**/examples/**",
"**/stories/**",
"platform/docs/**"
]
}
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# CS3D branch merge guard: blocks merge when tests ran against a CS3D branch (not a version).
# Exits 0 when merge is allowed or guard is skipped; exits 1 when merge must be blocked.
#
# Required env: GH_TOKEN, EVENT_NAME, REPO, PR_NUMBER
# Optional env: CS3D_REF_INPUT (for workflow_dispatch, default 4.19+)
set -e
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "::notice::workflow_dispatch — no merge to block, skipping guard."
exit 0
elif [[ "$EVENT_NAME" == "pull_request" ]]; then
LABELS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" --jq '.[].name')
if echo "$LABELS" | grep -q "ohif-integration"; then
ENABLED=true
CS3D_REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body' \
| sed -n 's/^[[:space:]]*CS3D_REF:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1)
if [[ -z "$CS3D_REF" ]]; then
CS3D_REF="4.19+"
fi
else
ENABLED=false
fi
else
ENABLED=false
fi
if [[ "$ENABLED" != "true" ]]; then
echo "::notice::No ohif-integration label — skipping merge guard."
exit 0
fi
# Check if the ref is a branch (not a version)
if [[ "$CS3D_REF" =~ ^[0-9]+\.[0-9x]+\+?(\.[0-9x]+)?(-[a-zA-Z0-9._]+)?$ ]]; then
echo "::notice::CS3D ref '$CS3D_REF' is a version — merge allowed."
exit 0
fi
echo "::error::Tests ran against CS3D branch '${CS3D_REF}' — this build cannot be merged."
echo "::error::Re-run with a published CS3D version (e.g. 4.19+) before merging."
exit 1
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# CS3D integration check: detects ohif-integration label and parses CS3D_REF.
# Writes to GITHUB_OUTPUT: enabled (true|false), cs3d_ref (when enabled).
#
# Required env: GH_TOKEN, EVENT_NAME, REPO, PR_NUMBER, GITHUB_OUTPUT
# Optional env: CS3D_REF_INPUT (for workflow_dispatch, default 4.19+)
set -e
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "cs3d_ref=${CS3D_REF_INPUT:-4.19+}" >> "$GITHUB_OUTPUT"
elif [[ "$EVENT_NAME" == "pull_request" ]]; then
LABELS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" --jq '.[].name')
if echo "$LABELS" | grep -q "ohif-integration"; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body' \
| sed -n 's/^[[:space:]]*CS3D_REF:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1)
if [[ -z "$REF" ]]; then
REF="4.19+"
fi
echo "cs3d_ref=${REF}" >> "$GITHUB_OUTPUT"
echo "::notice::CS3D ref from PR body: ${REF}"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
-55
View File
@@ -1,55 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
const workflowPath = path.resolve(
process.cwd(),
'libs',
'@cornerstonejs',
'.github',
'workflows',
'ohif-downstream.yml'
);
if (!fs.existsSync(workflowPath)) {
console.error(`[cs3d:check] Workflow file not found: ${workflowPath}`);
process.exit(1);
}
const workflowText = fs.readFileSync(workflowPath, 'utf8');
const ohifRefMatch = workflowText.match(/^\s*OHIF_REF:\s*["']?([^"'\r\n]+)["']?\s*$/m);
if (!ohifRefMatch) {
console.error('[cs3d:check] Could not find OHIF_REF in ohif-downstream workflow.');
process.exit(1);
}
const expectedBranch = ohifRefMatch[1].trim();
let currentBranch = '';
try {
currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: process.cwd(),
encoding: 'utf8',
}).trim();
} catch (error) {
console.error('[cs3d:check] Failed to determine current git branch.');
process.exit(1);
}
if (currentBranch !== expectedBranch) {
console.error(
`[cs3d:check] Branch mismatch: current='${currentBranch}', expected='${expectedBranch}' from ${path.relative(
process.cwd(),
workflowPath
)}`
);
process.exit(1);
}
console.log(
`[cs3d:check] OK: current branch '${currentBranch}' matches OHIF_REF in ${path.relative(
process.cwd(),
workflowPath
)}`
);
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
/**
* Resolves a version pattern to a concrete npm version of @cornerstonejs/core.
*
* Patterns:
* 4.18.2 -> 4.18.2 (exact, returned as-is)
* 4.18.2-beta.3 -> 4.18.2-beta.3 (exact prerelease)
* 4.x -> latest 4.* from npm
* 4.17.x -> latest 4.17.* from npm
* 4.19+ -> latest >=4.19.0 <5.0.0-0 from npm (4.19 and later, same major)
*
* Prints the resolved version to stdout.
*/
import { execSync } from 'child_process';
const pattern = process.argv[2];
if (!pattern) {
console.error('Usage: cs3d-resolve-version.mjs <pattern>');
console.error(' e.g. 4.x, 4.17.x, 4.19+, 4.18.2, 4.18.2-beta.3');
process.exit(1);
}
// Exact version (no wildcard or range) — pass through unchanged
if (!pattern.includes('x') && !pattern.endsWith('+')) {
console.log(pattern);
process.exit(0);
}
// Convert "M.m+" to npm semver range ">=M.m.0 <(M+1).0.0-0"
let npmRange = pattern;
const plusMatch = pattern.match(/^(\d+)\.(\d+)\+$/);
if (plusMatch) {
const major = Number(plusMatch[1]);
const minor = Number(plusMatch[2]);
npmRange = `>=${major}.${minor}.0 <${major + 1}.0.0-0`;
}
try {
// Let npm resolve the range: @package@<range> → concrete version
const raw = execSync(`npm view @cornerstonejs/core@"${npmRange}" version --json`, {
encoding: 'utf8',
timeout: 30_000,
});
const resolved = JSON.parse(raw);
if (!resolved) {
console.error(`npm returned no version when resolving @cornerstonejs/core@${npmRange}`);
process.exit(1);
}
// npm may return an array of versions for ranges; pick the latest (last) one
const version = Array.isArray(resolved) ? resolved[resolved.length - 1] : resolved;
console.log(version);
} catch (err) {
const message =
(err && (err.stderr?.toString() || err.message || String(err))) ||
`Unknown error resolving @cornerstonejs/core@${pattern}`;
console.error(`Failed to resolve @cornerstonejs/core version for pattern "${pattern}":`);
console.error(message);
process.exit(1);
}
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env node
/**
* Updates all @cornerstonejs/* package versions across the OHIF workspace.
*
* Usage: node .scripts/cs3d-set-version.mjs <version>
*
* Only updates the 8 main CS3D packages (not codec packages):
* adapters, ai, core, dicom-image-loader, labelmap-interpolation,
* nifti-volume-loader, polymorphic-segmentation, tools
*/
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { resolve, dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '..');
const version = process.argv[2];
if (!version) {
console.error('Usage: cs3d-set-version.mjs <version>');
console.error(' e.g. 4.18.2, 4.19.0-beta.1');
process.exit(1);
}
// The 8 CS3D packages that are built from source (not codecs)
const CS3D_PACKAGES = [
'@cornerstonejs/adapters',
'@cornerstonejs/ai',
'@cornerstonejs/core',
'@cornerstonejs/dicom-image-loader',
'@cornerstonejs/labelmap-interpolation',
'@cornerstonejs/nifti-volume-loader',
'@cornerstonejs/polymorphic-segmentation',
'@cornerstonejs/tools',
];
// Read root package.json to get workspace globs
const rootPkgPath = resolve(rootDir, 'package.json');
const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8'));
const workspaceGlobs = rootPkg.workspaces?.packages || rootPkg.workspaces || [];
// Collect all package.json paths from workspace globs
function findWorkspacePackageJsons() {
const paths = [rootPkgPath]; // include root
for (const pattern of workspaceGlobs) {
const parts = pattern.split('/');
let searchDir = rootDir;
let hasWildcard = false;
for (const part of parts) {
if (part === '*') {
hasWildcard = true;
break;
}
searchDir = join(searchDir, part);
}
if (hasWildcard) {
try {
const entries = readdirSync(searchDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const pkgJson = join(searchDir, entry.name, 'package.json');
if (existsSync(pkgJson)) {
paths.push(pkgJson);
}
}
}
} catch {
// directory doesn't exist, skip
}
} else {
const pkgJson = join(rootDir, pattern, 'package.json');
if (existsSync(pkgJson)) {
paths.push(pkgJson);
}
}
}
return paths;
}
// Update a dependencies object, returning count of changes
function updateDeps(deps, targetVersion) {
let count = 0;
if (!deps) return count;
for (const pkg of CS3D_PACKAGES) {
if (pkg in deps && deps[pkg] !== targetVersion) {
deps[pkg] = targetVersion;
count++;
}
}
return count;
}
const pkgPaths = findWorkspacePackageJsons();
let totalChanges = 0;
for (const pkgPath of pkgPaths) {
const content = readFileSync(pkgPath, 'utf8');
const pkg = JSON.parse(content);
let changes = 0;
changes += updateDeps(pkg.dependencies, version);
changes += updateDeps(pkg.devDependencies, version);
changes += updateDeps(pkg.peerDependencies, version);
changes += updateDeps(pkg.resolutions, version);
if (changes > 0) {
// Preserve original formatting (detect indent — restrict to spaces/tabs so
// we don't accidentally capture a CRLF newline as part of the indent string)
const indent = content.match(/^([ \t]+)/m)?.[1] || ' ';
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n');
const rel = pkgPath.replace(rootDir + '/', '').replace(rootDir + '\\', '');
console.log(` Updated ${rel} (${changes} packages)`);
totalChanges += changes;
}
}
console.log(
`\nDone: ${totalChanges} version(s) updated to ${version} across ${pkgPaths.length} package files.`
);
console.log(
'This step changes package.json; the following install must not use a frozen Bun lockfile ' +
'(OHIF+CS3D combined “version” CI does: `bun install --config=./bunfig.update-lockfile.toml`). ' +
'Other installs stay frozen. Locally after this script, use that bun command and/or ' +
'`bun run install:update-lockfile` when you intend to commit lockfile updates.\n'
);
+10 -18
View File
@@ -4,34 +4,26 @@
* The JSON file can be used to load the study into the OHIF Viewer. You can get more detail
* in the DICOM JSON Data source on docs.ohif.org
*
* Usage: node dicom-json-generator.js <studyFolder> <urlPrefix> <outputJSONPath> <optional scheme>
* Usage: node dicomStudyToJSONLaunch.js <studyFolder> <urlPrefix> <outputJSONPath>
*
* params:
* - studyFolder: path to the study folder which contains the DICOM files
* - studyFolder: path to the study folder
* - urlPrefix: prefix to the url that will be used to load the study into the viewer. For instance
* we use https://ohif-assets.s3.us-east-2.amazonaws.com/dicom-json/data as the urlPrefix for the
* example since the data is hosted on S3 and each study is in a folder. So the url in the generated
* json file for the first instance of the first series of the first study will be
* dicomweb:https://ohif-assets.s3.us-east-2.amazonaws.com/dicom-json/data/Series1/Instance1
*
* as you see the dicomweb is a prefix that is used to load the data into the viewer, which is suited when
* the .dcm file is hosted statically and can be accessed via a URL (like our example above)
* However, you can specify a new scheme bellow.
*
* - outputJSONPath: path to the output JSON file
* - scheme: default dicomweb if not provided
*/
const dcmjs = require('dcmjs');
const path = require('path');
const fs = require('fs').promises;
const args = process.argv.slice(2);
const [studyDirectory, urlPrefix, outputPath, scheme = 'dicomweb'] = args;
const [studyDirectory, urlPrefix, outputPath] = args;
if (args.length < 3 || args.length > 4) {
console.error(
'Usage: node dicom-json-generator.js <studyFolder> <urlPrefix> <outputJSONPath> [scheme]'
);
if (args.length !== 3) {
console.error('Usage: node dicomStudyToJSONLaunch.js <studyFolder> <urlPrefix> <outputJSONPath>');
process.exit(1);
}
@@ -39,7 +31,7 @@ const model = {
studies: [],
};
async function convertDICOMToJSON(studyDirectory, urlPrefix, outputPath, scheme) {
async function convertDICOMToJSON(studyDirectory, urlPrefix, outputPath) {
try {
const files = await recursiveReadDir(studyDirectory);
console.debug('Processing...');
@@ -50,7 +42,7 @@ async function convertDICOMToJSON(studyDirectory, urlPrefix, outputPath, scheme)
const dicomDict = dcmjs.data.DicomMessage.readFile(arrayBuffer.buffer);
const instance = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomDict.dict);
instance.fileLocation = createImageId(file, urlPrefix, studyDirectory, scheme);
instance.fileLocation = createImageId(file, urlPrefix, studyDirectory);
processInstance(instance);
}
}
@@ -85,10 +77,10 @@ async function recursiveReadDir(dir) {
return results;
}
function createImageId(fileLocation, urlPrefix, studyDirectory, scheme) {
function createImageId(fileLocation, urlPrefix, studyDirectory) {
const relativePath = path.relative(studyDirectory, fileLocation);
const normalizedPath = path.normalize(relativePath).replace(/\\/g, '/');
return `${scheme}:${urlPrefix}${normalizedPath}`;
return `dicomweb:${urlPrefix}${normalizedPath}`;
}
function processInstance(instance) {
@@ -270,4 +262,4 @@ function createInstanceMetaDataMultiFrame(instance) {
return instances;
}
convertDICOMToJSON(studyDirectory, urlPrefix, outputPath, scheme);
convertDICOMToJSON(studyDirectory, urlPrefix, outputPath);
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env node
/**
* Logs build context (OHIF branch/version, CS3D source) for diagnosing build issues on GitHub.
* Run from repo root. Used by version.mjs and can be invoked from CI workflows.
*/
import { execa } from 'execa';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
function log(msg) {
console.log(`[build-context] ${msg}`);
}
async function detectCs3dSource() {
const libsCs3d = path.join(REPO_ROOT, 'libs/@cornerstonejs');
const coreInNodeModules = path.join(REPO_ROOT, 'node_modules/@cornerstonejs/core');
try {
const libsExists = await fs.access(libsCs3d).then(() => true).catch(() => false);
if (!libsExists) {
return { source: 'npm', detail: 'published @cornerstonejs packages (no libs/@cornerstonejs)' };
}
const coreStat = await fs.lstat(coreInNodeModules).catch(() => null);
const isSymlink = coreStat?.isSymbolicLink();
if (isSymlink) {
const target = await fs.readlink(coreInNodeModules);
return { source: 'integrated', detail: `linked from libs/@cornerstonejs (→ ${target})` };
}
return { source: 'npm', detail: 'published @cornerstonejs packages (libs exists but not linked)' };
} catch {
return { source: 'unknown', detail: 'could not detect' };
}
}
async function getCs3dVersion() {
try {
const corePkg = path.join(REPO_ROOT, 'node_modules/@cornerstonejs/core/package.json');
const pkg = JSON.parse(await fs.readFile(corePkg, 'utf-8'));
return pkg.version || 'unknown';
} catch {
return 'unknown';
}
}
async function getCs3dBranchFromLibs() {
try {
const libsCs3d = path.join(REPO_ROOT, 'libs/@cornerstonejs');
const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: libsCs3d,
});
return stdout;
} catch {
return null;
}
}
async function run() {
const inCI = process.env.GITHUB_ACTIONS === 'true';
const buildType = inCI ? (process.env.BUILD_TYPE || 'ohif-upstream') : 'local';
log('═══════════════════════════════════════════════════════════════');
log('Build context (for diagnosing GitHub build issues)');
log('═══════════════════════════════════════════════════════════════');
if (inCI) {
log(`Build type: ${process.env.BUILD_TYPE || 'ohif-upstream'}`);
log(`GitHub repo: ${process.env.GITHUB_REPOSITORY || 'unknown'}`);
log(`GitHub ref: ${process.env.GITHUB_REF || 'unknown'}`);
log(`GitHub SHA: ${process.env.GITHUB_SHA || 'unknown'}`);
log(`Workflow: ${process.env.GITHUB_WORKFLOW || 'unknown'}`);
}
try {
const { stdout: branch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: REPO_ROOT,
});
const { stdout: sha } = await execa('git', ['rev-parse', '--short', 'HEAD'], {
cwd: REPO_ROOT,
});
log(`OHIF branch: ${branch} (${sha})`);
} catch {
log('OHIF branch: (not a git repo or error)');
}
const cs3d = await detectCs3dSource();
log(`CS3D source: ${cs3d.source} — ${cs3d.detail}`);
if (cs3d.source === 'integrated') {
const branch = await getCs3dBranchFromLibs();
if (branch) log(`CS3D branch (libs/@cornerstonejs): ${branch}`);
} else {
const ver = await getCs3dVersion();
log(`CS3D version (@cornerstonejs/core): ${ver}`);
}
log('═══════════════════════════════════════════════════════════════');
}
run().catch((err) => {
console.error('[build-context] Error:', err.message);
process.exitCode = 1;
});
-1
View File
@@ -1,7 +1,6 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"streetsidesoftware.code-spell-checker",
"dbaeumer.vscode-eslint",
"mikestead.dotenv",
"bungcip.better-toml",
+12 -93
View File
@@ -1,5 +1,4 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.rulers": [80, 120],
// ===
// Spacing
@@ -15,103 +14,23 @@
// Event Triggers
// ===
"editor.formatOnSave": true,
"eslint.autoFixOnSave": true,
"eslint.run": "onSave",
"eslint.validate": [
{
"language": "javascript",
"autoFix": true
},
{
"language": "javascriptreact",
"autoFix": true
}
],
"jest.autoRun": "off",
"prettier.disableLanguages": ["html"],
"prettier.endOfLine": "lf",
"workbench.colorCustomizations": {},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"cSpell.userWords": [
"aabb",
"architectured",
"attrname",
"Barksy",
"browserslist",
"bulkdata",
"Cacheable",
"cfun",
"clonedeep",
"Colormap",
"Colormaps",
"Comlink",
"cornerstonejs",
"Crosshairs",
"datasource",
"dcmjs",
"decache",
"decached",
"decaching",
"deepmerge",
"Dicom",
"dicomweb",
"DISPLAYSETS",
"glwindow",
"grababble",
"grabbable",
"Hounsfield",
"Interactable",
"Interactor",
"istyle",
"kitware",
"labelmap",
"labelmaps",
"livewire",
"Mergeable",
"multiframe",
"nifti",
"ofun",
"OHIF",
"polylines",
"POLYSEG",
"prapogation",
"precisionmetrics",
"prefetch",
"Prescaled",
"pydicom",
"Radiopharmaceutical",
"rasterizing",
"reconstructable",
"Rehydratable",
"renderable",
"resampler",
"resemblejs",
"reslice",
"resliced",
"Reslices",
"roadmap",
"ROADMAPS",
"rtstruct",
"Segmentations",
"semibold",
"sitk",
"SUBRESOLUTION",
"suvbsa",
"suvbw",
"suvlbm",
"textbox",
"thresholded",
"thresholding",
"timepoint",
"timepoints",
"TMTV",
"TOOLGROUP",
"tqdm",
"transferables",
"typedoc",
"unsubscriptions",
"uuidv",
"viewplane",
"viewports",
"Voxel",
"Voxels",
"Vtkjs",
"wado",
"wadors",
"wadouri",
"workerpool",
"Colorbar",
"Colorbars"
]
}
}
+15 -71
View File
@@ -7,26 +7,23 @@ const fs = require('fs');
const webpack = require('webpack');
// ~~ PLUGINS
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const TerserJSPlugin = require('terser-webpack-plugin');
// ~~ PackageJSON
// const vtkRules = require('vtk.js/Utilities/config/dependency.js').webpack.core
// .rules;
// ~~ RULES
// const loadShadersRule = require('./rules/loadShaders.js');
const loadShadersRule = require('./rules/loadShaders.js');
const loadWebWorkersRule = require('./rules/loadWebWorkers.js');
const transpileJavaScriptRule = require('./rules/transpileJavaScript.js');
const cssToJavaScript = require('./rules/cssToJavaScript.js');
// Only uncomment for old v2 stylus
// const stylusToJavaScript = require('./rules/stylusToJavaScript.js');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const stylusToJavaScript = require('./rules/stylusToJavaScript.js');
// ~~ ENV VARS
const NODE_ENV = process.env.NODE_ENV;
const QUICK_BUILD = process.env.QUICK_BUILD;
const BUILD_NUM = process.env.CIRCLE_BUILD_NUM || '0';
const IS_COVERAGE = process.env.COVERAGE === 'true';
// read from ../version.txt
const VERSION_NUMBER = fs.readFileSync(path.join(__dirname, '../version.txt'), 'utf8') || '';
@@ -50,7 +47,6 @@ const defineValues = {
'process.env.LOCIZE_PROJECTID': JSON.stringify(process.env.LOCIZE_PROJECTID || ''),
'process.env.LOCIZE_API_KEY': JSON.stringify(process.env.LOCIZE_API_KEY || ''),
'process.env.REACT_APP_I18N_DEBUG': JSON.stringify(process.env.REACT_APP_I18N_DEBUG || ''),
'process.env.TEST_ENV': JSON.stringify(process.env.TEST_ENV || ''),
};
// Only redefine updated values. This avoids warning messages in the logs
@@ -59,6 +55,10 @@ if (!process.env.APP_CONFIG) {
}
module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
if (!process.env.NODE_ENV) {
throw new Error('process.env.NODE_ENV not set');
}
const mode = NODE_ENV === 'production' ? 'production' : 'development';
const isProdBuild = NODE_ENV === 'production';
const isQuickBuild = QUICK_BUILD === 'true';
@@ -96,67 +96,12 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
type: 'filesystem',
},
module: {
noParse: [/(dicomicc)/],
noParse: [/(codec)/, /(dicomicc)/],
rules: [
...(isProdBuild
? []
: [
...(IS_COVERAGE
? [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-typescript', '@babel/preset-react'],
plugins: ['istanbul'],
},
},
},
]
: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
plugins: isProdBuild ? [] : ['react-refresh/babel'],
},
},
]),
]),
{
test: /\.svg?$/,
oneOf: [
{
use: [
{
loader: '@svgr/webpack',
options: {
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
},
},
],
},
prettier: false,
svgo: true,
titleProp: true,
},
},
],
issuer: {
and: [/\.(ts|tsx|js|jsx|md|mdx)$/],
},
},
],
test: /\.js$/,
enforce: 'pre',
use: 'source-map-loader',
},
transpileJavaScriptRule(mode),
loadWebWorkersRule,
@@ -186,10 +131,6 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
},
],
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
], //.concat(vtkRules),
},
resolve: {
@@ -201,6 +142,10 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
'@hooks': path.resolve(__dirname, '../platform/app/src/hooks'),
'@routes': path.resolve(__dirname, '../platform/app/src/routes'),
'@state': path.resolve(__dirname, '../platform/app/src/state'),
'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: [
@@ -228,7 +173,6 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
}),
...(isProdBuild ? [] : [new ReactRefreshWebpackPlugin({ overlay: false })]),
// Uncomment to generate bundle analyzer
// new BundleAnalyzerPlugin(),
],
-201
View File
@@ -1,201 +0,0 @@
# AGENTS.md
This file provides guidance to AI coding agents (Claude, Codex, and other LLM tools) when working with code in this repository.
## Project Overview
This is **OHIF** v3 (Open Health Imaging Foundation) - a medical imaging viewer. It's an extensible web imaging platform.
## Development Commands
### Main Development
```bash
# Start development server for all packages
yarn dev
```
### Building
```bash
# Build all packages for production
yarn build
# Build specific packages
cd platform/app && yarn build # Main viewer app
```
## Architecture Overview
### Monorepo Structure
- **`platform/`** - Core OHIF infrastructure
- `app/` - Main viewer application (`@ohif/viewer`)
- `core/` - Core services and utilities
- `ui-next/` - Modern UI component library
- **`extensions/`** - Modular functionality plugins
- **`modes/`** - Application workflow configurations
### Key Extension Architecture
**Extension System**: Each extension exports modules (viewports, tools, panels, commands) that the app dynamically loads. Extensions are self-contained with their own webpack builds.
**Core Extensions:**
- `cornerstone/` - Medical image rendering engine
- `cornerstone-dicom-pmp/` - DICOM PMP support
- `cornerstone-dicom-seg/` - DICOM Segmentation support
- `cornerstone-dicom-sr/` - DICOM SR support
- `dicom-pdf/` - DICOM PDF support
- `dicom-video/` - DICOM Video support
- `measurement-tracking/` - Measurement tracking support
- `default/` - Standard OHIF functionality
### Service-Oriented Design (PUB-SUB)
The app uses a Services Manager pattern with these core services:
- **Display Set Service**: Manages image series organization
- **Measurement Service**: Handles annotations and measurements
- **Hanging Protocol Service**: Controls image layout and display rules
- **UI Service**: Manages panels, modals, and notifications
- **Segmentation Service**: AI/ML powered image segmentation, loading segmentations, etc.
- **Viewport Grid Service**: Manages viewport layout and display rules
- **Viewport Display Set History Service**: Manages viewport display set history
- **Viewport Dialog Service**: Manages viewport dialogs
- **Notification Service**: Manages notifications
- **Modal Service**: Manages modals
- **Dialog Service**: Manages dialogs, more general not just viewport dialogs
- **Customization Service**: Manages customization of the app
- **Toolbar Service**: Manages the toolbar, viewport action corners, tool states
- **User Authentication Service**: Manages user authentication, but used only for injecting tokens in dicomweb requests in our context
- **Panel Service**: Manages side panels
- **Cornerstone Viewport Service**: Manages the cornerstone viewport, rendering engines, presentation states, more tightly coupled to cornerstone than the other services
- **Tool Group Service**: Manages tool groups, creating and managing tool groups, etc.
- **Sync Group Service**: Manages sync groups, syncing zooming, panning, scrolling, etc.
- **Cornerstone Cache Service**: Manages the cornerstone cache, caching images, etc.
Most of the services utilize a pub sub architecture and extend the pub sub service interace at `pubSubServiceInterface.ts`
### Commands Manager
The Commands Manager tracks named commands (or functions) that are scoped to
a context. When we attempt to run a command with a given name, we look for it
in our active contexts, in the order specified.
If found, we run the command, passing in any application
or call specific data specified in the command's definition.
You can call `commandsManager.runCommand` to run a command.
### Extension Manager
Aggregates and exposes extension modules throughout the OHIF application, manages data sources, and provides a centralized registry for accessing extension functionality.
### Build System
**Yarn Workspaces**: Optimized monorepo builds with dependency caching
**Webpack 5**: Module federation for dynamic extension loading
**Plugin Import System**: Extensions auto-register via `writePluginImportsFile.js`
### Key Technologies
- **React 18 + TypeScript**: UI framework
- **Cornerstone.js**: Medical image rendering
- **DICOM**: Medical imaging standard support
- **ONNX Runtime**: AI model inference (SAM segmentation models)
- **Zustand**: State management
- **TailwindCSS**: Styling system
## Development Patterns
### Adding New Tools
1. Create tool class in `extensions/cornerstone/src/tools/`
2. Register in tool module's `toolNames.ts`
3. Add to toolbar via `getToolbarModule.tsx`
4. Add measurement mapping if needed in `measurementServiceMappings/`
### Creating Extensions
Extensions must export:
- `id.js` - Unique extension identifier
- `index.tsx` - Extension registration
- Module functions (`getToolbarModule`, `getViewportModule`, etc.)
### Viewport Customization
Custom viewports extend base Cornerstone viewport:
- Override render methods for custom overlays
- Implement measurement tracking
- Add viewport-specific tools and interactions
### Service Integration
Register services in extension's `servicesManager.registerService` and access via:
```javascript
const { MeasurementService } = servicesManager.services;
```
### Creating stores
To create a store, you can make one in your extension's `stores/` directory, and you can follow the example of an existing store such as `useLutPresentationStore.ts` or `useSynchronizersStore.ts`.
### Creating hooks
To create a hook, you can make one in your extension's `hooks/` directory, and you can follow the example of an existing hook such as `usePatientInfo.tsx`.
### Creating providers
To create a provider, you can make one in your extension's `providers/` or `contexts/` directory, and you can follow the example of an existing provider such as `ViewportGridProvider.tsx`.
### Adding new icons
To add a new icon, you can add it to the `icons/` directory, then register the icon using `import { addIcon } from '@ohif/extension-default/src/utils'`
### Creating synchronizers
You can create custom synchronizers and place them in the `synchronizers/` directory, you can follow the example of `frameViewSynchronizer.ts`
### Utilites
Any new utilites should be placed in the `utils/` directory, and you can follow the example of `formatPN.ts`
### Commands
Commands are created in the commandsModule of the extension, for example the cornerstone extension has `commandsModule.tsx`, sometimes its also named `getCommandsModule.tsx.`
### Overriding OHIF Components
To override an OHIF component, you can create a new component in your extension's `components/` directory, then import it instead of the original ui-next component.
### Mode layout
The layoutTemplate is a function that returns a layout object, you can follow the example of `longitudinal/src/index.ts`. This would be helpful when you need to override a component as you can know where to look for the original component.
### Pub Sub
Always prioritrize pub sub, by calling a services subscribe over useEffects as it's more reliable, for example
```ts
useEffect(() => {
const subscriptions = [
cornerstoneViewportService.subscribe(EVENTS.VIEWPORT_DATA_CHANGED, handleViewportDataChanged),
syncGroupService.subscribe(EVENTS.VIEWPORT_REMOVED, onHotKeyRemoval),
syncGroupService.subscribe(EVENTS.VIEWPORT_ADDED, onHotKeyAddition),
];
return () => {
subscriptions.forEach(({ unsubscribe }) => unsubscribe());
};
}, []);
```
### Never modify core architecture
Do not modify the core and always find a way to implement the solution via the extensions and modes, only modify core as a last resort if all other fail or there's an architectural constraint.
## Configuration
### Plugin Configuration
Extensions are auto-discovered via `pluginConfig.json` and dynamically imported during build.
## Medical Imaging Specifics
### DICOM Support
- Multi-format: CT, MRI, X-Ray, Mammography, Ultrasound
- SOP Class handlers for specialized DICOM types (RT, SEG, SR)
- DICOMweb protocol for web-based image retrieval
### Hanging Protocols
Define how images are arranged and displayed:
- Located in `hps/` directories
- JSON configuration with viewport rules
- Support for priors comparison and multi-monitor layouts
### Measurement Tools
- Cornerstone Tools integration for annotations
- Bidirectional measurements, polylines, annotations
- Export capabilities (DICOM SR, CSV reports)
- AI-assisted measurements via ONNX models
+5 -6331
View File
File diff suppressed because it is too large. Load diff
-148
View File
@@ -10,7 +10,6 @@ Please find below the list of datasets used on the demo page, along with their r
## 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.
@@ -148,150 +147,3 @@ Stony Brook University COVID-19 Positive Cases (COVID-19-NY-SBU)
### 2.16.840.1.114362.1.11972228.22789312658.616067305.306.2
https://data.kitware.com/
### 1.2.276.0.7230010.3.1.2.296485376.1.1665793212.499772
### 2.25.269859997690759739055099378767846712697
### 1.3.6.1.4.1.14519.5.2.1.5099.8010.217836670708542506360829799868
### 1.3.6.1.4.1.14519.5.2.1.4792.2001.232252967813565730694525674696
### 1.3.6.1.4.1.14519.5.2.1.4792.2001.105216574054253895819671475627
### 1.3.6.1.4.1.5962.99.1.1117.5035.1620319789811.1.2.1
### 1.3.6.1.4.1.5962.99.1.1123.9231.1620326176300.1.2.1
### 1.3.6.1.4.1.5962.99.1.1126.3483.1620329455972.1.2.1
https://github.com/ImagingInformatics/hackathon-images
### 2.16.124.113543.6004.101.103.20021117.162333.1
### 2.16.124.113543.6004.101.103.20021117.190619.1
### 2.16.124.113543.6004.101.103.20021117.123455.1
### 2.16.124.113543.6004.101.103.20021117.061159.1
https://www.aapm.org/
### 1.2.840.113619.2.30.1.1762295590.1623.978668949.886
### 1.2.276.0.7230010.3.1.2.447481088.1.1669202398.851612
Custom data SPECT, specifically I123-FP-CIT (DaTSCAN) SPECT, evaluates the dopaminergic system to diagnose Parkinson's disease, especially when tremor symptoms are unclear. It helps distinguish Parkinson's disease from treatment-related tremor.
### 1.3.6.1.4.1.9328.50.1.54652
https://www.cancerimagingarchive.net/collection/rider-pilot/
Lung Image Database Consortium (LIDC). (2023) RIDER Pilot [Data set]. The Cancer Imaging Archive (TCIA). https://doi.org/10.7937/m87f-mz83
### 1.3.6.1.4.1.14519.5.2.1.331759366792756327296606233801322964986
Mayr, N., Yuh, W. T. C., Bowen, S., Harkenrider, M., Knopp, M. V., Lee, E. Y.-P., Leung, E., Lo, S. S., Small Jr., W., & Wolfson, A. H. (2023). Cervical Cancer – Tumor Heterogeneity: Serial Functional and Molecular Imaging Across the Radiation Therapy Course in Advanced Cervical Cancer (Version 1) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/ERZ5-QZ59
https://www.cancerimagingarchive.net/collection/cc-tumor-heterogeneity/
### 1.3.6.1.4.1.14519.5.2.1.297577087050970310787702792940607009472
Eslick, E. M., Kipritidis, J., Gradinscak, D., Stevens, M. J., Bailey, D. L., Harris, B., Booth, J. T., & Keall, P. J. (2022). CT Ventilation as a functional imaging modality for lung cancer radiotherapy (CT-vs-PET-Ventilation-Imaging) (Version 1) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/3ppx-7s22
https://www.cancerimagingarchive.net/collection/ct-vs-pet-ventilation-imaging/
### 1.3.6.1.4.1.14519.5.2.1.2103.7010.634114621738943599785009586807
### 1.3.6.1.4.1.14519.5.2.1.2103.7010.135953723682765205394176991681
Huang, W., Tudorica, A., Chui, S., Kemmer, K., Naik, A., Troxell, M., Oh, K., Roy, N., Afzal, A., & Holtorf, M. (2014). Variations of dynamic contrast-enhanced magnetic resonance imaging in evaluation of breast cancer therapy response: a multicenter data analysis challenge (QIN Breast DCE-MRI) (Version 2) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/k9/tcia.2014.a2n1ixox
https://www.cancerimagingarchive.net/collection/qin-breast-dce-mri/
### 1.3.6.1.4.1.14519.5.2.1.1.24766180081901755714059656629507905556
Cancer Moonshot Biobank. (2023). Cancer Moonshoot Biobank – Acute Myeloid Leukemia (CMB-AML) (Version 4) [Dataset]. The Cancer Imaging Archive. https://doi.org/10.7937/PCTE-6M66
https://www.cancerimagingarchive.net/collection/cmb-aml/
### 1.3.6.1.4.1.14519.5.2.1.3098.5025.285242291560760827564488897577
https://www.cancerimagingarchive.net/collection/anti-pd-1_lung/
Madhavi, P., Patel, S., & Tsao, A. S. (2019). Data from Anti-PD-1 Immunotherapy Lung [Data set]. The Cancer Imaging Archive. DOI: 10.7937/tcia.2019.zjjwb9ip
### 1.3.6.1.4.1.14519.5.2.1.1.84416332615988066829602832830236187384
https://www.cancerimagingarchive.net/collection/cmb-pca/
Cancer Moonshot Biobank. (2022). Cancer Moonshot Biobank – Prostate Cancer Collection (CMB-PCA) (Version 7) [Dataset]. The Cancer Imaging Archive. https://doi.org/10.7937/25T7-6Y12
### 1.3.6.1.4.1.32722.99.99.239341353911714368772597187099978969331
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. (2014). Data From NSCLC-Radiomics (version 4) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/K9/TCIA.2015.PF0M9REI
https://www.cancerimagingarchive.net/collection/nsclc-radiomics/
### 1.3.6.1.4.1.14519.5.2.1.7085.2626.494695569589117268722281491772
https://www.cancerimagingarchive.net/collection/cptac-ucec/
National Cancer Institute Clinical Proteomic Tumor Analysis Consortium (CPTAC). (2019). The Clinical Proteomic Tumor Analysis Consortium Uterine Corpus Endometrial Carcinoma Collection (CPTAC-UCEC) (Version 12) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/K9/TCIA.2018.3R3JUISW
### 1.3.6.1.4.1.14519.5.2.1.207544490797667703011829289839681390478
https://www.cancerimagingarchive.net/collection/remind/
Juvekar, P., Dorent, R., Kögl, F., Torio, E., Barr, C., Rigolo, L., Galvin, C., Jowkar, N., Kazi, A., Haouchine, N., Cheema, H., Navab, N., Pieper, S., Wells, W. M., Bi, W. L., Golby, A., Frisken, S., & Kapur, T. (2023). The Brain Resection Multimodal Imaging Database (ReMIND) (Version 1) [dataset]. The Cancer Imaging Archive. https://doi.org/10.7937/3RAG-D070
### 1.3.12.2.1107.5.1.4.60175.30000008042114404745300000010
Gavrielides, M. A., Kinnard, L. M., Myers, K. J., Peregoy, J., Pritchard, W. F., Zeng, R., Esparza, J., Karanian, J., & Petrick, N. (2015). Data From Phantom FDA [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/k9/TCIA.2015.orbjkmux
https://www.cancerimagingarchive.net/collection/phantom-fda/
### 1.3.6.1.4.1.14519.5.2.1.6834.5010.992793141464713669479982159310
https://www.cancerimagingarchive.net/collection/4d-lung/
Hugo, G. D., Weiss, E., Sleeman, W. C., Balik, S., Keall, P. J., Lu, J., & Williamson, J. F. (2016). Data from 4D Lung Imaging of NSCLC Patients (Version 2) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/K9/TCIA.2016.ELN8YGLE
### 1.3.6.1.4.1.9328.50.17.15423521354819720574322014551955370036
https://www.cancerimagingarchive.net/collection/rider-lung-pet-ct/
Muzi P, Wanner M, & Kinahan P. (2015). Data From RIDER Lung PET-CT. The Cancer Imaging Archive. https://doi.org/10.7937/k9/tcia.2015.ofip7tvm
### 1.3.6.1.4.1.14519.5.2.1.9823.1001.134394060407147891170882809392
https://www.cancerimagingarchive.net/collection/prostate-mri/
Choyke P, Turkbey B, Pinto P, Merino M, Wood B. (2016). Data From PROSTATE-MRI. The Cancer Imaging Archive. http://doi.org/10.7937/K9/TCIA.2016.6046GUDv
### 1.3.6.1.4.1.14519.5.2.1.191696062987463500085282581898315738844
https://www.cancerimagingarchive.net/collection/upenn-gbm/
Bakas, S., Sako, C., Akbari, H., Bilello, M., Sotiras, A., Shukla, G., Rudie, J. D., Flores Santamaria, N., Fathi Kazerooni, A., Pati, S., Rathore, S., Mamourian, E., Ha, S. M., Parker, W., Doshi, J., Baid, U., Bergman, M., Binder, Z. A., Verma, R., … Davatzikos, C. (2021). Multi-parametric magnetic resonance imaging (mpMRI) scans for de novo Glioblastoma (GBM) patients from the University of Pennsylvania Health System (UPENN-GBM) (Version 2) [Data set]. The Cancer Imaging Archive. https://doi.org/10.7937/TCIA.709X-DN49
### 1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481
https://www.cancerimagingarchive.net/collection/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.1620.1225.189514895974227080410265976065
Comstock, C. E., Gatsonis, C., Newstead, G. M., Snyder, B. S., Gareen, I. F., Bergin, J. T., Rahbar, H., Sung, J. S., Jacobs, C., Harvey, J. A., Nicholson, M. H., Ward, R. C., Holt, J., Prather, A., Miller, K. D., Schnall, M. D., & Kuhl, C. K. (2023). Abbreviated Breast MRI and Digital Tomosynthesis Mammography in Screening Women With Dense Breasts (EA1141) (Version 1) [dataset]. The Cancer Imaging Archive. https://doi.org/10.7937/2BAS-HR33
https://www.cancerimagingarchive.net/collection/ea1141/
### 1.2.276.0.7230010.3.1.2.2155604110.4180.1021041295.21
From OFFIS DICOM-Team
https://www.offis.de/
OFFIS DICOM-Team
+32 -58
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1.7-labs
# This dockerfile is used to publish the `ohif/app` image on dockerhub.
#
# It's a good example of how to build our static application and package it
@@ -20,86 +19,61 @@
#
# syntax=docker/dockerfile:1.7-labs
# This dockerfile is used to publish the `ohif/app` image on dockerhub.
#
# It's a good example of how to build our static application and package it
# with a web server capable of hosting it as static content.
#
# docker build
# --------------
# If you would like to use this dockerfile to build and tag an image, make sure
# you set the context to the project's root directory:
# https://docs.docker.com/engine/reference/commandline/build/
#
#
# SUMMARY
# --------------
# This dockerfile is used as an input for a second stage to make things run faster.
#
# Stage 1: Build the application
# docker build -t ohif/viewer:latest .
# Copy Files
FROM node:20.18.1-slim as builder
RUN apt-get update && apt-get install -y build-essential python3
FROM node:18.16.1-slim as json-copier
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
RUN npm install -g bun@1.2.23
RUN npm install -g lerna@7.4.2
ENV PATH=/usr/src/app/node_modules/.bin:$PATH
# Do an initial install and then a final install
COPY package.json yarn.lock preinstall.js lerna.json ./
COPY --parents ./addOns/package.json ./addOns/*/*/package.json ./extensions/*/package.json ./modes/*/package.json ./platform/*/package.json ./
COPY ["package.json", "yarn.lock", "preinstall.js", "./"]
COPY extensions /usr/src/app/extensions
COPY modes /usr/src/app/modes
COPY platform /usr/src/app/platform
# Find and remove non-package.json files
#RUN find extensions \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
#RUN find modes \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
#RUN find platform \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
# Copy Files
FROM node:18.16.1-slim as builder
RUN apt-get update && apt-get install -y build-essential python3
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
COPY --from=json-copier /usr/src/app .
# Run the install before copying the rest of the files
RUN yarn config set workspaces-experimental true
RUN yarn install --frozen-lockfile --verbose
RUN bun pm cache rm
RUN bun install
RUN bun add ajv@8.12.0
# Copy the local directory
COPY --link --exclude=yarn.lock --exclude=package.json --exclude=Dockerfile . .
COPY . .
# Build here
# After install it should hopefully be stable until the local directory changes
# To restore workspaces symlinks
RUN yarn install --frozen-lockfile --verbose
ENV PATH /usr/src/app/node_modules/.bin:$PATH
ENV QUICK_BUILD true
# ENV GENERATE_SOURCEMAP=false
ARG APP_CONFIG=config/default.js
ARG PUBLIC_URL=/
ENV PUBLIC_URL=${PUBLIC_URL}
# ENV REACT_APP_CONFIG=config/default.js
RUN bun run show:config
RUN bun run build
# Precompress files
RUN chmod u+x .docker/compressDist.sh
RUN ./.docker/compressDist.sh
RUN yarn run build
# Stage 3: Bundle the built application into a Docker container
# which runs Nginx using Alpine Linux
FROM nginxinc/nginx-unprivileged:1.27-alpine as final
FROM nginxinc/nginx-unprivileged:1.25-alpine as final
#RUN apk add --no-cache bash
ARG PUBLIC_URL=/
ENV PUBLIC_URL=${PUBLIC_URL}
ARG PORT=80
ENV PORT=${PORT}
ENV PORT=80
RUN rm /etc/nginx/conf.d/default.conf
USER nginx
COPY --chown=nginx:nginx .docker/Viewer-v3.x /usr/src
RUN chmod 777 /usr/src/entrypoint.sh
COPY --from=builder /usr/src/app/platform/app/dist /usr/share/nginx/html${PUBLIC_URL}
# Copy paths that are renamed/redirected generally
# Microscopy libraries depend on root level include, so must be copied
COPY --from=builder /usr/src/app/platform/app/dist/dicom-microscopy-viewer /usr/share/nginx/html/dicom-microscopy-viewer
COPY --from=builder /usr/src/app/platform/app/dist /usr/share/nginx/html
# In entrypoint.sh, app-config.js might be overwritten, so chmod it to be writeable.
# The nginx user cannot chmod it, so change to root.
USER root
RUN chown -R nginx:nginx /usr/share/nginx/html && chmod -R 777 /usr/share/nginx/html
RUN chmod 666 /usr/share/nginx/html/app-config.js
USER nginx
ENTRYPOINT ["/usr/src/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+21 -82
View File
@@ -1,4 +1,5 @@
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<div align="center">
<h1>OHIF Medical Imaging Viewer</h1>
<p><strong>The OHIF Viewer</strong> is a zero-footprint medical image viewer
@@ -49,12 +50,12 @@ provided by the <a href="https://ohif.org/">Open Health Imaging Foundation (OHIF
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-4d.webp?raw=true" alt="4D" width="350"/> | 4D | [Demo](https://viewer.ohif.org/dynamic-volume?StudyInstanceUIDs=2.25.232704420736447710317909004159492840763) |
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-video.webp?raw=true" alt="VIDEO" width="350"/> | Video | [Demo](https://viewer.ohif.org/viewer?StudyInstanceUIDs=2.25.96975534054447904995905761963464388233) |
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/microscopy.webp?raw=true" alt="microscopy" width="350"/> | Slide Microscopy | [Demo](https://viewer.ohif.org/microscopy?StudyInstanceUIDs=2.25.141277760791347900862109212450152067508) |
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-ecg.webp?raw=true" alt="ECG" width="350"/> | ECG Waveform | [Demo](https://viewer-dev.ohif.org/viewer?StudyInstanceUIDs=2.25.209974489360710696739324151261716440238) |
## About
The OHIF Viewer can retrieve
and load images from most sources and formats, render sets in 2D, 3D, and
The OHIF Medical Imaging Viewer is for viewing medical images. It can retrieve
and load images from most sources and formats; render sets in 2D, 3D, and
reconstructed representations; allows for the manipulation, annotation, and
serialization of observations; supports internationalization, OpenID Connect,
offline use, hotkeys, and many more features.
@@ -77,7 +78,7 @@ contributions of individuals, research groups, and commercial organizations.
After more than 8-years of integrating with many companies and organizations,
The OHIF Viewer has been rebuilt from the ground up to better address the
varying workflow and configuration needs of its many users. All of the Viewer's
core features are built using its own extension system. The same extensibility
core features are built using it's own extension system. The same extensibility
that allows us to offer:
- 2D and 3D medical image viewing
@@ -105,6 +106,7 @@ For commercial support, academic collaborations, and answers to common
questions; please use [Get Support](https://ohif.org/get-support/) to contact
us.
## Developing
### Branches
@@ -117,16 +119,19 @@ This is typically where the latest development happens. Code that is in the mast
Each package is tagged with beta version numbers, and published to npm such as `@ohif/ui@3.6.0-beta.1`
### `release/*` branches - The latest stable releases
Once the `master` branch code reaches a stable, release-ready state, we conduct a comprehensive code review and QA testing. Upon approval, we create a new release branch from `master`. These branches represent the latest stable version considered ready for production.
### `release` branch - The latest stable release
For example, `release/3.5` is the branch for version 3.5.0, and `release/3.6` is for version 3.6.0. After each release, we wait a few days to ensure no critical bugs. If any are found, we fix them in the release branch and create a new release with a minor version bump, e.g., 3.5.1 in the `release/3.5` branch.
This branch represents the latest stable version of the project that is considered ready for production. The code in this branch should be fully tested and vetted for release. Once the code in the master branch reaches a state where it's stable and ready to be released to users,
we do a comprehensive code review and QA testing. Once the code is approved,
we merge it into the release branch and tag a new release.
Each package is tagged with version numbers and published to npm, such as `@ohif/ui@3.5.0`. Note that `master` is always ahead of the `release` branch. We publish docker builds for both beta and stable releases.
Each package is tagged with version numbers, and published to npm such as `@ohif/ui@3.5.0`
Note: `master` is always ahead of `release` branch. We publish both docker builds for beta and stable releases.
Here is a schematic representation of our development workflow:
![alt text](platform/docs/docs/assets/img/github-readme-branches-Jun2024.png)
![Alt text](platform/docs/docs/assets/img/github-readme-branches.png)
@@ -134,8 +139,8 @@ Here is a schematic representation of our development workflow:
### Requirements
- [Yarn 1.20.0+](https://yarnpkg.com/en/docs/install)
- [Node 18+](https://nodejs.org/en/)
- [Yarn 1.17.3+](https://yarnpkg.com/en/docs/install)
- [Node 16+](https://nodejs.org/en/)
- Yarn Workspaces should be enabled on your machine:
- `yarn config set workspaces-experimental true`
@@ -147,14 +152,7 @@ Here is a schematic representation of our development workflow:
3. Navigate to the cloned project's directory
4. Add this repo as a `remote` named `upstream`
- `git remote add upstream https://github.com/OHIF/Viewers.git`
5. `yarn install --frozen-lockfile` to restore dependencies and link projects
:::danger
In general run `yarn install` with the `--frozen-lockfile` flag to help avoid
supply chain attacks by enforcing reproducible dependencies. That is, if the
`yarn.lock` file is clean and does NOT reference compromised packages, then
no compromised packages should land on your machine by using this flag.
:::
5. `yarn install` to restore dependencies and link projects
#### To Develop
@@ -165,67 +163,9 @@ _From this repository's root directory:_
yarn config set workspaces-experimental true
# Restore dependencies
yarn install --frozen-lockfile
yarn install
```
### Cornerstone3D Integration Testing
OHIF's Playwright end-to-end tests can run against a **CS3D branch** or a
**published CS3D version**, allowing changes that span both repositories to be
validated together before merging.
#### Setting up an integration build
1. Add the **`ohif-integration`** label to your OHIF pull request.
2. In the PR body, add a line specifying the CS3D ref:
```
CS3D_REF: feat/my-feature
```
- **Version ref** (e.g. `4.19+`, `4.18.2`) — the workflow resolves it to an
exact published version and swaps the CS3D dependency via npm.
- **Branch ref** (e.g. `main`, `cornerstonejs:feat/foo`) — the workflow
clones the branch, builds CS3D from source with `bun run build:esm`, and
symlinks the built packages into OHIF's `node_modules`.
- For forks, use the `<owner>:<branch>` format
(e.g. `myGithubUser:feat/foo`).
- If no `CS3D_REF` is specified, the default is `4.19+`.
3. The workflow can also be triggered manually via **workflow_dispatch** with a
`cs3d_ref` input.
#### What happens in CI
The [Playwright workflow](.github/workflows/playwright.yml) runs two jobs:
| Job | Purpose |
|-----|---------|
| **Playwright Tests** | Builds OHIF (with CS3D linked or version-swapped), runs the full Playwright suite, uploads test results and coverage, and deploys a Netlify preview when `ohif-integration` is active. |
| **CS3D Branch Merge Guard** | A lightweight check that **fails** when the `ohif-integration` label is present and `CS3D_REF` points to a branch (not a version). This prevents merging while still letting the Playwright tests show green so you can see whether the code actually works. |
#### Testing changes that span both repos
If a feature requires changes in both Cornerstone3D and OHIF:
1. Create your feature branch in CS3D and push it.
2. Create a matching branch in OHIF.
3. Add the `ohif-integration` label to the OHIF pull request.
4. In the PR body, add: `CS3D_REF: <your-cs3d-branch>`.
5. Playwright tests will build CS3D from source, link it, and run the full
suite. The merge guard will block merge until you switch to a published
version — but you can see the test results and the preview deploy while
iterating.
6. Once the CS3D side is merged and published, update the PR body to reference
the published version (e.g. `CS3D_REF: 4.19+`). The tests will run against
the registry version and the merge guard will pass.
#### Preview deploys
When `ohif-integration` is active, the Playwright workflow also builds the OHIF
viewer and deploys it to Netlify as a preview. This gives you a live URL to
manually test the combined CS3D + OHIF changes without running anything locally.
For details on linking CS3D locally for development, see the
[Cornerstone3D README](libs/@cornerstonejs/README.md#local-development-linking--unlinking).
## Commands
These commands are available from the root directory. Each project directory
@@ -235,13 +175,12 @@ also supports a number of commands that can be found in their respective
| Yarn Commands | Description |
| ---------------------------- | ------------------------------------------------------------- |
| **Develop** | |
| `dev` | Default development experience for Viewer |
| `dev:fast` | Our experimental fast dev mode that uses rsbuild instead of webpack |
| `dev` or `start` | Default development experience for Viewer |
| `test:unit` | Jest multi-project test runner; overall coverage |
| **Deploy** | |
| `build`\* | Builds production output for our PWA Viewer | |
\* - For more information on different builds, check out our [Deploy
\* - For more information on our different builds, check out our [Deploy
Docs][deployment-docs]
## Project
@@ -398,4 +337,4 @@ MIT © [OHIF](https://github.com/OHIF)
[vtk-npm]: https://www.npmjs.com/package/@ohif/extension-vtk
<!-- prettier-ignore-end -->
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FOHIF%2FViewers.svg?type=large&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2FOHIF%2FViewers?ref=badge_large&issueType=license)
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FOHIF%2FViewers.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FOHIF%2FViewers?ref=badge_large)
-3
View File
@@ -1,3 +0,0 @@
# External Dependencies
This module contains optional dependencies and external dependencies for including in OHIF, such as the DICOM Microscopy Viewer component.
File diff suppressed because it is too large. Load diff
-91
View File
@@ -1,91 +0,0 @@
{
"name": "@externals/devDependencies",
"description": "External dev dependencies - put dev build dependencies here",
"version": "3.13.0-beta.83",
"license": "MIT",
"private": true,
"engines": {
"node": ">=12",
"yarn": ">=1.19.1"
},
"dependencies": {
"@babel/runtime": "7.28.2",
"@kitware/vtk.js": "34.15.1",
"clsx": "2.1.1",
"core-js": "3.45.1",
"moment": "2.30.1"
},
"peerDependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@pmmmwh/react-refresh-webpack-plugin": "0.5.17",
"@rsbuild/core": "1.5.1",
"@rsbuild/plugin-node-polyfill": "1.4.2",
"@rsbuild/plugin-react": "1.4.0",
"@svgr/webpack": "8.1.0",
"@swc/helpers": "0.5.17",
"@types/jest": "27.5.2",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"autoprefixer": "10.4.21",
"babel-loader": "8.4.1",
"clean-webpack-plugin": "3.0.0",
"copy-webpack-plugin": "9.1.0",
"cross-env": "7.0.3",
"css-loader": "6.11.0",
"dotenv": "8.6.0",
"eslint": "9.39.3",
"eslint-config-prettier": "7.2.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-cypress": "2.15.2",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "5.5.1",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-tsdoc": "0.2.17",
"execa": "8.0.1",
"extract-css-chunks-webpack-plugin": "4.10.0",
"html-webpack-plugin": "5.6.3",
"husky": "3.1.0",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
"jest-environment-jsdom": "29.7.0",
"jest-junit": "6.4.0",
"lerna": "9.0.4",
"lint-staged": "9.5.0",
"mini-css-extract-plugin": "2.9.2",
"optimize-css-assets-webpack-plugin": "6.0.1",
"postcss": "8.5.6",
"postcss-import": "14.1.0",
"postcss-loader": "6.2.1",
"postcss-preset-env": "7.8.3",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.9",
"react-refresh": "0.14.2",
"semver": "7.7.2",
"serve": "14.2.5",
"shader-loader": "1.3.1",
"shx": "0.3.4",
"source-map-loader": "4.0.2",
"style-loader": "1.3.0",
"terser-webpack-plugin": "5.3.14",
"typescript": "5.5.4",
"unused-webpack-plugin": "2.4.0",
"webpack": "5.105.0",
"webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.2",
"webpack-hot-middleware": "2.26.1",
"webpack-merge": "5.10.0",
"workbox-webpack-plugin": "6.6.1",
"worker-loader": "3.0.8"
},
"scripts": {
"build": "Included as direct dependency"
}
}
File diff suppressed because it is too large. Load diff
-9
View File
@@ -1,9 +0,0 @@
{
"name": "@externals/dicom-microscopy-viewer",
"description": "External reference to dicom-microscopy-viewer",
"version": "3.13.0-beta.83",
"license": "MIT",
"dependencies": {
"dicom-microscopy-viewer": "0.48.17"
}
}
-50
View File
@@ -1,50 +0,0 @@
{
"name": "ohif-monorepo-root",
"private": true,
"packageManager": "yarn@1.22.22",
"workspaces": {
"packages": [
"../platform/i18n",
"../platform/core",
"../platform/ui",
"../platform/ui-next",
"../platform/app",
"../extensions/*",
"../modes/*",
"../addOns/externals/*"
],
"nohoist": [
"**/html-minifier-terser"
]
},
"scripts": {
"preinstall": "cd .. && node preinstall.js"
},
"devDependencies": {
"@babel/core": "7.28.0",
"@babel/plugin-transform-class-properties": "7.27.1",
"@babel/plugin-transform-object-rest-spread": "7.28.0",
"@babel/plugin-transform-private-methods": "7.27.1",
"@babel/plugin-transform-private-property-in-object": "7.27.1",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/plugin-transform-arrow-functions": "7.27.1",
"@babel/plugin-transform-regenerator": "7.28.1",
"@babel/plugin-transform-runtime": "7.28.0",
"@babel/plugin-transform-typescript": "7.28.0",
"@babel/preset-env": "7.29.5",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1"
},
"resolutions": {
"**/@babel/runtime": "7.28.2",
"commander": "8.3.0",
"dcmjs": "0.49.4",
"dicomweb-client": "0.10.4",
"nth-check": "2.1.1",
"trim-newlines": "5.0.0",
"glob-parent": "6.0.2",
"trim": "1.0.1",
"package-json": "8.1.1",
"typescript": "5.5.4"
}
}
+24 -10
View File
@@ -1,12 +1,31 @@
// https://babeljs.io/docs/en/options#babelrcroots
const { extendDefaultPlugins } = require('svgo');
module.exports = {
babelrcRoots: ['./platform/*', './extensions/*', './modes/*'],
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
plugins: [
['@babel/plugin-transform-class-properties', { loose: true }],
[
'inline-react-svg',
{
svgo: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
},
},
],
},
},
],
['@babel/plugin-proposal-class-properties', { loose: true }],
'@babel/plugin-transform-typescript',
['@babel/plugin-transform-private-property-in-object', { loose: true }],
['@babel/plugin-transform-private-methods', { loose: true }],
['@babel/plugin-proposal-private-property-in-object', { loose: true }],
['@babel/plugin-proposal-private-methods', { loose: true }],
'@babel/plugin-transform-class-static-block',
],
env: {
@@ -18,24 +37,18 @@ module.exports = {
{
modules: 'commonjs',
debug: false,
targets: { node: 'current' },
bugfixes: true,
},
],
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: [
'babel-plugin-istanbul',
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/transform-destructuring',
'@babel/plugin-transform-runtime',
'@babel/plugin-transform-typescript',
'@babel/plugin-transform-class-static-block',
'@babel/plugin-transform-for-of',
['babel-plugin-transform-import-meta', { module: 'ES6' }],
],
},
production: {
@@ -54,6 +67,7 @@ module.exports = {
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: ['react-hot-loader/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
-8176
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -1,2 +0,0 @@
[install]
frozenLockfile = true
-2
View File
@@ -1,2 +0,0 @@
[install]
frozenLockfile = false
+1 -1
View File
@@ -1 +1 @@
0c1ca6b04a6c1aef478c7a7d9f360f9b9c50f188
1dcdc9dabe9d4179f223912d6adc00bc95629a85
@@ -1,12 +0,0 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
};
@@ -1,54 +0,0 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, '../');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
const outputName = `ohif-${pkg.name.split('/').pop()}`;
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-pmap',
libraryTarget: 'umd',
filename: pkg.main,
},
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
new MiniCssExtractPlugin({
filename: `./dist/${outputName}.css`,
chunkFilename: `./dist/${outputName}.css`,
}),
],
});
};
File diff suppressed because it is too large. Load diff
-20
View File
@@ -1,20 +0,0 @@
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.
@@ -1,12 +0,0 @@
# dicom-pmap
## Description
DICOM PMAP read workflow. This extension will allow you to load a DICOM Parametric
Map image and display it on OHIF.
## Author
OHIF
## License
MIT
@@ -1,44 +0,0 @@
module.exports = {
plugins: ['@babel/plugin-transform-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-transform-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__'],
},
},
};
@@ -1,53 +0,0 @@
{
"name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.13.0-beta.83",
"description": "DICOM Parametric Map read workflow",
"author": "OHIF",
"license": "MIT",
"main": "dist/ohif-extension-cornerstone-dicom-pmap.umd.js",
"module": "src/index.tsx",
"files": [
"dist/**",
"public/**",
"README.md"
],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": {
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-pmap": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.13.0-beta.83",
"@ohif/extension-cornerstone": "3.13.0-beta.83",
"@ohif/extension-default": "3.13.0-beta.83",
"@ohif/i18n": "3.13.0-beta.83",
"prop-types": "15.8.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "12.3.1",
"react-router": "6.30.3",
"react-router-dom": "6.30.3"
},
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "4.22.10",
"@cornerstonejs/core": "4.22.10",
"@kitware/vtk.js": "34.15.1"
}
}
@@ -1,245 +0,0 @@
import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { metaData, cache, utilities as csUtils, volumeLoader } from '@cornerstonejs/core';
import { adaptersPMAP } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomLoaderService, VOLUME_LOADER_SCHEME } from '@ohif/extension-cornerstone';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.30'];
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.ServicesManager,
extensionManager
) {
const instance = instances[0];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPClassUID,
wadoRoot,
wadoUri,
wadoUriRoot,
} = instance;
const displaySet = {
// Parametric map use to have the same modality as its referenced volume but
// "PMAP" is used in the viewer even though this is not a valid DICOM modality
Modality: 'PMAP',
isReconstructable: true, // by default for now
displaySetInstanceUID: `pmap.${utils.guid()}`,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
referencedVolumeURI: null,
referencedVolumeId: null,
isDerivedDisplaySet: true,
loadStatus: {
loading: false,
loaded: false,
},
sopClassUids,
instance,
instances: [instance],
wadoRoot,
wadoUriRoot,
wadoUri,
supportsWindowLevel: true,
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('PMAP')}`,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (!referencedSeriesSequence) {
console.error('ReferencedSeriesSequence is missing for the parametric map');
return;
}
const referencedSeries = referencedSeriesSequence[0] || referencedSeriesSequence;
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
// Does not get the referenced displaySet during parametric displaySet creation
// because it is still not available (getDisplaySetByUID returns `undefined`).
displaySet.getReferenceDisplaySet = () => {
const { displaySetService } = servicesManager.services;
if (displaySet.referencedDisplaySetInstanceUID) {
return displaySetService.getDisplaySetByUID(displaySet.referencedDisplaySetInstanceUID);
}
const referencedDisplaySets = displaySetService.getDisplaySetsForSeries(
displaySet.referencedSeriesInstanceUID
);
if (!referencedDisplaySets || referencedDisplaySets.length === 0) {
throw new Error('Referenced displaySet is missing for the parametric map');
}
const referencedDisplaySet = referencedDisplaySets[0];
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
return referencedDisplaySet;
};
// Does not get the referenced volumeId during parametric displaySet creation because the
// referenced displaySet is still not available (getDisplaySetByUID returns `undefined`).
displaySet.getReferencedVolumeId = () => {
if (displaySet.referencedVolumeId) {
return displaySet.referencedVolumeId;
}
const referencedDisplaySet = displaySet.getReferenceDisplaySet();
const referencedVolumeURI = referencedDisplaySet.displaySetInstanceUID;
const referencedVolumeId = `${VOLUME_LOADER_SCHEME}:${referencedVolumeURI}`;
displaySet.referencedVolumeURI = referencedVolumeURI;
displaySet.referencedVolumeId = referencedVolumeId;
return referencedVolumeId;
};
displaySet.load = async ({ headers }) =>
await _load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
const getRangeFromPixelData = (pixelData: Float32Array) => {
let lowest = pixelData[0];
let highest = pixelData[0];
for (let i = 1; i < pixelData.length; i++) {
if (pixelData[i] < lowest) {
lowest = pixelData[i];
}
if (pixelData[i] > highest) {
highest = pixelData[i];
}
}
return [lowest, highest];
};
async function _load(
displaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager,
headers
) {
const volumeId = `${VOLUME_LOADER_SCHEME}:${displaySet.displaySetInstanceUID}`;
const volumeLoadObject = cache.getVolumeLoadObject(volumeId);
if (volumeLoadObject) {
return volumeLoadObject.promise;
}
displaySet.loading = true;
displaySet.isLoaded = false;
// 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.
const promise = _loadParametricMap({
extensionManager,
displaySet,
headers,
});
cache.putVolumeLoadObject(volumeId, { promise }).catch(err => {
throw err;
});
promise
.then(() => {
displaySet.loading = false;
displaySet.isLoaded = true;
// Broadcast that loading is complete
servicesManager.services.segmentationService._broadcastEvent(
servicesManager.services.segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
{
pmapDisplaySet: displaySet,
}
);
})
.catch(err => {
displaySet.loading = false;
displaySet.isLoaded = false;
throw err;
});
return promise;
}
async function _loadParametricMap({ displaySet, headers }: withAppTypes) {
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(displaySet, null, headers);
const referencedVolumeId = displaySet.getReferencedVolumeId();
const cachedReferencedVolume = cache.getVolume(referencedVolumeId);
// Parametric map can be loaded only if its referenced volume exists otherwise it will fail
if (!cachedReferencedVolume) {
throw new Error(
'Referenced Volume is missing for the PMAP, and stack viewport PMAP is not supported yet'
);
}
const { imageIds } = cachedReferencedVolume;
const results = await adaptersPMAP.Cornerstone3D.ParametricMap.generateToolState(
imageIds,
arrayBuffer,
metaData
);
const { pixelData } = results;
const TypedArrayConstructor = pixelData.constructor;
const paramMapId = displaySet.displaySetInstanceUID;
const derivedVolume = await volumeLoader.createAndCacheDerivedVolume(referencedVolumeId, {
volumeId: paramMapId,
targetBuffer: {
type: TypedArrayConstructor.name,
},
});
const newPixelData = new TypedArrayConstructor(pixelData.length);
for (let i = 0; i < pixelData.length; i++) {
newPixelData[i] = pixelData[i] * 100;
}
derivedVolume.voxelManager.setCompleteScalarDataArray(newPixelData);
const range = getRangeFromPixelData(newPixelData);
const windowLevel = csUtils.windowLevel.toWindowLevel(range[0], range[1]);
derivedVolume.metadata.voiLut = [windowLevel];
derivedVolume.loadStatus = { loaded: true };
return derivedVolume;
}
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
const { servicesManager, extensionManager } = params;
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return [
{
name: 'dicom-pmap',
sopClassUids,
getDisplaySetsFromSeries,
},
];
}
export default getSopClassHandlerModule;
@@ -1,7 +0,0 @@
import packageJson from '../package.json';
const id = packageJson.name;
const SOPClassHandlerName = 'dicom-pmap';
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
export { id, SOPClassHandlerId, SOPClassHandlerName };
@@ -1,39 +0,0 @@
import { id } from './id';
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstonePMAPViewport');
});
const OHIFCornerstonePMAPViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};
/**
* You can remove any of the following modules if you don't need them.
*/
const extension = {
id,
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
const ExtendedOHIFCornerstonePMAPViewport = props => {
return (
<OHIFCornerstonePMAPViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
{...props}
/>
);
};
return [{ name: 'dicom-pmap', component: ExtendedOHIFCornerstonePMAPViewport }];
},
getSopClassHandlerModule,
};
export default extension;
@@ -1,202 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewportGrid } from '@ohif/ui-next';
import { OHIFCornerstoneViewport } from '@ohif/extension-cornerstone';
function OHIFCornerstonePMAPViewport(props: withAppTypes) {
const { displaySets, children, viewportOptions, displaySetOptions, servicesManager } = props;
const viewportId = viewportOptions.viewportId;
const { displaySetService, segmentationService, uiNotificationService, customizationService } =
servicesManager.services;
// PMAP viewport will always have a single display set
if (displaySets.length !== 1) {
throw new Error('PMAP viewport must have a single display set');
}
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const pmapDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
const referencedDisplaySetRef = useRef(null);
const { viewports, activeViewportId } = viewportGrid;
const referencedDisplaySet = pmapDisplaySet.getReferenceDisplaySet();
const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata(
referencedDisplaySet,
pmapDisplaySet
);
referencedDisplaySetRef.current = {
displaySet: referencedDisplaySet,
metadata: referencedDisplaySetMetadata,
};
const [pmapIsLoading, setPmapIsLoading] = useState(!pmapDisplaySet.isLoaded);
// Add effect to listen for loading complete
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (evt.pmapDisplaySet?.displaySetInstanceUID === pmapDisplaySet.displaySetInstanceUID) {
setPmapIsLoading(false);
}
}
);
return () => {
unsubscribe();
};
}, [pmapDisplaySet]);
const getCornerstoneViewport = useCallback(() => {
const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current;
displaySetOptions.unshift({});
const [pmapDisplaySetOptions] = displaySetOptions;
// Make sure `options` exists
pmapDisplaySetOptions.options = pmapDisplaySetOptions.options ?? {};
Object.assign(pmapDisplaySetOptions.options, {
colormap: {
name: 'rainbow_2',
opacity: [
{ value: 0, opacity: 0 },
{ value: 0.25, opacity: 0.25 },
{ value: 0.5, opacity: 0.5 },
{ value: 0.75, opacity: 0.75 },
{ value: 0.9, opacity: 0.99 },
],
},
voi: {
windowCenter: 50,
windowWidth: 100,
},
});
uiNotificationService.show({
title: 'Parametric Map',
type: 'warning',
message: 'The values are multiplied by 100 in the viewport for better visibility',
});
return (
<OHIFCornerstoneViewport
{...props}
// Referenced + PMAP displaySets must be passed as parameter in this order
displaySets={[referencedDisplaySet, pmapDisplaySet]}
viewportOptions={{
viewportType: 'volume',
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
displaySetOptions={[{}, pmapDisplaySetOptions]}
/>
);
}, [
displaySetOptions,
props,
pmapDisplaySet,
viewportOptions.orientation,
viewportOptions.viewportId,
viewportOptions.presentationIds,
uiNotificationService,
]);
// Cleanup the PMAP viewport when the viewport is destroyed
useEffect(() => {
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports.get(activeViewportId);
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
viewportGridService.setDisplaySetsForViewport({
viewportId: activeViewportId,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, [activeViewportId, displaySetService, viewportGridService, viewports]);
let childrenWithProps = null;
if (children && children.length) {
childrenWithProps = children.map((child, index) => {
return (
child &&
React.cloneElement(child, {
viewportId,
key: index,
})
);
});
}
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
{pmapIsLoading && (
<LoadingIndicatorTotalPercent
className="h-full w-full"
totalNumbers={null}
percentComplete={null}
loadingText="Loading Parametric Map..."
/>
)}
{getCornerstoneViewport()}
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstonePMAPViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
};
function _getReferencedDisplaySetMetadata(referencedDisplaySet, pmapDisplaySet) {
const { SharedFunctionalGroupsSequence } = pmapDisplaySet.instance;
const SharedFunctionalGroup = Array.isArray(SharedFunctionalGroupsSequence)
? SharedFunctionalGroupsSequence[0]
: SharedFunctionalGroupsSequence;
const { PixelMeasuresSequence } = SharedFunctionalGroup;
const PixelMeasures = Array.isArray(PixelMeasuresSequence)
? PixelMeasuresSequence[0]
: PixelMeasuresSequence;
const { SpacingBetweenSlices, SliceThickness } = PixelMeasures;
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
PatientID: image0.PatientID,
PatientName: image0.PatientName,
PatientSex: image0.PatientSex,
PatientAge: image0.PatientAge,
SliceThickness: image0.SliceThickness || SliceThickness,
StudyDate: image0.StudyDate,
SeriesDescription: image0.SeriesDescription,
SeriesInstanceUID: image0.SeriesInstanceUID,
SeriesNumber: image0.SeriesNumber,
ManufacturerModelName: image0.ManufacturerModelName,
SpacingBetweenSlices: image0.SpacingBetweenSlices || SpacingBetweenSlices,
};
return referencedDisplaySetMetadata;
}
export default OHIFCornerstonePMAPViewport;
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
module.exports = {
plugins: ['@babel/plugin-transform-class-properties'],
plugins: ['inline-react-svg', '@babel/plugin-proposal-class-properties'],
env: {
test: {
presets: [
@@ -15,7 +15,7 @@ module.exports = {
'@babel/preset-react',
],
plugins: [
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
@@ -37,6 +37,7 @@ module.exports = {
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: ['react-hot-loader/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
+13 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.13.0-beta.83",
"version": "3.8.0",
"description": "DICOM RT read workflow",
"author": "OHIF",
"license": "MIT",
@@ -33,18 +33,19 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.13.0-beta.83",
"@ohif/extension-cornerstone": "3.13.0-beta.83",
"@ohif/extension-default": "3.13.0-beta.83",
"@ohif/i18n": "3.13.0-beta.83",
"prop-types": "15.8.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "12.3.1",
"react-router": "6.30.3",
"react-router-dom": "6.30.3"
"@ohif/core": "3.8.0",
"@ohif/extension-cornerstone": "3.8.0",
"@ohif/extension-default": "3.8.0",
"@ohif/i18n": "3.8.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"
},
"dependencies": {
"@babel/runtime": "7.28.2"
"@babel/runtime": "^7.20.13",
"react-color": "^2.19.3"
}
}
@@ -1,55 +0,0 @@
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
const services = servicesManager.services;
const { displaySetService, viewportGridService } = services;
const actions = {
hydrateRTSDisplaySet: ({ displaySet, viewportId }) => {
if (displaySet.Modality !== 'RTSTRUCT') {
throw new Error('Display set is not an RTSTRUCT');
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
displaySet.referencedDisplaySetInstanceUID
);
// update the previously stored segmentationPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will have the correct segmentation representation hydrated
commandsManager.runCommand('updateStoredSegmentationPresentation', {
displaySet: displaySet,
type: SegmentationRepresentations.Contour,
});
// update the previously stored positionPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will be in the correct position zoom and pan
commandsManager.runCommand('updateStoredPositionPresentation', {
viewportId,
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
});
viewportGridService.setDisplaySetsForViewport({
viewportId,
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
});
},
};
const definitions = {
hydrateRTSDisplaySet: {
commandFn: actions.hydrateRTSDisplaySet,
storeContexts: [],
options: {},
},
};
return {
actions,
definitions,
defaultContext: 'cornerstone-dicom-rt',
};
};
export default commandsModule;
@@ -0,0 +1,193 @@
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;
@@ -1,235 +0,0 @@
import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { segmentation as cstSegmentation } from '@cornerstonejs/tools';
import { SOPClassHandlerId } from './id';
import loadRTStruct from './loadRTStruct';
const { sopClassDictionary } = utils;
const sopClassUids = [sopClassDictionary.RTStructureSetStorage];
const loadPromises = {};
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.ServicesManager,
extensionManager
) {
utils.sortStudyInstances(instances);
// Choose the LAST instance in the list as the most recently created one.
const instance = instances[instances.length - 1];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription = '',
SeriesNumber,
SeriesDate,
SeriesTime,
StructureSetDate,
StructureSetTime,
SOPClassUID,
wadoRoot,
wadoUri,
wadoUriRoot,
imageId: predecessorImageId,
} = instance;
const displaySet = {
Modality: 'RTSTRUCT',
loading: false,
isReconstructable: false,
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
/**
* The "SeriesDate" for a display set is really the display set date, which
* should be the date of the instance being used, which will be the structure
* set date in this case.
*/
SeriesDate: StructureSetDate || SeriesDate,
SeriesTime: StructureSetTime || SeriesTime,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
FrameOfReferenceUID: null,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
isDerivedDisplaySet: true,
isLoaded: false,
isHydrated: false,
structureSet: null,
sopClassUids,
instance,
instances,
predecessorImageId,
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('RTSTRUCT')}`,
};
let referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (
instance.ReferencedFrameOfReferenceSequence?.RTReferencedStudySequence &&
!instance.ReferencedSeriesSequence
) {
instance.ReferencedSeriesSequence = _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
instance.ReferencedFrameOfReferenceSequence
);
referencedSeriesSequence = instance.ReferencedSeriesSequence;
}
if (!referencedSeriesSequence) {
console.error('ReferencedSeriesSequence is missing for the RTSTRUCT');
return;
}
const referencedSeries = referencedSeriesSequence[0];
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
displaySet.FrameOfReferenceUID =
instance.ReferencedFrameOfReferenceSequence?.[0]?.FrameOfReferenceUID;
const { displaySetService } = servicesManager.services;
const referencedDisplaySets =
displaySetService.getDisplaySetsForReferences(referencedSeriesSequence);
if (referencedDisplaySets?.length > 1) {
console.warn(
'Reference applies to more than 1 display set for Contours, applying only to first display set'
);
}
if (!referencedDisplaySets || referencedDisplaySets.length === 0) {
// Instead of throwing error, subscribe to display sets added
const { unsubscribe } = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
({ displaySetsAdded }) => {
const addedDisplaySet = displaySetsAdded[0];
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
displaySet.FrameOfReferenceUID = addedDisplaySet.FrameOfReferenceUID;
unsubscribe();
}
}
);
} else {
const [referencedDisplaySet] = referencedDisplaySets;
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
displaySet.FrameOfReferenceUID = referencedDisplaySet.FrameOfReferenceUID;
}
displaySet.load = ({ headers, createSegmentation = true }) =>
_load(displaySet, servicesManager, extensionManager, headers, createSegmentation);
return [displaySet];
}
function _load(
rtDisplaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager,
headers,
createSegmentation = true
) {
const { SOPInstanceUID } = rtDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(rtDisplaySet.loading || rtDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID] &&
_segmentationExists(rtDisplaySet)
) {
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<void>(async (resolve, reject) => {
try {
if (!rtDisplaySet.structureSet) {
const structureSet = await loadRTStruct(extensionManager, rtDisplaySet, headers);
rtDisplaySet.structureSet = structureSet;
}
if (createSegmentation) {
await segmentationService.createSegmentationForRTDisplaySet(rtDisplaySet);
}
resolve();
} catch (error) {
reject(error);
} finally {
rtDisplaySet.loading = false;
}
});
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 _segmentationExists(segDisplaySet) {
return !!cstSegmentation.state.getSegmentation(segDisplaySet.displaySetInstanceUID);
}
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
const { servicesManager, extensionManager } = params;
return [
{
name: 'dicom-rt',
sopClassUids,
getDisplaySetsFromSeries: instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
},
},
];
}
export default getSopClassHandlerModule;
@@ -2,7 +2,6 @@ import { id } from './id';
import React from 'react';
import { Types } from '@ohif/core';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getCommandsModule from './getCommandsModule';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneRTViewport');
@@ -25,7 +24,6 @@ const extension: Types.Extensions.Extension = {
* You ID can be anything you want, but it should be unique.
*/
id,
getCommandsModule,
/**
* PanelModule should provide a list of panels that will be available in OHIF
@@ -2,34 +2,9 @@ import dcmjs from 'dcmjs';
const { DicomMessage, DicomMetaDictionary } = dcmjs.data;
const dicomlab2RGB = dcmjs.data.Colors.dicomlab2RGB;
/**
* Checks and loads contour data for RT Structure Set, handling both inline and bulk data URIs.
* Processes ROIContourSequence to extract contour data and resolve any bulk data references.
*
* @async
* @function checkAndLoadContourData
* @param {Object} params - Parameters object
* @param {Object} params.instance - Initial RT Structure instance
* @param {Object} params.dataSource - Data source for retrieving bulk data
* @param {Object} params.extensionManager - OHIF extension manager
* @param {Object} params.rtStructDisplaySet - RT Structure display set
* @param {Object} params.headers - HTTP headers for requests
* @returns {Promise<Object>} Promise that resolves to the processed RT Structure instance with loaded contour data
* @throws {Promise<string>} Rejects with error message if instance is invalid or data retrieval fails
*/
async function checkAndLoadContourData({
instance: initialInstance,
dataSource,
extensionManager,
rtStructDisplaySet,
headers,
}) {
let instance = initialInstance;
async function checkAndLoadContourData(instance, datasource) {
if (!instance || !instance.ROIContourSequence) {
instance = await getRTStructInstance({ extensionManager, rtStructDisplaySet, headers });
if (!instance || !instance.ROIContourSequence) {
return Promise.reject('Invalid instance object or ROIContourSequence');
}
return Promise.reject('Invalid instance object or ROIContourSequence');
}
const promisesMap = new Map();
@@ -55,11 +30,11 @@ async function checkAndLoadContourData({
} 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');
if (!datasource || !datasource.retrieve || !datasource.retrieve.bulkDataURI) {
return Promise.reject('Invalid datasource object or retrieve function');
}
const bulkDataPromise = dataSource.retrieve.bulkDataURI({
const bulkDataPromise = datasource.retrieve.bulkDataURI({
BulkDataURI: bulkDataURI,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
@@ -69,40 +44,6 @@ async function checkAndLoadContourData({
promisesMap.has(referencedROINumber)
? promisesMap.get(referencedROINumber).push(bulkDataPromise)
: promisesMap.set(referencedROINumber, [bulkDataPromise]);
} else if (contourData && contourData.InlineBinary) {
// Contour data is still in binary format, conversion needed
const base64String = contourData.InlineBinary;
const decodedText = atob(base64String);
const rawValues = decodedText.split('\\');
const result = [];
// Ensure strictly that we have a full set of 3 coordinates
if (rawValues.length % 3 !== 0) {
return Promise.reject('ContourData raw values not divisible by 3');
}
for (let i = 0; i < rawValues.length; i += 3) {
if (i + 2 < rawValues.length) {
const x = parseFloat(rawValues[i]);
const y = parseFloat(rawValues[i + 1]);
const z = parseFloat(rawValues[i + 2]);
// Only push if all three are valid numbers (filters out trailing empty splits)
if (!isNaN(x) && !isNaN(y) && !isNaN(z)) {
result.push(x);
result.push(y);
result.push(z);
} else {
return Promise.reject('Error parsing contourData from InlineBinary format');
}
}
}
promisesMap.has(referencedROINumber)
? promisesMap.get(referencedROINumber).push(result)
: promisesMap.set(referencedROINumber, [result]);
} else {
return Promise.reject(`Invalid ContourData: ${contourData}`);
}
@@ -123,12 +64,9 @@ async function checkAndLoadContourData({
ROIContour.ContourSequence.forEach((Contour, index) => {
const promise = resolvedPromises[index];
if (promise.status === 'fulfilled') {
if (
Array.isArray(promise.value) &&
promise.value.every(it => Number.isFinite(Number(it)))
) {
if (Array.isArray(promise.value) && promise.value.every(Number.isFinite)) {
// If promise.value is already an array of numbers, use it directly
Contour.ContourData = promise.value.map(Number);
Contour.ContourData = promise.value;
} else {
// If the resolved promise value is a byte array (Blob), it needs to be decoded
const uint8Array = new Uint8Array(promise.value);
@@ -149,74 +87,42 @@ async function checkAndLoadContourData({
console.error(error);
}
});
return instance;
}
/**
* Retrieves and parses RT Structure Set instance from DICOM data.
* Uses the cornerstone utility module to load DICOM data and converts it to a naturalized dataset.
*
* @async
* @function getRTStructInstance
* @param {Object} params - Parameters object
* @param {Object} params.extensionManager - OHIF extension manager
* @param {Object} params.rtStructDisplaySet - RT Structure display set
* @param {Object} params.headers - HTTP headers for requests
* @returns {Promise<Object>} Promise that resolves to the parsed RT Structure dataset
*/
const getRTStructInstance = async ({ extensionManager, rtStructDisplaySet, headers }) => {
export default async function loadRTStruct(
extensionManager,
rtStructDisplaySet,
referencedDisplaySet,
headers
) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { dicomLoaderService } = utilityModule.exports;
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);
return rtStructDataset;
};
/**
* Main function to load and process RT Structure Set data.
* Creates a structure set object with ROI contours, metadata, and visualization properties.
* Handles both bulk data URI and inline contour data scenarios.
*
* @async
* @function loadRTStruct
* @param {Object} extensionManager - OHIF extension manager
* @param {Object} rtStructDisplaySet - RT Structure display set to process
* @param {Object} headers - HTTP headers for data requests
* @returns {Promise<Object>} Promise that resolves to a structure set object containing:
* - StructureSetLabel: Label of the structure set
* - SeriesInstanceUID: Series instance UID
* - ROIContours: Array of ROI contour data with points and metadata
* - visible: Visibility state
* - ReferencedSOPInstanceUIDsSet: Set of referenced SOP instance UIDs
*/
export default async function loadRTStruct(extensionManager, rtStructDisplaySet, headers) {
const dataSource = extensionManager.getActiveDataSource()[0];
const { bulkDataURI } = 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 (!bulkDataURI || !bulkDataURI.enabled) {
instance = await getRTStructInstance({ extensionManager, rtStructDisplaySet, headers });
} else {
instance = await checkAndLoadContourData({
instance,
dataSource,
extensionManager,
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
rtStructDisplaySet,
headers,
});
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;
@@ -227,22 +133,26 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
SeriesInstanceUID: instance.SeriesInstanceUID,
ROIContours: [],
visible: true,
ReferencedSOPInstanceUIDsSet: new Set(),
};
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 (const ContourSequenceItem of ContourSequenceArray) {
const { ContourData, NumberOfContourPoints, ContourGeometricType, ContourImageSequence } =
ContourSequenceItem;
for (let c = 0; c < ContourSequenceArray.length; c++) {
const { ContourImageSequence, ContourData, NumberOfContourPoints, ContourGeometricType } =
ContourSequenceArray[c];
let isSupported = false;
const points = [];
for (let p = 0; p < NumberOfContourPoints * 3; p += 3) {
@@ -253,25 +163,23 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
});
}
const supportedContourTypesMap = new Map([
['CLOSED_PLANAR', false],
['OPEN_NONPLANAR', false],
['OPEN_PLANAR', false],
['POINT', true],
]);
switch (ContourGeometricType) {
case 'CLOSED_PLANAR':
case 'OPEN_PLANAR':
case 'POINT':
isSupported = true;
break;
default:
continue;
}
contourPoints.push({
numberOfPoints: NumberOfContourPoints,
points,
type: ContourGeometricType,
isSupported: supportedContourTypesMap.get(ContourGeometricType) ?? false,
isSupported,
});
if (ContourImageSequence?.ReferencedSOPInstanceUID) {
structureSet.ReferencedSOPInstanceUIDsSet.add(
ContourImageSequence?.ReferencedSOPInstanceUID
);
}
}
_setROIContourMetadata(
@@ -279,30 +187,38 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
StructureSetROISequence,
RTROIObservationsSequence,
ROIContour,
contourPoints
contourPoints,
isSupported
);
}
return structureSet;
}
/**
* Sets metadata for ROI contour data and adds it to the structure set.
* Extracts ROI information from StructureSetROISequence and RTROIObservationsSequence,
* then creates a complete ROI contour data object with visualization properties.
*
* @function _setROIContourMetadata
* @param {Object} structureSet - The structure set object to add ROI contour to
* @param {Array} StructureSetROISequence - Array of structure set ROI definitions
* @param {Array} RTROIObservationsSequence - Array of RT ROI observations
* @param {Object} ROIContour - ROI contour object containing contour data
* @param {Array} contourPoints - Array of processed contour points
*/
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
contourPoints,
isSupported
) {
const StructureSetROI = StructureSetROISequence.find(
structureSetROI => structureSetROI.ROINumber === ROIContour.ReferencedROINumber
@@ -313,9 +229,9 @@ function _setROIContourMetadata(
ROIName: StructureSetROI.ROIName,
ROIGenerationAlgorithm: StructureSetROI.ROIGenerationAlgorithm,
ROIDescription: StructureSetROI.ROIDescription,
isSupported,
contourPoints,
visible: true,
colorArray: [],
};
_setROIContourDataColor(ROIContour, ROIContourData);
@@ -332,14 +248,6 @@ function _setROIContourMetadata(
structureSet.ROIContours.push(ROIContourData);
}
/**
* Sets the display color for ROI contour data.
* Uses ROIDisplayColor if available, otherwise converts RecommendedDisplayCIELabValue to RGB.
*
* @function _setROIContourDataColor
* @param {Object} ROIContour - ROI contour object containing color information
* @param {Object} ROIContourData - ROI contour data object to set color on
*/
function _setROIContourDataColor(ROIContour, ROIContourData) {
let { ROIDisplayColor, RecommendedDisplayCIELabValue } = ROIContour;
@@ -353,15 +261,6 @@ function _setROIContourDataColor(ROIContour, ROIContourData) {
}
}
/**
* Sets RT ROI observations metadata for ROI contour data.
* Finds matching RTROIObservations by ROINumber and adds observation details to contour data.
*
* @function _setROIContourRTROIObservations
* @param {Object} ROIContourData - ROI contour data object to add observations to
* @param {Array} RTROIObservationsSequence - Array of RT ROI observations
* @param {number} ROINumber - ROI number to match observations
*/
function _setROIContourRTROIObservations(ROIContourData, RTROIObservationsSequence, ROINumber) {
const RTROIObservations = RTROIObservationsSequence.find(
RTROIObservations => RTROIObservations.ReferencedROINumber === ROINumber
@@ -381,14 +280,6 @@ function _setROIContourRTROIObservations(ROIContourData, RTROIObservationsSequen
}
}
/**
* Converts a single object or array to an array.
* Utility function to ensure consistent array handling for DICOM sequences.
*
* @function _toArray
* @param {*} objOrArray - Object or array to convert
* @returns {Array} Array containing the input (if already array) or wrapped in array
*/
function _toArray(objOrArray) {
return Array.isArray(objOrArray) ? objOrArray : [objOrArray];
}
@@ -1,5 +1,5 @@
function createRTToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {};
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
}
@@ -1,25 +1,77 @@
import { utils, Types } from '@ohif/extension-cornerstone';
import { ButtonEnums } from '@ohif/ui';
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
HYDRATE_SEG: 5,
};
function promptHydrateRT({
servicesManager,
rtDisplaySet,
viewportId,
toolGroupId = 'default',
preHydrateCallbacks,
hydrateRTDisplaySet,
}: {
servicesManager: AppTypes.ServicesManager;
rtDisplaySet: AppTypes.DisplaySet;
viewportId: string;
preHydrateCallbacks?: Types.HydrationCallback[];
hydrateRTDisplaySet: Types.HydrationCallback;
}) {
return utils.promptHydrationDialog({
servicesManager,
viewportId,
displaySet: rtDisplaySet,
preHydrateCallbacks,
hydrateCallback: hydrateRTDisplaySet,
type: 'RTSTRUCT',
const { uiViewportDialogService } = servicesManager.services;
return new Promise(async function (resolve, reject) {
const promptResult = await _askHydrate(uiViewportDialogService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
callback();
});
const isHydrated = await hydrateRTDisplaySet({
rtDisplaySet,
viewportId,
toolGroupId,
servicesManager,
});
resolve(isHydrated);
}
});
}
function _askHydrate(uiViewportDialogService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const actions = [
{
type: ButtonEnums.type.secondary,
text: 'No',
value: RESPONSE.CANCEL,
},
{
type: ButtonEnums.type.primary,
text: 'Yes',
value: RESPONSE.HYDRATE_SEG,
},
];
const onSubmit = result => {
uiViewportDialogService.hide();
resolve(result);
};
uiViewportDialogService.show({
viewportId,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
onSubmit(RESPONSE.HYDRATE_SEG);
}
},
});
});
}
@@ -1,27 +1,31 @@
import React, { Component, useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useViewportGrid } from '@ohif/ui-next';
import {
utils,
usePositionPresentationStore,
OHIFCornerstoneViewport,
} from '@ohif/extension-cornerstone';
import { useViewportGrid, LoadingIndicatorTotalPercent, ViewportActionArrows } from '@ohif/ui';
import promptHydrateRT from '../utils/promptHydrateRT';
import _getStatusComponent from './_getStatusComponent';
import createRTToolGroupAndAddTools from '../utils/initRTToolGroup';
import { useSystem } from '@ohif/core/src';
const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup';
function OHIFCornerstoneRTViewport(props: withAppTypes) {
const { servicesManager, commandsManager } = useSystem();
const { children, displaySets, viewportOptions } = props as {
children: React.ReactNode;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportOptions;
};
function OHIFCornerstoneRTViewport(props) {
const {
children,
displaySets,
viewportOptions,
servicesManager,
extensionManager,
commandsManager,
} = props;
const { displaySetService, toolGroupService, segmentationService, customizationService } =
servicesManager.services;
const {
displaySetService,
toolGroupService,
segmentationService,
uiNotificationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const viewportId = viewportOptions.viewportId;
@@ -32,91 +36,152 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
throw new Error('RT viewport should only have a single display set');
}
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const rtDisplaySet = displaySets[0];
const [{ viewports, activeViewportId }, viewportGridService] = useViewportGrid();
const [viewportGrid, viewportGridService] = useViewportGrid();
// States
const { setPositionPresentation } = usePositionPresentationStore();
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 referencedDisplaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID;
// If the referencedDisplaySetInstanceUID is not found, it means the RTStruct series is being
// launched without its corresponding referenced display set (e.g., the RTStruct series is launched using
// series launch /mode?StudyInstanceUIDs=&SeriesInstanceUID).
// In such cases, we attempt to handle this scenario gracefully by
// invoking a custom handler. Ideally, if a user tries to launch a series that isn't viewable,
// (eg.: we can prompt them with an explanation and provide a link to the full study).
if (!referencedDisplaySetInstanceUID) {
const missingReferenceDisplaySetHandler = customizationService.getCustomization(
'missingReferenceDisplaySetHandler'
);
const { handled } = missingReferenceDisplaySetHandler();
if (handled) {
return;
}
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
referencedDisplaySetInstanceUID
);
const { viewports, activeViewportId } = 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 storePresentationState = useCallback(() => {
viewportGrid?.viewports.forEach(({ viewportId }) => {
commandsManager.runCommand('storePresentation', {
viewportId,
});
});
}, [viewportGrid]);
const hydrateRTDisplaySet = ({ rtDisplaySet, viewportId }) => {
commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', {
displaySets: [rtDisplaySet],
viewportId,
});
};
const getCornerstoneViewport = useCallback(() => {
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current;
// Todo: jump to the center of the first segment
return (
<Component
{...props}
displaySets={[referencedDisplaySet, rtDisplaySet]}
viewportOptions={{
viewportType: 'volume',
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
onElementDisabled={onElementDisabled}
></Component>
);
}, [viewportId, rtDisplaySet, toolGroupId]);
const onSegmentChange = useCallback(
direction => {
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;
}
// if not active viewport, return
if (viewportId !== activeViewportId) {
return;
}
promptHydrateRT({
servicesManager,
viewportId,
rtDisplaySet,
hydrateRTDisplaySet: async () => {
return commandsManager.runCommand('hydrateSecondaryDisplaySet', {
displaySet: rtDisplaySet,
viewportId,
});
},
preHydrateCallbacks: [storePresentationState],
hydrateRTDisplaySet,
}).then(isHydrated => {
if (isHydrated) {
setIsHydrated(true);
}
});
}, [servicesManager, viewportId, rtDisplaySet, rtIsLoading, commandsManager, activeViewportId]);
}, [servicesManager, viewportId, rtDisplaySet, rtIsLoading]);
useEffect(() => {
// I'm not sure what is this, since in RT we support Overlapping segments
// via contours
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (evt.rtDisplaySet?.displaySetInstanceUID === rtDisplaySet.displaySetInstanceUID) {
if (evt.rtDisplaySet.displaySetInstanceUID === rtDisplaySet.displaySetInstanceUID) {
setRtIsLoading(false);
}
if (rtDisplaySet?.firstSegmentedSliceImageId && viewportOptions?.presentationIds) {
const { firstSegmentedSliceImageId } = rtDisplaySet;
const { presentationIds } = viewportOptions;
setPositionPresentation(presentationIds.positionPresentationId, {
viewportType: 'stack',
viewReference: {
referencedImageId: firstSegmentedSliceImageId,
},
viewPresentation: {},
if (evt.overlappingSegments) {
uiNotificationService.show({
title: 'Overlapping Segments',
message: 'Overlapping segments detected which is not currently supported',
type: 'warning',
});
}
}
@@ -128,7 +193,7 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
}, [rtDisplaySet]);
useEffect(() => {
const segmentLoadingSubscription = segmentationService.subscribe(
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
({ percentComplete, numSegments }) => {
setProcessingProgress({
@@ -138,7 +203,16 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
}
);
const displaySetsRemovedSubscription = displaySetService.subscribe(
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.get(activeViewportId);
@@ -152,10 +226,9 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
);
return () => {
segmentLoadingSubscription.unsubscribe();
displaySetsRemovedSubscription.unsubscribe();
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, [rtDisplaySet, displaySetService, viewports, activeViewportId, viewportGridService]);
}, []);
useEffect(() => {
let toolGroup = toolGroupService.getToolGroup(toolGroupId);
@@ -166,36 +239,27 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
toolGroup = createRTToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId);
setToolGroupCreated(true);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeRepresentationsFromViewport(viewportId);
referencedDisplaySetRef.current = null;
segmentationService.removeSegmentationRepresentationFromToolGroup(toolGroupId);
toolGroupService.destroyToolGroup(toolGroupId);
};
}, []);
const getCornerstoneViewport = useCallback(() => {
const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current;
useEffect(() => {
setIsHydrated(rtDisplaySet.isHydrated);
// Todo: jump to the center of the first segment
return (
<OHIFCornerstoneViewport
{...props}
displaySets={[referencedDisplaySet, rtDisplaySet]}
viewportOptions={{
viewportType: viewportOptions.viewportType,
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
}}
/>
);
}, [viewportId, rtDisplaySet, toolGroupId]);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentationFromToolGroup(toolGroupId);
referencedDisplaySetRef.current = null;
};
}, [rtDisplaySet]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
if (
@@ -218,6 +282,59 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
});
}
const onStatusClick = useCallback(async () => {
// Before hydrating a RT and make it added to all viewports in the grid
// that share the same frameOfReferenceUID, we need to store the viewport grid
// presentation state, so that we can restore it after hydrating the RT. This is
// required if the user has changed the viewport (other viewport than RT viewport)
// presentation state (w/l and invert) and then opens the RT. If we don't store
// the presentation state, the viewport will be reset to the default presentation
storePresentationState();
const isHydrated = await hydrateRTDisplaySet({
rtDisplaySet,
viewportId,
});
setIsHydrated(isHydrated);
}, [hydrateRTDisplaySet, rtDisplaySet, storePresentationState, viewportId]);
useEffect(() => {
viewportActionCornersService.setComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
isHydrated,
onStatusClick,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onSegmentChange}
className={
viewportId === activeViewportId ? 'visible' : 'invisible group-hover:visible'
}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [
activeViewportId,
isHydrated,
onSegmentChange,
onStatusClick,
viewportActionCornersService,
viewportId,
]);
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
@@ -241,6 +358,11 @@ OHIFCornerstoneRTViewport.propTypes = {
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
customProps: PropTypes.object,
};
OHIFCornerstoneRTViewport.defaultProps = {
customProps: {},
};
function _getReferencedDisplaySetMetadata(referencedDisplaySet) {
@@ -0,0 +1,62 @@
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;
switch (isHydrated) {
case true:
StatusIcon = () => <Icon name="status-alert" />;
ToolTipMessage = () => <div>This Segmentation is loaded in the segmentation panel</div>;
break;
case false:
StatusIcon = () => (
<Icon
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>Click LOAD to load RTSTRUCT.</div>;
}
const StatusArea = () => {
const { t } = useTranslation('Common');
const loadStr = t('LOAD');
return (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">RTSTRUCT</span>
</div>
{!isHydrated && (
<div
className="bg-primary-main hover:bg-primary-light ml-1 cursor-pointer rounded px-1.5 hover:text-black"
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
onMouseUp={onStatusClick}
>
{loadStr}
</div>
)}
</div>
);
};
return (
<>
{ToolTipMessage && (
<Tooltip
content={<ToolTipMessage />}
position="bottom-left"
>
<StatusArea />
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
module.exports = {
plugins: ['@babel/plugin-transform-class-properties'],
plugins: ['inline-react-svg', '@babel/plugin-proposal-class-properties'],
env: {
test: {
presets: [
@@ -15,7 +15,7 @@ module.exports = {
'@babel/preset-react',
],
plugins: [
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
@@ -37,6 +37,7 @@ module.exports = {
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: ['react-hot-loader/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
+16 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.13.0-beta.83",
"version": "3.8.0",
"description": "DICOM SEG read workflow",
"author": "OHIF",
"license": "MIT",
@@ -33,21 +33,22 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.13.0-beta.83",
"@ohif/extension-cornerstone": "3.13.0-beta.83",
"@ohif/extension-default": "3.13.0-beta.83",
"@ohif/i18n": "3.13.0-beta.83",
"prop-types": "15.8.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "12.3.1",
"react-router": "6.30.3",
"react-router-dom": "6.30.3"
"@ohif/core": "3.8.0",
"@ohif/extension-cornerstone": "3.8.0",
"@ohif/extension-default": "3.8.0",
"@ohif/i18n": "3.8.0",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^12.2.2",
"react-router": "^6.8.1",
"react-router-dom": "^6.8.1"
},
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "4.22.10",
"@cornerstonejs/core": "4.22.10",
"@kitware/vtk.js": "34.15.1"
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.70.14",
"@cornerstonejs/core": "^1.70.14",
"@kitware/vtk.js": "30.4.1",
"react-color": "^2.19.3"
}
}
@@ -1,43 +1,126 @@
import dcmjs from 'dcmjs';
import { classes, Types, utils } from '@ohif/core';
import { createReportDialogPrompt } from '@ohif/extension-default';
import { ServicesManager, Types } from '@ohif/core';
import { cache, metaData } from '@cornerstonejs/core';
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
import {
segmentation as cornerstoneToolsSegmentation,
Enums as cornerstoneToolsEnums,
utilities,
} from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { classes, DicomMetadataStore } from '@ohif/core';
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
import vtkImageMarchingSquares from '@kitware/vtk.js/Filters/General/ImageMarchingSquares';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
const getTargetViewport = ({ viewportId, viewportGridService }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
const targetViewportId = viewportId || activeViewportId;
import {
updateViewportsForSegmentationRendering,
getUpdatedViewportsForSegmentation,
getTargetViewport,
} from './utils/hydrationUtils';
const { segmentation: segmentationUtils } = utilities;
const viewport = viewports.get(targetViewportId);
return viewport;
};
const { datasetToBlob } = dcmjs.data;
const {
Cornerstone3D: {
Segmentation: { generateSegmentation },
Segmentation: { generateLabelMaps2DFrom3D, generateSegmentation },
},
} = adaptersSEG;
const {
Cornerstone3D: {
RTSS: { generateRTSSFromRepresentation },
RTSS: { generateRTSSFromSegmentations },
},
} = adaptersRT;
const { downloadDICOMData } = helpers;
const commandsModule = ({
servicesManager,
extensionManager,
commandsManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const { segmentationService, displaySetService, viewportGridService } =
servicesManager.services as AppTypes.Services;
const {
uiNotificationService,
segmentationService,
uiDialogService,
displaySetService,
viewportGridService,
toolGroupService,
} = (servicesManager as ServicesManager).services;
const actions = {
/**
* Retrieves a list of viewports that require updates in preparation for segmentation rendering.
* This function evaluates viewports based on their compatibility with the provided segmentation's
* frame of reference UID and appends them to the updated list if they should render the segmentation.
*
* @param {Object} params - Parameters for the function.
* @param params.viewportId - the ID of the viewport to be updated.
* @param params.servicesManager - The services manager
* @param params.referencedDisplaySetInstanceUID - Optional UID for the referenced display set instance.
*
* @returns {Array} Returns an array of viewports that require updates for segmentation rendering.
*/
getUpdatedViewportsForSegmentation,
/**
* Creates an empty segmentation for a specified viewport.
* It first checks if the display set associated with the viewport is reconstructable.
* If not, it raises a notification error. Otherwise, it creates a new segmentation
* for the display set after handling the necessary steps for making the viewport
* a volume viewport first
*
* @param {Object} params - Parameters for the function.
* @param params.viewportId - the target viewport ID.
*
*/
createEmptySegmentationForViewport: async ({ viewportId }) => {
const viewport = getTargetViewport({ viewportId, viewportGridService });
// Todo: add support for multiple display sets
const displaySetInstanceUID = viewport.displaySetInstanceUIDs[0];
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (!displaySet.isReconstructable) {
uiNotificationService.show({
title: 'Segmentation',
message: 'Segmentation is not supported for non-reconstructible displaysets yet',
type: 'error',
});
return;
}
updateViewportsForSegmentationRendering({
viewportId,
servicesManager,
loadFn: async () => {
const currentSegmentations = segmentationService.getSegmentations();
const segmentationId = await segmentationService.createSegmentationForDisplaySet(
displaySetInstanceUID,
{ label: `Segmentation ${currentSegmentations.length + 1}` }
);
const toolGroupId = viewport.viewportOptions.toolGroupId;
await segmentationService.addSegmentationRepresentationToToolGroup(
toolGroupId,
segmentationId
);
// Add only one segment for now
segmentationService.addSegment(segmentationId, {
toolGroupId,
segmentIndex: 1,
properties: {
label: 'Segment 1',
},
});
return segmentationId;
},
});
},
/**
* Loads segmentations for a specified viewport.
* The function prepares the viewport for rendering, then loads the segmentation details.
@@ -49,30 +132,103 @@ const commandsModule = ({
*
*/
loadSegmentationsForViewport: async ({ segmentations, viewportId }) => {
updateViewportsForSegmentationRendering({
viewportId,
servicesManager,
loadFn: async () => {
// Todo: handle adding more than one segmentation
const viewport = getTargetViewport({ viewportId, viewportGridService });
const displaySetInstanceUID = viewport.displaySetInstanceUIDs[0];
const segmentation = segmentations[0];
const segmentationId = segmentation.id;
const label = segmentation.label;
const segments = segmentation.segments;
delete segmentation.segments;
await segmentationService.createSegmentationForDisplaySet(displaySetInstanceUID, {
segmentationId,
label,
});
if (segmentation.scalarData) {
const labelmapVolume = segmentationService.getLabelmapVolume(segmentationId);
labelmapVolume.scalarData.set(segmentation.scalarData);
}
segmentationService.addOrUpdateSegmentation(segmentation);
const toolGroupId = viewport.viewportOptions.toolGroupId;
await segmentationService.addSegmentationRepresentationToToolGroup(
toolGroupId,
segmentationId
);
segments.forEach(segment => {
if (segment === null) {
return;
}
segmentationService.addSegment(segmentationId, {
segmentIndex: segment.segmentIndex,
toolGroupId,
properties: {
color: segment.color,
label: segment.label,
opacity: segment.opacity,
isLocked: segment.isLocked,
visibility: segment.isVisible,
active: segmentation.activeSegmentIndex === segment.segmentIndex,
},
});
});
if (segmentation.centroidsIJK) {
segmentationService.setCentroids(segmentation.id, segmentation.centroidsIJK);
}
return segmentationId;
},
});
},
/**
* Loads segmentation display sets for a specified viewport.
* Depending on the modality of the display set (SEG or RTSTRUCT),
* it chooses the appropriate service function to create
* the segmentation for the display set.
* The function then prepares the viewport for rendering segmentation.
*
* @param {Object} params - Parameters for the function.
* @param params.viewportId - ID of the viewport where the segmentation display sets should be loaded.
* @param params.displaySets - Array of display sets to be loaded for segmentation.
*
*/
loadSegmentationDisplaySetsForViewport: async ({ viewportId, displaySets }) => {
// Todo: handle adding more than one segmentation
const viewport = getTargetViewport({ viewportId, viewportGridService });
const displaySetInstanceUID = viewport.displaySetInstanceUIDs[0];
const displaySet = displaySets[0];
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
displaySet.referencedDisplaySetInstanceUID
);
const segmentation = segmentations[0];
const segmentationId = segmentation.segmentationId;
const label = segmentation.config.label;
const segments = segmentation.config.segments;
updateViewportsForSegmentationRendering({
viewportId,
servicesManager,
referencedDisplaySetInstanceUID: displaySet.referencedDisplaySetInstanceUID,
loadFn: async () => {
const segDisplaySet = displaySet;
const suppressEvents = false;
const serviceFunction =
segDisplaySet.Modality === 'SEG'
? 'createSegmentationForSEGDisplaySet'
: 'createSegmentationForRTDisplaySet';
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
await segmentationService.createLabelmapForDisplaySet(displaySet, {
segmentationId,
segments,
label,
const boundFn = segmentationService[serviceFunction].bind(segmentationService);
const segmentationId = await boundFn(segDisplaySet, null, suppressEvents);
const segmentation = segmentationService.getSegmentation(segmentationId);
segmentation.description = `S${referencedDisplaySet.SeriesNumber}: ${referencedDisplaySet.SeriesDescription}`;
return segmentationId;
},
});
segmentationService.addOrUpdateSegmentation(segmentation);
await segmentationService.addSegmentationRepresentation(viewport.viewportId, {
segmentationId,
});
return segmentationId;
},
/**
* Generates a segmentation from a given segmentation ID.
@@ -89,63 +245,23 @@ const commandsModule = ({
*/
generateSegmentation: ({ segmentationId, options = {} }) => {
const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId);
const predecessorImageId = options.predecessorImageId ?? segmentation.predecessorImageId;
const { imageIds } = segmentation.representationData.Labelmap;
const { referencedVolumeId } = segmentation.representationData.LABELMAP;
const segImages = imageIds.map(imageId => cache.getImage(imageId));
const referencedImages = segImages.map(image => cache.getImage(image.referencedImageId));
const segmentationVolume = cache.getVolume(segmentationId);
const referencedVolume = cache.getVolume(referencedVolumeId);
const referencedImages = referencedVolume.getCornerstoneImages();
const labelmaps2D = [];
const labelmapObj = generateLabelMaps2DFrom3D(segmentationVolume);
let z = 0;
for (const segImage of segImages) {
const segmentsOnLabelmap = new Set();
const pixelData = segImage.getPixelData();
const { rows, columns } = segImage;
// Use a single pass through the pixel data
for (let i = 0; i < pixelData.length; i++) {
const segment = pixelData[i];
if (segment !== 0) {
segmentsOnLabelmap.add(segment);
}
}
labelmaps2D[z++] = {
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
pixelData,
rows,
columns,
};
}
const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap);
const labelmap3D = {
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
metadata: [],
labelmaps2D,
};
// Generate fake metadata as an example
labelmapObj.metadata = [];
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
const representations = segmentationService.getRepresentationsForSegmentation(segmentationId);
Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => {
labelmapObj.segmentsOnLabelmap.forEach(segmentIndex => {
// segmentation service already has a color for each segment
if (!segment) {
return;
}
const { label } = segment;
const firstRepresentation = representations[0];
const color = segmentationService.getSegmentColor(
firstRepresentation.viewportId,
segmentationId,
segment.segmentIndex
);
const segment = segmentationInOHIF?.segments[segmentIndex];
const { label, color } = segment;
const RecommendedDisplayCIELabValue = dcmjs.data.Colors.rgb2DICOMLAB(
color.slice(0, 3).map(value => value / 255)
@@ -154,8 +270,8 @@ const commandsModule = ({
const segmentMetadata = {
SegmentNumber: segmentIndex.toString(),
SegmentLabel: label,
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
SegmentAlgorithmName: segment?.algorithmName || 'OHIF Brush',
SegmentAlgorithmType: 'MANUAL',
SegmentAlgorithmName: 'OHIF Brush',
RecommendedDisplayCIELabValue,
SegmentedPropertyCategoryCodeSequence: {
CodeValue: 'T-D0050',
@@ -168,13 +284,15 @@ const commandsModule = ({
CodeMeaning: 'Tissue',
},
};
labelmap3D.metadata[segmentIndex] = segmentMetadata;
labelmapObj.metadata[segmentIndex] = segmentMetadata;
});
const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
predecessorImageId,
...options,
});
const generatedSegmentation = generateSegmentation(
referencedImages,
labelmapObj,
metaData,
options
);
return generatedSegmentation;
},
@@ -193,11 +311,8 @@ const commandsModule = ({
const generatedSegmentation = actions.generateSegmentation({
segmentationId,
});
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource: 'download',
defaultFileName: `${segmentationInOHIF.label}.dcm`,
});
storeFn(generatedSegmentation.dataset);
downloadDICOMData(generatedSegmentation.dataset, `${segmentationInOHIF.label}`);
},
/**
* Stores a segmentation based on the provided segmentationId into a specified data source.
@@ -211,138 +326,147 @@ const commandsModule = ({
* @returns {Object|void} Returns the naturalized report if successfully stored,
* otherwise throws an error.
*/
storeSegmentation: async ({ segmentationId, dataSource, modality = 'SEG' }) => {
storeSegmentation: async ({ segmentationId, dataSource }) => {
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action !== 1 && promptResult.value) {
return;
}
const segmentation = segmentationService.getSegmentation(segmentationId);
if (!segmentation) {
throw new Error('No segmentation found');
}
const { label, predecessorImageId } = segmentation;
const { label } = segmentation;
const SeriesDescription = promptResult.value || label || 'Research Derived Series';
const {
value: reportName,
dataSourceName,
series,
priorSeriesNumber,
action,
} = await createReportDialogPrompt({
servicesManager,
extensionManager,
predecessorImageId,
title: 'Store Segmentation',
modality,
enableDownload: true,
const generatedData = actions.generateSegmentation({
segmentationId,
options: {
SeriesDescription,
},
});
if (action !== PROMPT_RESPONSES.CREATE_REPORT) {
return;
if (!generatedData || !generatedData.dataset) {
throw new Error('Error during segmentation generation');
}
const defaultFileName =
modality === 'RTSTRUCT'
? `rtss-${segmentationId}.dcm`
: `${label || 'segmentation'}.dcm`;
const { dataset: naturalizedReport } = generatedData;
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource: dataSourceName,
defaultFileName,
});
await dataSource.store.dicom(naturalizedReport);
if (!storeFn) {
throw new Error(`No valid store for dataSource: ${dataSourceName}`);
}
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
// add the information for where we stored it to the instance as well
naturalizedReport.wadoRoot = dataSource.getConfig().wadoRoot;
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
},
/**
* Converts segmentations into RTSS for download.
* This sample function retrieves all segentations and passes to
* cornerstone tool adapter to convert to DICOM RTSS format. It then
* converts dataset to downloadable blob.
*
*/
downloadRTSS: ({ segmentationId }) => {
const segmentations = segmentationService.getSegmentation(segmentationId);
const vtkUtils = {
vtkImageMarchingSquares,
vtkDataArray,
vtkImageData,
};
const RTSS = generateRTSSFromSegmentations(
segmentations,
classes.MetadataProvider,
DicomMetadataStore,
cache,
cornerstoneToolsEnums,
vtkUtils
);
try {
const args = {
segmentationId,
options: {
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
predecessorImageId: series,
},
};
const generatedDataAsync =
(modality === 'SEG' && actions.generateSegmentation(args)) ||
(modality === 'RTSTRUCT' && actions.generateContour(args));
const generatedData = await generatedDataAsync;
const reportBlob = datasetToBlob(RTSS);
if (!generatedData?.dataset) {
throw new Error('Error during segmentation generation');
}
const { dataset: naturalizedReport } = generatedData;
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
if (naturalizedReport.StudyID === 'No Study ID') {
naturalizedReport.StudyID = '';
}
await storeFn(naturalizedReport, {});
return naturalizedReport;
} catch (error) {
console.debug('Error storing segmentation:', error);
throw error;
//Create a URL for the binary.
const objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
} catch (e) {
console.warn(e);
}
},
setBrushSize: ({ value, toolNames }) => {
const brushSize = Number(value);
generateContour: async args => {
const { segmentationId, options } = args;
const segmentations = segmentationService.getSegmentation(segmentationId);
// inject colors to the segmentIndex
const firstRepresentation =
segmentationService.getRepresentationsForSegmentation(segmentationId)[0];
Object.entries(segmentations.segments).forEach(([segmentIndex, segment]) => {
segment.color = segmentationService.getSegmentColor(
firstRepresentation.viewportId,
segmentationId,
Number(segmentIndex)
);
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
if (toolNames?.length === 0) {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize);
} else {
toolNames?.forEach(toolName => {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize, toolName);
});
}
});
const predecessorImageId = options?.predecessorImageId ?? segmentations.predecessorImageId;
const dataset = await generateRTSSFromRepresentation(segmentations, {
predecessorImageId,
...options,
});
return { dataset };
},
/**
* Downloads an RTSS instance from a segmentation or contour
* representation.
*/
downloadRTSS: async args => {
const { dataset } = await actions.generateContour(args);
const { InstanceNumber: instanceNumber = 1, SeriesInstanceUID: seriesUID } = dataset;
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource: 'download',
defaultFileName: `rtss-${seriesUID}-${instanceNumber}.dcm`,
setThresholdRange: ({
value,
toolNames = ['ThresholdCircularBrush', 'ThresholdSphereBrush'],
}) => {
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
toolNames?.forEach(toolName => {
toolGroup.setToolConfiguration(toolName, {
strategySpecificConfiguration: {
THRESHOLD: {
threshold: value,
},
},
});
});
});
await storeFn(dataset);
},
toggleActiveSegmentationUtility: ({ itemId: buttonId }) => {
const { uiState, setUIState } = useUIStateStore.getState();
const isButtonActive = uiState['activeSegmentationUtility'] === buttonId;
console.log('toggleActiveSegmentationUtility', isButtonActive, buttonId);
// if the button is active, clear the active segmentation utility
if (isButtonActive) {
setUIState('activeSegmentationUtility', null);
} else {
setUIState('activeSegmentationUtility', buttonId);
}
},
};
const definitions = {
loadSegmentationsForViewport: actions.loadSegmentationsForViewport,
generateSegmentation: actions.generateSegmentation,
downloadSegmentation: actions.downloadSegmentation,
storeSegmentation: actions.storeSegmentation,
downloadRTSS: actions.downloadRTSS,
toggleActiveSegmentationUtility: actions.toggleActiveSegmentationUtility,
getUpdatedViewportsForSegmentation: {
commandFn: actions.getUpdatedViewportsForSegmentation,
},
loadSegmentationDisplaySetsForViewport: {
commandFn: actions.loadSegmentationDisplaySetsForViewport,
},
loadSegmentationsForViewport: {
commandFn: actions.loadSegmentationsForViewport,
},
createEmptySegmentationForViewport: {
commandFn: actions.createEmptySegmentationForViewport,
},
generateSegmentation: {
commandFn: actions.generateSegmentation,
},
downloadSegmentation: {
commandFn: actions.downloadSegmentation,
},
storeSegmentation: {
commandFn: actions.storeSegmentation,
},
downloadRTSS: {
commandFn: actions.downloadRTSS,
},
setBrushSize: {
commandFn: actions.setBrushSize,
},
setThresholdRange: {
commandFn: actions.setThresholdRange,
},
};
return {
@@ -1,246 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useRunCommand, useSystem } from '@ohif/core';
import { useActiveViewportSegmentationRepresentations } from '@ohif/extension-cornerstone';
import {
Button,
cn,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Switch,
Tabs,
TabsList,
TabsTrigger,
} from '@ohif/ui-next';
import { Icons } from '@ohif/ui-next';
import { contourSegmentation } from '@cornerstonejs/tools/utilities';
import { useTranslation } from 'react-i18next';
import { Segment } from '@cornerstonejs/tools/types';
const { LogicalOperation } = contourSegmentation;
const options = [
{
value: 'merge',
logicalOperation: LogicalOperation.Union,
label: 'Merge',
icon: 'actions-combine-merge',
helperIcon: 'helper-combine-merge',
},
{
value: 'intersect',
logicalOperation: LogicalOperation.Intersect,
label: 'Intersect',
icon: 'actions-combine-intersect',
helperIcon: 'helper-combine-intersect',
},
{
value: 'subtract',
logicalOperation: LogicalOperation.Subtract,
label: 'Subtract',
icon: 'actions-combine-subtract',
helperIcon: 'helper-combine-subtract',
},
];
// Shared component for segment selection
function SegmentSelector({
label,
value,
onValueChange,
segments,
placeholder = 'Select a segment',
}: {
label: string;
value: string;
onValueChange: (value: string) => void;
segments: Segment[];
placeholder?: string;
}) {
const { t } = useTranslation('SegmentationPanel');
return (
<div className="flex justify-between gap-6">
<div>{label}</div>
<Select
key={`select-segment-${label}`}
onValueChange={onValueChange}
value={value}
>
<SelectTrigger className="overflow-hidden">
<SelectValue placeholder={t(placeholder)} />
</SelectTrigger>
<SelectContent>
{segments.map(segment => (
<SelectItem
key={segment.segmentIndex}
value={segment.segmentIndex.toString()}
>
{segment.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function LogicalContourOperationOptions() {
const { servicesManager } = useSystem();
const { segmentationService } = servicesManager.services;
const { t } = useTranslation('SegmentationPanel');
const { segmentationsWithRepresentations } = useActiveViewportSegmentationRepresentations();
const activeRepresentation = segmentationsWithRepresentations?.find(
({ representation }) => representation?.active
);
const segments = activeRepresentation
? Object.values(activeRepresentation.segmentation.segments)
: [];
// Calculate the next available segment index
const nextSegmentIndex = activeRepresentation
? segmentationService.getNextAvailableSegmentIndex(
activeRepresentation.segmentation.segmentationId
)
: 1;
const activeSegment = segments.find(segment => segment.active);
const activeSegmentIndex = activeSegment?.segmentIndex || 0;
const [operation, setOperation] = useState(options[0]);
const [segmentA, setSegmentA] = useState<string>(activeSegmentIndex?.toString() || '');
const [segmentB, setSegmentB] = useState<string>('');
const [createNewSegment, setCreateNewSegment] = useState<boolean>(false);
const [newSegmentName, setNewSegmentName] = useState<string>('');
useEffect(() => {
setSegmentA(activeSegmentIndex?.toString() || null);
}, [activeSegmentIndex]);
useEffect(() => {
setNewSegmentName(`Segment ${nextSegmentIndex}`);
}, [nextSegmentIndex]);
const runCommand = useRunCommand();
const applyLogicalContourOperation = useCallback(() => {
let resultSegmentIndex = segmentA;
if (createNewSegment) {
resultSegmentIndex = nextSegmentIndex.toString();
runCommand('addSegment', {
segmentationId: activeRepresentation.segmentation.segmentationId,
config: {
label: newSegmentName,
segmentIndex: nextSegmentIndex,
},
});
}
runCommand('applyLogicalContourOperation', {
segmentAInfo: {
segmentationId: activeRepresentation.segmentation.segmentationId,
segmentIndex: parseInt(segmentA),
},
segmentBInfo: {
segmentationId: activeRepresentation.segmentation.segmentationId,
segmentIndex: parseInt(segmentB),
},
resultSegmentInfo: {
segmentationId: activeRepresentation.segmentation.segmentationId,
segmentIndex: parseInt(resultSegmentIndex),
},
logicalOperation: operation.logicalOperation,
});
}, [
activeRepresentation?.segmentation?.segmentationId,
createNewSegment,
newSegmentName,
nextSegmentIndex,
operation.logicalOperation,
runCommand,
segmentA,
segmentB,
]);
return (
<div className="flex w-[245px] flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex w-auto flex-col items-center gap-2 text-base font-normal leading-none">
<Tabs value={operation.value}>
<TabsList className="inline-flex space-x-1">
{options.map(option => {
const { value, icon } = option;
return (
<TabsTrigger
value={value}
key={`logical-contour-operation-${value}`}
onClick={() => setOperation(option)}
>
<Icons.ByName name={icon}></Icons.ByName>
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
<div>{t(operation.label)}</div>
</div>
<div className="bg-muted flex h-[62px] w-[88px] items-center justify-center rounded-lg">
<Icons.ByName name={operation.helperIcon}></Icons.ByName>
</div>
</div>
<SegmentSelector
label="A"
value={segmentA}
onValueChange={setSegmentA}
segments={segments}
/>
<SegmentSelector
label="B"
value={segmentB}
onValueChange={setSegmentB}
segments={segments}
/>
<div className="flex justify-end pl-[34px]">
<Button
className="border-primary/60 grow border"
variant="ghost"
onClick={() => {
applyLogicalContourOperation();
}}
>
{t(operation.label)}
</Button>
</div>
<Separator className="bg-input mt-2 h-[1px]" />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-start gap-2">
<Switch
id="logical-contour-operations-create-new-segment-switch"
onCheckedChange={setCreateNewSegment}
></Switch>
<Label htmlFor="logical-contour-operations-create-new-segment-switch">
{t('Create a new segment')}
</Label>
</div>
<div className="pl-9">
<Input
className={cn(createNewSegment ? 'visible' : 'hidden')}
disabled={!createNewSegment}
id="logical-contour-operations-create-new-segment-input"
type="text"
placeholder={t('New segment name')}
value={newSegmentName}
onChange={e => setNewSegmentName(e.target.value)}
/>
</div>
</div>
</div>
);
}
export default LogicalContourOperationOptions;
@@ -1,73 +0,0 @@
import React, { useState } from 'react';
import { Button, Input, Label, Separator } from '@ohif/ui-next';
import { useRunCommand } from '@ohif/core';
import { useTranslation } from 'react-i18next';
function SimplifyContourOptions() {
const [areaThreshold, setAreaThreshold] = useState(10);
const runCommand = useRunCommand();
const { t } = useTranslation('SegmentationPanel');
return (
<div className="flex w-auto w-[252px] flex-col gap-[8px] text-base font-normal leading-none">
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>{t('Fill contour holes')}</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('removeContourHoles');
}}
>
{t('Fill Holes')}
</Button>
<Separator className="bg-input mt-[20px] h-[1px]" />
</div>
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>{t('Remove Small Contours')}</div>
<div className="flex items-center gap-2 self-end">
<Label
htmlFor="simplify-contour-options"
className="text-muted-foreground"
>
{t('Area Threshold')}
</Label>
<Input
id="simplify-contour-options"
className="w-20"
type="number"
value={areaThreshold}
onChange={e => setAreaThreshold(Number(e.target.value))}
/>
</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('removeSmallContours', {
areaThreshold,
});
}}
>
{t('Remove Small Contours')}
</Button>
<Separator className="bg-input mt-[20px] h-[1px]" />
</div>
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>{t('Create New Segment from Holes')}</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('convertContourHoles');
}}
>
{t('Create New Segment')}
</Button>
</div>
</div>
);
}
export default SimplifyContourOptions;
@@ -1,41 +0,0 @@
import React from 'react';
import { Button, Separator } from '@ohif/ui-next';
import { useRunCommand } from '@ohif/core';
import { useTranslation } from 'react-i18next';
function SmoothContoursOptions() {
const runCommand = useRunCommand();
const { t } = useTranslation('SegmentationPanel');
return (
<div className="flex w-auto w-[245px] flex-col gap-[8px] text-base font-normal leading-none">
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>{t('Smooth all edges')}</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('smoothContours');
}}
>
{t('Smooth Edges')}
</Button>
<Separator className="bg-input mt-[20px] h-[1px]" />
</div>
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>{t('Remove extra points')}</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('decimateContours');
}}
>
{t('Remove Points')}
</Button>
</div>
</div>
);
}
export default SmoothContoursOptions;
@@ -20,17 +20,6 @@ const segProtocol: Types.HangingProtocol.Protocol = {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
@@ -63,20 +52,7 @@ const segProtocol: Types.HangingProtocol.Protocol = {
},
viewports: [
{
viewportOptions: {
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
viewportOptions: { allowUnmatchedView: true },
displaySets: [
{
id: 'segDisplaySetId',
@@ -0,0 +1,80 @@
import React from 'react';
import { useAppConfig } from '@state';
import { Toolbox } from '@ohif/ui';
import PanelSegmentation from './panels/PanelSegmentation';
const getPanelModule = ({
commandsManager,
servicesManager,
extensionManager,
configuration,
title,
}) => {
const { customizationService } = servicesManager.services;
const wrappedPanelSegmentation = configuration => {
const [appConfig] = useAppConfig();
return (
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
disableEditing: appConfig.disableEditing,
...customizationService.get('segmentation.panel'),
}}
/>
);
};
const wrappedPanelSegmentationWithTools = configuration => {
const [appConfig] = useAppConfig();
return (
<>
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="segmentationToolbox"
title="Segmentation Tools"
configuration={{
...configuration,
}}
/>
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
disableEditing: appConfig.disableEditing,
...customizationService.get('segmentation.panel'),
}}
/>
</>
);
};
return [
{
name: 'panelSegmentation',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentation,
},
{
name: 'panelSegmentationWithTools',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentationWithTools,
},
];
};
export default getPanelModule;
@@ -0,0 +1,234 @@
import { utils } from '@ohif/core';
import { metaData, cache, triggerEvent, eventTarget } from '@cornerstonejs/core';
import { CONSTANTS } from '@cornerstonejs/tools';
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomlabToRGB } from './utils/dicomlabToRGB';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4'];
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: 'SEG',
loading: false,
isReconstructable: true, // 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,
segments: {},
sopClassUids,
instance,
instances: [instance],
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (!referencedSeriesSequence) {
console.error('ReferencedSeriesSequence is missing for the SEG');
return;
}
const referencedSeries = referencedSeriesSequence[0] || referencedSeriesSequence;
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 SEG');
}
const referencedDisplaySet = referencedDisplaySets[0];
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
// Todo: this needs to be able to work with other reference volumes (other than streaming) such as nifti, etc.
displaySet.referencedVolumeURI = referencedDisplaySet.displaySetInstanceUID;
const referencedVolumeId = `cornerstoneStreamingImageVolume:${displaySet.referencedVolumeURI}`;
displaySet.referencedVolumeId = referencedVolumeId;
return referencedDisplaySet;
};
displaySet.load = async ({ headers }) =>
await _load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
function _load(segDisplaySet, servicesManager, extensionManager, headers) {
const { SOPInstanceUID } = segDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(segDisplaySet.loading || segDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID] &&
_segmentationExists(segDisplaySet, segmentationService)
) {
return loadPromises[SOPInstanceUID];
}
segDisplaySet.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 (!segDisplaySet.segments || Object.keys(segDisplaySet.segments).length === 0) {
await _loadSegments({
extensionManager,
servicesManager,
segDisplaySet,
headers,
});
}
const suppressEvents = true;
segmentationService
.createSegmentationForSEGDisplaySet(segDisplaySet, null, suppressEvents)
.then(() => {
segDisplaySet.loading = false;
resolve();
})
.catch(error => {
segDisplaySet.loading = false;
reject(error);
});
});
return loadPromises[SOPInstanceUID];
}
async function _loadSegments({ extensionManager, servicesManager, segDisplaySet, headers }) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { segmentationService, uiNotificationService } = servicesManager.services;
const { dicomLoaderService } = utilityModule.exports;
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
const cachedReferencedVolume = cache.getVolume(segDisplaySet.referencedVolumeId);
if (!cachedReferencedVolume) {
throw new Error(
'Referenced Volume is missing for the SEG, and stack viewport SEG is not supported yet'
);
}
const { imageIds } = cachedReferencedVolume;
// Todo: what should be defaults here
const tolerance = 0.001;
const skipOverlapping = true;
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
const { percentComplete } = evt.detail;
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
percentComplete,
});
});
const results = await adaptersSEG.Cornerstone3D.Segmentation.generateToolState(
imageIds,
arrayBuffer,
metaData,
{ skipOverlapping, tolerance, eventTarget, triggerEvent }
);
let usedRecommendedDisplayCIELabValue = true;
results.segMetadata.data.forEach((data, i) => {
if (i > 0) {
data.rgba = data.RecommendedDisplayCIELabValue;
if (data.rgba) {
data.rgba = dicomlabToRGB(data.rgba);
} else {
usedRecommendedDisplayCIELabValue = false;
data.rgba = CONSTANTS.COLOR_LUT[i % CONSTANTS.COLOR_LUT.length];
}
}
});
if (results.overlappingSegments) {
uiNotificationService.show({
title: 'Overlapping Segments',
message:
'Unsupported overlapping segments detected, segmentation rendering results may be incorrect.',
type: 'warning',
});
}
if (!usedRecommendedDisplayCIELabValue) {
// Display a notification about the non-utilization of RecommendedDisplayCIELabValue
uiNotificationService.show({
title: 'DICOM SEG import',
message:
'RecommendedDisplayCIELabValue not found for one or more segments. The default color was used instead.',
type: 'warning',
duration: 5000,
});
}
Object.assign(segDisplaySet, results);
}
function _segmentationExists(segDisplaySet, segmentationService) {
// This should be abstracted with the CornerstoneCacheService
return segmentationService.getSegmentation(segDisplaySet.displaySetInstanceUID);
}
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return [
{
name: 'dicom-seg',
sopClassUids,
getDisplaySetsFromSeries,
},
];
}
export default getSopClassHandlerModule;
@@ -1,270 +0,0 @@
import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { metaData, eventTarget } from '@cornerstonejs/core';
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomlabToRGB } from './utils/dicomlabToRGB';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4', '1.2.840.10008.5.1.4.1.1.66.7'];
const loadPromises = {};
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.ServicesManager,
extensionManager
) {
utils.sortStudyInstances(instances);
// Choose the LAST instance in the list as the most recently created one.
const instance = instances[instances.length - 1];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription = '',
SeriesNumber,
SeriesDate,
StructureSetDate,
SOPClassUID,
FrameOfReferenceUID,
wadoRoot,
wadoUri,
wadoUriRoot,
imageId: predecessorImageId,
} = instance;
const displaySet = {
Modality: 'SEG',
loading: false,
isReconstructable: false,
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate: SeriesDate || StructureSetDate || '',
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
FrameOfReferenceUID,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
isDerivedDisplaySet: true,
isLoaded: false,
isHydrated: false,
segments: {},
sopClassUids,
instance,
predecessorImageId,
instances: [instance],
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('SEG')}`,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (!referencedSeriesSequence) {
console.error('ReferencedSeriesSequence is missing for the SEG');
return;
}
const referencedSeries = referencedSeriesSequence[0] || referencedSeriesSequence;
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
const { displaySetService } = servicesManager.services;
const referencedDisplaySets = displaySetService.getDisplaySetsForReferences(
instance.ReferencedSeriesSequence
);
if (referencedDisplaySets?.length > 1) {
console.warn(
'Segmentation does not currently handle references to multiple series, defaulting to first series'
);
}
const referencedDisplaySet = referencedDisplaySets[0];
if (!referencedDisplaySet) {
// subscribe to display sets added which means at some point it will be available
const { unsubscribe } = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
({ displaySetsAdded }) => {
// here we can also do a little bit of search, since sometimes DICOM SEG
// does not contain the referenced display set uid , and we can just
// see which of the display sets added is more similar and assign it
// to the referencedDisplaySet
const addedDisplaySet = displaySetsAdded[0];
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
displaySet.FrameOfReferenceUID = addedDisplaySet.FrameOfReferenceUID;
unsubscribe();
}
}
);
} else {
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
displaySet.FrameOfReferenceUID = referencedDisplaySet.FrameOfReferenceUID;
}
displaySet.load = async ({ headers }) =>
await _load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
function _load(
segDisplaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager,
headers
) {
const { SOPInstanceUID } = segDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(segDisplaySet.loading || segDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID] &&
_segmentationExists(segDisplaySet)
) {
return loadPromises[SOPInstanceUID];
}
segDisplaySet.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 (!segDisplaySet.segments || Object.keys(segDisplaySet.segments).length === 0) {
try {
await _loadSegments({
extensionManager,
servicesManager,
segDisplaySet,
headers,
});
} catch (e) {
segDisplaySet.loading = false;
return reject(e);
}
}
segmentationService
.createSegmentationForSEGDisplaySet(segDisplaySet)
.then(() => {
segDisplaySet.loading = false;
resolve();
})
.catch(error => {
segDisplaySet.loading = false;
reject(error);
});
});
return loadPromises[SOPInstanceUID];
}
async function _loadSegments({
extensionManager,
servicesManager,
segDisplaySet,
headers,
}: withAppTypes) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { segmentationService, uiNotificationService } = servicesManager.services;
const { dicomLoaderService } = utilityModule.exports;
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
segDisplaySet.referencedDisplaySetInstanceUID
);
if (!referencedDisplaySet) {
throw new Error('referencedDisplaySet is missing for SEG');
}
let { imageIds } = referencedDisplaySet;
if (!imageIds) {
// try images
const { images } = referencedDisplaySet;
imageIds = images.map(image => image.imageId);
}
// Todo: what should be defaults here
const tolerance = 0.001;
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
const { percentComplete } = evt.detail;
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
percentComplete,
});
});
const results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer(
imageIds,
arrayBuffer,
{ metadataProvider: metaData, tolerance }
);
let usedRecommendedDisplayCIELabValue = true;
results.segMetadata.data.forEach((data, i) => {
if (i > 0) {
data.rgba = data.RecommendedDisplayCIELabValue;
if (data.rgba) {
data.rgba = dicomlabToRGB(data.rgba);
} else {
usedRecommendedDisplayCIELabValue = false;
data.rgba = CONSTANTS.COLOR_LUT[i % CONSTANTS.COLOR_LUT.length];
}
}
});
if (!usedRecommendedDisplayCIELabValue) {
// Display a notification about the non-utilization of RecommendedDisplayCIELabValue
uiNotificationService.show({
title: 'DICOM SEG import',
message:
'RecommendedDisplayCIELabValue not found for one or more segments. The default color was used instead.',
type: 'warning',
duration: 5000,
});
}
Object.assign(segDisplaySet, results);
}
function _segmentationExists(segDisplaySet) {
return cstSegmentation.state.getSegmentation(segDisplaySet.displaySetInstanceUID);
}
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
const { servicesManager, extensionManager } = params;
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return [
{
name: 'dicom-seg',
sopClassUids,
getDisplaySetsFromSeries,
},
];
}
export default getSopClassHandlerModule;
@@ -1,68 +1,6 @@
import { utilities as cstUtils } from '@cornerstonejs/tools';
import i18n from '@ohif/i18n';
import { useUIStateStore } from '@ohif/extension-default';
import LogicalContourOperationsOptions from './components/LogicalContourOperationsOptions';
import SimplifyContourOptions from './components/SimplifyContourOptions';
import SmoothContoursOptions from './components/SmoothContoursOptions';
export function getToolbarModule({ servicesManager }: withAppTypes) {
export function getToolbarModule({ servicesManager }) {
const { segmentationService, toolbarService, toolGroupService } = servicesManager.services;
return [
{
name: 'cornerstone.SimplifyContourOptions',
defaultComponent: SimplifyContourOptions,
},
{
name: 'cornerstone.LogicalContourOperationsOptions',
defaultComponent: LogicalContourOperationsOptions,
},
{
name: 'cornerstone.SmoothContoursOptions',
defaultComponent: SmoothContoursOptions,
},
{
name: 'cornerstone.isActiveSegmentationUtility',
evaluate: ({ button }) => {
const { uiState } = useUIStateStore.getState();
return {
isActive: uiState[`activeSegmentationUtility`] === button.id,
};
},
},
{
name: 'evaluate.cornerstone.hasSegmentation',
evaluate: ({ viewportId }) => {
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
return {
disabled: !segmentations?.length,
};
},
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
evaluate: ({ viewportId, segmentationRepresentationType }) => {
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
if (!segmentations?.length) {
return {
disabled: true,
disabledText: i18n.t('SegmentationPanel:No segmentations available'),
};
}
if (
!segmentations.some(segmentation =>
Boolean(segmentation.type === segmentationRepresentationType)
)
) {
return {
disabled: true,
disabledText: `No ${segmentationRepresentationType} segmentations available`,
};
}
},
},
{
name: 'evaluate.cornerstone.segmentation',
evaluate: ({ viewportId, button, toolNames, disabledText }) => {
@@ -70,19 +8,12 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
// forcing the button to have black background since initially
// it is designed for the toolbox not the toolbar on top
// we should then branch the buttonSectionId to have different styles
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
const segmentations = segmentationService.getSegmentations();
if (!segmentations?.length) {
return {
disabled: true,
disabledText: disabledText ?? i18n.t('SegmentationPanel:No segmentations available'),
};
}
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
if (!Object.keys(activeSegmentation.segments).length) {
return {
disabled: true,
disabledText: i18n.t('SegmentationPanel:Add segment to enable this tool'),
className: '!text-common-bright !bg-black opacity-50',
disabledText: disabledText ?? 'No segmentations available',
};
}
@@ -91,14 +22,8 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
if (!toolGroup) {
return {
disabled: true,
disabledText: disabledText ?? i18n.t('SegmentationPanel:Not available on the current viewport'),
};
}
if (!toolNames) {
return {
disabled: false,
// isActive: false,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
@@ -107,7 +32,8 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
if (!toolGroup.hasTool(toolName) && !toolNames) {
return {
disabled: true,
disabledText: disabledText ?? i18n.t('SegmentationPanel:Not available on the current viewport'),
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
@@ -117,27 +43,15 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
return {
disabled: false,
className: isPrimaryActive
? '!text-black !bg-primary-light hover:bg-primary-light hover-text-black hover:cursor-pointer'
: '!text-common-bright !bg-black hover:bg-primary-light hover:cursor-pointer hover:text-black',
// Todo: isActive right now is used for nested buttons where the primary
// button needs to be fully rounded (vs partial rounded) when active
// otherwise it does not have any other use
isActive: isPrimaryActive,
};
},
},
{
name: 'evaluate.cornerstone.segmentation.synchronizeDrawingRadius',
evaluate: ({ button, radiusOptionId }) => {
const toolGroupIds = toolGroupService.getToolGroupIds();
if (!toolGroupIds?.length) {
return;
}
for (const toolGroupId of toolGroupIds) {
const brushSize = cstUtils.segmentation.getBrushSizeForToolGroup(toolGroupId);
if (brushSize) {
const option = toolbarService.getOptionById(button, radiusOptionId);
option.value = brushSize;
}
}
},
},
];
}
@@ -3,6 +3,7 @@ import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getPanelModule from './getPanelModule';
import getCommandsModule from './commandsModule';
import { getToolbarModule } from './getToolbarModule';
@@ -27,6 +28,13 @@ const extension = {
* 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.
*/
getPanelModule,
getCommandsModule,
getToolbarModule,
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
@@ -0,0 +1,386 @@
import { createReportAsync } from '@ohif/extension-default';
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { SegmentationGroupTable, SegmentationGroupTableExpanded } from '@ohif/ui';
import { SegmentationPanelMode } from '../types/segmentation';
import callInputDialog from './callInputDialog';
import callColorPickerDialog from './colorPickerDialog';
import { useTranslation } from 'react-i18next';
const components = {
[SegmentationPanelMode.Expanded]: SegmentationGroupTableExpanded,
[SegmentationPanelMode.Dropdown]: SegmentationGroupTable,
};
export default function PanelSegmentation({
servicesManager,
commandsManager,
extensionManager,
configuration,
}) {
const {
segmentationService,
viewportGridService,
uiDialogService,
displaySetService,
cornerstoneViewportService,
} = servicesManager.services;
const { t } = useTranslation('PanelSegmentation');
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const [addSegmentationClassName, setAddSegmentationClassName] = useState('');
const [segmentationConfiguration, setSegmentationConfiguration] = useState(
segmentationService.getConfiguration()
);
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
useEffect(() => {
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const removed = segmentationService.EVENTS.SEGMENTATION_REMOVED;
const subscriptions = [];
[added, updated, removed].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
setSegmentationConfiguration(segmentationService.getConfiguration());
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
// temporary measure to not allow add segmentation when the selected viewport
// is stack viewport
useEffect(() => {
const handleActiveViewportChange = viewportId => {
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(
viewportId || viewportGridService.getActiveViewportId()
);
if (!displaySetUIDs) {
return;
}
const isReconstructable =
displaySetUIDs?.some(displaySetUID => {
const displaySet = displaySetService.getDisplaySetByUID(displaySetUID);
return displaySet?.isReconstructable;
}) || false;
if (isReconstructable) {
setAddSegmentationClassName('');
} else {
setAddSegmentationClassName('ohif-disabled');
}
};
// Handle initial state
handleActiveViewportChange();
const changedGrid = viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED;
const ready = viewportGridService.EVENTS.VIEWPORTS_READY;
const subsGrid = [];
[ready, changedGrid].forEach(evt => {
const { unsubscribe } = viewportGridService.subscribe(evt, ({ viewportId }) => {
handleActiveViewportChange(viewportId);
});
subsGrid.push(unsubscribe);
});
const changedData = cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED;
const subsData = [];
[changedData].forEach(evt => {
const { unsubscribe } = cornerstoneViewportService.subscribe(evt, () => {
handleActiveViewportChange();
});
subsData.push(unsubscribe);
});
// Clean up
return () => {
subsGrid.forEach(unsub => unsub());
subsData.forEach(unsub => unsub());
};
}, []);
const getToolGroupIds = segmentationId => {
const toolGroupIds = segmentationService.getToolGroupIdsWithSegmentation(segmentationId);
return toolGroupIds;
};
const onSegmentationAdd = async () => {
commandsManager.runCommand('createEmptySegmentationForViewport', {
viewportId: viewportGridService.getActiveViewportId(),
});
};
const onSegmentationClick = (segmentationId: string) => {
segmentationService.setActiveSegmentationForToolGroup(segmentationId);
};
const onSegmentationDelete = (segmentationId: string) => {
segmentationService.remove(segmentationId);
};
const onSegmentAdd = segmentationId => {
segmentationService.addSegment(segmentationId);
};
const onSegmentClick = (segmentationId, segmentIndex) => {
segmentationService.setActiveSegment(segmentationId, segmentIndex);
const toolGroupIds = getToolGroupIds(segmentationId);
toolGroupIds.forEach(toolGroupId => {
// const toolGroupId =
segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId);
segmentationService.jumpToSegmentCenter(segmentationId, segmentIndex, toolGroupId);
});
};
const onSegmentEdit = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segment = segmentation.segments[segmentIndex];
const { label } = segment;
callInputDialog(uiDialogService, label, (label, actionId) => {
if (label === '') {
return;
}
segmentationService.setSegmentLabel(segmentationId, segmentIndex, label);
});
};
const onSegmentationEdit = segmentationId => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const { label } = segmentation;
callInputDialog(uiDialogService, label, (label, actionId) => {
if (label === '') {
return;
}
segmentationService.addOrUpdateSegmentation(
{
id: segmentationId,
label,
},
false, // suppress event
true // notYetUpdatedAtSource
);
});
};
const onSegmentColorClick = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segment = segmentation.segments[segmentIndex];
const { color, opacity } = segment;
const rgbaColor = {
r: color[0],
g: color[1],
b: color[2],
a: opacity / 255.0,
};
callColorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
if (actionId === 'cancel') {
return;
}
segmentationService.setSegmentRGBAColor(segmentationId, segmentIndex, [
newRgbaColor.r,
newRgbaColor.g,
newRgbaColor.b,
newRgbaColor.a * 255.0,
]);
});
};
const onSegmentDelete = (segmentationId, segmentIndex) => {
segmentationService.removeSegment(segmentationId, segmentIndex);
};
// segment hide
const onToggleSegmentVisibility = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segmentInfo = segmentation.segments[segmentIndex];
const isVisible = !segmentInfo.isVisible;
const toolGroupIds = getToolGroupIds(segmentationId);
// Todo: right now we apply the visibility to all tool groups
toolGroupIds.forEach(toolGroupId => {
segmentationService.setSegmentVisibility(
segmentationId,
segmentIndex,
isVisible,
toolGroupId
);
});
};
const onToggleSegmentLock = (segmentationId, segmentIndex) => {
segmentationService.toggleSegmentLocked(segmentationId, segmentIndex);
};
const onToggleSegmentationVisibility = segmentationId => {
segmentationService.toggleSegmentationVisibility(segmentationId);
const segmentation = segmentationService.getSegmentation(segmentationId);
const isVisible = segmentation.isVisible;
const segments = segmentation.segments;
const toolGroupIds = getToolGroupIds(segmentationId);
toolGroupIds.forEach(toolGroupId => {
segments.forEach((segment, segmentIndex) => {
segmentationService.setSegmentVisibility(
segmentationId,
segmentIndex,
isVisible,
toolGroupId
);
});
});
};
const _setSegmentationConfiguration = useCallback(
(segmentationId, key, value) => {
segmentationService.setConfiguration({
segmentationId,
[key]: value,
});
},
[segmentationService]
);
const onSegmentationDownload = segmentationId => {
commandsManager.runCommand('downloadSegmentation', {
segmentationId,
});
};
const storeSegmentation = async segmentationId => {
const datasources = extensionManager.getActiveDataSource();
const displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport: () =>
commandsManager.runCommand('storeSegmentation', {
segmentationId,
dataSource: datasources[0],
}),
reportType: 'Segmentation',
});
// Show the exported report in the active viewport as read only (similar to SR)
if (displaySetInstanceUIDs) {
// clear the segmentation that we exported, similar to the storeMeasurement
// where we remove the measurements and prompt again the user if they would like
// to re-read the measurements in a SR read only viewport
segmentationService.remove(segmentationId);
viewportGridService.setDisplaySetsForViewport({
viewportId: viewportGridService.getActiveViewportId(),
displaySetInstanceUIDs,
});
}
};
const onSegmentationDownloadRTSS = segmentationId => {
commandsManager.runCommand('downloadRTSS', {
segmentationId,
});
};
const SegmentationGroupTableComponent =
components[configuration?.segmentationPanelMode] || SegmentationGroupTable;
const allowAddSegment = configuration?.addSegment;
const onSegmentationAddWrapper =
configuration?.onSegmentationAdd && typeof configuration?.onSegmentationAdd === 'function'
? configuration?.onSegmentationAdd
: onSegmentationAdd;
return (
<SegmentationGroupTableComponent
title={t('Segmentations')}
segmentations={segmentations}
disableEditing={configuration.disableEditing}
activeSegmentationId={selectedSegmentationId || ''}
onSegmentationAdd={onSegmentationAddWrapper}
addSegmentationClassName={addSegmentationClassName}
showAddSegment={allowAddSegment}
onSegmentationClick={onSegmentationClick}
onSegmentationDelete={onSegmentationDelete}
onSegmentationDownload={onSegmentationDownload}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
storeSegmentation={storeSegmentation}
onSegmentationEdit={onSegmentationEdit}
onSegmentClick={onSegmentClick}
onSegmentEdit={onSegmentEdit}
onSegmentAdd={onSegmentAdd}
onSegmentColorClick={onSegmentColorClick}
onSegmentDelete={onSegmentDelete}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentLock={onToggleSegmentLock}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
showDeleteSegment={true}
segmentationConfig={{ initialConfig: segmentationConfiguration }}
setRenderOutline={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value)
}
setOutlineOpacityActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value)
}
setRenderFill={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value)
}
setRenderInactiveSegmentations={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderInactiveSegmentations', value)
}
setOutlineWidthActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value)
}
setFillAlpha={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value)
}
setFillAlphaInactive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value)
}
/>
);
}
PanelSegmentation.propTypes = {
commandsManager: PropTypes.shape({
runCommand: PropTypes.func.isRequired,
}),
servicesManager: PropTypes.shape({
services: PropTypes.shape({
segmentationService: PropTypes.shape({
getSegmentation: PropTypes.func.isRequired,
getSegmentations: PropTypes.func.isRequired,
toggleSegmentationVisibility: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
@@ -0,0 +1,62 @@
import React from 'react';
import { Input, Dialog, ButtonEnums } from '@ohif/ui';
function callInputDialog(uiDialogService, label, callback) {
const dialogId = 'enter-segment-label';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.label, 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: 'Segment',
value: { label },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Confirm', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
return (
<Input
label="Enter the segment label"
labelClassName="text-white text-[14px] leading-[1.2]"
autoFocus
className="border-primary-main bg-black"
type="text"
value={value.label}
onChange={event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
}}
onKeyPress={event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
}}
/>
);
},
},
});
}
}
export default callInputDialog;
@@ -0,0 +1,58 @@
import React from 'react';
import { Dialog } from '@ohif/ui';
import { ChromePicker } from 'react-color';
import './colorPickerDialog.css';
function callColorPickerDialog(uiDialogService, rgbaColor, callback) {
const dialogId = 'pick-color';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.rgbaColor, 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: 'Segment Color',
value: { rgbaColor },
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 }) => {
const handleChange = color => {
setValue({ rgbaColor: color.rgb });
};
return (
<ChromePicker
color={value.rgbaColor}
onChange={handleChange}
presetColors={[]}
width={300}
/>
);
},
},
});
}
}
export default callColorPickerDialog;
@@ -0,0 +1,33 @@
// Todo: use defaults in cs3d
const initialState = {
renderOutline: true,
renderFill: true,
outlineOpacity: 0.9,
outlineWidth: 3,
fillOpacity: 0.9,
fillOpacityInactive: 0.8,
renderInactiveSegmentations: true,
};
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_OPACITY':
return { ...state, fillOpacity: action.payload.value };
case 'SET_FILL_OPACITY_INACTIVE':
return { ...state, fillOpacityInactive: action.payload.value };
case 'RENDER_INACTIVE_SEGMENTATIONS':
return { ...state, renderInactiveSegmentations: action.payload.value };
default:
return state;
}
};
export { initialState, reducer };
@@ -0,0 +1,192 @@
import { Enums, cache } from '@cornerstonejs/core';
/**
* Updates the viewports in preparation for rendering segmentations.
* Evaluates each viewport to determine which need modifications,
* then for those viewports, changes them to a volume type and ensures
* they are ready for segmentation rendering.
*
* @param {Object} params - Parameters for the function.
* @param params.viewportId - ID of the viewport to be updated.
* @param params.loadFn - Function to load the segmentation data.
* @param params.servicesManager - The services manager.
* @param params.referencedDisplaySetInstanceUID - Optional UID for the referenced display set instance.
*
* @returns Returns true upon successful update of viewports for segmentation rendering.
*/
async function updateViewportsForSegmentationRendering({
viewportId,
loadFn,
servicesManager,
referencedDisplaySetInstanceUID,
}: {
viewportId: string;
loadFn: () => Promise<string>;
servicesManager: any;
referencedDisplaySetInstanceUID?: string;
}) {
const { cornerstoneViewportService, segmentationService, viewportGridService } =
servicesManager.services;
const viewport = getTargetViewport({ viewportId, viewportGridService });
const targetViewportId = viewport.viewportOptions.viewportId;
referencedDisplaySetInstanceUID =
referencedDisplaySetInstanceUID || viewport?.displaySetInstanceUIDs[0];
const updatedViewports = getUpdatedViewportsForSegmentation({
servicesManager,
viewportId,
referencedDisplaySetInstanceUID,
});
// create Segmentation callback which needs to be waited until
// the volume is created (if coming from stack)
const createSegmentationForVolume = async () => {
const segmentationId = await loadFn();
segmentationService.hydrateSegmentation(segmentationId);
};
// the reference volume that is used to draw the segmentation. so check if the
// volume exists in the cache (the target Viewport is already a volume viewport)
const volumeExists = Array.from(cache._volumeCache.keys()).some(volumeId =>
volumeId.includes(referencedDisplaySetInstanceUID)
);
updatedViewports.forEach(async viewport => {
viewport.viewportOptions = {
...viewport.viewportOptions,
viewportType: 'volume',
needsRerendering: true,
};
const viewportId = viewport.viewportId;
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const prevCamera = csViewport.getCamera();
// only run the createSegmentationForVolume for the targetViewportId
// since the rest will get handled by cornerstoneViewportService
if (volumeExists && viewportId === targetViewportId) {
await createSegmentationForVolume();
return;
}
const createNewSegmentationWhenVolumeMounts = async evt => {
const isTheActiveViewportVolumeMounted = evt.detail.volumeActors?.find(ac =>
ac.uid.includes(referencedDisplaySetInstanceUID)
);
// Note: make sure to re-grab the viewport since it might have changed
// during the time it took for the volume to be mounted, for instance
// the stack viewport has been changed to a volume viewport
const volumeViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
volumeViewport.setCamera(prevCamera);
volumeViewport.element.removeEventListener(
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
createNewSegmentationWhenVolumeMounts
);
if (!isTheActiveViewportVolumeMounted) {
// it means it is one of those other updated viewports so just update the camera
return;
}
if (viewportId === targetViewportId) {
await createSegmentationForVolume();
}
};
csViewport.element.addEventListener(
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
createNewSegmentationWhenVolumeMounts
);
});
// Set the displaySets for the viewports that require to be updated
viewportGridService.setDisplaySetsForViewports(updatedViewports);
return true;
}
const getTargetViewport = ({ viewportId, viewportGridService }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
const targetViewportId = viewportId || activeViewportId;
const viewport = viewports.get(targetViewportId);
return viewport;
};
/**
* Retrieves a list of viewports that require updates in preparation for segmentation rendering.
* This function evaluates viewports based on their compatibility with the provided segmentation's
* frame of reference UID and appends them to the updated list if they should render the segmentation.
*
* @param {Object} params - Parameters for the function.
* @param params.viewportId - the ID of the viewport to be updated.
* @param params.servicesManager - The services manager
* @param params.referencedDisplaySetInstanceUID - Optional UID for the referenced display set instance.
*
* @returns {Array} Returns an array of viewports that require updates for segmentation rendering.
*/
function getUpdatedViewportsForSegmentation({
viewportId,
servicesManager,
referencedDisplaySetInstanceUID,
}) {
const { hangingProtocolService, displaySetService, segmentationService, viewportGridService } =
servicesManager.services;
const { viewports, isHangingProtocolLayout } = viewportGridService.getState();
const viewport = getTargetViewport({ viewportId, viewportGridService });
const targetViewportId = viewport.viewportOptions.viewportId;
const displaySetInstanceUIDs = viewports.get(targetViewportId).displaySetInstanceUIDs;
const referenceDisplaySetInstanceUID =
referencedDisplaySetInstanceUID || displaySetInstanceUIDs[0];
const referencedDisplaySet = displaySetService.getDisplaySetByUID(referenceDisplaySetInstanceUID);
const segmentationFrameOfReferenceUID = referencedDisplaySet.instances[0].FrameOfReferenceUID;
const updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
targetViewportId,
referenceDisplaySetInstanceUID,
isHangingProtocolLayout
);
viewports.forEach((viewport, viewportId) => {
if (
targetViewportId === viewportId ||
updatedViewports.find(v => v.viewportId === viewportId)
) {
return;
}
const shouldDisplaySeg = segmentationService.shouldRenderSegmentation(
viewport.displaySetInstanceUIDs,
segmentationFrameOfReferenceUID
);
if (shouldDisplaySeg) {
updatedViewports.push({
viewportId,
displaySetInstanceUIDs: viewport.displaySetInstanceUIDs,
viewportOptions: {
viewportType: 'volume',
needsRerendering: true,
},
});
}
});
return updatedViewports.filter(v => v.viewportOptions?.viewportType !== 'volume3d');
}
export {
updateViewportsForSegmentationRendering,
getUpdatedViewportsForSegmentation,
getTargetViewport,
};
@@ -1,14 +1,7 @@
function createSEGToolGroupAndAddTools({
commandsManager,
toolGroupService,
customizationService,
toolGroupId,
}) {
const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
function createSEGToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {};
const updatedTools = commandsManager.run('initializeSegmentLabelTool', { tools });
return toolGroupService.createToolGroupAndAddTools(toolGroupId, updatedTools);
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
}
export default createSEGToolGroupAndAddTools;
@@ -1,25 +1,74 @@
import { utils, Types } from '@ohif/extension-cornerstone';
import { ButtonEnums } from '@ohif/ui';
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
HYDRATE_SEG: 5,
};
function promptHydrateSEG({
servicesManager,
segDisplaySet,
viewportId,
preHydrateCallbacks,
hydrateCallback,
}: {
servicesManager: AppTypes.ServicesManager;
segDisplaySet: AppTypes.DisplaySet;
viewportId: string;
preHydrateCallbacks?: Types.HydrationCallback[];
hydrateCallback: Types.HydrationCallback;
hydrateSEGDisplaySet,
}) {
return utils.promptHydrationDialog({
servicesManager,
viewportId,
displaySet: segDisplaySet as AppTypes.DisplaySet,
preHydrateCallbacks,
hydrateCallback,
type: 'SEG',
const { uiViewportDialogService } = servicesManager.services;
return new Promise(async function (resolve, reject) {
const promptResult = await _askHydrate(uiViewportDialogService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
callback();
});
const isHydrated = await hydrateSEGDisplaySet({
segDisplaySet,
viewportId,
});
resolve(isHydrated);
}
});
}
function _askHydrate(uiViewportDialogService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const actions = [
{
type: ButtonEnums.type.secondary,
text: 'No',
value: RESPONSE.CANCEL,
},
{
type: ButtonEnums.type.primary,
text: 'Yes',
value: RESPONSE.HYDRATE_SEG,
},
];
const onSubmit = result => {
uiViewportDialogService.hide();
resolve(result);
};
uiViewportDialogService.show({
viewportId,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
onSubmit(RESPONSE.HYDRATE_SEG);
}
},
});
});
}
@@ -1,28 +1,33 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewportGrid } from '@ohif/ui-next';
import { useTranslation } from 'react-i18next';
import { LoadingIndicatorTotalPercent, useViewportGrid, ViewportActionArrows } from '@ohif/ui';
import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup';
import promptHydrateSEG from '../utils/promptHydrateSEG';
import { usePositionPresentationStore, OHIFCornerstoneViewport } from '@ohif/extension-cornerstone';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import { useSystem } from '@ohif/core/src/contextProviders/SystemProvider';
import _getStatusComponent from './_getStatusComponent';
const SEG_TOOLGROUP_BASE_NAME = 'SEGToolGroup';
function OHIFCornerstoneSEGViewport(props: withAppTypes) {
const { servicesManager, commandsManager } = useSystem();
const { children, displaySets, viewportOptions } = props as {
children: React.ReactNode;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportOptions;
};
function OHIFCornerstoneSEGViewport(props) {
const {
children,
displaySets,
viewportOptions,
servicesManager,
extensionManager,
commandsManager,
} = props;
const { t } = useTranslation('SEGViewport');
const viewportId = viewportOptions.viewportId;
const { displaySetService, toolGroupService, segmentationService, customizationService } =
servicesManager.services;
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const {
displaySetService,
toolGroupService,
segmentationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const toolGroupId = `${SEG_TOOLGROUP_BASE_NAME}-${viewportId}`;
@@ -32,17 +37,20 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
}
const segDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
// States
const { setPositionPresentation } = usePositionPresentationStore();
const [selectedSegment, setSelectedSegment] = useState(1);
// Hydration means that the SEG is opened and segments are loaded into the
// segmentation panel, and SEG is also rendered on any viewport that is in the
// same frameOfReferenceUID as the referencedSeriesUID of the SEG. However,
// loading basically means SEG loading over network and bit unpacking of the
// SEG data.
const [isHydrated, setIsHydrated] = useState(segDisplaySet.isHydrated);
const [segIsLoading, setSegIsLoading] = useState(!segDisplaySet.isLoaded);
const [element, setElement] = useState(null);
const [processingProgress, setProcessingProgress] = useState({
percentComplete: null,
totalSegments: null,
@@ -53,37 +61,7 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
const { viewports, activeViewportId } = viewportGrid;
const referencedDisplaySetInstanceUID = segDisplaySet.referencedDisplaySetInstanceUID;
// If the referencedDisplaySetInstanceUID is not found, it means the SEG series is being
// launched without its corresponding referenced display set (e.g., the SEG series is launched using
// series launch /mode?StudyInstanceUIDs=&SeriesInstanceUID).
// In such cases, we attempt to handle this scenario gracefully by
// invoking a custom handler. Ideally, if a user tries to launch a series that isn't viewable,
// (eg.: we can prompt them with an explanation and provide a link to the full study).
// Additional guard: If no customization handler is registered for missing
// referenced display sets, skip SEG rendering to avoid a viewport crash.
if (!referencedDisplaySetInstanceUID) {
const missingReferenceDisplaySetHandler = customizationService.getCustomization(
'missingReferenceDisplaySetHandler'
);
if (typeof missingReferenceDisplaySetHandler === 'function') {
const { handled } = missingReferenceDisplaySetHandler();
if (handled) {
return;
}
} else {
console.log(
"No customization 'missingReferenceDisplaySetHandler' registered. Skipping SEG rendering."
);
return;
}
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
referencedDisplaySetInstanceUID
);
const referencedDisplaySet = segDisplaySet.getReferenceDisplaySet();
const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata(
referencedDisplaySet,
segDisplaySet
@@ -93,72 +71,105 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
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 storePresentationState = useCallback(() => {
viewportGrid?.viewports.forEach(({ viewportId }) => {
commandsManager.runCommand('storePresentation', {
viewportId,
});
});
}, [viewportGrid]);
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 (
<OHIFCornerstoneViewport
<Component
{...props}
displaySets={[segDisplaySet]}
displaySets={[referencedDisplaySet, segDisplaySet]}
viewportOptions={{
viewportType: viewportOptions.viewportType,
viewportType: 'volume',
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
/>
onElementDisabled={onElementDisabled}
></Component>
);
}, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]);
}, [viewportId, segDisplaySet, toolGroupId]);
const onSegmentChange = useCallback(
direction => {
const segmentationId = segDisplaySet.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 (segIsLoading) {
return;
}
// if not active viewport, return
if (viewportId !== activeViewportId) {
return;
}
promptHydrateSEG({
servicesManager,
viewportId,
segDisplaySet,
hydrateCallback: async () => {
await commandsManager.runCommand('hydrateSecondaryDisplaySet', {
displaySet: segDisplaySet,
viewportId,
});
return true;
},
preHydrateCallbacks: [storePresentationState],
hydrateSEGDisplaySet,
}).then(isHydrated => {
if (isHydrated) {
setIsHydrated(true);
}
});
}, [servicesManager, viewportId, segDisplaySet, segIsLoading, commandsManager, activeViewportId]);
}, [servicesManager, viewportId, segDisplaySet, segIsLoading]);
useEffect(() => {
// on new seg display set, remove all segmentations from all viewports
segmentationService.clearSegmentationRepresentations(viewportId);
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (evt.segDisplaySet?.displaySetInstanceUID === segDisplaySet?.displaySetInstanceUID) {
if (evt.segDisplaySet.displaySetInstanceUID === segDisplaySet.displaySetInstanceUID) {
setSegIsLoading(false);
}
if (segDisplaySet?.firstSegmentedSliceImageId && viewportOptions?.presentationIds) {
const { firstSegmentedSliceImageId } = segDisplaySet;
const { presentationIds } = viewportOptions;
setPositionPresentation(presentationIds.positionPresentationId, {
viewReference: {
referencedImageId: firstSegmentedSliceImageId,
},
});
}
}
);
@@ -212,36 +223,92 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
return;
}
// keep the already stored segmentationPresentation for this viewport in memory
// so that we can restore it after hydrating the SEG
commandsManager.runCommand('updateStoredSegmentationPresentation', {
displaySet: segDisplaySet,
type: SegmentationRepresentations.Labelmap,
});
// always start fresh for this viewport since it is special type of viewport
// that should only show one segmentation at a time.
segmentationService.clearSegmentationRepresentations(viewportId);
// This creates a custom tool group which has the lifetime of this view
// only, and does NOT interfere with currently displayed segmentations.
toolGroup = createSEGToolGroupAndAddTools({
commandsManager,
toolGroupService,
customizationService,
toolGroupId,
});
toolGroup = createSEGToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId);
return () => {
// remove the segmentation representations if seg displayset changed
// e.g., another seg displayset is dragged into the viewport
segmentationService.clearSegmentationRepresentations(viewportId);
segmentationService.removeSegmentationRepresentationFromToolGroup(toolGroupId);
// Only destroy the viewport specific implementation
toolGroupService.destroyToolGroup(toolGroupId);
};
}, []);
useEffect(() => {
setIsHydrated(segDisplaySet.isHydrated);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentationFromToolGroup(toolGroupId);
referencedDisplaySetRef.current = null;
};
}, [segDisplaySet]);
const hydrateSEGDisplaySet = useCallback(
({ segDisplaySet, viewportId }) => {
commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', {
displaySets: [segDisplaySet],
viewportId,
});
},
[commandsManager]
);
const onStatusClick = useCallback(async () => {
// Before hydrating a SEG and make it added to all viewports in the grid
// that share the same frameOfReferenceUID, we need to store the viewport grid
// presentation state, so that we can restore it after hydrating the SEG. This is
// required if the user has changed the viewport (other viewport than SEG viewport)
// presentation state (w/l and invert) and then opens the SEG. If we don't store
// the presentation state, the viewport will be reset to the default presentation
storePresentationState();
const isHydrated = await hydrateSEGDisplaySet({
segDisplaySet,
viewportId,
});
setIsHydrated(isHydrated);
}, [hydrateSEGDisplaySet, segDisplaySet, storePresentationState, viewportId]);
useEffect(() => {
viewportActionCornersService.setComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
isHydrated,
onStatusClick,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onSegmentChange}
className={
viewportId === activeViewportId ? 'visible' : 'invisible group-hover:visible'
}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [
activeViewportId,
isHydrated,
onSegmentChange,
onStatusClick,
viewportActionCornersService,
viewportId,
]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
@@ -265,6 +332,18 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
});
}
const {
PatientID,
PatientName,
PatientSex,
PatientAge,
SliceThickness,
ManufacturerModelName,
StudyDate,
SeriesDescription,
SpacingBetweenSlices,
} = referencedDisplaySetRef.current.metadata;
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
@@ -283,6 +362,18 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
);
}
OHIFCornerstoneSEGViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
customProps: PropTypes.object,
};
OHIFCornerstoneSEGViewport.defaultProps = {
customProps: {},
};
function _getReferencedDisplaySetMetadata(referencedDisplaySet, segDisplaySet) {
const { SharedFunctionalGroupsSequence } = segDisplaySet.instance;
@@ -0,0 +1,62 @@
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;
switch (isHydrated) {
case true:
StatusIcon = () => <Icon name="status-alert" />;
ToolTipMessage = () => <div>This Segmentation is loaded in the segmentation panel</div>;
break;
case false:
StatusIcon = () => (
<Icon
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>Click LOAD to load segmentation.</div>;
}
const StatusArea = () => {
const { t } = useTranslation('Common');
const loadStr = t('LOAD');
return (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">SEG</span>
</div>
{!isHydrated && (
<div
className="bg-primary-main hover:bg-primary-light ml-1 cursor-pointer rounded px-1.5 hover:text-black"
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
onMouseUp={onStatusClick}
>
{loadStr}
</div>
)}
</div>
);
};
return (
<>
{ToolTipMessage && (
<Tooltip
content={<ToolTipMessage />}
position="bottom-left"
>
<StatusArea />
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}
File diff suppressed because it is too large. Load diff
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.13.0-beta.83",
"version": "3.8.0",
"description": "OHIF extension for an SR Cornerstone Viewport",
"author": "OHIF",
"license": "MIT",
@@ -34,21 +34,21 @@
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "3.13.0-beta.83",
"@ohif/extension-cornerstone": "3.13.0-beta.83",
"@ohif/extension-measurement-tracking": "3.13.0-beta.83",
"@ohif/ui": "3.13.0-beta.83",
"dcmjs": "0.49.4",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
"react": "18.3.1"
"@ohif/core": "3.8.0",
"@ohif/extension-cornerstone": "3.8.0",
"@ohif/extension-measurement-tracking": "3.8.0",
"@ohif/ui": "3.8.0",
"dcmjs": "^0.29.12",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^17.0.2"
},
"dependencies": {
"@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "4.22.10",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"classnames": "2.5.1"
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.70.14",
"@cornerstonejs/core": "^1.70.14",
"@cornerstonejs/tools": "^1.70.14",
"classnames": "^2.3.2"
}
}
@@ -0,0 +1,144 @@
import { metaData, utilities } from '@cornerstonejs/core';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import dcmjs from 'dcmjs';
import { adaptersSR } from '@cornerstonejs/adapters';
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { log } = OHIF;
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
*
*/
const _generateReport = (measurementData, additionalFindingTypes, options = {}) => {
const filteredToolState = getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
);
const report = MeasurementReport.generateReport(
filteredToolState,
metaData,
utilities.worldToImageCoords,
options
);
const { dataset } = report;
// Set the default character set as UTF-8
// https://dicom.innolitics.com/ciods/nm-image/sop-common/00080005
if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192';
}
return dataset;
};
const commandsModule = props => {
const { servicesManager } = props;
const { customizationService } = servicesManager.services;
const actions = {
/**
*
* @param measurementData An array of measurements from the measurements service
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* as opposed to Finding Sites.
* that you wish to serialize.
*/
downloadReport: ({ measurementData, additionalFindingTypes, options = {} }) => {
const srDataset = actions.generateReport(measurementData, additionalFindingTypes, options);
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
//Create a URL for the binary.
var objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
},
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param dataSource The dataSource that you wish to use to persist the data.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* @return The naturalized report
*/
storeMeasurements: async ({
measurementData,
dataSource,
additionalFindingTypes,
options = {},
}) => {
// Use the @cornerstonejs adapter for converting to/from DICOM
// But it is good enough for now whilst we only have cornerstone as a datasource.
log.info('[DICOMSR] storeMeasurements');
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
return Promise.reject({});
}
try {
const naturalizedReport = _generateReport(measurementData, additionalFindingTypes, options);
const { StudyInstanceUID, 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');
}
const onBeforeDicomStore =
customizationService.getModeCustomization('onBeforeDicomStore')?.value;
let dicomDict;
if (typeof onBeforeDicomStore === 'function') {
dicomDict = onBeforeDicomStore({ measurementData, naturalizedReport });
}
await dataSource.store.dicom(naturalizedReport, null, dicomDict);
if (StudyInstanceUID) {
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);
log.error(`[DICOMSR] Error while saving the measurements: ${error.message}`);
throw new Error(error.message || 'Error while saving the measurements.');
}
},
};
const definitions = {
downloadReport: {
commandFn: actions.downloadReport,
},
storeMeasurements: {
commandFn: actions.storeMeasurements,
},
};
return {
actions,
definitions,
defaultContext: 'CORNERSTONE_STRUCTURED_REPORT',
};
};
export default commandsModule;
@@ -1,158 +0,0 @@
import { metaData } from '@cornerstonejs/core';
import OHIF from '@ohif/core';
import { adaptersSR } from '@cornerstonejs/adapters';
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
import hydrateStructuredReport from './utils/hydrateStructuredReport';
const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { log } = OHIF;
interface Options {
SeriesDescription?: string;
SeriesInstanceUID?: string;
SeriesNumber?: number;
InstanceNumber?: number;
SeriesDate?: string;
SeriesTime?: string;
}
/**
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
*
*/
const _generateReport = (measurementData, additionalFindingTypes, options: Options = {}) => {
const filteredToolState = getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
);
const report = MeasurementReport.generateReport(filteredToolState, metaData, options);
const { dataset } = report;
// Set the default character set as UTF-8
// https://dicom.innolitics.com/ciods/nm-image/sop-common/00080005
if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192';
}
return dataset;
};
const commandsModule = (props: withAppTypes) => {
const { servicesManager, extensionManager, commandsManager } = props;
const { customizationService } = servicesManager.services;
const actions = {
changeColorMeasurement: ({ uid }) => {
// When this gets supported, it probably belongs in cornerstone, not sr
throw new Error('Unsupported operation: changeColorMeasurement');
// const { color } = measurementService.getMeasurement(uid);
// const rgbaColor = {
// r: color[0],
// g: color[1],
// b: color[2],
// a: color[3] / 255.0,
// };
// colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
// if (actionId === 'cancel') {
// return;
// }
// const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
// segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
// });
},
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param dataSource The data source name ('download', 'copyToClipboard', or a named data source).
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* @return The naturalized report
*/
storeMeasurements: async ({
measurementData,
dataSource,
additionalFindingTypes,
options = {},
}) => {
log.info('[DICOMSR] storeMeasurements');
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource,
defaultFileName: 'dicom-sr.dcm',
});
if (!storeFn) {
log.error('[DICOMSR] No valid store for dataSource:', dataSource);
return Promise.reject({});
}
try {
const naturalizedReport = _generateReport(
measurementData,
additionalFindingTypes,
options
);
const { 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');
}
if (!naturalizedReport.SOPClassUID) {
throw new Error('No sop class uid');
}
const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
let dicomDict;
if (typeof onBeforeDicomStore === 'function') {
dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
}
await storeFn(naturalizedReport, { measurementData, dicomDict });
return naturalizedReport;
} catch (error) {
console.warn(error);
log.error(`[DICOMSR] Error while saving the measurements: ${error.message}`);
throw new Error(error.message || 'Error while saving the measurements.');
}
},
/**
* Loads measurements by hydrating and loading the SR for the given display set instance UID
* and displays it in the active viewport.
*/
hydrateStructuredReport: ({ displaySetInstanceUID }) => {
return hydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager },
displaySetInstanceUID
);
},
};
const definitions = {
storeMeasurements: actions.storeMeasurements,
hydrateStructuredReport: actions.hydrateStructuredReport,
};
return {
actions,
definitions,
defaultContext: 'CORNERSTONE_STRUCTURED_REPORT',
};
};
export default commandsModule;
@@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { OHIFCornerstoneSRContentItem } from './OHIFCornerstoneSRContentItem';
export function OHIFCornerstoneSRContainer(props) {
const { container, nodeIndexesTree = [0], containerNumberedTree = [1] } = props;
const { ContinuityOfContent, ConceptNameCodeSequence } = container;
const { CodeMeaning } = ConceptNameCodeSequence ?? {};
let childContainerIndex = 1;
const contentItems = container.ContentSequence?.map((contentItem, i) => {
const { ValueType } = contentItem;
const childNodeLevel = [...nodeIndexesTree, i];
const key = childNodeLevel.join('.');
let Component;
let componentProps;
if (ValueType === 'CONTAINER') {
const childContainerNumberedTree = [...containerNumberedTree, childContainerIndex++];
Component = OHIFCornerstoneSRContainer;
componentProps = {
container: contentItem,
nodeIndexesTree: childNodeLevel,
containerNumberedTree: childContainerNumberedTree,
};
} else {
Component = OHIFCornerstoneSRContentItem;
componentProps = {
contentItem,
nodeIndexesTree: childNodeLevel,
continuityOfContent: ContinuityOfContent,
};
}
return (
<Component
key={key}
{...componentProps}
/>
);
});
return (
<div>
<div className="font-bold">
{containerNumberedTree.join('.')}.&nbsp;
{CodeMeaning}
</div>
<div className="ml-4 mb-2">{contentItems}</div>
</div>
);
}
OHIFCornerstoneSRContainer.propTypes = {
/**
* A tree node that may contain another container or one or more content items
* (text, code, uidref, pname, etc.)
*/
container: PropTypes.object,
/**
* A 0-based index list
*/
nodeIndexesTree: PropTypes.arrayOf(PropTypes.number),
/**
* A 1-based index list that represents a container in a multi-level numbered
* list (tree).
*
* Example:
* 1. History
* 1.1. Chief Complaint
* 1.2. Present Illness
* 1.3. Past History
* 1.4. Family History
* 2. Findings
* */
containerNumberedTree: PropTypes.arrayOf(PropTypes.number),
};
@@ -1,63 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { CodeNameCodeSequenceValues } from '../enums';
import formatContentItemValue from '../utils/formatContentItem';
const EMPTY_TAG_VALUE = '[empty]';
function OHIFCornerstoneSRContentItem(props) {
const { contentItem, nodeIndexesTree, continuityOfContent } = props;
const { ConceptNameCodeSequence } = contentItem;
const { CodeValue, CodeMeaning } = ConceptNameCodeSequence;
const isChildFirstNode = nodeIndexesTree[nodeIndexesTree.length - 1] === 0;
const formattedValue = formatContentItemValue(contentItem) ?? EMPTY_TAG_VALUE;
const startWithAlphaNumCharRegEx = /^[a-zA-Z0-9]/;
const isContinuous = continuityOfContent === 'CONTINUOUS';
const isFinding = CodeValue === CodeNameCodeSequenceValues.Finding;
const addExtraSpace =
isContinuous && !isChildFirstNode && startWithAlphaNumCharRegEx.test(formattedValue?.[0]);
// Collapse sequences of white space preserving newline characters
let className = 'whitespace-pre-line';
if (CodeValue === CodeNameCodeSequenceValues.Finding) {
// Preserve spaces because it is common to see tabular text in a
// "Findings" ConceptNameCodeSequence
className = 'whitespace-pre-wrap';
}
if (isContinuous) {
return (
<>
<span
className={className}
title={CodeMeaning}
>
{addExtraSpace ? ' ' : ''}
{formattedValue}
</span>
</>
);
}
return (
<>
<div className="mb-2">
<span className="font-bold">{CodeMeaning}: </span>
{isFinding ? (
<pre>{formattedValue}</pre>
) : (
<span className={className}>{formattedValue}</span>
)}
</div>
</>
);
}
OHIFCornerstoneSRContentItem.propTypes = {
contentItem: PropTypes.object,
nodeIndexesTree: PropTypes.arrayOf(PropTypes.number),
continuityOfContent: PropTypes.string,
};
export { OHIFCornerstoneSRContentItem };
@@ -1,277 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useState } from 'react';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import createReferencedImageDisplaySet from '../utils/createReferencedImageDisplaySet';
import { usePositionPresentationStore, OHIFCornerstoneViewport } from '@ohif/extension-cornerstone';
import { useViewportGrid } from '@ohif/ui-next';
import { useSystem } from '@ohif/core/src/contextProviders/SystemProvider';
const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup';
function OHIFCornerstoneSRMeasurementViewport(props) {
const { servicesManager } = useSystem();
const { children, dataSource, displaySets, viewportOptions } = props as {
children: React.ReactNode;
dataSource: unknown;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportOptions;
};
const { displaySetService } = servicesManager.services;
const viewportId = viewportOptions.viewportId;
// SR viewport will always have a single display set
if (displaySets.length > 1) {
throw new Error('SR viewport should only have a single display set');
}
const srDisplaySet = displaySets[0];
const { setPositionPresentation } = usePositionPresentationStore();
const [viewportGrid, viewportGridService] = useViewportGrid();
const [measurementSelected, setMeasurementSelected] = useState(0);
const [activeImageDisplaySetData, setActiveImageDisplaySetData] = useState(null);
const [referencedDisplaySetMetadata, setReferencedDisplaySetMetadata] = useState(null);
const [element, setElement] = useState(null);
const { viewports, activeViewportId } = viewportGrid;
const setTrackingIdentifiers = useCallback(
measurementSelected => {
const { measurements } = srDisplaySet;
setTrackingUniqueIdentifiersForElement(
element,
measurements.map(measurement => measurement.TrackingUniqueIdentifier),
measurementSelected
);
},
[element, measurementSelected, srDisplaySet]
);
/**
* OnElementEnabled callback which is called after the cornerstoneExtension
* has enabled the element. Note: we delegate all the image rendering to
* cornerstoneExtension, so we don't need to do anything here regarding
* the image rendering, element enabling etc.
*/
const onElementEnabled = evt => {
setElement(evt.detail.element);
};
const updateViewport = useCallback(
newMeasurementSelected => {
const { StudyInstanceUID, displaySetInstanceUID } = srDisplaySet;
if (!StudyInstanceUID || !displaySetInstanceUID) {
return;
}
_getViewportReferencedDisplaySetData(
srDisplaySet,
newMeasurementSelected,
displaySetService
).then(({ referencedDisplaySet, referencedDisplaySetMetadata }) => {
if (!referencedDisplaySet || !referencedDisplaySetMetadata) {
return;
}
setMeasurementSelected(newMeasurementSelected);
setActiveImageDisplaySetData(referencedDisplaySet);
setReferencedDisplaySetMetadata(referencedDisplaySetMetadata);
const { presentationIds } = viewportOptions;
const measurement = srDisplaySet.measurements[newMeasurementSelected];
setPositionPresentation(presentationIds.positionPresentationId, {
viewReference: measurement.viewReference || {
referencedImageId: measurement.imageId,
},
});
});
},
[dataSource, srDisplaySet, activeImageDisplaySetData, viewportId]
);
const getCornerstoneViewport = useCallback(() => {
if (!activeImageDisplaySetData) {
return null;
}
const { measurements } = srDisplaySet;
const measurement = measurements[measurementSelected];
if (!measurement) {
return null;
}
return (
<OHIFCornerstoneViewport
{...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={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
isJumpToMeasurementDisabled={true}
/>
);
}, [activeImageDisplaySetData, viewportId, measurementSelected]);
/**
Cleanup the SR viewport when the viewport is destroyed
*/
useEffect(() => {
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports.get(activeViewportId);
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
viewportGridService.setDisplaySetsForViewport({
viewportId: activeViewportId,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, []);
/**
* Loading the measurements from the SR viewport, which goes through the
* isHydratable check, the outcome for the isHydrated state here is always FALSE
* since we don't do the hydration here. Todo: can't we just set it as false? why
* we are changing the state here? isHydrated is always false at this stage, and
* if it is hydrated we don't even use the SR viewport.
*/
useEffect(() => {
const loadSR = async () => {
if (!srDisplaySet.isLoaded) {
await srDisplaySet.load();
}
updateViewport(measurementSelected);
};
loadSR();
}, [srDisplaySet]);
/**
* Hook to update the tracking identifiers when the selected measurement changes or
* the element changes
*/
useEffect(() => {
const updateSR = async () => {
if (!srDisplaySet.isLoaded) {
await srDisplaySet.load();
}
if (!element || !srDisplaySet.isLoaded) {
return;
}
setTrackingIdentifiers(measurementSelected);
};
updateSR();
}, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
if (!activeImageDisplaySetData || !referencedDisplaySetMetadata) {
return null;
}
if (children && children.length) {
childrenWithProps = children.map((child, index) => {
return (
child &&
React.cloneElement(child, {
viewportId,
key: index,
})
);
});
}
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
{getCornerstoneViewport()}
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstoneSRMeasurementViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
viewportOptions: PropTypes.object,
};
async function _getViewportReferencedDisplaySetData(
displaySet,
measurementSelected,
displaySetService
) {
const { measurements } = displaySet;
const measurement = measurements[measurementSelected];
const { displaySetInstanceUID } = measurement;
if (!displaySet.keyImageDisplaySet) {
// Create a new display set, and preserve a reference to it here,
// so that it can be re-displayed and shown inside the SR viewport.
// This is only for ease of redisplay - the display set is stored in the
// usual manner in the display set service.
displaySet.keyImageDisplaySet = createReferencedImageDisplaySet(displaySetService, displaySet);
}
if (!displaySetInstanceUID) {
return { referencedDisplaySetMetadata: null, referencedDisplaySet: null };
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (!referencedDisplaySet?.images) {
return { referencedDisplaySetMetadata: null, referencedDisplaySet: null };
}
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
PatientID: image0.PatientID,
PatientName: image0.PatientName,
PatientSex: image0.PatientSex,
PatientAge: image0.PatientAge,
SliceThickness: image0.SliceThickness,
StudyDate: image0.StudyDate,
SeriesDescription: image0.SeriesDescription,
SeriesInstanceUID: image0.SeriesInstanceUID,
SeriesNumber: image0.SeriesNumber,
ManufacturerModelName: image0.ManufacturerModelName,
SpacingBetweenSlices: image0.SpacingBetweenSlices,
};
return { referencedDisplaySetMetadata, referencedDisplaySet };
}
export default OHIFCornerstoneSRMeasurementViewport;
@@ -1,32 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { ExtensionManager } from '@ohif/core';
import { OHIFCornerstoneSRContainer } from './OHIFCornerstoneSRContainer';
function OHIFCornerstoneSRTextViewport(props: withAppTypes) {
const { displaySets } = props;
const displaySet = displaySets[0];
const instance = displaySet.instances[0];
return (
<div className="text-foreground relative flex h-full w-full flex-col overflow-auto p-4">
<div>
{/* The root level is always a container */}
<OHIFCornerstoneSRContainer container={instance} />
</div>
</div>
);
}
OHIFCornerstoneSRTextViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
viewportOptions: PropTypes.object,
servicesManager: PropTypes.object.isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};
export default OHIFCornerstoneSRTextViewport;
@@ -1,30 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { ExtensionManager } from '@ohif/core';
import OHIFCornerstoneSRMeasurementViewport from './OHIFCornerstoneSRMeasurementViewport';
import OHIFCornerstoneSRTextViewport from './OHIFCornerstoneSRTextViewport';
function OHIFCornerstoneSRViewport(props: withAppTypes) {
const { displaySets } = props;
const { isImagingMeasurementReport } = displaySets[0];
if (isImagingMeasurementReport) {
return <OHIFCornerstoneSRMeasurementViewport {...props}></OHIFCornerstoneSRMeasurementViewport>;
}
return <OHIFCornerstoneSRTextViewport {...props}></OHIFCornerstoneSRTextViewport>;
}
OHIFCornerstoneSRViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
viewportOptions: PropTypes.object,
servicesManager: PropTypes.object.isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};
export default OHIFCornerstoneSRViewport;
@@ -0,0 +1,7 @@
export default {
POINT: 'POINT',
MULTIPOINT: 'MULTIPOINT',
POLYLINE: 'POLYLINE',
CIRCLE: 'CIRCLE',
ELLIPSE: 'ELLIPSE',
};
Loaded 100 of 3251 files, more files were not shown because too many files have changed in this diff. Show more