Qt平滑手写笔迹绘制:从事件采集到贝塞尔曲线拟合
简介:本资源是一份面向Qt中级开发者与嵌入式图形界面工程师的实用绘图技术实现方案,聚焦于鼠标与触屏双模态下平滑曲线绘制的核心难点——矢量与非矢量两种路径生成策略的对比实现。资源完整封装了基于QGraphicsView架构的书写画板Demo,涵盖多点触控事件(QTouchEvent)与单点鼠标事件(mousePressEvent/move/release)的差异化处理逻辑,并通过ID追踪实现多触点独立轨迹绘制。压缩包共12个文件(6个头文件、5个源文件、1个工程配置),总大小仅10KB,结构精炼:头文件定义绘图对象与场景管理,cpp文件分别实现临时图层缓存、矢量线段构建、图形项渲染及通用宏定义,便于理解Qt绘图管线中坐标变换、贝塞尔插值与重绘优化机制。已有1772人学习下载,适合需要快速掌握跨设备手写输入、矢量笔迹平滑算法及Qt事件分发机制的开发者参考与二次开发。 做 Qt 客户端开发的朋友应该都遇到过这个需求:鼠标或者手指在界面上画一条线,还要画得平滑、能存、能回放。我最初接到的任务是一个白板批注工具,第一版用 QPainter 的 drawLine 把鼠标移动事件里的点一个个连起来,结果画出来的线条全是锯齿和抖动,客户截了一张图发过来,说“这跟小学生涂鸦有什么区别”。后来我把整套流程重做了一遍,才意识到问题不在 painter,而在从事件采集、坐标映射、平滑拟合,一直到最后保存成矢量路径还是位图,每一个环节都在影响手感。
这篇文章就把我在这条路上整理出来的经验完整写一遍,重点说清楚两个东西:一是 Qt 里鼠标/触屏事件怎么采才能采出“靠谱”的坐标点,二是这些坐标点如何拟合成平滑曲线,并且按矢量/非矢量两种方式保存和绘制。不管你是只做桌面白板、电子签名,还是要接触摸一体机,这篇文章应该都能给你一个直接能用的方案。
1. 先想明白:矢量曲线和非矢量曲线到底差在哪
1.1 两种数据模型是根本区别
很多人一听到矢量/非矢量,第一反应是文件格式,比如 SVG 和 PNG。但在 Qt 里,更重要的是数据结构。非矢量曲线,本质上就是一张位图,QImage、QPixmap 都是这个思路。画一条线,其实就是把像素矩阵里的一串点变成前景色,这条线的“控制点”“路径”信息都不存在了。优点是显示快、保存简单,缺点是放大后立刻看到锯齿,因为像素被等比放大了。
矢量曲线则完全相反。它保存的是几何描述:起点、终点、控制点、曲线类型。在 Qt 里对应 QPainterPath,内部可能是一串 moveTo、lineTo、quadTo、cubicTo 操作。QPainter 在绘制时才把几何转换成像素,屏幕上看到的是栅格化后的结果,但原始几何一直保留着。所以无论放大多少倍,重新栅格化都能得到清晰的边缘。
两者的关系可以用一个很直白的例子理解:你在纸上用铅笔画一条线,然后用手机拍下来,这是非矢量;如果你在 CAD 软件里画一条样条曲线,随时能拖拽控制点,这是矢量。拍照得到的照片无论如何处理都不会变成真正可编辑的 CAD 曲线,除非重新描一遍。
Qt 里的对应关系大概是这样:
| 维度 | 非矢量 | 矢量 |
|---|---|---|
| 数据形态 | QImage / QPixmap 像素矩阵 | QPainterPath / QVector<QPointF> |
| 缩放 | 会失真,出现锯齿或模糊 | 无损,重新栅格化即可 |
| 编辑能力 | 只能整体擦除/叠加,很难改单条线 | 可以改控制点、改颜色、改粗细 |
| 典型场景 | 涂鸦、批注、截图标注 | 电子签名、CAD、动画路径、可编辑图形 |
1.2 平滑在两种方案里不是一回事
普通的折线不平滑,是因为相邻线段在转折点处只有位置连续,切线方向突变,人眼对“尖角”特别敏感。要解决这个问题,可以在数据层面做曲线拟合,也可以简单地把抗锯齿打开。
在非矢量模式下,很多人以为只要把QPainter::Antialiasing打开就是平滑。实际上抗锯齿只能让斜边边缘不发毛,并不能消除折线本身的棱角。如果鼠标事件给的点很稀疏,两点之间直接用 drawLine,连起来依然是一条折线。真正的平滑,还是得先把离散点拟合成贝塞尔曲线或样条,再用拟合后的路径去绘制。
在矢量模式下,平滑的核心就是拟合算法。保存下来的路径本身就是平滑的,缩放、导出都不会丢失。这也是我后来一直坚持“即便最终要输出位图,中间也先用 QPainterPath 拟合,画完再丢给 QPixmap”的原因,因为拟合过程保证了线条质量,矢量/非矢量只是最后数据怎么留的问题。
1.3 什么时候选哪种
如果做电子签名、CAD、动画轨迹这类应用,必须上矢量。你要考虑用户后续的缩放、编辑、导出 SVG/PDF,这些只有矢量路径能做到。如果再保存成位图,签名放大到 A4 纸上会糊成一团。
如果只是白板批注、随手涂鸦、截图标注,非矢量也能做,但建议用高分辨率 QImage 作为画布,不要用纯控件大小的 QPixmap,否则稍一放大就露馅。
我个人建议采用混合方案:输入点保留一份,实时绘制用 QPainterPath 做贝塞尔拟合;笔画结束后,画到一张 QPixmap 上作为缓存,同时把 QPainterPath 保存在 vector 里。这样既保证了显示性能,也保留了导出矢量数据的可能性。
2. 事件采集这一步,直接决定曲线质量
2.1 鼠标和触屏事件怎么统一
最开始我天真地认为,只要重写mousePressEvent、mouseMoveEvent、mouseReleaseEvent就够了,因为绝大多数触屏设备会把单指触摸自动转换成鼠标事件。Windows 上大部分情况确实如此,Linux 嵌入式触摸屏上也能用,但后来真机测试发现问题不少。
首先,触摸屏产生的合成鼠标事件有延迟,快速画线时明显跟不上手指。其次,鼠标事件拿不到压力信息,也没法分辨是哪个触点,多点触控时会出现莫名其妙的跳线。所以,只要你的目标设备可能带触屏,就一定要显式处理QTouchEvent。
一个比较稳的做法是这样的。构造函数里设置:
setAttribute(Qt::WA_AcceptTouchEvents); setMouseTracking(true);然后重写event(),在里面优先处理触摸事件。触摸事件没有像mousePressEvent那样便捷的事件函数,所以走统一入口最方便。
bool CurveCanvas::event(QEvent *event) { if (event->type() == QEvent::TouchBegin || event->type() == QEvent::TouchUpdate || event->type() == QEvent::TouchEnd) { QTouchEvent *touchEvent = static_cast<QTouchEvent *>(event); const QList<QTouchEvent::TouchPoint> points = touchEvent->points(); if (points.isEmpty()) return true; if (event->type() == QEvent::TouchBegin) { touchId_ = points.first().id(); startStroke(points.first().position()); touchActive_ = true; } else if (event->type() == QEvent::TouchUpdate && touchActive_) { for (const auto &point : points) { if (point.id() == touchId_) addPoint(point.position()); } } else if (event->type() == QEvent::TouchEnd && touchActive_) { endStroke(); touchActive_ = false; touchId_ = -1; } event->accept(); return true; } return QWidget::event(event); }注意我这里的代码按 Qt 6 接口写,用的是point.position()。如果你还在用 Qt 5,把position()换成pos()就行,逻辑完全一样。
有一个细节特别容易踩坑:鼠标事件里也要加一个touchActive_判断。因为某些平台在触摸事件处理完之后,还会继续产生一个合成鼠标事件,如果你不拦截,就会画两条线,或者一条线画到一半被鼠标事件重新 start。
2.2 坐标映射和高 DPI 陷阱
坐标映射这个坑,我在 4K 触摸一体机上栽过一次。现象就是手指在屏幕上划的线,最终画出来的笔迹整体偏移,而且越靠近屏幕边缘偏移越明显。排查了很久,发现问题出在某一版代码里用event->globalPosition()拿全局坐标,然后又手动mapFromGlobal转回控件坐标。窗口如果不是全屏,或者跨屏幕移动,这一套转换很容易出错。
正确做法是直接用事件里已经算好的本地坐标。Qt 6 的鼠标事件可以用event->position(),返回 QPointF。Qt 5 用event->pos(),它也是控件坐标系。
void CurveCanvas::mousePressEvent(QMouseEvent *event) { if (touchActive_) return; startStroke(event->position()); } void CurveCanvas::mouseMoveEvent(QMouseEvent *event) { if (touchActive_ || !drawing_) return; addPoint(event->position()); } void CurveCanvas::mouseReleaseEvent(QMouseEvent *event) { if (touchActive_ || !drawing_) return; endStroke(); }高 DPI 方面,Qt 6 默认开启高 DPI 缩放,event->position()给的是窗口逻辑坐标,直接拿给 QPainter 用是没问题的。但如果你要把同样的笔画导出一张 2 倍分辨率的 PNG,就需要把像素尺寸按devicePixelRatio放大,然后给 QImage 设置setDevicePixelRatio,或者干脆在绘制前scale(dpr, dpr)。
我自己习惯的做法是:控件内始终用逻辑坐标,导出时再统一换算。不要在事件采集阶段就到处乘 devicePixelRatio,那样反而会在不同屏幕之间切换时产生各种奇怪偏移。
2.3 原始坐标点长什么样
理想很丰满,现实中的离散点往往长这样:鼠标快速划一道弧,点与点之间距离可能达到十几甚至几十像素;慢速书写时,每移动 1 像素就会产生一个点。触屏设备更糟糕,触摸控制器采样率通常在 60Hz 到 120Hz 之间,快速运动时点距大,低速时点距小,偶尔还会出现一个从 (100, 100) 直接跳到 (140, 115) 的跳点。
这样的原始数据直接连成直线,出来的线条在某些地方会显得“一顿一顿”。如果采样率低,本来很平滑的大弧线会被折线切成很明显的多边形。如果触屏有跳点,曲线上会凭空多出一个尖刺。这也是为什么不能只做抗锯齿就完事,必须对坐标点做滤波和曲线拟合。
鼠标和触屏的行为区别,我做了一个简单对比:
| 项目 | 鼠标 | 触摸屏 |
|---|---|---|
| 采样率 | 125Hz / 500Hz / 1000Hz 不等 | 常见 60~120Hz |
| 精度 | 较高,但手会抖 | 中低,受触摸控制器影响 |
| 跳点 | 少见 | 容易出现在快速移动时 |
| 附带信息 | 无压力 | 部分支持 pressure |
| 默认事件 | QMouseEvent | QTouchEvent,部分合成鼠标事件 |
所以在采集阶段,我建议除了坐标点之外,至少记录一个自增序号或者时间戳。判断跳点的时候,要用距离和时间一起判断,不能只看距离。快速画一笔,50ms 内移动 40 像素可能是正常动作;如果是 5ms 内移动 40 像素,那就多半是跳点。
3. 平滑算法:从折线到曲线的本质
3.1 为什么直线连点不够
直线连点的问题不是“线段不够多”,而是相邻线段在连接点处只有 C0 连续,也就是位置连续,但切线方向突然变化。人眼对切线突变非常敏感,所以会看到明显的尖角。
要解决这个问题,要么加密采样点,让折线在视觉上接近曲线,要么用曲线拟合,把离散点拟合成贝塞尔曲线或样条。前者成本高,而且触摸屏采样率就那么多,你再加密也只是机械地插值,不能增加真实采样信息。后者才是画平滑曲线的正路。
3.2 实时绘制最简单的方案:二次贝塞尔中点法
如果你追求简单、实时、手感好,我强烈推荐“二次贝塞尔中点法”。这个方法不是让曲线经过每一个采样点,而是经过相邻采样点的中点,用采样点作为控制点。它的好处是曲线段之间天然切线连续,而且不需要等待整条笔画结束,每来一个新点就能立即更新路径。
核心逻辑是这样的:
void CurveCanvas::addPoint(const QPointF &point) { if (!drawing_ || !started_) return; QPointF mid = (lastPoint_ + point) * 0.5; currentPath_.quadTo(lastPoint_, mid); lastPoint_ = point; update(); }开头部分在startStroke里处理:
void CurveCanvas::startStroke(const QPointF &point) { drawing_ = true; currentPath_ = QPainterPath(); currentPath_.moveTo(point); lastPoint_ = point; started_ = true; update(); }结尾部分在endStroke里处理:
void CurveCanvas::endStroke() { if (!drawing_) return; currentPath_.lineTo(lastPoint_); finishedStrokes_.append(currentPath_); currentPath_ = QPainterPath(); drawing_ = false; started_ = false; update(); }为什么最后要lineTo(lastPoint_)?因为addPoint每次把终点放在中点,最后一个原始采样点并没有进入路径。如果不补一条线段,笔画末尾会缺一截。补上之后,末尾可能有一小段直线,但视觉上不明显,因为最后两个采样点之间距离通常很小。
这个方案还有一个好处:不需要保存完整原始点序列就能实时绘制。你只记得上一个点和当前路径,内存开销很小。
3.3 离线进阶:Catmull-Rom 转三次贝塞尔
中点法虽然顺滑,但它不经过原始点。如果你希望最终曲线精确经过所有采样点,那就要用 Catmull-Rom 样条。Catmull-Rom 是一种插值样条,经过每个控制点,并且相邻段之间有 C1 连续。
在 Qt 里,没有现成的 Catmull-Rom API,但可以很轻松地把 Catmull-Rom 转成三次贝塞尔曲线。转换公式非常简单。对原始点序列pts[i]和pts[i+1]之间的段,取前后两个点作为辅助点:
c1 = p_i + (p_{i+1} - p_{i-1}) / 6 c2 = p_{i+1} - (p_{i+2} - p_i) / 6然后用QPainterPath::cubicTo(c1, c2, p_{i+1})。边界点需要重复端点,下面是一个可以直接用的实现:
QPainterPath buildCatmullRomPath(const QVector<QPointF> &pts) { QPainterPath path; if (pts.size() < 2) return path; QVector<QPointF> ext; ext.reserve(pts.size() + 2); ext.append(pts.first()); for (const auto &p : pts) ext.append(p); ext.append(pts.last()); path.moveTo(pts.first()); for (int i = 0; i < pts.size() - 1; ++i) { const QPointF &pm1 = ext[i]; const QPointF &p0 = ext[i + 1]; const QPointF &p1 = ext[i + 2]; const QPointF &p2 = ext[i + 3]; QPointF c1 = p0 + (p1 - pm1) / 6.0; QPointF c2 = p1 - (p2 - p0) / 6.0; path.cubicTo(c1, c2, p1); } return path; }这个算法适合在笔画结束时对整段点集做一次重算。比如电子签名场景,用户落笔到抬笔之间采集了几百个点,抬笔后立刻用 Catmull-Rom 生成最终路径,再把路径显示出来。这样既能保证笔迹和手指轨迹高度一致,又不会让实时绘制过程变卡。
3.4 滤波与抽稀:预处理坐标点
触摸屏的原始点经常有噪声,尤其是快速划过时,某个点会突然偏离轨迹。对这类问题,可以做一阶低通滤波,类似:
QPointF filtered = lastFiltered_ * 0.7 + rawPoint * 0.3;系数 0.7/0.3 是我比较常用的值,效果是让轨迹更平滑,但代价是延迟。如果你觉得线条发肉,可以把系数调成 0.85/0.15,或者 0.5/0.5。注意,滤波系数太大会让快速笔画严重拖尾,所以一般来说实时绘制用 0.3~0.5 的“新点权重”比较合适。
还有一个预处理是抽稀。矢量保存时,几百个采样点会导致 SVG 文件很大,也没必要。可以用 Douglas-Peucker 算法做抽稀,容差按最终用途设:屏幕显示 1px 左右就够,导出到高清打印可以设 0.5px。
但我要提醒一句:不要在实时绘制的移动事件里跑完整抽稀。Douglas-Peucker 必须等整条路径结束才能得到稳定结果,你每来一个点重算一次,性能会很差。通常做法是:实时绘制用中点法生成平滑路径,原始点先存在QVector<QPointF>里;松手之后离线抽稀,然后用抽稀后的点重建最终路径。
3.5 平滑方案怎么选
| 方案 | 是否经过采样点 | 连续性 | 实时性 | 适用场景 |
|---|---|---|---|---|
| 直线连点 | 是 | C0 | 高 | 不推荐,除非只是调试 |
| 二次贝塞尔中点法 | 否,只经过中点 | C1 | 高 | 实时手写、涂鸦 |
| Catmull-Rom 转三次贝塞尔 | 是 | C1 | 中 | 离线签名、精确笔迹 |
| 三次贝塞尔拟合 | 否 | 视算法而定 | 低 | 批量美化、轨迹压缩 |
如果你的需求是“画线时手感顺滑”,实时用中点法就够。如果用户能感觉到笔迹和自己画的不一致,那就改成捕获时存点、松手后 Catmull-Rom 重建。
4. 动手实现:支持鼠标和触屏的自绘控件
4.1 基础控件骨架
下面是一个可以直接抄走的控件骨架,支持鼠标和触屏,同时支持矢量/非矢量两种模式。为了不让你看晕,我拆成几段讲。
头文件:
#ifndef CURVECANVAS_H #define CURVECANVAS_H #include <QWidget> #include <QPainterPath> #include <QPointF> #include <QVector> #include <QPixmap> class CurveCanvas : public QWidget { Q_OBJECT public: explicit CurveCanvas(QWidget *parent = nullptr); void setVectorMode(bool enabled) { vectorMode_ = enabled; } bool vectorMode() const { return vectorMode_; } QVector<QPainterPath> strokes() const { return strokes_; } void clear(); protected: void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; bool event(QEvent *event) override; private: void startStroke(const QPointF &point); void addPoint(const QPointF &point); void endStroke(); void commitToPixmap(); bool vectorMode_ = true; bool drawing_ = false; bool started_ = false; bool touchActive_ = false; int touchId_ = -1; QVector<QPainterPath> strokes_; QPainterPath currentPath_; QPointF lastPoint_; QPixmap canvasP <p> <a href="https://download.csdn.net/download/luoyayun361/85302042" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>