Qt QCustomPlot 瀑布图实现:从频谱到实时滚动可视化

发布时间:2026/10/16 11:58:10
Qt QCustomPlot 瀑布图实现:从频谱到实时滚动可视化 简介一份基于Qt框架和QCustomPlot绘图库实现的瀑布图示例项目面向需要掌握自定义图表开发的C桌面应用开发者适用于学习图形界面与数据可视化。压缩包共36个文件以cpp、h源码和pro工程文件为主附带ui界面描述以及release与debug目录下的可执行文件、目标文件等编译产物整体大小约6.08MB在Qt 5环境下可直接打开运行。目前已有7812人学习通过随机数模拟数据并逐层绘制矩形来展示瀑布图效果包含坐标轴初始化、背景与颜色区分正负值、连接线绘制、replot刷新以及动画逐帧显示等实现细节。阅读代码可深入理解QCustomPlot的图层管理、数据组织与事件处理机制同时也能学习到随机数据生成、坐标系设置、图形刷新等基础操作为后续搭建交互式数据可视化界面提供可复用的设计思路。1. 瀑布图 Demo把一帧频谱看不出的事交给时间轴去暴露调试某信号处理模块时遇到一个典型困境单看一帧 FFT 频谱峰在 2kHz 处稳定存在可设备跑起来后偶发异常频谱截图对比了半天也找不出问题。直到我把连续几十帧频谱摞成一张图——横轴频率、纵轴时间、颜色代表幅度频率漂移和偶发毛刺一目了然。这就是瀑布图的威力。这个 QT QCustomPlot 的瀑布图 Demo实现了从模拟信号源到实时滚动瀑布图的完整链路适合做频谱监测、雷达信号分析、音频可视化界面的开发者拿去当起点跑通后直接替换数据源就能接进自己的采集程序。2. 技术选型QCPColorMap 如何撑起一张实时瀑布图2.1 瀑布图的本质是一块二维内存很多人第一次听到「用 QCustomPlot 画瀑布图」会愣一下QCustomPlot 不是 2D 绘图库吗曲线、柱状图、散点图才是它的主场瀑布图不是得用 OpenGL 或者 Q3DSurface 那套吗这个理解有点误会。瀑布图Waterfall Plot落到数据模型上就是一张二维矩阵行代表时间帧列代表频率点矩阵的值代表该频点在该时刻的幅度。可视化时把矩阵画成一个矩形区域用颜色深浅表示数值大小。这不就是 QCPColorMap 干的事吗——它本质就是一张规则的 2D 网格每个格点cell存一个值渲染时把一个值范围映射到一条颜色梯度上。所以这个 Demo 的技术路线很直接不用 QCustomPlot 的曲线能力而是用它的 QCPColorMap 组件。横轴是频率或谱线索引纵轴是帧序号时间方向颜色从深蓝到亮黄表示幅度从低到高。每来一帧新频谱就把矩阵整体向上滚动一行新数据填到最新一行旧数据自然被顶出视野。整个滚动过程本质上是在维护一块QCPColorMapData的内存块改完值之后调一次rescaleValues()加一次重绘。2.2 三种画法对比为什么不选 QCPGraph 堆叠在决定用 ColorMap 之前我简单对比过另外两种常见画法这里直接说结论。第一种是 QCPGraph 多曲线堆叠每一帧频谱画成一条曲线每条曲线在纵轴上人为偏移一个固定量层叠起来形成「层峦叠嶂」的 3D 效果。这种画法最容易理解每一条线就是一次 FFT 结果代码也很好写。但它的缺点很致命曲线堆叠只能保留频谱的形状幅值信息的可读性很差——某一帧在 3kHz 的峰值比相邻帧高出 6dB堆叠图里根本看不出来因为人眼对线条前后遮挡的幅度差异很不敏感。另外帧数一多几百条 QCPGraph 对象会造成明显的绘制开销。第二种是 QCPCurve 在三维投影上的硬画既然 QCustomPlot 支持自定义 AxisRect 和坐标变换理论上可以把频率、时间、幅度三个维度投影到二维平面上。这条路做出来确实有 3D 旋转的视觉效果但 QCustomPlot 并非为 3D 渲染设计投影矩阵、深度排序、视角变换全得自己写一套搞下来工程量和维护成本远超一个 Demo 应有的体量。除非你要做的是「旋转视角的立体瀑布」否则不建议走这条路。第三种就是这个 Demo 用的 QCPColorMap。它的优势是渲染模型简单、滚动更新极其高效——改的是矩阵里的数值而不是对象的集合颜色梯度能承载幅度信息动态范围远比线条堆叠大而且 QCustomPlot 对 ColorMap 的重绘做了充分的局部更新优化。缺点只有一个颜色映射需要一段适应期——新手看图时总想先找「哪条线是峰」得先习惯「哪个颜色块是峰」。2.3 目录结构与数据流这个 Demo 的工程结构不复杂我把核心文件的职责拆出来看waterfall_demo/ ├── main.cpp // 程序入口 ├── mainwindow.h / .cpp // 主窗口状态栏 控件布局 ├── waterfall_widget.h / .cpp // 核心自绘控件封装瀑布图逻辑 ├── datasource.h / .cpp // 模拟数据源QTimer 定时产帧 ├── mainwindow.ui // 界面文件放了按钮和控制项 └── qcustomplot.h / .cpp // QCustomPlot 库源码随 Demo 工程附带数据流是一条直线QTimer每到一次超时就从datasource里取出一帧QVectordouble长度等于频率点数把它交给waterfall_widget的一个公开方法addFrame()里面完成矩阵滚动、数值更新、刷新 UI 三步操作。主窗口里一个「开始/停止」按钮控制定时器的启停。datasource是个独立类这点设计得很聪明——它内部生成什么数据完全是可替换的。Demo 里生成的是「两个固定频点的正弦峰 白噪声」模拟频谱你把它换成fft()输出或者网络接收到的频谱帧瀑布图本体不用动。下面拆核心代码时你就发现了addFrame()的输入只是一个QVectordouble至于数据怎么来的它不关心。3. 核心实现从模拟信号源到滚动瀑布的完整链路3.1 模拟数据源三个频率分量加上噪声datasource的职责是每次被调用时生成一帧频谱数据。模拟瀑布图的理想输入是「几个稳定存在的峰加上偶尔跳变的细节」。我在这里给数据源设计了三个频率分量两个固定峰、一个缓慢漂移的峰叠加在噪声底上。QVectordouble DataSource::generateFrame(int frameIndex) { const int N 512; // 频率点数 QVectordouble frame(N, 0.0); for (int i 0; i N; i) { double freq i * 1.0; // 模拟频率刻度单位可自定义 double value 0.0; // 峰1固定在 120Hz 附近幅度 1.0 value 1.0 * exp(-0.5 * pow((freq - 120.0) / 4.0, 2)); // 峰2固定在 300Hz 附近幅度 0.7 value 0.7 * exp(-0.5 * pow((freq - 300.0) / 6.0, 2)); // 峰3中心频率随帧序号缓慢漂移模拟实际设备中的频飘现象 double driftingCenter 220.0 10.0 * sin(frameIndex * 0.05); value 0.5 * exp(-0.5 * pow((freq - driftingCenter) / 5.0, 2)); // 均匀白噪声幅度 0.05避免图像过于干净 value 0.05 * ((rand() % 1000) / 500.0 - 1.0); frame[i] value; } return frame; }参数说明frameIndex是帧序号这里用它计算漂移峰的中心频率制造出「频点随时间缓慢移动」的效果这正好是瀑布图最擅长暴露的现象N是频谱点数对应瀑布图矩阵的列数高斯峰的exp(-0.5*(x/σ)^2)形式是为了产生平滑的峰形状σ 控制峰宽调成 4、6、5 分别对应不同带宽。rand() % 1000 / 500.0 - 1.0生成的随机数范围是 [-1, 1)乘 0.05 后作为噪声底肉眼观察时既能看出「底色不干净」又不会淹没主峰。实际使用中你把整个函数体换成 FFT 结果填充逻辑即可。比如从音频设备拿一帧 PCM做 1024 点 FFT取幅度谱的QVectordouble返回即可。数据源独立的好处就在这里核心瀑布图代码完全不感知数据的物理含义。3.2 初始化 QCPColorMap范围、坐标轴与颜色梯度瀑布图控件的核心是WaterfallWidget类它内部持有一个QCustomPlot指针。初始化时最关键的三个动作是创建 QCPColorMap、设定矩阵尺寸、配置坐标轴范围。void WaterfallWidget::initPlot() { m_plot new QCustomPlot(this); // 1. 新建 ColorMap并指定尺寸128 个时间行512 个频率列 m_colorMap new QCPColorMap(m_plot-xAxis, m_plot-yAxis); m_colorMap-data()-setSize(512, 128); // (频率点数, 滚动窗口帧数) // 2. 设定矩阵元素对应的坐标范围 m_colorMap-data()-setRange(QCPRange(0, 512), // 键轴横轴范围 QCPRange(0, 128)); // 值轴纵轴范围 // 3. 颜色梯度从深蓝到紫色再到亮黄模拟经典频谱仪配色 QCPColorGradient gradient; gradient.setColorStopAt(0.0, QColor(10, 10, 60)); // 低幅度深蓝 gradient.setColorStopAt(0.3, QColor(80, 60, 160)); // 中低蓝紫 gradient.setColorStopAt(0.6, QColor(200, 80, 30)); // 中高橙红 gradient.setColorStopAt(1.0, QColor(255, 240, 120)); // 高亮黄 gradient.setLevelCount(256); // 颜色离散级数 m_colorMap-setGradient(gradient); // 4. 设定颜色映射的数据范围超出范围的数值会被截断 m_colorMap-setDataRange(QCPRange(0.0, 1.5)); // 5. 坐标轴标签 m_plot-xAxis-setLabel(Frequency); m_plot-yAxis-setLabel(Frame (older at bottom)); m_plot-rescaleAxes(); }逐个说明setSize(512, 128)的两个参数第一个是键轴x方向格点数第二个是值轴y方向格点数。我的约定是横轴表示频率、纵轴表示时间所以矩阵宽 512频率点数、高 128可显示的滚动帧数。这里的高也就是「瀑布图能保底多少帧历史」。setRange是把这个离散格点矩阵映射到实际坐标。QCPRange(0, 512)和QCPRange(0, 128)看起来和格点数相同是因为模拟数据源里的频率索引直接用了整数实际换成 ADC 采样率、FFT 频率分辨率时这里要写真实的频率范围。gradient.setColorStopAt是颜色梯度函数的关键接口——参数 1 是归一化位置0.0 到 1.0参数 2 是颜色。我这里用了四级渐变来模拟频谱仪那种「深底亮峰」的视觉效果。setLevelCount(256)表示把渐变离散成 256 级数值平滑同时避免一帧更新后颜色条出现明显的带状断层。setDataRange是很多新手容易漏的一步它决定哪个数值范围对应颜色梯度的 0.0 到 1.0我这里把上限设到 1.5给噪声留下的视觉余量。3.3 滚动更新新帧挤入、矩阵整体上移addFrame()是这个 Demo 的心脏每次一帧新频谱进来要把矩阵里所有旧数据往上推进一行空出的最新一行写入新帧。void WaterfallWidget::addFrame(const QVectordouble frame) { QCPColorMapData* data m_colorMap-data(); int rows >// 数据更新后只刷新 ColorMap 层不动坐标轴减少重绘开销 void WaterfallWidget::addFrameFast(const QVectordouble frame) { QCPColorMapData* data m_colorMap-data(); int rows >QT core gui printsupport greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c11 SOURCES \ main.cpp \ mainwindow.cpp \ waterfall_widget.cpp \ datasource.cpp \ qcustomplot.cpp HEADERS \ mainwindow.h \ waterfall_widget.h \ datasource.h \ qcustomplot.h FORMS \ mainwindow.ui参数说明printsupport是 QCustomPlot 的硬依赖主要是为了它的QCPPainter在导出图片、打印等场景下工作正常不加这一项编译会报QPrinter相关的错误。CONFIG c11是因为 QCustomPlot 较新版本里使用了nullptr、override等 C11 特性Qt 5.x 里默认没开。注意如果直接用 Qt 5.15 较新版 QCustomPlotgreaterThan(QT_MAJOR_VERSION, 4)这个条件会在 Qt 6 下继续生效Qt 6 的 widgets 模块也要保留这个判断用 Qt 6 编译时printsupport模块写法不变。4.2 在 .ui 文件里提升控件为 QCustomPlot这一步是很考验操作习惯的地方。传统做法是在界面文件里拖入一个普通QWidget然后通过「提升为」Promote to操作把它替换成QCustomPlot类。操作路径是在 Qt Designer 中拖入QWidget右键选择Promote to...在提升对话框中填提升的类名 (Promoted class name): QCustomPlot 头文件 (Header file): qcustomplot.h勾选「全局包含Global include」后点确定。这一步的本质是给.ui文件里的widget标签增加一个自定义类名和头文件引用。如果手动编辑.ui文件等价效果是widget classQWidget namewidgetPlot property namegeometry rectx20/xy20/ywidth780/widthheight480/height/rect /property /widget改成widget classQCustomPlot namewidgetPlot !-- 其余属性保持不动 -- /widget并且在customwidgets段加入customwidget classQCustomPlot/class extendsQWidget/extends headerqcustomplot.h/header /customwidget提升完成后在MainWindow构造函数里把ui-widgetPlot转成WaterfallWidget初始化逻辑的宿主或者把WaterfallWidget作为一个整体封装类拖入界面——我用的是后者整个瀑布图控件作为一个自定义 widget 提升比单独提升QCustomPlot再外挂数据源逻辑更内聚。4.3 验证跑通什么样才算真的瀑布图代码写完编译通过只是第一步你得确认它真的在「滚动」。按下开始按钮后应该看到颜色条带从上方或下方持续「涌出」旧的谱峰缓慢向上或向下移出视野。我一般验证三个点第一漂移峰要能看出斜线轨迹。Demo 数据源里的第三个峰中心频率按sin曲线变化表现到瀑布图上应该是一条波浪形的亮色曲线而不是一个固定竖条。如果看到的是不动竖条说明addFrame()根本没被定时器调用或者调用了但矩阵没有滚动。第二噪声底要显示为均匀的暗色颗粒感。如果整个图全黄或全蓝多半是setDataRange范围设错了——峰值如果只有 1.0而你设了QCPRange(0, 10)那最大值也落在颜色梯度很低的区间整张图会接近深蓝。第三新的帧进来时 UI 不卡。512×128 的矩阵规模下Qt 在 Debug 模式也能跑到 30 FPS 以上。如果明显掉帧检查是不是每次addFrame里都调用了rescaleAxes()或者全量replot()这两个操作是性能隐患。验证通过的标准我认为是能清楚说出「这张图里频率 220Hz 附近的峰在这段时间内漂移了约 10Hz」这类信息——这就是瀑布图存在的意义。5. 实战避坑瀑布图五个高频翻车点5.1 瀑布图静止不动或者只闪了一下就全黑现象程序启动后图里只有初始零值产生的纯色背景点按钮后没有任何滚动效果。原因最常见的两个。一是QTimer没有连接到addFrame槽函数或者连接了但定时器没start()二是addFrame里的矩阵滚动方向反了——新帧写到了第 0 行而 y 轴方向又是正向导致新数据出现在视野最底端恰好被坐标轴标签或者绘图区外沿裁掉。解决先确认定时器在工作在addFrame里加一句qDebug() frame.at(0)看有没有输出。确认有数据后检查setCell(x, y)里 y 的写入位置是不是最后一行且 y 轴方向符合预期——把 y 轴方向设为setRangeReversed(true)时视觉上最顶行对应矩阵最后一行反之亦然。5.2 颜色全黑或全白完全看不到峰的层次现象绘图区不是纯黑就是纯白峰的位置没有颜色差异。原因setDataRange的数据范围设得太宽或太窄。设太宽比如QCPRange(0, 1000)而实际数据最大值只有 1.5那所有数值都落在颜色梯度最底端附近设太窄比如QCPRange(0, 0.1)噪声都超过上限整片区域全是最高颜色。解决在addFrame首次运行时打印>for (int x 0; x rows; x) { double v (x frame.size()) ? frame.at(x) : 0.0; if (!std::isfinite(v)) v 0.0; // 非有限数值强制归零 >QVectordouble DataSource::generateFrame(int frameIndex) { Q_UNUSED(frameIndex); std::vectordouble pcm audioInput-readSamples(1024); // 假设的采集接口 QVectordouble spectrum(512); fftForward(pcm, spectrum); // 假设的 FFT 封装 // 转成幅度dB 或线性均可但 setDataRange 要相应调整 for (int i 0; i spectrum.size(); i) { spectrum[i] std::abs(spectrum[i]); } return spectrum; }换完数据源后有两个配套改动。第一是setDataRange不再写死改为首次数据到达时根据实际幅度范围自适应第二是坐标轴标签——把Frequency改成真实的频率刻度比如xAxis-setRange(0, sampleRate / 2)纵轴改成真实时间比如每帧间隔 20ms那 y 轴范围就是(0, 帧数 * 0.02)秒。往「能用的频谱监测组件」方向走我建议再补三件事。一是鼠标悬停读数QCustomPlot 的QCPColorMap支持按像素坐标反查key和value通过plot-setMouseTracking(true)加plot-setInteractions(QCP::iSelectPlottables)信号槽里能实时显示「当前频率 当前时间 幅度」的三元组。二是加一个「暂停/恢复」开关这在信号分析里刚需——瀑布图一旦滚动起来想细看某个时刻的历史数据就得暂停更新。三是把addFrame的memcpy版本封装成setFrameData批量接口以便未来从采集线程直接灌数据避免跨线程槽调用。有一次我拿着这套组件去分析一段电机异响的录音文件第一次跑出来看到瀑布图上 800Hz 附近有一条断断续续的黄色带状轨迹但单看频谱图完全无感知——那条轨迹恰好对应电机转速波动时的电磁噪声。那一刻我才真正认同瀑布图这个形态的价值它把「频率随时间的变化」变成了「直接长在图上」的信息而不是你从一堆频谱图里强行对比出来的结论。从那以后我每次调试信号类项目都会先把瀑布图搭起来再开始看数据。希望这篇拆解能帮你少走几个弯路让这个 Demo 直接变成你下一个分析工具的起点。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询