用 OpenCV 实现手绘效果蔡徐坤打篮球
引言
众所周知,我们的蔡老师一直是一个全能型选手,喜欢唱、跳、Rap、篮球,他的优秀一直让我佩服的五体投地,今天我也想像一些 B 站 UP 主一样抒发一下自己对蔡老师的敬佩之情,毕竟老夫也不是什么恶魔,就从用 OpenCV 实现手绘蔡老师打篮球开始吧!
名场面回顾
首先我们先来回顾一下蔡老师的名场面,品一品 wuli 坤坤炫酷的唱跳 Rap 篮球技巧:
不知道大家看完有没有觉得有一种意犹未尽的感觉,不过我不要你觉得,我要我觉得,我反正是觉得看不够,哈哈哈!下面我们就来实践一下吧!
第一步:读取一帧图片并显示
在对视频进行操作之前,我们要先明白对视频操作的根本其实还是对图片进行各种操作。所以一切的基础先从图片开始。先从视频中截取一张截图,然后对图片进行操作,首先要做的就是能够显示这张图片。
先导入 OpenCV 模块:
import cv2 as cv
接着读取并显示 cxk-basketball.jpg 图片:
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
cv.waitKey(0)
cv.destroyAllWindows()
运行以上代码显示的结果:
代码解释:
首先利用 cv.imread('cxk-basketball.jpg') 读取了这张截图,保存到 img_origin 这个变量里面。
接下来用 cv.imshow('origin', img_origin) 将这张照片通过一个窗口显示出来,并且这个窗口的名称叫做 origin 。
然后我们用 cv.waitKey(0) 来等待用户的按键操作,waitKey(n) 里的 n 表示等待多少毫秒的时间,超过这个时间程序就会继续运行下去。我们把它设为0表示无限等待下去,也就是只要用户没有在这个窗口内按下任何按键,程序就会一直停在这里。
最后,当用户按下任意按键,程序执行 cv.destroyAllWindows() ,把窗口都关掉,程序结束。
第二步:彩色图片转灰度图片
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
img_gray = cv.cvtColor(img_origin, cv.COLOR_BGR2GRAY)
cv.imshow('gray', img_gray)
cv.waitKey(0)
cv.destroyAllWindows()
其实这里转为灰度图只需要一行代码:
img_gray = cv.cvtColor(img_origin, cv.COLOR_BGR2GRAY)
这里需要注意的就是 OpenCV 读取图片的格式是 BGR 格式,所以是 cv.COLOR_BGR2GRAY ,当然最新版的 OpenCV 写成 RGB 也不会报错并且正常运行,但是为了代码的准确性还是写 BGR。
运行以上代码之后我们就得到了如下的窗口:

第三步:对图像进行高斯模糊
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
img_gray = cv.cvtColor(img_origin, cv.COLOR_RGB2GRAY)
cv.imshow('gray', img_gray)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
cv.imshow('blurred', img_blurred)
cv.waitKey(0)
cv.destroyAllWindows()
在这一步我们要进行高斯模糊,那么高斯模糊是如何实现的呢?
简单的来说就是,假设图像中有一块区域如下所示:
如果我们要对图像中的 2 进行模糊,那只需要把它周围的 8 块像素加起来取平均数替换 2 就可以了,对于这块区域,就是 1×8÷8=1,那么 2 的值将被 1 替换,如下图所示:
"中间点"取"周围点"的平均值,就会变成 1。在数值上,这是一种"平滑化"。在图形上,就相当于产生"模糊"效果,"中间点"失去细节。
显然,计算平均值时,取值范围越大,"模糊效果"越强烈。
上面分别是原图、模糊半径 3 像素、模糊半径 10 像素的效果。模糊半径越大,图像就越模糊。从数值角度看,就是数值越平滑。
我们回顾一下刚才代码中的 cv.GaussianBlur(img_gray, (5, 5), 0) , 使用的(5,5)参数就表示高斯核的尺寸,这个核尺寸越大图像越模糊。但是记住尺寸得是奇数!这是为了保证中心位置是一个像素而不是四个像素。
接下来的问题就是,既然每个点都要取周边像素的平均值,那么应该如何分配权重呢?
如果使用简单平均,显然不是很合理,因为图像都是连续的,越靠近的点关系越密切,越远离的点关系越疏远。因此,加权平均更合理,距离越近的点权重越大,距离越远的点权重越小。由此,正态分布就是一种可取的加权模式。
在图形上,正态分布是一种钟形曲线,越接近中心,取值越大,越远离中心,取值越小。
计算平均值的时候,我们只需要将"中心点"作为原点,其他点按照其在正态曲线上的位置,分配权重,就可以得到一个加权平均值。
那么问题就来了,正态分布是一维的,如何引入到图像的这种二维呢?答案就是用二维的高斯函数 (Gaussian function) ,也就是正态分布的密度函数。 它的一维形式是:
\[ f(x)=\frac{1}{\sigma \sqrt{2\pi}}e^{-(x-\mu)^{2}/2\sigma^{2}} \]
其中,μ是 x 的均值,σ是 x 的方差。因为计算平均值的时候,中心点就是原点,所以μ等于 0:
\[ f(x)=\frac{1}{\sigma \sqrt{2\pi}}e^{-x^{2}/2\sigma^{2}}\]
根据一维高斯函数,可以推导得到二维高斯函数:
\[ G(x,y)=\frac{1}{2\pi \sigma^{2}}e^{-(x^{2}+y^{2})/2\sigma^{2}} \]
有了这个函数 ,就可以计算每个点的权重了。
假定中心点的坐标是(0,0),那么距离它最近的 8 个点的坐标如下:
更远的点以此类推。
为了计算权重矩阵,需要设定σ的值。假定σ=1.5,则模糊半径为 1 的权重矩阵如下:
这 9 个点的权重总和等于 0.4787147,如果只计算这 9 个点的加权平均,还必须让它们的权重之和等于 1,因此上面 9 个值还要分别除以 0.4787147,得到最终的权重矩阵。
有了权重矩阵,就可以计算高斯模糊的值了。假设现有 9 个像素点,灰度值(0-255)如下:
每个点乘以自己的权重值:
得到:
最后将这 9 个值相加,得到的就是原本图像中中间点的高斯模糊数值了。
那么我们运行上面的代码,就能得到一个高斯模糊后的蔡老师:
第四步:图像二值化
二值化的概念其实很简单,就是对一张图片上的点,像素值大于等于某个值的都直接设为最大值,小于这个值的都直接设为最小值,这样这张图片上每个点都只可能是最大值或最小值其中之一了,其中我们比较的这个数值就是阈值。
当然如果对整张图片都规定同一个阈值,可能会出现下图的效果,因为实际图片还有阴影之类的问题会影响,这样就出现了自适应二值化的方法,如下图第二排所示:


下面来看一下这里的代码:
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
img_gray = cv.cvtColor(img_origin, cv.COLOR_RGB2GRAY)
cv.imshow('gray', img_gray)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
cv.imshow('blurred', img_blurred)
img_threshold1 = cv2.adaptiveThreshold(img_blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 5, 2)
cv.imshow('img_threshold1', img_threshold1)
cv.waitKey(0)
cv.destroyAllWindows()
在代码中我们用 cv.adaptiveThreshold() 来实现这种自适应二值化方法。其中参数 255 表示我们二值化后图像的最大值, cv.ADAPTIVE_THRESH_GAUSSIAN_C 表示我们采用的自适应方法, cv.THRESH_BINARY 表示我们是将大于阈值的像素点的值变成最大值,反之这里如果使用 cv.THRESH_BINARY_INV 表示我们是将大于阈值的像素点的值变成 0,倒数第二个参数 5 表示我们用多大尺寸的区块来计算阈值,倒数第一个参数 2 表示计算周边像素点均值时待减去的常数 C。
运行后就可以得到一个二值化的蔡老师 :
第五步:再次对二值化图像进行模糊
由于采用了自适应二值化的方法,原本深色衣服的地方也自适应地变成了白色,实现了一个简单描边效果。现在我们已经初步实现了素描效果,但是还不够,让我们继续完善一下,让边线更宽,噪点更少一些。
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
img_gray = cv.cvtColor(img_origin, cv.COLOR_RGB2GRAY)
cv.imshow('gray', img_gray)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
cv.imshow('blurred', img_blurred)
img_threshold1 = cv2.adaptiveThreshold(img_blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 5, 2)
cv.imshow('img_threshold1', img_threshold1)
img_threshold1_blurred = cv.GaussianBlur(img_threshold1, (5, 5), 0)
cv.imshow('img_threshold1_blurred', img_threshold1_blurred)
cv.waitKey(0)
cv.destroyAllWindows()
和刚才一样我们用 cv2.GaussianBlur()完成了高斯模糊,这样我们就可以得到一个模糊的描边蔡老师:
第六步:再次进行二值化
接下来我们对这张图片再次进行二值化:
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
img_gray = cv.cvtColor(img_origin, cv.COLOR_RGB2GRAY)
cv.imshow('gray', img_gray)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
cv.imshow('blurred', img_blurred)
img_threshold1 = cv2.adaptiveThreshold(img_blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 5, 2)
cv.imshow('img_threshold1', img_threshold1)
img_threshold1_blurred = cv.GaussianBlur(img_threshold1, (5, 5), 0)
cv.imshow('img_threshold1_blurred', img_threshold1_blurred)
_, img_threshold2 = cv.threshold(img_threshold1_blurred, 200, 255, cv.THRESH_BINARY)
cv.imshow('img_threshold2', img_threshold2)
cv.waitKey(0)
cv.destroyAllWindows()
和刚才不一样的是,由于这张图片已经比较干净没有什么阴影,我们直接采用最简单的二值化方法 cv.threshold(img_threshold1_blurred, 200, 255, cv.THRESH_BINARY) 。其中 200 表示将图片中像素值为 200 以上的点都变成 255,255 就是白色。
这样我们就能得到一个边线更宽的二值化效果:
第七步:图像开运算
下面让我们去掉图片中一些细小的噪点,这种效果可以通过图像的开运算来实现:
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
img_gray = cv.cvtColor(img_origin, cv.COLOR_RGB2GRAY)
cv.imshow('gray', img_gray)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
cv.imshow('blurred', img_blurred)
img_threshold1 = cv2.adaptiveThreshold(img_blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 5, 2)
cv.imshow('img_threshold1', img_threshold1)
img_threshold1_blurred = cv.GaussianBlur(img_threshold1, (5, 5), 0)
cv.imshow('img_threshold1_blurred', img_threshold1_blurred)
_, img_threshold2 = cv.threshold(img_threshold1_blurred, 200, 255, cv.THRESH_BINARY)
cv.imshow('img_threshold2', img_threshold2)
kernel = cv.getStructuringElement(cv.MORPH_RECT,(3, 3))
img_opening = cv.bitwise_not(cv.morphologyEx(cv.bitwise_not(img_threshold2), cv.MORPH_OPEN, kernel))
cv.imshow('img_opening', img_opening)
cv.waitKey(0)
cv.destroyAllWindows()
要理解图像的开运算就要知道图像的腐蚀和膨胀, 膨胀与腐蚀能实现多种多样的功能,主要如下:
- 消除噪声
- 分割(isolate)出独立的图像元素,在图像中连接(join)相邻的元素。
- 寻找图像中的明显的极大值区域或极小值区域
- 求出图像的梯度
这里必须要注意的是:腐蚀和膨胀是对白色部分(高亮部分)而言的,不是黑色部分。膨胀就是图像中的高亮部分进行膨胀,“领域扩张”,效果图拥有比原图更大的高亮区域。腐蚀就是原图中的高亮部分被腐蚀,“领域被蚕食”,效果图拥有比原图更小的高亮区域。
因此当我们对一个图像先腐蚀再膨胀的时候,一些小的区块就会由于腐蚀而消失,再膨胀回来的时候大块区域的边线的宽度没有发生变化,这样就起到了消除小的噪点的效果。图像先腐蚀再膨胀的操作就叫做开运算。
回到我们的代码,首先开运算要有一个运算的核,我们通过:
kernel = cv.getStructuringElement(cv.MORPH_RECT,(3, 3))
可以得到一个 3×3 的核。
然后通过:
cv.morphologyEx(cv.bitwise_not(img_threshold2), cv.MORPH_OPEN, kernel)
来进行图像的开运算。
这里需要注意的是我们没有直接对 img_threshold2 进行开运算,而是对 cv.bitwise_not(img_threshold2) 进行运算, cv2.bitwise_not() 其实就是对图像进行一个简单的操作,原来是 0 的像素点变成最大值,原来是最大值的像素点变成 0。相当于黑色的地方变成白色,白色的地方变成黑色。 具体的原因在之前的注意部分已经明确说明了,开运算主要是对图像中的白色,即有数值得部分进行运算的,而黑色是 0,白色最高是 255,那么我们肯定要将颜色反转才能够处理我们想要处理的部分。
这样我们就得到一个更少噪点蔡老师:
第八步:第三次对图像进行高斯模糊
接下来我们对这张二值化的图像再简单进行高斯模糊,让图片更接近手绘的效果。
import cv2 as cv
img_origin = cv.imread('cxk-basketball.jpg')
cv.imshow('origin', img_origin)
img_gray = cv.cvtColor(img_origin, cv.COLOR_RGB2GRAY)
cv.imshow('gray', img_gray)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
cv.imshow('blurred', img_blurred)
img_threshold1 = cv2.adaptiveThreshold(img_blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 5, 2)
cv.imshow('img_threshold1', img_threshold1)
img_threshold1_blurred = cv.GaussianBlur(img_threshold1, (5, 5), 0)
cv.imshow('img_threshold1_blurred', img_threshold1_blurred)
_, img_threshold2 = cv.threshold(img_threshold1_blurred, 200, 255, cv.THRESH_BINARY)
cv.imshow('img_threshold2', img_threshold2)
kernel = cv.getStructuringElement(cv.MORPH_RECT,(3, 3))
img_opening = cv.bitwise_not(cv.morphologyEx(cv.bitwise_not(img_threshold2), cv.MORPH_OPEN, kernel))
cv.imshow('img_opening', img_opening)
img_opening_blurred = cv.GaussianBlur(img_opening, (3, 3), 0)
cv.imshow('img_opening_blurred', img_opening_blurred)
cv.waitKey(0)
cv.destroyAllWindows()
这样下来我们就可以实现对一张彩色图片转换成素描的效果:
第九步:读取并处理视频中的图像
搞定了单张图片,对视频进行处理就非常简单了,只需要将视频里每一帧都做同样的处理再输出即可。
首先在开头位置加上读取视频的语句:
cap = cv.VideoCapture('footage.mp4')
然后创建一个 while 循环,将图像处理的语句都放进去:
while True:
ret, frame = cap.read()
if frame is None:
break
img_gray = cv.cvtColor(frame, cv.COLOR_RGB2GRAY)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
img_threshold1 = cv.adaptiveThreshold(img_blurred, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, 5, 2)
img_threshold1_blurred = cv.GaussianBlur(img_threshold1, (5, 5), 0)
_, img_threshold2 = cv.threshold(img_threshold1_blurred, 200, 255, cv.THRESH_BINARY)
kernel = cv.getStructuringElement(cv.MORPH_RECT,(3, 3))
img_opening = cv.bitwise_not(cv.morphologyEx(cv.bitwise_not(img_threshold2), cv.MORPH_OPEN, kernel))
img_opening_blurred = cv.GaussianBlur(img_opening, (3, 3), 0)
cv.imshow('img_opening_blurred', img_opening_blurred)
if cv.waitKey(40) & 0xFF == ord('q'):
break
其中,cap.read() 用来读取视频每一帧的数据,每一次调用就读取一帧图像,当读取到的 frame 是 None 时就说明视频结束,可以直接退出 while 循环。
在 while 循环的最后我们用 cv.waitKey(40) & 0xFF == ord('q') 来判断用户有没有按下键盘上的 q 键,如果按下了就直接退出 while 循环。
而 cv.waitKey(40) 中我们填 40 表示等待 40 毫秒,也就相当于每两张图片之间间隔 40 毫秒,即 25 帧/秒。
然后我们还要把转换完的每一帧图像做到不仅能播放,还要能输出为 mp4 格式的视频,这里我们首先要抓取视频,采用:
cap = cv.VideoCapture('cxk-basketball.mp4')
之后,我们抓取视频,逐帧处理,我们希望保存该视频。对于图像,它非常简单,只需使用 cv.imwrite() 。但是在保存视频的时候,这里需要做更多的工作。
我们应该首先创建一个 VideoWriter 对象,指定输出文件名(例如:output.mp4)。然后我们应该指定 FourCC 代码。然后应传递每秒帧数(fps)和帧大小。最后一个是 isColor 标志。如果是 True ,编码器需要彩色帧,否则它适用于灰度帧。
FourCC 是一个 4 字节代码,用于指定视频编解码器。可在fourcc.org中找到可用代码列表。它取决于平台。以下编解码器在我这里工作的很好。
- 在 Fedora 中:DIVX,XVID,MJPG,X264,WMV1,WMV2。(XVID 更为可取。MJPG 会产生高大小的视频。X264 可以提供非常小的视频)
- 在 Windows 中:DIVX(更多要测试和添加)
- 在 OSX 中:MJPG(.mp4),DIVX(.avi),X264(.mkv)。
对于 MJPG,FourCC 代码作为`cv.VideoWriter_fourcc('M','J','P','G')or cv.VideoWriter_fourcc(*'MJPG')`传递。
那我们就按如下定义:
fourcc = cv.VideoWriter_fourcc(*'X264')
out = cv.VideoWriter('output.mp4', fourcc, 25.0,(864,480))
这一部分还要将输出写入:
out.write(img_opening_blurred)
最后,完整的代码如下:
import cv2 as cv
cap = cv.VideoCapture('cxk-basketball.mp4')
fourcc = cv.VideoWriter_fourcc(*'X264')
out = cv.VideoWriter('output.mp4', fourcc, 25.0,(864,480))
while True:
ret, frame = cap.read()
if frame is None:
break
img_gray = cv.cvtColor(frame, cv.COLOR_RGB2GRAY)
img_blurred = cv.GaussianBlur(img_gray, (5, 5), 0)
img_threshold1 = cv.adaptiveThreshold(img_blurred, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, 5, 2)
img_threshold1_blurred = cv.GaussianBlur(img_threshold1, (5, 5), 0)
_, img_threshold2 = cv.threshold(img_threshold1_blurred, 200, 255, cv.THRESH_BINARY)
kernel = cv.getStructuringElement(cv.MORPH_RECT,(3, 3))
img_opening = cv.bitwise_not(cv.morphologyEx(cv.bitwise_not(img_threshold2), cv.MORPH_OPEN, kernel))
img_opening_blurred = cv.GaussianBlur(img_opening, (3, 3), 0)
out.write(img_opening_blurred)
cv.imshow('img_opening_blurred', img_opening_blurred)
if cv.waitKey(32) & 0xFF == ord('q'):
break
cap.release()
out.release()
cv.destroyAllWindows()
到这里,我们所有的工作就都做完啦!其实 OpenCV 输出的视频是没有音频的,我们接下来看的效果视频是我把原视频的音频和处理后的输出视频合并了,这样看起来也更加舒服~
来看一下最后的成果,品一品手绘蔡老师的感觉吧!
参考文章:
- OpenCV 4.1.1 官方文档: https://docs.opencv.org/4.1.1/dd/d43/tutorial_py_video_display.html
- 高斯模糊的算法: http://www.ruanyifeng.com/blog/2012/11/gaussian_blur.html
- 自适应阈值二值化算法: https://www.cnblogs.com/polly333/p/7269153.html
- 形态学图像处理:膨胀与腐蚀: https://www.jianshu.com/p/fc07d3065cf1