Qt桌面白板工具其一(解决曲线不平滑的问题——贝塞尔曲线)

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)

贝塞尔曲线的数学概念我们不用深究,但我们得知道接口方法的每一项参数。简单来说
两点之间的曲线效果,或是由一到两个控制点来决定的。图Qt桌面白板工具其一(解决曲线不平滑的问题——贝塞尔曲线)_第1张图片
图一,对应void quadTo(const QPointF &ctrlPt, const QPointF &endPt);
Qt桌面白板工具其一(解决曲线不平滑的问题——贝塞尔曲线)_第2张图片
图二,对应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点作为控制点。如图: 贝塞尔曲线

Qt桌面白板工具其一(解决曲线不平滑的问题——贝塞尔曲线)_第3张图片

接下来呢 算出 cd 的中点 c1 以 B1 为起点, c点为控制点, c1为终点画出下面图形: 连续曲线图

Qt桌面白板工具其一(解决曲线不平滑的问题——贝塞尔曲线)_第4张图片

这两个图很直观了,不明白的我再描述一次。当我们拥有一系列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());
}

四、最终演示效果

第一个红色的是普通的折线效果;第二个是折线和贝塞尔处理后的对比;第三个绿色的就是贝塞尔曲线处理过的效果啦。
Qt桌面白板工具其一(解决曲线不平滑的问题——贝塞尔曲线)_第5张图片

五、结尾

终于解决了这个问题,感觉收获还是蛮多的。但还是有些缺憾,比如在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其他的实现方式(但我需要他可以实现透明度,以达到电脑桌面画板的穿透效果,这些估计不太行吧)。

你可能感兴趣的:(qt,开发语言,c++)