做图像处理的朋友大概都遇到过这种抓狂时刻:一张砖墙照片,你想提取墙缝和窗户的外轮廓,结果高斯模糊一上去,轮廓和砖面颗粒一起糊了;换成双边滤波,砖面颗粒倒是“倔强”地留下了,可墙缝边缘也保不住。这个矛盾我当年在做老照片线稿提取时被折磨得够呛,直到我试了基于相对总变分(Relative Total Variation,RTV)的结构提取方法。RTV最早发表在ACM TOG 2012,论文名就叫Structure Extraction from Texture via Relative Total Variation,它专门解决“去纹理但是保结构”这个看似矛盾的问题。RTV用的是一个非常聪明的局部比值,而不是全局阈值,能够在像素级判断这里到底是纹理、结构边缘还是平坦区,然后把判断结果融合进一个优化目标里。文章尾部我会提一下Intel Texture Works这类商用插件,方便你判断什么场景用现成工具,什么场景值得自己写RTV。本文适合对图像平滑、线稿提取、卡通化、OCR预处理感兴趣的开发者,我会把原理、代码、参数和坑一次讲透。
1. 结构提取到底想提取什么
1.1 结构、纹理还有平坦区,三者如何界定
很多时候我们说的“结构提取”,并不是做边缘检测,而是想把图像中语义上真正重要的轮廓、骨架、大尺度几何关系抽离出来,把那些重复出现的、细碎的、不影响大局的表面细节扔掉。比如一张建筑外墙照片,墙面的砖缝、窗户边线、屋檐轮廓是结构;砖块表面的烧结颗粒、水泥斑点、轻微色差是纹理。再比如一张布料照片,衣褶和领口形状是结构,织物经纬线形成的小花纹是纹理。
难点在于结构和纹理在频域上并不总是清晰分离的。砖缝本身也是高频边缘,砖面颗粒同样是高频变化;如果单纯用低通滤波,会把两者一起抹掉。如果单纯用边缘检测,纹理边缘数量可能远多于结构边缘,提取结果会像一张密密麻麻的蛛网。工程上一个比较实用的定义是:结构边缘是局部邻域内方向一致、辐射范围较大的梯度变化;纹理则是同尺度下方向随机、正负交替、彼此抵消的梯度波动。这个“抵消”的概念,是RTV方法的核心。
1.2 为什么普通滤波和TV在这里会翻车
我最早试用的是高斯模糊。它本质是一个低通滤波器,对整幅图像做加权平均,结果就是所有高频信息一起衰减。纹理没了,结构也软了,尤其在墙角、窗户这些对比强烈的区域,输出会变成一团带着光晕的橡皮泥。后来我换双边滤波,它考虑了像素值差异,确实能在平滑时保留一些边缘,但遇到对比度较高的纹理就很尴尬:砖面颗粒和砖缝一样是深色,双边滤波无法区分哪个是“该保留的边缘”,哪个是“不该保留的纹理”。如果你把空间距离和颜色差参数调狠,纹理是去掉了,结构也跟着变成不连续的黑点。
总变分去噪(Total Variation Denoising)比双边滤波更“懂”图像,它通过最小化梯度的L1范数来抑制振荡。但TV惩罚的是所有梯度,纹理区域的梯度会被抑制,可如果纹理幅度大、频率高,在有限次迭代里很难彻底抹掉;而结构边缘因为梯度更大,反而更容易被当作“重要细节”残留下来。实际效果经常是纹理去不干净,边缘却出现阶梯状伪影。几乎每种经典方法都有自己的死穴,而RTV从一开始就换了个问题角度:与其找“哪些像素像边缘”,不如先找“哪些局部区域是纹理”,然后在该区域加大平滑力度。
2. 相对总变分:用局部比值给像素“定性”
2.1 窗口梯度绝对值和:先知道局部到底“乱不乱”
RTV的出发点是把像素放到它的邻域里观察,这个邻域通常是一个带高斯权重的窗口。对每个像素 (p),算法计算水平方向的窗口梯度绝对值总和 (D_x(p)):
[ D_x(p)=\sum_{q\in N(p)} g(p,q),\left|\partial_x I_q\right| ]
其中 (N(p)) 是以 (p) 为中心的窗口,(g(p,q)) 是高斯权重,离中心越近权重越大。垂直方向同理,记作 (D_y(p))。这个量其实是在回答一个问题:这个像素周围,梯度到底有多“闹腾”。如果是砖面颗粒密集的区域,(D_x) 和 (D_y) 都会很大,因为窗口内每个像素几乎都有明显的明暗交替。如果是大块平滑墙壁,(D_x) 和 (D_y) 都非常小,接近零。到这里,它和我们熟悉的局部方差统计很像,还不能很好地区分纹理与结构。
2.2 窗口梯度抵消后的绝对值:结构边缘和纹理的真正分水岭
关键来了。RTV除了算“绝对值求和”,还要算一个“先求和再取绝对值”的量:
[ L_x(p)=\left|\sum_{q\in N(p)} g(p,q),\partial_x I_q\right| ]
(L_x(p)) 被称为窗口固有变分(Windowed Inherent Variation)。它和 (D_x(p)) 的唯一区别是:(D) 先把每个像素梯度取绝对值再加总,(L) 先把窗口内所有梯度按原始方向加总,再对总和取绝对值。可就是这一个区别,让纹理和结构现出了原形。
想象一条竖直的结构边缘:在它附近的窗口里,水平方向的梯度基本都朝同一个方向,比如左边暗、右边亮,那么所有梯度符号相同,加总后不会抵消。此时 (D_x(p) \approx L_x(p)),比值接近1。再看砖块表面的颗粒纹理:那些颗粒的明暗方向是随机的,有的地方左暗右亮,有的地方左亮右暗,在窗口里加总时正负梯度会互相抵消,导致 (L_x(p)) 很小,而 (D_x(p)) 因为取了绝对值,仍然很大。于是 (D_x(p)/L_x(p)) 就会变成一个明显大于1的数。平坦区域的 (D_x(p)) 和 (L_x(p)) 都趋近于零,加一个很小的 (\epsilon) 之后,比值趋近于0。
2.3 RTV的值域,其实是一个三分类器
把水平和垂直两个方向合起来,RTV为每个像素产生的指示量大致可以这样理解:
- 值接近1:这里是结构边缘,梯度方向一致,窗口内没有抵消。应该保留下来的S梯度。
- 值远大于1:这里是纹理区域,梯度方向混乱,窗口内抵消严重。后续优化会在这里施加强大的平滑惩罚。
- 值接近0:这里是平坦区域,梯度本来就不存在。不需要额外做任何事。
这个“比值”就是相对总变分(Relative Total Variation)。它妙在用一个无量纲的比值代替了绝对梯度大小,因此天然对手动设置阈值不敏感。砖面颗粒和小猫身上的毛,只要它们在局部方向上是随机分布的,RTV就会给出很大的响应,哪怕它们的绝对梯度值差异巨大。而一条弱边缘,即使对比度不高,只要方向一致,比值也会稳定在1附近。这一点是传统边缘检测算子很难做到的。
2.4 高斯窗口参数不是玄学,是尺度选择
窗口半径和加权方式决定了RTV的“视野”。如果窗口半径太小,比如只有1个像素,那么窗口内几乎没有机会让随机纹理的梯度互相抵消,RTV就无法区分纹理和结构。如果窗口半径太大,比如超过图像的主要结构尺度,那么结构边缘也会被大量周围梯度“淹没”,同样可能导致边缘处的 (L) 被纹理抬高,让RTV误把结构当成纹理。常见的做法是在半径3到5之间选,配合标准差1.0左右的高斯核。高斯权重的作用是让窗口中心附近的梯度贡献更大,远离中心的贡献递减,这样即使窗口内有几条不相干纹理干扰,也不至于把中心的结构判断完全带偏。
我在实践中还发现一个细节:如果把梯度图先做一次轻微的高斯模糊,再进入窗口统计,结果会更稳定。原因是原始梯度图上的单个噪点响应很尖锐,直接统计容易让窗口冒出一些极端的 (D) 或 (L) 值,轻微模糊相当于给统计一个更平滑的密度估计。
3. 从数学公式到可运行代码
3.1 目标函数的设计逻辑
知道了RTV的值还不够,我们真正想要的是让RTV去引导图像平滑。论文把RTV作为正则项的权重放进一个变分模型里:
[ E(S)=\sum_p\left(R_x(p),|\partial_x S_p|+R_y(p),|\partial_y S_p|\right)+\frac{\lambda}{2}\sum_p(S_p-I_p)^2 ]
其中 (I) 是输入图像,(S) 是要输出的结构图像。(\partial_x S) 和 (\partial_y S) 是 (S) 的梯度,(R_x,R_y) 是根据输入 (I) 计算出的RTV权重,(\lambda) 是数据项权重。
这个目标函数的意思是:第一项希望 (S) 的梯度尽量小,但不能一刀切地小——在纹理区域 (R) 很大,这个惩罚会被放大,所以 (S) 的纹理梯度会被强烈抑制;在结构边缘 (R\approx1),惩罚适中,(S) 可以保留边缘;在平坦区域 (R\approx0),几乎不惩罚,输出保持原来的平坦。第二项保证 (S) 不能离原始图 (I) 太远,防止过度平滑把图像变成一张白纸。可以看出,RTV不是先提取一个边缘图再用阈值二值化,而是把“哪里是纹理”的信息直接融进平滑过程里,所以结果不会有硬边或空洞。
3.2 迭代重加权最小二乘原理
目标函数里带有绝对值,不是光滑的二次函数,不能直接解。常见做法是迭代重加权最小二乘(IRLS)。基本思想是用一个二次函数去近似绝对值:
[ |x| \approx \frac{x^2}{2|x^{(t)}|}+\frac{|x^{(t)}|}{2} ]
其中 (x^{(t)}) 是上一次迭代得到的梯度值。在每次迭代里,把 (|x^{(t)}|) 当常数,于是目标函数变成关于 (S) 的二次型,求导后得到一个稀疏线性方程组。求解完得到新的 (S),再更新权重,反复两三次即可收敛。RTV权重 (R_x,R_y) 在迭代过程中保持不变,因为它们描述的是输入图像的纹理分布,不需要被输出图像影响。真正在迭代中变化的是IRLS的模长权重,它让算法逐渐逼近L1正则的几何行为。
3.3 核心实现代码
下面是一个可跑通的Python实现。为了可读性,我用Sobel算子计算梯度,用高斯核做窗口统计,用SciPy的共轭梯度法求解线性系统。它适合图像边长在几百像素级别的原型验证,生产环境可以换成GPU求解器。
import numpy as np import cv2 import scipy.sparse as sp import scipy.sparse.linalg as spla def diff_matrices(h, w): """构造水平和垂直前向差分矩阵,边界不做惩罚""" N = h * w # 水平差分:x[i, j+1] - x[i, j] row_inds = np.arange(h * (w - 1)) col_inds = np.arange(N).reshape(h, w) cols1 = col_inds[:, :-1].ravel() cols2 = col_inds[:, 1:].ravel() rows = np.concatenate([row_inds, row_inds]) cols = np.concatenate([cols1, cols2]) data = np.concatenate([-np.ones(len(cols1)), np.ones(len(cols2))]) Dx = sp.csr_matrix((data, (rows, cols)), shape=(h * (w - 1), N)) # 垂直差分:x[i+1, j] - x[i, j] row_inds = np.arange((h - 1) * w) cols1 = col_inds[:-1, :].ravel() cols2 = col_inds[1:, :].ravel() rows = np.concatenate([row_inds, row_inds]) cols = np.concatenate([cols1, cols2]) data = np.concatenate([-np.ones(len(cols1)), np.ones(len(cols2))]) Dy = sp.csr_matrix((data, (rows, cols)), shape=((h - 1) * w, N)) return Dx, Dy def rtv_structure(I, lam=0.02, radius=3, sigma=1.0, n_irls=3): """ I: 单通道float图像,取值范围[0,1] 返回结构图S """ h, w = I.shape N = h * w # 梯度 gx = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float64) / 8.0 gy = gx.T Ix = cv2.filter2D(I, -1, gx, borderType=cv2.BORDER_REPLICATE) Iy = cv2.filter2D(I, -1, gy, borderType=cv2.BORDER_REPLICATE) # 高斯窗口 k = 2 * radius + 1 gk = cv2.getGaussianKernel(k, sigma) g = gk @ gk.T # 窗口统计 Dx = cv2.filter2D(np.abs(Ix), -1, g, borderType=cv2.BORDER_REPLICATE) Dy = cv2.filter2D(np.abs(Iy), -1, g, borderType=cv2.BORDER_REPLICATE) Lx = np.abs(cv2.filter2D(Ix, -1, g, borderType=cv2.BORDER_REPLICATE)) Ly = np.abs(cv2.filter2D(Iy, -1, g, borderType=cv2.BORDER_REPLICATE)) eps = 1e-6 Rx = Dx / (Lx + eps) Ry = Dy / (Ly + eps) # 差分矩阵 Dx_mat, Dy_mat = diff_matrices(h, w) # 取相邻像素R的平均值作为边的权重 Rx_edges = 0.5 * (Rx[:, :-1] + Rx[:, 1:]).ravel() Ry_edges = 0.5 * (Ry[:-1, :] + Ry[1:, :]).ravel() # 迭代重加权 Iflat = I.ravel() S = Iflat.copy() eps2 = 1e-4 for it in range(n_irls): Sx = Dx_mat @ S Sy = Dy_mat @ S wx = 1.0 / (np.abs(Sx) + eps2) wy = 1.0 / (np.abs(Sy) + eps2) Wx = sp.diags(Rx_edges * wx, format='csr') Wy = sp.diags(Ry_edges * wy, format='csr') A = lam * sp.eye(N, format='csr') + Dx_mat.T @ Wx @ Dx_mat + Dy_mat.T @ Wy @ Dy_mat rhs = lam * Iflat S, info = spla.cg(A, rhs, x0=S, rtol=1e-5, maxiter=200) if info != 0: print(f"[warn] CG not converged at iter {it}, info={info}") return S.reshape(h, w)这段代码把RTV的计算分成两个阶段。第一阶段用输入图像 (I) 的梯度计算 (R_x,R_y),固定住。第二阶段用IRLS迭代求解 (S)。如果你跑出来的结果不理想,优先不是改代码,而是调 (\lambda) 和窗口半径:(\lambda) 越小,输出越平滑;半径越大,纹理越容易被识别,但结构也可能被误伤。
3.4 调参套路和效果预期
我在自己图像上跑这个模型时,习惯先用一组基础参数看趋势:(radius=3),(sigma=1.0),(\lambda=0.02),迭代次数3次。如果纹理还有大量残留,说明RTV权重没有把纹理区域“标记”得足够大,可以增大 (radius) 到5,或者把 (\lambda) 降到0.01。如果结构边缘开始变钝,则相反,调小 (radius),增大 (\lambda)。迭代次数一般不需要超过5次,我在第3次之后看到的视觉差异就很小了。需要注意,这里的 (\lambda) 是数据项权重,不是正则项权重,所以数值越大越保守。
还有一个容易被忽略的点:输入的灰度图范围要规范到 ([0,1])。如果你直接把0到255的图像丢进去,梯度和窗口统计的数值会整体放大,(\lambda) 的尺度就不对了。我习惯用I = I.astype(np.float64) / 255.0做个归一化,处理完再转回输出。
4. 实战案例:砖墙结构提取
4.1 原始图像与处理流程
我挑了一张典型的砖墙照片做演示,分辨率为600乘400。墙面的结构主要是砖块之间的灰缝,一条条接近水平,偶尔有竖缝错位;纹理主要是砖面本身的粗糙颗粒、局部色斑以及水泥残留。处理流程很简单:读取图像、转灰度、归一化,然后调用rtv_structure,参数设置为 (radius=4),(sigma=1.2),(\lambda=0.015),迭代3次。为什么窗口稍微放大?因为砖面颗粒在空间上分布比较密,3像素半径的窗口内抵消效果还不够好,放大到4更稳。
4.2 RTV输出解读
处理后的输出,最直观的感受是“干净”。砖面颗粒基本消失,原本密密麻麻的明暗噪点被抹成均匀的砖面色块,但砖缝依然是一条条清晰连贯的深色线条,墙面和窗户的边缘也保留了锐度。更难得的是,墙面和砖缝的过渡区域没有出现双边滤波常见的“光晕”或者TV去噪的“阶梯感”。这是因为RTV的权重是逐像素连续变化的,在结构边缘附近权重平滑地从高到低过渡,不会突然二值化。
如果你把输出图和输入图做差,会发现残差几乎都集中在砖面颗粒区域,而砖缝位置只有很小的改动。这说明RTV没有“无差别平滑”,而是把几乎全部平滑预算花在了纹理区域。这个特性在后续做线稿提取时很有用,因为从输出图上跑Canny,得到的边缘会非常干净,几乎不需要再做形态学后处理。
4.3 与双边滤波、TV去噪的直观对比
我同一个砖墙图上试过三种方法。双边滤波参数是 (d=9, sigmaColor=30, sigmaSpace=30),砖面颗粒还是若隐若现,把 (sigmaColor) 调大到80,砖缝边缘开始发虚,线条出现断裂。TV去噪我用的是经典梯度下降求解,迭代100次,纹理虽然淡了,但砖缝边缘也变细变淡,墙面出现一些模糊的色块。RTV的效果是三种里最符合“结构提取”预期的。
这并不是说RTV在所有场景都碾压其他方法,而是说在“结构化纹理”场景下,RTV用方向一致性作为判据,比双边滤波的“颜色相似度”判据和TV的“梯度幅值”判据更接近我们想要的语义。双边滤波更适合处理“噪声大但边缘强”的照片,TV去噪更适合处理“噪声均匀且没有重复纹理”的图像,RTV则专门对付“有重复纹理但需要保留几何结构”的情况。
5. 踩坑实录与常见问题速查
5.1 为什么我的边缘被削平了
最常见的原因是窗口半径太大。我有一次拿 (radius=7) 处理一张布料图像,结果衣服上最重要的几条褶皱也消失了,因为窗口把整个褶皱宽度包进去了,褶皱两边的方向不同的梯度在统计时被“类纹理化”,RTV给出较大的值。遇到这种情况,先看边缘处的 (R_x/R_y) 是否远大于1。如果确实是这样,说明尺度选得不合适。解决办法是缩小窗口半径,同时适当提高 (\lambda),让数据项把边缘拉回来。
还有一个容易忽略的因素:梯度计算用的边界填充。如果边界填充方式不当,图像边界附近会产生假的强梯度,导致边缘处的RTV异常。我用BORDER_REPLICATE而不是默认的BORDER_REFLECT,因为反射填充会把边缘附近的纹理方向镜像过来,干扰方向一致性统计。
5.2 为什么纹理还是“阴魂不散”
如果输出里纹理依然可见,通常是RTV的权重没有区分度。检查RTV统计量之前,可以先用少量代码打印一下纹理区域的 (R) 和结构边缘区域的 (R),看看数值差距。如果纹理区域 (R) 只在2到3的范围,而结构边缘也在1.5左右,说明窗口内抵消不足。原因可能是窗口内参与统计的像素太少,或者灰度梯度过稀疏。解决办法:一是调大 (radius);二是像我在2.4小节说的那样,对梯度绝对值做一次轻微的预平滑,相当于扩大有效感受野;三是如果图像是彩色图,别只转灰度,彩色通道里的纹理信息可能比亮度更强。
5.3 彩色图像到底怎么处理
简单粗暴的方法是逐通道处理,最后合成RGB,但这样容易在通道之间产生不一致,导致结构边缘颜色错位。更好的做法是先转灰度,计算出 (R_x,R_y) 权重,然后把这个权重共享到每个颜色通道进行IRLS求解。也就是说,纹理位置判断用亮度信息就够了,但平滑操作在RGB三通道同步执行。我在项目中实测下来,共享权重比逐通道处理在主观视觉上明显更稳,边缘不会出现彩色重影。
5.4 运行太慢怎么办
RTV的瓶颈在稀疏线性求解,图像越大越慢。原型阶段我建议先降采样到长边不超过500像素,提取出结构图之后,再把结构信息映射回原始分辨率,比如把RTV输出当作一个引导滤波的输入或边缘权重。另外,代码里那个稀疏矩阵 (A) 其实在整个IRLS过程中结构相同,只是对角线权重在变,可以用Cholesky分解或预分解来加速。真要做实时处理,就得把卷积和共轭梯度搬到GPU上,RTV的并行度很好,CUDA实现并不难。如果只是离线处理贴图,Python版本完全够用。
6. 现成工具还是自研算法?聊聊Intel Texture Works
6.1 Intel Texture Works能处理什么
最近总有人把RTV和Intel Texture Works插件混在一起问,这里单独说一下。Intel Texture Works是Adobe Photoshop的一套插件,主要面向游戏美术和纹理打包场景,功能包括BC格式压缩、纹理格式转换、mipmap生成、压缩质量预览等。它解决的是“贴图怎么存储、怎么压缩、怎么在引擎里看起来更好”的问题,而不是“这张贴图里的纹理和结构怎么分离”的问题。如果你只是想快速输出一套适用于引擎的BC7纹理,那Intel Texture Works确实省心,但你的需求如果是“从含纹理的图像里提取干净的结构线”,这个插件帮不上忙。
6.2 什么时候你其实不需要RTV
很多时候,我们的目标并不是严格的结构提取,而是贴图压缩前的一个预处理步骤。比如你只想把一张高分辨率砖墙贴图压缩成BC3或BC7,直接把原图丢给Intel Texture Works,夸张说“压完就能用”,因为压缩算法本身会牺牲一部分高频噪音,结构信息通常能保住。但如果砖面颗粒的细节和砖缝争抢码率,压缩后容易出现闪烁或banding,这时候可以先跑一遍RTV把颗粒抹匀,再交给压缩插件,结果会好很多。我的经验是:RTV适合放在“素材准备阶段”,而不是“输出阶段”。
6.3 RTV适合嵌入哪些实际流程
线稿提取是RTV最顺手的场景:卡通化、转手绘、漫画风预处理,用RTV先剥离表面纹理,再叠加轮廓检测,基本可以省掉后期一堆去杂点的步骤。OCR方向也可以先用RTV去掉背景纹理,再进文本检测,二值化结果干净许多。医学图像里,比如皮肤镜图像或显微镜图像,用RTV去除角质层纹理或组织杂色,结构区域会更突出。甚至做风格迁移时,RTV可以当作一种“结构保持平滑”的预处理,把纹理风格和结构风格解耦。总而言之,凡是“重复纹理干扰大、几何边界不能丢”的场景,RTV都值得先跑一遍试试。
我在实际使用中还有一个体会:RTV不是万能的。当纹理尺度和结构尺度接近时,比如一堆沙砾和大石头混在一起,RTV也会分得比较勉强,因为窗口内统计无法区分“方向凌乱的小尺度纹理”和“方向一致但很短的边缘”。这种时候可以先做一次形态学开闭运算,或者用小波变换把大尺度结构信息抽出来,再用RTV处理残差。最后分享一个真正救过我的小技巧:在计算 (R_x) 之前,把 (I_x) 的绝对值先做一次轻微高斯模糊,比如标准差0.8,再进窗口求和。这样既不会破坏方向的固有属性,又能显著压掉单点噪点带来的误判,输出会更稳定。希望这篇内容能让你少走点弯路,和纹理的这场仗,值得打好。