Day 6 实现扫描业务类ScanTask

接下来要实现扫描业务。这将是软件比较复杂的一部分,我们将先抛弃界面部分,集中于业务管理。扫描活动是一个耗时操作,为了避免界面阻塞,我们需要在单独的线程中运行它。另外,它本身也会有很多需要并发执行的任务,也将会包含诸多的工作线程。本章开始我们的工作重点将是各种线程模式的使用。在多线程中运行ScanTask将放在界面部分讨论,我们重点关注ScanTask内部的多线程机制。

ScanTask概述

当设备可用后,我们就可以进行扫描活动了。我们先看一下扫描的过程:

简单的来说,一张玻片样本的扫描步骤由下面几步组成:

1
2
flowchart LR
进片 --> 拍摄预览图 --> 计算低倍扫描区域 --> 低倍扫描 --> 计算高倍扫描区域 --> 高倍扫描 --> 高倍扫描 --> 退片
  • 进片:扫描仪移动载物台,将载物台移动到预览相机下面,拍摄样本全局图和标签图。这一步是由驱动的GetSlidePreview()实现的。

  • 计算低倍扫描区域:软件对拍摄到的预览图做分析,根据玻片类型和扫描要求寻找出需要进行低倍扫描的区域来。比如,我们在第5章中看到的一张骨髓的涂片,它的最左边太厚,扫描完全没有意义,最右边是空白区域,扫不到区域。我们需要对它做分析,找出适合扫描的位置来。对机推的外周血,推片质量很高,基本上固定一个区域就可以了。而对于手推的片子,尤其是骨髓,涂片形状会各种各样,就必须根据样本的图像来做识别。因为涉及到机密,具体的识别算法在本书中不会讨论。

另一方面,即使是低倍镜的扫描,数据量也是十分巨大的。如果对整个玻片做扫描,大约是900-1000张照片,数据量在1.8G~2G之间,而对于正常的骨髓玻片,200个细胞大多数情况下1个十倍视野就能凑齐了,因此,选择位置适当,大小适当的区域做低倍扫描,不管是时间还是空间上都有明显的价值。

  • 低倍扫描:低倍扫描的含义比较简单,就是对预览图上指定的一个范围做连续扫描,并得到照片。

  • 计算高倍扫描区域:对低倍扫描的图像,软件需要做分析,根据扫描得到的图像寻找最适合做高倍扫描的区域。前面我们曾经提到过什么是适合的区域:要在玻片头-体-尾的尾部,不能太靠后,细胞不能太重叠,等等。不同的样本会有不同的计算策略。外周血有白细胞,红细胞,骨髓有白细胞,巨核细胞,核型有染色体等等。扫描方式还有连续扫描和非连续扫描等。

  • 执行高倍扫描:根据计算出来的高倍扫描区域,控制扫描仪做扫描。高倍扫描有两种模式:连续扫描和非连续扫描。连续扫描的意思是对一个矩形区域做扫描,每一张照片都会和上一张有一定的重叠,我们最终需要做拼接,逻辑上拼接成一张完整的图片,再其裁减成固定大小的图片。而非连续扫描就是给定若干个位置,每个位置拍摄一张照片。这些照片并不连续,也不需要做特殊处理。高倍扫描我们可能会做多次。例如,对于外周血,我们可能会先扫描若干个非连续的白细胞,然后会对某一个区域做连续红细胞的连续扫描;对骨髓,我们可能会扫描若干个白细胞的连续区域,然后又扫描若干个非连续的巨核细胞照片。等等。

对十倍图我们无需做拼接,实际的意思是在硬件出厂时已经做过了校正,使得每两张图片都恰好相邻,因此就不需要做拼接了。

  • 退片:完成全部扫描后,或扫描过程中出现了错误,都会退片。即将载物台移动到上片位置,等待操作人员或送片机将扫描完成的玻片拿走,并放上新的玻片。

我们定义一个类ScanTask,用于实现扫描的业务处理,它对应于一个玻片的一次扫描活动。ScanTask的上下文关系如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
---
title: ScanTask上下文关系
---
classDiagram
class App
class ScanTask
class MainWindow
class IDevice
class IConfiger

App *-- MainWindow
MainWindow o-- "*" ScanTask
MainWindow --> "1" IDevice
ScanTask --> "1" IDevice
ScanTask o-- IPreviewImageAnalyzer
ScanTask o-- ILowAnalyzer
ILowAnalyzer o-- ICellFinder
ScanTask o-- PackingWorker
ScanTask o-- IImageJoiner
ScanTask o-- IHighAnalyzer
ScanTask o-- DataMgr

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class FRAMEWORKS_EXPORT ScanTask : public QObject
{
Q_OBJECT
public:
explicit ScanTask(IConfiger*configer, IScanDevice* scaner, QObject *parent = nullptr);
~ScanTask();

public slots:
void doScan(const QVariantHash& params);

signals:
void sigScanFinish();
void sigScanComplete();
void sigPreviewed(cv::Mat prev_image, cv::Mat label_image, const QString& sample_id, int sample_type);
void sigLowScanRanged(QRect rect);
void sigScanStart(int columns, int rows, int method);
void sigImageCaptured(const QPoint* pos, cv::Mat image);

private:
bool doPrepare(const QVariantHash& params);
bool doPreview();
bool doLowScan();
bool doHighScan();
bool complete();
private:
struct Implementation;
QScopedPointer<Implementation> _impl;
};

接下来我们要创建ScanTask的单元测试项目。在UnitTest下面创建一个Qt Test Project,命名为UnitTest_Frameworks_ScanTask,并指定测试类名为ScanTaskTester。然后将其作为ScanTask的friend类添加到ScanTask里面。

实现准备工作doPrepare()

我们要实现的第一个函数是doPrepare(),它完成扫描前的准备工作。在我们的规划中,doScan()由外部调用,通过参数params传入一些额外的控制参数,在doPrepare()中会将它们合并到类的属性中。因为由两种扫描方式——单张扫描和批量扫描,这个参数会通过params进来。此外,还需要判断是否需要人工干预选区等行为——它应该是一个配置参数,我们会在IConfiger中添加一个配置项等。

此外,在这里我们需要构造构造本次扫描的标识,准备数据存储的目录,读取配置数据等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool ScanTask::doPrepare(const QVariantHash &params)
{
_impl->_batchScan = params.value("batch", false).toBool();
_impl->_manualMode = _impl->_configer->ManualMode();
_impl->_scanTime = QDateTime::currentDateTime();
_impl->_slideId = _impl->_scanTime.toString("yyyy-MM-dd_hh-mm-ss");
_impl->_basePath = QString("%1/%2")
.arg(_impl->_configer->DataPath())
.arg(_impl->_slideId);
QDir().mkpath(_impl->_basePath);
// 从设备获取其他的属性...
_impl->_imageSize = _impl->_device->imageSize();
_impl->_lowZoom = _impl->_device->lowZoom();
_impl->_highZoom = _impl->_device->highZoom();
...
return true;
}

这里,我们涉及到两个新的配置项:ManualMode(),指明是否需要在扫描过程中手工调整自动计算的结果,DataPath(),返回数据存储的根目录。我们可以看一下Configer中使用DEF_PROPERTY2实现的这两个配置参数:

1
2
3
4
5
6
7
8
class FRAMEWORKS_EXPORT Configer : public IConfiger
{
public:
...
DEF_PROPERTY2(DataPath, "data-path",QString, QString("%1/data").arg(QCoreApplication::applicationDirPath()) );
DEF_PROPERTY2(ManualMode, "manual-mode", bool, false);
...
};

另外,doPrepare()还有一项更重要的活动,是配置signal-slot的关系。我们会在后面逐步讨论。

测试doPrepare()

接下来我们编写一下doPrepare()的单元测试。因为TaskScan的内部参数我们使用了pImpl模式来实现,即使是友元也无法访问cpp中的内容,所以我们不得不为属性提供访问接口。而且我们也无论如何都没有办法简化代码了,只能老老实实手工编写。所以我们只会提供slideIddataPath两个访问接口。

另外还有一个问题,因为我们使用精确到秒的扫描时间作为扫描标识,并用来创建目录,如果两次扫描的时间间隔少于1秒,就会出现两个扫描是同一个目录的情况。这种情况在实际中是不会出现的,但是在单元测试中却无法避免。为此,我们让每个测试函数都先sleep1秒钟以规避这个问题。

我们编写了两个测试函数,分别测试在params里面传入和未传入batch参数的情况,并检查是否创建了对应的数据目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void ScanTaskTester::test_doPrepare_batch()
{
QThread::sleep(1);
auto configer = AlgorighmFactory::makeConfiger("config.ini");
auto scaner = AlgorighmFactory::makeScanDevice();
ScanTask task{configer.get(), scaner.get()};

task.doPrepare({{"batch", true}});
QCOMPARE(task.isBatch(), true);
auto slideId = task.slideId();
QCOMPARE_EQ( QDir().exists(QString("%1/%2").arg(configer->DataPath()).arg(slideId)), true);
}

void ScanTaskTester::test_doPrepare_nobatch()
{
QThread::sleep(1);
auto configer = AlgorighmFactory::makeConfiger("config.ini");
auto scaner = AlgorighmFactory::makeScanDevice();
ScanTask task{configer.get(), scaner.get()};

task.doPrepare({});
QCOMPARE(task.isBatch(), false);
auto slideId = task.slideId();
QCOMPARE_EQ( QDir().exists(QString("%1/%2").arg(configer->DataPath()).arg(slideId)), true);
}

我们还发现这两个测试函数前面的行为都是相同的。当测试函数多了,不管是写代码还是维护修改都是很麻烦的事情。本来QTEST框架还提供了init()cleanup()函数,定义了之后,会让每个测试函数在执行前先执行init(),在执行后执行cleanup()。但是它们的问题在于,首先它们无法满足我们为每个用例创建task实例的目的,二来这两个函数被定义了,它们不会收到QSKIP的影响。这都不是我们想要的。最终,我们还是只能用宏来解决。定义一个宏:

1
2
3
4
5
#define SCAN_PREPARE()                      \
QThread::sleep(1); \
auto configer = AlgorighmFactory::makeConfiger("config.ini"); \
auto scaner = AlgorighmFactory::makeScanDevice(); \
ScanTask task{configer.get(), scaner.get()};

然后测试函数就可以改写为:

1
2
3
4
5
6
7
8
9
10
void ScanTaskTester::test_doPrepare_batch()
{
//QSKIP("skip test with bath parameters");
SCAN_PREPARE()

task.doPrepare({{"batch", true}});
QCOMPARE(task.isBatch(), true);
auto slideId = task.slideId();
QCOMPARE_EQ( QDir().exists(QString("%1/%2").arg(configer->DataPath()).arg(slideId)), true);
}

到目前为止,doPrepare()的工作就暂时告一段落了。以后,随着开发的进展,我们还会继续增加里面的内容。