Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/core/dicomTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion src/core/streaming/dicomChunkImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
31 changes: 31 additions & 0 deletions src/io/import/__tests__/stateFileLeaves.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
44 changes: 30 additions & 14 deletions src/io/import/importDataSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string> {
const stateIDToStoreID: Record<string, string> = {};
const storeIDsByState = new Map<string, Set<string>>();
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[]) {
Expand All @@ -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';
Expand Down
271 changes: 271 additions & 0 deletions src/store/__tests__/datasets-dicom-split.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading