11.2 抖动与蓝噪声

抖动不减少量化误差,它把误差从低频的「带状」搬到高频的「噪声」。本节把 0..255 的斜坡量化到 8 级,比较无抖动、Bayer 有序抖动、Floyd–Steinberg 误差扩散与 16×16 蓝噪声:总误差几乎不变,低频带状分量从 6.4844 压到 1.2 上下;蓝噪声更把阈值矩阵的低频功率占比压到同尺寸白噪声的 1/243。

11.2 抖动与蓝噪声

站内 /graphics-anti-aliasing-methods/ 那篇在时间域上做抗锯齿——多采几帧再平均。本节换到空间域,而且一分采样都不加:输入还是那张 128×128 的 0..255 水平斜坡,输出还是 8 位,只是量化到 8 级。问题出在量化本身——步长 36,于是斜坡被切成一条条 36 级的色带,这就是带状伪影(banding)。

抖动(dithering)的全部作用是:把量化误差从低频搬到高频。它不减少误差的总量(误差预算是量化步长决定的,抖动改不了),改的是误差的频谱分布——人眼对低频的带状轮廓极其敏感,对高频的细密噪点则宽容得多。所以「抖动好不好」不能用 MAE 一个数判断,必须看误差落在哪个频段。

四个方案,一条斜坡

方案阈值来源误差落在哪
none常数 128(四舍五入)每条带内线性变化,低频、可见
orderedBayer 4×4(整数递推构造)高频,但图案规则,有谱峰
bluevoid-and-cluster 16×16 rank 矩阵高频且各向同性,低频功率最小
fs无阈值矩阵,误差向后扩散高频、不规则,逐像素串行

Bayer 矩阵的整数递推是本节第一个可断言的东西:M_2n = [[4M+0, 4M+2], [4M+3, 4M+1]],从 [[0]] 出发翻倍四次就是 16×16,元素恰好是 0..n²-1 的一个排列。量化则把阈值折进偏移量:off = (thr * step) // 256,q = clip((v + off) // step, 0, levels-1) * step。误差扩散走另一条路——它不查表,把每个像素的量化误差按 7/16、3/16、5/16、1/16 的权重分给右、左下、下、右下四个邻居。

S 层的三段核心(完整脚本见工作区 sim/cg_shade_11_2_dither.py):

# snippet: 11.2 的三种抖动构造(节选):Bayer 整数递推 / 带阈值量化 / Floyd-Steinberg 整数扩散
def bayer(n):
    """Bayer 矩阵的整数递推:M_2n = [[4M+0, 4M+2], [4M+3, 4M+1]]。"""
    m = np.zeros((1, 1), dtype=np.int64)
    k = 1
    while k < n:
        m = np.block([[4 * m + 0, 4 * m + 2], [4 * m + 3, 4 * m + 1]])
        k *= 2
    return m


def quantize(v, levels, thr):
    """带阈值的均匀量化:`thr` 为 Q8 阈值(0..255),返回量化后的 0..255 整数。"""
    step = 255 // (levels - 1)
    off = (thr * step) // 256
    qi = np.clip((v + off) // step, 0, levels - 1)
    return qi * step


def fs_dither(v, levels):
    """Floyd-Steinberg 误差扩散(整数)。误差缓冲以 16 为分母,避免浮点。"""
    h, w = v.shape
    step = 255 // (levels - 1)
    out = np.zeros_like(v)
    err = np.zeros((h + 1, w + 2), dtype=np.int64)
    for y in range(h):
        for x in range(w):
            p = v[y, x] + err[y, x + 1] // 16
            qi = min(levels - 1, max(0, (p + step // 2) // step))
            q = qi * step
            out[y, x] = q
            e = p - q
            err[y, x + 2] += e * 7
            err[y + 1, x] += e * 3
            err[y + 1, x + 1] += e * 5
            err[y + 1, x + 2] += e
    return out

err[y, x+1] // 16 是这里唯一会碰到负数的一行——误差有正有负,Python 的 // 向 -∞ 取整。搬到 TypeScript 时这一行必须写成 Math.floor,写成 | 0 或 Math.trunc 就会在负误差上差 1,累积几百个像素后整幅图都变了。这是本章第二次踩同一个坑(11.1 是 >> 的 32 位域)。

实测(/opt/homebrew/bin/python3 sim/cg_shade_11_2_dither.py):

画布 / 级数     : 128x128  量化到 8 级(步长 36)
方案           MAE      低频(带状)      高频(噪声)          谱峰       有符号均值
none       8.875      6.4844      2.4531    91120.04      0.9688
ordered  11.8281      1.2922     11.2155    145152.0     -0.0156
blue     11.7617      1.3262     11.9453    32487.81     -0.1035
fs       11.4558      1.1705     11.2203    22720.98     -0.4507
低频功率占比    : blue 16x16 = 0.000217  white 16x16 = 0.052722  (蓝噪声把低频功率压低 243 倍)

这张表要横着读。总误差那一列(MAE)反而变大了:无抖动 8.875,三种抖动都在 11.5~11.8。这不是 bug——抖动的阈值偏移让误差不再逐像素抵消,MAE 上升是必然的代价。真正变化的是第二列:低频(带状)分量从 6.4844 掉到 1.2 上下,降了五倍;对应的第三列高频(噪声)分量从 2.4531 涨到 11~12。误差被搬走了,没被消灭。

有符号均值一列是「直流偏置」的体检:无抖动时是 0.9688(带内误差系统性偏正,这正是带状轮廓的来源),三种抖动都压到 0.1 以内。抖动的阈值矩阵必须均值为 128,否则它会引入一条整体色偏——这一条在 P 层被写成了断言。

最后一行是蓝噪声的定义性指标,也是本节唯一一个「谁更好」可以复算的地方:把 16×16 蓝噪声 rank 矩阵与同尺寸白噪声矩阵都做零均值化、求二维功率谱,再看半径小于 3 的低频段占总功率的比例。蓝噪声 0.000217,白噪声 0.052722——低两个数量级,243 倍。这正是「蓝」的含义:能量被推到高频去了。

P 层:Bayer、误差扩散与朴素 DFT

P 层用 TypeScript 重写同一套东西(core/src/lantern/cg_shade_11_dither.ts),要求四个量化平面与 Python 逐字节相同:

// file: lantern/core/src/lantern/cg_shade_11_dither.ts
// tier: P
// verify: node --test 'core/test/cg_shade_11_2_dither.test.ts'
// 11.2 抖动与蓝噪声的 P 层:Bayer 整数递推、带阈值量化、Floyd–Steinberg 整数误差扩散,
// 以及低频功率占比(蓝噪声的定义性指标)。与 Python 侧 sim/cg_shade_11_2_dither.py
// 做**同一件事**,两侧是两份独立代码,结果必须逐字节相同。
//
// 三个「Python 与 JS 语义不同」的坑,本节全部踩在明处:
//   ① 误差扩散里 err 可为负,Python `//` 向 -inf 取整,故 JS 侧必须用 `Math.floor`,不能用 `| 0`;
//   ② 量化偏移 `(thr*step)//256` 的分子非负,floor 与截断同义;
//   ③ 谱分析用朴素 DFT(16x16 只有 65536 次乘加),避免引入 FFT 依赖。

/** Bayer 矩阵的整数递推:M_2n = [[4M+0, 4M+2], [4M+3, 4M+1]]。n 必须是 2 的幂。 */
export function bayer(n: number): number[][] {
  let m: number[][] = [[0]];
  let k = 1;
  while (k < n) {
    const size = k * 2;
    const next: number[][] = Array.from({ length: size }, () => new Array<number>(size).fill(0));
    for (let y = 0; y < k; y += 1) {
      for (let x = 0; x < k; x += 1) {
        const v = m[y][x] * 4;
        next[y][x] = v;
        next[y][x + k] = v + 2;
        next[y + k][x] = v + 3;
        next[y + k][x + k] = v + 1;
      }
    }
    m = next;
    k = size;
  }
  return m;
}

/**
 * 把 n x n 阈值矩阵平铺到 w x h。
 * `mode = "bayer"`:阈值 = r*16 + 8(r 是 0..n²-1 的序号,把阈值均分到 0..255);
 * `mode = "rank"`:阈值 = r(蓝噪声 rank 矩阵本身就是 0..255 的阈值)。
 */
export function tileThreshold(mat: number[][], w: number, h: number, mode: "bayer" | "rank"): Int32Array {
  const n = mat.length;
  const out = new Int32Array(w * h);
  for (let y = 0; y < h; y += 1) {
    for (let x = 0; x < w; x += 1) {
      const r = mat[y % n][x % n];
      out[y * w + x] = mode === "bayer" ? r * 16 + 8 : r;
    }
  }
  return out;
}

/** 带阈值的均匀量化:q = clip((v + (thr*step)//256) // step, 0, levels-1) * step。 */
export function quantize(v: Int32Array, levels: number, thr: Int32Array): Int32Array {
  const step = Math.floor(255 / (levels - 1));
  const out = new Int32Array(v.length);
  for (let i = 0; i < v.length; i += 1) {
    const off = Math.floor((thr[i] * step) / 256);
    let qi = Math.floor((v[i] + off) / step);
    if (qi < 0) qi = 0;
    else if (qi > levels - 1) qi = levels - 1;
    out[i] = qi * step;
  }
  return out;
}

/** Floyd–Steinberg 误差扩散(整数)。误差缓冲以 16 为分母,全程无浮点。 */
export function fsDither(v: Int32Array, w: number, h: number, levels: number): Int32Array {
  const step = Math.floor(255 / (levels - 1));
  const out = new Int32Array(w * h);
  const EW = w + 2;
  const err = new Int32Array((h + 1) * EW);
  for (let y = 0; y < h; y += 1) {
    for (let x = 0; x < w; x += 1) {
      // err 可为负 -> 必须 Math.floor(Python 的 // 向 -inf 取整)
      const p = v[y * w + x] + Math.floor(err[y * EW + x + 1] / 16);
      const qi = Math.min(levels - 1, Math.max(0, Math.floor((p + (step >> 1)) / step)));
      const q = qi * step;
      out[y * w + x] = q;
      const e = p - q;
      err[y * EW + x + 2] += e * 7;
      err[(y + 1) * EW + x] += e * 3;
      err[(y + 1) * EW + x + 1] += e * 5;
      err[(y + 1) * EW + x + 2] += e;
    }
  }
  return out;
}

/** 5x5 盒式低通(整数,边缘用 reflect 填充)。 */
export function box5(a: Int32Array, w: number, h: number): Int32Array {
  const at = (x: number, y: number) => {
    const xx = x < 0 ? -x : (x >= w ? 2 * w - 2 - x : x);
    const yy = y < 0 ? -y : (y >= h ? 2 * h - 2 - y : y);
    return a[yy * w + xx];
  };
  const out = new Int32Array(w * h);
  for (let y = 0; y < h; y += 1) {
    for (let x = 0; x < w; x += 1) {
      let s = 0;
      for (let dy = -2; dy <= 2; dy += 1) for (let dx = -2; dx <= 2; dx += 1) s += at(x + dx, y + dy);
      out[y * w + x] = Math.floor(s / 25);
    }
  }
  return out;
}

/** 低频(带状)误差:|5x5 盒式低通(量化误差)| 的均值。抖动要做的事就是把它压低。 */
export function bandingLf(q: Int32Array, v: Int32Array, w: number, h: number): number {
  const e = new Int32Array(q.length);
  for (let i = 0; i < q.length; i += 1) e[i] = q[i] - v[i];
  const lf = box5(e, w, h);
  let s = 0;
  for (let i = 0; i < lf.length; i += 1) s += Math.abs(lf[i]);
  return s / lf.length;
}

/** 二维功率谱(朴素 DFT,O(n^4))。仅用于 16x16 的阈值矩阵。 */
export function powerSpectrum(mat: number[][]): number[][] {
  const n = mat.length;
  const out: number[][] = [];
  for (let u = 0; u < n; u += 1) {
    out.push(new Array<number>(n).fill(0));
    for (let v = 0; v < n; v += 1) {
      let re = 0;
      let im = 0;
      for (let y = 0; y < n; y += 1) {
        for (let x = 0; x < n; x += 1) {
          const ang = (-2 * Math.PI * (u * y + v * x)) / n;
          re += mat[y][x] * Math.cos(ang);
          im += mat[y][x] * Math.sin(ang);
        }
      }
      out[u][v] = re * re + im * im;
    }
  }
  return out;
}

/** 低频功率占比:半径 < cut 的频段功率 / 全频段功率。蓝噪声的定义性指标,越小越「蓝」。 */
export function lfPowerRatio(mat: number[][], cut = 3): number {
  const n = mat.length;
  let mean = 0;
  for (const row of mat) for (const v of row) mean += v;
  mean /= n * n;
  const centered = mat.map((row) => row.map((v) => v - mean));
  const P = powerSpectrum(centered);
  let total = 0;
  let low = 0;
  for (let u = 0; u < n; u += 1) {
    for (let v = 0; v < n; v += 1) {
      const p = P[u][v];
      total += p;
      const ru = Math.min(u, n - u);
      const rv = Math.min(v, n - v);
      if (Math.hypot(ru, rv) < cut) low += p;
    }
  }
  return low / total;
}

谱分析那一段刻意用朴素 DFT而不是 FFT:16×16 的矩阵只有 256 个频点、每个频点 256 次乘加,一共 65536 次——写成一团四重循环也就几毫秒,换来的是「不引入任何依赖」。蓝噪声的判据不需要快,需要的是能被独立复算。

node --test 的结果:

✔ Bayer 整数递推:M_2 与 M_4 逐项正确
✔ 四个量化平面与 Python 侧逐字节相同
✔ 低频(带状)误差与 S 层一致:无抖动最高,三种抖动都被压低
✔ 蓝噪声的定义性指标:低频功率占比比同尺寸白噪声低两个数量级
✔ 负例:把蓝噪声阈值换成常数 128 -> 退化为无抖动,低频必须回升
tests 5 / pass 5 / fail 0

第二条是跨语言断言:四个平面各 16384 字节,Python 与 TypeScript 逐字节相同——只要 Math.floor 那一行写错,fs 平面立刻对不上。第五条是负例:把蓝噪声的 rank 阈值换成常数 128,图案退化成无抖动,低频从 1.33 涨回 6.48,判据必须拒绝它。

小结

抖动是一类「用误差的空间分布换主观质量」的技术,它的判据必须分频段看:总误差(MAE)可以变大,低频(带状)必须变小,高频(噪声)变大是代价而不是缺陷。有序抖动与误差扩散都能把低频压到 1/5,区别在图案的规则度——Bayer 的谱峰是 145152,误差扩散只有 22720,肉眼看到的分别是「规则网格」和「随机颗粒」。蓝噪声介于两者之间:图案不规则(谱峰 32487),但低频功率最低(0.000217)。下一节把「谁更好」写成一条可复算的判据,而不是形容词。

阅读导航:上一节:11.1 时间抗锯齿 · 下一节:11.3 抗锯齿的度量 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配