由于后面的工作会偏cv一些,所以这段时间抓住最后毕业的小尾巴抽出时间来想开一条cv的自学线路,和当时入门推荐差不多,cv这里也是简单的梳理了目前的一些方向以及各个方向常用的一些知识,然后各个方向进行学习和突破。 当然作为初学者,我依然还是从经典的模型开始,因为我发现,读论文学模型,然后做相关项目是比较快速的入门方式,但是呢, 这个过程中,我突然发现,对于cv来讲,基础的图像预处理操作也是非常重要的一大块,虽然做一些重大项目还是以大规模的深度学习模型为主,但是,如何让模型能更好的学习到图像的特征,图像的相关特征工程,也是需要非常多的技术的,我觉得这是和推荐或者结构化数据不太一样的地方,图像数据有时候本身很复杂,同一张图片在不同的清晰度,颜色,亮度,轮廓等不同情况下,可能最后的模型识别效果会相差很大,所以对图像进行预处理操作,做细致的特征工程也很重要,但图像的特征工程方面,还需要一些针对图像的专门预处理的方式,比如图像的平滑,阈值,增强,形态学,边缘检测,轮廓检测,模板匹配,滤波等,而这些技术如果能使用的好,就能很好把图像的特征给表现出来,能对后面模型的识别起到很大的作用,甚至可能模型都不用很复杂。这就是我要学习这块的原因。
OpenCV是一个专门针对图像处理的计算机视觉的一个工具包,里面包含了大量的图像预处理操作,这次我从OpenCV开始学习,跟着唐宇迪老师的OpenCV入门教程学习的,这个教程是先讲图像的一些基本操作,比如变换,阈值,平滑,形态学,算子,边缘检测,金字塔,轮廓,模板匹配以及傅里叶变换等,然后再通过几个实践项目来把前面知识融合起来,这正符合我的学习习惯。所以觉得还不错,目前到了项目实战部分,但由于前面的这些知识太多,好多都忘了,于是,就想先通过一篇文章,把之前学习到的这些东西总结一下,然后再通过后面的项目把知识融会一下。
主要内容:
Ok, let’s go!
首先是图像的读取操作, opencv提供了cv2.imread
函数, 帮助我们读取一张图片,主要有两种读取方式:
cv2.IMREAD_COLOR
: 默认的,读取彩色图像cv2.IMREAD_GRAYSCALE
: 读取灰度图像下面是代码示例:
def cv_show(name, img, wait_key=0):
# 图像的展示, 也可以创建多个窗口
cv2.imshow(name, img)
# 展示时间, 毫秒级, 0表示任意键终止
cv2.waitKey(wait_key)
cv2.destroyAllWindows()
img=cv2.imread('img/cat.jpg')
# 读取灰度图
# 单通道的图一定是灰度图, 而三通道的图也可以有灰度模式,即R,G,B三个通道的亮度一致,即RGB三通道的值改成一样? 那么改成啥呢?
# 这里其实是要做灰度转换的, 涉及到灰度转换算法了
img_gray = cv2.imread("img/cat.jpg", cv2.IMREAD_GRAYSCALE)
img.shape, img.dtype等
# ndarry的形式,所以支持切片处理, 拿到部分像素值
img_part = img_gray[0:200, 0:200]
cv_show('cat', img_part)
这里读取的img其实就是numpy数组,所以np数组的一些属性这里的img都会有,并且既然是numpy数组,也能够进行基本的四则运算来改变图像的像素值大小。
cv2.split
函数把图片的三个通道划分开
b, g, r = cv2.split(img)
# 三个通道也可以融合起来
img_merge = cv2.merge((b, g, r))
# 只保留某个通道
# 只保留某个通道, 这里写个函数
def get_channel_img(img, stay_channel=0):
# B, G, R 是 0, 1, 2
cur_img = img.copy()
for i in range(3):
if i != stay_channel:
cur_img[:, :, i] = 0
return cur_img
blue_channel_img = get_channel_img(img, 0)
green_channel_img = get_channel_img(img, 1)
red_channel_img = get_channel_img(img, 2)
这里补充点灰度图,单通道,三通道的知识:
如果想将单通道转成三通道,可以用下面的函数:
# 单通道转成三通道
r2img = cv2.cvtColor(img_gray, cv2.COLOR_GRAY2RGB)
cv_show('img', r2img)
这里用的cv2.copyMakeBorder
函数。这里就类似于图像的padding操作, 不过是在预处理过程中,我们自定义方式进行的padding,具体使用示例如下:
# 上下左右分别填充的大小
top_pad, bottom_pad, left_pad, right_pad = 50, 50, 50, 50
# 有不同的填充方式
def img_pad(img, top_pad, bottom_pad, left_pad, right_pad, mode=cv2.BORDER_REPLICATE, value=0):
if mode == cv2.BORDER_CONSTANT:
pad_img = cv2.copyMakeBorder(img, top_pad, bottom_pad, left_pad, right_pad, borderType=mode, value=value)
else:
pad_img = cv2.copyMakeBorder(img, top_pad, bottom_pad, left_pad, right_pad, borderType=mode)
return pad_img
replicate = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_REPLICATE)
reflect = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_REFLECT)
reflect101 = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_REFLECT_101)
wrap = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_WRAP)
constant = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_CONSTANT, value=0)
这里面的几个属性:
cv2.BORDER_REPLICATE
: 复制法, 复制最边缘像素cv2.BORDER_REFLECE
: 反射法,对感兴趣的图像中的像素在两边进行复制, 比如原图abcdefg, 那么两边填充之后,fedcba|abcdefg|gfecv2.BORDER_REFLECT_101
: 反射法, 以最边缘像素为轴, 对称 gfedcb|abcdefg|fedcbacv2.BORDER_WRAP
: 外包装法 cdefg|abcdefg|abcdefcv2.BORDER_CONSTANT
: 常数值填充图像融合的需求是两张图片融合成一张,本质上还是像素之间的操作,但前提: shape必须一致,并且我们还可以给不同图片加权,可以用cv2.add_weight
函数。
img_dog = cv2.imread('img/dog.jpg')
# 把狗和猫的成分按照比例融合在一起
# img + img_dog 这个直接相加不行 ,因为shape不一样
# 转成一样的先
img_dog = cv2.resize(img_dog, (666, 548)) # 这里注意下后面参数, 这里的是先指定宽, 再指定高
img_dog.shape
# resize 还能这么玩 cv2.resize(img, (0, 0), fx=4, fy=1) 这个是不知道具体大小,而是让x,y轴按照倍数伸长或者缩短
# 进行融合
res = cv2.addWeighted(img, 0.4, img_dog, 0.6, 0) # aplha * img + beta * img_dog + b
OpenCV可以读取视频的,使用cv2.VideoCaptuer
函数, 视频是很多帧的图片组成,所以读取视频,本质上还是需要拿到每一帧图像,对图像进行处理。
vc = cv2.VideoCapture("img/test.mp4") # 从网上现下载了一个视频
# 检测是否打开正确
if vc.isOpened():
open, frame = vc.read()
else:
open = False
# 读取完视频后,将视频进行拆分成每一帧进行操作
while open:
ret, frame = vc.read()
# 如果当前没有帧,停掉
if frame is None:
break
if ret == True:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
cv2.imshow("result", gray)
if cv2.waitKey(10) & 0xFF == 27:
break
vc.release()
cv2.destroyAllWindows()
阈值操作,就是对某张图片,根据给定的阈值进行像素改变,比如大于某个阈值或者小于某个阈值的像素点,我们给他改成多少。 这里使用的cv2.threshold
函数, 里面4个参数:
ret, dst = cv2.threshold(src, thresh, maxval, type)
- src: 输入图像,只能是单通道图像,通常来说是灰度图
- dst: 输出图
- thresh: 阈值, 大于多少的或者小于多少的进行处理, 一般是127
- maxval: 当像素值超过了阈值(或者小于阈值的,根据type来决定)所赋予的值, 一般255
- type: 二值化操作类型,包括下面五种类型:
- cv2.THRESH_BINARY: 超过阈值部分取maxval,否则取0
- cv2.THRESH_BINAEY_INV: 上面的相反
- cv2.THRESH_TRUNC: 大于阈值部分设置为阈值,否则不变
- cv2.THRESH_TOZERO: 大于阈值部分的不改变,否则设为0
- cv2.THRESH_TOZERO_INV: 上面这个反转
这里可以看一个应用示例, 感受下效果:
# 数据读取 opencv读取的格式是BGR, 不是RGB,所以图片展示的时候,最好是使用opencv自带的工具函数
import cv2
import matplotlib.pyplot as plt
img_gray = cv2.imread("img/cat.jpg", cv2.IMREAD_GRAYSCALE)
ret, thresh1 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_BINARY) # 像素大于127的替换成255, 小于127的换成0 255是最亮,白
ret, thresh2 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_BINARY_INV) # 像素大于127的换成0, 大于127的换成255
ret, thresh3 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_TRUNC) # 大于127的换成255, 其余不变, 这个相当于截断
ret, thresh4 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_TOZERO) # 大于127的不改变,小于127的变成0
ret, thresh5 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_TOZERO_INV) # 大于127的变成0, 小于127的不改变
titles = ['original image', 'binary', 'binary_inv', 'trunc', 'tozero', 'tozero_inv']
images = [img, thresh1, thresh2, thresh3, thresh4, thresh5]
for i in range(6):
plt.subplot(2, 3, i+1), plt.imshow(images[i], 'gray')
plt.title(titles[i])
plt.xticks([]), plt.yticks([])
plt.show()
图像的滤波目的一般有两个:
对滤波处理的要求也有两个:
平滑滤波是低频增强的空间滤波技术,目的主要是模糊或者是消除图像的噪音。
空间域的平滑滤波一般采用简单平均法进行,就是求邻近像元点的平均亮度值。邻域的大小与平滑的效果直接相关,邻域越大平滑的效果越好,但邻域过大,平滑会使边缘信息损失的越大,从而使输出的图像变得模糊,因此需合理选择邻域的大小。
举一个滤波在我们生活中的应用:美颜的磨皮功能。如果将我们脸上坑坑洼洼比作是噪声的话,那么滤波算法就是来取出这些噪声,使我们自拍的皮肤看起来很光滑。
常用的滤波方法: 均值滤波,中值滤波和高斯滤波。
均值滤波是周围相似点的均值代替中心点的值,边缘部分保持不变,比如下面这个例子:
# 均值滤波
# 简单的平均卷积操作
blur = cv2.blur(img, (3, 3)) # 3*3是卷积核的大小
cv_imshow('blur', blur) # 真实实现的时候,其实这个卷积核的参数是[[1,1,1], [1,1,1], [1,1,1]] * 1/9 用这样的卷积核卷上面图片
缺陷:均值滤波本身存在着固有的缺陷,即它不能很好地保护图像细节,在图像去噪的同时也破坏了图像的细节部分,从而使图像变得模糊,不能很好地去除噪声点。特别是椒盐噪声。
中值滤波就是中心点和周围一圈的像素点从小到大排序,用中值替换中心点的值,这样的好处就是不会破坏图像的细节部分,能够很好的处理椒盐噪声,因为中心点最终的取值必定是周围那一圈以及它自己的某个值,比较连贯。 而均值滤波的话是所有值的平均,一旦这些点的距离相差很大,一平均,就把原图给破坏掉了。 这也就是为啥均值滤波之后图像变模糊的原因。 但是均值滤波在处理高斯噪声上有用。
# 中值滤波
img_median = cv2.medianBlur(img, 5)
均值滤波 VS 中值滤波:
高斯滤波,就是卷积核参数符合高斯分布, 类比均值滤波比较好理解, 均值滤波的话,是周围点和中心点的均值,所谓求均值,就是所有像素的权重都是一样的。 这样,相当于周围点对中心点的贡献程度一样了,不是很合理。因为从像素上来看,理应周围像素点的数值和中心点越近,权重越大。 所以高斯分布是这样,根据像素点离中心点的权重去分配不同的权重参数。
# 高斯滤波
# 高斯滤波的卷积核里面的参数满足高斯分布,相当于给周围的像素值根据距离中心点的远近加一个权重
aussian = cv2.GaussianBlur(img, (3, 3), 1)
cv_imshow('aussian', aussian)
下面记录一点思考:
既然上面整理到了简单的滤波操作,为了知识的连贯性,这里再整理一点知识,把后面学习的低高通滤波也拿过来。
傅里叶变换我们知道是将图像从时域转换到频域的一种非常强大的武器, 时域中的图像数据就是我们看到的一个个像素点组成的图片,而频域中,我们是能够得到灰度分量的频率大小的。可能图像看起来不是很好理解,拿一段语言来描述最贴切:假设我们录了一段语言,里面各种声音混杂,从时域的角度,这就是按照时间序组成的一段音频,那么我们有办法把这段音频的噪声去掉,只保留重要的音频信息吗? 其实这个在时域中非常难做到,而转到频域里面,就会发现这些声音都是一条条频率不同的声音线组成,通过频率就能非常轻松的过滤出某些我们想要的声音。所以通过傅里叶变换操作,我们能非常容易的拿出图像或者声音中我们需要的某些灰度分量了。
那么回到图像里面, 同样会存在高频或者低频的灰度分量:
根据频率进行滤波,主要分为两种:
在opencv中的函数:
cv2.dft()
和cv2.idft()
, 输入图像需要先转成np.float32格式, cv2.dft
就是把时域转成了频域,但是为了显示,还需要进行逆变换,即cv2.idft()
cv2.dft()
返回的结果是双通道的(实部,虚部), 通常还需要转换成图像格式才能展示(0,255)下面总结下低通滤波和高通滤波的过程:
cv2.ift
-> 得到频域图像dft -> 低频信息移到中间(np.fft.fftshift
) -> 这个结果是双通道(实部+虚部) -> 转成图像格式cv2.magnitude
dft* mask
-> 得到过滤之后的频域图像fshift
np.fft.ifftshift
) -> 傅里叶逆变换cv2.idft
-> 转成图像格式cv2.magnitude
这就是整个的处理逻辑, 高通滤波和低通滤波只是保留的频率不一样,所以区别在于制作的mask矩阵上,但整体逻辑是一样的, 下面我把高通滤波和低通滤波操作封装成了一个函数:
# 下面是代码总结
def dft_idft(img, threshold=30, mode='low'):
img_float = np.float32(img) # 转成float
# 时域 -> 频域
dft = cv2.dft(img_float, flags=cv2.DFT_COMPLEX_OUTPUT)
# 把低频值转到中间位置
dft_shift = np.fft.fftshift(dft) # 低频值转到中心位置
# 掩码矩阵
row, col = img.shape
c_row, c_col = int(row/2), int(col/2)
if mode == 'low':
mask = np.zeros((row, col, 2), np.uint8)
mask[c_row-threshold:c_row+threshold, c_col-threshold:c_row+threshold] = 1
elif mode == 'high':
mask = np.ones((row, col, 2), np.uint8)
mask[c_row-threshold:c_row+threshold, c_col-threshold:c_row+threshold] = 0
else:
print("参数错误")
return
# 下面就是用这个滤波器对频域的那个图像做操作
fshift = dft_shift * mask
f_ishift = np.fft.ifftshift(fshift) # 之前是低频信息shift到了中间位置,而这个操作是从中间位置变到原来位置
img_back = cv2.idft(f_ishift) # 逆变换
img_back = cv2.magnitude(img_back[:, :, 0], img_back[:, :, 1])
return img_back
# 低通滤波和高通滤波
img_low = dft_idft(img, mode='low')
img_high = dft_idft(img, mode='high')
我们可视化下看看效果:
高频滤波和低频滤波的作用就是对原始图像在频域的角度上进行了相关的频率信息过滤,在频域上,图像其实是有层次性的,所以通过将时域的图像转到频域中,我们就很容易得到图像的层次信息,而这样就非常重要过滤高频或者低频信息。
所谓形态学操作,我理解,对图像本身进行的一些预处理,比如让图像里面的线条变粗或者变细,重点突出图像的某些部分, 去掉图像中的毛刺,处理一些缺陷等。
图像形态学中常用的两个操作是腐蚀和膨胀,腐蚀一般用于处理毛刺问题,能够让线条或者图形变细。 而膨胀一般是填补一些缺陷, 能够让线条变粗。
腐蚀操作一般处理图像的毛刺,其原理如下:
我们事先定义了一个kernel,是一个 3 ∗ 3 3*3 3∗3的全1的卷积核, 指定这样的kernel之后, 就开始对输入的图像进行卷积操作, 对于当前的 3 ∗ 3 3*3 3∗3区域, 卷积核会这样检测:
看上面这两个中心点,右边的中心点会被腐蚀掉, 并且这个中心点之外的点也都会被腐蚀掉, 因为 3 ∗ 3 3 * 3 3∗3的区域肯定白色和黑色都有。 这样,迭代一次,就能把一些中心点变成黑色, 再迭代一次, 又会有一些点被腐蚀掉。 所以上面腐蚀操作虽然会去掉毛刺,但笔也变细了,而iteration就是控制迭代次数的, 迭代次数越多, 白色被腐蚀的越厉害。 当然,这个腐蚀程度也和卷积核大小有关, 5 ∗ 5 5 * 5 5∗5的估计迭代1次就很细了, 这个也非常好理解,毕竟中心点周围的区域大了, 而只有这里面黑白兼有,这个点就要被腐蚀掉。 所以大的卷积核使得中心点被腐蚀性的概率会更大。
应用示例:
img = cv2.imread('img/dige.png')
cv_imshow('img', img)
kernel = np.ones((3, 3), np.uint8)
erosion_img = cv2.erode(img, kernel, iterations = 1)
cv_imshow('erosion_img', erosion_img)
效果如下:
这里卷积核越大,或者迭代次数越多, 都会使得右边的线条变细。
上面经过腐蚀操作,虽然能把图像里面有的毛刺去掉,但是,线条也会随着腐蚀越多越细, 那么我们能不能让它变得更粗一些呢? 使得图片线条更加丰满? 这就是膨胀做的事情了。
这个原理,也非常好理解:
迭代次数和卷积核越大,越有利于膨胀操作, 越来越胖
# 下面尝试把上面腐蚀的图片弄成胖一点的
kernel = np.ones((3, 3), np.uint8)
dige_dilate = cv2.dilate(erosion_img, kernel, iterations=1)
cv_imshow('dilate_img', dige_dilate)
# 对线条起了加粗的效果
但,全0的卷积核发现并不会改变图像。
类似于一个管道把腐蚀和膨胀两个操作连接到了一起:
cv2.MORPH_OPEN
: 先腐蚀,后膨胀cv2.MORPH_CLOSE
: 先膨胀,后腐蚀代码如下:
img = cv2.imread('img/dige.png')
cv_imshow('img', img)
# 开运算: 先腐蚀,再膨胀
kernel = np.ones((5, 5), np.uint8)
opening = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel)
cv_imshow('opening', opening)
这个也能达到去毛刺的效果,就是先腐蚀,再膨胀嘛, 并且这个结果和之前的线条粗细上还差不多。
# 闭运算: 先膨胀后腐蚀
closing = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)
cv_imshow('closing', closing)
这个并没有去掉毛刺, 想想就能明白, 先膨胀,相当于毛刺也变粗了,再腐蚀,怎么腐蚀的掉?
但开闭运算的用处不仅如此, 我看好多项目里面开闭运算都非常有用,能通过这种运算达到重点突出图片中某些部分的效果。
cv2.MORPH_GRADIENT
这个就是膨胀-腐蚀,容易提取到边缘信息。
# 梯度 = 膨胀 - 腐蚀
pie = cv2.imread('img/pie.png')
kernel = np.ones((5, 5), np.uint8)
dilate = cv2.dilate(pie, kernel, iterations=5)
erosion = cv2.erode(pie, kernel, iterations=5)
res = np.hstack((dilate, erosion))
cv_imshow('res', res)
cv_imshow('subtract', dilate-erosion)
gradient = cv2.morphologyEx(pie, cv2.MORPH_GRADIENT, kernel, iterations=5)
cv_imshow('gradient', gradient)
这个梯度运算和直接膨胀-腐蚀效果是一样的结果, 背后实现,我猜其实就是膨胀先执行iterations次,然后腐蚀执行iterations次,然后前面结果减去后面的结果。
代码如下:
# 礼帽
img = cv2.imread('img/dige.png')
tophat = cv2.morphologyEx(img, cv2.MORPH_TOPHAT, kernel)
cv_imshow('tophat', tophat)
# 黑帽
img = cv2.imread('img/dige.png')
blackhat = cv2.morphologyEx(img, cv2.MORPH_BLACKHAT, kernel)
cv_imshow('blackhat', blackhat)
这里一开始并没弄明白干啥用的? 参考了这篇文章,有些理解了。
顶帽运算: 取出亮度高的地方
黑帽运算: 取出亮度低的地方
一般用的时候,原始图像转灰度图像,然后进行二值化,然后, 再通过开闭运算, 礼帽和黑帽等操作,就能拿到图像中的想要的区域来了。 所以后面这些组合操作很重要,也很实用。比如车牌号识别,信用卡数字识别等,都会用到这些技术,后面会重点整理。
图像的算子操作其实可以帮助我们去找图像的轮廓信息,主要有Sobel算子,Scharr算子以及Laplacian算子,区别在于卷积核参数不一样。
Sobel算子, 这感觉依然是两个卷积核进行操作, 原理如下:
这个东西其实找的是图像的轮廓信息,或者边缘信息,依赖于上面的两个卷积核, 一个是水平方向的,一个是垂直方向的, 实际计算的时候是这样, 3 ∗ 3 3 * 3 3∗3的卷积核覆盖到一个图像区域, 中间点的取值,就是水平的这个卷积核与当前图像卷积结果或者垂直方向的卷积核与当前图像卷积结果。这个就看是从水平上算还是垂直方向上算了。 当然, 这个算子也是保证最终结果是0-255,如果超了,就会进行截断操作。
靠中心越近权重越大,所以这里用了2或者-2表示
具体函数如下:
dst = cv2.Sobel(src, ddepth, dx, dy, ksize)
ddepth: 图像的深度
dx和dx分别表示水平和垂直方向, 是算x方向还是y方向
ksize是Sobel算子的大小, 一般是3*3
这里直接说常规使用方法, 对于一张图片, 先通过imread的灰度方式读入进来,然后先求水平上的梯度,再求垂直方向上的梯度, 最后加权融合就能找出图片的梯度来。
lena = cv2.imread('img/lena.jpg', cv2.IMREAD_GRAYSCALE)
# 水平做sobel算子
lena_sobelx = cv2.Sobel(lena, cv2.CV_64F, 1, 0, ksize=3)
lena_sobelx = cv2.convertScaleAbs(lena_sobelx) # 这里是为了如果出现负数不让他截断成0,而是变成它的绝对值
# cv_imshow('lena_x', lena_sobelx)
# 垂直做sobel计算
lena_sobely = cv2.Sobel(lena, cv2.CV_64F, 0, 1, ksize=3)
lena_sobely = cv2.convertScaleAbs(lena_sobely)
# cv_imshow('lena_y', lena_sobely)
# 把这俩合并
lena_margin = cv2.addWeighted(lena_sobelx, 1, lena_sobely, 1, 0)
cv_imshow('lena_margin', lena_margin)
这里一个小经验就是两个方向分开算,要比同时算,最终效果要好, 原因是因为,如果是同时计算的时候, 亮度上会有一些抵消。
# 如果是同时找
lena_test = cv2.Sobel(lena, cv2.CV_64F, 1, 1, ksize=5)
lena_test = cv2.convertScaleAbs(lena_test)
cv_imshow('lena', lena_test)
这个效果不好。对比结果如下:
左边原始图像,中间是先找水平边缘,再找垂直边缘然后合并的结果,右边是同时找两个方向边缘的结果。
这里做了两个尝试:
这个算子的卷积核长下面这样:
Scharr算子能够找出更加细致的边界,使得图像的纹理信息更加丰富。使用的方法和上面Sobel是一样的,无非就是函数换了下:
# 这里用scharr算子试一下
img = cv2.imread('img/lena.jpg', cv2.IMREAD_GRAYSCALE)
lena_scharr_x = cv2.Scharr(img, cv2.CV_64F, 1, 0)
lena_scharr_x = cv2.convertScaleAbs(lena_scharr_x)
lena_scharr_y = cv2.Scharr(img, cv2.CV_64F, 0, 1)
lena_scharr_y = cv2.convertScaleAbs(lena_scharr_y)
lena_scharr_xy = cv2.addWeighted(lena_scharr_x, 0.5, lena_scharr_y, 0.5, 0)
cv_imshow('lena_scharr_xy', lena_scharr_xy)
# 可以找出更细致的边界, 我猜的没错 对, 纹理细节这个词用的好
卷积核长下面这样:
一般不会单独使用这个, 常和其他算子组合使用, 对噪声会更加敏感, 但噪音点可能不是边界,所以这个效果单独用不好
这个具体用的时候,会发现是中心点和紧挨着的上下左右四个邻居进行比较, 这里就没有x和y的概念了
laplacian = cv2.Laplacian(img, cv2.CV_64F)
laplacian = cv2.convertScaleAbs(laplacian)
cv_imshow('laplacian', laplacian)
最后对比下各种算子找边缘的效果:
Canny边缘检测算法,用于检测图像的边缘信息。主要包括下面的流程:
这一步是对原始图像处理,去掉一些噪声,让其本身更加平滑, 在Canny算法中用的是高斯滤波器
其他滤波器,像均值滤波器, 中值滤波器等都比较常用,这是滤波器那里的相关知识。
这里就是求各个中心点的梯度, Canny算法中用的是sobel算子
这里是为了去除一些梯度值很小的边缘信息, 以消除边缘信息带来的杂散效应。 这里介绍了两种方法:
这里描述下这种方法是怎么做的, 这个属实有些复杂, 这里是判断像素C这个点要不要被抑制点,即判断C这个点是不是极大值,可以这么做:
M(dtmp1), M(dtmp2), M(C)
的大小, 如果M(C)
比那两个都大,那么说明C这个点的像素值是极大值像素点, 保留下这个点,否则, 把它干掉。 那么接下来的问题,就是M(dtmp1), M(dtmp2)
如何计算呢?
M(dtmp1)
M(g1), M(g2)
这个方法比上面那个简单了, 这里会借助它周围的8个点:
这里是再自定义进行一波筛选
这里其实就是指定了一个最大值和一个最小值,然后看看中心点这个梯度值大小,根据右边的规则进行判断需要需要保留。 这里的连有边界的意思是看他是不是挨着一个边界, 如果它旁边那个点是边界了, 那么这个点也会保留下来,否则会去掉。
上面就是Canny算法各个流程的细节部分,在opencv中用起来其实很简单,只需要一个cv2.Canny
函数即可。
lena = cv2.imread('img/lena.jpg', cv2.IMREAD_GRAYSCALE)
v1 = cv2.Canny(lena, 80, 150)
v2 = cv2.Canny(lena, 50, 100)
res_lena = np.hstack((lena, v1, v2))
cv_imshow('res', res_lena)
看下结果:
这里的minval指定的如果比较小,会找到更加细致的边界, 纹理信息更多, 但可能会拿到很多误选择的边界
maxval指定的如果比较大, 会找到更加严格的边界,但可能会漏掉一些边界
图像金字塔, 把图像组合成像金字塔一样的形状。图像金字塔的作用,比如我们要对一张图像进行特征提取, 我们可以把图像制作成一个金字塔, 对于金字塔里面的每张图片都进行特征提取, 每张图片可能提取的特征是不一样的,这样就可能增加了图像特征的丰富性。
图像金字塔是图像多尺度表达的一种,最主要用于图像分割,是一种以多分辨率来解释图像的有效但概念简单的结构,最早用于视觉和图像压缩。最底部是待处理图像的高分分辨率表示, 越高层,分辨率越低。
常见的两类金字塔:
两者的简要区别:高斯金字塔用来向下降采样图像,注意降采样其实是由金字塔底部向上,分辨率降低,它和我们理解的金字塔概念相反(注意);而拉普拉斯金字塔则用来从金字塔底层图像中向上采样重建一个图像。
将level0级别的图像转换为 level1,level2,level3,level4,图像分辨率不断降低的过程称为向下取样。
从金字塔下往上,是一个向下采样,越采样越小, 主要过程有两步:
将level4级别的图像转换为 level3,level2,level1,leve0,图像分辨率不断增大的过程称为向上取样
金字塔上往下, 是一个向上采样, 越采样越大
它将图像在每个方向上扩大为原图像的2倍,新增的行和列均使用0来填充,并使用于“向下取样”相同的卷积核乘以4,再与放大后的图像进行卷积运算,以获得“新增像素”的新值
注意: 把图像下上采样,再下采样还原,或者是下采样再上采样还原,都会损失掉信息,虽然和原始图片一样大,但会比原始图像模糊,所以上采样和下采样都是非线性处理, 不可逆,会损失掉信息!
下面是具体实现:
img = cv2.imread('img/AM.png') # (442, 340, 3)
# 先向上采样看看
up_sample = cv2.pyrUp(img)
cv_imshow('up', up_sample)
print(up_sample.shape) # (884, 680, 3)
# 向下采样
up_down = cv2.pyrDown(img)
cv_imshow('down', up_down)
print(up_down.shape) # (221, 170, 3)
# 先经历上采样再还原
cv_imshow('up_down', np.hstack((img, cv2.pyrDown(up_sample))))
可以看下最后这个的结果, 会有一些失真的, 因为下采样和上采样,都会进行一定的信息损失
这个东西的公式计算就是上面这个, G i G_i Gi表示第 i i i层的图像,下面解释下流程:
也就是说,拉普拉斯金字塔是通过源图像减去先缩小后再放大的图像的一系列图像构成的。保留的是残差!为图像还原做准备,实现非常简单。
down = cv2.pyrDown(img)
down_up = cv2.pyrUp(down)
l_1 = img - down_up
cv_imshow('l1', l_1)
下面,就是把高斯上采样, 下采样, 拉普拉斯金字塔的这个过程,都写成了函数的形式,可以一键把整个金字塔干出来。
# 高斯降采样金字塔
def Gaussian_Down(img, sample_times):
Gaussian_Down_pyramid = []
for i in range(sample_times):
if i == 0:
down = cv2.pyrDown(img)
else:
down = cv2.pyrDown(down)
Gaussian_Down_pyramid.append(down)
return Gaussian_Down_pyramid
def Gaussian_up(img, sample_times):
Gaussian_up_pyramid = []
for i in range(sample_times):
if i == 0:
up = cv2.pyrUp(img)
else:
up = cv2.pyrUp(up)
Gaussian_up_pyramid.append(up)
return up
def Laplace(img, sample_times):
laplace_pyramid = []
for i in range(sample_times):
if i == 0:
down = cv2.pyrDown(img)
laplace_pyramid.append(img-cv2.pyrUp(cv2.pyrDown(img)))
else:
up_down = cv2.pyrUp(cv2.pyrDown(down))
# 注意这里由于取整的关系,导致这俩还不一样大
if down.shape != up_down.shape:
laplace_pyramid.append(down - up_down[:down.shape[0], :down.shape[1], :])
else:
laplace_pyramid.append(down - up_down)
down = cv2.pyrDown(down)
return laplace_pyramid
根据我目前的理解,轮廓检测和边缘检测是不一样的概念,轮廓检测是只检测对象最外面的那个大轮廓,类似于用一个框圈起对象来,相当于锁定对象的位置,而边缘检测是检测对象内部的各种边界信息。所以不一样。
轮廓检测的作用是可以帮助我们做一些额外的数值特征, 比如图像轮廓的面积,周长, 这样就能反映出大小来了。
核心是下面这个函数:
cv2.findContours(img, mode, method) → contours, hierarchy(返回的轮廓列表及层级信息)
- mode: 轮廓检索模式
- RETR_EXTERNAL: 只检索最外面的轮廓
- RETR_LIST: 检索所有的轮廓,并将其保存到一条链表当中
- RETR_CCOMP: 检索所有的轮廓, 并将他们组织成两层, 顶层是各部分的外部边界, 第二层是空洞的边界
- RETR_TREE: 检索所有的轮廓, 并重构嵌套轮廓的整个层次(最常用), 多层字典?
- method: 轮廓逼近方法:
- CHAIN_APPROX_NONE: 以Freeman链码的方式输出轮廓, 所有其他方法输出多边形(顶点的序列)
- CHAIN_APPROX_SIMPLE: 压缩水平的,垂直的和斜的部分, 也就是,函数只保留他们的终点部分
下面看如何用:
img = cv2.imread('img/car.png')
# 转成灰度, 这里为啥不直接读取的时候转? 因为有些人后序会用到彩色图,仅此而已
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
# 下一步, 对上面的灰度图像进行二值处理
ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) # 像素小于127的弄成0, 大于127的弄成255,这样变成二值图像
# 下面绘制轮廓
# 最新版opencv只返回两个值了 3.2之后, 不会返回原来的二值图像了,直接返回轮廓信息和层级信息
contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
下面是轮廓在原图进行可视化:
# 下面把轮廓进行可视化
draw_img = img.copy() # 这里要copy一下, 否则会改变原始图像, 下面那哥们貌似是原子操作
# drawContours(image, contours, contourIdx, color[, thickness[, lineType[, hierarchy[, maxLevel[, offset]]]]]) -> image
# 这里的contourIdx要绘制的轮廓线,负数表示所有, 正是只画对应位置的那个点
# color是轮廓线的颜色, (0,0,255)表示红色,因为opencv里面通道排列是bgr吧应该--> 对的
# 最后面那个2是线条的宽度,值越大, 线条越宽
res = cv2.drawContours(draw_img, contours, -1, (0, 255, 0), 2)
cv_imshow('res', res)
找轮廓, 是为了把这些信息弄成数值特征,方便后面计算, 这里可以找轮廓面积和轮廓周长等。
但是必须是具体的轮廓, 不能是上面的轮廓列表
def contours_feature(contour):
features = []
features.append(cv2.contourArea(contour)) # 轮廓面积
features.append(cv2.arcLength(contours, True)) # 轮廓周长,后面那个是closed,表示这个曲线是否闭合
return features
controus_feature(countours[0])
这里可以弄一个函数,来找某个具体轮廓的系列特征,然后进行返回,这些特征可以反映出具体对象的大小来。
左边轮廓这个如果找到太细了,就会出现一些毛毛刺刺的一些轮廓,这时候,我们可能会把这种毛刺的这种轮廓给去掉,就需要右边这样的近似结果, 右边两个是两种不同的近似结果,有点避免它过拟合的味道。
下面简单看下轮廓近似的原理:
比如,我AB这是一条曲线,我想找一条或者几条直线近似它, 可以这么做:
使用起来很简单:
# 读取图片 -> 转成灰度图 -> 二值化 -> 找轮廓
img = cv2.imread('img/contours2.png')
# 用上面的函数画一些轮廓
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
cnt = contours[0]
draw_img = img.copy()
res = cv2.drawContours(draw_img, [cnt], -1, (0, 0, 255), 2)
cv_imshow('res', res)
# 下面进行轮廓近似
epsilon = 0.1 * cv2.arcLength(cnt, True) # 这里是指定的阈值,一般按照周长的百分比设置
# 这个阈值越大,那么画出来的轮廓就越粗糙,因为很容易直接一条直线代替曲线, 如果阈值越小,那么近似出来的轮廓细腻,因为d很容易大于阈值,用多条直线近似
approx = cv2.approxPolyDP(cnt, epsilon, True)
draw_img2 = img.copy()
res2 = cv2.drawContours(draw_img2, [approx], -1, (0, 0, 255), 2)
cv_imshow('res2', res2)
基于图像的轮廓,还可以计算一些外接矩形,外接圆等等,然后基于这些外接矩形,外接圆再构建一些数值型的特征,比如再加一些额外的组合特征,像轮廓自身面积与边界矩形比,周长之比等
# 基于这个轮廓画外接矩形
x, y, w, h = cv2.boundingRect(cnt) # 这里返回的是(x, y)是左上角的顶点坐标, w是宽, h是长
draw_img3 = res2.copy()
img_rec = cv2.rectangle(draw_img3, (x, y), (x+w, y+h), (0, 255, 0), 2)
cv_imshow('rec', img_rec)
有了外界矩形,就可以做一些额外的特征:
# 有了这样的一个矩形,我们就可以额外做一些特征
area = cv2.contourArea(cnt)
x, y, w, h = cv2.boundingRect(cnt)
rect_area = w * h
extent = float(area) / rect_area
print('轮廓面积与边界矩形比: ', extent)
还可以做外接圆:
# 还可以做外接圆
(x, y), radius = cv2.minEnclosingCircle(cnt) # 圆心位置和半径
center = (int(x), int(y))
radius = int(radius)
draw_img4 = draw_img3.copy()
res_circle = cv2.circle(draw_img4, center, radius, (255, 0, 0), 2)
cv_imshow('circle', res_circle)
结果如下:
通过轮廓检测算法,是能够找到上面图像中各个图像的具体轮廓,这样就能对每个具体图像图形具体操作,如果再加上后面学习的模板匹配,就能很轻松的让计算机知道这些具体对象是啥了。 像车牌号检测,信用卡数字识别等,其实就是用的这样的原理,先锁定卡上的数字区域,然后再模板匹配。
所以这些内容很重要。
模板匹配和卷积原理很像, 模板在原图像上从原点开始滑动,计算模板与(图像被模板覆盖的地方)的差别程度,这个差别程度的计算方法在opencv里面有6种, 然后将每次计算的结果放入一个矩阵里,作为结果输出。
假如原图是 A ∗ B A*B A∗B大小, 而模板是 a ∗ b a*b a∗b大小, 则输出结果的矩阵 ( A − a + 1 ) ( B − b + 1 ) (A-a+1)(B-b+1) (A−a+1)(B−b+1), 这个感觉和卷积神经网络中的卷积操作很像了。 主要步骤如下:
读入原始图像,一般是灰度图
读入模板图像,也是灰度图
模板匹配,这里直接使用模板匹配函数cv2.matchTemplate(img, template, mode)
, mode表示计算方式
TM_SQDIFF
: 计算平方不同, 计算出来的值越小越相关TM_CCORR
: 计算相关性, 计算出来的值越大,越相关TM_CCOEFF
: 计算相关系数,计算出来的值越大,越相关TM_SQDIFF_NORMED
: 计算归一化平方不同,计算出来的值越接近0, 越相关TM_CCORR_NORMED
: 计算归一化相关性,计算出来的值越接近1, 越相关TM_CCOEFF_NORMED
: 计算归一化相关系数, 计算出来的值越接近1, 越相关具体计算公式可以去查文档。尽量使用下面归一化的结果
获取到匹配好的原始图像的起始位置cv2.minMaxLoc
, 这里可以获取到最大值位置,也可以获取到最小值位置,但是由于上面计算方式的不同, 得先弄明白是最大的时候两个越相关,还是越小的时候, 两个越相关
下面一个示例带起来:
# 1. 读入原始图像,灰度图模式读入 0表示灰度图,还有就是cv2.IMREAD_SCALE
img = cv2.imread('img/cat.jpg', 0) # <==> cv2.imread('img/cat.jpg', cv2.IMREAD_GRAYSCALE)
# 2. 读入模板
template = cv2.imread('img/template.jpg', 0)
# 3. 模板匹配
res = cv2.matchTemplate(img, template, cv2.TM_SQDIFF) # 值越小越相似
res.shape # (548-122+1)(666-181+1)
# 4. 获取匹配好图像的起始位置
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
# 下面尝试把矩形框画出来
top_left = min_loc
bottom_right = (top_left[0]+template.shape[0], top_left[1]+template.shape[1])
# 画矩形
img2 = img.copy()
rect = cv2.rectangle(img2, top_left, bottom_right, 255, 2)
效果如下:
假设图片里面不只是一个对象能匹配呢? 有没有办法把所有的模板图像都匹配出来, 那就需要自己最大或者最小位置的地方了,其实也简单:
img_rgb = cv2.imread('img/mario.jpg')
# 转成灰度图
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY)
template = cv2.imread('img/mario_coin.jpg', 0)
h, w = template.shape[:2]
res = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED)
threshold = 0.8 # 这里设置一个门限
# 取匹配程度大于百分之八十的坐标
loc = np.where(res >= threshold)
# 这个loc是横坐标一个数组,纵坐标一个数组的形式,所以下面得用可选参数的方式进行组合
for pt in zip(*loc[::-1]):
bottom_right = (pt[0]+w, pt[1]+h)
cv2.rectangle(img_rgb, pt, bottom_right, (0, 255, 0), 2)
cv_imshow('img_rgb', img_rgb)
所谓直方图,就是通过直方图,统计一张图片中,各个像素点值出现的个数。
函数介绍如下:
cv2.calcHist(images, channels, mask, histSize, ranges):
- images: 原图像格式为uint8或者float32, 当传入时用[]括起来, 一般时灰度图像居多。
- channels: 同样时中括号括起来, 指定彩色图片的哪个通道,如果时灰度图,它的值就是[0], 如果时彩色图, [0], [1], [2]表示BGR三个通道。
- mask: 掩膜图像, 统计整个图的时候,就时None,如果想统计图像的某一部分,那么可以制作一个掩膜矩阵
- histSize: bin的数目, 可以统计0-255,每个像素点,也可以按照范围, 比如0-10, 10-20, 等等的个数,这里指定
- ranges: 像素值范围,一般默认[0,256)
使用也非常简单:
img_bgr = cv2.imread('img/cat.jpg')
color = ('b', 'g', 'r')
for i, col in enumerate(color):
histr = cv2.calcHist([img_bgr], [i], None, [256], [0, 256])
plt.plot(histr, color=col)
plt.xlim([0, 256])
其实这个东西,用plt.plot
也能画出直方图来,毕竟图片数据是numpy数组, 通过可视化的方式,就能大致上知道图片的像素分布取值了。
mask掩码操作的使用, mask掩码矩阵传入之后,只会统计我们需要部分的直方图:
# 先创建mask
mask = np.zeros(img.shape[:2], np.uint8)
mask[100:300, 100:400] = 255
cv_imshow('mask', mask) # 这样就创造除了一个掩码矩阵, 只有0和255组成, 白色的地方是要显示的,黑色的地方是被遮挡
masked_img = cv2.bitwise_and(img, img, mask=mask) # 与操作 等价img & mask
# 下面对比一下
hist_full = cv2.calcHist([img], [0], None, [256], [0, 256])
hist_mask = cv2.calcHist([img], [0], mask, [256], [0, 256])
直方图的作用,就是可以对一些图像均衡化处理,使得图像的对比度拉高,更加清晰。 看下面这个图片:
先简单介绍下原理:
上面的左图假设是原始图像,右边是均衡化之后的结果,所谓均衡化,无非就是通过一种算法,把原始图像里面的像素值映射成别的值代替。让其更加均匀一些,那么怎么映射呢?
具体使用:
img = cv2.imread('img/clahe.jpg', 0)
plt.hist(img.ravel(), 256)
# 均衡化
equ = cv2.equalizeHist(img)
plt.hist(equ.ravel(), 256)
plt.show()
res = np.hstack((img, equ))
cv_imshow('res', res) # 会发现均衡话之后,变得更加清晰了
效果如下:
均衡话可能存在的问题,虽然有时候会增强图片的对比度,但是可能有些细节丢失掉,因为均衡化类似于全局求了个平均,有细节的地方会受到其他位置的干扰。 所以,改进就是分块进行均衡话, 比如把一张图片分成多个小块,然后在每个小块里面执行均衡化的操作。当然,这样可能带来的问题就是每个小块的边界可能很明显了, opencv提供了一些插值处理来解决这个问题。
# cv2提供了分块均衡化的函数
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) # 自适应均衡化
res_clahe = clahe.apply(img)
res = np.hstack((img, equ, res_clahe))
cv_imshow('res', res)
最终的效果如下:
这就是目前学习到的通过OpenCV库进行图像预处理操作的所有知识了,内容有些多,不适合顺序阅读,而是想着把这些东西整理到一块,后面用到的时候会很方便。 当然,目前对每一块的了解还处在皮毛状态,如果后面学习到更深入的知识,也会及时的在相应版块进行补充。 下面一张导图把知识拎起来:
基础知识应该就到这里了,后面就是通过几个项目把上面所有内容带起来, Rush
参考: