diff --git a/.agents/skills/metadata-migration.md b/.agents/skills/metadata-migration.md new file mode 100644 index 0000000000..abe189aea2 --- /dev/null +++ b/.agents/skills/metadata-migration.md @@ -0,0 +1,44 @@ +--- +name: metadata-migration +description: Migrate codebases from legacy CS3D metadata flows to the metadata branch model with required init/provider updates and recommended provider/cache/imageId changes. +--- + +# Metadata Migration Skill (`origin/main` -> `metadata`) + +Use this skill to migrate codebases from the legacy CS3D metadata flow in +`origin/main` to the `metadata` branch model. + +## Mandated changes + +1. Add the metadata module. +2. Re-add required providers after each CS3D init call. + +### Required checklist + +- Add and initialize the metadata module in bootstrap/startup. +- Ensure provider registration runs after CS3D init. +- Re-register all providers needed by your workflows after init. +- Confirm provider priority/ordering where providers overlap. + +## Recommended changes + +1. Use metadata module imports instead of legacy import paths. +2. Switch to new metadata providers and deprecate old providers. +3. Adopt the new caching model. +4. Adopt the `imageId` / `frameImageId` storage model. + +## Compatibility notes + +- Some providers are not yet available in both old and new schemes. +- Do not block migration on full parity. +- Keep temporary fallback providers only where required. +- Track and remove fallbacks as equivalent providers become available. + +## Suggested migration sequence + +1. Add metadata module and initialize it. +2. Move provider registration to post-init and re-add required providers. +3. Validate key metadata workflows and provider lookups. +4. Replace old imports with metadata module imports. +5. Migrate providers to new implementations where available. +6. Incrementally adopt new cache and `imageId`/`frameImageId` storage. diff --git a/.circleci/config.yml b/.circleci/config.yml index 5ebd421589..a301f77dba 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -103,6 +103,7 @@ jobs: - persist_to_workspace: root: ~/repo paths: + - packages/metadata/dist - packages/core/dist - packages/tools/dist - packages/adapters/dist diff --git a/.github/workflows/ohif-downstream.yml b/.github/workflows/ohif-downstream.yml index 2e6557ec9b..bfc048d176 100644 --- a/.github/workflows/ohif-downstream.yml +++ b/.github/workflows/ohif-downstream.yml @@ -28,8 +28,9 @@ jobs: runs-on: self-hosted timeout-minutes: 120 env: - BUN_VERSION: 1.3.11 + BUN_VERSION: 1.3.13 NODE_VERSION: 24 + OHIF_REF: master OHIF_DIR: ohif # Update to force a rebuild of the OHIF integration BUILD_INDEX: 0 @@ -73,7 +74,7 @@ jobs: - name: Install Cornerstone dependencies run: bun install --frozen-lockfile - - name: Build local Cornerstone packages for OHIF + - name: Build local Cornerstone packages for OHIF (includes metadata) run: bun run build:esm - name: Checkout OHIF @@ -116,7 +117,7 @@ jobs: working-directory: ${{ env.OHIF_DIR }} env: PLAYWRIGHT_HTML_OPEN: never - run: bun run test:e2e:ci + run: bun run test:e2e:ci -- --max-failures=10 --retries=1 - name: Upload OHIF Playwright artifacts if: always() diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 48637afd49..3d4042d8dc 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -9,7 +9,7 @@ concurrency: jobs: playwright-tests: - timeout-minutes: 60 + timeout-minutes: 120 runs-on: self-hosted strategy: fail-fast: false @@ -28,9 +28,22 @@ jobs: # - name: Install Playwright browsers # run: bun x playwright install-deps chromium - name: Run Playwright tests + env: + CI: true + PLAYWRIGHT_HTML_OPEN: never run: | export NODE_OPTIONS="--max_old_space_size=10192" - bun run test:e2e:coverage + bun x nyc --reporter=html npx playwright test --max-failures=10 --retries=1 + - name: Upload Playwright report and test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-results-${{ github.sha }} + if-no-files-found: warn + path: | + tests/playwright-report/ + tests/test-results/ + retention-days: 7 - name: create the coverage report run: | bun nyc report --reporter=lcov --reporter=text diff --git a/CHANGELOG.md b/CHANGELOG.md index 8758aa39f0..a355753a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,32 +15,16 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **security:** Update various dependencies to fix security vulnerabilities. ([#2736](https://github.com/cornerstonejs/cornerstone3D/issues/2736)) ([f8385af](https://github.com/cornerstonejs/cornerstone3D/commit/f8385afe4116f8e824f2480b7a5c0c032c1e5da0)) -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) ### Bug Fixes - **annotations:** incorrect area calculation for livewire, spline, rectangle, and planar freehand ROI tools ([#2734](https://github.com/cornerstonejs/cornerstone3D/issues/2734)) ([c8a96e9](https://github.com/cornerstonejs/cornerstone3D/commit/c8a96e9a025a51e70f0c20f217827fdd036aa4c5)) - -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -### Bug Fixes - - **segmentation:** fully remove segmentations from viewport on delete after reload ([#2729](https://github.com/cornerstonejs/cornerstone3D/issues/2729)) ([0e186bd](https://github.com/cornerstonejs/cornerstone3D/commit/0e186bd7a804d706df1bb9ee263378e09726b087)) - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package root - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) - -**Note:** Version bump only for package root - -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -### Bug Fixes - - **security:** Patch protobufjs for CVE-2026-41242 ([#2712](https://github.com/cornerstonejs/cornerstone3D/issues/2712)) ([7027290](https://github.com/cornerstonejs/cornerstone3D/commit/7027290d168d91829f608a6c2f3c39c995e28c31)) +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) + ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) ### Bug Fixes diff --git a/README.md b/README.md index 88c1ae1513..f8755ba138 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # [Cornerstone.js](https://cornerstonejs.org/) · ![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg) -Cornerstone is a set of JavaScript libraries that can be used to build web-based medical imaging applications. It provides a framework to build radiology applications such as the [OHIF Viewer](https://ohif.org/). +Cornerstone is a set of JavaScript libraries that can be used to build web-based medical imaging applications. It provides a framework to build radiology applications such as the [OHIF Viewer](https://ohif.org/) - **Fast:** Cornerstone leverages WebGL to provide high-performance image rendering and WebAssembly for fast image decompression. - **Flexible:** Cornerstone provides APIs for defining custom image, volume, and metadata loading schemes, allowing developers to easily connect with proprietary image archives. - **Community Driven:** Cornerstone is supported by the [Open Health Imaging Foundation](https://ohif.org/). We publish our roadmap and welcome contributions and collaboration. -- **Standards Compliant:** Cornerstone's core focus is Radiology, so it provides DICOMweb compatibility out-of-the-box. +- **Standards Compliant:** Cornerstone's core focus is Radiology, so it provides DICOMweb compatibility out-of-the-box [Learn how to use Cornerstone3D in your project](https://www.cornerstonejs.org/docs/getting-started/overview). @@ -108,6 +108,7 @@ following packages are linked: | `@cornerstonejs/core` | `packages/core` | | `@cornerstonejs/dicom-image-loader` | `packages/dicomImageLoader` | | `@cornerstonejs/labelmap-interpolation` | `packages/labelmap-interpolation` | +| `@cornerstonejs/metadata` | `packages/metadata` | | `@cornerstonejs/nifti-volume-loader` | `packages/nifti-volume-loader` | | `@cornerstonejs/polymorphic-segmentation` | `packages/polymorphic-segmentation` | | `@cornerstonejs/tools` | `packages/tools` | @@ -154,6 +155,10 @@ node scripts/unlink-ohif-cornerstone-node-modules.mjs /path/to/ohif > package rather than the full set), see the > [Linking Cornerstone Libraries](packages/docs/docs/contribute/linking.md) doc. +## Troubleshooting + +If unit tests fail with **\"Cannot find module '../build/Release/canvas.node'\"**, the native `canvas` addon wasn’t built. Run `yarn rebuild:canvas` (or `npm run rebuild:canvas`); if that doesn’t fix it, see [docs/troubleshooting.md](docs/troubleshooting.md#unit-tests-cannot-find-module-buildreleasecanvasnode). + ## Support Users can post questions and issues on the [Open Health Imaging Foundation (OHIF) Community Forum](https://community.ohif.org/). Developer issues or bugs can be reported as [Github Issues](https://github.com/cornerstonejs/cornerstone3D/issues). diff --git a/bun.lock b/bun.lock index 44c83871ec..5a705ce709 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "root", @@ -9,7 +10,9 @@ "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-object-rest-spread": "7.20.7", "@babel/plugin-transform-class-static-block": "7.26.0", + "@babel/plugin-transform-private-methods": "7.28.6", "@babel/plugin-transform-runtime": "7.26.10", + "@babel/plugin-transform-typescript": "7.28.6", "@babel/preset-env": "7.29.5", "@babel/preset-react": "7.26.3", "@babel/preset-typescript": "7.26.0", @@ -119,30 +122,30 @@ }, "packages/adapters": { "name": "@cornerstonejs/adapters", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { "@babel/runtime-corejs2": "7.26.10", "buffer": "6.0.3", - "dcmjs": "0.49.4", + "dcmjs": "0.50.1", "gl-matrix": "3.4.3", "ndarray": "1.0.19", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0", }, }, "packages/ai": { "name": "@cornerstonejs/ai", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { "@babel/runtime-corejs2": "7.26.10", "buffer": "6.0.3", - "dcmjs": "0.49.4", + "dcmjs": "0.50.1", "gl-matrix": "3.4.3", "lodash.clonedeep": "4.5.0", "ndarray": "1.0.19", @@ -150,27 +153,35 @@ "onnxruntime-web": "1.17.1", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0", }, }, "packages/core": { "name": "@cornerstonejs/core", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { + "@cornerstonejs/utils": "5.0.0-beta.2", "@kitware/vtk.js": "34.15.1", "comlink": "4.4.2", "gl-matrix": "3.4.3", "loglevel": "1.9.2", }, + "devDependencies": { + "@cornerstonejs/metadata": "5.0.0-beta.2", + }, + "peerDependencies": { + "@cornerstonejs/metadata": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/utils": ">=5.0.0-beta.1 <6.0.0-0", + }, }, "packages/dicomImageLoader": { "name": "@cornerstonejs/dicom-image-loader", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { "@cornerstonejs/codec-charls": "1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", @@ -183,73 +194,85 @@ "uuid": "9.0.1", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/metadata": "5.0.0-beta.2", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/metadata": ">=5.0.0-beta.1 <6.0.0-0", "dicom-parser": "1.8.21", }, }, "packages/labelmap-interpolation": { "name": "@cornerstonejs/labelmap-interpolation", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { "@itk-wasm/morphological-contour-interpolation": "1.1.0", "itk-wasm": "1.0.0-b.165", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0", "@kitware/vtk.js": "34.15.1", }, }, + "packages/metadata": { + "name": "@cornerstonejs/metadata", + "version": "5.0.0-beta.2", + "dependencies": { + "@cornerstonejs/calculate-suv": "1.0.3", + "@cornerstonejs/utils": "5.0.0-beta.2", + "dcmjs": "0.50.1", + "gl-matrix": "3.4.3", + }, + }, "packages/nifti-volume-loader": { "name": "@cornerstonejs/nifti-volume-loader", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { "nifti-reader-js": "0.6.9", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "5.0.0-beta.2", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", }, }, "packages/polymorphic-segmentation": { "name": "@cornerstonejs/polymorphic-segmentation", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { "@icr/polyseg-wasm": "0.4.0", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0", "@kitware/vtk.js": "34.15.1", }, }, "packages/tools": { "name": "@cornerstonejs/tools", - "version": "4.22.7", + "version": "5.0.0-beta.2", "dependencies": { "@types/offscreencanvas": "2019.7.3", "comlink": "4.4.2", "lodash.get": "4.4.2", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "5.0.0-beta.2", "canvas": "3.2.0", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", "@kitware/vtk.js": "34.15.1", "@types/d3-array": "3.2.1", "@types/d3-interpolate": "3.0.4", @@ -258,6 +281,13 @@ "gl-matrix": "3.4.3", }, }, + "packages/utils": { + "name": "@cornerstonejs/utils", + "version": "5.0.0-beta.2", + "dependencies": { + "dcmjs": "0.50.1", + }, + }, }, "overrides": { "@babel/helper-create-class-features-plugin": "7.28.3", @@ -266,7 +296,6 @@ "buffer": "6.0.3", "caniuse-lite": "1.0.30001767", "canvas": "3.2.0", - "dcmjs": "0.49.4", "fast-uri": "3.1.2", "flatted": "3.4.2", "handlebars": "4.7.9", @@ -391,7 +420,7 @@ "@babel/plugin-syntax-top-level-await": ["@babel/plugin-syntax-top-level-await@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw=="], - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ=="], + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], @@ -499,7 +528,7 @@ "@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw=="], - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.26.8", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw=="], + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], "@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg=="], @@ -553,12 +582,16 @@ "@cornerstonejs/labelmap-interpolation": ["@cornerstonejs/labelmap-interpolation@workspace:packages/labelmap-interpolation"], + "@cornerstonejs/metadata": ["@cornerstonejs/metadata@workspace:packages/metadata"], + "@cornerstonejs/nifti-volume-loader": ["@cornerstonejs/nifti-volume-loader@workspace:packages/nifti-volume-loader"], "@cornerstonejs/polymorphic-segmentation": ["@cornerstonejs/polymorphic-segmentation@workspace:packages/polymorphic-segmentation"], "@cornerstonejs/tools": ["@cornerstonejs/tools@workspace:packages/tools"], + "@cornerstonejs/utils": ["@cornerstonejs/utils@workspace:packages/utils"], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@csstools/cascade-layer-name-parser": ["@csstools/cascade-layer-name-parser@1.0.13", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^2.7.1", "@csstools/css-tokenizer": "^2.4.1" } }, "sha512-MX0yLTwtZzr82sQ0zOjqimpZbzjMaK/h2pmlrLK7DCzlmiZLYFpoO94WmN1akRVo6ll/TdpHb53vihHLUMyvng=="], @@ -963,7 +996,7 @@ "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="], + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], @@ -1653,7 +1686,7 @@ "dateformat": ["dateformat@3.0.3", "", {}, "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q=="], - "dcmjs": ["dcmjs@0.49.4", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-w77Gde5JvLjg37FyGIAsTB+oWZIqkO/5NvBeheEVKy6k9XjBgeGsoAbVAByjuGAFLpGqRbf9iWI0edBq87yu/g=="], + "dcmjs": ["dcmjs@0.50.1", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-858uKIFD8plzv0lPcvZceA/ZGH/wzZf36wFkQToie3pSWp8YRI88dysQYQI1DJxc6AyGPk6eyf6RwKJbhoiQUw=="], "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], @@ -3527,6 +3560,8 @@ "@babel/plugin-syntax-import-attributes/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/plugin-syntax-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/plugin-transform-arrow-functions/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/plugin-transform-async-generator-functions/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], @@ -3665,9 +3700,7 @@ "@babel/plugin-transform-typeof-symbol/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/plugin-transform-typescript/@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.25.9", "", { "dependencies": { "@babel/types": "^7.25.9" } }, "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g=="], - - "@babel/plugin-transform-typescript/@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA=="], + "@babel/plugin-transform-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/plugin-transform-unicode-escapes/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], @@ -3707,6 +3740,8 @@ "@babel/preset-typescript/@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.26.3", "", { "dependencies": { "@babel/helper-module-transforms": "^7.26.0", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ=="], + "@babel/preset-typescript/@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.26.8", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw=="], + "@babel/runtime-corejs2/core-js": ["core-js@2.6.12", "", {}, "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -4045,6 +4080,8 @@ "dicom-microscopy-viewer/@cornerstonejs/codec-openjpeg": ["@cornerstonejs/codec-openjpeg@1.2.4", "", {}, "sha512-UT2su6xZZnCPSuWf2ldzKa/2+guQ7BGgfBSKqxanggwJHh48gZqIAzekmsLyJHMMK5YDK+ti+fzvVJhBS3Xi/g=="], + "dicom-microscopy-viewer/dcmjs": ["dcmjs@0.41.0", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-kr46REomItFeWz+0ck4Wif4uS5VVDWVlwdh5GGaCtTYHWfNQmrcCSiQOkrShc7Dc5zP8vNKrHEdORlZXenlg3w=="], + "dicom-microscopy-viewer/dicomweb-client": ["dicomweb-client@0.10.4", "", {}, "sha512-TEt26c0JI37IGmSqoj1k1/Y/ZIyq33/ysVaUwE0/Haosn2IBM55NEIPkT+AnhFss2nFAMVtKKWKWLox4luthVw=="], "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], @@ -4245,8 +4282,6 @@ "jest-snapshot/@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], - "jest-snapshot/@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - "jest-snapshot/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "jest-snapshot/@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="], @@ -4881,6 +4916,12 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], + "@babel/preset-typescript/@babel/plugin-transform-typescript/@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.25.9", "", { "dependencies": { "@babel/types": "^7.25.9" } }, "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g=="], + + "@babel/preset-typescript/@babel/plugin-transform-typescript/@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA=="], + + "@babel/preset-typescript/@babel/plugin-transform-typescript/@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@itk-wasm/morphological-contour-interpolation/itk-wasm/chalk": ["chalk@5.3.0", "", {}, "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="], @@ -5335,8 +5376,6 @@ "jest-snapshot/@babel/plugin-syntax-jsx/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "jest-snapshot/@babel/plugin-syntax-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "jest-snapshot/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "jest-snapshot/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], diff --git a/commit.txt b/commit.txt index b0e7426859..b9c1bc87fe 100644 --- a/commit.txt +++ b/commit.txt @@ -1 +1 @@ -c0e7efc0619d584e8426cd33517cb89ba3597eec \ No newline at end of file +ccb453f7d6ececc37ac6f5c025565063b91eb86e diff --git a/jest.config.base.js b/jest.config.base.js index cac2667b25..fddefa37dd 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -3,6 +3,8 @@ // '' warning: // Strings should avoid referencing the node_modules directory (prefer require.resolve) +const path = require('path'); + module.exports = { // roots: ['/src'], testMatch: ['/test/**/*.jest.js'], @@ -11,6 +13,8 @@ module.exports = { moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], transformIgnorePatterns: ['/node_modules/(?!@kitware/.*)'], moduleNameMapper: { + // Resolve from node_modules so workspace alias does not map this package + '^@cornerstonejs/calculate-suv$': path.resolve(__dirname, 'node_modules/@cornerstonejs/calculate-suv'), '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/src/__mocks__/fileMock.js', '\\.(css|less)$': 'identity-obj-proxy', diff --git a/karma.conf.js b/karma.conf.js index e89e948a06..2830bfaded 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -183,6 +183,8 @@ module.exports = function (config) { '@cornerstonejs/core': path.resolve('packages/core/src/index'), '@cornerstonejs/tools': path.resolve('packages/tools/src/index'), '@cornerstonejs/dicom-image-loader': path.resolve('packages/dicomImageLoader/src/index'), + '@cornerstonejs/metadata': path.resolve('packages/metadata/src/index'), + '@cornerstonejs/utils': path.resolve('packages/utils/src/index'), }, }, }, diff --git a/lerna.json b/lerna.json index 7b4c21d6a5..5b8c381ffa 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,8 @@ { - "version": "4.22.9", + "version": "5.0.0-beta.2", "packages": [ + "packages/metadata", + "packages/utils", "packages/core", "packages/tools", "packages/adapters", diff --git a/package.json b/package.json index 7cb5f6ca45..f95b01f54e 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "description": "Cornerstone.js Monorepo Root", "private": true, "workspaces": [ + "packages/metadata", + "packages/utils", "packages/adapters", "packages/core", "packages/dicomImageLoader", @@ -61,6 +63,7 @@ "test": "karma start", "test:unit": "jest --collectCoverage", "test:unit:no-coverage": "jest", + "rebuild:canvas": "npm rebuild canvas", "test:debug": "karma start ./karma.conf.js --browsers Chrome --no-single-run", "lint-staged": "lint-staged", "lint": "oxlint packages/**/src --quiet", @@ -72,7 +75,9 @@ "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-object-rest-spread": "7.20.7", "@babel/plugin-transform-class-static-block": "7.26.0", + "@babel/plugin-transform-private-methods": "7.28.6", "@babel/plugin-transform-runtime": "7.26.10", + "@babel/plugin-transform-typescript": "7.28.6", "@babel/preset-env": "7.29.5", "@babel/preset-react": "7.26.3", "@babel/preset-typescript": "7.26.0", @@ -192,7 +197,6 @@ "buffer": "6.0.3", "node-forge": "1.4.0", "canvas": "3.2.0", - "dcmjs": "0.49.4", "tar-fs": "2.1.4", "body-parser": "1.20.3", "ws": "8.18.3", diff --git a/packages/adapters/CHANGELOG.md b/packages/adapters/CHANGELOG.md index bdcbe139f6..237099b055 100644 --- a/packages/adapters/CHANGELOG.md +++ b/packages/adapters/CHANGELOG.md @@ -1,4 +1,4 @@ -# Change Log +# Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. @@ -11,25 +11,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/adapters -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) **Note:** Version bump only for package @cornerstonejs/adapters -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/adapters - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/adapters - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) - -**Note:** Version bump only for package @cornerstonejs/adapters - -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/adapters +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) @@ -3479,16 +3465,16 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Bug Fixes -- 🐛 adding readme notes ([#191](https://github.com/dcmjs-org/dcmjs/issues/191)) ([459260d](https://github.com/dcmjs-org/dcmjs/commit/459260d6e2a6f905729ddf68b1f12d3140b53849)) -- 🐛 fix array format regression from commit 70b24332783d63c9db2ed21d512d9f7b526c5222 ([#236](https://github.com/dcmjs-org/dcmjs/issues/236)) ([5441063](https://github.com/dcmjs-org/dcmjs/commit/5441063d0395ede6d9f8bd3ac0d92ee14f6ef209)) -- 🐛 Fix rotation mapping for SEG cornerstone adapter ([#151](https://github.com/dcmjs-org/dcmjs/issues/151)) ([3fab68c](https://github.com/dcmjs-org/dcmjs/commit/3fab68cbfd95f82820663b9fc99a2b0cd07e43c8)) -- 🐛 Harden Segmentation import for different possible SEGs ([#146](https://github.com/dcmjs-org/dcmjs/issues/146)) ([c4952bc](https://github.com/dcmjs-org/dcmjs/commit/c4952bc5842bab80a5d928de0d860f89afc8f400)) -- 🐛 IDC Re [#2003](https://github.com/dcmjs-org/dcmjs/issues/2003): fix regression in parsing segmentation orietations ([#220](https://github.com/dcmjs-org/dcmjs/issues/220)) ([5c0c6a8](https://github.com/dcmjs-org/dcmjs/commit/5c0c6a85e67b25ce5f39412ced37d8e825691481)) -- 🐛 IDC2733: find segmentations reference source image Ids ([#253](https://github.com/dcmjs-org/dcmjs/issues/253)) ([f3e7101](https://github.com/dcmjs-org/dcmjs/commit/f3e71016dffa233bf0fb912cc7cf413718b8a1a9)) -- 🐛 ignore frames without SourceImageSequence information when loading a segmentation ([#198](https://github.com/dcmjs-org/dcmjs/issues/198)) ([82709c4](https://github.com/dcmjs-org/dcmjs/commit/82709c4a8a317aa1354244010300ab9b902802dd)) -- 🐛 indentation in nearlyEqual ([#202](https://github.com/dcmjs-org/dcmjs/issues/202)) ([989d6c9](https://github.com/dcmjs-org/dcmjs/commit/989d6c9a80686425563c55424ac1795e6a06cd7b)) -- 🐛 relax condition in nearlyEquals check for detecting numbers near to zero ([#304](https://github.com/dcmjs-org/dcmjs/issues/304)) ([974cddd](https://github.com/dcmjs-org/dcmjs/commit/974cddd785c076f1ac0211b534a7c0b82a4ba68a)) -- 🐛 When converting to multiframe, fix IPP issues ([#152](https://github.com/dcmjs-org/dcmjs/issues/152)) ([80496e4](https://github.com/dcmjs-org/dcmjs/commit/80496e422152c1a3dfd850a145011dd3dc632964)) +- 🐛 adding readme notes ([#191](https://github.com/dcmjs-org/dcmjs/issues/191)) ([459260d](https://github.com/dcmjs-org/dcmjs/commit/459260d6e2a6f905729ddf68b1f12d3140b53849)) +- 🐛 fix array format regression from commit 70b24332783d63c9db2ed21d512d9f7b526c5222 ([#236](https://github.com/dcmjs-org/dcmjs/issues/236)) ([5441063](https://github.com/dcmjs-org/dcmjs/commit/5441063d0395ede6d9f8bd3ac0d92ee14f6ef209)) +- 🐛 Fix rotation mapping for SEG cornerstone adapter ([#151](https://github.com/dcmjs-org/dcmjs/issues/151)) ([3fab68c](https://github.com/dcmjs-org/dcmjs/commit/3fab68cbfd95f82820663b9fc99a2b0cd07e43c8)) +- 🐛 Harden Segmentation import for different possible SEGs ([#146](https://github.com/dcmjs-org/dcmjs/issues/146)) ([c4952bc](https://github.com/dcmjs-org/dcmjs/commit/c4952bc5842bab80a5d928de0d860f89afc8f400)) +- 🐛 IDC Re [#2003](https://github.com/dcmjs-org/dcmjs/issues/2003): fix regression in parsing segmentation orietations ([#220](https://github.com/dcmjs-org/dcmjs/issues/220)) ([5c0c6a8](https://github.com/dcmjs-org/dcmjs/commit/5c0c6a85e67b25ce5f39412ced37d8e825691481)) +- 🐛 IDC2733: find segmentations reference source image Ids ([#253](https://github.com/dcmjs-org/dcmjs/issues/253)) ([f3e7101](https://github.com/dcmjs-org/dcmjs/commit/f3e71016dffa233bf0fb912cc7cf413718b8a1a9)) +- 🐛 ignore frames without SourceImageSequence information when loading a segmentation ([#198](https://github.com/dcmjs-org/dcmjs/issues/198)) ([82709c4](https://github.com/dcmjs-org/dcmjs/commit/82709c4a8a317aa1354244010300ab9b902802dd)) +- 🐛 indentation in nearlyEqual ([#202](https://github.com/dcmjs-org/dcmjs/issues/202)) ([989d6c9](https://github.com/dcmjs-org/dcmjs/commit/989d6c9a80686425563c55424ac1795e6a06cd7b)) +- 🐛 relax condition in nearlyEquals check for detecting numbers near to zero ([#304](https://github.com/dcmjs-org/dcmjs/issues/304)) ([974cddd](https://github.com/dcmjs-org/dcmjs/commit/974cddd785c076f1ac0211b534a7c0b82a4ba68a)) +- 🐛 When converting to multiframe, fix IPP issues ([#152](https://github.com/dcmjs-org/dcmjs/issues/152)) ([80496e4](https://github.com/dcmjs-org/dcmjs/commit/80496e422152c1a3dfd850a145011dd3dc632964)) - **adapter:** Removed comment around getTID300RepresentationArguments 'tool' parameter ([#322](https://github.com/dcmjs-org/dcmjs/issues/322)) ([d8f05ff](https://github.com/dcmjs-org/dcmjs/commit/d8f05ffb9ef1b5cce254980a597b4c428ffdfb6e)), closes [#306](https://github.com/dcmjs-org/dcmjs/issues/306) - **adapters:** Measurement reports can throw exceptions that prevent loading ([#458](https://github.com/dcmjs-org/dcmjs/issues/458)) ([7bc7d8a](https://github.com/dcmjs-org/dcmjs/commit/7bc7d8aaeb5aa0df91c24e278665d14f590ec234)) - **adapters:** Update rollup to newer version ([#407](https://github.com/dcmjs-org/dcmjs/issues/407)) ([543675f](https://github.com/dcmjs-org/dcmjs/commit/543675f1269f8b739764291b1c27b40470c48c63)) @@ -3664,16 +3650,16 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Bug Fixes -- 🐛 adding readme notes ([#191](https://github.com/dcmjs-org/dcmjs/issues/191)) ([459260d](https://github.com/dcmjs-org/dcmjs/commit/459260d6e2a6f905729ddf68b1f12d3140b53849)) -- 🐛 fix array format regression from commit 70b24332783d63c9db2ed21d512d9f7b526c5222 ([#236](https://github.com/dcmjs-org/dcmjs/issues/236)) ([5441063](https://github.com/dcmjs-org/dcmjs/commit/5441063d0395ede6d9f8bd3ac0d92ee14f6ef209)) -- 🐛 Fix rotation mapping for SEG cornerstone adapter ([#151](https://github.com/dcmjs-org/dcmjs/issues/151)) ([3fab68c](https://github.com/dcmjs-org/dcmjs/commit/3fab68cbfd95f82820663b9fc99a2b0cd07e43c8)) -- 🐛 Harden Segmentation import for different possible SEGs ([#146](https://github.com/dcmjs-org/dcmjs/issues/146)) ([c4952bc](https://github.com/dcmjs-org/dcmjs/commit/c4952bc5842bab80a5d928de0d860f89afc8f400)) -- 🐛 IDC Re [#2003](https://github.com/dcmjs-org/dcmjs/issues/2003): fix regression in parsing segmentation orietations ([#220](https://github.com/dcmjs-org/dcmjs/issues/220)) ([5c0c6a8](https://github.com/dcmjs-org/dcmjs/commit/5c0c6a85e67b25ce5f39412ced37d8e825691481)) -- 🐛 IDC2733: find segmentations reference source image Ids ([#253](https://github.com/dcmjs-org/dcmjs/issues/253)) ([f3e7101](https://github.com/dcmjs-org/dcmjs/commit/f3e71016dffa233bf0fb912cc7cf413718b8a1a9)) -- 🐛 ignore frames without SourceImageSequence information when loading a segmentation ([#198](https://github.com/dcmjs-org/dcmjs/issues/198)) ([82709c4](https://github.com/dcmjs-org/dcmjs/commit/82709c4a8a317aa1354244010300ab9b902802dd)) -- 🐛 indentation in nearlyEqual ([#202](https://github.com/dcmjs-org/dcmjs/issues/202)) ([989d6c9](https://github.com/dcmjs-org/dcmjs/commit/989d6c9a80686425563c55424ac1795e6a06cd7b)) -- 🐛 relax condition in nearlyEquals check for detecting numbers near to zero ([#304](https://github.com/dcmjs-org/dcmjs/issues/304)) ([974cddd](https://github.com/dcmjs-org/dcmjs/commit/974cddd785c076f1ac0211b534a7c0b82a4ba68a)) -- 🐛 When converting to multiframe, fix IPP issues ([#152](https://github.com/dcmjs-org/dcmjs/issues/152)) ([80496e4](https://github.com/dcmjs-org/dcmjs/commit/80496e422152c1a3dfd850a145011dd3dc632964)) +- 🐛 adding readme notes ([#191](https://github.com/dcmjs-org/dcmjs/issues/191)) ([459260d](https://github.com/dcmjs-org/dcmjs/commit/459260d6e2a6f905729ddf68b1f12d3140b53849)) +- 🐛 fix array format regression from commit 70b24332783d63c9db2ed21d512d9f7b526c5222 ([#236](https://github.com/dcmjs-org/dcmjs/issues/236)) ([5441063](https://github.com/dcmjs-org/dcmjs/commit/5441063d0395ede6d9f8bd3ac0d92ee14f6ef209)) +- 🐛 Fix rotation mapping for SEG cornerstone adapter ([#151](https://github.com/dcmjs-org/dcmjs/issues/151)) ([3fab68c](https://github.com/dcmjs-org/dcmjs/commit/3fab68cbfd95f82820663b9fc99a2b0cd07e43c8)) +- 🐛 Harden Segmentation import for different possible SEGs ([#146](https://github.com/dcmjs-org/dcmjs/issues/146)) ([c4952bc](https://github.com/dcmjs-org/dcmjs/commit/c4952bc5842bab80a5d928de0d860f89afc8f400)) +- 🐛 IDC Re [#2003](https://github.com/dcmjs-org/dcmjs/issues/2003): fix regression in parsing segmentation orietations ([#220](https://github.com/dcmjs-org/dcmjs/issues/220)) ([5c0c6a8](https://github.com/dcmjs-org/dcmjs/commit/5c0c6a85e67b25ce5f39412ced37d8e825691481)) +- 🐛 IDC2733: find segmentations reference source image Ids ([#253](https://github.com/dcmjs-org/dcmjs/issues/253)) ([f3e7101](https://github.com/dcmjs-org/dcmjs/commit/f3e71016dffa233bf0fb912cc7cf413718b8a1a9)) +- 🐛 ignore frames without SourceImageSequence information when loading a segmentation ([#198](https://github.com/dcmjs-org/dcmjs/issues/198)) ([82709c4](https://github.com/dcmjs-org/dcmjs/commit/82709c4a8a317aa1354244010300ab9b902802dd)) +- 🐛 indentation in nearlyEqual ([#202](https://github.com/dcmjs-org/dcmjs/issues/202)) ([989d6c9](https://github.com/dcmjs-org/dcmjs/commit/989d6c9a80686425563c55424ac1795e6a06cd7b)) +- 🐛 relax condition in nearlyEquals check for detecting numbers near to zero ([#304](https://github.com/dcmjs-org/dcmjs/issues/304)) ([974cddd](https://github.com/dcmjs-org/dcmjs/commit/974cddd785c076f1ac0211b534a7c0b82a4ba68a)) +- 🐛 When converting to multiframe, fix IPP issues ([#152](https://github.com/dcmjs-org/dcmjs/issues/152)) ([80496e4](https://github.com/dcmjs-org/dcmjs/commit/80496e422152c1a3dfd850a145011dd3dc632964)) - **adapter:** Removed comment around getTID300RepresentationArguments 'tool' parameter ([#322](https://github.com/dcmjs-org/dcmjs/issues/322)) ([d8f05ff](https://github.com/dcmjs-org/dcmjs/commit/d8f05ffb9ef1b5cce254980a597b4c428ffdfb6e)), closes [#306](https://github.com/dcmjs-org/dcmjs/issues/306) - **add check for nullable numeric string vrs:** adds a check for nullable numeric strinv vrs ([#150](https://github.com/dcmjs-org/dcmjs/issues/150)) ([75046c4](https://github.com/dcmjs-org/dcmjs/commit/75046c4e1b2830dd3a32dc8d6938f6def71940a5)) - **anonymizer:** [FIX & TESTS] cleanTags : check if param is undefined. Add 3 test ([#308](https://github.com/dcmjs-org/dcmjs/issues/308)) ([44d23d6](https://github.com/dcmjs-org/dcmjs/commit/44d23d6a9e347fcf049053129d4d7323a9258b71)) diff --git a/packages/adapters/examples/segmentationVolume/index.ts b/packages/adapters/examples/segmentationVolume/index.ts index 1b0bd350a1..7c4e91f6cc 100644 --- a/packages/adapters/examples/segmentationVolume/index.ts +++ b/packages/adapters/examples/segmentationVolume/index.ts @@ -107,7 +107,7 @@ addInstruction( "After making changes, click 'Export SEG' to download the updated segmentation as a DICOM SEG file." ); addInstruction( - "You can also upload local DICOM images or SEG files by using the 'Import DICOM' and 'Import SEG' buttons." + "You can also load local DICOM images or SEG files from disk using the 'Import DICOM' and 'Import SEG' buttons." ); const state = { diff --git a/packages/adapters/jest.config.js b/packages/adapters/jest.config.js index e5e4695912..4a62658f4b 100644 --- a/packages/adapters/jest.config.js +++ b/packages/adapters/jest.config.js @@ -7,6 +7,7 @@ module.exports = { displayName: 'adapters', testMatch: ['/test/**/*.jest.js'], moduleNameMapper: { + ...base.moduleNameMapper, '^@cornerstonejs/(.*)$': path.resolve(__dirname, '../$1/src'), }, }; diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 8a09affc71..5f44755333 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/adapters", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "Adapters for Cornerstone3D to/from formats including DICOM SR and others", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", @@ -59,7 +59,7 @@ "scripts": { "test": "jest --testTimeout 60000", "build": "rollup -c rollup.config.mjs", - "build:esm": "rollup -c rollup.config.mjs", + "build:esm": "yarn run prebuild && rollup -c rollup.config.mjs", "build:esm:watch": "rollup --watch -c rollup.config.mjs", "clean": "rm -rf node_modules/.cache/storybook && shx rm -rf dist", "clean:deep": "yarn run clean && shx rm -rf node_modules", @@ -84,16 +84,16 @@ "dependencies": { "@babel/runtime-corejs2": "7.26.10", "buffer": "6.0.3", - "dcmjs": "0.49.4", + "dcmjs": "0.50.1", "gl-matrix": "3.4.3", "ndarray": "1.0.19" }, "devDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9" + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2" }, "peerDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9" + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0" } } diff --git a/packages/adapters/src/version.ts b/packages/adapters/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/adapters/src/version.ts +++ b/packages/adapters/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ed1bcbf145..bb952d91c9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,4 +1,4 @@ -# Change Log +# Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. @@ -11,25 +11,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/ai -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) **Note:** Version bump only for package @cornerstonejs/ai -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/ai - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/ai - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) - -**Note:** Version bump only for package @cornerstonejs/ai - -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/ai +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) diff --git a/packages/ai/package.json b/packages/ai/package.json index 5607bd80ab..f7a5e61f26 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/ai", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "AI and ML Interfaces for Cornerstone3D", "files": [ "dist" @@ -30,7 +30,7 @@ "clean": "rm -rf node_modules/.cache/storybook && shx rm -rf dist", "clean:deep": "yarn run clean && shx rm -rf node_modules", "build": "yarn run build:esm", - "build:esm": "tsc --project ./tsconfig.json", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", "build:esm:watch": "tsc --project ./tsconfig.json --watch", "dev": "tsc --project ./tsconfig.json --watch", "build:all": "yarn run build:esm", @@ -53,19 +53,19 @@ "dependencies": { "@babel/runtime-corejs2": "7.26.10", "buffer": "6.0.3", - "dcmjs": "0.49.4", + "dcmjs": "0.50.1", "gl-matrix": "3.4.3", "lodash.clonedeep": "4.5.0", "ndarray": "1.0.19", "onnxruntime-common": "1.17.1", "onnxruntime-web": "1.17.1" }, - "peerDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9" - }, "devDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9" + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2" + }, + "peerDependencies": { + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0" } } diff --git a/packages/ai/src/version.ts b/packages/ai/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/ai/src/version.ts +++ b/packages/ai/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 52f4ece69c..5c1f1b0bcf 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -11,25 +11,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/core -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) **Note:** Version bump only for package @cornerstonejs/core -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/core - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/core - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) - -**Note:** Version bump only for package @cornerstonejs/core - -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/core +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) diff --git a/packages/core/examples/dynamicVolume/index.ts b/packages/core/examples/dynamicVolume/index.ts index 6e170d3b91..46afe99da0 100644 --- a/packages/core/examples/dynamicVolume/index.ts +++ b/packages/core/examples/dynamicVolume/index.ts @@ -8,6 +8,7 @@ import { import { initDemo, createImageIdsAndCacheMetaData, + get4DVolumeImageIds, setTitleAndDescription, addSliderToToolbar, addDropdownToToolbar, @@ -82,11 +83,12 @@ async function run() { // Init Cornerstone and related libraries await initDemo(); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ + const seriesImageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '2.25.79767489559005369769092179787138169587', SeriesInstanceUID: '2.25.87977716979310885152986847054790859463', wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); + const imageIds = get4DVolumeImageIds(seriesImageIds); // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); diff --git a/packages/core/examples/ecg/index.ts b/packages/core/examples/ecg/index.ts index e3b9f0df6f..a748ff4dda 100644 --- a/packages/core/examples/ecg/index.ts +++ b/packages/core/examples/ecg/index.ts @@ -1,6 +1,7 @@ import type { Types } from '@cornerstonejs/core'; import { RenderingEngine, Enums } from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; +import { addDicomPart10Instance } from '@cornerstonejs/metadata/utilities/metadataProvider'; import { initDemo, setTitleAndDescription, @@ -52,9 +53,12 @@ element.oncontextmenu = (e) => e.preventDefault(); * Runs the demo */ async function run() { + // Use metadata package only: do not register legacy wadors/wadouri metaDataProvider. + // Instances are cached via addDicomWebInstance in createImageIdsAndCacheMetaData; + // ECG is provided by @cornerstonejs/metadata (ecgFromInstance provider). await initDemo(); - // Use the standard pipeline to fetch and cache DICOM metadata + // Use the standard pipeline to fetch and cache DICOM metadata (addDicomWebInstance) const imageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID, SeriesInstanceUID, @@ -185,6 +189,37 @@ async function run() { }, }); + // Local file — load a DICOM waveform from disk and display it (not a server upload) + addButtonToToolbar({ + title: 'Local file', + onClick: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.dcm,.dicom,application/dicom'; + input.onchange = async (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const arrayBuffer = await file.arrayBuffer(); + const imageId = `ecg:local:${file.name}`; + await addDicomPart10Instance(imageId, arrayBuffer); + await viewport.setEcg(imageId); + const { width: ecgWidth, height: ecgHeight } = + viewport.getContentDimensions(); + element.style.width = `${ecgWidth}px`; + element.style.height = `${ecgHeight}px`; + renderingEngine.resize(); + } catch (err) { + console.error(err); + alert( + err instanceof Error ? err.message : 'Failed to load ECG file.' + ); + } + }; + input.click(); + }, + }); + // Trace toggle checkboxes const channels = viewport.getVisibleChannels(); channels.forEach((channel, index) => { diff --git a/packages/core/examples/stackToVolumeFusion/index.ts b/packages/core/examples/stackToVolumeFusion/index.ts index f263e776f5..9c5ce4493e 100644 --- a/packages/core/examples/stackToVolumeFusion/index.ts +++ b/packages/core/examples/stackToVolumeFusion/index.ts @@ -9,7 +9,6 @@ import { initDemo, createImageIdsAndCacheMetaData, setTitleAndDescription, - ctVoiRange, addButtonToToolbar, setCtTransferFunctionForVolumeActor, setPetColorMapTransferFunctionForVolumeActor, @@ -40,8 +39,25 @@ const ptVolumeId = `${volumeLoaderScheme}:${ptVolumeName}`; // Segmentation ID const segmentationId = 'MY_SEGMENTATION_ID'; +// Parse URL flags for comparison: ?url=&useLegacyMetadataProvider=true +const urlParams = new URLSearchParams( + typeof window !== 'undefined' ? window.location.search : '' +); +const wadoRsRootFromUrl = urlParams.get('url') ?? urlParams.get('wadoRsRoot'); +const useLegacyMetadataProvider = + urlParams.get('useLegacyMetadataProvider') === 'true'; + +const defaultWadoRsRoot = 'https://d33do7qe4w26qo.cloudfront.net/dicomweb'; +const wadoRsRoot = wadoRsRootFromUrl ?? defaultWadoRsRoot; + // ======== Set up page ======== // -setTitleAndDescription('Stack Viewport First then Add Overlay Volume', ''); +const descParts: string[] = []; +if (useLegacyMetadataProvider) descParts.push('Legacy metadata provider'); +if (wadoRsRootFromUrl) descParts.push(`URL: ${wadoRsRoot}`); +setTitleAndDescription( + 'Stack Viewport First then Add Overlay Volume', + descParts.length ? descParts.join(' | ') : '' +); const content = document.getElementById('content'); const element = document.createElement('div'); @@ -55,36 +71,36 @@ let renderingEngine: RenderingEngine; let ctImageIds, ptImageIds; addButtonToToolbar({ title: 'Stack to Volume', - onClick: () => { + onClick: async () => { const viewPresentation = viewport.getViewPresentation(); const viewReference = viewport.getViewReference(); - convertStackToFusionVolume(); - viewport = renderingEngine.getViewport(viewportId) as Types.IVolumeViewport; + await convertStackToFusionVolume(); + viewport = renderingEngine.getViewport(viewportId); - // for some reason we need the setTimeout here since the viewReference requires - // image data - setTimeout(() => { - viewport.setViewReference(viewReference); - viewport.setViewPresentation(viewPresentation); - viewport.render(); - }, 100); + // viewport.render(); + + console.warn( + 'Setting view presentation and reference', + viewPresentation, + viewReference + ); + viewport.setViewPresentation(viewPresentation); + viewport.setViewReference(viewReference); + viewport.render(); }, }); addButtonToToolbar({ title: 'Stack to Volume with Labelmap', - onClick: () => { - const viewPresentation = viewport.getViewPresentation(); + onClick: async () => { const viewReference = viewport.getViewReference(); - convertStackToVolumeWithLabelmap(); - viewport = renderingEngine.getViewport(viewportId) as Types.IVolumeViewport; + await convertStackToVolumeWithLabelmap(); + viewport = renderingEngine.getViewport(viewportId); - setTimeout(() => { - viewport.setViewReference(viewReference); - viewport.render(); - }, 100); + viewport.setViewReference(viewReference); + viewport.render(); }, }); @@ -92,15 +108,15 @@ addButtonToToolbar({ * Runs the demo */ async function run() { - // Init Cornerstone and related libraries - await initDemo(); + // Init Cornerstone and related libraries (useLegacyMetadataProvider from URL for comparison) + await initDemo({ useLegacyMetadataProvider }); ptImageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', SeriesInstanceUID: '1.3.6.1.4.1.14519.5.2.1.7009.2403.879445243400782656317561081015', - wadoRsRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRsRoot, }); ctImageIds = await createImageIdsAndCacheMetaData({ @@ -108,7 +124,7 @@ async function run() { '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', SeriesInstanceUID: '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', - wadoRsRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRsRoot, }); // Instantiate a rendering engine @@ -127,10 +143,11 @@ async function run() { renderingEngine.enableElement(viewportInput); // Get the stack viewport that was created - viewport = renderingEngine.getViewport(viewportId) as Types.IStackViewport; + viewport = renderingEngine.getViewport(viewportId); + const stackViewport = viewport as Types.IStackViewport; // Set the stack on the viewport - await viewport.setStack(ctImageIds); + await stackViewport.setStack(ctImageIds); // Create and cache both volumes const ctVolume = await volumeLoader.createAndCacheVolume(ctVolumeId, { @@ -150,7 +167,7 @@ async function run() { volumeId: segmentationId, }); - // Render the image + // Render the image - note the offset/zoom is specifically set to to be re-rendered after the viewport is updated viewport.render(); viewport.setZoom(2.4); viewport.setPan([-100, -100]); @@ -159,7 +176,7 @@ async function run() { /** * Converts stack viewport to volume viewport with fusion */ -async function convertStackToFusionVolume() { +function convertStackToFusionVolume() { // Disable the current viewport const renderingEngine = getRenderingEngine(renderingEngineId); renderingEngine.disableElement(viewportId); @@ -178,10 +195,11 @@ async function convertStackToFusionVolume() { // Enable the volume viewport renderingEngine.enableElement(viewportInput); - viewport = renderingEngine.getViewport(viewportId) as Types.IVolumeViewport; + viewport = renderingEngine.getViewport(viewportId); + const volumeViewport = viewport as Types.IVolumeViewport; // Set both volumes on the viewport with fusion - viewport.setVolumes([ + return volumeViewport.setVolumes([ { volumeId: ctVolumeId, callback: setCtTransferFunctionForVolumeActor, @@ -230,8 +248,9 @@ async function convertStackToVolumeWithLabelmap() { }, ]); + const volumeViewport = viewport as Types.IVolumeViewport; // Set the volume on the viewport - viewport.setVolumes([ + await volumeViewport.setVolumes([ { volumeId: ctVolumeId, callback: setCtTransferFunctionForVolumeActor, diff --git a/packages/core/examples/video/index.ts b/packages/core/examples/video/index.ts index 9b459b3261..1e8611b6d7 100644 --- a/packages/core/examples/video/index.ts +++ b/packages/core/examples/video/index.ts @@ -10,6 +10,7 @@ import { addButtonToToolbar, createImageIdsAndCacheMetaData, getLocalUrl, + getVideoImageIdFromImageIds, } from '../../../../utils/demo/helpers'; // This is for debugging purposes @@ -117,10 +118,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find((it) => - it.includes('2.25.179478223177027022014772769075050874231') - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index be56fc7a06..452e6ec3f3 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -7,6 +7,7 @@ module.exports = { displayName: 'core', setupFiles: ['jest-canvas-mock'], moduleNameMapper: { + ...base.moduleNameMapper, '^@cornerstonejs/(.*)$': path.resolve(__dirname, '../$1/src'), }, }; diff --git a/packages/core/package.json b/packages/core/package.json index 372fe8ba91..45ea310c87 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/core", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "Cornerstone3D Core", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", @@ -69,7 +69,7 @@ }, "scripts": { "prebuild": "node ../../scripts/generate-version.js ./", - "build:esm": "tsc --project ./tsconfig.json", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", "build:esm:watch": "tsc --project ./tsconfig.json --watch", "clean": "rm -rf node_modules/.cache/storybook && shx rm -rf dist", "clean:deep": "yarn run clean && shx rm -rf node_modules", @@ -81,11 +81,19 @@ "prepublishOnly": "yarn run build" }, "dependencies": { + "@cornerstonejs/utils": "5.0.0-beta.2", "@kitware/vtk.js": "34.15.1", "comlink": "4.4.2", "gl-matrix": "3.4.3", "loglevel": "1.9.2" }, + "devDependencies": { + "@cornerstonejs/metadata": "5.0.0-beta.2" + }, + "peerDependencies": { + "@cornerstonejs/metadata": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/utils": ">=5.0.0-beta.1 <6.0.0-0" + }, "contributors": [ { "name": "Cornerstone.js Contributors", diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index 625afba5a0..335c38d807 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -2212,7 +2212,8 @@ class StackViewport extends Viewport { public getLoaderImageOptions(imageId: string) { const imageIdIndex = this.imageIds.indexOf(imageId); - const { transferSyntaxUID } = metaData.get('transferSyntax', imageId) || {}; + const { transferSyntaxUID } = + metaData.get(MetadataModules.TRANSFER_SYNTAX, imageId) || {}; const options = { useRGBA: false, diff --git a/packages/core/src/RenderingEngine/helpers/getCameraVectors.ts b/packages/core/src/RenderingEngine/helpers/getCameraVectors.ts index 166c49d351..8e51ae3d6f 100644 --- a/packages/core/src/RenderingEngine/helpers/getCameraVectors.ts +++ b/packages/core/src/RenderingEngine/helpers/getCameraVectors.ts @@ -262,7 +262,10 @@ export function getCameraVectors( if (!imageId) { return; } - const { imageOrientationPatient } = metaData.get('imagePlaneModule', imageId); + const { imageOrientationPatient } = metaData.get( + Enums.MetadataModules.IMAGE_PLANE, + imageId + ); const rowCosineVec = vec3.fromValues( imageOrientationPatient[0], imageOrientationPatient[1], diff --git a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts index 2c6cd864fa..68adbce4b6 100644 --- a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts +++ b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts @@ -7,7 +7,7 @@ import type { import { loadAndCacheImage } from '../../loaders/imageLoader'; import * as metaData from '../../metaData'; import * as windowLevel from '../../utilities/windowLevel'; -import { RequestType } from '../../enums'; +import { MetadataModules, RequestType } from '../../enums'; import cache from '../../cache/cache'; const PRIORITY = 0; @@ -53,7 +53,7 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { const imageId = imageIds[imageIdIndex]; const generalSeriesModule = - metaData.get('generalSeriesModule', imageId) || {}; + metaData.get(MetadataModules.GENERAL_SERIES, imageId) || {}; /** * If the volume is prescaled and the modality is PT Sometimes you get super high @@ -84,7 +84,7 @@ function getVOIFromMetadata(imageVolume: IImageVolume): VOIRange | undefined { if (imageIds?.length) { const imageIdIndex = Math.floor(imageIds.length / 2); const imageId = imageIds[imageIdIndex]; - const voiLutModule = metaData.get('voiLutModule', imageId); + const voiLutModule = metaData.get(MetadataModules.VOI_LUT, imageId); if (voiLutModule && voiLutModule.windowWidth && voiLutModule.windowCenter) { if (voiLutModule?.voiLUTFunction) { voi = {}; @@ -140,9 +140,10 @@ async function getVOIFromMiddleSliceMinMax( const imageIdIndex = Math.floor(imageIds.length / 2); const imageId = imageVolume.imageIds[imageIdIndex]; const generalSeriesModule = - metaData.get('generalSeriesModule', imageId) || {}; + metaData.get(MetadataModules.GENERAL_SERIES, imageId) || {}; const { modality } = generalSeriesModule; - const modalityLutModule = metaData.get('modalityLutModule', imageId) || {}; + const modalityLutModule = + metaData.get(MetadataModules.MODALITY_LUT, imageId) || {}; const scalingParameters: ScalingParameters = { rescaleSlope: modalityLutModule.rescaleSlope, @@ -152,7 +153,7 @@ async function getVOIFromMiddleSliceMinMax( let scalingParametersToUse; if (modality === 'PT') { - const suvFactor = metaData.get('scalingModule', imageId); + const suvFactor = metaData.get(MetadataModules.SCALING, imageId); if (suvFactor) { scalingParametersToUse = { diff --git a/packages/core/src/cache/classes/BaseStreamingImageVolume.ts b/packages/core/src/cache/classes/BaseStreamingImageVolume.ts index ea49925a7a..c305de8864 100644 --- a/packages/core/src/cache/classes/BaseStreamingImageVolume.ts +++ b/packages/core/src/cache/classes/BaseStreamingImageVolume.ts @@ -1,5 +1,10 @@ import * as metaData from '../../metaData'; -import { Events, ImageQualityStatus, RequestType } from '../../enums'; +import { + Events, + ImageQualityStatus, + MetadataModules, + RequestType, +} from '../../enums'; import eventTarget from '../../eventTarget'; import imageLoadPoolManager from '../../requestPool/imageLoadPoolManager'; import type { @@ -272,7 +277,7 @@ export class BaseStreamingImageVolume public load(callback: (...args: unknown[]) => void): void { const { imageIds, loadStatus, numFrames } = this; const { transferSyntaxUID } = - metaData.get('transferSyntax', imageIds[0]) || {}; + metaData.get(MetadataModules.TRANSFER_SYNTAX, imageIds[0]) || {}; const imageRetrieveConfiguration = metaData.get( imageRetrieveMetadataProvider.IMAGE_RETRIEVE_CONFIGURATION, this.volumeId, @@ -318,17 +323,18 @@ export class BaseStreamingImageVolume public getLoaderImageOptions(imageId: string) { const { transferSyntaxUID: transferSyntaxUID } = - metaData.get('transferSyntax', imageId) || {}; + metaData.get(MetadataModules.TRANSFER_SYNTAX, imageId) || {}; // Use the actual dimensions for this volume in order to support volumes not the same size as the raw data const targetRows = this.dimensions[1]; const targetCols = this.dimensions[0]; const imageIdIndex = this.getImageIdIndex(imageId); - const modalityLutModule = metaData.get('modalityLutModule', imageId) || {}; + const modalityLutModule = + metaData.get(MetadataModules.MODALITY_LUT, imageId) || {}; const generalSeriesModule = - metaData.get('generalSeriesModule', imageId) || {}; + metaData.get(MetadataModules.GENERAL_SERIES, imageId) || {}; const scalingParameters: ScalingParameters = { rescaleSlope: modalityLutModule.rescaleSlope, @@ -338,7 +344,7 @@ export class BaseStreamingImageVolume const modality = scalingParameters.modality; if (modality === 'PT' || modality === 'RTDOSE') { - const scalingFactor = metaData.get('scalingModule', imageId); + const scalingFactor = metaData.get(MetadataModules.SCALING, imageId); if (scalingFactor) { this._addScalingToVolume(scalingFactor); diff --git a/packages/core/src/enums/CalibrationTypes.ts b/packages/core/src/enums/CalibrationTypes.ts index ac77075006..a25b141230 100644 --- a/packages/core/src/enums/CalibrationTypes.ts +++ b/packages/core/src/enums/CalibrationTypes.ts @@ -1,60 +1,7 @@ /** - * Defines the calibration types available. These define how the units - * for measurements are specified. + * @deprecated Import from `@cornerstonejs/metadata` instead. */ -export enum CalibrationTypes { - /** - * Not applicable means the units are directly defind by the underlying - * hardware, such as CT and MR volumetric displays, so no special handling - * or notification is required. - */ - NOT_APPLICABLE = '', - /** - * ERMF is estimated radiographic magnification factor. This defines how - * much the image is magnified at the detector as opposed to the location in - * the body of interest. This occurs because the radiation beam is expanding - * and effectively magnifies the image on the detector compared to where the - * point of interest in the body is. - * This suggests that measurements can be partially trusted, but the user - * still needs to be aware that different depths within the body have differing - * ERMF values, so precise measurements would still need to be manually calibrated. - */ - ERMF = 'ERMF', - /** - * User calibration means that the user has provided a custom calibration - * specifying how large the image data is. This type can occur on - * volumetric images, eg for scout images that might have invalid spacing - * tags. - */ - USER = 'User', - /** - * A projection calibration means the raw detector size, without any - * ERMF applied, meaning that the size in the body cannot be trusted and - * that a calibration should be applied. - * This is different from Error in that there is simply no magnification - * factor applied as opposed to having multiple, inconsistent magnification - * factors. - */ - PROJECTION = 'Proj', - /** - * A region calibration is used for other types of images, typically - * ultrasouunds where the distance in the image may mean something other than - * physical distance, such as mV or Hz or some other measurement values. - */ - REGION = 'Region', - /** - * Error is used to define mismatches between various units, such as when - * there are two different ERMF values specified. This is an indication to - * NOT trust the measurement values but to manually calibrate. - */ - ERROR = 'Error', - /** Uncalibrated image */ - UNCALIBRATED = 'Uncalibrated', - /** When the calibration is present and can be accessed by the - * PixelSpacingCalibrationType or PixelSpacingCalibrationDescription tags*/ - CALIBRATED = 'Calibrated', - /** When it is unknown if the pixelSpacing is calibrated*/ - UNKNOWN = 'Unknown', -} +import { Enums } from '@cornerstonejs/metadata'; -export default CalibrationTypes; +export const CalibrationTypes = Enums.CalibrationTypes; +export default Enums.CalibrationTypes; diff --git a/packages/core/src/enums/MetadataModules.ts b/packages/core/src/enums/MetadataModules.ts index f3930cc78b..01e7fbe29f 100644 --- a/packages/core/src/enums/MetadataModules.ts +++ b/packages/core/src/enums/MetadataModules.ts @@ -1,137 +1,6 @@ /** - * Contains the names for the metadata modules. - * Recommendation is to add all module names here rather than having them - * just use string names. - * The naming convention is that the enum has the modules in it, so the - * enum key does not repeat the Modules, but the enum value does (to agree - * with existing naming conventions) + * @deprecated Import from `@cornerstonejs/metadata` instead. */ -enum MetadataModules { - CALIBRATION = 'calibrationModule', - CINE = 'cineModule', - GENERAL_IMAGE = 'generalImageModule', - GENERAL_SERIES = 'generalSeriesModule', - GENERAL_STUDY = 'generalStudyModule', - IMAGE_PIXEL = 'imagePixelModule', - IMAGE_PLANE = 'imagePlaneModule', - IMAGE_URL = 'imageUrlModule', - MODALITY_LUT = 'modalityLutModule', - MULTIFRAME = 'multiframeModule', - NM_MULTIFRAME_GEOMETRY = 'nmMultiframeGeometryModule', - OVERLAY_PLANE = 'overlayPlaneModule', - PATIENT = 'patientModule', - PATIENT_STUDY = 'patientStudyModule', - PET_IMAGE = 'petImageModule', - PET_ISOTOPE = 'petIsotopeModule', - PET_SERIES = 'petSeriesModule', - SOP_COMMON = 'sopCommonModule', - ULTRASOUND_ENHANCED_REGION = 'ultrasoundEnhancedRegionModule', - ECG = 'ecgModule', - VOI_LUT = 'voiLutModule', - /** - * The frame module is used to get information on the number of frames - * in the sop instance, and the current frame number, independently of the - * registration method. - */ - FRAME_MODULE = 'frameModule', - /** - * Some modules need direct access to a data services (WADO) web client. - * This allows getting images and metadata as raw results for display. - * This is DICOMweb WADO, not base WADO, and should support: - * * Series level metadata retrieve - * * Bulkdata retrieve - * * Image retrieve - */ - WADO_WEB_CLIENT = 'wadoWebClient', +import { Enums } from '@cornerstonejs/metadata'; - /** - * Some modules rely on an instance access to the full metadata. - * WARNING: This may not be available or may be expensive to create, use - * with caution. If you can use the existing modules, that is recommended - * instead. - * - */ - INSTANCE = 'instance', - - /** - * There are some convenience methods to get partial metadata related to a - * study referencing the existing one in various ways. - * These are Normalized referenced, eg upper camel case references - * used for creating new instance data. - * - * See the adapters package for standard methods to create these. - */ - - /** - * Reference object for the frame of the imageId provided - */ - IMAGE_SOP_INSTANCE_REFERENCE = 'ImageSopInstanceReference', - /** - * Reference object starting with the series to the sop instance - * provided. - * - * This will likely need to be merged with other series references - */ - REFERENCED_SERIES_REFERENCE = 'ReferencedSeriesReference', - - /** - * Creates a predecessor sequence to indicate the new object replaces - * the old one. - * - * Also includes the series level attributes that this object has - * in order to allow placing the new instance into the same series. - */ - PREDECESSOR_SEQUENCE = 'PredecessorSequence', - - /** - * The study data module contains the normalized values associated with the - * study header, including StudyInstanceUID, PatientID and the other cross- - * study information. - * - * This should be used as a basis for adding a new series to an existing study. - */ - STUDY_DATA = 'StudyData', - /** - * The Series Data module contains the normalized values associated with the - * series object, PLUS the study instance uid. - * - * This should be combined with the study data to add new instances to an - * existing series. - */ - SERIES_DATA = 'SeriesData', - - /** - * The image data module has the image specific information associated with - * the image frame of interest. - * - * This is used when modifying study structure such as creating a multiframe - * reference used internally for segmentation. - */ - IMAGE_DATA = 'ImageData', - - /** - * Static data for various header initialization. - * This change allows writing a custom provider to replace the metadata - * either on a per-instance basis or the default data. - */ - /** - * The basic header data for new RTSS instances - */ - RTSS_INSTANCE_DATA = 'RtssInstanceData', - /** - * Generic new instance data, including study and new series instance data. - */ - NEW_INSTANCE_DATA = 'NewInstanceData', - - /** - * Meta module providers return the _meta field for a new instance - */ - /** Metadata module for RTSS contour */ - RTSS_CONTOUR = 'metaRTSSContour', - /** Metadata module for single bit segmentation */ - SEG_BIT = 'metaSegBitmap', - /** Metadata module for RTSS annotations */ - SR_ANNOTATION = 'metaSrAnnotation', -} - -export default MetadataModules; +export default Enums.MetadataModules; diff --git a/packages/core/src/loaders/imageLoader.ts b/packages/core/src/loaders/imageLoader.ts index 0ad7b10654..1b05512352 100644 --- a/packages/core/src/loaders/imageLoader.ts +++ b/packages/core/src/loaders/imageLoader.ts @@ -1,5 +1,6 @@ import cache from '../cache/cache'; import Events from '../enums/Events'; +import MetadataModules from '../enums/MetadataModules'; import eventTarget from '../eventTarget'; import genericMetadataProvider from '../utilities/genericMetadataProvider'; import { getBufferConfiguration } from '../utilities/getBufferConfiguration'; @@ -247,7 +248,10 @@ export function createAndCacheDerivedImage( const { imageId, skipCreateBuffer, onCacheAdd, voxelRepresentation } = options; - const imagePlaneModule = metaData.get('imagePlaneModule', referencedImageId); + const imagePlaneModule = metaData.get( + MetadataModules.IMAGE_PLANE, + referencedImageId + ); const length = imagePlaneModule.rows * imagePlaneModule.columns; @@ -262,35 +266,38 @@ export function createAndCacheDerivedImage( ); const derivedImageId = imageId; const referencedImagePlaneMetadata = metaData.get( - 'imagePlaneModule', + MetadataModules.IMAGE_PLANE, referencedImageId ); genericMetadataProvider.add(derivedImageId, { - type: 'imagePlaneModule', + type: MetadataModules.IMAGE_PLANE, metadata: referencedImagePlaneMetadata, }); const referencedImageGeneralSeriesMetadata = metaData.get( - 'generalSeriesModule', + MetadataModules.GENERAL_SERIES, referencedImageId ); genericMetadataProvider.add(derivedImageId, { - type: 'generalSeriesModule', + type: MetadataModules.GENERAL_SERIES, metadata: referencedImageGeneralSeriesMetadata, }); genericMetadataProvider.add(derivedImageId, { - type: 'generalImageModule', + type: MetadataModules.GENERAL_IMAGE, metadata: { instanceNumber: options.instanceNumber, }, }); - const imagePixelModule = metaData.get('imagePixelModule', referencedImageId); + const imagePixelModule = metaData.get( + MetadataModules.IMAGE_PIXEL, + referencedImageId + ); genericMetadataProvider.add(derivedImageId, { - type: 'imagePixelModule', + type: MetadataModules.IMAGE_PIXEL, metadata: { ...imagePixelModule, bitsAllocated: 8, @@ -488,7 +495,7 @@ export function createAndCacheLocalImage( }; // Add metadata to genericMetadataProvider - ['imagePlaneModule', 'imagePixelModule'].forEach((type) => { + [MetadataModules.IMAGE_PLANE, MetadataModules.IMAGE_PIXEL].forEach((type) => { genericMetadataProvider.add(imageId, { type, metadata: metadata[type] || {}, diff --git a/packages/core/src/metaData.ts b/packages/core/src/metaData.ts index cd77d78ee3..0f4ed8269f 100644 --- a/packages/core/src/metaData.ts +++ b/packages/core/src/metaData.ts @@ -1,147 +1,17 @@ -// This module defines a way to access various metadata about an imageId. This layer of abstraction exists -// So metadata can be provided in different ways (e.g. by parsing DICOM P10 or by a WADO-RS document) - -const providers = []; - -/** - * Adds a metadata provider with the specified priority - * @param provider - Metadata provider function - * @param priority - 0 is default/normal, > 0 is high, < 0 is low - * - * @category MetaData - */ -export function addProvider( - provider: (type: string, ...query: string[]) => unknown, - priority = 0 -): void { - let i; - - // Find the right spot to insert this provider based on priority - for (i = 0; i < providers.length; i++) { - if (providers[i].priority <= priority) { - break; - } - } - - // Insert the decode task at position i - providers.splice(i, 0, { - priority, - provider, - }); -} - -/** - * Removes the specified provider - * - * @param provider - Metadata provider function - * - * @category MetaData - */ -export function removeProvider( - provider: (type: string, query: unknown) => unknown -): void { - for (let i = 0; i < providers.length; i++) { - if (providers[i].provider === provider) { - providers.splice(i, 1); - - break; - } - } -} - -/** - * Removes all providers - * - * @category MetaData - */ -export function removeAllProviders(): void { - while (providers.length > 0) { - providers.pop(); - } -} - /** - * Gets metadata from the registered metadata providers. Will call each one from highest priority to lowest - * until one responds - * - * @param type - The type of metadata requested from the metadata store - * @param query - The query for the metadata store, often imageId - * Some metadata providers support multi-valued strings, which are interpreted - * as the provider chooses. - * - * @returns The metadata retrieved from the metadata store - * @category MetaData + * @deprecated Import from `@cornerstonejs/metadata` instead. + * This module re-exports the metadata provider chain from `@cornerstonejs/metadata`. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getMetaData(type: string, ...queries): any { - // Invoke each provider in priority order until one returns something - for (let i = 0; i < providers.length; i++) { - const result = providers[i].provider(type, ...queries); - - if (result !== undefined) { - return result; - } - } -} - -/** - * Retrieves metadata from a DICOM image and returns it as an object with capitalized keys. - * @param imageId - the imageId - * @param metaDataProvider - The metadata provider either wadors or wadouri - * @param types - An array of metadata types to retrieve. - * @returns An object containing the retrieved metadata with capitalized keys. - */ -export function getNormalized( - imageId: string, - types: string[], - metaDataProvider = getMetaData -) { - const result = {}; - for (const t of types) { - try { - const data = metaDataProvider(t, imageId); - if (data) { - const capitalizedData = {}; - for (const key in data) { - if (key in data) { - const capitalizedKey = toUpperCamelTag(key); - capitalizedData[capitalizedKey] = data[key]; - } - } - Object.assign(result, capitalizedData); - } - } catch (error) { - console.error(`Error retrieving ${t} data:`, error); - } - } - - return result; -} - -/** - * Converts a tag name to UpperCamelCase - */ -export const toUpperCamelTag = (tag: string) => { - if (tag.startsWith('sop')) { - return `SOP${tag.substring(3)}`; - } - if (tag.endsWith('Id')) { - tag = `${tag.substring(0, tag.length - 2)}ID`; - } - return tag.charAt(0).toUpperCase() + tag.slice(1); -}; - -/** - * Converts a tag name to lowerCamelCase - */ -export const toLowerCamelTag = (tag: string) => { - if (tag.startsWith('SOP')) { - return `sop${tag.substring(3)}`; - } - if (tag.endsWith('ID')) { - tag = `${tag.substring(0, tag.length - 2)}Id`; - } - return tag.charAt(0).toLowerCase() + tag.slice(1); -}; - -export { getMetaData as get }; +import { metaData } from '@cornerstonejs/metadata'; + +export const { + addProvider, + removeProvider, + removeAllProviders, + getMetaData, + getTyped, + get, + getNormalized, + toUpperCamelTag, + toLowerCamelTag, +} = metaData; diff --git a/packages/core/src/types/IImageCalibration.ts b/packages/core/src/types/IImageCalibration.ts index 2dd1b1e810..9bc73c3b1d 100644 --- a/packages/core/src/types/IImageCalibration.ts +++ b/packages/core/src/types/IImageCalibration.ts @@ -1,41 +1,3 @@ -import type CalibrationTypes from '../enums/CalibrationTypes'; - -/** - * IImageCalibration is an object that stores information about the type - * of image calibration. - */ -export interface IImageCalibration { - /** - * The pixel spacing for the image, in mm between pixel centers - * These are not required, and are deprecated in favour of getting the original - * image spacing and then applying the transforms. The values here should - * be identical to original spacing. - */ - rowPixelSpacing?: number; - columnPixelSpacing?: number; - /** The scaling of measurement values relative to the base pixel spacing (1 if not specified) */ - scale?: number; - /** - * The calibration aspect ratio for non-square calibrations. - * This is the aspect ratio similar to the scale above that applies when - * the viewport is displaying non-square image pixels as square screen pixels. - * - * Defaults to 1 if not specified, and is also 1 if the Viewport has squared - * up the image pixels so that they are displayed as a square. - * Not well handled currently as this needs to be incorporated into - * tools when doing calculations. - */ - aspect?: number; - /** The type of the pixel spacing, distinguishing between various - * types projection (CR/DX/MG) spacing and volumetric spacing (the type is - * an empty string as it doesn't get a suffix, but this distinguishes it - * from other types) - */ - type: CalibrationTypes; - /** A tooltip which can be used to explain the calibration information */ - tooltip?: string; - /** The DICOM defined ultrasound regions. Used for non-distance spacing units (US and ECG). */ - sequenceOfUltrasoundRegions?: Record[]; -} - -export type { IImageCalibration as default }; +/** @deprecated Import from `@cornerstonejs/metadata` instead. */ +export type { IImageCalibration } from '@cornerstonejs/metadata'; +export type { IImageCalibration as default } from '@cornerstonejs/metadata'; diff --git a/packages/core/src/types/ImagePlaneModule.ts b/packages/core/src/types/ImagePlaneModule.ts index d6669d9bc7..bb86bbf9b0 100644 --- a/packages/core/src/types/ImagePlaneModule.ts +++ b/packages/core/src/types/ImagePlaneModule.ts @@ -4,13 +4,15 @@ import type IImageCalibration from './IImageCalibration'; export interface ImagePlaneModule { columnCosines?: Point3; columnPixelSpacing?: number; - imageOrientationPatient?: Float32Array; + imageOrientationPatient?: number[] | Float32Array; imagePositionPatient?: Point3; pixelSpacing?: Point2; rowCosines?: Point3; rowPixelSpacing?: number; sliceLocation?: number; sliceThickness?: number; + spacingBetweenSlices?: number; + pixelPaddingValue?: number; frameOfReferenceUID: string; columns: number; rows: number; diff --git a/packages/core/src/types/MetadataModuleTypes.ts b/packages/core/src/types/MetadataModuleTypes.ts index 1aacf6e4b9..7bcc933dc2 100644 --- a/packages/core/src/types/MetadataModuleTypes.ts +++ b/packages/core/src/types/MetadataModuleTypes.ts @@ -1,79 +1,12 @@ -export interface DicomDateObject { - year: number; - month: number; - day: number; -} - -export interface DicomTimeObject { - hours: number; - minutes?: number; - seconds?: number; - fractionalSeconds?: number; -} - -export interface GeneralSeriesModuleMetadata { - modality: string; - seriesInstanceUID: string; - seriesNumber: number; - studyInstanceUID: string; - seriesDate: DicomDateObject; - seriesTime: DicomTimeObject; -} - -export interface PatientStudyModuleMetadata { - patientAge: number; - patientSize: number; - patientWeight: number; -} - -export interface ImagePlaneModuleMetadata { - frameOfReferenceUID: string; - rows: number; - columns: number; - imageOrientationPatient: number[]; - rowCosines: number[]; - columnCosines: number[]; - imagePositionPatient: number[]; - sliceThickness: number; - sliceLocation: number; - pixelSpacing: number[]; - rowPixelSpacing: number | null; - columnPixelSpacing: number | null; - usingDefaultValues: boolean; -} - -export interface ImagePixelModuleMetadata { - samplesPerPixel: number; - photometricInterpretation: string; - rows: number; - columns: number; - bitsAllocated: number; - bitsStored: number; - highBit: number; - pixelRepresentation: number; - planarConfiguration: number; - pixelAspectRatio: string; - redPaletteColorLookupTableDescriptor: number[]; - greenPaletteColorLookupTableDescriptor: number[]; - bluePaletteColorLookupTableDescriptor: number[]; - redPaletteColorLookupTableData: number[]; - greenPaletteColorLookupTableData: number[]; - bluePaletteColorLookupTableData: number[]; - smallestPixelValue?: number; - largestPixelValue?: number; -} - -export interface SopCommonModuleMetadata { - sopClassUID: string; - sopInstanceUID: string; -} - -export interface FrameMetadata extends SopCommonModuleMetadata { - // This is a 1 based frame number - frameNumber: number; - numberOfFrames: number; -} - -export interface TransferSyntaxMetadata { - transferSyntaxUID: string; -} +/** @deprecated Import from `@cornerstonejs/metadata` instead. */ +export type { + DicomDateObject, + DicomTimeObject, + GeneralSeriesModuleMetadata, + PatientStudyModuleMetadata, + ImagePlaneModuleMetadata, + ImagePixelModuleMetadata, + SopCommonModuleMetadata, + FrameMetadata, + TransferSyntaxMetadata, +} from '@cornerstonejs/metadata'; diff --git a/packages/core/src/utilities/buildMetadata.ts b/packages/core/src/utilities/buildMetadata.ts index 279b904992..879948e6a2 100644 --- a/packages/core/src/utilities/buildMetadata.ts +++ b/packages/core/src/utilities/buildMetadata.ts @@ -1,7 +1,7 @@ import * as metaData from '../metaData'; import { MetadataModules, VOILUTFunctionType } from '../enums'; import type IImage from '../types/IImage'; -import type { ImagePlaneModule, ImagePixelModule } from '../types'; +import type { ImagePlaneModule } from '../types'; import type IImageCalibration from '../types/IImageCalibration'; export interface BuildMetadataResult { @@ -50,35 +50,34 @@ export function getValidVOILUTFunction( export function getImagePlaneModule(imageId: string): ImagePlaneModule { const imagePlaneModule = metaData.get(MetadataModules.IMAGE_PLANE, imageId); + if (imagePlaneModule.usingDefaultValues !== undefined) { + // If the usingDefault values is set, then everything is already available + return imagePlaneModule; + } + if ( + imagePlaneModule.columnPixelSpacing && + imagePlaneModule.rowPixelSpacing && + imagePlaneModule.columnCosines && + imagePlaneModule.rowCosines && + imagePlaneModule.imagePositionPatient && + imagePlaneModule.imageOrientationPatient + ) { + // Everything is specifically provided, assume it is correct already. + return imagePlaneModule; + } const newImagePlaneModule: ImagePlaneModule = { ...imagePlaneModule, + usingDefaultValues: true, }; - if (!newImagePlaneModule.columnPixelSpacing) { - newImagePlaneModule.columnPixelSpacing = 1; - } - - if (!newImagePlaneModule.rowPixelSpacing) { - newImagePlaneModule.rowPixelSpacing = 1; - } - - if (!newImagePlaneModule.columnCosines) { - newImagePlaneModule.columnCosines = [0, 1, 0]; - } - - if (!newImagePlaneModule.rowCosines) { - newImagePlaneModule.rowCosines = [1, 0, 0]; - } - - if (!newImagePlaneModule.imagePositionPatient) { - newImagePlaneModule.imagePositionPatient = [0, 0, 0]; - } - - if (!newImagePlaneModule.imageOrientationPatient) { - newImagePlaneModule.imageOrientationPatient = new Float32Array([ - 1, 0, 0, 0, 1, 0, - ]); - } + newImagePlaneModule.columnPixelSpacing ||= 1; + newImagePlaneModule.rowPixelSpacing ||= 1; + newImagePlaneModule.columnCosines ||= [0, 1, 0]; + newImagePlaneModule.rowCosines ||= [1, 0, 0]; + newImagePlaneModule.imagePositionPatient ||= [0, 0, 0]; + newImagePlaneModule.imageOrientationPatient ||= new Float32Array([ + 1, 0, 0, 0, 1, 0, + ]); return newImagePlaneModule; } @@ -138,12 +137,12 @@ export function buildMetadata(image: IImage): BuildMetadataResult { highBit, photometricInterpretation, samplesPerPixel, - } = metaData.get('imagePixelModule', imageId); + } = metaData.get(MetadataModules.IMAGE_PIXEL, imageId); const { windowWidth, windowCenter, voiLUTFunction } = image; - const { modality } = metaData.get('generalSeriesModule', imageId); - const imageIdScalingFactor = metaData.get('scalingModule', imageId); + const { modality } = metaData.get(MetadataModules.GENERAL_SERIES, imageId); + const imageIdScalingFactor = metaData.get(MetadataModules.SCALING, imageId); const calibration = metaData.get(MetadataModules.CALIBRATION, imageId); const voiLUTFunctionEnum = getValidVOILUTFunction(voiLUTFunction); diff --git a/packages/core/src/utilities/calculateSpacingBetweenImageIds.ts b/packages/core/src/utilities/calculateSpacingBetweenImageIds.ts index 35eb0791b2..5767696db8 100644 --- a/packages/core/src/utilities/calculateSpacingBetweenImageIds.ts +++ b/packages/core/src/utilities/calculateSpacingBetweenImageIds.ts @@ -1,5 +1,6 @@ import { vec3 } from 'gl-matrix'; import * as metaData from '../metaData'; +import { MetadataModules } from '../enums'; import { getConfiguration } from '../init'; /** @@ -46,7 +47,7 @@ export default function calculateSpacingBetweenImageIds( const { imagePositionPatient: referenceImagePositionPatient, imageOrientationPatient, - } = metaData.get('imagePlaneModule', imageIds[0]); + } = metaData.get(MetadataModules.IMAGE_PLANE, imageIds[0]); if (imageIds.length === 1) { const { @@ -55,7 +56,7 @@ export default function calculateSpacingBetweenImageIds( columnPixelSpacing, rowPixelSpacing, pixelSpacing, - } = metaData.get('imagePlaneModule', imageIds[0]); + } = metaData.get(MetadataModules.IMAGE_PLANE, imageIds[0]); if (sliceThickness) return sliceThickness; if (spacingBetweenSlices) return spacingBetweenSlices; @@ -99,7 +100,10 @@ export default function calculateSpacingBetweenImageIds( let spacing: number; function getDistance(imageId: string) { - const { imagePositionPatient } = metaData.get('imagePlaneModule', imageId); + const { imagePositionPatient } = metaData.get( + MetadataModules.IMAGE_PLANE, + imageId + ); const positionVector = vec3.create(); // Convert imagePositionPatient to vec3 @@ -144,7 +148,7 @@ export default function calculateSpacingBetweenImageIds( ]; const metadataForMiddleImage = metaData.get( - 'imagePlaneModule', + MetadataModules.IMAGE_PLANE, prefetchedImageIds[1] ); @@ -177,7 +181,7 @@ export default function calculateSpacingBetweenImageIds( columnPixelSpacing, rowPixelSpacing, pixelSpacing, - } = metaData.get('imagePlaneModule', imageIds[0]); + } = metaData.get(MetadataModules.IMAGE_PLANE, imageIds[0]); const { strictZSpacingForVolumeViewport } = getConfiguration().rendering; diff --git a/packages/core/src/utilities/calibratedPixelSpacingMetadataProvider.ts b/packages/core/src/utilities/calibratedPixelSpacingMetadataProvider.ts index 0dfb036c45..2597a509c6 100644 --- a/packages/core/src/utilities/calibratedPixelSpacingMetadataProvider.ts +++ b/packages/core/src/utilities/calibratedPixelSpacingMetadataProvider.ts @@ -1,36 +1,4 @@ -import imageIdToURI from './imageIdToURI'; -import type { IImageCalibration } from '../types/IImageCalibration'; +/** @deprecated Import from `@cornerstonejs/metadata` instead. */ +import { utilities } from '@cornerstonejs/metadata'; -const state: Record = {}; // Calibrated pixel spacing per imageId - -/** - * Simple metadataProvider object to store metadata for calibrated spacings. - * This can be added via cornerstone.metaData.addProvider(...) in order to store - * and return calibrated pixel spacings when metaData type is "calibratedPixelSpacing". - */ -const metadataProvider = { - /** - * Adds metadata for an imageId. - * @param imageId - the imageId for the metadata to store - * @param payload - the payload composed of new calibrated pixel spacings - */ - add: (imageId: string, payload: IImageCalibration): void => { - const imageURI = imageIdToURI(imageId); - state[imageURI] = payload; - }, - - /** - * Returns the metadata for an imageId if it exists. - * @param type - the type of metadata to enquire about - * @param imageId - the imageId to enquire about - * @returns the calibrated pixel spacings for the imageId if it exists, otherwise undefined - */ - get: (type: string, imageId: string): IImageCalibration => { - if (type === 'calibratedPixelSpacing') { - const imageURI = imageIdToURI(imageId); - return state[imageURI]; - } - }, -}; - -export default metadataProvider; +export default utilities.calibratedPixelSpacingMetadataProvider; diff --git a/packages/core/src/utilities/getClosestImageId.ts b/packages/core/src/utilities/getClosestImageId.ts index e3af10b6de..0f13ff3292 100644 --- a/packages/core/src/utilities/getClosestImageId.ts +++ b/packages/core/src/utilities/getClosestImageId.ts @@ -1,6 +1,7 @@ import type { mat3 } from 'gl-matrix'; import { vec3 } from 'gl-matrix'; import * as metaData from '../metaData'; +import { MetadataModules } from '../enums'; import type { IImageVolume, Point3 } from '../types'; import { coreLog } from './logger'; @@ -67,7 +68,7 @@ export default function getClosestImageId( const imageId = imageIds[i]; // 4.a Get metadata for the imageId - const imagePlaneModule = metaData.get('imagePlaneModule', imageId); + const imagePlaneModule = metaData.get(MetadataModules.IMAGE_PLANE, imageId); if (!imagePlaneModule?.imagePositionPatient) { log.warn(`Missing imagePositionPatient for imageId: ${imageId}`); continue; // Skip if essential metadata is missing diff --git a/packages/core/src/utilities/getClosestStackImageIndexForPoint.ts b/packages/core/src/utilities/getClosestStackImageIndexForPoint.ts index 923da205db..7d72f11cd3 100644 --- a/packages/core/src/utilities/getClosestStackImageIndexForPoint.ts +++ b/packages/core/src/utilities/getClosestStackImageIndexForPoint.ts @@ -1,6 +1,7 @@ import { vec3 } from 'gl-matrix'; import * as planar from './planar'; import * as metaData from '../metaData'; +import { MetadataModules } from '../enums'; import type { IStackViewport, Point3 } from '../types'; /** @@ -91,7 +92,7 @@ function getPlaneMetadata(imageId: string): null | { imagePositionPatient: Point3; planeNormal: Point3; } { - const targetImagePlane = metaData.get('imagePlaneModule', imageId); + const targetImagePlane = metaData.get(MetadataModules.IMAGE_PLANE, imageId); if ( !targetImagePlane || diff --git a/packages/core/src/utilities/getPixelSpacingInformation.ts b/packages/core/src/utilities/getPixelSpacingInformation.ts index efb6307b8d..c17ffaece8 100644 --- a/packages/core/src/utilities/getPixelSpacingInformation.ts +++ b/packages/core/src/utilities/getPixelSpacingInformation.ts @@ -1,158 +1,15 @@ -import { isEqual } from './isEqual'; -import { CalibrationTypes } from '../enums'; - -// TODO: Use ENUMS from dcmjs -const projectionRadiographSOPClassUIDs = new Set([ - '1.2.840.10008.5.1.4.1.1.1', // CR Image Storage - '1.2.840.10008.5.1.4.1.1.1.1', // Digital X-Ray Image Storage – for Presentation - '1.2.840.10008.5.1.4.1.1.1.1.1', // Digital X-Ray Image Storage – for Processing - '1.2.840.10008.5.1.4.1.1.1.2', // Digital Mammography X-Ray Image Storage – for Presentation - '1.2.840.10008.5.1.4.1.1.1.2.1', // Digital Mammography X-Ray Image Storage – for Processing - '1.2.840.10008.5.1.4.1.1.1.3', // Digital Intra – oral X-Ray Image Storage – for Presentation - '1.2.840.10008.5.1.4.1.1.1.3.1', // Digital Intra – oral X-Ray Image Storage – for Processing - '1.2.840.10008.5.1.4.1.1.12.1', // X-Ray Angiographic Image Storage - '1.2.840.10008.5.1.4.1.1.12.1.1', // Enhanced XA Image Storage - '1.2.840.10008.5.1.4.1.1.12.2', // X-Ray Radiofluoroscopic Image Storage - '1.2.840.10008.5.1.4.1.1.12.2.1', // Enhanced XRF Image Storage - '1.2.840.10008.5.1.4.1.1.12.3', // X-Ray Angiographic Bi-plane Image Storage Retired -]); - -/** - * Calculates the ERMF value using any of: - * * EstimatedRadiographicMagnificationFactor - * * PixelSpacing / Imager Pixel Spacing - * * Distance Source / imager / patient pair - * - * @returns ERMF if available. True means the PixelSpacing has been pre-calculated - */ -export function getERMF(instance) { - const { - PixelSpacing, - ImagerPixelSpacing, - EstimatedRadiographicMagnificationFactor: ermf, - // Naming is traditionally sid/sod here - DistanceSourceToDetector: sid, - DistanceSourceToPatient: sod, - } = instance; - if (ermf) { - return ermf; - } - if (sod < sid) { - return sid / sod; - } - if (ImagerPixelSpacing?.[0] > PixelSpacing?.[0]) { - return true; - } -} - -/** - * Given an instance, calculates the project (radiographic) pixel spacing - * plus the type of calibration that got used for it. - * - * This will be Calibrated if the calibration type is Fiducial (calibration) - * - * It will be ERMF if Pixel Spacing is present, and the calibration is GEOMETRY - * - * It will be ERMF if Imager Pixel Spacing is present and ermf is defined in some - * format. - * - * It will be projection if imager pixel spacing is defined, but no ermf - * - * It will be unknown if pixel spacing is defined - * - * Otherwise it will be undefined (no spacing). - */ -export function calculateRadiographicPixelSpacing(instance) { - const { PixelSpacing, ImagerPixelSpacing, PixelSpacingCalibrationType } = - instance; - - const isProjection = true; - - if (PixelSpacing && PixelSpacingCalibrationType === 'GEOMETRY') { - if (isEqual(PixelSpacing, ImagerPixelSpacing)) { - console.warn( - 'Calibration type is geometry, but pixel spacing and imager pixel spacing identical', - PixelSpacing, - ImagerPixelSpacing - ); - } - // This tag says just trust the pixel spacing without worrying about the value - return { - PixelSpacing, - type: CalibrationTypes.ERMF, - isProjection, - }; - } - - if (PixelSpacing && PixelSpacingCalibrationType === 'FIDUCIAL') { - // This tag says that the pixel spacing has been manually calibrated - return { - PixelSpacing, - type: CalibrationTypes.CALIBRATED, - isProjection, - }; - } - - if (ImagerPixelSpacing) { - const ermf = getERMF(instance); - if (ermf > 1) { - // The IHE Mammo profile specifies that the value of Imager Pixel Spacing is required to be corrected by - // Estimated Radiographic Magnification Factor and the user informed of that. - const correctedPixelSpacing = ImagerPixelSpacing.map( - (pixelSpacing) => pixelSpacing / ermf - ); - - return { - PixelSpacing: correctedPixelSpacing, - type: CalibrationTypes.ERMF, - isProjection, - }; - } - if (ermf === true) { - // PixelSpacing already updated/correct, don't tweak it - return { - PixelSpacing, - type: CalibrationTypes.ERMF, - isProjection, - }; - } - if (ermf) { - console.error('Illegal ERMF value:', ermf); - } - return { - PixelSpacing: PixelSpacing || ImagerPixelSpacing, - type: CalibrationTypes.PROJECTION, - isProjection, - }; - } - - // If only Pixel Spacing is present, and this is a projection radiograph, - // PixelSpacing should be used, but the user should be informed that - // what it means is unknown - return { - PixelSpacing, - type: CalibrationTypes.UNKNOWN, - isProjection, - }; -} - -export function getPixelSpacingInformation(instance) { - // See http://gdcm.sourceforge.net/wiki/index.php/Imager_Pixel_Spacing - // TODO: Add manual calibration - - const { PixelSpacing, SOPClassUID } = instance; - - const isProjection = projectionRadiographSOPClassUIDs.has(SOPClassUID); - - if (isProjection) { - return calculateRadiographicPixelSpacing(instance); - } - - return { - PixelSpacing, - type: CalibrationTypes.NOT_APPLICABLE, - isProjection: false, - }; -} - -export default getPixelSpacingInformation; +/** @deprecated Import from `@cornerstonejs/metadata` instead. */ +import { utilities } from '@cornerstonejs/metadata'; + +const { + getPixelSpacingInformation, + calculateRadiographicPixelSpacing, + getERMF, +} = utilities; + +export { + getPixelSpacingInformation, + calculateRadiographicPixelSpacing, + getERMF, +}; +export default utilities.getPixelSpacingInformation; diff --git a/packages/core/src/utilities/getScalingParameters.ts b/packages/core/src/utilities/getScalingParameters.ts index a9f631a895..eda21b5dd9 100644 --- a/packages/core/src/utilities/getScalingParameters.ts +++ b/packages/core/src/utilities/getScalingParameters.ts @@ -1,4 +1,5 @@ import * as metaData from '../metaData'; +import { MetadataModules } from '../enums'; import type { ScalingParameters } from '../types'; /** @@ -11,9 +12,10 @@ import type { ScalingParameters } from '../types'; export default function getScalingParameters( imageId: string ): ScalingParameters { - const modalityLutModule = metaData.get('modalityLutModule', imageId) || {}; + const modalityLutModule = + metaData.get(MetadataModules.MODALITY_LUT, imageId) || {}; const generalSeriesModule = - metaData.get('generalSeriesModule', imageId) || {}; + metaData.get(MetadataModules.GENERAL_SERIES, imageId) || {}; const { modality } = generalSeriesModule; @@ -23,7 +25,7 @@ export default function getScalingParameters( modality, }; - const scalingModules = metaData.get('scalingModule', imageId) || {}; + const scalingModules = metaData.get(MetadataModules.SCALING, imageId) || {}; return { ...scalingParameters, diff --git a/packages/core/src/utilities/imageIdToURI.ts b/packages/core/src/utilities/imageIdToURI.ts index 9f24fe523d..efa997ab00 100644 --- a/packages/core/src/utilities/imageIdToURI.ts +++ b/packages/core/src/utilities/imageIdToURI.ts @@ -1,13 +1,4 @@ -/** - * Removes the data loader scheme from the imageId - * - * @param imageId - Image ID - * @returns imageId without the data loader scheme, or empty string if imageId is falsy - */ -export default function imageIdToURI(imageId: string): string { - if (!imageId) { - return ''; - } - const colonIndex = imageId.indexOf(':'); - return colonIndex === -1 ? imageId : imageId.substring(colonIndex + 1); -} +/** @deprecated Import from `@cornerstonejs/metadata` instead. */ +import { utilities } from '@cornerstonejs/metadata'; + +export default utilities.imageIdToURI; diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index fcba9af39f..b1734ce67b 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -108,7 +108,6 @@ export * from './getPlaneCubeIntersectionDimensions'; export * from './rotateToViewCoordinates'; import { asArray } from './asArray'; export { updatePlaneRestriction } from './updatePlaneRestriction'; - const getViewportModality = (viewport: IViewport, volumeId?: string) => _getViewportModality(viewport, volumeId, cache.getVolume); diff --git a/packages/core/src/utilities/isEqual.ts b/packages/core/src/utilities/isEqual.ts index 24d343ed1f..bbd9a39791 100644 --- a/packages/core/src/utilities/isEqual.ts +++ b/packages/core/src/utilities/isEqual.ts @@ -1,110 +1,11 @@ -function areNumbersEqualWithTolerance( - num1: number, - num2: number, - tolerance: number -): boolean { - return Math.abs(num1 - num2) <= tolerance; -} - -function areArraysEqual( - arr1: ArrayLike, - arr2: ArrayLike, - tolerance = 1e-5 -): boolean { - if (arr1.length !== arr2.length) { - return false; - } - - for (let i = 0; i < arr1.length; i++) { - if (!areNumbersEqualWithTolerance(arr1[i], arr2[i], tolerance)) { - return false; - } - } - - return true; -} - -function isNumberType(value: unknown): value is number { - return typeof value === 'number'; -} - -function isNumberArrayLike(value: unknown): value is ArrayLike { - return ( - value && - typeof value === 'object' && - 'length' in value && - typeof (value as ArrayLike).length === 'number' && - (value as ArrayLike).length > 0 && - typeof (value as ArrayLike)[0] === 'number' - ); -} - -/** - * Returns whether two values are equal or not, based on epsilon comparison. - * For array comparison, it does NOT strictly compare them but only compare its values. - * It can compare array of numbers and also typed array. Otherwise it will just return false. - * - * @param v1 - The first value to compare - * @param v2 - The second value to compare - * @param tolerance - The acceptable tolerance, the default is 0.00001 - * - * @returns True if the two values are within the tolerance levels. - */ -export function isEqual( - v1: ValueType, - v2: ValueType, - tolerance = 1e-5 -): boolean { - // values must be the same type or not null - if (typeof v1 !== typeof v2 || v1 === null || v2 === null) { - return false; - } - - if (isNumberType(v1) && isNumberType(v2)) { - return areNumbersEqualWithTolerance(v1, v2, tolerance); - } - - if (isNumberArrayLike(v1) && isNumberArrayLike(v2)) { - return areArraysEqual(v1, v2, tolerance); - } - - return false; -} - -const negative = (v) => - typeof v === 'number' ? -v : v?.map ? v.map(negative) : !v; - -const abs = (v) => - typeof v === 'number' ? Math.abs(v) : v?.map ? v.map(abs) : v; - -/** - * Compare negative values of both single numbers and vectors - */ -export const isEqualNegative = ( - v1: ValueType, - v2: ValueType, - tolerance = undefined -) => isEqual(v1, negative(v2) as unknown as ValueType, tolerance); - -/** - * Compare absolute values for single numbers and vectors. - * Not recommended for large vectors as this creates a copy - */ -export const isEqualAbs = ( - v1: ValueType, - v2: ValueType, - tolerance = undefined -) => isEqual(abs(v1), abs(v2) as unknown as ValueType, tolerance); - -/** - * @param n - array of numbers or a simple number - * @returns True if n or the first element of n is finite and not NaN - */ -export function isNumber(n: number[] | number): boolean { - if (Array.isArray(n)) { - return isNumber(n[0]); - } - return isFinite(n) && !isNaN(n); -} - -export default isEqual; +/** @deprecated Import from `@cornerstonejs/utils` instead. */ +import { + isEqual, + isEqualNegative, + isEqualAbs, + isNumber, + isEqual as isEqualDefault, +} from '@cornerstonejs/utils'; + +export { isEqual, isEqualNegative, isEqualAbs, isNumber }; +export default isEqualDefault; diff --git a/packages/core/src/utilities/isValidVolume.ts b/packages/core/src/utilities/isValidVolume.ts index 35070ca497..d3a0e712f5 100644 --- a/packages/core/src/utilities/isValidVolume.ts +++ b/packages/core/src/utilities/isValidVolume.ts @@ -1,4 +1,5 @@ import * as metaData from '../metaData'; +import { MetadataModules } from '../enums'; import isEqual from './isEqual'; /** @@ -16,11 +17,16 @@ function isValidVolume(imageIds: string[]): boolean { const imageId0 = imageIds[0]; - const { modality, seriesInstanceUID } = metaData.get( - 'generalSeriesModule', - imageId0 - ); + const generalSeries = metaData.get(MetadataModules.GENERAL_SERIES, imageId0); + if (!generalSeries) { + return false; + } + const { modality, seriesInstanceUID } = generalSeries; + const imagePlane = metaData.get(MetadataModules.IMAGE_PLANE, imageId0); + if (!imagePlane) { + return false; + } const { imageOrientationPatient, pixelSpacing, @@ -28,7 +34,7 @@ function isValidVolume(imageIds: string[]): boolean { columns, rows, usingDefaultValues, - } = metaData.get('imagePlaneModule', imageId0); + } = imagePlane; if (usingDefaultValues) { return false; @@ -48,12 +54,14 @@ function isValidVolume(imageIds: string[]): boolean { for (let i = 0; i < imageIds.length; i++) { const imageId = imageIds[i]; - const { modality, seriesInstanceUID } = metaData.get( - 'generalSeriesModule', - imageId - ); - const { imageOrientationPatient, pixelSpacing, columns, rows } = - metaData.get('imagePlaneModule', imageId); + const general = metaData.get(MetadataModules.GENERAL_SERIES, imageId); + const plane = metaData.get(MetadataModules.IMAGE_PLANE, imageId); + if (!general || !plane) { + validVolume = false; + break; + } + const { modality, seriesInstanceUID } = general; + const { imageOrientationPatient, pixelSpacing, columns, rows } = plane; if (seriesInstanceUID !== baseMetadata.seriesInstanceUID) { validVolume = false; diff --git a/packages/core/src/utilities/isVideoTransferSyntax.ts b/packages/core/src/utilities/isVideoTransferSyntax.ts index c82c9ba309..147817ea1d 100644 --- a/packages/core/src/utilities/isVideoTransferSyntax.ts +++ b/packages/core/src/utilities/isVideoTransferSyntax.ts @@ -1,26 +1,5 @@ -export const videoUIDs = new Set([ - '1.2.840.10008.1.2.4.100', - '1.2.840.10008.1.2.4.100.1', - '1.2.840.10008.1.2.4.101', - '1.2.840.10008.1.2.4.101.1', - '1.2.840.10008.1.2.4.102', - '1.2.840.10008.1.2.4.102.1', - '1.2.840.10008.1.2.4.103', - '1.2.840.10008.1.2.4.103.1', - '1.2.840.10008.1.2.4.104', - '1.2.840.10008.1.2.4.104.1', - '1.2.840.10008.1.2.4.105', - '1.2.840.10008.1.2.4.105.1', - '1.2.840.10008.1.2.4.106', - '1.2.840.10008.1.2.4.106.1', - '1.2.840.10008.1.2.4.107', - '1.2.840.10008.1.2.4.108', -]); +/** @deprecated Import from `@cornerstonejs/metadata` instead. */ +import { utilities } from '@cornerstonejs/metadata'; -export default function isVideoTransferSyntax(uidOrUids: string | string[]) { - if (!uidOrUids) { - return false; - } - const uids = Array.isArray(uidOrUids) ? uidOrUids : [uidOrUids]; - return uids.find((uid) => videoUIDs.has(uid)); -} +export default utilities.isVideoTransferSyntax; +export const { videoUIDs } = utilities; diff --git a/packages/core/src/utilities/loadImageToCanvas.ts b/packages/core/src/utilities/loadImageToCanvas.ts index ff99eaebea..e89ed9c022 100644 --- a/packages/core/src/utilities/loadImageToCanvas.ts +++ b/packages/core/src/utilities/loadImageToCanvas.ts @@ -9,7 +9,7 @@ import type { import { loadAndCacheImage } from '../loaders/imageLoader'; import * as metaData from '../metaData'; -import { RequestType } from '../enums'; +import { MetadataModules, RequestType } from '../enums'; import imageLoadPoolManager from '../requestPool/imageLoadPoolManager'; import renderToCanvasGPU from './renderToCanvasGPU'; import renderToCanvasCPU from './renderToCanvasCPU'; @@ -129,7 +129,8 @@ export default function loadImageToCanvas( return new Promise((resolve, reject) => { function successCallback(imageOrVolume: IImage | IVolume, imageId: string) { - const { modality } = metaData.get('generalSeriesModule', imageId) || {}; + const { modality } = + metaData.get(MetadataModules.GENERAL_SERIES, imageId) || {}; const image = !isVolume && (imageOrVolume as IImage); const volume = isVolume && (imageOrVolume as IVolume); diff --git a/packages/core/src/utilities/logger.ts b/packages/core/src/utilities/logger.ts index 4764998639..33e1e2302d 100644 --- a/packages/core/src/utilities/logger.ts +++ b/packages/core/src/utilities/logger.ts @@ -1,96 +1,20 @@ -/* eslint-disable prettier/prettier */ -import loglevelImport from 'loglevel'; -import type { Logger as LogLevelLogger } from 'loglevel'; - -/** Get the global/shared loglevel version */ -const loglevel = loglevelImport.noConflict(); - -type WindowLog = { - log: unknown; -}; - -if (typeof window !== 'undefined') { - (window as unknown as WindowLog).log = loglevel; -} - -export type Logger = LogLevelLogger & { - getLogger: (...categories: string[]) => Logger; -}; - +import { logging } from '@cornerstonejs/utils'; /** - * Gets a logger and adds a getLogger function to id to get child loggers. - * This looks like the loggers in the unreleased loglevel 2.0 and is intended - * for forwards compatibility. - */ -export function getRootLogger(name: string): Logger { - const logger = loglevel.getLogger(name[0]) as Logger; - logger.getLogger = (...names: string[]) => { - return getRootLogger(`${name}.${names.join('.')}`); - }; - return logger; -} - -/** - * Gets a nested logger. - * This will eventually inherit the level from the parent level, but right now - * it doesn't - */ -export function getLogger(...name: string[]): Logger { - return getRootLogger(name.join('.')); -} - -/** - * The cs3dLog is a root category for Cornerstone3D logs. In forms a grouping - * for the logs underneath it, although at this point the log levels are entirely - * either local or inherited from the root loglevel. - * In loglevel 2.0, the default log levels will inherit from the parent logger, so - * that using `cs3dLog.setLevel("info")` for example, will set child categories - * to level info unless they have been otherwise specified. - * - * As well, the categories could be used with an externally defined appended - * to separate various logs by source. See dicom issue log below. - */ -export const cs3dLog = getRootLogger('cs3d'); - -/** - * The core, tools etc logs are intended to form root categories for the various - * packages to allow a particular package to be debugged or output redirected. - * - * The recommended usage is to create a sub-logger at the file level to log - * data for a particular area such as: - * ``` - * const log = coreLog.getLogger('RenderingEngine', 'StackViewport'); - * ``` - * This usage is intended to allow hierarchical categories to turn on an entire - * sub-directory of loggers such as `RenderingEngine` once hierarchical categories - * have been enabled in loglevel 2.0 - */ -export const coreLog = cs3dLog.getLogger('core'); -export const toolsLog = cs3dLog.getLogger('tools'); -export const loaderLog = cs3dLog.getLogger('dicomImageLoader'); -export const aiLog = cs3dLog.getLogger('ai'); - -/** - * The examples log is intended as a cross-package root logger for the examples, - * allowing separation of logging for examples from that for other areas. - */ -export const examplesLog = cs3dLog.getLogger('examples'); - -/** - * Dicom issue log is for reporting inconsistencies and issues with DICOM logging - * This log is separated from the cs3d hierarchy to allow separation of logs - * by use of an external appended to store inconsistencies and invalid DICOM - * values separately. + * @deprecated Moved to utils. Import from `@cornerstonejs/utils` instead. * - * Levels: - * * error - this is an issue in the data which prevents displaying at all - * * warn - a serious issue in the data which could cause significant display - * issues or mismatches of data. - * * info - an issue in the data which is handled internally or worked around such - * as not having patient name separated by `^` characters. - * * debug - an issue in the data which is common and is easily managed + * Re-exports logging from @cornerstonejs/utils for backward compatibility. */ -export const dicomConsistencyLog = getLogger('consistency', 'dicom'); - -/** An image consistency/issue log for reporting image decompression issues */ -export const imageConsistencyLog = getLogger('consistency', 'image'); +export const { + getRootLogger, + getLogger, + cs3dLog, + workerLog, + coreLog, + toolsLog, + loaderLog, + aiLog, + examplesLog, + dicomConsistencyLog, + imageConsistencyLog, +} = logging; +export type Logger = logging.Logger; diff --git a/packages/core/src/utilities/sortImageIdsAndGetSpacing.ts b/packages/core/src/utilities/sortImageIdsAndGetSpacing.ts index 216ac1c784..2a6d28e9dd 100644 --- a/packages/core/src/utilities/sortImageIdsAndGetSpacing.ts +++ b/packages/core/src/utilities/sortImageIdsAndGetSpacing.ts @@ -1,5 +1,6 @@ import { vec3 } from 'gl-matrix'; import * as metaData from '../metaData'; +import { MetadataModules } from '../enums'; import calculateSpacingBetweenImageIds from './calculateSpacingBetweenImageIds'; import type { Point3 } from '../types'; @@ -24,7 +25,7 @@ export default function sortImageIdsAndGetSpacing( const { imagePositionPatient: referenceImagePositionPatient, imageOrientationPatient, - } = metaData.get('imagePlaneModule', imageIds[0]); + } = metaData.get(MetadataModules.IMAGE_PLANE, imageIds[0]); if (!scanAxisNormal) { const rowCosineVec = vec3.fromValues( @@ -50,7 +51,10 @@ export default function sortImageIdsAndGetSpacing( let sortedImageIds: string[]; function getDistance(imageId: string) { - const { imagePositionPatient } = metaData.get('imagePlaneModule', imageId); + const { imagePositionPatient } = metaData.get( + MetadataModules.IMAGE_PLANE, + imageId + ); const positionVector = vec3.create(); @@ -99,7 +103,7 @@ export default function sortImageIdsAndGetSpacing( } const { imagePositionPatient: origin } = metaData.get( - 'imagePlaneModule', + MetadataModules.IMAGE_PLANE, sortedImageIds[0] ); diff --git a/packages/core/src/utilities/splitImageIdsBy4DTags.ts b/packages/core/src/utilities/splitImageIdsBy4DTags.ts index 14bff3bf65..af3ed0dc53 100644 --- a/packages/core/src/utilities/splitImageIdsBy4DTags.ts +++ b/packages/core/src/utilities/splitImageIdsBy4DTags.ts @@ -1,517 +1,8 @@ -import * as metaData from '../metaData'; -import { toFiniteNumber } from './toNumber'; +/** @deprecated Import from `@cornerstonejs/metadata` instead. */ +import { utilities } from '@cornerstonejs/metadata'; -// TODO: Test remaining implemented tags -// Supported 4D Tags -// (0018,1060) Trigger Time [Implemented, not tested] -// (0018,0081) Echo Time [Implemented, not tested] -// (0018,0086) Echo Number [Implemented, not tested] -// (0020,0100) Temporal Position Identifier [OK] -// (0054,1300) FrameReferenceTime [OK] -// (0018,9087) Diffusion B Value [OK] -// (2001,1003) Philips Diffusion B-factor [OK] -// (0019,100c) Siemens Diffusion B Value [Implemented, not tested] -// (0043,1039) GE Diffusion B Value [OK] -// -// Multiframe 4D Support (NM Multi-frame Module): -// (0054,0070) TimeSlotVector [OK] -// (0054,0080) SliceVector [Used for ordering within time slots] - -interface MappedIPP { - imageId: string; - imagePositionPatient; -} - -interface MultiframeSplitResult { - imageIdGroups: string[][]; - splittingTag: string; -} - -/** - * Generates frame-specific imageIds for a multiframe image. - * Replaces the frame number in the imageId with the specified frame number (1-based). - * - * @param baseImageId - The base imageId that must contain a "/frames/" pattern followed by a digit. - * Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1". - * The pattern "/frames/\d+" will be replaced with "/frames/" + frameNumber. - * @param frameNumber - The frame number to use (1-based) - * @returns The imageId with the frame number replaced - * @throws {Error} If baseImageId does not contain the required "/frames/" pattern, throws an error - * with a clear message indicating the expected format. - */ -function generateFrameImageId( - baseImageId: string, - frameNumber: number -): string { - const framePattern = /\/frames\/\d+/; - - if (!framePattern.test(baseImageId)) { - throw new Error( - `generateFrameImageId: baseImageId must contain a "/frames/" pattern followed by a digit. ` + - `Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1". ` + - `Received: ${baseImageId}` - ); - } - - return baseImageId.replace(framePattern, `/frames/${frameNumber}`); -} - -/** - * Handles multiframe 4D splitting using TimeSlotVector (0054,0070). - * For NM Multi-frame images where frames are indexed by time slot and slice. - * - * @param imageIds - Array containing the base imageId (typically just one for multiframe). - * The base imageId must contain a "/frames/" pattern (e.g., "wadouri:http://example.com/image/frames/1"). - * See generateFrameImageId for format requirements. - * @returns Split result if multiframe 4D is detected, null otherwise - */ -function handleMultiframe4D(imageIds: string[]): MultiframeSplitResult | null { - if (!imageIds || imageIds.length === 0) { - return null; - } - - const baseImageId = imageIds[0]; - const instance = metaData.get('instance', baseImageId); - - if (!instance) { - return null; - } - - const numberOfFrames = instance.NumberOfFrames; - if (!numberOfFrames || numberOfFrames <= 1) { - return null; - } - - const timeSlotVector = instance.TimeSlotVector; - if (!timeSlotVector || !Array.isArray(timeSlotVector)) { - return null; - } - - const sliceVector = instance.SliceVector; - const numberOfSlices = instance.NumberOfSlices; - - if (timeSlotVector.length !== numberOfFrames) { - console.warn( - 'TimeSlotVector length does not match NumberOfFrames:', - timeSlotVector.length, - 'vs', - numberOfFrames - ); - return null; - } - - if (sliceVector) { - if (!Array.isArray(sliceVector)) { - console.warn( - 'SliceVector exists but is not an array. Expected length:', - numberOfFrames - ); - return null; - } - - if ( - sliceVector.length !== numberOfFrames || - sliceVector.some((val) => val === undefined) - ) { - console.warn( - 'SliceVector exists but has invalid length or undefined entries. Expected length:', - numberOfFrames, - 'Actual length:', - sliceVector.length - ); - return null; - } - } - - const timeSlotGroups: Map< - number, - Array<{ frameIndex: number; sliceIndex: number }> - > = new Map(); - - for (let frameIndex = 0; frameIndex < numberOfFrames; frameIndex++) { - const timeSlot = timeSlotVector[frameIndex]; - const sliceIndex = sliceVector?.[frameIndex] ?? frameIndex; - - if (!timeSlotGroups.has(timeSlot)) { - timeSlotGroups.set(timeSlot, []); - } - - timeSlotGroups.get(timeSlot).push({ frameIndex, sliceIndex }); - } - - const sortedTimeSlots = Array.from(timeSlotGroups.keys()).sort( - (a, b) => a - b - ); - - const imageIdGroups: string[][] = sortedTimeSlots.map((timeSlot) => { - const frames = timeSlotGroups.get(timeSlot); - - frames.sort((a, b) => a.sliceIndex - b.sliceIndex); - - return frames.map((frame) => - generateFrameImageId(baseImageId, frame.frameIndex + 1) - ); - }); - - const expectedSlicesPerTimeSlot = numberOfSlices || imageIdGroups[0]?.length; - const allGroupsHaveSameLength = imageIdGroups.every( - (group) => group.length === expectedSlicesPerTimeSlot - ); - - if (!allGroupsHaveSameLength) { - console.warn( - 'Multiframe 4D split resulted in uneven time slot groups. Expected', - expectedSlicesPerTimeSlot, - 'slices per time slot.' - ); - } - - return { - imageIdGroups, - splittingTag: 'TimeSlotVector', - }; -} - -function handleCardiac4D(imageIds: string[]): MultiframeSplitResult | null { - if (!imageIds || imageIds.length === 0) { - return null; - } - - const cardiacNumberOfImages = getFiniteValue( - imageIds[0], - 'CardiacNumberOfImages' - ); - - // Check if CardiacNumberOfImages exists as a detection flag for cardiac 4D data - if (cardiacNumberOfImages === undefined) { - return null; - } - - const stacks: Map< - string, - Map> - > = new Map(); - - for (const imageId of imageIds) { - const stackId = metaData.get('StackID', imageId); - const inStackPositionNumber = getFiniteValue( - imageId, - 'InStackPositionNumber' - ); - const triggerTime = getFiniteValue(imageId, 'TriggerTime'); - - if ( - stackId === undefined || - inStackPositionNumber === undefined || - triggerTime === undefined - ) { - return null; - } - - const stackKey = String(stackId); - if (!stacks.has(stackKey)) { - stacks.set(stackKey, new Map()); - } - - const positions = stacks.get(stackKey); - if (!positions.has(inStackPositionNumber)) { - positions.set(inStackPositionNumber, []); - } - - positions.get(inStackPositionNumber).push({ imageId, triggerTime }); - } - - const sortedStackIds = Array.from(stacks.keys()).sort( - (a, b) => Number(a) - Number(b) - ); - if (sortedStackIds.length === 0) { - return null; - } - - const preparedStacks: Array<{ - stackId: string; - positions: number[]; - framesByPosition: Map< - number, - Array<{ imageId: string; triggerTime: number }> - >; - }> = []; - - let timeCount: number | undefined; - - for (const stackId of sortedStackIds) { - const positions = stacks.get(stackId); - const sortedPositions = Array.from(positions.keys()).sort((a, b) => a - b); - - for (const position of sortedPositions) { - const frames = positions.get(position); - frames.sort((a, b) => a.triggerTime - b.triggerTime); - - if (timeCount === undefined) { - timeCount = frames.length; - } else if (frames.length !== timeCount) { - return null; - } - } - - preparedStacks.push({ - stackId, - positions: sortedPositions, - framesByPosition: positions, - }); - } - - if (!timeCount) { - return null; - } - - const imageIdGroups: string[][] = []; - for (let timeIndex = 0; timeIndex < timeCount; timeIndex++) { - const group: string[] = []; - for (const stack of preparedStacks) { - for (const position of stack.positions) { - const frames = stack.framesByPosition.get(position); - group.push(frames[timeIndex].imageId); - } - } - imageIdGroups.push(group); - } - - return { - imageIdGroups, - splittingTag: 'CardiacTriggerTime', - }; -} - -const groupBy = (array, key) => { - return array.reduce((rv, x) => { - (rv[x[key]] = rv[x[key]] || []).push(x); - return rv; - }, {}); -}; - -function getIPPGroups(imageIds: string[]): { [id: string]: Array } { - const ippMetadata: Array = imageIds.map((imageId) => { - const { imagePositionPatient } = - metaData.get('imagePlaneModule', imageId) || {}; - return { imageId, imagePositionPatient }; - }); - - if (!ippMetadata.every((item) => item.imagePositionPatient)) { - // Fail if any instances don't provide a position - return null; - } - - const positionGroups = groupBy(ippMetadata, 'imagePositionPatient'); - const positions = Object.keys(positionGroups); - const frame_count = positionGroups[positions[0]].length; - if (frame_count === 1) { - // Single frame indicates 3D volume - return null; - } - const frame_count_equal = positions.every( - (k) => positionGroups[k].length === frame_count - ); - if (!frame_count_equal) { - // Differences in number of frames per position group --> not a valid MV - return null; - } - return positionGroups; -} - -function test4DTag( - IPPGroups: { [id: string]: Array }, - value_getter: (imageId: string) => number -) { - const frame_groups = {}; - let first_frame_value_set: number[] = []; - - const positions = Object.keys(IPPGroups); - for (let i = 0; i < positions.length; i++) { - const frame_value_set: Set = new Set(); - const frames = IPPGroups[positions[i]]; - - for (let j = 0; j < frames.length; j++) { - const frame_value = value_getter(frames[j].imageId) || 0; - - frame_groups[frame_value] = frame_groups[frame_value] || []; - frame_groups[frame_value].push({ imageId: frames[j].imageId }); - - frame_value_set.add(frame_value); - if (frame_value_set.size - 1 < j) { - return undefined; - } - } - - if (i == 0) { - first_frame_value_set = Array.from(frame_value_set); - } else if (!setEquals(first_frame_value_set, frame_value_set)) { - return undefined; - } - } - return frame_groups; -} - -function getTagValue(imageId: string, tag: string): number | undefined { - const value = metaData.get(tag, imageId); - try { - return parseFloat(value); - } catch { - return undefined; - } -} - -function getFiniteValue(imageId: string, tag: string): number | undefined { - return toFiniteNumber(getTagValue(imageId, tag)); -} - -function getPhilipsPrivateBValue(imageId: string) { - // Philips Private Diffusion B-factor tag (2001, 1003) - // Private creator: Philips Imaging DD 001, VR=FL, VM=1 - const value = metaData.get('20011003', imageId); - try { - const { InlineBinary } = value; - if (InlineBinary) { - const value_bytes = atob(InlineBinary); - const ary_buf = new ArrayBuffer(value_bytes.length); - const dv = new DataView(ary_buf); - for (let i = 0; i < value_bytes.length; i++) { - dv.setUint8(i, value_bytes.charCodeAt(i)); - } - //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // For WebGL Buffers, can skip Float32Array, - // just return ArrayBuffer is all that's needed. - return new Float32Array(ary_buf)[0]; - } - - return parseFloat(value); - } catch { - return undefined; - } -} - -function getSiemensPrivateBValue(imageId: string) { - // Siemens Private Diffusion B-factor tag (0019, 100c) - // Private creator: SIEMENS MR HEADER, VR=IS, VM=1 - let value = - metaData.get('0019100c', imageId) || metaData.get('0019100C', imageId); - - try { - const { InlineBinary } = value; - if (InlineBinary) { - value = atob(InlineBinary); - } - return parseFloat(value); - } catch { - return undefined; - } -} - -function getGEPrivateBValue(imageId: string) { - // GE Private Diffusion B-factor tag (0043, 1039) - // Private creator: GEMS_PARM_01, VR=IS, VM=4 - let value = metaData.get('00431039', imageId); - - try { - const { InlineBinary } = value; - if (InlineBinary) { - value = atob(InlineBinary).split('//'); - } - return parseFloat(value[0]) % 100000; - } catch { - return undefined; - } -} - -function setEquals(set_a: number[], set_b: Set): boolean { - if (set_a.length != set_b.size) { - return false; - } - for (let i = 0; i < set_a.length; i++) { - if (!set_b.has(set_a[i])) { - return false; - } - } - return true; -} - -function getPetFrameReferenceTime(imageId) { - const moduleInfo = metaData.get('petImageModule', imageId); - return moduleInfo ? moduleInfo['frameReferenceTime'] : 0; -} - -/** - * Split the imageIds array by 4D tags into groups. Each group must have the - * same number of imageIds or the same imageIds array passed in is returned. - * - * For multiframe images (NumberOfFrames > 1), this function checks for - * TimeSlotVector (0054,0070) which is common in NM (Nuclear Medicine) gated - * SPECT/PET images. The TimeSlotVector indicates which time slot each frame - * belongs to, and SliceVector (0054,0080) indicates the slice position. - * - * @param imageIds - array of imageIds - * @returns imageIds grouped by 4D tags - */ -function splitImageIdsBy4DTags(imageIds: string[]): { - imageIdGroups: string[][]; - splittingTag: string | null; -} { - const multiframeResult = handleMultiframe4D(imageIds); - if (multiframeResult) { - return multiframeResult; - } - - const cardiacResult = handleCardiac4D(imageIds); - if (cardiacResult) { - return cardiacResult; - } - - const positionGroups = getIPPGroups(imageIds); - if (!positionGroups) { - return { imageIdGroups: [imageIds], splittingTag: null }; - } - - const tags = [ - 'TemporalPositionIdentifier', - 'DiffusionBValue', - 'TriggerTime', - 'EchoTime', - 'EchoNumber', - 'PhilipsPrivateBValue', - 'SiemensPrivateBValue', - 'GEPrivateBValue', - 'PetFrameReferenceTime', - ]; - - const fncList2 = [ - (imageId) => getTagValue(imageId, tags[0]), - (imageId) => getTagValue(imageId, tags[1]), - (imageId) => getTagValue(imageId, tags[2]), - (imageId) => getTagValue(imageId, tags[3]), - (imageId) => getTagValue(imageId, tags[4]), - getPhilipsPrivateBValue, - getSiemensPrivateBValue, - getGEPrivateBValue, - getPetFrameReferenceTime, - ]; - - for (let i = 0; i < fncList2.length; i++) { - const frame_groups = test4DTag(positionGroups, fncList2[i]); - if (frame_groups) { - const sortedKeys = Object.keys(frame_groups) - .map(Number.parseFloat) - .sort((a, b) => a - b); - - const imageIdGroups = sortedKeys.map((key) => - frame_groups[key].map((item) => item.imageId) - ); - return { imageIdGroups, splittingTag: tags[i] }; - } - } - - // Return the same imagesIds for non-4D volumes and indicate no tag was used - return { imageIdGroups: [imageIds], splittingTag: null }; -} +const { splitImageIdsBy4DTags, handleMultiframe4D, generateFrameImageId } = + utilities; export default splitImageIdsBy4DTags; export { handleMultiframe4D, generateFrameImageId }; diff --git a/packages/core/src/utilities/toNumber.ts b/packages/core/src/utilities/toNumber.ts index 8021fe6f1c..33f34c7129 100644 --- a/packages/core/src/utilities/toNumber.ts +++ b/packages/core/src/utilities/toNumber.ts @@ -1,9 +1,9 @@ -/** - * Converts a value to a finite number, returning undefined if the value is not finite. - * - * @param value - The value to convert to a finite number - * @returns The finite number value, or undefined if the value is not finite - */ -export function toFiniteNumber(value: number | undefined): number | undefined { - return Number.isFinite(value) ? value : undefined; -} +/** @deprecated Import from `@cornerstonejs/utils` instead. */ +import { + toNumber, + toFiniteNumber, + toNumber as toNumberDefault, +} from '@cornerstonejs/utils'; + +export { toNumber, toFiniteNumber }; +export default toNumberDefault; diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/core/src/webWorkerManager/webWorkerManager.ts b/packages/core/src/webWorkerManager/webWorkerManager.ts index 3c9f19c68d..9193eee31f 100644 --- a/packages/core/src/webWorkerManager/webWorkerManager.ts +++ b/packages/core/src/webWorkerManager/webWorkerManager.ts @@ -1,6 +1,9 @@ import * as Comlink from 'comlink'; import { RequestType } from '../enums'; import { RequestPoolManager } from '../requestPool/requestPoolManager'; +import { workerLog } from '../utilities/logger'; + +const registrationLog = workerLog.getLogger('registration'); export type WebWorkerManagerOptions = { /** @@ -69,7 +72,9 @@ class CentralizedWorkerManager { } = options; if (this.workerRegistry[workerName] && !overwrite) { - console.warn(`Worker type '${workerName}' is already registered...`); + registrationLog.warn( + `Worker type '${workerName}' is already registered...` + ); return; } diff --git a/packages/core/test/dicomImageLoader_wadors_test.js b/packages/core/test/dicomImageLoader_wadors_test.js index bbd0877ff7..b28a70db1c 100644 --- a/packages/core/test/dicomImageLoader_wadors_test.js +++ b/packages/core/test/dicomImageLoader_wadors_test.js @@ -30,7 +30,7 @@ const WADO_RS_TESTS = [ describe('dicomImageLoader - WADO-RS', () => { beforeEach(() => { wadors.register(); - dicomImageLoaderInit(); + dicomImageLoaderInit({ useLegacyMetadataProvider: true }); }); afterEach(() => { diff --git a/packages/core/test/dicomImageLoader_wadouri_test.js b/packages/core/test/dicomImageLoader_wadouri_test.js index eb41e66121..f7aaba6261 100644 --- a/packages/core/test/dicomImageLoader_wadouri_test.js +++ b/packages/core/test/dicomImageLoader_wadouri_test.js @@ -1,6 +1,12 @@ // @ts-check -import { cache, Enums, imageLoader, metaData } from '@cornerstonejs/core'; +import { + cache, + Enums, + imageLoader, + metaData, + utilities, +} from '@cornerstonejs/core'; import { init as dicomImageLoaderInit, wadouri, @@ -60,7 +66,7 @@ const tests = [ ]; /** - * These are paramaterized tests for dicomImageLoader. It allows us to test + * These paramaterized tests for dicomImageLoader. It allows us to test * that different images are loaded correctly, and that the metadata returned by * the loader is as expected. * @@ -72,12 +78,22 @@ const tests = [ * image object. * 3. Retrieving metadata modules and comparing them with expected metadata * modules. + * + * Notes: + * - "Worker type 'dicomImageLoader' is already registered" appears because + * beforeEach calls dicomImageLoaderInit() every test; the worker is registered + * once and not unregistered in afterEach, so subsequent tests see the warning. + * - The NATURAL path (loadImageFromNatural) uses dcmjs stream + COMPRESSED_FRAME_DATA + * from NATURAL. NATURAL is stored under the base imageId (frame stripped) so registration + * happens once per URL. Pixel data is resolved from PixelData, FloatPixelData, or hex tags + * (7FE0,0010) / (7FE0,0008) so paramap and paramap-float work on the default path. */ describe('dicomImageLoader - WADO-URI', () => { beforeEach(() => { - // register the wadouri loader + // Suppress "Worker type 'dicomImageLoader' is already registered" in tests + utilities.logger.workerLog.setLevel('error'); + // register the wadouri loader and default (NATURAL) path wadouri.register(); - // re-initialise the loader before each test to clear any previous config dicomImageLoaderInit(); }); @@ -151,7 +167,7 @@ describe('dicomImageLoader - WADO-URI', () => { expect(onprogressSpy).toHaveBeenCalled(); - expect(onreadystatechangeSpy).toHaveBeenCalledTimes(3); + expect(onreadystatechangeSpy.calls.count()).toBeGreaterThanOrEqual(3); expect(onreadystatechangeSpy.calls.argsFor(0)).toEqual([ jasmine.any(Event), expectedLoaderParams, @@ -163,8 +179,98 @@ describe('dicomImageLoader - WADO-URI', () => { ); }); + describe('legacy loader', () => { + beforeEach(() => { + wadouri.dataSetCacheManager.purge(); + cache.purgeCache(); + imageLoader.unregisterAllImageLoaders(); + wadouri.register(); + dicomImageLoaderInit({ useLegacyMetadataProvider: true }); + }); + + afterEach(() => { + wadouri.dataSetCacheManager.purge(); + cache.purgeCache(); + imageLoader.unregisterAllImageLoaders(); + }); + + it('should allow customising the http request with beforeSend', async () => { + const test = CtLittleEndian_1_2_840_10008_1_2; + const beforeSpy = jasmine.createSpy('beforeHandler').and.resolveTo(); + dicomImageLoaderInit({ + useLegacyMetadataProvider: true, + beforeSend: beforeSpy, + }); + await imageLoader.loadImage(test.wadouri); + const expectedHeaders = {}; + const expectedImageId = test.wadouri; + const expectedUrl = test.wadouri.replace('wadouri:', ''); + expect(beforeSpy).toHaveBeenCalledWith( + jasmine.any(XMLHttpRequest), + expectedImageId, + expectedHeaders, + { + url: expectedUrl, + deferred: { + resolve: jasmine.any(Function), + reject: jasmine.any(Function), + }, + imageId: expectedImageId, + } + ); + }); + + it('should call request lifecycle callbacks', async () => { + const test = CtLittleEndian_1_2_840_10008_1_2; + const onreadystatechangeSpy = jasmine.createSpy('onreadystatechange'); + const onprogressSpy = jasmine.createSpy('onprogress'); + const onloadendSpy = jasmine.createSpy('onloadend'); + const onloadstartSpy = jasmine.createSpy('onloadstart'); + dicomImageLoaderInit({ + useLegacyMetadataProvider: true, + onreadystatechange: onreadystatechangeSpy, + onprogress: onprogressSpy, + onloadend: onloadendSpy, + onloadstart: onloadstartSpy, + }); + await imageLoader.loadImage(test.wadouri); + const expectedImageId = test.wadouri; + const expectedUrl = test.wadouri.replace('wadouri:', ''); + const expectedLoaderParams = { + url: expectedUrl, + deferred: { + resolve: jasmine.any(Function), + reject: jasmine.any(Function), + }, + imageId: expectedImageId, + }; + expect(onloadstartSpy).toHaveBeenCalledOnceWith( + jasmine.any(Event), + expectedLoaderParams + ); + expect(onprogressSpy).toHaveBeenCalled(); + expect(onreadystatechangeSpy.calls.count()).toBeGreaterThanOrEqual(3); + expect(onloadendSpy).toHaveBeenCalledOnceWith( + jasmine.any(Event), + expectedLoaderParams + ); + }); + }); + for (const t of tests) { + if (!t.frames.find((frame) => frame.pixelDataHash || frame.image)) { + console.log( + `Skipping ${t.name} because it has no pixel data or image tests` + ); + continue; + } + if (t.name.indexOf('No Pixel Spacing') === -1) { + continue; + } describe(t.name, () => { + beforeEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); for (const frame of t.frames) { // Determine the frame to use (default to 1 if not specified) const frameIndex = frame.index || 1; @@ -184,11 +290,23 @@ describe('dicomImageLoader - WADO-URI', () => { ); const hash = await createImageHash(image.getPixelData()); + // No Pixel Spacing: dump decoded pixel data as PNG data URL for visual inspection (paste in browser). + if (t.name === 'No Pixel Spacing') { + const redPalette = + image.imageFrame?.redPaletteColorLookupTableData; + expect(redPalette).toBeDefined(); + const firstN = NO_PIXEL_SPACING_RED_PALETTE_FIRST_VALUES.length; + const actualFirst = Array.from(redPalette).slice(0, firstN); + expect(actualFirst).toEqual( + NO_PIXEL_SPACING_RED_PALETTE_FIRST_VALUES + ); + } + expect(hash).toBe(frame.pixelDataHash); }); } - if ('image' in frame && frame.image) { + if (frame.image) { it(`returns the correct image object for ${frameIndex} of the ${t.name} image`, async () => { // first load the image without the frame so that it is loaded into // the cache @@ -202,31 +320,59 @@ describe('dicomImageLoader - WADO-URI', () => { expect(imagObj).toEqual(frame.image); }); } + } + }); + } - // WADO-RS Loader Tests - if (frame.metadataModule) { - for (const [ - metadataModuleName, - expectedModuleValues, - ] of Object.entries(frame.metadataModule)) { - it(`returns the correct ${metadataModuleName} metadata for frame ${frameIndex} of ${t.name} image`, async () => { - const { imageId } = await imageLoader.loadImage(t.wadouri); - const imageIdWithFrameIndex = imageIdWithFrame( - imageId, - frameIndex - ); - const actualModuleValue = metaData.get( - metadataModuleName, - imageIdWithFrameIndex - ); + // Metadata module tests use the legacy provider (dataset-based metadata). + // Specific legacy handling is not being moved forward to the NATURAL path. + describe('legacy loader metadata modules', () => { + beforeEach(() => { + wadouri.dataSetCacheManager.purge(); + cache.purgeCache(); + imageLoader.unregisterAllImageLoaders(); + wadouri.register(); + dicomImageLoaderInit({ useLegacyMetadataProvider: true }); + }); - expect(actualModuleValue).toEqual(expectedModuleValues); - }); + afterEach(() => { + wadouri.dataSetCacheManager.purge(); + cache.purgeCache(); + imageLoader.unregisterAllImageLoaders(); + }); + + for (const t of tests) { + if (!t.frames.some((frame) => frame.metadataModule)) { + continue; + } + describe(t.name, () => { + for (const frame of t.frames) { + const frameIndex = frame.index || 1; + + if (frame.metadataModule) { + for (const [ + metadataModuleName, + expectedModuleValues, + ] of Object.entries(frame.metadataModule)) { + it(`returns the correct ${metadataModuleName} metadata for frame ${frameIndex} of ${t.name} image`, async () => { + const { imageId } = await imageLoader.loadImage(t.wadouri); + const imageIdWithFrameIndex = imageIdWithFrame( + imageId, + frameIndex + ); + const actualModuleValue = metaData.get( + metadataModuleName, + imageIdWithFrameIndex + ); + + expect(actualModuleValue).toEqual(expectedModuleValues); + }); + } } } - } - }); - } + }); + } + }); describe('multiframe images', () => { it('returns ImagePlaneModule metadata for each frame', async () => { @@ -309,3 +455,18 @@ describe('dicomImageLoader - WADO-URI', () => { function imageIdWithFrame(imageId, frame) { return `${imageId}?frame=${frame}`; } + +/** + * First 80 values of RedPaletteColorLookupTableData (0028,1201) for no-pixel-spacing.dcm. + * DICOM OW #512: 16-bit; negatives as unsigned 16-bit for Uint16Array comparison. + */ +const NO_PIXEL_SPACING_RED_PALETTE_FIRST_VALUES = [ + 0, 256, 256, 256, 256, 256, 512, 512, 512, 768, 768, 768, 768, 1024, 1024, + 1280, 1280, 1280, 1280, 1280, 1536, 1536, 1536, 1536, 1792, 1792, 1792, 2048, + 2304, 2304, 2304, 2304, 2816, 2816, 2816, 3072, 3072, 3072, 3328, 3328, 3584, + 3840, 3840, 3840, 4096, 4352, 4352, 4352, 4608, 4864, 4864, 5120, 5376, 5376, + 5632, 5632, 5888, 5888, 6144, 6400, 6656, 6656, 6912, 7168, 7168, 7424, 7680, + 7680, 8192, 8192, 8448, 8704, 8704, 9216, 9216, 9472, 9728, 9984, 10240, + 10496, 10496, 10752, 11008, 11264, 11520, 12032, 12032, 12544, 12544, 12800, + 13056, 13312, 13568, 13824, 14336, 14336, +]; diff --git a/packages/core/test/metaDataProvider_test.js b/packages/core/test/metaDataProvider_test.js index 3af68d7933..1258aa9b58 100644 --- a/packages/core/test/metaDataProvider_test.js +++ b/packages/core/test/metaDataProvider_test.js @@ -95,7 +95,7 @@ describe('metaData Provider', function () { // metaData.addProvider(metadataProvider1); metaData.addProvider(metadataProvider2, 100); - metaData.addProvider(metadataProvider1, 1000); + metaData.addProvider(metadataProvider1.bind(), 1000); const result = metaData.get('modalityLutModule', 'imageId'); expect(result.rescaleSlope).toBe(1); diff --git a/packages/core/test/stackViewport_node_render.jest.js b/packages/core/test/stackViewport_node_render.jest.js index 52992b964c..3175697d65 100644 --- a/packages/core/test/stackViewport_node_render.jest.js +++ b/packages/core/test/stackViewport_node_render.jest.js @@ -6,7 +6,6 @@ import { import { describe, it, expect } from '@jest/globals'; import { render } from 'react-dom'; -import { createCanvas } from 'canvas'; const { utilities, @@ -16,7 +15,6 @@ const { imageLoader, metaData, init, - setCanvasCreator, } = cornerstone3D; const { ViewportType, Events } = Enums; @@ -75,11 +73,6 @@ function initCore() { } describe('stackViewport_node_render', function () { - beforeEach(() => { - // TODO - enable this once the full path works - // setCanvasCreator(createCanvas); - }); - let viewport, element, renderingEngine; beforeEach(() => { diff --git a/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js b/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js index a618a88786..506f0b4422 100644 --- a/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js +++ b/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js @@ -2,7 +2,7 @@ import { handleMultiframe4D, generateFrameImageId, } from '../../src/utilities/splitImageIdsBy4DTags'; -import * as metaData from '../../src/metaData'; +import { metaData } from '@cornerstonejs/metadata'; import { describe, it, @@ -13,18 +13,16 @@ import { } from '@jest/globals'; describe('splitImageIdsBy4DTags - Multiframe 4D Functions', () => { - let originalMetaDataGet; let mockMetaDataGet; beforeEach(() => { - originalMetaDataGet = metaData.get; - mockMetaDataGet = jest.fn(); - metaData.get = mockMetaDataGet; + mockMetaDataGet = jest + .spyOn(metaData, 'get') + .mockImplementation(() => undefined); }); afterEach(() => { - metaData.get = originalMetaDataGet; - jest.clearAllMocks(); + jest.restoreAllMocks(); }); describe('generateFrameImageId', () => { diff --git a/packages/dicomImageLoader/CHANGELOG.md b/packages/dicomImageLoader/CHANGELOG.md index 503e86f9a6..412bec26d4 100644 --- a/packages/dicomImageLoader/CHANGELOG.md +++ b/packages/dicomImageLoader/CHANGELOG.md @@ -1,4 +1,4 @@ -# Change Log +# Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. @@ -11,25 +11,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/dicom-image-loader -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) **Note:** Version bump only for package @cornerstonejs/dicom-image-loader -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/dicom-image-loader - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/dicom-image-loader - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) - -**Note:** Version bump only for package @cornerstonejs/dicom-image-loader - -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/dicom-image-loader +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) @@ -2352,7 +2338,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Features -- **ultrasound:** add ULTRASOUND_ENHANCED_REGION and CALIBRATION to wadouri metadata pr… ([#1358](https://github.com/cornerstonejs/cornerstone3D/issues/1358)) ([17a4e12](https://github.com/cornerstonejs/cornerstone3D/commit/17a4e12128c8e67a46c02c80cd47b07f6e563829)) +- **ultrasound:** add ULTRASOUND_ENHANCED_REGION and CALIBRATION to wadouri metadata pr… ([#1358](https://github.com/cornerstonejs/cornerstone3D/issues/1358)) ([17a4e12](https://github.com/cornerstonejs/cornerstone3D/commit/17a4e12128c8e67a46c02c80cd47b07f6e563829)) ## [1.80.4](https://github.com/cornerstonejs/cornerstone3D/compare/v1.80.3...v1.80.4) (2024-06-27) @@ -3719,7 +3705,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Features -- 🎸 Allow optional in-worker scaling and buffer redirection ([c504bb1](https://github.com/cornerstonejs/cornerstone3D-beta/commit/c504bb16ee150d3e616a2fd9966fe8275163d28a)) +- 🎸 Allow optional in-worker scaling and buffer redirection ([c504bb1](https://github.com/cornerstonejs/cornerstone3D-beta/commit/c504bb16ee150d3e616a2fd9966fe8275163d28a)) - add 16 bit data type scale under a decode flag ([#501](https://github.com/cornerstonejs/cornerstone3D-beta/issues/501)) ([1b47073](https://github.com/cornerstonejs/cornerstone3D-beta/commit/1b47073d8f526dc49d9cdcfd57a42c691e2969d2)) - Add beforeProcessing hook to WADO-URI XMLHttpRequest ([#338](https://github.com/cornerstonejs/cornerstone3D-beta/issues/338)) ([43dbacb](https://github.com/cornerstonejs/cornerstone3D-beta/commit/43dbacbd11fd9388355f48cf6daabc1e391acde9)) - Add HTJ2K - release attempt ([c65218e](https://github.com/cornerstonejs/cornerstone3D-beta/commit/c65218ef61bb54e7afa64d7e1600ac91e0ef9def)) @@ -3940,7 +3926,7 @@ Co-authored-by: Alireza ### Features -- 🎸 Allow optional in-worker scaling and buffer redirection ([c504bb1](https://github.com/cornerstonejs/cornerstone3D-beta/commit/c504bb16ee150d3e616a2fd9966fe8275163d28a)) +- 🎸 Allow optional in-worker scaling and buffer redirection ([c504bb1](https://github.com/cornerstonejs/cornerstone3D-beta/commit/c504bb16ee150d3e616a2fd9966fe8275163d28a)) - add 16 bit data type scale under a decode flag ([#501](https://github.com/cornerstonejs/cornerstone3D-beta/issues/501)) ([1b47073](https://github.com/cornerstonejs/cornerstone3D-beta/commit/1b47073d8f526dc49d9cdcfd57a42c691e2969d2)) - Add beforeProcessing hook to WADO-URI XMLHttpRequest ([#338](https://github.com/cornerstonejs/cornerstone3D-beta/issues/338)) ([43dbacb](https://github.com/cornerstonejs/cornerstone3D-beta/commit/43dbacbd11fd9388355f48cf6daabc1e391acde9)) - Add HTJ2K - release attempt ([c65218e](https://github.com/cornerstonejs/cornerstone3D-beta/commit/c65218ef61bb54e7afa64d7e1600ac91e0ef9def)) diff --git a/packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/index.ts b/packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/index.ts index 3700162ed7..4dcaba00bb 100644 --- a/packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/index.ts +++ b/packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/index.ts @@ -6,6 +6,7 @@ import { setUseCPURendering, setPreferSizeOverAccuracy, cache, + metaData, } from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; import uids from '../uids'; @@ -152,89 +153,119 @@ async function loadAndViewImage(imageId) { viewport.render(); const image = viewport.csImage; + if (!image) { + console.error('Image failed to load'); + return; + } + + // Use metadata API with imageId instead of direct dataset access + const { MetadataModules } = Enums; + const transferSyntaxMeta = metaData.get( + MetadataModules.TRANSFER_SYNTAX, + imageId + ); + const sopCommonMeta = metaData.get(MetadataModules.SOP_COMMON, imageId); + const imagePixelMeta = metaData.get(MetadataModules.IMAGE_PIXEL, imageId); + const generalImageMeta = metaData.get( + MetadataModules.GENERAL_IMAGE, + imageId + ); + const voiLutMeta = metaData.get(MetadataModules.VOI_LUT, imageId); + const modalityLutMeta = metaData.get( + MetadataModules.MODALITY_LUT, + imageId + ); + const compressedFrameData = metaData.getMetaData( + MetadataModules.COMPRESSED_FRAME_DATA, + imageId, + { frameIndex: 0 } + ); function getTransferSyntax() { - const value = image.data.string('x00020010'); - return value + ' [' + uids[value] + ']'; + const value = + transferSyntaxMeta?.transferSyntaxUID ?? + transferSyntaxMeta?.transferSyntaxUid; + if (value == null) return ''; + return value + ' [' + (uids[value] ?? '') + ']'; } function getSopClass() { - const value = image.data.string('x00080016'); - return value + ' [' + uids[value] + ']'; + const value = sopCommonMeta?.sopClassUid; + if (value == null) return ''; + return value + ' [' + (uids[value] ?? '') + ']'; } function getPixelRepresentation() { - const value = image.data.uint16('x00280103'); - if (value === undefined) { - return; - } + const value = imagePixelMeta?.pixelRepresentation; + if (value === undefined) return ''; return value + (value === 0 ? ' (unsigned)' : ' (signed)'); } function getPlanarConfiguration() { - const value = image.data.uint16('x00280006'); - if (value === undefined) { - return; - } + const value = imagePixelMeta?.planarConfiguration; + if (value === undefined) return ''; return value + (value === 0 ? ' (pixel)' : ' (plane)'); } + // basicOffsetTable is not exposed via metadata API; show empty when using metadata + const basicOffsetTableText = ''; + const fragmentsText = + compressedFrameData?.pixelData != null + ? Array.isArray(compressedFrameData.pixelData) + ? String(compressedFrameData.pixelData.length) + : '1' + : ''; + document.getElementById('transferSyntax').textContent = getTransferSyntax(); document.getElementById('sopClass').textContent = getSopClass(); document.getElementById('samplesPerPixel').textContent = - image.data.uint16('x00280002'); + imagePixelMeta?.samplesPerPixel ?? ''; document.getElementById('photometricInterpretation').textContent = - image.data.string('x00280004'); + imagePixelMeta?.photometricInterpretation ?? ''; document.getElementById('numberOfFrames').textContent = - image.data.string('x00280008'); + generalImageMeta?.numberOfFrames ?? ''; document.getElementById('planarConfiguration').textContent = getPlanarConfiguration(); - document.getElementById('rows').textContent = - image.data.uint16('x00280010'); + document.getElementById('rows').textContent = imagePixelMeta?.rows ?? ''; document.getElementById('columns').textContent = - image.data.uint16('x00280011'); + imagePixelMeta?.columns ?? ''; document.getElementById('pixelSpacing').textContent = - image.data.string('x00280030'); + generalImageMeta?.pixelSpacing ?? ''; document.getElementById('rowPixelSpacing').textContent = - image.rowPixelSpacing; + image.rowPixelSpacing ?? ''; document.getElementById('columnPixelSpacing').textContent = - image.columnPixelSpacing; + image.columnPixelSpacing ?? ''; document.getElementById('bitsAllocated').textContent = - image.data.uint16('x00280100'); + imagePixelMeta?.bitsAllocated ?? ''; document.getElementById('bitsStored').textContent = - image.data.uint16('x00280101'); + imagePixelMeta?.bitsStored ?? ''; document.getElementById('highBit').textContent = - image.data.uint16('x00280102'); + imagePixelMeta?.highBit ?? ''; document.getElementById('pixelRepresentation').textContent = getPixelRepresentation(); document.getElementById('windowCenter').textContent = - image.data.string('x00281050'); + voiLutMeta?.windowCenter ?? ''; document.getElementById('windowWidth').textContent = - image.data.string('x00281051'); + voiLutMeta?.windowWidth ?? ''; document.getElementById('rescaleIntercept').textContent = - image.data.string('x00281052'); + modalityLutMeta?.rescaleIntercept ?? ''; document.getElementById('rescaleSlope').textContent = - image.data.string('x00281053'); - document.getElementById('basicOffsetTable').textContent = image.data - .elements.x7fe00010.basicOffsetTable - ? image.data.elements.x7fe00010.basicOffsetTable.length - : ''; - document.getElementById('fragments').textContent = image.data.elements - .x7fe00010.fragments - ? image.data.elements.x7fe00010.fragments.length - : ''; + modalityLutMeta?.rescaleSlope ?? ''; + document.getElementById('basicOffsetTable').textContent = + basicOffsetTableText; + document.getElementById('fragments').textContent = fragmentsText; document.getElementById('minStoredPixelValue').textContent = - image.minPixelValue; + image.minPixelValue ?? ''; document.getElementById('maxStoredPixelValue').textContent = - image.maxPixelValue; + image.maxPixelValue ?? ''; const end = new Date().getTime(); const time = end - start; document.getElementById('totalTime').textContent = time + 'ms'; document.getElementById('loadTime').textContent = - image.loadTimeInMS + 'ms'; + image.loadTimeInMS != null ? image.loadTimeInMS + 'ms' : ''; document.getElementById('decodeTime').textContent = - image.decodeTimeInMS + 'ms'; + image.decodeTimeInMS != null ? image.decodeTimeInMS + 'ms' : ''; }, function (err) { throw err; diff --git a/packages/dicomImageLoader/karma.conf.js b/packages/dicomImageLoader/karma.conf.js index eb68189e9b..a56fd3cb12 100644 --- a/packages/dicomImageLoader/karma.conf.js +++ b/packages/dicomImageLoader/karma.conf.js @@ -194,6 +194,7 @@ module.exports = function (config) { '@cornerstonejs/dicomImageLoader': path.resolve( 'packages/dicomImageLoader/src/imageLoader/index' ), + '@cornerstonejs/utils': path.resolve('packages/utils/src/index'), }, }, }, diff --git a/packages/dicomImageLoader/package.json b/packages/dicomImageLoader/package.json index c3cc286824..d9782e1237 100644 --- a/packages/dicomImageLoader/package.json +++ b/packages/dicomImageLoader/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/dicom-image-loader", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "Cornerstone Image Loader for DICOM WADO-URI and WADO-RS and Local file", "keywords": [ "DICOM", @@ -66,6 +66,7 @@ }, "scripts": { "build:loader": "yarn run build:all && yarn run copy-dts", + "build": "yarn run build:esm", "build:esm": "tsc --project ./tsconfig.json", "build:esm:watch": "tsc --project ./tsconfig.json --watch", "build:umd:dynamic": "cross-env NODE_ENV=production webpack --config .webpack/webpack-dynamic-import.js", @@ -111,13 +112,15 @@ "pako": "2.1.0", "uuid": "9.0.1" }, + "devDependencies": { + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/metadata": "5.0.0-beta.2" + }, "peerDependencies": { - "@cornerstonejs/core": "4.22.9", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/metadata": ">=5.0.0-beta.1 <6.0.0-0", "dicom-parser": "1.8.21" }, - "devDependencies": { - "@cornerstonejs/core": "4.22.9" - }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" diff --git a/packages/dicomImageLoader/src/decodeImageFrameWorker.js b/packages/dicomImageLoader/src/decodeImageFrameWorker.js index ea41912fff..144420f5ae 100644 --- a/packages/dicomImageLoader/src/decodeImageFrameWorker.js +++ b/packages/dicomImageLoader/src/decodeImageFrameWorker.js @@ -151,11 +151,7 @@ export function postProcessDecodedPixels( maxAfterScale = scaledValues.max; } } else if (disableScale) { - imageFrame.preScale = { - enabled: true, - scaled: false, - }; - + // Do not set imageFrame.preScale when scaling is disabled (e.g. identity slope/intercept). minAfterScale = minBeforeScale; maxAfterScale = maxBeforeScale; } diff --git a/packages/dicomImageLoader/src/imageLoader/colorSpaceConverters/convertPALETTECOLOR.ts b/packages/dicomImageLoader/src/imageLoader/colorSpaceConverters/convertPALETTECOLOR.ts index 84b7fa5ad7..81c1696251 100644 --- a/packages/dicomImageLoader/src/imageLoader/colorSpaceConverters/convertPALETTECOLOR.ts +++ b/packages/dicomImageLoader/src/imageLoader/colorSpaceConverters/convertPALETTECOLOR.ts @@ -46,7 +46,9 @@ export default function convertPaletteColor( const start = imageFrame.redPaletteColorLookupTableDescriptor[1]; const bitsStored = imageFrame.redPaletteColorLookupTableDescriptor[2]; - const shift = bitsStored > 8 || rData.some((num) => num > 255) ? 8 : 0; + // Only shift down when descriptor says 16-bit and LUT actually has values > 255. + // If LUT is 8-bit (all values ≤255) but descriptor says 16-bit, use shift 0 to avoid zeroing. + const shift = bitsStored > 8 && rData.some((num) => num > 255) ? 8 : 0; const rDataCleaned = convertLUTto8Bit(rData, shift); const gDataCleaned = convertLUTto8Bit(gData, shift); diff --git a/packages/dicomImageLoader/src/imageLoader/createImage.ts b/packages/dicomImageLoader/src/imageLoader/createImage.ts index 62d46cf315..5740810f1d 100644 --- a/packages/dicomImageLoader/src/imageLoader/createImage.ts +++ b/packages/dicomImageLoader/src/imageLoader/createImage.ts @@ -53,6 +53,15 @@ async function createImage( options.allowFloatRendering = canRenderFloatTextures(); let redData, greenData, blueData; + // Capture palette descriptors before decode (worker may not return them). + const paletteDescriptors = + imageFrame.photometricInterpretation === 'PALETTE COLOR' + ? { + red: imageFrame.redPaletteColorLookupTableDescriptor, + green: imageFrame.greenPaletteColorLookupTableDescriptor, + blue: imageFrame.bluePaletteColorLookupTableDescriptor, + } + : null; // For PALETTE COLOR images, ensure palette bulkdata is loaded before decoding if (imageFrame.photometricInterpretation === 'PALETTE COLOR') { [redData, greenData, blueData] = await Promise.all([ @@ -71,6 +80,9 @@ async function createImage( ...options.preScale, scalingParameters: scalingParameters as Types.ScalingParameters, }; + } else { + // Identity transform (slope 1, intercept 0) or no LUT: treat as non-prescaled so worker does not use scalingParameters. + options.preScale.enabled = false; } } @@ -176,8 +188,61 @@ async function createImage( const { rows, columns } = imageFrame; // For PALETTE COLOR images, assign palette bulkdata after decoding - // to avoid copying unnecessary memory to/from the worker - if (imageFrame.photometricInterpretation === 'PALETTE COLOR') { + // to avoid copying unnecessary memory to/from the worker. + // Defensive normalization: NATURAL has e.g. [ArrayBuffer(512)]; the chain + // may pass that through or expose Uint8Array(512). Reinterpret as + // Uint16Array(256) when descriptor says 16-bit LUT. + if ( + imageFrame.photometricInterpretation === 'PALETTE COLOR' && + paletteDescriptors + ) { + const normalizeLutIfBytes = ( + data: + | ArrayBufferView + | ArrayBuffer + | (ArrayBuffer | ArrayBufferView)[] + | null + | undefined, + descriptor: number[] | undefined + ): ArrayBufferView | null | undefined => { + if (data == null || !descriptor || descriptor.length < 3) + return data as ArrayBufferView | null | undefined; + const tableLen = descriptor[0]; + const bits = descriptor[2]; + if (bits !== 16 || tableLen <= 0) + return data as ArrayBufferView | null | undefined; + const expectedBytes = tableLen * 2; + let view: ArrayBufferView | null = null; + if (Array.isArray(data) && data.length > 0) { + const first = data[0]; + if (first instanceof ArrayBuffer) { + view = new Uint8Array(first); + } else if (ArrayBuffer.isView(first)) { + view = first; + } + } else if (data instanceof ArrayBuffer) { + view = new Uint8Array(data); + } else if (ArrayBuffer.isView(data)) { + view = data; + } + if (view && view.byteLength === expectedBytes) { + return new Uint16Array(view.buffer, view.byteOffset, tableLen); + } + return data as ArrayBufferView | null | undefined; + }; + imageFrame.redPaletteColorLookupTableData = normalizeLutIfBytes( + redData, + paletteDescriptors.red + ) as typeof redData; + imageFrame.greenPaletteColorLookupTableData = normalizeLutIfBytes( + greenData, + paletteDescriptors.green + ) as typeof greenData; + imageFrame.bluePaletteColorLookupTableData = normalizeLutIfBytes( + blueData, + paletteDescriptors.blue + ) as typeof blueData; + } else if (imageFrame.photometricInterpretation === 'PALETTE COLOR') { imageFrame.redPaletteColorLookupTableData = redData; imageFrame.greenPaletteColorLookupTableData = greenData; imageFrame.bluePaletteColorLookupTableData = blueData; @@ -202,6 +267,63 @@ async function createImage( ), }; } + // Debug PALETTE COLOR: log descriptor, relative lengths, and first LUT entries before conversion + if (imageFrame.photometricInterpretation === 'PALETTE COLOR') { + const pd = imageFrame.pixelData; + const len = pd?.length ?? 0; + const sliceSize = Math.min(40, len); + const r = imageFrame.redPaletteColorLookupTableData; + const g = imageFrame.greenPaletteColorLookupTableData; + const b = imageFrame.bluePaletteColorLookupTableData; + const desc = imageFrame.redPaletteColorLookupTableDescriptor; + const lutLen = (x: unknown) => + x != null && + typeof (x as { length?: number }).length === 'number' + ? (x as { length: number }).length + : null; + const lutByteLen = (x: unknown) => { + if (x == null) return null; + if (x instanceof ArrayBuffer) return x.byteLength; + if (ArrayBuffer.isView(x)) + return (x as ArrayBufferView).byteLength; + return null; + }; + const first10 = (x: unknown) => + x != null && + typeof (x as { length?: number }).length === 'number' + ? Array.from(x as ArrayLike).slice(0, 10) + : null; + console.log( + '[createImage] PALETTE COLOR before convertColorSpace', + { + imageId, + descriptor: desc, + pixelDataLength: len, + pixelDataSlice: + sliceSize > 0 && pd + ? Array.from( + { length: sliceSize }, + (_, i) => (pd as ArrayLike)[i] + ) + : [], + redLUT: { + length: lutLen(r), + byteLength: lutByteLen(r), + first10: first10(r), + }, + greenLUT: { + length: lutLen(g), + byteLength: lutByteLen(g), + first10: first10(g), + }, + blueLUT: { + length: lutLen(b), + byteLength: lutByteLen(b), + first10: first10(b), + }, + } + ); + } convertColorSpace(imageFrame, imageData.data, useRGBA); imageFrame.imageData = imageData; imageFrame.pixelData = imageData.data; @@ -283,9 +405,11 @@ async function createImage( windowWidth: voiLutModule.windowWidth ? voiLutModule.windowWidth[0] : undefined, - voiLUTFunction: voiLutModule.voiLUTFunction - ? voiLutModule.voiLUTFunction - : undefined, + voiLUTFunction: + (voiLutModule.voiLUTFunction?.length && + voiLutModule.voiLUTFunction[0]) || + voiLutModule.voiLutFunction || + undefined, decodeTimeInMS: imageFrame.decodeTimeInMS, floatPixelData: undefined, imageFrame, diff --git a/packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts b/packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts index 148139e193..25040e9f9c 100644 --- a/packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts +++ b/packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts @@ -37,8 +37,7 @@ const instanceModuleNames = [ 'multiframeModule', 'generalSeriesModule', 'patientStudyModule', - 'imagePlaneModule', - 'nmMultiframeGeometryModule', + // 'imagePlaneModule', 'imagePixelModule', 'modalityLutModule', 'voiLutModule', diff --git a/packages/dicomImageLoader/src/imageLoader/getScalingParameters.ts b/packages/dicomImageLoader/src/imageLoader/getScalingParameters.ts index 77e2f4d20b..98c135b142 100644 --- a/packages/dicomImageLoader/src/imageLoader/getScalingParameters.ts +++ b/packages/dicomImageLoader/src/imageLoader/getScalingParameters.ts @@ -15,9 +15,20 @@ export default function getScalingParameters(metaData, imageId: string) { const { modality } = generalSeriesModule; + const rescaleSlope = modalityLutModule.rescaleSlope; + const rescaleIntercept = modalityLutModule.rescaleIntercept; + + // Identity transform (slope 1, intercept 0) is implicitly non-prescaled; do not set preScale. + if ( + rescaleSlope === 1 && + (rescaleIntercept === 0 || rescaleIntercept == null) + ) { + return undefined; + } + const scalingParameters = { - rescaleSlope: modalityLutModule.rescaleSlope, - rescaleIntercept: modalityLutModule.rescaleIntercept, + rescaleSlope, + rescaleIntercept, modality, }; diff --git a/packages/dicomImageLoader/src/imageLoader/registerLoaders.ts b/packages/dicomImageLoader/src/imageLoader/registerLoaders.ts index 24a112a34a..4dff9d921f 100644 --- a/packages/dicomImageLoader/src/imageLoader/registerLoaders.ts +++ b/packages/dicomImageLoader/src/imageLoader/registerLoaders.ts @@ -1,15 +1,29 @@ +import { cache } from '@cornerstonejs/core'; +import { utilities, metaData } from '@cornerstonejs/metadata'; + +import dataSetCacheManager from './wadouri/dataSetCacheManager'; import wadouriRegister from './wadouri/register'; import wadorsRegister from './wadors/register'; /** - * Register the WADO-URI and WADO-RS image loaders and metaData providers - * with an instance of Cornerstone Core. + * Register the WADO-URI and WADO-RS image loaders. + * On each call (e.g. re-init), clears all relevant caches and providers so + * the new registration is used consistently (ensures lifecycle and loader + * tests see fresh requests for both legacy and NATURAL paths). * - * @param cornerstone The Cornerstone Core library to register the image loaders with + * @param options.useLegacyMetadataProvider - When true, registers the + * legacy wadouri/wadors metadata providers. Default is false (new design). */ -function registerLoaders(): void { - wadorsRegister(); - wadouriRegister(); +function registerLoaders(options?: { + useLegacyMetadataProvider?: boolean; +}): void { + cache.purgeCache(); + dataSetCacheManager.purge(); + utilities.clearCacheData(); + metaData.removeAllProviders(); + + wadorsRegister(options); + wadouriRegister(options); } export default registerLoaders; diff --git a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts index 9224a51f15..16991b768d 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts @@ -22,8 +22,10 @@ import { getImageTypeSubItemFromMetadata } from './NMHelpers'; import isNMReconstructable from '../../isNMReconstructable'; import { instanceModuleNames } from '../../getInstanceModule'; import { getUSEnhancedRegions } from './USHelpers'; -import { getECGModule } from './ECGHelpers'; +/** + * @deprecated Use addDicomWebInstance from @cornerstonejs/metadata instead. + */ function metaDataProvider(type, imageId) { const { MetadataModules } = Enums; @@ -206,22 +208,6 @@ function metaDataProvider(type, imageId) { return getUSEnhancedRegions(metaData); } - if (type === MetadataModules.ECG) { - // Extract wadoRsRoot and studyUID from the imageId for BulkDataURI resolution - const imageUri = imageId.replace('wadors:', ''); - const studiesIndex = imageUri.indexOf('/studies/'); - let wadoRsRoot: string | undefined; - let studyUID: string | undefined; - if (studiesIndex !== -1) { - wadoRsRoot = imageUri.substring(0, studiesIndex); - const afterStudies = imageUri.substring(studiesIndex + 9); - const nextSlash = afterStudies.indexOf('/'); - studyUID = - nextSlash !== -1 ? afterStudies.substring(0, nextSlash) : afterStudies; - } - return getECGModule(metaData, wadoRsRoot, studyUID); - } - if (type === MetadataModules.CALIBRATION) { const modality = getValue(metaData['00080060']); @@ -231,46 +217,6 @@ function metaDataProvider(type, imageId) { sequenceOfUltrasoundRegions: enhancedRegion, }; } - - // ECG waveform: use sequenceOfUltrasoundRegions for time (seconds) and amplitude (mV) - const imageUri = imageId.replace('wadors:', ''); - const studiesIndex = imageUri.indexOf('/studies/'); - let wadoRsRoot; - let studyUID; - if (studiesIndex !== -1) { - wadoRsRoot = imageUri.substring(0, studiesIndex); - const afterStudies = imageUri.substring(studiesIndex + 9); - const nextSlash = afterStudies.indexOf('/'); - studyUID = - nextSlash !== -1 ? afterStudies.substring(0, nextSlash) : afterStudies; - } - const ecgModule = getECGModule(metaData, wadoRsRoot, studyUID); - if (ecgModule) { - const { numberOfWaveformSamples, samplingFrequency } = ecgModule; - const physicalDeltaX = 1 / (samplingFrequency || 1); - // Typical ECG: raw Int16 in µV or similar; 0.001 mV per raw unit (1 µV/LSB) - const physicalDeltaY = 0.001; - // Match ECGViewport getImageData(): amplitude mapped to index [0, 65536), offset 32768 - const ECG_AMPLITUDE_INDEX_SIZE = 65536; - const ECG_AMPLITUDE_OFFSET = 32768; - return { - sequenceOfUltrasoundRegions: [ - { - regionLocationMinX0: 0, - regionLocationMaxX1: numberOfWaveformSamples, - regionLocationMinY0: 0, - regionLocationMaxY1: ECG_AMPLITUDE_INDEX_SIZE - 1, - referencePixelX0: 0, - referencePixelY0: ECG_AMPLITUDE_OFFSET, - physicalDeltaX, - physicalDeltaY, - physicalUnitsXDirection: 4, // seconds - physicalUnitsYDirection: -1, // mV (extension unit) - regionDataType: 1, - }, - ], - }; - } } if (type === MetadataModules.IMAGE_URL) { diff --git a/packages/dicomImageLoader/src/imageLoader/wadors/register.ts b/packages/dicomImageLoader/src/imageLoader/wadors/register.ts index 55b4f9aebe..4c51baed91 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadors/register.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadors/register.ts @@ -1,9 +1,24 @@ import { metaData, registerImageLoader, type Types } from '@cornerstonejs/core'; import loadImage from './loadImage'; import { metaDataProvider } from './metaData'; +import { registerDefaultProviders } from '@cornerstonejs/metadata'; -export default function () { - // register wadors scheme and metadata provider +/** + * Registers the wadors scheme image loader, and either the + * default metadata providers to use those, or the legacy metadata providers + * when options?.useLegacyMetadataProvider is true. + */ +export default function (options?: { useLegacyMetadataProvider?: boolean }) { + // register wadors scheme image loader registerImageLoader('wadors', loadImage as unknown as Types.ImageLoaderFn); - metaData.addProvider(metaDataProvider); + + if (options?.useLegacyMetadataProvider) { + console.warn( + 'wadors metaDataProvider is deprecated. Use addDicomWebInstance from @cornerstonejs/metadata instead.' + ); + metaData.addProvider(metaDataProvider); + return; + } + + registerDefaultProviders(); } diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts index ad86feee30..0f1fa241b9 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts @@ -1,6 +1,6 @@ import type { DataSet } from 'dicom-parser'; -import * as dicomParser from 'dicom-parser'; import { xhrRequest } from '../internal/index'; +import { parseDicom } from './parseDicomWithInflater'; import dataSetFromPartialContent from './dataset-from-partial-content'; import type { LoadRequestFunction, @@ -143,7 +143,7 @@ function load( } ); } else { - dataSet = dicomParser.parseDicom(byteArray); + dataSet = parseDicom(byteArray); } } catch (error) { return reject(error); diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/dataset-from-partial-content.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/dataset-from-partial-content.ts index f327fecf60..0b40693d9c 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/dataset-from-partial-content.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/dataset-from-partial-content.ts @@ -1,5 +1,5 @@ import type { DataSet } from 'dicom-parser'; -import * as dicomParser from 'dicom-parser'; +import { parseDicom } from './parseDicomWithInflater'; import type { LoadRequestFunction, DICOMLoaderDataSetWithFetchMore, @@ -37,7 +37,7 @@ function parsePartialByteArray(byteArray: Uint8Array) { * partial pixel data in the error object. */ - let dataSet = dicomParser.parseDicom(byteArray, { + let dataSet = parseDicom(byteArray, { untilTag: 'x7fe00010', }); @@ -55,7 +55,7 @@ function parsePartialByteArray(byteArray: Uint8Array) { // incomplete, because dicomParser throws *before* combining the // metadata header and regular datasets, so transfer syntax and // other metadata headers aren't included. - pixelDataSet = dicomParser.parseDicom(byteArray); + pixelDataSet = parseDicom(byteArray); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { // Todo: This is probably invalid handling - it expects the only reason to diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts index d6f150dcaa..4f2937a436 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/index.ts @@ -17,6 +17,7 @@ import { loadImageFromPromise, getLoaderForScheme, loadImage, + loadImageFromNaturalizedMetadata, } from './loadImage'; import parseImageId from './parseImageId'; import unpackBinaryFrame from './unpackBinaryFrame'; @@ -42,6 +43,7 @@ export default { getLoaderForScheme, getPixelData, loadImage, + loadImageFromNaturalizedMetadata, parseImageId, unpackBinaryFrame, register, @@ -58,6 +60,7 @@ export { getLoaderForScheme, getPixelData, loadImage, + loadImageFromNaturalizedMetadata, parseImageId, unpackBinaryFrame, register, diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/loadImage.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/loadImage.ts index 5fb7b0fd6f..d7176b36fa 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/loadImage.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/loadImage.ts @@ -1,6 +1,7 @@ -import type { DataSet } from 'dicom-parser'; +import type { ByteArray, DataSet } from 'dicom-parser'; import type { Types } from '@cornerstonejs/core'; -import { Enums } from '@cornerstonejs/core'; +import { Enums, metaData } from '@cornerstonejs/core'; +import { Enums as MetadataEnums, utilities } from '@cornerstonejs/metadata'; import createImage from '../createImage'; import { xhrRequest } from '../internal/index'; import dataSetCacheManager from './dataSetCacheManager'; @@ -15,6 +16,9 @@ import parseImageId from './parseImageId'; const { ImageQualityStatus } = Enums; +const { addDicomPart10Instance } = utilities; +const NATURALIZED = MetadataEnums.MetadataModules.NATURALIZED; + // add a decache callback function to clear out our dataSetCacheManager function addDecache(imageLoadObject: Types.IImageLoadObject, imageId: string) { imageLoadObject.decache = function () { @@ -75,7 +79,6 @@ function loadImageFromPromise( (image) => { image = image as DICOMLoaderIImage; image.data = dataSet; - image.sharedCacheKey = sharedCacheKey; const end = new Date().getTime(); image.loadTimeInMS = loadEnd - start; @@ -114,7 +117,7 @@ function loadImageFromDataSet( dataSet, imageId: string, frame = 0, - sharedCacheKey: string, + _sharedCacheKey, options ): Types.IImageLoadObject { const start = new Date().getTime(); @@ -169,62 +172,181 @@ function getLoaderForScheme(scheme: string): LoadRequestFunction { } } -function loadImage( +const asByteArray = (data) => + data instanceof ArrayBuffer ? new Uint8Array(data) : data; + +function concatPixelData(pixelData) { + // Single buffer case + if (!Array.isArray(pixelData)) { + return asByteArray(pixelData); + } + + if (pixelData.length === 0) { + return undefined; + } + + if (pixelData.length === 1) { + return asByteArray(pixelData[0]); + } + + // Concatenate multiple frames + let totalLength = 0; + for (const frame of pixelData) { + totalLength += asByteArray(frame).length; + } + + const result = new Uint8Array(totalLength); + let offset = 0; + for (const frame of pixelData) { + const view = asByteArray(frame); + result.set(view, offset); + offset += view.length; + } + return result; +} + +/** + * Loads an image from the NATURALIZED path: ensures NATURALIZED is populated (fetch + + * addDicomPart10Instance when needed), gets frame pixel data via getMetaData(MetadataModules.COMPRESSED_FRAME_DATA, imageId, `{ frameIndex }`), + * then creates IImage. Does not use dataSetCacheManager. + */ +function loadImageFromNaturalizedMetadata( imageId: string, options: DICOMLoaderImageOptions = {} ): Types.IImageLoadObject { const parsedImageId = parseImageId(imageId); - options = Object.assign({}, options); + delete (options as Record).loader; - // IMPORTANT: if you have a custom loader that you want to use for a specific - // scheme, you should create your own loader and register it with the scheme - // in the image loader, and NOT just pass it in as an option. This is because - // the scheme is used to determine the loader to use and is more maintainable - - // The loader isn't transferable, so ensure it is deleted - delete options.loader; - // The options might have a loader above, but it is a loader into the cache, - // so not the scheme loader, which is separate and defined by the scheme here const schemeLoader = getLoaderForScheme(parsedImageId.scheme); + const frameIndex = + parsedImageId.pixelDataFrame !== undefined + ? parsedImageId.pixelDataFrame + : 0; - // if the dataset for this url is already loaded, use it, in case of multiframe - // images, we need to extract the frame pixelData from the dataset although the - // image is loaded - if (dataSetCacheManager.isLoaded(parsedImageId.url)) { - /** - * @todo The arguments to the dataSetCacheManager below are incorrect. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dataSet: DataSet = (dataSetCacheManager as any).get( - parsedImageId.url, - schemeLoader, - imageId + const promise = (async (): Promise => { + const start = Date.now(); + console.log( + '[dicomImageLoader/wadouri] loadImageFromNaturalizedMetadata: start', + { + imageId, + scheme: parsedImageId.scheme, + url: parsedImageId.url, + frameIndex, + } ); - return loadImageFromDataSet( - dataSet, + let natural = metaData.get(NATURALIZED, imageId); + if (!natural) { + console.log( + '[dicomImageLoader/wadouri] loadImageFromNaturalizedMetadata: no NATURALIZED metadata, attempting to fetch and populate', + { imageId } + ); + + if (!schemeLoader) { + throw new Error( + `loadImageFromNaturalizedMetadata: no NATURALIZED cache and unknown scheme ${parsedImageId.scheme}` + ); + } + const result = (await schemeLoader(parsedImageId.url, imageId)) as + | ArrayBuffer + | { arrayBuffer: ArrayBuffer }; + const arrayBuffer = + result instanceof ArrayBuffer ? result : result.arrayBuffer; + // Store NATURALIZED under base imageId (no ?frame=) so registration happens once per URL + const baseImageId = `${parsedImageId.scheme}:${parsedImageId.url}`; + await addDicomPart10Instance(baseImageId, arrayBuffer); + natural = metaData.get(NATURALIZED, imageId); + } + + const loadEnd = Date.now(); + + const frameData = metaData.getTyped( + MetadataEnums.MetadataModules.COMPRESSED_FRAME_DATA, imageId, - parsedImageId.pixelDataFrame, - parsedImageId.url, + { frameIndex } + ); + if (!frameData) { + console.warn( + '[dicomImageLoader/wadouri] loadImageFromNaturalizedMetadata: no COMPRESSED_FRAME_DATA for imageId', + { imageId, frameIndex } + ); + + throw new Error( + `loadImageFromNaturalizedMetadata: no pixel data in NATURALIZED for imageId ${imageId}` + ); + } + + const { pixelData, transferSyntaxUid } = frameData; + + const concatenatedPixelData = concatPixelData(pixelData); + + const image = await createImage( + imageId, + concatenatedPixelData, + transferSyntaxUid, options ); + const end = Date.now(); + const out = image as DICOMLoaderIImage; + out.imageQualityStatus = ImageQualityStatus.FULL_RESOLUTION; + out.data = natural; + out.loadTimeInMS = loadEnd - start; + out.totalTimeInMS = end - start; + return out; + })(); + + return { promise }; +} + +/** + * Legacy image loader entry point used when `useLegacyMetadataProvider` is true. + * This conforms to `Types.ImageLoaderFn` (imageId, options) and internally + * uses `dataSetCacheManager.load` plus `loadImageFromPromise`. + * + * @deprecated This loads images using the legacy URI loader, not the newer @cornerstonejs/metadata framework + */ +const loadImage = ( + imageId: string, + options: DICOMLoaderImageOptions = {} +): Types.IImageLoadObject => { + const parsedImageId = parseImageId(imageId); + + const schemeLoader = getLoaderForScheme(parsedImageId.scheme); + if (!schemeLoader) { + throw new Error( + `wadouri loadImage: no loader for scheme '${parsedImageId.scheme}'` + ); } - // load the dataSet via the dataSetCacheManager + const frameIndex = + parsedImageId.pixelDataFrame !== undefined + ? parsedImageId.pixelDataFrame + : 0; + + // For legacy wadouri, the shared cache key is the underlying URL without + // any frame parameter; multiframe helpers handle per-frame metadata. + const sharedCacheKey = parsedImageId.url; + const dataSetPromise = dataSetCacheManager.load( parsedImageId.url, schemeLoader, imageId - ); + ) as Promise; return loadImageFromPromise( dataSetPromise, imageId, - parsedImageId.pixelDataFrame, - parsedImageId.url, + frameIndex, + sharedCacheKey, options ); -} +}; -export { loadImageFromPromise, getLoaderForScheme, loadImage }; +export { + loadImageFromPromise, + getLoaderForScheme, + loadImage, + loadImageFromNaturalizedMetadata, + loadImageFromDataSet, +}; diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts index 079aaa1f47..391ae586f0 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts @@ -20,6 +20,9 @@ import isNMReconstructable from '../../isNMReconstructable'; import { instanceModuleNames } from '../../getInstanceModule'; import { getUSEnhancedRegions } from './USHelpers'; +/** + * @deprecated Use addDicomPart10Instance from @cornerstonejs/metadata instead. + */ function metaDataProvider(type, imageId) { const { MetadataModules } = Enums; diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/parseDicomWithInflater.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/parseDicomWithInflater.ts new file mode 100644 index 0000000000..481aca3341 --- /dev/null +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/parseDicomWithInflater.ts @@ -0,0 +1,35 @@ +import * as dicomParser from 'dicom-parser'; +import pako from 'pako'; + +/** + * Inflater callback for deflate transfer syntax (1.2.840.10008.1.2.1.99). + * dicom-parser expects a global pako or this option; we pass it so deflate + * works when pako is bundled and not on window. + */ +function inflater(byteArray: Uint8Array, position: number): Uint8Array { + const deflated = byteArray.slice(position); + const inflated = pako.inflateRaw(deflated); + const fullByteArray = new Uint8Array(inflated.length + position); + fullByteArray.set(byteArray.slice(0, position), 0); + fullByteArray.set(inflated, position); + return fullByteArray; +} + +/** + * Options to pass to dicomParser.parseDicom so that deflate transfer syntax + * is supported (uses pako when not running in Node). + */ +export const parseDicomOptions = { inflater }; + +/** + * Parse a DICOM P10 byte array with deflate support. + */ +export function parseDicom( + byteArray: Uint8Array, + options?: dicomParser.ParseDicomOptions +) { + return dicomParser.parseDicom(byteArray, { + ...parseDicomOptions, + ...options, + }); +} diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/register.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/register.ts index 1e296e8cce..b2d6e2dfca 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/register.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/register.ts @@ -1,13 +1,43 @@ import { metaData, registerImageLoader, type Types } from '@cornerstonejs/core'; -import { loadImage } from './loadImage'; +import { registerDefaultProviders } from '@cornerstonejs/metadata'; + +import { loadImage, loadImageFromNaturalizedMetadata } from './loadImage'; import { metaDataProvider } from './metaData/index'; -export default function (): void { - // register dicomweb and wadouri image loader prefixes - registerImageLoader('dicomweb', loadImage as unknown as Types.ImageLoaderFn); - registerImageLoader('wadouri', loadImage as unknown as Types.ImageLoaderFn); - registerImageLoader('dicomfile', loadImage as unknown as Types.ImageLoaderFn); +/** + * Registers the image loaders for Part 10 DICOM files, and either the + * default metadata providers to use those, or the legacy metadata providers + * when options?.useLegacyMetadataProvider is true. + */ +export default function (options?: { + useLegacyMetadataProvider?: boolean; +}): void { + if (options?.useLegacyMetadataProvider === true) { + /** + * @deprecated The wadouri metadata provider is deprecated. + * Use addBinaryDicomInstance from @cornerstonejs/metadata to register + * Part 10 binary metadata directly into the NATURAL cache instead. + */ + console.warn( + 'wadouri metaDataProvider is deprecated. Use registerMetadataProvider module from @cornerstonejs/metadata instead.' + ); + // register dicomweb and wadouri image loader prefixes and bind them + // to the loadImage. Note this registers both legacy and new metadata + // loader, but the metadata provider is registered separately. + registerImageLoader('dicomweb', loadImage); + registerImageLoader('wadouri', loadImage); + registerImageLoader('dicomfile', loadImage); + metaData.addProvider(metaDataProvider); + return; + } + + // register dicomweb and wadouri image loader prefixes to loadImageFromNaturalizedMetadata + // (dataSetCacheManager populates NATURAL via addDicomPart10Instance when loading; returns IImage). + registerImageLoader('dicomweb', loadImageFromNaturalizedMetadata); + registerImageLoader('wadouri', loadImageFromNaturalizedMetadata); + registerImageLoader('dicomfile', loadImageFromNaturalizedMetadata); - // add wadouri metadata provider - metaData.addProvider(metaDataProvider); + registerDefaultProviders(); } + +export { loadImageFromNaturalizedMetadata as loadImage } from './loadImage'; diff --git a/packages/dicomImageLoader/src/init.ts b/packages/dicomImageLoader/src/init.ts index 41d1bb0b77..7383a9c7ec 100644 --- a/packages/dicomImageLoader/src/init.ts +++ b/packages/dicomImageLoader/src/init.ts @@ -16,7 +16,7 @@ function init(options: LoaderOptions = {}): void { // cornerstone set // DO NOT CHANGE THE ORDER OF THESE TWO LINES! setOptions(options); - registerLoaders(); + registerLoaders(options); const workerManager = getWebWorkerManager(); const maxWorkers = options?.maxWebWorkers || getReasonableWorkerCount(); diff --git a/packages/dicomImageLoader/src/types/LoaderOptions.ts b/packages/dicomImageLoader/src/types/LoaderOptions.ts index d34885d167..9513584c8e 100644 --- a/packages/dicomImageLoader/src/types/LoaderOptions.ts +++ b/packages/dicomImageLoader/src/types/LoaderOptions.ts @@ -31,4 +31,13 @@ export interface LoaderOptions { errorInterceptor?: (error: LoaderXhrRequestError) => void; strict?: boolean; decodeConfig?: LoaderDecodeOptions; + /** + * When true, registers the legacy wadouri/wadors metadata providers. + * Default is false (use the new metadata design). Set to true only for + * backward compatibility. + * New design: use addDicomPart10Instance and addDicomWebInstance from + * @cornerstonejs/metadata to populate the NATURAL cache instead. + * @see https://www.cornerstonejs.org/docs/concepts/cornerstone-core/metadataProvider + */ + useLegacyMetadataProvider?: boolean; } diff --git a/packages/dicomImageLoader/src/version.ts b/packages/dicomImageLoader/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/dicomImageLoader/src/version.ts +++ b/packages/dicomImageLoader/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/dicomImageLoader/testImages/TestPattern_Palette.ts b/packages/dicomImageLoader/testImages/TestPattern_Palette.ts index 26b37c321d..6d02b59666 100644 --- a/packages/dicomImageLoader/testImages/TestPattern_Palette.ts +++ b/packages/dicomImageLoader/testImages/TestPattern_Palette.ts @@ -20,15 +20,15 @@ const EXPECTED_IMAGE: Types.IImage = { imageFrame: { bitsAllocated: 8, bitsStored: 8, - // @ts-expect-error jasmine matcher - bluePaletteColorLookupTableData: jasmine.any(Array), + // @ts-expect-error jasmine matcher - LUT may be Uint8Array from metadata pipeline + bluePaletteColorLookupTableData: jasmine.any(Uint8Array), bluePaletteColorLookupTableDescriptor: [256, 0, 8], columns: 640, decodeLevel: undefined, // @ts-expect-error jasmine matcher decodeTimeInMS: jasmine.any(Number), - // @ts-expect-error jasmine matcher - greenPaletteColorLookupTableData: jasmine.any(Array), + // @ts-expect-error jasmine matcher - LUT may be Uint8Array from metadata pipeline + greenPaletteColorLookupTableData: jasmine.any(Uint8Array), greenPaletteColorLookupTableDescriptor: [256, 0, 8], // @ts-expect-error jasmine matcher imageData: { data: jasmine.any(Uint8ClampedArray) }, @@ -41,8 +41,8 @@ const EXPECTED_IMAGE: Types.IImage = { pixelDataLength: 768000, pixelRepresentation: 0, planarConfiguration: undefined, - // @ts-expect-error jasmine matcher - redPaletteColorLookupTableData: jasmine.any(Array), + // @ts-expect-error jasmine matcher - LUT may be Uint8Array from metadata pipeline + redPaletteColorLookupTableData: jasmine.any(Uint8Array), redPaletteColorLookupTableDescriptor: [256, 0, 8], rows: 400, samplesPerPixel: 1, diff --git a/packages/dicomImageLoader/testImages/TestPattern_Palette_16.ts b/packages/dicomImageLoader/testImages/TestPattern_Palette_16.ts index db0d05db7b..a431e53acf 100644 --- a/packages/dicomImageLoader/testImages/TestPattern_Palette_16.ts +++ b/packages/dicomImageLoader/testImages/TestPattern_Palette_16.ts @@ -20,15 +20,15 @@ const EXPECTED_IMAGE: Types.IImage = { imageFrame: { bitsAllocated: 8, bitsStored: 8, - // @ts-expect-error jasmine matcher - bluePaletteColorLookupTableData: jasmine.any(Array), + // @ts-expect-error jasmine matcher - LUT may be Uint16Array from metadata pipeline + bluePaletteColorLookupTableData: jasmine.any(Uint16Array), bluePaletteColorLookupTableDescriptor: [256, 0, 16], columns: 640, decodeLevel: undefined, // @ts-expect-error jasmine matcher decodeTimeInMS: jasmine.any(Number), - // @ts-expect-error jasmine matcher - greenPaletteColorLookupTableData: jasmine.any(Array), + // @ts-expect-error jasmine matcher - LUT may be Uint16Array from metadata pipeline + greenPaletteColorLookupTableData: jasmine.any(Uint16Array), greenPaletteColorLookupTableDescriptor: [256, 0, 16], // @ts-expect-error jasmine matcher imageData: { data: jasmine.any(Uint8ClampedArray) }, @@ -41,8 +41,8 @@ const EXPECTED_IMAGE: Types.IImage = { pixelDataLength: 768000, pixelRepresentation: 0, planarConfiguration: undefined, - // @ts-expect-error jasmine matcher - redPaletteColorLookupTableData: jasmine.any(Array), + // @ts-expect-error jasmine matcher - LUT may be Uint16Array from metadata pipeline + redPaletteColorLookupTableData: jasmine.any(Uint16Array), redPaletteColorLookupTableDescriptor: [256, 0, 16], rows: 400, samplesPerPixel: 1, diff --git a/packages/dicomImageLoader/testImages/us-multiframe-ybr-full-422.ts b/packages/dicomImageLoader/testImages/us-multiframe-ybr-full-422.ts index bd5ef14519..d469eafb7a 100644 --- a/packages/dicomImageLoader/testImages/us-multiframe-ybr-full-422.ts +++ b/packages/dicomImageLoader/testImages/us-multiframe-ybr-full-422.ts @@ -9,10 +9,11 @@ const CALIBRATION_MODULE = { physicalDeltaY: 0.041258670759110834, physicalUnitsXDirection: 3, physicalUnitsYDirection: 3, - referencePhysicalPixelValueX: undefined, - referencePhysicalPixelValueY: undefined, referencePixelX0: null, referencePixelY0: null, + referencePhysicalPixelValueX: undefined, + referencePhysicalPixelValueY: undefined, + transducerFrequency: undefined, regionDataType: 1, regionFlags: 2, regionLocationMaxX1: 788, @@ -20,7 +21,6 @@ const CALIBRATION_MODULE = { regionLocationMinX0: 11, regionLocationMinY0: 30, regionSpatialFormat: 1, - transducerFrequency: undefined, }, ], }; diff --git a/packages/docs/docs/concepts/cornerstone-core/metadataProvider.md b/packages/docs/docs/concepts/cornerstone-core/metadataProvider.md index 648270597e..6e440a257d 100644 --- a/packages/docs/docs/concepts/cornerstone-core/metadataProvider.md +++ b/packages/docs/docs/concepts/cornerstone-core/metadataProvider.md @@ -6,6 +6,9 @@ summary: Functions that retrieve and provide non-pixel metadata associated with # Metadata Providers +For package-level architecture and current 5.x metadata behavior, see +[Metadata Module](../cornerstone-metadata/index.md). + Medical images typically come with lots of non-pixel-wise metadata such as the pixel spacing of the image, the patient ID, or the scan acquisition date. With some file types (e.g. DICOM), this information is stored within the file header and can be read and parsed and passed around your application. With others (e.g. JPEG, PNG), this information needs to be provided independently from the actual pixel data. Even for DICOM images, however, it is common for application developers to provide metadata independently from the transmission of pixel data from the server to the client since this can considerably improve performance. A Metadata Provider is a JavaScript function that acts as an interface for accessing metadata related to Images in Cornerstone. Users can define their own provider functions in order to return any metadata they wish for each specific image. A Metadata Provider function has the following prototype: diff --git a/packages/docs/docs/concepts/cornerstone-metadata/index.md b/packages/docs/docs/concepts/cornerstone-metadata/index.md new file mode 100644 index 0000000000..5d3e069c17 --- /dev/null +++ b/packages/docs/docs/concepts/cornerstone-metadata/index.md @@ -0,0 +1,83 @@ +--- +id: index +title: Cornerstone Metadata +summary: How @cornerstonejs/metadata provides canonical metadata ingestion, providers, and cache behavior in current Cornerstone3D +--- + +# Metadata Module + +`@cornerstonejs/metadata` is the canonical metadata layer for current +Cornerstone3D. It centralizes metadata ingestion, typed provider resolution, and +shared cache behavior so applications do not need to duplicate source-specific +metadata conversion logic. + +## Current package role + +- Owns metadata provider registration and typed provider orchestration. +- Normalizes source metadata into common module outputs used by core/tools. +- Provides metadata cache coordination across source and derived metadata types. +- Exposes utilities for tag mapping, normalized object handling, and metadata + organization flows. + +## Import path guidance + +- Recommended: import metadata APIs from `@cornerstonejs/metadata`. +- Legacy compatibility: `@cornerstonejs/core` still re-exports metadata APIs via + `core/src/metaData.ts`, but this path is deprecated. + +## Provider model + +The module supports two complementary provider patterns: + +- **General provider chain** (`addProvider`): priority-ordered providers, highest + priority first. +- **Typed provider chain** (`addTypedProvider`): per-type provider composition + with a typed provider bridge in the general chain. + +This allows applications to keep legacy provider integrations while adopting +typed providers incrementally. + +## Add-path ingestion and NATURALIZED + +Current metadata changes add explicit ingestion handlers through the add path: + +- `metaData.addMetaData(type, query, options)` routes to typed `typeAdd` + providers. +- `NATURALIZED` is the canonical base metadata state for DICOM source data. +- Callers can provide source payloads (for example DICOMweb JSON or Part10 data) + and let the metadata layer naturalize and cache them consistently. + +## Cache and imageId model (current behavior) + +- Shared typed caches support read-through and in-flight de-duplication. +- Source metadata (especially `NATURALIZED`) should be keyed by canonical base + imageId. +- Derived frame-specific modules resolve on frame imageIds. +- Frame/base normalization and frame-image expansion are handled by metadata + providers (including `FRAME_IMAGE_IDS`) rather than scattered call-site logic. + +## Initialization and provider registration + +`registerDefaultProviders()` wires the default typed provider stack and related +helpers. If the provider chain is reset or re-initialized by application startup +flow, required providers must be re-registered after init. + +This is especially important during migration from older code paths where +provider registration happened once and relied on persistent global state. + +## Package boundaries + +- `@cornerstonejs/metadata`: metadata ingestion, provider chains, normalized + module resolution, metadata-specific cache orchestration. +- `@cornerstonejs/core`: rendering/runtime primitives and rendering-focused + caches and loaders. +- `@cornerstonejs/dicom-image-loader`: retrieve/decode pipeline and source data + handoff into metadata. +- adapters: conversion between parsed metadata and tool/segmentation + representations. + +## Related docs + +- [Metadata Providers](../cornerstone-core/metadataProvider.md) +- [Custom Metadata Provider](../../how-to-guides/custom-metadata-provider.md) +- [5.x Migration Guides](../../migration-guides/5x/index.md) diff --git a/packages/docs/docs/migration-guides/5x/1-migration-notes.md b/packages/docs/docs/migration-guides/5x/1-migration-notes.md new file mode 100644 index 0000000000..35e7586d47 --- /dev/null +++ b/packages/docs/docs/migration-guides/5x/1-migration-notes.md @@ -0,0 +1,56 @@ +# 5.x Migration Reference Notes + +This page tracks smaller migration-impacting behavior changes that are useful +as reference during 4.x -> 5.x upgrades. + +## `disableScale` and `imageFrame.preScale` + +## What Changed + +In 5.x, when `disableScale` is `true`, Cornerstone3D no longer sets +`imageFrame.preScale` and preserves the original pixel min/max range +(`minAfterScale = minBeforeScale`, `maxAfterScale = maxBeforeScale`). + +This is intentional for cases where scaling is identity +(for example slope/intercept being 1/0). + +## Why This Matters + +In 4.x, some workflows implicitly relied on `imageFrame.preScale` always being +present. In 5.x, that object may be `undefined` when scaling is disabled. + +## Migration Guidance + +- Treat `imageFrame.preScale` as optional and guard access accordingly. +- If your downstream logic requires a pre-scale descriptor, create one in your + application code when `disableScale` is enabled. +- If you only need pixel statistics, use `minPixelValue`/`maxPixelValue` from + the image frame values directly instead of assuming post-scale values. + +## `instance` data object model in metadata modules + +### What Changed + +In 5.x, this is primarily a documentation clarification rather than a new +runtime behavior change: `instance` data should be understood as a single +per-frame object that includes computed per-frame values merged into one object. + +This object can use inheritance to compose values from multiple metadata levels. +Because of that, consumers should not assume all attributes are directly +iterable/enumerable on the object itself. + +### 4.x vs 5.x interpretation + +- **4.x:** this shape/behavior existed in practice, but was not clearly documented. +- **5.x:** the same model is now explicitly documented so integrations can rely + on the intended contract. + +### Migration Guidance + +- Do not rely on object enumeration (`Object.keys`, `for...in`) to discover all + available attributes on instance data. +- Access known attributes explicitly, or use module utilities that understand the + composed/inherited object structure. +- When building instance data from naturalized metadata, prefer the + `combineFramesInstance` utility so downstream modules receive the expected + base object shape. diff --git a/packages/docs/docs/migration-guides/5x/index.md b/packages/docs/docs/migration-guides/5x/index.md new file mode 100644 index 0000000000..c0ae22e964 --- /dev/null +++ b/packages/docs/docs/migration-guides/5x/index.md @@ -0,0 +1,116 @@ +--- +id: index +title: 5.0 Migration Guides +--- + +import DocCardList from '@theme/DocCardList'; +import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; + +# 5.0 Migration Guides + +Here you can find migration notes for moving from Cornerstone3D 4.x to 5.x. + +# Shared utilities (`@cornerstonejs/utils`) + +5.x introduces `@cornerstonejs/utils`, a package for shared helpers (for example small math utilities and general-purpose logging) that are also surfaced from other Cornerstone3D packages during the transition. + +- **Optional for now:** you do not need to change your imports on day one. You can keep consuming the same helpers through their existing package entry points while those re-exports remain available. +- **Direction of travel:** over time, **`@cornerstonejs/utils` is intended to be the only published home** for these shared utilities. New code and incremental refactors may prefer importing from `@cornerstonejs/utils` so you are aligned with where they will ultimately live. + +# Metadata Module + +In 5.x, the metadata module is designed as a shared handling layer for viewer metadata concerns, so core behavior is implemented once and reused rather than replicated across DICOMweb-specific code, OHIF-specific flows, JSON ingestion paths, and other module-specific integrations (which often resulted in multiple implementations with differing bugs). + +### Optional metadata module features in current CS3D + +The current CS3D version keeps existing metadata flows working, and also introduces optional features you can adopt incrementally: + +- Typed getters for metadata lookup. +- addMetadata providers for adding information to caches/waiting for async results to be added. +- clear metadata handling for removing specific changes and/or removing all cached data +- Providers that register directly for a specific single metadata type, so resolution can short-circuit quickly. +- Shared caches used across metadata providers and metadata types. +- Shared cache usage for Part10, DICOMweb, imageId-derived values, and other cached metadata outputs. +- Provision for new metadata types such as display set, series-level, and study-level results. + +## Metadata provider caching updates + +In 5.x, metadata providers are first-class extension points in the retrieval pipeline. Instead of each caller manually transforming and writing metadata, providers can normalize source payloads, compose with other providers, and rely on shared cache behavior for consistent lookups. + +- Use add-path metadata ingestion when the data requires parameters/externally provided values. + For example, the NATURALIZED data is computed from binary part 10 or DICMweb metadata format + - Use `metaData.addMetaData(MetadataModules.NATURALIZED, imageId, { dicomwebJson })` for DICOMweb JSON payloads. + - Use `metaData.addMetaData(MetadataModules.NATURALIZED, imageId, { part10Buffer })` for async Part10 ingestion (`ArrayBuffer`, `Uint8Array`, or resolver function). +- The metadata layer now owns frame/base imageId mapping and derived cache invalidation; avoid direct frame propagation with `setCacheData`. + +### New metadata handling (and backwards compatibility) + +- In 5.x, **NATURALIZED metadata is the base state for DICOM imaging data**. Other metadata modules (for example `INSTANCE` and derived module lookups) are expected to resolve from that canonical naturalized state rather than from source-specific conversion code. +- Data sources should migrate to providing metadata through naturalized add handlers (`{ dicomwebJson }` and `{ part10Buffer }`) instead of performing custom source-local conversion to instance/natural objects. +- New in 5.x: metadata ingestion is handled through add-path typed-provider requests, so callers can pass source data as options (`{ dicomwebJson }` or `{ part10Buffer }`) and let the provider chain naturalize/cache it. +- Existing usage still works: if your app already resolves metadata through the legacy provider chain (`addProvider` / prior `metaData.get(...)` flow), that behavior remains supported while you migrate. +- Recommended migration path: move NATURALIZED writes to `metaData.addMetaData(...)` calls and remove custom frame/base propagation logic from app code. +- This migration is optional until you adopt the new metadata handler path; legacy flows remain supported during transition. +- Why this matters: shared naturalization creates consistent behavior across DICOMweb, Part10, and other ingest paths, and helps eliminate recurring bugs caused by multiple, slightly different conversion implementations. + +### Naturalized handlers + +`registerNaturalizedHandlers()` now registers NATURALIZED handlers as composable read and add provider chains: + +- **Base imageId query filter:** a shared `baseImageIdQueryFilter` can be plugged into typed provider chains and is registered for `NATURALIZED` at high priority so frame-specific imageIds resolve on canonical base imageId first. +- **Synchronous naturalization handler (add path):** when callers provide `{ dicomwebJson }`, the handler naturalizes DICOMweb-style metadata into NATURALIZED output. +- **Asynchronous Part10 handler (add path):** accepts `{ part10Buffer }` (`ArrayBuffer`, `Uint8Array`, or resolver function), resolves to NATURALIZED, and commits to shared cache. +- **Cache interaction:** with base-image filtering ahead of cache providers, NATURALIZED cache keys remain canonical and downstream typed modules can rely on consistent lookups. + +Recommended usage: + +- `metaData.addMetaData(MetadataModules.NATURALIZED, imageId, { dicomwebJson })` for sync naturalization from DICOMweb metadata. +- `metaData.addMetaData(MetadataModules.NATURALIZED, imageId, { part10Buffer })` for async naturalization from Part10 payloads. + +### Standard cache behavior in 5.x + +The metadata cache in 5.x is a new shared layer that can be reused by different metadata providers and types. It centralizes cache population, in-flight de-duplication, and query-key consistency so provider implementations can focus on source-specific lookup logic. + +- Standard caches are now "read-through" caches: fetching metadata is expected to populate cache as a side effect of that fetch. +- `metaData.get(type, imageId, options)` resolves providers, and cache providers store successful results for the `(type, imageId)` key. +- Async lookups are de-duplicated in-flight, then committed to cache when resolved. +- For most modules, avoid manual `setCacheData(...)` calls; prefer provider-based lookup + automatic caching. +- Source metadata caches (NATURALIZED and ingestion inputs such as DICOMweb/Part10 handlers) should be keyed by base imageId, while derived per-frame caches are keyed by frame imageId. + +### Adding a new cache type + +- Register a cache for your module/type by calling `addCacheForType('yourType')` during provider registration. +- Then register one or more typed providers for that type; returned values are automatically cached under the query key. +- Keep provider logic as source-of-truth retrieval; let the cache layer handle storage and re-use. + +### Writable cache ingestion path + +- Prefer add-path ingestion (`metaData.addMetaData(...)`) over direct writable cache setters. +- Register writable behavior with `addWritableCacheForType(type)` (currently intended for `NATURALIZED`) so add-path ingestion writes to shared cache consistently. +- Use `addCacheForType(type, { secondaryOf: ... })` to register derived caches that should be invalidated when a base cache type changes. +- This keeps write behavior centralized in providers while preserving typed-provider cache behavior for reads. + +### ImageId mapping changes (old vs new) + +- Previous behavior: + - Frame/base conversion knowledge was spread across call sites, so mappings were inconsistent and not guaranteed to be unique. + - Source metadata was sometimes written per-frame instead of at a canonical base imageId. +- Metadata 5.x behavior: + - NATURALIZED/source metadata is canonicalized to base imageId only. + - `INSTANCE` metadata is per-frame and indexed by the frame imageId (including frame selector). + - A `FRAME_IMAGE_IDS` typed provider exposes frame-related imageIds generated from canonical base imageId + NATURALIZED metadata. + - `FRAME_IMAGE_IDS` now resolves in this order: cache first, then NATURALIZED-backed generation. + - If NATURALIZED is unavailable, `FRAME_IMAGE_IDS` resolves to `null`. + - If NATURALIZED has no photometric interpretation, `FRAME_IMAGE_IDS` returns a `Set` containing only the base imageId. + - If NATURALIZED defines `NumberOfFrames`, frame ids are generated for `1..NumberOfFrames` and include: + - DICOMweb path form (`/instances/{sopUID}/frames/{frameNo}`) + - Query-param form (`?frame={frameNo}` or `&frame={frameNo}`) + - Frame imageId -> base imageId normalization is handled by two filters: one for `/frames/{frameNo}` and one for `[?&]frame={frameNo}`. + - The reusable generator `generateFrameImageIdsFromNaturalized(baseImageId, naturalized)` is exported for non-metadata clients that need the same expansion behavior. + - A cache provider sits in front of frame/base filters so normalized base lookups and frame imageId expansion are reused. +- Migration guidance: + - Keep `convertMultiframeImageIds(...)` for generating frame imageIds. + - Store or fetch NATURALIZED/source metadata using canonical base imageId. + - Resolve per-frame metadata via `INSTANCE`/derived modules using frame imageIds, and rely on provider filters for frame<->base normalization. + + item.docId !== 'migration-guides/5x/index')}/> diff --git a/packages/docs/docusaurus.config.js b/packages/docs/docusaurus.config.js index b5f3daebfc..c9da5c91c8 100644 --- a/packages/docs/docusaurus.config.js +++ b/packages/docs/docusaurus.config.js @@ -260,6 +260,7 @@ module.exports = { tsconfig: `../${pkg}/tsconfig.json`, exclude: [`../${pkg}/test/**/*`, `../${pkg}/jest.config.js`], skipErrorChecking: true, + sanitizeComments: true, }, ]); } diff --git a/packages/docs/sidebars.js b/packages/docs/sidebars.js index e79f086857..c1176391c9 100644 --- a/packages/docs/sidebars.js +++ b/packages/docs/sidebars.js @@ -147,6 +147,13 @@ module.exports = { 'concepts/cornerstone-core/webWorker', ], }, + { + type: 'category', + label: 'Metadata', + link: { type: 'doc', id: 'concepts/cornerstone-metadata/index' }, + collapsed: true, + items: ['concepts/cornerstone-metadata/index'], + }, { type: 'category', label: 'Progressive Loading', @@ -269,6 +276,16 @@ module.exports = { }, collapsed: true, items: [ + { + type: 'category', + label: '4.x -> 5.x', + collapsed: false, + link: { + type: 'doc', + id: 'migration-guides/5x/index', + }, + items: [{ type: 'autogenerated', dirName: 'migration-guides/5x' }], + }, { type: 'category', label: '3.x -> 4.x', diff --git a/packages/docs/src/version.ts b/packages/docs/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/docs/src/version.ts +++ b/packages/docs/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/labelmap-interpolation/CHANGELOG.md b/packages/labelmap-interpolation/CHANGELOG.md index 6c3d6cc5a8..55c973f0e2 100644 --- a/packages/labelmap-interpolation/CHANGELOG.md +++ b/packages/labelmap-interpolation/CHANGELOG.md @@ -1,4 +1,4 @@ -# Change Log +# Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. @@ -11,25 +11,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/labelmap-interpolation -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) - -**Note:** Version bump only for package @cornerstonejs/labelmap-interpolation - -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/labelmap-interpolation - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/labelmap-interpolation - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) **Note:** Version bump only for package @cornerstonejs/labelmap-interpolation -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/labelmap-interpolation +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) diff --git a/packages/labelmap-interpolation/package.json b/packages/labelmap-interpolation/package.json index 3a1509386d..c4af328171 100644 --- a/packages/labelmap-interpolation/package.json +++ b/packages/labelmap-interpolation/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/labelmap-interpolation", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "Labelmap Interpolation utility for Cornerstone3D", "files": [ "dist" @@ -30,7 +30,7 @@ "clean": "rimraf dist", "clean:deep": "yarn run clean && shx rm -rf node_modules", "build": "yarn run build:esm", - "build:esm": "tsc --project ./tsconfig.json", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", "build:esm:watch": "tsc --project ./tsconfig.json --watch", "dev": "tsc --project ./tsconfig.json --watch", "build:all": "yarn run build:esm", @@ -54,13 +54,13 @@ "@itk-wasm/morphological-contour-interpolation": "1.1.0", "itk-wasm": "1.0.0-b.165" }, + "devDependencies": { + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2" + }, "peerDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0", "@kitware/vtk.js": "34.15.1" - }, - "devDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9" } } diff --git a/packages/labelmap-interpolation/src/version.ts b/packages/labelmap-interpolation/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/labelmap-interpolation/src/version.ts +++ b/packages/labelmap-interpolation/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/metadata/babel.config.js b/packages/metadata/babel.config.js new file mode 100644 index 0000000000..325ca2a8ee --- /dev/null +++ b/packages/metadata/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config.js'); diff --git a/packages/metadata/jest.config.js b/packages/metadata/jest.config.js new file mode 100644 index 0000000000..f9560bfe13 --- /dev/null +++ b/packages/metadata/jest.config.js @@ -0,0 +1,18 @@ +/* eslint-disable */ +const base = require('../../jest.config.base.js'); +const path = require('path'); + +module.exports = { + ...base, + displayName: 'metadata', + testMatch: [ + ...base.testMatch, + '/src/**/*.test.ts', + '/src/**/*.spec.ts', + ], + moduleNameMapper: { + ...base.moduleNameMapper, + '^@cornerstonejs/(\\w+)/(.+)$': path.resolve(__dirname, '../$1/src/$2'), + '^@cornerstonejs/(.*)$': path.resolve(__dirname, '../$1/src'), + }, +}; diff --git a/packages/metadata/package.json b/packages/metadata/package.json new file mode 100644 index 0000000000..99123431c7 --- /dev/null +++ b/packages/metadata/package.json @@ -0,0 +1,104 @@ +{ + "name": "@cornerstonejs/metadata", + "version": "5.0.0-beta.2", + "description": "Cornerstone3D Metadata Foundation - Provider chain, DICOM stream parsing, tag modules, and series splitting", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "repository": "https://github.com/cornerstonejs/cornerstone3D", + "files": [ + "./dist/" + ], + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/esm/index.js", + "node": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts" + }, + "./metaData": { + "import": "./dist/esm/metaData.js", + "node": "./dist/esm/metaData.js", + "types": "./dist/esm/metaData.d.ts" + }, + "./enums": { + "import": "./dist/esm/enums/index.js", + "node": "./dist/esm/enums/index.js", + "types": "./dist/esm/enums/index.d.ts" + }, + "./enums/*": { + "import": "./dist/esm/enums/*.js", + "node": "./dist/esm/enums/*.js", + "types": "./dist/esm/enums/*.d.ts" + }, + "./utilities": { + "import": "./dist/esm/utilities/index.js", + "node": "./dist/esm/utilities/index.js", + "types": "./dist/esm/utilities/index.d.ts" + }, + "./utilities/dicomStream": { + "import": "./dist/esm/utilities/dicomStream/index.js", + "node": "./dist/esm/utilities/dicomStream/index.js", + "types": "./dist/esm/utilities/dicomStream/index.d.ts" + }, + "./utilities/metadataProvider": { + "import": "./dist/esm/utilities/metadataProvider/index.js", + "node": "./dist/esm/utilities/metadataProvider/index.js", + "types": "./dist/esm/utilities/metadataProvider/index.d.ts" + }, + "./utilities/*": { + "import": "./dist/esm/utilities/*.js", + "node": "./dist/esm/utilities/*.js", + "types": "./dist/esm/utilities/*.d.ts" + }, + "./displayset": { + "import": "./dist/esm/displayset/index.js", + "node": "./dist/esm/displayset/index.js", + "types": "./dist/esm/displayset/index.d.ts" + }, + "./types": { + "types": "./dist/esm/types/index.d.ts" + }, + "./types/*": { + "types": "./dist/esm/types/*.d.ts" + }, + "./version": { + "import": "./dist/esm/version.js", + "node": "./dist/esm/version.js", + "types": "./dist/esm/version.d.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prebuild": "node ../../scripts/generate-version.js ./", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", + "build:esm:watch": "tsc --project ./tsconfig.json --watch", + "clean": "rm -rf node_modules/.cache/storybook && shx rm -rf dist", + "clean:deep": "yarn run clean && shx rm -rf node_modules", + "build": "yarn run build:esm", + "build:all": "yarn run build:esm", + "dev": "tsc --project ./tsconfig.json --watch", + "api-check": "api-extractor --debug run", + "lint": "oxlint .", + "test:unit": "jest --config ../../jest.config.js --testPathPattern=packages/metadata", + "prepublishOnly": "yarn run build" + }, + "dependencies": { + "@cornerstonejs/utils": "5.0.0-beta.2", + "@cornerstonejs/calculate-suv": "1.0.3", + "dcmjs": "0.50.1", + "gl-matrix": "3.4.3" + }, + "contributors": [ + { + "name": "Cornerstone.js Contributors", + "url": "https://github.com/orgs/cornerstonejs/people" + } + ], + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://ohif.org/donate" + } +} diff --git a/packages/metadata/src/displayset/BaseDisplaySet.ts b/packages/metadata/src/displayset/BaseDisplaySet.ts new file mode 100644 index 0000000000..630064a5fc --- /dev/null +++ b/packages/metadata/src/displayset/BaseDisplaySet.ts @@ -0,0 +1,68 @@ +import type { IDisplaySet } from './IDisplaySet'; +import type { ViewportTypeHint } from './types'; +import { getPreferredViewportType } from './viewportTypes'; + +export type BaseDisplaySetOptions = { + displaySetInstanceUID: string; + viewportTypes?: readonly ViewportTypeHint[]; + frameImageIds?: Iterable; + underlyingImageIds?: Iterable; +}; + +/** + * Base display set metadata with frame-level and underlying image id sets. + */ +export class BaseDisplaySet implements IDisplaySet { + displaySetInstanceUID: string; + viewportTypes: readonly ViewportTypeHint[]; + + protected readonly frameImageIdSet: Set; + protected readonly underlyingImageIdSet: Set; + + constructor(options: BaseDisplaySetOptions) { + this.displaySetInstanceUID = options.displaySetInstanceUID; + this.viewportTypes = options.viewportTypes?.length + ? options.viewportTypes + : ['stack']; + this.frameImageIdSet = new Set(options.frameImageIds ?? []); + this.underlyingImageIdSet = new Set(options.underlyingImageIds ?? []); + } + + getPreferredViewportType(): ViewportTypeHint { + return getPreferredViewportType(this.viewportTypes); + } + + getFrameImageIds(): ReadonlySet { + return this.frameImageIdSet; + } + + getUnderlyingImageIds(): ReadonlySet { + return this.underlyingImageIdSet; + } + + addFrameImageId(imageId: string): void { + if (imageId) { + this.frameImageIdSet.add(imageId); + } + } + + addUnderlyingImageId(imageId: string): void { + if (imageId) { + this.underlyingImageIdSet.add(imageId); + } + } + + setFrameImageIds(imageIds: Iterable): void { + this.frameImageIdSet.clear(); + for (const imageId of imageIds) { + this.addFrameImageId(imageId); + } + } + + setUnderlyingImageIds(imageIds: Iterable): void { + this.underlyingImageIdSet.clear(); + for (const imageId of imageIds) { + this.addUnderlyingImageId(imageId); + } + } +} diff --git a/packages/metadata/src/displayset/IDisplaySet.ts b/packages/metadata/src/displayset/IDisplaySet.ts new file mode 100644 index 0000000000..fc830d6fab --- /dev/null +++ b/packages/metadata/src/displayset/IDisplaySet.ts @@ -0,0 +1,16 @@ +import type { ViewportTypeHint } from './types'; + +/** + * Framework-agnostic display set metadata stored in the Cornerstone metadata cache. + */ +export interface IDisplaySet { + displaySetInstanceUID: string; + /** + * Allowed viewport types for this display set. + * `viewportTypes[0]` is the preferred viewport type. + */ + viewportTypes: readonly ViewportTypeHint[]; + getFrameImageIds(): ReadonlySet; + getUnderlyingImageIds(): ReadonlySet; + getPreferredViewportType(): ViewportTypeHint; +} diff --git a/packages/metadata/src/displayset/ImageStackDisplaySet.ts b/packages/metadata/src/displayset/ImageStackDisplaySet.ts new file mode 100644 index 0000000000..ff1e929084 --- /dev/null +++ b/packages/metadata/src/displayset/ImageStackDisplaySet.ts @@ -0,0 +1,110 @@ +import { BaseDisplaySet, type BaseDisplaySetOptions } from './BaseDisplaySet'; +import type { NaturalizedInstance, ViewportTypeHint } from './types'; + +export type ImageStackDisplaySetOptions = Omit< + BaseDisplaySetOptions, + 'frameImageIds' | 'underlyingImageIds' +> & { + instances?: NaturalizedInstance[]; + frameImageIds?: Iterable; + underlyingImageIds?: Iterable; +}; + +function collectUnderlyingImageIds(instances: NaturalizedInstance[]): string[] { + const ids: string[] = []; + for (const instance of instances) { + if (instance.imageId) { + ids.push(instance.imageId); + } + } + return ids; +} + +function collectFrameImageIds( + instances: NaturalizedInstance[], + underlyingImageIds: string[] +): string[] { + const frameIds: string[] = []; + for (const instance of instances) { + if (instance.imageId) { + frameIds.push(instance.imageId); + } + } + if (frameIds.length === 0) { + return [...underlyingImageIds]; + } + return frameIds; +} + +/** + * Image/stack display set metadata with underlying vs frame image id semantics. + */ +export class ImageStackDisplaySet extends BaseDisplaySet { + protected readonly instances: NaturalizedInstance[]; + + constructor(options: ImageStackDisplaySetOptions) { + const instances = options.instances ?? []; + const underlyingImageIds = + options.underlyingImageIds ?? collectUnderlyingImageIds(instances); + const underlyingList = [...underlyingImageIds]; + const frameImageIds = + options.frameImageIds ?? collectFrameImageIds(instances, underlyingList); + + super({ + ...options, + frameImageIds, + underlyingImageIds: underlyingList, + }); + this.instances = instances; + } + + getInstances(): readonly NaturalizedInstance[] { + return this.instances; + } + + get isMultiFrame(): boolean { + return this.instances.some( + (instance) => Number(instance.NumberOfFrames) > 1 + ); + } + + static fromInstances( + instances: NaturalizedInstance[], + options?: { + displaySetInstanceUID?: string; + viewportTypes?: readonly ViewportTypeHint[]; + frameImageIds?: Iterable; + } + ): ImageStackDisplaySet { + const displaySetInstanceUID = + options?.displaySetInstanceUID ?? + instances[0]?.SeriesInstanceUID ?? + `display-set-${instances[0]?.imageId ?? 'unknown'}`; + + return new ImageStackDisplaySet({ + displaySetInstanceUID, + viewportTypes: options?.viewportTypes ?? ['stack', 'volume', 'volume3d'], + instances, + frameImageIds: options?.frameImageIds, + }); + } + + static fromImageIds( + imageIds: string[], + getNaturalizedInstance: ( + imageId: string + ) => NaturalizedInstance | undefined, + options?: { + displaySetInstanceUID?: string; + viewportTypes?: readonly ViewportTypeHint[]; + } + ): ImageStackDisplaySet { + const instances = imageIds + .map((imageId) => getNaturalizedInstance(imageId)) + .filter( + (instance): instance is NaturalizedInstance => instance !== undefined + ); + + return ImageStackDisplaySet.fromInstances(instances, options); + } +} diff --git a/packages/metadata/src/displayset/buildSeriesInfo.ts b/packages/metadata/src/displayset/buildSeriesInfo.ts new file mode 100644 index 0000000000..63ab1d76c6 --- /dev/null +++ b/packages/metadata/src/displayset/buildSeriesInfo.ts @@ -0,0 +1,42 @@ +import type { NaturalizedInstance, SeriesInfo, SplitRule } from './types'; + +/** + * Builds series-level metadata used by split rule selectors. + */ +export function buildSeriesInfo( + instances: NaturalizedInstance[], + splitRules: SplitRule[] = [] +): SeriesInfo { + const NumberOfSeriesRelatedInstances = instances.length; + let numberOfFrames = 0; + let numberOfNonImageObjects = 0; + let numberOfSOPInstanceUIDsPerSeries = 0; + + for (const instance of instances) { + if (instance.NumberOfFrames) { + numberOfFrames += Number(instance.NumberOfFrames); + } else if (instance.Rows) { + numberOfFrames += 1; + } else { + numberOfNonImageObjects += 1; + } + + if (instance.SOPInstanceUID) { + numberOfSOPInstanceUIDsPerSeries += 1; + } + } + + const seriesInfo: SeriesInfo = { + NumberOfSeriesRelatedInstances, + numberOfFrames, + numImageFrames: numberOfFrames, + numberOfNonImageObjects, + numberOfSOPInstanceUIDsPerSeries, + }; + + for (const splitRule of splitRules) { + splitRule.makeSeriesInfo?.(instances, seriesInfo); + } + + return seriesInfo; +} diff --git a/packages/metadata/src/displayset/createDisplaySetFromGroup.ts b/packages/metadata/src/displayset/createDisplaySetFromGroup.ts new file mode 100644 index 0000000000..3596622bd5 --- /dev/null +++ b/packages/metadata/src/displayset/createDisplaySetFromGroup.ts @@ -0,0 +1,45 @@ +import { BaseDisplaySet } from './BaseDisplaySet'; +import { ImageStackDisplaySet } from './ImageStackDisplaySet'; +import { isEcgInstance } from './isEcgInstance'; +import { isVideoInstance } from './isVideoInstance'; +import type { IDisplaySet } from './IDisplaySet'; +import type { GroupedInstanceBucket } from './types'; +import { getViewportTypesForGroup } from './viewportTypes'; + +export type CreateDisplaySetFromGroupOptions = { + displaySetInstanceUID?: string; + frameImageIds?: Iterable; +}; + +/** + * Builds cornerstone display set metadata for a grouped instance bucket. + */ +export function createDisplaySetFromGroup( + group: GroupedInstanceBucket, + options: CreateDisplaySetFromGroupOptions = {} +): IDisplaySet { + const viewportTypes = getViewportTypesForGroup(group); + const { instances } = group; + const displaySetInstanceUID = + options.displaySetInstanceUID ?? + instances[0]?.SeriesInstanceUID ?? + `display-set-${instances[0]?.imageId ?? 'unknown'}`; + + const first = instances[0]; + if (first && (isVideoInstance(first) || isEcgInstance(first))) { + return new BaseDisplaySet({ + displaySetInstanceUID, + viewportTypes, + frameImageIds: + options.frameImageIds ?? + instances.map((i) => i.imageId).filter(Boolean), + underlyingImageIds: instances.map((i) => i.imageId).filter(Boolean), + }); + } + + return ImageStackDisplaySet.fromInstances(instances, { + displaySetInstanceUID, + viewportTypes, + frameImageIds: options.frameImageIds, + }); +} diff --git a/packages/metadata/src/displayset/defaultDisplaySetSplitRules.ts b/packages/metadata/src/displayset/defaultDisplaySetSplitRules.ts new file mode 100644 index 0000000000..ce0ccfef26 --- /dev/null +++ b/packages/metadata/src/displayset/defaultDisplaySetSplitRules.ts @@ -0,0 +1,123 @@ +import { isEcgInstance } from './isEcgInstance'; +import { isImageSopClass } from './isImageSopClass'; +import { isVideoInstance } from './isVideoInstance'; +import type { SplitRule } from './types'; + +const VOLUME_MODALITIES = new Set(['CT', 'MR', 'PT', 'NM']); + +/** + * Default display-set split rules (OHIF PR parity + video, ECG, volume3d). + * Rules are evaluated in order; the first match wins. + * Each rule may set `viewportTypes` where index 0 is the preferred viewport. + */ +export const defaultDisplaySetSplitRules: SplitRule[] = [ + { + id: 'video', + viewportTypes: ['video'], + ruleSelector: (instance) => isVideoInstance(instance), + splitKey: ['SOPInstanceUID'], + customAttributes: () => ({ + viewportTypes: ['video'], + }), + }, + + { + id: 'ecg', + viewportTypes: ['ecg'], + ruleSelector: (instance) => isEcgInstance(instance), + splitKey: ['SOPInstanceUID'], + customAttributes: () => ({ + viewportTypes: ['ecg'], + }), + }, + + { + id: 'singleImageModality', + viewportTypes: ['stack'], + ruleSelector: (instance) => + ['CR', 'DX', 'MG'].includes(instance.Modality ?? '') && + isImageSopClass(instance.SOPClassUID) && + !!instance.Rows, + splitKey: [ + (instance) => + `rows=${Math.round(Number(instance.Rows) / 64)}&cols=${Math.round(Number(instance.Columns) / 64)}`, + ], + customAttributes: () => ({ + viewportTypes: ['stack'], + }), + }, + + { + id: 'multiFrame', + viewportTypes: ['stack'], + makeSeriesInfo: (instances, seriesInfo) => { + const { NumberOfFrames, SliceLocation } = instances[0]; + seriesInfo.isMultiFrame = + Number(NumberOfFrames) > 1 && SliceLocation !== undefined; + }, + ruleSelector: (_instance, seriesInfo) => !!seriesInfo.isMultiFrame, + splitKey: ['SeriesInstanceUID', 'InstanceNumber'], + customAttributes: ({ isMultiFrame }, options) => ({ + isClip: true, + numImageFrames: options.instances[0]?.NumberOfFrames, + splitNumber: options.splitNumber, + isMultiFrame, + viewportTypes: ['stack'], + }), + }, + + { + id: 'mixedDimensionalityBValue', + viewportTypes: ['stack', 'volume', 'volume3d'], + makeSeriesInfo: (instances, seriesInfo) => { + const [instance] = instances; + if (instance.Modality !== 'MR') { + return; + } + const hasBValue = instances.some((i) => i.DiffusionBValue !== undefined); + if (!hasBValue) { + return; + } + const missingBValue = instances.some( + (i) => i.DiffusionBValue === undefined + ); + if (hasBValue && missingBValue) { + seriesInfo.mixedBValue = true; + } + }, + ruleSelector: (_instance, seriesInfo) => !!seriesInfo.mixedBValue, + splitKey: [ + 'SeriesInstanceUID', + (instance) => instance.DiffusionBValue === undefined, + ], + customAttributes: () => ({ + viewportTypes: ['stack', 'volume', 'volume3d'], + }), + }, + + { + id: 'volume3d', + viewportTypes: ['volume3d', 'volume', 'stack'], + makeSeriesInfo: (instances, seriesInfo) => { + const modality = instances[0]?.Modality; + if (modality && VOLUME_MODALITIES.has(modality) && instances.length > 1) { + seriesInfo.supportsVolume3d = true; + } + }, + ruleSelector: (_instance, seriesInfo) => !!seriesInfo.supportsVolume3d, + splitKey: ['SeriesInstanceUID'], + customAttributes: () => ({ + viewportTypes: ['volume3d', 'volume', 'stack'], + }), + }, + + { + id: 'defaultImageRule', + viewportTypes: ['stack', 'volume', 'volume3d'], + ruleSelector: (instance) => + isImageSopClass(instance.SOPClassUID) && !!instance.Rows, + customAttributes: () => ({ + viewportTypes: ['stack', 'volume', 'volume3d'], + }), + }, +]; diff --git a/packages/metadata/src/displayset/displaySetProvider.ts b/packages/metadata/src/displayset/displaySetProvider.ts new file mode 100644 index 0000000000..4016eea30a --- /dev/null +++ b/packages/metadata/src/displayset/displaySetProvider.ts @@ -0,0 +1,21 @@ +import { MetadataModules } from '../enums'; +import { addAddProvider } from '../metaData'; +import { addWritableCacheForType } from '../utilities/metadataProvider/cacheData'; + +function displaySetAddProvider(next, query: string, _data, options) { + const displaySet = options?.displaySet; + if (displaySet) { + return displaySet; + } + return next(query, _data, options); +} + +/** + * Registers read/add typed providers for display set metadata. + */ +export function registerDisplaySetProviders() { + addWritableCacheForType(MetadataModules.DISPLAY_SET); + addAddProvider(MetadataModules.DISPLAY_SET, displaySetAddProvider, { + priority: 40_000, + }); +} diff --git a/packages/metadata/src/displayset/displayset.test.ts b/packages/metadata/src/displayset/displayset.test.ts new file mode 100644 index 0000000000..e3d1d6c1a8 --- /dev/null +++ b/packages/metadata/src/displayset/displayset.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from '@jest/globals'; +import { buildSeriesInfo } from './buildSeriesInfo'; +import { createDisplaySetFromGroup } from './createDisplaySetFromGroup'; +import { defaultDisplaySetSplitRules } from './defaultDisplaySetSplitRules'; +import { groupInstancesBySplitRules } from './groupInstancesBySplitRules'; +import { ImageStackDisplaySet } from './ImageStackDisplaySet'; +import { resolveInstances } from './resolveInstances'; +import { splitSeriesInstanceGroupsFromImageIds } from './splitSeriesInstanceGroupsFromImageIds'; +import type { NaturalizedInstance, SplitRule } from './types'; +import { getPreferredViewportType } from './viewportTypes'; + +describe('displayset split utilities', () => { + const instances: NaturalizedInstance[] = [ + { + imageId: 'wadors:1', + Modality: 'CT', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.2', + Rows: 512, + Columns: 512, + SeriesInstanceUID: '1.2.3', + InstanceNumber: 1, + }, + { + imageId: 'wadors:2', + Modality: 'CT', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.2', + Rows: 512, + Columns: 512, + SeriesInstanceUID: '1.2.3', + InstanceNumber: 2, + }, + ]; + + const getNaturalizedInstance = (imageId: string) => + instances.find((instance) => instance.imageId === imageId); + + it('resolveInstances preserves order and skips missing ids', () => { + const resolved = resolveInstances( + ['wadors:2', 'wadors:missing', 'wadors:1'], + getNaturalizedInstance + ); + expect(resolved.map((i) => i.imageId)).toEqual(['wadors:2', 'wadors:1']); + }); + + it('default rules group multi-slice CT as volume3d preferred', () => { + const groups = splitSeriesInstanceGroupsFromImageIds( + instances.map((i) => i.imageId!), + { + getNaturalizedInstance: (id) => instances.find((i) => i.imageId === id), + splitRules: defaultDisplaySetSplitRules, + } + ); + expect(groups).toHaveLength(1); + expect(groups[0].matchedRule.id).toBe('volume3d'); + const displaySet = createDisplaySetFromGroup(groups[0]); + expect(displaySet.viewportTypes[0]).toBe('volume3d'); + expect(displaySet.getPreferredViewportType()).toBe('volume3d'); + }); + + it('video rule uses video viewportTypes', () => { + const videoInstance: NaturalizedInstance = { + imageId: 'wadors:video', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.77.1.4.1', + Modality: 'US', + }; + const groups = splitSeriesInstanceGroupsFromImageIds(['wadors:video'], { + getNaturalizedInstance: () => videoInstance, + splitRules: defaultDisplaySetSplitRules, + }); + expect(groups[0].matchedRule.id).toBe('video'); + const displaySet = createDisplaySetFromGroup(groups[0]); + expect(displaySet.viewportTypes).toEqual(['video']); + expect(getPreferredViewportType(displaySet.viewportTypes)).toBe('video'); + }); + + it('ecg rule uses ecg viewportTypes', () => { + const ecgInstance: NaturalizedInstance = { + imageId: 'wadors:ecg', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.9.1.1', + Modality: 'ECG', + }; + const groups = splitSeriesInstanceGroupsFromImageIds(['wadors:ecg'], { + getNaturalizedInstance: () => ecgInstance, + splitRules: defaultDisplaySetSplitRules, + }); + expect(groups[0].matchedRule.id).toBe('ecg'); + expect(createDisplaySetFromGroup(groups[0]).viewportTypes[0]).toBe('ecg'); + }); + + it('splits MR mixed B-value series', () => { + const mrInstances: NaturalizedInstance[] = [ + { + imageId: 'wadors:a', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'series-mr', + DiffusionBValue: 800, + }, + { + imageId: 'wadors:b', + Modality: 'MR', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.4', + Rows: 256, + SeriesInstanceUID: 'series-mr', + }, + ]; + const groups = splitSeriesInstanceGroupsFromImageIds( + mrInstances.map((i) => i.imageId!), + { + getNaturalizedInstance: (id) => + mrInstances.find((i) => i.imageId === id), + splitRules: defaultDisplaySetSplitRules, + } + ); + expect(groups).toHaveLength(2); + }); + + it('ImageStackDisplaySet exposes underlying and frame ids', () => { + const displaySet = ImageStackDisplaySet.fromInstances(instances, { + displaySetInstanceUID: 'uid-1', + viewportTypes: ['stack', 'volume', 'volume3d'], + }); + expect(displaySet.getUnderlyingImageIds().size).toBe(2); + expect(displaySet.viewportTypes[0]).toBe('stack'); + expect(displaySet.getPreferredViewportType()).toBe('stack'); + }); + + it('groups by default image rule into a single bucket', () => { + const singleInstance = [instances[0]]; + const rules: SplitRule[] = [ + { + id: 'defaultImageRule', + viewportTypes: ['stack'], + ruleSelector: (instance) => + instance.SOPClassUID === '1.2.840.10008.5.1.4.1.1.2' && + !!instance.Rows, + }, + ]; + const seriesInfo = buildSeriesInfo(singleInstance, rules); + const groups = groupInstancesBySplitRules( + singleInstance, + rules, + seriesInfo + ); + expect(groups).toHaveLength(1); + expect(groups[0].instances).toHaveLength(1); + }); +}); diff --git a/packages/metadata/src/displayset/groupInstancesBySplitRules.ts b/packages/metadata/src/displayset/groupInstancesBySplitRules.ts new file mode 100644 index 0000000000..a2e193f864 --- /dev/null +++ b/packages/metadata/src/displayset/groupInstancesBySplitRules.ts @@ -0,0 +1,56 @@ +import type { + GroupedInstanceBucket, + NaturalizedInstance, + SeriesInfo, + SplitRule, +} from './types'; + +function buildSplitKey( + instance: NaturalizedInstance, + seriesInfo: SeriesInfo, + splitRule: SplitRule +): string { + const splitKey = splitRule.splitKey ?? ['SeriesInstanceUID']; + return splitKey + .map((key) => + typeof key === 'function' ? key(instance, seriesInfo) : instance[key] + ) + .join('&'); +} + +/** + * Groups instances into buckets using the first matching split rule per instance. + */ +export function groupInstancesBySplitRules( + instances: NaturalizedInstance[], + splitRules: SplitRule[], + seriesInfo: SeriesInfo +): GroupedInstanceBucket[] { + const instancesMap = new Map< + string, + { instances: NaturalizedInstance[]; matchedRule: SplitRule } + >(); + + for (const instance of instances) { + let addedToDisplaySet = false; + + for (const splitRule of splitRules) { + if ( + !addedToDisplaySet && + (!splitRule.ruleSelector || + splitRule.ruleSelector(instance, seriesInfo)) + ) { + addedToDisplaySet = true; + const key = buildSplitKey(instance, seriesInfo, splitRule); + + if (!instancesMap.has(key)) { + instancesMap.set(key, { instances: [], matchedRule: splitRule }); + } + + instancesMap.get(key)!.instances.push(instance); + } + } + } + + return Array.from(instancesMap.values()); +} diff --git a/packages/metadata/src/displayset/index.ts b/packages/metadata/src/displayset/index.ts new file mode 100644 index 0000000000..87c07274e6 --- /dev/null +++ b/packages/metadata/src/displayset/index.ts @@ -0,0 +1,37 @@ +export type { IDisplaySet } from './IDisplaySet'; +export { BaseDisplaySet } from './BaseDisplaySet'; +export type { BaseDisplaySetOptions } from './BaseDisplaySet'; +export { ImageStackDisplaySet } from './ImageStackDisplaySet'; +export type { ImageStackDisplaySetOptions } from './ImageStackDisplaySet'; +export { resolveInstances } from './resolveInstances'; +export type { ResolveInstancesOptions } from './resolveInstances'; +export { buildSeriesInfo } from './buildSeriesInfo'; +export { groupInstancesBySplitRules } from './groupInstancesBySplitRules'; +export { splitSeriesInstanceGroupsFromImageIds } from './splitSeriesInstanceGroupsFromImageIds'; +export type { SplitSeriesInstanceGroupsOptions } from './splitSeriesInstanceGroupsFromImageIds'; +export { + registerDisplaySetMetadata, + type RegisterDisplaySetMetadataOptions, +} from './registerDisplaySetMetadata'; +export { registerDisplaySetProviders } from './displaySetProvider'; +export { defaultDisplaySetSplitRules } from './defaultDisplaySetSplitRules'; +export { createDisplaySetFromGroup } from './createDisplaySetFromGroup'; +export type { CreateDisplaySetFromGroupOptions } from './createDisplaySetFromGroup'; +export { isImageSopClass } from './isImageSopClass'; +export { isVideoInstance } from './isVideoInstance'; +export { isEcgInstance } from './isEcgInstance'; +export { + getViewportTypesForRule, + getPreferredViewportType, + getViewportTypesForGroup, +} from './viewportTypes'; +export type { + NaturalizedInstance, + SeriesInfo, + SplitRule, + SplitContext, + SplitRuleOptions, + SplitRuleCustomAttributesContext, + GroupedInstanceBucket, + ViewportTypeHint, +} from './types'; diff --git a/packages/metadata/src/displayset/isEcgInstance.ts b/packages/metadata/src/displayset/isEcgInstance.ts new file mode 100644 index 0000000000..cfe1f3ac6f --- /dev/null +++ b/packages/metadata/src/displayset/isEcgInstance.ts @@ -0,0 +1,13 @@ +import type { NaturalizedInstance } from './types'; + +const ECG_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.9.1.1', + '1.2.840.10008.5.1.4.1.1.9.1.2', + '1.2.840.10008.5.1.4.1.1.9.1.3', + '1.2.840.10008.5.1.4.1.1.9.2.1', + '1.2.840.10008.5.1.4.1.1.9.3.1', +]); + +export function isEcgInstance(instance: NaturalizedInstance): boolean { + return ECG_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? ''); +} diff --git a/packages/metadata/src/displayset/isImageSopClass.ts b/packages/metadata/src/displayset/isImageSopClass.ts new file mode 100644 index 0000000000..cc155ec3e0 --- /dev/null +++ b/packages/metadata/src/displayset/isImageSopClass.ts @@ -0,0 +1,50 @@ +/** SOP Class UIDs that represent image storage (aligned with OHIF isImage). */ +const IMAGE_STORAGE_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.1', + '1.2.840.10008.5.1.4.1.1.1.1', + '1.2.840.10008.5.1.4.1.1.1.1.1', + '1.2.840.10008.5.1.4.1.1.2', + '1.2.840.10008.5.1.4.1.1.2.1', + '1.2.840.10008.5.1.4.1.1.2.2', + '1.2.840.10008.5.1.4.1.1.4', + '1.2.840.10008.5.1.4.1.1.4.1', + '1.2.840.10008.5.1.4.1.1.4.2', + '1.2.840.10008.5.1.4.1.1.4.3', + '1.2.840.10008.5.1.4.1.1.4.4', + '1.2.840.10008.5.1.4.1.1.7', + '1.2.840.10008.5.1.4.1.1.7.1', + '1.2.840.10008.5.1.4.1.1.7.2', + '1.2.840.10008.5.1.4.1.1.7.3', + '1.2.840.10008.5.1.4.1.1.7.4', + '1.2.840.10008.5.1.4.1.1.12.1', + '1.2.840.10008.5.1.4.1.1.12.1.1', + '1.2.840.10008.5.1.4.1.1.12.2', + '1.2.840.10008.5.1.4.1.1.12.2.1', + '1.2.840.10008.5.1.4.1.1.13.1.1', + '1.2.840.10008.5.1.4.1.1.13.1.2', + '1.2.840.10008.5.1.4.1.1.13.1.3', + '1.2.840.10008.5.1.4.1.1.13.1.4', + '1.2.840.10008.5.1.4.1.1.13.1.5', + '1.2.840.10008.5.1.4.1.1.13.1.6', + '1.2.840.10008.5.1.4.1.1.128', + '1.2.840.10008.5.1.4.1.1.77.1.1', + '1.2.840.10008.5.1.4.1.1.77.1.1.1', + '1.2.840.10008.5.1.4.1.1.77.1.2', + '1.2.840.10008.5.1.4.1.1.77.1.2.1', + '1.2.840.10008.5.1.4.1.1.77.1.3', + '1.2.840.10008.5.1.4.1.1.77.1.4', + '1.2.840.10008.5.1.4.1.1.77.1.4.1', + '1.2.840.10008.5.1.4.1.1.128.1', + '1.2.840.10008.5.1.4.1.1.128.2', + '1.2.840.10008.5.1.4.1.1.128.3', + '1.2.840.10008.5.1.4.1.1.128.4', + '1.2.840.10008.5.1.4.1.1.128.5', + '1.2.840.10008.5.1.4.1.1.77.1.6', +]); + +export function isImageSopClass(sopClassUID?: string): boolean { + if (!sopClassUID) { + return false; + } + return IMAGE_STORAGE_SOP_CLASS_UIDS.has(sopClassUID); +} diff --git a/packages/metadata/src/displayset/isVideoInstance.ts b/packages/metadata/src/displayset/isVideoInstance.ts new file mode 100644 index 0000000000..ecb382634b --- /dev/null +++ b/packages/metadata/src/displayset/isVideoInstance.ts @@ -0,0 +1,54 @@ +import type { NaturalizedInstance } from './types'; + +const VIDEO_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.77.1.2.1', + '1.2.840.10008.5.1.4.1.1.77.1.4.1', + '1.2.840.10008.5.1.4.1.1.77.1.1.1', +]); + +const SECONDARY_CAPTURE_SOP_CLASS_UIDS = new Set([ + '1.2.840.10008.5.1.4.1.1.7', + '1.2.840.10008.5.1.4.1.1.7.4', +]); + +const VIDEO_TRANSFER_SYNTAX_UIDS = new Set([ + '1.2.840.10008.1.2.4.102', + '1.2.840.10008.1.2.4.103', + '1.2.840.10008.1.2.4.104', + '1.2.840.10008.1.2.4.105', + '1.2.840.10008.1.2.4.106', + '1.2.840.10008.1.2.4.107', + '1.2.840.10008.1.2.4.108', +]); + +function getTransferSyntaxUid( + instance: NaturalizedInstance +): string | undefined { + const tsuid = + instance.AvailableTransferSyntaxUID || + instance.TransferSyntaxUID || + instance['00083002']; + return Array.isArray(tsuid) ? tsuid[0] : tsuid; +} + +/** Heuristic aligned with OHIF dicom-video SOP class handler. */ +export function isVideoInstance(instance: NaturalizedInstance): boolean { + const tsuid = getTransferSyntaxUid(instance); + if (tsuid && VIDEO_TRANSFER_SYNTAX_UIDS.has(tsuid)) { + return true; + } + + if (instance.SOPClassUID === '1.2.840.10008.5.1.4.1.1.77.1.4.1') { + return true; + } + + if (VIDEO_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? '')) { + return true; + } + + const numberOfFrames = Number(instance.NumberOfFrames) || 0; + return ( + SECONDARY_CAPTURE_SOP_CLASS_UIDS.has(instance.SOPClassUID ?? '') && + numberOfFrames >= 90 + ); +} diff --git a/packages/metadata/src/displayset/registerDisplaySetMetadata.ts b/packages/metadata/src/displayset/registerDisplaySetMetadata.ts new file mode 100644 index 0000000000..9424210afa --- /dev/null +++ b/packages/metadata/src/displayset/registerDisplaySetMetadata.ts @@ -0,0 +1,36 @@ +import { MetadataModules } from '../enums'; +import { addTyped } from '../metaData'; +import type { IDisplaySet } from './IDisplaySet'; + +export type RegisterDisplaySetMetadataOptions = { + /** Register on frame ids in addition to underlying ids. */ + includeFrameImageIds?: boolean; +}; + +/** + * Stores display set metadata in the typed metadata cache via addTyped. + */ +export function registerDisplaySetMetadata( + imageIds: string[], + displaySet: IDisplaySet, + options: RegisterDisplaySetMetadataOptions = {} +): void { + const idsToRegister = new Set(imageIds); + + if (options.includeFrameImageIds) { + for (const frameId of displaySet.getFrameImageIds()) { + idsToRegister.add(frameId); + } + } + + for (const underlyingId of displaySet.getUnderlyingImageIds()) { + idsToRegister.add(underlyingId); + } + + for (const imageId of idsToRegister) { + if (!imageId) { + continue; + } + addTyped(MetadataModules.DISPLAY_SET, imageId, { displaySet }); + } +} diff --git a/packages/metadata/src/displayset/resolveInstances.ts b/packages/metadata/src/displayset/resolveInstances.ts new file mode 100644 index 0000000000..b89d769669 --- /dev/null +++ b/packages/metadata/src/displayset/resolveInstances.ts @@ -0,0 +1,33 @@ +import type { NaturalizedInstance } from './types'; + +export type ResolveInstancesOptions = { + /** When true, skip missing ids silently; otherwise they are omitted with optional warn. */ + skipMissing?: boolean; + onMissing?: (imageId: string) => void; +}; + +/** + * Resolves imageIds to naturalized instances in input order. + */ +export function resolveInstances( + imageIds: string[], + getNaturalizedInstance: (imageId: string) => NaturalizedInstance | undefined, + options: ResolveInstancesOptions = {} +): NaturalizedInstance[] { + const { skipMissing = true, onMissing } = options; + const instances: NaturalizedInstance[] = []; + + for (const imageId of imageIds) { + const instance = getNaturalizedInstance(imageId); + if (!instance) { + if (!skipMissing) { + throw new Error(`No naturalized instance for imageId: ${imageId}`); + } + onMissing?.(imageId); + continue; + } + instances.push(instance); + } + + return instances; +} diff --git a/packages/metadata/src/displayset/splitSeriesInstanceGroupsFromImageIds.ts b/packages/metadata/src/displayset/splitSeriesInstanceGroupsFromImageIds.ts new file mode 100644 index 0000000000..ad6552acd9 --- /dev/null +++ b/packages/metadata/src/displayset/splitSeriesInstanceGroupsFromImageIds.ts @@ -0,0 +1,30 @@ +import { buildSeriesInfo } from './buildSeriesInfo'; +import { groupInstancesBySplitRules } from './groupInstancesBySplitRules'; +import { resolveInstances } from './resolveInstances'; +import type { GroupedInstanceBucket, SplitContext, SplitRule } from './types'; + +export type SplitSeriesInstanceGroupsOptions = SplitContext & { + splitRules: SplitRule[]; + onMissingImageId?: (imageId: string) => void; +}; + +/** + * Primary entrypoint: splits a series represented by metadata imageIds into instance groups. + */ +export function splitSeriesInstanceGroupsFromImageIds( + imageIds: string[], + options: SplitSeriesInstanceGroupsOptions +): GroupedInstanceBucket[] { + const { getNaturalizedInstance, splitRules, onMissingImageId } = options; + + const instances = resolveInstances(imageIds, getNaturalizedInstance, { + onMissing: onMissingImageId, + }); + + if (!instances.length) { + return []; + } + + const seriesInfo = buildSeriesInfo(instances, splitRules); + return groupInstancesBySplitRules(instances, splitRules, seriesInfo); +} diff --git a/packages/metadata/src/displayset/types.ts b/packages/metadata/src/displayset/types.ts new file mode 100644 index 0000000000..f243da3423 --- /dev/null +++ b/packages/metadata/src/displayset/types.ts @@ -0,0 +1,85 @@ +/** + * Naturalized DICOM instance used by display-set split rules and metadata. + * OHIF naturalized instances satisfy this type; additional tags are allowed. + */ +export type NaturalizedInstance = { + imageId?: string; + Modality?: string; + SOPClassUID?: string; + Rows?: number; + Columns?: number; + NumberOfFrames?: number; + SliceLocation?: number; + SeriesInstanceUID?: string; + InstanceNumber?: number; + DiffusionBValue?: number; + TransferSyntaxUID?: string; + AvailableTransferSyntaxUID?: string; + [key: string]: unknown; +}; + +export type ViewportTypeHint = + | 'stack' + | 'volume' + | 'volume3d' + | 'video' + | 'wholeslide' + | 'ecg' + | string; + +export type SeriesInfo = { + NumberOfSeriesRelatedInstances: number; + numberOfFrames: number; + numImageFrames: number; + numberOfNonImageObjects: number; + numberOfSOPInstanceUIDsPerSeries: number; + isMultiFrame?: boolean; + mixedBValue?: boolean; + supportsVolume3d?: boolean; + [key: string]: unknown; +}; + +export type SplitRuleCustomAttributesContext = { + instance: NaturalizedInstance; + isMultiFrame?: boolean; + sopClassUids?: string[]; + viewportTypes?: readonly ViewportTypeHint[]; + [key: string]: unknown; +}; + +export type SplitRuleOptions = { + instances: NaturalizedInstance[]; + splitNumber?: number; + descriptionName?: string; +}; + +export type SplitRule = { + id?: string; + /** Allowed viewport types; index 0 is the preferred viewport type. */ + viewportTypes?: readonly ViewportTypeHint[]; + ruleSelector?: ( + instance: NaturalizedInstance, + seriesInfo: SeriesInfo + ) => boolean; + splitKey?: ( + | string + | ((instance: NaturalizedInstance, seriesInfo: SeriesInfo) => unknown) + )[]; + makeSeriesInfo?: ( + instances: NaturalizedInstance[], + seriesInfo: SeriesInfo + ) => SeriesInfo | void; + customAttributes?: ( + attributes: SplitRuleCustomAttributesContext, + options: SplitRuleOptions + ) => Record; +}; + +export type SplitContext = { + getNaturalizedInstance: (imageId: string) => NaturalizedInstance | undefined; +}; + +export type GroupedInstanceBucket = { + instances: NaturalizedInstance[]; + matchedRule: SplitRule; +}; diff --git a/packages/metadata/src/displayset/viewportTypes.ts b/packages/metadata/src/displayset/viewportTypes.ts new file mode 100644 index 0000000000..ee331d0a43 --- /dev/null +++ b/packages/metadata/src/displayset/viewportTypes.ts @@ -0,0 +1,32 @@ +import type { + GroupedInstanceBucket, + SplitRule, + ViewportTypeHint, +} from './types'; + +const DEFAULT_VIEWPORT_TYPES: readonly ViewportTypeHint[] = ['stack']; + +/** + * Resolves viewport types for a matched split rule. + * `viewportTypes[0]` is the preferred viewport type. + */ +export function getViewportTypesForRule( + rule: SplitRule +): readonly ViewportTypeHint[] { + if (rule.viewportTypes?.length) { + return rule.viewportTypes; + } + return DEFAULT_VIEWPORT_TYPES; +} + +export function getPreferredViewportType( + viewportTypes: readonly ViewportTypeHint[] +): ViewportTypeHint { + return viewportTypes[0] ?? 'stack'; +} + +export function getViewportTypesForGroup( + group: GroupedInstanceBucket +): readonly ViewportTypeHint[] { + return getViewportTypesForRule(group.matchedRule); +} diff --git a/packages/metadata/src/enums/CalibrationTypes.ts b/packages/metadata/src/enums/CalibrationTypes.ts new file mode 100644 index 0000000000..ac77075006 --- /dev/null +++ b/packages/metadata/src/enums/CalibrationTypes.ts @@ -0,0 +1,60 @@ +/** + * Defines the calibration types available. These define how the units + * for measurements are specified. + */ +export enum CalibrationTypes { + /** + * Not applicable means the units are directly defind by the underlying + * hardware, such as CT and MR volumetric displays, so no special handling + * or notification is required. + */ + NOT_APPLICABLE = '', + /** + * ERMF is estimated radiographic magnification factor. This defines how + * much the image is magnified at the detector as opposed to the location in + * the body of interest. This occurs because the radiation beam is expanding + * and effectively magnifies the image on the detector compared to where the + * point of interest in the body is. + * This suggests that measurements can be partially trusted, but the user + * still needs to be aware that different depths within the body have differing + * ERMF values, so precise measurements would still need to be manually calibrated. + */ + ERMF = 'ERMF', + /** + * User calibration means that the user has provided a custom calibration + * specifying how large the image data is. This type can occur on + * volumetric images, eg for scout images that might have invalid spacing + * tags. + */ + USER = 'User', + /** + * A projection calibration means the raw detector size, without any + * ERMF applied, meaning that the size in the body cannot be trusted and + * that a calibration should be applied. + * This is different from Error in that there is simply no magnification + * factor applied as opposed to having multiple, inconsistent magnification + * factors. + */ + PROJECTION = 'Proj', + /** + * A region calibration is used for other types of images, typically + * ultrasouunds where the distance in the image may mean something other than + * physical distance, such as mV or Hz or some other measurement values. + */ + REGION = 'Region', + /** + * Error is used to define mismatches between various units, such as when + * there are two different ERMF values specified. This is an indication to + * NOT trust the measurement values but to manually calibrate. + */ + ERROR = 'Error', + /** Uncalibrated image */ + UNCALIBRATED = 'Uncalibrated', + /** When the calibration is present and can be accessed by the + * PixelSpacingCalibrationType or PixelSpacingCalibrationDescription tags*/ + CALIBRATED = 'Calibrated', + /** When it is unknown if the pixelSpacing is calibrated*/ + UNKNOWN = 'Unknown', +} + +export default CalibrationTypes; diff --git a/packages/metadata/src/enums/MetadataModules.ts b/packages/metadata/src/enums/MetadataModules.ts new file mode 100644 index 0000000000..c04e2465f9 --- /dev/null +++ b/packages/metadata/src/enums/MetadataModules.ts @@ -0,0 +1,234 @@ +/** + * Contains the names for the metadata modules. + * Recommendation is to add all module names here rather than having them + * just use string names. + * The naming convention is that the enum has the modules in it, so the + * enum key does not repeat the Modules, but the enum value does (to agree + * with existing naming conventions) + */ +enum MetadataModules { + CALIBRATION = 'calibrationModule', + CINE = 'cineModule', + FRAME_OF_REFERENCE = 'frameOfReferenceModule', + GENERAL_IMAGE = 'generalImageModule', + GENERAL_SERIES = 'generalSeriesModule', + GENERAL_STUDY = 'generalStudyModule', + IMAGE_PIXEL = 'imagePixelModule', + IMAGE_PLANE = 'imagePlaneModule', + IMAGE_URL = 'imageUrlModule', + MODALITY_LUT = 'modalityLutModule', + MULTIFRAME = 'multiframeModule', + NM_MULTIFRAME_GEOMETRY = 'nmMultiframeGeometryModule', + OVERLAY_PLANE = 'overlayPlaneModule', + PATIENT = 'patientModule', + /** @deprecated Prefer PATIENT; alias for legacy WADO image loader tag name */ + PATIENT_DEMOGRAPHIC = 'patientDemographicModule', + PATIENT_STUDY = 'patientStudyModule', + PET_IMAGE = 'petImageModule', + PET_ISOTOPE = 'petIsotopeModule', + PET_SERIES = 'petSeriesModule', + /** SUV/dose scaling derived from instance (PT: suvbw/suvlbm/suvbsa; RTDOSE: DoseGridScaling etc.) */ + SCALING = 'scalingModule', + SOP_COMMON = 'sopCommonModule', + ULTRASOUND_ENHANCED_REGION = 'ultrasoundEnhancedRegionModule', + ECG = 'ecgModule', + VOI_LUT = 'voiLutModule', + + /** Transfer syntax information*/ + TRANSFER_SYNTAX = 'transferSyntax', + + /* + * Functional groups are the metadata modules defines for functional group + * sequences. Note these are UPPER camel case to agree with normalized + * representations so that the data is directly usable from shared/per frame + * sequences. + */ + + /** Functional group for the plane orientation */ + PLANE_ORIENTATION = 'PlaneOrientation', + + /** Functional group for the plat position */ + PLANE_POSITION = 'PlanePosition', + + /** Functional group for pixel measures */ + PIXEL_MEASURES = 'PixelMeasures', + + /** Functional group for xray geometry */ + XRAY_GEOMETRY = 'XrayGeometry', + + /** Functional group frame pixel data */ + FRAME_PIXEL_DATA = 'FramePixelData', + + /** + * The frame module is used to get information on the number of frames + * in the sop instance, and the current frame number, independently of the + * registration method. + */ + FRAME_MODULE = 'frameModule', + /** + * The uriModule provides just basic information extractable from the URI. + * At a minimum, this shall include the frame number being referenced + * This is closely related to the frame module, but the frame module includes + * additional information. + */ + URI_MODULE = 'uriModule', + + /** + * Some modules need direct access to a data services (WADO) web client. + * This allows getting images and metadata as raw results for display. + * This is DICOMweb WADO, not base WADO, and should support: + * * Series level metadata retrieve + * * Bulkdata retrieve + * * Image retrieve + */ + WADO_WEB_CLIENT = 'wadoWebClient', + + /** + * The instance data is a single per-frame object data that has per-frame + * computed data already added and combined into a single object. + * This object may NOT be iterable for attributes within it, because it + * uses inheritance to combine different levels of the data. + * + * In the legacy metadata modules, this could be a call time generated object + * where it combines all the individual modules back into an instance. However, + * in the newer metadata module, this is a base object used to create other modules + * assuming this item has already been stored. + * + * Typically this object will be created from the 'natural' object using the + * combineFramesInstance utility function. + */ + INSTANCE = 'instance', + + /** + * There are some convenience methods to get partial metadata related to a + * study referencing the existing one in various ways. + * These are Normalized referenced, eg upper camel case references + * used for creating new instance data. + * + * See the adapters package for standard methods to create these. + */ + + /** + * Reference object for the frame of the imageId provided + */ + IMAGE_SOP_INSTANCE_REFERENCE = 'ImageSopInstanceReference', + /** + * Reference object starting with the series to the sop instance + * provided. + * + * This will likely need to be merged with other series references + */ + REFERENCED_SERIES_REFERENCE = 'ReferencedSeriesReference', + + /** + * Creates a predecessor sequence to indicate the new object replaces + * the old one. + * + * Also includes the series level attributes that this object has + * in order to allow placing the new instance into the same series. + */ + PREDECESSOR_SEQUENCE = 'PredecessorSequence', + + /** + * The study data module contains the normalized values associated with the + * study header, including StudyInstanceUID, PatientID and the other cross- + * study information. + * + * This should be used as a basis for adding a new series to an existing study. + */ + STUDY_DATA = 'StudyData', + /** + * The Series Data module contains the normalized values associated with the + * series object, PLUS the study instance uid. + * + * This should be combined with the study data to add new instances to an + * existing series. + */ + SERIES_DATA = 'SeriesData', + + /** + * The image data module has the image specific information associated with + * the image frame of interest. + * + * This is used when modifying study structure such as creating a multiframe + * reference used internally for segmentation. + */ + IMAGE_DATA = 'ImageData', + + /** + * Static data for various header initialization. + * This change allows writing a custom provider to replace the metadata + * either on a per-instance basis or the default data. + */ + /** + * The basic header data for new RTSS instances + */ + RTSS_INSTANCE_DATA = 'RtssInstanceData', + /** + * Generic new instance data, including study and new series instance data. + */ + NEW_INSTANCE_DATA = 'NewInstanceData', + + /** + * Meta module providers return the _meta field for a new instance + */ + /** Metadata module for RTSS contour */ + RTSS_CONTOUR = 'metaRTSSContour', + /** Metadata module for single bit segmentation */ + SEG_BIT = 'metaSegBitmap', + /** Metadata module for RTSS annotations */ + SR_ANNOTATION = 'metaSrAnnotation', + + /** + * Compressed frame data: transferSyntaxUid, frameOfInterest, frameNumber, + * and pixelData (from NATURALIZED when available). Use getMetaData(MetadataModules.COMPRESSED_FRAME_DATA, imageId, { frameIndex }). + */ + COMPRESSED_FRAME_DATA = 'compressedFrameData', + /** Canonical base imageId derived from a frame-specific or base imageId query. */ + BASE_IMAGE_ID = 'baseImageId', + /** Frame imageIds resolved/generated for a base imageId query. */ + FRAME_IMAGE_IDS = 'frameImageIds', + + /** + * The natural metadata is the naturalized instance data without any frame + * references/per-frame data added. + * + * This is an upper camel case DICOM tag name object, where VM=1 attributes or + * VM=0-1 attributes are represented by a single object vlaue instead of an array. + * All other attribute values are represented as an array. + * + * For example: + * + * ``` + * { + * PatientName: 'Doe^John', + * PatientID: '1234567890', + * ModalitiesInStudy: ['CT', 'MR'], + * } + * ``` + * Every frame of a multiframe should have the same natural value. + * The per-frame data object is generated from the natural value object - see INSTANCE + */ + NATURALIZED = 'naturalized', + + /** + * Display set metadata for a series group (frame and underlying image ids, + * viewport hints). Registered per imageId via addTyped / registerDisplaySetMetadata. + */ + DISPLAY_SET = 'displaySetModule', +} + +export const ADD_MODULE_TYPE_SUFFIX = 'Add'; + +/** + * Returns the add-path module type for a base metadata module. + * + * The metadata add pipeline registers providers under a derived module name + * (e.g. `naturalizedAdd`) so ingestion handlers can be isolated from read-path + * `get` providers. + */ +export function getAddModuleType(type: string) { + return `${type}${ADD_MODULE_TYPE_SUFFIX}`; +} + +export default MetadataModules; diff --git a/packages/metadata/src/enums/index.ts b/packages/metadata/src/enums/index.ts new file mode 100644 index 0000000000..b4df18bac8 --- /dev/null +++ b/packages/metadata/src/enums/index.ts @@ -0,0 +1,5 @@ +import CalibrationTypes from './CalibrationTypes'; +import MetadataModules from './MetadataModules'; +export { getAddModuleType, ADD_MODULE_TYPE_SUFFIX } from './MetadataModules'; + +export { CalibrationTypes, MetadataModules }; diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts new file mode 100644 index 0000000000..8d6edc3043 --- /dev/null +++ b/packages/metadata/src/index.ts @@ -0,0 +1,44 @@ +export * as Enums from './enums'; +export { version } from './version'; +export * as metaData from './metaData'; +export * as utilities from './utilities'; +export * as displaySet from './displayset'; +export type { + IDisplaySet, + BaseDisplaySetOptions, + ImageStackDisplaySetOptions, + ResolveInstancesOptions, + SplitSeriesInstanceGroupsOptions, + RegisterDisplaySetMetadataOptions, + NaturalizedInstance, + SeriesInfo, + SplitRule, + SplitContext, + SplitRuleOptions, + SplitRuleCustomAttributesContext, + GroupedInstanceBucket, + ViewportTypeHint, +} from './displayset'; +export { + BaseDisplaySet, + ImageStackDisplaySet, + resolveInstances, + buildSeriesInfo, + groupInstancesBySplitRules, + splitSeriesInstanceGroupsFromImageIds, + registerDisplaySetMetadata, + registerDisplaySetProviders, + defaultDisplaySetSplitRules, + createDisplaySetFromGroup, + isImageSopClass, + isVideoInstance, + isEcgInstance, + getViewportTypesForRule, + getPreferredViewportType, + getViewportTypesForGroup, +} from './displayset'; +export type { CreateDisplaySetFromGroupOptions } from './displayset'; +export * as logging from './utilities/logging'; +export { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv'; +export { registerDefaultProviders } from './registerDefaultProviders'; +export type * from './types'; diff --git a/packages/metadata/src/metaData.ts b/packages/metadata/src/metaData.ts new file mode 100644 index 0000000000..61e9a4fbda --- /dev/null +++ b/packages/metadata/src/metaData.ts @@ -0,0 +1,389 @@ +// This module defines a way to access various metadata about an imageId. This layer of abstraction exists +// So metadata can be provided in different ways (e.g. by parsing DICOM P10 or by a WADO-RS document) + +import type { MetadataModuleType } from './types'; +import { getAddModuleType } from './enums'; + +const providers = []; + +const typedProviderValueMap = new Map(); + +const typedProviderMap = new Map(); + +export type TypedProviderValue = { + provider: TypedProvider; + priority: number; + isDefault: boolean; + /** Clears any data from this instance */ + clear?: () => void; + /** Clears just this type/query pair */ + clearQuery?: (query: string) => void; +}; + +export type TypedProvider = ( + next: TypedProviderBound, + query: string, + data?, + options? +) => unknown; + +export type TypedProviderBound = (query: string, data?, options?) => unknown; + +/** + * Adds a metadata provider with the specified priority + * @param provider - Metadata provider function + * @param priority - 0 is default/normal, > 0 is high, < 0 is low + * + * @category MetaData + */ +export function addProvider( + provider: (type: string, ...query: string[]) => unknown, + priority = 0 +): void { + if (providers.some((p) => p.provider === provider)) { + return; + } + + let i; + + // Find the right spot to insert this provider based on priority + for (i = 0; i < providers.length; i++) { + if (providers[i].priority <= priority) { + break; + } + } + + // Insert the decode task at position i + providers.splice(i, 0, { + priority, + provider, + }); +} + +const nullProvider = (_query, _data, options) => options?.defaultValue; + +function insertPriority( + type: string, + list, + provider, + options +): TypedProviderBound { + const providerValue = { type, ...options, provider }; + if (!list.find((it) => it.provider === provider)) { + let i; + const { priority = 0 } = options; + + // Find the right spot to insert this provider based on priority + for (i = 0; i < list.length; i++) { + if (list[i].priority <= priority) { + break; + } + } + + // Insert the decode task at position i + list.splice(i, 0, providerValue); + } + + let currentProvider = nullProvider; + for (let i = list.length - 1; i >= 0; i--) { + const p = list[i].provider; + if (p) { + currentProvider = p.bind(null, currentProvider); + } + } + return currentProvider; +} + +export interface TypedProviderOptions { + priority?: number; + requires?: string[]; + isDefault?: boolean; + clear?: () => void; + clearQuery?: (query: string) => void; +} + +/** + * Adds a typed provider at the given priority level + * + * Typed providers all run as part of the standard provider framework at + * priority -1000. They differ from regular providers in that each provider + * function handles exactly one type + * + * Note: All typed providers are included overall at priority "-1000" with the + * global priority - that is, at the last item so that the existing non-typed + * providers all run first. + */ +export function addTypedProvider( + type: string, + provider: TypedProvider, + options: TypedProviderOptions = { priority: 0, isDefault: true } +) { + if (!provider) { + throw new Error( + `addTypedProvider: cannot register undefined provider for type "${type}"` + ); + } + let list = typedProviderValueMap.get(type); + if (!list) { + list = new Array(); + typedProviderValueMap.set(type, list); + } + if (list.find((it) => it.provider === provider)) { + return; + } + const newProvider = insertPriority(type, list, provider, options); + if (!newProvider) { + throw new Error(`newProvider is empty for ${type}`); + } + typedProviderMap.set(type, newProvider); +} + +/** + * Adds an add-path typed provider at the given priority level. + * + * The add-path provider chain is keyed by appending "Add" to the type and is + * resolved via add()/addTyped(). + */ +export function addAddProvider( + type: string, + provider: TypedProvider, + options: TypedProviderOptions = { priority: 0, isDefault: true } +) { + addTypedProvider(getAddModuleType(type), provider, options); +} + +/** + * A provider bridge for typed metadata modules. + */ +export function metadataModuleProvider(type: string, query: string, options) { + const typedProvider = typedProviderMap.get(type); + if (!typedProvider) { + return; + } + return typedProvider(query, null, options); +} + +/** + * Removes the specified provider + * + * @param provider - Metadata provider function + * + * @category MetaData + */ +export function removeProvider( + provider: (type: string, query: unknown) => unknown +): void { + for (let i = 0; i < providers.length; i++) { + if (providers[i].provider === provider) { + providers.splice(i, 1); + + break; + } + } +} + +const TYPED_PROVIDER_BRIDGE_PRIORITY = -1000; + +/** + * Removes all providers, clears all typed providers, and re-adds the typed + * provider bridge at the end so getMetaData can still resolve typed types + * after registerDefaultProviders() is called again. + * + * @category MetaData + */ +export function removeAllProviders(): void { + while (providers.length > 0) { + providers.pop(); + } + typedProviderValueMap.clear(); + typedProviderMap.clear(); +} + +/** + * Gets metadata from the registered metadata providers. Will call each one from highest priority to lowest + * until one responds + * + * @param type - The type of metadata requested from the metadata store + * @param query - The query for the metadata store, often imageId + * Some metadata providers support multi-valued strings, which are interpreted + * as the provider chooses. + * + * @returns The metadata retrieved from the metadata store + * @category MetaData + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getMetaData(type: string, query: string, options?): any { + // Invoke each provider in priority order until one returns something + if (query === undefined) { + return; + } + for (let i = 0; i < providers.length; i++) { + const result = providers[i].provider(type, query, options); + if (result !== undefined) { + return result; + } + } +} + +/** + * Gets metadata with the return type inferred from the module type. + * Pass a name from MetadataModules (e.g. MetadataModules.COMPRESSED_FRAME_DATA) or the constant + * string (e.g. 'compressedFrameData'); T is inferred from MetadataModuleType. + * For module types not in that map, the return type is undefined (never in union). + * + * @param type - The metadata module type (MetadataModules constant or literal string) + * @param query - The query (e.g. imageId) + * @param options - Optional options (e.g. { frameIndex }) + * @returns The result, typed per module, or undefined + * @category MetaData + */ +export function getTyped( + type: K, + query: string, + options?: unknown +): + | (K extends keyof MetadataModuleType ? MetadataModuleType[K] : never) + | undefined { + return getMetaData(type as string, query, options) as + | (K extends keyof MetadataModuleType ? MetadataModuleType[K] : never) + | undefined; +} + +/** + * Performs metadata ingestion on the add-path provider chain. + * Uses the type key `${type}Add`. + */ +export function addMetaData(type: string, query: string, options?): unknown { + return getMetaData(getAddModuleType(type), query, options) as unknown; +} + +/** + * Adds metadata with return type inferred from module type, same mapping as + * getTyped() but allowing async ingestion handlers. + */ +export function addTyped( + type: K, + query: string, + options?: unknown +): + | (K extends keyof MetadataModuleType ? MetadataModuleType[K] : never) + | Promise + | undefined { + const result = addMetaData(type as string, query, options); + + return result as + | (K extends keyof MetadataModuleType ? MetadataModuleType[K] : never) + | Promise< + K extends keyof MetadataModuleType ? MetadataModuleType[K] : never + > + | undefined; +} + +/** + * Clears cached data on the specific type + * and query key + */ +export const clearQuery = (type: string, query?: string) => { + const typesToClear = [type, getAddModuleType(type)]; + + for (const currentType of typesToClear) { + const typedProviders = typedProviderValueMap.get(currentType); + if (!typedProviders) { + continue; + } + for (const providerInfo of typedProviders) { + providerInfo?.clearQuery?.(query); + } + } +}; + +/** + * Clears cached data on the specific type + * and query key + */ +export const clear = (type: string) => { + const typesToClear = Array.from(new Set([type, getAddModuleType(type)])); + + for (const currentType of typesToClear) { + const typedProviders = typedProviderValueMap.get(currentType); + if (!typedProviders) { + continue; + } + for (const providerInfo of typedProviders) { + providerInfo?.clear?.(); + } + } +}; + +export const get = (type: string, ...queries: string[]) => + queries.length === 1 + ? getMetaData(type, queries[0]) + : queries + .map((query) => query && getMetaData(type, query)) + .find((it) => it !== undefined); + +/** + * Retrieves metadata from a DICOM image and returns it as an object with capitalized keys. + * @param imageId - the imageId + * @param metaDataProvider - The metadata provider either wadors or wadouri + * @param types - An array of metadata types to retrieve. + * @returns An object containing the retrieved metadata with capitalized keys. + */ +export function getNormalized( + imageId: string, + types: string[], + metaDataProvider = getMetaData +) { + const result = {}; + for (const t of types) { + try { + const data = metaDataProvider(t, imageId); + if (data) { + const capitalizedData = {}; + for (const key in data) { + if (key in data) { + const capitalizedKey = toUpperCamelTag(key); + capitalizedData[capitalizedKey] = data[key]; + } + } + Object.assign(result, capitalizedData); + } + } catch (error) { + console.error(`Error retrieving ${t} data:`, error); + } + } + + return result; +} + +/** + * Converts a tag name to UpperCamelCase + */ +export const toUpperCamelTag = (tag: string) => { + if (tag.startsWith('sop')) { + return `SOP${tag.substring(3)}`; + } + if (tag.startsWith('voi')) { + return `VOI${tag.substring(3)}`; + } + if (tag.endsWith('Id')) { + tag = `${tag.substring(0, tag.length - 2)}ID`; + } + return tag.charAt(0).toUpperCase() + tag.slice(1); +}; + +/** + * Converts a tag name to lowerCamelCase + */ +export const toLowerCamelTag = (tag: string) => { + if (tag.startsWith('SOP')) { + return `sop${tag.substring(3)}`; + } + if (tag.startsWith('VOI')) { + return `voi${tag.substring(3)}`; + } + if (tag.endsWith('ID') && !tag.endsWith('UID')) { + tag = `${tag.substring(0, tag.length - 2)}Id`; + } + return tag.charAt(0).toLowerCase() + tag.slice(1); +}; diff --git a/packages/metadata/src/registerDefaultProviders.ts b/packages/metadata/src/registerDefaultProviders.ts new file mode 100644 index 0000000000..0fd75e48d8 --- /dev/null +++ b/packages/metadata/src/registerDefaultProviders.ts @@ -0,0 +1,102 @@ +import { addProvider, metadataModuleProvider } from './metaData'; +import calibratedPixelSpacingMetadataProvider from './utilities/calibratedPixelSpacingMetadataProvider'; +import { registerCacheProviders } from './utilities/metadataProvider/cacheData'; +import { registerUriModule } from './utilities/metadataProvider/uriModule'; +import { registerDataLookup } from './utilities/metadataProvider/dataLookup'; +import { registerInstanceFromListener } from './utilities/metadataProvider/instanceFromListener'; +import { registerCombineFrameProvider } from './utilities/metadataProvider/combineFrameInstance'; +import { + clearTagModules, + registerTagModules, +} from './utilities/metadataProvider/tagModules'; +import { registerImagePlaneCalibrated } from './utilities/metadataProvider/imagePlaneCalibrated'; +import { registerCalibrationModule } from './utilities/metadataProvider/calibrationModule'; +import { registerPixelDataUpdate } from './utilities/metadataProvider/pixelDataUpdate'; +import { registerTransferSyntaxProvider } from './utilities/metadataProvider/transferSyntaxProvider'; +import { registerEcgFromInstanceProvider } from './utilities/metadataProvider/ecgFromInstance'; +import { registerCompressedFrameDataProvider } from './utilities/metadataProvider/compressedFrameData'; +import { registerScalingFromInstanceProvider } from './utilities/metadataProvider/scalingFromInstance'; +import { registerNaturalizedHandlers } from './utilities/metadataProvider/naturalizedHandlers'; +import { registerImageIdProviders } from './utilities/metadataProvider/imageIdsProviders'; +import { registerDisplaySetProviders } from './displayset/displaySetProvider'; + +const TYPED_PROVIDER_BRIDGE_PRIORITY = -1000; + +/** + * Registers the default/base typed metadata providers. + * + * This sets up the typed provider infrastructure including: + * - The typed provider bridge (makes typed providers available via the general provider chain) + * - Cache providers for instance, image plane, URI, and frame modules + * - URI module provider (extracts frame info from imageId URIs) + * - Data lookup providers (bridge between instance data and specific modules) + * - Instance provider (bridges INSTANCE to NATURALIZED) + * - Combine frame provider (handles multiframe instances) + * - Tag modules (converts instance data to module-specific results) + * - Image plane calibrated provider + * - Calibration module provider + * - Pixel data update provider (palette color handling) + * - Transfer syntax provider + * + * Call at start or after removeAllProviders() to re-establish typed providers; duplicates are skipped. + */ +export function registerDefaultProviders() { + // Register the typed provider bridge at low priority so that + // legacy providers added via addProvider() run first + addProvider(metadataModuleProvider, TYPED_PROVIDER_BRIDGE_PRIORITY); + + // Register calibrated pixel spacing so metaData.get('calibratedPixelSpacing', imageId) resolves + addProvider(calibratedPixelSpacingMetadataProvider.get); + + // Clear tag modules cache so registerTagModules() gets fresh providers + // (after removeAllProviders the typed map is cleared but tagModules' map isn't) + clearTagModules(); + + // Register cache providers + registerCacheProviders(); + + // Register NATURALIZED read filters and add-path ingestion handlers + registerNaturalizedHandlers(); + + // Register URI module provider + registerUriModule(); + + // Register imageId provider pipeline with front-end cache + registerImageIdProviders(); + + // Display set metadata (OHIF display sets registered per imageId) + registerDisplaySetProviders(); + + // Register data lookup providers + registerDataLookup(); + + // Register INSTANCE -> NATURALIZED bridge + registerInstanceFromListener(); + + // Register combine frame provider + registerCombineFrameProvider(); + + // Register tag modules for all known metadata modules + registerTagModules(); + + // Full ECG module from instance (waveformData.retrieveBulkData) for ECGViewport + registerEcgFromInstanceProvider(); + + // Register image plane calibrated provider + registerImagePlaneCalibrated(); + + // Register calibration module provider + registerCalibrationModule(); + + // Register pixel data update provider + registerPixelDataUpdate(); + + // Register transfer syntax provider + registerTransferSyntaxProvider(); + + // Compressed frame data (pixel data from NATURALIZED) via getMetaData('compressedFrameData', imageId, { frameIndex }) + registerCompressedFrameDataProvider(); + + // scalingModule from instance (PT: SUV factors; RTDOSE: DoseGridScaling etc.) + registerScalingFromInstanceProvider(); +} diff --git a/packages/metadata/src/types/DicomStreamTypes.ts b/packages/metadata/src/types/DicomStreamTypes.ts new file mode 100644 index 0000000000..19f1c98041 --- /dev/null +++ b/packages/metadata/src/types/DicomStreamTypes.ts @@ -0,0 +1,20 @@ +export interface IListenerInfo { + vr?: string; + vm?: number | string; + length?: number; + name?: string; +} + +export type MetadataValueType = + | ArrayBuffer[] + | ArrayBuffer + | string + | number + | MetadataType; + +export interface MetadataType { + Value?: MetadataValueType[]; + BulkDataURI?: string; + BulkDataUUID?: string; + vr: string; +} diff --git a/packages/metadata/src/types/IImageCalibration.ts b/packages/metadata/src/types/IImageCalibration.ts new file mode 100644 index 0000000000..795865ee72 --- /dev/null +++ b/packages/metadata/src/types/IImageCalibration.ts @@ -0,0 +1,41 @@ +import type CalibrationTypes from '../enums/CalibrationTypes'; + +/** + * IImageCalibration is an object that stores information about the type + * of image calibration. + */ +export interface IImageCalibration { + /** + * The pixel spacing for the image, in mm between pixel centers + * These are not required, and are deprecated in favour of getting the original + * image spacing and then applying the transforms. The values here should + * be identical to original spacing. + */ + rowPixelSpacing?: number; + columnPixelSpacing?: number; + /** The scaling of measurement values relative to the base pixel spacing (1 if not specified) */ + scale?: number; + /** + * The calibration aspect ratio for non-square calibrations. + * This is the aspect ratio similar to the scale above that applies when + * the viewport is displaying non-square image pixels as square screen pixels. + * + * Defaults to 1 if not specified, and is also 1 if the Viewport has squared + * up the image pixels so that they are displayed as a square. + * Not well handled currently as this needs to be incorporated into + * tools when doing calculations. + */ + aspect?: number; + /** The type of the pixel spacing, distinguishing between various + * types projection (CR/DX/MG) spacing and volumetric spacing (the type is + * an empty string as it doesn't get a suffix, but this distinguishes it + * from other types) + */ + type: CalibrationTypes; + /** A tooltip which can be used to explain the calibration information */ + tooltip?: string; + /** The DICOM defined ultrasound regions. Used for non-distance spacing units. */ + sequenceOfUltrasoundRegions?: Record[]; +} + +export type { IImageCalibration as default }; diff --git a/packages/metadata/src/types/MetadataModuleTypes.ts b/packages/metadata/src/types/MetadataModuleTypes.ts new file mode 100644 index 0000000000..d0437db8ac --- /dev/null +++ b/packages/metadata/src/types/MetadataModuleTypes.ts @@ -0,0 +1,133 @@ +export interface DicomDateObject { + year: number; + month: number; + day: number; +} + +export interface DicomTimeObject { + hours: number; + minutes?: number; + seconds?: number; + fractionalSeconds?: number; +} + +export interface GeneralSeriesModuleMetadata { + modality: string; + seriesInstanceUID: string; + seriesNumber: number; + studyInstanceUID: string; + seriesDate: string; + seriesTime: string; +} + +export interface PatientStudyModuleMetadata { + patientAge: number; + patientSize: number; + patientWeight: number; +} + +export interface ImagePlaneModuleMetadata { + frameOfReferenceUID: string; + rows: number; + columns: number; + imageOrientationPatient: number[]; + rowCosines: number[]; + columnCosines: number[]; + imagePositionPatient: number[]; + sliceThickness?: number; + sliceLocation: number; + pixelSpacing: number[]; + rowPixelSpacing: number | null; + columnPixelSpacing: number | null; + usingDefaultValues?: boolean; +} + +export interface ImagePixelModuleMetadata { + samplesPerPixel: number; + photometricInterpretation: string; + rows: number; + columns: number; + bitsAllocated: number; + bitsStored: number; + highBit: number; + pixelRepresentation: number; + planarConfiguration: number; + pixelAspectRatio: string; + redPaletteColorLookupTableDescriptor: number[]; + greenPaletteColorLookupTableDescriptor: number[]; + bluePaletteColorLookupTableDescriptor: number[]; + redPaletteColorLookupTableData: number[]; + greenPaletteColorLookupTableData: number[]; + bluePaletteColorLookupTableData: number[]; + smallestPixelValue?: number; + largestPixelValue?: number; +} + +export interface SopCommonModuleMetadata { + sopClassUID: string; + sopInstanceUID: string; +} + +/** ECG module: instance-derived fields used for ECG display sets (e.g. modality, series/study/sop UIDs). */ +export interface EcgModuleMetadata { + modality: string; + sopInstanceUID: string; + sopClassUID: string; + seriesDescription?: string; + seriesNumber?: number; + seriesDate?: string; + seriesTime?: string; + seriesInstanceUID: string; + studyInstanceUID: string; +} + +export interface FrameMetadata extends SopCommonModuleMetadata { + // This is a 1 based frame number + frameNumber: number; + numberOfFrames: number; +} + +export interface TransferSyntaxMetadata { + transferSyntaxUID: string; +} + +/** + * Compressed frame data when NATURALIZED has pixel data as a Value. + * pixelData may be a single buffer or an array of per-frame data. + */ +export interface CompressedFrameDataMetadata { + transferSyntaxUid: string; + frameOfInterest: number; + frameNumber: number; + pixelData: ArrayBufferView | ArrayBufferView[]; +} + +/** Display set metadata cached per imageId. */ +export interface DisplaySetModuleMetadata { + displaySetInstanceUID: string; + /** Allowed viewport types; index 0 is preferred. */ + viewportTypes: readonly string[]; + getPreferredViewportType(): string; + getFrameImageIds(): ReadonlySet; + getUnderlyingImageIds(): ReadonlySet; +} + +/** + * Maps metadata module names (MetadataModules enum values or literal strings) to their + * return types. Used by getTyped() to infer the return type from the module type argument. + * Names must match MetadataModules enum values (e.g. 'transferSyntax', 'imagePlaneModule'). + */ +export interface MetadataModuleType { + baseImageId: string; + frameImageIds: Set; + generalSeriesModule: GeneralSeriesModuleMetadata; + patientStudyModule: PatientStudyModuleMetadata; + imagePlaneModule: ImagePlaneModuleMetadata; + imagePixelModule: ImagePixelModuleMetadata; + sopCommonModule: SopCommonModuleMetadata; + ecgModule: EcgModuleMetadata; + frameModule: FrameMetadata; + transferSyntax: TransferSyntaxMetadata; + compressedFrameData: CompressedFrameDataMetadata; + displaySetModule: DisplaySetModuleMetadata; +} diff --git a/packages/metadata/src/types/index.ts b/packages/metadata/src/types/index.ts new file mode 100644 index 0000000000..ba51f782c5 --- /dev/null +++ b/packages/metadata/src/types/index.ts @@ -0,0 +1,21 @@ +export type { + IListenerInfo, + MetadataType, + MetadataValueType, +} from './DicomStreamTypes'; +export type { IImageCalibration } from './IImageCalibration'; +export type { + DicomDateObject, + DicomTimeObject, + GeneralSeriesModuleMetadata, + PatientStudyModuleMetadata, + ImagePlaneModuleMetadata, + ImagePixelModuleMetadata, + SopCommonModuleMetadata, + EcgModuleMetadata, + FrameMetadata, + TransferSyntaxMetadata, + CompressedFrameDataMetadata, + DisplaySetModuleMetadata, + MetadataModuleType, +} from './MetadataModuleTypes'; diff --git a/packages/metadata/src/utilities/Tags.ts b/packages/metadata/src/utilities/Tags.ts new file mode 100644 index 0000000000..8c38dcb929 --- /dev/null +++ b/packages/metadata/src/utilities/Tags.ts @@ -0,0 +1,182 @@ +import * as metaData from '../metaData'; +import dcmjs from 'dcmjs'; +import { + moduleDefinitions, + USRegionChild, + CLINICAL_TRIAL, + RadiopharmaceuticalInfoModule, +} from './modules'; +import type { ModuleTagEntry } from './modules'; + +const dicomDictionary = dcmjs.data.DicomMetaDictionary.dictionary; +const nameMap = dcmjs.data.DicomMetaDictionary.nameMap; + +// Re-export custom module name constants for backward compatibility +export { USRegionChild, CLINICAL_TRIAL, RadiopharmaceuticalInfoModule }; + +export interface TagEntry { + name: string; + lowerName: string; + xTag: string; + vm: number; + tag: string; + vr: string; + primaryGroup: string; + groups: string[]; +} + +/** + * Creates a tag entry defining module membership only. + * VR and VM are resolved from the dcmjs dictionary in addTag(). + */ +export function createTagEntry(hexTag: string, ...groups: string[]): TagEntry { + return { + tag: hexTag, + groups, + xTag: null, + primaryGroup: null, + name: null, + lowerName: null, + vr: null, + vm: null, + }; +} + +/** + * Parses a DICOM VM string (e.g. "1", "1-n", "2") into a numeric value. + * Returns 1 for single-valued, the exact count for fixed multi-valued, + * or 0 for variable-length multi-valued. + */ +export function parseVm(vm: string | number | undefined): number | null { + if (vm === undefined || vm === null) { + return null; + } + if (typeof vm === 'number') { + return vm; + } + const n = parseInt(vm, 10); + // If the string is exactly a number (like "1", "2", "3"), return it + if (String(n) === vm) { + return n; + } + // Otherwise it's a range like "1-n", "2-n", "1-3" → multi-valued + return 0; +} + +/** + * Looks up a tag in the dcmjs dictionary by hex string (e.g. "00080005"). + * Returns { name, vr, vm } or undefined if not found. + */ +export function dictionaryLookup( + hexTag: string +): { name: string; vr: string; vm: string } | undefined { + return dicomDictionary[hexTag.toUpperCase()]; +} + +/** + * Looks up the hex tag code for a natural tag name (e.g. "SOPInstanceUID" → "00080018"). + * Uses the mapTagInfo registry which is populated from the Tags definitions. + */ +export function getTagCodeByName(name: string): string | undefined { + return mapTagInfo.get(name)?.tag; +} + +export const mapModuleTags = new Map(); + +export const mapTagInfo = new Map(); + +/** + * Adds a tag name/type, resolving vr/vm from the dcmjs dictionary. + */ +export function addTag(name: string, value: TagEntry) { + if (name && value.name && name !== value.name) { + throw new Error( + `Tag name provided and value don't match: ${name} !== ${value.name}` + ); + } + value.name ||= name; + value.lowerName ||= metaData.toLowerCamelTag(name); + Tags[name] = value; + const { tag: hexTag } = value; + value.primaryGroup ||= value.groups?.[0]; + const { groups } = value; + mapTagInfo.set(name, value); + if (hexTag) { + value.xTag = `x${hexTag.toLowerCase()}`; + value.tag = hexTag.toUpperCase(); + mapTagInfo.set(value.xTag, value); + mapTagInfo.set(value.tag, value); + + // Resolve vr/vm from dcmjs dictionary if not already set + if (!value.vr) { + const dictEntry = dicomDictionary[value.tag]; + if (dictEntry) { + value.vr = dictEntry.vr; + value.vm = parseVm(dictEntry.vm); + } + } + } + if (groups?.length) { + for (const group of groups) { + let moduleEntries = mapModuleTags.get(group); + if (!moduleEntries) { + moduleEntries = [value]; + mapModuleTags.set(group, moduleEntries); + return; + } + const foundIndex = moduleEntries.findIndex((it) => it.name === name); + if (foundIndex === -1) { + moduleEntries.push(value); + } else { + moduleEntries[foundIndex] = value; + } + } + } +} + +/** + * Resolves a tag keyword to its hex code using dcmjs nameMap. + * nameMap entries have tag in "(GGGG,EEEE)" format; we unpunctuate to "GGGGEEEE". + */ +export function resolveHexFromKeyword(keyword: string): string | undefined { + const entry = nameMap[keyword]; + if (!entry) { + return undefined; + } + return entry.tag.substring(1, 5) + entry.tag.substring(6, 10); +} + +/** + * The Tags object. Built at module load time from moduleDefinitions + * and dcmjs nameMap lookups. + */ +export const Tags: Record = {}; + +// Accumulate groups per keyword across all module definitions. +// Tags appearing in multiple modules get all their groups collected. +const tagGroups = new Map(); + +for (const [moduleName, keywords] of moduleDefinitions) { + for (const entry of keywords as ModuleTagEntry[]) { + const keyword = typeof entry === 'string' ? entry : entry[0]; + const hexOverride = typeof entry === 'string' ? undefined : entry[1]; + let data = tagGroups.get(keyword); + if (!data) { + const hex = hexOverride ?? resolveHexFromKeyword(keyword); + data = { hex, groups: [] }; + tagGroups.set(keyword, data); + } + if (moduleName !== null) { + data.groups.push(moduleName); + } + } +} + +// Create TagEntry objects and register them +for (const [keyword, { hex, groups }] of tagGroups) { + if (!hex) { + console.warn(`Tags: keyword "${keyword}" not found in dcmjs nameMap`); + continue; + } + addTag(keyword, createTagEntry(hex, ...groups)); +} diff --git a/packages/metadata/src/utilities/bulkDataFromArray.ts b/packages/metadata/src/utilities/bulkDataFromArray.ts new file mode 100644 index 0000000000..7afd554eec --- /dev/null +++ b/packages/metadata/src/utilities/bulkDataFromArray.ts @@ -0,0 +1,71 @@ +/** + * Utilities to extract a single ArrayBufferView from bulk data that may be + * stored as an array of buffers (e.g. from DICOM stream listeners or + * compressed frame data), matching the pattern used for compressed pixel + * data frames. + */ + +function asView(buf: ArrayBuffer | ArrayBufferView): ArrayBufferView { + if (buf instanceof ArrayBuffer) { + return new Uint8Array(buf); + } + return buf as ArrayBufferView; +} + +/** + * Extracts a single ArrayBufferView from a value that may be: + * - A single ArrayBuffer or ArrayBufferView (returned as-view) + * - An array of one buffer: returns asView(arr[0]) + * - An array of multiple buffers: concatenates and returns one Uint8Array + * + * Use for bulk data that can be delivered as either a single buffer or an + * array of fragments (e.g. palette LUT, pixel data frame). + * + * @param raw - ArrayBuffer, ArrayBufferView, or array of same + * @returns Single ArrayBufferView, or undefined if raw is not a supported type + */ +export function getSingleBufferFromArray( + raw: unknown +): ArrayBufferView | undefined { + if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) { + return asView(raw as ArrayBuffer | ArrayBufferView); + } + if (!Array.isArray(raw) || raw.length === 0) { + return undefined; + } + const first = raw[0]; + if ( + first === undefined || + first === null || + (!(first instanceof ArrayBuffer) && !ArrayBuffer.isView(first)) + ) { + return undefined; + } + if (raw.length === 1) { + return asView(first as ArrayBuffer | ArrayBufferView); + } + const views = raw.filter( + (item): item is ArrayBuffer | ArrayBufferView => + item != null && (item instanceof ArrayBuffer || ArrayBuffer.isView(item)) + ); + if (views.length === 0) return undefined; + const totalLength = views.reduce( + (sum, v) => + sum + + (v instanceof ArrayBuffer + ? v.byteLength + : (v as ArrayBufferView).byteLength), + 0 + ); + const out = new Uint8Array(totalLength); + let offset = 0; + for (const v of views) { + const view = asView(v); + out.set( + new Uint8Array(view.buffer, view.byteOffset, view.byteLength), + offset + ); + offset += view.byteLength; + } + return out; +} diff --git a/packages/metadata/src/utilities/calibratedPixelSpacingMetadataProvider.ts b/packages/metadata/src/utilities/calibratedPixelSpacingMetadataProvider.ts new file mode 100644 index 0000000000..9fa8d7757e --- /dev/null +++ b/packages/metadata/src/utilities/calibratedPixelSpacingMetadataProvider.ts @@ -0,0 +1,44 @@ +import imageIdToURI from './imageIdToURI'; +import type { IImageCalibration } from '../types/IImageCalibration'; +import { MetadataModules } from '../enums'; +import { clearQuery } from '../metaData'; + +/** Calibrated pixel spacing per imageId */ +const state = new Map(); + +/** + * Simple metadataProvider object to store metadata for calibrated spacings. + * This can be added via cornerstone.metaData.addProvider(...) in order to store + * and return calibrated pixel spacings when metaData type is "calibratedPixelSpacing". + */ +const metadataProvider = { + /** + * Adds metadata for an imageId. + * @param imageId - the imageId for the metadata to store + * @param payload - the payload composed of new calibrated pixel spacings + */ + add: (imageId: string, payload: IImageCalibration): void => { + const imageURI = imageIdToURI(imageId); + state.set(imageURI, payload); + clearQuery(MetadataModules.IMAGE_PLANE, imageId); + }, + + /** + * Returns the metadata for an imageId if it exists. + * @param type - the type of metadata to enquire about + * @param imageId - the imageId to enquire about + * @returns the calibrated pixel spacings for the imageId if it exists, otherwise undefined + */ + get: (type: string, imageId: string): IImageCalibration => { + if (type === 'calibratedPixelSpacing') { + const imageURI = imageIdToURI(imageId); + return state.get(imageURI); + } + }, + + clear: () => { + state.clear(); + }, +}; + +export default metadataProvider; diff --git a/packages/metadata/src/utilities/dicomStream/MetaDataIterator.ts b/packages/metadata/src/utilities/dicomStream/MetaDataIterator.ts new file mode 100644 index 0000000000..54dbc7713c --- /dev/null +++ b/packages/metadata/src/utilities/dicomStream/MetaDataIterator.ts @@ -0,0 +1,77 @@ +/** + * Delivers metadata from a standard DICOMweb Metadata instance to a listener + */ + +import { dictionaryLookup, mapTagInfo } from '../Tags'; + +export class MetaDataIterator { + public metadata; + + constructor(metadata) { + this.metadata = metadata; + } + + public syncIterator(listener, object = this.metadata) { + for (const [key, value] of Object.entries(object)) { + if (key === '_vrMap' || value === undefined) { + continue; + } + if (value === null) { + listener.addTag(key, { length: 0 }); + listener.pop(); + continue; + } + const vr = value.vr; + const tagData = mapTagInfo.get(key); + const dictEntry = !tagData ? dictionaryLookup(key) : undefined; + const hasBulk = + !value.Value && + ((value as MetadataValue).BulkDataURI ?? + (value as MetadataValue).InlineBinary); + if (!value.Value && !hasBulk) { + continue; + } + listener.addTag(key, { + vr, + name: tagData?.name || dictEntry?.name, + vm: tagData?.vm ?? dictEntry?.vm, + }); + if (vr === 'SQ') { + for (const v of value.Value) { + listener.startObject(); + this.syncIterator(listener, v); + listener.pop(); + } + listener.pop(); + continue; + } + if (hasBulk) { + listener.value({ + BulkDataURI: (value as MetadataValue).BulkDataURI, + InlineBinary: (value as MetadataValue).InlineBinary, + }); + listener.pop(); + continue; + } + if ( + value.vr === 'CS' && + value.Value.length === 1 && + String(value.Value[0]).indexOf('\\') !== -1 + ) { + // Fix static dicomweb CS values not split + value.Value = String(value.Value[0]).split('\\'); + } + for (const v of value.Value) { + listener.value(v); + } + listener.pop(); + } + } +} + +export type MetadataValue = { + Value?: unknown[]; + vr: string; + BulkDataURI?: string; + InlineBinary?: string; +}; diff --git a/packages/metadata/src/utilities/dicomStream/NaturalTagListener.ts b/packages/metadata/src/utilities/dicomStream/NaturalTagListener.ts new file mode 100644 index 0000000000..d817629f73 --- /dev/null +++ b/packages/metadata/src/utilities/dicomStream/NaturalTagListener.ts @@ -0,0 +1,175 @@ +import dcmjs from 'dcmjs'; +import { makeArrayLike } from '../metadataProvider/makeArrayLike'; +import { dictionaryLookup, mapTagInfo, parseVm } from '../Tags'; +import type { IListenerInfo } from '../../types'; + +const { DicomMetadataListener } = dcmjs.utilities; + +export type ListenerContext = { + natural?: { name: string; singleVm: boolean | null; tag: string }; + parent?: { dest: Record }; + dest?: Record; +}; + +/** + * Resolves whether a tag is single-valued. + * Returns true for VM=1, false for multi-valued, null for unknown. + */ +function resolveSingleVm( + tagData: { vm?: number } | undefined, + dictEntry: { vm?: string } | undefined, + tagInfo: IListenerInfo | undefined +): boolean | null { + if (tagData && tagData.vm !== undefined && tagData.vm !== null) { + return tagData.vm === 1; + } + const vm = tagInfo?.vm ?? dictEntry?.vm; + if (vm !== undefined && vm !== null) { + const parsed = parseVm(vm); + if (parsed !== null) { + return parsed === 1; + } + } + return null; +} + +/** + * Returns true if the value looks like bulk data (e.g. pixel data): + * - array of ArrayBuffer (or TypedArray), or + * - array of arrays of ArrayBuffer (frames of fragments). + */ +function isBulkDataValue(val: unknown): boolean { + if (!Array.isArray(val) || val.length === 0) { + return false; + } + const first = val[0]; + if (first instanceof ArrayBuffer || ArrayBuffer.isView(first)) { + return true; + } + if (Array.isArray(first)) { + return val.every( + (item) => + Array.isArray(item) && + item.every( + (f: unknown) => f instanceof ArrayBuffer || ArrayBuffer.isView(f) + ) + ); + } + return false; +} + +const DEFAULT_NAME_KEY = 'name'; + +/** + * A filter for DicomMetadataListener that naturalizes tag names and values. + * + * Use `NaturalTagListener.createMetadataListener()` for a fully constructed + * listener (single place that wires DicomMetadataListener + natural filter). + * + * The base listener handles value/values and stack; this filter converts in pop + * so that bulk data (e.g. pixel data: array of frames, each frame array of + * ArrayBuffer fragments) and scalar tags are stored under natural names. + * The base {vr, Value} entry is removed after copying to the natural name. + * + * Tag names and VR/VM are resolved from tagInfo, mapTagInfo, and dcmjs dictionary. + */ +export class NaturalTagListener { + constructor(_options?: { nameKey?: string }) { + // nameKey could be used if DicomMetadataListener passed filter to _init; for now we use DEFAULT_NAME_KEY + } + + /** + * Returns a fully constructed DicomMetadataListener with the natural filter. + * Single place to know how to build the overall listener for naturalized metadata. + */ + static createMetadataListener(options?: { nameKey?: string }) { + return new DicomMetadataListener({}, new NaturalTagListener(options)); + } + + addTag( + next: (tag: string, tagInfo?: IListenerInfo) => void, + tag: string, + tagInfo?: IListenerInfo + ) { + const tagData = mapTagInfo.get(tag); + const dictEntry = !tagData ? dictionaryLookup(tag) : undefined; + const name = + tagInfo?.name || tagData?.[DEFAULT_NAME_KEY] || dictEntry?.name || tag; + const singleVm = resolveSingleVm(tagData, dictEntry, tagInfo); + + next(tag, tagInfo); + + ( + this as unknown as { + current: { + natural?: { name: string; singleVm: boolean | null; tag: string }; + }; + } + ).current.natural = { + name, + singleVm, + tag, + }; + } + + pop(next: () => unknown): unknown { + const listener = this as unknown as { current: ListenerContext }; + const nat = listener.current?.natural; + const parentContext = listener.current?.parent; + if (!nat || !parentContext?.dest) { + return next(); + } + + const result = next(); + + // Use the parent we had before next(); after next() the chain may not have updated current yet + const parent = parentContext as { dest: Record }; + const raw = parent.dest[nat.tag] as { Value?: unknown[] } | undefined; + if (raw === undefined) { + return result; + } + + let val: unknown = raw.Value ?? raw; + if (Array.isArray(val) && val.length === 1 && val[0] === undefined) { + val = []; + } + + if (isBulkDataValue(val)) { + parent.dest[nat.name] = val; + if (nat.name !== nat.tag) delete parent.dest[nat.tag]; + return result; + } + if (nat.singleVm === true && Array.isArray(val) && val.length === 1) { + const one = val[0]; + if ( + typeof one === 'object' && + one !== null && + !(one instanceof ArrayBuffer) && + !ArrayBuffer.isView(one) + ) { + parent.dest[nat.name] = makeArrayLike(one); + } else { + parent.dest[nat.name] = one; + } + if (nat.name !== nat.tag) delete parent.dest[nat.tag]; + return result; + } + if ( + nat.singleVm === null && + Array.isArray(val) && + val.length === 1 && + typeof val[0] === 'object' && + val[0] !== null && + !(val[0] instanceof ArrayBuffer) && + !ArrayBuffer.isView(val[0]) + ) { + parent.dest[nat.name] = makeArrayLike(val[0]); + } else { + parent.dest[nat.name] = val; + } + if (nat.name !== nat.tag) { + delete parent.dest[nat.tag]; + } + return result; + } +} diff --git a/packages/metadata/src/utilities/dicomStream/index.ts b/packages/metadata/src/utilities/dicomStream/index.ts new file mode 100644 index 0000000000..fcb7e945e8 --- /dev/null +++ b/packages/metadata/src/utilities/dicomStream/index.ts @@ -0,0 +1,7 @@ +export * from './MetaDataIterator'; +export * from './NaturalTagListener'; +export type { + IListenerInfo, + MetadataType, + MetadataValueType, +} from '../../types'; diff --git a/packages/metadata/src/utilities/getNaturalizedField.ts b/packages/metadata/src/utilities/getNaturalizedField.ts new file mode 100644 index 0000000000..8add5acb11 --- /dev/null +++ b/packages/metadata/src/utilities/getNaturalizedField.ts @@ -0,0 +1,81 @@ +/** + * Reads an UpperCamelCase field from a NATURALIZED metadata object. + * + * `fieldName` must be the exact NATURALIZED key (UpperCamelCase DICOM-style + * attribute name, for example `PhotometricInterpretation` or `NumberOfFrames`). + * This helper does not perform any casing fallback. + * + * `defaultValue` is returned when the field (or indexed value) is unavailable. + * `index` applies only to array values; non-array values require `index = 0`. + */ +export function getNaturalizedField( + naturalized: Record | null | undefined, + fieldName: string, + defaultValue: T | undefined = undefined, + index = 0 +): unknown | T | undefined { + if (!naturalized) { + return defaultValue; + } + + const value = naturalized[fieldName]; + if (value === undefined || value === null) { + return defaultValue; + } + + if (Array.isArray(value)) { + const indexedValue = value[index]; + return indexedValue === undefined || indexedValue === null + ? defaultValue + : indexedValue; + } + + if (index !== 0) { + return defaultValue; + } + + return value; +} + +/** + * Reads a NATURALIZED field as a string. + */ +export function getNaturalizedString( + naturalized: Record | null | undefined, + fieldName: string, + defaultValue: string | undefined = undefined, + index = 0 +): string | undefined { + const value = getNaturalizedField( + naturalized, + fieldName, + defaultValue, + index + ); + if (value === null || value === undefined) { + return defaultValue; + } + return String(value); +} + +/** + * Reads a NATURALIZED field as a finite number. + */ +export function getNaturalizedNumber( + naturalized: Record | null | undefined, + fieldName: string, + defaultValue: number | undefined = undefined, + index = 0 +): number | undefined { + const value = getNaturalizedField( + naturalized, + fieldName, + defaultValue, + index + ); + if (value === null || value === undefined) { + return defaultValue; + } + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : defaultValue; +} diff --git a/packages/metadata/src/utilities/getPixelSpacingInformation.ts b/packages/metadata/src/utilities/getPixelSpacingInformation.ts new file mode 100644 index 0000000000..28940196d0 --- /dev/null +++ b/packages/metadata/src/utilities/getPixelSpacingInformation.ts @@ -0,0 +1,135 @@ +import { isEqual } from './isEqual'; +import { CalibrationTypes } from '../enums'; + +// TODO: Use ENUMS from dcmjs +const projectionRadiographSOPClassUIDs = new Set([ + '1.2.840.10008.5.1.4.1.1.1', // CR Image Storage + '1.2.840.10008.5.1.4.1.1.1.1', // Digital X-Ray Image Storage – for Presentation + '1.2.840.10008.5.1.4.1.1.1.1.1', // Digital X-Ray Image Storage – for Processing + '1.2.840.10008.5.1.4.1.1.1.2', // Digital Mammography X-Ray Image Storage – for Presentation + '1.2.840.10008.5.1.4.1.1.1.2.1', // Digital Mammography X-Ray Image Storage – for Processing + '1.2.840.10008.5.1.4.1.1.1.3', // Digital Intra – oral X-Ray Image Storage – for Presentation + '1.2.840.10008.5.1.4.1.1.1.3.1', // Digital Intra – oral X-Ray Image Storage – for Processing + '1.2.840.10008.5.1.4.1.1.12.1', // X-Ray Angiographic Image Storage + '1.2.840.10008.5.1.4.1.1.12.1.1', // Enhanced XA Image Storage + '1.2.840.10008.5.1.4.1.1.12.2', // X-Ray Radiofluoroscopic Image Storage + '1.2.840.10008.5.1.4.1.1.12.2.1', // Enhanced XRF Image Storage + '1.2.840.10008.5.1.4.1.1.12.3', // X-Ray Angiographic Bi-plane Image Storage Retired +]); + +/** + * Calculates the ERMF value using any of: + * * EstimatedRadiographicMagnificationFactor + * * PixelSpacing / Imager Pixel Spacing + * * Distance Source / imager / patient pair + * + * @returns ERMF if available. True means the PixelSpacing has been pre-calculated + */ +export function getERMF(instance) { + const { + PixelSpacing, + ImagerPixelSpacing, + EstimatedRadiographicMagnificationFactor: ermf, + // Naming is traditionally sid/sod here + DistanceSourceToDetector: sid, + DistanceSourceToEntrance: soe, + DistanceSourceToPatient: sod = soe, + } = instance; + if (ermf > 1) { + return ermf; + } + if (sod < sid) { + return sid / sod; + } + if (ImagerPixelSpacing?.[0] > PixelSpacing?.[0]) { + return true; + } +} + +/** + * Given an instance, calculates the project (radiographic) pixel spacing + * plus the type of calibration that got used for it. + */ +export function calculateRadiographicPixelSpacing(instance) { + const { PixelSpacing, ImagerPixelSpacing, PixelSpacingCalibrationType } = + instance; + + const isProjection = true; + + if (PixelSpacing && PixelSpacingCalibrationType === 'GEOMETRY') { + if (isEqual(PixelSpacing, ImagerPixelSpacing)) { + console.warn( + 'Calibration type is geometry, but pixel spacing and imager pixel spacing identical', + PixelSpacing, + ImagerPixelSpacing + ); + } + return { + PixelSpacing, + type: CalibrationTypes.ERMF, + isProjection, + }; + } + + if (PixelSpacing && PixelSpacingCalibrationType === 'FIDUCIAL') { + return { + PixelSpacing, + type: CalibrationTypes.CALIBRATED, + isProjection, + }; + } + + if (ImagerPixelSpacing) { + const ermf = getERMF(instance); + if (ermf > 1) { + const correctedPixelSpacing = ImagerPixelSpacing.map( + (pixelSpacing) => pixelSpacing / ermf + ); + + return { + PixelSpacing: correctedPixelSpacing, + type: CalibrationTypes.ERMF, + isProjection, + }; + } + if (ermf === true) { + return { + PixelSpacing, + type: CalibrationTypes.ERMF, + isProjection, + }; + } + if (ermf) { + console.error('Illegal ERMF value:', ermf); + } + return { + PixelSpacing: PixelSpacing || ImagerPixelSpacing, + type: CalibrationTypes.PROJECTION, + isProjection, + }; + } + + return { + PixelSpacing, + type: CalibrationTypes.UNKNOWN, + isProjection, + }; +} + +export function getPixelSpacingInformation(instance) { + const { PixelSpacing, SOPClassUID } = instance; + + const isProjection = projectionRadiographSOPClassUIDs.has(SOPClassUID); + + if (isProjection) { + return calculateRadiographicPixelSpacing(instance); + } + + return { + PixelSpacing, + type: CalibrationTypes.NOT_APPLICABLE, + isProjection: false, + }; +} + +export default getPixelSpacingInformation; diff --git a/packages/metadata/src/utilities/imageIdToURI.ts b/packages/metadata/src/utilities/imageIdToURI.ts new file mode 100644 index 0000000000..46b4ed5169 --- /dev/null +++ b/packages/metadata/src/utilities/imageIdToURI.ts @@ -0,0 +1,30 @@ +const schemePrefixPattern = /^[a-zA-Z]+:/; + +/** + * Removes the data loader scheme from the imageId + * + * @param imageId - Image ID + * @returns imageId without the data loader scheme, or empty string if imageId is falsy + */ +export default function imageIdToURI(imageId: string): string { + if (!imageId) { + return ''; + } + + const firstPrefixMatch = imageId.match(schemePrefixPattern); + + if (!firstPrefixMatch) { + return imageId; + } + + const remainder = imageId.substring(firstPrefixMatch[0].length); + + // Only strip one scheme if another scheme-like prefix remains. + // Example: wadouri:derived:uuid -> derived:uuid + // Example: derived:uuid -> derived:uuid + if (!schemePrefixPattern.test(remainder)) { + return imageId; + } + + return remainder; +} diff --git a/packages/metadata/src/utilities/index.ts b/packages/metadata/src/utilities/index.ts new file mode 100644 index 0000000000..ec0e1bbc69 --- /dev/null +++ b/packages/metadata/src/utilities/index.ts @@ -0,0 +1,27 @@ +export { getSingleBufferFromArray } from './bulkDataFromArray'; +export { toNumber, toFiniteNumber } from './toNumber'; +export { default as toNumberDefault } from './toNumber'; +export { + default as isVideoTransferSyntax, + videoUIDs, +} from './isVideoTransferSyntax'; +export { default as imageIdToURI } from './imageIdToURI'; +export { isEqual, isEqualNegative, isEqualAbs, isNumber } from './isEqual'; +export { + getPixelSpacingInformation, + calculateRadiographicPixelSpacing, + getERMF, +} from './getPixelSpacingInformation'; +export { default as calibratedPixelSpacingMetadataProvider } from './calibratedPixelSpacingMetadataProvider'; +export * from './getNaturalizedField'; +export { + default as splitImageIdsBy4DTags, + handleMultiframe4D, + generateFrameImageId, +} from './splitImageIdsBy4DTags'; +export * as Tags from './Tags'; +export * as DicomStream from './dicomStream'; +export * from './logging'; +export * from './metadataProvider'; +export * as typedMetadataProviders from './metadataProvider'; +export * from '../displayset'; diff --git a/packages/metadata/src/utilities/isEqual.ts b/packages/metadata/src/utilities/isEqual.ts new file mode 100644 index 0000000000..a1ecf5b7ad --- /dev/null +++ b/packages/metadata/src/utilities/isEqual.ts @@ -0,0 +1,9 @@ +export { + DEFAULT_EPSILON, + areNumbersEqualWithTolerance, + isEqual, + isEqualNegative, + isEqualAbs, + isNumber, +} from '@cornerstonejs/utils'; +export { isEqual as default } from '@cornerstonejs/utils'; diff --git a/packages/metadata/src/utilities/isVideoTransferSyntax.ts b/packages/metadata/src/utilities/isVideoTransferSyntax.ts new file mode 100644 index 0000000000..c82c9ba309 --- /dev/null +++ b/packages/metadata/src/utilities/isVideoTransferSyntax.ts @@ -0,0 +1,26 @@ +export const videoUIDs = new Set([ + '1.2.840.10008.1.2.4.100', + '1.2.840.10008.1.2.4.100.1', + '1.2.840.10008.1.2.4.101', + '1.2.840.10008.1.2.4.101.1', + '1.2.840.10008.1.2.4.102', + '1.2.840.10008.1.2.4.102.1', + '1.2.840.10008.1.2.4.103', + '1.2.840.10008.1.2.4.103.1', + '1.2.840.10008.1.2.4.104', + '1.2.840.10008.1.2.4.104.1', + '1.2.840.10008.1.2.4.105', + '1.2.840.10008.1.2.4.105.1', + '1.2.840.10008.1.2.4.106', + '1.2.840.10008.1.2.4.106.1', + '1.2.840.10008.1.2.4.107', + '1.2.840.10008.1.2.4.108', +]); + +export default function isVideoTransferSyntax(uidOrUids: string | string[]) { + if (!uidOrUids) { + return false; + } + const uids = Array.isArray(uidOrUids) ? uidOrUids : [uidOrUids]; + return uids.find((uid) => videoUIDs.has(uid)); +} diff --git a/packages/metadata/src/utilities/logging.ts b/packages/metadata/src/utilities/logging.ts new file mode 100644 index 0000000000..23a712b90f --- /dev/null +++ b/packages/metadata/src/utilities/logging.ts @@ -0,0 +1,19 @@ +import { logging } from '@cornerstonejs/utils'; + +export const { + getRootLogger, + getLogger, + cs3dLog, + metadataLog, + coreLog, + toolsLog, + loaderLog, + aiLog, + examplesLog, + workerLog, + dicomConsistencyLog, + imageConsistencyLog, + log, +} = logging; + +export type Logger = logging.Logger; diff --git a/packages/metadata/src/utilities/metadataProvider/addDicomPart10Instance.ts b/packages/metadata/src/utilities/metadataProvider/addDicomPart10Instance.ts new file mode 100644 index 0000000000..cfa39ddb56 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/addDicomPart10Instance.ts @@ -0,0 +1,47 @@ +import { addTyped } from '../../metaData'; +import { MetadataModules } from '../../enums'; + +/** + * Adds a DICOMweb JSON metadata instance to the NATURALIZED cache. + * + * Takes hex-tagged DICOMweb JSON (e.g. {"00080060": {vr:"CS", Value:["CT"]}}) + * and converts it to a naturalized instance via MetaDataIterator and + * NaturalTagListener.createMetadataListener() (DicomMetadataListener + natural filter). + * + * @param imageId - The imageId to associate with this instance + * @param metadata - DICOMweb JSON metadata object with hex-tagged entries + * @returns The naturalized instance object + */ +export function addDicomWebInstance( + imageId: string, + metadata: Record +) { + return addTyped(MetadataModules.NATURALIZED, imageId, { + dicomwebJson: metadata, + }); +} + +/** + * Adds a binary DICOM Part 10 instance to the NATURALIZED cache. + * + * Parses the ArrayBuffer using dcmjs AsyncDicomReader with + * NaturalTagListener.createMetadataListener() so that naturalized output + * (including pixel data as array of frames of + * ArrayBuffer fragments) is produced and listener.information is + * populated for the reader. + * + * @param imageId - The imageId to associate with this instance + * @param part10 - ArrayBuffer/Uint8Array or resolver function returning those values + * @returns A promise that resolves to the naturalized instance object + */ +export async function addDicomPart10Instance( + imageId: string, + part10: + | ArrayBuffer + | Uint8Array + | (() => ArrayBuffer | Uint8Array | Promise) +) { + return addTyped(MetadataModules.NATURALIZED, imageId, { + part10Buffer: part10, + }); +} diff --git a/packages/metadata/src/utilities/metadataProvider/cacheData.ts b/packages/metadata/src/utilities/metadataProvider/cacheData.ts new file mode 100644 index 0000000000..a385025e8f --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/cacheData.ts @@ -0,0 +1,348 @@ +import { getAddModuleType, MetadataModules } from '../../enums'; +import { + addAddProvider, + addTypedProvider, + clear, + clearQuery, + getMetaData, +} from '../../metaData'; +import { BASE_IMAGE_ID, FRAME_IMAGE_IDS } from './imageIdsProviders'; + +interface CacheGetOptions { + noCache?: boolean; + reCache?: boolean; +} + +type CacheRegistrationOptions = CacheGetOptions & { + /** Register this cache type as secondary of one or more base cache types. */ + secondaryOf?: string | string[]; +}; + +export class CacheData { + protected static readonly mapCacheData = new Map< + string, + Map + >(); + protected static readonly secondaryTypesByBaseType = new Map< + string, + Set + >(); + + protected static setCacheDataInternal( + type: string, + query: string, + value: unknown + ) { + let valueMap = this.mapCacheData.get(type); + if (!valueMap) { + valueMap = new Map(); + this.mapCacheData.set(type, valueMap); + } + valueMap.set(query, value); + this.clearRelatedDerivedCache(type, query); + } + + protected static clearRelatedDerivedCache(type: string, query: string) { + const derivedTypes = this.secondaryTypesByBaseType.get(type); + if (!derivedTypes?.size) { + return; + } + const frameImageIds = + (getMetaData(FRAME_IMAGE_IDS, query) as Set | undefined) ?? + new Set([query]); + for (const frameImageId of frameImageIds) { + for (const derivedType of derivedTypes) { + const typeMap = this.mapCacheData.get(derivedType); + typeMap?.delete(frameImageId); + } + } + } + + static registerSecondaryTypes( + secondaryType: string, + secondaryOf?: string | string[] + ) { + if (!secondaryOf) { + return; + } + const baseTypes = Array.isArray(secondaryOf) ? secondaryOf : [secondaryOf]; + for (const baseType of baseTypes) { + let secondaryTypes = this.secondaryTypesByBaseType.get(baseType); + if (!secondaryTypes) { + secondaryTypes = new Set(); + this.secondaryTypesByBaseType.set(baseType, secondaryTypes); + } + secondaryTypes.add(secondaryType); + } + } + + static getCacheData(type: string, query: string): unknown { + return this.mapCacheData.get(type)?.get(query); + } + + static hasCacheData(type: string, query: string): boolean { + return this.mapCacheData.get(type)?.has(query) === true; + } + + static clearCacheData() { + this.mapCacheData.clear(); + this.secondaryTypesByBaseType.clear(); + clear(FRAME_IMAGE_IDS); + clear(BASE_IMAGE_ID); + } + + static clearTypedCacheData(type: string, query?: string) { + const secondaryTypes = this.secondaryTypesByBaseType.get(type); + const valueMap = this.mapCacheData.get(type); + + if (query) { + valueMap?.delete(query); + + if (secondaryTypes?.size) { + for (const secondaryType of secondaryTypes) { + this.mapCacheData.get(secondaryType)?.delete(query); + } + } + + if (type === MetadataModules.NATURALIZED || secondaryTypes?.size) { + clearQuery(FRAME_IMAGE_IDS, query); + clearQuery(BASE_IMAGE_ID, query); + } + return; + } + valueMap?.clear(); + if (secondaryTypes?.size) { + for (const secondaryType of secondaryTypes) { + this.mapCacheData.get(secondaryType)?.clear(); + } + } + if (type === MetadataModules.NATURALIZED || secondaryTypes?.size) { + clear(FRAME_IMAGE_IDS); + clear(BASE_IMAGE_ID); + } + } + + static fromAsyncLookup( + type: string, + query: string, + lookup: () => T | Promise, + options?: CacheGetOptions + ): T | Promise | undefined { + if (options?.noCache !== true && options?.reCache !== true) { + const cachedValue = this.getCacheData(type, query); + if (cachedValue !== undefined) { + return cachedValue as T; + } + } + + const lookupValue = lookup(); + if (lookupValue === undefined) { + return undefined; + } + if (!(lookupValue instanceof Promise)) { + if (!options?.noCache) { + this.setCacheDataInternal(type, query, lookupValue); + } + return lookupValue; + } + + return lookupValue.then((resolvedValue) => { + if (resolvedValue !== undefined && !options?.noCache) { + this.setCacheDataInternal(type, query, resolvedValue); + } + return resolvedValue; + }); + } + + createTypeCacheProvider(type: string) { + return createTypeCacheProvider(type); + } + + clearCacheData() { + CacheData.clearCacheData(); + } + + clearTypedCacheData(type: string, query?: string) { + CacheData.clearTypedCacheData(type, query); + } + + getCacheData(type: string, query: string) { + return CacheData.getCacheData(type, query); + } + + hasCacheData(type: string, query: string) { + return CacheData.hasCacheData(type, query); + } + + fromAsyncLookup( + type: string, + query: string, + lookup: () => T | Promise, + options?: CacheGetOptions + ) { + return CacheData.fromAsyncLookup(type, query, lookup, options); + } +} + +export class WritableCacheData extends CacheData { + static setCacheData(type: string, query: string, value: unknown) { + this.setCacheDataInternal(type, query, value); + } + + setCacheData(type: string, query: string, value: unknown) { + WritableCacheData.setCacheData(type, query, value); + } +} + +export const cacheData: CacheData = new WritableCacheData(); + +/** + * Creates a typed provider that caches results for the given type. + * + * Options can include: noCache to not cache this value or use the cached value, + * and reCache to get a new value and add it to the cache. + */ +export function createTypeCacheProvider(type: string) { + return (next, query: string, data, options) => { + return CacheData.fromAsyncLookup( + type, + query, + () => next(query, data, options), + options + ); + }; +} + +export function createTypeWritableCacheProvider(type: string) { + const addType = getAddModuleType(type); + + return (next, query: string, data, options) => { + const cachedValue = CacheData.getCacheData(type, query); + if (cachedValue !== undefined) { + console.warn( + `Metadata add skipped for "${type}" at query "${query}" because cache already has a value.` + ); + return cachedValue; + } + + const addCachedValue = CacheData.getCacheData(addType, query); + if (addCachedValue !== undefined) { + return addCachedValue; + } + + const nextValue = next(query, data, options); + if (nextValue === undefined) { + return undefined; + } + + if (!(nextValue instanceof Promise)) { + WritableCacheData.setCacheData(type, query, nextValue); + return nextValue; + } + + const managedPromise = nextValue + .then((resolvedValue) => { + if (resolvedValue !== undefined) { + WritableCacheData.setCacheData(type, query, resolvedValue); + } + return resolvedValue; + }) + .finally(() => { + CacheData.clearTypedCacheData(addType, query); + }); + + WritableCacheData.setCacheData(addType, query, managedPromise); + + return managedPromise; + }; +} + +export function clearCacheData() { + CacheData.clearCacheData(); +} + +export function clearTypedCacheData(type: string, query?: string) { + CacheData.clearTypedCacheData(type, query); +} + +/** + * Directly sets a value in the typed cache for the given type and query key. + * + * This is primarily an internal/advanced escape hatch for providers that need + * explicit external writes (for example calibration-related providers). Most + * metadata ingestion paths should use typed provider handlers instead. + */ +export function setCacheData(type: string, query: string, value: unknown) { + WritableCacheData.setCacheData(type, query, value); +} + +/** + * Reads a value from the typed cache for the given type and query key. + */ +export function getCacheData(type: string, query: string): unknown { + return CacheData.getCacheData(type, query); +} + +export function hasCacheData(type: string, query: string): boolean { + return CacheData.hasCacheData(type, query); +} + +export function fromAsyncLookup( + type: string, + query: string, + lookup: () => T | Promise, + options?: CacheGetOptions +) { + return CacheData.fromAsyncLookup(type, query, lookup, options); +} + +export function addCacheForType(type: string, options?) { + const { secondaryOf, ...providerOptions } = (options ?? + {}) as CacheRegistrationOptions; + + CacheData.registerSecondaryTypes(type, secondaryOf); + + addTypedProvider(type, createTypeCacheProvider(type), { + priority: 50_000, + clear: clearTypedCacheData.bind(null, type) as () => void, + clearQuery: clearTypedCacheData.bind(null, type), + ...providerOptions, + }); +} + +export function addWritableCacheForType(type: string, options?) { + const addType = getAddModuleType(type); + const { secondaryOf, ...providerOptions } = (options ?? + {}) as CacheRegistrationOptions; + + addCacheForType(type, { secondaryOf, ...providerOptions }); + CacheData.registerSecondaryTypes(addType, type); + + addAddProvider(type, createTypeWritableCacheProvider(type), { + priority: 50_000, + clear: clearTypedCacheData.bind(null, addType) as () => void, + clearQuery: clearTypedCacheData.bind(null, addType), + ...providerOptions, + }); +} + +export function registerCacheProviders() { + addCacheForType(BASE_IMAGE_ID); + addCacheForType(FRAME_IMAGE_IDS); + addWritableCacheForType(MetadataModules.NATURALIZED); + addCacheForType(MetadataModules.INSTANCE, { + secondaryOf: MetadataModules.NATURALIZED, + }); + addCacheForType(MetadataModules.URI_MODULE, { + secondaryOf: MetadataModules.NATURALIZED, + }); + addCacheForType(MetadataModules.IMAGE_PLANE, { + secondaryOf: MetadataModules.NATURALIZED, + }); + addCacheForType(MetadataModules.FRAME_MODULE, { + secondaryOf: MetadataModules.NATURALIZED, + }); + addCacheForType(MetadataModules.GENERAL_IMAGE, { + secondaryOf: MetadataModules.NATURALIZED, + }); +} diff --git a/packages/metadata/src/utilities/metadataProvider/calibrationModule.ts b/packages/metadata/src/utilities/metadataProvider/calibrationModule.ts new file mode 100644 index 0000000000..2d5e9fc8c9 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/calibrationModule.ts @@ -0,0 +1,31 @@ +import { MetadataModules } from '../../enums'; +import { addTypedProvider, toLowerCamelTag } from '../../metaData'; + +/** + * Converts the calibration object if any to lower case + */ +export function calibrationModuleProvider(next, query, data, options) { + if (!data) { + return next(query, data, options); + } + if (!data.SequenceOfUltrasoundRegions?.length) { + return; + } + const { SequenceOfUltrasoundRegions } = data; + const sequenceOfUltrasoundRegions = []; + + for (const sequenceItem of SequenceOfUltrasoundRegions) { + const newItem = {}; + sequenceOfUltrasoundRegions.push(newItem); + for (const [key, value] of Object.entries(sequenceItem)) { + newItem[toLowerCamelTag(key)] = value; + } + } + return { + sequenceOfUltrasoundRegions, + }; +} + +export function registerCalibrationModule() { + addTypedProvider(MetadataModules.CALIBRATION, calibrationModuleProvider); +} diff --git a/packages/metadata/src/utilities/metadataProvider/combineFrameInstance.ts b/packages/metadata/src/utilities/metadataProvider/combineFrameInstance.ts new file mode 100644 index 0000000000..a97d5e039d --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/combineFrameInstance.ts @@ -0,0 +1,282 @@ +import { vec3 } from 'gl-matrix'; +import { dicomSplit } from './dicomSplit'; +import { addTypedProvider, getMetaData } from '../../metaData'; +import { MetadataModules } from '../../enums'; +import { isEqual } from '../isEqual'; + +/** + * Combine the Per instance frame data, the shared frame data + * and the root data objects. + * The data is combined by taking nested sequence objects within + * the functional group sequences. Data that is directly contained + * within the functional group sequences, such as private creators + * will be ignored. + * This can be safely called with an undefined frame in order to handle + * single frame data. (eg frame is undefined is the same as frame===1). + */ +export const combineFrameInstance = (frame, instance) => { + const { + PerFrameFunctionalGroupsSequence, + SharedFunctionalGroupsSequence, + NumberOfFrames, + ImageType, + } = instance; + + if (NumberOfFrames < 2) { + return instance; + } + + instance.ImageType = dicomSplit(ImageType); + const frameNumber = Number.parseInt(frame || 1); + + const hasDetectorButMissingSpatialInfo = + instance.DetectorInformationSequence && + (!instance.ImagePositionPatient || !instance.ImageOrientationPatient); + + if ( + (PerFrameFunctionalGroupsSequence && SharedFunctionalGroupsSequence) || + hasDetectorButMissingSpatialInfo + ) { + // this is to fix NM multiframe datasets with position and orientation + // information inside DetectorInformationSequence + if ( + !instance.ImageOrientationPatient && + instance.DetectorInformationSequence + ) { + instance.ImageOrientationPatient = + instance.DetectorInformationSequence[0].ImageOrientationPatient; + } + + const rootImagePositionPatient = instance.ImagePositionPatient; + let detectorDerivedImagePositionPatient; + + if ( + !instance.ImagePositionPatient && + instance.DetectorInformationSequence + ) { + let imagePositionPatient = + instance.DetectorInformationSequence[0].ImagePositionPatient; + let imageOrientationPatient = instance.ImageOrientationPatient; + + imagePositionPatient = imagePositionPatient?.map((it) => Number(it)); + imageOrientationPatient = imageOrientationPatient?.map((it) => + Number(it) + ); + const SpacingBetweenSlices = Number(instance.SpacingBetweenSlices); + + // Calculate the position for the current frame + if (imageOrientationPatient && SpacingBetweenSlices) { + const rowOrientation = vec3.fromValues( + imageOrientationPatient[0], + imageOrientationPatient[1], + imageOrientationPatient[2] + ); + + const colOrientation = vec3.fromValues( + imageOrientationPatient[3], + imageOrientationPatient[4], + imageOrientationPatient[5] + ); + + const normalVector = vec3.cross( + vec3.create(), + rowOrientation, + colOrientation + ); + + const position = vec3.scaleAndAdd( + vec3.create(), + imagePositionPatient, + normalVector, + SpacingBetweenSlices * (frameNumber - 1) + ); + + detectorDerivedImagePositionPatient = [ + position[0], + position[1], + position[2], + ]; + } + } + + // Cache the _parentInstance at the top level as a full copy to prevent + // setting values hard. + if (!instance._parentInstance) { + Object.defineProperty(instance, '_parentInstance', { + value: { ...instance }, + }); + } + const sharedInstance = createCombinedValue( + instance._parentInstance, + SharedFunctionalGroupsSequence?.[0], + '_shared' + ); + const newInstance = createCombinedValue( + sharedInstance, + PerFrameFunctionalGroupsSequence?.[frameNumber - 1], + frameNumber + ); + + if (detectorDerivedImagePositionPatient) { + newInstance.ImagePositionPatient = detectorDerivedImagePositionPatient; + } else if (!newInstance.ImagePositionPatient && rootImagePositionPatient) { + newInstance.ImagePositionPatient = rootImagePositionPatient; + } else if (!newInstance.ImagePositionPatient) { + newInstance.ImagePositionPatient = [0, 0, frameNumber]; + } + + Object.defineProperty(newInstance, 'frameNumber', { + value: frameNumber, + writable: true, + enumerable: true, + configurable: true, + }); + return newInstance; + } + + // For RTDOSE datasets + if (instance.GridFrameOffsetVector) { + if (!instance._parentInstance) { + Object.defineProperty(instance, '_parentInstance', { + value: { ...instance }, + }); + } + + const sharedInstance = createCombinedValue( + instance._parentInstance, + SharedFunctionalGroupsSequence?.[0], + '_shared' + ); + + const newInstance = createCombinedValue( + sharedInstance, + PerFrameFunctionalGroupsSequence?.[frameNumber - 1], + frameNumber + ); + + const origin = newInstance.ImagePositionPatient?.map(Number); + const orientation = newInstance.ImageOrientationPatient?.map(Number); + const offsets = instance.GridFrameOffsetVector; + const firstOffset = Number(offsets[0]); + const offset = Number(offsets[frameNumber - 1]); + + if (origin && orientation && !Number.isNaN(offset)) { + const isIdentityAxialOrientation = + isEqual(orientation[0], 1) && + isEqual(orientation[1], 0) && + isEqual(orientation[2], 0) && + isEqual(orientation[3], 0) && + isEqual(orientation[4], 1) && + isEqual(orientation[5], 0); + + // RTDOSE GridFrameOffsetVector has two valid encodings: + // (a) relative offsets where first value is 0 + // (b) absolute patient z positions for axial orientation + const isRelativeOffsetForm = isEqual(firstOffset, 0); + const isAbsolutePatientZForm = + isIdentityAxialOrientation && isEqual(firstOffset, origin[2]); + + if (isAbsolutePatientZForm) { + newInstance.ImagePositionPatient = [origin[0], origin[1], offset]; + } else if (isRelativeOffsetForm) { + const row = vec3.fromValues( + orientation[0], + orientation[1], + orientation[2] + ); + const col = vec3.fromValues( + orientation[3], + orientation[4], + orientation[5] + ); + const normal = vec3.cross(vec3.create(), row, col); + + const position = vec3.scaleAndAdd( + vec3.create(), + origin, + normal, + offset + ); + newInstance.ImagePositionPatient = [ + position[0], + position[1], + position[2], + ]; + } + } + + Object.defineProperty(newInstance, 'frameNumber', { + value: frameNumber, + writable: true, + enumerable: true, + configurable: true, + }); + + return newInstance; + } + + return instance; +}; + +/** + * Creates a combined instance stored in the parent object which + * inherits from the parent instance the attributes in the functional groups. + * The storage key in the parent is in key + */ +export function createCombinedValue(parent, functionalGroups, key) { + if (parent[key]) { + return parent[key]; + } + // Exclude any proxying values + const newInstance = Object.create(parent); + Object.defineProperty(parent, key, { + value: newInstance, + writable: false, + enumerable: false, + }); + if (!functionalGroups) { + return newInstance; + } + const shared = functionalGroups + ? Object.values(functionalGroups) + .filter(Boolean) + .map((it) => it[0]) + .filter((it) => typeof it === 'object') + : []; + + // merge the shared first then the per frame to override + [...shared].forEach((item) => { + if (item.SOPInstanceUID) { + // This sub-item is a previous value information item, so don't merge it + return; + } + Object.entries(item).forEach(([key, value]) => { + newInstance[key] = value; + }); + }); + return newInstance; +} + +export const combineFrameProvider = (next, query, instance, options) => { + if (!instance) { + return next(query, instance, options); + } + if (!instance.NumberOfFrames) { + return instance; + } + const uriModule = getMetaData( + MetadataModules.URI_MODULE, + query, + options?.frameModule + ); + const { frameNumber = 1 } = uriModule || {}; + + const combined = combineFrameInstance(frameNumber, instance); + return combined; +}; + +export function registerCombineFrameProvider() { + addTypedProvider(MetadataModules.INSTANCE, combineFrameProvider); +} + +export default combineFrameInstance; diff --git a/packages/metadata/src/utilities/metadataProvider/compressedFrameData.ts b/packages/metadata/src/utilities/metadataProvider/compressedFrameData.ts new file mode 100644 index 0000000000..50038bf528 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/compressedFrameData.ts @@ -0,0 +1,222 @@ +import { MetadataModules } from '../../enums'; +import { addTypedProvider } from '../../metaData'; +import type { TypedProvider } from '../../metaData'; +import type { CompressedFrameDataMetadata } from '../../types'; + +/** Known natural keys and hex tags for pixel data (standard and float/paramap). */ +const PIXEL_DATA_KEYS = [ + 'PixelData', + 'FramePixelData', + 'FloatPixelData', + '7FE00010', + '7fe00010', + '7FE00008', + '7fe00008', +]; + +/** Keys that indicate paramap-type (parametric map) images for single-buffer frame slicing. */ +const PARAMAP_PIXEL_DATA_KEYS = ['FloatPixelData', '7FE00008', '7fe00008']; + +/** + * Resolves the pixel data array from natural (frames as array of buffers or array of buffer arrays). + */ +function getPixelDataFromNatural( + natural: Record +): unknown[] | undefined { + for (const key of PIXEL_DATA_KEYS) { + const val = natural[key]; + if ( + val !== undefined && + val !== null && + Array.isArray(val) && + val.length > 0 + ) { + return val as unknown[]; + } + } + return undefined; +} + +/** + * Returns which pixel data key was used in natural, or undefined if none. + */ +function getPixelDataKeyFromNatural( + natural: Record +): string | undefined { + for (const key of PIXEL_DATA_KEYS) { + const val = natural[key]; + if ( + val !== undefined && + val !== null && + Array.isArray(val) && + val.length > 0 + ) { + return key; + } + } + return undefined; +} + +function isParamapType(natural: Record): boolean { + const key = getPixelDataKeyFromNatural(natural); + return key !== undefined && PARAMAP_PIXEL_DATA_KEYS.includes(key); +} + +function asView(buf: ArrayBuffer | ArrayBufferView): ArrayBufferView { + if (buf instanceof ArrayBuffer) { + return new Uint8Array(buf); + } + return buf as ArrayBufferView; +} + +function byteLength(buf: ArrayBuffer | ArrayBufferView): number { + if (buf instanceof ArrayBuffer) { + return buf.byteLength; + } + return (buf as ArrayBufferView).byteLength; +} + +/** + * Returns the pixel data for the given frame as ArrayBufferView(s) for CompressedFrameDataMetadata. + * Supports: [ [buf] ] (multiframe), [ [buf1, buf2] ] (single frame, fragments), [ buf ] (single frame, one buffer). + */ +function getFramePixelData( + pixelDataTag: unknown[], + frameIndex: number +): CompressedFrameDataMetadata['pixelData'] | undefined { + const frame = pixelDataTag[frameIndex]; + if (frame === undefined || frame === null) { + return undefined; + } + if (Array.isArray(frame)) { + return (frame as (ArrayBuffer | ArrayBufferView)[]).map(asView); + } + return asView(frame as ArrayBuffer | ArrayBufferView); +} + +/** + * When pixelDataTag has one buffer and natural has multiple frames, slice one frame for paramap-type images. + * Returns undefined if not applicable or split is not even. + */ +function getFramePixelDataFromSingleBuffer( + pixelDataTag: unknown[], + frameIndex: number, + numberOfFrames: number, + natural: Record +): CompressedFrameDataMetadata['pixelData'] | undefined { + if (pixelDataTag.length !== 1 || !isParamapType(natural)) { + return undefined; + } + const buf = pixelDataTag[0]; + if (buf === undefined || buf === null) { + return undefined; + } + const view = asView(buf as ArrayBuffer | ArrayBufferView); + const totalLength = byteLength(buf as ArrayBuffer | ArrayBufferView); + if (numberOfFrames <= 0 || totalLength % numberOfFrames !== 0) { + return undefined; + } + const frameSize = totalLength / numberOfFrames; + const offset = frameIndex * frameSize; + if (offset + frameSize > totalLength) { + return undefined; + } + console.warn( + '[compressedFrameData] Splitting single-buffer pixel data by numberOfFrames for paramap-type image; frameIndex=', + frameIndex, + ', numberOfFrames=', + numberOfFrames, + ', frameSize=', + frameSize + ); + const u8 = + view instanceof Uint8Array + ? view + : new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + return [u8.subarray(offset, offset + frameSize)]; +} + +/** + * Builds compressed frame data from a natural instance (pixel data as Value). + * Returns undefined if natural has no pixel data or transfer syntax. + * When array length !== numberOfFrames and image is paramap-type, slices a single buffer by frame (with warning). + */ +function compressedFrameDataFromNatural( + natural, + frameIndex: number +): CompressedFrameDataMetadata | undefined { + if (!natural) { + return; + } + + const pixelDataTag = getPixelDataFromNatural(natural); + if (!pixelDataTag) { + return; + } + + const { TransferSyntaxUID: transferSyntaxUid } = natural; + if (!transferSyntaxUid) { + return; + } + + const frameOfInterest = frameIndex ?? 0; + const frameNumber = frameOfInterest + 1; + const numberOfFrames = + natural.NumberOfFrames != null ? Number(natural.NumberOfFrames) : 1; + + let pixelData: CompressedFrameDataMetadata['pixelData'] | undefined; + if (pixelDataTag.length === numberOfFrames) { + pixelData = getFramePixelData(pixelDataTag, frameOfInterest); + } else { + pixelData = getFramePixelDataFromSingleBuffer( + pixelDataTag, + frameOfInterest, + numberOfFrames, + natural + ); + if (pixelData === undefined) { + pixelData = getFramePixelData(pixelDataTag, frameOfInterest); + } + } + if (pixelData === undefined) { + return; + } + + return { + transferSyntaxUid, + frameOfInterest, + frameNumber, + pixelData, + }; +} + +const COMPRESSED_FRAME_DATA_TYPE = MetadataModules.COMPRESSED_FRAME_DATA; + +/** + * Typed provider for COMPRESSED_FRAME_DATA. Gets natural metadata via + * getMetaData(MetadataModules.NATURALIZED, query); if it has pixel data as Value, + * returns { transferSyntaxUid, frameOfInterest, frameNumber, pixelData }. + * Otherwise calls next. + */ +const compressedFrameDataProvider: TypedProvider = ( + next: (query: string, data: unknown, options?: unknown) => unknown, + query: string, + natural, + options +): CompressedFrameDataMetadata | unknown => { + const frameIndex = options?.frameIndex ?? 0; + const value = compressedFrameDataFromNatural(natural, frameIndex); + if (value) { + return value; + } + return next( + query, + natural, + + options + ); +}; + +export function registerCompressedFrameDataProvider(): void { + addTypedProvider(COMPRESSED_FRAME_DATA_TYPE, compressedFrameDataProvider); +} diff --git a/packages/metadata/src/utilities/metadataProvider/dataLookup.ts b/packages/metadata/src/utilities/metadataProvider/dataLookup.ts new file mode 100644 index 0000000000..2fd28602b9 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/dataLookup.ts @@ -0,0 +1,39 @@ +import { MetadataModules } from '../../enums'; +import { addTypedProvider, metadataModuleProvider } from '../../metaData'; + +/** + * Creates a function that looks up the given dataType and provides it as "data" + */ +export function dataLookup(dataType: string) { + return (next, query, data, options) => { + data ||= metadataModuleProvider(dataType, query, options?.[dataType]); + return next(query, data, options); + }; +} + +/** The data lookup for the instance module */ +export const instanceLookup = dataLookup(MetadataModules.INSTANCE); +export const naturalLookup = dataLookup(MetadataModules.NATURALIZED); + +export const DATA_PRIORITY = { priority: 5000 }; + +export function registerDataLookup() { + addTypedProvider( + MetadataModules.INSTANCE, + dataLookup(MetadataModules.NATURALIZED), + DATA_PRIORITY + ); + + addTypedProvider(MetadataModules.IMAGE_PLANE, instanceLookup, DATA_PRIORITY); + + addTypedProvider(MetadataModules.CALIBRATION, instanceLookup, DATA_PRIORITY); + + addTypedProvider( + MetadataModules.COMPRESSED_FRAME_DATA, + naturalLookup, + DATA_PRIORITY + ); + + // Scaling uses NATURALIZED (multiframe, no per-frame scaling); provider receives data from this lookup + addTypedProvider(MetadataModules.SCALING, naturalLookup, DATA_PRIORITY); +} diff --git a/packages/metadata/src/utilities/metadataProvider/dicomSplit.ts b/packages/metadata/src/utilities/metadataProvider/dicomSplit.ts new file mode 100644 index 0000000000..c9b81f3630 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/dicomSplit.ts @@ -0,0 +1,21 @@ +/** + * Converts a value into an array by splitting on backslashes when needed. + * Use for DICOM multi-valued attributes (e.g. value representations that are + * backslash-separated). When a source supplies a single string with backslashes + * instead of separate values (non-conforming to the DICOM standard), this + * normalizes it to an array of strings to match the standard representation. + * + * - If `value` is already an array, it is returned unchanged. + * - If `value` is a string, it is split on '\\' and the resulting array is returned. + * - Otherwise `value` is returned as-is. + * + * @param value - A string (possibly with '\\'), an array, or another value. + * @returns An array of strings when given a backslash-separated string, otherwise the original value or array. + */ +export function dicomSplit(value) { + return ( + (Array.isArray(value) && value) || + (typeof value === 'string' && value.split('\\')) || + value + ); +} diff --git a/packages/metadata/src/utilities/metadataProvider/ecgFromInstance.ts b/packages/metadata/src/utilities/metadataProvider/ecgFromInstance.ts new file mode 100644 index 0000000000..6a62cf6428 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/ecgFromInstance.ts @@ -0,0 +1,343 @@ +/** + * Builds the full ECG module (including waveformData.retrieveBulkData) from a + * naturalized instance in NATURAL cache. Used by the typed provider so + * ECGViewport can get data via metaData.get(MetadataModules.ECG, imageId) + * without the legacy dicomImageLoader ECG provider. + */ + +import { addTypedProvider } from '../../metaData'; +import { MetadataModules } from '../../enums'; +import type { TypedProvider } from '../../metaData'; + +export interface EcgModuleFull { + numberOfWaveformChannels: number; + numberOfWaveformSamples: number; + samplingFrequency: number; + waveformBitsAllocated: number; + waveformSampleInterpretation: string; + multiplexGroupLabel: string; + channelDefinitionSequence: Array<{ + channelSourceSequence?: { codeMeaning?: string }; + }>; + waveformData: { + retrieveBulkData: () => Promise; + }; +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +function convertBuffer( + dataSrc: ArrayBuffer | Uint8Array, + numberOfChannels: number, + numberOfSamples: number, + bits: number, + type: string +): Int16Array[] { + const data = new Uint8Array(dataSrc); + if (bits === 16 && type === 'SS') { + const ret: Int16Array[] = []; + const bytesPerSample = 2; + const totalBytes = bytesPerSample * numberOfChannels * numberOfSamples; + const length = Math.min(data.length, totalBytes); + for (let channel = 0; channel < numberOfChannels; channel++) { + const buffer = new Int16Array(numberOfSamples); + ret.push(buffer); + let sampleI = 0; + for ( + let sample = 2 * channel; + sample < length; + sample += 2 * numberOfChannels + ) { + const highByte = data[sample + 1]; + const lowByte = data[sample]; + const sign = highByte & 0x80; + buffer[sampleI++] = sign + ? 0xffff0000 | (highByte << 8) | lowByte + : (highByte << 8) | lowByte; + } + } + return ret; + } + return []; +} + +function multipartDecode(response: ArrayBuffer): ArrayBuffer[] { + const message = new Uint8Array(response); + const separator = new TextEncoder().encode('\r\n\r\n'); + let offset = 0; + const maxHeader = 1000; + let headerEnd = -1; + for ( + let i = 0; + i < Math.min(message.length - separator.length, offset + maxHeader); + i++ + ) { + let found = true; + for (let j = 0; j < separator.length; j++) { + if (message[i + j] !== separator[j]) { + found = false; + break; + } + } + if (found) { + headerEnd = i; + break; + } + } + if (headerEnd === -1) return [response]; + const headerStr = new TextDecoder().decode(message.slice(0, headerEnd)); + const boundaryMatch = headerStr.match(/boundary=([^\s;]+)/i); + const boundary = boundaryMatch + ? new TextEncoder().encode(`--${boundaryMatch[1].replace(/"/g, '').trim()}`) + : null; + if (!boundary) return [response]; + const dataStart = headerEnd + separator.length; + const components: ArrayBuffer[] = []; + let idx = message.indexOf(boundary[0], dataStart); + while (idx !== -1) { + const nextSep = message.indexOf(separator[0], idx); + if (nextSep === -1) break; + const partStart = nextSep + separator.length; + const nextBound = message.indexOf(boundary[0], partStart); + const partEnd = nextBound === -1 ? message.length : nextBound - 2; + if (partEnd > partStart) { + components.push(response.slice(partStart, partEnd)); + } + if (nextBound === -1) break; + idx = message.indexOf(boundary[0], nextBound + boundary.length); + } + return components.length > 0 ? components : [response]; +} + +/** + * Parse wadors: URL to get wadoRsRoot and studyUID for relative BulkDataURI. + */ +function parseWadoRsImageId(imageId: string): { + wadoRsRoot?: string; + studyUID?: string; +} { + const uri = imageId.replace(/^wadors:/i, ''); + const studiesIndex = uri.indexOf('/studies/'); + if (studiesIndex === -1) return {}; + const wadoRsRoot = uri.substring(0, studiesIndex); + const afterStudies = uri.substring(studiesIndex + 9); + const nextSlash = afterStudies.indexOf('/'); + const studyUID = + nextSlash !== -1 ? afterStudies.substring(0, nextSlash) : afterStudies; + return { wadoRsRoot, studyUID }; +} + +/** Normalize sequence to array (handles makeArrayLike single-item and real arrays). */ +function toArray(seq: T[] | ArrayLike | undefined): T[] { + if (!seq) return []; + if (Array.isArray(seq)) return seq; + if (typeof (seq as ArrayLike).length === 'number') { + return Array.from(seq as ArrayLike); + } + return [seq as T]; +} + +/** + * Build full EcgModule from a naturalized instance (UpperCamelCase convention). + */ +export function buildEcgModuleFromInstance( + instance: Record, + imageId?: string +): EcgModuleFull | null { + const raw = instance.WaveformSequence as + | ArrayLike> + | undefined; + const groups = toArray(raw); + if (!groups.length) return null; + + const group = groups[0]; + const numberOfChannels = (group.NumberOfWaveformChannels as number) ?? 0; + const numberOfSamples = (group.NumberOfWaveformSamples as number) ?? 0; + const samplingFrequency = (group.SamplingFrequency as number) ?? 1; + const bitsAllocated = (group.WaveformBitsAllocated as number) ?? 16; + const sampleInterpretation = + (group.WaveformSampleInterpretation as string) ?? 'SS'; + const multiplexGroupLabel = (group.MultiplexGroupLabel as string) ?? ''; + + const channelDefSeq = toArray( + group.ChannelDefinitionSequence as + | ArrayLike> + | undefined + ); + const channelDefinitionSequence = channelDefSeq.map((ch) => { + const srcSeqArr = toArray( + ch.ChannelSourceSequence as ArrayLike> | undefined + ); + const srcSeq = srcSeqArr[0]; + const codeMeaning = (srcSeq?.CodeMeaning as string) ?? ''; + return { + channelSourceSequence: { codeMeaning }, + }; + }); + + let waveformDataRaw = (group.WaveformData ?? group.waveformData) as + | Record + | ArrayLike> + | undefined; + if ( + waveformDataRaw && + typeof (waveformDataRaw as ArrayLike).length === 'number' && + (waveformDataRaw as ArrayLike>).length > 0 + ) { + waveformDataRaw = ( + waveformDataRaw as ArrayLike> + )[0]; + } + const waveformData = (waveformDataRaw as Record) ?? {}; + const { wadoRsRoot = undefined, studyUID = undefined } = imageId + ? parseWadoRsImageId(imageId) + : {}; + + const retrieveBulkData = async (): Promise => { + // Binary file upload: AsyncDicomReader stores raw bytes as ArrayBuffer / TypedArray + const wd = waveformData as unknown; + if ( + wd instanceof ArrayBuffer || + (typeof ArrayBuffer !== 'undefined' && + ArrayBuffer.isView && + ArrayBuffer.isView(wd)) + ) { + return convertBuffer( + wd as ArrayBuffer | Uint8Array, + numberOfChannels, + numberOfSamples, + bitsAllocated, + sampleInterpretation + ); + } + if (waveformData.Value) return waveformData.Value as Int16Array[]; + if (waveformData.InlineBinary) { + const raw = base64ToUint8Array(waveformData.InlineBinary as string); + return convertBuffer( + raw, + numberOfChannels, + numberOfSamples, + bitsAllocated, + sampleInterpretation + ); + } + if ( + typeof ( + waveformData as { retrieveBulkData?: () => Promise } + ).retrieveBulkData === 'function' + ) { + return ( + waveformData as { retrieveBulkData: () => Promise } + ).retrieveBulkData(); + } + if (waveformData.BulkDataURI) { + let url = waveformData.BulkDataURI as string; + if (url.indexOf(':') === -1 && wadoRsRoot) { + url = studyUID + ? `${wadoRsRoot}/studies/${studyUID}/${url}` + : `${wadoRsRoot}/${url}`; + } + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') || ''; + const decoded = contentType.includes('multipart') + ? multipartDecode(buffer)[0] + : buffer; + return convertBuffer( + decoded, + numberOfChannels, + numberOfSamples, + bitsAllocated, + sampleInterpretation + ); + } + console.warn( + '[ecgFromInstance] No waveform data source found. group keys:', + Object.keys(group), + 'waveformData keys:', + Object.keys(waveformData) + ); + throw new Error('[ecgFromInstance] No waveform data source found'); + }; + + return { + numberOfWaveformChannels: numberOfChannels, + numberOfWaveformSamples: numberOfSamples, + samplingFrequency, + waveformBitsAllocated: bitsAllocated, + waveformSampleInterpretation: sampleInterpretation, + multiplexGroupLabel, + channelDefinitionSequence, + waveformData: { retrieveBulkData }, + }; +} + +/** Run after instanceLookup (INSTANCE_PRIORITY 5000) so we receive instance as data */ +const ECG_FROM_INSTANCE_PRIORITY = 4_000; + +/** + * Typed provider: when data (instance from instanceLookup) has WaveformSequence, + * return the full ECG module so ECGViewport gets waveformData.retrieveBulkData. + */ +const ecgFromInstanceProvider: TypedProvider = (next, query, data, options) => { + const instance = data as Record | undefined; + const hasWaveform = instance && instance.WaveformSequence; + if (!hasWaveform) { + return next(query, data, options); + } + const result = buildEcgModuleFromInstance(instance, query); + return result ?? next(query, data, options); +}; + +const ECG_AMPLITUDE_INDEX_SIZE = 65536; +const ECG_AMPLITUDE_OFFSET = 32768; + +/** + * CALIBRATION provider for ECG: when instance has WaveformSequence, return + * sequenceOfUltrasoundRegions so measurement tools get physical units (time, mV). + */ +const ecgCalibrationProvider: TypedProvider = (next, query, data, options) => { + const instance = data as Record | undefined; + const raw = instance?.WaveformSequence; + const groups = toArray(raw as ArrayLike> | undefined); + if (!groups.length) return next(query, data, options); + const group = groups[0]; + const numberOfWaveformSamples = + (group.NumberOfWaveformSamples as number) ?? 0; + const samplingFrequency = (group.SamplingFrequency as number) ?? 1; + const physicalDeltaX = 1 / (samplingFrequency || 1); + const physicalDeltaY = 0.001; + return { + sequenceOfUltrasoundRegions: [ + { + regionLocationMinX0: 0, + regionLocationMaxX1: numberOfWaveformSamples, + regionLocationMinY0: 0, + regionLocationMaxY1: ECG_AMPLITUDE_INDEX_SIZE - 1, + referencePixelX0: 0, + referencePixelY0: ECG_AMPLITUDE_OFFSET, + physicalDeltaX, + physicalDeltaY, + physicalUnitsXDirection: 4, + physicalUnitsYDirection: -1, + regionDataType: 1, + }, + ], + }; +}; + +export function registerEcgFromInstanceProvider(): void { + addTypedProvider(MetadataModules.ECG, ecgFromInstanceProvider, { + priority: ECG_FROM_INSTANCE_PRIORITY, + }); + addTypedProvider(MetadataModules.CALIBRATION, ecgCalibrationProvider, { + priority: ECG_FROM_INSTANCE_PRIORITY, + }); +} diff --git a/packages/metadata/src/utilities/metadataProvider/ecgModule.ts b/packages/metadata/src/utilities/metadataProvider/ecgModule.ts new file mode 100644 index 0000000000..334830e6eb --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/ecgModule.ts @@ -0,0 +1,6 @@ +/** + * ECG module is provided from instance data (NATURAL) via registerTagModules(), + * the same way as other tag-based modules. Use metaData.get(MetadataModules.ECG, imageId) + * to retrieve it when instance is in cache. + */ +export type { EcgModuleMetadata } from '../../types'; diff --git a/packages/metadata/src/utilities/metadataProvider/imageIdsProviders.ts b/packages/metadata/src/utilities/metadataProvider/imageIdsProviders.ts new file mode 100644 index 0000000000..2b6baf36c7 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/imageIdsProviders.ts @@ -0,0 +1,209 @@ +import { MetadataModules } from '../../enums'; +import { + addTypedProvider, + getTyped, + metadataModuleProvider, + type TypedProvider, +} from '../../metaData'; +import { + getNaturalizedNumber, + getNaturalizedString, +} from '../getNaturalizedField'; + +export const FRAME_IMAGE_IDS = MetadataModules.FRAME_IMAGE_IDS; +export const BASE_IMAGE_ID = MetadataModules.BASE_IMAGE_ID; + +/** Typed provider signature used by imageId/baseId provider chains. */ +export type ImageIdTransformPlugin = TypedProvider; + +const BASE_IMAGE_ID_PATH_FILTER_PRIORITY = 9_000; +const BASE_IMAGE_ID_QUERY_FILTER_PRIORITY = 8_000; +const DEFAULT_FRAME_IMAGE_IDS_PRIORITY = 9_000; + +function defaultBaseImageIdProvider(_next, imageId: string) { + return imageId; +} + +function framePathToBaseFilter(next, imageId: string, data, options) { + const baseImageId = + (next(imageId, data, options) as string | undefined) ?? imageId; + return baseImageId.replace(/\/frames\/\d+(?=(\?|#|$))/i, ''); +} + +function frameQueryToBaseFilter(next, imageId: string, data, options) { + const baseImageId = + (next(imageId, data, options) as string | undefined) ?? imageId; + // Strip frame query value from either ?frame=N or &frame=N and normalize separators. + return baseImageId + .replace(/([?&])frame=\d+(&?)/i, (_match, separator, hasTrailingAmp) => { + if (separator === '?' && hasTrailingAmp) { + return '?'; + } + return ''; + }) + .replace(/\?$/, '') + .replace(/\?&/, '?') + .replace(/&&+/g, '&'); +} + +function hasPhotometricInterpretation( + naturalized: Record +): boolean { + return !!getNaturalizedString(naturalized, 'PhotometricInterpretation'); +} + +function addFrameQueryParameter(imageId: string, frameNumber: number) { + const separator = imageId.includes('?') ? '&' : '?'; + return `${imageId}${separator}frame=${frameNumber}`; +} + +function addDicomwebFramePath( + imageId: string, + frameNumber: number +): string | undefined { + const instancesMatch = /\/instances\/[^/]+/i.exec(imageId); + if (!instancesMatch) { + return; + } + const insertIndex = instancesMatch.index + instancesMatch[0].length; + return `${imageId.slice(0, insertIndex)}/frames/${frameNumber}${imageId.slice(insertIndex)}`; +} + +export function generateFrameImageIdsFromNaturalized( + baseImageId: string, + naturalized: Record | null | undefined +): Set | undefined { + if (!naturalized) { + return; + } + + if (!hasPhotometricInterpretation(naturalized)) { + return new Set([baseImageId]); + } + + const frameImageIds = new Set(); + const numberOfFrames = Math.floor( + getNaturalizedNumber(naturalized, 'NumberOfFrames', 1) + ); + if (!Number.isFinite(numberOfFrames) || numberOfFrames < 1) { + return; + } + + for (let frameNumber = 1; frameNumber <= numberOfFrames; frameNumber++) { + const framePath = addDicomwebFramePath(baseImageId, frameNumber); + if (framePath) { + frameImageIds.add(framePath); + continue; + } + frameImageIds.add(addFrameQueryParameter(baseImageId, frameNumber)); + } + + return frameImageIds; +} + +function defaultFrameImageIdsProvider(next, imageId: string, data, options) { + const naturalized = metadataModuleProvider( + MetadataModules.NATURALIZED, + imageId, + options + ) as Record | null | undefined; + return ( + generateFrameImageIdsFromNaturalized(imageId, naturalized) || + next(imageId, data, options) + ); +} + +export function registerFrameImageIdsProvider( + provider: ImageIdTransformPlugin, + priority = 0 +) { + addTypedProvider(FRAME_IMAGE_IDS, provider, { priority }); +} + +/** + * Generic query-normalization filter that rewrites a query to its canonical + * base imageId before delegating to the remainder of a typed provider chain. + * + * Can be plugged into any typed type where frame-specific imageIds should + * resolve against base-image keyed cache/state. + */ +export function baseImageIdQueryFilter(next, query: string, data, options) { + const baseImageId = getTyped(MetadataModules.BASE_IMAGE_ID, query, options); + return next(baseImageId, data, options); +} + +/** + * Registers a transform filter for `frameImageIds`. + * + * The filter receives the current imageId and accumulated frame-image-id set, + * and may return additional imageIds to merge into that set. + */ +export function registerImageIdTransformFilter( + filter: ( + imageId: string, + frameImageIds: Set + ) => Iterable | void, + priority = 0 +) { + const provider: ImageIdTransformPlugin = ( + next, + imageId: string, + data, + options + ) => { + const frameImageIds = + (next(imageId, data, options) as Set | undefined) ?? + new Set([imageId]); + const produced = filter(imageId, frameImageIds); + if (produced) { + for (const transformedImageId of produced) { + frameImageIds.add(transformedImageId); + } + } + return frameImageIds; + }; + registerFrameImageIdsProvider(provider, priority); +} + +/** + * Registers the default imageId providers: + * - base imageId canonicalization providers + * - frame imageId expansion providers + */ +export function registerImageIdProviders() { + addTypedProvider(BASE_IMAGE_ID, defaultBaseImageIdProvider, { priority: 0 }); + addTypedProvider(BASE_IMAGE_ID, framePathToBaseFilter, { + priority: BASE_IMAGE_ID_PATH_FILTER_PRIORITY, + }); + addTypedProvider(BASE_IMAGE_ID, frameQueryToBaseFilter, { + priority: BASE_IMAGE_ID_QUERY_FILTER_PRIORITY, + }); + addTypedProvider(FRAME_IMAGE_IDS, defaultFrameImageIdsProvider, { + priority: DEFAULT_FRAME_IMAGE_IDS_PRIORITY, + }); +} + +/** + * Expands each input imageId to its related frame imageIds and applies + * an update callback once per expanded imageId. + * + * Returns the set of unique imageIds that were updated. + */ +export function bulkUpdateImageIds( + imageIds: Iterable, + updater: (imageId: string) => void +): Set { + const updatedImageIds = new Set(); + for (const imageId of imageIds) { + const frameImageIds = getTyped(MetadataModules.FRAME_IMAGE_IDS, imageId); + if (!frameImageIds) { + continue; + } + for (const frameImageId of frameImageIds) { + updater(frameImageId); + updatedImageIds.add(frameImageId); + } + } + + return updatedImageIds; +} diff --git a/packages/metadata/src/utilities/metadataProvider/imagePlaneCalibrated.ts b/packages/metadata/src/utilities/metadataProvider/imagePlaneCalibrated.ts new file mode 100644 index 0000000000..88fb041ac1 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/imagePlaneCalibrated.ts @@ -0,0 +1,116 @@ +import type { ImagePlaneModuleMetadata } from '../../types'; +import { MetadataModules } from '../../enums'; +import { toFiniteNumber } from '../toNumber'; +import { getNaturalizedNumber } from '../getNaturalizedField'; +import calibratedPixelSpacingMetadataProvider from '../calibratedPixelSpacingMetadataProvider'; +import getPixelSpacingInformation from '../getPixelSpacingInformation'; + +import { addTypedProvider } from '../../metaData'; + +export const getImagePlaneCalibrated = ( + next, + imageId: string, + instance, + options +): ImagePlaneModuleMetadata => { + if (!instance) { + return next(imageId, instance, options); + } + const { ImageOrientationPatient, ImagePositionPatient } = instance; + const { PixelSpacing, type } = getPixelSpacingInformation(instance); + + let rowPixelSpacing; + let columnPixelSpacing; + + let rowCosines: number[]; + let columnCosines: number[]; + + let usingDefaultValues = false; + let isDefaultValueSetForRowCosine = false; + let isDefaultValueSetForColumnCosine = false; + let imageOrientationPatient: number[]; + if (PixelSpacing) { + [rowPixelSpacing, columnPixelSpacing] = PixelSpacing; + const calibratedPixelSpacing = calibratedPixelSpacingMetadataProvider.get( + 'calibratedPixelSpacing', + imageId + ); + if (!calibratedPixelSpacing) { + calibratedPixelSpacingMetadataProvider.add(imageId, { + rowPixelSpacing: parseFloat(PixelSpacing[0]), + columnPixelSpacing: parseFloat(PixelSpacing[1]), + type, + }); + } + } else { + rowPixelSpacing = columnPixelSpacing = 1; + usingDefaultValues = true; + } + + if (ImageOrientationPatient) { + imageOrientationPatient = toFiniteNumber( + ImageOrientationPatient as ArrayLike + ) || [1, 0, 0, 0, 1, 0]; + rowCosines = imageOrientationPatient.slice(0, 3); + columnCosines = imageOrientationPatient.slice(3, 6); + } else { + rowCosines = [1, 0, 0]; + columnCosines = [0, 1, 0]; + imageOrientationPatient = [1, 0, 0, 0, 1, 0]; + usingDefaultValues = true; + isDefaultValueSetForRowCosine = true; + isDefaultValueSetForColumnCosine = true; + } + + const imagePositionPatient = toFiniteNumber( + ImagePositionPatient as ArrayLike + ) || [0, 0, 0]; + if (!ImagePositionPatient) { + usingDefaultValues = true; + } + + const rowPixelSpacingNumber = toFiniteNumber(rowPixelSpacing) ?? null; + const columnPixelSpacingNumber = toFiniteNumber(columnPixelSpacing) ?? null; + const pixelSpacing = + rowPixelSpacingNumber !== null && columnPixelSpacingNumber !== null + ? [rowPixelSpacingNumber, columnPixelSpacingNumber] + : Array.isArray(PixelSpacing) + ? PixelSpacing.map((value) => Number(value)).filter(Number.isFinite) + : []; + + const result = { + frameOfReferenceUID: instance.FrameOfReferenceUID, + rows: getNaturalizedNumber(instance, 'Rows', 0), + columns: getNaturalizedNumber(instance, 'Columns', 0), + imageOrientationPatient, + rowCosines, + columnCosines, + imagePositionPatient, + sliceLocation: getNaturalizedNumber(instance, 'SliceLocation', 0), + pixelSpacing, + rowPixelSpacing: rowPixelSpacingNumber, + sliceThickness: getNaturalizedNumber(instance, 'SliceThickness'), + spacingBetweenSlices: getNaturalizedNumber( + instance, + 'SpacingBetweenSlices' + ), + columnPixelSpacing: columnPixelSpacingNumber, + }; + Object.defineProperty(result, 'usingDefaultValues', { + value: usingDefaultValues, + }); + Object.defineProperty(result, 'isDefaultValueSetForRowCosine', { + value: isDefaultValueSetForRowCosine, + }); + Object.defineProperty(result, 'isDefaultValueSetForColumnCosine', { + value: isDefaultValueSetForColumnCosine, + }); + + return result; +}; + +export function registerImagePlaneCalibrated() { + addTypedProvider(MetadataModules.IMAGE_PLANE, getImagePlaneCalibrated); +} + +export default getImagePlaneCalibrated; diff --git a/packages/metadata/src/utilities/metadataProvider/index.ts b/packages/metadata/src/utilities/metadataProvider/index.ts new file mode 100644 index 0000000000..9b34495454 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/index.ts @@ -0,0 +1,18 @@ +export { dicomSplit } from './dicomSplit'; +export * from './imagePlaneCalibrated'; +export * from './cacheData'; +export * from './imageIdsProviders'; +export * from './dataLookup'; +export * from './naturalizedHandlers'; +export * from './tagModules'; +export * from './instanceFromListener'; +export * from './calibrationModule'; +export * from './combineFrameInstance'; +export * from './uriModule'; +export * from './pixelDataUpdate'; +export * from './transferSyntaxProvider'; +export * from './addDicomPart10Instance'; +export type { CompressedFrameDataMetadata } from '../../types'; +export * from './ecgModule'; +export * from './ecgFromInstance'; +export * from './scalingFromInstance'; diff --git a/packages/metadata/src/utilities/metadataProvider/instanceFromListener.ts b/packages/metadata/src/utilities/metadataProvider/instanceFromListener.ts new file mode 100644 index 0000000000..f34351211f --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/instanceFromListener.ts @@ -0,0 +1,13 @@ +import { MetadataModules } from '../../enums'; +import { addTypedProvider, metadataModuleProvider } from '../../metaData'; + +export const instanceOrigToInstanceProvider = (next, query, data, options) => { + return ( + metadataModuleProvider(MetadataModules.NATURALIZED, query, options) || + next(query, data, options) + ); +}; + +export function registerInstanceFromListener() { + addTypedProvider(MetadataModules.INSTANCE, instanceOrigToInstanceProvider); +} diff --git a/packages/metadata/src/utilities/metadataProvider/makeArrayLike.ts b/packages/metadata/src/utilities/metadataProvider/makeArrayLike.ts new file mode 100644 index 0000000000..3685c0f1b9 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/makeArrayLike.ts @@ -0,0 +1,42 @@ +/** + * Converts an object into an array-like object. + * This is used to convert a single object into an array-like object with length 1 + * so that it can be iterated over or otherwise extended. The array like + * properties are hidden so it doesn't show up in JSON.stringify or other serializations. + * + * This is used to allow VM=1 attributes to be stored in the NATURAL cache as a single object but + * still accessed as an array-like object for backwards compatibility. + * + * Note this does not work well on arrays or String objects. + * + * @param obj - The object to convert. + * @returns The array-like object. + */ +export function makeArrayLike(obj) { + if (obj === null || obj === undefined) { + return obj; + } + if (typeof obj !== 'object' || Array.isArray(obj)) { + return obj; + } + Object.defineProperty(obj, 'length', { + value: 1, + configurable: true, + }); + + Object.defineProperty(obj, 0, { + value: obj, + writable: true, + configurable: true, + enumerable: false, // do not iterate index either + }); + + Object.defineProperty(obj, Symbol.iterator, { + value: function* () { + yield this; // iterator yields only the object + }, + configurable: true, + }); + + return obj; +} diff --git a/packages/metadata/src/utilities/metadataProvider/naturalizedHandlers.ts b/packages/metadata/src/utilities/metadataProvider/naturalizedHandlers.ts new file mode 100644 index 0000000000..70c6959b94 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/naturalizedHandlers.ts @@ -0,0 +1,109 @@ +import dcmjs from 'dcmjs'; +import { MetadataModules } from '../../enums'; +import { addAddProvider, addTypedProvider } from '../../metaData'; +import { MetaDataIterator, NaturalTagListener } from '../dicomStream'; +import { baseImageIdQueryFilter } from './imageIdsProviders'; + +const { AsyncDicomReader } = dcmjs.async; + +const NATURAL_BASE_IMAGE_ID_FILTER_PRIORITY = 60_000; +const NATURALIZED_ADD_HANDLER_PRIORITY = 30_000; + +type Part10Input = + | ArrayBuffer + | Uint8Array + | (() => ArrayBuffer | Uint8Array | Promise); + +function toArrayBuffer(part10Value: ArrayBuffer | Uint8Array): ArrayBuffer { + if (part10Value instanceof ArrayBuffer) { + return part10Value; + } + return part10Value.buffer.slice( + part10Value.byteOffset, + part10Value.byteOffset + part10Value.byteLength + ); +} + +async function resolvePart10Input(part10: Part10Input): Promise { + const resolvedValue = typeof part10 === 'function' ? await part10() : part10; + if ( + !(resolvedValue instanceof ArrayBuffer) && + !(resolvedValue instanceof Uint8Array) + ) { + throw new Error('part10 must resolve to ArrayBuffer or Uint8Array'); + } + return toArrayBuffer(resolvedValue); +} + +/** + * Converts DICOMweb JSON-style metadata into a NATURALIZED object + * using the standard metadata iterator/listener pipeline. + */ +export function naturalizeDicomwebMetadata(metadata: Record) { + const iterator = new MetaDataIterator(metadata); + const listener = NaturalTagListener.createMetadataListener(); + listener.startObject(); + iterator.syncIterator(listener); + return listener.pop(); +} + +/** + * Naturalizes a Part10 payload into a DICOM dictionary-like object. + * + * Supports direct binary input or a lazy resolver function and preserves + * transfer syntax information on the returned naturalized object. + */ +export async function naturalizePart10Buffer(part10: Part10Input) { + const arrayBuffer = await resolvePart10Input(part10); + const reader = new AsyncDicomReader(); + const listener = NaturalTagListener.createMetadataListener(); + + reader.stream.addBuffer(arrayBuffer); + reader.stream.setComplete(); + await reader.readFile({ listener }); + + const naturalized = reader.dict; + const transferSyntaxUid = reader.syntax; + if (transferSyntaxUid) { + naturalized.TransferSyntaxUID = Array.isArray(transferSyntaxUid) + ? transferSyntaxUid[0] + : transferSyntaxUid; + } + + return naturalized; +} + +/** + * Add-path NATURALIZED provider that handles sync `{ dicomwebJson }` and async + * `{ part10Buffer }` ingestion. + */ +function naturalizedAddProvider(next, query: string, data, options) { + const dicomwebJson = options?.dicomwebJson ?? options?.metadata; + if (dicomwebJson && typeof dicomwebJson === 'object') { + return naturalizeDicomwebMetadata(dicomwebJson as Record); + } + + const part10Buffer = options?.part10Buffer ?? options?.part10; + if (part10Buffer) { + return naturalizePart10Buffer(part10Buffer as Part10Input); + } + + return next(query, data, options); +} + +/** + * Registers NATURALIZED-related handlers for read and add paths: + * - base-image-id query normalization filter + * - sync/async ingestion through add-path providers + */ +export function registerNaturalizedHandlers() { + addTypedProvider(MetadataModules.NATURALIZED, baseImageIdQueryFilter, { + priority: NATURAL_BASE_IMAGE_ID_FILTER_PRIORITY, + }); + addAddProvider(MetadataModules.NATURALIZED, baseImageIdQueryFilter, { + priority: NATURAL_BASE_IMAGE_ID_FILTER_PRIORITY, + }); + addAddProvider(MetadataModules.NATURALIZED, naturalizedAddProvider, { + priority: NATURALIZED_ADD_HANDLER_PRIORITY, + }); +} diff --git a/packages/metadata/src/utilities/metadataProvider/pixelDataUpdate.ts b/packages/metadata/src/utilities/metadataProvider/pixelDataUpdate.ts new file mode 100644 index 0000000000..5b3f759325 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/pixelDataUpdate.ts @@ -0,0 +1,186 @@ +/** + * Converts the palette data information into + * an array when it is present as an array buffer, typed array, array of buffers, or InlineBinary. + */ + +import { MetadataModules } from '../../enums'; +import { addTypedProvider } from '../../metaData'; +import { getSingleBufferFromArray } from '../bulkDataFromArray'; + +function normalizePaletteLUT(raw: unknown): { + view: Uint8Array | Uint16Array; + byteLength: number; +} { + if (raw instanceof ArrayBuffer) { + const len = raw.byteLength; + return { + view: len <= 256 ? new Uint8Array(raw) : new Uint16Array(raw), + byteLength: len, + }; + } + if (ArrayBuffer.isView(raw)) { + const v = raw as Uint8Array | Uint16Array; + return { view: v, byteLength: v.byteLength }; + } + if (Array.isArray(raw)) { + const view = getSingleBufferFromArray(raw); + if (view) { + return { + view: view as Uint8Array | Uint16Array, + byteLength: view.byteLength, + }; + } + } + const inline = + raw != null && + typeof raw === 'object' && + 'InlineBinary' in (raw as Record) && + typeof (raw as { InlineBinary?: string }).InlineBinary === 'string'; + if (inline) { + const b64 = (raw as { InlineBinary: string }).InlineBinary; + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return { + view: bytes, + byteLength: bytes.byteLength, + }; + } + const desc = describeValue(raw); + throw new Error( + 'Palette color lookup table data could not be normalized: expected ArrayBuffer, ArrayBufferView, or object with InlineBinary string. ' + + desc + ); +} + +/** + * Normalizes raw palette LUT data to the final typed array using the descriptor. + * Handles validation and 8-bit vs 16-bit conversion in one place. + * @param raw - Raw LUT data (ArrayBuffer, view, array of buffers, or InlineBinary) + * @param descriptor - [numEntries, firstMappedValue, bitsPerEntry] + * @param color - Channel name for error messages ('red' | 'green' | 'blue') + * @returns Uint8Array or Uint16Array ready to assign to the module + */ +function normalizePaletteLUTToFinal( + raw: unknown, + descriptor: number[], + color: 'red' | 'green' | 'blue' +): Uint8Array | Uint16Array { + descriptor[0] ||= 65536; + const tableLen = descriptor[0]; + const bits = descriptor[2] ?? 16; + const { view, byteLength } = normalizePaletteLUT(raw); + const expectedByteLengths = [tableLen, tableLen * 2]; + if (!expectedByteLengths.includes(byteLength)) { + const actualEntries = + byteLength === tableLen ? view.length : Math.floor(byteLength / 2); + throw new Error( + `Palette color lookup table length mismatch (${color}): descriptor has ${tableLen} entries (expected byteLength ${tableLen} or ${tableLen * 2}), but got ${byteLength} bytes (${actualEntries} effective entries). This may indicate duplicated or concatenated buffer data from the natural filter.` + ); + } + const use8 = tableLen === byteLength; + if (use8) { + return view instanceof Uint8Array ? view : new Uint8Array(view); + } + return view instanceof Uint16Array + ? view + : new Uint16Array(view.buffer, view.byteOffset, view.byteLength / 2); +} + +function describeValue(raw: unknown): string { + if (raw === null) return 'Got null.'; + if (raw === undefined) return 'Got undefined.'; + if (typeof raw !== 'object') return `Got primitive: ${typeof raw}.`; + const obj = raw as Record; + const constructorName = + obj.constructor != null && typeof obj.constructor === 'function' + ? (obj.constructor as Function).name + : 'unknown'; + const keys = Object.keys(obj); + const preview: Record = {}; + for (const k of keys) { + const v = obj[k]; + if (v === null) preview[k] = 'null'; + else if (v === undefined) preview[k] = 'undefined'; + else if (typeof v === 'string') + preview[k] = + v.length > 80 + ? `"${v.slice(0, 80)}..." (len=${v.length})` + : JSON.stringify(v); + else if (Array.isArray(v)) + preview[k] = + `Array(${v.length})${v.length > 0 ? ` e.g. ${JSON.stringify(v[0])}` : ''}`; + else if (v instanceof ArrayBuffer) + preview[k] = `ArrayBuffer(${v.byteLength})`; + else if (ArrayBuffer.isView(v)) + preview[k] = + `${(v as object).constructor?.name ?? 'ArrayBufferView'}(${(v as ArrayBufferView).byteLength})`; + else preview[k] = typeof v; + } + return `Got object: constructor=${constructorName}, keys=[${keys.join(', ')}], preview=${JSON.stringify(preview)}.`; +} + +/** + * Converts pixel data to the appropriate array type + */ +export function pixelDataUpdate(next, query, data, options) { + const basePixelData = next(query, data, options); + if (!basePixelData) { + return basePixelData; + } + const result = { ...basePixelData }; + const { + redPaletteColorLookupTableData, + greenPaletteColorLookupTableData, + bluePaletteColorLookupTableData, + pixelPaddingValue, + pixelPaddingRangeLimit, + pixelRepresentation, + } = basePixelData; + + if ( + redPaletteColorLookupTableData != null && + greenPaletteColorLookupTableData != null && + bluePaletteColorLookupTableData != null + ) { + result.redPaletteColorLookupTableData = normalizePaletteLUTToFinal( + redPaletteColorLookupTableData, + result.redPaletteColorLookupTableDescriptor, + 'red' + ); + result.greenPaletteColorLookupTableData = normalizePaletteLUTToFinal( + greenPaletteColorLookupTableData, + result.greenPaletteColorLookupTableDescriptor, + 'green' + ); + result.bluePaletteColorLookupTableData = normalizePaletteLUTToFinal( + bluePaletteColorLookupTableData, + result.bluePaletteColorLookupTableDescriptor, + 'blue' + ); + } + + if (pixelRepresentation == 1) { + if (pixelPaddingValue < 0) { + result.pixelPaddingValue = pixelPaddingValue & 0xffff; + } + if (pixelPaddingRangeLimit < 0) { + result.pixelPaddingValue = pixelPaddingRangeLimit & 0xffff; + } + const { smallestPixelValue, largestPixelValue } = result; + if (smallestPixelValue < 0) { + result.smallestPixelValue = smallestPixelValue & 0xffff; + } + if (largestPixelValue < 0) { + result.largestPixelValue = largestPixelValue & 0xffff; + } + } + + return result; +} + +export function registerPixelDataUpdate() { + addTypedProvider(MetadataModules.IMAGE_PIXEL, pixelDataUpdate); +} diff --git a/packages/metadata/src/utilities/metadataProvider/scalingFromInstance.ts b/packages/metadata/src/utilities/metadataProvider/scalingFromInstance.ts new file mode 100644 index 0000000000..129d31e8fc --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/scalingFromInstance.ts @@ -0,0 +1,220 @@ +/** + * Typed provider for scalingModule. Expects instance or natural data in the + * chain's data field (via dataLookup). Data is assumed to use upper camel case + * for all tags (DICOM keyword style). Uses NATURAL in the default setup + * (multiframe, no per-frame scaling). PT: builds InstanceMetadata from data + * and uses @cornerstonejs/calculate-suv; RTDOSE: returns DoseGridScaling etc. + */ + +import { addTypedProvider } from '../../metaData'; +import type { TypedProvider } from '../../metaData'; +import { MetadataModules } from '../../enums'; +import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv'; +import type { InstanceMetadata } from '@cornerstonejs/calculate-suv'; + +function timeToString(v: unknown): string { + if (typeof v === 'string') return v; + if (v && typeof v === 'object' && !Array.isArray(v)) { + const t = v as { + hours?: number; + minutes?: number; + seconds?: number; + fractionalSeconds?: number; + }; + const hours = `${t?.hours ?? '00'}`.padStart(2, '0'); + const minutes = `${t?.minutes ?? '00'}`.padStart(2, '0'); + const seconds = `${t?.seconds ?? '00'}`.padStart(2, '0'); + const fractionalSeconds = `${t?.fractionalSeconds ?? '000000'}`.padEnd( + 6, + '0' + ); + return `${hours}${minutes}${seconds}.${fractionalSeconds}`; + } + return v as string; +} + +function dateToString(v: unknown): string { + if (typeof v === 'string') return v; + if (v && typeof v === 'object' && !Array.isArray(v) && 'year' in v) { + const d = v as { year: number; month: number; day: number }; + const month = `${d.month}`.padStart(2, '0'); + const day = `${d.day}`.padStart(2, '0'); + return `${d.year}${month}${day}`; + } + return v as string; +} + +function parseNumber(v: unknown): number | undefined { + if (typeof v === 'number' && !Number.isNaN(v)) return v; + if (typeof v === 'string') { + const n = Number(v); + return Number.isNaN(n) ? undefined : n; + } + return undefined; +} + +/** First item of RadiopharmaceuticalInfo (sequence); supports array or array-like (e.g. makeArrayLike). */ +function getRadiopharmaceuticalInfo( + data: Record +): Record | undefined { + const ri = data.RadiopharmaceuticalInfo; + if (ri == null || typeof ri !== 'object') return undefined; + const first = Array.isArray(ri) + ? ri[0] + : ((ri as { 0?: unknown; length?: number })[0] ?? ri); + return first && typeof first === 'object' && !Array.isArray(first) + ? (first as Record) + : undefined; +} + +/** + * Build InstanceMetadata for calculate-suv from data. Data is assumed to use + * upper camel case for all tags (Modality, SeriesDate, PatientWeight, etc.). + */ +function buildPTInstanceMetadataFromData( + data: Record +): InstanceMetadata | null { + if (data.Modality !== 'PT') return null; + + const { + SeriesDate: seriesDate, + SeriesTime: seriesTime, + AcquisitionDate: acquisitionDate, + AcquisitionTime: acquisitionTime, + PatientWeight: patientWeight, + CorrectedImage: correctedImage, + Units: units, + DecayCorrection: decayCorrection, + } = data; + + const ri = getRadiopharmaceuticalInfo(data); + if (!ri) return null; + const radionuclideTotalDose = parseNumber(ri.RadionuclideTotalDose); + const radionuclideHalfLife = parseNumber(ri.RadionuclideHalfLife); + const radiopharmaceuticalStartDateTime = + ri.RadiopharmaceuticalStartDateTime as string | undefined; + const radiopharmaceuticalStartTime = ri.RadiopharmaceuticalStartTime as + | string + | undefined; + + const patientWeightNum = parseNumber(patientWeight); + if ( + seriesDate === undefined || + seriesTime === undefined || + patientWeightNum === undefined || + acquisitionDate === undefined || + acquisitionTime === undefined || + correctedImage === undefined || + units === undefined || + decayCorrection === undefined || + radionuclideTotalDose === undefined || + radionuclideHalfLife === undefined + ) { + return null; + } + + const toDate = (v: unknown): string => + typeof v === 'string' + ? v + : v && typeof v === 'object' && 'year' in v + ? dateToString(v as { year: number; month: number; day: number }) + : (v as string); + const toTime = (v: unknown): string => + typeof v === 'string' + ? v + : v && typeof v === 'object' + ? timeToString(v as Parameters[0]) + : (v as string); + + const correctedImageValue = + typeof correctedImage === 'string' + ? correctedImage.split('\\') + : Array.isArray(correctedImage) + ? correctedImage + : correctedImage; + + const instanceMetadata: InstanceMetadata = { + CorrectedImage: correctedImageValue as string | string[], + Units: units as string, + RadionuclideHalfLife: radionuclideHalfLife, + RadionuclideTotalDose: radionuclideTotalDose, + DecayCorrection: decayCorrection as string, + PatientWeight: patientWeightNum, + SeriesDate: dateToString(seriesDate), + SeriesTime: timeToString(seriesTime), + AcquisitionDate: dateToString(acquisitionDate), + AcquisitionTime: timeToString(acquisitionTime), + }; + + if (radiopharmaceuticalStartDateTime !== undefined) { + instanceMetadata.RadiopharmaceuticalStartDateTime = dateToString( + radiopharmaceuticalStartDateTime + ); + } + if (radiopharmaceuticalStartTime !== undefined) { + instanceMetadata.RadiopharmaceuticalStartTime = timeToString( + radiopharmaceuticalStartTime + ); + } + + if (data.PatientSex !== undefined) + instanceMetadata.PatientSex = data.PatientSex as string; + if (data.PatientSize !== undefined) + instanceMetadata.PatientSize = data.PatientSize as number; + + return instanceMetadata; +} + +function scalingFromInstanceProvider( + next: (query: string, data?: unknown, options?: unknown) => unknown, + query: string, + data?: unknown, + options?: unknown +): unknown { + if (data == null || typeof data !== 'object') { + return next(query, data, options); + } + + const d = data as Record; + + if (d.Modality === 'RTDOSE') { + const doseGridScaling = parseNumber(d.DoseGridScaling); + const { DoseSummation, DoseType, DoseUnit } = d; + if ( + doseGridScaling !== undefined || + DoseSummation !== undefined || + DoseType !== undefined || + DoseUnit !== undefined + ) { + return { + DoseGridScaling: doseGridScaling, + DoseSummation, + DoseType, + DoseUnit, + }; + } + } + + if (d.Modality === 'PT') { + const instanceMetadata = buildPTInstanceMetadataFromData(d); + if (!instanceMetadata) { + return next(query, undefined, options); + } + try { + const factors = calculateSUVScalingFactors([instanceMetadata]); + return factors[0] ?? next(query, undefined, options); + } catch { + return next(query, undefined, options); + } + } + + return next(query, undefined, options); +} + +export function registerScalingFromInstanceProvider(): void { + addTypedProvider( + MetadataModules.SCALING, + scalingFromInstanceProvider as TypedProvider, + { priority: 0, isDefault: true } + ); +} diff --git a/packages/metadata/src/utilities/metadataProvider/tagModules.ts b/packages/metadata/src/utilities/metadataProvider/tagModules.ts new file mode 100644 index 0000000000..ace30dd008 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/tagModules.ts @@ -0,0 +1,79 @@ +import { addTypedProvider, type TypedProvider } from '../../metaData'; +import { mapModuleTags } from '../Tags'; +import { dataLookup, DATA_PRIORITY, instanceLookup } from './dataLookup'; +import { makeArrayLike } from './makeArrayLike'; +import { metadataLog } from '../logging'; + +const log = metadataLog.getLogger('tagModules'); + +const mapModules = new Map(); + +/** + * Clears the tag modules cache. Call after removeAllProviders() so that + * registerTagModules() can re-register providers (tagModules(module) will + * return a function instead of undefined). + */ +export function clearTagModules(): void { + mapModules.clear(); +} + +export function tagModules(module: string, dataLookupName = 'instance') { + if (!mapModuleTags.has(module)) { + throw new Error(`No module found: ${module}`); + } + if (mapModules.has(module)) { + return mapModules.get(module); + } + + if (dataLookupName === 'instance') { + addTypedProvider(module, instanceLookup, { priority: 25_000 }); + } else if (dataLookupName) { + addTypedProvider(module, dataLookup(dataLookupName), { priority: 25_000 }); + } + + const moduleProvider = (next, query, data, options) => { + const keys = mapModuleTags.get(module); + const destName = options?.destName || 'lowerName'; + if (!data) { + return next(query, data, options); + } + + const result = {}; + for (const key of keys) { + let value = data[key.name]; + if (value !== undefined) { + if (mapModules.has(key.name)) { + log.debug('Getting nested module', key.name); + const newValue = []; + for (const entry of value) { + if (!entry) { + continue; + } + newValue.push( + mapModules.get(key.name)(null, query, entry, options?.[key.name]) + ); + } + value = newValue.length === 1 ? makeArrayLike(newValue[0]) : newValue; + } + result[key[destName]] = value; + } + } + return result; + }; + + mapModules.set(module, moduleProvider); + + return moduleProvider as TypedProvider; +} + +export const MODULE_PRIORITY = { priority: -1_000 }; + +export function registerTagModules() { + for (const module of mapModuleTags.keys()) { + const providerFn = tagModules(module); + if (providerFn) { + addTypedProvider(module, providerFn, MODULE_PRIORITY); + } + addTypedProvider(module, instanceLookup, DATA_PRIORITY); + } +} diff --git a/packages/metadata/src/utilities/metadataProvider/transferSyntaxProvider.ts b/packages/metadata/src/utilities/metadataProvider/transferSyntaxProvider.ts new file mode 100644 index 0000000000..559760eb58 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/transferSyntaxProvider.ts @@ -0,0 +1,23 @@ +import { MetadataModules } from '../../enums'; +import { addTypedProvider } from '../../metaData'; +import isVideoTransferSyntax from '../isVideoTransferSyntax'; + +export function transferSyntaxProvider(next, query, data, options) { + const fmiBase = next(query, data, options); + if (fmiBase) { + const transferSyntaxUID = + fmiBase.transferSyntaxUID || fmiBase.availableTransferSyntaxUID; + const isVideo = isVideoTransferSyntax(transferSyntaxUID); + return { + ...fmiBase, + transferSyntaxUID: Array.isArray(transferSyntaxUID) + ? transferSyntaxUID[0] + : transferSyntaxUID, + isVideo, + }; + } +} + +export function registerTransferSyntaxProvider() { + addTypedProvider(MetadataModules.TRANSFER_SYNTAX, transferSyntaxProvider); +} diff --git a/packages/metadata/src/utilities/metadataProvider/uriModule.ts b/packages/metadata/src/utilities/metadataProvider/uriModule.ts new file mode 100644 index 0000000000..16452aa809 --- /dev/null +++ b/packages/metadata/src/utilities/metadataProvider/uriModule.ts @@ -0,0 +1,43 @@ +import { MetadataModules } from '../../enums'; +import { addTypedProvider } from '../../metaData'; + +export interface UriModule { + baseImageId: string; + frameNumber?: number; + framesString?: string; + remaining?: string; + sopInstanceUID?: string; + seriesInstanceUID?: string; + studyInstanceUID?: string; +} + +export const frameRangeExtractor = + /(\/frames\/|[&?]frameNumber=|[&?]frame=)([^/&?]*)(.*)/i; + +export function getUriModule(imageId) { + const framesMatch = imageId.match(frameRangeExtractor); + if (!framesMatch) { + return; + } + const baseImageId = imageId.substring(0, framesMatch.index); + const framesString = framesMatch[2]; + const frameNumber = parseFloat(framesString); + const remaining = framesMatch[3]; + + return { + baseImageId, + frameNumber, + framesString, + remaining, + }; +} + +export function uriModuleProvider(next, imageId, data, options) { + return getUriModule(imageId) || next(imageId, data, options); +} + +export function registerUriModule() { + addTypedProvider(MetadataModules.URI_MODULE, uriModuleProvider); +} + +export default getUriModule; diff --git a/packages/metadata/src/utilities/modules/cine.ts b/packages/metadata/src/utilities/modules/cine.ts new file mode 100644 index 0000000000..0dbadfafa6 --- /dev/null +++ b/packages/metadata/src/utilities/modules/cine.ts @@ -0,0 +1,7 @@ +import type { ModuleTagEntry } from './index'; + +/** Cine module tags. */ +export const tags: ModuleTagEntry[] = [ + 'FrameTime', + 'RecommendedDisplayFrameRate', +]; diff --git a/packages/metadata/src/utilities/modules/clinicalTrial.ts b/packages/metadata/src/utilities/modules/clinicalTrial.ts new file mode 100644 index 0000000000..87381eb9e9 --- /dev/null +++ b/packages/metadata/src/utilities/modules/clinicalTrial.ts @@ -0,0 +1,9 @@ +import type { ModuleTagEntry } from './index'; + +/** Clinical Trial module tags. */ +export const tags: ModuleTagEntry[] = [ + 'ClinicalTrialSponsorName', + 'ClinicalTrialSiteID', + 'ClinicalTrialSiteName', + 'ClinicalTrialSubjectID', +]; diff --git a/packages/metadata/src/utilities/modules/ecg.ts b/packages/metadata/src/utilities/modules/ecg.ts new file mode 100644 index 0000000000..b5862ab878 --- /dev/null +++ b/packages/metadata/src/utilities/modules/ecg.ts @@ -0,0 +1,17 @@ +import type { ModuleTagEntry } from './index'; + +/** + * ECG module tags. Subset of instance data used for ECG display sets. + * Same fields as General Series + SOP Common used by the OHIF ecg-dicom extension. + */ +export const tags: ModuleTagEntry[] = [ + 'StudyInstanceUID', + 'SeriesInstanceUID', + 'SeriesDescription', + 'SeriesNumber', + 'SeriesDate', + 'SeriesTime', + 'Modality', + 'SOPInstanceUID', + 'SOPClassUID', +]; diff --git a/packages/metadata/src/utilities/modules/generalImage.ts b/packages/metadata/src/utilities/modules/generalImage.ts new file mode 100644 index 0000000000..83f5dc99af --- /dev/null +++ b/packages/metadata/src/utilities/modules/generalImage.ts @@ -0,0 +1,17 @@ +import type { ModuleTagEntry } from './index'; + +/** General Image module tags. */ +export const tags: ModuleTagEntry[] = [ + 'SOPInstanceUID', + 'SOPClassUID', + 'InstanceNumber', + 'InstanceCreationDate', + 'InstanceCreationTime', + 'ContentTime', + 'LossyImageCompression', + 'LossyImageCompressionRatio', + 'LossyImageCompressionMethod', + 'FrameOfReferenceUID', + 'NumberOfFrames', + 'PixelSpacing', +]; diff --git a/packages/metadata/src/utilities/modules/generalSeries.ts b/packages/metadata/src/utilities/modules/generalSeries.ts new file mode 100644 index 0000000000..db4556ea53 --- /dev/null +++ b/packages/metadata/src/utilities/modules/generalSeries.ts @@ -0,0 +1,14 @@ +import type { ModuleTagEntry } from './index'; + +/** General Series module tags. */ +export const tags: ModuleTagEntry[] = [ + 'StudyInstanceUID', + 'SeriesInstanceUID', + 'Modality', + 'SeriesDescription', + 'SeriesNumber', + 'SeriesDate', + 'SeriesTime', + 'AcquisitionDate', + 'AcquisitionTime', +]; diff --git a/packages/metadata/src/utilities/modules/generalStudy.ts b/packages/metadata/src/utilities/modules/generalStudy.ts new file mode 100644 index 0000000000..7634d2deb7 --- /dev/null +++ b/packages/metadata/src/utilities/modules/generalStudy.ts @@ -0,0 +1,10 @@ +import type { ModuleTagEntry } from './index'; + +/** General Study module tags. */ +export const tags: ModuleTagEntry[] = [ + 'StudyInstanceUID', + 'StudyDescription', + 'StudyDate', + 'StudyTime', + 'AccessionNumber', +]; diff --git a/packages/metadata/src/utilities/modules/imagePixel.ts b/packages/metadata/src/utilities/modules/imagePixel.ts new file mode 100644 index 0000000000..f6cb587ff5 --- /dev/null +++ b/packages/metadata/src/utilities/modules/imagePixel.ts @@ -0,0 +1,34 @@ +import type { ModuleTagEntry } from './index'; + +/** Image Pixel module tags. */ +export const tags: ModuleTagEntry[] = [ + 'SamplesPerPixel', + 'PhotometricInterpretation', + 'PlanarConfiguration', + 'Rows', + 'Columns', + 'BitsAllocated', + 'BitsStored', + 'HighBit', + 'PixelRepresentation', + ['SmallestPixelValue', '00280106'], + ['LargestPixelValue', '00280107'], + 'PixelPaddingValue', + 'PixelPaddingRangeLimit', + 'RedPaletteColorLookupTableDescriptor', + 'GreenPaletteColorLookupTableDescriptor', + 'BluePaletteColorLookupTableDescriptor', + 'AlphaPaletteColorLookupTableDescriptor', + 'RedPaletteColorLookupTableData', + 'GreenPaletteColorLookupTableData', + 'BluePaletteColorLookupTableData', + 'AlphaPaletteColorLookupTableData', + 'PaletteColorLookupTableUID', + 'DistanceSourceToDetector', + 'DistanceSourceToPatient', + 'EstimatedRadiographicMagnificationFactor', + 'DistanceSourceToEntrance', + 'PixelSpacingCalibrationType', + 'PixelSpacingCalibrationDescription', + 'SequenceOfUltrasoundRegions', +]; diff --git a/packages/metadata/src/utilities/modules/imagePlane.ts b/packages/metadata/src/utilities/modules/imagePlane.ts new file mode 100644 index 0000000000..8cdd001cd7 --- /dev/null +++ b/packages/metadata/src/utilities/modules/imagePlane.ts @@ -0,0 +1,11 @@ +import type { ModuleTagEntry } from './index'; + +/** Image Plane module tags. */ +export const tags: ModuleTagEntry[] = [ + 'ImagerPixelSpacing', + 'ImageOrientationPatient', + 'ImagePositionPatient', + 'SpacingBetweenSlices', + 'SliceThickness', + 'SliceLocation', +]; diff --git a/packages/metadata/src/utilities/modules/index.ts b/packages/metadata/src/utilities/modules/index.ts new file mode 100644 index 0000000000..465bf1fe54 --- /dev/null +++ b/packages/metadata/src/utilities/modules/index.ts @@ -0,0 +1,63 @@ +import { MetadataModules } from '../../enums'; + +import { tags as transferSyntaxTags } from './transferSyntax'; +import { tags as cineTags } from './cine'; +import { tags as ptIsotopeTags } from './ptIsotope'; +import { tags as radiopharmaceuticalInfoTags } from './radiopharmaceuticalInfo'; +import { tags as ptImageTags } from './ptImage'; +import { tags as ptSeriesTags } from './ptSeries'; +import { tags as patientTags } from './patient'; +import { tags as patientStudyTags } from './patientStudy'; +import { tags as generalStudyTags } from './generalStudy'; +import { tags as generalSeriesTags } from './generalSeries'; +import { tags as clinicalTrialTags } from './clinicalTrial'; +import { tags as sopCommonTags } from './sopCommon'; +import { tags as ecgTags } from './ecg'; +import { tags as generalImageTags } from './generalImage'; +import { tags as imagePlaneTags } from './imagePlane'; +import { tags as imagePixelTags } from './imagePixel'; +import { tags as voiLutTags } from './voiLut'; +import { tags as modalityLutTags } from './modalityLut'; +import { tags as ultrasoundEnhancedRegionTags } from './ultrasoundEnhancedRegion'; +import { tags as usRegionChildTags } from './usRegionChild'; +import { tags as unassignedTags } from './unassigned'; + +/** Custom module name constants (not in MetadataModules enum). */ +export const USRegionChild = 'usRegionChild'; +export const CLINICAL_TRIAL = 'clinicalTrialModule'; +export const RadiopharmaceuticalInfoModule = 'RadiopharmaceuticalInfo'; + +/** + * A module tag entry is either a keyword string (resolved via dcmjs nameMap) + * or a [keyword, hexOverride] tuple for names that differ from dcmjs. + */ +export type ModuleTagEntry = string | [name: string, hexOverride: string]; + +/** + * Maps module name to the array of tag keywords belonging to that module. + * A null module name means the tags are registered for lookup but not + * assigned to any specific metadata module. + */ +export const moduleDefinitions: [string | null, ModuleTagEntry[]][] = [ + [MetadataModules.TRANSFER_SYNTAX, transferSyntaxTags], + [MetadataModules.CINE, cineTags], + [MetadataModules.PET_ISOTOPE, ptIsotopeTags], + [RadiopharmaceuticalInfoModule, radiopharmaceuticalInfoTags], + [MetadataModules.PET_IMAGE, ptImageTags], + [MetadataModules.PET_SERIES, ptSeriesTags], + [MetadataModules.PATIENT, patientTags], + [MetadataModules.PATIENT_STUDY, patientStudyTags], + [MetadataModules.GENERAL_STUDY, generalStudyTags], + [MetadataModules.GENERAL_SERIES, generalSeriesTags], + [CLINICAL_TRIAL, clinicalTrialTags], + [MetadataModules.SOP_COMMON, sopCommonTags], + [MetadataModules.ECG, ecgTags], + [MetadataModules.GENERAL_IMAGE, generalImageTags], + [MetadataModules.IMAGE_PLANE, imagePlaneTags], + [MetadataModules.IMAGE_PIXEL, imagePixelTags], + [MetadataModules.VOI_LUT, voiLutTags], + [MetadataModules.MODALITY_LUT, modalityLutTags], + [MetadataModules.ULTRASOUND_ENHANCED_REGION, ultrasoundEnhancedRegionTags], + [USRegionChild, usRegionChildTags], + [null, unassignedTags], +]; diff --git a/packages/metadata/src/utilities/modules/modalityLut.ts b/packages/metadata/src/utilities/modules/modalityLut.ts new file mode 100644 index 0000000000..7eb5fc5a33 --- /dev/null +++ b/packages/metadata/src/utilities/modules/modalityLut.ts @@ -0,0 +1,8 @@ +import type { ModuleTagEntry } from './index'; + +/** Modality LUT module tags. */ +export const tags: ModuleTagEntry[] = [ + 'RescaleIntercept', + 'RescaleSlope', + 'RescaleType', +]; diff --git a/packages/metadata/src/utilities/modules/patient.ts b/packages/metadata/src/utilities/modules/patient.ts new file mode 100644 index 0000000000..bd8dff344d --- /dev/null +++ b/packages/metadata/src/utilities/modules/patient.ts @@ -0,0 +1,9 @@ +import type { ModuleTagEntry } from './index'; + +/** Patient module tags. */ +export const tags: ModuleTagEntry[] = [ + 'PatientID', + 'PatientName', + 'PatientBirthDate', + 'PatientBirthTime', +]; diff --git a/packages/metadata/src/utilities/modules/patientStudy.ts b/packages/metadata/src/utilities/modules/patientStudy.ts new file mode 100644 index 0000000000..484436cbc9 --- /dev/null +++ b/packages/metadata/src/utilities/modules/patientStudy.ts @@ -0,0 +1,9 @@ +import type { ModuleTagEntry } from './index'; + +/** Patient Study module tags. */ +export const tags: ModuleTagEntry[] = [ + 'PatientAge', + 'PatientSize', + 'PatientSex', + 'PatientWeight', +]; diff --git a/packages/metadata/src/utilities/modules/ptImage.ts b/packages/metadata/src/utilities/modules/ptImage.ts new file mode 100644 index 0000000000..0d5c50e3aa --- /dev/null +++ b/packages/metadata/src/utilities/modules/ptImage.ts @@ -0,0 +1,7 @@ +import type { ModuleTagEntry } from './index'; + +/** PET Image module tags. */ +export const tags: ModuleTagEntry[] = [ + 'FrameReferenceTime', + 'ActualFrameDuration', +]; diff --git a/packages/metadata/src/utilities/modules/ptIsotope.ts b/packages/metadata/src/utilities/modules/ptIsotope.ts new file mode 100644 index 0000000000..1a68799f33 --- /dev/null +++ b/packages/metadata/src/utilities/modules/ptIsotope.ts @@ -0,0 +1,4 @@ +import type { ModuleTagEntry } from './index'; + +/** PET Isotope module tags. */ +export const tags: ModuleTagEntry[] = [['RadiopharmaceuticalInfo', '00540016']]; diff --git a/packages/metadata/src/utilities/modules/ptSeries.ts b/packages/metadata/src/utilities/modules/ptSeries.ts new file mode 100644 index 0000000000..b61426bb91 --- /dev/null +++ b/packages/metadata/src/utilities/modules/ptSeries.ts @@ -0,0 +1,8 @@ +import type { ModuleTagEntry } from './index'; + +/** PET Series module tags. */ +export const tags: ModuleTagEntry[] = [ + 'CorrectedImage', + 'Units', + 'DecayCorrection', +]; diff --git a/packages/metadata/src/utilities/modules/radiopharmaceuticalInfo.ts b/packages/metadata/src/utilities/modules/radiopharmaceuticalInfo.ts new file mode 100644 index 0000000000..2d9e6a5793 --- /dev/null +++ b/packages/metadata/src/utilities/modules/radiopharmaceuticalInfo.ts @@ -0,0 +1,9 @@ +import type { ModuleTagEntry } from './index'; + +/** Radiopharmaceutical Information module tags. */ +export const tags: ModuleTagEntry[] = [ + 'RadiopharmaceuticalStartTime', + 'RadiopharmaceuticalStopTime', + 'RadionuclideTotalDose', + 'RadionuclideHalfLife', +]; diff --git a/packages/metadata/src/utilities/modules/sopCommon.ts b/packages/metadata/src/utilities/modules/sopCommon.ts new file mode 100644 index 0000000000..b21c33b40d --- /dev/null +++ b/packages/metadata/src/utilities/modules/sopCommon.ts @@ -0,0 +1,4 @@ +import type { ModuleTagEntry } from './index'; + +/** SOP Common module tags. */ +export const tags: ModuleTagEntry[] = ['SOPInstanceUID', 'SOPClassUID']; diff --git a/packages/metadata/src/utilities/modules/transferSyntax.ts b/packages/metadata/src/utilities/modules/transferSyntax.ts new file mode 100644 index 0000000000..df66e3c563 --- /dev/null +++ b/packages/metadata/src/utilities/modules/transferSyntax.ts @@ -0,0 +1,7 @@ +import type { ModuleTagEntry } from './index'; + +/** Transfer Syntax module tags. */ +export const tags: ModuleTagEntry[] = [ + 'TransferSyntaxUID', + 'AvailableTransferSyntaxUID', +]; diff --git a/packages/metadata/src/utilities/modules/ultrasoundEnhancedRegion.ts b/packages/metadata/src/utilities/modules/ultrasoundEnhancedRegion.ts new file mode 100644 index 0000000000..5e8bde88d4 --- /dev/null +++ b/packages/metadata/src/utilities/modules/ultrasoundEnhancedRegion.ts @@ -0,0 +1,4 @@ +import type { ModuleTagEntry } from './index'; + +/** Ultrasound Enhanced Region module tags. */ +export const tags: ModuleTagEntry[] = ['SequenceOfUltrasoundRegions']; diff --git a/packages/metadata/src/utilities/modules/unassigned.ts b/packages/metadata/src/utilities/modules/unassigned.ts new file mode 100644 index 0000000000..9d5581ac5d --- /dev/null +++ b/packages/metadata/src/utilities/modules/unassigned.ts @@ -0,0 +1,21 @@ +import type { ModuleTagEntry } from './index'; + +/** + * Tags registered for lookup but not assigned to any specific metadata module. + * Includes referenced image sequences and functional group sequences. + */ +export const tags: ModuleTagEntry[] = [ + 'ReferencedImageSequence', + 'ReferencedSOPClassUID', + 'ReferencedSOPInstanceUID', + 'ReferencedFrameNumber', + 'SharedFunctionalGroupsSequence', + 'PerFrameFunctionalGroupsSequence', + 'PlanePositionSequence', + 'AnatomicRegionSequence', + 'PlaneOrientationSequence', + 'PixelMeasuresSequence', + 'PixelValueTransformationSequence', + 'ParametricMapFrameTypeSequence', + 'RealWorldValueMappingSequence', +]; diff --git a/packages/metadata/src/utilities/modules/usRegionChild.ts b/packages/metadata/src/utilities/modules/usRegionChild.ts new file mode 100644 index 0000000000..a39a2fde08 --- /dev/null +++ b/packages/metadata/src/utilities/modules/usRegionChild.ts @@ -0,0 +1,21 @@ +import type { ModuleTagEntry } from './index'; + +/** US Region child tags (nested within SequenceOfUltrasoundRegions). */ +export const tags: ModuleTagEntry[] = [ + 'PhysicalDeltaX', + 'PhysicalDeltaY', + 'PhysicalUnitsXDirection', + 'PhysicalUnitsYDirection', + 'RegionLocationMinY0', + 'RegionLocationMaxY1', + 'RegionLocationMinX0', + 'RegionLocationMaxX1', + 'ReferencePixelX0', + 'ReferencePixelY0', + ['ReferencePhysicalPixelValueY', '0018602A'], + ['ReferencePhysicalPixelValueX', '00186028'], + 'RegionSpatialFormat', + 'RegionDataType', + 'RegionFlags', + 'TransducerFrequency', +]; diff --git a/packages/metadata/src/utilities/modules/voiLut.ts b/packages/metadata/src/utilities/modules/voiLut.ts new file mode 100644 index 0000000000..23c678d3b3 --- /dev/null +++ b/packages/metadata/src/utilities/modules/voiLut.ts @@ -0,0 +1,9 @@ +import type { ModuleTagEntry } from './index'; + +/** VOI LUT module tags. */ +export const tags: ModuleTagEntry[] = [ + 'WindowCenter', + 'WindowWidth', + 'VOILUTFunction', + 'WindowCenterWidthExplanation', +]; diff --git a/packages/metadata/src/utilities/splitImageIdsBy4DTags.ts b/packages/metadata/src/utilities/splitImageIdsBy4DTags.ts new file mode 100644 index 0000000000..7a255abbe6 --- /dev/null +++ b/packages/metadata/src/utilities/splitImageIdsBy4DTags.ts @@ -0,0 +1,514 @@ +import * as metaData from '../metaData'; +import { toFiniteNumber } from './toNumber'; + +// TODO: Test remaining implemented tags +// Supported 4D Tags +// (0018,1060) Trigger Time [Implemented, not tested] +// (0018,0081) Echo Time [Implemented, not tested] +// (0018,0086) Echo Number [Implemented, not tested] +// (0020,0100) Temporal Position Identifier [OK] +// (0054,1300) FrameReferenceTime [OK] +// (0018,9087) Diffusion B Value [OK] +// (2001,1003) Philips Diffusion B-factor [OK] +// (0019,100c) Siemens Diffusion B Value [Implemented, not tested] +// (0043,1039) GE Diffusion B Value [OK] +// +// Multiframe 4D Support (NM Multi-frame Module): +// (0054,0070) TimeSlotVector [OK] +// (0054,0080) SliceVector [Used for ordering within time slots] + +interface MappedIPP { + imageId: string; + imagePositionPatient; +} + +interface MultiframeSplitResult { + imageIdGroups: string[][]; + splittingTag: string; +} + +/** + * Generates frame-specific imageIds for a multiframe image. + * Replaces the frame number in the imageId with the specified frame number (1-based). + * + * @param baseImageId - The base imageId that must contain a "/frames/" pattern followed by a digit. + * Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1". + * The pattern "/frames/\d+" will be replaced with "/frames/" + frameNumber. + * @param frameNumber - The frame number to use (1-based) + * @returns The imageId with the frame number replaced + * @throws {Error} If baseImageId does not contain the required "/frames/" pattern, throws an error + * with a clear message indicating the expected format. + */ +function generateFrameImageId( + baseImageId: string, + frameNumber: number +): string { + const framePattern = /\/frames\/\d+/; + + if (!framePattern.test(baseImageId)) { + throw new Error( + `generateFrameImageId: baseImageId must contain a "/frames/" pattern followed by a digit. ` + + `Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1". ` + + `Received: ${baseImageId}` + ); + } + + return baseImageId.replace(framePattern, `/frames/${frameNumber}`); +} + +/** + * Handles multiframe 4D splitting using TimeSlotVector (0054,0070). + * For NM Multi-frame images where frames are indexed by time slot and slice. + * + * @param imageIds - Array containing the base imageId (typically just one for multiframe). + * The base imageId must contain a "/frames/" pattern (e.g., "wadouri:http://example.com/image/frames/1"). + * See generateFrameImageId for format requirements. + * @returns Split result if multiframe 4D is detected, null otherwise + */ +function handleMultiframe4D(imageIds: string[]): MultiframeSplitResult | null { + if (!imageIds || imageIds.length === 0) { + return null; + } + + const baseImageId = imageIds[0]; + const instance = metaData.get('instance', baseImageId); + + if (!instance) { + return null; + } + + const numberOfFrames = instance.NumberOfFrames; + if (!numberOfFrames || numberOfFrames <= 1) { + return null; + } + + const timeSlotVector = instance.TimeSlotVector; + if (!timeSlotVector || !Array.isArray(timeSlotVector)) { + return null; + } + + const sliceVector = instance.SliceVector; + const numberOfSlices = instance.NumberOfSlices; + + if (timeSlotVector.length !== numberOfFrames) { + console.warn( + 'TimeSlotVector length does not match NumberOfFrames:', + timeSlotVector.length, + 'vs', + numberOfFrames + ); + return null; + } + + if (sliceVector) { + if (!Array.isArray(sliceVector)) { + console.warn( + 'SliceVector exists but is not an array. Expected length:', + numberOfFrames + ); + return null; + } + + if ( + sliceVector.length !== numberOfFrames || + sliceVector.some((val) => val === undefined) + ) { + console.warn( + 'SliceVector exists but has invalid length or undefined entries. Expected length:', + numberOfFrames, + 'Actual length:', + sliceVector.length + ); + return null; + } + } + + const timeSlotGroups: Map< + number, + Array<{ frameIndex: number; sliceIndex: number }> + > = new Map(); + + for (let frameIndex = 0; frameIndex < numberOfFrames; frameIndex++) { + const timeSlot = timeSlotVector[frameIndex]; + const sliceIndex = sliceVector?.[frameIndex] ?? frameIndex; + + if (!timeSlotGroups.has(timeSlot)) { + timeSlotGroups.set(timeSlot, []); + } + + timeSlotGroups.get(timeSlot).push({ frameIndex, sliceIndex }); + } + + const sortedTimeSlots = Array.from(timeSlotGroups.keys()).sort( + (a, b) => a - b + ); + + const imageIdGroups: string[][] = sortedTimeSlots.map((timeSlot) => { + const frames = timeSlotGroups.get(timeSlot); + + frames.sort((a, b) => a.sliceIndex - b.sliceIndex); + + return frames.map((frame) => + generateFrameImageId(baseImageId, frame.frameIndex + 1) + ); + }); + + const expectedSlicesPerTimeSlot = numberOfSlices || imageIdGroups[0]?.length; + const allGroupsHaveSameLength = imageIdGroups.every( + (group) => group.length === expectedSlicesPerTimeSlot + ); + + if (!allGroupsHaveSameLength) { + console.warn( + 'Multiframe 4D split resulted in uneven time slot groups. Expected', + expectedSlicesPerTimeSlot, + 'slices per time slot.' + ); + } + + return { + imageIdGroups, + splittingTag: 'TimeSlotVector', + }; +} + +function handleCardiac4D(imageIds: string[]): MultiframeSplitResult | null { + if (!imageIds || imageIds.length === 0) { + return null; + } + + const cardiacNumberOfImages = getFiniteValue( + imageIds[0], + 'CardiacNumberOfImages' + ); + + // Check if CardiacNumberOfImages exists as a detection flag for cardiac 4D data + if (cardiacNumberOfImages === undefined) { + return null; + } + + const stacks: Map< + string, + Map> + > = new Map(); + + for (const imageId of imageIds) { + const stackId = metaData.get('StackID', imageId); + const inStackPositionNumber = getFiniteValue( + imageId, + 'InStackPositionNumber' + ); + const triggerTime = getFiniteValue(imageId, 'TriggerTime'); + + if ( + stackId === undefined || + inStackPositionNumber === undefined || + triggerTime === undefined + ) { + return null; + } + + const stackKey = String(stackId); + if (!stacks.has(stackKey)) { + stacks.set(stackKey, new Map()); + } + + const positions = stacks.get(stackKey); + if (!positions.has(inStackPositionNumber)) { + positions.set(inStackPositionNumber, []); + } + + positions.get(inStackPositionNumber).push({ imageId, triggerTime }); + } + + const sortedStackIds = Array.from(stacks.keys()).sort( + (a, b) => Number(a) - Number(b) + ); + if (sortedStackIds.length === 0) { + return null; + } + + const preparedStacks: Array<{ + stackId: string; + positions: number[]; + framesByPosition: Map< + number, + Array<{ imageId: string; triggerTime: number }> + >; + }> = []; + + let timeCount: number | undefined; + + for (const stackId of sortedStackIds) { + const positions = stacks.get(stackId); + const sortedPositions = Array.from(positions.keys()).sort((a, b) => a - b); + + for (const position of sortedPositions) { + const frames = positions.get(position); + frames.sort((a, b) => a.triggerTime - b.triggerTime); + + if (timeCount === undefined) { + timeCount = frames.length; + } else if (frames.length !== timeCount) { + return null; + } + } + + preparedStacks.push({ + stackId, + positions: sortedPositions, + framesByPosition: positions, + }); + } + + if (!timeCount) { + return null; + } + + const imageIdGroups: string[][] = []; + for (let timeIndex = 0; timeIndex < timeCount; timeIndex++) { + const group: string[] = []; + for (const stack of preparedStacks) { + for (const position of stack.positions) { + const frames = stack.framesByPosition.get(position); + group.push(frames[timeIndex].imageId); + } + } + imageIdGroups.push(group); + } + + return { + imageIdGroups, + splittingTag: 'CardiacTriggerTime', + }; +} + +const groupBy = (array, key) => { + return array.reduce((rv, x) => { + (rv[x[key]] = rv[x[key]] || []).push(x); + return rv; + }, {}); +}; + +function getIPPGroups(imageIds: string[]): { [id: string]: Array } { + const ippMetadata: Array = imageIds.map((imageId) => { + const { imagePositionPatient } = + metaData.get('imagePlaneModule', imageId) || {}; + return { imageId, imagePositionPatient }; + }); + + if (!ippMetadata.every((item) => item.imagePositionPatient)) { + // Fail if any instances don't provide a position + return null; + } + + const positionGroups = groupBy(ippMetadata, 'imagePositionPatient'); + const positions = Object.keys(positionGroups); + const frame_count = positionGroups[positions[0]].length; + if (frame_count === 1) { + // Single frame indicates 3D volume + return null; + } + const frame_count_equal = positions.every( + (k) => positionGroups[k].length === frame_count + ); + if (!frame_count_equal) { + // Differences in number of frames per position group --> not a valid MV + return null; + } + return positionGroups; +} + +function test4DTag( + IPPGroups: { [id: string]: Array }, + value_getter: (imageId: string) => number +) { + const frame_groups = {}; + let first_frame_value_set: number[] = []; + + const positions = Object.keys(IPPGroups); + for (let i = 0; i < positions.length; i++) { + const frame_value_set: Set = new Set(); + const frames = IPPGroups[positions[i]]; + + for (let j = 0; j < frames.length; j++) { + const frame_value = value_getter(frames[j].imageId) || 0; + + frame_groups[frame_value] = frame_groups[frame_value] || []; + frame_groups[frame_value].push({ imageId: frames[j].imageId }); + + frame_value_set.add(frame_value); + if (frame_value_set.size - 1 < j) { + return undefined; + } + } + + if (i == 0) { + first_frame_value_set = Array.from(frame_value_set); + } else if (!setEquals(first_frame_value_set, frame_value_set)) { + return undefined; + } + } + return frame_groups; +} + +function getTagValue(imageId: string, tag: string): number | undefined { + const value = metaData.get(tag, imageId); + try { + return parseFloat(value); + } catch { + return undefined; + } +} + +function getFiniteValue(imageId: string, tag: string): number | undefined { + return toFiniteNumber(getTagValue(imageId, tag)); +} + +function getPhilipsPrivateBValue(imageId: string) { + // Philips Private Diffusion B-factor tag (2001, 1003) + // Private creator: Philips Imaging DD 001, VR=FL, VM=1 + const value = metaData.get('20011003', imageId); + try { + const { InlineBinary } = value; + if (InlineBinary) { + const value_bytes = atob(InlineBinary); + const ary_buf = new ArrayBuffer(value_bytes.length); + const dv = new DataView(ary_buf); + for (let i = 0; i < value_bytes.length; i++) { + dv.setUint8(i, value_bytes.charCodeAt(i)); + } + return new Float32Array(ary_buf)[0]; + } + + return parseFloat(value); + } catch { + return undefined; + } +} + +function getSiemensPrivateBValue(imageId: string) { + // Siemens Private Diffusion B-factor tag (0019, 100c) + // Private creator: SIEMENS MR HEADER, VR=IS, VM=1 + let value = + metaData.get('0019100c', imageId) || metaData.get('0019100C', imageId); + + try { + const { InlineBinary } = value; + if (InlineBinary) { + value = atob(InlineBinary); + } + return parseFloat(value); + } catch { + return undefined; + } +} + +function getGEPrivateBValue(imageId: string) { + // GE Private Diffusion B-factor tag (0043, 1039) + // Private creator: GEMS_PARM_01, VR=IS, VM=4 + let value = metaData.get('00431039', imageId); + + try { + const { InlineBinary } = value; + if (InlineBinary) { + value = atob(InlineBinary).split('//'); + } + return parseFloat(value[0]) % 100000; + } catch { + return undefined; + } +} + +function setEquals(set_a: number[], set_b: Set): boolean { + if (set_a.length != set_b.size) { + return false; + } + for (let i = 0; i < set_a.length; i++) { + if (!set_b.has(set_a[i])) { + return false; + } + } + return true; +} + +function getPetFrameReferenceTime(imageId) { + const moduleInfo = metaData.get('petImageModule', imageId); + return moduleInfo ? moduleInfo['frameReferenceTime'] : 0; +} + +/** + * Split the imageIds array by 4D tags into groups. Each group must have the + * same number of imageIds or the same imageIds array passed in is returned. + * + * For multiframe images (NumberOfFrames > 1), this function checks for + * TimeSlotVector (0054,0070) which is common in NM (Nuclear Medicine) gated + * SPECT/PET images. The TimeSlotVector indicates which time slot each frame + * belongs to, and SliceVector (0054,0080) indicates the slice position. + * + * @param imageIds - array of imageIds + * @returns imageIds grouped by 4D tags + */ +function splitImageIdsBy4DTags(imageIds: string[]): { + imageIdGroups: string[][]; + splittingTag: string | null; +} { + const multiframeResult = handleMultiframe4D(imageIds); + if (multiframeResult) { + return multiframeResult; + } + + const cardiacResult = handleCardiac4D(imageIds); + if (cardiacResult) { + return cardiacResult; + } + + const positionGroups = getIPPGroups(imageIds); + if (!positionGroups) { + return { imageIdGroups: [imageIds], splittingTag: null }; + } + + const tags = [ + 'TemporalPositionIdentifier', + 'DiffusionBValue', + 'TriggerTime', + 'EchoTime', + 'EchoNumber', + 'PhilipsPrivateBValue', + 'SiemensPrivateBValue', + 'GEPrivateBValue', + 'PetFrameReferenceTime', + ]; + + const fncList2 = [ + (imageId) => getTagValue(imageId, tags[0]), + (imageId) => getTagValue(imageId, tags[1]), + (imageId) => getTagValue(imageId, tags[2]), + (imageId) => getTagValue(imageId, tags[3]), + (imageId) => getTagValue(imageId, tags[4]), + getPhilipsPrivateBValue, + getSiemensPrivateBValue, + getGEPrivateBValue, + getPetFrameReferenceTime, + ]; + + for (let i = 0; i < fncList2.length; i++) { + const frame_groups = test4DTag(positionGroups, fncList2[i]); + if (frame_groups) { + const sortedKeys = Object.keys(frame_groups) + .map(Number.parseFloat) + .sort((a, b) => a - b); + + const imageIdGroups = sortedKeys.map((key) => + frame_groups[key].map((item) => item.imageId) + ); + return { imageIdGroups, splittingTag: tags[i] }; + } + } + + // Return the same imagesIds for non-4D volumes and indicate no tag was used + return { imageIdGroups: [imageIds], splittingTag: null }; +} + +export default splitImageIdsBy4DTags; +export { handleMultiframe4D, generateFrameImageId }; diff --git a/packages/metadata/src/utilities/toNumber.ts b/packages/metadata/src/utilities/toNumber.ts new file mode 100644 index 0000000000..3d736f50db --- /dev/null +++ b/packages/metadata/src/utilities/toNumber.ts @@ -0,0 +1,2 @@ +export { toNumber, toFiniteNumber } from '@cornerstonejs/utils'; +export { toNumber as default } from '@cornerstonejs/utils'; diff --git a/packages/metadata/src/version.ts b/packages/metadata/src/version.ts new file mode 100644 index 0000000000..f368f2fbda --- /dev/null +++ b/packages/metadata/src/version.ts @@ -0,0 +1,5 @@ +/** + * Auto-generated from version.json + * Do not modify this file directly + */ +export const version = '5.0.0-beta.2'; diff --git a/packages/metadata/test/DicomStreamListener.jest.js b/packages/metadata/test/DicomStreamListener.jest.js new file mode 100644 index 0000000000..c4603d2d14 --- /dev/null +++ b/packages/metadata/test/DicomStreamListener.jest.js @@ -0,0 +1,71 @@ +import { NaturalTagListener } from '../src/utilities/dicomStream/NaturalTagListener'; +import { Tags } from '../src/utilities/Tags'; + +import { describe, beforeEach, it } from '@jest/globals'; + +const abList = ['a', 'b']; +describe('NaturalTagListener', () => { + let listener; + + describe('as DicomMetadataListener filter', () => { + beforeEach(() => { + listener = NaturalTagListener.createMetadataListener(); + }); + + it('accepts simple values', () => { + listener.startObject(); + + listener.addTag('abList1'); + abList.forEach((item) => listener.value(item)); + listener.pop(); + + listener.addTag('abList2'); + abList.forEach((item) => listener.value(item)); + listener.pop(); + + listener.addTag(Tags.SOPClassUID.tag, { vr: 'UI' }); + listener.value('1.2.3'); + listener.pop(); + + const instance = listener.pop(); + + expect(instance.abList1).toEqual(abList); + expect(instance.abList2).toEqual(abList); + expect(instance.SOPClassUID).toEqual('1.2.3'); + }); + + it('accepts sequences', () => { + const root = {}; + listener.startObject(root); + + listener.addTag('sequence', { vr: 'SQ' }); + listener.startObject(); + + listener.addTag('abList'); + abList.forEach((item) => listener.value(item)); + listener.pop(); // pop abList -> at item1 + listener.pop(); // pop item1 -> at sequence + + listener.startObject(); + listener.addTag('abList'); + abList.forEach((item) => listener.value(item)); + listener.pop(); + + // Ends the second item (startObject) -> back to sequence tag + listener.pop(); + + // Ends the sequence tag -> back to root + listener.pop(); + + // Gets the root (same object we passed to startObject) + const instance = listener.pop(); + + expect(instance).toBe(root); + expect(root.sequence).toBeDefined(); + expect(Array.isArray(root.sequence)).toBe(true); + expect(root.sequence.length).toBe(2); + expect(root.sequence[0].abList).toEqual(abList); + expect(root.sequence[1].abList).toEqual(abList); + }); + }); +}); diff --git a/packages/metadata/test/MetaDataIterator.jest.js b/packages/metadata/test/MetaDataIterator.jest.js new file mode 100644 index 0000000000..0d795c1afb --- /dev/null +++ b/packages/metadata/test/MetaDataIterator.jest.js @@ -0,0 +1,19 @@ +import { NaturalTagListener } from '../src/utilities/dicomStream/NaturalTagListener'; +import { tags } from '../../dicomImageLoader/testImages/CTImage.dcm_BigEndianExplicitTransferSyntax_1.2.840.10008.1.2.2.wado-rs-tags'; + +import { describe, it } from '@jest/globals'; +import { MetaDataIterator } from '../src/utilities/dicomStream'; + +describe('MetaDataIterator', () => { + it('creates natural CTImage_BigEndianExplicit from metadata', () => { + const data = new MetaDataIterator(tags); + const listener = NaturalTagListener.createMetadataListener(); + listener.startObject(); + data.syncIterator(listener); + const instance = listener.pop(); + expect(instance).toBeTruthy(); + expect(instance.Rows).toBe(512); + expect(instance.StudyTime).toBe('083501'); + expect(instance.PixelSpacing).toEqual([0.675781, 0.675781]); + }); +}); diff --git a/packages/metadata/test/MetadataParsing.jest.js b/packages/metadata/test/MetadataParsing.jest.js new file mode 100644 index 0000000000..072aa4443e --- /dev/null +++ b/packages/metadata/test/MetadataParsing.jest.js @@ -0,0 +1,270 @@ +import { NaturalTagListener } from '../src/utilities/dicomStream/NaturalTagListener'; +import { MetaDataIterator } from '../src/utilities/dicomStream/MetaDataIterator'; +import { tags as ctBigEndianTags } from '../../dicomImageLoader/testImages/CTImage.dcm_BigEndianExplicitTransferSyntax_1.2.840.10008.1.2.2.wado-rs-tags'; +import { tags as noPixelSpacingTags } from '../../dicomImageLoader/testImages/no-pixel-spacing.wado-rs-tags'; +import { tags as usMultiframeTags } from '../../dicomImageLoader/testImages/us-multiframe-ybr-full-422.wado-rs-tags'; + +import { describe, it, expect } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; + +import dcmjs from 'dcmjs'; + +const { AsyncDicomReader } = dcmjs.async; + +const testImagesDir = path.resolve( + __dirname, + '../../dicomImageLoader/testImages' +); + +/** + * Parse DICOMweb JSON metadata (wadors) into a natural instance. + */ +function parseWadoRs(tags) { + const data = new MetaDataIterator(tags); + const listener = NaturalTagListener.createMetadataListener(); + listener.startObject(); + data.syncIterator(listener); + return listener.pop(); +} + +/** + * Parse a binary DICOM file using AsyncDicomReader and + * NaturalTagListener.createMetadataListener() so that pixel data (and other bulk data) is + * naturalized correctly. + */ +async function parseBinaryDicom(filePath) { + const buffer = fs.readFileSync(filePath); + const reader = new AsyncDicomReader(); + const listener = NaturalTagListener.createMetadataListener(); + + reader.stream.addBuffer(buffer); + reader.stream.setComplete(); + + await reader.readFile({ listener }); + return reader.dict; +} + +// --------------------------------------------------------------------------- +// WADO-RS (DICOMweb JSON metadata) parsing via MetaDataIterator +// --------------------------------------------------------------------------- +describe('MetaDataIterator - WADO-RS parsing', () => { + describe('CT BigEndian', () => { + let instance; + beforeAll(() => { + instance = parseWadoRs(ctBigEndianTags); + }); + + it('produces a truthy instance', () => { + expect(instance).toBeTruthy(); + }); + + it('parses single-valued numeric tags', () => { + expect(instance.Rows).toBe(512); + expect(instance.Columns).toBe(512); + expect(instance.BitsAllocated).toBe(16); + expect(instance.BitsStored).toBe(16); + expect(instance.HighBit).toBe(15); + expect(instance.SamplesPerPixel).toBe(1); + }); + + it('parses single-valued string tags', () => { + expect(instance.Modality).toBe('CT'); + expect(instance.StudyTime).toBe('083501'); + expect(instance.PhotometricInterpretation).toBe('MONOCHROME2'); + }); + + it('parses multi-valued numeric tags as arrays', () => { + expect(instance.PixelSpacing).toEqual([0.675781, 0.675781]); + expect(instance.ImageOrientationPatient).toEqual([1, 0, 0, 0, 1, 0]); + expect(instance.ImagePositionPatient).toEqual([ + -161.399994, -148.800003, 4.7, + ]); + }); + + it('parses SliceThickness as a scalar', () => { + expect(instance.SliceThickness).toBe(5); + }); + + it('parses UIDs', () => { + expect(instance.SOPClassUID).toBe('1.2.840.10008.5.1.4.1.1.2'); + expect(instance.StudyInstanceUID).toBe( + '1.2.840.113619.2.30.1.1762295590.1623.978668949.886' + ); + }); + }); + + describe('No Pixel Spacing (US)', () => { + let instance; + beforeAll(() => { + instance = parseWadoRs(noPixelSpacingTags); + }); + + it('produces a truthy instance', () => { + expect(instance).toBeTruthy(); + }); + + it('parses basic dimensions', () => { + expect(instance.Rows).toBe(600); + expect(instance.Columns).toBe(800); + }); + + it('has no PixelSpacing', () => { + expect(instance.PixelSpacing).toBeUndefined(); + }); + }); + + describe('US Multiframe', () => { + let instance; + beforeAll(() => { + instance = parseWadoRs(usMultiframeTags); + }); + + it('produces a truthy instance', () => { + expect(instance).toBeTruthy(); + }); + + it('parses NumberOfFrames', () => { + expect(instance.NumberOfFrames).toBe(78); + }); + + it('parses basic dimensions', () => { + expect(instance.Rows).toBe(600); + expect(instance.Columns).toBe(800); + }); + + it('parses sequences', () => { + expect(instance.SequenceOfUltrasoundRegions).toBeDefined(); + expect(instance.SequenceOfUltrasoundRegions.length).toBeGreaterThan(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Binary DICOM parsing via AsyncDicomReader + NaturalTagListener +// --------------------------------------------------------------------------- +describe('AsyncDicomReader - Binary DICOM parsing', () => { + const ctBigEndianDcm = path.join( + testImagesDir, + 'CTImage.dcm_BigEndianExplicitTransferSyntax_1.2.840.10008.1.2.2.dcm' + ); + + describe('CT BigEndian', () => { + let instance; + beforeAll(async () => { + instance = await parseBinaryDicom(ctBigEndianDcm); + }); + + it('produces a truthy instance', () => { + expect(instance).toBeTruthy(); + }); + + it('parses single-valued numeric tags', () => { + expect(instance.Rows).toBe(512); + expect(instance.Columns).toBe(512); + expect(instance.BitsAllocated).toBe(16); + expect(instance.BitsStored).toBe(16); + }); + + it('parses single-valued string tags', () => { + expect(instance.Modality).toBe('CT'); + expect(instance.StudyTime).toBe('083501'); + }); + + it('parses multi-valued numeric tags', () => { + expect(instance.PixelSpacing).toEqual([0.675781, 0.675781]); + expect(instance.ImageOrientationPatient).toEqual([1, 0, 0, 0, 1, 0]); + }); + + it('parses UIDs', () => { + expect(instance.SOPClassUID).toBe('1.2.840.10008.5.1.4.1.1.2'); + }); + }); + + describe('consistency with WADO-RS', () => { + let wadorsInstance; + let binaryInstance; + + beforeAll(async () => { + wadorsInstance = parseWadoRs(ctBigEndianTags); + binaryInstance = await parseBinaryDicom(ctBigEndianDcm); + }); + + it('produces matching Rows/Columns', () => { + expect(binaryInstance.Rows).toBe(wadorsInstance.Rows); + expect(binaryInstance.Columns).toBe(wadorsInstance.Columns); + }); + + it('produces matching Modality', () => { + expect(binaryInstance.Modality).toBe(wadorsInstance.Modality); + }); + + it('produces matching BitsAllocated', () => { + expect(binaryInstance.BitsAllocated).toBe(wadorsInstance.BitsAllocated); + }); + + it('produces matching SliceThickness', () => { + expect(binaryInstance.SliceThickness).toBe(wadorsInstance.SliceThickness); + }); + + it('produces matching ImageOrientationPatient', () => { + expect(binaryInstance.ImageOrientationPatient).toEqual( + wadorsInstance.ImageOrientationPatient + ); + }); + + it('produces matching PixelSpacing', () => { + expect(binaryInstance.PixelSpacing).toEqual(wadorsInstance.PixelSpacing); + }); + + it('produces matching StudyTime', () => { + expect(binaryInstance.StudyTime).toBe(wadorsInstance.StudyTime); + }); + + it('produces matching SOPClassUID', () => { + expect(binaryInstance.SOPClassUID).toBe(wadorsInstance.SOPClassUID); + }); + }); + + describe('Little Endian Explicit', () => { + const leDcm = path.join( + testImagesDir, + 'CTImage.dcm_LittleEndianExplicitTransferSyntax_1.2.840.10008.1.2.1.dcm' + ); + + it('parses basic metadata', async () => { + const instance = await parseBinaryDicom(leDcm); + expect(instance).toBeTruthy(); + expect(instance.Rows).toBe(512); + expect(instance.Columns).toBe(512); + expect(instance.Modality).toBe('CT'); + }); + }); + + describe('Little Endian Implicit', () => { + const leiDcm = path.join( + testImagesDir, + 'CTImage.dcm_LittleEndianImplicitTransferSyntax_1.2.840.10008.1.2.dcm' + ); + + it('parses basic metadata', async () => { + const instance = await parseBinaryDicom(leiDcm); + expect(instance).toBeTruthy(); + expect(instance.Rows).toBe(512); + expect(instance.Columns).toBe(512); + expect(instance.Modality).toBe('CT'); + }); + }); + + describe('US Multiframe', () => { + const usDcm = path.join(testImagesDir, 'us-multiframe-ybr-full-422.dcm'); + + it('parses multiframe metadata', async () => { + const instance = await parseBinaryDicom(usDcm); + expect(instance).toBeTruthy(); + expect(instance.NumberOfFrames).toBe(78); + expect(instance.Rows).toBe(600); + expect(instance.Columns).toBe(800); + }); + }); +}); diff --git a/packages/metadata/test/addDicomPart10Instance.jest.js b/packages/metadata/test/addDicomPart10Instance.jest.js new file mode 100644 index 0000000000..a5dca4639b --- /dev/null +++ b/packages/metadata/test/addDicomPart10Instance.jest.js @@ -0,0 +1,45 @@ +import { describe, it, expect, jest, afterEach } from '@jest/globals'; +import * as metaData from '../src/metaData'; +import { MetadataModules } from '../src/enums'; +import { + addDicomWebInstance, + addDicomPart10Instance, +} from '../src/utilities/metadataProvider/addDicomPart10Instance'; + +describe('naturalized add entrypoints', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('maps dicomweb payload to dicomwebJson via addTyped', () => { + const addTypedSpy = jest + .spyOn(metaData, 'addTyped') + .mockReturnValue({ value: 'ok' }); + const payload = { '00080060': { vr: 'CS', Value: ['CT'] } }; + + const result = addDicomWebInstance('image-6', payload); + + expect(addTypedSpy).toHaveBeenCalledWith( + MetadataModules.NATURALIZED, + 'image-6', + { dicomwebJson: payload } + ); + expect(result).toEqual({ value: 'ok' }); + }); + + it('maps part10 payload to part10Buffer via addTyped', async () => { + const addTypedSpy = jest + .spyOn(metaData, 'addTyped') + .mockResolvedValue({ value: 'ok' }); + const payload = new Uint8Array([1, 2, 3]); + + const result = await addDicomPart10Instance('image-7', payload); + + expect(addTypedSpy).toHaveBeenCalledWith( + MetadataModules.NATURALIZED, + 'image-7', + { part10Buffer: payload } + ); + expect(result).toEqual({ value: 'ok' }); + }); +}); diff --git a/packages/metadata/test/imageIdsProviders.jest.js b/packages/metadata/test/imageIdsProviders.jest.js new file mode 100644 index 0000000000..7ab07993f8 --- /dev/null +++ b/packages/metadata/test/imageIdsProviders.jest.js @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { getTyped, removeAllProviders } from '../src/metaData'; +import { registerDefaultProviders } from '../src/registerDefaultProviders'; +import { + clearCacheData, + setCacheData, +} from '../src/utilities/metadataProvider/cacheData'; +import { MetadataModules } from '../src/enums'; + +describe('imageIdsProviders', () => { + beforeEach(() => { + removeAllProviders(); + clearCacheData(); + registerDefaultProviders(); + }); + + it('converts frame image ids to base image id for both path and query forms', () => { + const baseImageId = + 'wadors:https://example.com/studies/1/series/2/instances/3'; + const pathFrameImageId = `${baseImageId}/frames/2`; + const queryFrameImageId = `${baseImageId}?frame=2`; + const queryFrameWithExistingParams = `${baseImageId}?foo=bar&frame=2`; + + expect(getTyped(MetadataModules.BASE_IMAGE_ID, pathFrameImageId)).toBe( + baseImageId + ); + expect(getTyped(MetadataModules.BASE_IMAGE_ID, queryFrameImageId)).toBe( + baseImageId + ); + expect( + getTyped(MetadataModules.BASE_IMAGE_ID, queryFrameWithExistingParams) + ).toBe(`${baseImageId}?foo=bar`); + }); + + it('generates path frame image ids from dicomweb base image id', () => { + const baseImageId = + 'wadors:https://example.com/studies/1/series/2/instances/3'; + setCacheData('naturalized', baseImageId, { + PhotometricInterpretation: 'MONOCHROME2', + NumberOfFrames: 2, + }); + + const frameImageIds = getTyped( + MetadataModules.FRAME_IMAGE_IDS, + baseImageId + ); + + expect(frameImageIds).toEqual( + new Set([`${baseImageId}/frames/1`, `${baseImageId}/frames/2`]) + ); + }); + + it('generates query-parameter frame image ids for non-dicomweb base image id', () => { + const baseImageId = 'wadouri:https://example.com/image.dcm'; + setCacheData('naturalized', baseImageId, { + PhotometricInterpretation: 'MONOCHROME2', + NumberOfFrames: 2, + }); + + expect(getTyped(MetadataModules.FRAME_IMAGE_IDS, baseImageId)).toEqual( + new Set([`${baseImageId}?frame=1`, `${baseImageId}?frame=2`]) + ); + }); +}); diff --git a/packages/metadata/test/metaDataAddGetSplit.jest.js b/packages/metadata/test/metaDataAddGetSplit.jest.js new file mode 100644 index 0000000000..a0dee3d521 --- /dev/null +++ b/packages/metadata/test/metaDataAddGetSplit.jest.js @@ -0,0 +1,98 @@ +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; +import * as metaData from '../src/metaData'; +import { + addCacheForType, + addWritableCacheForType, +} from '../src/utilities/metadataProvider/cacheData'; + +const TEST_TYPE = 'testAddGetType'; + +describe('metaData add/get split', () => { + beforeEach(() => { + metaData.removeAllProviders(); + metaData.addProvider(metaData.metadataModuleProvider, -1000); + }); + + afterEach(() => { + metaData.removeAllProviders(); + jest.restoreAllMocks(); + }); + + it('keeps get-path independent from add-path providers', () => { + const addProvider = jest.fn(() => ({ value: 'from-add' })); + metaData.addAddProvider(TEST_TYPE, addProvider, { priority: 100 }); + + expect(metaData.get(TEST_TYPE, 'image-1')).toBeUndefined(); + expect(metaData.addMetaData(TEST_TYPE, 'image-1')).toEqual({ + value: 'from-add', + }); + expect(addProvider).toHaveBeenCalledTimes(1); + }); + + it('supports addTyped for sync and async providers', async () => { + const syncType = 'syncTypedType'; + const asyncType = 'asyncTypedType'; + + metaData.addAddProvider(syncType, () => ({ mode: 'sync' }), { + priority: 100, + }); + metaData.addAddProvider(asyncType, async () => ({ mode: 'async' }), { + priority: 100, + }); + + expect(metaData.addTyped(syncType, 'image-2')).toEqual({ mode: 'sync' }); + await expect(metaData.addTyped(asyncType, 'image-3')).resolves.toEqual({ + mode: 'async', + }); + }); + + it('returns cached value and warns on duplicate writable add', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const downstream = jest.fn(async () => ({ value: 7 })); + + addCacheForType(TEST_TYPE); + addWritableCacheForType(TEST_TYPE); + metaData.addAddProvider(TEST_TYPE, downstream, { priority: 10 }); + + await expect(metaData.addMetaData(TEST_TYPE, 'image-4')).resolves.toEqual({ + value: 7, + }); + expect(metaData.addMetaData(TEST_TYPE, 'image-4')).toEqual({ value: 7 }); + + expect(downstream).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('clearQuery invalidates in-flight add promise reuse', async () => { + let resolvePromise; + const inflightPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + const downstream = jest.fn(() => inflightPromise); + + addCacheForType(TEST_TYPE); + addWritableCacheForType(TEST_TYPE); + metaData.addAddProvider(TEST_TYPE, downstream, { priority: 10 }); + + const firstCall = metaData.addMetaData(TEST_TYPE, 'image-5'); + const secondCall = metaData.addMetaData(TEST_TYPE, 'image-5'); + expect(firstCall).toBe(secondCall); + expect(downstream).toHaveBeenCalledTimes(1); + + metaData.clearQuery(TEST_TYPE, 'image-5'); + const thirdCall = metaData.addMetaData(TEST_TYPE, 'image-5'); + expect(downstream).toHaveBeenCalledTimes(2); + expect(thirdCall).not.toBe(firstCall); + + resolvePromise({ value: 99 }); + await expect(firstCall).resolves.toEqual({ value: 99 }); + await expect(thirdCall).resolves.toEqual({ value: 99 }); + }); +}); diff --git a/packages/metadata/test/scalingModule.jest.js b/packages/metadata/test/scalingModule.jest.js new file mode 100644 index 0000000000..96175d613a --- /dev/null +++ b/packages/metadata/test/scalingModule.jest.js @@ -0,0 +1,109 @@ +/** + * Unit tests for the scalingModule typed provider (PT and RTDOSE). + * The provider expects instance or natural data in the chain's data field; the + * default registration uses naturalLookup (see registerDataLookup), so we + * prime the NATURALIZED cache and the lookup passes that data into the provider. + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { getMetaData, removeAllProviders } from '../src/metaData'; +import { registerDefaultProviders } from '../src/registerDefaultProviders'; +import { + setCacheData, + clearCacheData, +} from '../src/utilities/metadataProvider/cacheData'; +import { MetadataModules } from '../src/enums'; + +const RT_DOSE_BASE_IMAGE_ID = + 'wadors:https://example.com/studies/1/series/2/instances/3'; +const RT_DOSE_IMAGE_ID = `${RT_DOSE_BASE_IMAGE_ID}/frames/1`; +const PT_BASE_IMAGE_ID = + 'wadors:https://example.com/studies/1/series/4/instances/5'; +const PT_IMAGE_ID = `${PT_BASE_IMAGE_ID}/frames/1`; + +describe('scalingModule typed provider', () => { + beforeEach(() => { + removeAllProviders(); + clearCacheData(); + registerDefaultProviders(); + }); + + describe('RTDOSE', () => { + it('returns scalingModule with DoseGridScaling, DoseSummation, DoseType, DoseUnit from NATURALIZED', () => { + // NATURALIZED is keyed by base imageId (no frame number) + setCacheData(MetadataModules.NATURALIZED, RT_DOSE_BASE_IMAGE_ID, { + Modality: 'RTDOSE', + DoseGridScaling: 2.5, + DoseSummation: 'PLAN', + DoseType: 'PHYSICAL', + DoseUnit: 'GY', + }); + + // scaling is requested for a frame-specific imageId; NATURALIZED is resolved + // via baseImageIdQueryFilter + naturalLookup + const result = getMetaData(MetadataModules.SCALING, RT_DOSE_IMAGE_ID); + + expect(result).toBeDefined(); + expect(result).toMatchObject({ + DoseGridScaling: 2.5, + DoseSummation: 'PLAN', + DoseType: 'PHYSICAL', + DoseUnit: 'GY', + }); + }); + + it('returns undefined when NATURALIZED has no dose fields', () => { + setCacheData(MetadataModules.NATURALIZED, RT_DOSE_BASE_IMAGE_ID, { + Modality: 'RTDOSE', + }); + + const result = getMetaData(MetadataModules.SCALING, RT_DOSE_IMAGE_ID); + + expect(result).toBeUndefined(); + }); + }); + + describe('PT', () => { + it('returns scalingModule with suvbw from NATURALIZED PT instance (data passed via naturalLookup)', () => { + // NATURALIZED is keyed by base imageId (no frame number) + setCacheData(MetadataModules.NATURALIZED, PT_BASE_IMAGE_ID, { + Modality: 'PT', + SeriesDate: '20200101', + SeriesTime: '120000', + AcquisitionDate: '20200101', + AcquisitionTime: '120000', + PatientWeight: 70, + // CorrectedImage must include ATTN and DECY for calculate-suv + CorrectedImage: 'ATTN\\DECY\\NORM', + Units: 'BQML', + DecayCorrection: 'ADMIN', + RadiopharmaceuticalInfo: [ + { + RadionuclideTotalDose: 400000000, + RadionuclideHalfLife: 6588, + RadiopharmaceuticalStartDateTime: '20200101100000.000000', + }, + ], + }); + + // scaling is requested for a frame-specific imageId; NATURALIZED is resolved + // via baseImageIdQueryFilter + naturalLookup + const result = getMetaData(MetadataModules.SCALING, PT_IMAGE_ID); + + expect(result).toBeDefined(); + expect(typeof result.suvbw).toBe('number'); + expect(result.suvbw).toBeGreaterThan(0); + expect(result).toHaveProperty('suvbw'); + }); + + it('returns undefined when modality is not PT or RTDOSE', () => { + setCacheData(MetadataModules.NATURALIZED, 'ct-image-id', { + Modality: 'CT', + }); + + const result = getMetaData(MetadataModules.SCALING, 'ct-image-id'); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/metadata/tsconfig.json b/packages/metadata/tsconfig.json new file mode 100644 index 0000000000..bc915f1e65 --- /dev/null +++ b/packages/metadata/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/esm", + "rootDir": "./src" + }, + "include": ["./src/**/*"] +} diff --git a/packages/nifti-volume-loader/CHANGELOG.md b/packages/nifti-volume-loader/CHANGELOG.md index 9ecaa18fd7..64b77405bf 100644 --- a/packages/nifti-volume-loader/CHANGELOG.md +++ b/packages/nifti-volume-loader/CHANGELOG.md @@ -1,4 +1,4 @@ -# Change Log +# Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. @@ -11,25 +11,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/nifti-volume-loader -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) **Note:** Version bump only for package @cornerstonejs/nifti-volume-loader -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/nifti-volume-loader - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/nifti-volume-loader - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) - -**Note:** Version bump only for package @cornerstonejs/nifti-volume-loader - -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/nifti-volume-loader +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) diff --git a/packages/nifti-volume-loader/package.json b/packages/nifti-volume-loader/package.json index a2ee6c8074..b0d2719ae3 100644 --- a/packages/nifti-volume-loader/package.json +++ b/packages/nifti-volume-loader/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/nifti-volume-loader", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "Nifti Image Loader for Cornerstone3D", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", @@ -49,7 +49,7 @@ }, "scripts": { "prebuild": "node ../../scripts/generate-version.js ./", - "build:esm": "tsc --project ./tsconfig.json", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", "build:esm:watch": "tsc --project ./tsconfig.json --watch", "build:all": "yarn run build:esm", "dev": "tsc --project ./tsconfig.json --watch", @@ -64,11 +64,11 @@ "dependencies": { "nifti-reader-js": "0.6.9" }, - "peerDependencies": { - "@cornerstonejs/core": "4.22.9" - }, "devDependencies": { - "@cornerstonejs/core": "4.22.9" + "@cornerstonejs/core": "5.0.0-beta.2" + }, + "peerDependencies": { + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0" }, "contributors": [ { diff --git a/packages/nifti-volume-loader/src/version.ts b/packages/nifti-volume-loader/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/nifti-volume-loader/src/version.ts +++ b/packages/nifti-volume-loader/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/polymorphic-segmentation/CHANGELOG.md b/packages/polymorphic-segmentation/CHANGELOG.md index 41a4ccfa24..1998696bf9 100644 --- a/packages/polymorphic-segmentation/CHANGELOG.md +++ b/packages/polymorphic-segmentation/CHANGELOG.md @@ -1,4 +1,4 @@ -# Change Log +# Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. @@ -11,25 +11,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/polymorphic-segmentation -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) - -**Note:** Version bump only for package @cornerstonejs/polymorphic-segmentation - -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/polymorphic-segmentation - -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/polymorphic-segmentation - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) **Note:** Version bump only for package @cornerstonejs/polymorphic-segmentation -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/polymorphic-segmentation +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) diff --git a/packages/polymorphic-segmentation/package.json b/packages/polymorphic-segmentation/package.json index 2bf2e3a55c..d3cebf8cc4 100644 --- a/packages/polymorphic-segmentation/package.json +++ b/packages/polymorphic-segmentation/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/polymorphic-segmentation", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "Polymorphic Segmentation utility for Cornerstone3D", "files": [ "dist" @@ -29,7 +29,7 @@ "test": "jest --testTimeout 60000", "clean": "rimraf dist", "build": "yarn run build:esm", - "build:esm": "tsc --project ./tsconfig.json", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", "build:esm:watch": "tsc --project ./tsconfig.json --watch", "dev": "tsc --project ./tsconfig.json --watch", "build:all": "yarn run build:esm", @@ -52,13 +52,13 @@ "dependencies": { "@icr/polyseg-wasm": "0.4.0" }, + "devDependencies": { + "@cornerstonejs/core": "5.0.0-beta.2", + "@cornerstonejs/tools": "5.0.0-beta.2" + }, "peerDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", + "@cornerstonejs/tools": ">=5.0.0-beta.1 <6.0.0-0", "@kitware/vtk.js": "34.15.1" - }, - "devDependencies": { - "@cornerstonejs/core": "4.22.9", - "@cornerstonejs/tools": "4.22.9" } } diff --git a/packages/polymorphic-segmentation/src/version.ts b/packages/polymorphic-segmentation/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/polymorphic-segmentation/src/version.ts +++ b/packages/polymorphic-segmentation/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/tools/CHANGELOG.md b/packages/tools/CHANGELOG.md index 7d06fadb60..50a005389c 100644 --- a/packages/tools/CHANGELOG.md +++ b/packages/tools/CHANGELOG.md @@ -13,29 +13,14 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @cornerstonejs/tools -## [4.22.7](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.6...v4.22.7) (2026-05-15) +# [5.0.0-beta.2](https://github.com/cornerstonejs/cornerstone3D/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-05-15) ### Bug Fixes - **annotations:** incorrect area calculation for livewire, spline, rectangle, and planar freehand ROI tools ([#2734](https://github.com/cornerstonejs/cornerstone3D/issues/2734)) ([c8a96e9](https://github.com/cornerstonejs/cornerstone3D/commit/c8a96e9a025a51e70f0c20f217827fdd036aa4c5)) - -## [4.22.6](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.5...v4.22.6) (2026-05-12) - -### Bug Fixes - - **segmentation:** fully remove segmentations from viewport on delete after reload ([#2729](https://github.com/cornerstonejs/cornerstone3D/issues/2729)) ([0e186bd](https://github.com/cornerstonejs/cornerstone3D/commit/0e186bd7a804d706df1bb9ee263378e09726b087)) -## [4.22.5](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.4...v4.22.5) (2026-05-12) - -**Note:** Version bump only for package @cornerstonejs/tools - -## [4.22.4](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.3...v4.22.4) (2026-05-06) - -**Note:** Version bump only for package @cornerstonejs/tools - -## [4.22.3](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.2...v4.22.3) (2026-04-23) - -**Note:** Version bump only for package @cornerstonejs/tools +# [5.0.0-beta.1](https://github.com/cornerstonejs/cornerstone3D/compare/v4.18.3...v5.0.0-beta.1) (2026-02-27) ## [4.22.2](https://github.com/cornerstonejs/cornerstone3D/compare/v4.22.1...v4.22.2) (2026-04-21) diff --git a/packages/tools/examples/calibrationTools/index.ts b/packages/tools/examples/calibrationTools/index.ts index 56022c2720..11df3d5f84 100644 --- a/packages/tools/examples/calibrationTools/index.ts +++ b/packages/tools/examples/calibrationTools/index.ts @@ -9,7 +9,12 @@ import { createImageIdsAndCacheMetaData, setTitleAndDescription, addDropdownToToolbar, - addButtonToToolbar, + viewportId, + renderingEngineId, + addUploadToToolbar, + toolGroupId, + imageIds, + setImageIds, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; import dicomImageLoader from '@cornerstonejs/dicom-image-loader'; @@ -34,14 +39,13 @@ const { ToolGroupManager, ArrowAnnotateTool, PlanarFreehandROITool, + StackScrollTool, Enums: csToolsEnums, utilities, } = cornerstoneTools; const { ViewportType, Events } = Enums; const { MouseBindings } = csToolsEnums; -const renderingEngineId = 'myRenderingEngine'; -const viewportId = 'CT_STACK'; // ======== Set up page ======== // setTitleAndDescription( @@ -99,7 +103,7 @@ element.addEventListener(Events.CAMERA_MODIFIED, (_) => { }); // ============================= // -const toolGroupId = 'STACK_TOOL_GROUP_ID'; +addUploadToToolbar(); const toolsNames = [ LengthTool.toolName, @@ -243,6 +247,7 @@ async function run() { cornerstoneTools.addTool(CobbAngleTool); cornerstoneTools.addTool(ArrowAnnotateTool); cornerstoneTools.addTool(PlanarFreehandROITool); + cornerstoneTools.addTool(StackScrollTool); // Define a tool group, which defines how mouse events map to tool commands for // Any viewport using the group @@ -260,6 +265,7 @@ async function run() { toolGroup.addTool(CobbAngleTool.toolName); toolGroup.addTool(ArrowAnnotateTool.toolName); toolGroup.addTool(PlanarFreehandROITool.toolName); + toolGroup.addTool(StackScrollTool.toolName); // Set the initial state of the tools, here we set one tool active on left click. // This means left click will draw that tool. @@ -270,6 +276,17 @@ async function run() { }, ], }); + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Wheel, + }, + { + mouseButton: MouseBindings.Auxiliary, + }, + ], + }); + // We set all the other tools passive here, this means that any state is rendered, and editable // But aren't actively being drawn (see the toolModes example for information) toolGroup.setToolPassive(HeightTool.toolName); @@ -283,28 +300,36 @@ async function run() { toolGroup.setToolPassive(ArrowAnnotateTool.toolName); // Get Cornerstone imageIds and fetch metadata into RAM - const imageIds = await createImageIdsAndCacheMetaData({ - StudyInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', - SeriesInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', - wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', - }); - + setImageIds( + await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + }) + ); // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); calibrationFunctions.userCalibration = function calibrationSelected() { - utilities.calibrateImageSpacing( - imageIds[0], - renderingEngine, - this.calibration + imageIds.forEach((imageId) => + utilities.calibrateImageSpacing( + imageId, + renderingEngine, + this.calibration + ) ); }; calibrationFunctions.applyMetadata = function applyMetadata() { - const instance = wadors.metaDataManager.get(imageIds[0]); - Object.assign(instance, this.metadata); - utilities.calibrateImageSpacing(imageIds[0], renderingEngine, null); + imageIds.forEach((imageId) => { + const instance = wadors.metaDataManager.get(imageId); + if (!instance) { + console.warn('Can only apply image id update to metadata managed data'); + } + Object.assign(instance, this.metadata); + utilities.calibrateImageSpacing(imageId, renderingEngine, null); + }); }; // Create a stack viewport diff --git a/packages/tools/examples/dynamicCINETool/index.ts b/packages/tools/examples/dynamicCINETool/index.ts index e8df1b11da..88fa0f0a2e 100644 --- a/packages/tools/examples/dynamicCINETool/index.ts +++ b/packages/tools/examples/dynamicCINETool/index.ts @@ -1,4 +1,3 @@ -import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; import type { Types } from '@cornerstonejs/core'; import { RenderingEngine, @@ -10,6 +9,7 @@ import { import { initDemo, createImageIdsAndCacheMetaData, + get4DVolumeImageIds, setTitleAndDescription, setPetTransferFunctionForVolumeActor, } from '../../../../utils/demo/helpers'; @@ -229,33 +229,20 @@ function initViewports(volume, elements) { } async function createVolume(numDimensionGroups: number): Promise { - const { metaDataManager } = cornerstoneDICOMImageLoader.wadors; - if (numDimensionGroups < 1 || numDimensionGroups > MAX_NUM_DIMENSION_GROUPS) { throw new Error('Number of dimension groups is out of range'); } - let imageIds = await createImageIdsAndCacheMetaData({ + const seriesImageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '2.25.232704420736447710317909004159492840763', SeriesInstanceUID: '2.25.16992883200578135914239363565496792012', wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - const NUM_IMAGES_PER_DIMENSION_GROUP = 235; - const TOTAL_NUM_IMAGES = - MAX_NUM_DIMENSION_GROUPS * NUM_IMAGES_PER_DIMENSION_GROUP; - const numImagesToLoad = numDimensionGroups * NUM_IMAGES_PER_DIMENSION_GROUP; - - // Load the last N dimension groups because they have a better image quality - // and first ones are white or contains only a few black pixels - const firstInstanceNumber = TOTAL_NUM_IMAGES - numImagesToLoad + 1; - - imageIds = imageIds.filter((imageId) => { - const instanceMetaData = metaDataManager.get(imageId); - const instanceTag = instanceMetaData['00200013']; - const instanceNumber = parseInt(instanceTag.Value[0]); - - return instanceNumber >= firstInstanceNumber; + // Load the last N dimension groups because they have better image quality + // than the first ones (often blank or sparse). + const imageIds = get4DVolumeImageIds(seriesImageIds, { + lastCount: numDimensionGroups, }); // Define a unique id for the volume diff --git a/packages/tools/examples/generateImageFromTimeData/index.ts b/packages/tools/examples/generateImageFromTimeData/index.ts index 324230583f..125477dfde 100644 --- a/packages/tools/examples/generateImageFromTimeData/index.ts +++ b/packages/tools/examples/generateImageFromTimeData/index.ts @@ -13,9 +13,9 @@ import { addSliderToToolbar, addDropdownToToolbar, addButtonToToolbar, + get4DVolumeImageIds, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; -import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; const { utilities: csToolsUtilities, @@ -226,32 +226,15 @@ async function run() { ], }); - const { metaDataManager } = cornerstoneDICOMImageLoader.wadors; - - // Get Cornerstone imageIds and fetch metadata into RAM - let imageIds = await createImageIdsAndCacheMetaData({ + const seriesImageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '2.25.79767489559005369769092179787138169587', SeriesInstanceUID: '2.25.87977716979310885152986847054790859463', wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - const firstDimensionGroup = 10; - const lastDimensionGroup = 14; - const NUM_IMAGES_PER_DIMENSION_GROUP = 235; - const firstInstanceNumber = - (firstDimensionGroup - 1) * NUM_IMAGES_PER_DIMENSION_GROUP + 1; - const lastInstanceNumber = - lastDimensionGroup * NUM_IMAGES_PER_DIMENSION_GROUP; - - imageIds = imageIds.filter((imageId) => { - const instanceMetaData = metaDataManager.get(imageId); - const instanceTag = instanceMetaData['00200013']; - const instanceNumber = parseInt(instanceTag.Value[0]); - - return ( - instanceNumber >= firstInstanceNumber && - instanceNumber <= lastInstanceNumber - ); + const imageIds = get4DVolumeImageIds(seriesImageIds, { + fromGroup: 10, + toGroup: 14, }); // Instantiate a rendering engine diff --git a/packages/tools/examples/localAdvanced/index.ts b/packages/tools/examples/localAdvanced/index.ts index a2128681f4..e95514ba95 100644 --- a/packages/tools/examples/localAdvanced/index.ts +++ b/packages/tools/examples/localAdvanced/index.ts @@ -6,7 +6,6 @@ import { setUseCPURendering, } from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; -import dicomImageLoader from '@cornerstonejs/dicom-image-loader'; import htmlSetup from '../local/htmlSetup'; import uids from '../local/uids'; @@ -19,7 +18,16 @@ import { addDropdownToToolbar, annotationTools, createImageIdsAndCacheMetaData, + getPrimaryStackFrameImageIds, + imageIds, + setImageIds, + handleFileSelect, + setLoadImageListener, + loadAndViewImages, + viewportId, + renderingEngineId, } from '../../../../utils/demo/helpers'; +import { toolGroupId } from '../../../../utils/demo/helpers/constants'; const { ToolGroupManager } = cornerstoneTools; @@ -49,21 +57,12 @@ dropZone.addEventListener('drop', handleFileSelect, false); let viewport; -const toolGroupId = 'myToolGroup'; +addUploadToToolbar(); -function onUpload(files) { - imageIds = [...files].map((file) => - dicomImageLoader.wadouri.fileManager.add(file) - ); - loadAndViewImages(imageIds); -} - -addUploadToToolbar({ title: 'Upload', onChange: onUpload }); - -function createToolGroup(toolGroupId = 'default') { +function createToolGroup(newToolGroupId = toolGroupId) { // Define a tool group, which defines how mouse events map to tool commands for // Any viewport using the group - const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + const toolGroup = ToolGroupManager.createToolGroup(newToolGroupId); addManipulationBindings(toolGroup, { toolMap }); return toolGroup; @@ -85,7 +84,7 @@ const defaultTool = 'Length'; addDropdownToToolbar({ options: { map: toolMap, defaultValue: defaultTool }, - onSelectedValueChange: (newSelectedToolName, data) => { + onSelectedValueChange: (newSelectedToolName, _data) => { const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); // Set the old tool passive @@ -170,8 +169,6 @@ webSeries.set('', { }); */ -let imageIds = []; - addDropdownToToolbar({ options: { map: webSeries, defaultValue: '' }, onSelectedValueChange: async (newSelectedSeries, data) => { @@ -179,8 +176,8 @@ addDropdownToToolbar({ if (!data?.wadoRsRoot) { return; } - imageIds = await createImageIdsAndCacheMetaData(data); - loadAndViewImages(imageIds); + const seriesImageIds = await createImageIdsAndCacheMetaData({ ...data }); + loadAndViewImages(getPrimaryStackFrameImageIds(seriesImageIds)); }, }); @@ -196,11 +193,9 @@ async function run() { // Get Cornerstone imageIds and fetch metadata into RAM // Instantiate a rendering engine - const renderingEngineId = 'myRenderingEngine'; const renderingEngine = new RenderingEngine(renderingEngineId); // Create a stack viewport - const viewportId = 'VIEWPORT_ID'; const viewportInput = { viewportId, type: ViewportType.STACK, @@ -218,82 +213,58 @@ async function run() { toolGroup.addViewport(viewportId, renderingEngineId); } -// this function gets called once the user drops the file onto the div -function handleFileSelect(evt) { - evt.stopPropagation(); - evt.preventDefault(); - - // Get the FileList object that contains the list of files that were dropped - const files = [...evt.dataTransfer.files]; - - // this UI is only built for a single file so just dump the first one - imageIds = files.map((file) => - dicomImageLoader.wadouri.fileManager.add(file) - ); - loadAndViewImages(imageIds); -} - function handleDragOver(evt) { evt.stopPropagation(); evt.preventDefault(); evt.dataTransfer.dropEffect = 'copy'; // Explicitly show this is a copy. } -function loadAndViewImages(imageIds) { - // Set the stack on the viewport - viewport.setStack(imageIds).then(() => { - // Set the VOI of the stack - // viewport.setProperties({ voiRange: ctVoiRange }); - // Render the image - viewport.render(); - - const imageData = viewport.getImageData(); - - const [imageId] = imageIds; - const { - pixelRepresentation, - bitsAllocated, - bitsStored, - highBit, - photometricInterpretation, - } = metaData.get('imagePixelModule', imageId); - - const voiLutModule = metaData.get('voiLutModule', imageId); - - const sopCommonModule = metaData.get('sopCommonModule', imageId); - const transferSyntax = metaData.get('transferSyntax', imageId); - - document.getElementById('numberofimages').innerHTML = imageIds.length; - document.getElementById('transfersyntax').innerHTML = - transferSyntax.transferSyntaxUID; - document.getElementById('sopclassuid').innerHTML = `${ - sopCommonModule.sopClassUID - } [${uids[sopCommonModule.sopClassUID]}]`; - document.getElementById('sopinstanceuid').innerHTML = - sopCommonModule.sopInstanceUID; - document.getElementById('rows').innerHTML = imageData.dimensions[0]; - document.getElementById('columns').innerHTML = imageData.dimensions[1]; - document.getElementById('spacing').innerHTML = imageData.spacing.join('\\'); - document.getElementById('direction').innerHTML = imageData.direction - .map((x) => Math.round(x * 100) / 100) - .join(','); - - document.getElementById('origin').innerHTML = imageData.origin - .map((x) => Math.round(x * 100) / 100) - .join(','); - document.getElementById('modality').innerHTML = imageData.metadata.Modality; - - document.getElementById('pixelrepresentation').innerHTML = - pixelRepresentation; - document.getElementById('bitsallocated').innerHTML = bitsAllocated; - document.getElementById('bitsstored').innerHTML = bitsStored; - document.getElementById('highbit').innerHTML = highBit; - document.getElementById('photometricinterpretation').innerHTML = - photometricInterpretation; - document.getElementById('windowcenter').innerHTML = - voiLutModule.windowCenter; - document.getElementById('windowwidth').innerHTML = voiLutModule.windowWidth; - }); -} +setLoadImageListener(() => { + const imageData = viewport.getImageData(); + + const [imageId] = imageIds; + const { + pixelRepresentation, + bitsAllocated, + bitsStored, + highBit, + photometricInterpretation, + } = metaData.get('imagePixelModule', imageId); + + const voiLutModule = metaData.get('voiLutModule', imageId); + + const sopCommonModule = metaData.get('sopCommonModule', imageId); + const transferSyntax = metaData.get('transferSyntax', imageId); + + document.getElementById('numberofimages').innerHTML = String(imageIds.length); + document.getElementById('transfersyntax').innerHTML = + transferSyntax.transferSyntaxUID; + document.getElementById('sopclassuid').innerHTML = `${ + sopCommonModule.sopClassUID + } [${uids[sopCommonModule.sopClassUID]}]`; + document.getElementById('sopinstanceuid').innerHTML = + sopCommonModule.sopInstanceUID; + document.getElementById('rows').innerHTML = imageData.dimensions[0]; + document.getElementById('columns').innerHTML = imageData.dimensions[1]; + document.getElementById('spacing').innerHTML = imageData.spacing.join('\\'); + document.getElementById('direction').innerHTML = imageData.direction + .map((x) => Math.round(x * 100) / 100) + .join(','); + + document.getElementById('origin').innerHTML = imageData.origin + .map((x) => Math.round(x * 100) / 100) + .join(','); + document.getElementById('modality').innerHTML = imageData.metadata.Modality; + + document.getElementById('pixelrepresentation').innerHTML = + pixelRepresentation; + document.getElementById('bitsallocated').innerHTML = bitsAllocated; + document.getElementById('bitsstored').innerHTML = bitsStored; + document.getElementById('highbit').innerHTML = highBit; + document.getElementById('photometricinterpretation').innerHTML = + photometricInterpretation; + document.getElementById('windowcenter').innerHTML = voiLutModule.windowCenter; + document.getElementById('windowwidth').innerHTML = voiLutModule.windowWidth; +}); run(); diff --git a/packages/tools/examples/videoColor/index.ts b/packages/tools/examples/videoColor/index.ts index 162f5e4682..c34aab5683 100644 --- a/packages/tools/examples/videoColor/index.ts +++ b/packages/tools/examples/videoColor/index.ts @@ -7,6 +7,7 @@ import { setTitleAndDescription, createImageIdsAndCacheMetaData, getLocalUrl, + getVideoImageIdFromImageIds, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -189,10 +190,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } // Add tools to Cornerstone3D cornerstoneTools.addTool(PanTool); diff --git a/packages/tools/examples/videoContourSegmentation/index.ts b/packages/tools/examples/videoContourSegmentation/index.ts index 51a0908927..6f4786175e 100644 --- a/packages/tools/examples/videoContourSegmentation/index.ts +++ b/packages/tools/examples/videoContourSegmentation/index.ts @@ -7,6 +7,7 @@ import { addDropdownToToolbar, addToggleButtonToToolbar, createImageIdsAndCacheMetaData, + getVideoImageIdFromImageIds, createInfoSection, initDemo, setTitleAndDescription, @@ -257,10 +258,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } // Instantiate a rendering engine const renderingEngineId = 'myRenderingEngine'; diff --git a/packages/tools/examples/videoGroup/index.ts b/packages/tools/examples/videoGroup/index.ts index 90a0604441..c48062f947 100644 --- a/packages/tools/examples/videoGroup/index.ts +++ b/packages/tools/examples/videoGroup/index.ts @@ -6,6 +6,7 @@ import { setTitleAndDescription, createImageIdsAndCacheMetaData, getLocalUrl, + getVideoImageIdFromImageIds, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -332,9 +333,10 @@ async function run() { }); // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } addAnnotationListeners(); diff --git a/packages/tools/examples/videoNavigation/index.ts b/packages/tools/examples/videoNavigation/index.ts index 54e8252d54..8eb5715587 100644 --- a/packages/tools/examples/videoNavigation/index.ts +++ b/packages/tools/examples/videoNavigation/index.ts @@ -7,6 +7,7 @@ import { setTitleAndDescription, createImageIdsAndCacheMetaData, getLocalUrl, + getVideoImageIdFromImageIds, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -175,14 +176,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // The default DICOMweb loader splits up the video into one image id per frame, - // but the video viewport needs a single combined reference, so find the first - // reference and use that one. - // Also, the series has more than one object in it, and the video viewport - // can only display a single video at a time. - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } // Add tools to Cornerstone3D cornerstoneTools.addTool(PanTool); diff --git a/packages/tools/examples/videoRange/index.ts b/packages/tools/examples/videoRange/index.ts index 24f5581bdb..2cf40832c3 100644 --- a/packages/tools/examples/videoRange/index.ts +++ b/packages/tools/examples/videoRange/index.ts @@ -7,6 +7,7 @@ import { initDemo, setTitleAndDescription, createImageIdsAndCacheMetaData, + getVideoImageIdFromImageIds, getLocalUrl, addManipulationBindings, addVideoTime, @@ -307,10 +308,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } addAnnotationListeners(); // Add annotation tools to Cornerstone3D diff --git a/packages/tools/examples/videoSegmentation/index.ts b/packages/tools/examples/videoSegmentation/index.ts index a8dceb0e19..d9baa28c9f 100644 --- a/packages/tools/examples/videoSegmentation/index.ts +++ b/packages/tools/examples/videoSegmentation/index.ts @@ -8,6 +8,7 @@ import * as cornerstone from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; import { createImageIdsAndCacheMetaData, + getVideoImageIdFromImageIds, initDemo, addDropdownToToolbar, setTitleAndDescription, @@ -197,10 +198,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } // Instantiate a rendering engine renderingEngine = new RenderingEngine(renderingEngineId); diff --git a/packages/tools/examples/videoSplineROITools/index.ts b/packages/tools/examples/videoSplineROITools/index.ts index 5b389c2c6c..73903ece2c 100644 --- a/packages/tools/examples/videoSplineROITools/index.ts +++ b/packages/tools/examples/videoSplineROITools/index.ts @@ -3,6 +3,7 @@ import { RenderingEngine, Enums } from '@cornerstonejs/core'; import { initDemo, createImageIdsAndCacheMetaData, + getVideoImageIdFromImageIds, setTitleAndDescription, addDropdownToToolbar, addSliderToToolbar, @@ -283,10 +284,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); diff --git a/packages/tools/examples/videoTools/index.ts b/packages/tools/examples/videoTools/index.ts index 33d33f7604..22d6d8a5c7 100644 --- a/packages/tools/examples/videoTools/index.ts +++ b/packages/tools/examples/videoTools/index.ts @@ -7,6 +7,7 @@ import { initDemo, setTitleAndDescription, createImageIdsAndCacheMetaData, + getVideoImageIdFromImageIds, getLocalUrl, addManipulationBindings, addVideoTime, @@ -194,10 +195,10 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Only one SOP instances is DICOM, so find it - const videoId = imageIds.find( - (it) => it.indexOf('2.25.179478223177027022014772769075050874231') !== -1 - ); + const videoId = getVideoImageIdFromImageIds(imageIds); + if (!videoId) { + throw new Error('No video display set found in series'); + } addAnnotationListeners(); diff --git a/packages/tools/jest.config.js b/packages/tools/jest.config.js index 682de0e841..7afba65ef6 100644 --- a/packages/tools/jest.config.js +++ b/packages/tools/jest.config.js @@ -7,6 +7,8 @@ module.exports = { displayName: 'tools', testMatch: [...base.testMatch, '/src/**/*.spec.ts'], moduleNameMapper: { + ...base.moduleNameMapper, + '^@cornerstonejs/(\\w+)/(.+)$': path.resolve(__dirname, '../$1/src/$2'), '^@cornerstonejs/(.*)$': path.resolve(__dirname, '../$1/src'), }, }; diff --git a/packages/tools/package.json b/packages/tools/package.json index 07d182dc88..2771f39676 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/tools", - "version": "4.22.9", + "version": "5.0.0-beta.2", "description": "Cornerstone3D Tools", "types": "./dist/esm/index.d.ts", "module": "./dist/esm/index.js", @@ -86,7 +86,7 @@ }, "scripts": { "prebuild": "node ../../scripts/generate-version.js ./", - "build:esm": "tsc --project ./tsconfig.json", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", "build:esm:watch": "tsc --project ./tsconfig.json --watch", "build:umd": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build:all": "yarn run build:umd && yarn run build:esm", @@ -105,11 +105,11 @@ "lodash.get": "4.4.2" }, "devDependencies": { - "@cornerstonejs/core": "4.22.9", + "@cornerstonejs/core": "5.0.0-beta.2", "canvas": "3.2.0" }, "peerDependencies": { - "@cornerstonejs/core": "4.22.9", + "@cornerstonejs/core": ">=5.0.0-beta.1 <6.0.0-0", "@kitware/vtk.js": "34.15.1", "@types/d3-array": "3.2.1", "@types/d3-interpolate": "3.0.4", diff --git a/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts b/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts index fc53dc2457..ac169342fc 100644 --- a/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts +++ b/packages/tools/src/tools/segmentation/RectangleROIStartEndThresholdTool.ts @@ -290,7 +290,7 @@ class RectangleROIStartEndThresholdTool extends RectangleROITool { } }; - //Now works for axial, sagitall and coronal + //Now works for axial, sagittal and coronal _computeProjectionPoints( annotation: RectangleROIStartEndThresholdAnnotation, imageVolume: Types.IImageVolume diff --git a/packages/tools/src/version.ts b/packages/tools/src/version.ts index a0b1e795a8..f368f2fbda 100644 --- a/packages/tools/src/version.ts +++ b/packages/tools/src/version.ts @@ -2,4 +2,4 @@ * Auto-generated from version.json * Do not modify this file directly */ -export const version = '4.22.9'; +export const version = '5.0.0-beta.2'; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000000..ad049143c3 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,89 @@ +{ + "name": "@cornerstonejs/utils", + "version": "5.0.0-beta.2", + "description": "Cornerstone3D shared utility helpers", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "repository": "https://github.com/cornerstonejs/cornerstone3D", + "files": [ + "./dist/" + ], + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/esm/index.js", + "node": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts" + }, + "./utilities": { + "import": "./dist/esm/utilities/index.js", + "node": "./dist/esm/utilities/index.js", + "types": "./dist/esm/utilities/index.d.ts" + }, + "./utilities/object": { + "import": "./dist/esm/utilities/object/index.js", + "node": "./dist/esm/utilities/object/index.js", + "types": "./dist/esm/utilities/object/index.d.ts" + }, + "./utilities/object/*": { + "import": "./dist/esm/utilities/object/*.js", + "node": "./dist/esm/utilities/object/*.js", + "types": "./dist/esm/utilities/object/*.d.ts" + }, + "./utilities/math": { + "import": "./dist/esm/utilities/math/index.js", + "node": "./dist/esm/utilities/math/index.js", + "types": "./dist/esm/utilities/math/index.d.ts" + }, + "./utilities/math/*": { + "import": "./dist/esm/utilities/math/*.js", + "node": "./dist/esm/utilities/math/*.js", + "types": "./dist/esm/utilities/math/*.d.ts" + }, + "./utilities/logging": { + "import": "./dist/esm/utilities/logging/index.js", + "node": "./dist/esm/utilities/logging/index.js", + "types": "./dist/esm/utilities/logging/index.d.ts" + }, + "./utilities/logging/*": { + "import": "./dist/esm/utilities/logging/*.js", + "node": "./dist/esm/utilities/logging/*.js", + "types": "./dist/esm/utilities/logging/*.d.ts" + }, + "./version": { + "import": "./dist/esm/version.js", + "node": "./dist/esm/version.js", + "types": "./dist/esm/version.d.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prebuild": "node ../../scripts/generate-version.js ./", + "build:esm": "yarn run prebuild && tsc --project ./tsconfig.json", + "build:esm:watch": "tsc --project ./tsconfig.json --watch", + "clean": "rm -rf node_modules/.cache/storybook && shx rm -rf dist", + "clean:deep": "yarn run clean && shx rm -rf node_modules", + "build": "yarn run build:esm", + "build:all": "yarn run build:esm", + "dev": "tsc --project ./tsconfig.json --watch", + "api-check": "api-extractor --debug run", + "lint": "oxlint .", + "prepublishOnly": "yarn run build" + }, + "dependencies": { + "dcmjs": "0.50.1" + }, + "contributors": [ + { + "name": "Cornerstone.js Contributors", + "url": "https://github.com/orgs/cornerstonejs/people" + } + ], + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://ohif.org/donate" + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000000..626f090141 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,16 @@ +export { version } from './version'; +export * as utilities from './utilities'; +export * as logging from './utilities/logging'; + +export { asArray } from './utilities/object'; +export { + toNumber, + toFiniteNumber, + isEqual, + isEqualNegative, + isEqualAbs, + isNumber, + DEFAULT_EPSILON, + areNumbersEqualWithTolerance, +} from './utilities/math'; +export type { Logger } from './utilities/logging'; diff --git a/packages/utils/src/utilities/index.ts b/packages/utils/src/utilities/index.ts new file mode 100644 index 0000000000..2485309acc --- /dev/null +++ b/packages/utils/src/utilities/index.ts @@ -0,0 +1,16 @@ +export * as object from './object'; +export * as math from './math'; +export * as logging from './logging'; + +export { asArray } from './object'; +export { + toNumber, + toFiniteNumber, + isEqual, + isEqualNegative, + isEqualAbs, + isNumber, + DEFAULT_EPSILON, + areNumbersEqualWithTolerance, +} from './math'; +export type { Logger } from './logging'; diff --git a/packages/utils/src/utilities/logging/index.ts b/packages/utils/src/utilities/logging/index.ts new file mode 100644 index 0000000000..fa4eb8b775 --- /dev/null +++ b/packages/utils/src/utilities/logging/index.ts @@ -0,0 +1,53 @@ +/** + * Logging utilities. Uses the log exported by dcmjs so all packages share + * the same log hierarchy. + */ +import { log } from 'dcmjs'; + +export type Logger = { + getLogger: (...categories: string[]) => Logger; + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + setLevel: (level: string | number) => void; +}; + +/** + * Root category for Cornerstone3D logs. + */ +export const cs3dLog = getRootLogger('cs3d'); + +/** + * Category loggers that existing packages re-export. + */ +export const metadataLog = cs3dLog.getLogger('metadata') as Logger; +export const coreLog = cs3dLog.getLogger('core') as Logger; +export const toolsLog = cs3dLog.getLogger('tools') as Logger; +export const loaderLog = cs3dLog.getLogger('dicomImageLoader') as Logger; +export const aiLog = cs3dLog.getLogger('ai') as Logger; +export const examplesLog = cs3dLog.getLogger('examples') as Logger; +export const workerLog = cs3dLog.getLogger('worker') as Logger; +export const dicomConsistencyLog = log.getLogger('consistency.dicom') as Logger; +export const imageConsistencyLog = log.getLogger('consistency.image') as Logger; + +/** + * Gets a root logger for the given category name and wires up a hierarchical + * `getLogger` method on it, similar to loglevel 2.x. + */ +export function getRootLogger(name: string): Logger { + const logger = log.getLogger(name) as Logger; + logger.getLogger = (...names: string[]): Logger => + getRootLogger(`${name}.${names.join('.')}`); + return logger; +} + +/** + * Gets a nested logger from the root, preserving hierarchical `getLogger`. + */ +export function getLogger(...name: string[]): Logger { + return getRootLogger(name.join('.')); +} + +/** Re-export dcmjs log for consumers that need the root */ +export { log }; diff --git a/packages/utils/src/utilities/math/index.ts b/packages/utils/src/utilities/math/index.ts new file mode 100644 index 0000000000..b4374780b0 --- /dev/null +++ b/packages/utils/src/utilities/math/index.ts @@ -0,0 +1,11 @@ +export { toNumber, toFiniteNumber } from './toNumber'; +export { default as toNumberDefault } from './toNumber'; +export { + DEFAULT_EPSILON, + areNumbersEqualWithTolerance, + isEqual, + isEqualNegative, + isEqualAbs, + isNumber, +} from './isEqual'; +export { default as isEqualDefault } from './isEqual'; diff --git a/packages/utils/src/utilities/math/isEqual.ts b/packages/utils/src/utilities/math/isEqual.ts new file mode 100644 index 0000000000..ee2b0b235b --- /dev/null +++ b/packages/utils/src/utilities/math/isEqual.ts @@ -0,0 +1,111 @@ +export const DEFAULT_EPSILON = 1e-5; + +export function areNumbersEqualWithTolerance( + num1: number, + num2: number, + tolerance: number +): boolean { + return Math.abs(num1 - num2) <= tolerance; +} + +function areArraysEqual( + arr1: ArrayLike, + arr2: ArrayLike, + tolerance = DEFAULT_EPSILON +): boolean { + if (arr1.length !== arr2.length) { + return false; + } + + for (let i = 0; i < arr1.length; i++) { + if (!areNumbersEqualWithTolerance(arr1[i], arr2[i], tolerance)) { + return false; + } + } + + return true; +} + +function isNumberType(value: unknown): value is number { + return typeof value === 'number'; +} + +function isNumberArrayLike(value: unknown): value is ArrayLike { + return ( + value && + typeof value === 'object' && + 'length' in value && + typeof (value as ArrayLike).length === 'number' && + (value as ArrayLike).length > 0 && + typeof (value as ArrayLike)[0] === 'number' + ); +} + +/** + * Returns whether two values are equal or not, based on epsilon comparison. + * For array comparison, it does NOT strictly compare them but only compare its values. + * It can compare array of numbers and also typed array. Otherwise it will just return false. + * + * @param v1 - The first value to compare + * @param v2 - The second value to compare + * @param tolerance - The acceptable tolerance, the default is 0.00001 + * + * @returns True if the two values are within the tolerance levels. + */ +export function isEqual( + v1: ValueType, + v2: ValueType, + tolerance = DEFAULT_EPSILON +): boolean { + if (typeof v1 !== typeof v2 || v1 === null || v2 === null) { + return false; + } + + if (isNumberType(v1) && isNumberType(v2)) { + return areNumbersEqualWithTolerance(v1, v2, tolerance); + } + + if (isNumberArrayLike(v1) && isNumberArrayLike(v2)) { + return areArraysEqual(v1, v2, tolerance); + } + + return false; +} + +const negative = (v) => + typeof v === 'number' ? -v : v?.map ? v.map(negative) : !v; + +const abs = (v) => + typeof v === 'number' ? Math.abs(v) : v?.map ? v.map(abs) : v; + +/** + * Compare negative values of both single numbers and vectors. + */ +export const isEqualNegative = ( + v1: ValueType, + v2: ValueType, + tolerance = undefined +) => isEqual(v1, negative(v2) as unknown as ValueType, tolerance); + +/** + * Compare absolute values for single numbers and vectors. + * Not recommended for large vectors as this creates a copy. + */ +export const isEqualAbs = ( + v1: ValueType, + v2: ValueType, + tolerance = undefined +) => isEqual(abs(v1), abs(v2) as unknown as ValueType, tolerance); + +/** + * @param n - array of numbers or a simple number + * @returns True if n or the first element of n is finite and not NaN + */ +export function isNumber(n: number[] | number): boolean { + if (Array.isArray(n)) { + return isNumber(n[0]); + } + return isFinite(n) && !isNaN(n); +} + +export default isEqual; diff --git a/packages/utils/src/utilities/math/toNumber.ts b/packages/utils/src/utilities/math/toNumber.ts new file mode 100644 index 0000000000..67fb94b2b8 --- /dev/null +++ b/packages/utils/src/utilities/math/toNumber.ts @@ -0,0 +1,74 @@ +type NumberLike = string | String | number | Number; + +function isNumberLike(value: unknown): value is NumberLike { + return ( + typeof value === 'string' || + typeof value === 'number' || + value instanceof String || + value instanceof Number + ); +} + +/** + * Converts a value to a finite number, returning undefined if the value is not finite. + * + * @param value - The value to convert to a finite number + * @returns The finite number value, or undefined if the value is not finite + */ +export function toFiniteNumber( + value: NumberLike | undefined +): number | undefined; +export function toFiniteNumber( + value: ArrayLike | undefined +): number[] | undefined; +export function toFiniteNumber( + value: T | ArrayLike | undefined +): number | undefined | number[] { + if (value === undefined) { + return undefined; + } + + if (isNumberLike(value)) { + const converted = Number(value); + return Number.isFinite(converted) ? converted : undefined; + } + + return Array.from(value, (entry) => { + const converted = Number(entry); + return Number.isFinite(converted) ? converted : undefined; + }) as number[]; +} + +/** + * Coerces DICOM-friendly numeric inputs to number(s). + * + * @param val - The javascript object for the specified element in the metadata + * @returns finite number(s); invalid values are coerced to NaN + */ +export function toNumber( + val: NumberLike | null | undefined +): number | undefined; +export function toNumber( + val: ArrayLike | Iterable +): number[]; +export function toNumber(val: unknown): number | number[] | undefined { + if (val === undefined || val === null) { + return undefined; + } + + if (isNumberLike(val)) { + return Number(val); + } + + if (Array.isArray(val)) { + return val.map((entry) => Number(entry)); + } + + if (typeof val === 'object' && Symbol.iterator in val) { + return Array.from(val as Iterable, (entry) => Number(entry)); + } + + return Number(val as NumberLike); +} + +export default toNumber; diff --git a/packages/utils/src/utilities/object/asArray.ts b/packages/utils/src/utilities/object/asArray.ts new file mode 100644 index 0000000000..c30a0322c5 --- /dev/null +++ b/packages/utils/src/utilities/object/asArray.ts @@ -0,0 +1,15 @@ +/** + * Returns an array with the item if it is an object/primitive, otherwise, + * if it is an array, returns the array itself. + * + * @param item array or single object/primitive + * @returns an array with the object/primitive as the single element or the original array + */ +export function asArray(item: T | T[]): T[] { + if (Array.isArray(item)) { + return item; + } + return [item]; +} + +export default asArray; diff --git a/packages/utils/src/utilities/object/index.ts b/packages/utils/src/utilities/object/index.ts new file mode 100644 index 0000000000..e6edf74d3b --- /dev/null +++ b/packages/utils/src/utilities/object/index.ts @@ -0,0 +1,2 @@ +export { asArray } from './asArray'; +export { default as asArrayDefault } from './asArray'; diff --git a/packages/utils/src/version.ts b/packages/utils/src/version.ts new file mode 100644 index 0000000000..f368f2fbda --- /dev/null +++ b/packages/utils/src/version.ts @@ -0,0 +1,5 @@ +/** + * Auto-generated from version.json + * Do not modify this file directly + */ +export const version = '5.0.0-beta.2'; diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000000..bc915f1e65 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/esm", + "rootDir": "./src" + }, + "include": ["./src/**/*"] +} diff --git a/playwright.config.ts b/playwright.config.ts index eb1bc47955..ddb2ab6445 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,12 +10,13 @@ export default defineConfig({ snapshotPathTemplate: 'tests/screenshots{/projectName}/{testFilePath}/{arg}{ext}', outputDir: './tests/test-results', - reporter: [ - [ - process.env.CI ? 'blob' : 'html', - { outputFolder: './packages/docs/static/playwright-report' }, - ], - ], + // In CI, blob feeds docs merge workflows; HTML goes under tests/ for artifact upload. + reporter: process.env.CI + ? [ + ['blob', { outputFolder: './packages/docs/static/playwright-report' }], + ['html', { open: 'never', outputFolder: './tests/playwright-report' }], + ] + : [['html', { outputFolder: './packages/docs/static/playwright-report' }]], use: { baseURL: 'http://localhost:3333', trace: 'on-first-retry', diff --git a/scripts/ci/ohif-ref-resolve.sh b/scripts/ci/ohif-ref-resolve.sh index c97c9ea277..5825021ba6 100755 --- a/scripts/ci/ohif-ref-resolve.sh +++ b/scripts/ci/ohif-ref-resolve.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Resolves OHIF/Viewers ref from workflow_dispatch input or PR body line: # OHIF_REF: +# ohif_ref: # Writes OHIF_REF to GITHUB_ENV for subsequent steps. # # Required env: EVENT_NAME, GITHUB_ENV @@ -19,7 +20,10 @@ if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then echo "::notice::OHIF ref (workflow_dispatch): ${REF}" elif [[ "$EVENT_NAME" == "pull_request" ]]; then REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body' \ - | sed -n 's/^[[:space:]]*OHIF_REF:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1) + | sed -n '/^[[:space:]]*[Oo][Hh][Ii][Ff]_[Rr][Ee][Ff]:[[:space:]]*/{ + s/^[[:space:]]*[Oo][Hh][Ii][Ff]_[Rr][Ee][Ff]:[[:space:]]*\([^[:space:]]*\).*/\1/p + q + }') if [[ -z "$REF" ]]; then REF="$DEFAULT_REF" fi diff --git a/scripts/link-ohif-cornerstone-node-modules.mjs b/scripts/link-ohif-cornerstone-node-modules.mjs index 66990de638..e3684df731 100644 --- a/scripts/link-ohif-cornerstone-node-modules.mjs +++ b/scripts/link-ohif-cornerstone-node-modules.mjs @@ -1,49 +1,73 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const [ohifDirArg] = process.argv.slice(2); - -if (!ohifDirArg) { - console.error( - 'Usage: node scripts/link-ohif-cornerstone-node-modules.mjs ' - ); - process.exit(1); -} - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const ohifDir = path.resolve(process.cwd(), ohifDirArg); -const nodeModulesRoot = path.join(ohifDir, 'node_modules', '@cornerstonejs'); - -if (!fs.existsSync(path.join(ohifDir, 'package.json'))) { - console.error(`Could not find OHIF package.json in ${ohifDir}`); - process.exit(1); -} - -if (!fs.existsSync(nodeModulesRoot)) { - console.error(`Could not find ${nodeModulesRoot}. Run install first.`); - process.exit(1); -} - -const localPackages = { - adapters: 'packages/adapters', - ai: 'packages/ai', - core: 'packages/core', - 'dicom-image-loader': 'packages/dicomImageLoader', - 'labelmap-interpolation': 'packages/labelmap-interpolation', - 'nifti-volume-loader': 'packages/nifti-volume-loader', - 'polymorphic-segmentation': 'packages/polymorphic-segmentation', - tools: 'packages/tools', -}; - -for (const [packageName, localPath] of Object.entries(localPackages)) { - const linkPath = path.join(nodeModulesRoot, packageName); - const targetPath = path.join(repoRoot, localPath); - - fs.rmSync(linkPath, { recursive: true, force: true }); - fs.symlinkSync(targetPath, linkPath, 'dir'); -} - -console.log(`Linked local Cornerstone packages into ${nodeModulesRoot}`); +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const [ohifDirArg] = process.argv.slice(2); + +if (!ohifDirArg) { + console.error( + 'Usage: node scripts/link-ohif-cornerstone-node-modules.mjs ' + ); + process.exit(1); +} + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ohifDir = path.resolve(process.cwd(), ohifDirArg); +const nodeModulesRoot = path.join(ohifDir, 'node_modules', '@cornerstonejs'); + +if (!fs.existsSync(path.join(ohifDir, 'package.json'))) { + console.error(`Could not find OHIF package.json in ${ohifDir}`); + process.exit(1); +} + +if (!fs.existsSync(nodeModulesRoot)) { + fs.mkdirSync(nodeModulesRoot, { recursive: true }); +} + +const localPackages = { + adapters: 'packages/adapters', + ai: 'packages/ai', + core: 'packages/core', + 'dicom-image-loader': 'packages/dicomImageLoader', + metadata: 'packages/metadata', + utils: 'packages/utils', + 'labelmap-interpolation': 'packages/labelmap-interpolation', + 'nifti-volume-loader': 'packages/nifti-volume-loader', + 'polymorphic-segmentation': 'packages/polymorphic-segmentation', + tools: 'packages/tools', +}; + +for (const [packageName, localPath] of Object.entries(localPackages)) { + const linkPath = path.join(nodeModulesRoot, packageName); + const targetPath = path.join(repoRoot, localPath); + + if (!fs.existsSync(targetPath)) { + console.error(`Local package path not found: ${targetPath}`); + process.exit(1); + } + + fs.rmSync(linkPath, { recursive: true, force: true }); + fs.symlinkSync(targetPath, linkPath, 'dir'); +} + +// Compatibility for OHIF Jest mappings that expect @cornerstonejs/*/dist/esm. +// calculate-suv only ships dist/ (without dist/esm), so create an alias. +const calculateSUVDistDir = path.join( + nodeModulesRoot, + 'calculate-suv', + 'dist' +); +const calculateSUVDistEsmDir = path.join(calculateSUVDistDir, 'esm'); + +if (fs.existsSync(calculateSUVDistDir) && !fs.existsSync(calculateSUVDistEsmDir)) { + fs.mkdirSync(calculateSUVDistEsmDir, { recursive: true }); + const shimContents = "module.exports = require('../index.js');\n"; + fs.writeFileSync(path.join(calculateSUVDistEsmDir, 'index.js'), shimContents, 'utf8'); + console.log( + `Created calculate-suv dist/esm compatibility shim: ${calculateSUVDistEsmDir}/index.js` + ); +} + +console.log(`Linked local Cornerstone packages into ${nodeModulesRoot}`); diff --git a/scripts/unlink-ohif-cornerstone-node-modules.mjs b/scripts/unlink-ohif-cornerstone-node-modules.mjs index 90e42defce..ee37ccd071 100644 --- a/scripts/unlink-ohif-cornerstone-node-modules.mjs +++ b/scripts/unlink-ohif-cornerstone-node-modules.mjs @@ -1,80 +1,82 @@ -#!/usr/bin/env node - -/** - * Removes symlinked Cornerstone packages from an OHIF project's node_modules - * and restores them from the registry. - * - * Counterpart to scripts/link-ohif-cornerstone-node-modules.mjs - * - * Usage: node scripts/unlink-ohif-cornerstone-node-modules.mjs - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import { execSync } from 'node:child_process'; - -const [ohifDirArg] = process.argv.slice(2); - -if (!ohifDirArg) { - console.error( - 'Usage: node scripts/unlink-ohif-cornerstone-node-modules.mjs ' - ); - process.exit(1); -} - -const ohifDir = path.resolve(process.cwd(), ohifDirArg); -const nodeModulesRoot = path.join(ohifDir, 'node_modules', '@cornerstonejs'); - -if (!fs.existsSync(path.join(ohifDir, 'package.json'))) { - console.error(`Could not find OHIF package.json in ${ohifDir}`); - process.exit(1); -} - -if (!fs.existsSync(nodeModulesRoot)) { - console.error(`Could not find ${nodeModulesRoot}. Run install first.`); - process.exit(1); -} - -// Same package list as link-ohif-cornerstone-node-modules.mjs -const localPackages = { - adapters: 'packages/adapters', - ai: 'packages/ai', - core: 'packages/core', - 'dicom-image-loader': 'packages/dicomImageLoader', - 'labelmap-interpolation': 'packages/labelmap-interpolation', - 'nifti-volume-loader': 'packages/nifti-volume-loader', - 'polymorphic-segmentation': 'packages/polymorphic-segmentation', - tools: 'packages/tools', -}; - -let removed = 0; -for (const packageName of Object.keys(localPackages)) { - const linkPath = path.join(nodeModulesRoot, packageName); - try { - const stat = fs.lstatSync(linkPath); - if (stat.isSymbolicLink()) { - fs.rmSync(linkPath, { recursive: true }); - console.log(` Removed symlink: @cornerstonejs/${packageName}`); - removed++; - } - } catch { - // doesn't exist or not a symlink — skip - } -} - -if (removed === 0) { - console.log('No symlinks found — nothing to unlink.'); -} else { - console.log(`\nRemoved ${removed} symlink(s).`); -} - -// Restore packages from registry -console.log('\nRestoring packages from registry (yarn install --frozen-lockfile)...'); -try { - execSync('yarn install --frozen-lockfile', { cwd: ohifDir, stdio: 'inherit' }); -} catch { - console.log('Frozen lockfile install failed, retrying without --frozen-lockfile...'); - execSync('yarn install', { cwd: ohifDir, stdio: 'inherit' }); -} - -console.log('\nDone. Packages restored from registry.'); +#!/usr/bin/env node + +/** + * Removes symlinked Cornerstone packages from an OHIF project's node_modules + * and restores them from the registry. + * + * Counterpart to scripts/link-ohif-cornerstone-node-modules.mjs + * + * Usage: node scripts/unlink-ohif-cornerstone-node-modules.mjs + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +const [ohifDirArg] = process.argv.slice(2); + +if (!ohifDirArg) { + console.error( + 'Usage: node scripts/unlink-ohif-cornerstone-node-modules.mjs ' + ); + process.exit(1); +} + +const ohifDir = path.resolve(process.cwd(), ohifDirArg); +const nodeModulesRoot = path.join(ohifDir, 'node_modules', '@cornerstonejs'); + +if (!fs.existsSync(path.join(ohifDir, 'package.json'))) { + console.error(`Could not find OHIF package.json in ${ohifDir}`); + process.exit(1); +} + +if (!fs.existsSync(nodeModulesRoot)) { + console.error(`Could not find ${nodeModulesRoot}. Run install first.`); + process.exit(1); +} + +// Same package list as link-ohif-cornerstone-node-modules.mjs +const localPackages = { + adapters: 'packages/adapters', + ai: 'packages/ai', + core: 'packages/core', + 'dicom-image-loader': 'packages/dicomImageLoader', + 'labelmap-interpolation': 'packages/labelmap-interpolation', + metadata: 'packages/metadata', + utils: 'packages/utils', + 'nifti-volume-loader': 'packages/nifti-volume-loader', + 'polymorphic-segmentation': 'packages/polymorphic-segmentation', + tools: 'packages/tools', +}; + +let removed = 0; +for (const packageName of Object.keys(localPackages)) { + const linkPath = path.join(nodeModulesRoot, packageName); + try { + const stat = fs.lstatSync(linkPath); + if (stat.isSymbolicLink()) { + fs.rmSync(linkPath, { recursive: true }); + console.log(` Removed symlink: @cornerstonejs/${packageName}`); + removed++; + } + } catch { + // doesn't exist or not a symlink — skip + } +} + +if (removed === 0) { + console.log('No symlinks found — nothing to unlink.'); +} else { + console.log(`\nRemoved ${removed} symlink(s).`); +} + +// Restore packages from registry +console.log('\nRestoring packages from registry (yarn install --frozen-lockfile)...'); +try { + execSync('yarn install --frozen-lockfile', { cwd: ohifDir, stdio: 'inherit' }); +} catch { + console.log('Frozen lockfile install failed, retrying without --frozen-lockfile...'); + execSync('yarn install', { cwd: ohifDir, stdio: 'inherit' }); +} + +console.log('\nDone. Packages restored from registry.'); diff --git a/tests/utils/visitExample.ts b/tests/utils/visitExample.ts index 33aa0cd903..2a0ee4b1a0 100644 --- a/tests/utils/visitExample.ts +++ b/tests/utils/visitExample.ts @@ -12,6 +12,16 @@ export const visitExample = async ( waitForNetwork = true, waitForDom = true ) => { + // Size to a constant size to prevent scroll into view changes + // which can trigger renderingEngine.resize() and move the image on capture. + const currentViewport = page.viewportSize(); + const isLikelyDesktop = + !currentViewport || currentViewport.width >= 800; + + if (isLikelyDesktop) { + await page.setViewportSize({ width: 1280, height: 720 }); + } + await page.goto('/'); if (waitForNetwork) { await page.waitForLoadState('networkidle'); diff --git a/tsconfig.json b/tsconfig.json index c824c6790e..71c4f4cc25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "baseUrl": "./packages", "paths": { + "@cornerstonejs/metadata": ["metadata/src"], + "@cornerstonejs/metadata/*": ["metadata/src/*"], "@cornerstonejs/core": ["core/src"], "@cornerstonejs/tools": ["tools/src"], "@cornerstonejs/dicomImageLoader": ["dicomImageLoader/src"], diff --git a/utils/ExampleRunner/template-config.js b/utils/ExampleRunner/template-config.js index 17683f6dc2..42873ba551 100644 --- a/utils/ExampleRunner/template-config.js +++ b/utils/ExampleRunner/template-config.js @@ -1,119 +1,123 @@ -const path = require('path'); - -const csRenderBasePath = path.resolve('packages/core/src/index'); -const csToolsBasePath = path.resolve('packages/tools/src/index'); -const csAiBasePath = path.resolve('packages/ai/src/index'); -const csLabelmapInterpolationBasePath = path.resolve( - 'packages/labelmap-interpolation/src/index' -); -const csPolymorphicSegmentationBasePath = path.resolve( - 'packages/polymorphic-segmentation/src/index' -); -const csAdapters = path.resolve('packages/adapters/src/index'); -const csDICOMImageLoaderDistPath = path.resolve( - 'packages/dicomImageLoader/src/index' -); -const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index'); - -module.exports = function buildConfig(name, destPath, root, exampleBasePath) { - return ` -// THIS FILE IS AUTOGENERATED - DO NOT EDIT -const path = require('path') - -const rules = require('./rules-examples.js'); -const modules = [path.resolve('../node_modules/'), path.resolve('../../../node_modules/')]; - -const rspack = require('@rspack/core'); - -module.exports = { - mode: 'development', - devtool: 'inline-source-map', - plugins: [ - new rspack.HtmlRspackPlugin({ - template: '${root.replace(/\\/g, '/')}/utils/ExampleRunner/template.html', - }), - new rspack.DefinePlugin({ - __BASE_PATH__: "''", - }), - new rspack.CopyRspackPlugin({ - patterns: [ - { - from: - '../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import/', - to: '${destPath.replace(/\\/g, '/') + '/dicom-microscopy-viewer'}', - noErrorOnMissing: true, - }, - { - from: - '../../../node_modules/onnxruntime-web/dist', - to: '${destPath.replace(/\\/g, '/')}/ort', - }, - ], - }), - new rspack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - }), - ], - entry: path.join('${exampleBasePath.replace(/\\/g, '/')}'), - output: { - path: '${destPath.replace(/\\/g, '/')}', - filename: '${name}.js', - }, - module: { - rules, - }, - experiments: { - asyncWebAssembly: true, - nativeWatcher: true - }, - externals: { - "dicom-microscopy-viewer": { - root: "window", - commonjs: "dicomMicroscopyViewer", - }, - }, - resolve: { - alias: { - '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/ai': '${csAiBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/polymorphic-segmentation': '${csPolymorphicSegmentationBasePath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/labelmap-interpolation': '${csLabelmapInterpolationBasePath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/nifti-volume-loader': '${csNiftiPath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/adapters': '${csAdapters.replace(/\\/g, '/')}', - '@cornerstonejs/dicom-image-loader': '${csDICOMImageLoaderDistPath.replace( - /\\/g, - '/' - )}', - }, - modules, - extensions: ['.ts', '.tsx', '.js', '.jsx'], - fallback: { - fs: false, - path: require.resolve('path-browserify'), - events: false, - buffer: require.resolve('buffer'), - }, - }, - devServer: { - hot: true, - open: false, - port: ${process.env.CS3D_PORT || 3000}, - historyApiFallback: true, - allowedHosts: [ - '127.0.0.1', - 'localhost', - ], - }, -}; -`; -}; +const path = require('path'); + +const csRenderBasePath = path.resolve('packages/core/src/index'); +const csToolsBasePath = path.resolve('packages/tools/src/index'); +const csAiBasePath = path.resolve('packages/ai/src/index'); +const csLabelmapInterpolationBasePath = path.resolve( + 'packages/labelmap-interpolation/src/index' +); +const csPolymorphicSegmentationBasePath = path.resolve( + 'packages/polymorphic-segmentation/src/index' +); +const csAdapters = path.resolve('packages/adapters/src/index'); +const csDICOMImageLoaderDistPath = path.resolve( + 'packages/dicomImageLoader/src/index' +); +const csMetadataBasePath = path.resolve('packages/metadata/src'); +const csUtilsBasePath = path.resolve('packages/utils/src'); +const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index'); + +module.exports = function buildConfig(name, destPath, root, exampleBasePath) { + return ` +// THIS FILE IS AUTOGENERATED - DO NOT EDIT +const path = require('path') + +const rules = require('./rules-examples.js'); +const modules = [path.resolve('../node_modules/'), path.resolve('../../../node_modules/')]; + +const rspack = require('@rspack/core'); + +module.exports = { + mode: 'development', + devtool: 'inline-source-map', + plugins: [ + new rspack.HtmlRspackPlugin({ + template: '${root.replace(/\\/g, '/')}/utils/ExampleRunner/template.html', + }), + new rspack.DefinePlugin({ + __BASE_PATH__: "''", + }), + new rspack.CopyRspackPlugin({ + patterns: [ + { + from: + '../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import/', + to: '${destPath.replace(/\\/g, '/') + '/dicom-microscopy-viewer'}', + noErrorOnMissing: true, + }, + { + from: + '../../../node_modules/onnxruntime-web/dist', + to: '${destPath.replace(/\\/g, '/')}/ort', + }, + ], + }), + new rspack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + }), + ], + entry: path.join('${exampleBasePath.replace(/\\/g, '/')}'), + output: { + path: '${destPath.replace(/\\/g, '/')}', + filename: '${name}.js', + }, + module: { + rules, + }, + experiments: { + asyncWebAssembly: true, + nativeWatcher: true + }, + externals: { + "dicom-microscopy-viewer": { + root: "window", + commonjs: "dicomMicroscopyViewer", + }, + }, + resolve: { + alias: { + '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/ai': '${csAiBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/polymorphic-segmentation': '${csPolymorphicSegmentationBasePath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/labelmap-interpolation': '${csLabelmapInterpolationBasePath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/nifti-volume-loader': '${csNiftiPath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/adapters': '${csAdapters.replace(/\\/g, '/')}', + '@cornerstonejs/metadata': '${csMetadataBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/utils': '${csUtilsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/dicom-image-loader': '${csDICOMImageLoaderDistPath.replace( + /\\/g, + '/' + )}', + }, + modules, + extensions: ['.ts', '.tsx', '.js', '.jsx'], + fallback: { + fs: false, + path: require.resolve('path-browserify'), + events: false, + buffer: require.resolve('buffer'), + }, + }, + devServer: { + hot: true, + open: false, + port: ${process.env.CS3D_PORT || 3000}, + historyApiFallback: true, + allowedHosts: [ + '127.0.0.1', + 'localhost', + ], + }, +}; +`; +}; diff --git a/utils/ExampleRunner/template-multiexample-config.js b/utils/ExampleRunner/template-multiexample-config.js index 2c6a09acf1..4a87ba3c14 100644 --- a/utils/ExampleRunner/template-multiexample-config.js +++ b/utils/ExampleRunner/template-multiexample-config.js @@ -1,173 +1,177 @@ -const path = require('path'); - -const csRenderBasePath = path.resolve('./packages/core/src/index'); -const csToolsBasePath = path.resolve('./packages/tools/src/index'); -const csAiBasePath = path.resolve('./packages/ai/src/index'); -const csLabelmapInterpolationBasePath = path.resolve( - './packages/labelmap-interpolation/src/index' -); -const csPolymorphicSegmentationBasePath = path.resolve( - 'packages/polymorphic-segmentation/src/index' -); -const csAdaptersBasePath = path.resolve('./packages/adapters/src/index'); -const csDICOMImageLoaderDistPath = path.resolve( - 'packages/dicomImageLoader/src/index' -); -const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index'); - -module.exports = function buildConfig(names, exampleBasePaths, destPath, root) { - let multiExampleEntryPoints = ''; - - names.forEach((name, index) => { - const exampleBasePath = exampleBasePaths[index]; - multiExampleEntryPoints += `${name.replace( - /\\/g, - '/' - )}: "${exampleBasePath.replace(/\\/g, '/')}", \n`; - }); - - let multiTemplates = ''; - names.forEach((name) => { - multiTemplates += ` - new rspack.HtmlRspackPlugin({ - title: '${name}', - chunks: ['${name}'], - filename: '${name}.html', - template: '${root.replace( - /\\/g, - '/' - )}/utils/ExampleRunner/template.html', - }),`; - }); - - multiTemplates += '\n'; - - return ` -// THIS FILE IS AUTOGENERATED - DO NOT EDIT -const path = require('path') -const fs = require('fs'); -const rules = require('./rules-examples.js'); -const modules = [path.resolve('../node_modules/'), path.resolve('../../../node_modules/')]; -const rspack = require('@rspack/core'); - -const dir = "${destPath.replace(/\\/g, '/')}"; - -if (!fs.existsSync(dir)){ - console.debug('Creating directory: ' + dir); - fs.mkdirSync(dir); -} - -module.exports = { - mode: 'development', - devtool: 'source-map', - plugins: [ - ${multiTemplates} - new rspack.DefinePlugin({ - __BASE_PATH__: "''", - }), - new rspack.CopyRspackPlugin({ - patterns: [ - { - from: '${root.replace(/\\/g, '/')}/utils/ExampleRunner/serve.json', - to: "${destPath.replace(/\\/g, '/')}" - }, - { - from: '../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import/', - to: '${destPath.replace(/\\/g, '/') + '/dicom-microscopy-viewer/'}', - noErrorOnMissing: true, - }, - { - from: - '../../../node_modules/onnxruntime-web/dist', - to: '${destPath.replace(/\\/g, '/')}/ort', - }, - ], - }), - new rspack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - }), - ], - entry: { - ${multiExampleEntryPoints} - }, - output: { - path: '${destPath.replace(/\\/g, '/')}', - filename: '[name].js', - }, - module: { - rules, - }, - experiments: { - asyncWebAssembly: true, - nativeWatcher: true, - }, - externals: { - "dicom-microscopy-viewer": { - root: "window", - commonjs: "dicomMicroscopyViewer", - }, - }, - resolve: { - alias: { - '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/ai': '${csAiBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/polymorphic-segmentation': '${csPolymorphicSegmentationBasePath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/labelmap-interpolation': '${csLabelmapInterpolationBasePath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/adapters': '${csAdaptersBasePath.replace(/\\/g, '/')}', - '@cornerstonejs/dicom-image-loader': '${csDICOMImageLoaderDistPath.replace( - /\\/g, - '/' - )}', - '@cornerstonejs/nifti-volume-loader': '${csNiftiPath.replace( - /\\/g, - '/' - )}', - }, - modules, - extensions: ['.ts', '.tsx', '.js', '.jsx'], - fallback: { - fs: false, - path: require.resolve('path-browserify'), - events: false, - buffer: require.resolve('buffer'), - }, - }, - devServer: { - hot: true, - open: false, - port: ${process.env.CS3D_PORT || 3000}, - historyApiFallback: true, - allowedHosts: [ - '127.0.0.1', - 'localhost', - ], - }, - optimization: { - minimize: false, - splitChunks: { - chunks: 'all', - cacheGroups: { - defaultVendors: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - chunks: 'all', - }, - commons: { - test: /[\\/]packages[\\/]/, - name: 'commons', - chunks: 'all', - minChunks: 2, - }, - }, - } - }, -}; -`; -}; +const path = require('path'); + +const csRenderBasePath = path.resolve('./packages/core/src/index'); +const csToolsBasePath = path.resolve('./packages/tools/src/index'); +const csAiBasePath = path.resolve('./packages/ai/src/index'); +const csLabelmapInterpolationBasePath = path.resolve( + './packages/labelmap-interpolation/src/index' +); +const csPolymorphicSegmentationBasePath = path.resolve( + 'packages/polymorphic-segmentation/src/index' +); +const csAdaptersBasePath = path.resolve('./packages/adapters/src/index'); +const csDICOMImageLoaderDistPath = path.resolve( + 'packages/dicomImageLoader/src/index' +); +const csMetadataBasePath = path.resolve('./packages/metadata/src'); +const csUtilsBasePath = path.resolve('./packages/utils/src'); +const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index'); + +module.exports = function buildConfig(names, exampleBasePaths, destPath, root) { + let multiExampleEntryPoints = ''; + + names.forEach((name, index) => { + const exampleBasePath = exampleBasePaths[index]; + multiExampleEntryPoints += `${name.replace( + /\\/g, + '/' + )}: "${exampleBasePath.replace(/\\/g, '/')}", \n`; + }); + + let multiTemplates = ''; + names.forEach((name) => { + multiTemplates += ` + new rspack.HtmlRspackPlugin({ + title: '${name}', + chunks: ['${name}'], + filename: '${name}.html', + template: '${root.replace( + /\\/g, + '/' + )}/utils/ExampleRunner/template.html', + }),`; + }); + + multiTemplates += '\n'; + + return ` +// THIS FILE IS AUTOGENERATED - DO NOT EDIT +const path = require('path') +const fs = require('fs'); +const rules = require('./rules-examples.js'); +const modules = [path.resolve('../node_modules/'), path.resolve('../../../node_modules/')]; +const rspack = require('@rspack/core'); + +const dir = "${destPath.replace(/\\/g, '/')}"; + +if (!fs.existsSync(dir)){ + console.debug('Creating directory: ' + dir); + fs.mkdirSync(dir); +} + +module.exports = { + mode: 'development', + devtool: 'source-map', + plugins: [ + ${multiTemplates} + new rspack.DefinePlugin({ + __BASE_PATH__: "''", + }), + new rspack.CopyRspackPlugin({ + patterns: [ + { + from: '${root.replace(/\\/g, '/')}/utils/ExampleRunner/serve.json', + to: "${destPath.replace(/\\/g, '/')}" + }, + { + from: '../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import/', + to: '${destPath.replace(/\\/g, '/') + '/dicom-microscopy-viewer/'}', + noErrorOnMissing: true, + }, + { + from: + '../../../node_modules/onnxruntime-web/dist', + to: '${destPath.replace(/\\/g, '/')}/ort', + }, + ], + }), + new rspack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + }), + ], + entry: { + ${multiExampleEntryPoints} + }, + output: { + path: '${destPath.replace(/\\/g, '/')}', + filename: '[name].js', + }, + module: { + rules, + }, + experiments: { + asyncWebAssembly: true, + nativeWatcher: true, + }, + externals: { + "dicom-microscopy-viewer": { + root: "window", + commonjs: "dicomMicroscopyViewer", + }, + }, + resolve: { + alias: { + '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/ai': '${csAiBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/polymorphic-segmentation': '${csPolymorphicSegmentationBasePath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/labelmap-interpolation': '${csLabelmapInterpolationBasePath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/adapters': '${csAdaptersBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/metadata': '${csMetadataBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/utils': '${csUtilsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/dicom-image-loader': '${csDICOMImageLoaderDistPath.replace( + /\\/g, + '/' + )}', + '@cornerstonejs/nifti-volume-loader': '${csNiftiPath.replace( + /\\/g, + '/' + )}', + }, + modules, + extensions: ['.ts', '.tsx', '.js', '.jsx'], + fallback: { + fs: false, + path: require.resolve('path-browserify'), + events: false, + buffer: require.resolve('buffer'), + }, + }, + devServer: { + hot: true, + open: false, + port: ${process.env.CS3D_PORT || 3000}, + historyApiFallback: true, + allowedHosts: [ + '127.0.0.1', + 'localhost', + ], + }, + optimization: { + minimize: false, + splitChunks: { + chunks: 'all', + cacheGroups: { + defaultVendors: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + }, + commons: { + test: /[\\/]packages[\\/]/, + name: 'commons', + chunks: 'all', + minChunks: 2, + }, + }, + } + }, +}; +`; +}; diff --git a/utils/demo/helpers/addUploadToToolbar.ts b/utils/demo/helpers/addUploadToToolbar.ts index 4260daf8a5..55d929ce42 100644 --- a/utils/demo/helpers/addUploadToToolbar.ts +++ b/utils/demo/helpers/addUploadToToolbar.ts @@ -1,18 +1,23 @@ +import { getRenderingEngine, imageLoader, metaData } from '@cornerstonejs/core'; +import dicomImageLoader from '@cornerstonejs/dicom-image-loader'; +import { utilities as metadataUtilities } from '@cornerstonejs/metadata'; + import createElement, { configElement } from './createElement'; import addButtonToToolbar from './addButtonToToolbar'; +import { renderingEngineId, viewportId } from './constants'; interface configUpload extends configElement { id?: string; - title: string; + title?: string; container?: HTMLElement; - onChange: (files: FileList) => void; + onChange?: (files: FileList) => void; input?: configElement; } -export default function addUploadToToolbar(config: configUpload): void { +export function addUploadToToolbar(config: configUpload = {}): void { config.container = config.container ?? document.getElementById('demo-toolbar'); - + config.onChange ||= onUpload; // const fnClick = () => { // @@ -46,7 +51,112 @@ export default function addUploadToToolbar(config: configUpload): void { // addButtonToToolbar({ merge: config, - title: config.title, + title: config.title || 'Local file', onClick: fnClick, }); } + +export let imageIds = new Array(); +export let loadImageListener = (_viewportInfo) => { + console.warn('Loaded', imageIds.length, 'images'); +}; + +export async function onUpload(files) { + const filesArray = [...files]; + imageIds = filesArray.map((file) => + dicomImageLoader.wadouri.fileManager.add(file) + ); + + // Parse binary DICOM and cache metadata in NATURAL + await Promise.all( + filesArray.map(async (file, index) => { + const arrayBuffer = await file.arrayBuffer(); + await metadataUtilities.addDicomPart10Instance(imageIds[index], arrayBuffer); + }) + ); + + loadAndViewImages(imageIds); +} + +export function setImageIds(newImageIds: string[]) { + imageIds = newImageIds; +} + +export function setLoadImageListener(listener) { + loadImageListener = listener; +} + +export function getViewport(viewportInfo?) { + const renderingEngine = getRenderingEngine( + viewportInfo?.renderingEngineId || renderingEngineId + ); + + // Get the volume viewport + const viewport = renderingEngine.getViewport( + viewportInfo?.viewportId || viewportId + ); + return viewport; +} + +export function loadAndViewImages(imageIdsBase, viewportInfo?) { + if (imageIdsBase[0]?.startsWith('dicom') !== true) { + return loadAndViewImagesSingle(imageIdsBase, viewportInfo); + } + const promise = Promise.all(imageLoader.loadAndCacheImages(imageIdsBase)); + promise.then((datasets) => { + const result = new Array(); + for (const imageId of imageIdsBase) { + const instance = metaData.get('instance', imageId); + const numberOfFrames = instance?.NumberOfFrames; + if (numberOfFrames > 1) { + for (let frame = 1; frame <= numberOfFrames; frame++) { + result.push(`${imageId}?frame=${frame}`); + } + } else { + result.push(imageId); + } + } + loadAndViewImagesSingle(result, viewportInfo); + }); +} + +export function loadAndViewImagesSingle(imageIds, viewportInfo?) { + // Set the stack on the viewport + setImageIds(imageIds); + + const viewport = getViewport(viewportInfo); + + viewport.setStack(imageIds).then(() => { + // Set the VOI of the stack + // viewport.setProperties({ voiRange: ctVoiRange }); + // Render the image + viewport.render(); + + loadImageListener(viewportInfo); + }); +} + +// this function gets called once the user drops the file onto the div +export async function handleFileSelect(evt) { + evt.stopPropagation(); + evt.preventDefault(); + + // Get the FileList object that contains the list of files that were dropped + const files = [...evt.dataTransfer.files]; + + imageIds = files.map((file) => + dicomImageLoader.wadouri.fileManager.add(file) + ); + + // Parse binary DICOM and cache metadata in NATURAL + await Promise.all( + files.map(async (file, index) => { + const arrayBuffer = await file.arrayBuffer(); + await metadataUtilities.addDicomPart10Instance(imageIds[index], arrayBuffer); + }) + ); + + loadAndViewImages(imageIds); +} + +export default addUploadToToolbar; diff --git a/utils/demo/helpers/constants.ts b/utils/demo/helpers/constants.ts new file mode 100644 index 0000000000..9e8d4b9815 --- /dev/null +++ b/utils/demo/helpers/constants.ts @@ -0,0 +1,6 @@ +/** + * This file contains constants for shared/default rendering id values. + */ +export const toolGroupId = 'exampleToolGroup'; +export const renderingEngineId = 'exampleRenderingEngine'; +export const viewportId = 'exampleViewportMain'; diff --git a/utils/demo/helpers/createImageIdsAndCacheMetaData.js b/utils/demo/helpers/createImageIdsAndCacheMetaData.js index 01d97fd980..1916ae17e1 100644 --- a/utils/demo/helpers/createImageIdsAndCacheMetaData.js +++ b/utils/demo/helpers/createImageIdsAndCacheMetaData.js @@ -1,151 +1,158 @@ -import { api } from 'dicomweb-client'; -import dcmjs from 'dcmjs'; -import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv'; -import { getPTImageIdInstanceMetadata } from './getPTImageIdInstanceMetadata'; -import { utilities } from '@cornerstonejs/core'; -import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; - -import ptScalingMetaDataProvider from './ptScalingMetaDataProvider'; -import { convertMultiframeImageIds } from './convertMultiframeImageIds'; -import removeInvalidTags from './removeInvalidTags'; - -const { DicomMetaDictionary } = dcmjs.data; -const { calibratedPixelSpacingMetadataProvider, getPixelSpacingInformation } = - utilities; - -/** -/** - * Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone, - * and return a list of imageIds for the frames. - * - * Uses the app config to choose which study to fetch, and which - * dicom-web server to fetch it from. - * - * @returns {string[]} An array of imageIds for instances in the study. - */ - -export default async function createImageIdsAndCacheMetaData({ - StudyInstanceUID, - SeriesInstanceUID, - SOPInstanceUID = null, - wadoRsRoot, - client = null, - convertMultiframe = true, -}) { - const SOP_INSTANCE_UID = '00080018'; - const SERIES_INSTANCE_UID = '0020000E'; - const MODALITY = '00080060'; - - const studySearchOptions = { - studyInstanceUID: StudyInstanceUID, - seriesInstanceUID: SeriesInstanceUID, - }; - - client = client || new api.DICOMwebClient({ url: wadoRsRoot }); - let instances = await client.retrieveSeriesMetadata(studySearchOptions); - - // if sop instance is provided we should filter the instances to only include the one we want - if (SOPInstanceUID) { - instances = instances.filter((instance) => { - return instance[SOP_INSTANCE_UID].Value[0] === SOPInstanceUID; - }); - } - - const modality = instances[0][MODALITY].Value[0]; - let imageIds = instances.map((instanceMetaData) => { - const SeriesInstanceUID = instanceMetaData[SERIES_INSTANCE_UID].Value[0]; - const SOPInstanceUIDToUse = - SOPInstanceUID || instanceMetaData[SOP_INSTANCE_UID].Value[0]; - - const prefix = 'wadors:'; - - const imageId = - prefix + - wadoRsRoot + - '/studies/' + - StudyInstanceUID.trim() + - '/series/' + - SeriesInstanceUID.trim() + - '/instances/' + - SOPInstanceUIDToUse.trim() + - '/frames/1'; - - cornerstoneDICOMImageLoader.wadors.metaDataManager.add( - imageId, - instanceMetaData - ); - return imageId; - }); - - // if the image ids represent multiframe information, creates a new list with one image id per frame - // if not multiframe data available, just returns the same list given - if (convertMultiframe) { - imageIds = convertMultiframeImageIds(imageIds); - } - - imageIds.forEach((imageId) => { - let instanceMetaData = - cornerstoneDICOMImageLoader.wadors.metaDataManager.get(imageId); - - if (!instanceMetaData) { - return; - } - - // It was using JSON.parse(JSON.stringify(...)) before but it is 8x slower - instanceMetaData = removeInvalidTags(instanceMetaData); - - if (instanceMetaData) { - // Add calibrated pixel spacing - const metadata = DicomMetaDictionary.naturalizeDataset(instanceMetaData); - const pixelSpacingInformation = getPixelSpacingInformation(metadata); - const pixelSpacing = pixelSpacingInformation?.PixelSpacing; - - if (pixelSpacing) { - calibratedPixelSpacingMetadataProvider.add(imageId, { - rowPixelSpacing: parseFloat(pixelSpacing[0]), - columnPixelSpacing: parseFloat(pixelSpacing[1]), - type: pixelSpacingInformation.type, - }); - } - } - }); - - // we don't want to add non-pet - // Note: for 99% of scanners SUV calculation is consistent bw slices - if (modality === 'PT') { - const InstanceMetadataArray = []; - imageIds.forEach((imageId) => { - const instanceMetadata = getPTImageIdInstanceMetadata(imageId); - - // TODO: Temporary fix because static-wado is producing a string, not an array of values - // (or maybe dcmjs isn't parsing it correctly?) - // It's showing up like 'DECY\\ATTN\\SCAT\\DTIM\\RAN\\RADL\\DCAL\\SLSENS\\NORM' - // but calculate-suv expects ['DECY', 'ATTN', ...] - if (typeof instanceMetadata.CorrectedImage === 'string') { - instanceMetadata.CorrectedImage = - instanceMetadata.CorrectedImage.split('\\'); - } - - if (instanceMetadata) { - InstanceMetadataArray.push(instanceMetadata); - } - }); - if (InstanceMetadataArray.length) { - try { - const suvScalingFactors = calculateSUVScalingFactors( - InstanceMetadataArray - ); - InstanceMetadataArray.forEach((instanceMetadata, index) => { - ptScalingMetaDataProvider.addInstance( - imageIds[index], - suvScalingFactors[index] - ); - }); - } catch (error) { - console.log(error); - } - } - } - - return imageIds; -} +import { api } from 'dicomweb-client'; +import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv'; +import { getPTImageIdInstanceMetadata } from './getPTImageIdInstanceMetadata'; +import { utilities, metaData } from '@cornerstonejs/core'; +import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; +import { + utilities as metadataUtilities, + Enums as metadataEnums, +} from '@cornerstonejs/metadata'; + +import ptScalingMetaDataProvider from './ptScalingMetaDataProvider'; +import { convertMultiframeImageIds } from './convertMultiframeImageIds'; + +const { calibratedPixelSpacingMetadataProvider, getPixelSpacingInformation } = + utilities; + +const { addDicomWebInstance, Tags } = metadataUtilities; +const { MetadataModules } = metadataEnums; + +const SOP_INSTANCE_UID = Tags.resolveHexFromKeyword('SOPInstanceUID'); +const SERIES_INSTANCE_UID = Tags.resolveHexFromKeyword('SeriesInstanceUID'); +const MODALITY = Tags.resolveHexFromKeyword('Modality'); + +/** + * Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone, + * and return a list of imageIds for the frames. + * + * Uses the app config to choose which study to fetch, and which + * dicom-web server to fetch it from. + * + * @param {object} options + * @param {boolean} [options.useLegacyWadoRs=false] - When true, store instances only in the legacy wadors metaDataManager; when false, use the typed metadata framework (@cornerstonejs/metadata) + * @returns {string[]} An array of imageIds for instances in the study. + */ + +export default async function createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID = null, + wadoRsRoot, + client = null, + convertMultiframe = true, + useLegacyWadoRs = false, +}) { + const studySearchOptions = { + studyInstanceUID: StudyInstanceUID, + seriesInstanceUID: SeriesInstanceUID, + }; + + client = client || new api.DICOMwebClient({ url: wadoRsRoot }); + let instances = await client.retrieveSeriesMetadata(studySearchOptions); + + // if sop instance is provided we should filter the instances to only include the one we want + if (SOPInstanceUID) { + instances = instances.filter((instance) => { + return instance[SOP_INSTANCE_UID].Value[0] === SOPInstanceUID; + }); + } + + const modality = instances[0][MODALITY].Value[0]; + let imageIds = instances.map((instanceMetaData) => { + const SeriesInstanceUID = instanceMetaData[SERIES_INSTANCE_UID].Value[0]; + const SOPInstanceUIDToUse = + SOPInstanceUID || instanceMetaData[SOP_INSTANCE_UID].Value[0]; + + const prefix = 'wadors:'; + + const imageId = + prefix + + wadoRsRoot + + '/studies/' + + StudyInstanceUID.trim() + + '/series/' + + SeriesInstanceUID.trim() + + '/instances/' + + SOPInstanceUIDToUse.trim() + + '/frames/1'; + + if (useLegacyWadoRs) { + cornerstoneDICOMImageLoader.wadors.metaDataManager.add( + imageId, + instanceMetaData + ); + } else { + addDicomWebInstance(imageId, instanceMetaData); + } + + return imageId; + }); + + // if the image ids represent multiframe information, creates a new list with one image id per frame + // if not multiframe data available, just returns the same list given + if (convertMultiframe) { + const originalImageIds = [...imageIds]; + imageIds = convertMultiframeImageIds(imageIds); + } + + if (!useLegacyWadoRs) { + imageIds.forEach((imageId) => { + const instance = metaData.get('instanceOrig', imageId); + + if (!instance) { + return; + } + + // Add calibrated pixel spacing from the naturalized instance + const pixelSpacingInformation = getPixelSpacingInformation(instance); + const pixelSpacing = pixelSpacingInformation?.PixelSpacing; + + if (pixelSpacing) { + calibratedPixelSpacingMetadataProvider.add(imageId, { + rowPixelSpacing: parseFloat(pixelSpacing[0]), + columnPixelSpacing: parseFloat(pixelSpacing[1]), + type: pixelSpacingInformation.type, + }); + } + }); + } + + // we don't want to add non-pet + // Note: for 99% of scanners SUV calculation is consistent bw slices + if (modality === 'PT') { + const InstanceMetadataArray = []; + imageIds.forEach((imageId) => { + const instanceMetadata = getPTImageIdInstanceMetadata(imageId); + + // TODO: Temporary fix because static-wado is producing a string, not an array of values + // (or maybe dcmjs isn't parsing it correctly?) + // It's showing up like 'DECY\\ATTN\\SCAT\\DTIM\\RAN\\RADL\\DCAL\\SLSENS\\NORM' + // but calculate-suv expects ['DECY', 'ATTN', ...] + if (typeof instanceMetadata.CorrectedImage === 'string') { + instanceMetadata.CorrectedImage = + instanceMetadata.CorrectedImage.split('\\'); + } + + if (instanceMetadata) { + InstanceMetadataArray.push(instanceMetadata); + } + }); + if (InstanceMetadataArray.length) { + try { + const suvScalingFactors = calculateSUVScalingFactors( + InstanceMetadataArray + ); + InstanceMetadataArray.forEach((instanceMetadata, index) => { + ptScalingMetaDataProvider.addInstance( + imageIds[index], + suvScalingFactors[index] + ); + }); + } catch (error) { + console.log(error); + } + } + } + + return imageIds; +} diff --git a/utils/demo/helpers/index.js b/utils/demo/helpers/index.js index 935a94e340..c80ef220cb 100644 --- a/utils/demo/helpers/index.js +++ b/utils/demo/helpers/index.js @@ -10,7 +10,7 @@ import addManipulationBindings from './addManipulationBindings'; import addSegmentIndexDropdown from './addSegmentIndexDropdown'; import addSliderToToolbar from './addSliderToToolbar'; import addToggleButtonToToolbar from './addToggleButtonToToolbar'; -import addUploadToToolbar from './addUploadToToolbar'; +export * from './addUploadToToolbar'; import addVideoTime from './addVideoTime'; import annotationTools from './annotationTools'; import camera from './camera'; @@ -18,6 +18,15 @@ import contourSegmentationToolBindings from './contourSegmentationToolBindings'; import contourTools from './contourTools'; import createElement from './createElement'; import createImageIdsAndCacheMetaData from './createImageIdsAndCacheMetaData'; +export { + splitDisplaySetsFromImageIds, + getVideoImageIdFromImageIds, + getPrimaryStackFrameImageIds, + getVolumeFrameImageIds, + get4DDimensionGroupImageIds, + get4DVolumeImageIds, + getNaturalizedInstanceForDisplaySetSplit, +} from './splitDisplaySetsFromImageIds'; import createInfoSection from './createInfoSection'; import downloadSurfacesData from './downloadSurfacesData'; import getLocalUrl from './getLocalUrl'; @@ -38,6 +47,7 @@ import { createAndCacheGeometriesFromSurfaces, } from './createAndCacheGeometriesFromSurfaces'; import { createAndCacheGeometriesFromContours } from './createAndCacheGeometriesFromContours'; +export * from './constants'; export { addBrushSizeSlider, @@ -52,7 +62,6 @@ export { addSegmentIndexDropdown, addSliderToToolbar, addToggleButtonToToolbar, - addUploadToToolbar, addVideoTime, annotationTools, camera, diff --git a/utils/demo/helpers/initDemo.ts b/utils/demo/helpers/initDemo.ts index 2e6f123764..b78acb342d 100644 --- a/utils/demo/helpers/initDemo.ts +++ b/utils/demo/helpers/initDemo.ts @@ -22,7 +22,9 @@ window.cornerstoneTools = cornerstoneTools; export default async function initDemo(config: any = {}) { initProviders(); - cornerstoneDICOMImageLoader.init(); + cornerstoneDICOMImageLoader.init({ + useLegacyMetadataProvider: config?.useLegacyMetadataProvider, + }); initVolumeLoader(); const urlParams = new URLSearchParams(window.location.search); diff --git a/utils/demo/helpers/initProviders.js b/utils/demo/helpers/initProviders.js index 3d12f3f654..ce45c38f33 100644 --- a/utils/demo/helpers/initProviders.js +++ b/utils/demo/helpers/initProviders.js @@ -1,9 +1,13 @@ import * as cornerstone from '@cornerstonejs/core'; +import { registerDefaultProviders } from '@cornerstonejs/metadata'; import ptScalingMetaDataProvider from './ptScalingMetaDataProvider'; const { calibratedPixelSpacingMetadataProvider } = cornerstone.utilities; export default function initProviders() { + // Register the typed metadata provider chain (tagModules, cache, instance bridge, etc.) + registerDefaultProviders(); + cornerstone.metaData.addProvider( ptScalingMetaDataProvider.get.bind(ptScalingMetaDataProvider), 10000 diff --git a/utils/demo/helpers/splitDisplaySetsFromImageIds.ts b/utils/demo/helpers/splitDisplaySetsFromImageIds.ts new file mode 100644 index 0000000000..0370d6d171 --- /dev/null +++ b/utils/demo/helpers/splitDisplaySetsFromImageIds.ts @@ -0,0 +1,211 @@ +import { metaData } from '@cornerstonejs/core'; +import { + createDisplaySetFromGroup, + defaultDisplaySetSplitRules, + splitSeriesInstanceGroupsFromImageIds, + utilities as metadataUtilities, + type IDisplaySet, + type NaturalizedInstance, +} from '@cornerstonejs/metadata'; + +const { splitImageIdsBy4DTags } = metadataUtilities; + +export type Select4DDimensionGroupsOptions = { + /** 1-based inclusive start dimension group (default 1) */ + fromGroup?: number; + /** 1-based inclusive end dimension group (default last group) */ + toGroup?: number; + /** Take the last N dimension groups */ + lastCount?: number; +}; + +function toBaseImageId(imageId: string): string { + const wadorsFrameIndex = imageId.indexOf('/frames/'); + if (wadorsFrameIndex > 0) { + return imageId.slice(0, wadorsFrameIndex + 8) + '1'; + } + + const uriFrameIndex = imageId.indexOf('&frame='); + if (uriFrameIndex > 0) { + return imageId.slice(0, uriFrameIndex + 7) + '1'; + } + + return imageId; +} + +/** + * Naturalized instance for display-set splitting (one row per SOP, base imageId). + */ +export function getNaturalizedInstanceForDisplaySetSplit( + imageId: string +): NaturalizedInstance | undefined { + const instance = metaData.get( + 'instance', + imageId + ) as NaturalizedInstance | undefined; + + if (!instance) { + return undefined; + } + + return { + ...instance, + imageId: toBaseImageId(imageId), + }; +} + +function getInstanceLevelImageIds(imageIds: string[]): string[] { + const bySop = new Map(); + + for (const imageId of imageIds) { + const instance = getNaturalizedInstanceForDisplaySetSplit(imageId); + if (!instance) { + continue; + } + + const sopUid = instance.SOPInstanceUID as string | undefined; + const key = sopUid ?? imageId; + if (!bySop.has(key)) { + bySop.set(key, instance.imageId ?? toBaseImageId(imageId)); + } + } + + return [...bySop.values()]; +} + +function collectFrameImageIdsForGroup( + seriesImageIds: string[], + groupInstances: NaturalizedInstance[] +): string[] { + const sopUids = new Set( + groupInstances + .map(instance => instance.SOPInstanceUID) + .filter(Boolean) as string[] + ); + + if (!sopUids.size) { + return seriesImageIds; + } + + return seriesImageIds.filter(imageId => { + const instance = getNaturalizedInstanceForDisplaySetSplit(imageId); + return instance?.SOPInstanceUID && sopUids.has(instance.SOPInstanceUID); + }); +} + +/** + * Splits a loaded series' imageIds using {@link defaultDisplaySetSplitRules}. + */ +export function splitDisplaySetsFromImageIds( + seriesImageIds: string[] +): IDisplaySet[] { + const instanceLevelImageIds = getInstanceLevelImageIds(seriesImageIds); + + const groups = splitSeriesInstanceGroupsFromImageIds(instanceLevelImageIds, { + getNaturalizedInstance: getNaturalizedInstanceForDisplaySetSplit, + splitRules: defaultDisplaySetSplitRules, + }); + + return groups.map(group => + createDisplaySetFromGroup(group, { + frameImageIds: collectFrameImageIdsForGroup( + seriesImageIds, + group.instances + ), + }) + ); +} + +/** + * Resolves the underlying imageId for a video display set (replaces hard-coded SOP lookup). + */ +export function getVideoImageIdFromImageIds( + seriesImageIds: string[] +): string | undefined { + const displaySets = splitDisplaySetsFromImageIds(seriesImageIds); + const videoDisplaySet = displaySets.find( + displaySet => displaySet.getPreferredViewportType() === 'video' + ); + + if (!videoDisplaySet) { + return undefined; + } + + return [...videoDisplaySet.getUnderlyingImageIds()][0]; +} + +/** + * Frame-level imageIds for the primary stack-oriented display set in a series. + */ +export function getPrimaryStackFrameImageIds( + seriesImageIds: string[] +): string[] { + const displaySets = splitDisplaySetsFromImageIds(seriesImageIds); + + const primaryDisplaySet = + displaySets.find(displaySet => { + const preferred = displaySet.getPreferredViewportType(); + return preferred === 'stack' || preferred === 'volume3d'; + }) ?? displaySets[0]; + + if (!primaryDisplaySet) { + return seriesImageIds; + } + + const frameImageIds = [...primaryDisplaySet.getFrameImageIds()]; + return frameImageIds.length ? frameImageIds : seriesImageIds; +} + +/** + * Frame-level imageIds for the primary volume-oriented display set (volume3d/volume). + */ +export function getVolumeFrameImageIds(seriesImageIds: string[]): string[] { + const displaySets = splitDisplaySetsFromImageIds(seriesImageIds); + + const volumeDisplaySet = + displaySets.find( + displaySet => displaySet.getPreferredViewportType() === 'volume3d' + ) ?? + displaySets.find( + displaySet => displaySet.getPreferredViewportType() === 'volume' + ) ?? + displaySets[0]; + + if (!volumeDisplaySet) { + return seriesImageIds; + } + + const frameImageIds = [...volumeDisplaySet.getFrameImageIds()]; + return frameImageIds.length ? frameImageIds : seriesImageIds; +} + +/** + * Splits a series into 4D dimension groups using DICOM 4D tags + * ({@link splitImageIdsBy4DTags}) after applying default display-set rules. + */ +export function get4DDimensionGroupImageIds(seriesImageIds: string[]): string[][] { + const volumeFrameImageIds = getVolumeFrameImageIds(seriesImageIds); + const { imageIdGroups } = splitImageIdsBy4DTags(volumeFrameImageIds); + return imageIdGroups; +} + +/** + * ImageIds for a dynamic (4D) volume, optionally restricted to dimension groups. + * Group indices are 1-based in `fromGroup` / `toGroup`. + */ +export function get4DVolumeImageIds( + seriesImageIds: string[], + options: Select4DDimensionGroupsOptions = {} +): string[] { + let groups = get4DDimensionGroupImageIds(seriesImageIds); + + if (options.lastCount !== undefined) { + groups = groups.slice(-options.lastCount); + } else if (options.fromGroup !== undefined || options.toGroup !== undefined) { + const fromIndex = Math.max(0, (options.fromGroup ?? 1) - 1); + const toIndex = options.toGroup ?? groups.length; + groups = groups.slice(fromIndex, toIndex); + } + + return groups.flat(); +} diff --git a/utils/fixJSDOMJest.js b/utils/fixJSDOMJest.js index 7ef475750e..6311433df1 100644 --- a/utils/fixJSDOMJest.js +++ b/utils/fixJSDOMJest.js @@ -7,5 +7,12 @@ export default class FixJSDOMEnvironment extends JSDOMEnvironment { // FIXME https://github.com/jsdom/jsdom/issues/3363 this.global.structuredClone = structuredClone; + + // jsdom doesn't provide TextEncoder/TextDecoder + if (!this.global.TextEncoder) { + const { TextEncoder, TextDecoder } = require('util'); + this.global.TextEncoder = TextEncoder; + this.global.TextDecoder = TextDecoder; + } } } diff --git a/utils/test/testUtils.js b/utils/test/testUtils.js index 17c10581e7..74897982e6 100644 --- a/utils/test/testUtils.js +++ b/utils/test/testUtils.js @@ -95,8 +95,10 @@ function setupTestEnvironment({ }; } +const testLog = utilities.logger.getLogger('cs3d', 'test'); + function cleanupTestEnvironment(options = {}) { - console.debug('running cleanupTestEnvironment'); + testLog.debug('running cleanupTestEnvironment'); const { renderingEngineId, toolGroupIds = [], @@ -129,8 +131,7 @@ function cleanupTestEnvironment(options = {}) { // Remove the metadata provider if (removeMetadataProvider && metaData) { - metaData.removeProvider(fakeMetaDataProvider); - metaData.removeProvider(utilities.calibratedPixelSpacingMetadataProvider); + metaData.removeAllProviders(); } // Unregister all image loaders diff --git a/utils/test/testUtilsImageLoader.js b/utils/test/testUtilsImageLoader.js index e2dcc5ac8c..2a54822227 100644 --- a/utils/test/testUtilsImageLoader.js +++ b/utils/test/testUtilsImageLoader.js @@ -96,7 +96,7 @@ const fakeImageLoader = (imageId) => { * @returns metadata based on the imageId and type */ function fakeMetaDataProvider(type, imageId) { - if (!imageId.startsWith('fakeImageLoader')) { + if (!imageId?.startsWith('fakeImageLoader')) { return; } diff --git a/version.json b/version.json index 80ca3d7a12..bd9ddd59f4 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "4.22.9", - "commit": "c0e7efc0619d584e8426cd33517cb89ba3597eec" -} \ No newline at end of file + "version": "5.0.0-beta.2", + "commit": "ccb453f7d6ececc37ac6f5c025565063b91eb86e" +} diff --git a/version.mjs b/version.mjs index 0f92c4e45b..4c50931e65 100644 --- a/version.mjs +++ b/version.mjs @@ -34,10 +34,18 @@ async function run() { if (branchName === 'beta') { console.log('Branch: beta'); const prereleaseComponents = semver.prerelease(currentVersion); - const isBumpBeta = lastCommitMessage.trim().endsWith('[BUMP BETA]'); + const trimmedLastCommitMessage = lastCommitMessage.trim(); + const isBumpBeta = trimmedLastCommitMessage.includes('[BUMP BETA]'); + const isBumpBetaMajor = trimmedLastCommitMessage.includes( + '[BUMP BETA MAJOR]' + ); console.log('isBumpBeta', isBumpBeta); + console.log('isBumpBetaMajor', isBumpBetaMajor); - if ( + if (isBumpBetaMajor) { + console.log('Bumping major version for beta release'); + nextVersion = `${semver.major(currentVersion) + 1}.0.0-beta.1`; + } else if ( prereleaseComponents && prereleaseComponents.includes('beta') && !isBumpBeta diff --git a/version.txt b/version.txt index a102b9019e..1ece2a2b51 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.22.9 \ No newline at end of file +5.0.0-beta.2 diff --git a/yarn.lock b/yarn.lock index 957854b37a..7aafff9124 100644 --- a/yarn.lock +++ b/yarn.lock @@ -82,7 +82,7 @@ "@babel/compat-data@^7.29.3": version "7.29.3" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.3.tgz#e3f5347f0589596c91d227ccb6a541d37fb1307b" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz#e3f5347f0589596c91d227ccb6a541d37fb1307b" integrity sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg== "@babel/core@7.26.10": @@ -271,7 +271,7 @@ "@babel/helper-create-regexp-features-plugin@^7.28.5": version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz#7c1ddd64b2065c7f78034b25b43346a7e19ed997" + resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz#7c1ddd64b2065c7f78034b25b43346a7e19ed997" integrity sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw== dependencies: "@babel/helper-annotate-as-pure" "^7.27.3" @@ -302,7 +302,7 @@ "@babel/helper-define-polyfill-provider@^0.6.8": version "0.6.8" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz#cf1e4462b613f2b54c41e6ff758d5dfcaa2c85d1" + resolved "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz#cf1e4462b613f2b54c41e6ff758d5dfcaa2c85d1" integrity sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA== dependencies: "@babel/helper-compilation-targets" "^7.28.6" @@ -326,7 +326,7 @@ "@babel/helper-member-expression-to-functions@^7.28.5": version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz#f3e07a10be37ed7a63461c63e6929575945a6150" + resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz#f3e07a10be37ed7a63461c63e6929575945a6150" integrity sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg== dependencies: "@babel/traverse" "^7.28.5" @@ -416,7 +416,7 @@ "@babel/helper-plugin-utils@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== "@babel/helper-remap-async-to-generator@^7.27.1": @@ -439,7 +439,7 @@ "@babel/helper-replace-supers@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz#94aa9a1d7423a00aead3f204f78834ce7d53fe44" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz#94aa9a1d7423a00aead3f204f78834ce7d53fe44" integrity sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg== dependencies: "@babel/helper-member-expression-to-functions" "^7.28.5" @@ -587,7 +587,7 @@ "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.28.5": version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz#fbde57974707bbfa0376d34d425ff4fa6c732421" + resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz#fbde57974707bbfa0376d34d425ff4fa6c732421" integrity sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -595,21 +595,21 @@ "@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz#43f70a6d7efd52370eefbdf55ae03d91b293856d" + resolved "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz#43f70a6d7efd52370eefbdf55ae03d91b293856d" integrity sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz#beb623bd573b8b6f3047bd04c32506adc3e58a72" + resolved "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz#beb623bd573b8b6f3047bd04c32506adc3e58a72" integrity sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@^7.29.3": version "7.29.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz#2e14f9335803d892ccb67ef487e23cf9726156fe" + resolved "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz#2e14f9335803d892ccb67ef487e23cf9726156fe" integrity sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -617,7 +617,7 @@ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz#e134a5479eb2ba9c02714e8c1ebf1ec9076124fd" + resolved "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz#e134a5479eb2ba9c02714e8c1ebf1ec9076124fd" integrity sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -626,7 +626,7 @@ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz#0e8289cec28baaf05d54fd08d81ae3676065f69f" + resolved "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz#0e8289cec28baaf05d54fd08d81ae3676065f69f" integrity sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -693,7 +693,7 @@ "@babel/plugin-syntax-import-assertions@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz#ae9bc1923a6ba527b70104dd2191b0cd872c8507" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz#ae9bc1923a6ba527b70104dd2191b0cd872c8507" integrity sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -707,7 +707,7 @@ "@babel/plugin-syntax-import-attributes@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz#b71d5914665f60124e133696f17cd7669062c503" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz#b71d5914665f60124e133696f17cd7669062c503" integrity sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -796,6 +796,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-typescript@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz#c7b2ddf1d0a811145b1de800d1abd146af92e3a2" + integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-syntax-unicode-sets-regex@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357" @@ -806,14 +813,14 @@ "@babel/plugin-transform-arrow-functions@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a" + resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a" integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-async-generator-functions@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz#63ed829820298f0bf143d5a4a68fb8c06ffd742f" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz#63ed829820298f0bf143d5a4a68fb8c06ffd742f" integrity sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -822,7 +829,7 @@ "@babel/plugin-transform-async-to-generator@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz#bd97b42237b2d1bc90d74bcb486c39be5b4d7e77" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz#bd97b42237b2d1bc90d74bcb486c39be5b4d7e77" integrity sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g== dependencies: "@babel/helper-module-imports" "^7.28.6" @@ -831,21 +838,21 @@ "@babel/plugin-transform-block-scoped-functions@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz#558a9d6e24cf72802dd3b62a4b51e0d62c0f57f9" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz#558a9d6e24cf72802dd3b62a4b51e0d62c0f57f9" integrity sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-block-scoping@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz#e1ef5633448c24e76346125c2534eeb359699a99" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz#e1ef5633448c24e76346125c2534eeb359699a99" integrity sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw== dependencies: "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-class-properties@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz#d274a4478b6e782d9ea987fda09bdb6d28d66b72" + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz#d274a4478b6e782d9ea987fda09bdb6d28d66b72" integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== dependencies: "@babel/helper-create-class-features-plugin" "^7.28.6" @@ -861,7 +868,7 @@ "@babel/plugin-transform-class-static-block@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz#1257491e8259c6d125ac4d9a6f39f9d2bf3dba70" + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz#1257491e8259c6d125ac4d9a6f39f9d2bf3dba70" integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ== dependencies: "@babel/helper-create-class-features-plugin" "^7.28.6" @@ -869,7 +876,7 @@ "@babel/plugin-transform-classes@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz#8f6fb79ba3703978e701ce2a97e373aae7dda4b7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz#8f6fb79ba3703978e701ce2a97e373aae7dda4b7" integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== dependencies: "@babel/helper-annotate-as-pure" "^7.27.3" @@ -881,7 +888,7 @@ "@babel/plugin-transform-computed-properties@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz#936824fc71c26cb5c433485776d79c8e7b0202d2" + resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz#936824fc71c26cb5c433485776d79c8e7b0202d2" integrity sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -889,7 +896,7 @@ "@babel/plugin-transform-destructuring@^7.28.5": version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz#b8402764df96179a2070bb7b501a1586cf8ad7a7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz#b8402764df96179a2070bb7b501a1586cf8ad7a7" integrity sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -897,7 +904,7 @@ "@babel/plugin-transform-dotall-regex@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz#def31ed84e0fb6e25c71e53c124e7b76a4ab8e61" + resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz#def31ed84e0fb6e25c71e53c124e7b76a4ab8e61" integrity sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.28.5" @@ -905,14 +912,14 @@ "@babel/plugin-transform-duplicate-keys@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz#f1fbf628ece18e12e7b32b175940e68358f546d1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz#f1fbf628ece18e12e7b32b175940e68358f546d1" integrity sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz#8014b8a6cfd0e7b92762724443bf0d2400f26df1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz#8014b8a6cfd0e7b92762724443bf0d2400f26df1" integrity sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.28.5" @@ -920,14 +927,14 @@ "@babel/plugin-transform-dynamic-import@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz#4c78f35552ac0e06aa1f6e3c573d67695e8af5a4" + resolved "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz#4c78f35552ac0e06aa1f6e3c573d67695e8af5a4" integrity sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-explicit-resource-management@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz#dd6788f982c8b77e86779d1d029591e39d9d8be7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz#dd6788f982c8b77e86779d1d029591e39d9d8be7" integrity sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -935,21 +942,21 @@ "@babel/plugin-transform-exponentiation-operator@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz#5e477eb7eafaf2ab5537a04aaafcf37e2d7f1091" + resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz#5e477eb7eafaf2ab5537a04aaafcf37e2d7f1091" integrity sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw== dependencies: "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-export-namespace-from@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz#71ca69d3471edd6daa711cf4dfc3400415df9c23" + resolved "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz#71ca69d3471edd6daa711cf4dfc3400415df9c23" integrity sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-for-of@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz#bc24f7080e9ff721b63a70ac7b2564ca15b6c40a" + resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz#bc24f7080e9ff721b63a70ac7b2564ca15b6c40a" integrity sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -957,7 +964,7 @@ "@babel/plugin-transform-function-name@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz#4d0bf307720e4dce6d7c30fcb1fd6ca77bdeb3a7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz#4d0bf307720e4dce6d7c30fcb1fd6ca77bdeb3a7" integrity sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ== dependencies: "@babel/helper-compilation-targets" "^7.27.1" @@ -966,35 +973,35 @@ "@babel/plugin-transform-json-strings@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz#4c8c15b2dc49e285d110a4cf3dac52fd2dfc3038" + resolved "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz#4c8c15b2dc49e285d110a4cf3dac52fd2dfc3038" integrity sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw== dependencies: "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-literals@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz#baaefa4d10a1d4206f9dcdda50d7d5827bb70b24" + resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz#baaefa4d10a1d4206f9dcdda50d7d5827bb70b24" integrity sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-logical-assignment-operators@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz#53028a3d77e33c50ef30a8fce5ca17065936e605" + resolved "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz#53028a3d77e33c50ef30a8fce5ca17065936e605" integrity sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A== dependencies: "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-member-expression-literals@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz#37b88ba594d852418e99536f5612f795f23aeaf9" + resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz#37b88ba594d852418e99536f5612f795f23aeaf9" integrity sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-modules-amd@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz#a4145f9d87c2291fe2d05f994b65dba4e3e7196f" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz#a4145f9d87c2291fe2d05f994b65dba4e3e7196f" integrity sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA== dependencies: "@babel/helper-module-transforms" "^7.27.1" @@ -1010,7 +1017,7 @@ "@babel/plugin-transform-modules-commonjs@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz#c0232e0dfe66a734cc4ad0d5e75fc3321b6fdef1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz#c0232e0dfe66a734cc4ad0d5e75fc3321b6fdef1" integrity sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA== dependencies: "@babel/helper-module-transforms" "^7.28.6" @@ -1018,7 +1025,7 @@ "@babel/plugin-transform-modules-systemjs@^7.29.4": version "7.29.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20" integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w== dependencies: "@babel/helper-module-transforms" "^7.28.6" @@ -1028,7 +1035,7 @@ "@babel/plugin-transform-modules-umd@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz#63f2cf4f6dc15debc12f694e44714863d34cd334" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz#63f2cf4f6dc15debc12f694e44714863d34cd334" integrity sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w== dependencies: "@babel/helper-module-transforms" "^7.27.1" @@ -1036,7 +1043,7 @@ "@babel/plugin-transform-named-capturing-groups-regex@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz#a26cd51e09c4718588fc4cce1c5d1c0152102d6a" + resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz#a26cd51e09c4718588fc4cce1c5d1c0152102d6a" integrity sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.28.5" @@ -1044,28 +1051,28 @@ "@babel/plugin-transform-new-target@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz#259c43939728cad1706ac17351b7e6a7bea1abeb" + resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz#259c43939728cad1706ac17351b7e6a7bea1abeb" integrity sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-nullish-coalescing-operator@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz#9bc62096e90ab7a887f3ca9c469f6adec5679757" + resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz#9bc62096e90ab7a887f3ca9c469f6adec5679757" integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg== dependencies: "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-numeric-separator@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz#1310b0292762e7a4a335df5f580c3320ee7d9e9f" + resolved "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz#1310b0292762e7a4a335df5f580c3320ee7d9e9f" integrity sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w== dependencies: "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-object-rest-spread@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz#fdd4bc2d72480db6ca42aed5c051f148d7b067f7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz#fdd4bc2d72480db6ca42aed5c051f148d7b067f7" integrity sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA== dependencies: "@babel/helper-compilation-targets" "^7.28.6" @@ -1076,7 +1083,7 @@ "@babel/plugin-transform-object-super@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz#1c932cd27bf3874c43a5cac4f43ebf970c9871b5" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz#1c932cd27bf3874c43a5cac4f43ebf970c9871b5" integrity sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1084,7 +1091,7 @@ "@babel/plugin-transform-optional-catch-binding@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz#75107be14c78385978201a49c86414a150a20b4c" + resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz#75107be14c78385978201a49c86414a150a20b4c" integrity sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -1099,7 +1106,7 @@ "@babel/plugin-transform-optional-chaining@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz#926cf150bd421fc8362753e911b4a1b1ce4356cd" + resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz#926cf150bd421fc8362753e911b4a1b1ce4356cd" integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -1119,9 +1126,9 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-private-methods@^7.28.6": +"@babel/plugin-transform-private-methods@7.28.6", "@babel/plugin-transform-private-methods@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz#c76fbfef3b86c775db7f7c106fff544610bdb411" + resolved "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz#c76fbfef3b86c775db7f7c106fff544610bdb411" integrity sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg== dependencies: "@babel/helper-create-class-features-plugin" "^7.28.6" @@ -1129,7 +1136,7 @@ "@babel/plugin-transform-private-property-in-object@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz#4fafef1e13129d79f1d75ac180c52aafefdb2811" + resolved "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz#4fafef1e13129d79f1d75ac180c52aafefdb2811" integrity sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA== dependencies: "@babel/helper-annotate-as-pure" "^7.27.3" @@ -1138,7 +1145,7 @@ "@babel/plugin-transform-property-literals@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz#07eafd618800591e88073a0af1b940d9a42c6424" + resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz#07eafd618800591e88073a0af1b940d9a42c6424" integrity sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1178,14 +1185,14 @@ "@babel/plugin-transform-regenerator@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz#dec237cec1b93330876d6da9992c4abd42c9d18b" + resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz#dec237cec1b93330876d6da9992c4abd42c9d18b" integrity sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog== dependencies: "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-regexp-modifiers@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz#7ef0163bd8b4a610481b2509c58cf217f065290b" + resolved "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz#7ef0163bd8b4a610481b2509c58cf217f065290b" integrity sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.28.5" @@ -1193,7 +1200,7 @@ "@babel/plugin-transform-reserved-words@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz#40fba4878ccbd1c56605a4479a3a891ac0274bb4" + resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz#40fba4878ccbd1c56605a4479a3a891ac0274bb4" integrity sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1212,14 +1219,14 @@ "@babel/plugin-transform-shorthand-properties@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90" + resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90" integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-spread@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz#40a2b423f6db7b70f043ad027a58bcb44a9757b6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz#40a2b423f6db7b70f043ad027a58bcb44a9757b6" integrity sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA== dependencies: "@babel/helper-plugin-utils" "^7.28.6" @@ -1227,25 +1234,36 @@ "@babel/plugin-transform-sticky-regex@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz#18984935d9d2296843a491d78a014939f7dcd280" + resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz#18984935d9d2296843a491d78a014939f7dcd280" integrity sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-template-literals@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8" + resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8" integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-typeof-symbol@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz#70e966bb492e03509cf37eafa6dcc3051f844369" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz#70e966bb492e03509cf37eafa6dcc3051f844369" integrity sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw== dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-typescript@7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz#1e93d96da8adbefdfdade1d4956f73afa201a158" + integrity sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.28.6" + "@babel/plugin-transform-typescript@^7.25.9": version "7.28.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz#796cbd249ab56c18168b49e3e1d341b72af04a6b" @@ -1259,14 +1277,14 @@ "@babel/plugin-transform-unicode-escapes@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz#3e3143f8438aef842de28816ece58780190cf806" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz#3e3143f8438aef842de28816ece58780190cf806" integrity sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-unicode-property-regex@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz#63a7a6c21a0e75dae9b1861454111ea5caa22821" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz#63a7a6c21a0e75dae9b1861454111ea5caa22821" integrity sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.28.5" @@ -1274,7 +1292,7 @@ "@babel/plugin-transform-unicode-regex@^7.27.1": version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97" integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.27.1" @@ -1282,7 +1300,7 @@ "@babel/plugin-transform-unicode-sets-regex@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz#924912914e5df9fe615ec472f88ff4788ce04d4e" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz#924912914e5df9fe615ec472f88ff4788ce04d4e" integrity sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.28.5" @@ -1290,7 +1308,7 @@ "@babel/preset-env@7.29.5": version "7.29.5" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.5.tgz#c48b7ed94582c8b685e21b8b42de8633ec289268" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz#c48b7ed94582c8b685e21b8b42de8633ec289268" integrity sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA== dependencies: "@babel/compat-data" "^7.29.3" @@ -3498,7 +3516,7 @@ "@protobufjs/codegen@^2.0.5": version "2.0.5" - resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + resolved "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== "@protobufjs/eventemitter@^1.1.0": @@ -3525,9 +3543,9 @@ integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== "@protobufjs/inquire@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.1.tgz#6cb936f4ac50965230af1e9d0bbfd57ea3675aa4" - integrity sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew== + version "1.1.2" + resolved "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz#ae64fbc014ff44c8bfad03dd4c93cd2d6a4c82db" + integrity sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw== "@protobufjs/path@^1.1.2": version "1.1.2" @@ -3541,7 +3559,7 @@ "@protobufjs/utf8@^1.1.1": version "1.1.1" - resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" + resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" integrity sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg== "@rollup/plugin-babel@6.0.4": @@ -4999,7 +5017,7 @@ babel-plugin-polyfill-corejs2@^0.4.10: babel-plugin-polyfill-corejs2@^0.4.15: version "0.4.17" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz#198f970f1c99a856b466d1187e88ce30bd199d91" + resolved "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz#198f970f1c99a856b466d1187e88ce30bd199d91" integrity sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w== dependencies: "@babel/compat-data" "^7.28.6" @@ -5016,7 +5034,7 @@ babel-plugin-polyfill-corejs3@^0.11.0: babel-plugin-polyfill-corejs3@^0.14.0: version "0.14.2" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz#6ac08d2f312affb70c4c69c0fbba4cb417ee5587" + resolved "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz#6ac08d2f312affb70c4c69c0fbba4cb417ee5587" integrity sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g== dependencies: "@babel/helper-define-polyfill-provider" "^0.6.8" @@ -5031,7 +5049,7 @@ babel-plugin-polyfill-regenerator@^0.6.1: babel-plugin-polyfill-regenerator@^0.6.6: version "0.6.8" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz#8a6bfd5dd54239362b3d06ce47ac52b2d95d7721" + resolved "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz#8a6bfd5dd54239362b3d06ce47ac52b2d95d7721" integrity sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg== dependencies: "@babel/helper-define-polyfill-provider" "^0.6.8" @@ -6003,7 +6021,7 @@ core-js-compat@^3.40.0: core-js-compat@^3.48.0: version "3.49.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.49.0.tgz#06145447d92f4aaf258a0c44f24b47afaeaffef6" + resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz#06145447d92f4aaf258a0c44f24b47afaeaffef6" integrity sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA== dependencies: browserslist "^4.28.1" @@ -6352,10 +6370,23 @@ dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dcmjs@0.49.4, dcmjs@^0.41.0: - version "0.49.4" - resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.49.4.tgz#833c014eb9e36a6859c80a860b0fb231d9bfeaf8" - integrity sha512-w77Gde5JvLjg37FyGIAsTB+oWZIqkO/5NvBeheEVKy6k9XjBgeGsoAbVAByjuGAFLpGqRbf9iWI0edBq87yu/g== +dcmjs@0.50.1: + version "0.50.1" + resolved "https://registry.npmjs.org/dcmjs/-/dcmjs-0.50.1.tgz#a3f241128579601c8135d2c3209a2756d187b50d" + integrity sha512-858uKIFD8plzv0lPcvZceA/ZGH/wzZf36wFkQToie3pSWp8YRI88dysQYQI1DJxc6AyGPk6eyf6RwKJbhoiQUw== + dependencies: + "@babel/runtime-corejs3" "^7.22.5" + adm-zip "^0.5.10" + gl-matrix "^3.1.0" + lodash.clonedeep "^4.5.0" + loglevel "^1.8.1" + ndarray "^1.0.19" + pako "^2.0.4" + +dcmjs@^0.41.0: + version "0.41.0" + resolved "https://registry.npmjs.org/dcmjs/-/dcmjs-0.41.0.tgz#4804099980ab769f8902a948ad330abdf3b577d2" + integrity sha512-kr46REomItFeWz+0ck4Wif4uS5VVDWVlwdh5GGaCtTYHWfNQmrcCSiQOkrShc7Dc5zP8vNKrHEdORlZXenlg3w== dependencies: "@babel/runtime-corejs3" "^7.22.5" adm-zip "^0.5.10" @@ -7344,7 +7375,7 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: fast-uri@3.1.2, fast-uri@^3.0.1: version "3.1.2" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" + resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16: @@ -8152,7 +8183,7 @@ hasown@^2.0.0, hasown@^2.0.2: hasown@^2.0.3: version "2.0.3" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" integrity sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg== dependencies: function-bind "^1.1.2" @@ -8655,7 +8686,7 @@ is-core-module@^2.16.0: is-core-module@^2.16.1: version "2.16.2" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== dependencies: hasown "^2.0.3" @@ -9669,7 +9700,7 @@ jsesc@~0.5.0: jsesc@~3.1.0: version "3.1.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== json-buffer@3.0.1: @@ -12586,7 +12617,7 @@ promzard@^2.0.0: protobufjs@7.5.8, protobufjs@^7.1.2, protobufjs@^7.2.4: version "7.5.8" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.8.tgz#51b153a06da6e47153a1aa6800cb1253bc502436" + resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz#51b153a06da6e47153a1aa6800cb1253bc502436" integrity sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA== dependencies: "@protobufjs/aspromise" "^1.1.2" @@ -12966,7 +12997,7 @@ regexpu-core@^6.2.0: regexpu-core@^6.3.1: version "6.4.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.4.0.tgz#3580ce0c4faedef599eccb146612436b62a176e5" + resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz#3580ce0c4faedef599eccb146612436b62a176e5" integrity sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA== dependencies: regenerate "^1.4.2" @@ -12990,7 +13021,7 @@ regjsparser@^0.12.0: regjsparser@^0.13.0: version "0.13.1" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.13.1.tgz#0593cbacb27527927692030928ae4d3b878d6f8d" + resolved "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz#0593cbacb27527927692030928ae4d3b878d6f8d" integrity sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw== dependencies: jsesc "~3.1.0" @@ -13101,7 +13132,7 @@ resolve@^1.22.10: resolve@^1.22.11: version "1.22.12" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f" integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA== dependencies: es-errors "^1.3.0"