ARTICLE DETAIL

资讯详情

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

基于PyQt5与pydicom的DICOM阅片及网络通信工具开发实践

基于PyQt5与pydicom的DICOM阅片及网络通信工具开发实践 简介一套面向医学影像软件开发者的Python demo演示将PyQt5图形界面、pydicom影像解析和pynetdicom网络协议栈整合在同一工程中解决三者如何协同工作的问题适合医疗信息化领域的初中级开发者、课程设计或项目预研参考。压缩包内共1231个文件以570个py源码与557个pyc编译文件为主体还包含24个pyd扩展模块、19个exe可执行程序、6个dll动态库、4个ui界面文件、3个qrc资源定义等其中py源码便于阅读和二次修改pyc可直接运行参考ui与qrc展示界面布局和资源管理方式exe与dll保障运行环境覆盖界面、通信、运行依赖等维度整体约16.33MB。目前已有83人学习浏览。资源提供可直接运行的demo包含DICOM文件读取、元数据显示、界面交互以及C-STORE/ECHO等网络服务调用示例覆盖从本地影像处理到网络传输的完整链路。代码结构清晰并附有配置文件和虚拟环境组件配置好依赖后即可运行观察三者协作效果也可在此基础上快速扩展自己的影像工具适用于课程作业、毕设预研或内部工具搭建。 医疗影像圈子里的开发同学多少都碰过这种尴尬手头就一个DICOM文件想快速看看内容还得专门去找支持DICOM的阅片软件想验证自己写的传输脚本能不能和PACS对接又要临时搭一套测试环境。这次我把 pyqt5、pydicom、pynetdicom 三个库拼在一起做了一个桌面端 demo目标很直接在同一个窗口里把 DICOM 本地解析显示和 DICOM 网络通信测试一起搞定。这篇文章会把环境搭建、界面实现、网络对接三个维度完整拆开讲把我实际跑通的代码和踩过的坑都写清楚适合正在做医学影像二次开发、或者想入门 DICOM 网络协议的同学参考。1. 先把这三样东西凑到一起的动机说清楚1.1 这套技术栈到底能解决什么问题医学影像领域的开发需求说来说去跳不出三类一是读写 DICOM 文件并提取像素数据二是把像素数据变成人类能看的图像三是通过网络把 DICOM 文件或者查询请求发给其他系统。市面上不少工具能分别解决其中某一件事比如 RadiAnt 看图像很强dcm4che 传输很专业但如果你想在同一个桌面程序里既能看本地序列又能主动向测试 PACS 发起一个 C-ECHO 或者 C-STORE就得自己动手粘合。pydicom 负责 DICOM 文件的解析像素数组、标签信息、传输语法都能处理pyqt5 负责把界面搭起来文件列表、图像画布、进度条这些交互控件靠它落地pynetdicom 则是在 DICOM 协议栈的上层实现了网络服务像 C-ECHO、C-STORE、C-FIND等操作都有现成的类和方法不用我们从头拼 TCP 报文。这三者合在一起才能形成一个完整的演示闭环左边文件列表选中一个 DICOM 文件中间窗口显示对应影像点一个按钮向本地或远端 SCP 发起传输再把传输结果打印在日志区域。很多医院内部的影像工具都是从这么一个 demo 原型开始长出来的。1.2 为什么是 PyQt5 而不是其他 GUI 框架如果你问我用 PySide6 行不行当然行API 其实和 PyQt5 高度相似这套 demo 的思路平移过去也不难。但 PyQt5 的生态资料最多网上能搜到的 DICOM 相关案例大多以 PyQt5 为基准对于刚接触医学影像开发的人来说踩坑后的排查成本最低。还有一个实际原因很多团队的存量项目仍然跑在 PyQt5 上接口文档、网络答疑、示例代码都很成熟。虽然 PyQt6 也在推进但它在某些控件事件处理和坐标转换上跟 PyQt5 有点差异如果只是为了演示 pydicom 和 pynetdicom 的集成没必要在 GUI 框架上过度纠结。2. 环境准备版本组合、安装顺序和翻车记录2.1 我实测可用的版本组合先说结论我本地用的 Python 3.10安装包版本是 PyQt5 5.15.9、pydicom 2.4.4、pynetdicom 2.0.2numpy 用的是 1.26.4。这三个库对 Python 的版本要求都不算苛刻3.8 到 3.11 之间基本都能跑。建议直接创建一个干净的虚拟环境不要和系统 Python 混在一起。医学影像相关的依赖链不长但 pydicom 和 pynetdicom 的某些附属依赖可能会和你已有的包版本冲突虚拟环境能省掉一半的烦恼。2.2 安装时最容易踩的几个坑如果直接pip install pyqt5 pydicom pynetdicom一路装下去多数情况能过。但我在帮同事排查时遇到过两类情况很典型。第一类是 PyQt5 的“下拉框闪退”问题。现象是界面能起来QComboBox 下拉列表一点就崩溃或者干脆程序直接退出。这个问题和 pydicom、pynetdicom 没关系通常是环境里同时混了多个 Qt 版本比如 conda 环境自带 qt 和 pyqt然后又用 pip 给同一环境装了 PyQt5两个 Qt 插件库打架。解决办法很简单把环境清理干净只保留一方安装源推荐都用 pip 安装不要混用 conda 和 pip。第二类问题是导入 pynetdicom 时报缺gast或pydicom版本不匹配。pynetdicom 对 pydicom 的主版本有兼容要求装完 pynetdicom 后最好pip list看一眼 pydicom 的版本如果发现是 3.x 而 pynetdicom 尚不支持就降到 2.x。2.3 快速验证环境是否正常的脚本环境装完后不要急着写界面先用下面这段做冒烟验证能一次性确认 pydicom 读取和 pynetdicom 导入都没问题import pydicom from pydicom import dcmread from pydicom.data import get_testdata_file # pydicom 自带的测试文件路径不需要额外下载 DICOM 文件 test_file get_testdata_file(CT_small.dcm) ds dcmread(test_file) print(ds.PatientName, ds.Modality, ds.Rows, ds.Columns) import pynetdicom print(pynetdicom imported ok)如果这个脚本能顺利打印出患者姓名和行列数说明解析链路是通的。接下来才进入界面开发阶段。3. 本地阅片界面从 DICOM 文件到窗口中的图像显示3.1 主窗口布局文件列表放左侧图像放中间我用的是 QMainWindow 水平布局。左侧一个 QListWidget 用来展示某个文件夹下扫描到的 DICOM 文件路径右侧一个 QLabel 作为图像显示区域窗口底部再加一个 QTextEdit 作为日志输出区。说句实话QLabel 并不是最专业的图像控件它没有内置的缩放和平滑滤波但胜在简单。对于 demo 来说用户只需要看清图像内容和窗宽窗位交互QLabel 完全够用。如果你想做得更专业可以换 QGraphicsView只不过代码量会明显增加。关键逻辑是选文件时自动读取 DICOM 并渲染def on_list_select(self, item): path item.text() ds dcmread(path) pixel_array ds.pixel_array # 原始图像可能是高像素深度的 uint16需要先做窗宽窗位映射 display_array self.apply_windowing(pixel_array, ds) qimage self.numpy_to_qimage(display_array) self.image_label.setPixmap(QPixmap.fromImage(qimage))3.2 DICOM 像素数据怎么变成屏幕上的灰度图这是整个 demo 里最重要的一步。DICOM 文件里的像素数组不一定是 8 位灰度常见 CT 图像是 16 位有符号整数值域可能从 -1024 到 3000 多如果直接把 numpy 数组转成 8 位 QImage图像细节根本看不见。必须做“窗宽窗位映射”。标准化做法是窗宽 WW窗位 WC 下限 WC - WW/2 上限 WC WW/2 显示值 (像素值 - 下限) / WW * 255再截断到 0~255用代码写就是def apply_windowing(self, pixel_array, ds): pixel_array pixel_array.astype(np.float32) if hasattr(ds, RescaleIntercept) and ds.RescaleIntercept is not None: pixel_array - float(ds.RescaleIntercept) wc getattr(ds, WindowCenter, None) ww getattr(ds, WindowWidth, None) if wc is None or ww is None: wc pixel_array.mean() ww pixel_array.max() - pixel_array.min() if isinstance(wc, pydicom.multival.MultiValue): wc float(wc[0]) if isinstance(ww, pydicom.multival.MultiValue): ww float(ww[0]) low wc - ww / 2 high wc ww / 2 display (pixel_array - low) / (high - low) * 255 display np.clip(display, 0, 255).astype(np.uint8) return display注意 RescaleIntercept 先处理这一步很多初学者容易漏。CT 图像的像素值通常是调整后的 HU 值如果忽略截距窗宽窗位计算会整体偏移图像会发暗或发亮。3.3 窗宽窗位调节的交互实现光有自动窗宽窗位还不够实际阅片的人一定会手动调整。我在图像区域上加了鼠标事件鼠标左键按住拖动调节窗宽和窗位鼠标滚轮缩放图像双击恢复默认窗宽窗位实现思路是记录鼠标按下时的坐标和当前窗宽窗位拖动过程中横向位移改变窗宽纵向位移改变窗位。def mousePressEvent(self, event): self.last_pos event.position() self.origin_ww self.current_ww self.origin_wc self.current_wc def mouseMoveEvent(self, event): pos event.position() dx pos.x() - self.last_pos.x() dy pos.y() - self.last_pos.y() self.current_ww self.origin_ww dx self.current_wc self.origin_wc - dy self.refresh_image()这里用 event.position() 是 PyQt5 5.15 之后推荐的写法老版本用 event.pos() 会收到弃用警告。拖动的灵敏度你可以自己调我习惯把 dx 直接当作窗宽变化值对大多数 CT 序列来说手感还行。4. pynetdicom 网络层把客户端和服务端都跑通4.1 DICOM 通信的几个基本概念pynetdicom 把 DICOM 上层协议封装得很干净但还是需要理解几个核心概念不然调试时看日志会一头雾水。AE Title应用实体标题相当于给当前软件起个名字比如MY_SCU、MY_SCPPACS 一般会做白名单校验。IP 和端口SCP 监听在某个端口上SCU 主动连接。Presentation Context可以理解为“双方商量好用哪种 SOP Class 和哪种传输语法来传输数据”。比如你要发 CT 图像就要支持 CT Image Storage 这个 SOP Class。Association一次完整的连接会话类似于 HTTP 的连接但 DICOM 连接是有状态、有上下文的。对于平时做 Web 开发的人来说DICOM 网络协议更像是一次“远程调用”C-ECHO 就是测连接是否成功类似 ping 但更上层。4.2 客户端发一个 C-ECHO 和 C-STOREC-ECHO 是 DICOM 网络里最基础的请求通常用来验证 AE 之间能否正常连接。客户端代码很简单from pynetdicom import AE ae AE(ae_titlebDEMO_SCU) ae.add_requested_context(1.2.840.10008.5.1.4.1.1.7) # Secondary Capture SOP Class assoc ae.associate(127.0.0.1, 11112, ae_titlebDEMO_SCP) if assoc.is_established: status assoc.send_c_echo() if status and status.Status 0x0000: print(C-ECHO 成功) assoc.release() else: print(关联失败)C-STORE 的写法也差不多在建立关联后调用 send_c_store传入一个 Dataset 对象from pydicom import dcmread ds dcmread(test.dcm) status assoc.send_c_store(ds)如果你写的是通用传输工具不要只注册一个 SOP Class应该把 pynetdicom 里提供的AllStoragePresentationContexts全部加上这样才能接收或发送各种影像类型from pynetdicom import ALL_STORAGE_PRESENTATION_CONTEXTS ae.requested_contexts ALL_STORAGE_PRESENTATION_CONTEXTS4.3 服务端起一个 SCP 接收文件SCP 端更像一个微型 PACS。初始化 AE绑定端口然后配合事件回调函数自动处理存储请求from pynetdicom import AE, evt def handle_store(event): ds event.dataset ds.file_meta event.file_meta save_path f./received/{ds.SOPInstanceUID}.dcm ds.save_as(save_path, write_like_originalFalse) return 0x0000 handlers [(evt.EVT_C_STORE, handle_store)] ae AE(ae_titlebDEMO_SCP) ae.supported_contexts ALL_STORAGE_PRESENTATION_CONTEXTS scp ae.start_server((, 11112), evt_handlershandlers, blockFalse)这里blockFalse很关键如果设成 TrueSCP 会一直阻塞当前线程后面就没法在 Qt 界面里做其他操作了。我后面会专门讲线程问题到底怎么处理。还有个容易忽略的点保存 DICOM 时要把 file_meta 一起保存。如果直接用 dcmread 读过的对象再存会丢失部分元信息常规做法就是ds.file_meta event.file_meta然后再 save_as否则发出去的 DICOM 文件可能被别的系统拒收。5. 网络请求和 Qt 界面结合时最容易踩的三个坑5.1 问题表现一发起网络请求界面就白屏冻结我第一次把 pynetdicom 的关联代码直接塞进 Qt 按钮的点击事件里结果按钮按下去后整个窗口立刻无响应像是卡死了一样。实际上线程还在跑只是所有的 UI 事件都在等网络请求返回如果对端 PACS 地址根本不通几秒到几十秒的超时时间里界面完全没法操作。这不是 Qt 的缺陷而是网络阻塞和 GUI 事件循环天然互斥。PyQt 所有界面操作都在主线程里运行任何耗时操作只要直接放在主线程中执行都会造成界面假死。5.2 用 QThread 和信号槽把网络操作扔到后台线程解决方案很标准用一个 QThread 子线程来跑 pynetdicom 的 assoc.send_c_echo 或 send_c_store完成后通过信号把结果抛回主线程更新界面。from PyQt5.QtCore import QThread, pyqtSignal class NetEchoWorker(QThread): result_ready pyqtSignal(int, str) # (状态码, 描述) def __init__(self, ae, host, port, aes, parentNone): super().__init__(parent) self.ae ae self.host host self.port port self.aes aes def run(self): assoc self.ae.associate( self.host, self.port, ae_titleself.aes.encode(utf-8) ) if not assoc.is_established: self.result_ready.emit(-1, 关联失败) return status assoc.send_c_echo() if status and status.Status 0x0000: self.result_ready.emit(0, C-ECHO 成功) else: self.result_ready.emit(1, fC-ECHO 失败状态码: {status}) assoc.release()在主窗口里创建并启动线程self.worker NetEchoWorker(self.ae, self.host_input.text(), int(self.port_input.text()), self.ae_title_input.text()) self.worker.result_ready.connect(self.on_net_result) self.worker.start()这样写的另一个好处是可以在 worker 里加一个request_interruption标志位用户点击“取消”时安全终止长时间等待的关联过程。5.3 超时、异常和日志处理pynetdicom 底层是 TCP 连接socket 层面有超时但默认超时时间比较长。在实际 demo 里我建议显式设置from pynetdicom import AE import pynetdicom ae AE() ae.network_timeout 10 # 单位秒 ae.acse_timeout 10 ae.dimse_timeout 10另外调试网络服务时强烈建议打开 pynetdicom 的调试日志它会把 ABRT、PDV、Presentation Context 协商这些细节都打印出来from pynetdicom import debug_logger debug_logger()这个日志在处理“为什么对端拒绝关联”“为什么传输语法不匹配”这类问题时特别有用。我第一次联调时就是靠调试日志发现 Presentation Context 没对上导致对面 PACS 根本不知道我要干什么。6. 跑通后还能扩展的方向和几个容易越陷越深的细节6.1 从本地文件扩展成 C-FIND 查询和 C-MOVE 拉取demo 里只做了 C-ECHO 和 C-STORE但在真实场景里更常用的操作是向 PACS 发 C-FIND 查询病人和序列再通过 C-MOVE 从 PACS 拉取数据。C-FIND 的请求结构稍微复杂一些需要先构建一个 Query Dataset里面包含你要匹配的字段query_ds Dataset() query_ds.QueryRetrieveLevel STUDY query_ds.PatientName * query_ds.StudyDate query_ds.StudyInstanceUID 然后通过assoc.send_c_find(query_ds)遍历结果。C-MOVE 还要指定目标 AE Title让 PACS 知道把数据推到哪个接收端。这部分逻辑不难但需要理解 Query/Retrieve 模型建议在和真实 PACS 配合测试前先用本地 SCP 做模拟。6.2 打包成 exe 时要处理的问题如果用 PyInstaller 把这个 demo 打包成 exe 分发给别人需要注意一个细节pynetdicom 内部会动态加载一些 DICOM 数据字典相关文件直接打包可能会落下资源文件导致运行时报“无法定位 meddicom”之类的错误。解决办法是在 PyInstaller 的 spec 文件里把 pynetdicom 包目录下的整个子包都包含进去或者用 pip 安装一个pyinstaller-hooks相关扩展。打包后记得在没装 Python 的干净机器上验一遍。6.3 界面显示 HTML 报告和超链接点击操作的扩展如果 DICOM 文件是结构化报告 SR或者你想在界面下方显示报告说明PyQt5 的 QTextBrowser 可以直接设置 HTML 内容还能捕获用户点击的链接self.text_browser.anchorClicked.connect(self.on_link_clicked)然后在回调里判断链接携带的参数执行自定义操作比如高亮图像区域或打开外部模块。这个扩展点虽然和 DICOM 解析本身无关但做实际工具时经常需要因为影像和报告往往是一起看的。回到这个 demo 本身我最想强调的是三个库之间的配合并不复杂真正的复杂度在于线程模型和协议细节。如果你按照我给的顺序先把环境验证通过再写本地界面最后才碰网络层整个过程不会太痛苦。在我实际跑通这个 demo 之后最直观的体验是以后再遇到同事丢来一个不知道哪台设备导出的 DICOM 文件我再也不用满世界找阅片工具了直接在自研小工具里拖进去就能看。对于做医学影像周边开发的团队来说这套组合确实值得花一个下午跑通。本文还有配套的精品资源点击获取
返回列表