☰
Qt+QCustomPlot实现实时频谱图与瀑布图详解
2026/10/15 13:59:16 网站建设 项目流程

简介:面向需要实现实时信号可视化功能的QT开发者,使用QCustomPlot绘图库完成了一套频谱图与瀑布图实时显示工程,以信号源模拟为背景,呈现频谱与瀑布图同步刷新的效果,界面参数区由自定义控件搭建,适合学习QT高级绘图与控件定制。压缩包共116个文件,约35MB,主要涵盖32个C++头文件、23个源文件、11个UI界面文件、8个SVG矢量资源及少量动态库与静态库依赖,头文件与源码对应清晰,便于按模块阅读工程结构。已有660人学习下载。读者可从中掌握QCustomPlot的实时数据刷新与瀑布图渲染机制、无边框窗口及自定义开关按钮的封装技巧,并能参考完整工程将频谱分析能力迁移到自己的信号处理或测控软件中。 QT QCustomPlot实现实时频谱图,瀑布图实时显示

做上位机开发的,十有八九都会碰到“把实时数据画出来”这种需求。声学分析、振动监测、无线信号检测,甚至医疗设备里那些波形图,本质都是同一件事:传感器不断采数据,软件端要实时把数据变成人眼能看懂的图像。我去年做一套音频分析工具的时候,就用Qt加QCustomPlot把实时频谱图和瀑布图做了出来,今天把整套思路和踩过的坑整理出来,给正在做类似项目的朋友一个参考。

先说结论:QCustomPlot做实时频谱和瀑布图,效率完全够用,而且不需要引入OpenGL之类的重依赖。它本身就是基于QPainter的轻量级绘图库,把核心API吃透后,实时刷新60帧也没问题,关键看你怎么操作数据、怎么控制重绘范围。

这套东西适合谁看?已经会用Qt基本控件、但对QCustomPlot不太熟的人,或者正打算做频谱类上位机、但不确定第三方库怎么选型的人。我会把数据流程、关键代码、性能优化的思路全部摊开讲,你照着改就能用在自己的项目里。

1. 整体设计与方案选型

1.1 为什么选QCustomPlot而不是QCharts或自绘

做实时频谱图,第一个选择摆在面前:用QCharts还是QCustomPlot。QCharts是Qt官方库,但它的性能在大量数据点实时刷新时比较吃力,尤其是波形数据每秒几十帧更新,QCharts很容易把UI线程卡死。QCustomPlot在数据点批量更新、局部重绘这块做了很多优化,底层直接操作QCPGraph的数据容器,效率高一个量级。

另外,瀑布图这种需求,用QCharts很难原生实现,它没有内置的二维颜色映射图。QCustomPlot有QCPColorMap,专治这种“数据矩阵拍成热力图”的场景。我当时还考虑过纯自绘,用QPainter配合双缓冲一个像素一个像素画,但从零写起要处理坐标变换、颜色映射、鼠标交互,开发周期太长,实在没必要重新造轮子。

1.2 数据流程:从采样到显示的完整链路

整套系统的实时链路,其实就是一条单向数据管道。采集线程拿到音频或传感器数据,先做FFT变换,把时域信号变成频域幅值序列,再把幅值序列丢给绘图线程,频谱图画实时曲线,瀑布图存历史帧。

这里有一个关键设计决定:FFT计算不能放在UI线程。你如果把傅里叶变换写在绘图代码旁边,一旦FFT计算量上去了,界面立刻掉帧。我的做法是单独开一个QThread做数据处理,算完的频谱数据通过信号槽发到主线程,QCustomPlot只管接收结果并刷新。信号槽虽然是队列连接,但在数据量可控的情况下延迟完全可以接受,毕竟你要的是“看起来实时”,不是纳秒级同步。

频谱图用了QCPGraph画曲线,瀑布图用QCPColorMap画颜色图。两者共享同一份FFT结果,只是展示维度不同。频谱图是“一帧一曲线”,瀑布图是“不断往下滚动的历史帧堆叠”。

1.3 瀑布图的实现原理:本质是一个滚动的二维数组

很多新手一听瀑布图就懵,其实说破了一点都不复杂。瀑布图的本质,是一个按时间堆叠的二维数组。每一行代表某个时间点的频谱幅值,横轴是频率,纵轴越靠下代表时间越早。新数据来一帧,就插到最上面一行,所有旧行往下挪一行,最底下的老数据被挤出去。

在QCustomPlot里用QCPColorMap实现这个滚动效果,比你自己维护一个二维矩阵再全量重绘快得多。因为QCPColorMap内部就是QCPColorMapData,它维护一个double数组,我们可以直接操作这个数组的指针,用memmove做整行搬移,一次只更新最新一行,效率非常高。这个细节后面实操部分我会重点讲。

2. 频谱图和瀑布图的核心细节

2.1 频谱图的X轴与Y轴设置

频谱图的横轴是频率,纵轴是幅值。频率范围取决于你的采样率和FFT点数。比如采样率是48000Hz,FFT点数取4096,那频率分辨率就是48000除以4096,约11.7Hz。根据奈奎斯特采样定理,有效频率范围是0到24000Hz,但通常只画到20000Hz就够了,因为人耳听力上限就在那附近。

X轴范围初始设置好之后,要调用setRangeFixed(true)把范围锁死。不要让它自动缩放,否则数据实时来的时候X轴会不断跳动,看起来就像画面在抽风。

Y轴的处理就不一样了。数据幅度范围可能动态变化,所以Y轴应该开启自动缩放。QCustomPlot里默认是replot的时候自动rescale,但你得自己调一下rescaleAxes(),并且只rescale Y轴,X轴保持固定。QCPAxis有rescale(bool onlyVisible)方法,用的时候传个true,就只对可见范围的数据做scale。

2.2 瀑布图的颜色映射与坐标映射

QCPColorMap的关键在于把幅值映射成颜色。这个映射关系由QCPColorGradient决定,你可以选择jet、hot、cold这类预设梯度,也可以自定义。做音频频谱,我习惯用jet,蓝绿黄红渐变,低频蓝高频红,视觉上非常直观。

渐变的下限和上限要注意设置合理。下限设太低,整个图一片蓝,动态细节全丢;上限设太低,又一片红,直接饱和。我推荐先用一段时间的数据统计出幅值分布范围,然后设置成较小的动态范围,比如40dB到100dB,这样能突出微弱信号的变化。QCPColorMap里对应的接口是setDataRange,参数传QCPRange。

关于坐标映射,QCPColorMap的X轴和Y轴可以直接用setKeyAxis和setValueAxis绑定到QCPAxis上。但要注意区分“数据索引”和“坐标值”。QCPColorMapData里操作的是行列索引,而你要把行索引映射成时间值,把列索引映射成频率值。如果时间轴和频率轴的刻度需要显示真实数值,就要在设置轴范围时让范围和矩阵尺寸对齐,这是一步很容易搞混的操作。

2.3 数据更新的节奏控制

实时系统里,绘图频率控制是个技术活。理论上,数据每来一帧就刷新一次界面,但QCustomPlot的replot不是免费的,画的内容越多越耗CPU。如果数据刷新频率是每秒钟60帧,而你的屏幕只有60Hz刷新率,那也没必要每帧都重绘。

我的经验是:给绘图做一个简单的节流器。可以用QTimer,每隔固定时间拉取一次最新数据并刷新,比如每16毫秒刷新一次,对应约60fps。也可以用累加器,攒够一定数量的数据帧再统一刷新。实测下来,节流到30fps,视觉上已经很流畅,但CPU占用能降一半。如果你的数据采样率很高、FFT点数又多,节流几乎是必须的。

3. 实操落地:完整实现流程

3.1 环境准备与QCustomPlot集成

先从环境说起。QCustomPlot是一个单头文件加单源文件的库,就qcustomplot.h和qcustomplot.cpp两个文件,把这两个文件拷进你的工程,然后在pro文件里加一句include,完事。

QT += core gui widgets printsupport

注意必须加上printsupport模块,否则编译会报一堆找不到QPrinter的错误。如果你跟我一样用CMake,就在CMakeLists里加:

find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets PrintSupport) target_link_libraries(your_target PRIVATE Qt6::PrintSupport)

新版QCustomPlot是2.x版本,我用的2.1.1,支持Qt5和Qt6,我在Qt 5.15.2和Qt 6.4上都编译过,没有遇到兼容性问题。

3.2 频谱图核心实现:QCPGraph实时更新

先创建一个自定义的绘图控件,直接基于QCustomPlot派生,或者简单点直接在界面里放一个QCustomPlot,然后添加一条曲线。

// 创建频谱图曲线 ui->customPlot->addGraph(); ui->customPlot->graph(0)->setPen(QPen(QColor(0, 180, 255))); ui->customPlot->graph(0)->setName("幅度谱"); // 设置X轴范围:0~20kHz ui->customPlot->xAxis->setRange(0, 20000); ui->customPlot->xAxis->setRangeFixed(true); // Y轴标签 ui->customPlot->yAxis->setLabel("幅值 (dB)");

接下来是数据更新的核心。QCustomPlot的QCPGraph内部有一个QCPDataContainer,你可以直接拿到这个容器,然后批量更新数据,而不是调用setData每次都重新分配内存。

void Widget::updateSpectrum(const QVector<double>& freq, const QVector<double>& mag) { // 拿到graph内部数据容器,直接操作 QCPGraphDataContainer* dataContainer = ui->customPlot->graph(0)->data(); dataContainer->clear(); // 批量追加数据,避免频繁的内存重分配 dataContainer->add(QCPGraphData(freq[i], mag[i])); // 只自动缩放Y轴 ui->customPlot->yAxis->rescale(true); // 触发重绘 ui->customPlot->replot(); }

这里面有个隐藏的优化点,就是clear和add的组合。如果每次setData重新传QVector,QCustomPlot内部要重新构建整个容器,数据量大时开销不小。直接操作data()返回的指针,可以大量减少重复内存操作。但前提是你自己管理好数据生命周期,别在绘图线程还在读的时候,另一线程就把数据改了。

3.3 瀑布图核心实现:QCPColorMap数据滚动

瀑布图方面,先创建QCPColorMap,初始化颜色梯度和尺寸。

// 假设FFT点数为1024,画面显示200帧历史数据 int fftSize = 1024; int historyFrames = 200; // 创建颜色映射对象 QCPColorMap* colorMap = new QCPColorMap(ui->customPlot->xAxis, ui->customPlot->yAxis); colorMap->data()->setSize(fftSize, historyFrames); colorMap->data()->setRange(QCPRange(0, fftSize - 1), QCPRange(0, historyFrames - 1)); // 设置颜色梯度 QCPColorGradient gradient; gradient.setColorStopAt(0.0, QColor(0, 0, 90)); gradient.setColorStopAt(0.3, QColor(0, 170, 255)); gradient.setColorStopAt(0.6, QColor(0, 255, 0)); gradient.setColorStopAt(1.0, QColor(255, 0, 0)); colorMap->setGradient(gradient); // 设置幅值范围 colorMap->setDataRange(QCPRange(-80, 0));

数据更新的滚动操作如下。每次新来一帧频谱,就把整个数据矩阵往下平移一行,最新数据放到最顶部。

void Widget::updateWaterfall(const QVector<double>& mag) { QCPColorMapData* data = ui->customPlot->colorMap(0)->data(); // 把已有数据往下移一行 // 注意这里用memmove处理连续内存,效率最高 int rows =>// 工作线程中发射信号 void Worker::onDataReady(const QVector<double>& freq, const QVector<double>& mag) { emit spectrumReady(freq, mag); } // 主线程中连接槽 connect(worker, &Worker::spectrumReady, this, &Widget::updateSpectrum, Qt::QueuedConnection);

如果你追求的是极限实时性,也可以考虑用双缓冲方案:子线程把数据写入一个锁保护的共享缓冲区,主线程用QTimer高频拉取。但从复杂度角度,信号槽方案已经能满足机械硬盘转起来的实时感,大部分场景确实不需要再卷到那种程度。

4.3 颜色梯度的选择和调参建议

瀑布图的可读性,配色占一半。很多人直接用默认的QCPColorGradient::gpGrayscale,结果低频高频全是灰的,什么细节都看不清。我强烈建议用jet色带,或者cold到hot的渐变。

色带确定后,重点调幅值映射范围。这个范围设置得好不好,决定瀑布图的细节是否清晰。比如信号的本底噪声在-90dB,峰值在-20dB,那你把range设成(-100, -10),整个动态范围就都能显示出来。如果设成(-60, 0),那背景噪声就会变成深蓝色,看起来和没信号的地方没差别,弱信号就丢了。

还有一个小技巧:QCPColorMap的interpolate开关。默认true,相邻像素之间会做线性插值,显示出来更平滑,适合音频频谱。如果你做的是频谱仪那种要精确读出单根谱线的场景,可以关掉插值,每个格子独立着色,测量更准确。

5. 常见问题与排查技巧实录

5.1 实时刷新卡顿,CPU占用率异常高

问: 明明只画一条曲线,一个颜色图,为什么CPU到了30%多?

答: 大概率是你每帧数据都调了replot(),而且QTimer间隔设置得太短。试试把刷新节流到30fps,再检查是不是不小心开了多个QCustomPlot实例同时replot。另外,确认下你没有在循环里new QCPGraph或QCPColorMap对象,如果每次更新都new再手动delete,会引来大量内存碎片和频繁的构造析构,这是最容易卡死的一种操作。

5.2 瀑布图的颜色渐变不平滑,出现明显色块

问: 明明是连续频谱,为什么瀑布图上一块一块的色块?

答: 先检查interpolate有没有打开,再检查颜色梯度设置的colorStop是否过少。越多的colorStop颜色过渡越平滑,但密度太高调起来也麻烦。通常3到5个色标点就够了,把变化集中在信号所在的幅值区间。还有一种情况是矩阵的尺寸太小,比如行数只有50,会显得颗粒感很重,适当调大historyFrames或者降低FFT点数会有改善。

5.3 编译不过:找不到QPrinter / QCustomPlot相关符号

问: 下载了最新版QCustomPlot,编译报错找不到QPrinter头文件。

答: 99%是工程没加printsupport模块。Qt5里QPrinter被拆到了QtPrintSupport模块。QCustomPlot的头文件里强制引用了它,没加这个模块,编译到一半就会报错。检查pro文件或CMakeLists,确认加了printsupport。另外,别用太老的QCustomPlot版本,我见过有人还在用1.3.x,那版对Qt5的支持不完整,直接用2.x吧。

5.4 多路数据源时,频谱图和瀑布图对不上

问: 我同时接了麦克风和加速度传感器,两个频谱图,结果发现和瀑布图对不上,差了半帧到一帧的时间。

答: 数据对齐是实时系统最常见的坑。原因很简单:两个数据源到达UI线程的时机不同,而绘图是最后一次数据到达时才replot。解决方案是对每个数据源打时间戳,或者把所有数据源先合并成一条消息帧,再同时更新。我这边是采用第二种方案:把两路数据打包在一个结构体里发出来,确保同一时刻的数据一定在同一帧处理,从根上解决这个问题。

5.5 鼠标交互失效,无法缩放拖拽

问: 我设置了setInteractions(QCP::iRangeDrag | QCP::iRangeZoom),但拖动没反应。

答: 检查一下你是不是把X轴的setRangeFixed设成了true。X轴范围被锁死后,鼠标拖拽拖动的是整个坐标系,看起来就像没反应。如果你只想锁定X轴但允许Y轴缩放,可以把X轴的setRangeFixed关掉,然后在rangeChanged信号里手动setRange固定X轴范围,这样交互和锁定效果并存。QCustomPlot没有单独的轴锁定接口,必须用信号转发来曲线救国。

6. 总结一下我从这套系统里学到的经验

写到这里,其实最想说的是:QCustomPlot这东西,API看起来简单,但想做得流畅、稳、好维护,靠的是对数据生命周期的理解和对绘图机制的吃透。实时绘图系统的瓶颈往往不在绘图库本身,而在你给绘图库喂数据的方式。

我踩过最大的坑,就是一开始图省事,用最直接的setData加replot的写法。当时FFT点数小,没看出问题,后来把FFT点数提高到8192,界面直接卡成一帧一帧放幻灯片。后来把数据更新改成指针对指针的批量操作,又做了刷新节流,才算真正解决。

如果让我给准备做同样需求的人一个建议的话:不要一开始就追求UI特效,先把数据链路跑通,再加上颜色渐变、坐标交互、多图联动,这样出了问题也好定位。另外,性能优化要在需求定型后做,不要提前优化,说不定你实际的数据量根本触发不了性能瓶颈。

如果你打算把这套思路用到自己的项目里,先去把QCustomPlot的官方demo跑一遍,尤其是color map那个demo,能省下很大的探索成本。有具体问题,欢迎评论区交流,我看到都会回复。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询