ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C# PACS源码拆解:DICOM收片归档与WinForms阅片端

C# PACS源码拆解:DICOM收片归档与WinForms阅片端

简介:全套PACS源码面向希望进入医疗信息化与医学影像系统开发领域的C#/.NET开发者,提供一套可参考的影像存档与通信系统实现。内容围绕图像采集设备接入、DICOM服务器收发与格式转换、SQL数据库中的患者信息及影像元数据管理,以及工作站端的影像查看、分析和注释等核心模块展开,并配有部署与使用说明,便于理解系统架构、数据库配置和DICOM服务启动流程。压缩包为rar格式,体积约399.87MB;文件总数与类型明细上游暂未提供,主体可结合描述理解为C#源码及.NET工程相关内容。目前已有561人学习下载,适合参考完整PACS项目结构、学习C#与.NET控件在医疗影像场景中的应用,或用于课程设计与类似系统二次开发。

1. 一套三十来人次日检查量的影像科室,商业 PACS 的报价能顶掉全年维保预算,于是有人把这套 C# 写的全套源码搬了出来:服务端收片归档、客户端调阅、DICOM 协议层、建库脚本一应俱全,界面用 .NET 控件直接拖出来,改个按钮位置不用重学一套前端框架。它适合谁?适合有 C# 底子、肯花两晚啃 DICOM 标准、手上管着几台 CR/DR 又想把数据流向攥在自己手里的开发者或科室信息员。不适合指望开箱即用、要求厂商级响应速度的场景——这套代码给你的是全部零件,装成什么样得自己拧螺丝。

2. 功能边界与技术选型:四条主干链路和一次技术下注

先把 PACS 这个词拆开。市面上讲 PACS 的文章喜欢从“影像信息化”讲起,落到代码上其实就四条主干:设备把影像推过来、服务器把影像按层级挂上并落盘、临床按条件把片子找出来、工作站把像素渲染成能看的灰度图。少一条,这套系统在科室里就跑不起来。

2.1 四条主干功能,缺一条就转不动

第一条是接收。CT、CR、DR 这些设备遵循 DICOM 标准里的 Storage Service Class,把一张图打包成一个数据集,通过 C-STORE 请求推给服务端。服务端要扮演 SCP 角色,先协商 Association,再逐张接收,接收完回一个状态码。这条链路最容易出问题的地方不是收,而是协商——对端设备想用 JPEG2000 压缩发,你这边只声明支持未压缩,协商就崩了,设备端只会弹一个含糊的失败提示。

第二条是归档。收到的不只是一张图,而是一个带层级关系的对象:患者(Patient)下有检查(Study),检查下有序列(Series),序列下才是图像(Image Instance)。这三层各有一个全局唯一 UID,DICOM 世界靠 UID 认亲,不靠姓名和日期。归档模块的活儿就是把这四层关系理清楚,元数据进数据库,像素数据落到磁盘。

第三条是检索。临床调阅时输入的是姓名、检查号或者日期范围,背后要么走 DICOM 的 C-FIND 查询,要么直接查本地 SQL。项目这两条路都留了:对外给设备和工作站提供 C-FIND,对内给客户端界面提供参数化 SQL。

第四条是显示。DICOM 存的不是“像素值”,是“带物理含义的采样值”——CT 的像素值要乘 Rescale Slope 加 Rescale Intercept 才还原成 HU 值,再用窗宽窗位映射到 0–255 的显示灰度。这一步做错,图像要么全黑要么全白,是最常见的“看起来跑通了其实没跑通”。

功能对应 DICOM 服务代码落点
影像接收C-STORE SCP服务端 StoreScp 工程
检查信息下发Modality Worklist(C-FIND)服务端 WorklistScp 工程
归档入库无(本地逻辑)ArchiveWorker 后台队列
检索查询C-FIND / 本地 SQLQueryScp + 客户端查询窗
调阅显示无(本地渲染)客户端 Viewer 控件
连通性测试C-ECHO服务端 VerificationScp

2.2 为什么是 C# 加 .NET 控件,而不是 Web 那套

阅片端的核心诉求是“秒开”。一个序列动辄三四百张 512×512 的图,一次性读进内存也就百来兆,用 GDI+ 双缓冲自绘能跑到流畅翻页;换成浏览器里逐张请求,网络往返加解码的开销会让翻页有明显顿挫。这不是技术优劣问题,是场景匹配问题。科室内部的局域网带宽稳定,客户端又必须是胖终端,WinForms 那套拖控件加自绘的路子反而是最省事的。

.NET 在这套系统里承担的角色比想象中重。DIMSE 协议是建立在 TCP 上的二进制协议,每个 PDU 有固定头部,用NetworkStream加BinaryReader读写并不别扭;像素处理用unsafe指针遍历ushort[],配合fixed固定数组,性能足够。数据库访问层常见做法是裸 SQL 加一层轻量 DAL,不引重型 ORM——PACS 的查询条件太灵活,检查号前缀、日期区间、设备类型、是否已归档,拼起来就是十几套组合,手写 SQL 反而更好控。

选型上真正需要下注的是这几件事:

  • 目标框架定在 .NET Framework 4.x,工程文件是老式 csproj,客户端界面依赖 WinForms 设计器。想迁到新版 .NET,客户端部分基本要重画。
  • 数据库优先 SQL Server,脚本里用到的分页和字符串拼接写法偏向 T-SQL;换 MySQL 要改分页语法和部分类型。
  • DICOM 协议层是项目自带的,标签字典、传输语法、SOP Class 注册表都在源码里,这意味着遇到设备发来的奇怪私有标签,你可以直接改字典,不用等第三方库发版。

2.3 双轨存储:元数据进库,像素进盘

PACS 的老规矩:像素数据不进数据库。一张 CT 图原始像素 512×512×2 字节就是 512KB,一个检查几百张,塞进 BLOB 字段会让备份和迁移变成灾难。项目的做法是磁盘按 StudyInstanceUID 建目录,序列号和 SOPInstanceUID 做文件名,数据库只存相对路径。这样备份策略可以分开:数据库每天全量,影像目录按周做增量同步。

代价是数据库和磁盘可能不一致。删了记录没删文件是垃圾堆积,删了文件没删记录是死链。所以归档流程必须是“先写盘、再写库”,写库失败要进补偿表,不能反过来。这个顺序在源码里是被刻意固定下来的,后面拆归档队列时能看到。

3. 解决方案目录拆解:协议层、服务端、客户端怎么分家

拿到一个几十个工程的大解决方案,第一件事不是编译,是看依赖方向。这套源码的分层算是干净:协议层不引用数据库,服务端引用协议层,客户端引用协议层但不引用服务端。搞清这条线,改哪块心里就有数了。

3.1 目录结构与工程职责

解决方案大致长这样(工程名按功能意译,实际以源码为准):

Pacs.sln ├─ Pacs.Dicom/ DICOM 协议层:标签字典、数据集、编解码、DIMSE │ ├─ Data/ DicomTag、DicomElement、DicomDataSet │ ├─ IO/ DicomStreamReader / Writer、传输语法 │ ├─ Network/ Association、PDU、DIMSE 消息构造 │ └─ Media/ 文件格式解析(Part 10) ├─ Pacs.Server/ 服务端宿主 │ ├─ Scp/ StoreScp、QueryScp、WorklistScp、VerificationScp │ ├─ Archive/ ArchiveWorker、StorageService、补偿表 │ └─ ServiceHost/ Windows 服务壳 + 控制台调试入口 ├─ Pacs.Data/ 实体、DAL、建库脚本 │ ├─ Entities/ Patient / Study / Series / ImageInstance │ └─ Scripts/ CreateDatabase.sql、升级脚本 ├─ Pacs.Client/ 阅片端 WinForms │ ├─ Forms/ 查询窗、主界面、设置 │ ├─ Controls/ ViewerControl、缩略图列表、测量工具 │ └─ Rendering/ 窗宽窗位、MONOCHROME1 反转、缩放插值 ├─ Pacs.Tools/ 辅助工具(模拟发送端、数据库巡检) └─ Pacs.Tests/ 协议层单元测试

依赖方向很明确:Pacs.Client和Pacs.Server都只往下依赖Pacs.Dicom与Pacs.Data,两个上层之间互不引用。好处是编译顺序固定,先Pacs.Dicom,再Pacs.Data,最后两个宿主;坏处是查询逻辑有两份——C-FIND 的匹配逻辑在服务端,界面的条件拼装在客户端,改查询语义时两边都要动。二次开发时这里最容易漏。

3.2 DICOM 协议层:显式 VR 与隐式 VR 的分叉点

整个协议层的心脏是数据集读取。DICOM 元素的格式是「组号 + 元素号 + 值长度 + 值」,但值长度前面有没有 VR(值表示)这两个字节,取决于传输语法。显式 VR 语法里 VR 写在流里,隐式 VR 语法里没有,只能靠标签字典反推。这段代码是理解整个协议层的钥匙:

// DicomStreamReader.ReadElement —— 显式/隐式 VR 的分叉就在开头这几行 public DicomElement ReadElement() { ushort group = ReadUInt16(); // 组号,一律小端 ushort element = ReadUInt16(); // 元素号 var tag = new DicomTag(group, element); if (tag == DicomTag.PixelData) // (7FE0,0010) 单独走,可能有封装 return ReadPixelData(); DicomVr vr; uint length; if (_transferSyntax.IsExplicitVr) { // 显式 VR:流里直接跟着两个字节的 VR 编码,如 "OB"、"US" vr = DicomVrTable.Lookup(ReadBytes(2)); if (vr.IsLongLength) { // OB/OW/OF/SQ/UT/UN 六个 VR 走长长度:2 字节保留 + 4 字节长度 Skip(2); length = ReadUInt32(); } else { length = ReadUInt16(); // 其余 VR 用 2 字节长度 } } else { // 隐式 VR:流里没有 VR,只能拿标签去字典里查 vr = DicomVrTable.LookupFromDictionary(tag); length = ReadUInt32(); } if (length == 0xFFFFFFFF) // 未定长:序列和封装像素的常见写法 return ReadUndefinedLength(tag, vr); return new DicomElement(tag, vr, ReadBytes((int)length)); }

几个参数值得单独说。_transferSyntax是整个读取器的状态,它在 Association 协商阶段就被确定下来,一次连接内不会变。0xFFFFFFFF这个魔数叫未定长,出现在 SQ 序列和封装像素数据里,读到它就得一直往下读,直到遇到分隔项标记FFFE,E0DD。DicomVrTable就是那张标签字典,常见做法是从标准文档整理成静态表,两三千个标签,加载一次常驻内存。

这套实现的好处是完全可控。设备发来的私有标签不认识?往字典里加一条就行。某台老 CR 的隐式 VR 实现不标准,长度字段写了奇数?在ReadBytes里补一个字节对齐即可。第三方库遇到这种情况只能提 issue 等发版,自己维护的协议层当场就能改。

3.3 服务端:SCP 宿主与后台归档队列

服务端不是“收到就入库”。DICOM 的 C-STORE 是同步语义,设备发完一张图等你回状态码,你如果在回调里直接写数据库、写磁盘,IO 一慢,设备端就超时重发,最后收到一堆重复图。项目的做法是把接收和归档解耦,接收线程只管把数据集塞进队列,立刻回成功:

// ArchiveWorker.cs —— 落盘与入库串行化,失败进补偿表 public sealed class ArchiveWorker : WorkerBase { // 有界队列,防止设备突发流量把内存打爆;满了就让接收端阻塞,形成背压 private readonly BlockingCollection<StoreTask> _queue = new(2048); public bool TryEnqueue(StoreTask task) => _queue.TryAdd(task, 5000); protected override void Run(CancellationToken token) { foreach (var task in _queue.GetConsumingEnumerable(token)) { string relativePath = null; try { // 1. 先落盘:路径 = 检查UID/序列号/实例UID.dcm relativePath = _storage.Save(task.DataSet); // 2. 再写库:按 SOPInstanceUID 做幂等 Upsert,重复图不新增行 _repository.Upsert(task.DataSet, relativePath); task.Complete(0x0000); // Success } catch (Exception ex) { _log.Error($"归档失败 {task.SopInstanceUid}", ex); // 3. 写库失败:磁盘文件保留,记录进补偿表,由巡检任务重试 if (relativePath != null) _pendingRepo.Add(task.DataSet, relativePath); task.Complete(0xA700); // Processing Failure } } } }

BlockingCollection的容量 2048 是个经验值,按单张图平均 512KB 算,满载大约 1GB 内存占用,普通服务器扛得住。TryAdd的 5 秒超时是给设备端留的缓冲——队列满了就阻塞接收线程,让 TCP 窗口自动收缩,形成背压,比直接丢弃礼貌得多。状态码0xA700是 DICOM 标准里的 Processing Failure,设备端看到这个会记日志,运维排查时有据可查。

补偿表这个设计值得学。写盘成功、写库失败的情况在磁盘满或者数据库连接池耗尽时真的会发生,如果没有补偿表,这批图就成了“磁盘上有、库里查不到”的孤儿文件,只能靠人工翻目录找。有了补偿表,一个定时任务每小时扫一遍重试,绝大部分能自愈。

4. 核心链路的完整拆解:一条 C-STORE 请求怎么变成可检索的检查

单看模块都懂,连起来就容易断片。挑一条最典型的链路走一遍:CT 技师按下曝光,图像从设备网口出来,到临床医生在客户端上拖动窗宽,中间发生了什么。

4.1 建链与协商:Association 阶段就把坑埋好了

DICOM 通信的第一步不是发数据,是建 Association。发起方(设备)送一份协商请求,里面列着「我想调用的 SOP Class」和「我能接受的传输语法」;接受方(服务端)逐条比对,能接受的回 Accept,不能的回 Reject。这一步谈崩了,后面的 C-STORE 一张也发不过来。

// StoreScp.OnAssociationRequest —— 协商阶段的三个判断 private AssociationResult Negotiate(AssociationRequest request) { // AE Title 校验:设备端配置的远端 AE 必须和服务端注册的一致 if (!_aeRegistry.Contains(request.CallingAE)) return AssociationResult.Reject(0x07); // Called AE 不识别 foreach (var ctx in request.PresentationContexts) { // 只接受存储类 SOP,别的一律拒;避免设备顺手推个打印请求过来 if (!SopClassRegistry.IsStorage(ctx.AbstractSyntax)) { ctx.Result = PresentationResult.RejectAbstractSyntax; continue; } // 传输语法取交集:设备给了一串,我们只用自己解码得了的那几个 var picked = ctx.TransferSyntaxes .FirstOrDefault(t => SupportedSyntaxes.Contains(t)); if (picked == null) { ctx.Result = PresentationResult.RejectTransferSyntax; continue; } ctx.AcceptedTransferSyntax = picked; ctx.Result = PresentationResult.Accept; } return AssociationResult.Accept(); }

CallingAE是设备自报的名字,CalledAE是它想连的服务端名字,两个都要校验——只校验一个的话,配置写错的设备会连上来然后所有请求失败,排查起来像玄学。传输语法取交集这一步是重灾区,SupportedSyntaxes里到底放哪几个,取决于你的解码能力:不打算支持 JPEG2000 就别声明,声明了又解不了,等于给自己挖坑。

4.2 落盘与入库:三级索引怎么建

图像进来之后,最考验设计的是索引结构。四张主表,靠 UID 串起来:

-- Scripts/CreateDatabase.sql 节选,只留关键字段和约束 CREATE TABLE Study ( StudyUid VARCHAR(64) NOT NULL PRIMARY KEY, -- (0020,000D) PatientId VARCHAR(64) NOT NULL, -- (0010,0020) PatientName NVARCHAR(128) NULL, -- (0010,0010) AccessionNumber VARCHAR(32) NULL, -- (0008,0050) 检查号 StudyDate DATE NULL, -- (0008,0020) Modality VARCHAR(16) NULL, -- (0008,0060) StudyDesc NVARCHAR(256) NULL, StoragePath VARCHAR(256) NOT NULL, -- 相对存储根目录 InstanceCount INT NOT NULL DEFAULT 0, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE ImageInstance ( SopInstanceUid VARCHAR(64) NOT NULL PRIMARY KEY, -- (0008,0018) SeriesUid VARCHAR(64) NOT NULL, -- (0020,000E) StudyUid VARCHAR(64) NOT NULL, InstanceNumber INT NULL, -- (0020,0013) 序列内序号 FilePath VARCHAR(256) NOT NULL, FileSize BIGINT NOT NULL, FileHash CHAR(32) NULL, -- MD5,用于去重和完整性巡检 CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE INDEX IX_Study_Patient ON Study(PatientId, StudyDate DESC); CREATE INDEX IX_Study_Date ON Study(StudyDate DESC); CREATE INDEX IX_Image_Series ON ImageInstance(SeriesUid, InstanceNumber);

三个 UID 字段都给了 64 长度,因为 DICOM 的 UID 规范上限就是 64 个字符,用VARCHAR(64)而不是VARCHAR(MAX)是为了能建主键。FileHash那一列是我建议一定要留的——设备重发、网络抖动重传、技师误操作重拍,都会导致同一张图进来两次,靠 SOPInstanceUID 主键能挡住数据库层的重复,但磁盘上可能已经写了一份,用哈希做二次校验能省下不少空间。

InstanceCount这个冗余字段也是必要的。阅片端列检查列表时要显示“共 320 张”,如果不冗余就得每次COUNT(*)关联查询,几百个检查列出来会明显卡顿。代价是每次入库要更新计数,用一条UPDATE Study SET InstanceCount = InstanceCount + 1带过去就行,比查询优化划算。

4.3 阅片端的窗宽窗位:最容易糊弄也最容易翻车的一段

图像能显示不等于显示对了。DICOM 里存的是原始采样值,CT 的 12 位数据范围 0–4095,直接映射到 8 位灰度会灰蒙蒙一片。正确的链路是:原始值 → 乘 Slope 加 Intercept 还原成 HU → 按窗宽窗位映射到 0–255。

// Rendering/WindowLevel.cs —— 一次遍历完成还原与映射 public static unsafe byte[] Apply(ushort[] pixels, double slope, double intercept, double center, double width, bool monochrome1) { var result = new byte[pixels.Length]; // 窗的下边界和上边界,减 0.5 是为了对齐 DICOM 标准的取整规则 double low = center - 0.5 - (width - 1) / 2.0; double high = center - 0.5 + (width - 1) / 2.0; fixed (ushort* src = pixels) fixed (byte* dst = result) { for (int i = 0; i < pixels.Length; i++) { // 1. 还原物理值:CT 里就是 HU,MR 里可能是任意单位 double hu = src[i] * slope + intercept; // 2. 线性映射到 0-255,超界截断 double gray; if (hu <= low) gray = 0; else if (hu >= high) gray = 255; else gray = (hu - low) * 255.0 / (high - low); // 3. MONOCHROME1 是"值越大越黑",CR 胸片常见,必须反转 dst[i] = (byte)(monochrome1 ? 255 - gray : gray); } } return result; }

slope和intercept来自标签 (0028,1053) 和 (0028,1052),缺省是 1 和 0,很多实现直接忽略这两个值,遇到带截距的 CT 就显示异常。center和width来自 (0028,1050) 和 (0028,1051),但如果序列里没有这两个标签,就得按 Modality 给默认值——CT 常用 40/400,DR 胸片常用 2048/4096。monochrome1来自 (0028,0004) 的 PhotometricInterpretation,CR 设备特别爱用这个,忘了反转就是黑白颠倒。unsafe加fixed是为了避免每次索引都做边界检查,一张 512×512 的图能省下几毫秒,翻页时手感差别很明显。

5. 跑通与避坑:本地起一套能收片的 PACS

代码读完,接下来是让它在自己机器上跑起来。这套东西没有安装包,全是源码加脚本,按顺序来大概半天能收到第一张图。

5.1 环境准备与编译顺序

需要的东西不多:Visual Studio(带 .NET Framework 4.x 开发组件)、SQL Server(Express 版够用)、一个能发 DICOM 的模拟端。编译顺序必须按依赖来,直接 F5 整个解决方案大概率会因为并行编译导致引用没生成而报一串红。

# 1. 建库:先跑建库脚本,再跑基础数据脚本(AE Title 注册表) sqlcmd -S localhost\SQLEXPRESS -i Pacs.Data/Scripts/CreateDatabase.sql sqlcmd -S localhost\SQLEXPRESS -d PacsDB -i Pacs.Data/Scripts/InitAeRegistry.sql # 2. 按依赖顺序单独编译,别整个解决方案一把梭 msbuild Pacs.Dicom/Pacs.Dicom.csproj /p:Configuration=Debug msbuild Pacs.Data/Pacs.Data.csproj /p:Configuration=Debug msbuild Pacs.Server/Pacs.Server.csproj /p:Configuration=Debug msbuild Pacs.Client/Pacs.Client.csproj /p:Configuration=Debug # 3. 服务端先用控制台模式起,能看日志 Pacs.Server/bin/Debug/Pacs.Server.exe --console

控制台模式是给调试用的,正式部署时同一个 exe 带--install参数注册成 Windows 服务。这个双入口的设计很实用,服务挂掉时先切回控制台看它到底卡在哪一步,比翻事件查看器快得多。

5.2 配置项:AE Title、端口、存储根路径

服务端配置在Pacs.Server.exe.config里,三组参数决定它能不能被设备找到:

<configuration> <appSettings> <!-- 服务端自己的 AE Title,设备端要填这个 --> <add key="Scp.AeTitle" value="PACS_STORE"/> <!-- DICOM 标准端口 104,非管理员权限起不来,调试期建议先改 11112 --> <add key="Scp.Port" value="11112"/> <!-- 允许多少个并发 Association,科室规模 16 足够 --> <add key="Scp.MaxAssociations" value="16"/> <!-- 存储根目录,建议独立磁盘,别和系统盘放一起 --> <add key="Storage.RootPath" value="D:\PacsData"/> <!-- 单个检查超过这个大小就告警,防止设备异常导致磁盘被写满 --> <add key="Storage.StudyQuotaMB" value="2048"/> </appSettings> </configuration>

MaxAssociations调大有副作用:每个 Association 占一个线程加一份缓冲区,设成 200 不会更快,只会在设备同时推图时把内存吃光。StudyQuotaMB这个配额限制是保命的,见过某台设备配置错误,把同一组图循环推了三个小时,磁盘直接写满,数据库也跟着挂了。

客户端那边配置的是服务端地址和本地缓存目录,缓存目录建议放在固态盘上,缩略图生成速度差别很大。

5.3 用模拟端发一张片子验证

最省事的验证方式是用项目自带的Pacs.Tools里那个模拟发送端,它能把一个目录下的 dcm 文件按 C-STORE 推给服务端。手上有真实片子的,用设备导出的 dcm 文件更真实。

# 模拟端需要四个参数:目标地址、目标 AE、自己的 AE、待发送目录 Pacs.Tools/bin/Debug/Pacs.Tools.exe send \ --host 127.0.0.1 --port 11112 \ --called-ae PACS_STORE --calling-ae TEST_SCU \ --source D:\sample-dcm

发送成功的标志是控制台打出每张图的状态码0x0000,并且D:\PacsData下出现了以检查 UID 命名的目录。这时候打开客户端,输入患者姓名查一下,能列出来就说明接收、归档、查询三段链路都通了。如果查询有结果但双击打不开图,问题在显示层,回去查窗宽窗位和文件路径拼接。

5.4 五个高频翻车点

现象一:C-ECHO 通,C-STORE 报错,设备端提示传输语法不支持。

原因:协商阶段取交集失败。设备声明的是 JPEG2000(1.2.840.10008.1.2.4.90/91),服务端SupportedSyntaxes里只有未压缩的两种。

解决:要么在服务端补上对应传输语法的解码器,要么去设备端把发送格式改成未压缩。短期内更现实的是后者——老设备的 JPEG2000 编码器经常有私有变体,自己实现解码成本不低。改完别忘重启服务端,传输语法列表是在进程启动时加载的。

现象二:图像能打开,但一片黑或者一片白,调窗宽窗位也没反应。

原因:三种可能。一是忽略了 Rescale Slope/Intercept,把原始采样值当 HU 用了;二是标签 (0028,1050)/(0028,1051) 缺失时没给默认窗值,用了 0/0 导致映射区间退化;三是 MONOCHROME1 没反转。

解决:先在协议层把数据集 dump 出来,看清 PhotometricInterpretation、Rescale 系列和窗值标签到底有没有。写死一个兜底逻辑:窗值为空时按 Modality 给默认,Photometric 为 MONOCHROME1 时强制反转。这段兜底代码看着丑,但能救活一大批不规范的 CR 数据。

现象三:中文患者姓名显示成问号或者乱码。

原因:DICOM 默认字符集是 ASCII,中文必须靠 (0008,0005) Specific Character Set 声明。设备发的是 GB18030 但没声明,或者声明了 ISO_IR 192 而你按 GBK 解。

解决:读取时先看 (0008,0005),映射到对应的Encoding实例;如果标签缺失,按设备 AE Title 配一张字符集映射表兜底。数据库那边的字段统一用NVARCHAR,别用VARCHAR,从源头避免二次转码。

现象四:磁盘一周涨了几十 G,明显超过实际数据量。

原因:重复接收。设备发送超时后重发,或者技师重拍覆盖了同一个 SOPInstanceUID,但代码按「检查 UID + 时间戳」生成路径,每次都写新文件。

解决:路径生成必须基于 SOPInstanceUID 且可重复计算,写盘前先查哈希或文件是否已存在。数据库层的主键约束能挡住重复记录,但挡不住磁盘重复文件,两层都要防。

现象五:服务端在开发机上跑得好好的,部署到科室服务器上 104 端口起不来。

原因:1024 以下端口在 Windows 上需要管理员权限,另外防火墙默认拦。还有一种是端口被别的进程占了,DICOM 端口冲突不报错,只是默默连到别人身上。

解决:调试期统一用 11112,正式部署时用netsh http之外的方式加防火墙规则并把服务账号提权。上线前先netstat -ano | findstr 104确认端口可用,这一步花十秒钟,能省掉半天扯皮。

6. 二次开发:加一个报告模块要动哪几个工程

源码给的是完整零件,真正落地时几乎没人原样用,总要往上加东西。最典型的改造是加报告模块——影像存下来了,医生总得有个地方写结论。

先看扩展点在哪。协议层不用动,报告不是 DICOM 影像;数据层要加表,Report表挂StudyUid外键,存草稿内容、状态、签署人和时间,状态机走「草稿 → 已提交 → 已审核」三态。服务端要加一个查询接口供客户端拉取报告,但不必走 DIMSE,走本地 SQL 或轻量 HTTP 都行。客户端是改动的重点,Forms下加一个报告编辑窗,Controls下加一个分栏控件把阅片区和报告区并排。

改造量大致是这样:

改造点涉及工程工作量风险
报告表结构 + 状态机Pacs.Data小与现有 Study 表的关联约束
报告查询/保存服务Pacs.Server小权限校验要想清楚
报告编辑窗Pacs.Client中WinForms 富文本控件的坑
图文对照(点报告定位影像)Pacs.Client中需要影像与解剖部位的映射
报告打印模板Pacs.Client大不同科室模板差异极大

真正花时间的不是写代码,是想清楚状态机。报告和影像的关系是一对多还是多对一?一份检查能不能有多份报告(比如初诊和复诊)?审核不通过退回草稿时,历史版本留不留?这些问题在数据库设计阶段定不下来,后面改起来就是伤筋动骨。

有个技巧值得说:报告模块最好像插件一样挂进去,别改客户端主窗体的构造逻辑。在MainForm里加一个扩展点,通过配置决定是否加载报告面板,不加载时主界面行为和原来完全一致。这样二次开发的代码和原始代码能保持相对独立,将来上游更新时合并冲突少很多。

验证方法别偷懒。改完报告模块,至少要跑三遍完整链路:第一遍只用模拟发送端推图,确认没破坏收片;第二遍推图后写报告再查报告,确认主外键没串;第三遍把数据库备份恢复一次,确认脚本能重建出同样的结构。第三遍最容易被跳过,也最容易在真上线时爆。

说个我自己的血泪经验:早些年改这类系统,总想着先把功能跑通再补日志,结果报告保存失败时客户端只弹一个「操作失败」,服务端什么也没记,两个人对着屏幕瞪了半小时。从那以后我每次动归档和报告这类写操作,都强制先加一条带 UID 和状态码的日志再写业务逻辑,宁可多打几行,也别让自己在黑匣子里猜。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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