Day 07 拍摄预览图

基本流程

接下来实现拍摄预览图的函数doPreview()

预览图拍摄阶段要做的活动包括:

  1. 调用GetSlidePreview()拍摄样本的预览图和标签图,并解析出标签上的条码/二维码内容
  2. 保存预览图和标签图到本地硬盘中和扫描档案文件中
  3. 解析标签图,识别出样本编号和样本类型
  4. (可选)弹出对话框,让用户确认和修改样本编号和扫描参数等信息
  5. 保存扫描参数到索引文件
  6. 利用预览图找出要进行低倍扫描的范围
  7. (可选)弹出对话框,让用户确认和修改低倍扫描的范围

从这里我们发现,要实现拍摄预览图,还有很多基础工作要做。我们先不考虑这些,尽量写一个初稿出来:

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
bool ScanTask::doPreview()
{
int retval;
std::vector<PicInfor> picList;
ScanInfo scanInfo;
auto cleanup = qScopeGuard([&picList](){
std::for_each(picList.begin(), picList.end(), [](auto& pic){ delete []pic.matPic;});
});

std::string qrcode;
retval = _impl->_device->GetSlidePreview(&picList, &scanInfo, qrcode);
_impl->_validRect.setRect(scanInfo.x, scanInfo.y, scanInfo.width, scanInfo.height);

auto labelMat = cvtPicToMat(picList.at(0));
auto prevMat = cvtPicToMat(picList.at(1));
auto qrCode = QString::fromStdString(qrcode);
emit sigPreviewed(prevMat, labelMat, qrCode);
// TODO: 保存拍摄的图像到硬盘上
// TODO: 压缩拍摄的图像到zip包中
// TODO: 解析二维码识别样本类型和样本编号
// TODO: 弹出并确认扫描参数
// TODO: 计算低倍扫描的范围
// TODO: 用户可选的确认选区
// TODO: 保存玻片信息到索引文件中
return true;
}

在介绍ScanDeviceStub的时候我们曾经介绍过PicInfor的结构,它里面有一个指针指向图像数据。GetSlidePreview()函数会将图像数据追加到std::vector<PicInfor> picList里面。虽然picInfor是一个局部变量,但是当它被析构的时候,它的每个PicInfor成员的matPic指针指向的内存却不会自动释放。我们要么考虑实现PicInfor的析构函数——而这不可能,驱动程序要求PicInofr必须是POD对象——要么就必须自己来手工释放它。

为此,我们使用了qScopeGuard,它利用RAII技术,当退出作用域时会被自动调用。这样避免了在代码中到处编写相同的代码。

另外要注意的时,我们一定要为qScopeGuard()结果赋值给一个局部变量,否则,像下面这种写法,它创建了一个临时对象,立刻就被释放了,起不到任何作用:

1
2
3
4
// 没有给qScopeGuard赋值, 不会起作用
/*auto cleanup = */
qScopeGuard([&picList](){
std::for_each(picList.begin(), picList.end(), [](auto& pic){ delete []pic.matPic;});

通过signal-slot解耦

我们需要将拍摄的预览图和标签图保存到本地硬盘和zip文件中,还需要将玻片信息和视野信息保存到扫描数据库中。这些工作我们会在后来实现一个类PackingWorker来做。而这个类的生命周期是在doPreview()之外的,并且开发耗时也比较慢,甚至,我们当前还拿不准应该将保存图像到硬盘的功能放在ScanTask里面实现还是放到PackingWorker里面实现。此外,不管是写数据库还是写zip文件,验证结果都是很麻烦的事情,我们希望将它们的验证放到它自己的类里面去测试,那么我们这里关键要测试的是这些功能确实被调用了。我们应如何做到它们的松耦合?我们可以利用signal-slot机制,让doPreview()signal出来,具体被connect到哪里在其他的地方实现。这样我们就可以对doPreview()进行测试了。

我们在ScanTask里面增加如下的一组signal

1
2
3
4
5
6
7
8
9
10
11
12
void sigCompletePacking();
// 数据库操作相关
void sigSetScanInfo(const QVariantHash& params);
void sigPackSlideInfo();
void sigPackViewInfo(int view_type, int cell_type, int round, int index, const QSize& size, const QRect& rect, int scan_method, const QString& path);
// zip操作相关
void sigZipDir(const QString& src_path, const QString& parent_path);
void sigZipFile(const QString& src_path, const QString& dst_path);
void sigZipImage(const cv::Mat* src, const QString& dst_path);
// Dump图像相关
void sigPrepareDump(const QString& dir_name);
void sigDumpImage(const cv::Mat& mat, int column, int row, int fmt, int quality, const QString& dir);

实现条码解析功能

对扫描识别出来的二维码/条码文本进行解析,从中提取出样本相关的信息(至少应包含样本类型,样本号),这一活动对自动化连续扫描起着至关重要的作用。

关于二维码或条码的识别,有很少开源库可以使用,比如ZBarZXingQuircOpenCV等。这一活动扫描驱动程序已经为我们实现了。而我们需要做的是从解析出来的文本中提取出有效的内容。其中,样本类别直接决定了扫描模式的选择,扫描区域的选取,而样本号则是数据进入LIS和HIS进行关联所必须的。对于信息的编码,不同的用户会有不同的编码要求,但是他们的基本思想还是一致的,而且也是相对简单的。我们只需要定义一个规则,能够尽量适应用户的现状就可以了。

对于这个问题,对第一个版本,我们首先定义几个约束:

  1. 标签的形制是固定的,条码或二维码的区域是大致不变的。
  2. 标签上只能有一个二维码或条码,并且为定长的。
  3. 二维码的内容中,最多包含样本类型,样本编号,病历编号三种数据。其中,样本类型sampleType是必须有的,且只使用一个字母来表示,并且一种类型只会有一种编码方式。样本号sampleId也是必选的,而病历号caseNo则是可选的,且长度和位置也都是固定的。不过实际上,以标签的尺寸和市面上打印机的分辨率,想包含样本号和病历号基本上是不可行的。

这个规则外人很容易理解。说到底就是从第几位到第几位是什么含义,什么字符表示什么类型。外人也很容易理解和修改。

这个方案最关键的是容易理解。不管是操作机器的医院人员还是维护机器的售前售后工程师,都不是计算机这个领域的人,你让他们了解诸如正则表达式等东西,实在是太强人所难了。最好的方式就是告诉他第几个到第几个字符是什么含义。超过这个复杂性,最终都是给自己惹麻烦。切记切记。千万不要想什么自定义DSL这种花活!事实上,本软件使用ini文件而不是json文件作为配置定义,很大程度上就是这个原因——写JSON文件比写INI文件容易出错。

这些规则也很容易在配置界面中实现。在当下,我们先不考虑GUI的配置界面,先在配置ini文件中将他们定义下来就可以了。

我们定义了一个类CodeParser,它负责根据定义的规则从条码字符串中提取出样本类型,样本号,病历号的信息。
这个类很简单,只有一个parse()方法用于解析内容,以及一个loadRule()用于从配置文件中加载和更新规则。

定义规则结构:

1
2
3
4
5
6
7
8
9
10
11
struct BarParserRule
{
bool _sample_id_include; // 是否包含了样本号
bool _case_no_include; // 是否包含了病历号
QSize _sample_id_location; // 样本号位置
QSize _case_no_location; // 病历号位置
int _sample_type_location; // 样本类型字段位置
QString _sample_type_bm_code; // 骨髓的编码值
QString _sample_type_pb_code; // 外周血的编码值
QString _sample_type_default; // 识别错误时使用的默认类别
};

里面的每个值都在ini文件中定义。下面是ini文件中的参数定义:

1
2
3
4
5
6
7
8
bar-sample-type-location=-1         ; 样本类型在code中的位置。-1表示最后一位
bar-sample-type-bm-code=M ; 骨髓的编码
bar-sample-type-pb-code=P ; 外周血的编码
bar-sample-type-default=P ; 默认的类别
bar-sample-id-include=false ; 编码中是否包含样本号
bar-sample-id-location=@Size(0 10) ; 编码中样本号的位置,第一个是起始位置,从0开始,第二个是长度
bar-sample-case-no-include=false ; 编码中是否包含病历号
bar-case-no-location=@Size(-1 -1) ; code中病历号的位置,第一个是起始位置,从0开始,第二个是长度

注意,对于样本号和病历号的位置的两个属性,我们将其定义为QSize。这个地方最好是能够使用QPair,这样概念很清晰,但是遗憾的是Qt未提供QPairQVariant的机制。我们也可以使用QList<QVariant>,可以定义多个值,比如bar-sample-id-location=100, 200。但是这样又有一个问题,如何确保用户修改的ini文件中值的个数恰好是2个?如果只有一个怎么办?如果有3个怎么办?我们还要花更多的时间写更多的代码来处理这种错误。所以,找一个能够明确界定范围的值是最省事的,比如QSizeQPoint都是可以的。

我们在IConfiger中定义了一个统一的接口来一次性返回所有的配置参数。

有了规则定义,我们就可以编写解析的方法。我们定义一个用于解析条码的类。这个类以后发生改变的机会应该是很小的,所以我们暂时也不用考虑定义什么接口了,直接实现它就可以了。

Frameworks中添加一个类CodeParser,它只有一个静态方法parser。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::tuple<int, QString, QString> CodeParser::parse(const QString &code, const TBarParserRule &rule)
{
RETVAL_LOG_IF(code.isEmpty(), {}, QString("empty bar code!"));
auto type_str = rule._sample_type_location==-1 ? code.last(1).toUpper() : code.mid(rule._sample_type_location,1).toUpper();
auto bm_code = rule._sample_type_bm_code.toUpper();
auto pb_code = rule._sample_type_pb_code.toUpper();
if(type_str!=bm_code && type_str!=pb_code)
{
type_str = rule._sample_type_default.toUpper();
}
auto sample_type = (type_str==bm_code) ? ESampleType::eBmType : ESampleType::ePbType;
auto sample_id = rule._sample_id_include ? code.mid(rule._sample_id_location.width(), rule._sample_id_location.height()) : QString();
auto case_no = rule._case_no_include ? code.mid(rule._case_no_location.width(), rule._case_no_location.height()) : QString();
return {sample_type, sample_id, case_no};
}

我们对它做单元测试。一般来说我们需要为每个要做测试的类单独写一个测试用例,但是这个函数实在是太简单了,我们就将它直接放在ScanTaskTester里面测试了。

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
void ScanTaskTester::test_codeParseTest()
{
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";

{
QString code{"SAMPLE_001-CASE_001-P"};
TRACE() << "解析条码 " << code;
auto [type_id, sample_id, case_no] = CodeParser::parse(code, rule1);
QCOMPARE(type_id, ESampleType::ePbType);
QCOMPARE(sample_id, "SAMPLE_001");
QCOMPARE(case_no, "CASE_001");
}

{
QString code{"SAMPLE_001-CASE_001-B"};
TRACE() << "解析条码 " << code;
auto [type_id, sample_id, case_no] = CodeParser::parse(code, rule1);
QCOMPARE(type_id, ESampleType::eBmType);
QCOMPARE(sample_id, "SAMPLE_001");
QCOMPARE(case_no, "CASE_001");
}
...

计算低倍扫描范围

拍摄预览图的根本目的是为了找出在其中做低倍扫描的范围来。一般而言我们不会对整张玻片做低倍扫描,因为如果对整张玻片做完全的低倍扫描,会得到上千张照片,不管是时间还是空间都是很大的浪费。同时,往往很多玻片(尤其是骨髓)来说,玻片上面大部分区域都是空白的或者是过于浓厚而无效的区域。选择哪个区域做低倍扫描涉及到具体的业务领域经验和图像分析知识,同时也将是受到用户诉求影响很大的地方。具体的实现算法本书不会涉及,但是可以明确的一点是,未来它将会有多种算法,并且需要根据样本类型,用户策略等进行调整。为此,我们将算法提取出来,定义一个接口IPreviewImageAnalyzer,并提供不同的实现。

1
2
3
4
5
class FRAMEWORK_EXPORT IPreviewImageAnalyzer
{
public:
virtual QRect getScanRegion(cv::Mat mat, int sample_type, const QRect& valid_rect) = 0;
};

它只有一个接口getScanRegion(),它的第一个参数是要分析的预览图,第二个参数是样本的类型(外周血还是骨髓),第三个参数是硬件支持的扫描范围(在这个范围之外的区域是无法进行扫描的)

在前期,我们定义一个测试桩就可以了:

1
2
3
4
5
6
QRect PreviewImageAnalyzerStub::getScanRegion(cv::Mat mat, int sample_type, const QRect &valid_rect)
{
Q_UNUSED(mat);
Q_UNUSED(sample_type);
return QRect(887+valid_rect.x(),222+valid_rect.y(),293,224);
}

同样的,我们在AlgorithmFactory中定义工厂方法:

1
2
3
4
5
6
7
std::unique_ptr<IPreviewImageAnalyzer> AlgorighmFactory::makePreviewAnalyzer()
{
#ifdef USE_STUB
return std::make_unique<PreviewImageAnalyzerStub>();
#else
#endif
}

在多线程中触发界面交互

接下来讨论一个比较有意思的功能的实现。在前面介绍doPreview()时说过它的工作流程:

  1. 调用GetSlidePreview()拍摄样本的预览图和标签图,并解析出标签上的条码/二维码内容
  2. 保存预览图和标签图到本地硬盘中和扫描档案文件中
  3. 解析标签图,识别出样本编号和样本类型
  4. (可选)弹出对话框,让用户确认和修改样本编号和扫描参数等信息
  5. 保存扫描参数到索引文件
  6. 利用预览图找出要进行低倍扫描的范围
  7. (可选)弹出对话框,让用户确认和修改地被扫描的范围

在完成标签中的条码解析后,会弹出一个对话框,让用户修改和确认修改参数;在计算出低倍扫描范围后,还会触发对话框,让用户审核和调整要进行低倍扫描的范围。这带给我们一个问题:ScanTask是工作在GUI之外的线程上的,不能在上面做GUI的操作。在传统方式下,我们可能只能将doPreview()拆分成三个函数,在GUI线程中先join第一个,再运行并join第二个,再运行并join第三个。这种做法带来的碎片化比经常被诟病的回调还令人烦恼。这个问题在C#中我们可以用一个dispatcher来解决,而在Qt中,有一种更解耦的方式,利用connect()方法,并指定它的第五个参数值为Qt::QueuedConnection

我们看一下Qt::ConnectionType的取值:

  • Qt::AutoConnection:(Default) If the receiver lives in the thread that emits the signal, Qt::DirectConnection is used. Otherwise, Qt::QueuedConnection is used. The connection type is determined when the signal is emitted.
  • Qt::DirectConnection:The slot is invoked immediately when the signal is emitted. The slot is executed in the signalling thread
  • Qt::QueuedConnection:The slot is invoked when control returns to the event loop of the receiver’s thread. The slot is executed in the receiver’s thread.
  • Qt::BlockingQueuedConnection:Same as Qt::QueuedConnection, except that the signalling thread blocks until the slot returns. This connection must not be used if the receiver lives in the signalling thread, or else the application will deadlock.

一般来说,当我们使用connect()的四参数形式的时候,它的第五个参数会使用默认值Qt::AutoConnection。此时,Qt会根据sender和receiver是否在同一个线程中来决定取值:如果在同一个线程中,就使用Qt::DirectConnection,此时slot函数运行在sender的线程中;如果不在同一个线程中,就使用Qt::QueuedConnection,此时slot会在receiver的EventLoop被调度时运行,运行在Receiver的线程中。而取值Qt::BlockingQueuedConnect前者的一个增强,它会阻塞sender的线程,直到receiver运行slot运行结束并返回。

注意,connect还有一个三参数的版本:connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)。这个版本不能指定connection_type,一定是DirectConnection。我们在后面会讲到,QTEST中有一个bug就撞了这个坑。

另外一个要注意的问题是Qt::QueuedConnection,它是基于QObjectEventLoop的调度的,有时候会得到很违反直觉的结果。在使用时要注意看一下效果,如果不是自己期望的行为,还是要回到底层的线程手段上。个人的看法,Qt的多线程下的signal-slot机制并不适合密集多线程场景,它本质上还是为和界面之间的少量交互而开发的。

回到doPreview()函数,我们只需要在需要将控制权暂时还给界面时发送signal,在控制端将这个signal和对应的slot关联时指定连接模式为BlockingQueuedConnection就可以了。

我们为ScanTask定义两个signal,用于分别通知界面弹出对应的对话框:

1
2
void sigChooseOptions(QVariantMap& options, bool& ok);
void sigChooseLowScanRange(cv::Mat image, QRect& rect, bool& ok);

注意这两个signal的参数,既然他们是阻塞方式,我们就可以通过直接修改参数来取回结果值。所以,我们使用了引用而没有加const修饰。

定义一个函数verifyOptions()封装用户确认的机制:

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
bool ScanTask::verifyOptions()
{
_impl->_whiteCount = _impl->_sampleType==ESampleType::ePbType
? _impl->_configer->PbCount() : _impl->_configer->BmCount();
_impl->_whiteMethod = _impl->_sampleType==ESampleType::ePbType
? _impl->_configer->PbScanMethod() : _impl->_configer->BmScanMethod();
_impl->_redCount = _impl->_sampleType==ESampleType::ePbType
? _impl->_configer->RedCount() : 0;
_impl->_megaCount = _impl->_sampleType==ESampleType::ePbType
? 0 : _impl->_configer->MegaCount();

RETVAL_LOG_IF(_impl->_batchScan, true, "批量扫描,跳过手工确认参数");
QVariantHash params{
{"sampleId", _impl->_sampleId},
{"sampleType", _impl->_sampleType},
{"scanTime", _impl->_scanTime},
{"caseNo", _impl->_caseNo },
{"pb_count", _impl->_configer->PbCount()},
{"pb_method", _impl->_configer->PbScanMethod()},
{"bm_count", _impl->_configer->BmCount()},
{"bm_method", _impl->_configer->BmScanMethod()},
{"scan_white", _impl->_configer->ScanWhiteCell()},
{"white_count", _impl->_whiteCount},
{"scan_red", _impl->_configer->ScanRedCell()},
{"red_count", _impl->_redCount},
{"scan_mega", _impl->_configer->ScanMegaCell()},
{"mega_count", _impl->_megaCount},
};
bool ok = true;
emit sigChooseOptions(params, ok);
if(ok)
{
_impl->_sampleId = params.value("sampleId").toString();
_impl->_sampleType = params.value("sampleType").toInt();
_impl->_caseNo = params.value("caseNo").toString();
if(_impl->_sampleType==ESampleType::ePbType)
{
_impl->_whiteCount = params.value("scan_white").toBool()
? params.value("pb_count").toInt() : 0;
_impl->_whiteMethod= params.value("pb_method").toInt();
}
else
{
_impl->_whiteCount = params.value("scan_white").toBool()
? params.value("bm_count").toInt() : 0;
_impl->_whiteMethod = params.value("bm_method").toInt();
}
_impl->_redCount = params.value("scan_red").toBool()
? params.value("red_count").toInt() : 0;
_impl->_megaCount = params.value("scan_mega").toBool()
? params.value("mega_count").toInt() : 0;
}
return ok;
}

它构造参数并emit sigChooseOptions给界面,界面会构造对话框让用户确认。如果用户取消了操作,ok就被设置为falsedoPreview()会检查verifyOptions()的返回值,如果为false就退出扫描。

对另一个信号sigChooseLowScanRange,我们构造函数verifyLowScanRange()来处理相似的流程。

界面内容我们放到后面再讨论,我们只需要在单元测试中实现两个slot函数就可以继续下去了。

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
67
68
69
70
71
72
73
74
75
76
77
void ScanTaskTester::test_verifyOptions()
{
//QSKIP(__func__);
{
TRACE() << "测试不修改参数的外周血";
auto configer = AlgorighmFactory::makeConfiger("config.ini");
auto scaner = AlgorighmFactory::makeScanDevice();
ScanTask task{configer.get(), scaner.get()};
task.setSampleType(ESampleType::ePbType);

configer->SetScanWhiteCell(true);
configer->SetPbCount(100);
configer->SetBmCount(200);
configer->SetScanRedCell(true);
configer->SetRedCount(1000);
configer->SetScanMegaCell(false);
configer->SetMegaCount(50);
QThread::sleep(1);

QCOMPARE(task.verifyOptions(), true);

QCOMPARE(task.cellCount(ECellType::eWhiteCell), configer->PbCount());
QCOMPARE(task.cellCount(ECellType::eRedCell), configer->RedCount());
QCOMPARE(task.cellCount(ECellType::eMegaCell), 0);
}

{
TRACE() << "测试用户放弃了扫描";
auto configer = AlgorighmFactory::makeConfiger("config.ini");
auto scaner = AlgorighmFactory::makeScanDevice();
ScanTask task{configer.get(), scaner.get()};
connect(&task, &ScanTask::sigChooseOptions,
this, [](QVariantHash& o, bool& b){ b=false; });
QCOMPARE(task.verifyOptions(), false);
}

{
TRACE() << "测试用户修改了玻片类型和扫描数量";
auto configer = AlgorighmFactory::makeConfiger("config.ini");
auto scaner = AlgorighmFactory::makeScanDevice();
ScanTask task{configer.get(), scaner.get()};

configer->SetScanWhiteCell(true);
configer->SetPbCount(100);
configer->SetBmCount(200);
configer->SetScanRedCell(false);
configer->SetRedCount(1000);
configer->SetScanMegaCell(false);
configer->SetMegaCount(50);

task.setSampleType(ESampleType::ePbType);
connect(&task, &ScanTask::sigChooseOptions,
this, [](QVariantHash& options, bool& ok){
options.insert("sampleType", ESampleType::eBmType);
options.insert("pb_count", 100);
options.insert("bm_count", 300);
options.insert("scan_white", true);
options.insert("scan_red", false);
options.insert("red_count", 1000);
options.insert("scan_mega", true);
options.insert("mega_count", 20);
options.insert("pb_method", eSingleScan);
options.insert("bm_method", EScanMethod::eMatrixScan);
ok=true;
});

task.setSampleType(ESampleType::eBmType);
QCOMPARE(task.verifyOptions(), true);

QCOMPARE(task.cellCount(ECellType::eWhiteCell), 300);
QCOMPARE(task.cellCount(ECellType::eRedCell), 0);
QCOMPARE(task.cellCount(ECellType::eMegaCell), 20);
QCOMPARE(task.scanMethod(ECellType::eWhiteCell), EScanMethod::eMatrixScan);
QCOMPARE(task.scanMethod(ECellType::eMegaCell), EScanMethod::eSingleScan);
QCOMPARE(task.sampleType(), ESampleType::eBmType);
}
}

完善doPreview()代码:

最后,doPreview()函数就是这个样子,为了简洁,我们略去了部分错误处理的代码

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
67
68
bool ScanTask::doPreview()
{
int retval;
std::vector<PicInfor> picList;
ScanInfo scanInfo;
auto cleanup = qScopeGuard([&picList](){
std::for_each(picList.begin(), picList.end(), [](auto& pic){ delete []pic.matPic;});
});
// 生成预览图使用的保存目录
auto dumpPath = viewDumpDirPath(EViewType::eLowView, 0, 0, 0);
emit sigPrepareDump(dumpPath);

std::string qrcode;
retval = _impl->_device->GetSlidePreview(&picList, &scanInfo, qrcode);
_impl->_validRect.setRect(scanInfo.x, scanInfo.y, scanInfo.width, scanInfo.height);
auto labelMat = cvtPicToMat(picList.at(0));
auto prevMat = cvtPicToMat(picList.at(1));
auto qrCode = QString::fromStdString(qrcode);
emit sigPreviewed(prevMat, labelMat, qrCode);
// dump预览图和标签图
emit sigDumpImage(prevMat, -1, 0, _impl->_prevImageFormat, _impl->_prevImageQuality, viewDumpDirPath(EViewType::ePreView, 0,0,0));
emit sigDumpImage(prevMat, -2, 0, _impl->_prevImageFormat, _impl->_prevImageQuality, viewDumpDirPath(EViewType::eLabelView, 0,0,0));
// 预览图和标签图视野写入数据库
QString prevZipPath = QString("images/%1").arg(previewFileName());
QString labelZipPath = QString("images/%1").arg(labelFileName());
emit sigPackViewInfo(EViewType::ePreView, 0, 0, 0, QSize(prevMat.cols, prevMat.rows), QRect(), EScanMethod::eSingleScan, prevZipPath );
emit sigPackViewInfo(EViewType::eLabelView, 0, 0, 0, QSize(labelMat.cols, labelMat.rows), QRect(), EScanMethod::eSingleScan, labelZipPath);
// 预览图和标签图写入zip文件
emit sigZipFile(QString("%1/preview.jpg").arg(dumpPath), prevZipPath);
emit sigZipFile(QString("%1/qrcode.jpg").arg(dumpPath), labelZipPath);

auto v = CodeParser::parse(qrCode, _impl->_configer->BarParserRule());
_impl->_sampleType = std::get<0>(v);
_impl->_sampleId = std::get<1>(v);
_impl->_caseNo = std::get<2>(v);
if(!verifyOptions())
{
TRACE() << "在verifyOptions用户取消了操作";
return false;
}
auto selector = AlgorithmFactory::makePreviewAnalyzer();
_impl->_lowScanRect = selector->getScanRegion(prevMat, _impl->_sampleType, _impl->_validRect);
if(!verifyLowScanRange(prevMat))
{
TRACE() << "在verifyLowScanRange用户取消了操作";
return false;
}
emit sigLowScanRanged(_impl->_lowScanRect);
// 保存样本信息到数据库
emit sigSetScanInfo({
{"sampleId", _impl->_sampleId},
{"sampleType", _impl->_sampleType},
{"scanTime", _impl->_scanTime},
{"caseNo", _impl->_caseNo },
{"deviceId", _impl->_machineSN},
{"hospital", _impl->_configer->Hospital()},
{"white_count", _impl->_whiteCount},
{"red_count", _impl->_redCount},
{"mega_count", _impl->_megaCount},
{"white_method", _impl->_whiteMethod},
{"lowZoom", _impl->_lowZoom},
{"highZoom", _impl->_highZoom},
{"lowPixel", _impl->_lowPixel},
{"highPixel", _impl->_highPixel},
});
emit sigPackSlideInfo();
return true;
}

在这里我们坑看到,我们通过一系列的emit来解耦操作。而这些signalslot的操作,最合适的地方就是doPrepare()了。

测试doPreview()

下面我们开始对doPreview()编写测试用例。下面是一个做外周血的默认测试流程:

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
void ScanTaskTester::test_doPreview_01()
{
//QSKIP("not run ");
QThread::sleep(1);
auto path = QString("%1/config.ini").arg(QCoreApplication::applicationDirPath());
auto configer = AlgorithmFactory::makeConfiger(path );
auto scaner = AlgorithmFactory::makeScanDevice();
// 初始化配置参数
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);
configer->SetHospital("AABBCCDDEEFF");
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);

ScanTask task{configer.get(), scaner.get()};

QSignalSpy spy1(&task, &ScanTask::sigPrepareDump);
QSignalSpy spy2(&task, &ScanTask::sigPreviewed);
QSignalSpy spy3(&task, &ScanTask::sigDumpImage);
QSignalSpy spy4(&task, &ScanTask::sigPackViewInfo);
QSignalSpy spy5(&task, &ScanTask::sigPackSlideInfo);

QCOMPARE(task.doPreview(), true);

QCOMPARE(spy1.size(), 1);
QCOMPARE(spy2.size(), 1);
QCOMPARE(spy3.size(), 2);
QCOMPARE(spy4.size(), 2);
QCOMPARE(spy5.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);
}

我们主要是使用QSignalSpy来测试每个signal的被发送的次数。QSignalSpyQTEST定义的一个类,它是QList的派生类,它会将监控的信号的每一次发送都添加到自己的QList里面。我们简单地获取其长度,来确认是否确实发送了信号。

保存cv::Mat图像到硬盘

OpenCV中保存图像到硬盘有专门的函数imwrite()。但是这个函数有一个很大的问题,它不支持中文路径,这各问题说大不大说小不小,解决它也很简单:只要按照imwrite()的实现思路,先使用cv::imencode()来编码图像,再用Qt自己的文件操作功能把数据保存到文件中。我们在图像工具类ImageTool中实现了这个功能。我们提供了transformMatdumpFile两个函数。因为做图像编码是比价耗时的活动,我们后面可能还会用到图像编码数据,所以将这个活动分开实现。

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
QByteArray ImageTool::transformMat(const cv::Mat &mat, int format, int quality)
{
std::vector<uchar> buf;
if(format==EImageFormat::ePngFormat)
{
cv::imencode(".png", mat, buf, {cv::ImwriteFlags::IMWRITE_PNG_COMPRESSION, quality});
}
else
{
cv::imencode(".jpg", mat, buf, {cv::ImwriteFlags::IMWRITE_JPEG_QUALITY, quality});
}
return QByteArray(reinterpret_cast<char*>(buf.data()), int(buf.size()));
}

bool ImageTool::dumpFile(const QByteArray &data, const QString &file_path)
{
QFile file(file_path);
if(!file.open(QIODevice::WriteOnly))
{
TRACE() << QString("Failed to open file %1 to write.").arg(file_path);
return false;
}
file.write(data);
file.close();
return true;
}