全景拼接学习笔记

本博客内容来源于网络以及其他书籍,结合自己学习的心得进行重编辑,因为看了很多文章不便一一标注引用,如图片文字等侵权,请告知删除。

传统2D计算机视觉学习笔记目录------->传送门
传统3D计算机视觉学习笔记目录------->传送门

之前几篇文章都介绍的关于视觉中的基础的算法概念,今天这篇文章我们来学习一下一个更有意思,能够实际应用的算法-- 全景拼接。可能文章中有一些方法,我们会从其他文章详解,所以没有进行详细描述。主要还是理解全景拼接的流程,对一些基础的概念有了解。所以我么开始。

全景拼接简介

全景拼接就是将多张图片进行缝合,生成一张视野更大的全景图。全景并不只是指环绕一周的图片,而是我们通过图像拼接技术创建更宽更高的场景。

现在像360度的全景相机已经很普遍了,价格也有很大的下降,比如insta360等。这一类的全景相机基本上都是由两个鱼眼相机拼接而成,也有一些是通过多个广角相机拼接而成的。大家每个人都应该用过手机上的全景拍摄,通过移动相机,完成拍摄一张视角更宽广的图片,这里也用到全景拼接技术。图像拼接已经有很多的落地场景,但是仍旧有一些问题还有很多学者在优化。

全景拼接一般流程

下面只是对基础的全景拼接整理的流程,还有很多算法有很多流程来优化提高最后的结果。

  1. 对图片畸变进行矫正。
  2. 对图片提取特征点,对特征进行匹配,得到输入图像之间的映射关系T。
  3. 图像拼接,根据映射关系T进行图像的Warp变换,对齐图像。
  4. 图像融合,利用颜色调整来消除图像间的色差等方式来消除拼缝,多张图片融合为一张
  5. 定义全景映射模型,常用的包括:球面、柱面、平面,其中球面映射应用最为广泛。

上面的流程,肯定有很多概念不知道什么意思,下面将对其进行解释。

全景拼接流程中关键技术点详解

  • 图像矫正

一般的全景相机会使用两颗或多颗广角或者鱼眼相机来节省成本。广角或者鱼眼相机的畸变都比较大,所以需要通过提前标定好的参数,对图片进行去畸变矫正。如果是正常的畸变较小的小孔相机则基本不需要。

  • 图片匹配

所谓图片匹配就是找到相邻图片中相对应的点。主要有两种方式:

  1. 与特征无关的匹配方式,一般都用于没有复杂变换的图像拼接情况下,常见的为灰度相关性匹配,这种方法计算简单,仅仅通过灰度模板匹配。
  2. 根于特征进行匹配,常见特征有特征曲线,特征轮廓,特征点,特征点使用较多。分别在图像中找到相应的特征描述点,比如sift,surf,orb等,然后根据描述子,通过描述子的相似性来匹配两种图像中的点,找到对应匹配的点对。针对特征匹配具体讲解会在其他文章中讲解。
  • warp变换

在上一步我们找到了许多对应的点对,通过这些点对来估算"单应矩阵",然后通过单应矩阵将待匹配的图片转换到原始图片的平面。

我们来慢慢解释上面的疑问,首先现在我们来看看什么是单应矩阵?
单应性矩阵为一3x3矩阵,描述了射影几何中平面到平面的映射关系,其自由度为8,由九个元素组成,通常令最后一个元素为1或者使其F范数为1,该矩阵可将无穷远点投射于有限处,即空间中平行线在图像上相交于有限处。

上图中描述将image2平面中的点投影到image1中的点的变换矩阵即单应矩阵。上图两个投影中心在一点,但单应性矩阵可以表达出image2平面投影到image1平面的旋转和平移。

现在我们知道单应矩阵是干什么用的,所以也可以将待匹配的图投影到原始图片的平面上了。现在需要知道的就是怎么来求单应矩阵?
为了提高单应矩阵的正确性,要提高匹配准确率,一般可以配合上RANSAC算法的方式进行估计,ransac主要帮助筛选出匹配准确性更高的点对。

假设两图像上的像点 p1(x1,y1) p2(x2,y2) 是一对匹配的点对,其单应矩阵为H,则有:

即:

所以我们至少需要四对点对就可以算出单应矩阵。

  • 图像融合

在拼接完成后,如果两幅图像因拍摄环境的不同,则会产生明显的的过渡区域(可以观察下面的实验结果),此时需要利用图像融合,将重叠区域的像素按两幅图像权重相加,使其能形成缓慢的过度。
常用的方式有:基于金字塔的图像融合,根据距重叠区边缘的距离来设定对应的权重,或者是直接将两幅图的重叠区按某个比例权重相加。这是一种比较基础简单的方式,还有更多优秀的方式暂不介绍。

  • 投影映射

对于360度的图片,可以说是一个全包围的图片,我们要把这个图片展开就需要投影映射,就像我们的世界地图一样,可以把平面的地图转换到到地球仪上进行观看。
映射模型可以看作是用于图像映射的载体,相当于二维图像映射到三维空间的一种变换。

选择合适的映射模型非常重要,需要与你的图像采集场景以及应用方式相匹配,一般对于水平拼接,采用柱面映射描述性最佳,而对于360°全景,球面映射或者立方体(多面体)映射的效果更好。
柱面映射模型
球体映射模型
立方体映射模型

全景拼接中的难点

  • 当相邻照片重叠部分过少时,难以匹配
  • 当图片中有多处相似部分,难以消除错误匹配
  • 图片之间存在视差以及匹配误差,拼缝处有时难以达到光滑过度且不变形。
  • 不同图片的亮度对比度等之间的差异,使图像融合不自然
  • 图片在拼接处有动态的物体,无法剔除

OpenCV 全景拼接效果展示[代码]

代码较多,直接从主函数步骤比较简单来分析。

#include <iostream>
#include <opencv2/opencv.hpp>
#include <vector>
#include <boost/filesystem.hpp>
#include <opencv2/xfeatures2d.hpp>
std::vector<cv::Mat> process_input_images(std::vector<cv::Mat> input){
    std::vector<cv::Mat> gray_images;
    for(int i=0 ;i < input.size() ;i++){
        cv::Mat gray_image;
        cvtColor(input[i], gray_image, CV_RGB2GRAY);
        gray_images.push_back(gray_image);
    }
    return gray_images;
}

std::vector<std::string> get_all_image_file(std::string image_folder_path){
    boost::filesystem::path dirpath = image_folder_path;
    boost::filesystem::directory_iterator end;
    std::vector<std::string> files;
    for (boost::filesystem::directory_iterator iter(dirpath); iter != end; iter++)
    {
        boost::filesystem::path p = *iter;
        files.push_back(dirpath.string()+ "/"+ p.leaf().string());
    }
    std::sort(files.begin(),files.end());
    return files;
}

// 从文件夹中读取图片
std::vector<cv::Mat> read_input_images(std::string image_folder_path)
{
    std::vector<cv::Mat> images;
    std::vector<std::string> image_files_path = get_all_image_file(image_folder_path);
    for(int i=0; i<  image_files_path.size() ;i++){
        cv::Mat image;
        image = cv::imread(image_files_path[i]);
        images.push_back(image);
    }
    return images;
}
// 提取特征点
std::vector< std::vector<cv::KeyPoint> > extracte_features(std::vector<cv::Mat> images){
    std::vector< std::vector<cv::KeyPoint> > keypoints;
    for(int i=0 ;i < images.size() ;i ++){
        std::vector<cv::KeyPoint> keypoint;
        cv::Ptr<cv::Feature2D> f2d = cv::xfeatures2d::SIFT::create();
        f2d->detect(images[i],keypoint);
        cv::Mat image_with_kp;
        cv::drawKeypoints(images[i], keypoint, image_with_kp, cv::Scalar::all(-1));
        cv::imwrite("image_with_kp"+std::to_string(i)+".png",image_with_kp);
        keypoints.push_back(keypoint);
    }
    return keypoints;
}

// 计算描述子
std::vector<cv::Mat> extracte_descriptors(std::vector<cv::Mat> images,std::vector< std::vector<cv::KeyPoint> >keypoints){
    std::vector< cv::Mat > descriptors;
    for(int i=0 ;i < images.size() ;i ++){
        cv::Mat descriptor;
        cv::Ptr<cv::Feature2D> f2d = cv::xfeatures2d::SIFT::create();
        f2d->compute(images[i],keypoints[i],descriptor);
        descriptors.push_back(descriptor);
    }
    return descriptors;
}

// 匹配计算出两张图片的变换矩阵
cv::Mat find_transfer(std::vector<cv::Mat> images,std::vector< std::vector<cv::KeyPoint> >keypoints,std::vector< cv::Mat > descriptors){
    cv::FlannBasedMatcher matcher;
    std::vector<cv::DMatch> matches, goodmatches;
    matcher.match(descriptors[0], descriptors[1], matches);
    cv::Mat  firstmatches;
    cv::drawMatches(images[0], keypoints[0], images[1], keypoints[1],
            matches, firstmatches, cv::Scalar::all(-1), cv::Scalar::all(-1),
            std::vector<char>(), cv::DrawMatchesFlags::NOT_DRAW_SINGLE_POINTS);
    cv::imwrite("firstmatches.png",firstmatches);
    double max_dist = 0; double min_dist = 1000;
    for (int i = 0; i < descriptors[0].rows; i++) {
        if (matches[i].distance > max_dist) {
            max_dist = matches[i].distance;
        }
        if (matches[i].distance < min_dist) {
            min_dist = matches[i].distance;
        }
    }
    for (int i = 0; i < descriptors[0].rows; i++) {
        if (matches[i].distance < 1.5 * min_dist) {
            goodmatches.push_back(matches[i]);
        }
    }
    cv::Mat img_matches;
    cv::drawMatches(images[0], keypoints[0], images[1], keypoints[1],
            goodmatches, img_matches, cv::Scalar::all(-1), cv::Scalar::all(-1),
            std::vector<char>(), cv::DrawMatchesFlags::NOT_DRAW_SINGLE_POINTS);
    cv::imwrite("img_matches.png",img_matches);
    std::vector<cv::Point2f> keypoints1, keypoints2;
    for (int i = 0; i < goodmatches.size(); i++) {
        keypoints1.push_back(keypoints[0][goodmatches[i].queryIdx].pt);
        keypoints2.push_back(keypoints[1][goodmatches[i].trainIdx].pt);
    }
    cv::Mat trans = findHomography(keypoints2, keypoints1, CV_RANSAC);
    return trans;
}
// 拼接出融合优化
void OptimizeSeam(cv::Mat& img1, cv::Mat& trans, cv::Mat& dst,std::vector<cv::Point2f> corners)
{
    int start = MIN(corners[0].x, corners[1].x);
    double processWidth = img1.cols - start;
    int rows = dst.rows;
    int cols = img1.cols; 
    double alpha = 1;
    for (int i = 0; i < rows; i++)
    {
        uchar* p = img1.ptr<uchar>(i);  
        uchar* t = trans.ptr<uchar>(i);
        uchar* d = dst.ptr<uchar>(i);
        for (int j = start; j < cols; j++)
        {
            if (t[j * 3] == 0 && t[j * 3 + 1] == 0 && t[j * 3 + 2] == 0)
            {
                alpha = 1;
            }
            else
            {
                alpha = (processWidth - (j - start)) / processWidth;
            }
            d[j * 3] = p[j * 3] * alpha + t[j * 3] * (1 - alpha);
            d[j * 3 + 1] = p[j * 3 + 1] * alpha + t[j * 3 + 1] * (1 - alpha);
            d[j * 3 + 2] = p[j * 3 + 2] * alpha + t[j * 3 + 2] * (1 - alpha);
        }
    }

}
// 拼接融合图片
cv::Mat warp_image(std::vector<cv::Mat> images,cv::Mat trans,std::vector<cv::Point2f> corners){
    cv::Mat image_right_perspective ;
    cv::warpPerspective(images[1], image_right_perspective, trans, cv::Size(MAX(corners[2].x, corners[3].x), images[0].rows));
    cv::imwrite("image_right_perspective.png",image_right_perspective);
    int dst_width = image_right_perspective.cols;
    int dst_height = images[0].rows;
    cv::Mat dst(dst_height, dst_width, CV_8UC3);
    dst.setTo(0);

    image_right_perspective.copyTo(dst(cv::Rect(0, 0, image_right_perspective.cols, image_right_perspective.rows)));
    images[0].copyTo(dst(cv::Rect(0, 0, images[0].cols, images[0].rows)));
    cv::imwrite("before_opt.png",dst);
    OptimizeSeam(images[0],image_right_perspective,dst,corners);
    cv::imwrite("reult.png",dst);
    return dst;
}
// 就算变换后图片的四个角
std::vector<cv::Point2f> cal_corner(std::vector<cv::Mat> images,cv::Mat trans){
    double v2[] = { 0, 0, 1 };
    double v1[3];
    std::vector<cv::Point2f> corners;
    cv::Point2f left_top,left_bottom,right_top,right_bottom;
    cv::Mat V2 = cv::Mat(3, 1, CV_64FC1, v2);  
    cv::Mat V1 = cv::Mat(3, 1, CV_64FC1, v1); 

    V1 = trans * V2;
    left_top.x = v1[0] / v1[2];
    left_top.y = v1[1] / v1[2];

    v2[0] = 0;
    v2[1] = images[1].rows;
    v2[2] = 1;
    V2 = cv::Mat(3, 1, CV_64FC1, v2);  
    V1 = cv::Mat(3, 1, CV_64FC1, v1);  
    V1 = trans * V2;
    left_bottom.x = v1[0] / v1[2];
    left_bottom.y = v1[1] / v1[2];

    v2[0] = images[1].cols;
    v2[1] = 0;
    v2[2] = 1;
    V2 = cv::Mat(3, 1, CV_64FC1, v2);  
    V1 = cv::Mat(3, 1, CV_64FC1, v1);  
    V1 = trans * V2;
    right_top.x = v1[0] / v1[2];
    right_top.y = v1[1] / v1[2];

    v2[0] = images[1].cols;
    v2[1] = images[1].rows;
    v2[2] = 1;
    V2 = cv::Mat(3, 1, CV_64FC1, v2);  
    V1 = cv::Mat(3, 1, CV_64FC1, v1);  
    V1 = trans * V2;
    right_bottom.x = v1[0] / v1[2];
    right_bottom.y = v1[1] / v1[2];
    corners.push_back(left_top);
    corners.push_back(left_bottom);
    corners.push_back(right_top);
    corners.push_back(right_bottom);
    return  corners;
}

int main(int argc, char *argv[])
{
    std::vector<cv::Mat> input_images = read_input_images(argv[1]);
    // 两张图片拼接按步演示
    {
            std::vector<cv::Mat> two_input_images;
            two_input_images.push_back(input_images[0]);
            two_input_images.push_back(input_images[1]);
            std::vector<cv::Mat> gray_images = process_input_images(two_input_images);
            std::vector< std::vector<cv::KeyPoint> > keypoints = extracte_features(gray_images);
            std::vector<cv::Mat> descriptors = extracte_descriptors(gray_images,keypoints);
            cv::Mat trans = find_transfer(two_input_images,keypoints,descriptors);
            std::vector<cv::Point2f> corners =cal_corner(two_input_images,trans);
            cv::Mat after_warp = warp_image(two_input_images,trans,corners);
    }
    // opencv自带函数对多张图片进行拼接
    {
        cv::Stitcher stitch_all = cv::Stitcher::createDefault(true);
        cv::Mat pano;
        cv::Stitcher::Status status = stitch_all.stitch(input_images, pano);
        cv::imwrite("pano_all.png",pano);
    }
}

实验数据就是我在屋子外面拍的几张照片(都上传的时候都做了resize)

图片1 图片2 图片3
图片4 图片5 --

两张拼接处理选择的第一张与第二张图片

图片1提取关键点 图片2提取关键点

配准效果(貌似看不清)

初次匹配 过滤后
变换后的右图
拼接后的图片
优化融合后的图片
opencv自带拼接拼图

总结

对于简单的版本的拼接算法,基本上还是免不了之前说的困难点,比如拼接处有重影,参数不好,容易匹配失误等。


重要的事情说三遍:

如果我的文章对您有所帮助,那就点赞加个关注呗 ( * ^ __ ^ * )

如果我的文章对您有所帮助,那就点赞加个关注呗 ( * ^ __ ^ * )

如果我的文章对您有所帮助,那就点赞加个关注呗 ( * ^ __ ^ * )

传统2D计算机视觉学习笔记目录------->传送门
传统3D计算机视觉学习笔记目录------->传送门

任何人或团体、机构全部转载或者部分转载、摘录,请保留本博客链接或标注来源。博客地址:开飞机的乔巴

作者简介:开飞机的乔巴(WeChat:zhangzheng-thu),现主要从事机器人抓取视觉系统以及三维重建等3D视觉相关方面,另外对slam以及深度学习技术也颇感兴趣,欢迎加我微信或留言交流相关工作。

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