Pytorch 实现双边滤波

前几天研究了传统的美颜算法,了解到双边滤波(bilateral filtering)。在看懂原理后,为加深理解,抽时间用 pytorch 重新造了个轮子。虽然效率肯定比不上 opencv ,但当个小练习也不错。为了方便复习以及帮助初学者,在此记录。

高斯滤波

高斯核函数

图像领域的高斯滤波器是个二维的矩阵。矩阵中每个元素的值与它与矩阵中心的距离有关,计算公式就是二维高斯函数的公式:

G(u,v)=\frac{1}{2\pi\sigma^2}\exp(-\frac{u^2+v^2}{2\sigma^2})\tag{1}

为了让卷积前后的图像亮度保持不变,需要对 (1) 计算的矩阵归一化(除以矩阵所有元素的和),因此 (1) 中 exp 之前的系数部分可以省略。生成高斯滤波器的代码如下:

@torch.no_grad()
def getGaussianKernel(ksize, sigma=0):
    if sigma <= 0:
        # 根据 kernelsize 计算默认的 sigma,和 opencv 保持一致
        sigma = 0.3 * ((ksize - 1) * 0.5 - 1) + 0.8 
    center = ksize // 2
    xs = (np.arange(ksize, dtype=np.float32) - center) # 元素与矩阵中心的横向距离
    kernel1d = np.exp(-(xs ** 2) / (2 * sigma ** 2)) # 计算一维卷积核
    # 根据指数函数性质,利用矩阵乘法快速计算二维卷积核
    kernel = kernel1d[..., None] @ kernel1d[None, ...] 
    kernel = torch.from_numpy(kernel)
    kernel = kernel / kernel.sum() # 归一化
    return kernel

高斯滤波器

pytorch 自带的 conv2d 可以很方便地对图像施加高斯滤波,代码如下:

def GaussianBlur(batch_img, ksize, sigma=None):
    kernel = getGaussianKernel(ksize, sigma) # 生成权重
    B, C, H, W = batch_img.shape # C:图像通道数,group convolution 要用到
    # 生成 group convolution 的卷积核
    kernel = kernel.view(1, 1, ksize, ksize).repeat(C, 1, 1, 1)
    pad = (ksize - 1) // 2 # 保持卷积前后图像尺寸不变
    # mode=relfect 更适合计算边缘像素的权重
    batch_img_pad = F.pad(batch_img, pad=[pad, pad, pad, pad], mode='reflect')
    weighted_pix = F.conv2d(batch_img_pad, weight=kernel, bias=None, 
                           stride=1, padding=0, groups=C)
    return weighted_pix

关于 group convolution,如果不熟悉可以看我这篇回答:什么是「Grouped Convolution」?

双边滤波

高斯滤波器的权重完全由距离决定。在大块颜色差不多、偶有噪点的区域,它可以把颜色平均化,从而过滤掉噪点。但是在颜色变化剧烈的边缘区域,它还是一视同仁地把所有像素做加权平均,这让本应该清晰锐利的边缘也变得模糊不清了,这就造成了如下图所示的效果,在做人像美颜时是不希望看到的。

高斯滤波

双边滤波的权重公式也基于高斯函数。但和高斯滤波的区别是,决定卷积核权重的,不单纯是像素之间的空间距离,还包括像素之间的亮度差异。以卷积核中心为坐标原点,该处像素值为I(0,0)。那么,坐标为 (u, v) 处的像素,对应的权重为:
G(u, v)=\exp(-\frac{u^2+v^2}{2\sigma_d^2}-\frac{\|I(u,v)-I(0,0)\|^2}{2\sigma_r^2})\tag{2}

(2) 中 exp 的第一个指数项和高斯核函数相同,与像素的空间距离有关;第二个指数项则是像素值距离的函数。以e为底对这两项做指数运算,再相乘即得到了公式 (2)。根据公式 (2) 计算的卷积核有如下性质:

  • 距离中心像素越远的像素,其权重就越小
  • 亮度和中心像素亮度差异越大的像素,其权重就越小

第一条比较好理解,第二条的含义是,像素只和与自己相似的像素求平均,不和与自己差别太大的像素求平均。例如上图中,人脸部分的像素和背景部分的像素差异过于显著,这将导致在对边缘区域做卷积时,背景侧像素对人脸侧的像素的权重极小,反之同理。这就达到了保持边缘 (edge-preserving) 的特性。

代码实现

由于 (2) 中卷积核的权重不仅仅依赖于空间距离,还依赖于像素的亮度,因此卷积核的权重是不固定的,不能简单地利用 pytorch 的 conv2d 来实现。pytorch 的 tensor 自带了一个 unfold 方法,正好可以用在这里。
unfold 的作用是把图像拆分成 patch,每个patch 为卷积核覆盖的像素。下面举个小例子:

import torch
x = torch.arange(12).view(3, 4)
x
Out[4]: 
tensor([[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]])

# 沿着行,以步长 1 拆分 x,每个 patch 为 2 行,列保持不变,
y = x.unfold(dimension=0, size=2, step=1) 
y.shape
Out[6]: torch.Size([2, 4, 2])
y[0]
Out[7]: 
tensor([[0, 4],
        [1, 5],
        [2, 6],
        [3, 7]])
y[1]
Out[8]: 
tensor([[ 4,  8],
        [ 5,  9],
        [ 6, 10],
        [ 7, 11]])

# 直接对 y 的第二个维度拆分,例如拆分成 3 列,步长仍为 1
z = y.unfold(dimension=1, size=3, step=1)
z.shape
Out[10]: torch.Size([2, 2, 2, 3])

# 观察 z[0, 0],发现正是 x 左上角的六个元素
z[0,0]
Out[11]: 
tensor([[0, 1, 2],
        [4, 5, 6]])

# z[0, 1] 也同样符合预期
z[0,1]
Out[12]: 
tensor([[1, 2, 3],
        [5, 6, 7]])

实现的思路是:把原始图像 unfold 成一个个的 patch,对每个 patch 计算权重以及加权平均。代码如下:

def bilateralFilter(batch_img, ksize, sigmaColor=None, sigmaSpace=None):
    device = batch_img.device
    if sigmaSpace is None:
        sigmaSpace = 0.15 * ksize + 0.35  # 0.3 * ((ksize - 1) * 0.5 - 1) + 0.8
    if sigmaColor is None:
        sigmaColor = sigmaSpace
    
    pad = (ksize - 1) // 2
    batch_img_pad = F.pad(batch_img, pad=[pad, pad, pad, pad], mode='reflect')
    
    # batch_img 的维度为 BxcxHxW, 因此要沿着第 二、三维度 unfold
    # patches.shape:  B x C x H x W x ksize x ksize
    patches = batch_img_pad.unfold(2, ksize, 1).unfold(3, ksize, 1)
    patch_dim = patches.dim() # 6 
    # 求出像素亮度差
    diff_color = patches - batch_img.unsqueeze(-1).unsqueeze(-1)
    # 根据像素亮度差,计算权重矩阵
    weights_color = torch.exp(-(diff_color ** 2) / (2 * sigmaColor ** 2))
    # 归一化权重矩阵
    weights_color = weights_color / weights_color.sum(dim=(-1, -2), keepdim=True)
    
    # 获取 gaussian kernel 并将其复制成和 weight_color 形状相同的 tensor
    weights_space = getGaussianKernel(ksize, sigmaSpace).to(device)
    weights_space_dim = (patch_dim - 2) * (1,) + (ksize, ksize)
    weights_space = weights_space.view(*weights_space_dim).expand_as(weights_color)
    
    # 两个权重矩阵相乘得到总的权重矩阵
    weights = weights_space * weights_color
    # 总权重矩阵的归一化参数
    weights_sum = weights.sum(dim=(-1, -2))
    # 加权平均
    weighted_pix = (weights * patches).sum(dim=(-1, -2)) / weights_sum
    return weighted_pix

最终结果为下图,雀斑都没了!同时人脸的轮廓和五官的细节依然被很好地保留下来:


输入图片尺寸为 256 x 256,ksize=15,sigmaColor=0.15,sigmaSpace=5 。

需要注意的是,由于 bilateral filter 的权重和像素值相关,因此设置 sigmaColor 时要注意输入图像的像素范围,看清楚到底是 0-1 还是 0-255(上图像素范围为 0-1)。

总结

本文介绍了双边滤波的基本原理,并附带了 pytorch 的实现。虽然不如 opencv 快,但优点是 backward trackable ,适合包装为模块加入网络中。利用 unfold 实现的缺点是很占内存/显存,kernelsize 越大,unfold 出来的冗余数据就越多,如果有大神知道更高效的实现方式,还望不吝赐教。

后记

我发现网上搜到的很多磨皮祛斑的算法,主要的目标是设计一个高通滤波器,从而得到一个基于像素亮度的 mask,亮的地方权重大(对应皮肤区域),暗的地方权重小(对应雀斑、噪点区域)。将原图 I 和 模糊化的图 I_blur(各种模糊化方式都可以,目标是把较暗的斑点模糊掉)利用 mask 融合:I_mask+I_blur(1-mask) 。这种方法既保留了原图的细节,又能模糊掉斑点,不过在不同图片上应用时,仍然免不了调整一些超参数,而真有调参的功夫,直接调一下双边滤波的几个参数,最后得到的效果未必比那些复杂的算法差。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,188评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,464评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,562评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,893评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,917评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,708评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,430评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,342评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,801评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,976评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,115评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,804评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,458评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,008评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,135评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,365评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,055评论 2 355