diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 07799f786..4ad57ece2 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -18,9 +18,11 @@ jobs:
cache: npm
- run: npm ci
- name: Enforce code style
- run: npx prettier --list-different "src/**/*.[jt]s" "tests/**/*.[jt]s" "src/**/*.vue"
+ run: npx prettier --list-different "src/**/*.[jt]s" "tests/**/*.[jt]s" "src/**/*.vue" "packages/dicom/**/*.{ts,mjs,json,md}"
- name: Lint
run: npm run lint
+ - name: Build independent DICOM consumers
+ run: npm run dicom:check
- name: Quality ratchets
# A file may not get worse than it is on the pull request's target branch.
if: github.event_name == 'pull_request'
diff --git a/.github/workflows/e2e-datasets.yml b/.github/workflows/e2e-datasets.yml
index 4d6f01351..16a259354 100644
--- a/.github/workflows/e2e-datasets.yml
+++ b/.github/workflows/e2e-datasets.yml
@@ -22,7 +22,7 @@ jobs:
uses: actions/cache@v4
with:
path: .tmp/datasets
- key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }}
+ key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'tests/dicomVolumeCorpus.json', 'tests/dicomCompositionCorpus.json') }}
restore-keys: e2e-datasets-
- name: Download missing datasets
run: node tests/downloadDatasets.ts
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 43312bf9c..18a784071 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -28,7 +28,7 @@ jobs:
uses: actions/cache@v4
with:
path: .tmp/datasets
- key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }}
+ key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'tests/dicomVolumeCorpus.json', 'tests/dicomCompositionCorpus.json') }}
restore-keys: e2e-datasets-
- name: Run E2E on chrome (linux)
if: matrix.os == 'ubuntu-latest'
diff --git a/.gitignore b/.gitignore
index dd9be9f03..f1546634b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,5 @@ reports
.github/copilot
CLAUDE.md
.claude/
+
+/packages/dicom/dist/
diff --git a/.prettierignore b/.prettierignore
index 6463485b9..da2a92bc8 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,6 +1,5 @@
dist
node_modules
public
-src/io/itk-dicom
src/io/resample
*.json
diff --git a/eslint.config.js b/eslint.config.js
index 8b22d955e..30e5c34b8 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -12,7 +12,7 @@ import globals from 'globals';
// not a dependency of this repo, and the built-in rule expresses the same
// zones with no new dependency. Two zones per feature:
// 1. Code OUTSIDE the feature directory may reach it ONLY through its public
-// surface `@/src/
` (the index), never a deep path.
+// modules listed in its public surface.
// 2. The feature's pure layer may not import stores, components, or the
// upper feature modules, and stays framework-free (no pinia, no vue) —
// dependencies point downward only.
@@ -64,16 +64,27 @@ 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/.+|vtk/.+)',
+ message: 'Import the defining DICOM core or vtk module directly.',
+};
+
const featureBoundaries = (features) => {
- const publicSurface = ({ dir }) => ({
- group: [`@/src/${dir}/*`, `@/src/${dir}/*/**`, `!@/src/${dir}/index`],
- message: `Import the ${dir} feature only from its public surface \`@/src/${dir}\` (src/${dir}/index.ts). A deep import bypasses the feature boundary.`,
+ const publicSurface = ({ dir, publicModules }) => ({
+ group: [
+ `@/src/${dir}/*`,
+ `@/src/${dir}/*/**`,
+ ...publicModules.map((module) => `!@/src/${dir}/${module}`),
+ ],
+ message: `Import the ${dir} feature only through its defining public modules: ${publicModules.join(', ')}.`,
});
const publicFeatures = features.filter(
(feature) => feature.publicSurface !== false
);
- const otherSurfaces = (feature) =>
- publicFeatures.filter((other) => other !== feature).map(publicSurface);
+ const otherSurfaces = (feature) => [
+ dicomPublicSurface,
+ ...publicFeatures.filter((other) => other !== feature).map(publicSurface),
+ ];
return [
{
@@ -82,7 +93,12 @@ const featureBoundaries = (features) => {
rules: {
'no-restricted-imports': [
'error',
- { patterns: publicFeatures.map(publicSurface) },
+ {
+ patterns: [
+ dicomPublicSurface,
+ ...publicFeatures.map(publicSurface),
+ ],
+ },
],
},
},
@@ -149,7 +165,6 @@ const E2E_TIMEOUT_DEBT = [
'tests/specs/cine-ruler-session.e2e.ts',
'tests/specs/crosshairs-multi-image.e2e.ts',
'tests/specs/delete-selected-annotation.e2e.ts',
- 'tests/specs/dicom-dimension-mismatch.e2e.ts',
'tests/specs/different-direction-labelmap.e2e.ts',
'tests/specs/imageCacheUtils.ts',
'tests/specs/layer-source-lifetime.e2e.ts',
@@ -172,10 +187,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/**',
],
},
@@ -247,9 +262,10 @@ export default tseslint.config(
'no-restricted-syntax': [
'error',
{
- selector: "Property[key.name='timeout']",
+ selector:
+ "Property[key.name='timeout']:not([value.type='Identifier'][value.name='DOWNLOAD_TIMEOUT'])",
message:
- 'Wait for a state the page publishes rather than passing a timeout.',
+ 'Wait for a state the page publishes; use DOWNLOAD_TIMEOUT for data-loading readiness.',
},
{
selector:
@@ -293,7 +309,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',
@@ -336,6 +352,7 @@ export default tseslint.config(
},
{
dir: 'processing',
+ publicModules: ['integration', 'store', 'types'],
pure: [
'src/processing/engine/**/*.{js,ts}',
'src/processing/types.ts',
@@ -344,6 +361,12 @@ export default tseslint.config(
},
{
dir: 'referenceLines',
+ publicModules: [
+ 'ReferenceLines.vue',
+ 'ReferenceLineSegments.vue',
+ 'geometry',
+ 'store',
+ ],
pure: [
'src/referenceLines/geometry.ts',
'src/referenceLines/crossings.ts',
@@ -372,8 +395,9 @@ export default tseslint.config(
// level because the ratchet only fails a file whose debt grows, so existing
// files stay over these numbers without failing `npm run lint`.
{
- files: ['src/**/*.{js,ts,vue}'],
+ files: ['src/**/*.{js,ts,vue}', 'packages/dicom/**/*.{js,ts}'],
ignores: [
+ 'packages/dicom/**/__tests__/**',
'src/**/__tests__/**',
'src/**/*.{spec,test}.{js,ts}',
'src/**/*.d.ts',
@@ -389,5 +413,26 @@ export default tseslint.config(
],
},
},
+ ...['core', 'vtk'].map((entry) => ({
+ files: [`packages/dicom/${entry}/**/*.{js,ts}`],
+ ignores: ['**/__tests__/**'],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ regex:
+ entry === 'core'
+ ? '^(@/|@src/|vue(?:/|$)|pinia(?:/|$)|@vueuse/|@kitware/vtk\\.js(?:/|$))'
+ : '^(@/|@src/|vue(?:/|$)|pinia(?:/|$)|@vueuse/)',
+ message:
+ 'DICOM package internals use relative imports and cannot depend on application state or UI frameworks. Core cannot depend on vtk.js.',
+ },
+ ],
+ },
+ ],
+ },
+ })),
eslintConfigPrettier
);
diff --git a/package-lock.json b/package-lock.json
index d545d55c8..44f2f76e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,6 +46,7 @@
"core-js": "3.47.0",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
+ "dcmjs": "^0.52.0",
"dicom-parser": "^1.8.21",
"dicomweb-client-typed": "^0.8.6",
"eslint": "^9.39.1",
@@ -543,6 +544,19 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime-corejs3": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz",
+ "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "core-js-pure": "^3.48.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -7643,6 +7657,16 @@
"dev": true,
"license": "(Apache-2.0 AND MIT)"
},
+ "node_modules/adm-zip": {
+ "version": "0.5.17",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
+ "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -9820,6 +9844,21 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-js-pure": {
+ "version": "3.50.0",
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz",
+ "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -10283,6 +10322,39 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dcmjs": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/dcmjs/-/dcmjs-0.52.0.tgz",
+ "integrity": "sha512-0/mT9NACKnwnOnkUx7JAqmWxRcUr7HiCoptw1KhN2StNYE0Hy6+tmpI7CU+lJuBc4rFpcMCalvI8QO6JkJ+7Fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime-corejs3": "7.29.2",
+ "adm-zip": "0.5.17",
+ "gl-matrix": "3.4.4",
+ "lodash.clonedeep": "4.5.0",
+ "loglevel": "1.9.2",
+ "ndarray": "1.0.19",
+ "pako": "2.1.0"
+ },
+ "engines": {
+ "node": ">=22.13"
+ }
+ },
+ "node_modules/dcmjs/node_modules/gl-matrix": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
+ "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dcmjs/node_modules/pako": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+ "dev": true,
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -13296,6 +13368,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/iota-array": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
+ "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/ip-address": {
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
@@ -13399,6 +13478,13 @@
"node": ">=8"
}
},
+ "node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -15916,6 +16002,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ndarray": {
+ "version": "1.0.19",
+ "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz",
+ "integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iota-array": "^1.0.0",
+ "is-buffer": "^1.0.2"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
diff --git a/package.json b/package.json
index a7ef1682f..50f80664e 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,11 @@
"files": [
"dist",
"src",
- "backend-contract"
+ "backend-contract",
+ "packages/dicom/core/**/*.ts",
+ "packages/dicom/vtk/**/*.ts",
+ "packages/dicom/README.md",
+ "!packages/dicom/**/__tests__/**"
],
"scripts": {
"prepare": "node scripts/apply-patches.mjs",
@@ -22,17 +26,17 @@
"test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts",
"test:e2e:chrome:skip-build": "cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts",
"test:e2e:dev": "cross-env HEADED=0 VITE_SHOW_SAMPLE_DATA=true concurrently -P \"npm run dev\" \"wdio run ./wdio.dev.conf.ts --watch {@}\"",
- "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\" \"scripts/**/*.mjs\"",
- "build:all": "npm run build:dicom && npm run build:resample && npm run build",
- "build:dicom": "itk-wasm -s src/io/itk-dicom/ build ",
- "build:dicom:debug": "itk-wasm -s src/io/itk-dicom/ build -- -DCMAKE_BUILD_TYPE=Debug",
+ "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\" \"scripts/**/*.mjs\" \"packages/dicom/**/*.{js,ts,mjs}\"",
+ "build:all": "npm run build:resample && npm run build",
"build:resample": "itk-wasm -s src/io/resample/ build ",
"build:resample:debug": "itk-wasm -s src/io/resample/ build -- -DCMAKE_BUILD_TYPE=Debug",
"build:watch": "npm run build -- --watch",
- "prettify": "prettier --write src tests",
+ "prettify": "prettier --write src tests packages/dicom",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
- "docs:preview": "vitepress preview docs"
+ "docs:preview": "vitepress preview docs",
+ "dicom:build": "node packages/dicom/scripts/build.mjs",
+ "dicom:check": "npm run dicom:build && node packages/dicom/scripts/check-consumers.mjs"
},
"devDependencies": {
"@commitlint/cli": "^21.0.0",
@@ -73,6 +77,7 @@
"core-js": "3.47.0",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
+ "dcmjs": "^0.52.0",
"dicom-parser": "^1.8.21",
"dicomweb-client-typed": "^0.8.6",
"eslint": "^9.39.1",
diff --git a/packages/dicom/README.md b/packages/dicom/README.md
new file mode 100644
index 000000000..6beebce2f
--- /dev/null
+++ b/packages/dicom/README.md
@@ -0,0 +1,201 @@
+# Browser DICOM module
+
+This private directory uses VolView's root install, lockfile and checks. It has
+no workspace install or release workflow. VolView imports defining source
+modules directly; the root npm distribution includes the core and vtk source files
+alongside `dist`, `src` and `backend-contract`. Package tests and standalone build
+output are excluded from that distribution.
+
+- `core/`: 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/`: `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 these modules.
+
+The package exports concrete module subpaths under `@volview/dicom/core/*` and
+`@volview/dicom/vtk/*`; there are no aggregate entry points.
+
+VolView owns its reactive wrappers, messages, browser state, selections,
+annotations, layers, import transactions and session provenance. The
+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/runtime/createDicomLoader';
+
+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.
+
+`await volume.prepareChunks(members, orientation, reconstruction)` prepares an
+allocation without changing the displayed image, emitting image events or
+decoding loaded chunks. It returns `{ changed, commit, dispose }`. `commit()`
+publishes the prepared allocation synchronously; `dispose()` releases it if it
+has not been committed. Disposing the volume also releases its pending
+allocations. An importer can prepare all affected volumes, dispose preparations
+if any fails, and publish only after every preparation succeeds. Abandoning a
+preparation leaves the displayed pixels intact. `setChunks()` combines
+preparation and publication for an immediate update.
+
+```ts
+import { createDicomCollectionRegistry } from '@volview/dicom/core/collections/collectionRegistry';
+import { VtkDicomImage } from '@volview/dicom/vtk/VtkDicomImage';
+
+const registry = createDicomCollectionRegistry();
+const { updates } = await registry.register(chunks);
+const { members, collection } = updates[0];
+const volume = new VtkDicomImage({
+ readDicomImage: loader.readDicomImage,
+ irregularGeometry: 'preserve-order',
+});
+await volume.setChunks(
+ members,
+ collection.orientation,
+ collection.reconstruction
+);
+volume.addEventListener('imageChanged', (image) => mapper.setInputData(image));
+mapper.setInputData(volume.getVtkImageData());
+volume.addEventListener('chunkLoad', () => renderWindow.render());
+volume.startLoad();
+```
+
+The collection carries reconstruction classification, reason, spacing and member
+slots. Supplied slots must start at zero because the first chunk anchors the
+image origin; gaps such as `[0, 2]` are allowed.
+
+Enhanced instances read image geometry and modality transforms from their
+specific shared and per-frame functional groups. Regular spatial frames retain
+their stored pixel order, including acquisitions whose frame direction opposes
+the row/column cross product. Single-detector reconstructed NM stacks use their
+detector patient geometry and signed slice spacing. Gated NM requires temporal
+assembly and is rejected by the vtk consumer.
+
+The default GDCM decoder applies one modality transform to the entire instance
+and rejects differing per-frame slopes or intercepts. Injected decoders may
+support those transforms. Decoding accepts optional already-parsed metadata
+that must describe the same file; otherwise the default decoder reads the
+header before decoding. Separate single-frame files may have different transforms.
+
+OCT shared in-plane spacing is preserved. Calibrated placement between B-scans
+requires a matching ophthalmic localizer and remains unsupported. Optical slice
+thickness does not define B-scan separation. Vendor-specific ultrasound volume
+calibration, RT dose semantics and floating-point parametric maps also require
+dedicated import support. VolView skips all RT modalities for both File and HTTP
+imports, including RTIMAGE and RTDOSE; these objects do not create a volume.
+
+The default `preserve-order` policy retains the existing approximate
+spacing for irregular geometry. Use `reject` to refuse that fallback. Neither
+policy resamples a scan. Multiple files containing multi-frame data, Node
+support and plugin registration are outside this package's current scope.
+
+`volume.getSpatialMetadata()` reports the applied spacing in image x/y/z order.
+Each axis records its value, unit (`mm` or `unknown`), source, and whether it was
+declared, derived from positions, or supplied as a display fallback. Reconstruction
+quality and ultrasound region limitations are separate from the unit. These are
+statements about the source and allocation, not independent calibration.
+`allocateImageFromChunks()` returns `{ image, spatialMetadata }`; the volume
+consumer also accounts for ultrasound spacing overrides.
+
+Missing physical spacing is permitted for some DICOM objects, including
+[secondary captures](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.8.html).
+Ordinary patient PixelSpacing and ImagePositionPatient use millimeters without
+requiring an additional calibration tag. A fallback value of 1 does not establish
+a millimeter scale. A singleton axis does not establish a distance between slices.
+
+VolView exposes this information through image metadata and the volume menu's
+Image spacing entry. Saved DICOM sessions retain their original sources and
+recompute the report when restored. Measurement values, annotation coordinates
+and their existing unit convention are unchanged.
+
+## Standalone checks
+
+`npm run dicom:check` builds the package and typechecks and bundles two small
+consumers against its emitted JavaScript and declarations. The build rejects
+VolView application imports and Vue/Pinia dependencies, and rejects vtk.js in
+the core consumer. Package boundary tests also check relative imports and
+declared dependencies.
+
+`npm run test:e2e:chrome:skip-build -- --spec tests/specs/dicom-package.e2e.ts`
+serves those standalone production bundles, generates its DICOM in memory, and
+checks Blob/HTTP metadata, calibrated pixel values and vtk geometry through
+rendered page output. It also checks the vtk image's rendered screenshot. The
+normal headless browser configuration applies.
+
+`tests/specs/dicom-volume-corpus.e2e.ts` exercises pinned public CT, MR and SPECT
+files through both File and HTTP sources, plus numeric-padding fixtures. It
+checks dimensions, physical geometry, explicit unsupported-topology errors and
+the hash of every calibrated voxel through rendered consumer output. It also
+imports enhanced CT and both SPECT volumes in VolView's UI. Public URLs, input
+checksums, independently decoded pixel hashes and provenance are recorded in
+`tests/dicomVolumeCorpus.json`; downloads use the existing ignored dataset cache.
+
+`tests/specs/dicom-volume-composition.e2e.ts` composes downloaded classic CT/PET
+series and two MR acquisitions sharing one Series UID. It checks every
+collection's ordered SOP membership, complete calibrated voxel hash and
+physical geometry after shuffled batch and incremental File/HTTP imports.
+`tests/dicomCompositionCorpus.json` records the pinned inputs and independent
+expectations. The optional `compositionReference.py` beside the consumer
+fixtures reproduces those expectations using pydicom and NumPy from the cache.
diff --git a/packages/dicom/__tests__/boundaries.spec.ts b/packages/dicom/__tests__/boundaries.spec.ts
new file mode 100644
index 000000000..9acba20c0
--- /dev/null
+++ b/packages/dicom/__tests__/boundaries.spec.ts
@@ -0,0 +1,95 @@
+import { existsSync, 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.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 imports defining core and vtk modules directly', () => {
+ const repository = resolve(root, '../..');
+ for (const file of sourceFiles(resolve(repository, 'src'))) {
+ for (const specifier of importsOf(file)) {
+ const target = specifier.startsWith('@/')
+ ? resolve(repository, specifier.slice(2))
+ : resolve(dirname(file), specifier);
+ if (!target.startsWith(`${root}/`)) continue;
+ const module = relative(root, target).replace(/\.ts$/, '');
+ expect(module, relative(repository, file)).toMatch(/^(core|vtk)\/.+/);
+ expect(module.endsWith('/index'), relative(repository, file)).toBe(false);
+ expect(existsSync(`${target.replace(/\.ts$/, '')}.ts`)).toBe(true);
+ }
+ }
+});
diff --git a/packages/dicom/core/collections/__tests__/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..481d3b654
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/collectionRegistry.spec.ts
@@ -0,0 +1,707 @@
+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));
+
+const collidingMultiframeChunks = (series = SERIES_UID) =>
+ ['1', null].map((sop) =>
+ chunkFor({ sop, series, tags: { [Tags.NumberOfFrames]: '2' } })
+ );
+
+describe('DICOM collection registry', () => {
+ it('plans one consistent series into one ordered collection', async () => {
+ const registry = createDicomCollectionRegistry();
+ 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('preserves existing ownership when a failed plan precedes a healthy import', async () => {
+ const registry = createDicomCollectionRegistry();
+ const [original, invalid] = collidingMultiframeChunks();
+ const [{ id }] = (await registry.register([original])).updates;
+ const healthy = chunkFor({ sop: '1.2.3.4' });
+
+ const failed = registry.register([invalid]);
+ const next = registry.register([healthy]);
+
+ await expect(failed).rejects.toThrow(/two collections share the key/);
+ const { updates } = await next;
+ expect(updates).toHaveLength(1);
+ expect(updates[0].members).toEqual([healthy]);
+ expect(registry.has(invalid)).toBe(false);
+ expect(registry.forget(id)).toEqual([original]);
+ expect(registry.forget(updates[0].id)).toEqual([healthy]);
+ });
+
+ it('leaves every series unchanged when one series in a batch cannot be planned', async () => {
+ const registry = createDicomCollectionRegistry();
+ const original = chunkFor({ sop: 'original', z: 0 });
+ const [{ id }] = (await registry.register([original])).updates;
+ const incoming = chunkFor({ sop: 'incoming', z: 1 });
+ const invalid = collidingMultiframeChunks(OTHER_SERIES_UID);
+
+ await expect(registry.register([incoming, ...invalid])).rejects.toThrow(
+ /two collections share the key/
+ );
+
+ [incoming, ...invalid].forEach((chunk) =>
+ expect(registry.has(chunk)).toBe(false)
+ );
+ const replacement = chunkFor({ sop: 'replacement', z: 2 });
+ const [update] = (await registry.register([replacement])).updates;
+ expect(update.id).toBe(id);
+ expect(update.members).toEqual([original, replacement]);
+ expect(registry.forget(id)).toEqual([original, replacement]);
+ });
+
+ it('surfaces the planner order and diagnostics for a positionless series', async () => {
+ const registry = createDicomCollectionRegistry();
+ const chunks = [
+ chunkFor({ sop: 'alpha', z: null, instanceNumber: 2 }),
+ chunkFor({ sop: 'beta', z: null, instanceNumber: 1 }),
+ ];
+
+ const [update] = (await registry.register(chunks)).updates;
+
+ expect(sops(update.members)).toEqual(['beta', 'alpha']);
+ expect(update.collection.order).toBe('instance-number');
+ const diagnostics = update.collection.diagnostics.join(' ');
+ expect(diagnostics).toContain('alpha');
+ expect(diagnostics).toContain('beta');
+ });
+
+ it('refuses a chunk whose metadata has not been read', async () => {
+ const registry = createDicomCollectionRegistry();
+ const chunk = { metadata: null } as unknown as Chunk;
+
+ await expect(registry.register([chunk])).rejects.toThrow(/metadata/i);
+ });
+
+ it('does not mutate the batch it registers', async () => {
+ const registry = createDicomCollectionRegistry();
+ const chunks = [chunkFor({ sop: 'a', z: 2 }), chunkFor({ sop: 'b', z: 0 })];
+ const order = [...chunks];
+
+ await registry.register(chunks);
+
+ expect(chunks).toEqual(order);
+ });
+});
+
+describe('registry normalization and conflicting identities', () => {
+ it('recognizes a committed instance with repeated NUL padding', async () => {
+ const registry = createDicomCollectionRegistry();
+ const chunk = chunkFor({ sop: '1.2.3\0\0' });
+ await registry.register([chunk]);
+ expect(registry.has(chunk)).toBe(true);
+ });
+
+ it('reports conflicting copies rather than silently discarding a different slice', async () => {
+ const registry = createDicomCollectionRegistry();
+ const original = chunkFor({ sop: 'same', z: 0 });
+ const conflicting = chunkFor({ sop: 'same', z: 1 });
+ await registry.register([original]);
+ const result = await registry.register([conflicting]);
+ expect(result.conflictingInstanceUids).toEqual(['same']);
+ expect(result.updates[0].members).toEqual([original]);
+ expect(result.updates[0].provenance).toEqual([original]);
+ expect(registry.has(original)).toBe(true);
+ expect(registry.has(conflicting)).toBe(false);
+ });
+
+ it('keeps slices with absent and explicit single-frame counts in one volume', async () => {
+ const registry = createDicomCollectionRegistry();
+ const result = await registry.register([
+ chunkFor({ sop: 'first', z: 0 }),
+ chunkFor({ sop: 'second', z: 1, tags: { [Tags.NumberOfFrames]: '1' } }),
+ ]);
+ expect(result.updates).toHaveLength(1);
+ expect(result.updates[0].members).toHaveLength(2);
+ expect(result.updates[0].collection.reconstruction.sliceSpacing).toBe(1);
+ });
+});
diff --git a/packages/dicom/core/collections/__tests__/dicomTags.spec.ts b/packages/dicom/core/collections/__tests__/dicomTags.spec.ts
new file mode 100644
index 000000000..65b04821a
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/dicomTags.spec.ts
@@ -0,0 +1,14 @@
+import { describe, it, expect } from 'vitest';
+import { NAME_TO_TAG, TAG_TO_NAME, Tags } from '../../metadata/dicomTags';
+
+describe('dicomTags', () => {
+ it.each([
+ ['SliceThickness', '0018|0050'],
+ ['SequenceName', '0018|0024'],
+ ['SeriesDate', '0008|0021'],
+ ])('names %s', (name, tag) => {
+ expect(Tags[name]).toBe(tag);
+ expect(NAME_TO_TAG.get(name)).toBe(tag);
+ expect(TAG_TO_NAME.get(tag)).toBe(name);
+ });
+});
diff --git a/packages/dicom/core/collections/__tests__/idcSeriesFixtures.ts b/packages/dicom/core/collections/__tests__/idcSeriesFixtures.ts
new file mode 100644
index 000000000..4d87b9fd4
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/idcSeriesFixtures.ts
@@ -0,0 +1,202 @@
+import { Tags } from '../../metadata/dicomTags';
+
+/**
+ * Real Imaging Data Commons (IDC) series behind the acquisition-split rules.
+ *
+ * Entries are keyed by SeriesInstanceUID: bucket paths, folder UUIDs, and
+ * portal URLs all change between IDC releases, the UID does not. Fetch one by
+ * searching the UID at portal.imaging.datacommons.cancer.gov, or with the
+ * idc-index package:
+ *
+ * from idc_index import IDCClient
+ * IDCClient().download_from_selection(
+ * seriesInstanceUID='', downloadDir='.')
+ *
+ * `groups` mirrors the real per-group tag values and slice layout, with
+ * positions projected onto the slice normal, in mm.
+ */
+
+export type SliceGroup = {
+ /** Tag values stamped on every slice of the group, keyed by 'gggg|eeee'. */
+ tags: Record;
+ /** Slice positions along the normal, in mm. */
+ zs: number[];
+};
+
+export type IdcSeriesFixture = {
+ seriesInstanceUID: string;
+ seriesDescription: string;
+ vendor: string;
+ /** What the series is and why it forces the rule it tests. */
+ why: string;
+ groups?: SliceGroup[];
+};
+
+const steps = (start: number, count: number, step: number) =>
+ Array.from({ length: count }, (_, i) => start + i * step);
+
+export const dceEightPhase: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.7695.4164.334885423614895574619945040495',
+ seriesDescription: 'ISPY2: VOLSER: uni-lateral cropped: original DCE',
+ vendor: 'GE',
+ why:
+ 'DCE breast MR: eight timepoints re-scan one identical 80-slice Z range ' +
+ 'in a single series. No AcquisitionNumber at all; only ' +
+ 'TemporalPositionIdentifier separates the timepoints. Loaded merged, ' +
+ 'every position appears eight times.',
+ groups: steps(0, 8, 1).map((phase) => ({
+ tags: { [Tags.TemporalPositionIdentifier]: String(phase) },
+ zs: steps(-81.198, 80, 2.0),
+ })),
+};
+
+export const dceSevenPhasePhilips: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.7695.4164.327395145625810010102069976322',
+ seriesDescription: 'ISPY2: 15ML OMNI T1 FS DYN SENSE 5',
+ vendor: 'Philips',
+ why:
+ 'DCE breast MR: seven timepoints (TemporalPositionIdentifier 1 through ' +
+ '7), 158 slices each over one Z range. Shows the split holds across ' +
+ 'vendors and with 1-based numbering.',
+ groups: steps(1, 7, 1).map((phase) => ({
+ tags: { [Tags.TemporalPositionIdentifier]: String(phase) },
+ zs: steps(-83.107, 158, 1.0),
+ })),
+};
+
+export const dixonDualEcho: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.9203.4004.413147664284321913537786821717',
+ seriesDescription: 'T1 AX IN_OUT_160 TR MBH',
+ vendor: 'Siemens',
+ why:
+ 'In-phase/opposed-phase Dixon in one series: two echoes (TE 2.4/5.04) ' +
+ 'over one identical 34-slice range, one AcquisitionNumber, no ' +
+ 'TemporalPositionIdentifier. Only EchoNumbers separates them; the ' +
+ 'categorize pipeline cannot (SequenceName is *fl2d2 for both echoes).',
+ groups: ['1', '2'].map((echo) => ({
+ tags: {
+ [Tags.AcquisitionNumber]: '1',
+ [Tags.EchoNumbers]: echo,
+ },
+ zs: steps(-107.502, 34, 7.8),
+ })),
+};
+
+export const dixonUnevenEchoes: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.1620.1226.196512451812657389463383990773',
+ seriesDescription: 'AXL_IN_OUT_ABD',
+ vendor: 'Siemens',
+ why:
+ 'Dixon dual-echo where echo 1 has 13 slices and echo 2 has 12 over the ' +
+ 'same range; unequal group sizes must still split.',
+ groups: [
+ {
+ tags: { [Tags.AcquisitionNumber]: '1', [Tags.EchoNumbers]: '1' },
+ zs: steps(0, 13, 7.8),
+ },
+ {
+ tags: { [Tags.AcquisitionNumber]: '1', [Tags.EchoNumbers]: '2' },
+ zs: steps(0, 12, 7.8),
+ },
+ ],
+};
+
+export const dwiMultiBValue: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.7695.4164.198361347871486858845671445367',
+ seriesDescription: 'ACRIN-6698: DWI_Multi_bValue',
+ vendor: 'Philips',
+ why:
+ 'Diffusion MR: 30 positions repeated at b-values 0, 100, 600, and 800. ' +
+ 'AcquisitionNumber, TemporalPositionIdentifier, and EchoNumbers are ' +
+ 'constant, so DiffusionBValue is the only standard discriminator.',
+ groups: [0, 100, 600, 800].map((bValue) => ({
+ tags: {
+ [Tags.AcquisitionNumber]: '6',
+ [Tags.TemporalPositionIdentifier]: '1',
+ [Tags.EchoNumbers]: '1',
+ [Tags.DiffusionBValue]: String(bValue),
+ },
+ zs: steps(-67.0785, 30, 4.0),
+ })),
+};
+
+export const bilateralSagittalSlabs: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.66737955842913643997059729379406867951',
+ seriesDescription: 'ISPY2: SAG IR',
+ vendor: 'GE',
+ why:
+ 'Bilateral sagittal breast slabs: StackID 1 and 2 cover two 4mm stacks ' +
+ 'with a 42mm gap, one orientation, one acquisition, all positions ' +
+ 'distinct. This series is why StackID is NOT a split discriminator: ' +
+ 'nothing overlaps, so the overlap gate keeps a StackID-like tag from ' +
+ 'tearing it apart. One collection is not one regular volume, though: no ' +
+ 'even lattice holds both slabs, so the reconstruction reports an ' +
+ 'irregular stack and warns, rather than fitting the endpoints to a ' +
+ 'fabricated 4.59mm spacing.',
+ groups: [
+ {
+ tags: { [Tags.AcquisitionNumber]: '1', '0020|9056': '1' },
+ zs: steps(-156.488, 31, 4.0),
+ },
+ {
+ tags: { [Tags.AcquisitionNumber]: '1', '0020|9056': '2' },
+ zs: steps(5.951, 35, 4.0),
+ },
+ ],
+};
+
+export const partialTemporalTags: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.7695.4164.232713885104107279235403827078',
+ seriesDescription: 'ISPY2: Ax Vibrant PRE/POST',
+ vendor: 'GE',
+ why:
+ 'Seven acquisitions re-scanning one range, but ' +
+ 'TemporalPositionIdentifier is present on only part of the slices. ' +
+ 'AcquisitionNumber must do the split, and the incomplete temporal tag ' +
+ 'must not sub-split or block anything.',
+ groups: [
+ {
+ tags: {
+ [Tags.AcquisitionNumber]: '1',
+ [Tags.TemporalPositionIdentifier]: '1',
+ },
+ zs: steps(-60, 36, 2.0),
+ },
+ // The rest of acquisition 1 carries no temporal tag at all.
+ { tags: { [Tags.AcquisitionNumber]: '1' }, zs: steps(12, 36, 2.0) },
+ ...steps(2, 6, 1).map((acq) => ({
+ tags: {
+ [Tags.AcquisitionNumber]: String(acq),
+ [Tags.TemporalPositionIdentifier]: String(acq),
+ },
+ zs: steps(-60, 72, 2.0),
+ })),
+ ],
+};
+
+export const doubledAcquisition: IdcSeriesFixture = {
+ seriesInstanceUID:
+ '1.3.6.1.4.1.14519.5.2.1.7695.1700.103847508594300579711113977161',
+ seriesDescription: 'IR-SPGR-SAG',
+ vendor: 'GE',
+ why:
+ 'Acquisition 1 holds 74 slices; acquisition 2 holds 148 slices at those ' +
+ 'same 74 positions. The split is right and the acquisition-2 volume must ' +
+ 'still carry the duplicate-position warning. Same shape in IDC series ' +
+ '1.3.6.1.4.1.14519.5.2.1.7695.2311.133871731749882407434486842181 ' +
+ '(LEFT - Dynamic-3dfgre, acquisition values 0 and 2).',
+ groups: [
+ { tags: { [Tags.AcquisitionNumber]: '1' }, zs: steps(-214.177, 74, 2.5) },
+ {
+ tags: { [Tags.AcquisitionNumber]: '2' },
+ zs: [...steps(-214.177, 74, 2.5), ...steps(-214.177, 74, 2.5)],
+ },
+ ],
+};
diff --git a/packages/dicom/core/collections/__tests__/instanceFacts.spec.ts b/packages/dicom/core/collections/__tests__/instanceFacts.spec.ts
new file mode 100644
index 000000000..c200bf49f
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/instanceFacts.spec.ts
@@ -0,0 +1,404 @@
+import { datasetFromTags } from '@/tests/dicomDataset';
+import { describe, expect, it } from 'vitest';
+import { Tags } from '../../metadata/dicomTags';
+import {
+ readInstanceFacts as readStructuredFacts,
+ seriesKeyOf as structuredSeriesKeyOf,
+} from '../instanceFacts';
+
+const INSTANCE_NUMBER_TAG = '0020|0013';
+
+// readDicomTags hands back DICOM's own padding, so every fixture value here is
+// written the way the reader returns it.
+const metadata = (overrides: Record = {}) => {
+ const entries: Array<[string, string]> = [
+ [Tags.SOPInstanceUID, '1.2.3.4'],
+ [Tags.SeriesInstanceUID, '1.2.3'],
+ [Tags.StudyInstanceUID, '1.2'],
+ [Tags.PatientID, 'patient-1'],
+ [Tags.SeriesNumber, '1 '],
+ [Tags.SliceThickness, '1 '],
+ [Tags.SeriesDate, '20240101'],
+ [Tags.Rows, '4'],
+ [Tags.Columns, '6'],
+ [Tags.SamplesPerPixel, '1'],
+ [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0 '],
+ [Tags.ImagePositionPatient, '10\\20\\30 '],
+ [Tags.PixelSpacing, '0.5\\0.75'],
+ [INSTANCE_NUMBER_TAG, '5 '],
+ ];
+ Object.entries(overrides).forEach(([tag, value]) => {
+ const existing = entries.find((entry) => entry[0] === tag);
+ if (existing) existing[1] = value;
+ else entries.push([tag, value]);
+ });
+ return entries;
+};
+
+const without = (tag: string) => metadata().filter((entry) => entry[0] !== tag);
+
+describe('readInstanceFacts', () => {
+ it('names the instance number tag the planner orders on', () => {
+ expect(Tags.InstanceNumber).toBe(INSTANCE_NUMBER_TAG);
+ });
+
+ it('reads every fact the planner compares', () => {
+ expect(readInstanceFacts(metadata())).toEqual({
+ sopInstanceUid: '1.2.3.4',
+ seriesInstanceUid: '1.2.3',
+ seriesNumber: '1',
+ sequenceName: null,
+ sliceThickness: '1',
+ seriesDate: '20240101',
+ rows: '4',
+ columns: '6',
+ samplesPerPixel: '1',
+ numberOfFrames: '1',
+ orientation: [1, 0, 0, 0, 1, 0],
+ position: [10, 20, 30],
+ projectedPosition: 30,
+ pixelSpacing: [0.5, 0.75],
+ instanceNumber: 5,
+ acquisitionNumber: null,
+ temporalPositionIdentifier: null,
+ echoNumbers: null,
+ diffusionBValue: null,
+ });
+ });
+
+ it('reads an instance with no tags at all as entirely unknown', () => {
+ expect(readInstanceFacts([])).toEqual({
+ sopInstanceUid: null,
+ seriesInstanceUid: null,
+ seriesNumber: null,
+ sequenceName: null,
+ sliceThickness: null,
+ seriesDate: null,
+ rows: null,
+ columns: null,
+ samplesPerPixel: null,
+ numberOfFrames: '1',
+ orientation: null,
+ position: null,
+ projectedPosition: null,
+ pixelSpacing: null,
+ instanceNumber: null,
+ acquisitionNumber: null,
+ temporalPositionIdentifier: null,
+ echoNumbers: null,
+ diffusionBValue: null,
+ });
+ });
+
+ it.each([
+ ['SeriesNumber', Tags.SeriesNumber, 'seriesNumber'],
+ ['SequenceName', Tags.SequenceName, 'sequenceName'],
+ ['SliceThickness', Tags.SliceThickness, 'sliceThickness'],
+ ['SeriesDate', Tags.SeriesDate, 'seriesDate'],
+ ['Rows', Tags.Rows, 'rows'],
+ ['Columns', Tags.Columns, 'columns'],
+ ['SamplesPerPixel', Tags.SamplesPerPixel, 'samplesPerPixel'],
+ ] as const)(
+ 'reads a zero length %s as absent, not as an empty string',
+ (_name, tag, fact) => {
+ expect(readInstanceFacts(metadata({ [tag]: '' }))[fact]).toBeNull();
+ expect(readInstanceFacts(metadata({ [tag]: ' ' }))[fact]).toBeNull();
+ }
+ );
+
+ it.each([
+ ['SeriesNumber', Tags.SeriesNumber, 'seriesNumber'],
+ ['SliceThickness', Tags.SliceThickness, 'sliceThickness'],
+ ['Rows', Tags.Rows, 'rows'],
+ ['Columns', Tags.Columns, 'columns'],
+ ['SamplesPerPixel', Tags.SamplesPerPixel, 'samplesPerPixel'],
+ ['NumberOfFrames', Tags.NumberOfFrames, 'numberOfFrames'],
+ ] as const)(
+ 'normalizes the numeric spelling of %s so 04 and 4.0 are one value',
+ (_name, tag, fact) => {
+ const readFact = (value: string) =>
+ readInstanceFacts(metadata({ [tag]: value }))[fact];
+ expect(readFact('4')).toBe('4');
+ expect(readFact('04')).toBe('4');
+ expect(readFact('4.0')).toBe('4');
+ expect(readFact(' 4 ')).toBe('4');
+ expect(readFact('1.50')).toBe('1.5');
+ }
+ );
+
+ // A study date's leading zeros are part of the value, so it stays text.
+ it('keeps a series date as text rather than as a number', () => {
+ expect(
+ readInstanceFacts(metadata({ [Tags.SeriesDate]: '00240101 ' })).seriesDate
+ ).toBe('00240101');
+ });
+
+ it('keeps a sequence name as text', () => {
+ expect(
+ readInstanceFacts(metadata({ [Tags.SequenceName]: '007 ' })).sequenceName
+ ).toBe('007');
+ });
+
+ it('trims the padding DICOM adds to odd length identifiers', () => {
+ const facts = readInstanceFacts(
+ metadata({
+ [Tags.SOPInstanceUID]: '1.2.3.4 ',
+ [Tags.SeriesInstanceUID]: '1.2.3 ',
+ })
+ );
+ expect(facts.sopInstanceUid).toBe('1.2.3.4');
+ expect(facts.seriesInstanceUid).toBe('1.2.3');
+ });
+
+ it.each([
+ ['a missing tag', without(Tags.ImageOrientationPatient)],
+ [
+ 'too few cosines',
+ metadata({ [Tags.ImageOrientationPatient]: '1\\0\\0' }),
+ ],
+ [
+ 'a non numeric cosine',
+ metadata({ [Tags.ImageOrientationPatient]: '1\\0\\0\\0\\x\\0' }),
+ ],
+ ])('reads an orientation with %s as unknown', (_case, entries) => {
+ const facts = readInstanceFacts(entries);
+ expect(facts.orientation).toBeNull();
+ expect(facts.projectedPosition).toBeNull();
+ });
+
+ it.each([
+ ['a missing tag', without(Tags.ImagePositionPatient)],
+ ['too few coordinates', metadata({ [Tags.ImagePositionPatient]: '1\\2' })],
+ [
+ 'a non numeric coordinate',
+ metadata({ [Tags.ImagePositionPatient]: '1\\2\\z' }),
+ ],
+ ])('reads a position with %s as unknown', (_case, entries) => {
+ const facts = readInstanceFacts(entries);
+ expect(facts.position).toBeNull();
+ expect(facts.projectedPosition).toBeNull();
+ });
+
+ it.each([
+ ['a blank component', '1\\\\3'],
+ ['a trailing blank component', '1\\2\\'],
+ ['a whitespace component', '1\\ \\3'],
+ ])('reads a position with %s as unknown, not as a zero', (_case, value) => {
+ const facts = readInstanceFacts(
+ metadata({ [Tags.ImagePositionPatient]: value })
+ );
+ expect(facts.position).toBeNull();
+ expect(facts.projectedPosition).toBeNull();
+ });
+
+ it.each([
+ ['an all zero basis', '0\\0\\0\\0\\0\\0'],
+ ['a zero length row', '0\\0\\0\\0\\1\\0'],
+ ['a row and column that are the same axis', '1\\0\\0\\1\\0\\0'],
+ ['a row and column 45 degrees apart', '1\\0\\0\\0.7071\\0.7071\\0'],
+ ['a blank cosine', '1\\0\\0\\0\\\\0'],
+ ])('reads %s as no orientation at all', (_case, value) => {
+ const facts = readInstanceFacts(
+ metadata({ [Tags.ImageOrientationPatient]: value })
+ );
+ expect(facts.orientation).toBeNull();
+ expect(facts.projectedPosition).toBeNull();
+ });
+
+ // A DS value carries a printed decimal, so exact unit length is not on offer.
+ it('normalizes cosines that are within the tolerance of unit length', () => {
+ const facts = readInstanceFacts(
+ metadata({
+ [Tags.ImageOrientationPatient]: '0.99998\\0\\0\\0\\1.00002\\0',
+ })
+ );
+
+ expect(facts.orientation).not.toBeNull();
+ expect(facts.orientation![0]).toBeCloseTo(1, 12);
+ expect(facts.orientation![4]).toBeCloseTo(1, 12);
+ expect(facts.projectedPosition).toBeCloseTo(30, 12);
+ });
+
+ // GDCM rescales whatever cosines it is given, so a basis printed to three
+ // decimals or written at the wrong length loads as the scanner meant it
+ // rather than landing in the unreadable bucket.
+ it.each([
+ [
+ 'an oblique printed to three decimals',
+ '0.707\\0.707\\0\\-0.707\\0.707\\0',
+ [0.7071, 0.7071, 0, -0.7071, 0.7071, 0],
+ ],
+ [
+ 'a row written at twice unit length',
+ '2\\0\\0\\0\\1\\0',
+ [1, 0, 0, 0, 1, 0],
+ ],
+ ])('rescales %s to unit axes', (_case, value, expected) => {
+ const facts = readInstanceFacts(
+ metadata({ [Tags.ImageOrientationPatient]: value })
+ );
+
+ expect(facts.orientation).not.toBeNull();
+ facts.orientation!.forEach((cosine, index) =>
+ expect(cosine).toBeCloseTo(expected[index], 3)
+ );
+ expect(facts.projectedPosition).toBeCloseTo(30, 10);
+ });
+
+ it('accepts cosines a printed digit away from square', () => {
+ const facts = readInstanceFacts(
+ metadata({
+ [Tags.ImageOrientationPatient]: '0.7071\\0.7071\\0\\-0.7072\\0.7070\\0',
+ })
+ );
+
+ expect(facts.orientation).not.toBeNull();
+ expect(facts.projectedPosition).toBeCloseTo(30, 3);
+ });
+
+ it.each([
+ ['zero', '0\\0.5'],
+ ['negative', '-1\\0.5'],
+ ['blank', '\\0.5'],
+ ])('reads a %s pixel spacing as unknown', (_case, value) => {
+ expect(
+ readInstanceFacts(metadata({ [Tags.PixelSpacing]: value })).pixelSpacing
+ ).toBeNull();
+ });
+
+ it('projects the position onto the slice normal, not onto z', () => {
+ // Rows run along +y and columns along +z, so the normal is +x.
+ const facts = readInstanceFacts(
+ metadata({ [Tags.ImageOrientationPatient]: '0\\1\\0\\0\\0\\1' })
+ );
+ expect(facts.projectedPosition).toBeCloseTo(10, 10);
+ });
+
+ it('projects onto the normal with the cosines in row then column order', () => {
+ // Swapping the two rows of the case above flips the normal to -x.
+ const facts = readInstanceFacts(
+ metadata({ [Tags.ImageOrientationPatient]: '0\\0\\1\\0\\1\\0' })
+ );
+ expect(facts.projectedPosition).toBeCloseTo(-10, 10);
+ });
+
+ it.each([
+ ['a missing tag', without(Tags.PixelSpacing), null],
+ ['a single value', metadata({ [Tags.PixelSpacing]: '0.5' }), null],
+ [
+ 'a non numeric value',
+ metadata({ [Tags.PixelSpacing]: '0.5\\bad' }),
+ null,
+ ],
+ ])('reads a pixel spacing with %s as unknown', (_case, entries) => {
+ expect(readInstanceFacts(entries).pixelSpacing).toBeNull();
+ });
+
+ it.each([
+ ['5 ', 5],
+ ['+5', 5],
+ ['-3', -3],
+ ])('reads the instance number %s as a number', (value, expected) => {
+ expect(
+ readInstanceFacts(metadata({ [INSTANCE_NUMBER_TAG]: value }))
+ .instanceNumber
+ ).toBe(expected);
+ });
+
+ it.each([
+ ['a missing tag', without(INSTANCE_NUMBER_TAG)],
+ ['an empty value', metadata({ [INSTANCE_NUMBER_TAG]: ' ' })],
+ ['a non numeric value', metadata({ [INSTANCE_NUMBER_TAG]: 'first' })],
+ ])('reads an instance number with %s as unknown', (_case, entries) => {
+ expect(readInstanceFacts(entries).instanceNumber).toBeNull();
+ });
+
+ it('does not mutate the metadata it reads', () => {
+ const entries = metadata();
+ const before = JSON.parse(JSON.stringify(entries));
+ readInstanceFacts(entries);
+ expect(entries).toEqual(before);
+ });
+});
+
+describe('seriesKeyOf', () => {
+ it('is the series instance uid so a collection id carries it as a prefix', () => {
+ expect(seriesKeyOf(metadata())).toBe('1.2.3');
+ expect(seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '1.2.3 ' }))).toBe(
+ '1.2.3'
+ );
+ });
+
+ it('separates two series that differ only in series instance uid', () => {
+ expect(
+ seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '1.2.4' }))
+ ).not.toBe(seriesKeyOf(metadata()));
+ });
+
+ it('never reuses a series key for an instance that has no series uid', () => {
+ const anonymous = seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '' }));
+ expect(anonymous).not.toBe('');
+ expect(anonymous).not.toBe(seriesKeyOf(metadata()));
+ });
+
+ it('falls back to the study and patient when the series uid is absent', () => {
+ const key = (overrides: Record) =>
+ seriesKeyOf(metadata({ [Tags.SeriesInstanceUID]: '', ...overrides }));
+
+ expect(key({})).toBe(key({}));
+ expect(key({ [Tags.StudyInstanceUID]: '9.9' })).not.toBe(key({}));
+ expect(key({ [Tags.PatientID]: 'patient-2' })).not.toBe(key({}));
+ });
+
+ // Concatenation alone would tie these two, since only the split moves.
+ it('escapes the fallback parts so a moved separator changes the key', () => {
+ const key = (study: string, patient: string) =>
+ seriesKeyOf(
+ metadata({
+ [Tags.SeriesInstanceUID]: '',
+ [Tags.StudyInstanceUID]: study,
+ [Tags.PatientID]: patient,
+ })
+ );
+ expect(key('a|b', 'c')).not.toBe(key('a', 'b|c'));
+ expect(key('a:b', 'c')).not.toBe(key('a', 'b:c'));
+ });
+
+ it('does not mutate the metadata it reads', () => {
+ const entries = metadata();
+ const before = JSON.parse(JSON.stringify(entries));
+ seriesKeyOf(entries);
+ expect(entries).toEqual(before);
+ });
+});
+
+function readInstanceFacts(tags: Iterable) {
+ return readStructuredFacts(datasetFromTags(tags));
+}
+function seriesKeyOf(tags: Iterable) {
+ return structuredSeriesKeyOf(datasetFromTags(tags));
+}
+
+describe('single-frame and spacing normalization', () => {
+ it.each([undefined, '', ' ', '1', '01'])(
+ 'defaults NumberOfFrames %s to one',
+ (frames) => {
+ expect(
+ readStructuredFacts(
+ datasetFromTags(
+ frames === undefined ? [] : [[Tags.NumberOfFrames, frames]]
+ )
+ ).numberOfFrames
+ ).toBe('1');
+ }
+ );
+
+ it.each(['0.5\\0.75\\2.5', '0.5\\0.75\\'])(
+ 'retains the first two PixelSpacing values in %s',
+ (value) => {
+ expect(
+ readInstanceFacts(metadata({ [Tags.PixelSpacing]: value })).pixelSpacing
+ ).toEqual([0.5, 0.75]);
+ }
+ );
+});
diff --git a/packages/dicom/core/collections/__tests__/instanceFactsFixture.ts b/packages/dicom/core/collections/__tests__/instanceFactsFixture.ts
new file mode 100644
index 000000000..7e22ea469
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/instanceFactsFixture.ts
@@ -0,0 +1,60 @@
+import type { InstanceFacts } from '../planDicomCollections';
+
+export const IDENTITY_ORIENTATION = [1, 0, 0, 0, 1, 0];
+
+// Rotates the second orientation row by theta, leaving both rows unit length.
+export const tiltedOrientation = (theta: number) => [
+ 1,
+ 0,
+ 0,
+ 0,
+ Math.cos(theta),
+ Math.sin(theta),
+];
+
+export const orientationKeyValue = (orientation: number[]) =>
+ orientation.map(String).join(',');
+
+// The position and its projection are one fact, so a fixture naming only the
+// projection gets the axial position that produces it.
+const positionFor = (overrides: Partial) => {
+ if ('position' in overrides) return overrides.position ?? null;
+ const projected =
+ 'projectedPosition' in overrides ? overrides.projectedPosition : 0;
+ return projected === null || projected === undefined
+ ? null
+ : [0, 0, projected];
+};
+
+const defaults = (sopInstanceUid: string | null): InstanceFacts => ({
+ sopInstanceUid,
+ seriesInstanceUid: 'series-1',
+ seriesNumber: '1',
+ sequenceName: null,
+ sliceThickness: '1',
+ seriesDate: '20240101',
+ rows: '4',
+ columns: '4',
+ samplesPerPixel: '1',
+ numberOfFrames: null,
+ orientation: IDENTITY_ORIENTATION,
+ position: [0, 0, 0],
+ projectedPosition: 0,
+ pixelSpacing: [1, 1],
+ instanceNumber: 1,
+ acquisitionNumber: null,
+ temporalPositionIdentifier: null,
+ echoNumbers: null,
+ diffusionBValue: null,
+});
+
+export const makeFacts = (
+ sopInstanceUid: string | null,
+ overrides: Partial = {}
+): InstanceFacts => ({
+ ...defaults(sopInstanceUid),
+ ...overrides,
+ position: positionFor(overrides),
+});
+
+export const clone = (value: unknown) => JSON.parse(JSON.stringify(value));
diff --git a/packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts b/packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts
new file mode 100644
index 000000000..a6c895e83
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/planDicomCollections.spec.ts
@@ -0,0 +1,879 @@
+import { describe, it, expect } from 'vitest';
+import {
+ HARD_FACT_RULES,
+ ORIENTATION_RULE,
+ ORIENTATION_TOLERANCE,
+ IN_PLANE_SHIFT_WARNING,
+ NONPARALLEL_SLICES_WARNING,
+ IRREGULAR_VOLUME_WARNING,
+ REPEATED_POSITIONS_WARNING,
+ UNREADABLE_POSITION_WARNING,
+ planDicomCollections,
+ validateCollections,
+} from '../planDicomCollections';
+import { SEMANTIC_AXES } from '../splitOverlappingAcquisitions';
+import type { DicomCollection, InstanceFacts } from '../planDicomCollections';
+import {
+ IDENTITY_ORIENTATION,
+ clone,
+ makeFacts,
+ orientationKeyValue,
+ tiltedOrientation,
+} from './instanceFactsFixture';
+
+const SERIES_KEY = 'series-1';
+
+const plan = (instances: InstanceFacts[], seriesKey = SERIES_KEY) =>
+ planDicomCollections({ seriesKey, instances });
+
+const uidsOf = (collection: DicomCollection) =>
+ collection.members.map((m) => m.sopInstanceUid);
+
+function collectionWith(collections: DicomCollection[], uid: string) {
+ const found = collections.find((c) =>
+ c.members.some((m) => m.sopInstanceUid === uid)
+ );
+ if (!found) throw new Error(`no collection holds ${uid}`);
+ return found;
+}
+
+const partValue = (collection: DicomCollection, rule: string) => {
+ const part = collection.key.parts.find(([name]) => name === rule);
+ if (!part) throw new Error(`no key part named ${rule}`);
+ return part[1];
+};
+
+const mentions = (diagnostics: string[], needle: string) =>
+ diagnostics.some((d) => d.includes(needle));
+
+// Second orientation row rotations, measured against the dot-product tolerance.
+const WITHIN = Math.acos(1 - ORIENTATION_TOLERANCE / 2);
+const BEYOND = Math.acos(1 - ORIENTATION_TOLERANCE * 2);
+// cos(CHAIN) is inside the tolerance while cos(2 * CHAIN) is outside it.
+const CHAIN = Math.sqrt(ORIENTATION_TOLERANCE);
+
+describe('planDicomCollections', () => {
+ it('plans nothing for an empty instance list', () => {
+ expect(plan([])).toEqual({ collections: [] });
+ });
+
+ it('keeps one consistent series in a single collection', () => {
+ const a = makeFacts('uid-a', { projectedPosition: 0, instanceNumber: 1 });
+ const b = makeFacts('uid-b', { projectedPosition: 1, instanceNumber: 2 });
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].key.seriesKey).toBe(SERIES_KEY);
+ expect(collections[0].members[0]).toBe(a);
+ expect(collections[0].members[1]).toBe(b);
+ expect(collections[0].order).toBe('spatial');
+ expect(collections[0].diagnostics).toEqual([]);
+ });
+
+ it('names the hard facts then orientation, in a fixed key order', () => {
+ const { collections } = plan([makeFacts('uid-a')]);
+
+ expect(collections[0].key.parts.map(([rule]) => rule)).toEqual([
+ ...HARD_FACT_RULES,
+ ORIENTATION_RULE,
+ ...SEMANTIC_AXES.map((axis) => axis.rule),
+ ]);
+ expect(collections[0].key.parts).toEqual([
+ ['seriesNumber', '1'],
+ ['sequenceName', null],
+ ['sliceThickness', '1'],
+ ['seriesDate', '20240101'],
+ ['rows', '4'],
+ ['columns', '4'],
+ ['samplesPerPixel', '1'],
+ ['numberOfFrames', null],
+ ['orientation', orientationKeyValue(IDENTITY_ORIENTATION)],
+ ['acquisition', null],
+ ['phase', null],
+ ['echo', null],
+ ['b-value', null],
+ ]);
+ });
+
+ const differingFacts: Array<[string, Partial, string]> = [
+ ['seriesNumber', { seriesNumber: '2' }, '2'],
+ ['sequenceName', { sequenceName: 'SEQ' }, 'SEQ'],
+ ['sliceThickness', { sliceThickness: '5' }, '5'],
+ ['seriesDate', { seriesDate: '20240102' }, '20240102'],
+ ['rows', { rows: '8' }, '8'],
+ ['columns', { columns: '8' }, '8'],
+ ['samplesPerPixel', { samplesPerPixel: '3' }, '3'],
+ ['numberOfFrames', { numberOfFrames: '2' }, '2'],
+ ];
+
+ it.each(differingFacts)(
+ 'partitions on a differing %s',
+ (rule, overrides, expected) => {
+ const a = makeFacts('uid-a');
+ const b = makeFacts('uid-b', overrides);
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(2);
+ expect(uidsOf(collectionWith(collections, 'uid-a'))).toEqual(['uid-a']);
+ expect(uidsOf(collectionWith(collections, 'uid-b'))).toEqual(['uid-b']);
+ expect(partValue(collectionWith(collections, 'uid-b'), rule)).toBe(
+ expected
+ );
+ }
+ );
+
+ const missingFacts: Array<
+ [string, Partial, Partial]
+ > = [
+ ['seriesNumber', { seriesNumber: '1' }, { seriesNumber: null }],
+ ['sequenceName', { sequenceName: 'SEQ' }, { sequenceName: null }],
+ ['sliceThickness', { sliceThickness: '1' }, { sliceThickness: null }],
+ ['seriesDate', { seriesDate: '20240101' }, { seriesDate: null }],
+ ['rows', { rows: '4' }, { rows: null }],
+ ['columns', { columns: '4' }, { columns: null }],
+ ['samplesPerPixel', { samplesPerPixel: '1' }, { samplesPerPixel: null }],
+ ['numberOfFrames', { numberOfFrames: '2' }, { numberOfFrames: null }],
+ ];
+
+ it.each(missingFacts)(
+ 'buckets a missing %s on its own',
+ (rule, present, absent) => {
+ const a = makeFacts('uid-a', present);
+ const b = makeFacts('uid-b', absent);
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(2);
+ expect(partValue(collectionWith(collections, 'uid-b'), rule)).toBeNull();
+ expect(
+ partValue(collectionWith(collections, 'uid-a'), rule)
+ ).not.toBeNull();
+ }
+ );
+
+ it('does not partition on pixel spacing, position or instance number', () => {
+ const a = makeFacts('uid-a', {
+ pixelSpacing: [1, 1],
+ position: [0, 0, 0],
+ projectedPosition: 0,
+ instanceNumber: 1,
+ });
+ const b = makeFacts('uid-b', {
+ pixelSpacing: [0.5, 0.75],
+ position: [0, 0, 3],
+ projectedPosition: 3,
+ instanceNumber: 9,
+ });
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(1);
+ expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']);
+ });
+
+ it('keys on the given series key, not the instances own series UID', () => {
+ const a = makeFacts('uid-a', { seriesInstanceUid: 'somewhere-else' });
+
+ const { collections } = plan([a], 'series-9');
+
+ expect(collections[0].key.seriesKey).toBe('series-9');
+ });
+
+ it('buckets orientations whose rows agree within the tolerance', () => {
+ const a = makeFacts('uid-a', { orientation: IDENTITY_ORIENTATION });
+ const b = makeFacts('uid-b', { orientation: tiltedOrientation(WITHIN) });
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(1);
+ expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']);
+ });
+
+ it('splits orientations whose rows disagree beyond the tolerance', () => {
+ const a = makeFacts('uid-a', { orientation: IDENTITY_ORIENTATION });
+ const b = makeFacts('uid-b', { orientation: tiltedOrientation(BEYOND) });
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(2);
+ expect(
+ partValue(collectionWith(collections, 'uid-b'), ORIENTATION_RULE)
+ ).toBe(orientationKeyValue(tiltedOrientation(BEYOND)));
+ });
+
+ it('buckets identical cosines that are slightly over normalized', () => {
+ const overNormalized = [1.0001, 0, 0, 0, 1, 0];
+ const a = makeFacts('uid-a', { orientation: overNormalized });
+ const b = makeFacts('uid-b', { orientation: overNormalized });
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(1);
+ expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']);
+ expect(partValue(collections[0], ORIENTATION_RULE)).toBe(
+ orientationKeyValue(overNormalized)
+ );
+ });
+
+ it('buckets a chain of near orientations the same way under permutation', () => {
+ const a = makeFacts('uid-a', { orientation: IDENTITY_ORIENTATION });
+ const b = makeFacts('uid-b', { orientation: tiltedOrientation(CHAIN) });
+ const c = makeFacts('uid-c', { orientation: tiltedOrientation(2 * CHAIN) });
+
+ const forward = plan([a, b, c]);
+ const backward = plan([c, b, a]);
+ const shuffled = plan([b, c, a]);
+
+ expect(forward.collections).toHaveLength(2);
+ expect(uidsOf(collectionWith(forward.collections, 'uid-a'))).toEqual([
+ 'uid-a',
+ 'uid-b',
+ ]);
+ expect(uidsOf(collectionWith(forward.collections, 'uid-c'))).toEqual([
+ 'uid-c',
+ ]);
+ expect(backward).toEqual(forward);
+ expect(shuffled).toEqual(forward);
+ });
+
+ it('buckets a chain of anonymous near orientations the same under permutation', () => {
+ const anonymousChain = () => [
+ makeFacts(null, {
+ orientation: IDENTITY_ORIENTATION,
+ projectedPosition: 0,
+ }),
+ makeFacts(null, {
+ orientation: tiltedOrientation(CHAIN),
+ projectedPosition: 1,
+ }),
+ makeFacts(null, {
+ orientation: tiltedOrientation(2 * CHAIN),
+ projectedPosition: 2,
+ }),
+ ];
+
+ const [x, y, z] = anonymousChain();
+ const forward = plan([x, y, z]);
+
+ expect(forward.collections.map((c) => c.members.length)).toEqual([2, 1]);
+ expect(plan([z, y, x])).toEqual(forward);
+ expect(plan([y, x, z])).toEqual(forward);
+ });
+
+ it('takes the orientation key from the smallest SOP UID, not the first input', () => {
+ const late = makeFacts('uid-z', { orientation: IDENTITY_ORIENTATION });
+ const early = makeFacts('uid-a', {
+ orientation: tiltedOrientation(WITHIN),
+ });
+
+ const { collections } = plan([late, early]);
+
+ expect(collections).toHaveLength(1);
+ expect(partValue(collections[0], ORIENTATION_RULE)).toBe(
+ orientationKeyValue(tiltedOrientation(WITHIN))
+ );
+ });
+
+ it('never represents a bucket with an instance that has no SOP UID', () => {
+ const anonymous = makeFacts(null, { orientation: IDENTITY_ORIENTATION });
+ const identified = makeFacts('uid-z', {
+ orientation: tiltedOrientation(WITHIN),
+ });
+
+ const { collections } = plan([anonymous, identified]);
+
+ expect(collections).toHaveLength(1);
+ expect(partValue(collections[0], ORIENTATION_RULE)).toBe(
+ orientationKeyValue(tiltedOrientation(WITHIN))
+ );
+ });
+
+ it('buckets an unreadable orientation on its own and diagnoses it', () => {
+ const a = makeFacts('uid-a');
+ const b = makeFacts('uid-b', { orientation: null });
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(2);
+ const orphan = collectionWith(collections, 'uid-b');
+ expect(partValue(orphan, ORIENTATION_RULE)).toBeNull();
+ expect(mentions(orphan.diagnostics, 'uid-b')).toBe(true);
+ });
+
+ it('deduplicates by SOP Instance UID, keeping the last occurrence', () => {
+ const stale = makeFacts('uid-a', { instanceNumber: 1 });
+ const fresh = makeFacts('uid-a', { instanceNumber: 7 });
+
+ const { collections } = plan([stale, fresh]);
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].members).toHaveLength(1);
+ expect(collections[0].members[0]).toBe(fresh);
+ expect(mentions(collections[0].diagnostics, 'uid-a')).toBe(true);
+ });
+
+ it('never merges instances that have no SOP Instance UID', () => {
+ const a = makeFacts(null, { projectedPosition: 0, instanceNumber: 1 });
+ const b = makeFacts(null, { projectedPosition: 1, instanceNumber: 2 });
+
+ const { collections } = plan([a, b]);
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].members).toHaveLength(2);
+ });
+
+ it('keeps both entries when one anonymous instance is listed twice', () => {
+ const shared = makeFacts(null);
+
+ const { collections } = plan([shared, shared]);
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].members).toEqual([shared, shared]);
+ });
+
+ it('orders members by projected position ascending', () => {
+ const high = makeFacts('uid-a', { projectedPosition: 10 });
+ const low = makeFacts('uid-b', { projectedPosition: -5 });
+ const mid = makeFacts('uid-c', { projectedPosition: 0 });
+
+ const { collections } = plan([high, low, mid]);
+
+ expect(uidsOf(collections[0])).toEqual(['uid-b', 'uid-c', 'uid-a']);
+ expect(collections[0].order).toBe('spatial');
+ expect(collections[0].diagnostics).toEqual([]);
+ });
+
+ // The tied members keep the scanner's own sequence, but position stays the
+ // primary key: slot i lands at origin + i * spacing along the normal, so an
+ // order that ran against position would mirror the volume.
+ it('breaks a repeated projected position on instance number, keeping spatial order', () => {
+ const later = makeFacts('uid-a', {
+ projectedPosition: 3,
+ instanceNumber: 2,
+ });
+ const earlier = makeFacts('uid-b', {
+ projectedPosition: 3,
+ instanceNumber: 1,
+ });
+ // Numbered before the tied pair yet positioned after it.
+ const far = makeFacts('uid-c', { projectedPosition: 5, instanceNumber: 0 });
+ const near = makeFacts('uid-d', {
+ projectedPosition: 0,
+ instanceNumber: 9,
+ });
+
+ const { collections } = plan([later, far, earlier, near]);
+
+ expect(uidsOf(collections[0])).toEqual([
+ 'uid-d',
+ 'uid-b',
+ 'uid-a',
+ 'uid-c',
+ ]);
+ expect(collections[0].order).toBe('spatial');
+ expect(collections[0].warnings).toEqual([REPEATED_POSITIONS_WARNING]);
+ });
+
+ it('breaks a repeated position on SOP Instance UID when instance numbers tie', () => {
+ const second = makeFacts('uid-b', { projectedPosition: 3 });
+ const first = makeFacts('uid-a', { projectedPosition: 3 });
+
+ const { collections } = plan([second, first]);
+
+ expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']);
+ expect(collections[0].order).toBe('spatial');
+ });
+
+ it('orders an unnumbered member after a numbered one at the same position', () => {
+ const numbered = makeFacts('uid-b', { projectedPosition: 3 });
+ const unnumbered = makeFacts('uid-a', {
+ projectedPosition: 3,
+ instanceNumber: null,
+ });
+ const low = makeFacts('uid-c', { projectedPosition: 0 });
+
+ const { collections } = plan([unnumbered, numbered, low]);
+
+ expect(uidsOf(collections[0])).toEqual(['uid-c', 'uid-b', 'uid-a']);
+ expect(collections[0].order).toBe('spatial');
+ });
+
+ it('breaks a shared position between anonymous members on content', () => {
+ const anonymousPair = () => [
+ makeFacts(null, { projectedPosition: 0, instanceNumber: 1 }),
+ makeFacts(null, { projectedPosition: 0, instanceNumber: 2 }),
+ ];
+
+ const [a, b] = anonymousPair();
+ const forward = plan([a, b]);
+
+ expect(forward.collections[0].members.map((m) => m.instanceNumber)).toEqual(
+ [1, 2]
+ );
+ expect(plan([b, a])).toEqual(forward);
+ });
+
+ it('breaks an anonymous tie on facts outside the ordering keys', () => {
+ const anonymousPair = () => [
+ makeFacts(null, { position: [0, 0, 0] }),
+ makeFacts(null, { position: [9, 9, 9] }),
+ ];
+
+ const [a, b] = anonymousPair();
+ const forward = plan([a, b]);
+
+ expect(forward.collections[0].members.map((m) => m.position)).toEqual([
+ [0, 0, 0],
+ [9, 9, 9],
+ ]);
+ expect(plan([b, a])).toEqual(forward);
+ });
+
+ it('keeps an unreadable position out of the collection it cannot be placed in', () => {
+ const blind = makeFacts('uid-a', {
+ projectedPosition: null,
+ instanceNumber: 3,
+ });
+ const alsoBlind = makeFacts('uid-c', {
+ projectedPosition: null,
+ instanceNumber: 1,
+ });
+ const seen = makeFacts('uid-b', {
+ projectedPosition: 2,
+ instanceNumber: 1,
+ });
+
+ const { collections } = plan([blind, seen, alsoBlind]);
+
+ expect(collections).toHaveLength(2);
+ expect(uidsOf(collectionWith(collections, 'uid-b'))).toEqual(['uid-b']);
+
+ const unknown = collectionWith(collections, 'uid-a');
+ expect(uidsOf(unknown)).toEqual(['uid-c', 'uid-a']);
+ expect(unknown.order).toBe('instance-number');
+ expect(mentions(unknown.diagnostics, 'uid-a')).toBe(true);
+ expect(unknown.warnings).toEqual([UNREADABLE_POSITION_WARNING]);
+ });
+
+ it('says nothing about a series that is one positionless instance', () => {
+ const alone = makeFacts('uid-a', {
+ projectedPosition: null,
+ instanceNumber: 1,
+ });
+
+ const { collections } = plan([alone]);
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].warnings).toEqual([]);
+ });
+
+ it('warns about a lone positionless instance beside a volume it missed', () => {
+ const alone = makeFacts('uid-a', {
+ projectedPosition: null,
+ instanceNumber: 1,
+ });
+ const seen = makeFacts('uid-b', {
+ projectedPosition: 2,
+ instanceNumber: 2,
+ });
+
+ const { collections } = plan([alone, seen]);
+
+ expect(collectionWith(collections, 'uid-a').warnings).toEqual([
+ UNREADABLE_POSITION_WARNING,
+ ]);
+ });
+
+ it('breaks a shared instance number on SOP Instance UID', () => {
+ const second = makeFacts('uid-b', {
+ projectedPosition: null,
+ instanceNumber: 4,
+ });
+ const first = makeFacts('uid-a', {
+ projectedPosition: null,
+ instanceNumber: 4,
+ });
+
+ const { collections } = plan([second, first]);
+
+ expect(uidsOf(collections[0])).toEqual(['uid-a', 'uid-b']);
+ expect(collections[0].order).toBe('instance-number');
+ });
+
+ it('records input order when neither position nor instance number reads', () => {
+ const second = makeFacts('uid-b', {
+ projectedPosition: null,
+ instanceNumber: null,
+ });
+ const first = makeFacts('uid-a', {
+ projectedPosition: null,
+ instanceNumber: null,
+ });
+
+ const { collections } = plan([second, first]);
+
+ expect(uidsOf(collections[0])).toEqual(['uid-b', 'uid-a']);
+ expect(collections[0].order).toBe('input');
+ expect(mentions(collections[0].diagnostics, 'uid-a')).toBe(true);
+ expect(mentions(collections[0].diagnostics, 'uid-b')).toBe(true);
+ });
+
+ const permutable = () => [
+ makeFacts('uid-1', { projectedPosition: 2, instanceNumber: 2 }),
+ makeFacts('uid-2', { projectedPosition: 1, instanceNumber: 1 }),
+ makeFacts('uid-3', {
+ projectedPosition: 0,
+ instanceNumber: 3,
+ sliceThickness: '5',
+ }),
+ makeFacts('uid-4', {
+ projectedPosition: 4,
+ instanceNumber: 4,
+ sliceThickness: '5',
+ }),
+ makeFacts('uid-5', {
+ projectedPosition: 3,
+ instanceNumber: 5,
+ orientation: tiltedOrientation(BEYOND),
+ }),
+ makeFacts('uid-6', {
+ projectedPosition: 5,
+ instanceNumber: 6,
+ orientation: tiltedOrientation(BEYOND),
+ }),
+ ];
+
+ it('is invariant to input permutation in membership and order', () => {
+ const base = plan(permutable());
+ const reversed = plan(permutable().reverse());
+ const rotated = (() => {
+ const items = permutable();
+ return plan([...items.slice(2), ...items.slice(0, 2)]);
+ })();
+ const interleaved = (() => {
+ const items = permutable();
+ return plan([items[5], items[0], items[3], items[1], items[4], items[2]]);
+ })();
+
+ expect(base.collections).toHaveLength(3);
+ expect(reversed).toEqual(base);
+ expect(rotated).toEqual(base);
+ expect(interleaved).toEqual(base);
+ });
+
+ it('does not mutate the instances it is given', () => {
+ const instances = permutable();
+ const before = clone(instances);
+
+ plan(instances);
+
+ expect(clone(instances)).toEqual(before);
+ });
+
+ it('holds every instance exactly once across the collections', () => {
+ const instances = permutable();
+
+ const { collections } = plan(instances);
+ const members = collections.flatMap((c) => c.members);
+
+ expect(members).toHaveLength(instances.length);
+ expect(new Set(members).size).toBe(instances.length);
+ instances.forEach((instance) => expect(members).toContain(instance));
+ expect(() => validateCollections(instances, collections)).not.toThrow();
+ });
+});
+
+describe('planDicomCollections semantic partition', () => {
+ const pass = (acquisition: string, zs: number[]) =>
+ zs.map((z, i) =>
+ makeFacts(`acq${acquisition}-${i}`, {
+ projectedPosition: z,
+ instanceNumber: i + 1,
+ acquisitionNumber: acquisition,
+ })
+ );
+
+ it('separates overlapping passes and labels each collection', () => {
+ const first = pass('1', [0, 2, 4]);
+ const second = pass('2', [1, 3, 5]);
+
+ const { collections } = plan([...first, ...second]);
+
+ expect(collections).toHaveLength(2);
+ const [one, two] = collections;
+ expect(one.label).toBe('acquisition 1');
+ expect(partValue(one, 'acquisition')).toBe('1');
+ expect(uidsOf(one)).toEqual(['acq1-0', 'acq1-1', 'acq1-2']);
+ expect(one.order).toBe('spatial');
+ expect(two.label).toBe('acquisition 2');
+ expect(partValue(two, 'acquisition')).toBe('2');
+ expect(collections.every((c) => c.warnings.length === 0)).toBe(true);
+ });
+
+ // Two members whose cosines agree within the tolerance are one plane. Each
+ // member's own normal would place them at two positions a fraction of a
+ // micron apart, and the repeated slice would go unreported.
+ it('projects a bucket on one normal rather than on each member of it', () => {
+ const straight = makeFacts('uid-a', {
+ position: [0, 0, 10],
+ projectedPosition: 10,
+ });
+ const tilted = makeFacts('uid-b', {
+ orientation: tiltedOrientation(WITHIN),
+ position: [0, 0, 10],
+ projectedPosition: 10 * Math.cos(WITHIN),
+ });
+
+ const { collections } = plan([straight, tilted]);
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].warnings).toEqual([REPEATED_POSITIONS_WARNING]);
+ });
+
+ it('partitions each orientation bucket on its own', () => {
+ const stack = [...pass('1', [0, 2]), ...pass('2', [1, 3])];
+ const scout = makeFacts('scout', {
+ orientation: tiltedOrientation(BEYOND),
+ acquisitionNumber: '1',
+ });
+
+ const { collections } = plan([...stack, scout]);
+
+ expect(collections).toHaveLength(3);
+ expect(collectionWith(collections, 'scout').label).toBeNull();
+ expect(collectionWith(collections, 'acq1-0').label).toBe('acquisition 1');
+ });
+
+ it('leaves an unlabelled collection when nothing overlaps', () => {
+ const { collections } = plan(pass('1', [0, 2, 4]));
+
+ expect(collections[0].label).toBeNull();
+ expect(partValue(collections[0], 'acquisition')).toBeNull();
+ expect(collections[0].warnings).toEqual([]);
+ });
+
+ it('warns that an unevenly spaced collection is not one regular volume', () => {
+ // Two 4mm runs with a gap between them, as the bilateral sagittal slabs
+ // of idcSeriesFixtures.ts are. One collection, but no single lattice.
+ const { collections } = plan(pass('1', [0, 4, 8, 50, 54, 58]));
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].warnings).toEqual([IRREGULAR_VOLUME_WARNING]);
+ });
+
+ it('leaves an evenly spaced collection unwarned', () => {
+ const { collections } = plan(pass('1', [0, 4, 8, 12]));
+
+ expect(collections[0].warnings).toEqual([]);
+ });
+
+ it('warns that a tilted gantry stack was stacked without correcting its shear', () => {
+ const theta = Math.PI / 9;
+ const orientation = tiltedOrientation(theta);
+ // The table advances along z while the columns lean into it, so each
+ // slice plane sits sideways of the last on the stack's own normal.
+ const stack = [0, 3, 6, 9].map((z, i) =>
+ makeFacts(`tilt-${i}`, {
+ orientation,
+ position: [0, 0, z],
+ projectedPosition: z * Math.cos(theta),
+ instanceNumber: i + 1,
+ })
+ );
+
+ const { collections } = plan(stack);
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].order).toBe('spatial');
+ expect(uidsOf(collections[0])).toEqual(stack.map((m) => m.sopInstanceUid));
+ expect(collections[0].warnings).toEqual([IN_PLANE_SHIFT_WARNING]);
+ });
+
+ // A 358mm field of view, so a lean shows at the far corner of a slice.
+ const wideStack = (strayUid: string, lean: number) =>
+ [0, 5, 10, 15].map((z, i) =>
+ makeFacts(i === 2 ? strayUid : `wide-${i}`, {
+ rows: '512',
+ columns: '512',
+ pixelSpacing: [0.7, 0.7],
+ orientation: i === 2 ? tiltedOrientation(lean) : IDENTITY_ORIENTATION,
+ projectedPosition: z,
+ instanceNumber: i + 1,
+ })
+ );
+
+ it.each(['a-walked-first', 'z-walked-last'])(
+ 'takes the axes most slices declare when the stray one is %s',
+ (strayUid) => {
+ const { collections } = plan(wideStack(strayUid, 0.0002));
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].orientation).toEqual(IDENTITY_ORIENTATION);
+ expect(collections[0].warnings).toEqual([]);
+ }
+ );
+
+ it('warns when a slice leans off the volume axes by more than a tenth of a millimetre', () => {
+ const { collections } = plan(wideStack('wide-stray', 0.001));
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].orientation).toEqual(IDENTITY_ORIENTATION);
+ expect(collections[0].warnings).toEqual([NONPARALLEL_SLICES_WARNING]);
+ });
+
+ it('warns about a repeated position no axis separates', () => {
+ const { collections } = plan(pass('1', [0, 2, 2, 4]));
+
+ expect(collections).toHaveLength(1);
+ expect(collections[0].warnings).toEqual([REPEATED_POSITIONS_WARNING]);
+ });
+
+ // A later import can bring a slice whose geometry cannot be read. Merging it
+ // back in would undo a separation the positions of the other members
+ // already earned.
+ it('keeps two separated acquisitions apart when a positionless member arrives', () => {
+ const first = pass('1', [0, 2, 4]);
+ const second = pass('2', [1, 3, 5]);
+ const before = plan([...first, ...second]);
+
+ const blind = makeFacts('blind', {
+ projectedPosition: null,
+ position: null,
+ instanceNumber: 7,
+ });
+ const after = plan([...first, ...second, blind]);
+
+ expect(before.collections).toHaveLength(2);
+ expect(after.collections).toHaveLength(3);
+
+ // The two evidence-backed collections survive unchanged, keys included.
+ before.collections.forEach((collection) => {
+ const kept = collectionWith(
+ after.collections,
+ collection.members[0].sopInstanceUid!
+ );
+ expect(kept.key).toEqual(collection.key);
+ expect(uidsOf(kept)).toEqual(uidsOf(collection));
+ expect(kept.warnings).toEqual([]);
+ });
+
+ const unknown = collectionWith(after.collections, 'blind');
+ expect(uidsOf(unknown)).toEqual(['blind']);
+ expect(unknown.warnings).toEqual([UNREADABLE_POSITION_WARNING]);
+ expect(unknown.key).not.toEqual(before.collections[0].key);
+ });
+
+ it('gives the two passes distinct, collision-free keys', () => {
+ const { collections } = plan([...pass('1', [0, 2]), ...pass('2', [1, 3])]);
+
+ expect(() =>
+ validateCollections(
+ collections.flatMap((c) => c.members),
+ collections
+ )
+ ).not.toThrow();
+ });
+});
+
+describe('validateCollections', () => {
+ const a = makeFacts('uid-a');
+ const b = makeFacts('uid-b');
+
+ const collectionOf = (
+ members: InstanceFacts[],
+ parts: Array<[string, string | null]>
+ ): DicomCollection => ({
+ key: { seriesKey: SERIES_KEY, parts },
+ members,
+ order: 'input',
+ reconstruction: {
+ kind: 'regular',
+ sliceSpacing: null,
+ slots: members.map((_, index) => index),
+ },
+ orientation: null,
+ label: null,
+ diagnostics: [],
+ warnings: [],
+ });
+
+ const partsOne: Array<[string, string | null]> = [['orientation', 'one']];
+ const partsTwo: Array<[string, string | null]> = [['orientation', 'two']];
+
+ it('accepts a conserving, collision-free plan', () => {
+ expect(() =>
+ validateCollections(
+ [a, b],
+ [collectionOf([a], partsOne), collectionOf([b], partsTwo)]
+ )
+ ).not.toThrow();
+ });
+
+ it('rejects an instance that landed in no collection', () => {
+ expect(() =>
+ validateCollections([a, b], [collectionOf([a], partsOne)])
+ ).toThrow('uid-b');
+ });
+
+ it('rejects an instance that landed in two collections', () => {
+ expect(() =>
+ validateCollections(
+ [a],
+ [collectionOf([a], partsOne), collectionOf([a], partsTwo)]
+ )
+ ).toThrow('uid-a');
+ });
+
+ it('accepts an instance listed twice when it is placed twice', () => {
+ expect(() =>
+ validateCollections([a, a], [collectionOf([a, a], partsOne)])
+ ).not.toThrow();
+ });
+
+ it('rejects an instance listed twice but placed once', () => {
+ expect(() =>
+ validateCollections([a, a], [collectionOf([a], partsOne)])
+ ).toThrow('uid-a');
+ });
+
+ it('rejects a member that was never one of the instances', () => {
+ expect(() =>
+ validateCollections([a], [collectionOf([a, b], partsOne)])
+ ).toThrow('uid-b');
+ });
+
+ it('rejects two collections sharing a key', () => {
+ expect(() =>
+ validateCollections(
+ [a, b],
+ [collectionOf([a], partsOne), collectionOf([b], partsOne)]
+ )
+ ).toThrow(/key/i);
+ });
+});
+
+describe('independent multi-frame instances', () => {
+ it.each([null, 0])(
+ 'keeps equal-shaped clips separate at position %s',
+ (position) => {
+ const instances = ['clip-a', 'clip-b'].map((uid) =>
+ makeFacts(uid, {
+ numberOfFrames: '3',
+ position: position === null ? null : [0, 0, position],
+ projectedPosition: position,
+ })
+ );
+ const collections = plan(instances).collections;
+ expect(collections.map((c) => uidsOf(c))).toEqual([
+ ['clip-a'],
+ ['clip-b'],
+ ]);
+ expect(new Set(collections.map((c) => JSON.stringify(c.key))).size).toBe(
+ 2
+ );
+ expect(plan([...instances].reverse())).toEqual(plan(instances));
+ }
+ );
+});
diff --git a/packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts b/packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts
new file mode 100644
index 000000000..2634f4969
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/reconstructVolume.spec.ts
@@ -0,0 +1,227 @@
+import { datasetFromTags } from '@/tests/dicomDataset';
+import { describe, expect, it } from 'vitest';
+import { Tags } from '../../metadata/dicomTags';
+import { readInstanceFacts as readStructuredFacts } from '../instanceFacts';
+import { reconstructVolume } from '../reconstructVolume';
+import {
+ bilateralSagittalSlabs,
+ type IdcSeriesFixture,
+} from './idcSeriesFixtures';
+
+// An axial slice at z, read through the fact reader so the fixtures stay in
+// the tag vocabulary the real data uses.
+const slice = (z: number, tags: Record = {}) =>
+ readInstanceFacts([
+ ...Object.entries({
+ [Tags.ImageOrientationPatient]: '1\\0\\0\\0\\1\\0',
+ [Tags.ImagePositionPatient]: `0\\0\\${z}`,
+ [Tags.PixelSpacing]: '0.5\\0.5',
+ ...tags,
+ }),
+ ]);
+
+const stack = (zs: number[]) => zs.map((z) => slice(z));
+
+// A gantry tilted by theta leans the columns into z, so each slice plane sits
+// sideways of the last as the table advances along z.
+const tiltedSlice = (z: number, theta: number) =>
+ slice(z, {
+ [Tags.ImageOrientationPatient]: [
+ 1,
+ 0,
+ 0,
+ 0,
+ Math.cos(theta),
+ Math.sin(theta),
+ ].join('\\'),
+ });
+
+const fixtureSlices = (fixture: IdcSeriesFixture) =>
+ fixture
+ .groups!.flatMap((group) => group.zs.map((z) => slice(z)))
+ .sort(
+ (left, right) =>
+ (left.projectedPosition as number) - (right.projectedPosition as number)
+ );
+
+describe('reconstructVolume', () => {
+ it('measures an evenly spaced stack and maps every member to its slot', () => {
+ const result = reconstructVolume(stack([-10, -6, -2, 2, 6]));
+
+ expect(result.kind).toBe('regular');
+ expect(result.sliceSpacing).toBeCloseTo(4, 12);
+ expect(result.slots).toEqual([0, 1, 2, 3, 4]);
+ });
+
+ it('derives the spacing from the endpoints once every gap matches', () => {
+ const result = reconstructVolume(stack([0, 9, 18]));
+
+ expect(result.kind).toBe('regular');
+ expect(result.sliceSpacing).toBe(9);
+ });
+
+ it('has no spacing to derive from a single member', () => {
+ const result = reconstructVolume(stack([7]));
+
+ expect(result.kind).toBe('regular');
+ expect(result.sliceSpacing).toBeNull();
+ expect(result.slots).toEqual([0]);
+ });
+
+ // IDC series 1.3.6.1.4.1.14519.5.2.1.7009.2403.337074050757748643824459343650
+ // ("WB_3D_NON-AC"): whole-body PET stepping by 3.27mm with the occasional
+ // 3.35mm step from printing precision. One lattice, printed unevenly.
+ it('accepts the printing jitter of quantized whole-body PET positions', () => {
+ const result = reconstructVolume(stack([0, 3.27, 6.54, 9.89, 13.16]));
+
+ expect(result.kind).toBe('regular');
+ expect(result.sliceSpacing).toBeCloseTo(3.29, 2);
+ });
+
+ // The two slabs are 4mm stacks 42.439mm apart. Endpoint distance over slice
+ // count would call that a 4.59mm lattice, which describes neither slab.
+ it('calls the bilateral sagittal slabs an irregular stack', () => {
+ const members = fixtureSlices(bilateralSagittalSlabs);
+
+ const result = reconstructVolume(members);
+
+ expect(members).toHaveLength(66);
+ expect(result.kind).toBe('irregular');
+ expect(result).toMatchObject({ reason: 'uneven-slice-spacing' });
+ // The fallback places the slices at a gap the series actually holds.
+ expect(result.sliceSpacing).toBeCloseTo(4, 12);
+ expect(result.sliceSpacing).not.toBeCloseTo(4.59137, 4);
+ });
+
+ it('reports a hole in an otherwise even stack as irregular', () => {
+ const result = reconstructVolume(stack([0, 4, 8, 20, 24]));
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'uneven-slice-spacing',
+ });
+ });
+
+ it('reports a repeated slice position as irregular', () => {
+ const result = reconstructVolume(stack([0, 4, 4, 8]));
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'repeated-position',
+ });
+ });
+
+ it('reports a member whose position cannot be read as irregular', () => {
+ const blind = readInstanceFacts([
+ [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0'],
+ [Tags.PixelSpacing, '0.5\\0.5'],
+ ]);
+
+ const result = reconstructVolume([...stack([0, 4]), blind]);
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'unreadable-geometry',
+ });
+ expect(result.sliceSpacing).toBeNull();
+ });
+
+ it('reports a slice shifted within its own plane as irregular', () => {
+ const shifted = readInstanceFacts([
+ [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0'],
+ [Tags.ImagePositionPatient, '0\\30\\8'],
+ [Tags.PixelSpacing, '0.5\\0.5'],
+ ]);
+
+ const result = reconstructVolume([...stack([0, 4]), shifted]);
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'in-plane-shift',
+ });
+ // The fallback is a step the members do take, not the one to the outlier.
+ expect(result.sliceSpacing).toBeCloseTo(4, 12);
+ });
+
+ it('steps a tilted gantry stack by the distance its slices advance', () => {
+ const theta = Math.PI / 9;
+
+ const result = reconstructVolume(
+ [0, 3, 6, 9].map((z) => tiltedSlice(z, theta))
+ );
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'in-plane-shift',
+ });
+ // The table stepped 3mm a slice, which is the extent ITK's series reader
+ // keeps too; the shear is warned about, not folded into the spacing.
+ expect(result.sliceSpacing).toBeCloseTo(3, 12);
+ expect(result.slots).toEqual([0, 1, 2, 3]);
+ });
+
+ it('reports two in-plane sample spacings as irregular', () => {
+ const coarse = slice(8, { [Tags.PixelSpacing]: '0.6\\0.6' });
+
+ const result = reconstructVolume([...stack([0, 4]), coarse]);
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'mixed-pixel-spacing',
+ });
+ expect(result.sliceSpacing).toBeCloseTo(4, 12);
+ });
+
+ it('steps a stack whose orientation cannot be read by its positions', () => {
+ const blind = (z: number) =>
+ readInstanceFacts([
+ [Tags.ImagePositionPatient, `0\\0\\${z}`],
+ [Tags.PixelSpacing, '0.5\\0.5'],
+ ]);
+
+ const result = reconstructVolume([0, 4, 8].map(blind));
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'unreadable-geometry',
+ });
+ expect(result.sliceSpacing).toBeCloseTo(4, 12);
+ });
+
+ it('has no step for members that never move', () => {
+ const result = reconstructVolume(stack([5, 5, 5]));
+
+ expect(result).toMatchObject({
+ kind: 'irregular',
+ reason: 'repeated-position',
+ });
+ expect(result.sliceSpacing).toBeNull();
+ });
+
+ it('projects on the normal it is given rather than on the first member', () => {
+ const members = stack([0, 4, 8]);
+
+ // On the members' own normal the stack is even; on +x it steps sideways.
+ expect(reconstructVolume(members).kind).toBe('regular');
+ expect(reconstructVolume(members, [1, 0, 0])).toMatchObject({
+ kind: 'irregular',
+ reason: 'in-plane-shift',
+ });
+ });
+});
+
+function readInstanceFacts(tags: Iterable) {
+ return readStructuredFacts(datasetFromTags(tags));
+}
+
+it.each([0.01, 0.05, 0.1])(
+ 'detects a missing slice in a %s mm stack',
+ (step) => {
+ expect(
+ reconstructVolume(stack([0, 1, 2, 4, 5, 6].map((i) => i * step)))
+ ).toMatchObject({
+ kind: 'irregular',
+ reason: 'uneven-slice-spacing',
+ });
+ }
+);
diff --git a/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts b/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts
new file mode 100644
index 000000000..53bb8cada
--- /dev/null
+++ b/packages/dicom/core/collections/__tests__/splitOverlappingAcquisitions.spec.ts
@@ -0,0 +1,632 @@
+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.each([
+ ['acquisition', Tags.AcquisitionNumber],
+ ['phase', Tags.TemporalPositionIdentifier],
+ ])(
+ 'keeps singleton %s values with the remainder of overlapping scans',
+ (rule, tag) => {
+ const scans = ['1', '2'].flatMap((value) =>
+ [0, 2, 4, 6, 8].map((z) => slice(z, { [tag]: value }))
+ );
+ const remainder = [
+ slice(1, { [tag]: '3' }),
+ slice(3),
+ slice(5, { [tag]: '4' }),
+ ];
+
+ const parts = splitOverlappingAcquisitions(
+ [...scans, ...remainder].sort(byPosition)
+ );
+
+ expect(labels(parts)).toEqual([
+ `${rule} 1`,
+ `${rule} 2`,
+ `${rule} unknown`,
+ ]);
+ expect(partLabelled(parts, `${rule} unknown`).members).toEqual(remainder);
+ expect(
+ partValue(partLabelled(parts, `${rule} unknown`), rule)
+ ).toBeNull();
+ }
+ );
+
+ it('still separates contrasts within singleton counter values', () => {
+ const scans = [...stack('1', 0, 3), ...stack('2', 0, 3)];
+ const contrasts = ['1', '2'].map((echo, index) =>
+ slice(2.5, {
+ [Tags.AcquisitionNumber]: String(index + 3),
+ [Tags.EchoNumbers]: echo,
+ })
+ );
+
+ const parts = splitOverlappingAcquisitions([...scans, ...contrasts]);
+
+ expect(labels(parts)).toEqual([
+ 'acquisition 1',
+ 'acquisition 2',
+ 'acquisition unknown, echo 1',
+ 'acquisition unknown, echo 2',
+ ]);
+ contrasts.forEach((member, index) =>
+ expect(
+ partLabelled(parts, `acquisition unknown, echo ${index + 1}`).members
+ ).toEqual([member])
+ );
+ });
+
+ it('leaves a repeated plane inside a split whole', () => {
+ // A full stack plus a bolus-tracking pass of one plane, each frame with
+ // its own phase: the pass is a time series, not twenty datasets.
+ 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/chunkFacts.ts b/packages/dicom/core/collections/chunkFacts.ts
new file mode 100644
index 000000000..9be885af2
--- /dev/null
+++ b/packages/dicom/core/collections/chunkFacts.ts
@@ -0,0 +1,22 @@
+import type { Chunk } from '../streaming/chunk';
+import type { DicomDataset } from '../metadata/dicomDataset';
+import { readInstanceDetails } from './instanceFacts';
+
+type ChunkFacts = {
+ metadata: DicomDataset;
+ details: ReturnType;
+};
+
+// Chunk metadata is immutable after loading. Weak keys release derived facts
+// with the source; standalone dataset readers remain uncached.
+const factsByChunk = new WeakMap();
+
+export function readChunkFacts(chunk: Chunk) {
+ const { metadata } = chunk;
+ if (!metadata) throw new Error('DICOM chunk metadata has not been read');
+ const cached = factsByChunk.get(chunk);
+ if (cached?.metadata === metadata) return cached.details;
+ const details = readInstanceDetails(metadata);
+ factsByChunk.set(chunk, { metadata, details });
+ return details;
+}
diff --git a/packages/dicom/core/collections/collectionRegistry.ts b/packages/dicom/core/collections/collectionRegistry.ts
new file mode 100644
index 000000000..3c11dc3d0
--- /dev/null
+++ b/packages/dicom/core/collections/collectionRegistry.ts
@@ -0,0 +1,373 @@
+import type { Chunk } from '../streaming/chunk';
+import { getDatasetTag } from '../metadata/datasetAccess';
+import { Tags } from '../metadata/dicomTags';
+import { trimDicomPadding } from '../utils';
+import {
+ planDicomCollections,
+ type DicomCollection,
+ type InstanceFacts,
+} from './planDicomCollections';
+import {
+ assignIds,
+ encodeCollectionKey,
+ type CommittedCollection,
+} from './assignCollectionIds';
+import { seriesKeyOf } from './instanceFacts';
+import { readChunkFacts } from './chunkFacts';
+import { groupBy } from './groupBy';
+
+export type CollectionUpdate = {
+ id: string;
+ seriesKey: string;
+ collection: DicomCollection;
+ // Every chunk the collection holds, in the collection's own order, index
+ // aligned with collection.members.
+ members: Chunk[];
+ // Index aligned with members: the batch's own chunk wherever the batch
+ // supplied that instance, so a re-import's provenance reaches the caller
+ // while the image keeps the object it already decodes.
+ provenance: Chunk[];
+};
+
+export type RegistrationResult = {
+ updates: CollectionUpdate[];
+ /** Conflicting geometry under an existing SOP Instance UID; the first copy is retained. */
+ conflictingInstanceUids: string[];
+ // Committed IDs the replan dissolved, whose members moved elsewhere.
+ removed: string[];
+ // Undoes this batch's registration for the named series, so a failed import
+ // does not pin its chunks. Collections forgotten since the batch was planned
+ // stay forgotten. Returns their restored members for the owner to release,
+ // leaving the failed batch's chunks with its caller. Assumes the caller
+ // registered nothing since.
+ rollback: (seriesKeys: Iterable) => Chunk[];
+ // Whether a collection of the series was forgotten since the batch was
+ // planned, so a commit would bring back what the user removed.
+ changedSince: (seriesKey: string, affectedIds?: Iterable) => boolean;
+};
+
+export type DicomCollectionRegistry = {
+ register: (chunks: Chunk[]) => Promise;
+ has: (chunk: Chunk) => boolean;
+ forget: (collectionId: string) => Chunk[];
+};
+
+// An instance with no SOP Instance UID is identified by its chunk, so two of
+// them never merge while re-registering one does not duplicate it.
+type InstanceKey = string | Chunk;
+
+type RegisteredInstance = {
+ key: InstanceKey;
+ chunk: Chunk;
+ facts: InstanceFacts;
+};
+
+type Committed = {
+ // The member keys the collection was last planned with, in order.
+ members: InstanceKey[];
+ // The encoded collection key, so a relabelled collection is reported even
+ // when its members did not move.
+ key: string;
+};
+
+type SeriesEntry = {
+ instances: Map;
+ committed: Map;
+ // IDs the latest plan dissolved. They own nothing here any more, but the
+ // store still shows them until that plan commits, so forgetting one is a
+ // change the plan must hear about.
+ dissolving: Set;
+ // Every collection ID forgotten from this series, in order.
+ forgotten: string[];
+};
+
+type Snapshot = {
+ entry: SeriesEntry;
+ instances: SeriesEntry['instances'];
+ committed: SeriesEntry['committed'];
+ dissolving: SeriesEntry['dissolving'];
+ forgotten: number;
+};
+
+const unread = () =>
+ new Error('Cannot register a DICOM chunk whose metadata has not been read');
+
+const instanceKeyOf = (chunk: Chunk) =>
+ trimDicomPadding(getDatasetTag(chunk.metadata, Tags.SOPInstanceUID) ?? '') ||
+ chunk;
+
+const readChunk = (chunk: Chunk) => {
+ const { metadata } = chunk;
+ if (!metadata) throw unread();
+ const { facts } = readChunkFacts(chunk);
+ return {
+ seriesKey: seriesKeyOf(metadata),
+ instance: { key: instanceKeyOf(chunk), chunk, facts },
+ };
+};
+
+/**
+ * Splits a batch into one list per series, preserving input order. Only the
+ * series key is read here, since registration reads the rest of every chunk;
+ * a batch naming an unread chunk still fails before any series transaction
+ * starts.
+ */
+export const groupChunksBySeries = (chunks: Chunk[]) =>
+ groupBy(chunks, (chunk) => {
+ if (!chunk.metadata) throw unread();
+ return seriesKeyOf(chunk.metadata);
+ });
+
+const groupBySeries = (chunks: Chunk[]) => {
+ const groups = new Map();
+ groupBy(chunks.map(readChunk), (read) => read.seriesKey).forEach(
+ (reads, seriesKey) =>
+ groups.set(
+ seriesKey,
+ reads.map((read) => read.instance)
+ )
+ );
+ return groups;
+};
+
+const firstByKey = (instances: RegisteredInstance[]) => {
+ const byKey = new Map();
+ instances.forEach((instance) => {
+ if (!byKey.has(instance.key)) byKey.set(instance.key, instance.chunk);
+ });
+ return byKey;
+};
+
+// A collection holding no SOP Instance UID cannot be claimed back by overlap,
+// so its ID is re-minted from its key instead of counting as taken.
+const claimable = (committed: SeriesEntry['committed']) =>
+ [...committed]
+ .map(
+ ([id, { members }]): CommittedCollection => ({
+ id,
+ sopInstanceUids: members.filter(
+ (key): key is string => typeof key === 'string'
+ ),
+ })
+ )
+ .filter((collection) => collection.sopInstanceUids.length > 0);
+
+const unchanged = (before: Committed | undefined, after: Committed) =>
+ before !== undefined &&
+ before.key === after.key &&
+ before.members.length === after.members.length &&
+ before.members.every((key, index) => key === after.members[index]);
+
+function planSeries(
+ entry: SeriesEntry,
+ seriesKey: string,
+ batch: RegisteredInstance[]
+) {
+ // The first registration of an instance owns its chunk: a re-supply only
+ // reports back, so the chunk an image already holds stays the member.
+ const registeredByKey = new Map(entry.instances);
+ const conflictingInstanceUids = new Set();
+ batch.forEach((instance) => {
+ const existing = registeredByKey.get(instance.key);
+ if (!existing) registeredByKey.set(instance.key, instance);
+ else if (
+ typeof instance.key === 'string' &&
+ JSON.stringify(existing.facts) !== JSON.stringify(instance.facts)
+ )
+ conflictingInstanceUids.add(instance.key);
+ });
+
+ const registered = [...registeredByKey.values()];
+ const plan = planDicomCollections({
+ seriesKey,
+ instances: registered.map((instance) => instance.facts),
+ });
+ const assigned = assignIds(plan, claimable(entry.committed));
+
+ const instanceOf = new Map(
+ registered.map((instance) => [instance.facts, instance])
+ );
+ const batchChunks = firstByKey(batch);
+
+ const planned = assigned.collections.map(({ id, ...collection }) => {
+ const instances = collection.members.map((facts) => instanceOf.get(facts)!);
+ const committed: Committed = {
+ members: instances.map((instance) => instance.key),
+ key: encodeCollectionKey(collection.key),
+ };
+ return {
+ id,
+ collection,
+ committed,
+ members: instances.map((instance) => instance.chunk),
+ provenance: instances.map((instance) =>
+ typeof instance.key === 'string' &&
+ conflictingInstanceUids.has(instance.key)
+ ? instance.chunk
+ : (batchChunks.get(instance.key) ?? instance.chunk)
+ ),
+ supplied: committed.members.some((key) => batchChunks.has(key)),
+ };
+ });
+
+ // A replan can move members between collections or split a collection out
+ // under a new key, so such changes are reported even where the batch brought
+ // the collection nothing.
+ const updates = planned
+ .filter(
+ ({ id, committed, supplied }) =>
+ supplied || !unchanged(entry.committed.get(id), committed)
+ )
+ .map(
+ ({ id, collection, members, provenance }): CollectionUpdate => ({
+ id,
+ seriesKey,
+ collection,
+ members,
+ provenance,
+ })
+ );
+
+ const removed = [...entry.committed.keys()].filter(
+ (id) => !planned.some((collection) => collection.id === id)
+ );
+
+ return {
+ state: {
+ instances: registeredByKey,
+ committed: new Map(planned.map(({ id, committed }) => [id, committed])),
+ dissolving: new Set(removed),
+ },
+ updates,
+ removed,
+ conflictingInstanceUids: [...conflictingInstanceUids],
+ };
+}
+
+/**
+ * Remembers every instance seen so far, per series, and replans the series a
+ * batch touches. Planning and publication are synchronous, so a registration
+ * either publishes every series or leaves the registry unchanged.
+ */
+export function createDicomCollectionRegistry(): DicomCollectionRegistry {
+ const series = new Map();
+
+ 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: [],
+ };
+ 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[]) => {
+ const batches = groupBySeries(chunks);
+ const snapshots = new Map();
+
+ const planned = [...batches].map(([seriesKey, batch]) => {
+ const entry = entryFor(seriesKey);
+ return { seriesKey, entry, result: planSeries(entry, seriesKey, batch) };
+ });
+ planned.forEach(({ seriesKey, entry, result }) => {
+ snapshots.set(seriesKey, {
+ entry,
+ instances: entry.instances,
+ committed: entry.committed,
+ dissolving: entry.dissolving,
+ forgotten: entry.forgotten.length,
+ });
+ Object.assign(entry, result.state);
+ series.set(seriesKey, entry);
+ });
+
+ // A forget empties and drops an entry once nothing is left in it, so the
+ // series may now be missing or be a fresh entry.
+ const changedSince = (
+ seriesKey: string,
+ affectedIds?: Iterable
+ ) => {
+ const snapshot = snapshots.get(seriesKey);
+ if (!snapshot) return false;
+ const entry = series.get(seriesKey);
+ if (entry !== snapshot.entry) return true;
+ const forgotten = entry.forgotten.slice(snapshot.forgotten);
+ const affected = affectedIds ? new Set(affectedIds) : null;
+ return affected
+ ? forgotten.some((id) => affected.has(id))
+ : forgotten.length > 0;
+ };
+
+ const rollback = (seriesKeys: Iterable) =>
+ [...new Set(seriesKeys)].flatMap((seriesKey) => {
+ const snapshot = snapshots.get(seriesKey);
+ if (!snapshot) return [];
+ const { entry } = snapshot;
+ entry.instances = snapshot.instances;
+ entry.committed = snapshot.committed;
+ entry.dissolving = snapshot.dissolving;
+ // What the user removed while the batch was in flight stays removed.
+ const forgotten = entry.forgotten.slice(snapshot.forgotten);
+ entry.forgotten.length = snapshot.forgotten;
+ series.set(seriesKey, entry);
+ return forgotten.flatMap((id) => forgetFrom(entry, seriesKey, id));
+ });
+
+ const results = planned.map(({ result }) => result);
+ return {
+ updates: results.flatMap((result) => result.updates),
+ conflictingInstanceUids: results.flatMap(
+ (result) => result.conflictingInstanceUids
+ ),
+ removed: results.flatMap((result) => result.removed),
+ rollback,
+ changedSince,
+ };
+ };
+
+ const forget = (collectionId: string) => {
+ return [...series].flatMap(([seriesKey, entry]) =>
+ forgetFrom(entry, seriesKey, collectionId)
+ );
+ };
+
+ const has = (chunk: Chunk) => {
+ const { metadata } = chunk;
+ if (!metadata) return false;
+ return (
+ series.get(seriesKeyOf(metadata))?.instances.get(instanceKeyOf(chunk))
+ ?.chunk === chunk
+ );
+ };
+
+ return { register, forget, has };
+}
diff --git a/packages/dicom/core/collections/groupBy.ts b/packages/dicom/core/collections/groupBy.ts
new file mode 100644
index 000000000..fec13bb4b
--- /dev/null
+++ b/packages/dicom/core/collections/groupBy.ts
@@ -0,0 +1,11 @@
+/** Groups preserve input order, both of the keys and of their members. */
+export const groupBy = (items: T[], keyOf: (item: T) => string) => {
+ const groups = new Map();
+ items.forEach((item) => {
+ const key = keyOf(item);
+ const group = groups.get(key);
+ if (group) group.push(item);
+ else groups.set(key, [item]);
+ });
+ return groups;
+};
diff --git a/packages/dicom/core/collections/instanceFacts.ts b/packages/dicom/core/collections/instanceFacts.ts
new file mode 100644
index 000000000..6c4e2774d
--- /dev/null
+++ b/packages/dicom/core/collections/instanceFacts.ts
@@ -0,0 +1,205 @@
+import { dot } from './orientation';
+import { multiframeGeometry } from './multiframeGeometry';
+import type { InstanceFacts } from './planDicomCollections';
+import { Tags } from '../metadata/dicomTags';
+import type { DicomDataset } from '../metadata/dicomDataset';
+import { getFrameTag } from '../metadata/functionalGroups';
+import { trimDicomPadding } from '../utils';
+
+// readDicomTags hands back DICOM's own byte padding, so a value that is only
+// padding is absent rather than an empty string.
+const textOf = (metadata: DicomDataset, tag: string) => {
+ const value = trimDicomPadding(getFrameTag(metadata, tag) ?? '');
+ return value.length > 0 ? value : null;
+};
+
+// Hard facts are compared for equality, so '04', '4' and '4.0' must be one
+// value. A value that is not a number keeps its own spelling.
+const numericTextOf = (metadata: DicomDataset, tag: string) => {
+ const text = textOf(metadata, tag);
+ if (text === null) return null;
+ const value = Number(text);
+ return Number.isFinite(value) ? String(value) : text;
+};
+
+const numberOf = (metadata: DicomDataset, tag: string) => {
+ const text = textOf(metadata, tag);
+ if (text === null) return null;
+ const value = Number(text);
+ return Number.isFinite(value) ? value : null;
+};
+
+// A blank component is absent, not zero: Number('') is 0, which would turn
+// '1\\3' into the readable position [1, 0, 3].
+const vectorOf = (
+ metadata: DicomDataset,
+ tag: string,
+ length: number,
+ frameIndex = 0
+) => {
+ const raw = getFrameTag(metadata, tag, frameIndex);
+ const text = raw === undefined ? null : trimDicomPadding(raw);
+ if (text === null) return null;
+ const parts = text.split('\\').map(trimDicomPadding);
+ const values = parts.map(Number);
+ return parts.length === length &&
+ parts.every((part) => part.length > 0) &&
+ values.every((value) => Number.isFinite(value))
+ ? values
+ : null;
+};
+
+const cross = (left: number[], right: number[]) => [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+];
+
+/**
+ * How far the row and column cosines may be from square, once each is scaled
+ * to unit length, before they span no plane. GDCM allows a thousandth and
+ * rescales any axis it is given, so a value printed to three decimals loads
+ * as the scanner meant it while a skewed basis still does not.
+ */
+export const ORTHOGONALITY_TOLERANCE = 1e-3;
+
+const magnitude = (axis: number[]) => Math.sqrt(dot(axis, axis));
+
+/**
+ * The axis scaled to unit length, or null when it has no length to scale. A
+ * printed cosine is never exactly unit, so the length it was written with is
+ * corrected rather than judged.
+ */
+const unitize = (axis: number[]) => {
+ const size = magnitude(axis);
+ return size > 0 ? axis.map((component) => component / size) : null;
+};
+
+/**
+ * Row and column cosines, normalized, or null when they are not two axes
+ * spanning a plane. A degenerate or skewed basis has no slice normal, so
+ * nothing can be projected on it.
+ */
+const orientationOf = (metadata: DicomDataset, frameIndex: number) => {
+ const cosines = vectorOf(
+ metadata,
+ Tags.ImageOrientationPatient,
+ 6,
+ frameIndex
+ );
+ if (cosines === null) return null;
+
+ const row = unitize(cosines.slice(0, 3));
+ const column = unitize(cosines.slice(3, 6));
+ if (row === null || column === null) return null;
+ if (Math.abs(dot(row, column)) > ORTHOGONALITY_TOLERANCE) return null;
+
+ return [...row, ...column];
+};
+
+/**
+ * The slice normal of a validated orientation: the row cosines crossed with
+ * the column cosines, which is only the z axis for an axial acquisition.
+ * Unit length to within the orthogonality tolerance, since the two axes are
+ * unit and square to it.
+ */
+export const sliceNormalOf = (orientation: number[] | null) =>
+ orientation === null
+ ? null
+ : cross(orientation.slice(0, 3), orientation.slice(3, 6));
+
+/** Where a position sits along a slice normal, in mm. */
+export const projectOnNormal = (
+ normal: number[] | null,
+ position: number[] | null
+) => (normal === null || position === null ? null : dot(normal, position));
+
+// A pixel spacing of zero or less places no sample anywhere.
+const pixelSpacingOf = (metadata: DicomDataset, frameIndex: number) => {
+ const raw = getFrameTag(metadata, Tags.PixelSpacing, frameIndex);
+ const parts = raw?.split('\\').slice(0, 2).map(trimDicomPadding);
+ const spacing =
+ parts?.length === 2 && parts.every(Boolean) ? parts.map(Number) : null;
+ return spacing !== null &&
+ spacing.every((value) => Number.isFinite(value) && value > 0)
+ ? spacing
+ : null;
+};
+
+/**
+ * Where an instance places its samples, for callers that need nothing else.
+ * Reading it costs three tag lookups rather than the twenty a full fact read
+ * takes, which a volume of two thousand slices notices.
+ */
+export function readGeometryFacts(metadata: DicomDataset, frameIndex = 0) {
+ return {
+ orientation: orientationOf(metadata, frameIndex),
+ position: vectorOf(metadata, Tags.ImagePositionPatient, 3, frameIndex),
+ pixelSpacing: pixelSpacingOf(metadata, frameIndex),
+ };
+}
+
+/** Reads planning facts and the full frame geometry from the same header. */
+export function readInstanceDetails(metadata: DicomDataset) {
+ const frames =
+ textOf(metadata, Tags.Modality) === 'SEG'
+ ? null
+ : multiframeGeometry(
+ metadata,
+ numberOf(metadata, Tags.NumberOfFrames) ?? 1
+ );
+ const { orientation, position, pixelSpacing } =
+ frames ?? readGeometryFacts(metadata);
+
+ const facts: InstanceFacts = {
+ ...(frames ? { frameReconstruction: frames.reconstruction } : {}),
+ sopInstanceUid: textOf(metadata, Tags.SOPInstanceUID),
+ seriesInstanceUid: textOf(metadata, Tags.SeriesInstanceUID),
+ seriesNumber: numericTextOf(metadata, Tags.SeriesNumber),
+ sequenceName: textOf(metadata, Tags.SequenceName),
+ sliceThickness: numericTextOf(metadata, Tags.SliceThickness),
+ seriesDate: textOf(metadata, Tags.SeriesDate),
+ rows: numericTextOf(metadata, Tags.Rows),
+ columns: numericTextOf(metadata, Tags.Columns),
+ samplesPerPixel: numericTextOf(metadata, Tags.SamplesPerPixel),
+ numberOfFrames: numericTextOf(metadata, Tags.NumberOfFrames) ?? '1',
+ orientation,
+ position,
+ projectedPosition: projectOnNormal(sliceNormalOf(orientation), position),
+ pixelSpacing,
+ instanceNumber: numberOf(metadata, Tags.InstanceNumber),
+ acquisitionNumber: numericTextOf(metadata, Tags.AcquisitionNumber),
+ temporalPositionIdentifier: numericTextOf(
+ metadata,
+ Tags.TemporalPositionIdentifier
+ ),
+ echoNumbers: numericTextOf(metadata, Tags.EchoNumbers),
+ diffusionBValue: numericTextOf(metadata, Tags.DiffusionBValue),
+ };
+ return { facts, frameGeometry: frames };
+}
+
+/** Reads the planner's plain facts out of one instance's tag values. */
+export function readInstanceFacts(metadata: DicomDataset) {
+ return readInstanceDetails(metadata).facts;
+}
+
+// Carries a separator no UID can hold, so a fallback key never equals a real
+// series key.
+const NO_SERIES_UID = 'no-series';
+
+/**
+ * The grouping key for one instance's series. Instances of different series
+ * are planned independently.
+ */
+export function seriesKeyOf(metadata: DicomDataset) {
+ const seriesUid = textOf(metadata, Tags.SeriesInstanceUID);
+ if (seriesUid !== null) return seriesUid;
+
+ // Escaped, so moving a separator from one part to the other changes the key.
+ return [
+ NO_SERIES_UID,
+ encodeURIComponent(textOf(metadata, Tags.StudyInstanceUID) ?? ''),
+ encodeURIComponent(textOf(metadata, Tags.PatientID) ?? ''),
+ ].join('|');
+}
diff --git a/packages/dicom/core/collections/multiframeGeometry.ts b/packages/dicom/core/collections/multiframeGeometry.ts
new file mode 100644
index 000000000..30adbf375
--- /dev/null
+++ b/packages/dicom/core/collections/multiframeGeometry.ts
@@ -0,0 +1,72 @@
+import type { DicomDataset } from '../metadata/dicomDataset';
+import { readGeometryFacts, sliceNormalOf } from './instanceFacts';
+import { reconstructVolume, type Reconstruction } from './reconstructVolume';
+import { dot, orientationsAgree } from './orientation';
+import { getDatasetTag } from '../metadata/datasetAccess';
+import { trimDicomPadding } from '../utils';
+import { perFrameGroups, sequenceItems } from '../metadata/functionalGroups';
+
+function storageDirection(frames: ReturnType[]) {
+ const first = frames[0];
+ const normal = sliceNormalOf(first.orientation);
+ const last = frames.at(-1)!.position;
+ const delta = first.position?.map((value, index) =>
+ last ? last[index] - value : 0
+ );
+ const sign = normal && delta && dot(normal, delta) < 0 ? -1 : 1;
+ return normal?.map((value) => value * sign) ?? null;
+}
+
+const singleSpatialStack = (groups: DicomDataset[]) =>
+ ['00209056', '00209128'].every((tag) => {
+ const values = groups
+ .map((group) =>
+ trimDicomPadding(
+ getDatasetTag(sequenceItems(group, '00209111')[0], tag) ?? ''
+ )
+ )
+ .filter(Boolean);
+ return new Set(values).size <= 1;
+ });
+
+/**
+ * Patient-space frames keep their stored pixel order. The third direction
+ * follows the positions, which can run opposite the row/column cross product.
+ * Ophthalmic scan-line coordinates require a separate calibrated localizer;
+ * optical SliceThickness cannot substitute for their separation.
+ */
+export function multiframeGeometry(metadata: DicomDataset, count: number) {
+ const groups = perFrameGroups(metadata);
+ const shared = sequenceItems(metadata, '52009229');
+ if (count < 2 || (shared.length === 0 && groups.length === 0)) return null;
+ const frames = Array.from({ length: count }, (_, index) =>
+ readGeometryFacts(metadata, index)
+ );
+ const first = frames[0];
+ const direction = storageDirection(frames);
+ const reconstruction = reconstructVolume(frames, direction);
+ const readable =
+ groups.length === count &&
+ singleSpatialStack(groups) &&
+ frames.every(
+ (frame) =>
+ frame.pixelSpacing !== null &&
+ first.orientation !== null &&
+ frame.orientation !== null &&
+ orientationsAgree(first.orientation, frame.orientation)
+ );
+ const validated: Reconstruction = readable
+ ? reconstruction
+ : {
+ kind: 'irregular',
+ reason: 'unreadable-geometry',
+ sliceSpacing: reconstruction.sliceSpacing,
+ slots: [0],
+ };
+ return {
+ ...first,
+ direction,
+ sliceSpacingSource: 'ImagePositionPatient',
+ reconstruction: { ...validated, slots: [0] },
+ };
+}
diff --git a/packages/dicom/core/collections/orientation.ts b/packages/dicom/core/collections/orientation.ts
new file mode 100644
index 000000000..1529fe569
--- /dev/null
+++ b/packages/dicom/core/collections/orientation.ts
@@ -0,0 +1,11 @@
+/** Minimum agreement of normalized row and column direction cosines. */
+export const ORIENTATION_TOLERANCE = 1e-4;
+
+export const dot = (left: number[], right: number[], offset = 0) =>
+ left[offset] * right[offset] +
+ left[offset + 1] * right[offset + 1] +
+ left[offset + 2] * right[offset + 2];
+
+export const orientationsAgree = (left: number[], right: number[]) =>
+ dot(left, right) >= 1 - ORIENTATION_TOLERANCE &&
+ dot(left, right, 3) >= 1 - ORIENTATION_TOLERANCE;
diff --git a/packages/dicom/core/collections/planDicomCollections.ts b/packages/dicom/core/collections/planDicomCollections.ts
new file mode 100644
index 000000000..450aae54a
--- /dev/null
+++ b/packages/dicom/core/collections/planDicomCollections.ts
@@ -0,0 +1,580 @@
+import { orientationsAgree } from './orientation';
+import { groupBy } from './groupBy';
+import {
+ splitOverlappingAcquisitions,
+ type PositionOf,
+} from './splitOverlappingAcquisitions';
+import { projectOnNormal, sliceNormalOf } from './instanceFacts';
+import {
+ IN_PLANE_TOLERANCE_MM,
+ reconstructInstances,
+ type IrregularReason,
+ type Reconstruction,
+} from './reconstructVolume';
+
+/**
+ * Facts read once per instance from a chunk's metadata. Plain data: the planner
+ * never sees a Chunk, a store, or an itk-wasm task.
+ */
+export type InstanceFacts = {
+ // Identity. Instances without a SOP Instance UID are never deduplicated.
+ sopInstanceUid: string | null;
+ seriesInstanceUid: string | null;
+
+ // Hard partition facts, compared for equality.
+ seriesNumber: string | null;
+ sequenceName: string | null;
+ sliceThickness: string | null;
+ seriesDate: string | null;
+ rows: string | null;
+ columns: string | null;
+ samplesPerPixel: string | null;
+ numberOfFrames: string | null;
+
+ // Geometry. Any of these may be unreadable.
+ orientation: number[] | null;
+ position: number[] | null;
+ projectedPosition: number | null;
+ pixelSpacing: number[] | null;
+ /** Intra-instance frame geometry; slots still index source chunks. */
+ frameReconstruction?: Reconstruction;
+
+ // Ordering fallback.
+ instanceNumber: number | null;
+
+ // Semantic partition facts: what distinguishes one scan of a range from
+ // another within a series. Normalized for equality.
+ acquisitionNumber: string | null;
+ temporalPositionIdentifier: string | null;
+ echoNumbers: string | null;
+ diffusionBValue: string | null;
+};
+
+export type CollectionKey = {
+ seriesKey: string;
+ parts: Array<[string, string | null]>;
+};
+
+export type MemberOrder = 'spatial' | 'instance-number' | 'input';
+
+export type DicomCollection = {
+ key: CollectionKey;
+ members: InstanceFacts[];
+ order: MemberOrder;
+ // The axes the members were ordered and judged on. Allocation must build
+ // on these too, or the warnings describe a volume other than the one shown.
+ orientation: number[] | null;
+ reconstruction: Reconstruction;
+ // Names the scan the collection was separated out as ('acquisition 2',
+ // 'phase 3, echo 1'); null when nothing separated it from its series.
+ label: string | null;
+ // Per-member notes about how the plan was reached.
+ diagnostics: string[];
+ // Conditions the user should hear about: the collection loads, but not as
+ // the sound volume its series promised.
+ warnings: string[];
+};
+
+export type DicomCollectionPlan = {
+ collections: DicomCollection[];
+};
+
+export type PlanInput = {
+ seriesKey: string;
+ instances: InstanceFacts[];
+};
+
+export { ORIENTATION_TOLERANCE } from './orientation';
+
+// The hard facts, in the order they appear in a collection key.
+export const HARD_FACT_RULES = [
+ 'seriesNumber',
+ 'sequenceName',
+ 'sliceThickness',
+ 'seriesDate',
+ 'rows',
+ 'columns',
+ 'samplesPerPixel',
+ 'numberOfFrames',
+] as const;
+
+export const ORIENTATION_RULE = 'orientation';
+
+export const REPEATED_POSITIONS_WARNING =
+ 'holds repeated slice positions that no tag separates. Its slice spacing ' +
+ 'and measurements along the slice axis may be wrong.';
+
+export const IRREGULAR_VOLUME_WARNING =
+ 'does not sample one regular volume: its slices are not evenly spaced on ' +
+ 'one grid. They were placed one slot apart in the order shown, so ' +
+ 'positions and measurements along the slice axis may be wrong.';
+
+export const UNREADABLE_POSITION_WARNING =
+ 'holds slices whose position could not be read. They were kept out of the ' +
+ 'volumes of their series, and their order and spacing may be wrong.';
+
+export const NONPARALLEL_SLICES_WARNING =
+ 'holds slices that are not parallel to one another. They were stacked on ' +
+ 'the axes most of them declare, so positions and measurements may be wrong.';
+
+export const IN_PLANE_SHIFT_WARNING =
+ 'holds slices that are offset within their own plane from one to the next, ' +
+ 'as a tilted gantry produces. They were stacked without correcting for the ' +
+ 'offset, so positions and measurements across slices may be wrong.';
+
+// A repeated or unreadable position makes a stack irregular too, and already
+// carries a warning of its own.
+const RECONSTRUCTION_WARNINGS: Record = {
+ 'unreadable-geometry': [],
+ 'repeated-position': [],
+ 'uneven-slice-spacing': [IRREGULAR_VOLUME_WARNING],
+ 'in-plane-shift': [IN_PLANE_SHIFT_WARNING],
+ 'mixed-pixel-spacing': [IRREGULAR_VOLUME_WARNING],
+};
+
+export const UNREADABLE_FRAME_GEOMETRY_WARNING =
+ 'holds frames whose spatial geometry could not be established. They were ' +
+ 'placed one slot apart in stored order, so positions and measurements ' +
+ 'along the slice axis may be wrong.';
+
+const reconstructionWarnings = (
+ reconstruction: Reconstruction,
+ intraInstance: boolean
+) => {
+ if (reconstruction.kind === 'regular') return [];
+ if (intraInstance && reconstruction.reason === 'unreadable-geometry')
+ return [UNREADABLE_FRAME_GEOMETRY_WARNING];
+ if (intraInstance && reconstruction.reason === 'repeated-position')
+ return [REPEATED_POSITIONS_WARNING];
+ return RECONSTRUCTION_WARNINGS[reconstruction.reason];
+};
+
+const ANONYMOUS = 'an instance with no SOP Instance UID';
+
+const label = (instance: InstanceFacts) =>
+ instance.sopInstanceUid === null
+ ? ANONYMOUS
+ : `instance ${instance.sopInstanceUid}`;
+
+// Nulls sort last so a missing identity never becomes a bucket representative.
+const compareUid = (left: string | null, right: string | null) => {
+ if (left === right) return 0;
+ if (left === null) return 1;
+ if (right === null) return -1;
+ return left < right ? -1 : 1;
+};
+
+const compareBySopUid = (left: InstanceFacts, right: InstanceFacts) =>
+ compareUid(left.sopInstanceUid, right.sopInstanceUid);
+
+const readableNumber = (value: number | null): value is number =>
+ value !== null && Number.isFinite(value);
+
+const compareText = (left: string, right: string) => {
+ if (left === right) return 0;
+ return left < right ? -1 : 1;
+};
+
+// Unreadable numbers sort last so they never win a tie-break.
+const compareNumber = (left: number | null, right: number | null) => {
+ if (!readableNumber(left)) return readableNumber(right) ? 1 : 0;
+ if (!readableNumber(right)) return -1;
+ return left === right ? 0 : left - right;
+};
+
+// JSON collapses NaN and Infinity onto null; keep them apart so the signature
+// below separates every pair of unequal instances.
+const encodeFact = (_key: string, value: unknown) =>
+ typeof value === 'number' && !Number.isFinite(value) ? String(value) : value;
+
+const contentSignature = (instance: InstanceFacts) =>
+ JSON.stringify(
+ [
+ instance.sopInstanceUid,
+ instance.seriesInstanceUid,
+ ...HARD_FACT_RULES.map((rule) => instance[rule]),
+ instance.orientation,
+ instance.position,
+ instance.projectedPosition,
+ instance.pixelSpacing,
+ instance.frameReconstruction,
+ instance.instanceNumber,
+ ],
+ encodeFact
+ );
+
+/**
+ * Total order over instances that share a SOP Instance UID comparison, which
+ * happens whenever two instances both lack one.
+ */
+const compareByContent = (left: InstanceFacts, right: InstanceFacts) =>
+ compareText(
+ JSON.stringify(left.orientation),
+ JSON.stringify(right.orientation)
+ ) ||
+ compareNumber(left.projectedPosition, right.projectedPosition) ||
+ compareNumber(left.instanceNumber, right.instanceNumber) ||
+ compareText(contentSignature(left), contentSignature(right));
+
+// Sorting on the UID alone ties every anonymous instance, leaving the greedy
+// orientation walk at the mercy of input order.
+const compareWalkOrder = (left: InstanceFacts, right: InstanceFacts) =>
+ compareBySopUid(left, right) || compareByContent(left, right);
+
+const readableOrientation = (value: number[] | null): value is number[] =>
+ value !== null &&
+ value.length === 6 &&
+ value.every((v) => Number.isFinite(v));
+
+const orientationValue = (orientation: number[]) =>
+ orientation.map(String).join(',');
+
+// Ties go to the first given, so a walk ordered input decides them the same
+// way whatever order the files arrived in.
+const mostDeclared = (orientations: number[][]) => {
+ const counts = new Map();
+ orientations.forEach((orientation) => {
+ const value = orientationValue(orientation);
+ counts.set(value, (counts.get(value) ?? 0) + 1);
+ });
+ const countOf = (orientation: number[]) =>
+ counts.get(orientationValue(orientation)) as number;
+ return orientations.reduce((best, orientation) =>
+ countOf(orientation) > countOf(best) ? orientation : best
+ );
+};
+
+/**
+ * How far a member's far corner sits from where the volume axes put it, in
+ * mm. A volume has one direction, so a slice leaning off it is resampled
+ * nowhere: its samples are simply shown as if it did not lean.
+ */
+const leanOffAxes = (axes: number[], member: InstanceFacts) => {
+ const { orientation } = member;
+ if (!readableOrientation(orientation)) return 0;
+ const [rowSpacing, columnSpacing] = member.pixelSpacing ?? [1, 1];
+ const width = Number(member.columns) * columnSpacing;
+ const height = Number(member.rows) * rowSpacing;
+ return Math.hypot(
+ ...[0, 1, 2].map(
+ (i) =>
+ width * (orientation[i] - axes[i]) +
+ height * (orientation[i + 3] - axes[i + 3])
+ )
+ );
+};
+
+const nonparallel = (axes: number[] | null, members: InstanceFacts[]) =>
+ axes !== null &&
+ members.some((member) => leanOffAxes(axes, member) > IN_PLANE_TOLERANCE_MM);
+
+const canonicalKey = (key: CollectionKey) =>
+ JSON.stringify([key.seriesKey, key.parts]);
+
+/** Last occurrence of a SOP Instance UID wins, so the newest batch survives. */
+const dedupe = (instances: InstanceFacts[]) => {
+ const lastIndex = new Map();
+ instances.forEach((instance, index) => {
+ if (instance.sopInstanceUid !== null)
+ lastIndex.set(instance.sopInstanceUid, index);
+ });
+
+ const kept = instances.filter(
+ (instance, index) =>
+ instance.sopInstanceUid === null ||
+ lastIndex.get(instance.sopInstanceUid) === index
+ );
+
+ const droppedCounts = new Map();
+ instances.forEach((instance, index) => {
+ const uid = instance.sopInstanceUid;
+ if (uid === null || lastIndex.get(uid) === index) return;
+ droppedCounts.set(uid, (droppedCounts.get(uid) ?? 0) + 1);
+ });
+
+ const diagnostics = new Map();
+ kept.forEach((instance) => {
+ const uid = instance.sopInstanceUid;
+ const dropped = uid === null ? 0 : (droppedCounts.get(uid) ?? 0);
+ if (dropped > 0)
+ diagnostics.set(
+ instance,
+ `dropped ${dropped} earlier copy of instance ${uid}`
+ );
+ });
+
+ return { kept, diagnostics };
+};
+
+const hardFactSignature = (instance: InstanceFacts) =>
+ JSON.stringify(HARD_FACT_RULES.map((rule) => instance[rule]));
+
+const hardFactParts = (instance: InstanceFacts) =>
+ HARD_FACT_RULES.map(
+ (rule) => [rule, instance[rule]] as [string, string | null]
+ );
+
+/**
+ * Greedy bucketing walked in SOP Instance UID then content order, so the
+ * representative whose cosines become the key, and the membership the greedy
+ * walk produces, do not depend on input order.
+ */
+const bucketByOrientation = (instances: InstanceFacts[]) => {
+ const references: number[][] = [];
+ const bucketOf = new Map();
+
+ const walked = [...instances].sort(compareWalkOrder);
+ walked.forEach((instance) => {
+ const { orientation } = instance;
+ if (!readableOrientation(orientation)) {
+ bucketOf.set(instance, -1);
+ return;
+ }
+ const found = references.findIndex((reference) =>
+ orientationsAgree(reference, orientation)
+ );
+ bucketOf.set(
+ instance,
+ found >= 0 ? found : references.push(orientation) - 1
+ );
+ });
+
+ // One normal for the whole bucket: two members agree within the tolerance,
+ // so projecting each on its own cosines would spread one slice plane over
+ // several positions. The reference stays the key, which a collection ID is
+ // minted from; the axes are the ones most members declare, so a stray slice
+ // that happens to be walked first does not lean every other one.
+ const buckets = references.map((reference, index) => {
+ const orientation = mostDeclared(
+ walked
+ .filter((instance) => bucketOf.get(instance) === index)
+ .map((instance) => instance.orientation as number[])
+ );
+ return {
+ value: orientationValue(reference) as string | null,
+ orientation: orientation as number[] | null,
+ normal: sliceNormalOf(orientation) as number[] | null,
+ members: [] as InstanceFacts[],
+ };
+ });
+ const unreadable = {
+ value: null,
+ orientation: null,
+ normal: null,
+ members: [] as InstanceFacts[],
+ };
+
+ instances.forEach((instance) => {
+ const index = bucketOf.get(instance) ?? -1;
+ const bucket = index === -1 ? unreadable : buckets[index];
+ bucket.members.push(instance);
+ });
+
+ return unreadable.members.length > 0 ? [...buckets, unreadable] : buckets;
+};
+
+// A repeated position breaks on the instance number, keeping the scanner's
+// own sequence for the tied members. Position stays primary: the allocator
+// places slot i at origin + i * spacing along the normal, so any order that
+// is not monotone in position mirrors or scrambles the volume. Anonymous
+// members that still tie order by content rather than by input position.
+const comparePosition =
+ (positionOf: PositionOf) => (left: InstanceFacts, right: InstanceFacts) =>
+ (positionOf(left) as number) - (positionOf(right) as number) ||
+ compareNumber(left.instanceNumber, right.instanceNumber) ||
+ compareWalkOrder(left, right);
+
+const compareInstanceNumber = (left: InstanceFacts, right: InstanceFacts) =>
+ (left.instanceNumber as number) - (right.instanceNumber as number) ||
+ compareWalkOrder(left, right);
+
+/** Never inherits input order silently: the order actually used is recorded. */
+const orderMembers = (members: InstanceFacts[], positionOf: PositionOf) => {
+ const numbered = members.every((m) => readableNumber(m.instanceNumber));
+
+ if (members.every((m) => readableNumber(positionOf(m))))
+ return {
+ members: [...members].sort(comparePosition(positionOf)),
+ order: 'spatial' as MemberOrder,
+ diagnostics: [] as string[],
+ };
+
+ const positionless = (ordered: InstanceFacts[]) =>
+ ordered
+ .filter((m) => !readableNumber(positionOf(m)))
+ .map((m) => `${label(m)} has no readable position`);
+
+ if (numbered) {
+ const ordered = [...members].sort(compareInstanceNumber);
+ return {
+ members: ordered,
+ order: 'instance-number' as MemberOrder,
+ diagnostics: positionless(ordered),
+ };
+ }
+
+ return {
+ members: [...members],
+ order: 'input' as MemberOrder,
+ diagnostics: [
+ ...positionless(members),
+ ...members
+ .filter((m) => !readableNumber(m.instanceNumber))
+ .map((m) => `${label(m)} has no readable instance number`),
+ ],
+ };
+};
+
+/** Occurrences per instance, so an input holding one object twice needs two. */
+const tally = (instances: InstanceFacts[]) => {
+ const counts = new Map();
+ instances.forEach((instance) =>
+ counts.set(instance, (counts.get(instance) ?? 0) + 1)
+ );
+ return counts;
+};
+
+/**
+ * Throws when the collections do not hold every instance as many times as the
+ * input does, when they hold one the input never gave, or when two collections
+ * share a key.
+ */
+export function validateCollections(
+ instances: InstanceFacts[],
+ collections: DicomCollection[]
+) {
+ const expected = tally(instances);
+ const placed = tally(collections.flatMap((collection) => collection.members));
+
+ expected.forEach((count, instance) => {
+ const found = placed.get(instance) ?? 0;
+ if (found === count) return;
+ if (found === 0)
+ throw new Error(`${label(instance)} landed in no collection`);
+ throw new Error(
+ `${label(instance)} landed in ${found} collections, expected ${count}`
+ );
+ });
+
+ placed.forEach((_count, instance) => {
+ if (!expected.has(instance))
+ throw new Error(
+ `${label(instance)} landed in a collection without being an input`
+ );
+ });
+
+ const seen = new Set();
+ collections.forEach((collection) => {
+ const encoded = canonicalKey(collection.key);
+ if (seen.has(encoded))
+ throw new Error(`two collections share the key ${encoded}`);
+ seen.add(encoded);
+ });
+}
+
+export function planDicomCollections(input: PlanInput) {
+ const { kept, diagnostics: dedupeDiagnostics } = dedupe(input.instances);
+
+ const multiframe = (instance: InstanceFacts) =>
+ Number(instance.numberOfFrames) > 1;
+ const groups = [
+ ...groupBy(
+ kept.filter((instance) => !multiframe(instance)),
+ hardFactSignature
+ ).values(),
+ ...kept.filter(multiframe).map((instance) => [instance]),
+ ];
+ const planned = groups
+ .flatMap((group) =>
+ bucketByOrientation(group).map((bucket) => ({
+ group,
+ bucket,
+ positionOf: ((member) =>
+ projectOnNormal(bucket.normal, member.position)) as PositionOf,
+ }))
+ )
+ .flatMap(({ group, bucket, positionOf }) =>
+ splitOverlappingAcquisitions(bucket.members, positionOf).map((part) => ({
+ group,
+ bucket,
+ positionOf,
+ part,
+ }))
+ )
+ .map(({ group, bucket, positionOf, part }) => {
+ const ordered = orderMembers(part.members, positionOf);
+ const reconstruction = reconstructInstances(
+ ordered.members,
+ bucket.normal
+ );
+ const key: CollectionKey = {
+ seriesKey: input.seriesKey,
+ parts: [
+ ...hardFactParts(group[0]),
+ [ORIENTATION_RULE, bucket.value],
+ ...part.parts,
+ ...(multiframe(group[0])
+ ? [
+ [
+ 'instance',
+ group[0].sopInstanceUid ?? String(kept.indexOf(group[0])),
+ ] as [string, string],
+ ]
+ : []),
+ ],
+ };
+ return {
+ key,
+ members: ordered.members,
+ order: ordered.order,
+ orientation: bucket.orientation,
+ reconstruction,
+ label: part.label,
+ diagnostics: [
+ ...ordered.members.flatMap((member) => {
+ const dropped = dedupeDiagnostics.get(member);
+ return dropped ? [dropped] : [];
+ }),
+ ...(bucket.value === null
+ ? ordered.members.map(
+ (member) => `${label(member)} has no readable orientation`
+ )
+ : []),
+ ...ordered.diagnostics,
+ ],
+ warnings: [
+ ...(part.repeatedPositions ? [REPEATED_POSITIONS_WARNING] : []),
+ ...(part.unreadablePositions && !multiframe(group[0])
+ ? [UNREADABLE_POSITION_WARNING]
+ : []),
+ ...reconstructionWarnings(
+ reconstruction,
+ ordered.members.length === 1 &&
+ !!ordered.members[0].frameReconstruction
+ ),
+ ...(nonparallel(bucket.orientation, ordered.members)
+ ? [NONPARALLEL_SLICES_WARNING]
+ : []),
+ ],
+ };
+ })
+ .sort((left, right) =>
+ canonicalKey(left.key) < canonicalKey(right.key) ? -1 : 1
+ );
+
+ // A series that plans to one instance is a whole volume: nothing was held
+ // out of a sibling, and one slice has no order or spacing to get wrong.
+ const lone = planned.length === 1 && planned[0].members.length === 1;
+ const collections = lone
+ ? planned.map((collection) => ({
+ ...collection,
+ warnings: collection.warnings.filter(
+ (warning) => warning !== UNREADABLE_POSITION_WARNING
+ ),
+ }))
+ : planned;
+
+ validateCollections(kept, collections);
+
+ return { collections };
+}
diff --git a/packages/dicom/core/collections/reconstructVolume.ts b/packages/dicom/core/collections/reconstructVolume.ts
new file mode 100644
index 000000000..be92081d0
--- /dev/null
+++ b/packages/dicom/core/collections/reconstructVolume.ts
@@ -0,0 +1,213 @@
+import type { InstanceFacts } from './planDicomCollections';
+import { projectOnNormal, sliceNormalOf } from './instanceFacts';
+
+/** The geometry a reconstruction judges. Nothing else about an instance. */
+export type ReconstructionMember = Pick<
+ InstanceFacts,
+ 'orientation' | 'position' | 'pixelSpacing'
+> & { frameReconstruction?: Reconstruction };
+
+export type IrregularReason =
+ | 'unreadable-geometry'
+ | 'repeated-position'
+ | 'uneven-slice-spacing'
+ | 'in-plane-shift'
+ | 'mixed-pixel-spacing';
+
+/**
+ * What the members can be reconstructed as.
+ *
+ * `regular` means every consecutive pair of slice planes is the same distance
+ * apart and the members share one in-plane grid, so member i belongs at slot
+ * `slots[i]` of a volume sampled at `sliceSpacing`. `irregular` means they do
+ * not: the members still load, placed one slot apart at the step the stack
+ * does take, but no lattice describes where they actually are.
+ *
+ * A slice spacing of null means there is nothing to derive one from, which one
+ * member alone always is.
+ */
+export type Reconstruction =
+ | { kind: 'regular'; sliceSpacing: number | null; slots: number[] }
+ | {
+ kind: 'irregular';
+ reason: IrregularReason;
+ sliceSpacing: number | null;
+ slots: number[];
+ };
+
+/**
+ * How far apart two slice gaps may be and still be one lattice: a tenth of a
+ * millimetre (capped at a tenth of the gap), or one part in a hundred of the
+ * gap, whichever is larger. DICOM
+ * prints a position as a decimal string, so the projection of an oblique stack
+ * onto its normal jitters in the last digits; a genuine slab boundary or a
+ * missed slice is orders of magnitude larger.
+ */
+export const SLICE_SPACING_TOLERANCE_MM = 0.1;
+export const SLICE_SPACING_RELATIVE_TOLERANCE = 0.01;
+
+/** How far a slice may sit from the in-plane origin of the first, in mm. */
+export const IN_PLANE_TOLERANCE_MM = 0.1;
+
+/** How far two in-plane sample spacings may differ and still be one grid. */
+export const PIXEL_SPACING_TOLERANCE_MM = 1e-4;
+
+const spacingTolerance = (gap: number) =>
+ Math.max(
+ Math.min(SLICE_SPACING_TOLERANCE_MM, Math.abs(gap) * 0.1),
+ Math.abs(gap) * SLICE_SPACING_RELATIVE_TOLERANCE
+ );
+
+// Lower median, so the fallback spacing of an irregular stack is one of its
+// own gaps rather than an average of a gap and a hole.
+const median = (values: number[]) =>
+ [...values].sort((left, right) => left - right)[
+ Math.floor((values.length - 1) / 2)
+ ];
+
+const subtract = (left: number[], right: number[]) =>
+ left.map((value, index) => value - right[index]);
+
+const scale = (vector: number[], factor: number) =>
+ vector.map((value) => value * factor);
+
+const maxAbs = (values: number[]) =>
+ values.reduce((largest, value) => Math.max(largest, Math.abs(value)), 0);
+
+const distance = (left: number[], right: number[]) =>
+ Math.sqrt(
+ subtract(left, right).reduce((sum, value) => sum + value * value, 0)
+ );
+
+/**
+ * The step a stack that is no lattice still takes: the lower median of the
+ * straight-line distance from each member to the next, in the order given.
+ * That is what ITK's series reader steps by, so a tilted gantry's slices,
+ * whose planes shift sideways as they advance, keep the extent they were
+ * scanned over. Null when a position is unreadable or the members never move.
+ */
+const positionStep = (members: ReconstructionMember[]) => {
+ const positions = members.map((member) => member.position);
+ if (positions.length < 2 || positions.some((position) => position === null))
+ return null;
+ const gaps = positions
+ .slice(1)
+ .map((position, index) =>
+ distance(position as number[], positions[index] as number[])
+ );
+ const step = median(gaps);
+ return step > 0 ? step : null;
+};
+
+// A member with no pixel spacing at all is unknown, not different: only two
+// spacings that both read and disagree describe two grids.
+const mixedPixelSpacing = (members: ReconstructionMember[]) => {
+ const spacings = members
+ .map((member) => member.pixelSpacing)
+ .filter((spacing): spacing is number[] => spacing !== null);
+ return spacings.some(
+ (spacing) =>
+ maxAbs(subtract(spacing, spacings[0])) > PIXEL_SPACING_TOLERANCE_MM
+ );
+};
+
+const irregular = (
+ reason: IrregularReason,
+ sliceSpacing: number | null,
+ count: number
+): Reconstruction => ({
+ kind: 'irregular',
+ reason,
+ sliceSpacing,
+ slots: Array.from({ length: count }, (_, index) => index),
+});
+
+const inPlaneShifted = (
+ members: ReconstructionMember[],
+ axis: number[],
+ along: number[]
+) => {
+ const inPlaneOf = (index: number) =>
+ subtract(members[index].position as number[], scale(axis, along[index]));
+ const origin = inPlaneOf(0);
+ return members.some(
+ (_member, index) =>
+ maxAbs(subtract(inPlaneOf(index), origin)) > IN_PLANE_TOLERANCE_MM
+ );
+};
+
+/** What stops these members from describing a volume at all, if anything. */
+const geometryFault = (
+ members: ReconstructionMember[],
+ axis: number[] | null,
+ along: Array
+): IrregularReason | null => {
+ if (along.some((position) => !Number.isFinite(position)))
+ return 'unreadable-geometry';
+ if (mixedPixelSpacing(members)) return 'mixed-pixel-spacing';
+ if (inPlaneShifted(members, axis as number[], along as number[]))
+ return 'in-plane-shift';
+ return null;
+};
+
+/** The lattice the slice planes sit on, once they are known to be readable. */
+const lattice = (along: number[]): Reconstruction => {
+ if (along.length < 2)
+ return { kind: 'regular', sliceSpacing: null, slots: [0] };
+
+ const gaps = along.slice(1).map((position, index) => position - along[index]);
+ const step = median(gaps);
+ const count = along.length;
+
+ if (gaps.some((gap) => gap <= 0))
+ return irregular('repeated-position', step > 0 ? step : null, count);
+ if (gaps.some((gap) => Math.abs(gap - step) > spacingTolerance(step)))
+ return irregular('uneven-slice-spacing', step, count);
+
+ // Every gap matches, so the endpoints fit the members better than any single
+ // printed gap does.
+ const sliceSpacing = (along[count - 1] - along[0]) / gaps.length;
+ const slots = along.map((position) =>
+ Math.round((position - along[0]) / sliceSpacing)
+ );
+ return slots.some((slot, index) => slot !== index)
+ ? irregular('uneven-slice-spacing', step, count)
+ : { kind: 'regular', sliceSpacing, slots };
+};
+
+/**
+ * Whether the members sit on one sampled volume, and where each one belongs in
+ * it. Pure: the members are judged by their own geometry, in the order given.
+ *
+ * `normal` is the slice normal the positions are projected on. The planner
+ * passes the one normal its orientation bucket agreed on; without it, the
+ * first member's own cosines decide.
+ */
+export function reconstructVolume(
+ members: ReconstructionMember[],
+ normal?: number[] | null
+): Reconstruction {
+ if (members.length === 0)
+ return { kind: 'regular', sliceSpacing: null, slots: [] };
+
+ const axis = normal ?? sliceNormalOf(members[0].orientation);
+ const along = members.map((member) => projectOnNormal(axis, member.position));
+
+ const fault = geometryFault(members, axis, along);
+ return fault === null
+ ? lattice(along as number[])
+ : irregular(fault, positionStep(members), members.length);
+}
+
+/** Instance slots index source chunks even when one source contains many frames. */
+export function reconstructInstances(
+ members: ReconstructionMember[],
+ normal?: number[] | null,
+ supplied?: Reconstruction
+): Reconstruction {
+ const framePlan =
+ members.length === 1 ? members[0].frameReconstruction : undefined;
+ return framePlan
+ ? { ...framePlan, slots: [0] }
+ : (supplied ?? reconstructVolume(members, normal));
+}
diff --git a/packages/dicom/core/collections/splitOverlappingAcquisitions.ts b/packages/dicom/core/collections/splitOverlappingAcquisitions.ts
new file mode 100644
index 000000000..475a98177
--- /dev/null
+++ b/packages/dicom/core/collections/splitOverlappingAcquisitions.ts
@@ -0,0 +1,236 @@
+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;
+ });
+};
+
+/** Eligible groups in first-seen order, with all other members held apart. */
+const groupByAxis = (members: InstanceFacts[], axis: SemanticAxis) => {
+ const tagged = new Map();
+ members.forEach((member) => {
+ const value = member[axis.fact];
+ if (value === null) return;
+ const group = tagged.get(value);
+ if (group) group.push(member);
+ else tagged.set(value, [member]);
+ });
+ // A counter stamped per slice cannot establish a separate acquisition.
+ const eligible = new Map(
+ [...tagged].filter(([, group]) => !axis.counter || group.length > 1)
+ );
+ const remainder = members.filter((member) => {
+ const value = member[axis.fact];
+ return value === null || !eligible.has(value);
+ });
+ return { tagged: eligible, remainder };
+};
+
+type Split = { value: string | null; members: InstanceFacts[] };
+
+/**
+ * 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(({ tagged }) => {
+ const groups = [...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, remainder } = found;
+ const index = axes.indexOf(axis);
+ const skipped = axes.slice(0, index);
+ const rest = axes.slice(index + 1);
+ const splits: Split[] = [
+ ...[...tagged].map(([value, group]) => ({ value, members: group })),
+ ...(remainder.length > 0 ? [{ value: null, members: remainder }] : []),
+ ];
+
+ 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 eligible group at that level, and a counter axis neither fans out a
+ * repeated single plane nor splits on groups of one slice. Members that lack
+ * the level's value and singleton counter groups form a remainder part rather
+ * than blocking the split, so the result depends only on the members given.
+ */
+export function splitOverlappingAcquisitions(
+ members: InstanceFacts[],
+ 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/metadata/__tests__/dcmjsParser.spec.ts b/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts
new file mode 100644
index 000000000..a0ea62921
--- /dev/null
+++ b/packages/dicom/core/metadata/__tests__/dcmjsParser.spec.ts
@@ -0,0 +1,344 @@
+import { readDicomDataset, findElement } from '../dicomDataset';
+import { getDatasetTag } from '../datasetAccess';
+import { getFrameTag } from '../functionalGroups';
+import { DicomMetaLoader } from '../../streaming/dicom/dicomMetaLoader';
+import { createDicomParser } from '../../streaming/dicom/dicomParser';
+import { blobFetcher } from '../../streaming/blobFetcher';
+import { deflateRawSync } from 'node:zlib';
+import { describe, expect, it } from 'vitest';
+import { parseDicomElements } from '../dcmjsParser';
+import { PART_10_HEADER_LENGTH, PREAMBLE_LENGTH } from '../dicomLayout';
+import { concatBytes } from '../../utils';
+import { buildSlice } from '@/src/io/__tests__/readDicomTagsFixtures';
+import { bytesFetcher, pixelDataStart } from '@/tests/dicomSourceFixtures';
+import {
+ rawElement,
+ sequenceItem,
+ toImplicitVr,
+ stripFileMeta,
+ stripPreamble,
+ undefinedLengthItem,
+ undefinedLengthSequence,
+} from '@/tests/specs/syntheticDicom';
+
+// (0002,0000) UL occupies twelve bytes, including its value.
+const GROUP_LENGTH_ELEMENT_LENGTH = 12;
+const withoutGroupLength = (file: Uint8Array) =>
+ concatBytes([
+ file.subarray(0, PART_10_HEADER_LENGTH),
+ file.subarray(PART_10_HEADER_LENGTH + GROUP_LENGTH_ELEMENT_LENGTH),
+ ]);
+
+// An empty fixed-Huffman block followed by a stored block and a final empty
+// block is valid raw DEFLATE. Its first two bytes look like DICOM group 2.
+const deflateWithMetaGroupPrefix = (data: Uint8Array) => {
+ const header = new Uint8Array([2, 0, 0, 0, 0, 0]);
+ const view = new DataView(header.buffer);
+ view.setUint16(2, data.length, true);
+ view.setUint16(4, 0xffff ^ data.length, true);
+ return concatBytes([header, data, new Uint8Array([3, 0])]);
+};
+
+describe('file meta information', () => {
+ it.each([
+ { vr: 'explicit', implicitVr: false, preamble: true },
+ { vr: 'implicit', implicitVr: true, preamble: true },
+ { vr: 'explicit', implicitVr: false, preamble: false },
+ { vr: 'implicit', implicitVr: true, preamble: false },
+ ])(
+ 'reads $vr VR without group length (preamble: $preamble)',
+ ({ implicitVr, preamble }) => {
+ const file = buildSlice({ implicitVr });
+ file.fill(0x41, 0, PREAMBLE_LENGTH);
+ const input = withoutGroupLength(file);
+
+ expect(
+ parseDicomElements(preamble ? input : stripPreamble(input))
+ ).toEqual(parseDicomElements(file));
+ }
+ );
+
+ it.each([
+ { groupLength: true, prefix: 'normal', deflate: deflateRawSync },
+ { groupLength: false, prefix: 'normal', deflate: deflateRawSync },
+ {
+ groupLength: true,
+ prefix: 'group 2',
+ deflate: deflateWithMetaGroupPrefix,
+ },
+ ])(
+ 'reads deflated data after nonzero preamble bytes (group length: $groupLength, prefix: $prefix)',
+ ({ groupLength, deflate }) => {
+ const file = buildSlice();
+ file.fill(0x41, 0, PREAMBLE_LENGTH);
+ const encode = (text: string) => new TextEncoder().encode(text);
+ const meta = concatBytes([
+ rawElement(0x0002, 0x0010, 'UI', encode('1.2.840.10008.1.2.1.99')),
+ // The transfer syntax need not be the last file-meta element.
+ rawElement(0x0002, 0x0013, 'SH', encode('VOLVIEW ')),
+ ]);
+ const length = new Uint8Array(4);
+ new DataView(length.buffer).setUint32(0, meta.length, true);
+ const input = concatBytes([
+ file.subarray(0, PART_10_HEADER_LENGTH),
+ ...(groupLength ? [rawElement(0x0002, 0x0000, 'UL', length)] : []),
+ meta,
+ deflate(stripFileMeta(file)),
+ ]);
+
+ expect(parseDicomElements(input)).toEqual(parseDicomElements(file));
+ }
+ );
+
+ it('still rejects file meta information without a transfer syntax', () => {
+ const file = buildSlice();
+ const input = concatBytes([
+ file.subarray(0, PART_10_HEADER_LENGTH),
+ rawElement(0x0002, 0x0000, 'UL', new Uint8Array(4)),
+ stripFileMeta(file),
+ ]);
+
+ expect(() => parseDicomElements(input)).toThrow(
+ 'the transfer syntax is missing'
+ );
+ });
+});
+
+describe('lenient headers with standards-defined values', () => {
+ const encode = (text: string) => new TextEncoder().encode(text);
+ it.each([false, true])(
+ 'ignores an undersized meta group length (implicit: %s)',
+ (implicitVr) => {
+ const original = buildSlice({ implicitVr });
+ const input = original.slice();
+ new DataView(input.buffer).setUint32(PART_10_HEADER_LENGTH + 8, 0, true);
+ expect(parseDicomElements(input)).toEqual(parseDicomElements(original));
+ }
+ );
+
+ it('reads a defined-length SQ containing a redundant sequence delimiter', async () => {
+ const value = concatBytes([
+ sequenceItem(rawElement(0x0008, 0x1155, 'UI', encode('1.2.3\0'))),
+ new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]),
+ ]);
+ const bytes = buildSlice({
+ extraElements: [rawElement(0x0008, 0x1140, 'SQ', value)],
+ });
+ const loader = new DicomMetaLoader(
+ blobFetcher(new Blob([new Uint8Array(bytes)]))
+ );
+ await loader.load();
+ expect(getDatasetTag(loader.meta, '00280010')).toBe('4');
+ loader.dispose();
+ });
+
+ it('keeps a prematurely delimited SQ inside its declared boundary', async () => {
+ const sequence = rawElement(
+ 0x0008,
+ 0x1140,
+ 'SQ',
+ concatBytes([
+ new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]),
+ rawElement(0x7fe0, 0x0010, 'OW', new Uint8Array(16)),
+ ])
+ );
+ const trailing = rawElement(0x0043, 0x1030, 'LO', encode('AFTER '));
+ const bytes = buildSlice({ extraElements: [sequence, trailing] });
+ const expectedPixelStart =
+ pixelDataStart(buildSlice()) + sequence.length + trailing.length;
+ const parse = createDicomParser({
+ stopAtElement: (group, element) => group === 0x7fe0 && element === 0x0010,
+ });
+ expect(parse(bytes)).toMatchObject({
+ done: true,
+ value: { position: expectedPixelStart },
+ });
+
+ const loader = new DicomMetaLoader(bytesFetcher(bytes, 7));
+ await loader.load();
+ expect(getDatasetTag(loader.meta, '00431030')).toBe('AFTER ');
+ expect(loader.meta).toEqual(readDicomDataset(bytes));
+ loader.dispose();
+ });
+
+ it('rejects a sequence whose declared boundary exceeds the file', async () => {
+ const sequence = rawElement(
+ 0x0008,
+ 0x1140,
+ 'SQ',
+ concatBytes([
+ new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]),
+ new Uint8Array(16),
+ ])
+ );
+ const base = buildSlice();
+ const bytes = concatBytes([
+ base.subarray(0, pixelDataStart(base)),
+ sequence.subarray(0, sequence.length - 2),
+ ]);
+ expect(() => readDicomDataset(bytes)).toThrow('available dataset bytes');
+ const loader = new DicomMetaLoader(bytesFetcher(bytes, 7));
+ await expect(loader.load()).rejects.toThrow('available dataset bytes');
+ loader.dispose();
+ });
+
+ it('does not recover a nested sequence past its enclosing item', async () => {
+ const inner = rawElement(
+ 0x0008,
+ 0x1140,
+ 'SQ',
+ concatBytes([
+ new Uint8Array([0xfe, 0xff, 0xdd, 0xe0, 0, 0, 0, 0]),
+ new Uint8Array(16),
+ ])
+ );
+ const bytes = buildSlice({
+ extraElements: [
+ rawElement(0x0040, 0x0275, 'SQ', sequenceItem(inner.subarray(0, 20))),
+ rawElement(0x0043, 0x1030, 'LO', encode('AFTER ')),
+ ],
+ });
+ const dataset = readDicomDataset(bytes);
+ expect(findElement(dataset, '00400275')?.values.kind).toBe('unread');
+ expect(getDatasetTag(dataset, '00431030')).toBe('AFTER ');
+ const loader = new DicomMetaLoader(bytesFetcher(bytes, 7));
+ await expect(loader.load()).rejects.toThrow('enclosing sequence or item');
+ loader.dispose();
+ });
+
+ it('decodes a known defined-length UN using the dataset character set', () => {
+ const dataset = readDicomDataset(
+ concatBytes([
+ rawElement(0x0008, 0x0005, 'CS', encode('ISO_IR 192')),
+ rawElement(0x0010, 0x0010, 'UN', encode('M\u00dcLLER ')),
+ ])
+ );
+ expect(getDatasetTag(dataset, '00100010')?.trim()).toBe('M\u00dcLLER');
+ });
+
+ it('reads functional groups stored as defined-length UN with implicit items', () => {
+ const spacing = rawElement(
+ 0x0028,
+ 0x9110,
+ 'SQ',
+ sequenceItem(rawElement(0x0028, 0x0030, 'DS', encode('0.5\\0.75')))
+ );
+ const dataset = readDicomDataset(
+ concatBytes([
+ rawElement(0x0008, 0x0060, 'CS', encode('CT')),
+ rawElement(0x5200, 0x9229, 'UN', sequenceItem(toImplicitVr(spacing))),
+ ])
+ );
+ expect(getFrameTag(dataset, '00280030')).toBe('0.5\\0.75');
+ });
+
+ it('starts undeclared escape runs in the first declared repertoire', () => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ specificCharacterSet: 'ISO 2022 IR 100\\ISO 2022 IR 126',
+ patientNameBytes: new Uint8Array([0x52, 0xe9, 0x6d, 0x79]),
+ })
+ );
+ expect(getDatasetTag(dataset, '00100010')).toBe('R\u00e9my');
+ });
+
+ it('preserves binary values in sequence items', () => {
+ const bytes = new Uint8Array([1, 2, 3, 4]);
+ const dataset = readDicomDataset(
+ buildSlice({
+ extraElements: [
+ rawElement(
+ 0x0008,
+ 0x1140,
+ 'SQ',
+ sequenceItem(rawElement(0x0029, 0x1010, 'OB', bytes))
+ ),
+ ],
+ })
+ );
+ const sequence = findElement(dataset, '00081140')!.values;
+ if (sequence.kind !== 'sequence') throw new Error('Expected a sequence');
+ expect(findElement(sequence.items[0], '00291010')!.values).toEqual({
+ kind: 'bytes',
+ values: [bytes],
+ });
+ });
+
+ it.each([false, true])(
+ 'reads ultrasound calibration from the structured header (implicit: %s)',
+ async (implicitVr) => {
+ const bytes = buildSlice({
+ implicitVr,
+ modality: 'US',
+ ultrasoundRegion: { physicalDeltaX: 0.05, physicalDeltaY: 0.1 },
+ });
+ const loader = new DicomMetaLoader(
+ blobFetcher(new Blob([new Uint8Array(bytes)]))
+ );
+ await loader.load();
+ expect(loader.ultrasoundRegions).toMatchObject({
+ regionCount: 1,
+ region: { physicalDeltaX: 0.05, physicalDeltaY: 0.1 },
+ });
+ loader.dispose();
+ }
+ );
+});
+
+it.each([sequenceItem, undefinedLengthItem])(
+ 'reads an implicit private undefined-length sequence with %s',
+ async (item) => {
+ const encode = (text: string) => new TextEncoder().encode(text);
+ const content = toImplicitVr(
+ rawElement(0x0010, 0x0010, 'PN', encode('DOE^JANE'))
+ );
+ const explicit = undefinedLengthSequence(0x0043, 0x1029, item(content));
+ const implicit = concatBytes([
+ explicit.subarray(0, 4),
+ explicit.subarray(8),
+ ]);
+ const base = buildSlice({ implicitVr: true });
+ const pixelStart = pixelDataStart(base);
+ const bytes = concatBytes([
+ base.subarray(0, pixelStart),
+ implicit,
+ base.subarray(pixelStart),
+ ]);
+ const dataset = readDicomDataset(bytes);
+ const sequence = findElement(dataset, '00431029')!.values;
+ if (sequence.kind !== 'sequence') throw new Error('Expected a sequence');
+ expect(getDatasetTag(sequence.items[0], '00100010')).toBe('DOE^JANE');
+ const loader = new DicomMetaLoader(bytesFetcher(bytes, 7));
+ await loader.load();
+ expect(loader.meta).toEqual(dataset);
+ loader.dispose();
+ }
+);
+
+it('decodes a known UN nested inside an explicit sequence with inherited UTF-8', () => {
+ const encode = (text: string) => new TextEncoder().encode(text);
+ const dataset = readDicomDataset(
+ concatBytes([
+ rawElement(0x0008, 0x0005, 'CS', encode('ISO_IR 192')),
+ rawElement(
+ 0x0008,
+ 0x1140,
+ 'SQ',
+ sequenceItem(rawElement(0x0010, 0x0010, 'UN', encode('M\u00dcLLER ')))
+ ),
+ ])
+ );
+ const sequence = findElement(dataset, '00081140')!.values;
+ if (sequence.kind !== 'sequence') throw new Error('Expected a sequence');
+ expect(getDatasetTag(sequence.items[0], '00100010')?.trim()).toBe(
+ 'M\u00dcLLER'
+ );
+});
+
+it('stops before a known UN Pixel Data value, even when only its header is present', () => {
+ const input = concatBytes([
+ rawElement(0x0008, 0x0060, 'CS', new TextEncoder().encode('CT')),
+ rawElement(0x7fe0, 0x0010, 'UN', new Uint8Array(16)).subarray(0, 12),
+ ]);
+ expect(parseDicomElements(input)).toHaveLength(1);
+});
diff --git a/packages/dicom/core/metadata/__tests__/dicomDataset.spec.ts b/packages/dicom/core/metadata/__tests__/dicomDataset.spec.ts
new file mode 100644
index 000000000..4d4436b4b
--- /dev/null
+++ b/packages/dicom/core/metadata/__tests__/dicomDataset.spec.ts
@@ -0,0 +1,356 @@
+import { describe, expect, it } from 'vitest';
+import { DicomDataset, findElement, readDicomDataset } from '../dicomDataset';
+import {
+ rawElement,
+ sequenceItem,
+ undefinedLengthItem,
+ undefinedLengthSequence,
+} from '@/tests/specs/syntheticDicom';
+import {
+ buildSlice,
+ JAPANESE_NAME,
+ JAPANESE_NAME_BYTES,
+ MIXED_VR_ELEMENTS,
+ UTF8_NAME,
+ UTF8_NAME_BYTES,
+} from '@/src/io/__tests__/readDicomTagsFixtures';
+
+const PATIENT_NAME = '00100010';
+const PIXEL_SPACING = '00280030';
+const IMAGE_POSITION_PATIENT = '00200032';
+const SPECIFIC_CHARACTER_SET = '00080005';
+const ICC_PROFILE = '00282000';
+const FRAME_DIMENSION_POINTER = '0028000A';
+const SIEMENS_CSA_DATA = '00291010';
+const GE_PRIVATE_TAG = '00091001';
+const ULTRASOUND_REGIONS = '00186011';
+const PHYSICAL_DELTA_X = '0018602C';
+const REQUEST_ATTRIBUTES = '00400275';
+const REFERENCED_STUDY = '00400008';
+const CODE_MEANING = '00080104';
+const SCHEDULED_PROCEDURE_STEP_ID = '00400009';
+const CURRENT_PATIENT_LOCATION = '00380300';
+const PERFORMING_PHYSICIAN_NAME = '00081050';
+const STUDY_COMMENTS = '00324000';
+
+const ascii = (text: string) => new TextEncoder().encode(text);
+
+const concat = (...parts: Uint8Array[]) => {
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
+ parts.reduce((offset, part) => {
+ out.set(part, offset);
+ return offset + part.length;
+ }, 0);
+ return out;
+};
+
+const item = sequenceItem;
+
+const sequence = (group: number, element: number, items: Uint8Array) =>
+ rawElement(group, element, 'SQ', items);
+
+// (0040,0275) holding one item that itself holds a sequence, which is the
+// shape an enhanced object's functional groups take.
+const NESTED_SEQUENCE = sequence(
+ 0x0040,
+ 0x0275,
+ item(
+ concat(
+ sequence(
+ 0x0040,
+ 0x0008,
+ item(rawElement(0x0008, 0x0104, 'LO', ascii('MEANING ')))
+ ),
+ rawElement(0x0040, 0x0009, 'SH', ascii('SPS1 '))
+ )
+ )
+);
+
+// A Japanese data set names the default repertoire and the extension its
+// ideographic runs escape into, which is the shape real studies write.
+const JAPANESE_CHARACTER_SET = 'ISO 2022 IR 6\\ISO 2022 IR 87';
+
+// A code meaning whose UTF-8 bytes no single byte character set survives.
+const UTF8_ITEM_TEXT = '\u738b ';
+
+const valuesOf = (dataset: DicomDataset, tag: string) =>
+ findElement(dataset, tag)?.values;
+
+describe('readDicomDataset', () => {
+ it('keeps the tag, VR and value multiplicity of every element', () => {
+ const dataset = readDicomDataset(
+ buildSlice({ pixelSpacing: [0.5, 0.75], imagePositionPatient: [0, 0, 0] })
+ );
+
+ expect(findElement(dataset, PIXEL_SPACING)).toMatchObject({
+ tag: PIXEL_SPACING,
+ group: 0x0028,
+ element: 0x0030,
+ vr: 'DS',
+ });
+ expect(valuesOf(dataset, PIXEL_SPACING)).toEqual({
+ kind: 'decimal',
+ values: [0.5, 0.75],
+ text: ['0.5', '0.75'],
+ });
+ expect(valuesOf(dataset, IMAGE_POSITION_PATIENT)).toEqual({
+ kind: 'decimal',
+ values: [0, 0, 0],
+ text: ['0', '0', '0 '],
+ });
+ });
+
+ it('reads a DS value that is not a number as null, not as zero', () => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ extraElements: [rawElement(0x0040, 0x9224, 'DS', ascii('1\\\\3'))],
+ })
+ );
+
+ expect(valuesOf(dataset, '00409224')).toEqual({
+ kind: 'decimal',
+ values: [1, null, 3],
+ text: ['1', '', '3'],
+ });
+ });
+
+ it('descends into a sequence and into the sequence inside it', () => {
+ const dataset = readDicomDataset(
+ buildSlice({ extraElements: [NESTED_SEQUENCE] })
+ );
+ const outer = valuesOf(dataset, REQUEST_ATTRIBUTES);
+ if (outer?.kind !== 'sequence') throw new Error('not a sequence');
+ const inner = valuesOf(outer.items[0], REFERENCED_STUDY);
+ if (inner?.kind !== 'sequence') throw new Error('not a sequence');
+
+ expect(outer.items).toHaveLength(1);
+ expect(valuesOf(outer.items[0], SCHEDULED_PROCEDURE_STEP_ID)).toEqual({
+ kind: 'text',
+ values: ['SPS1 '],
+ });
+ expect(inner.items).toHaveLength(1);
+ expect(valuesOf(inner.items[0], CODE_MEANING)).toEqual({
+ kind: 'text',
+ values: ['MEANING '],
+ });
+ });
+
+ it.each([
+ ['inherits the enclosing', []],
+ [
+ 'declares its own',
+ [rawElement(0x0008, 0x0005, 'CS', ascii('ISO_IR 192'))],
+ ],
+ ])('decodes an item that %s character set once', (_, localCharacterSet) => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ specificCharacterSet: 'ISO_IR 192',
+ extraElements: [
+ sequence(
+ 0x0040,
+ 0x0275,
+ item(
+ concat(
+ ...localCharacterSet,
+ rawElement(0x0008, 0x0104, 'LO', ascii(UTF8_ITEM_TEXT))
+ )
+ )
+ ),
+ ],
+ })
+ );
+ const values = valuesOf(dataset, REQUEST_ATTRIBUTES);
+ if (values?.kind !== 'sequence') throw new Error('not a sequence');
+
+ expect(valuesOf(values.items[0], CODE_MEANING)).toEqual({
+ kind: 'text',
+ values: [UTF8_ITEM_TEXT],
+ });
+ });
+
+ it.each([
+ [
+ 'a defined length',
+ (content: Uint8Array) => sequence(0x0040, 0x0275, item(content)),
+ ],
+ [
+ 'an undefined length',
+ (content: Uint8Array) =>
+ undefinedLengthSequence(0x0040, 0x0275, undefinedLengthItem(content)),
+ ],
+ ])(
+ 'decodes an item of %s sequence that declares a character set extension',
+ (_, build) => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ specificCharacterSet: JAPANESE_CHARACTER_SET,
+ extraElements: [
+ build(
+ concat(
+ rawElement(0x0008, 0x0005, 'CS', ascii(JAPANESE_CHARACTER_SET)),
+ rawElement(0x0010, 0x0010, 'PN', JAPANESE_NAME_BYTES)
+ )
+ ),
+ ],
+ })
+ );
+ const values = valuesOf(dataset, REQUEST_ATTRIBUTES);
+ if (values?.kind !== 'sequence') throw new Error('not a sequence');
+
+ expect(values.items[0].specificCharacterSet).toEqual([
+ 'ISO 2022 IR 6',
+ 'ISO 2022 IR 87',
+ ]);
+ expect(valuesOf(values.items[0], PATIENT_NAME)).toEqual({
+ kind: 'text',
+ values: [JAPANESE_NAME],
+ });
+ }
+ );
+
+ it.each([
+ ['explicit', false],
+ ['implicit', true],
+ ])(
+ 'reads the elements of an %s VR ultrasound region item',
+ (_, implicitVr) => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ implicitVr,
+ ultrasoundRegion: { physicalDeltaX: 0.1, physicalDeltaY: 0.2 },
+ })
+ );
+ const values = valuesOf(dataset, ULTRASOUND_REGIONS);
+ if (values?.kind !== 'sequence') throw new Error('not a sequence');
+
+ expect(values.items[0].elements.map(({ tag }) => tag)).toEqual([
+ '00186024',
+ '00186026',
+ '0018602C',
+ '0018602E',
+ ]);
+ expect(valuesOf(values.items[0], PHYSICAL_DELTA_X)).toEqual({
+ kind: 'number',
+ values: [0.1],
+ });
+ }
+ );
+
+ it('keeps the bytes of a sequence whose items it cannot read', () => {
+ // An item that claims more bytes than the sequence holds.
+ const truncatedItem = new Uint8Array([
+ 0xfe, 0xff, 0x00, 0xe0, 0x40, 0x00, 0x00, 0x00, 0x18, 0x00, 0x24, 0x60,
+ ]);
+ const dataset = readDicomDataset(
+ buildSlice({
+ extraElements: [rawElement(0x0018, 0x6011, 'SQ', truncatedItem)],
+ })
+ );
+ const values = valuesOf(dataset, ULTRASOUND_REGIONS);
+ if (values?.kind !== 'unread') throw new Error('unexpectedly read');
+
+ expect(values.bytes).toEqual(truncatedItem);
+ expect(values.reason.length).toBeGreaterThan(0);
+ });
+
+ it('keeps a private element and the bytes of its value', () => {
+ const dataset = readDicomDataset(
+ buildSlice({ extraElements: MIXED_VR_ELEMENTS })
+ );
+
+ expect(findElement(dataset, SIEMENS_CSA_DATA)?.bytes).toEqual(
+ new Uint8Array(6).fill(0x41)
+ );
+ expect(findElement(dataset, GE_PRIVATE_TAG)?.bytes).toEqual(
+ ascii('GEPRIV ')
+ );
+ expect(findElement(dataset, GE_PRIVATE_TAG)?.vr).toBe('SH');
+ });
+
+ it('leaves a public element of a known VR without a byte copy', () => {
+ const dataset = readDicomDataset(buildSlice());
+
+ expect(findElement(dataset, PATIENT_NAME)?.bytes).toBeUndefined();
+ });
+
+ it('reads a buffer VR as bytes and an AT as referenced tags', () => {
+ const dataset = readDicomDataset(
+ buildSlice({ extraElements: MIXED_VR_ELEMENTS })
+ );
+
+ expect(valuesOf(dataset, ICC_PROFILE)).toEqual({
+ kind: 'bytes',
+ values: [new Uint8Array(8).fill(0x7f)],
+ });
+ expect(valuesOf(dataset, FRAME_DIMENSION_POINTER)).toEqual({
+ kind: 'attributeTag',
+ values: [
+ { group: 0x0018, element: 0x1063 },
+ { group: 0x0028, element: 0x00ff },
+ ],
+ });
+ });
+
+ it('applies the declared character set to the VRs it reaches', () => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ specificCharacterSet: 'ISO_IR 192',
+ patientNameBytes: UTF8_NAME_BYTES,
+ })
+ );
+
+ expect(dataset.specificCharacterSet).toEqual(['ISO_IR 192']);
+ expect(valuesOf(dataset, PATIENT_NAME)).toEqual({
+ kind: 'text',
+ values: [UTF8_NAME],
+ });
+ });
+
+ it.each([
+ ['LO', 0x0038, 0x0300, CURRENT_PATIENT_LOCATION],
+ ['PN', 0x0008, 0x1050, PERFORMING_PHYSICIAN_NAME],
+ ])(
+ 'separates %s values on a delimiter, not on a multibyte character',
+ (vr, group, element, tag) => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ specificCharacterSet: 'GB18030',
+ extraElements: [
+ rawElement(
+ group,
+ element,
+ vr,
+ new Uint8Array([0x81, 0x5c, 0x5c, 0x81, 0x40])
+ ),
+ ],
+ })
+ );
+
+ expect(valuesOf(dataset, tag)).toEqual({
+ kind: 'text',
+ values: ['\u4e57', '\u4e02 '],
+ });
+ }
+ );
+
+ it('keeps a backslash inside a single valued text VR as a character', () => {
+ const dataset = readDicomDataset(
+ buildSlice({
+ extraElements: [rawElement(0x0032, 0x4000, 'LT', ascii('C:\\DICOM'))],
+ })
+ );
+
+ expect(valuesOf(dataset, STUDY_COMMENTS)).toEqual({
+ kind: 'text',
+ values: ['C:\\DICOM'],
+ });
+ });
+
+ it('reports a missing attribute as missing rather than as a default', () => {
+ const dataset = readDicomDataset(buildSlice());
+
+ expect(dataset.specificCharacterSet).toBeNull();
+ expect(findElement(dataset, SPECIFIC_CHARACTER_SET)).toBeUndefined();
+ expect(findElement(dataset, ICC_PROFILE)).toBeUndefined();
+ });
+});
diff --git a/packages/dicom/core/metadata/__tests__/dicomLayout.spec.ts b/packages/dicom/core/metadata/__tests__/dicomLayout.spec.ts
new file mode 100644
index 000000000..d56e742d4
--- /dev/null
+++ b/packages/dicom/core/metadata/__tests__/dicomLayout.spec.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import {
+ EXPLICIT_VR_LITTLE_ENDIAN,
+ IMPLICIT_VR_LITTLE_ENDIAN,
+ PART_10_HEADER_LENGTH,
+ sniffDicomLayout,
+} from '../dicomLayout';
+import { stripFileMeta, stripPreamble } from '@/tests/specs/syntheticDicom';
+import { buildSlice } from '@/src/io/__tests__/readDicomTagsFixtures';
+
+const head = (bytes: Uint8Array) => bytes.subarray(0, PART_10_HEADER_LENGTH);
+
+describe('sniffDicomLayout', () => {
+ it('reads a Part 10 file as preamble and file meta information', () => {
+ expect(sniffDicomLayout(head(buildSlice()))).toEqual({
+ preamble: true,
+ fileMeta: true,
+ });
+ });
+
+ it('reads file meta information at the first byte when the preamble is missing', () => {
+ expect(sniffDicomLayout(head(stripPreamble(buildSlice())))).toEqual({
+ preamble: false,
+ fileMeta: true,
+ });
+ });
+
+ it('assumes explicit VR little endian for a bare data set that spells VRs', () => {
+ expect(sniffDicomLayout(head(stripFileMeta(buildSlice())))).toEqual({
+ preamble: false,
+ fileMeta: false,
+ transferSyntaxUid: EXPLICIT_VR_LITTLE_ENDIAN,
+ });
+ });
+
+ it('assumes implicit VR little endian for a bare data set that does not', () => {
+ expect(
+ sniffDicomLayout(head(stripFileMeta(buildSlice({ implicitVr: true }))))
+ ).toEqual({
+ preamble: false,
+ fileMeta: false,
+ transferSyntaxUid: IMPLICIT_VR_LITTLE_ENDIAN,
+ });
+ });
+
+ it('judges a first element from fewer bytes than a preamble takes', () => {
+ expect(
+ sniffDicomLayout(stripFileMeta(buildSlice()).subarray(0, 8))
+ ).toMatchObject({ fileMeta: false });
+ });
+
+ it.each([
+ ['all zeros', new Uint8Array(512)],
+ [
+ 'a first element outside group 0008',
+ new Uint8Array([0x10, 0x00, 0x10, 0x00, 0x50, 0x4e, 0, 0]),
+ ],
+ ['letters all the way down', new Uint8Array(256).fill(0x41)],
+ ['too few bytes for an element', new Uint8Array([0x08, 0x00, 0x16])],
+ ])('rejects %s', (_case, bytes) => {
+ expect(() => sniffDicomLayout(bytes)).toThrow(/Not DICOM/);
+ });
+});
diff --git a/packages/dicom/core/metadata/__tests__/metadataReader.spec.ts b/packages/dicom/core/metadata/__tests__/metadataReader.spec.ts
new file mode 100644
index 000000000..1177c24b5
--- /dev/null
+++ b/packages/dicom/core/metadata/__tests__/metadataReader.spec.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { readDicomDataset } from '../dicomDataset';
+import { createMetadataReader } from '../../runtime/metadataReader';
+const readDicomDatasetOffThread = createMetadataReader().read;
+import { buildSlice } from '@/src/io/__tests__/readDicomTagsFixtures';
+
+// The test DOM has no Worker, so this covers the in-place path only; the
+// worker path is the same reader behind a message.
+describe('readDicomDatasetOffThread', () => {
+ it('reads what the reader reads', async () => {
+ const bytes = buildSlice({ patientName: 'DOE^JOHN' });
+
+ expect(await readDicomDatasetOffThread(bytes)).toEqual(
+ readDicomDataset(bytes)
+ );
+ });
+
+ it('reads a view that does not own its buffer', async () => {
+ const whole = buildSlice();
+ const padded = new Uint8Array(whole.length + 3);
+ padded.set(whole, 3);
+
+ expect(await readDicomDatasetOffThread(padded.subarray(3))).toEqual(
+ readDicomDataset(whole)
+ );
+ });
+
+ it('rejects what the reader rejects', async () => {
+ await expect(
+ readDicomDatasetOffThread(new Uint8Array(256).fill(0x41))
+ ).rejects.toThrow(/Not DICOM/);
+ });
+});
+
+class MetadataWorker extends EventTarget {
+ terminated = false;
+ postMessage() {}
+ terminate() {
+ this.terminated = true;
+ }
+}
+
+describe('metadata worker ownership', () => {
+ it('settles worker failure, allows retry, and terminates pending work on disposal', async () => {
+ const workers: MetadataWorker[] = [];
+ const reader = createMetadataReader({
+ metadataWorkerFactory: () => {
+ const worker = new MetadataWorker();
+ workers.push(worker);
+ return worker as unknown as Worker;
+ },
+ });
+ const first = reader.read(buildSlice());
+ const failure = expect(first).rejects.toThrow('worker failed');
+ workers[0].dispatchEvent(new Event('error'));
+ await failure;
+ expect(workers[0].terminated).toBe(true);
+ const retry = reader.read(buildSlice());
+ const stopped = expect(retry).rejects.toMatchObject({ name: 'AbortError' });
+ reader.dispose();
+ await stopped;
+ expect(workers[1].terminated).toBe(true);
+ await expect(reader.read(buildSlice())).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ });
+});
diff --git a/packages/dicom/core/metadata/__tests__/spatialMetadata.spec.ts b/packages/dicom/core/metadata/__tests__/spatialMetadata.spec.ts
new file mode 100644
index 000000000..7c9551c01
--- /dev/null
+++ b/packages/dicom/core/metadata/__tests__/spatialMetadata.spec.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import { datasetFromTags } from '@/tests/dicomDataset';
+import { readGeometryFacts } from '../../collections/instanceFacts';
+import { getDatasetTag } from '../datasetAccess';
+import { readPixelSpacing } from '../spatialMetadata';
+
+const spacingDataset = (value: string) =>
+ datasetFromTags([['00280030', value]]);
+
+describe('single-value PixelSpacing display compatibility', () => {
+ it.each([
+ ['0.5', 0.5],
+ ['0.0104', 0.0104],
+ [' 2.5\u0000', 2.5],
+ ] as const)(
+ 'displays %s as square pixels without claiming calibration',
+ (raw, value) => {
+ const dataset = spacingDataset(raw);
+ const axes = readPixelSpacing(dataset);
+
+ expect(axes.map((axis) => axis.value)).toEqual([value, value]);
+ for (const axis of axes) {
+ expect(axis).toMatchObject({
+ unit: 'unknown',
+ status: 'fallback',
+ source: 'Single PixelSpacing value',
+ });
+ expect(axis.reason).toContain('square pixels are assumed for display');
+ }
+ expect(readGeometryFacts(dataset).pixelSpacing).toBeNull();
+ expect(getDatasetTag(dataset, '00280030')).toBe(raw);
+ }
+ );
+
+ it.each(['0', '-0.5', 'NaN', 'Infinity', ''])(
+ 'does not use %s as display spacing',
+ (raw) => {
+ expect(readPixelSpacing(spacingDataset(raw))).toEqual([
+ expect.objectContaining({
+ value: 1,
+ unit: 'unknown',
+ status: 'fallback',
+ }),
+ expect.objectContaining({
+ value: 1,
+ unit: 'unknown',
+ status: 'fallback',
+ }),
+ ]);
+ }
+ );
+});
diff --git a/packages/dicom/core/metadata/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..dccff0f97
--- /dev/null
+++ b/packages/dicom/core/metadata/dcmjs.d.ts
@@ -0,0 +1,83 @@
+declare module 'dcmjs' {
+ export type DicomTag = {
+ toCleanString(): string;
+ };
+
+ /** `vr` and `values` are `0` for the element a read stopped at. */
+ export type DicomElement = {
+ tag: DicomTag;
+ vr: { type: string } | 0;
+ values: unknown;
+ rawValues: unknown;
+ };
+
+ export type DicomReadOptions = {
+ untilTag?: string;
+ includeUntilTagValue?: boolean;
+ forceStoreRaw?: boolean;
+ ignoreErrors?: boolean;
+ };
+
+ export type DicomStringDecoder = {
+ decode(view: ArrayBufferView): string;
+ };
+
+ export type DicomReadStream = {
+ offset: number;
+ readonly endOffset: number;
+ reset(): DicomReadStream;
+ end(): boolean;
+ increment(count: number): void;
+ setEndian(littleEndian: boolean): void;
+ readAsciiString(length: number): string;
+ readVR(): string;
+ readUint16(): number;
+ readUint32(): number;
+ setDecoder(decoder: DicomStringDecoder): void;
+ more(length: number): DicomReadStream;
+ slice(start: number, end: number): ArrayBuffer;
+ };
+
+ export type DicomDictionaryEntry = {
+ tag: string;
+ vr: string;
+ vm: string;
+ name: string;
+ };
+
+ export type DicomDataElement = {
+ vr: string;
+ Value: unknown[];
+ _rawValue: unknown;
+ };
+
+ const dcmjs: {
+ constants: {
+ encodingMapping: Record;
+ };
+ data: {
+ DicomMetaDictionary: {
+ dictionary: Record;
+ };
+ ReadBufferStream: new (buffer: ArrayBuffer) => DicomReadStream;
+ DeflatedReadBufferStream: new (
+ stream: DicomReadStream
+ ) => DicomReadStream;
+ DicomMessage: {
+ _readTag(
+ stream: DicomReadStream,
+ syntax: string,
+ options?: DicomReadOptions
+ ): DicomElement;
+ _read(
+ stream: DicomReadStream,
+ syntax: string,
+ options?: DicomReadOptions
+ ): Record;
+ _normalizeSyntax(syntax: string): string;
+ };
+ };
+ };
+
+ export default dcmjs;
+}
diff --git a/packages/dicom/core/metadata/dcmjsParser.ts b/packages/dicom/core/metadata/dcmjsParser.ts
new file mode 100644
index 000000000..5b6447d3a
--- /dev/null
+++ b/packages/dicom/core/metadata/dcmjsParser.ts
@@ -0,0 +1,443 @@
+import dcmjs, { DicomElement, DicomReadStream } from 'dcmjs';
+import {
+ EXPLICIT_VR_LITTLE_ENDIAN,
+ IMPLICIT_VR_LITTLE_ENDIAN,
+ PART_10_HEADER_LENGTH,
+ sniffDicomLayout,
+} from './dicomLayout';
+
+const { encodingMapping } = dcmjs.constants;
+const {
+ DicomMessage,
+ DicomMetaDictionary,
+ ReadBufferStream,
+ DeflatedReadBufferStream,
+} = dcmjs.data;
+
+const FILE_META_GROUP = 0x0002;
+const FILE_META_GROUP_LENGTH = '00020000';
+const TRANSFER_SYNTAX_UID = '00020010';
+const PIXEL_DATA = '7FE00010';
+const EXPLICIT_VR_BIG_ENDIAN = '1.2.840.10008.1.2.2';
+const DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1.99';
+const RESERVED_LENGTH = 2;
+const UNDEFINED_LENGTH = 0xffffffff;
+
+/** VRs whose explicit form carries two reserved bytes and a 32 bit length. */
+const LENGTH_32_VRS = new Set([
+ 'OB',
+ 'OD',
+ 'OF',
+ 'OL',
+ 'OV',
+ 'OW',
+ 'SQ',
+ 'SV',
+ 'UC',
+ 'UN',
+ 'UR',
+ 'UT',
+]);
+
+/** Bound on one `String.fromCharCode` spread, which is an argument list. */
+const SPREAD_LIMIT = 4096;
+
+/** A byte string holds one code unit per byte, which `toBytes` reverses. */
+export const binaryString = (bytes: Uint8Array) => {
+ let text = '';
+ for (let i = 0; i < bytes.length; i += SPREAD_LIMIT)
+ text += String.fromCharCode(...bytes.subarray(i, i + SPREAD_LIMIT));
+ return text;
+};
+
+export const toBytes = (raw: string) =>
+ Uint8Array.from(raw, (char) => char.charCodeAt(0) & 0xff);
+
+/** DICOM character set name to `TextDecoder` label, as dcmjs tabulates it. */
+export const encodingLabel = (name: string) =>
+ encodingMapping[name.trim().replace(/[_ ]/g, '-').toLowerCase()];
+
+/**
+ * One value as the file wrote it. A text VR is a byte string, so the Specific
+ * Character Set can be applied once the whole data set is known.
+ */
+export type ParsedValue = string | number | Uint8Array;
+
+/** One element, with no dcmjs object left in it. */
+export type ParsedElement = {
+ group: number;
+ element: number;
+ vr: string;
+ values: ParsedValue[];
+ /** Items of a sequence, each a list of elements. */
+ items?: ParsedElement[][];
+ /** Set when the element's header was read but its value was not. */
+ unread?: { bytes: Uint8Array; reason: string };
+};
+
+/**
+ * Keeps one code unit per byte so the Specific Character Set can be applied
+ * once the whole data set is read. dcmjs otherwise picks a single decoder from
+ * character set value 1, which is the default repertoire rather than the
+ * extension that covers the non-ASCII characters.
+ */
+const byteDecoder = {
+ decode: (view: ArrayBufferView) =>
+ binaryString(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)),
+};
+
+/**
+ * Bounded item streams must keep their bytes until the enclosing dataset's
+ * Specific Character Set is known, including an item's inherited repertoire.
+ */
+const readsBytes = (stream: DicomReadStream) => {
+ stream.setDecoder(byteDecoder);
+ const derive = stream.more.bind(stream);
+ stream.more = (length: number) => readsBytes(derive(length));
+ return stream;
+};
+
+const viewBytes = (value: unknown) => {
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
+ if (ArrayBuffer.isView(value))
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ return null;
+};
+
+/** A group or element number as four upper case hexadecimal digits. */
+export const hex4 = (value: number) =>
+ value.toString(16).padStart(4, '0').toUpperCase();
+
+const dictionaryVr = (group: number, element: number) =>
+ DicomMetaDictionary.dictionary[`(${hex4(group)},${hex4(element)})`]?.vr;
+
+/** dcmjs reads from an ArrayBuffer, which a whole-buffer view already is. */
+const bufferOf = (bytes: Uint8Array) =>
+ bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
+ ? (bytes.buffer as ArrayBuffer)
+ : (bytes.slice().buffer as ArrayBuffer);
+
+/**
+ * Opens the data set. A file without the Part 10 preamble is read from its
+ * first element; one without file meta information is read in the transfer
+ * syntax its first element suggests.
+ */
+const pastDeflatedMeta = (syntax: unknown, offset: number, end: number) =>
+ syntax === DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN && offset >= end;
+
+const openFile = (bytes: Uint8Array) => {
+ const layout = sniffDicomLayout(bytes.subarray(0, PART_10_HEADER_LENGTH));
+
+ const stream = new ReadBufferStream(bufferOf(bytes));
+ stream.reset();
+ if (layout.preamble) stream.increment(PART_10_HEADER_LENGTH);
+ if (!layout.fileMeta)
+ return {
+ dataSet: readsBytes(stream),
+ syntax: DicomMessage._normalizeSyntax(layout.transferSyntaxUid),
+ };
+
+ // Read group 2 sequentially when its group length is absent. A declared
+ // length also prevents compressed data from being mistaken for more meta.
+ let syntax: unknown;
+ let metaEnd = bytes.length;
+ stream.setEndian(true);
+ while (!stream.end() && !pastDeflatedMeta(syntax, stream.offset, metaEnd)) {
+ const start = stream.offset;
+ const group = stream.readUint16();
+ stream.offset = start;
+ if (group !== FILE_META_GROUP) break;
+ const attribute = DicomMessage._readTag(stream, EXPLICIT_VR_LITTLE_ENDIAN);
+ const tag = attribute.tag.toCleanString();
+ if (tag === FILE_META_GROUP_LENGTH) {
+ const [length] = attribute.values as number[];
+ metaEnd = stream.offset + length;
+ }
+ if (tag === TRANSFER_SYNTAX_UID) [syntax] = attribute.values as unknown[];
+ }
+ if (typeof syntax !== 'string')
+ throw new Error('Not a DICOM file: the transfer syntax is missing');
+
+ const dataSet =
+ syntax === DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN
+ ? new DeflatedReadBufferStream(stream)
+ : stream;
+ return {
+ dataSet: readsBytes(dataSet),
+ syntax: DicomMessage._normalizeSyntax(syntax),
+ };
+};
+
+const needsSequenceReader = (vr: string | undefined, length: number) =>
+ vr === 'SQ' ||
+ ((vr === 'UN' || vr === undefined) && length === UNDEFINED_LENGTH);
+
+/**
+ * A known UN retains its implicit encoding even inside an explicit dataset.
+ */
+const isKnownUnknown = (
+ vr: string | undefined,
+ group: number,
+ element: number
+) => {
+ if (vr !== 'UN') return false;
+ const known = dictionaryVr(group, element);
+ return known !== undefined && known !== 'UN';
+};
+
+const peekSequence = (dataSet: DicomReadStream, syntax: string) => {
+ const start = dataSet.offset;
+ const implicit = syntax === IMPLICIT_VR_LITTLE_ENDIAN;
+ dataSet.setEndian(syntax !== EXPLICIT_VR_BIG_ENDIAN);
+ const group = dataSet.readUint16();
+ const element = dataSet.readUint16();
+ const vr = implicit ? dictionaryVr(group, element) : dataSet.readVR();
+ const length32 = implicit || LENGTH_32_VRS.has(vr ?? '');
+ if (length32 && !implicit) dataSet.increment(RESERVED_LENGTH);
+ const length = length32 ? dataSet.readUint32() : dataSet.readUint16();
+ const valueStart = dataSet.offset;
+ dataSet.offset = start;
+ const knownUnknown = isKnownUnknown(vr, group, element);
+ return needsSequenceReader(vr, length) || knownUnknown
+ ? {
+ unknown: vr === 'UN',
+ group,
+ element,
+ valueStart,
+ end: length === UNDEFINED_LENGTH ? null : valueStart + length,
+ }
+ : null;
+};
+
+/**
+ * dcmjs drops the trailing byte that padded a value to an even length, which
+ * the file wrote. A UI's pad is a null byte and carries no value, so only the
+ * space that pads a text VR is put back.
+ */
+const restorePadByte = (vr: string, values: string[]) => {
+ const byteLength = values.reduce(
+ (total, value) => total + value.length,
+ values.length - 1
+ );
+ return vr !== 'UI' && byteLength % 2 === 1
+ ? values.map((value, index) =>
+ index === values.length - 1 ? `${value} ` : value
+ )
+ : values;
+};
+
+const rawValuesOf = (rawValues: unknown) => {
+ if (rawValues == null) return [];
+ return Array.isArray(rawValues) ? (rawValues as unknown[]) : [rawValues];
+};
+
+const parsedValues = (vr: string, rawValues: unknown): ParsedValue[] => {
+ const values = rawValuesOf(rawValues);
+ if (values.every((value): value is string => typeof value === 'string'))
+ return restorePadByte(vr, values);
+ return values.map((value) => {
+ const bytes = viewBytes(value);
+ if (bytes) return bytes;
+ return typeof value === 'number' ? value : String(value);
+ });
+};
+
+const groupOf = (tag: string) => Number.parseInt(tag.slice(0, 4), 16);
+const elementOf = (tag: string) => Number.parseInt(tag.slice(4), 16);
+
+const parsedElement = (element: DicomElement): ParsedElement => {
+ const tag = element.tag.toCleanString();
+ const vr = element.vr === 0 ? '' : element.vr.type;
+ return {
+ group: groupOf(tag),
+ element: elementOf(tag),
+ vr,
+ values: parsedValues(vr, element.rawValues),
+ };
+};
+
+const READ_OPTIONS = {
+ untilTag: PIXEL_DATA,
+ includeUntilTagValue: false,
+ forceStoreRaw: true,
+};
+
+const readSequence = (
+ dataSet: DicomReadStream,
+ syntax: string,
+ sequence: {
+ group: number;
+ element: number;
+ valueStart: number;
+ end: number | null;
+ unknown?: boolean;
+ }
+): ParsedElement => {
+ if (sequence.end !== null && sequence.end > dataSet.endOffset)
+ throw new Error('Sequence exceeds the available dataset bytes');
+ if (sequence.unknown && sequence.end !== null) {
+ // PS3.5 6.2.2: a known UN value retains Implicit VR Little Endian encoding.
+ const value = new Uint8Array(
+ dataSet.slice(sequence.valueStart, sequence.end)
+ );
+ const bytes = new Uint8Array(8 + value.length);
+ const header = new DataView(bytes.buffer);
+ header.setUint16(0, sequence.group, true);
+ header.setUint16(2, sequence.element, true);
+ header.setUint32(4, value.length, true);
+ bytes.set(value, 8);
+ dataSet.offset = sequence.end;
+ return readElements(
+ readsBytes(new ReadBufferStream(bytes.buffer)),
+ IMPLICIT_VR_LITTLE_ENDIAN,
+ { stopAtPixelData: false }
+ )[0];
+ }
+
+ const itemSyntax = sequence.unknown ? IMPLICIT_VR_LITTLE_ENDIAN : syntax;
+ dataSet.offset = sequence.valueStart;
+ try {
+ const items = readSequenceItems(dataSet, itemSyntax, sequence.end);
+ return {
+ group: sequence.group,
+ element: sequence.element,
+ vr: sequence.unknown ? 'UN' : 'SQ',
+ values: [],
+ items,
+ };
+ } catch (error) {
+ if (sequence.end === null) throw error;
+ const bytes = new Uint8Array(
+ dataSet.slice(sequence.valueStart, sequence.end)
+ );
+ dataSet.offset = sequence.end;
+ return {
+ group: sequence.group,
+ element: sequence.element,
+ vr: sequence.unknown ? 'UN' : 'SQ',
+ values: [],
+ unread: { bytes, reason: String(error) },
+ };
+ }
+};
+
+const assertSequenceBounds = (
+ offset: number,
+ end: number | null,
+ length = 0
+) => {
+ if (end !== null && offset + length > end)
+ throw new Error('Sequence item exceeds the declared sequence length');
+};
+
+function readSequenceItemLength(dataSet: DicomReadStream, syntax: string) {
+ dataSet.setEndian(syntax !== EXPLICIT_VR_BIG_ENDIAN);
+ const group = dataSet.readUint16();
+ const element = dataSet.readUint16();
+ const length = dataSet.readUint32();
+ if (group === 0xfffe && element === 0xe0dd && length === 0) return null;
+ if (group !== 0xfffe || element !== 0xe000)
+ throw new Error('Expected a sequence item');
+ return length;
+}
+
+function readSequenceItems(
+ dataSet: DicomReadStream,
+ syntax: string,
+ end: number | null
+) {
+ const items: ParsedElement[][] = [];
+ while (end === null || dataSet.offset < end) {
+ assertSequenceBounds(dataSet.offset, end, 8);
+ const length = readSequenceItemLength(dataSet, syntax);
+ if (length === null) {
+ assertSequenceBounds(dataSet.offset, end);
+ // A defined length still bounds the value after a premature delimiter.
+ // Its trailing bytes cannot become elements of the enclosing dataset.
+ if (end !== null) dataSet.offset = end;
+ return items;
+ }
+ assertSequenceBounds(
+ dataSet.offset,
+ end,
+ length === UNDEFINED_LENGTH ? 0 : length
+ );
+ items.push(
+ length === UNDEFINED_LENGTH
+ ? readElements(dataSet, syntax, {
+ untilItemDelimiter: true,
+ stopAtPixelData: false,
+ })
+ : readElements(dataSet.more(length), syntax, { stopAtPixelData: false })
+ );
+ assertSequenceBounds(dataSet.offset, end);
+ }
+ return items;
+}
+
+/** Consumes an item delimiter, leaving any other element unread. */
+const readItemDelimiter = (dataSet: DicomReadStream) => {
+ const start = dataSet.offset;
+ dataSet.setEndian(true);
+ const group = dataSet.readUint16();
+ const element = dataSet.readUint16();
+ if (group !== 0xfffe || element !== 0xe00d) {
+ dataSet.offset = start;
+ return false;
+ }
+ if (dataSet.readUint32() !== 0)
+ throw new Error('Invalid item delimiter length');
+ return true;
+};
+
+const readNextElement = (
+ dataSet: DicomReadStream,
+ syntax: string,
+ stopAtPixelData: boolean
+) => {
+ const sequence = peekSequence(dataSet, syntax);
+ if (
+ sequence &&
+ stopAtPixelData &&
+ sequence.group === 0x7fe0 &&
+ sequence.element === 0x0010
+ )
+ return { element: null, done: true };
+ if (sequence)
+ return { element: readSequence(dataSet, syntax, sequence), done: false };
+
+ const element = DicomMessage._readTag(
+ dataSet,
+ syntax,
+ stopAtPixelData ? READ_OPTIONS : { forceStoreRaw: true }
+ );
+ const done = stopAtPixelData && element.tag.toCleanString() === PIXEL_DATA;
+ return {
+ element: done || element.vr === 0 ? null : parsedElement(element),
+ done,
+ };
+};
+
+const readElements = (
+ dataSet: DicomReadStream,
+ syntax: string,
+ { untilItemDelimiter = false, stopAtPixelData = true } = {}
+): ParsedElement[] => {
+ const elements: ParsedElement[] = [];
+ let done = false;
+ while (!done && !dataSet.end()) {
+ if (untilItemDelimiter && readItemDelimiter(dataSet)) return elements;
+ const next = readNextElement(dataSet, syntax, stopAtPixelData);
+ done = next.done;
+ if (next.element) elements.push(next.element);
+ }
+ if (untilItemDelimiter)
+ throw new Error('Missing item delimiter in undefined-length UN');
+ return elements;
+};
+
+/** Reads a Part 10 file's data set elements, stopping before Pixel Data. */
+export const parseDicomElements = (bytes: Uint8Array) => {
+ const { dataSet, syntax } = openFile(bytes);
+ return readElements(dataSet, syntax);
+};
diff --git a/packages/dicom/core/metadata/dicomCharacterSet.ts b/packages/dicom/core/metadata/dicomCharacterSet.ts
new file mode 100644
index 000000000..d5a6ae45d
--- /dev/null
+++ b/packages/dicom/core/metadata/dicomCharacterSet.ts
@@ -0,0 +1,206 @@
+import { encodingLabel, toBytes } from './dcmjsParser';
+
+const ESCAPE_BYTE = 0x1b;
+
+export const VALUE_DELIMITER = '\\';
+
+/** Bytes a conforming writer resets the repertoire before. */
+const DELIMITER_BYTES = new Set([0x0a, 0x0c, 0x0d, 0x3d, 0x5c, 0x5e]);
+
+/**
+ * The VRs a Specific Character Set applies to, from PS3.5 6.1.2 and PS3.3
+ * C.12.1.1.2. Every other string VR, DS and IS included, stays in the default
+ * repertoire, so no charset transform can reach a numeric value.
+ */
+export const CHARSET_VRS = new Set(['SH', 'LO', 'ST', 'PN', 'LT', 'UC', 'UT']);
+
+/** Bytes of a VR the Specific Character Set does not reach are code points. */
+export const defaultRepertoire = (raw: string) => raw;
+
+/** A repertoire in force over one run of bytes. */
+type Repertoire = {
+ decode: (bytes: Uint8Array) => string;
+ multibyte: boolean;
+};
+
+const tryDecode = (decoder: TextDecoder, bytes: Uint8Array) => {
+ try {
+ return decoder.decode(bytes);
+ } catch {
+ return undefined;
+ }
+};
+
+const highHalf = (bytes: Uint8Array) => bytes.map((byte) => byte | 0x80);
+
+/** JIS X 0212 rides EUC-JP's third plane, one 0x8f before each high half pair. */
+const jisX0212Bytes = (bytes: Uint8Array) =>
+ Uint8Array.from(
+ Array.from({ length: Math.floor(bytes.length / 2) }, (_, pair) => [
+ 0x8f,
+ bytes[pair * 2] | 0x80,
+ bytes[pair * 2 + 1] | 0x80,
+ ]).flat()
+ );
+
+const repertoire = (
+ label: string,
+ {
+ multibyte = false,
+ mapBytes = (bytes: Uint8Array) => bytes,
+ }: { multibyte?: boolean; mapBytes?: (bytes: Uint8Array) => Uint8Array } = {}
+): Repertoire => {
+ const decoder = new TextDecoder(label);
+ return { decode: (bytes) => decoder.decode(mapBytes(bytes)), multibyte };
+};
+
+/** Final byte of an `ESC -` designation to the decoder for its high half. */
+const G1_LABELS: Record = {
+ A: 'latin1',
+ B: 'iso-8859-2',
+ C: 'iso-8859-3',
+ D: 'iso-8859-4',
+ F: 'iso-ir-126',
+ G: 'iso-ir-127',
+ H: 'iso-ir-138',
+ L: 'iso-ir-144',
+ M: 'iso-ir-148',
+ T: 'tis-620',
+};
+
+/**
+ * The ISO 2022 designations DICOM allows, keyed by the bytes following the
+ * escape. `ESC ( B` and `ESC ( J` return to the default repertoire, so they are
+ * added per file where that default is known.
+ */
+const DESIGNATIONS: Record = {
+ '(I': repertoire('shift-jis', { mapBytes: highHalf }),
+ ')I': repertoire('shift-jis'),
+ $B: repertoire('euc-jp', { multibyte: true, mapBytes: highHalf }),
+ $A: repertoire('gbk', { multibyte: true, mapBytes: highHalf }),
+ '$(D': repertoire('euc-jp', { multibyte: true, mapBytes: jisX0212Bytes }),
+ '$)C': repertoire('euc-kr', { multibyte: true }),
+ '$)A': repertoire('gbk', { multibyte: true }),
+ ...Object.fromEntries(
+ Object.entries(G1_LABELS).map(([final, label]) => [
+ `-${final}`,
+ repertoire(label),
+ ])
+ ),
+};
+
+/** Designations are two or three bytes wide, longest match first. */
+const designationAt = (
+ bytes: Uint8Array,
+ at: number,
+ table: Record
+) =>
+ [3, 2]
+ .map((width) => ({
+ width,
+ mode: table[String.fromCharCode(...bytes.subarray(at, at + width))],
+ }))
+ .find(({ mode }) => mode != null);
+
+/**
+ * Reads a value whose repertoire switches mid stream. Every run between two
+ * escapes decodes under the repertoire the earlier escape designated, and a
+ * delimiter returns a single byte repertoire to the default, as the standard
+ * requires a writer to do explicitly.
+ */
+const decodeDesignations = (
+ bytes: Uint8Array,
+ initial: Repertoire,
+ table: Record
+) => {
+ const runs: string[] = [];
+ let mode = initial;
+ let start = 0;
+ let at = 0;
+ const flush = (end: number) => {
+ if (end > start) runs.push(mode.decode(bytes.subarray(start, end)));
+ };
+
+ while (at < bytes.length) {
+ const designation =
+ bytes[at] === ESCAPE_BYTE
+ ? designationAt(bytes, at + 1, table)
+ : undefined;
+ if (designation) {
+ flush(at);
+ mode = designation.mode;
+ at += 1 + designation.width;
+ start = at;
+ } else {
+ if (
+ mode !== initial &&
+ !mode.multibyte &&
+ DELIMITER_BYTES.has(bytes[at])
+ ) {
+ flush(at);
+ mode = initial;
+ start = at;
+ }
+ at += 1;
+ }
+ }
+ flush(bytes.length);
+ return runs.join('');
+};
+
+/**
+ * Value 1 of Specific Character Set is the default repertoire and values 2 and
+ * up are extensions. Bytes outside an escape sequence use value 1; another
+ * declared set is tried only if that decoder rejects the bytes. ISO 2022 IR 13 holds half width
+ * katakana as raw bytes that the escape driven ISO 2022 IR 87 decoder rejects,
+ * so no single decoder covers every value of such a file.
+ */
+export const characterSetDecoder = (specificCharacterSet: string[]) => {
+ const first = specificCharacterSet[0]?.trim() ?? '';
+ const declared = specificCharacterSet
+ .slice(1)
+ .filter((name) => name.trim().length > 0)
+ .map(encodingLabel)
+ .filter((label): label is string => label != null);
+ const labels = [encodingLabel(first) ?? 'utf-8', ...declared];
+ const asciiDefault = !first || /^ISO[ _](?:2022[ _])?IR[ _]6$/.test(first);
+ const strict = labels.map((label) => new TextDecoder(label, { fatal: true }));
+ const lenient = new TextDecoder(labels[0]);
+ // JIS X 0201 has no backslash or tilde; those code points are the yen sign
+ // and the overline, which is what GDCM emitted for such a file.
+ const jisRoman = labels.includes('shift-jis');
+
+ const initial: Repertoire = {
+ decode: (bytes) =>
+ strict.reduce(
+ (text, decoder, index) =>
+ text ??
+ (index === 0 && asciiDefault && bytes.some((byte) => byte > 0x7f)
+ ? undefined
+ : tryDecode(decoder, bytes)),
+ undefined
+ ) ?? lenient.decode(bytes),
+ multibyte: false,
+ };
+ const table = { ...DESIGNATIONS, '(B': initial, '(J': initial };
+
+ const decode = (raw: string) => {
+ const bytes = toBytes(raw);
+ return bytes.includes(ESCAPE_BYTE)
+ ? decodeDesignations(bytes, initial, table)
+ : initial.decode(bytes);
+ };
+ const repertoireText = (text: string) =>
+ jisRoman ? text.replace(/\\/g, '\u00a5').replace(/~/g, '\u203e') : text;
+
+ return {
+ /** An element of value multiplicity 1, whose backslash is a character. */
+ value: (raw: string) => repertoireText(decode(raw)),
+ /**
+ * A whole element's bytes, split into its values once decoded: byte 0x5c
+ * delimits values only where it is not part of a multibyte character.
+ */
+ values: (raw: string) =>
+ decode(raw).split(VALUE_DELIMITER).map(repertoireText),
+ };
+};
diff --git a/packages/dicom/core/metadata/dicomDataset.ts b/packages/dicom/core/metadata/dicomDataset.ts
new file mode 100644
index 000000000..02eb33ff5
--- /dev/null
+++ b/packages/dicom/core/metadata/dicomDataset.ts
@@ -0,0 +1,248 @@
+import {
+ hex4,
+ ParsedElement,
+ ParsedValue,
+ parseDicomElements,
+ toBytes,
+} from './dcmjsParser';
+import { concatBytes, trimDicomPadding } from '../utils';
+import {
+ CHARSET_VRS,
+ characterSetDecoder,
+ defaultRepertoire,
+ VALUE_DELIMITER,
+} from './dicomCharacterSet';
+
+const SPECIFIC_CHARACTER_SET = '00080005';
+
+/** A tag as `ggggeeee` in upper case hexadecimal. */
+export type DicomTagKey = string;
+
+export type AttributeTag = { group: number; element: number };
+
+/** VRs whose values are text under the default repertoire or a declared set. */
+const TEXT_VRS = new Set([
+ 'AE',
+ 'AS',
+ 'CS',
+ 'DA',
+ 'DT',
+ 'LO',
+ 'LT',
+ 'PN',
+ 'SH',
+ 'ST',
+ 'TM',
+ 'UC',
+ 'UI',
+ 'UR',
+ 'UT',
+]);
+
+/** Text VRs of value multiplicity 1, whose backslash is a character. */
+const SINGLE_VALUED_TEXT_VRS = new Set(['LT', 'ST', 'UT']);
+
+/** VRs holding a number written as characters. */
+const DECIMAL_VRS = new Set(['DS', 'IS']);
+
+/** VRs holding a number written as bytes. */
+const NUMBER_VRS = new Set(['FL', 'FD', 'SL', 'SS', 'SV', 'UL', 'US', 'UV']);
+
+/** VRs whose value is an opaque buffer. */
+const BYTES_VRS = new Set(['OB', 'OD', 'OF', 'OL', 'OV', 'OW', 'UN']);
+
+const KNOWN_VRS = new Set([
+ ...TEXT_VRS,
+ ...DECIMAL_VRS,
+ ...NUMBER_VRS,
+ ...BYTES_VRS,
+ 'AT',
+ 'SQ',
+]);
+
+export type DicomValues =
+ /** A text VR, one entry per value, with the Specific Character Set applied. */
+ | { kind: 'text'; values: string[] }
+ /** DS and IS: the numbers, next to the characters the file wrote. A value
+ * that is not a number is null rather than zero. */
+ | { kind: 'decimal'; values: Array; text: string[] }
+ /** A binary numeric VR. */
+ | { kind: 'number'; values: number[] }
+ /** AT, each value a referenced tag. */
+ | { kind: 'attributeTag'; values: AttributeTag[] }
+ /** SQ, one data set per item. */
+ | { kind: 'sequence'; items: DicomDataset[] }
+ /** A buffer VR. */
+ | { kind: 'bytes'; values: Uint8Array[] }
+ /** An element whose header was read and whose value was not. */
+ | { kind: 'unread'; bytes: Uint8Array; reason: string };
+
+export type DicomElement = {
+ tag: DicomTagKey;
+ group: number;
+ element: number;
+ vr: string;
+ values: DicomValues;
+ /** Value bytes, kept where the VR alone does not say how to read them. */
+ bytes?: Uint8Array;
+};
+
+export type DicomDataset = {
+ /** Elements in the order the parser reported them. */
+ elements: DicomElement[];
+ /** (0008,0005) as this data set declares it, or null when it declares none. */
+ specificCharacterSet: string[] | null;
+};
+
+export const tagKey = (group: number, element: number) =>
+ `${hex4(group)}${hex4(element)}`;
+
+export const isPrivateElement = ({ group }: DicomElement) => (group & 1) === 1;
+
+export const findElement = (dataset: DicomDataset, tag: DicomTagKey) =>
+ dataset.elements.find((element) => element.tag === tag);
+
+const isText = (value: ParsedValue): value is string =>
+ typeof value === 'string';
+
+const isNumber = (value: ParsedValue): value is number =>
+ typeof value === 'number';
+
+const isBytes = (value: ParsedValue): value is Uint8Array =>
+ value instanceof Uint8Array;
+
+const decimalOf = (text: string) => {
+ const trimmed = trimDicomPadding(text);
+ const value = Number(trimmed);
+ return trimmed.length > 0 && Number.isFinite(value) ? value : null;
+};
+
+const attributeTagOf = (packed: number) => ({
+ group: packed >>> 16,
+ element: packed & 0xffff,
+});
+
+/** Undecoded value bytes, where every value carries them. */
+const rawBytesOf = ({ values }: ParsedElement) => {
+ if (values.every(isText)) return toBytes(values.join(VALUE_DELIMITER));
+ if (values.every(isBytes))
+ return values.length === 1 ? values[0] : concatBytes(values);
+ return undefined;
+};
+
+/**
+ * A private element's meaning follows its private creator, and an unknown VR
+ * says nothing at all, so both keep the bytes the file wrote.
+ */
+const keepsBytes = ({ group, vr }: ParsedElement) =>
+ (group & 1) === 1 || vr === 'UN' || !KNOWN_VRS.has(vr);
+
+type CharacterSetDecoder = ReturnType;
+
+/**
+ * dcmjs splits a value on every 0x5c byte, which GB18030, GBK and ISO 2022
+ * write inside multibyte characters, so a value the character set reaches is
+ * put back together and separated again once its characters are known.
+ */
+const textValues = (
+ vr: string,
+ values: string[],
+ decode: CharacterSetDecoder
+) => {
+ if (!CHARSET_VRS.has(vr) || values.length === 0)
+ return values.map(defaultRepertoire);
+ const raw = values.join(VALUE_DELIMITER);
+ return SINGLE_VALUED_TEXT_VRS.has(vr)
+ ? [decode.value(raw)]
+ : decode.values(raw);
+};
+
+const classifiedValues = (
+ vr: string,
+ values: ParsedValue[],
+ decode: CharacterSetDecoder
+): DicomValues => {
+ if (vr === 'AT' && values.every(isNumber))
+ return { kind: 'attributeTag', values: values.map(attributeTagOf) };
+ if (DECIMAL_VRS.has(vr) && values.every(isText))
+ return { kind: 'decimal', values: values.map(decimalOf), text: values };
+ if (values.every(isText))
+ return { kind: 'text', values: textValues(vr, values, decode) };
+ if (values.every(isNumber)) return { kind: 'number', values };
+ if (values.every(isBytes)) return { kind: 'bytes', values };
+ return {
+ kind: 'unread',
+ bytes: new Uint8Array(0),
+ reason: `mixed value types for VR ${vr}`,
+ };
+};
+
+const elementValues = (
+ parsed: ParsedElement,
+ decode: CharacterSetDecoder,
+ charset: string[] | null
+): DicomValues => {
+ if (parsed.unread)
+ return {
+ kind: 'unread',
+ bytes: parsed.unread.bytes,
+ reason: parsed.unread.reason,
+ };
+ if (parsed.items)
+ return {
+ kind: 'sequence',
+ items: parsed.items.map((item) => buildDataset(item, charset, decode)),
+ };
+ 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,
+ inheritedDecoder?: CharacterSetDecoder
+): DicomDataset {
+ const declared = declaredCharacterSet(elements);
+ const charset = declared ?? inherited;
+ const decode =
+ declared === null && inheritedDecoder
+ ? inheritedDecoder
+ : characterSetDecoder(charset ?? []);
+ return {
+ elements: elements.map((element) => buildElement(element, decode, charset)),
+ specificCharacterSet: declared,
+ };
+}
+
+/**
+ * Reads a Part 10 file's header into a standards-shaped data set: every element
+ * keeps its tag, VR, value multiplicity, nested items and, where the VR alone
+ * cannot say how to read it, its bytes. Nothing missing is filled in.
+ */
+export const readDicomDataset = (bytes: Uint8Array) =>
+ buildDataset(parseDicomElements(bytes), null);
diff --git a/packages/dicom/core/metadata/dicomLayout.ts b/packages/dicom/core/metadata/dicomLayout.ts
new file mode 100644
index 000000000..af3b2c294
--- /dev/null
+++ b/packages/dicom/core/metadata/dicomLayout.ts
@@ -0,0 +1,90 @@
+import { toAscii } from '../utils';
+
+/**
+ * How a DICOM byte stream opens.
+ *
+ * Part 10 puts 128 bytes of preamble and the letters DICM before the file meta
+ * information, but files written without them are common enough that GDCM
+ * reads them, so the opening is sniffed rather than required: the file meta
+ * information may start at the first byte, or the data set itself may, with
+ * nothing to name its transfer syntax but the shape of its first element.
+ */
+export const PREAMBLE_LENGTH = 128;
+export const DICOM_PREFIX = 'DICM';
+/** The bytes a Part 10 file spends before its first element. */
+export const PART_10_HEADER_LENGTH = PREAMBLE_LENGTH + DICOM_PREFIX.length;
+
+export const IMPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2';
+export const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
+
+export type DicomLayout =
+ /** Part 10: preamble, prefix and file meta information. */
+ | { preamble: true; fileMeta: true }
+ /** File meta information from the first byte, with no preamble. */
+ | { preamble: false; fileMeta: true }
+ /**
+ * A bare data set, with no file meta information to name its transfer
+ * syntax. The syntax is what the encoding of the first element suggests.
+ */
+ | { preamble: false; fileMeta: false; transferSyntaxUid: string };
+
+const FILE_META_GROUP = 0x0002;
+
+/**
+ * The group a bare data set opens with: every SOP instance carries a SOP Class
+ * UID in it, and Specific Character Set and Image Type sort ahead of that.
+ * Bytes that open with any other group are not a data set, and reading them
+ * as one would take arbitrary noise for a header.
+ */
+const FIRST_DATA_SET_GROUP = 0x0008;
+
+/** The bytes needed to judge one element's tag and, in explicit VR, its VR. */
+const ELEMENT_HEAD_LENGTH = 6;
+
+// prettier-ignore
+const VRS = new Set([
+ 'AE', 'AS', 'AT', 'CS', 'DA', 'DS', 'DT', 'FD', 'FL', 'IS', 'LO', 'LT', 'OB',
+ 'OD', 'OF', 'OL', 'OV', 'OW', 'PN', 'SH', 'SL', 'SQ', 'SS', 'ST', 'SV', 'TM',
+ 'UC', 'UI', 'UL', 'UN', 'UR', 'US', 'UT', 'UV',
+]);
+
+const littleEndianGroup = (head: Uint8Array) => head[0] | (head[1] << 8);
+
+/** Whether the two bytes after the first tag spell a VR, as explicit VR does. */
+const opensWithVr = (head: Uint8Array) =>
+ VRS.has(toAscii(head.subarray(4, ELEMENT_HEAD_LENGTH)));
+
+/**
+ * Reads how `head` opens. `head` should hold the first 132 bytes of the file;
+ * fewer decide what they can. Throws when no reading fits, since guessing past
+ * this point would parse noise as a header.
+ */
+export function sniffDicomLayout(head: Uint8Array): DicomLayout {
+ if (
+ head.length >= PART_10_HEADER_LENGTH &&
+ toAscii(head.subarray(PREAMBLE_LENGTH, PART_10_HEADER_LENGTH)) ===
+ DICOM_PREFIX
+ )
+ return { preamble: true, fileMeta: true };
+
+ if (head.length < ELEMENT_HEAD_LENGTH)
+ throw new Error('Not DICOM: too short to hold a data element');
+
+ // File meta information is always explicit VR little endian.
+ const group = littleEndianGroup(head);
+ if (group === FILE_META_GROUP && opensWithVr(head))
+ return { preamble: false, fileMeta: true };
+
+ if (group !== FIRST_DATA_SET_GROUP)
+ throw new Error(
+ 'Not DICOM: no preamble, file meta information or data set'
+ );
+
+ return {
+ preamble: false,
+ fileMeta: false,
+ transferSyntaxUid: opensWithVr(head)
+ ? EXPLICIT_VR_LITTLE_ENDIAN
+ : IMPLICIT_VR_LITTLE_ENDIAN,
+ };
+}
diff --git a/src/core/dicomTags.ts b/packages/dicom/core/metadata/dicomTags.ts
similarity index 89%
rename from src/core/dicomTags.ts
rename to packages/dicom/core/metadata/dicomTags.ts
index 585f43ac7..59125b5dc 100644
--- a/src/core/dicomTags.ts
+++ b/packages/dicom/core/metadata/dicomTags.ts
@@ -18,7 +18,9 @@ const tags: Tag[] = [
{ name: 'Modality', tag: '0008|0060' },
{ name: 'SeriesInstanceUID', tag: '0020|000e' },
{ name: 'SeriesNumber', tag: '0020|0011' },
+ { name: 'SeriesDate', tag: '0008|0021' },
{ name: 'SeriesDescription', tag: '0008|103e' },
+ { name: 'InstanceNumber', tag: '0020|0013' },
{ name: 'WindowLevel', tag: '0028|1050' },
{ name: 'WindowWidth', tag: '0028|1051' },
{ name: 'VOILUTFunction', tag: '0028|1056' },
@@ -27,10 +29,16 @@ const tags: Tag[] = [
{ name: 'BitsAllocated', tag: '0028|0100' },
{ name: 'BitsStored', tag: '0028|0101' },
{ name: 'PixelRepresentation', tag: '0028|0103' },
+ { name: 'AcquisitionNumber', tag: '0020|0012' },
+ { name: 'TemporalPositionIdentifier', tag: '0020|0100' },
+ { name: 'EchoNumbers', tag: '0018|0086' },
+ { name: 'DiffusionBValue', tag: '0018|9087' },
{ name: 'ImagePositionPatient', tag: '0020|0032' },
{ name: 'ImageOrientationPatient', tag: '0020|0037' },
{ name: 'PixelSpacing', tag: '0028|0030' },
{ name: 'SpacingBetweenSlices', tag: '0018|0088' },
+ { name: 'SliceThickness', tag: '0018|0050' },
+ { name: 'SequenceName', tag: '0018|0024' },
{ name: 'SamplesPerPixel', tag: '0028|0002' },
{ name: 'RescaleIntercept', tag: '0028|1052' },
{ name: 'RescaleSlope', tag: '0028|1053' },
diff --git a/packages/dicom/core/metadata/functionalGroups.ts b/packages/dicom/core/metadata/functionalGroups.ts
new file mode 100644
index 000000000..2f84c1a99
--- /dev/null
+++ b/packages/dicom/core/metadata/functionalGroups.ts
@@ -0,0 +1,54 @@
+import type { DicomDataset } from './dicomDataset';
+import { findElement } from './dicomDataset';
+import { getDatasetTag } from './datasetAccess';
+import { getNmDetectorTag } from './nmReconstruction';
+
+const SHARED_GROUPS = '52009229';
+const PER_FRAME_GROUPS = '52009230';
+
+const IMAGE_MACROS = new Map([
+ ['00180050', '00289110'], // Pixel Measures: Slice Thickness
+ ['00180088', '00289110'], // Pixel Measures: Spacing Between Slices
+ ['00280030', '00289110'], // Pixel Measures: Pixel Spacing
+ ['00200032', '00209113'], // Plane Position (Patient)
+ ['00200037', '00209116'], // Plane Orientation (Patient)
+ ['00281052', '00289145'], // Pixel Value Transformation: Intercept
+ ['00281053', '00289145'], // Pixel Value Transformation: Slope
+]);
+
+export function sequenceItems(dataset: DicomDataset, tag: string) {
+ const values = findElement(dataset, tag)?.values;
+ return values?.kind === 'sequence' ? values.items : [];
+}
+
+const macroValue = (
+ group: DicomDataset | undefined,
+ macro: string,
+ tag: string
+) => (group ? getDatasetTag(sequenceItems(group, macro)[0], tag) : undefined);
+
+/**
+ * Image attributes have specific Functional Group paths. Unrelated nested
+ * sequences, including referenced images, never supply the image's own tags.
+ */
+export function getFrameTag(
+ dataset: DicomDataset | null | undefined,
+ tag: string,
+ frameIndex = 0
+) {
+ if (!dataset) return undefined;
+ const key = tag.replace('|', '').toUpperCase();
+ const macro = IMAGE_MACROS.get(key);
+ if (!macro) return getDatasetTag(dataset, key);
+ const perFrame = sequenceItems(dataset, PER_FRAME_GROUPS)[frameIndex];
+ const shared = sequenceItems(dataset, SHARED_GROUPS)[0];
+ return (
+ macroValue(perFrame, macro, key) ??
+ macroValue(shared, macro, key) ??
+ getNmDetectorTag(dataset, key) ??
+ getDatasetTag(dataset, key)
+ );
+}
+
+export const perFrameGroups = (dataset: DicomDataset) =>
+ sequenceItems(dataset, PER_FRAME_GROUPS);
diff --git a/packages/dicom/core/metadata/modalityRescale.ts b/packages/dicom/core/metadata/modalityRescale.ts
new file mode 100644
index 000000000..0e270ed06
--- /dev/null
+++ b/packages/dicom/core/metadata/modalityRescale.ts
@@ -0,0 +1,27 @@
+import type { DicomDataset } from './dicomDataset';
+import { getFrameTag, perFrameGroups } from './functionalGroups';
+import { numberOr } from '../utils';
+
+export const readModalityRescale = (
+ metadata: DicomDataset | null | undefined,
+ frameIndex = 0
+) => ({
+ rescaleSlope: numberOr(getFrameTag(metadata, '00281053', frameIndex), 1),
+ rescaleIntercept: numberOr(getFrameTag(metadata, '00281052', frameIndex), 0),
+});
+
+/** GDCM applies one transform to an instance, so its frames must agree. */
+export function assertUniformModalityRescale(metadata: DicomDataset) {
+ const first = readModalityRescale(metadata);
+ const differs = perFrameGroups(metadata).some((_, index) => {
+ const frame = readModalityRescale(metadata, index);
+ return (
+ frame.rescaleSlope !== first.rescaleSlope ||
+ frame.rescaleIntercept !== first.rescaleIntercept
+ );
+ });
+ if (differs)
+ throw new Error(
+ 'Cannot load DICOM frames with differing modality rescale: the decoder applies one transform to the entire instance'
+ );
+}
diff --git a/packages/dicom/core/metadata/nmReconstruction.ts b/packages/dicom/core/metadata/nmReconstruction.ts
new file mode 100644
index 000000000..6f9ec5fcc
--- /dev/null
+++ b/packages/dicom/core/metadata/nmReconstruction.ts
@@ -0,0 +1,146 @@
+import { findElement, type DicomDataset } from './dicomDataset';
+import { getDatasetTag } from './datasetAccess';
+import { trimDicomPadding } from '../utils';
+
+const NM_IMAGE_STORAGE = '1.2.840.10008.5.1.4.1.1.20';
+const SLICE_VECTOR = '00540080';
+const DETECTOR_INFORMATION = '00540022';
+
+const text = (metadata: DicomDataset, tag: string) =>
+ trimDicomPadding(getDatasetTag(metadata, tag) ?? '');
+
+const number = (metadata: DicomDataset, tag: string) => {
+ const value = text(metadata, tag);
+ return value ? Number(value) : NaN;
+};
+
+const vector = (metadata: DicomDataset, tag: string) =>
+ text(metadata, tag)
+ .split('\\')
+ .map((value) => Number(trimDicomPadding(value)));
+
+const detectorItems = (metadata: DicomDataset) => {
+ const values = findElement(metadata, DETECTOR_INFORMATION)?.values;
+ return values?.kind === 'sequence' ? values.items : [];
+};
+
+const spatialPointer = (metadata: DicomDataset) => {
+ const pointer = findElement(metadata, '00280009');
+ if (!pointer) return true;
+ return (
+ pointer.values.kind === 'attributeTag' &&
+ pointer.values.values.length === 1 &&
+ pointer.values.values[0].group === 0x0054 &&
+ pointer.values.values[0].element === 0x0080
+ );
+};
+
+const singleDimension = (metadata: DicomDataset) =>
+ [
+ '00540011',
+ '00540031',
+ '00540051',
+ '00540061',
+ '00540071',
+ '00540101',
+ ].every((tag) => !text(metadata, tag) || number(metadata, tag) === 1);
+
+const singleDimensionVectors = (metadata: DicomDataset) =>
+ [
+ '00540010',
+ '00540020',
+ '00540030',
+ '00540050',
+ '00540060',
+ '00540070',
+ '00540100',
+ ].every(
+ (tag) =>
+ !text(metadata, tag) ||
+ vector(metadata, tag).every((value) => value === 1)
+ );
+
+const orderedSlices = (metadata: DicomDataset, count: number) => {
+ const values = text(metadata, SLICE_VECTOR);
+ if (!values) return !findElement(metadata, '00280009');
+ const slices = vector(metadata, SLICE_VECTOR);
+ return (
+ slices.length === count &&
+ slices.every((slice, index) => slice === index + 1)
+ );
+};
+
+type NmReconstruction =
+ | { error: string }
+ | { detector: DicomDataset; count: number; sliceSpacing: number | null };
+
+const dimensionError = (
+ metadata: DicomDataset,
+ type: string,
+ count: number
+) => {
+ if (!text(metadata, '00540081'))
+ return 'Cannot allocate NM volume without NumberOfSlices';
+ return type !== 'RECON TOMO' ||
+ count !== number(metadata, '00540081') ||
+ !singleDimension(metadata) ||
+ !singleDimensionVectors(metadata) ||
+ !spatialPointer(metadata)
+ ? 'Unsupported NM volume with multiple spatial/time dimensions'
+ : null;
+};
+
+const detectorError = (metadata: DicomDataset, detectors: DicomDataset[]) =>
+ number(metadata, '00540021') !== 1 || detectors.length !== 1
+ ? 'Unsupported NM volume with multiple or missing detectors'
+ : null;
+
+function sliceError(metadata: DicomDataset, count: number) {
+ if (
+ !Number.isSafeInteger(count) ||
+ count <= 0 ||
+ !orderedSlices(metadata, count)
+ )
+ return 'Unsupported NM volume with unordered or missing slice indices';
+ const spacing = number(metadata, '00180088');
+ return (count === 1 && !text(metadata, '00180088')) ||
+ (Number.isFinite(spacing) && spacing !== 0)
+ ? null
+ : 'Cannot allocate NM volume without nonzero slice spacing';
+}
+
+/**
+ * NM detector coordinates describe a volume only for a single reconstructed
+ * spatial stack. Projection and gated dimensions require their own assembly.
+ */
+export function readNmReconstruction(
+ metadata: DicomDataset
+): NmReconstruction | null {
+ if (text(metadata, '00080016') !== NM_IMAGE_STORAGE) return null;
+ const imageType = text(metadata, '00080008').split('\\')[2]?.trim() ?? '';
+ if (imageType !== 'RECON TOMO' && imageType !== 'RECON GATED TOMO')
+ return null;
+ const count = number(metadata, '00280008');
+ const detectors = detectorItems(metadata);
+ const error =
+ dimensionError(metadata, imageType, count) ??
+ detectorError(metadata, detectors) ??
+ sliceError(metadata, count);
+ return error
+ ? { error }
+ : {
+ detector: detectors[0],
+ count,
+ sliceSpacing: text(metadata, '00180088')
+ ? number(metadata, '00180088')
+ : null,
+ };
+}
+
+export function getNmDetectorTag(metadata: DicomDataset, tag: string) {
+ if (tag !== '00200032' && tag !== '00200037') return undefined;
+ const reconstruction = readNmReconstruction(metadata);
+ return reconstruction && !('error' in reconstruction)
+ ? getDatasetTag(reconstruction.detector, tag)
+ : undefined;
+}
diff --git a/packages/dicom/core/metadata/readDicomDataset.worker.ts b/packages/dicom/core/metadata/readDicomDataset.worker.ts
new file mode 100644
index 000000000..4446f9a64
--- /dev/null
+++ b/packages/dicom/core/metadata/readDicomDataset.worker.ts
@@ -0,0 +1,5 @@
+import * as Comlink from 'comlink';
+import { readDicomDataset } from './dicomDataset';
+
+export type DicomDatasetWorker = { readDicomDataset: typeof readDicomDataset };
+Comlink.expose({ readDicomDataset });
diff --git a/packages/dicom/core/metadata/spatialMetadata.ts b/packages/dicom/core/metadata/spatialMetadata.ts
new file mode 100644
index 000000000..e5406c490
--- /dev/null
+++ b/packages/dicom/core/metadata/spatialMetadata.ts
@@ -0,0 +1,70 @@
+import type { IrregularReason } from '../collections/reconstructVolume';
+import type { DicomDataset } from './dicomDataset';
+import { getFrameTag } from './functionalGroups';
+import { trimDicomPadding } from '../utils';
+
+/** Describes the spacing actually used by the image, not independent calibration. */
+export type AxisSpacing = {
+ value: number;
+ unit: 'mm' | 'unknown';
+ status: 'declared' | 'derived' | 'fallback';
+ source: string;
+ reason?: string;
+};
+
+export type SpatialMetadata = {
+ /** Image index axes, in x/y/z order. */
+ axes: [AxisSpacing, AxisSpacing, AxisSpacing];
+ reconstruction?: { kind: 'regular' | 'irregular'; reason?: IrregularReason };
+ notes: string[];
+ calibration?: { type?: string; description?: string };
+};
+
+export const physicalSpacing = (
+ value: number,
+ source: string,
+ status: 'declared' | 'derived' = 'declared'
+): AxisSpacing => ({ value, unit: 'mm', status, source });
+
+export const fallbackSpacing = (reason: string): AxisSpacing => ({
+ value: 1,
+ unit: 'unknown',
+ status: 'fallback',
+ source: 'Display default',
+ reason,
+});
+
+export function declaredSpacing(raw: string | undefined, source: string) {
+ const text = trimDicomPadding(raw ?? '');
+ if (!text) return fallbackSpacing(`${source} is not supplied.`);
+ const value = Number(text);
+ return Number.isFinite(value) && value > 0
+ ? physicalSpacing(value, source)
+ : fallbackSpacing(`${source} has no usable positive spacing.`);
+}
+
+function singletonPixelSpacing(raw: string) {
+ const declared = declaredSpacing(raw, 'PixelSpacing');
+ if (declared.status === 'fallback') return declared;
+ return {
+ ...fallbackSpacing(
+ 'PixelSpacing has only one value; square pixels are assumed for display.'
+ ),
+ value: declared.value,
+ source: 'Single PixelSpacing value',
+ };
+}
+
+/** Preserve a usable axis even when the other axis is missing or unusable. */
+export function readPixelSpacing(metadata: DicomDataset | null | undefined) {
+ const raw = trimDicomPadding(getFrameTag(metadata, '00280030') ?? '');
+ const parts = raw ? raw.split('\\') : undefined;
+ if (parts && parts.length < 2) {
+ const assumed = singletonPixelSpacing(parts[0]);
+ return [assumed, { ...assumed }] as const;
+ }
+ return [
+ declaredSpacing(parts?.[1], 'PixelSpacing'),
+ declaredSpacing(parts?.[0], 'PixelSpacing'),
+ ] as const;
+}
diff --git a/packages/dicom/core/runtime/__tests__/decoderCalibration.spec.ts b/packages/dicom/core/runtime/__tests__/decoderCalibration.spec.ts
new file mode 100644
index 000000000..284db371e
--- /dev/null
+++ b/packages/dicom/core/runtime/__tests__/decoderCalibration.spec.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { createDicomDecoder } from '../decoder';
+import { createDicomLoader } from '../createDicomLoader';
+import { readDicomDataset } from '../../metadata/dicomDataset';
+import { defer } from '../../utils';
+import { enhancedFixtureBytes } from '@/tests/fixtures/dicom-consumer/enhancedFixtures';
+
+const assets = {
+ pipelineWorkerUrl: '/unused-worker.js',
+ imageIOUrl: '/unused-image-io',
+};
+const varyingFrames = () =>
+ enhancedFixtureBytes({
+ pixelValues: [2, 2],
+ transforms: [
+ [1, 0],
+ [2, 0],
+ ],
+ });
+const fileOf = (bytes: Uint8Array) =>
+ new File([bytes as Uint8Array], 'enhanced.dcm');
+
+describe('Default DICOM decoder calibration', () => {
+ for (const create of [
+ () => createDicomDecoder(assets),
+ () => {
+ const loader = createDicomLoader({ assets });
+ return { decode: loader.readDicomImage, dispose: loader.dispose };
+ },
+ ]) {
+ it('rejects varying frame transforms before invoking GDCM, with or without supplied metadata', async () => {
+ const decoder = create();
+ const bytes = varyingFrames();
+ try {
+ await expect(decoder.decode(fileOf(bytes))).rejects.toThrow(
+ 'differing modality rescale'
+ );
+ await expect(
+ decoder.decode(fileOf(bytes), readDicomDataset(bytes))
+ ).rejects.toThrow('differing modality rescale');
+ } finally {
+ decoder.dispose();
+ }
+ });
+ }
+
+ it('settles disposal while a direct decoder waits for metadata', async () => {
+ const started = defer();
+ const metadata = defer>();
+ const decoder = createDicomDecoder(assets, () => {
+ started.resolve();
+ return metadata.promise;
+ });
+ const bytes = varyingFrames();
+ const pending = decoder.decode(fileOf(bytes));
+ const rejected = expect(pending).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ await started.promise;
+ decoder.dispose();
+ await rejected;
+ metadata.resolve(readDicomDataset(bytes));
+ await expect(decoder.decode(fileOf(bytes))).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ });
+});
diff --git a/packages/dicom/core/runtime/__tests__/lifetime.spec.ts b/packages/dicom/core/runtime/__tests__/lifetime.spec.ts
new file mode 100644
index 000000000..ec75bc523
--- /dev/null
+++ b/packages/dicom/core/runtime/__tests__/lifetime.spec.ts
@@ -0,0 +1,213 @@
+import { describe, it, expect } from 'vitest';
+import { createDicomLoader } from '../createDicomLoader';
+import { Chunk, waitForChunkState } from '../../streaming/chunk';
+import { ChunkState } from '../../streaming/chunkStateMachine';
+import { DicomMetaLoader } from '../../streaming/dicom/dicomMetaLoader';
+import { DicomDataLoader } from '../../streaming/dicom/dicomDataLoader';
+import { blobFetcher } from '../../streaming/blobFetcher';
+import { RequestPool } from '../../streaming/requestPool';
+import { readDicomDataset } from '../../metadata/dicomDataset';
+import { getDatasetTag } from '../../metadata/datasetAccess';
+import { defer } from '../../utils';
+import { syntheticSlice } from '@/tests/dicomSourceFixtures';
+
+const blob = () =>
+ new Blob([new Uint8Array(syntheticSlice({ patientName: 'DOE^JOHN' }))]);
+
+describe('DICOM instance lifetimes', () => {
+ it('settles cancellation and lets retry finish before a stale header read', async () => {
+ const first = defer>();
+ const started = defer();
+ const source = blobFetcher(blob());
+ let reads = 0;
+ const metadata = new DicomMetaLoader(source, (bytes) => {
+ if (reads++ === 0) {
+ started.resolve();
+ return first.promise;
+ }
+ return readDicomDataset(bytes);
+ });
+ const chunk = new Chunk({
+ metaLoader: metadata,
+ dataLoader: new DicomDataLoader(source),
+ });
+ const cancelledRead = chunk.loadMeta();
+ await started.promise;
+ const rejected = expect(cancelledRead).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ chunk.stopLoad();
+ await chunk.loadMeta();
+ await rejected;
+ first.resolve(
+ readDicomDataset(syntheticSlice({ patientName: 'STALE^NAME' }))
+ );
+ await first.promise;
+ expect(getDatasetTag(chunk.metadata, '00100010')).toBe('DOE^JOHN');
+ await chunk.loadData();
+ expect(await chunk.dataBlob!.arrayBuffer()).toEqual(
+ await blob().arrayBuffer()
+ );
+ chunk.dispose();
+ await expect(chunk.loadMeta()).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ });
+
+ it('retries a failed HTTP source without creating a second instance', async () => {
+ let requests = 0;
+ const loader = createDicomLoader({
+ fetch: async () => {
+ if (requests++ === 0) throw new Error('offline');
+ return new Response(blob());
+ },
+ });
+ const chunk = loader.createChunk('https://example.test/slice.dcm');
+ await expect(chunk.loadMeta()).rejects.toThrow('offline');
+ await chunk.loadMeta();
+ expect(getDatasetTag(chunk.metadata, '00100010')).toBe('DOE^JOHN');
+ await chunk.loadData();
+ expect(chunk.dataBlob?.size).toBe(blob().size);
+ loader.release(chunk);
+ expect(chunk.dataBlob).toBeUndefined();
+ await expect(chunk.loadData()).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ loader.dispose();
+ });
+
+ it('settles an injected pending decode when its loader is disposed', async () => {
+ const result = defer<{
+ image: {
+ size: number[];
+ data: Uint16Array;
+ imageType: { components: number };
+ };
+ }>();
+ const started = defer();
+ const loader = createDicomLoader({
+ decoder: () => {
+ started.resolve();
+ return result.promise;
+ },
+ });
+ const decode = loader.readDicomImage(new File([blob()], 'slice.dcm'));
+ const rejected = expect(decode).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ await started.promise;
+ loader.dispose();
+ await rejected;
+ result.resolve({
+ image: {
+ size: [1, 1],
+ data: new Uint16Array([17]),
+ imageType: { components: 1 },
+ },
+ });
+ await expect(
+ loader.readDicomImage(new File([], 'slice.dcm'))
+ ).rejects.toMatchObject({ name: 'AbortError' });
+ });
+
+ it('settles queued requests immediately on cancellation or disposal', async () => {
+ const fetchStarted = defer();
+ const pool = new RequestPool(
+ 1,
+ (_request, init) =>
+ new Promise((_resolve, reject) => {
+ fetchStarted.resolve();
+ init?.signal?.addEventListener(
+ 'abort',
+ () => reject(init.signal?.reason),
+ { once: true }
+ );
+ })
+ );
+ const first = pool.fetch('https://example.test/first');
+ const firstRejected = expect(first).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ await fetchStarted.promise;
+ const cancellation = new AbortController();
+ const second = pool.fetch('https://example.test/second', {
+ signal: cancellation.signal,
+ });
+ const secondRejected = expect(second).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ cancellation.abort();
+ await secondRejected;
+ pool.dispose();
+ await firstRejected;
+ await expect(
+ pool.fetch('https://example.test/third')
+ ).rejects.toMatchObject({ name: 'AbortError' });
+ });
+ it.each(['source', 'pool'])(
+ 'cancels the HTTP response body after headers through %s disposal',
+ async (owner) => {
+ const controller = new AbortController();
+ const pool = new RequestPool(
+ 1,
+ async (_request, init) =>
+ new Response(
+ new ReadableStream({
+ start(stream) {
+ init?.signal?.addEventListener(
+ 'abort',
+ () => stream.error(init.signal?.reason),
+ { once: true }
+ );
+ },
+ })
+ )
+ );
+ const response = await pool.fetch('https://example.test/stream', {
+ signal: controller.signal,
+ });
+ const pending = response.body!.getReader().read();
+ const rejected = expect(pending).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ if (owner === 'source') controller.abort();
+ else pool.dispose();
+ await rejected;
+ pool.dispose();
+ }
+ );
+ it('gives each byte source one live chunk owner and keeps loaders independent', async () => {
+ const first = createDicomLoader();
+ const second = createDicomLoader();
+ const source = blobFetcher(blob());
+ const chunk = first.createChunk(source);
+ expect(() => first.createChunk(source)).toThrow('already belongs');
+ second.release(chunk);
+ await chunk.loadMeta();
+ expect(getDatasetTag(chunk.metadata, '00100010')).toBe('DOE^JOHN');
+ first.release(chunk);
+ const replacement = first.createChunk(source);
+ await replacement.loadMeta();
+ expect(getDatasetTag(replacement.metadata, '00100010')).toBe('DOE^JOHN');
+ first.dispose();
+ second.dispose();
+ });
+});
+
+it.each(['before', 'after'])(
+ 'settles chunk-state waiting started %s idle disposal',
+ async (when) => {
+ const source = blobFetcher(blob());
+ const chunk = new Chunk({
+ metaLoader: new DicomMetaLoader(source),
+ dataLoader: new DicomDataLoader(source),
+ });
+ if (when === 'after') chunk.dispose();
+ const pending = waitForChunkState(chunk, ChunkState.Loaded);
+ const rejected = expect(pending).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ chunk.dispose();
+ await rejected;
+ }
+);
diff --git a/packages/dicom/core/runtime/createDicomLoader.ts b/packages/dicom/core/runtime/createDicomLoader.ts
new file mode 100644
index 000000000..c6f3bc97d
--- /dev/null
+++ b/packages/dicom/core/runtime/createDicomLoader.ts
@@ -0,0 +1,114 @@
+import { blobFetcher } from '../streaming/blobFetcher';
+import { CachedStreamFetcher } from '../streaming/cachedStreamFetcher';
+import { Chunk } from '../streaming/chunk';
+import { DicomDataLoader } from '../streaming/dicom/dicomDataLoader';
+import { DicomMetaLoader } from '../streaming/dicom/dicomMetaLoader';
+import { RequestPool } from '../streaming/requestPool';
+import type { Fetcher } from '../streaming/types';
+import {
+ createMetadataReader,
+ type MetadataReaderOptions,
+} from './metadataReader';
+import {
+ createDicomDecoder,
+ type DicomAssets,
+ type DicomDecoder,
+} from './decoder';
+
+export type DicomLoaderOptions = MetadataReaderOptions & {
+ fetch?: typeof fetch;
+ requestConcurrency?: number;
+ assets?: DicomAssets;
+ /** A supplied decoder owns its own resources. Returned pixel arrays belong to the caller. */
+ decoder?: DicomDecoder;
+ imageImportPolicy?: 'complete-header' | 'skip-radiotherapy';
+};
+
+/** Owns sources, requests and workers; images borrow chunks and copy decoded pixels. */
+export function createDicomLoader(options: DicomLoaderOptions = {}) {
+ const requests = new RequestPool(options.requestConcurrency, options.fetch);
+ const metadata = createMetadataReader(options);
+ const decoder =
+ options.assets && !options.decoder
+ ? createDicomDecoder(options.assets, metadata.read)
+ : null;
+ const chunks = new Set();
+ const sources = new WeakSet();
+ const pending = new Set<(reason: unknown) => void>();
+ let disposed = false;
+ const assertActive = () => {
+ if (disposed) throw new DOMException('DICOM loader disposed', 'AbortError');
+ };
+ const createChunk = (source: Blob | string | URL | Fetcher) => {
+ assertActive();
+ let fetcher: Fetcher;
+ if (source instanceof Blob) fetcher = blobFetcher(source);
+ else if (typeof source === 'string' || source instanceof URL)
+ fetcher = new CachedStreamFetcher(source, { fetch: requests.fetch });
+ else fetcher = source;
+ if (sources.has(fetcher))
+ throw new Error('A DICOM byte source already belongs to a live chunk');
+ sources.add(fetcher);
+ const chunk = new Chunk({
+ metaLoader: new DicomMetaLoader(
+ fetcher,
+ metadata.read,
+ options.imageImportPolicy
+ ),
+ dataLoader: new DicomDataLoader(fetcher),
+ onDispose: () => {
+ chunks.delete(chunk);
+ sources.delete(fetcher);
+ },
+ });
+ chunks.add(chunk);
+ return chunk;
+ };
+ const readDicomImage: DicomDecoder = (file, header) =>
+ new Promise((resolve, reject) => {
+ try {
+ assertActive();
+ } catch (error) {
+ reject(error);
+ return;
+ }
+ const decode = options.decoder ?? decoder?.decode;
+ if (!decode) {
+ reject(new Error('Provide DICOM decoder assets or a decoder'));
+ return;
+ }
+ pending.add(reject);
+ Promise.resolve()
+ .then(() => decode(file, header))
+ .then((result) => {
+ assertActive();
+ resolve(result);
+ }, reject)
+ .catch(reject)
+ .finally(() => pending.delete(reject));
+ });
+ const release = (chunk: Chunk) => {
+ if (chunks.has(chunk)) chunk.dispose();
+ };
+ const dispose = () => {
+ if (disposed) return;
+ disposed = true;
+ chunks.forEach((chunk) => chunk.dispose());
+ chunks.clear();
+ requests.dispose();
+ metadata.dispose();
+ decoder?.dispose();
+ pending.forEach((reject) =>
+ reject(new DOMException('DICOM loader disposed', 'AbortError'))
+ );
+ pending.clear();
+ };
+ return {
+ createChunk,
+ readDicomImage,
+ release,
+ dispose,
+ fetch: requests.fetch,
+ };
+}
+export type DicomLoader = ReturnType;
diff --git a/packages/dicom/core/runtime/decoder.ts b/packages/dicom/core/runtime/decoder.ts
new file mode 100644
index 000000000..31282c7d8
--- /dev/null
+++ b/packages/dicom/core/runtime/decoder.ts
@@ -0,0 +1,150 @@
+import {
+ InterfaceTypes,
+ runPipeline,
+ type Image,
+ type TypedArray,
+} from 'itk-wasm';
+import type { DicomDataset } from '../metadata/dicomDataset';
+import { assertUniformModalityRescale } from '../metadata/modalityRescale';
+import { createMetadataReader } from './metadataReader';
+
+export type DecodedDicomImage = {
+ size: number[];
+ data: Exclude;
+ imageType: { components: number };
+};
+export type DicomDecoder = (
+ file: File,
+ /** When supplied, this header must describe the same file. */
+ metadata?: DicomDataset
+) => Promise<{ image: DecodedDicomImage }>;
+export type DicomAssets = { pipelineWorkerUrl: string; imageIOUrl: string };
+
+function decodedImage(result: Awaited>) {
+ if (result.returnValue !== 0 || !result.outputs[0]?.data)
+ throw new Error(result.stderr || 'Could not decode DICOM image');
+ const image = result.outputs[1]?.data as Image;
+ if (!image?.data) throw new Error('No pixel data decoded from DICOM image');
+ if (
+ image.data instanceof BigInt64Array ||
+ image.data instanceof BigUint64Array
+ )
+ throw new Error('DICOM pixel arrays must contain numbers');
+ return {
+ image: {
+ size: image.size,
+ data: image.data,
+ imageType: { components: image.imageType.components },
+ },
+ };
+}
+
+/** Reuses the GDCM image-io pipeline with explicit assets and one owned worker. */
+export function createDicomDecoder(
+ assets: DicomAssets,
+ readMetadata?: ReturnType['read']
+) {
+ const ownedMetadata = readMetadata ? null : createMetadataReader();
+ const read = readMetadata ?? ownedMetadata!.read;
+ let worker: Worker | null = null;
+ let disposed = false;
+ let queue = Promise.resolve();
+ let rejectActive: ((reason: unknown) => void) | undefined;
+ const pending = new Set<(reason: unknown) => void>();
+ const resetWorker = (reason: unknown) => {
+ if (worker) {
+ worker.removeEventListener('error', onError);
+ worker.removeEventListener('messageerror', onMessageError);
+ worker.terminate();
+ worker = null;
+ }
+ rejectActive?.(reason);
+ };
+ const onError = () => resetWorker(new Error('DICOM decoder worker failed'));
+ const onMessageError = () =>
+ resetWorker(new Error('DICOM decoder worker message failed'));
+ const assertActive = () => {
+ if (disposed)
+ throw new DOMException('DICOM decoder disposed', 'AbortError');
+ };
+ const decode: DicomDecoder = (file, metadata) =>
+ new Promise((resolve, reject) => {
+ if (disposed) {
+ reject(new DOMException('DICOM decoder disposed', 'AbortError'));
+ return;
+ }
+ pending.add(reject);
+ const operation = queue.then(async () => {
+ assertActive();
+ const data = new Uint8Array(await file.arrayBuffer());
+ assertActive();
+ const header = metadata ?? (await read(data));
+ assertActive();
+ assertUniformModalityRescale(header);
+ if (!worker) {
+ worker = new Worker(assets.pipelineWorkerUrl, { type: 'module' });
+ const ownedWorker = worker;
+ const addEventListener = worker.addEventListener.bind(worker);
+ // ITK attaches its message listener asynchronously, after creation.
+ worker.addEventListener = (
+ ...args: Parameters
+ ) => {
+ if (worker !== ownedWorker)
+ throw new DOMException(
+ 'DICOM decoder worker stopped',
+ 'AbortError'
+ );
+ addEventListener(...args);
+ };
+ worker.addEventListener('error', onError);
+ worker.addEventListener('messageerror', onMessageError);
+ }
+ const failed = new Promise((_, rejectFailure) => {
+ rejectActive = rejectFailure;
+ });
+ try {
+ const result = await Promise.race([
+ runPipeline(
+ 'gdcm-read-image',
+ [file.name, '0', '1', '--memory-io'],
+ [
+ { type: InterfaceTypes.JsonCompatible },
+ { type: InterfaceTypes.Image },
+ ],
+ [
+ {
+ type: InterfaceTypes.BinaryFile,
+ data: { path: file.name, data },
+ },
+ ],
+ {
+ webWorker: worker,
+ pipelineWorkerUrl: assets.pipelineWorkerUrl,
+ pipelineBaseUrl: assets.imageIOUrl,
+ }
+ ),
+ failed,
+ ]);
+ assertActive();
+ return decodedImage(result);
+ } finally {
+ rejectActive = undefined;
+ }
+ });
+ queue = operation.then(
+ () => {},
+ () => {}
+ );
+ operation.then(resolve, reject).finally(() => pending.delete(reject));
+ });
+ const dispose = () => {
+ if (disposed) return;
+ disposed = true;
+ const reason = new DOMException('DICOM decoder disposed', 'AbortError');
+ resetWorker(reason);
+ ownedMetadata?.dispose();
+ pending.forEach((reject) => reject(reason));
+ pending.clear();
+ };
+ return { decode, dispose };
+}
diff --git a/packages/dicom/core/runtime/metadataReader.ts b/packages/dicom/core/runtime/metadataReader.ts
new file mode 100644
index 000000000..7b060352d
--- /dev/null
+++ b/packages/dicom/core/runtime/metadataReader.ts
@@ -0,0 +1,80 @@
+import * as Comlink from 'comlink';
+import { readDicomDataset, type DicomDataset } from '../metadata/dicomDataset';
+import type { DicomDatasetWorker } from '../metadata/readDicomDataset.worker';
+
+export type MetadataReaderOptions = {
+ metadataWorkerFactory?: () => Worker;
+ metadataConcurrency?: number;
+};
+
+/** Owns parser workers. Input bytes are copied before transfer; the caller keeps its buffer. */
+export function createMetadataReader(options: MetadataReaderOptions = {}) {
+ const count = options.metadataConcurrency ?? 2;
+ if (!Number.isInteger(count) || count < 1)
+ throw new Error('Metadata concurrency must be a positive integer');
+ const workers: Array<{
+ worker: Worker;
+ remote: Comlink.Remote;
+ }> = [];
+ const pending = new Set<(reason: unknown) => void>();
+ let next = 0;
+ let disposed = false;
+ const createWorker =
+ options.metadataWorkerFactory ??
+ (() =>
+ new Worker(
+ new URL('../metadata/readDicomDataset.worker.ts', import.meta.url),
+ { type: 'module' }
+ ));
+ const resetWorkers = (error: unknown) => {
+ pending.forEach((reject) => reject(error));
+ pending.clear();
+ workers.forEach(({ remote, worker }) => {
+ remote[Comlink.releaseProxy]();
+ worker.terminate();
+ });
+ workers.length = 0;
+ };
+ const read = async (bytes: Uint8Array): Promise => {
+ if (disposed)
+ throw new DOMException('Metadata reader disposed', 'AbortError');
+ if (typeof Worker === 'undefined' && !options.metadataWorkerFactory)
+ return readDicomDataset(bytes);
+ if (workers.length < count) {
+ const worker = createWorker();
+ const fail = (message: string) => {
+ if (workers.some((entry) => entry.worker === worker))
+ resetWorkers(new Error(message));
+ };
+ worker.addEventListener(
+ 'error',
+ () => fail('DICOM metadata worker failed'),
+ { once: true }
+ );
+ worker.addEventListener(
+ 'messageerror',
+ () => fail('DICOM metadata worker message failed'),
+ { once: true }
+ );
+ workers.push({
+ worker,
+ remote: Comlink.wrap(worker),
+ });
+ }
+ const { remote } = workers[next++ % workers.length];
+ const owned = bytes.slice();
+ return new Promise((resolve, reject) => {
+ pending.add(reject);
+ remote
+ .readDicomDataset(Comlink.transfer(owned, [owned.buffer]))
+ .then(resolve, reject)
+ .finally(() => pending.delete(reject));
+ });
+ };
+ const dispose = () => {
+ if (disposed) return;
+ disposed = true;
+ resetWorkers(new DOMException('Metadata reader disposed', 'AbortError'));
+ };
+ return { read, dispose };
+}
diff --git a/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts b/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts
new file mode 100644
index 000000000..20e3cc8a4
--- /dev/null
+++ b/packages/dicom/core/streaming/__tests__/blobFetcher.spec.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest';
+import { blobFetcher } from '../blobFetcher';
+import { StopSignal } from '../cachedStreamFetcher';
+import { concatBytes } from '../../utils';
+
+const KIB = 1024;
+
+const bytesOf = (length: number) =>
+ Uint8Array.from({ length }, (_, index) => index % 251);
+
+const drain = async (
+ reader: ReadableStreamDefaultReader,
+ into: Uint8Array[] = []
+) => {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) return into;
+ into.push(value);
+ }
+};
+
+const readAll = (stream: ReadableStream) =>
+ drain(stream.getReader());
+
+const lengthOf = (chunks: Uint8Array[]) =>
+ chunks.reduce((total, chunk) => total + chunk.length, 0);
+
+describe('blobFetcher', () => {
+ it('streams the blob from its first byte in slices', async () => {
+ const bytes = bytesOf(200 * KIB);
+ const fetcher = blobFetcher(new Blob([bytes]));
+ await fetcher.connect();
+
+ const chunks = await readAll(fetcher.getStream());
+
+ expect(chunks.length).toBeGreaterThan(1);
+ expect(concatBytes(chunks)).toEqual(bytes);
+ });
+
+ // A meta load cut short and started again must not see the first stream's
+ // bytes ahead of its own.
+ it('starts every stream at the first byte', async () => {
+ const bytes = bytesOf(200 * KIB);
+ const fetcher = blobFetcher(new Blob([bytes]));
+ await fetcher.connect();
+
+ const first = fetcher.getStream().getReader();
+ await first.read();
+ const second = await readAll(fetcher.getStream());
+
+ expect(concatBytes(second)).toEqual(bytes);
+ });
+
+ it('ends a stream in flight once closed', async () => {
+ const bytes = bytesOf(400 * KIB);
+ const fetcher = blobFetcher(new Blob([bytes]));
+ await fetcher.connect();
+ const reader = fetcher.getStream().getReader();
+ const delivered = [(await reader.read()).value!];
+
+ fetcher.close();
+
+ await expect(drain(reader, delivered)).rejects.toBe(StopSignal);
+ expect(lengthOf(delivered)).toBeLessThan(bytes.length);
+ expect(fetcher.connected).toBe(false);
+ });
+
+ it('does not revive a closed stream when reconnected', async () => {
+ const fetcher = blobFetcher(new Blob([bytesOf(400 * KIB)]));
+ await fetcher.connect();
+ const reader = fetcher.getStream().getReader();
+
+ fetcher.close();
+ await fetcher.connect();
+
+ await expect(drain(reader)).rejects.toBe(StopSignal);
+ expect(concatBytes(await readAll(fetcher.getStream()))).toHaveLength(
+ 400 * KIB
+ );
+ });
+
+ it('closes a stream opened before connecting', async () => {
+ const fetcher = blobFetcher(new Blob([bytesOf(10)]));
+
+ expect(await readAll(fetcher.getStream())).toEqual([]);
+ });
+
+ it('hands out the blob itself as the whole data', async () => {
+ const blob = new Blob([bytesOf(10)]);
+
+ expect(await blobFetcher(blob).blob()).toBe(blob);
+ });
+});
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 80%
rename from src/core/streaming/__tests__/cachedStreamFetcher.spec.ts
rename to packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts
index d7eb46216..dbd74db9f 100644
--- a/src/core/streaming/__tests__/cachedStreamFetcher.spec.ts
+++ b/packages/dicom/core/streaming/__tests__/cachedStreamFetcher.spec.ts
@@ -1,8 +1,5 @@
-import { RequestPool } from '@/src/core/streaming/requestPool';
-import {
- CachedStreamFetcher,
- sliceChunks,
-} from '@/src/core/streaming/cachedStreamFetcher';
+import { RequestPool } from '../requestPool';
+import { CachedStreamFetcher, sliceChunks } from '../cachedStreamFetcher';
import { describe, expect, it } from 'vitest';
const readStream = async (
@@ -118,3 +115,25 @@ describe('sliceChunks', () => {
).toEqual([new Uint8Array([1, 2]), new Uint8Array([3])]);
});
});
+
+it('rejects an unverified resumed range while preserving the cached header', async () => {
+ const prefix = new Uint8Array([1, 2, 3, 4]);
+ let requests = 0;
+ const fetcher = new CachedStreamFetcher(
+ 'https://example.test/hidden-headers',
+ {
+ prefixChunks: [prefix],
+ fetch: async () =>
+ new Response(new Uint8Array([5, 6]), {
+ status: 206,
+ headers: requests++ === 0 ? {} : { 'content-range': 'bytes 4-5/6' },
+ }),
+ }
+ );
+ await expect(fetcher.connect()).rejects.toThrow('readable content range');
+ expect(fetcher.connected).toBe(false);
+ expect(new Uint8Array(await (await fetcher.blob()).arrayBuffer())).toEqual(
+ new Uint8Array([1, 2, 3, 4, 5, 6])
+ );
+ fetcher.close();
+});
diff --git a/src/core/streaming/__tests__/chunkStateMachine.spec.ts b/packages/dicom/core/streaming/__tests__/chunkStateMachine.spec.ts
similarity index 95%
rename from src/core/streaming/__tests__/chunkStateMachine.spec.ts
rename to packages/dicom/core/streaming/__tests__/chunkStateMachine.spec.ts
index 4933fd158..706493400 100644
--- a/src/core/streaming/__tests__/chunkStateMachine.spec.ts
+++ b/packages/dicom/core/streaming/__tests__/chunkStateMachine.spec.ts
@@ -2,7 +2,7 @@ import {
ChunkState,
ChunkStateMachine,
TransitionEvent,
-} from '@/src/core/streaming/chunkStateMachine';
+} from '../chunkStateMachine';
import { describe, expect, it } from 'vitest';
describe('chunk', () => {
diff --git a/src/core/streaming/__tests__/requestPool.spec.ts b/packages/dicom/core/streaming/__tests__/requestPool.spec.ts
similarity index 69%
rename from src/core/streaming/__tests__/requestPool.spec.ts
rename to packages/dicom/core/streaming/__tests__/requestPool.spec.ts
index 3cc846db9..de25f67b0 100644
--- a/src/core/streaming/__tests__/requestPool.spec.ts
+++ b/packages/dicom/core/streaming/__tests__/requestPool.spec.ts
@@ -1,4 +1,4 @@
-import { RequestPool } from '@/src/core/streaming/requestPool';
+import { RequestPool } from '../requestPool';
import { describe, it, expect } from 'vitest';
const neverResolvingFetch = async () => {
@@ -55,3 +55,21 @@ describe('requestPool', () => {
}
});
});
+
+it('imports a remote resource without AbortSignal.any', async () => {
+ const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, 'any');
+ Object.defineProperty(AbortSignal, 'any', {
+ configurable: true,
+ value: undefined,
+ });
+ const pool = new RequestPool(1, async () => new Response('resource'));
+ try {
+ expect(
+ await (await pool.fetch('https://example.test/resource')).text()
+ ).toBe('resource');
+ } finally {
+ pool.dispose();
+ if (descriptor) Object.defineProperty(AbortSignal, 'any', descriptor);
+ else Reflect.deleteProperty(AbortSignal, 'any');
+ }
+});
diff --git a/src/core/streaming/__tests__/streamingByteReader.spec.ts b/packages/dicom/core/streaming/__tests__/streamingByteReader.spec.ts
similarity index 95%
rename from src/core/streaming/__tests__/streamingByteReader.spec.ts
rename to packages/dicom/core/streaming/__tests__/streamingByteReader.spec.ts
index cbd4e5dd5..cc720f925 100644
--- a/src/core/streaming/__tests__/streamingByteReader.spec.ts
+++ b/packages/dicom/core/streaming/__tests__/streamingByteReader.spec.ts
@@ -1,4 +1,4 @@
-import StreamingByteReader from '@/src/core/streaming/streamingByteReader';
+import StreamingByteReader from '../streamingByteReader';
import { asCoroutine } from '@/src/utils';
import { describe, it, expect } from 'vitest';
diff --git a/packages/dicom/core/streaming/blobFetcher.ts b/packages/dicom/core/streaming/blobFetcher.ts
new file mode 100644
index 000000000..d588b1184
--- /dev/null
+++ b/packages/dicom/core/streaming/blobFetcher.ts
@@ -0,0 +1,67 @@
+import { StopSignal } from './cachedStreamFetcher';
+import { Fetcher } from './types';
+
+/** Read enough per pull that a header rarely needs a second slice. */
+const CHUNK_SIZE = 64 * 1024;
+
+/**
+ * A Fetcher over bytes that are already addressable, so a local file and a
+ * remote URI reach the same loaders. Slicing rather than reading the whole
+ * blob keeps a header read off the rest of the file.
+ *
+ * The blob is its own cache: every stream starts at its first byte and keeps
+ * nothing, so a header the meta loader is done with is not pinned for the
+ * chunk's lifetime, and a stream cut short leaves nothing behind for the next
+ * one to trip over. Closing ends a stream in flight the way a cancelled
+ * download ends a network stream.
+ */
+export const blobFetcher = (blob: Blob): Fetcher => {
+ let abortController: AbortController | null = null;
+
+ return {
+ connect: async () => {
+ abortController ??= new AbortController();
+ },
+ getStream: () => {
+ // The stream belongs to the connection it was opened on: a close then
+ // a reconnect must not revive it.
+ const connection = abortController;
+ let offset = 0;
+ return new ReadableStream({
+ pull: async (controller) => {
+ if (!connection) {
+ controller.close();
+ return;
+ }
+ if (connection.signal.aborted) {
+ controller.error(StopSignal);
+ return;
+ }
+ if (offset >= blob.size) {
+ controller.close();
+ return;
+ }
+ const slice = blob.slice(offset, offset + CHUNK_SIZE);
+ const chunk = new Uint8Array(await slice.arrayBuffer());
+ offset += chunk.length;
+ controller.enqueue(chunk);
+ },
+ });
+ },
+ blob: async () => blob,
+ close: () => {
+ abortController?.abort(StopSignal);
+ abortController = null;
+ },
+ get connected() {
+ return !!abortController;
+ },
+ get size() {
+ return blob.size;
+ },
+ contentType: blob.type,
+ get abortSignal() {
+ return abortController?.signal;
+ },
+ };
+};
diff --git a/src/core/streaming/byteDeque.ts b/packages/dicom/core/streaming/byteDeque.ts
similarity index 100%
rename from src/core/streaming/byteDeque.ts
rename to packages/dicom/core/streaming/byteDeque.ts
diff --git a/src/core/streaming/cachedStreamFetcher.ts b/packages/dicom/core/streaming/cachedStreamFetcher.ts
similarity index 80%
rename from src/core/streaming/cachedStreamFetcher.ts
rename to packages/dicom/core/streaming/cachedStreamFetcher.ts
index 7265ed2d0..423e3a646 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() {
@@ -98,10 +100,6 @@ export class CachedStreamFetcher implements Fetcher {
return this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
}
- get cachedChunks() {
- return this.chunks;
- }
-
get abortSignal() {
return this.abortController?.signal;
}
@@ -110,6 +108,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 +129,7 @@ export class CachedStreamFetcher implements Fetcher {
signal: this.abortController.signal,
});
+ this.validateConnection(connection, response);
this.contentType = response.headers.get('content-type') ?? '';
this.contentDisposition = response.headers.get('content-disposition') ?? '';
@@ -172,17 +172,7 @@ export class CachedStreamFetcher implements Fetcher {
}
if (response.status === HTTP_STATUS_PARTIAL_CONTENT) {
- if (contentRange.type === 'invalid-range')
- throw new Error('Invalid content-range header');
- if (contentRange.type === 'unsatisfied-range')
- throw new Error('Range could not be satisfied');
-
- let start: number = 0;
- if (contentRange.type === 'range') {
- start = contentRange.start;
- }
-
- this.chunks = sliceChunks(this.chunks, start);
+ this.resumeChunks(response, contentRange);
} else if (!noMoreContent) {
this.chunks = [];
}
@@ -191,6 +181,29 @@ export class CachedStreamFetcher implements Fetcher {
this.activeNetworkStreamReader = this.activeNetworkStream.getReader();
}
+ private resumeChunks(response: Response, contentRange: ContentRange) {
+ if (contentRange.type === 'invalid-range')
+ throw new Error('Invalid content-range header');
+ if (contentRange.type === 'unsatisfied-range')
+ throw new Error('Range could not be satisfied');
+ if (contentRange.type === 'empty-range' && this.size > 0) {
+ response.body?.cancel().catch(() => {});
+ this.close();
+ throw new Error(
+ 'Cannot resume a DICOM download without a readable content range'
+ );
+ }
+ const start = contentRange.type === 'range' ? contentRange.start : 0;
+ this.chunks = sliceChunks(this.chunks, start);
+ }
+
+ private validateConnection(connection: AbortController, response: Response) {
+ if (connection.signal.aborted || connection !== this.abortController) {
+ response.body?.cancel().catch(() => {});
+ throw new DOMException('DICOM request cancelled', 'AbortError');
+ }
+ }
+
close() {
if (!this.abortController) return;
this.abortController.abort(StopSignal);
@@ -203,6 +216,7 @@ export class CachedStreamFetcher implements Fetcher {
pull: async (controller) => {
if (!this.connected) {
controller.close();
+ return;
}
if (chunkIndex > this.chunks.length)
@@ -232,9 +246,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 +260,7 @@ export class CachedStreamFetcher implements Fetcher {
return result;
})
.finally(() => {
- this.promise = null;
+ if (connection === this.abortController) this.promise = null;
});
}
return this.promise;
@@ -260,6 +276,7 @@ export class CachedStreamFetcher implements Fetcher {
}
private cleanupAfterAbort() {
+ this.promise = null;
this.activeNetworkStreamReader = null;
this.activeNetworkStream = null;
this.abortController = null;
diff --git a/packages/dicom/core/streaming/chunk.ts b/packages/dicom/core/streaming/chunk.ts
new file mode 100644
index 000000000..adec692d6
--- /dev/null
+++ b/packages/dicom/core/streaming/chunk.ts
@@ -0,0 +1,215 @@
+import { UpdateEvent, enters, leaves } from './stateMachine';
+import {
+ ChunkState,
+ ChunkStateMachine,
+ TransitionEvent,
+} from './chunkStateMachine';
+import { DataLoader, MetaLoader } from './types';
+import mitt, { Emitter } from 'mitt';
+
+type ChunkEvents = {
+ doneMeta: void;
+ doneData: void;
+ error: unknown;
+ disposed: void;
+};
+type ChunkInit = {
+ metaLoader: MetaLoader;
+ dataLoader: DataLoader;
+ onDispose?: () => void;
+};
+const cancelled = () =>
+ new DOMException('DICOM loading cancelled', 'AbortError');
+
+/** A source instance owns its bytes until disposal; its immutable metadata remains inspectable afterwards. */
+export class Chunk {
+ private machine = new ChunkStateMachine();
+ private metaLoader: MetaLoader;
+ private dataLoader: DataLoader;
+ private events: Emitter = mitt();
+ private userData = new Map();
+ private generation = 0;
+ private disposed = false;
+ private onDispose?: () => void;
+ private pendingMeta: Promise | null = null;
+ private pendingData: Promise | null = null;
+
+ constructor(init: ChunkInit) {
+ this.metaLoader = init.metaLoader;
+ this.dataLoader = init.dataLoader;
+ this.onDispose = init.onDispose;
+ this.machine.subscribe(this.onStateUpdated);
+ }
+
+ getUserData(key: string) {
+ return this.userData.get(key);
+ }
+
+ setUserData(key: string, value: unknown) {
+ return this.userData.set(key, value);
+ }
+
+ addEventListener(event: keyof ChunkEvents, callback: (arg?: any) => void) {
+ this.events.on(event, callback);
+ return () => this.removeEventListener(event, callback);
+ }
+
+ removeEventListener(event: keyof ChunkEvents, callback: (arg?: any) => void) {
+ this.events.off(event, callback);
+ }
+
+ watchForState(state: ChunkState, callback: () => void) {
+ return this.machine.subscribe((event) => {
+ if (state === event.state) callback();
+ });
+ }
+
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.stopLoad();
+ this.events.emit('disposed');
+ this.machine.unsubscribe(this.onStateUpdated);
+ this.events.all.clear();
+ this.userData.clear();
+ this.metaLoader.dispose?.();
+ this.dataLoader.dispose?.();
+ this.onDispose?.();
+ this.onDispose = undefined;
+ }
+
+ get isDisposed() {
+ return this.disposed;
+ }
+
+ get state() {
+ return this.machine.state;
+ }
+
+ get metadata() {
+ return this.metaLoader.meta;
+ }
+
+ get fileLayout() {
+ return this.metaLoader.fileLayout;
+ }
+
+ get ultrasoundRegions() {
+ return this.metaLoader.ultrasoundRegions;
+ }
+
+ get dataBlob() {
+ return this.dataLoader.data;
+ }
+
+ loadMeta() {
+ if (this.disposed) return Promise.reject(cancelled());
+ if (this.pendingMeta) return this.pendingMeta;
+ if (this.state !== ChunkState.Init) return Promise.resolve();
+ const pending = this.load('doneMeta', TransitionEvent.LoadMeta).finally(
+ () => {
+ if (this.pendingMeta === pending) this.pendingMeta = null;
+ }
+ );
+ this.pendingMeta = pending;
+ return pending;
+ }
+
+ stopLoad() {
+ this.generation += 1;
+ this.machine.send(TransitionEvent.Cancel, { error: cancelled() });
+ this.pendingMeta = null;
+ this.pendingData = null;
+ }
+
+ loadData() {
+ if (this.disposed) return Promise.reject(cancelled());
+ if (this.pendingData) return this.pendingData;
+ if (this.state !== ChunkState.MetaOnly) return Promise.resolve();
+ const pending = this.load('doneData', TransitionEvent.LoadData).finally(
+ () => {
+ if (this.pendingData === pending) this.pendingData = null;
+ }
+ );
+ this.pendingData = pending;
+ return pending;
+ }
+
+ private load(done: 'doneMeta' | 'doneData', transition: TransitionEvent) {
+ return new Promise((resolve, reject) => {
+ const cleanup = () => {
+ this.events.off(done, onDone);
+ this.events.off('error', onError);
+ };
+ const onDone = () => {
+ cleanup();
+ resolve();
+ };
+ const onError = (error: unknown) => {
+ cleanup();
+ reject(error);
+ };
+ this.events.on(done, onDone);
+ this.events.on('error', onError);
+ this.machine.send(transition);
+ });
+ }
+
+ private onStateUpdated = async (
+ event: UpdateEvent
+ ) => {
+ const generation = this.generation;
+ const complete = (transition: TransitionEvent, data?: unknown) => {
+ if (generation === this.generation && !this.disposed)
+ this.machine.send(transition, data);
+ };
+ if (leaves(event, ChunkState.MetaLoading)) this.metaLoader.stop();
+ else if (leaves(event, ChunkState.DataLoading)) this.dataLoader.stop();
+ if (event.data?.error) this.events.emit('error', event.data.error);
+ if (event.event === TransitionEvent.MetaLoaded)
+ this.events.emit('doneMeta');
+ else if (event.event === TransitionEvent.DataLoaded)
+ this.events.emit('doneData');
+ try {
+ if (enters(event, ChunkState.MetaLoading)) {
+ await this.metaLoader.load();
+ complete(TransitionEvent.MetaLoaded);
+ } else if (enters(event, ChunkState.DataLoading)) {
+ await this.dataLoader.load();
+ complete(TransitionEvent.DataLoaded);
+ }
+ } catch (error) {
+ complete(TransitionEvent.Cancel, { error });
+ }
+ };
+}
+
+export function waitForChunkState(chunk: Chunk, state: ChunkState) {
+ return new Promise((resolve, reject) => {
+ if (chunk.isDisposed) {
+ reject(cancelled());
+ return;
+ }
+ if (chunk.state === state) {
+ resolve(chunk);
+ return;
+ }
+ const cleanup = () => {
+ stop();
+ stopError();
+ stopDisposed();
+ };
+ const stop = chunk.watchForState(state, () => {
+ cleanup();
+ resolve(chunk);
+ });
+ const stopError = chunk.addEventListener('error', (error) => {
+ cleanup();
+ reject(error);
+ });
+ const stopDisposed = chunk.addEventListener('disposed', () => {
+ cleanup();
+ reject(cancelled());
+ });
+ });
+}
diff --git a/src/core/streaming/chunkStateMachine.ts b/packages/dicom/core/streaming/chunkStateMachine.ts
similarity index 94%
rename from src/core/streaming/chunkStateMachine.ts
rename to packages/dicom/core/streaming/chunkStateMachine.ts
index 638f0292f..041c24cce 100644
--- a/src/core/streaming/chunkStateMachine.ts
+++ b/packages/dicom/core/streaming/chunkStateMachine.ts
@@ -1,4 +1,4 @@
-import StateMachine from '@/src/core/stateMachine';
+import StateMachine from './stateMachine';
export enum ChunkState {
Init = 'Init',
diff --git a/src/core/streaming/concatStreams.ts b/packages/dicom/core/streaming/concatStreams.ts
similarity index 100%
rename from src/core/streaming/concatStreams.ts
rename to packages/dicom/core/streaming/concatStreams.ts
diff --git a/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts
new file mode 100644
index 000000000..9bca4eb76
--- /dev/null
+++ b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts
@@ -0,0 +1,28 @@
+import { DicomMetaLoader } from '../dicomMetaLoader';
+import { RequestPool } from '../../requestPool';
+import { CachedStreamFetcher } from '../../cachedStreamFetcher';
+import { describe, it, expect } from 'vitest';
+import { syntheticSlice, bytesFetcher } from '@/tests/dicomSourceFixtures';
+import { getDatasetTag } from '../../../metadata/datasetAccess';
+
+describe('dicomMetaLoader', () => {
+ it('loads only the header from an HTTP response', async () => {
+ const bytes = syntheticSlice({ patientName: 'DOE^JOHN' });
+ const source = bytesFetcher(bytes, 64);
+ const pool = new RequestPool(1, async () => {
+ await source.connect();
+ return new Response(source.getStream(), {
+ headers: { 'content-length': String(bytes.length) },
+ });
+ });
+ const fetcher = new CachedStreamFetcher('https://example.test/slice.dcm', {
+ fetch: pool.fetch,
+ });
+ const loader = new DicomMetaLoader(fetcher);
+ await loader.load();
+ expect(getDatasetTag(loader.meta, '00100010')).toBe('DOE^JOHN');
+ expect(fetcher.size).toBeLessThan(bytes.length);
+ loader.dispose();
+ pool.dispose();
+ });
+});
diff --git a/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts
new file mode 100644
index 000000000..7e929270f
--- /dev/null
+++ b/packages/dicom/core/streaming/dicom/__tests__/dicomMetaLoaderSource.spec.ts
@@ -0,0 +1,264 @@
+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,
+ 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));
+ }
+ );
+
+ 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('reports the file layout', async () => {
+ const bytes = syntheticSlice();
+ const loader = new DicomMetaLoader(bytesFetcher(bytes));
+
+ await loader.load();
+
+ 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(fetcher.size).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 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.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);
+ });
+
+ // 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(fetcher.size).toBeLessThan(bytes.length);
+ });
+
+ 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..7d73e9571
--- /dev/null
+++ b/packages/dicom/core/streaming/dicom/dicomMetaLoader.ts
@@ -0,0 +1,159 @@
+import { createDicomParser } from './dicomParser';
+import { StopSignal } from '../cachedStreamFetcher';
+import { Fetcher, MetaLoader } from '../types';
+import { Maybe } from '../../utils';
+import { Awaitable } from '../../utils';
+
+import {
+ readDicomDataset,
+ type DicomDataset,
+} from '../../metadata/dicomDataset';
+import type { DicomLayout } from '../../metadata/dicomLayout';
+import { concatBytes, toAscii } from '../../utils';
+import { readUltrasoundRegions, UltrasoundRegions } from './ultrasoundRegion';
+
+export type ReadDicomDatasetFunction = (
+ bytes: Uint8Array
+) => Awaitable;
+
+const MODALITY = [0x0008, 0x0060] as const;
+const PIXEL_DATA = [0x7fe0, 0x0010] as const;
+
+const isTag = (
+ tag: readonly [number, number],
+ group: number,
+ element: number
+) => group === tag[0] && element === tag[1];
+
+/**
+ * Radiotherapy objects are dropped on their modality alone, so nothing past
+ * that element is read: a structure set carries megabytes of contours.
+ */
+const isRadiotherapy = (modality: string | undefined) =>
+ modality?.startsWith('RT') ?? false;
+
+export class DicomMetaLoader implements MetaLoader {
+ private tags: Maybe;
+ private generation = 0;
+ private source: Fetcher | null;
+ private 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;
+ }
+
+ /** How the bytes opened: with or without the Part 10 preamble and file meta. */
+ get fileLayout() {
+ return this.layout;
+ }
+
+ private async readHeader(
+ stream: ReadableStream,
+ parse: ReturnType
+ ) {
+ const generation = this.generation;
+ let pixelDataIdx = -1;
+ // The bytes read so far live here rather than in the fetcher: the header
+ // is parsed once and then done with, and a fetcher over a file has no
+ // reason to keep a copy of it for the chunk's lifetime.
+ const received: Uint8Array[] = [];
+
+ // Read a chunk at a time and cancel the moment the header ends: a stream
+ // that reads ahead pulls pixel data this loader never uses.
+ const reader = stream.getReader();
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ received.push(value);
+ const result = parse(value);
+ if (result.done) {
+ pixelDataIdx = result.value.position;
+ break;
+ }
+ }
+ } catch (err) {
+ if (err !== StopSignal) throw err;
+ // Stopped from outside before the header ended, so what arrived is not
+ // a header and must not be read as one.
+ throw new Error('The DICOM header read was stopped before it finished');
+ } finally {
+ // Not awaited, so the cancel lands before the stream refills its queue.
+ reader.cancel().catch(() => {});
+ if (generation === this.generation) this.fetcher.close();
+ }
+ return { received, pixelDataIdx };
+ }
+
+ async load() {
+ if (this.tags) return;
+
+ const generation = this.generation;
+ await this.fetcher.connect();
+ if (generation !== this.generation)
+ throw new DOMException('DICOM read cancelled', 'AbortError');
+ const stream = this.fetcher.getStream();
+ let modality: string | undefined;
+
+ const parse = createDicomParser({
+ stopAtElement: (group, element) =>
+ isTag(PIXEL_DATA, group, element) ||
+ (this.imageImportPolicy === 'skip-radiotherapy' &&
+ isRadiotherapy(modality)),
+ onLayout: (layout) => {
+ this.layout = layout;
+ },
+ onDataElement: (el) => {
+ if (
+ isTag(MODALITY, el.group, el.element) &&
+ el.data instanceof Uint8Array
+ )
+ modality = toAscii(el.data).trim();
+ },
+ });
+
+ const { received, pixelDataIdx } = await this.readHeader(stream, parse);
+
+ // An object with no Pixel Data, such as a structured report, is header to
+ // its last byte.
+ const header = concatBytes(
+ received,
+ pixelDataIdx < 0 ? undefined : pixelDataIdx
+ );
+ const dataset = await this.readDataset(header);
+ if (generation !== this.generation)
+ throw new DOMException('DICOM read cancelled', 'AbortError');
+ this.tags = dataset;
+
+ if (modality === 'US') {
+ this.ultrasoundRegions = readUltrasoundRegions(dataset);
+ }
+ }
+
+ dispose() {
+ this.stop();
+ this.source = null;
+ }
+
+ stop() {
+ this.generation += 1;
+ this.source?.close();
+ }
+}
diff --git a/src/core/streaming/dicom/dicomParser.ts b/packages/dicom/core/streaming/dicom/dicomParser.ts
similarity index 72%
rename from src/core/streaming/dicom/dicomParser.ts
rename to packages/dicom/core/streaming/dicom/dicomParser.ts
index 51502a4d3..78a53d5a7 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];
@@ -18,12 +23,22 @@ export type ElementTagCallback = (
element: number
) => string | null;
-interface ParseOptions {
+type ParseOptions = {
peek?: boolean;
littleEndian?: boolean;
explicitVr?: boolean;
+ endPosition?: number;
onDataElement?: DataElementCallback;
-}
+};
+
+const assertReadBounds = (
+ reader: StreamingByteReader,
+ end: number | undefined,
+ length: number
+) => {
+ if (end !== undefined && reader.position + length > end)
+ throw new Error('Data element exceeds its enclosing sequence or item');
+};
// This is based on the DICOM standard as of 2023. All links may point to whatever is the current at the time of visiting.
@@ -103,13 +118,29 @@ function* readElementValue(
reader: StreamingByteReader,
vr: string,
length: number,
- { explicitVr = false, littleEndian = false } = {}
+ { explicitVr = false, littleEndian = false, endPosition }: ParseOptions = {}
): Generator {
+ assertReadBounds(
+ reader,
+ endPosition,
+ length === UndefinedLength ? 0 : length
+ );
// See examples: https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.2.html
+ // PS3.5 6.2.2: an undefined-length UN holds sequence items encoded
+ // in Implicit VR Little Endian, regardless of the enclosing syntax.
+ if (vr === 'UN' && length === UndefinedLength) {
+ return yield* readSequenceValue(reader, length, {
+ explicitVr: false,
+ littleEndian: true,
+ endPosition,
+ });
+ }
+
if (vr === 'SQ') {
return yield* readSequenceValue(reader, length, {
explicitVr,
littleEndian,
+ endPosition,
});
}
@@ -129,8 +160,12 @@ function* readElementValue(
* https://dicom.nema.org/medical/dicom/current/output/chtml/part05/chapter_7.html#sect_7.1.1
* @param {*} reader
*/
-function* readDataElement(reader: StreamingByteReader, opts?: ParseOptions) {
- const { littleEndian = false, explicitVr = false } = opts ?? {};
+function* readDataElement(
+ reader: StreamingByteReader,
+ opts: ParseOptions = {}
+) {
+ const { littleEndian = false, explicitVr = false, endPosition } = opts;
+ assertReadBounds(reader, endPosition, 8);
// read tag
const [group, element] = yield* readElementTag(reader, { littleEndian });
let length = 0;
@@ -165,6 +200,7 @@ function* readDataElement(reader: StreamingByteReader, opts?: ParseOptions) {
data = yield* readElementValue(reader, vr, length, {
littleEndian,
explicitVr,
+ endPosition,
});
} else {
length = yield* reader.readUint32({ littleEndian });
@@ -184,6 +220,7 @@ function* readDataElement(reader: StreamingByteReader, opts?: ParseOptions) {
data = yield* readElementValue(reader, vr, length, {
littleEndian,
explicitVr,
+ endPosition,
});
}
@@ -203,6 +240,7 @@ function* readDataElementsUntil(
opts: ParseOptions
) {
while (true) {
+ assertReadBounds(reader, opts.endPosition, 4);
const [group, element] = yield* peekElementTag(reader, opts);
if (untilFn(group, element)) {
return;
@@ -223,12 +261,40 @@ function* readFiniteDataElements(
opts: ParseOptions
) {
const endPosition = reader.position + byteLength;
+ assertReadBounds(reader, opts.endPosition, byteLength);
while (reader.position < endPosition) {
- const el = yield* readDataElement(reader, opts);
+ const el = yield* readDataElement(reader, { ...opts, endPosition });
opts.onDataElement?.(el);
}
}
+function* readSequenceItem(reader: StreamingByteReader, opts: ParseOptions) {
+ const itemByteLength = yield* reader.readUint32(opts);
+ const itemDataset: DataElement[] = [];
+ const itemOptions = {
+ ...opts,
+ onDataElement: (el: DataElement) => itemDataset.push(el),
+ };
+ if (itemByteLength !== UndefinedLength) {
+ yield* readFiniteDataElements(reader, itemByteLength, itemOptions);
+ } else {
+ yield* readDataElementsUntil(
+ reader,
+ equalsToTag(Tags.ItemDelimiter),
+ itemOptions
+ );
+ assertReadBounds(reader, opts.endPosition, 8);
+ yield* reader.seek(8);
+ }
+ return itemDataset;
+}
+
+function* finishSequence(reader: StreamingByteReader, end?: number) {
+ yield* reader.seek(4);
+ // The declared boundary wins over a premature delimiter in a finite SQ.
+ if (end !== undefined) yield* reader.seek(end - reader.position);
+}
+
/**
* The reader must be pointing to the start of the sequence's data, namely the first item or the sequence delimitation item.
*
@@ -238,56 +304,31 @@ function* readFiniteDataElements(
function* readSequenceValue(
reader: StreamingByteReader,
byteLength: number,
- { explicitVr = false, littleEndian = false } = {}
+ opts: ParseOptions
) {
- const items = [] as Array;
- const isUndefinedLength = byteLength === UndefinedLength;
- const endPosition = isUndefinedLength ? 0 : reader.position + byteLength;
- while (true) {
- if (!isUndefinedLength && reader.position >= endPosition) {
- return items;
- }
-
- const [group, element] = yield* readElementTag(reader, { littleEndian });
+ const items: DataElement[][] = [];
+ const endPosition =
+ byteLength === UndefinedLength ? undefined : reader.position + byteLength;
+ const itemOptions = {
+ ...opts,
+ endPosition: endPosition ?? opts.endPosition,
+ };
+ while (endPosition === undefined || reader.position < endPosition) {
+ assertReadBounds(reader, itemOptions.endPosition, 8);
+ const [group, element] = yield* readElementTag(reader, opts);
// Item tag
// https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.2.html#table_7.5-3
if (equalsToTag(Tags.Item)(group, element)) {
- const itemDataset: DataElement[] = [];
- const itemByteLength = yield* reader.readUint32({ littleEndian });
-
- // length rules: https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.html, heading 7.5.1
- if (itemByteLength !== UndefinedLength) {
- yield* readFiniteDataElements(reader, itemByteLength, {
- explicitVr,
- littleEndian,
- onDataElement(el) {
- itemDataset.push(el);
- },
- });
- } else {
- // read until item delimitation tag
- yield* readDataElementsUntil(reader, equalsToTag(Tags.ItemDelimiter), {
- explicitVr,
- littleEndian,
- onDataElement(el) {
- itemDataset.push(el);
- },
- });
- // skip the item delimitation tag and the 4-byte zero length
- yield* reader.seek(4 + 4);
- }
-
- items.push(itemDataset);
+ items.push(yield* readSequenceItem(reader, itemOptions));
continue;
}
// Sequence Delimitation Item tag
// "Otherwise, the Value Field has an Undefined Length and a Sequence Delimitation Item marks the end of the Value Field."
// https://dicom.nema.org/medical/dicom/current/output/chtml/part05/chapter_7.html#sect_7.1.2
- if (isUndefinedLength && group === 0xfffe && element === 0xe0dd) {
- // skip the 4-byte zero length
- yield* reader.seek(4);
+ if (group === 0xfffe && element === 0xe0dd) {
+ yield* finishSequence(reader, endPosition);
return items;
}
@@ -297,6 +338,7 @@ function* readSequenceValue(
)},${element.toString(16)})`
);
}
+ return items;
}
// Per Part 10: "File Meta Information shall be encoded using the Explicit VR Little Endian Transfer Syntax"
@@ -352,25 +394,27 @@ function* readFieldMetaInfo(
export type ReadDicomOptions = {
stopAtElement?(group: number, element: number): boolean;
onDataElement?: DataElementCallback;
+ /** Called once the opening bytes say how the file is laid out. */
+ onLayout?(layout: DicomLayout): void;
};
export function* readDicomUntilPixelData(opts?: ReadDicomOptions) {
const reader = new StreamingByteReader();
- // preamble
- yield* reader.seek(128);
-
- // prefix
- const prefix = yield* reader.readAscii(4);
- if (prefix !== 'DICM') {
- throw new Error('Not DICOM');
- }
-
- const info = yield* readFieldMetaInfo(reader, {
- onDataElement: opts?.onDataElement,
- });
- const explicitVr = info.transferSyntaxUid !== ImplicitTransferSyntaxUID;
- const littleEndian = info.transferSyntaxUid !== ExplicitVRBigEndianUID;
+ // Peeked rather than read: a file without the Part 10 preamble has its
+ // first element at byte 0.
+ const head = yield* reader.read(PART_10_HEADER_LENGTH, { peek: true });
+ const layout = sniffDicomLayout(head);
+ opts?.onLayout?.(layout);
+ if (layout.preamble) yield* reader.seek(PART_10_HEADER_LENGTH);
+
+ const transferSyntaxUid = layout.fileMeta
+ ? (yield* readFieldMetaInfo(reader, {
+ onDataElement: opts?.onDataElement,
+ })).transferSyntaxUid
+ : layout.transferSyntaxUid;
+ const explicitVr = transferSyntaxUid !== ImplicitTransferSyntaxUID;
+ const littleEndian = transferSyntaxUid !== ExplicitVRBigEndianUID;
yield* readDataElementsUntil(
reader,
diff --git a/src/core/streaming/dicom/ultrasoundRegion.ts b/packages/dicom/core/streaming/dicom/ultrasoundRegion.ts
similarity index 79%
rename from src/core/streaming/dicom/ultrasoundRegion.ts
rename to packages/dicom/core/streaming/dicom/ultrasoundRegion.ts
index 3fab3a12b..c83984775 100644
--- a/src/core/streaming/dicom/ultrasoundRegion.ts
+++ b/packages/dicom/core/streaming/dicom/ultrasoundRegion.ts
@@ -1,8 +1,9 @@
-import {
- createDicomParser,
- DataElement,
-} from '@/src/core/streaming/dicom/dicomParser';
-import { Tags, tagToGroupElement } from '@/src/core/dicomTags';
+import { getDatasetTag } from '../../metadata/datasetAccess';
+import { sequenceItems } from '../../metadata/functionalGroups';
+import type { DicomDataset } from '../../metadata/dicomDataset';
+import { trimDicomPadding } from '../../utils';
+import { createDicomParser, type DataElement } from './dicomParser';
+import { Tags, tagToGroupElement } from '../../metadata/dicomTags';
// DICOM unit codes for PhysicalUnitsXDirection / YDirection.
// See DICOM PS3.3 C.8.5.5.1.15. The only spatial spacing code defined for
@@ -104,10 +105,30 @@ export function decodeUltrasoundRegion(
};
}
-/**
- * Parses a DICOM blob and returns the first ultrasound region plus the
- * total region count, if a SequenceOfUltrasoundRegions is present.
- */
+/** The structured header supplies regions in either transfer syntax. */
+export function readUltrasoundRegions(
+ metadata: DicomDataset
+): UltrasoundRegions {
+ const items = sequenceItems(
+ metadata,
+ Tags.SequenceOfUltrasoundRegions.replace('|', '')
+ );
+ const value = (tag: string) => {
+ const text = trimDicomPadding(getDatasetTag(items[0], tag) ?? '');
+ return text ? Number(text) : NaN;
+ };
+ const region = {
+ physicalDeltaX: value(Tags.PhysicalDeltaX),
+ physicalDeltaY: value(Tags.PhysicalDeltaY),
+ physicalUnitsXDirection: value(Tags.PhysicalUnitsXDirection),
+ physicalUnitsYDirection: value(Tags.PhysicalUnitsYDirection),
+ };
+ return {
+ regionCount: items.length,
+ ...(Object.values(region).every(Number.isFinite) ? { region } : {}),
+ };
+}
+
export async function parseUltrasoundRegionFromBlob(
blob: Blob
): Promise {
diff --git a/src/core/streaming/httpCodes.ts b/packages/dicom/core/streaming/httpCodes.ts
similarity index 100%
rename from src/core/streaming/httpCodes.ts
rename to packages/dicom/core/streaming/httpCodes.ts
diff --git a/packages/dicom/core/streaming/requestPool.ts b/packages/dicom/core/streaming/requestPool.ts
new file mode 100644
index 000000000..896cd263d
--- /dev/null
+++ b/packages/dicom/core/streaming/requestPool.ts
@@ -0,0 +1,110 @@
+import { Deferred, defer } from '../utils';
+
+type FetchRequest = {
+ request: RequestInfo | URL;
+ init?: RequestInit;
+ deferred: Deferred;
+ controller: AbortController;
+ cleanup: () => void;
+};
+
+/** A FIFO request pool. Disposal aborts active fetches and rejects queued callers. */
+export class RequestPool {
+ private queue: FetchRequest[] = [];
+ private inflight = new Set();
+ private disposed = false;
+ private lifetime = new AbortController();
+
+ constructor(
+ public readonly poolSize = 24,
+ private fetchFn: typeof fetch = (input, init) =>
+ globalThis.fetch(input, init)
+ ) {
+ if (!Number.isInteger(poolSize) || poolSize < 1)
+ throw new Error('Request concurrency must be a positive integer');
+ }
+
+ get activeConnections() {
+ return this.inflight.size;
+ }
+
+ fetch = (request: RequestInfo | URL, init?: RequestInit) => {
+ if (this.disposed)
+ return Promise.reject(
+ new DOMException('Request pool disposed', 'AbortError')
+ );
+ const deferred = defer();
+ const controller = new AbortController();
+ const sourceSignal =
+ init?.signal ?? (request instanceof Request ? request.signal : undefined);
+ const abort = () => {
+ controller.abort(sourceSignal?.reason);
+ deferred.reject(sourceSignal?.reason);
+ this.queue = this.queue.filter((entry) => entry !== queued);
+ };
+ const onDispose = () => controller.abort(this.lifetime.signal.reason);
+ const queued: FetchRequest = {
+ request,
+ init,
+ deferred,
+ controller,
+ cleanup: () => {
+ sourceSignal?.removeEventListener('abort', abort);
+ this.lifetime.signal.removeEventListener('abort', onDispose);
+ },
+ };
+ if (sourceSignal?.aborted) {
+ deferred.reject(sourceSignal.reason);
+ return deferred.promise;
+ }
+ sourceSignal?.addEventListener('abort', abort, { once: true });
+ this.lifetime.signal.addEventListener('abort', onDispose, { once: true });
+ controller.signal.addEventListener('abort', queued.cleanup, { once: true });
+ this.queue.push(queued);
+ this.processQueue();
+ return deferred.promise;
+ };
+
+ dispose() {
+ this.disposed = true;
+ const error = new DOMException('Request pool disposed', 'AbortError');
+ this.lifetime.abort(error);
+ [...this.queue, ...this.inflight].forEach((request) => {
+ request.controller.abort(error);
+ request.deferred.reject(error);
+ request.cleanup();
+ });
+ this.queue = [];
+ }
+
+ private processQueue() {
+ while (
+ !this.disposed &&
+ this.inflight.size < this.poolSize &&
+ this.queue.length
+ ) {
+ this.startRequest(this.queue.shift()!);
+ }
+ }
+
+ private async startRequest(request: FetchRequest) {
+ this.inflight.add(request);
+ try {
+ const fetchFn = this.fetchFn;
+ const response = await fetchFn(request.request, {
+ ...request.init,
+ signal: request.controller.signal,
+ });
+ if (request.controller.signal.aborted) {
+ response.body?.cancel().catch(() => {});
+ request.deferred.reject(request.controller.signal.reason);
+ } else request.deferred.resolve(response);
+ } catch (error) {
+ request.cleanup();
+ request.deferred.reject(error);
+ } finally {
+ this.inflight.delete(request);
+ this.processQueue();
+ }
+ }
+}
diff --git a/src/core/stateMachine.ts b/packages/dicom/core/streaming/stateMachine.ts
similarity index 100%
rename from src/core/stateMachine.ts
rename to packages/dicom/core/streaming/stateMachine.ts
diff --git a/src/core/streaming/streamingByteReader.ts b/packages/dicom/core/streaming/streamingByteReader.ts
similarity index 96%
rename from src/core/streaming/streamingByteReader.ts
rename to packages/dicom/core/streaming/streamingByteReader.ts
index 902fd9f1c..41d606ad0 100644
--- a/src/core/streaming/streamingByteReader.ts
+++ b/packages/dicom/core/streaming/streamingByteReader.ts
@@ -1,5 +1,5 @@
-import ByteDeque from '@/src/core/streaming/byteDeque.js';
-import { toAscii } from '@/src/utils';
+import ByteDeque from './byteDeque';
+import { toAscii } from '../utils';
/**
* StreamingByteReader
diff --git a/packages/dicom/core/streaming/types.ts b/packages/dicom/core/streaming/types.ts
new file mode 100644
index 000000000..afd63720c
--- /dev/null
+++ b/packages/dicom/core/streaming/types.ts
@@ -0,0 +1,53 @@
+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;
+ connected: boolean;
+ size: number;
+ contentType?: string;
+ abortSignal?: AbortSignal;
+};
diff --git a/packages/dicom/core/utils.ts b/packages/dicom/core/utils.ts
new file mode 100644
index 000000000..304ef325a
--- /dev/null
+++ b/packages/dicom/core/utils.ts
@@ -0,0 +1,77 @@
+export type Maybe = T | null | undefined;
+export type Awaitable = T | Promise;
+/** Boundary padding accepted by numeric readers, without changing the stored text. */
+export const trimDicomPadding = (value: string) =>
+ value.replace(/^[\s\0]+|[\s\0]+$/g, '');
+
+type PromiseResolveFunction = (value: T) => void;
+type PromiseRejectFunction = (reason?: any) => void;
+export type Deferred = {
+ promise: Promise;
+ resolve: PromiseResolveFunction;
+ reject: PromiseRejectFunction;
+};
+
+export function defer(): Deferred {
+ let innerResolve: PromiseResolveFunction | null = null;
+ let innerReject: PromiseRejectFunction | null = null;
+
+ const resolve = (value: T) => {
+ if (innerResolve) innerResolve(value);
+ };
+ const reject = (reason?: Error) => {
+ if (innerReject) innerReject(reason);
+ };
+
+ const promise = new Promise((res, rej) => {
+ innerResolve = res;
+ innerReject = rej;
+ });
+
+ return { promise, resolve, reject };
+}
+export function concatBytes(
+ parts: readonly Uint8Array[],
+ length = parts.reduce((total, part) => total + part.length, 0)
+) {
+ const out = new Uint8Array(length);
+ parts.reduce((offset, part) => {
+ if (offset >= length) return offset;
+ const slice = part.subarray(0, length - offset);
+ out.set(slice, offset);
+ return offset + slice.length;
+ }, 0);
+ return out;
+}
+
+export function toAscii(
+ bytes: Uint8Array | Uint8ClampedArray,
+ { ignoreNulls = false } = {}
+) {
+ const chars = [];
+ for (let i = 0; i < bytes.length; i++) {
+ if (!(ignoreNulls && bytes[i] === 0)) {
+ chars.push(String.fromCharCode(bytes[i]));
+ }
+ }
+ return chars.join('');
+}
+
+/**
+ * Wraps a generator as a coroutine.
+ * @param generator
+ * @param args
+ * @returns
+ */
+export function asCoroutine(gen: Generator) {
+ // run initial code
+ const result = gen.next();
+ if (result.done) return () => result;
+ return (value: N): IteratorResult => gen.next(value);
+}
+
+export const numberOr = (value: string | undefined, fallback: number) => {
+ const text = trimDicomPadding(value ?? '');
+ const number = Number(text);
+ return text && Number.isFinite(number) ? number : fallback;
+};
diff --git a/src/utils/__tests__/parseContentRangeHeader.spec.ts b/packages/dicom/core/utils/__tests__/parseContentRangeHeader.spec.ts
similarity index 94%
rename from src/utils/__tests__/parseContentRangeHeader.spec.ts
rename to packages/dicom/core/utils/__tests__/parseContentRangeHeader.spec.ts
index fe397107e..7f0837449 100644
--- a/src/utils/__tests__/parseContentRangeHeader.spec.ts
+++ b/packages/dicom/core/utils/__tests__/parseContentRangeHeader.spec.ts
@@ -1,4 +1,4 @@
-import { parseContentRangeHeader } from '@/src/utils/parseContentRangeHeader';
+import { parseContentRangeHeader } from '../parseContentRangeHeader';
import { describe, expect, it } from 'vitest';
describe('parseContentRangeHeader', () => {
diff --git a/src/utils/parseContentRangeHeader.ts b/packages/dicom/core/utils/parseContentRangeHeader.ts
similarity index 100%
rename from src/utils/parseContentRangeHeader.ts
rename to packages/dicom/core/utils/parseContentRangeHeader.ts
diff --git a/packages/dicom/package.json b/packages/dicom/package.json
new file mode 100644
index 000000000..601a90293
--- /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/*.d.ts",
+ "import": "./dist/core/*.js"
+ },
+ "./vtk/*": {
+ "types": "./dist/vtk/*.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..ce251206e
--- /dev/null
+++ b/packages/dicom/scripts/build.mjs
@@ -0,0 +1,95 @@
+import { execFileSync } from 'node:child_process';
+import { cp, mkdir, readFile, readdir } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import { dirname, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { build } from 'vite';
+
+const packageRoot = fileURLToPath(new URL('..', import.meta.url));
+const root = resolve(packageRoot, '../..');
+const require = createRequire(import.meta.url);
+const outDir = resolve(packageRoot, process.argv[2] ?? 'dist');
+const manifest = JSON.parse(
+ await readFile(resolve(packageRoot, 'package.json'), 'utf8')
+);
+const dependencies = Object.keys({
+ ...manifest.dependencies,
+ ...manifest.peerDependencies,
+});
+const external = (id) =>
+ dependencies.some((name) => id === name || id.startsWith(`${name}/`));
+
+async function sourceFiles(directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const files = await Promise.all(
+ entries.map((entry) => {
+ const file = resolve(directory, entry.name);
+ if (entry.isDirectory())
+ return entry.name === '__tests__' ? [] : sourceFiles(file);
+ return entry.name.endsWith('.ts') && !/\.(d|worker)\.ts$/.test(entry.name)
+ ? [file]
+ : [];
+ })
+ );
+ return files.flat();
+}
+
+const sources = (
+ await Promise.all(
+ ['core', 'vtk'].map((dir) => sourceFiles(resolve(packageRoot, dir)))
+ )
+).flat();
+
+await build({
+ configFile: false,
+ root: packageRoot,
+ publicDir: false,
+ base: './',
+ build: {
+ outDir,
+ emptyOutDir: true,
+ sourcemap: true,
+ lib: {
+ entry: Object.fromEntries(
+ sources.map((file) => [
+ relative(packageRoot, file)
+ .replaceAll('\\', '/')
+ .replace(/\.ts$/, ''),
+ file,
+ ])
+ ),
+ formats: ['es'],
+ fileName: (_format, entry) => `${entry}.js`,
+ },
+ rollupOptions: { external },
+ },
+ worker: { format: 'es' },
+});
+execFileSync(
+ process.execPath,
+ [
+ require.resolve('typescript/bin/tsc'),
+ '-p',
+ 'packages/dicom/tsconfig.build.json',
+ '--outDir',
+ outDir,
+ ],
+ { cwd: root, stdio: 'inherit' }
+);
+
+const moduleRoot = (name) => resolve(dirname(require.resolve(name)), '..');
+const assets = resolve(outDir, 'assets');
+await mkdir(resolve(assets, 'image-io'), { recursive: true });
+await cp(
+ resolve(
+ moduleRoot('itk-wasm'),
+ 'dist/pipeline/web-workers/bundles/itk-wasm-pipeline.min.worker.js'
+ ),
+ resolve(assets, 'itk-wasm-pipeline.min.worker.js')
+);
+const pipelines = resolve(moduleRoot('@itk-wasm/image-io'), 'dist/pipelines');
+for (const name of await readdir(pipelines)) {
+ if (/^gdcm-read-image\.(js|wasm|wasm\.zst)$/.test(name)) {
+ await cp(resolve(pipelines, name), resolve(assets, 'image-io', name));
+ }
+}
diff --git a/packages/dicom/scripts/check-consumers.mjs b/packages/dicom/scripts/check-consumers.mjs
new file mode 100644
index 000000000..31a0411c2
--- /dev/null
+++ b/packages/dicom/scripts/check-consumers.mjs
@@ -0,0 +1,97 @@
+import { execFileSync } from 'node:child_process';
+import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import { tmpdir } from 'node:os';
+import { resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { build } from 'vite';
+
+const root = resolve(fileURLToPath(new URL('../../..', import.meta.url)));
+const packageRoot = resolve(root, 'packages/dicom');
+const fixture = resolve(root, 'tests/fixtures/dicom-consumer');
+const require = createRequire(import.meta.url);
+
+const forbiddenModule = (id, kind) => {
+ const path = id.replaceAll('\\', '/');
+ return (
+ path.startsWith(`${root.replaceAll('\\', '/')}/src/`) ||
+ /\/node_modules\/(vue|pinia|@vue\/|@vueuse\/)/.test(path) ||
+ (kind === 'core' && path.includes('/node_modules/@kitware/vtk.js/'))
+ );
+};
+
+export async function buildConsumer(
+ kind,
+ outDir,
+ packageDist = resolve(packageRoot, 'dist')
+) {
+ await build({
+ configFile: false,
+ root: fixture,
+ publicDir: false,
+ base: './',
+ resolve: {
+ alias: {
+ '@volview/dicom': packageDist,
+ },
+ },
+ plugins: [
+ {
+ name: 'independent-dicom-consumer',
+ generateBundle() {
+ for (const id of this.getModuleIds()) {
+ if (forbiddenModule(id, kind))
+ this.error(`Unexpected ${kind} consumer dependency: ${id}`);
+ }
+ },
+ },
+ ],
+ build: {
+ outDir,
+ emptyOutDir: true,
+ rollupOptions: { input: resolve(fixture, `${kind}.html`) },
+ },
+ worker: { format: 'es' },
+ });
+ await cp(resolve(packageDist, 'assets'), resolve(outDir, 'assets'), {
+ recursive: true,
+ });
+}
+
+async function checkDeclarations(directory) {
+ const config = {
+ extends: resolve(root, 'tsconfig.json'),
+ compilerOptions: {
+ baseUrl: root,
+ types: ['node'],
+ typeRoots: [resolve(root, 'node_modules/@types')],
+ paths: {
+ '@volview/dicom/core/*': ['packages/dicom/dist/core/*'],
+ '@volview/dicom/vtk/*': ['packages/dicom/dist/vtk/*'],
+ },
+ },
+ 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..e234e9a59
--- /dev/null
+++ b/packages/dicom/vtk/VtkDicomImage.ts
@@ -0,0 +1,619 @@
+import { Chunk } from '../core/streaming/chunk';
+import { ChunkState } from '../core/streaming/chunkStateMachine';
+import { getChunkTag } from '../core/metadata/datasetAccess';
+import { type Reconstruction } from '../core/collections/reconstructVolume';
+import { type SpatialMetadata } from '../core/metadata/spatialMetadata';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import type { TypedArray } from '@kitware/vtk.js/types';
+import mitt, { Emitter } from 'mitt';
+import type { IrregularGeometryPolicy } from './allocateImageFromChunks';
+import {
+ assertSamplesRepresentable,
+ assertSingleFramePerFile,
+ assertChunkFitsSlot,
+} from './pixelValidation';
+import { prepareChunkAllocation } from './prepareChunkAllocation';
+import { preparedChunks, unchangedChunks } from './preparedChunks';
+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;
+ try {
+ this.events.emit('imageChanged', image);
+ } finally {
+ previous.delete();
+ }
+ }
+
+ getModality() {
+ return modalityOf(this.chunks);
+ }
+
+ getChunkStatuses() {
+ 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(async () => {
+ const prepared = await this.prepareChunks(ordered, axes, geometry);
+ prepared.commit();
+ });
+ 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;
+ }
+ }
+
+ /** Prepares new membership without changing the image, events or decoding. */
+ async prepareChunks(
+ chunks: Chunk[],
+ orientation: number[] | null,
+ supplied?: Reconstruction
+ ) {
+ if (this.disposed || this.sameMembership(chunks, orientation, supplied))
+ return unchangedChunks;
+ const ordered = chunks.slice();
+ const axes = orientation?.slice() ?? null;
+ const geometry = supplied ? structuredClone(supplied) : undefined;
+ await this.loadMetadata(ordered);
+ if (this.disposed) return unchangedChunks;
+ const generation = this.allocationGeneration;
+ const allocated = prepareChunkAllocation(
+ ordered,
+ axes,
+ geometry,
+ this.irregularGeometry
+ );
+ return preparedChunks(
+ () => {
+ if (generation !== this.allocationGeneration)
+ throw new Error(
+ 'The DICOM image changed after this update was prepared'
+ );
+ this.publishChunks(ordered, axes, allocated);
+ },
+ () => {
+ if (allocated.image !== this.image) allocated.image?.delete();
+ },
+ this.lifetime.signal
+ );
+ }
+
+ private publishChunks(
+ chunks: Chunk[],
+ orientation: number[] | null,
+ allocated: ReturnType
+ ) {
+ const status = chunks.map(initialChunkStatus);
+ this.unregisterChunkListeners();
+ this.allocationGeneration += 1;
+ this.chunks = chunks;
+ this.orientation = orientation;
+ this.reconstruction = allocated.reconstruction;
+ this.chunkStatus = status;
+ this.invalidateThumbnail();
+ this.segmentationMetadata = null;
+ this.onChunksUpdated();
+
+ if (allocated.image)
+ this.replaceImage(allocated.image, allocated.spatialMetadata);
+
+ this.registerChunkListeners();
+ this.processLoadedChunks();
+
+ if (allocated.image) {
+ this.updateDataRangeFromChunks();
+ }
+ allocated.warnings.forEach(([title, details]) => this.warn(title, details));
+ }
+
+ private invalidateThumbnail() {
+ this.thumbnailLifetime.abort();
+ this.thumbnailLifetime = new AbortController();
+ this.thumbnail = null;
+ }
+
+ getThumbnail(): Promise {
+ if (this.disposed) return Promise.reject(this.lifetime.signal.reason);
+ if (!this.thumbnail) {
+ const chunks = this.chunks;
+ const requested = this.readThumbnail().catch((error) => {
+ if (this.thumbnail === requested) this.thumbnail = null;
+ if (
+ !this.disposed &&
+ chunks !== this.chunks &&
+ error instanceof Error &&
+ error.name === 'AbortError'
+ )
+ return this.getThumbnail();
+ throw error;
+ });
+ this.thumbnail = requested;
+ }
+ return this.thumbnail;
+ }
+
+ private async readThumbnail() {
+ 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() {
+ 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..33f4be322
--- /dev/null
+++ b/packages/dicom/vtk/__tests__/consumer.spec.ts
@@ -0,0 +1,380 @@
+import { sliceFromChunk } from '../thumbnailSource';
+import { describe, expect, it } from 'vitest';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import { Chunk } from '../../core/streaming/chunk';
+import { type Reconstruction } from '../../core/collections/reconstructVolume';
+import { VtkDicomImage } from '../VtkDicomImage';
+import { type VtkDicomImageInit } from '../types';
+import { datasetFromTags } from '@/tests/dicomDataset';
+
+const AXIAL = [1, 0, 0, 0, 1, 0];
+
+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('keeps displayed pixels until prepared growth commits and can discard growth', async () => {
+ const chunks = await Promise.all([loadedChunk(1), loadedChunk(2)]);
+ const image = new VtkDicomImage({ readDicomImage: read });
+ const ready = completed(image);
+ await image.setChunks([chunks[0]], AXIAL);
+ await ready;
+ const original = image.getVtkImageData();
+ const replacements: number[][] = [];
+ image.addEventListener('imageChanged', (next) => {
+ replacements.push(next.getDimensions());
+ });
+
+ const abandoned = await image.prepareChunks(chunks, AXIAL);
+ expect(image.getVtkImageData()).toBe(original);
+ expect(pixels(image)).toEqual([1]);
+ expect(image.isLoaded()).toBe(true);
+ expect(replacements).toEqual([]);
+ abandoned.dispose();
+ abandoned.commit();
+ expect(original.isDeleted()).toBe(false);
+ expect(pixels(image)).toEqual([1]);
+
+ const update = await image.prepareChunks(chunks, AXIAL);
+ const grown = completed(image);
+ update.commit();
+ await grown;
+ expect(replacements).toEqual([[1, 1, 2]]);
+ expect(original.isDeleted()).toBe(true);
+ expect(pixels(image)).toEqual([1, 2]);
+ update.dispose();
+ expect(image.getVtkImageData().isDeleted()).toBe(false);
+ image.dispose();
+ });
+
+ it('does not publish prepared growth over a newer image', async () => {
+ const [first, second, third] = await Promise.all([
+ loadedChunk(1),
+ loadedChunk(2),
+ loadedChunk(3),
+ ]);
+ const image = new VtkDicomImage({ readDicomImage: read });
+ await image.setChunks([first], AXIAL);
+ const stale = await image.prepareChunks([first, second], AXIAL);
+ const ready = completed(image);
+ await image.setChunks([third], AXIAL);
+ await ready;
+ expect(() => stale.commit()).toThrow('changed after');
+ expect(pixels(image)).toEqual([3]);
+ expect(image.getVtkImageData().isDeleted()).toBe(false);
+ image.dispose();
+ });
+
+ it('uses the plan spacing and slice mapping without reconstructing it', async () => {
+ const chunks = await Promise.all([loadedChunk(1), loadedChunk(19)]);
+ const image = new VtkDicomImage({ readDicomImage: read });
+ const reconstruction: Reconstruction = {
+ kind: 'regular',
+ sliceSpacing: 9,
+ slots: [0, 2],
+ };
+ const done = completed(image);
+ await image.setChunks(chunks, AXIAL, reconstruction);
+ reconstruction.slots[1] = 0;
+ await done;
+ expect(image.getVtkImageData().getDimensions()).toEqual([1, 1, 3]);
+ expect(image.getVtkImageData().getSpacing()).toEqual([1, 1, 9]);
+ expect(pixels(image)).toEqual([1, 0, 19]);
+ image.dispose();
+ });
+
+ it('can reject irregular geometry before replacing an existing image', async () => {
+ const chunk = await loadedChunk(1);
+ const image = new VtkDicomImage({
+ readDicomImage: read,
+ irregularGeometry: 'reject',
+ });
+ const done = completed(image);
+ await image.setChunks([chunk], AXIAL);
+ await done;
+ const original = image.getVtkImageData();
+ await expect(
+ image.setChunks([chunk], AXIAL, {
+ kind: 'irregular',
+ reason: 'in-plane-shift',
+ sliceSpacing: 5,
+ slots: [0],
+ })
+ ).rejects.toThrow('in-plane-shift');
+ expect(image.getVtkImageData()).toBe(original);
+ expect(pixels(image)).toEqual([1]);
+ image.dispose();
+ });
+
+ it('rejects a nonzero first slice slot before replacing an existing image', async () => {
+ const chunk = await loadedChunk(1);
+ const chunks = await Promise.all([loadedChunk(10), loadedChunk(20)]);
+ const image = new VtkDicomImage({ readDicomImage: read });
+ const done = completed(image);
+ await image.setChunks([chunk], AXIAL);
+ await done;
+ const original = image.getVtkImageData();
+
+ await expect(
+ image.setChunks(chunks, AXIAL, {
+ kind: 'regular',
+ sliceSpacing: 10,
+ slots: [1, 2],
+ })
+ ).rejects.toThrow('slice slot');
+
+ expect(image.getVtkImageData()).toBe(original);
+ expect(original.getOrigin()).toEqual([0, 0, 1]);
+ expect(pixels(image)).toEqual([1]);
+
+ const updated = completed(image);
+ await image.setChunks(chunks, AXIAL, {
+ kind: 'regular',
+ sliceSpacing: 10,
+ slots: [0, 1],
+ });
+ await updated;
+ expect(image.getVtkImageData().getOrigin()).toEqual([0, 0, 10]);
+ expect(pixels(image)).toEqual([10, 20]);
+ image.dispose();
+ });
+
+ it('restarts decoding after cancellation and ignores the cancelled result', async () => {
+ const chunk = await loadedChunk(1);
+ const first = deferred>();
+ const second = deferred>();
+ let calls = 0;
+ const image = new VtkDicomImage({
+ readDicomImage: () => (++calls === 1 ? first.promise : second.promise),
+ });
+ const delivered: number[][] = [];
+ image.addEventListener('chunkLoad', () => delivered.push(pixels(image)));
+ await image.setChunks([chunk], AXIAL);
+ image.stopLoad();
+ image.startLoad();
+ const done = completed(image);
+ first.resolve(decoded(99));
+ second.resolve(decoded(2));
+ await done;
+ expect(delivered).toEqual([[2]]);
+ expect(pixels(image)).toEqual([2]);
+ image.dispose();
+ });
+
+ it('rejects incomplete pixel buffers and retries without reallocating', async () => {
+ const chunk = await loadedChunk(1);
+ let calls = 0;
+ const image = new VtkDicomImage({
+ readDicomImage: async () => {
+ calls += 1;
+ return calls === 1
+ ? { image: { ...decoded(1).image, data: new Uint16Array() } }
+ : decoded(3);
+ },
+ });
+ const failed = new Promise((resolve) =>
+ image.addEventListener('chunkError', ({ error }) => resolve(error))
+ );
+ await image.setChunks([chunk], AXIAL);
+ expect(String(await failed)).toContain('decoded 0 samples; expected 1');
+ const original = image.getVtkImageData();
+ const done = completed(image);
+ image.startLoad();
+ await done;
+ expect(image.getVtkImageData()).toBe(original);
+ expect(pixels(image)).toEqual([3]);
+ image.dispose();
+ });
+
+ it.each(['waiting for bytes', 'decoding pixels'])(
+ 'settles a thumbnail on disposal while %s',
+ async (phase) => {
+ const chunk = await loadedChunk(1);
+ if (phase === 'waiting for bytes') {
+ chunk.dispose();
+ }
+ const pendingDecode = deferred>();
+ const source =
+ phase === 'waiting for bytes'
+ ? new Chunk({
+ metaLoader: { meta: chunk.metadata, load() {}, stop() {} },
+ dataLoader: { data: null, load() {}, stop() {} },
+ })
+ : chunk;
+ const image = new VtkDicomImage({
+ readDicomImage: () => pendingDecode.promise,
+ });
+ await image.setChunks([source], AXIAL);
+ const thumbnail = image.getThumbnail();
+ image.dispose();
+ await expect(thumbnail).rejects.toMatchObject({ name: 'AbortError' });
+ pendingDecode.resolve(decoded(1));
+ }
+ );
+
+ it('deletes segmentation images returned after disposal', async () => {
+ const chunk = await loadedChunk(1, 'SEG');
+ const result = deferred<{ image: vtkImageData; metadata: unknown }>();
+ const image = new VtkDicomImage({
+ readDicomImage: read,
+ readSegmentation: () => result.promise,
+ });
+ await image.setChunks([chunk], AXIAL);
+ image.dispose();
+ const stale = vtkImageData.newInstance();
+ let signalDeleted = () => {};
+ const deleted = new Promise((resolve) => {
+ signalDeleted = resolve;
+ });
+ result.resolve({
+ image: {
+ ...stale,
+ delete() {
+ stale.delete();
+ signalDeleted();
+ },
+ },
+ metadata: null,
+ });
+ await deleted;
+ expect(stale.isDeleted()).toBe(true);
+ expect(chunk.dataBlob).toBeInstanceOf(Blob);
+ });
+});
+
+describe('consumer failure recovery', () => {
+ it.each([
+ { size: [2, 1, 1], components: 1, count: 1, error: 'does not fit' },
+ { size: [1, 1, 1], components: 2, count: 1, error: 'does not fit' },
+ { size: [1, 1, 2], components: 1, count: 2, error: 'multiple frames' },
+ ])(
+ 'rejects decoded slot shape $size with $components components',
+ async ({ size, components, count, error }) => {
+ const chunks = await Promise.all(
+ Array.from({ length: count }, (_, i) => loadedChunk(i))
+ );
+ const image = new VtkDicomImage({
+ readDicomImage: async () => ({
+ image: {
+ size,
+ data: new Uint16Array(size.reduce((n, v) => n * v, components)),
+ imageType: { components },
+ },
+ }),
+ });
+ const failed = new Promise((resolve) =>
+ image.addEventListener('chunkError', ({ error: err }) => resolve(err))
+ );
+ await image.setChunks(chunks, AXIAL);
+ expect(String(await failed)).toContain(error);
+ expect(pixels(image).every((value) => value === 0)).toBe(true);
+ image.dispose();
+ }
+ );
+
+ it('retries an idle failed middle chunk when a thumbnail is requested', async () => {
+ const metadata = (await loadedChunk(5)).metadata;
+ const data = {
+ data: null as Blob | null,
+ load() {
+ if (!this.data) {
+ this.data = new Blob(['5']);
+ throw new Error('offline');
+ }
+ },
+ stop() {},
+ };
+ const chunk = new Chunk({
+ metaLoader: { meta: metadata, load() {}, stop() {} },
+ dataLoader: data,
+ });
+ await chunk.loadMeta();
+ await expect(chunk.loadData()).rejects.toThrow('offline');
+ expect(
+ await sliceFromChunk(chunk, read, new AbortController().signal)
+ ).toMatchObject({ width: 1, height: 1 });
+ chunk.dispose();
+ });
+
+ it('fulfills a thumbnail invalidated by a replacement allocation', async () => {
+ const firstDecode = deferred>();
+ const pendingThumbnail = deferred>();
+ const thumbnailStarted = deferred();
+ let thumbnailCalls = 0;
+ const image = new VtkDicomImage({
+ readDicomImage: async (file) => {
+ if (file.name === 'thumbnail.dcm') {
+ if (++thumbnailCalls === 1) {
+ thumbnailStarted.resolve();
+ return pendingThumbnail.promise;
+ }
+ return decoded(2);
+ }
+ return firstDecode.promise;
+ },
+ encodeThumbnail: () => 'current-thumbnail',
+ });
+ const a = await loadedChunk(1);
+ const b = await loadedChunk(2);
+ await image.setChunks([a], AXIAL);
+ const thumbnail = image.getThumbnail();
+ await thumbnailStarted.promise;
+ await image.setChunks([a, b], AXIAL);
+ await expect(thumbnail).resolves.toBe('current-thumbnail');
+ image.dispose();
+ firstDecode.resolve(decoded(1));
+ pendingThumbnail.resolve(decoded(1));
+ });
+});
diff --git a/packages/dicom/vtk/__tests__/functionalGroups.spec.ts b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts
new file mode 100644
index 000000000..7b6781376
--- /dev/null
+++ b/packages/dicom/vtk/__tests__/functionalGroups.spec.ts
@@ -0,0 +1,565 @@
+import { describe, expect, it } from 'vitest';
+import type { DicomDataset } from '../../core/metadata/dicomDataset';
+import { Chunk } from '../../core/streaming/chunk';
+import {
+ readGeometryFacts,
+ readInstanceFacts,
+} from '../../core/collections/instanceFacts';
+import {
+ planDicomCollections,
+ IRREGULAR_VOLUME_WARNING,
+ REPEATED_POSITIONS_WARNING,
+ UNREADABLE_FRAME_GEOMETRY_WARNING,
+} from '../../core/collections/planDicomCollections';
+import { VtkDicomImage } from '../VtkDicomImage';
+import { getFrameTag } from '../../core/metadata/functionalGroups';
+import { allocateImageFromChunks } from '../allocateImageFromChunks';
+import { datasetFromTags } from '../../../../tests/dicomDataset';
+import { createDicomCollectionRegistry } from '../../core/collections/collectionRegistry';
+
+const tags = (values: Record) =>
+ datasetFromTags(Object.entries(values));
+
+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.each([1, -1])(
+ 'preserves stored frame direction %s through registration and image allocation',
+ async (direction) => {
+ const positions = [0, 1, 2].map((index) => 10 + direction * index * 3);
+ const chunk = await loadedMetadata(
+ enhanced(
+ ctShared(),
+ positions.map((z) => positioned(`1\\2\\${z}`))
+ )
+ );
+ const registry = createDicomCollectionRegistry();
+ const { updates } = await registry.register([chunk]);
+ const { members, collection } = updates[0];
+ const volume = new VtkDicomImage({
+ readDicomImage: async () => ({
+ image: {
+ size: [2, 2, 3],
+ data: new Int32Array(12),
+ imageType: { components: 1 },
+ },
+ }),
+ });
+ try {
+ await volume.setChunks(
+ members,
+ collection.orientation,
+ collection.reconstruction
+ );
+ const image = volume.getVtkImageData();
+ expect(image.getSpacing()[2]).toBe(3);
+ positions.forEach((z, index) =>
+ expect(image.indexToWorld([0, 0, index])).toEqual([1, 2, z])
+ );
+ } finally {
+ volume.dispose();
+ chunk.dispose();
+ }
+ }
+ );
+
+ it('rejects shared-plane temporal frames under strict geometry validation', () => {
+ const shared = ctShared();
+ shared.elements.push(
+ sequence('00209113', [tags({ '00200032': '10\\20\\30' })])
+ );
+ const metadata = enhanced(
+ shared,
+ [1, 2].map((time) =>
+ groups([sequence('00209111', [tags({ '00209128': String(time) })])])
+ )
+ );
+ const collection = collectionOf(metadata);
+ expect(collection.reconstruction).toMatchObject({
+ kind: 'irregular',
+ slots: [0],
+ });
+ expect(collection.warnings).toContain(UNREADABLE_FRAME_GEOMETRY_WARNING);
+ expect(() =>
+ allocateImageFromChunks(
+ [{ metadata } as Chunk],
+ collection.orientation,
+ collection.reconstruction,
+ 'reject'
+ )
+ ).toThrow('irregular DICOM geometry');
+ });
+
+ it('keeps an unreadable frame geometry warning for a lone multiframe source', () => {
+ const metadata = enhanced(
+ ctShared(),
+ [1, 2].map((index) =>
+ groups([sequence('00209111', [tags({ '00209057': String(index) })])])
+ )
+ );
+ const collection = collectionOf(metadata);
+ expect(collection.reconstruction).toMatchObject({
+ kind: 'irregular',
+ reason: 'unreadable-geometry',
+ slots: [0],
+ });
+ expect(collection.warnings).toContain(UNREADABLE_FRAME_GEOMETRY_WARNING);
+ });
+
+ it.each([
+ [[0, 1, 3], 'uneven-slice-spacing', IRREGULAR_VOLUME_WARNING],
+ [[0, 0, 2], 'repeated-position', REPEATED_POSITIONS_WARNING],
+ ] as const)(
+ 'reports intra-instance geometry %s while retaining one source slot',
+ async (positions, reason, warning) => {
+ const metadata = enhanced(
+ ctShared(),
+ positions.map((z) => positioned(`0\\0\\${z}`))
+ );
+ const collection = collectionOf(metadata);
+ expect(collection.reconstruction).toMatchObject({
+ kind: 'irregular',
+ reason,
+ slots: [0],
+ });
+ expect(collection.warnings).toContain(warning);
+ const volume = new VtkDicomImage({
+ readDicomImage: async () => ({
+ image: {
+ size: [2, 2, 3],
+ data: new Int32Array(12),
+ imageType: { components: 1 },
+ },
+ }),
+ });
+ const done = completed(volume);
+ await volume.setChunks(
+ [await loadedMetadata(metadata)],
+ collection.orientation,
+ {
+ kind: 'regular',
+ sliceSpacing: null,
+ slots: [0],
+ }
+ );
+ await done;
+ expect(volume.getReconstruction()).toEqual(collection.reconstruction);
+ expect(volume.getVtkImageData().getDimensions()).toEqual([2, 2, 3]);
+ volume.dispose();
+ }
+ );
+});
+
+function nmMetadata(overrides: Record = {}) {
+ const metadata = tags({
+ '00080016': '1.2.840.10008.5.1.4.1.1.20',
+ '00080008': 'ORIGINAL\\PRIMARY\\RECON TOMO\\EMISSION',
+ '00280010': '2',
+ '00280011': '2',
+ '00280008': '3',
+ '00280101': '16',
+ '00280030': '4.7951998710632\\4.7951998710632',
+ '00180088': '-4.7951998710632',
+ '00540021': '1',
+ '00540081': '3',
+ '00540080': '1\\2\\3',
+ ...overrides,
+ });
+ metadata.elements.push(
+ sequence('00540022', [
+ tags({
+ '00200032': '4\\5\\6',
+ '00200037': '1\\0\\0\\0\\1\\0',
+ }),
+ ])
+ );
+ metadata.elements.push({
+ tag: '00280009',
+ group: 0x0028,
+ element: 0x0009,
+ vr: 'AT',
+ values: {
+ kind: 'attributeTag',
+ values: [{ group: 0x0054, element: 0x0080 }],
+ },
+ });
+ return metadata;
+}
+
+describe('reconstructed NM spatial volumes', () => {
+ it.each([-1, 1])(
+ 'uses the detector plane and signed NM slice progression %s',
+ (sign) => {
+ const image = allocate(
+ nmMetadata({ '00180088': String(sign * 4.7951998710632) }),
+ 'reject'
+ );
+ expect(image.getSpacing()).toEqual([
+ 4.7951998710632, 4.7951998710632, 4.7951998710632,
+ ]);
+ expect(image.getOrigin()).toEqual([4, 5, 6]);
+ expect(image.indexToWorld([0, 0, 2])[2]).toBeCloseTo(
+ 6 + sign * 2 * 4.7951998710632,
+ 10
+ );
+ image.delete();
+ }
+ );
+
+ it('retains one NM detector plane when Type 2 slice spacing is unknown', () => {
+ const metadata = nmMetadata({
+ '00280008': '1',
+ '00540081': '1',
+ '00540080': '1',
+ '00180088': '',
+ });
+ const image = allocate(metadata, 'reject');
+ expect(image.getOrigin()).toEqual([4, 5, 6]);
+ expect(image.getSpacing()).toEqual([4.7951998710632, 4.7951998710632, 1]);
+ expect(image.getDirection()).toEqual([1, 0, 0, 0, 1, 0, 0, 0, 1]);
+ image.delete();
+ });
+
+ it('rejects a gated acquisition instead of treating time slots as additional slices', () => {
+ const metadata = nmMetadata({
+ '00080008': 'ORIGINAL\\PRIMARY\\RECON GATED TOMO\\EMISSION',
+ '00280008': '264',
+ '00540081': '33',
+ '00540071': '8',
+ });
+ expect(() => allocate(metadata)).toThrow(
+ 'Unsupported NM volume with multiple spatial/time dimensions'
+ );
+ });
+
+ it.each(['1\\3\\2', '1\\2\\2', '1\\2'])(
+ 'rejects nonsequential NM frame ordering %s',
+ (indices) => {
+ expect(() => allocate(nmMetadata({ '00540080': indices }))).toThrow(
+ 'slice indices'
+ );
+ }
+ );
+
+ it('rejects hidden nonspatial indices even when the frame count matches the slice count', () => {
+ expect(() => allocate(nmMetadata({ '00540070': '1\\1\\2' }))).toThrow(
+ 'multiple spatial/time dimensions'
+ );
+ });
+
+ it('does not infer a spatial volume from multiple detectors or unrelated nested coordinates', () => {
+ expect(() => allocate(nmMetadata({ '00540021': '2' }))).toThrow(
+ 'detectors'
+ );
+ const metadata = nmMetadata();
+ metadata.elements = metadata.elements.filter(
+ (element) => element.tag !== '00540022'
+ );
+ metadata.elements.push(
+ sequence('00081140', [
+ tags({
+ '00200032': '1\\2\\3',
+ '00200037': '1\\0\\0\\0\\1\\0',
+ }),
+ ])
+ );
+ expect(readGeometryFacts(metadata).position).toBeNull();
+ expect(() => allocate(metadata)).toThrow('detectors');
+ });
+});
+
+it('does not report normal segment-major SEG frames as an irregular volume', () => {
+ const metadata = enhanced(
+ ctShared(),
+ [0, 1, 0, 1].map((z) => positioned('0\\0\\' + z)),
+ { '00080060': 'SEG' }
+ );
+ const plan = planDicomCollections({
+ seriesKey: 'seg',
+ instances: [readInstanceFacts(metadata)],
+ });
+ expect(plan.collections[0].warnings).toEqual([]);
+});
+
+it('reports a missing NM NumberOfSlices without claiming temporal dimensions', () => {
+ const metadata = nmMetadata();
+ metadata.elements = metadata.elements.filter((el) => el.tag !== '00540081');
+ expect(() => allocate(metadata)).toThrow('without NumberOfSlices');
+});
diff --git a/packages/dicom/vtk/allocateImageFromChunks.ts b/packages/dicom/vtk/allocateImageFromChunks.ts
new file mode 100644
index 000000000..97d752369
--- /dev/null
+++ b/packages/dicom/vtk/allocateImageFromChunks.ts
@@ -0,0 +1,413 @@
+import { Chunk } from '../core/streaming/chunk';
+import { DicomDataset } from '../core/metadata/dicomDataset';
+import { getDatasetTag, getChunkTag } from '../core/metadata/datasetAccess';
+import {
+ readGeometryFacts,
+ sliceNormalOf,
+} from '../core/collections/instanceFacts';
+import { type Reconstruction } from '../core/collections/reconstructVolume';
+import { multiframeGeometry } from '../core/collections/multiframeGeometry';
+import { readChunkFacts } from '../core/collections/chunkFacts';
+import {
+ declaredSpacing,
+ physicalSpacing,
+ readPixelSpacing,
+ type SpatialMetadata,
+} from '../core/metadata/spatialMetadata';
+import { getFrameTag } from '../core/metadata/functionalGroups';
+import { numberOr, trimDicomPadding } from '../core/utils';
+import { nmGeometry } from './nmGeometry';
+import { readModalityRescale } from '../core/metadata/modalityRescale';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import { Vector3 } from '@kitware/vtk.js/types';
+import { mat3 } from 'gl-matrix';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+
+const SpacingBetweenSlicesTag = '00180088';
+const RowsTag = '00280010';
+const ColumnsTag = '00280011';
+const BitsStoredTag = '00280101';
+const PixelRepresentationTag = '00280103';
+const SamplesPerPixelTag = '00280002';
+const NumberOfFrames = '00280008';
+
+function isPositiveFiniteNumber(value: number) {
+ return Number.isFinite(value) && value > 0;
+}
+
+function getBitStorageSize(num: number, signed: boolean) {
+ const addSignedBit = signed ? 1 : 0;
+ const val = num < 0 ? -num : num + 1; // range shift for log2
+ const nbits = Math.ceil(Math.log2(val) + addSignedBit);
+ // round up to a word size
+ return 2 ** Math.ceil(Math.log2(nbits));
+}
+
+type TypedArrayConstructor =
+ | typeof Int8Array
+ | typeof Uint8Array
+ | typeof Int16Array
+ | typeof Uint16Array
+ | typeof Int32Array
+ | typeof Uint32Array;
+
+const TYPED_ARRAY_VALUE_RANGES = new Map<
+ TypedArrayConstructor,
+ { min: number; max: number }
+>([
+ [Int8Array, { min: -(2 ** 7), max: 2 ** 7 - 1 }],
+ [Uint8Array, { min: 0, max: 2 ** 8 - 1 }],
+ [Int16Array, { min: -(2 ** 15), max: 2 ** 15 - 1 }],
+ [Uint16Array, { min: 0, max: 2 ** 16 - 1 }],
+ [Int32Array, { min: -(2 ** 31), max: 2 ** 31 - 1 }],
+ [Uint32Array, { min: 0, max: 2 ** 32 - 1 }],
+]);
+
+/**
+ * The values a buffer of the given element type can hold without wrapping.
+ * Undefined for element types the allocator never produces, floats included.
+ */
+export function getTypedArrayValueRange(ctor: unknown) {
+ return TYPED_ARRAY_VALUE_RANGES.get(ctor as TypedArrayConstructor);
+}
+
+/**
+ * The values `buffer` can hold without wrapping, or undefined if its element
+ * type has no fixed integer range to enforce.
+ */
+export function getBufferValueRange(buffer: ArrayBufferView) {
+ return getTypedArrayValueRange(buffer.constructor);
+}
+
+/**
+ * Whether a decoded value range is representable in `buffer`'s element type.
+ * A buffer with no enforceable range accepts everything.
+ */
+export function valuesFitBuffer(
+ range: { min: number; max: number },
+ buffer: ArrayBufferView
+) {
+ const bufferRange = getBufferValueRange(buffer);
+ if (!bufferRange) return true;
+ return range.min >= bufferRange.min && range.max <= bufferRange.max;
+}
+
+/**
+ * Whether every sample is a whole number, which an integer-element buffer
+ * needs. Integer typed arrays hold nothing else, so only float or plain
+ * arrays are scanned. A buffer with no integer range accepts everything.
+ */
+export function samplesAreIntegral(
+ values: ArrayLike,
+ buffer: ArrayBufferView
+) {
+ if (!getBufferValueRange(buffer)) return true;
+ if (
+ ArrayBuffer.isView(values) &&
+ !(values instanceof Float32Array) &&
+ !(values instanceof Float64Array)
+ ) {
+ return true;
+ }
+ return Array.prototype.every.call(values, (value: number) =>
+ Number.isInteger(value)
+ );
+}
+
+export function getTypedArrayForDataRange(min: number, max: number) {
+ if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max))
+ throw new Error('Input must be integers');
+
+ const isSigned = min < 0;
+ const nbits = getBitStorageSize(
+ Math.abs(min) > Math.abs(max) ? min : max,
+ isSigned
+ );
+
+ if (nbits <= 8) return isSigned ? Int8Array : Uint8Array;
+ if (nbits <= 16) return isSigned ? Int16Array : Uint16Array;
+ if (nbits <= 32) return isSigned ? Int32Array : Uint32Array;
+
+ throw new Error(`Cannot handle ${nbits}-bit sized ranges`);
+}
+
+/**
+ * The tags that decide what values an instance decodes to.
+ *
+ * A bitsStored of 0 means the tag was absent or unparseable. It is not a DICOM
+ * default: BitsStored is Type 1, so a conforming instance always carries one.
+ */
+export function getPixelFormat(
+ metadata: DicomDataset | null | undefined,
+ frameIndex = 0
+) {
+ const valueOf = (tag: string) => getFrameTag(metadata, tag, frameIndex);
+ return {
+ bitsStored: numberOr(valueOf(BitsStoredTag), 0),
+ pixelRepresentation: numberOr(valueOf(PixelRepresentationTag), 0),
+ ...readModalityRescale(metadata, frameIndex),
+ };
+}
+
+type PixelFormat = ReturnType;
+
+/**
+ * The values an instance can decode to. ITK/GDCM applies RescaleSlope and
+ * RescaleIntercept while decoding, so this is the range after rescaling.
+ *
+ * Null when BitsStored is missing, since nothing can be derived without it.
+ */
+export function getRescaledValueRange(format: PixelFormat) {
+ const { bitsStored, pixelRepresentation, rescaleSlope, rescaleIntercept } =
+ format;
+ if (!Number.isInteger(bitsStored) || bitsStored <= 0) return null;
+
+ const isSigned = pixelRepresentation === 1;
+ const storedMin = isSigned ? -(2 ** (bitsStored - 1)) : 0;
+ const storedMax = 2 ** (bitsStored - (isSigned ? 1 : 0)) - 1;
+ const a = storedMin * rescaleSlope + rescaleIntercept;
+ const b = storedMax * rescaleSlope + rescaleIntercept;
+
+ return { min: Math.min(a, b), max: Math.max(a, b) };
+}
+
+/**
+ * The element type a volume allocated from instances of these formats holds.
+ *
+ * An instance whose tags give no finite range is left out of the union and
+ * judged on its decoded values by the containment guard instead.
+ */
+export function getVolumeBufferType(formats: PixelFormat[]) {
+ const usable = formats.flatMap((format) => {
+ const range = getRescaledValueRange(format);
+ return range && Number.isFinite(range.min) && Number.isFinite(range.max)
+ ? [{ format, range }]
+ : [];
+ });
+ if (usable.length === 0)
+ throw new Error('No instance declares a finite modality rescale range');
+
+ const needsFloat64 = usable.some(
+ ({ format, range }) =>
+ !Number.isInteger(format.rescaleSlope) ||
+ !Number.isInteger(format.rescaleIntercept) ||
+ !Number.isSafeInteger(range.min) ||
+ !Number.isSafeInteger(range.max)
+ );
+ if (needsFloat64) return Float64Array;
+
+ const min = Math.min(...usable.map(({ range }) => range.min));
+ const max = Math.max(...usable.map(({ range }) => range.max));
+ const exceedsSigned32 = min < 0 && (min < -(2 ** 31) || max > 2 ** 31 - 1);
+ const exceedsUnsigned32 = min >= 0 && max > 2 ** 32 - 1;
+ if (exceedsSigned32 || exceedsUnsigned32) return Float64Array;
+
+ return getTypedArrayForDataRange(min, max);
+}
+
+function instancePixelFormats(chunk: Chunk) {
+ const formats = Array.from(
+ { length: numberOr(getDatasetTag(chunk.metadata, NumberOfFrames), 1) },
+ (_, index) => getPixelFormat(chunk.metadata, index)
+ );
+ return formats;
+}
+
+export function allocateImageFromChunks(
+ sortedChunks: Chunk[],
+ orientation: number[] | null,
+ reconstruction: Reconstruction,
+ irregularGeometry: IrregularGeometryPolicy = 'preserve-order'
+) {
+ if (sortedChunks.length === 0) {
+ throw new Error('Cannot allocate an image from zero chunks');
+ }
+
+ assertRegularGeometry(reconstruction, irregularGeometry);
+ assertSliceSlots(reconstruction.slots, sortedChunks.length);
+ const { rows, columns, slices, samplesPerPixel } = allocationShape(
+ sortedChunks,
+ reconstruction
+ );
+ const geometry = allocationGeometry(
+ sortedChunks,
+ orientation,
+ reconstruction,
+ irregularGeometry
+ );
+ const { position, direction } = geometry;
+ const spatialMetadata = allocationSpatialMetadata(sortedChunks, geometry);
+ const TypedArrayCtor = getVolumeBufferType(
+ sortedChunks.flatMap(instancePixelFormats)
+ );
+ const pixelData = new TypedArrayCtor(
+ rows * columns * slices * samplesPerPixel
+ );
+
+ const image = vtkImageData.newInstance();
+ image.setExtent([0, columns - 1, 0, rows - 1, 0, slices - 1]);
+
+ if (position) image.setOrigin(position as Vector3);
+
+ image.setSpacing(spatialMetadata.axes.map((axis) => axis.value));
+
+ if (direction) image.setDirection(direction as mat3);
+
+ const dataArray = vtkDataArray.newInstance({
+ numberOfComponents: samplesPerPixel,
+ values: pixelData,
+ });
+ image.getPointData().setScalars(dataArray);
+
+ // Needed for volume rendering to work at start
+ dataArray.setRange({ min: 0, max: 255 }, 0);
+
+ return { image, spatialMetadata };
+}
+
+/** Preserve acquisition order and its fallback spacing, or require a regular lattice. */
+export type IrregularGeometryPolicy = 'preserve-order' | 'reject';
+
+function assertRegularGeometry(
+ reconstruction: Reconstruction,
+ policy: IrregularGeometryPolicy
+) {
+ if (reconstruction.kind === 'irregular' && policy === 'reject')
+ throw new Error(
+ `Cannot allocate irregular DICOM geometry: ${reconstruction.reason}`
+ );
+}
+
+const instanceGeometry = (chunk: Chunk, count: number) => {
+ const metadata = chunk.metadata!;
+ const nm = nmGeometry(metadata);
+ if (nm) return nm;
+ if (count < 2) return null;
+ // SEG planning does not judge segment-major frames as one spatial stack.
+ if (trimDicomPadding(getDatasetTag(metadata, '00080060') ?? '') === 'SEG')
+ return multiframeGeometry(metadata, count);
+ return readChunkFacts(chunk).frameGeometry;
+};
+
+function allocationDirection(
+ orientation: number[] | null,
+ 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(chunks[0], 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/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..c5c3008fc
--- /dev/null
+++ b/packages/dicom/vtk/nmGeometry.ts
@@ -0,0 +1,32 @@
+import { DicomDataset } from '../core/metadata/dicomDataset';
+import {
+ readGeometryFacts,
+ sliceNormalOf,
+} from '../core/collections/instanceFacts';
+import { type Reconstruction } from '../core/collections/reconstructVolume';
+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/prepareChunkAllocation.ts b/packages/dicom/vtk/prepareChunkAllocation.ts
new file mode 100644
index 000000000..78b12beaa
--- /dev/null
+++ b/packages/dicom/vtk/prepareChunkAllocation.ts
@@ -0,0 +1,61 @@
+import type { Chunk } from '../core/streaming/chunk';
+import { getChunkTag } from '../core/metadata/datasetAccess';
+import { sliceNormalOf } from '../core/collections/instanceFacts';
+import { readChunkFacts } from '../core/collections/chunkFacts';
+import { reconstructInstances } from '../core/collections/reconstructVolume';
+import type { Reconstruction } from '../core/collections/reconstructVolume';
+import {
+ allocateImageFromChunks,
+ type IrregularGeometryPolicy,
+} from './allocateImageFromChunks';
+import { applyUltrasoundSpacing } from './ultrasoundSpacing';
+
+const reconstructionFor = (
+ chunks: Chunk[],
+ orientation: number[] | null,
+ supplied: Reconstruction | undefined
+) => {
+ const needsFrameGeometry =
+ chunks.length === 1 && Number(getChunkTag(chunks[0], '00280008')) > 1;
+ return supplied && !needsFrameGeometry
+ ? supplied
+ : reconstructInstances(
+ chunks.map((chunk) => readChunkFacts(chunk).facts),
+ sliceNormalOf(orientation),
+ supplied
+ );
+};
+
+/** Allocate off-screen; warnings become visible only when the image commits. */
+export function prepareChunkAllocation(
+ chunks: Chunk[],
+ orientation: number[] | null,
+ supplied: Reconstruction | undefined,
+ policy: IrregularGeometryPolicy
+) {
+ if (chunks.length === 0)
+ throw new Error('Cannot allocate an image from zero chunks');
+ const reconstruction = reconstructionFor(chunks, orientation, supplied);
+ const modality = getChunkTag(chunks[0], '00080060')?.trim();
+ const allocated =
+ modality === 'SEG'
+ ? null
+ : allocateImageFromChunks(chunks, orientation, reconstruction, policy);
+ const warnings: Array<[string, string]> = [];
+ try {
+ const spatialMetadata = allocated
+ ? applyUltrasoundSpacing(allocated, chunks, (...warning) =>
+ warnings.push(warning)
+ )
+ : undefined;
+ return {
+ image: allocated?.image,
+ reconstruction,
+ spatialMetadata,
+ warnings,
+ };
+ } catch (error) {
+ allocated?.image.delete();
+ throw error;
+ }
+}
diff --git a/packages/dicom/vtk/preparedChunks.ts b/packages/dicom/vtk/preparedChunks.ts
new file mode 100644
index 000000000..cf817b836
--- /dev/null
+++ b/packages/dicom/vtk/preparedChunks.ts
@@ -0,0 +1,42 @@
+export type PreparedChunks = {
+ changed: boolean;
+ commit: () => void;
+ dispose: () => void;
+};
+
+export const unchangedChunks: PreparedChunks = {
+ changed: false,
+ commit: () => {},
+ dispose: () => {},
+};
+
+/** A prepared allocation belongs to its caller until committed or discarded. */
+export function preparedChunks(
+ apply: () => void,
+ release: () => void,
+ signal: AbortSignal
+): PreparedChunks {
+ let pending = true;
+ const detach = () => {
+ pending = false;
+ signal.removeEventListener('abort', dispose);
+ };
+ const dispose = () => {
+ if (!pending) return;
+ detach();
+ release();
+ };
+ const commit = () => {
+ if (!pending) return;
+ detach();
+ try {
+ apply();
+ } catch (error) {
+ release();
+ throw error;
+ }
+ };
+ signal.addEventListener('abort', dispose, { once: true });
+ if (signal.aborted) dispose();
+ return { changed: true, commit, dispose };
+}
diff --git a/packages/dicom/vtk/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..e4cba0ee1
--- /dev/null
+++ b/packages/dicom/vtk/thumbnailSource.ts
@@ -0,0 +1,59 @@
+import { Chunk } from '../core/streaming/chunk';
+import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import type { TypedArray } from '@kitware/vtk.js/types';
+import type { VtkDicomImageInit } from './types';
+import { sliceToThumbnail } from './thumbnail';
+import { untilAborted } from './lifetime';
+
+const { fastComputeRange } = vtkDataArray;
+export function sliceFromBuffer(image: vtkImageData, slot: number) {
+ const scalars = image.getPointData().getScalars();
+ const components = scalars.getNumberOfComponents();
+ const [width, height] = image.getDimensions();
+ const samplesPerSlice = width * height * components;
+ const data = scalars.getData();
+
+ return sliceToThumbnail({
+ data: data.subarray(slot * samplesPerSlice, (slot + 1) * samplesPerSlice),
+ width,
+ height,
+ components,
+ // The thumbnail reads component 0, so it windows on that component's
+ // range, not the multi-component vector magnitude range.
+ range: scalars.getRange(0) as [number, number],
+ });
+}
+
+// Before a chunk's slot holds pixels, its own bytes are the only source.
+export async function sliceFromChunk(
+ chunk: Chunk,
+ read: VtkDicomImageInit['readDicomImage'],
+ signal: AbortSignal
+) {
+ await untilAborted(chunk.loadData(), signal);
+ const loaded = chunk;
+ if (!loaded.dataBlob) throw new Error('No chunk data');
+
+ const { image } = await untilAborted(
+ read(
+ new File([loaded.dataBlob], 'thumbnail.dcm'),
+ loaded.metadata ?? undefined
+ ),
+ signal
+ );
+ if (!image.data) throw new Error('No data read from chunk');
+
+ const [width, height] = image.size;
+ const components = image.imageType.components;
+ const data = image.data as TypedArray;
+ const { min, max } = fastComputeRange(data, 0, components);
+
+ return sliceToThumbnail({
+ data,
+ width,
+ height,
+ components,
+ range: [min, max],
+ });
+}
diff --git a/packages/dicom/vtk/types.ts b/packages/dicom/vtk/types.ts
new file mode 100644
index 000000000..d5db7ab8a
--- /dev/null
+++ b/packages/dicom/vtk/types.ts
@@ -0,0 +1,50 @@
+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 } from '../core/streaming/chunk';
+import type { DicomDataset } from '../core/metadata/dicomDataset';
+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..a700939e4
--- /dev/null
+++ b/packages/dicom/vtk/ultrasoundSpacing.ts
@@ -0,0 +1,91 @@
+import { type Chunk } from '../core/streaming/chunk';
+import { getChunkTag } from '../core/metadata/datasetAccess';
+import { unitToMm } from '../core/streaming/dicom/ultrasoundRegion';
+import {
+ physicalSpacing,
+ type SpatialMetadata,
+} from '../core/metadata/spatialMetadata';
+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/App.vue b/src/components/App.vue
index e35673a5a..b577a0a42 100644
--- a/src/components/App.vue
+++ b/src/components/App.vue
@@ -58,7 +58,7 @@ import useLoadDataStore from '@/src/store/load-data';
import { useViewStore } from '@/src/store/views';
// Side-effect import: registers processing's config section and
// launch-load subscriber before any config or data loads.
-import '@/src/processing';
+import '@/src/processing/integration';
import { signalLaunchLoadComplete } from '@/src/core/launchLoad';
import useRemoteSaveStateStore from '@/src/store/remote-save-state';
import AppBar from '@/src/components/AppBar.vue';
diff --git a/src/components/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 @@
+
+
+
+
+
+ Image spacing
+
+
+
+ Spacing follows the image's X, Y and Z axes. Declared values come
+ from the file; derived values come from slice positions.
+
+
+
+
+ | Axis |
+ Step |
+ Source |
+
+
+
+
+ |
+ {{ row.label }}
+
+ One sample
+
+ |
+
+ {{ row.value }}
+ {{ row.unit === 'mm' ? 'mm' : '(physical unit unknown)' }}
+ |
+
+ {{ statuses[row.status] }}: {{ row.source }}
+
+ {{ row.reason }}
+
+ |
+
+
+
+
+ Assumed steps position pixels for display. They do not establish a
+ physical distance.
+
+
+ {{ geometryReasons[reconstruction.reason ?? ''] }} The displayed
+ grid is approximate; measurements across slices may not represent
+ physical distances.
+
+
+ {{ note }}
+
+
+ File calibration: {{ spatial.calibration.type }}
+ {{ spatial.calibration.description }}
+
+
+
+ The image reader has not reported how its physical spacing was
+ established.
+
+
+
+
+ Close
+
+
+
+
diff --git a/src/components/ModulePanel.vue b/src/components/ModulePanel.vue
index 7713d3a0a..7b267a50a 100644
--- a/src/components/ModulePanel.vue
+++ b/src/components/ModulePanel.vue
@@ -48,7 +48,8 @@
import { Component, computed, defineComponent, ref, watch } from 'vue';
import { ConnectionState, useServerStore } from '@/src/store/server';
-import { JobsModule, useProcessingJobsStore } from '@/src/processing';
+import { JobsModule } from '@/src/processing/integration';
+import { useProcessingJobsStore } from '@/src/processing/store';
import DataBrowser from './DataBrowser.vue';
import RenderingModule from './RenderingModule.vue';
import AnnotationsModule from './AnnotationsModule.vue';
diff --git a/src/components/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 @@
+