跳转至

十九、使用拼接模块的 iOS 全景图

全景成像从摄影的早期就已经存在了。 在那些古老的时代,大约 150 年前,它被称为画法,它用胶带或胶水仔细地将单个图像组合在一起,重建出全景。 随着计算机视觉的发展,全景拼接成为几乎所有数码相机和移动设备上的便捷工具。 如今,创建全景图很简单,只需在视图中滑动设备或相机,拼接计算就会立即发生,最终展开的场景就可以查看了。 在本章中,我们将使用 OpenCV 的 iOS 预编译库在 iPhone 上实现一个适度的全景图像拼接应用。 我们先来研究一下图像拼接背后的一些数学和理论,然后选择相关的 OpenCV 函数来实现,最后用一个基本的 UI 把它集成到 iOS 应用中。

本章将介绍以下主题:

  • 图像拼接与全景图构建概念简介
  • OpenCV 的图像拼接模块及其功能
  • 构建用于全景捕获的 SWIFT iOS 应用 UI
  • 将 Objective C++ 编写的 OpenCV 组件与 SWIFT 应用集成

技术要求

重新创建本章内容需要以下技术和安装:

  • 运行 MacOS High Sierra v10.13+的 MacOSX 计算机(例如 MacBook、iMac)
  • 运行 iOS v11+的 iPhone 6+
  • Xcode v9+
  • CocoaPods v1.5+:https://cocoapods.org/CocoaPods
  • 通过 CocoaPods 安装 OpenCV V4.0

前面组件的构建说明以及实现本章中所示概念的代码将在附带的代码存储库中提供。

本章的代码可以通过 gihub:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter19 访问。

全景图像拼接方法

全景图本质上是将多幅图像融合成一幅图像。 从多个图像创建全景图的过程涉及许多步骤;其中一些是其他计算机视觉任务所共有的,如下所示:

  • 提取二维特征
  • 基于图像特征的图像对匹配
  • 将图像转换或扭曲为公共边框
  • 使用(混合)图像之间的接缝,以获得较大图像令人愉悦的连续效果

其中一些基本操作在运动结构(SFM)、3D 重建视觉里程计同步定位和测绘(SLAM)中也很常见。 我们已经在第 14 章使用 SfM 模块第 18 章Android 摄像机校准和 AR 使用 ArUco 模块中讨论了其中的一些内容。 以下是全景图创建过程的粗略图像:

在这一部分中,我们将简要回顾一下特征匹配、摄像机姿态估计和图像扭曲。 实际上,全景拼接有多条路径和多个类,具体取决于输入和所需输出的类型。 例如,如果相机有鱼眼镜头(视角极高),则需要特殊处理。

全景图的特征提取与鲁棒匹配

我们从重叠的图像创建全景图。 在重叠区域中,我们寻找将两个图像配准(对齐)的共同视觉特征。 在 SFM 或 SLAM 中,我们逐帧执行此操作,在帧之间重叠极高的实时视频序列中寻找匹配特征。 然而,在全景图中,我们得到的帧之间有一个很大的运动分量,重叠部分可能只占图像的 10%-20%。 首先提取图像特征,如尺度不变特征变换(SIFT)加速稳健特征(SURF)、面向的 Brief(Orb)等图像特征,然后在全景图中的图像之间进行匹配。 请注意,SIFT 和 SURF 功能受专利保护,不能用于商业目的。 ORB 被认为是一种免费的替代方案,但不是很健壮。

下图显示了提取的特征及其匹配:

仿射约束

对于健壮且有意义的两两匹配,我们通常会应用几何约束。 一个这样的约束可以是仿射变换,这是一种只允许缩放、旋转和平移的变换。 在 2D 中,仿射变换可以在 2 x 3 矩阵中建模:

为了施加约束,我们寻找一个仿射变换,它可以最小化来自左和右图像的匹配点之间的距离(误差)。

随机样本一致性(RANSAC)

在上图中,我们演示了这样一个事实:并非所有的点都符合仿射约束,并且大多数匹配对因不正确而被丢弃。 因此,在大多数情况下,我们使用基于投票的估计方法,如随机样本一致性(RANSAC),即随机选择一组点直接(通过齐次线性系统)求解假设M,然后在所有点之间进行投票以支持或拒绝这一假设。

以下是 RANSAC 的伪算法:

  1. 查找图像i和图像j中的点之间的匹配。
  2. 初始化图像ij之间的转换假设,支持度最小。
  3. 虽然不是融合的,但是:
    1. 选择一小组随机的点对。 对于仿射变换,三对就足够了。
    2. 基于对集合直接计算仿射变换T,例如使用线性方程组计算仿射变换T[T1
    3. 计算支座。 对于整个i,j匹配中的每个点p
      • 如果图像j中的变换点与图像i中的匹配点之间的距离(第二误差)在小阈值t内:,则*向支持计数器加 1。
    4. 如果支持数大于当前假设的支持,则取T作为新假设。
    5. 可选:如果支持足够大(或不同的中断策略为真),则中断;否则,继续迭代。
  4. 返回最新且支持最好的假设转换。
  5. 另外,返回支持掩码:一个二进制变量,说明匹配中的一个点是否支持最终假设。

算法的输出将提供具有最高支持度的变换,并且支持掩码可用于丢弃不支持的点。 我们还可以推论支持点的数量,例如,如果我们观察到的支持点少于 50%,我们就可以认为这场比赛不好,根本不会尝试匹配这两个图像。

RANSAC 还有其他选择,如最小中值平方(LMedS)算法,它与 RANSAC 没有太大区别:它不计算支撑点,而是计算每个变换假设的平方误差中值,最后返回中值误差最小的假设。

单应约束

虽然仿射变换对于拼接扫描的文档很有用(例如,从平板扫描仪拼接),但它们不能用于拼接照片全景图。 对于拼接照片,我们可以使用相同的过程来找到单应,即一个平面和另一个平面之间的变换,而不是仿射变换,它有八个自由度,并以 3x3 矩阵表示,如下所示:

一旦找到合适的匹配,我们就可以找到图像的排序,以便为全景图对它们进行排序,本质上是为了了解图像之间是如何相互关联的。 在大多数情况下,在全景图中,假设摄影师(相机)静止不动,只绕其轴线旋转,例如,从左向右扫视。 因此,目标是恢复摄影机姿势之间的旋转分量。 如果我们认为输入是纯旋转的,那么单应可以分解以恢复旋转:。如果我们假设单应最初是由摄像机固有的(校准)、矩阵K、和一个 3x3 旋转矩阵R组成的,如果我们知道K,我们可以恢复R。 本征矩阵可以通过摄像机提前校准来计算,或者可以在全景创建过程中估计。

捆绑平差

当在所有照片之间实现了局部转换时,我们可以在全局步骤中进一步优化我们的解决方案。 这被称为束调整的过程,被广泛地构建为所有重建参数(相机或图像变换)的全局优化。 如果图像之间的所有匹配点都放在相同的坐标框架(例如,3D 空间)中,并且存在跨越两个以上图像的约束,则全局束平差的执行效果最好。 例如,如果一个特征点出现在全景图中的两个以上图像中,则它对于全局优化非常有用,因为它涉及注册三个或更多视图。

大多数捆绑平差方法的目标是使平均重建误差最小化。 这意味着,希望将视图的近似参数(例如相机或图像变换)调整为值,以便重新投影回原始视图上的二维点将以最小的误差对齐。 这可以用数学的方式表示如下:

其中我们寻找最佳摄像机或图像变换T,使得原始点XI和重新投影点Proj(Tj,Xi)之间的距离最小。 二进制变量vij标记点i是否可以在图像j中看到,并可能导致错误。 这类优化问题可以用迭代非线性最小二乘法求解,如Levenberg-MarQuardt,因为以前的Proj函数通常是非线性的。

用于全景创建的扭曲图像

如果我们知道图像之间的单应关系,我们就可以应用它们的逆来将所有图像投影到同一平面上。 但是,例如,如果所有图像都投影到第一个图像的平面上,则使用单应性的直接扭曲最终会产生拉伸的外观。 在下图中,我们可以看到使用拼接的单应(透视)扭曲的 4 个图像的拼接,这意味着所有图像都对准到第一个图像的平面,这说明了笨拙的拉伸:

为了解决这个问题,我们把全景看作是从圆柱体内部看图像,图像被投影到圆柱体的壁上,我们旋转中心的相机。 要实现此效果,我们首先需要将图像扭曲到柱面坐标,就好像圆柱体的圆壁被松开并展平为矩形一样。 下图说明了圆柱形翘曲的过程:

为了在柱面坐标中包装图像,我们首先应用本征矩阵的逆来获得归一化坐标中的像素。 我们现在假设像素是圆柱体表面上的一个点,该点由高度h和角度θ参数化。 高度h实质上对应于y坐标,而xz(相对于y彼此垂直)存在于单位圆上,因此分别对应于 Sinθ和 Cosθ,Sin。 要获得与原始图像相同像素大小的扭曲图像,我们可以再次应用固有矩阵K;但是,我们可以更改焦距参数f,以影响全景图的输出分辨率。

在柱面扭曲模型中,图像之间的关系变成纯粹的平移关系,实际上由单个参数控制:θ要将图像拼接在同一平面上,我们只需找到θs,这只是一个自由度,与为每两个连续图像之间的单应性找到八个参数相比,这是很简单的。 圆柱法的一个主要缺点是,我们假设相机的旋转轴运动与其上方轴完全对齐,并且在其位置上保持静止,这在手持相机中几乎从来不是这样的。 尽管如此,柱面全景图仍能产生非常令人满意的效果。 另一个扭曲选项是球面坐标,它允许在xy轴上拼接图像时有更多选项。

*# 项目概述

该项目将包括以下两个主要部分:

  • 支持捕捉全景的 iOS 应用
  • OpenCV Objective-用于从图像创建全景图并集成到应用中的 C++ 代码

IOS 代码主要涉及构建 UI、访问摄像头和捕获图像。 然后,我们将重点介绍如何将图像转换为 OpenCV 数据结构,并从stitch模块运行图像拼接功能。

使用 CocoaPods 设置 iOS OpenCV 项目

要开始在 iOS 中使用 OpenCV,我们必须导入为 iOS 设备编译的库。 使用 CocoaPods 很容易做到这一点,CocoaPods 是一个庞大的 iOS 和 MacOS 外部包存储库,它有一个名为pod的方便的命令行包管理器实用程序。

我们首先为 iOS 创建一个空的 Xcode 项目,模板为“Single View App”。 确保选择 SWIFT 项目,而不是 Objective-C 项目。 稍后将添加我们将看到的 Objective-C++ 代码。

在某个目录中初始化工程后,我们在该目录下的终端中执行pod init命令。 这将在目录中创建一个名为Podfile的新文件。 我们需要编辑该文件,使其如下所示:

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'OpenCV Stitcher' do
  use_frameworks!
  # Pods for OpenCV Stitcher
  pod 'OpenCV2', '4.0.0.beta'
end

本质上,只要将pod 'OpenCV2', '4.0.0'添加到target,就会告诉 CocoaPods 下载并解压缩我们项目中的 OpenCV 框架。 然后,我们在终端的同一目录下运行pod install,这将设置我们的项目和工作区以包括所有 Pod(在我们的例子中只有 OpenCVv4)。 要开始处理项目,我们打开$(PROJECT_NAME).xcworkspace文件,而不是像 Xcode 项目那样打开.xcodeproject文件。

用于全景捕捉的 iOS UI

在深入研究将图像集合转换为全景图的 OpenCV 代码之前,我们将首先构建一个 UI 来支持轻松捕获一系列重叠图像。 首先,我们必须确保我们可以访问摄像机以及保存的图像。 打开Info.plist文件并添加以下三行:

要开始构建 UI,我们将创建一个视图,右侧是相机预览的View对象,左侧是重叠的ImageView对象。 ImageView应覆盖摄像机预览视图的某些区域,以帮助指导用户捕获与上一幅图像有足够重叠的图像。 我们还可以在顶部添加几个ImageView实例以显示以前捕获的图像,并在底部添加一个捕获按钮和一个拼接按钮来控制应用流:

要将相机预览连接到预览视图,必须执行以下操作:

  1. 启动捕获会话(AVCaptureSession)
  2. 选择设备(AVCaptureDevice)
  3. 使用来自设备的输入设置捕获会话(AVCaptureDeviceInput)
  4. 添加用于捕获照片的输出(AVCapturePhotoOutput)

当它们被初始化为 ViewController 类的成员时,大多数都可以立即设置。 以下代码显示如何动态设置捕获会话、设备和输出:

class ViewController: UIViewController, AVCapturePhotoCaptureDelegate {

    private lazy var captureSession: AVCaptureSession = {
        let s = AVCaptureSession()
        s.sessionPreset = .photo
        return s
    }()
    private let backCamera: AVCaptureDevice? = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)

    private lazy var photoOutput: AVCapturePhotoOutput = {
        let o = AVCapturePhotoOutput()
        o.setPreparedPhotoSettingsArray([AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])], completionHandler: nil)
        return o
    }()
    var capturePreviewLayer: AVCaptureVideoPreviewLayer?

其余的初始化可以通过viewDidLoad函数来完成,例如,将捕获输入添加到会话中,并创建用于在屏幕上显示摄像机视频源的预览层。 以下代码显示了初始化过程的其余部分,将输入和输出添加到捕获会话,并设置预览层。

    override func viewDidLoad() {
        super.viewDidLoad()

        let captureDeviceInput = try AVCaptureDeviceInput(device: backCamera!)
        captureSession.addInput(captureDeviceInput)
        captureSession.addOutput(photoOutput)

        capturePreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        capturePreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspect
        capturePreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation.portrait

        // add the preview layer to the view we designated for preview
        let previewViewLayer = self.view.viewWithTag(1)!.layer
        capturePreviewLayer?.frame = previewViewLayer.bounds
        previewViewLayer.insertSublayer(capturePreviewLayer!, at: 0)
        previewViewLayer.masksToBounds = true
        captureSession.startRunning()
    }

设置好预览后,只需单击一下即可处理照片捕获。 下面的代码显示了单击按钮(TouchUpInside)将如何通过delegate触发photoOutput函数,然后简单地将新图像添加到列表中,并将其保存到照片库中的内存中。

@IBAction func captureButton_TouchUpInside(_ sender: UIButton) {
    photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
}

var capturedImages = [UIImage]()

func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
    let cgImage = photo.cgImageRepresentation()!.takeRetainedValue()
    let image = UIImage(cgImage: cgImage)
    prevImageView.image = image // save the last photo, for the overlapping ImageView
    capturedImages += [image] // add to array of captured photos

    // save to photo gallery on phone as well
    PHPhotoLibrary.shared().performChanges({
            PHAssetChangeRequest.creationRequestForAsset(from: image)
    }, completionHandler: nil)
}

这将允许我们连续捕捉多幅图像,同时帮助用户将一幅图像与另一幅图像对齐。 以下是在手机上运行的 UI 示例:

接下来,我们将了解如何将图像置于 Objective-C++ 上下文中,在该上下文中,我们可以使用 OpenCV C++ API 进行全景拼接。

Objective-C++ 包装器中的 OpenCV 拼接

为了在 iOS 中工作,OpenCV 提供了可以从 Objective-C++ 调用的常用 C++ 接口。 然而,最近几年,苹果鼓励 iOS 应用开发者使用更通用的 SWIFT 语言来构建应用,放弃 Objective-C。 幸运的是,可以很容易地在 SWIFT 和 Objective-C(以及 Objective-C++)之间建立一座桥梁,使我们能够从 SWIFT 调用 Objective-C 函数。 Xcode 自动化了大部分过程,并创建了必要的粘合代码。

首先,我们在 Xcode 中创建一个新文件(Command-N),并选择 Cocoa Touch Class,如以下屏幕截图所示:

为文件选择一个有意义的名称(例如,StitchingWrapper),并确保选择 Objective-C 作为语言,如以下屏幕截图所示:

接下来,如以下屏幕截图所示,确认 Xcode 应该为您的 Objective-C 代码创建桥头代码:

此过程将产生三个文件:StitchingWrapper.hStitchingWrapper.mOpenCV Stitcher-Bridging-Header.h。 我们应该手动将StitchingWrapper.m重命名为StitchingWrapper.mm,以启用 Objective-C++而不是普通的 Objective-C。此时,我们准备开始在 Objective-C++ 代码中使用 OpenCV。

在 StitchingWrapper.h 中,我们将定义一个新函数,该函数将接受NSMutableArray*作为前面的 UI SWIFT 代码捕获的图像列表:

@interface StitchingWrapper : NSObject

+ (UIImage* _Nullable)stitch:(NSMutableArray*) images;

@end

而且,在我们的 ViewController 的 SWIFT 代码中,我们可以实现一个函数来处理单击 Stitch 按钮,其中我们从UIImagecapturedImagesSWIFT 数组创建NSMutableArray

@IBAction func stitch_TouchUpInside(_ sender: Any) {
    let image = StitchingWrapper.stitch(NSMutableArray(array: capturedImages, copyItems: true))
    if image != nil {
        PHPhotoLibrary.shared().performChanges({ // save stitching result to gallery
                PHAssetChangeRequest.creationRequestForAsset(from: image!)
        }, completionHandler: nil)
    }
}

回到 Objective-C++ 端,我们首先需要从UIImage*的输入获取 OpenCVcv::Mat对象,如下所示:

+ (UIImage* _Nullable)stitch:(NSMutableArray*) images {
    using namespace cv;

    std::vector<Mat> imgs;

    for (UIImage* img in images) {
        Mat mat;
        UIImageToMat(img, mat);
        if ([img imageOrientation] == UIImageOrientationRight) {
            rotate(mat, mat, cv::ROTATE_90_CLOCKWISE);
        }
        cvtColor(mat, mat, cv::COLOR_BGRA2BGR);
        imgs.push_back(mat);
    }

最后,我们准备对图像数组调用stitching函数,如下所示:

    Mat pano;
    Stitcher::Mode mode = Stitcher::PANORAMA;
    Ptr<Stitcher> stitcher = Stitcher::create(mode, false);
    try {
        Stitcher::Status status = stitcher->stitch(imgs, pano);
        if (status != Stitcher::OK) {
            NSLog(@"Can't stitch images, error code = %d", status);
            return NULL;
        }
    } catch (const cv::Exception& e) {
        NSLog(@"Error %s", e.what());
        return NULL;
    }

使用此代码创建的输出全景示例(请注意柱面扭曲的使用)如下所示:

当边缘已混合时,您可能会注意到四个图像之间的照明发生了一些变化。 可以使用cv::detail::ExposureCompensator基础 API 在 OpenCV 图像拼接 API 中处理变化的照明。

简略的 / 概括的 / 简易判罪的 / 简易的

在这一章中,我们学习了全景创作。 我们已经看到了在 OpenCV 的stitching模块中实现的全景创建的一些基本理论和实践。 然后,我们将重点转向创建一个 iOS 应用,该应用可以帮助用户捕捉与重叠视图拼接的全景图像。 最后,我们了解了如何从 SWIFT 应用调用 OpenCV 代码来对捕获的图像运行stitching函数,从而生成完整的全景图。

下一章将重点介绍 OpenCV 算法的选择策略,给出一个手头的问题。 我们将了解如何在 OpenCV 中推理计算机视觉问题及其解决方案,以及如何比较竞争算法以便做出明智的选择。

进一步阅读

里克·塞利斯基关于计算机视觉的书http://szeliski.org/Book/

OpenCV 的图像拼接教程https://docs.opencv.org/trunk/d8/d19/tutorial_stitcher.html

OpenCV 的单应扭曲教程https://docs.opencv.org/3.4.1/d9/dab/tutorial_homography.html#tutorial_homography_Demo5*



回到顶部