一、介绍
在现实生活中,哈哈镜是指一种表面凹凸不平的镜子,可以反应出人像及物体的扭曲面貌。
本文介绍如何设计变换函数对实时视频(从摄像头读取)进行变形,生成哈哈镜的效果。
具体的要求有:
①采用双线性插值进行图像重采样
②采用cv::VideoCapture读取摄像头视频,并进行实时处理和显示结果。
③优化代码执行效率,改善实时性。(需要打开编译优化,VS下使用release模式编译)。
④将使用哈哈镜过程录制成视频。
二、过程
1、读取摄像头视频并显示
第一次学习OpenCV视频文件读取,有很多没有学习过的函数和方法,参考了该教程。
读取摄像头视频需要使用到以下函数:
①读取视摄像头实时画面数据:
VideoCapture capture(0)
参数0默认是笔记本的摄像头;参数1表示外接摄像头。
②判断摄像头是否打开:
capture.isOpened()
判断视频读取或者摄像头调用是否成功,成功则返回true。
③将画面读取到Mat帧:
capture.read(frame)
如果没有读取帧到Mat对象,那么会返回0。在C++语法下也可以直接使用capture >> frame
④将画面停留、阻塞:
waitKey(20)
“20”表示延时为20ms,相当于1s内显示50帧的画面。
在之前实验中waitKey一般设置为waitKey()或者waitKey(0),意思都是让画面永久停留。
同时waitKey也会等待键盘输入并将当前字符的ASCII码对应的十进制值返回。
在该实验中,如果仍然设置为永久停留,那么函数将一直阻塞,相当于一直阻塞在第一帧数据中,即画面会停留在一个画面的。所以这里需要设置阻塞时间。
阻塞时间越短,那么摄像头读取的实时画面帧率也会越高,视频也会越流畅。但是帧率越高,后续哈哈镜处理的时间成本也越大,导致延迟增加。同时人所能感受到的帧率是有限的,一般动画为24帧/秒,因此这里设置20ms即可。
将上面函数组合起来使用:
Mat frame;
VideoCapture capture(0);//读取视摄像头实时画面数据,0默认是笔记本的摄像头;如果是外接摄像头,这里改为1
while (capture.isOpened())//判断摄像头是否打开
{
//读取当前帧
if (!capture.read(frame))//相当于 capture >> frame
{
cout << "摄像头断开连接或视频读取完成..." << endl;
break;
}
if (!frame.empty())//判断输入的视频帧是否为空的
imshow("window", frame); //在window窗口显示frame摄像头数据画面
char key = waitKey(20);//延时20ms,相当于1s内显示50帧的画面
if (key == 27)//输入"Ese"键则退出
break;
}
capture.release(); //释放摄像头资源
destroyAllWindows(); //释放全部窗口
运行上面函数就可以测试自己的摄像头了,这时候对摄像头读取出来的一帧一帧画面是没有作任何处理直接显示出来的。一般来说此时运行是很流畅的,基本感觉不到延迟。
在该实验中我们设置了key=27(也就是ESE的ASC码)时才会终止。这里需要注意,无论设置成key='q'还是其他的键,都必须使用鼠标点击一下跳出来的视频窗口,激活这个窗口后输入设置的键才能触发(不是点黑框框的终端窗口,是点击有视频的“window”)。
同时因为上面函数有while(1)循环,因此如果直接关掉窗口是不会停止的,会接着再次弹出一个窗口。而如果直接中断终端,那么VS会报异常。正确的关闭方法是使用ESE键正常终止或直接点击VS里的强制终止进程。
所以整个过程思路就是:
①定义一个Mat数据容器frame用来存放摄像头的实时画面数据,使用 VideoCapture函数来获取摄像头的实时画面数据;
②把VideoCapture函数读取的摄像头数据,写到Mat数据容器frame,读取的是当前帧;
③判断frame是否为空,如果不为空,用一个窗口显示摄像头的画面;
④释放资源。
上述摄像头读取出来的画面与我们生活中的场景是相同的,但我们日常习惯了镜像后的画面。因此将原有的画面进行镜像,即上下不变,左右相反,方法如下:
void mirroY(Mat src, Mat& dst)//将src图像镜像变换到dst
{
dst = src.clone();
int cols = src.cols;
for (int i = 0; i < cols; i++)//对于每一列都相反
src.col(cols - 1 - i).copyTo(dst.col(i));
}
2、对实时视频进行变形
上面只是对摄像头读取出来的画面进行了一次镜像翻转,接下来需要继续对画面进行处理。
2.1 凸透镜变换映射函数
哈哈镜放大效果的基本原理是图像的中心区域呈现类似凸透镜效果。简单来说就是变形后的图像的像素点位置映射到原有的图像上时,要更加靠近中心区域。
哈哈镜的实现原理如下:
假设输入图像的宽高为,图像中心点
的坐标
,可知满足如下关系:
因为哈哈镜转化后的图像宽高不变,因此上述对于原图像和转化后的图像都是满足的。
那么变换后的图像中任意一点到中心点的距离为:
设拉伸变换的中心区域的半径为,可以理解为
是哈哈镜的范围大小。
对于变换后与原图像的
映射关系如下:
上面的坐标采用的是直角坐标系。我们程序中对于某一点的坐标使用的是二维数组,因此对于,事实上表示的直角坐标是
。
函数的实现过程如下:
void transformImg()
{
int h = img_transformed.rows;
int w = img_transformed.cols;
float center_x = (float)w / 2;
float center_y = (float)h / 2;
float radius = 300;//该值可以自行定义,它决定了哈哈镜中心放大区域的大小,当图像很大时,应该相应的调大
float dx, dy, distance, x_, y_;
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
dx = x - center_x;
dy = y - center_y;
distance = dx * dx + dy * dy;
if (distance < radius * radius)
{
x_ = dx * sqrt(distance) / radius + center_x;
y_ = dy * sqrt(distance) / radius + center_y;
//双线性插值
/.../
}
else
{
img_transformed.at<Vec3b>(y, x)[0] = img_mirro.at<Vec3b>(y, x)[0];
img_transformed.at<Vec3b>(y, x)[1] = img_mirro.at<Vec3b>(y, x)[1];
img_transformed.at<Vec3b>(y, x)[2] = img_mirro.at<Vec3b>(y, x)[2];
}
}
}
}
上述代码中没有写完全,双线性插值部分在下面部分补充。
上面计算出的可能是含有小数的,如
,在原图像上是找不到这样的位置的,需要进行处理。可以直接使用NN(最近邻)算法,也就是将上述位置四舍五入为
,但这样误差较大,会出现较多模糊点。这里使用双线性插值进行重采样。
为了不对程序数组中的索引造成混淆,后续用表示坐标。
2.2 双线性插值
思路是计算出这个点的周围四个点(左上,右上,左下,右下)的像素值,设为
先进行2次水平方向的线性插值:
之间的插值:
之间的插值:
再进行1次竖直方向的线性插值:
由此得到该位置的像素值。
并不是所有映射后的像素点都需要2次水平插值1次竖直插值,对于整数位置的像素点可能只需要1次水平或竖直插值,或者不需要插值。
//双线性插值
int x1, y1, x2, y2;
//计算该点周围的四个点(y1,x1),(y1,x2),(y2,x1),(y2,x2)
x1 = (int)x_;
x2 = (x_ - x1 == 0) ? x1 : x1 + 1;
y1 = (int)y_;
y2 = (y_ - y1 == 0) ? y1 : y1 + 1;
//小数部分
float dt_x, dt_y;
dt_x = x_ - x1;
dt_y = y_ - y1;
for (int i = 0; i < 3; i++)
{
int a = img_mirro.at<Vec3b>(y1, x1)[i];
int b = img_mirro.at<Vec3b>(y1, x2)[i];
int c = img_mirro.at<Vec3b>(y2, x1)[i];
int d = img_mirro.at<Vec3b>(y2, x2)[i];
//2次水平插值
float e = a + (b - a) * dt_x;
float f = c + (d - c) * dt_x;
//1次竖直插值
float g = e + (f - e) * dt_y;
img_transformed.at<Vec3b>(y, x)[i] = (int)g;
}
2.3 程序运行
可以看到范围内的区域形成了凸透镜的效果,而
范围外的区域没有变化,形成了哈哈镜的效果。
3、代码优化
上述算法执行时,是在遍历每一个位置时才计算映射到原图像的位置。
对于相同尺寸大小的画面,摄像头采集到的各个帧的画面中点的映射位置是一样的。如果对于每一帧都要进行了重复的运算,那么会产生较大延迟。
因此只需在处理第一帧时计算映射位置并存储该映射的位置,后续每一帧都直接取出该映射位置即可。
创建全局变量的结构体数组pos:
bool first;
struct position
{
int x1, x2, y1, y2;
float dt_x, dt_y;
};
position pos[2000][2000];
修改后的函数如下:
void transformImg()
{
int h = img_transformed.rows;
int w = img_transformed.cols;
float center_x = (float)w / 2;
float center_y = (float)h / 2;
float radius = 300;//该值可以自行定义,它决定了哈哈镜中心放大区域的大小,当图像很大时,应该相应的调大
float dx, dy, distance, x_, y_;
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
dx = x - center_x;
dy = y - center_y;
distance = dx * dx + dy * dy;
if (distance < radius * radius)
{
if (!first)//只会在第一帧时计算
{
x_ = dx * sqrt(distance) / radius + center_x;
y_ = dy * sqrt(distance) / radius + center_y;
//双线性插值
//计算该点周围的四个点(y1,x1),(y1,x2),(y2,x1),(y2,x2)
pos[y][x].x1 = (int)x_;
pos[y][x].x2 = (x_ - pos[y][x].x1 == 0) ? pos[y][x].x1 : pos[y][x].x1 + 1;
pos[y][x].y1 = (int)y_;
pos[y][x].y2 = (y_ - pos[y][x].y1 == 0) ? pos[y][x].y1 : pos[y][x].y1 + 1;
//小数部分
pos[y][x].dt_x = x_ - pos[y][x].x1;
pos[y][x].dt_y = y_ - pos[y][x].y1;
}
for (int i = 0; i < 3; i++)
{
int a = img_mirro.at<Vec3b>(pos[y][x].y1, pos[y][x].x1)[i];
int b = img_mirro.at<Vec3b>(pos[y][x].y1, pos[y][x].x2)[i];
int c = img_mirro.at<Vec3b>(pos[y][x].y2, pos[y][x].x1)[i];
int d = img_mirro.at<Vec3b>(pos[y][x].y2, pos[y][x].x2)[i];
//2次水平插值
float e = a + (b - a) * pos[y][x].dt_x;
float f = c + (d - c) * pos[y][x].dt_x;
//1次竖直插值
float g = e + (f - e) * pos[y][x].dt_y;
img_transformed.at<Vec3b>(y, x)[i] = (int)g;
}
}
else
{
img_transformed.at<Vec3b>(y, x)[0] = img_mirro.at<Vec3b>(y, x)[0];
img_transformed.at<Vec3b>(y, x)[1] = img_mirro.at<Vec3b>(y, x)[1];
img_transformed.at<Vec3b>(y, x)[2] = img_mirro.at<Vec3b>(y, x)[2];
}
}
}
if (!first)//后续的帧不再进行计算
first = 1;
}
4、release模式编译
没有打开编译优化时,优化后的代码依然有延迟。
刚开始改用release模式编译的时候,将debug模式下的属性表复制到了release模式的属性表,没有显示找不到库,但是个别函数显示无法解析的错误。
经过查找资料后发现,有两个原因:
①debug模式和Release模式配置不一样,需要单独配置。debug模式附加依赖项比Release模式多了一个“d”, Debug模式是:opencv_world460d.lib,release模式是opencv_world460.lib
②debug模式代码生成运行库是多线程调试 DLL (/MDd),release模式是:多线程调试 DLL (/MD)
将上述问题修改完后,即可成功运行。经过编译优化之后,基本感觉不到延迟, 实时性有了很大提升。
具体效果见自己测试的视频。
5、保存视频
OpenCV提供VideoWriter类写视频文件,类的构造函数可以指定文件名、播放帧率、帧尺寸、是否创建彩色视频。
VideoWriter函数参数如下:
VideoWriter(filename, fourcc, fps, frameSize, isColor)
①参数filename:保存的文件的路径/文件名,默认保存在当前文件夹下
②参数fourcc:指定编码器
fourcc 指定编码器有如下几种:
CV_FOURCC('P', 'I', 'M', '1') = MPEG-1 code
CV_FOURCC('M', 'J', 'P', 'G') = motion-jpeg codec
CV_FOURCC('M', 'P', '4', '2') = MPEG-4.2 codec
CV_FOURCC('D', 'I', 'V', '3') = MPEG-4.3 codec
CV_FOURCC('D', 'I', 'V', 'X') = MPEG-4 codec
CV_FOURCC('U', '2', '6', '3') = H263 codec
CV_FOURCC('I', '2', '6', '3') = H263I codec
CV_FOURCC('F', 'L', 'V', '1') = FLV1 codec
③参数fps:要保存的视频的帧率,即1秒多少帧
④参数frameSize:要保存的文件的画面尺寸
⑤参数isColor:指示是黑白画面还是彩色的画面,1表示彩色,0表示黑白。
需要注意的是:
①写入视频前需安装对应的编解码器
②生成视频是否支持彩色应与构造函数设置一致
③生成视频尺寸需与读取视频尺寸一致
以下是创建过程:
int codec = VideoWriter::fourcc('M', 'J', 'P', 'G'); // 选择编码格式
double fps = 30.0; //设置视频帧率
string filename = "哈哈镜vedio.avi"; //保存的视频文件名称
Size size0 = Size(capture.get(CAP_PROP_FRAME_WIDTH), capture.get(CAP_PROP_FRAME_HEIGHT));
VideoWriter writer(filename, codec, fps, size0, 1); //创建保存视频文件的视频流
//也可以创建时不指定参数VideoWriter writer,然后调用writer.open();
然后在显示图片之前调用如下函数即可保存到writer中:
writer << img;//写入writer保存视频
//或writer.write(img)
在最后退出后,会自动生成名为“哈哈镜vedio.avi”
的视频文件,点击即可播放。
三、代码
#include <iostream>
#include<opencv2/opencv.hpp>
using namespace std;
using namespace cv;
Mat img, img_mirro, img_transformed;
bool first;
struct position
{
int x1, x2, y1, y2;
float dt_x, dt_y;
};
position pos[2000][2000];
void mirroY(Mat src, Mat& dst)//将src图像镜像变换到dst
{
dst = src.clone();
int cols = src.cols;
for (int i = 0; i < cols; i++)//对于每一列都相反
src.col(cols - 1 - i).copyTo(dst.col(i));
}
void transformImg()
{
int h = img_transformed.rows;
int w = img_transformed.cols;
float center_x = (float)w / 2;
float center_y = (float)h / 2;
float radius = 300;//该值可以自行定义,它决定了哈哈镜中心放大区域的大小,当图像很大时,应该相应的调大
float dx, dy, distance, x_, y_;
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
dx = x - center_x;
dy = y - center_y;
distance = dx * dx + dy * dy;
if (distance < radius * radius)
{
if (!first)//只会在第一帧时计算
{
x_ = dx * sqrt(distance) / radius + center_x;
y_ = dy * sqrt(distance) / radius + center_y;
//双线性插值
//计算该点周围的四个点(y1,x1),(y1,x2),(y2,x1),(y2,x2)
pos[y][x].x1 = (int)x_;
pos[y][x].x2 = (x_ - pos[y][x].x1 == 0) ? pos[y][x].x1 : pos[y][x].x1 + 1;
pos[y][x].y1 = (int)y_;
pos[y][x].y2 = (y_ - pos[y][x].y1 == 0) ? pos[y][x].y1 : pos[y][x].y1 + 1;
//小数部分
pos[y][x].dt_x = x_ - pos[y][x].x1;
pos[y][x].dt_y = y_ - pos[y][x].y1;
}
for (int i = 0; i < 3; i++)
{
int a = img_mirro.at<Vec3b>(pos[y][x].y1, pos[y][x].x1)[i];
int b = img_mirro.at<Vec3b>(pos[y][x].y1, pos[y][x].x2)[i];
int c = img_mirro.at<Vec3b>(pos[y][x].y2, pos[y][x].x1)[i];
int d = img_mirro.at<Vec3b>(pos[y][x].y2, pos[y][x].x2)[i];
//2次水平插值
float e = a + (b - a) * pos[y][x].dt_x;
float f = c + (d - c) * pos[y][x].dt_x;
//1次竖直插值
float g = e + (f - e) * pos[y][x].dt_y;
img_transformed.at<Vec3b>(y, x)[i] = (int)g;
}
}
else
{
img_transformed.at<Vec3b>(y, x)[0] = img_mirro.at<Vec3b>(y, x)[0];
img_transformed.at<Vec3b>(y, x)[1] = img_mirro.at<Vec3b>(y, x)[1];
img_transformed.at<Vec3b>(y, x)[2] = img_mirro.at<Vec3b>(y, x)[2];
}
}
}
if (!first)//后续的帧不再进行计算
first = 1;
}
int main(int argc, char** argv)
{
VideoCapture capture(0);//读取视摄像头实时画面数据,0默认是笔记本的摄像头;如果是外接摄像头,这里改为1
int codec = VideoWriter::fourcc('M', 'J', 'P', 'G'); // 选择编码格式
double fps = 30.0; //设置视频帧率
string filename = "哈哈镜vedio.avi"; //保存的视频文件名称
Size size0 = Size(capture.get(CAP_PROP_FRAME_WIDTH), capture.get(CAP_PROP_FRAME_HEIGHT));
VideoWriter writer(filename, codec, fps, size0, 1); //创建保存视频文件的视频流
//也可以创建时不指定参数,然后调用writer.open();
while (capture.isOpened())//判断摄像头是否打开
{
//读取当前帧
if (!capture.read(img))//相当于 capture >> frame
{
cout << "摄像头断开连接或视频读取完成..." << endl;
break;
}
if (!img.empty())//判断输入的视频帧是否为空的
{
mirroY(img, img_mirro);//对原图像镜像
img_transformed = Mat::zeros(img_mirro.size(), img_mirro.type());
//转换图像
transformImg();
writer << img_transformed;//写入writer保存视频,或writer.write(img)
imshow("哈哈镜", img_transformed); //在window窗口显示frame摄像头数据画面
}
char key = waitKey(20);//延时20ms,相当于1s内显示50帧的画面
if (key == 27)//输入"Ese"键则退出
break;
}
capture.release(); //释放摄像头资源
destroyAllWindows(); //释放全部窗口
return 0;
}