ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt平滑手写笔迹绘制:从事件采集到贝塞尔曲线拟合

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 / QVectorQPointF缩放会失真出现锯齿或模糊无损重新栅格化即可编辑能力只能整体擦除/叠加很难改单条线可以改控制点、改颜色、改粗细典型场景涂鸦、批注、截图标注电子签名、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_castQTouchEvent *(event); const QListQTouchEvent::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默认事件QMouseEventQTouchEvent部分合成鼠标事件所以在采集阶段我建议除了坐标点之外至少记录一个自增序号或者时间戳。判断跳点的时候要用距离和时间一起判断不能只看距离。快速画一笔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[i1]之间的段取前后两个点作为辅助点c1 p_i (p_{i1} - p_{i-1}) / 6 c2 p_{i1} - (p_{i2} - p_i) / 6然后用QPainterPath::cubicTo(c1, c2, p_{i1})。边界点需要重复端点下面是一个可以直接用的实现QPainterPath buildCatmullRomPath(const QVectorQPointF pts) { QPainterPath path; if (pts.size() 2) return path; QVectorQPointF 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 必须等整条路径结束才能得到稳定结果你每来一个点重算一次性能会很差。通常做法是实时绘制用中点法生成平滑路径原始点先存在QVectorQPointF里松手之后离线抽稀然后用抽稀后的点重建最终路径。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_; } QVectorQPainterPath 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; QVectorQPainterPath strokes_; QPainterPath currentPath_; QPointF lastPoint_; QPixmap canvasP p a hrefhttps://download.csdn.net/download/luoyayun361/85302042 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
返回列表