Qt桌面白板工具其一(解决曲线不平滑的问题——贝塞尔曲线)
前言:
有关Qt的绘画、白板、画板等应用,前前后后研究过好几次,每一次都有新的收获和体会。而这次终于研究明白,如何解决因为电脑配置应用卡顿所导致的线条折线明显、存在卡顿的问题。是的,网上说的都是贝塞尔曲线,但研究半天没有很明确地解决我的需求,所以这里我也结合了自己的思考,给出以下的解决方法和代码吧。
一、核心实现代码
我们首先要在mousePressEvent和mouseMoveEvent里面收集我们的鼠标点击移动点,或者触摸屏的移动点。
void BezierTestWidget::mousePressEvent(QMouseEvent *event)
{
if(flag_bezier)
{
bezier_points.clear();
bezier_points.append(event->pos());
}
}
void BezierTestWidget::mouseMoveEvent(QMouseEvent *event)
{
if((event->buttons() & Qt::LeftButton))//是否左击
{
QImage image = last_image;
if(flag_bezier)
{
//采集点的时候,适当过滤一下比较接近的一些点,不然会影响平滑处理的效果
if(qAbs(bezier_points.last().x()-event->pos().x())>15 || qAbs(bezier_points.last().y()-event->pos().y())>15)
{
bezier_points.append(event->pos());
}
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing, true);
QPen pen;
QColor brush_color(0,255,0,100);
pen.setBrush(brush_color);
pen.setWidth(5);
painter.setPen(pen);
drawBezier(&painter, &image);
painter.end();
}
*draw_image = image;
repaint();
}
}
这段是真正绘制到图片上的代码:
void BezierTestWidget::drawBezier(QPainter *painter, QImage *image)
{
if(bezier_points.size()<=0)
return;
//最终生成的点队列
QList<QPointF> points;
//遍历添加中点,将实际点当做控制点
if(bezier_points.count() > 2)
{
points.append(bezier_points[0]);
points.append(bezier_points[1]);//根据算法,第一个和第二个点间不添加中点
for (int i = 2; i <bezier_points.count(); i++) {
points.append((bezier_points[i]+bezier_points[i-1])/2);
points.append(bezier_points[i]);
}
}
QPainterPath draw_path;
if(bezier_points.count() > 2)
{
int i = 0;
while(i < points.count())
{
if(i+3 <= points.count())//按照顺序进行贝塞尔曲线处理,并添加到绘图路径中
{
QPainterPath path;
path.moveTo(points[i]);
path.quadTo(points[i+1],points[i+2]);
draw_path.addPath(path);
}else{
int a = i;
QPolygon polypon;
while(a < points.count())
{
polypon << points[a].toPoint();
a++;
}
draw_path.addPolygon(polypon);
}
i = i + 2;
}
}
//绘制path
painter->drawPath(draw_path);
}
有关如何实现的原理和逻辑,放到后面再仔细展开。接下来先简单介绍一下我画板实现的基本思路。
二、画板实现思路
在网上找过很多资料,有关画板、桌面白板等实现方案,无非就是两种。
1.QGraphicsView和QGraphicsScene
通过添加图元的方法来实现。关于这个,其实我了解也不是很深刻,其实还挺复杂的。我的理解是,创建一个个单独独立的图元对象,然后添加进QGraphicsScene里面。
比如说最简单的,QGraphicsLineItem *addLine(qreal x1, qreal y1, qreal x2, qreal y2, const QPen &pen = QPen(),实际上就是往绘图场景中添加一段直线,这里的直线其实相当于是一个对象,添加进了整个场景画布中。
我们知道,鼠标移动绘制一段曲线,其实是由许许多多个点组成的,而每一个点都用直线相连的话,就是一段曲线了。而之所以使用贝塞尔曲线处理来追求平滑,无非就是电脑卡顿应用卡顿的时候,mouseMoveEvent鼠标事件触发得不够,导致我们的样本点太少了,不然其实一般都很顺滑的。
缺点:
(1)QGraphicsScene的绘制可以实现画矩形、文字等功能,但因为是一个个图元添加进去的,而不是实际我们直观上绘制再画布上的,所以不能很好地实现“橡皮擦”、“消除”的功能。
该方案如果想要用橡皮擦,只能不断去判断当前鼠标移动的点,有无和图元队列中的图元相交,有就去除。这种做法客户体验并不好,我所做的项目也是因为这个而取消了橡皮擦的开发。
(2)刚才说了,实际上曲线是通过点与点之间的无数曲线叠加组成的。问题来了,如果我们的画笔是设置成荧光笔,也就是带有透明度,会变成怎样呢?结果就是线段叠加的部分颜色也会叠加,移动速度快的话能清楚看到叠加点,而移动速度慢的话,点基本重合,又没有透明度的效果。
(3)出现过一个bug,那就是画太多东西的时候,QGraphicsScene::clear()会导致崩溃,不知道为什么它内部就崩了,可能对于item的管理有问题吧。最后是用delete后,再new一个添加进QGraphicsView来实现清空的。
综上,该方法虽然封装比较好,扩展功能也做得不错,而且好像有硬件加速还是什么鬼的功能,但其实并不能很好地实现我们传统想象中的绘画画板,故我现在使用了第二种。
2.利用QImage间接绘制窗口
如果你有接触过QPainter和paintEvent(),你应该知道其实窗口的刷新都在这个里面去做,你可以重载paintEvent(),将你期望的内容绘制在窗口部件中。当然,这也可以绘制你的鼠标点击路径。
之所以在paintEvent()不断绘制QImage,而mouseMoveEvent触发时把路径绘制在QImage里面,其实是考虑到撤销的问题。撤销需要记录上一步的画面,与其记住繁杂的路径点位,不如直接记住上一次的画面QImage算了,并且限定最多撤销10次,那我也就最多储存10个QImage而已,内存上涨不大。
优点:
(1)画图可以直接绘制在上面,并且利用QPainter的方法drawPath或drawPolyline,绘制出来的曲线没有上述的重合点颜色叠加问题。另外橡皮擦也很好实现,直接擦除了QImage的像素,甚至可以设置橡皮擦的形状和大小。文字也可以画上去,再加一些什么三角形矩形之类的东西。
总的来说,这种实现方式的限制比较少,容易实现我们想要的功能。
关键是,QPainter相关的QPainterPath提供了以下两个方法,以实现贝塞尔曲线处理:
void cubicTo(const QPointF &ctrlPt1, const QPointF &ctrlPt2, const QPointF &endPt);
void quadTo(const QPointF &ctrlPt, const QPointF &endPt);
三、贝塞尔曲线实现的详细思路
首先,我们要说清楚什么是贝塞尔曲线,这里也参考了不少文章,感兴趣可以自己去看一下。
贝塞尔曲线的作用和特点
QPainterPath详解
Qt用算法画平滑曲线(cubicTo)
贝塞尔曲线的数学概念我们不用深究,但我们得知道接口方法的每一项参数。简单来说
两点之间的曲线效果,或是由一到两个控制点来决定的。图
图一,对应void quadTo(const QPointF &ctrlPt, const QPointF &endPt);
图二,对应void cubicTo(const QPointF &ctrlPt1, const QPointF &ctrlPt2, const QPointF &endPt);
我们期望的平滑曲线效果,使用图一这种就好了。
首先,我们用QVetor获取了一系列的QPointF点对不对?然后我们再来看,如何获取这个二阶贝塞尔曲线信息算法(参考第三个文章):
假设我们在鼠标移动的过程中有A、B、C、D、E、F、G、这6个点。如何画出平滑的曲线呢, 我们取B点和C点的中点B1
作为第一条贝塞尔曲线的终点,B点作为控制点。如图: 贝塞尔曲线接下来呢 算出 cd 的中点 c1 以 B1 为起点, c点为控制点, c1为终点画出下面图形: 连续曲线图
这两个图很直观了,不明白的我再描述一次。当我们拥有一系列ABCD的点集时,从第二个点开始,点和下一个点之间计算出一个新的中点(直接相加除二),然后添加进点集队列当中。这样,我们除了第一个点之外的其他实际点,都将作为控制点成为参数,而第一个点和我们手动计算出的中点,反而成为了实际点,与曲线相连。
这实际上是一种很精妙且简洁的算法,利用已有点生成成倍的点,再巧妙地曲线平滑化,成功做到了因为设备应用卡顿导致的折线现象。
不明白的话,只能说再多看几次哈哈。具体的代码实现,其实也很简单。将我们收集到的点,除去第一个点之外,其余都插入一个中点。然后,我们再遍历以贝塞尔曲线的方式绘制即可。
这是插入中点的实现,bezier_points是我mouse事件收集到的点集。
//最终生成的点队列
QList<QPointF> points;
//遍历添加中点,将实际点当做控制点
if(bezier_points.count() > 2)
{
points.append(bezier_points[0]);
points.append(bezier_points[1]);//根据算法,第一个和第二个点间不添加中点
for (int i = 2; i <bezier_points.count(); i++) {
points.append((bezier_points[i]+bezier_points[i-1])/2);
points.append(bezier_points[i]);
}
}
我们继续遍历,每三个点为一组,用QPainterPath 来实现。moveTo参数是起始点,quadTo的参数分别是控制点和终点。最后将QPainterPath 绘制在总的QPainterPath 当中,结束遍历后再总的绘制QPainterPath(可以避免透明画笔的重合点问题)。如果不足 三个点,那就直接画直线(折线)算了。
QPainterPath draw_path;
if(bezier_points.count() > 2)
{
int i = 0;
while(i < points.count())
{
if(i+3 <= points.count())//按照顺序进行贝塞尔曲线处理,并添加到绘图路径中
{
QPainterPath path;
path.moveTo(points[i]);
path.quadTo(points[i+1],points[i+2]);
draw_path.addPath(path);
}else{
int a = i;
QPolygon polypon;
while(a < points.count())
{
polypon << points[a].toPoint();
a++;
}
draw_path.addPolygon(polypon);
}
i = i + 2;
}
}
//绘制path
painter->drawPath(draw_path);
另外,有很多时候画得太慢了,点与点之间靠的太近,所以在收集的时候,就适当地过滤掉一些点,以免影响处理的效果。
//采集点的时候,适当过滤一下比较接近的一些点,不然会影响平滑处理的效果
if(qAbs(bezier_points.last().x()-event->pos().x())>15 || qAbs(bezier_points.last().y()-event->pos().y())>15)
{
bezier_points.append(event->pos());
}
四、最终演示效果
第一个红色的是普通的折线效果;第二个是折线和贝塞尔处理后的对比;第三个绿色的就是贝塞尔曲线处理过的效果啦。
五、结尾
终于解决了这个问题,感觉收获还是蛮多的。但还是有些缺憾,比如在4K屏上的绘制还是太卡了,不知道有什么方法可以优化一下。另外,这只是简单的贝塞尔曲线示例,日后有机会的话再写一下橡皮擦啊,三角形,文字输入等实现,日后争取做一个比较完善且好看的白板工具。
六、补充(以下纯属个人哔哔,可能会很啰嗦)
虽然说上述方法都能实现吧,但可能最终效果还是觉得有点卡卡的(本来就是为了解决卡顿嘛笑死)。因素有很多吧,也测试了很多种可能,对比了一下。
1.paintevent中,QPainter绘制QImage本身渲染的效率问题,QImage画布本身的大小,渲染窗口的尺寸,都会影响最终的流畅度,但这都是次要的。
2.和QGraphicsView和QGraphicsScene的直接点与点间添加直线的方法进行对比,发现他们之间的流畅度有比较大的区别,QGraphicsView和QGraphicsScene要优越很多,但无奈实现的方式不一样,它也有其局限性。如果不考虑像素型橡皮擦的话,可以考虑用它。另外,他其实也有addPath的方法,但尝试过直接在scene上绘制完整的QPainterPath,发现还是会很卡)。
3.最后发现,还是与绘制的线条本身,我们move收集的点数有关系。
首先怀疑是quadTo贝塞尔曲线处理的问题,但即便总点数较高,他的耗时都比较低,始终都是1~2ms左右,可以忽略。最后反倒是在QImage上painter->drawPath(draw_path);的耗时比较高,在一开始可能只是10ms以内,但随着点数越来越多,超过500点后,它居然来到了将近150ms的耗时。
也就是说,**当点数高的时候,我们每move一下,产生一个新点,再在QImage上绘制完整的 QPainterPath,要150ms,那也就是局限了一秒钟之内,我们move只能最多只能反应收集到6个点…不卡顿不流畅才怪咧!!**而这个也就跟QPainterPath的复杂度和范围、QImage的大小有关嘛。
这里再次说明为什么非得要绘制完整的QPainterPath,而不选择双点间直线,直线与直线之间相连的做法(虽然也挺卡的),那是因为我们需要完整长曲线的贝塞尔曲线,而且在荧光笔带有透明度的时候,不可以出现颜色重叠点!!
如果将线段分段绘制,的确可以减轻以上的影响,但也无法实现荧光笔了,你如何避免颜色重点?考虑到我们不是精细绘图,可以缩小画布的大小,比如QImage直接除二,收集的点和画笔粗度也直接除二,最终绘制出来是一样的,但是这个也没有解决根源问题吧。
啊啊啊,太好奇别人的绘图软件,白板工具是怎么实现的,太难啦!!!有知道解决方案的朋友一定要在评论区回复哦,拜托了!!~~
以下是一些经验的思考和总结,比较琐碎,主要是个人回顾的…
七、卡顿解决方案
想了很久,首先我认为在每一个moveevent就触发一次对QImage画笔的drawpath思路是错误的,首先于收集点数过高时,产生的耗时会严重阻塞进程,进而影响moveevent触发的频率和后续点的收集,导致死循环越来越卡。
尝试继续维持moveevent的点数收集,但做了50毫秒的延时才触发QImage的drawpath绘制,情况大大改善,但受限于其必须会产生100多毫秒的延时,曲线还是会产生阶段性的凸点(因为阻塞时鼠标仍在移动,moveevent反应过来时,坐标已经产生较大偏移)
那么有意思的地方来了,我又不是对widget的ui本身绘制,而是对QImage对象进行绘制而已,我为啥非得让他阻塞我的窗口ui线程?直接把这部分操作丢进单独的线程中不就好了嘛?
不影响点的收集,也将耗时的操作放进线程中处理,最后给我返回QImage图片,我再刷图就好了。这个可能实时性会差一点,但只要可行,绝对不会再出现线条卡顿的情况!!(还没尝试,有进展会再补充)
八、反思和总结
再反思卡顿情况的产生,无非就是我们在鼠标事件和paintevent中直接就绘制东西,导致了一定程度的高频阻塞,进而影响了进程的流程度,触发鼠标事件和paintevent的频率也会直线降低,这当然会造成连锁反应,导致了各种卡顿阻塞。
所以,除去绘制本身,一些耗时较大的图像处理问题,其实就应该放到线程当中去进行的。最后,我们才应该考虑绘制渲染的方式,看用QWidget直接画,还是QLabel,还是说用QOpengl,SDL其他的实现方式(但我需要他可以实现透明度,以达到电脑桌面画板的穿透效果,这些估计不太行吧)。
九、线程尝试
2023.8.22
一年后的尝试。
针对卡顿问题,主要想到有三处可优化的地方,先总结一下:
1.moveevent采集点的时机和生成图像、绘制图像的时机有矛盾
为了确保曲线足够顺滑,除了贝塞尔算法处理外,最核心的应该是尽可能多地采集点。而如果采集一个点,就处理一次图像,那处理图像的耗时就会导致采集点的频率降低,甚至是500ms才采集一次,那将是灾难性的。
2.根据点集,生成折线(QPolygon )或贝塞尔运算后的曲线(QPainterPath)会产生耗时,后者耗时更是成倍增加。
3.对QImage本身绘制曲线的耗时
对单条Line的绘制就是一瞬间的事情,然而对于曲线这种多点线段,往往内含成百上千的点数,这对于绘制是极高要求的。
尝试的解决方法:
1.moveevent采集到移动点时,不主动刷新
即moveevent中,仅把点加进队列里,repaint和update都不用,直接使用一个定时器每隔100msupdate一次,在paintevent中刷新图像)。
这样的思路是将采集点和生成曲线、绘制曲线、刷新曲线分离开来,一定程度上缓解了卡顿现象,但其实治标不治本。因为都是同一个线程,所以当“生成曲线”和“绘制曲线”产生耗时时,都会导致moveevent触发太少,导致点数采集太少,从而卡顿。
2.过程中动态生成曲线
QPolygon draw_polypon;
QPainterPath draw_path;
不管是折线还是路径曲线,本质上都是对象而已,我们大可把它们当成全局变量,然后获得新的点时,添加新的线段或运算新的贝塞尔曲线塞入进去。这样能够极大缓解生成曲线时带来的耗时。
3.将绘制曲线过长放进线程中执行
因为paintevent中是画QImage,我们其实可以将曲线,画笔,源图放进线程中去draw。画完之后,再信号发送出来,给paintevent绘制。
但结果就是,曲线可以一直相对顺滑,但随着单次绘制的曲线越长,执行draw的耗时也越长,甚至能达到1000ms。因为放进线程,并多次绘制是互斥的(触发绘制的定时是100ms,但单次耗时就1000ms了,于是加了标志上锁)。
直观感受是,线段是一段一段、一秒一秒显示出来的,相对于鼠标动作延迟太大。
虽然想方设法进行优化,但因为绘制曲线步骤本身的耗时,无法避免导致各种各样的卡顿。
总结一下以上三个步骤的优化,第一点和第二点是可以采取的,能够优化不少的卡顿感;第三点能确保曲线是顺滑的,但随着采集点数增多,曲线变长,显示上的卡顿在所难免。
十、为何一定需要完整的曲线???
2023.8.22
到了这里,仍未完全完美实现平滑的、可半透明不重叠的曲线,这不禁让我思考,到底为什么一定要“单次绘制完整的曲线”。
核心的理由有两个:
其一,贝塞尔曲线的运算是连续的、如果分成不同的线段,可能会导致一些凸点——但这对于卡顿来说,其实是可以勉强接收的。线段大部分都平滑了,偶尔出现一两个凸点,没什么打不了的。而且这是实时绘制,如果不是实时的,的确可以实现一整条完整的平滑曲线。退而求其次嘛,追求效果就追求不了效率,哪有完美的方法,都是折中而已。
其二,半透明笔刷中,我不希望明明是单次鼠标点击绘制,却出现线段叠加产生深色交点的问题。而直接绘制连续的曲线,能够解决这个问题。
再一次尝试:
因为本人业余兴趣是用数位板或ipad来画画,我突然想到一种新的思路,锁定像素,整体重刷!这在实际数字绘画中,其实是一种统一更换颜色的便利方式。比如我画了个黄色的头发,我先锁定头发图层的像素,再调大笔刷半径,整体刷成蓝色。
然而,这种方法也收到透明度的限制,比如头发发梢是半透明的,我用蓝色刷了一遍后,头发末端还是半透明的蓝色。
先别急着解决问题,如果在qt上实现以上的绘画,该如何解决呢?
答案是设置合成方式:
QPainter painter;
painter.setCompositionMode(QPainter::CompositionMode_SourceIn);
关于CompositionMode有足足32种,这里使用的CompositionMode_SourceIn,意思是“仅保留源与目标重合部分的源部分”。
听上去有点拗口,但代入以上头发例子,原本的黄色就是“目标”,后来要画的蓝色就是“源”,这一点要区分清楚。而他们重合的部分,仅会显示源的部分,目标部分忽略。
这是不是完美解决了?不是,源会受到目标alpha通道的影响,就算你刷的是纯蓝色,也会变成半透明蓝色,也就是半透明的发梢。
那该怎么办呢?
非常简单,不要让“目标”有透明度,“源”就不会受到“目标”透明通道的影响。
也就是说,我们绘制线条的时候,先在一张空白的QImage上,用不透明的颜色画一遍,然后再对整幅图像,用CompositionMode_SourceIn刷一遍带有透明度的颜色,这样生成的图像,就是仅受“源”透明度影响的图像了,这个显然能够人为控制且能保证准确。
之后,我们再将QImage通过CompositionMode_SourceOver覆盖的方式绘制在嘴中的图像上,就能解决我的烦恼了!!
然而这又增加了额外的消耗:原本绘制在最终的QImage就行了,现在得将单次步骤的路径画在一张额外的QImage,完了还要刷一次色,再覆盖在最终的QImage上,多了两步资源消耗。初步感觉效率会进一步降低,不过等后续再测试一下吧。