07. 采样、走样与滤波
07. 采样、走样与滤波
本篇位置:模块一 · 第 7 篇 | 前置:插值 | 预计阅读:45 分钟
你已经在用它了
SurfaceFlinger 做背景模糊时,第一件事是把图缩到四分之一(libs/renderengine/skia/filters/BlurFilter.h):
class BlurFilter {
public:
// Downsample FBO to improve performance
static constexpr float kInputScale = 0.25f;
// Downsample scale factor used to improve performance
static constexpr float kInverseInputScale = 1.0f / kInputScale;
// ...
};具体怎么缩的(filters/GaussianBlurFilter.cpp):
SkImageInfo scaledInfo = input->imageInfo().makeWH(std::ceil(blurRect.width() * kInputScale),
std::ceil(blurRect.height() * kInputScale));
sk_sp<SkSurface> surface = context->createRenderTarget(scaledInfo);
SkPaint paint;
paint.setBlendMode(SkBlendMode::kSrc);
paint.setImageFilter(SkImageFilters::Blur(
blurRadius * kInputScale * BLUR_SIGMA_SCALE,
blurRadius * kInputScale * BLUR_SIGMA_SCALE,
SkTileMode::kClamp, nullptr));
surface->getCanvas()->drawImageRect(
input, blurRect, SkRect::MakeWH(scaledInfo.width(), scaledInfo.height()),
SkSamplingOptions{SkFilterMode::kLinear, SkMipmapMode::kNone},
&paint, SkCanvas::SrcRectConstraint::kFast_SrcRectConstraint);三个问题:
- 模糊之前为什么要先缩到 1/4? 注释说是为了性能——省下来的到底是什么?
- 缩到 1/4 意味着每 16 个像素只保留 1 个,而
kLinear只看 2×2 邻域。 这不是漏看了 12 个像素吗?为什么这里不出问题? - 上一篇留的尾巴:为什么放大用双线性没事,缩小就要格外小心?
学完你会什么
- 说清"走样"是什么、它从哪来,以及为什么它造出来的东西是原图里没有的。
- 说清奈奎斯特那句话的意思,不用背公式。
- 说清卷积就是"滑动的加权和",并认出模糊、锐化、边缘检测各是哪一组权重。
- 用频率的语言重述模糊和锐化,具备读超分/抗锯齿文献的基本词汇。
- 说清缩小图像必须先低通再抽样,以及 mipmap 到底在解决什么问题。
1. 一切都是采样
采样(sampling)就是"在连续的东西上,隔一段取一个值"。
图形管线里到处都是采样:
| 什么在被采样 | 采样点是什么 |
|---|---|
| 一个连续的三维场景 | 每个像素的中心 |
| 一张纹理 | 每次 texture() 调用的 UV 位置 |
| 一段连续的运动 | 每一帧(时间上的采样) |
| 一张大图缩小成小图 | 小图的每个像素 |
采样必然丢信息——你只留下了有限个数,中间发生了什么无从得知。上一篇讲的插值,是在"猜"丢掉的那部分。这一篇讲的是:丢得太多的时候,会发生比"糊"严重得多的事。
2. 走样:信号伪装成了别的东西
图:灰色细线是真实信号,每秒振荡 9 次。蓝点是 10 个采样点。橙色粗线是每秒振荡 1 次的另一条信号——它在每一个采样点上的取值,和真实信号完全相同。
光看这 10 个点,你没有任何办法区分这两条信号。 高频信号"伪装"成了一条低频信号,这个现象叫走样(aliasing,也译混叠)。alias 本来就是"化名"的意思。
关键在于:走样造出来的低频,是原始信号里根本不存在的东西。 它不是"细节丢了",是"凭空多了假东西"——这比丢细节严重得多,因为你没法靠后处理去掉它(你分不清哪些是真的)。
生活里的例子:电影里高速旋转的车轮看起来在倒转,就是快门(时间采样)跟不上转速导致的走样。
3. 奈奎斯特:一句话版本
采样率要多高才不出事?答案叫奈奎斯特采样定理(Nyquist):
采样频率必须超过信号最高频率的两倍。
翻译成图像的话:想正确表现一条 1 像素宽的条纹,你至少需要每 2 个像素采一次——也就是说,比 1 像素更细的花纹,无论如何都表现不出来。
反过来说,如果信号里含有超过一半采样率的频率成分,它们不会消失,而是会折叠回来变成假的低频。这就是上一节那张图的原理。
这条定理给了我们唯一的解法:既然高频注定要作乱,那就在采样之前先把高频去掉。去掉高频这件事叫低通滤波(low-pass filter),俗称"模糊"。
4. 摩尔纹:二维的走样
图:一张同心圆条纹图,越往外条纹越细。中间那格是直接每 4 个像素取 1 个——出现了大片原图里根本没有的粗条纹。右边那格先做 4×4 平均再抽样,虽然细节没了,但没有假花纹。
这些假花纹就是摩尔纹(moiré)。你在这些地方见过它:
- 拍摄电脑屏幕或格子衬衫的照片;
- 图层被缩小时,细密的纹理糊成一片诡异的条纹;
- 远处的栅栏、瓦片屋顶在画面里闪烁。
中间那格和右边那格的差别,就是有没有做低通。 这是本篇的中心。
5. 卷积:一个滑动的加权和
"低通滤波"具体怎么做?答案是卷积(convolution)。
听起来吓人,其实上一篇已经讲过了它的核心思想:给每个像素,把它周围一圈邻居按一组固定的权重加起来。
( 就是"全部加起来",不认识翻速查表。)
那组权重排成方阵,就叫卷积核(kernel)。上一篇讲的三角核、三次核是一维的版本,这里是二维的。
跟着算一次。取 3×3 的盒式模糊核,九个格子都是 :
假设某个像素周围 9 个值分别是 ,那么
中心那个 0.9 被周围的小值拉了下来——这就是"模糊"在数值上的样子。
从权重和看核在干什么
| 核 | 权重和 | 效果 |
|---|---|---|
| 盒式模糊 | 1 | 平均,整体亮度不变 |
| 高斯模糊 | 1 | 平均但中间权重更高,比盒式自然 |
| 锐化 | 1 | 中心加权、四邻取负,放大局部反差 |
| 边缘检测 | 0 | 平坦区域输出 0(全黑),只有变化处才有值 |
权重和是个很好用的速查指标:和为 1 的核不改变整体亮度;和为 0 的核会把平坦区域压成黑色,说明它在提取"变化"而不是"内容"。
6. 频域:把话说得更准
上面这些用"模糊""锐化""细节"来描述,够用但不精确。有一套更准的语言,值得花十分钟建立直觉。
任何图像都可以看成一堆不同频率的波叠加起来。
- 低频 = 大块的色调、整体的明暗;
- 高频 = 边缘、细小纹理、噪点。
图:第二格是原图的频谱——中间是低频,越往外频率越高,亮度表示这个频率的成分有多强。第三格只保留中间那一圈(低通),得到的就是模糊图。第四格只保留外圈(高通),只剩下边缘。
有了这套词汇,前面所有结论都能重说一遍,而且更准:
| 之前的说法 | 频域的说法 |
|---|---|
| 模糊 | 低通滤波:砍掉高频 |
| 锐化 | 提升高频:把高频成分乘个大于 1 的系数再加回去 |
| 双线性放大会糊(第 6 篇) | 三角核是个低通滤波器,天然削高频 |
| 走样 | 超过一半采样率的高频,折叠成了假的低频 |
| 抗走样 | 采样前先低通,把会折叠的那部分提前去掉 |
你不需要会算傅里叶变换。 你需要的是"高频=细节、低频=大块"这个直觉,以及看到论文里写 "low-pass"、"high-frequency detail"、"spectrum" 时知道它在说什么——这就是读超分和抗锯齿文献的入场券。
锐化的标准做法叫反锐化蒙版(unsharp mask),公式是:
括号里"原图减模糊版"就是只剩高频的那部分(因为模糊版 = 低频,原图 = 低频+高频)。乘个系数加回原图,就是"高频被抬高了"。名字里的 "unsharp"(不锐利)指的是中间那个模糊版——用一张糊图来把原图变清楚,这个反直觉的名字困扰过很多人。
7. 缩小为什么必须先低通
现在回答问题 2 和 3,这是本篇的落点。
放大和缩小是两个完全不同的问题:
| 放大 | 缩小 | |
|---|---|---|
| 目标像素 vs 源像素 | 目标比源多 | 目标比源少 |
| 每个目标像素对应源的一小块(footprint) | 小于 1 个像素 | 好几个像素 |
| 主要风险 | 糊(信息不够) | 走样(信息太多,装不下) |
| 解法 | 挑个好核去猜 | 先低通,再抽样 |
缩小 4 倍时,输出图的每一个像素,代表的是原图 4×4 = 16 个像素那么大一片区域。要想正确地代表这 16 个像素,就得把它们都考虑进来。
图左:最近邻,16 个像素里只看了 1 个——摩尔纹最严重。图中:双线性缩小 4 倍,只看了 2×2 = 4 个,还有 12 个从来没被读过——摩尔纹依然明显。图右:先做 4×4 平均再抽样,16 个像素全部参与,假花纹消失。
这就是问题 3 的答案:双线性的 2×2 邻域,只够应付"缩小不到 2 倍"的情况。缩得越狠,它漏看的像素越多,走样越严重。双线性不是万能的抗走样手段,它只是个 2 抽头的滤波器。
mipmap 在解决什么
每次缩小都现场做一次大范围平均,太贵了——而且同一张纹理会被反复以不同比例缩小。
mipmap 的思路是把这件事预先做好:把纹理逐级减半,每一级都是上一级"先低通再抽样"的结果,存成一串。
采样时,GPU 根据"这个像素覆盖了纹理上多大一片"自动挑一级来读——这个判断就是靠第 4 篇提过的 dFdx/dFdy(相邻像素之间 UV 变了多少)。
代价只有 1/3 的额外显存(),换来的是缩小场景下的正确性和速度。
回到源码
问题 1:模糊前为什么先缩到 1/4?
省的是带宽和计算量。缩到 1/4 边长意味着像素数变成 1/16,后续每一次模糊读写都便宜 16 倍。而且模糊半径也可以按同样比例缩小(代码里 blurRadius * kInputScale 就是在做这件事),卷积核变小,每个像素要读的邻居也变少——两头都省。
问题 2:缩到 1/4 只看 2×2,不会走样吗?
理论上会。 缩 4 倍需要 4×4 的覆盖范围,而 kLinear 只提供 2×2,中间那 12 个像素确实没被读到。
但在这个具体场景里不要紧,因为紧接着就要做一次高斯模糊——这一步本来就是要把高频全部去掉。降采样时折叠进来的那点假低频,会连同真高频一起被模糊掉。目标是一张糊图,走样引入的误差最终看不出来。
这是个很典型的工程取舍:知道理论上不严谨,但知道为什么在这个上下文里可以接受。同样的写法如果出现在"缩小一张要给用户看清楚的缩略图"那里,就是实打实的 bug。
问题 3:为什么放大用双线性没事,缩小要小心?
因为放大时每个输出像素对应源图上不到 1 个像素,2×2 邻域绰绰有余;缩小时每个输出像素对应源图上好几个像素,2×2 根本盖不住,漏掉的部分就折叠成了摩尔纹。
以后再看到任何"改变分辨率"的代码,先问一句:这是放大还是缩小? 缩小的话,再问:滤波的覆盖范围够不够盖住缩放比例? 不够的话,走样是必然的——接下来只需要判断"这里在不在乎"。
自测
1. 走样和"细节丢失"有什么本质区别?
细节丢失是少了东西,走样是多了原本不存在的东西。
高频成分不会安静消失,它会折叠成假的低频混进画面里(摩尔纹、闪烁)。这比丢细节严重,因为你无法在事后区分哪些低频是真的、哪些是折叠出来的,所以没法靠后处理修复。
唯一的解法是在采样之前把高频去掉。
2. 一个卷积核的九个权重加起来等于 0,这个核大概是干什么的?
提取变化,也就是边缘检测。
权重和为 0 意味着:面对一片颜色完全一样的区域,加权和必然是 0(输出黑色)。只有当邻域内存在差异时,输出才非零。
对照:权重和为 1 的核(模糊、锐化)保持整体亮度不变。看权重和是判断一个陌生核在干什么的最快办法。
3. 把一张 1024x1024 的纹理缩小到 128x128,双线性够用吗?
远远不够。 缩小 8 倍,每个输出像素代表源图 8×8 = 64 个像素,而双线性只读 2×2 = 4 个——60 个像素从没被看过,摩尔纹会非常严重。
正确做法是用 mipmap(GPU 会自动挑到合适的层级),或者手动做多次逐级减半的低通+抽样。
4. 反锐化蒙版为什么叫"不锐利"蒙版?
因为公式中间那个 用到了一张模糊(unsharp)的图。
原图 = 低频 + 高频,模糊版 ≈ 低频,两者相减就得到了纯高频。把这个高频乘个系数加回原图,高频被抬高,画面看起来更锐。
名字描述的是中间步骤用的素材,不是最终效果——用一张糊图来把原图变清楚。
5. SurfaceFlinger 的模糊先降到 1/4 再模糊。如果把同样的写法用在"生成一张要给用户看的缩略图"上,会怎样?
会出现明显的摩尔纹和闪烁,是实打实的 bug。
模糊场景能容忍降采样走样,是因为后面还有一步高斯模糊会把高频(包括折叠进来的假低频)一起抹掉,最终目标本来就是一张糊图。
缩略图的目标恰恰相反——要尽量保留可辨认的内容。这时必须用能覆盖整个缩放比例的滤波(box 平均、mipmap、或多次逐级减半)。
同一段代码是不是正确,取决于它的下游要什么。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 采样 | 在连续的东西上隔一段取一个值 | 渲染、纹理、动画帧都是采样 |
| 走样 | 高频伪装成假低频 | 多了假东西,不是少了真东西 |
| 奈奎斯特 | 采样率要超过最高频率的两倍 | 推论:采样前必须先低通 |
| 摩尔纹 | 二维走样的样子 | 缩小细密纹理时最常见 |
| 卷积 | 滑动的加权和 | 换一组权重就换一种效果 |
| 看权重和 | 和为 1 保亮度,和为 0 提取变化 | 判断陌生核的最快办法 |
| 频域 | 低频=大块,高频=细节 | 模糊=低通,锐化=抬高高频 |
| 反锐化蒙版 | 原图 + λ×(原图 − 模糊版) | 名字说的是中间素材 |
| 放大 vs 缩小 | 放大怕糊,缩小怕走样 | 两个问题,两套解法 |
| 缩小的规则 | 滤波范围必须盖住缩放比例 | 双线性只有 2×2,缩 2 倍以上就不够 |
| mipmap | 预先算好的一串低通版本 | 只多花 1/3 显存 |
延伸阅读
- GAMES101 Lecture 6:Rasterization 2(Antialiasing and Z-Buffering):https://games-cn.org/intro-graphics/
- 3Blue1Brown《傅里叶变换的可视化》:https://www.bilibili.com/video/BV1pW411J7s8
- Scratchapixel: Introduction to Signal Processing:https://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/introduction-to-shading/introduction-to-signal-processing.html
- AOSP 源码:
frameworks/native/libs/renderengine/skia/filters/BlurFilter.h、filters/GaussianBlurFilter.cpp

觉得有用?关注公众号「阿豪讲Framework」
Android 系统开发,新文章第一时间推送。