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; QMap<int, LayoutItem*> _patternRepo; Notation* _opNotation{nullptr}; QList<Notation *> _notationList;
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() { _initRepo(); _arrange(); }
void MarshalScene::_initRepo() { ... 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)); } 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 *>()); } }
... }
|
初始化时调用_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);
void _arrangeLineGroups(int line_id, qreal scale);
void _arrangeGroupItems(int group_id, qreal scale);
int _calcItemsMaxOriHeight(const QList<LayoutItem*> item_list, int align_policy); int _calcItemsTotalOriWidth(const QList<LayoutItem*> item_list);
QSizeF _calcGroupMinSize(int groupId, qreal factor=1.0);
std::pair<QSizeF, QSizeF> _calcLineMinSize(int lineId, qreal factor=1.0);
qreal _calcScale();
QSizeF _calcLineOverheadSize(int lineId);
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() { 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); for(int i=0; i<_impl->_pageTemplate->lineCount; ++i) { _arrangeLineGroups(i, use_scale); } 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
—- 这个类的命名也正是源自于此.
做的修改:
- 从原始分裂相中得到的染色体轮廓, 是基于这张图片的全局坐标的轮廓
- 根据轮廓, 得到其boundingRect, 并进而得到其左上角的坐标. 从而得到将染色体移动到(0,0)处需要的仿射变换矩阵
m1
和逆矩阵_rm1
.
- 再将轮廓旋转和缩放, 得到最后需要摆正的图像, 并得到变换矩阵
m2
和rm2
.
- 旋转之后的图像和轮廓还需要再将左上角移动到(0,0), 于是又有
m3
和rm3
.
而实际上, 由于我们对轮廓和图像是分别进行的, 对于轮廓的操作其实并不在于坐标是否是负数, 因此, 我们可以调整简化这个操作, 得到两步操作:
- 旋转和缩放的矩阵
m1
和rm1
- 移动到(0,0)的矩阵
m2
和rm2
对于图像和轮廓分开进行.
这样, 根据矩阵变换的结合律(这里的矩阵都是Matx33d
, 一定是支持结合律),
m = m2 * m1;
rm = rm1 * rm2;
另外, Item在MarshalScene中布局, 同样进行一次变化, 得到m4
和rm4
. 这样, 鼠标在MarshalScene中移动, 对轮廓的修改, 通过
rm`可以反向映射到原始图片中, 并进行同步修改.