ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WinForms PACS源码:DICOM解析、窗宽窗位与C-STORE归档

C# WinForms PACS源码:DICOM解析、窗宽窗位与C-STORE归档

简介:面向医疗信息化开发者与C#/.NET学习者的全套PACS源码,围绕医学影像的存储、检索、分发与显示场景,提供一套可参考的系统实现。内容覆盖C#编程、.NET控件界面构建、SQL数据库管理患者信息与影像元数据,以及图像采集设备、DICOM服务器、数据库服务器、工作站和网络等PACS核心组成;描述中还提到图像查看器、菜单按钮等界面控件,患者信息、检查日期、影像序列等数据对象,以及DICOM数据格式转换、事务处理和大规模并发访问等要点。资源配有部署与使用说明,便于理解模块职责与系统落地思路。压缩包为rar格式,包体约399.87MB,文件总数记录为0、类型明细暂未提供。已有561人学习或下载。对希望进入医疗影像管理领域、需要对照源码梳理架构与数据流的初中级开发者,这份材料可用于建立从界面、数据库到DICOM通信的整体认识,并作为类似项目设计的参考蓝本。

1. 从一台读片工作站说起:这套 C# PACS 源码到底能跑出什么

凌晨被叫起来处理一台读片机打不开影像,这种事在影像科室不算稀奇。这套 C# 写的 PACS 源码,就是那台工作站的完整实现:DICOM 接收、序列浏览、窗宽窗位调节、长度角度测量、Worklist 查询、归档落盘,一条链路全在 WinForms 界面里闭环。它用的是 .NET 桌面控件那一套,不是浏览器方案。

适合两类人:手上有老设备、必须本地部署、网络不能出科室的开发者;以及想弄明白 PACS 内部到底怎么转、不想只停留在“调个接口”的人。整套解决方案编译完,能起一个收图的服务端加一个看图的客户端,中间没有必须联网的环节。

2. 技术栈与工程结构:WinForms 桌面端是怎么把 PACS 撑起来的

2.1 为什么影像工作站压在 WinForms 而不是浏览器

这是工作形态决定的,不是技术偏好。一台阅片工作站开机就是全屏,同时开三四个序列,每个序列几百张 512×512 的 16 位像素,一条 CT 序列的原始数据几十兆到上百兆。浏览器方案要把像素按需拉过来、解码、再转 canvas 或 WebGL,网络抖一下滚轮就卡;桌面端直接把文件读进内存数组,翻页就是指针移动。

还有设备和权限的硬约束。工作站经常要接本地采集卡、要调胶片打印机、要写本地缓存目录、要在断网时继续看已经归档的片子。这些在浏览器沙箱里要么做不了,要么做得很别扭。

WinForms 也不是因为它先进,而是因为它“够笨”。GDI+ 的绘制模型简单,双缓冲一开,自定义控件画测量线、画 ROI、画定位线都是直接操作Graphics,不用跟布局引擎和虚拟 DOM 打架。坑当然也有——GDI+ 对大图缩放容易糊,得自己做插值和分块绘制,这个后面会讲。

第三方控件主要用在非影像区域:患者列表、检查列表用表格控件,主界面用 Docking 面板做可拖拽布局,皮肤只是顺带。影像区一律自绘,别指望现成控件能给你一个能测距的视口。

2.2 四层结构:界面、业务、通信、存储各自的边界

源码里的分层比较克制,四个项目对应四件事。分层不是为了好看,是为了现场远程维护时,看一行日志就知道该去哪个目录翻代码。

层典型类型职责不该做的事
表现层MainForm、ViewerControl、ThumbnailPanel布局、交互、绘制不直接拼 SQL、不直接收发 PDU
业务层StudyService、PatientService、MeasureService查询组装、权限过滤、序列排序不碰字节流
通信层DicomServer、DicomClient、DicomParser、PduReader编解码、PDU 收发不写数据库
存储层StudyRepository、FileStore入库、写盘、路径规则不解析标签语义

边界一旦被打破,最先出问题的是并发。通信层是多线程的,业务层如果拿着Session.Current这种静态状态去拼查询条件,收图线程和 UI 线程就会互相踩。我一般要求通信层只往外抛不可变的DicomDataSet快照,谁需要业务上下文谁自己往下传。

2.3 解决方案目录:哪些项目能删,哪些动不得

PacsSolution/ ├── Pacs.UI/ # WinForms 主程序,唯一可执行入口 │ ├── Forms/ # 登录、主窗体、查询、设置 │ ├── Controls/Viewer/ # 自绘影像控件与测量图层 │ └── app.config ├── Pacs.Dicom/ # DICOM 编解码 + PDU 收发 │ ├── Codec/ # 传输语法、VR 字典、像素解码 │ ├── Network/ # SCP 监听、SCU 客户端 │ └── Model/ # DicomTag / DicomDataSet / DicomElement ├── Pacs.Service/ # 业务逻辑 ├── Pacs.Repository/ # 数据库与文件存储 ├── Pacs.Common/ # 日志、配置、扩展方法 └── Pacs.Tools/ # 离线工具:批量导入、标签查看、回放

Pacs.Tools是最容易被忽略、但最该先跑起来的一个项目。里面的标签查看器能把任意.dcm的标签树按十六进制和语义两种方式打出来,调解析器的时候,它就是你的黑匣子。Pacs.Common里放的是全项目共用的日志和配置读取,动它之前先确认没有别的地方依赖静态初始化顺序。

Pacs.UI是唯一输出可执行文件的项目,其余全是类库。想砍功能的话,Pacs.Tools可以先不编译,Pacs.Dicom和Pacs.Repository千万别动,动了等于重写。

2.4 启动链路:Program.cs 到主窗体之间发生了什么

很多人拿到源码直接 F5,登录框一弹就以为跑起来了,其实中间有几处顺序不能换。

[STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // 日志必须最先初始化,否则后面任何异常都只能是白屏 AppLog.Init(ConfigManager.Current.LogPath); try { using (var login = new LoginForm()) { if (login.ShowDialog() != DialogResult.OK) return; // 取消登录直接退出 Session.Current = login.Session; // 用户 ID、科室、角色位 } // 通信服务与主窗体同进程,内部是 TcpListener + 线程池,本身不阻塞 UI var server = new DicomServer(ConfigManager.Current.DicomPort); server.Start(); // 未捕获异常统一落盘,现场没有调试器的时候全靠这两行 Application.ThreadException += (s, e) => AppLog.Error(e.Exception); AppDomain.CurrentDomain.UnhandledException += (s, e) => AppLog.Error(e.ExceptionObject as Exception); Application.Run(new MainForm(server)); } catch (Exception ex) { AppLog.Error(ex); MessageBox.Show("启动失败,详见日志:" + ConfigManager.Current.LogPath); } }

逻辑上分五步:日志先起、登录阻塞、服务后起、异常钩子挂在Application.Run之前、主窗体接管消息循环。ShowDialog是模态的,返回非OK就说明用户放弃,这时不能再去启动服务,否则端口被占着,下次启动会报“地址已在使用”。

参数上注意两点。DicomPort默认写的是 104,但 1024 以下的端口在 Windows 上需要管理员权限,普通账户启动会抛SocketException,改成 11112 就行。Session.Current里存的是用户 ID、所属科室和角色位,后面所有列表过滤、删图权限都从它取,别在业务层再传一遍。

2.5 运行环境与依赖清单

项要求说明
操作系统Windows 7 SP1 及以上现场老机器占比不低
运行时.NET Framework 4.6.2 及以上低于 4.6 会缺部分异步 API
数据库SQL Server 2012 及以上连接串在 app.config
端口104 或 11112需要防火墙入站放行
磁盘影像盘单独一块按天线性增长,别和系统盘混
账户权限首次建库需 dbcreator建完降权到 db_datareader/writer

配置文件是这套源码的中枢,改错一个键就是启动即崩。

<configuration> <appSettings> <add key="AeTitle" value="PACS_SCP"/> <!-- 本机 AE 标题,对端要配成一致 --> <add key="DicomPort" value="11112"/> <!-- 建议避开 104 --> <add key="StorageRoot" value="D:\PacsData"/> <!-- 影像根目录,必须可写 --> <add key="ThumbnailCache" value="300"/> <!-- 缩略图内存缓存条数 --> <add key="LogPath" value="D:\PacsData\logs"/> <!-- 日志目录 --> </appSettings> <connectionStrings> <add name="PacsDb" connectionString="Data Source=127.0.0.1;Initial Catalog=PacsDb;User Id=pacs;Password=***;" providerName="System.Data.SqlClient"/> </connectionStrings> </configuration>

AeTitle是最容易配错的一项。它是 DICOM 里的应用实体名,对端设备在配置“目的端”时要填一模一样的字符串,大小写敏感,超过 16 个字符很多设备直接拒绝。StorageRoot指向的目录要有写权限,且不要放在会被同步软件扫描的路径下,同步进程锁住.dcm文件会导致入库失败。

3. 核心模块拆解:DICOM 解析、窗宽窗位与测量标注

3.1 文件解析:前导、元信息与数据集三段式

一份标准的 Part 10 文件由三部分组成:128 字节全零前导、4 字节DICM魔数、然后是元信息组和数据集。解析器的骨架就是顺着这三段往下读。

public DicomDataSet Read(string path) { using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var br = new BinaryReader(fs)) { br.ReadBytes(128); // 前导,规范要求全 0 var magic = Encoding.ASCII.GetString(br.ReadBytes(4)); if (magic == "DICM") { // Part 10 文件:元信息组固定显式 VR、小端 var meta = ReadMetaInfo(br); return ReadDataSet(br, meta.TransferSyntaxUid); } // 兜底分支:部分老设备直接发裸数据集,既没有前导也没有 DICM fs.Seek(0, SeekOrigin.Begin); return ReadDataSet(br, TransferSyntax.ImplicitVRLittleEndian); } }

TransferSyntaxUid决定了三件事:显式还是隐式 VR、小端还是大端、像素数据是否压缩。源码里通过IDicomCodec接口注册解码器,默认只挂了未压缩的两种传输语法,遇到 JPEG 有损就会走进“不支持的传输语法”分支。真要支持压缩,在启动时往注册表里加一个解码器实现即可,不用改解析主流程。

数据集本身是个变长循环,长度字段是这里唯一的陷阱:

private DicomDataSet ReadDataSet(BinaryReader br, string tsUid) { bool explicitVr = tsUid != TransferSyntax.ImplicitVRLittleEndian; bool littleEndian = !tsUid.StartsWith("1.2.840.10008.1.2.2"); var ds = new DicomDataSet(); long total = br.BaseStream.Length; while (br.BaseStream.Position + 8 <= total) { ushort group = ReadUInt16(br, littleEndian); ushort element = ReadUInt16(br, littleEndian); var tag = new DicomTag(group, element); // 隐式 VR 没有 VR 字段,只能查字典反推 string vr = explicitVr ? Encoding.ASCII.GetString(br.ReadBytes(2)) : VrTable.Lookup(tag); int length = ReadLength(br, vr, littleEndian); // OB/OW/SQ/UN 走 4 字节长度 if (length == 0xFFFFFFFF) // 未定长:序列或封装像素 { ds.Add(tag, ReadUndefinedLength(br, vr, littleEndian)); continue; } ds.Add(tag, br.ReadBytes(length)); if (tag == DicomTag.PixelData) break; // 像素之后不再往下解析 } return ds; }

0xFFFFFFFF这个未定长标记只出现在序列(SQ)和封装像素(OB)里,读到它就要一直往下扫,直到遇到FFFE,E0DD这个序列分隔符才停。VrTable.Lookup是隐式 VR 下的标签字典,字典不全就会把 OB 当成 SH 读,长度字段瞬间读飞,后面的字节全部错位——这是自研解析器最常见的崩法。

3.2 窗宽窗位:16 位像素怎么落到屏幕的 8 位灰度

CT 的像素值不是 HU,必须先做一次重缩放:HU = 原始像素 × RescaleSlope + RescaleIntercept。这一步漏掉,你看到的窗位数值就是错的,调起来手感完全不对。

public static byte[] Render(ushort[] pixels, double slope, double intercept, double center, double width, bool monochrome1) { var output = new byte[pixels.Length]; // 遵守 PS3.3 C.11.2.1.2 的标准公式,0.5 和 width-1 都不能省 double lower = center - 0.5 - (width - 1) / 2.0; double upper = center - 0.5 + (width - 1) / 2.0; if (upper <= lower) upper = lower + 1; // 防除零,窗宽为 1 时的边界 for (int i = 0; i < pixels.Length; i++) { double hu = pixels[i] * slope + intercept; byte g; if (hu <= lower) g = 0; else if (hu > upper) g = 255; else g = (byte)Math.Round((hu - lower) * 255.0 / (upper - lower)); output[i] = monochrome1 ? (byte)(255 - g) : g; // MONOCHROME1 是负片,必须反相 } return output; }

center和width就是鼠标拖动要改的两个值,拖拽时只重算这一个函数,不要重读文件。monochrome1来自(0028,0004),很多 DR 设备用的是 MONOCHROME1,不反相的话骨头是黑的、肺是白的,看着像坏了。

灰度数组到Bitmap这一步有个隐蔽的坑:

var bmp = new Bitmap(width, height, PixelFormat.Format8bppIndexed); var palette = bmp.Palette; for (int i = 0; i < 256; i++) palette.Entries[i] = Color.FromArgb(i, i, i); bmp.Palette = palette; // 8bpp 索引格式必须重设调色板,否则出来是张彩图 var rect = new Rectangle(0, 0, width, height); var data = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); for (int y = 0; y < height; y++) { // 按 Stride 定位每行起点,不是按 width Marshal.Copy(gray, y * width, data.Scan0 + y * data.Stride, width); } bmp.UnlockBits(data);

Stride是四字节对齐后的行宽,512 宽刚好整除 4 看不出来,一旦遇到宽度不是 4 的倍数的设备,按width拷就会出一张斜的图。这个坑我在别的项目里翻过一次,排查了两个小时。

3.3 测量标注:屏幕坐标怎么换算回物理毫米

测量是 PACS 里最容易“看起来对、实际错”的功能。屏幕上量的是像素距离,报给医生的是毫米距离,中间隔着PixelSpacing和当前缩放比两座桥。

public double LengthMm(Point p1, Point p2, DicomDataSet ds, double zoom) { // PixelSpacing 的顺序是 [行间距, 列间距],单位 mm,写反了值会差一位 double[] spacing = ds.GetDoubleArray(DicomTag.PixelSpacing) ?? new[] { 1.0, 1.0 }; double rowSpacing = spacing[0]; // 对应 Y 方向 double colSpacing = spacing[1]; // 对应 X 方向 double dx = (p2.X - p1.X) / zoom * colSpacing; double dy = (p2.Y - p1.Y) / zoom * rowSpacing; return Math.Sqrt(dx * dx + dy * dy); }

zoom是当前显示缩放比,控件放大 2 倍,鼠标移动 2 个屏幕像素才等于 1 个原始像素,不除就会量出双倍长度。如果控件支持旋转或水平翻转,还得先把p1、p2变换回原始像素坐标系再算,否则旋转 90° 后长度不变但角度全乱。

没有PixelSpacing的检查(比如部分内镜、超声截图)要把测量结果标记成“像素”而不是“毫米”,界面上给个单位切换,别硬套 1.0 的默认值假装是毫米——医生拿这个做术前规划,差一倍是要出事的。

3.4 多序列缩略图与布局切换

一条检查动辄三五个序列,进检查先出缩略图墙,点哪条加载哪条。这里的关键是别把所有序列一次性解到内存。

private async void BuildThumbnails(IEnumerable<string> files, CancellationToken token) { var options = new ParallelOptions { MaxDegreeOfParallelism = 2, CancellationToken = token }; await Task.Run(() => { Parallel.ForEach(files, options, file => { // 只解像素、缩到 128 见方,不保留原始 Bitmap var thumb = _renderer.RenderThumbnail(file, 128, 128); _cache.TryAdd(Path.GetFileNameWithoutExtension(file), thumb); BeginInvoke((Action)(() => _panel.Add(thumb))); // 必须回 UI 线程加控件 }); }, token); }

MaxDegreeOfParallelism设 2 是血泪经验。设成-1让线程池自己决定,遇到大矩阵的 CR 序列会把几 GB 的解码缓冲同时摊在内存里,32 位进程直接OutOfMemoryException。BeginInvoke不能省,Panel.Controls.Add在非 UI 线程调用会抛跨线程异常,而且抛在Parallel里会被吞掉。

布局切换用的是一个枚举加TableLayoutPanel重排:1×1、2×2、1×3、2×3 四档,每档对应一组行列数。切换时不要销毁ViewerControl,只调整它的Bounds和可见性,否则每切一次都要重新加载影像,体验很差。

4. 服务端与归档:C-STORE 接收、Worklist 查询与表结构

4.1 C-STORE SCP:从 TCP 连接一直到文件落盘

服务端的主循环简单到只有几十行,但每一层都有讲究。

private void AcceptLoop() { var listener = new TcpListener(IPAddress.Any, _config.Port); listener.Start(16); // backlog 给 16,够科室规模用 while (!_stop) { TcpClient client = listener.AcceptTcpClient(); client.NoDelay = true; // 关 Nagle,否则大文件末尾会莫名卡几百毫秒 ThreadPool.QueueUserWorkItem(_ => Handle(client)); } } private void Handle(TcpClient client) { using (client) using (var stream = client.GetStream()) { while (true) { var pdu = PduReader.Read(stream); // 先读 6 字节头:类型 + 保留 + 4 字节大端长度 switch (pdu.Type) { case 0x01: OnAssociate(stream, pdu); break; // A-ASSOCIATE-RQ 协商 case 0x04: OnData(stream, pdu); break; // P-DATA-TF,真正的影像 case 0x05: OnRelease(stream); return; // A-RELEASE-RQ 正常断开 default: return; // 未知 PDU,直接断 } } } }

PDU 头里的长度字段是大端,C# 的BinaryReader默认小端,必须自己翻字节。这一步写错了,读出来的长度会是个天文数字,然后就是“申请超大数组”的异常。

真正的难点在 P-DATA 里。一条 C-STORE 的数据集经常被拆成多个 PDV 片段,必须拼完整再解析:

private void OnData(NetworkStream stream, Pdu pdu) { int offset = 0; while (offset < pdu.Body.Length) { int pdvLength = ReadInt32BE(pdu.Body, offset); offset += 4; byte contextId = pdu.Body[offset]; offset += 1; byte control = pdu.Body[offset]; offset += 1; // bit0=1 表示命令集 var payload = new byte[pdvLength - 2]; Buffer.BlockCopy(pdu.Body, offset, payload, 0, payload.Length); offset += payload.Length; if ((control & 0x01) != 0) _commandBuffer.Append(payload); // 命令集:C-STORE-RQ else _dataBuffer.Append(payload); // 数据集:影像本体 } // 只有收到完整的命令集 + 数据集,才组装并回 C <p> <a href="https://download.csdn.net/download/weixin_43355508/88530362" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进