C++、基于Qt和Qwt实现交互式曲线图

前言

在很多调试场景下,我们需要配置一条参数曲线给某些模块使用。比如在各种图像处理软件中,我们都可以看到一个 Gamma 曲线调整的功能,里面的曲线可以通过鼠标随意地拖动,十分的方便。如果你有接触过硬件调试,那么就会知道配置曲线基本是通过预设几个数据点,然后通过线性插值获取的,每次想更改曲线的形状就得把数据点一个一个地通过键盘修改,可以说低效至极,因此得想办法实现以上的效果。

对于曲线作图,很多语言基本都有相关的函数工具库,比如 Python 里面就有 matplotlib.pypplot,可以完成各种复杂的数据显示效果。然而,这种作图一般是静态的,也就是只能显示由已知数据点所连成的曲线,而不能自由地增减数据点,以及拖动已有的数据点,同时相应地改变曲线形状。很显然,要实现这种效果,我们必须要和鼠标事件进行交互,同时也离不开窗体应用的开发。在这方面,Qt 是具有比较明显的优势的,一方面其本身绝大部分功能都是开源的,而且大多数属于 LGPL 协议,这就意味这只要我们通过动态链接的方式使用这些 Qt 库,就可以实现代码的闭源,这对于商业用途是十分友好的;另外,Qt 是基于 C++ 的,只要你对 C++ 面向对象的概念有基本的理解,那么你只需要知道 Qt 里面有哪些工具,工具里面有哪些方法,就可以十分流畅地使用。同时,Qt 也有一些现成的曲线作图工具,比如 QChart 和 Qwt。其中 QChart 功能比较丰富,在安装 Qt 时一般都有直接安装 QChart 的选项,但问题在于 QChart 是 GPL 协议的,其缺点就是沾上了 GPL 协议的代码也得按 GPL 开源。而 Qwt 则是 LGPL 协议的,虽然功能上要比 QChart 有所逊色,但实现本文的效果已经是绰绰有余了,因此这里选择的是 Qt + Qwt 的方案。Qt 和 Qwt 可选择以下的下载链接。编译安装流程相对比较简单,但是要先安装一个 VS2019,可以选择社区版,主要是提供一个编译环境,实际开发的时候还是在 Qt Creator 内的。貌似 VS2019 以下版本容易编译不过。Qwt 编译成功后会在 plugins 下生成相应的 Qt Designer 插件 qwt_designer_plugin.dll,把它复制到 Qt 安装目录相应的位置,比如我的是 F:\Qt\5.15.2\msvc2019_64\plugins\designer,那么在 Qt Creator 项目的 .ui 文件右键选择在 Designer 中打开即可看到 Qwt 的窗体,然而直接在 Creator 的设计界面是无法正常显示的。不过一般来说直接将 Qwt 作为依赖库,然后通过代码创建使用就可以了。

Qt: https://mirrors.tuna.tsinghua.edu.cn/qt/archive/online_installers/4.4/qt-unified-windows-x64-4.4.1-online.exe
Qwt: https://udomain.dl.sourceforge.net/project/qwt/qwt/6.2.0/qwt-6.2.0.zip

在开始实现之前,我们还得知道一种数据插值的算法。因为 Qwt 作图的时候默认是使用直线来连接相邻两个点的,所以整体上表现的是分段折线图,不过我们是可以配置选项让其自动插值出一条曲线并显示的。然而,我们不仅要看到曲线,还要根据曲线来计算任意 x 值对应的 y 值,这就需要知道这条曲线的具体表达式,自动插值的曲线是满足不了我们的需求的。因此,比较合理的一种方案是,给定少数几个已知数据点,这些数据点可以通过鼠标点击创建以及拖动,插值算法基于这几个已知数据点计算出相应的表达式,并插值出相对密集且平滑的数据点,然后通过 Qwt 把这些密集的点用折线连接起来,就形成了我们想要的平滑曲线了。具体的插值算法在我之前的一篇文章中有所提及,主要是分段三次 Hermite 插值多项式(PCHIP),其中包括形状保持的 PCHIP 算法即 SPPCHIP 还有样条插值算法 Spline,区别在于前者是一阶导连续的,而后者是二阶导连续的,具体原理可查阅以下文章,大多数作图工具也会基于这类的插值算法。SPPCHIP 可以保证相邻两个点之间的单调性,即相邻两点间通过插值所得的 y 值不会超过这两点的 y 值范围,会比较符合数据的变化趋势,因此才被称为形状保持。而且其具体实现非常简单,复杂度低,是比较合适的选择。

https://blog.csdn.net/qq_33552519/article/details/102742715

最终实现的效果如下所示。由于本人不是专门做 Qt 开发的,这只是项目临时需要,所以文章侧重点在于实现自己想要的效果,对于 Qt 以及 Qwt 额外的功能不会过多提及,如果有兴趣可以查阅相关的文档,也可以直接阅读相关的代码,毕竟这些都是开源的。


实现

具体的实现主要分为三个部分。第一是插值算法实现,第二是曲线作图,第三是鼠标事件响应。插值算法在上面的链接中有具体的 Python 实现,当然本文后面开源的代码也会有相应的 C++ 实现,这部分的作用是根据给定的几个已知点,建立一条相对平滑的曲线,并且曲线表达式也是可以计算的,后续只要提供 x 值,就可以直接计算出相应的 y 值。我们的重点主要在后面两个部分。话不多说,先给出实现的头文件,方便后面描述。注意其中 “qwt_xx.h” 都是 Qwt 的头文件,需要先编译安装好 Qwt 然后把相关路径加入到 Qt 项目的 .pro 文件。由于我所应用的领域一般是图像处理,这里假设所有数据都限制在 0~1 的范围,并且固定有 x=0 和 x=1 两个点,不过这里的插值算法是可以处理任意范围的数据的。

#include <QHBoxLayout>
#include <QWidget>
#include "qwt_plot.h"
#include "qwt_plot_curve.h"
#include "qwt_plot_picker.h"
#include "sppchip.h"  // 这是插值算法实现的类

class PlotLayout : public QHBoxLayout
{
    
    
    Q_OBJECT
public:
    explicit PlotLayout(QWidget* = nullptr, _Tp y0 = 0, _Tp y1 = 1);

    void setPickerEnabled(bool);		// 设置是否响应鼠标事件
    void setPickerXMovable(bool);		// 设置数据点是否可以左右移动
    void setPickerInsertable(bool);		// 设置是否可以增加数据点
    void deleteSelectedPoint();			// 删除选中点
    void reset();						// 重置曲线
    
    void interp(const std::vector<_Tp>& xs, std::vector<_Tp>& ys); // 根据曲线插值
    uint32_t getCurveVersion() const;	// 返回曲线版本

protected slots:
    void slotPointSelected(const QPointF& mousePos); 	// 响应鼠标点击事件
    void slotPointDragged(const QPointF& mousePos);		// 响应鼠标拖动数据

protected:
    void plotShow();					// 曲线作图
    void setBasePointsSamples();		// 根据基础数据点建立曲线

protected:
    QwtPlot *m_plot = NULL;				// 作图区域
    QwtPlotCurve *m_points = NULL;		// 用于显示数据点
    QwtPlotCurve *m_curve = NULL;		// 用于显示曲线
    QwtPlotCurve *m_marker = NULL;		// 用于高亮选中点
    QwtPlotPicker *m_picker = NULL;		// 鼠标事件相关

    SPPCHIP m_sppchip;					// 插值类
    QList<QPointF> m_base_points;		// 基础数据点
    _Tp m_init_y0 = 0.;					// 初始 y0
    _Tp m_init_y1 = 1.;					// 初始 y1

    const int m_max_base_pnum = 16;		// 最大基础数据点个数
    std::vector<_Tp> m_base_xs;			// 用于插值类基础 x 数据输入
    std::vector<_Tp> m_base_ys;			// 用于插值类基础 y 数据输入
    bool m_basex_movable = 1;           // 基础数据点是否可以左右移动(x=0和x=1除外)
    bool m_base_insertable = 1;         // 是否可以新增基础数据点

    const int m_show_pnum = 51;         // 在0~1范围内均匀分布相对密集的点用于显示曲线
    std::vector<_Tp> m_show_xs;			// 记录密集点的 x 值
    std::vector<_Tp> m_show_ys;			// 记录密集点的 y 值

    _Tp m_mark_x = 0;					// 被选中点的 x 坐标
    _Tp m_mark_y = 0;					// 被选中点的 y 坐标
    bool m_mark_selected = 0;			// 是否有基础点被选中
    int  m_mark_base_idx = 0;			// 被选中基础点的索引

    uint32_t m_curveVersion = 0;		// 曲线版本
};

曲线作图

类似于 Python 里面的 Maplotlib,Qwt 作图首先也需要创建一个作图区域,需要使用到 QwtPlot 类,其继承于 QFrame,一般可依附于一个 QHBoxLayout,这样其作图区域就可根据给定的方框大小自动调整。QwtPlot 可设置上下左右四个坐标轴,这里只需要用到左边和下边两个,分别为 QwtPlot::xBottom 和 QwtPlot::yLeft,那么可通过以下代码建立一个作图区域 m_plot。后续包括网格、数据点以及曲线等都可通过各自的 attach(m_plot) 方法依附到 m_plot 上,通过 m_plot->replot() 即可在作图区域上显示。注意在 Qt 窗体相关的类中,一般父对象被析构后会自动把子对象以及依附于其的类对象都析构掉,不需要自己手动管理指针和析构,因此 Qt 的代码中通常很少见有手动 delete 对象的情况。

m_plot = new QwtPlot;
this->addWidget(m_plot);	// 这里的this指QHBoxLayout
m_plot->setAxisScale(QwtPlot::xBottom, 0., 1., 0.2);	// x轴范围为0~1,每0.2为一个大刻度
m_plot->setAxisScale(QwtPlot::yLeft, 0., 1., 0.2);		// y轴范围为0~1,每0.2为一个大刻度

QwtPlotGrid *grid = new QwtPlotGrid;				// 创建网格
grid->setMajorPen(Qt::darkGray, 0, Qt::DotLine);	// 设置网格风格
grid->attach(m_plot);								// 将网格依附到m_plot上

在 QwtPlot 上画曲线需要用到 QwtPlotCurve,但是 QwtPlotCurve 默认是用折线把数据点连接起来的,要实现平滑的显示,可以通过设置曲线属性的方式实现。但前面已经说了,我们希望使用自己的插值算法来建立曲线。因为一个 QwtPlot 对象可以同时显示多个 QwtPlotCurve 的数据,因此合适的做法是:

  1. 首先用一个 QwtPlotCurve 对象显示基础数据点,但是需要设置只显示点,不显示连线,同时需要设置数据的标记风格,用以突出显示这些基础数据点,代码里用 m_points 表示。
  2. 插值算法 SPPCHIP 基于所提供的基础数据点获取曲线的具体表达式。将 x 定义域均匀划分为比较密集的点,根据插值表达式分别算出这些点的 y 值,使用另外一个 QwtPlotCurve 对象以折线图形式显示这些密集数据点,注意此时不需要设置数据的标记风格,我们只需要把这根曲线显示出来,代码里用 m_curve 表示。
  3. 还需另外一个 QwtPlotCurve 对象,但这个对象只用于显示一个数据点,用来高亮标记我们用鼠标选中的数据点。该数据点的标记风格和基础数据点稍有不同,以此与其他数据点区分开来,代码里用 m_marker 表示。注意,鼠标点击曲线以外的区域时,就意味着没有数据点被选中,这时就应该取消数据点的高亮标记。我们可以比较简单地通过设置其 visible 属性实现。

将以上三个 QwtPlotCurve 对象都依附到 m_plot 上,就可以实现自定义的插值曲线显示了。实现代码如下。各对象显示的数据由 setSamples 设置,具体可查看完整代码。

/* 创建基础数据点的标记风格 */
QwtSymbol *symbol = new QwtSymbol(QwtSymbol::Ellipse); 	// 圆形
symbol->setSize(7);										// 标记大小
symbol->setBrush(QBrush(Qt::red, Qt::SolidPattern)); 	// 实心填充红色

/* 基础数据点显示对象 */
m_points = new QwtPlotCurve;
// m_points->setCurveAttribute(QwtPlotCurve::Fitted); 	// 这句代码可以实现曲线自动插值
m_points->setStyle(QwtPlotCurve::NoCurve);  			// 但我们这里不需要显示曲线
m_points->setSymbol(symbol);
m_points->attach(m_plot);

/* 创建高亮选中数据点的标记风格 */
symbol = new QwtSymbol(QwtSymbol::Ellipse);
symbol->setSize(9);
symbol->setBrush(QBrush(Qt::green, Qt::SolidPattern));

/* 高亮数据点实现对象 */
m_marker = new QwtPlotCurve;
m_marker->setSymbol(symbol);
m_marker->setVisible(false);
m_marker->attach(m_plot);

/* 自定义插值曲线显示对象,不需要数据点标记,使用直线连接即可 */
m_curve = new QwtPlotCurve;
m_curve->setPen(Qt::blue, 2);	// 设置线宽
m_curve->attach(m_plot);

鼠标响应

Qwt 可响应鼠标以及键盘事件,这里我们主要讨论鼠标事件。事件捕捉通过设置一个 QwtPlotPicker 对象实现,在定义对象时需要设置其需要捕捉事件的画布,可通过 m_plot->canvas() 获取。同时还需要添加一个状态机 QwtPickerMachine,用来设置 QwtPlotPicker 需要响应的事件。不同的 QwtPickerMachine 根据事件的不同会产生不同的命令队列,相应地会发射不同的信号,具体可查看 Qwt 的源码,主要在 qwt_picker.h(.cpp), qwt_plot_picker.h(.cpp), qwt_picker_machine.h(.cpp) 等几个文件。

常用的 QwtPickerMachine 有 QwtPickerClickPointMachine 和 QwtPickerDragPointMachine,两者的区别在于前者只响应鼠标点击事件,而后者还会响应鼠标移动事件。因此我们这里选择的是后者。当鼠标点击事件发生时,QwtPickerDragPointMachine 会产生一个 Append 命令,该命令会发射一个 appended(const QPointF& pos) 信号,pos 即为鼠标点击位置的坐标值,注意是当前显示坐标系下的坐标值,而不是鼠标的像素位置;而当鼠标移动事件发生时,QwtPickerDragPointMachine 会产生一个 Move 命令,该命令会发射一个 moved(const QPointF& pos) 信号,pos 为鼠标当前所在位置的坐标值。因此,当鼠标点击的时候,QwtPlotPicker 就会发射一个 appended 信号;只要鼠标不松开,并且产生移动,那么 QwtPlotPicker 就会持续地发射 moved 信号;当鼠标被松开,鼠标点击以及拖动的动作结束,QwtPlotPicker 不再响应鼠标的移动事件,直到下一次点击的发生。

因此,我们可以定义两个槽函数,分别响应 QwtPlotPicker 的 appended 和 moved 信号,根据其发射的坐标信息,确定当前曲线中是否有基础数据点被选中,即该坐标与某个数据点的距离是否足够小。对于 appended 信号,如果有基础数据点被选中,则高亮该数据点;如果没有基础数据点被选中,但是该坐标落在我们所插值的曲线上,则需要新增一个基础数据点,并高亮该数据点;否则不做任何处理,如果在此之前有基础数据点被选中高亮,则应该取消选中和高亮。对于 moved 信号,因为 moved 信号产生之前必定先有 appended 信号,我们需要先看响应 appended 信号后是否有基础数据点被选中。如果有,则需要把该数据点的坐标更改为当前鼠标所在坐标,因为插值曲线是由基础数据点决定的,所以这时候我们需要重新执行一次插值算法重构曲线,并且把更新后的基础数据点和曲线通过 m_plot->replot() 方法重新显示一遍,而人眼是察觉不到那么快的变化的,在主观上就形成了拖动曲线的效果;如果没有,则不做任何处理即可。

以下为 QwtPlotPicker 对象定义以及信号连接的代码。其中,slotPointSelected 和 slotPointDragged 为自定义的分别响应鼠标点击和移动事件的方法,具体可查看完整代码。TrackerMode 主要用于控制鼠标十字光标在画布上移动时所显示的效果,默认的是鼠标所在位置的坐标值,这里选择的是 AlwaysOff,也就是关闭显示。如果选择 AlwaysOn,则会一直显示;如果选择 ActiveOnly,则只有以上所述事件发生时才会显示。除此以外还可以设置更加复杂的显示效果,这里不赘述。

m_picker = new QwtPlotPicker(m_plot->canvas());
m_picker->setStateMachine(new QwtPickerDragPointMachine());
m_picker->setTrackerMode(QwtPicker::AlwaysOff);

connect(m_picker, SIGNAL(appended(QPointF)), this, SLOT(slotPointSelected(QPointF)));
connect(m_picker, SIGNAL(moved(QPointF)), this, SLOT(slotPointDragged(QPointF)));

要注意的是,在确定是否有基础数据点被选中时,通常的做法是计算鼠标坐标值与每个基础数据点的距离,然后选中距离最小的一个。QwtPlotCurve 有一个 closestPoint(const QPointF& pos, double* dist) 的方法,比如通过 m_points->closestPoint(pos) 就可求得基础数据点中与 pos 最靠近的点以及坐标。但这里坑在于,pos 并不是所显示坐标系下的坐标值,而被当作是鼠标在画布中的像素坐标值,里面的计算也是先把个基础数据点坐标转换为像素坐标计算的。因此,建议自己实现 closestPoint 的方法,也就是个简单的遍历而已。

完整的代码我放在了个人 Github 上,欢迎下载。

https://github.com/ZoengMingWong/EasyCurve-With-Qt-Qwt

猜你喜欢

转载自blog.csdn.net/qq_33552519/article/details/126410233
今日推荐