简介这套资源是一套基于C#与.NET框架开发的全套PACS医学影像存档与通信系统源码适合医疗信息化开发者、HIS/LIS系统集成人员以及想了解医学影像技术的软件工程师。源码围绕图像采集、DICOM通信、SQL数据库存储、工作站显示等核心模块展开完整呈现了从患者登记、影像上传、序列管理到报告查看的临床业务流程。资源以rar压缩包形式提供包体约399.87MB内含项目源码、部署说明及使用文档便于快速还原系统环境并开展二次开发部署部分覆盖SQL服务器配置、DICOM服务启动、数据库脚本导入等关键环节使用部分则说明如何通过.NET控件完成患者信息添加、影像上传和报告查看。虽然平台未单独列明文件总数与类型清单但压缩包内模块划分清晰适合作为PACS入门学习和再工程基线已有554人浏览学习值得希望借助C#/.NET医疗项目提升实战能力、或准备构建类似影像管理系统的开发者参考。1. 为什么值得拆一套C#版PACS源码DICOM协议、.NET控件与全链路落地如果要在 .NET 技术栈里挑一门同时覆盖网络通信、文件归档、桌面交互和图像处理的综合实战PACS医学影像归档与通信系统源码算是最硬核的方向之一。它既不是普通 CRUD也不只是控件皮肤而是围绕 DICOM 协议构建的完整影像链路设备端用 C-STORE 把影像推过来服务端解析 Tag、落盘归档阅片端再把像素渲染到 WinForms 控件上。对做 C#/.NET 软件或插件开发的从业者来说这套源码的价值在于——你能看到协议层怎么建模、数据层怎么做对象管理、UI 层怎么和像素缓冲交互而不是只在业务系统里写增删改查。这套资源解决的核心问题很具体影像科每天产生大量 CT/MR/DX 数据如果靠手动拷贝、按患者文件夹堆放查询和调阅都会失控。PACS 把“接收—存储—调阅—胶片打印”串成一条自动化管线技师推完片子就能在阅片端看到。适合三类人刚接触医学影像的 C# 开发、想给现有 .NET 系统加影像模块的工程师、以及需要评估自研 PACS 成本的团队负责人。下文按我拆这套源码时的思路从协议、架构、代码实现到排错逐层展开。2. 先摸清PACS的四块底协议、架构、存储与数据流决定代码往哪读拿到任何一套 PACS 源码第一步不是打开解决方案而是先确认它站在哪套协议和架构之上。很多人一上来就搜“图像显示在哪”结果在 WinForms 控件里绕了半天却不知道影像数据是怎么从网络进到硬盘的。这章把四个底层问题理清楚后面读代码才有坐标。2.1 DICOM不是文件格式是通信规则C-STORE/C-FIND/C-MOVE的职责边界DICOM 常被误解为“一种医学图像格式”实际上它是一整套通信协议和数据结构标准。你在源码里看到的 .dcm 文件只是 DICOM 标准里“介质存储”那一部分的具体化形式。真正的协议核心是网络通信服务C-STORE 负责把影像从 SCU客户端推送到 SCP服务端C-FIND 用于在远端查询患者/检查列表C-MOVE 则请求对方把影像传输到第三个节点。理解这三者你才能分清源码里哪些模块是接收、哪些模块是查询代理。读这套源码时先找服务端入口。通常是一个继承自 DICOM 服务基类的类重写了关联响应和 C-STORE 请求处理函数。关联Association阶段相当于 TCP 握手之外的另一层握手SCU 发送关联请求声明自己要用的抽象语法SOP Class和传输语法Transfer SyntaxSCP 逐项确认。如果传输语法不匹配比如设备端用 JPEG-LS 压缩服务端只接收未压缩的格式关联会被拒绝或转入降级协商。C-MOVE 的职责也需要单独确认。很多 PACS 的 C-MOVE 不是从设备拉图而是把请求转发给存储节点执行。源码里如果出现 MoveOriginator 相关的 Tag说明它在追踪移动请求的发起者。这块逻辑容易和 C-STORE 混淆判断方法很简单C-STORE 只管“传”C-MOVE 管“让谁传给谁”。读代码时盯住服务类里实现了哪几个接口就能快速知道这套源码覆盖了哪些服务不支持的扩展点在哪。2.2 C/S架构与影像存储分层从接收到归档的链路拆解PACS 多数采用 C/S 架构服务端负责接收和归档客户端负责展示和操作。这套 C# 源码也不例外。客户端通过 DICOM 协议访问服务端而不是直接读数据库里的 BLOB这是医学影像系统区别于普通文件管理系统的关键点——影像数据必须保留 DICOM 原貌包括患者信息、设备参数、序列属性等几千个 Tag拆成独立字段存储反而会丢失原始语义。存储层通常是分层设计。第一层是接收缓冲目录SCP 收到影像后先落到一个临时的文件系统目录再异步写入正式存储池。这套源码若在接收函数里直接写数据库基本可以判断它并不适合高并发场景。正确的做法是解耦接收与归档接收线程只管把 DICOM 文件完整写到磁盘另一个服务或后台进程扫描缓冲目录解析 SOP Instance UID、Study Instance UID 等关键 Tag写入文件索引库并移动文件到按检查归档的目录结构中。文件目录的组织方式也决定了后续调阅效率。合理结构一般是“患者ID/检查UID/序列UID/实例UID.dcm”这种层级这样通过 UID 直接拼接路径就能定位文件不需要每次查数据库。数据库只保存检查状态和索引信息而不是影像本体。拆源码时重点看路径生成函数——如果它用数据库自增 ID 做目录名迁移数据或跨院互认时会非常痛苦。2.3 与HIS/RIS的对接HL7消息与Worklist的联动方式PACS 不是孤立系统它需要从 RIS放射信息系统获取检查申请单这就是 Worklist 服务的场景。设备端在拍片前会向 PACS 发起 C-FIND Worklist 查询获取今天要做的患者列表和检查部位。源码里如果实现了 Worklist SCP通常会在查询响应里拼装患者姓名、检查描述、体位等信息这些字段来自数据库或对接消息。对接方式最常见的是 HL7 消息。RIS 通过 HL7 的 ADT患者入出转和 ORM医嘱消息通知 PACS 新检查、修改或取消。这套源码若包含消息监听服务一般会监听一个 TCP 端口解析 HL7 管道分隔文本。这里有个隐藏坑HL7 字段分隔符是竖线|但组件分隔符是脱字符^解析时必须注意转义字符。很多联调问题就出在“把组件分隔符也按字段处理”。源码中如果有专门的 HL7 工具类读它时顺手确认转义处理是否完整。从这套源码的边界也能看出它的定位如果 Worklist 和 HL7 都齐全说明它是一套完整的科室级 PACS如果只有 C-STORE 和查询那它更接近影像归档存储库。拆源码前先认边界能帮你决定是整体复用还是只抽取部分模块。3. C#实现DICOM接收与影像解析从.NET服务到控件显示一步步落代码原理看清后就该动代码了。这套源码的核心是把 DICOM 协议栈和 WinForms 控件串起来。这章我按“接收—解析—显示”三个环节拆出最小可复现的代码路径。如果你之前只写过业务系统这一章的节奏会比较快但每一步都是后续排错的基础。3.1 用fo-dicom搭建C-STORE SCP接收端AE Title与传输语法协商DICOM 服务端在 .NET 生态里最常见的实现是 fo-dicom 库。它把关联协商、PDU 解析、传输语法编解码都封装好了我们只需要实现服务接口。下面的代码是接收端核心骨架负责接收设备推送的 DICOM 文件并落盘// CStoreSCP.cs using Dicom; using Dicom.Network; using Dicom.Log; public class CStoreSCP : DicomService, IDicomServiceProvider, IDicomCStoreProvider { private readonly string _storagePath; public CStoreSCP(Stream stream, DicomAssociation association) : base(stream, association) { _storagePath Path.Combine(AppContext.BaseDirectory, STORAGE); Directory.CreateDirectory(_storagePath); } // 关联阶段校验请求方身份决定接受还是拒绝 public async Task OnReceiveAssociationRequestAsync(DicomAssociation association) { // 常见做法先检查CalledAE Title是否等于本服务配置的AE if (association.CalledAE ! PACS_SERVER) { await SendAssociationRejectAsync( DicomRejectResult.Permanent, DicomRejectSource.ServiceUser, DicomRejectReason.CalledAENotRecognized); return; } // 逐个确认表示上下文接收方可以只接受未压缩语法 foreach (var pc in association.PresentationContexts) { pc.SetResult(DicomPresentationContextResult.Accept); } await SendAssociationAcceptAsync(association); } // C-STORE请求处理接收整个DICOM文件并保存 public async TaskDicomCStoreResponse OnCStoreRequestAsync(DicomCStoreRequest request) { try { var dataset request.Dataset; var sopInstanceUid dataset.GetSingleValuestring(DicomTag.SOPInstanceUID); var studyUid dataset.GetSingleValuestring(DicomTag.StudyInstanceUID); var seriesUid dataset.GetSingleValuestring(DicomTag.SeriesInstanceUID); // 按 患者/检查/序列/实例 四级目录落盘 var saveDir Path.Combine(_storagePath, studyUid, seriesUid); Directory.CreateDirectory(saveDir); var savePath Path.Combine(saveDir, sopInstanceUid .dcm); await Task.Run(() request.File.Save(savePath)); return new DicomCStoreResponse(request, DicomStatus.Success); } catch (Exception ex) { // 生产环境建议记录详细日志后再返回失败 Logger.Error($C-STORE failed: {ex}); return new DicomCStoreResponse(request, DicomStatus.ProcessingFailure); } } // 关联释放与异常处理的默认实现 public Task OnReceiveAssociationReleaseRequestAsync(DicomAssociation association) { return SendAssociationReleaseResponseAsync(); } public void OnReceiveAbort(DicomAbort aborted) { Logger.Warn($Association aborted: {aborted.Reason}); } }这段代码的关键在于三个接口方法关联请求、C-STORE 请求、释放请求。OnReceiveAssociationRequestAsync 里做的 AE Title 校验是第一步过滤——如果请求方发来的 CalledAE 不对直接拒绝避免无关设备往存储里写垃圾数据。OnCStoreRequestAsync 是接收主逻辑调 request.File.Save 会按照 DICOM 文件的原始编码保存不改变任何 Tag。参数说明里最需要注意的是 SOPInstanceUID 和 StudyInstanceUID 这类 UID。UID 在 DICOM 里是全局唯一的字符串标识不能为空。如果设备端数据不规范这里可能拿到空串导致目录路径变成 STORAGE\ 的根目录文件四散。兜底做法是用 Guid 生成内部标识但这会丢失与原始影像的关联关系所以我在代码里保留了 UID 优先的策略。3.2 影像解析与像素数据处理从DICOM Tag到可显示图像接收只是开始影像只有解析成像素才能上屏。DICOM 文件里的像素数据存于 Tag (7FE0,0010)但它不一定是原生 RGB。CT 影像一般是 Monochrome2灰度每个像素 12 或 16 位可能存在高位填充。直接按字节读出来显示会得到一张黑屏或花屏。正确的解析路径是先读 PhotometricInterpretation光度解释、BitsAllocated分配位数、BitsStored有效位数、PixelRepresentation有符号/无符号这几个关键 Tag。// PixelDataHelper.cs using Dicom; using Dicom.Imaging; using Dicom.Imaging.Codec; using System.Drawing; using System.Drawing.Imaging; public static class PixelDataHelper { public static Bitmap ToBitmap(DicomDataset dataset, double windowCenter, double windowWidth) { // 创建一个像素访问器fo-dicom会按传输语法自动解码 var pixelData DicomPixelData.Create(dataset); // 灰度图像取第一个帧 var frame pixelData.GetFrame(0); var bitsAllocated dataset.GetSingleValueushort(DicomTag.BitsAllocated); var bitsStored dataset.GetSingleValueushort(DicomTag.BitsStored); var pixelRepresentation dataset.GetSingleValueushort(DicomTag.PixelRepresentation); // 把帧数据读入 ushort 数组注意大小端已在解码时处理 var buffer new ushort[frame.Size]; frame.Data.CopyTo(buffer, 0); // 调窗映射线性变换 [min,max] - [0,255] var min (int)(windowCenter - windowWidth / 2.0); var max (int)(windowCenter windowWidth / 2.0); var bmp new Bitmap(pixelData.Width, pixelData.Height, PixelFormat.Format8bppIndexed); var rect new Rectangle(0, 0, bmp.Width, bmp.Height); var bitmapData bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); unsafe { var ptr (byte*)bitmapData.Scan0; for (int i 0; i buffer.Length; i) { // CT值通常按无符号处理但部分设备会存有符号值 int val pixelRepresentation 0 ? buffer[i] : (short)buffer[i]; val val (bitsAllocated - bitsStored); // 右移去除高位填充 int gray (val - min) * 255 / (max - min); if (gray 0) gray 0; if (gray 255) gray 255; ptr[i] (byte)gray; } } bmp.UnlockBits(bitmapData); return bmp; } }这段代码里的重点不是循环本身而是“右移”这一步。BitsAllocated16、BitsStored12 是 CT 常见配置设备把有效 12 位存在 16 位容器的低 12 位高 4 位填充 0。如果不右移值域会偏大图像整体偏白。PixelRepresentation 判断有符号还是无符号也很关键MRI 图像的像素值可以是负的如果当成无符号数读整张图的灰度会错乱。调窗参数从 WindowCenter 和 WindowWidth 两个 Tag 取这是设备给出的默认值。实际阅片时医生会手动调整窗宽窗位比如肺窗、骨窗所以这个函数在源码里会被多次调用。性能方面要注意GetFrame(0) 只取第一帧多帧影像如动态增强需要循环取帧建议在 3.3 的界面里做帧缓存避免每次重绘都解码一整遍。3.3 WinForms控件展示与调窗.NET下的交互实现显示层在这套源码里用的是 .NET 原生的 WinForms 控件。把像素数据渲染到 PictureBox 是简单做法但它自带的缩放算法和像素格式处理不一定符合阅片场景尤其是需要精确 1:1 显示、平移和窗宽窗位实时调节时更可控的方式是自定义控件。// ImageViewer.cs — 自定义图片显示控件 using System.Drawing; using System.Windows.Forms; public class ImageViewer : Control { private Bitmap _image; private double _windowCenter; private double _windowWidth; private Point _lastMousePos; public ImageViewer() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); } public void LoadImage(Bitmap image, double wc, double ww) { _image image; _windowCenter wc; _windowWidth ww; Invalidate(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_image null) return; var g e.Graphics; // 按控件大小等比缩放绘制保持影像纵横比 var scale Math.Min((double)Width / _image.Width, (double)Height / _image.Height); var drawWidth (int)(_image.Width * scale); var drawHeight (int)(_image.Height * scale); var offsetX (Width - drawWidth) / 2; var offsetY (Height - drawHeight) / 2; g.DrawImage(_image, new Rectangle(offsetX, offsetY, drawWidth, drawHeight)); } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _lastMousePos e.Location; } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (e.Button MouseButtons.Left) { // 拖拽平移改变显示偏移量 // 实现时把偏移量保存为类字段并调用 Invalidate() 刷新 Invalidate(); } } }自定义控件的好处是鼠标事件完全可控。OnMouseMove 里根据鼠标位移更新平移量滚轮事件里可以调整窗宽窗位OnPaint 里重新计算映射——这是普通 PictureBox 做不到的。代码里的 SetStyle 双缓冲是必备的否则拖动图片时会有闪烁。控件与像素数据之间建议加一层 ViewModel控件的 LoadImage 传入的是调窗后的 Bitmap而调窗操作放在 3.2 的处理函数里。这样 UI 层不直接碰 DICOM Tag切换窗宽窗位时只用重新调用一次 ToBitmap 再 LoadImage。要注意的是8bpp 索引色 Bitmap 在缩放时可能出现色带处理方式是重绘时用 InterpolationMode.HighQualityBilinear 或把像素数据转成 24bpp RGB 再显示。4. PACS开发避坑AE Title、压缩语法、中文乱码与显示异常最常出问题拆这套源码、跑通链路的过程中我记录了几个高频踩坑点。这些问题在验收阶段尤其容易出现每一条都按现象、原因、解决整理如下。4.1 设备连接被拒AE Title大小写不匹配现象设备端配置好 IP 和端口发送影像时 PACS 直接返回 Association Rejected日志显示 CalledAENotRecognized。原因DICOM 的 AE Title 是大小写敏感的应用实体名称设备端配置的 CalledAE 与服务端 Accept 的完全字符串比对差一个大小写字母都判失败。很多现场实施人员会习惯性把 AE Title 写成大写或小写导致两边不一致。解决服务端关联请求处理里增加宽松校验常见做法是允许配置一个 AE Title 列表匹配时统一转大写后再比较。也可以在拒收前打日志把请求里的 CalledAE 和本机配置的都打印出来方便现场快速排查。从那以后我每次部署 PACS 都会先手动测试两端 AE Title 的一致性而不是等设备推图时报错。4.2 影像接收失败压缩传输语法未注册现象普通 CT 影像接收正常但某台设备发来压缩后的 MR 影像时C-STORE 请求被拒绝或返回 Cannot Understand。原因DICOM 关联协商时设备端会在表示上下文里列出它支持的传输语法比如 JPEG Baseline、JPEG-LS。服务端如果只接受了隐式 VR Little Endian未压缩设备就会放弃发送或者按未压缩重传很多设备默认不重传直接报错。解决在关联请求处理循环里把设备端提出的所有传输语法全部 SetResult(Accept)。fo-dicom 在接收到压缩帧数据时会自动调用编解码器不需要手动干预前提是项目里引用了对应的编解码程序集。我一般会在 OnReceiveAssociationRequestAsync 里加一个循环不管 pc.AbstractSyntax 和 TransferSyntax 是什么都先 Accept然后在 C-STORE 处理时再针对不支持的语法返回失败。这样可以先让关联建立拿到完整错误信息而不是在握手阶段就断掉。4.3 患者姓名中文乱码字符集声明缺失或解析错误现象设备端、服务端、阅片端都是中文操作系统但 PACS 里显示的患者姓名是乱码英文姓名正常中文全部变成问号或特殊字符。原因DICOM 默认字符集是 ISO-IR 6ASCII。中文姓名必须在 SpecificCharacterSet 这个 Tag0008,0005里声明比如 GB18030 或 ISO_IR 192UTF-8。部分老设备只翻译数据不声明字符集或者声明了但 PACS 端没有按声明解码。解决解析 Dataset 时先读 SpecificCharacterSet再用对应编码转换 PatientName。fo-dicom 的 DicomDataset 在读取字符串时本身支持字符集转换但前提是数据集里正确声明了该 Tag。如果设备端不声明常见的兜底做法是在接收后强制将 PatientName 按 GBK 解码一遍把转出来的非法字节纠正。注意这个兜底不能用于所有 Tag只处理 PersonName 相关字段否则会把本来的合法字符破坏掉。4.4 保存文件名非法SOPInstanceUID包含路径分隔符现象接收设备推送的文件部分能保存部分在 Directory.CreateDirectory 或文件写入时报路径错误。原因DICOM 标准对 UID 的字符集限制为数字和点号但个别设备存在不规范实现把 UID 和附加信息拼接比如加了“.”加设备号甚至包含绝对路径的分隔符。这种情况在第三方导出工具生成的 DICOM 文件里偶尔出现。解决保存路径里的每个 UID 段都要做合法性过滤。我一般会写一个 SanitizeUid 函数把非数字和非点号的字符替换为下划线同时保证替换后的字符串不为空。更深一层的做法是不依赖 UID 做文件系统路径而是在数据库里建 UID 与存储路径的映射文件名为 GUID索引表保存真实 UID。代价是查文件要多一次数据库 IO好处是彻底规避文件名非法的问题。4.5 图像显示偏白或全黑BitsStored与BitsAllocated的位深坑现象接收的是 CT 影像显示时整个图像一片白或者只有极少数像素有深浅变化窗宽窗位数据也正常。原因这通常不是调窗函数的问题而是像素解析时没有处理 BitsStored 与 BitsAllocated 的差异。如果 BitsAllocated16、BitsStored12像素的低 12 位有效但高 4 位可能是噪声或保留位。直接把 ushort 值映射到 0-255值域被拉高图像自然偏白。解决在 3.2 的 ToBitmap 里做右移处理val val (bitsAllocated - bitsStored)。同时注意 PixelRepresentation1有符号时要先转 short 再移位否则负数补位会全变成零。还有一个细节是有些压缩格式如 JPEG-LS解码后像素是归一化到 0 到 2^bits-1 范围的这时不需要右移判断方式是看传输语法是否带 Lossless 字样。这个坑排查起来比较费时间建议在显示端加一个像素值统计功能显示当前鼠标位置的像素灰度值能一眼看出数据范围是否合理。5. 联调与进阶用真实DICOM影像验证传输和显示假影像一测就露馅前面几章把代码链路走通了最后一章说验证。很多人把“能接收到文件”当作联调完成但接收成功只代表网络层通了不代表影像数据完整、Tag 正确、显示可信。我习惯用三步走做验证简单但能拦住大部分问题。第一步是校验传输完整性。接收端保存文件后计算 DICOM 像素数据的哈希与发送端原始文件的像素数据哈希对比。不需要第三方工具fo-dicom 的 DicomPixelData 可以直接读原始帧数据// 校验发送端和接收端同一影像的像素是否一致 var sentFile DicomFile.Open(C:\raw\original.dcm); var receivedFile DicomFile.Open(C:\STORAGE\received.dcm); var sentPixels DicomPixelData.Create(sentFile.Dataset).GetFrame(0).Data; var receivedPixels DicomPixelData.Create(receivedFile.Dataset).GetFrame(0).Data; Console.WriteLine($像素字节数一致: {sentPixels.Length receivedPixels.Length}); Console.WriteLine($像素内容一致: {sentPixels.SequenceEqual(receivedPixels)});如果两边像素字节数相等但内容不一致一般不是传输问题而是文件保存时被重新编码了——比如服务端强制把传输语法改成小端未压缩。这时候要检查保存逻辑是否调用了 DicomFile.Save 之外的额外处理。第二步是验证 Tag 解析。我通常会挑十个核心 Tag 做断言PatientID、StudyDate、Modality、SeriesInstanceUID、SOPInstanceUID、Rows、Columns、BitsAllocated、PhotometricInterpretation、WindowCenter。任何一个为空或格式异常都说明数据集不完整。记得把断言结果输出到日志方便追溯。第三步是真实影像的目视验证。打开显示界面用已知病理特征的 DICOM 影像做测试——比如一张肺部 CT 应该能看到气管分叉和纵隔结构一张膝关节 MR 应该能区分皮质骨和关节液。如果看到的图像纹理完全随机大概率是像素位深或调窗映射的问题而不是显示控件的问题。进阶方向有两个值得投入。一个是多线程接收与存储的平衡设备推图往往是并发多个实例服务端如果单线程串行 Save高峰期必然积压。常见做法是接收线程池 持久化队列接收后先入队列归档服务批量写盘。另一个是预取策略阅片端打开一个患者时提前把下一序列的影像加载进内存缓存减少医生拖动滚动条时的等待感。这两块做好PACS 从“能跑”到“好用”的距离就缩短了大半。从那以后我每次做完影像模块联调都会强制把这三步验证走一遍像素哈希不一致就当场查而不是等到科室反馈“图不对”。这套源码里如果遇到类似功能建议也按这个顺序去验证。希望帮到你。本文还有配套的精品资源点击获取