From 3226b47790410bb55828d0653b0e401a981d8255 Mon Sep 17 00:00:00 2001 From: mozzie Date: Mon, 16 Sep 2024 00:54:12 +0800 Subject: [PATCH] feat: stack viewport --- apps/desktop/package.json | 31 ++--- apps/desktop/src/components/ui/slider.tsx | 28 +++++ .../createImageIdsAndCacheMetaData.ts | 7 +- .../src/pages/Viewer/MprViewer/Crosshair.tsx | 107 ++++-------------- .../src/pages/Viewer/StackViewer/index.tsx | 79 +++++++++++++ apps/desktop/src/pages/Viewer/index.tsx | 78 +++++++------ pnpm-lock.yaml | 35 ++++++ 7 files changed, 225 insertions(+), 140 deletions(-) create mode 100644 apps/desktop/src/components/ui/slider.tsx create mode 100644 apps/desktop/src/pages/Viewer/StackViewer/index.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index af1f9b8..2154e97 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,11 @@ }, "dependencies": { "@ant-design/icons": "^5.4.0", + "@cornerstonejs/calculate-suv": "1.1.0", + "@cornerstonejs/core": "1.84.4", + "@cornerstonejs/dicom-image-loader": "1.84.4", + "@cornerstonejs/streaming-image-volume-loader": "1.84.4", + "@cornerstonejs/tools": "1.84.4", "@google-cloud/spanner": "^7.12.0", "@hookform/resolvers": "3.9.0", "@radix-ui/react-checkbox": "^1.1.1", @@ -23,6 +28,7 @@ "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", @@ -31,19 +37,25 @@ "@types/react-icons": "^3.0.0", "@xenova/transformers": "^2.17.2", "antd": "^5.20.0", + "axios": "1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "custom-electron-titlebar": "^4.2.8", "date-fns": "^3.6.0", "dayjs": "1.11.13", + "dcmjs": "0.34.1", "dexie": "^4.0.8", "dicom-parser": "1.8.21", + "dicomweb-client": "0.10.4", "dockview": "^1.15.2", + "electron-log": "5.2.0", "electron-store": "^10.0.0", "embla-carousel-react": "^8.2.0", "flexlayout-react": "^0.7.15", + "form-data": "4.0.0", "framer-motion": "^11.3.24", + "lodash": "4.17.21", "lowdb": "^7.0.1", "lucide-react": "^0.408.0", "node-machine-id": "1.1.12", @@ -55,29 +67,19 @@ "react-desktop": "^0.3.9", "react-dom": "^18.2.0", "react-dropzone": "14.2.3", + "react-grid-layout": "1.4.4", "react-hook-form": "7.53.0", "react-icons": "^5.2.1", + "react-resizable": "3.0.5", "react-resizable-panels": "^2.1.1", "react-router-dom": "^6.26.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", - "zod": "3.23.8", - "axios": "1.7.7", - "lodash": "4.17.21", - "electron-log": "5.2.0", - "form-data": "4.0.0", - "@cornerstonejs/core": "1.84.4", - "@cornerstonejs/tools": "1.84.4", - "@cornerstonejs/dicom-image-loader": "1.84.4", - "@cornerstonejs/streaming-image-volume-loader": "1.84.4", - "dicomweb-client": "0.10.4", - "@cornerstonejs/calculate-suv": "1.1.0", - "dcmjs": "0.34.1", - "react-grid-layout": "1.4.4", - "react-resizable": "3.0.5" + "zod": "3.23.8" }, "devDependencies": { "@radix-ui/react-icons": "^1.3.0", + "@types/lodash": "4.17.7", "@types/node": "22.5.2", "@types/react": "^18.2.64", "@types/react-dom": "^18.2.21", @@ -96,7 +98,6 @@ "vite": "^5.1.6", "vite-plugin-electron": "^0.28.6", "vite-plugin-electron-renderer": "^0.14.5", - "@types/lodash": "4.17.7", "vite-plugin-node-polyfills": "0.22.0" } } \ No newline at end of file diff --git a/apps/desktop/src/components/ui/slider.tsx b/apps/desktop/src/components/ui/slider.tsx new file mode 100644 index 0000000..ab19d57 --- /dev/null +++ b/apps/desktop/src/components/ui/slider.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/apps/desktop/src/pages/Viewer/MprViewer/CornerstoneDicomLoader/createImageIdsAndCacheMetaData.ts b/apps/desktop/src/pages/Viewer/MprViewer/CornerstoneDicomLoader/createImageIdsAndCacheMetaData.ts index 7486dba..ae3f0f9 100644 --- a/apps/desktop/src/pages/Viewer/MprViewer/CornerstoneDicomLoader/createImageIdsAndCacheMetaData.ts +++ b/apps/desktop/src/pages/Viewer/MprViewer/CornerstoneDicomLoader/createImageIdsAndCacheMetaData.ts @@ -22,6 +22,7 @@ interface CreateImageIdsAndCacheMetaDataOptions { SOPInstanceUID?: string | null; wadoRsRoot: string; client?: api.DICOMwebClient | null; + convertMultiframe?: boolean, } /** @@ -70,7 +71,8 @@ export const createImageIdsAndCacheMetaData = async ( return imageId; }); - imageIds = convertMultiframeImageIds(imageIds); + if (convertMultiframeImageIds) + imageIds = convertMultiframeImageIds(imageIds); imageIds.forEach((imageId) => { let instanceMetaData = @@ -84,11 +86,8 @@ export const createImageIdsAndCacheMetaData = async ( const pixelSpacing = getPixelSpacingInformation(metadata) as Number[]; if (pixelSpacing) { - // FIXME: cornerstone类型定义有问题,这里.add方法缺少type属性 calibratedPixelSpacingMetadataProvider.add(imageId, { - // @ts-ignore rowPixelSpacing: pixelSpacing[0], - // @ts-ignore columnPixelSpacing: pixelSpacing[1], }); } diff --git a/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx b/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx index 3792a04..87d9a80 100644 --- a/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx +++ b/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx @@ -1,12 +1,10 @@ import { useEffect, useRef } from "react"; import * as cornerstoneTools from "@cornerstonejs/tools"; import { PublicViewportInput } from "@cornerstonejs/core/dist/types/types/IViewport.js"; -import { createImageIdsAndCacheMetaData } from "./CornerstoneDicomLoader/createImageIdsAndCacheMetaData"; import setCtTransferFunctionForVolumeActor from "./CornerstoneDicomLoader/setCtTransferFunctionForVolumeActor"; import { RenderingEngine, setVolumesForViewports, - volumeLoader, Enums as CoreEnums, } from "@cornerstonejs/core"; import { @@ -19,7 +17,6 @@ import { viewportId3, ViewportId, } from "./Crosshair.config"; -import { IStackViewport } from "@cornerstonejs/core/dist/types/types"; const { ToolGroupManager, @@ -34,10 +31,6 @@ const { MouseBindings } = csToolsEnums; const { ViewportType } = CoreEnums; -interface CrosshairMprProps { - children?: JSX.Element; -} - function getReferenceLineColor(vpId: ViewportId) { return viewportColors[vpId]; } @@ -58,23 +51,25 @@ function getReferenceLineSlabThicknessControlsOn(vpId: ViewportId) { } interface CrosshairMprProps { - StudyInstanceUID: string; - SeriesInstanceUID: string; + wwwl: { + windowCenter: number; + windowWidth: number; + }; + volumeId: string; } export const CrosshairMpr = (props: CrosshairMprProps) => { const containerRef = useRef(null); - const viewportStack = useRef(null); const viewportRef_AXIAL = useRef(null); const viewportRef_SAGITTAL = useRef(null); const viewportRef_CORONAL = useRef(null); const renderingEngine = useRef(); - const toolGroupIdRef = useRef(""); const toolGroupRef = useRef( undefined ); - const imageIds = useRef(); const ts = "-" + Date.now(); + const toolGroupId = "mprToolGroup" + ts; + const renderingEngineId = "mprRenderingEngine" + ts; useEffect(() => { cornerstoneTools.addTool(StackScrollMouseWheelTool); @@ -82,8 +77,6 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { cornerstoneTools.addTool(WindowLevelTool); cornerstoneTools.addTool(ZoomTool); - const toolGroupId = props.SeriesInstanceUID + ts; - toolGroupIdRef.current = toolGroupId; toolGroupRef.current = ToolGroupManager.createToolGroup(toolGroupId); const run = async () => { @@ -94,40 +87,7 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { ) return; - renderingEngine.current = new RenderingEngine( - props.SeriesInstanceUID + ts - ); - - imageIds.current = await createImageIdsAndCacheMetaData({ - StudyInstanceUID: props.StudyInstanceUID, - SeriesInstanceUID: props.SeriesInstanceUID, - wadoRsRoot: "http://localhost:8042/dicom-web", - }); - - // Define a volume in memory - const volume = await volumeLoader.createAndCacheVolume( - props.SeriesInstanceUID + ts, - { - imageIds: imageIds.current ?? [], - } - ); - - // 默认windowWidtth - const { windowCenter, windowWidth } = volume.cornerstoneImageMetaData; - const defaultWindowCenter = 50; - const defaultWindowWidth = 850; - console.log("采用默认窗宽/位: ", windowCenter, windowWidth); - - // 定义 Stack 视口 - const stackViewport = "stack" + props.SeriesInstanceUID + ts; - const viewportInput: PublicViewportInput = { - viewportId: stackViewport, - type: CoreEnums.ViewportType.STACK, // 用于 Stack 视图 - element: viewportStack.current!, - defaultOptions: { - background: [0, 0, 0], - }, - }; + renderingEngine.current = new RenderingEngine(renderingEngineId); // Create the viewports const viewportInputArray: PublicViewportInput[] = [ @@ -160,31 +120,19 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { }, ]; - renderingEngine.current.setViewports([ - ...viewportInputArray, - viewportInput, - ]); - - const viewport = renderingEngine.current.getViewport( - stackViewport - ) as IStackViewport; - - viewport.setStack(imageIds.current, 80); - - // Set the volume to load - volume.load(); + renderingEngine.current.setViewports(viewportInputArray); // Set volumes on the viewports await setVolumesForViewports( renderingEngine.current, [ { - volumeId: props.SeriesInstanceUID + ts, + volumeId: props.volumeId, callback: ({ volumeActor }) => setCtTransferFunctionForVolumeActor({ volumeActor, - defaultWindowCenter, - defaultWindowWidth, + defaultWindowCenter: props.wwwl.windowCenter, + defaultWindowWidth: props.wwwl.windowWidth, }), }, ], @@ -194,18 +142,9 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { if (toolGroupRef.current) { // For the crosshairs to operate, the viewports must currently be // added ahead of setting the tool active. This will be improved in the future. - toolGroupRef.current.addViewport( - viewportId1, - props.SeriesInstanceUID + ts - ); - toolGroupRef.current.addViewport( - viewportId2, - props.SeriesInstanceUID + ts - ); - toolGroupRef.current.addViewport( - viewportId3, - props.SeriesInstanceUID + ts - ); + toolGroupRef.current.addViewport(viewportId1, renderingEngineId); + toolGroupRef.current.addViewport(viewportId2, renderingEngineId); + toolGroupRef.current.addViewport(viewportId3, renderingEngineId); /** * zoom影像 @@ -256,9 +195,7 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { ]); }; - if (props.SeriesInstanceUID && props.StudyInstanceUID) { - run(); - } + run(); return () => { // 禁用视口 @@ -270,7 +207,7 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { renderingEngine.current?.destroy(); // 从 ToolGroupManager 中移除工具组 - ToolGroupManager.destroyToolGroup(toolGroupIdRef.current); + ToolGroupManager.destroyToolGroup(toolGroupId); // 移出工具注册 cornerstoneTools.removeTool(StackScrollMouseWheelTool); @@ -278,7 +215,7 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { cornerstoneTools.removeTool(WindowLevelTool); cornerstoneTools.removeTool(ZoomTool); }; - }, [props, ts]); + }, [props, renderingEngineId, toolGroupId]); /** * mpr resize @@ -288,7 +225,6 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { if (!container) return; let resizeTimeout: NodeJS.Timeout | null = null; const resizeObserver = new ResizeObserver(() => { - console.log("mpr resize"); if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => renderingEngine.current?.resize(), 100); }); @@ -302,10 +238,9 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { return (
-
-
-
-
+
+
+
); }; diff --git a/apps/desktop/src/pages/Viewer/StackViewer/index.tsx b/apps/desktop/src/pages/Viewer/StackViewer/index.tsx new file mode 100644 index 0000000..1b41b5d --- /dev/null +++ b/apps/desktop/src/pages/Viewer/StackViewer/index.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; +import { PublicViewportInput } from "@cornerstonejs/core/dist/types/types/IViewport.js"; +import { Enums as CoreEnums, RenderingEngine } from "@cornerstonejs/core"; +import { IStackViewport } from "@cornerstonejs/core/dist/types/types"; +import { Slider } from "@/components/ui/slider"; + +export interface StackViewerProps { + imageIds: string[]; +} + +export const StackViewer = (props: StackViewerProps) => { + const viewportStackRef = useRef(null); + const renderingEngineRef = useRef(); + const containerRef = useRef(null); + const viewportId = "stackViewport"; + const renderingEngineId = "stackRenderingEngine"; + + useEffect(() => { + if (!viewportStackRef.current) return; + renderingEngineRef.current = new RenderingEngine(renderingEngineId); + const viewportInput: PublicViewportInput = { + viewportId, + type: CoreEnums.ViewportType.STACK, // 用于 Stack 视图 + element: viewportStackRef.current, + defaultOptions: { + background: [0, 0, 0], + }, + }; + renderingEngineRef.current.enableElement(viewportInput); + const viewport = renderingEngineRef.current.getViewport( + viewportId + ) as IStackViewport; + + viewport.setStack(props.imageIds); + + return () => { + renderingEngineRef.current?.disableElement(viewportId); + renderingEngineRef.current?.destroy(); + }; + }, [props.imageIds, renderingEngineId, viewportId]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + let resizeTimeout: NodeJS.Timeout | null = null; + const resizeObserver = new ResizeObserver(() => { + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = setTimeout( + () => renderingEngineRef.current?.resize(), + 100 + ); + }); + resizeObserver.observe(container); + return () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeObserver.unobserve(container); + resizeObserver.disconnect(); + }; + }, []); + + const onChangeIndex = (value: number[]) => { + if (renderingEngineRef.current) { + const viewport = renderingEngineRef.current.getViewport(viewportId); + (viewport as IStackViewport).setImageIdIndex(value[0]); + } + }; + + return ( +
+
+ +
+ ); +}; diff --git a/apps/desktop/src/pages/Viewer/index.tsx b/apps/desktop/src/pages/Viewer/index.tsx index 9668b13..3a81196 100644 --- a/apps/desktop/src/pages/Viewer/index.tsx +++ b/apps/desktop/src/pages/Viewer/index.tsx @@ -7,47 +7,56 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; -import { ToolBarMenu } from "./MprViewer/ToolBarMenu"; -import { RenderingEngine } from "@cornerstonejs/core"; +import { StackViewer } from "./StackViewer"; +import { createImageIdsAndCacheMetaData } from "./MprViewer/CornerstoneDicomLoader/createImageIdsAndCacheMetaData"; +import { volumeLoader } from "@cornerstonejs/core"; -export interface CurrentDicom { - SeriesInstanceUID: string | null; - StudyInstanceUID: string | null; -} +const wadoRsRoot = "http://localhost:8042/dicom-web"; export const Viewer = () => { const [cornerstoneLoaded, setCornerstoneLoaded] = useState(false); const location = useLocation(); const queryParams = new URLSearchParams(location.search); - const [currentDicom, setCurrentDicom] = useState({ - SeriesInstanceUID: queryParams.get("SeriesInstanceUID"), - StudyInstanceUID: queryParams.get("StudyInstanceUID"), - }); + const SeriesInstanceUID = queryParams.get("SeriesInstanceUID"); + const StudyInstanceUID = queryParams.get("StudyInstanceUID"); + const imageIdsRef = useRef(); + const volumeId = "volume"; + const wwwl = { windowCenter: 50, windowWidth: 850 }; useEffect(() => { - const { SeriesInstanceUID, StudyInstanceUID } = currentDicom; - if (StudyInstanceUID && SeriesInstanceUID) { - const metadata = { SeriesInstanceUID, StudyInstanceUID }; - localStorage.setItem("viewCache", JSON.stringify(metadata)); - } - }, [currentDicom]); + const setImageOrderCache = async () => { + if (!StudyInstanceUID || !SeriesInstanceUID) return; + // imageIds此时由于流式加载for mpr,是错乱的图片顺序 + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID, + SeriesInstanceUID, + wadoRsRoot, + }); + // 这一步会对imageIds进行排序,如果不排序imageIds会错误乱,stackViewport顺序会错误 + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + volume.load(); + imageIdsRef.current = volume.imageIds; + + // 默认windowWidtth + const { windowCenter, windowWidth } = volume.cornerstoneImageMetaData; + console.log("默认窗宽/位: ", windowCenter, windowWidth); - useEffect(() => { - console.log(window.location.href); - initCornerstone(() => { setCornerstoneLoaded(true); - }); - }, [queryParams]); + }; - useEffect(() => { - console.log(cornerstoneLoaded); - }, [cornerstoneLoaded]); + initCornerstone(() => { + setImageOrderCache(); + }); + + }, [SeriesInstanceUID, StudyInstanceUID]); return (
-
+ {/*
-
+
*/}
@@ -57,7 +66,11 @@ export const Viewer = () => { className="w-full h-full" > -
+
+ {cornerstoneLoaded && imageIdsRef.current && ( + + )} +
@@ -68,14 +81,9 @@ export const Viewer = () => { - {cornerstoneLoaded && - currentDicom.StudyInstanceUID && - currentDicom.SeriesInstanceUID && ( - - )} + {cornerstoneLoaded && imageIdsRef.current && ( + + )}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9518ae8..b008218 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,9 @@ importers: '@radix-ui/react-select': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: ^1.2.0 + version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.4)(react@18.3.1) @@ -1742,6 +1745,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.2.0': + resolution: {integrity: sha512-dAHCDA4/ySXROEPaRtaMV5WHL8+JB/DbtyTbJjYkY0RXmKMO2Ln8DFZhywG5/mVQ4WqHDBc8smc14yPXPqZHYA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.0.2': resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -7233,6 +7249,25 @@ snapshots: '@types/react': 18.3.4 '@types/react-dom': 18.3.0 + '@radix-ui/react-slider@1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.4)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.4 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slot@1.0.2(@types/react@18.3.4)(react@18.3.1)': dependencies: '@babel/runtime': 7.25.4