尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

基于C#与JavaScript的中文开源轻量级PACS系统设计源码实战

发布时间:2026/9/26 4:31:25

资讯中心
01
ARTICLE

基于C#与JavaScript的中文开源轻量级PACS系统设计源码实战

基于C#与JavaScript的中文开源轻量级PACS系统设计源码实战
简介这份源码面向中小型医疗机构信息化建设者、医学影像方向开发者及C#学习者提供一套中文开源社区内较为完善的轻量级PACS系统实现用于解决医学影像存储、传输与DICOM标准通信等需求。压缩包共约2000个文件整体96.89MB以1809个SVG矢量图形为主辅以85个JavaScript、34个CSS、22个Map、19个PNG及若干HTML、JSON、XML、SCSS等文件分别承担界面图标、动态交互、样式布局与配置说明等职责。项目采用C#为核心、前后端分离架构目录划分为Services、Properties、Configuration、Controllers等模块并配有editorconfig、appsettings.json等配置与说明文件便于理解源码结构与二次开发。目前已有156人学习关注。读者可借此掌握DICOM工具箱的工程组织方式、轻量级PACS的模块划分与扩展思路适合作为医疗信息化项目参考或课程设计、毕业设计的实践素材。1. 从一台旧工作站说起为什么我要用 C# 加 JavaScript 搭一套轻量级 PACS医院信息科最头疼的场景之一是放射科那台跑了七八年的老工作站——DICOM 影像调不出来报告医生在走廊里催厂商工程师说升级要排期到下个月。商业 PACS 动辄几十万授权费部署还要专用服务器和存储阵列小科室、体检中心、宠物医院根本吃不消。我后来接手的一个社区影像项目预算只够买两台普通 PC却要支撑 DR 和彩超的日常阅片。这就是「基于 C# 与 JavaScript 的中文开源社区最完善轻量级 PACS 系统设计源码」这个标题真正要解决的问题用一套能自己改、自己部署的源码把影像归档、调阅、报告这几件事跑起来。PACS 全称是 Picture Archiving and Communication System影像归档与通信系统核心就三件事收图、存图、看图。轻量级的意思不是功能残缺而是把并发规模、存储架构、部署复杂度压到单机或小集群能扛住的程度。C# 负责后端服务——DICOM 协议解析、文件归档、数据库读写、REST 接口JavaScript 负责前端——浏览器里直接渲染影像、调窗宽窗位、测量标注。这套组合的好处是后端能吃到 .NET 生态里成熟的 DICOM 库前端不用装任何插件就能在 Chrome 里阅片。适合谁信息科自研团队、医疗软件创业者、想入门医学影像的开发者以及被商业授权费卡住的小型医疗机构。下面我按实际落地的顺序把选型、环境、核心模块和踩过的坑讲清楚。2. 技术选型与架构拆解C# 后端和 JavaScript 前端各自扛什么2.1 为什么后端选 C# 而不是 Java 或 Python医学影像领域最成熟的 DICOM 解析库之一是 fo-dicom它是纯 .NET 实现支持 DICOM 文件的读写、网络传输C-STORE、C-FIND、C-MOVE、以及 JPEG/JPEG2000 解码。用 C# 调它代码量比 Java 的 dcm4che 少一截比 Python 的 pydicom 在并发网络服务上更稳。我实测过用 pydicom 做 C-STORE SCP单进程跑到几十个并发连接就开始丢包而 .NET 的异步 IO 模型处理几百个连接很轻松。另外 C# 的Spanbyte和Memorybyte在处理大影像文件时能避免频繁内存拷贝这对一张 30MB 的 CR 图像来说很关键。选型时还要考虑部署。.NET 可以发布成自包含的单文件可执行程序目标机器不需要装运行时拷贝过去就能跑。这对医院内网那种不能随便装软件的环境太重要了。Java 要带 JREPython 要配虚拟环境都容易在客户现场翻车。2.2 前端为什么用 JavaScript 直接渲染而不是传图片早期方案是后端把 DICOM 转成 JPEG 再传给前端简单但问题多窗宽窗位调节要重新请求测量标注没法做多帧影像更是灾难。现在主流做法是前端拿到原始像素数据用 Canvas 或 WebGL 渲染。JavaScript 这边我一般用 Cornerstone.js 这个库它专门做医学影像的浏览器端渲染支持 DICOM 解析、窗宽窗位、缩放平移、测量工具。配合 cornerstoneWADOImageLoader 可以直接从后端的 WADO 接口拉影像。前端渲染的另一个好处是诊断质量。JPEG 是有损压缩对骨窗和肺窗的细节影响很大而原始像素数据是无损的。医生调窗的时候前端只是重新映射灰度值不涉及网络传输响应速度在毫秒级。2.3 整体架构从设备到浏览器的数据流一套最小可用的轻量级 PACS数据流是这样的DR 设备通过 DICOM C-STORE 把影像推到后端服务后端解析 DICOM 标签把像素数据和元数据分开存——元数据进数据库SQLite 或 PostgreSQL像素数据按 Study/Series/Instance 的层级存到文件系统前端通过 REST API 查询检查列表点开某个检查后通过 WADO 接口按帧拉取像素数据在 Canvas 上渲染。这里有个关键设计存储路径不能直接用患者姓名或检查号因为中文路径在跨平台时容易出编码问题。我一般用 StudyInstanceUID 的哈希值做目录名元数据里保留原始信息用于检索。数据库表至少要有 patient、study、series、instance 四张外加一张 report 存报告。模块技术选型职责DICOM 服务fo-dicom .NET 6接收 C-STORE、提供 C-FIND/C-MOVE元数据存储SQLite / PostgreSQL患者、检查、序列、实例索引像素存储本地文件系统 / MinIO按 UID 层级归档 DICOM 文件REST APIASP.NET Core Web API检查列表、影像调阅、报告读写前端渲染Cornerstone.js Canvas窗宽窗位、测量、多帧播放报告模块JavaScript 富文本结构化报告模板与编辑3. 本地跑通最小闭环环境搭建与 DICOM 收图服务3.1 开发环境与依赖安装先确认机器上装了 .NET 6 SDK 或更高版本以及 Node.js 16 以上。数据库用 SQLite 就够不需要额外安装。创建项目结构mkdir LightPacs cd LightPacs dotnet new webapi -n Pacs.Server dotnet new classlib -n Pacs.Core cd Pacs.Server dotnet add package fo-dicom dotnet add package Microsoft.EntityFrameworkCore.Sqlite前端部分单独建目录用 Vite 起一个 vanilla JS 项目npm create vitelatest pacs-web -- --template vanilla cd pacs-web npm install cornerstone-core cornerstone-wado-image-loader dicom-parser这里注意 fo-dicom 的版本5.x 和 4.x 的 API 差异不小我一般锁 5.0.3 这个版本稳定且文档全。Cornerstone.js 这边要注意它分 core 和 tools 两个包测量工具在 tools 里别漏装。3.2 用 fo-dicom 写一个能收图的 C-STORE SCP后端最核心的是 DICOM 接收服务。下面这段代码启动一个 C-STORE SCP监听 104 端口收到影像后存到本地并写数据库using Dicom; using Dicom.Network; public class StoreSCP : IDicomServiceProvider, IDicomCStoreProvider { public void OnReceiveAssociationRequest(DicomAssociation association) { // 接受任何 AE Title 的关联请求生产环境要加白名单 association.Accept(); } public DicomStatus OnCStoreRequest(DicomCStoreRequest request) { var dataset request.Dataset; var sopInstanceUid dataset.GetSingleValuestring(DicomTag.SOPInstanceUID); var studyUid dataset.GetSingleValuestring(DicomTag.StudyInstanceUID); var seriesUid dataset.GetSingleValuestring(DicomTag.SeriesInstanceUID); // 按 Study/Series 层级建目录用 UID 避免中文路径问题 var basePath Path.Combine(storage, studyUid, seriesUid); Directory.CreateDirectory(basePath); var filePath Path.Combine(basePath, sopInstanceUid .dcm); // 保存原始 DICOM 文件保留全部标签 var dicomFile new DicomFile(dataset); dicomFile.Save(filePath); // 元数据写库这里用 EF Core 的 DbContext using var db new PacsDbContext(); db.Instances.Add(new InstanceEntity { SopInstanceUid sopInstanceUid, StudyInstanceUid studyUid, SeriesInstanceUid seriesUid, FilePath filePath, ReceivedAt DateTime.UtcNow }); db.SaveChanges(); return DicomStatus.Success; } public void OnReceiveAssociationReleaseRequest() { } public void OnReceiveAbort(DicomAbortSource source, DicomAbortReason reason) { } public void OnConnectionClosed(Exception exception) { } }启动服务的入口var server DicomServerFactory.CreateStoreSCP(104); Console.WriteLine(C-STORE SCP 已启动监听 104 端口); Console.ReadLine();这段代码的逻辑是设备发起关联请求时直接接受收到 C-STORE 请求后提取三个 UID按层级建目录保存文件同时把索引信息写进 SQLite。参数方面端口 104 是 DICOM 标准端口但 Linux 下非 root 用户不能绑定 1024 以下端口开发时改成 11112 更省事。AE Title 在生产环境必须做白名单校验否则任何设备都能推图进来。3.3 前端调阅用 Cornerstone.js 在浏览器里显示第一张影像后端提供一个 WADO 接口根据 SOPInstanceUID 返回 DICOM 文件流[HttpGet(wado)] public IActionResult Wado([FromQuery] string studyUid, [FromQuery] string seriesUid, [FromQuery] string sopUid) { var path Path.Combine(storage, studyUid, seriesUid, sopUid .dcm); if (!System.IO.File.Exists(path)) return NotFound(); // 返回 application/dicom 类型前端 loader 才能识别 return PhysicalFile(path, application/dicom); }前端加载并渲染import * as cornerstone from cornerstone-core; import * as cornerstoneWADOImageLoader from cornerstone-wado-image-loader; import dicomParser from dicom-parser; // 初始化 WADO Image Loader指定后端接口地址 cornerstoneWADOImageLoader.external.cornerstone cornerstone; cornerstoneWADOImageLoader.external.dicomParser dicomParser; cornerstoneWADOImageLoader.configure({ beforeSend: function(xhr) { // 如果需要鉴权在这里加 token } }); const element document.getElementById(dicomImage); cornerstone.enable(element); // 构造 WADO URI指向后端接口 const imageId wadouri:http://localhost:5000/api/pacs/wado?studyUidxxxseriesUidyyysopUidzzz; cornerstone.loadImage(imageId).then(function(image) { cornerstone.displayImage(element, image); // 设置初始窗宽窗位这里用影像自带的默认值 cornerstone.setViewport(element, { scale: 1, translation: { x: 0, y: 0 }, voi: { windowWidth: image.windowWidth, windowCenter: image.windowCenter } }); });逻辑说明cornerstone.enable把普通 div 变成可渲染影像的容器loadImage通过 wadouri 协议请求后端拿到 DICOM 文件解析出像素数据和窗宽窗位默认值displayImage完成渲染。参数上windowWidth和windowCenter是调窗的关键前端后续的调窗操作就是改这两个值再调setViewport。注意跨域问题前端和后端不同端口时要在后端加 CORS 策略否则浏览器会拦截请求。4. 影像归档与检索数据库设计和文件存储的取舍4.1 元数据表结构怎么设计才够用轻量级 PACS 的数据库不需要照搬商业系统的几百张表但四张核心表不能省。Patient 表存患者基本信息Study 表存检查级别信息Series 表存序列信息Instance 表存每张影像的索引。关键字段和索引设计如下CREATE TABLE Patients ( PatientId TEXT PRIMARY KEY, PatientName TEXT, BirthDate TEXT, Sex TEXT ); CREATE TABLE Studies ( StudyInstanceUid TEXT PRIMARY KEY, PatientId TEXT, StudyDate TEXT, Modality TEXT, Description TEXT, FOREIGN KEY (PatientId) REFERENCES Patients(PatientId) ); CREATE TABLE Series ( SeriesInstanceUid TEXT PRIMARY KEY, StudyInstanceUid TEXT, SeriesNumber INTEGER, Modality TEXT, FOREIGN KEY (StudyInstanceUid) REFERENCES Studies(StudyInstanceUid) ); CREATE TABLE Instances ( SopInstanceUid TEXT PRIMARY KEY, SeriesInstanceUid TEXT, InstanceNumber INTEGER, FilePath TEXT, FOREIGN KEY (SeriesInstanceUid) REFERENCES Series(SeriesInstanceUid) ); -- 检索最常用的索引按患者查检查、按检查查序列 CREATE INDEX idx_studies_patient ON Studies(PatientId); CREATE INDEX idx_series_study ON Series(StudyInstanceUid); CREATE INDEX idx_instances_series ON Instances(SeriesInstanceUid);这里有个血泪经验PatientName 字段不要用来做查询条件中文姓名在 DICOM 里可能是 GBK 或 UTF-8 编码直接比较容易出乱码。检索一律走 PatientId姓名只做显示。另外 StudyDate 存成 TEXT 类型格式统一用 DICOM 标准的 YYYYMMDD排序和范围查询都方便。4.2 文件存储的目录策略与容量估算文件存储我试过三种方案全部平铺在一个目录、按日期分目录、按 Study/Series 层级分目录。平铺在文件数超过一万后ls命令都卡Windows 资源管理器直接无响应。按日期分目录检索不方便因为调阅是按检查走的。最终用 Study/Series 层级每个序列一个目录目录里放该序列的所有 DICOM 文件。容量估算一张 DR 影像大约 10-30MB一次检查通常 1-2 张CT 一次检查 200-500 张每张 0.5-1MB合计 100-500MB。一个社区医院每天 50 次检查按平均 50MB 算一天 2.5GB一年约 900GB。所以存储规划至少按 1TB 起步用普通 SATA 硬盘做 RAID1 就够不需要上 SAN。如果预算允许加一层 MinIO 做对象存储扩展性更好但会增加部署复杂度。4.3 检查列表接口从数据库到前端的查询链路前端打开时先拉检查列表接口按日期范围和患者 ID 过滤[HttpGet(studies)] public IActionResult GetStudies([FromQuery] string dateFrom, [FromQuery] string dateTo, [FromQuery] string patientId) { using var db new PacsDbContext(); var query db.Studies.AsQueryable(); if (!string.IsNullOrEmpty(dateFrom)) query query.Where(s string.Compare(s.StudyDate, dateFrom) 0); if (!string.IsNullOrEmpty(dateTo)) query query.Where(s string.Compare(s.StudyDate, dateTo) 0); if (!string.IsNullOrEmpty(patientId)) query query.Where(s s.PatientId patientId); // 只返回列表需要的字段不要带像素数据 var result query.Select(s new { s.StudyInstanceUid, s.PatientId, s.StudyDate, s.Modality, s.Description, SeriesCount db.Series.Count(se se.StudyInstanceUid s.StudyInstanceUid) }).OrderByDescending(s s.StudyDate).Take(100).ToList(); return Ok(result); }这个接口的关键是分页和字段裁剪。不加Take限制检查多了以后前端会卡死。SeriesCount用子查询算虽然效率不是最优但轻量级场景下数据量小可读性更重要。如果检查量上万改成 JOIN 加 GROUP BY 更好。5. 避坑与排查部署轻量级 PACS 时最容易翻车的五件事5.1 中文患者姓名显示乱码现象前端检查列表里患者姓名显示成问号或方块。原因DICOM 文件里的SpecificCharacterSet标签可能是 GB18030 或 ISO_IR 100而 fo-dicom 默认按 ASCII 解析。解决在读取数据集时显式指定字符集dataset.AddOrUpdate(DicomTag.SpecificCharacterSet, GB18030)或者用DicomEncoding.GetEncoding(GB18030)注册编码。前端显示时确保 HTML 页面声明 UTF-8。5.2 C-STORE 接收大文件时连接中断现象小影像能收CT 的大序列传到一半报错。原因默认的 DICOM 关联参数里MaxPDU太小或者服务端接收缓冲区不够。解决在OnReceiveAssociationRequest里设置association.MaxPDU 16384同时把 .NET 的 Kestrel 或自托管服务的请求体大小限制调大。另外检查防火墙是否对长连接做了超时断开。5.3 前端调窗时影像闪烁或卡顿现象拖动鼠标调窗宽窗位影像刷新跟不上有明显闪烁。原因每次鼠标移动都触发完整的loadImage重新请求。解决影像加载一次后缓存像素数据调窗只调setViewport改voi参数不重新请求。Cornerstone.js 的displayImage会复用已加载的 image 对象前提是 imageId 不变。另外用requestAnimationFrame节流鼠标事件避免高频重绘。5.4 SQLite 并发写入报 database is locked现象多个设备同时推图后端写数据库时报锁错误。原因SQLite 默认的写锁是排他的并发写入会冲突。解决连接字符串加CacheShared或者改用 PostgreSQL。轻量级场景下更简单的办法是加一个内存队列收图线程只负责写文件单独一个后台线程从队列取数据写库把并发写变成串行写。5.5 浏览器跨域导致影像加载失败现象前端控制台报 CORS 错误影像显示空白。原因前端开发服务器和后端 API 端口不同浏览器拦截了跨域请求。解决后端加 CORS 策略允许前端源或者用 Vite 的 proxy 配置把/api转发到后端。生产环境如果前后端同源部署就不存在这个问题。注意 WADO 接口返回的Content-Type必须是application/dicom否则 cornerstone 的 loader 不认。6. 进阶技巧用 JavaScript 做测量标注和报告联动基础阅片跑通后真正拉开体验差距的是测量标注和报告联动。Cornerstone Tools 提供了长度、角度、矩形 ROI、椭圆 ROI 等工具但默认的标注数据只存在内存里刷新就丢。我的做法是把标注序列化成 JSON跟着报告一起存到后端。先初始化工具import * as cornerstoneTools from cornerstone-tools; import cornerstone from cornerstone-core; cornerstoneTools.external.cornerstone cornerstone; cornerstoneTools.init(); const element document.getElementById(dicomImage); const LengthTool cornerstoneTools.LengthTool; cornerstoneTools.addTool(LengthTool); cornerstoneTools.setToolActive(Length, { mouseButtonMask: 1 });这段代码注册了长度测量工具鼠标左键按下就能画线测距。参数mouseButtonMask: 1表示左键激活改成 2 是右键4 是中键。实际使用中要给医生提供工具切换按钮不然画完长度想画角度还得改代码。标注数据的保存和恢复// 保存当前所有标注 const toolState cornerstoneTools.getToolState(element, Length); const annotations toolState ? toolState.data.map(item ({ toolType: Length, start: item.handles.start, end: item.handles.end, length: item.length, visible: item.visible })) : []; // 随报告一起提交到后端 fetch(/api/report/save, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ studyUid: currentStudyUid, annotations: annotations, reportText: document.getElementById(reportEditor).innerHTML }) }); // 重新打开时恢复标注 function restoreAnnotations(element, annotations) { cornerstoneTools.clearToolState(element, Length); annotations.forEach(anno { cornerstoneTools.addToolState(element, Length, { handles: { start: anno.start, end: anno.end }, length: anno.length, visible: anno.visible }); }); cornerstoneTools.store.state.isToolLocked true; cornerstone.updateImage(element); }逻辑上标注数据的关键是handles里的坐标它用的是影像坐标系而不是屏幕坐标系所以缩放平移后标注位置不会跑偏。保存时把坐标和测量值一起存恢复时直接重建 tool state。注意isToolLocked设为 true 后标注不可拖动避免误操作需要编辑时再解锁。报告联动这块我一般用 contenteditable 的 div 做富文本编辑器医生在报告里插入测量值时从当前标注里读length字段填进去。这样报告里的数据和影像上的标注始终一致不会出现改了标注忘了改报告的情况。最后说一个我踩过的坑Cornerstone Tools 的版本和 Cornerstone Core 的版本必须匹配我试过 core 用 2.x 而 tools 用 6.x结果工具完全无法激活控制台还不报错排查了一下午。锁版本的时候把这两个包的版本号写进 package.json 的 dependencies 里别用^或~。另外测量数据在序列切换时要清空否则会把上一个序列的标注画到新序列上这个 bug 在测试时不容易发现上了临床被医生一眼看穿。这套方案我从头搭到尾大概花了两周其中一半时间在调 DICOM 编码和前端渲染的兼容性。如果你也想做一套自己能掌控的轻量级 PACS建议先把 C-STORE 收图和 WADO 调阅这两个闭环跑通再往上加报告和标注。别一上来就想着做全功能先把一张影像从设备推到浏览器显示出来后面的路就顺了。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。