资讯详情

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

📅 2026/10/23 19:09:43 | 华诺云谱 👁 阅读
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 keyAeTitle valuePACS_SCP/ !-- 本机 AE 标题对端要配成一致 -- add keyDicomPort value11112/ !-- 建议避开 104 -- add keyStorageRoot valueD:\PacsData/ !-- 影像根目录必须可写 -- add keyThumbnailCache value300/ !-- 缩略图内存缓存条数 -- add keyLogPath valueD:\PacsData\logs/ !-- 日志目录 -- /appSettings connectionStrings add namePacsDb connectionStringData Source127.0.0.1;Initial CatalogPacsDb;User Idpacs;Password***; providerNameSystem.Data.SqlClient/ /connectionStrings /configurationAeTitle是最容易配错的一项。它是 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(IEnumerablestring 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; // bit01 表示命令集 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 hrefhttps://download.csdn.net/download/weixin_43355508/88530362 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑