简介:基于Fo-Dicom库开发MPPS和MWL服务可视化程序的C#示例工程,适合医学影像开发者、DICOM协议学习者以及需要对接HIS/RIS的技术人员。项目完整呈现了SCP服务监听、DICOM消息解析、Worklist查询以及MPPS状态上报的调用链路,WPF界面可直观展示设备执行步骤、患者检查流程和任务状态,便于理解医疗设备与信息系统之间的交互机制。压缩包共424个文件,大小2.93MB,文件以cs源码、xaml界面描述、csproj/sln工程配置、dll依赖库及pdb调试符号为主,同时包含buildwithskipanalyzers构建脚本和cache缓存文件,几乎覆盖从编译调试到运行验证的完整开发链路。已有227人学习浏览此资源,适合作为课堂项目、毕业设计或企业内训参考。借助该工程可快速掌握Fo-Dicom网络服务搭建要点,熟悉DICOM标准中MPPS与MWL服务的实际落地方式,并基于现有界面与通信逻辑进行二次开发。
1. 基于Fo-Dicom的MPPS和MWL可视化服务:一个能直接跑的C# DICOM工具
这个名为“基于Fo-Dicom实现的MPPS服务和MWL服务可视化程序.zip”的C#资源,我愿称之为影像科室里最省事的“中间人”工具。在没有RIS系统或RIS接口未开通的现场,CT、MR设备需要自动拉取检查申请单、回传开始和结束状态,MWL(Modality Worklist)和MPPS(Modality Performed Procedure Step)就得有一个服务端来应答。这个zip里就是一套用Fo-Dicom写好的Windows可视化程序,C#编译,开了端口就能跑。适合影像设备对接工程师、科室信息管理员,以及想搞懂DICOM服务端请求处理机制的开发者。你不需要先搭一套完整的PACS,只要把这程序部署在一台Windows主机上,设备就能把它当成Worklist和MPPS服务端来用。
2. MWL服务:Fo-Dicom实现C-FIND查询的流程和可视化参数表
MWL服务端的本质是处理来自设备的C-FIND请求。CT或MR按下“查询患者列表”时,设备作为SCU发送一个DICOM C-FIND请求到服务端,服务端从数据库或内存列表里筛选出符合条件的待执行检查,以C-FIND响应返回。Fo-Dicom里实现这个服务,并不是靠配置,而是靠继承DicomServer并实现IDicomCFindProvider接口。
2.1 从DICOM端口到C-FIND Provider:Fo-Dicom服务注册顺序
Fo-Dicom的DicomServer类负责监听TCP端口、处理DICOM消息关联。要让服务端响应C-FIND,必须把处理逻辑放进一个继承自DicomService的类里,同时实现IDicomCFindProvider接口。常见做法是写一个MwlService类,然后在程序启动时用DicomServer.Create把这个类绑定到端口上。
// Program.cs 启动入口 var mwlServer = DicomServer.Create<MwlService>( port: 11112, wildcardIP: "0.0.0.0", certificate: null);这段代码的要点:第一个参数port是DICOM经典端口11112,也可以改成104或你自己规划的端口;第二个参数wildcardIP传"0.0.0.0"表示监听所有网卡的DICOM连接,设备只要能和这台机器通信就能连上;第三个参数certificate传null,表示不启用TLS加密,内网调试阶段不需要走DICOM TLS,等上了生产环境再考虑。Fo-Dicom里DicomServer.Create的泛型参数必须是继承自DicomService的处理类,否则启动直接抛异常。
接下来MwlService要实现两个核心职责:让设备能测通连接(C-ECHO),以及处理Worklist查询(C-FIND)。C-ECHO在Fo-Dicom里默认由IDicomCEchoProvider接口提供,但我们要显式实现,因为很多设备的联调第一步就是发送C-ECHO探活,如果处理不对,设备直接报“无法连接服务器”。
/// <summary> /// 继承DicomService,同时处理Echo和Find请求 /// </summary> public class MwlService : DicomService, IDicomCEchoProvider, IDicomCFindProvider { public MwlService(Stream stream, Logger log, INetworkStream networkStream, Encoding fallbackEncoding) : base(stream, log, networkStream, fallbackEncoding) { } public async Task<DicomCEchoResponse> OnCEchoRequestAsync(DicomCEchoRequest request) { return new DicomCEchoResponse(request, DicomStatus.Success); } }注意构造函数的四个参数是Fo-Dicom内部传入的,你不需要手动实例化,但签名必须对齐,否则DicomServer.Create在反射创建类时会报“缺少构造函数”。OnCEchoRequestAsync返回DicomStatus.Success即可,这就是设备侧看到的“连接正常”。
2.2 组织Worklist数据集:C-FIND匹配规则和返回键
真正复杂的是OnCFindRequestAsync。DICOM C-FIND查询不是简单的SQL查表,它有一套匹配键规则:设备发来的DataSet里,每个标签的值就是过滤条件。最关键的一点是,患者ID为空表示“不限制”,但返回数据集里必须包含QueryRetrieveLevel、ScheduledProcedureStepSequence这些必要元素。很多初版实现只返回患者ID和姓名,设备照样报“查询失败”。
public async Task<DicomCFindResponse> OnCFindRequestAsync(DicomCFindRequest request) { var pid = request.Dataset.GetSingleValueOrDefault<string>(DicomTag.PatientID, null); var modality = request.Dataset.GetSingleValueOrDefault<string>(DicomTag.Modality, null); var startDate = request.Dataset.GetSingleValueOrDefault<string>( DicomTag.ScheduledProcedureStepStartDate, null); // 从内存仓库中筛选 var matches = _worklistRepository.Query(pid, modality, startDate); var response = new DicomCFindResponse(request, DicomStatus.Success); foreach (var item in matches) { var ds = BuildWorklistDataset(item, request.Dataset); response.AddDataset(ds); } return response; }这里有个隐藏细节:request.Dataset里的标签,既是过滤条件,也是“返回键列表”的声明。也就是说,设备请求时带了ScheduledProcedureStepStartDate,服务端就应该返回这个时间;如果设备请求里没带,服务端即使有数据也不应返回。BuildWorklistDataset的逻辑我一般会这样做:
private DicomDataset BuildWorklistDataset(WorklistItem item, DicomDataset queryDs) { var ds = new DicomDataset(); ds.AddOrUpdate(DicomTag.QueryRetrieveLevel, "PATIENT"); ds.AddOrUpdate(DicomTag.PatientID, item.PatientID); ds.AddOrUpdate(DicomTag.PatientName, item.PatientName); ds.AddOrUpdate(DicomTag.Modality, item.Modality); if (queryDs.Contains(DicomTag.ScheduledProcedureStepStartDate)) { ds.AddOrUpdate(DicomTag.ScheduledProcedureStepStartDate, item.StartDate); } if (queryDs.Contains(DicomTag.ScheduledProcedureStepStartTime)) { ds.AddOrUpdate(DicomTag.ScheduledProcedureStepStartTime, item.StartTime); } var seq = new DicomSequence(DicomTag.ScheduledProcedureStepSequence); var step = new DicomDataset(); step.AddOrUpdate(DicomTag.ScheduledProcedureStepSequence, seq); // 占位 step.AddOrUpdate(DicomTag.Modality, item.Modality); step.AddOrUpdate(DicomTag.ScheduledStationAETitle, item.StationAETitle); seq.Items.Add(step); ds.Add(seq); return ds; }这段代码里最容易错的是ScheduledProcedureStepSequence的结构。C-FIND响应里的这个Sequence是一个嵌套数据集,设备会从中读取检查项目、执行AE、检查部位等信息。有些设备非常较真,如果Sequence里缺少ScheduledProcedureStepID或RequestedProcedureID,它会认为响应不合法,直接忽略整条记录。所以我在实际项目里会把WorklistItem至少映射成这些必填字段:PatientID、PatientName、Modality、ScheduledStationAETitle、ScheduledProcedureStepStartDate、ScheduledProcedureStepStartTime、ScheduledProcedureStepID。你可以看作一张参数表:
| DICOM标签 | 关键字 | 必填/可选 | 典型值 |
|---|---|---|---|
| (0008,0060) | Modality | 必填 | CT / MR / DX |
| (0008,0050) | AccessionNumber | 可选 | RIS流水号 |
| (0010,0020) | PatientID | 必填 | P000123 |
| (0010,0010) | PatientName | 必填 | 张三 |
| (0040,0001) | ScheduledStationAETitle | 必填 | CT_ROOM_AE |
| (0040,0002) | ScheduledProcedureStepStartDate | 必填 | 20240401 |
| (0040,0009) | ScheduledProcedureStepID | 必填 | SPS-001 |
| (0040,0100) | ScheduledProcedureStepSequence | 必填 | 包含步骤细节 |
2.3 可视化界面:DataGridView绑定Worklist结果
这份zip既然是可视化程序,就不能只做服务端。WinForms界面上通常会放一个DataGridView或者ListView,把Worklist查询结果实时刷出来。由于Fo-Dicom的回调线程和UI线程不是同一条,直接跨线程更新控件会抛异常,稳妥做法是定义事件,让服务端把结果抛给UI层。
public event Action<DicomCFindRequest, DicomDataset> SingleQueryCompleted; // 在OnCFindRequestAsync里,每个匹配项生成后触发事件 foreach (var item in matches) { var ds = BuildWorklistDataset(item, request.Dataset); SingleQueryCompleted?.Invoke(request, ds); response.AddDataset(ds); }界面线程订阅事件后再刷新DataGridView。
_mwlService.SingleQueryCompleted += (req, ds) => { if (this.InvokeRequired) { this.BeginInvoke((Action)(() => AddGridRow(ds))); } else { AddGridRow(ds); } }; private void AddGridRow(DicomDataset ds) { var row = new DataGridViewRow(); row.Cells["PatientID"].Value = ds.GetSingleValueOrDefault<string>(DicomTag.PatientID, ""); row.Cells["PatientName"].Value = ds.GetSingleValueOrDefault<string>(DicomTag.PatientName, ""); row.Cells["Modality"].Value = ds.GetSingleValueOrDefault<string>(DicomTag.Modality, ""); dataGridView1.Rows.Add(row); }BeginInvoke是在WinForms里跨线程更新UI的标准姿势。我见过初学者直接this.dataGridView1.Rows.Add写在服务回调里,结果程序跑两分钟就崩,DICOM请求也会因为UI线程假死而超时。记住:凡是Fo-Dicom回调里涉及控件的操作,一律用Invoke或BeginInvoke排到UI线程队列里。
3. MPPS服务:N-CREATE和N-SET状态机与DICOM消息处理
MPPS和MWL虽然都叫DICOM服务,但实现方式完全不同。MWL是C-FIND查询,属于“请求-响应”模式;MPPS则是N-CREATE和N-SET,属于“对象管理”模式,设备创建一张MPPS实例,然后不断更新它的状态。Fo-Dicom里要处理MPPS,必须实现IDicomNServiceProvider接口。
3.1 MPPS状态机与AE Title约束
MPPS的SOP Class UID是1.2.840.10008.5.1.4.32,它描述了一个完整的执行步骤:检查开始、检查中、检查结束或中断。标准规定状态有三种:IN PROGRESS、COMPLETED、DISCONTINUED。设备先发出N-CREATE,把实例状态置为IN PROGRESS;检查做完后发出N-SET,把状态改成COMPLETED或DISCONTINUED。这条链路不能跳过N-CREATE直接N-SET,否则服务端应该返回错误。
这里有一个容易被忽略的AE Title约束。设备侧的AE Title必须和MWL请求里ScheduledStationAETitle一致,否则严格模式下MPPS会被拒绝。很多程序只比对SOP Instance UID,不比对AE Title,到了设备厂商验收时才发现问题。所以我一般会在MPPS处理前做一次AE Title校验。
public class MppsService : DicomService, IDicomCEchoProvider, IDicomNServiceProvider { private readonly IMppsRepository _repository; public MppsService(Stream stream, Logger log, INetworkStream networkStream, Encoding fallbackEncoding, IMppsRepository repository) : base(stream, log, networkStream, fallbackEncoding) { _repository = repository; } }注意这里的构造函数多了一个自定义参数IMppsRepository。Fo-Dicom的DicomServer.Create默认不认这种自定义参数,所以你需要在启动时传入一个依赖注入方式。常见做法是给MppsService写一个静态实例工厂,或者用属性注入。我习惯定义MppsService.ConfigureRepository静态方法,在DicomServer.Create之前设置好仓库实例。
3.2 用Fo-Dicom注册MPPS服务:N-CREATE的处理逻辑
N-CREATE请求由设备发出,服务端收到后要创建一个新的MPPS实例。Fo-Dicom里对应的方法是OnNCreateRequestAsync,签名接收DicomNCreateRequest,返回DicomNCreateResponse。一个合格的N-CREATE处理必须校验SOP Class UID和初始状态。
public async Task<DicomNCreateResponse> OnNCreateRequestAsync(DicomNCreateRequest request) { // 校验请求的SOP类是否为MPPS,防止把别的N-CREATE塞进来 if (request.SOPClassUID != DicomUID.ModalityPerformedProcedureStepSOPClass) { return new DicomNCreateResponse(request, DicomStatus.SOPClassNotSupported); } var status = request.Dataset.GetSingleValueOrDefault<string>( DicomTag.PerformedProcedureStepStatus, "IN PROGRESS"); if (status != "IN PROGRESS") { return new DicomNCreateResponse(request, DicomStatus.InvalidArgumentValue); } var sopInstanceUID = request.SOPInstanceUID; // 保存实例,返回成功 _repository.Store(sopInstanceUID, request.Dataset); return new DicomNCreateResponse(request, DicomStatus.Success); }注意request.SOPInstanceUID是设备生成的一串UID,服务端无需自己造,直接保存即可。PerformedProcedureStepStatus在N-CREATE时必须是IN PROGRESS,如果设备传了COMPLETED或空值,就要返回InvalidArgumentValue。很多设备在这里传了空值,为了兼容,有些厂商实现允许空值当作IN PROGRESS处理,但我建议严格校验,否则后续N-SET的状态变化会被污染。
3.3 N-SET的坑:只有部分标签可更新
N-SET请求是设备更新MPPS实例属性的方式,例如把状态从IN PROGRESS改成COMPLETED,同时补上传结束时间、剂量信息等。N-SET和N-CREATE最大的不同是:N-SET是“更新指定字段”,不是“覆盖整个实例”。有些初版代码会把N-SET的DataSet直接替换掉之前存的DataSet,结果把N-CREATE时写入的PatientID、AccessionNumber全冲掉了。
public async Task<DicomNSetResponse> OnNSetRequestAsync(DicomNSetRequest request) { var existing = _repository.Get(request.SOPInstanceUID); if (existing == null) { return new DicomNSetResponse(request, DicomStatus.NoSuchObjectInstance); } // 把请求里的字段更新到已有实例上,而不是替换整个数据集 foreach (var item in request.Dataset) { existing.AddOrUpdate(item); } _repository.Update(request.SOPInstanceUID, existing); var newStatus = existing.GetSingleValueOrDefault<string>( DicomTag.PerformedProcedureStepStatus, "IN PROGRESS"); // 异步让UI层刷新MPPS面板 StatusChanged?.Invoke(request.SOPInstanceUID, newStatus); return new DicomNSetResponse(request, DicomStatus.Success); }这里我用existing.AddOrUpdate(item),Fo-Dicom的DicomDataset.AddOrUpdate会保留原值覆盖新增值。request.Dataset里只会有本次要更新的标签,比如(0040,0250)Performed Procedure Step Start Date、(0040,0251)Performed Procedure Step Start Time、(0040,0252)End Date、(0040,0253)End Time,还有状态。很多设备的N-SET请求里会把整个数据集再传一遍,这时候AddOrUpdate也能正确处理。如果你上来就existing = request.Dataset,那等于丢掉了之前N-CREATE的申请信息,MPPS报表就会缺字段。
3.4 把MPPS转成SQLite落地:事务与并发注意
可视化程序一般需要把MPPS实例持久化,方便后续生成检查统计报表。SQLite够用,我给出一个能直接建表的SQL。
CREATE TABLE IF NOT EXISTS mpps_instance ( sop_instance_uid TEXT PRIMARY KEY, pps_id TEXT, status TEXT, patient_id TEXT, patient_name TEXT, modality TEXT, station_ae TEXT, start_date TEXT, start_time TEXT, end_date TEXT, end_time TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );写入时要注意并发。同一台设备可能在几秒内连续发多个N-SET,UPDATE同一行时如果没有锁,SQLite会报“database is locked”。我一般用简单的原子更新语句,并且把写库操作限制到单线程。Fo-Dicom的请求回调是线程池调度的,所以仓储层必须加锁。
private readonly object _dbLock = new object(); public void Update(string sopInstanceUid, DicomDataset ds) { lock (_dbLock) { using var cmd = new SQLiteCommand(_connection); cmd.CommandText = @" UPDATE mpps_instance SET status = @status, end_date = @endDate, end_time = @endTime, updated_at = datetime('now') WHERE sop_instance_uid = @uid"; cmd.Parameters.AddWithValue("@status", ds.GetSingleValueOrDefault<string>(DicomTag.PerformedProcedureStepStatus, "")); cmd.Parameters.AddWithValue("@endDate", ds.GetSingleValueOrDefault<string>(DicomTag.PerformedProcedureStepEndDate, "")); cmd.Parameters.AddWithValue("@endTime", ds.GetSingleValueOrDefault<string>(DicomTag.PerformedProcedureStepEndTime, "")); cmd.Parameters.AddWithValue("@uid", sopInstanceUid); cmd.ExecuteNonQuery(); } }lock (_dbLock)保证同一时间只有一个请求在写库。不要天真地以为SQLite自带事务就万事大吉,Fo-Dicom的并发量虽然不高,但设备重试机制可能在同一秒内发多个N-SET,锁是必须的。另外,N-CREATE和N-SET的UID一致性校验也放在这里,避免更新不存在实例。
4. 避坑指南:从编译到联调,MPPS/MWL可视化程序最常踩的5个坑
无论资源里的代码写得再顺手,实际部署时总会遇到意料之外的报错。以下几条是我拆这套Fo-Dicom程序时反复遇到的坑,每条都按“现象、原因、解决”来写。
4.1 现象:Fo-Dicom版本不同导致接口签名找不到
现象是最直接的:项目编译报错,提示MwlService没有实现IDicomCFindProvider,或者OnCFindRequestAsync方法签名不对。原因很简单,Fo-Dicom 3.x里的回调方法是同步的,比如DicomCFindResponse OnCFindRequest(DicomCFindRequest request);到了4.x改成了异步Task<DicomCFindResponse> OnCFindRequestAsync(DicomCFindRequest request)。如果你用的代码来自老版本,而NuGet包装的是4.x,必然编不过。解决方法是锁定版本,或按4.x签名改造。我习惯在csproj里固定版本:
<PackageReference Include="fo-dicom.Desktop" Version="4.0.7" />fo-dicom.Desktop是包含WinForms依赖的包,如果只做服务端,可以引用fo-dicom.NetCore。版本号确定后,所有异步接口都按...Async方法重写。
4.2 现象:Worklist查询返回空,但监听端口已通
现象是设备C-ECHO正常,但点查询列表时返回0条记录。原因通常是返回数据集缺少必填字段,或者ScheduledProcedureStepSequence结构不对。设备解析响应失败时,有些设备会直接丢弃整个响应,表现成“查询结果为空”。解决方法是先在服务端把所有请求和响应数据集打印出来,用DicomDataset.Dump看。
public async Task<DicomCFindResponse> OnCFindRequestAsync(DicomCFindRequest request) { Console.WriteLine("查询条件:"); Console.WriteLine(request.Dataset.Dump()); // ... 处理逻辑 }Dump()是Fo-Dicom自带的方法,能把数据集里所有标签和值按层级打印成字符串。我看到很多程序员卡在这里半天,最后就是靠Dump发现ScheduledProcedureStepSequence这个VR类型写成了普通标签而不是Sequence。修好后一查就通。
4.3 现象:MPPS N-CREATE成功,N-SET却报错
现象是设备日志显示N-CREATE收到成功响应,紧跟着N-SET就被服务端拒绝,状态码是NoSuchObjectInstance。原因几乎都是服务端没有把N-CREATE创建的实例保存到同一个可见的存储里,或者N-SET里的SOP Instance UID和N-CREATE不一致。有些设备的N-SET请求会使用另一个UID,这是设备配置错误,但服务端要能容忍。解决方法是先确认两个请求的SOPInstanceUID,从服务端日志里抓出来对比。如果一致,再检查是不是存储层用了两个不同的MppsRepository实例。我见过一个现场项目在DicomServer.Create<MppsService>时每次new了一个新仓库,导致N-CREATE存到实例A,N-SET却查实例B,永远查不到。解决办法是仓储用单例。
4.4 现象:UI线程卡死,DICOM请求超时
现象是程序跑起来,一收到DICOM请求,界面就转圈;设备侧反馈请求超时。原因是Fo-Dicom的回调线程里直接操作了WinForms控件,或者反过来,在UI线程里用了.Result同步等待异步任务。这两种都会导致线程互相等死。解决方法是所有UI更新都走BeginInvoke,所有耗时的数据库操作放到Task.Run里。另外,不要在Form_Load里写DicomServer.Create然后紧跟死循环,正确的做法是异步启动:
private async void Form_Load(object sender, EventArgs e) { await Task.Run(() => { _server = DicomServer.Create<MwlService>(11111, "0.0.0.0", null); }); label1.Text = "服务已启动"; }async void在事件处理器里可以接受,注意捕获异常,否则服务启动失败时程序会静默崩溃。
4.5 现象:双服务监听同一端口冲突
现象是启动MWL服务后,再启动MPPS服务抛AddressAlreadyInUseException。原因很简单,两个服务想共用同一个DICOM端口。有两种解决方式:一是给MPPS和MWL分配不同端口,设备侧配置时分别填两个端口和两个AE Title;二是把两个服务合并到一个类里,同时实现IDicomCFindProvider和IDicomNServiceProvider。我推荐第二种,因为设备通常只配一个服务端地址,端口分开会多一次配置。
public class CombinedDicomService : DicomService, IDicomCEchoProvider, IDicomCFindProvider, IDicomNServiceProvider { // C-FIND和N-CREATE都写在同一个类里 }合并类时注意,IDicomNServiceProvider要求实现OnNCreateRequestAsync、OnNSetRequestAsync、OnNActionRequestAsync、OnNDeleteRequestAsync和OnNEventReportRequestAsync。如果你不需要所有方法,Fo-Dicom仍然要求全部实现,但可以让不用的方法返回DicomStatus.NoSuchAction或DicomStatus.SOPClassNotSupported。
5. 进阶:用DicomClient反向验证服务端和日志追踪技巧
最后一章给两个实战技巧。第一个是用Fo-Dicom自带客户端反向验证你的服务端。每次改完服务端代码,不要急着接真机,先在代码里写一个SCU,模拟设备发一条C-FIND和一条N-CREATE/N-SET,验证服务端行为。我一般写一个WinForms测试按钮,点击后就发请求。
var client = DicomClientFactory.Create("127.0.0.1", 11111, false, "CLIENT_AE", "SERVER_AE"); var cfind = DicomCFindRequest.Create(DicomQueryRetrieveLevel.Patient); cfind.Dataset.AddOrUpdate(DicomTag.PatientID, "TEST001"); await client.AddRequestAsync(cfind); await client.SendAsync();第二个技巧是日志追踪。Fo-Dicom的DicomServer.Create默认日志输出到控制台,部署成Windows服务后日志无处可寻。我一般会注册一个自定义日志处理器,把DICOM请求和响应摘要写到文件里。
Dicom.Log.LogManager.SetImplementation(() => new CustomFileLogger("dicom.log"));自定义Logger里每个请求记录一行,包含时间、关联ID、请求类型、SOP实例UID、返回状态。这套日志是排查设备联调问题的第一手资料。从那以后我每次拿到这类DICOM服务端资源,都会先做三件事:固定Fo-Dicom版本,跑通C-ECHO,再看日志确认C-FIND和N-CREATE的请求结构。顺序千万别反过来,否则你会被设备厂商的“代码没问题”拖进神仙打架的境地。这几点都落地后,希望帮到你。
本文还有配套的精品资源,点击获取