Compare commits

..

18 Commits

Author SHA1 Message Date
mozzie
66d3350edc feat: test 2024-09-20 16:48:32 +08:00
mozzie
e4eb06d71c feat: 优化算法分割输出文件夹结构 2024-09-20 16:29:55 +08:00
mozzie
67b5e488fe feat: 算法调度测试缓存 2024-09-20 15:55:17 +08:00
mozzie
2cbab89273 fix: 上传完成内存泄漏 2024-09-20 12:30:56 +08:00
mozzie
e5bf5685cf ui: 修改stackviewport滚动条样式 2024-09-19 16:08:16 +08:00
mozzie
dff24401bf feat: 处理窗口resize 2024-09-19 15:00:40 +08:00
mozzie
d09d852207 feat: tools注册顺序导致的viewportInfo找不到 2024-09-19 14:29:59 +08:00
mozzie
ba516af2f7 feat: 解决了opengl 默认chrome配置不调用的问题 2024-09-19 13:01:29 +08:00
mozzie
a0f4ca2f3a Merge branch 'main' of ssh://git.maxshader.com:10022/mozzie/cvpilot-tool 2024-09-19 10:59:22 +08:00
mozzie
cc783768db feat: memory leak 2024-09-19 10:59:20 +08:00
mozzie
7291840ed8 feat: ui 2024-09-18 22:26:40 +08:00
mozzie
f2f7382fec feat: volume+ts解决刷新问题 2024-09-18 15:58:34 +08:00
mozzie
98b5b354d9 feat : test 2024-09-18 15:23:43 +08:00
mozzie
9917b77077 fff 2024-09-18 13:00:28 +08:00
mozzie
5814637d7a chore: remove unuse var 2024-09-18 12:59:40 +08:00
mozzie
21914f3077 freat: sharedArrayBuffer 2024-09-18 12:58:45 +08:00
mozzie
c52b3f1e00 fix: upload and refresh patient list 2024-09-18 10:52:08 +08:00
mozzie
039325b768 feat: integration version 2024-09-18 10:12:47 +08:00
28 changed files with 660 additions and 734 deletions

View File

@ -23,6 +23,8 @@ pnpm config set virtual-store-dir-max-length 70
- 不同结构的窗宽窗位快速选择需要考虑不同身体结构的dicom image - 不同结构的窗宽窗位快速选择需要考虑不同身体结构的dicom image
- 炸显存问题electron关闭硬件加速还是无法接解决1000+外周的mpr
## 窗宽创维相关的一些小问题 ## 窗宽创维相关的一些小问题
- 心脏软组织窗 - 心脏软组织窗

View File

@ -0,0 +1,55 @@
import log from "electron-log";
import { spawn } from "node:child_process";
import path from "node:path";
import { InferReq } from "./alg.type";
import axios from "axios";
export const ALGServerRoot = "http://127.0.0.1:5000/root";
export const getEntryPath = () => {
// 区分操作系统
return path.join(process.env.VITE_PUBLIC!, "main.exe");
};
export const startALGServer = () => {
const entryPath = getEntryPath();
const child_process = spawn(entryPath);
child_process.on("message", (data) => console.log(data));
child_process.stdout.on("data", (data) => log.info(data.toString()));
child_process.stderr.on("data", (data) => log.error(data.toString()));
};
/**
*
*/
export const executeInferTask = (
task: InferReq,
onData: (data: string) => void
) => {
return new Promise((resolve, reject) => {
axios
.post(ALGServerRoot, task, {
responseType: "stream",
headers: { "Content-Type": "application/json" },
})
.then((response) => {
response.data.on("data", (chunk: Buffer) => {
const data = chunk.toString();
onData(data); // 实时处理数据
});
response.data.on("end", () => {
resolve(task); // 数据流结束
});
response.data.on("error", (err: Error) => {
console.log("error", err);
reject(err);
});
})
.catch((error) => {
console.log("axios error", error);
reject(error);
});
});
};

View File

@ -0,0 +1,29 @@
export enum InferDeviceEnum {
GPU = "GPU",
CPU = "CPU",
NPU = "NPU",
}
/**
*
*/
export enum InferStructuralEnum {
PERI = "peripheral",
AORTA = "root",
}
export type InferReq = {
/**
* .dcm文件夹path
*/
img_path: string;
/**
* path
* @description {app.getPath('userData')}/{SeriesInstanceUID}/${xxxx}
*/
save_path: string;
pu: InferDeviceEnum;
module: InferStructuralEnum;
turbo?: boolean;
seg_schedule: boolean;
};

View File

@ -1,11 +1,13 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import log from "electron-log"; import log from "electron-log";
import path from "node:path"; import path from "node:path";
import FormData from "form-data"; import FormData from "form-data";
import { readFile } from "fs/promises"; import { readFile } from "fs/promises";
import axios from "axios"; import axios from "axios";
import { PatientInfo, SeriesInfo, StudyInfo } from "./pacs.type"; import { InstanceInfo, PatientInfo, SeriesInfo, StudyInfo } from "./pacs.type";
import { app } from "electron";
import pLimit from "p-limit";
export const OrthancServerRoot = "http://localhost:8042"; export const OrthancServerRoot = "http://localhost:8042";
@ -103,3 +105,77 @@ export const selectStructuredDicom = async (
throw error; // or handle it accordingly throw error; // or handle it accordingly
} }
}; };
/**
* SeriesInstanceUID DICOM
* @param seriesInstanceUID SeriesInstanceUID
* @returns
*/
export const downloadSeriesDicomFiles = async (
seriesInstanceUID: string
): Promise<string> => {
try {
// 使用 Orthanc 的查询接口查找匹配的序列 ID
const findResponse = await axios.post<string[]>(
`${OrthancServerRoot}/tools/find`,
{
Level: "Series",
Query: {
SeriesInstanceUID: seriesInstanceUID,
},
}
);
const seriesIds = findResponse.data;
if (seriesIds.length === 0) {
throw new Error("在 Orthanc 中未找到指定的 SeriesInstanceUID。");
}
const seriesId = seriesIds[0]; // 假设只有一个匹配的序列
// 获取该序列中的所有实例DICOM 文件)
const instancesResponse = await axios.get<string[]>(
`${OrthancServerRoot}/series/${seriesId}/instances`
);
const instances = instancesResponse.data as unknown as InstanceInfo[];
// 创建保存 DICOM 文件的本地目录
const saveDir = path.join(
app.getPath("userData"),
"dicom",
seriesInstanceUID
);
if (!existsSync(saveDir)) {
mkdirSync(saveDir, { recursive: true });
}
// 并发限制
const limit = pLimit(5); // 限制同时进行的请求数为 5
// 并行下载 DICOM 文件
await Promise.all(
instances.map((instance) => {
const instanceId = instance.ID;
return limit(async () => {
const dicomResponse = await axios.get<ArrayBuffer>(
`${OrthancServerRoot}/instances/${instanceId}/file`,
{
responseType: "arraybuffer",
}
);
const filePath = path.join(saveDir, `${instanceId}.dcm`);
writeFileSync(filePath, Buffer.from(dicomResponse.data), "binary");
});
})
);
console.log(`所有 DICOM 文件已保存至:${saveDir}`);
return saveDir;
} catch (error) {
console.error("下载 DICOM 文件时出错:", error);
throw error;
}
};

View File

@ -1,8 +1,11 @@
import { ipcMain, shell } from "electron"; import { app, ipcMain, shell } from "electron";
import { mkdir, stat } from "fs/promises"; import { mkdir, stat } from "fs/promises";
import { db } from "../../core/db"; import { db } from "../../core/db";
import log from "electron-log"; import log from "electron-log";
import path from "node:path"; import path from "node:path";
import { downloadSeriesDicomFiles } from "../../core/pacs";
import { executeInferTask } from "../../core/alg";
import { InferDeviceEnum, InferStructuralEnum } from "../../core/alg.type";
export const registerCommonHandler = () => { export const registerCommonHandler = () => {
ipcMain.handle("output:open", async () => { ipcMain.handle("output:open", async () => {
@ -30,4 +33,28 @@ export const registerCommonHandler = () => {
return { success: false, msg: `操作失败` }; return { success: false, msg: `操作失败` };
} }
}); });
ipcMain.handle("model:infer", async (_event, SeriesInstanceUIDs) => {
// 构造推理任务参数列表
const pu = InferDeviceEnum.GPU;
const module = InferStructuralEnum.AORTA;
const turbo = true;
const seg_schedule = true;
for (let i = 0; i < SeriesInstanceUIDs.length; i++) {
const SeriesInstanceUID = SeriesInstanceUIDs[i];
// 下载dicom到本地获取文件夹路径
const img_path = await downloadSeriesDicomFiles(SeriesInstanceUID);
const save_path = path.join(
app.getPath("userData"),
"output",
SeriesInstanceUID
);
const task = { save_path, pu, module, turbo, seg_schedule, img_path };
console.log(task);
const result = await executeInferTask(task, (data) => {
console.log(data);
});
console.log("end: ", result);
}
});
}; };

View File

@ -108,11 +108,4 @@ export const uploadFilesInBatches = async ({
totalEndTime - totalStartTime totalEndTime - totalStartTime
} ms` } ms`
); );
return {
totalSuccess,
totalFailed,
totalTime: totalEndTime - totalStartTime,
progress: 100,
};
}; };

View File

@ -13,6 +13,7 @@ import { createDatabase } from "./core/db";
import { getMachineId } from "./core/auth"; import { getMachineId } from "./core/auth";
import { getPacsPath, runOrthancServer } from "./core/pacs"; import { getPacsPath, runOrthancServer } from "./core/pacs";
import { registerIpcMainHandlers } from "./ipcEvent"; import { registerIpcMainHandlers } from "./ipcEvent";
import { startALGServer } from "./core/alg";
// const require = createRequire(import.meta.url); // const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -37,8 +38,11 @@ const themeTitleBarStyles = {
}; };
export const platform = process.platform === "darwin" ? "macos" : "windows"; export const platform = process.platform === "darwin" ? "macos" : "windows";
app.commandLine.appendSwitch("disable-web-security");
app.commandLine.appendSwitch("ignore-gpu-blocklist");
app.commandLine.appendSwitch("use-angle", "gl");
app.commandLine.appendSwitch('disable-web-security'); // app.disableHardwareAcceleration()
function createWindow() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
@ -51,7 +55,7 @@ function createWindow() {
titleBarOverlay: { height: 36, ...themeTitleBarStyles[theme] }, // 渲染进程发消息动态改变这个 titleBarOverlay: { height: 36, ...themeTitleBarStyles[theme] }, // 渲染进程发消息动态改变这个
webPreferences: { webPreferences: {
preload: path.join(__dirname, "preload.mjs"), preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: true nodeIntegration: true,
}, },
}); });
@ -70,10 +74,7 @@ function createWindow() {
win.loadURL(VITE_DEV_SERVER_URL); win.loadURL(VITE_DEV_SERVER_URL);
registerIpcMainHandlers(win); registerIpcMainHandlers(win);
runOrthancServer(getPacsPath(platform, true)); runOrthancServer(getPacsPath(platform, true));
startALGServer();
// if (platform !== "macos") {
// python_process = spawn(path.join(process.env.VITE_PUBLIC!, "main.exe"));
// }
} else { } else {
// if (platform !== "macos") { // if (platform !== "macos") {
// python_process = spawn(path.join(process.env.VITE_PUBLIC!, "main.exe")); // python_process = spawn(path.join(process.env.VITE_PUBLIC!, "main.exe"));

View File

@ -75,7 +75,9 @@
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "3.23.8" "zod": "3.23.8",
"mitt": "3.0.1",
"p-limit": "6.1.0"
}, },
"devDependencies": { "devDependencies": {
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",

Binary file not shown.

Binary file not shown.

View File

@ -28,19 +28,23 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { RocketIcon } from "@radix-ui/react-icons"; import { RocketIcon } from "@radix-ui/react-icons";
import emitter from "@/lib/events";
interface ScanProgress { interface UploadProgress {
percentage: number; progress: number;
totalSuccess: number;
totalFailed: number;
} }
const defaultProgress = {
percentage: 0,
};
export const MenuBar = () => { export const MenuBar = () => {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const [progress, setProgress] = useState<ScanProgress>(defaultProgress); const [uploading, setUploading] = useState<UploadProgress>({
progress: 0,
totalSuccess: 0,
totalFailed: 0,
});
const [inferOption, setInferOption] = const [inferOption, setInferOption] =
useState<inferDeviceType[]>(inferDevices); useState<inferDeviceType[]>(inferDevices);
const [importDialogVisible, setImportDialogVisible] = useState(false); const [importDialogVisible, setImportDialogVisible] = useState(false);
@ -48,9 +52,9 @@ export const MenuBar = () => {
useEffect(() => { useEffect(() => {
const handleUploadFeedback = ( const handleUploadFeedback = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
data: { progress: number; totalSuccess: number; totalFailed: number } data: UploadProgress
) => { ) => {
setProgress({ percentage: data.progress }); setUploading(data);
}; };
window.ipcRenderer.on("dicom:upload:detail", handleUploadFeedback); window.ipcRenderer.on("dicom:upload:detail", handleUploadFeedback);
return () => { return () => {
@ -59,12 +63,20 @@ export const MenuBar = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
const visible = ![0, 100].includes(progress.percentage); const p = uploading?.progress;
setImportDialogVisible(visible); if (p > 0 && p < 100 && !importDialogVisible) {
}, [progress.percentage]); setImportDialogVisible(true);
} else if (p === 100 && importDialogVisible) {
setImportDialogVisible(false);
toast({
title: "操作",
description: `导入完成,成功: ${uploading.totalSuccess},失败: ${uploading.totalFailed}`,
});
emitter.emit("datasource:fetch");
}
}, [uploading, importDialogVisible, navigate, toast]);
const handleImportDicom = () => { const handleImportDicom = () => {
navigate("datasource");
window.ipcRenderer.send("dicom:upload"); window.ipcRenderer.send("dicom:upload");
}; };
@ -192,9 +204,9 @@ export const MenuBar = () => {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Alert> <Alert>
<RocketIcon className="h-4 w-4" /> <RocketIcon className="h-4 w-4" />
<AlertTitle></AlertTitle> <AlertTitle></AlertTitle>
<AlertDescription> <AlertDescription>
<Progress value={progress?.percentage} /> <Progress value={uploading.progress} />
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>

View File

@ -0,0 +1,5 @@
import mitt from "mitt";
const emitter = mitt();
export default emitter;

View File

@ -15,22 +15,22 @@ import { ScrollArea } from "@/components/ui/scroll-area";
const Boot = () => { const Boot = () => {
const location = useLocation(); const location = useLocation();
const selectDicoms =
location.state?.selectDicoms ??
JSON.parse(localStorage.getItem("selectDicoms") ?? "[]");
const [messageText, setMessageText] = useState(["进度信息简化"]); const [messageText, setMessageText] = useState(["进度信息简化"]);
/** /**
* windows系统右键启动应用菜单的入口路径 * windows系统右键启动应用菜单的入口路径
*/ */
const [bootDirectoryPath, setBootDirectoryPath] = useState(""); const [bootDirectoryPath, setBootDirectoryPath] = useState("");
const [tasks, setTasks] = useState(selectDicoms);
/** /**
* electron主进程 * electron主进程
*/ */
const handleTasks = () => { const handleTasks = () => {
window.ipcRenderer.send("ai:task", { selectDicoms: tasks }); const SeriesInstanceUIDs = [
"1.3.12.2.1107.5.1.4.73399.30000020080900171669200001479",
"1.2.156.112605.14038010222575.230518044041.3.4344.300061",
];
window.ipcRenderer.invoke("model:infer", SeriesInstanceUIDs);
}; };
useEffect(() => { useEffect(() => {
@ -42,30 +42,11 @@ const Boot = () => {
useEffect(() => { useEffect(() => {
if (bootDirectoryPath) { if (bootDirectoryPath) {
window.ipcRenderer.send("one-step"); // TODO: 启动分析
} }
return () => { return () => {};
window.ipcRenderer.off("one-step", () => {});
};
}, [bootDirectoryPath]); }, [bootDirectoryPath]);
const handleEmptyTasks = () => {
setTasks([]);
localStorage.removeItem("selectDicoms");
};
useEffect(() => {
window.ipcRenderer.on("tasksFinished", (_event, data) => {
setMessageText((p) => [...p, `总用时: ${data}`]);
});
}, []);
useEffect(() => {
window.ipcRenderer.on("taskFinished", (_event, data) => {
setMessageText((p) => [...p, data]);
});
}, []);
return ( return (
<motion.div <motion.div
initial={{ y: 10, opacity: 0 }} initial={{ y: 10, opacity: 0 }}
@ -79,51 +60,9 @@ const Boot = () => {
<ResizablePanel defaultSize={38.2}> <ResizablePanel defaultSize={38.2}>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<ScrollArea className="flex-grow w-full h-full px-4 pb-2"> <ScrollArea className="flex-grow w-full h-full px-4 pb-2">
<div className="w-full flex flex-col gap-y-2"> <div className="w-full flex flex-col gap-y-2"></div>
{tasks.map((dicom: Series, index: number) => (
<Card
key={index}
className="flex shadow-none flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent"
>
<div className="flex w-full flex-col gap-1">
<div className="flex items-center">
<div className="flex items-center gap-2">
<div className="font-semibold">
{dicom.PatientName}
</div>
<span className="flex h-2 w-2 rounded-full bg-blue-600"></span>
</div>
<div className="ml-auto text-xs text-foreground">
{dicom.PatientSex}
</div>
</div>
<div className="text-xs font-medium">
{dicom.PatientAge}
</div>
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
AI的判断结果metadata扔给大语言模型使
</div>
<div className="flex items-center gap-2">
<div className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80">
</div>
<div className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80">
</div>
<div className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80">
</div>
</div>
</Card>
))}
</div>
</ScrollArea> </ScrollArea>
<div className="flex-shrink-0 flex items-center justify-end gap-2 px-4 py-4"> <div className="flex-shrink-0 flex items-center justify-end gap-2 px-4 py-4">
<Button onClick={handleEmptyTasks} variant="ghost">
<Trash2 className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleTasks}> <Button onClick={handleTasks}>
<SparkleIcon className="mr-2 h-4 w-4" /> <SparkleIcon className="mr-2 h-4 w-4" />

View File

@ -13,6 +13,7 @@ import { PatientInfo, SeriesInfo } from "./type";
import { PatientList } from "./PatientList"; import { PatientList } from "./PatientList";
import { StudyList } from "./StudyList"; import { StudyList } from "./StudyList";
import { SeriesList } from "./SeriesList"; import { SeriesList } from "./SeriesList";
import emitter from "@/lib/events";
export const Datasource = () => { export const Datasource = () => {
const rawPatientsRef = useRef<PatientInfo[]>([]); const rawPatientsRef = useRef<PatientInfo[]>([]);
@ -24,13 +25,20 @@ export const Datasource = () => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const fetchData = () => {
window.ipcRenderer window.ipcRenderer
.invoke("dicom:select") .invoke("dicom:select")
.then((patients: PatientInfo[]) => { .then((patients: PatientInfo[]) => {
console.log(patients); console.log("patients", patients);
rawPatientsRef.current = patients; rawPatientsRef.current = patients;
setPatients(patients); setPatients(patients);
}); });
};
fetchData();
emitter.on("datasource:fetch", fetchData);
return () => {
emitter.off("datasource:fetch", fetchData);
};
}, []); }, []);
const handlePatientSearch = (filterValue: string) => { const handlePatientSearch = (filterValue: string) => {

View File

@ -0,0 +1,3 @@
export const Model3DViewer = () => {
return <div>3d model</div>;
};

View File

@ -1,185 +0,0 @@
import * as cornerstoneTools from '@cornerstonejs/tools';
import type { Types } from '@cornerstonejs/tools';
const {
LengthTool,
StackScrollMouseWheelTool,
StackScrollTool,
PanTool,
ZoomTool,
TrackballRotateTool,
Enums: csToolsEnums,
} = cornerstoneTools;
const { MouseBindings, KeyboardBindings } = csToolsEnums;
let registered = false;
export type ToolBinding = {
// A base tool to register. Should only be defined once per tool
tool?: any;
// The tool name to base this on
baseTool?: string;
// The configuration to register with
configuration?: Record<string, any>;
// Sets to passive initially
passive?: boolean;
// Initial bindings
bindings?: Types.IToolBinding[];
};
/**
* Adds navigation bindings to the given tool group. Registers the basic
* tool with CS Tools if register is true.
*
* Adds:
* * Pan on Right or Primary+Ctrl
* * Zoom on Middle, Primary+Shift
* * Stack Scroll on Mouse Wheel, Primary+Alt
* * Length Tool on fourth button
*
* Also allows registering other tools by having them in the options.toolMap with configuration values.
*/
export default function addManipulationBindings(
toolGroup,
options: {
enableShiftClickZoom?: boolean;
is3DViewport?: boolean;
toolMap?: Map<string, ToolBinding>;
} = {}
) {
const zoomBindings: Types.IToolBinding[] = [
{
mouseButton: MouseBindings.Secondary,
},
];
const {
is3DViewport = false,
enableShiftClickZoom = false,
toolMap = new Map(),
} = options;
if (enableShiftClickZoom === true) {
zoomBindings.push({
mouseButton: MouseBindings.Primary, // Shift Left Click
modifierKey: KeyboardBindings.Shift,
});
}
if (!registered) {
cornerstoneTools.addTool(StackScrollMouseWheelTool);
cornerstoneTools.addTool(PanTool);
cornerstoneTools.addTool(ZoomTool);
cornerstoneTools.addTool(TrackballRotateTool);
cornerstoneTools.addTool(LengthTool);
cornerstoneTools.addTool(StackScrollTool);
for (const [, config] of toolMap) {
if (config.tool) {
cornerstoneTools.addTool(config.tool);
}
}
}
registered = true;
toolGroup.addTool(PanTool.toolName);
// Allow significant zooming to occur
toolGroup.addTool(ZoomTool.toolName, {
minZoomScale: 0.001,
maxZoomScale: 4000,
});
if (is3DViewport) {
toolGroup.addTool(TrackballRotateTool.toolName);
} else {
toolGroup.addTool(StackScrollMouseWheelTool.toolName);
}
toolGroup.addTool(LengthTool.toolName);
toolGroup.addTool(StackScrollTool.toolName);
toolGroup.setToolActive(PanTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Auxiliary,
},
{
numTouchPoints: 1,
modifierKey: KeyboardBindings.Ctrl,
},
],
});
toolGroup.setToolActive(ZoomTool.toolName, {
bindings: zoomBindings,
});
// Need a binding to navigate without a wheel mouse
toolGroup.setToolActive(StackScrollTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Primary,
modifierKey: KeyboardBindings.Alt,
},
{
numTouchPoints: 1,
modifierKey: KeyboardBindings.Alt,
},
],
});
// Add a length tool binding to allow testing annotations on examples targetting
// other use cases. Use a primary button with shift+ctrl as that is relatively
// unlikely to be otherwise used.
toolGroup.setToolActive(LengthTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Primary,
modifierKey: KeyboardBindings.ShiftCtrl,
},
{
numTouchPoints: 1,
modifierKey: KeyboardBindings.ShiftCtrl,
},
],
});
if (is3DViewport) {
toolGroup.setToolActive(TrackballRotateTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Primary,
},
],
});
} else {
toolGroup.setToolActive(StackScrollMouseWheelTool.toolName);
}
// Add extra tools from the toolMap
for (const [toolName, config] of toolMap) {
if (config.baseTool) {
if (!toolGroup.hasTool(config.baseTool)) {
toolGroup.addTool(
config.baseTool,
toolMap.get(config.baseTool)?.configuration
);
}
toolGroup.addToolInstance(
toolName,
config.baseTool,
config.configuration
);
} else if (!toolGroup.hasTool(toolName)) {
toolGroup.addTool(toolName, config.configuration);
}
if (config.passive) {
// This can be applied during add/remove contours
toolGroup.setToolPassive(toolName);
}
if (config.bindings || config.selected) {
toolGroup.setToolActive(
toolName,
(config.bindings && config) || {
bindings: [{ mouseButton: MouseBindings.Primary }],
}
);
}
}
}

View File

@ -9,9 +9,12 @@ export const initCornerstoneDICOMImageLoader = () => {
cornerstone.setUseSharedArrayBuffer(false); cornerstone.setUseSharedArrayBuffer(false);
cornerstone.setConfiguration({ cornerstone.setConfiguration({
detectGPUConfig: { detectGPUConfig: {
// benchmarksURL: "http://localhost:9000", benchmarksURL: "",
},
rendering: {
...cornerstone.getConfiguration().rendering,
// useNorm16Texture: true,
}, },
rendering: cornerstone.getConfiguration().rendering,
isMobile: false, isMobile: false,
enableCacheOptimization: false, enableCacheOptimization: false,
}); });
@ -22,6 +25,7 @@ export const initCornerstoneDICOMImageLoader = () => {
useWebWorkers: true, useWebWorkers: true,
decodeConfig: { decodeConfig: {
convertFloatPixelDataToInt: false, convertFloatPixelDataToInt: false,
// use16BitDataType: false,
use16BitDataType: preferSizeOverAccuracy || useNorm16Texture, use16BitDataType: preferSizeOverAccuracy || useNorm16Texture,
}, },
}); });
@ -37,6 +41,7 @@ export const initCornerstoneDICOMImageLoader = () => {
startWebWorkersOnDemand: false, startWebWorkersOnDemand: false,
taskConfiguration: { taskConfiguration: {
decodeTask: { decodeTask: {
useNorm16Texture: true,
initializeCodecsOnStartup: false, initializeCodecsOnStartup: false,
strict: false, strict: false,
}, },

View File

@ -1,11 +1,5 @@
type VOIRange = {
/** upper value for display */
upper: number;
/** lower value for display */
lower: number;
};
let ctVoiRange: VOIRange let ctVoiRange;
export interface CtTransferFunction { export interface CtTransferFunction {
volumeActor: any volumeActor: any
@ -15,7 +9,6 @@ export interface CtTransferFunction {
export default function setCtTransferFunctionForVolumeActor(p: CtTransferFunction) { export default function setCtTransferFunctionForVolumeActor(p: CtTransferFunction) {
const { volumeActor, defaultWindowCenter, defaultWindowWidth } = p const { volumeActor, defaultWindowCenter, defaultWindowWidth } = p
console.log(defaultWindowCenter, defaultWindowWidth)
const lower = defaultWindowCenter - defaultWindowWidth / 2.0; const lower = defaultWindowCenter - defaultWindowWidth / 2.0;
const upper = defaultWindowCenter + defaultWindowWidth / 2.0; const upper = defaultWindowCenter + defaultWindowWidth / 2.0;
ctVoiRange = { lower, upper }; ctVoiRange = { lower, upper };

View File

@ -1,228 +0,0 @@
import { useEffect, useRef } from "react";
import * as cornerstoneTools from "@cornerstonejs/tools";
import { PublicViewportInput } from "@cornerstonejs/core/dist/types/types/IViewport.js";
import setCtTransferFunctionForVolumeActor from "./CornerstoneDicomLoader/setCtTransferFunctionForVolumeActor";
import {
RenderingEngine,
setVolumesForViewports,
Enums as CoreEnums,
volumeLoader,
} from "@cornerstonejs/core";
import {
viewportColors,
viewportReferenceLineControllable,
viewportReferenceLineDraggableRotatable,
viewportReferenceLineSlabThicknessControlsOn,
viewportId1,
viewportId2,
viewportId3,
ViewportId,
} from "./Crosshair.config";
const viewportIds = [viewportId1, viewportId2, viewportId3];
const {
ToolGroupManager,
CrosshairsTool,
StackScrollMouseWheelTool,
WindowLevelTool,
ZoomTool,
Enums: csToolsEnums,
} = cornerstoneTools;
const { MouseBindings } = csToolsEnums;
const { ViewportType } = CoreEnums;
function getReferenceLineColor(vpId: ViewportId) {
return viewportColors[vpId];
}
function getReferenceLineControllable(vpId: ViewportId) {
const index = viewportReferenceLineControllable.indexOf(vpId);
return index !== -1;
}
function getReferenceLineDraggableRotatable(vpId: ViewportId) {
const index = viewportReferenceLineDraggableRotatable.indexOf(vpId);
return index !== -1;
}
function getReferenceLineSlabThicknessControlsOn(vpId: ViewportId) {
const index = viewportReferenceLineSlabThicknessControlsOn.indexOf(vpId);
return index !== -1;
}
interface CrosshairMprProps {
wwwl: {
windowCenter: number;
windowWidth: number;
};
imageIds: string[];
}
export const CrosshairMpr = (props: CrosshairMprProps) => {
const containerRef = 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 volumeId = "volumeId";
const toolGroupId = "mprToolGroup";
const renderingEngineId = "mprRenderingEngine";
useEffect(() => {
const run = async () => {
if (
viewportRef_AXIAL.current &&
viewportRef_SAGITTAL.current &&
viewportRef_CORONAL.current
) {
console.log("mpr rendering");
renderingEngine.current = new RenderingEngine(renderingEngineId);
const viewportInputArray: PublicViewportInput[] = [
{
viewportId: viewportId1,
type: ViewportType.ORTHOGRAPHIC,
element: viewportRef_AXIAL.current,
defaultOptions: {
orientation: CoreEnums.OrientationAxis.AXIAL,
background: [0, 0, 0],
},
},
{
viewportId: viewportId2,
type: ViewportType.ORTHOGRAPHIC,
element: viewportRef_SAGITTAL.current,
defaultOptions: {
orientation: CoreEnums.OrientationAxis.SAGITTAL,
background: [0, 0, 0],
},
},
{
viewportId: viewportId3,
type: ViewportType.ORTHOGRAPHIC,
element: viewportRef_CORONAL.current,
defaultOptions: {
orientation: CoreEnums.OrientationAxis.CORONAL,
background: [0, 0, 0],
},
},
];
renderingEngine.current.setViewports(viewportInputArray);
/**
* bug here: WebGL: INVALID_OPERATION: bindTexture: object does not belong to this context
*/
await setVolumesForViewports(
renderingEngine.current,
[
{
volumeId: volumeId,
callback: ({ volumeActor }) =>
setCtTransferFunctionForVolumeActor({
volumeActor,
defaultWindowCenter: props.wwwl.windowCenter,
defaultWindowWidth: props.wwwl.windowWidth,
}),
},
],
viewportIds
);
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
if (toolGroup) {
// 为使十字准线正常工作,目前必须在将工具设置为活动之前添加视口
toolGroup.addViewport(viewportId1, renderingEngineId);
toolGroup.addViewport(viewportId2, renderingEngineId);
toolGroup.addViewport(viewportId3, renderingEngineId);
toolGroup.addTool(ZoomTool.toolName);
toolGroup.addTool(StackScrollMouseWheelTool.toolName);
// 添加十字准线工具并将其配置为连接三个视口
toolGroup.addTool(CrosshairsTool.toolName, {
getReferenceLineColor,
getReferenceLineControllable,
getReferenceLineDraggableRotatable,
getReferenceLineSlabThicknessControlsOn,
});
toolGroup.addTool(WindowLevelTool.toolName);
toolGroup.setToolActive(CrosshairsTool.toolName, {
bindings: [{ mouseButton: 1 }],
});
toolGroup.setToolActive(ZoomTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Secondary, // 鼠标中键
},
],
});
// using the `mouseWheelCallback`
toolGroup.setToolActive(StackScrollMouseWheelTool.toolName);
toolGroup.setToolActive(WindowLevelTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Auxiliary,
},
],
});
}
renderingEngine.current.renderViewports(viewportIds);
}
};
run();
return () => {
renderingEngine.current?.disableElement(viewportId1);
renderingEngine.current?.disableElement(viewportId2);
renderingEngine.current?.disableElement(viewportId3);
// 从 ToolGroupManager 中移除工具组
ToolGroupManager.destroyToolGroup(toolGroupId);
// 销毁渲染引擎
renderingEngine.current?.destroy();
};
}, [props, renderingEngineId, toolGroupId]);
/**
* mpr resize
*/
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => renderingEngine.current?.resize(), 100);
});
resizeObserver.observe(container);
return () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeObserver.unobserve(container);
resizeObserver.disconnect();
};
}, []);
return (
<div ref={containerRef} className="w-full h-full flex flex-col">
<div
className="w-full h-1/3 border-b border-secondary"
ref={viewportRef_AXIAL}
/>
<div
className="w-full h-1/3 border-b border-secondary"
ref={viewportRef_SAGITTAL}
/>
<div className="w-full h-1/3" ref={viewportRef_CORONAL} />
</div>
);
};

View File

@ -5,6 +5,11 @@ export type ViewportId = "CT_AXIAL" | "CT_SAGITTAL" | "CT_CORONAL";
export const viewportId1: ViewportId = "CT_AXIAL"; export const viewportId1: ViewportId = "CT_AXIAL";
export const viewportId2: ViewportId = "CT_SAGITTAL"; export const viewportId2: ViewportId = "CT_SAGITTAL";
export const viewportId3: ViewportId = "CT_CORONAL"; export const viewportId3: ViewportId = "CT_CORONAL";
export const viewportIds: ViewportId[] = [
viewportId1,
viewportId2,
viewportId3,
];
export const viewportColors = { export const viewportColors = {
[viewportId1]: "rgb(200, 0, 0)", [viewportId1]: "rgb(200, 0, 0)",
@ -34,3 +39,24 @@ export const viewportReferenceLineSlabThicknessControlsOn = [
export const volumeName = "CT_VOLUME_ID"; // Id of the volume less loader prefix export const volumeName = "CT_VOLUME_ID"; // Id of the volume less loader prefix
export const volumeLoaderScheme = "cornerstoneStreamingImageVolume"; // Loader id which defines which volume loader to use export const volumeLoaderScheme = "cornerstoneStreamingImageVolume"; // Loader id which defines which volume loader to use
export const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id export const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id
export const toolGroupMprId = "toolMpr";
export const toolGroupStackId = "toolStack";
export function getReferenceLineColor(vpId: ViewportId) {
return viewportColors[vpId];
}
export function getReferenceLineControllable(vpId: ViewportId) {
const index = viewportReferenceLineControllable.indexOf(vpId);
return index !== -1;
}
export function getReferenceLineDraggableRotatable(vpId: ViewportId) {
const index = viewportReferenceLineDraggableRotatable.indexOf(vpId);
return index !== -1;
}
export function getReferenceLineSlabThicknessControlsOn(vpId: ViewportId) {
const index = viewportReferenceLineSlabThicknessControlsOn.indexOf(vpId);
return index !== -1;
}

View File

@ -0,0 +1,2 @@
export const stackViewportId = "stackNo1";
export const toolGroupStackId = "toolStackNo1";

View File

@ -1,158 +0,0 @@
import { useEffect, useRef, useState } from "react";
import * as cornerstoneTools from "@cornerstonejs/tools";
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";
import { ctVoiRange } from "../MprViewer/CornerstoneDicomLoader/setCtTransferFunctionForVolumeActor";
const {
ToolGroupManager,
WindowLevelTool,
ZoomTool,
Enums: csToolsEnums,
} = cornerstoneTools;
const { MouseBindings } = csToolsEnums;
export interface StackViewerProps {
imageIds: string[];
wwwl: {
windowCenter: number;
windowWidth: number;
};
}
export const StackViewer = (props: StackViewerProps) => {
const viewportStackRef = useRef<HTMLDivElement | null>(null);
const renderingEngineRef = useRef<RenderingEngine>();
/**
* index
*/
const [index, setIndex] = useState(Math.floor(props.imageIds.length / 2));
const containerRef = useRef<HTMLDivElement | null>(null);
const viewportId = "stackViewport";
const renderingEngineId = "stackRenderingEngine";
const toolGroupId = "stackToolGroup";
useEffect(() => {
if (!viewportStackRef.current) return;
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
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);
// viewport.setProperties({
// voiRange: ctVoiRange,
// });
if (toolGroup) {
toolGroup.addViewport(viewportId, renderingEngineId);
toolGroup.addTool(ZoomTool.toolName);
toolGroup.setToolActive(ZoomTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Secondary, // 鼠标中键
},
],
});
toolGroup.addTool(WindowLevelTool.toolName);
toolGroup.setToolActive(WindowLevelTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Auxiliary,
},
],
});
}
return () => {
ToolGroupManager.destroyToolGroup(toolGroupId);
renderingEngineRef.current?.disableElement(viewportId);
renderingEngineRef.current?.destroy();
};
}, [props.imageIds, renderingEngineId, viewportId]);
/**
*
*/
useEffect(() => {
const handleWheel = (event: WheelEvent) => {
const delta = event.deltaY > 0 ? -1 : 1;
if (delta === -1 && index === 0) return;
if (delta === 1 && index === props.imageIds.length - 1) return;
setIndex((p) => p + delta);
};
const stackElement = viewportStackRef.current;
stackElement?.addEventListener("wheel", handleWheel);
return () => {
stackElement?.removeEventListener("wheel", handleWheel);
};
}, [index, props.imageIds.length]);
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[]) => {
setIndex(value[0]);
};
useEffect(() => {
if (renderingEngineRef.current) {
const viewport = renderingEngineRef.current.getViewport(viewportId);
(viewport as IStackViewport).setImageIdIndex(index);
}
}, [index]);
return (
<div
ref={containerRef}
className="relative w-full h-full flex flex-col pb-8"
>
<div className="w-full h-full" ref={viewportStackRef} />
<div className="absolute w-1/2 left-1/2 transform -translate-x-1/2 bottom-4">
<div>
{index + 1}/{props.imageIds.length}
</div>
<Slider
value={[index]}
min={0}
max={props.imageIds.length - 1}
step={1}
onValueChange={onChangeIndex}
/>
</div>
</div>
);
};

View File

@ -5,9 +5,14 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { ResetIcon } from "@radix-ui/react-icons";
import { MoonIcon, PersonStanding } from "lucide-react"; import { MoonIcon, PersonStanding } from "lucide-react";
export const ToolBarMenu = () => { export interface ToolBarMenuProps {
onToolButtonClick?: (key: string) => void;
}
export const ToolBarMenu = (props: ToolBarMenuProps) => {
return ( return (
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<TooltipProvider> <TooltipProvider>
@ -34,6 +39,22 @@ export const ToolBarMenu = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => props.onToolButtonClick?.("reset")}
>
<ResetIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
); );
}; };

View File

@ -0,0 +1,11 @@
.stack-slider {
>span:first-child{
height: 8px;
border-radius: 0;
}
[role="slider"] {
width: 16px;
height: 8px;
border-radius: 0;
}
}

View File

@ -1,81 +1,273 @@
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { initCornerstone } from "./MprViewer/CornerstoneDicomLoader/init"; import { initCornerstone } from "./MprViewer/CornerstoneDicomLoader/init";
import { CrosshairMpr } from "./MprViewer/Crosshair"; import * as cornerstoneTools from "@cornerstonejs/tools";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { StackViewer } from "./StackViewer";
import { createImageIdsAndCacheMetaData } from "./MprViewer/CornerstoneDicomLoader/createImageIdsAndCacheMetaData"; import { createImageIdsAndCacheMetaData } from "./MprViewer/CornerstoneDicomLoader/createImageIdsAndCacheMetaData";
import {
RenderingEngine,
setVolumesForViewports,
volumeLoader,
Enums as CoreEnums,
cache,
} from "@cornerstonejs/core";
import * as cornerstoneTools from "@cornerstonejs/tools"; import {
import { volumeLoader } from "@cornerstonejs/core"; IStackViewport,
IVolumeViewport,
PublicViewportInput,
} from "@cornerstonejs/core/dist/types/types";
import {
getReferenceLineColor,
getReferenceLineControllable,
getReferenceLineDraggableRotatable,
getReferenceLineSlabThicknessControlsOn,
toolGroupMprId,
toolGroupStackId,
viewportIds,
} from "./MprViewer/index.config";
import { Slider } from "@/components/ui/slider";
import { stackViewportId } from "./StackViewer/index.config";
import { ToolBarMenu } from "./ToolBarMenu";
import { Model3DViewer } from "./ModelViewer";
import useMultiResizeObserver from "./useMultiResizeObserver";
import "./index.css";
const {
ToolGroupManager,
CrosshairsTool,
StackScrollMouseWheelTool,
WindowLevelTool,
ZoomTool,
Enums: csToolsEnums,
} = cornerstoneTools;
const { MouseBindings } = csToolsEnums;
const wadoRsRoot = "http://localhost:8042/dicom-web"; const wadoRsRoot = "http://localhost:8042/dicom-web";
const renderingEngineId = "renderEngineNo1";
const { StackScrollMouseWheelTool, WindowLevelTool, ZoomTool, CrosshairsTool } = const { ViewportType, OrientationAxis } = CoreEnums;
cornerstoneTools;
export const Viewer = () => { export const Viewer = () => {
const [cornerstoneLoaded, setCornerstoneLoaded] = useState(false); const volumeViewport1Ref = useRef<HTMLDivElement | null>(null);
const volumeViewport2Ref = useRef<HTMLDivElement | null>(null);
const volumeViewport3Ref = useRef<HTMLDivElement | null>(null);
const stackViewportRef = useRef<HTMLDivElement | null>(null);
/**
* index
*/
const [index, setIndex] = useState<number>(0);
const location = useLocation(); const location = useLocation();
const queryParams = new URLSearchParams(location.search); const queryParams = new URLSearchParams(location.search);
const SeriesInstanceUID = queryParams.get("SeriesInstanceUID"); const SeriesInstanceUID = queryParams.get("SeriesInstanceUID");
const StudyInstanceUID = queryParams.get("StudyInstanceUID"); const StudyInstanceUID = queryParams.get("StudyInstanceUID");
const imageIdsSorted = useRef<string[]>(); const [imageIds, setImageIds] = useState<string[]>();
const wwwl = { windowCenter: 50, windowWidth: 850 }; const renderingEngineRef = useRef<RenderingEngine>();
const volumeId = "volumeId"; const volumeId = SeriesInstanceUID;
useMultiResizeObserver(
[
stackViewportRef,
volumeViewport1Ref,
volumeViewport2Ref,
volumeViewport3Ref,
],
() => renderingEngineRef.current?.resize()
);
useEffect(() => { useEffect(() => {
const setImageOrderCache = async () => { cornerstoneTools.addTool(StackScrollMouseWheelTool);
cornerstoneTools.addTool(CrosshairsTool);
cornerstoneTools.addTool(WindowLevelTool);
cornerstoneTools.addTool(ZoomTool);
const renderViewport = async () => {
if (!StudyInstanceUID || !SeriesInstanceUID) return; if (!StudyInstanceUID || !SeriesInstanceUID) return;
if (!volumeViewport1Ref.current) return;
if (!volumeViewport2Ref.current) return;
if (!volumeViewport3Ref.current) return;
if (!stackViewportRef.current) return;
renderingEngineRef.current = new RenderingEngine(renderingEngineId);
// imageIds此时由于流式加载for mpr是错乱的图片顺序 // imageIds此时由于流式加载for mpr是错乱的图片顺序
const unSortedImageIds = await createImageIdsAndCacheMetaData({ const imageIds = await createImageIdsAndCacheMetaData({
StudyInstanceUID, StudyInstanceUID,
SeriesInstanceUID, SeriesInstanceUID,
wadoRsRoot, wadoRsRoot,
}); });
// 这一步会对imageIds进行排序如果不排序imageIds会错误乱,stackViewport顺序会错误 // 这一步会对imageIds进行排序如果不排序imageIds会错误乱,stackViewport顺序会错误
const volume = await volumeLoader.createAndCacheVolume(volumeId, { const volume = await volumeLoader.createAndCacheVolume(
imageIds: unSortedImageIds, SeriesInstanceUID,
}); {
imageIds,
}
);
volume.load(); volume.load();
imageIdsSorted.current = volume.imageIds; const volumeViewportInput: PublicViewportInput[] = [
{
viewportId: viewportIds[0],
type: ViewportType.ORTHOGRAPHIC,
element: volumeViewport1Ref.current,
defaultOptions: {
orientation: OrientationAxis.AXIAL,
background: [0, 0, 0],
},
},
{
viewportId: viewportIds[1],
type: ViewportType.ORTHOGRAPHIC,
element: volumeViewport2Ref.current,
defaultOptions: {
orientation: OrientationAxis.SAGITTAL,
background: [0, 0, 0],
},
},
{
viewportId: viewportIds[2],
type: ViewportType.ORTHOGRAPHIC,
element: volumeViewport3Ref.current,
defaultOptions: {
orientation: OrientationAxis.CORONAL,
background: [0, 0, 0],
},
},
];
const stackViewportInput: PublicViewportInput = {
viewportId: stackViewportId,
type: ViewportType.STACK, // 用于 Stack 视图
element: stackViewportRef.current,
defaultOptions: {
background: [0, 0, 0],
},
};
// 默认windowWidtth renderingEngineRef.current.setViewports([
const { windowCenter, windowWidth } = volume.cornerstoneImageMetaData; ...volumeViewportInput,
console.log("默认窗宽/位: ", windowCenter, windowWidth); stackViewportInput,
]);
const stackViewport = renderingEngineRef.current.getViewport(
stackViewportId
) as IStackViewport;
setCornerstoneLoaded(true); stackViewport.setStack(volume.imageIds);
setImageIds(volume.imageIds);
const toolGroupMpr = ToolGroupManager.createToolGroup(toolGroupMprId);
if (toolGroupMpr) {
viewportIds.forEach((vp) =>
toolGroupMpr.addViewport(vp, renderingEngineId)
);
toolGroupMpr.addTool(ZoomTool.toolName);
toolGroupMpr.addTool(WindowLevelTool.toolName);
toolGroupMpr.addTool(StackScrollMouseWheelTool.toolName);
toolGroupMpr.addTool(CrosshairsTool.toolName, {
getReferenceLineColor,
getReferenceLineControllable,
getReferenceLineDraggableRotatable,
getReferenceLineSlabThicknessControlsOn,
});
toolGroupMpr.setToolActive(ZoomTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Secondary }],
});
toolGroupMpr.setToolActive(CrosshairsTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
toolGroupMpr.setToolActive(StackScrollMouseWheelTool.toolName);
toolGroupMpr.setToolActive(WindowLevelTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Auxiliary }],
});
}
const toolGroupStack = ToolGroupManager.createToolGroup(toolGroupStackId);
if (toolGroupStack) {
toolGroupStack.addViewport(stackViewportId, renderingEngineId);
toolGroupStack.addTool(ZoomTool.toolName);
toolGroupStack.setToolActive(ZoomTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Secondary }],
});
toolGroupStack.addTool(WindowLevelTool.toolName);
toolGroupStack.setToolActive(WindowLevelTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Auxiliary }],
});
}
/**
* toolGroupviewportInfo
*/
await setVolumesForViewports(
renderingEngineRef.current,
[{ volumeId: SeriesInstanceUID }],
viewportIds
);
}; };
initCornerstone(() => { initCornerstone(() => {
setImageOrderCache(); renderViewport();
cornerstoneTools.addTool(StackScrollMouseWheelTool);
cornerstoneTools.addTool(WindowLevelTool);
cornerstoneTools.addTool(ZoomTool);
cornerstoneTools.addTool(CrosshairsTool);
}); });
return () => { return () => {
// 移出工具注册 cache.purgeCache();
cornerstoneTools.removeTool(CrosshairsTool); ToolGroupManager.destroy();
renderingEngineRef.current?.destroy();
cornerstoneTools.removeTool(StackScrollMouseWheelTool); cornerstoneTools.removeTool(StackScrollMouseWheelTool);
cornerstoneTools.removeTool(CrosshairsTool);
cornerstoneTools.removeTool(WindowLevelTool); cornerstoneTools.removeTool(WindowLevelTool);
cornerstoneTools.removeTool(ZoomTool); cornerstoneTools.removeTool(ZoomTool);
}; };
}, [SeriesInstanceUID, StudyInstanceUID]); }, [SeriesInstanceUID, StudyInstanceUID, volumeId]);
const onChangeIndex = (value: number[]) => setIndex(value[0]);
/**
*
*/
useEffect(() => {
const handleWheel = (event: WheelEvent) => {
console.log("wheel");
if (!imageIds) return;
const delta = event.deltaY > 0 ? -1 : 1;
if (delta === -1 && index === 0) return;
if (delta === 1 && index === imageIds?.length - 1) return;
setIndex((p) => p + delta);
};
const stackElement = stackViewportRef.current;
stackElement?.addEventListener("wheel", handleWheel);
return () => {
stackElement?.removeEventListener("wheel", handleWheel);
};
}, [imageIds, index]);
// stack换图
useEffect(() => {
const engine = renderingEngineRef.current;
if (engine) {
const viewport = engine.getViewport(stackViewportId) as IStackViewport;
viewport?.setImageIdIndex(index);
}
}, [index]);
const onToolMenuClick = (key: string) => {
const engine = renderingEngineRef.current;
if (key === "reset" && engine) {
const viewport = engine.getViewport(viewportIds[0]) as IVolumeViewport;
viewport.resetCamera(true, true, true, true, false);
viewport.render();
}
};
return ( return (
<div className="w-full h-full flex flex-col"> <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 /> <ToolBarMenu onToolButtonClick={onToolMenuClick} />
</div> */} </div>
<div className="flex-grow"> <div className="flex-grow">
<ResizablePanelGroup direction="horizontal" className="w-full h-full"> <ResizablePanelGroup direction="horizontal" className="w-full h-full">
<ResizablePanel defaultSize={50}> <ResizablePanel defaultSize={50}>
@ -85,27 +277,49 @@ export const Viewer = () => {
className="w-full h-full" className="w-full h-full"
> >
<ResizablePanel defaultSize={50}> <ResizablePanel defaultSize={50}>
<div className="w-full h-full"> <div className="w-full h-full flex flex-col relative">
{cornerstoneLoaded && imageIdsSorted.current && ( <div ref={stackViewportRef} className="w-full flex-grow" />
<StackViewer {imageIds && (
imageIds={imageIdsSorted.current} <div className="absolute left-0 right-0 top-0 text-end">
wwwl={wwwl} <Slider
value={[index]}
min={0}
max={imageIds.length - 1}
step={1}
className="stack-slider"
onValueChange={onChangeIndex}
/> />
<div className="text-xs">
{index + 1}/{imageIds.length}
</div>
</div>
)} )}
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle={false} />
<ResizablePanel defaultSize={50}> <ResizablePanel defaultSize={50}>
<div>bototm</div> <div className="w-full h-full">
<Model3DViewer />
</div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle />
<ResizablePanel defaultSize={50}> <ResizablePanel defaultSize={50}>
{cornerstoneLoaded && imageIdsSorted.current && ( <ResizablePanelGroup direction="vertical" className="w-full h-full">
<CrosshairMpr wwwl={wwwl} imageIds={imageIdsSorted.current} /> <ResizablePanel defaultSize={1 / 3}>
)} <div className="h-full" ref={volumeViewport1Ref} />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={1 / 3}>
<div className="h-full" ref={volumeViewport2Ref} />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={1 / 3}>
<div className="h-full" ref={volumeViewport3Ref} />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>

View File

@ -0,0 +1,41 @@
import { useEffect, RefObject } from "react";
type ResizeObserverCallback = () => void;
/**
* Hook Ref
* @param refs - Ref
* @param callback -
*/
function useMultiResizeObserver(
refs: Array<RefObject<Element>>,
callback: ResizeObserverCallback
) {
useEffect(() => {
const elements = refs
.map((ref) => ref.current)
.filter((el): el is Element => el !== null);
if (elements.length === 0) return;
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(callback, 100);
});
elements.forEach((element) => {
resizeObserver.observe(element);
});
return () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
elements.forEach((element) => {
resizeObserver.unobserve(element);
});
resizeObserver.disconnect();
};
}, [refs, callback]);
}
export default useMultiResizeObserver;

View File

@ -34,4 +34,11 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: {
cors: true,
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
}); });

View File

@ -151,6 +151,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.408.0 specifier: ^0.408.0
version: 0.408.0(react@18.3.1) version: 0.408.0(react@18.3.1)
mitt:
specifier: 3.0.1
version: 3.0.1
node-machine-id: node-machine-id:
specifier: 1.1.12 specifier: 1.1.12
version: 1.1.12 version: 1.1.12
@ -163,6 +166,9 @@ importers:
openvino-node: openvino-node:
specifier: 2024.3.0 specifier: 2024.3.0
version: 2024.3.0 version: 2024.3.0
p-limit:
specifier: 6.1.0
version: 6.1.0
react: react:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.3.1 version: 18.3.1
@ -4034,6 +4040,9 @@ packages:
resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp-classic@0.5.3: mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@ -4182,6 +4191,10 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
p-limit@6.1.0:
resolution: {integrity: sha512-H0jc0q1vOzlEk0TqAKXKZxdl7kX3OFUzCnNVUnq5Pc3DGo0kpeaMuPqxQn235HibwBEb0/pm9dgKTjXy66fBkg==}
engines: {node: '>=18'}
p-locate@5.0.0: p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5504,6 +5517,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
yocto-queue@1.1.1:
resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==}
engines: {node: '>=12.20'}
zip-stream@4.1.1: zip-stream@4.1.1:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@ -9845,6 +9862,8 @@ snapshots:
minipass: 7.1.2 minipass: 7.1.2
rimraf: 5.0.10 rimraf: 5.0.10
mitt@3.0.1: {}
mkdirp-classic@0.5.3: {} mkdirp-classic@0.5.3: {}
mkdirp@1.0.4: {} mkdirp@1.0.4: {}
@ -10003,6 +10022,10 @@ snapshots:
dependencies: dependencies:
yocto-queue: 0.1.0 yocto-queue: 0.1.0
p-limit@6.1.0:
dependencies:
yocto-queue: 1.1.1
p-locate@5.0.0: p-locate@5.0.0:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
@ -11524,6 +11547,8 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
yocto-queue@1.1.1: {}
zip-stream@4.1.1: zip-stream@4.1.1:
dependencies: dependencies:
archiver-utils: 3.0.4 archiver-utils: 3.0.4