diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 326955b07..05c7199f0 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -206,11 +206,16 @@ export default class DicomChunkImage }); this.onChunksUpdated(); + if (this.getModality() !== 'SEG') { + this.reallocateImage(); + } + this.registerChunkListeners(); this.processNewChunks(newChunks); + // Update data range with already loaded chunks after reallocating image if (this.getModality() !== 'SEG') { - this.reallocateImage(); + this.updateDataRangeFromChunks(); } } @@ -235,18 +240,22 @@ export default class DicomChunkImage } private processNewChunks(chunks: Chunk[]) { - chunks - .filter((chunk) => chunk.state === ChunkState.Loaded) - .forEach((_, idx) => { - this.onChunkHasData(idx); + chunks.forEach((chunk, idx) => { + if (chunk.state !== ChunkState.Loaded) return; + + this.onChunkHasData(idx).catch((err) => { + this.onChunkErrored(idx, err); }); + }); } private registerChunkListeners() { this.chunkListeners = [ ...this.chunks.map((chunk, index) => { const stopDoneData = chunk.addEventListener('doneData', () => { - this.onChunkHasData(index); + this.onChunkHasData(index).catch((err) => { + this.onChunkErrored(index, err); + }); }); const stopError = chunk.addEventListener('error', (err) => { @@ -270,13 +279,17 @@ export default class DicomChunkImage private reallocateImage() { this.vtkImageData.value.delete(); this.vtkImageData.value = allocateImageFromChunks(this.chunks); + } - // recalculate image data's data range, since allocateImageFromChunks doesn't know anything about it + private updateDataRangeFromChunks() { const scalars = this.vtkImageData.value.getPointData().getScalars(); - this.dataRangeFromChunks().forEach(([min, max], compIdx) => { - scalars.setRange({ min, max }, compIdx); - }); - scalars.modified(); // so image-stats will trigger update of range + const ranges = this.dataRangeFromChunks(); + if (ranges.length > 0) { + ranges.forEach(([min, max], compIdx) => { + scalars.setRange({ min, max }, compIdx); + }); + scalars.modified(); // so image-stats will trigger update of range + } } private dataRangeFromChunks() { @@ -309,7 +322,9 @@ export default class DicomChunkImage private async onSegChunkHasData(chunkIndex: number) { if (this.chunks.length !== 1 || chunkIndex !== 0) - throw new Error('cannot handle multiple seg files'); + throw new Error( + `Cannot handle multiple SEG files. Expected 1 chunk at index 0, got ${this.chunks.length} chunks with current index ${chunkIndex}` + ); const [chunk] = this.chunks; const results = await buildSegmentGroups( @@ -327,7 +342,10 @@ export default class DicomChunkImage private async onRegularChunkHasData(chunkIndex: number) { const chunk = this.chunks[chunkIndex]; - if (!chunk.dataBlob) throw new Error('Chunk does not have data'); + if (!chunk.dataBlob) + throw new Error(`Chunk ${chunkIndex} does not have data`); + + const chunkId = chunk.metadata ? getChunkId(chunk) : `index-${chunkIndex}`; const result = await readImage( new File([chunk.dataBlob], `file-${chunkIndex}.dcm`), { @@ -335,7 +353,16 @@ export default class DicomChunkImage } ); - if (!result.image.data) throw new Error('No data read from chunk'); + if (!result.image.data) + throw new Error(`No data read from chunk ${chunkId}`); + + if (result.image.size[2] > 1 && this.chunks.length > 1) { + // we're trying to load multiple chunks where individual chunks have multiple frames + throw new Error( + `Loading a single volume from multiple DICOM files where individual files contain multiple frames is not supported. ` + + `File ${chunkId} (chunk ${chunkIndex}) contains ${result.image.size[2]} frames.` + ); + } const scalars = this.vtkImageData.value.getPointData().getScalars(); const pixelData = scalars.getData() as TypedArray; diff --git a/src/core/streaming/types.ts b/src/core/streaming/types.ts index 9fe61c5b8..32740cd2c 100644 --- a/src/core/streaming/types.ts +++ b/src/core/streaming/types.ts @@ -44,5 +44,6 @@ export interface Fetcher { cachedChunks: Uint8Array[]; connected: boolean; size: number; + contentType?: string; abortSignal?: AbortSignal; } diff --git a/src/io/import/processors/updateUriType.ts b/src/io/import/processors/updateUriType.ts index 3407f1f93..ef78ec248 100644 --- a/src/io/import/processors/updateUriType.ts +++ b/src/io/import/processors/updateUriType.ts @@ -2,10 +2,14 @@ import { Skip } from '@/src/utils/evaluateChain'; import StreamingByteReader from '@/src/core/streaming/streamingByteReader'; import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; import { getFileMimeFromMagicStream } from '@/src/io/magic'; +import { getMimeTypeFromFilename } from '@/src/io/io'; import { asCoroutine } from '@/src/utils'; const DoneSignal = Symbol('DoneSignal'); +// MIME types that don't need magic byte detection +const TRUSTED_MIME_TYPES = new Set(['application/json', 'application/dicom']); + function detectStreamType(stream: ReadableStream) { return new Promise((resolve, reject) => { const reader = new StreamingByteReader(); @@ -44,11 +48,37 @@ const updateUriType: ImportHandler = async (dataSource) => { return Skip; } + // First, try to determine MIME type from filename extension + const mimeFromFilename = getMimeTypeFromFilename(dataSource.name); + if (mimeFromFilename) { + // Prioritize extension-based MIME type over server-provided type + return asIntermediateResult([ + { + ...dataSource, + mime: mimeFromFilename, + }, + ]); + } + const { fetcher } = dataSource; await fetcher.connect(); + + // First try to use the Content-Type header from the HTTP response + let mime = fetcher.contentType || ''; + + // Extract just the MIME type without charset or other parameters + if (mime.includes(';')) { + mime = mime.split(';')[0].trim(); + } + + // Always get the stream to ensure it's properly teed for later use const stream = fetcher.getStream(); - const mime = await detectStreamType(stream); + + // Use magic detection unless we trust the MIME type + if (!TRUSTED_MIME_TYPES.has(mime)) { + mime = await detectStreamType(stream); + } const streamDataSource = { ...dataSource, diff --git a/src/io/io.ts b/src/io/io.ts index 0a8afbc43..d6ff81fd9 100644 --- a/src/io/io.ts +++ b/src/io/io.ts @@ -2,6 +2,22 @@ import { FILE_EXTENSIONS, FILE_EXT_TO_MIME, MIME_TYPES } from './mimeTypes'; import { Maybe } from '../types'; import { getFileMimeFromMagic } from './magic'; +/** + * Determines MIME type from a filename based on its extension. + * + * @param filename The filename to check + * @returns The MIME type if a known extension is found, null otherwise + */ +export function getMimeTypeFromFilename(filename: string): Maybe { + const supportedExt = [...FILE_EXTENSIONS].find((ext) => + filename.toLowerCase().endsWith(`.${ext}`) + ); + if (supportedExt) { + return FILE_EXT_TO_MIME[supportedExt]; + } + return null; +} + /** * Determines the file's mime type. * @@ -17,11 +33,9 @@ export async function getFileMimeType(file: File): Promise> { return FILE_EXT_TO_MIME[fileType]; } - const supportedExt = [...FILE_EXTENSIONS].find((ext) => - file.name.toLowerCase().endsWith(`.${ext}`) - ); - if (supportedExt) { - return FILE_EXT_TO_MIME[supportedExt]; + const mimeFromFilename = getMimeTypeFromFilename(file.name); + if (mimeFromFilename) { + return mimeFromFilename; } const mimeFromMagic = await getFileMimeFromMagic(file); diff --git a/src/store/dicom-web/dicom-web-store.ts b/src/store/dicom-web/dicom-web-store.ts index acbd0c56d..254b648b6 100644 --- a/src/store/dicom-web/dicom-web-store.ts +++ b/src/store/dicom-web/dicom-web-store.ts @@ -202,7 +202,6 @@ export const useDicomWebStore = defineStore('dicom-web', () => { state: 'Done', }; } catch (error) { - console.error(error); const messageStore = useMessageStore(); messageStore.addError('Failed to load DICOM', error as Error); volumes.value[volumeKey] = { diff --git a/src/store/image-cache.ts b/src/store/image-cache.ts index b07d782f4..01e99e9e8 100644 --- a/src/store/image-cache.ts +++ b/src/store/image-cache.ts @@ -4,6 +4,7 @@ import { ProgressiveImageStatus, } from '@/src/core/progressiveImage'; import { useIdStore } from '@/src/store/id'; +import { useMessageStore } from '@/src/store/messages'; import { Maybe } from '@/src/types'; import { ImageMetadata } from '@/src/types/image'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; @@ -44,6 +45,9 @@ export const useImageCacheStore = defineStore('image-cache', () => { const onError = (error: Error) => { imageErrors[id] ??= []; imageErrors[id].push(error); + + const messageStore = useMessageStore(); + messageStore.addError('Error loading DICOM data', error); }; imageListenerCleanup[id] = () => { diff --git a/src/store/messages.ts b/src/store/messages.ts index a868e9817..12e166c1a 100644 --- a/src/store/messages.ts +++ b/src/store/messages.ts @@ -50,6 +50,8 @@ export const useMessageStore = defineStore('message', { * @param opts an Error, a string containing details, or a MessageOptions */ addError(title: string, opts?: Error | string | MessageOptions) { + console.error(title, opts); + if (opts instanceof Error) { return this._addMessage( { diff --git a/src/store/tools/paintProcess.ts b/src/store/tools/paintProcess.ts index e5cae1435..99d6ea9c3 100644 --- a/src/store/tools/paintProcess.ts +++ b/src/store/tools/paintProcess.ts @@ -142,7 +142,6 @@ export const usePaintProcessStore = defineStore('paintProcess', () => { showingOriginal: false, }; } catch (error) { - console.error(`${activeProcessType.value} Operation Failed:`, error); messageStore.addError( `${activeProcessType.value} Operation Failed`, error as Error diff --git a/src/utils/allocateImageFromChunks.ts b/src/utils/allocateImageFromChunks.ts index 75461c486..def96f3bb 100644 --- a/src/utils/allocateImageFromChunks.ts +++ b/src/utils/allocateImageFromChunks.ts @@ -99,7 +99,10 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) { ); } - const slices = numberOfFrames === null ? sortedChunks.length : numberOfFrames; + // We don't support volumes with multiple chunks/files with multi-frame data at the moment. + // Some CT modality series have NumberOfFrames === 1, so use the number of chunks if more than 1 chunk. + const slices = + sortedChunks.length > 1 ? sortedChunks.length : numberOfFrames ?? 1; const TypedArrayCtor = getTypedArrayConstructor( bitsStored, pixelRepresentation, @@ -126,7 +129,7 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) { const zVec = vec3.create(); const firstIPP = imagePositionPatient; vec3.sub(zVec, lastIPP as vec3, firstIPP as vec3); - const zSpacing = vec3.len(zVec) / (sortedChunks.length - 1) || 1; + const zSpacing = vec3.len(zVec) / (slices - 1) || 1; const spacing = [...pixelSpacing, zSpacing]; image.setSpacing(spacing); } diff --git a/tests/specs/state-manifest.e2e.ts b/tests/specs/state-manifest.e2e.ts index 14eeee96c..fe8324a0e 100644 --- a/tests/specs/state-manifest.e2e.ts +++ b/tests/specs/state-manifest.e2e.ts @@ -43,7 +43,7 @@ describe.skip('State file manifest.json code', () => { }); // Dev test - // http://localhost:8080/?&urls=[http://localhost:9999/session.volview-2-1-0-labelmap-tools.zip] + // http://localhost:5173/?&urls=[http://localhost:9999/session.volview-2-1-0-labelmap-tools.zip] it('has no errors loading version 2.1.0 manifest.json file ', async () => { const FILE_NAME = 'session.volview-2-1-0-labelmap-tools.zip'; diff --git a/vite.config.ts b/vite.config.ts index 9dff41dfe..b136377e4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -213,7 +213,6 @@ export default defineConfig({ configureSentryPlugin(), ], server: { - port: 8080, // so `npm run test:e2e:dev` can access the webdriver static server temp directory proxy: { '/tmp': config.baseUrl!,