网页波形降采样要同时保留极值和边沿
问题从哪里来
我在做一个信号处理实验室的 Web 工具,需要用 Canvas 在浏览器里画波形图。右侧面板每个示波器窗口大概 400 像素宽,但信号数据动辄几万到几十万个采样点。
一个像素要代表几百个数据点——这时候,怎么画?

最朴素的做法:每隔 N 个点取一个
不行的。想象一段信号:99.9% 的采样点都在 0 附近,只有 1 个尖峰冲到 10V。每隔 N 点采样,有 99.9% 概率正好跳过那个尖峰。屏幕上看起来就是一条平平的线,用户完全不知道那里有个 10V 的脉冲。
这个问题不是边缘情况——是高频信号的普遍现象。
一个像素 = 一个竖线,线涵盖 min 到 max
标准做法叫 min-max 降采样:
- 把数据按像素宽度切成一段一段
- 每一段找最小值和最大值
- 在这一列的像素位置上,从 min 画一条竖线到 max
for (let px = 0; px < canvasWidth; px++) {
const chunkStart = Math.floor(iStart + (px / canvasWidth) * (iEnd - iStart));
const chunkEnd = Math.floor(iStart + ((px + 1) / canvasWidth) * (iEnd - iStart));
let chunkMin = Infinity, chunkMax = -Infinity;
for (let j = chunkStart; j < chunkEnd; j++) {
const v = data[j];
if (v < chunkMin) chunkMin = v;
if (v > chunkMax) chunkMax = v;
}
const yMin = height - pad - ((chunkMax - globalMin) / range) * (height - 2 * pad);
const yMax = height - pad - ((chunkMin - globalMin) / range) * (height - 2 * pad);
ctx.fillRect(px, yMin, 1, Math.max(1, yMax - yMin));
}这样,只要尖峰落在这个像素列对应的数据段里,它就一定会出现在画布上——因为 chunkMax 一定 >= 尖峰值。无论缩多小,尖峰永远不会丢。
第二个坑:方波的上升沿、下降沿去哪了
min-max 解决尖峰之后,很快发现了另一个问题:画矩形波的时候,眼看高电平和低电平都能显示,但上升沿和下降沿经常消失。
原因很简单:上升沿通常只有 1~2 个采样点,恰好处于两个相邻像素列的分界线上。左侧像素列的 chunk 结束于分界线前一格,右侧像素列的 chunk 从分界线开始——上升沿那 1 个点正好在缝隙里。
修起来非常廉价——让相邻像素列的 chunk 重叠 1 个采样点:
const chunkStart = px === 0
? Math.floor(iStart + (px / dw) * (iEnd - iStart))
: Math.floor(iStart + (px / dw) * (iEnd - iStart)) - 1; // 向前重叠 1 个采样之前: [0..9] [10..19] [20..29] ← 9-10、19-20 之间的边沿可能掉进缝隙
现在: [0..9] [9..19] [19..29] ← 每个边界点被左右两个像素列共同覆盖增加的运算量可以忽略不计——多看了不到 1% 的数据点。
放大时的策略:别画竖线了,画连线
min-max 竖线在缩小时表现完美,但放大到接近原始采样率时,一个像素列只覆盖 1~2 个甚至 0 个采样点。这时候画竖线看起来会有颗粒感。
做一个简单的分支:
const pointsPerPixel = (iEnd - iStart) / canvasWidth;
if (pointsPerPixel <= 1) {
// 放大:逐像素线性插值,平滑连线
for (let px = 0; px < dw; px++) {
const dataIdx = iStart + (px / dw) * (iEnd - iStart);
const idx = Math.floor(dataIdx);
const frac = dataIdx - idx;
const v = data[idx] * (1 - frac) + data[idx + 1] * frac;
// ... 连线
}
} else {
// 缩小:min-max 竖线
// ...
}pointsPerPixel 是每个像素对应的数据点数。等于 1 就是刚好 1:1;小于 1 说明是放大状态,采样点比像素还稀疏,用线性插值能给出最流畅的显示;大于 1 进入缩小状态,走 min-max 保极值。
这套方案的特点
- 实现简单:核心逻辑不到 50 行,没有额外依赖
- 性能稳定:渲染开销完全由像素宽度决定,与数据量无关。百万点数据在 400px 宽的 canvas 上,每秒刷几十帧无压力
- 不会丢极值:缩到多小都保证尖峰可见
- 不会丢边沿:1-sample overlap 确保边界点的连续性
- 放大也精细:自适应切换到线性插值,不破坏近距离观察的体验
这个方案既可以独立用于 Canvas 2D 直绘,也可以迁移到 WebGL 做纹理采样,或做成图片服务端的降采样管道。
本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。