Day 6 实现扫描业务类
Day 6 实现扫描业务类ScanTask
接下来要实现扫描业务。这将是软件比较复杂的一部分,我们将先抛弃界面部分,集中于业务管理。扫描活动是一个耗时操作,为了避免界面阻塞,我们需要在单独的线程中运行它。另外,它本身也会有很多需要并发执行的任务,也将会包含诸多的工作线程。本章开始我们的工作重点将是各种线程模式的使用。在多线程中运行ScanTask
将放在界面部分讨论,我们重点关注ScanTask
内部的多线程机制。
ScanTask概述
当设备可用后,我们就可以进行扫描活动了。我们先看一下扫描的过程:
简单的来说,一张玻片样本的扫描步骤由下面几步组成:
1 | flowchart LR |
进片:扫描仪移动载物台,将载物台移动到预览相机下面,拍摄样本全局图和标签图。这一步是由驱动的
GetSlidePreview()
实现的。计算低倍扫描区域:软件对拍摄到的预览图做分析,根据玻片类型和扫描要求寻找出需要进行低倍扫描的区域来。比如,我们在第5章中看到的一张骨髓的涂片,它的最左边太厚,扫描完全没有意义,最右边是空白区域,扫不到区域。我们需要对它做分析,找出适合扫描的位置来。对机推的外周血,推片质量很高,基本上固定一个区域就可以了。而对于手推的片子,尤其是骨髓,涂片形状会各种各样,就必须根据样本的图像来做识别。因为涉及到机密,具体的识别算法在本书中不会讨论。
另一方面,即使是低倍镜的扫描,数据量也是十分巨大的。如果对整个玻片做扫描,大约是900-1000张照片,数据量在1.8G~2G之间,而对于正常的骨髓玻片,200个细胞大多数情况下1个十倍视野就能凑齐了,因此,选择位置适当,大小适当的区域做低倍扫描,不管是时间还是空间上都有明显的价值。
低倍扫描:低倍扫描的含义比较简单,就是对预览图上指定的一个范围做连续扫描,并得到照片。
计算高倍扫描区域:对低倍扫描的图像,软件需要做分析,根据扫描得到的图像寻找最适合做高倍扫描的区域。前面我们曾经提到过什么是适合的区域:要在玻片头-体-尾的尾部,不能太靠后,细胞不能太重叠,等等。不同的样本会有不同的计算策略。外周血有白细胞,红细胞,骨髓有白细胞,巨核细胞,核型有染色体等等。扫描方式还有连续扫描和非连续扫描等。
执行高倍扫描:根据计算出来的高倍扫描区域,控制扫描仪做扫描。高倍扫描有两种模式:连续扫描和非连续扫描。连续扫描的意思是对一个矩形区域做扫描,每一张照片都会和上一张有一定的重叠,我们最终需要做拼接,逻辑上拼接成一张完整的图片,再其裁减成固定大小的图片。而非连续扫描就是给定若干个位置,每个位置拍摄一张照片。这些照片并不连续,也不需要做特殊处理。高倍扫描我们可能会做多次。例如,对于外周血,我们可能会先扫描若干个非连续的白细胞,然后会对某一个区域做连续红细胞的连续扫描;对骨髓,我们可能会扫描若干个白细胞的连续区域,然后又扫描若干个非连续的巨核细胞照片。等等。
对十倍图我们无需做拼接,实际的意思是在硬件出厂时已经做过了校正,使得每两张图片都恰好相邻,因此就不需要做拼接了。
- 退片:完成全部扫描后,或扫描过程中出现了错误,都会退片。即将载物台移动到上片位置,等待操作人员或送片机将扫描完成的玻片拿走,并放上新的玻片。
我们定义一个类ScanTask,用于实现扫描的业务处理,它对应于一个玻片的一次扫描活动。ScanTask的上下文关系如下所示:
1 | --- |
ScanTask
相关的类包括:
- IConfiger:参数配置类
- IPreviewImageAnalyzer: 预览图分析
- ILowAnalyzer: 低倍图分析和选区
- ICellFinder: 低倍图中寻找细胞
- IHighAnalyzer:高倍图分析
- PackingWorker: 数据打包归档
- IImageJoiner: 高倍图拼接
- DataMgr:数据包管理和上传
接下来的几天时间中,我们会一步步完成这些类。
定义ScanTask
骨架
首先定义ScanTask
的骨架代码。我们在FrameWorks
中增加类ScanTask
。
在我们的设计种,ScanTask
将会运行在一个单独的工作线程中,它需要通过signal-slot
机制和控制端(GUI界面)通信。我们需要定义几个signal
分别用于向外发布它的工作状态变化:
sigScanFinish()
:扫描仪使用结束了。当使用完扫描仪后会发送此信号给控制端,控制端可以启动一个新的扫描任务,而本任务会转向后台运行。sigScanComplete()
:完成了数据的切割处理打包等工作。sigPreviewed()
:拍摄了预览图并处理完毕,控制端可以在界面上显示预览图。sigLowScanRanged()
:低倍扫描在预览图上的范围已经确定,控制端可以在界面上的预览图中画出扫描范围的示意。sigScanStart()
:当启动扫描活动后给控制端发送此信号,返回要扫描的视野个数信息sigImageCaptured()
:拍摄了一张照片后给控制端发送此信号,控制端可以更新扫描进度,显示拍摄的图片
一个公共接口,用于控制端启动扫描活动。
doScan()
:
接下来考虑doScan()
的实现。按照扫描的活动来划分工作,定义如下几个方法:
doPrepare()
:测试前的环境准备工作doPreview()
:执行预览图扫描相关活动doLowScan()
:执行低倍扫描相关活动doHighScan()
:执行高倍扫描相关活动。因为可能存在多种高倍扫描活动,这个方法就会被多次调用。
上述这些活动一定是串行执行的,我们将数据保存在类属性中,避免在这些函数之间传递太多的参数。
这样,得到的类骨架如下所示。我们会随着开发工作的进展,逐步细化,添加其他的内容。
1 | class FRAMEWORKS_EXPORT ScanTask : public QObject |
接下来我们要创建ScanTask
的单元测试项目。在UnitTest
下面创建一个Qt Test Project
,命名为UnitTest_Frameworks_ScanTask
,并指定测试类名为ScanTaskTester
。然后将其作为ScanTask
的friend类添加到ScanTask
里面。
实现准备工作doPrepare()
我们要实现的第一个函数是doPrepare()
,它完成扫描前的准备工作。在我们的规划中,doScan()
由外部调用,通过参数params
传入一些额外的控制参数,在doPrepare()
中会将它们合并到类的属性中。因为由两种扫描方式——单张扫描和批量扫描,这个参数会通过params
进来。此外,还需要判断是否需要人工干预选区等行为——它应该是一个配置参数,我们会在IConfiger
中添加一个配置项等。
此外,在这里我们需要构造构造本次扫描的标识,准备数据存储的目录,读取配置数据等。
1 | bool ScanTask::doPrepare(const QVariantHash ¶ms) |
这里,我们涉及到两个新的配置项:ManualMode()
,指明是否需要在扫描过程中手工调整自动计算的结果,DataPath()
,返回数据存储的根目录。我们可以看一下Configer
中使用DEF_PROPERTY2
实现的这两个配置参数:
1 | class FRAMEWORKS_EXPORT Configer : public IConfiger |
另外,doPrepare()
还有一项更重要的活动,是配置signal-slot
的关系。我们会在后面逐步讨论。
测试doPrepare()
接下来我们编写一下doPrepare()
的单元测试。因为TaskScan
的内部参数我们使用了pImpl
模式来实现,即使是友元也无法访问cpp
中的内容,所以我们不得不为属性提供访问接口。而且我们也无论如何都没有办法简化代码了,只能老老实实手工编写。所以我们只会提供slideId
和dataPath
两个访问接口。
另外还有一个问题,因为我们使用精确到秒的扫描时间作为扫描标识,并用来创建目录,如果两次扫描的时间间隔少于1秒,就会出现两个扫描是同一个目录的情况。这种情况在实际中是不会出现的,但是在单元测试中却无法避免。为此,我们让每个测试函数都先sleep
1秒钟以规避这个问题。
我们编写了两个测试函数,分别测试在params
里面传入和未传入batch
参数的情况,并检查是否创建了对应的数据目录。
1 | void ScanTaskTester::test_doPrepare_batch() |
我们还发现这两个测试函数前面的行为都是相同的。当测试函数多了,不管是写代码还是维护修改都是很麻烦的事情。本来QTEST
框架还提供了init()
和cleanup()
函数,定义了之后,会让每个测试函数在执行前先执行init()
,在执行后执行cleanup()
。但是它们的问题在于,首先它们无法满足我们为每个用例创建task
实例的目的,二来这两个函数被定义了,它们不会收到QSKIP
的影响。这都不是我们想要的。最终,我们还是只能用宏来解决。定义一个宏:
1 |
然后测试函数就可以改写为:
1 | void ScanTaskTester::test_doPrepare_batch() |
到目前为止,doPrepare()
的工作就暂时告一段落了。以后,随着开发的进展,我们还会继续增加里面的内容。