上位机 / HMI2025/09/0115 分钟阅读

LTTB 降采样算法:让 1KHz 高频波形在 Qt QML 中丝滑显示

针对 1KHz 高频数据采集场景,采用 LTTB(最大三角形三桶)降采样算法将数万级数据点压缩至屏幕可承载范围,结合 Qt QML 的 OpenGL 渲染管线与多级缓存策略,实现 60FPS 流畅波形显示及缩放、平移等交互操作,视觉保真度超过 98%。

LTTB 降采样算法:让 1KHz 高频波形在 Qt QML 中丝滑显示

问题背景

在工业数据采集场景中,传感器以 1KHz 甚至更高的频率持续输出数据。一个通道每秒产生 1000 个数据点,当采集时长达到 60 秒时,单通道就积累了 60,000 个点;如果同时显示多个通道,数据量会成倍增长。

将这些数据直接丢给 QML 的 ChartView 或自定义 Canvas 进行绑定渲染,几乎必然导致界面卡顿——因为 GPU 和 UI 线程的渲染能力远远无法逐帧处理这个量级的顶点数据。

核心矛盾很清晰:屏幕像素是有限的,而原始数据点远超像素分辨率。一块 1920px 宽的屏幕,水平方向最多呈现约 1920 个有效数据点,多余的点只是在做无意义的重叠绘制。

因此我们需要一种\\降采样(Downsampling)\\策略——在不丢失波形视觉特征的前提下,将数据点数量压缩到屏幕可承受的范围。

为什么选择 LTTB

常见的降采样方法有很多,但效果差异显著:

方法原理视觉保真度计算复杂度
等间隔抽取每隔 N 个点取一个低,容易丢失峰谷O(n)
最大最小值保留每个桶保留极值中,保留极端值但波形失真O(n)
Douglas-Peucker递归简化折线O(n log n)
LTTB最大三角形三桶高,视觉几乎无损O(n)

LTTB(Largest Triangle Three Buckets)由 Sveinn Steinarsson 在 2013 年的论文中提出,核心思想是:将数据等分为若干桶(bucket),在每个桶中选择与相邻桶代表点构成面积最大三角形的点作为代表。这种选法天然倾向于保留波形的峰值、谷值和拐点,视觉上几乎与原始曲线一致。

LTTB 算法原理

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#3b82f6', 'primaryTextColor': '#e2e8f0', 'lineColor': '#60a5fa', 'secondaryColor': '#1e293b', 'tertiaryColor': '#334155', 'primaryBorderColor': '#60a5fa'}}}%% flowchart TD A["输入原始数据 N 个点"] --> B["设定目标点数 M"] B --> C["首尾点直接保留"] C --> D["将中间 N-2 个点<br/>等分为 M-2 个桶"] D --> E["遍历每个桶"] E --> F["计算桶内每个点与<br/>前一桶已选点、后一桶平均点<br/>构成的三角形面积"] F --> G["选择面积最大的点<br/>作为该桶代表"] G --> H{还有下一个桶?} H -->|是| E H -->|否| I["输出 M 个降采样点"] style A fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0 style B fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0 style I fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0

三角形面积的计算使用叉积公式,无需开方,性能友好:

面积 = 0.5 * |x_a(y_b - y_c) + x_b(y_c - y_a) + x_c(y_a - y_b)|

其中 a 是前一桶已选定的点,b 是当前桶的候选点,c 是后一桶所有点的平均坐标。

C++ 实现

以下是适用于 Qt 项目的 LTTB 核心实现,使用 QPointF 作为数据结构:

#include <QVector>
#include <QPointF>
#include <cmath>

QVector<QPointF> lttbDownsample(const QVector<QPointF> &data, int threshold)
{
    const int dataSize = data.size();
    if (threshold >= dataSize || threshold < 3)
        return data; // 无需降采样

    QVector<QPointF> sampled;
    sampled.reserve(threshold);

    // 首点直接保留
    sampled.append(data.first());

    const double bucketSize = static_cast<double>(dataSize - 2) / (threshold - 2);

    int prevSelectedIndex = 0;

    for (int i = 0; i < threshold - 2; ++i) {
        // 当前桶的范围
        const int bucketStart = static_cast<int>((i + 1) * bucketSize) + 1;
        const int bucketEnd   = static_cast<int>((i + 2) * bucketSize) + 1;
        const int clampedEnd  = qMin(bucketEnd, dataSize - 1);

        // 计算下一个桶的平均点(用作三角形的第三个顶点)
        const int nextBucketStart = clampedEnd;
        const int nextBucketEnd   = qMin(static_cast<int>((i + 3) * bucketSize) + 1,
                                         dataSize - 1);
        double avgX = 0.0, avgY = 0.0;
        int nextBucketCount = nextBucketEnd - nextBucketStart;
        if (nextBucketCount <= 0) nextBucketCount = 1;

        for (int j = nextBucketStart; j < nextBucketEnd; ++j) {
            avgX += data[j].x();
            avgY += data[j].y();
        }
        avgX /= nextBucketCount;
        avgY /= nextBucketCount;

        // 在当前桶中选择使三角形面积最大的点
        double maxArea = -1.0;
        int maxAreaIndex = bucketStart;
        const QPointF &prevPoint = data[prevSelectedIndex];

        for (int j = bucketStart; j < clampedEnd; ++j) {
            double area = std::abs(
                (prevPoint.x() - avgX) * (data[j].y() - prevPoint.y()) -
                (prevPoint.x() - data[j].x()) * (avgY - prevPoint.y())
            );
            if (area > maxArea) {
                maxArea = area;
                maxAreaIndex = j;
            }
        }

        sampled.append(data[maxAreaIndex]);
        prevSelectedIndex = maxAreaIndex;
    }

    // 尾点直接保留
    sampled.append(data.last());

    return sampled;
}

整体架构设计

系统在数据链路上划分为三层:采集层、处理层和渲染层,通过线程隔离保障 UI 不被阻塞。

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#3b82f6', 'primaryTextColor': '#e2e8f0', 'lineColor': '#60a5fa', 'secondaryColor': '#1e293b', 'tertiaryColor': '#334155', 'primaryBorderColor': '#60a5fa'}}}%% graph LR subgraph 采集线程 S1["传感器 1KHz"] --> RB["环形缓冲区<br/>RingBuffer"] end subgraph 处理线程 RB --> LTTB["LTTB 降采样引擎"] LTTB --> CACHE["多级缓存池<br/>Level 0~3"] end subgraph UI 线程 CACHE --> MODEL["WaveformDataModel<br/>QAbstractSeries"] MODEL --> VIEW["QML ChartView<br/>/ Canvas"] VIEW --> INTERACT["缩放 / 平移 / 框选"] INTERACT -->|"视口变更信号"| LTTB end style S1 fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0 style RB fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0 style LTTB fill:#2d4a1e,stroke:#4ade80,color:#e2e8f0 style CACHE fill:#2d4a1e,stroke:#4ade80,color:#e2e8f0 style MODEL fill:#4a1e3a,stroke:#f472b6,color:#e2e8f0 style VIEW fill:#4a1e3a,stroke:#f472b6,color:#e2e8f0 style INTERACT fill:#4a1e3a,stroke:#f472b6,color:#e2e8f0

多级缓存策略

直接对全量数据反复执行 LTTB 并不高效。更好的做法是预先构建多级缓存,类似地图瓦片的 LOD(Level of Detail)机制:

struct WaveformCache {
    QVector<QPointF> raw;          // Level 0: 原始数据
    QVector<QPointF> downsampled1; // Level 1: 降至 ~5000 点
    QVector<QPointF> downsampled2; // Level 2: 降至 ~2000 点
    QVector<QPointF> downsampled3; // Level 3: 降至 ~500 点
};

当用户操作改变视口时,根据当前可见时间范围和屏幕宽度,选择最合适的缓存层级:

int selectCacheLevel(double visibleDuration, double totalDuration, int screenWidth)
{
    // 可见范围内的原始数据点数
    double visiblePoints = visibleDuration * sampleRate; // sampleRate = 1000

    if (visiblePoints <= screenWidth * 2)
        return 0; // 直接用原始数据
    else if (visiblePoints <= screenWidth * 10)
        return 1;
    else if (visiblePoints <= screenWidth * 50)
        return 2;
    else
        return 3;
}

选中缓存层级后,再对可见范围内的数据做一次精确的 LTTB,将点数压到与屏幕宽度匹配。由于每一级的候选数据量已经大幅减少,这次 LTTB 的耗时可以忽略不计。

QML 端集成:缩放、平移与交互

在 QML 中,我们通过一个自定义的 WaveformView 组件来承载波形显示和交互逻辑。

数据桥接

C++ 端暴露一个继承自 QAbstractSeries 或使用 Q_PROPERTY 的 Model 类:

class WaveformModel : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QVariantList displayPoints READ displayPoints NOTIFY displayPointsChanged)
    Q_PROPERTY(double viewStart READ viewStart WRITE setViewStart NOTIFY viewChanged)
    Q_PROPERTY(double viewEnd READ viewEnd WRITE setViewEnd NOTIFY viewChanged)

public:
    // 视口变更时触发重新降采样
    Q_INVOKABLE void updateViewport(double start, double end, int pixelWidth);

signals:
    void displayPointsChanged();
    void viewChanged();

private:
    void recalculateDisplay(); // 内部调用 LTTB
};

QML 交互组件

import QtQuick 2.15
import QtCharts 2.15

ChartView {
    id: waveformChart
    antialiasing: true
    theme: ChartView.ChartThemeDark
    backgroundColor: "#0f172a"
    legend.visible: false

    ValueAxis {
        id: axisX
        color: "#60a5fa"
        gridLineColor: "#1e293b"
        labelsColor: "#94a3b8"
    }

    ValueAxis {
        id: axisY
        color: "#60a5fa"
        gridLineColor: "#1e293b"
        labelsColor: "#94a3b8"
    }

    LineSeries {
        id: waveformSeries
        axisX: axisX
        axisY: axisY
        color: "#3b82f6"
        width: 1.5
        useOpenGL: true  // 关键:启用 OpenGL 加速渲染
    }

    // 鼠标滚轮缩放
    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.LeftButton | Qt.RightButton

        property real lastX: 0

        onWheel: function(event) {
            var zoomFactor = event.angleDelta.y > 0 ? 0.8 : 1.25;
            var mouseRatio = event.x / width;

            var range = axisX.max - axisX.min;
            var newRange = range * zoomFactor;
            var pivot = axisX.min + range * mouseRatio;

            axisX.min = pivot - newRange * mouseRatio;
            axisX.max = pivot + newRange * (1 - mouseRatio);

            // 通知 C++ 端重新降采样
            waveformModel.updateViewport(axisX.min, axisX.max, waveformChart.width);
        }

        onPressed: function(event) {
            lastX = event.x;
        }

        onPositionChanged: function(event) {
            if (pressed) {
                var dx = event.x - lastX;
                var range = axisX.max - axisX.min;
                var shift = -dx / width * range;

                axisX.min += shift;
                axisX.max += shift;
                lastX = event.x;

                waveformModel.updateViewport(axisX.min, axisX.max, waveformChart.width);
            }
        }
    }

    // 触控双指缩放(移动端适配)
    PinchArea {
        anchors.fill: parent
        onPinchUpdated: function(pinch) {
            var range = axisX.max - axisX.min;
            var newRange = range / pinch.scale;
            var center = axisX.min + range * 0.5;

            axisX.min = center - newRange * 0.5;
            axisX.max = center + newRange * 0.5;

            waveformModel.updateViewport(axisX.min, axisX.max, waveformChart.width);
        }
    }
}

交互响应的降采样流程

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#3b82f6', 'primaryTextColor': '#e2e8f0', 'lineColor': '#60a5fa', 'secondaryColor': '#1e293b', 'tertiaryColor': '#334155', 'primaryBorderColor': '#60a5fa'}}}%% sequenceDiagram participant User as 用户操作 participant QML as QML 视图层 participant Model as WaveformModel participant Engine as LTTB 引擎 participant Cache as 多级缓存 User->>QML: 滚轮缩放 / 拖拽平移 QML->>Model: updateViewport(start, end, width) Model->>Cache: selectCacheLevel() Cache-->>Model: 返回对应层级数据切片 Model->>Engine: lttbDownsample(slice, width * 2) Engine-->>Model: 降采样结果 Model->>QML: displayPointsChanged() QML->>QML: 更新 LineSeries

性能优化要点

1. 启用 OpenGL 渲染

LineSeries 中设置 useOpenGL: true,将绑定的数据点直接通过 GPU 渲染,绕过 Qt 的 QPainter 软渲染路径。在万级数据点场景下,帧率提升极为明显。

2. 节流视口更新

快速拖拽或滚轮连续滚动会触发大量 updateViewport 调用。使用定时器做节流处理:

void WaveformModel::updateViewport(double start, double end, int pixelWidth)
{
    m_pendingStart = start;
    m_pendingEnd = end;
    m_pendingWidth = pixelWidth;

    if (!m_throttleTimer->isActive()) {
        m_throttleTimer->start(16); // 约 60fps 的节流频率
    }
}

3. 增量更新替代全量替换

当实时数据持续流入时,避免每次都重建整个 Series。使用 QXYSeries::replace() 批量替换而非逐点 append()

void WaveformModel::pushToSeries(QXYSeries *series,
                                  const QVector<QPointF> &points)
{
    series->replace(points); // 一次性替换,触发单次重绘
}

4. 异步降采样

对于大数据量场景,将 LTTB 计算放入工作线程,通过信号将结果传回 UI 线程:

QtConcurrent::run([=]() {
    auto result = lttbDownsample(cacheSlice, targetPoints);
    QMetaObject::invokeMethod(this, [=]() {
        m_displayPoints = result;
        emit displayPointsChanged();
    }, Qt::QueuedConnection);
});

实际效果对比

以单通道 1KHz 采集、60 秒数据(60,000 点)为例:

指标直接渲染全量LTTB 降采样后渲染
渲染点数60,000\~2,000
帧率(FPS)8\~1255\~60
单帧渲染耗时80\~120ms2\~5ms
视觉保真度100%(但卡顿)>98%(肉眼无差)
缩放响应延迟200\~500ms<16ms

当通道数增加到 8 个时,直接渲染方案的帧率会跌至 2\~3 FPS,而 LTTB 方案依然保持在 45 FPS 以上。

适用场景

LTTB + Qt QML 的组合方案适用于以下典型场景:

工业设备监控:振动信号、温度曲线、压力波形的实时展示 医疗设备:心电图(ECG)、脑电图(EEG)等生理信号的波形回放 测试测量仪器:示波器界面、频谱分析仪的数据可视化 能源管理:电力系统中电压、电流波形的长时间监测与回溯

总结

高频数据的实时波形显示,本质是一个数据量与渲染能力之间的适配问题。LTTB 算法以线性时间复杂度和极高的视觉保真度,成为这一场景下的最优解之一。结合 Qt QML 的 OpenGL 渲染管线、多级缓存策略和异步处理机制,可以在保持 60 FPS 流畅体验的同时,支撑灵活的缩放、平移等交互操作。

关键实施原则可归纳为三点:只渲染眼睛需要的点用空间换时间的多级缓存让 UI 线程只做轻量工作

本文作者
成都尘轻扬技术团队

尘轻扬科技团队长期服务于制造业、军工和高可靠场景,聚焦 AI 私有知识库、工业软件与现场控制系统交付。

联系作者团队
RELATED

继续阅读

查看全部文章
工业软件24 分钟阅读

Qt/PySide 上位机开发 RS485 Modbus 对接全攻略:从总线拓扑到线程安全

系统梳理在 Qt(C++)或 PySide6(Python)环境下对接 RS485 Modbus RTU/ASCII 设备时的工程实践要点,涵盖总线拓扑与物理层规范、帧结构与 CRC 校验、分级轮询策略、超时重试机制、线程安全通信架构(Worker + 信号槽)、收发切换时序、多从机设备管理及通信质量诊断,帮助开发者规避工业现场的常见陷阱。

成都尘轻扬技术团队
上位机 / HMI18 分钟阅读

在上位机开发中,我们为什么选择 QML 而不是 Qt Widgets?

在工业 HMI 和上位机开发中,Qt Widgets 与 QML 的选型之争从未停歇。本文结合多个实际项目经验,从渲染架构、动画系统、分层设计与工程协作四个维度,系统解析我们为何最终将 QML + Qt Quick 作为主力界面开发方案,以及 Widgets 仍然适用的场景边界。

成都尘轻扬技术团队
工业软件27 分钟阅读

做上位机时该选哪个数据库?SQLite3 / MySQL / PostgreSQL / MongoDB 深度对比

工业上位机软件在数据存储层面面临高频写入、时序查询、离线自治、运维轻量等独特挑战。本文从上位机开发的实际视角,系统梳理 SQLite3、MySQL、PostgreSQL、MongoDB 四种主流数据库的核心差异、优缺点与适用边界,并提供可落地的选型决策树和实战组合方案,帮助工控软件开发者快速做出合理选择。

成都尘轻扬技术团队