
简介一套面向中小医疗机构的中文开源社区轻量级医学影像存档与通讯系统PACS设计源码采用C#作为核心开发语言并配合JavaScript、CSS、HTML等构建前后端分离架构专注解决医学影像的存储、传输与管理问题是一个完整的DICOM工具箱。压缩包共2000个文件约96.89MB主要包含1809个SVG矢量图形用于界面图标与影像标注85个JavaScript实现交互逻辑34个CSS负责样式布局另有PNG、Map、TXT、JSON等辅助资源目录按Services、Controllers等功能分区组织便于理解与二次开发。系统遵循DICOM标准兼顾轻量级与扩展性适合预算有限但信息化需求迫切的诊所和中小型医院使用。源码中附带appsettings.json、editorconfig等配置说明文件可帮助读者快速搭建环境并掌握项目结构。已有156人学习下载值得医学影像开发者学习和参考其中SVG与JavaScript的高占比也反映出系统在界面呈现和交互体验上的扎实设计。1. 轻量级PACS系统设计源码C#与JavaScript到底解决什么问题一台CT一个上午能出几十个检查每个检查几百张图像没有PACS的科室只能靠光盘和U盘倒腾数据时间一长就乱成一锅粥。可传统PACS系统动辄几十万授权费、专用硬件和全套运维团队中小医院、体检中心、第三方影像中心根本扛不住。基于C#与JavaScript的中文开源社区轻量级PACS系统就是冲着这个空白来的后端用C#做DICOM接收、归档和检索前端用JavaScript做纯浏览器阅片把传统PACS的存储、传输、查看三件事压进一套能跑在普通PC上的系统里。适合三类人要给科室做影像归档的HIS集成商、想自建阅片工具的一线工程师、以及评估开源PACS方案的信息科负责人。这套路线的核心判断是医学影像领域最重的部分是DICOM协议而C#生态里恰好有成熟的DICOM库最需要交互体验的部分是阅片端而浏览器里的JavaScript渲染栈近几年已经追上了专业工作站。能不能把这两者缝成一套可靠系统就是这篇要展开的事。2. 从“轻量”二字拆架构C#管归档JavaScript管阅片2.1 为什么是C#做服务端DICOM生态与Windows部署成本PACS最核心的活不是存储是听懂DICOM协议。DICOM不是单一文件格式而是一整套网络通信、信息模型和文件封装的组合。设备往你系统里传图像走的是DICOM Storage SCU到SCP的C-STORE请求查询影像走的是C-FIND取回影像走的是C-MOVE或WADO。这些协议细节靠手写TCP包不现实得用现成的DICOM实现库。C#生态里最常用的DICOM库是fo-dicom它把DICOM文件解析、网络SCP/SCU、传输语法转换、标签字典都封装好了API风格对做医疗信息化的团队非常友好。相比之下Java生态的dicom4chee功能全但重Python的pydicom偏文件和科研服务端并发能力需要自己补。更重要的是部署场景国内大量PACS运行在Windows Server上和HIS、RIS、LIS做集成时C#的ASP.NET Core可以直接和内网其他.NET系统共享认证、配置和运维习惯。很多医院信息科的中心机房还是Windows选C#意味着少引入一套Linux运维。还有一个常被忽略的点DICOM文件里的像素数据可能是未压缩的16位灰度也可能是JPEG-LS、JPEG2000、RLE等压缩传输语法。C#处理二进制缓冲区、做字节序转换和内存流拷贝性能表现稳定可控。轻量级系统通常没有专职DevOps出了问题一两个人能顶住C#的调试工具链和Windows事件日志、性能计数器配合排查问题比在Linux上翻core dump直觉得多。2.2 JavaScript端为什么能扛起阅片cornerstone.js与浏览器渲染十年前说Web端阅片懂行的都摇头16位灰度、窗宽窗位、多序列同步翻页浏览器哪扛得住。现在情况变了。JavaScript生态里cornerstone.js这一套库含cornerstoneCore、cornerstoneTools、dicomParser专门解决医学影像的浏览器渲染问题核心思路是用Canvas直接操作像素缓冲区把DICOM里的原始像素数据还原成灰度图像再通过自定义的windowLevel函数做窗宽窗位映射。整个过程不依赖任何插件现代浏览器直接跑。选用cornerstone.js而不是自己写Canvas渲染关键在三个点一是它处理了端序问题DICOM像素数据的传输语法是Little Endian还是Big Endian解析层会转成统一格式二是它实现了多平面渲染的基础能力MPR多平面重建和Volume Rendering虽然不在轻量级范围里但基础切片渲染、缩放、平移、测量这些工具都有现成实现三是它对JPEG、JPEG-LS、JPEG2000解码做了WebAssembly加速封装常见的影像压缩格式能直接出图。前端架构上我会把整个Viewer拆成三层最底层是dicomParser做文件解析负责把ArrayBuffer里的DICOM文件读成JS对象中间层是cornerstone注册imageLoader把解析结果注册成可渲染的Image对象最上层是自己的业务组件管序列列表、翻页、窗宽窗位交互。这套分层的好处是如果哪天发现cornerstone不够用可以只替换中间层业务代码不大动。图层渲染的逻辑则要自己控制同一序列的CT图像在切换窗宽窗位时所有切片要同步重绘这靠共享一个WindowLevel状态对象来实现属于业务层该管的活。2.3 数据模型与目录结构Study/Series/Instance三级关系PACS的数据模型必须跟DICOM标准的信息模型走这是与设备、与上级PACS互联的前提。最顶层是Patient患者下面挂Study检查、Series序列、Instance单张图像每一层都有唯一的标识Patient ID、Study Instance UID、Series Instance UID、SOP Instance UID。系统里几乎所有业务都围绕这四个UID转归档时用Study UID组织目录查询时用Patient ID拉列表渲染时用Series UID加载序列。PacsData/ ├── Archive/ │ ├── 2025/ # 按年份归档 │ │ └── 03/ # 按月份 │ │ └── 1.2.840.113564.2025.3.12.001/ # Study UID │ │ └── 1.2.840.113564.2025.3.12.001.01/ # Series UID │ │ └── IMG_00001.dcm │ └── Thumb/ # 缩略图JPEG ├── Database/ │ └── pacs.db # SQLite或SQL Server Express └── Viewer/ ├── index.html └── js/ ├── dicomParser.min.js ├── cornerstone.js └── viewer.js目录结构设计有两个原则一是按Study/Series两级组织文件夹文件系统本身就是索引即使数据库坏了也能靠目录找回数据二是原始DICOM和生成的缩略图分开存放阅片列表页只读JPEG避免每次都去解析完整DICOM文件。数据库表结构则保持最小patients、studies、series、instances四张表加一个settings表。不要一上来就设计插件机制和权限模型轻量级系统先把接收、查询、调阅跑通后面再补。这里要提个常见认知偏差很多人以为轻量级PACS就是不存原始DICOM只存压缩图。这是错的。PACS的第一职责是保真归档原始DICOM一个字都不能丢压缩图只是加速预览的副产品。存储再紧张也要把原始数据和派生数据分清楚。3. 最小可用的PACS实现DICOM接收、归档、Web查看三步落地3.1 用C#搭DICOM接收服务SCP监听与文件落盘先做整条链路里最硬的部分让系统能听懂设备的C-STORE请求并保存文件。用fo-dicom实现一个独立的DICOM接收服务监听104或11112端口收到SOP Instance后把数据集写入归档目录并把索引写入数据库。这个过程需要处理传输语法协商、AE Title校验、文件完整性校验否则真机联调时会翻车。using Dicom; using Dicom.Network; using Dicom.Log; // 自定义SCP服务处理C-STORE请求 public class StorageScp : IDicomServiceProvider, IDicomCEchoProvider, IDicomCStoreProvider { private readonly string _archiveRoot; private readonly IStudyRepository _repo; public StorageScp(string archiveRoot, IStudyRepository repo) { _archiveRoot archiveRoot; _repo repo; } // 拒绝未知的AE Title防止无关设备乱传 public Task OnReceiveAssociationRequestAsync(DicomAssociation association) { var allowedAe association.CalledAE PACS_SERVER; if (!allowedAe) { return Task.FromResult(association.Reject( DicomRejectReason.CalledAENotRecognized)); } association.Accept(); return Task.CompletedTask; } // C-STORE核心接收实例并落盘 public async TaskDicomCStoreResponse OnCStoreRequestAsync( DicomCStoreRequest request) { try { var ds request.Dataset; var studyUid ds.GetString(DicomTag.StudyInstanceUID); var seriesUid ds.GetString(DicomTag.SeriesInstanceUID); var sopUid ds.GetString(DicomTag.SOPInstanceUID); // 按 Study/Series 两级目录落盘文件名用 SOP Instance UID var dir Path.Combine(_archiveRoot, studyUid, seriesUid); Directory.CreateDirectory(dir); var path Path.Combine(dir, sopUid .dcm); await File.WriteAllBytesAsync(path, ds.ToByteArray()); // 写数据库索引重复实例则跳过 await _repo.InsertInstanceAsync(ds, path, File.Exists(path)); return new DicomCStoreResponse(request, DicomStatus.Success); } catch (Exception ex) { Logger.Error(C-STORE failed: {0}, ex); return new DicomCStoreResponse(request, DicomStatus.ProcessingFailure); } } }这段代码的逻辑线是先校验调用方AE Title只允许设备列表里的设备连接然后从Dataset里取出三级UID做目录路径最后把字节流写入文件并同步写索引。注意ds.ToByteArray()用的是内存序列化对大文件几百MB的血管造影先把整个数据集读进内存再写盘虽然简单但吃内存后面性能优化时改成流式拷贝更稳。设备联调时我最常遇到的问题是CalledAE大小写不匹配设备配置里全大写服务端判断却用了小写一眼看上去全是“收到请求但拒绝”排查半天才发现是字符串比较问题。所以AE Title校验我一般建议先归一化成大写再比较能少踩一半坑。SCP服务跑起来后先用fo-dicom自带的DicomClient发一个C-ECHODICOM协议里的ping验证链路通不通再发真实的C-STORE测试文件。这一步跑通了设备端才轮到接入。3.2 用dicomParser解析DICOM并在Canvas上渲染第一张图服务端能存前端得能看。DICOM文件本质上是一个个Tag组成的二进制块dicomParser负责把ArrayBuffer解析成JS对象再交给cornerstone的imageLoader去渲染。这里关键要理解一个概念DICOM的像素数据不是随便就能画的它可能是带封装的压缩格式也可能是裸的Pixel Data段解析器要能区分传输语法Transfer Syntax。// 1. 从服务器拿到DICOM文件的ArrayBuffer async function loadAndDisplayDicom(url, element) { const response await fetch(url); const arrayBuffer await response.arrayBuffer(); // 2. 用dicomParser解析二进制数据 const dataSet dicomParser.parseDicom(arrayBuffer, { untilTag: null, // 解析全部Tag transferSyntax: 1.2.840.10008.1.2.1 // 如已知传输语法可显式指定 }); // 3. 注册cornerstone的ImageLoader把DataSet渲染成图像 const image cornerstone.loadAndCacheImage(url, { loader: createImageLoader(dataSet) // 自定义imageLoader }); cornerstone.displayImage(element, image); } // imageLoader核心把PixelData转成Cornerstone需要的图像对象 function createImageLoader(dataSet) { return function () { const pixelData dataSet.byteArray.slice( dataSet.elements[7fe00010].dataOffset, dataSet.elements[7fe00010].dataOffset dataSet.elements[7fe00010].length ); return { minPixelValue: dataSet.string(00280106), // 最小像素值 maxPixelValue: dataSet.string(00280107), // 最大像素值 rows: dataSet.uint16(00280010), // 高 columns: dataSet.uint16(00280011), // 宽 slope: dataSet.float(00281053) || 1, // 斜率 intercept: dataSet.float(00281052) || 0, // 截距 windowWidth: dataSet.float(00281051), // 窗宽 windowLevel: dataSet.float(00281050), // 窗位 pixelData: new Uint8Array(pixelData), // 像素数组 }; }; }这里有两个参数直接影响出图效果。第一个是transferSyntax如果知道当前文件的传输语法是隐式VR还是显式VR、是否压缩可以显式传给解析器省去自动探测的开销不知道就让dicomParser自动判断但碰到压缩传输语法如JPEG 2k时要先解码得配cornerstone的WebAssembly解码器否则图像是黑的。第二个是slope和intercept这俩是DICOM里做灰度线性变换的参数影像设备存的是设备原始值要还原成真实CT值必须做value * slope intercept很多自研阅片器渲染出来图像对比度奇怪就是漏了这一步。cornerstone渲染是异步的大文件512×512×16bit的CT约500KB血管造影DSA一帧1MB起步加载时会白屏所以必须加加载中的占位提示。我一般用一个isLoading状态控制UI渲染完成再移除。至于多张切片连续翻页靠的是cornerstone内部缓存预加载前后两张翻到中间时基本无感不要一次性把整个序列全load进内存几百张血管造影能直接让浏览器崩掉。3.3 REST查询接口Study/Series/Instance三级检索怎么做浏览器要能按患者、检查日期捞数据服务端得暴露一套REST接口。和DICOM协议里的C-FIND不同Web端接口不需要严格对齐DICOM网络服务直接用JSON返回列表即可但字段名建议跟DICOM Tag对应方便以后扩展。接口设计成三个层级GET /patients、GET /patients/{pid}/studies、GET /studies/{studyUid}/series、GET /series/{seriesUid}/instances前端按需逐级拉取。// GET /studies?patientIdP001startDate2025-03-01endDate2025-03-31 { studies: [ { studyUid: 1.2.840.113564.2025.3.12.001, patientId: P001, patientName: 张**, studyDate: 2025-03-12, modality: CT, bodyPart: CHEST, seriesCount: 3, instanceCount: 428, thumbnailUrl: /thumb/1.2.840.113564.2025.3.12.001.jpg } ], total: 1 }关键SQL上的取舍是不要用多表JOIN去拼“最新检查列表”数据量大时很慢。我会单独维护一张study_overview冗余表把患者姓名、检查日期、设备类型、序列数、图像数、缩略图路径都冗余进去应用层只查这一张表真正进instances表的查询只有一种场景——用户点进某个序列开始阅片时。这个设计牺牲了一点写入性能但换来了列表页的秒开体验。轻量级系统的用户量几十个并发根本打不爆写库读才是主场景。分页参数也值得说明page从1开始pageSize固定成20接口返回total字段让前端知道要不要显示“加载更多”。排序上影像列表必须倒序最新检查排最前这是阅片工作流的肌肉记忆做正序会被医生骂。另外患者姓名涉及隐私接口返回时要考虑脱敏一般做法是后端只返回姓星号详情页再返回全名。3.4 缩略图与预览图一次JPEG转码省下90%带宽阅片列表页若直接加载原始DICOM每个检查动辄几百MB浏览器根本受不了。正确做法是DICOM文件落盘后立即用C#生成一张JPEG缩略图和一张预览图存到独立的Thumb目录列表页和阅片页都走静态文件不用再碰DICOM解析。这一步能把列表页流量从几百MB降到几MB体验是质的飞跃。// C# 生成缩略图存成JPEG public void GenerateThumbnail(string dcmPath, string thumbPath) { var file DicomFile.Open(dcmPath); var pixelData DicomPixelData.Create(file.Dataset, 0); // 取第一帧像素数据转成Bitmap var converter new GrayscaleRenderOption { // 关键参数窗宽窗位用DICOM里的默认值或手动指定 WindowWidth file.Dataset.GetSingleValuedouble(DicomTag.WindowWidth), WindowLevel file.Dataset.GetSingleValuedouble(DicomTag.WindowLevel) }; using (var bmp new DicomImage(file.Dataset).RenderImage(0, converter)) { // 缩略图最长边512pxJPEG质量85体积一般在50KB以内 var ratio Math.Min(512f / bmp.Width, 512f / bmp.Height); var newW (int)(bmp.Width * ratio); var newH (int)(bmp.Height * ratio); using (var thumb new Bitmap(bmp, newW, newH)) { thumb.Save(thumbPath, ImageFormat.Jpeg, 85L); } } }转码里有三个参数务必验证过再上线。一个是WindowWidth/WindowLevelCT图像默认窗宽窗位是400/40但不同部位差异很大如果直接用DICOM文件里存的默认值第一次出图一般能用但遇到增强扫描和骨窗会偏亮偏暗所以预览图生成时要读设备的00281051和00281050Tag没有就回退到400/40。第二个是JPEG质量85比较折中质量再高体积增加不明显医生在手机上看不出区别第三个是关键保存路径要按StudyUID/SeriesUID/InstanceUID.jpg组织和DICOM文件保持同样的目录层级这样数据库按UID查缩略图时路径可以直接拼接出来不用额外维护映射表。JPEG转码有个天然限制JPEG是有损压缩16位灰度存成8位会丢细节。所以这里边界要划清楚缩略图只用于列表展示和快速预览真正要诊断必须看原始DICOM。系统里要强制规定阅片页面默认加载原始DICOM缩略图只出现在列表页别让医生在手机上拿缩略图诊断出了医疗事故这锅背不起。4. 上线前必看的避坑清单影像丢失、花屏、并发卡死的排查4.1 现象设备显示传图成功服务端目录却是空的设备端日志显示C-STORE已发送服务端也返回了Success但归档目录里找不到文件。这个是最典型的“假成功”。第一个怀疑对象是AE Title配置设备发送的CalledAE是给目标SCP的称呼服务端对大小写敏感设备里配的是PACSServer服务端判的是PACS_SERVER必然拒绝但有些设备在自己日志里会显示“发送完成”而不是“关联被拒”容易误判。解决方法是先把SCP的AE校验临时放通只保留端口监听看文件能不能落盘能落盘就是AE配置问题立刻统一归一化成大写再比较。第二个原因是路径拼接时的非法字符。SOP Instance UID和Study UID是纯数字加点的格式但患者ID有可能包含中文或特殊字符如果用患者ID做目录层Windows路径会出现非法字符导致创建目录失败异常又被外层catch吞了表面看是成功返回。解决方法是目录层级只用UID不要用患者ID做路径。第三个原因是防火墙只在服务端开了端口但设备和服务端之间有网关设备网关的端口映射没配104端口。用Telnet测端口通不通再用DicomClient发C-ECHO一步步缩小范围。C-ECHO都通但C-STORE丢就抓设备端的完整网络日志看是关联阶段被拒还是传输中途断开。4.2 现象DICOM文件能打开但渲染出来图像花屏或全黑文件成功归档了浏览器也把图渲染出来了但图像像噪声一样花掉。这个坑几乎每个做DICOM前端的人都会踩一次像素数据是16位的但你按8位去读了。CT、MR的PixelData大部分是US无符号16位或SS有符号16位而dicomParser默认返回的byteArray按8位访问如果直接把字节流塞给Canvas灰度级直接错乱。解决方法是用dataSet.getPixelData()配合Uint16Array视图重新解释缓冲区并且要确认当前传输语法的大端小端显式VR大端序1.2.840.10008.1.2.2下像素字节顺序要反转一次。全黑是另一个原因。DICOM的像素值可能是带符号的比如CT值范围是-1024到3071屏幕只能显示0到255必须经过窗宽窗位映射。如果你只给了minPixelValue0和maxPixelValue3071整个图像的像素全都被压缩到接近黑色的一端。解决方法是拿到DICOM里的00281050Window Center和00281051Window Width做线性映射或者提供手动调节的输入框医生自己拉窗。这一步和3.2节里渲染逻辑是一回事但很多人在缩略图转码时图省事没做映射列表页整片黑排查时还以为是JPEG转码的问题。4.3 现象并发上传时文件写到一半设备一般不是单线程传图CT一个序列十几个上百个文件并发过来服务端用异步方法落盘。这时候出现文件字节数不对或者DICOM文件解析到一半报错。原因大多是内存流被多线程共享了比如用了同一个MemoryStream去接收多个请求的字节流后一个请求的写入把前一个覆盖了。解决方法是每个请求独立创建流对象不要用字段缓存。还要注意异步写文件时同一个文件路径被两个线程同时写路径用SOP Instance UID唯一正常不会冲突但设备重传同一个实例时会发生所以写文件前先判断文件是否存在存在就直接返回SuccessID幂等。我见过一台DR把整个序列重传了三遍服务端响应越来越卡排查发现是索引表里主键冲突重复的UID没做去重数据库抛异常后整个事务回滚磁盘上文件明明在了但索引里没有。记一条铁律文件系统和数据库必须做成“可重放”的文件在、索引不在比两个都在更难收拾。4.4 现象浏览器预览图不更新换了一台设备看还是旧图部署后发现医生反馈缩略图是旧的换设备、刷新缓存都一样。原因是缩略图路径是固定的靠StudyUID.jpg这种命名第一次生成后浏览器和CDN都缓存了之后文件被更新但URL没变浏览器直接读缓存。这个是纯前端缓存策略问题。解决方法是生成缩略图时带一个版本参数thumbnailUrl /thumb/{studyUid}.jpg?v{fileLastWriteTime}每次文件更新就直接换参数强制浏览器重新请求。另外如果用了反向代理记得把/thumb/路径上的缓存规则关闭或者让缓存时间设短不然代理层也会吞掉更新。测试时记得用无痕窗口验证别看半天以为服务端没生效。4.5 现象Web阅片页面卡到白屏尤其切序列时列表页速度快了阅片页卡是下一个瓶颈。卡的原因九成不是渲染性能而是网络请求每次序列切换都把几百个DICOM文件整批拉给浏览器。正确做法是懒加载页面上只加载当前看到的切片和前后各两张其余等用户翻到再说。cornerstone的loadAndCacheImage本身有缓存机制配合一个预加载管理器最简单实现是监听cornerstoneimagerendered事件当前切片渲染完后自动触发加载下一个。还有一个隐蔽瓶颈浏览器对同域名并发请求有限制HTTP/1.1约6个几十张图同时请求会排队表现就是卡。解决方法是把静态图片放到独立域名/端口下或者Nginx里配置HTTP/2多路复用把并发窗口打开。血泪经验是先把这五个点逐个Reply过再去折腾什么“内存泄漏”“Canvas性能优化”大多数所谓性能问题都是网络层面的调度问题。5. 进阶JPEG-LS分层压缩与性能验收指标在归档链路上预览图和原始图之外还可以加一层“无损压缩”的归档压缩策略。DICOM原始文件往往很大CT一张512×512×16bit约500KBDR一张2000×3000的片子能到20MB。磁盘再便宜也经不起长期囤。分层存储的做法是带诊断价值的原始图像保留不压缩或做JPEG-LS无损压缩JPEG-LS对灰度图像压缩比能到2:1到3:1远比RLE行程编码效果好同时生成低分辨率的JPEG有损预览图、JPEG 2000无损中间层。这套分级的核心权衡是读取速度与存储占用JPEG-LS无损压缩能省一半多的空间但解压时要消耗CPU如果设备本身对图像质量极其敏感乳腺钼靶、血管造影就不要动原始数据只做有损层。验收这套系统好不好用我的习惯是定三个硬指标上线前逐项打勾第一从设备发出C-STORE到Web端列表出现该检查时间不超过3秒这不单考验接收速度更考验缩略图转码速度转码慢就等不到3秒第二列表页打开某个检查从点击到首帧渲染时间不超过2秒第三连续翻页时50张CT序列顺滑翻完不出现白屏等待。压测方法是拿测试设备连续传100个检查大约2万张图打循环不停地翻页看服务端CPU、磁盘I/O、内存有没有持续上涨不回落。如果认证一下轻度负载下内存泄漏是测不出来的必须长跑一整天看趋势。我在这套系统上吃过最大的亏是自作聪明统一用了高压缩比的有损JPEG做预览结果一位老医生反馈增强CT的微小病灶在预览图上显示不清晰吓得我连夜把压缩策略改成“预览仅用于定位阅片强制走原始DICOM”并且把JPEG质量锁死在90以上、禁止下调。医学影像这条线的底线是“所见即所得”任何压缩优化都不能以牺牲诊断信息为代价。做轻量级PACS的人要有这个敬畏你不是在做图床你是在做诊断链条的一部分。这套C#加JavaScript的组合真正验证下来是可行的。把DICOM接收、归档、查询交给C#把交互、渲染交给JavaScript两台普通服务器就能撑起一个日均几百检查的影像中心。代码不复杂复杂的是把这些环节串起来时的那些细节。希望这篇能把你的踩坑时间压缩一点少走我走过的那些弯路这中间最值钱的经验就是先保住原始数据再谈速度。本文还有配套的精品资源点击获取