ARTICLE DETAIL

资讯详情

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

Fo-Dicom实战:MWL与MPPS可视化服务实现及设备联调避坑指南

Fo-Dicom实战:MWL与MPPS可视化服务实现及设备联调避坑指南 简介Fo-Dicom是开源C# DICOM库基于它开发的这套程序实现了MPPS与MWL服务的可视化监控面向医学影像软件工程师、医院信息系统集成人员及专业学生适用于PACS/RIS环境下的设备联调与流程展示。资源包共424个文件压缩为2.93MB其中包含123个C#源码文件承担主界面、事件驱动和DICOM消息解析逻辑34个DLL与4个EXE为运行所需依赖4个XAML文件定义界面其余为构建配置文件、缓存文件等整体结构清晰。已有227人学习下载。该工程展示了Fo-Dicom中SCP组件的接入方式涵盖网络监听、连接维护、MPPS状态上报和MWL查询响应等关键环节还包含WPF可视化面板用于实时显示服务状态。开发者可以以此为模板学习DICOM网络服务的实现细节快速构建面向HIS/RIS的通信工具也可在此基础上二次开发加入远程诊断、检查流程管理等功能。1. 看到这个标题说明你正被设备端联调卡住看到「基于Fo-Dicom实现的MPPS服务和MWL服务可视化程序」这个标题你大概率和我一样手里正捏着一台要接 PACS 的 DICOM 设备CT、MR、DR 都算。设备端要拉检查申请得先找到一个能应答的 MWL 服务检查做完要把执行状态回传又绕不开 MPPS。这类程序的价值不在界面多好看而是把这两个 DICOM 服务在本地拉起来同时把每一次 C-FIND 查询、N-CREATE 登记、N-SET 更新都变成界面上戳得见的记录。适合三类人做设备联调被 RIS 临时放鸽子的工程师、写 DICOM 教学 demo 的人、以及想快速验证 Fo-Dicom 服务端能力的医疗软件开发者。2. MPPS 和 MWL 到底是什么从设备端把 C-FIND / N-CREATE / N-SET 读一遍2.1 MWL 就是设备开机后拉工单的那一声 C-FINDMWL全称 Modality Worklist中文常叫「待检列表」SOP Class UID 是 1.2.840.10008.5.1.4.31走的是 C-FIND 操作。设备开机的第一件事不是扫患者条码而是拿着自己配置的检查站信息去 MWL SCP 问一句「今天这个检查站有哪些单子」。SCP 把命中的工单一条一条通过 Pending 状态的数据集回给设备最后用一个 Success 收尾一次查询结束。很多人把 MWL 理解成「查数据库」其实 MWL 本质是按 DICOM 查询模型过滤的接口。设备端在 C-FIND-RQ 里带上一组匹配条件比如 ScheduledStationAETitle、Modality、ScheduledProcedureStepStartDateSCP 必须按这些条件过滤再把匹配结果逐条返回。这里容易忽略一个细节返回数据集不是平铺的ScheduledProcedureStepSequence (0040,0100) 是一个嵌套序列里面还要再放 ScheduledProcedureStepID、ScheduledStationAETitle、ScheduledProcedureStepStartDate 这些子字段。设备端对嵌套结构很敏感你把序列拍平了它可能整单解析失败界面上什么都不显示。2.2 MPPS 是设备干完活以后的两个动作N-CREATE 和 N-SETMPPS 全称 Modality Performed Procedure StepSOP Class UID 是 1.2.840.10008.3.1.2.3.3。它和 MWL 正好互补MWL 是设备「问」MPPS 是设备「报」。开始扫描时设备发 N-CREATE-RQ把本次执行的步骤 ID、患者信息、当前状态 IN PROGRESS 报上来扫描结束再发 N-SET-RQ把状态改成 COMPLETED 或 DISCONTINUED。一个 MPPS 实例由 SOP Instance UID 唯一标识。设备端在 N-CREATE-RQ 里会带上它生成的 SOP Instance UID服务端接收后必须把这个 UID 存下来作为后续 N-SET 的索引。第一次做 MPPS 服务的人常在这里翻车N-CREATE 响应里没把 AffectedSOPInstanceUID 正确带回去或者 N-SET 拿不到这个 UID直接回 NoSuchObjectInstance设备端就会一直重试日志刷屏。MPPS 的状态字段 PerformedProcedureStepStatus (0040,0252) 是单向推进的IN PROGRESS 之后只能走到 COMPLETED 或 DISCONTINUED不允许回退。2.3 为什么选 Fo-Dicom 而不是自己拼 PDUFo-Dicom 是 .NET 生态里最主流的 DICOM 库开箱就带 DICOM 消息编解码、association 管理、C-ECHO/C-FIND/C-STORE 这些常用 SCP 骨架。C-FIND 有现成的 DicomCFindProvider 可以继承MPPS 虽然是 N 类操作不走 C-FIND 通道但同样挂在 DIMSE 栈上库已经帮你把 TCP 连接、PDU 分包、association 协商都处理完了你只需要专注业务回调。如果不用 Fo-Dicom你就得自己用 BinaryWriter 拼 VR 字段、自己维护 PDU 序号、自己处理 association 的 presentation context 协商。DICOM 标准上千页光是把 AE Title 和 Transfer Syntax 的协商逻辑写对就要一两周而 Fo-Dicom 用几行就能启动一个监听服务。做这个可视化程序把力气花在业务逻辑和界面上比花在协议栈上值得多。3. 用 Fo-Dicom 把 MWL 服务跑起来C-FIND SCP 的最小实现3.1 建工程和引包Fo-Dicom 4.x 与 5.x 先选一个先初始化一个 WPF 工程。协议代码和 UI 代码我建议分两个工程放不要都塞在 MainWindow.xaml.cs 里——MPPS 和 MWL 的服务端要长期监听端口和 UI 线程搅在一起你最后一定会为界面卡顿和重启问题掉头发。初始化命令dotnet new wpf -n MppsMwlVisualizer cd MppsMwlVisualizer dotnet add package fo-dicom如果决定直接用 5.x包名不一样dotnet add package FellowOakDicomFo-Dicom 的 4.x 是老牌版本NuGet 上搜fo-dicom5.x 开始包名改成FellowOakDicom命名空间从Dicom.*整体迁到FellowOakDicom.*tag、dataset、network 这些核心类名没变。下面代码按 4.x 的命名空间写5.x 只需要把using Dicom换成using FellowOakDicom其余逻辑几乎平移。3.2 实现 C-FIND SCPWorklistProvider 的完整代码MWL 服务的本质是 C-FIND SCP。Fo-Dicom 给了现成的基类DicomCFindProvider要做的事三步收到 query、拿 query 过滤内存里的工单、把命中的数据集作为 Pending 响应回传。代码长这样using Dicom; using Dicom.Network; namespace MppsMwlVisualizer.Core { public class WorklistProvider : DicomCFindProvider { private readonly IReadOnlyListDicomDataset _worklist; public WorklistProvider(IReadOnlyListDicomDataset worklist) { _worklist worklist; } public override IEnumerableDicomCFindResponse OnCFindRequest( DicomCFindRequest request) { var query request.Dataset; var matchCount 0; foreach (var item in _worklist) { if (!IsMatch(query, item)) continue; // 每个命中项回一个 Pending设备端会逐条渲染 var pending new DicomCFindResponse(request, DicomStatus.Pending) { Dataset item.Clone() }; matchCount; yield return pending; } // 最后必须补一个 Success 收尾告诉设备端查完了 yield return new DicomCFindResponse(request, DicomStatus.Success); } private bool IsMatch(DicomDataset query, DicomDataset item) { if (query.TryGetString(DicomTag.PatientID, out var pid) !string.IsNullOrEmpty(pid) item.GetString(DicomTag.PatientID) ! pid) { return false; } if (query.TryGetString(DicomTag.AccessionNumber, out var acc) !string.IsNullOrEmpty(acc) item.GetString(DicomTag.AccessionNumber) ! acc) { return false; } return true; } } }这段代码的核心是OnCFindRequest返回一个迭代器。Fo-Dicom 会把这个迭代器产生的响应逐条发回给请求方所以 Pending 响应数量不限但最后一个必须是 Success否则设备端会认为查询被异常截断。IsMatch里的逻辑是精确匹配实际联调时可以放宽成 Contains因为设备端有时会在 PatientID 里带前导空格或补零。构造 worklist 数据集时有几个字段必须带上设备端才认字段Tag说明PatientName(0010,0010)患者姓名PatientID(0010,0020)患者 IDAccessionNumber(0008,0050)检查号Modality(0008,0060)设备类型ScheduledStationAETitle(0040,0001)计划执行站 AE TitleScheduledProcedureStepStartDate(0040,0002)计划开始日期ScheduledProcedureStepStartTime(0040,0003)计划开始时间ScheduledProcedureStepID(0040,0009)步骤 IDScheduledStationAETitle 是设备端筛选工单最常用的条件缺了它设备端可能把整单忽略。3.3 把服务拉起来端口、AE Title 和日志服务启动代码就几行var worklist BuildWorklist(); // 从数据库或内存构造工单列表 var server DicomServer.CreateWorklistProvider( port: 11112, aeTitle: MWL_SIM, options: null); Dicom.Log.LogManager.SetImplementation(new ConsoleLogManager());port是 TCP 监听端口DICOM 官方默认端口是 11112没特殊要求就固定用它。aeTitle是 DICOM 应用实体名称最大 16 字节、大小写敏感设备端配置里的 AE Title 必须和这个字符串严格一致。LogManager.SetImplementation这句把 Fo-Dicom 的日志输出到控制台联调阶段建议一定打开C-FIND 请求来没来、回了什么状态码一眼就能看到。这里有个省事点C-ECHO 不需要额外写 Provider。Fo-Dicom 对 C-ECHO 请求会自动应答所以你只要把服务监听起来设备端就能先做 Echo 测试确认网络通再做 Worklist 查询。4. 把 MPPS 接进来N-CREATE / N-SET 处理和可视化面板4.1 没有现成的 MPPS Provider两条实现路径Fo-Dicom 没有提供像DicomCFindProvider那样开箱即用的 MPPS Provider因为 N-CREATE、N-SET 属于 DIMSE 的 N 类操作需要自己在DicomService的派生类里处理。常见做法有两种路径 A让一个继承自DicomService的类同时处理 C-FIND、N-CREATE、N-SET共用一个端口。路径 BMWL 和 MPPS 分开两个端口监听MWL 用现成的DicomCFindProviderMPPS 用自写的DicomService子类。我一般选路径 B。原因很实际MWL 和 MPPS 是两个独立的 SOP Class设备端联调时往往分两步验证分开监听端口日志和断点都不会互相干扰。而且DicomCFindProvider的封装已经够好没必要把逻辑揉进一个大 Service 里增加出错面积。4.2 处理 N-CREATE 登记开始、N-SET 更新结束MPPS 服务端的核心是两段式回调代码框架如下using Dicom; using Dicom.Network; namespace MppsMwlVisualizer.Core { public class MppsServiceHandler : DicomService { // 用字典维护进行中的 MPPS 实例真实项目可换数据库 private static readonly Dictionarystring, DicomDataset _steps new Dictionarystring, DicomDataset(); public override DicomNCreateResponse OnNCreateRequest( DicomNCreateRequest request) { var sopUid DicomUIDGenerator.GenerateNew().UID; var ds request.Dataset.Clone(); // 设备上报的状态通常是 IN PROGRESS这里兜底 ds.AddOrUpdate(DicomTag.PerformedProcedureStepStatus, IN PROGRESS); _steps[sopUid] ds; var rsp new DicomNCreateResponse(request, DicomStatus.Success); rsp.AffectedSOPInstanceUID sopUid; // 把这个 UID 回给设备后续 N-SET 全靠它定位 return rsp; } public override DicomNSetResponse OnNSetRequest( DicomNSetRequest request) { var sopUid request.SOPInstanceUID; if (!_steps.TryGetValue(sopUid, out var ds)) { return new DicomNSetResponse(request, DicomStatus.NoSuchObjectInstance); } var status request.Dataset.GetString(DicomTag.PerformedProcedureStepStatus); ds.AddOrUpdate(DicomTag.PerformedProcedureStepStatus, status); ds.AddOrUpdate(DicomTag.PerformedProcedureStepEndDate, request.Dataset.GetString(DicomTag.PerformedProcedureStepEndDate)); ds.AddOrUpdate(DicomTag.PerformedProcedureStepEndTime, request.Dataset.GetString(DicomTag.PerformedProcedureStepEndTime)); return new DicomNSetResponse(request, DicomStatus.Success); } } }启动时单独开一个端口绑定这个 Handlervar mppsServer DicomServer.CreateMppsServiceHandler( port: 11113, aeTitle: MPPS_SIM);注意两个关键点。第一N-CREATE 响应里的AffectedSOPInstanceUID是设备后续 N-SET 的钥匙必须保证唯一并被正确返回设备在 N-SET 请求里通过SOPInstanceUID指定要更新的实例。第二N-CREATE 时设备带来的数据集里已经包含 PerformedProcedureStepID、PerformedStationAETitle、PerformedProcedureStepStartDate 等字段服务端只需原样存下不要画蛇添足去改写。4.3 可视化面板把请求和状态变化刷到界面上可视化部分我用 WPF 的 DataGrid 展示一条条事务记录界面就三列时间、类型、描述。XAML 长得这样DataGrid ItemsSource{Binding Transactions} AutoGenerateColumnsFalse IsReadOnlyTrue DataGrid.Columns DataGridTextColumn Header时间 Binding{Binding Time} Width70/ DataGridTextColumn Header类型 Binding{Binding Type} Width80/ DataGridTextColumn Header描述 Binding{Binding Description} Width*/ /DataGrid.Columns /DataGrid对应的 ViewModelpublic class TransactionItem { public string Time { get; set; } public string Type { get; set; } public string Description { get; set; } } public class MainViewModel : INotifyPropertyChanged { public ObservableCollectionTransactionItem Transactions { get; } new ObservableCollectionTransactionItem(); public void Append(string type, string description) { Transactions.Add(new TransactionItem { Time DateTime.Now.ToString(HH:mm:ss), Type type, Description description }); } }然后在WorklistProvider和MppsServiceHandler里注入一个事件回调把关键节点推给界面。例如在WorklistProvider的查询入口加public Actionstring, string OnEvent delegate { }; // 在 OnCFindRequest 拿到 query 后调用 OnEvent(MWL, $PatientID{pid} 返回 {matchCount} 条工单);这样 MWL 查询、MPPS 登记、MPPS 状态更新都会实时滚到界面上。整个可视化程序的数据流就是设备端发请求 → 服务端回调触发 → 事件推送 → 界面刷新环环相扣。5. 避坑记录这 5 个坑我踩过你直接绕开5.1 AE Title 对不上C-FIND 静默失败现象设备端 TCP 能连上服务端口但 C-FIND 请求发出去后一直没有响应服务端日志也看不到请求进来。原因DICOM 的 association 协商阶段会校验 AE Title。设备端配置的本地 AE Title 或对端 AE Title 与服务端创建时传的aeTitle不一致连接会在协商阶段被静默拒绝请求根本没走到 OnCFindRequest。解决把服务端的 AE Title 做成可视化程序里的可配置输入框设备端每一台设备的 AE Title 都可能不一样硬编码会让你在测试现场反复改代码。另外注意大小写和末尾空格MWL_SIM和MWL_SIM 是两回事。5.2 日期范围查询不能当普通字符串比较现象设备端查询ScheduledProcedureStepStartDate传的是20250101-20250320这种区间格式服务端用直接比较结果明明有工单却一条都查不到。原因DICOM 里 DA 类型的 VR 支持 range 语义MWL 查询允许起止区间。Range 字符串的规则是yyyyMMdd-yyyyMMdd表示闭区间yyyyMMdd-表示从某天开始-yyyyMMdd表示截至某天。简单相等匹配当然捞不到。解决匹配时把区间拆开手动比较代码如下private static bool MatchDateRange(string queryValue, DicomDataset item) { if (string.IsNullOrEmpty(queryValue)) return true; var parts queryValue.Split(-); var actual item.GetSingleValueOrDefault( DicomTag.ScheduledProcedureStepStartDate, ); if (parts.Length 1) return actual parts[0]; if (parts[0].Length 8 string.CompareOrdinal(actual, parts[0]) 0) return false; if (parts.Length 1 parts[1].Length 8 string.CompareOrdinal(actual, parts[1]) 0) return false; return true; }日期字符串按yyyymmdd格式直接做字典序比较是安全的不需要转 DateTime省去解析开销和时区问题。5.3 中文患者姓名全变问号现象设备端查询回来的工单界面显示患者姓名是???或乱码。原因DICOM 默认字符集是 ASCIIISO-IR 6如果返回数据集没声明字符集Fo-Dicom 按 ASCII 解码中文字节直接被丢成问号。解决在构造 worklist 返回数据集时显式加上字符集声明item.AddOrUpdate(DicomTag.SpecificCharacterSet, GB18030);这里有个兼容性细节部分老设备只认ISO 2022 IR 58这类转义序列字符集不认 GB18030。可视化程序里最好把字符集做成配置项联调时换一换就能适配不同设备。5.4 端口占用和重复启动服务起不来现象调试时反复停掉又运行第二次启动直接报Address already in useFo-Dicom 的监听 socket 没释放干净。原因Fo-Dicom 的DicomServer.Stop()是异步释放的进程内重复Create同一个端口时前一个实例可能还没完全关闭。解决开发阶段固定端口停服后加一个短暂等待再重启不要在一个进程里对同一个端口多次Create。可视化程序里把端口和 AE Title 一起放进配置区调试时直接改端口最省事。5.5 MPPS 状态不能回退界面状态乱了现象设备端把状态改成 COMPLETED 后又发了一次 N-SET 改 DISCONTINUED服务端照单全收界面显示两个状态业务对不上。原因DICOM 标准里 PerformedProcedureStepStatus 是单向的IN PROGRESS 之后只能切到终止态 COMPLETED 或 DISCONTINUED不能从 COMPLETED 再改回 DISCONTINUED。Fo-Dicom 不会替你校验这个状态机。解决在 N-SET 回调里检查现有状态已终止的实例只允许幂等返回成功不更新数据。可视化界面用颜色区分状态IN PROGRESS 显示黄色、COMPLETED 绿色、DISCONTINUED 灰色看板状态一眼可辨。6. 从模拟到实测用最小客户端把这套程序真正验一遍验证这套可视化程序不要只靠肉眼盯日志我用一个 Fo-Dicom 自带的 SCU 打一遍最快。写一个控制台里的小客户端var client new DicomClient(127.0.0.1, 11112, false, SCU, MWL_SIM); var request new DicomCFindRequest(DicomQueryRetrieveLevel.Worklist); request.Dataset.AddOrUpdate(DicomTag.ScheduledStationAETitle, CT_SIM); await client.SendAsync(request); foreach (var response in request.Response) { if (response.Status DicomStatus.Success) Console.WriteLine(查询结束共 response.Dataset?.Count() 条); }这个客户端能验证三件事本地 MWL 服务是否正常应答、ScheduledStationAETitle 过滤是否生效、返回数据集是否带全了设备端要的字段。MPPS 侧则用模拟设备端依次发 N-CREATE 和 N-SET然后观察可视化面板里状态是否从 IN PROGRESS 走到 COMPLETED。注意 N-SET 用的 SOP Instance UID 必须取自 N-CREATE 响应的AffectedSOPInstanceUID这在联调时是最容易被忽略的一环。进阶一点的做法是把 MWL 数据源从内存 List 换成 SQLite让可视化程序成为一套可以持续运行的联调桩。MPPS 记录则落一张独立的表按 PerformedProcedureStepID 做唯一约束这样即使设备端超时重试也不会生成重复记录。我自己的习惯是给服务端日志加上时间戳输出到文件联调完直接拿日志和设备端的记录逐条比对谁丢了谁多了一目了然。这套流程跑顺了这个可视化程序就不再只是演示工具而是你设备联调时离不开的后扶手。希望帮到你。本文还有配套的精品资源点击获取
返回列表