Add stripWadouriPrefix function and refactor image ID handling for improved URL normalization

This commit is contained in:
Ross
2026-05-18 12:17:35 +01:00
parent f169238057
commit 99df1cd1c1
+124 -34
View File
@@ -166,6 +166,12 @@ function getToolIcon(toolName?: string) {
return found?.icon || '🔖'; 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 // Mouse button labels
const MOUSE_BUTTONS = [ const MOUSE_BUTTONS = [
{ label: "Left", value: "Primary" }, { label: "Left", value: "Primary" },
@@ -601,7 +607,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
try { try {
const first = imageIds[0]; const first = imageIds[0];
if (typeof fetch === 'function' && typeof (window as any).dicomParser !== 'undefined' && first.startsWith('wadouri:')) { 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 resp = await fetch(url);
const buffer = await resp.arrayBuffer(); const buffer = await resp.arrayBuffer();
const ds = (window as any).dicomParser.parseDicom(new Uint8Array(buffer)); 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) { for (const imageId of imageIds) {
if (imageId.startsWith("wadouri:")) { if (imageId.startsWith("wadouri:")) {
try { try {
const url = imageId.slice(8); const url = stripWadouriPrefix(imageId);
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
// @ts-ignore // @ts-ignore
const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url); 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(() => []) () => Array(MAX_VIEWPORTS).fill(null).map(() => [])
); );
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill("")); const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
const defaultVoiRangeByViewportRef = useRef<Array<{ lower: number; upper: number } | null>>(Array(MAX_VIEWPORTS).fill(null));
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>( const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
() => Array(MAX_VIEWPORTS).fill('stack') () => Array(MAX_VIEWPORTS).fill('stack')
); );
// State for preset menu open/close per viewport // State for preset menu open/close per viewport
const [presetsOpenArr, setPresetsOpenArr] = useState<boolean[]>( const [presetsOpenArr, setPresetsOpenArr] = useState<boolean[]>(
() => Array(viewportGrid.rows * viewportGrid.cols).fill(false) () => Array(viewportGrid.rows * viewportGrid.cols).fill(false)
@@ -1675,7 +1681,27 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const currentImageId = imageIds[currentIndex]; const currentImageId = imageIds[currentIndex];
if (currentImageId) { if (currentImageId) {
const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any; 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 || ""; 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) { if (currentIndex >= 0) {
@@ -1697,6 +1723,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const { lower, upper } = props.voiRange; const { lower, upper } = props.voiRange;
windowCenter = ((upper + lower) / 2).toFixed(0); windowCenter = ((upper + lower) / 2).toFixed(0);
windowWidth = (upper - lower).toFixed(0); windowWidth = (upper - lower).toFixed(0);
if (defaultVoiRangeByViewportRef.current[viewportIdx] === null) {
defaultVoiRangeByViewportRef.current[viewportIdx] = { lower, upper };
}
} }
} }
} else if (viewportModes[viewportIdx] === "volume") { } else if (viewportModes[viewportIdx] === "volume") {
@@ -1721,6 +1750,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const { lower, upper } = props.voiRange; const { lower, upper } = props.voiRange;
windowCenter = ((upper + lower) / 2).toFixed(0); windowCenter = ((upper + lower) / 2).toFixed(0);
windowWidth = (upper - lower).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) // 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] || ""}`; const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
if (viewportStackSignaturesRef.current[i] !== signature) { if (viewportStackSignaturesRef.current[i] !== signature) {
viewportStackSignaturesRef.current[i] = signature; viewportStackSignaturesRef.current[i] = signature;
defaultVoiRangeByViewportRef.current[i] = null;
next[i] = []; next[i] = [];
changed = true; changed = true;
} }
@@ -2290,6 +2323,52 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
}, [updateOverlay]); }, [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 handleAutoLevel = useCallback((viewportIdx: number) => {
const viewportId = `CT_${viewportIdx}`; const viewportId = `CT_${viewportIdx}`;
try { try {
@@ -2307,39 +2386,51 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const imageId = ids[currentIndex] || ids[0]; const imageId = ids[currentIndex] || ids[0];
if (!imageId) { if (!imageId) {
if (typeof viewport.resetProperties === "function") viewport.resetProperties(); applyDefaultWindowLevel(viewportIdx);
viewport.render();
updateOverlay(viewportIdx, viewport);
return; return;
} }
const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any; const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any;
const lutMeta = (metaData.get("modalityLutModule", 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 minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue);
const maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue); const maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue);
const slope = Number(lutMeta.rescaleSlope ?? 1); const slope = Number(lutMeta.rescaleSlope ?? 1);
const intercept = Number(lutMeta.rescaleIntercept ?? 0); 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 (Number.isFinite(minRaw) && Number.isFinite(maxRaw) && maxRaw > minRaw) {
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
viewport.render();
updateOverlay(viewportIdx, viewport);
return;
}
viewport.setProperties({ viewport.setProperties({
voiRange: { voiRange: {
lower: minRaw * slope + intercept, lower: minRaw * slope + intercept,
upper: maxRaw * 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.render(); viewport.render();
updateOverlay(viewportIdx, viewport); updateOverlay(viewportIdx, viewport);
} catch (e) { } catch (e) {
// Ignore failures and keep existing VOI. applyDefaultWindowLevel(viewportIdx);
} }
}, [updateOverlay, viewportImageIds]); }, [applyDefaultWindowLevel, updateOverlay, viewportImageIds]);
const toggleViewerFullscreen = useCallback(() => { const toggleViewerFullscreen = useCallback(() => {
const root = appRootRef.current; const root = appRootRef.current;
@@ -2461,7 +2552,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
try { try {
const firstId = wadouriImageIds[0]; const firstId = wadouriImageIds[0];
if (firstId && firstId.startsWith("wadouri:")) { if (firstId && firstId.startsWith("wadouri:")) {
const url = firstId.slice(8); const url = stripWadouriPrefix(firstId);
const response = await fetch(url); const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(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; if (!viewport || typeof viewport.getImageIds !== "function") return;
const imageIds = viewport.getImageIds(); const imageIds = viewport.getImageIds();
// Some stacks may have imageIds with or without "wadouri:" prefix, so normalize for comparison // Some stacks may have imageIds with or without "wadouri:" prefix, so normalize for comparison
const normImageIds = imageIds.map(id => id.replace(/^wadouri:/, "")); const normImageIds = imageIds.map(id => stripWadouriPrefix(id));
const normTarget = imageId.replace(/^wadouri:/, ""); const normTarget = stripWadouriPrefix(imageId);
const idx = normImageIds.indexOf(normTarget); const idx = normImageIds.indexOf(normTarget);
if (idx !== -1 && typeof viewport.setImageIdIndex === "function") { if (idx !== -1 && typeof viewport.setImageIdIndex === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx }); csUtilities.jumpToSlice(viewport.element, { imageIndex: idx });
@@ -2720,7 +2811,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
let uid = studyInstanceUID; let uid = studyInstanceUID;
if (!uid && imageIds[0]?.startsWith("wadouri:")) { if (!uid && imageIds[0]?.startsWith("wadouri:")) {
try { try {
const url = imageIds[0].slice(8); const url = stripWadouriPrefix(imageIds[0]);
const response = await fetch(url); const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
@@ -3790,6 +3881,14 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
<div> <div>
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth} WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
</div> </div>
{volumeDiagnosticsEnabled && (
<>
<div>Ori: {imageInfos[i]?.orientation || "N/A"}</div>
{imageInfos[i]?.resolution && <div>Res: {imageInfos[i]?.resolution}</div>}
{imageInfos[i]?.spacing && <div>Spacing: {imageInfos[i]?.spacing}</div>}
{imageInfos[i]?.focalPoint && <div>Pos: {imageInfos[i]?.focalPoint}</div>}
</>
)}
</> </>
) : ( ) : (
<div> <div>
@@ -3907,19 +4006,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
onClick={e => { onClick={e => {
e.stopPropagation(); // <-- prevent bubbling to viewport e.stopPropagation(); // <-- prevent bubbling to viewport
if (preset.action === "reset") { if (preset.action === "reset") {
try { applyDefaultWindowLevel(i, true);
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);
}
} else if (preset.action === "autolevel") { } else if (preset.action === "autolevel") {
handleAutoLevel(i); handleAutoLevel(i);
} else { } else {
@@ -4722,6 +4809,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
width: "auto", width: "auto",
pointerEvents: "auto", pointerEvents: "auto",
transition: "top 180ms ease", transition: "top 180ms ease",
paddingTop: 28,
marginTop: -28,
paddingBottom: 12,
}} }}
onMouseEnter={() => setTopControlsPeek(true)} onMouseEnter={() => setTopControlsPeek(true)}
onMouseLeave={() => { onMouseLeave={() => {
@@ -4752,7 +4842,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}} }}
> >
<span style={{ fontSize: 16, lineHeight: 1 }}></span> <span style={{ fontSize: 16, lineHeight: 1 }}></span>
<span>Viewer</span> <span>Fullscreen</span>
</button> </button>
<div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "stretch" }}> <div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "stretch" }}>
@@ -4993,7 +5083,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const midIndex = Math.floor(imageIds.length / 2); const midIndex = Math.floor(imageIds.length / 2);
const chosenImage = imageIds[midIndex] || imageIds[0]; const chosenImage = imageIds[midIndex] || imageIds[0];
// Remove the wadouri: scheme if present. Use replace to avoid off-by-one slice errors. // 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 ( return (
<div <div