1. 为什么要把“结构”从“纹理”里抠出来
先说个我自己的经历。前几年做图像卡通化的小项目,输入一张风景照,想输出类似手绘风格的扁平色块。最头疼的不是颜色量化,而是草地的噪点、树叶的细碎光斑、瓷砖墙的花纹这些纹理,它们会让量化后的色块变得脏兮兮的。我当时试了一圈平滑算法,最后才找到这篇 SIGGRAPH Asia 2012 的文章——李徐(Li Xu)等人的《Structure Extraction from Texture via Relative Total Variation》(RTV)。一句话概括它做的事:把图像里“大尺度的结构边缘”保留下来,同时把“小尺度的重复纹理”尽可能抹平。
这个需求在图形图像领域比你想的更普遍。边缘检测希望只输出物体轮廓而不被纹理干扰;分割算法希望输入的是干净的色块图而不是满屏颗粒;抠图、线稿提取、图像融合、seam carving 预处理,全都受益于先把结构从纹理中剥离。这篇文章值得所有做图像处理、计算机视觉、甚至游戏美术工具链的人认真读一遍。
1.1 纹理和结构,本质区别在哪
做算法的人最喜欢问一句话:你的特征量到底区分的是什么?纹理和结构在视觉上不难分,但数学上的差异并不是“梯度大小”。砖墙的缝隙和墙面的边界都是高梯度,草地上一根叶子的边缘和远处山峰的轮廓也都是高梯度,单纯靠梯度的绝对值没法把它们分开。
RTV 真正抓住的特征是两个:方向一致性和空间分布。结构边缘附近,所有像素的梯度方向基本一致。一条竖直的房檐边缘,它周围的梯度基本都指向水平方向,而且符号一致——亮的一侧统一偏左,暗的一侧统一偏右。纹理区域则相反,砖缝、草叶、布纹这些元素的方向随机、正负交替,在局部窗口内互相抵消。一篇纹理杂乱的图像,局部窗口里的梯度向量就像一堆各奔东西的箭头;一条干净的结构边,窗口里的梯度向量则像一队整齐行进的士兵。
这篇论文的核心贡献,就是把“方向一致/互相抵消”这件事变成了一个可以求导、可以放进优化框架里的量——相对总变分。
1.2 这篇文章到底解决了什么问题
在 RTV 之前,做纹理结构分离的主流办法有双边滤波、导向滤波、L0 平滑,各有各的缺陷。双边滤波和导向滤波本质上是局部加权平均,只对“低对比度纹理”有效,遇到强纹理、大幅度的纹理会把它们当成边缘保下来。L0 平滑是全局稀疏约束,能去掉低幅度的细节,但纹理如果对比度较高,它也会犹豫,容易把纹理的边缘和结构的边缘一起搞成“阶梯”。
RTV 的思路和前面都不一样:它不是在“平滑”和“保边”之间做折中,而是显式地建模“什么是纹理、什么是结构”。先定义问题,再设计特征,最后优化求解。这也是这篇文章后来被大量引用、被做成各种变体的原因——它把“纹理结构分离”从一个调参活变成了一个有明确数学定义的问题。文章里用大量实验证明了它在卡通化、去纹理、边缘检测等任务上的一致性表现,这也是我敢把它用进生产管线的原因。
2. RTV的核心思想:窗口内两种梯度的比值
2.1 窗口总变分与窗口固有变分
先定义两个量,分别叫 windowed total variation 和 windowed inherent variation,中文可以叫“窗口总变分”和“窗口固有变分”。
窗口总变分(记为 D)就是我们熟悉的梯度绝对值在局部窗口内的加权和:
D_x(p) = Σ g(p,q) · |∂_x S(q)|
其中 g(p,q) 是空间高斯权重,离中心像素 p 越远的像素贡献越小;求和范围是以 p 为中心的一个窗口。D 度量的是窗口内所有像素的梯度幅度总和,可以理解为这个窗口里“变化的剧烈程度”。
窗口固有变分(记为 L)则不一样,它是先把窗口内的梯度加起来再取绝对值:
L_x(p) = | Σ g(p,q) · ∂_x S(q) |
注意差别:一个是“先取绝对值再求和”,一个是“先求和再取绝对值”。就这么一个顺序差别,正好抓住了纹理和结构的本质差异。如果窗口内的梯度方向一致、符号相同,求和之后不会抵消,L 会比较大。如果窗口内的梯度方向乱七八糟、正负交错,求和之后大部分都抵消了,L 会很小。
2.2 相对总变分:比值才是真正的判别量
RTV 的精髓就是用这两个量的比值作为纹理判别项:
R = D_x / (L_x + ε) + D_y / (L_y + ε)
ε 是一个极小的常数,防止除以零。对纹理窗口,D 大、L 小,比值 R 很大;对结构边缘窗口,D 和 L 同量级地大,比值 R 相对小。把这个比值作为正则项放进能量函数里,优化过程就会主动惩罚“R 大的像素”——也就是纹理区域,而结构边缘的惩罚很小,因此被保留。
放到生活里类比一下:D 就像一群人一会儿往前跑一会儿往后跑的总步数,L 是他们最终位移的绝对值。一堆人原地乱跑,总步数巨大但位移为零;一支队伍整齐前进,总步数和位移差不多大。用“总步数除以位移”这个比值,就能轻松区分“乱窜”和“挺进”。我当初第一次看到这个设计时,觉得这真是把一个问题想透了才写得出来的表达式。
2.3 目标函数与优化框架
把上面的思想写成完整的能量函数:
min S → Σ_p (S(p) - I(p))² + λ · Σ_p [ D_x(p)/(L_x(p)+ε) + D_y(p)/(L_y(p)+ε) ]
第一项是保真项,要求输出 S 不能偏离输入 I 太远;第二项是 RTV 正则项,λ 控制两项的权重。整个优化是让 S 在“贴近原图”和“去除纹理”之间找一个平衡。
这个目标函数里 D、L 都包含未知的 S 的梯度,所以不是一个直接能解的二次问题。论文的做法是固定点迭代:在每一步里,把 D/(L+ε) 当成已知的权重(用当前的 S 估计去算),这样目标函数就变成了关于 S 的加权最小二乘,转成一个稀疏线性方程组;解完更新 S,再重新算权重,如此迭代三四次就收敛。实际实现中,方程组的形式是 (I + λA)S = I₀,其中 A 是一个由 RTV 权重构造的稀疏矩阵,可以用共轭梯度法或高斯-赛德尔迭代求解。
3. 从公式到代码:完整实现流程
3.1 前置准备与梯度计算
动手写代码之前,先把变量说清楚。输入是一张灰度图 I(彩色图可以转 YUV 或者直接在 RGB 三个通道分别处理,后面会讲),输出是平滑后的结构图 S。
第一步是算梯度。建议用中心差分而不是前向差分,中心差分对噪声稍微稳一点,而且梯度对称。以 x 方向为例:
Ix(i,j) = (I(i,j+1) - I(i,j-1)) / 2
y 方向同理。注意边界补零或者复制。
第二步是构造空间高斯权重 g(p,q)。窗口半径根据 σ 定,一般取 ceil(3σ) 左右。标准的做法是生成一个和窗口同尺寸的高斯核,然后对 D 和 L 的计算做卷积。为了速度,可以把二维高斯核拆成行列两个一维核,分两次卷积。这一步在 OpenCV 里就是 getGaussianKernel 然后两次 filter2D,在 MATLAB 里就是 fspecial('gaussian') 加 conv2。不要小看这个预处理,窗口半径如果算错,后面的所有数值都会偏移。
3.2 计算窗口总变分和窗口固有变分
有了梯度和高斯核,D 和 L 的计算非常直接:
D_x = conv(gauss, |Ix|) D_y = conv(gauss, |Iy|) L_x = | conv(gauss, Ix) | L_y = | conv(gauss, Iy) |
L 的计算是“先混叠再取模”,千万不能先取绝对值再卷积,那样就退化成了 D,整个算法就废了。我见过有同学刚上手时在这里写反,出来的结果和普通高斯平滑没什么区别,还百思不得其解。其实这个顺序问题恰好是整个算法的命门:D 衡量局部梯度的总强度,L 衡量局部梯度的净和,两者结合才能把“方向抵消”的信息提取出来。
算完之后,RTV 正则权重 w = D_x/(L_x+ε) + D_y/(L_y+ε)。这个 w 是一个和原图同尺寸的权重图,纹理区域 w 大,结构边缘 w 小,后续的线性系统完全由它驱动。
3.3 稀疏线性系统的构造与求解
固定 w 之后,目标函数变成关于 S 的加权最小二乘问题:
min S → Σ_p (S_p - I_p)² + λ Σ_p w_p ( (∂_x S)_p² + (∂_y S)_p² )
注意这里把 RTV 正则项的绝对值形式近似成了平方形式。对 S 求导并令其为零,得到一个类似于各向异性扩散的稳态方程:
S - λ · div(w · ∇S) = I
离散化之后就是一个五对角的稀疏矩阵。实际写代码时,把算子的系数直接填到 scipy.sparse 或 MATLAB 的 sparse 矩阵里,然后用共轭梯度法求解。不少人第一次写到这里会卡壳,因为要手动处理“对每个像素取梯度、再把权重乘进去、最后散度回来”这一整套索引关系。我的建议是先画一个 5×5 的小图,把每个像素对应的矩阵行手写一遍,理解了规律再上完整图。
加快收敛的小技巧:把初始解 S 设成输入 I,权重 w 用输入图像的梯度来算。迭代一次之后,w 用新的 S 重新算。论文里一般迭代 2 到 3 次结果就稳定了,继续迭代变化很小,但耗时翻倍,性价比不高。
3.4 一套可直接上手的核心流程
我给出一个 Python 风格的伪代码流程,方便理解核心链路:
# S: 当前估计,初始化为输入图 I(float 类型 [0,1] 归一化) for it in range(3): Sx = center_diff(S, axis='x') # 中心差分梯度 Sy = center_diff(S, axis='y') Dx = gauss_filter(abs(Sx), sigma) # 高斯卷积 Dy = gauss_filter(abs(Sy), sigma) Lx = abs(gauss_filter(Sx, sigma)) Ly = abs(gauss_filter(Sy, sigma)) w = Dx / (Lx + eps) + Dy / (Ly + eps) A = build_sparse_operator(w) # 离散化 -div(w * grad) S = solve_linear_system(I + lam * A, I) # CG 或直接法用 Python 写的话,梯度、高斯滤波用 OpenCV 的 filter2D 和 Sobel 就能搞定,稀疏矩阵用 scipy.sparse 构建,再调 scipy.sparse.linalg 里的 cg 方法求解。图像尺寸在 1000×1000 以内,这个流程单次迭代大概一到两秒,完全可接受。如果只做一次离线处理,用 spsolve 直接求逆也可以,省去折腾预条件子的麻烦。
4. 参数调优与实践效果对比
4.1 三个核心参数的物理含义
RTV 有三个参数要调:λ、σ、ε,外加迭代次数。
λ 是正则项的权重,λ 越大,去纹理越狠,但结构边缘也会同步变钝。论文的默认值是 0.01,我实际测下来,0.008 到 0.02 是比较合理的工作区间。纹理很重,比如密集的砖墙、树叶,往 0.015 以上调;纹理本身已经比较弱,比如磨砂材质,降到 0.008 左右,再低就没什么效果了,纯粹变成轻微模糊。
σ 决定窗口大小,也就是“多大尺度的纹理算纹理”。σ 越小,窗口内梯度越容易方向一致,一些大纹理块可能被当成结构;σ 越大,去除的纹理尺度越大,但大结构边缘也会被影响。论文推荐 σ=3,这个值适合大多数自然图像。如果你的目标纹理很细密,比如布纹、木纹,σ=2 就够;如果目标纹理像砖墙那样有周期性的大花纹,可能要 σ=5 以上。还要注意,σ 与图像分辨率强相关,处理 4K 大图时要把 σ 适当放大,否则原本的细纹理在更高分辨率下会被当成结构保下来。
ε 只是避免除零的常数,一般取 1e-5 到 1e-3。取太小,在 L 接近 0 的纯平坦区域会出现数值上的大权重,容易产生局部过冲;取太大,会把 RTV 的判别能力稀释掉。我通常取 1e-4,表现稳定。
迭代次数默认 3 次。第一次迭代是最关键的,权重的初始估计决定了算法的基本走向;第二第三次是修正。不要在第一次迭代就为了追求效果把 λ 拉得很大,那样权重自己就不稳定了。宁可 λ 保持正常值,多迭代一次。
4.2 和双边滤波、L0平滑的实际对比
我拿一组室内照片做过对比。输入是带瓷砖和木纹的场景,目标是把墙面结构和家具轮廓提取出来。
双边滤波的结果是:细纹理被压下去一部分,但瓷砖缝这种高对比度的纹理被当成了边缘,保留了大量“假结构”;而且靠近真实边缘时容易出光晕,这是双边核在高对比度区域的固有毛病。
L0 平滑的结果:整体干净很多,但有两个问题。一是平坦区域被“分段常数化”,容易出现明显的阶梯感,后续做边缘检测时会出现很多假轮廓;二是纹理对比度一高,L0 就分不清纹理和结构,会留下残影。
RTV 的结果:纹理几乎被抹平,而窗边、桌沿这类结构边缘保持锐利。与前面两种方法最关键的差异是,RTV 的结果没有阶梯感,因为它不是把梯度强行置零,而是通过比值惩罚让纹理区域的梯度变小但不至于完全封死,过渡更自然。如果你要做卡通化,这种自然过渡比 L0 的硬分段更适合后处理。
4.3 调参过程中容易被忽略的细节
我踩过的坑里,最有代表性的一个是对彩色图像的处理方式。直接对 RGB 三个通道分别跑 RTV,容易在色块边界上出现颜色渗透,因为三个通道的纹理强度不一致,平滑程度不同。更好的做法是把图像转到 YUV 或 Lab 空间,对亮度通道跑 RTV,色度通道做较轻的普通高斯模糊即可,输出时再转回 RGB。纹理主要影响亮度,色度通道不需要强平滑,这样能最大限度保留原色彩真实性。
另一个容易被忽略的点是梯度和高斯核的边界处理。卷积推荐用 reflect 模式而不是 zero padding,否则边缘处权重大幅变化,图的第一圈像素会发黑或者发亮。这个细节在图像中心看不出来,但一旦你 crop 图像的边缘区域,问题立刻暴露。我曾在批处理大量小图时因为这个吃了大亏,跑出来的缩略图四周全是一圈暗边,排查了很久才找到原因。
5. 常见问题与排查实录
5.1 纹理去不干净,怎么办
现象是输出里还有明显的纹理残影,尤其是对比度高的周期纹理。排查顺序:先确认 λ 是否到位,一般要 0.015 以上;再确认 σ 是否匹配纹理尺度,如果单个纹理单元的尺寸接近 σ,它是不会被当作纹理的;最后检查权重计算里的 L,如果 L 的卷积核与 D 不一致,也会破坏比值。
有个容易被忽略的点:图像本身的对比度和动态范围。如果输入图整体偏暗或者对比度被拉满,D 和 L 的数值比例会变化,同样的 λ 效果差很多。处理前把图像归一化到 [0,1] 区间,会比直接用 0~255 的 uint8 稳定得多。我习惯在管线入口统一做归一化,后面所有算法都按浮点算,最后再转回 uint8。
5.2 结构边缘被削平、发虚
如果输出图里桌椅轮廓、人物边缘都变得雾蒙蒙的,多半是 λ 或 σ 偏大了。降低 λ 是一个方向,更有效的做法是缩小 σ,让窗口内更偏向于“局部方向一致”,这样结构边缘的 L 可以保持较大,比例不会被高估。
还有一种边缘发虚的隐蔽原因:迭代次数过多。权重每次更新都会对边缘产生一次“侵蚀”,迭代三次和迭代六次相比,边缘清晰度差异肉眼可见。如果你只关心边缘保留,把迭代次数固定在 2 次,用稍大的 λ 去补去纹理力度。实测下来这个组合在绝大多数场景都比“少 λ 多迭代”的效果好。
5.3 计算慢,怎么优化
RTV 的复杂度瓶颈在稀疏线性系统求解。三个思路:
第一,降采样。把长边缩到 1000 像素以内跑 RTV,得到平滑结果后上采样回原尺寸,再做一次轻量的导向滤波把细节贴回去。这个组合拳在工程里非常实用,处理 4K 图也能控制在几秒内。
第二,把高斯卷积换成积分图加速或用可分离滤波,二维高斯可分离,复杂度从半径平方降到线性。OpenCV 的 sepFilter2D 在这里明显比 filter2D 快。
第三,求解器换用带预条件处理的共轭梯度。我在 2000×2000 图上实测,带对角线预条件子的 CG 比默认 CG 快一倍以上。如果矩阵结构固定、多个输入只改右侧常向量,还可以预先做一次矩阵分解,后面每次求解就是一次回代,速度提升非常明显。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 纹理残留明显 | λ 过小或 σ 小于纹理尺度 | 增大 λ 到 0.015+,或增大 σ |
| 边缘发虚 | λ 过大或 σ 过大 | 减小 λ,同时把迭代次数降到 2 |
| 平坦区域出现斑点 | ε 过小 | 把 ε 从 1e-6 调到 1e-4 |
| 图像边缘发黑/发亮 | 卷积边界用了 zero padding | 改成 reflect 边界模式 |
| 彩色图有颜色渗透 | 直接对 RGB 分通道处理 | 转 YUV,只平滑 Y 通道 |
| 结果与论文效果差异大 | 没有归一化输入或迭代次数过多 | 输入归一化到 [0,1],迭代 2~3 次 |
6. 应用场景与周边工具
6.1 真实项目里 RTV 能帮上什么
我实际用 RTV 最多的场景有三个。
第一个是边缘检测的前处理。直接用 Canny 处理原图,草地、砖墙、树叶能产生成千上万条假边缘;先跑一遍 RTV,再上 Canny,输出干净到可以直接拿去当线稿。这个操作在文档扫描、建筑图纸矢量化、漫画线稿提取里都极其实用。
第二个是卡通化与风格化管线。RTV 输出作为区域平滑层,再做颜色量化,出来的色块既干净又有清晰的轮廓边界。相比直接对原图做 K-means 量化,先 RTV 后量化可以杜绝纹理导致的色块噪声。
第三个是图像融合和物体替换的辅助。把结构图作为融合权重的基础,可以避免纹理在融合过程中产生莫尔纹,也能显著减少接缝处的视觉跳变。还有人在做深度图预处理时利用 RTV 保持深度边缘同时抹平深度噪声,效果也比纯双边滤波好。
学者们在论文里还做了不少扩展应用,比如 HDR 色调映射的基底层提取、遥感影像的建筑轮廓提取等。原理都一样:先分离结构,再针对结构做后续处理。
6.2 关于 Intel Texture Works 这类插件的题外话
在图形工作者圈子里,“处理纹理”这个词很容易让人联想到 Intel Texture Works 这类 Photoshop 插件——它主要用于 BC 格式纹理压缩与 DDS 导出,服务于游戏资产管线。这里要提醒一句:工具层面的纹理压缩和算法层面的结构提取是两码事。前者解决的是“纹理存下来占用多少显存”,后者解决的是“图像里的结构和纹理怎么分开”。如果你在游戏项目里既要压缩纹理,又想要干净的轮廓辅助,正确的做法是把 RTV 放在预处理阶段,Texture Works 这类插件放在资源导出阶段,两者不冲突,也不可互相替代。理解它们各自的定位,能避免在工具选型时把两件不相干的事情混在一起。
6.3 扩展思路:把 RTV 用在自己的代码库里
如果你不想从零实现,可以直接使用论文作者公开的 MATLAB 代码,或者网上各种 OpenCV/NumPy 移植版。但我的建议是至少自己实现一遍核心的 D/L 计算和稀疏矩阵求解,因为真正干活时你会遇到很多作者代码没覆盖的情况,比如超大图分块处理、批量处理多张图、动态调整 λ。
分块处理时要注意块与块之间的重叠区域,直接用硬切会引入接缝。我的做法是每块向外扩 σ×2 的 margin,跑完 RTV 后只保留内部区域,接缝问题基本消失。批量处理时还可以把整批图像的 λ 做成一个和纹理密度相关的自适应函数,先用一个小窗口算出局部梯度方差,再映射成 λ 的缩放系数,效果比全局固定 λ 好不少。
最后再分享一个经验:RTV 框架最值钱的不是那个具体公式,而是“用窗口内梯度方向一致性来判别纹理”这个思想。我自己后来在做视频去纹理时,就是把 RTV 的比值项拿出来做时域引导,效果比直接逐帧跑 RTV 稳定得多,也避免了帧间闪烁。有时候,读懂一篇论文的最好方式,不是背诵它的目标函数,而是把它拆开、换一个场景重新组装一遍。