feat: 更新了查询orthanc数据结构

This commit is contained in:
mozzie 2024-09-11 17:01:22 +08:00
parent 9b905b7498
commit 5aa710431d
10 changed files with 427 additions and 159 deletions

View File

@ -1,26 +1,104 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import log from 'electron-log'
import log from "electron-log";
import path from "node:path";
import FormData from "form-data";
import { readFile } from "fs/promises";
import axios from "axios";
import { PatientInfo, SeriesInfo, StudyInfo } from "./pacs.type";
export const getPacsPath = (platform: "macos" | "windows", isDevelopment: boolean): string => {
export const OrthancServerRoot = "http://localhost:8042";
export const getPacsPath = (
platform: "macos" | "windows",
isDevelopment: boolean
): string => {
const orthancExecFile = {
macos: "orthanc-mac-24.8.1/Orthanc",
windows: "orthanc-win64-1.12.4/Orthanc.exe"
}
windows: "orthanc-win64-1.12.4/Orthanc.exe",
};
const basePath = isDevelopment
? path.join(process.env.VITE_PUBLIC, "../extraResources")
: path.join(process.resourcesPath, 'lib');
return path.join(basePath, orthancExecFile[platform])
}
: path.join(process.resourcesPath, "lib");
return path.join(basePath, orthancExecFile[platform]);
};
export const runOrthancServer = (pacsPath: string) => {
if (existsSync(pacsPath)) {
const child_process = spawn(pacsPath)
child_process.stdout.on('data', data => log.info(data))
const child_process = spawn(pacsPath);
child_process.stdout.on("data", (data) => log.info(data));
// child_process.stderr.on('data', data => log.error(data))
} else {
console.error('pacsPath is a not exist')
log.error('pacsPath is a not exist')
console.error("pacsPath is a not exist");
log.error("pacsPath is a not exist");
}
}
};
/**
* pacs
* @param {string} filePath
* @param {string} orthancUrl orthanc的服务地址
* @returns
*/
export const uploadDicomFile = async (
filePath: string,
orthancUrl: string = OrthancServerRoot
): Promise<boolean> => {
try {
const buffer = await readFile(filePath);
const fd = new FormData();
fd.append("files", buffer);
const url = `${orthancUrl}/instances`;
const headers = { "Content-Type": "multipart/form-data" };
const { status } = await axios.post(url, fd, { headers });
return status === 200;
} catch (error) {
log.error("Failed to upload DICOM file:", error);
console.error("Failed to upload DICOM file:", error);
return false;
}
};
export const selectStructuredDicom = async (
orthancUrl: string = OrthancServerRoot
) => {
try {
const response = await axios.get(`${orthancUrl}/patients`);
const patientIds: string[] = response.data;
const patients: PatientInfo[] = [];
for (const patientId of patientIds) {
const patientDetailsResponse = await axios.get<PatientInfo>(
`${orthancUrl}/patients/${patientId}`
);
const patientDetails = patientDetailsResponse.data;
const studyIds = patientDetails.Studies;
const studies: StudyInfo[] = [];
for (const studyId of studyIds) {
const studyDetailsResponse = await axios.get<StudyInfo>(
`${orthancUrl}/studies/${studyId}`
);
const studyDetails = studyDetailsResponse.data;
const seriesIds = studyDetails.Series;
const series: SeriesInfo[] = [];
for (const seriesId of seriesIds) {
const seriesDetailsResponse = await axios.get<SeriesInfo>(
`${orthancUrl}/series/${seriesId}`
);
const seriesDetails = seriesDetailsResponse.data;
series.push({ ...seriesDetails });
}
studies.push({ ...studyDetails, children: series });
}
patients.push({ ...patientDetails, children: studies });
}
return patients;
} catch (error) {
console.error("Error fetching detailed patient information:", error);
throw error; // or handle it accordingly
}
};

View File

@ -0,0 +1,68 @@
export interface PatientInfo {
ID: string;
LastUpdate: string;
MainDicomTags: {
OtherPatientIDs: string;
PatientBirthDate: string;
PatientID: string;
PatientName: string;
PatientSex: string;
};
Studies: string[];
Type: string;
children: StudyInfo[];
}
export interface StudyInfo {
ID: string;
MainDicomTags: {
AccessionNumber: string;
StudyDate: string;
StudyDescription: string;
StudyID: string;
StudyInstanceUID: string;
StudyTime: string;
};
ParentPatient: string;
Series: string[];
Type: string;
children: SeriesInfo[];
}
export interface SeriesInfo {
ExpectedNumberOfInstances: number;
ID: string;
Instances: string[];
MainDicomTags: {
Manufacturer: string;
Modality: string;
NumberOfSlices: string;
ProtocolName: string;
SeriesDate: string;
SeriesDescription: string;
SeriesInstanceUID: string;
SeriesNumber: string;
SeriesTime: string;
StationName: string;
};
ParentStudy: string;
Status: string;
Type: string;
children: [];
}
export interface InstanceInfo {
FileSize: number;
FileUuid: string;
ID: string;
IndexInSeries: number;
MainDicomTags: {
ImageIndex: string;
InstanceCreationDate: string;
InstanceCreationTime: string;
InstanceNumber: string;
SOPInstanceUID: string;
};
ParentSeries: string;
Type: string;
}

View File

@ -0,0 +1,33 @@
import { ipcMain, shell } from "electron";
import { mkdir, stat } from "fs/promises";
import { db } from "../../core/db";
import log from "electron-log";
import path from "node:path";
export const registerCommonHandler = () => {
ipcMain.handle("output:open", async () => {
await db.read();
const optPath = db.data.setting.outputPath;
const resolvedPath = path.resolve(optPath);
try {
// 检查路径是否存在
const stats = await stat(resolvedPath);
if (stats.isDirectory()) shell.openPath(resolvedPath);
} catch (error) {
log.error(error);
await mkdir(resolvedPath, { recursive: true });
shell.openPath(resolvedPath);
}
});
ipcMain.handle("device:infer:set", async (_event, inferDevice) => {
try {
await db.update(({ setting }) => ({ ...setting, inferDevice }));
return { success: true, msg: `推理硬件修改为${inferDevice}` };
} catch (error) {
await db.update(({ setting }) => ({ ...setting, inferDevice }));
log.error(error);
return { success: false, msg: `操作失败` };
}
});
};

View File

@ -1,12 +1,32 @@
import { dialog, ipcMain } from "electron";
import { filterDicoms, uploadFilesInBatches } from "./util";
import { selectStructuredDicom } from "../../core/pacs";
import dayjs from "dayjs";
export const registerDicomHandler = () => {
ipcMain.handle("dicom:upload", async () => {
ipcMain.on("dicom:upload", async (event) => {
const dia = await dialog.showOpenDialog({ properties: ["openDirectory"] });
if (dia.canceled) return null;
const dcmPaths = await filterDicoms(dia.filePaths[0]);
uploadFilesInBatches(dcmPaths, 5);
// return dia.filePaths[0];
uploadFilesInBatches({
filePaths: dcmPaths,
batchSize: 6,
feedback: (d) => event.reply("dicom:upload:detail", d),
});
});
ipcMain.handle("dicom:select", async () => {
const patients = await selectStructuredDicom();
const sortedPatients = [...patients]
.map((patient) => ({
...patient,
LastUpdate: dayjs(patient.LastUpdate).format("YYYY-MM-DD HH:mm:ss"), // 格式化日期
}))
.sort((a, b) => {
return (
new Date(b.LastUpdate).getTime() - new Date(a.LastUpdate).getTime()
);
});
return sortedPatients;
});
};

View File

@ -1,8 +1,6 @@
import path from "node:path";
import fs from "node:fs";
import axios from "axios";
import FormData from "form-data";
import log from "electron-log";
import { uploadDicomFile } from "../../core/pacs";
/**
* DICOM Magic Number
@ -52,49 +50,69 @@ export const filterDicoms = async (
return fileList;
};
export const uploadDicomFile = async (
filePath: string,
orthancUrl: string = "http://localhost:8042"
) => {
try {
const buffer = await fs.promises.readFile(filePath);
const fd = new FormData();
fd.append("files", buffer);
const url = `${orthancUrl}/instances`;
const headers = { "Content-Type": "multipart/form-data" };
const { status } = await axios.post(url, fd, { headers });
return status === 200;
} catch (error) {
log.error("Failed to upload DICOM file:", error);
console.error("Failed to upload DICOM file:", error);
}
};
interface UploadFilesInBatchesParams {
filePaths: string[];
batchSize: number;
orthancUrl?: string;
feedback?: (detail: {
progress: number;
totalSuccess: number;
totalFailed: number;
}) => void;
}
export const uploadFilesInBatches = async (
filePaths: string[],
batchSize: number,
orthancUrl: string = "http://localhost:8042"
) => {
const results = [];
const totalStartTime = Date.now(); // 记录总体开始时间
for (let i = 0; i < filePaths.length; i += batchSize) {
/**
*
* @param filePaths
* @param batchSize
* @param orthancUrl Orthanc URL
* @param feedback
*/
export const uploadFilesInBatches = async ({
filePaths,
batchSize,
feedback,
}: UploadFilesInBatchesParams) => {
const totalStartTime = Date.now();
let totalSuccess = 0;
let totalFailed = 0;
const totalFiles = filePaths.length;
for (let i = 0; i < totalFiles; i += batchSize) {
const batch = filePaths.slice(i, i + batchSize);
const batchResults = await Promise.allSettled(
batch.map((filePath) => uploadDicomFile(filePath, orthancUrl))
batch.map((filePath) => uploadDicomFile(filePath))
);
// 提取状态为 'fulfilled' 的结果的 value
const fulfilledResults = batchResults
.filter((result) => result.status === "fulfilled")
.map((result) => (result as PromiseFulfilledResult<boolean>).value);
results.push(...fulfilledResults);
batchResults.forEach((result) => {
if (result.status === "fulfilled" && result.value) {
totalSuccess++;
} else {
totalFailed++;
}
});
const actualProcessed = i + batch.length;
const progress = Math.round((actualProcessed / totalFiles) * 100);
const detail = {
progress: Math.min(progress, 100),
totalSuccess,
totalFailed,
};
if (feedback) feedback(detail);
}
const totalEndTime = Date.now(); // 记录总体结束时间
const totalSuccess = results.filter(Boolean).length;
const totalFailed = results.length - totalSuccess;
log.info(
const totalEndTime = Date.now();
console.log(
`[上传序列] Success: ${totalSuccess}, Failed: ${totalFailed}, Total upload time: ${
totalEndTime - totalStartTime
} ms`
);
return results;
return {
totalSuccess,
totalFailed,
totalTime: totalEndTime - totalStartTime,
progress: 100,
};
};

View File

@ -1,5 +1,6 @@
import { ipcMain } from "electron";
import { registerDicomHandler } from "./dicom/handler";
import { registerCommonHandler } from "./common";
export const registerIpcMainHandlers = (mainWindow: Electron.BrowserWindow) => {
ipcMain.removeAllListeners();
@ -9,5 +10,6 @@ export const registerIpcMainHandlers = (mainWindow: Electron.BrowserWindow) => {
*/
ipcMain.on("ipc-loaded", () => mainWindow.show());
registerCommonHandler();
registerDicomHandler();
};

View File

@ -13,12 +13,11 @@ import {
MenubarSubTrigger,
MenubarTrigger,
} from "@/components/ui/menubar";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { inferDeviceType } from "./type";
import { inferDevices } from "./constant";
import { useToast } from "@/components/ui/use-toast";
import { ToastAction } from "@/components/ui/toast";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Dialog,
@ -29,7 +28,6 @@ import {
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { RocketIcon } from "@radix-ui/react-icons";
import axios from "axios";
interface ScanProgress {
percentage: number;
@ -45,73 +43,29 @@ export const MenuBar = () => {
const [progress, setProgress] = useState<ScanProgress>(defaultProgress);
const [inferOption, setInferOption] =
useState<inferDeviceType[]>(inferDevices);
const [importDialogVisible, setImportDialogVisible] = useState(false);
const [, setResult] = useState<[]>([]);
useEffect(() => {
const handleScanProgress = (
const handleUploadFeedback = (
_event: Electron.IpcRendererEvent,
data: any
data: { progress: number; totalSuccess: number; totalFailed: number }
) => {
setProgress(data);
if (data.error) return;
setProgress({ percentage: data.progress });
};
window.ipcRenderer.on("scan-progress", handleScanProgress);
window.ipcRenderer.on("dicom:upload:detail", handleUploadFeedback);
return () => {
window.ipcRenderer.off("scan-progress", handleScanProgress);
window.ipcRenderer.off("dicom:upload:detail", handleUploadFeedback);
};
}, []);
useEffect(() => {
const handleScanFinished = (
_event: Electron.IpcRendererEvent,
data: any
) => {
const { scanDuration, structDicom } = data;
console.log(data);
setResult(structDicom);
if (data.error) {
return toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: "There was a problem with your request.",
action: <ToastAction altText="重试"></ToastAction>,
});
} else {
toast({
variant: "default",
title: "完成",
description: `导入${structDicom.length}组序列数据,耗时:${(
scanDuration / 1000
).toFixed(2)} s`,
action: (
<ToastAction
altText="启动AI测量"
onClick={() => handleTasks(structDicom)}
>
</ToastAction>
),
duration: 30 * 1000,
});
window.ipcRenderer.send("db:series:select");
}
};
window.ipcRenderer.on("scan-progress-done", handleScanFinished);
return () => {
window.ipcRenderer.off("scan-progress-done", handleScanFinished);
};
}, [toast]);
const visible = ![0, 100].includes(progress.percentage);
setImportDialogVisible(visible);
}, [progress.percentage]);
const handleImportDicom = () => {
navigate("datasource");
// window.ipcRenderer.send("import-dicom-dialog-visible");
window.ipcRenderer.invoke("dicom:upload").then((value) => {
console.log(value);
});
window.ipcRenderer.send("dicom:upload");
};
/**
@ -121,33 +75,13 @@ export const MenuBar = () => {
setInferOption((p) =>
p.map((i) => ({ ...i, checked: i.key === item.key }))
);
window.ipcRenderer.send("setInferDevice", item.key);
window.ipcRenderer.invoke("device:infer:set", item.key).then((res) => {
const { success, msg } = res;
if (success) toast({ title: "操作成功", description: msg });
});
};
useEffect(() => {
const actionToast = (
_event: Electron.IpcRendererEvent,
response: string
) => {
toast({
title: "操作成功",
description: response,
});
};
window.ipcRenderer.on("setInferDevice:response", actionToast);
return () => {
window.ipcRenderer.off("setInferDevice:response", actionToast);
};
}, [toast]);
const handleOpenOutputPath = () => {
window.ipcRenderer.send("openOutputPath");
};
const handleTasks = (structDicoms: any) => {
localStorage.setItem("selectDicoms", JSON.stringify(structDicoms));
navigate("/", { state: { selectDicoms: structDicoms } });
};
const handleOpenOutputPath = () => window.ipcRenderer.invoke("output:open");
return (
<>
@ -158,7 +92,7 @@ export const MenuBar = () => {
<MenubarTrigger></MenubarTrigger>
<MenubarContent>
<MenubarItem onSelect={handleImportDicom}>
Dicom<MenubarShortcut>T</MenubarShortcut>
Dicom<MenubarShortcut>T</MenubarShortcut>
</MenubarItem>
<MenubarItem onClick={handleOpenOutputPath}>
<MenubarShortcut>N</MenubarShortcut>

View File

@ -77,7 +77,7 @@ const Boot = () => {
<div className="p-4 h-full flex flex-col">
<ResizablePanelGroup direction="horizontal" className="w-full h-full">
<ResizablePanel defaultSize={38.2}>
<div className="flex flex-col h-full pt-4">
<div className="flex flex-col h-full">
<ScrollArea className="flex-grow w-full h-full px-4 pb-2">
<div className="w-full flex flex-col gap-y-2">
{tasks.map((dicom: Series, index: number) => (

View File

@ -1,30 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useEffect, useState } from "react";
import { Series, SeriesTable } from "./SeriesTable";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Card } from "@/components/ui/card";
import { PatientInfo } from "./type";
export const Datasource = () => {
const [seriesData, setSeriesData] = useState<Series[]>([]);
const [patients, setPatients] = useState<
(PatientInfo & { active: boolean })[]
>([]);
useEffect(() => {
window.ipcRenderer.send("db:series:select");
window.ipcRenderer
.invoke("dicom:select")
.then((patients: PatientInfo[]) => {
console.log(patients);
setPatients(patients.map((p) => ({ ...p, active: false })));
});
}, []);
const fetchSeriesData = useCallback(
(_event: Electron.IpcRendererEvent, data: any) => {
console.log(data);
setSeriesData(data);
},
[]
);
useEffect(() => {
window.ipcRenderer.once("db:series:select:response", fetchSeriesData);
return () => {
window.ipcRenderer.off("db:series:select:response", fetchSeriesData);
};
}, [fetchSeriesData]);
return (
<motion.div
className="h-full"
@ -33,7 +30,57 @@ export const Datasource = () => {
exit={{ y: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
>
<SeriesTable data={seriesData} />
<div className="p-4 h-full flex flex-col">
<ResizablePanelGroup direction="horizontal" className="w-full h-full">
<ResizablePanel defaultSize={24}>
<div className="flex flex-col h-full">
<ScrollArea className="flex-grow w-full h-full px-4 pb-2">
<div className="w-full flex flex-col gap-y-2">
{patients.map((patient) => (
<Card
key={patient.ID}
onClick={() =>
setPatients((p) =>
p.map((i) => ({ ...i, active: i.ID === patient.ID }))
)
}
className={`flex shadow-none flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent hover:cursor-pointer ${
patient.active ? "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">
{patient.MainDicomTags.PatientName}
</div>
<span className="flex h-2 w-2 rounded-full bg-blue-600"></span>
</div>
<div className="ml-auto text-xs text-foreground">
{patient.MainDicomTags.PatientSex}
</div>
</div>
<div className="text-xs font-medium">
{patient.MainDicomTags.PatientBirthDate}
</div>
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
: {patient.LastUpdate}
</div>
</Card>
))}
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={24}>
123
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={52}>33</ResizablePanel>
</ResizablePanelGroup>
</div>
</motion.div>
);
};

View File

@ -0,0 +1,68 @@
export interface PatientInfo {
ID: string;
LastUpdate: string;
MainDicomTags: {
OtherPatientIDs: string;
PatientBirthDate: string;
PatientID: string;
PatientName: string;
PatientSex: string;
};
Studies: string[];
Type: string;
children: StudyInfo[];
}
export interface StudyInfo {
ID: string;
MainDicomTags: {
AccessionNumber: string;
StudyDate: string;
StudyDescription: string;
StudyID: string;
StudyInstanceUID: string;
StudyTime: string;
};
ParentPatient: string;
Series: string[];
Type: string;
children: SeriesInfo[];
}
export interface SeriesInfo {
ExpectedNumberOfInstances: number;
ID: string;
Instances: string[];
MainDicomTags: {
Manufacturer: string;
Modality: string;
NumberOfSlices: string;
ProtocolName: string;
SeriesDate: string;
SeriesDescription: string;
SeriesInstanceUID: string;
SeriesNumber: string;
SeriesTime: string;
StationName: string;
};
ParentStudy: string;
Status: string;
Type: string;
children: [];
}
export interface InstanceInfo {
FileSize: number;
FileUuid: string;
ID: string;
IndexInSeries: number;
MainDicomTags: {
ImageIndex: string;
InstanceCreationDate: string;
InstanceCreationTime: string;
InstanceNumber: string;
SOPInstanceUID: string;
};
ParentSeries: string;
Type: string;
}