本文为在iOS环境下利用OpenCV技术实现全景图片合成并对合成图片进行剪裁的简单实现。
Image Stitching with OpenCV and Python
本文参考以上连接实现多张图像的拼接,并通过C++和Objective-C代码实现构建一张全景图。
根据多个图像创建全景图的步骤为:
检测两张图像的关键点特征(DoG、Harris等)
计算不变特征描述符(SIFT、SURF或ORB等)
根据关键点特征和描述符,对两张图像进行匹配,得到若干匹配点对,并移除错误匹配;
使用Ransac算法和匹配的特征来估计单应矩阵(homography matrix);
通过单应矩阵来对图像进行仿射变换;
两图像拼接,重叠部分融合;
裁剪以获得美观的最终图像。
原理比较复杂,本文先不讲解,OpenCV中已经实现了全景图拼接的算法,cv::Stitcher::createDefault(try_use_gpu) (OpenCV 3.x) 和 cv::Stitcher::create()(OpenCV 4.x) 。
该算法对以下条件具有较好的鲁棒性:
输入图像的顺序
图像的方向
光照变化
图像噪声
OpenCV在Stitcher类中实现的拼接模块流程(源)
一、函数介绍
OpenCV 3.x 的 cv::Stitcher::createDefault() 函数原型为:
Stitcher stitcher = Stitcher::createDefault(try_use_gpu);
这个函数有一个参数 try_use_gpu,它可以用来提升图像拼接整个过程的速度。
OpenCV 4 的 cv::Stitcher::create()函数原型为:
Ptr create(Mode mode = Stitcher::PANORAMA);
要执行实际的图像拼接,我们需要调用 .stitch 方法:
OpenCV 3.x:
stitch(...) method of cv::Stitcher instance
Status cv::Stitcher::stitch(InputArrayOfArrays images,
const std::vector< std::vector< Rect > > & rois,
OutputArray pano )
OpenCV 4.x:
stitch(...) method of cv::Stitcher instance
Status stitch(InputArrayOfArrays images, OutputArray pano);
/** @brief These functions try to stitch the given images.
@param images Input images.
@param masks Masks for each input image specifying where to look for keypoints (optional).
@param pano Final pano.
@return Status code.
*/
该方法接收一个图像列表,然后尝试将它们拼接成全景图像,并进行返回。
变量 status=0表示图像拼接是否成功。
二、图像拼接算法实现
先将图片读取出来放入iOS原生数组内
UIImage*image1 = [UIImageimageNamed:@"pano_01.jpg"];
UIImage*image2 = [UIImageimageNamed:@"pano_02.jpg"];
UIImage*image3 = [UIImageimageNamed:@"pano_03.jpg"];
NSArray*imageArray = @[image1, image2, image3];
[self.imgArr addObjectsFromArray:imageArray];
图片顺序没有影响,不同的图片顺序,输出全景图都相同。
调用CVWrapper内图像拼接方法
[CVWrapper processWithArray:self.imgArr stitchType:QKDefaultStitch];
该方法会将传入的图片调整方向后再转成cv::Mat图片矩阵,再将矩阵加入cv::Mat泛型数组内,供OpenCV方法拼接图像后,返回UIImage对象。
//多张图片合成处理
+ (UIImage*) processWithArray:(NSArray*)imageArray stitchType:(QKCVWrapperTypeCode)stitchType
{
if ([imageArray count]==0){
NSLog (@"imageArray is empty");
return 0;
}
std::vector matImages;
for (id image in imageArray) {
if ([image isKindOfClass: [UIImage class]]) {
/*
All images taken with the iPhone/iPa cameras are LANDSCAPE LEFT orientation.
The UIImage imageOrientation flag is an instruction to the OS to transform the image during display only.
When we feed images into openCV, they need to be the actual orientation that we expect
them to be for stitching. So we rotate the actual pixel matrix here if required.
*/
UIImage* rotatedImage = [image rotateToImageOrientation];
cv::Mat matImage = [rotatedImage CVMat3];
// matImage = testaaaa(matImage);
NSLog (@"matImage: %@",image);
matImages.push_back(matImage);
}
}
NSLog (@"stitching...");
cv::Mat stitchedMat;
switch (stitchType) {
case QKDefaultStitch:
stitchedMat = stitch(matImages);
break;
case QKFisheyeStitch:
stitchedMat = fisheyeStitch(matImages);
break;
case QKPlaneStitch:
stitchedMat = planeStitch(matImages);
break;
default:
break;
}
UIImage* result = [UIImage imageWithCVMat:stitchedMat];
return result;
}
stitch、fisheyeStitch或者planeStitch 方法实现图片拼
我们暂时提供了三种拼接方法,分别为默认拼接方法、鱼眼相机拼接方法和环视(平面曲翘)拼接方法。本文我们主要介绍默认拼接方法,其他方法暂不介绍。
图片转换好后,我们将执行图像拼接
首先构造拼接对象stitcher,要注意OpenCV 3和4的构造方法是不同的。
Ptr stitcher = Stitcher::create();//4.0
然后再把图像列表传入.stitch函数,该函数会返回状态和拼接好的全景图(如果没有错误):
//拼接
Stitcher::Status status = stitcher->stitch(imgs, pano);
完整代码如下
cv::Mat stitch (vector& images)
{
imgs = images;
Mat pano;//拼接图
/* 3.0
Stitcher stitcher = Stitcher::createDefault(try_use_gpu);
Stitcher::Status status = stitcher.stitch(imgs, pano);
*/
Ptr stitcher = Stitcher::create();//4.0
//拼接
Stitcher::Status status = stitcher->stitch(imgs, pano);
if (status != Stitcher::OK)
{
cout << "Can't stitch images, error code = " << int(status) << endl;
}
return pano;
}
全景图如下:
通过openCV基础方法我们实现了全景图,但是周围出现了一些黑色区域。
这是因为构建全景时会做透视变换,透视变换时会产生黑色区域。
所以我们需要进一步处理,剪裁出全景图内的最大内部矩形区域,也就是留下图中红色虚线边框内的全景区域。
三、图像裁剪算法
在活的全景图后,我们需要对其进行剪裁加工,以便我们得到更完美的图片。
1.在全景图四周各添加10像素宽的黑色边框,以确保能够找到全景图的完整轮廓:
Mat stitched;//黑色边框轮廓图copyMakeBorder(inputMat, stitched,10,10,10,10, cv::BORDER_CONSTANT,true);
2.全景图转换灰度图,并将所有大于0的像素全置为255。
Matgray;cv::cvtColor(stitched,gray,cv::COLOR_BGR2GRAY);
3.中值滤波,去除黑色边际中可能含有的噪声干扰。
int ksize: 滤波模板的尺寸大小,必须是大于1的奇数,如3、5、7……
cv::medianBlur(gray, gray,7)
4.作为前景,其他像素灰度值为0,作为背景。
Mat tresh;
threshold(gray, tresh, 0, 255, THRESH_BINARY);
经过上述步骤操作,我们得到结果(tresh):
现在有了全景图的二值图,其中白色像素(255)是前景,黑色像素(0)是背景,通过我们的阈值图像,
我们可以应用轮廓检测,找到最大轮廓的边界框(即全景图本身的轮廓) ,并绘制边框。
5.寻找最大轮廓
vector> contours; //contours:包含图像中所有轮廓的python列表(三维数组),每个轮廓是包含边界所有坐标点(x, y)的Numpy数组。
vector hierarchy = vector();//vec4i是一种用于表示具有4个维度的向量的结构,每个值都小于cc>
findContours(tresh.clone(), contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE);//传入参数不一样
//计算最大轮廓的边界框
int index = getMaxContour(contours);
if (index == -1) {
return -1;
}
vector cnt = contours[index];
//使用边界矩形信息,将轮廓内填充成白色
drawContours(tresh, contours, index, Scalar(255,0,0));
//蒙板
Mat mask = Mat::zeros(tresh.rows, tresh.cols, CV_8UC1); // 0矩阵
//依赖轮廓,绘制最大外接矩形框(内部填充)
Rect cntRect = cv::boundingRect(cnt);
rectangle(mask, cntRect, cv::Scalar(255, 0, 0), -1);
//循环最大的轮廓边框
int getMaxContour(std::vector> contours){
double max_area = 0;
int index = -1;
for (int i = 0; i < contours.size(); i++) {
double tempArea = contourArea(contours[i]);
if (tempArea > max_area) {
max_area = tempArea;
index = i;
}
}
return index;
}
经过上述操作我们得到下图:
这个白色区域是整个全景图可以容纳下的最小的矩形区域。
接下来我们将进行微微关键和巧妙的部分。
首先我们创建一块mask蒙板的两个副本。
minRect,这个mask的白色区域会慢慢缩小,直到它刚好可以完全放入全景图内部。
sub,这个mask用于确定minRect是否需要继续减小,以得到满足要求的矩形区域。
不断地对minRect进行腐蚀操作,然后用minRect减去之前得到的阈值图像,得到sub,
再判断sub中是否存在非零像素,如果不存在,则此时的minRect就是我们最终想要的全景图内部最大矩形区域。
sub和minRect在while循环中的变化情况如下动图所示:
因为OpenCV中灰度图像素值范围0-255,如果两个数相减得到负数的话,会直接将其置为0;如果两个数相加,结果超过了255的话,则直接置为255。
比如下面这个图,左图中白色矩形可以完全包含在全景图中,但不是全景图的最大内接矩形,用它减去右边的阈值图,
因为黑色像素减白色像素,会得到黑色像素,所以其结果图为全黑的图。
既然我们已经得到了全景图内的内置最大矩形边框,接下来就是找到这个矩形框的轮廓,并获取其坐标:
//第二次循环
cv::Mat minRectClone = minRect.clone();
cv::resize(minRectClone, minRectClone,
cv::Size(minRectClone.cols * scale, minRectClone.rows * scale),
(float)minRect.cols / 2, (float)minRect.rows / 2,INTER_LINEAR);
std::vector > cnts;
vector hierarchyA = vector();
findContours(minRectClone, cnts, hierarchyA, RETR_TREE, CHAIN_APPROX_SIMPLE);
int idx = getMaxContour(cnts);
if (idx == -1) {
return -1;
}
//最终矩形轮廓
Rect finalRect = cv::boundingRect(cnts[idx]);
最后我们通内接矩形轮廓,提取最终的全景图
//提取最终全景图
outputMat = Mat(stitched, finalRect).clone();
得到最终结果图如下:
源代码如下:
#include "stitching.h"
#include "algorithm"
#include
#include //openCV 2.4.x
//#include "opencv2/stitching/stitcher.hpp"
//openCV 3.x
#include "opencv2/stitching.hpp"
//cpenCV 4.x 以上调用混编,OC类需将引入的openCV头文件放入s引入的最前方
using namespace std;
using namespace cv;
bool try_use_gpu = false;
vector imgs;
string result_name = "result.jpg";
int thresh = 100;
int max_thresh = 255;
RNG rng(12345);
const int scale = 2;
void printUsage();
int parseCmdArgs(int argc, char** argv);
int getMaxContour(std::vector> contours);
cv::Mat stitch (vector& images)
{
imgs = images;
Mat pano;//拼接图
/* 3.0
Stitcher stitcher = Stitcher::createDefault(try_use_gpu);
Stitcher::Status status = stitcher.stitch(imgs, pano);
*/
Ptr stitcher = Stitcher::create();//4.0
//拼接
Stitcher::Status status = stitcher->stitch(imgs, pano);
if (status != Stitcher::OK)
{
cout << "Can't stitch images, error code = " << int(status) << endl;
}
return pano;
}
int corpBoundingRect(cv::Mat &inputMat, cv::Mat &outputMat)
{
//在全景图四周各添加10像素宽的黑色边框,以确保能够找到全景图的完整轮廓:
Mat stitched;//黑色边框轮廓图
copyMakeBorder(inputMat, stitched, 10, 10, 10, 10, cv::BORDER_CONSTANT, true);
//全景图转换灰度图,并将不为0的像素全置为255
//作为前景,其他像素灰度值为0,作为背景。
Mat gray;
cv::cvtColor(stitched, gray, cv::COLOR_BGR2GRAY);
//中值滤波,去除黑色边际中可能含有的噪声干扰
cv::medianBlur(gray, gray, 7);
//白色剪影与黑色背景
Mat tresh;
threshold(gray, tresh, 0, 255, THRESH_BINARY);
//resize 缩小一半处理
resize(tresh, tresh,
Size(tresh.cols / scale, tresh.rows / scale),
tresh.cols / 2,
tresh.rows / 2, INTER_LINEAR);
//现在有了全景图的二值图,再应用轮廓检测,找到最大轮廓的边界框,
vector> contours; //contours:包含图像中所有轮廓的python列表(三维数组),每个轮廓是包含边界所有坐标点(x, y)的Numpy数组。
vector hierarchy = vector();//vec4i是一种用于表示具有4个维度的向量的结构,每个值都小于cc>
findContours(tresh.clone(), contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE);//传入参数不一样
//计算最大轮廓的边界框
int index = getMaxContour(contours);
if (index == -1) {
return -1;
}
vector cnt = contours[index];
drawContours(tresh, contours, index, Scalar(255,0,0));
//蒙板
Mat mask = Mat::zeros(tresh.rows, tresh.cols, CV_8UC1); // 0矩阵
//依赖轮廓创建矩形
Rect cntRect = cv::boundingRect(cnt);
rectangle(mask, cntRect, cv::Scalar(255, 0, 0), -1);
Mat minRect = mask.clone();//minRect的白色区域会慢慢缩小,直到它刚好可以完全放入全景图内部。
Mat sub = mask.clone();//sub用于确定minRect是否需要继续减小,以得到满足要求的矩形区域。
//开始while循环,直到sub中不再有前景像素
while (cv::countNonZero(sub) > 0) {
// int zero = cv::countNonZero(sub);
// printf("剩余前景像素 %d \n",zero);
cv::erode(minRect, minRect, Mat());
cv::subtract(minRect, tresh, sub);
}
//第二次循环
cv::Mat minRectClone = minRect.clone();
cv::resize(minRectClone, minRectClone,
cv::Size(minRectClone.cols * scale, minRectClone.rows * scale),
(float)minRect.cols / 2, (float)minRect.rows / 2,INTER_LINEAR);
std::vector > cnts;
vector hierarchyA = vector();
findContours(minRectClone, cnts, hierarchyA, RETR_TREE, CHAIN_APPROX_SIMPLE);
int idx = getMaxContour(cnts);
if (idx == -1) {
return -1;
}
Rect finalRect = cv::boundingRect(cnts[idx]);
//
//// printf("finalRect {x = %d, y = %d, width = %d, height = %d \n", finalRect.x, finalRect.y, finalRect.width, finalRect.height);
outputMat = Mat(stitched, finalRect).clone();
return 0;
}
//鱼眼拼接
cv::Mat fisheyeStitch (vector& images) {
imgs = images;
Mat pano;
// Stitcher stitcher = Stitcher::createDefault(try_use_gpu);
Ptr stitcher = Stitcher::create();//4.0
Ptr fisheye_warper = makePtr();
// stitcher.setWarper(fisheye_warper);
stitcher->setWarper(fisheye_warper);
//拼接
// Stitcher::Status status = stitcher.stitch(imgs, pano);
Stitcher::Status status = stitcher->stitch(imgs, pano);
if (status != Stitcher::OK)
{
cout << "Can't stitch images, error code = " << int(status) << endl;
//return 0;
}
return pano;
}