feat: stack viewport

This commit is contained in:
mozzie 2024-09-16 00:54:12 +08:00
parent 6c6d2178a0
commit 3226b47790
7 changed files with 225 additions and 140 deletions

View File

@ -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"
}
}

View File

@ -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<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -22,6 +22,7 @@ interface CreateImageIdsAndCacheMetaDataOptions {
SOPInstanceUID?: string | null;
wadoRsRoot: string;
client?: api.DICOMwebClient | null;
convertMultiframe?: boolean,
}
/**
@ -70,6 +71,7 @@ export const createImageIdsAndCacheMetaData = async (
return imageId;
});
if (convertMultiframeImageIds)
imageIds = convertMultiframeImageIds(imageIds);
imageIds.forEach((imageId) => {
@ -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],
});
}

View File

@ -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<HTMLDivElement | null>(null);
const viewportStack = useRef<HTMLDivElement | null>(null);
const viewportRef_AXIAL = useRef<HTMLDivElement | null>(null);
const viewportRef_SAGITTAL = useRef<HTMLDivElement | null>(null);
const viewportRef_CORONAL = useRef<HTMLDivElement | null>(null);
const renderingEngine = useRef<RenderingEngine>();
const toolGroupIdRef = useRef<string>("");
const toolGroupRef = useRef<cornerstoneTools.Types.IToolGroup | undefined>(
undefined
);
const imageIds = useRef<string[]>();
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();
}
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 (
<div ref={containerRef} className="w-full h-full flex flex-col">
<div className="w-full h-1/4" ref={viewportRef_AXIAL} />
<div className="w-full h-1/4" ref={viewportRef_SAGITTAL} />
<div className="w-full h-1/4" ref={viewportRef_CORONAL} />
<div className="w-full h-1/4" ref={viewportStack} />
<div className="w-full h-1/3" ref={viewportRef_AXIAL} />
<div className="w-full h-1/3" ref={viewportRef_SAGITTAL} />
<div className="w-full h-1/3" ref={viewportRef_CORONAL} />
</div>
);
};

View File

@ -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<HTMLDivElement | null>(null);
const renderingEngineRef = useRef<RenderingEngine>();
const containerRef = useRef<HTMLDivElement | null>(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 (
<div ref={containerRef} className="w-full h-full flex flex-col pb-4">
<div className="w-full h-full" ref={viewportStackRef} />
<Slider
defaultValue={[0]}
max={props.imageIds.length - 1}
step={1}
onValueChange={onChangeIndex}
/>
</div>
);
};

View File

@ -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<CurrentDicom>({
SeriesInstanceUID: queryParams.get("SeriesInstanceUID"),
StudyInstanceUID: queryParams.get("StudyInstanceUID"),
const SeriesInstanceUID = queryParams.get("SeriesInstanceUID");
const StudyInstanceUID = queryParams.get("StudyInstanceUID");
const imageIdsRef = useRef<string[]>();
const volumeId = "volume";
const wwwl = { windowCenter: 50, windowWidth: 850 };
useEffect(() => {
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;
useEffect(() => {
const { SeriesInstanceUID, StudyInstanceUID } = currentDicom;
if (StudyInstanceUID && SeriesInstanceUID) {
const metadata = { SeriesInstanceUID, StudyInstanceUID };
localStorage.setItem("viewCache", JSON.stringify(metadata));
}
}, [currentDicom]);
// 默认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 (
<div className="w-full h-full flex flex-col">
<div className="flex-shrink-0 border-b border-secondary">
{/* <div className="flex-shrink-0 border-b border-secondary">
<ToolBarMenu />
</div>
</div> */}
<div className="flex-grow">
<ResizablePanelGroup direction="horizontal" className="w-full h-full">
<ResizablePanel defaultSize={50}>
@ -57,7 +66,11 @@ export const Viewer = () => {
className="w-full h-full"
>
<ResizablePanel defaultSize={50}>
<div id="stackElement" className="w-full h-full"></div>
<div className="w-full h-full">
{cornerstoneLoaded && imageIdsRef.current && (
<StackViewer imageIds={imageIdsRef.current} />
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50}>
@ -68,13 +81,8 @@ export const Viewer = () => {
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50}>
{cornerstoneLoaded &&
currentDicom.StudyInstanceUID &&
currentDicom.SeriesInstanceUID && (
<CrosshairMpr
StudyInstanceUID={currentDicom.StudyInstanceUID}
SeriesInstanceUID={currentDicom.SeriesInstanceUID}
/>
{cornerstoneLoaded && imageIdsRef.current && (
<CrosshairMpr wwwl={wwwl} volumeId={volumeId} />
)}
</ResizablePanel>
</ResizablePanelGroup>

View File

@ -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