组内新人培训(六):常用处理技巧之伽马曲线调整,还有卷积!

F@NAZOrip
F@NAZOrip 2018年06月01日
  • 在其它设备中阅读本文章

    在前面的几章学习中,我们在经历了一个非常亲切友好的开端之后,分别走过了不明觉厉的第二章(深层卷积神经网络Waifu2X)、似懂非懂的第三章(Expr工具)、不明觉厉的第四章(客观评价算法与分辨率效率)、以及一脸懵逼的第五章(贝塞尔曲线与幼儿园水平的动态侦测)。不知你的心态是否也和我描述的一样一脸懵逼了。

    这些章节之间看似想到哪写到哪(实际上就是这样),但这也是由于我们教学的定位在于对网络上既有视频压缩方面的文章进行补充。具体来说就如同大家在组员教学第一章附录中看到的,除了06taro的mask工具教学外,vcb也对Vapoursynth的大部分常用工具进行了科普,包括其中“底层”级别的科普;以及各种论坛里野生大神们零散的发言,这些都是非常好的教学资料,已经有的,我们实在没有必要浪费时间再赘述一遍。

    不过鉴于有的同学从一开始就是跟着组长一路学下来,又或者我知道些同学对文字的反应没有图像那么直观,所以虽然忙我还是抽出时间,在今年冬天快要结束的时候终于把视频教学的第二章也录了出来。这样有一个好处,就是通过快速梳理的方式整理知识点,使我们的教学终于也不再是断层的,而同样具备了一条完整的脉络曲线。

    在视频教学第一章中我们给大家演示了一个Vapoursynth压制的实现方式,作为一个实做DEMO叫你看看效果;而后我们从较易讲解的编码器参数开始,简述了高压缩动画的常用参数对压缩机制的影响,并涉及了一点点点点底层知识(包括IPB、量化与CRF系统,因为一些更深层的逻辑对于初学者来说可能过于抽象,这一点也许我们会在后续教程中补完)。紧接着在第二章的视频教学中我们从VS端插件的基本使用方法开始讲起,从教你怎么找插件、怎么看文档学习使用方法开始,讲到vs四大件主要工具(限制器,分离器,调色板,遮罩)为止。于是,理论上大家在学习完视频教学中的内容后,终于可以开始承接官网的组员教学第二章,开始更进一步的“自由学习”了。(新来的同学有兴趣可以去B站观看视频,https://www.bilibili.com/video/av21146864

    那么,不再多废话,我们开始进入本章的教学。
    继上一期的贝塞尔曲线为我们提供了具有高操控性的、普适性的曲线工具后,本章我们要讲另一条操控性不高,但对视频处理同样重要的曲线:伽马曲线。

一、伽马曲线是什么?

    按照惯例我们先讲是什么,再讲为什么。

    看漫画的同学应该都知道,我们看到的绝大多数漫画是有人买来,然后扫描,然后发到网上的。但是如果你有找日文版的习惯,那你应该已经习惯绝大多数的扫描都会出现颜色过爆或过暗的情况(如下图左侧),这是由扫描仪的物理特性决定的,很难改变。那么汉化组是通过什么样的操作把这些质量不良的扫描图处理回你看到的汉化版的颜色鲜艳的状态的呢?不难猜到,就是通过PS中提供的伽马曲线调整工具了。

(↑调整后如图右侧,虽然PS中曲线用的是spline并不是gamma,不过这里就不要在意这些细节了)


    ↑直观来看,伽马曲线就是上图这样的一条曲线,它被用来描述灰阶。灰阶大家都清楚,也就是比如你在vs中用ShufflePlanes把YUV平面中的Y单独切出来,这个时候它在VS的储存格式就会变成vs.GRAY的这样一张只有灰度的图像。在vs.GRAY中,比如说在10bit下,0就是最暗,然后1就会比0亮一些,2则更亮一些,以此类推一直到最亮的1023。亮度是线性递增,而我们在上一章中讲解的贝塞尔曲线则是教给大家把这条直线变成曲线的方法,对吗?

    对又不对,实际上在上一章中我们所看到的“线性关系”,它其实是一条曲线,为了理解更容易、操作更简便,我们对Y平面进行预伽马校正把它调成了一条直线。其实大家潜意识里已经知道它不是直线,只不过以前有些问题是我们刻意忽略的,例子之一就是比如我们想起,在以前的视频处理中,各种大佬经常告诫各种萌新说分辨率缩放要在RGB下做而不要在YUV下直接做,当时大佬们没有直说原因,实际上出处就在这里。

    好,那么到目前为止,你说直线好理解所以要调成直线我懂,但是为什么说它本来是一条曲线呢?

    为了理解这个问题不妨来做这样一个设想。假如说你原本是一个有妹有房、父母双亡的快乐肥宅,但是人到中年你老婆却背着你跟高富帅私奔了,这导致你开始自暴自弃,辞了工作宅在家里打游戏。后来由于没有工作,生活无以为继于是以房子为抵押去借了高利贷,最终由于还不上钱被黑社会抓起来关在一个容积为2立方米的密封铁质容器里,准备明天一早就把你沉到河里......假设说现在是这样一个标准B级片剧情。

    而你坐在铁箱子里,里面没有一点光,你一摸口袋,突然掏出一盒火柴,这个时候你点亮了一根火柴,四周瞬间明亮了起来。这个情景大家应该不难想象。我们不妨再假设一个情况,就是假设你的火柴是核能的,不会熄灭,那么你不断地点燃火柴,显然这个铁盒子内会越来越亮。而当你已经点燃了1023根火柴,紧接着又点燃了第1024根火柴的时候,很显然,你会感觉1024根火柴的亮度相比于1023根似乎没什么变化,因为之前已经够亮了。

    这个小故事告诉我们这样一个道理(远离高利贷),我们理性上很清楚,点亮的第1根火柴、与点亮的第1024根火柴,他们是同样的火柴,对亮度的贡献应该是相同的。但是你对后者的亮度变化的感知却远没有前者来的明显,毕竟第一根火柴刚点亮的时候你的狗眼都要被闪瞎了。

    这是什么意思?意思是N倍的光射入人眼,人感觉到的并不一定是N倍的亮度。自然界给人眼一个刺激,人眼反映给大脑一个信号,而当这种刺激越强烈,你对这种刺激反而越来越不敏感,不难理解,人类在视觉、听觉、味觉、痛觉等方面都呈现这类特征。实际上人眼对自然光摄入量的感知与感性上亮度的理解大概是这种关系(盗图):

    人眼认为的黑和白的中间点(我们认为的0.5白),实际是自然界中大概只给了0.2份光。这也就是为什么我们在之前的教学里曾经提到,“当涉及到去色带问题的时候,由于人眼对暗场更加敏感,给暗场和亮场的去色带强度不应相同”。

    说到这里,我们可以比较好地解释伽马是什么,以及曲线调整产生的原因了。
    知乎上有一个答案说的很好,该观点认为伽马曲线的产生有两个必要条件:

  • 人眼对自然亮度的感知是非线性的。
  • 我们用来记录自然界信息的媒介是物理上有限的。

    即由于人类想找到描述自然界中一幅图像的方法,但无奈人眼对自然界的感知又不是线性的,再者加上人类能选取的采样点又不可能是无穷多个。这些总和在一起,产生了一个问题:如果直接按自然界中照光的能量(线性)来描述图像的话,我们今天看到的所有图像中偏黑的那一半信息,都会被压缩在0-20%的空间内(8bit下大概只有40多个灰度),这显然会导致啥也看不清、色带丛生等各种各样的问题。于是人类发明了伽马曲线调整灰阶,将这20%的内容调整到以50%的范围表示,使上述问题得到大幅缓解,于是也就得到了今天我们看到的一半是亮、一半是暗的灰阶图了。
    
    (https://www.zhihu.com/question/27467127/answer/37555901)

二、那么在哪里才可以买得到呢?

    说了这么多伽马曲线的前世今生。重点是它有什么用呢?

    跟随我们视频教学一路学下来的同学都知道,我们鼓励大家要建立图形处理的思想。也就是说我们学习VS并不是要学习使用几个插件,或者比如学着按某种顺序操作就可以抗锯齿,学习并不是指这么无聊的事情。我们说的学习更多的是指,要建立视频处理的思想:层思想、遮罩的使用都是其中典型代表。

    而伽马曲线调整同样是一种重要的曲线变换思想,这也是我们在文章的最开头说它重要的原因。由于伽马曲线本身的特性(公式见下文,这里暂不表),伽马校正具有可逆性(*注1),比如我们定义函数f(n,gamma),假设gamma=2,输入n=10000,那么f(10000,gamma)会返回一个值是1526。同样地假设我们将结果带回f(1526,1/gamma)它又会被还原成10000。

    加之,伽马曲线调整的物理特性是(如果你手头有PS这类可以进行伽马曲线调整的工具的话不放自己拉一拉曲线试一试),当Gamma矫正的值大于1(曲线下凹)时,图像的高光部分会被压缩而暗调部分被扩展,当Gamma矫正的值小于1(曲线上凸)时,图像的高光部分会被扩展而暗调部分被压缩。

    这就给我们提供一个新的思路,也就是说是否可以通过伽马曲线调整,来让某种处理在更合适的环境下进行,而后再缩回,这是否能达到,让处理更加明显的效果呢?更进一步地、如果结合层思想,我们能否通过贴回一个“增强处理层”的方式来加强处理效果呢?人有多大胆地有多大产,能运用到什么程度大概只限制于你的想象力有几何了。

马上来动手试试,以下示范两种典型应用:

    1、比如说暗场纹理增强,对很多人来说是一个非常头痛的问题。下图是我组压过的末日时在做什么第一话截图。大家注意左边的窗栏上的纹理在暗场下是较难辨识的,如果mask设计不佳导致这部分被识别为大平面,甚至被施加了去色带的话,我们会丢失很多细节。

而如果我们略施小计,使用gamma将暗场细节伸张,大家可以明显地看到无论是mask框选或是处理都会简单许多。

    2、另一种应用,同样是这张图。vs中我们最常用的mask接口是TCanny,我们都知道由于Canny边缘侦测的特性导致其对暗部信息敏感性不如亮部信息,典型效果如下图:参考上文的原图,大家可以注意到红色标记区域的mask明显没有蓝色区域来的健壮,因为很明显原图中前者没有后者的明暗分界明显。而这会导致一些问题,比如说如果要使用这个mask做主体mask,那么被标记为灰色的区域是会被识别为平面且被去色带插件平滑的,如果处理力度大甚至可能导致烂线。

    那么要怎么办呢?这里我们引入伽马曲线试试,我们知道当伽马值大于1时画面高光会收缩而暗调会伸张,而如果在此基础之上将处理前后平面做差,那么显然暗调区域的分离度会大于高光。具体感觉大概像下图:

    我们可以看到红色区域对比度明显增大(甚至变成了黑与白的高对比),而蓝色区域则变得难以区分界限,我们得到了如预想般的结果。这时如果在此之上重新使用TCanny,会得到如下图的、将原本不健壮的区域补完的mask:

    注意框选区域的差异。至此我们分别获得了两份原始素材,以此为基础构建健壮的主体mask也就不再是难事了。

    最后说一下公式。
    二次伽马曲线调整的公式是非常简单明了的:

    

    当然了,对于我们接触到的绝大部分动画来说纯黑到纯白并非0-255而是16-235,由于不能让0凭空从某个大于0的正数开始起跳,所以往往我们使用时还要做TVrange的适配,但这些都属于细枝末节的琐事了,这里不再细讲。最后我们结合公式,简单把上文的例2写成vs实现供大家参考:

import vapoursynth as vs
import mvsfunc as mvf

core = vs.core
core.num_threads = 8
core.max_cache_size = 4000

# 定义整数运算的位深转换器
def Scale(val, d):
    return val * ((1 << d) - 1) // 255
    
# 伽马曲线
def gamma_curve(clip,gamma):

    def lut_y(x):
        floa = min(max(int(x/Scale(235-16,16)*65536),0),65535) / 65535
        gammaed = floa ** gamma
        return min(max(int(gammaed*Scale(235-16,16)+Scale(16,16)),Scale(16,16)),Scale(235,16))
    
    clip_y = mvf.GetPlane(clip,0)
    clip_y = core.std.Lut(clip_y, planes=[0], function=lut_y)    
    return core.std.ShufflePlanes([clip_y,clip], [0,1,2], vs.YUV)

# 列表检查    
def check(clipa,*args):
    sformat,args = clipa.format,list(args)
    forma,name,bit = sformat.color_family,sformat.name,sformat.bits_per_sample
    for i,t in enumerate(args):
        bit2 = args[i].format.bits_per_sample
        if args[i].format.color_family != forma:
            if forma == vs.YUV:
                css = '420' if '420' in name else ('444' if '444' in name else None)
                args[i] = mvf.ToYUV(args[i],css=css,full=False,depth=bit)
        elif bit2!=bit:
            args[i] = dep(args[i],bit)
        args[i] = args[i].std.AssumeFPS(clipa)
        if t.width != clipa.width or t.height!=clipa.height:
            args[i] = core.resize.Spline16(args[i],clipa.width,clipa.height)
    rl = [clipa];rl.extend(args)
    return core.std.Interleave(rl)

src16 = mvf.Depth(core.lsmas.LWLibavSource('...'),16)

invert = core.std.Expr([gamma_curve(src16,1.1),src16],['x y - 25 *',''])
mask_1 = mvf.GetPlane(core.tcanny.TCanny(src16, sigma=1, mode=1,op=2),0)
mask_2 = mvf.GetPlane(core.tcanny.TCanny(invert, sigma=1, mode=1,op=2),0)
mask = mvf.Max(mask_1,mask_2).std.Maximum().std.Minimum()

check(src16,mask_1,mask_2,mask).set_output()

    你当然可以尝试使用其他曲线进行类似的操作,比如你想试试前面章节中讲的bezier或者spline等等,gamma与他们并没有本质区别,差别只在于gamma 1、对暗场更强的亲和力;2、更快的运算速度(至少gamma并没有专门用C实现的插件);以及3、更简易的可逆性上。关于可逆性,为了行文逻辑的连贯我们把上文注1位置的备注放在这里,即,虽然伽马缩放是可逆的,但很显然将50%压缩的灰度到20%的灰度,即使可以还原,其中也丢失了若干信息。具体表现即为,例如16bit下10000和10001的gamma=2的变换取整后很可能是同一个值(1591),即如果直接进行压缩与还原会导致图像出现高斯误差。这种特性使得我们并不会在全图泼撒性的处理中使用伽马曲线。

    除了上文范例外常见的自定义伽马调整操作还有诸如伸张亮部的dering、伸张暗场的deband、梯度NoiseMask等等,都是非常常见的操作,甚至可以说是标准操作。这里不再逐个展开,解放思想,更多的操作等待你自己去发现。

三、卷积

    本来卷积这部分内容是不打算讲的,毕竟Canny和Sobel都有人打包好了,vs里虽然天天和卷积打交道但实际上完全脱离开卷积概念也无妨。然而,由于我们之前一不小心开了一个跟随MXNet官网范例讲解CNN深度学习的新坑,所以....该来的总是会来,看来我们似乎还是要讲讲卷积。

    数学中对于卷积的定义相对来说是颇复杂的,连续卷积使用积分,相比之下离散定义要简单一些,定义式分别如下:

    如果你去问数学家什么是卷积,他会告诉你你往水里扔一个石子(信号),水面上就会生成一个波纹(反馈),假设你以无限快的速度飞快地往水里扔石子,那此时的湖面就形成了一个卷积。
    
    接着他又说:同样地,如果你没听懂,我要一巴掌拍在你右脸上(信号),你的右脸中毛细血管破裂就会略显红肿(反馈),如果我越拍越快,当速度快到无限快的时候,你的脸会此起彼伏地红肿....此时你的脸上就是一个卷积。
    

    所以你听完他们的描述恍然大悟了一个道理,听什么都不要听数学家扯淡。
    的确你经常会觉得,有时候数学如果不像一门宗教一样神秘的话,那数学家便难以继续忽悠人。如果我们换一种简单的说法的话,卷积简单来说本质是反馈在输入上的加权叠加。

    这就完了?没错这就完了,上面说的那些都是屁话。
    甚至在图形学中卷积理解起来还要更简单一些,不用考虑什么无限快,图形学中没有连续,不光没有连续,甚至不用翻转。其实早在我们讲解之前你就已经是卷积高手了,为什么?因为大家早已用过无数遍卷积,你平常使用的minblur、上面说的Canny和Sobel,实际上全都是卷积,你写一个脚本要用一万遍。

    举个栗子
    假设我们有一张如下的5x5图片A(8bit),与一个卷积核h,卷积运算中我们称卷积核为算子。

A = [17  24   1   8  15            h = [8   1   6
     23   5   7  14  16                 3   5   7
      4   6  13  20  22                 4   9   2]
     10  12  19  21   3           
     11  18  25   2   9]

    那么如图所示,如果我们要利用卷积求第二排左数第四个点的值,只需要计算它周围的点(3x3矩阵)与卷积核的内积。即1*8+8*1+15*6+7*3+5*14+7*16+4*13+9*20+2*22=585,运算后这个点的值就会是585,如下图所示

    以此类推,我们不断滑动卷积核,直到求出所有点的值后,就得到了一幅新的图,这幅图会是3x3大小的(因为5x5大小的最外一圈像素周围没有9个像素,故无法进行卷积运算,削除最外一圈后剩下3x3像素)。

    看到这里你应该明白了,实际上我们平常经常用的rg20就是进行算子为[1,1,1,1,1,1,1,1,1]的运算,rg19则是[1,1,1,1,0,1,1,1,1]。(当然了,实际上vs处理并不会让图片每次都小一圈,这是因为它使用了一些技巧,诸如事先在图片外围补一圈0之类的方法来保证输入输出相同。包括vs当中为了保证输入输出的定义域相同,还会给每一个结果,比如上文的585,除以sum(h)=45,将其还原为一个8bit下的色彩(反之卷积神经网络中由于没有进行这种统一线性缩减的必要,所以不会有这一步操作),这些都是细枝末节的事情这里不多说)

    那么讲到这里我们已经明白卷积了。不过卷积存在的意义难道就只是为了求平均运算?为了模糊图片?显然不是,一旦定义卷积后它能实现的操作要丰富得多。

    再举一个例子,假设我定义一个算子k如下:

[1,0,-1
 1,0,-1
 1,0,-1]

    我们不难想象,当这个算子被作用于一个纯色的平面区域的时候,由于sum(k)=0,所以左边的1会和右边的-1相消,导致每个像素的运算结果都是0。而一旦平面上出现线条,则对于线条边缘的像素,由于输入值不是个个相同,1和-1无法相消,会得到一个大于0的数。如此,我们是否就得到了一种运算,能让平面区域都是黑色(0),而将线条区域标记为白色(大于0),这是否就成为了某种mask接口呢?

    更进一步地,根据我们对矩阵内积的物理意义的理解,我们知道向量A与B的内积,代表A在B方向上的分量。当A与B正交(垂直)时内积为0。所以说,当我们用卷积核扫描一张图片,实际上是在进行相同模式匹配。如果某个3x3矩阵与卷积核高度相似,那么他们会得到非常高的数值,令该区域的高亮。反之如果相似程度低,则该区域会变成黑色。这在侦测中是相当有用的技巧。


    举个卷积的简单应用,比如边缘侦测的另一个常用算子Kirsch(樱桃酒):

# vs为我们提供了一个自定义卷积接口std.Convolution()
# 不妨我们直接用来实现Kirsch边缘侦测
import vapoursynth as vs

core = vs.core
core.num_threads = 8
core.max_cache_size = 4000
   
def kirsch(clip):
    kirsch1 = clip.std.Convolution(matrix=[ 5,  5,  5, -3,  0, -3, -3, -3, -3])
    kirsch2 = clip.std.Convolution(matrix=[-3,  5,  5,  5,  0, -3, -3, -3, -3])
    kirsch3 = clip.std.Convolution(matrix=[-3, -3,  5,  5,  0,  5, -3, -3, -3])
    kirsch4 = clip.std.Convolution(matrix=[-3, -3, -3,  5,  0,  5,  5, -3, -3])
    kirsch5 = clip.std.Convolution(matrix=[-3, -3, -3, -3,  0,  5,  5,  5, -3])
    kirsch6 = clip.std.Convolution(matrix=[-3, -3, -3, -3,  0, -3,  5,  5,  5])
    kirsch7 = clip.std.Convolution(matrix=[ 5, -3, -3, -3,  0, -3, -3,  5,  5])
    kirsch8 = clip.std.Convolution(matrix=[ 5,  5, -3, -3,  0, -3, -3, -3,  5])
    return core.std.Expr([kirsch1, kirsch2, kirsch3, kirsch4, kirsch5, kirsch6, kirsch7, kirsch8],'x y max z max a max b max c max d max e max')

src = core.lsmas.LWLibavSource('...')
kirsch(src).set_output()

    根据上文的讲解这里我们很容易理解,kirsch分别使用8个算子对不同3x3矩阵内的所有可能方向扫描,当任何一个方向上(无论是横竖或者左斜右斜)出现了由高到低的落差,都会被标记为白色,由此显然可以强力侦测线条。

    理解原理后我们就可以很容易地做一些改进,比方说将原版卷积核[5,5,5,-3,0,-3,-3,-3,-3]改成平衡的[3,3,3,3,0,-3,-3,-3,-3],然后再接一些后处理,做成一个增强版Kirsch。大家可以把下面这段代码跑跑看:

import vapoursynth as vs
import mvsfunc as mvf

core = vs.core
core.num_threads = 8
core.max_cache_size = 4000

def kirsch(clip):
    raise Exception('原版kirsch直接复制上文的代码')

def Enhanced_kirsch(clip, low = 25, high = 60):
    kirsch0 = kirsch(clip)
    kirsch1 = clip.std.Convolution(matrix=[ 3,  3,  3,  3,  0, -3, -3, -3, -3])
    kirsch2 = clip.std.Convolution(matrix=[-3,  3,  3,  3,  0,  3, -3, -3, -3])
    kirsch3 = clip.std.Convolution(matrix=[-3, -3,  3,  3,  0,  3,  3, -3, -3])
    kirsch4 = clip.std.Convolution(matrix=[-3, -3, -3,  3,  0,  3,  3,  3, -3])
    kirsch5 = clip.std.Convolution(matrix=[-3, -3, -3, -3,  0,  3,  3,  3,  3])
    kirsch6 = clip.std.Convolution(matrix=[ 3, -3, -3, -3,  0, -3,  3,  3,  3])
    kirsch7 = clip.std.Convolution(matrix=[ 3,  3, -3, -3,  0, -3, -3,  3,  3])
    kirsch8 = clip.std.Convolution(matrix=[ 3,  3,  3, -3,  0, -3, -3, -3,  3])
    
    # kirsch增强
    full_mask = core.std.Expr([kirsch1, kirsch2, kirsch3, kirsch4, kirsch5, kirsch6, kirsch7, kirsch8, kirsch0],'x y max z max a max b max c max d max e max f max') 
    bit = clip.format.bits_per_sample
    low,high,high_end = scale(low,bit),scale(high,bit)-1,(1<<bit)-1

    def low_cut_lut(x):
        return min(max(int((x-low)/(high_end-low)*high_end+0.5),0),high_end) if x > low else 0

    # 筛除孤立点    
    low_cut = core.std.Lut(full_mask,function = low_cut_lut)
    maximum = core.std.Maximum(core.std.Expr([full_mask],['x {0} > {1} x ?'.format(high,high_end)]))
    return core.std.Expr([low_cut,maximum],['y {0} = x 0 ?'.format(high_end)]).std.Maximum().std.Minimum()

src = core.lsmas.LWLibavSource('...')
Enhanced_kirsch(mvf.GetPlane(clip,0)).set_output()

    再比如,我们再来讲讲大家用过一万遍的TCanny。以前我们一直把这个东西当成黑盒来讲,现在讲过卷积之后我们终于可以......见识一下Canny长什么样子:

↑上图就是John.F.Canny本人,他在28岁时提出了Canny算法,直到今天仍然是最有效的边缘侦测算法之一。

    为什么Canny边缘侦测如此牛逼呢?因为JFKanny大佬在研究过最优边缘检测方法所需的特性后,给出了评价边缘检测性能优劣的3个指标:

  • 好的信噪比,即将非边缘点判定为边缘点的概率要低,将边缘点判为非边缘点的概率要低;
  • 高的定位性能,即检测出的边缘点要尽可能在实际边缘的中心;
  • 对单一边缘仅有唯一响应,即单个像素我们可以膨胀得到多个,而反之则定位困难;

    根据以上指导思想,Canny发明了以下五步走的边缘侦测方法:

    1、利用高斯卷积核进行模糊:排除高频干扰。高斯模糊大家是很熟悉的,比如vs中的tcanny(mode=-1)、类似的还有rg11等,简单查找二维高斯函数公式如图:
        

用上一章当中讲过的matplotlib跑一跑,得到优美的高斯分布:

import numpy as np # numpy是py的运算模块,这里不展开
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

# 一维高斯函数
def gauss_1(x,sigma=1,x0=0):
    den = 1/sigma/np.sqrt(2*np.pi)
    num_0 = -(x-x0)**2 / (2*sigma**2)
    num = np.exp(num_0)
    return den*num

# 显示一维高斯函数
x = np.arange(-5,5,0.01)
y = gauss_1(x)
plt.plot(x,y)
plt.show()

# 二维高斯函数
def gauss_2(x,y,sigma=1,x0=0,y0=0):
    den = 1/2/np.pi/sigma**2
    num_num = (x-x0)**2+(y-y0)**2
    num_den = 2*sigma**2
    num = np.exp(-num_num/num_den)
    return num*den

# 显示二维高斯函数
figure = plt.figure()
ax = Axes3D(figure)
x = np.arange(-3, 3, 0.07)
y = np.arange(-3, 3, 0.07)
x, y = np.meshgrid(x, y)
z = gauss_2(x,y)
ax.plot_surface(x, y, z, rstride=1, cstride=1, cmap='rainbow')
plt.show()

所以一个sigma=1的3x3的高斯卷积核大概长这样:
(之后convolution模糊就行了,当然了因为vs默认对浮点数取整所以可能我们还需要处理一下数据)

[[ 0.05854983  0.09653235  0.05854983]
 [ 0.09653235  0.15915494  0.09653235]
 [ 0.05854983  0.09653235  0.05854983]]

    2、利用Sobel算子进行边缘侦测:Sobel同样是一个经典边缘侦测方法。与上文选择的简明直观、有助于理解的Krisch不同的是,Sobel只在横向和纵向两个方向上进行侦测。卷积核如下:

Gx = [-1,0,1     Gy = [+1,+2,+1
      -2,0,2            0, 0, 0
      -1,0,1]          -1,-2,-1]

之后将横向与纵向侦测结果混合,得到完整mask,混合公式为o = sqrt(x^2+y^2),目前为止的vs实现大概长这样:

import vapoursynth as vs
import mvsfunc as mvf

core = vs.core
core.num_threads = 8
core.max_cache_size = 4000
    
src8 = mvf.GetPlane(core.lsmas.LWLibavSource('...'),0)
    
blur = core.std.Convolution(src8, [58,96,58,96,159,96,58,96,58])
sx = core.std.Convolution(blur, [-1,0,1,-2,0,2,-1,0,1])
sy = core.std.Convolution(blur, [1,2,1,0,0,0,-1,-2,-1])
sobel = core.std.Expr([sx,sy],['x dup * y dup * + sqrt'])
sobel.set_output()

    3、计算梯度与非极大值抑制:计算梯度实际上是上一步中同时完成的,即计算一个像素被识别为边界后,他是什么方向的边界(意指,3x3内一共有四种方向,横、纵、左上到右下、左下到右上),计算公式 θ = arctan(Gx / Gy) 。进而我们可以利用这个边界方向,为目前为止的边界瘦身(作为上文中指导思想2与3的延伸)。

    其逻辑为,如果在该方向上的亮度排列为低、高、低,那么显然这很像一个边界。反之如果中间像素小于两边任何一个像素,则将其舍弃。这部分逻辑用vs提供的接口虽然能做,但是实现起来很蠢,就不演示了。

    4、双阈值过滤:设定高、低两个阈值。如果像素卷积后的值高于高阈值,则判断其为一个强边界(一定是边界),如果在高低阈值中间,则判断其为一个弱边界(也许是边界),如果低于低阈值则不可能是边界,将其舍弃。如果你经常用TCanny,现在你应该想起了其中有两个参数名叫t_h和t_l。这种双阈值过滤的类似操作我们在vs里生成主体mask的时候很常用,这里不再赘述。

    5、滞后边界跟踪:这一步的逻辑是,对于每个上一步中判断的弱边界,搜索当前像素周围9像素,如果周围9像素内存在上一步中判断的强边界,表示这个像素与强边界“接壤”,属于线条的一部分,将其转化为强边界。反之若不存在,则该点孤立,判断它是被误判的边界,将其舍弃。这种思路我们在排除mask噪点时也常用到,比如上文中的增强版kirsch中就已经偷偷使用,如果之前没看懂的话你可以现在回去再看一遍。

    至此,一个准确、健壮的CannyMask就被生成了出来,同时本章的内容到此也就全部结束了。

    不妨回顾一下本章都讲了些什么。本章中首先介绍了Gamma曲线的原理,并对一些常用操作进行了示范。大概是本系列教程中为数不多的直接讲操作技巧的章节吧。而后我们为了与日后的卷积神经网络内容做衔接,分别讲解了数学中对卷积的定义,以及它在图形学中的物理意义。为了让大家理解的更形象我们举了krisch算子做边缘侦测的例子,而后讲解了大多数人用过无数次,但从不理解原理的Canny边缘侦测。

    本来感觉卷积这么简单的东西两三句话就能说完的,我也不知道为什么写着写着就写了这么多。

    我组真是充满了神秘。

    迷之压制组,我们下期再见。

    Kiyamou
    Kiyamou  2020-04-25, 17:52

    话说文中的图片大部分都挂了