From 8c218d42f487c07183f02c1bc55b84ac63aee7b2 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 3 Jun 2025 15:45:11 +0100 Subject: [PATCH] get legacy annotation import working --- dicom-viewer/index.html | 12 +- dicom-viewer/src/App.tsx | 842 +++++++++++++++++++++++--------------- dicom-viewer/src/main.tsx | 5 + 3 files changed, 518 insertions(+), 341 deletions(-) diff --git a/dicom-viewer/index.html b/dicom-viewer/index.html index 23fb46c..7c5650a 100644 --- a/dicom-viewer/index.html +++ b/dicom-viewer/index.html @@ -7,10 +7,16 @@ Vite + React + TS -
-
--> +
+ diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 413ef58..6068580 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -21,9 +21,28 @@ import { NoLabelArrowAnnotateTool } from "./NoLabelArrowAnnotateTool"; // Extend the Window interface to include cornerstoneTools declare global { interface Window { - cornerstoneTools: typeof cornerstoneTools; + cornerstone3dTools: typeof cornerstoneTools; } } +function pixelToWorld(imageId: string, point: { x: number, y: number }) { + const plane = metaData.get("imagePlaneModule", imageId); + if (!plane) return undefined; + const { rowCosines, columnCosines, imagePositionPatient, rowPixelSpacing, columnPixelSpacing } = plane; + if (!rowCosines || !columnCosines || !imagePositionPatient) return undefined; + // Default to 1 if spacing is missing + const dx = columnPixelSpacing || 1; + const dy = rowPixelSpacing || 1; + // Convert cosines to vectors + const rc = rowCosines; + const cc = columnCosines; + const ipp = imagePositionPatient; + // World = IPP + x*col*cos*spacing + y*row*cos*spacing + return [ + ipp[0] + point.x * cc[0] * dx + point.y * rc[0] * dy, + ipp[1] + point.x * cc[1] * dx + point.y * rc[1] * dy, + ipp[2] + point.x * cc[2] * dx + point.y * rc[2] * dy, + ]; +} // Add to global types: declare global { @@ -32,6 +51,8 @@ declare global { importViewerState?: (json: string) => void; exportAnnotations?: () => string; importAnnotations?: (json: string) => void; + importLegacyAnnotations?: (json: string) => void; + importLegacyViewport?: (json: string) => void; } } @@ -50,7 +71,7 @@ declare global { } } -window.cornerstoneTools = cornerstoneTools; +window.cornerstone3dTools = cornerstoneTools; type ViewportDiv = HTMLDivElement & { @@ -116,11 +137,12 @@ const ALL_MOUSE_BINDINGS = [ type AppProps = { container_id?: string; imageStacks: () => Promise; + autoCacheStack?: boolean; // <-- new prop // ...other props if needed }; // App definintion -function App({ container_id, imageStacks, ...props }: AppProps) { +function App({ container_id, imageStacks, autoCacheStack, ...props }: AppProps) { const elementRef = useRef(null) const running = useRef(false) @@ -175,7 +197,7 @@ function App({ container_id, imageStacks, ...props }: AppProps) { ); setAvailableStacks(stackEntries); //setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined }))); - setLoadedImageIdsAndCacheMeta(stacks[0]); + await setLoadedImageIdsAndCacheMeta(stacks[0]); setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || [])); }); }, []); @@ -214,6 +236,7 @@ function App({ container_id, imageStacks, ...props }: AppProps) { const [showAdvancedCtrlMouseBindings, setShowAdvancedCtrlMouseBindings] = useState(false); const sortViewportImageIdsBySliceLocation = () => { + console.log("Sorting viewport imageIds by slice location"); setViewportImageIds(prev => { const next = prev.map((imageIds, idx) => { if (!imageIds || imageIds.length === 0) return imageIds; @@ -255,38 +278,44 @@ function App({ container_id, imageStacks, ...props }: AppProps) { // Wrap setLoadedImageIds to also cache DICOM metadata const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => { + console.log("Setting loaded imageIds:", imageIds); + console.log("autoCacheStack:", autoCacheStack); const loadPromises: Promise[] = []; - for (const imageId of imageIds) { - if (imageId.startsWith("wadouri:")) { - try { - const url = imageId.slice(8); - if (url.startsWith("blob:") || url.startsWith("/")) { - if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { - // If load returns a promise, collect it - // @ts-ignore - const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load( - url, - ); - if (result && typeof result.then === "function") { - loadPromises.push(result); + if (autoCacheStack) { + for (const imageId of imageIds) { + console.log("Caching metadata for imageId:", imageId); + if (imageId.startsWith("wadouri:")) { + try { + const url = imageId.slice(8); + if (url.startsWith("blob:") || url.startsWith("/")) { + if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { + // If load returns a promise, collect it + // @ts-ignore + const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load( + url, + ); + if (result && typeof result.then === "function") { + loadPromises.push(result); + } + } else { + console.warn("MetaDataManager not found. Unable to cache metadata."); } - } else { - console.warn("MetaDataManager not found. Unable to cache metadata."); } + } catch (e) { + console.warn(`Failed to cache metadata for ${imageId}:`, e); } - } catch (e) { - console.warn(`Failed to cache metadata for ${imageId}:`, e); } } - } - // Wait for all metadata loads to finish - await Promise.all(loadPromises); + // Wait for all metadata loads to finish + await Promise.all(loadPromises); - // Trigger sort if enabled - if (orderBySliceLocation) { - sortViewportImageIdsBySliceLocation(); + // Trigger sort if enabled + if (orderBySliceLocation) { + console.log("Sorting viewport imageIds by slice location"); + sortViewportImageIdsBySliceLocation(); + } } }; @@ -667,7 +696,7 @@ function App({ container_id, imageStacks, ...props }: AppProps) { // --- In your setup function, use the correct tool group per viewport --- let mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); if (!mainToolGroup) { - mainToolGroup = setupTools(MAIN_TOOL_GROUP_ID).mainToolGroup; + mainToolGroup = setupTools(MAIN_TOOL_GROUP_ID) } @@ -1249,12 +1278,149 @@ function App({ container_id, imageStacks, ...props }: AppProps) { } }; + window[`importLegacyAnnotations_${apiKey}`] = (json: string) => { + let legacyData: any; + try { + legacyData = typeof json === "string" ? JSON.parse(json) : json; + } catch (e) { + alert("Invalid legacy annotation JSON"); + return; + } + + // Remove all existing annotations + if (cornerstoneTools.annotation.state.removeAllAnnotations) { + cornerstoneTools.annotation.state.removeAllAnnotations(); + } + + const toolNameMap: Record = { + ArrowAnnotate: "ArrowAnnotate", + // Add more mappings if needed + }; + + Object.entries(legacyData).forEach(([imageId, tools]) => { + Object.entries(tools as any).forEach(([legacyTool, toolData]: [string, any]) => { + const cs3Tool = toolNameMap[legacyTool] || legacyTool; + if (toolData.data && Array.isArray(toolData.data)) { + toolData.data.forEach((ann: any) => { + // Convert pixel handles to world coordinates +const startWorld = pixelToWorld(imageId, { x: ann.handles.start.y, y: ann.handles.start.x }); +const endWorld = pixelToWorld(imageId, { x: ann.handles.end.y, y: ann.handles.end.x }); + + console.log("Importing legacy annotation:") + console.log(" Image ID:", imageId); + console.log(" Start World:", startWorld); + console.log(" End World:", endWorld); + + // Get spatial metadata + const plane = metaData.get("imagePlaneModule", imageId); + console.log(metaData); + let FrameOfReferenceUID = metaData.get("frameOfReferenceUID", imageId); + if (!FrameOfReferenceUID && plane && plane.frameOfReferenceUID) { + FrameOfReferenceUID = plane.frameOfReferenceUID; + } + const viewPlaneNormal = plane?.rowCosines && plane?.columnCosines + ? [ + plane.rowCosines[1] * plane.columnCosines[2] - plane.rowCosines[2] * plane.columnCosines[1], + plane.rowCosines[2] * plane.columnCosines[0] - plane.rowCosines[0] * plane.columnCosines[2], + plane.rowCosines[0] * plane.columnCosines[1] - plane.rowCosines[1] * plane.columnCosines[0], + ] + : undefined; + + + + const annotation = { + annotationUID: ann.uuid || cornerstoneTools.utilities.uuidv4(), + metadata: { + toolName: cs3Tool, + referencedImageId: imageId, + FrameOfReferenceUID, + viewPlaneNormal, + // Optionally add viewUp, cameraFocalPoint, etc. + }, + data: { + handles: { + points: [startWorld, endWorld], + textBox: ann.handles.textBox, + }, + text: ann.text || "", + // Add more fields as needed + }, + visibility: ann.visible !== false, + active: ann.active || false, + }; + cornerstoneTools.annotation.state.addAnnotation(annotation, annotation.annotationUID); + }); + } + }); + }); + + if (renderingEngineRef.current) { + renderingEngineRef.current.render(); + } + }; + + // Import and apply a legacy viewport state to the active viewport + window[`importLegacyViewport_${apiKey}`] = (json: string) => { + let legacy: any; + try { + legacy = typeof json === "string" ? JSON.parse(json) : json; + } catch (e) { + alert("Invalid legacy viewport JSON"); + return; + } + + const renderingEngine = renderingEngineRef.current; + if (!renderingEngine) return; + const viewportId = `CT_${activeViewport ?? 0}`; + const viewport = renderingEngine.getViewport(viewportId); + if (!viewport) return; + + // Map legacy fields to Cornerstone3D properties + const props: any = {}; + if (legacy.voi) { + props.voiRange = { + lower: legacy.voi.windowCenter - legacy.voi.windowWidth / 2, + upper: legacy.voi.windowCenter + legacy.voi.windowWidth / 2, + }; + } + if (typeof legacy.invert === "boolean") props.invert = legacy.invert; + if (typeof legacy.pixelReplication === "boolean") props.interpolationType = legacy.pixelReplication ? 0 : 1; + if (typeof legacy.rotation === "number") props.rotation = legacy.rotation; + if (typeof legacy.hflip === "boolean") props.hflip = legacy.hflip; + if (typeof legacy.vflip === "boolean") props.vflip = legacy.vflip; + + if (typeof viewport.setProperties === "function") { + viewport.setProperties(props); + } + + // Apply translation and scale (pan/zoom) + if (legacy.translation || legacy.scale) { + const camera = viewport.getCamera ? viewport.getCamera() : {}; + if (legacy.translation) { + camera.translation = { ...camera.translation, ...legacy.translation }; + } + if (legacy.scale) { + camera.parallelScale = 1 / legacy.scale; + } + if (typeof viewport.setCamera === "function") { + viewport.setCamera(camera); + } + } + + viewport.render(); + updateOverlay(activeViewport ?? 0, viewport); + }; + + + // Cleanup return () => { window[`exportViewerState_${apiKey}`] = undefined; window[`importViewerState_${apiKey}`] = undefined; window[`exportAnnotations_${apiKey}`] = undefined; window[`importAnnotations_${apiKey}`] = undefined; + window[`importLegacyAnnotations_${apiKey}`] = undefined; + window[`importLegacyViewport_${apiKey}`] = undefined; }; }, [ viewportGrid, @@ -1279,7 +1445,7 @@ function App({ container_id, imageStacks, ...props }: AppProps) { const numVisible = viewportGrid.rows * viewportGrid.cols; const viewports = []; //for (let i = 0; i < MAX_VIEWPORTS; i++) { -for (let i = 0; i < numVisible; i++) { + for (let i = 0; i < numVisible; i++) { // Determine if this viewport should be visible in the current grid const isVisible = i < viewportGrid.rows * viewportGrid.cols; @@ -1651,8 +1817,8 @@ for (let i = 0; i < numVisible; i++) { idx < viewportImageIds[i].length - 1 ? "1px solid #222" : "none", - cursor: loaded ? "pointer" : "default", - pointerEvents: loaded ? "auto" : "none", + pointerEvents: "auto", + cursor: "pointer", }} title={ isCurrent @@ -1683,164 +1849,164 @@ for (let i = 0; i < numVisible; i++) { )} -{isVisible && ( -
- {/* Custom Stack Dropdown */} -
- - {openDropdownIdx === i && ( -
{ - if (el) el.focus(); - }} - tabIndex={0} + {isVisible && ( +
+ {/* 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"; + const isEmpty = stackObj.imageIds.length === 0; + return ( +
{ + e.preventDefault(); + if (!isEmpty) { + handleLoadStack(i, idx); + setOpenDropdownIdx(null); + } + }} + > + + Stack {idx + 1} ({stackObj.imageIds.length} images + {isEmpty && (empty)}) +
+ ); + })} +
+ )} +
-
-)} + )}
); } @@ -1850,16 +2016,16 @@ for (let i = 0; i < numVisible; i++) { return ( // App root -
+
{/* Side menu toggle button */} -
- -
+
+ 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 */} @@ -2515,5 +2681,5 @@ function setupTools(MAIN_TOOL_GROUP_ID) { mainToolGroup.addTool(ReferenceCursors.toolName); } - return { mainToolGroup }; + return mainToolGroup; } diff --git a/dicom-viewer/src/main.tsx b/dicom-viewer/src/main.tsx index 0846ffd..e2d03ea 100644 --- a/dicom-viewer/src/main.tsx +++ b/dicom-viewer/src/main.tsx @@ -51,10 +51,15 @@ containers.forEach((container) => { imageStacks = () => Promise.resolve([[]]); } + // Read autoCacheStack from data-auto-cache-stack attribute + const autoCacheStackAttr = container.getAttribute('data-auto-cache-stack'); + const autoCacheStack = autoCacheStackAttr === "true" || autoCacheStackAttr === "1"; + createRoot(container).render( ); }); \ No newline at end of file