实现PackingWorker

设计PackingWorker

接下来是数据打包功能。数据打包功能类PackingWorker,我们封装了对玻片索引数据库的操作和对zip归档文件的操作。这个类的生命周期应该是贯穿玻片扫描的全过程的,它应该是在doPrepare()的时候被创建,在每个扫描活动进行中被操作,用于记录数据库,将图像压缩到数据库等。下面是我们定义的它的初步的接口。可以看到,它的接口是和ScanTask中的signal对应的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class FRAMEWORKS_EXPORT PackingWorker : public QObject
{
Q_OBJECT
public:
friend class PackingWorkerTester;
friend class ScanTaskTester;
explicit PackingWorker(const QString& path, const QString& slide_id, IConfiger* configer, QObject *parent = nullptr);
~PackingWorker();
bool init();
void setParam(const QVariantHash& params);
public slots:
// 数据库操作
bool addSlideInfo();
bool addViewInfo(int view_type, int cell_type, int round, int index, const QSize& size, const QRect& rect, int scan_method, const QString& path);
bool completePacking();
// zip操作
void zipFile(const QString& src_path, const QString& dst_path);
void zipImage(const cv::Mat& mat, int column, int row, int fmt, int quality, const QString& dst_dir);
void zipDir(const QString& src_path, const QString& base_path);
// 待定
void prepareDump(const QString& path);
void dumpImage(const cv::Mat& mat, const QString& dir, const QString& name, int fmt, int quality);
...
}

其中:

  • init():完成档案初始化工作。把它从构造函数中拿出来,避免出现异常/错误后的麻烦
  • setParam():设置参数信息
  • addSlideInfo():将样本信息保存到数据库中
  • addViewInfo():将视野信息保存到数据库中
  • completePacking():扫描完成后的收尾工作
  • zipFilezipImagezipDir:规划的将文件/目录/图像数据压缩到zip包的操作
  • prepareDump()dumpImage():保存拍摄的原始图像到硬盘。是否防止这里暂时拿不准

我们应该考虑的一个重要问题是,PackingWorker的运行环境。它是应该运行在ScanTask的线程中,还是应该再由ScanTask启动一个独立的工作线程来运行PackingWorker,还是将其中的耗时操作作为工作线程运行,等等。它其实涉及到的问题是我们对数据归档的时间和扫描时间的一个评估。首先我们知道,我们使用的是SQLite作为数据库引擎来保存视野信息,这个数据库很小,大概在K级别,性能是不需要考虑的。然后是图像文件的压缩保存,图像文件的数据量就很大,从二三百M到一点几个G都有可能,我们就要综合考虑各种方案的复杂性和性能的影响。

另一方面,PackingWorker涉及到数据库和zip文件的操作,都是属于和主业无关的工作,我们不希望它的开发把doPreview()的工作有过多打断,所以我们还是使用signal的方式,先让doPreview()能独立运行起来再说。

使用SQLITE保存扫描信息

扫描得到的图像数据要传给后台去做分析处理。出于保证数据完整性的考虑,会在扫描后将数据打包成zip格式后统一传递给后台。这样做,由于操作的串行,网络传输,压缩和解压等都会对性能产生一定的影响,但是和处理数据完整性的代价比较起来都是值得的。

在数据包中,除了图像文件外,还需要提供一个描述文件,描述玻片信息,每个扫描的”视野“信息,扫描任务信息等相关的描述信息。有时候我们也称之为索引文件。

在本书中,”视野“这个名词在大多数情况指的是一个逻辑上的概念,指的是”一次“扫描得到的范围,如果是单张扫描,则是拍摄的一张照片对应的范围,如果是连续扫描,则是这个连续区域对应的范围。

这里的索引信息是层次性关系。我们在这里使用SQLite来保存数据——这样做相当符合SQLite创始人的本意:将SQLite当作一个结构化的文件存储工具。我们也可以使用其他的方式,比如Json文件,XML或自定义的数据结构等,但是需要自己做封装,相对来说,直接利用SQLite要简单地多。

接下来分析要保存的数据。首先是玻片和扫描活动的信息,包括玻片的样本号,样本类别,病人的病历号,扫描时间,扫描模式,设备编号等。这些信息在开始时就已经确定了。我们将其保存在一张表slides里面。这张表应该只有一行。

slides表的结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
slide_id    TEXT    NOTNULL         ; 玻片ID,由扫描设备创建,唯一标识玻片
sample_type TEXT NOTNULL ; 样本类别, 在扫描时确定, BM/PB
scan_time TEXT NOTNULL ; 扫描时间, 在扫描时确定, yyyy-mm-dd hh:mm:ss的格式
sample_id TEXT ; 样本号,扫描时获取,可以没有
case_no TEXT ; 病历编号, 扫描时获取
device_id TEXT ; 设备编号, 扫描设备本身的标识
hostital TEXT ; 扫描用户的名称,
white_count INT ; 白细胞的目标个数, 0表示不扫描
white_method TEXT ; 白细胞扫描的方式. SINGLE|MATRIX|NULL
red_count INT ; 红细胞的期望个数, 0表示不扫描
mega_count INT ; 巨核细胞的期望个数, 0表示不扫描
low_zoom INT DEFAULT 10 ; 低倍镜的倍率.
high_zoom INT DEFAULT 100 ; 高倍镜的倍率
low_pixel REAL ; 低倍镜每个像素的物理尺寸
high_pixel REAL ; 高倍镜每个像素的物理尺寸
version INT DEFAULT 1 ; 格式的版本号

接下来要定义另一张表views,它里面保存了扫描的每个视野的信息——预览图和标签图也被视为一种”视野“来统一处理。对一次玻片的扫描活动,会包括下面的一些视野:

  • 预览图视野,指的是预览图的照片,只有一个
  • 标签图视野,指的是标签图的照片,只有一个
  • 低倍扫描视野,对玻片做低倍扫描得到的总的拼接区域。一个玻片只会做一次低倍扫描,得到一个视野。低倍视野只需要将拍摄的照片简单拼接就可以,不需要做重叠检查和匹配。
  • 白细胞视野,白细胞扫描会有两种扫描模式,连续扫描和单张扫描。对连续扫描,一张玻片可能会扫描几个连续区域,每个称为“一轮(round)”,得到一个视野。而单张扫描,每张照片就是一个视野。
  • 红细胞视野,红细胞一定是连续扫描,只会做一次扫描
  • 巨核细胞视野,巨核细胞一定是单张扫描,和白细胞的非连续扫描一样处理
1
2
3
4
5
6
7
8
9
10
11
view_type   TEXT    NOTNULL             ; 视野类别  PREVIEW/LABEL/LOW/WHITE/RED/MEGA
view_index INT NOTNULL ; 视野的编号, 在view_type下编号.
scan_method TEXT NOTNULL ; 扫描方式 SINGLE/MATRIX
path TEXT NOTNULL ; 图像的保存位置. 如果是SINGLE, 就是文件名, 如果是MATRX, 是目录名
area_left INT ; 这个区域在上一级的位置 预览图<-低倍图<-高倍图
area_top INT
area_right INT
area_bottom INT
width INT NOTNULL ; 视野的大小(像素)
height INT
zoom INT ; 拍摄的倍率, 10/100, 同slides表中的low_zoom/high_zoom

我们将SQLite视为一个文件,所以数据库设计没有遵循数据库的设计模式。对于views表,数据条数也无论如何不会超过1000个,所以空间并不是什么问题,而可读性反倒是更重要的,所以对于枚举含义的参数,我们都使用文本来保存,以便于在调试时方便人工观察内容

Qt中使用SQL数据库

在Qt中使用SQL数据库是很简单的工作。只需要做几步操作:

  1. 在项目中增加对SQL模块的使用。我们使用的是qmake,就只需要增加QT += sql就可以。
  2. 确认数据库驱动。我们使用的是SQLite,
  3. 连接数据库,可以通过QSqlDatabase实现。最常用的是静态方法QSqlDatabase::addDatabase()
  4. 打开数据库
  5. 利用QSalQuery执行SQL命令

对于大多数简单应用场景,这几点就够了。

现代的后端软件一般都会会利用ORM来操作数据库,对我们而言,只有两张表,手工编写数据库操作就足够了。Qt下也有一些ORM工具,比如QxOrm。读者有兴趣也可以了解一下。

创建数据库

内部函数initDb()用于初始化数据库,并创建数据库中的两张表,slidesviews

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
bool PackingWorker::initDb()
{
auto dbFileName = dbPath();
_impl->_db = QSqlDatabase::addDatabase("QSQLITE", _impl->_slideId);
if(!_impl->_db.isValid())
{
TRACE() << tr("SQLite Driver abnormal: %1").arg(_impl->_db.lastError().text());
return false;
}
_impl->_db.setDatabaseName(dbFileName);
if(!_impl->_db.open())
{
TRACE() << QString("Open database %1 failed: %2")
.arg(dbFileName).arg(_impl->_db.lastError().text());
return false;
}
QSqlQuery query(_impl->_db);
QString addSlideTableStr = QString("CREATE TABLE IF NOT EXISTS slides (")
.append("slide_id TEXT(32) NOT NULL")
.append(", sample_type TEXT(8) NOT NULL")
.append(", scan_time TEXT NOT NULL")
.append(", sample_id TEXT(64)")
.append(", case_no TEXT(64)")
.append(", device_id TEXT")
.append(", hospital TEXT")
.append(", white_count INT")
.append(", red_count INT")
.append(", mega_count INT")
.append(", white_method TEXT")
.append(", low_zoom INT DEFAULT 10")
.append(", high_zoom INT DEFAULT 100")
.append(", low_pixel REAL DEFAULT 0.477124183006536")
.append(", high_pixel REAL DEFAULT 0.0477124183006536")
.append(", version1 INT DEFAULT 1, version2 INT DEFAULT 0")
.append(")");

if(!query.exec(addSlideTableStr))
{
TRACE() << QString("Failed to create table slides: %1")
.arg(query.lastError().text());
return false;
}

//TRACE() << "创建view表: ====================================================";
QString addViewTableStr = QString("CREATE TABLE IF NOT EXISTS views (")
.append("analysis TEXT NOT NULL")
.append(", view_round INT NOT NULL")
.append(", view_index INT NOT NULL")
.append(", scan_method TEXT NOT NULL")
.append(", path TEXT NOT NULL")
.append(", area_left INT NOT NULL")
.append(", area_top INT NOT NULL")
.append(", area_right INT NOT NULL")
.append(", area_bottom INT NOT NULL")
.append(", width INT NOT NULL")
.append(", height INT NOT NULL")
.append(", zoom INT NOT NULL")
.append(")");

if(!query.exec(addViewTableStr))
{
TRACE() << QString("Failed to create table views: %1").arg(query.lastError().text());
return false;
}
return true;
}

addSlideInfoToDb()用于想slides中添加一条记录:

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
29
30
31
32
33
34
35
36
37
38
bool PackingWorker::addSlideInfoToDb(const QVariantMap &data)
{
QString addSlideInfoStr = QString("INSERT OR REPLACE INTO slides (")
.append(" slide_id, sample_type, scan_time ")
.append(", sample_id, case_no, device_id, hospital, white_count, red_count, mega_count")
.append(", white_method, low_zoom, high_zoom, low_pixel, high_pixel")
.append(") VALUES (")
.append(" :slide_id, :sample_type, :scan_time")
.append(", :sample_id, :case_no, :device_id, :hospital, :white_count, :red_count, :mega_count")
.append(", :white_method, :low_zoom, :high_zoom, :low_pixel, :high_pixel")
.append(")")
;

QSqlQuery query(_impl->_db);
query.prepare(addSlideInfoStr);
query.bindValue(":slide_id", data.value("slide_id").toString());
query.bindValue(":sample_type", data.value("sample_type").toString());
query.bindValue(":scan_time", data.value("scan_time").toString());
query.bindValue(":sample_id", data.value("sample_id").toString());
query.bindValue(":case_no", data.value("case_no").toString());
query.bindValue(":device_id", data.value("device_id").toString());
query.bindValue(":hospital", data.value("hospital").toString());
query.bindValue(":white_count", data.value("white_count").toInt());
query.bindValue(":red_count", data.value("red_count").toInt());
query.bindValue(":mega_count", data.value("mega_count").toInt());
query.bindValue(":white_method", data.value("white_method").toString());
query.bindValue(":low_zoom", data.value("low_zoom").toInt());
query.bindValue(":high_zoom", data.value("high_zoom").toInt());
query.bindValue(":low_pixel", data.value("low_pixel").toReal());
query.bindValue(":high_pixel", data.value("high_pixel").toReal());

if(!query.exec())
{
TRACE() << query.lastError().text();
return false;
}
return true;
}

addViewInfoToDb()用于向views中增加一条记录:

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
29
30
31
32
33
34
bool PackingWorker::addViewInfoToDb(const QVariantMap &param)
{
QString addViewInfoStr = QString("INSERT OR REPLACE INTO views (")
.append("analysis, view_round, view_index, scan_method, path")
.append(", area_left, area_top, area_right, area_bottom")
.append(", width, height")
.append(", zoom) VALUES (")
.append(" :analysis, :view_round, :view_index, :scan_method, :path" )
.append(", :area_left, :area_top, :area_right, :area_bottom ")
.append(", :width, :height")
.append(", :zoom")
.append(")");
QSqlQuery query(_impl->_db);
query.prepare(addViewInfoStr);
query.bindValue(":analysis", param.value("analysis").toString());
query.bindValue(":view_round", param.value("view_round").toInt());
query.bindValue(":view_index", param.value("view_index").toInt());
query.bindValue(":scan_method", param.value("scan_method").toString());
query.bindValue(":path", param.value("path").toString());
query.bindValue(":area_left", param.value("area_left").toInt());
query.bindValue(":area_top", param.value("area_top").toInt());
query.bindValue(":area_right", param.value("area_right").toInt());
query.bindValue(":area_bottom", param.value("area_bottom").toInt());
query.bindValue(":width", param.value("width").toInt());
query.bindValue(":height", param.value("height").toInt());
query.bindValue(":zoom", param.value("zoom").toInt());

if(!query.exec())
{
TRACE() << query.lastError().text();
return false;
}
return true;
}

这两个内部函数会被addSlideInfo()addPreviewInfo()等调用。例如,addSlideInfo()的工作就是构造addSlideInfoToDb()的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool PackingWorker::addSlideInfo()
{
QVariantMap params{
{"slide_id", _impl->_slideId},
{"sample_type", sampleTypeName(_impl->_sampleType)},
{"scan_time", _impl->_scanTime},
{"sample_id", _impl->_sampleId},
{"case_no", _impl->_caseNo},
{"device_id", _impl->_deviceId},
{"hospital", _impl->_hospital},
{"white_count", _impl->_whiteCount},
{"red_count", _impl->_redCount},
{"mega_count", _impl->_megaCount},
{"white_method", _impl->_whiteMethod==0 ? "SINGLE":"MATRIX"},
{"low_zoom", _impl->_lowZoom},
{"high_zoom", _impl->_highZoom},
{"low_pixel", _impl->_lowPixel},
{"high_pixel", _impl->_highPixel}
};
return addSlideInfoToDb(params);
}

有了这几个函数,那些slot函数不过是对这几个函数的使用。

利用Quazip操作zip文件

接下来我们会讨论如何操作zip文件。当扫描结束后,我们需要将采集到的图片文件和数据库文件打包保存到zip文件中,以上传给后台处理。Qt提供了qCompress()函数,但是这个函数仅支持压缩单个文件,而我们希望的是能够支持多级目录的压缩。在C/C++领域,最流行的压缩库是zlib,但是它太复制,没有用过的人用起来太困难,并且,zlib是通用的C接口,要在Qt中使用,还需要做Qt常用的数据类型的转换。而Quazip则是一个为Qt而做的对zlib的封装。我们选择它作为我们的工具。

我们首先需要下载Quazip和zlib两个库到本地,并自己编译。需要先编译zlib,再编译Quazip,在网上能找到很详细的教程,这里就不再讨论了。我们在项目目录下创建一个thirdpart目录,以后这个目录会放置我们会用到的比较小的第三方库的内容。我们将zlibquazip的目录拷贝进去。

然后,编辑thirdpart.pri,将这两个库也添加进去:

1
2
3
4
5
6
7
8
9
10
# zlib
win32:CONFIG(release, debug|release): LIBS += -L$$PWD/thirdpart/zlib/lib/ -lzlibstatic
else:win32:CONFIG(debug, debug|release): LIBS += -L$$PWD/thirdpart/zlib/lib/ -lzlibstaticd
INCLUDEPATH += $$PWD/thirdpart/zlib/include
DEPENDPATH += $$PWD/thirdpart/zlib/include
# quazip1.4
win32:CONFIG(release, debug|release): LIBS += -L$$PWD/thirdpart/quazip-1.4/dll/ -lquazip1-qt6
else:win32:CONFIG(debug, debug|release): LIBS += -L$$PWD/thirdpart/quazip-1.4/dll/ -lquazip1-qt6d
INCLUDEPATH += $$PWD/thirdpart/quazip-1.4
DEPENDPATH += $$PWD/thirdpart/quazip-1.4

在这里,$$PWD指的是当前文件所在的路径。在这里就是thirdpart.pri这个文件,thirdparty目录的路径就表述为$$PWD/thirdpart了,而不管是在哪里include这个pri文件。这也是我们建议在多项目的情况下将这些内容放在pri文件中然后includepro里面的缘故。我们不用再操心相对路径的问题。

Quazip中的两个核心概念是QuaZipQuaZipFile。前者代表一个zip文件,后者代表zip里面的一个压缩的文件。除此之外,还有一个便捷类JlCompress,它提供了一组更便于使用的静态方法供我们使用。其中,我们比较感兴趣的是下面几个函数:

1
2
3
static bool compressFile(QuaZip* zip, QString fileName, QString fileDest);
static bool compressDir(QString fileCompressed, QString dir, bool recursive, QDir::Filters filters);
static bool compressSubDir(QuaZip* parentZip, QString dir, QString parentDir, bool recursive,QDir::Filters filters);

第一个函数将指定的文件fileName压缩到zip里面

第二个函数简单的将目录dir整个压缩为fileCompressed

第三个函数将指定的目录dir压缩到zip中,使用它相对于parentDir的相对路径。注意这里的“相对路径”的含义。它是有点坑的。读者注意看一下它的源码就可以理解。准确的说,个人猜测,这个函数其实是给compressDir()使用的,拿出来给外部使用,其实是有点笨拙的。

zip包的管理逻辑

我们要考虑在扫描过程中如何管理zip文件。我们有几种选择:

  • 等扫描结束后再开始压缩文件,一次性将文件压缩到zip文件中。这么做是最简单的做法。
  • 一边扫描一边压缩。这样做实现起来要复杂一些,我们需要管理zip文件指针的生命周期,及时关闭,另外还有一个问题是,当连续扫描时,我们会以多线程的方式来生成图像,而很明显,Quazip是不支持多线程并发的,我们需要同步,更容易出错。
  • 分批进行。完成一个阶段或视野的扫描后,一次性将生成的图像文件压缩到压缩文件中。

我们会采用第三种方法,在每个阶段接收后都执行一次压缩,将新生成的图像目录压缩到zip文件中。

PackingWorker提供的压缩目录的接口是zipDir(),它响应ScanTask::sigZipDir(),其实就是对JlCompress::compressSubDir()函数的封装。其中,src_path是要压缩的目录在磁盘上的路径,base_path的叫法有点奇怪,在JlCompress中被叫做parent_dir。它的意思是,在zip中,这个目录的保存位置,是src_path中去掉了base_path后的后半段路径。例如,加入src_path的取值是D:\ScanData\20240422_162347\images\lower,而base_path的取值是D:\ScanData\20240422_162347,那么这个目录在zip中的存储路径就是images\lower

1
2
3
4
void PackingWorker::zipDir(const QString &src_path, const QString &base_path)
{
JlCompress::compressSubDir(_impl->_archive.get(), src_path, base_path, true, QDir::Filters());
}

其他方法的实现与之类似,这里就不再赘述了。

doPrepare()中做装配工作

定义好PackingWorker之后,我们就要在doPrepare()中完成PackingWorker的创建和关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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_hhmmss");
_impl->_basePath = QString("%1/%2").arg(_impl->_configer->DataPath()).arg(_impl->_slideId);
// 其他设置工作...
_impl->_packing.reset(new PackingWorker(_impl->_basePath, _impl->_slideId, _impl->_configer));
_impl->_packing->init();
connect(this, &ScanTask::sigSetScanInfo, _impl->_packing.get(), &PackingWorker::setParam);
connect(this, &ScanTask::sigPackSlideInfo, _impl->_packing.get(), &PackingWorker::addSlideInfo);
connect(this, &ScanTask::sigPackViewInfo, _impl->_packing.get(), &PackingWorker::addViewInfo);
connect(this, &ScanTask::sigZipDir, _impl->_packing.get(), &PackingWorker::zipDir);
connect(this, &ScanTask::sigZipFile, _impl->_packing.get(), &PackingWorker::zipFile);
connect(this, &ScanTask::sigCompletePacking, _impl->_packing.get(), &PackingWorker::completePacking);

connect(this, &ScanTask::sigPrepareDump, this, &ScanTask::prepareDump); // 建立保存图像的目录
connect(this, &ScanTask::sigDumpImage, this, &ScanTask::dumpImage); // 保存图像到硬盘
return true;
}

我们在这里创建了PackingWorker类,并实现了signal-slot的关联。

我们最终还是决定将拍摄获取的图像的保存功能留在ScanTask中实施。所以在这里,将ScanTask::sigDumpImage连接到了ScanTask::dumpImage上面而完全不需要修改doPreview()函数,也不影响单元测试。

doPrepare()中实现了signal-slot关联后,我们就可以实际测试一下doPreview()的操作了。我们写一个模仿真实使用的用例test_scan(),并会在后面一点点完善它。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void ScanTaskTester::test_scan()
{
QSKIP("full scan");
SCAN_PREPARE()
// 初始化测试参数
TBarParserRule rule1;
rule1._sample_id_include = true;
rule1._case_no_include = true;
rule1._sample_id_location = QSize(0,10);
rule1._case_no_location = QSize(11, 8);
rule1._sample_type_location = -1;
rule1._sample_type_bm_code = "B";
rule1._sample_type_pb_code = "P";
rule1._sample_type_default = "P";
configer->SetBarParserRule(rule1);
TRACE() << TSHOW(configer->Hospital());
configer->SetHospital("RENJI");
TRACE() << "修改后" << configer->Hospital();
configer->SetPbCount(100);
configer->SetBmCount(100);
configer->SetRedCount(1000);
configer->SetPbScanMethod(EScanMethod::eSingleScan);
configer->SetBmScanMethod(EScanMethod::eMatrixScan);
configer->SetScanWhiteCell(true);
configer->SetScanRedCell(false);
configer->SetScanMegaCell(false);

// 测试预览图
{
QSignalSpy spy11(&task, &ScanTask::sigPrepareDump);
QSignalSpy spy12(&task, &ScanTask::sigPreviewed);
QSignalSpy spy13(&task, &ScanTask::sigDumpImage);
QSignalSpy spy14(&task, &ScanTask::sigPackViewInfo);
QSignalSpy spy15(&task, &ScanTask::sigPackSlideInfo);

QCOMPARE(task.doPreview(), true);

QCOMPARE(spy11.size(), 1);
QCOMPARE(spy12.size(), 1);
QCOMPARE(spy13.size(), 2);
QCOMPARE(spy14.size(), 2);
QCOMPARE(spy15.size(), 1);

QCOMPARE(task.scanMethod(ECellType::eWhiteCell), EScanMethod::eSingleScan);
QCOMPARE(task.cellCount(ECellType::eWhiteCell), 100);
QCOMPARE(task.cellCount(ECellType::eRedCell), 0);
QCOMPARE(task.cellCount(ECellType::eMegaCell), 0);
QCOMPARE(task.sampleType(), ESampleType::ePbType);
}

}

实现低倍扫描

完成预览图扫描,确定了要做低倍扫描的范围后,就要开始低倍扫描。和同步方式的拍摄预览图不同,厂商提供的低倍扫描是异步进行的。当客户端调用LowScanMoving()之后,函数会立刻返回,并在返回参数中包含了要扫描的图像的行列数。然后设备驱动会启动一个扫描任务做实际的扫描,在扫描过程中,每扫描到一张图片,就会将其保存到一个FIFO队列中,客户端可以通过GetRealtimePics()来轮询并获取已经拍摄的图片。低倍扫描时设备会存在三种状态:START,表示正在扫描,FINISHED,表示设备扫描结束了,ERROR,表示出现了错误。扫描过程中不会丢失照片,即一开始就确定了要拍摄的照片数量,那么到扫描结束时,一定会有且仅有这么多照片,不会多,也不会少。而这一点和高倍扫描不一样,高倍扫描时是不确保一定能给出期望数量的照片的。

低倍扫描的最终结果应该是连续的图片,因为不需要太精确,所以这种连续性是通过调整设备参数来实现相邻的两张照片是紧挨着的来实现的,而不是通过后期做图像的匹配拼接来完成的。这样大大加快了工作速度。扫描的区域大小就是每个照片的大小乘以图像的行列数得到,比高倍的处理要简单地多。

有了前面的经验,我们再写就容易多了。我们先写一下函数的骨架,再考虑添加其他的内容:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
bool ScanTask::doLowScan()
{
// 准备环境
emit sigPrepareDumpLow();

std::vector<PicInfor> picList;
ScanInfo scanInfo;
// 启动扫描
scanInfo.x = _impl->_lowScanRect.x();
scanInfo.y = _impl->_lowScanRect.y();
scanInfo.width = _impl->_lowScanRect.width();
scanInfo.height = _impl->_lowScanRect.height();
auto ret = _impl->_device->LowScanMoving(&scanInfo);
RETVAL_LOG_IF(ret!=DCM_LOW_SCANMOVING_START, false, "低倍扫描失败!");
_impl->_lowViewColumns = scanInfo.column;
_impl->_lowViewRows = scanInfo.row;
emit sigScanStart(scanInfo.column, scanInfo.row, EScanMethod::eMatrixScan);
int total = scanInfo.column * scanInfo.row;

QSize viewSize = QSize(_impl->_lowViewColumns*_impl->_imageSize.width(),
_impl->_lowViewRows*_impl->_imageSize.height());
emit sigPackViewInfo(EViewType::eLowView, 0, 0, 0, viewSize, _impl->_lowScanRect,
EScanMethod::eMatrixScan,
viewZipDirPath(EViewType::eLowView,0,0) );
// TODO:创建分析器
// ...
// 等待图像处理结束
int index = 0;
while(true)
{
ret = _impl->_device->NormalStatusCheck();
if(ret != DCM_LOW_SCANMOVING_START && ret != DCM_LOW_SCANMOVING_FINISHED)
{
TRACE() << QString("扫描出错了: %1").arg(ret);
return false;
}
else if (index==total && ret==DCM_LOW_SCANMOVING_FINISHED)
{
TRACE() << "图像采集完毕.";
break;
}
else
{
_impl->_device->GetRealtimePics(&picList);
while(index<picList.size())
{
auto& pic = picList.at(index);
int column = pic.point.first;
int row = pic.point.second;
auto imgMat = cvtPicToMat(pic).clone();
// TODO:图像分析
// ...
// 发送给界面
emit sigImageCaptured({column, row}, imgMat);
// 发送给packing
emit sigDumpImage(imgMat, column, row, _impl->_lowImageFormat, _impl->_lowImageFormat, dumpPath);
index++;
}
}
}
emit sigZipDir(dumpPath, basePath());

// TODO:计算高倍扫描区域
// ...
return true;
}

和在doPreview()中的做法不同,我们通过消息解耦,可以完美地实现对doLowScan()的独立测试而不需要太多的相关处理:

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
void ScanTaskTester::test_doLowScan_01()
{
//QSKIP("test doLowScan()");
const int COLUMNS = 4;
const int ROWS = 4;
SCAN_PREPARE()
scaner.setLowScanCount(COLUMNS,ROWS);
connect(&task, &ScanTask::sigScanStart, this, [](int a, int b)
{
QCOMPARE(a, COLUMNS);
QCOMPARE(b, ROWS);
});
QSignalSpy spy1(&task, &ScanTask::sigPrepareDump);
QSignalSpy spy2(&task, &ScanTask::sigPackViewInfo);
QSignalSpy spy3(&task, &ScanTask::sigImageCaptured);
QSignalSpy spy4(&task, &ScanTask::sigDumpImage);

QCOMPARE(task.doLowScan(), true);
QCOMPARE(spy1.size(), 1);
QCOMPARE(spy2.size(), 1);
QCOMPARE(spy3.size(), COLUMNS*ROWS);
QCOMPARE(spy4.size(), COLUMNS*ROWS);


QCOMPARE(task.scanRegions(ECellType::eWhiteCell).size(),1);
QCOMPARE(task.scanRegions(ECellType::eWhiteCell).at(0).size(),12);
}

计算高倍扫描区域

我们执行低倍扫描的目的是为了从中进一步找到进行高倍扫描的区域。这是这个软件中最复杂的内容之一。它具有如下的特点:

原则上需要对拍摄到的每张照片做分析,照片数量从几十张上百张到上千张。必须合理规划以满足电脑的限制

  • 分析工作相对比较缓慢,需要考虑如何充分利用硬件的能力
  • 分析技术有多种,且不稳定,需要便于升级和替换
  • 低倍扫描速度比较快的,分析要尽量跟上扫描的速度

ILowAnalyzer

我们定义一个接口类ILowAnalyzer来完成低倍图的分析和高倍扫描区域的计算工作。这个类未来一定会有多种实现方法,根据不同的参数来创建不同的具体实例。另外,图像分析一定是一个耗时耗CPU的活动,为了尽可能利用CPU,我们会利用多线程技术异步执行。我们会将线程管理工作委托给Analyzer来完成。因此,它的工作模式应该是这样:ScanTask每得到一张照片,就将其发送给Analyzer做异步处理。最后,ScanTask会等待Analyzer工作结束,并得到最终计算的结果。

按照这个逻辑,我们定义ILowAnalyzer的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FRAMEWORKS_EXPORT ILowAnalyzer : public QObject
{
Q_OBJECT
public:
explicit ILowAnalyzer(QObject *parent = nullptr) : QObject(parent) {}
virtual ~ILowAnalyzer(){}
// 增加一个低倍视野待处理
virtual void add(PointType pos, const cv::Mat& mat) = 0;
// 等待运行结束
virtual bool waitForFinished() = 0;
// 获取白细胞的扫描选区
virtual std::vector<HighPointTypeList> getWhiteScanRegions() = 0;
// 获取红细胞扫描区域
virtual std::vector<HighPointTypeList> getRedScanRegions() = 0;
// 获取巨核细胞的扫描区域
virtual std::vector<HighPointTypeList> getMegaScanRegions() = 0;
signals:
private:
};

其中,add()用于添加一张待分析的照片,而waitForFinished()用于等待所有的照片都分析完成,然后,可以使用下面的几个get接口获取各种细胞的扫描范围。

那么如何从这些低倍图中找到一个区域来做高倍扫描呢?对一名医生来说,当拿到一张玻片,他会先肉眼看一下这张玻片,找一个适合做低倍观察的区域,然后将显微镜拧到低倍镜,移动载物台将这个区域移动到镜下开始观察(我们的低倍扫描区域选择)。然后他会找一个他认为质量比较好的区域,然后将显微镜拧到高倍镜,开始观察。我们的处理流程稍微有所不同:我们会找一个“质量比较好”的区域,找到一个包含足够的细胞数的区域作为高倍扫描区域。因此,对我们来说,首先两个工作是计算图像的“质量”和找到每个照片中的细胞的坐标来。最后,当梭有的照片都分析完成后,再寻找一个最好的包含了要求的细胞个数的区域来进行扫描。这个区域可能是一个或几个连续的区域(当做连续扫描时),也可能是一些离散的位置(做单点扫描时)。

下面是两张10倍镜下的样本图,其中紫红色的就是白细胞,浅色的是红细胞。在十倍镜下已经能够基本识别出哪些是白细胞了,只是要识别分类还是不够。
这张照片总体来说质量勉强还凑合,左侧还可以,右侧有些太密。

而在下面这张照片中,白细胞则是挤到一起了,就不合格了

评估图片的“质量”,我们使用图像分析技术来实现,而从照片中寻找白细胞的位置,既可以用图像处理技术,也可以利用AI。利用AI能得到更准确的结果,对某些特殊的疾病有更好的效果。这两种技术应该是可以配置选择的。

因为有两种寻找细胞的技术,我们还是将其抽象出接口来:ICellFinder,并提供两种实现CvCellFinderAICellFinder。其中,CvCellFinder利用CPU来做计算,会为每张照片启动一个工作线程分析;AICellFinder利用GPU计算,而GPU不支持并发,我们需要将任务串行化。而图像质量的计算则交给LowAnalyzer来负责。

ICellFinder接口

定义ICellFinder接口类。它的接口很简单,只有一个add()方法和一个waitResult()方法。它的工作模式和ILowAnalyzer是一样的,将图像异步发送给它,然后等待它完成。

1
2
3
4
5
6
7
8
9
10
class FRAMEWORKS_EXPORT ICellFinder : public QObject
{
Q_OBJECT
public:
explicit ICellFinder(QObject *parent = nullptr) : QObject{parent}{}
~ICellFinder(){}
virtual void add(const PointType& pos, const cv::Mat& mat) = 0;
virtual HighPointTypeList waitResult() =0;
signals:
};

我们从ICellFinder派生出两个抽象类,CvCellFinderAICellFinder。它们分别实现了两种细胞计算方法的无业务逻辑部分:

实现类CvCellFinder

CvCellFinder的类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class FRAMEWORKS_EXPORT CvCellFinder : public ICellFinder
{
Q_OBJECT
public:
explicit CvCellFinder(int sample_type, QObject* parent=nullptr);
~CvCellFinder();
virtual void add(const PointType& pos, const cv::Mat& mat) override;
virtual HighPointTypeList waitResult() override;
protected:
virtual std::vector<PointType> getWhiteCells(const cv::Mat& mat, int sample_type) = 0;
private:
int _sampleType;
QFutureSynchronizer<HighPointTypeList> _sync;
};

add()方法会启动一个工作线程,在工作线程中会调用getWhiteCells()计算细胞的坐标,并返回。它的返回值是一个由两个std::pair组成的pairvector。具体细节我们不需要关心,只看它的线程模式即可。QtConcurrent::run会返回一个QFuture,我们用一个QFutureSynchronizer来同步这些线程。这是Qt提供的一个多线程同步的便捷类,如果说和C++标准库对照,它可能有点类似于std::experimental::when_all()

其中,私有抽象方法getWhiteCells()用于计算图像中的白细胞列表,由具体实现类实现。

C++标准库中在experimental中提供了when_all()when_any()两个实验性方法,实验方法,总是让人用的时候心里惴惴地:-)。它们在Qt中的准确的对应物应该是QtFuture::whenAll()QtFuture::whenAny()。这两组方法都是Qt6.3才被引入的。

QFutureSynchronizer::waitForFinished()能够实现whenAll()的基本场景,实际上更高级的使用一般也几乎不会有人用到。而实际上,QtFuture命名空间中的这几个函数,我还几乎没有见过有谁关注和介绍过的。它里面的内容很有趣,有兴趣的读者应该去认真了解一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void CvCellFinder::add(const PointType &pos, const cv::Mat &mat)
{
auto future = QtConcurrent::run([this](const PointType& pos, const cv::Mat& mat){
HighPointTypeList result;
auto cells = getWhiteCells(mat, this->_sampleType);
for(const auto& cell: cells)
{
result.push_back(std::make_pair(pos, cell));
}
return result;
}, pos, mat);

_sync.addFuture(future);
}

waitResult()方法则是阻塞等待梭有的线程结束,并将每个线程的计算结果拷贝合并出来。这里涉及到对QFutureresult()方法的使用。这里的waitForFinished()的调用其实是不需要的,因为QFuture::result()方法在线程未结束时,会阻塞等待。不过我们习惯了加上这一句。

QFuture是Qt对std::future的近似等价物。但是两者的差别也是比较明显的。比较重要的,一个是result()的获取和概念,另一个是链式计算then,那是完全不一样。使用的时候要注意。就我个人而言,我认为Qt的链式语义比std的好多了,后者让人觉得很教条和蠢笨。

1
2
3
4
5
6
7
8
9
10
11
12
13
HighPointTypeList CvCellFinder::waitResult()
{
HighPointTypeList result;
_sync.waitForFinished();
for(auto future: _sync.futures())
{
for(auto cell: future.result())
{
result.push_back(cell);
}
}
return result;
}

实现类AICellFinder

AICellFinder的实现机理和CvCellFinder不同。因为使用GPU计算,我们需要对任务做串行化。我们通过一个消息队列实现这个功能。ScanTask每次得到一张照片,就将数据写入队列,AICellFinder本身有一个消费者线程,它依次取出一张照片调用AI接口来获取照片中的细胞信息。这是一个单写入单读出的队列。我们主要是要防止生产者被阻塞,而消费者本身——AI接口——是阻塞的。

要构建一个线程安全的生产者-消费者队列,有两种经典同步机制。一种是使用QMutexQWaitCondition,另一种是使用QSemaphore。使用信号量的做法尤其适合限制最大队列长度的时候,相对于QWaitCondition方案中需要两个条件变量分别控制队列满和队列空两种情况,使用信号量的时候只需要一个。而另一方面,如果我们不限制队列长度,那么更适合条件变量的方案。

从理论上说,对一个FIFO的队列,我们应该选择那种pushpop操作都是O(1)的数据结构。比如,std::queue。但是,std::queue为了付出O(1),其实是有代价的。而我们做了一个取巧,使用std::vector作为数据容器。至于为什么这么用,后面讲到代码的时候再解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class FRAMEWORKS_EXPORT AICellFinder : public ICellFinder
{
Q_OBJECT
public:
AICellFinder(int sample_type, QObject* parent=nullptr);
virtual ~AICellFinder();
virtual void add(const PointType& pos, const cv::Mat& mat) override;
virtual HighPointTypeList waitResult() override;
protected:
void run();
virtual HighPointTypeList getWhiteCells(const PointType& pos, const cv::Mat& mat) = 0;
private:
int _sampleType;
QFutureSynchronizer<void> _sync;
QMutex _mutex;
QWaitCondition _not_empty;
std::vector<std::pair<PointType, cv::Mat>> _todo_views;
HighPointTypeList _result;
bool _finish;
};

我们的实现很因陋就简。构造函数中直接启动消费者线程:

1
2
3
4
5
6
7
AICellFinder::AICellFinder(int sample_type, QObject *parent)
: ICellFinder()
, _sampleType{sample_type}
, _finish{false}
{
this->_sync.setFuture(QtConcurrent::run(&AICellFinder::run, this));
}

add()同样是添加一个待分析的照片。这是生产者的标准行为:先加锁,然后修改修改,再对条件变量_not_emptywakeOne(),以唤醒一个在锁上面等待的线程——因为只有一个消费者run(),所以我们使用wakeOne()就可以了。

1
2
3
4
5
6
void AICellFinder::add(const PointType &pos, const cv::Mat &mat)
{
QMutexLocker locker(&_mutex);
_todo_views.emplace_back(pos, mat);
_not_empty.wakeOne();
}

而队列消费者函数run()实现如下:

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
void AICellFinder::run()
{
while(true)
{
decltype(_todo_views) views;
{
QMutexLocker locker(&_mutex);
while (_todo_views.empty())
{
if(_finish)
{
return;
}
else
{
_not_empty.wait(&_mutex);
}
}
views.swap(_todo_views);
}
for(auto view: views)
{
HighPointTypeList cells = getWhiteCells(view.first, view.second);
std::copy(cells.begin(), cells.end(), std::back_inserter(this->_result));
}
}
}

它同样是使用条件变量时的标准的消费者行为。唯一注意的是变量_finish的使用。我们使用它来简单地标记是否数据全部都放进去了。这样,当_finishtrue,且为空的时候,就表示可以结束了。为此,我们每次将整个队列都移出来,然后阻塞线程进行计算——同样是这样,我们使用std::vector就可以实现性能了。

它会对每个照片调用getWhiteCells()寻找细胞,并将结果保存到类属性_result里面。

而等待函数waitResult()则是当将所有照片都add了之后才会被调用,它先将_finish设置为true,以通知run()不会再有数据到达了。然后等待线程结束,并返回结果。

1
2
3
4
5
6
7
8
HighPointTypeList AICellFinder::waitResult()
{
_finish = true;
TRACE() << "等待处理结束";
_sync.waitForFinished();
TRACE() << "所有视野处理结束";
return _result;
}

接下来,我们为这两个类分别实现测试桩。我们的桩函数仅仅是覆盖getWhiteCell()函数。分别创建CvCellFinderStubAICellFinderStub类。我们随便实现一下,让它消耗一点CPU时间,返回随便一个结果就可以了。例如:

1
2
3
4
5
6
7
8
9
HighPointTypeList AICellFinderStub::getWhiteCells(const PointType &pos, const cv::Mat &mat)
{
cv::Mat grayMat;
cv::cvtColor(mat, grayMat, cv::COLOR_BGR2GRAY);
cv::threshold(grayMat, grayMat, 0, 255, cv::THRESH_OTSU);
cv::erode(grayMat, grayMat, cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5,5)));
cv::dilate(grayMat, grayMat, cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5,5)));
return {{pos, std::pair(1,1)},{pos, std::pair(2,2)},{pos, std::pair(3,3)},};
}

OpenCV的简单函数中,格式转换,腐蚀膨胀,还有大尺寸的模糊,都是消耗CPU的好手,在可能的情况下使用它们来模拟运算,要比用sleep更能模拟真实情况。我们这里用用了一个腐蚀一个膨胀。以后如果发现时间不够,就再多做几次。

以后我们可以在真实产品中CvCellFinderAICellFinder都给出getWhiteCells()的实际实现。这样也不妨碍我们使用桩函数来做单元测试。我们先将它们定义为抽象类,可以避免遗忘了实现它们。

接下来我们测试一下这两个类。我们只是简单测试一下add之后waitResult不会被挂死,并且结果数量和期望一致就可以了。例如,AICellFinder的测试函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void LowAnalyzerTester::test_AICellFinder()
{
TRACE() << "开始测试AI";
AICellFinderStub finder(ESampleType::ePbType);
cv::Mat img = cv::imread(R"(D:\DataStore\TestData\scanstub\2024-02-19-092454\lower\0_0.jpg)");
finder.add({0,0}, img);
finder.add({1,0}, img);
finder.add({2,0}, img);
finder.add({0,1}, img);
finder.add({1,1}, img);
finder.add({2,1}, img);
auto r = finder.waitResult();
// AI桩函数为每个图片返回三个点
QCOMPARE(r.size(), 18);
}

实现LowCellAnalyzer

接下来实现LowCellAnalyzer类:

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
29
30
31
class FRAMEWORKS_EXPORT LowAnalyzer : public ILowAnalyzer
{
Q_OBJECT
public:
explicit LowAnalyzer(int sample_type, std::unique_ptr<ICellFinder> cell_finder, QObject *parent = nullptr);
~LowAnalyzer();

int sampleType() const;
LowAnalyzer& setSize(int columns, int rows);
int viewColumns() const;
int viewRows() const;
LowAnalyzer& setWhiteCellCount(int count) ;
int whiteCellCount() const;
LowAnalyzer& setWhiteScanMethod(int method);
int whiteScanMethod() const;
LowAnalyzer& setWhiteMaxViews(int count);
int whiteMaxViews() const;
LowAnalyzer& setRedCellCount(int count);
int redCellCount() const;
LowAnalyzer& setMegaCellCount(int count);
int megaCellCount() const;

virtual void add(PointType pos, const cv::Mat& mat) override;
virtual bool waitForFinished() override;
protected:
virtual int quality(const cv::Mat& mat) = 0;
virtual void onFinished() = 0;
private:
struct Implementation;
QScopedPointer<Implementation> _impl;
};

同样的,我们将它的quality()临时设置为纯虚函数,以强迫继续派生出打桩类来做单元测试。我们先看一下它的addwaitForFinished()的实现。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct LowAnalyzer::Implementation
{
...
std::unique_ptr<ICellFinder> _cellFinder;
QFutureSynchronizer<LowImageElement *> _imageAnalyseSync;
std::vector<LowImageElement *> _viewElements;
};

void LowAnalyzer::add(PointType pos, const cv::Mat &mat)
{
// 发送给_cellFinder计算细胞位置
_impl->_cellFinder->add(pos, mat);
// 自己启动一个线程来计算其他的参数
_impl->_imageAnalyseSync.addFuture( QtConcurrent::run([this](PointType pos, const cv::Mat &mat){
auto elem = new LowImageElement;
elem->_pos = pos;
elem->_score = quality(mat);
elem->_width = mat.cols;
elem->_height = mat.rows;
return elem;
}, pos, mat));
}

bool LowAnalyzer::waitForFinished()
{
auto white_cells = _impl->_cellFinder->waitResult();
_impl->_imageAnalyseSync.waitForFinished();
for(const auto& future: _impl->_imageAnalyseSync.futures())
{
auto elem = future.result();
// 从white_cells里面寻找属于elem的细胞,并添加进去
for(const auto& pt: white_cells)
{
if(pt.first == elem->_pos)
{
elem->_whitePoints.push_back(pt.second);
}
}
elem->_whiteCount = elem->_whitePoints.size();
_impl->_viewElements.push_back(elem);
TRACE() << "计算视野" << TSHOW(elem->_pos) << "的结果:" << TSHOW(elem->_score) << TSHOW(elem->_whiteCount);
}
onFinished();
return true;
}

另外一个纯虚函数onFinished()是用于在waitForFinished()函数中,当获取到所有的计算结果后的计算活动的。我们仍然在这里暂时设置为纯虚函数,等以后做实际开发时再提供。

最后是打桩类LowAnalyzerStub。它没有什么多说的,只是打桩实现了LowAnalyzer没有实现的几个接口用于单元测试。比如,getWhiteScanRegions(),就是返回了固定的扫描区域范围。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
std::vector<HighPointTypeList> LowAnalyzerStub::getWhiteScanRegions()
{
TRACE() << TSHOW(whiteScanMethod());
if(whiteScanMethod() == EScanMethod::eMatrixScan)
{
TRACE() << "连续扫描模式";
return {
// round0
{
{{1,2},{1844,15}}, //top-left
{{2,2},{679 ,15}}, //top-right
{{2,2},{679 ,1204}}, //bottom-right
{{1,2},{1844,1204}} //bottom-left
},
// round1
{
{{3,3},{1844,15}}, //top-left
{{4,3},{679 ,15}}, //top-right
{{4,3},{679 ,1204}}, //bottom-right
{{3,3},{1844,1204}} //bottom-left
},
};
}
else
{
TRACE() << "非连续扫描模式";
return {
// 非连续扫描只会提供一个round
{
{{1,1},{44,101}},
{{1,1},{1732,1}},
{{1,2},{44,61}},
{{1,1},{599,45}},
{{2,1},{1610,1282}},
{{1,1},{805,862}},
{{3,1},{100,55}},
{{1,1},{10,30}},
{{2,2},{1208,678}},
{{2,1},{67,1674}},
{{1,3},{1000,800}},
{{2,1},{1897,1938}},
}
};
}
}

单元测试也只是测试一下线程是否能结束,数据数量对不对而已——这些更多的是为实际的使用打一个预演。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void LowAnalyzerTester::test_LowAnalyzerStub_Single()
{
cv::Mat img = cv::imread(R"(D:\DataStore\TestData\scanstub\2024-02-19-092454\lower\0_0.jpg)");
LowAnalyzerStub analyzer(ESampleType::ePbType, std::make_unique<AICellFinderStub>(ESampleType::ePbType));
analyzer.setWhiteScanMethod(EScanMethod::eSingleScan);
analyzer.add({0,0}, img );
analyzer.add({1,0}, img);
analyzer.add({2,0}, img);
analyzer.add({0,1}, img);
analyzer.add({1,1}, img);
analyzer.add({2,1}, img);
QVERIFY2(analyzer.waitForFinished(), "Failed to finish");
auto regions = analyzer.getWhiteScanRegions();
QCOMPARE(regions.size(), 1);
QCOMPARE(regions.at(0).size(), 12);
}

接下来,我们将将LowAnalyzer集成到ScanTask中去。首先,我们按照管理,在AlgorighmFactory中为它们创建工厂函数:

1
2
static std::unique_ptr<ICellFinder> makeCellFinder(int sample_type, bool use_ai);
static std::unique_ptr<ILowAnalyzer> makeLowAnalyzer(int sample_type, bool use_ai);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::unique_ptr<ICellFinder> AlgorighmFactory::makeCellFinder(int sample_type, bool use_ai)
{
#ifdef USE_STUB
if(use_ai)
return std::make_unique<AICellFinderStub>(sample_type);
else
return std::make_unique<CvCellFinderStub>(sample_type);
#else
#endif
}


std::unique_ptr<ILowAnalyzer> AlgorighmFactory::makeLowAnalyzer(int sample_type, bool use_ai)
{
#ifdef USE_STUB
return std::make_unique<LowAnalyzerStub>(sample_type, makeCellFinder(sample_type, use_ai));
#else
#endif
}

修改ScanTask::doLowScan()函数:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
bool ScanTask::doLowScan()
{
...
auto analyzer = AlgorighmFactory::makeLowAnalyzer(sampleType(),
_impl->_configer->EnableAI());
analyzer->setWhiteScanMethod(_impl->_whiteMethod)
.setWhiteCellCount(_impl->_whiteCount)
.setRedCellCount(_impl->_redCount)
.setMegaCellCount(_impl->_megaCount)
;

// 等待图像处理结束
int index = 0;
while(true)
{
ret = _impl->_device->NormalStatusCheck();
...
else
{
_impl->_device->GetRealtimePics(&picList);
while(index<picList.size())
{
auto& pic = picList.at(index);
int column = pic.point.first;
int row = pic.point.second;
auto imgMat = cvtPicToMat(pic).clone();
// 图像分析
analyzer->add({column, row}, imgMat);
emit sigImageCaptured({column, row}, imgMat);
emit sigDumpLowImage(imgMat, column, row);
index++;
}
}
}
emit sigZipDir(dumpPath, basePath());

analyzer->waitForFinished();
_impl->_whiteMethod = analyzer->whiteScanMethod();
_impl->_whiteScanRegions = analyzer->getWhiteScanRegions();
_impl->_redScanRegions = analyzer->getRedScanRegions();
_impl->_megaScanRegions = analyzer->getMegaScanRegions();

return true;
}

我们创建LowAnalyzer对象,并在采集到每张照片后将其发送给它。然后,在扫描完成后,等待analyzer计算结束,并将结果保存到类属性中,供下一个阶段使用。

在真实产品中,分析器可能会根据情况和指定的策略修改扫描方式。比如,对于某种白血病,其外周血中会有大量的白细胞增生,此时使用单点拍摄就不合适了,应该改为连续扫描。同样,对经过晚期化疗的癌症晚期病人,即使是在骨髓中白细胞也极为稀少,就应该改为使用单点方式。

修改doLowScan()的测试函数,把结果检查加进去:

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
void ScanTaskTester::test_doLowScan_01()
{
//QSKIP("test doLowScan()");
const int COLUMNS = 4;
const int ROWS = 4;
SCAN_PREPARE()
scaner.setLowScanCount(COLUMNS,ROWS);
connect(&task, &ScanTask::sigScanStart, this, [](int a, int b)
{
QCOMPARE(a, COLUMNS);
QCOMPARE(b, ROWS);
});
QSignalSpy spy1(&task, &ScanTask::sigPrepareDump);
QSignalSpy spy2(&task, &ScanTask::sigPackViewInfo);
QSignalSpy spy3(&task, &ScanTask::sigImageCaptured);
QSignalSpy spy4(&task, &ScanTask::sigDumpImage);
QSignalSpy spy5(&task, &ScanTask::sigZipDir);

QCOMPARE(task.doLowScan(), true);
QCOMPARE(spy1.size(), 1);
QCOMPARE(spy2.size(), 1);
QCOMPARE(spy3.size(), COLUMNS*ROWS);
QCOMPARE(spy4.size(), COLUMNS*ROWS);
QCOMPARE(spy5.size(), 1);

QCOMPARE(task.scanRegions(ECellType::eWhiteCell).size(),1);
QCOMPARE(task.scanRegions(ECellType::eWhiteCell).at(0).size(),12);
}