From 12838a0e53eecb175034971818f170910d3e2301 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 18 Aug 2026 11:35:58 -0400 Subject: [PATCH 1/2] refactor(dicom): group DICOM chunk utilities under src/utils/dicom toVec and the slice-normal cross product were private to allocateImageFromChunks, and a Map view of a chunk's metadata was built inline there. Move all three into dicomChunks.ts, and collect the DICOM-specific modules of src/utils into src/utils/dicom so they sit with their peers rather than among the generic helpers. No behavior change. --- src/core/streaming/dicomChunkImage.ts | 2 +- .../__tests__/allocateImageFromChunks.spec.ts | 2 +- .../{ => dicom}/allocateImageFromChunks.ts | 22 +++++++----------- src/utils/dicom/dicomChunks.ts | 23 +++++++++++++++++++ 4 files changed, 33 insertions(+), 16 deletions(-) rename src/utils/{ => dicom}/__tests__/allocateImageFromChunks.spec.ts (97%) rename src/utils/{ => dicom}/allocateImageFromChunks.ts (92%) create mode 100644 src/utils/dicom/dicomChunks.ts diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 16fbef9a3..da1153b8f 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -7,7 +7,7 @@ import { import { Chunk, waitForChunkState } from '@/src/core/streaming/chunk'; import { Image, JsonCompatible, readImage } from '@itk-wasm/image-io'; import { getWorker } from '@/src/io/itk/worker'; -import { allocateImageFromChunks } from '@/src/utils/allocateImageFromChunks'; +import { allocateImageFromChunks } from '@/src/utils/dicom/allocateImageFromChunks'; import { TypedArray } from '@kitware/vtk.js/types'; import { Tags } from '@/src/core/dicomTags'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; diff --git a/src/utils/__tests__/allocateImageFromChunks.spec.ts b/src/utils/dicom/__tests__/allocateImageFromChunks.spec.ts similarity index 97% rename from src/utils/__tests__/allocateImageFromChunks.spec.ts rename to src/utils/dicom/__tests__/allocateImageFromChunks.spec.ts index f6c675246..cc6f2dbef 100644 --- a/src/utils/__tests__/allocateImageFromChunks.spec.ts +++ b/src/utils/dicom/__tests__/allocateImageFromChunks.spec.ts @@ -3,7 +3,7 @@ import { Tags } from '@/src/core/dicomTags'; import { allocateImageFromChunks, getTypedArrayForDataRange, -} from '@/src/utils/allocateImageFromChunks'; +} from '@/src/utils/dicom/allocateImageFromChunks'; import { describe, it, expect } from 'vitest'; function chunk(overrides: Record = {}) { diff --git a/src/utils/allocateImageFromChunks.ts b/src/utils/dicom/allocateImageFromChunks.ts similarity index 92% rename from src/utils/allocateImageFromChunks.ts rename to src/utils/dicom/allocateImageFromChunks.ts index d46f59ed7..1f2311b16 100644 --- a/src/utils/allocateImageFromChunks.ts +++ b/src/utils/dicom/allocateImageFromChunks.ts @@ -1,6 +1,10 @@ import { Chunk } from '@/src/core/streaming/chunk'; -import { Maybe } from '@/src/types'; import { NAME_TO_TAG } from '@/src/core/dicomTags'; +import { + getChunkMetadata, + getSliceNormal, + toVec, +} from '@/src/utils/dicom/dicomChunks'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import { Vector3 } from '@kitware/vtk.js/types'; import { mat3, vec3 } from 'gl-matrix'; @@ -19,11 +23,6 @@ const RescaleIntercept = NAME_TO_TAG.get('RescaleIntercept')!; const RescaleSlope = NAME_TO_TAG.get('RescaleSlope')!; const NumberOfFrames = NAME_TO_TAG.get('NumberOfFrames')!; -function toVec(s: Maybe): number[] | null { - if (!s?.length) return null; - return s.split('\\').map((a) => Number(a)) as number[]; -} - function isPositiveFiniteNumber(value: number) { return Number.isFinite(value) && value > 0; } @@ -79,7 +78,7 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) { } // use the first chunk as the source of metadata - const meta = new Map(sortedChunks[0].metadata!); + const meta = getChunkMetadata(sortedChunks[0]); const imagePositionPatient = toVec(meta.get(ImagePositionPatientTag)); const imageOrientationPatient = toVec(meta.get(ImageOrientationPatientTag)); const pixelSpacing = toVec(meta.get(PixelSpacingTag)); @@ -138,7 +137,7 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) { } if (imagePositionPatient && sortedChunks.length > 1) { - const lastMeta = new Map(sortedChunks[sortedChunks.length - 1].metadata); + const lastMeta = getChunkMetadata(sortedChunks[sortedChunks.length - 1]); const lastIPP = toVec(lastMeta.get(ImagePositionPatientTag)); if (lastIPP) { // assumption: uniform Z spacing @@ -152,12 +151,7 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) { image.setSpacing(spacing); if (imageOrientationPatient) { - const zDir = vec3.create() as Vector3; - vec3.cross( - zDir, - imageOrientationPatient.slice(0, 3) as vec3, - imageOrientationPatient.slice(3, 6) as vec3 - ); + const zDir = getSliceNormal(imageOrientationPatient); image.setDirection([...imageOrientationPatient, ...zDir] as mat3); } diff --git a/src/utils/dicom/dicomChunks.ts b/src/utils/dicom/dicomChunks.ts new file mode 100644 index 000000000..df4d900e5 --- /dev/null +++ b/src/utils/dicom/dicomChunks.ts @@ -0,0 +1,23 @@ +import { vec3 } from 'gl-matrix'; +import { Chunk } from '@/src/core/streaming/chunk'; +import { Maybe } from '@/src/types'; + +/** Chunk metadata as a Map. For reading many tags off a single chunk. */ +export const getChunkMetadata = (chunk: Chunk) => new Map(chunk.metadata ?? []); + +/** Parses a backslash-delimited DICOM multi-value string. */ +export function toVec(value: Maybe) { + if (!value?.length) return null; + return value.split('\\').map(Number); +} + +/** Slice normal: cross product of the ImageOrientationPatient row and column. */ +export function getSliceNormal(imageOrientationPatient: number[]) { + const normal = vec3.create(); + vec3.cross( + normal, + imageOrientationPatient.slice(0, 3) as vec3, + imageOrientationPatient.slice(3, 6) as vec3 + ); + return normal; +} From 9d62e2a204f7af9e2d71257eda74bdd2ee4adafa Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 18 Aug 2026 11:36:35 -0400 Subject: [PATCH 2/2] feat(dicom): split series holding overlapping acquisitions GDCM's series-detail key covers SeriesNumber, SliceThickness, Rows and Columns, but not AcquisitionNumber, so the categorize pipeline hands back a series holding several overlapping scans as a single volume. Sorted by position, those slices sit on no one lattice, and the derived Z spacing describes none of the scans. Separate such a volume by grouping on AcquisitionNumber, then TemporalPositionIdentifier, then EchoNumbers, splitting on the first tag whose groups cover overlapping stretches of the slice axis and recursing into each part with the remaining tags, so a 4D multi-echo series separates on both axes. Groups that follow one another along the axis are one volume between them and stay merged, however their acquisition is numbered: the decision compares positions rather than measuring how even the spacing looks. The split is decided over the batch plus every chunk already imported for the same group, so a series loaded across several imports converges on the same volumes as one loaded at once. Split volumes keep the provenance of their member files so a saved session restores them, and a state file leaf covered by more than one volume is left unmapped rather than bound to an arbitrary one of them. Volumes that still hold two slices at one position after splitting are reported as a warning, since no listed tag separates them. --- src/core/dicomTags.ts | 3 + .../import/__tests__/stateFileLeaves.spec.ts | 31 ++ src/io/import/importDataSources.ts | 44 +- .../__tests__/datasets-dicom-split.spec.ts | 271 ++++++++++++ src/store/__tests__/dicom-web-store.spec.ts | 75 ++++ src/store/datasets-dicom.ts | 252 ++++++++--- src/store/datasets.ts | 84 +++- src/store/dicom-web/dicom-web-store.ts | 48 ++- .../dicom/__tests__/idcSeriesFixtures.ts | 179 ++++++++ .../splitOverlappingAcquisitions.spec.ts | 403 ++++++++++++++++++ src/utils/dicom/dicomChunks.ts | 11 + .../dicom/splitOverlappingAcquisitions.ts | 233 ++++++++++ tests/specs/multi-acquisition-series.e2e.ts | 101 +++++ tests/specs/syntheticDicom.ts | 8 +- 14 files changed, 1642 insertions(+), 101 deletions(-) create mode 100644 src/store/__tests__/datasets-dicom-split.spec.ts create mode 100644 src/store/__tests__/dicom-web-store.spec.ts create mode 100644 src/utils/dicom/__tests__/idcSeriesFixtures.ts create mode 100644 src/utils/dicom/__tests__/splitOverlappingAcquisitions.spec.ts create mode 100644 src/utils/dicom/splitOverlappingAcquisitions.ts create mode 100644 tests/specs/multi-acquisition-series.e2e.ts diff --git a/src/core/dicomTags.ts b/src/core/dicomTags.ts index fe2e13cc7..24e17ca74 100644 --- a/src/core/dicomTags.ts +++ b/src/core/dicomTags.ts @@ -26,6 +26,9 @@ 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: 'ImagePositionPatient', tag: '0020|0032' }, { name: 'ImageOrientationPatient', tag: '0020|0037' }, { name: 'PixelSpacing', tag: '0028|0030' }, diff --git a/src/io/import/__tests__/stateFileLeaves.spec.ts b/src/io/import/__tests__/stateFileLeaves.spec.ts index f33221ee7..cdf9d77e8 100644 --- a/src/io/import/__tests__/stateFileLeaves.spec.ts +++ b/src/io/import/__tests__/stateFileLeaves.spec.ts @@ -104,4 +104,35 @@ describe('buildStateIDToStoreID', () => { 'leaf:4': 'store-seg', }); }); + + it('leaves a dataset unmapped when its leaves load as several volumes', () => { + // A series saved as one dataset that a later build splits into several + // volumes (overlapping acquisitions) has no single owner for its saved + // state. Binding to either volume would attach annotations to an + // arbitrary sub-volume, so the dataset must go unresolved instead. + const loadables: LoadableResult[] = [ + { + type: 'data', + dataID: 'store-acq1', + dataType: 'image', + dataSource: mergedDicomSource(['ds-split', 'ds-split']), + }, + { + type: 'data', + dataID: 'store-acq2', + dataType: 'image', + dataSource: mergedDicomSource(['ds-split']), + }, + { + type: 'data', + dataID: 'store-whole', + dataType: 'image', + dataSource: mergedDicomSource(['ds-whole']), + }, + ]; + + expect(buildStateIDToStoreID(loadables)).toEqual({ + 'ds-whole': 'store-whole', + }); + }); }); diff --git a/src/io/import/importDataSources.ts b/src/io/import/importDataSources.ts index b88d0d003..bd2e4c974 100644 --- a/src/io/import/importDataSources.ts +++ b/src/io/import/importDataSources.ts @@ -42,7 +42,7 @@ import handleDicomStream from '@/src/io/import/processors/handleDicomStream'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { asyncSelect } from '@/src/utils/asyncSelect'; import { evaluateChain, Skip } from '@/src/utils/evaluateChain'; -import { ensureError, partition } from '@/src/utils'; +import { ensureError, nonNullable, partition } from '@/src/utils'; import { Chunk } from '@/src/core/streaming/chunk'; import { useDatasetStore } from '@/src/store/datasets'; import { useDICOMStore } from '@/src/store/datasets-dicom'; @@ -83,16 +83,26 @@ const applyConfigsPostState = ( // The restore-time stateID -> storeID map: every state-file leaf a loadable // covers maps to its ONE store id. Many-to-one is the normal shape — a merged // multi-file DICOM volume covers every member file's per-file dataset id. +// A dataset whose leaves now load as SEVERAL volumes (a series since split +// into acquisitions) has no single owner for its saved state; it is left +// unmapped so restore reports it instead of binding annotations, layers, and +// views to an arbitrary one of the volumes. export function buildStateIDToStoreID( loadables: readonly LoadableResult[] ): Record { - const stateIDToStoreID: Record = {}; + const storeIDsByState = new Map>(); loadables.forEach((loadable) => { findStateFileLeaves(loadable.dataSource).forEach((leaf) => { - stateIDToStoreID[leaf.stateID] = loadable.dataID; + const storeIDs = storeIDsByState.get(leaf.stateID) ?? new Set(); + storeIDs.add(loadable.dataID); + storeIDsByState.set(leaf.stateID, storeIDs); }); }); - return stateIDToStoreID; + return Object.fromEntries( + [...storeIDsByState.entries()] + .filter(([, storeIDs]) => storeIDs.size === 1) + .map(([stateID, storeIDs]) => [stateID, [...storeIDs][0]]) + ); } async function importDicomChunkSources(sources: ChunkSource[]) { @@ -108,16 +118,22 @@ async function importDicomChunkSources(sources: ChunkSource[]) { chunkToDataSource.set(src.chunk, src); }); - return Object.entries(volumeChunks).map(([id, chunks]) => - asLoadableResult( - id, - { - type: 'collection', - sources: chunks.map((chunk) => chunkToDataSource.get(chunk)!), - }, - 'image' - ) - ); + // A volume can hold chunks imported by an earlier call (the store re-splits + // a series over everything imported so far); this call's loadables cover + // only the chunks it carried in. + return Object.entries(volumeChunks).flatMap(([id, chunks]) => { + const volumeSources = chunks + .map((chunk) => chunkToDataSource.get(chunk)) + .filter(nonNullable); + if (volumeSources.length === 0) return []; + return [ + asLoadableResult( + id, + { type: 'collection', sources: volumeSources }, + 'image' + ), + ]; + }); } type ImportPolicy = 'application' | 'volume-data'; diff --git a/src/store/__tests__/datasets-dicom-split.spec.ts b/src/store/__tests__/datasets-dicom-split.spec.ts new file mode 100644 index 000000000..dc7835d1c --- /dev/null +++ b/src/store/__tests__/datasets-dicom-split.spec.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +import type { Chunk } from '@/src/core/streaming/chunk'; +import { Tags } from '@/src/core/dicomTags'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useDICOMStore, getDisplayName } from '@/src/store/datasets-dicom'; +import { useDatasetStore } from '@/src/store/datasets'; +import { uriToDataSource, type DataSource } from '@/src/io/import/dataSource'; +import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; + +const mocks = vi.hoisted(() => { + class MockDicomChunkImage { + chunks: Chunk[] = []; + + name = ''; + + loading = { value: false }; + + async addChunks(chunks: Chunk[]) { + this.chunks = chunks; + } + + getDicomMetadata() { + return this.chunks[0].metadata; + } + + getChunks() { + return this.chunks.slice(); + } + + setName(name: string) { + this.name = name; + } + + getStatus() { + return 'complete'; + } + + getVtkImageData() { + return { + getPointData: () => ({ getScalars: () => null }), + }; + } + + isLoading() { + return false; + } + + isLoaded() { + return true; + } + + getImageMetadata() { + return null; + } + + addEventListener() {} + + removeEventListener() {} + + startLoad() {} + + dispose() {} + } + + return { + splitAndSort: vi.fn(), + MockDicomChunkImage, + }; +}); + +vi.mock('@/src/io/dicom', () => ({ + splitAndSort: mocks.splitAndSort, +})); + +vi.mock('@/src/core/streaming/dicomChunkImage', () => ({ + default: mocks.MockDicomChunkImage, +})); + +function chunk(sopUid: string, z: number, acquisition?: string) { + return { + metadata: [ + ...(acquisition + ? [[Tags.AcquisitionNumber, acquisition] as [string, string]] + : []), + [Tags.SOPClassUID, '1.2.840.10008.5.1.4.1.1.2'], + [Tags.NumberOfFrames, '1'], + [Tags.SOPInstanceUID, sopUid], + [Tags.PatientID, 'patient-1'], + [Tags.PatientName, 'Test Patient'], + [Tags.PatientBirthDate, ''], + [Tags.PatientSex, ''], + [Tags.StudyID, 'study-1'], + [Tags.StudyInstanceUID, 'study-uid'], + [Tags.StudyDate, ''], + [Tags.StudyTime, ''], + [Tags.AccessionNumber, ''], + [Tags.StudyDescription, ''], + [Tags.Modality, 'CT'], + [Tags.SeriesInstanceUID, 'series-uid'], + [Tags.SeriesNumber, '2'], + [Tags.SeriesDescription, 'CHEST'], + [Tags.WindowLevel, ''], + [Tags.WindowWidth, ''], + [Tags.ImageOrientationPatient, '1\\0\\0\\0\\1\\0'], + [Tags.ImagePositionPatient, `0\\0\\${z}`], + ] as [string, string][], + metaBlob: new Blob([new Uint8Array([1])]), + } as unknown as Chunk; +} + +function dataSource(chunks: Chunk[]): DataSource { + return { + type: 'collection', + sources: chunks.map((item) => { + const sopUid = new Map(item.metadata!).get(Tags.SOPInstanceUID)!; + return { + type: 'chunk', + chunk: item, + mime: FILE_EXT_TO_MIME.dcm, + parent: uriToDataSource( + `https://example.test/${sopUid}.dcm`, + `${sopUid}.dcm`, + FILE_EXT_TO_MIME.dcm + ), + }; + }), + }; +} + +function sourceSopUids(source?: DataSource) { + if (source?.type !== 'collection') return []; + return source.sources.flatMap((item) => { + if (item.type !== 'chunk') return []; + const sopUid = new Map(item.chunk.metadata!).get(Tags.SOPInstanceUID); + return sopUid ? [sopUid] : []; + }); +} + +// A series whose acquisitions arrive in separate import calls must converge +// on the same volumes as one loaded at once: each call re-decides the split +// over every chunk imported so far, not just the chunks it carried in. +describe('DICOM store acquisition split across imports', () => { + beforeEach(() => { + setActivePinia(createPinia()); + mocks.splitAndSort.mockReset(); + }); + + it('re-splits a series imported one acquisition at a time', async () => { + const store = useDICOMStore(); + const imageCacheStore = useImageCacheStore(); + const datasetStore = useDatasetStore(); + + const acq1 = [0, 2.5, 5].map((z, i) => chunk(`acq1-${i}`, z, '1')); + const acq2 = [0.75, 3.25, 5.75].map((z, i) => chunk(`acq2-${i}`, z, '2')); + + mocks.splitAndSort.mockResolvedValueOnce({ S: acq1 }); + await store.importChunks(acq1); + datasetStore.addDataSources([ + { dataID: 'S', dataSource: dataSource(acq1) }, + ]); + + expect(Object.keys(store.volumeInfo)).toEqual(['S']); + + mocks.splitAndSort.mockResolvedValueOnce({ S: acq2 }); + const volumes = await store.importChunks(acq2); + + expect(Object.keys(volumes).sort()).toEqual(['S.1', 'S.2']); + expect(Object.keys(store.volumeInfo).sort()).toEqual(['S.1', 'S.2']); + expect(imageCacheStore.imageById.S).toBeUndefined(); + expect(store.volumeKeysByBase.S.sort()).toEqual(['S.1', 'S.2']); + // The series description stays a verbatim tag mirror; the split shows up + // in the display name. + expect(store.volumeInfo['S.1'].SeriesDescription).toBe('CHEST'); + expect(store.volumeInfo['S.1'].splitLabel).toBe('acquisition 1'); + expect(store.volumeInfo['S.2'].splitLabel).toBe('acquisition 2'); + expect(getDisplayName(store.volumeInfo['S.1'])).toBe( + 'CHEST (acquisition 1)' + ); + expect(getDisplayName(store.volumeInfo['S.2'])).toBe( + 'CHEST (acquisition 2)' + ); + expect(volumes['S.1']).toHaveLength(3); + expect(volumes['S.2']).toHaveLength(3); + + // The importer adds this call's sources after importChunks returns. The + // store must already have migrated the first call's sources from S to + // S.1, or saving now would omit acquisition 1 entirely. + datasetStore.addDataSources([ + { dataID: 'S.2', dataSource: dataSource(acq2) }, + ]); + expect(datasetStore.getDataSource('S')).toBeUndefined(); + expect(sourceSopUids(datasetStore.getDataSource('S.1'))).toEqual( + acq1.map((item) => new Map(item.metadata!).get(Tags.SOPInstanceUID)) + ); + expect(sourceSopUids(datasetStore.getDataSource('S.2'))).toEqual( + acq2.map((item) => new Map(item.metadata!).get(Tags.SOPInstanceUID)) + ); + }); + + it('does not duplicate a subset re-imported after the full series', async () => { + const store = useDICOMStore(); + const imageCacheStore = useImageCacheStore(); + + const acq1 = [0, 2.5, 5].map((z, i) => chunk(`acq1-${i}`, z, '1')); + const acq2 = [0.75, 3.25, 5.75].map((z, i) => chunk(`acq2-${i}`, z, '2')); + + mocks.splitAndSort.mockResolvedValueOnce({ S: [...acq1, ...acq2] }); + await store.importChunks([...acq1, ...acq2]); + + expect(Object.keys(store.volumeInfo).sort()).toEqual(['S.1', 'S.2']); + + // Re-import only acquisition 1 with fresh chunk objects carrying the same + // SOPInstanceUIDs, as a second drag of the same files produces. + const acq1Again = [0, 2.5, 5].map((z, i) => chunk(`acq1-${i}`, z, '1')); + mocks.splitAndSort.mockResolvedValueOnce({ S: acq1Again }); + const volumes = await store.importChunks(acq1Again); + + expect(Object.keys(store.volumeInfo).sort()).toEqual(['S.1', 'S.2']); + expect(imageCacheStore.imageById.S).toBeUndefined(); + expect(volumes['S.1']).toHaveLength(3); + expect(volumes['S.2']).toHaveLength(3); + }); + + it('keeps an existing split when a later batch cannot be judged', async () => { + const store = useDICOMStore(); + + const acq1 = [0, 2.5, 5].map((z, i) => chunk(`acq1-${i}`, z, '1')); + const acq2 = [0.75, 3.25, 5.75].map((z, i) => chunk(`acq2-${i}`, z, '2')); + + mocks.splitAndSort.mockResolvedValueOnce({ S: [...acq1, ...acq2] }); + await store.importChunks([...acq1, ...acq2]); + + expect(Object.keys(store.volumeInfo).sort()).toEqual(['S.1', 'S.2']); + + // A chunk with no AcquisitionNumber makes the accumulated set + // unjudgeable. The split volumes must survive; the batch lands in its + // own base volume instead of collapsing the series back together. + const untagged = [chunk('untagged-0', 7.5)]; + mocks.splitAndSort.mockResolvedValueOnce({ S: untagged }); + await store.importChunks(untagged); + + expect(Object.keys(store.volumeInfo).sort()).toEqual(['S', 'S.1', 'S.2']); + expect(store.volumeKeysByBase.S.sort()).toEqual(['S', 'S.1', 'S.2']); + }); + + it('converges to one volume when a later batch makes IDs collide', async () => { + const store = useDICOMStore(); + + const acq1 = [0, 2.5, 5].map((z, i) => chunk(`acq1-${i}`, z, '1')); + const acq2 = [0.75, 3.25, 5.75].map((z, i) => chunk(`acq2-${i}`, z, '2')); + + mocks.splitAndSort.mockResolvedValueOnce({ S: [...acq1, ...acq2] }); + await store.importChunks([...acq1, ...acq2]); + + expect(Object.keys(store.volumeInfo).sort()).toEqual(['S.1', 'S.2']); + + // '+1' and '-1' both encode to the ID suffix 'D1'. The union cannot + // split without silently dropping chunks, so the whole series must + // converge to the single base volume, not re-split batch-only. + const colliding = [chunk('plus-0', 10, '+1'), chunk('minus-0', 10.5, '-1')]; + mocks.splitAndSort.mockResolvedValueOnce({ S: colliding }); + const volumes = await store.importChunks(colliding); + + expect(Object.keys(volumes)).toEqual(['S']); + expect(Object.keys(store.volumeInfo)).toEqual(['S']); + expect(store.volumeKeysByBase.S).toEqual(['S']); + expect(volumes.S).toHaveLength(8); + }); +}); diff --git a/src/store/__tests__/dicom-web-store.spec.ts b/src/store/__tests__/dicom-web-store.spec.ts new file mode 100644 index 000000000..e09399b38 --- /dev/null +++ b/src/store/__tests__/dicom-web-store.spec.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +import { + type PatientInfo, + type StudyInfo, + type VolumeInfo, + useDICOMStore, +} from '@/src/store/datasets-dicom'; +import { useDicomWebStore } from '@/src/store/dicom-web/dicom-web-store'; + +const patient: PatientInfo = { + PatientID: 'patient-1', + PatientName: 'Test Patient', + PatientBirthDate: '', + PatientSex: '', +}; + +const study: StudyInfo = { + StudyID: 'study-1', + StudyInstanceUID: 'study-uid', + StudyDate: '', + StudyTime: '', + AccessionNumber: '', + StudyDescription: '', +}; + +function volume(VolumeID: string, SeriesInstanceUID: string): VolumeInfo { + return { + NumberOfSlices: 1, + VolumeID, + Modality: 'CT', + SeriesInstanceUID, + SeriesNumber: '1', + SeriesDescription: '', + WindowLevel: '', + WindowWidth: '', + kind: 'volume', + }; +} + +describe('DICOMweb loaded-series tracking', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('does not treat a different series with a shared UID prefix as loaded', () => { + const dicomStore = useDICOMStore(); + const dicomWebStore = useDicomWebStore(); + + dicomStore._updateDatabase( + patient, + study, + volume('1.2.3.orientation', '1.2.3') + ); + dicomStore._updateDatabase( + patient, + study, + volume('1.2.30.orientation', '1.2.30') + ); + dicomWebStore.volumes['1.2.3'] = { + state: 'Done', + loaded: 1, + total: 1, + }; + + dicomStore.deleteVolume('1.2.3.orientation'); + + expect(dicomWebStore.volumes['1.2.3']).toEqual({ + state: 'Remote', + loaded: 0, + total: 1, + }); + }); +}); diff --git a/src/store/datasets-dicom.ts b/src/store/datasets-dicom.ts index 56750f197..2556f6638 100644 --- a/src/store/datasets-dicom.ts +++ b/src/store/datasets-dicom.ts @@ -7,6 +7,10 @@ import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; import DicomCineImage from '@/src/core/cine/DicomCineImage'; import { parseCineDicom } from '@/src/core/cine/parseCineDicom'; import { isUltrasoundMultiframeSopClass, Tags } from '@/src/core/dicomTags'; +import { splitOverlappingAcquisitions } from '@/src/utils/dicom/splitOverlappingAcquisitions'; +import { getChunkTag } from '@/src/utils/dicom/dicomChunks'; +import { useMessageStore } from '@/src/store/messages'; +import { useDatasetStore } from '@/src/store/datasets'; import { removeFromArray } from '../utils'; export const ANONYMOUS_PATIENT = 'Anonymous'; @@ -47,6 +51,9 @@ export type VolumeInfo = { SeriesDescription: string; WindowLevel: string; WindowWidth: string; + // Names the scan a split volume was separated out as ('acquisition 2', + // 'phase 3, echo 1'). Absent on volumes that were not split. + splitLabel?: string; // For 'cine', NumberOfSlices is the frame count. Optional for back-compat // with saved state that predates the field. kind?: 'volume' | 'cine'; @@ -77,6 +84,9 @@ type State = { volumeStudy: Record; // studyKey -> patientKey studyPatient: Record; + + // categorize-pipeline volume ID -> volume keys currently imported from it + volumeKeysByBase: Record; }; /** @@ -89,10 +99,10 @@ const cleanupName = (name: string) => { }; export const getDisplayName = (info: VolumeInfo) => { - return ( + const name = cleanupName(info.SeriesDescription || info.SeriesNumber) || - info.SeriesInstanceUID - ); + info.SeriesInstanceUID; + return info.splitLabel ? `${name} (${info.splitLabel})` : name; }; export function isCineChunkGroup(chunks: Chunk[]): boolean { @@ -146,79 +156,192 @@ export const useDICOMStore = defineStore('dicom', { volumeStudy: {}, studyPatient: {}, needsRebuild: {}, + volumeKeysByBase: {}, }), actions: { async importChunks(chunks: Chunk[]) { - const imageCacheStore = useImageCacheStore(); + const messageStore = useMessageStore(); + const datasetStore = useDatasetStore(); // split into groups - const chunksByVolume = await DICOM.splitAndSort( + const categorized = await DICOM.splitAndSort( chunks, (chunk) => chunk.metaBlob! ); - await Promise.all( - Object.entries(chunksByVolume).map(async ([id, sortedChunks]) => { - if (isCineChunkGroup(sortedChunks)) { - const importedAsCine = await this._importCineChunk( - id, - sortedChunks[0] - ); - if (importedAsCine) return; - } + const imported: Record = {}; + let anyChange = false; - const cachedImage = imageCacheStore.imageById[id]; - if (cachedImage && !(cachedImage instanceof DicomChunkImage)) { - throw new Error( - `Volume ${id} is already loaded as a non-chunk progressive image; cannot re-import as a chunk volume.` + await Promise.all( + Object.entries(categorized).map(async ([baseId, batchChunks]) => { + const { + volumes, + duplicated, + labels, + retainedIds, + staleIds, + changed, + } = this._resolveVolumes(baseId, batchChunks); + if (changed) anyChange = true; + + datasetStore.replaceDicomDataSources(staleIds, volumes); + this.volumeKeysByBase[baseId] = retainedIds; + + duplicated.forEach((volumeId) => { + const description = getChunkTag( + volumes[volumeId][0], + Tags.SeriesDescription + )?.trim(); + const name = description + ? `Series "${description}"` + : `Volume ${volumeId}`; + messageStore.addWarning( + 'A DICOM series has more than one slice at the same position', + `${name} holds repeated slice positions that no tag ` + + 'separates. Its slice spacing and measurements along the ' + + 'slice axis may be wrong.' ); - } - const image = cachedImage ?? new DicomChunkImage(); - - await image.addChunks(sortedChunks); - imageCacheStore.addProgressiveImage(image, { id }); - - // update database - const metaPairs = image.getDicomMetadata(); - if (!metaPairs) throw new Error('Metdata not ready'); - const metadata = Object.fromEntries(metaPairs); - - const patientInfo: PatientInfo = { - PatientID: metadata[Tags.PatientID], - PatientName: metadata[Tags.PatientName], - PatientBirthDate: metadata[Tags.PatientBirthDate], - PatientSex: metadata[Tags.PatientSex], - }; - - const studyInfo: StudyInfo = { - StudyID: metadata[Tags.StudyID], - StudyInstanceUID: metadata[Tags.StudyInstanceUID], - StudyDate: metadata[Tags.StudyDate], - StudyTime: metadata[Tags.StudyTime], - AccessionNumber: metadata[Tags.AccessionNumber], - StudyDescription: metadata[Tags.StudyDescription], - }; - - const volumeInfo: VolumeInfo = { - NumberOfSlices: image.getChunks().length, - VolumeID: id, - Modality: metadata[Tags.Modality], - SeriesInstanceUID: metadata[Tags.SeriesInstanceUID], - SeriesNumber: metadata[Tags.SeriesNumber], - SeriesDescription: metadata[Tags.SeriesDescription], - WindowLevel: metadata[Tags.WindowLevel], - WindowWidth: metadata[Tags.WindowWidth], - kind: 'volume', - }; - - this._updateDatabase(patientInfo, studyInfo, volumeInfo); - - // save the image name - image.setName(getDisplayName(volumeInfo)); + }); + + await Promise.all( + Object.entries(volumes).map(async ([id, sortedChunks]) => { + await this._importVolume(id, sortedChunks, labels[id]); + imported[id] = sortedChunks; + }) + ); }) ); - return chunksByVolume; + return anyChange ? imported : categorized; + }, + + /** + * Which volumes a categorize-pipeline group becomes. + * + * The pipeline groups on series details that ignore the acquisition, so a + * series holding several overlapping scans arrives as one volume with + * interleaved slices. The split is decided over this batch plus every + * chunk already imported for the same group, so a series loaded across + * several imports converges on the same volumes as one loaded at once. + */ + _resolveVolumes(baseId: string, batchChunks: Chunk[]) { + const imageCacheStore = useImageCacheStore(); + + const priorIds = + this.volumeKeysByBase[baseId] ?? + (imageCacheStore.imageById[baseId] ? [baseId] : []); + const cachedChunks = priorIds + .map((id) => imageCacheStore.imageById[id]) + .filter( + (image): image is DicomChunkImage => image instanceof DicomChunkImage + ) + .flatMap((image) => image.getChunks()); + + // Batch instances win SOP collisions so callers can map the returned + // chunks back to this call's sources. + const allChunks = cachedChunks.length + ? [ + ...new Map( + [...cachedChunks, ...batchChunks].map((chunk) => [ + getChunkTag(chunk, Tags.SOPInstanceUID), + chunk, + ]) + ).values(), + ] + : batchChunks; + + let split = splitOverlappingAcquisitions({ [baseId]: allChunks }); + + // Overlap never disappears as chunks accumulate, so a merged verdict + // after the series already split means the union became unjudgeable (a + // chunk without a discriminator tag or readable geometry). Keep the + // split volumes and decide this batch on its own. Collisions are + // excluded: re-splitting the batch alone would mint the colliding ID + // again and silently merge two tag values. + const priorWasSplit = priorIds.some((id) => id !== baseId); + const collapsed = + priorWasSplit && + baseId in split.volumes && + !split.collided.includes(baseId); + if (collapsed) { + split = splitOverlappingAcquisitions({ [baseId]: batchChunks }); + } + + const finalIds = Object.keys(split.volumes); + // A group that re-splits under new chunks changes volume identity; drop + // the stale entries so the same scan is not listed twice. + const retainedIds = collapsed + ? [...new Set([...priorIds, ...finalIds])] + : finalIds; + + return { + ...split, + retainedIds, + staleIds: priorIds.filter((id) => !retainedIds.includes(id)), + changed: + collapsed || + split.volumes[baseId] !== batchChunks || + finalIds.length !== 1, + }; + }, + + async _importVolume(id: string, sortedChunks: Chunk[], label?: string) { + const imageCacheStore = useImageCacheStore(); + + if (isCineChunkGroup(sortedChunks)) { + const importedAsCine = await this._importCineChunk(id, sortedChunks[0]); + if (importedAsCine) return; + } + + const cachedImage = imageCacheStore.imageById[id]; + if (cachedImage && !(cachedImage instanceof DicomChunkImage)) { + throw new Error( + `Volume ${id} is already loaded as a non-chunk progressive image; cannot re-import as a chunk volume.` + ); + } + const image = cachedImage ?? new DicomChunkImage(); + + await image.addChunks(sortedChunks); + imageCacheStore.addProgressiveImage(image, { id }); + + // update database + const metaPairs = image.getDicomMetadata(); + if (!metaPairs) throw new Error('Metdata not ready'); + const metadata = Object.fromEntries(metaPairs); + + const patientInfo: PatientInfo = { + PatientID: metadata[Tags.PatientID], + PatientName: metadata[Tags.PatientName], + PatientBirthDate: metadata[Tags.PatientBirthDate], + PatientSex: metadata[Tags.PatientSex], + }; + + const studyInfo: StudyInfo = { + StudyID: metadata[Tags.StudyID], + StudyInstanceUID: metadata[Tags.StudyInstanceUID], + StudyDate: metadata[Tags.StudyDate], + StudyTime: metadata[Tags.StudyTime], + AccessionNumber: metadata[Tags.AccessionNumber], + StudyDescription: metadata[Tags.StudyDescription], + }; + + const volumeInfo: VolumeInfo = { + NumberOfSlices: image.getChunks().length, + VolumeID: id, + Modality: metadata[Tags.Modality], + SeriesInstanceUID: metadata[Tags.SeriesInstanceUID], + SeriesNumber: metadata[Tags.SeriesNumber], + SeriesDescription: metadata[Tags.SeriesDescription], + WindowLevel: metadata[Tags.WindowLevel], + WindowWidth: metadata[Tags.WindowWidth], + splitLabel: label, + kind: 'volume', + }; + + this._updateDatabase(patientInfo, studyInfo, volumeInfo); + + // save the image name + image.setName(getDisplayName(volumeInfo)); }, async _importCineChunk(id: string, chunk: Chunk): Promise { @@ -315,6 +438,11 @@ export const useDICOMStore = defineStore('dicom', { delete this.sliceData[volumeKey]; delete this.volumeStudy[volumeKey]; + Object.entries(this.volumeKeysByBase).forEach(([baseId, ids]) => { + removeFromArray(ids, volumeKey); + if (ids.length === 0) delete this.volumeKeysByBase[baseId]; + }); + removeFromArray(this.studyVolumes[studyKey], volumeKey); if (this.studyVolumes[studyKey].length === 0) { this._deleteStudy(studyKey); diff --git a/src/store/datasets.ts b/src/store/datasets.ts index ac3204ec0..61e2cb251 100644 --- a/src/store/datasets.ts +++ b/src/store/datasets.ts @@ -10,6 +10,8 @@ import { useModelStore } from '@/src/store/datasets-models'; import { useViewConfigStore } from '@/src/store/view-configs'; import { useImageStatsStore } from '@/src/store/image-stats'; import { Tags } from '@/src/core/dicomTags'; +import type { Chunk } from '@/src/core/streaming/chunk'; +import { getChunkTag } from '@/src/utils/dicom/dicomChunks'; export const DataType = { Image: 'Image', @@ -21,12 +23,15 @@ interface LoadedData { dataSource: DataSource; } +function dicomChunkIdentity(chunk: Chunk): string | undefined { + const sopInstanceUID = getChunkTag(chunk, Tags.SOPInstanceUID)?.trim(); + return sopInstanceUID ? `dicom:${sopInstanceUID}` : undefined; +} + function sourceIdentity(dataSource: DataSource): string | undefined { if (dataSource.type === 'chunk') { - const sopInstanceUID = dataSource.chunk.metadata - ?.find(([tag]) => tag === Tags.SOPInstanceUID)?.[1] - ?.trim(); - if (sopInstanceUID) return `dicom:${sopInstanceUID}`; + const identity = dicomChunkIdentity(dataSource.chunk); + if (identity) return identity; } if (dataSource.type === 'uri') return `uri:${dataSource.uri}`; @@ -50,14 +55,17 @@ function sourceIdentity(dataSource: DataSource): string | undefined { return undefined; } +// Remote provenance survives a merge: it is the copy that can be re-fetched +// when the session is restored. +const preferRemote = (incoming: DataSource, kept: DataSource) => + isRemoteDataSource(incoming) && !isRemoteDataSource(kept); + function mergeCollectionSources( existing: DataSource, incoming: DataSource ): DataSource { if (existing.type !== 'collection' || incoming.type !== 'collection') { - return isRemoteDataSource(incoming) && !isRemoteDataSource(existing) - ? incoming - : existing; + return preferRemote(incoming, existing) ? incoming : existing; } const merged: DataSource[] = []; @@ -81,8 +89,7 @@ function mergeCollectionSources( return; } - const kept = merged[existingIndex]; - if (isRemoteDataSource(source) && !isRemoteDataSource(kept)) { + if (preferRemote(source, merged[existingIndex])) { merged[existingIndex] = source; } }); @@ -296,10 +303,69 @@ export const useDatasetStore = defineStore('dataset', () => { loadedData.value = [...byId.values()]; } + function replaceDicomDataSources( + staleIds: string[], + chunksByReplacement: Record + ) { + if (staleIds.length === 0) return; + + // Preserve the serializable sources before remove() cascades through the + // old dataset IDs, then partition them by the replacement chunks. + const staleIdSet = new Set(staleIds); + const priorSources = loadedData.value + .filter(({ dataID }) => staleIdSet.has(dataID)) + .flatMap(({ dataSource }) => + dataSource.type === 'collection' ? dataSource.sources : [] + ); + + const sourceByChunk = new Map(); + const sourceByIdentity = new Map(); + priorSources.forEach((source) => { + if (source.type !== 'chunk') return; + sourceByChunk.set(source.chunk, source); + + const identity = dicomChunkIdentity(source.chunk); + if (!identity) return; + const existing = sourceByIdentity.get(identity); + if (!existing || preferRemote(source, existing)) { + sourceByIdentity.set(identity, source); + } + }); + + const replacements = Object.entries(chunksByReplacement).flatMap( + ([dataID, chunks]) => { + const sources = [ + ...new Set( + chunks + .map((chunk) => { + const identity = dicomChunkIdentity(chunk); + return ( + (identity && sourceByIdentity.get(identity)) ?? + sourceByChunk.get(chunk) + ); + }) + .filter((source): source is DataSource => source != null) + ), + ]; + if (sources.length === 0) return []; + return [ + { + dataID, + dataSource: { type: 'collection' as const, sources }, + }, + ]; + } + ); + + staleIds.forEach(remove); + addDataSources(replacements); + } + return { idsAsSelections, getDataSource, addDataSources, + replaceDicomDataSources, serialize, remove, removeAll, diff --git a/src/store/dicom-web/dicom-web-store.ts b/src/store/dicom-web/dicom-web-store.ts index 0d8ada899..8645c9095 100644 --- a/src/store/dicom-web/dicom-web-store.ts +++ b/src/store/dicom-web/dicom-web-store.ts @@ -21,6 +21,7 @@ import { parseUrl, } from '@/src/core/dicom-web-api'; import { useViewStore } from '@/src/store/views'; +import { isLoadableResult } from '@/src/io/import/common'; const DICOM_WEB_URL_PARAM = 'dicomweb'; @@ -186,15 +187,26 @@ export const useDicomWebStore = defineStore('dicom-web', () => { throw new Error('Could not fetch series'); } - const [loadResult] = await importDataSources(files.map(fileToDataSource)); - if (!loadResult) { + const results = await importDataSources(files.map(fileToDataSource)); + if (results.length === 0) { throw new Error('Did not receive a load result'); } - if (loadResult.type === 'error') { - throw loadResult.error; - } + const failed = results.find((result) => result.type === 'error'); + if (failed) throw failed.error; + + // A series holding overlapping acquisitions loads as several volumes; + // show the one with the most slices rather than whichever import + // happened to finish first. + const dicomStore = useDICOMStore(); + const sliceCount = (result: (typeof results)[number]) => + isLoadableResult(result) + ? (dicomStore.volumeInfo[result.dataID]?.NumberOfSlices ?? -1) + : -1; + const primaryResult = results.reduce((best, result) => + sliceCount(result) > sliceCount(best) ? result : best + ); - const selection = convertSuccessResultToDataSelection(loadResult); + const selection = convertSuccessResultToDataSelection(primaryResult); useViewStore().setDataForAllViews(selection); volumes.value[volumeKey] = { ...volumes.value[volumeKey], @@ -287,17 +299,23 @@ export const useDicomWebStore = defineStore('dicom-web', () => { loadedDicoms.$onAction(({ name, args, after }) => { if (name !== 'deleteVolume') return; + const [loadedVolumeKey] = args; + const seriesInstanceUID = + loadedDicoms.volumeInfo[loadedVolumeKey]?.SeriesInstanceUID; + after(() => { - const [loadedVolumeKey] = args; - const volumeKey = Object.keys(volumes.value).find((key) => - loadedVolumeKey.startsWith(key) + if (!seriesInstanceUID || !(seriesInstanceUID in volumes.value)) return; + // A split series loads as several volumes with one Series Instance UID; + // only mark the series remote when the last of them is deleted. + const stillLoaded = Object.values(loadedDicoms.volumeInfo).some( + (info) => info.SeriesInstanceUID === seriesInstanceUID ); - if (volumeKey) - volumes.value[volumeKey] = { - ...volumes.value[volumeKey], - state: 'Remote', - loaded: 0, - }; + if (stillLoaded) return; + volumes.value[seriesInstanceUID] = { + ...volumes.value[seriesInstanceUID], + state: 'Remote', + loaded: 0, + }; }); }); diff --git a/src/utils/dicom/__tests__/idcSeriesFixtures.ts b/src/utils/dicom/__tests__/idcSeriesFixtures.ts new file mode 100644 index 000000000..2069c15c3 --- /dev/null +++ b/src/utils/dicom/__tests__/idcSeriesFixtures.ts @@ -0,0 +1,179 @@ +import { Tags } from '@/src/core/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 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: the ' + + 'slabs form one sound volume, and only the overlap gate keeps a ' + + 'StackID-like tag from tearing it apart.', + 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/src/utils/dicom/__tests__/splitOverlappingAcquisitions.spec.ts b/src/utils/dicom/__tests__/splitOverlappingAcquisitions.spec.ts new file mode 100644 index 000000000..8fca1b988 --- /dev/null +++ b/src/utils/dicom/__tests__/splitOverlappingAcquisitions.spec.ts @@ -0,0 +1,403 @@ +import type { Chunk } from '@/src/core/streaming/chunk'; +import { Tags } from '@/src/core/dicomTags'; +import { + hasDuplicateSlicePositions, + splitOverlappingAcquisitions, +} from '@/src/utils/dicom/splitOverlappingAcquisitions'; +import { + bilateralSagittalSlabs, + dceEightPhase, + dceSevenPhasePhilips, + dixonDualEcho, + dixonUnevenEchoes, + doubledAcquisition, + partialTemporalTags, + type IdcSeriesFixture, +} from '@/src/utils/dicom/__tests__/idcSeriesFixtures'; +import { describe, it, expect } from 'vitest'; + +function chunk(z: number, overrides: Record = {}) { + const metadata = { + [Tags.ImageOrientationPatient]: '1\\0\\0\\0\\1\\0', + [Tags.ImagePositionPatient]: `0\\0\\${z}`, + ...overrides, + }; + return { metadata: Object.entries(metadata) } as unknown as Chunk; +} + +// A stack of `count` slices starting at `start`, tagged with an acquisition. +function stack(acquisition: string, start: number, count: number, step = 2.5) { + return Array.from({ length: count }, (_, i) => + chunk(start + i * step, { [Tags.AcquisitionNumber]: acquisition }) + ); +} + +const zOf = (c: Chunk) => + Number(new Map(c.metadata!).get(Tags.ImagePositionPatient)!.split('\\')[2]); +const byPosition = (a: Chunk, b: Chunk) => zOf(a) - zOf(b); + +// Chunks mirroring a real IDC series recorded in idcSeriesFixtures.ts. +const fixtureChunks = (fixture: IdcSeriesFixture) => + fixture + .groups!.flatMap((group) => group.zs.map((z) => chunk(z, group.tags))) + .sort(byPosition); + +describe('hasDuplicateSlicePositions', () => { + it('is false for distinct positions', () => { + expect(hasDuplicateSlicePositions([0, 2.5, 5].map((z) => chunk(z)))).toBe( + false + ); + }); + + it('is true when two slices share a position', () => { + expect( + hasDuplicateSlicePositions([0, 2.5, 2.5, 5].map((z) => chunk(z))) + ).toBe(true); + }); + + it('says nothing about geometry it cannot read', () => { + const noOrientation = [0, 0].map((z) => ({ + metadata: [[Tags.ImagePositionPatient, `0\\0\\${z}`]], + })) as unknown as Chunk[]; + expect(hasDuplicateSlicePositions(noOrientation)).toBe(false); + }); +}); + +describe('splitOverlappingAcquisitions', () => { + it('passes a single-acquisition volume through untouched', () => { + const chunks = stack('1', 0, 5); + const { volumes, duplicated, labels } = splitOverlappingAcquisitions({ + vol: chunks, + }); + + expect(duplicated).toEqual([]); + expect(labels).toEqual({}); + expect(Object.keys(volumes)).toEqual(['vol']); + expect(volumes.vol).toBe(chunks); + }); + + 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 merged = [...acq1, ...acq2, ...acq3].sort(byPosition); + + const { volumes, duplicated, labels } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(duplicated).toEqual([]); + expect(Object.keys(volumes).sort()).toEqual(['vol.1', 'vol.2', 'vol.3']); + expect(volumes['vol.1']).toEqual(acq1); + expect(volumes['vol.2']).toEqual(acq2); + expect(volumes['vol.3']).toEqual(acq3); + expect(labels).toEqual({ + 'vol.1': 'acquisition 1', + 'vol.2': 'acquisition 2', + 'vol.3': 'acquisition 3', + }); + }); + + 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 acq3 = stack('3', -320.25, 4, 5); + const acq2 = stack('2', -300.25, 6, 5); + const acq1 = stack('1', -245.25, 56, 5); + const merged = [...acq3, ...acq2, ...acq1].sort(byPosition); + + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes)).toEqual(['vol']); + expect(volumes.vol).toBe(merged); + expect(duplicated).toEqual([]); + }); + + 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 merged = groups.flat().sort(byPosition); + + const { volumes } = splitOverlappingAcquisitions({ vol: merged }); + + expect(Object.keys(volumes)).toEqual(['vol']); + }); + + 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 zs = [0, 3.27, 6.54, 9.89, 13.16, 16.43]; + const chunks = zs.map((z) => chunk(z, { [Tags.AcquisitionNumber]: '1' })); + + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: chunks, + }); + + expect(Object.keys(volumes)).toEqual(['vol']); + expect(duplicated).toEqual([]); + }); + + it('separates repeat scans taken at one position', () => { + // 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. Merged it would be a bogus seven-slice volume. + const chunks = Array.from({ length: 7 }, (_, i) => + chunk(-82.89, { [Tags.AcquisitionNumber]: String(i + 1) }) + ); + + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: chunks, + }); + + expect(Object.keys(volumes).length).toBe(7); + expect(duplicated).toEqual([]); + }); + + it('separates scans that share a boundary slice', () => { + const lower = stack('1', 0, 4); + const upper = stack('2', 7.5, 4); + const merged = [...lower, ...upper].sort(byPosition); + + const { volumes } = splitOverlappingAcquisitions({ vol: merged }); + + expect(Object.keys(volumes).sort()).toEqual(['vol.1', 'vol.2']); + }); + + it('does not separate scans that abut without overlapping', () => { + const lower = stack('1', 0, 4); + const upper = stack('2', 10, 4); + const merged = [...lower, ...upper].sort(byPosition); + + const { volumes } = splitOverlappingAcquisitions({ vol: merged }); + + expect(Object.keys(volumes)).toEqual(['vol']); + }); + + it('reports repeated positions no tag separates', () => { + const chunks = [0, 2.5, 2.5, 5].map((z) => + chunk(z, { [Tags.AcquisitionNumber]: '1' }) + ); + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: chunks, + }); + + expect(Object.keys(volumes)).toEqual(['vol']); + expect(duplicated).toEqual(['vol']); + }); + + it('ignores slices with no acquisition number', () => { + const chunks = [0, 2.5, 5, 6.25, 8.75].map((z) => chunk(z)); + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: chunks, + }); + + expect(Object.keys(volumes)).toEqual(['vol']); + expect(duplicated).toEqual([]); + }); + + it('keeps each group in slice order', () => { + const acq1 = stack('1', -20, 5); + const acq2 = stack('2', -19.25, 6); + const merged = [...acq1, ...acq2].sort(byPosition); + + const { volumes } = splitOverlappingAcquisitions({ vol: merged }); + + Object.values(volumes).forEach((group) => { + expect(group).toEqual([...group].sort(byPosition)); + }); + }); + + it('handles several volumes independently', () => { + const good = stack('1', 0, 4); + const acqA = stack('1', -20, 5); + const acqB = stack('2', -19.25, 6); + + const { volumes } = splitOverlappingAcquisitions({ + good, + mixed: [...acqA, ...acqB].sort(byPosition), + }); + + expect(Object.keys(volumes).sort()).toEqual(['good', 'mixed.1', 'mixed.2']); + }); + + it('encodes non-alphanumeric tag values into the volume id', () => { + const acq1 = stack('1.5', -20, 5); + const acq2 = stack('2.5', -19.25, 6); + + const { volumes } = splitOverlappingAcquisitions({ + vol: [...acq1, ...acq2].sort(byPosition), + }); + + expect(Object.keys(volumes).sort()).toEqual(['vol.1D5', 'vol.2D5']); + }); +}); + +describe('splitOverlappingAcquisitions temporal and echo discriminators', () => { + it('separates DCE timepoints that re-scan one range', () => { + const merged = fixtureChunks(dceEightPhase); + + const { volumes, duplicated, labels } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes).sort()).toEqual( + ['0', '1', '2', '3', '4', '5', '6', '7'].map((p) => `vol.${p}`) + ); + expect(duplicated).toEqual([]); + expect(labels['vol.0']).toBe('phase 0'); + expect(volumes['vol.3']).toHaveLength(80); + }); + + it('separates timepoints across vendors and 1-based numbering', () => { + const merged = fixtureChunks(dceSevenPhasePhilips); + + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes)).toHaveLength(7); + expect(duplicated).toEqual([]); + Object.values(volumes).forEach((group) => { + expect(group).toHaveLength(158); + }); + }); + + it('separates Dixon echoes that nothing else distinguishes', () => { + const merged = fixtureChunks(dixonDualEcho); + + const { volumes, duplicated, labels } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes).sort()).toEqual(['vol.1', 'vol.2']); + expect(labels['vol.1']).toBe('echo 1'); + expect(labels['vol.2']).toBe('echo 2'); + expect(duplicated).toEqual([]); + }); + + it('separates echoes with unequal slice counts', () => { + const merged = fixtureChunks(dixonUnevenEchoes); + + const { volumes } = splitOverlappingAcquisitions({ vol: merged }); + + expect(volumes['vol.1']).toHaveLength(13); + expect(volumes['vol.2']).toHaveLength(12); + }); + + 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 a discriminator. + const merged = fixtureChunks(bilateralSagittalSlabs); + + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes)).toEqual(['vol']); + expect(duplicated).toEqual([]); + }); + + it('splits on acquisition when the temporal tag is partially present', () => { + const merged = fixtureChunks(partialTemporalTags); + + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes).sort()).toEqual( + ['1', '2', '3', '4', '5', '6', '7'].map((a) => `vol.${a}`) + ); + expect(volumes['vol.1']).toHaveLength(72); + expect(duplicated).toEqual([]); + }); + + it('keeps sequential timepoints merged', () => { + const phase = (value: string, start: number, count: number) => + Array.from({ length: count }, (_, i) => + chunk(start + i * 5, { [Tags.TemporalPositionIdentifier]: value }) + ); + const merged = [...phase('1', 0, 5), ...phase('2', 30, 5)].sort(byPosition); + + const { volumes } = splitOverlappingAcquisitions({ vol: merged }); + + expect(Object.keys(volumes)).toEqual(['vol']); + }); + + it('splits a doubled acquisition and still warns on it', () => { + const merged = fixtureChunks(doubledAcquisition); + + const { volumes, duplicated } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes).sort()).toEqual(['vol.1', 'vol.2']); + expect(volumes['vol.1']).toHaveLength(74); + expect(volumes['vol.2']).toHaveLength(148); + expect(duplicated).toEqual(['vol.2']); + }); + + it('recurses into each timepoint of a 4D multi-echo series', () => { + // Synthetic: no IDC case observed with two active levels. Pins the + // hierarchical ID shape and label rendering. + const merged = ['1', '2'] + .flatMap((phase) => + ['1', '2'].flatMap((echo) => + Array.from({ length: 10 }, (_, i) => + chunk(i * 2, { + [Tags.AcquisitionNumber]: '1', + [Tags.TemporalPositionIdentifier]: phase, + [Tags.EchoNumbers]: echo, + }) + ) + ) + ) + .sort(byPosition); + + const { volumes, labels } = splitOverlappingAcquisitions({ vol: merged }); + + expect(Object.keys(volumes).sort()).toEqual([ + 'vol.1.1', + 'vol.1.2', + 'vol.2.1', + 'vol.2.2', + ]); + expect(labels['vol.2.1']).toBe('phase 2, echo 1'); + Object.values(volumes).forEach((group) => { + expect(group).toHaveLength(10); + }); + }); + + it('falls back unsplit when tag values collide into one ID', () => { + // '+1' and '-1' both encode to 'D1'; splitting would silently drop one + // group's chunks, so the volume must pass through whole and be reported. + const merged = [...stack('+1', 0, 5), ...stack('-1', 0, 5)].sort( + byPosition + ); + + const { volumes, duplicated, collided } = splitOverlappingAcquisitions({ + vol: merged, + }); + + expect(Object.keys(volumes)).toEqual(['vol']); + expect(collided).toEqual(['vol']); + expect(duplicated).toEqual(['vol']); + expect(volumes.vol).toHaveLength(10); + }); +}); diff --git a/src/utils/dicom/dicomChunks.ts b/src/utils/dicom/dicomChunks.ts index df4d900e5..a9fe6394e 100644 --- a/src/utils/dicom/dicomChunks.ts +++ b/src/utils/dicom/dicomChunks.ts @@ -2,6 +2,17 @@ import { vec3 } from 'gl-matrix'; import { Chunk } from '@/src/core/streaming/chunk'; import { Maybe } from '@/src/types'; +/** + * Value of one tag, keyed 'gggg|eeee'. + * + * Chunk metadata is the whole file header, so this scans rather than + * materializing a Map: per-chunk paths read a handful of tags and a Map per + * chunk would retain a second copy of every header for the session. + */ +export function getChunkTag(chunk: Chunk, tag: string) { + return chunk.metadata?.find(([key]) => key === tag)?.[1]; +} + /** Chunk metadata as a Map. For reading many tags off a single chunk. */ export const getChunkMetadata = (chunk: Chunk) => new Map(chunk.metadata ?? []); diff --git a/src/utils/dicom/splitOverlappingAcquisitions.ts b/src/utils/dicom/splitOverlappingAcquisitions.ts new file mode 100644 index 000000000..d1715f71a --- /dev/null +++ b/src/utils/dicom/splitOverlappingAcquisitions.ts @@ -0,0 +1,233 @@ +import { vec3 } from 'gl-matrix'; +import { Chunk } from '@/src/core/streaming/chunk'; +import { Tags } from '@/src/core/dicomTags'; +import { + getChunkTag, + getSliceNormal, + toVec, +} from '@/src/utils/dicom/dicomChunks'; + +// Tags tried, in order, as the identity of a single scan. A tag that does not +// split hands the volume to the next tag; a tag that does split recurses into +// each part with the remaining tags, so a 4D multi-echo series separates on +// both axes. Every entry traces to an IDC series in +// __tests__/idcSeriesFixtures.ts. StackID (0020|9056) is absent on purpose: +// bilateral slab series put two stacks in one sound volume. +const SPLIT_TAGS = [ + { tag: Tags.AcquisitionNumber, label: 'acquisition' }, + { tag: Tags.TemporalPositionIdentifier, label: 'phase' }, + { tag: Tags.EchoNumbers, label: 'echo' }, +]; + +/** + * Distance of each chunk along the normal of the first slice. + * + * Computed once per volume and threaded through the split, since every later + * question (does this group overlap, does this part repeat a position) is + * answered from the same numbers. Returns null when orientation or position + * is missing, since nothing can be judged about a volume whose geometry + * cannot be read. + */ +function getSlicePositions(chunks: Chunk[]) { + const orientation = toVec( + getChunkTag(chunks[0], Tags.ImageOrientationPatient) + ); + if (orientation?.length !== 6) return null; + + const normal = getSliceNormal(orientation); + + const positions = new Map(); + for (let i = 0; i < chunks.length; i += 1) { + const position = toVec(getChunkTag(chunks[i], Tags.ImagePositionPatient)); + if (position?.length !== 3) return null; + positions.set(chunks[i], vec3.dot(normal, position as vec3)); + } + return positions; +} + +type Positions = Map; + +/** Two slices at one position cannot both belong to the same volume. */ +function hasDuplicatePositions(chunks: Chunk[], positions: Positions) { + const seen = new Set(chunks.map((chunk) => positions.get(chunk))); + return seen.size !== chunks.length; +} + +export function hasDuplicateSlicePositions(chunks: Chunk[]) { + const positions = getSlicePositions(chunks); + return positions ? hasDuplicatePositions(chunks, positions) : false; +} + +function groupByTag(chunks: Chunk[], tag: string) { + const groups = new Map(); + for (let i = 0; i < chunks.length; i += 1) { + const value = getChunkTag(chunks[i], tag)?.trim(); + if (!value) return null; + const group = groups.get(value); + if (group) group.push(chunks[i]); + else groups.set(value, [chunks[i]]); + } + return groups; +} + +/** + * Whether any two groups cover overlapping stretches of the slice axis. + * + * Sorting by lower bound and sweeping keeps this linear in the number of + * groups, which is unbounded: scanners that set AcquisitionNumber from + * InstanceNumber yield one group per slice. + * + * Bounds are closed, so scans that merely share a boundary slice count as + * overlapping: that shared position is a duplicate either way. + */ +function anySpansOverlap(spans: { min: number; max: number }[]) { + const sorted = [...spans].sort((a, b) => a.min - b.min); + let reach = -Infinity; + for (let i = 0; i < sorted.length; i += 1) { + if (sorted[i].min <= reach) return true; + reach = Math.max(reach, sorted[i].max); + } + return false; +} + +function getSpan(chunks: Chunk[], positions: Positions) { + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < chunks.length; i += 1) { + const position = positions.get(chunks[i])!; + if (position < min) min = position; + if (position > max) max = position; + } + return { min, max }; +} + +/** + * Groups of slices that cover overlapping stretches of the slice axis. + * + * Scans that follow one another along the axis are one volume between them and + * are left merged, however their acquisition is numbered. Scans that cover the + * same stretch twice are not, whatever their spacing works out to. + */ +function findOverlappingGroups( + chunks: Chunk[], + tag: string, + positions: Positions +) { + const groups = groupByTag(chunks, tag); + if (!groups || groups.size < 2) return null; + + const entries = [...groups.entries()].map(([value, group]) => ({ + value, + chunks: group, + })); + const spans = entries.map((entry) => getSpan(entry.chunks, positions)); + return anySpansOverlap(spans) ? entries : null; +} + +// Mirrors the volume ID suffixing done by the itk-wasm categorize pipeline: +// keep the part alphanumeric so IDs stay roughly UID shaped. +const encodeIdPart = (value: string) => value.replace(/[^A-Za-z0-9]/g, 'D'); + +type SplitPart = { suffixes: string[]; labels: string[]; chunks: Chunk[] }; + +/** + * Separates chunks on the first of `tags` that yields overlapping groups, then + * re-examines each part with the remaining tags. Returns null when no tag + * splits. + */ +function splitByTags( + chunks: Chunk[], + positions: Positions, + tags: typeof SPLIT_TAGS = SPLIT_TAGS +): SplitPart[] | null { + const [head, ...rest] = tags; + if (!head) return null; + + const groups = findOverlappingGroups(chunks, head.tag, positions); + if (!groups) return splitByTags(chunks, positions, rest); + + return groups.flatMap(({ value, chunks: group }) => { + const suffix = encodeIdPart(value); + const partLabel = `${head.label} ${value}`; + const nested = splitByTags(group, positions, rest); + if (!nested) { + return [{ suffixes: [suffix], labels: [partLabel], chunks: group }]; + } + return nested.map((child) => ({ + suffixes: [suffix, ...child.suffixes], + labels: [partLabel, ...child.labels], + chunks: child.chunks, + })); + }); +} + +/** + * Separates volumes that hold more than one scan of the same anatomy. + * + * A single DICOM series can carry several scans of one Z range: overlapping + * acquisitions, DCE timepoints, or Dixon echoes. The itk-wasm categorize + * pipeline keeps them together because its grouping key covers none of those + * tags, and merging them interleaves slices from different passes and derives + * a Z spacing from a lattice none of them sit on. + * + * The test is whether per-tag-value groups cover overlapping stretches of the + * slice axis, decided by comparing positions rather than by measuring how even + * the spacing looks. Series whose groups follow one another along the axis are + * passed through and keep their chunk array identity. + * + * Each split volume's ID appends one encoded tag value per splitting level, + * with a display name for the split ('acquisition 2', 'phase 3, echo 1') in + * `labels`. Volumes still holding two slices at one position are listed in + * `duplicated`. When two distinct tag values encode to one ID, splitting would + * silently drop chunks, so the volume is passed through unsplit and listed in + * `collided`. + * + * Deliberately conservative in two places: one overlapping pair separates + * every group at that level, including groups that sequentially continue one + * another, and series interleaved along a dimension no listed tag captures + * (private-tag b-values, repeat breath-holds separated only by + * AcquisitionTime) stay merged and are only reported through `duplicated`. + */ +export function splitOverlappingAcquisitions( + chunksByVolume: Record +) { + const duplicated: string[] = []; + const collided: string[] = []; + const labels: Record = {}; + + const entries = Object.entries(chunksByVolume).flatMap( + ([volumeId, chunks]) => { + // Unreadable geometry: nothing can be judged, so pass the volume + // through as the pipeline grouped it. + const positions = getSlicePositions(chunks); + if (!positions) return [[volumeId, chunks] as const]; + + const split = splitByTags(chunks, positions); + if (split) { + const ids = split.map( + ({ suffixes }) => `${volumeId}.${suffixes.join('.')}` + ); + if (new Set(ids).size === ids.length) { + return split.map((part, i) => { + labels[ids[i]] = part.labels.join(', '); + if (hasDuplicatePositions(part.chunks, positions)) { + duplicated.push(ids[i]); + } + return [ids[i], part.chunks] as const; + }); + } + collided.push(volumeId); + } + + if (hasDuplicatePositions(chunks, positions)) duplicated.push(volumeId); + return [[volumeId, chunks] as const]; + } + ); + + return { + volumes: Object.fromEntries(entries), + duplicated, + collided, + labels, + }; +} diff --git a/tests/specs/multi-acquisition-series.e2e.ts b/tests/specs/multi-acquisition-series.e2e.ts new file mode 100644 index 000000000..8003be589 --- /dev/null +++ b/tests/specs/multi-acquisition-series.e2e.ts @@ -0,0 +1,101 @@ +// One DICOM series carrying several overlapping acquisitions must load as one +// volume per acquisition, not as a single stack of interleaved slices. +// +// GDCM's series-detail key covers SeriesNumber, SliceThickness, Rows and +// Columns but not AcquisitionNumber, so the categorize pipeline hands back all +// of these slices as one volume. Sorted by position they no longer sit on any +// single lattice, and the derived Z spacing describes none of the scans. +// +// Modelled on IDC series +// 1.3.6.1.4.1.14519.5.2.1.3098.5025.295130953269492004748715270821, which +// holds three 2.5mm chest/abdomen passes offset from each other by fractions +// of a slice. Synthetic DICOMs are generated on the fly so the test carries +// no binary fixtures. +import * as path from 'path'; +import * as fs from 'fs'; +import { volViewPage } from '../pageobjects/volview.page'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { writeManifestToFile } from './utils'; +import { buildSyntheticDicom, newUid } from './syntheticDicom'; + +const SLICE_SPACING = 2.5; + +// Each pass is uniform on its own; the sub-slice offsets are what make the +// merged stack irregular. Slice counts differ so each volume card is +// identifiable by its label alone. +const ACQUISITIONS = [ + { number: 1, firstSliceZ: 0, sliceCount: 5 }, + { number: 2, firstSliceZ: 0.75, sliceCount: 6 }, + { number: 3, firstSliceZ: 1.75, sliceCount: 4 }, +]; + +const DIR_NAME = 'multi-acquisition-series'; +const MANIFEST_NAME = 'multi-acquisition-series.json'; + +async function writeSeries() { + const studyUid = newUid(); + const seriesUid = newUid(); + const dir = path.join(TEMP_DIR, DIR_NAME); + fs.mkdirSync(dir, { recursive: true }); + + let instanceNumber = 0; + const resources = ACQUISITIONS.flatMap( + ({ number, firstSliceZ, sliceCount }) => + Array.from({ length: sliceCount }, (_, i) => { + instanceNumber += 1; + const filename = `acq${number}-slice${i}.dcm`; + fs.writeFileSync( + path.join(dir, filename), + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + instanceNumber, + acquisitionNumber: number, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [0, 0, firstSliceZ + i * SLICE_SPACING], + sliceThickness: SLICE_SPACING, + }) + ); + return { url: `tmp/${DIR_NAME}/${filename}`, name: filename }; + }) + ); + + await writeManifestToFile({ resources }, MANIFEST_NAME); +} + +// The card label carries the slice count as "[N]". +async function getVolumeCardSliceCounts() { + const cards = [...(await $$('.volume-card'))]; + const labels = await Promise.all(cards.map((card) => card.getText())); + return labels + .map((label) => Number(label.match(/\[(\d+)\]/)?.[1])) + .filter(Number.isFinite) + .sort((a, b) => a - b); +} + +describe('Multi-acquisition series: one series holding three overlapping scans', () => { + before(async () => { + await writeSeries(); + }); + + it('loads one volume per acquisition instead of one interleaved stack', async () => { + await volViewPage.open(`?urls=[tmp/${MANIFEST_NAME}]`); + await volViewPage.waitForViews(); + + const expected = ACQUISITIONS.map((a) => a.sliceCount).sort( + (a, b) => a - b + ); + + await browser.waitUntil( + async () => (await getVolumeCardSliceCounts()).length === expected.length, + { + timeout: 30000, + timeoutMsg: `expected ${expected.length} labelled volume cards`, + } + ); + + // One card per acquisition, each holding only that acquisition's slices. + expect(await getVolumeCardSliceCounts()).toEqual(expected); + }); +}); diff --git a/tests/specs/syntheticDicom.ts b/tests/specs/syntheticDicom.ts index 70744bcee..d52f584f1 100644 --- a/tests/specs/syntheticDicom.ts +++ b/tests/specs/syntheticDicom.ts @@ -1,6 +1,7 @@ // Minimal synthetic DICOM (Explicit VR Little Endian) for tests. // Emits just enough tags for ITK/GDCM to categorize and load a series: -// SOP Class/Instance UIDs, Study/SeriesInstanceUID, SeriesNumber, Modality, +// SOP Class/Instance UIDs, Study/SeriesInstanceUID, SeriesNumber, +// AcquisitionNumber, Modality, // Patient identifiers, ImageOrientationPatient, ImagePositionPatient, // PixelSpacing, SliceThickness, image geometry, and zeroed PixelData. @@ -115,6 +116,7 @@ export type SyntheticSliceOptions = { patientName?: string; patientId?: string; seriesNumber?: number; + acquisitionNumber?: number; studyDate?: string; }; @@ -135,6 +137,7 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { patientName = 'TEST', patientId = 'TEST001', seriesNumber = 1, + acquisitionNumber, studyDate = '20260101', } = opts; @@ -156,6 +159,9 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { ui(0x0020, 0x000e, seriesUid), sh(0x0020, 0x0010, '1'), is(0x0020, 0x0011, String(seriesNumber)), + ...(acquisitionNumber == null + ? [] + : [is(0x0020, 0x0012, String(acquisitionNumber))]), is(0x0020, 0x0013, String(instanceNumber)), ds( 0x0020,