From a9d508294b301580db3c8d59d4bbb46c8c554d9f Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 25 Oct 2026 11:01:23 -0400 Subject: [PATCH 1/4] refactor: stage DICOM volume loading --- .github/workflows/checks.yml | 4 +- .github/workflows/e2e-datasets.yml | 2 +- .github/workflows/e2e.yml | 2 +- .gitignore | 2 + .prettierignore | 1 - eslint.config.js | 46 +- package-lock.json | 97 ++ package.json | 19 +- packages/dicom/README.md | 188 +++ packages/dicom/__tests__/boundaries.spec.ts | 100 ++ .../__tests__/assignCollectionIds.spec.ts | 331 +++++ .../__tests__/collectionRegistry.spec.ts | 663 ++++++++++ .../collections/__tests__/dicomTags.spec.ts | 14 + .../__tests__/idcSeriesFixtures.ts | 202 +++ .../__tests__/instanceFacts.spec.ts | 404 ++++++ .../__tests__/instanceFactsFixture.ts | 60 + .../__tests__/planDicomCollections.spec.ts | 879 +++++++++++++ .../__tests__/reconstructVolume.spec.ts | 227 ++++ .../splitOverlappingAcquisitions.spec.ts | 577 +++++++++ .../core/collections/assignCollectionIds.ts | 104 ++ .../core/collections/collectionRegistry.ts | 394 ++++++ .../dicom/core/collections/instanceFacts.ts | 198 +++ .../core/collections/multiframeGeometry.ts | 72 ++ .../dicom/core/collections/orientation.ts | 11 + .../core/collections/planDicomCollections.ts | 594 +++++++++ .../core/collections/reconstructVolume.ts | 213 ++++ .../splitOverlappingAcquisitions.ts | 237 ++++ packages/dicom/core/index.ts | 32 + .../metadata/__tests__/dcmjsParser.spec.ts | 236 ++++ .../metadata/__tests__/dicomDataset.spec.ts | 356 ++++++ .../metadata/__tests__/dicomLayout.spec.ts | 63 + .../metadata/__tests__/metadataReader.spec.ts | 67 + packages/dicom/core/metadata/datasetAccess.ts | 30 + packages/dicom/core/metadata/dcmjs.d.ts | 82 ++ packages/dicom/core/metadata/dcmjsParser.ts | 433 +++++++ .../dicom/core/metadata/dicomCharacterSet.ts | 206 +++ packages/dicom/core/metadata/dicomDataset.ts | 244 ++++ packages/dicom/core/metadata/dicomLayout.ts | 90 ++ .../dicom/core/metadata}/dicomTags.ts | 8 + .../dicom/core/metadata/functionalGroups.ts | 54 + .../dicom/core/metadata/modalityRescale.ts | 27 + .../dicom/core/metadata/nmReconstruction.ts | 146 +++ .../core/metadata/readDicomDataset.worker.ts | 5 + .../dicom/core/metadata/spatialMetadata.ts | 60 + .../__tests__/decoderCalibration.spec.ts | 67 + .../core/runtime/__tests__/lifetime.spec.ts | 213 ++++ .../dicom/core/runtime/createDicomLoader.ts | 114 ++ packages/dicom/core/runtime/decoder.ts | 150 +++ packages/dicom/core/runtime/metadataReader.ts | 80 ++ .../streaming/__tests__/blobFetcher.spec.ts | 101 ++ .../streaming/__tests__/byteDeque.spec.ts | 2 +- .../__tests__/cachedStreamFetcher.spec.ts | 21 +- .../__tests__/chunkStateMachine.spec.ts | 2 +- .../streaming/__tests__/requestPool.spec.ts | 20 +- .../__tests__/streamingByteReader.spec.ts | 2 +- packages/dicom/core/streaming/blobFetcher.ts | 67 + .../dicom}/core/streaming/byteDeque.ts | 0 .../core/streaming/cachedStreamFetcher.ts | 59 +- packages/dicom/core/streaming/chunk.ts | 215 ++++ .../core/streaming/chunkStateMachine.ts | 2 +- .../dicom}/core/streaming/concatStreams.ts | 0 .../dicom/__tests__/dicomMetaLoader.spec.ts | 28 + .../__tests__/dicomMetaLoaderSource.spec.ts | 270 ++++ .../dicom/__tests__/ultrasoundRegion.spec.ts | 22 +- .../core/streaming/dicom/dicomDataLoader.ts | 33 + .../core/streaming/dicom/dicomMetaLoader.ts | 172 +++ .../core/streaming/dicom/dicomParser.ts | 50 +- .../core/streaming/dicom/ultrasoundRegion.ts | 39 +- .../dicom}/core/streaming/httpCodes.ts | 0 packages/dicom/core/streaming/requestPool.ts | 110 ++ .../dicom/core/streaming}/stateMachine.ts | 0 .../core/streaming/streamingByteReader.ts | 4 +- packages/dicom/core/streaming/types.ts | 59 + packages/dicom/core/utils.ts | 77 ++ .../__tests__/parseContentRangeHeader.spec.ts | 2 +- .../core}/utils/parseContentRangeHeader.ts | 0 packages/dicom/package.json | 38 + packages/dicom/scripts/build.mjs | 70 + packages/dicom/scripts/check-consumers.mjs | 98 ++ packages/dicom/tsconfig.build.json | 23 + packages/dicom/vtk/VtkDicomImage.ts | 619 +++++++++ packages/dicom/vtk/__tests__/consumer.spec.ts | 326 +++++ .../vtk/__tests__/functionalGroups.spec.ts | 522 ++++++++ packages/dicom/vtk/allocateImageFromChunks.ts | 402 ++++++ packages/dicom/vtk/index.ts | 4 + packages/dicom/vtk/lifetime.ts | 11 + packages/dicom/vtk/nmGeometry.ts | 32 + packages/dicom/vtk/pixelValidation.ts | 90 ++ packages/dicom/vtk/thumbnail.ts | 76 ++ packages/dicom/vtk/thumbnailSource.ts | 59 + packages/dicom/vtk/types.ts | 49 + packages/dicom/vtk/ultrasoundSpacing.ts | 91 ++ scripts/checks/complexity.mjs | 2 +- src/components/ImageSpacingDetails.vue | 113 ++ src/components/PatientStudyVolumeBrowser.vue | 99 +- ...tientStudyVolumeBrowser.thumbnails.spec.ts | 339 +++++ .../vtk/VtkBaseVolumeRepresentation.vue | 6 +- src/core/cine/DicomCineImage.ts | 74 +- src/core/progressiveImage.ts | 11 +- .../__tests__/dicomChunkImage.spec.ts | 744 ++++++++--- .../__tests__/dicomThumbnail.spec.ts | 118 ++ src/core/streaming/chunk.ts | 186 --- src/core/streaming/chunkImage.ts | 37 +- .../dicom/__tests__/dicomMetaLoader.spec.ts | 27 - src/core/streaming/dicom/dicomDataLoader.ts | 19 - .../streaming/dicom/dicomFileDataLoader.ts | 15 - .../streaming/dicom/dicomFileMetaLoader.ts | 47 - src/core/streaming/dicom/dicomMetaLoader.ts | 151 --- src/core/streaming/dicomChunkImage.ts | 676 ++-------- src/core/streaming/requestPool.ts | 126 -- src/core/streaming/types.ts | 51 - src/io/__tests__/dicom.spec.ts | 68 + .../__tests__/dicomLoaderUnification.spec.ts | 68 + src/io/__tests__/itkDicomPipeline.spec.ts | 140 ++ .../readDicomTags.differential.spec.ts | 51 + src/io/__tests__/readDicomTags.spec.ts | 476 +++++++ src/io/__tests__/readDicomTagsFixtures.ts | 337 +++++ src/io/__tests__/sourceTree.ts | 38 + src/io/dicom.ts | 165 --- src/io/import/__tests__/dataSource.spec.ts | 2 +- .../__tests__/dicomChunkProvenance.spec.ts | 82 ++ .../__tests__/missingArchiveMember.spec.ts | 20 + .../__tests__/restoreStateFileLeaves.spec.ts | 142 ++- .../import/__tests__/stateFileLeaves.spec.ts | 63 +- src/io/import/dataSource.ts | 6 +- src/io/import/importDataSources.ts | 219 +++- .../processors/__tests__/handleDicom.spec.ts | 192 +++ src/io/import/processors/extractArchive.ts | 45 +- src/io/import/processors/handleDicom.ts | 113 ++ src/io/import/processors/handleDicomFile.ts | 59 - src/io/import/processors/handleDicomStream.ts | 81 -- src/io/import/processors/openUriStream.ts | 38 +- src/io/import/processors/restoreStateFile.ts | 101 +- src/io/import/processors/updateUriType.ts | 2 +- src/io/itk-dicom/.gitignore | 5 - src/io/itk-dicom/CMakeLists.txt | 71 -- src/io/itk-dicom/dicom.cpp | 319 ----- src/io/itk-dicom/emscripten-build/dicom.js | 16 - src/io/itk-dicom/emscripten-build/dicom.wasm | Bin 7777161 -> 0 bytes .../itk-dicom/emscripten-build/dicom.wasm.zst | Bin 2202242 -> 0 bytes src/io/itk/worker.ts | 18 +- src/io/magic.ts | 2 +- src/io/readDicomTags.ts | 111 ++ src/io/zip.ts | 3 +- .../__tests__/artifactParentRestore.spec.ts | 11 +- .../datasets-dicom-acquisition-split.spec.ts | 234 ++++ .../__tests__/datasets-dicom-cine.spec.ts | 248 ++-- .../__tests__/datasets-dicom-geometry.spec.ts | 119 ++ .../__tests__/datasets-dicom-lifetime.spec.ts | 389 ++++++ .../__tests__/datasets-dicom-reimport.spec.ts | 355 ++++-- .../datasets-dicom-transaction.spec.ts | 804 ++++++++++++ src/store/__tests__/dicom-web-store.spec.ts | 107 ++ src/store/__tests__/dicomImportFixtures.ts | 210 +++ src/store/__tests__/dicomTagFixtures.ts | 2 +- .../__tests__/duplicateDatasetSave.spec.ts | 118 +- src/store/datasets-dicom.ts | 654 ++++++++-- src/store/datasets.ts | 64 +- src/store/dicom-import-session.ts | 86 ++ src/store/dicom-runtime.ts | 21 + src/store/dicom-web/dicom-web-store.ts | 57 +- src/store/view-configs/layers.ts | 5 +- src/types/image.ts | 7 +- src/utils/__tests__/dicomWindowRange.spec.ts | 2 +- src/utils/__tests__/fetch.spec.ts | 15 +- src/utils/allocateImageFromChunks.ts | 300 ----- .../__tests__/allocateImageFromChunks.spec.ts | 181 ++- src/utils/dicom/dicomChunks.ts | 7 + src/utils/dicomWindowRange.ts | 2 +- src/utils/index.ts | 19 - tests/datasets.ts | 111 +- tests/dicomCompositionCorpus.json | 1129 +++++++++++++++++ tests/dicomConsumer.ts | 23 + tests/dicomDataset.ts | 18 + tests/dicomOrientationFixtures.ts | 19 + tests/dicomSourceFixtures.ts | 141 ++ tests/dicomVolumeCorpus.json | 853 +++++++++++++ .../fixtures/dicom-consumer/composition.html | 13 + tests/fixtures/dicom-consumer/composition.ts | 114 ++ .../dicom-consumer/compositionReference.py | 77 ++ tests/fixtures/dicom-consumer/core.html | 25 + tests/fixtures/dicom-consumer/core.ts | 45 + tests/fixtures/dicom-consumer/corpus.html | 12 + tests/fixtures/dicom-consumer/corpus.ts | 43 + .../fixtures/dicom-consumer/describeVolume.ts | 29 + .../dicom-consumer/enhancedFixtures.ts | 100 ++ tests/fixtures/dicom-consumer/fixtureData.ts | 44 + tests/fixtures/dicom-consumer/loadVolume.ts | 41 + tests/fixtures/dicom-consumer/server.mjs | 105 ++ tests/fixtures/dicom-consumer/shared.ts | 48 + tests/fixtures/dicom-consumer/vtk.html | 25 + tests/fixtures/dicom-consumer/vtk.ts | 47 + tests/pageobjects/volview.page.ts | 70 +- tests/specs/annotationTestUtils.ts | 23 +- tests/specs/crosshairs-multi-image.e2e.ts | 77 +- tests/specs/dicom-archive-state.e2e.ts | 107 ++ tests/specs/dicom-dimension-mismatch.e2e.ts | 196 +-- tests/specs/dicom-frame-reconstruction.e2e.ts | 137 ++ tests/specs/dicom-import-regressions.e2e.ts | 100 ++ tests/specs/dicom-japanese-name.e2e.ts | 113 ++ tests/specs/dicom-package.e2e.ts | 59 + tests/specs/dicom-spacing-details.e2e.ts | 179 +++ tests/specs/dicom-volume-composition.e2e.ts | 116 ++ tests/specs/dicom-volume-corpus.e2e.ts | 156 +++ tests/specs/dicomRenderedSlice.ts | 128 ++ .../incremental-positionless-import.e2e.ts | 116 ++ tests/specs/layers-and-rendering.e2e.ts | 3 + tests/specs/multi-acquisition-series.e2e.ts | 83 ++ tests/specs/pet-ct-sample.e2e.ts | 42 +- tests/specs/sample-rendering.e2e.ts | 23 +- .../specs/session-overlapping-imports.e2e.ts | 109 ++ tests/specs/syntheticDicom.ts | 300 ++++- tests/specs/utils.ts | 34 + tsconfig.json | 12 +- vite.config.ts | 10 +- 214 files changed, 23915 insertions(+), 3522 deletions(-) create mode 100644 packages/dicom/README.md create mode 100644 packages/dicom/__tests__/boundaries.spec.ts create mode 100644 packages/dicom/core/collections/__tests__/assignCollectionIds.spec.ts create mode 100644 packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts create mode 100644 packages/dicom/core/collections/__tests__/dicomTags.spec.ts create mode 100644 packages/dicom/core/collections/__tests__/idcSeriesFixtures.ts create mode 100644 packages/dicom/core/collections/__tests__/instanceFacts.spec.ts create mode 100644 packages/dicom/core/collections/__tests__/instanceFactsFixture.ts create mode 100644 packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts create mode 100644 packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts create mode 100644 packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts create mode 100644 packages/dicom/core/collections/assignCollectionIds.ts create mode 100644 packages/dicom/core/collections/collectionRegistry.ts create mode 100644 packages/dicom/core/collections/instanceFacts.ts create mode 100644 packages/dicom/core/collections/multiframeGeometry.ts create mode 100644 packages/dicom/core/collections/orientation.ts create mode 100644 packages/dicom/core/collections/planDicomCollections.ts create mode 100644 packages/dicom/core/collections/reconstructVolume.ts create mode 100644 packages/dicom/core/collections/splitOverlappingAcquisitions.ts create mode 100644 packages/dicom/core/index.ts create mode 100644 packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts create mode 100644 packages/dicom/core/metadata/__tests__/dicomDataset.spec.ts create mode 100644 packages/dicom/core/metadata/__tests__/dicomLayout.spec.ts create mode 100644 packages/dicom/core/metadata/__tests__/metadataReader.spec.ts create mode 100644 packages/dicom/core/metadata/datasetAccess.ts create mode 100644 packages/dicom/core/metadata/dcmjs.d.ts create mode 100644 packages/dicom/core/metadata/dcmjsParser.ts create mode 100644 packages/dicom/core/metadata/dicomCharacterSet.ts create mode 100644 packages/dicom/core/metadata/dicomDataset.ts create mode 100644 packages/dicom/core/metadata/dicomLayout.ts rename {src/core => packages/dicom/core/metadata}/dicomTags.ts (89%) create mode 100644 packages/dicom/core/metadata/functionalGroups.ts create mode 100644 packages/dicom/core/metadata/modalityRescale.ts create mode 100644 packages/dicom/core/metadata/nmReconstruction.ts create mode 100644 packages/dicom/core/metadata/readDicomDataset.worker.ts create mode 100644 packages/dicom/core/metadata/spatialMetadata.ts create mode 100644 packages/dicom/core/runtime/__tests__/decoderCalibration.spec.ts create mode 100644 packages/dicom/core/runtime/__tests__/lifetime.spec.ts create mode 100644 packages/dicom/core/runtime/createDicomLoader.ts create mode 100644 packages/dicom/core/runtime/decoder.ts create mode 100644 packages/dicom/core/runtime/metadataReader.ts create mode 100644 packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts rename {src => packages/dicom}/core/streaming/__tests__/byteDeque.spec.ts (97%) rename {src => packages/dicom}/core/streaming/__tests__/cachedStreamFetcher.spec.ts (84%) rename {src => packages/dicom}/core/streaming/__tests__/chunkStateMachine.spec.ts (95%) rename {src => packages/dicom}/core/streaming/__tests__/requestPool.spec.ts (69%) rename {src => packages/dicom}/core/streaming/__tests__/streamingByteReader.spec.ts (95%) create mode 100644 packages/dicom/core/streaming/blobFetcher.ts rename {src => packages/dicom}/core/streaming/byteDeque.ts (100%) rename {src => packages/dicom}/core/streaming/cachedStreamFetcher.ts (81%) create mode 100644 packages/dicom/core/streaming/chunk.ts rename {src => packages/dicom}/core/streaming/chunkStateMachine.ts (94%) rename {src => packages/dicom}/core/streaming/concatStreams.ts (100%) create mode 100644 packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts create mode 100644 packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts rename {src => packages/dicom}/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts (91%) create mode 100644 packages/dicom/core/streaming/dicom/dicomDataLoader.ts create mode 100644 packages/dicom/core/streaming/dicom/dicomMetaLoader.ts rename {src => packages/dicom}/core/streaming/dicom/dicomParser.ts (89%) rename {src => packages/dicom}/core/streaming/dicom/ultrasoundRegion.ts (79%) rename {src => packages/dicom}/core/streaming/httpCodes.ts (100%) create mode 100644 packages/dicom/core/streaming/requestPool.ts rename {src/core => packages/dicom/core/streaming}/stateMachine.ts (100%) rename {src => packages/dicom}/core/streaming/streamingByteReader.ts (96%) create mode 100644 packages/dicom/core/streaming/types.ts create mode 100644 packages/dicom/core/utils.ts rename {src => packages/dicom/core}/utils/__tests__/parseContentRangeHeader.spec.ts (94%) rename {src => packages/dicom/core}/utils/parseContentRangeHeader.ts (100%) create mode 100644 packages/dicom/package.json create mode 100644 packages/dicom/scripts/build.mjs create mode 100644 packages/dicom/scripts/check-consumers.mjs create mode 100644 packages/dicom/tsconfig.build.json create mode 100644 packages/dicom/vtk/VtkDicomImage.ts create mode 100644 packages/dicom/vtk/__tests__/consumer.spec.ts create mode 100644 packages/dicom/vtk/__tests__/functionalGroups.spec.ts create mode 100644 packages/dicom/vtk/allocateImageFromChunks.ts create mode 100644 packages/dicom/vtk/index.ts create mode 100644 packages/dicom/vtk/lifetime.ts create mode 100644 packages/dicom/vtk/nmGeometry.ts create mode 100644 packages/dicom/vtk/pixelValidation.ts create mode 100644 packages/dicom/vtk/thumbnail.ts create mode 100644 packages/dicom/vtk/thumbnailSource.ts create mode 100644 packages/dicom/vtk/types.ts create mode 100644 packages/dicom/vtk/ultrasoundSpacing.ts create mode 100644 src/components/ImageSpacingDetails.vue create mode 100644 src/components/__tests__/PatientStudyVolumeBrowser.thumbnails.spec.ts create mode 100644 src/core/streaming/__tests__/dicomThumbnail.spec.ts delete mode 100644 src/core/streaming/chunk.ts delete mode 100644 src/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts delete mode 100644 src/core/streaming/dicom/dicomDataLoader.ts delete mode 100644 src/core/streaming/dicom/dicomFileDataLoader.ts delete mode 100644 src/core/streaming/dicom/dicomFileMetaLoader.ts delete mode 100644 src/core/streaming/dicom/dicomMetaLoader.ts delete mode 100644 src/core/streaming/requestPool.ts delete mode 100644 src/core/streaming/types.ts create mode 100644 src/io/__tests__/dicom.spec.ts create mode 100644 src/io/__tests__/dicomLoaderUnification.spec.ts create mode 100644 src/io/__tests__/itkDicomPipeline.spec.ts create mode 100644 src/io/__tests__/readDicomTags.differential.spec.ts create mode 100644 src/io/__tests__/readDicomTags.spec.ts create mode 100644 src/io/__tests__/readDicomTagsFixtures.ts create mode 100644 src/io/__tests__/sourceTree.ts create mode 100644 src/io/import/__tests__/dicomChunkProvenance.spec.ts create mode 100644 src/io/import/processors/__tests__/handleDicom.spec.ts create mode 100644 src/io/import/processors/handleDicom.ts delete mode 100644 src/io/import/processors/handleDicomFile.ts delete mode 100644 src/io/import/processors/handleDicomStream.ts delete mode 100644 src/io/itk-dicom/.gitignore delete mode 100644 src/io/itk-dicom/CMakeLists.txt delete mode 100644 src/io/itk-dicom/dicom.cpp delete mode 100644 src/io/itk-dicom/emscripten-build/dicom.js delete mode 100755 src/io/itk-dicom/emscripten-build/dicom.wasm delete mode 100755 src/io/itk-dicom/emscripten-build/dicom.wasm.zst create mode 100644 src/io/readDicomTags.ts create mode 100644 src/store/__tests__/datasets-dicom-acquisition-split.spec.ts create mode 100644 src/store/__tests__/datasets-dicom-geometry.spec.ts create mode 100644 src/store/__tests__/datasets-dicom-lifetime.spec.ts create mode 100644 src/store/__tests__/datasets-dicom-transaction.spec.ts create mode 100644 src/store/__tests__/dicom-web-store.spec.ts create mode 100644 src/store/__tests__/dicomImportFixtures.ts create mode 100644 src/store/dicom-import-session.ts create mode 100644 src/store/dicom-runtime.ts delete mode 100644 src/utils/allocateImageFromChunks.ts rename src/utils/{ => dicom}/__tests__/allocateImageFromChunks.spec.ts (72%) create mode 100644 src/utils/dicom/dicomChunks.ts create mode 100644 tests/dicomCompositionCorpus.json create mode 100644 tests/dicomConsumer.ts create mode 100644 tests/dicomDataset.ts create mode 100644 tests/dicomOrientationFixtures.ts create mode 100644 tests/dicomSourceFixtures.ts create mode 100644 tests/dicomVolumeCorpus.json create mode 100644 tests/fixtures/dicom-consumer/composition.html create mode 100644 tests/fixtures/dicom-consumer/composition.ts create mode 100644 tests/fixtures/dicom-consumer/compositionReference.py create mode 100644 tests/fixtures/dicom-consumer/core.html create mode 100644 tests/fixtures/dicom-consumer/core.ts create mode 100644 tests/fixtures/dicom-consumer/corpus.html create mode 100644 tests/fixtures/dicom-consumer/corpus.ts create mode 100644 tests/fixtures/dicom-consumer/describeVolume.ts create mode 100644 tests/fixtures/dicom-consumer/enhancedFixtures.ts create mode 100644 tests/fixtures/dicom-consumer/fixtureData.ts create mode 100644 tests/fixtures/dicom-consumer/loadVolume.ts create mode 100644 tests/fixtures/dicom-consumer/server.mjs create mode 100644 tests/fixtures/dicom-consumer/shared.ts create mode 100644 tests/fixtures/dicom-consumer/vtk.html create mode 100644 tests/fixtures/dicom-consumer/vtk.ts create mode 100644 tests/specs/dicom-archive-state.e2e.ts create mode 100644 tests/specs/dicom-frame-reconstruction.e2e.ts create mode 100644 tests/specs/dicom-import-regressions.e2e.ts create mode 100644 tests/specs/dicom-japanese-name.e2e.ts create mode 100644 tests/specs/dicom-package.e2e.ts create mode 100644 tests/specs/dicom-spacing-details.e2e.ts create mode 100644 tests/specs/dicom-volume-composition.e2e.ts create mode 100644 tests/specs/dicom-volume-corpus.e2e.ts create mode 100644 tests/specs/dicomRenderedSlice.ts create mode 100644 tests/specs/incremental-positionless-import.e2e.ts create mode 100644 tests/specs/multi-acquisition-series.e2e.ts create mode 100644 tests/specs/session-overlapping-imports.e2e.ts diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 07799f786..4ad57ece2 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -18,9 +18,11 @@ jobs: cache: npm - run: npm ci - name: Enforce code style - run: npx prettier --list-different "src/**/*.[jt]s" "tests/**/*.[jt]s" "src/**/*.vue" + run: npx prettier --list-different "src/**/*.[jt]s" "tests/**/*.[jt]s" "src/**/*.vue" "packages/dicom/**/*.{ts,mjs,json,md}" - name: Lint run: npm run lint + - name: Build independent DICOM consumers + run: npm run dicom:check - name: Quality ratchets # A file may not get worse than it is on the pull request's target branch. if: github.event_name == 'pull_request' diff --git a/.github/workflows/e2e-datasets.yml b/.github/workflows/e2e-datasets.yml index 4d6f01351..16a259354 100644 --- a/.github/workflows/e2e-datasets.yml +++ b/.github/workflows/e2e-datasets.yml @@ -22,7 +22,7 @@ jobs: uses: actions/cache@v4 with: path: .tmp/datasets - key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }} + key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'tests/dicomVolumeCorpus.json', 'tests/dicomCompositionCorpus.json') }} restore-keys: e2e-datasets- - name: Download missing datasets run: node tests/downloadDatasets.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 43312bf9c..18a784071 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -28,7 +28,7 @@ jobs: uses: actions/cache@v4 with: path: .tmp/datasets - key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }} + key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'tests/dicomVolumeCorpus.json', 'tests/dicomCompositionCorpus.json') }} restore-keys: e2e-datasets- - name: Run E2E on chrome (linux) if: matrix.os == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index dd9be9f03..f1546634b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ reports .github/copilot CLAUDE.md .claude/ + +/packages/dicom/dist/ diff --git a/.prettierignore b/.prettierignore index 6463485b9..da2a92bc8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,5 @@ dist node_modules public -src/io/itk-dicom src/io/resample *.json diff --git a/eslint.config.js b/eslint.config.js index 8b22d955e..9cc858c32 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -64,6 +64,11 @@ const ownFeatureExceptPure = (feature) => { const upperLayerMessage = (feature) => `The ${feature.dir} pure layer must not import stores, components, or upper feature modules. Dependencies point downward only.`; +const dicomPublicSurface = { + regex: '^@/packages/dicom/(?!core(?:/index)?$|vtk(?:/index)?$)', + message: 'Use the DICOM core or vtk public entry point.', +}; + const featureBoundaries = (features) => { const publicSurface = ({ dir }) => ({ group: [`@/src/${dir}/*`, `@/src/${dir}/*/**`, `!@/src/${dir}/index`], @@ -72,8 +77,10 @@ const featureBoundaries = (features) => { const publicFeatures = features.filter( (feature) => feature.publicSurface !== false ); - const otherSurfaces = (feature) => - publicFeatures.filter((other) => other !== feature).map(publicSurface); + const otherSurfaces = (feature) => [ + dicomPublicSurface, + ...publicFeatures.filter((other) => other !== feature).map(publicSurface), + ]; return [ { @@ -82,7 +89,12 @@ const featureBoundaries = (features) => { rules: { 'no-restricted-imports': [ 'error', - { patterns: publicFeatures.map(publicSurface) }, + { + patterns: [ + dicomPublicSurface, + ...publicFeatures.map(publicSurface), + ], + }, ], }, }, @@ -172,10 +184,10 @@ const E2E_TIMEOUT_DEBT = [ export default tseslint.config( { ignores: [ - 'src/io/itk-dicom/emscripten-build/**', 'src/io/resample/emscripten-build/**', '**/*.d.ts', 'dist/**', + 'packages/dicom/dist/**', 'node_modules/**', ], }, @@ -293,7 +305,7 @@ export default tseslint.config( }, }, { - files: ['src/vtk/**/*.{js,ts}'], + files: ['src/vtk/**/*.{js,ts}', 'packages/dicom/vtk/**/*.{js,ts}'], rules: { 'no-param-reassign': [ 'error', @@ -372,8 +384,9 @@ export default tseslint.config( // level because the ratchet only fails a file whose debt grows, so existing // files stay over these numbers without failing `npm run lint`. { - files: ['src/**/*.{js,ts,vue}'], + files: ['src/**/*.{js,ts,vue}', 'packages/dicom/**/*.{js,ts}'], ignores: [ + 'packages/dicom/**/__tests__/**', 'src/**/__tests__/**', 'src/**/*.{spec,test}.{js,ts}', 'src/**/*.d.ts', @@ -389,5 +402,26 @@ export default tseslint.config( ], }, }, + ...['core', 'vtk'].map((entry) => ({ + files: [`packages/dicom/${entry}/**/*.{js,ts}`], + ignores: ['**/__tests__/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + regex: + entry === 'core' + ? '^(@/|@src/|vue(?:/|$)|pinia(?:/|$)|@vueuse/|@kitware/vtk\\.js(?:/|$))' + : '^(@/|@src/|vue(?:/|$)|pinia(?:/|$)|@vueuse/)', + message: + 'DICOM package internals use relative imports and cannot depend on application state or UI frameworks. Core cannot depend on vtk.js.', + }, + ], + }, + ], + }, + })), eslintConfigPrettier ); diff --git a/package-lock.json b/package-lock.json index d545d55c8..44f2f76e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "core-js": "3.47.0", "cors": "^2.8.5", "cross-env": "^10.1.0", + "dcmjs": "^0.52.0", "dicom-parser": "^1.8.21", "dicomweb-client-typed": "^0.8.6", "eslint": "^9.39.1", @@ -543,6 +544,19 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -7643,6 +7657,16 @@ "dev": true, "license": "(Apache-2.0 AND MIT)" }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -9820,6 +9844,21 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-js-pure": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", + "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -10283,6 +10322,39 @@ "dev": true, "license": "MIT" }, + "node_modules/dcmjs": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/dcmjs/-/dcmjs-0.52.0.tgz", + "integrity": "sha512-0/mT9NACKnwnOnkUx7JAqmWxRcUr7HiCoptw1KhN2StNYE0Hy6+tmpI7CU+lJuBc4rFpcMCalvI8QO6JkJ+7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "7.29.2", + "adm-zip": "0.5.17", + "gl-matrix": "3.4.4", + "lodash.clonedeep": "4.5.0", + "loglevel": "1.9.2", + "ndarray": "1.0.19", + "pako": "2.1.0" + }, + "engines": { + "node": ">=22.13" + } + }, + "node_modules/dcmjs/node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dcmjs/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -13296,6 +13368,13 @@ "dev": true, "license": "MIT" }, + "node_modules/iota-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", + "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.7.2", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", @@ -13399,6 +13478,13 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -15916,6 +16002,17 @@ "dev": true, "license": "MIT" }, + "node_modules/ndarray": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", + "integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iota-array": "^1.0.0", + "is-buffer": "^1.0.2" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", diff --git a/package.json b/package.json index a7ef1682f..50f80664e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "files": [ "dist", "src", - "backend-contract" + "backend-contract", + "packages/dicom/core/**/*.ts", + "packages/dicom/vtk/**/*.ts", + "packages/dicom/README.md", + "!packages/dicom/**/__tests__/**" ], "scripts": { "prepare": "node scripts/apply-patches.mjs", @@ -22,17 +26,17 @@ "test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts", "test:e2e:chrome:skip-build": "cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts", "test:e2e:dev": "cross-env HEADED=0 VITE_SHOW_SAMPLE_DATA=true concurrently -P \"npm run dev\" \"wdio run ./wdio.dev.conf.ts --watch {@}\"", - "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\" \"scripts/**/*.mjs\"", - "build:all": "npm run build:dicom && npm run build:resample && npm run build", - "build:dicom": "itk-wasm -s src/io/itk-dicom/ build ", - "build:dicom:debug": "itk-wasm -s src/io/itk-dicom/ build -- -DCMAKE_BUILD_TYPE=Debug", + "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\" \"scripts/**/*.mjs\" \"packages/dicom/**/*.{js,ts,mjs}\"", + "build:all": "npm run build:resample && npm run build", "build:resample": "itk-wasm -s src/io/resample/ build ", "build:resample:debug": "itk-wasm -s src/io/resample/ build -- -DCMAKE_BUILD_TYPE=Debug", "build:watch": "npm run build -- --watch", - "prettify": "prettier --write src tests", + "prettify": "prettier --write src tests packages/dicom", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs" + "docs:preview": "vitepress preview docs", + "dicom:build": "node packages/dicom/scripts/build.mjs", + "dicom:check": "npm run dicom:build && node packages/dicom/scripts/check-consumers.mjs" }, "devDependencies": { "@commitlint/cli": "^21.0.0", @@ -73,6 +77,7 @@ "core-js": "3.47.0", "cors": "^2.8.5", "cross-env": "^10.1.0", + "dcmjs": "^0.52.0", "dicom-parser": "^1.8.21", "dicomweb-client-typed": "^0.8.6", "eslint": "^9.39.1", diff --git a/packages/dicom/README.md b/packages/dicom/README.md new file mode 100644 index 000000000..9a0e79523 --- /dev/null +++ b/packages/dicom/README.md @@ -0,0 +1,188 @@ +# Browser DICOM module + +This private directory uses VolView's root install, lockfile and checks. It has +no workspace install or release workflow. VolView imports the source entry +points; the root npm distribution includes the core and vtk source files +alongside `dist`, `src` and `backend-contract`. Package tests and standalone build +output are excluded from that distribution. + +- `core/index.ts`: Blob and HTTP sources, structured `DicomDataset` metadata, + normalized facts, collection planning and stable identity, owned loading + resources, and calibrated typed pixel arrays. It has no Vue, Pinia or vtk.js + dependency. +- `vtk/index.ts`: `VtkDicomImage`, allocation and validated pixel writes, + reconstruction policy, ranges, image notifications and vtk cleanup. vtk.js is + an optional peer, required only by consumers of this entry point. + +VolView owns its reactive wrappers, messages, browser state, selections, +annotations, layers, import transactions and session provenance. The +application projects structured metadata into flat tag strings where its +existing consumers need that representation. + +## Loading and assets + +`npm run dicom:build` emits ES modules and declarations in `packages/dicom/dist`. +It also emits the parser worker and copies the existing ITK pipeline worker and +GDCM image reader assets. Serve the emitted assets from a URL your application +controls, and pass their locations explicitly: + +```ts +import { createDicomLoader } from '@volview/dicom/core'; + +const loader = createDicomLoader({ + assets: { + pipelineWorkerUrl: new URL( + './assets/itk-wasm-pipeline.min.worker.js', + document.baseURI + ).href, + imageIOUrl: new URL('./assets/image-io', document.baseURI).href, + }, +}); +const chunk = loader.createChunk(file); // Blob, URL, string URL or Fetcher +await chunk.loadMeta(); +const dataset = chunk.metadata; +await chunk.loadData(); +const { image } = await loader.readDicomImage( + new File([chunk.dataBlob!], 'instance.dcm') +); +``` + +The default metadata worker URL is relative to the module and is emitted by +Vite. `metadataWorkerFactory` supplies a worker when a consumer uses another +asset layout. Serve `pipelineWorkerUrl` from the same origin as the application. +`fetch` supports authentication and other request policies; +`requestConcurrency` and `metadataConcurrency` bound their respective pools. +A supplied `decoder` replaces the owned ITK decoder and owns its own resources. +Metadata-only use needs no pixel decoder or WASM assets. + +Streaming means metadata-first reads and progressive whole-instance decoding. +It does not mean arbitrary-byte incremental pixel decoding. The default +`imageImportPolicy: 'complete-header'` reads through the header; +`'skip-radiotherapy'` permits the image-import shortcut that returns after the +Modality tag for RT objects. + +## Ownership and reconstruction + +A loader owns chunks it creates, their requests and its parser/decoder workers. +A supplied Fetcher can belong to only one live chunk; sharing its closeable +stream between chunks is unsupported. +`chunk.stopLoad()` cancels the current read and leaves a retryable state. +`loader.release(chunk)` disposes a chunk; `loader.dispose()` cancels pending +operations and releases all owned resources. These operations settle pending +promises. Disposal drops pixel bytes and active resources but retains parsed +metadata for provenance. Do not release a chunk while another collection still +borrows it. The collection registry borrows chunks and preserves the first +registration of an instance. Its rollback and forget operations do not dispose +chunks. `registry.forget(id)` returns members that are no longer registered so +the owner can release them after removing their consumers. +A registration's `rollback(seriesKeys)` keeps deleted collections forgotten. +When it replays a deletion against restored membership, it returns the removed +members for the owner to release. Restore surviving consumers before releasing +those members, checking `registry.has(chunk)` before disposal. New sources from +the failed batch remain with the caller for retry. + +Each decoded pixel array belongs to the caller. The vtk consumer copies pixels +into its own allocation, validates dimensions and representable values, and +rejects stale decode results after reallocation or disposal. It owns and deletes +its vtk images, but borrows its chunks. Dispose the vtk consumer before releasing +its borrowed chunks, then dispose the loader. + +```ts +import { createDicomCollectionRegistry } from '@volview/dicom/core'; +import { VtkDicomImage } from '@volview/dicom/vtk'; + +const registry = createDicomCollectionRegistry(); +const { updates } = await registry.register(chunks); +const { members, collection } = updates[0]; +const volume = new VtkDicomImage({ + readDicomImage: loader.readDicomImage, + irregularGeometry: 'preserve-order', +}); +await volume.setChunks( + members, + collection.orientation, + collection.reconstruction +); +volume.addEventListener('imageChanged', (image) => mapper.setInputData(image)); +mapper.setInputData(volume.getVtkImageData()); +volume.addEventListener('chunkLoad', () => renderWindow.render()); +volume.startLoad(); +``` + +The collection carries reconstruction classification, reason, spacing and member +slots. Supplied slots must start at zero because the first chunk anchors the +image origin; gaps such as `[0, 2]` are allowed. + +Enhanced instances read image geometry and modality transforms from their +specific shared and per-frame functional groups. Regular spatial frames retain +their stored pixel order, including acquisitions whose frame direction opposes +the row/column cross product. Single-detector reconstructed NM stacks use their +detector patient geometry and signed slice spacing. Gated NM requires temporal +assembly and is rejected by the vtk consumer. + +The default GDCM decoder applies one modality transform to the entire instance +and rejects differing per-frame slopes or intercepts. Injected decoders may +support those transforms. Decoding accepts optional already-parsed metadata +that must describe the same file; otherwise the default decoder reads the +header before decoding. Separate single-frame files may have different transforms. + +OCT shared in-plane spacing is preserved. Calibrated placement between B-scans +requires a matching ophthalmic localizer and remains unsupported. Optical slice +thickness does not define B-scan separation. Vendor-specific ultrasound volume +calibration, RT dose semantics and floating-point parametric maps also require +dedicated import support. VolView skips all RT modalities for both File and HTTP +imports, including RTIMAGE and RTDOSE; these objects do not create a volume. + +The default `preserve-order` policy retains the existing approximate +spacing for irregular geometry. Use `reject` to refuse that fallback. Neither +policy resamples a scan. Multiple files containing multi-frame data, Node +support and plugin registration are outside this package's current scope. + +`volume.getSpatialMetadata()` reports the applied spacing in image x/y/z order. +Each axis records its value, unit (`mm` or `unknown`), source, and whether it was +declared, derived from positions, or supplied as a display fallback. Reconstruction +quality and ultrasound region limitations are separate from the unit. These are +statements about the source and allocation, not independent calibration. +`allocateImageFromChunks()` returns `{ image, spatialMetadata }`; the volume +consumer also accounts for ultrasound spacing overrides. + +Missing physical spacing is permitted for some DICOM objects, including +[secondary captures](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.8.html). +Ordinary patient PixelSpacing and ImagePositionPatient use millimeters without +requiring an additional calibration tag. A fallback value of 1 does not establish +a millimeter scale. A singleton axis does not establish a distance between slices. + +VolView exposes this information through image metadata and the volume menu's +Image spacing entry. Saved DICOM sessions retain their original sources and +recompute the report when restored. Measurement values, annotation coordinates +and their existing unit convention are unchanged. + +## Standalone checks + +`npm run dicom:check` builds the package and typechecks and bundles two small +consumers against its emitted JavaScript and declarations. The build rejects +VolView application imports and Vue/Pinia dependencies, and rejects vtk.js in +the core consumer. Package boundary tests also check relative imports and +declared dependencies. + +`npm run test:e2e:chrome:skip-build -- --spec tests/specs/dicom-package.e2e.ts` +serves those standalone production bundles, generates its DICOM in memory, and +checks Blob/HTTP metadata, calibrated pixel values and vtk geometry through +rendered page output. It also checks the vtk image's rendered screenshot. The +normal headless browser configuration applies. + +`tests/specs/dicom-volume-corpus.e2e.ts` exercises pinned public CT, MR and SPECT +files through both File and HTTP sources, plus numeric-padding fixtures. It +checks dimensions, physical geometry, explicit unsupported-topology errors and +the hash of every calibrated voxel through rendered consumer output. It also +imports enhanced CT and both SPECT volumes in VolView's UI. Public URLs, input +checksums, independently decoded pixel hashes and provenance are recorded in +`tests/dicomVolumeCorpus.json`; downloads use the existing ignored dataset cache. + +`tests/specs/dicom-volume-composition.e2e.ts` composes downloaded classic CT/PET +series and two MR acquisitions sharing one Series UID. It checks every +collection's ordered SOP membership, complete calibrated voxel hash and +physical geometry after shuffled batch and incremental File/HTTP imports. +`tests/dicomCompositionCorpus.json` records the pinned inputs and independent +expectations. The optional `compositionReference.py` beside the consumer +fixtures reproduces those expectations using pydicom and NumPy from the cache. diff --git a/packages/dicom/__tests__/boundaries.spec.ts b/packages/dicom/__tests__/boundaries.spec.ts new file mode 100644 index 000000000..6e3614c41 --- /dev/null +++ b/packages/dicom/__tests__/boundaries.spec.ts @@ -0,0 +1,100 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifest = JSON.parse( + readFileSync(resolve(root, 'package.json'), 'utf8') +); +const dependencies = new Set([ + ...Object.keys(manifest.dependencies), + ...Object.keys(manifest.peerDependencies), +]); +const sourceFiles = (directory: string): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const file = resolve(directory, entry.name); + if (entry.isDirectory()) + return entry.name === '__tests__' ? [] : sourceFiles(file); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') + ? [file] + : []; + }); + +const importsOf = (file: string) => { + const source = ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest + ); + const imports: string[] = []; + const visit = (node: ts.Node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) + imports.push(node.moduleSpecifier.text); + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteral(node.arguments[0]) + ) + imports.push(node.arguments[0].text); + ts.forEachChild(node, visit); + }; + visit(source); + return imports; +}; + +for (const entry of ['core', 'vtk']) { + describe(`DICOM ${entry} dependency boundary`, () => { + const sources = sourceFiles(resolve(root, entry)); + it('has a public entry point', () => { + expect(sources).toContain(resolve(root, entry, 'index.ts')); + }); + it.each(sources.map((file) => relative(root, file)))( + '%s stays inside its declared boundary', + (name) => { + for (const specifier of importsOf(resolve(root, name))) { + if (specifier.startsWith('.')) { + const target = relative( + root, + resolve(dirname(resolve(root, name)), specifier) + ); + expect(target.startsWith('..')).toBe(false); + if (entry === 'core') expect(target.startsWith('vtk/')).toBe(false); + } else { + const dependency = specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/')[0]; + expect(dependencies.has(dependency), specifier).toBe(true); + expect(['vue', 'pinia', '@vueuse/core']).not.toContain(dependency); + if (entry === 'core') + expect(dependency).not.toBe('@kitware/vtk.js'); + } + } + } + ); + }); +} + +it('VolView reaches the package only through its public entry points', () => { + const repository = resolve(root, '../..'); + for (const file of sourceFiles(resolve(repository, 'src'))) { + for (const specifier of importsOf(file)) { + const target = specifier.startsWith('@/') + ? resolve(repository, specifier.slice(2)) + : resolve(dirname(file), specifier); + if (!target.startsWith(`${root}/`)) continue; + expect( + relative(root, target) + .replace(/\.ts$/, '') + .replace(/\/index$/, ''), + relative(repository, file) + ).toMatch(/^(core|vtk)$/); + } + } +}); diff --git a/packages/dicom/core/collections/__tests__/assignCollectionIds.spec.ts b/packages/dicom/core/collections/__tests__/assignCollectionIds.spec.ts new file mode 100644 index 000000000..6efd79e2f --- /dev/null +++ b/packages/dicom/core/collections/__tests__/assignCollectionIds.spec.ts @@ -0,0 +1,331 @@ +import { describe, it, expect } from 'vitest'; +import { assignIds, encodeCollectionKey } from '../assignCollectionIds'; +import type { CommittedCollection } from '../assignCollectionIds'; +import { + ORIENTATION_TOLERANCE, + planDicomCollections, +} from '../planDicomCollections'; +import type { CollectionKey, DicomCollection } from '../planDicomCollections'; +import { + IDENTITY_ORIENTATION, + clone, + makeFacts, + tiltedOrientation, +} from './instanceFactsFixture'; + +const SERIES_KEY = 'series-1'; + +// A second orientation row rotation the bucketing tolerance still accepts. +const WITHIN_TOLERANCE = Math.acos(1 - ORIENTATION_TOLERANCE / 2); + +const keyOf = (orientation: string): CollectionKey => ({ + seriesKey: SERIES_KEY, + parts: [ + ['seriesNumber', '1'], + ['sliceThickness', '1'], + ['orientation', orientation], + ], +}); + +const collectionOf = ( + uids: Array, + key = keyOf('1,0,0,0,1,0') +): DicomCollection => ({ + key, + members: uids.map((uid) => makeFacts(uid)), + order: 'spatial', + reconstruction: { + kind: 'regular', + sliceSpacing: null, + slots: uids.map((_, index) => index), + }, + orientation: [1, 0, 0, 0, 1, 0], + label: null, + diagnostics: [], + warnings: [], +}); + +const planOf = (...collections: DicomCollection[]) => ({ collections }); + +const idsOf = (collections: Array<{ id: string }>) => + collections.map((c) => c.id); + +describe('encodeCollectionKey', () => { + it('encodes equal keys identically', () => { + expect(encodeCollectionKey(keyOf('1,0,0,0,1,0'))).toBe( + encodeCollectionKey(keyOf('1,0,0,0,1,0')) + ); + }); + + // The structured key this encodes: the same characters the encoding joins on, + // smuggled inside a series key, a rule name or a value. + const structured: CollectionKey = { + seriesKey: SERIES_KEY, + parts: [ + ['rows', '4'], + ['columns', '8'], + ], + }; + + const smuggled: Array<[string, CollectionKey]> = [ + [ + 'the series key', + { seriesKey: `${SERIES_KEY}|rows=.4`, parts: [['columns', '8']] }, + ], + [ + 'a rule name', + { seriesKey: SERIES_KEY, parts: [['rows=.4|columns', '8']] }, + ], + ['a value', { seriesKey: SERIES_KEY, parts: [['rows', '4|columns=.8']] }], + ]; + + it.each(smuggled)( + 'does not collide when a separator hides in %s', + (_where, key) => { + expect(encodeCollectionKey(key)).not.toBe( + encodeCollectionKey(structured) + ); + } + ); + + it('separates a missing value from the literal strings for it', () => { + const literals = [null, 'null', '', '~'].map( + (value): CollectionKey => ({ + seriesKey: SERIES_KEY, + parts: [['rows', value]], + }) + ); + + const encodings = literals.map(encodeCollectionKey); + + expect(new Set(encodings).size).toBe(literals.length); + }); + + it('separates keys differing only in series key or rule name', () => { + const base: CollectionKey = { + seriesKey: 'series-1', + parts: [['rows', '4']], + }; + const otherSeries: CollectionKey = { + seriesKey: 'series-2', + parts: [['rows', '4']], + }; + const otherRule: CollectionKey = { + seriesKey: 'series-1', + parts: [['columns', '4']], + }; + + const encodings = [base, otherSeries, otherRule].map(encodeCollectionKey); + + expect(new Set(encodings).size).toBe(3); + }); +}); + +describe('assignIds', () => { + it('mints the encoded key when nothing is committed', () => { + const collection = collectionOf(['a', 'b']); + + const { collections } = assignIds(planOf(collection), []); + + expect(collections[0].id).toBe(encodeCollectionKey(collection.key)); + }); + + it('gives distinct collections distinct ids', () => { + const first = collectionOf(['a'], keyOf('1,0,0,0,1,0')); + const second = collectionOf(['b'], keyOf('0,1,0,0,0,1')); + + const { collections } = assignIds(planOf(first, second), []); + + expect(new Set(idsOf(collections)).size).toBe(2); + }); + + it('keeps the committed id when key and members are unchanged', () => { + const collection = collectionOf(['a', 'b']); + const committed: CommittedCollection[] = [ + { id: 'visible-1', sopInstanceUids: ['a', 'b'] }, + ]; + + const { collections } = assignIds(planOf(collection), committed); + + expect(collections[0].id).toBe('visible-1'); + }); + + it('keeps the committed id when a later batch changes the bucket key', () => { + // A lower-sorting SOP UID takes over as the orientation bucket's + // representative, so the second batch keys the same dataset differently. + const firstBatch = [ + makeFacts('uid-m', { + orientation: IDENTITY_ORIENTATION, + projectedPosition: 0, + }), + makeFacts('uid-n', { + orientation: IDENTITY_ORIENTATION, + projectedPosition: 1, + }), + ]; + const newcomer = makeFacts('uid-a', { + orientation: tiltedOrientation(WITHIN_TOLERANCE), + projectedPosition: 2, + }); + + const before = assignIds( + planDicomCollections({ seriesKey: SERIES_KEY, instances: firstBatch }), + [] + ); + const committed = before.collections.map((collection) => ({ + id: collection.id, + sopInstanceUids: collection.members.flatMap((member) => + member.sopInstanceUid === null ? [] : [member.sopInstanceUid] + ), + })); + const after = assignIds( + planDicomCollections({ + seriesKey: SERIES_KEY, + instances: [...firstBatch, newcomer], + }), + committed + ); + + expect(after.collections).toHaveLength(1); + expect(encodeCollectionKey(after.collections[0].key)).not.toBe( + encodeCollectionKey(before.collections[0].key) + ); + expect(after.collections[0].id).toBe(before.collections[0].id); + expect(after.collections[0].id).not.toBe( + encodeCollectionKey(after.collections[0].key) + ); + }); + + it('gives a committed id to the collection holding most of its members', () => { + const majority = collectionOf(['a', 'b', 'c'], keyOf('1,0,0,0,1,0')); + const remainder = collectionOf(['d'], keyOf('0,1,0,0,0,1')); + const committed: CommittedCollection[] = [ + { id: 'visible-1', sopInstanceUids: ['a', 'b', 'c', 'd'] }, + ]; + + const { collections } = assignIds(planOf(majority, remainder), committed); + + expect(collections[0].id).toBe('visible-1'); + expect(collections[1].id).not.toBe('visible-1'); + }); + + it('mints for both halves of an evenly split committed collection', () => { + const left = collectionOf(['a', 'b'], keyOf('1,0,0,0,1,0')); + const right = collectionOf(['c', 'd'], keyOf('0,1,0,0,0,1')); + const committed: CommittedCollection[] = [ + { id: 'visible-1', sopInstanceUids: ['a', 'b', 'c', 'd'] }, + ]; + + const { collections } = assignIds(planOf(left, right), committed); + + expect(idsOf(collections)).not.toContain('visible-1'); + expect(new Set(idsOf(collections)).size).toBe(2); + }); + + it('mints when nothing overlaps, without reusing a committed id', () => { + const collection = collectionOf(['a']); + const committed: CommittedCollection[] = [ + { id: 'visible-1', sopInstanceUids: ['x', 'y'] }, + ]; + + const { collections } = assignIds(planOf(collection), committed); + + expect(collections[0].id).not.toBe('visible-1'); + expect(collections[0].id).toBe(encodeCollectionKey(collection.key)); + }); + + it('never mints an id already taken by a committed collection', () => { + const collection = collectionOf(['a']); + const committed: CommittedCollection[] = [ + { + id: encodeCollectionKey(collection.key), + sopInstanceUids: ['x'], + }, + ]; + + const { collections } = assignIds(planOf(collection), committed); + + expect(collections[0].id).not.toBe(committed[0].id); + }); + + it('prefers the committed collection it overlaps most', () => { + const collection = collectionOf(['a', 'b', 'c', 'd', 'e']); + const committed: CommittedCollection[] = [ + { id: 'small', sopInstanceUids: ['a', 'b'] }, + { id: 'large', sopInstanceUids: ['c', 'd', 'e'] }, + ]; + + const { collections } = assignIds(planOf(collection), committed); + + expect(collections[0].id).toBe('large'); + }); + + it('breaks an equal overlap on the smallest committed id', () => { + const collection = collectionOf(['a', 'b', 'c', 'd']); + const committed: CommittedCollection[] = [ + { id: 'zeta', sopInstanceUids: ['a', 'b'] }, + { id: 'alpha', sopInstanceUids: ['c', 'd'] }, + ]; + + const { collections } = assignIds(planOf(collection), committed); + + expect(collections[0].id).toBe('alpha'); + }); + + it('measures overlap only on members that have a SOP Instance UID', () => { + const collection = collectionOf([null, 'a']); + const committed: CommittedCollection[] = [ + { id: 'visible-1', sopInstanceUids: ['a'] }, + ]; + + const { collections } = assignIds(planOf(collection), committed); + + expect(collections[0].id).toBe('visible-1'); + }); + + it('mints for a collection whose members all lack a SOP Instance UID', () => { + const collection = collectionOf([null, null]); + const committed: CommittedCollection[] = [ + { id: 'visible-1', sopInstanceUids: ['a'] }, + ]; + + const { collections } = assignIds(planOf(collection), committed); + + expect(collections[0].id).not.toBe('visible-1'); + }); + + it('carries the planned collections through in order, only adding an id', () => { + const first = collectionOf(['a'], keyOf('1,0,0,0,1,0')); + const second: DicomCollection = { + ...collectionOf(['b'], keyOf('0,1,0,0,0,1')), + order: 'instance-number', + diagnostics: ['b has no position'], + }; + + const { collections } = assignIds(planOf(first, second), []); + + expect(collections).toHaveLength(2); + expect(collections[0].key).toEqual(first.key); + expect(collections[0].members).toEqual(first.members); + expect(collections[1].order).toBe('instance-number'); + expect(collections[1].diagnostics).toEqual(['b has no position']); + expect(collections[1].members[0]).toBe(second.members[0]); + }); + + it('does not mutate the plan or the committed collections', () => { + const plan = planOf( + collectionOf(['a', 'b'], keyOf('1,0,0,0,1,0')), + collectionOf(['c'], keyOf('0,1,0,0,0,1')) + ); + const committed: CommittedCollection[] = [ + { id: 'visible-1', sopInstanceUids: ['a', 'b'] }, + ]; + const planBefore = clone(plan); + const committedBefore = clone(committed); + + assignIds(plan, committed); + + expect(clone(plan)).toEqual(planBefore); + expect(clone(committed)).toEqual(committedBefore); + }); +}); diff --git a/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts b/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts new file mode 100644 index 000000000..96d0243b5 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts @@ -0,0 +1,663 @@ +import { datasetFromTags } from '@/tests/dicomDataset'; +import { getDatasetTag } from '../../metadata/datasetAccess'; +import { describe, expect, it } from 'vitest'; +import type { Chunk } from '../../streaming/chunk'; +import { Tags } from '../../metadata/dicomTags'; +import { + createDicomCollectionRegistry, + type CollectionUpdate, +} from '../collectionRegistry'; +import { UNREADABLE_POSITION_WARNING } from '../planDicomCollections'; +import { mergingChunks, tilt } from '@/tests/dicomOrientationFixtures'; + +const SERIES_UID = '1.2.826.0.1.3680043.9.7'; +const OTHER_SERIES_UID = '1.2.826.0.1.3680043.9.8'; + +type SliceOptions = { + sop?: string | null; + series?: string | null; + z?: number | null; + rows?: string; + orientation?: string; + instanceNumber?: number | null; + // Extra tags, keyed 'gggg|eeee'. + tags?: Record; +}; + +let sopCounter = 0; + +function chunkFor(options: SliceOptions = {}) { + const { + sop = `sop-${(sopCounter += 1)}`, + series = SERIES_UID, + z = 0, + rows = '4', + orientation = '1\\0\\0\\0\\1\\0', + instanceNumber = 1, + tags = {}, + } = options; + + const entries: Array<[string, string]> = [ + [Tags.StudyInstanceUID, '1.2.826.0.1.3680043.9.1'], + [Tags.PatientID, 'patient-1'], + [Tags.SeriesNumber, '1'], + [Tags.Modality, 'MR'], + [Tags.Rows, rows], + [Tags.Columns, '4'], + [Tags.SamplesPerPixel, '1'], + [Tags.ImageOrientationPatient, orientation], + ]; + if (series !== null) entries.push([Tags.SeriesInstanceUID, series]); + if (sop !== null) entries.push([Tags.SOPInstanceUID, sop]); + if (z !== null) entries.push([Tags.ImagePositionPatient, `0\\0\\${z}`]); + if (instanceNumber !== null) + entries.push(['0020|0013', String(instanceNumber)]); + entries.push(...Object.entries(tags)); + + return { metadata: datasetFromTags(entries) } as unknown as Chunk; +} + +const sopOf = (chunk: Chunk) => + getDatasetTag(chunk.metadata!, Tags.SOPInstanceUID); + +const sops = (chunks: Chunk[]) => chunks.map(sopOf); + +const byId = (updates: CollectionUpdate[]) => + [...updates].sort((left, right) => (left.id < right.id ? -1 : 1)); + +describe('DICOM collection registry', () => { + it('plans one consistent series into one ordered collection', async () => { + const registry = createDicomCollectionRegistry(); + const first = chunkFor({ sop: 'a', z: 0 }); + const second = chunkFor({ sop: 'b', z: 1 }); + const third = chunkFor({ sop: 'c', z: 2 }); + + // Arrival order deliberately differs from slice order. + const { updates } = await registry.register([third, first, second]); + + expect(updates).toHaveLength(1); + const [update] = updates; + expect(sops(update.members)).toEqual(['a', 'b', 'c']); + expect(update.provenance).toEqual(update.members); + expect(update.collection.order).toBe('spatial'); + expect(update.collection.diagnostics).toEqual([]); + }); + + it('aligns the chunk members with the planned facts', async () => { + const registry = createDicomCollectionRegistry(); + const chunks = [chunkFor({ sop: 'a', z: 2 }), chunkFor({ sop: 'b', z: 0 })]; + + const { updates } = await registry.register(chunks); + const [update] = updates; + + expect(update.collection.members.map((m) => m.sopInstanceUid)).toEqual( + sops(update.members) + ); + }); + + // The DICOMweb store reconciles its own series keyed progress against the + // loaded volume id by prefix. + it('mints an id that starts with the series instance uid', async () => { + const registry = createDicomCollectionRegistry(); + + const { updates } = await registry.register([chunkFor({ sop: 'a' })]); + + expect(updates[0].id.startsWith(SERIES_UID)).toBe(true); + }); + + it('keeps the collection id and grows its members across two batches', async () => { + const registry = createDicomCollectionRegistry(); + const first = chunkFor({ sop: 'a', z: 0 }); + const second = chunkFor({ sop: 'b', z: 1 }); + + const [firstUpdate] = (await registry.register([first])).updates; + const [secondUpdate] = (await registry.register([second])).updates; + + expect(secondUpdate.id).toBe(firstUpdate.id); + expect(sops(secondUpdate.members)).toEqual(['a', 'b']); + // Every member reports its provenance, not just the one the batch brought. + expect(secondUpdate.provenance).toEqual([first, second]); + }); + + it('orders a late arriving earlier slice into its planned position', async () => { + const registry = createDicomCollectionRegistry(); + const first = chunkFor({ sop: 'a', z: 0 }); + const second = chunkFor({ sop: 'b', z: 1 }); + + await registry.register([second]); + const [update] = (await registry.register([first])).updates; + + expect(sops(update.members)).toEqual(['a', 'b']); + expect(update.provenance).toEqual([first, second]); + }); + + const POSITIONS: Record = { a: 0, b: 1, c: 2 }; + + const loadBatches = (batches: ReadonlyArray>) => { + const registry = createDicomCollectionRegistry(); + return batches.reduce( + (chain, batch) => + chain.then(() => + registry + .register(batch.map((sop) => chunkFor({ sop, z: POSITIONS[sop] }))) + .then((result) => result.updates) + ), + Promise.resolve([] as CollectionUpdate[]) + ); + }; + + it.each([ + ['one batch', [['a', 'b', 'c']]], + ['two batches', [['a'], ['b', 'c']]], + ['two batches in reverse', [['b', 'c'], ['a']]], + ['one slice at a time', [['c'], ['a'], ['b']]], + [ + 'a repeated batch', + [ + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ], + ], + ] as const)( + 'plans the whole series into one collection when loaded as %s', + async (_case, batches) => { + const updates = await loadBatches(batches); + + expect(updates).toHaveLength(1); + expect(sops(updates[0].members)).toEqual(['a', 'b', 'c']); + } + ); + + it('gives one series the same id however the slices are batched', async () => { + const idOf = async (batches: ReadonlyArray>) => + (await loadBatches(batches))[0].id; + + const oneBatch = await idOf([['a', 'b', 'c']]); + expect(await idOf([['a'], ['b', 'c']])).toBe(oneBatch); + expect(await idOf([['b', 'c'], ['a']])).toBe(oneBatch); + expect( + await idOf([ + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ]) + ).toBe(oneBatch); + }); + + it('keeps the first chunk registered for a repeated instance uid', async () => { + const registry = createDicomCollectionRegistry(); + const original = chunkFor({ sop: 'a', z: 0 }); + const resupplied = chunkFor({ sop: 'a', z: 0 }); + + await registry.register([original]); + const [update] = (await registry.register([resupplied])).updates; + + expect(update.members).toEqual([original]); + // The batch's own chunk still reports back, so its provenance can merge. + expect(update.provenance).toEqual([resupplied]); + }); + + it.each([ + { sop: ' a ', series: ` ${SERIES_UID} ` }, + { sop: null, series: SERIES_UID }, + { sop: null, series: null }, + { sop: '', series: SERIES_UID }, + { sop: ' ', series: '' }, + { sop: 'a', series: ' ' }, + ])( + 'recognizes only the canonical member for identity %j', + async (identity) => { + const registry = createDicomCollectionRegistry(); + const original = chunkFor(identity); + const duplicate = chunkFor(identity); + + const { updates } = await registry.register([original]); + + expect(registry.has(original)).toBe(true); + expect(registry.has(duplicate)).toBe(false); + registry.forget(updates[0].id); + expect(registry.has(original)).toBe(false); + } + ); + + it('counts one chunk once however often a batch repeats it', async () => { + const registry = createDicomCollectionRegistry(); + const chunk = chunkFor({ sop: 'a', z: 0 }); + + const [update] = (await registry.register([chunk, chunk])).updates; + + expect(update.members).toEqual([chunk]); + }); + + it('never merges two instances that carry no instance uid', async () => { + const registry = createDicomCollectionRegistry(); + const first = chunkFor({ sop: null, z: 0 }); + const second = chunkFor({ sop: null, z: 1 }); + + const [update] = (await registry.register([first, second])).updates; + + expect(update.members).toEqual([first, second]); + }); + + it('re-registers an anonymous chunk without duplicating it', async () => { + const registry = createDicomCollectionRegistry(); + const chunk = chunkFor({ sop: null, z: 0 }); + + await registry.register([chunk]); + const [update] = (await registry.register([chunk])).updates; + + expect(update.members).toEqual([chunk]); + }); + + it('splits one batch of incompatible instances into two collections', async () => { + const registry = createDicomCollectionRegistry(); + const scout = chunkFor({ sop: 'scout', rows: '8', z: 0 }); + const slices = [chunkFor({ sop: 'a', z: 0 }), chunkFor({ sop: 'b', z: 1 })]; + + const { updates } = await registry.register([scout, ...slices]); + + expect(updates).toHaveLength(2); + const membership = updates.map((update) => sops(update.members)); + expect(membership).toContainEqual(['scout']); + expect(membership).toContainEqual(['a', 'b']); + expect(updates[0].id).not.toBe(updates[1].id); + updates.forEach((update) => + expect(update.id.startsWith(SERIES_UID)).toBe(true) + ); + }); + + it('leaves an untouched collection out of a later batch', async () => { + const registry = createDicomCollectionRegistry(); + const scout = chunkFor({ sop: 'scout', rows: '8', z: 0 }); + const slice = chunkFor({ sop: 'a', z: 0 }); + + const [sliceUpdate] = (await registry.register([slice])).updates; + const { updates } = await registry.register([scout]); + + expect(updates).toHaveLength(1); + expect(sops(updates[0].members)).toEqual(['scout']); + expect(updates[0].id).not.toBe(sliceUpdate.id); + }); + + it('reports a collection whose key changed without gaining a chunk', async () => { + const registry = createDicomCollectionRegistry(); + const first = [0, 2].map((z) => + chunkFor({ sop: `one-${z}`, z, tags: { [Tags.AcquisitionNumber]: '1' } }) + ); + const second = [1, 3].map((z) => + chunkFor({ sop: `two-${z}`, z, tags: { [Tags.AcquisitionNumber]: '2' } }) + ); + + const [{ id }] = (await registry.register(first)).updates; + const { updates, removed } = await registry.register(second); + + // The first pass keeps its members and its id, but is now labelled as one + // acquisition of two, so the store must hear about it. + expect(removed).toEqual([]); + expect(updates.map((update) => update.id)).toContain(id); + const relabelled = updates.find((update) => update.id === id)!; + expect(sops(relabelled.members)).toEqual(['one-0', 'one-2']); + expect(relabelled.collection.label).toBe('acquisition 1'); + }); + + // The positions of the two passes already earned their separation; a later + // slice nothing spatial can be said about must not dissolve it. + it('keeps two separated acquisitions when a positionless chunk arrives later', async () => { + const registry = createDicomCollectionRegistry(); + const first = [0, 2, 4].map((z) => + chunkFor({ sop: `one-${z}`, z, tags: { [Tags.AcquisitionNumber]: '1' } }) + ); + const second = [1, 3, 5].map((z) => + chunkFor({ sop: `two-${z}`, z, tags: { [Tags.AcquisitionNumber]: '2' } }) + ); + + const separated = byId( + (await registry.register([...first, ...second])).updates + ); + expect(separated).toHaveLength(2); + + const blind = chunkFor({ sop: 'blind', z: null, instanceNumber: 9 }); + const { updates, removed } = await registry.register([blind]); + + // Neither committed collection dissolved, and neither was re-planned. + expect(removed).toEqual([]); + expect(updates).toHaveLength(1); + expect(updates[0].id).not.toBe(separated[0].id); + expect(updates[0].id).not.toBe(separated[1].id); + expect(sops(updates[0].members)).toEqual(['blind']); + expect(updates[0].collection.warnings).toEqual([ + UNREADABLE_POSITION_WARNING, + ]); + + // The two volumes still hold exactly the slices of their own pass. + const again = byId( + (await registry.register([...first, ...second])).updates + ); + expect(again.map((update) => update.id)).toEqual( + separated.map((update) => update.id) + ); + expect(again.map((update) => sops(update.members))).toEqual([ + sops(separated[0].members), + sops(separated[1].members), + ]); + expect(again.flatMap((update) => update.collection.warnings)).toEqual([]); + }); + + it('reports a collection a later batch re-registers unchanged', async () => { + const registry = createDicomCollectionRegistry(); + const slice = chunkFor({ sop: 'a', z: 0 }); + + await registry.register([slice]); + const { updates } = await registry.register([slice]); + + expect(updates).toHaveLength(1); + expect(updates[0].provenance).toEqual([slice]); + }); + + const merging = () => mergingChunks(chunkFor); + + it('reports the id a replan dissolved when two collections merge', async () => { + const registry = createDicomCollectionRegistry(); + const { between, straight, tilted } = merging(); + + const first = await registry.register([straight, tilted]); + expect(first.updates).toHaveLength(2); + expect(first.removed).toEqual([]); + + const second = await registry.register([between]); + + expect(second.updates).toHaveLength(1); + expect(sops(second.updates[0].members)).toEqual([ + 'sop-2', + 'sop-3', + 'sop-1', + ]); + expect(second.removed).toEqual( + first.updates + .map((update) => update.id) + .filter((id) => id !== second.updates[0].id) + ); + }); + + it('reports a collection that lost members without gaining a chunk', async () => { + const registry = createDicomCollectionRegistry(); + const straight = chunkFor({ sop: 'sop-2', z: 0, orientation: tilt(0) }); + const near = chunkFor({ sop: 'sop-3', z: 1, orientation: tilt(0.01) }); + // Agrees with the straight instance but not with the near one, so the + // bucket it seeds as the new reference leaves the near one behind. + const splitter = chunkFor({ + sop: 'sop-1', + z: 2, + orientation: tilt(-0.005), + }); + + const first = await registry.register([straight, near]); + expect(first.updates).toHaveLength(1); + + const second = await registry.register([splitter]); + + const orphaned = second.updates.find( + (update) => !update.members.includes(splitter) + ); + expect(orphaned).toBeDefined(); + expect(sops(orphaned!.members)).toEqual(['sop-3']); + expect(second.removed).toEqual([first.updates[0].id]); + }); + + it('lets a rolled back batch re-supply the chunk for its instance', async () => { + const registry = createDicomCollectionRegistry(); + const malformed = chunkFor({ sop: 'a', z: 0 }); + const first = await registry.register([malformed]); + + first.rollback(first.updates.map((update) => update.seriesKey)); + + const corrected = chunkFor({ sop: 'a', z: 0 }); + const second = await registry.register([corrected]); + + expect(second.updates[0].members).toHaveLength(1); + expect(second.updates[0].members[0]).toBe(corrected); + expect(second.updates[0].id).toBe(first.updates[0].id); + }); + + it('reports a collection forgotten after the batch was planned', async () => { + const registry = createDicomCollectionRegistry(); + const slice = chunkFor({ sop: 'a', z: 0 }); + const [{ id }] = (await registry.register([slice])).updates; + + const { changedSince } = await registry.register([ + chunkFor({ sop: 'b', z: 1 }), + ]); + expect(changedSince(SERIES_UID)).toBe(false); + + registry.forget(id); + + expect(changedSince(SERIES_UID)).toBe(true); + expect(changedSince(OTHER_SERIES_UID)).toBe(false); + }); + + it('reports a forget of a collection the pending plan dissolves', async () => { + const registry = createDicomCollectionRegistry(); + const { between, straight, tilted } = merging(); + const first = await registry.register([straight]); + const second = await registry.register([tilted]); + const [straightId] = first.updates.map((update) => update.id); + const [tiltedId] = second.updates.map((update) => update.id); + + // The between instance merges both into one; the store still shows the + // dissolved one until this plan commits. + const { removed, changedSince } = await registry.register([between]); + expect(removed).toHaveLength(1); + const dissolved = removed[0] === straightId ? straightId : tiltedId; + + registry.forget(dissolved); + + expect(changedSince(SERIES_UID)).toBe(true); + }); + + it('keeps a collection forgotten across the rollback of the batch that saw it', async () => { + const registry = createDicomCollectionRegistry(); + const slice = chunkFor({ sop: 'a', z: 0 }); + const [{ id }] = (await registry.register([slice])).updates; + + const { rollback } = await registry.register([ + chunkFor({ sop: 'b', z: 1 }), + ]); + registry.forget(id); + rollback([SERIES_UID]); + + // Neither the removed volume's member nor the abandoned batch's is held, + // so the next import plans both afresh. + const { updates, removed } = await registry.register([ + slice, + chunkFor({ sop: 'b', z: 1 }), + ]); + expect(removed).toEqual([]); + expect(updates).toHaveLength(1); + expect(sops(updates[0].members)).toEqual(['a', 'b']); + expect(sops(updates[0].provenance)).toEqual(['a', 'b']); + }); + + it('rolls back only the series it is given', async () => { + const registry = createDicomCollectionRegistry(); + const kept = chunkFor({ sop: 'a', series: SERIES_UID }); + const undone = chunkFor({ sop: 'b', series: OTHER_SERIES_UID }); + + const first = await registry.register([kept, undone]); + const undoneUpdate = first.updates.find((update) => + update.members.includes(undone) + )!; + first.rollback([undoneUpdate.seriesKey]); + + const second = await registry.register([ + chunkFor({ sop: 'a', series: SERIES_UID }), + chunkFor({ sop: 'b', series: OTHER_SERIES_UID }), + ]); + const bySeries = (uid: string) => + second.updates.find((update) => update.id.startsWith(uid))!; + + // Identity, not shape: the rolled back series drops its stale chunk. + expect(bySeries(SERIES_UID).members[0]).toBe(kept); + expect(bySeries(OTHER_SERIES_UID).members[0]).not.toBe(undone); + }); + + it('restores the collections a rolled back batch dissolved', async () => { + const registry = createDicomCollectionRegistry(); + const { between, straight, tilted } = merging(); + + await registry.register([straight, tilted]); + const merged = await registry.register([between]); + expect(merged.removed).toHaveLength(1); + + merged.rollback(merged.updates.map((update) => update.seriesKey)); + const retried = await registry.register([between]); + + expect(retried.removed).toEqual(merged.removed); + expect(retried.updates[0].id).toBe(merged.updates[0].id); + expect(sops(retried.updates[0].members)).toEqual([ + 'sop-2', + 'sop-3', + 'sop-1', + ]); + }); + + it('replans without the instances a forgotten collection held', async () => { + const registry = createDicomCollectionRegistry(); + const original = chunkFor({ sop: 'a', z: 0 }); + const { updates } = await registry.register([original]); + + registry.forget(updates[0].id); + const resupplied = chunkFor({ sop: 'a', z: 0 }); + const second = await registry.register([resupplied]); + + // Identity, not shape: the released chunk is an equal but stale object. + expect(second.updates[0].members).toHaveLength(1); + expect(second.updates[0].members[0]).toBe(resupplied); + expect(second.updates[0].id).toBe(updates[0].id); + }); + + it('keeps the members a replan moved when the dissolved id is forgotten', async () => { + const registry = createDicomCollectionRegistry(); + const { between, straight, tilted } = merging(); + + await registry.register([straight, tilted]); + const merged = await registry.register([between]); + merged.removed.forEach((id) => registry.forget(id)); + + const third = await registry.register([between]); + + expect(third.updates[0].members).toHaveLength(3); + }); + + it('forgets one collection of a series without touching the others', async () => { + const registry = createDicomCollectionRegistry(); + const scout = chunkFor({ sop: 'scout', rows: '8', z: 0 }); + const slice = chunkFor({ sop: 'a', z: 0 }); + + const { updates } = await registry.register([scout, slice]); + const scoutUpdate = updates.find((update) => update.members[0] === scout)!; + registry.forget(scoutUpdate.id); + + const resupplied = chunkFor({ sop: 'scout', rows: '8', z: 0 }); + const second = await registry.register([resupplied]); + + expect(second.updates).toHaveLength(1); + expect(second.updates[0].members).toHaveLength(1); + expect(second.updates[0].members[0]).toBe(resupplied); + expect(second.removed).toEqual([]); + }); + + it('plans two series independently', async () => { + const registry = createDicomCollectionRegistry(); + const first = chunkFor({ sop: 'a', series: SERIES_UID }); + const second = chunkFor({ sop: 'b', series: OTHER_SERIES_UID }); + + const updates = byId((await registry.register([first, second])).updates); + + expect(updates).toHaveLength(2); + expect(updates[0].id).not.toBe(updates[1].id); + const forSeries = (uid: string) => + updates.find((update) => update.id.startsWith(uid))!; + expect(forSeries(SERIES_UID).members).toEqual([first]); + expect(forSeries(OTHER_SERIES_UID).members).toEqual([second]); + }); + + it('serializes overlapping registrations of one series', async () => { + const registry = createDicomCollectionRegistry(); + const first = chunkFor({ sop: 'a', z: 0 }); + const second = chunkFor({ sop: 'b', z: 1 }); + + const [firstResult, secondResult] = await Promise.all([ + registry.register([first]), + registry.register([second]), + ]); + + expect(sops(firstResult.updates[0].members)).toEqual(['a']); + expect(sops(secondResult.updates[0].members)).toEqual(['a', 'b']); + expect(secondResult.updates[0].id).toBe(firstResult.updates[0].id); + }); + + it('surfaces the planner order and diagnostics for a positionless series', async () => { + const registry = createDicomCollectionRegistry(); + const chunks = [ + chunkFor({ sop: 'alpha', z: null, instanceNumber: 2 }), + chunkFor({ sop: 'beta', z: null, instanceNumber: 1 }), + ]; + + const [update] = (await registry.register(chunks)).updates; + + expect(sops(update.members)).toEqual(['beta', 'alpha']); + expect(update.collection.order).toBe('instance-number'); + const diagnostics = update.collection.diagnostics.join(' '); + expect(diagnostics).toContain('alpha'); + expect(diagnostics).toContain('beta'); + }); + + it('refuses a chunk whose metadata has not been read', async () => { + const registry = createDicomCollectionRegistry(); + const chunk = { metadata: null } as unknown as Chunk; + + await expect(registry.register([chunk])).rejects.toThrow(/metadata/i); + }); + + it('does not mutate the batch it registers', async () => { + const registry = createDicomCollectionRegistry(); + const chunks = [chunkFor({ sop: 'a', z: 2 }), chunkFor({ sop: 'b', z: 0 })]; + const order = [...chunks]; + + await registry.register(chunks); + + expect(chunks).toEqual(order); + }); +}); + +describe('registry normalization and conflicting identities', () => { + it('recognizes a committed instance with repeated NUL padding', async () => { + const registry = createDicomCollectionRegistry(); + const chunk = chunkFor({ sop: '1.2.3\0\0' }); + await registry.register([chunk]); + expect(registry.has(chunk)).toBe(true); + }); + + it('reports conflicting copies rather than silently discarding a different slice', async () => { + const registry = createDicomCollectionRegistry(); + const original = chunkFor({ sop: 'same', z: 0 }); + const conflicting = chunkFor({ sop: 'same', z: 1 }); + await registry.register([original]); + const result = await registry.register([conflicting]); + expect(result.conflictingInstanceUids).toEqual(['same']); + expect(result.updates[0].members).toEqual([original]); + expect(result.updates[0].provenance).toEqual([original]); + expect(registry.has(original)).toBe(true); + expect(registry.has(conflicting)).toBe(false); + }); + + it('keeps slices with absent and explicit single-frame counts in one volume', async () => { + const registry = createDicomCollectionRegistry(); + const result = await registry.register([ + chunkFor({ sop: 'first', z: 0 }), + chunkFor({ sop: 'second', z: 1, tags: { [Tags.NumberOfFrames]: '1' } }), + ]); + expect(result.updates).toHaveLength(1); + expect(result.updates[0].members).toHaveLength(2); + expect(result.updates[0].collection.reconstruction.sliceSpacing).toBe(1); + }); +}); diff --git a/packages/dicom/core/collections/__tests__/dicomTags.spec.ts b/packages/dicom/core/collections/__tests__/dicomTags.spec.ts new file mode 100644 index 000000000..65b04821a --- /dev/null +++ b/packages/dicom/core/collections/__tests__/dicomTags.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { NAME_TO_TAG, TAG_TO_NAME, Tags } from '../../metadata/dicomTags'; + +describe('dicomTags', () => { + it.each([ + ['SliceThickness', '0018|0050'], + ['SequenceName', '0018|0024'], + ['SeriesDate', '0008|0021'], + ])('names %s', (name, tag) => { + expect(Tags[name]).toBe(tag); + expect(NAME_TO_TAG.get(name)).toBe(tag); + expect(TAG_TO_NAME.get(tag)).toBe(name); + }); +}); diff --git a/packages/dicom/core/collections/__tests__/idcSeriesFixtures.ts b/packages/dicom/core/collections/__tests__/idcSeriesFixtures.ts new file mode 100644 index 000000000..4d87b9fd4 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/idcSeriesFixtures.ts @@ -0,0 +1,202 @@ +import { Tags } from '../../metadata/dicomTags'; + +/** + * Real Imaging Data Commons (IDC) series behind the acquisition-split rules. + * + * Entries are keyed by SeriesInstanceUID: bucket paths, folder UUIDs, and + * portal URLs all change between IDC releases, the UID does not. Fetch one by + * searching the UID at portal.imaging.datacommons.cancer.gov, or with the + * idc-index package: + * + * from idc_index import IDCClient + * IDCClient().download_from_selection( + * seriesInstanceUID='', downloadDir='.') + * + * `groups` mirrors the real per-group tag values and slice layout, with + * positions projected onto the slice normal, in mm. + */ + +export type SliceGroup = { + /** Tag values stamped on every slice of the group, keyed by 'gggg|eeee'. */ + tags: Record; + /** Slice positions along the normal, in mm. */ + zs: number[]; +}; + +export type IdcSeriesFixture = { + seriesInstanceUID: string; + seriesDescription: string; + vendor: string; + /** What the series is and why it forces the rule it tests. */ + why: string; + groups?: SliceGroup[]; +}; + +const steps = (start: number, count: number, step: number) => + Array.from({ length: count }, (_, i) => start + i * step); + +export const dceEightPhase: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7695.4164.334885423614895574619945040495', + seriesDescription: 'ISPY2: VOLSER: uni-lateral cropped: original DCE', + vendor: 'GE', + why: + 'DCE breast MR: eight timepoints re-scan one identical 80-slice Z range ' + + 'in a single series. No AcquisitionNumber at all; only ' + + 'TemporalPositionIdentifier separates the timepoints. Loaded merged, ' + + 'every position appears eight times.', + groups: steps(0, 8, 1).map((phase) => ({ + tags: { [Tags.TemporalPositionIdentifier]: String(phase) }, + zs: steps(-81.198, 80, 2.0), + })), +}; + +export const dceSevenPhasePhilips: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7695.4164.327395145625810010102069976322', + seriesDescription: 'ISPY2: 15ML OMNI T1 FS DYN SENSE 5', + vendor: 'Philips', + why: + 'DCE breast MR: seven timepoints (TemporalPositionIdentifier 1 through ' + + '7), 158 slices each over one Z range. Shows the split holds across ' + + 'vendors and with 1-based numbering.', + groups: steps(1, 7, 1).map((phase) => ({ + tags: { [Tags.TemporalPositionIdentifier]: String(phase) }, + zs: steps(-83.107, 158, 1.0), + })), +}; + +export const dixonDualEcho: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.9203.4004.413147664284321913537786821717', + seriesDescription: 'T1 AX IN_OUT_160 TR MBH', + vendor: 'Siemens', + why: + 'In-phase/opposed-phase Dixon in one series: two echoes (TE 2.4/5.04) ' + + 'over one identical 34-slice range, one AcquisitionNumber, no ' + + 'TemporalPositionIdentifier. Only EchoNumbers separates them; the ' + + 'categorize pipeline cannot (SequenceName is *fl2d2 for both echoes).', + groups: ['1', '2'].map((echo) => ({ + tags: { + [Tags.AcquisitionNumber]: '1', + [Tags.EchoNumbers]: echo, + }, + zs: steps(-107.502, 34, 7.8), + })), +}; + +export const dixonUnevenEchoes: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.1620.1226.196512451812657389463383990773', + seriesDescription: 'AXL_IN_OUT_ABD', + vendor: 'Siemens', + why: + 'Dixon dual-echo where echo 1 has 13 slices and echo 2 has 12 over the ' + + 'same range; unequal group sizes must still split.', + groups: [ + { + tags: { [Tags.AcquisitionNumber]: '1', [Tags.EchoNumbers]: '1' }, + zs: steps(0, 13, 7.8), + }, + { + tags: { [Tags.AcquisitionNumber]: '1', [Tags.EchoNumbers]: '2' }, + zs: steps(0, 12, 7.8), + }, + ], +}; + +export const dwiMultiBValue: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7695.4164.198361347871486858845671445367', + seriesDescription: 'ACRIN-6698: DWI_Multi_bValue', + vendor: 'Philips', + why: + 'Diffusion MR: 30 positions repeated at b-values 0, 100, 600, and 800. ' + + 'AcquisitionNumber, TemporalPositionIdentifier, and EchoNumbers are ' + + 'constant, so DiffusionBValue is the only standard discriminator.', + groups: [0, 100, 600, 800].map((bValue) => ({ + tags: { + [Tags.AcquisitionNumber]: '6', + [Tags.TemporalPositionIdentifier]: '1', + [Tags.EchoNumbers]: '1', + [Tags.DiffusionBValue]: String(bValue), + }, + zs: steps(-67.0785, 30, 4.0), + })), +}; + +export const bilateralSagittalSlabs: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.66737955842913643997059729379406867951', + seriesDescription: 'ISPY2: SAG IR', + vendor: 'GE', + why: + 'Bilateral sagittal breast slabs: StackID 1 and 2 cover two 4mm stacks ' + + 'with a 42mm gap, one orientation, one acquisition, all positions ' + + 'distinct. This series is why StackID is NOT a split discriminator: ' + + 'nothing overlaps, so the overlap gate keeps a StackID-like tag from ' + + 'tearing it apart. One collection is not one regular volume, though: no ' + + 'even lattice holds both slabs, so the reconstruction reports an ' + + 'irregular stack and warns, rather than fitting the endpoints to a ' + + 'fabricated 4.59mm spacing.', + groups: [ + { + tags: { [Tags.AcquisitionNumber]: '1', '0020|9056': '1' }, + zs: steps(-156.488, 31, 4.0), + }, + { + tags: { [Tags.AcquisitionNumber]: '1', '0020|9056': '2' }, + zs: steps(5.951, 35, 4.0), + }, + ], +}; + +export const partialTemporalTags: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7695.4164.232713885104107279235403827078', + seriesDescription: 'ISPY2: Ax Vibrant PRE/POST', + vendor: 'GE', + why: + 'Seven acquisitions re-scanning one range, but ' + + 'TemporalPositionIdentifier is present on only part of the slices. ' + + 'AcquisitionNumber must do the split, and the incomplete temporal tag ' + + 'must not sub-split or block anything.', + groups: [ + { + tags: { + [Tags.AcquisitionNumber]: '1', + [Tags.TemporalPositionIdentifier]: '1', + }, + zs: steps(-60, 36, 2.0), + }, + // The rest of acquisition 1 carries no temporal tag at all. + { tags: { [Tags.AcquisitionNumber]: '1' }, zs: steps(12, 36, 2.0) }, + ...steps(2, 6, 1).map((acq) => ({ + tags: { + [Tags.AcquisitionNumber]: String(acq), + [Tags.TemporalPositionIdentifier]: String(acq), + }, + zs: steps(-60, 72, 2.0), + })), + ], +}; + +export const doubledAcquisition: IdcSeriesFixture = { + seriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7695.1700.103847508594300579711113977161', + seriesDescription: 'IR-SPGR-SAG', + vendor: 'GE', + why: + 'Acquisition 1 holds 74 slices; acquisition 2 holds 148 slices at those ' + + 'same 74 positions. The split is right and the acquisition-2 volume must ' + + 'still carry the duplicate-position warning. Same shape in IDC series ' + + '1.3.6.1.4.1.14519.5.2.1.7695.2311.133871731749882407434486842181 ' + + '(LEFT - Dynamic-3dfgre, acquisition values 0 and 2).', + groups: [ + { tags: { [Tags.AcquisitionNumber]: '1' }, zs: steps(-214.177, 74, 2.5) }, + { + tags: { [Tags.AcquisitionNumber]: '2' }, + zs: [...steps(-214.177, 74, 2.5), ...steps(-214.177, 74, 2.5)], + }, + ], +}; diff --git a/packages/dicom/core/collections/__tests__/instanceFacts.spec.ts b/packages/dicom/core/collections/__tests__/instanceFacts.spec.ts new file mode 100644 index 000000000..c200bf49f --- /dev/null +++ b/packages/dicom/core/collections/__tests__/instanceFacts.spec.ts @@ -0,0 +1,404 @@ +import { datasetFromTags } from '@/tests/dicomDataset'; +import { describe, expect, it } from 'vitest'; +import { Tags } from '../../metadata/dicomTags'; +import { + readInstanceFacts as readStructuredFacts, + seriesKeyOf as structuredSeriesKeyOf, +} from '../instanceFacts'; + +const INSTANCE_NUMBER_TAG = '0020|0013'; + +// readDicomTags hands back DICOM's own padding, so every fixture value here is +// written the way the reader returns it. +const metadata = (overrides: Record = {}) => { + const entries: Array<[string, string]> = [ + [Tags.SOPInstanceUID, '1.2.3.4'], + [Tags.SeriesInstanceUID, '1.2.3'], + [Tags.StudyInstanceUID, '1.2'], + [Tags.PatientID, 'patient-1'], + [Tags.SeriesNumber, '1 '], + [Tags.SliceThickness, '1 '], + [Tags.SeriesDate, '20240101'], + [Tags.Rows, '4'], + [Tags.Columns, '6'], + [Tags.SamplesPerPixel, '1'], + [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0 '], + [Tags.ImagePositionPatient, '10\\20\\30 '], + [Tags.PixelSpacing, '0.5\\0.75'], + [INSTANCE_NUMBER_TAG, '5 '], + ]; + Object.entries(overrides).forEach(([tag, value]) => { + const existing = entries.find((entry) => entry[0] === tag); + if (existing) existing[1] = value; + else entries.push([tag, value]); + }); + return entries; +}; + +const without = (tag: string) => metadata().filter((entry) => entry[0] !== tag); + +describe('readInstanceFacts', () => { + it('names the instance number tag the planner orders on', () => { + expect(Tags.InstanceNumber).toBe(INSTANCE_NUMBER_TAG); + }); + + it('reads every fact the planner compares', () => { + expect(readInstanceFacts(metadata())).toEqual({ + sopInstanceUid: '1.2.3.4', + seriesInstanceUid: '1.2.3', + seriesNumber: '1', + sequenceName: null, + sliceThickness: '1', + seriesDate: '20240101', + rows: '4', + columns: '6', + samplesPerPixel: '1', + numberOfFrames: '1', + orientation: [1, 0, 0, 0, 1, 0], + position: [10, 20, 30], + projectedPosition: 30, + pixelSpacing: [0.5, 0.75], + instanceNumber: 5, + acquisitionNumber: null, + temporalPositionIdentifier: null, + echoNumbers: null, + diffusionBValue: null, + }); + }); + + it('reads an instance with no tags at all as entirely unknown', () => { + expect(readInstanceFacts([])).toEqual({ + sopInstanceUid: null, + seriesInstanceUid: null, + seriesNumber: null, + sequenceName: null, + sliceThickness: null, + seriesDate: null, + rows: null, + columns: null, + samplesPerPixel: null, + numberOfFrames: '1', + orientation: null, + position: null, + projectedPosition: null, + pixelSpacing: null, + instanceNumber: null, + acquisitionNumber: null, + temporalPositionIdentifier: null, + echoNumbers: null, + diffusionBValue: null, + }); + }); + + it.each([ + ['SeriesNumber', Tags.SeriesNumber, 'seriesNumber'], + ['SequenceName', Tags.SequenceName, 'sequenceName'], + ['SliceThickness', Tags.SliceThickness, 'sliceThickness'], + ['SeriesDate', Tags.SeriesDate, 'seriesDate'], + ['Rows', Tags.Rows, 'rows'], + ['Columns', Tags.Columns, 'columns'], + ['SamplesPerPixel', Tags.SamplesPerPixel, 'samplesPerPixel'], + ] as const)( + 'reads a zero length %s as absent, not as an empty string', + (_name, tag, fact) => { + expect(readInstanceFacts(metadata({ [tag]: '' }))[fact]).toBeNull(); + expect(readInstanceFacts(metadata({ [tag]: ' ' }))[fact]).toBeNull(); + } + ); + + it.each([ + ['SeriesNumber', Tags.SeriesNumber, 'seriesNumber'], + ['SliceThickness', Tags.SliceThickness, 'sliceThickness'], + ['Rows', Tags.Rows, 'rows'], + ['Columns', Tags.Columns, 'columns'], + ['SamplesPerPixel', Tags.SamplesPerPixel, 'samplesPerPixel'], + ['NumberOfFrames', Tags.NumberOfFrames, 'numberOfFrames'], + ] as const)( + 'normalizes the numeric spelling of %s so 04 and 4.0 are one value', + (_name, tag, fact) => { + const readFact = (value: string) => + readInstanceFacts(metadata({ [tag]: value }))[fact]; + expect(readFact('4')).toBe('4'); + expect(readFact('04')).toBe('4'); + expect(readFact('4.0')).toBe('4'); + expect(readFact(' 4 ')).toBe('4'); + expect(readFact('1.50')).toBe('1.5'); + } + ); + + // A study date's leading zeros are part of the value, so it stays text. + it('keeps a series date as text rather than as a number', () => { + expect( + readInstanceFacts(metadata({ [Tags.SeriesDate]: '00240101 ' })).seriesDate + ).toBe('00240101'); + }); + + it('keeps a sequence name as text', () => { + expect( + readInstanceFacts(metadata({ [Tags.SequenceName]: '007 ' })).sequenceName + ).toBe('007'); + }); + + it('trims the padding DICOM adds to odd length identifiers', () => { + const facts = readInstanceFacts( + metadata({ + [Tags.SOPInstanceUID]: '1.2.3.4 ', + [Tags.SeriesInstanceUID]: '1.2.3 ', + }) + ); + expect(facts.sopInstanceUid).toBe('1.2.3.4'); + expect(facts.seriesInstanceUid).toBe('1.2.3'); + }); + + it.each([ + ['a missing tag', without(Tags.ImageOrientationPatient)], + [ + 'too few cosines', + metadata({ [Tags.ImageOrientationPatient]: '1\\0\\0' }), + ], + [ + 'a non numeric cosine', + metadata({ [Tags.ImageOrientationPatient]: '1\\0\\0\\0\\x\\0' }), + ], + ])('reads an orientation with %s as unknown', (_case, entries) => { + const facts = readInstanceFacts(entries); + expect(facts.orientation).toBeNull(); + expect(facts.projectedPosition).toBeNull(); + }); + + it.each([ + ['a missing tag', without(Tags.ImagePositionPatient)], + ['too few coordinates', metadata({ [Tags.ImagePositionPatient]: '1\\2' })], + [ + 'a non numeric coordinate', + metadata({ [Tags.ImagePositionPatient]: '1\\2\\z' }), + ], + ])('reads a position with %s as unknown', (_case, entries) => { + const facts = readInstanceFacts(entries); + expect(facts.position).toBeNull(); + expect(facts.projectedPosition).toBeNull(); + }); + + it.each([ + ['a blank component', '1\\\\3'], + ['a trailing blank component', '1\\2\\'], + ['a whitespace component', '1\\ \\3'], + ])('reads a position with %s as unknown, not as a zero', (_case, value) => { + const facts = readInstanceFacts( + metadata({ [Tags.ImagePositionPatient]: value }) + ); + expect(facts.position).toBeNull(); + expect(facts.projectedPosition).toBeNull(); + }); + + it.each([ + ['an all zero basis', '0\\0\\0\\0\\0\\0'], + ['a zero length row', '0\\0\\0\\0\\1\\0'], + ['a row and column that are the same axis', '1\\0\\0\\1\\0\\0'], + ['a row and column 45 degrees apart', '1\\0\\0\\0.7071\\0.7071\\0'], + ['a blank cosine', '1\\0\\0\\0\\\\0'], + ])('reads %s as no orientation at all', (_case, value) => { + const facts = readInstanceFacts( + metadata({ [Tags.ImageOrientationPatient]: value }) + ); + expect(facts.orientation).toBeNull(); + expect(facts.projectedPosition).toBeNull(); + }); + + // A DS value carries a printed decimal, so exact unit length is not on offer. + it('normalizes cosines that are within the tolerance of unit length', () => { + const facts = readInstanceFacts( + metadata({ + [Tags.ImageOrientationPatient]: '0.99998\\0\\0\\0\\1.00002\\0', + }) + ); + + expect(facts.orientation).not.toBeNull(); + expect(facts.orientation![0]).toBeCloseTo(1, 12); + expect(facts.orientation![4]).toBeCloseTo(1, 12); + expect(facts.projectedPosition).toBeCloseTo(30, 12); + }); + + // GDCM rescales whatever cosines it is given, so a basis printed to three + // decimals or written at the wrong length loads as the scanner meant it + // rather than landing in the unreadable bucket. + it.each([ + [ + 'an oblique printed to three decimals', + '0.707\\0.707\\0\\-0.707\\0.707\\0', + [0.7071, 0.7071, 0, -0.7071, 0.7071, 0], + ], + [ + 'a row written at twice unit length', + '2\\0\\0\\0\\1\\0', + [1, 0, 0, 0, 1, 0], + ], + ])('rescales %s to unit axes', (_case, value, expected) => { + const facts = readInstanceFacts( + metadata({ [Tags.ImageOrientationPatient]: value }) + ); + + expect(facts.orientation).not.toBeNull(); + facts.orientation!.forEach((cosine, index) => + expect(cosine).toBeCloseTo(expected[index], 3) + ); + expect(facts.projectedPosition).toBeCloseTo(30, 10); + }); + + it('accepts cosines a printed digit away from square', () => { + const facts = readInstanceFacts( + metadata({ + [Tags.ImageOrientationPatient]: '0.7071\\0.7071\\0\\-0.7072\\0.7070\\0', + }) + ); + + expect(facts.orientation).not.toBeNull(); + expect(facts.projectedPosition).toBeCloseTo(30, 3); + }); + + it.each([ + ['zero', '0\\0.5'], + ['negative', '-1\\0.5'], + ['blank', '\\0.5'], + ])('reads a %s pixel spacing as unknown', (_case, value) => { + expect( + readInstanceFacts(metadata({ [Tags.PixelSpacing]: value })).pixelSpacing + ).toBeNull(); + }); + + it('projects the position onto the slice normal, not onto z', () => { + // Rows run along +y and columns along +z, so the normal is +x. + const facts = readInstanceFacts( + metadata({ [Tags.ImageOrientationPatient]: '0\\1\\0\\0\\0\\1' }) + ); + expect(facts.projectedPosition).toBeCloseTo(10, 10); + }); + + it('projects onto the normal with the cosines in row then column order', () => { + // Swapping the two rows of the case above flips the normal to -x. + const facts = readInstanceFacts( + metadata({ [Tags.ImageOrientationPatient]: '0\\0\\1\\0\\1\\0' }) + ); + expect(facts.projectedPosition).toBeCloseTo(-10, 10); + }); + + it.each([ + ['a missing tag', without(Tags.PixelSpacing), null], + ['a single value', metadata({ [Tags.PixelSpacing]: '0.5' }), null], + [ + 'a non numeric value', + metadata({ [Tags.PixelSpacing]: '0.5\\bad' }), + null, + ], + ])('reads a pixel spacing with %s as unknown', (_case, entries) => { + expect(readInstanceFacts(entries).pixelSpacing).toBeNull(); + }); + + it.each([ + ['5 ', 5], + ['+5', 5], + ['-3', -3], + ])('reads the instance number %s as a number', (value, expected) => { + expect( + readInstanceFacts(metadata({ [INSTANCE_NUMBER_TAG]: value })) + .instanceNumber + ).toBe(expected); + }); + + it.each([ + ['a missing tag', without(INSTANCE_NUMBER_TAG)], + ['an empty value', metadata({ [INSTANCE_NUMBER_TAG]: ' ' })], + ['a non numeric value', metadata({ [INSTANCE_NUMBER_TAG]: 'first' })], + ])('reads an instance number with %s as unknown', (_case, entries) => { + expect(readInstanceFacts(entries).instanceNumber).toBeNull(); + }); + + it('does not mutate the metadata it reads', () => { + const entries = metadata(); + const before = JSON.parse(JSON.stringify(entries)); + readInstanceFacts(entries); + expect(entries).toEqual(before); + }); +}); + +describe('seriesKeyOf', () => { + it('is the series instance uid so a collection id carries it as a prefix', () => { + expect(seriesKeyOf(metadata())).toBe('1.2.3'); + expect(seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '1.2.3 ' }))).toBe( + '1.2.3' + ); + }); + + it('separates two series that differ only in series instance uid', () => { + expect( + seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '1.2.4' })) + ).not.toBe(seriesKeyOf(metadata())); + }); + + it('never reuses a series key for an instance that has no series uid', () => { + const anonymous = seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '' })); + expect(anonymous).not.toBe(''); + expect(anonymous).not.toBe(seriesKeyOf(metadata())); + }); + + it('falls back to the study and patient when the series uid is absent', () => { + const key = (overrides: Record) => + seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '', ...overrides })); + + expect(key({})).toBe(key({})); + expect(key({ [Tags.StudyInstanceUID]: '9.9' })).not.toBe(key({})); + expect(key({ [Tags.PatientID]: 'patient-2' })).not.toBe(key({})); + }); + + // Concatenation alone would tie these two, since only the split moves. + it('escapes the fallback parts so a moved separator changes the key', () => { + const key = (study: string, patient: string) => + seriesKeyOf( + metadata({ + [Tags.SeriesInstanceUID]: '', + [Tags.StudyInstanceUID]: study, + [Tags.PatientID]: patient, + }) + ); + expect(key('a|b', 'c')).not.toBe(key('a', 'b|c')); + expect(key('a:b', 'c')).not.toBe(key('a', 'b:c')); + }); + + it('does not mutate the metadata it reads', () => { + const entries = metadata(); + const before = JSON.parse(JSON.stringify(entries)); + seriesKeyOf(entries); + expect(entries).toEqual(before); + }); +}); + +function readInstanceFacts(tags: Iterable) { + return readStructuredFacts(datasetFromTags(tags)); +} +function seriesKeyOf(tags: Iterable) { + return structuredSeriesKeyOf(datasetFromTags(tags)); +} + +describe('single-frame and spacing normalization', () => { + it.each([undefined, '', ' ', '1', '01'])( + 'defaults NumberOfFrames %s to one', + (frames) => { + expect( + readStructuredFacts( + datasetFromTags( + frames === undefined ? [] : [[Tags.NumberOfFrames, frames]] + ) + ).numberOfFrames + ).toBe('1'); + } + ); + + it.each(['0.5\\0.75\\2.5', '0.5\\0.75\\'])( + 'retains the first two PixelSpacing values in %s', + (value) => { + expect( + readInstanceFacts(metadata({ [Tags.PixelSpacing]: value })).pixelSpacing + ).toEqual([0.5, 0.75]); + } + ); +}); diff --git a/packages/dicom/core/collections/__tests__/instanceFactsFixture.ts b/packages/dicom/core/collections/__tests__/instanceFactsFixture.ts new file mode 100644 index 000000000..7e22ea469 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/instanceFactsFixture.ts @@ -0,0 +1,60 @@ +import type { InstanceFacts } from '../planDicomCollections'; + +export const IDENTITY_ORIENTATION = [1, 0, 0, 0, 1, 0]; + +// Rotates the second orientation row by theta, leaving both rows unit length. +export const tiltedOrientation = (theta: number) => [ + 1, + 0, + 0, + 0, + Math.cos(theta), + Math.sin(theta), +]; + +export const orientationKeyValue = (orientation: number[]) => + orientation.map(String).join(','); + +// The position and its projection are one fact, so a fixture naming only the +// projection gets the axial position that produces it. +const positionFor = (overrides: Partial) => { + if ('position' in overrides) return overrides.position ?? null; + const projected = + 'projectedPosition' in overrides ? overrides.projectedPosition : 0; + return projected === null || projected === undefined + ? null + : [0, 0, projected]; +}; + +const defaults = (sopInstanceUid: string | null): InstanceFacts => ({ + sopInstanceUid, + seriesInstanceUid: 'series-1', + seriesNumber: '1', + sequenceName: null, + sliceThickness: '1', + seriesDate: '20240101', + rows: '4', + columns: '4', + samplesPerPixel: '1', + numberOfFrames: null, + orientation: IDENTITY_ORIENTATION, + position: [0, 0, 0], + projectedPosition: 0, + pixelSpacing: [1, 1], + instanceNumber: 1, + acquisitionNumber: null, + temporalPositionIdentifier: null, + echoNumbers: null, + diffusionBValue: null, +}); + +export const makeFacts = ( + sopInstanceUid: string | null, + overrides: Partial = {} +): InstanceFacts => ({ + ...defaults(sopInstanceUid), + ...overrides, + position: positionFor(overrides), +}); + +export const clone = (value: unknown) => JSON.parse(JSON.stringify(value)); diff --git a/packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts b/packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts new file mode 100644 index 000000000..a6c895e83 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts @@ -0,0 +1,879 @@ +import { describe, it, expect } from 'vitest'; +import { + HARD_FACT_RULES, + ORIENTATION_RULE, + ORIENTATION_TOLERANCE, + IN_PLANE_SHIFT_WARNING, + NONPARALLEL_SLICES_WARNING, + IRREGULAR_VOLUME_WARNING, + REPEATED_POSITIONS_WARNING, + UNREADABLE_POSITION_WARNING, + planDicomCollections, + validateCollections, +} from '../planDicomCollections'; +import { SEMANTIC_AXES } from '../splitOverlappingAcquisitions'; +import type { DicomCollection, InstanceFacts } from '../planDicomCollections'; +import { + IDENTITY_ORIENTATION, + clone, + makeFacts, + orientationKeyValue, + tiltedOrientation, +} from './instanceFactsFixture'; + +const SERIES_KEY = 'series-1'; + +const plan = (instances: InstanceFacts[], seriesKey = SERIES_KEY) => + planDicomCollections({ seriesKey, instances }); + +const uidsOf = (collection: DicomCollection) => + collection.members.map((m) => m.sopInstanceUid); + +function collectionWith(collections: DicomCollection[], uid: string) { + const found = collections.find((c) => + c.members.some((m) => m.sopInstanceUid === uid) + ); + if (!found) throw new Error(`no collection holds ${uid}`); + return found; +} + +const partValue = (collection: DicomCollection, rule: string) => { + const part = collection.key.parts.find(([name]) => name === rule); + if (!part) throw new Error(`no key part named ${rule}`); + return part[1]; +}; + +const mentions = (diagnostics: string[], needle: string) => + diagnostics.some((d) => d.includes(needle)); + +// Second orientation row rotations, measured against the dot-product tolerance. +const WITHIN = Math.acos(1 - ORIENTATION_TOLERANCE / 2); +const BEYOND = Math.acos(1 - ORIENTATION_TOLERANCE * 2); +// cos(CHAIN) is inside the tolerance while cos(2 * CHAIN) is outside it. +const CHAIN = Math.sqrt(ORIENTATION_TOLERANCE); + +describe('planDicomCollections', () => { + it('plans nothing for an empty instance list', () => { + expect(plan([])).toEqual({ collections: [] }); + }); + + it('keeps one consistent series in a single collection', () => { + const a = makeFacts('uid-a', { projectedPosition: 0, instanceNumber: 1 }); + const b = makeFacts('uid-b', { projectedPosition: 1, instanceNumber: 2 }); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(1); + expect(collections[0].key.seriesKey).toBe(SERIES_KEY); + expect(collections[0].members[0]).toBe(a); + expect(collections[0].members[1]).toBe(b); + expect(collections[0].order).toBe('spatial'); + expect(collections[0].diagnostics).toEqual([]); + }); + + it('names the hard facts then orientation, in a fixed key order', () => { + const { collections } = plan([makeFacts('uid-a')]); + + expect(collections[0].key.parts.map(([rule]) => rule)).toEqual([ + ...HARD_FACT_RULES, + ORIENTATION_RULE, + ...SEMANTIC_AXES.map((axis) => axis.rule), + ]); + expect(collections[0].key.parts).toEqual([ + ['seriesNumber', '1'], + ['sequenceName', null], + ['sliceThickness', '1'], + ['seriesDate', '20240101'], + ['rows', '4'], + ['columns', '4'], + ['samplesPerPixel', '1'], + ['numberOfFrames', null], + ['orientation', orientationKeyValue(IDENTITY_ORIENTATION)], + ['acquisition', null], + ['phase', null], + ['echo', null], + ['b-value', null], + ]); + }); + + const differingFacts: Array<[string, Partial, string]> = [ + ['seriesNumber', { seriesNumber: '2' }, '2'], + ['sequenceName', { sequenceName: 'SEQ' }, 'SEQ'], + ['sliceThickness', { sliceThickness: '5' }, '5'], + ['seriesDate', { seriesDate: '20240102' }, '20240102'], + ['rows', { rows: '8' }, '8'], + ['columns', { columns: '8' }, '8'], + ['samplesPerPixel', { samplesPerPixel: '3' }, '3'], + ['numberOfFrames', { numberOfFrames: '2' }, '2'], + ]; + + it.each(differingFacts)( + 'partitions on a differing %s', + (rule, overrides, expected) => { + const a = makeFacts('uid-a'); + const b = makeFacts('uid-b', overrides); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(2); + expect(uidsOf(collectionWith(collections, 'uid-a'))).toEqual(['uid-a']); + expect(uidsOf(collectionWith(collections, 'uid-b'))).toEqual(['uid-b']); + expect(partValue(collectionWith(collections, 'uid-b'), rule)).toBe( + expected + ); + } + ); + + const missingFacts: Array< + [string, Partial, Partial] + > = [ + ['seriesNumber', { seriesNumber: '1' }, { seriesNumber: null }], + ['sequenceName', { sequenceName: 'SEQ' }, { sequenceName: null }], + ['sliceThickness', { sliceThickness: '1' }, { sliceThickness: null }], + ['seriesDate', { seriesDate: '20240101' }, { seriesDate: null }], + ['rows', { rows: '4' }, { rows: null }], + ['columns', { columns: '4' }, { columns: null }], + ['samplesPerPixel', { samplesPerPixel: '1' }, { samplesPerPixel: null }], + ['numberOfFrames', { numberOfFrames: '2' }, { numberOfFrames: null }], + ]; + + it.each(missingFacts)( + 'buckets a missing %s on its own', + (rule, present, absent) => { + const a = makeFacts('uid-a', present); + const b = makeFacts('uid-b', absent); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(2); + expect(partValue(collectionWith(collections, 'uid-b'), rule)).toBeNull(); + expect( + partValue(collectionWith(collections, 'uid-a'), rule) + ).not.toBeNull(); + } + ); + + it('does not partition on pixel spacing, position or instance number', () => { + const a = makeFacts('uid-a', { + pixelSpacing: [1, 1], + position: [0, 0, 0], + projectedPosition: 0, + instanceNumber: 1, + }); + const b = makeFacts('uid-b', { + pixelSpacing: [0.5, 0.75], + position: [0, 0, 3], + projectedPosition: 3, + instanceNumber: 9, + }); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(1); + expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']); + }); + + it('keys on the given series key, not the instances own series UID', () => { + const a = makeFacts('uid-a', { seriesInstanceUid: 'somewhere-else' }); + + const { collections } = plan([a], 'series-9'); + + expect(collections[0].key.seriesKey).toBe('series-9'); + }); + + it('buckets orientations whose rows agree within the tolerance', () => { + const a = makeFacts('uid-a', { orientation: IDENTITY_ORIENTATION }); + const b = makeFacts('uid-b', { orientation: tiltedOrientation(WITHIN) }); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(1); + expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']); + }); + + it('splits orientations whose rows disagree beyond the tolerance', () => { + const a = makeFacts('uid-a', { orientation: IDENTITY_ORIENTATION }); + const b = makeFacts('uid-b', { orientation: tiltedOrientation(BEYOND) }); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(2); + expect( + partValue(collectionWith(collections, 'uid-b'), ORIENTATION_RULE) + ).toBe(orientationKeyValue(tiltedOrientation(BEYOND))); + }); + + it('buckets identical cosines that are slightly over normalized', () => { + const overNormalized = [1.0001, 0, 0, 0, 1, 0]; + const a = makeFacts('uid-a', { orientation: overNormalized }); + const b = makeFacts('uid-b', { orientation: overNormalized }); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(1); + expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']); + expect(partValue(collections[0], ORIENTATION_RULE)).toBe( + orientationKeyValue(overNormalized) + ); + }); + + it('buckets a chain of near orientations the same way under permutation', () => { + const a = makeFacts('uid-a', { orientation: IDENTITY_ORIENTATION }); + const b = makeFacts('uid-b', { orientation: tiltedOrientation(CHAIN) }); + const c = makeFacts('uid-c', { orientation: tiltedOrientation(2 * CHAIN) }); + + const forward = plan([a, b, c]); + const backward = plan([c, b, a]); + const shuffled = plan([b, c, a]); + + expect(forward.collections).toHaveLength(2); + expect(uidsOf(collectionWith(forward.collections, 'uid-a'))).toEqual([ + 'uid-a', + 'uid-b', + ]); + expect(uidsOf(collectionWith(forward.collections, 'uid-c'))).toEqual([ + 'uid-c', + ]); + expect(backward).toEqual(forward); + expect(shuffled).toEqual(forward); + }); + + it('buckets a chain of anonymous near orientations the same under permutation', () => { + const anonymousChain = () => [ + makeFacts(null, { + orientation: IDENTITY_ORIENTATION, + projectedPosition: 0, + }), + makeFacts(null, { + orientation: tiltedOrientation(CHAIN), + projectedPosition: 1, + }), + makeFacts(null, { + orientation: tiltedOrientation(2 * CHAIN), + projectedPosition: 2, + }), + ]; + + const [x, y, z] = anonymousChain(); + const forward = plan([x, y, z]); + + expect(forward.collections.map((c) => c.members.length)).toEqual([2, 1]); + expect(plan([z, y, x])).toEqual(forward); + expect(plan([y, x, z])).toEqual(forward); + }); + + it('takes the orientation key from the smallest SOP UID, not the first input', () => { + const late = makeFacts('uid-z', { orientation: IDENTITY_ORIENTATION }); + const early = makeFacts('uid-a', { + orientation: tiltedOrientation(WITHIN), + }); + + const { collections } = plan([late, early]); + + expect(collections).toHaveLength(1); + expect(partValue(collections[0], ORIENTATION_RULE)).toBe( + orientationKeyValue(tiltedOrientation(WITHIN)) + ); + }); + + it('never represents a bucket with an instance that has no SOP UID', () => { + const anonymous = makeFacts(null, { orientation: IDENTITY_ORIENTATION }); + const identified = makeFacts('uid-z', { + orientation: tiltedOrientation(WITHIN), + }); + + const { collections } = plan([anonymous, identified]); + + expect(collections).toHaveLength(1); + expect(partValue(collections[0], ORIENTATION_RULE)).toBe( + orientationKeyValue(tiltedOrientation(WITHIN)) + ); + }); + + it('buckets an unreadable orientation on its own and diagnoses it', () => { + const a = makeFacts('uid-a'); + const b = makeFacts('uid-b', { orientation: null }); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(2); + const orphan = collectionWith(collections, 'uid-b'); + expect(partValue(orphan, ORIENTATION_RULE)).toBeNull(); + expect(mentions(orphan.diagnostics, 'uid-b')).toBe(true); + }); + + it('deduplicates by SOP Instance UID, keeping the last occurrence', () => { + const stale = makeFacts('uid-a', { instanceNumber: 1 }); + const fresh = makeFacts('uid-a', { instanceNumber: 7 }); + + const { collections } = plan([stale, fresh]); + + expect(collections).toHaveLength(1); + expect(collections[0].members).toHaveLength(1); + expect(collections[0].members[0]).toBe(fresh); + expect(mentions(collections[0].diagnostics, 'uid-a')).toBe(true); + }); + + it('never merges instances that have no SOP Instance UID', () => { + const a = makeFacts(null, { projectedPosition: 0, instanceNumber: 1 }); + const b = makeFacts(null, { projectedPosition: 1, instanceNumber: 2 }); + + const { collections } = plan([a, b]); + + expect(collections).toHaveLength(1); + expect(collections[0].members).toHaveLength(2); + }); + + it('keeps both entries when one anonymous instance is listed twice', () => { + const shared = makeFacts(null); + + const { collections } = plan([shared, shared]); + + expect(collections).toHaveLength(1); + expect(collections[0].members).toEqual([shared, shared]); + }); + + it('orders members by projected position ascending', () => { + const high = makeFacts('uid-a', { projectedPosition: 10 }); + const low = makeFacts('uid-b', { projectedPosition: -5 }); + const mid = makeFacts('uid-c', { projectedPosition: 0 }); + + const { collections } = plan([high, low, mid]); + + expect(uidsOf(collections[0])).toEqual(['uid-b', 'uid-c', 'uid-a']); + expect(collections[0].order).toBe('spatial'); + expect(collections[0].diagnostics).toEqual([]); + }); + + // The tied members keep the scanner's own sequence, but position stays the + // primary key: slot i lands at origin + i * spacing along the normal, so an + // order that ran against position would mirror the volume. + it('breaks a repeated projected position on instance number, keeping spatial order', () => { + const later = makeFacts('uid-a', { + projectedPosition: 3, + instanceNumber: 2, + }); + const earlier = makeFacts('uid-b', { + projectedPosition: 3, + instanceNumber: 1, + }); + // Numbered before the tied pair yet positioned after it. + const far = makeFacts('uid-c', { projectedPosition: 5, instanceNumber: 0 }); + const near = makeFacts('uid-d', { + projectedPosition: 0, + instanceNumber: 9, + }); + + const { collections } = plan([later, far, earlier, near]); + + expect(uidsOf(collections[0])).toEqual([ + 'uid-d', + 'uid-b', + 'uid-a', + 'uid-c', + ]); + expect(collections[0].order).toBe('spatial'); + expect(collections[0].warnings).toEqual([REPEATED_POSITIONS_WARNING]); + }); + + it('breaks a repeated position on SOP Instance UID when instance numbers tie', () => { + const second = makeFacts('uid-b', { projectedPosition: 3 }); + const first = makeFacts('uid-a', { projectedPosition: 3 }); + + const { collections } = plan([second, first]); + + expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']); + expect(collections[0].order).toBe('spatial'); + }); + + it('orders an unnumbered member after a numbered one at the same position', () => { + const numbered = makeFacts('uid-b', { projectedPosition: 3 }); + const unnumbered = makeFacts('uid-a', { + projectedPosition: 3, + instanceNumber: null, + }); + const low = makeFacts('uid-c', { projectedPosition: 0 }); + + const { collections } = plan([unnumbered, numbered, low]); + + expect(uidsOf(collections[0])).toEqual(['uid-c', 'uid-b', 'uid-a']); + expect(collections[0].order).toBe('spatial'); + }); + + it('breaks a shared position between anonymous members on content', () => { + const anonymousPair = () => [ + makeFacts(null, { projectedPosition: 0, instanceNumber: 1 }), + makeFacts(null, { projectedPosition: 0, instanceNumber: 2 }), + ]; + + const [a, b] = anonymousPair(); + const forward = plan([a, b]); + + expect(forward.collections[0].members.map((m) => m.instanceNumber)).toEqual( + [1, 2] + ); + expect(plan([b, a])).toEqual(forward); + }); + + it('breaks an anonymous tie on facts outside the ordering keys', () => { + const anonymousPair = () => [ + makeFacts(null, { position: [0, 0, 0] }), + makeFacts(null, { position: [9, 9, 9] }), + ]; + + const [a, b] = anonymousPair(); + const forward = plan([a, b]); + + expect(forward.collections[0].members.map((m) => m.position)).toEqual([ + [0, 0, 0], + [9, 9, 9], + ]); + expect(plan([b, a])).toEqual(forward); + }); + + it('keeps an unreadable position out of the collection it cannot be placed in', () => { + const blind = makeFacts('uid-a', { + projectedPosition: null, + instanceNumber: 3, + }); + const alsoBlind = makeFacts('uid-c', { + projectedPosition: null, + instanceNumber: 1, + }); + const seen = makeFacts('uid-b', { + projectedPosition: 2, + instanceNumber: 1, + }); + + const { collections } = plan([blind, seen, alsoBlind]); + + expect(collections).toHaveLength(2); + expect(uidsOf(collectionWith(collections, 'uid-b'))).toEqual(['uid-b']); + + const unknown = collectionWith(collections, 'uid-a'); + expect(uidsOf(unknown)).toEqual(['uid-c', 'uid-a']); + expect(unknown.order).toBe('instance-number'); + expect(mentions(unknown.diagnostics, 'uid-a')).toBe(true); + expect(unknown.warnings).toEqual([UNREADABLE_POSITION_WARNING]); + }); + + it('says nothing about a series that is one positionless instance', () => { + const alone = makeFacts('uid-a', { + projectedPosition: null, + instanceNumber: 1, + }); + + const { collections } = plan([alone]); + + expect(collections).toHaveLength(1); + expect(collections[0].warnings).toEqual([]); + }); + + it('warns about a lone positionless instance beside a volume it missed', () => { + const alone = makeFacts('uid-a', { + projectedPosition: null, + instanceNumber: 1, + }); + const seen = makeFacts('uid-b', { + projectedPosition: 2, + instanceNumber: 2, + }); + + const { collections } = plan([alone, seen]); + + expect(collectionWith(collections, 'uid-a').warnings).toEqual([ + UNREADABLE_POSITION_WARNING, + ]); + }); + + it('breaks a shared instance number on SOP Instance UID', () => { + const second = makeFacts('uid-b', { + projectedPosition: null, + instanceNumber: 4, + }); + const first = makeFacts('uid-a', { + projectedPosition: null, + instanceNumber: 4, + }); + + const { collections } = plan([second, first]); + + expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']); + expect(collections[0].order).toBe('instance-number'); + }); + + it('records input order when neither position nor instance number reads', () => { + const second = makeFacts('uid-b', { + projectedPosition: null, + instanceNumber: null, + }); + const first = makeFacts('uid-a', { + projectedPosition: null, + instanceNumber: null, + }); + + const { collections } = plan([second, first]); + + expect(uidsOf(collections[0])).toEqual(['uid-b', 'uid-a']); + expect(collections[0].order).toBe('input'); + expect(mentions(collections[0].diagnostics, 'uid-a')).toBe(true); + expect(mentions(collections[0].diagnostics, 'uid-b')).toBe(true); + }); + + const permutable = () => [ + makeFacts('uid-1', { projectedPosition: 2, instanceNumber: 2 }), + makeFacts('uid-2', { projectedPosition: 1, instanceNumber: 1 }), + makeFacts('uid-3', { + projectedPosition: 0, + instanceNumber: 3, + sliceThickness: '5', + }), + makeFacts('uid-4', { + projectedPosition: 4, + instanceNumber: 4, + sliceThickness: '5', + }), + makeFacts('uid-5', { + projectedPosition: 3, + instanceNumber: 5, + orientation: tiltedOrientation(BEYOND), + }), + makeFacts('uid-6', { + projectedPosition: 5, + instanceNumber: 6, + orientation: tiltedOrientation(BEYOND), + }), + ]; + + it('is invariant to input permutation in membership and order', () => { + const base = plan(permutable()); + const reversed = plan(permutable().reverse()); + const rotated = (() => { + const items = permutable(); + return plan([...items.slice(2), ...items.slice(0, 2)]); + })(); + const interleaved = (() => { + const items = permutable(); + return plan([items[5], items[0], items[3], items[1], items[4], items[2]]); + })(); + + expect(base.collections).toHaveLength(3); + expect(reversed).toEqual(base); + expect(rotated).toEqual(base); + expect(interleaved).toEqual(base); + }); + + it('does not mutate the instances it is given', () => { + const instances = permutable(); + const before = clone(instances); + + plan(instances); + + expect(clone(instances)).toEqual(before); + }); + + it('holds every instance exactly once across the collections', () => { + const instances = permutable(); + + const { collections } = plan(instances); + const members = collections.flatMap((c) => c.members); + + expect(members).toHaveLength(instances.length); + expect(new Set(members).size).toBe(instances.length); + instances.forEach((instance) => expect(members).toContain(instance)); + expect(() => validateCollections(instances, collections)).not.toThrow(); + }); +}); + +describe('planDicomCollections semantic partition', () => { + const pass = (acquisition: string, zs: number[]) => + zs.map((z, i) => + makeFacts(`acq${acquisition}-${i}`, { + projectedPosition: z, + instanceNumber: i + 1, + acquisitionNumber: acquisition, + }) + ); + + it('separates overlapping passes and labels each collection', () => { + const first = pass('1', [0, 2, 4]); + const second = pass('2', [1, 3, 5]); + + const { collections } = plan([...first, ...second]); + + expect(collections).toHaveLength(2); + const [one, two] = collections; + expect(one.label).toBe('acquisition 1'); + expect(partValue(one, 'acquisition')).toBe('1'); + expect(uidsOf(one)).toEqual(['acq1-0', 'acq1-1', 'acq1-2']); + expect(one.order).toBe('spatial'); + expect(two.label).toBe('acquisition 2'); + expect(partValue(two, 'acquisition')).toBe('2'); + expect(collections.every((c) => c.warnings.length === 0)).toBe(true); + }); + + // Two members whose cosines agree within the tolerance are one plane. Each + // member's own normal would place them at two positions a fraction of a + // micron apart, and the repeated slice would go unreported. + it('projects a bucket on one normal rather than on each member of it', () => { + const straight = makeFacts('uid-a', { + position: [0, 0, 10], + projectedPosition: 10, + }); + const tilted = makeFacts('uid-b', { + orientation: tiltedOrientation(WITHIN), + position: [0, 0, 10], + projectedPosition: 10 * Math.cos(WITHIN), + }); + + const { collections } = plan([straight, tilted]); + + expect(collections).toHaveLength(1); + expect(collections[0].warnings).toEqual([REPEATED_POSITIONS_WARNING]); + }); + + it('partitions each orientation bucket on its own', () => { + const stack = [...pass('1', [0, 2]), ...pass('2', [1, 3])]; + const scout = makeFacts('scout', { + orientation: tiltedOrientation(BEYOND), + acquisitionNumber: '1', + }); + + const { collections } = plan([...stack, scout]); + + expect(collections).toHaveLength(3); + expect(collectionWith(collections, 'scout').label).toBeNull(); + expect(collectionWith(collections, 'acq1-0').label).toBe('acquisition 1'); + }); + + it('leaves an unlabelled collection when nothing overlaps', () => { + const { collections } = plan(pass('1', [0, 2, 4])); + + expect(collections[0].label).toBeNull(); + expect(partValue(collections[0], 'acquisition')).toBeNull(); + expect(collections[0].warnings).toEqual([]); + }); + + it('warns that an unevenly spaced collection is not one regular volume', () => { + // Two 4mm runs with a gap between them, as the bilateral sagittal slabs + // of idcSeriesFixtures.ts are. One collection, but no single lattice. + const { collections } = plan(pass('1', [0, 4, 8, 50, 54, 58])); + + expect(collections).toHaveLength(1); + expect(collections[0].warnings).toEqual([IRREGULAR_VOLUME_WARNING]); + }); + + it('leaves an evenly spaced collection unwarned', () => { + const { collections } = plan(pass('1', [0, 4, 8, 12])); + + expect(collections[0].warnings).toEqual([]); + }); + + it('warns that a tilted gantry stack was stacked without correcting its shear', () => { + const theta = Math.PI / 9; + const orientation = tiltedOrientation(theta); + // The table advances along z while the columns lean into it, so each + // slice plane sits sideways of the last on the stack's own normal. + const stack = [0, 3, 6, 9].map((z, i) => + makeFacts(`tilt-${i}`, { + orientation, + position: [0, 0, z], + projectedPosition: z * Math.cos(theta), + instanceNumber: i + 1, + }) + ); + + const { collections } = plan(stack); + + expect(collections).toHaveLength(1); + expect(collections[0].order).toBe('spatial'); + expect(uidsOf(collections[0])).toEqual(stack.map((m) => m.sopInstanceUid)); + expect(collections[0].warnings).toEqual([IN_PLANE_SHIFT_WARNING]); + }); + + // A 358mm field of view, so a lean shows at the far corner of a slice. + const wideStack = (strayUid: string, lean: number) => + [0, 5, 10, 15].map((z, i) => + makeFacts(i === 2 ? strayUid : `wide-${i}`, { + rows: '512', + columns: '512', + pixelSpacing: [0.7, 0.7], + orientation: i === 2 ? tiltedOrientation(lean) : IDENTITY_ORIENTATION, + projectedPosition: z, + instanceNumber: i + 1, + }) + ); + + it.each(['a-walked-first', 'z-walked-last'])( + 'takes the axes most slices declare when the stray one is %s', + (strayUid) => { + const { collections } = plan(wideStack(strayUid, 0.0002)); + + expect(collections).toHaveLength(1); + expect(collections[0].orientation).toEqual(IDENTITY_ORIENTATION); + expect(collections[0].warnings).toEqual([]); + } + ); + + it('warns when a slice leans off the volume axes by more than a tenth of a millimetre', () => { + const { collections } = plan(wideStack('wide-stray', 0.001)); + + expect(collections).toHaveLength(1); + expect(collections[0].orientation).toEqual(IDENTITY_ORIENTATION); + expect(collections[0].warnings).toEqual([NONPARALLEL_SLICES_WARNING]); + }); + + it('warns about a repeated position no axis separates', () => { + const { collections } = plan(pass('1', [0, 2, 2, 4])); + + expect(collections).toHaveLength(1); + expect(collections[0].warnings).toEqual([REPEATED_POSITIONS_WARNING]); + }); + + // A later import can bring a slice whose geometry cannot be read. Merging it + // back in would undo a separation the positions of the other members + // already earned. + it('keeps two separated acquisitions apart when a positionless member arrives', () => { + const first = pass('1', [0, 2, 4]); + const second = pass('2', [1, 3, 5]); + const before = plan([...first, ...second]); + + const blind = makeFacts('blind', { + projectedPosition: null, + position: null, + instanceNumber: 7, + }); + const after = plan([...first, ...second, blind]); + + expect(before.collections).toHaveLength(2); + expect(after.collections).toHaveLength(3); + + // The two evidence-backed collections survive unchanged, keys included. + before.collections.forEach((collection) => { + const kept = collectionWith( + after.collections, + collection.members[0].sopInstanceUid! + ); + expect(kept.key).toEqual(collection.key); + expect(uidsOf(kept)).toEqual(uidsOf(collection)); + expect(kept.warnings).toEqual([]); + }); + + const unknown = collectionWith(after.collections, 'blind'); + expect(uidsOf(unknown)).toEqual(['blind']); + expect(unknown.warnings).toEqual([UNREADABLE_POSITION_WARNING]); + expect(unknown.key).not.toEqual(before.collections[0].key); + }); + + it('gives the two passes distinct, collision-free keys', () => { + const { collections } = plan([...pass('1', [0, 2]), ...pass('2', [1, 3])]); + + expect(() => + validateCollections( + collections.flatMap((c) => c.members), + collections + ) + ).not.toThrow(); + }); +}); + +describe('validateCollections', () => { + const a = makeFacts('uid-a'); + const b = makeFacts('uid-b'); + + const collectionOf = ( + members: InstanceFacts[], + parts: Array<[string, string | null]> + ): DicomCollection => ({ + key: { seriesKey: SERIES_KEY, parts }, + members, + order: 'input', + reconstruction: { + kind: 'regular', + sliceSpacing: null, + slots: members.map((_, index) => index), + }, + orientation: null, + label: null, + diagnostics: [], + warnings: [], + }); + + const partsOne: Array<[string, string | null]> = [['orientation', 'one']]; + const partsTwo: Array<[string, string | null]> = [['orientation', 'two']]; + + it('accepts a conserving, collision-free plan', () => { + expect(() => + validateCollections( + [a, b], + [collectionOf([a], partsOne), collectionOf([b], partsTwo)] + ) + ).not.toThrow(); + }); + + it('rejects an instance that landed in no collection', () => { + expect(() => + validateCollections([a, b], [collectionOf([a], partsOne)]) + ).toThrow('uid-b'); + }); + + it('rejects an instance that landed in two collections', () => { + expect(() => + validateCollections( + [a], + [collectionOf([a], partsOne), collectionOf([a], partsTwo)] + ) + ).toThrow('uid-a'); + }); + + it('accepts an instance listed twice when it is placed twice', () => { + expect(() => + validateCollections([a, a], [collectionOf([a, a], partsOne)]) + ).not.toThrow(); + }); + + it('rejects an instance listed twice but placed once', () => { + expect(() => + validateCollections([a, a], [collectionOf([a], partsOne)]) + ).toThrow('uid-a'); + }); + + it('rejects a member that was never one of the instances', () => { + expect(() => + validateCollections([a], [collectionOf([a, b], partsOne)]) + ).toThrow('uid-b'); + }); + + it('rejects two collections sharing a key', () => { + expect(() => + validateCollections( + [a, b], + [collectionOf([a], partsOne), collectionOf([b], partsOne)] + ) + ).toThrow(/key/i); + }); +}); + +describe('independent multi-frame instances', () => { + it.each([null, 0])( + 'keeps equal-shaped clips separate at position %s', + (position) => { + const instances = ['clip-a', 'clip-b'].map((uid) => + makeFacts(uid, { + numberOfFrames: '3', + position: position === null ? null : [0, 0, position], + projectedPosition: position, + }) + ); + const collections = plan(instances).collections; + expect(collections.map((c) => uidsOf(c))).toEqual([ + ['clip-a'], + ['clip-b'], + ]); + expect(new Set(collections.map((c) => JSON.stringify(c.key))).size).toBe( + 2 + ); + expect(plan([...instances].reverse())).toEqual(plan(instances)); + } + ); +}); diff --git a/packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts b/packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts new file mode 100644 index 000000000..2634f4969 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts @@ -0,0 +1,227 @@ +import { datasetFromTags } from '@/tests/dicomDataset'; +import { describe, expect, it } from 'vitest'; +import { Tags } from '../../metadata/dicomTags'; +import { readInstanceFacts as readStructuredFacts } from '../instanceFacts'; +import { reconstructVolume } from '../reconstructVolume'; +import { + bilateralSagittalSlabs, + type IdcSeriesFixture, +} from './idcSeriesFixtures'; + +// An axial slice at z, read through the fact reader so the fixtures stay in +// the tag vocabulary the real data uses. +const slice = (z: number, tags: Record = {}) => + readInstanceFacts([ + ...Object.entries({ + [Tags.ImageOrientationPatient]: '1\\0\\0\\0\\1\\0', + [Tags.ImagePositionPatient]: `0\\0\\${z}`, + [Tags.PixelSpacing]: '0.5\\0.5', + ...tags, + }), + ]); + +const stack = (zs: number[]) => zs.map((z) => slice(z)); + +// A gantry tilted by theta leans the columns into z, so each slice plane sits +// sideways of the last as the table advances along z. +const tiltedSlice = (z: number, theta: number) => + slice(z, { + [Tags.ImageOrientationPatient]: [ + 1, + 0, + 0, + 0, + Math.cos(theta), + Math.sin(theta), + ].join('\\'), + }); + +const fixtureSlices = (fixture: IdcSeriesFixture) => + fixture + .groups!.flatMap((group) => group.zs.map((z) => slice(z))) + .sort( + (left, right) => + (left.projectedPosition as number) - (right.projectedPosition as number) + ); + +describe('reconstructVolume', () => { + it('measures an evenly spaced stack and maps every member to its slot', () => { + const result = reconstructVolume(stack([-10, -6, -2, 2, 6])); + + expect(result.kind).toBe('regular'); + expect(result.sliceSpacing).toBeCloseTo(4, 12); + expect(result.slots).toEqual([0, 1, 2, 3, 4]); + }); + + it('derives the spacing from the endpoints once every gap matches', () => { + const result = reconstructVolume(stack([0, 9, 18])); + + expect(result.kind).toBe('regular'); + expect(result.sliceSpacing).toBe(9); + }); + + it('has no spacing to derive from a single member', () => { + const result = reconstructVolume(stack([7])); + + expect(result.kind).toBe('regular'); + expect(result.sliceSpacing).toBeNull(); + expect(result.slots).toEqual([0]); + }); + + // IDC series 1.3.6.1.4.1.14519.5.2.1.7009.2403.337074050757748643824459343650 + // ("WB_3D_NON-AC"): whole-body PET stepping by 3.27mm with the occasional + // 3.35mm step from printing precision. One lattice, printed unevenly. + it('accepts the printing jitter of quantized whole-body PET positions', () => { + const result = reconstructVolume(stack([0, 3.27, 6.54, 9.89, 13.16])); + + expect(result.kind).toBe('regular'); + expect(result.sliceSpacing).toBeCloseTo(3.29, 2); + }); + + // The two slabs are 4mm stacks 42.439mm apart. Endpoint distance over slice + // count would call that a 4.59mm lattice, which describes neither slab. + it('calls the bilateral sagittal slabs an irregular stack', () => { + const members = fixtureSlices(bilateralSagittalSlabs); + + const result = reconstructVolume(members); + + expect(members).toHaveLength(66); + expect(result.kind).toBe('irregular'); + expect(result).toMatchObject({ reason: 'uneven-slice-spacing' }); + // The fallback places the slices at a gap the series actually holds. + expect(result.sliceSpacing).toBeCloseTo(4, 12); + expect(result.sliceSpacing).not.toBeCloseTo(4.59137, 4); + }); + + it('reports a hole in an otherwise even stack as irregular', () => { + const result = reconstructVolume(stack([0, 4, 8, 20, 24])); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'uneven-slice-spacing', + }); + }); + + it('reports a repeated slice position as irregular', () => { + const result = reconstructVolume(stack([0, 4, 4, 8])); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'repeated-position', + }); + }); + + it('reports a member whose position cannot be read as irregular', () => { + const blind = readInstanceFacts([ + [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0'], + [Tags.PixelSpacing, '0.5\\0.5'], + ]); + + const result = reconstructVolume([...stack([0, 4]), blind]); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'unreadable-geometry', + }); + expect(result.sliceSpacing).toBeNull(); + }); + + it('reports a slice shifted within its own plane as irregular', () => { + const shifted = readInstanceFacts([ + [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0'], + [Tags.ImagePositionPatient, '0\\30\\8'], + [Tags.PixelSpacing, '0.5\\0.5'], + ]); + + const result = reconstructVolume([...stack([0, 4]), shifted]); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'in-plane-shift', + }); + // The fallback is a step the members do take, not the one to the outlier. + expect(result.sliceSpacing).toBeCloseTo(4, 12); + }); + + it('steps a tilted gantry stack by the distance its slices advance', () => { + const theta = Math.PI / 9; + + const result = reconstructVolume( + [0, 3, 6, 9].map((z) => tiltedSlice(z, theta)) + ); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'in-plane-shift', + }); + // The table stepped 3mm a slice, which is the extent ITK's series reader + // keeps too; the shear is warned about, not folded into the spacing. + expect(result.sliceSpacing).toBeCloseTo(3, 12); + expect(result.slots).toEqual([0, 1, 2, 3]); + }); + + it('reports two in-plane sample spacings as irregular', () => { + const coarse = slice(8, { [Tags.PixelSpacing]: '0.6\\0.6' }); + + const result = reconstructVolume([...stack([0, 4]), coarse]); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'mixed-pixel-spacing', + }); + expect(result.sliceSpacing).toBeCloseTo(4, 12); + }); + + it('steps a stack whose orientation cannot be read by its positions', () => { + const blind = (z: number) => + readInstanceFacts([ + [Tags.ImagePositionPatient, `0\\0\\${z}`], + [Tags.PixelSpacing, '0.5\\0.5'], + ]); + + const result = reconstructVolume([0, 4, 8].map(blind)); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'unreadable-geometry', + }); + expect(result.sliceSpacing).toBeCloseTo(4, 12); + }); + + it('has no step for members that never move', () => { + const result = reconstructVolume(stack([5, 5, 5])); + + expect(result).toMatchObject({ + kind: 'irregular', + reason: 'repeated-position', + }); + expect(result.sliceSpacing).toBeNull(); + }); + + it('projects on the normal it is given rather than on the first member', () => { + const members = stack([0, 4, 8]); + + // On the members' own normal the stack is even; on +x it steps sideways. + expect(reconstructVolume(members).kind).toBe('regular'); + expect(reconstructVolume(members, [1, 0, 0])).toMatchObject({ + kind: 'irregular', + reason: 'in-plane-shift', + }); + }); +}); + +function readInstanceFacts(tags: Iterable) { + return readStructuredFacts(datasetFromTags(tags)); +} + +it.each([0.01, 0.05, 0.1])( + 'detects a missing slice in a %s mm stack', + (step) => { + expect( + reconstructVolume(stack([0, 1, 2, 4, 5, 6].map((i) => i * step))) + ).toMatchObject({ + kind: 'irregular', + reason: 'uneven-slice-spacing', + }); + } +); diff --git a/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts b/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts new file mode 100644 index 000000000..b84488d6a --- /dev/null +++ b/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts @@ -0,0 +1,577 @@ +import { datasetFromTags } from '@/tests/dicomDataset'; +import { describe, it, expect } from 'vitest'; +import { Tags } from '../../metadata/dicomTags'; +import { readInstanceFacts as readStructuredFacts } from '../instanceFacts'; +import type { InstanceFacts } from '../planDicomCollections'; +import { + GEOMETRY_RULE, + SEMANTIC_AXES, + UNREADABLE_GEOMETRY, + UNREADABLE_GEOMETRY_LABEL, + splitOverlappingAcquisitions, + type SemanticPart, +} from '../splitOverlappingAcquisitions'; +import { makeFacts } from './instanceFactsFixture'; +import { + bilateralSagittalSlabs, + dceEightPhase, + dceSevenPhasePhilips, + dixonDualEcho, + dixonUnevenEchoes, + doubledAcquisition, + dwiMultiBValue, + partialTemporalTags, + type IdcSeriesFixture, +} from './idcSeriesFixtures'; + +let nextUid = 0; + +// An axial slice at z carrying the given tags, read through the fact reader so +// the fixtures stay in the tag vocabulary the real data uses. +function slice(z: number, tags: Record = {}) { + nextUid += 1; + return readInstanceFacts([ + [Tags.SOPInstanceUID, `1.2.3.${nextUid}`], + [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0'], + [Tags.ImagePositionPatient, `0\\0\\${z}`], + ...Object.entries(tags), + ]); +} + +// A stack of `count` slices starting at `start`, tagged with an acquisition. +const stack = (acquisition: string, start: number, count: number, step = 2.5) => + Array.from({ length: count }, (_, i) => + slice(start + i * step, { [Tags.AcquisitionNumber]: acquisition }) + ); + +const zOf = (facts: InstanceFacts) => facts.projectedPosition as number; +const byPosition = (a: InstanceFacts, b: InstanceFacts) => zOf(a) - zOf(b); + +// Slices mirroring a real IDC series recorded in idcSeriesFixtures.ts. +const fixtureSlices = (fixture: IdcSeriesFixture) => + fixture + .groups!.flatMap((group) => group.zs.map((z) => slice(z, group.tags))) + .sort(byPosition); + +const labels = (parts: SemanticPart[]) => + parts.map((part) => part.label).sort(); + +const partLabelled = (parts: SemanticPart[], label: string | null) => { + const found = parts.find((part) => part.label === label); + if (!found) throw new Error(`no part labelled ${label}`); + return found; +}; + +const partValue = (part: SemanticPart, rule: string) => { + const entry = part.parts.find(([name]) => name === rule); + if (!entry) throw new Error(`no part named ${rule}`); + return entry[1]; +}; + +const unsplitParts = SEMANTIC_AXES.map((axis) => [axis.rule, null]); + +describe('splitOverlappingAcquisitions', () => { + it('passes a single acquisition through as one unlabelled part', () => { + const members = stack('1', 0, 5); + + const parts = splitOverlappingAcquisitions(members); + + expect(parts).toEqual([ + { + parts: unsplitParts, + members, + label: null, + repeatedPositions: false, + unreadablePositions: false, + }, + ]); + }); + + it('names one key part per axis, in axis order, split or not', () => { + const parts = splitOverlappingAcquisitions(stack('1', 0, 3)); + + expect(parts[0].parts.map(([rule]) => rule)).toEqual([ + 'acquisition', + 'phase', + 'echo', + 'b-value', + ]); + }); + + it('separates overlapping acquisitions merged into one series', () => { + // The reference bug, IDC series + // 1.3.6.1.4.1.14519.5.2.1.3098.5025.295130953269492004748715270821 + // ("ST+ N15/12/17 A30/20/40"): three overlapping 2.5mm passes that merged + // into one 447-slice volume at a fabricated 1.478mm spacing. + const acq1 = stack('1', -20, 5); + const acq2 = stack('2', -19.25, 6); + const acq3 = stack('3', -18.5, 4); + + const parts = splitOverlappingAcquisitions( + [...acq1, ...acq2, ...acq3].sort(byPosition) + ); + + expect(labels(parts)).toEqual([ + 'acquisition 1', + 'acquisition 2', + 'acquisition 3', + ]); + expect(partLabelled(parts, 'acquisition 1').members).toEqual(acq1); + expect(partLabelled(parts, 'acquisition 2').members).toEqual(acq2); + expect(partLabelled(parts, 'acquisition 3').members).toEqual(acq3); + expect(partLabelled(parts, 'acquisition 2').parts).toEqual([ + ['acquisition', '2'], + ['phase', null], + ['echo', null], + ['b-value', null], + ]); + expect(parts.every((part) => !part.repeatedPositions)).toBe(true); + }); + + it('keeps acquisitions that follow one another together', () => { + // IDC series + // 1.3.6.1.4.1.14519.5.2.1.7009.2403.262533307142705262678914598920 + // ("Recon 2: CHEST"): one continuous 5mm stack whose slices carry three + // acquisition numbers, with a 30mm hole where slices are missing. Uneven + // spacing is not a reason to tear the scans apart. + const merged = [ + ...stack('3', -320.25, 4, 5), + ...stack('2', -300.25, 6, 5), + ...stack('1', -245.25, 56, 5), + ].sort(byPosition); + + const parts = splitOverlappingAcquisitions(merged); + + expect(parts).toHaveLength(1); + expect(parts[0].members).toEqual(merged); + expect(parts[0].label).toBeNull(); + expect(parts[0].repeatedPositions).toBe(false); + }); + + it('leaves a long run of sequential acquisitions merged', () => { + // IDC series + // 1.3.6.1.4.1.14519.5.2.1.3320.3273.243812674588352758771557518364: + // acquisitions numbered 20 through 36 across one evenly spaced stack. + const groups = Array.from({ length: 17 }, (_, i) => + stack(String(20 + i), i * 25, 10) + ); + + const parts = splitOverlappingAcquisitions(groups.flat().sort(byPosition)); + + expect(parts).toHaveLength(1); + }); + + it('tolerates quantized whole-body PET positions', () => { + // IDC series + // 1.3.6.1.4.1.14519.5.2.1.7009.2403.337074050757748643824459343650 + // ("WB_3D_NON-AC"): whole-body PET stepping by 3.27mm with the occasional + // 3.35mm step from printing precision. One acquisition, nothing to + // separate. + const members = [0, 3.27, 6.54, 9.89, 13.16, 16.43].map((z) => + slice(z, { [Tags.AcquisitionNumber]: '1' }) + ); + + const parts = splitOverlappingAcquisitions(members); + + expect(parts).toHaveLength(1); + expect(parts[0].repeatedPositions).toBe(false); + }); + + it('leaves repeat scans of one plane whole and flags the repetition', () => { + // IDC series + // 1.3.6.1.4.1.14519.5.2.1.7009.2403.668914995861790731496154478744 + // ("Smart Prep Series"), bolus tracking: seven scans of the same slice, + // one acquisition each. That is a time series of one plane, and fanning it + // out would make seven one-slice datasets. + const members = Array.from({ length: 7 }, (_, i) => + slice(-82.89, { [Tags.AcquisitionNumber]: String(i + 1) }) + ); + + const parts = splitOverlappingAcquisitions(members); + + expect(parts).toEqual([ + { + parts: unsplitParts, + members, + label: null, + repeatedPositions: true, + unreadablePositions: false, + }, + ]); + }); + + it('does not fan out a counter stamped per slice over one repeated position', () => { + // Some scanners set AcquisitionNumber from InstanceNumber. One duplicate + // slice must not turn such a stack into one dataset per slice. + const members = [0, 2.5, 5, 5, 7.5, 10].map((z, i) => + slice(z, { [Tags.AcquisitionNumber]: String(i + 1) }) + ); + + const parts = splitOverlappingAcquisitions(members); + + expect(parts).toHaveLength(1); + expect(parts[0].label).toBeNull(); + expect(parts[0].repeatedPositions).toBe(true); + }); + + it('leaves a single overlapping slice of another pass merged', () => { + const members = [...stack('1', 0, 3), ...stack('2', 2.5, 1)].sort( + byPosition + ); + + const parts = splitOverlappingAcquisitions(members); + + expect(parts).toHaveLength(1); + expect(parts[0].repeatedPositions).toBe(true); + }); + + it('leaves a repeated plane inside a split whole', () => { + // A full stack plus a bolus-tracking pass of one plane, each frame with + // its own phase: the pass is a time series, not twenty datasets. + const volume = stack('1', 0, 10); + const monitoring = Array.from({ length: 20 }, (_, i) => + slice(5, { + [Tags.AcquisitionNumber]: '2', + [Tags.TemporalPositionIdentifier]: String(i + 1), + }) + ); + + const parts = splitOverlappingAcquisitions( + [...volume, ...monitoring].sort(byPosition) + ); + + expect(labels(parts)).toEqual(['acquisition 1', 'acquisition 2']); + expect(partLabelled(parts, 'acquisition 2').members).toHaveLength(20); + expect(partLabelled(parts, 'acquisition 2').repeatedPositions).toBe(true); + }); + + it('separates scans that share a boundary slice', () => { + const merged = [...stack('1', 0, 4), ...stack('2', 7.5, 4)].sort( + byPosition + ); + + expect(labels(splitOverlappingAcquisitions(merged))).toEqual([ + 'acquisition 1', + 'acquisition 2', + ]); + }); + + it('does not separate scans that abut without overlapping', () => { + const merged = [...stack('1', 0, 4), ...stack('2', 10, 4)].sort(byPosition); + + expect(splitOverlappingAcquisitions(merged)).toHaveLength(1); + }); + + it('reports repeated positions no axis separates', () => { + const members = [0, 2.5, 2.5, 5].map((z) => + slice(z, { [Tags.AcquisitionNumber]: '1' }) + ); + + const parts = splitOverlappingAcquisitions(members); + + expect(parts).toHaveLength(1); + expect(parts[0].repeatedPositions).toBe(true); + }); + + it('ignores slices with no acquisition number', () => { + const members = [0, 2.5, 5, 6.25, 8.75].map((z) => slice(z)); + + const parts = splitOverlappingAcquisitions(members); + + expect(parts).toHaveLength(1); + expect(parts[0].repeatedPositions).toBe(false); + }); + + it('holds members whose position cannot be read in a part of their own', () => { + const blind = makeFacts('uid-a', { + projectedPosition: null, + acquisitionNumber: '1', + }); + const first = stack('2', 0, 3); + const second = stack('3', 1, 3); + + const parts = splitOverlappingAcquisitions([blind, ...first, ...second]); + + // The two overlapping acquisitions stay separated, and the member nothing + // spatial can be said about is diagnosed rather than merged into them. + expect(labels(parts)).toEqual([ + 'acquisition 2', + 'acquisition 3', + UNREADABLE_GEOMETRY_LABEL, + ]); + const unknown = partLabelled(parts, UNREADABLE_GEOMETRY_LABEL); + expect(unknown.members).toEqual([blind]); + expect(partLabelled(parts, 'acquisition 2').members).toEqual(first); + expect(partLabelled(parts, 'acquisition 3').members).toEqual(second); + expect(unknown.unreadablePositions).toBe(true); + expect(partValue(unknown, GEOMETRY_RULE)).toBe(UNREADABLE_GEOMETRY); + parts + .filter((part) => part !== unknown) + .forEach((part) => { + expect(part.unreadablePositions).toBe(false); + expect(part.parts.some(([rule]) => rule === GEOMETRY_RULE)).toBe(false); + }); + }); + + it('keeps members whose positions are all unreadable in one diagnosed part', () => { + const blind = [ + makeFacts('uid-a', { projectedPosition: null }), + makeFacts('uid-b', { projectedPosition: null }), + ]; + + const parts = splitOverlappingAcquisitions(blind); + + expect(parts).toHaveLength(1); + expect(parts[0].members).toEqual(blind); + expect(parts[0].unreadablePositions).toBe(true); + }); + + it('keeps each part in the member order it was given', () => { + const acq1 = stack('1', -20, 5); + const acq2 = stack('2', -19.25, 6); + const merged = [...acq1, ...acq2].sort(byPosition); + + const parts = splitOverlappingAcquisitions(merged); + + parts.forEach((part) => { + expect(part.members).toEqual([...part.members].sort(byPosition)); + }); + }); + + it('is invariant to member permutation in membership', () => { + const acq1 = stack('1', -20, 5); + const acq2 = stack('2', -19.25, 6); + const forward = splitOverlappingAcquisitions([...acq1, ...acq2]); + const backward = splitOverlappingAcquisitions([...acq2, ...acq1].reverse()); + + const membership = (parts: SemanticPart[]) => + parts + .map((part) => ({ + label: part.label, + uids: part.members.map((m) => m.sopInstanceUid).sort(), + })) + .sort((a, b) => (a.label! < b.label! ? -1 : 1)); + + expect(membership(backward)).toEqual(membership(forward)); + }); + + it('keeps the value spelling the fact reader normalized', () => { + const merged = [...stack('01', 0, 5), ...stack('+2', 0.75, 5)].sort( + byPosition + ); + + const parts = splitOverlappingAcquisitions(merged); + + expect(labels(parts)).toEqual(['acquisition 1', 'acquisition 2']); + }); +}); + +describe('splitOverlappingAcquisitions temporal and echo axes', () => { + it('separates DCE timepoints that re-scan one range', () => { + const parts = splitOverlappingAcquisitions(fixtureSlices(dceEightPhase)); + + expect(labels(parts)).toEqual( + ['0', '1', '2', '3', '4', '5', '6', '7'].map((p) => `phase ${p}`) + ); + expect(partLabelled(parts, 'phase 3').members).toHaveLength(80); + expect(partValue(partLabelled(parts, 'phase 3'), 'phase')).toBe('3'); + expect(parts.every((part) => !part.repeatedPositions)).toBe(true); + }); + + it('separates timepoints across vendors and 1-based numbering', () => { + const parts = splitOverlappingAcquisitions( + fixtureSlices(dceSevenPhasePhilips) + ); + + expect(parts).toHaveLength(7); + parts.forEach((part) => { + expect(part.members).toHaveLength(158); + expect(part.repeatedPositions).toBe(false); + }); + }); + + it('separates Dixon echoes that nothing else distinguishes', () => { + const parts = splitOverlappingAcquisitions(fixtureSlices(dixonDualEcho)); + + expect(labels(parts)).toEqual(['echo 1', 'echo 2']); + expect(partLabelled(parts, 'echo 1').parts).toEqual([ + ['acquisition', null], + ['phase', null], + ['echo', '1'], + ['b-value', null], + ]); + }); + + it('separates echoes with unequal slice counts', () => { + const parts = splitOverlappingAcquisitions( + fixtureSlices(dixonUnevenEchoes) + ); + + expect(partLabelled(parts, 'echo 1').members).toHaveLength(13); + expect(partLabelled(parts, 'echo 2').members).toHaveLength(12); + }); + + it('separates repeated diffusion positions by b-value', () => { + const parts = splitOverlappingAcquisitions(fixtureSlices(dwiMultiBValue)); + + expect(labels(parts)).toEqual([ + 'b-value 0', + 'b-value 100', + 'b-value 600', + 'b-value 800', + ]); + parts.forEach((part) => { + expect(part.members).toHaveLength(30); + expect(part.repeatedPositions).toBe(false); + }); + }); + + it('isolates a slice missing the discriminator instead of blocking the split', () => { + const b0 = [0, 2, 4].map((z) => slice(z, { [Tags.DiffusionBValue]: '0' })); + const b800 = [0, 2, 4].map((z) => + slice(z, { [Tags.DiffusionBValue]: '800' }) + ); + const untagged = slice(2); + + const parts = splitOverlappingAcquisitions( + [...b0, untagged, ...b800].sort(byPosition) + ); + + expect(labels(parts)).toEqual([ + 'b-value 0', + 'b-value 800', + 'b-value unknown', + ]); + expect(partLabelled(parts, 'b-value unknown').members).toEqual([untagged]); + expect( + partValue(partLabelled(parts, 'b-value unknown'), 'b-value') + ).toBeNull(); + }); + + it('still separates contrasts of a single plane', () => { + // A one-slice in/out-phase pair differs in echo, not in time, so each + // echo is its own dataset even at one position. + const members = ['1', '2'].map((echo) => + slice(0, { [Tags.AcquisitionNumber]: '1', [Tags.EchoNumbers]: echo }) + ); + + const parts = splitOverlappingAcquisitions(members); + + expect(labels(parts)).toEqual(['echo 1', 'echo 2']); + expect(parts.every((part) => !part.repeatedPositions)).toBe(true); + }); + + it('treats equivalent integer spellings as one tag value', () => { + const merged = [...stack('+1', 0, 5), ...stack('01', 0, 5)].sort( + byPosition + ); + + const parts = splitOverlappingAcquisitions(merged); + + expect(parts).toHaveLength(1); + expect(parts[0].repeatedPositions).toBe(true); + }); + + it('does not split bilateral slabs whose stacks do not overlap', () => { + // StackID (0020|9056) differs per slab, but the slabs form one sound + // volume with a gap; this series is why StackID is not an axis. + const parts = splitOverlappingAcquisitions( + fixtureSlices(bilateralSagittalSlabs) + ); + + expect(parts).toHaveLength(1); + expect(parts[0].repeatedPositions).toBe(false); + }); + + it('splits on acquisition when the temporal tag is partially present', () => { + const parts = splitOverlappingAcquisitions( + fixtureSlices(partialTemporalTags) + ); + + expect(labels(parts)).toEqual( + ['1', '2', '3', '4', '5', '6', '7'].map((a) => `acquisition ${a}`) + ); + expect(partLabelled(parts, 'acquisition 1').members).toHaveLength(72); + expect(parts.every((part) => !part.repeatedPositions)).toBe(true); + }); + + it('keeps sequential timepoints merged', () => { + const phase = (value: string, start: number, count: number) => + Array.from({ length: count }, (_, i) => + slice(start + i * 5, { [Tags.TemporalPositionIdentifier]: value }) + ); + + const parts = splitOverlappingAcquisitions( + [...phase('1', 0, 5), ...phase('2', 30, 5)].sort(byPosition) + ); + + expect(parts).toHaveLength(1); + }); + + it('splits a doubled acquisition and still flags the repetition', () => { + const parts = splitOverlappingAcquisitions( + fixtureSlices(doubledAcquisition) + ); + + expect(labels(parts)).toEqual(['acquisition 1', 'acquisition 2']); + expect(partLabelled(parts, 'acquisition 1').members).toHaveLength(74); + expect(partLabelled(parts, 'acquisition 1').repeatedPositions).toBe(false); + expect(partLabelled(parts, 'acquisition 2').members).toHaveLength(148); + expect(partLabelled(parts, 'acquisition 2').repeatedPositions).toBe(true); + }); + + it('recurses into each timepoint of a 4D multi-echo series', () => { + // Synthetic: no IDC case observed with two active levels. Pins the + // hierarchical key shape and label rendering. + const merged = ['1', '2'] + .flatMap((phase) => + ['1', '2'].flatMap((echo) => + Array.from({ length: 10 }, (_, i) => + slice(i * 2, { + [Tags.AcquisitionNumber]: '1', + [Tags.TemporalPositionIdentifier]: phase, + [Tags.EchoNumbers]: echo, + }) + ) + ) + ) + .sort(byPosition); + + const parts = splitOverlappingAcquisitions(merged); + + expect(labels(parts)).toEqual([ + 'phase 1, echo 1', + 'phase 1, echo 2', + 'phase 2, echo 1', + 'phase 2, echo 2', + ]); + expect(partLabelled(parts, 'phase 2, echo 1').parts).toEqual([ + ['acquisition', null], + ['phase', '2'], + ['echo', '1'], + ['b-value', null], + ]); + parts.forEach((part) => { + expect(part.members).toHaveLength(10); + }); + }); + + it('gives distinct values distinct key parts however they are spelled', () => { + // A lossy encoder once mapped '1.5' and '1-5' to one suffix; the key + // carries the normalized value itself, so no two values can collide. + const merged = [...stack('1.5', 0, 5), ...stack('1-5', 0, 5)].sort( + byPosition + ); + + const parts = splitOverlappingAcquisitions(merged); + + expect(labels(parts)).toEqual(['acquisition 1-5', 'acquisition 1.5']); + expect( + partValue(partLabelled(parts, 'acquisition 1-5'), 'acquisition') + ).toBe('1-5'); + }); +}); + +function readInstanceFacts(tags: Iterable) { + return readStructuredFacts(datasetFromTags(tags)); +} diff --git a/packages/dicom/core/collections/assignCollectionIds.ts b/packages/dicom/core/collections/assignCollectionIds.ts new file mode 100644 index 000000000..0233b9430 --- /dev/null +++ b/packages/dicom/core/collections/assignCollectionIds.ts @@ -0,0 +1,104 @@ +import type { + CollectionKey, + DicomCollection, + DicomCollectionPlan, +} from './planDicomCollections'; + +export type CommittedCollection = { + id: string; + sopInstanceUids: string[]; +}; + +export type AssignedCollection = DicomCollection & { id: string }; + +export type AssignedPlan = { + collections: AssignedCollection[]; +}; + +// encodeURIComponent escapes every separator the key values can carry, so the +// join characters below cannot appear inside an encoded component. +const escape = (value: string) => encodeURIComponent(value); + +// A present value is prefixed, so it can never collide with the missing marker. +const encodeValue = (value: string | null) => + value === null ? '~' : `.${escape(value)}`; + +/** Escaped encoding of a structured key, safe to use as a store ID. */ +export function encodeCollectionKey(key: CollectionKey) { + return [ + escape(key.seriesKey), + ...key.parts.map( + ([rule, value]) => `${escape(rule)}=${encodeValue(value)}` + ), + ].join('|'); +} + +const uidsOf = (collection: DicomCollection) => + new Set( + collection.members + .map((member) => member.sopInstanceUid) + .filter((uid): uid is string => uid !== null) + ); + +const overlapSize = (planned: Set, committed: Set) => + [...planned].filter((uid) => committed.has(uid)).length; + +const mint = (key: CollectionKey, taken: Set) => { + const base = encodeCollectionKey(key); + if (!taken.has(base)) return base; + const next = (suffix: number): string => { + const candidate = `${base}~${suffix}`; + return taken.has(candidate) ? next(suffix + 1) : candidate; + }; + return next(2); +}; + +/** + * Carries a committed ID over to the collection holding the plurality of its + * members; mints a fresh ID from the key otherwise. + */ +export function assignIds( + plan: DicomCollectionPlan, + committedIds: CommittedCollection[] +) { + const plannedUids = plan.collections.map(uidsOf); + const committedUids = committedIds.map( + (committed) => new Set(committed.sopInstanceUids) + ); + + // A committed ID moves only where its members are not split more evenly + // elsewhere: the plurality holder must be strictly ahead of every rival. + const claimable = committedIds.map((committed, committedIndex) => { + const overlaps = plannedUids.map((planned) => + overlapSize(planned, committedUids[committedIndex]) + ); + const best = Math.max(0, ...overlaps); + const holders = overlaps.filter((size) => size === best).length; + return best > 0 && holders === 1 + ? { plannedIndex: overlaps.indexOf(best), overlap: best, committed } + : null; + }); + + const claimFor = (plannedIndex: number) => + claimable + .filter( + (claim): claim is NonNullable => + claim !== null && claim.plannedIndex === plannedIndex + ) + .sort( + (left, right) => + right.overlap - left.overlap || + (left.committed.id < right.committed.id ? -1 : 1) + )[0]; + + const taken = new Set(committedIds.map((committed) => committed.id)); + + const collections = plan.collections.map((collection, index) => { + const claim = claimFor(index); + const id = claim ? claim.committed.id : mint(collection.key, taken); + taken.add(id); + return { ...collection, id }; + }); + + return { collections }; +} diff --git a/packages/dicom/core/collections/collectionRegistry.ts b/packages/dicom/core/collections/collectionRegistry.ts new file mode 100644 index 000000000..2238b9cbe --- /dev/null +++ b/packages/dicom/core/collections/collectionRegistry.ts @@ -0,0 +1,394 @@ +import type { Chunk } from '../streaming/chunk'; +import { getDatasetTag } from '../metadata/datasetAccess'; +import { Tags } from '../metadata/dicomTags'; +import { trimDicomPadding } from '../utils'; +import { + planDicomCollections, + type DicomCollection, + type InstanceFacts, +} from './planDicomCollections'; +import { + assignIds, + encodeCollectionKey, + type CommittedCollection, +} from './assignCollectionIds'; +import { readInstanceFacts, seriesKeyOf } from './instanceFacts'; + +export type CollectionUpdate = { + id: string; + seriesKey: string; + collection: DicomCollection; + // Every chunk the collection holds, in the collection's own order, index + // aligned with collection.members. + members: Chunk[]; + // Index aligned with members: the batch's own chunk wherever the batch + // supplied that instance, so a re-import's provenance reaches the caller + // while the image keeps the object it already decodes. + provenance: Chunk[]; +}; + +export type RegistrationResult = { + updates: CollectionUpdate[]; + /** Conflicting geometry under an existing SOP Instance UID; the first copy is retained. */ + conflictingInstanceUids: string[]; + // Committed IDs the replan dissolved, whose members moved elsewhere. + removed: string[]; + // Undoes this batch's registration for the named series, so a failed import + // does not pin its chunks. Collections forgotten since the batch was planned + // stay forgotten. Returns their restored members for the owner to release, + // leaving the failed batch's chunks with its caller. Assumes the caller + // registered nothing since. + rollback: (seriesKeys: Iterable) => Chunk[]; + // Whether a collection of the series was forgotten since the batch was + // planned, so a commit would bring back what the user removed. + changedSince: (seriesKey: string, affectedIds?: Iterable) => boolean; +}; + +export type DicomCollectionRegistry = { + register: (chunks: Chunk[]) => Promise; + has: (chunk: Chunk) => boolean; + forget: (collectionId: string) => Chunk[]; +}; + +// An instance with no SOP Instance UID is identified by its chunk, so two of +// them never merge while re-registering one does not duplicate it. +type InstanceKey = string | Chunk; + +type RegisteredInstance = { + key: InstanceKey; + chunk: Chunk; + facts: InstanceFacts; +}; + +type Committed = { + // The member keys the collection was last planned with, in order. + members: InstanceKey[]; + // The encoded collection key, so a relabelled collection is reported even + // when its members did not move. + key: string; +}; + +type SeriesEntry = { + instances: Map; + committed: Map; + // IDs the latest plan dissolved. They own nothing here any more, but the + // store still shows them until that plan commits, so forgetting one is a + // change the plan must hear about. + dissolving: Set; + // Every collection ID forgotten from this series, in order. + forgotten: string[]; + // Sequencing only: a settled queue holds no plan, so it pins no chunk. + queue: Promise; +}; + +type Snapshot = { + entry: SeriesEntry; + instances: SeriesEntry['instances']; + committed: SeriesEntry['committed']; + dissolving: SeriesEntry['dissolving']; + forgotten: number; +}; + +const unread = () => + new Error('Cannot register a DICOM chunk whose metadata has not been read'); + +const instanceKeyOf = (chunk: Chunk) => + trimDicomPadding(getDatasetTag(chunk.metadata, Tags.SOPInstanceUID) ?? '') || + chunk; + +const readChunk = (chunk: Chunk) => { + const { metadata } = chunk; + if (!metadata) throw unread(); + const facts = readInstanceFacts(metadata); + return { + seriesKey: seriesKeyOf(metadata), + instance: { key: instanceKeyOf(chunk), chunk, facts }, + }; +}; + +/** Groups preserve input order, both of the keys and of their members. */ +const groupBy = (items: T[], keyOf: (item: T) => string) => { + const groups = new Map(); + items.forEach((item) => { + const key = keyOf(item); + const group = groups.get(key); + if (group) group.push(item); + else groups.set(key, [item]); + }); + return groups; +}; + +/** + * Splits a batch into one list per series, preserving input order. Only the + * series key is read here, since registration reads the rest of every chunk; + * a batch naming an unread chunk still fails before any series transaction + * starts. + */ +export const groupChunksBySeries = (chunks: Chunk[]) => + groupBy(chunks, (chunk) => { + if (!chunk.metadata) throw unread(); + return seriesKeyOf(chunk.metadata); + }); + +const groupBySeries = (chunks: Chunk[]) => { + const groups = new Map(); + groupBy(chunks.map(readChunk), (read) => read.seriesKey).forEach( + (reads, seriesKey) => + groups.set( + seriesKey, + reads.map((read) => read.instance) + ) + ); + return groups; +}; + +const firstByKey = (instances: RegisteredInstance[]) => { + const byKey = new Map(); + instances.forEach((instance) => { + if (!byKey.has(instance.key)) byKey.set(instance.key, instance.chunk); + }); + return byKey; +}; + +// A collection holding no SOP Instance UID cannot be claimed back by overlap, +// so its ID is re-minted from its key instead of counting as taken. +const claimable = (committed: SeriesEntry['committed']) => + [...committed] + .map( + ([id, { members }]): CommittedCollection => ({ + id, + sopInstanceUids: members.filter( + (key): key is string => typeof key === 'string' + ), + }) + ) + .filter((collection) => collection.sopInstanceUids.length > 0); + +const unchanged = (before: Committed | undefined, after: Committed) => + before !== undefined && + before.key === after.key && + before.members.length === after.members.length && + before.members.every((key, index) => key === after.members[index]); + +function planSeries( + entry: SeriesEntry, + seriesKey: string, + batch: RegisteredInstance[] +) { + // The first registration of an instance owns its chunk: a re-supply only + // reports back, so the chunk an image already holds stays the member. + const conflictingInstanceUids = new Set(); + batch.forEach((instance) => { + const existing = entry.instances.get(instance.key); + if (!existing) entry.instances.set(instance.key, instance); + else if ( + typeof instance.key === 'string' && + JSON.stringify(existing.facts) !== JSON.stringify(instance.facts) + ) + conflictingInstanceUids.add(instance.key); + }); + + const registered = [...entry.instances.values()]; + const plan = planDicomCollections({ + seriesKey, + instances: registered.map((instance) => instance.facts), + }); + const assigned = assignIds(plan, claimable(entry.committed)); + + const instanceOf = new Map( + registered.map((instance) => [instance.facts, instance]) + ); + const batchChunks = firstByKey(batch); + + const planned = assigned.collections.map(({ id, ...collection }) => { + const instances = collection.members.map((facts) => instanceOf.get(facts)!); + const committed: Committed = { + members: instances.map((instance) => instance.key), + key: encodeCollectionKey(collection.key), + }; + return { + id, + collection, + committed, + members: instances.map((instance) => instance.chunk), + provenance: instances.map((instance) => + typeof instance.key === 'string' && + conflictingInstanceUids.has(instance.key) + ? instance.chunk + : (batchChunks.get(instance.key) ?? instance.chunk) + ), + supplied: committed.members.some((key) => batchChunks.has(key)), + }; + }); + + // A replan can move members between collections or split a collection out + // under a new key, so such changes are reported even where the batch brought + // the collection nothing. + const updates = planned + .filter( + ({ id, committed, supplied }) => + supplied || !unchanged(entry.committed.get(id), committed) + ) + .map( + ({ id, collection, members, provenance }): CollectionUpdate => ({ + id, + seriesKey, + collection, + members, + provenance, + }) + ); + + const removed = [...entry.committed.keys()].filter( + (id) => !planned.some((collection) => collection.id === id) + ); + + entry.committed = new Map( + planned.map(({ id, committed }) => [id, committed]) + ); + entry.dissolving = new Set(removed); + + return { + updates, + removed, + conflictingInstanceUids: [...conflictingInstanceUids], + }; +} + +/** + * Remembers every instance seen so far, per series, and replans the series a + * batch touches. Registration is serialized per series. + */ +export function createDicomCollectionRegistry(): DicomCollectionRegistry { + const series = new Map(); + + const entryFor = (seriesKey: string) => { + const existing = series.get(seriesKey); + if (existing) return existing; + const created: SeriesEntry = { + instances: new Map(), + committed: new Map(), + dissolving: new Set(), + forgotten: [], + queue: Promise.resolve(), + }; + series.set(seriesKey, created); + return created; + }; + + // Forgetting returns the instances for their owner to release. An ID a + // replan already dissolved owns nothing, so forgetting it releases nothing + // and the members it handed to another collection stay registered; it is + // still recorded, once, as the change to the pending plan it is. + const forgetFrom = ( + entry: SeriesEntry, + seriesKey: string, + collectionId: string + ) => { + const forgotten = entry.committed.get(collectionId); + if (!forgotten) { + if (entry.dissolving.delete(collectionId)) + entry.forgotten.push(collectionId); + return []; + } + const released = forgotten.members.flatMap((key) => { + const instance = entry.instances.get(key); + entry.instances.delete(key); + return instance ? [instance.chunk] : []; + }); + entry.committed.delete(collectionId); + entry.forgotten.push(collectionId); + if (entry.instances.size === 0) series.delete(seriesKey); + return released; + }; + + const register = async (chunks: Chunk[]) => { + // Reading every chunk before touching a queue keeps a batch that names an + // unread chunk from half registering. + const batches = groupBySeries(chunks); + const snapshots = new Map(); + + const planned = [...batches].map(([seriesKey, batch]) => { + const entry = entryFor(seriesKey); + const update = entry.queue.then(() => { + snapshots.set(seriesKey, { + entry, + instances: new Map(entry.instances), + committed: new Map(entry.committed), + dissolving: new Set(entry.dissolving), + forgotten: entry.forgotten.length, + }); + return planSeries(entry, seriesKey, batch); + }); + // A failed plan must not wedge the series behind it, and the queue + // outlives every batch it orders, so it keeps the outcome and not the + // plan: holding the members would pin the chunks of a collection the + // user has since removed for as long as the series has any other. + entry.queue = update.then( + () => {}, + () => {} + ); + return update; + }); + + // A forget empties and drops an entry once nothing is left in it, so the + // series may now be missing or be a fresh entry. + const changedSince = ( + seriesKey: string, + affectedIds?: Iterable + ) => { + const snapshot = snapshots.get(seriesKey); + if (!snapshot) return false; + const entry = series.get(seriesKey); + if (entry !== snapshot.entry) return true; + const forgotten = entry.forgotten.slice(snapshot.forgotten); + const affected = affectedIds ? new Set(affectedIds) : null; + return affected + ? forgotten.some((id) => affected.has(id)) + : forgotten.length > 0; + }; + + const rollback = (seriesKeys: Iterable) => + [...new Set(seriesKeys)].flatMap((seriesKey) => { + const snapshot = snapshots.get(seriesKey); + if (!snapshot) return []; + const { entry } = snapshot; + entry.instances = snapshot.instances; + entry.committed = snapshot.committed; + entry.dissolving = snapshot.dissolving; + // What the user removed while the batch was in flight stays removed. + const forgotten = entry.forgotten.slice(snapshot.forgotten); + entry.forgotten.length = snapshot.forgotten; + series.set(seriesKey, entry); + return forgotten.flatMap((id) => forgetFrom(entry, seriesKey, id)); + }); + + const results = await Promise.all(planned); + return { + updates: results.flatMap((result) => result.updates), + conflictingInstanceUids: results.flatMap( + (result) => result.conflictingInstanceUids + ), + removed: results.flatMap((result) => result.removed), + rollback, + changedSince, + }; + }; + + const forget = (collectionId: string) => { + return [...series].flatMap(([seriesKey, entry]) => + forgetFrom(entry, seriesKey, collectionId) + ); + }; + + const has = (chunk: Chunk) => { + const { metadata } = chunk; + if (!metadata) return false; + return ( + series.get(seriesKeyOf(metadata))?.instances.get(instanceKeyOf(chunk)) + ?.chunk === chunk + ); + }; + + return { register, forget, has }; +} diff --git a/packages/dicom/core/collections/instanceFacts.ts b/packages/dicom/core/collections/instanceFacts.ts new file mode 100644 index 000000000..1fef4b905 --- /dev/null +++ b/packages/dicom/core/collections/instanceFacts.ts @@ -0,0 +1,198 @@ +import { dot } from './orientation'; +import { multiframeGeometry } from './multiframeGeometry'; +import type { InstanceFacts } from './planDicomCollections'; +import { Tags } from '../metadata/dicomTags'; +import type { DicomDataset } from '../metadata/dicomDataset'; +import { getFrameTag } from '../metadata/functionalGroups'; +import { trimDicomPadding } from '../utils'; + +// readDicomTags hands back DICOM's own byte padding, so a value that is only +// padding is absent rather than an empty string. +const textOf = (metadata: DicomDataset, tag: string) => { + const value = trimDicomPadding(getFrameTag(metadata, tag) ?? ''); + return value.length > 0 ? value : null; +}; + +// Hard facts are compared for equality, so '04', '4' and '4.0' must be one +// value. A value that is not a number keeps its own spelling. +const numericTextOf = (metadata: DicomDataset, tag: string) => { + const text = textOf(metadata, tag); + if (text === null) return null; + const value = Number(text); + return Number.isFinite(value) ? String(value) : text; +}; + +const numberOf = (metadata: DicomDataset, tag: string) => { + const text = textOf(metadata, tag); + if (text === null) return null; + const value = Number(text); + return Number.isFinite(value) ? value : null; +}; + +// A blank component is absent, not zero: Number('') is 0, which would turn +// '1\\3' into the readable position [1, 0, 3]. +const vectorOf = ( + metadata: DicomDataset, + tag: string, + length: number, + frameIndex = 0 +) => { + const raw = getFrameTag(metadata, tag, frameIndex); + const text = raw === undefined ? null : trimDicomPadding(raw); + if (text === null) return null; + const parts = text.split('\\').map(trimDicomPadding); + const values = parts.map(Number); + return parts.length === length && + parts.every((part) => part.length > 0) && + values.every((value) => Number.isFinite(value)) + ? values + : null; +}; + +const cross = (left: number[], right: number[]) => [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], +]; + +/** + * How far the row and column cosines may be from square, once each is scaled + * to unit length, before they span no plane. GDCM allows a thousandth and + * rescales any axis it is given, so a value printed to three decimals loads + * as the scanner meant it while a skewed basis still does not. + */ +export const ORTHOGONALITY_TOLERANCE = 1e-3; + +const magnitude = (axis: number[]) => Math.sqrt(dot(axis, axis)); + +/** + * The axis scaled to unit length, or null when it has no length to scale. A + * printed cosine is never exactly unit, so the length it was written with is + * corrected rather than judged. + */ +const unitize = (axis: number[]) => { + const size = magnitude(axis); + return size > 0 ? axis.map((component) => component / size) : null; +}; + +/** + * Row and column cosines, normalized, or null when they are not two axes + * spanning a plane. A degenerate or skewed basis has no slice normal, so + * nothing can be projected on it. + */ +const orientationOf = (metadata: DicomDataset, frameIndex: number) => { + const cosines = vectorOf( + metadata, + Tags.ImageOrientationPatient, + 6, + frameIndex + ); + if (cosines === null) return null; + + const row = unitize(cosines.slice(0, 3)); + const column = unitize(cosines.slice(3, 6)); + if (row === null || column === null) return null; + if (Math.abs(dot(row, column)) > ORTHOGONALITY_TOLERANCE) return null; + + return [...row, ...column]; +}; + +/** + * The slice normal of a validated orientation: the row cosines crossed with + * the column cosines, which is only the z axis for an axial acquisition. + * Unit length to within the orthogonality tolerance, since the two axes are + * unit and square to it. + */ +export const sliceNormalOf = (orientation: number[] | null) => + orientation === null + ? null + : cross(orientation.slice(0, 3), orientation.slice(3, 6)); + +/** Where a position sits along a slice normal, in mm. */ +export const projectOnNormal = ( + normal: number[] | null, + position: number[] | null +) => (normal === null || position === null ? null : dot(normal, position)); + +// A pixel spacing of zero or less places no sample anywhere. +const pixelSpacingOf = (metadata: DicomDataset, frameIndex: number) => { + const raw = getFrameTag(metadata, Tags.PixelSpacing, frameIndex); + const parts = raw?.split('\\').slice(0, 2).map(trimDicomPadding); + const spacing = + parts?.length === 2 && parts.every(Boolean) ? parts.map(Number) : null; + return spacing !== null && + spacing.every((value) => Number.isFinite(value) && value > 0) + ? spacing + : null; +}; + +/** + * Where an instance places its samples, for callers that need nothing else. + * Reading it costs three tag lookups rather than the twenty a full fact read + * takes, which a volume of two thousand slices notices. + */ +export function readGeometryFacts(metadata: DicomDataset, frameIndex = 0) { + return { + orientation: orientationOf(metadata, frameIndex), + position: vectorOf(metadata, Tags.ImagePositionPatient, 3, frameIndex), + pixelSpacing: pixelSpacingOf(metadata, frameIndex), + }; +} + +/** Reads the planner's plain facts out of one instance's tag values. */ +export function readInstanceFacts(metadata: DicomDataset): InstanceFacts { + const { orientation, position, pixelSpacing } = readGeometryFacts(metadata); + const frames = + textOf(metadata, Tags.Modality) === 'SEG' + ? null + : multiframeGeometry( + metadata, + numberOf(metadata, Tags.NumberOfFrames) ?? 1 + ); + + return { + ...(frames ? { frameReconstruction: frames.reconstruction } : {}), + sopInstanceUid: textOf(metadata, Tags.SOPInstanceUID), + seriesInstanceUid: textOf(metadata, Tags.SeriesInstanceUID), + seriesNumber: numericTextOf(metadata, Tags.SeriesNumber), + sequenceName: textOf(metadata, Tags.SequenceName), + sliceThickness: numericTextOf(metadata, Tags.SliceThickness), + seriesDate: textOf(metadata, Tags.SeriesDate), + rows: numericTextOf(metadata, Tags.Rows), + columns: numericTextOf(metadata, Tags.Columns), + samplesPerPixel: numericTextOf(metadata, Tags.SamplesPerPixel), + numberOfFrames: numericTextOf(metadata, Tags.NumberOfFrames) ?? '1', + orientation, + position, + projectedPosition: projectOnNormal(sliceNormalOf(orientation), position), + pixelSpacing, + instanceNumber: numberOf(metadata, Tags.InstanceNumber), + acquisitionNumber: numericTextOf(metadata, Tags.AcquisitionNumber), + temporalPositionIdentifier: numericTextOf( + metadata, + Tags.TemporalPositionIdentifier + ), + echoNumbers: numericTextOf(metadata, Tags.EchoNumbers), + diffusionBValue: numericTextOf(metadata, Tags.DiffusionBValue), + }; +} + +// Carries a separator no UID can hold, so a fallback key never equals a real +// series key. +const NO_SERIES_UID = 'no-series'; + +/** + * The grouping key for one instance's series. Instances of different series + * are planned independently. + */ +export function seriesKeyOf(metadata: DicomDataset) { + const seriesUid = textOf(metadata, Tags.SeriesInstanceUID); + if (seriesUid !== null) return seriesUid; + + // Escaped, so moving a separator from one part to the other changes the key. + return [ + NO_SERIES_UID, + encodeURIComponent(textOf(metadata, Tags.StudyInstanceUID) ?? ''), + encodeURIComponent(textOf(metadata, Tags.PatientID) ?? ''), + ].join('|'); +} diff --git a/packages/dicom/core/collections/multiframeGeometry.ts b/packages/dicom/core/collections/multiframeGeometry.ts new file mode 100644 index 000000000..30adbf375 --- /dev/null +++ b/packages/dicom/core/collections/multiframeGeometry.ts @@ -0,0 +1,72 @@ +import type { DicomDataset } from '../metadata/dicomDataset'; +import { readGeometryFacts, sliceNormalOf } from './instanceFacts'; +import { reconstructVolume, type Reconstruction } from './reconstructVolume'; +import { dot, orientationsAgree } from './orientation'; +import { getDatasetTag } from '../metadata/datasetAccess'; +import { trimDicomPadding } from '../utils'; +import { perFrameGroups, sequenceItems } from '../metadata/functionalGroups'; + +function storageDirection(frames: ReturnType[]) { + const first = frames[0]; + const normal = sliceNormalOf(first.orientation); + const last = frames.at(-1)!.position; + const delta = first.position?.map((value, index) => + last ? last[index] - value : 0 + ); + const sign = normal && delta && dot(normal, delta) < 0 ? -1 : 1; + return normal?.map((value) => value * sign) ?? null; +} + +const singleSpatialStack = (groups: DicomDataset[]) => + ['00209056', '00209128'].every((tag) => { + const values = groups + .map((group) => + trimDicomPadding( + getDatasetTag(sequenceItems(group, '00209111')[0], tag) ?? '' + ) + ) + .filter(Boolean); + return new Set(values).size <= 1; + }); + +/** + * Patient-space frames keep their stored pixel order. The third direction + * follows the positions, which can run opposite the row/column cross product. + * Ophthalmic scan-line coordinates require a separate calibrated localizer; + * optical SliceThickness cannot substitute for their separation. + */ +export function multiframeGeometry(metadata: DicomDataset, count: number) { + const groups = perFrameGroups(metadata); + const shared = sequenceItems(metadata, '52009229'); + if (count < 2 || (shared.length === 0 && groups.length === 0)) return null; + const frames = Array.from({ length: count }, (_, index) => + readGeometryFacts(metadata, index) + ); + const first = frames[0]; + const direction = storageDirection(frames); + const reconstruction = reconstructVolume(frames, direction); + const readable = + groups.length === count && + singleSpatialStack(groups) && + frames.every( + (frame) => + frame.pixelSpacing !== null && + first.orientation !== null && + frame.orientation !== null && + orientationsAgree(first.orientation, frame.orientation) + ); + const validated: Reconstruction = readable + ? reconstruction + : { + kind: 'irregular', + reason: 'unreadable-geometry', + sliceSpacing: reconstruction.sliceSpacing, + slots: [0], + }; + return { + ...first, + direction, + sliceSpacingSource: 'ImagePositionPatient', + reconstruction: { ...validated, slots: [0] }, + }; +} diff --git a/packages/dicom/core/collections/orientation.ts b/packages/dicom/core/collections/orientation.ts new file mode 100644 index 000000000..1529fe569 --- /dev/null +++ b/packages/dicom/core/collections/orientation.ts @@ -0,0 +1,11 @@ +/** Minimum agreement of normalized row and column direction cosines. */ +export const ORIENTATION_TOLERANCE = 1e-4; + +export const dot = (left: number[], right: number[], offset = 0) => + left[offset] * right[offset] + + left[offset + 1] * right[offset + 1] + + left[offset + 2] * right[offset + 2]; + +export const orientationsAgree = (left: number[], right: number[]) => + dot(left, right) >= 1 - ORIENTATION_TOLERANCE && + dot(left, right, 3) >= 1 - ORIENTATION_TOLERANCE; diff --git a/packages/dicom/core/collections/planDicomCollections.ts b/packages/dicom/core/collections/planDicomCollections.ts new file mode 100644 index 000000000..2899f77ba --- /dev/null +++ b/packages/dicom/core/collections/planDicomCollections.ts @@ -0,0 +1,594 @@ +import { orientationsAgree } from './orientation'; +import { + splitOverlappingAcquisitions, + type PositionOf, +} from './splitOverlappingAcquisitions'; +import { projectOnNormal, sliceNormalOf } from './instanceFacts'; +import { + IN_PLANE_TOLERANCE_MM, + reconstructInstances, + type IrregularReason, + type Reconstruction, +} from './reconstructVolume'; + +/** + * Facts read once per instance from a chunk's metadata. Plain data: the planner + * never sees a Chunk, a store, or an itk-wasm task. + */ +export type InstanceFacts = { + // Identity. Instances without a SOP Instance UID are never deduplicated. + sopInstanceUid: string | null; + seriesInstanceUid: string | null; + + // Hard partition facts, compared for equality. + seriesNumber: string | null; + sequenceName: string | null; + sliceThickness: string | null; + seriesDate: string | null; + rows: string | null; + columns: string | null; + samplesPerPixel: string | null; + numberOfFrames: string | null; + + // Geometry. Any of these may be unreadable. + orientation: number[] | null; + position: number[] | null; + projectedPosition: number | null; + pixelSpacing: number[] | null; + /** Intra-instance frame geometry; slots still index source chunks. */ + frameReconstruction?: Reconstruction; + + // Ordering fallback. + instanceNumber: number | null; + + // Semantic partition facts: what distinguishes one scan of a range from + // another within a series. Normalized for equality. + acquisitionNumber: string | null; + temporalPositionIdentifier: string | null; + echoNumbers: string | null; + diffusionBValue: string | null; +}; + +export type CollectionKey = { + seriesKey: string; + parts: Array<[string, string | null]>; +}; + +export type MemberOrder = 'spatial' | 'instance-number' | 'input'; + +export type DicomCollection = { + key: CollectionKey; + members: InstanceFacts[]; + order: MemberOrder; + // The axes the members were ordered and judged on. Allocation must build + // on these too, or the warnings describe a volume other than the one shown. + orientation: number[] | null; + reconstruction: Reconstruction; + // Names the scan the collection was separated out as ('acquisition 2', + // 'phase 3, echo 1'); null when nothing separated it from its series. + label: string | null; + // Per-member notes about how the plan was reached. + diagnostics: string[]; + // Conditions the user should hear about: the collection loads, but not as + // the sound volume its series promised. + warnings: string[]; +}; + +export type DicomCollectionPlan = { + collections: DicomCollection[]; +}; + +export type PlanInput = { + seriesKey: string; + instances: InstanceFacts[]; +}; + +export { ORIENTATION_TOLERANCE } from './orientation'; + +// The hard facts, in the order they appear in a collection key. +export const HARD_FACT_RULES = [ + 'seriesNumber', + 'sequenceName', + 'sliceThickness', + 'seriesDate', + 'rows', + 'columns', + 'samplesPerPixel', + 'numberOfFrames', +] as const; + +export const ORIENTATION_RULE = 'orientation'; + +export const REPEATED_POSITIONS_WARNING = + 'holds repeated slice positions that no tag separates. Its slice spacing ' + + 'and measurements along the slice axis may be wrong.'; + +export const IRREGULAR_VOLUME_WARNING = + 'does not sample one regular volume: its slices are not evenly spaced on ' + + 'one grid. They were placed one slot apart in the order shown, so ' + + 'positions and measurements along the slice axis may be wrong.'; + +export const UNREADABLE_POSITION_WARNING = + 'holds slices whose position could not be read. They were kept out of the ' + + 'volumes of their series, and their order and spacing may be wrong.'; + +export const NONPARALLEL_SLICES_WARNING = + 'holds slices that are not parallel to one another. They were stacked on ' + + 'the axes most of them declare, so positions and measurements may be wrong.'; + +export const IN_PLANE_SHIFT_WARNING = + 'holds slices that are offset within their own plane from one to the next, ' + + 'as a tilted gantry produces. They were stacked without correcting for the ' + + 'offset, so positions and measurements across slices may be wrong.'; + +// A repeated or unreadable position makes a stack irregular too, and already +// carries a warning of its own. +const RECONSTRUCTION_WARNINGS: Record = { + 'unreadable-geometry': [], + 'repeated-position': [], + 'uneven-slice-spacing': [IRREGULAR_VOLUME_WARNING], + 'in-plane-shift': [IN_PLANE_SHIFT_WARNING], + 'mixed-pixel-spacing': [IRREGULAR_VOLUME_WARNING], +}; + +export const UNREADABLE_FRAME_GEOMETRY_WARNING = + 'holds frames whose spatial geometry could not be established. They were ' + + 'placed one slot apart in stored order, so positions and measurements ' + + 'along the slice axis may be wrong.'; + +const reconstructionWarnings = ( + reconstruction: Reconstruction, + intraInstance: boolean +) => { + if (reconstruction.kind === 'regular') return []; + if (intraInstance && reconstruction.reason === 'unreadable-geometry') + return [UNREADABLE_FRAME_GEOMETRY_WARNING]; + if (intraInstance && reconstruction.reason === 'repeated-position') + return [REPEATED_POSITIONS_WARNING]; + return RECONSTRUCTION_WARNINGS[reconstruction.reason]; +}; + +const ANONYMOUS = 'an instance with no SOP Instance UID'; + +const label = (instance: InstanceFacts) => + instance.sopInstanceUid === null + ? ANONYMOUS + : `instance ${instance.sopInstanceUid}`; + +// Nulls sort last so a missing identity never becomes a bucket representative. +const compareUid = (left: string | null, right: string | null) => { + if (left === right) return 0; + if (left === null) return 1; + if (right === null) return -1; + return left < right ? -1 : 1; +}; + +const compareBySopUid = (left: InstanceFacts, right: InstanceFacts) => + compareUid(left.sopInstanceUid, right.sopInstanceUid); + +const readableNumber = (value: number | null): value is number => + value !== null && Number.isFinite(value); + +const compareText = (left: string, right: string) => { + if (left === right) return 0; + return left < right ? -1 : 1; +}; + +// Unreadable numbers sort last so they never win a tie-break. +const compareNumber = (left: number | null, right: number | null) => { + if (!readableNumber(left)) return readableNumber(right) ? 1 : 0; + if (!readableNumber(right)) return -1; + return left === right ? 0 : left - right; +}; + +// JSON collapses NaN and Infinity onto null; keep them apart so the signature +// below separates every pair of unequal instances. +const encodeFact = (_key: string, value: unknown) => + typeof value === 'number' && !Number.isFinite(value) ? String(value) : value; + +const contentSignature = (instance: InstanceFacts) => + JSON.stringify( + [ + instance.sopInstanceUid, + instance.seriesInstanceUid, + ...HARD_FACT_RULES.map((rule) => instance[rule]), + instance.orientation, + instance.position, + instance.projectedPosition, + instance.pixelSpacing, + instance.frameReconstruction, + instance.instanceNumber, + ], + encodeFact + ); + +/** + * Total order over instances that share a SOP Instance UID comparison, which + * happens whenever two instances both lack one. + */ +const compareByContent = (left: InstanceFacts, right: InstanceFacts) => + compareText( + JSON.stringify(left.orientation), + JSON.stringify(right.orientation) + ) || + compareNumber(left.projectedPosition, right.projectedPosition) || + compareNumber(left.instanceNumber, right.instanceNumber) || + compareText(contentSignature(left), contentSignature(right)); + +// Sorting on the UID alone ties every anonymous instance, leaving the greedy +// orientation walk at the mercy of input order. +const compareWalkOrder = (left: InstanceFacts, right: InstanceFacts) => + compareBySopUid(left, right) || compareByContent(left, right); + +const readableOrientation = (value: number[] | null): value is number[] => + value !== null && + value.length === 6 && + value.every((v) => Number.isFinite(v)); + +const orientationValue = (orientation: number[]) => + orientation.map(String).join(','); + +// Ties go to the first given, so a walk ordered input decides them the same +// way whatever order the files arrived in. +const mostDeclared = (orientations: number[][]) => { + const counts = new Map(); + orientations.forEach((orientation) => { + const value = orientationValue(orientation); + counts.set(value, (counts.get(value) ?? 0) + 1); + }); + const countOf = (orientation: number[]) => + counts.get(orientationValue(orientation)) as number; + return orientations.reduce((best, orientation) => + countOf(orientation) > countOf(best) ? orientation : best + ); +}; + +/** + * How far a member's far corner sits from where the volume axes put it, in + * mm. A volume has one direction, so a slice leaning off it is resampled + * nowhere: its samples are simply shown as if it did not lean. + */ +const leanOffAxes = (axes: number[], member: InstanceFacts) => { + const { orientation } = member; + if (!readableOrientation(orientation)) return 0; + const [rowSpacing, columnSpacing] = member.pixelSpacing ?? [1, 1]; + const width = Number(member.columns) * columnSpacing; + const height = Number(member.rows) * rowSpacing; + return Math.hypot( + ...[0, 1, 2].map( + (i) => + width * (orientation[i] - axes[i]) + + height * (orientation[i + 3] - axes[i + 3]) + ) + ); +}; + +const nonparallel = (axes: number[] | null, members: InstanceFacts[]) => + axes !== null && + members.some((member) => leanOffAxes(axes, member) > IN_PLANE_TOLERANCE_MM); + +const canonicalKey = (key: CollectionKey) => + JSON.stringify([key.seriesKey, key.parts]); + +/** Last occurrence of a SOP Instance UID wins, so the newest batch survives. */ +const dedupe = (instances: InstanceFacts[]) => { + const lastIndex = new Map(); + instances.forEach((instance, index) => { + if (instance.sopInstanceUid !== null) + lastIndex.set(instance.sopInstanceUid, index); + }); + + const kept = instances.filter( + (instance, index) => + instance.sopInstanceUid === null || + lastIndex.get(instance.sopInstanceUid) === index + ); + + const droppedCounts = new Map(); + instances.forEach((instance, index) => { + const uid = instance.sopInstanceUid; + if (uid === null || lastIndex.get(uid) === index) return; + droppedCounts.set(uid, (droppedCounts.get(uid) ?? 0) + 1); + }); + + const diagnostics = new Map(); + kept.forEach((instance) => { + const uid = instance.sopInstanceUid; + const dropped = uid === null ? 0 : (droppedCounts.get(uid) ?? 0); + if (dropped > 0) + diagnostics.set( + instance, + `dropped ${dropped} earlier copy of instance ${uid}` + ); + }); + + return { kept, diagnostics }; +}; + +const hardFactSignature = (instance: InstanceFacts) => + JSON.stringify(HARD_FACT_RULES.map((rule) => instance[rule])); + +const hardFactParts = (instance: InstanceFacts) => + HARD_FACT_RULES.map( + (rule) => [rule, instance[rule]] as [string, string | null] + ); + +/** Groups preserve input order, both of the groups and of their members. */ +const groupBy = ( + instances: InstanceFacts[], + signature: (instance: InstanceFacts) => string +) => { + const groups = new Map(); + instances.forEach((instance) => { + const id = signature(instance); + const existing = groups.get(id); + if (existing) existing.push(instance); + else groups.set(id, [instance]); + }); + return [...groups.values()]; +}; + +/** + * Greedy bucketing walked in SOP Instance UID then content order, so the + * representative whose cosines become the key, and the membership the greedy + * walk produces, do not depend on input order. + */ +const bucketByOrientation = (instances: InstanceFacts[]) => { + const references: number[][] = []; + const bucketOf = new Map(); + + const walked = [...instances].sort(compareWalkOrder); + walked.forEach((instance) => { + const { orientation } = instance; + if (!readableOrientation(orientation)) { + bucketOf.set(instance, -1); + return; + } + const found = references.findIndex((reference) => + orientationsAgree(reference, orientation) + ); + bucketOf.set( + instance, + found >= 0 ? found : references.push(orientation) - 1 + ); + }); + + // One normal for the whole bucket: two members agree within the tolerance, + // so projecting each on its own cosines would spread one slice plane over + // several positions. The reference stays the key, which a collection ID is + // minted from; the axes are the ones most members declare, so a stray slice + // that happens to be walked first does not lean every other one. + const buckets = references.map((reference, index) => { + const orientation = mostDeclared( + walked + .filter((instance) => bucketOf.get(instance) === index) + .map((instance) => instance.orientation as number[]) + ); + return { + value: orientationValue(reference) as string | null, + orientation: orientation as number[] | null, + normal: sliceNormalOf(orientation) as number[] | null, + members: [] as InstanceFacts[], + }; + }); + const unreadable = { + value: null, + orientation: null, + normal: null, + members: [] as InstanceFacts[], + }; + + instances.forEach((instance) => { + const index = bucketOf.get(instance) ?? -1; + const bucket = index === -1 ? unreadable : buckets[index]; + bucket.members.push(instance); + }); + + return unreadable.members.length > 0 ? [...buckets, unreadable] : buckets; +}; + +// A repeated position breaks on the instance number, keeping the scanner's +// own sequence for the tied members. Position stays primary: the allocator +// places slot i at origin + i * spacing along the normal, so any order that +// is not monotone in position mirrors or scrambles the volume. Anonymous +// members that still tie order by content rather than by input position. +const comparePosition = + (positionOf: PositionOf) => (left: InstanceFacts, right: InstanceFacts) => + (positionOf(left) as number) - (positionOf(right) as number) || + compareNumber(left.instanceNumber, right.instanceNumber) || + compareWalkOrder(left, right); + +const compareInstanceNumber = (left: InstanceFacts, right: InstanceFacts) => + (left.instanceNumber as number) - (right.instanceNumber as number) || + compareWalkOrder(left, right); + +/** Never inherits input order silently: the order actually used is recorded. */ +const orderMembers = (members: InstanceFacts[], positionOf: PositionOf) => { + const numbered = members.every((m) => readableNumber(m.instanceNumber)); + + if (members.every((m) => readableNumber(positionOf(m)))) + return { + members: [...members].sort(comparePosition(positionOf)), + order: 'spatial' as MemberOrder, + diagnostics: [] as string[], + }; + + const positionless = (ordered: InstanceFacts[]) => + ordered + .filter((m) => !readableNumber(positionOf(m))) + .map((m) => `${label(m)} has no readable position`); + + if (numbered) { + const ordered = [...members].sort(compareInstanceNumber); + return { + members: ordered, + order: 'instance-number' as MemberOrder, + diagnostics: positionless(ordered), + }; + } + + return { + members: [...members], + order: 'input' as MemberOrder, + diagnostics: [ + ...positionless(members), + ...members + .filter((m) => !readableNumber(m.instanceNumber)) + .map((m) => `${label(m)} has no readable instance number`), + ], + }; +}; + +/** Occurrences per instance, so an input holding one object twice needs two. */ +const tally = (instances: InstanceFacts[]) => { + const counts = new Map(); + instances.forEach((instance) => + counts.set(instance, (counts.get(instance) ?? 0) + 1) + ); + return counts; +}; + +/** + * Throws when the collections do not hold every instance as many times as the + * input does, when they hold one the input never gave, or when two collections + * share a key. + */ +export function validateCollections( + instances: InstanceFacts[], + collections: DicomCollection[] +) { + const expected = tally(instances); + const placed = tally(collections.flatMap((collection) => collection.members)); + + expected.forEach((count, instance) => { + const found = placed.get(instance) ?? 0; + if (found === count) return; + if (found === 0) + throw new Error(`${label(instance)} landed in no collection`); + throw new Error( + `${label(instance)} landed in ${found} collections, expected ${count}` + ); + }); + + placed.forEach((_count, instance) => { + if (!expected.has(instance)) + throw new Error( + `${label(instance)} landed in a collection without being an input` + ); + }); + + const seen = new Set(); + collections.forEach((collection) => { + const encoded = canonicalKey(collection.key); + if (seen.has(encoded)) + throw new Error(`two collections share the key ${encoded}`); + seen.add(encoded); + }); +} + +export function planDicomCollections(input: PlanInput) { + const { kept, diagnostics: dedupeDiagnostics } = dedupe(input.instances); + + const multiframe = (instance: InstanceFacts) => + Number(instance.numberOfFrames) > 1; + const groups = [ + ...groupBy( + kept.filter((instance) => !multiframe(instance)), + hardFactSignature + ), + ...kept.filter(multiframe).map((instance) => [instance]), + ]; + const planned = groups + .flatMap((group) => + bucketByOrientation(group).map((bucket) => ({ + group, + bucket, + positionOf: ((member) => + projectOnNormal(bucket.normal, member.position)) as PositionOf, + })) + ) + .flatMap(({ group, bucket, positionOf }) => + splitOverlappingAcquisitions(bucket.members, positionOf).map((part) => ({ + group, + bucket, + positionOf, + part, + })) + ) + .map(({ group, bucket, positionOf, part }) => { + const ordered = orderMembers(part.members, positionOf); + const reconstruction = reconstructInstances( + ordered.members, + bucket.normal + ); + const key: CollectionKey = { + seriesKey: input.seriesKey, + parts: [ + ...hardFactParts(group[0]), + [ORIENTATION_RULE, bucket.value], + ...part.parts, + ...(multiframe(group[0]) + ? [ + [ + 'instance', + group[0].sopInstanceUid ?? String(kept.indexOf(group[0])), + ] as [string, string], + ] + : []), + ], + }; + return { + key, + members: ordered.members, + order: ordered.order, + orientation: bucket.orientation, + reconstruction, + label: part.label, + diagnostics: [ + ...ordered.members.flatMap((member) => { + const dropped = dedupeDiagnostics.get(member); + return dropped ? [dropped] : []; + }), + ...(bucket.value === null + ? ordered.members.map( + (member) => `${label(member)} has no readable orientation` + ) + : []), + ...ordered.diagnostics, + ], + warnings: [ + ...(part.repeatedPositions ? [REPEATED_POSITIONS_WARNING] : []), + ...(part.unreadablePositions && !multiframe(group[0]) + ? [UNREADABLE_POSITION_WARNING] + : []), + ...reconstructionWarnings( + reconstruction, + ordered.members.length === 1 && + !!ordered.members[0].frameReconstruction + ), + ...(nonparallel(bucket.orientation, ordered.members) + ? [NONPARALLEL_SLICES_WARNING] + : []), + ], + }; + }) + .sort((left, right) => + canonicalKey(left.key) < canonicalKey(right.key) ? -1 : 1 + ); + + // A series that plans to one instance is a whole volume: nothing was held + // out of a sibling, and one slice has no order or spacing to get wrong. + const lone = planned.length === 1 && planned[0].members.length === 1; + const collections = lone + ? planned.map((collection) => ({ + ...collection, + warnings: collection.warnings.filter( + (warning) => warning !== UNREADABLE_POSITION_WARNING + ), + })) + : planned; + + validateCollections(kept, collections); + + return { collections }; +} diff --git a/packages/dicom/core/collections/reconstructVolume.ts b/packages/dicom/core/collections/reconstructVolume.ts new file mode 100644 index 000000000..be92081d0 --- /dev/null +++ b/packages/dicom/core/collections/reconstructVolume.ts @@ -0,0 +1,213 @@ +import type { InstanceFacts } from './planDicomCollections'; +import { projectOnNormal, sliceNormalOf } from './instanceFacts'; + +/** The geometry a reconstruction judges. Nothing else about an instance. */ +export type ReconstructionMember = Pick< + InstanceFacts, + 'orientation' | 'position' | 'pixelSpacing' +> & { frameReconstruction?: Reconstruction }; + +export type IrregularReason = + | 'unreadable-geometry' + | 'repeated-position' + | 'uneven-slice-spacing' + | 'in-plane-shift' + | 'mixed-pixel-spacing'; + +/** + * What the members can be reconstructed as. + * + * `regular` means every consecutive pair of slice planes is the same distance + * apart and the members share one in-plane grid, so member i belongs at slot + * `slots[i]` of a volume sampled at `sliceSpacing`. `irregular` means they do + * not: the members still load, placed one slot apart at the step the stack + * does take, but no lattice describes where they actually are. + * + * A slice spacing of null means there is nothing to derive one from, which one + * member alone always is. + */ +export type Reconstruction = + | { kind: 'regular'; sliceSpacing: number | null; slots: number[] } + | { + kind: 'irregular'; + reason: IrregularReason; + sliceSpacing: number | null; + slots: number[]; + }; + +/** + * How far apart two slice gaps may be and still be one lattice: a tenth of a + * millimetre (capped at a tenth of the gap), or one part in a hundred of the + * gap, whichever is larger. DICOM + * prints a position as a decimal string, so the projection of an oblique stack + * onto its normal jitters in the last digits; a genuine slab boundary or a + * missed slice is orders of magnitude larger. + */ +export const SLICE_SPACING_TOLERANCE_MM = 0.1; +export const SLICE_SPACING_RELATIVE_TOLERANCE = 0.01; + +/** How far a slice may sit from the in-plane origin of the first, in mm. */ +export const IN_PLANE_TOLERANCE_MM = 0.1; + +/** How far two in-plane sample spacings may differ and still be one grid. */ +export const PIXEL_SPACING_TOLERANCE_MM = 1e-4; + +const spacingTolerance = (gap: number) => + Math.max( + Math.min(SLICE_SPACING_TOLERANCE_MM, Math.abs(gap) * 0.1), + Math.abs(gap) * SLICE_SPACING_RELATIVE_TOLERANCE + ); + +// Lower median, so the fallback spacing of an irregular stack is one of its +// own gaps rather than an average of a gap and a hole. +const median = (values: number[]) => + [...values].sort((left, right) => left - right)[ + Math.floor((values.length - 1) / 2) + ]; + +const subtract = (left: number[], right: number[]) => + left.map((value, index) => value - right[index]); + +const scale = (vector: number[], factor: number) => + vector.map((value) => value * factor); + +const maxAbs = (values: number[]) => + values.reduce((largest, value) => Math.max(largest, Math.abs(value)), 0); + +const distance = (left: number[], right: number[]) => + Math.sqrt( + subtract(left, right).reduce((sum, value) => sum + value * value, 0) + ); + +/** + * The step a stack that is no lattice still takes: the lower median of the + * straight-line distance from each member to the next, in the order given. + * That is what ITK's series reader steps by, so a tilted gantry's slices, + * whose planes shift sideways as they advance, keep the extent they were + * scanned over. Null when a position is unreadable or the members never move. + */ +const positionStep = (members: ReconstructionMember[]) => { + const positions = members.map((member) => member.position); + if (positions.length < 2 || positions.some((position) => position === null)) + return null; + const gaps = positions + .slice(1) + .map((position, index) => + distance(position as number[], positions[index] as number[]) + ); + const step = median(gaps); + return step > 0 ? step : null; +}; + +// A member with no pixel spacing at all is unknown, not different: only two +// spacings that both read and disagree describe two grids. +const mixedPixelSpacing = (members: ReconstructionMember[]) => { + const spacings = members + .map((member) => member.pixelSpacing) + .filter((spacing): spacing is number[] => spacing !== null); + return spacings.some( + (spacing) => + maxAbs(subtract(spacing, spacings[0])) > PIXEL_SPACING_TOLERANCE_MM + ); +}; + +const irregular = ( + reason: IrregularReason, + sliceSpacing: number | null, + count: number +): Reconstruction => ({ + kind: 'irregular', + reason, + sliceSpacing, + slots: Array.from({ length: count }, (_, index) => index), +}); + +const inPlaneShifted = ( + members: ReconstructionMember[], + axis: number[], + along: number[] +) => { + const inPlaneOf = (index: number) => + subtract(members[index].position as number[], scale(axis, along[index])); + const origin = inPlaneOf(0); + return members.some( + (_member, index) => + maxAbs(subtract(inPlaneOf(index), origin)) > IN_PLANE_TOLERANCE_MM + ); +}; + +/** What stops these members from describing a volume at all, if anything. */ +const geometryFault = ( + members: ReconstructionMember[], + axis: number[] | null, + along: Array +): IrregularReason | null => { + if (along.some((position) => !Number.isFinite(position))) + return 'unreadable-geometry'; + if (mixedPixelSpacing(members)) return 'mixed-pixel-spacing'; + if (inPlaneShifted(members, axis as number[], along as number[])) + return 'in-plane-shift'; + return null; +}; + +/** The lattice the slice planes sit on, once they are known to be readable. */ +const lattice = (along: number[]): Reconstruction => { + if (along.length < 2) + return { kind: 'regular', sliceSpacing: null, slots: [0] }; + + const gaps = along.slice(1).map((position, index) => position - along[index]); + const step = median(gaps); + const count = along.length; + + if (gaps.some((gap) => gap <= 0)) + return irregular('repeated-position', step > 0 ? step : null, count); + if (gaps.some((gap) => Math.abs(gap - step) > spacingTolerance(step))) + return irregular('uneven-slice-spacing', step, count); + + // Every gap matches, so the endpoints fit the members better than any single + // printed gap does. + const sliceSpacing = (along[count - 1] - along[0]) / gaps.length; + const slots = along.map((position) => + Math.round((position - along[0]) / sliceSpacing) + ); + return slots.some((slot, index) => slot !== index) + ? irregular('uneven-slice-spacing', step, count) + : { kind: 'regular', sliceSpacing, slots }; +}; + +/** + * Whether the members sit on one sampled volume, and where each one belongs in + * it. Pure: the members are judged by their own geometry, in the order given. + * + * `normal` is the slice normal the positions are projected on. The planner + * passes the one normal its orientation bucket agreed on; without it, the + * first member's own cosines decide. + */ +export function reconstructVolume( + members: ReconstructionMember[], + normal?: number[] | null +): Reconstruction { + if (members.length === 0) + return { kind: 'regular', sliceSpacing: null, slots: [] }; + + const axis = normal ?? sliceNormalOf(members[0].orientation); + const along = members.map((member) => projectOnNormal(axis, member.position)); + + const fault = geometryFault(members, axis, along); + return fault === null + ? lattice(along as number[]) + : irregular(fault, positionStep(members), members.length); +} + +/** Instance slots index source chunks even when one source contains many frames. */ +export function reconstructInstances( + members: ReconstructionMember[], + normal?: number[] | null, + supplied?: Reconstruction +): Reconstruction { + const framePlan = + members.length === 1 ? members[0].frameReconstruction : undefined; + return framePlan + ? { ...framePlan, slots: [0] } + : (supplied ?? reconstructVolume(members, normal)); +} diff --git a/packages/dicom/core/collections/splitOverlappingAcquisitions.ts b/packages/dicom/core/collections/splitOverlappingAcquisitions.ts new file mode 100644 index 000000000..b4e6c7e82 --- /dev/null +++ b/packages/dicom/core/collections/splitOverlappingAcquisitions.ts @@ -0,0 +1,237 @@ +import type { InstanceFacts } from './planDicomCollections'; + +/** + * Facts tried, in order, as the identity of one scan within a series. An axis + * that does not split hands the members to the next; an axis that does split + * recurses into each part with the remaining axes, so a 4D multi-echo series + * separates on both. Every entry traces to an IDC series in + * __tests__/idcSeriesFixtures.ts. Stack ID is absent on purpose: bilateral + * slab series put two stacks in one sound volume. + * + * A counter axis numbers passes over time. Scanners sometimes stamp it per + * slice, and a repeated plane under it is a time series, so it earns two + * guards a contrast axis (echo, b-value) does not: it never fans out a single + * plane, and it splits only on groups that hold more than one slice. + */ +export const SEMANTIC_AXES = [ + { rule: 'acquisition', fact: 'acquisitionNumber', counter: true }, + { rule: 'phase', fact: 'temporalPositionIdentifier', counter: true }, + { rule: 'echo', fact: 'echoNumbers', counter: false }, + { rule: 'b-value', fact: 'diffusionBValue', counter: false }, +] as const; + +type SemanticAxis = (typeof SEMANTIC_AXES)[number]; + +export type SemanticPart = { + // One entry per axis, in SEMANTIC_AXES order: the value the part was split + // out on, or null where the axis did not partition its members. + parts: Array<[string, string | null]>; + members: InstanceFacts[]; + // 'acquisition 2, echo 1'; null for members no axis separated. + label: string | null; + // Two members share a slice position and no axis separates them. + repeatedPositions: boolean; + // No member of this part carries a readable slice position, so nothing + // spatial was judged about it. + unreadablePositions: boolean; +}; + +// The key part that keeps the members of unreadable geometry apart from the +// judged parts of the same series. +export const GEOMETRY_RULE = 'geometry'; +export const UNREADABLE_GEOMETRY = 'unreadable'; +export const UNREADABLE_GEOMETRY_LABEL = 'position unknown'; + +/** + * Where a member sits along the slice axis. The planner passes the projection + * on its bucket's common normal; the member's own is the default. + */ +export type PositionOf = (member: InstanceFacts) => number | null; + +const ownPosition: PositionOf = (member) => member.projectedPosition; + +const countDistinctPositions = ( + members: InstanceFacts[], + positionOf: PositionOf +) => new Set(members.map(positionOf)).size; + +const hasRepeatedPositions = ( + members: InstanceFacts[], + positionOf: PositionOf +) => countDistinctPositions(members, positionOf) !== members.length; + +/** + * Whether any two groups cover overlapping stretches of the slice axis. + * Bounds are closed, so scans sharing a boundary slice count as overlapping: + * that shared position is a duplicate either way. + */ +const spanOf = (group: InstanceFacts[], positionOf: PositionOf) => + group.reduce( + (span, member) => ({ + min: Math.min(span.min, positionOf(member) as number), + max: Math.max(span.max, positionOf(member) as number), + }), + { min: Infinity, max: -Infinity } + ); + +const anySpansOverlap = (groups: InstanceFacts[][], positionOf: PositionOf) => { + const spans = groups + .map((group) => spanOf(group, positionOf)) + .sort((a, b) => a.min - b.min); + let reach = -Infinity; + return spans.some((span) => { + if (span.min <= reach) return true; + reach = Math.max(reach, span.max); + return false; + }); +}; + +/** Members grouped by the axis value, in first-seen order; untagged apart. */ +const groupByAxis = (members: InstanceFacts[], axis: SemanticAxis) => { + const tagged = new Map(); + const untagged: InstanceFacts[] = []; + members.forEach((member) => { + const value = member[axis.fact]; + if (value === null) { + untagged.push(member); + return; + } + const group = tagged.get(value); + if (group) group.push(member); + else tagged.set(value, [member]); + }); + return { tagged, untagged }; +}; + +type Split = { value: string | null; members: InstanceFacts[] }; + +// A counter stamped per slice yields one group per slice; a single repeated +// position among them must not become one dataset per slice. +const splittingGroups = (axis: SemanticAxis, tagged: InstanceFacts[][]) => + axis.counter ? tagged.filter((group) => group.length > 1) : tagged; + +/** + * The first axis whose tagged groups cover overlapping stretches of the slice + * axis, with the members that carry no value for it as their own part. Scans + * that follow one another along the axis are one volume between them and stay + * merged, however their acquisition is numbered. + */ +const firstSplittingAxis = ( + members: InstanceFacts[], + axes: SemanticAxis[], + positionOf: PositionOf +) => + axes + .map((axis) => ({ axis, ...groupByAxis(members, axis) })) + .find(({ axis, tagged }) => { + const groups = splittingGroups(axis, [...tagged.values()]); + return groups.length >= 2 && anySpansOverlap(groups, positionOf); + }); + +const unsplit = ( + members: InstanceFacts[], + axes: SemanticAxis[], + repeatedPositions: boolean +): SemanticPart[] => [ + { + parts: axes.map((axis) => [axis.rule, null]), + members, + label: null, + repeatedPositions, + unreadablePositions: false, + }, +]; + +/** Members no axis can judge, held apart under a key part of their own. */ +const unreadableGeometry = ( + members: InstanceFacts[], + axes: SemanticAxis[] +): SemanticPart => ({ + parts: [ + ...axes.map((axis): [string, null] => [axis.rule, null]), + [GEOMETRY_RULE, UNREADABLE_GEOMETRY], + ], + members, + label: UNREADABLE_GEOMETRY_LABEL, + repeatedPositions: false, + unreadablePositions: true, +}); + +const labelFor = (axis: SemanticAxis, value: string | null) => + value === null ? `${axis.rule} unknown` : `${axis.rule} ${value}`; + +function splitByAxes( + members: InstanceFacts[], + axes: SemanticAxis[], + positionOf: PositionOf +): SemanticPart[] { + // One plane scanned repeatedly is a time series, not a stack of volumes; + // only a contrast axis may still tell its frames apart. + const singlePlane = countDistinctPositions(members, positionOf) === 1; + const found = firstSplittingAxis( + members, + singlePlane ? axes.filter((axis) => !axis.counter) : axes, + positionOf + ); + if (!found) + return unsplit(members, axes, hasRepeatedPositions(members, positionOf)); + + const { axis, tagged, untagged } = found; + const index = axes.indexOf(axis); + const skipped = axes.slice(0, index); + const rest = axes.slice(index + 1); + const splits: Split[] = [ + ...[...tagged].map(([value, group]) => ({ value, members: group })), + ...(untagged.length > 0 ? [{ value: null, members: untagged }] : []), + ]; + + return splits.flatMap(({ value, members: group }) => + splitByAxes(group, rest, positionOf).map((child) => ({ + ...child, + parts: [ + ...skipped.map((s): [string, null] => [s.rule, null]), + [axis.rule, value], + ...child.parts, + ], + label: [labelFor(axis, value), child.label] + .filter((part) => part !== null) + .join(', '), + })) + ); +} + +/** + * Separates members that hold more than one scan of the same anatomy. + * + * A single series can carry several scans of one range: overlapping + * acquisitions, DCE timepoints, Dixon echoes, or diffusion b-values. Merged, + * they interleave slices from different passes and derive a slice spacing from + * a lattice none of them sit on. The test is whether per-value groups cover + * overlapping stretches of the slice axis, decided by comparing positions + * rather than by measuring how even the spacing looks. + * + * Members whose position cannot be read are judged by nothing here, so they + * are held in a part of their own instead of suppressing the split of the + * members that can be judged. + * + * Conservative on the members it does judge: one overlapping pair separates + * every group at that level, and a counter axis neither fans out a repeated + * single plane nor splits on groups of one slice. Members that lack the value + * the level splits on form their own part rather than blocking the split, so + * the result depends only on the members given. + */ +export function splitOverlappingAcquisitions( + members: InstanceFacts[], + positionOf: PositionOf = ownPosition +): SemanticPart[] { + const axes = [...SEMANTIC_AXES]; + const readable = (member: InstanceFacts) => + Number.isFinite(positionOf(member)); + const judgeable = members.filter(readable); + const unreadable = members.filter((member) => !readable(member)); + + return [ + ...(judgeable.length > 0 ? splitByAxes(judgeable, axes, positionOf) : []), + ...(unreadable.length > 0 ? [unreadableGeometry(unreadable, axes)] : []), + ]; +} diff --git a/packages/dicom/core/index.ts b/packages/dicom/core/index.ts new file mode 100644 index 000000000..31af9068b --- /dev/null +++ b/packages/dicom/core/index.ts @@ -0,0 +1,32 @@ +export * from './collections/assignCollectionIds'; +export * from './collections/collectionRegistry'; +export * from './collections/instanceFacts'; +export * from './collections/planDicomCollections'; +export * from './collections/reconstructVolume'; +export * from './collections/splitOverlappingAcquisitions'; +export * from './collections/multiframeGeometry'; +export * from './metadata/dicomDataset'; +export * from './metadata/datasetAccess'; +export * from './metadata/dicomTags'; +export * from './metadata/dicomLayout'; +export * from './metadata/spatialMetadata'; +export * from './metadata/dcmjsParser'; +export * from './metadata/dicomCharacterSet'; +export * from './streaming/chunk'; +export * from './streaming/chunkStateMachine'; +export * from './streaming/types'; +export * from './streaming/blobFetcher'; +export * from './streaming/cachedStreamFetcher'; +export * from './streaming/requestPool'; +export * from './streaming/httpCodes'; +export * from './streaming/concatStreams'; +export * from './streaming/dicom/dicomMetaLoader'; +export * from './streaming/dicom/dicomDataLoader'; +export * from './streaming/dicom/dicomParser'; +export * from './streaming/dicom/ultrasoundRegion'; +export * from './streaming/stateMachine'; +export { default as StateMachine } from './streaming/stateMachine'; +export { default as ByteDeque } from './streaming/byteDeque'; +export { default as StreamingByteReader } from './streaming/streamingByteReader'; +export * from './runtime/createDicomLoader'; +export * from './runtime/decoder'; diff --git a/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts b/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts new file mode 100644 index 000000000..4d246e394 --- /dev/null +++ b/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts @@ -0,0 +1,236 @@ +import { readDicomDataset, findElement } from '../dicomDataset'; +import { getDatasetTag } from '../datasetAccess'; +import { getFrameTag } from '../functionalGroups'; +import { DicomMetaLoader } from '../../streaming/dicom/dicomMetaLoader'; +import { blobFetcher } from '../../streaming/blobFetcher'; +import { deflateRawSync } from 'node:zlib'; +import { describe, expect, it } from 'vitest'; +import { parseDicomElements } from '../dcmjsParser'; +import { PART_10_HEADER_LENGTH, PREAMBLE_LENGTH } from '../dicomLayout'; +import { concatBytes } from '../../utils'; +import { buildSlice } from '@/src/io/__tests__/readDicomTagsFixtures'; +import { + rawElement, + sequenceItem, + toImplicitVr, + stripFileMeta, + stripPreamble, +} from '@/tests/specs/syntheticDicom'; + +// (0002,0000) UL occupies twelve bytes, including its value. +const GROUP_LENGTH_ELEMENT_LENGTH = 12; +const withoutGroupLength = (file: Uint8Array) => + concatBytes([ + file.subarray(0, PART_10_HEADER_LENGTH), + file.subarray(PART_10_HEADER_LENGTH + GROUP_LENGTH_ELEMENT_LENGTH), + ]); + +// An empty fixed-Huffman block followed by a stored block and a final empty +// block is valid raw DEFLATE. Its first two bytes look like DICOM group 2. +const deflateWithMetaGroupPrefix = (data: Uint8Array) => { + const header = new Uint8Array([2, 0, 0, 0, 0, 0]); + const view = new DataView(header.buffer); + view.setUint16(2, data.length, true); + view.setUint16(4, 0xffff ^ data.length, true); + return concatBytes([header, data, new Uint8Array([3, 0])]); +}; + +describe('file meta information', () => { + it.each([ + { vr: 'explicit', implicitVr: false, preamble: true }, + { vr: 'implicit', implicitVr: true, preamble: true }, + { vr: 'explicit', implicitVr: false, preamble: false }, + { vr: 'implicit', implicitVr: true, preamble: false }, + ])( + 'reads $vr VR without group length (preamble: $preamble)', + ({ implicitVr, preamble }) => { + const file = buildSlice({ implicitVr }); + file.fill(0x41, 0, PREAMBLE_LENGTH); + const input = withoutGroupLength(file); + + expect( + parseDicomElements(preamble ? input : stripPreamble(input)) + ).toEqual(parseDicomElements(file)); + } + ); + + it.each([ + { groupLength: true, prefix: 'normal', deflate: deflateRawSync }, + { groupLength: false, prefix: 'normal', deflate: deflateRawSync }, + { + groupLength: true, + prefix: 'group 2', + deflate: deflateWithMetaGroupPrefix, + }, + ])( + 'reads deflated data after nonzero preamble bytes (group length: $groupLength, prefix: $prefix)', + ({ groupLength, deflate }) => { + const file = buildSlice(); + file.fill(0x41, 0, PREAMBLE_LENGTH); + const encode = (text: string) => new TextEncoder().encode(text); + const meta = concatBytes([ + rawElement(0x0002, 0x0010, 'UI', encode('1.2.840.10008.1.2.1.99')), + // The transfer syntax need not be the last file-meta element. + rawElement(0x0002, 0x0013, 'SH', encode('VOLVIEW ')), + ]); + const length = new Uint8Array(4); + new DataView(length.buffer).setUint32(0, meta.length, true); + const input = concatBytes([ + file.subarray(0, PART_10_HEADER_LENGTH), + ...(groupLength ? [rawElement(0x0002, 0x0000, 'UL', length)] : []), + meta, + deflate(stripFileMeta(file)), + ]); + + expect(parseDicomElements(input)).toEqual(parseDicomElements(file)); + } + ); + + it('still rejects file meta information without a transfer syntax', () => { + const file = buildSlice(); + const input = concatBytes([ + file.subarray(0, PART_10_HEADER_LENGTH), + rawElement(0x0002, 0x0000, 'UL', new Uint8Array(4)), + stripFileMeta(file), + ]); + + expect(() => parseDicomElements(input)).toThrow( + 'the transfer syntax is missing' + ); + }); +}); + +describe('lenient headers with standards-defined values', () => { + const encode = (text: string) => new TextEncoder().encode(text); + it.each([false, true])( + 'ignores an undersized meta group length (implicit: %s)', + (implicitVr) => { + const original = buildSlice({ implicitVr }); + const input = original.slice(); + new DataView(input.buffer).setUint32(PART_10_HEADER_LENGTH + 8, 0, true); + expect(parseDicomElements(input)).toEqual(parseDicomElements(original)); + } + ); + + it('reads a defined-length SQ containing a redundant sequence delimiter', async () => { + const value = concatBytes([ + sequenceItem(rawElement(0x0008, 0x1155, 'UI', encode('1.2.3\0'))), + new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]), + ]); + const bytes = buildSlice({ + extraElements: [rawElement(0x0008, 0x1140, 'SQ', value)], + }); + const loader = new DicomMetaLoader( + blobFetcher(new Blob([new Uint8Array(bytes)])) + ); + await loader.load(); + expect(getDatasetTag(loader.meta, '00280010')).toBe('4'); + loader.dispose(); + }); + + it('decodes a known defined-length UN using the dataset character set', () => { + const dataset = readDicomDataset( + concatBytes([ + rawElement(0x0008, 0x0005, 'CS', encode('ISO_IR 192')), + rawElement(0x0010, 0x0010, 'UN', encode('M\u00dcLLER ')), + ]) + ); + expect(getDatasetTag(dataset, '00100010')?.trim()).toBe('M\u00dcLLER'); + }); + + it('reads functional groups stored as defined-length UN with implicit items', () => { + const spacing = rawElement( + 0x0028, + 0x9110, + 'SQ', + sequenceItem(rawElement(0x0028, 0x0030, 'DS', encode('0.5\\0.75'))) + ); + const dataset = readDicomDataset( + concatBytes([ + rawElement(0x0008, 0x0060, 'CS', encode('CT')), + rawElement(0x5200, 0x9229, 'UN', sequenceItem(toImplicitVr(spacing))), + ]) + ); + expect(getFrameTag(dataset, '00280030')).toBe('0.5\\0.75'); + }); + + it('starts undeclared escape runs in the first declared repertoire', () => { + const dataset = readDicomDataset( + buildSlice({ + specificCharacterSet: 'ISO 2022 IR 100\\ISO 2022 IR 126', + patientNameBytes: new Uint8Array([0x52, 0xe9, 0x6d, 0x79]), + }) + ); + expect(getDatasetTag(dataset, '00100010')).toBe('R\u00e9my'); + }); + + it('preserves binary values in sequence items', () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const dataset = readDicomDataset( + buildSlice({ + extraElements: [ + rawElement( + 0x0008, + 0x1140, + 'SQ', + sequenceItem(rawElement(0x0029, 0x1010, 'OB', bytes)) + ), + ], + }) + ); + const sequence = findElement(dataset, '00081140')!.values; + if (sequence.kind !== 'sequence') throw new Error('Expected a sequence'); + expect(findElement(sequence.items[0], '00291010')!.values).toEqual({ + kind: 'bytes', + values: [bytes], + }); + }); + + it.each([false, true])( + 'reads ultrasound calibration from the structured header (implicit: %s)', + async (implicitVr) => { + const bytes = buildSlice({ + implicitVr, + modality: 'US', + ultrasoundRegion: { physicalDeltaX: 0.05, physicalDeltaY: 0.1 }, + }); + const loader = new DicomMetaLoader( + blobFetcher(new Blob([new Uint8Array(bytes)])) + ); + await loader.load(); + expect(loader.ultrasoundRegions).toMatchObject({ + regionCount: 1, + region: { physicalDeltaX: 0.05, physicalDeltaY: 0.1 }, + }); + loader.dispose(); + } + ); +}); + +it('decodes a known UN nested inside an explicit sequence with inherited UTF-8', () => { + const encode = (text: string) => new TextEncoder().encode(text); + const dataset = readDicomDataset( + concatBytes([ + rawElement(0x0008, 0x0005, 'CS', encode('ISO_IR 192')), + rawElement( + 0x0008, + 0x1140, + 'SQ', + sequenceItem(rawElement(0x0010, 0x0010, 'UN', encode('M\u00dcLLER '))) + ), + ]) + ); + const sequence = findElement(dataset, '00081140')!.values; + if (sequence.kind !== 'sequence') throw new Error('Expected a sequence'); + expect(getDatasetTag(sequence.items[0], '00100010')?.trim()).toBe( + 'M\u00dcLLER' + ); +}); + +it('stops before a known UN Pixel Data value, even when only its header is present', () => { + const input = concatBytes([ + rawElement(0x0008, 0x0060, 'CS', new TextEncoder().encode('CT')), + rawElement(0x7fe0, 0x0010, 'UN', new Uint8Array(16)).subarray(0, 12), + ]); + expect(parseDicomElements(input)).toHaveLength(1); +}); diff --git a/packages/dicom/core/metadata/__tests__/dicomDataset.spec.ts b/packages/dicom/core/metadata/__tests__/dicomDataset.spec.ts new file mode 100644 index 000000000..4d4436b4b --- /dev/null +++ b/packages/dicom/core/metadata/__tests__/dicomDataset.spec.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from 'vitest'; +import { DicomDataset, findElement, readDicomDataset } from '../dicomDataset'; +import { + rawElement, + sequenceItem, + undefinedLengthItem, + undefinedLengthSequence, +} from '@/tests/specs/syntheticDicom'; +import { + buildSlice, + JAPANESE_NAME, + JAPANESE_NAME_BYTES, + MIXED_VR_ELEMENTS, + UTF8_NAME, + UTF8_NAME_BYTES, +} from '@/src/io/__tests__/readDicomTagsFixtures'; + +const PATIENT_NAME = '00100010'; +const PIXEL_SPACING = '00280030'; +const IMAGE_POSITION_PATIENT = '00200032'; +const SPECIFIC_CHARACTER_SET = '00080005'; +const ICC_PROFILE = '00282000'; +const FRAME_DIMENSION_POINTER = '0028000A'; +const SIEMENS_CSA_DATA = '00291010'; +const GE_PRIVATE_TAG = '00091001'; +const ULTRASOUND_REGIONS = '00186011'; +const PHYSICAL_DELTA_X = '0018602C'; +const REQUEST_ATTRIBUTES = '00400275'; +const REFERENCED_STUDY = '00400008'; +const CODE_MEANING = '00080104'; +const SCHEDULED_PROCEDURE_STEP_ID = '00400009'; +const CURRENT_PATIENT_LOCATION = '00380300'; +const PERFORMING_PHYSICIAN_NAME = '00081050'; +const STUDY_COMMENTS = '00324000'; + +const ascii = (text: string) => new TextEncoder().encode(text); + +const concat = (...parts: Uint8Array[]) => { + const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0)); + parts.reduce((offset, part) => { + out.set(part, offset); + return offset + part.length; + }, 0); + return out; +}; + +const item = sequenceItem; + +const sequence = (group: number, element: number, items: Uint8Array) => + rawElement(group, element, 'SQ', items); + +// (0040,0275) holding one item that itself holds a sequence, which is the +// shape an enhanced object's functional groups take. +const NESTED_SEQUENCE = sequence( + 0x0040, + 0x0275, + item( + concat( + sequence( + 0x0040, + 0x0008, + item(rawElement(0x0008, 0x0104, 'LO', ascii('MEANING '))) + ), + rawElement(0x0040, 0x0009, 'SH', ascii('SPS1 ')) + ) + ) +); + +// A Japanese data set names the default repertoire and the extension its +// ideographic runs escape into, which is the shape real studies write. +const JAPANESE_CHARACTER_SET = 'ISO 2022 IR 6\\ISO 2022 IR 87'; + +// A code meaning whose UTF-8 bytes no single byte character set survives. +const UTF8_ITEM_TEXT = '\u738b '; + +const valuesOf = (dataset: DicomDataset, tag: string) => + findElement(dataset, tag)?.values; + +describe('readDicomDataset', () => { + it('keeps the tag, VR and value multiplicity of every element', () => { + const dataset = readDicomDataset( + buildSlice({ pixelSpacing: [0.5, 0.75], imagePositionPatient: [0, 0, 0] }) + ); + + expect(findElement(dataset, PIXEL_SPACING)).toMatchObject({ + tag: PIXEL_SPACING, + group: 0x0028, + element: 0x0030, + vr: 'DS', + }); + expect(valuesOf(dataset, PIXEL_SPACING)).toEqual({ + kind: 'decimal', + values: [0.5, 0.75], + text: ['0.5', '0.75'], + }); + expect(valuesOf(dataset, IMAGE_POSITION_PATIENT)).toEqual({ + kind: 'decimal', + values: [0, 0, 0], + text: ['0', '0', '0 '], + }); + }); + + it('reads a DS value that is not a number as null, not as zero', () => { + const dataset = readDicomDataset( + buildSlice({ + extraElements: [rawElement(0x0040, 0x9224, 'DS', ascii('1\\\\3'))], + }) + ); + + expect(valuesOf(dataset, '00409224')).toEqual({ + kind: 'decimal', + values: [1, null, 3], + text: ['1', '', '3'], + }); + }); + + it('descends into a sequence and into the sequence inside it', () => { + const dataset = readDicomDataset( + buildSlice({ extraElements: [NESTED_SEQUENCE] }) + ); + const outer = valuesOf(dataset, REQUEST_ATTRIBUTES); + if (outer?.kind !== 'sequence') throw new Error('not a sequence'); + const inner = valuesOf(outer.items[0], REFERENCED_STUDY); + if (inner?.kind !== 'sequence') throw new Error('not a sequence'); + + expect(outer.items).toHaveLength(1); + expect(valuesOf(outer.items[0], SCHEDULED_PROCEDURE_STEP_ID)).toEqual({ + kind: 'text', + values: ['SPS1 '], + }); + expect(inner.items).toHaveLength(1); + expect(valuesOf(inner.items[0], CODE_MEANING)).toEqual({ + kind: 'text', + values: ['MEANING '], + }); + }); + + it.each([ + ['inherits the enclosing', []], + [ + 'declares its own', + [rawElement(0x0008, 0x0005, 'CS', ascii('ISO_IR 192'))], + ], + ])('decodes an item that %s character set once', (_, localCharacterSet) => { + const dataset = readDicomDataset( + buildSlice({ + specificCharacterSet: 'ISO_IR 192', + extraElements: [ + sequence( + 0x0040, + 0x0275, + item( + concat( + ...localCharacterSet, + rawElement(0x0008, 0x0104, 'LO', ascii(UTF8_ITEM_TEXT)) + ) + ) + ), + ], + }) + ); + const values = valuesOf(dataset, REQUEST_ATTRIBUTES); + if (values?.kind !== 'sequence') throw new Error('not a sequence'); + + expect(valuesOf(values.items[0], CODE_MEANING)).toEqual({ + kind: 'text', + values: [UTF8_ITEM_TEXT], + }); + }); + + it.each([ + [ + 'a defined length', + (content: Uint8Array) => sequence(0x0040, 0x0275, item(content)), + ], + [ + 'an undefined length', + (content: Uint8Array) => + undefinedLengthSequence(0x0040, 0x0275, undefinedLengthItem(content)), + ], + ])( + 'decodes an item of %s sequence that declares a character set extension', + (_, build) => { + const dataset = readDicomDataset( + buildSlice({ + specificCharacterSet: JAPANESE_CHARACTER_SET, + extraElements: [ + build( + concat( + rawElement(0x0008, 0x0005, 'CS', ascii(JAPANESE_CHARACTER_SET)), + rawElement(0x0010, 0x0010, 'PN', JAPANESE_NAME_BYTES) + ) + ), + ], + }) + ); + const values = valuesOf(dataset, REQUEST_ATTRIBUTES); + if (values?.kind !== 'sequence') throw new Error('not a sequence'); + + expect(values.items[0].specificCharacterSet).toEqual([ + 'ISO 2022 IR 6', + 'ISO 2022 IR 87', + ]); + expect(valuesOf(values.items[0], PATIENT_NAME)).toEqual({ + kind: 'text', + values: [JAPANESE_NAME], + }); + } + ); + + it.each([ + ['explicit', false], + ['implicit', true], + ])( + 'reads the elements of an %s VR ultrasound region item', + (_, implicitVr) => { + const dataset = readDicomDataset( + buildSlice({ + implicitVr, + ultrasoundRegion: { physicalDeltaX: 0.1, physicalDeltaY: 0.2 }, + }) + ); + const values = valuesOf(dataset, ULTRASOUND_REGIONS); + if (values?.kind !== 'sequence') throw new Error('not a sequence'); + + expect(values.items[0].elements.map(({ tag }) => tag)).toEqual([ + '00186024', + '00186026', + '0018602C', + '0018602E', + ]); + expect(valuesOf(values.items[0], PHYSICAL_DELTA_X)).toEqual({ + kind: 'number', + values: [0.1], + }); + } + ); + + it('keeps the bytes of a sequence whose items it cannot read', () => { + // An item that claims more bytes than the sequence holds. + const truncatedItem = new Uint8Array([ + 0xfe, 0xff, 0x00, 0xe0, 0x40, 0x00, 0x00, 0x00, 0x18, 0x00, 0x24, 0x60, + ]); + const dataset = readDicomDataset( + buildSlice({ + extraElements: [rawElement(0x0018, 0x6011, 'SQ', truncatedItem)], + }) + ); + const values = valuesOf(dataset, ULTRASOUND_REGIONS); + if (values?.kind !== 'unread') throw new Error('unexpectedly read'); + + expect(values.bytes).toEqual(truncatedItem); + expect(values.reason.length).toBeGreaterThan(0); + }); + + it('keeps a private element and the bytes of its value', () => { + const dataset = readDicomDataset( + buildSlice({ extraElements: MIXED_VR_ELEMENTS }) + ); + + expect(findElement(dataset, SIEMENS_CSA_DATA)?.bytes).toEqual( + new Uint8Array(6).fill(0x41) + ); + expect(findElement(dataset, GE_PRIVATE_TAG)?.bytes).toEqual( + ascii('GEPRIV ') + ); + expect(findElement(dataset, GE_PRIVATE_TAG)?.vr).toBe('SH'); + }); + + it('leaves a public element of a known VR without a byte copy', () => { + const dataset = readDicomDataset(buildSlice()); + + expect(findElement(dataset, PATIENT_NAME)?.bytes).toBeUndefined(); + }); + + it('reads a buffer VR as bytes and an AT as referenced tags', () => { + const dataset = readDicomDataset( + buildSlice({ extraElements: MIXED_VR_ELEMENTS }) + ); + + expect(valuesOf(dataset, ICC_PROFILE)).toEqual({ + kind: 'bytes', + values: [new Uint8Array(8).fill(0x7f)], + }); + expect(valuesOf(dataset, FRAME_DIMENSION_POINTER)).toEqual({ + kind: 'attributeTag', + values: [ + { group: 0x0018, element: 0x1063 }, + { group: 0x0028, element: 0x00ff }, + ], + }); + }); + + it('applies the declared character set to the VRs it reaches', () => { + const dataset = readDicomDataset( + buildSlice({ + specificCharacterSet: 'ISO_IR 192', + patientNameBytes: UTF8_NAME_BYTES, + }) + ); + + expect(dataset.specificCharacterSet).toEqual(['ISO_IR 192']); + expect(valuesOf(dataset, PATIENT_NAME)).toEqual({ + kind: 'text', + values: [UTF8_NAME], + }); + }); + + it.each([ + ['LO', 0x0038, 0x0300, CURRENT_PATIENT_LOCATION], + ['PN', 0x0008, 0x1050, PERFORMING_PHYSICIAN_NAME], + ])( + 'separates %s values on a delimiter, not on a multibyte character', + (vr, group, element, tag) => { + const dataset = readDicomDataset( + buildSlice({ + specificCharacterSet: 'GB18030', + extraElements: [ + rawElement( + group, + element, + vr, + new Uint8Array([0x81, 0x5c, 0x5c, 0x81, 0x40]) + ), + ], + }) + ); + + expect(valuesOf(dataset, tag)).toEqual({ + kind: 'text', + values: ['\u4e57', '\u4e02 '], + }); + } + ); + + it('keeps a backslash inside a single valued text VR as a character', () => { + const dataset = readDicomDataset( + buildSlice({ + extraElements: [rawElement(0x0032, 0x4000, 'LT', ascii('C:\\DICOM'))], + }) + ); + + expect(valuesOf(dataset, STUDY_COMMENTS)).toEqual({ + kind: 'text', + values: ['C:\\DICOM'], + }); + }); + + it('reports a missing attribute as missing rather than as a default', () => { + const dataset = readDicomDataset(buildSlice()); + + expect(dataset.specificCharacterSet).toBeNull(); + expect(findElement(dataset, SPECIFIC_CHARACTER_SET)).toBeUndefined(); + expect(findElement(dataset, ICC_PROFILE)).toBeUndefined(); + }); +}); diff --git a/packages/dicom/core/metadata/__tests__/dicomLayout.spec.ts b/packages/dicom/core/metadata/__tests__/dicomLayout.spec.ts new file mode 100644 index 000000000..d56e742d4 --- /dev/null +++ b/packages/dicom/core/metadata/__tests__/dicomLayout.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + EXPLICIT_VR_LITTLE_ENDIAN, + IMPLICIT_VR_LITTLE_ENDIAN, + PART_10_HEADER_LENGTH, + sniffDicomLayout, +} from '../dicomLayout'; +import { stripFileMeta, stripPreamble } from '@/tests/specs/syntheticDicom'; +import { buildSlice } from '@/src/io/__tests__/readDicomTagsFixtures'; + +const head = (bytes: Uint8Array) => bytes.subarray(0, PART_10_HEADER_LENGTH); + +describe('sniffDicomLayout', () => { + it('reads a Part 10 file as preamble and file meta information', () => { + expect(sniffDicomLayout(head(buildSlice()))).toEqual({ + preamble: true, + fileMeta: true, + }); + }); + + it('reads file meta information at the first byte when the preamble is missing', () => { + expect(sniffDicomLayout(head(stripPreamble(buildSlice())))).toEqual({ + preamble: false, + fileMeta: true, + }); + }); + + it('assumes explicit VR little endian for a bare data set that spells VRs', () => { + expect(sniffDicomLayout(head(stripFileMeta(buildSlice())))).toEqual({ + preamble: false, + fileMeta: false, + transferSyntaxUid: EXPLICIT_VR_LITTLE_ENDIAN, + }); + }); + + it('assumes implicit VR little endian for a bare data set that does not', () => { + expect( + sniffDicomLayout(head(stripFileMeta(buildSlice({ implicitVr: true })))) + ).toEqual({ + preamble: false, + fileMeta: false, + transferSyntaxUid: IMPLICIT_VR_LITTLE_ENDIAN, + }); + }); + + it('judges a first element from fewer bytes than a preamble takes', () => { + expect( + sniffDicomLayout(stripFileMeta(buildSlice()).subarray(0, 8)) + ).toMatchObject({ fileMeta: false }); + }); + + it.each([ + ['all zeros', new Uint8Array(512)], + [ + 'a first element outside group 0008', + new Uint8Array([0x10, 0x00, 0x10, 0x00, 0x50, 0x4e, 0, 0]), + ], + ['letters all the way down', new Uint8Array(256).fill(0x41)], + ['too few bytes for an element', new Uint8Array([0x08, 0x00, 0x16])], + ])('rejects %s', (_case, bytes) => { + expect(() => sniffDicomLayout(bytes)).toThrow(/Not DICOM/); + }); +}); diff --git a/packages/dicom/core/metadata/__tests__/metadataReader.spec.ts b/packages/dicom/core/metadata/__tests__/metadataReader.spec.ts new file mode 100644 index 000000000..1177c24b5 --- /dev/null +++ b/packages/dicom/core/metadata/__tests__/metadataReader.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { readDicomDataset } from '../dicomDataset'; +import { createMetadataReader } from '../../runtime/metadataReader'; +const readDicomDatasetOffThread = createMetadataReader().read; +import { buildSlice } from '@/src/io/__tests__/readDicomTagsFixtures'; + +// The test DOM has no Worker, so this covers the in-place path only; the +// worker path is the same reader behind a message. +describe('readDicomDatasetOffThread', () => { + it('reads what the reader reads', async () => { + const bytes = buildSlice({ patientName: 'DOE^JOHN' }); + + expect(await readDicomDatasetOffThread(bytes)).toEqual( + readDicomDataset(bytes) + ); + }); + + it('reads a view that does not own its buffer', async () => { + const whole = buildSlice(); + const padded = new Uint8Array(whole.length + 3); + padded.set(whole, 3); + + expect(await readDicomDatasetOffThread(padded.subarray(3))).toEqual( + readDicomDataset(whole) + ); + }); + + it('rejects what the reader rejects', async () => { + await expect( + readDicomDatasetOffThread(new Uint8Array(256).fill(0x41)) + ).rejects.toThrow(/Not DICOM/); + }); +}); + +class MetadataWorker extends EventTarget { + terminated = false; + postMessage() {} + terminate() { + this.terminated = true; + } +} + +describe('metadata worker ownership', () => { + it('settles worker failure, allows retry, and terminates pending work on disposal', async () => { + const workers: MetadataWorker[] = []; + const reader = createMetadataReader({ + metadataWorkerFactory: () => { + const worker = new MetadataWorker(); + workers.push(worker); + return worker as unknown as Worker; + }, + }); + const first = reader.read(buildSlice()); + const failure = expect(first).rejects.toThrow('worker failed'); + workers[0].dispatchEvent(new Event('error')); + await failure; + expect(workers[0].terminated).toBe(true); + const retry = reader.read(buildSlice()); + const stopped = expect(retry).rejects.toMatchObject({ name: 'AbortError' }); + reader.dispose(); + await stopped; + expect(workers[1].terminated).toBe(true); + await expect(reader.read(buildSlice())).rejects.toMatchObject({ + name: 'AbortError', + }); + }); +}); diff --git a/packages/dicom/core/metadata/datasetAccess.ts b/packages/dicom/core/metadata/datasetAccess.ts new file mode 100644 index 000000000..62423ff75 --- /dev/null +++ b/packages/dicom/core/metadata/datasetAccess.ts @@ -0,0 +1,30 @@ +import type { DicomDataset, DicomElement } from './dicomDataset'; +import type { Chunk } from '../streaming/chunk'; + +const canonicalTag = (tag: string) => tag.replace('|', '').toUpperCase(); + +/** Text and numeric values retain multiplicity; sequences and opaque bytes do not become text. */ +export function getElementText(element: DicomElement): string | undefined { + const { values } = element; + switch (values.kind) { + case 'text': + case 'number': + return values.values.join('\\'); + case 'decimal': + return values.text.join('\\'); + default: + return undefined; + } +} + +export function getDatasetTag( + dataset: DicomDataset | null | undefined, + tag: string +) { + const requestedTag = canonicalTag(tag); + const element = dataset?.elements.find((entry) => entry.tag === requestedTag); + return element ? getElementText(element) : undefined; +} + +export const getChunkTag = (chunk: Chunk, tag: string) => + getDatasetTag(chunk.metadata, tag); diff --git a/packages/dicom/core/metadata/dcmjs.d.ts b/packages/dicom/core/metadata/dcmjs.d.ts new file mode 100644 index 000000000..efe4cfa94 --- /dev/null +++ b/packages/dicom/core/metadata/dcmjs.d.ts @@ -0,0 +1,82 @@ +declare module 'dcmjs' { + export type DicomTag = { + toCleanString(): string; + }; + + /** `vr` and `values` are `0` for the element a read stopped at. */ + export type DicomElement = { + tag: DicomTag; + vr: { type: string } | 0; + values: unknown; + rawValues: unknown; + }; + + export type DicomReadOptions = { + untilTag?: string; + includeUntilTagValue?: boolean; + forceStoreRaw?: boolean; + ignoreErrors?: boolean; + }; + + export type DicomStringDecoder = { + decode(view: ArrayBufferView): string; + }; + + export type DicomReadStream = { + offset: number; + reset(): DicomReadStream; + end(): boolean; + increment(count: number): void; + setEndian(littleEndian: boolean): void; + readAsciiString(length: number): string; + readVR(): string; + readUint16(): number; + readUint32(): number; + setDecoder(decoder: DicomStringDecoder): void; + more(length: number): DicomReadStream; + slice(start: number, end: number): ArrayBuffer; + }; + + export type DicomDictionaryEntry = { + tag: string; + vr: string; + vm: string; + name: string; + }; + + export type DicomDataElement = { + vr: string; + Value: unknown[]; + _rawValue: unknown; + }; + + const dcmjs: { + constants: { + encodingMapping: Record; + }; + data: { + DicomMetaDictionary: { + dictionary: Record; + }; + ReadBufferStream: new (buffer: ArrayBuffer) => DicomReadStream; + DeflatedReadBufferStream: new ( + stream: DicomReadStream + ) => DicomReadStream; + DicomMessage: { + _readTag( + stream: DicomReadStream, + syntax: string, + options?: DicomReadOptions + ): DicomElement; + _read( + stream: DicomReadStream, + syntax: string, + options?: DicomReadOptions + ): Record; + _normalizeSyntax(syntax: string): string; + }; + }; + }; + + export default dcmjs; +} diff --git a/packages/dicom/core/metadata/dcmjsParser.ts b/packages/dicom/core/metadata/dcmjsParser.ts new file mode 100644 index 000000000..d23e3a573 --- /dev/null +++ b/packages/dicom/core/metadata/dcmjsParser.ts @@ -0,0 +1,433 @@ +import dcmjs, { DicomElement, DicomReadStream } from 'dcmjs'; +import { + EXPLICIT_VR_LITTLE_ENDIAN, + IMPLICIT_VR_LITTLE_ENDIAN, + PART_10_HEADER_LENGTH, + sniffDicomLayout, +} from './dicomLayout'; + +const { encodingMapping } = dcmjs.constants; +const { + DicomMessage, + DicomMetaDictionary, + ReadBufferStream, + DeflatedReadBufferStream, +} = dcmjs.data; + +const FILE_META_GROUP = 0x0002; +const FILE_META_GROUP_LENGTH = '00020000'; +const TRANSFER_SYNTAX_UID = '00020010'; +const PIXEL_DATA = '7FE00010'; +const EXPLICIT_VR_BIG_ENDIAN = '1.2.840.10008.1.2.2'; +const DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1.99'; +const RESERVED_LENGTH = 2; +const UNDEFINED_LENGTH = 0xffffffff; + +/** VRs whose explicit form carries two reserved bytes and a 32 bit length. */ +const LENGTH_32_VRS = new Set([ + 'OB', + 'OD', + 'OF', + 'OL', + 'OV', + 'OW', + 'SQ', + 'SV', + 'UC', + 'UN', + 'UR', + 'UT', +]); + +/** Bound on one `String.fromCharCode` spread, which is an argument list. */ +const SPREAD_LIMIT = 4096; + +/** A byte string holds one code unit per byte, which `toBytes` reverses. */ +export const binaryString = (bytes: Uint8Array) => { + let text = ''; + for (let i = 0; i < bytes.length; i += SPREAD_LIMIT) + text += String.fromCharCode(...bytes.subarray(i, i + SPREAD_LIMIT)); + return text; +}; + +export const toBytes = (raw: string) => + Uint8Array.from(raw, (char) => char.charCodeAt(0) & 0xff); + +/** DICOM character set name to `TextDecoder` label, as dcmjs tabulates it. */ +export const encodingLabel = (name: string) => + encodingMapping[name.trim().replace(/[_ ]/g, '-').toLowerCase()]; + +/** + * One value as the file wrote it. A text VR is a byte string, so the Specific + * Character Set can be applied once the whole data set is known. + */ +export type ParsedValue = string | number | Uint8Array; + +/** One element, with no dcmjs object left in it. */ +export type ParsedElement = { + group: number; + element: number; + vr: string; + values: ParsedValue[]; + /** Items of a sequence, each a list of elements. */ + items?: ParsedElement[][]; + /** Set when the element's header was read but its value was not. */ + unread?: { bytes: Uint8Array; reason: string }; +}; + +/** + * Keeps one code unit per byte so the Specific Character Set can be applied + * once the whole data set is read. dcmjs otherwise picks a single decoder from + * character set value 1, which is the default repertoire rather than the + * extension that covers the non-ASCII characters. + */ +const byteDecoder = { + decode: (view: ArrayBufferView) => + binaryString(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)), +}; + +/** + * Bounded item streams must keep their bytes until the enclosing dataset's + * Specific Character Set is known, including an item's inherited repertoire. + */ +const readsBytes = (stream: DicomReadStream) => { + stream.setDecoder(byteDecoder); + const derive = stream.more.bind(stream); + stream.more = (length: number) => readsBytes(derive(length)); + return stream; +}; + +const viewBytes = (value: unknown) => { + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + return null; +}; + +/** A group or element number as four upper case hexadecimal digits. */ +export const hex4 = (value: number) => + value.toString(16).padStart(4, '0').toUpperCase(); + +const dictionaryVr = (group: number, element: number) => + DicomMetaDictionary.dictionary[`(${hex4(group)},${hex4(element)})`]?.vr; + +/** dcmjs reads from an ArrayBuffer, which a whole-buffer view already is. */ +const bufferOf = (bytes: Uint8Array) => + bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength + ? (bytes.buffer as ArrayBuffer) + : (bytes.slice().buffer as ArrayBuffer); + +/** + * Opens the data set. A file without the Part 10 preamble is read from its + * first element; one without file meta information is read in the transfer + * syntax its first element suggests. + */ +const pastDeflatedMeta = (syntax: unknown, offset: number, end: number) => + syntax === DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN && offset >= end; + +const openFile = (bytes: Uint8Array) => { + const layout = sniffDicomLayout(bytes.subarray(0, PART_10_HEADER_LENGTH)); + + const stream = new ReadBufferStream(bufferOf(bytes)); + stream.reset(); + if (layout.preamble) stream.increment(PART_10_HEADER_LENGTH); + if (!layout.fileMeta) + return { + dataSet: readsBytes(stream), + syntax: DicomMessage._normalizeSyntax(layout.transferSyntaxUid), + }; + + // Read group 2 sequentially when its group length is absent. A declared + // length also prevents compressed data from being mistaken for more meta. + let syntax: unknown; + let metaEnd = bytes.length; + stream.setEndian(true); + while (!stream.end() && !pastDeflatedMeta(syntax, stream.offset, metaEnd)) { + const start = stream.offset; + const group = stream.readUint16(); + stream.offset = start; + if (group !== FILE_META_GROUP) break; + const attribute = DicomMessage._readTag(stream, EXPLICIT_VR_LITTLE_ENDIAN); + const tag = attribute.tag.toCleanString(); + if (tag === FILE_META_GROUP_LENGTH) { + const [length] = attribute.values as number[]; + metaEnd = stream.offset + length; + } + if (tag === TRANSFER_SYNTAX_UID) [syntax] = attribute.values as unknown[]; + } + if (typeof syntax !== 'string') + throw new Error('Not a DICOM file: the transfer syntax is missing'); + + const dataSet = + syntax === DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN + ? new DeflatedReadBufferStream(stream) + : stream; + return { + dataSet: readsBytes(dataSet), + syntax: DicomMessage._normalizeSyntax(syntax), + }; +}; + +const needsSequenceReader = (vr: string | undefined, length: number) => + vr === 'SQ' || (vr === 'UN' && length === UNDEFINED_LENGTH); + +/** + * A known UN retains its implicit encoding even inside an explicit dataset. + */ +const isKnownUnknown = ( + vr: string | undefined, + group: number, + element: number +) => { + if (vr !== 'UN') return false; + const known = dictionaryVr(group, element); + return known !== undefined && known !== 'UN'; +}; + +const peekSequence = (dataSet: DicomReadStream, syntax: string) => { + const start = dataSet.offset; + const implicit = syntax === IMPLICIT_VR_LITTLE_ENDIAN; + dataSet.setEndian(syntax !== EXPLICIT_VR_BIG_ENDIAN); + const group = dataSet.readUint16(); + const element = dataSet.readUint16(); + const vr = implicit ? dictionaryVr(group, element) : dataSet.readVR(); + const length32 = implicit || LENGTH_32_VRS.has(vr ?? ''); + if (length32 && !implicit) dataSet.increment(RESERVED_LENGTH); + const length = length32 ? dataSet.readUint32() : dataSet.readUint16(); + const valueStart = dataSet.offset; + dataSet.offset = start; + const knownUnknown = isKnownUnknown(vr, group, element); + return needsSequenceReader(vr, length) || knownUnknown + ? { + unknown: vr === 'UN', + group, + element, + valueStart, + end: length === UNDEFINED_LENGTH ? null : valueStart + length, + } + : null; +}; + +/** + * dcmjs drops the trailing byte that padded a value to an even length, which + * the file wrote. A UI's pad is a null byte and carries no value, so only the + * space that pads a text VR is put back. + */ +const restorePadByte = (vr: string, values: string[]) => { + const byteLength = values.reduce( + (total, value) => total + value.length, + values.length - 1 + ); + return vr !== 'UI' && byteLength % 2 === 1 + ? values.map((value, index) => + index === values.length - 1 ? `${value} ` : value + ) + : values; +}; + +const rawValuesOf = (rawValues: unknown) => { + if (rawValues == null) return []; + return Array.isArray(rawValues) ? (rawValues as unknown[]) : [rawValues]; +}; + +const parsedValues = (vr: string, rawValues: unknown): ParsedValue[] => { + const values = rawValuesOf(rawValues); + if (values.every((value): value is string => typeof value === 'string')) + return restorePadByte(vr, values); + return values.map((value) => { + const bytes = viewBytes(value); + if (bytes) return bytes; + return typeof value === 'number' ? value : String(value); + }); +}; + +const groupOf = (tag: string) => Number.parseInt(tag.slice(0, 4), 16); +const elementOf = (tag: string) => Number.parseInt(tag.slice(4), 16); + +const parsedElement = (element: DicomElement): ParsedElement => { + const tag = element.tag.toCleanString(); + const vr = element.vr === 0 ? '' : element.vr.type; + return { + group: groupOf(tag), + element: elementOf(tag), + vr, + values: parsedValues(vr, element.rawValues), + }; +}; + +const READ_OPTIONS = { + untilTag: PIXEL_DATA, + includeUntilTagValue: false, + forceStoreRaw: true, +}; + +const readSequence = ( + dataSet: DicomReadStream, + syntax: string, + sequence: { + group: number; + element: number; + valueStart: number; + end: number | null; + unknown?: boolean; + } +): ParsedElement => { + if (sequence.unknown && sequence.end !== null) { + // PS3.5 6.2.2: a known UN value retains Implicit VR Little Endian encoding. + const value = new Uint8Array( + dataSet.slice(sequence.valueStart, sequence.end) + ); + const bytes = new Uint8Array(8 + value.length); + const header = new DataView(bytes.buffer); + header.setUint16(0, sequence.group, true); + header.setUint16(2, sequence.element, true); + header.setUint32(4, value.length, true); + bytes.set(value, 8); + dataSet.offset = sequence.end; + return readElements( + readsBytes(new ReadBufferStream(bytes.buffer)), + IMPLICIT_VR_LITTLE_ENDIAN, + { stopAtPixelData: false } + )[0]; + } + + const itemSyntax = sequence.unknown ? IMPLICIT_VR_LITTLE_ENDIAN : syntax; + dataSet.offset = sequence.valueStart; + try { + const items = readSequenceItems(dataSet, itemSyntax, sequence.end); + return { + group: sequence.group, + element: sequence.element, + vr: sequence.unknown ? 'UN' : 'SQ', + values: [], + items, + }; + } catch (error) { + if (sequence.end === null) throw error; + const bytes = new Uint8Array( + dataSet.slice(sequence.valueStart, sequence.end) + ); + dataSet.offset = sequence.end; + return { + group: sequence.group, + element: sequence.element, + vr: sequence.unknown ? 'UN' : 'SQ', + values: [], + unread: { bytes, reason: String(error) }, + }; + } +}; + +const assertSequenceBounds = ( + offset: number, + end: number | null, + length = 0 +) => { + if (end !== null && offset + length > end) + throw new Error('Sequence item exceeds the declared sequence length'); +}; + +function readSequenceItemLength(dataSet: DicomReadStream, syntax: string) { + dataSet.setEndian(syntax !== EXPLICIT_VR_BIG_ENDIAN); + const group = dataSet.readUint16(); + const element = dataSet.readUint16(); + const length = dataSet.readUint32(); + if (group === 0xfffe && element === 0xe0dd && length === 0) return null; + if (group !== 0xfffe || element !== 0xe000) + throw new Error('Expected a sequence item'); + return length; +} + +function readSequenceItems( + dataSet: DicomReadStream, + syntax: string, + end: number | null +) { + const items: ParsedElement[][] = []; + while (end === null || dataSet.offset < end) { + const length = readSequenceItemLength(dataSet, syntax); + if (length === null) return items; + assertSequenceBounds( + dataSet.offset, + end, + length === UNDEFINED_LENGTH ? 0 : length + ); + items.push( + length === UNDEFINED_LENGTH + ? readElements(dataSet, syntax, { + untilItemDelimiter: true, + stopAtPixelData: false, + }) + : readElements(dataSet.more(length), syntax, { stopAtPixelData: false }) + ); + assertSequenceBounds(dataSet.offset, end); + } + return items; +} + +/** Consumes an item delimiter, leaving any other element unread. */ +const readItemDelimiter = (dataSet: DicomReadStream) => { + const start = dataSet.offset; + dataSet.setEndian(true); + const group = dataSet.readUint16(); + const element = dataSet.readUint16(); + if (group !== 0xfffe || element !== 0xe00d) { + dataSet.offset = start; + return false; + } + if (dataSet.readUint32() !== 0) + throw new Error('Invalid item delimiter length'); + return true; +}; + +const readNextElement = ( + dataSet: DicomReadStream, + syntax: string, + stopAtPixelData: boolean +) => { + const sequence = peekSequence(dataSet, syntax); + if ( + sequence && + stopAtPixelData && + sequence.group === 0x7fe0 && + sequence.element === 0x0010 + ) + return { element: null, done: true }; + if (sequence) + return { element: readSequence(dataSet, syntax, sequence), done: false }; + + const element = DicomMessage._readTag( + dataSet, + syntax, + stopAtPixelData ? READ_OPTIONS : { forceStoreRaw: true } + ); + const done = stopAtPixelData && element.tag.toCleanString() === PIXEL_DATA; + return { + element: done || element.vr === 0 ? null : parsedElement(element), + done, + }; +}; + +const readElements = ( + dataSet: DicomReadStream, + syntax: string, + { untilItemDelimiter = false, stopAtPixelData = true } = {} +): ParsedElement[] => { + const elements: ParsedElement[] = []; + let done = false; + while (!done && !dataSet.end()) { + if (untilItemDelimiter && readItemDelimiter(dataSet)) return elements; + const next = readNextElement(dataSet, syntax, stopAtPixelData); + done = next.done; + if (next.element) elements.push(next.element); + } + if (untilItemDelimiter) + throw new Error('Missing item delimiter in undefined-length UN'); + return elements; +}; + +/** Reads a Part 10 file's data set elements, stopping before Pixel Data. */ +export const parseDicomElements = (bytes: Uint8Array) => { + const { dataSet, syntax } = openFile(bytes); + return readElements(dataSet, syntax); +}; diff --git a/packages/dicom/core/metadata/dicomCharacterSet.ts b/packages/dicom/core/metadata/dicomCharacterSet.ts new file mode 100644 index 000000000..d5a6ae45d --- /dev/null +++ b/packages/dicom/core/metadata/dicomCharacterSet.ts @@ -0,0 +1,206 @@ +import { encodingLabel, toBytes } from './dcmjsParser'; + +const ESCAPE_BYTE = 0x1b; + +export const VALUE_DELIMITER = '\\'; + +/** Bytes a conforming writer resets the repertoire before. */ +const DELIMITER_BYTES = new Set([0x0a, 0x0c, 0x0d, 0x3d, 0x5c, 0x5e]); + +/** + * The VRs a Specific Character Set applies to, from PS3.5 6.1.2 and PS3.3 + * C.12.1.1.2. Every other string VR, DS and IS included, stays in the default + * repertoire, so no charset transform can reach a numeric value. + */ +export const CHARSET_VRS = new Set(['SH', 'LO', 'ST', 'PN', 'LT', 'UC', 'UT']); + +/** Bytes of a VR the Specific Character Set does not reach are code points. */ +export const defaultRepertoire = (raw: string) => raw; + +/** A repertoire in force over one run of bytes. */ +type Repertoire = { + decode: (bytes: Uint8Array) => string; + multibyte: boolean; +}; + +const tryDecode = (decoder: TextDecoder, bytes: Uint8Array) => { + try { + return decoder.decode(bytes); + } catch { + return undefined; + } +}; + +const highHalf = (bytes: Uint8Array) => bytes.map((byte) => byte | 0x80); + +/** JIS X 0212 rides EUC-JP's third plane, one 0x8f before each high half pair. */ +const jisX0212Bytes = (bytes: Uint8Array) => + Uint8Array.from( + Array.from({ length: Math.floor(bytes.length / 2) }, (_, pair) => [ + 0x8f, + bytes[pair * 2] | 0x80, + bytes[pair * 2 + 1] | 0x80, + ]).flat() + ); + +const repertoire = ( + label: string, + { + multibyte = false, + mapBytes = (bytes: Uint8Array) => bytes, + }: { multibyte?: boolean; mapBytes?: (bytes: Uint8Array) => Uint8Array } = {} +): Repertoire => { + const decoder = new TextDecoder(label); + return { decode: (bytes) => decoder.decode(mapBytes(bytes)), multibyte }; +}; + +/** Final byte of an `ESC -` designation to the decoder for its high half. */ +const G1_LABELS: Record = { + A: 'latin1', + B: 'iso-8859-2', + C: 'iso-8859-3', + D: 'iso-8859-4', + F: 'iso-ir-126', + G: 'iso-ir-127', + H: 'iso-ir-138', + L: 'iso-ir-144', + M: 'iso-ir-148', + T: 'tis-620', +}; + +/** + * The ISO 2022 designations DICOM allows, keyed by the bytes following the + * escape. `ESC ( B` and `ESC ( J` return to the default repertoire, so they are + * added per file where that default is known. + */ +const DESIGNATIONS: Record = { + '(I': repertoire('shift-jis', { mapBytes: highHalf }), + ')I': repertoire('shift-jis'), + $B: repertoire('euc-jp', { multibyte: true, mapBytes: highHalf }), + $A: repertoire('gbk', { multibyte: true, mapBytes: highHalf }), + '$(D': repertoire('euc-jp', { multibyte: true, mapBytes: jisX0212Bytes }), + '$)C': repertoire('euc-kr', { multibyte: true }), + '$)A': repertoire('gbk', { multibyte: true }), + ...Object.fromEntries( + Object.entries(G1_LABELS).map(([final, label]) => [ + `-${final}`, + repertoire(label), + ]) + ), +}; + +/** Designations are two or three bytes wide, longest match first. */ +const designationAt = ( + bytes: Uint8Array, + at: number, + table: Record +) => + [3, 2] + .map((width) => ({ + width, + mode: table[String.fromCharCode(...bytes.subarray(at, at + width))], + })) + .find(({ mode }) => mode != null); + +/** + * Reads a value whose repertoire switches mid stream. Every run between two + * escapes decodes under the repertoire the earlier escape designated, and a + * delimiter returns a single byte repertoire to the default, as the standard + * requires a writer to do explicitly. + */ +const decodeDesignations = ( + bytes: Uint8Array, + initial: Repertoire, + table: Record +) => { + const runs: string[] = []; + let mode = initial; + let start = 0; + let at = 0; + const flush = (end: number) => { + if (end > start) runs.push(mode.decode(bytes.subarray(start, end))); + }; + + while (at < bytes.length) { + const designation = + bytes[at] === ESCAPE_BYTE + ? designationAt(bytes, at + 1, table) + : undefined; + if (designation) { + flush(at); + mode = designation.mode; + at += 1 + designation.width; + start = at; + } else { + if ( + mode !== initial && + !mode.multibyte && + DELIMITER_BYTES.has(bytes[at]) + ) { + flush(at); + mode = initial; + start = at; + } + at += 1; + } + } + flush(bytes.length); + return runs.join(''); +}; + +/** + * Value 1 of Specific Character Set is the default repertoire and values 2 and + * up are extensions. Bytes outside an escape sequence use value 1; another + * declared set is tried only if that decoder rejects the bytes. ISO 2022 IR 13 holds half width + * katakana as raw bytes that the escape driven ISO 2022 IR 87 decoder rejects, + * so no single decoder covers every value of such a file. + */ +export const characterSetDecoder = (specificCharacterSet: string[]) => { + const first = specificCharacterSet[0]?.trim() ?? ''; + const declared = specificCharacterSet + .slice(1) + .filter((name) => name.trim().length > 0) + .map(encodingLabel) + .filter((label): label is string => label != null); + const labels = [encodingLabel(first) ?? 'utf-8', ...declared]; + const asciiDefault = !first || /^ISO[ _](?:2022[ _])?IR[ _]6$/.test(first); + const strict = labels.map((label) => new TextDecoder(label, { fatal: true })); + const lenient = new TextDecoder(labels[0]); + // JIS X 0201 has no backslash or tilde; those code points are the yen sign + // and the overline, which is what GDCM emitted for such a file. + const jisRoman = labels.includes('shift-jis'); + + const initial: Repertoire = { + decode: (bytes) => + strict.reduce( + (text, decoder, index) => + text ?? + (index === 0 && asciiDefault && bytes.some((byte) => byte > 0x7f) + ? undefined + : tryDecode(decoder, bytes)), + undefined + ) ?? lenient.decode(bytes), + multibyte: false, + }; + const table = { ...DESIGNATIONS, '(B': initial, '(J': initial }; + + const decode = (raw: string) => { + const bytes = toBytes(raw); + return bytes.includes(ESCAPE_BYTE) + ? decodeDesignations(bytes, initial, table) + : initial.decode(bytes); + }; + const repertoireText = (text: string) => + jisRoman ? text.replace(/\\/g, '\u00a5').replace(/~/g, '\u203e') : text; + + return { + /** An element of value multiplicity 1, whose backslash is a character. */ + value: (raw: string) => repertoireText(decode(raw)), + /** + * A whole element's bytes, split into its values once decoded: byte 0x5c + * delimits values only where it is not part of a multibyte character. + */ + values: (raw: string) => + decode(raw).split(VALUE_DELIMITER).map(repertoireText), + }; +}; diff --git a/packages/dicom/core/metadata/dicomDataset.ts b/packages/dicom/core/metadata/dicomDataset.ts new file mode 100644 index 000000000..31d8f02ff --- /dev/null +++ b/packages/dicom/core/metadata/dicomDataset.ts @@ -0,0 +1,244 @@ +import { + hex4, + ParsedElement, + ParsedValue, + parseDicomElements, + toBytes, +} from './dcmjsParser'; +import { concatBytes, trimDicomPadding } from '../utils'; +import { + CHARSET_VRS, + characterSetDecoder, + defaultRepertoire, + VALUE_DELIMITER, +} from './dicomCharacterSet'; + +const SPECIFIC_CHARACTER_SET = '00080005'; + +/** A tag as `ggggeeee` in upper case hexadecimal. */ +export type DicomTagKey = string; + +export type AttributeTag = { group: number; element: number }; + +/** VRs whose values are text under the default repertoire or a declared set. */ +const TEXT_VRS = new Set([ + 'AE', + 'AS', + 'CS', + 'DA', + 'DT', + 'LO', + 'LT', + 'PN', + 'SH', + 'ST', + 'TM', + 'UC', + 'UI', + 'UR', + 'UT', +]); + +/** Text VRs of value multiplicity 1, whose backslash is a character. */ +const SINGLE_VALUED_TEXT_VRS = new Set(['LT', 'ST', 'UT']); + +/** VRs holding a number written as characters. */ +const DECIMAL_VRS = new Set(['DS', 'IS']); + +/** VRs holding a number written as bytes. */ +const NUMBER_VRS = new Set(['FL', 'FD', 'SL', 'SS', 'SV', 'UL', 'US', 'UV']); + +/** VRs whose value is an opaque buffer. */ +const BYTES_VRS = new Set(['OB', 'OD', 'OF', 'OL', 'OV', 'OW', 'UN']); + +const KNOWN_VRS = new Set([ + ...TEXT_VRS, + ...DECIMAL_VRS, + ...NUMBER_VRS, + ...BYTES_VRS, + 'AT', + 'SQ', +]); + +export type DicomValues = + /** A text VR, one entry per value, with the Specific Character Set applied. */ + | { kind: 'text'; values: string[] } + /** DS and IS: the numbers, next to the characters the file wrote. A value + * that is not a number is null rather than zero. */ + | { kind: 'decimal'; values: Array; text: string[] } + /** A binary numeric VR. */ + | { kind: 'number'; values: number[] } + /** AT, each value a referenced tag. */ + | { kind: 'attributeTag'; values: AttributeTag[] } + /** SQ, one data set per item. */ + | { kind: 'sequence'; items: DicomDataset[] } + /** A buffer VR. */ + | { kind: 'bytes'; values: Uint8Array[] } + /** An element whose header was read and whose value was not. */ + | { kind: 'unread'; bytes: Uint8Array; reason: string }; + +export type DicomElement = { + tag: DicomTagKey; + group: number; + element: number; + vr: string; + values: DicomValues; + /** Value bytes, kept where the VR alone does not say how to read them. */ + bytes?: Uint8Array; +}; + +export type DicomDataset = { + /** Elements in the order the parser reported them. */ + elements: DicomElement[]; + /** (0008,0005) as this data set declares it, or null when it declares none. */ + specificCharacterSet: string[] | null; +}; + +export const tagKey = (group: number, element: number) => + `${hex4(group)}${hex4(element)}`; + +export const isPrivateElement = ({ group }: DicomElement) => (group & 1) === 1; + +export const findElement = (dataset: DicomDataset, tag: DicomTagKey) => + dataset.elements.find((element) => element.tag === tag); + +const isText = (value: ParsedValue): value is string => + typeof value === 'string'; + +const isNumber = (value: ParsedValue): value is number => + typeof value === 'number'; + +const isBytes = (value: ParsedValue): value is Uint8Array => + value instanceof Uint8Array; + +const decimalOf = (text: string) => { + const trimmed = trimDicomPadding(text); + const value = Number(trimmed); + return trimmed.length > 0 && Number.isFinite(value) ? value : null; +}; + +const attributeTagOf = (packed: number) => ({ + group: packed >>> 16, + element: packed & 0xffff, +}); + +/** Undecoded value bytes, where every value carries them. */ +const rawBytesOf = ({ values }: ParsedElement) => { + if (values.every(isText)) return toBytes(values.join(VALUE_DELIMITER)); + if (values.every(isBytes)) + return values.length === 1 ? values[0] : concatBytes(values); + return undefined; +}; + +/** + * A private element's meaning follows its private creator, and an unknown VR + * says nothing at all, so both keep the bytes the file wrote. + */ +const keepsBytes = ({ group, vr }: ParsedElement) => + (group & 1) === 1 || vr === 'UN' || !KNOWN_VRS.has(vr); + +type CharacterSetDecoder = ReturnType; + +/** + * dcmjs splits a value on every 0x5c byte, which GB18030, GBK and ISO 2022 + * write inside multibyte characters, so a value the character set reaches is + * put back together and separated again once its characters are known. + */ +const textValues = ( + vr: string, + values: string[], + decode: CharacterSetDecoder +) => { + if (!CHARSET_VRS.has(vr) || values.length === 0) + return values.map(defaultRepertoire); + const raw = values.join(VALUE_DELIMITER); + return SINGLE_VALUED_TEXT_VRS.has(vr) + ? [decode.value(raw)] + : decode.values(raw); +}; + +const classifiedValues = ( + vr: string, + values: ParsedValue[], + decode: CharacterSetDecoder +): DicomValues => { + if (vr === 'AT' && values.every(isNumber)) + return { kind: 'attributeTag', values: values.map(attributeTagOf) }; + if (DECIMAL_VRS.has(vr) && values.every(isText)) + return { kind: 'decimal', values: values.map(decimalOf), text: values }; + if (values.every(isText)) + return { kind: 'text', values: textValues(vr, values, decode) }; + if (values.every(isNumber)) return { kind: 'number', values }; + if (values.every(isBytes)) return { kind: 'bytes', values }; + return { + kind: 'unread', + bytes: new Uint8Array(0), + reason: `mixed value types for VR ${vr}`, + }; +}; + +const elementValues = ( + parsed: ParsedElement, + decode: CharacterSetDecoder, + charset: string[] | null +): DicomValues => { + if (parsed.unread) + return { + kind: 'unread', + bytes: parsed.unread.bytes, + reason: parsed.unread.reason, + }; + if (parsed.items) + return { + kind: 'sequence', + items: parsed.items.map((item) => buildDataset(item, charset)), + }; + return classifiedValues(parsed.vr, parsed.values, decode); +}; + +const buildElement = ( + parsed: ParsedElement, + decode: CharacterSetDecoder, + charset: string[] | null +): DicomElement => { + const bytes = keepsBytes(parsed) ? rawBytesOf(parsed) : undefined; + return { + tag: tagKey(parsed.group, parsed.element), + group: parsed.group, + element: parsed.element, + vr: parsed.vr, + values: elementValues(parsed, decode, charset), + ...(bytes ? { bytes } : {}), + }; +}; + +// The declared set is the values the data set itself wrote, so a data set that +// declares none stays distinguishable from one that declares the default. +const declaredCharacterSet = (elements: ParsedElement[]) => { + const found = elements.find( + (element) => + tagKey(element.group, element.element) === SPECIFIC_CHARACTER_SET + ); + return found ? found.values.filter(isText).map((name) => name.trim()) : null; +}; + +function buildDataset( + elements: ParsedElement[], + inherited: string[] | null +): DicomDataset { + const declared = declaredCharacterSet(elements); + const charset = declared ?? inherited; + const decode = characterSetDecoder(charset ?? []); + return { + elements: elements.map((element) => buildElement(element, decode, charset)), + specificCharacterSet: declared, + }; +} + +/** + * Reads a Part 10 file's header into a standards-shaped data set: every element + * keeps its tag, VR, value multiplicity, nested items and, where the VR alone + * cannot say how to read it, its bytes. Nothing missing is filled in. + */ +export const readDicomDataset = (bytes: Uint8Array) => + buildDataset(parseDicomElements(bytes), null); diff --git a/packages/dicom/core/metadata/dicomLayout.ts b/packages/dicom/core/metadata/dicomLayout.ts new file mode 100644 index 000000000..af3b2c294 --- /dev/null +++ b/packages/dicom/core/metadata/dicomLayout.ts @@ -0,0 +1,90 @@ +import { toAscii } from '../utils'; + +/** + * How a DICOM byte stream opens. + * + * Part 10 puts 128 bytes of preamble and the letters DICM before the file meta + * information, but files written without them are common enough that GDCM + * reads them, so the opening is sniffed rather than required: the file meta + * information may start at the first byte, or the data set itself may, with + * nothing to name its transfer syntax but the shape of its first element. + */ +export const PREAMBLE_LENGTH = 128; +export const DICOM_PREFIX = 'DICM'; +/** The bytes a Part 10 file spends before its first element. */ +export const PART_10_HEADER_LENGTH = PREAMBLE_LENGTH + DICOM_PREFIX.length; + +export const IMPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2'; +export const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1'; + +export type DicomLayout = + /** Part 10: preamble, prefix and file meta information. */ + | { preamble: true; fileMeta: true } + /** File meta information from the first byte, with no preamble. */ + | { preamble: false; fileMeta: true } + /** + * A bare data set, with no file meta information to name its transfer + * syntax. The syntax is what the encoding of the first element suggests. + */ + | { preamble: false; fileMeta: false; transferSyntaxUid: string }; + +const FILE_META_GROUP = 0x0002; + +/** + * The group a bare data set opens with: every SOP instance carries a SOP Class + * UID in it, and Specific Character Set and Image Type sort ahead of that. + * Bytes that open with any other group are not a data set, and reading them + * as one would take arbitrary noise for a header. + */ +const FIRST_DATA_SET_GROUP = 0x0008; + +/** The bytes needed to judge one element's tag and, in explicit VR, its VR. */ +const ELEMENT_HEAD_LENGTH = 6; + +// prettier-ignore +const VRS = new Set([ + 'AE', 'AS', 'AT', 'CS', 'DA', 'DS', 'DT', 'FD', 'FL', 'IS', 'LO', 'LT', 'OB', + 'OD', 'OF', 'OL', 'OV', 'OW', 'PN', 'SH', 'SL', 'SQ', 'SS', 'ST', 'SV', 'TM', + 'UC', 'UI', 'UL', 'UN', 'UR', 'US', 'UT', 'UV', +]); + +const littleEndianGroup = (head: Uint8Array) => head[0] | (head[1] << 8); + +/** Whether the two bytes after the first tag spell a VR, as explicit VR does. */ +const opensWithVr = (head: Uint8Array) => + VRS.has(toAscii(head.subarray(4, ELEMENT_HEAD_LENGTH))); + +/** + * Reads how `head` opens. `head` should hold the first 132 bytes of the file; + * fewer decide what they can. Throws when no reading fits, since guessing past + * this point would parse noise as a header. + */ +export function sniffDicomLayout(head: Uint8Array): DicomLayout { + if ( + head.length >= PART_10_HEADER_LENGTH && + toAscii(head.subarray(PREAMBLE_LENGTH, PART_10_HEADER_LENGTH)) === + DICOM_PREFIX + ) + return { preamble: true, fileMeta: true }; + + if (head.length < ELEMENT_HEAD_LENGTH) + throw new Error('Not DICOM: too short to hold a data element'); + + // File meta information is always explicit VR little endian. + const group = littleEndianGroup(head); + if (group === FILE_META_GROUP && opensWithVr(head)) + return { preamble: false, fileMeta: true }; + + if (group !== FIRST_DATA_SET_GROUP) + throw new Error( + 'Not DICOM: no preamble, file meta information or data set' + ); + + return { + preamble: false, + fileMeta: false, + transferSyntaxUid: opensWithVr(head) + ? EXPLICIT_VR_LITTLE_ENDIAN + : IMPLICIT_VR_LITTLE_ENDIAN, + }; +} diff --git a/src/core/dicomTags.ts b/packages/dicom/core/metadata/dicomTags.ts similarity index 89% rename from src/core/dicomTags.ts rename to packages/dicom/core/metadata/dicomTags.ts index 585f43ac7..59125b5dc 100644 --- a/src/core/dicomTags.ts +++ b/packages/dicom/core/metadata/dicomTags.ts @@ -18,7 +18,9 @@ const tags: Tag[] = [ { name: 'Modality', tag: '0008|0060' }, { name: 'SeriesInstanceUID', tag: '0020|000e' }, { name: 'SeriesNumber', tag: '0020|0011' }, + { name: 'SeriesDate', tag: '0008|0021' }, { name: 'SeriesDescription', tag: '0008|103e' }, + { name: 'InstanceNumber', tag: '0020|0013' }, { name: 'WindowLevel', tag: '0028|1050' }, { name: 'WindowWidth', tag: '0028|1051' }, { name: 'VOILUTFunction', tag: '0028|1056' }, @@ -27,10 +29,16 @@ const tags: Tag[] = [ { name: 'BitsAllocated', tag: '0028|0100' }, { name: 'BitsStored', tag: '0028|0101' }, { name: 'PixelRepresentation', tag: '0028|0103' }, + { name: 'AcquisitionNumber', tag: '0020|0012' }, + { name: 'TemporalPositionIdentifier', tag: '0020|0100' }, + { name: 'EchoNumbers', tag: '0018|0086' }, + { name: 'DiffusionBValue', tag: '0018|9087' }, { name: 'ImagePositionPatient', tag: '0020|0032' }, { name: 'ImageOrientationPatient', tag: '0020|0037' }, { name: 'PixelSpacing', tag: '0028|0030' }, { name: 'SpacingBetweenSlices', tag: '0018|0088' }, + { name: 'SliceThickness', tag: '0018|0050' }, + { name: 'SequenceName', tag: '0018|0024' }, { name: 'SamplesPerPixel', tag: '0028|0002' }, { name: 'RescaleIntercept', tag: '0028|1052' }, { name: 'RescaleSlope', tag: '0028|1053' }, diff --git a/packages/dicom/core/metadata/functionalGroups.ts b/packages/dicom/core/metadata/functionalGroups.ts new file mode 100644 index 000000000..2f84c1a99 --- /dev/null +++ b/packages/dicom/core/metadata/functionalGroups.ts @@ -0,0 +1,54 @@ +import type { DicomDataset } from './dicomDataset'; +import { findElement } from './dicomDataset'; +import { getDatasetTag } from './datasetAccess'; +import { getNmDetectorTag } from './nmReconstruction'; + +const SHARED_GROUPS = '52009229'; +const PER_FRAME_GROUPS = '52009230'; + +const IMAGE_MACROS = new Map([ + ['00180050', '00289110'], // Pixel Measures: Slice Thickness + ['00180088', '00289110'], // Pixel Measures: Spacing Between Slices + ['00280030', '00289110'], // Pixel Measures: Pixel Spacing + ['00200032', '00209113'], // Plane Position (Patient) + ['00200037', '00209116'], // Plane Orientation (Patient) + ['00281052', '00289145'], // Pixel Value Transformation: Intercept + ['00281053', '00289145'], // Pixel Value Transformation: Slope +]); + +export function sequenceItems(dataset: DicomDataset, tag: string) { + const values = findElement(dataset, tag)?.values; + return values?.kind === 'sequence' ? values.items : []; +} + +const macroValue = ( + group: DicomDataset | undefined, + macro: string, + tag: string +) => (group ? getDatasetTag(sequenceItems(group, macro)[0], tag) : undefined); + +/** + * Image attributes have specific Functional Group paths. Unrelated nested + * sequences, including referenced images, never supply the image's own tags. + */ +export function getFrameTag( + dataset: DicomDataset | null | undefined, + tag: string, + frameIndex = 0 +) { + if (!dataset) return undefined; + const key = tag.replace('|', '').toUpperCase(); + const macro = IMAGE_MACROS.get(key); + if (!macro) return getDatasetTag(dataset, key); + const perFrame = sequenceItems(dataset, PER_FRAME_GROUPS)[frameIndex]; + const shared = sequenceItems(dataset, SHARED_GROUPS)[0]; + return ( + macroValue(perFrame, macro, key) ?? + macroValue(shared, macro, key) ?? + getNmDetectorTag(dataset, key) ?? + getDatasetTag(dataset, key) + ); +} + +export const perFrameGroups = (dataset: DicomDataset) => + sequenceItems(dataset, PER_FRAME_GROUPS); diff --git a/packages/dicom/core/metadata/modalityRescale.ts b/packages/dicom/core/metadata/modalityRescale.ts new file mode 100644 index 000000000..0e270ed06 --- /dev/null +++ b/packages/dicom/core/metadata/modalityRescale.ts @@ -0,0 +1,27 @@ +import type { DicomDataset } from './dicomDataset'; +import { getFrameTag, perFrameGroups } from './functionalGroups'; +import { numberOr } from '../utils'; + +export const readModalityRescale = ( + metadata: DicomDataset | null | undefined, + frameIndex = 0 +) => ({ + rescaleSlope: numberOr(getFrameTag(metadata, '00281053', frameIndex), 1), + rescaleIntercept: numberOr(getFrameTag(metadata, '00281052', frameIndex), 0), +}); + +/** GDCM applies one transform to an instance, so its frames must agree. */ +export function assertUniformModalityRescale(metadata: DicomDataset) { + const first = readModalityRescale(metadata); + const differs = perFrameGroups(metadata).some((_, index) => { + const frame = readModalityRescale(metadata, index); + return ( + frame.rescaleSlope !== first.rescaleSlope || + frame.rescaleIntercept !== first.rescaleIntercept + ); + }); + if (differs) + throw new Error( + 'Cannot load DICOM frames with differing modality rescale: the decoder applies one transform to the entire instance' + ); +} diff --git a/packages/dicom/core/metadata/nmReconstruction.ts b/packages/dicom/core/metadata/nmReconstruction.ts new file mode 100644 index 000000000..6f9ec5fcc --- /dev/null +++ b/packages/dicom/core/metadata/nmReconstruction.ts @@ -0,0 +1,146 @@ +import { findElement, type DicomDataset } from './dicomDataset'; +import { getDatasetTag } from './datasetAccess'; +import { trimDicomPadding } from '../utils'; + +const NM_IMAGE_STORAGE = '1.2.840.10008.5.1.4.1.1.20'; +const SLICE_VECTOR = '00540080'; +const DETECTOR_INFORMATION = '00540022'; + +const text = (metadata: DicomDataset, tag: string) => + trimDicomPadding(getDatasetTag(metadata, tag) ?? ''); + +const number = (metadata: DicomDataset, tag: string) => { + const value = text(metadata, tag); + return value ? Number(value) : NaN; +}; + +const vector = (metadata: DicomDataset, tag: string) => + text(metadata, tag) + .split('\\') + .map((value) => Number(trimDicomPadding(value))); + +const detectorItems = (metadata: DicomDataset) => { + const values = findElement(metadata, DETECTOR_INFORMATION)?.values; + return values?.kind === 'sequence' ? values.items : []; +}; + +const spatialPointer = (metadata: DicomDataset) => { + const pointer = findElement(metadata, '00280009'); + if (!pointer) return true; + return ( + pointer.values.kind === 'attributeTag' && + pointer.values.values.length === 1 && + pointer.values.values[0].group === 0x0054 && + pointer.values.values[0].element === 0x0080 + ); +}; + +const singleDimension = (metadata: DicomDataset) => + [ + '00540011', + '00540031', + '00540051', + '00540061', + '00540071', + '00540101', + ].every((tag) => !text(metadata, tag) || number(metadata, tag) === 1); + +const singleDimensionVectors = (metadata: DicomDataset) => + [ + '00540010', + '00540020', + '00540030', + '00540050', + '00540060', + '00540070', + '00540100', + ].every( + (tag) => + !text(metadata, tag) || + vector(metadata, tag).every((value) => value === 1) + ); + +const orderedSlices = (metadata: DicomDataset, count: number) => { + const values = text(metadata, SLICE_VECTOR); + if (!values) return !findElement(metadata, '00280009'); + const slices = vector(metadata, SLICE_VECTOR); + return ( + slices.length === count && + slices.every((slice, index) => slice === index + 1) + ); +}; + +type NmReconstruction = + | { error: string } + | { detector: DicomDataset; count: number; sliceSpacing: number | null }; + +const dimensionError = ( + metadata: DicomDataset, + type: string, + count: number +) => { + if (!text(metadata, '00540081')) + return 'Cannot allocate NM volume without NumberOfSlices'; + return type !== 'RECON TOMO' || + count !== number(metadata, '00540081') || + !singleDimension(metadata) || + !singleDimensionVectors(metadata) || + !spatialPointer(metadata) + ? 'Unsupported NM volume with multiple spatial/time dimensions' + : null; +}; + +const detectorError = (metadata: DicomDataset, detectors: DicomDataset[]) => + number(metadata, '00540021') !== 1 || detectors.length !== 1 + ? 'Unsupported NM volume with multiple or missing detectors' + : null; + +function sliceError(metadata: DicomDataset, count: number) { + if ( + !Number.isSafeInteger(count) || + count <= 0 || + !orderedSlices(metadata, count) + ) + return 'Unsupported NM volume with unordered or missing slice indices'; + const spacing = number(metadata, '00180088'); + return (count === 1 && !text(metadata, '00180088')) || + (Number.isFinite(spacing) && spacing !== 0) + ? null + : 'Cannot allocate NM volume without nonzero slice spacing'; +} + +/** + * NM detector coordinates describe a volume only for a single reconstructed + * spatial stack. Projection and gated dimensions require their own assembly. + */ +export function readNmReconstruction( + metadata: DicomDataset +): NmReconstruction | null { + if (text(metadata, '00080016') !== NM_IMAGE_STORAGE) return null; + const imageType = text(metadata, '00080008').split('\\')[2]?.trim() ?? ''; + if (imageType !== 'RECON TOMO' && imageType !== 'RECON GATED TOMO') + return null; + const count = number(metadata, '00280008'); + const detectors = detectorItems(metadata); + const error = + dimensionError(metadata, imageType, count) ?? + detectorError(metadata, detectors) ?? + sliceError(metadata, count); + return error + ? { error } + : { + detector: detectors[0], + count, + sliceSpacing: text(metadata, '00180088') + ? number(metadata, '00180088') + : null, + }; +} + +export function getNmDetectorTag(metadata: DicomDataset, tag: string) { + if (tag !== '00200032' && tag !== '00200037') return undefined; + const reconstruction = readNmReconstruction(metadata); + return reconstruction && !('error' in reconstruction) + ? getDatasetTag(reconstruction.detector, tag) + : undefined; +} diff --git a/packages/dicom/core/metadata/readDicomDataset.worker.ts b/packages/dicom/core/metadata/readDicomDataset.worker.ts new file mode 100644 index 000000000..4446f9a64 --- /dev/null +++ b/packages/dicom/core/metadata/readDicomDataset.worker.ts @@ -0,0 +1,5 @@ +import * as Comlink from 'comlink'; +import { readDicomDataset } from './dicomDataset'; + +export type DicomDatasetWorker = { readDicomDataset: typeof readDicomDataset }; +Comlink.expose({ readDicomDataset }); diff --git a/packages/dicom/core/metadata/spatialMetadata.ts b/packages/dicom/core/metadata/spatialMetadata.ts new file mode 100644 index 000000000..fa5dd4b39 --- /dev/null +++ b/packages/dicom/core/metadata/spatialMetadata.ts @@ -0,0 +1,60 @@ +import type { IrregularReason } from '../collections/reconstructVolume'; +import type { DicomDataset } from './dicomDataset'; +import { getFrameTag } from './functionalGroups'; +import { trimDicomPadding } from '../utils'; + +/** Describes the spacing actually used by the image, not independent calibration. */ +export type AxisSpacing = { + value: number; + unit: 'mm' | 'unknown'; + status: 'declared' | 'derived' | 'fallback'; + source: string; + reason?: string; +}; + +export type SpatialMetadata = { + /** Image index axes, in x/y/z order. */ + axes: [AxisSpacing, AxisSpacing, AxisSpacing]; + reconstruction?: { kind: 'regular' | 'irregular'; reason?: IrregularReason }; + notes: string[]; + calibration?: { type?: string; description?: string }; +}; + +export const physicalSpacing = ( + value: number, + source: string, + status: 'declared' | 'derived' = 'declared' +): AxisSpacing => ({ value, unit: 'mm', status, source }); + +export const fallbackSpacing = (reason: string): AxisSpacing => ({ + value: 1, + unit: 'unknown', + status: 'fallback', + source: 'Display default', + reason, +}); + +export function declaredSpacing(raw: string | undefined, source: string) { + const text = trimDicomPadding(raw ?? ''); + if (!text) return fallbackSpacing(`${source} is not supplied.`); + const value = Number(text); + return Number.isFinite(value) && value > 0 + ? physicalSpacing(value, source) + : fallbackSpacing(`${source} has no usable positive spacing.`); +} + +/** Preserve a usable axis even when the other axis is missing or unusable. */ +export function readPixelSpacing(metadata: DicomDataset | null | undefined) { + const raw = trimDicomPadding(getFrameTag(metadata, '00280030') ?? ''); + const parts = raw ? raw.split('\\') : undefined; + if (parts && parts.length < 2) { + return [ + fallbackSpacing('PixelSpacing does not contain two values.'), + fallbackSpacing('PixelSpacing does not contain two values.'), + ] as const; + } + return [ + declaredSpacing(parts?.[1], 'PixelSpacing'), + declaredSpacing(parts?.[0], 'PixelSpacing'), + ] as const; +} diff --git a/packages/dicom/core/runtime/__tests__/decoderCalibration.spec.ts b/packages/dicom/core/runtime/__tests__/decoderCalibration.spec.ts new file mode 100644 index 000000000..284db371e --- /dev/null +++ b/packages/dicom/core/runtime/__tests__/decoderCalibration.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { createDicomDecoder } from '../decoder'; +import { createDicomLoader } from '../createDicomLoader'; +import { readDicomDataset } from '../../metadata/dicomDataset'; +import { defer } from '../../utils'; +import { enhancedFixtureBytes } from '@/tests/fixtures/dicom-consumer/enhancedFixtures'; + +const assets = { + pipelineWorkerUrl: '/unused-worker.js', + imageIOUrl: '/unused-image-io', +}; +const varyingFrames = () => + enhancedFixtureBytes({ + pixelValues: [2, 2], + transforms: [ + [1, 0], + [2, 0], + ], + }); +const fileOf = (bytes: Uint8Array) => + new File([bytes as Uint8Array], 'enhanced.dcm'); + +describe('Default DICOM decoder calibration', () => { + for (const create of [ + () => createDicomDecoder(assets), + () => { + const loader = createDicomLoader({ assets }); + return { decode: loader.readDicomImage, dispose: loader.dispose }; + }, + ]) { + it('rejects varying frame transforms before invoking GDCM, with or without supplied metadata', async () => { + const decoder = create(); + const bytes = varyingFrames(); + try { + await expect(decoder.decode(fileOf(bytes))).rejects.toThrow( + 'differing modality rescale' + ); + await expect( + decoder.decode(fileOf(bytes), readDicomDataset(bytes)) + ).rejects.toThrow('differing modality rescale'); + } finally { + decoder.dispose(); + } + }); + } + + it('settles disposal while a direct decoder waits for metadata', async () => { + const started = defer(); + const metadata = defer>(); + const decoder = createDicomDecoder(assets, () => { + started.resolve(); + return metadata.promise; + }); + const bytes = varyingFrames(); + const pending = decoder.decode(fileOf(bytes)); + const rejected = expect(pending).rejects.toMatchObject({ + name: 'AbortError', + }); + await started.promise; + decoder.dispose(); + await rejected; + metadata.resolve(readDicomDataset(bytes)); + await expect(decoder.decode(fileOf(bytes))).rejects.toMatchObject({ + name: 'AbortError', + }); + }); +}); diff --git a/packages/dicom/core/runtime/__tests__/lifetime.spec.ts b/packages/dicom/core/runtime/__tests__/lifetime.spec.ts new file mode 100644 index 000000000..ec75bc523 --- /dev/null +++ b/packages/dicom/core/runtime/__tests__/lifetime.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest'; +import { createDicomLoader } from '../createDicomLoader'; +import { Chunk, waitForChunkState } from '../../streaming/chunk'; +import { ChunkState } from '../../streaming/chunkStateMachine'; +import { DicomMetaLoader } from '../../streaming/dicom/dicomMetaLoader'; +import { DicomDataLoader } from '../../streaming/dicom/dicomDataLoader'; +import { blobFetcher } from '../../streaming/blobFetcher'; +import { RequestPool } from '../../streaming/requestPool'; +import { readDicomDataset } from '../../metadata/dicomDataset'; +import { getDatasetTag } from '../../metadata/datasetAccess'; +import { defer } from '../../utils'; +import { syntheticSlice } from '@/tests/dicomSourceFixtures'; + +const blob = () => + new Blob([new Uint8Array(syntheticSlice({ patientName: 'DOE^JOHN' }))]); + +describe('DICOM instance lifetimes', () => { + it('settles cancellation and lets retry finish before a stale header read', async () => { + const first = defer>(); + const started = defer(); + const source = blobFetcher(blob()); + let reads = 0; + const metadata = new DicomMetaLoader(source, (bytes) => { + if (reads++ === 0) { + started.resolve(); + return first.promise; + } + return readDicomDataset(bytes); + }); + const chunk = new Chunk({ + metaLoader: metadata, + dataLoader: new DicomDataLoader(source), + }); + const cancelledRead = chunk.loadMeta(); + await started.promise; + const rejected = expect(cancelledRead).rejects.toMatchObject({ + name: 'AbortError', + }); + chunk.stopLoad(); + await chunk.loadMeta(); + await rejected; + first.resolve( + readDicomDataset(syntheticSlice({ patientName: 'STALE^NAME' })) + ); + await first.promise; + expect(getDatasetTag(chunk.metadata, '00100010')).toBe('DOE^JOHN'); + await chunk.loadData(); + expect(await chunk.dataBlob!.arrayBuffer()).toEqual( + await blob().arrayBuffer() + ); + chunk.dispose(); + await expect(chunk.loadMeta()).rejects.toMatchObject({ + name: 'AbortError', + }); + }); + + it('retries a failed HTTP source without creating a second instance', async () => { + let requests = 0; + const loader = createDicomLoader({ + fetch: async () => { + if (requests++ === 0) throw new Error('offline'); + return new Response(blob()); + }, + }); + const chunk = loader.createChunk('https://example.test/slice.dcm'); + await expect(chunk.loadMeta()).rejects.toThrow('offline'); + await chunk.loadMeta(); + expect(getDatasetTag(chunk.metadata, '00100010')).toBe('DOE^JOHN'); + await chunk.loadData(); + expect(chunk.dataBlob?.size).toBe(blob().size); + loader.release(chunk); + expect(chunk.dataBlob).toBeUndefined(); + await expect(chunk.loadData()).rejects.toMatchObject({ + name: 'AbortError', + }); + loader.dispose(); + }); + + it('settles an injected pending decode when its loader is disposed', async () => { + const result = defer<{ + image: { + size: number[]; + data: Uint16Array; + imageType: { components: number }; + }; + }>(); + const started = defer(); + const loader = createDicomLoader({ + decoder: () => { + started.resolve(); + return result.promise; + }, + }); + const decode = loader.readDicomImage(new File([blob()], 'slice.dcm')); + const rejected = expect(decode).rejects.toMatchObject({ + name: 'AbortError', + }); + await started.promise; + loader.dispose(); + await rejected; + result.resolve({ + image: { + size: [1, 1], + data: new Uint16Array([17]), + imageType: { components: 1 }, + }, + }); + await expect( + loader.readDicomImage(new File([], 'slice.dcm')) + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('settles queued requests immediately on cancellation or disposal', async () => { + const fetchStarted = defer(); + const pool = new RequestPool( + 1, + (_request, init) => + new Promise((_resolve, reject) => { + fetchStarted.resolve(); + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true } + ); + }) + ); + const first = pool.fetch('https://example.test/first'); + const firstRejected = expect(first).rejects.toMatchObject({ + name: 'AbortError', + }); + await fetchStarted.promise; + const cancellation = new AbortController(); + const second = pool.fetch('https://example.test/second', { + signal: cancellation.signal, + }); + const secondRejected = expect(second).rejects.toMatchObject({ + name: 'AbortError', + }); + cancellation.abort(); + await secondRejected; + pool.dispose(); + await firstRejected; + await expect( + pool.fetch('https://example.test/third') + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + it.each(['source', 'pool'])( + 'cancels the HTTP response body after headers through %s disposal', + async (owner) => { + const controller = new AbortController(); + const pool = new RequestPool( + 1, + async (_request, init) => + new Response( + new ReadableStream({ + start(stream) { + init?.signal?.addEventListener( + 'abort', + () => stream.error(init.signal?.reason), + { once: true } + ); + }, + }) + ) + ); + const response = await pool.fetch('https://example.test/stream', { + signal: controller.signal, + }); + const pending = response.body!.getReader().read(); + const rejected = expect(pending).rejects.toMatchObject({ + name: 'AbortError', + }); + if (owner === 'source') controller.abort(); + else pool.dispose(); + await rejected; + pool.dispose(); + } + ); + it('gives each byte source one live chunk owner and keeps loaders independent', async () => { + const first = createDicomLoader(); + const second = createDicomLoader(); + const source = blobFetcher(blob()); + const chunk = first.createChunk(source); + expect(() => first.createChunk(source)).toThrow('already belongs'); + second.release(chunk); + await chunk.loadMeta(); + expect(getDatasetTag(chunk.metadata, '00100010')).toBe('DOE^JOHN'); + first.release(chunk); + const replacement = first.createChunk(source); + await replacement.loadMeta(); + expect(getDatasetTag(replacement.metadata, '00100010')).toBe('DOE^JOHN'); + first.dispose(); + second.dispose(); + }); +}); + +it.each(['before', 'after'])( + 'settles chunk-state waiting started %s idle disposal', + async (when) => { + const source = blobFetcher(blob()); + const chunk = new Chunk({ + metaLoader: new DicomMetaLoader(source), + dataLoader: new DicomDataLoader(source), + }); + if (when === 'after') chunk.dispose(); + const pending = waitForChunkState(chunk, ChunkState.Loaded); + const rejected = expect(pending).rejects.toMatchObject({ + name: 'AbortError', + }); + chunk.dispose(); + await rejected; + } +); diff --git a/packages/dicom/core/runtime/createDicomLoader.ts b/packages/dicom/core/runtime/createDicomLoader.ts new file mode 100644 index 000000000..c6f3bc97d --- /dev/null +++ b/packages/dicom/core/runtime/createDicomLoader.ts @@ -0,0 +1,114 @@ +import { blobFetcher } from '../streaming/blobFetcher'; +import { CachedStreamFetcher } from '../streaming/cachedStreamFetcher'; +import { Chunk } from '../streaming/chunk'; +import { DicomDataLoader } from '../streaming/dicom/dicomDataLoader'; +import { DicomMetaLoader } from '../streaming/dicom/dicomMetaLoader'; +import { RequestPool } from '../streaming/requestPool'; +import type { Fetcher } from '../streaming/types'; +import { + createMetadataReader, + type MetadataReaderOptions, +} from './metadataReader'; +import { + createDicomDecoder, + type DicomAssets, + type DicomDecoder, +} from './decoder'; + +export type DicomLoaderOptions = MetadataReaderOptions & { + fetch?: typeof fetch; + requestConcurrency?: number; + assets?: DicomAssets; + /** A supplied decoder owns its own resources. Returned pixel arrays belong to the caller. */ + decoder?: DicomDecoder; + imageImportPolicy?: 'complete-header' | 'skip-radiotherapy'; +}; + +/** Owns sources, requests and workers; images borrow chunks and copy decoded pixels. */ +export function createDicomLoader(options: DicomLoaderOptions = {}) { + const requests = new RequestPool(options.requestConcurrency, options.fetch); + const metadata = createMetadataReader(options); + const decoder = + options.assets && !options.decoder + ? createDicomDecoder(options.assets, metadata.read) + : null; + const chunks = new Set(); + const sources = new WeakSet(); + const pending = new Set<(reason: unknown) => void>(); + let disposed = false; + const assertActive = () => { + if (disposed) throw new DOMException('DICOM loader disposed', 'AbortError'); + }; + const createChunk = (source: Blob | string | URL | Fetcher) => { + assertActive(); + let fetcher: Fetcher; + if (source instanceof Blob) fetcher = blobFetcher(source); + else if (typeof source === 'string' || source instanceof URL) + fetcher = new CachedStreamFetcher(source, { fetch: requests.fetch }); + else fetcher = source; + if (sources.has(fetcher)) + throw new Error('A DICOM byte source already belongs to a live chunk'); + sources.add(fetcher); + const chunk = new Chunk({ + metaLoader: new DicomMetaLoader( + fetcher, + metadata.read, + options.imageImportPolicy + ), + dataLoader: new DicomDataLoader(fetcher), + onDispose: () => { + chunks.delete(chunk); + sources.delete(fetcher); + }, + }); + chunks.add(chunk); + return chunk; + }; + const readDicomImage: DicomDecoder = (file, header) => + new Promise((resolve, reject) => { + try { + assertActive(); + } catch (error) { + reject(error); + return; + } + const decode = options.decoder ?? decoder?.decode; + if (!decode) { + reject(new Error('Provide DICOM decoder assets or a decoder')); + return; + } + pending.add(reject); + Promise.resolve() + .then(() => decode(file, header)) + .then((result) => { + assertActive(); + resolve(result); + }, reject) + .catch(reject) + .finally(() => pending.delete(reject)); + }); + const release = (chunk: Chunk) => { + if (chunks.has(chunk)) chunk.dispose(); + }; + const dispose = () => { + if (disposed) return; + disposed = true; + chunks.forEach((chunk) => chunk.dispose()); + chunks.clear(); + requests.dispose(); + metadata.dispose(); + decoder?.dispose(); + pending.forEach((reject) => + reject(new DOMException('DICOM loader disposed', 'AbortError')) + ); + pending.clear(); + }; + return { + createChunk, + readDicomImage, + release, + dispose, + fetch: requests.fetch, + }; +} +export type DicomLoader = ReturnType; diff --git a/packages/dicom/core/runtime/decoder.ts b/packages/dicom/core/runtime/decoder.ts new file mode 100644 index 000000000..31282c7d8 --- /dev/null +++ b/packages/dicom/core/runtime/decoder.ts @@ -0,0 +1,150 @@ +import { + InterfaceTypes, + runPipeline, + type Image, + type TypedArray, +} from 'itk-wasm'; +import type { DicomDataset } from '../metadata/dicomDataset'; +import { assertUniformModalityRescale } from '../metadata/modalityRescale'; +import { createMetadataReader } from './metadataReader'; + +export type DecodedDicomImage = { + size: number[]; + data: Exclude; + imageType: { components: number }; +}; +export type DicomDecoder = ( + file: File, + /** When supplied, this header must describe the same file. */ + metadata?: DicomDataset +) => Promise<{ image: DecodedDicomImage }>; +export type DicomAssets = { pipelineWorkerUrl: string; imageIOUrl: string }; + +function decodedImage(result: Awaited>) { + if (result.returnValue !== 0 || !result.outputs[0]?.data) + throw new Error(result.stderr || 'Could not decode DICOM image'); + const image = result.outputs[1]?.data as Image; + if (!image?.data) throw new Error('No pixel data decoded from DICOM image'); + if ( + image.data instanceof BigInt64Array || + image.data instanceof BigUint64Array + ) + throw new Error('DICOM pixel arrays must contain numbers'); + return { + image: { + size: image.size, + data: image.data, + imageType: { components: image.imageType.components }, + }, + }; +} + +/** Reuses the GDCM image-io pipeline with explicit assets and one owned worker. */ +export function createDicomDecoder( + assets: DicomAssets, + readMetadata?: ReturnType['read'] +) { + const ownedMetadata = readMetadata ? null : createMetadataReader(); + const read = readMetadata ?? ownedMetadata!.read; + let worker: Worker | null = null; + let disposed = false; + let queue = Promise.resolve(); + let rejectActive: ((reason: unknown) => void) | undefined; + const pending = new Set<(reason: unknown) => void>(); + const resetWorker = (reason: unknown) => { + if (worker) { + worker.removeEventListener('error', onError); + worker.removeEventListener('messageerror', onMessageError); + worker.terminate(); + worker = null; + } + rejectActive?.(reason); + }; + const onError = () => resetWorker(new Error('DICOM decoder worker failed')); + const onMessageError = () => + resetWorker(new Error('DICOM decoder worker message failed')); + const assertActive = () => { + if (disposed) + throw new DOMException('DICOM decoder disposed', 'AbortError'); + }; + const decode: DicomDecoder = (file, metadata) => + new Promise((resolve, reject) => { + if (disposed) { + reject(new DOMException('DICOM decoder disposed', 'AbortError')); + return; + } + pending.add(reject); + const operation = queue.then(async () => { + assertActive(); + const data = new Uint8Array(await file.arrayBuffer()); + assertActive(); + const header = metadata ?? (await read(data)); + assertActive(); + assertUniformModalityRescale(header); + if (!worker) { + worker = new Worker(assets.pipelineWorkerUrl, { type: 'module' }); + const ownedWorker = worker; + const addEventListener = worker.addEventListener.bind(worker); + // ITK attaches its message listener asynchronously, after creation. + worker.addEventListener = ( + ...args: Parameters + ) => { + if (worker !== ownedWorker) + throw new DOMException( + 'DICOM decoder worker stopped', + 'AbortError' + ); + addEventListener(...args); + }; + worker.addEventListener('error', onError); + worker.addEventListener('messageerror', onMessageError); + } + const failed = new Promise((_, rejectFailure) => { + rejectActive = rejectFailure; + }); + try { + const result = await Promise.race([ + runPipeline( + 'gdcm-read-image', + [file.name, '0', '1', '--memory-io'], + [ + { type: InterfaceTypes.JsonCompatible }, + { type: InterfaceTypes.Image }, + ], + [ + { + type: InterfaceTypes.BinaryFile, + data: { path: file.name, data }, + }, + ], + { + webWorker: worker, + pipelineWorkerUrl: assets.pipelineWorkerUrl, + pipelineBaseUrl: assets.imageIOUrl, + } + ), + failed, + ]); + assertActive(); + return decodedImage(result); + } finally { + rejectActive = undefined; + } + }); + queue = operation.then( + () => {}, + () => {} + ); + operation.then(resolve, reject).finally(() => pending.delete(reject)); + }); + const dispose = () => { + if (disposed) return; + disposed = true; + const reason = new DOMException('DICOM decoder disposed', 'AbortError'); + resetWorker(reason); + ownedMetadata?.dispose(); + pending.forEach((reject) => reject(reason)); + pending.clear(); + }; + return { decode, dispose }; +} diff --git a/packages/dicom/core/runtime/metadataReader.ts b/packages/dicom/core/runtime/metadataReader.ts new file mode 100644 index 000000000..7b060352d --- /dev/null +++ b/packages/dicom/core/runtime/metadataReader.ts @@ -0,0 +1,80 @@ +import * as Comlink from 'comlink'; +import { readDicomDataset, type DicomDataset } from '../metadata/dicomDataset'; +import type { DicomDatasetWorker } from '../metadata/readDicomDataset.worker'; + +export type MetadataReaderOptions = { + metadataWorkerFactory?: () => Worker; + metadataConcurrency?: number; +}; + +/** Owns parser workers. Input bytes are copied before transfer; the caller keeps its buffer. */ +export function createMetadataReader(options: MetadataReaderOptions = {}) { + const count = options.metadataConcurrency ?? 2; + if (!Number.isInteger(count) || count < 1) + throw new Error('Metadata concurrency must be a positive integer'); + const workers: Array<{ + worker: Worker; + remote: Comlink.Remote; + }> = []; + const pending = new Set<(reason: unknown) => void>(); + let next = 0; + let disposed = false; + const createWorker = + options.metadataWorkerFactory ?? + (() => + new Worker( + new URL('../metadata/readDicomDataset.worker.ts', import.meta.url), + { type: 'module' } + )); + const resetWorkers = (error: unknown) => { + pending.forEach((reject) => reject(error)); + pending.clear(); + workers.forEach(({ remote, worker }) => { + remote[Comlink.releaseProxy](); + worker.terminate(); + }); + workers.length = 0; + }; + const read = async (bytes: Uint8Array): Promise => { + if (disposed) + throw new DOMException('Metadata reader disposed', 'AbortError'); + if (typeof Worker === 'undefined' && !options.metadataWorkerFactory) + return readDicomDataset(bytes); + if (workers.length < count) { + const worker = createWorker(); + const fail = (message: string) => { + if (workers.some((entry) => entry.worker === worker)) + resetWorkers(new Error(message)); + }; + worker.addEventListener( + 'error', + () => fail('DICOM metadata worker failed'), + { once: true } + ); + worker.addEventListener( + 'messageerror', + () => fail('DICOM metadata worker message failed'), + { once: true } + ); + workers.push({ + worker, + remote: Comlink.wrap(worker), + }); + } + const { remote } = workers[next++ % workers.length]; + const owned = bytes.slice(); + return new Promise((resolve, reject) => { + pending.add(reject); + remote + .readDicomDataset(Comlink.transfer(owned, [owned.buffer])) + .then(resolve, reject) + .finally(() => pending.delete(reject)); + }); + }; + const dispose = () => { + if (disposed) return; + disposed = true; + resetWorkers(new DOMException('Metadata reader disposed', 'AbortError')); + }; + return { read, dispose }; +} diff --git a/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts b/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts new file mode 100644 index 000000000..f727e6c41 --- /dev/null +++ b/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { blobFetcher } from '../blobFetcher'; +import { StopSignal } from '../cachedStreamFetcher'; +import { concatBytes } from '../../utils'; + +const KIB = 1024; + +const bytesOf = (length: number) => + Uint8Array.from({ length }, (_, index) => index % 251); + +const drain = async ( + reader: ReadableStreamDefaultReader, + into: Uint8Array[] = [] +) => { + for (;;) { + const { done, value } = await reader.read(); + if (done) return into; + into.push(value); + } +}; + +const readAll = (stream: ReadableStream) => + drain(stream.getReader()); + +const lengthOf = (chunks: Uint8Array[]) => + chunks.reduce((total, chunk) => total + chunk.length, 0); + +describe('blobFetcher', () => { + it('streams the blob from its first byte in slices', async () => { + const bytes = bytesOf(200 * KIB); + const fetcher = blobFetcher(new Blob([bytes])); + await fetcher.connect(); + + const chunks = await readAll(fetcher.getStream()); + + expect(chunks.length).toBeGreaterThan(1); + expect(concatBytes(chunks)).toEqual(bytes); + }); + + // A meta load cut short and started again must not see the first stream's + // bytes ahead of its own. + it('starts every stream at the first byte', async () => { + const bytes = bytesOf(200 * KIB); + const fetcher = blobFetcher(new Blob([bytes])); + await fetcher.connect(); + + const first = fetcher.getStream().getReader(); + await first.read(); + const second = await readAll(fetcher.getStream()); + + expect(concatBytes(second)).toEqual(bytes); + }); + + it('ends a stream in flight once closed', async () => { + const bytes = bytesOf(400 * KIB); + const fetcher = blobFetcher(new Blob([bytes])); + await fetcher.connect(); + const reader = fetcher.getStream().getReader(); + const delivered = [(await reader.read()).value!]; + + fetcher.close(); + + await expect(drain(reader, delivered)).rejects.toBe(StopSignal); + expect(lengthOf(delivered)).toBeLessThan(bytes.length); + expect(fetcher.connected).toBe(false); + }); + + it('does not revive a closed stream when reconnected', async () => { + const fetcher = blobFetcher(new Blob([bytesOf(400 * KIB)])); + await fetcher.connect(); + const reader = fetcher.getStream().getReader(); + + fetcher.close(); + await fetcher.connect(); + + await expect(drain(reader)).rejects.toBe(StopSignal); + expect(concatBytes(await readAll(fetcher.getStream()))).toHaveLength( + 400 * KIB + ); + }); + + it('closes a stream opened before connecting', async () => { + const fetcher = blobFetcher(new Blob([bytesOf(10)])); + + expect(await readAll(fetcher.getStream())).toEqual([]); + }); + + it('hands out the blob itself as the whole data', async () => { + const blob = new Blob([bytesOf(10)]); + + expect(await blobFetcher(blob).blob()).toBe(blob); + }); + + it('keeps no copy of what it streamed', async () => { + const fetcher = blobFetcher(new Blob([bytesOf(10)])); + await fetcher.connect(); + await readAll(fetcher.getStream()); + + expect(fetcher.cachedChunks).toBeUndefined(); + }); +}); diff --git a/src/core/streaming/__tests__/byteDeque.spec.ts b/packages/dicom/core/streaming/__tests__/byteDeque.spec.ts similarity index 97% rename from src/core/streaming/__tests__/byteDeque.spec.ts rename to packages/dicom/core/streaming/__tests__/byteDeque.spec.ts index 70d8777a1..c32e8a708 100644 --- a/src/core/streaming/__tests__/byteDeque.spec.ts +++ b/packages/dicom/core/streaming/__tests__/byteDeque.spec.ts @@ -1,4 +1,4 @@ -import ByteDeque from '@/src/core/streaming/byteDeque'; +import ByteDeque from '../byteDeque'; import { describe, it, expect } from 'vitest'; describe('ByteDeque', () => { diff --git a/src/core/streaming/__tests__/cachedStreamFetcher.spec.ts b/packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts similarity index 84% rename from src/core/streaming/__tests__/cachedStreamFetcher.spec.ts rename to packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts index d7eb46216..1f1a5d0c8 100644 --- a/src/core/streaming/__tests__/cachedStreamFetcher.spec.ts +++ b/packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts @@ -1,8 +1,5 @@ -import { RequestPool } from '@/src/core/streaming/requestPool'; -import { - CachedStreamFetcher, - sliceChunks, -} from '@/src/core/streaming/cachedStreamFetcher'; +import { RequestPool } from '../requestPool'; +import { CachedStreamFetcher, sliceChunks } from '../cachedStreamFetcher'; import { describe, expect, it } from 'vitest'; const readStream = async ( @@ -118,3 +115,17 @@ describe('sliceChunks', () => { ).toEqual([new Uint8Array([1, 2]), new Uint8Array([3])]); }); }); + +it('rejects an unverified resumed range while preserving the cached header', async () => { + const prefix = new Uint8Array([1, 2, 3, 4]); + const fetcher = new CachedStreamFetcher( + 'https://example.test/hidden-headers', + { + prefixChunks: [prefix], + fetch: async () => new Response(new Uint8Array([5, 6]), { status: 206 }), + } + ); + await expect(fetcher.connect()).rejects.toThrow('readable content range'); + expect(fetcher.cachedChunks).toEqual([prefix]); + expect(fetcher.connected).toBe(false); +}); diff --git a/src/core/streaming/__tests__/chunkStateMachine.spec.ts b/packages/dicom/core/streaming/__tests__/chunkStateMachine.spec.ts similarity index 95% rename from src/core/streaming/__tests__/chunkStateMachine.spec.ts rename to packages/dicom/core/streaming/__tests__/chunkStateMachine.spec.ts index 4933fd158..706493400 100644 --- a/src/core/streaming/__tests__/chunkStateMachine.spec.ts +++ b/packages/dicom/core/streaming/__tests__/chunkStateMachine.spec.ts @@ -2,7 +2,7 @@ import { ChunkState, ChunkStateMachine, TransitionEvent, -} from '@/src/core/streaming/chunkStateMachine'; +} from '../chunkStateMachine'; import { describe, expect, it } from 'vitest'; describe('chunk', () => { diff --git a/src/core/streaming/__tests__/requestPool.spec.ts b/packages/dicom/core/streaming/__tests__/requestPool.spec.ts similarity index 69% rename from src/core/streaming/__tests__/requestPool.spec.ts rename to packages/dicom/core/streaming/__tests__/requestPool.spec.ts index 3cc846db9..de25f67b0 100644 --- a/src/core/streaming/__tests__/requestPool.spec.ts +++ b/packages/dicom/core/streaming/__tests__/requestPool.spec.ts @@ -1,4 +1,4 @@ -import { RequestPool } from '@/src/core/streaming/requestPool'; +import { RequestPool } from '../requestPool'; import { describe, it, expect } from 'vitest'; const neverResolvingFetch = async () => { @@ -55,3 +55,21 @@ describe('requestPool', () => { } }); }); + +it('imports a remote resource without AbortSignal.any', async () => { + const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, 'any'); + Object.defineProperty(AbortSignal, 'any', { + configurable: true, + value: undefined, + }); + const pool = new RequestPool(1, async () => new Response('resource')); + try { + expect( + await (await pool.fetch('https://example.test/resource')).text() + ).toBe('resource'); + } finally { + pool.dispose(); + if (descriptor) Object.defineProperty(AbortSignal, 'any', descriptor); + else Reflect.deleteProperty(AbortSignal, 'any'); + } +}); diff --git a/src/core/streaming/__tests__/streamingByteReader.spec.ts b/packages/dicom/core/streaming/__tests__/streamingByteReader.spec.ts similarity index 95% rename from src/core/streaming/__tests__/streamingByteReader.spec.ts rename to packages/dicom/core/streaming/__tests__/streamingByteReader.spec.ts index cbd4e5dd5..cc720f925 100644 --- a/src/core/streaming/__tests__/streamingByteReader.spec.ts +++ b/packages/dicom/core/streaming/__tests__/streamingByteReader.spec.ts @@ -1,4 +1,4 @@ -import StreamingByteReader from '@/src/core/streaming/streamingByteReader'; +import StreamingByteReader from '../streamingByteReader'; import { asCoroutine } from '@/src/utils'; import { describe, it, expect } from 'vitest'; diff --git a/packages/dicom/core/streaming/blobFetcher.ts b/packages/dicom/core/streaming/blobFetcher.ts new file mode 100644 index 000000000..d588b1184 --- /dev/null +++ b/packages/dicom/core/streaming/blobFetcher.ts @@ -0,0 +1,67 @@ +import { StopSignal } from './cachedStreamFetcher'; +import { Fetcher } from './types'; + +/** Read enough per pull that a header rarely needs a second slice. */ +const CHUNK_SIZE = 64 * 1024; + +/** + * A Fetcher over bytes that are already addressable, so a local file and a + * remote URI reach the same loaders. Slicing rather than reading the whole + * blob keeps a header read off the rest of the file. + * + * The blob is its own cache: every stream starts at its first byte and keeps + * nothing, so a header the meta loader is done with is not pinned for the + * chunk's lifetime, and a stream cut short leaves nothing behind for the next + * one to trip over. Closing ends a stream in flight the way a cancelled + * download ends a network stream. + */ +export const blobFetcher = (blob: Blob): Fetcher => { + let abortController: AbortController | null = null; + + return { + connect: async () => { + abortController ??= new AbortController(); + }, + getStream: () => { + // The stream belongs to the connection it was opened on: a close then + // a reconnect must not revive it. + const connection = abortController; + let offset = 0; + return new ReadableStream({ + pull: async (controller) => { + if (!connection) { + controller.close(); + return; + } + if (connection.signal.aborted) { + controller.error(StopSignal); + return; + } + if (offset >= blob.size) { + controller.close(); + return; + } + const slice = blob.slice(offset, offset + CHUNK_SIZE); + const chunk = new Uint8Array(await slice.arrayBuffer()); + offset += chunk.length; + controller.enqueue(chunk); + }, + }); + }, + blob: async () => blob, + close: () => { + abortController?.abort(StopSignal); + abortController = null; + }, + get connected() { + return !!abortController; + }, + get size() { + return blob.size; + }, + contentType: blob.type, + get abortSignal() { + return abortController?.signal; + }, + }; +}; diff --git a/src/core/streaming/byteDeque.ts b/packages/dicom/core/streaming/byteDeque.ts similarity index 100% rename from src/core/streaming/byteDeque.ts rename to packages/dicom/core/streaming/byteDeque.ts diff --git a/src/core/streaming/cachedStreamFetcher.ts b/packages/dicom/core/streaming/cachedStreamFetcher.ts similarity index 81% rename from src/core/streaming/cachedStreamFetcher.ts rename to packages/dicom/core/streaming/cachedStreamFetcher.ts index 7265ed2d0..ee9be490a 100644 --- a/src/core/streaming/cachedStreamFetcher.ts +++ b/packages/dicom/core/streaming/cachedStreamFetcher.ts @@ -3,21 +3,21 @@ import { HTTP_STATUS_PARTIAL_CONTENT, HTTP_STATUS_REQUESTED_RANGE_NOT_SATISFIABLE, HttpNotFound, -} from '@/src/core/streaming/httpCodes'; -import { Fetcher, FetcherInit } from '@/src/core/streaming/types'; +} from './httpCodes'; +import { Fetcher, FetcherInit } from './types'; import { ContentRange, parseContentRangeHeader, -} from '@/src/utils/parseContentRangeHeader'; -import { Maybe } from '@/src/types'; +} from '../utils/parseContentRangeHeader'; +import { Maybe } from '../utils'; type FetchFunction = typeof fetch; -export interface CachedStreamFetcherRequestInit extends RequestInit { +export type CachedStreamFetcherRequestInit = RequestInit & { prefixChunks?: Uint8Array[]; contentLength?: number; fetch?: FetchFunction; -} +}; export const StopSignal = Symbol('StopSignal'); @@ -87,7 +87,9 @@ export class CachedStreamFetcher implements Fetcher { ) { this.chunks = [...(init?.prefixChunks ?? [])] as Uint8Array[]; this.contentLength = init?.contentLength ?? null; - this.fetch = init?.fetch ?? globalThis.fetch; + const fetchFn = + init?.fetch ?? ((input, options) => globalThis.fetch(input, options)); + this.fetch = (input, options) => fetchFn(input, options); } get connected() { @@ -110,6 +112,7 @@ export class CachedStreamFetcher implements Fetcher { if (this.connected) return; this.abortController = init?.abortController ?? new AbortController(); + const connection = this.abortController; this.abortController.signal.addEventListener('abort', () => { this.cleanupAfterAbort(); }); @@ -130,6 +133,7 @@ export class CachedStreamFetcher implements Fetcher { signal: this.abortController.signal, }); + this.validateConnection(connection, response); this.contentType = response.headers.get('content-type') ?? ''; this.contentDisposition = response.headers.get('content-disposition') ?? ''; @@ -172,17 +176,7 @@ export class CachedStreamFetcher implements Fetcher { } if (response.status === HTTP_STATUS_PARTIAL_CONTENT) { - if (contentRange.type === 'invalid-range') - throw new Error('Invalid content-range header'); - if (contentRange.type === 'unsatisfied-range') - throw new Error('Range could not be satisfied'); - - let start: number = 0; - if (contentRange.type === 'range') { - start = contentRange.start; - } - - this.chunks = sliceChunks(this.chunks, start); + this.resumeChunks(response, contentRange); } else if (!noMoreContent) { this.chunks = []; } @@ -191,6 +185,29 @@ export class CachedStreamFetcher implements Fetcher { this.activeNetworkStreamReader = this.activeNetworkStream.getReader(); } + private resumeChunks(response: Response, contentRange: ContentRange) { + if (contentRange.type === 'invalid-range') + throw new Error('Invalid content-range header'); + if (contentRange.type === 'unsatisfied-range') + throw new Error('Range could not be satisfied'); + if (contentRange.type === 'empty-range' && this.size > 0) { + response.body?.cancel().catch(() => {}); + this.close(); + throw new Error( + 'Cannot resume a DICOM download without a readable content range' + ); + } + const start = contentRange.type === 'range' ? contentRange.start : 0; + this.chunks = sliceChunks(this.chunks, start); + } + + private validateConnection(connection: AbortController, response: Response) { + if (connection.signal.aborted || connection !== this.abortController) { + response.body?.cancel().catch(() => {}); + throw new DOMException('DICOM request cancelled', 'AbortError'); + } + } + close() { if (!this.abortController) return; this.abortController.abort(StopSignal); @@ -203,6 +220,7 @@ export class CachedStreamFetcher implements Fetcher { pull: async (controller) => { if (!this.connected) { controller.close(); + return; } if (chunkIndex > this.chunks.length) @@ -232,9 +250,11 @@ export class CachedStreamFetcher implements Fetcher { }); if (!this.promise) { + const connection = this.abortController; this.promise = this.activeNetworkStreamReader .read() .then((result) => { + if (connection !== this.abortController) throw StopSignal; if (!result.done) { this.chunks.push(result.value as Uint8Array); } else if (this.contentLength === null) { @@ -244,7 +264,7 @@ export class CachedStreamFetcher implements Fetcher { return result; }) .finally(() => { - this.promise = null; + if (connection === this.abortController) this.promise = null; }); } return this.promise; @@ -260,6 +280,7 @@ export class CachedStreamFetcher implements Fetcher { } private cleanupAfterAbort() { + this.promise = null; this.activeNetworkStreamReader = null; this.activeNetworkStream = null; this.abortController = null; diff --git a/packages/dicom/core/streaming/chunk.ts b/packages/dicom/core/streaming/chunk.ts new file mode 100644 index 000000000..adec692d6 --- /dev/null +++ b/packages/dicom/core/streaming/chunk.ts @@ -0,0 +1,215 @@ +import { UpdateEvent, enters, leaves } from './stateMachine'; +import { + ChunkState, + ChunkStateMachine, + TransitionEvent, +} from './chunkStateMachine'; +import { DataLoader, MetaLoader } from './types'; +import mitt, { Emitter } from 'mitt'; + +type ChunkEvents = { + doneMeta: void; + doneData: void; + error: unknown; + disposed: void; +}; +type ChunkInit = { + metaLoader: MetaLoader; + dataLoader: DataLoader; + onDispose?: () => void; +}; +const cancelled = () => + new DOMException('DICOM loading cancelled', 'AbortError'); + +/** A source instance owns its bytes until disposal; its immutable metadata remains inspectable afterwards. */ +export class Chunk { + private machine = new ChunkStateMachine(); + private metaLoader: MetaLoader; + private dataLoader: DataLoader; + private events: Emitter = mitt(); + private userData = new Map(); + private generation = 0; + private disposed = false; + private onDispose?: () => void; + private pendingMeta: Promise | null = null; + private pendingData: Promise | null = null; + + constructor(init: ChunkInit) { + this.metaLoader = init.metaLoader; + this.dataLoader = init.dataLoader; + this.onDispose = init.onDispose; + this.machine.subscribe(this.onStateUpdated); + } + + getUserData(key: string) { + return this.userData.get(key); + } + + setUserData(key: string, value: unknown) { + return this.userData.set(key, value); + } + + addEventListener(event: keyof ChunkEvents, callback: (arg?: any) => void) { + this.events.on(event, callback); + return () => this.removeEventListener(event, callback); + } + + removeEventListener(event: keyof ChunkEvents, callback: (arg?: any) => void) { + this.events.off(event, callback); + } + + watchForState(state: ChunkState, callback: () => void) { + return this.machine.subscribe((event) => { + if (state === event.state) callback(); + }); + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.stopLoad(); + this.events.emit('disposed'); + this.machine.unsubscribe(this.onStateUpdated); + this.events.all.clear(); + this.userData.clear(); + this.metaLoader.dispose?.(); + this.dataLoader.dispose?.(); + this.onDispose?.(); + this.onDispose = undefined; + } + + get isDisposed() { + return this.disposed; + } + + get state() { + return this.machine.state; + } + + get metadata() { + return this.metaLoader.meta; + } + + get fileLayout() { + return this.metaLoader.fileLayout; + } + + get ultrasoundRegions() { + return this.metaLoader.ultrasoundRegions; + } + + get dataBlob() { + return this.dataLoader.data; + } + + loadMeta() { + if (this.disposed) return Promise.reject(cancelled()); + if (this.pendingMeta) return this.pendingMeta; + if (this.state !== ChunkState.Init) return Promise.resolve(); + const pending = this.load('doneMeta', TransitionEvent.LoadMeta).finally( + () => { + if (this.pendingMeta === pending) this.pendingMeta = null; + } + ); + this.pendingMeta = pending; + return pending; + } + + stopLoad() { + this.generation += 1; + this.machine.send(TransitionEvent.Cancel, { error: cancelled() }); + this.pendingMeta = null; + this.pendingData = null; + } + + loadData() { + if (this.disposed) return Promise.reject(cancelled()); + if (this.pendingData) return this.pendingData; + if (this.state !== ChunkState.MetaOnly) return Promise.resolve(); + const pending = this.load('doneData', TransitionEvent.LoadData).finally( + () => { + if (this.pendingData === pending) this.pendingData = null; + } + ); + this.pendingData = pending; + return pending; + } + + private load(done: 'doneMeta' | 'doneData', transition: TransitionEvent) { + return new Promise((resolve, reject) => { + const cleanup = () => { + this.events.off(done, onDone); + this.events.off('error', onError); + }; + const onDone = () => { + cleanup(); + resolve(); + }; + const onError = (error: unknown) => { + cleanup(); + reject(error); + }; + this.events.on(done, onDone); + this.events.on('error', onError); + this.machine.send(transition); + }); + } + + private onStateUpdated = async ( + event: UpdateEvent + ) => { + const generation = this.generation; + const complete = (transition: TransitionEvent, data?: unknown) => { + if (generation === this.generation && !this.disposed) + this.machine.send(transition, data); + }; + if (leaves(event, ChunkState.MetaLoading)) this.metaLoader.stop(); + else if (leaves(event, ChunkState.DataLoading)) this.dataLoader.stop(); + if (event.data?.error) this.events.emit('error', event.data.error); + if (event.event === TransitionEvent.MetaLoaded) + this.events.emit('doneMeta'); + else if (event.event === TransitionEvent.DataLoaded) + this.events.emit('doneData'); + try { + if (enters(event, ChunkState.MetaLoading)) { + await this.metaLoader.load(); + complete(TransitionEvent.MetaLoaded); + } else if (enters(event, ChunkState.DataLoading)) { + await this.dataLoader.load(); + complete(TransitionEvent.DataLoaded); + } + } catch (error) { + complete(TransitionEvent.Cancel, { error }); + } + }; +} + +export function waitForChunkState(chunk: Chunk, state: ChunkState) { + return new Promise((resolve, reject) => { + if (chunk.isDisposed) { + reject(cancelled()); + return; + } + if (chunk.state === state) { + resolve(chunk); + return; + } + const cleanup = () => { + stop(); + stopError(); + stopDisposed(); + }; + const stop = chunk.watchForState(state, () => { + cleanup(); + resolve(chunk); + }); + const stopError = chunk.addEventListener('error', (error) => { + cleanup(); + reject(error); + }); + const stopDisposed = chunk.addEventListener('disposed', () => { + cleanup(); + reject(cancelled()); + }); + }); +} diff --git a/src/core/streaming/chunkStateMachine.ts b/packages/dicom/core/streaming/chunkStateMachine.ts similarity index 94% rename from src/core/streaming/chunkStateMachine.ts rename to packages/dicom/core/streaming/chunkStateMachine.ts index 638f0292f..041c24cce 100644 --- a/src/core/streaming/chunkStateMachine.ts +++ b/packages/dicom/core/streaming/chunkStateMachine.ts @@ -1,4 +1,4 @@ -import StateMachine from '@/src/core/stateMachine'; +import StateMachine from './stateMachine'; export enum ChunkState { Init = 'Init', diff --git a/src/core/streaming/concatStreams.ts b/packages/dicom/core/streaming/concatStreams.ts similarity index 100% rename from src/core/streaming/concatStreams.ts rename to packages/dicom/core/streaming/concatStreams.ts diff --git a/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts new file mode 100644 index 000000000..9bca4eb76 --- /dev/null +++ b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts @@ -0,0 +1,28 @@ +import { DicomMetaLoader } from '../dicomMetaLoader'; +import { RequestPool } from '../../requestPool'; +import { CachedStreamFetcher } from '../../cachedStreamFetcher'; +import { describe, it, expect } from 'vitest'; +import { syntheticSlice, bytesFetcher } from '@/tests/dicomSourceFixtures'; +import { getDatasetTag } from '../../../metadata/datasetAccess'; + +describe('dicomMetaLoader', () => { + it('loads only the header from an HTTP response', async () => { + const bytes = syntheticSlice({ patientName: 'DOE^JOHN' }); + const source = bytesFetcher(bytes, 64); + const pool = new RequestPool(1, async () => { + await source.connect(); + return new Response(source.getStream(), { + headers: { 'content-length': String(bytes.length) }, + }); + }); + const fetcher = new CachedStreamFetcher('https://example.test/slice.dcm', { + fetch: pool.fetch, + }); + const loader = new DicomMetaLoader(fetcher); + await loader.load(); + expect(getDatasetTag(loader.meta, '00100010')).toBe('DOE^JOHN'); + expect(fetcher.size).toBeLessThan(bytes.length); + loader.dispose(); + pool.dispose(); + }); +}); diff --git a/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts new file mode 100644 index 000000000..8646c7155 --- /dev/null +++ b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest'; +import { DicomMetaLoader } from '../dicomMetaLoader'; +import { StopSignal } from '../../cachedStreamFetcher'; +import type { Fetcher } from '../../types'; +import { Tags } from '../../../metadata/dicomTags'; +import { readDicomDataset } from '../../../metadata/dicomDataset'; +import { getDatasetTag } from '../../../metadata/datasetAccess'; +import { + EXPLICIT_VR_LITTLE_ENDIAN, + IMPLICIT_VR_LITTLE_ENDIAN, +} from '../../../metadata/dicomLayout'; +import { + rawElement, + sequenceItem, + stripFileMeta, + stripPreamble, + toImplicitVr, + undefinedLengthItem, + undefinedLengthUnknown, +} from '@/tests/specs/syntheticDicom'; +import { + bytesFetcher, + countingFetcher, + deliveredBytes, + pixelDataStart, + syntheticSlice, + withoutPixelData, +} from '@/tests/dicomSourceFixtures'; + +const REGION = { physicalDeltaX: 0.0625, physicalDeltaY: 0.125 }; + +/** A source that delivers `after` bytes and then stops, as a cancel does. */ +const stoppingFetcher = (bytes: Uint8Array, after: number): Fetcher => ({ + ...bytesFetcher(bytes), + getStream: () => { + let delivered = false; + return new ReadableStream({ + pull: (controller) => { + if (delivered) { + controller.error(StopSignal); + return; + } + delivered = true; + controller.enqueue(bytes.slice(0, after)); + }, + }); + }, +}); + +describe('DicomMetaLoader over a byte source', () => { + it.each([ + { name: 'defined-length item', item: sequenceItem }, + { name: 'undefined-length item', item: undefinedLengthItem }, + ])( + 'reads an undefined-length UN with $name across stream boundaries', + async ({ item }) => { + // A nonempty item catches treating the UN contents as Explicit VR. + // The value also contains delimiter-like bytes that must stay data. + const payload = new Uint8Array([ + 0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0, 0xfe, 0xff, 0x0d, 0xe0, 0, 0, 0, 0, + ]); + const content = toImplicitVr(rawElement(0x0043, 0x1030, 'OB', payload)); + const bytes = syntheticSlice({ + extraElements: [ + rawElement(0x0043, 0x0010, 'LO', new TextEncoder().encode('TEST')), + undefinedLengthUnknown(0x0043, 0x1029, item(content)), + ], + }); + const loader = new DicomMetaLoader(bytesFetcher(bytes, 7)); + + await loader.load(); + + expect( + readDicomDataset(bytes).elements.find((el) => el.tag === '00431029') + ?.values + ).toMatchObject({ + kind: 'sequence', + items: [ + { + elements: [ + { tag: '00431030', values: { kind: 'bytes', values: [payload] } }, + ], + }, + ], + }); + expect(loader.meta).toEqual(readDicomDataset(bytes)); + expect(loader.pixelDataOffset).toBe(pixelDataStart(bytes)); + } + ); + + it('reads the file tags with the JS reader by default', async () => { + const bytes = syntheticSlice({ patientName: 'DOE^JOHN', modality: 'CT' }); + const loader = new DicomMetaLoader(bytesFetcher(bytes)); + + await loader.load(); + + expect(loader.meta).toEqual(readDicomDataset(bytes)); + }); + + it('stops at Pixel Data', async () => { + const bytes = syntheticSlice(); + const loader = new DicomMetaLoader(bytesFetcher(bytes)); + + await loader.load(); + + expect(loader.pixelDataOffset).toBe(pixelDataStart(bytes)); + expect(loader.fileLayout).toEqual({ preamble: true, fileMeta: true }); + }); + + it('reads a source without a preamble as the Part 10 file it came from', async () => { + const bytes = syntheticSlice({ patientName: 'DOE^JOHN' }); + const loader = new DicomMetaLoader(bytesFetcher(stripPreamble(bytes), 64)); + + await loader.load(); + + expect(loader.meta).toEqual(readDicomDataset(bytes)); + expect(loader.fileLayout).toEqual({ preamble: false, fileMeta: true }); + }); + + it.each([ + ['explicit', false, EXPLICIT_VR_LITTLE_ENDIAN], + ['implicit', true, IMPLICIT_VR_LITTLE_ENDIAN], + ])( + 'reads a bare %s VR data set with its transfer syntax assumed', + async (_kind, implicitVr, transferSyntaxUid) => { + const bytes = syntheticSlice({ patientName: 'DOE^JOHN', implicitVr }); + const loader = new DicomMetaLoader( + bytesFetcher(stripFileMeta(bytes), 64) + ); + + await loader.load(); + + expect(loader.meta).toEqual(readDicomDataset(bytes)); + expect(loader.fileLayout).toEqual({ + preamble: false, + fileMeta: false, + transferSyntaxUid, + }); + } + ); + + it('reads nothing as a header when stopped before the header ends', async () => { + const bytes = syntheticSlice(); + const seen: Uint8Array[] = []; + const loader = new DicomMetaLoader(stoppingFetcher(bytes, 64), (header) => { + seen.push(header); + return { elements: [], specificCharacterSet: null }; + }); + + await expect(loader.load()).rejects.toThrow(/stopped/); + + expect(seen).toEqual([]); + expect(loader.meta).toBeUndefined(); + }); + + it('leaves the byte source undelivered past Pixel Data', async () => { + const bytes = syntheticSlice(); + const fetcher = bytesFetcher(bytes, 64); + const loader = new DicomMetaLoader(fetcher); + + await loader.load(); + + expect(loader.meta).toEqual(readDicomDataset(bytes)); + expect(deliveredBytes(fetcher)).toBeLessThan(bytes.length); + }); + + it('hands the reader the header bytes, once', async () => { + const bytes = syntheticSlice(); + const seen: Uint8Array[] = []; + const loader = new DicomMetaLoader(bytesFetcher(bytes), (headerBytes) => { + seen.push(headerBytes); + return { elements: [], specificCharacterSet: null }; + }); + + await loader.load(); + await loader.load(); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBeInstanceOf(Uint8Array); + expect(seen[0]).toEqual(bytes.slice(0, pixelDataStart(bytes))); + }); + + it('takes tags, offset and ultrasound regions from one pass', async () => { + const bytes = syntheticSlice({ modality: 'US', ultrasoundRegion: REGION }); + const { fetcher, counts } = countingFetcher(bytesFetcher(bytes)); + const loader = new DicomMetaLoader(fetcher); + + await loader.load(); + + expect(loader.meta).toEqual(readDicomDataset(bytes)); + expect(loader.pixelDataOffset).toBe(pixelDataStart(bytes)); + expect(loader.ultrasoundRegions?.regionCount).toBe(1); + expect(loader.ultrasoundRegions?.region?.physicalDeltaX).toBeCloseTo( + REGION.physicalDeltaX + ); + expect(loader.ultrasoundRegions?.region?.physicalDeltaY).toBeCloseTo( + REGION.physicalDeltaY + ); + // One stream, and never the whole blob: the regions cost no second read. + expect(counts.getStream).toBe(1); + expect(counts.blob).toBe(0); + }); + + it('reports no ultrasound regions for a non-ultrasound modality', async () => { + const bytes = syntheticSlice({ modality: 'MR', ultrasoundRegion: REGION }); + const loader = new DicomMetaLoader(bytesFetcher(bytes)); + + await loader.load(); + + expect(loader.ultrasoundRegions).toBeUndefined(); + }); + + it('reads every tag of an object that has no Pixel Data', async () => { + const bytes = withoutPixelData(syntheticSlice({ modality: 'SR' })); + const loader = new DicomMetaLoader(bytesFetcher(bytes)); + + await loader.load(); + + expect(loader.meta).toEqual(readDicomDataset(bytes)); + expect(loader.meta!.elements.length).toBeGreaterThan(1); + expect(loader.pixelDataOffset).toBe(bytes.length); + }); + + // A structure set is megabytes of contours the importer drops unread, so + // the loader stops at the modality that says so. + it('reads a radiotherapy object no further than its modality', async () => { + const bytes = withoutPixelData(syntheticSlice({ modality: 'RTSTRUCT' })); + const fetcher = bytesFetcher(bytes, 32); + const seen: Uint8Array[] = []; + const loader = new DicomMetaLoader( + fetcher, + (header) => { + seen.push(header); + return readDicomDataset(header); + }, + 'skip-radiotherapy' + ); + + await loader.load(); + + expect(getDatasetTag(loader.meta, Tags.Modality)?.trim()).toBe('RTSTRUCT'); + expect(seen).toHaveLength(1); + expect(deliveredBytes(fetcher)).toBeLessThan(bytes.length); + expect(loader.pixelDataOffset).toBeUndefined(); + }); + + it('closes the byte source once the header is read', async () => { + const { fetcher, counts } = countingFetcher(bytesFetcher(syntheticSlice())); + const loader = new DicomMetaLoader(fetcher); + + await loader.load(); + + expect(counts.close).toBeGreaterThanOrEqual(1); + }); + + it('closes the byte source on stop', async () => { + const { fetcher, counts } = countingFetcher(bytesFetcher(syntheticSlice())); + const loader = new DicomMetaLoader(fetcher); + + await loader.stop(); + + expect(counts.close).toBeGreaterThanOrEqual(1); + }); + + it('rejects a byte source that is not DICOM', async () => { + const loader = new DicomMetaLoader(bytesFetcher(new Uint8Array(512))); + + await expect(loader.load()).rejects.toThrow(); + }); +}); diff --git a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts b/packages/dicom/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts similarity index 91% rename from src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts rename to packages/dicom/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts index 8b4968529..17147d006 100644 --- a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts +++ b/packages/dicom/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -1,11 +1,12 @@ +import { rawElement as shortVRElement } from '@/tests/specs/syntheticDicom'; import { describe, it, expect } from 'vitest'; -import type { DataElement } from '@/src/core/streaming/dicom/dicomParser'; +import type { DataElement } from '../dicomParser'; import { decodeUltrasoundRegion, parseUltrasoundRegionFromBlob, unitToMm, US_UNIT_CENTIMETERS, -} from '@/src/core/streaming/dicom/ultrasoundRegion'; +} from '../ultrasoundRegion'; const u16LE = (v: number) => { const b = new Uint8Array(2); @@ -30,23 +31,6 @@ const concat = (parts: Uint8Array[]) => { return out; }; -// Builds an explicit-VR-LE data element for a VR with 2-byte length (US/FD/UI/etc). -const shortVRElement = ( - group: number, - element: number, - vr: string, - value: Uint8Array -) => { - const header = new Uint8Array(8); - const dv = new DataView(header.buffer); - dv.setUint16(0, group, true); - dv.setUint16(2, element, true); - header[4] = vr.charCodeAt(0); - header[5] = vr.charCodeAt(1); - dv.setUint16(6, value.length, true); - return concat([header, value]); -}; - type Item = { group: number; element: number; vr: string; value: Uint8Array }; // Fake parsed sequence data mirroring what readSequenceValue emits. diff --git a/packages/dicom/core/streaming/dicom/dicomDataLoader.ts b/packages/dicom/core/streaming/dicom/dicomDataLoader.ts new file mode 100644 index 000000000..64c32e1a4 --- /dev/null +++ b/packages/dicom/core/streaming/dicom/dicomDataLoader.ts @@ -0,0 +1,33 @@ +import { DataLoader, Fetcher } from '../types'; +import { Maybe } from '../../utils'; + +export class DicomDataLoader implements DataLoader { + public data: Maybe; + private fetcher: Fetcher | null; + private generation = 0; + + constructor(fetcher: Fetcher) { + this.fetcher = fetcher; + } + + async load() { + const generation = this.generation; + if (!this.fetcher) + throw new DOMException('DICOM data loader disposed', 'AbortError'); + const data = await this.fetcher.blob(); + if (generation !== this.generation) + throw new DOMException('DICOM read cancelled', 'AbortError'); + this.data = data; + } + + dispose() { + this.stop(); + this.data = undefined; + this.fetcher = null; + } + + stop() { + this.generation += 1; + this.fetcher?.close(); + } +} diff --git a/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts b/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts new file mode 100644 index 000000000..bc59c3b29 --- /dev/null +++ b/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts @@ -0,0 +1,172 @@ +import { createDicomParser } from './dicomParser'; +import { StopSignal } from '../cachedStreamFetcher'; +import { Fetcher, MetaLoader } from '../types'; +import { Maybe } from '../../utils'; +import { Awaitable } from '../../utils'; + +import { + readDicomDataset, + type DicomDataset, +} from '../../metadata/dicomDataset'; +import type { DicomLayout } from '../../metadata/dicomLayout'; +import { concatBytes, toAscii } from '../../utils'; +import { readUltrasoundRegions, UltrasoundRegions } from './ultrasoundRegion'; + +export type ReadDicomDatasetFunction = ( + bytes: Uint8Array +) => Awaitable; + +const MODALITY = [0x0008, 0x0060] as const; +const PIXEL_DATA = [0x7fe0, 0x0010] as const; + +const isTag = ( + tag: readonly [number, number], + group: number, + element: number +) => group === tag[0] && element === tag[1]; + +/** + * Radiotherapy objects are dropped on their modality alone, so nothing past + * that element is read: a structure set carries megabytes of contours. + */ +const isRadiotherapy = (modality: string | undefined) => + modality?.startsWith('RT') ?? false; + +export class DicomMetaLoader implements MetaLoader { + private tags: Maybe; + private generation = 0; + private source: Fetcher | null; + private offset: Maybe; + private layout: Maybe; + public ultrasoundRegions: UltrasoundRegions | undefined; + + constructor( + fetcher: Fetcher, + private readDataset: ReadDicomDatasetFunction = readDicomDataset, + private imageImportPolicy: + | 'complete-header' + | 'skip-radiotherapy' = 'complete-header' + ) { + this.source = fetcher; + } + + private get fetcher() { + if (!this.source) + throw new DOMException('DICOM metadata loader disposed', 'AbortError'); + return this.source; + } + + get meta() { + return this.tags; + } + + /** + * Where Pixel Data starts, or the file's length when it has none. Unset for + * a radiotherapy object, whose header past Modality is never read. + */ + get pixelDataOffset() { + return this.offset; + } + + /** How the bytes opened: with or without the Part 10 preamble and file meta. */ + get fileLayout() { + return this.layout; + } + + private async readHeader( + stream: ReadableStream, + parse: ReturnType + ) { + const generation = this.generation; + let pixelDataIdx = -1; + // The bytes read so far live here rather than in the fetcher: the header + // is parsed once and then done with, and a fetcher over a file has no + // reason to keep a copy of it for the chunk's lifetime. + const received: Uint8Array[] = []; + + // Read a chunk at a time and cancel the moment the header ends: a stream + // that reads ahead pulls pixel data this loader never uses. + const reader = stream.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + received.push(value); + const result = parse(value); + if (result.done) { + pixelDataIdx = result.value.position; + break; + } + } + } catch (err) { + if (err !== StopSignal) throw err; + // Stopped from outside before the header ended, so what arrived is not + // a header and must not be read as one. + throw new Error('The DICOM header read was stopped before it finished'); + } finally { + // Not awaited, so the cancel lands before the stream refills its queue. + reader.cancel().catch(() => {}); + if (generation === this.generation) this.fetcher.close(); + } + return { received, pixelDataIdx }; + } + + async load() { + if (this.tags) return; + + const generation = this.generation; + await this.fetcher.connect(); + if (generation !== this.generation) + throw new DOMException('DICOM read cancelled', 'AbortError'); + const stream = this.fetcher.getStream(); + let modality: string | undefined; + + const parse = createDicomParser({ + stopAtElement: (group, element) => + isTag(PIXEL_DATA, group, element) || + (this.imageImportPolicy === 'skip-radiotherapy' && + isRadiotherapy(modality)), + onLayout: (layout) => { + this.layout = layout; + }, + onDataElement: (el) => { + if ( + isTag(MODALITY, el.group, el.element) && + el.data instanceof Uint8Array + ) + modality = toAscii(el.data).trim(); + }, + }); + + const { received, pixelDataIdx } = await this.readHeader(stream, parse); + + // An object with no Pixel Data, such as a structured report, is header to + // its last byte. + const header = concatBytes( + received, + pixelDataIdx < 0 ? undefined : pixelDataIdx + ); + const dataset = await this.readDataset(header); + if (generation !== this.generation) + throw new DOMException('DICOM read cancelled', 'AbortError'); + this.offset = + this.imageImportPolicy === 'skip-radiotherapy' && isRadiotherapy(modality) + ? undefined + : header.length; + this.tags = dataset; + + if (modality === 'US') { + this.ultrasoundRegions = readUltrasoundRegions(dataset); + } + } + + dispose() { + this.stop(); + this.source = null; + } + + stop() { + this.generation += 1; + this.source?.close(); + } +} diff --git a/src/core/streaming/dicom/dicomParser.ts b/packages/dicom/core/streaming/dicom/dicomParser.ts similarity index 89% rename from src/core/streaming/dicom/dicomParser.ts rename to packages/dicom/core/streaming/dicom/dicomParser.ts index 51502a4d3..765a5ca91 100644 --- a/src/core/streaming/dicom/dicomParser.ts +++ b/packages/dicom/core/streaming/dicom/dicomParser.ts @@ -1,5 +1,10 @@ -import StreamingByteReader from '@/src/core/streaming/streamingByteReader.js'; -import { toAscii, asCoroutine } from '@/src/utils'; +import StreamingByteReader from '../streamingByteReader'; +import { toAscii, asCoroutine } from '../../utils'; +import { + PART_10_HEADER_LENGTH, + sniffDicomLayout, + type DicomLayout, +} from '../../metadata/dicomLayout'; // [Group, Element] type Tag = [number, number]; @@ -106,6 +111,15 @@ function* readElementValue( { explicitVr = false, littleEndian = false } = {} ): Generator { // See examples: https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.2.html + // PS3.5 6.2.2: an undefined-length UN holds sequence items encoded + // in Implicit VR Little Endian, regardless of the enclosing syntax. + if (vr === 'UN' && length === UndefinedLength) { + return yield* readSequenceValue(reader, length, { + explicitVr: false, + littleEndian: true, + }); + } + if (vr === 'SQ') { return yield* readSequenceValue(reader, length, { explicitVr, @@ -285,7 +299,7 @@ function* readSequenceValue( // Sequence Delimitation Item tag // "Otherwise, the Value Field has an Undefined Length and a Sequence Delimitation Item marks the end of the Value Field." // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/chapter_7.html#sect_7.1.2 - if (isUndefinedLength && group === 0xfffe && element === 0xe0dd) { + if (group === 0xfffe && element === 0xe0dd) { // skip the 4-byte zero length yield* reader.seek(4); return items; @@ -352,25 +366,27 @@ function* readFieldMetaInfo( export type ReadDicomOptions = { stopAtElement?(group: number, element: number): boolean; onDataElement?: DataElementCallback; + /** Called once the opening bytes say how the file is laid out. */ + onLayout?(layout: DicomLayout): void; }; export function* readDicomUntilPixelData(opts?: ReadDicomOptions) { const reader = new StreamingByteReader(); - // preamble - yield* reader.seek(128); - - // prefix - const prefix = yield* reader.readAscii(4); - if (prefix !== 'DICM') { - throw new Error('Not DICOM'); - } - - const info = yield* readFieldMetaInfo(reader, { - onDataElement: opts?.onDataElement, - }); - const explicitVr = info.transferSyntaxUid !== ImplicitTransferSyntaxUID; - const littleEndian = info.transferSyntaxUid !== ExplicitVRBigEndianUID; + // Peeked rather than read: a file without the Part 10 preamble has its + // first element at byte 0. + const head = yield* reader.read(PART_10_HEADER_LENGTH, { peek: true }); + const layout = sniffDicomLayout(head); + opts?.onLayout?.(layout); + if (layout.preamble) yield* reader.seek(PART_10_HEADER_LENGTH); + + const transferSyntaxUid = layout.fileMeta + ? (yield* readFieldMetaInfo(reader, { + onDataElement: opts?.onDataElement, + })).transferSyntaxUid + : layout.transferSyntaxUid; + const explicitVr = transferSyntaxUid !== ImplicitTransferSyntaxUID; + const littleEndian = transferSyntaxUid !== ExplicitVRBigEndianUID; yield* readDataElementsUntil( reader, diff --git a/src/core/streaming/dicom/ultrasoundRegion.ts b/packages/dicom/core/streaming/dicom/ultrasoundRegion.ts similarity index 79% rename from src/core/streaming/dicom/ultrasoundRegion.ts rename to packages/dicom/core/streaming/dicom/ultrasoundRegion.ts index 3fab3a12b..c83984775 100644 --- a/src/core/streaming/dicom/ultrasoundRegion.ts +++ b/packages/dicom/core/streaming/dicom/ultrasoundRegion.ts @@ -1,8 +1,9 @@ -import { - createDicomParser, - DataElement, -} from '@/src/core/streaming/dicom/dicomParser'; -import { Tags, tagToGroupElement } from '@/src/core/dicomTags'; +import { getDatasetTag } from '../../metadata/datasetAccess'; +import { sequenceItems } from '../../metadata/functionalGroups'; +import type { DicomDataset } from '../../metadata/dicomDataset'; +import { trimDicomPadding } from '../../utils'; +import { createDicomParser, type DataElement } from './dicomParser'; +import { Tags, tagToGroupElement } from '../../metadata/dicomTags'; // DICOM unit codes for PhysicalUnitsXDirection / YDirection. // See DICOM PS3.3 C.8.5.5.1.15. The only spatial spacing code defined for @@ -104,10 +105,30 @@ export function decodeUltrasoundRegion( }; } -/** - * Parses a DICOM blob and returns the first ultrasound region plus the - * total region count, if a SequenceOfUltrasoundRegions is present. - */ +/** The structured header supplies regions in either transfer syntax. */ +export function readUltrasoundRegions( + metadata: DicomDataset +): UltrasoundRegions { + const items = sequenceItems( + metadata, + Tags.SequenceOfUltrasoundRegions.replace('|', '') + ); + const value = (tag: string) => { + const text = trimDicomPadding(getDatasetTag(items[0], tag) ?? ''); + return text ? Number(text) : NaN; + }; + const region = { + physicalDeltaX: value(Tags.PhysicalDeltaX), + physicalDeltaY: value(Tags.PhysicalDeltaY), + physicalUnitsXDirection: value(Tags.PhysicalUnitsXDirection), + physicalUnitsYDirection: value(Tags.PhysicalUnitsYDirection), + }; + return { + regionCount: items.length, + ...(Object.values(region).every(Number.isFinite) ? { region } : {}), + }; +} + export async function parseUltrasoundRegionFromBlob( blob: Blob ): Promise { diff --git a/src/core/streaming/httpCodes.ts b/packages/dicom/core/streaming/httpCodes.ts similarity index 100% rename from src/core/streaming/httpCodes.ts rename to packages/dicom/core/streaming/httpCodes.ts diff --git a/packages/dicom/core/streaming/requestPool.ts b/packages/dicom/core/streaming/requestPool.ts new file mode 100644 index 000000000..896cd263d --- /dev/null +++ b/packages/dicom/core/streaming/requestPool.ts @@ -0,0 +1,110 @@ +import { Deferred, defer } from '../utils'; + +type FetchRequest = { + request: RequestInfo | URL; + init?: RequestInit; + deferred: Deferred; + controller: AbortController; + cleanup: () => void; +}; + +/** A FIFO request pool. Disposal aborts active fetches and rejects queued callers. */ +export class RequestPool { + private queue: FetchRequest[] = []; + private inflight = new Set(); + private disposed = false; + private lifetime = new AbortController(); + + constructor( + public readonly poolSize = 24, + private fetchFn: typeof fetch = (input, init) => + globalThis.fetch(input, init) + ) { + if (!Number.isInteger(poolSize) || poolSize < 1) + throw new Error('Request concurrency must be a positive integer'); + } + + get activeConnections() { + return this.inflight.size; + } + + fetch = (request: RequestInfo | URL, init?: RequestInit) => { + if (this.disposed) + return Promise.reject( + new DOMException('Request pool disposed', 'AbortError') + ); + const deferred = defer(); + const controller = new AbortController(); + const sourceSignal = + init?.signal ?? (request instanceof Request ? request.signal : undefined); + const abort = () => { + controller.abort(sourceSignal?.reason); + deferred.reject(sourceSignal?.reason); + this.queue = this.queue.filter((entry) => entry !== queued); + }; + const onDispose = () => controller.abort(this.lifetime.signal.reason); + const queued: FetchRequest = { + request, + init, + deferred, + controller, + cleanup: () => { + sourceSignal?.removeEventListener('abort', abort); + this.lifetime.signal.removeEventListener('abort', onDispose); + }, + }; + if (sourceSignal?.aborted) { + deferred.reject(sourceSignal.reason); + return deferred.promise; + } + sourceSignal?.addEventListener('abort', abort, { once: true }); + this.lifetime.signal.addEventListener('abort', onDispose, { once: true }); + controller.signal.addEventListener('abort', queued.cleanup, { once: true }); + this.queue.push(queued); + this.processQueue(); + return deferred.promise; + }; + + dispose() { + this.disposed = true; + const error = new DOMException('Request pool disposed', 'AbortError'); + this.lifetime.abort(error); + [...this.queue, ...this.inflight].forEach((request) => { + request.controller.abort(error); + request.deferred.reject(error); + request.cleanup(); + }); + this.queue = []; + } + + private processQueue() { + while ( + !this.disposed && + this.inflight.size < this.poolSize && + this.queue.length + ) { + this.startRequest(this.queue.shift()!); + } + } + + private async startRequest(request: FetchRequest) { + this.inflight.add(request); + try { + const fetchFn = this.fetchFn; + const response = await fetchFn(request.request, { + ...request.init, + signal: request.controller.signal, + }); + if (request.controller.signal.aborted) { + response.body?.cancel().catch(() => {}); + request.deferred.reject(request.controller.signal.reason); + } else request.deferred.resolve(response); + } catch (error) { + request.cleanup(); + request.deferred.reject(error); + } finally { + this.inflight.delete(request); + this.processQueue(); + } + } +} diff --git a/src/core/stateMachine.ts b/packages/dicom/core/streaming/stateMachine.ts similarity index 100% rename from src/core/stateMachine.ts rename to packages/dicom/core/streaming/stateMachine.ts diff --git a/src/core/streaming/streamingByteReader.ts b/packages/dicom/core/streaming/streamingByteReader.ts similarity index 96% rename from src/core/streaming/streamingByteReader.ts rename to packages/dicom/core/streaming/streamingByteReader.ts index 902fd9f1c..41d606ad0 100644 --- a/src/core/streaming/streamingByteReader.ts +++ b/packages/dicom/core/streaming/streamingByteReader.ts @@ -1,5 +1,5 @@ -import ByteDeque from '@/src/core/streaming/byteDeque.js'; -import { toAscii } from '@/src/utils'; +import ByteDeque from './byteDeque'; +import { toAscii } from '../utils'; /** * StreamingByteReader diff --git a/packages/dicom/core/streaming/types.ts b/packages/dicom/core/streaming/types.ts new file mode 100644 index 000000000..2984d65db --- /dev/null +++ b/packages/dicom/core/streaming/types.ts @@ -0,0 +1,59 @@ +import { Maybe } from '../utils'; +import type { DicomDataset } from '../metadata/dicomDataset'; +import type { DicomLayout } from '../metadata/dicomLayout'; +import { Awaitable } from '../utils'; +import type { UltrasoundRegions } from './dicom/ultrasoundRegion'; + +export type LoaderEvents = { + error: any; + done: any; +}; + +type Loader = { + load(): Awaitable; + stop(): Awaitable; + dispose?(): void; +}; + +/** + * A metadata loader. + */ +export type MetaLoader = Loader & { + meta: Maybe; + ultrasoundRegions?: UltrasoundRegions; + fileLayout?: DicomLayout | null; +}; + +/** + * A data loader. + */ +export type DataLoader = Loader & { + data: Maybe; +}; + +/** + * Init options for a Fetcher. + */ +export type FetcherInit = { + abortController?: AbortController; +}; + +/** + * A source of bytes that can be streamed from the start or taken whole. + */ +export type Fetcher = { + connect(): Promise; + getStream(): ReadableStream; + blob(): Promise; + close(): void; + /** + * What the streams have delivered so far, kept by a fetcher whose source + * has to be cached to be read again. A fetcher over addressable bytes keeps + * none. + */ + cachedChunks?: Uint8Array[]; + connected: boolean; + size: number; + contentType?: string; + abortSignal?: AbortSignal; +}; diff --git a/packages/dicom/core/utils.ts b/packages/dicom/core/utils.ts new file mode 100644 index 000000000..304ef325a --- /dev/null +++ b/packages/dicom/core/utils.ts @@ -0,0 +1,77 @@ +export type Maybe = T | null | undefined; +export type Awaitable = T | Promise; +/** Boundary padding accepted by numeric readers, without changing the stored text. */ +export const trimDicomPadding = (value: string) => + value.replace(/^[\s\0]+|[\s\0]+$/g, ''); + +type PromiseResolveFunction = (value: T) => void; +type PromiseRejectFunction = (reason?: any) => void; +export type Deferred = { + promise: Promise; + resolve: PromiseResolveFunction; + reject: PromiseRejectFunction; +}; + +export function defer(): Deferred { + let innerResolve: PromiseResolveFunction | null = null; + let innerReject: PromiseRejectFunction | null = null; + + const resolve = (value: T) => { + if (innerResolve) innerResolve(value); + }; + const reject = (reason?: Error) => { + if (innerReject) innerReject(reason); + }; + + const promise = new Promise((res, rej) => { + innerResolve = res; + innerReject = rej; + }); + + return { promise, resolve, reject }; +} +export function concatBytes( + parts: readonly Uint8Array[], + length = parts.reduce((total, part) => total + part.length, 0) +) { + const out = new Uint8Array(length); + parts.reduce((offset, part) => { + if (offset >= length) return offset; + const slice = part.subarray(0, length - offset); + out.set(slice, offset); + return offset + slice.length; + }, 0); + return out; +} + +export function toAscii( + bytes: Uint8Array | Uint8ClampedArray, + { ignoreNulls = false } = {} +) { + const chars = []; + for (let i = 0; i < bytes.length; i++) { + if (!(ignoreNulls && bytes[i] === 0)) { + chars.push(String.fromCharCode(bytes[i])); + } + } + return chars.join(''); +} + +/** + * Wraps a generator as a coroutine. + * @param generator + * @param args + * @returns + */ +export function asCoroutine(gen: Generator) { + // run initial code + const result = gen.next(); + if (result.done) return () => result; + return (value: N): IteratorResult => gen.next(value); +} + +export const numberOr = (value: string | undefined, fallback: number) => { + const text = trimDicomPadding(value ?? ''); + const number = Number(text); + return text && Number.isFinite(number) ? number : fallback; +}; diff --git a/src/utils/__tests__/parseContentRangeHeader.spec.ts b/packages/dicom/core/utils/__tests__/parseContentRangeHeader.spec.ts similarity index 94% rename from src/utils/__tests__/parseContentRangeHeader.spec.ts rename to packages/dicom/core/utils/__tests__/parseContentRangeHeader.spec.ts index fe397107e..7f0837449 100644 --- a/src/utils/__tests__/parseContentRangeHeader.spec.ts +++ b/packages/dicom/core/utils/__tests__/parseContentRangeHeader.spec.ts @@ -1,4 +1,4 @@ -import { parseContentRangeHeader } from '@/src/utils/parseContentRangeHeader'; +import { parseContentRangeHeader } from '../parseContentRangeHeader'; import { describe, expect, it } from 'vitest'; describe('parseContentRangeHeader', () => { diff --git a/src/utils/parseContentRangeHeader.ts b/packages/dicom/core/utils/parseContentRangeHeader.ts similarity index 100% rename from src/utils/parseContentRangeHeader.ts rename to packages/dicom/core/utils/parseContentRangeHeader.ts diff --git a/packages/dicom/package.json b/packages/dicom/package.json new file mode 100644 index 000000000..8574f150f --- /dev/null +++ b/packages/dicom/package.json @@ -0,0 +1,38 @@ +{ + "name": "@volview/dicom", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "dist", + "core", + "vtk" + ], + "exports": { + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core.js" + }, + "./vtk": { + "types": "./dist/vtk/index.d.ts", + "import": "./dist/vtk.js" + }, + "./assets/*": "./dist/assets/*" + }, + "dependencies": { + "@itk-wasm/image-io": "1.6.1", + "comlink": "^4.4.2", + "dcmjs": "^0.52.0", + "gl-matrix": "3.4.3", + "itk-wasm": "^1.0.0-b.199", + "mitt": "^3.0.1" + }, + "peerDependencies": { + "@kitware/vtk.js": "^38.0.0" + }, + "peerDependenciesMeta": { + "@kitware/vtk.js": { + "optional": true + } + } +} diff --git a/packages/dicom/scripts/build.mjs b/packages/dicom/scripts/build.mjs new file mode 100644 index 000000000..b547210e6 --- /dev/null +++ b/packages/dicom/scripts/build.mjs @@ -0,0 +1,70 @@ +import { execFileSync } from 'node:child_process'; +import { cp, mkdir, readFile, readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const root = resolve(packageRoot, '../..'); +const require = createRequire(import.meta.url); +const outDir = resolve(packageRoot, process.argv[2] ?? 'dist'); +const manifest = JSON.parse( + await readFile(resolve(packageRoot, 'package.json'), 'utf8') +); +const dependencies = Object.keys({ + ...manifest.dependencies, + ...manifest.peerDependencies, +}); +const external = (id) => + dependencies.some((name) => id === name || id.startsWith(`${name}/`)); + +await build({ + configFile: false, + root: packageRoot, + publicDir: false, + base: './', + build: { + outDir, + emptyOutDir: true, + sourcemap: true, + lib: { + entry: { + core: resolve(packageRoot, 'core/index.ts'), + vtk: resolve(packageRoot, 'vtk/index.ts'), + }, + formats: ['es'], + fileName: (_format, entry) => `${entry}.js`, + }, + rollupOptions: { external }, + }, + worker: { format: 'es' }, +}); +execFileSync( + process.execPath, + [ + require.resolve('typescript/bin/tsc'), + '-p', + 'packages/dicom/tsconfig.build.json', + '--outDir', + outDir, + ], + { cwd: root, stdio: 'inherit' } +); + +const moduleRoot = (name) => resolve(dirname(require.resolve(name)), '..'); +const assets = resolve(outDir, 'assets'); +await mkdir(resolve(assets, 'image-io'), { recursive: true }); +await cp( + resolve( + moduleRoot('itk-wasm'), + 'dist/pipeline/web-workers/bundles/itk-wasm-pipeline.min.worker.js' + ), + resolve(assets, 'itk-wasm-pipeline.min.worker.js') +); +const pipelines = resolve(moduleRoot('@itk-wasm/image-io'), 'dist/pipelines'); +for (const name of await readdir(pipelines)) { + if (/^gdcm-read-image\.(js|wasm|wasm\.zst)$/.test(name)) { + await cp(resolve(pipelines, name), resolve(assets, 'image-io', name)); + } +} diff --git a/packages/dicom/scripts/check-consumers.mjs b/packages/dicom/scripts/check-consumers.mjs new file mode 100644 index 000000000..4490dcca1 --- /dev/null +++ b/packages/dicom/scripts/check-consumers.mjs @@ -0,0 +1,98 @@ +import { execFileSync } from 'node:child_process'; +import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { build } from 'vite'; + +const root = resolve(fileURLToPath(new URL('../../..', import.meta.url))); +const packageRoot = resolve(root, 'packages/dicom'); +const fixture = resolve(root, 'tests/fixtures/dicom-consumer'); +const require = createRequire(import.meta.url); + +const forbiddenModule = (id, kind) => { + const path = id.replaceAll('\\', '/'); + return ( + path.startsWith(`${root.replaceAll('\\', '/')}/src/`) || + /\/node_modules\/(vue|pinia|@vue\/|@vueuse\/)/.test(path) || + (kind === 'core' && path.includes('/node_modules/@kitware/vtk.js/')) + ); +}; + +export async function buildConsumer( + kind, + outDir, + packageDist = resolve(packageRoot, 'dist') +) { + await build({ + configFile: false, + root: fixture, + publicDir: false, + base: './', + resolve: { + alias: { + '@volview/dicom/core': resolve(packageDist, 'core.js'), + '@volview/dicom/vtk': resolve(packageDist, 'vtk.js'), + }, + }, + plugins: [ + { + name: 'independent-dicom-consumer', + generateBundle() { + for (const id of this.getModuleIds()) { + if (forbiddenModule(id, kind)) + this.error(`Unexpected ${kind} consumer dependency: ${id}`); + } + }, + }, + ], + build: { + outDir, + emptyOutDir: true, + rollupOptions: { input: resolve(fixture, `${kind}.html`) }, + }, + worker: { format: 'es' }, + }); + await cp(resolve(packageDist, 'assets'), resolve(outDir, 'assets'), { + recursive: true, + }); +} + +async function checkDeclarations(directory) { + const config = { + extends: resolve(root, 'tsconfig.json'), + compilerOptions: { + baseUrl: root, + types: ['node'], + typeRoots: [resolve(root, 'node_modules/@types')], + paths: { + '@volview/dicom/core': ['packages/dicom/dist/core/index.d.ts'], + '@volview/dicom/vtk': ['packages/dicom/dist/vtk/index.d.ts'], + }, + }, + include: [`${fixture}/*.ts`], + exclude: [], + }; + const configPath = resolve(directory, 'tsconfig.json'); + await writeFile(configPath, JSON.stringify(config)); + execFileSync( + process.execPath, + [require.resolve('typescript/bin/tsc'), '-p', configPath], + { cwd: root, stdio: 'inherit' } + ); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + const directory = await mkdtemp(resolve(tmpdir(), 'dicom-consumers-')); + try { + await checkDeclarations(directory); + for (const kind of ['core', 'vtk']) + await buildConsumer(kind, resolve(directory, kind)); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/packages/dicom/tsconfig.build.json b/packages/dicom/tsconfig.build.json new file mode 100644 index 000000000..0a535382c --- /dev/null +++ b/packages/dicom/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": ".", + "types": [], + "baseUrl": ".", + "paths": {} + }, + "include": [ + "core/**/*.ts", + "vtk/**/*.ts" + ], + "exclude": [ + "**/__tests__/**", + "dist" + ] +} diff --git a/packages/dicom/vtk/VtkDicomImage.ts b/packages/dicom/vtk/VtkDicomImage.ts new file mode 100644 index 000000000..b3a11a9a5 --- /dev/null +++ b/packages/dicom/vtk/VtkDicomImage.ts @@ -0,0 +1,619 @@ +import { + Chunk, + ChunkState, + getChunkTag, + readInstanceFacts, + reconstructInstances, + sliceNormalOf, + type Reconstruction, + type SpatialMetadata, +} from '../core/index'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { TypedArray } from '@kitware/vtk.js/types'; +import mitt, { Emitter } from 'mitt'; +import { + allocateImageFromChunks, + type IrregularGeometryPolicy, +} from './allocateImageFromChunks'; +import { + assertSamplesRepresentable, + assertSingleFramePerFile, + assertChunkFitsSlot, +} from './pixelValidation'; +import { applyUltrasoundSpacing } from './ultrasoundSpacing'; +import { + ChunkStatus, + type ChunkImageEvents, + type ProgressiveImageStatus, + type VtkDicomImageInit, +} from './types'; +import { encodeThumbnailToUri } from './thumbnail'; +import { sliceFromBuffer, sliceFromChunk } from './thumbnailSource'; +import { untilAborted } from './lifetime'; + +const ensureError = (error: unknown) => + error instanceof Error ? error : new Error(String(error)); + +const { fastComputeRange } = vtkDataArray; + +const DATA_RANGE_KEY = 'pixel-data-range'; + +const getChunkId = (chunk: Chunk) => getChunkTag(chunk, '00080018'); + +// A scan, not a map of the header: this runs once per decoded chunk. +const modalityOf = (chunks: Chunk[]) => + chunks.length > 0 + ? (getChunkTag(chunks[0], '00080060')?.trim() ?? null) + : null; + +const sameChunkOrder = (first: Chunk[], second: Chunk[]) => + first.length === second.length && + first.every((chunk, index) => chunk === second[index]); + +function initialChunkStatus(chunk: Chunk) { + switch (chunk.state) { + case ChunkState.Init: + case ChunkState.MetaLoading: + case ChunkState.MetaOnly: + return ChunkStatus.NotLoaded; + case ChunkState.DataLoading: + return ChunkStatus.Loading; + // Loaded pixels belong to the previous allocation. + case ChunkState.Loaded: + return ChunkStatus.Loading; + default: + throw new Error('Chunk is in an invalid state'); + } +} + +export class VtkDicomImage { + private encodeThumbnail: NonNullable; + private readDicomImage: VtkDicomImageInit['readDicomImage']; + private warn: NonNullable; + protected chunks: Chunk[]; + private orientation: number[] | null = null; + private reconstruction: Reconstruction | null = null; + private spatialMetadata: SpatialMetadata | undefined; + private image = vtkImageData.newInstance(); + private loading = false; + private status: ProgressiveImageStatus = 'incomplete'; + private readonly irregularGeometry: IrregularGeometryPolicy; + private readonly readSegmentation: VtkDicomImageInit['readSegmentation']; + public segmentationMetadata: unknown = null; + private chunkListeners: Array<() => void>; + private events: Emitter; + private chunkStatus: ChunkStatus[]; + private allocationGeneration: number; + private chunkUpdateQueue: Promise; + private disposed: boolean; + private readonly lifetime = new AbortController(); + private thumbnailLifetime = new AbortController(); + private thumbnail: Promise | null; + + constructor(init: VtkDicomImageInit) { + this.encodeThumbnail = init.encodeThumbnail ?? encodeThumbnailToUri; + this.readDicomImage = init.readDicomImage; + this.warn = init.warn ?? (() => {}); + this.irregularGeometry = init.irregularGeometry ?? 'preserve-order'; + this.readSegmentation = init.readSegmentation; + + this.chunks = []; + this.chunkListeners = []; + this.chunkStatus = []; + this.events = mitt(); + this.allocationGeneration = 0; + this.chunkUpdateQueue = Promise.resolve(); + this.disposed = false; + this.thumbnail = null; + + this.addEventListener('loading', (loading) => { + this.loading = loading; + }); + + this.addEventListener('status', (status) => { + this.status = status; + }); + } + + getVtkImageData() { + return this.image; + } + + getStatus() { + return this.status; + } + + isLoading() { + return this.loading; + } + + isLoaded() { + return !this.loading && this.status === 'complete'; + } + + getReconstruction() { + return this.reconstruction ? structuredClone(this.reconstruction) : null; + } + + getSpatialMetadata() { + return this.spatialMetadata + ? structuredClone(this.spatialMetadata) + : undefined; + } + + private replaceImage(image: vtkImageData, spatialMetadata?: SpatialMetadata) { + const previous = this.image; + this.image = image; + this.spatialMetadata = spatialMetadata; + this.events.emit('imageChanged', image); + previous.delete(); + } + + getModality() { + return modalityOf(this.chunks); + } + + getChunkStatuses(): Array { + return this.chunkStatus.slice(); + } + + getDicomMetadata(chunkNum = 0) { + if (chunkNum < 0 || chunkNum >= this.chunks.length) { + throw RangeError('chunkNum is out of bounds'); + } + return this.chunks[chunkNum].metadata; + } + + getOrientation() { + return this.orientation?.slice() ?? null; + } + + getChunks() { + return this.chunks.slice(); + } + + addEventListener( + type: T, + callback: (info: ChunkImageEvents[T]) => void + ): void { + this.events.on(type, callback); + } + + removeEventListener( + type: T, + callback: (info: ChunkImageEvents[T]) => void + ): void { + this.events.off(type, callback); + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.lifetime.abort(); + this.thumbnailLifetime.abort(); + this.allocationGeneration += 1; + this.unregisterChunkListeners(); + this.events.all.clear(); + this.chunks.length = 0; + this.image.delete(); + this.chunkStatus = []; + } + + startLoad() { + if (this.disposed) return; + this.chunks.forEach((chunk, index) => { + if (chunk.state === ChunkState.Loaded) { + if ( + this.chunkStatus[index] === ChunkStatus.Loaded || + this.chunkStatus[index] === ChunkStatus.Loading + ) + return; + this.chunkStatus[index] = ChunkStatus.Loading; + this.decodeChunk(chunk); + } else { + this.chunkStatus[index] = ChunkStatus.Loading; + chunk.loadData().catch(() => {}); + } + }); + this.events.emit('loading', !this.isSettled()); + } + + stopLoad() { + this.invalidateThumbnail(); + this.allocationGeneration += 1; + this.chunks.forEach((chunk) => { + chunk.stopLoad(); + }); + this.chunkStatus = this.chunkStatus.map((status) => + status === ChunkStatus.Loading ? ChunkStatus.NotLoaded : status + ); + this.events.emit('loading', false); + } + + /** + * Replaces this image's membership and order. Grouping and ordering belong + * to the collection planner, so the chunks are taken exactly as given. + */ + setChunks( + chunks: Chunk[], + orientation: number[] | null, + reconstruction?: Reconstruction + ) { + const ordered = chunks.slice(); + const axes = orientation?.slice() ?? null; + const geometry = reconstruction + ? structuredClone(reconstruction) + : undefined; + const update = this.chunkUpdateQueue.then(() => + this.applyChunks(ordered, axes, geometry) + ); + this.chunkUpdateQueue = update.catch(() => {}); + return update; + } + + private sameMembership( + chunks: Chunk[], + orientation: number[] | null, + supplied?: Reconstruction + ) { + return ( + orientation?.join() === this.orientation?.join() && + sameChunkOrder(chunks, this.chunks) && + (!supplied || sameReconstruction(supplied, this.reconstruction)) + ); + } + + private async loadMetadata(chunks: Chunk[]) { + try { + await untilAborted( + Promise.all(chunks.map((chunk) => chunk.loadMeta())), + this.lifetime.signal + ); + } catch (error) { + if (!this.disposed) throw error; + } + } + + private async applyChunks( + chunks: Chunk[], + orientation: number[] | null, + supplied?: Reconstruction + ) { + if (this.sameMembership(chunks, orientation, supplied)) return; + + if (this.disposed) return; + await this.loadMetadata(chunks); + if (this.disposed) return; + + const status = chunks.map(initialChunkStatus); + const needsFrameGeometry = + chunks.length === 1 && Number(getChunkTag(chunks[0], '00280008')) > 1; + const reconstruction = + supplied && !needsFrameGeometry + ? supplied + : reconstructInstances( + chunks.map((chunk) => readInstanceFacts(chunk.metadata!)), + sliceNormalOf(orientation), + supplied + ); + const allocated = + modalityOf(chunks) === 'SEG' + ? null + : allocateImageFromChunks( + chunks, + orientation, + reconstruction, + this.irregularGeometry + ); + + this.unregisterChunkListeners(); + + this.allocationGeneration += 1; + this.chunks = chunks; + this.orientation = orientation; + this.reconstruction = reconstruction; + this.chunkStatus = status; + this.invalidateThumbnail(); + this.segmentationMetadata = null; + this.onChunksUpdated(); + + if (allocated) { + const spatialMetadata = applyUltrasoundSpacing( + allocated, + chunks, + this.warn + ); + this.replaceImage(allocated.image, spatialMetadata); + } + + this.registerChunkListeners(); + this.processLoadedChunks(); + + if (allocated) { + this.updateDataRangeFromChunks(); + } + } + + private invalidateThumbnail() { + this.thumbnailLifetime.abort(); + this.thumbnailLifetime = new AbortController(); + this.thumbnail = null; + } + + getThumbnail(): Promise { + if (this.disposed) return Promise.reject(this.lifetime.signal.reason); + if (!this.thumbnail) { + const chunks = this.chunks; + const requested = this.readThumbnail().catch((error) => { + if (this.thumbnail === requested) this.thumbnail = null; + if ( + !this.disposed && + chunks !== this.chunks && + error instanceof Error && + error.name === 'AbortError' + ) + return this.getThumbnail(); + throw error; + }); + this.thumbnail = requested; + } + return this.thumbnail; + } + + private async readThumbnail(): Promise { + const signal = this.thumbnailLifetime.signal; + const middle = Math.floor(this.chunks.length / 2); + const chunk = this.chunks[middle]; + if (!chunk) return null; + + const slice = + this.chunkStatus[middle] === ChunkStatus.Loaded + ? sliceFromBuffer(this.image, this.reconstruction!.slots[middle]) + : await sliceFromChunk(chunk, this.readDicomImage, signal); + + signal.throwIfAborted(); + return this.encodeThumbnail(slice); + } + + // Reallocation clears the buffer, so restore every available slice. + private processLoadedChunks() { + this.chunks.forEach((chunk) => { + if (chunk.state !== ChunkState.Loaded) return; + this.decodeChunk(chunk); + }); + } + + private decodeChunk(chunk: Chunk) { + const generation = this.allocationGeneration; + this.onChunkHasData(chunk, generation).catch((err) => { + if (generation !== this.allocationGeneration) return; + this.onChunkErrored(chunk, err); + }); + } + + private registerChunkListeners() { + this.chunkListeners = [ + ...this.chunks.map((chunk) => { + const stopDoneData = chunk.addEventListener('doneData', () => { + this.decodeChunk(chunk); + }); + + const stopError = chunk.addEventListener('error', (err) => { + this.onChunkErrored(chunk, err); + }); + + return () => { + stopDoneData(); + stopError(); + }; + }), + ]; + } + + private unregisterChunkListeners() { + while (this.chunkListeners.length) { + this.chunkListeners.pop()!(); + } + } + + private updateDataRangeFromChunks() { + const scalars = this.image.getPointData().getScalars(); + const ranges = this.dataRangeFromChunks(); + if (ranges.length > 0) { + ranges.forEach(([min, max], compIdx) => { + scalars.setRange({ min, max }, compIdx); + }); + scalars.modified(); // so image-stats will trigger update of range + } + } + + private dataRangeFromChunks() { + const outputRanges: Array<[number, number]> = []; + this.chunks.forEach((chunk) => { + const ranges = chunk.getUserData(DATA_RANGE_KEY) as + | Array<[number, number]> + | undefined; + if (!ranges) return; + ranges.forEach((range, idx) => { + const curMin = outputRanges[idx]?.[0] ?? range[0]; + const curMax = outputRanges[idx]?.[1] ?? range[1]; + outputRanges[idx] = [ + Math.min(curMin, range[0]), + Math.max(curMax, range[1]), + ]; + }); + }); + + return outputRanges; + } + + private async onChunkHasData(chunk: Chunk, generation: number) { + if (this.getModality() === 'SEG') { + await this.onSegChunkHasData(chunk, generation); + } else { + await this.onRegularChunkHasData(chunk, generation); + } + } + + private async onSegChunkHasData(chunk: Chunk, generation: number) { + if (this.chunks.length !== 1 || this.chunks[0] !== chunk) + throw new Error( + `Cannot handle multiple SEG files. Expected 1 chunk at index 0, got ${this.chunks.length} chunks with current index ${this.chunks.indexOf(chunk)}` + ); + + if (!this.readSegmentation) + throw new Error('A segmentation decoder is required for SEG objects'); + const results = await this.readSegmentation( + new File([chunk.dataBlob!], 'seg.dcm') + ); + if (generation !== this.allocationGeneration) { + results.image.delete(); + return; + } + this.segmentationMetadata = results.metadata; + this.replaceImage(results.image); + + this.chunkStatus[0] = ChunkStatus.Loaded; + this.onChunksUpdated(); + } + + private async onRegularChunkHasData(chunk: Chunk, generation: number) { + const chunkIndex = this.chunks.indexOf(chunk); + if (!chunk.dataBlob) + throw new Error(`Chunk ${chunkIndex} does not have data`); + + const chunkId = chunk.metadata ? getChunkId(chunk) : `index-${chunkIndex}`; + const result = await this.readDicomImage( + new File([chunk.dataBlob], `file-${chunkIndex}.dcm`), + chunk.metadata ?? undefined + ); + + if (!result.image.data) + throw new Error(`No data read from chunk ${chunkId}`); + + // Only the decode started for the current allocation may update it. + if (generation !== this.allocationGeneration) return; + + const sliceIndex = this.chunks.indexOf(chunk); + if (sliceIndex === -1) return; + + const where = `File ${chunkId} (chunk ${sliceIndex})`; + assertSingleFramePerFile(result.image.size[2], this.chunks.length, where); + + const scalars = this.image.getPointData().getScalars(); + const pixelData = scalars.getData(); + const componentCount = scalars.getNumberOfComponents(); + + const dims = this.image.getDimensions(); + + assertChunkFitsSlot( + result.image, + { dims, componentCount, count: this.chunks.length }, + where + ); + + const chunkData = result.image.data as TypedArray; + const chunkDataRange: Array<[number, number]> = []; + for (let comp = 0; comp < componentCount; comp++) { + const { min, max } = fastComputeRange(chunkData, comp, componentCount); + chunkDataRange.push([min, max]); + } + + const chunkMin = Math.min(...chunkDataRange.map(([min]) => min)); + const chunkMax = Math.max(...chunkDataRange.map(([, max]) => max)); + assertSamplesRepresentable( + chunkData, + { min: chunkMin, max: chunkMax }, + pixelData, + where + ); + + const slot = this.reconstruction!.slots[sliceIndex]; + const offset = dims[0] * dims[1] * componentCount * slot; + pixelData.set(chunkData, offset); + + const rangeAlreadyInitialized = this.chunkStatus.some( + (status) => status === ChunkStatus.Loaded + ); + + chunkDataRange.forEach(([min, max], comp) => { + const curRange = scalars.getRange(comp); + const newMin = rangeAlreadyInitialized ? Math.min(min, curRange[0]) : min; + const newMax = rangeAlreadyInitialized ? Math.max(max, curRange[1]) : max; + scalars.setRange({ min: newMin, max: newMax }, comp); + }); + scalars.modified(); // so image-stats will trigger update of range + + chunk.setUserData(DATA_RANGE_KEY, chunkDataRange); + + this.chunkStatus[sliceIndex] = ChunkStatus.Loaded; + this.events.emit('chunkLoad', { + chunk, + updatedExtent: [ + 0, + dims[0] - 1, + 0, + dims[1] - 1, + slot, + this.chunks.length === 1 ? dims[2] - 1 : slot, + ], + }); + this.onChunksUpdated(); + + this.image.modified(); + } + + private onChunkErrored(chunk: Chunk, err: unknown) { + const sliceIndex = this.chunks.indexOf(chunk); + if (sliceIndex === -1) return; + + if (err instanceof Error && err.name === 'AbortError') { + this.chunkStatus[sliceIndex] = ChunkStatus.NotLoaded; + this.onChunksUpdated(); + this.events.emit( + 'loading', + this.chunkStatus.includes(ChunkStatus.Loading) + ); + return; + } + this.chunkStatus[sliceIndex] = ChunkStatus.Errored; + this.events.emit('chunkError', { + chunk, + error: err, + }); + this.events.emit('error', ensureError(err)); + this.onChunksUpdated(); + } + + // Failures settle this attempt; startLoad can retry their slots. + private isSettled() { + return this.chunkStatus.every( + (status) => + status === ChunkStatus.Loaded || status === ChunkStatus.Errored + ); + } + + private computeStatus(): ProgressiveImageStatus { + const anyLoaded = this.chunkStatus.some( + (status) => status === ChunkStatus.Loaded + ); + // An image where every chunk errored holds no pixel data, so it is settled + // but never complete. + return this.isSettled() && anyLoaded ? 'complete' : 'incomplete'; + } + + private onChunksUpdated() { + this.events.emit('status', this.computeStatus()); + if (this.isSettled()) { + this.events.emit('loading', false); + } + } +} + +function sameReconstruction( + left: Reconstruction, + right: Reconstruction | null +) { + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/packages/dicom/vtk/__tests__/consumer.spec.ts b/packages/dicom/vtk/__tests__/consumer.spec.ts new file mode 100644 index 000000000..a4251467d --- /dev/null +++ b/packages/dicom/vtk/__tests__/consumer.spec.ts @@ -0,0 +1,326 @@ +import { sliceFromChunk } from '../thumbnailSource'; +import { describe, expect, it } from 'vitest'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { Chunk, type Reconstruction } from '../../core'; +import { VtkDicomImage, type VtkDicomImageInit } from '..'; +import { datasetFromTags } from '@/tests/dicomDataset'; + +const AXIAL = [1, 0, 0, 0, 1, 0]; + +async function loadedChunk(value: number, modality = 'CT') { + const metadata = datasetFromTags( + Object.entries({ + '00080060': modality, + '00080018': `1.2.3.${value}`, + '00200032': `0\\0\\${value}`, + '00200037': '1\\0\\0\\0\\1\\0', + '00280010': '1', + '00280011': '1', + '00280030': '1\\1', + '00280101': '16', + '00280103': '0', + }) + ); + const chunk = new Chunk({ + metaLoader: { meta: metadata, load() {}, stop() {} }, + dataLoader: { data: new Blob([String(value)]), load() {}, stop() {} }, + }); + await chunk.loadMeta(); + await chunk.loadData(); + return chunk; +} + +const decoded = (value: number) => ({ + image: { + size: [1, 1, 1], + data: new Uint16Array([value]), + imageType: { components: 1 }, + }, +}); + +const read: VtkDicomImageInit['readDicomImage'] = async (file) => + decoded(Number(await file.text())); +const pixels = (image: VtkDicomImage) => + Array.from(image.getVtkImageData().getPointData().getScalars().getData()); + +function completed(image: VtkDicomImage) { + return new Promise((resolve) => { + const listener = (status: string) => { + if (status !== 'complete') return; + image.removeEventListener('status', listener); + resolve(); + }; + image.addEventListener('status', listener); + }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('vtk DICOM consumer', () => { + it('uses the plan spacing and slice mapping without reconstructing it', async () => { + const chunks = await Promise.all([loadedChunk(1), loadedChunk(19)]); + const image = new VtkDicomImage({ readDicomImage: read }); + const reconstruction: Reconstruction = { + kind: 'regular', + sliceSpacing: 9, + slots: [0, 2], + }; + const done = completed(image); + await image.setChunks(chunks, AXIAL, reconstruction); + reconstruction.slots[1] = 0; + await done; + expect(image.getVtkImageData().getDimensions()).toEqual([1, 1, 3]); + expect(image.getVtkImageData().getSpacing()).toEqual([1, 1, 9]); + expect(pixels(image)).toEqual([1, 0, 19]); + image.dispose(); + }); + + it('can reject irregular geometry before replacing an existing image', async () => { + const chunk = await loadedChunk(1); + const image = new VtkDicomImage({ + readDicomImage: read, + irregularGeometry: 'reject', + }); + const done = completed(image); + await image.setChunks([chunk], AXIAL); + await done; + const original = image.getVtkImageData(); + await expect( + image.setChunks([chunk], AXIAL, { + kind: 'irregular', + reason: 'in-plane-shift', + sliceSpacing: 5, + slots: [0], + }) + ).rejects.toThrow('in-plane-shift'); + expect(image.getVtkImageData()).toBe(original); + expect(pixels(image)).toEqual([1]); + image.dispose(); + }); + + it('rejects a nonzero first slice slot before replacing an existing image', async () => { + const chunk = await loadedChunk(1); + const chunks = await Promise.all([loadedChunk(10), loadedChunk(20)]); + const image = new VtkDicomImage({ readDicomImage: read }); + const done = completed(image); + await image.setChunks([chunk], AXIAL); + await done; + const original = image.getVtkImageData(); + + await expect( + image.setChunks(chunks, AXIAL, { + kind: 'regular', + sliceSpacing: 10, + slots: [1, 2], + }) + ).rejects.toThrow('slice slot'); + + expect(image.getVtkImageData()).toBe(original); + expect(original.getOrigin()).toEqual([0, 0, 1]); + expect(pixels(image)).toEqual([1]); + + const updated = completed(image); + await image.setChunks(chunks, AXIAL, { + kind: 'regular', + sliceSpacing: 10, + slots: [0, 1], + }); + await updated; + expect(image.getVtkImageData().getOrigin()).toEqual([0, 0, 10]); + expect(pixels(image)).toEqual([10, 20]); + image.dispose(); + }); + + it('restarts decoding after cancellation and ignores the cancelled result', async () => { + const chunk = await loadedChunk(1); + const first = deferred>(); + const second = deferred>(); + let calls = 0; + const image = new VtkDicomImage({ + readDicomImage: () => (++calls === 1 ? first.promise : second.promise), + }); + const delivered: number[][] = []; + image.addEventListener('chunkLoad', () => delivered.push(pixels(image))); + await image.setChunks([chunk], AXIAL); + image.stopLoad(); + image.startLoad(); + const done = completed(image); + first.resolve(decoded(99)); + second.resolve(decoded(2)); + await done; + expect(delivered).toEqual([[2]]); + expect(pixels(image)).toEqual([2]); + image.dispose(); + }); + + it('rejects incomplete pixel buffers and retries without reallocating', async () => { + const chunk = await loadedChunk(1); + let calls = 0; + const image = new VtkDicomImage({ + readDicomImage: async () => { + calls += 1; + return calls === 1 + ? { image: { ...decoded(1).image, data: new Uint16Array() } } + : decoded(3); + }, + }); + const failed = new Promise((resolve) => + image.addEventListener('chunkError', ({ error }) => resolve(error)) + ); + await image.setChunks([chunk], AXIAL); + expect(String(await failed)).toContain('decoded 0 samples; expected 1'); + const original = image.getVtkImageData(); + const done = completed(image); + image.startLoad(); + await done; + expect(image.getVtkImageData()).toBe(original); + expect(pixels(image)).toEqual([3]); + image.dispose(); + }); + + it.each(['waiting for bytes', 'decoding pixels'])( + 'settles a thumbnail on disposal while %s', + async (phase) => { + const chunk = await loadedChunk(1); + if (phase === 'waiting for bytes') { + chunk.dispose(); + } + const pendingDecode = deferred>(); + const source = + phase === 'waiting for bytes' + ? new Chunk({ + metaLoader: { meta: chunk.metadata, load() {}, stop() {} }, + dataLoader: { data: null, load() {}, stop() {} }, + }) + : chunk; + const image = new VtkDicomImage({ + readDicomImage: () => pendingDecode.promise, + }); + await image.setChunks([source], AXIAL); + const thumbnail = image.getThumbnail(); + image.dispose(); + await expect(thumbnail).rejects.toMatchObject({ name: 'AbortError' }); + pendingDecode.resolve(decoded(1)); + } + ); + + it('deletes segmentation images returned after disposal', async () => { + const chunk = await loadedChunk(1, 'SEG'); + const result = deferred<{ image: vtkImageData; metadata: unknown }>(); + const image = new VtkDicomImage({ + readDicomImage: read, + readSegmentation: () => result.promise, + }); + await image.setChunks([chunk], AXIAL); + image.dispose(); + const stale = vtkImageData.newInstance(); + let signalDeleted = () => {}; + const deleted = new Promise((resolve) => { + signalDeleted = resolve; + }); + result.resolve({ + image: { + ...stale, + delete() { + stale.delete(); + signalDeleted(); + }, + }, + metadata: null, + }); + await deleted; + expect(stale.isDeleted()).toBe(true); + expect(chunk.dataBlob).toBeInstanceOf(Blob); + }); +}); + +describe('consumer failure recovery', () => { + it.each([ + { size: [2, 1, 1], components: 1, count: 1, error: 'does not fit' }, + { size: [1, 1, 1], components: 2, count: 1, error: 'does not fit' }, + { size: [1, 1, 2], components: 1, count: 2, error: 'multiple frames' }, + ])( + 'rejects decoded slot shape $size with $components components', + async ({ size, components, count, error }) => { + const chunks = await Promise.all( + Array.from({ length: count }, (_, i) => loadedChunk(i)) + ); + const image = new VtkDicomImage({ + readDicomImage: async () => ({ + image: { + size, + data: new Uint16Array(size.reduce((n, v) => n * v, components)), + imageType: { components }, + }, + }), + }); + const failed = new Promise((resolve) => + image.addEventListener('chunkError', ({ error: err }) => resolve(err)) + ); + await image.setChunks(chunks, AXIAL); + expect(String(await failed)).toContain(error); + expect(pixels(image).every((value) => value === 0)).toBe(true); + image.dispose(); + } + ); + + it('retries an idle failed middle chunk when a thumbnail is requested', async () => { + const metadata = (await loadedChunk(5)).metadata; + const data = { + data: null as Blob | null, + load() { + if (!this.data) { + this.data = new Blob(['5']); + throw new Error('offline'); + } + }, + stop() {}, + }; + const chunk = new Chunk({ + metaLoader: { meta: metadata, load() {}, stop() {} }, + dataLoader: data, + }); + await chunk.loadMeta(); + await expect(chunk.loadData()).rejects.toThrow('offline'); + expect( + await sliceFromChunk(chunk, read, new AbortController().signal) + ).toMatchObject({ width: 1, height: 1 }); + chunk.dispose(); + }); + + it('fulfills a thumbnail invalidated by a replacement allocation', async () => { + const firstDecode = deferred>(); + const pendingThumbnail = deferred>(); + const thumbnailStarted = deferred(); + let thumbnailCalls = 0; + const image = new VtkDicomImage({ + readDicomImage: async (file) => { + if (file.name === 'thumbnail.dcm') { + if (++thumbnailCalls === 1) { + thumbnailStarted.resolve(); + return pendingThumbnail.promise; + } + return decoded(2); + } + return firstDecode.promise; + }, + encodeThumbnail: () => 'current-thumbnail', + }); + const a = await loadedChunk(1); + const b = await loadedChunk(2); + await image.setChunks([a], AXIAL); + const thumbnail = image.getThumbnail(); + await thumbnailStarted.promise; + await image.setChunks([a, b], AXIAL); + await expect(thumbnail).resolves.toBe('current-thumbnail'); + image.dispose(); + firstDecode.resolve(decoded(1)); + pendingThumbnail.resolve(decoded(1)); + }); +}); diff --git a/packages/dicom/vtk/__tests__/functionalGroups.spec.ts b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts new file mode 100644 index 000000000..1e5494e5b --- /dev/null +++ b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts @@ -0,0 +1,522 @@ +import { describe, expect, it } from 'vitest'; +import type { DicomDataset } from '../../core'; +import { + Chunk, + readGeometryFacts, + readInstanceFacts, + planDicomCollections, + IRREGULAR_VOLUME_WARNING, + REPEATED_POSITIONS_WARNING, + UNREADABLE_FRAME_GEOMETRY_WARNING, +} from '../../core'; +import { VtkDicomImage } from '../VtkDicomImage'; +import { getFrameTag } from '../../core/metadata/functionalGroups'; +import { allocateImageFromChunks } from '../allocateImageFromChunks'; +import { datasetFromTags } from '../../../../tests/dicomDataset'; + +const tags = (values: Record) => + datasetFromTags(Object.entries(values)); + +function sequence(tag: string, items: DicomDataset[]) { + return { + tag, + group: parseInt(tag.slice(0, 4), 16), + element: parseInt(tag.slice(4), 16), + vr: 'SQ', + values: { kind: 'sequence' as const, items }, + }; +} + +const groups = (items: Array>): DicomDataset => ({ + specificCharacterSet: null, + elements: items, +}); + +function enhanced( + shared: DicomDataset, + frames: DicomDataset[], + root: Record = {} +): DicomDataset { + const header = tags({ + '00280010': '2', + '00280011': '2', + '00280008': String(frames.length), + '00280101': '16', + ...root, + }); + return { + ...header, + elements: [ + ...header.elements, + sequence('52009229', [shared]), + sequence('52009230', frames), + ], + }; +} + +const positioned = (position: string) => + groups([sequence('00209113', [tags({ '00200032': position })])]); + +const allocate = ( + metadata: DicomDataset, + policy: 'reject' | 'preserve-order' = 'preserve-order' +) => + allocateImageFromChunks( + [{ metadata } as Chunk], + null, + { kind: 'regular', sliceSpacing: null, slots: [0] }, + policy + ).image; + +const ctShared = () => + groups([ + sequence('00289110', [tags({ '00280030': '.388672\\.388672' })]), + sequence('00209116', [tags({ '00200037': '-1\\0\\0\\0\\1\\0' })]), + sequence('00289145', [tags({ '00281052': '-1024', '00281053': '1' })]), + ]); + +async function loadedMetadata(metadata: DicomDataset) { + const chunk = new Chunk({ + metaLoader: { meta: metadata, load() {}, stop() {} }, + dataLoader: { data: new Blob(['pixels']), load() {}, stop() {} }, + }); + await chunk.loadMeta(); + await chunk.loadData(); + return chunk; +} + +const completed = (volume: VtkDicomImage) => + new Promise((resolve) => { + const listener = (status: string) => { + if (status !== 'complete') return; + volume.removeEventListener('status', listener); + resolve(); + }; + volume.addEventListener('status', listener); + }); + +describe('enhanced volume Functional Groups', () => { + it('places stored enhanced CT frames at their patient positions and preserves negative values', () => { + const metadata = enhanced(ctShared(), [ + positioned('99.5\\-301.5\\-159'), + positioned('99.5\\-301.5\\-149'), + ]); + const image = allocate(metadata, 'reject'); + expect(image.getSpacing()).toEqual([0.388672, 0.388672, 10]); + expect(image.indexToWorld([0, 0, 0])).toEqual([99.5, -301.5, -159]); + expect(image.indexToWorld([0, 0, 1])).toEqual([99.5, -301.5, -149]); + const buffer = image.getPointData().getScalars().getData(); + buffer[0] = -1024; + buffer[4] = 172; + expect([buffer[0], buffer[4]]).toEqual([-1024, 172]); + expect(buffer).toBeInstanceOf(Int32Array); + image.delete(); + }); + + it('uses the storage direction for an oblique enhanced MR stack', () => { + const metadata = enhanced( + groups([ + sequence('00289110', [tags({ '00280030': '1\\1' })]), + sequence('00209116', [ + tags({ + '00200037': + '0\\0.9762960207\\-0.2164395526\\0\\-0.2164395526\\-0.9762960207', + }), + ]), + ]), + [ + positioned('-83.2177146\\-123.869376\\159.934076'), + positioned('-81.8877146\\-123.869376\\159.934076'), + ] + ); + const image = allocate(metadata, 'reject'); + expect(image.getSpacing()[2]).toBeCloseTo(1.33, 8); + expect(image.indexToWorld([0, 0, 1])[0]).toBeCloseTo(-81.8877146, 7); + expect(image.getDirection()[1]).toBeCloseTo(0.9762960207, 8); + expect(image.getDirection()[5]).toBeCloseTo(-0.9762960207, 8); + image.delete(); + }); + + it('unions per-frame calibration and lets capable custom decoders preserve it', async () => { + const frames = [ + groups([ + sequence('00289145', [tags({ '00281053': '1', '00281052': '-1024' })]), + ]), + groups([ + sequence('00289145', [tags({ '00281053': '.5', '00281052': '-500' })]), + ]), + ]; + const metadata = enhanced(groups([]), frames); + const chunk = await loadedMetadata(metadata); + let suppliedMetadata: DicomDataset | undefined; + const volume = new VtkDicomImage({ + readDicomImage: async (_file, header) => { + suppliedMetadata = header; + return { + image: { + size: [2, 2, 2], + imageType: { components: 1 }, + data: new Float64Array([ + -1024, -1023, -1022, -1021, -499.5, -499, -498.5, -498, + ]), + }, + }; + }, + }); + const done = completed(volume); + await volume.setChunks([chunk], null); + await done; + expect(suppliedMetadata).toBe(metadata); + expect( + volume.getVtkImageData().getPointData().getScalars().getData() + ).toEqual( + new Float64Array([-1024, -1023, -1022, -1021, -499.5, -499, -498.5, -498]) + ); + volume.dispose(); + }); + + it('accepts shared and per-frame calibration with equal numeric values', () => { + const metadata = enhanced( + groups([ + sequence('00289145', [tags({ '00281053': '.5', '00281052': '-1024' })]), + ]), + [ + groups([]), + groups([ + sequence('00289145', [ + tags({ + '00281053': '0.500\u0000', + '00281052': '-1024.0\u0000', + }), + ]), + ]), + ] + ); + const image = allocate(metadata); + const buffer = image.getPointData().getScalars().getData(); + expect(buffer).toBeInstanceOf(Float64Array); + buffer[0] = -1023.5; + expect(buffer[0]).toBe(-1023.5); + image.delete(); + }); + + it.each([ + ['repeated-position', [0, 0, 2]], + ['uneven-slice-spacing', [0, 1, 3]], + ])( + 'honors the irregular geometry policy for %s frames', + (reason, positions) => { + const metadata = enhanced( + ctShared(), + positions.map((z) => positioned(`0\\0\\${z}`)) + ); + expect(() => allocate(metadata, 'reject')).toThrow(reason); + const image = allocate(metadata); + expect(image.getDimensions()).toEqual([2, 2, 3]); + image.delete(); + } + ); + + it('rejects changing frame orientation instead of claiming a regular lattice', () => { + const second = positioned('0\\0\\1'); + second.elements.push( + sequence('00209116', [tags({ '00200037': '0\\1\\0\\1\\0\\0' })]) + ); + expect(() => + allocate(enhanced(ctShared(), [positioned('0\\0\\0'), second]), 'reject') + ).toThrow('unreadable-geometry'); + }); + + it('rejects changing pixel spacing and temporal frames under the regular geometry policy', () => { + const changedSpacing = positioned('0\\0\\1'); + changedSpacing.elements.push( + sequence('00289110', [tags({ '00280030': '2\\2' })]) + ); + expect(() => + allocate( + enhanced(ctShared(), [positioned('0\\0\\0'), changedSpacing]), + 'reject' + ) + ).toThrow('mixed-pixel-spacing'); + const temporal = [0, 1].map((index) => { + const frame = positioned(`0\\0\\${index}`); + frame.elements.push( + sequence('00209111', [tags({ '00209128': String(index + 1) })]) + ); + return frame; + }); + expect(() => allocate(enhanced(ctShared(), temporal), 'reject')).toThrow( + 'unreadable-geometry' + ); + }); + + it('reads OCT in-plane calibration without using optical depth of field as frame separation', () => { + const metadata = enhanced( + groups([ + sequence('00289110', [ + tags({ + '00280030': '.001955034\\.01174168\u0000', + '00180050': '.002', + }), + ]), + ]), + [groups([]), groups([])] + ); + const image = allocate(metadata); + expect(image.getSpacing()).toEqual([0.01174168, 0.001955034, 1]); + image.delete(); + }); + + it('reads exact macro paths without changing raw tags or importing referenced-image geometry', () => { + const metadata = enhanced(ctShared(), [positioned('1\\2\\3')], { + '00280030': '9\\9', + }); + metadata.elements.push( + sequence('00081140', [tags({ '00200037': '0\\1\\0\\0\\0\\1' })]) + ); + const before = JSON.stringify(metadata); + expect(readGeometryFacts(metadata)).toEqual({ + orientation: [-1, 0, 0, 0, 1, 0], + position: [1, 2, 3], + pixelSpacing: [0.388672, 0.388672], + }); + expect(getFrameTag(metadata, '00280030')).toBe('.388672\\.388672'); + expect(JSON.stringify(metadata)).toBe(before); + const rootOnly = tags({ '00280030': '2\\3' }); + rootOnly.elements.push( + sequence('00081140', [tags({ '00200037': '0\\1\\0\\0\\0\\1' })]) + ); + expect(readGeometryFacts(rootOnly).orientation).toBeNull(); + }); +}); + +describe('intra-instance frame planning', () => { + const collectionOf = (metadata: DicomDataset) => + planDicomCollections({ + seriesKey: 'frames', + instances: [readInstanceFacts(metadata)], + }).collections[0]; + + it('rejects shared-plane temporal frames under strict geometry validation', () => { + const shared = ctShared(); + shared.elements.push( + sequence('00209113', [tags({ '00200032': '10\\20\\30' })]) + ); + const metadata = enhanced( + shared, + [1, 2].map((time) => + groups([sequence('00209111', [tags({ '00209128': String(time) })])]) + ) + ); + const collection = collectionOf(metadata); + expect(collection.reconstruction).toMatchObject({ + kind: 'irregular', + slots: [0], + }); + expect(collection.warnings).toContain(UNREADABLE_FRAME_GEOMETRY_WARNING); + expect(() => + allocateImageFromChunks( + [{ metadata } as Chunk], + collection.orientation, + collection.reconstruction, + 'reject' + ) + ).toThrow('irregular DICOM geometry'); + }); + + it('keeps an unreadable frame geometry warning for a lone multiframe source', () => { + const metadata = enhanced( + ctShared(), + [1, 2].map((index) => + groups([sequence('00209111', [tags({ '00209057': String(index) })])]) + ) + ); + const collection = collectionOf(metadata); + expect(collection.reconstruction).toMatchObject({ + kind: 'irregular', + reason: 'unreadable-geometry', + slots: [0], + }); + expect(collection.warnings).toContain(UNREADABLE_FRAME_GEOMETRY_WARNING); + }); + + it.each([ + [[0, 1, 3], 'uneven-slice-spacing', IRREGULAR_VOLUME_WARNING], + [[0, 0, 2], 'repeated-position', REPEATED_POSITIONS_WARNING], + ] as const)( + 'reports intra-instance geometry %s while retaining one source slot', + async (positions, reason, warning) => { + const metadata = enhanced( + ctShared(), + positions.map((z) => positioned(`0\\0\\${z}`)) + ); + const collection = collectionOf(metadata); + expect(collection.reconstruction).toMatchObject({ + kind: 'irregular', + reason, + slots: [0], + }); + expect(collection.warnings).toContain(warning); + const volume = new VtkDicomImage({ + readDicomImage: async () => ({ + image: { + size: [2, 2, 3], + data: new Int32Array(12), + imageType: { components: 1 }, + }, + }), + }); + const done = completed(volume); + await volume.setChunks( + [await loadedMetadata(metadata)], + collection.orientation, + { + kind: 'regular', + sliceSpacing: null, + slots: [0], + } + ); + await done; + expect(volume.getReconstruction()).toEqual(collection.reconstruction); + expect(volume.getVtkImageData().getDimensions()).toEqual([2, 2, 3]); + volume.dispose(); + } + ); +}); + +function nmMetadata(overrides: Record = {}) { + const metadata = tags({ + '00080016': '1.2.840.10008.5.1.4.1.1.20', + '00080008': 'ORIGINAL\\PRIMARY\\RECON TOMO\\EMISSION', + '00280010': '2', + '00280011': '2', + '00280008': '3', + '00280101': '16', + '00280030': '4.7951998710632\\4.7951998710632', + '00180088': '-4.7951998710632', + '00540021': '1', + '00540081': '3', + '00540080': '1\\2\\3', + ...overrides, + }); + metadata.elements.push( + sequence('00540022', [ + tags({ + '00200032': '4\\5\\6', + '00200037': '1\\0\\0\\0\\1\\0', + }), + ]) + ); + metadata.elements.push({ + tag: '00280009', + group: 0x0028, + element: 0x0009, + vr: 'AT', + values: { + kind: 'attributeTag', + values: [{ group: 0x0054, element: 0x0080 }], + }, + }); + return metadata; +} + +describe('reconstructed NM spatial volumes', () => { + it.each([-1, 1])( + 'uses the detector plane and signed NM slice progression %s', + (sign) => { + const image = allocate( + nmMetadata({ '00180088': String(sign * 4.7951998710632) }), + 'reject' + ); + expect(image.getSpacing()).toEqual([ + 4.7951998710632, 4.7951998710632, 4.7951998710632, + ]); + expect(image.getOrigin()).toEqual([4, 5, 6]); + expect(image.indexToWorld([0, 0, 2])[2]).toBeCloseTo( + 6 + sign * 2 * 4.7951998710632, + 10 + ); + image.delete(); + } + ); + + it('retains one NM detector plane when Type 2 slice spacing is unknown', () => { + const metadata = nmMetadata({ + '00280008': '1', + '00540081': '1', + '00540080': '1', + '00180088': '', + }); + const image = allocate(metadata, 'reject'); + expect(image.getOrigin()).toEqual([4, 5, 6]); + expect(image.getSpacing()).toEqual([4.7951998710632, 4.7951998710632, 1]); + expect(image.getDirection()).toEqual([1, 0, 0, 0, 1, 0, 0, 0, 1]); + image.delete(); + }); + + it('rejects a gated acquisition instead of treating time slots as additional slices', () => { + const metadata = nmMetadata({ + '00080008': 'ORIGINAL\\PRIMARY\\RECON GATED TOMO\\EMISSION', + '00280008': '264', + '00540081': '33', + '00540071': '8', + }); + expect(() => allocate(metadata)).toThrow( + 'Unsupported NM volume with multiple spatial/time dimensions' + ); + }); + + it.each(['1\\3\\2', '1\\2\\2', '1\\2'])( + 'rejects nonsequential NM frame ordering %s', + (indices) => { + expect(() => allocate(nmMetadata({ '00540080': indices }))).toThrow( + 'slice indices' + ); + } + ); + + it('rejects hidden nonspatial indices even when the frame count matches the slice count', () => { + expect(() => allocate(nmMetadata({ '00540070': '1\\1\\2' }))).toThrow( + 'multiple spatial/time dimensions' + ); + }); + + it('does not infer a spatial volume from multiple detectors or unrelated nested coordinates', () => { + expect(() => allocate(nmMetadata({ '00540021': '2' }))).toThrow( + 'detectors' + ); + const metadata = nmMetadata(); + metadata.elements = metadata.elements.filter( + (element) => element.tag !== '00540022' + ); + metadata.elements.push( + sequence('00081140', [ + tags({ + '00200032': '1\\2\\3', + '00200037': '1\\0\\0\\0\\1\\0', + }), + ]) + ); + expect(readGeometryFacts(metadata).position).toBeNull(); + expect(() => allocate(metadata)).toThrow('detectors'); + }); +}); + +it('does not report normal segment-major SEG frames as an irregular volume', () => { + const metadata = enhanced( + ctShared(), + [0, 1, 0, 1].map((z) => positioned('0\\0\\' + z)), + { '00080060': 'SEG' } + ); + const plan = planDicomCollections({ + seriesKey: 'seg', + instances: [readInstanceFacts(metadata)], + }); + expect(plan.collections[0].warnings).toEqual([]); +}); + +it('reports a missing NM NumberOfSlices without claiming temporal dimensions', () => { + const metadata = nmMetadata(); + metadata.elements = metadata.elements.filter((el) => el.tag !== '00540081'); + expect(() => allocate(metadata)).toThrow('without NumberOfSlices'); +}); diff --git a/packages/dicom/vtk/allocateImageFromChunks.ts b/packages/dicom/vtk/allocateImageFromChunks.ts new file mode 100644 index 000000000..bb43d875d --- /dev/null +++ b/packages/dicom/vtk/allocateImageFromChunks.ts @@ -0,0 +1,402 @@ +import { + Chunk, + DicomDataset, + getDatasetTag, + getChunkTag, + readGeometryFacts, + sliceNormalOf, + type Reconstruction, + multiframeGeometry, + declaredSpacing, + physicalSpacing, + readPixelSpacing, + type SpatialMetadata, +} from '../core/index'; +import { getFrameTag } from '../core/metadata/functionalGroups'; +import { numberOr } from '../core/utils'; +import { nmGeometry } from './nmGeometry'; +import { readModalityRescale } from '../core/metadata/modalityRescale'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { Vector3 } from '@kitware/vtk.js/types'; +import { mat3 } from 'gl-matrix'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; + +const SpacingBetweenSlicesTag = '00180088'; +const RowsTag = '00280010'; +const ColumnsTag = '00280011'; +const BitsStoredTag = '00280101'; +const PixelRepresentationTag = '00280103'; +const SamplesPerPixelTag = '00280002'; +const NumberOfFrames = '00280008'; + +function isPositiveFiniteNumber(value: number) { + return Number.isFinite(value) && value > 0; +} + +function getBitStorageSize(num: number, signed: boolean) { + const addSignedBit = signed ? 1 : 0; + const val = num < 0 ? -num : num + 1; // range shift for log2 + const nbits = Math.ceil(Math.log2(val) + addSignedBit); + // round up to a word size + return 2 ** Math.ceil(Math.log2(nbits)); +} + +type TypedArrayConstructor = + | typeof Int8Array + | typeof Uint8Array + | typeof Int16Array + | typeof Uint16Array + | typeof Int32Array + | typeof Uint32Array; + +const TYPED_ARRAY_VALUE_RANGES = new Map< + TypedArrayConstructor, + { min: number; max: number } +>([ + [Int8Array, { min: -(2 ** 7), max: 2 ** 7 - 1 }], + [Uint8Array, { min: 0, max: 2 ** 8 - 1 }], + [Int16Array, { min: -(2 ** 15), max: 2 ** 15 - 1 }], + [Uint16Array, { min: 0, max: 2 ** 16 - 1 }], + [Int32Array, { min: -(2 ** 31), max: 2 ** 31 - 1 }], + [Uint32Array, { min: 0, max: 2 ** 32 - 1 }], +]); + +/** + * The values a buffer of the given element type can hold without wrapping. + * Undefined for element types the allocator never produces, floats included. + */ +export function getTypedArrayValueRange(ctor: unknown) { + return TYPED_ARRAY_VALUE_RANGES.get(ctor as TypedArrayConstructor); +} + +/** + * The values `buffer` can hold without wrapping, or undefined if its element + * type has no fixed integer range to enforce. + */ +export function getBufferValueRange(buffer: ArrayBufferView) { + return getTypedArrayValueRange(buffer.constructor); +} + +/** + * Whether a decoded value range is representable in `buffer`'s element type. + * A buffer with no enforceable range accepts everything. + */ +export function valuesFitBuffer( + range: { min: number; max: number }, + buffer: ArrayBufferView +) { + const bufferRange = getBufferValueRange(buffer); + if (!bufferRange) return true; + return range.min >= bufferRange.min && range.max <= bufferRange.max; +} + +/** + * Whether every sample is a whole number, which an integer-element buffer + * needs. Integer typed arrays hold nothing else, so only float or plain + * arrays are scanned. A buffer with no integer range accepts everything. + */ +export function samplesAreIntegral( + values: ArrayLike, + buffer: ArrayBufferView +) { + if (!getBufferValueRange(buffer)) return true; + if ( + ArrayBuffer.isView(values) && + !(values instanceof Float32Array) && + !(values instanceof Float64Array) + ) { + return true; + } + return Array.prototype.every.call(values, (value: number) => + Number.isInteger(value) + ); +} + +export function getTypedArrayForDataRange(min: number, max: number) { + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) + throw new Error('Input must be integers'); + + const isSigned = min < 0; + const nbits = getBitStorageSize( + Math.abs(min) > Math.abs(max) ? min : max, + isSigned + ); + + if (nbits <= 8) return isSigned ? Int8Array : Uint8Array; + if (nbits <= 16) return isSigned ? Int16Array : Uint16Array; + if (nbits <= 32) return isSigned ? Int32Array : Uint32Array; + + throw new Error(`Cannot handle ${nbits}-bit sized ranges`); +} + +/** + * The tags that decide what values an instance decodes to. + * + * A bitsStored of 0 means the tag was absent or unparseable. It is not a DICOM + * default: BitsStored is Type 1, so a conforming instance always carries one. + */ +export function getPixelFormat( + metadata: DicomDataset | null | undefined, + frameIndex = 0 +) { + const valueOf = (tag: string) => getFrameTag(metadata, tag, frameIndex); + return { + bitsStored: numberOr(valueOf(BitsStoredTag), 0), + pixelRepresentation: numberOr(valueOf(PixelRepresentationTag), 0), + ...readModalityRescale(metadata, frameIndex), + }; +} + +type PixelFormat = ReturnType; + +/** + * The values an instance can decode to. ITK/GDCM applies RescaleSlope and + * RescaleIntercept while decoding, so this is the range after rescaling. + * + * Null when BitsStored is missing, since nothing can be derived without it. + */ +export function getRescaledValueRange(format: PixelFormat) { + const { bitsStored, pixelRepresentation, rescaleSlope, rescaleIntercept } = + format; + if (!Number.isInteger(bitsStored) || bitsStored <= 0) return null; + + const isSigned = pixelRepresentation === 1; + const storedMin = isSigned ? -(2 ** (bitsStored - 1)) : 0; + const storedMax = 2 ** (bitsStored - (isSigned ? 1 : 0)) - 1; + const a = storedMin * rescaleSlope + rescaleIntercept; + const b = storedMax * rescaleSlope + rescaleIntercept; + + return { min: Math.min(a, b), max: Math.max(a, b) }; +} + +/** + * The element type a volume allocated from instances of these formats holds. + * + * An instance whose tags give no finite range is left out of the union and + * judged on its decoded values by the containment guard instead. + */ +export function getVolumeBufferType(formats: PixelFormat[]) { + const usable = formats.flatMap((format) => { + const range = getRescaledValueRange(format); + return range && Number.isFinite(range.min) && Number.isFinite(range.max) + ? [{ format, range }] + : []; + }); + if (usable.length === 0) + throw new Error('No instance declares a finite modality rescale range'); + + const needsFloat64 = usable.some( + ({ format, range }) => + !Number.isInteger(format.rescaleSlope) || + !Number.isInteger(format.rescaleIntercept) || + !Number.isSafeInteger(range.min) || + !Number.isSafeInteger(range.max) + ); + if (needsFloat64) return Float64Array; + + const min = Math.min(...usable.map(({ range }) => range.min)); + const max = Math.max(...usable.map(({ range }) => range.max)); + const exceedsSigned32 = min < 0 && (min < -(2 ** 31) || max > 2 ** 31 - 1); + const exceedsUnsigned32 = min >= 0 && max > 2 ** 32 - 1; + if (exceedsSigned32 || exceedsUnsigned32) return Float64Array; + + return getTypedArrayForDataRange(min, max); +} + +function instancePixelFormats(chunk: Chunk) { + const formats = Array.from( + { length: numberOr(getDatasetTag(chunk.metadata, NumberOfFrames), 1) }, + (_, index) => getPixelFormat(chunk.metadata, index) + ); + return formats; +} + +export function allocateImageFromChunks( + sortedChunks: Chunk[], + orientation: number[] | null, + reconstruction: Reconstruction, + irregularGeometry: IrregularGeometryPolicy = 'preserve-order' +) { + if (sortedChunks.length === 0) { + throw new Error('Cannot allocate an image from zero chunks'); + } + + assertRegularGeometry(reconstruction, irregularGeometry); + assertSliceSlots(reconstruction.slots, sortedChunks.length); + const { rows, columns, slices, samplesPerPixel } = allocationShape( + sortedChunks, + reconstruction + ); + const geometry = allocationGeometry( + sortedChunks, + orientation, + reconstruction, + irregularGeometry + ); + const { position, direction } = geometry; + const spatialMetadata = allocationSpatialMetadata(sortedChunks, geometry); + const TypedArrayCtor = getVolumeBufferType( + sortedChunks.flatMap(instancePixelFormats) + ); + const pixelData = new TypedArrayCtor( + rows * columns * slices * samplesPerPixel + ); + + const image = vtkImageData.newInstance(); + image.setExtent([0, columns - 1, 0, rows - 1, 0, slices - 1]); + + if (position) image.setOrigin(position as Vector3); + + image.setSpacing(spatialMetadata.axes.map((axis) => axis.value)); + + if (direction) image.setDirection(direction as mat3); + + const dataArray = vtkDataArray.newInstance({ + numberOfComponents: samplesPerPixel, + values: pixelData, + }); + image.getPointData().setScalars(dataArray); + + // Needed for volume rendering to work at start + dataArray.setRange({ min: 0, max: 255 }, 0); + + return { image, spatialMetadata }; +} + +/** Preserve acquisition order and its fallback spacing, or require a regular lattice. */ +export type IrregularGeometryPolicy = 'preserve-order' | 'reject'; + +function assertRegularGeometry( + reconstruction: Reconstruction, + policy: IrregularGeometryPolicy +) { + if (reconstruction.kind === 'irregular' && policy === 'reject') + throw new Error( + `Cannot allocate irregular DICOM geometry: ${reconstruction.reason}` + ); +} + +const instanceGeometry = (metadata: DicomDataset, count: number) => + nmGeometry(metadata) ?? multiframeGeometry(metadata, count); + +function allocationDirection( + orientation: number[] | null, + frameDirection?: number[] | null +) { + const normal = frameDirection ?? sliceNormalOf(orientation); + return orientation && normal ? [...orientation, ...normal] : null; +} + +function allocationGeometry( + chunks: Chunk[], + orientation: number[] | null, + reconstruction: Reconstruction, + policy: IrregularGeometryPolicy +) { + const metadata = chunks[0].metadata!; + const count = numberOr(getDatasetTag(metadata, NumberOfFrames), 1); + const frames = chunks.length === 1 ? instanceGeometry(metadata, count) : null; + const geometry = frames ?? readGeometryFacts(metadata); + const plan = frames?.reconstruction ?? reconstruction; + assertRegularGeometry(plan, policy); + const effectiveOrientation = orientation ?? geometry.orientation; + return { + ...geometry, + reconstruction: plan, + sliceSpacingSource: frames?.sliceSpacingSource ?? 'ImagePositionPatient', + direction: allocationDirection(effectiveOrientation, frames?.direction), + }; +} + +function assertSliceSlots(slots: number[], count: number) { + if ( + slots.length !== count || + new Set(slots).size !== count || + slots.some((slot) => !Number.isSafeInteger(slot) || slot < 0) || + (count > 0 && slots[0] !== 0) + ) + throw new Error( + 'Reconstruction must map each chunk to one distinct nonnegative slice slot, starting at zero' + ); +} + +function allocationShape(chunks: Chunk[], reconstruction: Reconstruction) { + const valueOf = (tag: string) => getChunkTag(chunks[0], tag); + const numberOfFrames = numberOr(valueOf(NumberOfFrames), 1); + if (numberOfFrames > 1 && chunks.length > 1) + throw new Error( + 'First chunk in a group of chunks (size > 1) is a multi-frame chunk' + ); + return { + rows: numberOr(valueOf(RowsTag), 0), + columns: numberOr(valueOf(ColumnsTag), 0), + samplesPerPixel: numberOr(valueOf(SamplesPerPixelTag), 1), + slices: + chunks.length > 1 + ? Math.max(...reconstruction.slots) + 1 + : numberOfFrames, + }; +} + +function allocationNotes( + chunks: Chunk[], + geometry: ReturnType +) { + const notes = []; + if (!geometry.position) + notes.push( + 'Patient position is unavailable; the display uses a zero origin.' + ); + if (!geometry.direction) + notes.push( + 'Patient orientation is unavailable; the display uses default axes.' + ); + if ( + chunks.length > 1 && + chunks.some((chunk) => + readPixelSpacing(chunk.metadata).some( + (axis) => axis.status === 'fallback' + ) + ) + ) + notes.push( + 'Some slices lack usable PixelSpacing; the displayed in-plane grid uses the first slice.' + ); + return notes; +} + +function allocationCalibration(metadata: DicomDataset | null | undefined) { + const type = getDatasetTag(metadata, '00280A02')?.trim(); + const description = getDatasetTag(metadata, '00280A04')?.trim(); + return type || description ? { calibration: { type, description } } : {}; +} + +function allocationSpatialMetadata( + chunks: Chunk[], + geometry: ReturnType +): SpatialMetadata { + const metadata = chunks[0].metadata; + const { reconstruction, sliceSpacingSource } = geometry; + const sliceSpacing = reconstruction.sliceSpacing ?? 0; + const z = isPositiveFiniteNumber(sliceSpacing) + ? physicalSpacing( + sliceSpacing, + sliceSpacingSource, + sliceSpacingSource === 'ImagePositionPatient' ? 'derived' : 'declared' + ) + : declaredSpacing( + getFrameTag(metadata, SpacingBetweenSlicesTag), + 'SpacingBetweenSlices' + ); + return { + axes: [...readPixelSpacing(metadata), z], + reconstruction: { + kind: reconstruction.kind, + ...(reconstruction.kind === 'irregular' + ? { reason: reconstruction.reason } + : {}), + }, + notes: allocationNotes(chunks, geometry), + ...allocationCalibration(metadata), + }; +} diff --git a/packages/dicom/vtk/index.ts b/packages/dicom/vtk/index.ts new file mode 100644 index 000000000..c31559bdc --- /dev/null +++ b/packages/dicom/vtk/index.ts @@ -0,0 +1,4 @@ +export { VtkDicomImage } from './VtkDicomImage'; +export * from './types'; +export * from './allocateImageFromChunks'; +export * from './thumbnail'; diff --git a/packages/dicom/vtk/lifetime.ts b/packages/dicom/vtk/lifetime.ts new file mode 100644 index 000000000..ab81fce7f --- /dev/null +++ b/packages/dicom/vtk/lifetime.ts @@ -0,0 +1,11 @@ +/** Settle a consumer operation when its owner cancels, without transferring ownership of the work. */ +export function untilAborted(operation: Promise, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason); + signal.addEventListener('abort', aborted, { once: true }); + operation + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', aborted)); + if (signal.aborted) aborted(); + }); +} diff --git a/packages/dicom/vtk/nmGeometry.ts b/packages/dicom/vtk/nmGeometry.ts new file mode 100644 index 000000000..fd743ef3d --- /dev/null +++ b/packages/dicom/vtk/nmGeometry.ts @@ -0,0 +1,32 @@ +import { + DicomDataset, + readGeometryFacts, + sliceNormalOf, + type Reconstruction, +} from '../core/index'; +import { readNmReconstruction } from '../core/metadata/nmReconstruction'; + +export function nmGeometry(metadata: DicomDataset) { + const nm = readNmReconstruction(metadata); + if (!nm) return null; + if ('error' in nm) throw new Error(nm.error); + const geometry = readGeometryFacts(metadata); + const normal = sliceNormalOf(geometry.orientation); + if (!normal || !geometry.position || !geometry.pixelSpacing) + throw new Error( + 'Cannot allocate NM volume without readable patient geometry' + ); + const step = nm.sliceSpacing || 1; + const direction = normal.map((value) => value * Math.sign(step)); + const reconstruction: Reconstruction = { + kind: 'regular', + sliceSpacing: nm.sliceSpacing ? Math.abs(nm.sliceSpacing) : null, + slots: [0], + }; + return { + ...geometry, + direction, + reconstruction, + sliceSpacingSource: 'SpacingBetweenSlices', + }; +} diff --git a/packages/dicom/vtk/pixelValidation.ts b/packages/dicom/vtk/pixelValidation.ts new file mode 100644 index 000000000..92707a02c --- /dev/null +++ b/packages/dicom/vtk/pixelValidation.ts @@ -0,0 +1,90 @@ +import type { TypedArray } from '@kitware/vtk.js/types'; +import type { DecodedChunkImage } from './types'; +import { + getBufferValueRange, + valuesFitBuffer, + samplesAreIntegral, +} from './allocateImageFromChunks'; + +export function assertSamplesRepresentable( + decoded: ArrayLike, + range: { min: number; max: number }, + buffer: TypedArray, + where: string +) { + if (!valuesFitBuffer(range, buffer)) { + const bufferRange = getBufferValueRange(buffer)!; + throw new Error( + `${where} has pixel values the volume it belongs to cannot represent. ` + + `Its pixel values run from ${range.min} to ${range.max}, but the volume's buffer is ` + + `${buffer.constructor.name}, holding values from ${bufferRange.min} to ${bufferRange.max}. ` + + `Every file in a volume must decode to values its buffer can hold without conversion.` + ); + } + if (!samplesAreIntegral(decoded, buffer)) { + throw new Error( + `${where} has fractional pixel values the volume it belongs to cannot represent. ` + + `Its pixel values run from ${range.min} to ${range.max}, but the volume's buffer is ` + + `${buffer.constructor.name}, which holds only whole numbers. ` + + `Every file in a volume must decode to values its buffer can hold without conversion.` + ); + } +} + +export function assertSingleFramePerFile( + frames: number, + count: number, + where: string +) { + if (frames > 1 && count > 1) { + // we're trying to load multiple chunks where individual chunks have multiple frames + throw new Error( + `Loading a single volume from multiple DICOM files where individual files contain multiple frames is not supported. ` + + `${where} contains ${frames} frames.` + ); + } +} + +// Each chunk gets a fixed slot: one frame per chunk in a multi-file volume, +// or the whole volume when a single multi-frame chunk fills it. A decoded +// chunk has to fill its slot exactly. +export function assertChunkFitsSlot( + image: DecodedChunkImage, + volume: { dims: number[]; componentCount: number; count: number }, + where: string +) { + const { dims, componentCount, count } = volume; + const multiFile = count > 1; + const framesPerChunk = multiFile ? 1 : dims[2]; + const [chunkWidth, chunkHeight] = image.size; + const chunkFrames = image.size[2] ?? 1; + const chunkComponents = image.imageType.components; + assertSampleCount(image, where); + if ( + chunkWidth !== dims[0] || + chunkHeight !== dims[1] || + chunkFrames !== framesPerChunk || + chunkComponents !== componentCount + ) { + // A lone chunk defines the volume it fails to fit, so advice about + // agreeing with the other files only makes sense for a multi-file volume. + const advice = multiFile + ? ' Every file in a volume must have the same Rows, Columns, and SamplesPerPixel.' + : ''; + throw new Error( + `${where} does not fit the volume it belongs to. ` + + `It decoded to ${chunkWidth}x${chunkHeight}x${chunkFrames} with ${chunkComponents} component(s), ` + + `but the volume has room for ${dims[0]}x${dims[1]}x${framesPerChunk} with ${componentCount} component(s).` + + advice + ); + } +} + +function assertSampleCount(image: DecodedChunkImage, where: string) { + const [width, height, frames = 1] = image.size; + const expected = width * height * frames * image.imageType.components; + if (image.data?.length !== expected) + throw new Error( + `${where} decoded ${image.data?.length ?? 0} samples; expected ${expected}` + ); +} diff --git a/packages/dicom/vtk/thumbnail.ts b/packages/dicom/vtk/thumbnail.ts new file mode 100644 index 000000000..5bb404a90 --- /dev/null +++ b/packages/dicom/vtk/thumbnail.ts @@ -0,0 +1,76 @@ +/** An 8 bit greyscale slice, small enough to encode as a thumbnail. */ +export type ThumbnailSlice = { + width: number; + height: number; + data: Uint8Array; +}; + +export type SliceToThumbnailInput = { + data: ArrayLike; + width: number; + height: number; + // Samples per pixel; only the first component is read. + components?: number; + // The window mapped onto 0..255. + range: readonly [number, number]; + maxSize?: number; +}; + +export const THUMBNAIL_MAX_SIZE = 256; + +const BYTE_MAX = 255; + +/** Downsamples one slice and casts it to 8 bit greyscale. */ +export function sliceToThumbnail({ + data, + width, + height, + components = 1, + range, + maxSize = THUMBNAIL_MAX_SIZE, +}: SliceToThumbnailInput): ThumbnailSlice { + const [low, high] = range; + const span = high - low; + + // One integer stride on both axes, so the aspect ratio survives. + const stride = Math.max(1, Math.ceil(Math.max(width, height) / maxSize)); + const thumbWidth = Math.ceil(width / stride); + const thumbHeight = Math.ceil(height / stride); + + const toByte = (sample: number) => { + if (!Number.isFinite(sample) || span <= 0) return 0; + const scaled = Math.round(((sample - low) / span) * BYTE_MAX); + return Math.min(BYTE_MAX, Math.max(0, scaled)); + }; + + const thumbData = Uint8Array.from( + { length: thumbWidth * thumbHeight }, + (_, index) => { + const column = (index % thumbWidth) * stride; + const row = Math.floor(index / thumbWidth) * stride; + return toByte(data[(row * width + column) * components]); + } + ); + + return { width: thumbWidth, height: thumbHeight, data: thumbData }; +} + +/** Encodes a thumbnail slice as a PNG data URI. */ +export function encodeThumbnailToUri(slice: ThumbnailSlice) { + const { width, height, data } = slice; + const image = new ImageData(width, height); + const pixels = new Uint32Array(image.data.buffer); + // ABGR order. + data.forEach((byte, index) => { + pixels[index] = (BYTE_MAX << 24) | (byte << 16) | (byte << 8) | byte; + }); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) return ''; + + context.putImageData(image, 0, 0); + return canvas.toDataURL('image/png'); +} diff --git a/packages/dicom/vtk/thumbnailSource.ts b/packages/dicom/vtk/thumbnailSource.ts new file mode 100644 index 000000000..90566e992 --- /dev/null +++ b/packages/dicom/vtk/thumbnailSource.ts @@ -0,0 +1,59 @@ +import { Chunk } from '../core/index'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import type { TypedArray } from '@kitware/vtk.js/types'; +import type { VtkDicomImageInit } from './types'; +import { sliceToThumbnail } from './thumbnail'; +import { untilAborted } from './lifetime'; + +const { fastComputeRange } = vtkDataArray; +export function sliceFromBuffer(image: vtkImageData, slot: number) { + const scalars = image.getPointData().getScalars(); + const components = scalars.getNumberOfComponents(); + const [width, height] = image.getDimensions(); + const samplesPerSlice = width * height * components; + const data = scalars.getData(); + + return sliceToThumbnail({ + data: data.subarray(slot * samplesPerSlice, (slot + 1) * samplesPerSlice), + width, + height, + components, + // The thumbnail reads component 0, so it windows on that component's + // range, not the multi-component vector magnitude range. + range: scalars.getRange(0) as [number, number], + }); +} + +// Before a chunk's slot holds pixels, its own bytes are the only source. +export async function sliceFromChunk( + chunk: Chunk, + read: VtkDicomImageInit['readDicomImage'], + signal: AbortSignal +) { + await untilAborted(chunk.loadData(), signal); + const loaded = chunk; + if (!loaded.dataBlob) throw new Error('No chunk data'); + + const { image } = await untilAborted( + read( + new File([loaded.dataBlob], 'thumbnail.dcm'), + loaded.metadata ?? undefined + ), + signal + ); + if (!image.data) throw new Error('No data read from chunk'); + + const [width, height] = image.size; + const components = image.imageType.components; + const data = image.data as TypedArray; + const { min, max } = fastComputeRange(data, 0, components); + + return sliceToThumbnail({ + data, + width, + height, + components, + range: [min, max], + }); +} diff --git a/packages/dicom/vtk/types.ts b/packages/dicom/vtk/types.ts new file mode 100644 index 000000000..7d549bb93 --- /dev/null +++ b/packages/dicom/vtk/types.ts @@ -0,0 +1,49 @@ +import type { Image } from '@itk-wasm/image-io'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { Extent } from '@kitware/vtk.js/types'; +import type { Chunk, DicomDataset } from '../core/index'; +import type { IrregularGeometryPolicy } from './allocateImageFromChunks'; +import type { ThumbnailSlice } from './thumbnail'; + +export enum ChunkStatus { + NotLoaded, + Loading, + Loaded, + Errored, +} + +export type ProgressiveImageStatus = 'complete' | 'incomplete'; +export type ChunkLoadedInfo = { updatedExtent: Extent; chunk: Chunk }; +export type ChunkErrorInfo = { error: unknown; chunk: Chunk }; +export type ChunkImageEvents = { + chunkLoad: ChunkLoadedInfo; + chunkError: ChunkErrorInfo; + imageChanged: vtkImageData; + status: ProgressiveImageStatus; + loading: boolean; + error: Error; +}; + +export type VtkDicomImageInit = { + /** Decoded buffers are borrowed for one write, never retained or transferred. */ + readDicomImage: ( + file: File, + metadata?: DicomDataset + ) => Promise<{ + image: Pick & { + imageType: Pick; + }; + }>; + /** A returned image becomes owned by this consumer, including stale results. */ + readSegmentation?: (file: File) => Promise<{ + image: vtkImageData; + metadata: unknown; + }>; + encodeThumbnail?: (slice: ThumbnailSlice) => string; + warn?: (title: string, details: string) => void; + irregularGeometry?: IrregularGeometryPolicy; +}; + +export type DecodedChunkImage = Awaited< + ReturnType +>['image']; diff --git a/packages/dicom/vtk/ultrasoundSpacing.ts b/packages/dicom/vtk/ultrasoundSpacing.ts new file mode 100644 index 000000000..c93cd1a4b --- /dev/null +++ b/packages/dicom/vtk/ultrasoundSpacing.ts @@ -0,0 +1,91 @@ +import { + type Chunk, + getChunkTag, + unitToMm, + physicalSpacing, + type SpatialMetadata, +} from '../core/index'; +import type { allocateImageFromChunks } from './allocateImageFromChunks'; + +const isUsableSpacing = (value: number) => + Number.isFinite(value) && value !== 0; + +const regionNotes = (count: number) => + count > 1 + ? [ + 'Multiple ultrasound regions are present; only the first region supplies the display spacing.', + ] + : []; + +export function applyUltrasoundSpacing( + allocation: ReturnType, + chunks: Chunk[], + warn: (title: string, details: string) => void +): SpatialMetadata { + const { image, spatialMetadata } = allocation; + if (getChunkTag(chunks[0], '00080060')?.trim() !== 'US') + return spatialMetadata; + + // Ultrasound DICOMs are typically a single multi-frame chunk, so the + // region table on chunk[0] applies to the whole image. If a US series + // ever spanned multiple chunks with differing regions, this would + // silently use the first chunk's spacing for all of them. + const regions = chunks[0]?.ultrasoundRegions; + if (!regions?.region) return spatialMetadata; + + warnMultipleRegions(regions.regionCount); + + const { region } = regions; + const notes = [...spatialMetadata.notes, ...regionNotes(regions.regionCount)]; + const xFactor = unitToMm(region.physicalUnitsXDirection); + const yFactor = unitToMm(region.physicalUnitsYDirection); + // All-or-nothing: if either axis lacks a spatial unit (e.g. one axis is + // cm and the other is seconds, or unitless) the metadata can't be trusted + // as a 2D physical spacing, so leave the default 1mm fallback in place. + if (xFactor === null || yFactor === null) { + console.warn( + `Ultrasound spacing not applied: PhysicalUnitsXDirection=${region.physicalUnitsXDirection}, PhysicalUnitsYDirection=${region.physicalUnitsYDirection}; only code 3 (cm) is converted to mm.` + ); + notes.push( + 'The first ultrasound region does not declare two spatial axes; its calibration is not applied.' + ); + return { ...spatialMetadata, notes }; + } + + const spacingX = region.physicalDeltaX * xFactor; + const spacingY = region.physicalDeltaY * yFactor; + if (!isUsableSpacing(spacingX) || !isUsableSpacing(spacingY)) { + warn( + 'Invalid ultrasound calibration', + `The ultrasound region declares PhysicalDeltaX=${region.physicalDeltaX} and PhysicalDeltaY=${region.physicalDeltaY}. Measurements use the image pixel spacing instead.` + ); + notes.push( + 'The first ultrasound region has unusable physical deltas; its calibration is not applied.' + ); + return { ...spatialMetadata, notes }; + } + + const [, , zSpacing] = image.getSpacing(); + image.setSpacing([spacingX, spacingY, zSpacing]); + notes.push( + 'Ultrasound physical spacing applies within the calibrated region, not necessarily the entire image.' + ); + return { + ...spatialMetadata, + axes: [ + physicalSpacing(spacingX, 'Ultrasound region (cm converted to mm)'), + physicalSpacing(spacingY, 'Ultrasound region (cm converted to mm)'), + spatialMetadata.axes[2], + ], + notes, + }; +} + +function warnMultipleRegions(count: number) { + // vtkImageData has one global spacing even when the acquisition contains + // regions with different physical calibration. + if (count > 1) + console.warn( + `Ultrasound image has ${count} regions; only the first region's physical spacing is applied. Multi-region (e.g. dual-pane B-mode + Doppler) ultrasound is not fully supported.` + ); +} diff --git a/scripts/checks/complexity.mjs b/scripts/checks/complexity.mjs index 977054074..758e00cad 100755 --- a/scripts/checks/complexity.mjs +++ b/scripts/checks/complexity.mjs @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { git, materialize, stagedContext } from './git.mjs'; import { limits as LIMITS } from './config.mjs'; -const SOURCE = /^src\/.*\.(ts|js|vue)$/; +const SOURCE = /^(src|packages\/dicom)\/.*\.(ts|js|vue)$/; const EXCLUDED = /(__tests__|\.(spec|test)\.|\.d\.ts$|emscripten-build)/; const { base, baseRef, changed: entries, worktree } = stagedContext(); diff --git a/src/components/ImageSpacingDetails.vue b/src/components/ImageSpacingDetails.vue new file mode 100644 index 000000000..05da6ab11 --- /dev/null +++ b/src/components/ImageSpacingDetails.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/PatientStudyVolumeBrowser.vue b/src/components/PatientStudyVolumeBrowser.vue index 8f8453489..572d5d60c 100644 --- a/src/components/PatientStudyVolumeBrowser.vue +++ b/src/components/PatientStudyVolumeBrowser.vue @@ -1,5 +1,5 @@ + + diff --git a/tests/fixtures/dicom-consumer/composition.ts b/tests/fixtures/dicom-consumer/composition.ts new file mode 100644 index 000000000..475908e22 --- /dev/null +++ b/tests/fixtures/dicom-consumer/composition.ts @@ -0,0 +1,114 @@ +import { + createDicomCollectionRegistry, + getChunkTag, + type Chunk, +} from '@volview/dicom/core'; +import type { VtkDicomImage } from '@volview/dicom/vtk'; +import corpus from '../../dicomCompositionCorpus.json'; +import { loader, failed, report } from './shared'; +import { loadCollection } from './loadVolume'; +import { describeVolume } from './describeVolume'; + +const registry = createDicomCollectionRegistry(); +const images = new Map(); +const params = new URL(location.href).searchParams; +const selected = corpus.cases.find(({ id }) => id === params.get('case'))!; +const incremental = params.get('mode') === 'incremental'; + +const append = (parent: HTMLElement, name: string, value: string) => { + const output = document.createElement('output'); + output.dataset.field = name; + output.textContent = value; + parent.append(output); +}; + +async function createChunk(name: string) { + const url = new URL( + `./corpus-data/${encodeURIComponent(name)}`, + document.baseURI + ); + const source = + params.get('source') === 'file' + ? new File([await (await fetch(url)).blob()], name) + : url; + const chunk = loader.createChunk(source); + await chunk.loadMeta(); + return chunk; +} + +async function render(status: string) { + document.querySelector('main')!.replaceChildren(); + for (const image of images.values()) { + const section = document.createElement('section'); + section.className = 'volume'; + const members = image + .getChunks() + .map((chunk) => getChunkTag(chunk, '00080018')!.trim()); + section.dataset.firstSop = members[0]; + append(section, 'members', members.join(', ')); + await describeVolume(image, (name, value) => append(section, name, value)); + document.querySelector('main')!.append(section); + } + report('status', status); +} + +async function register(chunks: Chunk[], status: string) { + const { updates, removed } = await registry.register(chunks); + for (const id of removed) { + images.get(id)?.dispose(); + images.delete(id); + } + for (const update of updates) { + images.set(update.id, await loadCollection(update, images.get(update.id))); + } + await render(status); +} + +async function run() { + // Interleave acquisitions/series and reverse their spatial order so neither + // input order nor adjacent file names can stand in for physical ordering. + const names = selected.volumes[0].files + .flatMap((_, index) => + selected.volumes.flatMap(({ files }) => + files[index] ? [files[index]] : [] + ) + ) + .reverse(); + const chunks = await Promise.all(names.map(createChunk)); + const initial = chunks.filter((_, inputIndex) => + selected.volumes.some(({ files }) => { + const spatialIndex = files.indexOf(names[inputIndex]); + return spatialIndex >= 0 && spatialIndex % 2 === 0; + }) + ); + if (!incremental) { + await register(chunks, 'complete'); + return; + } + await register(initial, 'partial'); + const button = document.querySelector('#complete-import')!; + button.disabled = false; + button.addEventListener( + 'click', + () => { + button.disabled = true; + document.querySelector('#status')!.textContent = 'loading'; + const remaining = chunks.filter((chunk) => !initial.includes(chunk)); + register([...remaining, initial[0], initial[1]], 'complete').catch( + failed + ); + }, + { once: true } + ); +} + +window.addEventListener( + 'pagehide', + () => { + images.forEach((image) => image.dispose()); + loader.dispose(); + }, + { once: true } +); + +run().catch(failed); diff --git a/tests/fixtures/dicom-consumer/compositionReference.py b/tests/fixtures/dicom-consumer/compositionReference.py new file mode 100644 index 000000000..f25742efa --- /dev/null +++ b/tests/fixtures/dicom-consumer/compositionReference.py @@ -0,0 +1,77 @@ +"""Check composed-volume expectations using pydicom and NumPy, independently of VolView. + +First download the fixtures with node tests/downloadDatasets.ts. This optional +reference check requires pydicom, NumPy and a JPEG Lossless plugin such as +pylibjpeg-libjpeg. It reads cached inputs and never creates binary fixtures. +""" +import argparse +import hashlib +import json +from pathlib import Path + +import numpy as np +import pydicom + + +def reference(items): + first = items[0] + axes = np.array(first.ImageOrientationPatient, dtype=np.float64).reshape(2, 3) + axes /= np.linalg.norm(axes, axis=1)[:, None] + normal = np.cross(*axes) + ordered = sorted(items, key=lambda ds: np.dot(ds.ImagePositionPatient, normal)) + positions = np.array([ds.ImagePositionPatient for ds in ordered], dtype=np.float64) + projected = positions @ normal + gaps = np.diff(projected) + step = float(np.median(gaps)) + assert step > 0 and np.allclose(gaps, step, atol=1e-4, rtol=0) + assert np.allclose(positions, positions[0] + np.outer(projected - projected[0], normal), atol=1e-4, rtol=0) + assert len({str(ds.SeriesInstanceUID) for ds in ordered}) == 1 + assert len({str(ds.AcquisitionNumber) for ds in ordered}) == 1 + for ds in ordered: + assert int(ds.get('NumberOfFrames', 1)) == 1 + assert np.allclose(ds.ImageOrientationPatient, axes.ravel(), atol=1e-6, rtol=0) + assert np.allclose(ds.PixelSpacing, first.PixelSpacing, atol=1e-6, rtol=0) + pixels = np.stack([ + ds.pixel_array.astype(np.float64) * float(ds.get('RescaleSlope', 1)) + float(ds.get('RescaleIntercept', 0)) + for ds in ordered + ]).astype(' + + + + DICOM core consumer + + + +

DICOM core consumer

+
+
+ + + diff --git a/tests/fixtures/dicom-consumer/core.ts b/tests/fixtures/dicom-consumer/core.ts new file mode 100644 index 000000000..e8d04ae41 --- /dev/null +++ b/tests/fixtures/dicom-consumer/core.ts @@ -0,0 +1,45 @@ +import { createDicomLoader } from '@volview/dicom/core'; +import { describeMetadata, failed, loadChunk, loader, report } from './shared'; +import { fixtureBytes } from './fixtureData'; + +async function recoverWorker() { + const recovering = createDicomLoader({ + assets: { + pipelineWorkerUrl: new URL('./recovering-worker.js', document.baseURI) + .href, + imageIOUrl: new URL('./assets/image-io', document.baseURI).href, + }, + }); + const file = new File( + [fixtureBytes() as Uint8Array], + 'fixture.dcm' + ); + try { + const first = recovering.readDicomImage(file).then( + () => report('first-error', 'unexpected success'), + (error: Error) => report('first-error', error.message) + ); + const queued = recovering.readDicomImage(file); + const [, { image }] = await Promise.all([first, queued]); + report('queued-pixels', Array.from(image.data).join(', ')); + const retry = await recovering.readDicomImage(file); + report('retry-pixels', Array.from(retry.image.data).join(', ')); + report('status', 'complete'); + } finally { + recovering.dispose(); + } +} + +async function run() { + const chunk = await loadChunk(); + describeMetadata(chunk); + await chunk.loadData(); + const { image } = await loader.readDicomImage( + new File([chunk.dataBlob!], 'fixture.dcm') + ); + report('pixels', Array.from(image.data!).join(', ')); + report('dimensions', image.size.join(', ')); + report('status', 'complete'); +} +const scenario = new URL(location.href).searchParams.get('scenario'); +(scenario === 'worker-recovery' ? recoverWorker() : run()).catch(failed); diff --git a/tests/fixtures/dicom-consumer/corpus.html b/tests/fixtures/dicom-consumer/corpus.html new file mode 100644 index 000000000..261355caa --- /dev/null +++ b/tests/fixtures/dicom-consumer/corpus.html @@ -0,0 +1,12 @@ + + + + + DICOM volume corpus consumer + + +

DICOM volume corpus consumer

+
+ + + diff --git a/tests/fixtures/dicom-consumer/corpus.ts b/tests/fixtures/dicom-consumer/corpus.ts new file mode 100644 index 000000000..66d371e0e --- /dev/null +++ b/tests/fixtures/dicom-consumer/corpus.ts @@ -0,0 +1,43 @@ +import { failed, loader, report } from './shared'; +import { loadVolume } from './loadVolume'; +import { describeVolume } from './describeVolume'; +import { VtkDicomImage } from '@volview/dicom/vtk'; + +async function run() { + const params = new URL(location.href).searchParams; + const url = new URL( + `./corpus-data/${encodeURIComponent(params.get('name')!)}`, + document.baseURI + ); + const source = + params.get('source') === 'file' + ? new File([await (await fetch(url)).blob()], 'volume.dcm') + : url; + const chunk = loader.createChunk(source); + const read = + params.get('decoder') === 'custom' + ? async () => ({ + image: { + size: [4, 4, 2], + imageType: { components: 1 }, + data: new Uint16Array([...Array(16).fill(2), ...Array(16).fill(4)]), + }, + }) + : loader.readDicomImage; + const image = new VtkDicomImage({ + readDicomImage: read, + irregularGeometry: + params.get('policy') === 'reject' ? 'reject' : 'preserve-order', + }); + const volume = await loadVolume(chunk, image); + try { + await describeVolume(volume, report); + report('reconstruction', volume.getReconstruction()!.kind); + report('status', 'complete'); + } finally { + volume.dispose(); + loader.dispose(); + } +} + +run().catch(failed); diff --git a/tests/fixtures/dicom-consumer/describeVolume.ts b/tests/fixtures/dicom-consumer/describeVolume.ts new file mode 100644 index 000000000..634e1e380 --- /dev/null +++ b/tests/fixtures/dicom-consumer/describeVolume.ts @@ -0,0 +1,29 @@ +import type { VtkDicomImage } from '@volview/dicom/vtk'; + +/** Public consumer output used to check geometry and every calibrated voxel. */ +export async function describeVolume( + volume: VtkDicomImage, + report: (name: string, value: string) => void +) { + const image = volume.getVtkImageData(); + const dimensions = image.getDimensions(); + const scalars = image.getPointData().getScalars(); + report('dimensions', dimensions.join(', ')); + report('spacing', image.getSpacing().join(', ')); + report('origin', image.getOrigin().join(', ')); + report('direction', Array.from(image.getDirection()).join(', ')); + report('range', scalars.getRange().join(', ')); + report('first-plane', image.indexToWorld([0, 0, 0]).join(', ')); + report( + 'last-plane', + image.indexToWorld([0, 0, dimensions[2] - 1]).join(', ') + ); + const pixels = Float64Array.from(scalars.getData()); + const digest = await crypto.subtle.digest('SHA-256', pixels.buffer); + report( + 'pixel-hash', + Array.from(new Uint8Array(digest), (value) => + value.toString(16).padStart(2, '0') + ).join('') + ); +} diff --git a/tests/fixtures/dicom-consumer/enhancedFixtures.ts b/tests/fixtures/dicom-consumer/enhancedFixtures.ts new file mode 100644 index 000000000..3b7631f1a --- /dev/null +++ b/tests/fixtures/dicom-consumer/enhancedFixtures.ts @@ -0,0 +1,100 @@ +import { + buildSyntheticDicom, + rawElement, + sequenceItem, +} from '../../specs/syntheticDicom'; + +const concatenate = (parts: Uint8Array[]) => { + const bytes = new Uint8Array( + parts.reduce((length, part) => length + part.length, 0) + ); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.length; + } + return bytes; +}; + +const text = (group: number, element: number, value: readonly number[]) => { + const string = value.join('\\'); + return rawElement( + group, + element, + 'DS', + new TextEncoder().encode(string.length % 2 ? `${string} ` : string) + ); +}; + +const sequence = (group: number, element: number, items: Uint8Array[]) => + rawElement(group, element, 'SQ', concatenate(items.map(sequenceItem))); + +const position = (value: readonly number[]) => + sequence(0x0020, 0x9113, [text(0x0020, 0x0032, value)]); + +const transformation = ([slope, intercept]: readonly [number, number]) => + sequence(0x0028, 0x9145, [ + concatenate([ + text(0x0028, 0x1052, [intercept]), + text(0x0028, 0x1053, [slope]), + ]), + ]); + +const temporalPosition = (value: number) => { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, true); + return sequence(0x0020, 0x9111, [rawElement(0x0020, 0x9128, 'UL', bytes)]); +}; + +export type EnhancedFixtureOptions = { + pixelValues: number[]; + positions?: number[][]; + sharedPosition?: number[]; + temporalPositions?: number[]; + transforms?: Array; +}; + +/** Encoded spatial/temporal frames with geometry exclusively in Functional Groups. */ +export function enhancedFixtureBytes(options: EnhancedFixtureOptions) { + const { + pixelValues, + positions, + sharedPosition, + temporalPositions, + transforms, + } = options; + const shared = [ + sequence(0x0020, 0x9116, [text(0x0020, 0x0037, [1, 0, 0, 0, 1, 0])]), + sequence(0x0028, 0x9110, [text(0x0028, 0x0030, [1, 1])]), + ]; + if (sharedPosition) shared.unshift(position(sharedPosition)); + const frames = pixelValues.map((_, index) => + concatenate([ + ...(temporalPositions + ? [temporalPosition(temporalPositions[index])] + : []), + ...(sharedPosition + ? [] + : [position(positions?.[index] ?? [10, 20, 30 + index])]), + ...(transforms ? [transformation(transforms[index])] : []), + ]) + ); + return buildSyntheticDicom({ + studyUid: '1.2.826.0.1.3680043.10.999.50', + seriesUid: '1.2.826.0.1.3680043.10.999.51', + sopUid: '1.2.826.0.1.3680043.10.999.52', + sopClassUid: transforms + ? '1.2.840.10008.5.1.4.1.1.2.1' + : '1.2.840.10008.5.1.4.1.1.4.1', + modality: transforms ? 'CT' : 'MR', + instanceNumber: 1, + imagePositionPatient: null, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + rootGeometry: false, + framePixelValues: pixelValues, + extraElements: [ + sequence(0x5200, 0x9229, [concatenate(shared)]), + sequence(0x5200, 0x9230, frames), + ], + }); +} diff --git a/tests/fixtures/dicom-consumer/fixtureData.ts b/tests/fixtures/dicom-consumer/fixtureData.ts new file mode 100644 index 000000000..d5286eb49 --- /dev/null +++ b/tests/fixtures/dicom-consumer/fixtureData.ts @@ -0,0 +1,44 @@ +import { + buildSyntheticDicom, + stripFileMeta, + type SyntheticSliceOptions, +} from '../../specs/syntheticDicom'; + +export const fixtureBytes = (overrides: Partial = {}) => + buildSyntheticDicom({ + studyUid: '1.2.826.0.1.3680043.10.999.20', + seriesUid: '1.2.826.0.1.3680043.10.999.21', + sopUid: '1.2.826.0.1.3680043.10.999.22', + instanceNumber: 1, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [10, 20, 30], + pixelSpacing: [2, 3], + spacingBetweenSlices: 4, + pixelValue: 5, + rescaleSlope: 2, + rescaleIntercept: -20, + patientName: 'EXTRACTED^DICOM', + ...overrides, + }); + +/** Numeric text padding found in a legacy CT series. Binary values stay intact. */ +export function nullPaddedFixtureBytes( + overrides: Partial = {} +) { + const bytes = fixtureBytes(overrides); + const view = new DataView(bytes.buffer); + let offset = bytes.length - stripFileMeta(bytes).length; + while (offset < bytes.length) { + const vr = String.fromCharCode(bytes[offset + 4], bytes[offset + 5]); + const long = vr === 'OB' || vr === 'OW'; + const header = long ? 12 : 8; + const length = long + ? view.getUint32(offset + 8, true) + : view.getUint16(offset + 6, true); + const end = offset + header + length; + if ((vr === 'DS' || vr === 'IS') && bytes[end - 1] === 0x20) + bytes[end - 1] = 0; + offset = end; + } + return bytes; +} diff --git a/tests/fixtures/dicom-consumer/loadVolume.ts b/tests/fixtures/dicom-consumer/loadVolume.ts new file mode 100644 index 000000000..14247cbea --- /dev/null +++ b/tests/fixtures/dicom-consumer/loadVolume.ts @@ -0,0 +1,41 @@ +import { + createDicomCollectionRegistry, + type Chunk, + type CollectionUpdate, +} from '@volview/dicom/core'; +import { VtkDicomImage } from '@volview/dicom/vtk'; +import { loader } from './shared'; + +export async function loadVolume(chunk: Chunk, image?: VtkDicomImage) { + await chunk.loadMeta(); + const registry = createDicomCollectionRegistry(); + const { + updates: [{ collection, members }], + } = await registry.register([chunk]); + return loadCollection({ collection, members }, image); +} + +export async function loadCollection( + { collection, members }: Pick, + image = new VtkDicomImage({ readDicomImage: loader.readDicomImage }) +) { + try { + const finished = new Promise((resolve, reject) => { + image.addEventListener('status', (status) => { + if (status === 'complete') resolve(); + }); + image.addEventListener('error', reject); + }); + await image.setChunks( + members, + collection.orientation, + collection.reconstruction + ); + image.startLoad(); + await finished; + return image; + } catch (error) { + image.dispose(); + throw error; + } +} diff --git a/tests/fixtures/dicom-consumer/server.mjs b/tests/fixtures/dicom-consumer/server.mjs new file mode 100644 index 000000000..6445dc610 --- /dev/null +++ b/tests/fixtures/dicom-consumer/server.mjs @@ -0,0 +1,105 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { preview } from 'vite'; +import { buildConsumer } from '../../../packages/dicom/scripts/check-consumers.mjs'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); + +function serveDicom(bytes, request, response) { + const match = /^bytes=(\d+)-/.exec(request.headers.range ?? ''); + const start = match ? Number(match[1]) : 0; + response.setHeader('Content-Type', 'application/dicom'); + response.setHeader('Accept-Ranges', 'bytes'); + response.setHeader('Content-Length', bytes.length - start); + if (match) { + response.statusCode = 206; + response.setHeader( + 'Content-Range', + `bytes ${start}-${bytes.length - 1}/${bytes.length}` + ); + } + response.end(bytes.subarray(start)); +} + +export async function startDicomConsumerFixtures( + bytes, + { kinds = ['core', 'vtk'], fixtures = {} } = {} +) { + await mkdir(resolve(root, '.tmp'), { recursive: true }); + const directory = await mkdtemp(resolve(root, '.tmp/dicom-browser-')); + const packageDist = resolve(directory, 'package'); + const servers = []; + /** @type {Record} */ + const urls = {}; + try { + execFileSync( + process.execPath, + ['packages/dicom/scripts/build.mjs', packageDist], + { + cwd: root, + stdio: 'inherit', + } + ); + for (const kind of kinds) { + const outDir = resolve(directory, kind); + await buildConsumer(kind, outDir, packageDist); + const pipelineWorker = await readFile( + resolve(outDir, 'assets/itk-wasm-pipeline.min.worker.js') + ); + let workerRequests = 0; + const server = await preview({ + configFile: false, + root, + build: { outDir }, + preview: { host: '127.0.0.1', port: 0 }, + plugins: [ + { + name: 'synthetic-dicom-source', + configurePreviewServer(instance) { + instance.middlewares.use( + '/recovering-worker.js', + (_request, response) => { + response.setHeader('Cache-Control', 'no-store'); + response.setHeader('Content-Type', 'text/javascript'); + if (workerRequests++ === 0) { + response.statusCode = 503; + response.end('temporary worker script failure'); + } else { + response.end(pipelineWorker); + } + } + ); + instance.middlewares.use('/fixture.dcm', (request, response) => { + serveDicom(bytes, request, response); + }); + instance.middlewares.use('/corpus-data', (request, response) => { + const name = decodeURIComponent(request.url?.slice(1) ?? ''); + if (!Object.hasOwn(fixtures, name)) { + response.statusCode = 404; + response.end(); + return; + } + serveDicom(fixtures[name], request, response); + }); + }, + }, + ], + }); + servers.push(server); + urls[kind] = `${server.resolvedUrls.local[0]}${kind}.html`; + } + } catch (error) { + await Promise.all(servers.map((server) => server.close())); + await rm(directory, { recursive: true, force: true }); + throw error; + } + return { + urls, + async close() { + await Promise.all(servers.map((server) => server.close())); + await rm(directory, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/fixtures/dicom-consumer/shared.ts b/tests/fixtures/dicom-consumer/shared.ts new file mode 100644 index 000000000..45b3c3c64 --- /dev/null +++ b/tests/fixtures/dicom-consumer/shared.ts @@ -0,0 +1,48 @@ +import { + createDicomLoader, + getDatasetTag, + readInstanceFacts, + type Chunk, +} from '@volview/dicom/core'; +import { fixtureBytes } from './fixtureData'; + +export const loader = createDicomLoader({ + assets: { + pipelineWorkerUrl: new URL( + './assets/itk-wasm-pipeline.min.worker.js', + document.baseURI + ).href, + imageIOUrl: new URL('./assets/image-io', document.baseURI).href, + }, +}); + +export function report(name: string, value: string) { + const output = document.createElement('output'); + output.id = name; + output.textContent = value; + document.querySelector('main')!.append(output); +} + +export async function loadChunk() { + const source = + new URL(location.href).searchParams.get('source') === 'http' + ? new URL('./fixture.dcm', document.baseURI) + : new File([fixtureBytes() as Uint8Array], 'fixture.dcm'); + const chunk = loader.createChunk(source); + await chunk.loadMeta(); + return chunk; +} + +export function describeMetadata(chunk: Chunk) { + const metadata = chunk.metadata!; + const facts = readInstanceFacts(metadata); + report('patient', getDatasetTag(metadata, '00100010')!.trim()); + report('position', facts.position!.join(', ')); + report('pixel-spacing', facts.pixelSpacing!.join(', ')); +} + +export function failed(error: unknown) { + report('error', error instanceof Error ? error.message : String(error)); +} + +window.addEventListener('pagehide', () => loader.dispose(), { once: true }); diff --git a/tests/fixtures/dicom-consumer/vtk.html b/tests/fixtures/dicom-consumer/vtk.html new file mode 100644 index 000000000..8784b9e43 --- /dev/null +++ b/tests/fixtures/dicom-consumer/vtk.html @@ -0,0 +1,25 @@ + + + + + DICOM vtk consumer + + + +

DICOM vtk consumer

+
+
+ + + diff --git a/tests/fixtures/dicom-consumer/vtk.ts b/tests/fixtures/dicom-consumer/vtk.ts new file mode 100644 index 000000000..57234d65c --- /dev/null +++ b/tests/fixtures/dicom-consumer/vtk.ts @@ -0,0 +1,47 @@ +import '@kitware/vtk.js/Rendering/Profiles/Volume'; +import vtkGenericRenderWindow from '@kitware/vtk.js/Rendering/Misc/GenericRenderWindow'; +import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; +import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; +import { describeMetadata, failed, loadChunk, report } from './shared'; +import { loadVolume } from './loadVolume'; + +async function run() { + const chunk = await loadChunk(); + describeMetadata(chunk); + const image = await loadVolume(chunk); + + const data = image.getVtkImageData(); + report('dimensions', data.getDimensions().join(', ')); + report('spacing', data.getSpacing().join(', ')); + report('origin', data.getOrigin().join(', ')); + report('range', data.getPointData().getScalars().getRange().join(', ')); + const view = vtkGenericRenderWindow.newInstance({ background: [0, 0, 0] }); + view.setContainer(document.querySelector('#viewport') as HTMLElement); + view.resize(); + const mapper = vtkImageMapper.newInstance(); + mapper.setInputData(data); + mapper.setKSlice(0); + const actor = vtkImageSlice.newInstance(); + actor.setMapper(mapper); + actor.getProperty().setColorWindow(20); + actor.getProperty().setColorLevel(-10); + const renderer = view.getRenderer(); + renderer.addActor(actor); + renderer.getActiveCamera().setParallelProjection(true); + renderer.resetCamera(); + renderer.resetCameraClippingRange(); + view.getRenderWindow().render(); + report('status', 'complete'); + + window.addEventListener( + 'pagehide', + () => { + view.delete(); + actor.delete(); + mapper.delete(); + image.dispose(); + }, + { once: true } + ); +} +run().catch(failed); diff --git a/tests/pageobjects/volview.page.ts b/tests/pageobjects/volview.page.ts index 71ba814e3..db2775179 100644 --- a/tests/pageobjects/volview.page.ts +++ b/tests/pageobjects/volview.page.ts @@ -68,6 +68,70 @@ class VolViewPage extends Page { ); } + get volumeCards() { + return $$('.volume-card'); + } + + /** Slice counts of the volume cards, ascending. Each label carries "[N]". */ + async getVolumeCardSliceCounts() { + const cards = [...(await this.volumeCards)]; + const labels = await Promise.all(cards.map((card) => card.getText())); + return labels + .map((label) => Number(label.match(/\[(\d+)\]/)?.[1])) + .filter(Number.isFinite) + .sort((a, b) => a - b); + } + + async waitForVolumeCardSliceCounts(expected: number[], timeout = 30000) { + await browser.waitUntil( + async () => { + const counts = await this.getVolumeCardSliceCounts(); + return ( + counts.length === expected.length && + counts.every((count, index) => count === expected[index]) + ); + }, + { + timeout, + timeoutMsg: `expected volume cards holding ${expected.join( + ', ' + )} slices`, + } + ); + } + + /** + * Deletes the volume of the card at `index` of `getVolumeCardSliceCounts`, + * which orders by slice count rather than by the order the cards render in. + */ + async deleteVolumeCard(index: number) { + const counts = await this.getVolumeCardSliceCounts(); + const cards = [...(await this.volumeCards)]; + const labelled = await Promise.all( + cards.map(async (card) => ({ + card, + count: Number((await card.getText()).match(/\[(\d+)\]/)?.[1]), + })) + ); + const target = labelled.find(({ count }) => count === counts[index]); + if (!target) throw new Error(`no volume card holding ${counts[index]}`); + + const menu = target.card.$('button[data-testid="dataset-menu-button"]'); + await menu.waitForClickable(); + await menu.click(); + + const remove = await browser.waitUntil( + async () => { + const items = [...(await $$('.v-overlay-container .v-list-item'))]; + const texts = await Promise.all(items.map((item) => item.getText())); + return items[texts.findIndex((text) => text.trim() === 'Delete')]; + }, + { timeoutMsg: 'expected a Delete item in the dataset menu' } + ); + await remove.waitForClickable(); + await remove.click(); + } + get notifications() { return $('#notifications'); } @@ -302,10 +366,8 @@ class VolViewPage extends Page { return $$('div[data-testid="layer-opacity-slider"]'); } - async getVolumeRenderingSection() { - const pwfEditor = await $('div.pwf-editor'); - const exists = await pwfEditor.isExisting(); - return exists ? pwfEditor : null; + getVolumeRenderingSection() { + return $('div.pwf-editor'); } async getView3D() { diff --git a/tests/specs/annotationTestUtils.ts b/tests/specs/annotationTestUtils.ts index 0e16c44cf..678e7a60d 100644 --- a/tests/specs/annotationTestUtils.ts +++ b/tests/specs/annotationTestUtils.ts @@ -41,29 +41,26 @@ export const pressAtPointer = () => hoveringMouse().down().up().perform(true); export const rightPressAtPointer = () => hoveringMouse().down({ button: 2 }).up({ button: 2 }).perform(true); -/** - * Loads the minimal DICOM or a generated fixture and returns the axial view - * with the center of its canvas in page coordinates. - */ -export const setupTest = async (fileName?: string) => { - if (fileName) await openVolViewPage(fileName); - else await openUrls([MINIMAL_DICOM]); - - const views2D = await AppPage.getViews2D(); - const axialView = views2D[0]; - const canvas = await axialView.$('canvas'); +export const canvasCenter = async (view: ChainablePromiseElement) => { + const canvas = view.$('canvas'); const [location, size] = await Promise.all([ canvas.getLocation(), canvas.getSize(), ]); - return { - axialView, centerX: location.x + size.width / 2, centerY: location.y + size.height / 2, }; }; +/** Loads a dataset and returns its axial view and canvas center. */ +export const setupTest = async (fileName?: string) => { + if (fileName) await openVolViewPage(fileName); + else await openUrls([MINIMAL_DICOM]); + const axialView = (await AppPage.getViews2D())[0]; + return { axialView, ...(await canvasCenter(axialView)) }; +}; + export const drawSquare = async (cx: number, cy: number, half: number) => { await clickAt(cx - half, cy - half); await clickAt(cx + half, cy - half); diff --git a/tests/specs/crosshairs-multi-image.e2e.ts b/tests/specs/crosshairs-multi-image.e2e.ts index b7371720f..ebbec7007 100644 --- a/tests/specs/crosshairs-multi-image.e2e.ts +++ b/tests/specs/crosshairs-multi-image.e2e.ts @@ -1,5 +1,7 @@ -import { MRA_HEAD_NECK_DATASET, PROSTATEX_DATASET } from '../datasets'; -import { openUrls } from './utils'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { makeTempDir, writeManifestToFile } from './utils'; +import { buildSyntheticDicom, newUid } from './syntheticDicom'; import { volViewPage } from '../pageobjects/volview.page'; const IMAGE_DRAG_MEDIA_TYPE = 'application/x-volview-image-id'; @@ -8,6 +10,44 @@ const IMAGE_DRAG_MEDIA_TYPE = 'application/x-volview-image-id'; const FIRST_OTHER_SLOT = 2; const SECOND_OTHER_SLOT = 1; +const openImages = async () => { + const directory = makeTempDir('crosshairs-images'); + const studyUid = newUid(); + // Separate image bounds make an incorrectly shared crosshair clamp the other + // volume to an edge slice instead of leaving it at the middle slice. + const resources = [0, 100].flatMap((origin, series) => { + const seriesUid = newUid(); + return Array.from({ length: 16 }, (_, slice) => { + const filename = `${series}-${slice}.dcm`; + writeFileSync( + join(directory, filename), + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + instanceNumber: slice + 1, + seriesNumber: series + 1, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [origin, origin, origin + slice], + rows: 16, + cols: 16, + pixelValue: 20 + slice * 10, + }) + ); + return { url: `/tmp/crosshairs-images/${filename}` }; + }); + }); + await writeManifestToFile({ resources }, 'crosshairs-images.json'); + await volViewPage.open('?urls=[tmp/crosshairs-images.json]'); + await volViewPage.waitForViews(); + await volViewPage.waitForVolumeCardSliceCounts([16, 16]); +}; + +const expectSlotImage = (index: number, name: string) => + expect( + $$('.grid-item')[index].$('.view-annotations .annotation-cell') + ).toHaveText(name); + /** * Dispatches a real dragstart on a volume card so the app stamps the imageID * into the dataTransfer, then synthesizes the drop on a layout slot. @@ -74,15 +114,7 @@ const dragInSlot = async (index: number, dx: number, dy: number) => { describe('Crosshairs with two base images', () => { it('only slices the views showing the image it was dragged in', async () => { - await openUrls([PROSTATEX_DATASET, MRA_HEAD_NECK_DATASET]); - await volViewPage.waitForViews(); - await browser.waitUntil( - async () => (await $$('.volume-card').length) >= 2, - { - timeout: 30000, - timeoutMsg: 'Expected both volume cards to appear', - } - ); + await openImages(); await browser.waitUntil(async () => (await $$('.grid-item').length) === 4, { timeout: 10000, timeoutMsg: 'Expected the Four Up layout (4 slots)', @@ -96,18 +128,25 @@ describe('Crosshairs with two base images', () => { ) ); expect(otherCard).toBeGreaterThanOrEqual(0); + const otherName = await $$('.volume-card') + [otherCard].$('.series-desc') + .getText(); + const activeName = await $$('.grid-item')[0] + .$('.view-annotations .annotation-cell') + .getText(); + expect(activeName).not.toBe(''); + expect(otherName).not.toBe(activeName); + await expectSlotImage(SECOND_OTHER_SLOT, activeName); expect(await dropCardOnSlot(otherCard, FIRST_OTHER_SLOT)).toBe(true); - await browser.waitUntil( - async () => (await getSlices())[FIRST_OTHER_SLOT] != null, - { timeout: 15000, timeoutMsg: 'Expected the dropped image to render' } - ); + await expectSlotImage(FIRST_OTHER_SLOT, otherName); const crosshairsButton = await $('button span i[class~=mdi-crosshairs]'); await crosshairsButton.waitForClickable(); await crosshairsButton.click(); const before = await getSlices(); + expect(before.slice(0, 3)).toEqual([9, 9, 9]); // Drag the crosshair well off centre in a view of the other image. await dragInSlot(0, 60, 40); @@ -124,10 +163,10 @@ describe('Crosshairs with two base images', () => { // not a crosshair interaction, so the slot already showing it must not be // re-sliced to wherever the crosshair sits in the other image. expect(await dropCardOnSlot(otherCard, SECOND_OTHER_SLOT)).toBe(true); - await browser.waitUntil( - async () => (await getSlices())[SECOND_OTHER_SLOT] !== afterDrag[1], - { timeout: 15000, timeoutMsg: 'Expected the second slot to remount' } - ); + await expectSlotImage(SECOND_OTHER_SLOT, otherName); + await expect( + $$('.grid-item')[SECOND_OTHER_SLOT].$('.slice-label') + ).toHaveText('Slice: 9/16'); const afterRemount = await getSlices(); expect(afterRemount[FIRST_OTHER_SLOT]).toBe(before[FIRST_OTHER_SLOT]); diff --git a/tests/specs/dicom-archive-state.e2e.ts b/tests/specs/dicom-archive-state.e2e.ts new file mode 100644 index 000000000..38ef69e8c --- /dev/null +++ b/tests/specs/dicom-archive-state.e2e.ts @@ -0,0 +1,107 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import JSZip from 'jszip'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { + buildSyntheticDicom, + buildSyntheticCineDicom, + newUid, +} from './syntheticDicom'; +import { canvasCenter, clickAt } from './annotationTestUtils'; +import { advanceCineFrame, waitForFrame } from './cineTestUtils'; +import { openSegmentShapes } from './segmentationTestUtils'; +import { + dropFiles, + openVolViewPage, + SESSION_SAVE_TIMEOUT, + waitForFileExists, +} from './utils'; + +describe('DICOM archive state and independent clips', () => { + it('restores the ruler saved with two series from one archive', async () => { + const archive = new JSZip(); + const studyUid = newUid(); + for (const count of [2, 3]) { + const seriesUid = newUid(); + for (let index = 0; index < count; index++) { + archive.file( + count + '-slice-' + index + '.dcm', + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + instanceNumber: index + 1, + seriesNumber: count, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [0, 0, index], + rows: 32, + cols: 32, + pixelValue: 100 + index, + }) + ); + } + } + const name = 'two-series-with-state.zip'; + writeFileSync( + join(TEMP_DIR, name), + await archive.generateAsync({ type: 'nodebuffer' }) + ); + await volViewPage.open('?urls=[tmp/' + name + ']'); + await volViewPage.waitForVolumeCardSliceCounts([2, 3]); + await volViewPage.waitForViews(); + + const view = (await volViewPage.getViews2D())[0]; + const { centerX: x, centerY: y } = await canvasCenter(view); + await volViewPage.selectTool('mdi-ruler'); + await clickAt(x - 30, y); + await clickAt(x + 30, y); + await openSegmentShapes(); + const rulerCount = () => + $$('[data-testid="segment-shape-row"] i.mdi-ruler').length; + await browser.waitUntil(async () => (await rulerCount()) === 1, { + timeoutMsg: 'Expected the placed ruler in the measurements list', + }); + + const saved = await volViewPage.saveSession(); + await waitForFileExists(join(TEMP_DIR, saved), SESSION_SAVE_TIMEOUT); + await openVolViewPage(saved); + await openSegmentShapes(); + await browser.waitUntil(async () => (await rulerCount()) === 1, { + timeoutMsg: 'Expected the restored archive dataset to retain its ruler', + }); + await $('button[data-testid="module-tab-Data"]').click(); + await volViewPage.waitForVolumeCardSliceCounts([2, 3]); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); + + it('plays two equal-shaped ultrasound clips sharing a series', async () => { + const studyUid = newUid(); + const seriesUid = newUid(); + const files = ['first-clip.dcm', 'second-clip.dcm'].map((name) => { + const file = join(TEMP_DIR, name); + writeFileSync( + file, + buildSyntheticCineDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + rows: 32, + cols: 32, + numberOfFrames: 3, + }) + ); + return file; + }); + await volViewPage.open(); + await $('#app-container').waitForDisplayed(); + await dropFiles(files); + await volViewPage.waitForVolumeCardSliceCounts([3, 3]); + await $('.play-controls').waitForDisplayed(); + await waitForFrame(1); + await volViewPage.focusFirst2DView(); + await advanceCineFrame(); + await waitForFrame(2); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); +}); diff --git a/tests/specs/dicom-dimension-mismatch.e2e.ts b/tests/specs/dicom-dimension-mismatch.e2e.ts index 016a503b8..72224dbaf 100644 --- a/tests/specs/dicom-dimension-mismatch.e2e.ts +++ b/tests/specs/dicom-dimension-mismatch.e2e.ts @@ -1,8 +1,7 @@ -// A DICOM series whose instances disagree on Rows/Columns still groups into a -// single volume, and that volume's buffer is sized from the first instance. -// An instance that does not fit has to be rejected with a message naming it, -// whether it is too big for its slot or too small to fill one, and the rest of -// the series has to reach a usable state anyway. +// A DICOM series whose instances disagree on Rows/Columns is two datasets, not +// one volume with a rejected slice: the collection planner splits on pixel +// geometry, so the scout lands in its own volume whether it arrives first or +// last, and neither volume reports an error. import * as path from 'path'; import * as fs from 'fs'; import { volViewPage } from '../pageobjects/volview.page'; @@ -11,20 +10,15 @@ import { makeTempDir, writeManifestToFile } from './utils'; const IMAGE_ORIENTATION_PATIENT = [1, 0, 0, 0, 1, 0] as const; const SLICE_COUNT = 5; -const MIDDLE_SLICE = 2; -const SMALL_ROWS = 4; -const SMALL_COLS = 6; -const BIG_ROWS = 8; -const BIG_COLS = 10; - -type Size = { rows: number; cols: number }; +const SERIES_ROWS = 4; +const SERIES_COLS = 6; +const SCOUT_ROWS = 8; +const SCOUT_COLS = 10; async function writeSeries( dirName: string, - series: Size, - outlier: Size, - outlierSlice: number, + scoutSlice: number, manifestName: string ) { const dir = makeTempDir(dirName); @@ -32,175 +26,83 @@ async function writeSeries( const studyUid = newUid(); const seriesUid = newUid(); const resources = []; - let outlierSopUid = ''; for (let i = 0; i < SLICE_COUNT; i++) { - const mismatched = i === outlierSlice; - const sopUid = newUid(); - if (mismatched) outlierSopUid = sopUid; - - const { rows, cols } = mismatched ? outlier : series; + const scout = i === scoutSlice; const filename = `slice-${i}.dcm`; fs.writeFileSync( path.join(dir, filename), buildSyntheticDicom({ studyUid, seriesUid, - sopUid, + sopUid: newUid(), instanceNumber: i + 1, imageOrientationPatient: IMAGE_ORIENTATION_PATIENT, imagePositionPatient: [0, 0, i], - rows, - cols, + rows: scout ? SCOUT_ROWS : SERIES_ROWS, + cols: scout ? SCOUT_COLS : SERIES_COLS, }) ); resources.push({ url: `tmp/${dirName}/${filename}`, name: filename }); } await writeManifestToFile({ resources }, manifestName); - - return outlierSopUid; } -// The slice count in the "Slice: n/total" overlay of the first 2D view. -async function getRenderedSliceCount() { - return browser.execute((selector: string) => { - const view = document.querySelector(selector); - const match = view?.textContent?.match(/Slice:\s*\d+\s*\/\s*(\d+)/); - return match ? parseInt(match[1], 10) : null; - }, 'div[data-testid~="vtk-two-view"]'); -} - -// A rejected chunk is terminal, so the views stop advertising progress even -// though part of the series never arrived. -async function waitForSeriesToSettle() { - await volViewPage.waitForViews(); - await browser.waitUntil( - async () => (await getRenderedSliceCount()) === SLICE_COUNT, - { - timeout: 30000, - timeoutMsg: `expected the 2D view to show a ${SLICE_COUNT} slice volume`, - } - ); +async function waitForVolumeCards(count: number) { await browser.waitUntil( - async () => (await $$('.loading-indicator').length) === 0, + async () => (await $$('.volume-card').length) === count, { timeout: 30000, - timeoutMsg: 'expected the views to stop showing a loading indicator', + timeoutMsg: `expected exactly ${count} volume cards`, } ); } -async function readErrorDetails() { - await volViewPage.notifications.click(); - - const errorPanel = $('.message-center .v-expansion-panel-title'); - await errorPanel.waitForClickable(); - await errorPanel.click(); - - const details = $('.message-center pre.details'); - await details.waitForDisplayed(); - // The panel animates open, so the text arrives a frame after the element. - await browser.waitUntil(async () => (await details.getText()).length > 0, { - timeout: 5000, - timeoutMsg: 'expected the error details to render', - }); - return details.getText(); -} - -async function loadAndReadRejection(manifestName: string) { +async function loadSplitSeries(dirName: string, scoutSlice: number) { + const manifestName = `${dirName}-${Date.now()}.json`; + await writeSeries(dirName, scoutSlice, manifestName); await volViewPage.open(`?urls=[tmp/${manifestName}]`); - await volViewPage.waitForNotification(); - await waitForSeriesToSettle(); - const errorCount = await volViewPage.getNotificationsCount(); - const text = await readErrorDetails(); - return { text, errorCount }; + await volViewPage.waitForViews(); + await waitForVolumeCards(2); } -describe('DICOM series with an instance of a different size', () => { - it('names the offending file when it overruns its slot', async () => { - const manifestName = `dimension-mismatch-big-${Date.now()}.json`; - const outlierSopUid = await writeSeries( - 'dimension-mismatch-big', - { rows: SMALL_ROWS, cols: SMALL_COLS }, - { rows: BIG_ROWS, cols: BIG_COLS }, - MIDDLE_SLICE, - manifestName - ); +describe('DICOM series with instances of two pixel geometries', () => { + it('splits the scout out when it arrives first', async () => { + await loadSplitSeries('geometry-split-first', 0); - const { text, errorCount } = await loadAndReadRejection(manifestName); + await volViewPage.waitForVolumeCardSliceCounts([1, 4]); - expect(text).toContain(outlierSopUid); - expect(text).toContain(`${BIG_COLS}x${BIG_ROWS}x1`); - expect(text).toContain(`${SMALL_COLS}x${SMALL_ROWS}x1`); - expect(text).toContain('Rows, Columns, and SamplesPerPixel'); - - // Only the instance that does not fit is rejected; the rest of the series - // still loads, so there is exactly one error to report. - expect(errorCount).toBe(1); + // Splitting is not a failure, so nothing is reported. + expect(await volViewPage.getNotificationsCount()).toBe(0); }); - // An under-sized instance fits inside its slot, so the write itself cannot - // detect it; only the metadata comparison can. - it('names the offending file when it underfills its slot', async () => { - const manifestName = `dimension-mismatch-small-${Date.now()}.json`; - const outlierSopUid = await writeSeries( - 'dimension-mismatch-small', - { rows: BIG_ROWS, cols: BIG_COLS }, - { rows: SMALL_ROWS, cols: SMALL_COLS }, - MIDDLE_SLICE, - manifestName - ); + it('splits the scout out when it arrives last', async () => { + await loadSplitSeries('geometry-split-last', SLICE_COUNT - 1); - const { text, errorCount } = await loadAndReadRejection(manifestName); + await volViewPage.waitForVolumeCardSliceCounts([1, 4]); - expect(text).toContain(outlierSopUid); - expect(text).toContain(`${SMALL_COLS}x${SMALL_ROWS}x1`); - expect(text).toContain(`${BIG_COLS}x${BIG_ROWS}x1`); - expect(errorCount).toBe(1); + expect(await volViewPage.getNotificationsCount()).toBe(0); }); - // Both axes differing would still be caught if either half of the comparison - // were dropped; a single differing axis pins down both halves. - it('rejects an instance that differs on rows alone', async () => { - const manifestName = `dimension-mismatch-rows-${Date.now()}.json`; - const outlierSopUid = await writeSeries( - 'dimension-mismatch-rows', - { rows: SMALL_ROWS, cols: SMALL_COLS }, - { rows: BIG_ROWS, cols: SMALL_COLS }, - MIDDLE_SLICE, - manifestName + it('thumbnails both volumes from their own slices', async () => { + await loadSplitSeries('geometry-split-thumbnails', 0); + + await browser.waitUntil( + async () => { + const sources = await $$('.volume-card img.v-img__img').map( + (thumbnail) => thumbnail.getAttribute('src') + ); + return ( + sources.length === 2 && + sources.every((src) => !!src && src.startsWith('data:image/')) + ); + }, + { + timeout: 30000, + timeoutMsg: + 'expected both volume cards to render a data-URL thumbnail instead of the modality fallback', + } ); - - const { text, errorCount } = await loadAndReadRejection(manifestName); - - expect(text).toContain(outlierSopUid); - expect(text).toContain(`${SMALL_COLS}x${BIG_ROWS}x1`); - expect(text).toContain(`${SMALL_COLS}x${SMALL_ROWS}x1`); - expect(errorCount).toBe(1); - }); - - // The volume is sized from the first instance, so an outlier there rejects - // every other chunk, one error apiece. Collapsing them into a single - // notification belongs with the error-boundary work in issue 911. - it('rejects the rest of the series when the first instance is the outlier', async () => { - const manifestName = `dimension-mismatch-first-${Date.now()}.json`; - const outlierSopUid = await writeSeries( - 'dimension-mismatch-first', - { rows: SMALL_ROWS, cols: SMALL_COLS }, - { rows: BIG_ROWS, cols: BIG_COLS }, - 0, - manifestName - ); - - const { text, errorCount } = await loadAndReadRejection(manifestName); - - expect(errorCount).toBe(SLICE_COUNT - 1); - // The first instance defines the volume, so it is the other four that are - // named as not fitting. - expect(text).not.toContain(outlierSopUid); - expect(text).toContain('does not fit the volume it belongs to'); - expect(text).toContain(`${SMALL_COLS}x${SMALL_ROWS}x1`); - expect(text).toContain(`${BIG_COLS}x${BIG_ROWS}x1`); }); }); diff --git a/tests/specs/dicom-frame-reconstruction.e2e.ts b/tests/specs/dicom-frame-reconstruction.e2e.ts new file mode 100644 index 000000000..349705f7c --- /dev/null +++ b/tests/specs/dicom-frame-reconstruction.e2e.ts @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { openDicomConsumer } from '../dicomConsumer'; +import { enhancedFixtureBytes } from '../fixtures/dicom-consumer/enhancedFixtures'; +import { fixtureBytes } from '../fixtures/dicom-consumer/fixtureData'; +import { startDicomConsumerFixtures } from '../fixtures/dicom-consumer/server.mjs'; +import { volViewPage } from '../pageobjects/volview.page'; + +const frames = { + uneven: enhancedFixtureBytes({ + pixelValues: [20, 120, 220], + positions: [ + [10, 20, 30], + [10, 20, 31], + [10, 20, 33], + ], + }), + temporal: enhancedFixtureBytes({ + pixelValues: [20, 120], + sharedPosition: [10, 20, 30], + temporalPositions: [1, 2], + }), + varying: enhancedFixtureBytes({ + pixelValues: [2, 2], + transforms: [ + [1, 0], + [2, 0], + ], + }), + uniform: enhancedFixtureBytes({ + pixelValues: [2, 2], + transforms: [ + [2, 0], + [2, 0], + ], + }), +}; + +const hashFrames = (values: number[]) => + createHash('sha256') + .update( + Buffer.from( + Float64Array.from(values.flatMap((value) => Array(16).fill(value))) + .buffer + ) + ) + .digest('hex'); +const irregular = 'Cannot allocate irregular DICOM geometry: '; +const scenarios = [ + { + name: 'uneven', + policy: 'preserve-order', + values: [20, 120, 220], + reconstruction: 'irregular', + }, + { + name: 'uneven', + policy: 'reject', + error: `${irregular}uneven-slice-spacing`, + }, + { + name: 'temporal', + policy: 'reject', + error: `${irregular}unreadable-geometry`, + }, + { + name: 'varying', + error: + 'Cannot load DICOM frames with differing modality rescale: the decoder applies one transform to the entire instance', + }, + { + name: 'varying', + decoder: 'custom', + values: [2, 4], + reconstruction: 'regular', + }, + { name: 'uniform', values: [4, 4], reconstruction: 'regular' }, +]; + +describe('Encoded multiframe reconstruction contracts', () => { + let fixture: Awaited>; + before(async () => { + fixture = await startDicomConsumerFixtures(fixtureBytes(), { + kinds: ['corpus'], + fixtures: frames, + }); + }); + after(async () => fixture?.close()); + + for (const scenario of scenarios) { + for (const source of ['file', 'http']) { + it(`handles ${scenario.name} frames from ${source} with ${scenario.decoder ?? 'default'} decoding and ${scenario.policy ?? 'default'} geometry policy`, async () => { + const params = new URLSearchParams({ name: scenario.name, source }); + if (scenario.policy) params.set('policy', scenario.policy); + if (scenario.decoder) params.set('decoder', scenario.decoder); + await openDicomConsumer( + `${fixture.urls.corpus}?${params}`, + scenario.error + ); + if (scenario.values) { + await expect($('#pixel-hash')).toHaveText( + hashFrames(scenario.values) + ); + await expect($('#reconstruction')).toHaveText( + scenario.reconstruction + ); + } + }); + } + } +}); + +describe('Multiframe reconstruction notices', () => { + for (const name of ['uneven', 'temporal'] as const) { + it(`shows the user why ${name} frames have approximate spatial placement`, async () => { + const file = `enhanced-${name}.dcm`; + writeFileSync(resolve(TEMP_DIR, file), frames[name]); + await volViewPage.open(`?urls=[tmp/${file}]`); + await volViewPage.waitForVolumeCardSliceCounts([ + name === 'uneven' ? 3 : 2, + ]); + await volViewPage.waitForViews(); + expect(await volViewPage.getNotificationsCount()).toBe(1); + await volViewPage.notifications.click(); + const warning = $('.message-center .warn-message'); + await expect(warning).toHaveText( + expect.stringContaining('did not load as one sound volume') + ); + await warning.click(); + await expect($('.message-center .details')).toHaveText( + expect.stringContaining('positions and measurements') + ); + }); + } +}); diff --git a/tests/specs/dicom-import-regressions.e2e.ts b/tests/specs/dicom-import-regressions.e2e.ts new file mode 100644 index 000000000..2ac177f44 --- /dev/null +++ b/tests/specs/dicom-import-regressions.e2e.ts @@ -0,0 +1,100 @@ +import { volViewPage } from '../pageobjects/volview.page'; +import { + buildSyntheticDicom, + newUid, + rawElement, + sequenceItem, + toImplicitVr, + undefinedLengthUnknown, +} from './syntheticDicom'; +import { + waitForFirstCachedImageSpacing, + waitForFirstCompleteCachedImageScalars, +} from './imageCacheUtils'; + +const AXIAL = [1, 0, 0, 0, 1, 0] as const; + +// Exercise the local File import path, including metadata scanning and pixel +// decoding, through the same drop event a user produces. +async function dropFiles(files: Uint8Array[]) { + await volViewPage.open(); + await $('#app-container').waitForExist(); + await browser.execute( + (encodedFiles) => { + const transfer = new DataTransfer(); + encodedFiles.forEach((encoded, i) => { + const bytes = Uint8Array.from(atob(encoded), (char) => + char.charCodeAt(0) + ); + transfer.items.add(new File([bytes], `slice-${i}.dcm`)); + }); + const event = new Event('drop', { bubbles: true, cancelable: true }); + // Synthetic items have no filesystem entries, so supply the files list. + Object.defineProperty(event, 'dataTransfer', { + value: { items: null, files: transfer.files }, + }); + document.querySelector('#app-container')!.dispatchEvent(event); + }, + files.map((bytes) => Buffer.from(bytes).toString('base64')) + ); +} + +describe('Local DICOM import regressions', () => { + it('keeps the planned 5 mm spacing with slightly different slice orientations', async () => { + const studyUid = newUid(); + const seriesUid = newUid(); + const sopPrefix = newUid(); + const files = Array.from({ length: 121 }, (_, i) => + buildSyntheticDicom({ + studyUid, + seriesUid, + // The axial representative sorts before the tilted spatial first slice. + sopUid: `${sopPrefix}.${i === 1 ? '1' : `2.${i}`}`, + instanceNumber: i + 1, + imageOrientationPatient: + i === 0 ? [Math.cos(0.0002), 0, Math.sin(0.0002), 0, 1, 0] : AXIAL, + imagePositionPatient: [0, 0, i * 5], + rows: 4, + cols: 4, + pixelValue: i, + }) + ); + + await dropFiles(files); + const { values } = await waitForFirstCompleteCachedImageScalars(); + expect(values).toEqual( + Array.from({ length: 121 }, (_, i) => Array(16).fill(i)).flat() + ); + expect(await waitForFirstCachedImageSpacing()).toEqual([1, 1, 5]); + await volViewPage.waitForVolumeCardSliceCounts([121]); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); + + it('loads pixels from a file containing an undefined-length private UN sequence', async () => { + const content = toImplicitVr( + rawElement(0x0043, 0x1030, 'LO', new TextEncoder().encode('VALUE ')) + ); + await dropFiles([ + buildSyntheticDicom({ + studyUid: newUid(), + seriesUid: newUid(), + sopUid: newUid(), + instanceNumber: 1, + imageOrientationPatient: AXIAL, + imagePositionPatient: [0, 0, 0], + rows: 4, + cols: 4, + pixelValue: 123, + extraElements: [ + rawElement(0x0043, 0x0010, 'LO', new TextEncoder().encode('TEST')), + undefinedLengthUnknown(0x0043, 0x1029, sequenceItem(content)), + ], + }), + ]); + + const { values } = await waitForFirstCompleteCachedImageScalars(); + expect(values).toEqual(Array(16).fill(123)); + await volViewPage.waitForVolumeCardSliceCounts([1]); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); +}); diff --git a/tests/specs/dicom-japanese-name.e2e.ts b/tests/specs/dicom-japanese-name.e2e.ts new file mode 100644 index 000000000..22a88816d --- /dev/null +++ b/tests/specs/dicom-japanese-name.e2e.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import JSZip from 'jszip'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; + +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { buildSyntheticDicom, newUid } from './syntheticDicom'; +import { writeManifestToFile } from './utils'; + +const ascii = (text: string) => Array.from(text, (c) => c.charCodeAt(0)); + +// ESC $ B designates JIS X 0208, ESC ( B returns to ASCII, as ISO 2022 IR 87 +// requires around every run of ideographic or phonetic characters. +const jisX0208 = (...codes: number[]) => [ + 0x1b, + 0x24, + 0x42, + ...codes, + 0x1b, + 0x28, + 0x42, +]; + +const JAPANESE_NAME = 'Yamada^Tarou=山田^太郎=やまだ^たろう'; +const JAPANESE_NAME_BYTES = new Uint8Array([ + ...ascii('Yamada^Tarou='), + ...jisX0208(0x3b, 0x33, 0x45, 0x44), + ...ascii('^'), + ...jisX0208(0x42, 0x40, 0x4f, 0x3a), + ...ascii('='), + ...jisX0208(0x24, 0x64, 0x24, 0x5e, 0x24, 0x40), + ...ascii('^'), + ...jisX0208(0x24, 0x3f, 0x24, 0x6d, 0x24, 0x26), +]); + +const japaneseNameDicom = () => + buildSyntheticDicom({ + studyUid: newUid(), + seriesUid: newUid(), + sopUid: newUid(), + instanceNumber: 1, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [0, 0, 0], + specificCharacterSet: 'ISO 2022 IR 6\\ISO 2022 IR 87', + patientNameBytes: JAPANESE_NAME_BYTES, + }); + +const writeTempFile = (fileName: string, contents: Uint8Array | Buffer) => { + const filePath = path.join(TEMP_DIR, fileName); + fs.writeFileSync(filePath, contents); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + return fileName; +}; + +const patientHeaderNames = async () => { + const headers = await $$('.patient-header-name'); + const names: string[] = []; + for (const header of headers) { + names.push(await header.getText()); + } + return names; +}; + +const waitForPatientName = async (expected: string) => { + let seen: string[] = []; + await browser.waitUntil( + async () => { + seen = await patientHeaderNames(); + return seen.includes(expected); + }, + { + timeoutMsg: `expected a patient named ${expected}`, + } + ); + expect(seen).toContain(expected); +}; + +describe('DICOM patient name in a non-default character set', () => { + it('decodes an ISO 2022 IR 87 name from a streamed DICOM', async () => { + const stamp = Date.now(); + const fileName = writeTempFile( + `japanese-name-${stamp}.dcm`, + japaneseNameDicom() + ); + const manifestName = `japanese-name-${stamp}.json`; + await writeManifestToFile( + { resources: [{ url: `/tmp/${fileName}`, name: fileName }] }, + manifestName + ); + + await volViewPage.open(`?urls=[tmp/${manifestName}]`); + await volViewPage.waitForViews(); + + await waitForPatientName(JAPANESE_NAME); + }); + + it('decodes an ISO 2022 IR 87 name from a DICOM inside an archive', async () => { + const zip = new JSZip(); + zip.file('japanese-name.dcm', japaneseNameDicom()); + const zipName = writeTempFile( + `japanese-name-${Date.now()}.zip`, + await zip.generateAsync({ type: 'nodebuffer' }) + ); + + await volViewPage.open(`?urls=[tmp/${zipName}]`); + await volViewPage.waitForViews(); + + await waitForPatientName(JAPANESE_NAME); + }); +}); diff --git a/tests/specs/dicom-package.e2e.ts b/tests/specs/dicom-package.e2e.ts new file mode 100644 index 000000000..e6076d630 --- /dev/null +++ b/tests/specs/dicom-package.e2e.ts @@ -0,0 +1,59 @@ +import { PNG } from 'pngjs'; +import { resolve } from 'node:path'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { fixtureBytes } from '../fixtures/dicom-consumer/fixtureData'; +import { startDicomConsumerFixtures } from '../fixtures/dicom-consumer/server.mjs'; +import { openDicomConsumer } from '../dicomConsumer'; + +// Both pages consume emitted JavaScript; metadata workers and GDCM assets are +// served from the same standalone output, without VolView startup. +describe('Independent DICOM package consumers', () => { + let fixture: Awaited>; + before(async () => { + fixture = await startDicomConsumerFixtures(fixtureBytes()); + }); + after(async () => fixture?.close()); + + it('rejects a failed worker startup and finishes queued and later reads', async () => { + await openDicomConsumer(`${fixture.urls.core}?scenario=worker-recovery`); + await expect($('#first-error')).toHaveText('DICOM decoder worker failed'); + await expect($('#queued-pixels')).toHaveText( + Array(16).fill('-10').join(', ') + ); + await expect($('#retry-pixels')).toHaveText( + Array(16).fill('-10').join(', ') + ); + }); + + for (const kind of ['core', 'vtk'] as const) { + for (const source of ['file', 'http']) { + it(`${kind} preserves metadata and calibrated pixels from ${source}`, async () => { + await openDicomConsumer(`${fixture.urls[kind]}?source=${source}`); + await expect($('#patient')).toHaveText('EXTRACTED^DICOM'); + await expect($('#position')).toHaveText('10, 20, 30'); + await expect($('#pixel-spacing')).toHaveText('2, 3'); + if (kind === 'core') { + await expect($('#pixels')).toHaveText( + Array(16).fill('-10').join(', ') + ); + } else { + await expect($('#spacing')).toHaveText('3, 2, 4'); + await expect($('#origin')).toHaveText('10, 20, 30'); + await expect($('#range')).toHaveText('-10, -10'); + const screenshot = PNG.sync.read( + await $('#viewport').saveScreenshot( + resolve(TEMP_DIR, `dicom-${source}.png`) + ) + ); + const center = + ((screenshot.height >> 1) * screenshot.width + + (screenshot.width >> 1)) * + 4; + expect(screenshot.data[center]).toBeGreaterThan(110); + expect(screenshot.data[center]).toBeLessThan(145); + expect(screenshot.data[0]).toBe(0); + } + }); + } + } +}); diff --git a/tests/specs/dicom-spacing-details.e2e.ts b/tests/specs/dicom-spacing-details.e2e.ts new file mode 100644 index 000000000..4738dfac6 --- /dev/null +++ b/tests/specs/dicom-spacing-details.e2e.ts @@ -0,0 +1,179 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { + buildSyntheticDicom, + newUid, + type SyntheticSliceOptions, +} from './syntheticDicom'; +import { enhancedFixtureBytes } from '../fixtures/dicom-consumer/enhancedFixtures'; +import { dropFiles, SESSION_SAVE_TIMEOUT, waitForFileExists } from './utils'; + +const dialog = () => $('[data-testid="image-spacing-details"]'); +const axis = (name: string) => + dialog().$(`[data-testid="spacing-axis-${name}"]`); +const closeDetails = () => $('[data-testid="spacing-details-close"]').click(); + +async function openDetails() { + await $('.volume-card [data-testid="dataset-menu-button"]').click(); + const item = $('[data-testid="dataset-menu-spacing-item"]'); + await item.waitForDisplayed(); + await item.waitForStable(); + await item.waitForClickable(); + await item.click(); + await dialog().waitForDisplayed(); +} + +function slice(options: Partial = {}) { + return buildSyntheticDicom({ + studyUid: newUid(), + seriesUid: newUid(), + sopUid: newUid(), + instanceNumber: 1, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [0, 0, 0], + rows: 4, + cols: 4, + pixelSpacing: [0.5, 0.25], + pixelValue: 80, + ...options, + }); +} + +async function openFile(name: string, bytes: Uint8Array, slices = 1) { + writeFileSync(join(TEMP_DIR, name), bytes); + await volViewPage.open(`?urls=[tmp/${name}]`); + await volViewPage.waitForVolumeCardSliceCounts([slices]); + await openDetails(); +} + +describe('DICOM physical spacing details', () => { + it('identifies unknown physical units in a public secondary capture', async () => { + await volViewPage.open('?urls=[tmp/dicom-corpus-image_dfl.dcm]'); + await volViewPage.waitForVolumeCardSliceCounts([1]); + await openDetails(); + await expect(axis('X')).toHaveText( + expect.stringContaining('physical unit unknown') + ); + await expect(axis('Y')).toHaveText( + expect.stringContaining('Assumed: Display default') + ); + await expect(dialog()).toHaveText( + expect.stringContaining('They do not establish a physical distance.') + ); + }); + + it('updates derived spacing after another slice arrives and restores it with the session', async () => { + const studyUid = newUid(); + const seriesUid = newUid(); + const first = slice({ studyUid, seriesUid }); + const secondName = 'spacing-second.dcm'; + writeFileSync( + join(TEMP_DIR, secondName), + slice({ + studyUid, + seriesUid, + instanceNumber: 2, + imagePositionPatient: [0, 0, 2], + }) + ); + await openFile('spacing-first.dcm', first); + await expect(axis('X')).toHaveText(expect.stringContaining('0.25 mm')); + await expect(axis('X')).toHaveText( + expect.stringContaining('Declared: PixelSpacing') + ); + await expect(axis('Z')).toHaveText(expect.stringContaining('One sample')); + await expect(axis('Z')).toHaveText( + expect.stringContaining('physical unit unknown') + ); + await closeDetails(); + + await dropFiles([join(TEMP_DIR, secondName)]); + await volViewPage.waitForVolumeCardSliceCounts([2]); + await openDetails(); + await expect(axis('Z')).toHaveText(expect.stringContaining('2 mm')); + await expect(axis('Z')).toHaveText( + expect.stringContaining('Derived: ImagePositionPatient') + ); + await closeDetails(); + + const saved = await volViewPage.saveSession(); + await waitForFileExists(join(TEMP_DIR, saved), SESSION_SAVE_TIMEOUT); + await volViewPage.open(`?urls=[tmp/${saved}]`); + await volViewPage.waitForVolumeCardSliceCounts([2]); + await openDetails(); + await expect(axis('X')).toHaveText(expect.stringContaining('0.25 mm')); + await expect(axis('Z')).toHaveText( + expect.stringContaining('Derived: ImagePositionPatient') + ); + await browser.saveScreenshot(join(TEMP_DIR, 'dicom-spacing-details.png')); + }); + + it('preserves the valid axis when the other PixelSpacing value is unusable', async () => { + await openFile('spacing-partial.dcm', slice({ pixelSpacing: [0, 0.25] })); + await expect(axis('X')).toHaveText(expect.stringContaining('0.25 mm')); + await expect(axis('X')).toHaveText( + expect.stringContaining('Declared: PixelSpacing') + ); + await expect(axis('Y')).toHaveText( + expect.stringContaining('physical unit unknown') + ); + await expect(axis('Y')).toHaveText( + expect.stringContaining('no usable positive spacing') + ); + }); + + it('reports the declared step of a single-file multiframe volume', async () => { + await openFile( + 'spacing-multiframe.dcm', + slice({ framePixelValues: [40, 80], spacingBetweenSlices: 1.5 }), + 2 + ); + await expect(axis('Z')).toHaveText(expect.stringContaining('1.5 mm')); + await expect(axis('Z')).toHaveText( + expect.stringContaining('Declared: SpacingBetweenSlices') + ); + }); + + it('identifies the ultrasound unit conversion and its region scope', async () => { + await openFile( + 'spacing-ultrasound.dcm', + slice({ + modality: 'US', + sopClassUid: '1.2.840.10008.5.1.4.1.1.6.1', + ultrasoundRegion: { physicalDeltaX: 0.05, physicalDeltaY: 0.03 }, + }) + ); + await expect(axis('X')).toHaveText(expect.stringContaining('0.5 mm')); + await expect(axis('Y')).toHaveText(expect.stringContaining('0.3 mm')); + await expect(dialog()).toHaveText( + expect.stringContaining('cm converted to mm') + ); + await expect(dialog()).toHaveText( + expect.stringContaining('not necessarily the entire image') + ); + }); + + it('keeps physical units distinct from an approximate reconstruction', async () => { + await openFile( + 'spacing-uneven.dcm', + enhancedFixtureBytes({ + pixelValues: [20, 80, 140], + positions: [ + [0, 0, 0], + [0, 0, 1], + [0, 0, 3], + ], + }), + 3 + ); + await expect(axis('X')).toHaveText(expect.stringContaining('mm')); + await expect(dialog()).toHaveText( + expect.stringContaining('Slices are not evenly spaced.') + ); + await expect(dialog()).toHaveText( + expect.stringContaining('The displayed grid is approximate') + ); + }); +}); diff --git a/tests/specs/dicom-volume-composition.e2e.ts b/tests/specs/dicom-volume-composition.e2e.ts new file mode 100644 index 000000000..fe685c2fe --- /dev/null +++ b/tests/specs/dicom-volume-composition.e2e.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import corpus from '../dicomCompositionCorpus.json'; +import { DATASET_CACHE } from '../downloadDatasets'; +import { waitForDicomConsumer } from '../dicomConsumer'; +import { startDicomConsumerFixtures } from '../fixtures/dicom-consumer/server.mjs'; +import { fixtureBytes } from '../fixtures/dicom-consumer/fixtureData'; +import { volViewPage } from '../pageobjects/volview.page'; +import { waitForRenderedSlice } from './dicomRenderedSlice'; + +type ExpectedVolume = (typeof corpus.cases)[number]['volumes'][number]; + +async function expectComposedVolumes( + volumes: ExpectedVolume[], + partial: boolean +) { + await expect($$('.volume')).toBeElementsArrayOfSize(volumes.length); + for (const volume of volumes) { + const members = partial + ? volume.sopInstanceUIDs.filter((_, index) => index % 2 === 0) + : volume.sopInstanceUIDs; + const expected = partial ? volume.initial : volume.expected; + const section = $(`.volume[data-first-sop="${members[0]}"]`); + await expect(section.$('[data-field="members"]')).toHaveText( + members.join(', ') + ); + await expect(section.$('[data-field="pixel-hash"]')).toHaveText( + expected.pixelHash + ); + await expect(section.$('[data-field="dimensions"]')).toHaveText( + expected.dimensions.join(', ') + ); + const numeric = { + spacing: expected.spacing, + origin: expected.origin, + direction: expected.direction, + range: expected.range, + 'first-plane': expected.firstPlanePosition, + 'last-plane': expected.lastPlanePosition, + }; + for (const [name, values] of Object.entries(numeric)) { + const actual = (await section.$(`[data-field="${name}"]`).getText()) + .split(', ') + .map(Number); + expect(actual).toHaveLength(values.length); + values.forEach((value, index) => + expect(Math.abs(actual[index] - value)).toBeLessThanOrEqual( + corpus.geometryToleranceMm + ) + ); + } + } +} + +describe('Downloaded DICOM volume composition', () => { + let fixture: Awaited>; + before(async () => { + const fixtures = Object.fromEntries( + corpus.datasets.map(({ name }) => [ + name, + readFileSync(resolve(DATASET_CACHE, name)), + ]) + ); + fixture = await startDicomConsumerFixtures(fixtureBytes(), { + kinds: ['composition'], + fixtures, + }); + }); + after(async () => fixture?.close()); + + for (const scenario of corpus.cases) { + for (const source of ['file', 'http']) { + for (const mode of ['batch', 'incremental']) { + it(`composes every ${scenario.id} collection from shuffled ${source} sources in ${mode} mode`, async () => { + await browser.url( + `${fixture.urls.composition}?${new URLSearchParams({ case: scenario.id, source, mode })}` + ); + if (mode === 'incremental') { + await waitForDicomConsumer(undefined, 'partial'); + await expectComposedVolumes(scenario.volumes, true); + await $('#complete-import').click(); + } + await waitForDicomConsumer(); + await expectComposedVolumes(scenario.volumes, false); + }); + } + } + } +}); + +describe('VolView downloaded collection import', () => { + for (const scenario of corpus.cases) { + it(`keeps the ${scenario.id} volumes separate in the application`, async () => { + const urls = scenario.volumes + .flatMap(({ files }) => files.map((name) => `tmp/${name}`)) + .reverse(); + await volViewPage.open(`?urls=[${urls.join(',')}]`); + await volViewPage.waitForVolumeCardSliceCounts( + scenario.volumes + .map(({ expected }) => expected.dimensions[2]) + .sort((a, b) => a - b) + ); + await volViewPage.waitForViews(); + const views = $$('[data-testid~="vtk-view"]'); + for (let index = 0; index < (await views.length); index++) + await volViewPage.waitForLoadingIndicator(views[index]); + await expect($('.loading-indicator')).not.toBeExisting(); + await waitForRenderedSlice( + `composition-${scenario.id}.png`, + 0, + scenario.id === 'pet-ct' + ); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); + } +}); diff --git a/tests/specs/dicom-volume-corpus.e2e.ts b/tests/specs/dicom-volume-corpus.e2e.ts new file mode 100644 index 000000000..20b6946e1 --- /dev/null +++ b/tests/specs/dicom-volume-corpus.e2e.ts @@ -0,0 +1,156 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { Key } from 'webdriverio'; +import corpus from '../dicomVolumeCorpus.json'; +import { DATASET_CACHE } from '../downloadDatasets'; +import { openDicomConsumer } from '../dicomConsumer'; +import { + fixtureBytes, + nullPaddedFixtureBytes, +} from '../fixtures/dicom-consumer/fixtureData'; +import { startDicomConsumerFixtures } from '../fixtures/dicom-consumer/server.mjs'; +import { volViewPage } from '../pageobjects/volview.page'; +import { + waitForRenderedSlice, + waitForRenderedSliceChange, +} from './dicomRenderedSlice'; + +type VolumeExpectation = { + dimensions: number[]; + range: number[]; + pixelHash: string; + spacing?: number[]; + origin?: number[]; + direction?: number[]; +}; + +const fractionalCalibration = { rescaleSlope: 1.9413152, rescaleIntercept: 0 }; +const calibratedValue = 5 * fractionalCalibration.rescaleSlope; +const pixelHash = (value: number) => + createHash('sha256') + .update(Buffer.from(new Float64Array(16).fill(value).buffer)) + .digest('hex'); + +// Expected hashes were independently decoded with pydicom. Each lossless +// variant must preserve every calibrated voxel, including frame order. +const cases: Array<{ + name: string; + expected?: VolumeExpectation; + expectedError?: string; +}> = [ + ...corpus, + { + name: 'null-padded-numbers.dcm', + expected: { + dimensions: [4, 4, 1], + range: [-10, -10], + spacing: [3, 2, 4], + origin: [10, 20, 30], + pixelHash: pixelHash(-10), + }, + }, + { + name: 'null-padded-fractional-rescale.dcm', + expected: { + dimensions: [4, 4, 1], + range: [calibratedValue, calibratedValue], + pixelHash: pixelHash(calibratedValue), + }, + }, +]; + +async function expectNumbers(id: string, expected: number[]) { + const actual = (await $(`#${id}`).getText()).split(', ').map(Number); + for (const [axis, value] of expected.entries()) { + expect(actual[axis]).toBeCloseTo(value, 8); + } +} + +describe('Public DICOM volume corpus', () => { + let fixture: Awaited>; + before(async () => { + const files = Object.fromEntries( + corpus.map(({ name }) => [ + name, + readFileSync(resolve(DATASET_CACHE, name)), + ]) + ); + fixture = await startDicomConsumerFixtures(fixtureBytes(), { + kinds: ['corpus'], + fixtures: { + ...files, + 'null-padded-numbers.dcm': nullPaddedFixtureBytes(), + 'null-padded-fractional-rescale.dcm': nullPaddedFixtureBytes( + fractionalCalibration + ), + }, + }); + }); + after(async () => fixture?.close()); + + for (const { name, expected, expectedError } of cases) { + for (const source of ['file', 'http']) { + const behavior = expectedError ? 'rejects' : 'preserves'; + it(`${behavior} ${name} pixels and geometry from ${source}`, async () => { + const params = new URLSearchParams({ name, source }); + await openDicomConsumer( + `${fixture.urls.corpus}?${params}`, + expectedError + ); + if (expectedError) return; + if (!expected) throw new Error(`Missing expectations for ${name}`); + await expect($('#dimensions')).toHaveText( + expected.dimensions.join(', ') + ); + await expect($('#pixel-hash')).toHaveText(expected.pixelHash); + await expectNumbers('range', expected.range); + for (const id of ['spacing', 'origin', 'direction'] as const) { + if (expected[id]) await expectNumbers(id, expected[id]); + } + }); + } + } +}); + +describe('VolView multiframe volume import', () => { + const volumes = corpus.filter( + ({ modality, expected }) => + expected && + expected.dimensions[2] > 1 && + (modality === 'CT' || modality === 'NM') + ); + for (const { name, expected } of volumes) { + it(`displays ${name} as a complete volume`, async () => { + await volViewPage.open(`?urls=[tmp/${name}]`); + await volViewPage.waitForVolumeCardSliceCounts([expected!.dimensions[2]]); + await volViewPage.waitForViews(); + await expect($('.loading-indicator')).not.toBeExisting(); + const before = await waitForRenderedSlice(`${name}-initial.png`); + await volViewPage.focusFirst2DView(); + const label = await $( + '[data-testid~="vtk-two-view"] .slice-label' + ).getText(); + const match = label.match(/Slice:\s*(\d+)\s*\/\s*(\d+)/); + if (!match) throw new Error('Expected a displayed slice counter'); + const slice = Number(match[1]); + const total = Number(match[2]); + if (total <= 1) throw new Error('Expected navigable image planes'); + const delta = slice > 1 ? -1 : 1; + const steps = Math.min(3, delta < 0 ? slice - 1 : total - slice); + for (let offset = 1; offset <= steps; offset++) { + await browser.keys([delta < 0 ? Key.ArrowDown : Key.ArrowUp]); + await browser.waitUntil( + async () => + (await volViewPage.getFirst2DSlice()) === slice + delta * offset, + { + timeoutMsg: + 'Expected the displayed slice counter to follow navigation', + } + ); + } + await waitForRenderedSliceChange(before, `${name}-navigated.png`); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); + } +}); diff --git a/tests/specs/dicomRenderedSlice.ts b/tests/specs/dicomRenderedSlice.ts new file mode 100644 index 000000000..a4dec4653 --- /dev/null +++ b/tests/specs/dicomRenderedSlice.ts @@ -0,0 +1,128 @@ +import { resolve } from 'node:path'; +import { PNG } from 'pngjs'; +import { TEMP_DIR } from '../../wdio.shared.conf'; + +type RenderedSlice = { width: number; height: number; data: Buffer }; + +async function captureSlice(fileName: string, viewIndex: number) { + const image = PNG.sync.read( + await browser.saveScreenshot(resolve(TEMP_DIR, fileName)) + ); + const geometry = await browser.execute((index) => { + const view = document.querySelectorAll('[data-testid~="vtk-two-view"]')[ + index + ]; + if (!view) return null; + const rect = view.getBoundingClientRect(); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; + }, viewIndex); + if (!geometry) throw new Error(`No slice viewport at index ${viewIndex}`); + const scaleX = image.width / geometry.viewportWidth; + const scaleY = image.height / geometry.viewportHeight; + const left = Math.ceil((geometry.x + geometry.width * 0.18) * scaleX); + const top = Math.ceil((geometry.y + geometry.height * 0.18) * scaleY); + const width = Math.floor(geometry.width * 0.64 * scaleX); + const height = Math.floor(geometry.height * 0.64 * scaleY); + if ( + width <= 0 || + height <= 0 || + left < 0 || + top < 0 || + left + width > image.width || + top + height > image.height + ) + throw new Error('The slice viewport has no interior image region'); + // The central image region excludes orientation labels, slice counters, + // sliders and viewport controls, so UI text cannot satisfy image assertions. + const data = Buffer.alloc(width * height * 4); + for (let row = 0; row < height; row++) { + const source = ((top + row) * image.width + left) * 4; + image.data.copy(data, row * width * 4, source, source + width * 4); + } + return { width, height, data }; +} + +function hasImageContent( + { width, height, data }: RenderedSlice, + color = false +) { + let visible = 0; + let midtone = 0; + const tones = new Set(); + for (let index = 0; index < data.length; index += 4) { + const red = data[index]; + const green = data[index + 1]; + const blue = data[index + 2]; + const intensity = (red + green + blue) / 3; + if (!color && Math.max(red, green, blue) - Math.min(red, green, blue) > 3) + continue; + if (intensity > 16) visible++; + if (intensity > 32 && intensity < 224) midtone++; + tones.add(Math.floor(intensity / 16)); + } + const count = width * height; + return ( + visible > count * 0.01 && + visible < count * 0.9 && + midtone > count * 0.003 && + tones.size >= 5 + ); +} + +function imageChanged(before: RenderedSlice, after: RenderedSlice) { + if (before.width !== after.width || before.height !== after.height) + return false; + let changed = 0; + for (let index = 0; index < before.data.length; index += 4) { + const delta = + Math.abs(before.data[index] - after.data[index]) + + Math.abs(before.data[index + 1] - after.data[index + 1]) + + Math.abs(before.data[index + 2] - after.data[index + 2]); + if (delta >= 24) changed++; + } + return changed > before.width * before.height * 0.002; +} + +export async function waitForRenderedSlice( + fileName: string, + viewIndex = 0, + color = false +) { + await $('header').moveTo(); + let image: RenderedSlice | undefined; + await browser.waitUntil( + async () => { + image = await captureSlice(fileName, viewIndex); + return hasImageContent(image, color); + }, + { + timeoutMsg: `Expected anatomical ${color ? 'color' : 'grayscale'} content inside the slice viewport`, + } + ); + return image!; +} + +export async function waitForRenderedSliceChange( + before: RenderedSlice, + fileName: string, + viewIndex = 0 +) { + await $('header').moveTo(); + await browser.waitUntil( + async () => { + const after = await captureSlice(fileName, viewIndex); + return hasImageContent(after) && imageChanged(before, after); + }, + { + timeoutMsg: + 'Expected slice navigation to change the interior image pixels', + } + ); +} diff --git a/tests/specs/incremental-positionless-import.e2e.ts b/tests/specs/incremental-positionless-import.e2e.ts new file mode 100644 index 000000000..1a9105fdf --- /dev/null +++ b/tests/specs/incremental-positionless-import.e2e.ts @@ -0,0 +1,116 @@ +// A second import must not dissolve the volumes the first one earned. +// +// One series holds two overlapping acquisitions, which load as two volumes. +// A later import brings one more slice of that series whose +// ImagePositionPatient is empty, so nothing can be said about where it sits. +// Both volumes must survive, the unplaceable slice must arrive as a volume of +// its own, and the user must be told about it. +// +// Synthetic DICOMs are generated on the fly so the test carries no binary +// fixtures. +import * as path from 'path'; +import * as fs from 'fs'; +import { volViewPage } from '../pageobjects/volview.page'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { dropFiles, writeManifestToFile } from './utils'; +import { buildSyntheticDicom, newUid } from './syntheticDicom'; + +const SLICE_SPACING = 2.5; +const ORIENTATION = [1, 0, 0, 0, 1, 0] as const; + +// Offset by half a slice, so the two passes overlap and the planner separates +// them. Slice counts differ so each volume card is identifiable by its label. +const ACQUISITIONS = [ + { number: 1, firstSliceZ: 0, sliceCount: 5 }, + { number: 2, firstSliceZ: 1.25, sliceCount: 6 }, +]; + +const DIR_NAME = 'incremental-positionless-import'; +const MANIFEST_NAME = 'incremental-positionless-import.json'; +const POSITIONLESS_NAME = 'positionless.dcm'; + +const seriesUid = newUid(); +const studyUid = newUid(); + +const dir = () => path.join(TEMP_DIR, DIR_NAME); + +function writeSeries() { + fs.mkdirSync(dir(), { recursive: true }); + + let instanceNumber = 0; + return ACQUISITIONS.flatMap(({ number, firstSliceZ, sliceCount }) => + Array.from({ length: sliceCount }, (_, i) => { + instanceNumber += 1; + const filename = `acq${number}-slice${i}.dcm`; + fs.writeFileSync( + path.join(dir(), filename), + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + instanceNumber, + acquisitionNumber: number, + imageOrientationPatient: ORIENTATION, + imagePositionPatient: [0, 0, firstSliceZ + i * SLICE_SPACING], + sliceThickness: SLICE_SPACING, + }) + ); + return { url: `tmp/${DIR_NAME}/${filename}`, name: filename }; + }) + ); +} + +// Same series and same hard facts as the two passes, so only its unreadable +// geometry can keep it out of them. +function writePositionlessSlice() { + fs.writeFileSync( + path.join(dir(), POSITIONLESS_NAME), + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + instanceNumber: 99, + imageOrientationPatient: ORIENTATION, + imagePositionPatient: null, + sliceThickness: SLICE_SPACING, + }) + ); +} + +describe('Incremental import of a slice with no readable position', () => { + before(async () => { + const resources = writeSeries(); + writePositionlessSlice(); + await writeManifestToFile({ resources }, MANIFEST_NAME); + }); + + it('keeps both acquisitions and warns about the slice it cannot place', async () => { + await volViewPage.open(`?urls=[tmp/${MANIFEST_NAME}]`); + await volViewPage.waitForViews(); + + const passes = ACQUISITIONS.map((a) => a.sliceCount).sort((a, b) => a - b); + await volViewPage.waitForVolumeCardSliceCounts(passes); + expect(await volViewPage.getNotificationsCount()).toEqual(0); + + await dropFiles([path.join(dir(), POSITIONLESS_NAME)]); + + // The two passes are untouched, and the unplaceable slice is a volume of + // its own rather than a member of either. + await volViewPage.waitForVolumeCardSliceCounts([1, ...passes]); + + await volViewPage.waitForNotification(); + + // The toast of that same warning covers the notifications button. + await browser.waitUntil( + async () => (await $$('.Vue-Toastification__toast').length) === 0, + { timeoutMsg: 'expected the warning toast to close' } + ); + + await volViewPage.notifications.click(); + const dialog = await $('.message-center'); + await dialog.waitForDisplayed(); + expect(await dialog.getText()).toContain( + 'A DICOM series did not load as one sound volume' + ); + }); +}); diff --git a/tests/specs/layers-and-rendering.e2e.ts b/tests/specs/layers-and-rendering.e2e.ts index fba5789e9..526931ac0 100644 --- a/tests/specs/layers-and-rendering.e2e.ts +++ b/tests/specs/layers-and-rendering.e2e.ts @@ -44,6 +44,9 @@ describe('Layers and Rendering', () => { await renderTab.click(); const view3D = await volViewPage.getView3D(); + await volViewPage.waitForLoadingIndicator( + $('[data-testid~="vtk-volume-view"]') + ); const volumeRenderingSection = await volViewPage.getVolumeRenderingSection(); diff --git a/tests/specs/multi-acquisition-series.e2e.ts b/tests/specs/multi-acquisition-series.e2e.ts new file mode 100644 index 000000000..6ecefb732 --- /dev/null +++ b/tests/specs/multi-acquisition-series.e2e.ts @@ -0,0 +1,83 @@ +// One DICOM series carrying several overlapping acquisitions must load as one +// volume per acquisition, not as a single stack of interleaved slices. +// +// GDCM's series-detail key covers SeriesNumber, SliceThickness, Rows and +// Columns but not AcquisitionNumber, so the categorize pipeline hands back all +// of these slices as one volume. Sorted by position they no longer sit on any +// single lattice, and the derived Z spacing describes none of the scans. +// +// Modelled on IDC series +// 1.3.6.1.4.1.14519.5.2.1.3098.5025.295130953269492004748715270821, which +// holds three 2.5mm chest/abdomen passes offset from each other by fractions +// of a slice. Synthetic DICOMs are generated on the fly so the test carries +// no binary fixtures. +import * as path from 'path'; +import * as fs from 'fs'; +import { volViewPage } from '../pageobjects/volview.page'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { writeManifestToFile } from './utils'; +import { buildSyntheticDicom, newUid } from './syntheticDicom'; + +const SLICE_SPACING = 2.5; + +// Each pass is uniform on its own; the sub-slice offsets are what make the +// merged stack irregular. Slice counts differ so each volume card is +// identifiable by its label alone. +const ACQUISITIONS = [ + { number: 1, firstSliceZ: 0, sliceCount: 5 }, + { number: 2, firstSliceZ: 0.75, sliceCount: 6 }, + { number: 3, firstSliceZ: 1.75, sliceCount: 4 }, +]; + +const DIR_NAME = 'multi-acquisition-series'; +const MANIFEST_NAME = 'multi-acquisition-series.json'; + +async function writeSeries() { + const studyUid = newUid(); + const seriesUid = newUid(); + const dir = path.join(TEMP_DIR, DIR_NAME); + fs.mkdirSync(dir, { recursive: true }); + + let instanceNumber = 0; + const resources = ACQUISITIONS.flatMap( + ({ number, firstSliceZ, sliceCount }) => + Array.from({ length: sliceCount }, (_, i) => { + instanceNumber += 1; + const filename = `acq${number}-slice${i}.dcm`; + fs.writeFileSync( + path.join(dir, filename), + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + instanceNumber, + acquisitionNumber: number, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [0, 0, firstSliceZ + i * SLICE_SPACING], + sliceThickness: SLICE_SPACING, + }) + ); + return { url: `tmp/${DIR_NAME}/${filename}`, name: filename }; + }) + ); + + await writeManifestToFile({ resources }, MANIFEST_NAME); +} + +describe('Multi-acquisition series: one series holding three overlapping scans', () => { + before(async () => { + await writeSeries(); + }); + + it('loads one volume per acquisition instead of one interleaved stack', async () => { + await volViewPage.open(`?urls=[tmp/${MANIFEST_NAME}]`); + await volViewPage.waitForViews(); + + const expected = ACQUISITIONS.map((a) => a.sliceCount).sort( + (a, b) => a - b + ); + + // One card per acquisition, each holding only that acquisition's slices. + await volViewPage.waitForVolumeCardSliceCounts(expected); + }); +}); diff --git a/tests/specs/pet-ct-sample.e2e.ts b/tests/specs/pet-ct-sample.e2e.ts index 82ad17552..a2e3e7be9 100644 --- a/tests/specs/pet-ct-sample.e2e.ts +++ b/tests/specs/pet-ct-sample.e2e.ts @@ -44,7 +44,9 @@ async function openAxialSample() { await volViewPage.open(); await selectLayout('Axial Only', 1, false); const sample = volViewPage.samplesList.$('[title="PET/CT Chest"]'); - await sample.scrollIntoView(); + await sample.execute((element) => + element.scrollIntoView({ block: 'center', behavior: 'instant' }) + ); await sample.waitForClickable(); await sample.click(); await volViewPage.waitForViews(); @@ -149,25 +151,28 @@ async function editPetColors( }; } -async function reopenSample( - expected: Awaited>, - range: string[], - preset = 'DICOM Hot Iron' -) { +async function showSampleData() { await $('[data-testid="module-tab-Data"]').click(); await waitForModuleTransitions(); const samplesSection = $('.v-expansion-panel-title*=Sample Data'); - if ((await samplesSection.getAttribute('aria-expanded')) === 'true') { + if ((await samplesSection.getAttribute('aria-expanded')) !== 'true') { await samplesSection.click(); - await expect(samplesSection).toHaveAttribute('aria-expanded', 'false'); + await expect(samplesSection).toHaveAttribute('aria-expanded', 'true'); await waitForModuleTransitions(); } - await samplesSection.click(); - await expect(samplesSection).toHaveAttribute('aria-expanded', 'true'); +} + +async function reopenSample( + expected: Awaited>, + range: string[], + preset = 'DICOM Hot Iron' +) { + await showSampleData(); const sample = volViewPage.samplesList.$('[title="PET/CT Chest"]'); await sample.waitForExist(); - await waitForModuleTransitions(); - await sample.scrollIntoView({ block: 'center', behavior: 'instant' }); + await sample.execute((element) => + element.scrollIntoView({ block: 'center', behavior: 'instant' }) + ); await browser.saveScreenshot(path.join(TEMP_DIR, 'pet-ct-sample-reload.png')); await sample.waitForClickable(); await sample.click(); @@ -313,16 +318,11 @@ describe('PET/CT sample', () => { await volViewPage.open(); await selectLayout('Four Up', 3, true); for (let loading = 0; loading < 2; loading++) { - await $('[data-testid="module-tab-Data"]').click(); - await waitForModuleTransitions(); - const samplesSection = $('.v-expansion-panel-title*=Sample Data'); - if ((await samplesSection.getAttribute('aria-expanded')) !== 'true') { - await samplesSection.click(); - await expect(samplesSection).toHaveAttribute('aria-expanded', 'true'); - await waitForModuleTransitions(); - } + await showSampleData(); const sample = volViewPage.samplesList.$('[title="PET/CT Chest"]'); - await sample.scrollIntoView({ block: 'center', behavior: 'instant' }); + await sample.execute((element) => + element.scrollIntoView({ block: 'center', behavior: 'instant' }) + ); await sample.waitForClickable(); await sample.click(); await expect($('[data-testid~="vtk-two-view"]')).toHaveText( diff --git a/tests/specs/sample-rendering.e2e.ts b/tests/specs/sample-rendering.e2e.ts index 632bee255..1020854e7 100644 --- a/tests/specs/sample-rendering.e2e.ts +++ b/tests/specs/sample-rendering.e2e.ts @@ -1,6 +1,7 @@ import AppPage from '../pageobjects/volview.page'; import { PROSTATEX_DATASET } from '../datasets'; import { useCachedSample } from './utils'; +import { DOWNLOAD_TIMEOUT } from '../../wdio.shared.conf'; const THRESHOLD = 12; // percent - handle pixel jitter in 3D view const RENDER_STABLE_TIMEOUT = 5000; @@ -11,7 +12,27 @@ describe('VolView', () => { await AppPage.open(); await AppPage.downloadProstateSample(); - await AppPage.waitForViews(); + + await browser.waitUntil( + async () => + browser.execute(() => { + const canvases = document.querySelectorAll( + '.layout-container [data-testid~="vtk-view"] canvas' + ); + return ( + canvases.length === 4 && + Array.from(canvases).every((element) => { + const canvas = element as HTMLCanvasElement; + return canvas.width > 10 && canvas.height > 10; + }) && + !document.querySelector('.layout-container .loading-indicator') + ); + }), + { + timeout: DOWNLOAD_TIMEOUT, + timeoutMsg: 'Expected all sample views to finish loading', + } + ); const layoutContainer = await $('.layout-container'); diff --git a/tests/specs/session-overlapping-imports.e2e.ts b/tests/specs/session-overlapping-imports.e2e.ts new file mode 100644 index 000000000..e0080eb48 --- /dev/null +++ b/tests/specs/session-overlapping-imports.e2e.ts @@ -0,0 +1,109 @@ +// A session saved after an incremental import must restore what the browser +// shows, not the membership the first import saw. +// +// One series holds two overlapping acquisitions. The first import loads one of +// them; a second import brings the other, and the planner regroups the series +// into two volumes. The session saved afterwards must restore both. Deleting +// one volume and saving again must restore only the survivor: a dataset the +// user removed cannot come back. +// +// Synthetic DICOMs are generated on the fly so the test carries no binary +// fixtures. +import * as path from 'path'; +import * as fs from 'fs'; +import { volViewPage } from '../pageobjects/volview.page'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { + SESSION_SAVE_TIMEOUT, + dropFiles, + waitForFileExists, + writeManifestToFile, +} from './utils'; +import { buildSyntheticDicom, newUid } from './syntheticDicom'; + +const SLICE_SPACING = 2.5; +const ORIENTATION = [1, 0, 0, 0, 1, 0] as const; + +// Offset by half a slice, so the two passes overlap and the planner separates +// them. Slice counts differ so each volume card is identifiable by its label. +const FIRST_PASS = { number: 1, firstSliceZ: 0, sliceCount: 5 }; +const SECOND_PASS = { number: 2, firstSliceZ: 1.25, sliceCount: 6 }; + +const DIR_NAME = 'session-overlapping-imports'; +const MANIFEST_NAME = 'session-overlapping-imports.json'; + +const seriesUid = newUid(); +const studyUid = newUid(); + +const dir = () => path.join(TEMP_DIR, DIR_NAME); + +type Pass = typeof FIRST_PASS; + +function writePass({ number, firstSliceZ, sliceCount }: Pass) { + fs.mkdirSync(dir(), { recursive: true }); + + return Array.from({ length: sliceCount }, (_, i) => { + const filename = `acq${number}-slice${i}.dcm`; + fs.writeFileSync( + path.join(dir(), filename), + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + // Unique across both passes, so no instance looks like a re-send. + instanceNumber: number * 100 + i, + acquisitionNumber: number, + imageOrientationPatient: ORIENTATION, + imagePositionPatient: [0, 0, firstSliceZ + i * SLICE_SPACING], + sliceThickness: SLICE_SPACING, + }) + ); + return { url: `tmp/${DIR_NAME}/${filename}`, name: filename }; + }); +} + +const saveSession = async () => { + const fileName = await volViewPage.saveSession(); + await waitForFileExists(path.join(TEMP_DIR, fileName), SESSION_SAVE_TIMEOUT); + return fileName; +}; + +const openSavedSession = async (fileName: string) => { + await volViewPage.open(`?urls=[tmp/${fileName}]`); + await volViewPage.waitForViews(); +}; + +describe('Session saved after an incremental import of one series', () => { + let secondPassFiles: string[]; + + before(async () => { + const resources = writePass(FIRST_PASS); + secondPassFiles = writePass(SECOND_PASS).map(({ name }) => name); + await writeManifestToFile({ resources }, MANIFEST_NAME); + }); + + it('restores both volumes, and drops one the user deleted', async () => { + await volViewPage.open(`?urls=[tmp/${MANIFEST_NAME}]`); + await volViewPage.waitForViews(); + await volViewPage.waitForVolumeCardSliceCounts([FIRST_PASS.sliceCount]); + + // The regroup this triggers replaces the volume the first import earned. + await dropFiles(secondPassFiles.map((name) => path.join(dir(), name))); + const bothPasses = [FIRST_PASS.sliceCount, SECOND_PASS.sliceCount].sort( + (a, b) => a - b + ); + await volViewPage.waitForVolumeCardSliceCounts(bothPasses); + + const withBoth = await saveSession(); + await openSavedSession(withBoth); + await volViewPage.waitForVolumeCardSliceCounts(bothPasses); + + await volViewPage.deleteVolumeCard(0); + const survivor = bothPasses.slice(1); + await volViewPage.waitForVolumeCardSliceCounts(survivor); + + const withSurvivor = await saveSession(); + await openSavedSession(withSurvivor); + await volViewPage.waitForVolumeCardSliceCounts(survivor); + }); +}); diff --git a/tests/specs/syntheticDicom.ts b/tests/specs/syntheticDicom.ts index eb9454a0d..36bfc54ca 100644 --- a/tests/specs/syntheticDicom.ts +++ b/tests/specs/syntheticDicom.ts @@ -1,12 +1,14 @@ // Minimal synthetic DICOM (Explicit VR Little Endian) for tests. // Emits just enough tags for ITK/GDCM to categorize and load a series: -// SOP Class/Instance UIDs, Study/SeriesInstanceUID, SeriesNumber, Modality, +// SOP Class/Instance UIDs, Study/SeriesInstanceUID, SeriesNumber, +// AcquisitionNumber, Modality, // Patient identifiers, ImageOrientationPatient, ImagePositionPatient, // PixelSpacing, SliceThickness, image geometry, and constant-valued PixelData. const SOP_CLASS_MR = '1.2.840.10008.5.1.4.1.1.4'; const SOP_CLASS_ULTRASOUND_MULTIFRAME = '1.2.840.10008.5.1.4.1.1.3.1'; const TS_EXPLICIT_VR_LE = '1.2.840.10008.1.2.1'; +const TS_IMPLICIT_VR_LE = '1.2.840.10008.1.2'; const enc = new TextEncoder(); @@ -105,6 +107,161 @@ const ds = (g: number, e: number, v: string) => const us = (g: number, e: number, v: number) => elemShort(g, e, 'US', writeShort(v)); +const writeDouble = (v: number) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setFloat64(0, v, true); + return b; +}; + +const fd = (g: number, e: number, v: number) => + elemShort(g, e, 'FD', writeDouble(v)); + +// A PN whose bytes are already encoded in some Specific Character Set, padded +// to DICOM's even length with a space. +const pnRaw = (g: number, e: number, v: Uint8Array) => + elemShort( + g, + e, + 'PN', + v.length % 2 === 0 ? v : combine(v, new Uint8Array([0x20])) + ); + +// VRs whose explicit form carries 2 reserved bytes and a 4-byte length. +const LONG_FORM_VRS = new Set([ + 'OB', + 'OD', + 'OF', + 'OL', + 'OV', + 'OW', + 'SQ', + 'SV', + 'UC', + 'UN', + 'UR', + 'UT', +]); + +// One Explicit VR LE element, short or long form as the VR requires. Lets a +// fixture write a VR this helper has no named writer for. +export const rawElement = ( + group: number, + element: number, + vr: string, + value: Uint8Array +) => + LONG_FORM_VRS.has(vr) + ? elemLong(group, element, vr, value) + : elemShort(group, element, vr, value); + +// An AT value is the referenced tag as two little-endian unsigned shorts. +export const attributeTagValue = (group: number, element: number) => + tagBytes(group, element); + +const ITEM_GROUP = 0xfffe; +const ITEM_TAG = 0xe000; +const ITEM_DELIMITER_TAG = 0xe00d; +const SEQUENCE_DELIMITER_TAG = 0xe0dd; +const UNDEFINED_LENGTH = 0xffffffff; + +// One sequence item of defined length, holding the elements given. +export const sequenceItem = (content: Uint8Array) => + combine(tagBytes(ITEM_GROUP, ITEM_TAG), writeLong(content.length), content); + +// One sequence item whose length the delimiter that follows it declares. +export const undefinedLengthItem = (content: Uint8Array) => + combine( + tagBytes(ITEM_GROUP, ITEM_TAG), + writeLong(UNDEFINED_LENGTH), + content, + tagBytes(ITEM_GROUP, ITEM_DELIMITER_TAG), + writeLong(0) + ); + +/** + * An Explicit VR LE sequence whose length the delimiter that follows it + * declares, the form an item's own length cannot be stepped over in. Not + * convertible to Implicit VR by `buildSyntheticDicom`. + */ +export const undefinedLengthSequence = ( + group: number, + element: number, + items: Uint8Array +) => + combine( + tagBytes(group, element), + enc.encode('SQ'), + new Uint8Array(2), + writeLong(UNDEFINED_LENGTH), + items, + tagBytes(ITEM_GROUP, SEQUENCE_DELIMITER_TAG), + writeLong(0) + ); + +/** An unknown sequence retains its Implicit VR LE items inside an explicit UN. */ +export const undefinedLengthUnknown = ( + group: number, + element: number, + items: Uint8Array +) => { + const bytes = undefinedLengthSequence(group, element, items); + bytes.set(enc.encode('UN'), 4); + return bytes; +}; + +// Items and delimiters carry no VR in either syntax, so only the elements +// inside each item change shape. Item lengths must be defined. +const itemsToImplicitVr = (value: Uint8Array): Uint8Array => { + const view = new DataView(value.buffer, value.byteOffset, value.length); + const out: Uint8Array[] = []; + let offset = 0; + while (offset < value.length) { + const group = view.getUint16(offset, true); + const element = view.getUint16(offset + 2, true); + const length = view.getUint32(offset + 4, true); + const contentStart = offset + 8; + if (group !== 0xfffe) + throw new Error(`Expected an item tag, got ${group.toString(16)}`); + if (element !== ITEM_TAG) { + out.push(value.subarray(offset, contentStart)); + offset = contentStart; + continue; + } + if (length === UNDEFINED_LENGTH) + throw new Error('Undefined-length items are not supported'); + const content = toImplicitVr( + value.subarray(contentStart, contentStart + length) + ); + out.push(tagBytes(group, element), writeLong(content.length), content); + offset = contentStart + length; + } + return combine(...out); +}; + +// Re-encodes an Explicit VR LE dataset as Implicit VR LE: tag, 4-byte length, +// value, with the VR carried only by the reader's dictionary. +export const toImplicitVr = (dataset: Uint8Array): Uint8Array => { + const view = new DataView(dataset.buffer, dataset.byteOffset, dataset.length); + const out: Uint8Array[] = []; + let offset = 0; + while (offset < dataset.length) { + const group = view.getUint16(offset, true); + const element = view.getUint16(offset + 2, true); + const vr = String.fromCharCode(dataset[offset + 4], dataset[offset + 5]); + const longForm = LONG_FORM_VRS.has(vr); + const length = longForm + ? view.getUint32(offset + 8, true) + : view.getUint16(offset + 6, true); + const valueStart = offset + (longForm ? 12 : 8); + const explicitValue = dataset.subarray(valueStart, valueStart + length); + const value = + vr === 'SQ' ? itemsToImplicitVr(explicitValue) : explicitValue; + out.push(combine(tagBytes(group, element), writeLong(value.length), value)); + offset = valueStart + length; + } + return combine(...out); +}; + // DICOM element values must have an even length, so an odd-sized 8-bit frame // gets a trailing pad byte. const frameBytes8 = (sampleCount: number, value: number) => { @@ -121,6 +278,16 @@ const frameBytes16 = (sampleCount: number, value: number) => { return bytes; }; +// A single-item sequence with an explicit item length, so a reader that skips +// sequences and one that descends into them both stay in step. +const sequenceOfOneItem = (g: number, e: number, item: Uint8Array) => + elemLong( + g, + e, + 'SQ', + combine(tagBytes(0xfffe, 0xe000), writeLong(item.length), item) + ); + export type SyntheticSliceOptions = { studyUid: string; seriesUid: string; @@ -134,7 +301,9 @@ export type SyntheticSliceOptions = { number, number, ]; - imagePositionPatient: readonly [number, number, number]; + // Null writes a zero length ImagePositionPatient, which reads as a slice + // whose geometry is unknown. + imagePositionPatient: readonly [number, number, number] | null; rows?: number; cols?: number; bitsAllocated?: number; @@ -146,14 +315,32 @@ export type SyntheticSliceOptions = { // Stored value written to every sample. Signed values are written as two's // complement, so they pair with pixelRepresentation 1. pixelValue?: number; + // One constant sample value per frame; writes NumberOfFrames when supplied. + framePixelValues?: readonly number[]; + // Enhanced images carry these attributes in Functional Groups instead. + rootGeometry?: boolean; pixelSpacing?: readonly [number, number]; spacingBetweenSlices?: number; sliceThickness?: number; modality?: string; + sopClassUid?: string; patientName?: string; patientId?: string; seriesNumber?: number; + acquisitionNumber?: number; studyDate?: string; + // (0008,0005), e.g. 'ISO 2022 IR 6\\ISO 2022 IR 87'. Omitted when absent. + specificCharacterSet?: string; + // PatientName bytes already encoded in specificCharacterSet, used instead of + // patientName. + patientNameBytes?: Uint8Array; + // Encodes the dataset (not the file meta group) as Implicit VR LE. + implicitVr?: boolean; + // Writes a Sequence of Ultrasound Regions (0018,6011) holding one item. + ultrasoundRegion?: { physicalDeltaX: number; physicalDeltaY: number }; + // Explicit VR LE element bytes, from `rawElement`, written just before Pixel + // Data. Tags must sort after (0028,1053) to keep the data set ascending. + extraElements?: readonly Uint8Array[]; }; export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { @@ -173,14 +360,23 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { rescaleSlope, rescaleIntercept, pixelValue = 0, + framePixelValues, + rootGeometry = true, pixelSpacing = [1, 1] as const, spacingBetweenSlices, sliceThickness = 1, modality = 'MR', + sopClassUid = SOP_CLASS_MR, patientName = 'TEST', patientId = 'TEST001', seriesNumber = 1, + acquisitionNumber, studyDate = '20260101', + specificCharacterSet, + patientNameBytes, + implicitVr = false, + ultrasoundRegion, + extraElements = [], } = opts; if (bitsAllocated !== 8 && bitsAllocated !== 16) { @@ -189,13 +385,26 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { ); } - const dataset = combine( - ui(0x0008, 0x0016, SOP_CLASS_MR), + const pixels = combine( + ...(framePixelValues ?? [pixelValue]).map((value) => + bitsAllocated === 8 + ? frameBytes8(rows * cols, value) + : frameBytes16(rows * cols, value) + ) + ); + + const explicitDataset = combine( + ...(specificCharacterSet == null + ? [] + : [cs(0x0008, 0x0005, specificCharacterSet)]), + ui(0x0008, 0x0016, sopClassUid), ui(0x0008, 0x0018, sopUid), da(0x0008, 0x0020, studyDate), da(0x0008, 0x0021, studyDate), cs(0x0008, 0x0060, modality), - pn(0x0010, 0x0010, patientName), + patientNameBytes == null + ? pn(0x0010, 0x0010, patientName) + : pnRaw(0x0010, 0x0010, patientNameBytes), lo(0x0010, 0x0020, patientId), da(0x0010, 0x0030, '19700101'), cs(0x0010, 0x0040, 'O'), @@ -203,26 +412,54 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { ...(spacingBetweenSlices == null ? [] : [ds(0x0018, 0x0088, String(spacingBetweenSlices))]), + ...(ultrasoundRegion == null + ? [] + : [ + sequenceOfOneItem( + 0x0018, + 0x6011, + combine( + us(0x0018, 0x6024, 3), + us(0x0018, 0x6026, 3), + fd(0x0018, 0x602c, ultrasoundRegion.physicalDeltaX), + fd(0x0018, 0x602e, ultrasoundRegion.physicalDeltaY) + ) + ), + ]), ui(0x0020, 0x000d, studyUid), ui(0x0020, 0x000e, seriesUid), sh(0x0020, 0x0010, '1'), is(0x0020, 0x0011, String(seriesNumber)), + ...(acquisitionNumber == null + ? [] + : [is(0x0020, 0x0012, String(acquisitionNumber))]), is(0x0020, 0x0013, String(instanceNumber)), - ds( - 0x0020, - 0x0032, - imagePositionPatient.map((n) => n.toString()).join('\\') - ), - ds( - 0x0020, - 0x0037, - imageOrientationPatient.map((n) => n.toString()).join('\\') - ), + ...(rootGeometry + ? [ + ds( + 0x0020, + 0x0032, + imagePositionPatient === null + ? '' + : imagePositionPatient.map((n) => n.toString()).join('\\') + ), + ds( + 0x0020, + 0x0037, + imageOrientationPatient.map((n) => n.toString()).join('\\') + ), + ] + : []), us(0x0028, 0x0002, 1), cs(0x0028, 0x0004, 'MONOCHROME2'), + ...(framePixelValues + ? [is(0x0028, 0x0008, String(framePixelValues.length))] + : []), us(0x0028, 0x0010, rows), us(0x0028, 0x0011, cols), - ds(0x0028, 0x0030, pixelSpacing.map((n) => n.toString()).join('\\')), + ...(rootGeometry + ? [ds(0x0028, 0x0030, pixelSpacing.map((n) => n.toString()).join('\\'))] + : []), us(0x0028, 0x0100, bitsAllocated), us(0x0028, 0x0101, bitsStored), us(0x0028, 0x0102, highBit), @@ -231,16 +468,17 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { ? [] : [ds(0x0028, 0x1052, String(rescaleIntercept))]), ...(rescaleSlope == null ? [] : [ds(0x0028, 0x1053, String(rescaleSlope))]), - bitsAllocated === 8 - ? elemLong(0x7fe0, 0x0010, 'OB', frameBytes8(rows * cols, pixelValue)) - : elemLong(0x7fe0, 0x0010, 'OW', frameBytes16(rows * cols, pixelValue)) + ...extraElements, + elemLong(0x7fe0, 0x0010, bitsAllocated === 8 ? 'OB' : 'OW', pixels) ); + const dataset = implicitVr ? toImplicitVr(explicitDataset) : explicitDataset; + const fileMetaBody = combine( elemLong(0x0002, 0x0001, 'OB', new Uint8Array([0x00, 0x01])), - ui(0x0002, 0x0002, SOP_CLASS_MR), + ui(0x0002, 0x0002, sopClassUid), ui(0x0002, 0x0003, sopUid), - ui(0x0002, 0x0010, TS_EXPLICIT_VR_LE) + ui(0x0002, 0x0010, implicitVr ? TS_IMPLICIT_VR_LE : TS_EXPLICIT_VR_LE) ); const fileMeta = combine( elemShort(0x0002, 0x0000, 'UL', writeLong(fileMetaBody.length)), @@ -250,6 +488,24 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { return combine(new Uint8Array(128), enc.encode('DICM'), fileMeta, dataset); } +/** The bytes a Part 10 file spends on its preamble and DICM prefix. */ +const PART_10_HEADER_LENGTH = 132; + +/** The same file with its preamble and prefix cut off: file meta comes first. */ +export const stripPreamble = (bytes: Uint8Array) => + bytes.slice(PART_10_HEADER_LENGTH); + +/** The same file as a bare data set: no preamble, prefix or file meta. */ +export const stripFileMeta = (bytes: Uint8Array) => { + const meta = stripPreamble(bytes); + // (0002,0000) UL: tag, VR and a 16 bit length, then the group length. + const bodyLength = new DataView(meta.buffer, meta.byteOffset).getUint32( + 8, + true + ); + return meta.slice(12 + bodyLength); +}; + export type SyntheticCineOptions = { studyUid: string; seriesUid: string; @@ -290,6 +546,7 @@ export function buildSyntheticCineDicom( da(0x0008, 0x0020, studyDate), da(0x0008, 0x0021, studyDate), cs(0x0008, 0x0060, 'US'), + lo(0x0008, 0x103e, 'Synthetic cine'), pn(0x0010, 0x0010, patientName), lo(0x0010, 0x0020, patientId), da(0x0010, 0x0030, '19700101'), @@ -298,7 +555,6 @@ export function buildSyntheticCineDicom( ui(0x0020, 0x000e, seriesUid), sh(0x0020, 0x0010, '1'), is(0x0020, 0x0011, String(seriesNumber)), - lo(0x0008, 0x103e, 'Synthetic cine'), us(0x0028, 0x0002, 1), cs(0x0028, 0x0004, 'MONOCHROME2'), is(0x0028, 0x0008, String(numberOfFrames)), diff --git a/tests/specs/utils.ts b/tests/specs/utils.ts index 01532ec11..2ee03a8d4 100644 --- a/tests/specs/utils.ts +++ b/tests/specs/utils.ts @@ -299,6 +299,40 @@ export async function useCachedSample(dataset: { ); } +/** Drops local files onto the open app using its files-list import path. */ +export async function dropFiles(filePaths: string[]) { + const encoded = filePaths.map((filePath) => ({ + name: path.basename(filePath), + base64: fs.readFileSync(filePath).toString('base64'), + })); + const dropped = await browser.execute( + (files: Array<{ name: string; base64: string }>) => { + const transfer = new DataTransfer(); + files.forEach(({ name, base64 }) => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) + bytes[i] = binary.charCodeAt(i); + transfer.items.add(new File([bytes], name)); + }); + + const target = document.querySelector('#app-container'); + if (!target) return false; + + const drop = new Event('drop', { bubbles: true, cancelable: true }); + // Synthetic items have no filesystem entry, so use the files list. + Object.defineProperty(drop, 'dataTransfer', { + value: { items: null, files: transfer.files }, + }); + // dispatchEvent reports preventDefault, not delivery. + target.dispatchEvent(drop); + return true; + }, + encoded + ); + expect(dropped).toBe(true); +} + /** Loads a file into the page already open, through the Open files dialog. */ export async function openThroughDialog( filePath: string, diff --git a/tsconfig.json b/tsconfig.json index 1b262cfbe..c2bfc39df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,12 @@ "paths": { "@/*": [ "./*" + ], + "@volview/dicom/core": [ + "./packages/dicom/core/index.ts" + ], + "@volview/dicom/vtk": [ + "./packages/dicom/vtk/index.ts" ] }, "lib": [ @@ -43,10 +49,12 @@ "backend-contract/**/*.ts", "test/**/*.ts", "tests/**/*.ts", - "tests/**/*.tsx" + "tests/**/*.tsx", + "packages/dicom/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "packages/dicom/dist" ], "ts-node": { "esm": true, diff --git a/vite.config.ts b/vite.config.ts index d17b0d6a1..64a6423cf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -246,13 +246,6 @@ export default defineConfig({ ), dest: 'itk/pipelines', }, - { - src: resolvePath( - rootDir, - 'src/io/itk-dicom/emscripten-build/**/dicom*' - ), - dest: 'itk/pipelines', - }, { src: resolvePath( rootDir, @@ -289,6 +282,9 @@ export default defineConfig({ environment: 'happy-dom', // canvas support. See: https://github.com/vitest-dev/vitest/issues/740 maxWorkers: 1, + // A store that outlives an import must not pin its chunks, which only a + // collection proves. + execArgv: ['--expose-gc'], server: { deps: { inline: ['vuetify'], From 4a70745b02d961d832099a0e6f1511bfa9ef1c9c Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 5 Nov 2026 08:28:45 -0500 Subject: [PATCH 2/4] fix(dicom): make imports atomic and remove barrel modules --- eslint.config.js | 19 ++- packages/dicom/README.md | 29 +++- packages/dicom/__tests__/boundaries.spec.ts | 17 +-- .../__tests__/collectionRegistry.spec.ts | 44 ++++++ .../splitOverlappingAcquisitions.spec.ts | 55 +++++++ packages/dicom/core/collections/chunkFacts.ts | 22 +++ .../core/collections/collectionRegistry.ts | 75 ++++------ packages/dicom/core/collections/groupBy.ts | 11 ++ .../dicom/core/collections/instanceFacts.ts | 15 +- .../core/collections/planDicomCollections.ts | 18 +-- .../splitOverlappingAcquisitions.ts | 39 +++-- packages/dicom/core/index.ts | 32 ---- .../metadata/__tests__/dcmjsParser.spec.ts | 108 ++++++++++++++ packages/dicom/core/metadata/dcmjs.d.ts | 1 + packages/dicom/core/metadata/dcmjsParser.ts | 14 +- packages/dicom/core/metadata/dicomDataset.ts | 10 +- .../streaming/__tests__/blobFetcher.spec.ts | 8 - .../__tests__/cachedStreamFetcher.spec.ts | 12 +- .../core/streaming/cachedStreamFetcher.ts | 4 - .../__tests__/dicomMetaLoaderSource.spec.ts | 14 +- .../core/streaming/dicom/dicomMetaLoader.ts | 13 -- .../dicom/core/streaming/dicom/dicomParser.ts | 116 +++++++++------ packages/dicom/core/streaming/types.ts | 6 - packages/dicom/package.json | 12 +- packages/dicom/scripts/build.mjs | 35 ++++- packages/dicom/scripts/check-consumers.mjs | 7 +- packages/dicom/vtk/VtkDicomImage.ts | 120 +++++++-------- packages/dicom/vtk/__tests__/consumer.spec.ts | 58 +++++++- .../vtk/__tests__/functionalGroups.spec.ts | 49 +++++- packages/dicom/vtk/allocateImageFromChunks.ts | 33 +++-- packages/dicom/vtk/index.ts | 4 - packages/dicom/vtk/nmGeometry.ts | 6 +- packages/dicom/vtk/prepareChunkAllocation.ts | 61 ++++++++ packages/dicom/vtk/preparedChunks.ts | 42 ++++++ packages/dicom/vtk/thumbnailSource.ts | 2 +- packages/dicom/vtk/types.ts | 3 +- packages/dicom/vtk/ultrasoundSpacing.ts | 8 +- src/components/App.vue | 2 +- src/components/ModulePanel.vue | 3 +- src/components/Settings.vue | 2 +- src/components/SliceViewer.vue | 3 +- ...tientStudyVolumeBrowser.thumbnails.spec.ts | 2 +- src/components/__tests__/latentGating.spec.ts | 4 +- .../vtk/VtkBaseVolumeRepresentation.vue | 2 +- src/core/cine/DicomCineImage.ts | 4 +- src/core/progressiveImage.ts | 2 +- .../__tests__/dicomChunkImage.spec.ts | 20 ++- .../__tests__/dicomThumbnail.spec.ts | 5 +- src/core/streaming/chunkImage.ts | 5 +- src/core/streaming/dicomChunkImage.ts | 15 +- src/io/__tests__/dicom.spec.ts | 68 --------- .../__tests__/dicomLoaderUnification.spec.ts | 68 --------- src/io/__tests__/io.spec.ts | 2 +- src/io/__tests__/itkDicomPipeline.spec.ts | 140 ------------------ .../readDicomTags.differential.spec.ts | 3 +- src/io/__tests__/readDicomTags.spec.ts | 6 +- src/io/__tests__/readDicomTagsFixtures.ts | 5 + src/io/__tests__/sourceTree.ts | 38 ----- .../__tests__/configRecognition.spec.ts | 4 +- src/io/import/__tests__/dataSource.spec.ts | 2 +- .../__tests__/dicomChunkProvenance.spec.ts | 6 +- .../processingConfigInjection.spec.ts | 33 ++--- src/io/import/dataSource.ts | 4 +- src/io/import/importDataSources.ts | 6 +- .../processors/__tests__/handleDicom.spec.ts | 4 +- src/io/import/processors/handleDicom.ts | 6 +- src/io/import/processors/importSingleFile.ts | 2 +- src/io/import/processors/openUriStream.ts | 2 +- .../import/processors/updateFileMimeType.ts | 2 +- src/io/import/processors/updateUriType.ts | 2 +- src/io/magic.ts | 2 +- src/io/readDicomTags.ts | 18 +-- src/io/{index.ts => readerRegistry.ts} | 2 - src/io/readers.ts | 2 +- src/io/state-file/serialize.ts | 2 +- src/io/vtk/common.ts | 2 +- src/main.js | 2 +- src/processing/__tests__/config.spec.ts | 2 +- src/processing/{index.ts => integration.ts} | 9 +- src/referenceLines/index.ts | 7 - .../__tests__/artifactParentRestore.spec.ts | 4 +- .../datasets-dicom-acquisition-split.spec.ts | 4 +- .../__tests__/datasets-dicom-cine.spec.ts | 15 +- .../__tests__/datasets-dicom-geometry.spec.ts | 4 +- .../__tests__/datasets-dicom-lifetime.spec.ts | 26 ++-- .../__tests__/datasets-dicom-reimport.spec.ts | 2 +- .../__tests__/datasets-dicom-staging.spec.ts | 96 ++++++++++++ .../datasets-dicom-transaction.spec.ts | 8 +- src/store/__tests__/dicomImportFixtures.ts | 41 ++++- src/store/__tests__/dicomTagFixtures.ts | 2 +- .../__tests__/duplicateDatasetSave.spec.ts | 4 +- src/store/datasets-dicom.ts | 101 ++++--------- src/store/datasets.ts | 6 +- src/store/dicom-import-session.ts | 4 +- src/store/dicom-runtime.ts | 2 +- src/types/image.ts | 2 +- src/utils/__tests__/dicomWindowRange.spec.ts | 2 +- .../__tests__/allocateImageFromChunks.spec.ts | 10 +- src/utils/dicom/dicomChunks.ts | 2 +- src/utils/dicomWindowRange.ts | 2 +- tests/dicomDataset.ts | 2 +- tests/dicomSourceFixtures.ts | 20 +-- tests/fixtures/dicom-consumer/composition.ts | 10 +- tests/fixtures/dicom-consumer/core.ts | 2 +- tests/fixtures/dicom-consumer/corpus.ts | 2 +- .../fixtures/dicom-consumer/describeVolume.ts | 2 +- tests/fixtures/dicom-consumer/loadVolume.ts | 6 +- tests/fixtures/dicom-consumer/shared.ts | 10 +- tests/pageobjects/volview.page.ts | 3 +- tests/specs/dicom-dimension-mismatch.e2e.ts | 2 - tests/specs/sample-rendering.e2e.ts | 2 - tsconfig.json | 25 +--- 112 files changed, 1122 insertions(+), 943 deletions(-) create mode 100644 packages/dicom/core/collections/chunkFacts.ts create mode 100644 packages/dicom/core/collections/groupBy.ts delete mode 100644 packages/dicom/core/index.ts delete mode 100644 packages/dicom/vtk/index.ts create mode 100644 packages/dicom/vtk/prepareChunkAllocation.ts create mode 100644 packages/dicom/vtk/preparedChunks.ts delete mode 100644 src/io/__tests__/dicom.spec.ts delete mode 100644 src/io/__tests__/dicomLoaderUnification.spec.ts delete mode 100644 src/io/__tests__/itkDicomPipeline.spec.ts delete mode 100644 src/io/__tests__/sourceTree.ts rename src/io/{index.ts => readerRegistry.ts} (96%) rename src/processing/{index.ts => integration.ts} (81%) delete mode 100644 src/referenceLines/index.ts create mode 100644 src/store/__tests__/datasets-dicom-staging.spec.ts diff --git a/eslint.config.js b/eslint.config.js index 9cc858c32..b485254f2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,7 +12,7 @@ import globals from 'globals'; // not a dependency of this repo, and the built-in rule expresses the same // zones with no new dependency. Two zones per feature: // 1. Code OUTSIDE the feature directory may reach it ONLY through its public -// surface `@/src/` (the index), never a deep path. +// modules listed in its public surface. // 2. The feature's pure layer may not import stores, components, or the // upper feature modules, and stays framework-free (no pinia, no vue) — // dependencies point downward only. @@ -65,14 +65,18 @@ const upperLayerMessage = (feature) => `The ${feature.dir} pure layer must not import stores, components, or upper feature modules. Dependencies point downward only.`; const dicomPublicSurface = { - regex: '^@/packages/dicom/(?!core(?:/index)?$|vtk(?:/index)?$)', - message: 'Use the DICOM core or vtk public entry point.', + regex: '^@/packages/dicom/(?!core/.+|vtk/.+)', + message: 'Import the defining DICOM core or vtk module directly.', }; const featureBoundaries = (features) => { - const publicSurface = ({ dir }) => ({ - group: [`@/src/${dir}/*`, `@/src/${dir}/*/**`, `!@/src/${dir}/index`], - message: `Import the ${dir} feature only from its public surface \`@/src/${dir}\` (src/${dir}/index.ts). A deep import bypasses the feature boundary.`, + const publicSurface = ({ dir, publicModules }) => ({ + group: [ + `@/src/${dir}/*`, + `@/src/${dir}/*/**`, + ...publicModules.map((module) => `!@/src/${dir}/${module}`), + ], + message: `Import the ${dir} feature only through its defining public modules: ${publicModules.join(', ')}.`, }); const publicFeatures = features.filter( (feature) => feature.publicSurface !== false @@ -161,7 +165,6 @@ const E2E_TIMEOUT_DEBT = [ 'tests/specs/cine-ruler-session.e2e.ts', 'tests/specs/crosshairs-multi-image.e2e.ts', 'tests/specs/delete-selected-annotation.e2e.ts', - 'tests/specs/dicom-dimension-mismatch.e2e.ts', 'tests/specs/different-direction-labelmap.e2e.ts', 'tests/specs/imageCacheUtils.ts', 'tests/specs/layer-source-lifetime.e2e.ts', @@ -348,6 +351,7 @@ export default tseslint.config( }, { dir: 'processing', + publicModules: ['integration', 'store', 'types'], pure: [ 'src/processing/engine/**/*.{js,ts}', 'src/processing/types.ts', @@ -356,6 +360,7 @@ export default tseslint.config( }, { dir: 'referenceLines', + publicModules: ['ReferenceLines.vue', 'store'], pure: [ 'src/referenceLines/geometry.ts', 'src/referenceLines/crossings.ts', diff --git a/packages/dicom/README.md b/packages/dicom/README.md index 9a0e79523..6beebce2f 100644 --- a/packages/dicom/README.md +++ b/packages/dicom/README.md @@ -1,18 +1,21 @@ # Browser DICOM module This private directory uses VolView's root install, lockfile and checks. It has -no workspace install or release workflow. VolView imports the source entry -points; the root npm distribution includes the core and vtk source files +no workspace install or release workflow. VolView imports defining source +modules directly; the root npm distribution includes the core and vtk source files alongside `dist`, `src` and `backend-contract`. Package tests and standalone build output are excluded from that distribution. -- `core/index.ts`: Blob and HTTP sources, structured `DicomDataset` metadata, +- `core/`: Blob and HTTP sources, structured `DicomDataset` metadata, normalized facts, collection planning and stable identity, owned loading resources, and calibrated typed pixel arrays. It has no Vue, Pinia or vtk.js dependency. -- `vtk/index.ts`: `VtkDicomImage`, allocation and validated pixel writes, +- `vtk/`: `VtkDicomImage`, allocation and validated pixel writes, reconstruction policy, ranges, image notifications and vtk cleanup. vtk.js is - an optional peer, required only by consumers of this entry point. + an optional peer, required only by consumers of these modules. + +The package exports concrete module subpaths under `@volview/dicom/core/*` and +`@volview/dicom/vtk/*`; there are no aggregate entry points. VolView owns its reactive wrappers, messages, browser state, selections, annotations, layers, import transactions and session provenance. The @@ -27,7 +30,7 @@ GDCM image reader assets. Serve the emitted assets from a URL your application controls, and pass their locations explicitly: ```ts -import { createDicomLoader } from '@volview/dicom/core'; +import { createDicomLoader } from '@volview/dicom/core/runtime/createDicomLoader'; const loader = createDicomLoader({ assets: { @@ -87,9 +90,19 @@ rejects stale decode results after reallocation or disposal. It owns and deletes its vtk images, but borrows its chunks. Dispose the vtk consumer before releasing its borrowed chunks, then dispose the loader. +`await volume.prepareChunks(members, orientation, reconstruction)` prepares an +allocation without changing the displayed image, emitting image events or +decoding loaded chunks. It returns `{ changed, commit, dispose }`. `commit()` +publishes the prepared allocation synchronously; `dispose()` releases it if it +has not been committed. Disposing the volume also releases its pending +allocations. An importer can prepare all affected volumes, dispose preparations +if any fails, and publish only after every preparation succeeds. Abandoning a +preparation leaves the displayed pixels intact. `setChunks()` combines +preparation and publication for an immediate update. + ```ts -import { createDicomCollectionRegistry } from '@volview/dicom/core'; -import { VtkDicomImage } from '@volview/dicom/vtk'; +import { createDicomCollectionRegistry } from '@volview/dicom/core/collections/collectionRegistry'; +import { VtkDicomImage } from '@volview/dicom/vtk/VtkDicomImage'; const registry = createDicomCollectionRegistry(); const { updates } = await registry.register(chunks); diff --git a/packages/dicom/__tests__/boundaries.spec.ts b/packages/dicom/__tests__/boundaries.spec.ts index 6e3614c41..9acba20c0 100644 --- a/packages/dicom/__tests__/boundaries.spec.ts +++ b/packages/dicom/__tests__/boundaries.spec.ts @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; @@ -52,9 +52,6 @@ const importsOf = (file: string) => { for (const entry of ['core', 'vtk']) { describe(`DICOM ${entry} dependency boundary`, () => { const sources = sourceFiles(resolve(root, entry)); - it('has a public entry point', () => { - expect(sources).toContain(resolve(root, entry, 'index.ts')); - }); it.each(sources.map((file) => relative(root, file)))( '%s stays inside its declared boundary', (name) => { @@ -81,7 +78,7 @@ for (const entry of ['core', 'vtk']) { }); } -it('VolView reaches the package only through its public entry points', () => { +it('VolView imports defining core and vtk modules directly', () => { const repository = resolve(root, '../..'); for (const file of sourceFiles(resolve(repository, 'src'))) { for (const specifier of importsOf(file)) { @@ -89,12 +86,10 @@ it('VolView reaches the package only through its public entry points', () => { ? resolve(repository, specifier.slice(2)) : resolve(dirname(file), specifier); if (!target.startsWith(`${root}/`)) continue; - expect( - relative(root, target) - .replace(/\.ts$/, '') - .replace(/\/index$/, ''), - relative(repository, file) - ).toMatch(/^(core|vtk)$/); + const module = relative(root, target).replace(/\.ts$/, ''); + expect(module, relative(repository, file)).toMatch(/^(core|vtk)\/.+/); + expect(module.endsWith('/index'), relative(repository, file)).toBe(false); + expect(existsSync(`${target.replace(/\.ts$/, '')}.ts`)).toBe(true); } } }); diff --git a/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts b/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts index 96d0243b5..481d3b654 100644 --- a/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts +++ b/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts @@ -65,6 +65,11 @@ const sops = (chunks: Chunk[]) => chunks.map(sopOf); const byId = (updates: CollectionUpdate[]) => [...updates].sort((left, right) => (left.id < right.id ? -1 : 1)); +const collidingMultiframeChunks = (series = SERIES_UID) => + ['1', null].map((sop) => + chunkFor({ sop, series, tags: { [Tags.NumberOfFrames]: '2' } }) + ); + describe('DICOM collection registry', () => { it('plans one consistent series into one ordered collection', async () => { const registry = createDicomCollectionRegistry(); @@ -595,6 +600,45 @@ describe('DICOM collection registry', () => { expect(secondResult.updates[0].id).toBe(firstResult.updates[0].id); }); + it('preserves existing ownership when a failed plan precedes a healthy import', async () => { + const registry = createDicomCollectionRegistry(); + const [original, invalid] = collidingMultiframeChunks(); + const [{ id }] = (await registry.register([original])).updates; + const healthy = chunkFor({ sop: '1.2.3.4' }); + + const failed = registry.register([invalid]); + const next = registry.register([healthy]); + + await expect(failed).rejects.toThrow(/two collections share the key/); + const { updates } = await next; + expect(updates).toHaveLength(1); + expect(updates[0].members).toEqual([healthy]); + expect(registry.has(invalid)).toBe(false); + expect(registry.forget(id)).toEqual([original]); + expect(registry.forget(updates[0].id)).toEqual([healthy]); + }); + + it('leaves every series unchanged when one series in a batch cannot be planned', async () => { + const registry = createDicomCollectionRegistry(); + const original = chunkFor({ sop: 'original', z: 0 }); + const [{ id }] = (await registry.register([original])).updates; + const incoming = chunkFor({ sop: 'incoming', z: 1 }); + const invalid = collidingMultiframeChunks(OTHER_SERIES_UID); + + await expect(registry.register([incoming, ...invalid])).rejects.toThrow( + /two collections share the key/ + ); + + [incoming, ...invalid].forEach((chunk) => + expect(registry.has(chunk)).toBe(false) + ); + const replacement = chunkFor({ sop: 'replacement', z: 2 }); + const [update] = (await registry.register([replacement])).updates; + expect(update.id).toBe(id); + expect(update.members).toEqual([original, replacement]); + expect(registry.forget(id)).toEqual([original, replacement]); + }); + it('surfaces the planner order and diagnostics for a positionless series', async () => { const registry = createDicomCollectionRegistry(); const chunks = [ diff --git a/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts b/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts index b84488d6a..53bb8cada 100644 --- a/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts +++ b/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts @@ -225,6 +225,61 @@ describe('splitOverlappingAcquisitions', () => { expect(parts[0].repeatedPositions).toBe(true); }); + it.each([ + ['acquisition', Tags.AcquisitionNumber], + ['phase', Tags.TemporalPositionIdentifier], + ])( + 'keeps singleton %s values with the remainder of overlapping scans', + (rule, tag) => { + const scans = ['1', '2'].flatMap((value) => + [0, 2, 4, 6, 8].map((z) => slice(z, { [tag]: value })) + ); + const remainder = [ + slice(1, { [tag]: '3' }), + slice(3), + slice(5, { [tag]: '4' }), + ]; + + const parts = splitOverlappingAcquisitions( + [...scans, ...remainder].sort(byPosition) + ); + + expect(labels(parts)).toEqual([ + `${rule} 1`, + `${rule} 2`, + `${rule} unknown`, + ]); + expect(partLabelled(parts, `${rule} unknown`).members).toEqual(remainder); + expect( + partValue(partLabelled(parts, `${rule} unknown`), rule) + ).toBeNull(); + } + ); + + it('still separates contrasts within singleton counter values', () => { + const scans = [...stack('1', 0, 3), ...stack('2', 0, 3)]; + const contrasts = ['1', '2'].map((echo, index) => + slice(2.5, { + [Tags.AcquisitionNumber]: String(index + 3), + [Tags.EchoNumbers]: echo, + }) + ); + + const parts = splitOverlappingAcquisitions([...scans, ...contrasts]); + + expect(labels(parts)).toEqual([ + 'acquisition 1', + 'acquisition 2', + 'acquisition unknown, echo 1', + 'acquisition unknown, echo 2', + ]); + contrasts.forEach((member, index) => + expect( + partLabelled(parts, `acquisition unknown, echo ${index + 1}`).members + ).toEqual([member]) + ); + }); + it('leaves a repeated plane inside a split whole', () => { // A full stack plus a bolus-tracking pass of one plane, each frame with // its own phase: the pass is a time series, not twenty datasets. diff --git a/packages/dicom/core/collections/chunkFacts.ts b/packages/dicom/core/collections/chunkFacts.ts new file mode 100644 index 000000000..9be885af2 --- /dev/null +++ b/packages/dicom/core/collections/chunkFacts.ts @@ -0,0 +1,22 @@ +import type { Chunk } from '../streaming/chunk'; +import type { DicomDataset } from '../metadata/dicomDataset'; +import { readInstanceDetails } from './instanceFacts'; + +type ChunkFacts = { + metadata: DicomDataset; + details: ReturnType; +}; + +// Chunk metadata is immutable after loading. Weak keys release derived facts +// with the source; standalone dataset readers remain uncached. +const factsByChunk = new WeakMap(); + +export function readChunkFacts(chunk: Chunk) { + const { metadata } = chunk; + if (!metadata) throw new Error('DICOM chunk metadata has not been read'); + const cached = factsByChunk.get(chunk); + if (cached?.metadata === metadata) return cached.details; + const details = readInstanceDetails(metadata); + factsByChunk.set(chunk, { metadata, details }); + return details; +} diff --git a/packages/dicom/core/collections/collectionRegistry.ts b/packages/dicom/core/collections/collectionRegistry.ts index 2238b9cbe..3c11dc3d0 100644 --- a/packages/dicom/core/collections/collectionRegistry.ts +++ b/packages/dicom/core/collections/collectionRegistry.ts @@ -12,7 +12,9 @@ import { encodeCollectionKey, type CommittedCollection, } from './assignCollectionIds'; -import { readInstanceFacts, seriesKeyOf } from './instanceFacts'; +import { seriesKeyOf } from './instanceFacts'; +import { readChunkFacts } from './chunkFacts'; +import { groupBy } from './groupBy'; export type CollectionUpdate = { id: string; @@ -77,8 +79,6 @@ type SeriesEntry = { dissolving: Set; // Every collection ID forgotten from this series, in order. forgotten: string[]; - // Sequencing only: a settled queue holds no plan, so it pins no chunk. - queue: Promise; }; type Snapshot = { @@ -99,25 +99,13 @@ const instanceKeyOf = (chunk: Chunk) => const readChunk = (chunk: Chunk) => { const { metadata } = chunk; if (!metadata) throw unread(); - const facts = readInstanceFacts(metadata); + const { facts } = readChunkFacts(chunk); return { seriesKey: seriesKeyOf(metadata), instance: { key: instanceKeyOf(chunk), chunk, facts }, }; }; -/** Groups preserve input order, both of the keys and of their members. */ -const groupBy = (items: T[], keyOf: (item: T) => string) => { - const groups = new Map(); - items.forEach((item) => { - const key = keyOf(item); - const group = groups.get(key); - if (group) group.push(item); - else groups.set(key, [item]); - }); - return groups; -}; - /** * Splits a batch into one list per series, preserving input order. Only the * series key is read here, since registration reads the rest of every chunk; @@ -177,10 +165,11 @@ function planSeries( ) { // The first registration of an instance owns its chunk: a re-supply only // reports back, so the chunk an image already holds stays the member. + const registeredByKey = new Map(entry.instances); const conflictingInstanceUids = new Set(); batch.forEach((instance) => { - const existing = entry.instances.get(instance.key); - if (!existing) entry.instances.set(instance.key, instance); + const existing = registeredByKey.get(instance.key); + if (!existing) registeredByKey.set(instance.key, instance); else if ( typeof instance.key === 'string' && JSON.stringify(existing.facts) !== JSON.stringify(instance.facts) @@ -188,7 +177,7 @@ function planSeries( conflictingInstanceUids.add(instance.key); }); - const registered = [...entry.instances.values()]; + const registered = [...registeredByKey.values()]; const plan = planDicomCollections({ seriesKey, instances: registered.map((instance) => instance.facts), @@ -243,12 +232,12 @@ function planSeries( (id) => !planned.some((collection) => collection.id === id) ); - entry.committed = new Map( - planned.map(({ id, committed }) => [id, committed]) - ); - entry.dissolving = new Set(removed); - return { + state: { + instances: registeredByKey, + committed: new Map(planned.map(({ id, committed }) => [id, committed])), + dissolving: new Set(removed), + }, updates, removed, conflictingInstanceUids: [...conflictingInstanceUids], @@ -257,7 +246,8 @@ function planSeries( /** * Remembers every instance seen so far, per series, and replans the series a - * batch touches. Registration is serialized per series. + * batch touches. Planning and publication are synchronous, so a registration + * either publishes every series or leaves the registry unchanged. */ export function createDicomCollectionRegistry(): DicomCollectionRegistry { const series = new Map(); @@ -270,9 +260,7 @@ export function createDicomCollectionRegistry(): DicomCollectionRegistry { committed: new Map(), dissolving: new Set(), forgotten: [], - queue: Promise.resolve(), }; - series.set(seriesKey, created); return created; }; @@ -303,32 +291,23 @@ export function createDicomCollectionRegistry(): DicomCollectionRegistry { }; const register = async (chunks: Chunk[]) => { - // Reading every chunk before touching a queue keeps a batch that names an - // unread chunk from half registering. const batches = groupBySeries(chunks); const snapshots = new Map(); const planned = [...batches].map(([seriesKey, batch]) => { const entry = entryFor(seriesKey); - const update = entry.queue.then(() => { - snapshots.set(seriesKey, { - entry, - instances: new Map(entry.instances), - committed: new Map(entry.committed), - dissolving: new Set(entry.dissolving), - forgotten: entry.forgotten.length, - }); - return planSeries(entry, seriesKey, batch); + return { seriesKey, entry, result: planSeries(entry, seriesKey, batch) }; + }); + planned.forEach(({ seriesKey, entry, result }) => { + snapshots.set(seriesKey, { + entry, + instances: entry.instances, + committed: entry.committed, + dissolving: entry.dissolving, + forgotten: entry.forgotten.length, }); - // A failed plan must not wedge the series behind it, and the queue - // outlives every batch it orders, so it keeps the outcome and not the - // plan: holding the members would pin the chunks of a collection the - // user has since removed for as long as the series has any other. - entry.queue = update.then( - () => {}, - () => {} - ); - return update; + Object.assign(entry, result.state); + series.set(seriesKey, entry); }); // A forget empties and drops an entry once nothing is left in it, so the @@ -363,7 +342,7 @@ export function createDicomCollectionRegistry(): DicomCollectionRegistry { return forgotten.flatMap((id) => forgetFrom(entry, seriesKey, id)); }); - const results = await Promise.all(planned); + const results = planned.map(({ result }) => result); return { updates: results.flatMap((result) => result.updates), conflictingInstanceUids: results.flatMap( diff --git a/packages/dicom/core/collections/groupBy.ts b/packages/dicom/core/collections/groupBy.ts new file mode 100644 index 000000000..fec13bb4b --- /dev/null +++ b/packages/dicom/core/collections/groupBy.ts @@ -0,0 +1,11 @@ +/** Groups preserve input order, both of the keys and of their members. */ +export const groupBy = (items: T[], keyOf: (item: T) => string) => { + const groups = new Map(); + items.forEach((item) => { + const key = keyOf(item); + const group = groups.get(key); + if (group) group.push(item); + else groups.set(key, [item]); + }); + return groups; +}; diff --git a/packages/dicom/core/collections/instanceFacts.ts b/packages/dicom/core/collections/instanceFacts.ts index 1fef4b905..6c4e2774d 100644 --- a/packages/dicom/core/collections/instanceFacts.ts +++ b/packages/dicom/core/collections/instanceFacts.ts @@ -139,9 +139,8 @@ export function readGeometryFacts(metadata: DicomDataset, frameIndex = 0) { }; } -/** Reads the planner's plain facts out of one instance's tag values. */ -export function readInstanceFacts(metadata: DicomDataset): InstanceFacts { - const { orientation, position, pixelSpacing } = readGeometryFacts(metadata); +/** Reads planning facts and the full frame geometry from the same header. */ +export function readInstanceDetails(metadata: DicomDataset) { const frames = textOf(metadata, Tags.Modality) === 'SEG' ? null @@ -149,8 +148,10 @@ export function readInstanceFacts(metadata: DicomDataset): InstanceFacts { metadata, numberOf(metadata, Tags.NumberOfFrames) ?? 1 ); + const { orientation, position, pixelSpacing } = + frames ?? readGeometryFacts(metadata); - return { + const facts: InstanceFacts = { ...(frames ? { frameReconstruction: frames.reconstruction } : {}), sopInstanceUid: textOf(metadata, Tags.SOPInstanceUID), seriesInstanceUid: textOf(metadata, Tags.SeriesInstanceUID), @@ -175,6 +176,12 @@ export function readInstanceFacts(metadata: DicomDataset): InstanceFacts { echoNumbers: numericTextOf(metadata, Tags.EchoNumbers), diffusionBValue: numericTextOf(metadata, Tags.DiffusionBValue), }; + return { facts, frameGeometry: frames }; +} + +/** Reads the planner's plain facts out of one instance's tag values. */ +export function readInstanceFacts(metadata: DicomDataset) { + return readInstanceDetails(metadata).facts; } // Carries a separator no UID can hold, so a fallback key never equals a real diff --git a/packages/dicom/core/collections/planDicomCollections.ts b/packages/dicom/core/collections/planDicomCollections.ts index 2899f77ba..450aae54a 100644 --- a/packages/dicom/core/collections/planDicomCollections.ts +++ b/packages/dicom/core/collections/planDicomCollections.ts @@ -1,4 +1,5 @@ import { orientationsAgree } from './orientation'; +import { groupBy } from './groupBy'; import { splitOverlappingAcquisitions, type PositionOf, @@ -313,21 +314,6 @@ const hardFactParts = (instance: InstanceFacts) => (rule) => [rule, instance[rule]] as [string, string | null] ); -/** Groups preserve input order, both of the groups and of their members. */ -const groupBy = ( - instances: InstanceFacts[], - signature: (instance: InstanceFacts) => string -) => { - const groups = new Map(); - instances.forEach((instance) => { - const id = signature(instance); - const existing = groups.get(id); - if (existing) existing.push(instance); - else groups.set(id, [instance]); - }); - return [...groups.values()]; -}; - /** * Greedy bucketing walked in SOP Instance UID then content order, so the * representative whose cosines become the key, and the membership the greedy @@ -495,7 +481,7 @@ export function planDicomCollections(input: PlanInput) { ...groupBy( kept.filter((instance) => !multiframe(instance)), hardFactSignature - ), + ).values(), ...kept.filter(multiframe).map((instance) => [instance]), ]; const planned = groups diff --git a/packages/dicom/core/collections/splitOverlappingAcquisitions.ts b/packages/dicom/core/collections/splitOverlappingAcquisitions.ts index b4e6c7e82..475a98177 100644 --- a/packages/dicom/core/collections/splitOverlappingAcquisitions.ts +++ b/packages/dicom/core/collections/splitOverlappingAcquisitions.ts @@ -86,30 +86,29 @@ const anySpansOverlap = (groups: InstanceFacts[][], positionOf: PositionOf) => { }); }; -/** Members grouped by the axis value, in first-seen order; untagged apart. */ +/** Eligible groups in first-seen order, with all other members held apart. */ const groupByAxis = (members: InstanceFacts[], axis: SemanticAxis) => { const tagged = new Map(); - const untagged: InstanceFacts[] = []; members.forEach((member) => { const value = member[axis.fact]; - if (value === null) { - untagged.push(member); - return; - } + if (value === null) return; const group = tagged.get(value); if (group) group.push(member); else tagged.set(value, [member]); }); - return { tagged, untagged }; + // A counter stamped per slice cannot establish a separate acquisition. + const eligible = new Map( + [...tagged].filter(([, group]) => !axis.counter || group.length > 1) + ); + const remainder = members.filter((member) => { + const value = member[axis.fact]; + return value === null || !eligible.has(value); + }); + return { tagged: eligible, remainder }; }; type Split = { value: string | null; members: InstanceFacts[] }; -// A counter stamped per slice yields one group per slice; a single repeated -// position among them must not become one dataset per slice. -const splittingGroups = (axis: SemanticAxis, tagged: InstanceFacts[][]) => - axis.counter ? tagged.filter((group) => group.length > 1) : tagged; - /** * The first axis whose tagged groups cover overlapping stretches of the slice * axis, with the members that carry no value for it as their own part. Scans @@ -123,8 +122,8 @@ const firstSplittingAxis = ( ) => axes .map((axis) => ({ axis, ...groupByAxis(members, axis) })) - .find(({ axis, tagged }) => { - const groups = splittingGroups(axis, [...tagged.values()]); + .find(({ tagged }) => { + const groups = [...tagged.values()]; return groups.length >= 2 && anySpansOverlap(groups, positionOf); }); @@ -176,13 +175,13 @@ function splitByAxes( if (!found) return unsplit(members, axes, hasRepeatedPositions(members, positionOf)); - const { axis, tagged, untagged } = found; + const { axis, tagged, remainder } = found; const index = axes.indexOf(axis); const skipped = axes.slice(0, index); const rest = axes.slice(index + 1); const splits: Split[] = [ ...[...tagged].map(([value, group]) => ({ value, members: group })), - ...(untagged.length > 0 ? [{ value: null, members: untagged }] : []), + ...(remainder.length > 0 ? [{ value: null, members: remainder }] : []), ]; return splits.flatMap(({ value, members: group }) => @@ -215,10 +214,10 @@ function splitByAxes( * members that can be judged. * * Conservative on the members it does judge: one overlapping pair separates - * every group at that level, and a counter axis neither fans out a repeated - * single plane nor splits on groups of one slice. Members that lack the value - * the level splits on form their own part rather than blocking the split, so - * the result depends only on the members given. + * every eligible group at that level, and a counter axis neither fans out a + * repeated single plane nor splits on groups of one slice. Members that lack + * the level's value and singleton counter groups form a remainder part rather + * than blocking the split, so the result depends only on the members given. */ export function splitOverlappingAcquisitions( members: InstanceFacts[], diff --git a/packages/dicom/core/index.ts b/packages/dicom/core/index.ts deleted file mode 100644 index 31af9068b..000000000 --- a/packages/dicom/core/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export * from './collections/assignCollectionIds'; -export * from './collections/collectionRegistry'; -export * from './collections/instanceFacts'; -export * from './collections/planDicomCollections'; -export * from './collections/reconstructVolume'; -export * from './collections/splitOverlappingAcquisitions'; -export * from './collections/multiframeGeometry'; -export * from './metadata/dicomDataset'; -export * from './metadata/datasetAccess'; -export * from './metadata/dicomTags'; -export * from './metadata/dicomLayout'; -export * from './metadata/spatialMetadata'; -export * from './metadata/dcmjsParser'; -export * from './metadata/dicomCharacterSet'; -export * from './streaming/chunk'; -export * from './streaming/chunkStateMachine'; -export * from './streaming/types'; -export * from './streaming/blobFetcher'; -export * from './streaming/cachedStreamFetcher'; -export * from './streaming/requestPool'; -export * from './streaming/httpCodes'; -export * from './streaming/concatStreams'; -export * from './streaming/dicom/dicomMetaLoader'; -export * from './streaming/dicom/dicomDataLoader'; -export * from './streaming/dicom/dicomParser'; -export * from './streaming/dicom/ultrasoundRegion'; -export * from './streaming/stateMachine'; -export { default as StateMachine } from './streaming/stateMachine'; -export { default as ByteDeque } from './streaming/byteDeque'; -export { default as StreamingByteReader } from './streaming/streamingByteReader'; -export * from './runtime/createDicomLoader'; -export * from './runtime/decoder'; diff --git a/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts b/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts index 4d246e394..a0ea62921 100644 --- a/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts +++ b/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts @@ -2,6 +2,7 @@ import { readDicomDataset, findElement } from '../dicomDataset'; import { getDatasetTag } from '../datasetAccess'; import { getFrameTag } from '../functionalGroups'; import { DicomMetaLoader } from '../../streaming/dicom/dicomMetaLoader'; +import { createDicomParser } from '../../streaming/dicom/dicomParser'; import { blobFetcher } from '../../streaming/blobFetcher'; import { deflateRawSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; @@ -9,12 +10,15 @@ import { parseDicomElements } from '../dcmjsParser'; import { PART_10_HEADER_LENGTH, PREAMBLE_LENGTH } from '../dicomLayout'; import { concatBytes } from '../../utils'; import { buildSlice } from '@/src/io/__tests__/readDicomTagsFixtures'; +import { bytesFetcher, pixelDataStart } from '@/tests/dicomSourceFixtures'; import { rawElement, sequenceItem, toImplicitVr, stripFileMeta, stripPreamble, + undefinedLengthItem, + undefinedLengthSequence, } from '@/tests/specs/syntheticDicom'; // (0002,0000) UL occupies twelve bytes, including its value. @@ -128,6 +132,80 @@ describe('lenient headers with standards-defined values', () => { loader.dispose(); }); + it('keeps a prematurely delimited SQ inside its declared boundary', async () => { + const sequence = rawElement( + 0x0008, + 0x1140, + 'SQ', + concatBytes([ + new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]), + rawElement(0x7fe0, 0x0010, 'OW', new Uint8Array(16)), + ]) + ); + const trailing = rawElement(0x0043, 0x1030, 'LO', encode('AFTER ')); + const bytes = buildSlice({ extraElements: [sequence, trailing] }); + const expectedPixelStart = + pixelDataStart(buildSlice()) + sequence.length + trailing.length; + const parse = createDicomParser({ + stopAtElement: (group, element) => group === 0x7fe0 && element === 0x0010, + }); + expect(parse(bytes)).toMatchObject({ + done: true, + value: { position: expectedPixelStart }, + }); + + const loader = new DicomMetaLoader(bytesFetcher(bytes, 7)); + await loader.load(); + expect(getDatasetTag(loader.meta, '00431030')).toBe('AFTER '); + expect(loader.meta).toEqual(readDicomDataset(bytes)); + loader.dispose(); + }); + + it('rejects a sequence whose declared boundary exceeds the file', async () => { + const sequence = rawElement( + 0x0008, + 0x1140, + 'SQ', + concatBytes([ + new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]), + new Uint8Array(16), + ]) + ); + const base = buildSlice(); + const bytes = concatBytes([ + base.subarray(0, pixelDataStart(base)), + sequence.subarray(0, sequence.length - 2), + ]); + expect(() => readDicomDataset(bytes)).toThrow('available dataset bytes'); + const loader = new DicomMetaLoader(bytesFetcher(bytes, 7)); + await expect(loader.load()).rejects.toThrow('available dataset bytes'); + loader.dispose(); + }); + + it('does not recover a nested sequence past its enclosing item', async () => { + const inner = rawElement( + 0x0008, + 0x1140, + 'SQ', + concatBytes([ + new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]), + new Uint8Array(16), + ]) + ); + const bytes = buildSlice({ + extraElements: [ + rawElement(0x0040, 0x0275, 'SQ', sequenceItem(inner.subarray(0, 20))), + rawElement(0x0043, 0x1030, 'LO', encode('AFTER ')), + ], + }); + const dataset = readDicomDataset(bytes); + expect(findElement(dataset, '00400275')?.values.kind).toBe('unread'); + expect(getDatasetTag(dataset, '00431030')).toBe('AFTER '); + const loader = new DicomMetaLoader(bytesFetcher(bytes, 7)); + await expect(loader.load()).rejects.toThrow('enclosing sequence or item'); + loader.dispose(); + }); + it('decodes a known defined-length UN using the dataset character set', () => { const dataset = readDicomDataset( concatBytes([ @@ -207,6 +285,36 @@ describe('lenient headers with standards-defined values', () => { ); }); +it.each([sequenceItem, undefinedLengthItem])( + 'reads an implicit private undefined-length sequence with %s', + async (item) => { + const encode = (text: string) => new TextEncoder().encode(text); + const content = toImplicitVr( + rawElement(0x0010, 0x0010, 'PN', encode('DOE^JANE')) + ); + const explicit = undefinedLengthSequence(0x0043, 0x1029, item(content)); + const implicit = concatBytes([ + explicit.subarray(0, 4), + explicit.subarray(8), + ]); + const base = buildSlice({ implicitVr: true }); + const pixelStart = pixelDataStart(base); + const bytes = concatBytes([ + base.subarray(0, pixelStart), + implicit, + base.subarray(pixelStart), + ]); + const dataset = readDicomDataset(bytes); + const sequence = findElement(dataset, '00431029')!.values; + if (sequence.kind !== 'sequence') throw new Error('Expected a sequence'); + expect(getDatasetTag(sequence.items[0], '00100010')).toBe('DOE^JANE'); + const loader = new DicomMetaLoader(bytesFetcher(bytes, 7)); + await loader.load(); + expect(loader.meta).toEqual(dataset); + loader.dispose(); + } +); + it('decodes a known UN nested inside an explicit sequence with inherited UTF-8', () => { const encode = (text: string) => new TextEncoder().encode(text); const dataset = readDicomDataset( diff --git a/packages/dicom/core/metadata/dcmjs.d.ts b/packages/dicom/core/metadata/dcmjs.d.ts index efe4cfa94..dccff0f97 100644 --- a/packages/dicom/core/metadata/dcmjs.d.ts +++ b/packages/dicom/core/metadata/dcmjs.d.ts @@ -24,6 +24,7 @@ declare module 'dcmjs' { export type DicomReadStream = { offset: number; + readonly endOffset: number; reset(): DicomReadStream; end(): boolean; increment(count: number): void; diff --git a/packages/dicom/core/metadata/dcmjsParser.ts b/packages/dicom/core/metadata/dcmjsParser.ts index d23e3a573..5b6447d3a 100644 --- a/packages/dicom/core/metadata/dcmjsParser.ts +++ b/packages/dicom/core/metadata/dcmjsParser.ts @@ -169,7 +169,8 @@ const openFile = (bytes: Uint8Array) => { }; const needsSequenceReader = (vr: string | undefined, length: number) => - vr === 'SQ' || (vr === 'UN' && length === UNDEFINED_LENGTH); + vr === 'SQ' || + ((vr === 'UN' || vr === undefined) && length === UNDEFINED_LENGTH); /** * A known UN retains its implicit encoding even inside an explicit dataset. @@ -272,6 +273,8 @@ const readSequence = ( unknown?: boolean; } ): ParsedElement => { + if (sequence.end !== null && sequence.end > dataSet.endOffset) + throw new Error('Sequence exceeds the available dataset bytes'); if (sequence.unknown && sequence.end !== null) { // PS3.5 6.2.2: a known UN value retains Implicit VR Little Endian encoding. const value = new Uint8Array( @@ -345,8 +348,15 @@ function readSequenceItems( ) { const items: ParsedElement[][] = []; while (end === null || dataSet.offset < end) { + assertSequenceBounds(dataSet.offset, end, 8); const length = readSequenceItemLength(dataSet, syntax); - if (length === null) return items; + if (length === null) { + assertSequenceBounds(dataSet.offset, end); + // A defined length still bounds the value after a premature delimiter. + // Its trailing bytes cannot become elements of the enclosing dataset. + if (end !== null) dataSet.offset = end; + return items; + } assertSequenceBounds( dataSet.offset, end, diff --git a/packages/dicom/core/metadata/dicomDataset.ts b/packages/dicom/core/metadata/dicomDataset.ts index 31d8f02ff..02eb33ff5 100644 --- a/packages/dicom/core/metadata/dicomDataset.ts +++ b/packages/dicom/core/metadata/dicomDataset.ts @@ -191,7 +191,7 @@ const elementValues = ( if (parsed.items) return { kind: 'sequence', - items: parsed.items.map((item) => buildDataset(item, charset)), + items: parsed.items.map((item) => buildDataset(item, charset, decode)), }; return classifiedValues(parsed.vr, parsed.values, decode); }; @@ -224,11 +224,15 @@ const declaredCharacterSet = (elements: ParsedElement[]) => { function buildDataset( elements: ParsedElement[], - inherited: string[] | null + inherited: string[] | null, + inheritedDecoder?: CharacterSetDecoder ): DicomDataset { const declared = declaredCharacterSet(elements); const charset = declared ?? inherited; - const decode = characterSetDecoder(charset ?? []); + const decode = + declared === null && inheritedDecoder + ? inheritedDecoder + : characterSetDecoder(charset ?? []); return { elements: elements.map((element) => buildElement(element, decode, charset)), specificCharacterSet: declared, diff --git a/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts b/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts index f727e6c41..20e3cc8a4 100644 --- a/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts +++ b/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts @@ -90,12 +90,4 @@ describe('blobFetcher', () => { expect(await blobFetcher(blob).blob()).toBe(blob); }); - - it('keeps no copy of what it streamed', async () => { - const fetcher = blobFetcher(new Blob([bytesOf(10)])); - await fetcher.connect(); - await readAll(fetcher.getStream()); - - expect(fetcher.cachedChunks).toBeUndefined(); - }); }); diff --git a/packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts b/packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts index 1f1a5d0c8..dbd74db9f 100644 --- a/packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts +++ b/packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts @@ -118,14 +118,22 @@ describe('sliceChunks', () => { it('rejects an unverified resumed range while preserving the cached header', async () => { const prefix = new Uint8Array([1, 2, 3, 4]); + let requests = 0; const fetcher = new CachedStreamFetcher( 'https://example.test/hidden-headers', { prefixChunks: [prefix], - fetch: async () => new Response(new Uint8Array([5, 6]), { status: 206 }), + fetch: async () => + new Response(new Uint8Array([5, 6]), { + status: 206, + headers: requests++ === 0 ? {} : { 'content-range': 'bytes 4-5/6' }, + }), } ); await expect(fetcher.connect()).rejects.toThrow('readable content range'); - expect(fetcher.cachedChunks).toEqual([prefix]); expect(fetcher.connected).toBe(false); + expect(new Uint8Array(await (await fetcher.blob()).arrayBuffer())).toEqual( + new Uint8Array([1, 2, 3, 4, 5, 6]) + ); + fetcher.close(); }); diff --git a/packages/dicom/core/streaming/cachedStreamFetcher.ts b/packages/dicom/core/streaming/cachedStreamFetcher.ts index ee9be490a..423e3a646 100644 --- a/packages/dicom/core/streaming/cachedStreamFetcher.ts +++ b/packages/dicom/core/streaming/cachedStreamFetcher.ts @@ -100,10 +100,6 @@ export class CachedStreamFetcher implements Fetcher { return this.chunks.reduce((sum, chunk) => sum + chunk.length, 0); } - get cachedChunks() { - return this.chunks; - } - get abortSignal() { return this.abortController?.signal; } diff --git a/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts index 8646c7155..7e929270f 100644 --- a/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts +++ b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts @@ -21,7 +21,6 @@ import { import { bytesFetcher, countingFetcher, - deliveredBytes, pixelDataStart, syntheticSlice, withoutPixelData, @@ -84,7 +83,6 @@ describe('DicomMetaLoader over a byte source', () => { ], }); expect(loader.meta).toEqual(readDicomDataset(bytes)); - expect(loader.pixelDataOffset).toBe(pixelDataStart(bytes)); } ); @@ -97,13 +95,12 @@ describe('DicomMetaLoader over a byte source', () => { expect(loader.meta).toEqual(readDicomDataset(bytes)); }); - it('stops at Pixel Data', async () => { + it('reports the file layout', async () => { const bytes = syntheticSlice(); const loader = new DicomMetaLoader(bytesFetcher(bytes)); await loader.load(); - expect(loader.pixelDataOffset).toBe(pixelDataStart(bytes)); expect(loader.fileLayout).toEqual({ preamble: true, fileMeta: true }); }); @@ -161,7 +158,7 @@ describe('DicomMetaLoader over a byte source', () => { await loader.load(); expect(loader.meta).toEqual(readDicomDataset(bytes)); - expect(deliveredBytes(fetcher)).toBeLessThan(bytes.length); + expect(fetcher.size).toBeLessThan(bytes.length); }); it('hands the reader the header bytes, once', async () => { @@ -180,7 +177,7 @@ describe('DicomMetaLoader over a byte source', () => { expect(seen[0]).toEqual(bytes.slice(0, pixelDataStart(bytes))); }); - it('takes tags, offset and ultrasound regions from one pass', async () => { + it('takes tags and ultrasound regions from one pass', async () => { const bytes = syntheticSlice({ modality: 'US', ultrasoundRegion: REGION }); const { fetcher, counts } = countingFetcher(bytesFetcher(bytes)); const loader = new DicomMetaLoader(fetcher); @@ -188,7 +185,6 @@ describe('DicomMetaLoader over a byte source', () => { await loader.load(); expect(loader.meta).toEqual(readDicomDataset(bytes)); - expect(loader.pixelDataOffset).toBe(pixelDataStart(bytes)); expect(loader.ultrasoundRegions?.regionCount).toBe(1); expect(loader.ultrasoundRegions?.region?.physicalDeltaX).toBeCloseTo( REGION.physicalDeltaX @@ -218,7 +214,6 @@ describe('DicomMetaLoader over a byte source', () => { expect(loader.meta).toEqual(readDicomDataset(bytes)); expect(loader.meta!.elements.length).toBeGreaterThan(1); - expect(loader.pixelDataOffset).toBe(bytes.length); }); // A structure set is megabytes of contours the importer drops unread, so @@ -240,8 +235,7 @@ describe('DicomMetaLoader over a byte source', () => { expect(getDatasetTag(loader.meta, Tags.Modality)?.trim()).toBe('RTSTRUCT'); expect(seen).toHaveLength(1); - expect(deliveredBytes(fetcher)).toBeLessThan(bytes.length); - expect(loader.pixelDataOffset).toBeUndefined(); + expect(fetcher.size).toBeLessThan(bytes.length); }); it('closes the byte source once the header is read', async () => { diff --git a/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts b/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts index bc59c3b29..7d73e9571 100644 --- a/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts +++ b/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts @@ -36,7 +36,6 @@ export class DicomMetaLoader implements MetaLoader { private tags: Maybe; private generation = 0; private source: Fetcher | null; - private offset: Maybe; private layout: Maybe; public ultrasoundRegions: UltrasoundRegions | undefined; @@ -60,14 +59,6 @@ export class DicomMetaLoader implements MetaLoader { return this.tags; } - /** - * Where Pixel Data starts, or the file's length when it has none. Unset for - * a radiotherapy object, whose header past Modality is never read. - */ - get pixelDataOffset() { - return this.offset; - } - /** How the bytes opened: with or without the Part 10 preamble and file meta. */ get fileLayout() { return this.layout; @@ -149,10 +140,6 @@ export class DicomMetaLoader implements MetaLoader { const dataset = await this.readDataset(header); if (generation !== this.generation) throw new DOMException('DICOM read cancelled', 'AbortError'); - this.offset = - this.imageImportPolicy === 'skip-radiotherapy' && isRadiotherapy(modality) - ? undefined - : header.length; this.tags = dataset; if (modality === 'US') { diff --git a/packages/dicom/core/streaming/dicom/dicomParser.ts b/packages/dicom/core/streaming/dicom/dicomParser.ts index 765a5ca91..78a53d5a7 100644 --- a/packages/dicom/core/streaming/dicom/dicomParser.ts +++ b/packages/dicom/core/streaming/dicom/dicomParser.ts @@ -23,12 +23,22 @@ export type ElementTagCallback = ( element: number ) => string | null; -interface ParseOptions { +type ParseOptions = { peek?: boolean; littleEndian?: boolean; explicitVr?: boolean; + endPosition?: number; onDataElement?: DataElementCallback; -} +}; + +const assertReadBounds = ( + reader: StreamingByteReader, + end: number | undefined, + length: number +) => { + if (end !== undefined && reader.position + length > end) + throw new Error('Data element exceeds its enclosing sequence or item'); +}; // This is based on the DICOM standard as of 2023. All links may point to whatever is the current at the time of visiting. @@ -108,8 +118,13 @@ function* readElementValue( reader: StreamingByteReader, vr: string, length: number, - { explicitVr = false, littleEndian = false } = {} + { explicitVr = false, littleEndian = false, endPosition }: ParseOptions = {} ): Generator { + assertReadBounds( + reader, + endPosition, + length === UndefinedLength ? 0 : length + ); // See examples: https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.2.html // PS3.5 6.2.2: an undefined-length UN holds sequence items encoded // in Implicit VR Little Endian, regardless of the enclosing syntax. @@ -117,6 +132,7 @@ function* readElementValue( return yield* readSequenceValue(reader, length, { explicitVr: false, littleEndian: true, + endPosition, }); } @@ -124,6 +140,7 @@ function* readElementValue( return yield* readSequenceValue(reader, length, { explicitVr, littleEndian, + endPosition, }); } @@ -143,8 +160,12 @@ function* readElementValue( * https://dicom.nema.org/medical/dicom/current/output/chtml/part05/chapter_7.html#sect_7.1.1 * @param {*} reader */ -function* readDataElement(reader: StreamingByteReader, opts?: ParseOptions) { - const { littleEndian = false, explicitVr = false } = opts ?? {}; +function* readDataElement( + reader: StreamingByteReader, + opts: ParseOptions = {} +) { + const { littleEndian = false, explicitVr = false, endPosition } = opts; + assertReadBounds(reader, endPosition, 8); // read tag const [group, element] = yield* readElementTag(reader, { littleEndian }); let length = 0; @@ -179,6 +200,7 @@ function* readDataElement(reader: StreamingByteReader, opts?: ParseOptions) { data = yield* readElementValue(reader, vr, length, { littleEndian, explicitVr, + endPosition, }); } else { length = yield* reader.readUint32({ littleEndian }); @@ -198,6 +220,7 @@ function* readDataElement(reader: StreamingByteReader, opts?: ParseOptions) { data = yield* readElementValue(reader, vr, length, { littleEndian, explicitVr, + endPosition, }); } @@ -217,6 +240,7 @@ function* readDataElementsUntil( opts: ParseOptions ) { while (true) { + assertReadBounds(reader, opts.endPosition, 4); const [group, element] = yield* peekElementTag(reader, opts); if (untilFn(group, element)) { return; @@ -237,12 +261,40 @@ function* readFiniteDataElements( opts: ParseOptions ) { const endPosition = reader.position + byteLength; + assertReadBounds(reader, opts.endPosition, byteLength); while (reader.position < endPosition) { - const el = yield* readDataElement(reader, opts); + const el = yield* readDataElement(reader, { ...opts, endPosition }); opts.onDataElement?.(el); } } +function* readSequenceItem(reader: StreamingByteReader, opts: ParseOptions) { + const itemByteLength = yield* reader.readUint32(opts); + const itemDataset: DataElement[] = []; + const itemOptions = { + ...opts, + onDataElement: (el: DataElement) => itemDataset.push(el), + }; + if (itemByteLength !== UndefinedLength) { + yield* readFiniteDataElements(reader, itemByteLength, itemOptions); + } else { + yield* readDataElementsUntil( + reader, + equalsToTag(Tags.ItemDelimiter), + itemOptions + ); + assertReadBounds(reader, opts.endPosition, 8); + yield* reader.seek(8); + } + return itemDataset; +} + +function* finishSequence(reader: StreamingByteReader, end?: number) { + yield* reader.seek(4); + // The declared boundary wins over a premature delimiter in a finite SQ. + if (end !== undefined) yield* reader.seek(end - reader.position); +} + /** * The reader must be pointing to the start of the sequence's data, namely the first item or the sequence delimitation item. * @@ -252,47 +304,23 @@ function* readFiniteDataElements( function* readSequenceValue( reader: StreamingByteReader, byteLength: number, - { explicitVr = false, littleEndian = false } = {} + opts: ParseOptions ) { - const items = [] as Array; - const isUndefinedLength = byteLength === UndefinedLength; - const endPosition = isUndefinedLength ? 0 : reader.position + byteLength; - while (true) { - if (!isUndefinedLength && reader.position >= endPosition) { - return items; - } - - const [group, element] = yield* readElementTag(reader, { littleEndian }); + const items: DataElement[][] = []; + const endPosition = + byteLength === UndefinedLength ? undefined : reader.position + byteLength; + const itemOptions = { + ...opts, + endPosition: endPosition ?? opts.endPosition, + }; + while (endPosition === undefined || reader.position < endPosition) { + assertReadBounds(reader, itemOptions.endPosition, 8); + const [group, element] = yield* readElementTag(reader, opts); // Item tag // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.2.html#table_7.5-3 if (equalsToTag(Tags.Item)(group, element)) { - const itemDataset: DataElement[] = []; - const itemByteLength = yield* reader.readUint32({ littleEndian }); - - // length rules: https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.html, heading 7.5.1 - if (itemByteLength !== UndefinedLength) { - yield* readFiniteDataElements(reader, itemByteLength, { - explicitVr, - littleEndian, - onDataElement(el) { - itemDataset.push(el); - }, - }); - } else { - // read until item delimitation tag - yield* readDataElementsUntil(reader, equalsToTag(Tags.ItemDelimiter), { - explicitVr, - littleEndian, - onDataElement(el) { - itemDataset.push(el); - }, - }); - // skip the item delimitation tag and the 4-byte zero length - yield* reader.seek(4 + 4); - } - - items.push(itemDataset); + items.push(yield* readSequenceItem(reader, itemOptions)); continue; } @@ -300,8 +328,7 @@ function* readSequenceValue( // "Otherwise, the Value Field has an Undefined Length and a Sequence Delimitation Item marks the end of the Value Field." // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/chapter_7.html#sect_7.1.2 if (group === 0xfffe && element === 0xe0dd) { - // skip the 4-byte zero length - yield* reader.seek(4); + yield* finishSequence(reader, endPosition); return items; } @@ -311,6 +338,7 @@ function* readSequenceValue( )},${element.toString(16)})` ); } + return items; } // Per Part 10: "File Meta Information shall be encoded using the Explicit VR Little Endian Transfer Syntax" diff --git a/packages/dicom/core/streaming/types.ts b/packages/dicom/core/streaming/types.ts index 2984d65db..afd63720c 100644 --- a/packages/dicom/core/streaming/types.ts +++ b/packages/dicom/core/streaming/types.ts @@ -46,12 +46,6 @@ export type Fetcher = { getStream(): ReadableStream; blob(): Promise; close(): void; - /** - * What the streams have delivered so far, kept by a fetcher whose source - * has to be cached to be read again. A fetcher over addressable bytes keeps - * none. - */ - cachedChunks?: Uint8Array[]; connected: boolean; size: number; contentType?: string; diff --git a/packages/dicom/package.json b/packages/dicom/package.json index 8574f150f..601a90293 100644 --- a/packages/dicom/package.json +++ b/packages/dicom/package.json @@ -9,13 +9,13 @@ "vtk" ], "exports": { - "./core": { - "types": "./dist/core/index.d.ts", - "import": "./dist/core.js" + "./core/*": { + "types": "./dist/core/*.d.ts", + "import": "./dist/core/*.js" }, - "./vtk": { - "types": "./dist/vtk/index.d.ts", - "import": "./dist/vtk.js" + "./vtk/*": { + "types": "./dist/vtk/*.d.ts", + "import": "./dist/vtk/*.js" }, "./assets/*": "./dist/assets/*" }, diff --git a/packages/dicom/scripts/build.mjs b/packages/dicom/scripts/build.mjs index b547210e6..ce251206e 100644 --- a/packages/dicom/scripts/build.mjs +++ b/packages/dicom/scripts/build.mjs @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { cp, mkdir, readFile, readdir } from 'node:fs/promises'; import { createRequire } from 'node:module'; -import { dirname, resolve } from 'node:path'; +import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; @@ -19,6 +19,27 @@ const dependencies = Object.keys({ const external = (id) => dependencies.some((name) => id === name || id.startsWith(`${name}/`)); +async function sourceFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const file = resolve(directory, entry.name); + if (entry.isDirectory()) + return entry.name === '__tests__' ? [] : sourceFiles(file); + return entry.name.endsWith('.ts') && !/\.(d|worker)\.ts$/.test(entry.name) + ? [file] + : []; + }) + ); + return files.flat(); +} + +const sources = ( + await Promise.all( + ['core', 'vtk'].map((dir) => sourceFiles(resolve(packageRoot, dir))) + ) +).flat(); + await build({ configFile: false, root: packageRoot, @@ -29,10 +50,14 @@ await build({ emptyOutDir: true, sourcemap: true, lib: { - entry: { - core: resolve(packageRoot, 'core/index.ts'), - vtk: resolve(packageRoot, 'vtk/index.ts'), - }, + entry: Object.fromEntries( + sources.map((file) => [ + relative(packageRoot, file) + .replaceAll('\\', '/') + .replace(/\.ts$/, ''), + file, + ]) + ), formats: ['es'], fileName: (_format, entry) => `${entry}.js`, }, diff --git a/packages/dicom/scripts/check-consumers.mjs b/packages/dicom/scripts/check-consumers.mjs index 4490dcca1..31a0411c2 100644 --- a/packages/dicom/scripts/check-consumers.mjs +++ b/packages/dicom/scripts/check-consumers.mjs @@ -32,8 +32,7 @@ export async function buildConsumer( base: './', resolve: { alias: { - '@volview/dicom/core': resolve(packageDist, 'core.js'), - '@volview/dicom/vtk': resolve(packageDist, 'vtk.js'), + '@volview/dicom': packageDist, }, }, plugins: [ @@ -67,8 +66,8 @@ async function checkDeclarations(directory) { types: ['node'], typeRoots: [resolve(root, 'node_modules/@types')], paths: { - '@volview/dicom/core': ['packages/dicom/dist/core/index.d.ts'], - '@volview/dicom/vtk': ['packages/dicom/dist/vtk/index.d.ts'], + '@volview/dicom/core/*': ['packages/dicom/dist/core/*'], + '@volview/dicom/vtk/*': ['packages/dicom/dist/vtk/*'], }, }, include: [`${fixture}/*.ts`], diff --git a/packages/dicom/vtk/VtkDicomImage.ts b/packages/dicom/vtk/VtkDicomImage.ts index b3a11a9a5..e234e9a59 100644 --- a/packages/dicom/vtk/VtkDicomImage.ts +++ b/packages/dicom/vtk/VtkDicomImage.ts @@ -1,27 +1,20 @@ -import { - Chunk, - ChunkState, - getChunkTag, - readInstanceFacts, - reconstructInstances, - sliceNormalOf, - type Reconstruction, - type SpatialMetadata, -} from '../core/index'; +import { Chunk } from '../core/streaming/chunk'; +import { ChunkState } from '../core/streaming/chunkStateMachine'; +import { getChunkTag } from '../core/metadata/datasetAccess'; +import { type Reconstruction } from '../core/collections/reconstructVolume'; +import { type SpatialMetadata } from '../core/metadata/spatialMetadata'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import type { TypedArray } from '@kitware/vtk.js/types'; import mitt, { Emitter } from 'mitt'; -import { - allocateImageFromChunks, - type IrregularGeometryPolicy, -} from './allocateImageFromChunks'; +import type { IrregularGeometryPolicy } from './allocateImageFromChunks'; import { assertSamplesRepresentable, assertSingleFramePerFile, assertChunkFitsSlot, } from './pixelValidation'; -import { applyUltrasoundSpacing } from './ultrasoundSpacing'; +import { prepareChunkAllocation } from './prepareChunkAllocation'; +import { preparedChunks, unchangedChunks } from './preparedChunks'; import { ChunkStatus, type ChunkImageEvents, @@ -146,15 +139,18 @@ export class VtkDicomImage { const previous = this.image; this.image = image; this.spatialMetadata = spatialMetadata; - this.events.emit('imageChanged', image); - previous.delete(); + try { + this.events.emit('imageChanged', image); + } finally { + previous.delete(); + } } getModality() { return modalityOf(this.chunks); } - getChunkStatuses(): Array { + getChunkStatuses() { return this.chunkStatus.slice(); } @@ -245,9 +241,10 @@ export class VtkDicomImage { const geometry = reconstruction ? structuredClone(reconstruction) : undefined; - const update = this.chunkUpdateQueue.then(() => - this.applyChunks(ordered, axes, geometry) - ); + const update = this.chunkUpdateQueue.then(async () => { + const prepared = await this.prepareChunks(ordered, axes, geometry); + prepared.commit(); + }); this.chunkUpdateQueue = update.catch(() => {}); return update; } @@ -275,64 +272,67 @@ export class VtkDicomImage { } } - private async applyChunks( + /** Prepares new membership without changing the image, events or decoding. */ + async prepareChunks( chunks: Chunk[], orientation: number[] | null, supplied?: Reconstruction ) { - if (this.sameMembership(chunks, orientation, supplied)) return; - - if (this.disposed) return; - await this.loadMetadata(chunks); - if (this.disposed) return; - - const status = chunks.map(initialChunkStatus); - const needsFrameGeometry = - chunks.length === 1 && Number(getChunkTag(chunks[0], '00280008')) > 1; - const reconstruction = - supplied && !needsFrameGeometry - ? supplied - : reconstructInstances( - chunks.map((chunk) => readInstanceFacts(chunk.metadata!)), - sliceNormalOf(orientation), - supplied - ); - const allocated = - modalityOf(chunks) === 'SEG' - ? null - : allocateImageFromChunks( - chunks, - orientation, - reconstruction, - this.irregularGeometry + if (this.disposed || this.sameMembership(chunks, orientation, supplied)) + return unchangedChunks; + const ordered = chunks.slice(); + const axes = orientation?.slice() ?? null; + const geometry = supplied ? structuredClone(supplied) : undefined; + await this.loadMetadata(ordered); + if (this.disposed) return unchangedChunks; + const generation = this.allocationGeneration; + const allocated = prepareChunkAllocation( + ordered, + axes, + geometry, + this.irregularGeometry + ); + return preparedChunks( + () => { + if (generation !== this.allocationGeneration) + throw new Error( + 'The DICOM image changed after this update was prepared' ); + this.publishChunks(ordered, axes, allocated); + }, + () => { + if (allocated.image !== this.image) allocated.image?.delete(); + }, + this.lifetime.signal + ); + } + private publishChunks( + chunks: Chunk[], + orientation: number[] | null, + allocated: ReturnType + ) { + const status = chunks.map(initialChunkStatus); this.unregisterChunkListeners(); - this.allocationGeneration += 1; this.chunks = chunks; this.orientation = orientation; - this.reconstruction = reconstruction; + this.reconstruction = allocated.reconstruction; this.chunkStatus = status; this.invalidateThumbnail(); this.segmentationMetadata = null; this.onChunksUpdated(); - if (allocated) { - const spatialMetadata = applyUltrasoundSpacing( - allocated, - chunks, - this.warn - ); - this.replaceImage(allocated.image, spatialMetadata); - } + if (allocated.image) + this.replaceImage(allocated.image, allocated.spatialMetadata); this.registerChunkListeners(); this.processLoadedChunks(); - if (allocated) { + if (allocated.image) { this.updateDataRangeFromChunks(); } + allocated.warnings.forEach(([title, details]) => this.warn(title, details)); } private invalidateThumbnail() { @@ -361,7 +361,7 @@ export class VtkDicomImage { return this.thumbnail; } - private async readThumbnail(): Promise { + private async readThumbnail() { const signal = this.thumbnailLifetime.signal; const middle = Math.floor(this.chunks.length / 2); const chunk = this.chunks[middle]; @@ -594,7 +594,7 @@ export class VtkDicomImage { ); } - private computeStatus(): ProgressiveImageStatus { + private computeStatus() { const anyLoaded = this.chunkStatus.some( (status) => status === ChunkStatus.Loaded ); diff --git a/packages/dicom/vtk/__tests__/consumer.spec.ts b/packages/dicom/vtk/__tests__/consumer.spec.ts index a4251467d..33f4be322 100644 --- a/packages/dicom/vtk/__tests__/consumer.spec.ts +++ b/packages/dicom/vtk/__tests__/consumer.spec.ts @@ -1,8 +1,10 @@ import { sliceFromChunk } from '../thumbnailSource'; import { describe, expect, it } from 'vitest'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import { Chunk, type Reconstruction } from '../../core'; -import { VtkDicomImage, type VtkDicomImageInit } from '..'; +import { Chunk } from '../../core/streaming/chunk'; +import { type Reconstruction } from '../../core/collections/reconstructVolume'; +import { VtkDicomImage } from '../VtkDicomImage'; +import { type VtkDicomImageInit } from '../types'; import { datasetFromTags } from '@/tests/dicomDataset'; const AXIAL = [1, 0, 0, 0, 1, 0]; @@ -63,6 +65,58 @@ function deferred() { } describe('vtk DICOM consumer', () => { + it('keeps displayed pixels until prepared growth commits and can discard growth', async () => { + const chunks = await Promise.all([loadedChunk(1), loadedChunk(2)]); + const image = new VtkDicomImage({ readDicomImage: read }); + const ready = completed(image); + await image.setChunks([chunks[0]], AXIAL); + await ready; + const original = image.getVtkImageData(); + const replacements: number[][] = []; + image.addEventListener('imageChanged', (next) => { + replacements.push(next.getDimensions()); + }); + + const abandoned = await image.prepareChunks(chunks, AXIAL); + expect(image.getVtkImageData()).toBe(original); + expect(pixels(image)).toEqual([1]); + expect(image.isLoaded()).toBe(true); + expect(replacements).toEqual([]); + abandoned.dispose(); + abandoned.commit(); + expect(original.isDeleted()).toBe(false); + expect(pixels(image)).toEqual([1]); + + const update = await image.prepareChunks(chunks, AXIAL); + const grown = completed(image); + update.commit(); + await grown; + expect(replacements).toEqual([[1, 1, 2]]); + expect(original.isDeleted()).toBe(true); + expect(pixels(image)).toEqual([1, 2]); + update.dispose(); + expect(image.getVtkImageData().isDeleted()).toBe(false); + image.dispose(); + }); + + it('does not publish prepared growth over a newer image', async () => { + const [first, second, third] = await Promise.all([ + loadedChunk(1), + loadedChunk(2), + loadedChunk(3), + ]); + const image = new VtkDicomImage({ readDicomImage: read }); + await image.setChunks([first], AXIAL); + const stale = await image.prepareChunks([first, second], AXIAL); + const ready = completed(image); + await image.setChunks([third], AXIAL); + await ready; + expect(() => stale.commit()).toThrow('changed after'); + expect(pixels(image)).toEqual([3]); + expect(image.getVtkImageData().isDeleted()).toBe(false); + image.dispose(); + }); + it('uses the plan spacing and slice mapping without reconstructing it', async () => { const chunks = await Promise.all([loadedChunk(1), loadedChunk(19)]); const image = new VtkDicomImage({ readDicomImage: read }); diff --git a/packages/dicom/vtk/__tests__/functionalGroups.spec.ts b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts index 1e5494e5b..7b6781376 100644 --- a/packages/dicom/vtk/__tests__/functionalGroups.spec.ts +++ b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts @@ -1,18 +1,21 @@ import { describe, expect, it } from 'vitest'; -import type { DicomDataset } from '../../core'; +import type { DicomDataset } from '../../core/metadata/dicomDataset'; +import { Chunk } from '../../core/streaming/chunk'; import { - Chunk, readGeometryFacts, readInstanceFacts, +} from '../../core/collections/instanceFacts'; +import { planDicomCollections, IRREGULAR_VOLUME_WARNING, REPEATED_POSITIONS_WARNING, UNREADABLE_FRAME_GEOMETRY_WARNING, -} from '../../core'; +} from '../../core/collections/planDicomCollections'; import { VtkDicomImage } from '../VtkDicomImage'; import { getFrameTag } from '../../core/metadata/functionalGroups'; import { allocateImageFromChunks } from '../allocateImageFromChunks'; import { datasetFromTags } from '../../../../tests/dicomDataset'; +import { createDicomCollectionRegistry } from '../../core/collections/collectionRegistry'; const tags = (values: Record) => datasetFromTags(Object.entries(values)); @@ -297,6 +300,46 @@ describe('intra-instance frame planning', () => { instances: [readInstanceFacts(metadata)], }).collections[0]; + it.each([1, -1])( + 'preserves stored frame direction %s through registration and image allocation', + async (direction) => { + const positions = [0, 1, 2].map((index) => 10 + direction * index * 3); + const chunk = await loadedMetadata( + enhanced( + ctShared(), + positions.map((z) => positioned(`1\\2\\${z}`)) + ) + ); + const registry = createDicomCollectionRegistry(); + const { updates } = await registry.register([chunk]); + const { members, collection } = updates[0]; + const volume = new VtkDicomImage({ + readDicomImage: async () => ({ + image: { + size: [2, 2, 3], + data: new Int32Array(12), + imageType: { components: 1 }, + }, + }), + }); + try { + await volume.setChunks( + members, + collection.orientation, + collection.reconstruction + ); + const image = volume.getVtkImageData(); + expect(image.getSpacing()[2]).toBe(3); + positions.forEach((z, index) => + expect(image.indexToWorld([0, 0, index])).toEqual([1, 2, z]) + ); + } finally { + volume.dispose(); + chunk.dispose(); + } + } + ); + it('rejects shared-plane temporal frames under strict geometry validation', () => { const shared = ctShared(); shared.elements.push( diff --git a/packages/dicom/vtk/allocateImageFromChunks.ts b/packages/dicom/vtk/allocateImageFromChunks.ts index bb43d875d..97d752369 100644 --- a/packages/dicom/vtk/allocateImageFromChunks.ts +++ b/packages/dicom/vtk/allocateImageFromChunks.ts @@ -1,19 +1,21 @@ +import { Chunk } from '../core/streaming/chunk'; +import { DicomDataset } from '../core/metadata/dicomDataset'; +import { getDatasetTag, getChunkTag } from '../core/metadata/datasetAccess'; import { - Chunk, - DicomDataset, - getDatasetTag, - getChunkTag, readGeometryFacts, sliceNormalOf, - type Reconstruction, - multiframeGeometry, +} from '../core/collections/instanceFacts'; +import { type Reconstruction } from '../core/collections/reconstructVolume'; +import { multiframeGeometry } from '../core/collections/multiframeGeometry'; +import { readChunkFacts } from '../core/collections/chunkFacts'; +import { declaredSpacing, physicalSpacing, readPixelSpacing, type SpatialMetadata, -} from '../core/index'; +} from '../core/metadata/spatialMetadata'; import { getFrameTag } from '../core/metadata/functionalGroups'; -import { numberOr } from '../core/utils'; +import { numberOr, trimDicomPadding } from '../core/utils'; import { nmGeometry } from './nmGeometry'; import { readModalityRescale } from '../core/metadata/modalityRescale'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; @@ -276,8 +278,16 @@ function assertRegularGeometry( ); } -const instanceGeometry = (metadata: DicomDataset, count: number) => - nmGeometry(metadata) ?? multiframeGeometry(metadata, count); +const instanceGeometry = (chunk: Chunk, count: number) => { + const metadata = chunk.metadata!; + const nm = nmGeometry(metadata); + if (nm) return nm; + if (count < 2) return null; + // SEG planning does not judge segment-major frames as one spatial stack. + if (trimDicomPadding(getDatasetTag(metadata, '00080060') ?? '') === 'SEG') + return multiframeGeometry(metadata, count); + return readChunkFacts(chunk).frameGeometry; +}; function allocationDirection( orientation: number[] | null, @@ -295,7 +305,8 @@ function allocationGeometry( ) { const metadata = chunks[0].metadata!; const count = numberOr(getDatasetTag(metadata, NumberOfFrames), 1); - const frames = chunks.length === 1 ? instanceGeometry(metadata, count) : null; + const frames = + chunks.length === 1 ? instanceGeometry(chunks[0], count) : null; const geometry = frames ?? readGeometryFacts(metadata); const plan = frames?.reconstruction ?? reconstruction; assertRegularGeometry(plan, policy); diff --git a/packages/dicom/vtk/index.ts b/packages/dicom/vtk/index.ts deleted file mode 100644 index c31559bdc..000000000 --- a/packages/dicom/vtk/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { VtkDicomImage } from './VtkDicomImage'; -export * from './types'; -export * from './allocateImageFromChunks'; -export * from './thumbnail'; diff --git a/packages/dicom/vtk/nmGeometry.ts b/packages/dicom/vtk/nmGeometry.ts index fd743ef3d..c5c3008fc 100644 --- a/packages/dicom/vtk/nmGeometry.ts +++ b/packages/dicom/vtk/nmGeometry.ts @@ -1,9 +1,9 @@ +import { DicomDataset } from '../core/metadata/dicomDataset'; import { - DicomDataset, readGeometryFacts, sliceNormalOf, - type Reconstruction, -} from '../core/index'; +} from '../core/collections/instanceFacts'; +import { type Reconstruction } from '../core/collections/reconstructVolume'; import { readNmReconstruction } from '../core/metadata/nmReconstruction'; export function nmGeometry(metadata: DicomDataset) { diff --git a/packages/dicom/vtk/prepareChunkAllocation.ts b/packages/dicom/vtk/prepareChunkAllocation.ts new file mode 100644 index 000000000..78b12beaa --- /dev/null +++ b/packages/dicom/vtk/prepareChunkAllocation.ts @@ -0,0 +1,61 @@ +import type { Chunk } from '../core/streaming/chunk'; +import { getChunkTag } from '../core/metadata/datasetAccess'; +import { sliceNormalOf } from '../core/collections/instanceFacts'; +import { readChunkFacts } from '../core/collections/chunkFacts'; +import { reconstructInstances } from '../core/collections/reconstructVolume'; +import type { Reconstruction } from '../core/collections/reconstructVolume'; +import { + allocateImageFromChunks, + type IrregularGeometryPolicy, +} from './allocateImageFromChunks'; +import { applyUltrasoundSpacing } from './ultrasoundSpacing'; + +const reconstructionFor = ( + chunks: Chunk[], + orientation: number[] | null, + supplied: Reconstruction | undefined +) => { + const needsFrameGeometry = + chunks.length === 1 && Number(getChunkTag(chunks[0], '00280008')) > 1; + return supplied && !needsFrameGeometry + ? supplied + : reconstructInstances( + chunks.map((chunk) => readChunkFacts(chunk).facts), + sliceNormalOf(orientation), + supplied + ); +}; + +/** Allocate off-screen; warnings become visible only when the image commits. */ +export function prepareChunkAllocation( + chunks: Chunk[], + orientation: number[] | null, + supplied: Reconstruction | undefined, + policy: IrregularGeometryPolicy +) { + if (chunks.length === 0) + throw new Error('Cannot allocate an image from zero chunks'); + const reconstruction = reconstructionFor(chunks, orientation, supplied); + const modality = getChunkTag(chunks[0], '00080060')?.trim(); + const allocated = + modality === 'SEG' + ? null + : allocateImageFromChunks(chunks, orientation, reconstruction, policy); + const warnings: Array<[string, string]> = []; + try { + const spatialMetadata = allocated + ? applyUltrasoundSpacing(allocated, chunks, (...warning) => + warnings.push(warning) + ) + : undefined; + return { + image: allocated?.image, + reconstruction, + spatialMetadata, + warnings, + }; + } catch (error) { + allocated?.image.delete(); + throw error; + } +} diff --git a/packages/dicom/vtk/preparedChunks.ts b/packages/dicom/vtk/preparedChunks.ts new file mode 100644 index 000000000..cf817b836 --- /dev/null +++ b/packages/dicom/vtk/preparedChunks.ts @@ -0,0 +1,42 @@ +export type PreparedChunks = { + changed: boolean; + commit: () => void; + dispose: () => void; +}; + +export const unchangedChunks: PreparedChunks = { + changed: false, + commit: () => {}, + dispose: () => {}, +}; + +/** A prepared allocation belongs to its caller until committed or discarded. */ +export function preparedChunks( + apply: () => void, + release: () => void, + signal: AbortSignal +): PreparedChunks { + let pending = true; + const detach = () => { + pending = false; + signal.removeEventListener('abort', dispose); + }; + const dispose = () => { + if (!pending) return; + detach(); + release(); + }; + const commit = () => { + if (!pending) return; + detach(); + try { + apply(); + } catch (error) { + release(); + throw error; + } + }; + signal.addEventListener('abort', dispose, { once: true }); + if (signal.aborted) dispose(); + return { changed: true, commit, dispose }; +} diff --git a/packages/dicom/vtk/thumbnailSource.ts b/packages/dicom/vtk/thumbnailSource.ts index 90566e992..e4cba0ee1 100644 --- a/packages/dicom/vtk/thumbnailSource.ts +++ b/packages/dicom/vtk/thumbnailSource.ts @@ -1,4 +1,4 @@ -import { Chunk } from '../core/index'; +import { Chunk } from '../core/streaming/chunk'; import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import type { TypedArray } from '@kitware/vtk.js/types'; diff --git a/packages/dicom/vtk/types.ts b/packages/dicom/vtk/types.ts index 7d549bb93..d5db7ab8a 100644 --- a/packages/dicom/vtk/types.ts +++ b/packages/dicom/vtk/types.ts @@ -1,7 +1,8 @@ import type { Image } from '@itk-wasm/image-io'; import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import type { Extent } from '@kitware/vtk.js/types'; -import type { Chunk, DicomDataset } from '../core/index'; +import type { Chunk } from '../core/streaming/chunk'; +import type { DicomDataset } from '../core/metadata/dicomDataset'; import type { IrregularGeometryPolicy } from './allocateImageFromChunks'; import type { ThumbnailSlice } from './thumbnail'; diff --git a/packages/dicom/vtk/ultrasoundSpacing.ts b/packages/dicom/vtk/ultrasoundSpacing.ts index c93cd1a4b..a700939e4 100644 --- a/packages/dicom/vtk/ultrasoundSpacing.ts +++ b/packages/dicom/vtk/ultrasoundSpacing.ts @@ -1,10 +1,10 @@ +import { type Chunk } from '../core/streaming/chunk'; +import { getChunkTag } from '../core/metadata/datasetAccess'; +import { unitToMm } from '../core/streaming/dicom/ultrasoundRegion'; import { - type Chunk, - getChunkTag, - unitToMm, physicalSpacing, type SpatialMetadata, -} from '../core/index'; +} from '../core/metadata/spatialMetadata'; import type { allocateImageFromChunks } from './allocateImageFromChunks'; const isUsableSpacing = (value: number) => diff --git a/src/components/App.vue b/src/components/App.vue index e35673a5a..b577a0a42 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -58,7 +58,7 @@ import useLoadDataStore from '@/src/store/load-data'; import { useViewStore } from '@/src/store/views'; // Side-effect import: registers processing's config section and // launch-load subscriber before any config or data loads. -import '@/src/processing'; +import '@/src/processing/integration'; import { signalLaunchLoadComplete } from '@/src/core/launchLoad'; import useRemoteSaveStateStore from '@/src/store/remote-save-state'; import AppBar from '@/src/components/AppBar.vue'; diff --git a/src/components/ModulePanel.vue b/src/components/ModulePanel.vue index 7713d3a0a..7b267a50a 100644 --- a/src/components/ModulePanel.vue +++ b/src/components/ModulePanel.vue @@ -48,7 +48,8 @@ import { Component, computed, defineComponent, ref, watch } from 'vue'; import { ConnectionState, useServerStore } from '@/src/store/server'; -import { JobsModule, useProcessingJobsStore } from '@/src/processing'; +import { JobsModule } from '@/src/processing/integration'; +import { useProcessingJobsStore } from '@/src/processing/store'; import DataBrowser from './DataBrowser.vue'; import RenderingModule from './RenderingModule.vue'; import AnnotationsModule from './AnnotationsModule.vue'; diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 5d81bdf4c..bd0c034fe 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -61,7 +61,7 @@ import { useLocalStorage } from '@vueuse/core'; import { useKeyboardShortcutsStore } from '@/src/store/keyboard-shortcuts'; import { useViewCameraStore } from '@/src/store/view-configs/camera'; -import { useReferenceLinesStore } from '@/src/referenceLines'; +import { useReferenceLinesStore } from '@/src/referenceLines/store'; import DicomWebSettings from './dicom-web/DicomWebSettings.vue'; import ServerSettings from './ServerSettings.vue'; import { DarkTheme, LightTheme, ThemeStorageKey } from '../constants'; diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue index 68406edd4..7894815c3 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -157,7 +157,8 @@ import { useSegmentationStore } from '@/src/segmentation/store'; import VtkLayerSliceRepresentation from '@/src/components/vtk/VtkLayerSliceRepresentation.vue'; import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; import CropTool from '@/src/components/tools/crop/CropTool.vue'; -import { ReferenceLines, useReferenceLinesStore } from '@/src/referenceLines'; +import ReferenceLines from '@/src/referenceLines/ReferenceLines.vue'; +import { useReferenceLinesStore } from '@/src/referenceLines/store'; import CrosshairsWidget2D from '@/src/components/tools/crosshairs/CrosshairsWidget2D.vue'; import PaintTool from '@/src/components/tools/paint/PaintTool.vue'; import PolygonTool from '@/src/components/tools/polygon/PolygonTool.vue'; diff --git a/src/components/__tests__/PatientStudyVolumeBrowser.thumbnails.spec.ts b/src/components/__tests__/PatientStudyVolumeBrowser.thumbnails.spec.ts index 89a12c238..7619039c6 100644 --- a/src/components/__tests__/PatientStudyVolumeBrowser.thumbnails.spec.ts +++ b/src/components/__tests__/PatientStudyVolumeBrowser.thumbnails.spec.ts @@ -12,7 +12,7 @@ import { defineComponent, h } from 'vue'; import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import PatientStudyVolumeBrowser from '@/src/components/PatientStudyVolumeBrowser.vue'; -import { Chunk } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; import type { DicomChunkImageInit } from '@/src/core/streaming/dicomChunkImage'; import { LoadedVtkImage } from '@/src/core/progressiveImage'; diff --git a/src/components/__tests__/latentGating.spec.ts b/src/components/__tests__/latentGating.spec.ts index 810e0239c..76be8582d 100644 --- a/src/components/__tests__/latentGating.spec.ts +++ b/src/components/__tests__/latentGating.spec.ts @@ -10,11 +10,11 @@ import { createPinia, setActivePinia } from 'pinia'; // Keep the Jobs async-component cheap and DOM-safe if it ever renders. import ModulePanel from '@/src/components/ModulePanel.vue'; -import { useProcessingJobsStore } from '@/src/processing'; +import { useProcessingJobsStore } from '@/src/processing/store'; import useRemoteSaveStateStore from '@/src/store/remote-save-state'; import { ConnectionState, useServerStore } from '@/src/store/server'; import { savePost, saveDependencies } from '@/src/store/__tests__/saveFixtures'; -import type { ProcessingProviderConfig } from '@/src/processing'; +import type { ProcessingProviderConfig } from '@/src/processing/types'; // Stub the Vuetify shell so mounting ModulePanel exercises only its own gating // logic (we read the `modules` computed, never the rendered DOM). diff --git a/src/components/vtk/VtkBaseVolumeRepresentation.vue b/src/components/vtk/VtkBaseVolumeRepresentation.vue index 517d3a98a..68eca9e34 100644 --- a/src/components/vtk/VtkBaseVolumeRepresentation.vue +++ b/src/components/vtk/VtkBaseVolumeRepresentation.vue @@ -38,7 +38,7 @@ import { useCroppingEffect } from '@/src/composables/useCroppingEffect'; import { useCropStore } from '@/src/store/tools/crop'; import { useEventListener } from '@vueuse/core'; import type { ChunkImage } from '@/src/core/streaming/chunkImage'; -import type { ChunkLoadedInfo } from '@/packages/dicom/vtk'; +import type { ChunkLoadedInfo } from '@/packages/dicom/vtk/types'; interface Props { viewId: string; diff --git a/src/core/cine/DicomCineImage.ts b/src/core/cine/DicomCineImage.ts index 16aede916..e5e540abe 100644 --- a/src/core/cine/DicomCineImage.ts +++ b/src/core/cine/DicomCineImage.ts @@ -24,12 +24,12 @@ import { decodeJpegFrame, decodeNativeFrame, } from './frameCache'; +import { unitToMm } from '@/packages/dicom/core/streaming/dicom/ultrasoundRegion'; import { - unitToMm, physicalSpacing, fallbackSpacing, type SpatialMetadata, -} from '@/packages/dicom/core'; +} from '@/packages/dicom/core/metadata/spatialMetadata'; function spatialRegionSpacing(r: CineHeader['regions'][number]) { const sx = r.physicalUnitsX != null ? unitToMm(r.physicalUnitsX) : null; diff --git a/src/core/progressiveImage.ts b/src/core/progressiveImage.ts index 24eaf9062..16140d2fc 100644 --- a/src/core/progressiveImage.ts +++ b/src/core/progressiveImage.ts @@ -1,5 +1,5 @@ import { computed, effectScope, ref, Ref, shallowRef } from 'vue'; -import type { SpatialMetadata } from '@/packages/dicom/core'; +import type { SpatialMetadata } from '@/packages/dicom/core/metadata/spatialMetadata'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import { ImageMetadata } from '@/src/types/image'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; diff --git a/src/core/streaming/__tests__/dicomChunkImage.spec.ts b/src/core/streaming/__tests__/dicomChunkImage.spec.ts index 4742fc9e8..6ebd701de 100644 --- a/src/core/streaming/__tests__/dicomChunkImage.spec.ts +++ b/src/core/streaming/__tests__/dicomChunkImage.spec.ts @@ -1,15 +1,21 @@ import { datasetFromTags } from '@/tests/dicomDataset'; -import { getChunkTag } from '@/packages/dicom/core'; +import { getChunkTag } from '@/packages/dicom/core/metadata/datasetAccess'; import { describe, it, expect, vi } from 'vitest'; -import { Chunk } from '@/packages/dicom/core'; -import { ChunkState } from '@/packages/dicom/core'; -import { Tags } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import { ChunkState } from '@/packages/dicom/core/streaming/chunkStateMachine'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import DicomChunkImage, { DicomChunkImageInit, } from '@/src/core/streaming/dicomChunkImage'; -import { sliceToThumbnail, type ThumbnailSlice } from '@/packages/dicom/vtk'; -import { ChunkStatus } from '@/packages/dicom/vtk'; -import { US_UNIT_CENTIMETERS, UltrasoundRegions } from '@/packages/dicom/core'; +import { + sliceToThumbnail, + type ThumbnailSlice, +} from '@/packages/dicom/vtk/thumbnail'; +import { ChunkStatus } from '@/packages/dicom/vtk/types'; +import { + US_UNIT_CENTIMETERS, + UltrasoundRegions, +} from '@/packages/dicom/core/streaming/dicom/ultrasoundRegion'; const AXIAL = [1, 0, 0, 0, 1, 0]; const ROWS = 2; diff --git a/src/core/streaming/__tests__/dicomThumbnail.spec.ts b/src/core/streaming/__tests__/dicomThumbnail.spec.ts index bbb971983..a0c58fd1b 100644 --- a/src/core/streaming/__tests__/dicomThumbnail.spec.ts +++ b/src/core/streaming/__tests__/dicomThumbnail.spec.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { sliceToThumbnail, THUMBNAIL_MAX_SIZE } from '@/packages/dicom/vtk'; +import { + sliceToThumbnail, + THUMBNAIL_MAX_SIZE, +} from '@/packages/dicom/vtk/thumbnail'; const bytes = (slice: { data: Uint8Array }) => Array.from(slice.data); diff --git a/src/core/streaming/chunkImage.ts b/src/core/streaming/chunkImage.ts index ae8c9f2c8..b8a3debd5 100644 --- a/src/core/streaming/chunkImage.ts +++ b/src/core/streaming/chunkImage.ts @@ -1,6 +1,7 @@ import type { ProgressiveImage } from '@/src/core/progressiveImage'; -import type { Chunk, Reconstruction } from '@/packages/dicom/core'; -import type { ChunkImageEvents, ChunkStatus } from '@/packages/dicom/vtk'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import type { Reconstruction } from '@/packages/dicom/core/collections/reconstructVolume'; +import type { ChunkImageEvents, ChunkStatus } from '@/packages/dicom/vtk/types'; export type ChunkImage = ProgressiveImage & { setChunks( diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 02ac0974b..8eee69284 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -1,9 +1,10 @@ +import { VtkDicomImage } from '@/packages/dicom/vtk/VtkDicomImage'; import { - VtkDicomImage, type VtkDicomImageInit, type ChunkImageEvents, -} from '@/packages/dicom/vtk'; -import type { Chunk, Reconstruction } from '@/packages/dicom/core'; +} from '@/packages/dicom/vtk/types'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import type { Reconstruction } from '@/packages/dicom/core/collections/reconstructVolume'; import { BaseProgressiveImage } from '@/src/core/progressiveImage'; import type { ChunkImage } from '@/src/core/streaming/chunkImage'; import { @@ -111,6 +112,14 @@ export default class DicomChunkImage return this.consumer.setChunks(chunks, orientation, reconstruction); } + prepareChunks( + chunks: Chunk[], + orientation: number[] | null, + reconstruction?: Reconstruction + ) { + return this.consumer.prepareChunks(chunks, orientation, reconstruction); + } + addEventListener( type: T, callback: (info: ChunkImageEvents[T]) => void diff --git a/src/io/__tests__/dicom.spec.ts b/src/io/__tests__/dicom.spec.ts deleted file mode 100644 index 9a6068d35..000000000 --- a/src/io/__tests__/dicom.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import * as dicomIo from '@/src/io/dicom'; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../..' -); - -const source = fs.readFileSync(path.join(repoRoot, 'src/io/dicom.ts'), 'utf-8'); - -const sourceFrom = (marker: string) => { - const start = source.indexOf(marker); - return start === -1 ? '' : source.slice(start); -}; - -describe('src/io/dicom.ts runtime surface', () => { - it('drops splitAndSort, readVolumeSlice and readTags', () => { - const exported = Object.keys(dicomIo); - expect(exported).not.toContain('splitAndSort'); - expect(exported).not.toContain('readVolumeSlice'); - expect(exported).not.toContain('readTags'); - expect(exported.sort()).toEqual(['readDicomSegmentation']); - }); - - it('keeps readDicomSegmentation, the DICOM SEG reader', () => { - expect(typeof dicomIo.readDicomSegmentation).toBe('function'); - }); -}); - -describe('src/io/dicom.ts source', () => { - it('drops the runTask itk-wasm pipeline runner and its plumbing', () => { - expect(source).not.toMatch(/\brunTask\b/); - expect(source).not.toMatch(/runPipeline/); - expect(source).not.toMatch(/itkConfig/); - expect(source).not.toMatch(/InterfaceTypes/); - expect(source).not.toMatch(/TextStream/); - }); - - it('drops the custom pipeline actions it used to dispatch', () => { - expect(source).not.toMatch(/--action/); - expect(source).not.toMatch(/categorize/); - expect(source).not.toMatch(/getSliceImage/); - expect(source).not.toMatch(/--memory-io/); - }); - - it('no longer calls itk-wasm readDicomTags', () => { - expect(source).not.toMatch(/readDicomTags/); - }); - - it('drops the types that only typed the deleted wrappers', () => { - expect(source).not.toMatch(/\bTagSpec\b/); - expect(source).not.toMatch(/\bVolumesToFileNamesMap\b/); - }); - - it('keeps the SEG reader reading a name sanitized file', () => { - expect(source).toMatch(/function sanitizeFile\b/); - const readDicomSegmentation = sourceFrom( - 'export async function readDicomSegmentation' - ); - expect(readDicomSegmentation).toMatch(/sanitizeFile\(file\)/); - expect(readDicomSegmentation).toMatch(/readOverlappingSegmentation\(/); - expect(readDicomSegmentation).toMatch(/mergeSegments: true/); - }); -}); diff --git a/src/io/__tests__/dicomLoaderUnification.spec.ts b/src/io/__tests__/dicomLoaderUnification.spec.ts deleted file mode 100644 index 051959401..000000000 --- a/src/io/__tests__/dicomLoaderUnification.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as fs from 'node:fs'; -import { - exists, - read, - relative, - sourceFiles, - sourcesMatching, -} from './sourceTree'; - -describe('one DICOM import processor', () => { - it('keeps handleDicom and drops the file and stream processors', () => { - expect(exists('src/io/import/processors/handleDicom.ts')).toBe(true); - expect(exists('src/io/import/processors/handleDicomFile.ts')).toBe(false); - expect(exists('src/io/import/processors/handleDicomStream.ts')).toBe(false); - }); - - it('registers handleDicom in the import chain, and nothing it replaced', () => { - const chain = read('src/io/import/importDataSources.ts'); - expect(chain).toMatch(/\bhandleDicom\b/); - expect(chain).not.toMatch(/handleDicomFile|handleDicomStream/); - }); -}); - -describe('one DICOM meta loader', () => { - it('drops the file meta loader', () => { - expect(exists('src/core/streaming/dicom/dicomFileMetaLoader.ts')).toBe( - false - ); - expect( - exists('packages/dicom/core/streaming/dicom/dicomMetaLoader.ts') - ).toBe(true); - }); - - it('drops the fake Pixel Data element', () => { - expect(sourcesMatching(/generateEmptyPixelData/)).toEqual([]); - }); -}); - -describe('the JS reader is the only DICOM tag reader', () => { - it('leaves no @itk-wasm/dicom readDicomTags import in src/', () => { - const offenders = sourceFiles() - .filter((file) => - /import[^;]*\breadDicomTags\b[^;]*from\s*'@itk-wasm\/dicom'/s.test( - fs.readFileSync(file, 'utf-8') - ) - ) - .map(relative) - .sort(); - expect(offenders).toEqual([]); - }); - - it('keeps flat tag projection in VolView and structured reading in core', () => { - expect(exists('src/io/readDicomTags.ts')).toBe(true); - expect(read('src/io/readDicomTags.ts')).toMatch(/dicomDatasetToTags/); - expect( - read('packages/dicom/core/streaming/dicom/dicomMetaLoader.ts') - ).toMatch(/readDicomDataset/); - }); -}); - -describe('the itk worker', () => { - const worker = () => read('src/io/itk/worker.ts'); - - it('no longer warms readDicomTags', () => { - expect(worker()).not.toMatch(/\breadDicomTags\b/); - }); -}); diff --git a/src/io/__tests__/io.spec.ts b/src/io/__tests__/io.spec.ts index a9a4acddf..7185ae524 100644 --- a/src/io/__tests__/io.spec.ts +++ b/src/io/__tests__/io.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { retypeFile } from '@/src/io'; +import { retypeFile } from '@/src/io/io'; function makeEmptyFile(name: string) { return new File([], name); diff --git a/src/io/__tests__/itkDicomPipeline.spec.ts b/src/io/__tests__/itkDicomPipeline.spec.ts deleted file mode 100644 index 902b758fb..000000000 --- a/src/io/__tests__/itkDicomPipeline.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as fs from 'node:fs'; -import { execSync } from 'node:child_process'; -import { - exists, - fromRoot, - read, - relative, - repoRoot, - sourcesMatching, - walk, -} from './sourceTree'; - -const pkg = JSON.parse(read('package.json')); - -describe('the custom itk-dicom wasm pipeline is deleted', () => { - it('removes src/io/itk-dicom from the worktree and from git', () => { - expect(exists('src/io/itk-dicom')).toBe(false); - expect(exists('src/io/itk-dicom/dicom.cpp')).toBe(false); - expect(exists('src/io/itk-dicom/CMakeLists.txt')).toBe(false); - expect(exists('src/io/itk-dicom/emscripten-build/dicom.js')).toBe(false); - expect(exists('src/io/itk-dicom/emscripten-build/dicom.wasm')).toBe(false); - expect(exists('src/io/itk-dicom/emscripten-build/dicom.wasm.zst')).toBe( - false - ); - const tracked = execSync('git ls-files src/io/itk-dicom', { - cwd: repoRoot, - encoding: 'utf-8', - }).trim(); - expect(tracked).toBe(''); - }); - - it('keeps the resample pipeline and its checked in build', () => { - expect(exists('src/io/resample/resample.cxx')).toBe(true); - expect(exists('src/io/resample/CMakeLists.txt')).toBe(true); - expect(exists('src/io/resample/emscripten-build/resample.js')).toBe(true); - expect(exists('src/io/resample/emscripten-build/resample.wasm')).toBe(true); - expect(exists('src/io/resample/emscripten-build/resample.wasm.zst')).toBe( - true - ); - }); -}); - -describe('package.json build scripts', () => { - it('drops build:dicom and build:dicom:debug', () => { - expect(pkg.scripts).not.toHaveProperty('build:dicom'); - expect(pkg.scripts).not.toHaveProperty('build:dicom:debug'); - }); - - it('runs build:all as resample then build', () => { - expect(pkg.scripts['build:all']).toBe( - 'npm run build:resample && npm run build' - ); - }); - - it('keeps the resample build scripts untouched', () => { - expect(pkg.scripts['build:resample']).toBe( - 'itk-wasm -s src/io/resample/ build ' - ); - expect(pkg.scripts['build:resample:debug']).toBe( - 'itk-wasm -s src/io/resample/ build -- -DCMAKE_BUILD_TYPE=Debug' - ); - }); -}); - -describe('bundler and tooling config', () => { - it('drops the itk-dicom static copy target from vite.config.ts', () => { - expect(read('vite.config.ts')).not.toMatch(/itk-dicom/); - }); - - it('keeps every other static copy target in vite.config.ts', () => { - const viteConfig = read('vite.config.ts'); - expect(viteConfig).toMatch( - /src\/io\/resample\/emscripten-build\/\*\*\/resample\*/ - ); - expect(viteConfig).toMatch(/@itk-wasm\/dicom/); - expect(viteConfig).toMatch(/@itk-wasm\/image-io/); - expect(viteConfig).toMatch( - /@itk-wasm\/morphological-contour-interpolation/ - ); - }); - - it('drops the dangling itk-dicom ignore entries', () => { - expect(read('eslint.config.js')).not.toMatch(/itk-dicom/); - expect(read('.prettierignore').split('\n')).not.toContain( - 'src/io/itk-dicom' - ); - }); - - it('keeps the resample ignore entries', () => { - expect(read('eslint.config.js')).toMatch( - /src\/io\/resample\/emscripten-build\/\*\*/ - ); - expect(read('.prettierignore').split('\n')).toContain('src/io/resample'); - }); -}); - -describe('no stale mention of the deleted pipeline', () => { - const TEXT = /\.(md|ts|js|json|yml|yaml|toml|html)$/; - - const checkedFiles = [ - 'package.json', - 'package-lock.json', - 'vite.config.ts', - 'eslint.config.js', - '.prettierignore', - 'tsconfig.json', - 'README.md', - 'CONTRIBUTING.md', - ] - .map((name) => fromRoot(name)) - .filter((file) => fs.existsSync(file)) - .concat(walk(fromRoot('docs')).filter((file) => TEXT.test(file))) - .concat(walk(fromRoot('.github')).filter((file) => TEXT.test(file))); - - it('names itk-dicom or build:dicom in no config, doc or workflow', () => { - const offenders = checkedFiles - .filter((file) => - /itk-dicom|build:dicom/.test(fs.readFileSync(file, 'utf-8')) - ) - .map(relative) - .sort(); - expect(offenders).toEqual([]); - }); -}); - -describe('no dangling reference to the deleted wrappers in src/', () => { - it('leaves no splitAndSort or readVolumeSlice caller', () => { - expect(sourcesMatching(/\bsplitAndSort\b/)).toEqual([]); - expect(sourcesMatching(/\breadVolumeSlice\b/)).toEqual([]); - }); - - it('leaves no runTask under src/io/', () => { - expect( - sourcesMatching(/\brunTask\b/).filter((file) => - file.startsWith('src/io/') - ) - ).toEqual([]); - }); -}); diff --git a/src/io/__tests__/readDicomTags.differential.spec.ts b/src/io/__tests__/readDicomTags.differential.spec.ts index 4ce2357ec..27f47e64a 100644 --- a/src/io/__tests__/readDicomTags.differential.spec.ts +++ b/src/io/__tests__/readDicomTags.differential.spec.ts @@ -9,8 +9,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { readDicomTagsNode } from '@itk-wasm/dicom'; -import { readDicomTags } from '@/src/io/readDicomTags'; -import { tagReaderCorpus } from './readDicomTagsFixtures'; +import { readDicomTags, tagReaderCorpus } from './readDicomTagsFixtures'; let workDir = ''; diff --git a/src/io/__tests__/readDicomTags.spec.ts b/src/io/__tests__/readDicomTags.spec.ts index 5725b67b6..ddfed9ff6 100644 --- a/src/io/__tests__/readDicomTags.spec.ts +++ b/src/io/__tests__/readDicomTags.spec.ts @@ -1,9 +1,9 @@ import { datasetFromTags } from '@/tests/dicomDataset'; import { describe, expect, it } from 'vitest'; -import { Tags } from '@/packages/dicom/core'; -import { readInstanceFacts } from '@/packages/dicom/core'; -import { readDicomTags } from '@/src/io/readDicomTags'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; +import { readInstanceFacts } from '@/packages/dicom/core/collections/instanceFacts'; import { + readDicomTags, buildSlice, CHINESE_NAME_BYTES, CHINESE_NAME_PADDED, diff --git a/src/io/__tests__/readDicomTagsFixtures.ts b/src/io/__tests__/readDicomTagsFixtures.ts index 915cf2c53..b3129e432 100644 --- a/src/io/__tests__/readDicomTagsFixtures.ts +++ b/src/io/__tests__/readDicomTagsFixtures.ts @@ -1,3 +1,5 @@ +import { readDicomDataset } from '@/packages/dicom/core/metadata/dicomDataset'; +import { dicomDatasetToTags } from '../readDicomTags'; import { attributeTagValue, buildSyntheticCineDicom, @@ -8,6 +10,9 @@ import { undefinedLengthSequence, } from '@/tests/specs/syntheticDicom'; +export const readDicomTags = (bytes: Uint8Array) => + dicomDatasetToTags(readDicomDataset(bytes)); + const concat = (...parts: Uint8Array[]) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); parts.reduce((offset, part) => { diff --git a/src/io/__tests__/sourceTree.ts b/src/io/__tests__/sourceTree.ts deleted file mode 100644 index cb1d8476e..000000000 --- a/src/io/__tests__/sourceTree.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Repo file lookups for specs that assert on the shape of the tree itself. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -export const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../..' -); - -export const fromRoot = (...parts: string[]) => path.join(repoRoot, ...parts); - -export const read = (...parts: string[]) => - fs.readFileSync(fromRoot(...parts), 'utf-8'); - -export const exists = (...parts: string[]) => fs.existsSync(fromRoot(...parts)); - -export const relative = (file: string) => path.relative(repoRoot, file); - -const SKIP_DIRS = new Set(['node_modules', '__tests__', 'emscripten-build']); - -export const walk = (dir: string): string[] => - fs.existsSync(dir) - ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - if (SKIP_DIRS.has(entry.name)) return []; - const full = path.join(dir, entry.name); - return entry.isDirectory() ? walk(full) : [full]; - }) - : []; - -export const sourceFiles = () => - walk(fromRoot('src')).filter((file) => /\.(ts|js|vue)$/.test(file)); - -export const sourcesMatching = (pattern: RegExp) => - sourceFiles() - .filter((file) => pattern.test(fs.readFileSync(file, 'utf-8'))) - .map(relative) - .sort(); diff --git a/src/io/import/__tests__/configRecognition.spec.ts b/src/io/import/__tests__/configRecognition.spec.ts index 26bebaa08..af47bc6c4 100644 --- a/src/io/import/__tests__/configRecognition.spec.ts +++ b/src/io/import/__tests__/configRecognition.spec.ts @@ -9,8 +9,8 @@ import { describe, it, expect } from 'vitest'; import { recognizeConfig } from '@/src/io/import/configJson'; // Registers the `processing` config section (a module-evaluation side effect of -// the feature entry point, mirroring the app's boot-time import). -import '@/src/processing'; +// the integration module, mirroring the app's boot-time import). +import '@/src/processing/integration'; describe('config-by-shape recognition', () => { it('recognizes a JSON with only known top-level keys as config', async () => { diff --git a/src/io/import/__tests__/dataSource.spec.ts b/src/io/import/__tests__/dataSource.spec.ts index 84676a30d..657b47a7c 100644 --- a/src/io/import/__tests__/dataSource.spec.ts +++ b/src/io/import/__tests__/dataSource.spec.ts @@ -3,7 +3,7 @@ import { getDataSourceName, isRemoteDataSource, } from '@/src/io/import/dataSource'; -import { Chunk } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; describe('isRemoteDatasource', () => { it('should work', () => { diff --git a/src/io/import/__tests__/dicomChunkProvenance.spec.ts b/src/io/import/__tests__/dicomChunkProvenance.spec.ts index 5d07912c1..6863fcbb3 100644 --- a/src/io/import/__tests__/dicomChunkProvenance.spec.ts +++ b/src/io/import/__tests__/dicomChunkProvenance.spec.ts @@ -1,10 +1,10 @@ import { datasetFromTags } from '@/tests/dicomDataset'; -import { getChunkTag } from '@/packages/dicom/core'; +import { getChunkTag } from '@/packages/dicom/core/metadata/datasetAccess'; import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import type { Chunk } from '@/packages/dicom/core'; -import { Tags } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import type { ChunkSource, DataSource } from '@/src/io/import/dataSource'; import { uriToDataSource } from '@/src/io/import/dataSource'; diff --git a/src/io/import/__tests__/processingConfigInjection.spec.ts b/src/io/import/__tests__/processingConfigInjection.spec.ts index 7e698a9ae..e5a92e03f 100644 --- a/src/io/import/__tests__/processingConfigInjection.spec.ts +++ b/src/io/import/__tests__/processingConfigInjection.spec.ts @@ -37,11 +37,16 @@ const configWithProvider = ( const jsonFile = (obj: unknown, name = 'config.json') => new File([JSON.stringify(obj)], name, { type: 'application/json' }); +const resetImportContext = () => { + vi.resetModules(); + setActivePinia(createPinia()); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}; + describe('processing config injection (config-by-shape, origin-gated)', () => { - beforeEach(() => { - vi.resetModules(); - setActivePinia(createPinia()); - vi.spyOn(console, 'warn').mockImplementation(() => {}); + beforeEach(async () => { + resetImportContext(); + await import('@/src/processing/integration'); }); afterEach(() => { @@ -56,7 +61,7 @@ describe('processing config injection (config-by-shape, origin-gated)', () => { ] = await Promise.all([ import('@/src/io/import/importDataSources'), import('@/src/io/import/dataSource'), - import('@/src/processing'), + import('@/src/processing/store'), ]); const ordinaryDataSource: DataSource = { @@ -90,7 +95,7 @@ describe('processing config injection (config-by-shape, origin-gated)', () => { ] = await Promise.all([ import('@/src/io/import/importDataSources'), import('@/src/io/import/dataSource'), - import('@/src/processing'), + import('@/src/processing/store'), import('@/src/store/view-configs/windowing'), ]); @@ -125,7 +130,7 @@ describe('processing config injection (config-by-shape, origin-gated)', () => { ] = await Promise.all([ import('@/src/io/import/importDataSources'), import('@/src/io/import/dataSource'), - import('@/src/processing'), + import('@/src/processing/store'), ]); // A config that both points a provider cross-origin AND tries to bless that @@ -156,7 +161,7 @@ describe('processing config injection (config-by-shape, origin-gated)', () => { const [{ importVolumeDataSources }, { useProcessingJobsStore }] = await Promise.all([ import('@/src/io/import/importDataSources'), - import('@/src/processing'), + import('@/src/processing/store'), ]); const dataSource: DataSource = { @@ -194,11 +199,7 @@ describe('processing config injection (config-by-shape, origin-gated)', () => { }); describe('multiple configs merge at section granularity, last-wins (in-flight decision)', () => { - beforeEach(() => { - vi.resetModules(); - setActivePinia(createPinia()); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); + beforeEach(resetImportContext); afterEach(() => { vi.restoreAllMocks(); @@ -237,11 +238,7 @@ describe('multiple configs merge at section granularity, last-wins (in-flight de }); describe('config with unknown top-level keys applies known sections (forward-compat)', () => { - beforeEach(() => { - vi.resetModules(); - setActivePinia(createPinia()); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); + beforeEach(resetImportContext); afterEach(() => { vi.restoreAllMocks(); diff --git a/src/io/import/dataSource.ts b/src/io/import/dataSource.ts index 5bc28c480..0e664a637 100644 --- a/src/io/import/dataSource.ts +++ b/src/io/import/dataSource.ts @@ -1,5 +1,5 @@ -import { Chunk } from '@/packages/dicom/core'; -import { Fetcher } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import { Fetcher } from '@/packages/dicom/core/streaming/types'; import { Maybe } from '@/src/types'; /** diff --git a/src/io/import/importDataSources.ts b/src/io/import/importDataSources.ts index 5fa6b3f70..8a0f60e7f 100644 --- a/src/io/import/importDataSources.ts +++ b/src/io/import/importDataSources.ts @@ -43,7 +43,7 @@ import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { asyncSelect } from '@/src/utils/asyncSelect'; import { evaluateChain, Skip } from '@/src/utils/evaluateChain'; import { ensureError, partition } from '@/src/utils'; -import { Chunk } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; import { useDatasetStore } from '@/src/store/datasets'; import { PartialImportError, @@ -182,8 +182,8 @@ const publishImportedChunks = ({ volumes }: ImportChunksResult) => { ) ); datasetStore.addDataSources(loadables); - // These name what this batch supplied, not what the merged dataset now holds; - // the store owns that. Callers may read a name off them, never persist one. + // These name every current member. The store can retain a remote source for + // a re-supplied local member, so its merged sources remain the session record. return loadables; }; diff --git a/src/io/import/processors/__tests__/handleDicom.spec.ts b/src/io/import/processors/__tests__/handleDicom.spec.ts index 85c270ba1..0e9b68f3a 100644 --- a/src/io/import/processors/__tests__/handleDicom.spec.ts +++ b/src/io/import/processors/__tests__/handleDicom.spec.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import { Tags, getChunkTag, readDicomDataset } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; +import { getChunkTag } from '@/packages/dicom/core/metadata/datasetAccess'; +import { readDicomDataset } from '@/packages/dicom/core/metadata/dicomDataset'; import type { DataSource } from '@/src/io/import/dataSource'; import { fileToDataSource } from '@/src/io/import/dataSource'; import handleDicom from '@/src/io/import/processors/handleDicom'; diff --git a/src/io/import/processors/handleDicom.ts b/src/io/import/processors/handleDicom.ts index d73e8fbbd..57fab5e64 100644 --- a/src/io/import/processors/handleDicom.ts +++ b/src/io/import/processors/handleDicom.ts @@ -1,4 +1,4 @@ -import { getChunkTag } from '@/packages/dicom/core'; +import { getChunkTag } from '@/packages/dicom/core/metadata/datasetAccess'; import { useDicomRuntimeStore } from '@/src/store/dicom-runtime'; import { Skip } from '@/src/utils/evaluateChain'; import { @@ -9,12 +9,12 @@ import { import { DataSource, getDataSourceName } from '@/src/io/import/dataSource'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { getErrorDetail } from '@/src/utils'; -import { Tags } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { surfaceWarning, useMessageStore } from '@/src/store/messages'; import { IMPLICIT_VR_LITTLE_ENDIAN, type DicomLayout, -} from '@/packages/dicom/core'; +} from '@/packages/dicom/core/metadata/dicomLayout'; import { Maybe } from '@/src/types'; // A folder of files that all depart from Part 10 the same way would raise one diff --git a/src/io/import/processors/importSingleFile.ts b/src/io/import/processors/importSingleFile.ts index 086c7b408..11b29e192 100644 --- a/src/io/import/processors/importSingleFile.ts +++ b/src/io/import/processors/importSingleFile.ts @@ -2,7 +2,7 @@ import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkPolyData from '@kitware/vtk.js/Common/DataModel/PolyData'; import { useImageStore } from '@/src/store/datasets-images'; import { useModelStore } from '@/src/store/datasets-models'; -import { FILE_READERS } from '@/src/io'; +import { FILE_READERS } from '@/src/io/readerRegistry'; import { ImportHandler, asLoadableResult } from '@/src/io/import/common'; import { surfaceWarning, useMessageStore } from '@/src/store/messages'; import { Skip } from '@/src/utils/evaluateChain'; diff --git a/src/io/import/processors/openUriStream.ts b/src/io/import/processors/openUriStream.ts index 66dfe2e28..16fe5644f 100644 --- a/src/io/import/processors/openUriStream.ts +++ b/src/io/import/processors/openUriStream.ts @@ -1,5 +1,5 @@ import { Skip } from '@/src/utils/evaluateChain'; -import { CachedStreamFetcher } from '@/packages/dicom/core'; +import { CachedStreamFetcher } from '@/packages/dicom/core/streaming/cachedStreamFetcher'; import { useDicomRuntimeStore } from '@/src/store/dicom-runtime'; import { ImportHandler, diff --git a/src/io/import/processors/updateFileMimeType.ts b/src/io/import/processors/updateFileMimeType.ts index ee4fff43b..14c0afae4 100644 --- a/src/io/import/processors/updateFileMimeType.ts +++ b/src/io/import/processors/updateFileMimeType.ts @@ -1,5 +1,5 @@ import { Skip } from '@/src/utils/evaluateChain'; -import { getFileMimeType } from '@/src/io'; +import { getFileMimeType } from '@/src/io/io'; import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; import { MIME_TYPES } from '@/src/io/mimeTypes'; diff --git a/src/io/import/processors/updateUriType.ts b/src/io/import/processors/updateUriType.ts index 70445ebbd..c45159608 100644 --- a/src/io/import/processors/updateUriType.ts +++ b/src/io/import/processors/updateUriType.ts @@ -1,5 +1,5 @@ import { Skip } from '@/src/utils/evaluateChain'; -import { StreamingByteReader } from '@/packages/dicom/core'; +import StreamingByteReader from '@/packages/dicom/core/streaming/streamingByteReader'; import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; import { getFileMimeFromMagicStream } from '@/src/io/magic'; import { getMimeTypeFromFilename } from '@/src/io/io'; diff --git a/src/io/magic.ts b/src/io/magic.ts index 226215474..6781ace24 100644 --- a/src/io/magic.ts +++ b/src/io/magic.ts @@ -1,4 +1,4 @@ -import { StreamingByteReader } from '@/packages/dicom/core'; +import StreamingByteReader from '@/packages/dicom/core/streaming/streamingByteReader'; import { Maybe } from '../types'; interface MagicDatabase { diff --git a/src/io/readDicomTags.ts b/src/io/readDicomTags.ts index 1a6f93ffe..7b02a3dbd 100644 --- a/src/io/readDicomTags.ts +++ b/src/io/readDicomTags.ts @@ -1,11 +1,10 @@ -import { binaryString, hex4 } from '@/packages/dicom/core'; -import { VALUE_DELIMITER } from '@/packages/dicom/core'; +import { binaryString, hex4 } from '@/packages/dicom/core/metadata/dcmjsParser'; +import { VALUE_DELIMITER } from '@/packages/dicom/core/metadata/dicomCharacterSet'; import { DicomElement, DicomDataset, isPrivateElement, - readDicomDataset, -} from '@/packages/dicom/core'; +} from '@/packages/dicom/core/metadata/dicomDataset'; const SPECIFIC_CHARACTER_SET = '00080005'; @@ -79,17 +78,6 @@ const reportedByItk = (element: DicomElement) => element.values.kind !== 'sequence' && element.values.kind !== 'unread'; -/** - * Reads a DICOM file's header tags, stopping before Pixel Data, in the shape - * and value formatting `@itk-wasm/dicom`'s `readDicomTags` produced: `gggg|eeee` - * keys sorted by tag, multiple values joined with a backslash, binary VRs as - * plain decimals, and text padding left as the file wrote it. - */ -export function readDicomTags(bytes: Uint8Array): Array<[string, string]> { - const dataset = readDicomDataset(bytes); - return dicomDatasetToTags(dataset); -} - /** Projects structured metadata for VolView consumers of GDCM-shaped strings. */ export function dicomDatasetToTags( dataset: DicomDataset diff --git a/src/io/index.ts b/src/io/readerRegistry.ts similarity index 96% rename from src/io/index.ts rename to src/io/readerRegistry.ts index 14c040a6e..7aeab0bad 100644 --- a/src/io/index.ts +++ b/src/io/readerRegistry.ts @@ -1,7 +1,5 @@ import type { vtkObject } from '@kitware/vtk.js/interfaces'; -export * from './io'; - export type ReaderResult = { dataObject: vtkObject; // File-format header fields (e.g. NRRD key/value pairs) that don't survive diff --git a/src/io/readers.ts b/src/io/readers.ts index cde899f63..fc94f12de 100644 --- a/src/io/readers.ts +++ b/src/io/readers.ts @@ -2,7 +2,7 @@ import { convertItkToVtkImage } from '@/src/io/convertItkToVtkImage'; import { readImage, extensionToImageIo } from '@itk-wasm/image-io'; import type { vtkObject } from '@kitware/vtk.js/interfaces'; import { getWorker } from '@/src/io/itk/worker'; -import { FileReaderMap, ReaderResult } from '.'; +import { FileReaderMap, ReaderResult } from './readerRegistry'; import { stlReader, vtiReader, vtpReader } from './vtk/async'; import { FILE_EXT_TO_MIME } from './mimeTypes'; diff --git a/src/io/state-file/serialize.ts b/src/io/state-file/serialize.ts index 9a835f2cb..27e74e968 100644 --- a/src/io/state-file/serialize.ts +++ b/src/io/state-file/serialize.ts @@ -14,7 +14,7 @@ import { StateFile, } from '@/src/io/state-file/schema'; -import { retypeFile } from '@/src/io'; +import { retypeFile } from '@/src/io/io'; import { ARCHIVE_FILE_TYPES } from '@/src/io/mimeTypes'; import { useViewConfigStore } from '@/src/store/view-configs'; import { useMessageStore, type MessageOptions } from '@/src/store/messages'; diff --git a/src/io/vtk/common.ts b/src/io/vtk/common.ts index 3d95cf785..0f2653e4f 100644 --- a/src/io/vtk/common.ts +++ b/src/io/vtk/common.ts @@ -1,5 +1,5 @@ import { vtkReader, vtkWriter, vtkClass } from '@/src/types/vtk-types'; -import { readFileAsArrayBuffer, readFileAsUTF8Text } from '@/src/io'; +import { readFileAsArrayBuffer, readFileAsUTF8Text } from '@/src/io/io'; import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet'; export async function readFile( diff --git a/src/main.js b/src/main.js index ea872828d..cfe597bce 100644 --- a/src/main.js +++ b/src/main.js @@ -24,7 +24,7 @@ import itkConfig from '@/src/io/itk/itkConfig'; import App from './components/App.vue'; import vuetify from './plugins/vuetify'; -import { FILE_READERS } from './io'; +import { FILE_READERS } from './io/readerRegistry'; import { registerAllReaders } from './io/readers'; import { CorePiniaProviderPlugin } from './core/provider'; import { patchExitPointerLock } from './utils/hacks'; diff --git a/src/processing/__tests__/config.spec.ts b/src/processing/__tests__/config.spec.ts index 2ae85f322..57f415da9 100644 --- a/src/processing/__tests__/config.spec.ts +++ b/src/processing/__tests__/config.spec.ts @@ -5,7 +5,7 @@ import { processingSection, type ProcessingSection, } from '@/src/processing/config'; -import { processingConfigSection } from '@/src/processing'; +import { processingConfigSection } from '@/src/processing/integration'; import { useProcessingJobsStore } from '@/src/processing/store'; function providerSection( diff --git a/src/processing/index.ts b/src/processing/integration.ts similarity index 81% rename from src/processing/index.ts rename to src/processing/integration.ts index b0bd53b50..2cbd33eef 100644 --- a/src/processing/index.ts +++ b/src/processing/integration.ts @@ -1,5 +1,3 @@ -// Sole entry point for the processing feature, enforced by eslint import zones. - import { defineAsyncComponent } from 'vue'; import { registerConfigSection } from '@/src/io/import/configJson'; @@ -11,9 +9,6 @@ import { } from './config'; import { useProcessingJobsStore } from './store'; -export { useProcessingJobsStore } from './store'; -export type { ProcessingProviderConfig } from './types'; - // Lazy so the panel chunk stays out of the boot bundle. export const JobsModule = defineAsyncComponent( () => import('./components/JobsModule.vue') @@ -33,12 +28,12 @@ export const processingConfigSection = { }, }; -// Module-evaluation-time registration: this entry point is statically imported +// Module-evaluation-time registration: this module is statically imported // at boot (App.vue), which runs before any config file can be recognized. registerConfigSection(processingConfigSection); // Job re-discovery after reload: once the launch data + providers are in, -// re-find THIS study's jobs for the Jobs panel — still-running jobs join the +// re-find THIS study's jobs for the Jobs panel. Still-running jobs join the // normal poller (finishing while open fires the ordinary in-session live // path), terminal ones are observability rows only. Inert with no configured // provider (the demo posture registers none) and never fatal to the load. diff --git a/src/referenceLines/index.ts b/src/referenceLines/index.ts deleted file mode 100644 index e854e7b0a..000000000 --- a/src/referenceLines/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Sole entry point for the reference lines feature, enforced by eslint import -// zones. - -export { default as ReferenceLines } from './ReferenceLines.vue'; -export { default as ReferenceLineSegments } from './ReferenceLineSegments.vue'; -export { useReferenceLinesStore } from './store'; -export { slicePlane, computeReferenceLine, type SlicePlane } from './geometry'; diff --git a/src/segmentation/__tests__/artifactParentRestore.spec.ts b/src/segmentation/__tests__/artifactParentRestore.spec.ts index 2537db52c..a6aab6fe8 100644 --- a/src/segmentation/__tests__/artifactParentRestore.spec.ts +++ b/src/segmentation/__tests__/artifactParentRestore.spec.ts @@ -6,10 +6,10 @@ import { flushPromises } from '@vue/test-utils'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import { Chunk } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; import { LoadedVtkImage } from '@/src/core/progressiveImage'; import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; -import { Tags } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { useImageCacheStore } from '@/src/store/image-cache'; import { type LabelmapIO } from '@/src/segmentation/store'; import { useToolStore } from '@/src/store/tools'; diff --git a/src/store/__tests__/datasets-dicom-acquisition-split.spec.ts b/src/store/__tests__/datasets-dicom-acquisition-split.spec.ts index 940f95a3a..b70f74396 100644 --- a/src/store/__tests__/datasets-dicom-acquisition-split.spec.ts +++ b/src/store/__tests__/datasets-dicom-acquisition-split.spec.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import type { Chunk } from '@/packages/dicom/core'; -import { Tags } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { uriToDataSource, type DataSource } from '@/src/io/import/dataSource'; import type { ChunkSource } from '@/src/io/import/dataSource'; diff --git a/src/store/__tests__/datasets-dicom-cine.spec.ts b/src/store/__tests__/datasets-dicom-cine.spec.ts index c5e0e6533..92b0c4ebe 100644 --- a/src/store/__tests__/datasets-dicom-cine.spec.ts +++ b/src/store/__tests__/datasets-dicom-cine.spec.ts @@ -3,13 +3,13 @@ import { datasetFromTags } from '@/tests/dicomDataset'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import type { Chunk } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; import type DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; import { SOP_CLASS_ULTRASOUND_MULTIFRAME, SOP_CLASS_ULTRASOUND_MULTIFRAME_RETIRED, Tags, -} from '@/packages/dicom/core'; +} from '@/packages/dicom/core/metadata/dicomTags'; import type { CineHeader, CineParseResult, @@ -42,6 +42,17 @@ class FakeChunkImage { this.orientation = orientation; } + async prepareChunks(chunks: Chunk[], orientation: number[] | null) { + return { + changed: true, + commit: () => { + this.chunks = chunks; + this.orientation = orientation; + }, + dispose: () => {}, + }; + } + getDicomMetadata() { return dicomDatasetToTags(this.chunks[0].metadata!); } diff --git a/src/store/__tests__/datasets-dicom-geometry.spec.ts b/src/store/__tests__/datasets-dicom-geometry.spec.ts index cf4efb6bc..8218c3c5d 100644 --- a/src/store/__tests__/datasets-dicom-geometry.spec.ts +++ b/src/store/__tests__/datasets-dicom-geometry.spec.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import { Chunk } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; -import { Tags } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useMessageStore } from '@/src/store/messages'; import { chunkFor } from './dicomImportFixtures'; diff --git a/src/store/__tests__/datasets-dicom-lifetime.spec.ts b/src/store/__tests__/datasets-dicom-lifetime.spec.ts index 24784f647..efb47012d 100644 --- a/src/store/__tests__/datasets-dicom-lifetime.spec.ts +++ b/src/store/__tests__/datasets-dicom-lifetime.spec.ts @@ -1,14 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import { nextTick } from 'vue'; +import { blobFetcher } from '@/packages/dicom/core/streaming/blobFetcher'; import { - blobFetcher, createDicomLoader, - getChunkTag, - Tags, type DicomLoader, - type Fetcher, -} from '@/packages/dicom/core'; +} from '@/packages/dicom/core/runtime/createDicomLoader'; +import { getChunkTag } from '@/packages/dicom/core/metadata/datasetAccess'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; +import { type Fetcher } from '@/packages/dicom/core/streaming/types'; import { buildSyntheticCineDicom, buildSyntheticDicom, @@ -297,6 +297,7 @@ describe('DICOM source ownership in the application store', () => { const cineStarted = deferred(); const cineGate = deferred(); const volumePrepared = deferred(); + let growingImage: DicomChunkImage | undefined; const source = blobFetcher(cineFile); const cine = loader.createChunk({ ...source, @@ -319,9 +320,15 @@ describe('DICOM source ownership in the application store', () => { }, }) as never, }); - image.addEventListener('status', () => { - if (image.getChunks().length === 3) volumePrepared.resolve(); - }); + const prepareChunks = image.prepareChunks.bind(image); + image.prepareChunks = async (...args) => { + const prepared = await prepareChunks(...args); + if (args[0].length === 3) { + growingImage = image; + volumePrepared.resolve(); + } + return prepared; + }; return image; }; const store = useDICOMStore(); @@ -337,8 +344,7 @@ describe('DICOM source ownership in the application store', () => { const rolledBack = expect(pending).rejects.toThrow(/removed/); await Promise.all([cineStarted.promise, volumePrepared.promise]); const survivorId = Object.keys(initial.volumes).find( - (id) => - (cache.imageById[id] as DicomChunkImage).getChunks().length === 3 + (id) => cache.imageById[id] === growingImage )!; const dissolvingId = Object.keys(initial.volumes).find( (id) => id !== survivorId diff --git a/src/store/__tests__/datasets-dicom-reimport.spec.ts b/src/store/__tests__/datasets-dicom-reimport.spec.ts index a285854bf..39efe8e30 100644 --- a/src/store/__tests__/datasets-dicom-reimport.spec.ts +++ b/src/store/__tests__/datasets-dicom-reimport.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import type { Chunk } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; import type DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; import { useImageCacheStore } from '@/src/store/image-cache'; import { diff --git a/src/store/__tests__/datasets-dicom-staging.spec.ts b/src/store/__tests__/datasets-dicom-staging.spec.ts new file mode 100644 index 000000000..b8dc49537 --- /dev/null +++ b/src/store/__tests__/datasets-dicom-staging.spec.ts @@ -0,0 +1,96 @@ +import { beforeEach, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { createDicomLoader } from '@/packages/dicom/core/runtime/createDicomLoader'; +import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; +import { useDICOMStore } from '@/src/store/datasets-dicom'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { syntheticSlice } from '@/tests/dicomSourceFixtures'; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; + +beforeEach(() => setActivePinia(createPinia())); + +it('keeps the displayed volume intact while growth prepares and a sibling fails', async () => { + const loader = createDicomLoader(); + const chunks = [0, 1, 2].map((index) => + loader.createChunk( + new Blob([ + syntheticSlice({ + sopUid: `1.2.3.${index}`, + imagePositionPatient: [0, 0, index], + rows: index === 2 ? 8 : 4, + }) as BlobPart, + ]) + ) + ); + await Promise.all( + chunks.map(async (chunk) => { + await chunk.loadMeta(); + await chunk.loadData(); + }) + ); + const sibling = deferred(); + const prepared = deferred(); + const rendered = deferred(); + const images: DicomChunkImage[] = []; + const createChunkImage = () => { + const image = new DicomChunkImage({ + readDicomImage: async () => ({ + image: { + size: [4, 4, 1], + data: new Uint16Array(16).fill(42), + imageType: { components: 1 }, + }, + }), + }); + if (images.length === 1) image.setChunks = () => sibling.promise; + images.push(image); + image.addEventListener('status', (status) => { + if (status === 'complete') rendered.resolve(); + }); + return image; + }; + const store = useDICOMStore(); + const { volumes } = await store.importChunks([chunks[0]], { + createChunkImage, + }); + await rendered.promise; + const [id] = Object.keys(volumes); + const visible = useImageCacheStore().imageById[id]; + const original = visible.getVtkImageData(); + const replacements: unknown[] = []; + images[0].addEventListener('imageChanged', (image) => + replacements.push(image) + ); + const prepareChunks = images[0].prepareChunks.bind(images[0]); + images[0].prepareChunks = async (...args) => { + const update = await prepareChunks(...args); + prepared.resolve(); + return update; + }; + + const outcome = store + .importChunks(chunks.slice(1), { createChunkImage }) + .catch((error) => error); + await prepared.promise; + expect(visible.getVtkImageData()).toBe(original); + expect(original.getDimensions()).toEqual([4, 4, 1]); + expect(store.volumeInfo[id].NumberOfSlices).toBe(1); + sibling.reject(new Error('cannot allocate sibling')); + expect((await outcome).message).toContain('cannot allocate sibling'); + expect(replacements).toEqual([]); + expect(original.isDeleted()).toBe(false); + expect(Array.from(original.getPointData().getScalars().getData())).toEqual( + Array(16).fill(42) + ); + images.forEach((image) => image.dispose()); + loader.dispose(); +}); diff --git a/src/store/__tests__/datasets-dicom-transaction.spec.ts b/src/store/__tests__/datasets-dicom-transaction.spec.ts index a276abe39..c34fa84ca 100644 --- a/src/store/__tests__/datasets-dicom-transaction.spec.ts +++ b/src/store/__tests__/datasets-dicom-transaction.spec.ts @@ -1,8 +1,8 @@ -import { Tags } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import type { Chunk } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import type { ChunkSource, DataSource } from '@/src/io/import/dataSource'; import { uriToDataSource } from '@/src/io/import/dataSource'; @@ -242,7 +242,7 @@ describe('DICOM store transactional commit', () => { const { store, id, first, created } = await growAndFail(); const [loaded, candidate] = created; - // Growth is undone, so a failed sibling leaves the volume as it was. + // A failed sibling leaves the existing volume untouched. expect(loaded.getChunks()).toEqual([first]); expect(loaded.startLoadCount).toBe(1); expect(loaded.disposeCount).toBe(0); @@ -441,7 +441,7 @@ describe('DICOM store transactional commit', () => { expect(store.volumeInfo[id]).toBeUndefined(); expect(store.studyVolumes[STUDY_UID]).toBeUndefined(); // The removed image is not asked to hold anything again. - expect(created[0].setChunksCalls).toHaveLength(2); + expect(created[0].setChunksCalls).toHaveLength(1); // The lane and registry stay usable: the series rebuilds from what a new // import brings, including the slice the abandoned batch carried. diff --git a/src/store/__tests__/dicomImportFixtures.ts b/src/store/__tests__/dicomImportFixtures.ts index eaa898dc5..076e9dc0e 100644 --- a/src/store/__tests__/dicomImportFixtures.ts +++ b/src/store/__tests__/dicomImportFixtures.ts @@ -1,11 +1,15 @@ import { datasetFromTags } from '@/tests/dicomDataset'; import { dicomDatasetToTags } from '@/src/io/readDicomTags'; -import { getChunkTag, type Reconstruction } from '@/packages/dicom/core'; +import { getChunkTag } from '@/packages/dicom/core/metadata/datasetAccess'; +import { type Reconstruction } from '@/packages/dicom/core/collections/reconstructVolume'; import { ref } from 'vue'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import { SOP_CLASS_ULTRASOUND_MULTIFRAME, Tags } from '@/packages/dicom/core'; +import { + SOP_CLASS_ULTRASOUND_MULTIFRAME, + Tags, +} from '@/packages/dicom/core/metadata/dicomTags'; import { defaultImageMetadata } from '@/src/core/progressiveImage'; -import type { Chunk } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; import type DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; export const SERIES_UID = '1.2.826.0.1.3680043.9.7'; @@ -107,11 +111,36 @@ export class FakeChunkImage { chunks: Chunk[], orientation: number[] | null, reconstruction: Reconstruction | null = null + ) { + const prepared = await this.prepareChunks( + chunks, + orientation, + reconstruction + ); + prepared.commit(); + } + + async prepareChunks( + chunks: Chunk[], + orientation: number[] | null, + reconstruction: Reconstruction | null = null ) { await this.hooks.onPrepare?.(this.index, chunks); - this.setChunksCalls.push(chunks); - this.orientation = orientation; - this.reconstruction = reconstruction; + const previous = this.setChunksCalls.at(-1) ?? []; + const changed = + previous.length !== chunks.length || + previous.some((chunk, index) => chunk !== chunks[index]) || + this.orientation?.join() !== orientation?.join() || + JSON.stringify(this.reconstruction) !== JSON.stringify(reconstruction); + return { + changed, + commit: () => { + this.setChunksCalls.push(chunks); + this.orientation = orientation; + this.reconstruction = reconstruction; + }, + dispose: () => {}, + }; } getImageMetadata() { diff --git a/src/store/__tests__/dicomTagFixtures.ts b/src/store/__tests__/dicomTagFixtures.ts index d6262c19c..47c31130f 100644 --- a/src/store/__tests__/dicomTagFixtures.ts +++ b/src/store/__tests__/dicomTagFixtures.ts @@ -1,4 +1,4 @@ -import { Tags } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; /** * The tags one synthetic instance carries, in the order the DICOM store reads diff --git a/src/store/__tests__/duplicateDatasetSave.spec.ts b/src/store/__tests__/duplicateDatasetSave.spec.ts index 67a962a73..2695907bd 100644 --- a/src/store/__tests__/duplicateDatasetSave.spec.ts +++ b/src/store/__tests__/duplicateDatasetSave.spec.ts @@ -13,8 +13,8 @@ import { isRemoteDataSource, type DataSource, } from '@/src/io/import/dataSource'; -import type { Chunk } from '@/packages/dicom/core'; -import { Tags } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; // --------------------------------------------------------------------------- diff --git a/src/store/datasets-dicom.ts b/src/store/datasets-dicom.ts index 78f95017c..f1eae20c3 100644 --- a/src/store/datasets-dicom.ts +++ b/src/store/datasets-dicom.ts @@ -1,8 +1,7 @@ import { dicomDatasetToTags } from '@/src/io/readDicomTags'; -import type { Reconstruction } from '@/packages/dicom/core'; import { defineStore } from 'pinia'; import { Image } from 'itk-wasm'; -import type { Chunk } from '@/packages/dicom/core'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; import { useImageCacheStore } from '@/src/store/image-cache'; import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; import DicomCineImage from '@/src/core/cine/DicomCineImage'; @@ -10,9 +9,12 @@ import { parseCineDicom } from '@/src/core/cine/parseCineDicom'; import { groupChunksBySeries, type CollectionUpdate, -} from '@/packages/dicom/core'; +} from '@/packages/dicom/core/collections/collectionRegistry'; import type { ProgressiveImage } from '@/src/core/progressiveImage'; -import { isUltrasoundMultiframeSopClass, Tags } from '@/packages/dicom/core'; +import { + isUltrasoundMultiframeSopClass, + Tags, +} from '@/packages/dicom/core/metadata/dicomTags'; import { useMessageStore } from '@/src/store/messages'; import { ensureError, removeFromArray } from '../utils'; import { @@ -153,15 +155,12 @@ export const getWindowLevels = (info: VolumeInfo) => { // A chunk volume, as this store uses it. An instanceof would refuse an // injected image, so the capability check names the method it calls. -type ChunkVolume = Pick< - DicomChunkImage, - 'setChunks' | 'getChunks' | 'getOrientation' | 'getReconstruction' ->; +type ChunkVolume = Pick; const canHoldChunks = ( image: ProgressiveImage ): image is ProgressiveImage & ChunkVolume => - typeof (image as Partial).setChunks === 'function'; + typeof (image as Partial).prepareChunks === 'function'; export type ImportChunksDeps = { createChunkImage?: () => DicomChunkImage; @@ -219,7 +218,7 @@ type DatabaseRecord = { /** * One collection's replacement, prepared before any store is touched. `add` - * builds a fresh image off-store, `grow` has already applied its members to + * builds a fresh image off-store, `grow` holds an unpublished allocation for * the image an earlier import registered, and `none` is a collection already * loaded as it is planned. */ @@ -235,54 +234,22 @@ type Candidate = kind: 'grow'; id: string; image: ProgressiveImage & ChunkVolume; - // The membership held before this batch grew the image, so a sibling's - // failure can put it back. - previous: Chunk[]; - previousOrientation: number[] | null; - previousReconstruction: Reconstruction | null; - // Whether the batch changed the membership at all; a relabel or a - // second drop of the same folder does not. - changed: boolean; + update: Awaited>; record: DatabaseRecord; }; -// Every image a batch builds, so a failure releases them all. An uncommitted -// candidate owns a volume buffer nothing else can free. +// Every unpublished image or allocation belongs to the preparing batch. const candidateTracker = () => { - const built: ProgressiveImage[] = []; + const built: Array<{ dispose: () => void }> = []; return { - track: (image: T) => { - built.push(image); - return image; + track: void }>(resource: T) => { + built.push(resource); + return resource; }, release: () => built.forEach((image) => image.dispose()), }; }; -/** - * Puts the images a failed batch already grew back to the membership they held, - * so an abandoned plan leaves no image holding members no record names. An - * image the user removed meanwhile is disposed and left alone. - */ -const undoGrowth = (candidates: Candidate[]) => { - const { imageById } = useImageCacheStore(); - return Promise.all( - candidates.map((candidate) => - candidate.kind === 'grow' && imageById[candidate.id] === candidate.image - ? candidate.image - .setChunks( - candidate.previous, - candidate.previousOrientation, - candidate.previousReconstruction ?? undefined - ) - .catch((err) => { - console.error('Failed to restore DICOM volume membership', err); - }) - : null - ) - ); -}; - type PrepareDeps = { createChunkImage: () => DicomChunkImage; parseCine: typeof parseCineDicom; @@ -419,26 +386,15 @@ async function prepareVolume( } const record = volumeRecord(update); - // Growth is part of preparation, not of the commit: the members have to be - // in the image before any record promises them, and a membership the image - // cannot hold has to fail while the batch can still be abandoned. if (cachedImage) { - const previous = cachedImage.getChunks(); - const previousOrientation = cachedImage.getOrientation(); - const previousReconstruction = cachedImage.getReconstruction(); - await cachedImage.setChunks(members, orientation, reconstruction); - const changed = - previousOrientation?.join() !== orientation?.join() || - previous.length !== members.length || - previous.some((chunk, index) => chunk !== members[index]); + const prepared = deps.track( + await cachedImage.prepareChunks(members, orientation, reconstruction) + ); return { kind: 'grow', id, image: cachedImage, - previous, - previousOrientation, - previousReconstruction, - changed, + update: prepared, record, }; } @@ -490,8 +446,7 @@ function commitPlan( if (candidate.kind === 'none') return; if (candidate.kind === 'grow') { - // The image already holds the members preparation gave it, so this load - // covers them. + candidate.update.commit(); candidate.image.startLoad(); } else { imageCacheStore.addProgressiveImage(candidate.image, { @@ -503,7 +458,7 @@ function commitPlan( store._updateDatabase(patient, study, volume); candidate.image.setName(name); // A membership that did not change was already warned about. - if (candidate.kind === 'add' || candidate.changed) + if (candidate.kind === 'add' || candidate.update.changed) warnings.forEach((warning) => messageStore.addWarning( 'A DICOM series did not load as one sound volume', @@ -590,18 +545,12 @@ export const useDICOMStore = defineStore('dicom', { ...removed, ]); if (failure || removedMeanwhile) { - // Every candidate has settled, so nothing is still writing to the - // images this puts back before the lane releases. try { - await undoGrowth(candidates); + release(); } finally { - try { - release(); - } finally { - // Failed incoming chunks remain available to their caller; - // only replayed user deletions release previous ownership. - this.releaseUnregisteredChunks(rollback([seriesKey])); - } + // Failed incoming chunks remain available to their caller; + // only replayed user deletions release previous ownership. + this.releaseUnregisteredChunks(rollback([seriesKey])); } throw failure ? failure.reason diff --git a/src/store/datasets.ts b/src/store/datasets.ts index fb6d5a317..ecdec35cc 100644 --- a/src/store/datasets.ts +++ b/src/store/datasets.ts @@ -9,9 +9,9 @@ import { useLayersStore } from '@/src/store/datasets-layers'; import { useModelStore } from '@/src/store/datasets-models'; import { useViewConfigStore } from '@/src/store/view-configs'; import { useImageStatsStore } from '@/src/store/image-stats'; -import { Tags } from '@/packages/dicom/core'; -import type { Chunk } from '@/packages/dicom/core'; -import { getChunkTag } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import { getChunkTag } from '@/packages/dicom/core/metadata/datasetAccess'; export const DataType = { Image: 'Image', diff --git a/src/store/dicom-import-session.ts b/src/store/dicom-import-session.ts index 9f248b225..fd389c8e7 100644 --- a/src/store/dicom-import-session.ts +++ b/src/store/dicom-import-session.ts @@ -1,8 +1,8 @@ +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; import { - Chunk, createDicomCollectionRegistry, type DicomCollectionRegistry, -} from '@/packages/dicom/core'; +} from '@/packages/dicom/core/collections/collectionRegistry'; // One import session per store instance, so a fresh pinia replans from nothing // and the chunks the registry holds stay outside reactive state. diff --git a/src/store/dicom-runtime.ts b/src/store/dicom-runtime.ts index cde252350..51913eaff 100644 --- a/src/store/dicom-runtime.ts +++ b/src/store/dicom-runtime.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { markRaw, onScopeDispose } from 'vue'; -import { createDicomLoader } from '@/packages/dicom/core'; +import { createDicomLoader } from '@/packages/dicom/core/runtime/createDicomLoader'; import { $fetch } from '@/src/utils/fetch'; import itkConfig from '@/src/io/itk/itkConfig'; diff --git a/src/types/image.ts b/src/types/image.ts index 9dd1b4263..86d92c12f 100644 --- a/src/types/image.ts +++ b/src/types/image.ts @@ -1,7 +1,7 @@ import { mat3, mat4, vec3 } from 'gl-matrix'; import type { Bounds } from '@kitware/vtk.js/types'; import { LPSDirections } from './lps'; -import type { SpatialMetadata } from '@/packages/dicom/core'; +import type { SpatialMetadata } from '@/packages/dicom/core/metadata/spatialMetadata'; export type ImageMetadata = { name: string; diff --git a/src/utils/__tests__/dicomWindowRange.spec.ts b/src/utils/__tests__/dicomWindowRange.spec.ts index 9afed511e..c72bc1210 100644 --- a/src/utils/__tests__/dicomWindowRange.spec.ts +++ b/src/utils/__tests__/dicomWindowRange.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Tags } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { getConsistentDicomWindowRange } from '@/src/utils/dicomWindowRange'; function windowTags(center: string, width: string, voiFunction?: string) { diff --git a/src/utils/dicom/__tests__/allocateImageFromChunks.spec.ts b/src/utils/dicom/__tests__/allocateImageFromChunks.spec.ts index 626e3dcf3..eb896f98d 100644 --- a/src/utils/dicom/__tests__/allocateImageFromChunks.spec.ts +++ b/src/utils/dicom/__tests__/allocateImageFromChunks.spec.ts @@ -1,11 +1,11 @@ import { datasetFromTags } from '@/tests/dicomDataset'; import { readGeometryFacts, - reconstructVolume, sliceNormalOf, -} from '@/packages/dicom/core'; -import type { Chunk } from '@/packages/dicom/core'; -import { Tags } from '@/packages/dicom/core'; +} from '@/packages/dicom/core/collections/instanceFacts'; +import { reconstructVolume } from '@/packages/dicom/core/collections/reconstructVolume'; +import type { Chunk } from '@/packages/dicom/core/streaming/chunk'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { allocateImageFromChunks as allocatePlannedImage, getPixelFormat, @@ -16,7 +16,7 @@ import { getVolumeBufferType, samplesAreIntegral, valuesFitBuffer, -} from '@/packages/dicom/vtk'; +} from '@/packages/dicom/vtk/allocateImageFromChunks'; import { describe, it, expect } from 'vitest'; function chunk(overrides: Record = {}) { diff --git a/src/utils/dicom/dicomChunks.ts b/src/utils/dicom/dicomChunks.ts index a7b13ad04..207f28f2f 100644 --- a/src/utils/dicom/dicomChunks.ts +++ b/src/utils/dicom/dicomChunks.ts @@ -1,4 +1,4 @@ -import { Chunk } from '@/packages/dicom/core'; +import { Chunk } from '@/packages/dicom/core/streaming/chunk'; import { dicomDatasetToTags } from '@/src/io/readDicomTags'; diff --git a/src/utils/dicomWindowRange.ts b/src/utils/dicomWindowRange.ts index b16469404..d90af3073 100644 --- a/src/utils/dicomWindowRange.ts +++ b/src/utils/dicomWindowRange.ts @@ -1,4 +1,4 @@ -import { Tags } from '@/packages/dicom/core'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; type DicomMetadata = Iterable | null | undefined; diff --git a/tests/dicomDataset.ts b/tests/dicomDataset.ts index aaec3b5e1..39e283ab2 100644 --- a/tests/dicomDataset.ts +++ b/tests/dicomDataset.ts @@ -1,4 +1,4 @@ -import type { DicomDataset } from '@/packages/dicom/core'; +import type { DicomDataset } from '@/packages/dicom/core/metadata/dicomDataset'; /** Structured headers for fixtures whose values are written as tag strings. */ export const datasetFromTags = ( diff --git a/tests/dicomSourceFixtures.ts b/tests/dicomSourceFixtures.ts index ff426bdda..918ebd3f1 100644 --- a/tests/dicomSourceFixtures.ts +++ b/tests/dicomSourceFixtures.ts @@ -1,5 +1,5 @@ -import { StopSignal } from '@/packages/dicom/core'; -import type { Fetcher } from '@/packages/dicom/core'; +import { StopSignal } from '@/packages/dicom/core/streaming/cachedStreamFetcher'; +import type { Fetcher } from '@/packages/dicom/core/streaming/types'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { buildSyntheticDicom, @@ -41,7 +41,7 @@ export const dicomFile = (bytes: Uint8Array, name = 'slice.dcm') => * them, so what a loader left undelivered says where it stopped. */ export const bytesFetcher = (bytes: Uint8Array, chunkSize = bytes.length) => { - const cachedChunks: Uint8Array[] = []; + let delivered = 0; let abortController: AbortController | null = null; const fetcher: Fetcher = { @@ -58,7 +58,7 @@ export const bytesFetcher = (bytes: Uint8Array, chunkSize = bytes.length) => { } const chunk = bytes.slice(offset, offset + chunkSize); offset += chunk.length; - cachedChunks.push(chunk); + delivered += chunk.length; controller.enqueue(chunk); }, }); @@ -68,12 +68,11 @@ export const bytesFetcher = (bytes: Uint8Array, chunkSize = bytes.length) => { close: () => { abortController?.abort(StopSignal); }, - cachedChunks, get connected() { return !!abortController; }, get size() { - return cachedChunks.reduce((total, chunk) => total + chunk.length, 0); + return delivered; }, contentType: FILE_EXT_TO_MIME.dcm, get abortSignal() { @@ -83,12 +82,6 @@ export const bytesFetcher = (bytes: Uint8Array, chunkSize = bytes.length) => { return fetcher; }; -export const deliveredBytes = (fetcher: Fetcher) => - (fetcher.cachedChunks ?? []).reduce( - (total, chunk) => total + chunk.length, - 0 - ); - export type FetcherCounts = { connect: number; getStream: number; @@ -121,9 +114,6 @@ export const countingFetcher = (inner: Fetcher) => { counts.close += 1; inner.close(); }, - get cachedChunks() { - return inner.cachedChunks; - }, get connected() { return inner.connected; }, diff --git a/tests/fixtures/dicom-consumer/composition.ts b/tests/fixtures/dicom-consumer/composition.ts index 475908e22..41de87aac 100644 --- a/tests/fixtures/dicom-consumer/composition.ts +++ b/tests/fixtures/dicom-consumer/composition.ts @@ -1,9 +1,7 @@ -import { - createDicomCollectionRegistry, - getChunkTag, - type Chunk, -} from '@volview/dicom/core'; -import type { VtkDicomImage } from '@volview/dicom/vtk'; +import { createDicomCollectionRegistry } from '@volview/dicom/core/collections/collectionRegistry'; +import { getChunkTag } from '@volview/dicom/core/metadata/datasetAccess'; +import { type Chunk } from '@volview/dicom/core/streaming/chunk'; +import type { VtkDicomImage } from '@volview/dicom/vtk/VtkDicomImage'; import corpus from '../../dicomCompositionCorpus.json'; import { loader, failed, report } from './shared'; import { loadCollection } from './loadVolume'; diff --git a/tests/fixtures/dicom-consumer/core.ts b/tests/fixtures/dicom-consumer/core.ts index e8d04ae41..c630e1be8 100644 --- a/tests/fixtures/dicom-consumer/core.ts +++ b/tests/fixtures/dicom-consumer/core.ts @@ -1,4 +1,4 @@ -import { createDicomLoader } from '@volview/dicom/core'; +import { createDicomLoader } from '@volview/dicom/core/runtime/createDicomLoader'; import { describeMetadata, failed, loadChunk, loader, report } from './shared'; import { fixtureBytes } from './fixtureData'; diff --git a/tests/fixtures/dicom-consumer/corpus.ts b/tests/fixtures/dicom-consumer/corpus.ts index 66d371e0e..da11ee7bf 100644 --- a/tests/fixtures/dicom-consumer/corpus.ts +++ b/tests/fixtures/dicom-consumer/corpus.ts @@ -1,7 +1,7 @@ import { failed, loader, report } from './shared'; import { loadVolume } from './loadVolume'; import { describeVolume } from './describeVolume'; -import { VtkDicomImage } from '@volview/dicom/vtk'; +import { VtkDicomImage } from '@volview/dicom/vtk/VtkDicomImage'; async function run() { const params = new URL(location.href).searchParams; diff --git a/tests/fixtures/dicom-consumer/describeVolume.ts b/tests/fixtures/dicom-consumer/describeVolume.ts index 634e1e380..3db6c23a0 100644 --- a/tests/fixtures/dicom-consumer/describeVolume.ts +++ b/tests/fixtures/dicom-consumer/describeVolume.ts @@ -1,4 +1,4 @@ -import type { VtkDicomImage } from '@volview/dicom/vtk'; +import type { VtkDicomImage } from '@volview/dicom/vtk/VtkDicomImage'; /** Public consumer output used to check geometry and every calibrated voxel. */ export async function describeVolume( diff --git a/tests/fixtures/dicom-consumer/loadVolume.ts b/tests/fixtures/dicom-consumer/loadVolume.ts index 14247cbea..ea9259f2a 100644 --- a/tests/fixtures/dicom-consumer/loadVolume.ts +++ b/tests/fixtures/dicom-consumer/loadVolume.ts @@ -1,9 +1,9 @@ import { createDicomCollectionRegistry, - type Chunk, type CollectionUpdate, -} from '@volview/dicom/core'; -import { VtkDicomImage } from '@volview/dicom/vtk'; +} from '@volview/dicom/core/collections/collectionRegistry'; +import { type Chunk } from '@volview/dicom/core/streaming/chunk'; +import { VtkDicomImage } from '@volview/dicom/vtk/VtkDicomImage'; import { loader } from './shared'; export async function loadVolume(chunk: Chunk, image?: VtkDicomImage) { diff --git a/tests/fixtures/dicom-consumer/shared.ts b/tests/fixtures/dicom-consumer/shared.ts index 45b3c3c64..733944c4b 100644 --- a/tests/fixtures/dicom-consumer/shared.ts +++ b/tests/fixtures/dicom-consumer/shared.ts @@ -1,9 +1,7 @@ -import { - createDicomLoader, - getDatasetTag, - readInstanceFacts, - type Chunk, -} from '@volview/dicom/core'; +import { createDicomLoader } from '@volview/dicom/core/runtime/createDicomLoader'; +import { getDatasetTag } from '@volview/dicom/core/metadata/datasetAccess'; +import { readInstanceFacts } from '@volview/dicom/core/collections/instanceFacts'; +import { type Chunk } from '@volview/dicom/core/streaming/chunk'; import { fixtureBytes } from './fixtureData'; export const loader = createDicomLoader({ diff --git a/tests/pageobjects/volview.page.ts b/tests/pageobjects/volview.page.ts index db2775179..d26a6e1b7 100644 --- a/tests/pageobjects/volview.page.ts +++ b/tests/pageobjects/volview.page.ts @@ -82,7 +82,7 @@ class VolViewPage extends Page { .sort((a, b) => a - b); } - async waitForVolumeCardSliceCounts(expected: number[], timeout = 30000) { + async waitForVolumeCardSliceCounts(expected: number[]) { await browser.waitUntil( async () => { const counts = await this.getVolumeCardSliceCounts(); @@ -92,7 +92,6 @@ class VolViewPage extends Page { ); }, { - timeout, timeoutMsg: `expected volume cards holding ${expected.join( ', ' )} slices`, diff --git a/tests/specs/dicom-dimension-mismatch.e2e.ts b/tests/specs/dicom-dimension-mismatch.e2e.ts index 72224dbaf..0c6b40ca8 100644 --- a/tests/specs/dicom-dimension-mismatch.e2e.ts +++ b/tests/specs/dicom-dimension-mismatch.e2e.ts @@ -53,7 +53,6 @@ async function waitForVolumeCards(count: number) { await browser.waitUntil( async () => (await $$('.volume-card').length) === count, { - timeout: 30000, timeoutMsg: `expected exactly ${count} volume cards`, } ); @@ -99,7 +98,6 @@ describe('DICOM series with instances of two pixel geometries', () => { ); }, { - timeout: 30000, timeoutMsg: 'expected both volume cards to render a data-URL thumbnail instead of the modality fallback', } diff --git a/tests/specs/sample-rendering.e2e.ts b/tests/specs/sample-rendering.e2e.ts index 1020854e7..accc6e8ab 100644 --- a/tests/specs/sample-rendering.e2e.ts +++ b/tests/specs/sample-rendering.e2e.ts @@ -1,7 +1,6 @@ import AppPage from '../pageobjects/volview.page'; import { PROSTATEX_DATASET } from '../datasets'; import { useCachedSample } from './utils'; -import { DOWNLOAD_TIMEOUT } from '../../wdio.shared.conf'; const THRESHOLD = 12; // percent - handle pixel jitter in 3D view const RENDER_STABLE_TIMEOUT = 5000; @@ -29,7 +28,6 @@ describe('VolView', () => { ); }), { - timeout: DOWNLOAD_TIMEOUT, timeoutMsg: 'Expected all sample views to finish loading', } ); diff --git a/tsconfig.json b/tsconfig.json index c2bfc39df..5a2d8c7b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,23 +24,11 @@ "@wdio/visual-service" ], "paths": { - "@/*": [ - "./*" - ], - "@volview/dicom/core": [ - "./packages/dicom/core/index.ts" - ], - "@volview/dicom/vtk": [ - "./packages/dicom/vtk/index.ts" - ] + "@/*": ["./*"], + "@volview/dicom/core/*": ["./packages/dicom/core/*"], + "@volview/dicom/vtk/*": ["./packages/dicom/vtk/*"] }, - "lib": [ - "esnext", - "dom", - "dom.iterable", - "scripthost", - "WebWorker" - ] + "lib": ["esnext", "dom", "dom.iterable", "scripthost", "WebWorker"] }, "include": [ "src/**/*.ts", @@ -52,10 +40,7 @@ "tests/**/*.tsx", "packages/dicom/**/*.ts" ], - "exclude": [ - "node_modules", - "packages/dicom/dist" - ], + "exclude": ["node_modules", "packages/dicom/dist"], "ts-node": { "esm": true, "experimentalSpecifierResolution": "node" From 26bedc5900e052bfb6d9221ddfb2252d6caaf23b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 5 Nov 2026 10:29:01 -0500 Subject: [PATCH 3/4] fix(dicom): preserve OCT behavior across loader refactor --- eslint.config.js | 7 +- .../__tests__/spatialMetadata.spec.ts | 52 +++++++ .../dicom/core/metadata/spatialMetadata.ts | 18 ++- .../ControlsStripTools.enFace.spec.ts | 3 +- .../dicom/__tests__/octMetadata.spec.ts | 10 +- .../__tests__/octStructuredMetadata.spec.ts | 125 ++++++++++++++++ src/core/streaming/dicom/octDataset.ts | 31 ++++ src/core/streaming/dicom/octMetadata.ts | 51 +++++-- src/core/streaming/dicom/octMetadataTags.ts | 2 +- src/core/streaming/dicom/seriesGeometry.ts | 18 ++- src/enface/EnFaceViewer.vue | 4 +- src/enface/__tests__/availability.spec.ts | 24 +-- src/enface/__tests__/calibration.spec.ts | 35 +++-- src/enface/__tests__/dicomMetadataFixture.ts | 45 ++++++ src/enface/__tests__/inputCalibration.spec.ts | 9 +- .../__tests__/occupiedDepthSupport.spec.ts | 22 +-- src/enface/__tests__/seriesGeometry.spec.ts | 140 ++++++++++-------- src/enface/__tests__/sliceGeometry.spec.ts | 2 +- src/enface/__tests__/viewer.spec.ts | 3 +- src/enface/availability.ts | 5 +- src/enface/occupiedDepthSupport.ts | 11 +- src/enface/sliceGeometry.ts | 5 +- src/enface/useEnFaceSliceLines.ts | 2 +- .../__tests__/allocateImageFromChunks.spec.ts | 26 ++-- tests/specs/syntheticDicom.ts | 18 +-- tests/unit/octFixture.ts | 43 ++++-- 26 files changed, 525 insertions(+), 186 deletions(-) create mode 100644 packages/dicom/core/metadata/__tests__/spatialMetadata.spec.ts create mode 100644 src/core/streaming/dicom/__tests__/octStructuredMetadata.spec.ts create mode 100644 src/core/streaming/dicom/octDataset.ts create mode 100644 src/enface/__tests__/dicomMetadataFixture.ts diff --git a/eslint.config.js b/eslint.config.js index b485254f2..8c36076a1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -360,7 +360,12 @@ export default tseslint.config( }, { dir: 'referenceLines', - publicModules: ['ReferenceLines.vue', 'store'], + publicModules: [ + 'ReferenceLines.vue', + 'ReferenceLineSegments.vue', + 'geometry', + 'store', + ], pure: [ 'src/referenceLines/geometry.ts', 'src/referenceLines/crossings.ts', diff --git a/packages/dicom/core/metadata/__tests__/spatialMetadata.spec.ts b/packages/dicom/core/metadata/__tests__/spatialMetadata.spec.ts new file mode 100644 index 000000000..7c9551c01 --- /dev/null +++ b/packages/dicom/core/metadata/__tests__/spatialMetadata.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { datasetFromTags } from '@/tests/dicomDataset'; +import { readGeometryFacts } from '../../collections/instanceFacts'; +import { getDatasetTag } from '../datasetAccess'; +import { readPixelSpacing } from '../spatialMetadata'; + +const spacingDataset = (value: string) => + datasetFromTags([['00280030', value]]); + +describe('single-value PixelSpacing display compatibility', () => { + it.each([ + ['0.5', 0.5], + ['0.0104', 0.0104], + [' 2.5\u0000', 2.5], + ] as const)( + 'displays %s as square pixels without claiming calibration', + (raw, value) => { + const dataset = spacingDataset(raw); + const axes = readPixelSpacing(dataset); + + expect(axes.map((axis) => axis.value)).toEqual([value, value]); + for (const axis of axes) { + expect(axis).toMatchObject({ + unit: 'unknown', + status: 'fallback', + source: 'Single PixelSpacing value', + }); + expect(axis.reason).toContain('square pixels are assumed for display'); + } + expect(readGeometryFacts(dataset).pixelSpacing).toBeNull(); + expect(getDatasetTag(dataset, '00280030')).toBe(raw); + } + ); + + it.each(['0', '-0.5', 'NaN', 'Infinity', ''])( + 'does not use %s as display spacing', + (raw) => { + expect(readPixelSpacing(spacingDataset(raw))).toEqual([ + expect.objectContaining({ + value: 1, + unit: 'unknown', + status: 'fallback', + }), + expect.objectContaining({ + value: 1, + unit: 'unknown', + status: 'fallback', + }), + ]); + } + ); +}); diff --git a/packages/dicom/core/metadata/spatialMetadata.ts b/packages/dicom/core/metadata/spatialMetadata.ts index fa5dd4b39..e5406c490 100644 --- a/packages/dicom/core/metadata/spatialMetadata.ts +++ b/packages/dicom/core/metadata/spatialMetadata.ts @@ -43,15 +43,25 @@ export function declaredSpacing(raw: string | undefined, source: string) { : fallbackSpacing(`${source} has no usable positive spacing.`); } +function singletonPixelSpacing(raw: string) { + const declared = declaredSpacing(raw, 'PixelSpacing'); + if (declared.status === 'fallback') return declared; + return { + ...fallbackSpacing( + 'PixelSpacing has only one value; square pixels are assumed for display.' + ), + value: declared.value, + source: 'Single PixelSpacing value', + }; +} + /** Preserve a usable axis even when the other axis is missing or unusable. */ export function readPixelSpacing(metadata: DicomDataset | null | undefined) { const raw = trimDicomPadding(getFrameTag(metadata, '00280030') ?? ''); const parts = raw ? raw.split('\\') : undefined; if (parts && parts.length < 2) { - return [ - fallbackSpacing('PixelSpacing does not contain two values.'), - fallbackSpacing('PixelSpacing does not contain two values.'), - ] as const; + const assumed = singletonPixelSpacing(parts[0]); + return [assumed, { ...assumed }] as const; } return [ declaredSpacing(parts?.[1], 'PixelSpacing'), diff --git a/src/components/__tests__/ControlsStripTools.enFace.spec.ts b/src/components/__tests__/ControlsStripTools.enFace.spec.ts index 139f039aa..ccead2c08 100644 --- a/src/components/__tests__/ControlsStripTools.enFace.spec.ts +++ b/src/components/__tests__/ControlsStripTools.enFace.spec.ts @@ -13,6 +13,7 @@ import { activateEnFace, createOCTFixture, loadOCT, + setImageChunks, } from '@/tests/unit/octFixture'; import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; import { untilLoaded } from '@/src/composables/untilLoaded'; @@ -69,7 +70,7 @@ describe('En face contrast toolbar availability', () => { windowAction(wrapper).get(':scope > [role="tooltip"]').text() ).toMatch(/complete volume/); await fixture.chunk.loadMeta(); - await fixture.image.addChunks([fixture.chunk]); + await setImageChunks(fixture.image, [fixture.chunk]); await nextTick(); expect(wrapper.get(windowTool).attributes('disabled')).toBeDefined(); expect( diff --git a/src/core/streaming/dicom/__tests__/octMetadata.spec.ts b/src/core/streaming/dicom/__tests__/octMetadata.spec.ts index 7301b0b5e..f1916c6c0 100644 --- a/src/core/streaming/dicom/__tests__/octMetadata.spec.ts +++ b/src/core/streaming/dicom/__tests__/octMetadata.spec.ts @@ -1,10 +1,8 @@ import dicomParser, { type DataSet } from 'dicom-parser'; import { deflateRawSync } from 'node:zlib'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Tags } from '@/src/core/dicomTags'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { augmentOCTMetadata } from '@/src/core/streaming/dicom/octMetadata'; -import { allocateImageFromChunks } from '@/src/utils/allocateImageFromChunks'; -import type { Chunk } from '@/src/core/streaming/chunk'; import { deflatedExplicitVR } from '@/tests/fixtures/oct/volumes'; import { @@ -385,10 +383,8 @@ describe('OCT enhanced spatial metadata', () => { await augmentOCTMetadata(new Blob([bytes]), metadata) ); expect(spatialMetadataError(tags)).toBeTruthy(); - const image = allocateImageFromChunks([{ metadata: [...tags] } as Chunk]); - expect(image.getOrigin()).toEqual([9, 8, 7]); - expect(image.getDirection()).toEqual([1, 0, 0, 0, 1, 0, 0, 0, 1]); - image.delete(); + expect(tags.get(Tags.ImagePositionPatient)).toBe('9\\8\\7'); + expect(tags.get(Tags.ImageOrientationPatient)).toBe('1\\0\\0\\0\\1\\0'); } ); it.each([false, true])( diff --git a/src/core/streaming/dicom/__tests__/octStructuredMetadata.spec.ts b/src/core/streaming/dicom/__tests__/octStructuredMetadata.spec.ts new file mode 100644 index 000000000..5c3ab5689 --- /dev/null +++ b/src/core/streaming/dicom/__tests__/octStructuredMetadata.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readDicomDataset } from '@/packages/dicom/core/metadata/dicomDataset'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; +import { blobFetcher } from '@/packages/dicom/core/streaming/blobFetcher'; +import { CachedStreamFetcher } from '@/packages/dicom/core/streaming/cachedStreamFetcher'; +import { DicomMetaLoader } from '@/packages/dicom/core/streaming/dicom/dicomMetaLoader'; +import { dicomDatasetToTags } from '@/src/io/readDicomTags'; +import { augmentOCTMetadata, dicomMetadataForDisplay } from '../octMetadata'; +import { OCT_GEOMETRY_ERROR, OCT_SCAN_PATTERN } from '../octMetadataTags'; +import { + createSyntheticOCT, + element, + enhancedFile, + sequence, +} from '@/tests/fixtures/oct/dicom'; +import { deflatedExplicitVR } from '@/tests/fixtures/oct/volumes'; + +const positions = (values: number[]) => + values.map((z) => ({ position: `0\\0\\${z}` })); + +const projectedFields = (metadata: Iterable<[string, string]>) => { + const tags = new Map(metadata); + return [ + Tags.PixelSpacing, + Tags.ImagePositionPatient, + Tags.ImageOrientationPatient, + Tags.SpacingBetweenSlices, + Tags.OphthalmicVolumetricPropertiesFlag, + OCT_SCAN_PATTERN, + OCT_GEOMETRY_ERROR, + ].map((key) => [key, tags.get(key)]); +}; + +describe('structured OCT application metadata', () => { + it.each([ + ['regular', [0, 0.2, 0.4]], + ['reversed', [0.4, 0.2, 0]], + ['uneven', [0, 0.1, 0.4]], + ] as const)( + 'retains the existing %s geometry policy without changing raw data', + async (_, z) => { + const file = enhancedFile(positions([...z])); + const dataset = readDicomDataset( + new Uint8Array(await file.arrayBuffer()) + ); + const before = structuredClone(dataset); + const legacy = await augmentOCTMetadata( + file, + dicomDatasetToTags(dataset) + ); + + expect(projectedFields(dicomMetadataForDisplay(dataset))).toEqual( + projectedFields(legacy) + ); + expect(dataset).toEqual(before); + } + ); + + it.each(['128280', '128282'])( + 'retains nested B-scan pattern %s and volumetric restrictions', + async (pattern) => { + const file = enhancedFile(positions([0, 0.2]), [ + element('00221622', 'CS', 'NO'), + sequence('00221640', [ + sequence('00221618', [element('00080100', 'SH', pattern)]), + ]), + ]); + const dataset = readDicomDataset( + new Uint8Array(await file.arrayBuffer()) + ); + const projected = new Map(dicomMetadataForDisplay(dataset)); + + expect(projected.get(OCT_SCAN_PATTERN)).toBe(pattern); + expect(projected.get(Tags.OphthalmicVolumetricPropertiesFlag)).toBe('NO'); + expect(projected.get(OCT_GEOMETRY_ERROR)).toBeUndefined(); + } + ); + + it('preserves shared-only geometry with declared spacing without inventing frame positions', async () => { + const file = enhancedFile([], [element('00180088', 'DS', '0.2')]); + const dataset = readDicomDataset(new Uint8Array(await file.arrayBuffer())); + const count = dataset.elements.find((entry) => entry.tag === '00280008')!; + count.values = { kind: 'decimal', values: [3], text: ['3'] }; + const projected = new Map(dicomMetadataForDisplay(dataset)); + + expect(projected.get(Tags.PixelSpacing)).toBe('0.01\\0.02'); + expect(projected.get(Tags.SpacingBetweenSlices)).toBe('0.2'); + expect(projected.has(Tags.ImagePositionPatient)).toBe(false); + expect(projected.has(OCT_GEOMETRY_ERROR)).toBe(false); + }); + + it.each([false, true])( + 'matches file and HTTP OCT metadata after loading, deflated=%s', + async (deflated) => { + const original = new Uint8Array(createSyntheticOCT()); + const bytes = deflated ? deflatedExplicitVR(original) : original; + const file = new File([bytes], 'oct.dcm'); + const fetch = vi.fn( + async () => + new Response(bytes, { + headers: { 'Content-Length': String(bytes.length) }, + }) + ); + const loaders = [ + new DicomMetaLoader(blobFetcher(file)), + new DicomMetaLoader( + new CachedStreamFetcher('https://example.test/oct.dcm', { fetch }) + ), + ]; + try { + await Promise.all(loaders.map((loader) => loader.load())); + const projected = loaders.map( + (loader) => new Map(dicomMetadataForDisplay(loader.meta!)) + ); + expect(projected[0]).toEqual(projected[1]); + expect(projected[0].get(OCT_SCAN_PATTERN)).toBe('128280'); + expect(projected[0].has(OCT_GEOMETRY_ERROR)).toBe(false); + expect(projected[0].get(Tags.PixelSpacing)).toBe('0.0104\\0.046875'); + expect(fetch).toHaveBeenCalledTimes(1); + } finally { + loaders.forEach((loader) => loader.dispose()); + } + } + ); +}); diff --git a/src/core/streaming/dicom/octDataset.ts b/src/core/streaming/dicom/octDataset.ts new file mode 100644 index 000000000..2630a1cdd --- /dev/null +++ b/src/core/streaming/dicom/octDataset.ts @@ -0,0 +1,31 @@ +import type { DataSet } from 'dicom-parser'; +import type { DicomDataset } from '@/packages/dicom/core/metadata/dicomDataset'; +import { getDatasetTag } from '@/packages/dicom/core/metadata/datasetAccess'; +import { sequenceItems } from '@/packages/dicom/core/metadata/functionalGroups'; +import { trimDicomPadding } from '@/packages/dicom/core/utils'; + +/** The OCT policy reads attributes and their explicit sequence paths only. */ +export type OCTDataset = { + value: (tag: string) => string | undefined; + items: (tag: string) => OCTDataset[]; +}; + +export const parsedOCTDataset = (dataset: DataSet): OCTDataset => ({ + value: (tag) => dataset.string(tag), + items: (tag) => + dataset.elements[tag]?.items?.flatMap((item) => + item.dataSet ? [parsedOCTDataset(item.dataSet)] : [] + ) ?? [], +}); + +const structuredTag = (tag: string) => tag.slice(1).toUpperCase(); + +/** Adapts already loaded metadata without reparsing or retaining source bytes. */ +export const structuredOCTDataset = (dataset: DicomDataset): OCTDataset => ({ + value: (tag) => { + const value = getDatasetTag(dataset, structuredTag(tag)); + return value === undefined ? undefined : trimDicomPadding(value); + }, + items: (tag) => + sequenceItems(dataset, structuredTag(tag)).map(structuredOCTDataset), +}); diff --git a/src/core/streaming/dicom/octMetadata.ts b/src/core/streaming/dicom/octMetadata.ts index 2111e3a37..572b67a37 100644 --- a/src/core/streaming/dicom/octMetadata.ts +++ b/src/core/streaming/dicom/octMetadata.ts @@ -1,7 +1,17 @@ -import dicomParser, { type DataSet } from 'dicom-parser'; +import dicomParser from 'dicom-parser'; +import type { DicomDataset } from '@/packages/dicom/core/metadata/dicomDataset'; +import { dicomDatasetToTags } from '@/src/io/readDicomTags'; +import { + type OCTDataset, + parsedOCTDataset, + structuredOCTDataset, +} from './octDataset'; import { inflateSync } from 'fflate'; import { areEquals } from '@kitware/vtk.js/Common/Core/Math'; -import { Tags, toDicomParserKey } from '@/src/core/dicomTags'; +import { + Tags, + toDicomParserKey, +} from '@/packages/dicom/core/metadata/dicomTags'; import { frameGridGeometry, imageDirection, @@ -43,25 +53,22 @@ const sequenceTags = new Set([ 'x00289145', ]); -const firstItem = (data: DataSet | undefined, tag: string) => - data?.elements[tag]?.items?.[0]?.dataSet; -const sequenceItems = (data: DataSet, tag: string) => - data.elements[tag]?.items?.flatMap((item) => - item.dataSet ? [item.dataSet] : [] - ) ?? []; -const stringValue = (data: DataSet | undefined, tag: string) => - data?.string(tag)?.trim() || undefined; +const firstItem = (data: OCTDataset | undefined, tag: string) => + data?.items(tag)[0]; +const sequenceItems = (data: OCTDataset, tag: string) => data.items(tag); +const stringValue = (data: OCTDataset | undefined, tag: string) => + data?.value(tag)?.trim() || undefined; const vector = (value: string | undefined) => value?.split('\\').map(Number); function frameAttribute( - data: DataSet | undefined, + data: OCTDataset | undefined, sequence: string, attribute: string ) { return stringValue(firstItem(data, sequence), attribute); } -function frameGeometry(data: DataSet) { +function frameGeometry(data: OCTDataset) { const shared = firstItem(data, SHARED); const frames = sequenceItems(data, PER_FRAME); // Shared groups take precedence over the first frame, then the top level. @@ -74,7 +81,10 @@ function frameGeometry(data: DataSet) { ); return { frames, - declaredFrames: data.intString(toDicomParserKey(Tags.NumberOfFrames)), + declaredFrames: Number.parseInt( + stringValue(data, toDicomParserKey(Tags.NumberOfFrames)) ?? '', + 10 + ), orientation: attribute(ORIENTATION, IMAGE_ORIENTATION), spacing: attribute(MEASURES, PIXEL_SPACING), positions, @@ -152,7 +162,7 @@ function geometryError(geometry: ReturnType) { return positionError(geometry); } -function scanPattern(data: DataSet) { +function scanPattern(data: OCTDataset) { const acquisitions = sequenceItems(data, ACQUISITION_PARAMETERS); const patterns = [data, ...acquisitions].flatMap((dataset) => sequenceItems(dataset, SCAN_PATTERN).flatMap( @@ -164,7 +174,7 @@ function scanPattern(data: DataSet) { : patterns[0]; } -function promoteOCTGeometry(data: DataSet, tags: Map) { +function promoteOCTGeometry(data: OCTDataset, tags: Map) { const geometry = frameGeometry(data); const set = (tag: string, value: Maybe) => { if (value) tags.set(tag, value); @@ -240,9 +250,18 @@ export async function augmentOCTMetadata( const tags = new Map(metadata); try { const data = await readOCTHeader(file); - promoteOCTGeometry(data, tags); + promoteOCTGeometry(parsedOCTDataset(data), tags); } catch { tags.set(OCT_GEOMETRY_ERROR, 'OCT spatial metadata could not be read.'); } return [...tags]; } + +/** Application-only OCT eligibility and measurement metadata; raw core data stays unchanged. */ +export function dicomMetadataForDisplay(dataset: DicomDataset) { + const metadata = dicomDatasetToTags(dataset); + if (!isOCTMetadata(metadata)) return metadata; + const tags = new Map(metadata); + promoteOCTGeometry(structuredOCTDataset(dataset), tags); + return [...tags]; +} diff --git a/src/core/streaming/dicom/octMetadataTags.ts b/src/core/streaming/dicom/octMetadataTags.ts index d78a916dd..dc6061ba5 100644 --- a/src/core/streaming/dicom/octMetadataTags.ts +++ b/src/core/streaming/dicom/octMetadataTags.ts @@ -1,4 +1,4 @@ -import { Tags } from '@/src/core/dicomTags'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; export const OPHTHALMIC_TOMOGRAPHY_SOP = '1.2.840.10008.5.1.4.1.1.77.1.5.4'; export const OPHTHALMIC_BSCAN_ANALYSIS_SOP = '1.2.840.10008.5.1.4.1.1.77.1.5.8'; diff --git a/src/core/streaming/dicom/seriesGeometry.ts b/src/core/streaming/dicom/seriesGeometry.ts index 430d41a5e..e061510fc 100644 --- a/src/core/streaming/dicom/seriesGeometry.ts +++ b/src/core/streaming/dicom/seriesGeometry.ts @@ -1,5 +1,5 @@ import { areEquals } from '@kitware/vtk.js/Common/Core/Math'; -import { Tags } from '@/src/core/dicomTags'; +import { Tags } from '@/packages/dicom/core/metadata/dicomTags'; import { frameGridGeometry, imageDirection, @@ -50,6 +50,20 @@ function seriesPositions(tags: Map[]) { return positions.every((position) => position !== null) ? positions : null; } +function matchesLoadedDirection(actual: ArrayLike, declared: number[]) { + const axes = [declared.slice(0, 3), declared.slice(3, 6)].flatMap((axis) => { + const length = Math.hypot(...axis); + return axis.map((value) => value / length); + }); + // The structured allocator normalizes valid, rounded direction cosines. + const normalized = imageDirection(axes); + return ( + areEquals(Array.from(actual), declared, TOLERANCE) || + (normalized !== null && + areEquals(Array.from(actual), normalized, TOLERANCE)) + ); +} + function loadedGridReason( grid: Grid | undefined, direction: number[], @@ -58,7 +72,7 @@ function loadedGridReason( ) { if ( grid && - (!areEquals(Array.from(grid.direction), direction, TOLERANCE) || + (!matchesLoadedDirection(grid.direction, direction) || !areEquals(Array.from(grid.origin), origin, TOLERANCE) || !near(grid.spacing[2], distance, TOLERANCE)) ) diff --git a/src/enface/EnFaceViewer.vue b/src/enface/EnFaceViewer.vue index 9455fff5a..85d6fbea1 100644 --- a/src/enface/EnFaceViewer.vue +++ b/src/enface/EnFaceViewer.vue @@ -1,7 +1,7 @@