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/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/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/__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/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/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..a9fe6394e --- /dev/null +++ b/src/utils/dicom/dicomChunks.ts @@ -0,0 +1,34 @@ +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 ?? []); + +/** 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; +} 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,