基本架构
pacs_module负责数据拉取的业务逻辑
orthanc负责所有的接收图像工作,和所有find与move。
pacsInterface仅保留pacs的写入操作 store(小众场景)
orthanc提供了很多新的基础能力.
- 接收兼容性强
- find可以实现patient, study, series, image 4个层次的查询
- move可以同步,异步可选.
- move的job持久化, 即使服务重启,也能继续move.
- 通过API可以查询patient, study, series, image层次的属性
这样,我们的很多精力可以用在我们的业务逻辑上,而不是基础能力上.
我们主要要解决下面几个业务场景:
- 如何将医生阅片的CT/DX胸部图像拉取回来?
- 如何更快的将这些图像拉取回来?
- 如何将拉取回来的图像中,分离出需要计算的部分,推送给算法?
- 如何在运维过程中,不丢失图像?
数据完整性设计
拉取完整性是数据拉取模块最重要的职责。
拉取完整性包括:
- 计算的胸部序列应等于pacs当天所有的胸部序列
- 每个胸部序列的图像应与pacs的相同
计算的胸部序列应等于pacs当天所有的胸部序列
过滤筛选胸部序列是完整性最大的难点。
判断是否胸部序列有三个维度: ris: 医生在开单时,会选择检查部位,检查部位会转为医院的代码,然后进入ris。通过ris的检查部位来判断是否胸部检查。这个可信度比较高 pacs:技师在做检查时,技师手动收入检查信息。通过dicom的属性描述来判断是否胸部序列。由于技师操作的不规范性,可信度一般。 医生识别: 手动输入。但是进来的study图像仍然要通过属性筛选出胸部序列。如果医生可以输入胸部的序列号,则可以直接跳过过滤,直接推送后端。 |
过滤阶段有:
- 查询过滤
- 接收过滤
过滤越早发生,收益越高。
查询过滤
Study查询的过滤
Study层次,有用的信息只有ModalitiesInStudy和StudyDescription。
StudyDescription是个不可信的信息,不能采用。(如深圳三院都填为111)
ModalitiesInStudy可以用来过滤模态。
因此,如果只有Pacs,且按Study查询,只能做到模态的过滤。
加入Ris后,可以做到胸部检查的筛选,排除其他部位Study的拉取。
(同时,加入Ris,可以查询到优先级信息,让急诊和绿色通道的检查更快出结果)
异常设计:
厦大附一的Ris信息也不可信,因为技师会将胸部的图像放入腹部的检查中,或将腹部的图像放入胸部的检查中。
因此,加入容错逻辑的配置。当打开时,病人当天的所有检查的检查部位进行连加,如果有胸部的关键字,则通过。
Series查询的过滤
序列级别的find,可以做到胸部序列的判定,但需要pacs的支持(一般品牌越好的Pacs越能做到)
将orthanc的config.py的FIND_PACS_USE_SERIES 设置为True,观察pacs_module的日志以及系统是否正常查询拉取。如果不正常,需要恢复默认的Study层次查询
序列find的过滤条件有:
- 模态(Modality)
- 属性包含胸的关键字(BodyPartExamined, ProtocolName,StudyDescription, SeriesDescription)
- 图像类型(ImageType)
- 薄层(SliceThickness和图像张数)
模态过滤
过去对模态属性的理解有误。导致无法做到模态过滤,出现pacs暴力拉取的问题。
Study层次的模态叫ModalitiesInStudy, Series层次的模态才叫Modality。
过去在Study层次去获取Modality,导致为空,因此出现过滤失效的问题。
由于一个Study会包含多个Series,因此会包含多个模态,ModalitiesInStudy会有CT\PR, MG\CT等的多模态写法。
因此,模态过滤,应该是包含关系,不是等于。
DX模态,除了DX, DR, CR,还有不常见的RF和OT。
胸部过滤
BodyPartExamined为可信度比较高的属性。
如果为胸部,判断为胸部。
如果为空,判断BodyPartExamined, ProtocolName,StudyDescription, SeriesDescription 是否包含胸的关键字。
相比旧版本加入新的关键字 肺(厦大附一发现)
图像类型
过去只针对这种平扫做过滤
ORIGIN, PRIMARY, AXIAL
新增螺旋扫描
ORIGIN, PRIMARY, HELICAL
薄层筛选
过去通过SliceThickness进行薄层的筛选。
现场发现SliceThickness很多为空,或有错误值。
新增按序列图像张数来筛选薄层的逻辑。
另外,还支持增强序列的筛选(小众场景)
如果增强序列配置打开,SeriesDescription不包含增强序列的关键字,则被过滤
接收过滤
接收过滤复用Series查询的过滤逻辑。
由于接收时,可以获取图像级别的dicom信息。因此,相比Series查询,可以增加如下过滤
胸部过滤
相比序列过滤,增加ImageComment属性的判断
如果现场发现胸部序列缺失,可以orthanc日志找到过滤原因。
cat orthanc-python.log | grep $缺失的studyUID| grep filter 查看被过滤的study的上面5个属性内容。
查看是否可以找到独特区分其他部位的关键字。
添加新关键词的案例:
- 贵州302. 胸不是胸。日志里的胸为b'\xc3\x90\xc3\x98' 而正常的胸为:b'\xe8\x83\xb8'。 拷贝日志里的胸,进入配置文件
2. 深圳三院。通过分析,发现个别可以通过STND来区分胸和腹。
卷积核筛选
ConvolutionKernel是一个用来重建图像的算法,可以用来区分肺窗和纵隔窗(厦大附一)
关闭图像必须连续的筛选
1.8.3之前,由于缺乏和Pacs图像张数的比对能力,需要通过这个判断是否完整。
1.8.3关闭。因为如果和Pacs一致,都不连续,说明Pacs本身图像就是不连续的(宝安人民发现一例,中间缺失一张)。
支持多PACS
对于多pacs的场景,可以采用基于orthanc二次开发的find和move。
study从哪个pacs里find到,则move时,会选择这个pacs。
如果多个pacs都包含了同一个study,则move时会随机选择一个pacs进行拉取。
由于深圳三院的pacs是镜像关系,find的内容完全一致,因此,move时会随机选择一个进行拉取。
当其中一个pacs停服时,find只能在另一个pacs find到study,因此,只会去另一个pacs中拉取图像。
其他异常情况
CT的时间和PACS的时间相差8小时,导致查询当天,会缺失8小时的study(TMH)
解决方法:pacs_module的crossday设置为-1。每次find可以同时查询昨天(-1)和今天(0)的检查。
CT机很晚才将个别序列推送到Pacs
默认配置RESEND_ONLY_THICKER_SERIES = True会保证,后续重新拉取study时,如果没有更薄的序列,不传递给后端。
深圳人民存在这个现象,由于都有薄层序列,因此配置只要薄层。早期的study由于只有厚层,不触发计算。
完整性验证
Pacs里按CT和检查部位为胸部可以搜索出CT胸部图像,查看数量和我们拉取到的是否一致。(可以排除刚做的检查,因为可能还没有进入平台)
如果不一致,可以按设备进行过滤,因为每台设备的属性有一定规律(技师操作特点或设备原因)。
缺失的检查一般会集中在某几台设备上,打开来自这些设备的图像,查看其dicom属性,分析为什么被过滤掉,然后针对性解决。
每个胸部图像应与pacs的相同
当采用Study层次查询时,通过NumberOfStudyRelatedInstances获取一个Study的图像张数
当采用Series层次查询时,通过NumberOfSeriesRelatedInstances获取一个Series的图像张数
Pacs图像稳定后再拉取
策略:find返回的图像张数如果和上一次一样,则认为Pacs稳定。
判定稳定后,立刻拉取。
接收到的图像张数需要和Pacs一致
接收完图像后(StableAge时间之内该Study无新进来的图像),会和find返回的图像张数进行比对,小于Pacs里的图像张数,则会触发重新拉取。
多次重新拉取也无法和Pacs一致,也会将图像传递给后端。
及时性设计
拉取及时性
Study稳定即拉取
通过周期查询pacs里study的图像数量NumberOfStudyRelatedInstances,如果查询到的图像数量和上一次相等,则认为图像是稳定的,进行下一步的拉取操作。
这个很好的解决过去简单通过固定延时后再去拉取的不严谨行为。
DX采用区别CT的拉取逻辑
DX图像少,查询到即拉取。如果图像张数不完整,拉取到为非正位片,会被判定为序列不支持,下个周期判断study图像增加,会继续拉取。
减少无用图像的拉取
在find阶段加入过滤,减少无用图像的拉取,让真正需要的图像更快的进入系统。
Move采用同步方式
新增同步和异步move可选,默认同步。
过去采用异步方式进行拉取,虽然可以利用多线程的原理提高下载速度,但会出现如下问题。
同时下载多例,导致早期没有数据进入算法模块,资源空置。后续会出现多例集中下载完成,同时进入算法模块导致多并发计算的问题。
采用同步拉取,可以让整个系统持续不断得进行计算,整体及时性更强。(缺点:如果某例下载时间很长,会出现后面图像阻塞的问题)
Move采用优先级和检查时间进行排序
加入优先级和检查时间的排序,让优先级高的图像先拉取,相同优先级的,先拉取检查时间最早的。
医生手动触发的拉取,优先级高于自动拉取的图像。
接收及时性
序列的过滤优化
序列的过滤,仅判断序列第一张图像,改进了官方示例中每张图像都判断的逻辑。
优化删除逻辑
orthanc的config.py的DELETE_AFTER_SEND = True
可以在想pacs_module传递完图像之后,对orthanc的图像进行删除操作。
这个行为可以节省磁盘和减少orthanc的数据库搜索时间。
同时,可以使用内存文件系统提高速度。(也可以使用固态硬盘的文件夹)
另外,删除逻辑改进为整个study进行删除,解决官方示例中的按instance级别删除的缓慢问题。
传递完后不删除还有如下弊端,虽然我们配置了orthanc的最大存储空间为500GB。
当orthanc达到这个阈值时,会先清理再接收(这个动作是同步行为),导致减缓了接收及时性。(我们的删除是异步删除,不阻塞新进来的图像)。
内存文件系统
拉取后,为了提高接收速度,采用了内存文件系统技术。
orthanc接收慢的主要原因在于每张图像都要解析,瓶颈为IO速度。
orthanc的docker-compose.yml有如下描述:
tmpfs:
- /ram-disk
可以将物理机的tmpfs挂载到docker内部,让docker内部的进程可以使用内存文件系统。
orthanc.json里将文件存储和索引存储路径改为内存文件系统。
"StorageDirectory" : "/ram-disk/db",
"IndexDirectory" : "/ram-disk/db",
为了解决内存占用问题,orthanc在发送给pacs_module之后,必须进行删除操作,否则会导致内存爆。
使用内存文件系统的场景,一定要保证DELETE_AFTER_SEND = True
为了进一步保证内存不因为软件原因出现持续增长,可以设置orthanc.json里的"MaximumStorageSize" : 5000,让内存限制在5G以内。
重新拉取的策略可以大胆降低orthanc的接收稳定时间
orthanc的默认接收稳定时间(StableAge)为60秒。
当设置为30秒,在高峰时段会出现个别检查的图像接收不全。
当具备重新拉取的功能后,个别不全的检查可以重新拉取,因此可以放心的设置为30秒。
整体及时性
图像的压缩后置
原来图像在拉取完成之后,立刻进行压缩操作。
现将这步骤后置到计算完成之后再进行。
压缩后置,可以节省后端和算法端的反解压的时间。
(匿名仍然保留在数据拉取完成之后进行)
只传递需要计算的序列
过去数据拉取模块会将整个study传递给后端,现在将后端的过滤逻辑前置,只传递需要计算的序列。
减少无用序列对磁盘空间,IO,CPU的消耗。
传递给后端时加入优先级
使用了rabbitmq的优先级能力,缓存在MQ里的消息,会采用优先级进行排序,解决过去只能先来先到的发送逻辑。
异常设计
阶段1:如何保证pacs_module发向orthanc的请求成功?
- find是周期查询,丢失可忽略
- move失败的,下个周期会继续拉取(有次数限制,目前仅支持同步move的成功与失败的判断)
- move成功,但是还没有收到数据,如果pacs_module发生重启,会重新move (采用同步方式,可以减少pull_data状态的study)
阶段2:如何保证orthanc正在拉取的数据不丢失?
- orthanc正在move,job会记录下来,重启后会继续move的job
阶段3:如何保证pacs的数据完整传送到orthanc?
- 采用同步move,move成功就代表数据已经传到orthanc
- 如果接收不完整,有重新拉取逻辑保障
- 重新拉取的等待间隔发生重启:接收不完整,会有completeness的状态位记录。重启后,会查询数据库继续重拉当天不完整的study
阶段4:orthanc接收完成后,并在发送到pacs_module之前发生重启,如何保证不丢失?
- 产生stable事件后,记录在数据库中,如果软件发生重启,查询stable事件未发送成功的,继续执行
阶段5:如何保证pacs_module向MQ发送消息不丢失?
- 发送给mq如果失败,有重试机制
阶段6:如何保证MQ向后端推送请求不丢失?
- 从mq发送给后端,如果软件重启,会重发后端还没有返回结果的请求
- mq发送给后端,如果失败,会重试发送(有次数限制)
- 采用线程池,如果发送请求的线程死亡,会重启线程
- 和mq的通信断,会重新连接后再发送
兼容设计
- 查询逻辑支持新的稳定判断逻辑,也支持传统的固定延时逻辑。
- 拉取逻辑支持高效的Series级别拉取,也支持更通用的Study级别拉取。
- 数据拉取模块具有很多的功能开关,让不同场景可以选择不同的功能。对于不稳定的功能,默认关闭,可以通过开关打开进行开发测试。
现场调试经验
为什么有个检查在pacs有,我们没有?
前端只能显示后端收到的检查,如果前端查不到,有如下可能:
没有find到
find到但是拉取失败
拉取到但是被过滤了
查看orthanc-python.log日志,这个检查是否被过滤,grep orthanc-python.log | grep filter | grep AccessionNumber/PatientID/StudyInstanceUID/SeriesInstanceUID
如果整个study的所有序列都被过滤,会有study is filtered out的提示。这时,代表这个检查所有序列都被过滤了,所以传递不到后方。
我们前端全部的总数和pacs不一致怎么办?
有3种可能:
- 数据还在find,move和内部排队中。——进入数据库搜索db.study_info_list.find({"studyDate": "2020-06-12", "state": {$exists: true}, "modality": "CT"}).count()
- pacs里总数是可能有重复的——深圳xx就有重复的检查号,例如,相同的检查号,1个为胸部平扫,1个为胸部三维重建。需要导出excel表去重查看
- 漏拉了
数据库查询
pacs查询/拉取的信息都在study_info_list的表中
state是一个非常重要的变量,可以知道检查处于什么状态量中
- meta_only: find到的目标对象
- pull_data: 拉取中
- pull_data_end: 拉取完成(pacs_module收到检查)
- unsupported_series: 序列不支持( 数据模块和后端如果没有找到有用的序列, 都会设置这里为序列不支持)
- completed: 计算完成
db.study_info_list.find({"studyDate": "2020-06-12"}) 可以查看到当天的情况
db.study_info_list.find({"studyDate": "2020-06-12", "state": "meta_only"}) 查看已经查找到的目标检查
db.study_info_list.find({"studyDate": "2020-06-12", "state": "pull_data"}) 正在拉取中的数据
db.study_info_list.find({"studyDate": "2020-06-12", "state": "pull_data_end"}) 拉取完成,pacs_module已经收到orthanc发过来的图像
db.study_info_list.find({"studyDate": "2020-06-12", "state": "unsupported_series"}) 序列不支持的检查
db.study_info_list.find({"studyDate": "2020-06-12", "state": "completed"}) 计算完成的检查
日志查看
如何查看orthanc-python.log
move成功,会有如下内容:
move: 1.2.840.78.75.7.5.307686.0996885.0717.1548586006 from HOSPITAL-PACS takes: 29.670351599808782
一个检查的第一张图像刚进来时, 会有如下内容:
new study: 1.2.392.200036.9116.2.5.1.3268.2060439916.1546394176.0680776.0717.899320
一个检查接收完,并等待了稳定时间之后:
stable study: 1.2.392.200036.9116.2.5.1.3268.2060439916.1546394176.0680776.0717.899320
开始触发我们的业务逻辑:
序列筛选
检查部位不是胸部被过滤
10939060 NA200618FLC029 1.2.840.113619.186.12418169119.20200618143936908.464 1.3.12.2.1107.5.3.49.22605.2.202006181629280296 filter because BodyPartExamined HAND
图像属性里找不到胸部的关键字被过滤
10892557 NA200617GECT060 1.2.840.113619.2.340.3.2831218256.616.1591958208.608 1.2.840.113619.2.340.3.2831218256.616.1591958208.613.4 filter because BodyPartExamined: ProtocolName:1.1 HEAD StudyDescription:HEAD SeriesDescription:SKULL 1.25MM ImageComments:
层厚太厚被过滤
10932135 ZH2006181CT085 1.2.840.113619.186.12418169119.20200618133401811.272 1.3.12.2.1107.5.1.4.75902.30000020061614400452200341568 filter because SliceThickness 8
模态不支持被过滤
10526392 NA200616RF4028 1.2.840.113619.186.12418169119.20200616152856396.161 1.3.12.2.1107.5.3.33.4445.2.202006181015450663 filter because Modality RF
CT序列的张数太少(用于过滤定位片)
10769621 NA200611EVCT058 1.2.840.113619.186.12418169119.20200611105931123.685 1.2.840.113619.2.428.3.2831218000.55.1592034845.350 filter because instance nums 1 < 10
整个检查都没有合适的序列, 整个检查被过滤
10938755 ME200618CT1010 1.2.840.113619.186.12418169119.20200618083017311.144 study is filtered out
检查开始筛选
study 1.2.392.200036.9116.2.5.1.3268.2060439916.1546394176.0680776.0717.899320 thickest series is 1.2.392.200036.9116.2.5.1.3268.2060439916.1546394383.0680776.0717.485292, instances num is 1147
这个检查从哪里来的
where study from 1.2.392.200036.9116.2.5.1.3268.2060439916.1546394176.0680776.0717.899320 GEPACS 192.168.1.150
图像完整性检查
completeness check success: series 1.2.840.113619.2.428.3.2831218000.623.1592390818.147.5 in pacs is 296, we have 296 too
再次传递到后方的原因(找到更薄的序列)
reinform 1.2.392.200036.9116.2.5.1.3268.2060439916.1546394176.0680776.0717.899320 last is 588;now 20200618T063027 1.2.392.200036.9116.2.5.1.3268.2060439916.1546394383.0680776.0717.485292 is 1147
不传递到后方的原因(没有找到更薄的序列)
notinform 1.2.840.113619.2.416.1018343.0717.1202946444748180619271649352699685862 has no more instances this time
传递给pacs_module
inform pacs_module {'studyUID': '1.2.840.113820.104.6.893261.0717.120181207091202370', 'seriesUID': '1.2.392.200036.9116.2.5.1.3268.2060439916.1544143543.893261.0717.321161', 'numberReceived': 1041, 'statecode': 800, 'message': '', 'filepath': '/store_image/4c18cd69-ecee1be6-10a1dcd4-73308186-275b913f', 'callingAPTitle': 'BATCH1', 'callingPresentationAddress': '192.168.1.224', 'seriesnum': 1, 'totalRecievedtime': '', 'priorities': 4, 'isPush': 1, 'lastUpdateTime': '20200618T080626'}
如何查看dcm_end.log
find 2020-06-18 study list
orthanc find query is {'StudyDate': '20200618'}
从pacs那查到839条检查(不是都会被采纳)
find 2020-06-18 study list from pacs is 839
新发现了9条
study not in local db is 9
经过模态过滤之后,还剩9条
study from pacs after modality filter left 9
实际新增9条
actually add new number: 9
检查被判断为稳定
patientID 10939048 accessionNumber NA200618EVCT077 studyuid 1.2.840.113619.186.12418169119.20200618132708201.630 is stable: instances num: 321
新增10条稳定的检查(判断稳定之后就准备去拉取)
num: stable study 10 finded
进入拉取, 准备要拉取10条
move data in ['2020-06-18']
send move study. amount is 10
continue to pull study, amount=10
拉取成功1条(如果失败, 这里为空列表)
move success: [['1.2.840.113619.2.340.3.2831218256.616.1591958247.853.4', 'HOSPITAL-PACS']]
pacs_module收到orthanc传递过来的检查
after receive notify {'studyUID': '1.2.840.113619.2.340.3.2831218256.616.1591958247.848', 'seriesUID': '1.2.840.113619.2.340.3.2831218256.616.1591958247.853.4', 'numberReceived': 294, 'statecode': 800, 'message': '', 'filepath': '/store_image/0b60c370-27100af8-f3d95718-3e094a6c-e257062e', 'callingAPTitle': 'GEPACS', 'callingPresentationAddress': '192.168.1.150', 'seriesnum': 1, 'totalRecievedtime': '', 'priorities': 0, 'isPush': 0, 'lastUpdateTime': '20200618T082032'}
成功发送到rabbitmq
send 1.2.840.113619.2.340.3.2831218256.616.1591958247.848 to rabbitmq success
负责发送给后端的线程收到了rabbitmq推送的检查消息
send_to_backend 140391514785536 get message {'studyUID': '1.2.840.113619.186.12418169119.20200618103856828.571'}
传递给后端的文件夹里有219张图像
upload_image_path file_path /store_image/74f85fdc-8de0b009-f592a9a0-e7b17eed-c73169cb has file number is 219
算法计算完成
upload_image_path file_path ----/store_image/0b60c370-27100af8-f3d95718-3e094a6c-e257062e----success
这个检查是重新计算
1.2.840.113619.2.340.3.2831218256.616.1591958248.428 exists, last isloading is 2, we receive a new one, calc again
插件手动查询拉取
orthanc find query is {'AccessionNumber': '10136791', 'StudyDate': ''}
orthanc find query is {'PatientID': '10136791', 'StudyDate': ''}
pull data manually. study_uid=1.2.840.113619.186.808617810995.20120428093140339.266, study_date=2012-04-28
continue to pull study, amount=1
orthanc move failed!
查看医生通过插件手动输入
医生会手动输入,代表阅片时没有结果,不是及时性问题就是完整性问题.
dcm_end.log搜索manual_find_move, 找到检查号或病人号
API
采用/api/v1/dcm/debug数据信息和状态
采用/api/v1/dcm/debug接口可以查询, 支持AccessionNumber和StudyInstanceUID
浏览器方式:
$IP/api/v1/dcm/debug?AccessionNumber=xxxxx
shell方式:
docker-compose exec pacs http :7000/api/v1/dcm/debug?AccessionNumber=xxxxx
附录
Pacs对接升级历史
深圳XX:新稳定拉取逻辑,基于内存文件系统的拉取
宝安XX:基于study的重新拉取
深圳XX:多pacs的查询与拉取
厦大XX:pacs数据错误的容错设计
中山XX:基于胸部序列的查询与拉取
贵州XX:见识山寨pacs (胸不是胸,find数量限制,find不响应,studyuid为空,studyuid包含非法空格,图像张数不可获取等)
不可信数据
AccessionNumber在多个医院存在不可信,检查的AccessionNumber与内部图像的dicom属性不一致。(厦大XX和深圳XX)
厦大附一的插件查询,应该改为PatientID级别,因为存在一个AccessionNumber的图像放到另一个AccessionNumber里的问题。
ris信息晚于pacs的处理
正常情况下,是ris先有检查信息,pacs再有图像信息。
深圳人民和厦大附一都出现过,pacs里已经有检查信息,但ris里缺没有。
这个场景为病人先做检查,再补开单信息。
这个场景一般为比较紧急,但是我们的逻辑是必须ris里有,流程才能进行。我们只能满足完整性,可能无法满足及时性。
CT和DX应采用不同逻辑进行处理
- 过滤不一样. DX缺少了很多信息,不能采用和CT一样的过滤逻辑. 过滤环节一定要区分模态
- CT可以只传递一个计算序列, 但是DX不能. 因为有的DX的正位片和侧位片存放在不同序列, 而DX的正位片和侧位片区分只能靠算法判断. 因此,必须将整个DX的study传递给后方.即使配置按序列拉取, DX也必须按study层面去拉取.传递给后方时,不能只传递一个序列,而是整个study
入门文档
https://book.orthanc-server.com/dicom-guide.html