**
前言
**
最近参加了大创项目,题目涉及到计算机视觉,学姐发了个修正图像的博客链接,于是打算用这个题目入门OpenCV。
分析问题
照片中的PPT区域总是沿着x,y,z三个轴都有倾斜(如下图),要想把照片翻转到平行位置,需要进行透视变换,而透视变换需要同一像素点变换前后的坐标。由此可以想到,提取矩形区域四个角的坐标作为变换前的坐标,变换后的坐标可以设为照片的四个角落,经过投影变换,矩形区域将会翻转并充满图像。
因此我们要解决的问题变为:提取矩形的四个角落、进行透视变换。
提取矩形角落坐标
矩形的检测主要是提取边缘,PPT显示部分的亮度通常高于周围环境,我们可以将图片阈值化,将PPT部分与周围环境明显的分别开来,这对后边的边缘检测非常有帮助。
检测矩形并提取坐标需要对图像进行预处理、边缘检测、提取轮廓、检测凸包、角点检测。
预处理
由于手机拍摄的照片像素可能会很高,为了加快处理速度,我们首先缩小图片,这里缩小了4倍。
pyrDown(srcPic, shrinkedPic); //减小尺寸 加快运算速度
pyrDown(shrinkedPic, shrinkedPic);
转化为灰度图
cvtColor(shrinkedPic, greyPic, COLOR_BGR2GRAY); //转化为灰度图
中值滤波
medianBlur(greyPic, greyPic, 7); //中值滤波
转为二值图片
threshold(greyPic, binPic, 80, 255, THRESH_BINARY); //阈值化为二值图片
此时图片已经变成了这个样子:
可见PPT部分已经与环境分离开来。
边缘检测与轮廓处理
进行Canny边缘检测
Canny(binPic, cannyPic, cannyThr, cannyThr*FACTOR); //Canny边缘检测
这里 cannyThr = 200, FACTOR = 2.5
可能由于边缘特征过于明显,系数在100-600范围(具体数字可能有出入,反正范围非常大)内产生的效果几乎相同。
提取轮廓
vector<vector<Point>> contours; //储存轮廓
vector<Vec4i> hierarchy;
findContours(cannyPic, contours, hierarchy, RETR_CCOMP, CHAIN_APPROX_SIMPLE); //获取轮廓
findContour函数原型如下:
CV_EXPORTS_W void findContours( InputOutputArray image, OutputArrayOfArrays contours,
OutputArray hierarchy, int mode,
int method, Point offset = Point());
检测到的轮廓都存在contours里,每个轮廓保存为一个vector
hierarchy为可选的输出向量,包括图像的拓扑信息,这里可以选择不用。
我们可以反复调用drawContours函数将轮廓画出
linePic = Mat::zeros(cannyPic.rows, cannyPic.cols, CV_8UC3);
for (int index = 0; index < contours.size(); index++){
drawContours(linePic, contours, index, Scalar(rand() & 255, rand() & 255, rand() & 255), 1, 8/*, hierarchy*/);
}
drawContours函数原型:
CV_EXPORTS_W void drawContours( InputOutputArray image, InputArrayOfArrays contours,
int contourIdx, const Scalar& color,
int thickness = 1, int lineType = LINE_8,
InputArray hierarchy = noArray(),
int maxLevel = INT_MAX, Point offset = Point() );
作用是将contours中的第contourIdx条轮廓用color颜色绘制到image中,thickness为线条的粗细, contourIdx为负数时画出所有轮廓
这里要注意的是在绘制轮廓前要提前为输出矩阵分配空间,否则会出现以下错误
OpenCV(3.4.1) Error: Assertion failed (size.width>0 && size.height>0) in cv::imshow, file C:\build\master_winpack-build-win64-vc15\opencv\modules\highgui\src\window.cpp, line 356
提取面积最大的轮廓并用多边形将轮廓包围
从上面的轮廓图中看出,PPT的矩形已经成为了图片的主要部分,接下来的思路是提取面积最大的轮廓,得到矩形轮廓。
vector<vector<Point>> polyContours(contours.size());
int maxArea = 0;
for (int index = 0; index < contours.size(); index++){
if (contourArea(contours[index]) > contourArea(contours[maxArea]))
maxArea = index;
approxPolyDP(contours[index], polyContours[index], 10, true);
}
contourArea用来计算轮廓的面积
approxPolyDP的作用是用多边形包围轮廓,可以得到严格的矩形,有助于找到角点
画出矩形,同样注意要提前为Mat分配空间
Mat polyPic = Mat::zeros(shrinkedPic.size(), CV_8UC3);
drawContours(polyPic, polyContours, maxArea, Scalar(0,0,255/*rand() & 255, rand() & 255, rand() & 255*/), 2);
如图,接下来我们只需提取到四个角的坐标
寻找凸包
vector<int> hull;
convexHull(polyContours[maxArea], hull, false); //检测该轮廓的凸包
convexHull函数原型
CV_EXPORTS_W void convexHull( InputArray points, OutputArray hull,
bool clockwise = false, bool returnPoints = true );
hull为输出参数, clockwise决定凸包顺逆时针方向, returnPoints为真时返回凸包的各个点,否则返回各点的指数
hull可以为vector类型,此时返回的是凸包点在原图中的下标索引
我们可以把点和多边形添加到原图中查看效果
for (int i = 0; i < hull.size(); ++i){
circle(polyPic, polyContours[maxArea][i], 10, Scalar(rand() & 255, rand() & 255, rand() & 255), 3);
}
addWeighted(polyPic, 0.5, shrinkedPic, 0.5, 0, shrinkedPic);
现在我们已经比较准确地获得了需要的点,下面就要利用这些点进行坐标映射。
投影变换
投影变换需要像素在两个坐标系中的坐标一一对应,虽然我们已经有了四个坐标,但还没有区分它们的位置。
新建两个数组
Point2f srcPoints[4], dstPoints[4];
dstPoints[0] = Point2f(0, 0);
dstPoints[1] = Point2f(srcPic.cols, 0);
dstPoints[2] = Point2f(srcPic.cols, srcPic.rows);
dstPoints[3] = Point2f(0, srcPic.rows);
dstPoints储存的是变换后各点的坐标,依次为左上,右上,右下, 左下
srcPoints储存的是上面得到的四个角的坐标
下面对得到的四个点进行处理
for (int i = 0; i < 4; i++){
polyContours[maxArea][i] = Point2f(polyContours[maxArea][i].x * 4, polyContours[maxArea][i].y * 4); //恢复坐标到原图
}
//对四个点进行排序 分出左上 右上 右下 左下
bool sorted = false;
int n = 4;
while (!sorted){
for (int i = 1; i < n; i++){
sorted = true;
if (polyContours[maxArea][i-1].x > polyContours[maxArea][i].x){
swap(polyContours[maxArea][i-1], polyContours[maxArea][i]);
sorted = false;
}
}
n--;
}
if (polyContours[maxArea][0].y < polyContours[maxArea][1].y){
srcPoints[0] = polyContours[maxArea][0];
srcPoints[3] = polyContours[maxArea][1];
}
else{
srcPoints[0] = polyContours[maxArea][1];
srcPoints[3] = polyContours[maxArea][0];
}
if (polyContours[maxArea][9].y < polyContours[maxArea][10].y){
srcPoints[1] = polyContours[maxArea][2];
srcPoints[2] = polyContours[maxArea][3];
}
else{
srcPoints[1] = polyContours[maxArea][3];
srcPoints[2] = polyContours[maxArea][2];
}
即先对四个点的x坐标进行冒泡排序分出左右,再根据两对坐标的y值比较分出上下
(笔者试图通过凸包的顺逆时针顺序以及凸包点与原点的距离来活得位置信息,却均以失败告终)
坐标变换需要矩阵运算,OpenCV中给我们提供了getPerspectiveTransform函数用来得到矩阵
Mat transMat = getPerspectiveTransform(srcPoints, dstPoints); //得到变换矩阵
接下来进行坐标变换,网上查到的步骤都是通过perspectiveTransform函数变换,但尝试多次都出现了报错,Google了好长时间才知道原来这个函数的传入输入输出参数均为点集,我们这个场景用起来比较麻烦。
而warpPerspective函数可以直接传入输入Mat类型数据,比较方便
warpPerspective(srcPic, outPic, transMat, srcPic.size()); //进行坐标变换
参数分别为输入输出图像、变换矩阵、大小。
坐标变换后就得到了我们要的最终图像。
总结
我们利用了屏幕亮度较高的特点,通过二值化突出轮廓提取坐标,进行透视变换。
但局限性在于,如果矩形的亮度与背景相差不大,就很难用这种方法检测到轮廓。