From 99df1cd1c1f4cf38fe9a417173a609ec12d7b5f8 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 May 2026 12:17:35 +0100 Subject: [PATCH] Add stripWadouriPrefix function and refactor image ID handling for improved URL normalization --- dicom-viewer/src/App.tsx | 164 ++++++++++++++++++++++++++++++--------- 1 file changed, 127 insertions(+), 37 deletions(-) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 8c8a2c2..6ab78b5 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -166,6 +166,12 @@ function getToolIcon(toolName?: string) { return found?.icon || '🔖'; } +function stripWadouriPrefix(imageId?: string): string { + if (!imageId) return ""; + if (!imageId.startsWith("wadouri:")) return imageId; + return imageId.replace(/^wadouri:+/, "").replace(/^:+/, ""); +} + // Mouse button labels const MOUSE_BUTTONS = [ { label: "Left", value: "Primary" }, @@ -601,7 +607,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer try { const first = imageIds[0]; if (typeof fetch === 'function' && typeof (window as any).dicomParser !== 'undefined' && first.startsWith('wadouri:')) { - const url = first.slice(7); + const url = stripWadouriPrefix(first); const resp = await fetch(url); const buffer = await resp.arrayBuffer(); const ds = (window as any).dicomParser.parseDicom(new Uint8Array(buffer)); @@ -908,7 +914,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer for (const imageId of imageIds) { if (imageId.startsWith("wadouri:")) { try { - const url = imageId.slice(8); + const url = stripWadouriPrefix(imageId); if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { // @ts-ignore const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url); @@ -1018,11 +1024,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer () => Array(MAX_VIEWPORTS).fill(null).map(() => []) ); const viewportStackSignaturesRef = useRef(Array(MAX_VIEWPORTS).fill("")); + const defaultVoiRangeByViewportRef = useRef>(Array(MAX_VIEWPORTS).fill(null)); const [viewportModes, setViewportModes] = useState>( () => Array(MAX_VIEWPORTS).fill('stack') ); - // State for preset menu open/close per viewport const [presetsOpenArr, setPresetsOpenArr] = useState( () => Array(viewportGrid.rows * viewportGrid.cols).fill(false) @@ -1675,7 +1681,27 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const currentImageId = imageIds[currentIndex]; if (currentImageId) { const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any; + const planeMeta = metaData.get("imagePlaneModule", currentImageId) as any; + const pixelMeta = metaData.get("imagePixelModule", currentImageId) as any; modality = seriesMeta?.modality || ""; + if (pixelMeta?.rows && pixelMeta?.columns) { + resolution = `${pixelMeta.columns} x ${pixelMeta.rows}`; + } + const spacingParts = [ + planeMeta?.columnPixelSpacing, + planeMeta?.rowPixelSpacing, + planeMeta?.sliceThickness, + ].filter((value) => value !== undefined && value !== null && value !== ""); + if (spacingParts.length > 0) { + spacing = spacingParts.join(" x "); + } + } + + if (typeof viewport.getCamera === "function") { + const camera = viewport.getCamera(); + if (camera?.focalPoint && Array.isArray(camera.focalPoint)) { + focalPoint = camera.focalPoint.map((value: number) => value.toFixed(1)).join(", "); + } } if (currentIndex >= 0) { @@ -1697,6 +1723,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const { lower, upper } = props.voiRange; windowCenter = ((upper + lower) / 2).toFixed(0); windowWidth = (upper - lower).toFixed(0); + if (defaultVoiRangeByViewportRef.current[viewportIdx] === null) { + defaultVoiRangeByViewportRef.current[viewportIdx] = { lower, upper }; + } } } } else if (viewportModes[viewportIdx] === "volume") { @@ -1721,6 +1750,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const { lower, upper } = props.voiRange; windowCenter = ((upper + lower) / 2).toFixed(0); windowWidth = (upper - lower).toFixed(0); + if (defaultVoiRangeByViewportRef.current[viewportIdx] === null) { + defaultVoiRangeByViewportRef.current[viewportIdx] = { lower, upper }; + } } } // Try to get slice position in mm (distance along viewPlaneNormal from origin) @@ -1787,6 +1819,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`; if (viewportStackSignaturesRef.current[i] !== signature) { viewportStackSignaturesRef.current[i] = signature; + defaultVoiRangeByViewportRef.current[i] = null; next[i] = []; changed = true; } @@ -2290,6 +2323,52 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } }, [updateOverlay]); + const applyDefaultWindowLevel = useCallback((viewportIdx: number, resetCamera = false) => { + const viewportId = `CT_${viewportIdx}`; + try { + const renderingEngine = renderingEngineRef.current; + const viewport = renderingEngine?.getViewport(viewportId); + if (!viewport) return false; + + const ids = typeof viewport.getImageIds === "function" + ? viewport.getImageIds() + : (viewportImageIds[viewportIdx] || []); + const currentIndex = typeof viewport.getCurrentImageIdIndex === "function" + ? viewport.getCurrentImageIdIndex() + : 0; + const imageId = ids[currentIndex] || ids[0]; + const defaultVoi = defaultVoiRangeByViewportRef.current[viewportIdx]; + const voiMeta = imageId ? (metaData.get("voiLutModule", imageId) || {}) as any : {}; + const centerMeta = Array.isArray(voiMeta.windowCenter) ? voiMeta.windowCenter[0] : voiMeta.windowCenter; + const widthMeta = Array.isArray(voiMeta.windowWidth) ? voiMeta.windowWidth[0] : voiMeta.windowWidth; + + if (defaultVoi) { + viewport.setProperties({ voiRange: defaultVoi }); + } else if (Number.isFinite(Number(centerMeta)) && Number.isFinite(Number(widthMeta)) && Number(widthMeta) > 0) { + const center = Number(centerMeta); + const width = Number(widthMeta); + viewport.setProperties({ + voiRange: { + lower: center - width / 2, + upper: center + width / 2, + }, + }); + } else if (typeof viewport.resetProperties === "function") { + viewport.resetProperties(); + } + + if (resetCamera && typeof viewport.resetCamera === "function") { + viewport.resetCamera(); + } + viewport.render(); + updateOverlay(viewportIdx, viewport); + return true; + } catch (err) { + console.warn("Failed to apply default window level", err); + return false; + } + }, [updateOverlay, viewportImageIds]); + const handleAutoLevel = useCallback((viewportIdx: number) => { const viewportId = `CT_${viewportIdx}`; try { @@ -2307,39 +2386,51 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const imageId = ids[currentIndex] || ids[0]; if (!imageId) { - if (typeof viewport.resetProperties === "function") viewport.resetProperties(); - viewport.render(); - updateOverlay(viewportIdx, viewport); + applyDefaultWindowLevel(viewportIdx); return; } const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any; const lutMeta = (metaData.get("modalityLutModule", imageId) || {}) as any; + const voiMeta = (metaData.get("voiLutModule", imageId) || {}) as any; + const defaultVoi = defaultVoiRangeByViewportRef.current[viewportIdx]; const minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue); const maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue); const slope = Number(lutMeta.rescaleSlope ?? 1); const intercept = Number(lutMeta.rescaleIntercept ?? 0); + const centerMeta = Array.isArray(voiMeta.windowCenter) ? voiMeta.windowCenter[0] : voiMeta.windowCenter; + const widthMeta = Array.isArray(voiMeta.windowWidth) ? voiMeta.windowWidth[0] : voiMeta.windowWidth; - if (!Number.isFinite(minRaw) || !Number.isFinite(maxRaw) || maxRaw <= minRaw) { - if (typeof viewport.resetProperties === "function") viewport.resetProperties(); - viewport.render(); - updateOverlay(viewportIdx, viewport); + if (Number.isFinite(minRaw) && Number.isFinite(maxRaw) && maxRaw > minRaw) { + viewport.setProperties({ + voiRange: { + lower: minRaw * slope + intercept, + upper: maxRaw * slope + intercept, + }, + }); + } else if (Number.isFinite(Number(centerMeta)) && Number.isFinite(Number(widthMeta)) && Number(widthMeta) > 0) { + const center = Number(centerMeta); + const width = Number(widthMeta); + viewport.setProperties({ + voiRange: { + lower: center - width / 2, + upper: center + width / 2, + }, + }); + } else if (defaultVoi) { + viewport.setProperties({ voiRange: defaultVoi }); + } else { + applyDefaultWindowLevel(viewportIdx); return; } - viewport.setProperties({ - voiRange: { - lower: minRaw * slope + intercept, - upper: maxRaw * slope + intercept, - }, - }); viewport.render(); updateOverlay(viewportIdx, viewport); } catch (e) { - // Ignore failures and keep existing VOI. + applyDefaultWindowLevel(viewportIdx); } - }, [updateOverlay, viewportImageIds]); + }, [applyDefaultWindowLevel, updateOverlay, viewportImageIds]); const toggleViewerFullscreen = useCallback(() => { const root = appRootRef.current; @@ -2461,7 +2552,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer try { const firstId = wadouriImageIds[0]; if (firstId && firstId.startsWith("wadouri:")) { - const url = firstId.slice(8); + const url = stripWadouriPrefix(firstId); const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); @@ -2686,8 +2777,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer if (!viewport || typeof viewport.getImageIds !== "function") return; const imageIds = viewport.getImageIds(); // Some stacks may have imageIds with or without "wadouri:" prefix, so normalize for comparison - const normImageIds = imageIds.map(id => id.replace(/^wadouri:/, "")); - const normTarget = imageId.replace(/^wadouri:/, ""); + const normImageIds = imageIds.map(id => stripWadouriPrefix(id)); + const normTarget = stripWadouriPrefix(imageId); const idx = normImageIds.indexOf(normTarget); if (idx !== -1 && typeof viewport.setImageIdIndex === "function") { csUtilities.jumpToSlice(viewport.element, { imageIndex: idx }); @@ -2720,7 +2811,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer let uid = studyInstanceUID; if (!uid && imageIds[0]?.startsWith("wadouri:")) { try { - const url = imageIds[0].slice(8); + const url = stripWadouriPrefix(imageIds[0]); const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); @@ -3790,6 +3881,14 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
WC: {imageInfos[i]?.windowCenter}   WW: {imageInfos[i]?.windowWidth}
+ {volumeDiagnosticsEnabled && ( + <> +
Ori: {imageInfos[i]?.orientation || "N/A"}
+ {imageInfos[i]?.resolution &&
Res: {imageInfos[i]?.resolution}
} + {imageInfos[i]?.spacing &&
Spacing: {imageInfos[i]?.spacing}
} + {imageInfos[i]?.focalPoint &&
Pos: {imageInfos[i]?.focalPoint}
} + + )} ) : (
@@ -3907,19 +4006,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer onClick={e => { e.stopPropagation(); // <-- prevent bubbling to viewport if (preset.action === "reset") { - try { - const viewportId = `CT_${i}`; - const renderingEngine = renderingEngineRef.current; - const viewport = renderingEngine?.getViewport(viewportId); - if (viewport) { - if (typeof viewport.resetProperties === "function") viewport.resetProperties(); - if (typeof viewport.resetCamera === "function") viewport.resetCamera(); - if (typeof viewport.render === "function") viewport.render(); - updateOverlay(i, viewport); - } - } catch (err) { - console.warn("Failed to reset viewport properties", err); - } + applyDefaultWindowLevel(i, true); } else if (preset.action === "autolevel") { handleAutoLevel(i); } else { @@ -4722,6 +4809,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer width: "auto", pointerEvents: "auto", transition: "top 180ms ease", + paddingTop: 28, + marginTop: -28, + paddingBottom: 12, }} onMouseEnter={() => setTopControlsPeek(true)} onMouseLeave={() => { @@ -4752,7 +4842,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }} > â›¶ - Viewer + Fullscreen
@@ -4993,7 +5083,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const midIndex = Math.floor(imageIds.length / 2); const chosenImage = imageIds[midIndex] || imageIds[0]; // Remove the wadouri: scheme if present. Use replace to avoid off-by-one slice errors. - const thumbnailUrl = chosenImage ? chosenImage.replace(/^wadouri:/, '') : null; + const thumbnailUrl = chosenImage ? stripWadouriPrefix(chosenImage) : null; return (