From 313c7a9e9ac741088f85492c5a4f5d83ab612e9f Mon Sep 17 00:00:00 2001 From: Ross Date: Sat, 31 May 2025 21:29:37 +0100 Subject: [PATCH] basic viewport save and restore fnuctiontality --- dicom-viewer/index.html | 38 ++ dicom-viewer/src/App.tsx | 1142 ++++++++++++++++++++++++-------------- 2 files changed, 749 insertions(+), 431 deletions(-) diff --git a/dicom-viewer/index.html b/dicom-viewer/index.html index f4f0df1..d878f56 100644 --- a/dicom-viewer/index.html +++ b/dicom-viewer/index.html @@ -12,6 +12,7 @@ + diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 6d74dee..7ed4e24 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -24,6 +24,16 @@ declare global { } } +// Add to global types: +declare global { + interface Window { + exportViewerState?: () => string; + importViewerState?: (json: string) => void; + exportAnnotations?: () => string; + importAnnotations?: (json: string) => void; + } +} + type StackEntry = { imageIds: string[], studyInstanceUID?: string }; // Add this type for clarity @@ -33,6 +43,12 @@ declare global { } } +declare global { + interface Window { + loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => void; + } +} + window.cornerstoneTools = cornerstoneTools; @@ -110,8 +126,8 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false); -const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState(null); -const [openDropdownIdx, setOpenDropdownIdx] = useState(null); + const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState(null); + const [openDropdownIdx, setOpenDropdownIdx] = useState(null); // State for overlay info const [imageInfo, setImageInfo] = useState({ index: 0, @@ -124,9 +140,10 @@ const [openDropdownIdx, setOpenDropdownIdx] = useState(null); const renderingEngineId = "mainRenderingEngine"; const renderingEngineRef = useRef(null); + const restoringStateRef = useRef(false); -const [availableStacks, setAvailableStacks] = useState([]); + const [availableStacks, setAvailableStacks] = useState([]); const [activeViewport, setActiveViewport] = useState(0); const [viewportImageIds, setViewportImageIds] = useState( () => Array(MAX_VIEWPORTS).fill([]) @@ -135,40 +152,41 @@ const [availableStacks, setAvailableStacks] = useState([]); () => Array(MAX_VIEWPORTS).fill(0) ); + // Load available stacks on mount -useEffect(() => { - availableImageIds().then(async stacks => { - // Try to extract StudyInstanceUID for each stack - const stackEntries: StackEntry[] = await Promise.all( - stacks.map(async imageIds => { - let studyInstanceUID: string | undefined = undefined; - try { - // Try to fetch the first image as a blob and parse it - const firstId = imageIds[0]; - if (firstId && firstId.startsWith("wadouri:")) { - const url = firstId.slice(8); - const response = await fetch(url); - const arrayBuffer = await response.arrayBuffer(); - const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); - studyInstanceUID = dataSet.string('x0020000d'); + useEffect(() => { + availableImageIds().then(async stacks => { + // Try to extract StudyInstanceUID for each stack + const stackEntries: StackEntry[] = await Promise.all( + stacks.map(async imageIds => { + let studyInstanceUID: string | undefined = undefined; + try { + // Try to fetch the first image as a blob and parse it + const firstId = imageIds[0]; + if (firstId && firstId.startsWith("wadouri:")) { + const url = firstId.slice(8); + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); + studyInstanceUID = dataSet.string('x0020000d'); + } + } catch (e) { + // Ignore errors, fallback to undefined } - } catch (e) { - // Ignore errors, fallback to undefined - } - return { imageIds, studyInstanceUID }; - }) - ); - setAvailableStacks(stackEntries); - //setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined }))); - setLoadedImageIdsAndCacheMeta(stacks[0]); - setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || [])); - }); -}, []); + return { imageIds, studyInstanceUID }; + }) + ); + setAvailableStacks(stackEntries); + //setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined }))); + setLoadedImageIdsAndCacheMeta(stacks[0]); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || [])); + }); + }, []); const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { - const stackObj = availableStacks[stackIdx]; - if (!stackObj) return; - const stack = stackObj.imageIds; + const stackObj = availableStacks[stackIdx]; + if (!stackObj) return; + const stack = stackObj.imageIds; // Always revert to stack mode when loading a new stack setViewportModes(prev => { @@ -536,39 +554,39 @@ useEffect(() => { const fileInputRef = useRef(null); // Handler for file input change -const handleFilesSelected = async (event: React.ChangeEvent | { target: { files: File[] | FileList } }) => { - const files = event.target.files; - if (!files || files.length === 0) return; + const handleFilesSelected = async (event: React.ChangeEvent | { target: { files: File[] | FileList } }) => { + const files = event.target.files; + if (!files || files.length === 0) return; - setOrderBySliceLocation(false); + setOrderBySliceLocation(false); - const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name)); - let studyInstanceUID: string | undefined = undefined; -const arrayBuffer = await fileArray[0].arrayBuffer(); -const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); + const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name)); + let studyInstanceUID: string | undefined = undefined; + const arrayBuffer = await fileArray[0].arrayBuffer(); + const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); studyInstanceUID = dataSet.string('x0020000d'); console.log("StudyInstanceUID:", studyInstanceUID); - const imageIds = fileArray.map( - file => `wadouri:${URL.createObjectURL(file)}` - ); + const imageIds = fileArray.map( + file => `wadouri:${URL.createObjectURL(file)}` + ); - setAvailableStacks(prev => { - const next = [...prev, { imageIds, studyInstanceUID }]; - setViewportImageIds(vpPrev => { - const vpNext = [...vpPrev]; - vpNext[0] = imageIds; - return vpNext; + setAvailableStacks(prev => { + const next = [...prev, { imageIds, studyInstanceUID }]; + setViewportImageIds(vpPrev => { + const vpNext = [...vpPrev]; + vpNext[0] = imageIds; + return vpNext; + }); + setSelectedStackIdx(selPrev => { + const selNext = [...selPrev]; + selNext[0] = next.length - 1; + return selNext; + }); + setLoadedImageIdsAndCacheMeta(imageIds); + return next; }); - setSelectedStackIdx(selPrev => { - const selNext = [...selPrev]; - selNext[0] = next.length - 1; - return selNext; - }); - setLoadedImageIdsAndCacheMeta(imageIds); - return next; - }); -}; + }; const handleToolChange = (mouseButton: string, toolName: string) => { @@ -629,6 +647,8 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); useEffect(() => { const setup = async () => { + console.log("setup") + // 1. Initialize Cornerstone3D and tools ONCE if (!running.current) { running.current = true; @@ -719,7 +739,7 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); } if (viewportModes[i] === 'stack') { - if (imageIds) { + if (imageIds && !restoringStateRef.current) { viewport.setStack(imageIds); } // CrosshairsTool is not applicable for stack viewports @@ -754,40 +774,43 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); } viewport.render(); - // Restore previous scroll position for existing viewports only - if ( - prevScrollIndices.current[i] !== undefined && - typeof viewport.setImageIdIndex === "function" - ) { - //viewport.setImageIdIndex(prevScrollIndices.current[i]); - csUtilities.jumpToSlice(viewport.element, { imageIndex: prevScrollIndices.current[i] }) - } - - - // Restore previous state for existing viewports only - const prevProps = prevViewportProps.current[i]; - if (prevProps && typeof viewport.setProperties === "function") { - // Only restore safe properties - const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = prevProps; - const propsToRestore: any = {}; - if (voiRange && voiRange.lower !== undefined && voiRange.upper !== undefined) propsToRestore.voiRange = voiRange; - if (invert !== undefined) propsToRestore.invert = invert; - if (interpolationType !== undefined) propsToRestore.interpolationType = interpolationType; - if (VOILUTFunction) propsToRestore.VOILUTFunction = VOILUTFunction; + if (!restoringStateRef.current) { + // Restore previous scroll position for existing viewports only if ( - colormap !== undefined && - colormap !== null && - (typeof colormap === "string" || (typeof colormap === "object" && !("actor" in colormap))) + prevScrollIndices.current[i] !== undefined && + typeof viewport.setImageIdIndex === "function" ) { - propsToRestore.colormap = colormap; + //viewport.setImageIdIndex(prevScrollIndices.current[i]); + csUtilities.jumpToSlice(viewport.element, { imageIndex: prevScrollIndices.current[i] }) } - setTimeout(() => { - viewport.setProperties(propsToRestore); - viewport.render() - }, 100); - } + // Restore previous state for existing viewports only + const prevProps = prevViewportProps.current[i]; + if (prevProps && typeof viewport.setProperties === "function") { + // Only restore safe properties + const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = prevProps; + const propsToRestore: any = {}; + if (voiRange && voiRange.lower !== undefined && voiRange.upper !== undefined) propsToRestore.voiRange = voiRange; + if (invert !== undefined) propsToRestore.invert = invert; + if (interpolationType !== undefined) propsToRestore.interpolationType = interpolationType; + if (VOILUTFunction) propsToRestore.VOILUTFunction = VOILUTFunction; + if ( + colormap !== undefined && + colormap !== null && + (typeof colormap === "string" || (typeof colormap === "object" && !("actor" in colormap))) + ) { + propsToRestore.colormap = colormap; + } + setTimeout(() => { + console.log("Restoring properties (timeout) for viewport", i, propsToRestore); + viewport.setProperties(propsToRestore); + viewport.render() + }, 100); + } + + } + // Always (re-)attach overlay update for each viewport if ((element)._overlayListener) { @@ -868,6 +891,8 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); const viewport = renderingEngine.getViewport(viewportId); if (viewport && typeof viewport.resetCamera === "function") { viewport.resetCamera(); + + console.log("Restoring viewport properties for viewport", viewportIdx); viewport.resetProperties(); // Resets window/level and other properties viewport.render(); } @@ -881,6 +906,7 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); if (renderingEngine) { const viewport = renderingEngine.getViewport(viewportId); if (viewport && typeof viewport.setProperties === "function") { + console.log(`Applying preset to viewport ${viewportIdx}: center=${center}, width=${width}`); viewport.setProperties({ voiRange: { lower: center - width / 2, @@ -948,6 +974,50 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); } }, []); + + useEffect(() => { + // API: Load a stack from a list of wadouri imageIds (strings) + window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => { + if (!wadouriImageIds || wadouriImageIds.length === 0) return; + + // Try to extract StudyInstanceUID from the first image if possible + let studyInstanceUID: string | undefined = undefined; + try { + const firstId = wadouriImageIds[0]; + if (firstId && firstId.startsWith("wadouri:")) { + const url = firstId.slice(8); + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); + studyInstanceUID = dataSet.string('x0020000d'); + } + } catch (e) { + // Ignore errors, fallback to undefined + } + + setAvailableStacks(prev => { + const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }]; + setViewportImageIds(vpPrev => { + const vpNext = [...vpPrev]; + vpNext[0] = wadouriImageIds; + return vpNext; + }); + setSelectedStackIdx(selPrev => { + const selNext = [...selPrev]; + selNext[0] = next.length - 1; + return selNext; + }); + setLoadedImageIdsAndCacheMeta(wadouriImageIds); + return next; + }); + }; + + // Cleanup + return () => { + window.loadDicomStackFromWadouriList = undefined; + }; + }, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]); + useEffect(() => { if (!orderBySliceLocation) return; @@ -1001,11 +1071,187 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); mainToolGroup.setToolDisabled(ReferenceLinesTool.toolName) } } + useEffect(() => { + // Export the current viewer state as JSON + window.exportViewerState = () => { + const numActive = viewportGrid.rows * viewportGrid.cols; + const activeViewportModes = viewportModes.slice(0, numActive); + const activeSelectedStackIdx = selectedStackIdx.slice(0, numActive); + const activeAvailableStacks = availableStacks; + const activeViewportImageIds = viewportImageIds.slice(0, numActive); -// Reference stack and UID for study comparison (use active viewport) -const referenceStackIdx = activeViewport !== null ? selectedStackIdx[activeViewport] : 0; -const referenceStackObj = availableStacks[referenceStackIdx]; -const referenceUID = referenceStackObj?.studyInstanceUID; + // Collect viewport properties, stack positions, and camera for active viewports + const renderingEngine = renderingEngineRef.current; + const viewportProperties: any[] = []; + const stackPositions: number[] = []; + const cameraProperties: any[] = []; + for (let i = 0; i < numActive; i++) { + let props = null; + let stackPos = null; + let camera = null; + if (renderingEngine) { + const viewportId = `CT_${i}`; + const viewport = renderingEngine.getViewport(viewportId); + if (viewport) { + if (typeof viewport.getProperties === "function") { + props = viewport.getProperties(); + } + if (typeof viewport.getCurrentImageIdIndex === "function") { + stackPos = viewport.getCurrentImageIdIndex(); + } + if (typeof viewport.getCamera === "function") { + camera = viewport.getCamera(); + } + } + } + viewportProperties.push(props); + stackPositions.push(stackPos); + cameraProperties.push(camera); + } + + const state = { + viewportGrid, + viewportModes: activeViewportModes, + selectedStackIdx: activeSelectedStackIdx, + availableStacks: activeAvailableStacks, + viewportImageIds: activeViewportImageIds, + viewportProperties, + stackPositions, + cameraProperties, + }; + return JSON.stringify(state, null, 2); + }; + + window.importViewerState = (json: string) => { + try { + console.log("Importing viewer state:", json); + restoringStateRef.current = true; + + const state = typeof json === "string" ? JSON.parse(json) : json; + if (state.viewportGrid) setViewportGrid(state.viewportGrid); + if (state.viewportModes) setViewportModes(state.viewportModes); + if (state.selectedStackIdx) setSelectedStackIdx(state.selectedStackIdx); + if (state.availableStacks) setAvailableStacks(state.availableStacks); + if (state.viewportImageIds) setViewportImageIds(state.viewportImageIds); + + setTimeout(() => { + const renderingEngine = renderingEngineRef.current; + if (!renderingEngine) { + restoringStateRef.current = false; + return; + } + const numActive = state.viewportGrid?.rows * state.viewportGrid?.cols || 1; + for (let i = 0; i < numActive; i++) { + const viewportId = `CT_${i}`; + const viewport = renderingEngine.getViewport(viewportId); + if (!viewport) continue; + + // Restore stack position (scroll) + if ( + state.stackPositions && + typeof state.stackPositions[i] === "number" && + typeof viewport.setImageIdIndex === "function" + ) { + if (csUtilities && csUtilities.jumpToSlice) { + csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPositions[i] }); + } else { + viewport.setImageIdIndex(state.stackPositions[i]); + } + } + + // Restore viewport properties (VOI, invert, etc.) + if ( + state.viewportProperties && + state.viewportProperties[i] && + typeof viewport.setProperties === "function" + ) { + const props = state.viewportProperties[i]; + const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = props; + const propsToRestore: any = {}; + if (voiRange && voiRange.lower !== undefined && voiRange.upper !== undefined) propsToRestore.voiRange = voiRange; + if (invert !== undefined) propsToRestore.invert = invert; + if (interpolationType !== undefined) propsToRestore.interpolationType = interpolationType; + if (VOILUTFunction) propsToRestore.VOILUTFunction = VOILUTFunction; + if ( + colormap !== undefined && + colormap !== null && + (typeof colormap === "string" || (typeof colormap === "object" && !("actor" in colormap))) + ) { + propsToRestore.colormap = colormap; + } + viewport.setProperties(propsToRestore); + viewport.render(); + } + + // Restore camera properties (zoom, pan, orientation) + if ( + state.cameraProperties && + state.cameraProperties[i] && + typeof viewport.setCamera === "function" + ) { + viewport.setCamera(state.cameraProperties[i]); + viewport.render(); + } + } + restoringStateRef.current = false; + console.log("Viewer state restored successfully"); + }, 300); + } catch (e) { + restoringStateRef.current = false; + alert("Failed to import viewer state: " + e); + } + }; + + // Export all annotations using cornerstoneTools.annotation.state.getAllAnnotations() + window.exportAnnotations = () => { + try { + const allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations(); + return JSON.stringify(allAnnotations, null, 2); + } catch (e) { + alert("Failed to export annotations: " + e); + return "{}"; + } + }; + + // Import and restore all annotations using cornerstoneTools.annotation.state.restoreAnnotations() + window.importAnnotations = (json: string) => { + try { + const annotationData = typeof json === "string" ? JSON.parse(json) : json; + cornerstoneTools.annotation.state.restoreAnnotations(annotationData); + if (renderingEngineRef.current) { + renderingEngineRef.current.render(); + } + } catch (e) { + alert("Failed to import annotations: " + e); + } + }; + + // Cleanup + return () => { + window.exportViewerState = undefined; + window.importViewerState = undefined; + window.exportAnnotations = undefined; + window.importAnnotations = undefined; + }; + }, [ + viewportGrid, + viewportModes, + selectedStackIdx, + availableStacks, + viewportImageIds, + setViewportGrid, + setViewportModes, + setSelectedStackIdx, + setAvailableStacks, + setViewportImageIds, + ]); + + + + // Reference stack and UID for study comparison (use active viewport) + const referenceStackIdx = activeViewport !== null ? selectedStackIdx[activeViewport] : 0; + const referenceStackObj = availableStacks[referenceStackIdx]; + const referenceUID = referenceStackObj?.studyInstanceUID; const viewports = []; for (let i = 0; i < MAX_VIEWPORTS; i++) { @@ -1148,51 +1394,51 @@ const referenceUID = referenceStackObj?.studyInstanceUID; )} {/* Fullscreen button below the Volume/Stack toggle */} - + {/* Overlay info (bottom left of viewport) */}
)} -
- {/* Custom Stack Dropdown */} -
- -{openDropdownIdx === i && ( -
- {availableStacks.map((stackObj, idx) => { - const thisUID = stackObj.studyInstanceUID; - const sameStudy = referenceUID && thisUID && referenceUID === thisUID; - const dotColor = sameStudy ? "#4caf50" : "#888"; - return (
{ - e.preventDefault(); - handleLoadStack(i, idx); - setOpenDropdownIdx(null); + justifyContent: "flex-end", + gap: "8px", + background: "rgba(34,34,34,0.85)", + borderRadius: "4px", + padding: "2px 8px", }} > - - Stack {idx + 1} ({stackObj.imageIds.length} images) + {/* Custom Stack Dropdown */} +
+ + {openDropdownIdx === i && ( +
{ + if (el) el.focus(); + }} + tabIndex={0} + style={{ + position: "absolute", + left: 0, + bottom: "110%", + background: "#222", + border: "1px solid #444", + borderRadius: "4px", + boxShadow: "0 2px 8px rgba(0,0,0,0.3)", + zIndex: 100, + minWidth: 180, + maxHeight: 220, + overflowY: "auto", + overscrollBehavior: "contain", + scrollbarWidth: "thin", + }} + role="listbox" + onMouseDown={e => e.stopPropagation()} + onWheel={e => { + // Trap scroll inside the dropdown + const target = e.currentTarget as HTMLDivElement; + const { scrollTop, scrollHeight, clientHeight } = target; + const atTop = scrollTop === 0; + const atBottom = scrollTop + clientHeight >= scrollHeight - 1; + if ( + (e.deltaY < 0 && atTop) || + (e.deltaY > 0 && atBottom) + ) { + // Let the event bubble to parent (viewport) + return; + } + // Otherwise, prevent scrolling the viewport + e.stopPropagation(); + e.preventDefault(); + // Manually scroll the menu + target.scrollTop += e.deltaY; + }} + onBlur={e => { + const related = e.relatedTarget as Node; + const parent = e.currentTarget.parentElement; + if (!parent?.contains(related)) { + setTimeout(() => setOpenDropdownIdx(null), 100); + } + }} + > + {availableStacks.map((stackObj, idx) => { + const thisUID = stackObj.studyInstanceUID; + const sameStudy = referenceUID && thisUID && referenceUID === thisUID; + const dotColor = sameStudy ? "#4caf50" : "#888"; + return ( +
{ + e.preventDefault(); + handleLoadStack(i, idx); + setOpenDropdownIdx(null); + }} + > + + Stack {idx + 1} ({stackObj.imageIds.length} images) +
+ ); + })} +
+ )} +
- ); - })} -
-)} -
-
); } @@ -1547,200 +1827,200 @@ const referenceUID = referenceStackObj?.studyInstanceUID; }}>
{/* Side menu toggle button */} - + -{/* Side menu drawer */} -
-
-
- Menu - -
- - + {/* Side menu drawer */} +
+
+ Menu + +
+ setOrderBySliceLocation(e.target.checked)} - style={{ accentColor: "#666" }} + ref={fileInputRef} + type="file" + style={{ display: "none" }} + multiple + onChange={handleFilesSelected} + accept=".dcm,application/dicom" /> - -
- - - - - - {/* Add more menu items here if needed */} +
+ setOrderBySliceLocation(e.target.checked)} + style={{ accentColor: "#666" }} + /> + +
+ + + + + + {/* Add more menu items here if needed */} -
+
{/* Viewport grid menu at top center */}