网页波形降采样要同时保留极值和边沿

问题从哪里来

我在做一个信号处理实验室的 Web 工具,需要用 Canvas 在浏览器里画波形图。右侧面板每个示波器窗口大概 400 像素宽,但信号数据动辄几万到几十万个采样点。

一个像素要代表几百个数据点——这时候,怎么画?

信号处理实验室的波形渲染:左侧显示了多种滤波后的信号波形,右侧示波器区域展示了 min-max 降采样后的实际效果

最朴素的做法:每隔 N 个点取一个

不行的。想象一段信号:99.9% 的采样点都在 0 附近,只有 1 个尖峰冲到 10V。每隔 N 点采样,有 99.9% 概率正好跳过那个尖峰。屏幕上看起来就是一条平平的线,用户完全不知道那里有个 10V 的脉冲。

这个问题不是边缘情况——是高频信号的普遍现象。

一个像素 = 一个竖线,线涵盖 min 到 max

标准做法叫 min-max 降采样:

  1. 把数据按像素宽度切成一段一段
  2. 每一段找最小值和最大值
  3. 在这一列的像素位置上,从 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 保极值。

这套方案的特点

这个方案既可以独立用于 Canvas 2D 直绘,也可以迁移到 WebGL 做纹理采样,或做成图片服务端的降采样管道。

本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。