From 68248737ecc15ada0c20c1071fbcbd8b55750573 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 May 2026 16:31:15 +0100 Subject: [PATCH] Enhance stack management by adding series groups support and improving multiframe handling --- dicom-viewer/src/App.tsx | 625 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 592 insertions(+), 33 deletions(-) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 2e93620..26275f6 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -71,6 +71,15 @@ type StackEntry = { name?: string; caseId?: string; studyId?: string; + seriesGroups?: StackSeriesGroup[]; +}; + +type StackSeriesGroup = { + key: string; + label: string; + imageIds: string[]; + seriesInstanceUID?: string; + bValue?: number | null; }; type ViewportDropZone = "center" | "left" | "right" | "top" | "bottom"; @@ -175,6 +184,36 @@ function stripWadouriPrefix(imageId?: string): string { return imageId.replace(/^wadouri:+/, "").replace(/^:+/, ""); } +function hasFrameQuery(imageId: string): boolean { + return /([?&])frame=\d+/i.test(imageId); +} + +function withFrameQuery(imageId: string, frameNumber: number): string { + const frame = Math.max(1, Math.floor(frameNumber)); + if (hasFrameQuery(imageId)) { + return imageId.replace(/([?&])frame=\d+/i, `$1frame=${frame}`); + } + return `${imageId}${imageId.includes("?") ? "&" : "?"}frame=${frame}`; +} + +function normalizeToWadouriOrExternal(imageId: string): string { + if (imageId.startsWith("wadouri:") || imageId.startsWith("external:")) { + return imageId; + } + return `wadouri:${imageId}`; +} + +function dedupeImageIds(imageIds: string[]): string[] { + const seen = new Set(); + const next: string[] = []; + for (const id of imageIds) { + if (!id || seen.has(id)) continue; + seen.add(id); + next.push(id); + } + return next; +} + function isCanvasLikelyReadyForThumbnail(canvas: HTMLCanvasElement): boolean { try { const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings); @@ -325,6 +364,19 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const [selectedStackIdx, setSelectedStackIdx] = useState( () => Array(MAX_VIEWPORTS).fill(0) ); + const [seriesGroupsByViewport, setSeriesGroupsByViewport] = useState( + () => Array(MAX_VIEWPORTS).fill(null).map(() => []) + ); + const [selectedSeriesIdxByViewport, setSelectedSeriesIdxByViewport] = useState( + () => Array(MAX_VIEWPORTS).fill(0) + ); + const [cinePlayingByViewport, setCinePlayingByViewport] = useState( + () => Array(MAX_VIEWPORTS).fill(false) + ); + const [cineFpsByViewport, setCineFpsByViewport] = useState( + () => Array(MAX_VIEWPORTS).fill(12) + ); + const multiframeCountCacheRef = useRef>({}); // Client-side generated thumbnails cache: stackIndex -> dataURL const [stackThumbnails, setStackThumbnails] = useState>({}); @@ -704,9 +756,38 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const normalized: Array = await Promise.all( stacksRaw.map(async (d) => { if (Array.isArray(d)) { - return { imageIds: d as string[] }; + const expanded = await expandMultiframeImageIds(d as string[]); + return { imageIds: expanded }; } - const imageIds = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []); + const imageIdsRaw = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []); + const imageIds = await expandMultiframeImageIds(imageIdsRaw); + let seriesGroups: StackSeriesGroup[] | undefined; + + if (Array.isArray((d as any).series)) { + const seriesExpanded = await Promise.all( + (d as any).series.map(async (seriesEntry: any, seriesIdx: number) => { + const rawIds = Array.isArray(seriesEntry?.imageIds) + ? seriesEntry.imageIds + : (Array.isArray(seriesEntry?.i) ? seriesEntry.i : []); + const expandedIds = await expandMultiframeImageIds(rawIds); + const rawLabel = typeof seriesEntry?.name === "string" ? seriesEntry.name : ""; + const label = rawLabel.trim() || `Series ${seriesIdx + 1}`; + return { + key: String(seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`), + label, + imageIds: expandedIds, + seriesInstanceUID: seriesEntry?.seriesInstanceUID, + bValue: Number.isFinite(Number(seriesEntry?.bValue)) ? Number(seriesEntry.bValue) : null, + } as StackSeriesGroup; + }) + ); + + const filtered = seriesExpanded.filter((group) => group.imageIds.length > 0); + if (filtered.length > 0) { + seriesGroups = filtered; + } + } + // try to preserve old studyInstanceUID extraction if needed (best-effort) let studyInstanceUID: string | undefined = (d as any).studyInstanceUID; // optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available @@ -726,6 +807,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } return { imageIds, + seriesGroups, studyInstanceUID, studyId: (d as any).studyId, name: (d as any).name, @@ -738,10 +820,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // Seed viewports with first stack if present (preserve old behavior) if (normalized[0]) { - await setLoadedImageIdsAndCacheMeta(normalized[0].imageIds); - setViewportImageIds(Array(MAX_VIEWPORTS).fill(normalized[0].imageIds || [])); + const firstStack = normalized[0]; + const firstSeries = firstStack.seriesGroups?.[0]?.imageIds || firstStack.imageIds; + await setLoadedImageIdsAndCacheMeta(firstSeries); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(firstSeries || [])); + setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstStack.seriesGroups || [])); + setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); } else { setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); + setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => [])); + setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); } }) .catch(err => { @@ -756,8 +844,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const stackObj = availableStacks[stackIdx]; if (!stackObj) return; + let seriesGroups = stackObj.seriesGroups; const stack = stackObj.imageIds; + await setLoadedImageIdsAndCacheMeta(stack, { force: true }); + if (!seriesGroups || seriesGroups.length === 0) { + seriesGroups = deriveSeriesGroupsFromImageIds(stack); + setAvailableStacks(prev => { + const next = [...prev]; + if (!next[stackIdx]) return prev; + next[stackIdx] = { ...next[stackIdx], seriesGroups }; + return next; + }); + } + + const primarySeries = seriesGroups[0]?.imageIds || stack; + setLoadingState("displayset"); try { // Always revert to stack mode when loading a new stack @@ -769,7 +871,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setViewportImageIds(prev => { const next = [...prev]; - next[viewportIdx] = stack; + next[viewportIdx] = primarySeries; return next; }); setSelectedStackIdx(prev => { @@ -777,9 +879,19 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer next[viewportIdx] = stackIdx; return next; }); + setSeriesGroupsByViewport(prev => { + const next = [...prev]; + next[viewportIdx] = seriesGroups || []; + return next; + }); + setSelectedSeriesIdxByViewport(prev => { + const next = [...prev]; + next[viewportIdx] = 0; + return next; + }); // Cache imageIds and load metadata for this stack - await setLoadedImageIdsAndCacheMeta(stack); + await setLoadedImageIdsAndCacheMeta(primarySeries); } finally { setLoadingState(null); } @@ -1047,6 +1159,147 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } }; + const getNumberOfFramesForImageId = useCallback(async (imageId: string): Promise => { + if (!imageId.startsWith("wadouri:") || hasFrameQuery(imageId)) { + return 1; + } + + const normalized = normalizeToWadouriOrExternal(imageId); + const url = stripWadouriPrefix(normalized); + const cacheKey = `wadouri:${url}`; + const cached = multiframeCountCacheRef.current[cacheKey]; + if (cached !== undefined) { + return cached; + } + + const readFramesFromDataSet = (dataSet: any): number => { + if (!dataSet || typeof dataSet.intString !== "function") return 1; + const value = Number(dataSet.intString("x00280008")); + return Number.isFinite(value) && value > 1 ? Math.floor(value) : 1; + }; + + try { + const multiframeMeta = metaData.get("multiframeModule", normalized) as any; + const fromMeta = Number(multiframeMeta?.numberOfFrames); + if (Number.isFinite(fromMeta) && fromMeta > 1) { + multiframeCountCacheRef.current[cacheKey] = Math.floor(fromMeta); + return Math.floor(fromMeta); + } + } catch (e) { + // Ignore metadata lookup failures. + } + + try { + const cacheManager = cornerstoneDICOMImageLoader?.wadouri?.dataSetCacheManager; + if (cacheManager?.get) { + const dataSet = cacheManager.get(url); + const fromCache = readFramesFromDataSet(dataSet); + multiframeCountCacheRef.current[cacheKey] = fromCache; + return fromCache; + } + } catch (e) { + // Ignore cache manager failures. + } + + try { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); + const fromFetch = readFramesFromDataSet(dataSet); + multiframeCountCacheRef.current[cacheKey] = fromFetch; + return fromFetch; + } catch (e) { + multiframeCountCacheRef.current[cacheKey] = 1; + return 1; + } + }, []); + + const expandMultiframeImageIds = useCallback(async (rawImageIds: string[]) => { + const expanded: string[] = []; + + for (const raw of rawImageIds || []) { + const imageId = normalizeToWadouriOrExternal(raw); + if (!imageId.startsWith("wadouri:") || hasFrameQuery(imageId)) { + expanded.push(imageId); + continue; + } + + const frameCount = await getNumberOfFramesForImageId(imageId); + if (frameCount <= 1) { + expanded.push(imageId); + continue; + } + + for (let frame = 1; frame <= frameCount; frame++) { + expanded.push(withFrameQuery(imageId, frame)); + } + } + + return dedupeImageIds(expanded); + }, [getNumberOfFramesForImageId]); + + const deriveSeriesGroupsFromImageIds = useCallback((imageIds: string[]): StackSeriesGroup[] => { + if (!Array.isArray(imageIds) || imageIds.length === 0) { + return []; + } + + const groups = new Map(); + + imageIds.forEach((id) => { + const seriesMeta = (metaData.get("generalSeriesModule", id) || {}) as any; + const imageMeta = (metaData.get("generalImageModule", id) || {}) as any; + const mrMeta = (metaData.get("mrImageModule", id) || {}) as any; + + const seriesInstanceUID = (seriesMeta.seriesInstanceUID || "").trim(); + const seriesDescription = (seriesMeta.seriesDescription || "").trim(); + const seriesNumber = Number(seriesMeta.seriesNumber); + const temporalPosition = Number(imageMeta.temporalPositionIdentifier); + const triggerTime = Number(imageMeta.triggerTime); + const bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue); + const bValue = Number.isFinite(bValueRaw) ? bValueRaw : null; + + const temporalKey = Number.isFinite(temporalPosition) ? `t${temporalPosition}` : ""; + const triggerKey = Number.isFinite(triggerTime) ? `tr${Math.round(triggerTime)}` : ""; + const bKey = bValue !== null ? `b${Math.round(bValue)}` : ""; + + const keyParts = [seriesInstanceUID || "NO_UID", bKey, temporalKey, triggerKey].filter(Boolean); + const groupKey = keyParts.join("|") || "SERIES_0"; + + if (!groups.has(groupKey)) { + groups.set(groupKey, { + imageIds: [], + seriesInstanceUID: seriesInstanceUID || undefined, + seriesDescription: seriesDescription || undefined, + seriesNumber: Number.isFinite(seriesNumber) ? seriesNumber : undefined, + bValue, + }); + } + + groups.get(groupKey)!.imageIds.push(id); + }); + + if (groups.size <= 1) { + return [{ key: "SERIES_0", label: "Series 1", imageIds: dedupeImageIds(imageIds) }]; + } + + const ordered = Array.from(groups.entries()).map(([key, group], idx) => { + const details: string[] = []; + if (group.seriesDescription) details.push(group.seriesDescription); + if (group.seriesNumber !== undefined) details.push(`#${group.seriesNumber}`); + if (group.bValue !== null && group.bValue !== undefined) details.push(`b=${Math.round(group.bValue)}`); + const label = details.length > 0 ? details.join(" • ") : `Series ${idx + 1}`; + return { + key, + label, + imageIds: dedupeImageIds(group.imageIds), + seriesInstanceUID: group.seriesInstanceUID, + bValue: group.bValue, + }; + }); + + return ordered; + }, []); + const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 }) const [viewportMenuOpen, setViewportMenuOpen] = useState(false) @@ -1644,6 +1897,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer let seriesInstanceUID: string | undefined = undefined; let instanceNumber: number | undefined = undefined; let sliceLocation: number | undefined = undefined; + let numberOfFrames = 1; let parsedSuccessfully = false; if (!isImage) { try { @@ -1655,6 +1909,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer if (instStr !== undefined && instStr !== null && instStr !== '') instanceNumber = parseInt(instStr as string, 10); const sliceStr = dataSet.string('x00201041') || dataSet.string('x00201040'); if (sliceStr !== undefined && sliceStr !== null && sliceStr !== '') sliceLocation = parseFloat(sliceStr as string); + const frameStr = dataSet.string('x00280008'); + if (frameStr !== undefined && frameStr !== null && frameStr !== '') { + const parsedFrames = Number(frameStr); + if (Number.isFinite(parsedFrames) && parsedFrames > 1) { + numberOfFrames = Math.floor(parsedFrames); + } + } parsedSuccessfully = true; } catch (e) { // Not a DICOM or failed to parse @@ -1662,7 +1923,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } } const imageId = isImage ? `external:${URL.createObjectURL(file)}` : `wadouri:${URL.createObjectURL(file)}`; - return { file, imageId, studyInstanceUID, seriesInstanceUID, instanceNumber, sliceLocation, isImage, parsedSuccessfully }; + return { file, imageId, studyInstanceUID, seriesInstanceUID, instanceNumber, sliceLocation, numberOfFrames, isImage, parsedSuccessfully }; })); // Group parsed entries by SeriesInstanceUID (fallback to StudyInstanceUID or filename folder) @@ -1690,8 +1951,28 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer if (a.instanceNumber !== undefined && b.instanceNumber !== undefined) return (a.instanceNumber - b.instanceNumber); return (a.file.webkitRelativePath || a.file.name).localeCompare(b.file.webkitRelativePath || b.file.name); }); - const imageIds = entries.map(e => e.imageId); - stacks.push({ imageIds, studyInstanceUID: g.studyInstanceUID, seriesInstanceUID: g.seriesInstanceUID }); + const imageIds = entries.flatMap((e) => { + if (e.isImage || !Number.isFinite(e.numberOfFrames) || e.numberOfFrames <= 1) { + return [e.imageId]; + } + const frames: string[] = []; + for (let frame = 1; frame <= e.numberOfFrames; frame++) { + frames.push(withFrameQuery(e.imageId, frame)); + } + return frames; + }); + stacks.push({ + imageIds, + studyInstanceUID: g.studyInstanceUID, + seriesInstanceUID: g.seriesInstanceUID, + seriesGroups: [{ + key: String(g.seriesInstanceUID || key), + label: `Series ${stacks.length + 1}`, + imageIds, + seriesInstanceUID: g.seriesInstanceUID, + bValue: null, + }], + }); } if (stacks.length === 0) return; // nothing to do @@ -1728,11 +2009,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setViewportImageIds(() => { const arr = Array(MAX_VIEWPORTS).fill([] as string[]); for (let i = 0; i < MAX_VIEWPORTS; i++) { - arr[i] = stacks[i] ? stacks[i].imageIds : stacks[0].imageIds; + const stackForViewport = stacks[i] || stacks[0]; + const firstSeries = stackForViewport?.seriesGroups?.[0]?.imageIds || stackForViewport?.imageIds || []; + arr[i] = firstSeries; } return arr; }); setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0)); + setSeriesGroupsByViewport(() => { + const arr = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]); + for (let i = 0; i < MAX_VIEWPORTS; i++) { + const stackForViewport = stacks[i] || stacks[0]; + arr[i] = stackForViewport?.seriesGroups || []; + } + return arr; + }); + setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); // Cache metadata for all stacks (fire-and-forget) for (const s of stacks) { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -1745,7 +2037,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // set first viewport to the last appended stack setViewportImageIds(vpPrev => { const vpNext = [...vpPrev]; - vpNext[0] = stacks[stacks.length - 1].imageIds; + const latest = stacks[stacks.length - 1]; + vpNext[0] = latest.seriesGroups?.[0]?.imageIds || latest.imageIds; return vpNext; }); setSelectedStackIdx(selPrev => { @@ -1753,6 +2046,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer selNext[0] = next.length - 1; return selNext; }); + setSeriesGroupsByViewport((prevSeries) => { + const nextSeries = [...prevSeries]; + nextSeries[0] = stacks[stacks.length - 1]?.seriesGroups || []; + return nextSeries; + }); + setSelectedSeriesIdxByViewport((prevIdx) => { + const nextIdx = [...prevIdx]; + nextIdx[0] = 0; + return nextIdx; + }); // Cache metadata for appended stacks for (const s of stacks) { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -1972,16 +2275,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer name: string, requestedZone: ViewportDropZone, ) => { - const imageIds = rawImageIds.map(url => - url.startsWith('wadouri:') ? url : `wadouri:${url}` - ); + const imageIds = await expandMultiframeImageIds(rawImageIds.map(normalizeToWadouriOrExternal)); if (imageIds.length === 0) return; + await setLoadedImageIdsAndCacheMeta(imageIds, { force: true }); + const seriesGroups = deriveSeriesGroupsFromImageIds(imageIds); + const primarySeries = seriesGroups[0]?.imageIds || imageIds; // Add the incoming stack to the panel and capture its index. let newStackIdx = 0; setAvailableStacks(prev => { newStackIdx = prev.length; - return [...prev, { imageIds, name }]; + return [...prev, { imageIds, name, seriesGroups }]; }); const resolvedZone = resolveDropZone(requestedZone); @@ -1989,10 +2293,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setLoadingState('displayset'); try { setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; }); - setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = imageIds; return n; }); + setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = primarySeries; return n; }); setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = newStackIdx; return n; }); + setSeriesGroupsByViewport(prev => { + const n = [...prev]; + n[viewportIdx] = seriesGroups; + return n; + }); + setSelectedSeriesIdxByViewport(prev => { + const n = [...prev]; + n[viewportIdx] = 0; + return n; + }); setActiveViewport(viewportIdx); - await setLoadedImageIdsAndCacheMeta(imageIds); + await setLoadedImageIdsAndCacheMeta(primarySeries); } finally { setLoadingState(null); } @@ -2028,6 +2342,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]); const nextModes = Array(MAX_VIEWPORTS).fill('stack') as Array<'stack' | 'volume'>; const nextSelected = Array(MAX_VIEWPORTS).fill(0); + const nextSeriesGroups = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]); + const nextSeriesIdx = Array(MAX_VIEWPORTS).fill(0); for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) { const oldRow = Math.floor(oldIdx / oldCols); @@ -2048,11 +2364,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || []; nextModes[mappedIdx] = viewportModes[oldIdx] || 'stack'; nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0; + nextSeriesGroups[mappedIdx] = seriesGroupsByViewport[oldIdx] || []; + nextSeriesIdx[mappedIdx] = selectedSeriesIdxByViewport[oldIdx] ?? 0; } - nextImageIds[insertedViewportIndex] = imageIds; + nextImageIds[insertedViewportIndex] = primarySeries; nextModes[insertedViewportIndex] = 'stack'; nextSelected[insertedViewportIndex] = newStackIdx; + nextSeriesGroups[insertedViewportIndex] = seriesGroups; + nextSeriesIdx[insertedViewportIndex] = 0; setLoadingState('displayset'); try { @@ -2061,8 +2381,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setViewportImageIds(nextImageIds); setViewportModes(nextModes); setSelectedStackIdx(nextSelected); + setSeriesGroupsByViewport(nextSeriesGroups); + setSelectedSeriesIdxByViewport(nextSeriesIdx); setActiveViewport(insertedViewportIndex); - await setLoadedImageIdsAndCacheMeta(imageIds); + await setLoadedImageIdsAndCacheMeta(primarySeries); } finally { setLoadingState(null); } @@ -2071,6 +2393,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => { const stackObj = availableStacks[stackIdx]; if (!stackObj) return; + let seriesGroups = stackObj.seriesGroups; + const stackImageIds = stackObj.imageIds || []; + + await setLoadedImageIdsAndCacheMeta(stackImageIds, { force: true }); + if (!seriesGroups || seriesGroups.length === 0) { + seriesGroups = deriveSeriesGroupsFromImageIds(stackImageIds); + setAvailableStacks(prev => { + const next = [...prev]; + if (!next[stackIdx]) return prev; + next[stackIdx] = { ...next[stackIdx], seriesGroups }; + return next; + }); + } + const primarySeries = seriesGroups[0]?.imageIds || stackImageIds; const resolvedZone = resolveDropZone(requestedZone); if (resolvedZone === "center") { @@ -2096,6 +2432,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]); const nextModes = Array(MAX_VIEWPORTS).fill("stack") as Array<'stack' | 'volume'>; const nextSelected = Array(MAX_VIEWPORTS).fill(0); + const nextSeriesGroups = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]); + const nextSeriesIdx = Array(MAX_VIEWPORTS).fill(0); const targetNewRow = resolvedZone === "top" ? row : resolvedZone === "bottom" ? row + 1 : row; const targetNewCol = resolvedZone === "left" ? col : resolvedZone === "right" ? col + 1 : col; @@ -2135,11 +2473,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || []; nextModes[mappedIdx] = viewportModes[oldIdx] || "stack"; nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0; + nextSeriesGroups[mappedIdx] = seriesGroupsByViewport[oldIdx] || []; + nextSeriesIdx[mappedIdx] = selectedSeriesIdxByViewport[oldIdx] ?? 0; } - nextImageIds[insertedViewportIndex] = stackObj.imageIds; + nextImageIds[insertedViewportIndex] = primarySeries; nextModes[insertedViewportIndex] = "stack"; nextSelected[insertedViewportIndex] = stackIdx; + nextSeriesGroups[insertedViewportIndex] = seriesGroups || []; + nextSeriesIdx[insertedViewportIndex] = 0; setLoadingState("displayset"); try { @@ -2148,8 +2490,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setViewportImageIds(nextImageIds); setViewportModes(nextModes); setSelectedStackIdx(nextSelected); + setSeriesGroupsByViewport(nextSeriesGroups); + setSelectedSeriesIdxByViewport(nextSeriesIdx); setActiveViewport(insertedViewportIndex); - await setLoadedImageIdsAndCacheMeta(stackObj.imageIds); + await setLoadedImageIdsAndCacheMeta(primarySeries); } finally { setLoadingState(null); } @@ -2788,6 +3132,82 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer return () => window.removeEventListener("keydown", handlePresetHotkey); }, [activeViewport, applyPresetOption, imageInfos]); + const handleSeriesIndexChange = useCallback((viewportIdx: number, nextSeriesIdx: number) => { + const groups = seriesGroupsByViewport[viewportIdx] || []; + if (groups.length <= 1) return; + + const boundedIdx = Math.max(0, Math.min(groups.length - 1, nextSeriesIdx)); + const selectedGroup = groups[boundedIdx]; + if (!selectedGroup || selectedGroup.imageIds.length === 0) return; + + const renderingEngine = renderingEngineRef.current; + const viewport = renderingEngine?.getViewport(`CT_${viewportIdx}`); + + let mappedIndex = 0; + if (viewport && typeof viewport.getCurrentImageIdIndex === "function") { + const oldIds = viewportImageIds[viewportIdx] || []; + const oldTotal = oldIds.length; + const currentIdx = viewport.getCurrentImageIdIndex(); + const ratio = oldTotal > 1 ? currentIdx / (oldTotal - 1) : 0; + mappedIndex = Math.max(0, Math.min(selectedGroup.imageIds.length - 1, Math.round(ratio * Math.max(0, selectedGroup.imageIds.length - 1)))); + } + + setSelectedSeriesIdxByViewport(prev => { + const next = [...prev]; + next[viewportIdx] = boundedIdx; + return next; + }); + setViewportImageIds(prev => { + const next = [...prev]; + next[viewportIdx] = selectedGroup.imageIds; + return next; + }); + + void setLoadedImageIdsAndCacheMeta(selectedGroup.imageIds, { force: true }); + window.setTimeout(() => { + const vp = renderingEngineRef.current?.getViewport(`CT_${viewportIdx}`); + if (!vp || typeof vp.setImageIdIndex !== "function") return; + csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex }); + vp.render(); + updateOverlay(viewportIdx, vp); + }, 25); + }, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds]); + + useEffect(() => { + const timers: number[] = []; + const visibleCount = viewportGrid.rows * viewportGrid.cols; + + for (let i = 0; i < visibleCount; i++) { + if (!cinePlayingByViewport[i]) continue; + if (viewportModes[i] !== "stack") continue; + const ids = viewportImageIds[i] || []; + if (ids.length < 2) continue; + + const fps = Math.max(1, Math.min(60, Math.round(cineFpsByViewport[i] || 12))); + const intervalMs = Math.max(16, Math.round(1000 / fps)); + + const timer = window.setInterval(() => { + const viewport = renderingEngineRef.current?.getViewport(`CT_${i}`); + if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return; + if (typeof viewport.setImageIdIndex !== "function") return; + + const stackIds = viewport.getImageIds?.() || ids; + const total = stackIds.length; + if (total < 2) return; + const currentIdx = viewport.getCurrentImageIdIndex(); + const nextIdx = (currentIdx + 1) % total; + csUtilities.jumpToSlice(viewport.element, { imageIndex: nextIdx }); + viewport.render(); + }, intervalMs); + + timers.push(timer); + } + + return () => { + timers.forEach((timer) => window.clearInterval(timer)); + }; + }, [cineFpsByViewport, cinePlayingByViewport, viewportGrid, viewportImageIds, viewportModes]); + const toggleViewerFullscreen = useCallback(() => { const root = appRootRef.current; if (!root) return; @@ -2902,11 +3322,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // API: Load a stack from a list of wadouri imageIds (strings) window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => { if (!wadouriImageIds || wadouriImageIds.length === 0) return; + const expandedImageIds = await expandMultiframeImageIds(wadouriImageIds); + await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true }); + const seriesGroups = deriveSeriesGroupsFromImageIds(expandedImageIds); + const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds; // Try to extract StudyInstanceUID from the first image if possible let studyInstanceUID: string | undefined = undefined; try { - const firstId = wadouriImageIds[0]; + const firstId = expandedImageIds[0]; if (firstId && firstId.startsWith("wadouri:")) { const url = stripWadouriPrefix(firstId); const response = await fetch(url); @@ -2919,10 +3343,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } setAvailableStacks(prev => { - const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }]; + const next = [...prev, { imageIds: expandedImageIds, studyInstanceUID, seriesGroups }]; setViewportImageIds(vpPrev => { const vpNext = [...vpPrev]; - vpNext[0] = wadouriImageIds; + vpNext[0] = primarySeries; return vpNext; }); setSelectedStackIdx(selPrev => { @@ -2930,7 +3354,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer selNext[0] = next.length - 1; return selNext; }); - setLoadedImageIdsAndCacheMeta(wadouriImageIds); + setSeriesGroupsByViewport((prevSeries) => { + const nextSeries = [...prevSeries]; + nextSeries[0] = seriesGroups; + return nextSeries; + }); + setSelectedSeriesIdxByViewport((prevIdx) => { + const nextIdx = [...prevIdx]; + nextIdx[0] = 0; + return nextIdx; + }); + setLoadedImageIdsAndCacheMeta(primarySeries); return next; }); }; @@ -2939,7 +3373,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer return () => { window.loadDicomStackFromWadouriList = undefined; }; - }, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]); + }, [deriveSeriesGroupsFromImageIds, expandMultiframeImageIds, setAvailableStacks, setLoadedImageIdsAndCacheMeta, setSelectedStackIdx, setViewportImageIds]); useEffect(() => { if (!orderBySliceLocation) return; @@ -3059,6 +3493,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setAvailableStacks([]); setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0)); + setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => [])); + setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); + setCinePlayingByViewport(Array(MAX_VIEWPORTS).fill(false)); setViewportModes(Array(MAX_VIEWPORTS).fill('stack')); Object.values(stackThumbnailsRef.current).forEach((thumbnail) => { if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) { @@ -3173,11 +3610,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer window[`loadAdditionalStack_${apiKey}`] = async (imageIds: string[], studyInstanceUID?: string) => { if (!Array.isArray(imageIds) || imageIds.length === 0) return; + const expandedImageIds = await expandMultiframeImageIds(imageIds); + await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true }); + const seriesGroups = deriveSeriesGroupsFromImageIds(expandedImageIds); + const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds; // Optionally extract StudyInstanceUID if not provided let uid = studyInstanceUID; - if (!uid && imageIds[0]?.startsWith("wadouri:")) { + if (!uid && expandedImageIds[0]?.startsWith("wadouri:")) { try { - const url = stripWadouriPrefix(imageIds[0]); + const url = stripWadouriPrefix(expandedImageIds[0]); const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); @@ -3186,17 +3627,27 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // Ignore errors, fallback to undefined } } - setAvailableStacks(prev => [...prev, { imageIds, studyInstanceUID: uid }]); + setAvailableStacks(prev => [...prev, { imageIds: expandedImageIds, studyInstanceUID: uid, seriesGroups }]); // Optionally, auto-populate a viewport with the new stack if there is an empty viewport setViewportImageIds(prev => { const next = [...prev]; const emptyIdx = next.findIndex(ids => !ids || ids.length === 0); if (emptyIdx !== -1) { - next[emptyIdx] = imageIds; + next[emptyIdx] = primarySeries; + setSeriesGroupsByViewport((prevSeries) => { + const nextSeries = [...prevSeries]; + nextSeries[emptyIdx] = seriesGroups; + return nextSeries; + }); + setSelectedSeriesIdxByViewport((prevIdx) => { + const nextIdx = [...prevIdx]; + nextIdx[emptyIdx] = 0; + return nextIdx; + }); } return next; }); - setLoadedImageIdsAndCacheMeta(imageIds); + setLoadedImageIdsAndCacheMeta(primarySeries); }; /** @@ -3233,7 +3684,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const stackIdx = selectedStackIdx.slice(0, numActive); const stacks = availableStacks.map(s => ({ i: s.imageIds, // imageIds - u: s.studyInstanceUID // studyInstanceUID + u: s.studyInstanceUID, // studyInstanceUID + g: s.seriesGroups, })); console.log("Exporting viewer state with", numActive, "active viewports"); console.log("modes", modes); @@ -3306,6 +3758,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({ imageIds: s.i, studyInstanceUID: s.u, + seriesGroups: Array.isArray(s.g) ? s.g : undefined, }))); if (state.stackIdx && state.stacks) { const reconstructed = state.stackIdx.map( @@ -3313,6 +3766,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer ); setViewportImageIds(reconstructed); setSelectedStackIdx(state.stackIdx); + setSeriesGroupsByViewport(() => { + const next = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]); + for (let i = 0; i < Math.min(MAX_VIEWPORTS, state.stackIdx.length); i++) { + const idx = state.stackIdx[i]; + const stack = state.stacks[idx]; + next[i] = Array.isArray(stack?.g) ? stack.g : []; + } + return next; + }); + setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); } // --- Delay the start of tryRestore until after the next paint --- @@ -4450,6 +4913,102 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer )} + {viewportModes[i] === "stack" && ( +
e.stopPropagation()} + > + {imageInfos[i]?.total > 1 && ( + <> + + + + )} + {(seriesGroupsByViewport[i] || []).length > 1 && ( + <> + Series + { + handleSeriesIndexChange(i, Number(e.target.value)); + }} + style={{ width: 140 }} + /> + + {seriesGroupsByViewport[i]?.[selectedSeriesIdxByViewport[i] || 0]?.label || "Series"} + + + )} +
+ )} {/* Position bar inside each viewport */} {viewportModes[i] === "stack" && viewportImageIds[i] && (