Skip to content
Merged
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
55 changes: 41 additions & 14 deletions src/core/streaming/dicomChunkImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand All @@ -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) => {
Expand All @@ -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() {
Expand Down Expand Up @@ -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(
Expand All @@ -327,15 +342,27 @@ 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`),
{
webWorker: getWorker(),
}
);

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;
Expand Down
1 change: 1 addition & 0 deletions src/core/streaming/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@ export interface Fetcher {
cachedChunks: Uint8Array[];
connected: boolean;
size: number;
contentType?: string;
abortSignal?: AbortSignal;
}
32 changes: 31 additions & 1 deletion src/io/import/processors/updateUriType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((resolve, reject) => {
const reader = new StreamingByteReader();
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 19 additions & 5 deletions src/io/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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.
*
Expand All @@ -17,11 +33,9 @@ export async function getFileMimeType(file: File): Promise<Maybe<string>> {
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);
Expand Down
1 change: 0 additions & 1 deletion src/store/dicom-web/dicom-web-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down
4 changes: 4 additions & 0 deletions src/store/image-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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] = () => {
Expand Down
2 changes: 2 additions & 0 deletions src/store/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
1 change: 0 additions & 1 deletion src/store/tools/paintProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/utils/allocateImageFromChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/specs/state-manifest.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 0 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand Down
Loading