Skip to content

Commit 83564b8

Browse files
authored
Merge pull request #789 from PaulHax/streaming-onchunk-fix
Streaming dicomChunkImage fixes
2 parents e221616 + 0367026 commit 83564b8

11 files changed

Lines changed: 104 additions & 26 deletions

File tree

src/core/streaming/dicomChunkImage.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -206,11 +206,16 @@ export default class DicomChunkImage
206206
});
207207
this.onChunksUpdated();
208208

209+
if (this.getModality() !== 'SEG') {
210+
this.reallocateImage();
211+
}
212+
209213
this.registerChunkListeners();
210214
this.processNewChunks(newChunks);
211215

216+
// Update data range with already loaded chunks after reallocating image
212217
if (this.getModality() !== 'SEG') {
213-
this.reallocateImage();
218+
this.updateDataRangeFromChunks();
214219
}
215220
}
216221

@@ -235,18 +240,22 @@ export default class DicomChunkImage
235240
}
236241

237242
private processNewChunks(chunks: Chunk[]) {
238-
chunks
239-
.filter((chunk) => chunk.state === ChunkState.Loaded)
240-
.forEach((_, idx) => {
241-
this.onChunkHasData(idx);
243+
chunks.forEach((chunk, idx) => {
244+
if (chunk.state !== ChunkState.Loaded) return;
245+
246+
this.onChunkHasData(idx).catch((err) => {
247+
this.onChunkErrored(idx, err);
242248
});
249+
});
243250
}
244251

245252
private registerChunkListeners() {
246253
this.chunkListeners = [
247254
...this.chunks.map((chunk, index) => {
248255
const stopDoneData = chunk.addEventListener('doneData', () => {
249-
this.onChunkHasData(index);
256+
this.onChunkHasData(index).catch((err) => {
257+
this.onChunkErrored(index, err);
258+
});
250259
});
251260

252261
const stopError = chunk.addEventListener('error', (err) => {
@@ -270,13 +279,17 @@ export default class DicomChunkImage
270279
private reallocateImage() {
271280
this.vtkImageData.value.delete();
272281
this.vtkImageData.value = allocateImageFromChunks(this.chunks);
282+
}
273283

274-
// recalculate image data's data range, since allocateImageFromChunks doesn't know anything about it
284+
private updateDataRangeFromChunks() {
275285
const scalars = this.vtkImageData.value.getPointData().getScalars();
276-
this.dataRangeFromChunks().forEach(([min, max], compIdx) => {
277-
scalars.setRange({ min, max }, compIdx);
278-
});
279-
scalars.modified(); // so image-stats will trigger update of range
286+
const ranges = this.dataRangeFromChunks();
287+
if (ranges.length > 0) {
288+
ranges.forEach(([min, max], compIdx) => {
289+
scalars.setRange({ min, max }, compIdx);
290+
});
291+
scalars.modified(); // so image-stats will trigger update of range
292+
}
280293
}
281294

282295
private dataRangeFromChunks() {
@@ -309,7 +322,9 @@ export default class DicomChunkImage
309322

310323
private async onSegChunkHasData(chunkIndex: number) {
311324
if (this.chunks.length !== 1 || chunkIndex !== 0)
312-
throw new Error('cannot handle multiple seg files');
325+
throw new Error(
326+
`Cannot handle multiple SEG files. Expected 1 chunk at index 0, got ${this.chunks.length} chunks with current index ${chunkIndex}`
327+
);
313328

314329
const [chunk] = this.chunks;
315330
const results = await buildSegmentGroups(
@@ -327,15 +342,27 @@ export default class DicomChunkImage
327342

328343
private async onRegularChunkHasData(chunkIndex: number) {
329344
const chunk = this.chunks[chunkIndex];
330-
if (!chunk.dataBlob) throw new Error('Chunk does not have data');
345+
if (!chunk.dataBlob)
346+
throw new Error(`Chunk ${chunkIndex} does not have data`);
347+
348+
const chunkId = chunk.metadata ? getChunkId(chunk) : `index-${chunkIndex}`;
331349
const result = await readImage(
332350
new File([chunk.dataBlob], `file-${chunkIndex}.dcm`),
333351
{
334352
webWorker: getWorker(),
335353
}
336354
);
337355

338-
if (!result.image.data) throw new Error('No data read from chunk');
356+
if (!result.image.data)
357+
throw new Error(`No data read from chunk ${chunkId}`);
358+
359+
if (result.image.size[2] > 1 && this.chunks.length > 1) {
360+
// we're trying to load multiple chunks where individual chunks have multiple frames
361+
throw new Error(
362+
`Loading a single volume from multiple DICOM files where individual files contain multiple frames is not supported. ` +
363+
`File ${chunkId} (chunk ${chunkIndex}) contains ${result.image.size[2]} frames.`
364+
);
365+
}
339366

340367
const scalars = this.vtkImageData.value.getPointData().getScalars();
341368
const pixelData = scalars.getData() as TypedArray;

src/core/streaming/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@ export interface Fetcher {
4444
cachedChunks: Uint8Array[];
4545
connected: boolean;
4646
size: number;
47+
contentType?: string;
4748
abortSignal?: AbortSignal;
4849
}

src/io/import/processors/updateUriType.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { Skip } from '@/src/utils/evaluateChain';
22
import StreamingByteReader from '@/src/core/streaming/streamingByteReader';
33
import { ImportHandler, asIntermediateResult } from '@/src/io/import/common';
44
import { getFileMimeFromMagicStream } from '@/src/io/magic';
5+
import { getMimeTypeFromFilename } from '@/src/io/io';
56
import { asCoroutine } from '@/src/utils';
67

78
const DoneSignal = Symbol('DoneSignal');
89

10+
// MIME types that don't need magic byte detection
11+
const TRUSTED_MIME_TYPES = new Set(['application/json', 'application/dicom']);
12+
913
function detectStreamType(stream: ReadableStream) {
1014
return new Promise<string>((resolve, reject) => {
1115
const reader = new StreamingByteReader();
@@ -44,11 +48,37 @@ const updateUriType: ImportHandler = async (dataSource) => {
4448
return Skip;
4549
}
4650

51+
// First, try to determine MIME type from filename extension
52+
const mimeFromFilename = getMimeTypeFromFilename(dataSource.name);
53+
if (mimeFromFilename) {
54+
// Prioritize extension-based MIME type over server-provided type
55+
return asIntermediateResult([
56+
{
57+
...dataSource,
58+
mime: mimeFromFilename,
59+
},
60+
]);
61+
}
62+
4763
const { fetcher } = dataSource;
4864

4965
await fetcher.connect();
66+
67+
// First try to use the Content-Type header from the HTTP response
68+
let mime = fetcher.contentType || '';
69+
70+
// Extract just the MIME type without charset or other parameters
71+
if (mime.includes(';')) {
72+
mime = mime.split(';')[0].trim();
73+
}
74+
75+
// Always get the stream to ensure it's properly teed for later use
5076
const stream = fetcher.getStream();
51-
const mime = await detectStreamType(stream);
77+
78+
// Use magic detection unless we trust the MIME type
79+
if (!TRUSTED_MIME_TYPES.has(mime)) {
80+
mime = await detectStreamType(stream);
81+
}
5282

5383
const streamDataSource = {
5484
...dataSource,

src/io/io.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@ import { FILE_EXTENSIONS, FILE_EXT_TO_MIME, MIME_TYPES } from './mimeTypes';
22
import { Maybe } from '../types';
33
import { getFileMimeFromMagic } from './magic';
44

5+
/**
6+
* Determines MIME type from a filename based on its extension.
7+
*
8+
* @param filename The filename to check
9+
* @returns The MIME type if a known extension is found, null otherwise
10+
*/
11+
export function getMimeTypeFromFilename(filename: string): Maybe<string> {
12+
const supportedExt = [...FILE_EXTENSIONS].find((ext) =>
13+
filename.toLowerCase().endsWith(`.${ext}`)
14+
);
15+
if (supportedExt) {
16+
return FILE_EXT_TO_MIME[supportedExt];
17+
}
18+
return null;
19+
}
20+
521
/**
622
* Determines the file's mime type.
723
*
@@ -17,11 +33,9 @@ export async function getFileMimeType(file: File): Promise<Maybe<string>> {
1733
return FILE_EXT_TO_MIME[fileType];
1834
}
1935

20-
const supportedExt = [...FILE_EXTENSIONS].find((ext) =>
21-
file.name.toLowerCase().endsWith(`.${ext}`)
22-
);
23-
if (supportedExt) {
24-
return FILE_EXT_TO_MIME[supportedExt];
36+
const mimeFromFilename = getMimeTypeFromFilename(file.name);
37+
if (mimeFromFilename) {
38+
return mimeFromFilename;
2539
}
2640

2741
const mimeFromMagic = await getFileMimeFromMagic(file);

src/store/dicom-web/dicom-web-store.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,6 @@ export const useDicomWebStore = defineStore('dicom-web', () => {
202202
state: 'Done',
203203
};
204204
} catch (error) {
205-
console.error(error);
206205
const messageStore = useMessageStore();
207206
messageStore.addError('Failed to load DICOM', error as Error);
208207
volumes.value[volumeKey] = {

src/store/image-cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ProgressiveImageStatus,
55
} from '@/src/core/progressiveImage';
66
import { useIdStore } from '@/src/store/id';
7+
import { useMessageStore } from '@/src/store/messages';
78
import { Maybe } from '@/src/types';
89
import { ImageMetadata } from '@/src/types/image';
910
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
@@ -44,6 +45,9 @@ export const useImageCacheStore = defineStore('image-cache', () => {
4445
const onError = (error: Error) => {
4546
imageErrors[id] ??= [];
4647
imageErrors[id].push(error);
48+
49+
const messageStore = useMessageStore();
50+
messageStore.addError('Error loading DICOM data', error);
4751
};
4852

4953
imageListenerCleanup[id] = () => {

src/store/messages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const useMessageStore = defineStore('message', {
5050
* @param opts an Error, a string containing details, or a MessageOptions
5151
*/
5252
addError(title: string, opts?: Error | string | MessageOptions) {
53+
console.error(title, opts);
54+
5355
if (opts instanceof Error) {
5456
return this._addMessage(
5557
{

src/store/tools/paintProcess.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ export const usePaintProcessStore = defineStore('paintProcess', () => {
142142
showingOriginal: false,
143143
};
144144
} catch (error) {
145-
console.error(`${activeProcessType.value} Operation Failed:`, error);
146145
messageStore.addError(
147146
`${activeProcessType.value} Operation Failed`,
148147
error as Error

src/utils/allocateImageFromChunks.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) {
9999
);
100100
}
101101

102-
const slices = numberOfFrames === null ? sortedChunks.length : numberOfFrames;
102+
// We don't support volumes with multiple chunks/files with multi-frame data at the moment.
103+
// Some CT modality series have NumberOfFrames === 1, so use the number of chunks if more than 1 chunk.
104+
const slices =
105+
sortedChunks.length > 1 ? sortedChunks.length : numberOfFrames ?? 1;
103106
const TypedArrayCtor = getTypedArrayConstructor(
104107
bitsStored,
105108
pixelRepresentation,
@@ -126,7 +129,7 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) {
126129
const zVec = vec3.create();
127130
const firstIPP = imagePositionPatient;
128131
vec3.sub(zVec, lastIPP as vec3, firstIPP as vec3);
129-
const zSpacing = vec3.len(zVec) / (sortedChunks.length - 1) || 1;
132+
const zSpacing = vec3.len(zVec) / (slices - 1) || 1;
130133
const spacing = [...pixelSpacing, zSpacing];
131134
image.setSpacing(spacing);
132135
}

tests/specs/state-manifest.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe.skip('State file manifest.json code', () => {
4343
});
4444

4545
// Dev test
46-
// http://localhost:8080/?&urls=[http://localhost:9999/session.volview-2-1-0-labelmap-tools.zip]
46+
// http://localhost:5173/?&urls=[http://localhost:9999/session.volview-2-1-0-labelmap-tools.zip]
4747
it('has no errors loading version 2.1.0 manifest.json file ', async () => {
4848
const FILE_NAME = 'session.volview-2-1-0-labelmap-tools.zip';
4949

0 commit comments

Comments
 (0)