ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Fo-Dicom的MPPS与MWL可视化服务:C# DICOM工具实战

基于Fo-Dicom的MPPS与MWL可视化服务:C# DICOM工具实战 简介基于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设备需要自动拉取检查申请单、回传开始和结束状态MWLModality Worklist和MPPSModality 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 ProviderFo-Dicom服务注册顺序Fo-Dicom的DicomServer类负责监听TCP端口、处理DICOM消息关联。要让服务端响应C-FIND必须把处理逻辑放进一个继承自DicomService的类里同时实现IDicomCFindProvider接口。常见做法是写一个MwlService类然后在程序启动时用DicomServer.Create把这个类绑定到端口上。// Program.cs 启动入口 var mwlServer DicomServer.CreateMwlService( 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 TaskDicomCEchoResponse 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 TaskDicomCFindResponse OnCFindRequestAsync(DicomCFindRequest request) { var pid request.Dataset.GetSingleValueOrDefaultstring(DicomTag.PatientID, null); var modality request.Dataset.GetSingleValueOrDefaultstring(DicomTag.Modality, null); var startDate request.Dataset.GetSingleValueOrDefaultstring( 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 ActionDicomCFindRequest, 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.GetSingleValueOrDefaultstring(DicomTag.PatientID, ); row.Cells[PatientName].Value ds.GetSingleValueOrDefaultstring(DicomTag.PatientName, ); row.Cells[Modality].Value ds.GetSingleValueOrDefaultstring(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 TaskDicomNCreateResponse OnNCreateRequestAsync(DicomNCreateRequest request) { // 校验请求的SOP类是否为MPPS防止把别的N-CREATE塞进来 if (request.SOPClassUID ! DicomUID.ModalityPerformedProcedureStepSOPClass) { return new DicomNCreateResponse(request, DicomStatus.SOPClassNotSupported); } var status request.Dataset.GetSingleValueOrDefaultstring( 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 TaskDicomNSetResponse 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.GetSingleValueOrDefaultstring( 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-SETUPDATE同一行时如果没有锁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.GetSingleValueOrDefaultstring(DicomTag.PerformedProcedureStepStatus, )); cmd.Parameters.AddWithValue(endDate, ds.GetSingleValueOrDefaultstring(DicomTag.PerformedProcedureStepEndDate, )); cmd.Parameters.AddWithValue(endTime, ds.GetSingleValueOrDefaultstring(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改成了异步TaskDicomCFindResponse OnCFindRequestAsync(DicomCFindRequest request)。如果你用的代码来自老版本而NuGet包装的是4.x必然编不过。解决方法是锁定版本或按4.x签名改造。我习惯在csproj里固定版本PackageReference Includefo-dicom.Desktop Version4.0.7 /fo-dicom.Desktop是包含WinForms依赖的包如果只做服务端可以引用fo-dicom.NetCore。版本号确定后所有异步接口都按...Async方法重写。4.2 现象Worklist查询返回空但监听端口已通现象是设备C-ECHO正常但点查询列表时返回0条记录。原因通常是返回数据集缺少必填字段或者ScheduledProcedureStepSequence结构不对。设备解析响应失败时有些设备会直接丢弃整个响应表现成“查询结果为空”。解决方法是先在服务端把所有请求和响应数据集打印出来用DicomDataset.Dump看。public async TaskDicomCFindResponse 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.CreateMppsService时每次new了一个新仓库导致N-CREATE存到实例AN-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.CreateMwlService(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的请求结构。顺序千万别反过来否则你会被设备厂商的“代码没问题”拖进神仙打架的境地。这几点都落地后希望帮到你。本文还有配套的精品资源点击获取
返回列表