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 3476de387..10898d1ee 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', @@ -365,8 +377,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', @@ -382,5 +395,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 c7f4449db..8bad229ce 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", @@ -542,6 +543,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", @@ -7642,6 +7656,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", @@ -9819,6 +9843,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", @@ -10282,6 +10321,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", @@ -13295,6 +13367,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", @@ -13398,6 +13477,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", @@ -15915,6 +16001,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 b77e44e2e..010bb9e43 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": { "dev": "cross-env VITE_SHOW_SAMPLE_DATA=true vite", @@ -21,17 +25,17 @@ "test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && wdio run ./wdio.chrome.conf.ts", "test:e2e:chrome:skip-build": "wdio run ./wdio.chrome.conf.ts", "test:e2e:dev": "cross-env 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", @@ -72,6 +76,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..a767fac59 --- /dev/null +++ b/packages/dicom/README.md @@ -0,0 +1,187 @@ +# 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. + +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..c675683a1 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts @@ -0,0 +1,630 @@ +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); + }); +}); 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..d36ff699f --- /dev/null +++ b/packages/dicom/core/collections/__tests__/instanceFacts.spec.ts @@ -0,0 +1,381 @@ +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: null, + 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: null, + 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'], + ['NumberOfFrames', Tags.NumberOfFrames, 'numberOfFrames'], + ] 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)); +} 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..5ea3781b2 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts @@ -0,0 +1,855 @@ +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); + }); +}); 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..3de6ba5d7 --- /dev/null +++ b/packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts @@ -0,0 +1,215 @@ +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)); +} 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..9d3388296 --- /dev/null +++ b/packages/dicom/core/collections/collectionRegistry.ts @@ -0,0 +1,366 @@ +import type { Chunk } from '../streaming/chunk'; +import { + planDicomCollections, + type DicomCollection, + type InstanceFacts, +} from './planDicomCollections'; +import { + assignIds, + encodeCollectionKey, + type CommittedCollection, +} from './assignCollectionIds'; +import { readInstanceFacts, seriesKeyOf } from './instanceFacts'; +import { getDatasetTag } from '../metadata/datasetAccess'; +import { Tags } from '../metadata/dicomTags'; + +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[]; + // 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) => 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 readChunk = (chunk: Chunk) => { + const { metadata } = chunk; + if (!metadata) throw unread(); + const facts = readInstanceFacts(metadata); + return { + seriesKey: seriesKeyOf(metadata), + instance: { key: facts.sopInstanceUid ?? 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. + batch.forEach((instance) => { + if (!entry.instances.has(instance.key)) + entry.instances.set(instance.key, instance); + }); + + 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) => 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 }; +} + +/** + * 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) => { + const snapshot = snapshots.get(seriesKey); + if (!snapshot) return false; + const entry = series.get(seriesKey); + return ( + entry !== snapshot.entry || + entry.forgotten.length !== snapshot.forgotten + ); + }; + + 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), + 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; + const key = getDatasetTag(metadata, Tags.SOPInstanceUID)?.trim() || chunk; + return ( + series.get(seriesKeyOf(metadata))?.instances.get(key)?.chunk === chunk + ); + }; + + return { register, forget, has }; +} diff --git a/packages/dicom/core/collections/index.ts b/packages/dicom/core/collections/index.ts new file mode 100644 index 000000000..74c39eeeb --- /dev/null +++ b/packages/dicom/core/collections/index.ts @@ -0,0 +1,7 @@ +export * from './assignCollectionIds'; +export * from './collectionRegistry'; +export * from './instanceFacts'; +export * from './planDicomCollections'; +export * from './reconstructVolume'; +export * from './splitOverlappingAcquisitions'; +export * from './multiframeGeometry'; diff --git a/packages/dicom/core/collections/instanceFacts.ts b/packages/dicom/core/collections/instanceFacts.ts new file mode 100644 index 000000000..dae4667e5 --- /dev/null +++ b/packages/dicom/core/collections/instanceFacts.ts @@ -0,0 +1,193 @@ +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], +]; + +const dot = (left: number[], right: number[]) => + left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; + +/** + * 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 spacing = vectorOf(metadata, Tags.PixelSpacing, 2, frameIndex); + return spacing !== null && spacing.every((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 = 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), + 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..efc51a82e --- /dev/null +++ b/packages/dicom/core/collections/multiframeGeometry.ts @@ -0,0 +1,79 @@ +import type { DicomDataset } from '../metadata/dicomDataset'; +import { readGeometryFacts, sliceNormalOf } from './instanceFacts'; +import { reconstructVolume, type Reconstruction } from './reconstructVolume'; +import { ORIENTATION_TOLERANCE } from './planDicomCollections'; +import { getDatasetTag } from '../metadata/datasetAccess'; +import { trimDicomPadding } from '../utils'; +import { perFrameGroups, sequenceItems } from '../metadata/functionalGroups'; + +const dot = (left: number[], right: number[]) => + left.reduce((sum, value, index) => sum + value * right[index], 0); + +const orientationsAgree = (left: number[] | null, right: number[] | null) => + left !== null && + right !== null && + dot(left.slice(0, 3), right.slice(0, 3)) >= 1 - ORIENTATION_TOLERANCE && + dot(left.slice(3), right.slice(3)) >= 1 - ORIENTATION_TOLERANCE; + +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 && + 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/planDicomCollections.ts b/packages/dicom/core/collections/planDicomCollections.ts new file mode 100644 index 000000000..8b7ea045d --- /dev/null +++ b/packages/dicom/core/collections/planDicomCollections.ts @@ -0,0 +1,586 @@ +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[]; +}; + +// Matches dicom.cpp's EPSILON: two orientation rows agree when their dot +// product is at least 1 minus this. One sided, as dicom.cpp is, so cosines +// that are slightly over normalized still agree with themselves. +export const ORIENTATION_TOLERANCE = 1e-4; + +// 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 dot = (left: number[], right: number[], offset: number) => + left[offset] * right[offset] + + left[offset + 1] * right[offset + 1] + + left[offset + 2] * right[offset + 2]; + +const orientationsAgree = (left: number[], right: number[]) => + dot(left, right, 0) >= 1 - ORIENTATION_TOLERANCE && + dot(left, right, 3) >= 1 - ORIENTATION_TOLERANCE; + +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 planned = groupBy(kept, hardFactSignature) + .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, + ], + }; + 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 ? [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..bc0816005 --- /dev/null +++ b/packages/dicom/core/collections/reconstructVolume.ts @@ -0,0 +1,212 @@ +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, 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( + SLICE_SPACING_TOLERANCE_MM, + 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..288f8eb18 --- /dev/null +++ b/packages/dicom/core/index.ts @@ -0,0 +1,26 @@ +export * from './collections'; +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__/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..da764f61c --- /dev/null +++ b/packages/dicom/core/metadata/dcmjs.d.ts @@ -0,0 +1,82 @@ +declare module 'dcmjs' { + export interface DicomTag { + toCleanString(): string; + } + + /** `vr` and `values` are `0` for the element a read stopped at. */ + export interface DicomElement { + tag: DicomTag; + vr: { type: string } | 0; + values: unknown; + rawValues: unknown; + } + + export interface DicomReadOptions { + untilTag?: string; + includeUntilTagValue?: boolean; + forceStoreRaw?: boolean; + ignoreErrors?: boolean; + } + + export interface DicomStringDecoder { + decode(view: ArrayBufferView): string; + } + + export interface 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 interface DicomDictionaryEntry { + tag: string; + vr: string; + vm: string; + name: string; + } + + export interface 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..84ae11e3e --- /dev/null +++ b/packages/dicom/core/metadata/dcmjsParser.ts @@ -0,0 +1,420 @@ +import dcmjs, { DicomDataElement, 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_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)), +}; + +/** + * dcmjs reads a sequence item from a stream of its own, whose decoder defaults + * to windows-1252 and which its item reader replaces with one of its own when + * the item declares a character set. Both lose the bytes a data set wide + * character set needs, so every derived stream keeps the byte decoder. + */ +const readsBytes = (stream: DicomReadStream) => { + stream.setDecoder(byteDecoder); + const derive = stream.more.bind(stream); + stream.more = (length: number) => readsBytes(derive(length)); + stream.setDecoder = () => {}; + 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 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), + }; + + const groupLength = DicomMessage._readTag(stream, EXPLICIT_VR_LITTLE_ENDIAN); + if (groupLength.tag.toCleanString() !== FILE_META_GROUP_LENGTH) + throw new Error('Not a DICOM file: the file meta group length is missing'); + + const [metaLength] = groupLength.values as number[]; + const meta = DicomMessage._read( + stream.more(metaLength), + EXPLICIT_VR_LITTLE_ENDIAN + ); + const syntax = meta[TRANSFER_SYNTAX_UID]?.Value[0]; + 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' && length !== UNDEFINED_LENGTH) || + (vr === 'UN' && length === UNDEFINED_LENGTH); + +/** + * Sequences needing explicit handling: a defined-length SQ can be stepped + * over if its contents fail to parse; an undefined-length UN must be read as + * item datasets rather than dcmjs's encapsulated pixel representation. + */ +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; + return needsSequenceReader(vr, length) + ? { + 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); + +/** + * dcmjs returns a sequence item as a dictionary keyed by tag, so a duplicate + * tag in one item collapses and a tag whose eight hex digits read as an integer + * sorts ahead of the rest. + */ +const itemElements = ( + item: Record +): ParsedElement[] => + Object.entries(item).flatMap(([tag, data]) => + data == null ? [] : [dictElement(tag, data)] + ); + +const dictElement = (tag: string, data: DicomDataElement): ParsedElement => ({ + group: groupOf(tag), + element: elementOf(tag), + vr: data.vr, + values: data.vr === 'SQ' ? [] : parsedValues(data.vr, data._rawValue), + ...(data.vr === 'SQ' + ? { + items: ( + data.Value as Array> + ).map(itemElements), + } + : {}), +}); + +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: vr === 'SQ' ? [] : parsedValues(vr, element.rawValues), + ...(vr === 'SQ' + ? { + items: ( + element.values as Array< + Record + > + ).map(itemElements), + } + : {}), + }; +}; + +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; + } +): ParsedElement => { + // dcmjs treats an unknown undefined-length value as encapsulated pixels, + // interpreting its first item as an offset table. UN sequence items are + // datasets instead, always in Implicit VR Little Endian (PS3.5 6.2.2). + if (sequence.end === null) { + dataSet.offset = sequence.valueStart; + const items: ParsedElement[][] = []; + for (;;) { + dataSet.setEndian(true); + const group = dataSet.readUint16(); + const element = dataSet.readUint16(); + const length = dataSet.readUint32(); + if (group === 0xfffe && element === 0xe0dd && length === 0) break; + if (group !== 0xfffe || element !== 0xe000) + throw new Error('Expected an item in an undefined-length UN'); + items.push( + length === UNDEFINED_LENGTH + ? readElements(dataSet, IMPLICIT_VR_LITTLE_ENDIAN, { + untilItemDelimiter: true, + stopAtPixelData: false, + }) + : readElements(dataSet.more(length), IMPLICIT_VR_LITTLE_ENDIAN, { + stopAtPixelData: false, + }) + ); + } + return { + group: sequence.group, + element: sequence.element, + vr: 'UN', + values: [], + items, + }; + } + try { + return parsedElement(DicomMessage._readTag(dataSet, syntax, READ_OPTIONS)); + } catch (error) { + const bytes = new Uint8Array( + dataSet.slice(sequence.valueStart, sequence.end) + ); + dataSet.offset = sequence.end; + return { + group: sequence.group, + element: sequence.element, + vr: 'SQ', + values: [], + unread: { bytes, reason: String(error) }, + }; + } +}; + +/** + * dcmjs reads every sequence item through `DicomMessage._read`, which it calls + * with no options of its own, and which throws on an item declaring a Specific + * Character Set of more than one value, as a Japanese or Korean data set does. + * An undefined length sequence has no length to step over, so that throw would + * escape past the whole file. Reading items leniently keeps them; `_rawValue` + * still carries what the item declared, so its character set is applied later. + */ +const LENIENT_ITEM_OPTIONS = { ignoreErrors: true }; + +const withLenientItems = (read: () => T) => { + const readDataSet = DicomMessage._read; + DicomMessage._read = (stream, syntax, options = LENIENT_ITEM_OPTIONS) => + readDataSet.call(DicomMessage, stream, syntax, options); + try { + return read(); + } finally { + DicomMessage._read = readDataSet; + } +}; + +/** 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) + 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 withLenientItems(() => 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..2454d69f9 --- /dev/null +++ b/packages/dicom/core/metadata/dicomCharacterSet.ts @@ -0,0 +1,200 @@ +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, so the last declared set is tried first and an earlier one + * is used only for bytes it cannot decode. 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 declared = specificCharacterSet + .filter((name) => name.trim().length > 0) + .map(encodingLabel) + .filter((label): label is string => label != null) + .reverse(); + const labels = declared.length > 0 ? declared : ['utf-8']; + 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) => text ?? 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..0b8698487 --- /dev/null +++ b/packages/dicom/core/metadata/dicomDataset.ts @@ -0,0 +1,243 @@ +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 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 88% rename from src/core/dicomTags.ts rename to packages/dicom/core/metadata/dicomTags.ts index d042b06ea..208dbe521 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..715158d68 --- /dev/null +++ b/packages/dicom/core/metadata/modalityRescale.ts @@ -0,0 +1,33 @@ +import type { DicomDataset } from './dicomDataset'; +import { getFrameTag, perFrameGroups } from './functionalGroups'; +import { trimDicomPadding } from '../utils'; + +const numberOr = (value: string | undefined, fallback: number) => { + const text = trimDicomPadding(value ?? ''); + const number = Number(text); + return text && Number.isFinite(number) ? number : fallback; +}; + +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..230f54c03 --- /dev/null +++ b/packages/dicom/core/metadata/nmReconstruction.ts @@ -0,0 +1,137 @@ +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) => + Number(text(metadata, tag)); + +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) => + 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..89fa4184a --- /dev/null +++ b/packages/dicom/core/metadata/spatialMetadata.ts @@ -0,0 +1,59 @@ +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?: string }; + 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..0fed8e3f7 --- /dev/null +++ b/packages/dicom/core/runtime/__tests__/lifetime.spec.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { createDicomLoader } from '../createDicomLoader'; +import { Chunk } from '../../streaming/chunk'; +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(); + }); +}); 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..31dd4de0b --- /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 '@/src/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 95% rename from src/core/streaming/__tests__/cachedStreamFetcher.spec.ts rename to packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts index d7eb46216..7009f009d 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 ( 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 95% rename from src/core/streaming/__tests__/requestPool.spec.ts rename to packages/dicom/core/streaming/__tests__/requestPool.spec.ts index 3cc846db9..f194306af 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 () => { 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 88% rename from src/core/streaming/cachedStreamFetcher.ts rename to packages/dicom/core/streaming/cachedStreamFetcher.ts index 7265ed2d0..88645a5fa 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') ?? ''; @@ -191,6 +195,13 @@ export class CachedStreamFetcher implements Fetcher { this.activeNetworkStreamReader = this.activeNetworkStream.getReader(); } + 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 +214,7 @@ export class CachedStreamFetcher implements Fetcher { pull: async (controller) => { if (!this.connected) { controller.close(); + return; } if (chunkIndex > this.chunks.length) @@ -232,9 +244,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 +258,7 @@ export class CachedStreamFetcher implements Fetcher { return result; }) .finally(() => { - this.promise = null; + if (connection === this.abortController) this.promise = null; }); } return this.promise; @@ -260,6 +274,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..6470c8ae5 --- /dev/null +++ b/packages/dicom/core/streaming/chunk.ts @@ -0,0 +1,194 @@ +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 }; +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.machine.unsubscribe(this.onStateUpdated); + this.events.all.clear(); + this.userData.clear(); + this.metaLoader.dispose?.(); + this.dataLoader.dispose?.(); + this.onDispose?.(); + this.onDispose = undefined; + } + + 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.state === state) { + resolve(chunk); + return; + } + const stop = chunk.watchForState(state, () => { + stop(); + stopError(); + resolve(chunk); + }); + const stopError = chunk.addEventListener('error', (error) => { + stop(); + stopError(); + reject(error); + }); + }); +} 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..a1e3522b4 --- /dev/null +++ b/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts @@ -0,0 +1,189 @@ +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 { + decodeUltrasoundRegion, + SEQUENCE_OF_ULTRASOUND_REGIONS, + 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; + let ultrasoundRegions: UltrasoundRegions | 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(); + if ( + isTag(SEQUENCE_OF_ULTRASOUND_REGIONS, el.group, el.element) && + !ultrasoundRegions + ) { + // Decoding can throw if a malformed FD/US value has an unexpected + // length; swallow rather than abort the whole metadata load. + try { + ultrasoundRegions = decodeUltrasoundRegion(el.data); + } catch (err) { + console.warn('Failed to decode SequenceOfUltrasoundRegions:', err); + } + } + }, + }); + + 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' && ultrasoundRegions) { + this.ultrasoundRegions = ultrasoundRegions; + } + } + + 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..da77c2f1c 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, @@ -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 96% rename from src/core/streaming/dicom/ultrasoundRegion.ts rename to packages/dicom/core/streaming/dicom/ultrasoundRegion.ts index 3fab3a12b..751b80f71 100644 --- a/src/core/streaming/dicom/ultrasoundRegion.ts +++ b/packages/dicom/core/streaming/dicom/ultrasoundRegion.ts @@ -1,8 +1,5 @@ -import { - createDicomParser, - DataElement, -} from '@/src/core/streaming/dicom/dicomParser'; -import { Tags, tagToGroupElement } from '@/src/core/dicomTags'; +import { createDicomParser, 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 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..fceffba22 --- /dev/null +++ b/packages/dicom/core/streaming/requestPool.ts @@ -0,0 +1,111 @@ +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 queued: FetchRequest = { + request, + init, + deferred, + controller, + cleanup: () => sourceSignal?.removeEventListener('abort', abort), + }; + if (sourceSignal?.aborted) { + deferred.reject(sourceSignal.reason); + return deferred.promise; + } + sourceSignal?.addEventListener('abort', abort, { 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 sourceSignal = + request.init?.signal ?? + (request.request instanceof Request + ? request.request.signal + : undefined); + const signals = [request.controller.signal, this.lifetime.signal]; + if (sourceSignal) signals.push(sourceSignal); + const fetchFn = this.fetchFn; + const response = await fetchFn(request.request, { + ...request.init, + signal: AbortSignal.any(signals), + }); + if (request.controller.signal.aborted) { + response.body?.cancel().catch(() => {}); + request.deferred.reject(request.controller.signal.reason); + } else request.deferred.resolve(response); + } catch (error) { + request.deferred.reject(error); + } finally { + request.cleanup(); + 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/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/packages/dicom/core/utils/index.ts b/packages/dicom/core/utils/index.ts new file mode 100644 index 000000000..ffac2d3ae --- /dev/null +++ b/packages/dicom/core/utils/index.ts @@ -0,0 +1,71 @@ +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); +} 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..7fee147de --- /dev/null +++ b/packages/dicom/scripts/build.mjs @@ -0,0 +1,67 @@ +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 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: 'dist', + 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', + ], + { cwd: root, stdio: 'inherit' } +); + +const moduleRoot = (name) => resolve(dirname(require.resolve(name)), '..'); +const assets = resolve(packageRoot, 'dist/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..f7e800f08 --- /dev/null +++ b/packages/dicom/scripts/check-consumers.mjs @@ -0,0 +1,94 @@ +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) { + await build({ + configFile: false, + root: fixture, + publicDir: false, + base: './', + resolve: { + alias: { + '@volview/dicom/core': resolve(packageRoot, 'dist/core.js'), + '@volview/dicom/vtk': resolve(packageRoot, 'dist/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(packageRoot, 'dist/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..ed4c5fae3 --- /dev/null +++ b/packages/dicom/vtk/VtkDicomImage.ts @@ -0,0 +1,606 @@ +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 reconstruction = 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 requested = this.readThumbnail().catch((error) => { + if (this.thumbnail === requested) this.thumbnail = null; + 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..8749a2ca1 --- /dev/null +++ b/packages/dicom/vtk/__tests__/consumer.spec.ts @@ -0,0 +1,240 @@ +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); + }); +}); diff --git a/packages/dicom/vtk/__tests__/functionalGroups.spec.ts b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts new file mode 100644 index 000000000..dc5f1bd31 --- /dev/null +++ b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts @@ -0,0 +1,503 @@ +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'); + }); +}); diff --git a/packages/dicom/vtk/allocateImageFromChunks.ts b/packages/dicom/vtk/allocateImageFromChunks.ts new file mode 100644 index 000000000..4cc37764e --- /dev/null +++ b/packages/dicom/vtk/allocateImageFromChunks.ts @@ -0,0 +1,409 @@ +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 { trimDicomPadding } 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`); +} + +function numberOr(value: string | undefined, fallback: number) { + const text = trimDicomPadding(value ?? ''); + if (!text) return fallback; + const num = Number(text); + return Number.isFinite(num) ? num : fallback; +} + +/** + * 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..2476d60df --- /dev/null +++ b/packages/dicom/vtk/thumbnailSource.ts @@ -0,0 +1,96 @@ +import { Chunk, ChunkState } 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; +const ensureError = (error: unknown) => + error instanceof Error ? error : new Error(String(error)); + +/** + * The chunk once its bytes are in, or the error that means they never will + * be. A chunk that errored settles back to holding metadata only, so waiting + * on the loaded state alone would wait forever. + */ +const loadedChunk = (chunk: Chunk, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + if (chunk.state === ChunkState.Loaded) { + resolve(chunk); + return; + } + const stopWatching = chunk.watchForState(ChunkState.Loaded, () => { + stopListening(); + resolve(chunk); + }); + const stopListening = () => { + stopWatching(); + stopOnError(); + signal.removeEventListener('abort', onAbort); + }; + const onAbort = () => { + stopListening(); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + const stopOnError = chunk.addEventListener('error', (err) => { + stopListening(); + reject(ensureError(err)); + }); + }); + +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 +) { + const loaded = await loadedChunk(chunk, signal); + 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..9b320c82a --- /dev/null +++ b/tests/fixtures/dicom-consumer/server.mjs @@ -0,0 +1,100 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +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 = {} } = {} +) { + execFileSync(process.execPath, ['packages/dicom/scripts/build.mjs'], { + cwd: root, + stdio: 'inherit', + }); + const directory = await mkdtemp(resolve(tmpdir(), 'dicom-browser-')); + const servers = []; + /** @type {Record} */ + const urls = {}; + try { + for (const kind of kinds) { + const outDir = resolve(directory, kind); + await buildConsumer(kind, outDir); + 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 673f710c9..6b28bc19b 100644 --- a/tests/pageobjects/volview.page.ts +++ b/tests/pageobjects/volview.page.ts @@ -72,6 +72,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'); } @@ -297,10 +361,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/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-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..c2d8f1825 --- /dev/null +++ b/tests/specs/dicom-volume-composition.e2e.ts @@ -0,0 +1,113 @@ +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(); + 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/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 ed9e9fa09..a4b33f35e 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(); @@ -310,16 +315,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 41cf7ef39..2105a3895 100644 --- a/tests/specs/sample-rendering.e2e.ts +++ b/tests/specs/sample-rendering.e2e.ts @@ -1,5 +1,6 @@ import AppPage from '../pageobjects/volview.page'; import { PROSTATEX_DATASET } from '../datasets'; +import { DOWNLOAD_TIMEOUT } from '../../wdio.shared.conf'; const THRESHOLD = 12; // percent - handle pixel jitter in 3D view const RENDER_STABLE_TIMEOUT = 5000; @@ -21,7 +22,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 f1750e94a..817b3d33a 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(); @@ -92,6 +94,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) => { @@ -108,6 +265,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; @@ -121,7 +288,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; @@ -133,14 +302,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 { @@ -160,14 +347,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) { @@ -176,13 +372,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'), @@ -190,26 +399,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), @@ -218,16 +455,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)), @@ -237,6 +475,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; @@ -277,6 +533,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'), @@ -285,7 +542,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 b6f2cc82c..3f082d5fb 100644 --- a/tests/specs/utils.ts +++ b/tests/specs/utils.ts @@ -268,6 +268,40 @@ export async function openUrls(datasets: ReadonlyArray) { await openVolViewPage(fileName); } +/** 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'],