五、Python-OpenCV基础
5.3.6 视频功能
视频中最常用的就是从视频设备采集图片或者视频,或者读取视频文件并从中采样。
所以比较重要的也是两个模块,一个是VideoCapture,用于获取相机设备并捕获图像和视频,或是从文件中捕获。还有一个VideoWriter,用于生成视频。
还是来看例子理解这两个功能的用法,首先是一个制作延时摄影视频的小例子:
import cv2 import time interval = 60 # 捕获图像的间隔,单位:秒 num_frames = 500 # 捕获图像的总帧数 out_fps = 24 # 输出文件的帧率 # VideoCapture(0)表示打开默认的相机 cap = cv2.VideoCapture(0) # 获取捕获的分辨率 size =(int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) # 设置要保存视频的编码,分辨率和帧率 video = cv2.VideoWriter( "time_lapse.avi", cv2.VideoWriter_fourcc('M','P','4','2'), out_fps, size ) # 对于一些低画质的摄像头,前面的帧可能不稳定,略过 for i in range(42): cap.read() # 开始捕获,通过read()函数获取捕获的帧 try: for i in range(num_frames): _, frame = cap.read() video.write(frame) # 如果希望把每一帧也存成文件,比如制作GIF,则取消下面的注释 # filename = '{:0>6d}.png'.format(i) # cv2.imwrite(filename, frame) print('Frame {} is captured.'.format(i)) time.sleep(interval) except KeyboardInterrupt: # 提前停止捕获 print('Stopped! {}/{} frames captured!'.format(i, num_frames)) # 释放资源并写入视频文件 video.release() cap.release()
这个例子实现了延时摄影的功能,把程序打开并将摄像头对准一些缓慢变化的画面,比如桌上缓慢蒸发的水,或者正在生长的小草,就能制作出有趣的延时摄影作品。比如下面这个链接中的图片就是用这段程序生成的:
程序的结构非常清晰简单,注释里也写清楚了每一步,所以流程就不解释了。
值得一提的就是 KeyboardInterrupt,这是一个常用的异常,用来获取用户Ctrl+C的中止,捕获这个异常后直接结束循环并释放VideoCapture和VideoWriter的资源,使已经捕获好的部分视频可以顺利生成。
从视频中截取帧也是处理视频时常见的任务,下面代码实现的是遍历一个指定文件夹下的所有视频并按照指定的间隔进行截屏并保存:
import cv2 import os import sys # 第一个输入参数是包含视频片段的路径 input_path = sys.argv[1] # 第二个输入参数是设定每隔多少帧截取一帧 frame_interval = int(sys.argv[2]) # 列出文件夹下所有的视频文件 filenames = os.listdir(input_path) # 获取文件夹名称 video_prefix = input_path.split(os.sep)[-1] # 建立一个新的文件夹,名称为原文件夹名称后加上_frames frame_path = '{}_frames'.format(input_path) if not os.path.exists(frame_path): os.mkdir(frame_path) # 初始化一个VideoCapture对象 cap = cv2.VideoCapture() # 遍历所有文件 for filename in filenames: filepath = os.sep.join([input_path, filename]) # VideoCapture::open函数可以从文件获取视频 cap.open(filepath) # 获取视频帧数 n_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 同样为了避免视频头几帧质量低下,黑屏或者无关等 for i in range(42): cap.read() for i in range(n_frames): ret, frame = cap.read() # 每隔frame_interval帧进行一次截屏操作 if i % frame_interval == 0: imagename = '{}_{}_{:0>6d}.jpg'.format(video_prefix, filename.split('.')[0], i) imagepath = os.sep.join([frame_path, imagename]) print('exported {}!'.format(imagepath)) cv2.imwrite(imagepath, frame) # 执行结束释放资源 cap.release()
5.3.7 用OpenCV实现数据增加小工具
到目前我们已经熟悉了numpy中的随机模块,多进程调用和OpenCV的基本操作,基于这些基础,本节将从思路到代码一步步实现一个最基本的数据增加小工具。
第三章和第四章都提到过数据增加(data augmentation),作为一种深度学习中的常用手段,数据增加对模型的泛化性和准确性都有帮助。数据增加的具体使用方式一般有两种,一种是实时增加,比如在Caffe中加入数据扰动层,每次图像都先经过扰动操作,再去训练,这样训练经过几代(epoch)之后,就等效于数据增加。还有一种是更加直接简单一些的,就是在训练之前就通过图像处理手段对数据样本进行扰动和增加,也就是本节要实现的。
这个例子中将包含三种基本类型的扰动:随机裁剪,随机旋转和随机颜色/明暗。
5.3.7.1 随机裁剪
AlexNet中已经讲过了随机裁剪的基本思路,我们的小例子中打算更进一步:在裁剪的时候考虑图像宽高比的扰动。
在绝大多数用于分类的图片中,样本进入网络前都是要变为统一大小,所以宽高比扰动相当于对物体的横向和纵向进行了缩放,这样除了物体的位置扰动,又多出了一项扰动。
只要变化范围控制合适,目标物体始终在画面内,这种扰动是有助于提升泛化性能的。实现这种裁剪的思路如下图所示:
图中最左边是一幅需要剪裁的画面,首先根据这幅画面我们可以算出一个宽高比w/h。然后设定一个小的扰动范围δ和要裁剪的画面占原画面的比例β,从到之间按均匀采样,获取一个随机数作为裁剪后画面的宽高比扰动的比例,则裁剪后画面的宽和高分别为:
想象一下先把这个宽为w’,高为h’的区域置于原画面的右下角,则这个区域的左上角和原画面的左上角框出的小区域,如图中的虚线框所示,就是裁剪后区域左上角可以取值的范围。所以在这个区域内随机采一点作为裁剪区域的左上角,就实现了如图中位置随机,且宽高比也随机的裁剪。
5.3.7.2 随机旋转
前面讲到过的旋转比起来,做数据增加时,一般希望旋转是沿着画面的中心。这样除了要知道旋转角度,还得计算平移的量才能让仿射变换的效果等效于旋转轴在画面中心,好在OpenCV中有现成的函数cv2.getRotationMatrix2D()可以使用。这个函数的第一个参数是旋转中心,第二个参数是逆时针旋转角度,第三个参数是缩放倍数,对于只是旋转的情况下这个值是1,返回值就是做仿射变换的矩阵。
直接用这个函数并接着使用cv2.warpAffine()会有一个潜在的问题,就是旋转之后会出现黑边。如果要旋转后的画面不包含黑边,就得沿着原来画面的轮廓做个内接矩形,该矩形的宽高比和原画面相同,如下图所示:
在图中,可以看到,限制内接矩形大小的主要是原画面更靠近中心的那条边,也就是图中比较长的一条边AB。因此我们只要沿着中心O和内接矩形的顶点方向的直线,求出和AB的交点P,就得到了内接矩形的大小。先来看长边的方程,考虑之前画面和横轴相交的点,经过角度-θ旋转后,到了图中的Q点所在:
当然需要注意的是,对于宽高比非常大或者非常小的图片,旋转后如果裁剪往往得到的画面是非常小的一部分,甚至不包含目标物体。所以是否需要旋转,以及是否需要裁剪,如果裁剪角度多少合适,都要视情况而定。
5.3.7.3 随机颜色和明暗
比起AlexNet论文里在PCA之后的主成分上做扰动的方法,本书用来实现随机的颜色以及明暗的方法相对简单很多,就是给HSV空间的每个通道,分别加上一个微小的扰动。其中对于色调,从到之间按均匀采样,获取一个随机数作为要扰动的值,然后新的像素值x’为原始像素值x +;对于其他两个空间则是新像素值x’为原始像素值x的(1+)倍,从而实现色调,饱和度和明暗度的扰动。
因为明暗度并不会对图像的直方图相对分布产生大的影响,所以在HSV扰动基础上,考虑再加入一个Gamma扰动,方法是设定一个大于1的Gamma值的上限γ,因为这个值通常会和1是一个量级,再用均匀采样的近似未必合适,所以从-logγ到logγ之间均匀采样一个值α,然后用作为Gamma值进行变换。
5.3.8 多进程调用加速处理
做数据增加时如果样本量本身就不小,则处理起来可能会很耗费时间,所以可以考虑利用多进程并行处理。比如我们的例子中,设定使用场景是输入一个文件夹路径,该文件夹下包含了所有原始的数据样本。用户指定输出的文件夹和打算增加图片的总量。执行程序的时候,通过os.listdir()获取所有文件的路径,然后按照上一章讲过的多进程平均划分样本的办法,把文件尽可能均匀地分给不同进程,进行处理。
5.3.9 图片数据增加小工具
按照前面4个部分的思路和方法,这节来实现这么一个图片数据增加小工具,首先对于一些基础的操作,我们定义在一个叫做image_augmentation.py的文件里:
import numpy as np import cv2 ''' 定义裁剪函数,四个参数分别是: 左上角横坐标x0 左上角纵坐标y0 裁剪宽度w 裁剪高度h ''' crop_image = lambda img, x0, y0, w, h: img[y0:y0+h, x0:x0+w] ''' 随机裁剪 area_ratio为裁剪画面占原画面的比例 hw_vari是扰动占原高宽比的比例范围 ''' def random_crop(img, area_ratio, hw_vari): h, w = img.shape[:2] hw_delta = np.random.uniform(-hw_vari, hw_vari) hw_mult = 1 + hw_delta # 下标进行裁剪,宽高必须是正整数 w_crop = int(round(w*np.sqrt(area_ratio*hw_mult))) # 裁剪宽度不可超过原图可裁剪宽度 if w_crop > w: w_crop = w h_crop = int(round(h*np.sqrt(area_ratio/hw_mult))) if h_crop > h: h_crop = h # 随机生成左上角的位置 x0 = np.random.randint(0, w-w_crop+1) y0 = np.random.randint(0, h-h_crop+1) return crop_image(img, x0, y0, w_crop, h_crop) ''' 定义旋转函数: angle是逆时针旋转的角度 crop是个布尔值,表明是否要裁剪去除黑边 ''' def rotate_image(img, angle, crop): h, w = img.shape[:2] # 旋转角度的周期是360° angle %= 360 # 用OpenCV内置函数计算仿射矩阵 M_rotate = cv2.getRotationMatrix2D((w/2, h/2), angle, 1) # 得到旋转后的图像 img_rotated = cv2.warpAffine(img, M_rotate, (w, h)) # 如果需要裁剪去除黑边 if crop: # 对于裁剪角度的等效周期是180° angle_crop = angle % 180 # 并且关于90°对称 if angle_crop > 90: angle_crop = 180 - angle_crop # 转化角度为弧度 theta = angle_crop * np.pi / 180.0 # 计算高宽比 hw_ratio = float(h) / float(w) # 计算裁剪边长系数的分子项 tan_theta = np.tan(theta) numerator = np.cos(theta) + np.sin(theta) * tan_theta # 计算分母项中和宽高比相关的项 r = hw_ratio if h > w else 1 / hw_ratio # 计算分母项 denominator = r * tan_theta + 1 # 计算最终的边长系数 crop_mult = numerator / denominator # 得到裁剪区域 w_crop = int(round(crop_mult*w)) h_crop = int(round(crop_mult*h)) x0 = int((w-w_crop)/2) y0 = int((h-h_crop)/2) img_rotated = crop_image(img_rotated, x0, y0, w_crop, h_crop) return img_rotated ''' 随机旋转 angle_vari是旋转角度的范围[-angle_vari, angle_vari) p_crop是要进行去黑边裁剪的比例 ''' def random_rotate(img, angle_vari, p_crop): angle = np.random.uniform(-angle_vari, angle_vari) crop = False if np.random.random() > p_crop else True return rotate_image(img, angle, crop) ''' 定义hsv变换函数: hue_delta是色调变化比例 sat_delta是饱和度变化比例 val_delta是明度变化比例 ''' def hsv_transform(img, hue_delta, sat_mult, val_mult): img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(np.float) img_hsv[:, :, 0] = (img_hsv[:, :, 0] + hue_delta) % 180 img_hsv[:, :, 1] *= sat_mult img_hsv[:, :, 2] *= val_mult img_hsv[img_hsv > 255] = 255 return cv2.cvtColor(np.round(img_hsv).astype(np.uint8), cv2.COLOR_HSV2BGR) ''' 随机hsv变换 hue_vari是色调变化比例的范围 sat_vari是饱和度变化比例的范围 val_vari是明度变化比例的范围 ''' def random_hsv_transform(img, hue_vari, sat_vari, val_vari): hue_delta = np.random.randint(-hue_vari, hue_vari) sat_mult = 1 + np.random.uniform(-sat_vari, sat_vari) val_mult = 1 + np.random.uniform(-val_vari, val_vari) return hsv_transform(img, hue_delta, sat_mult, val_mult) ''' 定义gamma变换函数: gamma就是Gamma ''' def gamma_transform(img, gamma): gamma_table = [np.power(x / 255.0, gamma) * 255.0 for x in range(256)] gamma_table = np.round(np.array(gamma_table)).astype(np.uint8) return cv2.LUT(img, gamma_table) ''' 随机gamma变换 gamma_vari是Gamma变化的范围[1/gamma_vari, gamma_vari) ''' def random_gamma_transform(img, gamma_vari): log_gamma_vari = np.log(gamma_vari) alpha = np.random.uniform(-log_gamma_vari, log_gamma_vari) gamma = np.exp(alpha) return gamma_transform(img, gamma)
调用这些函数需要通过一个主程序。这个主程序里首先定义三个子模块,定义一个函数parse_arg()通过Python的argparse模块定义了各种输入参数和默认值。需要注意的是这里用argparse来输入所有参数是因为参数总量并不是特别多,如果增加了更多的扰动方法,更合适的参数输入方式可能是通过一个配置文件。
然后定义一个生成待处理图像列表的函数generate_image_list(),根据输入中要增加图片的数量和并行进程的数目尽可能均匀地为每个进程生成了需要处理的任务列表。
执行随机扰动的代码定义在augment_images()中,这个函数是每个进程内进行实际处理的函数,执行顺序是镜像裁剪旋转HSVGamma。