feat: 下载dicom

This commit is contained in:
mozzie 2023-09-05 15:07:15 +08:00
parent d63eb1a5af
commit c6834cb4c4
13 changed files with 351 additions and 64 deletions

View File

@ -1,4 +1,4 @@
import { Apis, ExistInPacsDTO } from "@@/infra/api";
import { Apis, DownloadArchiveDTO, ExistInPacsDTO } from "@@/infra/api";
export class DicomRepository {
async upload2Pacs(dcmFile: File) {
@ -10,4 +10,8 @@ export class DicomRepository {
async existInPacs(p: ExistInPacsDTO) {
return Apis.existInPacs(p);
}
async downloadDicom(p: DownloadArchiveDTO) {
return Apis.downloadDicom(p);
}
}

View File

@ -1,4 +1,4 @@
import { ExistInPacsDTO } from "@@/infra/api";
import { DownloadArchiveDTO, ExistInPacsDTO } from "@@/infra/api";
import { DicomRepository } from "./DicomRepository";
export class DicomService {
@ -17,4 +17,8 @@ export class DicomService {
async existInPacs(p: ExistInPacsDTO) {
return await this.dicomRepository.existInPacs(p);
}
async downloadDicom(p: DownloadArchiveDTO) {
return await this.dicomRepository.downloadDicom(p);
}
}

View File

@ -1,6 +1,8 @@
import { User } from "@@/domain/User/entities/User";
import { Request } from "./Request";
import { Study } from "@/modules/Admin/Dicom/Upload/DicomUploader/util";
import axios from "axios";
import { saveAs } from "file-saver";
const PREFIX = "/api/dmp";
const PREFIX_CERT = "/cert";
@ -31,6 +33,11 @@ export type ArchiveTaskCreateDto = {
study: Study[];
};
export type DownloadArchiveDTO = {
ID: string;
Type: "study" | "series" | "Study" | "Series";
};
export const Apis = {
/**
*
@ -80,4 +87,20 @@ export const Apis = {
Request.get(PREFIX + "/annotator/find/archiveTask"),
findDicoms: (): ResponseType => Request.get(PREFIX + "/admin/find/dicom/all"),
/**
* dicom zip
*
*/
downloadDicom: (p: DownloadArchiveDTO) =>
axios
.post(PREFIX + `/dicom/download`, p, { responseType: "blob" })
.then((response) => {
const filename = response.headers["content-name"];
const blob = new Blob([response.data], { type: "application/zip" });
saveAs(blob, filename);
})
.catch((error) => {
console.error("Download error:", error);
}),
};

View File

@ -22,7 +22,8 @@
"@ant-design/icons": "5.2.5",
"dicom-parser": "1.8.21",
"path-to-regexp": "6.2.1",
"@tavi/util": "workspace:*"
"@tavi/util": "workspace:*",
"file-saver": "2.0.5"
},
"devDependencies": {
"@types/react": "18.2.14",
@ -36,6 +37,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"typescript": "^5.0.2",
"vite": "^4.4.0"
"vite": "^4.4.0",
"@types/file-saver": "2.0.5"
}
}

View File

@ -1,35 +1,74 @@
import { TableColumnsType } from "antd";
import { DataItemType } from ".";
import { SeriesItemType, StudyItemType } from ".";
export const columnsForStudy: TableColumnsType<DataItemType> = [
export const columnsForStudy: TableColumnsType<StudyItemType> = [
{ title: "PatientID", dataIndex: "PatientID", key: "PatientID" },
{ title: "患者姓名", dataIndex: "PatientName", key: "PatientName" },
{ title: "性别", dataIndex: "PatientSex", key: "PatientSex" },
{ title: "出生日期", dataIndex: "PatientBirthDate", key: "PatientBirthDate" },
{ title: "病例日期", dataIndex: "StudyDate", key: "StudyDate" },
{
title: "相关序列数",
dataIndex: "NumberPatientRelatedInstances",
key: "NumberPatientRelatedInstances",
},
{ title: "机构", dataIndex: "InstitutionName", key: "InstitutionName" },
];
export const columnsForSeries: TableColumnsType<DataItemType> = [
{ title: "序列号", dataIndex: "SeriesNumber", key: "SeriesNumber" },
{
title: "成像设备",
dataIndex: "Modality",
key: "Modality",
},
export const columnsForSeries: TableColumnsType<SeriesItemType> = [
{
title: "序列描述",
dataIndex: "SeriesDescription",
key: "SeriesDescription",
},
{
title: (
<div>
<span></span>
</div>
),
key: "PatientPosition",
render: (_, record: SeriesItemType) => {
return <>{record.tags.PatientPosition}</>;
},
},
{
title: "检查区域",
key: "BodyPartExamined",
render: (_, record: SeriesItemType) => {
return <>{record.tags.BodyPartExamined}</>;
},
},
{
title: "切片数",
dataIndex: "NumberSeriesRelatedInstances",
key: "NumberSeriesRelatedInstances",
title: "切片数",
dataIndex: "InstanceNumber",
key: "InstanceNumber",
},
{
title: "层厚",
key: "SliceThickness",
render: (_, record: SeriesItemType) => {
return <>{record.tags.SliceThickness}</>;
},
},
{
title: "制造商",
dataIndex: "Manufacturer",
key: "Manufacturer",
},
{
title: "成像方式",
dataIndex: "Modality",
key: "Modality",
},
// {
// title: "焦点大小",
// key: "FocalSpots",
// render: (_, record: SeriesItemType) => {
// return <>{record.tags.FocalSpots}</>;
// },
// },
// {
// title: "图像位数",
// key: "BitsStored",
// render: (_, record: SeriesItemType) => {
// return <>{record.tags.BitsStored} bit</>;
// },
// },
];

View File

@ -1,44 +1,62 @@
import { useDomain } from "@/hook/useDomain";
import { Button, Space, Table } from "antd";
import { Button, Space, Spin, Table, Tooltip, message } from "antd";
import { useEffect, useState } from "react";
import { columnsForStudy, columnsForSeries } from "./columns";
import { EyeOutlined } from "@ant-design/icons";
import { DesktopOutlined, FileZipOutlined } from "@ant-design/icons";
import { openOHIFViewer } from "../Upload/util";
interface DicomListProps {
children?: JSX.Element;
}
export type DataItemType = {
StudyDate: string;
StudyTime: string;
AccessionNumber: string;
export type SeriesItemType = {
BodyPartExamined: string;
ContrastBolusAgent: string;
ID: string;
ImageOrientationPatient: string;
Instances: string[];
Manufacturer: string;
Modality: string;
PatientName: string;
PatientID: string;
PatientBirthDate: string;
PatientSex: string;
StudyInstanceUID: string;
SeriesInstanceUID: string;
CharacterSet: string;
OperatorsName: string;
ProtocolName: string;
SeriesDate: string;
SeriesDescription: string;
SeriesInstanceUID: string;
SeriesNumber: string;
SeriesTime: string;
StationName: string;
Type: "study";
tags: any;
};
export type StudyItemType = {
AccessionNumber: string;
ID: string;
InstitutionName: string;
PatientBirthDate: string;
PatientID: string;
PatientName: string;
PatientSex: string;
ReferringPhysicianName: string;
StudyDate: string;
StudyID: string;
SeriesNumber: string | number;
NumberSeriesRelatedInstances: string | number;
NumberPatientRelatedInstances: string | number;
NumberPatientRelatedSeries: string | number;
subs: DataItemType[];
StudyInstanceUID: string;
StudyTime: string;
Type: "series";
subs: SeriesItemType[];
};
export const DicomList = (props: DicomListProps) => {
const [dataSource, setDataSource] = useState<DataItemType[]>([]);
const [dataSource, setDataSource] = useState<StudyItemType[]>([]);
const [tableLoading, setTableLoading] = useState(false);
const { userDomainService } = useDomain();
const [loading, setLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const { userDomainService, dicomDomainService } = useDomain();
useEffect(() => {
setTableLoading(true);
userDomainService.findDicoms().then((result) => {
const { data } = result;
setDataSource(data as DataItemType[]);
setDataSource(data as StudyItemType[]);
setTableLoading(false);
});
}, []);
@ -46,7 +64,7 @@ export const DicomList = (props: DicomListProps) => {
/**
*
*/
const expandedRowRenderForSeries = (record: DataItemType) => {
const expandedRowRenderForSeries = (record: StudyItemType) => {
return (
<Table
rowKey="SeriesInstanceUID"
@ -55,16 +73,22 @@ export const DicomList = (props: DicomListProps) => {
{
title: "操作",
dataIndex: "operation",
render: (_: any, recordSeries: DataItemType) => (
render: (_: any, recordSeries: SeriesItemType) => (
<Space>
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
onClick={() => onViewDicom(record, recordSeries)}
>
</Button>
<Tooltip title="在线阅片">
<Button
type="text"
icon={<DesktopOutlined />}
onClick={() => onViewDicom(record, recordSeries)}
/>
</Tooltip>
<Tooltip title="下载原始影像 .zip文件">
<Button
type="text"
icon={<FileZipOutlined />}
onClick={() => onDownloadDicom(recordSeries)}
/>
</Tooltip>
</Space>
),
},
@ -75,17 +99,61 @@ export const DicomList = (props: DicomListProps) => {
);
};
const onViewDicom = (record: DataItemType, recordSeries: DataItemType) => {
const onViewDicom = (record: StudyItemType, recordSeries: SeriesItemType) => {
const { StudyInstanceUID } = record;
const { SeriesInstanceUID } = recordSeries;
openOHIFViewer(StudyInstanceUID, SeriesInstanceUID);
};
const onDownloadDicom = ({ ID, Type }: SeriesItemType | StudyItemType) => {
setLoading(true);
dicomDomainService.downloadDicom({ ID, Type }).finally(() => {
messageApi.success("数据下载成功");
setLoading(false);
});
};
return (
<div>
{contextHolder}
{loading && (
<div
style={{
position: "fixed",
background: "rgb(255 195 195 / 30%)",
left: 0,
right: 0,
top: 0,
bottom: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 19940121,
}}
>
<Spin size="large" />
</div>
)}
<Table
dataSource={dataSource}
columns={columnsForStudy}
columns={[
...columnsForStudy,
{
title: "操作",
dataIndex: "operation",
render: (_: any, record: StudyItemType) => (
<Space>
<Tooltip title="下载原始影像 .zip文件">
<Button
type="text"
icon={<FileZipOutlined />}
onClick={() => onDownloadDicom(record)}
/>
</Tooltip>
</Space>
),
},
]}
loading={tableLoading}
expandable={{
expandedRowRender: expandedRowRenderForSeries,

View File

@ -7,4 +7,4 @@ NACOS_NAMESPACE=56a3b295-f319-4ced-82b5-0df2e98cc541
NACOS_DATAID='test'
NACOS_GROUP='DEFAULT_GROUP'
# dicom配置可以后期转移到nacos配置中心
PACS_URL=http://localhost:8042/dicom-web/
PACS_URL=http://localhost:8042

View File

@ -27,6 +27,47 @@ const metaFormatter = (data) => {
};
};
type StudyResourceType = {
ID: string;
MainDicomTags: {
AccessionNumber: string;
StudyDate: string;
StudyDescription: string;
StudyID: string;
StudyInstanceUID: string;
StudyTime: string;
};
PatientMainDicomTags: {
PatientBirthDate: string;
PatientID: string;
PatientName: string;
PatientSex: string;
};
Series: string[];
Type: 'Study';
};
type SeriesResourceType = {
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: 'Series';
};
@Controller()
export class AppController {
constructor(
@ -38,19 +79,52 @@ export class AppController {
async findDicoms() {
const pacsUrl = this.configService.get('PACS_URL');
try {
const { data: studyData } = await axios.get(`${pacsUrl}/studies`);
// 全部的study pacs uuid
const { data: study_uuids } = await axios.get(`${pacsUrl}/studies`);
const result = [];
for (const study of studyData) {
const studyItem = metaFormatter(study);
const { data: seriesData } = await axios.get(
`${pacsUrl}/studies/${studyItem.StudyInstanceUID}/series`,
);
const subs = seriesData.map((s) => metaFormatter(s));
result.push({ ...studyItem, subs });
// study
for (const study_uuid of study_uuids) {
const studyUrl = `${pacsUrl}/studies/${study_uuid}`;
const { data: studyResource } = await axios.get(studyUrl);
const { Series, PatientMainDicomTags, MainDicomTags, ID, Type } =
studyResource as StudyResourceType;
const subs = [];
// series + 第一张 instance 的 tags
for (const series_uuid of Series) {
const seriesUrl = `${pacsUrl}/series/${series_uuid}`;
const { data: seriesResource } = await axios.get(seriesUrl);
const { MainDicomTags, Instances, ID, Type } =
seriesResource as SeriesResourceType;
// 取第一张instance
const InstanceNumber = Instances.length;
const firstInstanceUrl = `${pacsUrl}/instances/${Instances[0]}/simplified-tags`;
const tags =
Instances.length > 0
? await axios.get(firstInstanceUrl).then((d) => d.data)
: {};
subs.push({ ...MainDicomTags, Type, InstanceNumber, ID, tags });
}
result.push({
...PatientMainDicomTags,
...MainDicomTags,
Type,
ID,
subs,
});
}
return { data: result };
} catch (error) {
throw new Error(`Failed to fetch DICOM data: ${error.message}`);
}
}
@EventPattern({ cmd: 'dicom.archive.url' })
async archiveUrl({ ID, Type }: { ID: string; Type: string }) {
const pacsUrl = this.configService.get('PACS_URL');
const mapping = {
study: 'studies',
series: 'series',
};
return `${pacsUrl}/${mapping[Type.toLowerCase()]}/${ID}/archive`;
}
}

View File

@ -27,7 +27,8 @@
"class-validator": "0.14.0",
"class-transformer": "0.5.1",
"uuid": "9.0.0",
"dayjs": "1.11.9"
"dayjs": "1.11.9",
"axios": "1.5.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",

View File

@ -9,6 +9,7 @@ import { ForbiddenExceptionFilter } from './filter/forbid.filter';
import { AuthController } from './auth/auth.controller';
import { AdminModule } from './admin/admin.module';
import { AnnotatorModule } from './annotator/annotator.module';
import { DicomModule } from './dicom/dicom.module';
import * as cookieParser from 'cookie-parser';
@Module({
@ -30,6 +31,7 @@ import * as cookieParser from 'cookie-parser';
]),
AdminModule,
AnnotatorModule,
DicomModule,
],
controllers: [AppController, AuthController],
providers: [

View File

@ -0,0 +1,28 @@
import { Body, Controller, Get, Inject, Param, Post, Res } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { Response } from 'express';
import { firstValueFrom } from 'rxjs';
import axios from 'axios';
@Controller('dicom')
export class DicomController {
constructor(@Inject('Client') private client: ClientProxy) {}
@Post('download')
async downloadDicom(@Body() body, @Res() res: Response) {
const { ID, Type } = body;
const url = await firstValueFrom(
this.client.send({ cmd: 'dicom.archive.url' }, { ID, Type }),
);
try {
const { data: dataStream } = await axios.get(url, {
responseType: 'stream',
});
res.setHeader('Content-Name', `${ID}.zip`);
res.setHeader('Content-Type', 'application/zip');
dataStream.pipe(res);
} catch (error) {
return { code: 1, msg: error };
}
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { DicomController } from './dicom.controller';
import { ClientsModule, Transport } from '@nestjs/microservices';
@Module({
imports: [
ClientsModule.register([
{
name: 'Client',
transport: Transport.NATS,
options: {
servers: ['nats://localhost:4222'],
maxReconnectAttempts: 5,
reconnectTimeWait: 1000,
},
},
]),
],
controllers: [DicomController],
})
export class DicomModule {}

View File

@ -258,6 +258,9 @@ importers:
dicom-parser:
specifier: 1.8.21
version: 1.8.21
file-saver:
specifier: 2.0.5
version: registry.npmmirror.com/file-saver@2.0.5
mobx:
specifier: 6.9.0
version: 6.9.0
@ -283,6 +286,9 @@ importers:
specifier: 6.14.2
version: 6.14.2(react-dom@18.2.0)(react@18.2.0)
devDependencies:
'@types/file-saver':
specifier: 2.0.5
version: registry.npmmirror.com/@types/file-saver@2.0.5
'@types/react':
specifier: 18.2.14
version: 18.2.14
@ -1073,6 +1079,9 @@ importers:
'@nestjs/typeorm':
specifier: 10.0.0
version: 10.0.0(@nestjs/common@10.1.0)(@nestjs/core@10.1.0)(reflect-metadata@0.1.13)(rxjs@7.8.1)(typeorm@0.3.17)
axios:
specifier: 1.5.0
version: registry.npmmirror.com/axios@1.5.0
class-transformer:
specifier: 0.5.1
version: 0.5.1
@ -12778,6 +12787,12 @@ packages:
'@types/serve-static': registry.npmmirror.com/@types/serve-static@1.15.2
dev: true
registry.npmmirror.com/@types/file-saver@2.0.5:
resolution: {integrity: sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.5.tgz}
name: '@types/file-saver'
version: 2.0.5
dev: true
registry.npmmirror.com/@types/http-errors@2.0.1:
resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.1.tgz}
name: '@types/http-errors'
@ -14869,6 +14884,12 @@ packages:
flat-cache: registry.npmmirror.com/flat-cache@3.0.4
dev: true
registry.npmmirror.com/file-saver@2.0.5:
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz}
name: file-saver
version: 2.0.5
dev: false
registry.npmmirror.com/fill-range@7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/fill-range/-/fill-range-7.0.1.tgz}
name: fill-range