简介:本资源是一个基于Qt 5.15.2的图形交互式绘图Demo,面向C++ GUI开发初学者与Qt进阶实践者,聚焦QGraphicsView框架下鼠标驱动的动态矩形绘制与交互逻辑实现。项目完整支持矩形的创建、拖拽移动、边缘缩放、Ctrl+滚轮缩放及右键删除/保存等核心功能,可快速迁移扩展至多边形、ROI区域标注等UI场景。压缩包共16个文件,含5个头文件(.h)定义核心类如DrawROI、GraphicsViewTool,5个源文件(.cpp)实现绘图逻辑与事件响应,另有.ui界面文件、.vcxproj工程配置、.qrc资源文件及.filters过滤器等,结构规范,便于理解Qt图形视图架构分层设计。资源体积仅18KB,轻量易读,目前已获2349人学习下载,适合用于Qt图形编程教学参考、自定义绘图控件开发或课程设计原型复用。
1. 用鼠标在 Qt 界面上“画框”这件事,远比QRect::setRect()复杂得多
你有没有试过:拖动鼠标画一个矩形选区,松手后它就稳稳停在那里,还能被点击、缩放、移动、删除?不是简单地在paintEvent里drawRect一下就完事——那只是“画”,不是“对象”。这个项目就是把“画框”这件事真正做成一个可交互的图形实体:按下鼠标时创建QGraphicsRectItem,拖拽时实时更新它的尺寸,松开后它就成为场景(QGraphicsScene)里一个独立、可选、可操作的矩形项。它不依赖任何第三方库,纯 Qt 原生 C++ 实现,核心就三块:QGraphicsView做视口容器,QPainter在自定义项中做精细绘制(比如加虚线边框、半透明填充),QGraphicsRectItem做逻辑载体。适合正在啃 Qt 图形视图框架(Graphics View Framework)的开发者,尤其是卡在“怎么让鼠标拖拽变成真实图形对象”这一步的同学——这不是 demo 小玩具,是能直接塞进你工业控制界面、图像标注工具或 CAD 草图模块里的可复用模块。
2. 架构拆解:从QGraphicsView到QGraphicsRectItem的四层链路
这个项目表面看是“鼠标画矩形”,但背后是 Qt Graphics View Framework 典型的四层协作模型:QGraphicsView(视口)→QGraphicsScene(场景)→QGraphicsItem(图元)→ 自定义绘制(QPainter)。每一层都承担明确职责,改错一层,整个拖拽逻辑就崩。下面逐层拆解,重点讲清为什么必须这样组织,而不是堆代码。
2.1 视口层:QGraphicsView不只是“窗口”,它是事件分发中枢
QGraphicsView是用户直接交互的入口。它本身不存图形数据,但负责捕获鼠标事件、缩放平移、坐标映射。关键点在于:所有鼠标事件必须由QGraphicsView拦截并转发给QGraphicsScene,不能绕过它去重写QWidget::mousePressEvent。否则,QGraphicsItem的itemAt()、selectedItems()等机制会失效。
// custom_view.h class CustomGraphicsView : public QGraphicsView { Q_OBJECT public: explicit CustomGraphicsView(QWidget *parent = nullptr); protected: void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; private: QPointF m_startPos; // 按下时的场景坐标,非视口坐标! QGraphicsRectItem *m_currentRectItem = nullptr; };提示:
m_startPos必须用mapToScene(event->pos())转换,这是血泪经验——用event->pos()直接取视口坐标,遇上缩放/平移就会偏移。Qt 的坐标系转换是黑匣子,但mapToScene是唯一安全出口。
2.2 场景层:QGraphicsScene是图形世界的“操作系统内核”
QGraphicsScene管理所有QGraphicsItem的生命周期、Z-order、碰撞检测和事件分发。它不渲染,只调度。本项目中,它干两件关键事:
- 接收
QGraphicsView转发的鼠标事件(通过mousePressEvent等虚函数); - 作为
QGraphicsRectItem的父容器,调用addItem()才能让矩形真正“活”起来。
// custom_scene.h class CustomGraphicsScene : public QGraphicsScene { Q_OBJECT public: explicit CustomGraphicsScene(QObject *parent = nullptr); signals: void rectCreated(QGraphicsRectItem *item); // 通知上层:新矩形诞生了 protected: void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; private: bool m_isDrawing = false; QGraphicsRectItem *m_tempRectItem = nullptr; QRectF m_startRect; };注意QGraphicsSceneMouseEvent和QMouseEvent的区别:前者已做过坐标转换,event->scenePos()直接可用,省去手动mapToScene。这是 Qt 框架的隐藏契约——跳过它,等于自己重写坐标系统。
2.3 图元层:QGraphicsRectItem是“活”的矩形,不是静态图片
QGraphicsRectItem继承自QGraphicsItem,自带位置、变换、选中、拖拽等能力。但默认绘制太简陋(实心色块),所以必须重写paint()函数,用QPainter控制细节:
// custom_rect_item.h class CustomRectItem : public QGraphicsRectItem { Q_OBJECT public: explicit CustomRectItem(const QRectF &rect, QGraphicsItem *parent = nullptr); void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr) override; QRectF boundingRect() const override; private: static constexpr qreal HANDLE_SIZE = 8.0; // 用于后续添加缩放手柄 };// custom_rect_item.cpp void CustomRectItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { // 1. 先画主体矩形(带半透明填充) QBrush brush(Qt::blue); brush.setStyle(Qt::SolidPattern); painter->setBrush(brush); painter->setPen(QPen(Qt::darkBlue, 2, Qt::DashLine)); // 虚线边框 painter->drawRect(rect()); // 2. 再画选中状态高亮(仅当 item 被选中时) if (option->state & QStyle::State_Selected) { painter->setPen(QPen(Qt::red, 2, Qt::DotLine)); painter->drawRect(rect().adjusted(-2, -2, 2, 2)); } }关键参数说明:
Qt::DashLine:比Qt::SolidLine更符合“选区”语义,视觉上暗示“临时、可编辑”;option->state & QStyle::State_Selected:Qt 内置状态位,不用自己维护isSelected标志;rect().adjusted(-2,-2,2,2):向外扩 2px 画虚线框,形成“选中外边框”效果,这是 UI 细节玄学。
2.4 绘制层:QPainter的调用时机与性能边界
QPainter只在paint()中生效,且每次update()都会触发完整重绘。这里有个常见误区:以为QPainter::drawRect是“画一笔”,其实它是“提交一个绘制指令到 GPU 队列”。所以:
- 不要在
paint()里做耗时计算(如遍历大数据集); - 避免在
paint()中 new/delete 对象(内存抖动); - 半透明填充(
QBrush(Qt::blue, 128))会触发混合运算,大量矩形时帧率骤降——生产环境建议用QPainter::setCompositionMode(QPainter::CompositionMode_SourceOver)显式指定。
3. 核心交互逻辑:拖拽矩形的完整状态机实现
鼠标画矩形本质是一个三态状态机:空闲 → 绘制中 → 已完成。状态切换由鼠标事件驱动,但状态数据必须跨事件持久化。很多初学者在这里翻车:松开鼠标后矩形消失、拖拽时矩形跳变、无法多选。根源在于状态管理混乱。本项目用清晰的状态变量 + 严格事件顺序约束来解决。
3.1 状态定义与初始化:m_isDrawing是唯一真理
所有交互逻辑围绕一个布尔量m_isDrawing展开。它只在两个时刻被修改:
mousePressEvent:设为true,并记录起点;mouseReleaseEvent:设为false,并清理临时项。
绝不允许在mouseMoveEvent中修改它——那是读状态,不是改状态。
// custom_scene.cpp void CustomGraphicsScene::mousePressEvent(QGraphicsSceneMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_isDrawing = true; m_startRect = QRectF(event->scenePos(), event->scenePos()); // 起点即矩形 m_tempRectItem = new QGraphicsRectItem(m_startRect); m_tempRectItem->setFlag(QGraphicsItem::ItemIsMovable, false); // 绘制中禁止移动 m_tempRectItem->setFlag(QGraphicsItem::ItemIsSelectable, false); addItem(m_tempRectItem); } QGraphicsScene::mousePressEvent(event); // 必须调用父类,否则选中失效 }注意:
addItem()必须在mousePressEvent中调用,而非mouseMoveEvent。否则第一次移动时m_tempRectItem还未加入场景,update()不会触发重绘。
3.2 拖拽过程:mouseMoveEvent只做一件事——更新矩形尺寸
mouseMoveEvent的唯一职责是根据当前鼠标位置,重新计算矩形的QRectF并setRect()。计算逻辑必须处理“鼠标向左/向上拖拽”这种反向情况,否则矩形会消失:
void CustomGraphicsScene::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (m_isDrawing && m_tempRectItem) { QPointF current = event->scenePos(); // 关键:用 top-left 和 bottom-right 保证矩形始终有效 qreal x = qMin(m_startRect.left(), current.x()); qreal y = qMin(m_startRect.top(), current.y()); qreal w = qAbs(current.x() - m_startRect.left()); qreal h = qAbs(current.y() - m_startRect.top()); m_tempRectItem->setRect(x, y, w, h); } QGraphicsScene::mouseMoveEvent(event); }qMin+qAbs是处理反向拖拽的标准解法。如果用QRectF::setTopLeft()/setBottomRight(),需额外判断坐标大小,代码更冗长且易错。
3.3 完成绘制:mouseReleaseEvent的收尾与交接
松开鼠标是状态终结点,也是新矩形诞生点。这里要做三件事:
- 将临时项
m_tempRectItem替换为正式项(启用可选、可移动); - 发射信号通知上层(如主窗口)新增了一个矩形;
- 清理状态,准备下一次绘制。
void CustomGraphicsScene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (m_isDrawing && m_tempRectItem) { // 1. 创建正式矩形项(继承自 CustomRectItem,支持自定义绘制) CustomRectItem *finalItem = new CustomRectItem(m_tempRectItem->rect()); finalItem->setFlag(QGraphicsItem::ItemIsMovable, true); finalItem->setFlag(QGraphicsItem::ItemIsSelectable, true); finalItem->setFlag(QGraphicsItem::ItemIsFocusable, true); // 2. 替换:移除临时项,添加正式项 removeItem(m_tempRectItem); delete m_tempRectItem; m_tempRectItem = nullptr; addItem(finalItem); // 3. 发射信号,上层可做保存、计数等操作 emit rectCreated(finalItem); } m_isDrawing = false; QGraphicsScene::mouseReleaseEvent(event); }提示:“替换”比“复用”更安全。
QGraphicsRectItem默认不支持虚线边框,强行dynamic_cast或setPen可能导致样式丢失。新建CustomRectItem实例,确保绘制逻辑完整。
4. 避坑指南:5 个让开发者深夜删库跑路的真实问题
这些坑我都踩过,有些甚至导致整周调试无进展。列在这里,不是为了吓人,而是帮你把时间花在真问题上。
4.1 现象:鼠标拖拽时矩形“抖动”或“跳变”
原因:mouseMoveEvent中用了event->pos()(视口坐标)而非event->scenePos()(场景坐标),且QGraphicsView启用了setDragMode(QGraphicsView::ScrollHandDrag)。视口坐标随滚动实时变化,导致计算出的矩形坐标乱跳。
解决:严格使用event->scenePos();若需支持手抓滚动,应在mousePressEvent中判断event->button() == Qt::MidButton单独处理,与左键绘制逻辑隔离。
4.2 现象:松开鼠标后矩形消失,或只显示一半
原因:QGraphicsRectItem的rect()坐标是相对于其自身坐标系的,但addItem()后未调用update(),或QGraphicsView的viewport()->update()被误调用。Qt 的绘制是延迟的,addItem()不等于立刻可见。
解决:在mouseReleaseEvent添加m_tempRectItem->update();更稳妥的是调用QGraphicsScene::update()(无参),它会刷新整个场景区域。
4.3 现象:多个矩形叠加时,只能选中最上面一个,无法多选
原因:QGraphicsView默认setSelectionArea()用的是Qt::IntersectsItemShape模式,而QGraphicsRectItem的shape()返回的是精确矩形路径。当矩形紧密排列时,IntersectsItemShape会漏判。
解决:在QGraphicsView初始化时设置setSelectionArea(rect, Qt::ContainsItemBoundingRect),用包围盒判断而非精确形状。
4.4 现象:缩放QGraphicsView后,拖拽矩形尺寸严重失真
原因:QGraphicsView::scale()改变了视口变换矩阵,但mousePressEvent中mapToScene()未同步更新,或QGraphicsScene的setItemIndexMethod()设置不当。
解决:确保QGraphicsScene::setItemIndexMethod(QGraphicsScene::NoIndex)(禁用索引加速,避免缩放后索引失效);所有坐标转换统一用mapToScene()/mapFromScene(),绝不混用像素坐标。
4.5 现象:CustomRectItem::paint()中QPainter::drawText文字模糊、锯齿严重
原因:QPainter默认抗锯齿关闭,且QGraphicsItem的deviceTransform()未应用到QPainter。文字在高 DPI 屏幕上尤其明显。
解决:在paint()开头添加:
painter->setRenderHint(QPainter::Antialiasing, true); painter->setRenderHint(QPainter::TextAntialiasing, true); painter->setRenderHint(QPainter::SmoothPixmapTransform, true);并确保QApplication构造时传入QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)。
5. 二次开发实战:从“画矩形”到“可编辑标注系统”的三步改造
这个项目不是终点,而是你构建专业图形工具的起点。我把它用在某高校实验室的医学图像标注系统里,从原始 demo 到生产环境,走了三步关键改造。每一步都附可验证代码,照着抄就能跑通。
5.1 第一步:给矩形加属性面板——用QVariant存储业务数据
QGraphicsItem提供setData()接口,底层是QMap<int, QVariant>,完美适配动态属性。比如医学标注需要记录“病灶类型”“置信度”“医生ID”,不用改类结构,直接塞进去:
// 创建矩形时附加业务数据 CustomRectItem *item = new CustomRectItem(rect); item->setData(1001, "lung_nodule"); // 自定义角色 1001 存类型 item->setData(1002, 0.92); // 角色 1002 存置信度 item->setData(1003, QVariant::fromValue(QDateTime::currentDateTime())); // 时间戳验证方法:在
CustomRectItem::paint()中加一行qDebug() << "Type:" << data(1001).toString();,拖拽后看控制台输出。QVariant的序列化能力极强,QDateTime、QColor、甚至自定义结构体(需注册qRegisterMetaType)都能存。
5.2 第二步:支持键盘快捷键——删除、复制、居中对齐
QGraphicsView默认不响应键盘事件,需显式启用焦点:
// custom_view.cpp 构造函数中 setFocusPolicy(Qt::StrongFocus); // 关键! setAcceptDrops(true); // 为后续拖拽文件准备然后重写keyPressEvent:
void CustomGraphicsView::keyPressEvent(QKeyEvent *event) { QList<QGraphicsItem*> selected = scene()->selectedItems(); if (selected.isEmpty()) return; switch (event->key()) { case Qt::Key_Delete: for (QGraphicsItem *item : selected) { scene()->removeItem(item); delete item; } break; case Qt::Key_C: if (event->modifiers() & Qt::ControlModifier) { copySelectedItems(); // 自定义复制逻辑 } break; case Qt::Key_H: // H for Horizontal center horizontalCenterSelected(); break; default: QGraphicsView::keyPressEvent(event); } }horizontalCenterSelected()实现:遍历选中项,用scene()->sceneRect().center().x()作为目标 X 坐标,调用item->setX(targetX - item->boundingRect().width()/2)。这就是“居中对齐”的数学本质。
5.3 第三步:导出为 JSON 标注文件——序列化所有矩形
生产环境必须落盘。QGraphicsScene不提供序列化接口,但我们可以遍历所有QGraphicsRectItem子类:
QJsonObject exportAnnotations() { QJsonObject root; QJsonArray annotations; for (QGraphicsItem *item : scene()->items()) { CustomRectItem *rectItem = dynamic_cast<CustomRectItem*>(item); if (!rectItem) continue; QJsonObject anno; anno["x"] = rectItem->rect().x(); anno["y"] = rectItem->rect().y(); anno["width"] = rectItem->rect().width(); anno["height"] = rectItem->rect().height(); anno["label"] = rectItem->data(1001).toString(); anno["confidence"] = rectItem->data(1002).toDouble(); annotations.append(anno); } root["annotations"] = annotations; return root; }验证:调用
QFile file("output.json"); file.open(QIODevice::WriteOnly); file.write(QJsonDocument(root).toJson());,打开 JSON 文件确认字段完整。这个 JSON 结构可直接喂给 PyTorch 的COCODataset加载器。
从那以后我每次启动新图形项目,都强制走一遍这三步:先加QVariant属性槽,再绑键盘事件,最后写导出函数。不是为了炫技,是避免后期重构时发现“所有矩形都缺 ID 字段”这种后悔药都买不到的坑。希望帮到你。
本文还有配套的精品资源,点击获取