低倍图选区界面

我们回过头来继续完善单张扫描的界面。今天我们要处理一下低倍图的选区实现。从功能上讲,低倍图的选区和预览图的选区是很相似的,都是在一个对话框上面拖动一个区域来设置要选择的范围。但是这里要更复杂一些,因为低倍图会扫描几十,上百,甚至上千张——如果使用10倍镜对一张涂满了的玻片做扫描,我们会得到大约1300张照片。当然,一般情况下根本不需要使用这么多,对于一般的样本来说,包含两百个白细胞的外周血区域其实也就大约火柴贵粗细的范围。通常情况下,通过对拍摄得到的预览图的识别处理,只需要拍摄一百左右的低倍图就够了。只有很特殊的情况,比如癌症晚期白细胞特别稀疏,核型,尤其是用户有特别要求才需要扫描整张玻片。

扫描的视野数量多了主要的问题是占用内存资源的大小问题。一张彩色的2448x2048分辨率的照片,图像数据大约会占用15M,1000张照片就是15G内存。对大多数使用环境来说,这是一个很客观的消耗,而且还是一个小概率使用场景。如果是大概率需求,那么事情反而很简单——只要堆资源就行了。反而是小概率事件不好这么做。我们会从易到难,先实现一个支持图像数量适度的实现,然后再考虑如何进一步让它能够支持更多的图片视野——这个实现对我们其他的高倍镜大范围扫描的产品是很有价值的。比如,使用20倍物镜扫描2cmx2cm的组织,大约会得到2000-3000张照片,如果用40倍,就是8000到上万张。

另外,更重要的是,我们还要考虑当数据保存在远端网络服务器时的情况,在这时,我们不可能一次性先把几百张上千张照片下载下来再显示的。

为了节省工作量,我们只实现一个显示平铺图片的对话框,不会再花时间来添加ResizedRectItem——工作和在PreviewChooseDlg中是基本相同的。

TileImageView

我们新增一个类,TileImageView,和前面实现的PreviewChooseView一样,它从ZoomableView派生,继承缩放功能。和PreviewChooseView的设计思路一样,我们在这里不定制Scene类,而是定制View类。但是,因为我们略去了相关的实现,所以这个类也很简单,什么都不需要实现。

BufferedPixmapItem

我们仍然是使用QGraphicsItem来在View中显示每张图片。因为扫描出来的照片是在视野中平铺的,我们只需要创建Item并正确设置它们的位置就可以了。

图像从我们之前扫描保存下来的低倍图的保存目录中读取,图像文件遵循固定的命名规则,我们就可以通过文件名得到图片的行列数,并进而计算出Scene的大小尺寸。

但是,不管是从硬盘或网络上读取图像文件还是图像数据解码都是很慢的操作,当图像很多的时候,一次性将图像全读出来是没有必要的,我们利用QGraphicsView框架的虚拟机制,只有当真的需要绘制这个Item的时候才真正从文件中读取。

我们直接定义BufferedPixmapItem,我们不利用QGraphicsPixmap,而是直接从QGraphicsItem派生出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class BufferedPixmapItem : public QGraphicsItem
{
public:
enum { Type = UserType + 2 };
static QSize size;
explicit BufferedPixmapItem(const QString& path, const QPoint& pos, QGraphicsItem* parent=nullptr);
~BufferedPixmapItem();
QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
signals:
public slots:

protected:
QString _path;
QPoint _pos;
QPixmap _pixmap;
bool _loaded{false};
};

这是一个极简单的QGraphicsItem类实现,我们只实现了boundingRect()paint(),其中重点是paint()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void BufferedPixmapItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
if(!_loaded)
{
auto file_path = QString("%1/%2_%3.jpg").arg(_path).arg(_pos.x()).arg(_pos.y());
//TRACE() << "加载图像" << file_path;
_pixmap.load(file_path);
_loaded = true;
}
else
{
}
painter->drawPixmap(option->rect, _pixmap, _pixmap.rect());
//painter->setPen(QPen(Qt::green, 5));
//painter->drawRect(option->rect.adjusted(10,10,-10,-10));
}

每个图元只有在第一次真正需要绘制自身的时候才会尝试读取文件。为了观察效果,我们可以加上调试打印语句,并绘制边框以便于观察。

LowChooseDlg

LowChooseDlg用于显示扫描出来的低倍图,并提供用户选择范围的手段。为了简单起见,我们只实现了显示图像的功能。我们定义了函数setSource(),它根据指定的图像路径计算出图片的行列数,创建图元并将设置其在Scene中的位置。

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
bool LowChooseDialog::setSource(const QString &path)
{
QDir dir(path);
RETVAL_LOG_IF(!dir.exists(), false, "invalid path");
auto [columns, rows] = countImages(path);
_impl->_columns = columns;
_impl->_rows = rows;
_impl->_srcPath = path;
RETVAL_LOG_IF(columns*rows==0, false, "no image find!");

ui->imageView->scene()->clear();

QRectF rect(0, 0, _impl->_imageSize.width()*columns, _impl->_imageSize.height()*rows);
ui->imageView->scene()->setSceneRect(rect);
ui->imageView->centerOn(rect.width()/2, rect.height()/2);

for(int row=0; row<rows; ++row)
{
for(int col=0; col<columns; ++col)
{
auto item = new BufferedPixmapItem(path, QPoint(col, row));
ui->imageView->scene()->addItem(item);
item->setPos(col*_impl->_imageSize.width(), row*_impl->_imageSize.height());
}
}
}

QSize LowChooseDialog::countImages(const QString &path, const QStringList &filters)
{
auto names = QDir(path).entryList(filters, QDir::Filter::Files);
int count1=-1, count2=-1;
QRegularExpression re{R"(^(\d+)_(\d+))"};
for(const auto& name: names)
{
auto m = re.match(name);
if(!m.captured(1).isEmpty() && !m.captured(2).isEmpty())
{
count1 = std::max(count1, m.captured(1).toInt());
count2 = std::max(count2, m.captured(2).toInt());
}
}
return {count1+1, count2+1};
}

接下来测试一下效果。创建对话框并设置它的图像文件目录即可:

1
2
3
LowChooseDialog dlg;
dlg.setSource(R"(D:\DataStore\TestData\scanstub\2024-02-19-092454\lower)");
dlg.exec();

运行后,效果如下:

绿色的线是我们在每张图片上绘制的标记,利用它来观察绘制情况。调试窗口输出如下。我们看到,在启动后,我们将View移动到Scene的中心,此时恰好落在两个视野交界处,所以调试窗口打印了读取两个文件的日志。

1
2
[ BufferedPixmapItem::paint (...)] 加载图像 "D:\\DataStore\\TestData\\scanstub\\2024-02-19-092454\\lower/2_3.jpg"
[ BufferedPixmapItem::paint (...)] 加载图像 "D:\\DataStore\\TestData\\scanstub\\2024-02-19-092454\\lower/3_3.jpg"

我们做移动,缩放操作,可以看到会随着图片的显示才会真的读取。比如,我们滚动鼠标缩小图像,慢慢缩小到显示了其他的照片视野时,能够从调试窗口看到又读取了两个图片:

1
2
3
4
[ BufferedPixmapItem::paint (...)] 加载图像 "D:\\DataStore\\TestData\\scanstub\\2024-02-19-092454\\lower/2_3.jpg"
[ BufferedPixmapItem::paint (...)] 加载图像 "D:\\DataStore\\TestData\\scanstub\\2024-02-19-092454\\lower/3_3.jpg"
[ BufferedPixmapItem::paint (...)] 加载图像 "D:\\DataStore\\TestData\\scanstub\\2024-02-19-092454\\lower/2_4.jpg"
[ BufferedPixmapItem::paint (...)] 加载图像 "D:\\DataStore\\TestData\\scanstub\\2024-02-19-092454\\lower/3_4.jpg"

到这里,我们一个简单地低倍图拼接显示就完成了。

分层图像显示

但是这个实现还是有一点问题的,我们一直缩小,会看到界面显示会有一点卡顿,因为每当一个新的照片需要显示时都会读取和解析文件。尤其是我们是缩小显示,图像缩得越小,每次增加的视野数量就越多,读取的负载就越来越高,界面也会显得卡顿,我们这里仅仅是$6 \times 7 = 42$张图像,如果图像数量上去了,快速缩放时的卡顿会更明显。当然,当读取了所有的图像之后,就不会卡顿了。但是这又带来另一个问题,假设有1000张照片,就是15G内存占用,电脑不得不使用虚拟内存,每一步都会卡顿了。

解决这个问题,我们需要重新考虑如何显示图像。我们采用一种标准的显示超大图像的方式,将原始的图像逐级缩小,生成一系列的金字塔图层,每一层图层的尺寸都是下一层的1/2,也就是一个金字塔的形状。这样,当我们需要显示时,可以根据缩放比例选择合适的图层来显示。

Tiff图像格式就支持这种思路,只是过于专业,我还从来没有在普通场合见过这类图像文件。

图像金字塔可以在每次使用时动态生成,也可以在采集的时候生成。我们的金字塔向上逐级缩小,直到缩小到只剩下一个视野大小,这是一个初中数学水平,极限情况下,金字塔的总的图片数量只比最底层原始图像多1/3,并不是一个很大的负担。

我们需要修改一下低倍图采集中的处理流程,增加生成金字塔的过程。不过在此之前,我们先看一下如何使用和显示它。

假设我们原始层有$MxN$张图片,每张图片由一个图元来负责显示,这些QGraphicsItem平铺着显示。现在我们一点点缩小视野的显示比例,当比例减小到$\frac{1}{2}$后,就要切换到图层1了。图层1的图片数量是$\lceil \frac{M}{2}\rceil \times \lceil \frac{N}{2}\rceil$,这里的$\lceil \frac{M}{2}\rceil$表示$M/2$并向上取整。

后面我们将简单地用$\frac{M}{2}$就表示M/2后向上取整。

这时,我们有两种做法可供选择:一种是保持Scene中现有的$M\times N$的Item数量不变,每个Item只显示层1的一张图片的1/4;另一种做法是将Scene中的图元数量减少到$\frac{M}{2} \times \frac{N}{2}$,每个图元仍然显示一张完整的图片。

两种方式都可以,具体看个人的喜好。而就我个人而言,从前面的PreviewChooseViewTileImageView,我们的View一直保持很干净的情况,将显示逻辑放到了ItemDialog类中了。我希望能够将这个逻辑保持下去。所以,接下来我们使用第一种方式。

第一种方式的好处是不需要频繁的添加和删除Scene中的Item,缺点是,假如是显示一个极大的图像,那么Item的个数,以及填充的处理消耗,可能会比较可观。而第二种要直观一些。
另外我们选择方式一的着眼点是以后位置计算会稍微方便一点——因为图元的编号是不变量,可以方便地直接计算出对应层0的坐标来,如果是方法2,就需要加进去缩放的处理。

我们使用方法一,还带来一个问题是,除了图层0,其他图层上,都是几个图元共享一个图像文件,我们需要一个图像文件加载管理器来统一管理,而避免每个图元自己打开图像文件提取数据。我们定义一个类ImageProvider来将图像文件的加载管理给提取出来。利用这个全局类,我们还可以做一些Cache相关的东西。我们先实现它。

ImageProvider这个类名字来源于QML里面的QQuickImageProvider,这个名字感觉很形象,就在这里借用了。

有兴趣的人可以去看一下QQuickImageProvider的说明,我们的思想是相似的,当然采取的技术和路径是不同。

定义ImageProvider

首先定义抽象类ImageProvider。我们这里定义为抽象类,而不是定义成接口,它负责管理了行列数等信息,只是requestImage()函数被声明为纯虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ImageProvider : public QObject
{
Q_OBJECT
public:
explicit ImageProvider(const QString& src_path, int columns, int rows, QObject *parent = nullptr);
~ImageProvider();
virtual QString srcPath() const;
virtual int rows() const;
virtual int columns() const;
virtual QSize imageSize() const;
virtual int maxLevel() const;
virtual void setMaxLevel(int val);
virtual QPixmap requestImage(const QPoint& pos, int level) = 0;
signals:
private:
struct Implementation;
QScopedPointer<Implementation> _impl;
};

然后,实现简单地直接读取文件的Provider类,SimpleImageProvider,它只需实现requestImage()

1
2
3
4
5
6
7
```cpp
QPixmap SimpleImageProvider::requestImage(const QPoint &pos, int level)
{
auto file_path = QString("%1/%2_%3.jpg").arg(srcPath()).arg(pos.x()).arg(pos.y());
return QPixmap(file_path);
}
```

有了这个类,我们就可以重构之前的BufferedPixmapItem了:

重构BufferedPixmapItem

接下来我们重构之前的BufferedPixmapItem类。在BufferedPixmapItem中,我们是让它自己来做读取操作的,现在,我们把这个业务逻辑从paint()里面给拿出来,交给ImageProvider来做。这样,BufferedPixmapItem的职责更为纯粹,与外部依赖更少。

修改BufferedPixmapItem类定义,删除它保存的路径,增加一个只想ImageProvider的指针,_provider,并修改BufferedPixmapItem的构造函数的参数,将原先的QString类型的路径改为ImageProvider的指针:

1
2
3
4
5
6
7
8
9
10
11
class ImageProvider;
class BufferedPixmapItem : public QGraphicsItem
{
public:
explicit BufferedPixmapItem(ImageProvider* provider, const QPoint& pos, QGraphicsItem* parent=nullptr);
...
protected:
//QString _path;
ImageProvider* _provider;
...
};

然后,paint()函数简化为:

1
2
3
4
5
6
7
8
9
void BufferedPixmapItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
if(!_loaded)
{
_pixmap = _provider->requestImage(_pos, 0);
_loaded = !_pixmap.isNull();
}
...
}

最后,在启动处创建SimpleImageProvider实例,并装配到图元中即可。我们在类中定义一个SimpleProvider的智能指针实例,并在setSource()中进行初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool LowChooseDialog::setSource(const QString &path)
{
...
_impl->_provider.reset(new SimpleImageProvider(path, columns, rows));
for(int row=0; row<rows; ++row)
{
for(int col=0; col<columns; ++col)
{
auto item = new BufferedPixmapItem(_impl->_provider.get(), QPoint(col, row));
...
}
}
}

试一下,行为和原先完全一致。接下来我们就要实现分层显示了。

实现分层图像加载类LayeredPixmapProvider

接下来我们实现真正的分层图像加载管理类LayeredPixmapProvider

1
2
3
4
5
6
7
8
9
10
11
12
class LayeredPixmapProvider : public ImageProvider
{
Q_OBJECT
public:
explicit LayeredPixmapProvider(const QString& base_path, int columns, int rows, QObject *parent = nullptr);
virtual ~LayeredPixmapProvider();
QPixmap requestImage(const QPoint& pos, int level);
signals:
private:
struct Implementation;
QScopedPointer<Implementation> _impl;
};

当生成金字塔时,我们需要为图像目录做一个约定:每一层保存在一个目录中,目录名称就是层号。这样,假定低倍图的根目录是lower,那么第0层,也就是原始图的保存目录就是lower/0,第1层的目录就是lower/1,依次类推。这种保存约定影响了前面低倍扫描的保存

columnsrows是0层的图像的行列数,我们在前面已经给出了计算实现。

函数imageSize()是每张图像的大小。

函数maxLevel()返回最多的层数。这个值可以根据行列数计算出来的:
$$
maxLevel = max(\lceil log_2 M \rceil, \lceil log_2 N \rceil)
$$
我们只看requestImage()的实现。它有两个参数:参数pos指的是这个图元在层0的行列号,level是当前要显示的图层的层号:$0 \leq level \leq maxLevel$。它的代码也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
QPixmap LayeredPixmapProvider::requestImage(const QPoint &pos, int level)
{
double ratio = std::pow(0.5, level);
int x = qFloor(pos.x() * ratio);
int y = qFloor(pos.y() * ratio);
qreal dx = (pos.x() * ratio - x) * _impl->_imageSize.width();
qreal dy = (pos.y() * ratio - y) * _impl->_imageSize.height();
auto index = makeIndex(level, pos.x(), pos.y());
if(!_impl->_cache.contains(index))
{
auto file_path = QString("%1/%2/%3_%4.jpg").arg(_impl->_srcPath).arg(level).arg(pos.x()).arg(pos.y());
auto pixmap = new QPixmap();
pixmap->load(file_path);
RETVAL_LOG_IF(pixmap->isNull(), QPixmap(), QString("failed to load image %1").arg(file_path));
_impl->_cache.insert(index, pixmap);
}
auto pixmap = _impl->_cache[index];
int width = qMin(pixmap->width(), qCeil(_impl->_imageSize.width() * ratio));
int height = qMin(pixmap->height(), qCeil(_impl->_imageSize.height() * ratio));

return pixmap->copy(dx, dy, width, height);
}

函数的实现有一点绕,不过还是很好理解的:我们首先根据level计算出实际的缩放比例,然后计算出图元在当前图层中的图像中的相对位置,然后获取图像,并从中取出子区域的图像内容并返回。

这里面,_impl->_cache的定义如下:

1
2
3
4
5
struct LayeredPixmapProvider::Implementation
{
...
QCache<quint64, QPixmap> _cache;
};

QCache是Qt中的一个相当古老的类,至少在Qt4.x时就存在了,但是只是在Container Classes文档中的一个很不起眼的角落里面提了一句,估计很少有人注意到这个类。当然还有一个比它更低调的类,QPixmapCache

有一篇文档Optimizing with QPixmapCache讲述了如何使用QPixmapCache的,有兴趣的可以去看一下

QPixmapCache应该是一个类似于全局单件的东西,我们看到,它的所有方法都是static的,而且,它的文档中也明确,只能用于GUI线程中。我们本来就是在GUI中使用,这个倒不是问题。但是,作为一个全局实例,我们还是不大愿意使用它的。

QCache应该是基于于QHash实现的类,一般的Cache的经典实现是一个链表加一个容器(这里就是Hash)。自然,它的key就要是能够满足QHashkey的要求——要能计算哈希值,并且有==重载。我们应当是使用三个值来表征一张图像文件——它的level,它的行列号。遗憾的是,我们需要定义这个结构的qHash()std::hash(),以及实现operator ==。有点兴师动众了是吧?我们用一个取巧的做法,把这三个值合并成一个整数值作为key就是了。只要我们保证实际的值域不会超过这个范围,就不是问题了。从业务范畴上看,要拍摄10倍图,一张玻片也就1000来张图片,合并成一个32位整数绰绰有余了。不过,我们还是少想一点,直接用64位数吧。定义一个生成key的函数:

1
2
3
4
quint64 makeIndex(int level, int column, int row)
{
return ((quint64)(level))<<32 | ((quint64)column)<<16 | ((quint64)row);
}

我们甚至也可以用QString作为key使用,就如QPixmapCache中给出的接口一样。不管怎么做,这点性能都不是什么需要考虑的地方。

QCache来说,我们需要在构造函数中使用setMaxCost()来设置一下它的最大大小,即我们的图像缓冲中要最多保存多少张照片的数据。我们大致估计一下:

一张图片的大小是2448x2048,已经大于屏幕的显示范围了,说明即使是层0,一次也最多只需要加载4张照片。我们再考虑上下左右的移动,以及来回的缩放,给出一个10~20之间的值就完全可以满足流畅性的要求了。保留20张图片,消耗的内存是$20\times15=300M$,对于PC软件来说,这个值实在是说不上多浪费。

实现LayeredPixmapItem

接下来我们实现图元Item。为了明晰概念,我们不在BufferedPixmapItem上面修改,而是定义一个新的类,LayeredPixmapItem

1
2
3
4
5
6
7
8
9
10
11
12
13
class LayeredPixmapProvider;
class LayeredPixmapItem : public QGraphicsItem
{
public:
enum { Type = UserType + 3 };
LayeredPixmapItem(LayeredPixmapProvider* provider, QGraphicsItem* parent=nullptr);
~LayeredPixmapItem();
QRectF boundingRect() const override;
void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
private:
struct Implementation;
QScopedPointer<Implementation> _impl;
};

BufferedPixmapItem是不是完全一样?

它和BufferedPixmapItem唯一的区别在paint()中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void LayeredPixmapItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
Q_UNUSED(widget);
const qreal lod = option->levelOfDetailFromTransform(painter->worldTransform());
int level = qBound(0, int(qLn(lod)/qLn(0.5)), _impl->_provider->maxLevel());
if(level != _impl->_level)
{
_impl->_level = level;
_impl->_pixmap = _impl->_provider->requestImage(_impl->_pos, _impl->_level);
}
painter->drawPixmap(option->rect, _impl->_pixmap, _impl->_pixmap.rect());
//painter->setPen(QPen(Qt::green, 5));
//painter->drawRect(option->rect.adjusted(10,10,-10,-10));
}

BufferedPixmapItem中,所谓level并不使用,而在这里,我们需要计算出它的值来:

我们首先利用QStyleOptionGraphicsItem::levelOfDetailFromTransform()计算出当前的显示比例,然后利用这个比例计算出最合适使用的图层来。剩下的就没啥新的东西了。