diff --git a/packages/nifti-volume-loader/src/cornerstoneNiftiImageLoader.ts b/packages/nifti-volume-loader/src/cornerstoneNiftiImageLoader.ts index 4be0a8e643..44d2b3fd40 100644 --- a/packages/nifti-volume-loader/src/cornerstoneNiftiImageLoader.ts +++ b/packages/nifti-volume-loader/src/cornerstoneNiftiImageLoader.ts @@ -1,9 +1,3 @@ -// Here we ideally could have a server that responds with range reads, -// and we could use the fetch API to load the imageId for that specific slice. -// However, we can safely assume the server can only provide the whole volume at once. -// So, we just fetch the entire volume by streaming. -// We create images one by one when their corresponding slice is ready. -// We then create the image and let Cornerstone handle the texture upload and rendering. import type { Types } from '@cornerstonejs/core'; import { Enums, @@ -26,7 +20,358 @@ type NiftiDataFetchState = scalarData: Types.PixelDataTypedArray; }; +type HeaderValue = string | null | undefined; +type HeaderMap = Record; +type VoiLutMetadata = { + windowCenter?: number | number[]; + windowWidth?: number | number[]; + voiLUTFunction?: string; +}; +type ModalityLutMetadata = { + rescaleSlope?: number; + rescaleIntercept?: number; +}; +type NiftiLoadingOverlay = { + wrap: HTMLDivElement; + label: HTMLDivElement; + barFill: HTMLSpanElement; + reposition: () => void; + repositionInterval: ReturnType; +}; + const dataFetchStateMap: Map = new Map(); +let niftiLoadingOverlay: NiftiLoadingOverlay | null = null; + +const PARALLEL_NUM_PARTS = 6; +const PARALLEL_MIN_SIZE = 32 * 1024 * 1024; + +function findNiftiOverlayTarget(): HTMLElement | null { + if (typeof document === 'undefined') { + return null; + } + + const selectors = [ + '[data-cy="viewport-grid"]', + '[class*="ViewportGrid"]', + '[class*="viewport-grid"]', + '#layoutManagerTarget', + '#layoutContent', + ]; + + for (const selector of selectors) { + const element = document.querySelector(selector); + if (element && element.clientWidth > 0 && element.clientHeight > 0) { + return element; + } + } + + const viewports = document.querySelectorAll( + '.cornerstone-viewport-element, .viewport-element' + ); + + if (viewports.length === 1) { + let element = viewports[0].parentElement; + + while ( + element && + element.parentElement && + element.clientWidth === viewports[0].clientWidth && + element.clientHeight === viewports[0].clientHeight + ) { + element = element.parentElement; + } + + return element; + } + + if (viewports.length > 1) { + let lowestCommonAncestor = viewports[0].parentElement; + + while ( + lowestCommonAncestor && + !Array.from(viewports).every((viewport) => + lowestCommonAncestor?.contains(viewport) + ) + ) { + lowestCommonAncestor = lowestCommonAncestor.parentElement; + } + + if (lowestCommonAncestor) { + return lowestCommonAncestor; + } + } + + return null; +} + +function ensureNiftiLoadingOverlay(): NiftiLoadingOverlay | null { + if (niftiLoadingOverlay || typeof document === 'undefined') { + return niftiLoadingOverlay; + } + + const target = findNiftiOverlayTarget(); + const wrap = document.createElement('div'); + wrap.id = 'nifti-loading-overlay'; + + const applyPosition = () => { + if (target) { + const rect = target.getBoundingClientRect(); + wrap.style.top = rect.top + 'px'; + wrap.style.left = rect.left + 'px'; + wrap.style.width = rect.width + 'px'; + wrap.style.height = rect.height + 'px'; + } else { + wrap.style.top = '0'; + wrap.style.left = '0'; + wrap.style.width = '100vw'; + wrap.style.height = '100vh'; + } + }; + + wrap.style.cssText = [ + 'position:fixed', + 'display:flex', + 'align-items:center', + 'justify-content:center', + 'flex-direction:column', + 'gap:14px', + 'background:rgba(0,0,0,0.55)', + 'backdrop-filter:blur(2px)', + '-webkit-backdrop-filter:blur(2px)', + 'z-index:2147483647', + 'font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif', + 'color:#fff', + 'pointer-events:all', + ].join(';'); + + applyPosition(); + + const style = document.createElement('style'); + style.textContent = + '@keyframes nifti-spin{to{transform:rotate(360deg)}}' + + '#nifti-loading-overlay .nifti-spinner{width:56px;height:56px;border:5px solid rgba(255,255,255,0.2);border-top-color:#5acbfa;border-radius:50%;animation:nifti-spin 0.9s linear infinite}' + + '#nifti-loading-overlay .nifti-bar{width:260px;height:6px;border-radius:3px;background:rgba(255,255,255,0.2);overflow:hidden}' + + '#nifti-loading-overlay .nifti-bar>span{display:block;height:100%;width:0%;background:#5acbfa;transition:width .15s linear}' + + '#nifti-loading-overlay .nifti-label{font-size:14px;letter-spacing:0.02em;text-shadow:0 1px 2px rgba(0,0,0,0.6)}'; + wrap.appendChild(style); + + const spinner = document.createElement('div'); + spinner.className = 'nifti-spinner'; + + const label = document.createElement('div'); + label.className = 'nifti-label'; + label.textContent = 'Loading volume...'; + + const bar = document.createElement('div'); + bar.className = 'nifti-bar'; + + const barFill = document.createElement('span'); + bar.appendChild(barFill); + + wrap.appendChild(spinner); + wrap.appendChild(label); + wrap.appendChild(bar); + document.body.appendChild(wrap); + + const reposition = () => applyPosition(); + window.addEventListener('resize', reposition); + const repositionInterval = setInterval(reposition, 250); + + niftiLoadingOverlay = { + wrap, + label, + barFill, + reposition, + repositionInterval, + }; + + return niftiLoadingOverlay; +} + +function updateNiftiLoadingOverlay(loaded: number, total?: number): void { + const overlay = ensureNiftiLoadingOverlay(); + + if (!overlay) { + return; + } + + if (total && Number.isFinite(total) && total > 0) { + const percent = Math.max(0, Math.min(100, (loaded / total) * 100)); + overlay.barFill.style.width = percent.toFixed(1) + '%'; + + const mb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); + overlay.label.textContent = + 'Loading volume... ' + + percent.toFixed(0) + + '% (' + + mb(loaded) + + ' / ' + + mb(total) + + ' MB)'; + } else { + const mb = (loaded / (1024 * 1024)).toFixed(1); + overlay.label.textContent = 'Loading volume... ' + mb + ' MB'; + } +} + +function hideNiftiLoadingOverlay(): void { + if (!niftiLoadingOverlay) { + return; + } + + const { wrap, reposition, repositionInterval } = niftiLoadingOverlay; + niftiLoadingOverlay = null; + + window.removeEventListener('resize', reposition); + clearInterval(repositionInterval); + + if (wrap.parentNode) { + wrap.parentNode.removeChild(wrap); + } +} + +async function fetchArrayBufferParallel({ + url, + signal, + onload, + onProgress, +}: { + url: string; + signal?: AbortSignal; + onload?: () => void; + onProgress?: (loaded: number, total: number) => void; +}): Promise { + const options = getOptions(); + + const buildHeaders = async ( + extra?: Record + ): Promise => { + const defaultHeaders = {} as Record; + let beforeSendHeaders: Record = {}; + + try { + beforeSendHeaders = + ((await (options as any).beforeSend?.(null, defaultHeaders, url)) as + | Record + | undefined) || {}; + } catch { + beforeSendHeaders = {}; + } + + const merged = Object.assign( + {}, + defaultHeaders, + beforeSendHeaders, + extra || {} + ) as Record; + + const cleanHeaders: HeaderMap = {}; + Object.keys(merged).forEach((key) => { + const value = merged[key]; + if (value !== null && value !== undefined) { + cleanHeaders[key] = value; + } + }); + + return cleanHeaders; + }; + + let total = 0; + let acceptsRanges = false; + + try { + const head = await fetch(url, { + method: 'HEAD', + signal, + headers: await buildHeaders(), + }); + + if (head.ok) { + total = parseInt(head.headers.get('Content-Length') || '0', 10) || 0; + acceptsRanges = + (head.headers.get('Accept-Ranges') || '').toLowerCase() === 'bytes'; + } + } catch { + acceptsRanges = false; + } + + if (!total || !acceptsRanges || total < PARALLEL_MIN_SIZE) { + return null; + } + + const partSize = Math.ceil(total / PARALLEL_NUM_PARTS); + const partBuffers: Uint8Array[] = new Array(PARALLEL_NUM_PARTS); + const perPartLoaded = new Array(PARALLEL_NUM_PARTS).fill(0); + + const reportProgress = () => { + const loaded = perPartLoaded.reduce((sum, value) => sum + value, 0); + onProgress?.(loaded, total); + }; + + const fetchPart = async (index: number) => { + const start = index * partSize; + const end = Math.min(start + partSize - 1, total - 1); + const response = await fetch(url, { + signal, + headers: await buildHeaders({ Range: `bytes=${start}-${end}` }), + }); + + if (!response.ok && response.status !== 206) { + throw new Error('range status ' + response.status); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('range response body missing'); + } + + const chunks: Uint8Array[] = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + chunks.push(value); + received += value.length; + perPartLoaded[index] = received; + reportProgress(); + } + + const buffer = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.length; + } + + partBuffers[index] = buffer; + }; + + try { + await Promise.all( + Array.from({ length: PARALLEL_NUM_PARTS }, (_, index) => fetchPart(index)) + ); + } catch (error) { + console.warn( + '[nifti-loader] parallel fetch failed, falling back:', + (error as Error)?.message + ); + return null; + } + + const result = new Uint8Array(total); + let offset = 0; + for (const part of partBuffers) { + result.set(part, offset); + offset += part.length; + } + + onload?.(); + return result.buffer; +} function fetchArrayBuffer({ url, @@ -38,60 +383,85 @@ function fetchArrayBuffer({ onload?: () => void; }): Promise { return new Promise(async (resolve, reject) => { + try { + const parallelResult = await fetchArrayBufferParallel({ + url, + signal, + onload, + onProgress: (loaded, total) => { + const data = { url, loaded, total }; + triggerEvent(eventTarget, Events.NIFTI_VOLUME_PROGRESS, { data }); + updateNiftiLoadingOverlay(loaded, total); + }, + }); + + if (parallelResult) { + resolve(parallelResult); + return; + } + } catch (error) { + console.warn( + '[nifti-loader] parallel path threw, falling back:', + (error as Error)?.message + ); + } + const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); const defaultHeaders = {} as Record; const options = getOptions(); - - const beforeSendHeaders = await options.beforeSend( + const beforeSendHeaders = await options.beforeSend?.( xhr, defaultHeaders, url ); - const headers = Object.assign({}, defaultHeaders, beforeSendHeaders); xhr.responseType = 'arraybuffer'; - Object.keys(headers).forEach(function (key) { + Object.keys(headers).forEach((key) => { if (headers[key] === null) { return; } xhr.setRequestHeader(key, headers[key]); }); - const onLoadHandler = function (e) { - if (onload && typeof onload === 'function') { - onload(); - } + const onLoadHandler = () => { + onload?.(); - // Remove event listener for 'abort' if (signal) { signal.removeEventListener('abort', onAbortHandler); } - resolve(xhr.response); + resolve(xhr.response as ArrayBuffer); }; const onAbortHandler = () => { xhr.abort(); - - // Remove event listener for 'load' xhr.removeEventListener('load', onLoadHandler); - + hideNiftiLoadingOverlay(); reject(new Error('Request aborted')); }; xhr.addEventListener('load', onLoadHandler); + xhr.addEventListener('error', () => { + hideNiftiLoadingOverlay(); + }); + xhr.addEventListener('abort', () => { + hideNiftiLoadingOverlay(); + }); - const onProgress = (loaded, total) => { + const onProgress = (loaded: number, total: number) => { const data = { url, loaded, total }; triggerEvent(eventTarget, Events.NIFTI_VOLUME_PROGRESS, { data }); + updateNiftiLoadingOverlay(loaded, total); }; - xhr.onprogress = function (e) { - onProgress(e.loaded, e.total); + updateNiftiLoadingOverlay(0, 0); + + xhr.onprogress = (event) => { + onProgress(event.loaded, event.total); }; if (signal && signal.aborted) { @@ -148,7 +518,7 @@ export default function cornerstoneNiftiImageLoader( return { promise: promise as Promise, - cancelFn: undefined, // TODO: add proper cancel function + cancelFn: undefined, decache: () => { dataFetchStateMap.delete(url); }, @@ -162,37 +532,45 @@ async function fetchAndProcessNiftiData( imagePixelModule: Types.ImagePixelModule, imagePlaneModule: Types.ImagePlaneModule ): Promise { - let niftiBuffer = await fetchArrayBuffer({ url }); - let niftiHeader = null; - let niftiImage = null; - - if (NiftiReader.isCompressed(niftiBuffer)) { - niftiBuffer = NiftiReader.decompress(niftiBuffer); - } + try { + let niftiBuffer = await fetchArrayBuffer({ url }); + let niftiHeader = null; + let niftiImage = null; - if (NiftiReader.isNIFTI(niftiBuffer)) { - niftiHeader = NiftiReader.readHeader(niftiBuffer); - niftiImage = NiftiReader.readImage(niftiHeader, niftiBuffer); - } else { - const errorMessage = 'The provided buffer is not a valid NIFTI file.'; - console.warn(errorMessage); - throw new Error(errorMessage); - } + if (NiftiReader.isCompressed(niftiBuffer)) { + niftiBuffer = NiftiReader.decompress(niftiBuffer); + } - const { scalarData } = modalityScaleNifti(niftiHeader, niftiImage); - dataFetchStateMap.set(url, { status: 'fetched', scalarData }); + if (NiftiReader.isNIFTI(niftiBuffer)) { + niftiHeader = NiftiReader.readHeader(niftiBuffer); + niftiImage = NiftiReader.readImage(niftiHeader, niftiBuffer); + } else { + const errorMessage = 'The provided buffer is not a valid NIFTI file.'; + console.warn(errorMessage); + throw new Error(errorMessage); + } - return createImage( - imageId, - sliceIndex, - imagePixelModule, - imagePlaneModule, - scalarData - ) as unknown as Types.IImage; + const { scalarData } = modalityScaleNifti(niftiHeader, niftiImage); + dataFetchStateMap.set(url, { status: 'fetched', scalarData }); + + const image = createImage( + imageId, + sliceIndex, + imagePixelModule, + imagePlaneModule, + scalarData + ) as unknown as Types.IImage; + + hideNiftiLoadingOverlay(); + return image; + } catch (error) { + hideNiftiLoadingOverlay(); + throw error; + } } function waitForNiftiData( - imageId, + imageId: string, url: string, sliceIndex: number, imagePixelModule: Types.ImagePixelModule, @@ -241,6 +619,18 @@ function createImage( })(numVoxels); pixelData.set(niftiScalarData.subarray(sliceOffset, sliceOffset + numVoxels)); + const rowBuffer = new (niftiScalarData.constructor as { + new (size: number): Types.PixelDataTypedArray; + })(columns); + const half = rows >> 1; + for (let y = 0; y < half; y++) { + const topStart = y * columns; + const bottomStart = (rows - 1 - y) * columns; + rowBuffer.set(pixelData.subarray(topStart, topStart + columns)); + pixelData.copyWithin(topStart, bottomStart, bottomStart + columns); + pixelData.set(rowBuffer, bottomStart); + } + // @ts-ignore const voxelManager = utilities.VoxelManager.createImageVoxelManager({ width: columns, @@ -261,6 +651,39 @@ function createImage( } } + const voiLut = metaData.get('voiLutModule', imageId) as + | VoiLutMetadata + | undefined; + const modalityLut = metaData.get('modalityLutModule', imageId) as + | ModalityLutMetadata + | undefined; + + let windowCenter: number | undefined; + let windowWidth: number | undefined; + + if (voiLut) { + const wc = Array.isArray(voiLut.windowCenter) + ? voiLut.windowCenter[0] + : voiLut.windowCenter; + const ww = Array.isArray(voiLut.windowWidth) + ? voiLut.windowWidth[0] + : voiLut.windowWidth; + + if (Number.isFinite(wc) && Number.isFinite(ww)) { + windowCenter = wc; + windowWidth = ww; + } + } + + const slope = + modalityLut && Number.isFinite(modalityLut.rescaleSlope) + ? modalityLut.rescaleSlope + : 1; + const intercept = + modalityLut && Number.isFinite(modalityLut.rescaleIntercept) + ? modalityLut.rescaleIntercept + : 0; + return { imageId, dataType: niftiScalarData.constructor @@ -279,5 +702,9 @@ function createImage( voxelManager, minPixelValue, maxPixelValue, + windowCenter, + windowWidth, + slope, + intercept, }; } diff --git a/packages/nifti-volume-loader/src/createNiftiImageIdsAndCacheMetadata.ts b/packages/nifti-volume-loader/src/createNiftiImageIdsAndCacheMetadata.ts index f36c3a345b..7dd109d03c 100644 --- a/packages/nifti-volume-loader/src/createNiftiImageIdsAndCacheMetadata.ts +++ b/packages/nifti-volume-loader/src/createNiftiImageIdsAndCacheMetadata.ts @@ -8,16 +8,36 @@ import makeVolumeMetadata from './helpers/makeVolumeMetadata'; import { getArrayConstructor } from './helpers/dataTypeCodeHelper'; import { getOptions } from './internal'; +type DicomMetadata = { + Modality?: string; + SeriesInstanceUID?: string; + SeriesNumber?: number | string; + SeriesDescription?: string; + WindowCenter?: number | string | Array; + WindowWidth?: number | string | Array; + VOILUTFunction?: string; + RescaleSlope?: number | string | Array; + RescaleIntercept?: number | string | Array; +}; + +type HeaderInfo = { + dimensions: number[]; + direction: mat3; + isValid: boolean; + message: string; + origin: number[]; + version: number; + orientation: number[]; + spacing: number[]; + header: unknown; + arrayConstructor: any; +}; + export const urlsMap = new Map(); const NIFTI1_HEADER_SIZE = 348; const NIFTI2_HEADER_SIZE = 540; const HEADER_CHECK_SIZE = Math.max(NIFTI1_HEADER_SIZE, NIFTI2_HEADER_SIZE); -// Note: I spent several hours attempting to use the stream request in dicomImageLoader, -// but I couldn't make the decompression work properly and eventually gave up. -// For some reason, fflate and pako cannot decompress stream data, returning undefined. -// The decompression stream I'm using here also doesn't work correctly -// with the streamRequest in dicomImageLoader for an unknown reason. export async function fetchArrayBuffer({ url, onProgress, @@ -26,8 +46,8 @@ export async function fetchArrayBuffer({ onHeader, loadFullVolume = false, }) { - const _url = new URL(url); - const isCompressed = _url.pathname.endsWith('.gz'); + const parsedUrl = new URL(url); + const isCompressed = parsedUrl.pathname.endsWith('.gz'); let receivedData = new Uint8Array(0); let niftiHeader = null; const sliceInfo = null; @@ -58,7 +78,10 @@ export async function fetchArrayBuffer({ } contentLength = response.headers.get('Content-Length'); - const reader = response.body.getReader(); + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Response body is not readable'); + } const decompressionStream = isCompressed ? new DecompressionStream('gzip') @@ -76,10 +99,9 @@ export async function fetchArrayBuffer({ controller ).catch(console.error); - if (isCompressed) { + if (isCompressed && decompressionStream) { const decompressedStream = decompressionStream.readable.getReader(); - // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await decompressedStream.read(); if (done) { @@ -87,7 +109,7 @@ export async function fetchArrayBuffer({ } processChunk(value); if (niftiHeader && !loadFullVolume) { - controller.abort(); // Abort the fetch request once the header is retrieved + controller.abort(); break; } } @@ -127,10 +149,9 @@ export async function fetchArrayBuffer({ ) { niftiHeader = handleNiftiHeader(receivedData); if (niftiHeader && niftiHeader.isValid) { - controller.abort(); // Abort the fetch request once the header is retrieved + controller.abort(); } - // create imageIds and cache metadata onHeader?.(niftiHeader); } } @@ -144,7 +165,6 @@ async function readStream( processChunk, controller ) { - // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read(); if (done) { @@ -168,18 +188,7 @@ async function readStream( } } -function handleNiftiHeader(data): { - dimensions: number[]; - direction: mat3; - isValid: boolean; - message: string; - origin: number[]; - version: number; - orientation: number[]; - spacing: number[]; - header: unknown; - arrayConstructor: unknown; -} { +function handleNiftiHeader(data): HeaderInfo { if (data.length < HEADER_CHECK_SIZE) { // @ts-ignore return { isValid: false, message: 'Not enough data to check header' }; @@ -188,14 +197,13 @@ function handleNiftiHeader(data): { try { const headerBuffer = data.slice(0, HEADER_CHECK_SIZE).buffer; const header = NiftiReader.readHeader(headerBuffer); - // @ts-ignore const version = header.sizeof_hdr === NIFTI2_HEADER_SIZE ? 2 : 1; const { orientation, origin, spacing } = rasToLps(header); const { dimensions, direction } = makeVolumeMetadata( header, orientation, - 1 // pixelRepresentation + 1 ); const arrayConstructor = getArrayConstructor(header.datatypeCode); @@ -219,7 +227,31 @@ function handleNiftiHeader(data): { } } -async function fetchAndAllocateNiftiVolume(url) { +function toFiniteNumber( + value: number | string | Array | undefined +): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + + if (Array.isArray(value)) { + for (const item of value) { + const numberValue = toFiniteNumber(item); + if (numberValue !== undefined) { + return numberValue; + } + } + return undefined; + } + + const numericValue = typeof value === 'number' ? value : parseFloat(value); + return Number.isFinite(numericValue) ? numericValue : undefined; +} + +async function fetchAndAllocateNiftiVolume( + url: string, + dicomMetadata?: DicomMetadata +) { const niftiURL = url; const onProgress = (loaded, total) => { @@ -242,20 +274,9 @@ async function fetchAndAllocateNiftiVolume(url) { onProgress, controller, onLoad, - onHeader: resolve, // Pass the resolve function to handle image IDs + onHeader: resolve, }); - })) as { - dimensions: number[]; - direction: mat3; - isValid: boolean; - message: string; - origin: number[]; - version: number; - orientation: number[]; - spacing: number[]; - header: unknown; - arrayConstructor: unknown; - }; + })) as HeaderInfo; const { dimensions, @@ -278,8 +299,9 @@ async function fetchAndAllocateNiftiVolume(url) { const imageIds = []; for (let i = 0; i < numImages; i++) { - const imageId = `nifti:${niftiURL}?frame=${i}`; - const imageIdIndex = i; + const frameIndex = numImages - 1 - i; + const imageId = `nifti:${niftiURL}?frame=${frameIndex}`; + const imageIdIndex = frameIndex; imageIds.push(imageId); const imageOrientationPatient = [ @@ -309,7 +331,7 @@ async function fetchAndAllocateNiftiVolume(url) { ) ), ]; - // Create metadata for the image + const imagePlaneMetadata = { frameOfReferenceUID: '1.2.840.10008.1.4', rows: dimensions[1], @@ -319,7 +341,7 @@ async function fetchAndAllocateNiftiVolume(url) { columnCosines: direction.slice(3, 6), imagePositionPatient, sliceThickness: spacing[2], - sliceLocation: origin[2] + i * spacing[2], + sliceLocation: origin[2] + frameIndex * spacing[2], pixelSpacing: [spacing[0], spacing[1]], rowPixelSpacing: spacing[1], columnPixelSpacing: spacing[0], @@ -350,12 +372,12 @@ async function fetchAndAllocateNiftiVolume(url) { }; const generalSeriesMetadata = { - // modality: 'MR', - // seriesInstanceUID: '1.2.840.10008.1.4', - // seriesNumber: 1, - // studyInstanceUID: '1.2.840.10008.1.4', seriesDate: new Date(), seriesTime: new Date(), + modality: dicomMetadata?.Modality, + seriesInstanceUID: dicomMetadata?.SeriesInstanceUID, + seriesNumber: dicomMetadata?.SeriesNumber, + seriesDescription: dicomMetadata?.SeriesDescription, }; utilities.genericMetadataProvider.add(imageId, { @@ -373,6 +395,36 @@ async function fetchAndAllocateNiftiVolume(url) { metadata: generalSeriesMetadata, }); + utilities.genericMetadataProvider.add(imageId, { + type: 'generalImageModule', + metadata: { + instanceNumber: i + 1, + }, + }); + + const windowCenter = toFiniteNumber(dicomMetadata?.WindowCenter); + const windowWidth = toFiniteNumber(dicomMetadata?.WindowWidth); + + if (windowCenter !== undefined && windowWidth !== undefined) { + utilities.genericMetadataProvider.add(imageId, { + type: 'voiLutModule', + metadata: { + windowCenter: [windowCenter], + windowWidth: [windowWidth], + voiLUTFunction: dicomMetadata?.VOILUTFunction || 'LINEAR', + }, + }); + } + + utilities.genericMetadataProvider.add(imageId, { + type: 'modalityLutModule', + metadata: { + rescaleSlope: 1, + rescaleIntercept: 0, + modalityLUTSequence: [], + }, + }); + utilities.genericMetadataProvider.add(imageId, { type: 'niftiVersion', metadata: { @@ -394,8 +446,14 @@ async function fetchAndAllocateNiftiVolume(url) { return imageIds; } -async function createNiftiImageIdsAndCacheMetadata({ url }) { - const imageIds = await fetchAndAllocateNiftiVolume(url); +async function createNiftiImageIdsAndCacheMetadata({ + url, + dicomMetadata, +}: { + url: string; + dicomMetadata?: DicomMetadata; +}) { + const imageIds = await fetchAndAllocateNiftiVolume(url, dicomMetadata); return imageIds; }