iOS全景图像拼接实现

本文为在iOS环境下利用OpenCV技术实现全景图片合成并对合成图片进行剪裁的简单实现。

Image Stitching with OpenCV and Python

本文参考以上连接实现多张图像的拼接,并通过C++和Objective-C代码实现构建一张全景图。

图1

根据多个图像创建全景图的步骤为:

检测两张图像的关键点特征(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流程图

一、函数介绍

OpenCV 3.x 的 cv::Stitcher::createDefault() 函数原型为:

Stitcher stitcher = Stitcher::createDefault(try_use_gpu);

这个函数有一个参数 try_use_gpu,它可以用来提升图像拼接整个过程的速度。

OpenCV 4 的 cv::Stitcher::create()函数原型为:

Ptr<Stitcher> 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<cv::Mat> 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 = Stitcher::create();//4.0

然后再把图像列表传入.stitch函数,该函数会返回状态和拼接好的全景图(如果没有错误):

//拼接
Stitcher::Status status = stitcher->stitch(imgs, pano);

完整代码如下

cv::Mat stitch (vector<Mat>& images)
{
  imgs = images;
  Mat pano;//拼接图
  /* 3.0
   Stitcher stitcher = Stitcher::createDefault(try_use_gpu);
   Stitcher::Status status = stitcher.stitch(imgs, pano);
   */
  Ptr<Stitcher> 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<vector<Point>> contours; //contours:包含图像中所有轮廓的python列表(三维数组),每个轮廓是包含边界所有坐标点(x, y)的Numpy数组。
 vector<Vec4i> hierarchy = vector<cv::Vec4i>();//vec4i是一种用于表示具有4个维度的向量的结构,每个值都小于cc>
 findContours(tresh.clone(), contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE);//传入参数不一样
 //计算最大轮廓的边界框
 int index = getMaxContour(contours);
 if (index == -1) {
     return -1;
 }
 vector<Point> 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<vector<cv::Point>> 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<std::vector<Point> > cnts;
vector<Vec4i> hierarchyA = vector<cv::Vec4i>();
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 <iostream>
  #include <fstream>//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<Mat> 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<vector<cv::Point>> contours);

  cv::Mat stitch (vector<Mat>& images)
  {
  imgs = images;
  Mat pano;//拼接图
  /* 3.0
   Stitcher stitcher = Stitcher::createDefault(try_use_gpu);
   Stitcher::Status status = stitcher.stitch(imgs, pano);
   */
  Ptr<Stitcher> 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<vector<Point>> contours; //contours:包含图像中所有轮廓的python列表(三维数组),每个轮廓是包含边界所有坐标点(x, y)的Numpy数组。
  vector<Vec4i> hierarchy = vector<cv::Vec4i>();//vec4i是一种用于表示具有4个维度的向量的结构,每个值都小于cc>
  findContours(tresh.clone(), contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE);//传入参数不一样
  //计算最大轮廓的边界框
  int index = getMaxContour(contours);
  if (index == -1) {
      return -1;
  }
  vector<Point> 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<std::vector<Point> > cnts;
  vector<Vec4i> hierarchyA = vector<cv::Vec4i>();
  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<Mat>& images) {
    imgs = images;
    Mat pano;
//    Stitcher stitcher = Stitcher::createDefault(try_use_gpu);

    Ptr<Stitcher> stitcher = Stitcher::create();//4.0
    Ptr<FisheyeWarper> fisheye_warper = makePtr<cv::FisheyeWarper>();
//    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;
}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271