简介:面向 Qt 开发者的 QWT 绘图实战资源,围绕心电图(ECG)、脉氧饱和度波形图等动态波形显示,讲解从 QwtPlot 组件搭建、数据曲线绑定到实时刷新与滚动缩放的关键技术,适合需要构建医疗监测或科学绘图界面的中高级开发者参考。包体共 52 个文件,压缩后 11.19MB,包含完整 Qt 工程源码(.pro/.sln/.vcxproj)、核心头文件与实现文件(.h/.cpp)、界面文件(.ui)及运行所需 Qt/QWT 动态库(.dll)和可执行文件(.exe),可直接打开工程编译学习,也可运行演示程序观察效果。资源目前已有 3383 人学习,内容覆盖采样线程、曲线数据管理、波形控件封装、鼠标缩放和平移等模块。借助配套工程配置与运行环境文件,能够快速复现心电、脉氧波形图的绘制流程,并进一步扩展实时数据处理、坐标轴限制与界面定制,对医疗信息化、便携式监护设备开发者尤其有参考价值。
1. 用 QWT 画心电图和脉氧波形,先过选型这道坎
做实时监护界面的人,迟早都会撞上同一个需求:QT 程序里要画心电图、脉氧饱和度波形图,波形得从左往右滚动,不能闪,不能越跑越卡,最好还能随手缩放看细节。自己用paintEvent一条线一条线画,工作量其实不大,但网格、坐标轴刻度、缩放、游标这些配套功能加起来,就变成了一笔收不了尾的债。QWT 恰好是专门干这个的:它基于 Qt,把曲线、坐标轴、网格、缩放、标记都封装好了,波形绘制这件事在工程上就退化成了“喂数据”和“定刷新节奏”两件事。这篇笔记面向的是要动手做监护波形界面的开发者,讲的是 QWT 从集成到双通道波形落地、再到压测排错的完整路径。
2. 把 QWT 装进 Qt 工程:编译选择、pro 配置与第一个能跑的曲线窗口
2.1 为什么是 QWT:它把波形绘制的脏活都干完了
先回答一个绕不开的问题:Qt 自带 Qt Charts,为什么还要单独引入 QWT?我个人的判断标准是看用途。Qt Charts 的优势是图表样式现代、动画顺手,适合做统计图、仪表盘;但心电波形这东西强调的不是好看,而是刷新效率和交互精度——要能在几百赫兹的采样率下持续写入数据、平滑滚动、自由缩放、拖动游标测量。QWT 在这类场景下的积累比 Qt Charts 厚实得多,尤其是QwtPlotCurve的原始数据接口和QwtPlotPicker的取点交互,做实时波形几乎是照着需求设计的。
另一个现实原因是可维护性。自绘方案在波形形态简单时很轻松,一旦叠加导联切换、基线漂移、阈值线、多通道时间对齐,代码量会成倍膨胀,而且每个新人都要把绘图逻辑重新读一遍。QWT 把这些职责拆成了 curve、scale、grid、marker、picker 这些独立对象,代码结构是现成的,后来者接手成本低。适合用它的人是:已经在用 Qt 做桌面程序、需要把真实或模拟的采样数据以波形形式稳定呈现、且后续大概率要加交互功能的开发者。
2.2 拿到 QWT 源码后怎么编:MinGW 与 MSVC 的取舍
QWT 不是装个包就能用,最常见的做法是下载源码包自行编译,用 qmake 构建。这里第一个坑就是工具链必须和你编译 Qt 程序时用的工具链一致:MinGW 版 Qt 就配 MinGW 版 QWT,MSVC 版 Qt 就配 MSVC 版 QWT。混用会出现符号不匹配或者运行时加载不上 dll 的诡异问题,而且这类报错往往不在链接期,而在程序启动时,排查起来很费时间。
Windows 下 MinGW 的编译过程大致是这样:打开 Qt 自带的 MinGW 命令行环境,进入 QWT 源码目录,依次执行:
qmake qwt.pro mingw32-make -j4 mingw32-make installqmake会读取qwt.pro,生成对应编译器能用的 Makefile;mingw32-make -j4用 4 个任务并行编译,能省不少时间;install把头文件、库文件和插件装到 Qt 目录下,我习惯不装到 Qt 目录,而是装到项目自己的third_party/qwt下,这样换 Qt 版本时不会被旧库干扰。-j4按 CPU 核数调整,核少就改成-j2,避免编译时机器卡死。
MSVC 环境下把mingw32-make换成nmake即可,其余步骤一致。编译前先看一眼qwt.pro里的版本号,这会决定你 pro 文件里引用的库名后缀,QWT 的 release 库通常不带后缀,debug 库带d。
2.3 集成进 pro 文件并跑通最小绘图
编译完之后,工程集成就是三件事:头文件路径、库路径、链接库名。以我常用的目录结构为例,pro 文件里这样写:
INCLUDEPATH += $$PWD/../third_party/qwt/include LIBS += -L$$PWD/../third_party/qwt/lib -lqwt$$PWD指向当前 pro 文件所在目录,用相对路径把第三方库固定在项目目录内,换机器不用改绝对路径。-lqwt在 Windows 上会去lib目录找qwt.dll对应的导入库,如果你编译的是 debug 版,这里要换成-lqwtd。运行时别忘了把qwt.dll放到 exe 同目录,或者把库目录加进环境变量,否则程序起来后会直接报“找不到 qwt.dll”,这个错误和链接阶段毫无关系,容易让人误判成代码问题。
集成完,先跑一个最小例子验证环境,再往后写业务逻辑。下面的代码不依赖任何外部数据,直接生成一组正弦波画出来:
#include <QApplication> #include <qwt_plot.h> #include <qwt_plot_curve.h> #include <cmath> int main(int argc, char *argv[]) { QApplication app(argc, argv); QwtPlot plot; plot.setTitle("QWT ECG Demo"); plot.setAxisScale(QwtPlot::xBottom, 0, 1000, 200); plot.setAxisScale(QwtPlot::yLeft, -1.5, 1.5, 0.5); QwtPlotCurve curve("ECG"); QVector<double> x(1000), y(1000); for (int i = 0; i < 1000; ++i) { x[i] = i; y[i] = 0.8 * std::sin(2.0 * M_PI * i / 200.0); } curve.setSamples(x, y); curve.attach(&plot); plot.resize(800, 300); plot.show(); return app.exec(); }setAxisScale的四个参数分别是坐标轴、最小值、最大值、主刻度步长,先把显示范围固定住,避免自动缩放被后续的边界数据带跑。setSamples会拷贝数据,验证阶段用它最稳妥。attach(&plot)把曲线挂到 plot 上,一条曲线只能挂一个 plot,重复 attach 会导致之前的关联失效。能看到一条正弦波滚动起来,说明 QWT 环境已经通了,接下来就可以进入实时刷新模型的设计。
3. 实时波形刷新模型:环形缓冲、定时器节拍与 setRawSamples 的指针生命周期
3.1 波形数据从哪来:模拟信号与真实采样点的组织方式
实时波形图的数据来源一般分两类:真实硬件采集和软件模拟。硬件设备通过串口或网络把采样点推给界面程序,采样率从 125Hz 到 500Hz 不等;模拟则是在程序内部按固定频率合成波形,用于开发调试和界面演示。两者的区别在数据到达方式上:硬件是异步不定时到达,模拟是按定时器均匀产生。但无论哪种来源,界面侧统一抽象成“一个带时间戳的采样点”最省事。
我一般会定义一个轻量的环形缓冲来承接采样点,而不是直接用 QVector 存所有历史数据。原因有两个:一是内存占用固定,不会因为程序跑了一晚上就线性增长;二是覆盖式写入天然符合“滚动窗口”的语义——界面上永远只显示最近几秒的数据,老数据被新数据顶掉是符合监护场景预期的:
class SampleBuffer { public: explicit SampleBuffer(int capacity) : cap(capacity) { data.resize(capacity); } void push(double value) { data[head] = value; head = (head + 1) % cap; if (count < cap) { ++count; } } QVector<double> toVector(int start, int length) const { QVector<double> out(length); for (int i = 0; i < length; ++i) { out[i] = data[(start + i) % cap]; } return out; } private: QVector<double> data; int head = 0; int count = 0; int cap = 0; };push用取模回绕实现环形写入,toVector按指定起点导出连续片段。注意head指向的是下一个写入位置,导出时要从想要的起点按序取模读取。容量按“采样率 × 显示窗口秒数”来定,比如 250Hz、显示 8 秒,容量就是 2000,多留 10% 余量比较稳妥。真实项目里,如果还要做存储和回放,环形缓冲可以换成带索引的双端队列,但实时显示场景下环形缓冲够用且可控。
3.2 滚动显示与压缩显示:两种时间轴策略的取舍
波形图的时间轴有两种典型行为,很多人做混了。一种是“滚动显示”:x 轴范围固定,比如始终显示最近 4 秒,新数据从右侧挤入,老数据从左侧消失,这是监护仪上心电图的标准效果。另一种是“压缩显示”:x 轴范围随时间整体右移,图像被横向压缩,适合看长趋势。同一个 QWT 界面里,这两种策略对应不同的 x 轴设置方式。
滚动显示的常见做法是固定 x 轴范围,比如setAxisScale(QwtPlot::xBottom, 0, 4000, 1000),数据写入时坐标也在 [0, 4000] 区间内按时间戳放置。这样 curve 不需要移动,QWT 每次重绘自然画出最新窗口。压缩显示则要每帧更新 x 轴上限:
double windowMs = 4000.0; double nowMs = latestTimeMs; plot->setAxisScale(QwtPlot::xBottom, nowMs - windowMs, nowMs, windowMs / 4);这段代码里nowMs是当前最新采样点的相对毫秒时间,windowMs是显示窗口长度。关键参数是最后一个windowMs / 4,它决定 x 轴主刻度数量,4 个格子是波形图的常见密度,刻度太密文字会重叠,太疏又看不出时间分辨率。滚动和压缩两种模式可以做成运行时切换,切换时只需要重新设置 x 轴范围,曲线数据不用动,这是 QWT 在这类需求上特别顺手的地方。
3.3 刷新节拍与 setRawSamples:不建议每条数据都 replot
新手最容易犯的错是“来一个点就 replot 一次”。在 250Hz 采样率下,这意味着每秒触发 250 次重绘,CPU 占用飙升,界面还可能闪烁。正确的做法是让数据和绘制解耦:数据按采样率持续写入环形缓冲,但界面用一个独立的定时器,以 30~50ms 为周期统一刷新一次。这样每秒重绘 20~33 次,人眼已经觉得流畅,CPU 负担却小一个量级。
刷新时的关键接口是setRawSamples,它和setSamples的差别在于:setSamples会拷贝一份数据,setRawSamples不拷贝,直接持有你传入的指针。这意味着传入的数组在你下一次调用之前必须一直存活、长度必须真实有效。利用这个特性,可以避免每帧都产生内存拷贝:
void refreshWaveform() { int n = buffer.size(); double t0 = currentTimeMs - windowMs; for (int i = 0; i < n; ++i) { xData[i] = t0 + i * sampleIntervalMs; yData[i] = buffer.at(i); } curve->setRawSamples(xData.constData(), yData.constData(), n); plot->replot(); }xData和yData是成员变量,提前resize好最大容量,每帧只填数不改大小,避免反复分配内存。setRawSamples之后必须调用replot(),因为 QWT 不会自动重绘;而replot()的代价主要花在坐标轴和曲线的重算上,数据量从几百到几千点时差别不大。这里最容易埋雷的是xData和yData的生命周期——如果它们被声明成函数内的局部变量,setRawSamples执行完函数返回后指针就悬空了,下次replot画出来的就是野指针数据,表现是波形随机跳变甚至崩溃。血泪教训:这个数组必须活得比 curve 更久。
4. 心电图与脉氧同屏绘制:双 QwtPlot 布局、时间对齐和实时数值
4.1 双通道布局:两个 QwtPlot 各自独立还是共用坐标轴
心电和脉氧饱和度波形通常共用一个界面,上下排列。这里说的“共用坐标轴”有两层含义:一是视觉上两条曲线是否画在同一个坐标系里,二是两个绘图控件的 x 轴范围是否同步。我的建议是物理上用两个QwtPlot控件,各自管理自己的曲线和 y 轴范围,但 x 轴范围保持同步。原因是心电幅度在毫伏级,脉氧波形是光电容积脉搏波,两者的量纲和基线完全不同,硬塞进同一个坐标系会让其中一个被压成一条直线,没有任何工程意义。
布局上用QVBoxLayout垂直排列,上面的 plot 放心电图,下面的放脉氧波形,中间留少量间距。一个值得注意的细节是下面这个 plot 的 x 轴刻度可以隐藏只留网格线,因为两条曲线完全同步,时间轴只看一条就够了;隐藏方法是对下侧 plot 调用axisScaleEngine相关接口设置刻度,或者直接把 x 轴标签的显隐关掉。对应代码:
plotSpO2->setAxisVisible(QwtPlot::xBottom, false);setAxisVisible只控制显示,不影响数据映射和范围设置,两条曲线的 x 轴逻辑保持同步,视觉上却更干净,接近监护仪的实际布局。
4.2 采样率不同怎么对齐:用相对毫秒时间戳而不是采样序号
心电采样率常见是 250Hz 或 500Hz,脉氧波形通常只有 50~100Hz,甚至部分设备是 60Hz。如果直接把两个数据流按“第几个采样点”作为 x 坐标,时间对齐会完全错乱,因为同一时刻两者的 sample index 差异巨大。正确做法是每个采样点都带相对时间戳,x 轴数据用毫秒表示,这样两条曲线在时间上是绝对对齐的:
double ecgTimeMs = ecgSampleIndex * (1000.0 / ecgSampleRate); double spo2TimeMs = spo2SampleIndex * (1000.0 / spo2SampleRate);ecgSampleIndex和spo2SampleIndex分别是两条数据流各自的累加计数,乘以采样间隔就得到相对毫秒时间。这里有个隐蔽问题:两路数据的起始时间必须一致,否则显示上会有一个固定的相位偏移,看起来像脉氧波形“慢半拍”。处理方式是建立统一的时间基准——界面启动时记录QDateTime::currentMSecsSinceEpoch()作为零点,每个采样点的毫秒时间戳都减去这个零点。这样即便两路数据开始推送的时机略有先后,显示窗口内也会自动对齐到同一条时间轴上。
4.3 QwtPlotMarker 画基线、阈值线和实时数值
波形画好只是第一步,监护界面上还要有基线和实时数值。QWT 里画横竖线用QwtPlotMarker,它不是曲线,而是一条带位置的标记线,最适合做零基线和报警阈值线。零基线放在心电图的 y=0 位置,阈值线则按需放在某个幅度值上。标记线的样式可以独立设置,线型和颜色与波形区分开:
QwtPlotMarker *baseLine = new QwtPlotMarker(); baseLine->setLineStyle(QwtPlotMarker::HLine); baseLine->setYValue(0.0); baseLine->setLinePen(QPen(Qt::gray, 1, Qt::DashLine)); baseLine->attach(plotEcg);setLineStyle(QwtPlotMarker::HLine)指定这是一条水平线,setYValue(0.0)决定它的垂直位置,setLinePen控制线型。基线本质上是一个辅助视觉元素,不是波形数据的一部分,所以用 Marker 而不是往曲线数据里塞一个恒定值——后者会干扰 y 轴的自动缩放。
实时数值(心率、血氧百分比)不建议用QwtPlotTextLabel频繁更新,因为文本标签的每次更新都要重建绘制对象,刷新一多开销就上去了。更常见的做法是在 plot 旁边叠加QLabel,用布局把 label 固定在右上角,数据刷新时直接setText。QLabel 的更新成本远低于 Qwt 的文本标签对象,而且样式可控性强。数值更新频率也不用和波形刷新一致,1 秒更新一次足够,监护场景下数值跳太快反而干扰观察。
5. QWT 波形绘制避坑指南:5 个翻车点与排查路径
5.1 波形刷新时闪烁或撕裂
现象:波形滚动时能看到明显的闪烁,或者曲线在刷新瞬间出现横向撕裂。原因通常是刷新频率过高、刷新和绘制撞在同一帧,或者代码里对绘图控件设置了不合适的属性。QWT 本身支持双缓冲,但如果你的刷新节拍过快——比如采样回调里直接 replot——双缓冲也来不及。解决思路是降频:统一走定时器刷新,把 replot 频率控制在 30fps 以内;另外检查主窗口是否设置了Qt::WA_OpaquePaintEvent,这类属性在某些平台上会破坏 QWT 的缓冲逻辑。真遇到调不动的情况,先把背景色设为固定色并调用setAutoFillBackground(true),能消除大部分闪烁。
5.2 程序退出崩溃在 Qwt 内部
现象:窗口关闭时程序崩溃,调用栈指向 Qwt 的绘制函数或析构函数。原因大多数不是 QWT 自己的问题,而是对象析构顺序不对。如果QwtPlotCurve作为成员变量声明在数据缓冲之前,那么析构时曲线会先于数据被销毁,而 curve 在析构时可能还要访问数据;更常见的是setRawSamples传入了局部数组,曲线持有悬垂指针。解决路径有两条:一是成员声明顺序让缓冲先于 curve 销毁——C++ 成员按声明逆序析构,所以把QVector成员声明在 curve 之前;二是退出前显式调用curve->detach(),让曲线先从 plot 上摘下来,再销毁数据容器。养成这个习惯之后,这类崩溃基本绝迹。
5.3 曲线不显示,replot 还在跑
现象:程序正常运行,replot 一直在调用,但画布上什么都没有。原因排查按三个方向走:曲线是否 attach 到了正确的 plot、数据是否全为同一值、y 轴范围是否合理。前两个好查,最隐蔽的是第三个——当波形数据在某段时间内全是 0,QWT 自动缩放会把 y 轴范围压缩成 [0,0],Qwt 的刻度引擎处理零高度区间时会退化,曲线就“消失”了。解决方法是给 y 轴设置一个最小安全范围,比如心电图固定为-1.5 到 1.5,脉氧固定为0 到 100,或者检测到数据恒定时强制给范围加一个 padding,不要让区间高度变成 0。
5.4 缩放后坐标轴刻度密度失控
现象:用滚轮缩放波形后,x 轴刻度文字密密麻麻叠在一起,或者反过来一个刻度都没有。原因是没有给坐标轴设置合理的刻度步长策略,QWT 在自由缩放模式下会动态计算刻度,但计算结果在实时波形场景下经常不符合预期。解决方法是锁干净刻度:在缩放事件里重新调用setAxisScale,明确指定当前时间窗口下的主刻度步长:
void onZoomed() { double step = 500.0; // 缩放后主刻度 500ms 一格 QwtScaleDiv div = plot->axisScaleDiv(QwtPlot::xBottom); plot->setAxisScale(QwtPlot::xBottom, div.lowerBound(), div.upperBound(), step); }axisScaleDiv取到当前的显示边界,setAxisScale第四个参数指定主刻度步长,强制让刻度在缩放后保持稳定。步长选择按窗口大小动态计算:窗口 4 秒时用 500ms,20 秒时用 2 秒,做成一个函数按窗口宽度映射,效果最自然。
5.5 内存随运行时间持续上涨
现象:程序跑几小时后内存明显增长,心电波形刷新正常但资源占用 steadily 爬升。原因绝大多数不是 QWT 泄漏,而是数据准备环节:每帧都重新resize一个 QVector 传给setSamples,或者在setRawSamples传入了临时数组导致 QWT 持有悬垂指针的同时,临时数组又反复分配释放。解决方法是把 x/y 数据缓冲变成成员变量,启动时reserve足量容量,刷新时填充数据但不改变数组大小;同时尽量避免在刷新路径里使用任何new。如果内存还在涨,下一步检查定时器是否被重复启动——start()不会自动去重,多个定时器实例叠加会让刷新频率随时间翻倍,内存和 CPU 一起涨,这种问题用 Qt 自带的性能工具一看便知。
6. 进阶验证:用模拟数据源压测刷新性能,和我的一个收尾习惯
6.1 把模拟心电数据源做成独立组件
调试波形界面时不能依赖真实硬件,否则你永远分不清是设备数据问题还是显示问题。我的做法是把模拟数据源做成独立类,接口只有两个:start()和nextSample()。nextSample()每次返回一个双精度值,内部用谐波叠加模拟出类似 QRS 主波的形态,采样率可配置:
double SimEcgSource::nextSample() { double t = sampleCount * (1000.0 / sampleRate); double phase = std::fmod(t / 1000.0, 1.0); double v = 0.8 * std::exp(-std::pow((phase - 0.18) / 0.02, 2)); v -= 0.3 * std::exp(-std::pow((phase - 0.08) / 0.03, 2)); v -= 0.4 * std::exp(-std::pow((phase - 0.30) / 0.05, 2)); ++sampleCount; return v; }这段代码用三个高斯峰拼出一个周期波形,phase是当前心跳周期内的相位,0.18附近的高斯峰模拟 R 波,0.08和0.30附近的小峰模拟 P 波和 T 波区域。它只用于界面调试,不是生理信号模型,真机接入时直接替换这个类的内部实现即可,界面代码不用动。
6.2 用帧率和 CPU 占用来判断要不要降频
刷新定时器设成多少毫秒不能拍脑袋,要用数据说话。我常用的验证方法是统计实际刷新帧率,对比设定值,差距过大说明绘制开销超标:
void MainWindow::onTick() { ++frameCount; if (frameClock.elapsed() >= 1000) { double fps = frameCount * 1000.0 / frameClock.elapsed(); qDebug() << "refresh fps:" << fps; frameCount = 0; frameClock.restart(); } // ... 刷新波形 }frameClock是QElapsedTimer成员,每秒打印一次实际刷新次数。如果设定 30ms 刷新、实际只能跑到 20 帧,就把窗口数据量减少或者把刷新间隔放宽到 40ms。压测时把模拟采样率调到 500Hz、窗口拉到 10 秒,CPU 占用在调试版还能控制在单核 20% 以内,发布版通常更低。
6.3 我的收尾习惯:先停定时器,再清曲线
最后分享一个让我少踩很多次坑的习惯:关闭窗口时,先停掉所有刷新定时器,再清除曲线数据,最后才销毁 plot 对象。顺序反了就会出现“析构到一半定时器又触发一次刷新,访问到半销毁状态的数据”这种玄学崩溃。我一般在closeEvent里固定写三行:refreshTimer.stop()、curve->detach()、buffer.clear()。这套顺序坚持了多个项目,几乎不再碰见退出崩溃。希望帮到你。
本文还有配套的精品资源,点击获取