Marshal设计

概述

Marshal界面如下图所示:
Marshal界面

使用Graphiscs Scene-View架构. 主要工作都在Scene类MarshalScene中完成.

主要类列表:

  • MarshalScene QGraphicsScene类
  • MarshalView QGraphicsView类
  • ChromosomePixmapItem 用于显示染色体Item的QGraphicsItem类
  • GroupLine: 表示一类染色体的Item.
  • ItemGroup: 表示一行的布局
  • PatternItem: 表示染色体模式图
  • Notation, NotationHead, NotationTail, NotationEdge : 批注对象
  • GroupTemplate : 定义Scene中布局的模板

MarshaScene

MarshalScene是用户对染色体做分类操作的主要界面后端Scene. 因为我们的View类几乎什么也没有做, 所以主要的用户交互都在Scene中实现.

同时, 它也是一个数据容器.

1
2
3
4
5
6
7
8
9
QMap<int, GroupLine*>   _lineRepo;          // 每一行的集合
QMap<int, ItemGroup*> _groupRepo; // 保存分组
QMap<int, QList<LayoutItem*>> _itemRepo; // 保存染色体和模式ITEM.
QMap<int, LayoutItem*> _patternRepo; // 模式图仓库.
Notation* _opNotation{nullptr}; // 正在添加的Notation. 在Notation模式下, 按下鼠标创建. 松开鼠标后拷贝到_notationList中,并且重置为nullptr.
QList<Notation *> _notationList; // Notation列表

int _resolution{400}; // 模式图的分辨率
PatternScribe _patternScrib; // 模式图描述集

布局

布局是一个层次结构: Page –> Line –> Group –> Item 组成.
支持不同的布局, 布局通过布局定义文件定义, 通过为Scene指定布局文件, Scene会加载布局文件, 解析内容, 并按照定义来动态绘制布局.

整个的系统布局在MarshalScene::reset()中被创建, 并在MarshalScene::_arrange()中重绘调整:

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
void MarshalScene::reset()
{
// 删除scene上面的所有的Item, 删除缓存
_initRepo();
// 布局初始化
_arrange();
}

void MarshalScene::_initRepo()
{
// 从Scene中删除所有的item, 包括notation.
...
// 创建GroupLineItem
for(int i=0; i<_impl->_pageTemplate->lineCount; ++i)
{
auto line_item = new GroupLine(_impl->_pageTemplate->pageWidth, _impl->_pageTemplate->groupMinHeight);
_impl->_lineRepo.insert(i, line_item);
addItem(_impl->_lineRepo.value(i));
}
// 创建GroupItem.
for(const auto& line: _impl->_pageTemplate->lineList)
{
int lineId = line.lineId;
auto line_item = _impl->_lineRepo.value(lineId);
for(const auto& group: line.groupList)
{
int groupId = group.groupId;
auto group_item = new ItemGroup(groupId, _impl->_pageTemplate->groupMinWidth, _impl->_pageTemplate->groupMinHeight, _impl->_pageTemplate->groupBaseHeight, _impl->_pageTemplate->fontSize, group.groupName);
group_item->setParentItem(line_item);
_impl->_groupRepo.insert(groupId, group_item);
_impl->_itemRepo.insert(groupId, QList<LayoutItem *>());
}
}

// 创建NotationItem
...
}

初始化时调用_initRepo(). 清理并重建基本图像元素, 得到的是一个不包含任何染色体对象的空界面.

函数_arrange()则是在每次界面更新的时候被调用. 它根据传入的scale参数–缩放系数, 来重新计算每个line, 每个group的高度和宽度, 重新布局, 并将每个item放进去.

布局相关的函数

布局相关的函数如下所示:
顶层的两个函数是_calcScale()_arrange().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// =========== 布局计算 =================================
// 重新排列
void _arrange(std::optional<qreal> scale = std::nullopt);
// 计算所有行的布局
void _arrangeLines(qreal scale);
// 计算一行内各个Group的布局
void _arrangeLineGroups(int line_id, qreal scale);
// 布局一个Group内的各个Item
void _arrangeGroupItems(int group_id, qreal scale);
// 布局计算时, 计算每个组件使用的最小尺寸, 以及空闲尺寸, 并分配空闲尺寸.
int _calcItemsMaxOriHeight(const QList<LayoutItem*> item_list, int align_policy);
int _calcItemsTotalOriWidth(const QList<LayoutItem*> item_list);
// 计算一个Group需要的空间大小(如果factor=1, 就是原始尺寸)
QSizeF _calcGroupMinSize(int groupId, qreal factor=1.0);
// 计算一个行需要的空间大小
std::pair<QSizeF, QSizeF> _calcLineMinSize(int lineId, qreal factor=1.0);

// ====================== 计算scale使用的函数 ============================
qreal _calcScale();
// 计算缩放因子时, 去掉overhead尺寸后的可用尺寸和物理尺寸相比, 计算缩放比例
// 计算一行的系统开销大小. 对于可以灵活使用的空间, 未考虑在内.
QSizeF _calcLineOverheadSize(int lineId);
// 计算一行中的所有item的总的原始尺寸(宽度和, 高度最大值)
QSizeF _calcLineItemsSize(int lineId, qreal factor=1.0);

_calcScale()

_calcScale()用于根据item计算出能够支持的最大的缩放比例, 以确保能够显示尽可能大的染色体.

注意, 染色体有两个缩放参数: 一个是统一缩放比例, 一个是针对个体的缩放比例. 在计算缩放比例时, 我们只考虑这个公共比例.

基本思路是:

  • 固定间隔overhead: 在垂直方向上, 行与行之间有固定间隔, 每个group有固定的最小高度(没有任何item时). 在水平方向上, group和group之间有固定的最小的间隔. section和section之间也有固定的最小间隔, 在group内部, Item和Item之间有固定间隔, Item和Group的边界之间也有固定间隔.
  • 将所有的固定间隔都去掉, 剩下的空间就是用于布置染色体Item的空间.
  • 染色体Item包括染色体Item和模式图Item, 其中, 模式图Item是高度可变(跟随临近的染色体), 宽度固定的特殊Item. 为此, 定义了一个基类LayoutItem来统一标识它们两个.
  • 然后, 可以计算出在水平方向和垂直方向上允许的最大的缩放比例, 取其中的最小值, 就是缩放比例.
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
qreal MarshalScene::_calcScale()
{
// 计算每一行的尺寸
//DEC_TRACER();
qreal overhead_width = 0;
qreal overhead_height = 0;
qreal items_width = 1;
qreal items_height = 1;
for(int lineId=0; lineId < _impl->_pageTemplate->lineCount; ++lineId)
{
auto line_overhead_size = _calcLineOverheadSize(lineId);
overhead_width = std::max(overhead_width, line_overhead_size.width());
overhead_height += line_overhead_size.height();
auto items_ori_size = _calcLineItemsSize(lineId, 1.0);
items_width = std::max(items_width, items_ori_size.width());
items_height += items_ori_size.height();
}
auto pageWidth = _impl->_pageTemplate->pageWidth;
auto pageHeight = _impl->_pageTemplate->pageHeight;
qreal free_width = _impl->_pageTemplate->pageWidth - overhead_width;
qreal free_height = _impl->_pageTemplate->pageHeight - overhead_height;

qreal scale_x = free_width / items_width;
qreal scale_y = free_height / items_height;
return std::min({scale_x, scale_y, _configer->getChromEnlargeInMarshal() });
}

QSizeF MarshalScene::_calcLineOverheadSize(int lineId)
{
const auto& line_template = _impl->_pageTemplate->lineList.at(lineId);
int group_count = line_template.groupCount;
int section_count = line_template.sectionCount;
auto groups_space = (group_count - section_count) * _impl->_pageTemplate->groupSpace;
auto sections_space = (section_count - 1) * _impl->_pageTemplate->groupSpace * line_template.sectionStdSpace;
auto groups_padding = group_count * 2 * _impl->_pageTemplate->groupPad;
int item_count = std::accumulate(line_template.groupList.cbegin()
, line_template.groupList.cend()
, 0
, [this](int s, const auto& g){ return s + _impl->_itemRepo[g.groupId].size(); });
auto items_space = item_count * _impl->_pageTemplate->itemSpace;

auto width = groups_space + groups_padding + sections_space + items_space;
auto height = _impl->_pageTemplate->groupBaseHeight;

return QSizeF(width, height);
}

QSizeF MarshalScene::_calcLineItemsSize(int lineId, qreal factor)
{
const auto& line_template = _impl->_pageTemplate->lineList.at(lineId);
QList<int> group_id_list;
std::transform(line_template.groupList.cbegin(), line_template.groupList.cend(), std::back_inserter(group_id_list), [](const auto& g){return g.groupId; });
qreal width=0, height=0;
for(int group_id: group_id_list)
{
width += _calcItemsTotalOriWidth(_impl->_itemRepo.value(group_id));
height = std::max(height, (double)_calcItemsMaxOriHeight(_impl->_itemRepo.value(group_id), _impl->_chromAlignPolicy));
}
return {width, height};
}

_arrange()

_arrange()函数完成整个的布局.

程序经过了一次重构, 最初的实现是使用了QGraphicsLayout系列来实现, 最终发现太笨重不说, 还不好控制. 反而不如从最底层做起来控制.

这里的Line, Group, Section, Item都只是逻辑意义上的层次关系, 在Scene中的图元对象之间是没有关系的, 都是Scene的直接子对象, 并不存在Item是Group的子对象的说法.

所有的布局和重绘行为都在这一个函数_arrange()中实现.

这样实现的问题是性能不好, 每次的修改都会导致整个界面的重新绘制. 但是实际上, 整个界面上一般也就是46个Item, 重绘的性能损失根本就无需考虑. 因此, 这种简化实现一直也没有做优化处理的必要.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void MarshalScene::_arrange(std::optional<qreal> scale)
{
auto use_scale = scale ? scale.value() : _impl->_scale;
// 布局行
_arrangeLines(use_scale);
// 布局每行中的Group
for(int i=0; i<_impl->_pageTemplate->lineCount; ++i)
{
_arrangeLineGroups(i, use_scale);
}
// 布局每个Group中的Item:
for(int i=0; i<_impl->_pageTemplate->groupCount; ++i)
{
_arrangeGroupItems(i, use_scale);
}
}

这其中, Item的布局在_arrageGroupItems()中实现:

Item的操作

用户用鼠标在界面中实现item的分类.

  • 点击一个染色体, 选中它:
    • 点击另一个染色体, 交换其位置
    • 点击Group的空白处, 将这个染色体移动过去
    • 再点击这个染色体一次, 取消选中
    • 长按染色体, 将其垂直翻转
    • 右键菜单, 支持水平翻转
    • 按下快捷键或右键菜单, 可以进入旋转状态, 鼠标水平左右移动(不需要按下鼠标), 控制染色体旋转不同的方向. 再按一次鼠标, 就取消旋转
  • 进入模式图状态:
    • 在Group上点击鼠标, 如果Group中没有模式图Item, 就添加一个Item.
  • 鼠标在模式图上flyover, 会显示该处的条带名称编号
  • 模式图支持在Group内部和染色体交换位置, 但是不支持移动到其他的Group, 也不不支持旋转.
  • 使用鼠标滚轮可以缩放染色体和模式图, 而不修改布局的字体.
  • 还支持批注对象的操作. 包括选中, 移动, 编辑文本等.

核心是两个函数: _swapItem()_moveItem(), 它们各自还有自己的重载函数.
其中, _swapItem()用于在同组或不同组的Item的交换位置, _moveItem()用于将一个Item移动到另一个Group.
这里主要的工作是在Scene中的移动和数据库的同步修改.

为了操作方便, 我们对数据做了缓冲, 由此导致修改的时候要自己实现信息的同步. 现在回过来看, 其实根本没有必要这么做, 每次从scene中filter和group, 性能足够了.

下面是最基本的形式:

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
void MarshalScene::_swapItem(LayoutItem *item1, LayoutItem *item2)
{
RETURN_LOG_IF(item1==nullptr || item2==nullptr, "item1和item2都不能为空指针!");
int groupId1 = item1->typeId();
int groupId2 = item2->typeId();
RETURN_LOG_IF(!_impl->_groupRepo.contains(groupId1) || !_impl->_groupRepo.contains(groupId2), QString("item类型 %1或%2非法!").arg(groupId1).arg(groupId2));

auto group1 = _impl->_groupRepo[groupId1];
auto group2 = _impl->_groupRepo[groupId2];
auto idx1 = _impl->_itemRepo[groupId1].indexOf(item1);
auto idx2 = _impl->_itemRepo[groupId2].indexOf(item2);
RETURN_LOG_IF(idx1<0, "找不到item1");
RETURN_LOG_IF(idx2<0, "找不到item2");
_impl->_itemRepo[groupId1][idx1] = item2;
_impl->_itemRepo[groupId2][idx2] = item1;
item1->setTypeId(groupId2);
item2->setTypeId(groupId1);
item1->setGroupLoc(idx2);
item2->setGroupLoc(idx1);
item1->setParentItem(group2);
item2->setParentItem(group1);

item1->assureClarify();
item2->assureClarify();
_arrange();
}

void MarshalScene::_moveItem(LayoutItem *item1, int type2, int new_pos)
{
RETURN_LOG_IF(item1==nullptr, "item1为空指针!");
auto type1 = item1->typeId();
RETURN_LOG_IF(!_impl->_groupRepo.contains(type1), QString("type1的值%1非法!").arg(type1));
RETURN_LOG_IF(!_impl->_groupRepo.contains(type2), QString("type2的值%1非法!").arg(type2));
auto group2 = _impl->_groupRepo[type2];

RETURN_LOG_IF(isPatternItem(item1) && type1!=type2, "不能更改模式图的类别位置!" );

item1->setParentItem(group2);
item1->setTypeId(type2);
item1->setGroupLoc(new_pos);

_impl->_itemRepo[type1].removeAll(item1);
_impl->_itemRepo[type2].insert(qBound(0, new_pos, _impl->_itemRepo[type2].size()), item1);

item1->assureClarify();

_arrange();
}

Item的移动比较简单, 最终仅仅是后台数据的修改, 以及位置的变化, 而比较复杂的是旋转操作, 以及位置的跟踪.

Scene的状态机实现如下:

ChromsomePixmapItem

前期, 由于需求很简单, 仅仅要求显示染色体的图片, 将ChromosomePixmapItem实现为QGraphicsPixmapItem的派生类, 只需要提供其图片就可以了. 随着后期用户需求的增加, 实际上QGraphicsPixmapItem完全没有任何价值, 我们完全应该直接从QGraphicsItem直接派生就可以了.

ChromosomePixmapItem的显示要求:

  • 要求显示染色体的图片
  • 染色体的图片要做图像增强, 并且可以调整, 调整后要求能够回显
  • 要求支持旋转操作
  • 要求能够编辑修改染色体的轮廓, 修改后的轮廓要能够在Extract中同步更新显示
  • 染色体上可选的要能显示着丝粒. 并且可以使用鼠标来调整着丝粒的位置. 着丝粒是软件识别的, 如果识别不准, 医生会手工调整.
  • MarshalScene中, 可以指定所有的染色体底部对齐或着丝粒对齐.
  • AI分析完成的染色体有一个分类的可信度, 对于可信度过低的染色体, 要在界面上特别标出
  • 对于一个样本下的所有分裂相, 要能够自动标识出”和其他分裂相”不一样的性染色体. 比如, 如果一个样本里面大部分都是XX的染色体, 那么某个分裂相中的Y染色体则应该在界面上特别显示标出
  • 这种标记要能够被用户确认, 确认之后就不再显示.

下面是其paint()函数. 最终完全不得不自己做所有的重绘, 完全没有用到QGraphicsPixmapItem的任何好处.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void ChromosomePixmapItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
if( (option->state & QStyle::State_Selected) || (option->state & QStyle::State_MouseOver))
{
painter->fillRect(this->boundingRect(), QBrush(Qt::green));
}
painter->drawPixmap(QPoint(1,1), _impl->_draw_pixmap);

// 画着丝点.
if( _impl->_is_show_center)
{
QRectF rect(_impl->_show_cent_pos.x()-10, _impl->_show_cent_pos.y()-10, 20,20);
painter->setPen(QPen(Qt::red, 2));
painter->drawEllipse(rect.adjusted(3,3,-3,-3));
painter->drawLine(_impl->_show_cent_pos.x()-10, _impl->_show_cent_pos.y(), _impl->_show_cent_pos.x()+10, _impl->_show_cent_pos.y());
painter->drawLine(_impl->_show_cent_pos.x(), _impl->_show_cent_pos.y()-10, _impl->_show_cent_pos.x(), _impl->_show_cent_pos.y()+10);
}
// 画存疑标记
if(_impl->_show_confidence && !_impl->_confidence)
{
painter->setPen(QPen(Qt::red, 4));
painter->drawLine(boundingRect().bottomLeft(), boundingRect().bottomRight());
}
}

染色体的坐标转换处理

一个染色体从源头开始的坐标转换.
经过AI分析, 或者在ExtractScene中手工分析后, 得到的染色体数据, 是它的轮廓信息. MarshalScene要负责利用轮廓数据得到染色体的图片, 并且还要能够将在MarshalScene中的修改同步返回到ExtractScene中实时更新显示.

前期的实现中, 因为没有在MarshalScene中的编辑需求, 做法是在ExtractScene中根据轮廓从分裂相图片中提取出染色体的图片, 然后发给MarshalScene来构造ChromosomePixmapItem—- 这个类的命名也正是源自于此.

做的修改:

  1. 从原始分裂相中得到的染色体轮廓, 是基于这张图片的全局坐标的轮廓
  2. 根据轮廓, 得到其boundingRect, 并进而得到其左上角的坐标. 从而得到将染色体移动到(0,0)处需要的仿射变换矩阵m1和逆矩阵_rm1.
  3. 再将轮廓旋转和缩放, 得到最后需要摆正的图像, 并得到变换矩阵m2rm2.
  4. 旋转之后的图像和轮廓还需要再将左上角移动到(0,0), 于是又有m3rm3.

而实际上, 由于我们对轮廓和图像是分别进行的, 对于轮廓的操作其实并不在于坐标是否是负数, 因此, 我们可以调整简化这个操作, 得到两步操作:

  1. 旋转和缩放的矩阵 m1rm1
  2. 移动到(0,0)的矩阵 m2rm2

对于图像和轮廓分开进行.

这样, 根据矩阵变换的结合律(这里的矩阵都是Matx33d, 一定是支持结合律),
m = m2 * m1;
rm = rm1 * rm2;

另外, Item在MarshalScene中布局, 同样进行一次变化, 得到m4rm4. 这样, 鼠标在MarshalScene中移动, 对轮廓的修改, 通过rm`可以反向映射到原始图片中, 并进行同步修改.