简介:一套基于QT与C++的无线电图形开发源码包,面向需要实现频谱图、瀑布图、波形图等实时可视化的嵌入式或通信方向开发者。工程共117个文件,包含68个头文件、37个C++源文件、6个UI界面文件及2个工程配置等,源码结构与类划分清晰,便于二次扩展。压缩包仅7.27MB,整体轻量,适合快速阅读或直接集成。已有900人学习下载。内容覆盖WRSpectroControl、WRWaterfallControl、WROscilloControl等核心控件,以及频谱分析、瀑布图数据视图、示波器轴绘制等模块,可帮助理解基于QCustomPlot、QChart或QPainter的绘图思路,并借鉴信号与槽机制、多线程处理下的实时数据刷新方案。对有基本QT基础、希望将无线电信号可视化落地到自研工具中的开发者,是一份实用的参考实现。
1. 频谱图、瀑布图、波形图:用 QT 自绘无线电图形,到底该怎么下手
接手无线电相关项目时,最头疼的不是算法,而是“数据到手了,画不出来”。SDR 接收机解调出的 IQ 数据、 spectrum analyzer 采集到的功率谱、甚至超声探伤仪的回波信号,最终都要变成屏幕上能看的波形、频谱和瀑布图。市面上的无线电软件(GNU Radio、SDR#、URho 等)界面各异,但核心画图部分大多脱不开 QT 的 QPainter 或 QCustomPlot。原因很简单:QT 跨平台、刷新机制成熟、对嵌入式设备(树莓派、RK3568 等)有现成的交叉编译链。这套“无线电基本图形”方案,适合做 SDR 上位机、频谱仪控制软件、雷达信号显示终端的人直接复用;如果你只是想在现有界面里加个波形图,这里的架构也能剪裁出一半直接用。
这套东西的本质并不玄学:频谱图是把 IQ 数据做 FFT 后把功率值映射成折线;瀑布图是把一连串频谱按时间堆叠成像素条;波形图是 I/Q 或解调后数据的实时趋势。难点不在数学,而在“怎么保证 30 帧每秒不卡顿、缩放不崩、长时间跑不内存爆掉”。我接过的收音机基带监控项目里,团队用 QWidget 硬绘,不做任何缓存,结果 CPU 占用常年 40% 以上,动一下鼠标就掉到 10 帧。后来改成了“块状重绘 + 离屏像素图 + 局部 update()”,同样的 4 路频谱,CPU 从 40% 降到了 12%。下文整个落地路径都围绕这个目标转:选对组件、理清线程、写好绘制函数,最后用一套避坑清单把你拦在已知的坑外。
2. 画图前必做的核心架构:QT 绘图的坐标系与刷新机制
2.1 用 QPainter 还是 QCustomPlot:无线电图形选型依据
无线电波形和频谱图,跟普通业务报表图表的绘制需求有明显差异:数据量极大(屏幕宽度就那么多像素,但底层数据是连续流)、更新频率极高(波形图每秒 30 到 60 帧刷新)、需要持续滚动且伴随缩放和拖拽。QCustomPlot 用起来舒服,因为它的 QCPGraph 和 QCPColorMap 已经帮你处理了坐标轴缩放、抗锯齿、数据重采样,也能直接用 setData() 换数据。但它的坑也很明显:QCPColorMap 做瀑布图时内存占用高、CPU 开销大,尤其是长时间跑频谱显示,颜色图的分辨率设置必须克制。做瀑布图,我见过不止一次有人把 colorMap 宽度设成 4096,结果跑两小时内存直接吃掉几个 GB —— 这是玩脱了,不是设计如此。
我一般会这么选:波形图和频谱图用 QCustomPlot,瀑布图绝不碰 QCPColorMap,而是用 QPainter 自绘。原因是瀑布图本质是“整体左移一像素,然后把最新一条频谱画在最右列”,这种逻辑用 QImage 或 QPixmap 的 copy() 加 drawImage() 实现,效率远高于 QCPColorMap 的逐点插值。另外,QCustomPlot 对超大数据的折线做了 downsampling,但也有时候会“自作聪明”地丢峰值,导致无线电频点显示偏移。真到那个场景,不如直接 QPainter 全权画三角函数频谱、Waterfall 用 QImage 自绘。所以我的选型结论是:你项目的核心卖点是频谱细节,就自绘;核心卖点是多通道软解调,那就 QCustomPlot 加一层封装。
2.2 五个必调参数:从 setRenderHint 到 setSampleRate
用 QPainter 画频谱时,有五个参数直接决定显示效果和性能。第一是抗锯齿开关:setRenderHint(QPainter::Antialiasing) 按需打开,缩放时才开,平时关掉,否则实时刷新会压垮 CPU。第二是 setCosmetic(true) 设置画笔,cosmetic pen 不随视图缩放变宽,适合画光标线、中心频点线;若不设置,你放大频谱时那条中心频点线会粗得像根棍子。第三是 viewport 设置,QPainter::setWindow() 和 setViewport() 配合可以做到“逻辑坐标显示频率,物理坐标显示像素”,不改数据只改坐标映射就能完成缩放平移,这是重绘效率的关键。第四是画布尺寸:QPixmap 在创建时就按屏幕物理尺寸匹配,不要每次 resize 就重新分配。第五是 setClipRect(),只在 dirty region 内绘制。你会发现,绘制代码里有一半工作是在防呆和兜底性能。
这几个参数如果你不去动,程序也能跑,但当你把 16 路频谱图一开,CPU 立刻到 100%、界面卡成幻灯片,你就知道参数不是玄学,是刚需。一个示例配置是这样的:QPainter painter(&widget); painter.setRenderHint(QPainter::Antialiasing, false); painter.setPen(QPen(QColor(255, 170, 0), 1, Qt::SolidLine)); painter.setWindow(loFreqMhz, -120, hiFreqMhz - loFreqMhz, 120); 其中 loFreqMhz 到 hiFreqMhz 是显示的频率范围,-120 到 0 是幅度范围(dBm)。如果只是把固定数据画一遍,这个配置就够了;如果要缩放,就改 setWindow 的范围再 update()。
2.3 把数据流与刷新隔离:生产者消费者模式与双缓冲
很多人写实时波形图,把数据接收、FFT、绘制全部塞在 QT 的 GUI 线程里,结果数据稍多就“主线程阻塞、窗口白屏”。标准做法是生产者-消费者模式:硬件数据(如 UDP 包、串口、libiio 回调)在线程 A 被接收,放进 ring buffer;线程 B(或一个 QTimer 驱动的工作线程)从 buffer 取数据做 FFT 并转换成绘图需要的 QVector ;UI 线程只做一件事——在 paintEvent 里把 QVector 画出来。这样三个环节各守一段,天然分离。
QT 里实现这个分离有两个注意点:一是 QVector 跨线程传递时不要直接传引用,要用 QSharedPointer 或深拷贝,避免一方在写、另一方在画时崩溃;二是触发重绘用 QTimer 的 setInterval(30) 或 33ms(约 30FPS),在超时信号里对 widget 调用 update(),而不是每收到一包数据就更新,因为数据包频率可能高达 2kHz,而屏幕根本画不了那么快。其实代码结构很固定:数据接收线程 emit 一个新数据信号,绘图线程里用 QueuedConnection 连接,把数据拷贝到“待绘制缓存”,再更新一个标志位;paintEvent 只读缓存。如果你用的是 QCustomPlot,那就把数据传给 QCPGraph 的 setData(),然后 replot()。当数据量超过一个阈值,把所有数据拼接成一个 QVector 再 setData,避免一千次小量 setData 调用拖垮绘制。
3. 频谱图实现:从 FFT 到频谱曲线绘制的完整链路
3.1 FFT 长度、窗函数与分辨率的关系
频谱图的基础是 FFT。无线电信号进来是 IQ 数据流,采样率 sampleRate、FFT 长度 fftSize 和频谱分辨率之间的关系是教科书级的,但落地时很容易出问题:FFT 的频点间隔是 sampleRate / fftSize,它决定了你能否分辨相邻的两个信号;如果你要显示 2.4GHz 到 2.5GHz 上百兆带宽,而 FFT 长度只有 1024,那每个频点间隔约 100kHz,窄带信号直接消失。所以第一步是算出需要的 fftSize,我一般直接用 2 的幂次,从 1024 到 16384 可选,给用户一个下拉框。
窗函数的选择更关键。矩形窗频率泄漏最大,但幅度最精确;Blackman-Harris 窗主瓣宽但旁瓣抑制好;平坦窗(FlatTop)适合幅度校准但主瓣特别宽。实际做法是:工程频谱分析默认用 Blackman-Harris 4 阶;如果是要找弱信号,就用 Kaiser 窗可调 beta 参数。这段逻辑应该在代码里留成常量或下拉选项,不要写死。FFT 的库用 FFTW 或 KISS FFT 都行,但 QT 里注意别在 GUI 线程里执行 FFT——即使 FFT 只要 1ms,但 60 帧时每帧都算,1ms 也已经占掉 6% 的 CPU 时间。建议在消费者线程里做完 FFT,直接把频域结果传给界面。
3.2 用 QPainter 绘制多条频谱曲线的实现代码
假设你要画一个频谱 widget,支持多条曲线叠加(比如参考频谱、实时频谱、峰值保持)。直接继承 QWidget 重写 paintEvent,代码结构按下面这个模板走:
void SpectrumWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); // 实时刷新关抗锯齿 // 1. 绘制网格背景(只在 resize 时清理一次) drawGrid(&painter); // 2. 设置逻辑坐标:X 轴为频率 MHz,Y 轴为幅度 dBm double freqStart = m_centerFreq - m_span / 2.0; double freqEnd = m_centerFreq + m_span / 2.0; painter.setWindow(freqStart, m_ampMax, freqEnd - freqStart, m_ampMax - m_ampMin); // 3. 绘制频谱折线:m_fftData 为浮点数组,长度与屏幕宽度一致(非 FFT 点数) QVector<QPointF> points; points.reserve(m_fftData.size()); for (int i = 0; i < m_fftData.size(); ++i) { double freq = freqStart + (freqEnd - freqStart) * i / (m_fftData.size() - 1); points.append(QPointF(freq, m_fftData.at(i))); } // 4. 峰值保持曲线(灰色虚线) painter.setPen(QPen(QColor(180, 180, 180, 120), 1, Qt::DashLine)); painter.drawPolyline(m_peakHoldPoints.constData(), m_peakHoldPoints.size()); // 5. 实时频谱(亮黄色实线) painter.setPen(QPen(QColor(255, 200, 0, 220), 1.2, Qt::SolidLine)); painter.drawPolyline(points.constData(), points.size()); // 6. 中心频率光标线 painter.setPen(QPen(QColor(255, 255, 255, 100), 1, Qt::SolidLine)); painter.drawLine(QPointF(m_centerFreq, m_ampMin), QPointF(m_centerFreq, m_ampMax)); }代码逻辑分四层:先画网格建立空间感,再设逻辑坐标让后续数值与 GUI 事件(坐标转换、鼠标拾取)对齐,然后按后画的在上层原则叠加曲线和光标线。这里的核心技巧是 m_fftData 的大小不要等于 FFT 点数,而是等于画布宽度(比如 1000 像素宽就存 1000 个点),在数据线程里把 FFT 结果按像素桶取最大值(不是平均值——峰值保留才能看到瞬态信号),这样 drawPolyline 的开销恒定,跟 FFT 长度无关。显示 16 条曲线时,这个“像素桶取 max”的策略能省掉大量的重复绘制计算。
3.3 频谱缩放的三个交互姿势:鼠标滚轮、拖拽平移、峰值保持
频谱缩放看起来很爽,但实现不能拍脑袋。常见做法是这样:鼠标滚轮缩放以鼠标位置为中心。先拿到鼠标在 widget 上的坐标,然后转换成当前频率值(因为设置了 setWindow,直接用映射关系:freq = windowLeft + (mouseX / width) * windowWidth),缩放的逻辑是把 span 乘以一个 factor(滚轮上 0.8,滚轮下 1.25),然后重新计算 centerFreq 保持鼠标指向的频率不动。拖拽平移更简单,鼠标按下时记住旧的 centerFreq,鼠标移动时根据水平位移换算频率增量,更新 centerFreq 后 update()。峰值保持是频谱仪刚需,做法是维护一个 m_peakArray,大小跟像素宽度一样,每次新数据画完曲线后,把这个像素点上的值跟旧峰值比,取最大值存入;长按“清除”按钮时把整个数组清零重来。
这里有个易踩的坑:缩放和平移改的是 setWindow 参数,而不是改数据,但如果你同时用了 QCustomPlot,它的缩放逻辑用的是 range 的缩放,对 setWindow 不生效。所以代码里要么坚持以 setWindow 实现,要么让 QCustomPlot 的 QCPAxisRect 接管交互。我的经验是:如果是频谱图,自绘 setWindow 灵活得多;如果要加坐标轴刻度自适应,QCustomPlot 的 QCPAxis 确实省事。两套方案不混用,选一套走下去。
4. 瀑布图绘制:像素滚动与颜色映射的两种高效方案
4.1 一张 QImage 实现时间轴滚动的原理
瀑布图本质上是把时间维度的频谱颜色化堆叠:横轴频率,纵轴时间(越靠下越老),颜色的明暗表示该频率点的信号强度。最省内存的实现就是一张纵向的 QImage:每来一帧频谱数据,就把整张图向上滚动一像素行,或者向左滚动一像素列(取决于瀑布图布局是横滚还是竖滚),然后把新数据映射成一条颜色线画到边缘。
具体做法是:创建一张 width(等于频谱数据的像素桶数量)x height(可配置,比如 512 行)的 QImage,格式用 QImage::Format_RGB32。新数据到来时,先把 image 整体向下移动一行:image.scroll(0, 1, image.rect()),注意 scroll 方向取决于你希望新数据在上边还是下边,习惯上是新数据在顶部,旧数据往下走。然后通过image.setPixelColor(x, 0, color)或逐行写入像素把新频谱画到最上边。这种实现方式的效率极高,因为 scroll() 只是内存拷贝,不涉及任何绘制计算。如果你把 QImage 直接嵌入 QWidget,在 paintEvent 里用 painter.drawImage(0, 0, m_waterfallImage) 画出来即可,不需要做二次变换。
4.2 颜色映射的三种模式:线性、对数、航空地图色
颜色映射是瀑布图观感的灵魂。常见做法有三种:线性映射就是把频谱幅度线性映射到 0~255 的灰度,适合范围小、噪声底的数据;对数映射是 20*log10(value) 线性映射,适合动态范围极大的射频信号——同样一个屏幕上要看 -120dBm 底噪和一个 -30dBm 强信号,线性映射会把底噪压成全黑,对数映射能同时看到两者的细节;第三种是伪彩色(比如类似航空地图的地形色),用查表法把幅度映射成彩虹色或热力图色。QT 里做颜色映射最坑的是 setPixelColor 循环太慢,你要用reinterpret_cast<QRgb*>(scanLine(y))来直接写像素,而不是调用慢速的 setPixelColor。一次映射要做 1000 次像素写入,scanLine 版本只需一次编译优化就能跑满,差异肉眼可见。
瀑布图还有一个“行列方向”的选择逻辑。频谱仪显示器里瀑布图多是横向滚动:最新数据在右侧,旧数据向左滚;Web 频谱图(比如 WebSDR)多是垂直滚动:最新数据在底部,旧数据向上滚。和 UI 布局匹配即可,但你要从第一天就定好,因为 QImage 的 scroll() 方向和像素写入方向都跟这个强相关,中途改方向极易把数据镜像或滚错方向。附一个颜色映射查表的最小示例:
QRgb colorTable[1024]; // 预计算 1024 级颜色 for (int i = 0; i < 1024; ++i) { colorTable[i] = qRgb(i * 255 / 1024, 0, 255 - i * 255 / 1024); } // 写入像素时,按幅度索引查表 QRgb* line = reinterpret_cast<QRgb*>(m_waterfallImage.scanLine(0)); for (int x = 0; x < width; ++x) { int idx = (m_fftData.at(x) + m_ampMin) / (m_ampMax - m_ampMin) * 1023; line[x] = colorTable[qBound(0, idx, 1023)]; }这里的参数注意 qBound 一定要做,因为 IQ 数据偶尔会出现突刺导致越界,不夹紧的话绘制代码会直接崩溃——这个问题尤其在你改幅度范围时突然爆发。
4.3 瀑布图内存边界:长度、波段切换与重新初始化策略
瀑布图的滚动缓存到底该存多长?我的建议是:默认 512 行到 1024 行,不要超过 2048 行,因为 4K 分辨率的瀑布图 1024 行已经是 400 万像素,加上主体频谱绘制,内存占用已经不小。内存可控之外还要处理两个问题:一是波段切换时,只有当前频谱有意义,历史数据全部作废,这时候不要试图“平滑滚动清空”,直接 resize 或 fill 成全黑归零;二是长跑几个小时,底噪的值可能漂移(温度、AGC 变化),如果颜色映射是固定范围,那么瀑布图会慢慢变成全黑或全白。基本应对方案是周期性地按底噪中位数自适应调整映射范围:每 60 秒在后台线程计算底噪统计值,然后更新颜色表索引偏移量,避免整个图失去对比度。自适应颜色映射不能每帧做,否则强信号会频繁压扁底噪。
5. 波形图与帧同步避坑:实时波形显示最容易翻车的 4 个地方
5.1 现象一:帧率上不去,CPU 被 drawLine 循环吃光
这是“实时波形显示”最常见的问题,现象是程序能跑但 CPU 占用极高,尤其是把一条 1M 点的波形全用 for 循环 drawLine。原因有三个层面:图层没有按需重绘;drawLine 被调用的次数远大于屏幕像素数;抗锯齿开启了过大的开销。解决方案是强制把数据降采样到屏幕宽度:比如屏幕宽 1920,那不管底层数据是 10000 点还是 100M 点,只取 1920 个点来画,具体取法是“每桶取最大最小值连成竖线”,这样既不会漏掉毛刺,又能让点数恒定。很多新手一开始就踩这个,然后去调代码用 QPainter::drawPolyline——其实降采样没做对,用什么 API 都没救。
5.2 现象二:波形拖尾重影,刷新后残留旧曲线
这算 QT 绘图的经典问题。如果你没有在 paintEvent 里先填充背景(比如黑色),旧帧的绘制结果会跟新帧叠加,波形越来越花,最后全屏都是线条,看起来像是信号特别密集,其实是重影。这个现象在自绘 QWidget 里最典型。解决就是 paintEvent 第一步调用painter.fillRect(rect(), QColor(10, 12, 16))清空画布,或者利用setAutoFillBackground(true)让 QWidget 自动填充 QPalette 的背景色。如果你是做频谱和瀑布图混合界面,给瀑布图 widget 设一个独立的背景色,别让它继承频谱的背景,否则内容全糊在一起。重影不是玄学,是绝大多数人第一次自绘时忘记清屏。
5.3 现象三:跨线程 setData 导致崩溃
QCustomPlot 的 setData 不是线程安全的。当你在数据接收线程里直接调用 curve->setData 并 replot,而 GUI 线程同时在处理重绘,崩溃就是概率事件。现象的典型表现是:程序正常运行几秒到几分钟,随机崩溃在 QCPGraph::draw 或者 QCPAxis::coordToPixel 中,而且崩溃时间不可预测。原因是两个线程同时访问同一个 QCPGraph 对象,QT 的事件循环根本没有保护这个内部数据结构。解决方法是:数据线程只往环形缓冲区写数据,通过信号槽触发 GUI 线程里的一个槽函数,在槽函数里 setData 并 replot。连接方式需要选 Qt::QueuedConnection,不能是默认的 AutoConnection——因为 AutoConnection 在跨线程时会自动转成队列连接,但如果你把数据对象用指针传递,队列连接在事件循环尚未处理时可能已经被释放。稳妥的做法是:数据拷贝成 QVector 后通过信号传递,槽函数里用 moveToThread 或者直接接收,务必确认这个槽函数执行时没有任何其他线程持有同一份数据的写权限。
5.4 现象四:波形图 y 轴范围固定,信号一进来就削顶
这个现象发生频率极高:默认的 y 轴范围是 -1 到 1(IQ 数据归一化),结果信号幅度是 ±0.99,画出来基本看不见细节;或者信号幅度是 ±5,画出来直接被截断。你不会想手动去改每一个数据源的 scale。一个常见的解决方案是引入“自动标度”:每次数据进来后,对当前缓冲区的最大值、最小值做指数移动平均,y 轴范围随信号动态调整。代码大概是这样:m_ampScale = qMax(m_ampScale * 0.95, maxAbsValue * 0.9);,这样信号小的时候 y 轴自动收缩,信号变大的时候 y 轴快速展开,不会因为一两帧的尖峰导致界面剧烈跳动。自动标度有两种模式:一是只扩大不缩小(适合看频谱峰值保持),二是双向自适应(适合音频或解调后的波形)。参数要区分开,否则用户会被自动缩放的界面搞到晕车。
6. 进阶:把三个图整合进一个 SDR 控制面板的布局策略
当频谱图、瀑布图、波形图都要显示时,最容易遇到的坑是“QWidget 嵌套太多导致刷新效率指数级下降”。如果你的界面结构是一个 QMainWindow 里嵌了 QSplitter,再嵌 QTabWidget,再嵌三个自定义 QWidget,那么任何一个控件的 update() 都可能触发整条父子链的部分重绘,并且在 QSplitter 拖动时所有子控件同时在重绘,帧率直接崩盘。我的经验做法有二:一是只在 QWidget 的 paintEvent 里重绘自己,绝不调用 parentWidget()->update(),也不在 paintEvent 里做复杂的 layout 计算;二是用 QTimer 统一以 30FPS 驱动三个图表的 update,而不是三个图各用各的定时器——同步刷新视觉上更顺滑,而且能避免三个定时器错峰触发产生的撕裂感。定时器槽函数里顺手检查一下当前 tab 是否可见,不可见就不 update,能省下不少无效重绘。
另一个进阶技巧是“简易频谱仪模式”:把鼠标悬停在频谱图上时,显示一个十字光标和对应的频率/幅度读数;鼠标拖拽选中一片区域,松开后自动把主频谱缩放到选中区域。这套交互在自绘方案里其实不难:在 QWidget 的 mouseMoveEvent 里把鼠标坐标映射成频率值,在 paintEvent 里把画好的十字光标和文字画上去。需要留意的是光标读数要画在图上而不是图外,坐标要用 painter 的 transform 映射好,否则鼠标指向和光标位置会偏。这个功能加到界面里之后,操作人员可以直接在软件里找干扰信号,不需要额外一台频谱仪。
最后说说帧率验证。我在交付 SDR 上位机时会加一个隐藏的 FPS 计数器:在 paintEvent 里递增帧数,每秒由 QTimer 读一次并显示在窗口标题栏或状态栏。验证标准是三个图同时刷新时 CPU 不超过一个核的 60%,帧率稳定在 25 以上。如果帧率不够,优先检查是不是开了抗锯齿,或者数据线程在 GUI 线程里做了 FFT。我一般会在接收线程跟绘图线程之间加一个“丢弃旧数据”策略:当绘图线程来不及处理时,直接把最早的半帧数据丢弃,保住实时性优先,这种体验远好于累积延迟导致界面一直显示“过去的频谱”。这个习惯帮我少调试了很多“为什么界面越来越卡”的问题,希望帮到你。
本文还有配套的精品资源,点击获取