Compare commits
18 Commits
feat/test-
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
66d3350edc | ||
|
e4eb06d71c | ||
|
67b5e488fe | ||
|
2cbab89273 | ||
|
e5bf5685cf | ||
|
dff24401bf | ||
|
d09d852207 | ||
|
ba516af2f7 | ||
|
a0f4ca2f3a | ||
|
cc783768db | ||
|
7291840ed8 | ||
|
f2f7382fec | ||
|
98b5b354d9 | ||
|
9917b77077 | ||
|
5814637d7a | ||
|
21914f3077 | ||
|
c52b3f1e00 | ||
|
039325b768 |
|
@ -23,6 +23,8 @@ pnpm config set virtual-store-dir-max-length 70
|
||||||
|
|
||||||
- 不同结构的窗宽窗位快速选择,需要考虑不同身体结构的dicom image
|
- 不同结构的窗宽窗位快速选择,需要考虑不同身体结构的dicom image
|
||||||
|
|
||||||
|
- 炸显存问题,electron关闭硬件加速,还是无法接解决1000+外周的mpr
|
||||||
|
|
||||||
## 窗宽创维相关的一些小问题
|
## 窗宽创维相关的一些小问题
|
||||||
|
|
||||||
- 心脏软组织窗
|
- 心脏软组织窗
|
||||||
|
|
55
apps/desktop/electron/core/alg.ts
Normal file
55
apps/desktop/electron/core/alg.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
29
apps/desktop/electron/core/alg.type.ts
Normal file
29
apps/desktop/electron/core/alg.type.ts
Normal 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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -108,11 +108,4 @@ export const uploadFilesInBatches = async ({
|
||||||
totalEndTime - totalStartTime
|
totalEndTime - totalStartTime
|
||||||
} ms`
|
} ms`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
|
||||||
totalSuccess,
|
|
||||||
totalFailed,
|
|
||||||
totalTime: totalEndTime - totalStartTime,
|
|
||||||
progress: 100,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"));
|
||||||
|
|
|
@ -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.
|
@ -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>
|
||||||
|
|
5
apps/desktop/src/lib/events.ts
Normal file
5
apps/desktop/src/lib/events.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import mitt from "mitt";
|
||||||
|
|
||||||
|
const emitter = mitt();
|
||||||
|
|
||||||
|
export default emitter;
|
|
@ -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" />
|
||||||
启动分析
|
启动分析
|
||||||
|
|
|
@ -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(() => {
|
||||||
window.ipcRenderer
|
const fetchData = () => {
|
||||||
.invoke("dicom:select")
|
window.ipcRenderer
|
||||||
.then((patients: PatientInfo[]) => {
|
.invoke("dicom:select")
|
||||||
console.log(patients);
|
.then((patients: PatientInfo[]) => {
|
||||||
rawPatientsRef.current = patients;
|
console.log("patients", patients);
|
||||||
setPatients(patients);
|
rawPatientsRef.current = patients;
|
||||||
});
|
setPatients(patients);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
emitter.on("datasource:fetch", fetchData);
|
||||||
|
return () => {
|
||||||
|
emitter.off("datasource:fetch", fetchData);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePatientSearch = (filterValue: string) => {
|
const handlePatientSearch = (filterValue: string) => {
|
||||||
|
|
3
apps/desktop/src/pages/Viewer/ModelViewer/index.tsx
Normal file
3
apps/desktop/src/pages/Viewer/ModelViewer/index.tsx
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const Model3DViewer = () => {
|
||||||
|
return <div>3d model</div>;
|
||||||
|
};
|
|
@ -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 }],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const stackViewportId = "stackNo1";
|
||||||
|
export const toolGroupStackId = "toolStackNo1";
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
11
apps/desktop/src/pages/Viewer/index.css
Normal file
11
apps/desktop/src/pages/Viewer/index.css
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.stack-slider {
|
||||||
|
>span:first-child{
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
[role="slider"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 先注册toolGroup,不然会找不到viewportInfo
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
|
41
apps/desktop/src/pages/Viewer/useMultiResizeObserver.tsx
Normal file
41
apps/desktop/src/pages/Viewer/useMultiResizeObserver.tsx
Normal 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;
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user