精通-OpenCV-安卓应用编程-全-

精通 OpenCV 安卓应用编程(全)

原文:annas-archive.org/md5/0c247622f7144f4539bb1a7b23aec35c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书将帮助你迅速开始使用 Android 平台上的 OpenCV。它从概念上解释了各种计算机视觉算法,以及它们在 Android 平台上的实现。如果你期待在新的或现有的 Android 应用中实现计算机视觉模块,这本书是无价的资源。

本书涵盖内容

第一章, 将效果应用于图像,包括在各种计算机视觉应用中使用的一些基本预处理算法。本章还解释了如何将 OpenCV 集成到现有的项目中。

第二章, 在图像中检测基本特征,涵盖了在图像中检测主要特征,如边缘、角点、线和圆。

第三章, 检测对象,深入到特征检测,使用更先进的算法来检测和描述特征,以便将它们独特地匹配到其他对象中的特征。

第四章, 深入研究对象检测 – 使用级联分类器,解释了图像和视频中一般对象的检测,如人脸/眼睛。

第五章, 在视频中跟踪对象,涵盖了光流作为运动检测器的概念,并实现了 Lucas-Kanade-Tomasi 跟踪器来跟踪视频中的对象。

第六章, 处理图像对齐和拼接,涵盖了图像对齐和图像拼接的基本概念,以创建全景场景图像。

第七章, 使用 OpenCV 机器学习让您的应用栩栩如生,解释了机器学习如何在计算机视觉应用中使用。在这一章中,我们查看了一些常见的机器学习算法及其在 Android 中的实现。

第八章, 故障排除和最佳实践,涵盖了开发者在构建应用程序时遇到的一些常见错误和问题。它还展开了一些可以提高应用程序效率的良好实践。

第九章, 开发文档扫描应用,使用在第几章中解释的各种算法来构建一个完整的文档扫描系统,无论你从哪个角度点击图像。

你需要为这本书准备什么

为了这本书,你需要一个至少有 1 GB RAM 的系统。Windows、OS X 和 Linux 是目前支持 Android 开发的操作系统。

这本书面向谁

如果你是一名 Java 和 Android 开发者,并希望通过学习 OpenCV Android 应用程序编程的最新特性来提升你的技能,那么这本书就是为你准备的。

术语约定

在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“创建一个名为Application.mk的文件,并将以下代码行复制到其中。”

代码块设置如下:

<uses-permission android:name="android.permission.CAMERA"/>
    <uses-feature android:name="android.hardware.camera" android:required="false"/>
    <uses-feature android:name="android.hardware.camera.autofocus" android:required="false"/>
    <uses-feature android:name="android.hardware.camera.front" android:required="false"/>
    <uses-feature android:name="android.hardware.camera.front.autofocus" android:required="false"/>

新术语重要词汇以粗体显示。

注意

警告或重要注意事项以如下方式出现在框中。

小贴士

小贴士和技巧看起来是这样的。

读者反馈

我们始终欢迎读者的反馈。请告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者的反馈对我们来说非常重要,因为它帮助我们开发出你真正能从中受益的书籍。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲拥有者,我们有一些东西可以帮助你从购买中获得最大收益。

下载示例代码

你可以从你购买的所有 Packt 出版物的账户中下载示例代码文件www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

下载本书的彩色图像

我们还为你提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助你更好地理解输出的变化。你可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/8204OS_ImageBundle.pdf

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面提供的帮助。

问题

如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章. 将效果应用于图像

通常,图像包含的信息比任何特定任务所需的信息要多。因此,我们需要预处理图像,以便它们只包含应用程序所需的信息,从而减少所需的计算时间。

在本章中,我们将学习不同的预处理操作,具体如下:

  • 模糊

  • 去噪

  • 锐化

  • 腐蚀和膨胀

  • 阈值和自适应阈值

在本章结束时,我们将看到如何将 OpenCV 集成到现有的 Android 应用程序中。

在我们查看各种特征检测算法及其实现之前,让我们首先构建一个基本的 Android 应用程序,我们将在此章中逐步添加特征检测算法。

开始使用

当我们看到一张图片时,我们将其感知为颜色和物体。然而,计算机视觉系统将其视为一个数字矩阵(见以下图片)。这些数字根据所使用的颜色模型被不同地解释。计算机不能直接在图像中检测模式或物体。计算机视觉系统的目标是解释这个数字矩阵为特定类型的物体。

开始使用

二值图像的表示

设置 OpenCV

OpenCV 是开源计算机视觉库的简称。它是使用最广泛的计算机视觉库。它是一组常用的函数,执行与计算机视觉相关的操作。OpenCV 是用 C/C++原生编写的,但提供了 Python、Java 以及任何 JVM 语言的包装器,这些包装器旨在创建 Java 字节码,例如 Scala 和 Clojure。由于大多数 Android 应用程序开发都是在 C++/Java 中进行的,因此 OpenCV 也被移植为 SDK,开发者可以使用它在其应用程序中实现,并使它们具有视觉功能。

现在,我们将看看如何开始设置 Android 平台的 OpenCV,并开始我们的旅程。我们将使用 Android Studio 作为我们的首选 IDE,但任何其他 IDE 经过轻微修改后也应该可以工作。按照以下步骤开始:

  1. developer.android.com/sdk/下载 Android Studio,从sourceforge.net/projects/opencvlibrary/files/opencv-android/下载 OpenCV4Android SDK。

  2. 将两个文件提取到已知位置。

  3. 创建一个普通的 Android 项目,并将其命名为FirstOpenCVApp。导航到文件 | 导入

  4. 选择OpenCV_SDK_location/sdk/java/目录。

  5. 导航到构建 | 重建项目

  6. 导航到文件 | 项目结构

  7. 通过在左侧列中选择app模块,将 OpenCV 模块添加到您的应用程序中。在依赖项选项卡中点击绿色按钮,最后选择 OpenCV 模块。

  8. 现在,你已经准备好在你的 Android 项目中使用 OpenCV 了。它应该看起来像这样:

设置 OpenCV

OpenCV 中的图像存储

OpenCV 将图像存储为称为Mat的自定义对象。该对象存储有关行、列、数据等信息,这些信息可用于在需要时唯一识别和重新创建图像。不同的图像包含不同数量的数据。例如,彩色图像比相同图像的灰度版本包含更多的数据。这是因为使用 RGB 模型时,彩色图像是 3 通道图像,而灰度图像是 1 通道图像。以下图显示了如何存储 1 通道和多通道(此处为 RGB)图像(这些图像来自docs.opencv.org):

图像的 1 通道表示如下:

OpenCV 中的图像存储

灰度(1 通道)图像表示:

图像的一种更详细的形式是 RGB 表示,如下所示:

OpenCV 中的图像存储

RGB(3 通道)图像表示

在灰度图像中,数字代表该特定颜色的强度。当使用整数表示时,它们在 0-255 的范围内表示,其中 0 是纯黑色,255 是纯白色。如果我们使用浮点表示,像素在 0-1 的范围内表示,其中 0 是纯黑色,1 是纯白色。在 OpenCV 中的 RGB 图像中,第一个通道对应蓝色,第二个通道对应绿色,第三个通道对应红色。因此,每个通道代表任何特定颜色的强度。正如我们所知,红色、绿色和蓝色是原色,它们可以以不同的比例组合以生成人类眼睛可见的任何颜色。以下图显示了不同颜色及其相应的整数格式下的 RGB 等效值:

OpenCV 中的图像存储OpenCV 中的图像存储

现在我们已经看到了图像在计算术语中的表示方式,我们将看到如何修改像素值,以便在执行实际任务时需要更少的计算时间。

OpenCV 中的线性滤波器

我们都喜欢清晰的图像。谁不喜欢呢?然而,这里需要做出权衡。更多信息意味着图像在完成相同任务时需要比信息较少的图像更多的计算时间。因此,为了解决这个问题,我们应用模糊操作。

许多线性滤波算法都使用一个称为核的数字数组。核可以被视为一个滑动的窗口,它遍历每个像素并计算该像素的输出值。通过查看以下图示可以更清楚地理解这一点(此线性滤波/卷积图像取自test.virtual-labs.ac.in/labs/cse19/neigh/convolution.jpg):

OpenCV 中的线性滤波器

在前面的图中,一个 3 x 3 的核被应用于一个 10 x 10 的图像。

用于线性滤波的最通用操作之一是卷积。核中的值是对应像素乘法系数。最终结果存储在锚点,通常是核的中心:

OpenCV 中的线性滤波器

注意

线性滤波操作通常不是就地操作,因为对于每个像素,我们使用原始图像中的值,而不是修改后的值。

线性滤波最常见的一种用途是去除噪声。噪声是图像中亮度或颜色信息的随机变化。我们使用模糊操作来减少图像中的噪声。

均值模糊方法

均值滤波是最简单的模糊形式。它计算给定核覆盖的所有像素的平均值。用于此类操作的核是一个简单的 Mat,其所有值均为 1,即每个相邻像素具有相同的权重。

对于本章,我们将从图库中选择一张图片并应用相应的图像变换。为此,我们将添加基本代码。我们假设 OpenCV4Android SDK 已设置并正在运行。

我们可以使用本章开头创建的第一个 OpenCV 应用程序来完成本章的目的。在创建项目时,默认名称将如下所示:

均值模糊方法

通过在 Java 文件夹上右键单击并导航到新建 | 活动来添加一个新的活动。然后,选择空白活动。将活动命名为MainActivity.java,XML 文件命名为activity_main.xml。转到res/menu/menu_main.xml。添加如下项:

<item android:id="@+id/action_load_image"
        android:title="@string/action_load_image"
        android:orderInCategory="1"
        android:showAsAction="ifRoom" />

由于MainActivity是我们将用于执行 OpenCV 特定任务的活动,我们需要实例化 OpenCV。将其添加为MainActivity.java的全局成员:

private BaseLoaderCallback mOpenCVCallBack = new BaseLoaderCallback(this) {
        @Override
        public void onManagerConnected(int status) {
            switch (status) {
                case LoaderCallbackInterface.SUCCESS:
                    //DO YOUR WORK/STUFF HERE
                    break;
                default:
                    super.onManagerConnected(status);
                    break;
            }
        }
    };
@Override
    protected void onResume() {
        super.onResume();
        OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION_2_4_10, this,
                mOpenCVCallBack);
    }

这是一个回调,它检查 OpenCV 管理器是否已安装。我们需要在设备上安装 OpenCV 管理器应用程序,因为它定义了所有 OpenCV 函数。如果我们不希望使用 OpenCV 管理器,我们可以将函数以本地方式提供,但这样 APK 的大小会显著增加。如果 OpenCV 管理器不存在,应用程序将重定向用户到 Play Store 下载它。onResume中的函数调用加载 OpenCV 以供使用。

接下来,我们将向activity_home.xml添加一个按钮:

<Button
            android:id="@+id/bMean"
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="Mean Blur" />

然后,在HomeActivity.java中,我们将实例化这个按钮,并为此按钮设置一个onClickListener

Button bMean = (Button)findViewById(R.id.bMean);
bMean.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent i = new Intent(getApplicationContext(),MainActivity.class);
                i.putExtra("ACTION_MODE", MEAN_BLUR);
                startActivity(i);
            }
        });

小贴士

下载示例代码

您可以从www.packtpub.com下载您购买的所有 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

在前面的代码中,MEAN_BLUR是一个值为1的常量,它指定了我们想要执行的操作类型。

在这里,我们在活动包中添加了额外的信息。这是为了区分我们将执行哪种操作。

打开activity_main.xml。将所有内容替换为以下代码片段。此片段添加了两个ImageView项目:一个用于原始图像,一个用于处理后的图像:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    android:orientation="vertical" 
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_weight="0.5"
        android:id="@+id/ivImage" />

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_weight="0.5"
        android:id="@+id/ivImageProcessed" />

</LinearLayout>

我们需要以编程方式将这些ImageView项目链接到我们的MainActivity.java中的ImageView项目:

    private final int SELECT_PHOTO = 1;
    private ImageView ivImage, ivImageProcessed;
    Mat src;
    static int ACTION_MODE = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
// Android specific code
ivImage = (ImageView)findViewById(R.id.ivImage);
        ivImageProcessed = (ImageView)findViewById(R.id.ivImageProcessed);
        Intent intent = getIntent();

        if(intent.hasExtra("ACTION_MODE")){
            ACTION_MODE = intent.getIntExtra("ACTION_MODE", 0);
}

在这里,Mat 和 ImageView 已经被设置为类的全局变量,这样我们就可以在其他函数中使用它们,而无需将它们作为参数传递。我们将使用ACTION_MODE变量来识别要执行的操作。

现在,我们将添加从图库加载图像的代码。为此,我们将使用我们之前创建的菜单按钮。当您点击菜单按钮时,我们将加载menu_main.xml文件:

@Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.menu_main, menu);
        return true;
    }

然后,我们将添加一个监听器,当选择操作项时执行所需操作。我们将使用Intent.ACTION_PICK从图库中获取图像:

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int id = item.getItemId();
        if (id == R.id.action_load_image) {
            Intent photoPickerIntent = new Intent(Intent.ACTION_PICK);
            photoPickerIntent.setType("image/*");
            startActivityForResult(photoPickerIntent, SELECT_PHOTO);
            return true;
        }
        return super.onOptionsItemSelected(item);
    }

如您所见,我们使用了startActivityForResult()。这将发送选定的图像到onActivityResult()。我们将使用这个方法来获取 Bitmap 并将其转换为 OpenCV Mat。一旦操作完成,我们希望从其他活动获取图像。为此,我们创建了一个新的函数onActivityResult(),当活动完成其工作并返回调用活动时会被调用。将以下代码添加到onActivityResult()中:

        switch(requestCode) {
            case SELECT_PHOTO:
                if(resultCode == RESULT_OK){
                    try {
                        //Code to load image into a Bitmap and convert it to a Mat for processing.
            final Uri imageUri = imageReturnedIntent.getData();
            final InputStream imageStream = getContentResolver().openInputStream(imageUri);
            final Bitmap selectedImage = BitmapFactory.decodeStream(imageStream);
                src = new Mat(selectedImage.getHeight(), selectedImage.getWidth(), CvType.CV_8UC4);
                        Utils.bitmapToMat(selectedImage, src);

                        switch (ACTION_MODE){
                            //Add different cases here depending on the required operation
                        }
                            //Code to convert Mat to Bitmap to load in an ImageView. Also load original image in imageView

                   } catch (FileNotFoundException e) {
                        e.printStackTrace();
                   }
    }
            break;
  }

要对图像应用均值模糊,我们使用 OpenCV 提供的blur()函数。我们为此目的使用了一个 3 x 3 的核:

case HomeActivity.MEAN_BLUR:
Imgproc.blur(src, src, new Size(3,3));
      break;

现在,我们将此图像设置在 ImageView 中,以查看操作的结果:

Bitmap processedImage = Bitmap.createBitmap(src.cols(), src.rows(), Bitmap.Config.ARGB_8888);
Utils.matToBitmap(src, processedImage);
ivImage.setImageBitmap(selectedImage);
ivImageProcessed.setImageBitmap(processedImage);

均值模糊方法

原始图像(左侧)和应用均值模糊后的图像(右侧)

高斯模糊方法

高斯模糊是最常用的模糊方法。高斯核是通过以下给出的高斯函数获得的:

高斯模糊方法高斯模糊方法

一维和二维的高斯函数

锚点像素被认为是位于(0,0)。正如我们所见,靠近锚点像素的像素比远离它的像素赋予更高的权重。这通常是理想的情况,因为附近的像素应该比远离的像素对特定像素的结果有更大的影响。以下图中显示了大小为 3、5 和 7 的高斯核(“高斯核”图像取自www1.adept.com/main/KE/DATA/ACE/AdeptSight_User/ImageProcessing_Operations.html):

高斯模糊方法

这些是大小为 3 x 3、5 x 5 和 7 x 7 的高斯核。

要在您的应用程序中使用高斯模糊,OpenCV 提供了一个名为GaussianBlur的内置函数。我们将使用此函数并得到以下结果图像。我们将向之前使用的相同 switch 块添加一个新情况。对于此代码,声明一个常量GAUSSIAN_BLUR,其值为 2:

case HomeActivity.GAUSSIAN_BLUR:
    Imgproc.GaussianBlur(src, src, new Size(3,3), 0);
    break;

高斯模糊方法

对原始图像应用高斯模糊后的图像

中值模糊方法

图像中常见的一种噪声类型被称为盐和胡椒噪声。在这种噪声中,稀疏出现的黑白像素分布在图像上。为了去除这种类型的噪声,我们使用中值模糊。在这种模糊中,我们按升序/降序排列我们的核覆盖的像素,并将中间元素的值设置为锚点像素的最终值。使用这种类型过滤的优势在于,盐和胡椒噪声是稀疏出现的,因此当平均它们的值时,其影响仅限于少数像素。因此,在更大的区域内,噪声像素的数量少于有用像素的数量,如下面的图像所示:

中值模糊方法

盐和胡椒噪声的示例

要在 OpenCV 中应用中值模糊,我们使用内置函数medianBlur。与之前的情况一样,我们必须添加一个按钮并添加OnClickListener函数。我们将为此操作添加另一个情况条件:

case HomeActivity.MEDIAN_BLUR:
    Imgproc.medianBlur(src, src, 3);
    break;

中值模糊方法

应用中值模糊后的结果图像

注意

中值模糊不使用卷积。

创建自定义核

我们已经看到了不同类型的核如何影响图像。如果我们想为 OpenCV 未原生提供的不同应用创建自己的核,会怎样呢?在本节中,我们将看到如何实现这一点。我们将尝试从给定的输入中形成更清晰的图像。

锐化可以被视为一种线性滤波操作,其中锚点像素具有高权重,而周围的像素具有低权重。满足此约束条件的核如下表所示:

0 -1 0
-1 5 -1
0 -1 0

我们将使用此核对我们的图像进行卷积:

case HomeActivity.SHARPEN:
    Mat kernel = new Mat(3,3,CvType.CV_16SC1); 
          kernel.put(0, 0, 0, -1, 0, -1, 5, -1, 0, -1, 0);

这里我们已将图像深度设置为 16SC1。这意味着我们的图像中的每个像素包含一个 16 位有符号整数(16S),并且图像有 1 个通道(C1)。

现在我们将使用 filter2D() 函数,该函数在给定输入图像和核时执行实际的卷积。我们将在 ImageView 中显示图像。我们将在之前创建的 switch 块中添加另一个情况:

    Imgproc.filter2D(src, src, src.depth(), kernel);

创建自定义核

原始图像(左)和锐化图像(右)

形态学操作

形态学操作是一组基于图像特征和结构元素的图像处理操作。这些操作通常在二值或灰度图像上工作。在继续探讨更高级的形态学操作之前,我们将先看看一些基本的形态学操作。

膨胀

膨胀是一种通过扩展图像的亮区来实现的操作。为了实现这一点,我们取一个所需大小的核,并用核重叠的最大值替换锚点像素。膨胀可以用来合并可能已经断裂的对象。

膨胀

二值图像(左)和应用膨胀操作后的结果(右)

要应用此操作,我们使用 dilate() 函数。我们需要使用一个核来进行膨胀。我们使用 getStructuringElement() OpenCV 函数来获取所需的核。

OpenCV 提供 MORPH_RECTMORPH_CROSSMORPH_ELLIPSE 作为创建所需核的选项:

case HomeActivity.DILATE:
    Mat kernelDilate = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(3, 3));
    Imgproc.dilate(src, src, kernelDilate);
    break;

膨胀

原始图像(左)和膨胀图像(右)

如果我们使用矩形结构元素,图像将呈矩形增长。同样,如果我们使用椭圆形结构元素,图像将呈椭圆形增长。

腐蚀

同样,腐蚀是一种通过扩展图像的暗区来实现的操作。为了实现这一点,我们取一个所需大小的核,并用核重叠的最小值替换锚点像素。腐蚀可以用来从图像中去除噪声。

腐蚀

二值图像(左)和应用腐蚀操作后的结果(右)

要应用此操作,我们使用 erode() 函数:

case HomeActivity.ERODE:
    Mat kernelErode = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, new Size(5, 5));
    Imgproc.erode(src, src, kernelErode);
         break;

腐蚀

原始图像(左)和腐蚀图像(右)

注意

腐蚀和膨胀不是逆操作。

阈值化

阈值化是将我们希望分析的图像部分分割出来的方法。每个像素的值与预定义的阈值值进行比较,并根据此结果修改像素的值。OpenCV 提供了五种类型的阈值化操作。

要执行阈值化,我们将使用以下代码作为模板,并根据所需的阈值化类型更改参数。我们需要将 THRESH_CONSTANT 替换为所需的阈值化方法的常数:

case HomeActivity.THRESHOLD:
    Imgproc.threshold(src, src, 100, 255, Imgproc.THRESH_CONSTANT);
    break;

在这里,100 是阈值值,255 是最大值(纯白色的值)。

常数列在以下表中:

阈值方法名称 阈值函数 常数
二值阈值 阈值 THRESH_BINARY
阈值置零 阈值 THRESH_TOZERO
截断 阈值 THRESH_TRUNC
二值阈值,反转 阈值 THRESH_BINARY_INV
阈值置零,反转 阈值 THRESH_TOZERO_INV

以下用于阈值结果的图像取自docs.opencv.org/trunk/d7/d4d/tutorial_py_thresholding.html:

阈值

自适应阈值

在进行分割时设置全局阈值值可能不是最佳选择。光照条件影响像素的强度。因此,为了克服这一限制,我们将尝试根据每个像素的相邻像素计算阈值值。

我们将使用三个参数来计算图像的自适应阈值:

  1. 自适应方法:以下是我们将使用的两种方法:

    • ADAPTIVE_THRESH_MEAN_C: 阈值值是相邻像素的平均值

    • ADAPTIVE_THRESH_GAUSSIAN_C: 阈值值是相邻像素值的加权总和,其中权重是高斯核

  2. 块大小:这是邻域的大小

  3. C:这是从每个像素计算出的均值/加权均值中减去的常数:

    case HomeActivity.ADAPTIVE_THRESHOLD:
        Imgproc.cvtColor(src, src, Imgproc.COLOR_BGR2GRAY);
        Imgproc.adaptiveThreshold(src, src, 255, Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY, 3, 0);
        break;
    

    自适应阈值

    原始图像(左)和应用自适应阈值后的图像(右)

在这里,结果图像中存在很多噪声。这可以通过在应用自适应阈值之前应用模糊操作来避免,以平滑图像。

摘要

在本章中,我们学习了如何在 Android 项目中开始使用 OpenCV。然后我们探讨了图像处理中的不同过滤器,特别是线性过滤器,以及它们如何在 Android 设备上实现。这些过滤器将构成您尝试构建的任何计算机视觉应用的基础。在接下来的章节中,我们将探讨更复杂的图像过滤器,并了解如何从图像中提取边缘、角点等信息。

第二章. 在图像中检测基本特征

在上一章阅读了图像处理和操作的基本知识之后,我们将探讨一些最广泛使用的算法,这些算法用于从图像中提取有意义的信息,形式为边缘、线条、圆形、椭圆形、块或轮廓、用户定义的形状和角落。在计算机视觉图像处理的背景下,此类信息通常被称为特征。在本章中,我们将探讨各种特征检测算法,如边缘和角落检测算法、霍夫变换和轮廓检测算法,以及它们在 Android 平台上的实现,使用 OpenCV。

为了使我们的生活更简单,并对本章有一个清晰的理解,我们将首先创建一个基本的 Android 应用程序,我们将向其中添加不同特征检测算法的实现。这将减少我们为本章中的每个算法必须编写的额外代码量。

创建我们的应用程序

让我们创建一个非常基本的 Android 应用程序,该程序将从您的手机相册中读取图像,并使用ImageView控件在屏幕上显示它们。该应用程序还将有一个菜单选项打开相册以选择图像。

我们将从一个新的 Eclipse(或 Android Studio)项目开始,创建一个空白活动,并让我们将我们的应用程序命名为功能应用

注意

在对应用程序进行任何操作之前,请在您的应用程序中初始化 OpenCV(有关如何在 Android 项目中初始化 OpenCV 的信息,请参阅第一章,对图像应用效果)。

在空白活动中添加一个ImageView控件(用于显示图像),如下面的代码片段所示:

<ImageView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:id="@+id/image_view"
        android:visibility="visible"/>

在应用程序菜单中添加一个OpenGallery菜单选项以打开手机的相册并帮助我们选择一张图片。为此,在项目的菜单资源 XML 文件中添加一个新的菜单项(文件的默认位置是/res/menu/filename.xml),如下所示:

<item android:id="@+id/OpenGallery" android:title="@string/OpenGallery"
        android:orderInCategory="100" android:showAsAction="never" />

提示

关于 Android 中菜单的更详细信息,请参阅developer.android.com/guide/topics/ui/menus.html

现在我们让OpenGallery菜单选项变得可用。Android API 提供了一个public boolean onOptionsItemSelected(MenuItem item)函数,允许开发者编程选项选择事件。在这个函数中,我们将添加一段代码,这将打开手机的相册以选择一张图片。Android API 提供了一个预定义的 intent Intent.ACTION_PICK专门用于这个任务;即打开相册并选择一张图片。我们将为此应用程序使用这个 intent,如下所示:

Intent intent = new Intent(Intent.ACTION_PICK, Uri.parse("content://media/internal/images/media"));

让我们修改public boolean onOptionsItemSelected(MenuItem item)函数,使其按我们的需求工作。

函数的最终实现应如下所示:

public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();

        //noinspection SimplifiableIfStatement
        if (id == R.id.action_settings) {
            return true;
        }
        else if (id == R.id.open_gallery) {
            Intent intent = new Intent(Intent.ACTION_PICK, Uri.parse("content://media/internal/images/media"));
            startActivityForResult(intent, 0);
        }
    }

这段代码只有一些易于理解的 if else 语句。这里你需要理解的是 startActivityForResult() 函数。正如你可能已经意识到的,我们希望将来自 ACTION_PICK Intent 的图像数据带到我们的应用程序中,以便我们以后可以用作特征检测算法的输入。因此,我们不是使用 startActivity() 函数,而是使用 startActivityForResult()。用户完成后续活动后,系统会调用 onActivityResult() 函数,并附带从调用意图返回的结果,在我们的例子中是图库选择器。我们现在的工作是实现符合我们应用程序的 onActivityResult() 函数。让我们首先列举一下我们想要对返回的图像做什么。实际上并不多;纠正图像的方向,并使用我们在本节开头添加到活动中的 ImageView 在屏幕上显示它。

注意

你可能想知道什么是纠正图像方向。在任何 Android 手机上,可能有多个图像来源,例如原生的相机应用程序、Java 相机应用程序或任何其他第三方应用程序。每个都可能以不同的方式捕获和存储图像。现在,在你的应用程序中,当你加载这些图像时,它们可能已经以某个角度旋转。在我们将这些图像用于应用程序之前,我们应该纠正它们的方向,以便它们对应用程序用户有意义。我们现在将查看执行此操作的代码。

以下是我们应用程序的 onActivityResult() 函数:

protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (requestCode == 0 && resultCode == RESULT_OK && null != data) {
            Uri selectedImage = data.getData();
            String[] filePathColumn = {MediaStore.Images.Media.DATA};

            Cursor cursor = getContentResolver().query(selectedImage,
                    filePathColumn, null, null, null);
            cursor.moveToFirst();

            int columnIndex = cursor.getColumnIndex(filePathColumn[0]);
            String picturePath = cursor.getString(columnIndex);
            cursor.close();

            // String picturePath contains the path of selected Image

            //To speed up loading of image
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inSampleSize = 2;

            Bitmap temp = BitmapFactory.decodeFile(picturePath, options);

            //Get orientation information
            int orientation = 0;
            try {
                ExifInterface imgParams = new ExifInterface(picturePath);
                orientation = imgParams.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);

            } catch (IOException e) {
                e.printStackTrace();
            }

            //Rotating the image to get the correct orientation
            Matrix rotate90 = new Matrix();
            rotate90.postRotate(orientation);
            originalBitmap = rotateBitmap(temp,orientation);

            //Convert Bitmap to Mat
            Bitmap tempBitmap = originalBitmap.copy(Bitmap.Config.ARGB_8888,true);
            originalMat = new Mat(tempBitmap.getHeight(), tempBitmap.getWidth(), CvType.CV_8U);
            Utils.bitmapToMat(tempBitmap, originalMat);

            currentBitmap = originalBitmap.copy(Bitmap.Config.ARGB_8888,false);
            loadImageToImageView();
        }
    }

让我们看看这段长代码做了什么。首先,我们进行一次合理性检查,看看结果是否来自适当的意图(即图库选择器),通过检查 requestCoderesultCode。完成这个步骤后,我们尝试从你的手机文件系统中检索图像的路径。从 ACTION.PICK 意图,我们获取所选图像的 Uri,我们将将其存储在 Uri selectedImage 中。为了获取图像的确切路径,我们使用 Cursor 类。我们使用它初始化一个新的 Cursor 类对象,指向我们的 selectedImage。使用 MediaStore.Images.Media.DATA,我们获取所选图像的列索引,然后最终使用之前声明的 cursor 类获取图像的路径,并将其存储在一个字符串 picturePath 中。在我们得到图像的路径后,我们创建一个新的 Bitmap 对象 temp 来存储图像。到目前为止,我们已经能够读取图像并将其存储在位图对象中。接下来我们需要纠正方向。为此,我们首先使用 ExifInterface 类从图像中提取方向信息。正如你在代码中所见,ExifInterface 类通过 ExifInterface.TAG_ORIENTATION 给我们方向信息。使用这个方向信息,我们使用 rotateBitmap() 函数相应地旋转我们的位图。

注意

关于rotateBitmap()函数的实现,请参考本书附带的代码包。

在纠正方向后,我们创建两个位图的副本:一个用于存储原始图像(originalBitmap),另一个用于存储处理过的位图(currentBitmap),即存储应用于原始位图的不同算法的输出。剩下的唯一部分是将图像显示在屏幕上。创建一个新的函数loadImageToView(),并向其中添加以下行:

private void loadImageToImageView()
    {
        ImageView imgView = (ImageView) findViewById(R.id.image_view);
        imgView.setImageBitmap(currentBitmap);
    }

第一行创建了一个ImageView实例,第二行将图像设置到视图中。很简单!

最后一件事情,我们的应用程序就准备好了!由于我们的应用程序将要从永久存储中读取数据(从外部存储读取图像),我们需要权限。在AndroidManifest.xml文件中添加以下行,这将允许应用程序访问外部存储以读取数据:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

现在我们已经建立了基本的应用程序,让我们来看看不同的特征检测算法,从边缘和角点检测、霍夫变换和轮廓开始。

边缘和角点检测

边缘检测和角点检测是两种最基本的特征检测算法,也是非常有用的算法。了解图像中的边缘信息在许多应用中非常有帮助,在这些应用中,你想要找到图像中不同对象的边界,或者当你想要分析对象在给定的一系列图像(或视频)中的旋转或移动时,你需要找到图像中的角点。在本节中,我们将探讨各种边缘和角点检测算法的技术和实现,例如高斯差分、Canny 边缘检测器、Sobel 算子和 Harris 角点。

高斯差分技术

让我们从最简单和最基本的技术开始。在我们理解高斯差分DoG)是如何工作的之前,让我们看看边缘究竟是什么。简单来说,边缘是图像中像素强度发生显著变化的点。我们将利用边缘的这一特性,通过对图像应用高斯模糊,来计算边缘点(边缘)。

这里是对算法的三个步骤的解释:

  1. 将给定的图像转换为灰度图像。

  2. 在灰度图像上,使用两个不同的模糊半径进行高斯模糊(在此步骤之后,你应该有两个高斯模糊后的图像)。

  3. 从上一步生成的两个图像中减去(算术减法)以获得只包含边缘点(边缘)的结果图像。

为什么这种技术有效?减去两个高斯模糊图像是如何给我们边缘点的?高斯滤波器用于平滑图像,平滑的程度取决于模糊半径。考虑一个棋盘图像。当你对棋盘图像应用高斯滤波器时,你会观察到在白色和黑色方块的中央几乎没有变化,而黑白方块的公共边(即边缘点)会变得模糊,这意味着边缘信息的丢失。高斯模糊使边缘不那么突出。

根据我们的技术,我们有两个具有不同模糊半径的高斯模糊图像。当你从这两个图像中减去时,你会丢失所有没有平滑或模糊的点,即棋盘图像中黑白方块的中央。然而,边缘附近的像素值会发生变化,因为模糊像素值和减去这样的点将给我们一个非零值,这表明是一个边缘点。因此,通过减去两个高斯模糊图像,你得到了边缘点。

由于我们只对图像执行高斯模糊,这是计算边缘最快的方法之一。话虽如此,这也确实是真的,这种技术并不总是能给出很有希望的结果。这种技术对于某些图像可能非常有效,但在某些场景中可能会完全失败。然而,了解一个额外的算法并不会有什么坏处!

让我们修改上一节中创建的 Features App,并对其应用 DoG 技术。在应用程序菜单中,我们使用以下行在菜单资源 XML 文件中添加一个新的菜单选项,高斯差分

<item android:id="@+id/DoG" android:title="@string/DoG"
        android:orderInCategory="100" android:showAsAction="never" />

创建一个新的函数 public void DifferenceOfGaussian(),它将计算任何给定图像的边缘,如下所示:

public void DifferenceOfGaussian()
    {
        Mat grayMat = new Mat();
        Mat blur1 = new Mat();
        Mat blur2 = new Mat();

        //Converting the image to grayscale
        Imgproc.cvtColor(originalMat,grayMat,Imgproc.COLOR_BGR2GRAY);

        //Bluring the images using two different blurring radius
        Imgproc.GaussianBlur(grayMat,blur1,new Size(15,15),5);
        Imgproc.GaussianBlur(grayMat,blur2,new Size(21,21),5);

        //Subtracting the two blurred images
        Mat DoG = new Mat();
        Core.absdiff(blur1, blur2,DoG);

        //Inverse Binary Thresholding
        Core.multiply(DoG,new Scalar(100), DoG);
        Imgproc.threshold(DoG,DoG,50,255,Imgproc.THRESH_BINARY_INV);

        //Converting Mat back to Bitmap
        Utils.matToBitmap(DoG, currentBitmap);
        loadImageToImageView();
    }

在前面的代码片段中,我们首先将图像转换为灰度图像。然后,我们使用 Imgproc.GaussianBlur() 函数对图像进行两次高斯滤波,使用两个不同的模糊半径。这个函数的前两个参数分别是输入和输出图像,第三个参数指定了应用滤波器时使用的核的大小,最后一个参数指定了高斯函数中使用的 sigma 值。然后我们使用 Core.absdiff() 函数确定图像之间的绝对差异。完成这一步后,我们通过应用 逆二值阈值 操作来后处理我们的图像,将边缘点值设置为白色(255)。最后,我们使用 loadImageToView() 函数将位图转换为 Mat 并在屏幕上显示。

下面是应用 DoG 技术到 Lenna 图像后的结果:

高斯差分技术

高斯差分并不常用,因为它已经被我们将在本章后面讨论的更复杂的其他技术所取代。

Canny 边缘检测器

Canny 边缘检测是计算机视觉中广泛使用的算法,通常被认为是边缘检测的最佳技术。该算法使用了比高斯差分更复杂的技巧,例如多方向上的强度梯度,以及具有阈值化的滞后效应。

算法大致分为四个阶段:

  1. 平滑图像:这是算法的第一步,通过执行适当的模糊半径的高斯模糊来减少图像中存在的噪声量。

  2. 计算图像的梯度:在这里,我们计算图像的强度梯度,并将梯度分类为垂直、水平或对角。这一步骤的输出用于在下一阶段计算实际边缘。

  3. 非最大抑制:使用前一步计算的梯度方向,我们检查一个像素是否是梯度正负方向上的局部最大值,如果不是,则抑制该像素(这意味着像素不是任何边缘的一部分)。这是一种边缘细化技术。选择变化最剧烈的边缘点。

  4. 通过阈值化选择边缘:这是算法的最终步骤。在这里,我们检查一个边缘是否足够强,可以包含在最终输出中,本质上移除所有不太显著的边缘。

小贴士

参考以下链接以获取更详细的解释:en.wikipedia.org/wiki/Canny_edge_detector

下面的代码是使用 OpenCV for Android 实现的算法。

对于高斯差分,首先通过在菜单资源 XML 文件中添加一个新项将Canny Edges选项添加到应用程序菜单中,如下所示:

<item android:id="@+id/CannyEdges" android:title="@string/CannyEdges"
        android:orderInCategory="100" android:showAsAction="never" />

创建一个新的函数public void Canny(),并将以下代码行添加到其中:

//Canny Edge Detection
    public void Canny()
    {
        Mat grayMat = new Mat();
        Mat cannyEdges = new Mat();
        //Converting the image to grayscale
            Imgproc.cvtColor(originalMat,grayMat,Imgproc.COLOR_BGR2GRAY);

        Imgproc.Canny(grayMat, cannyEdges,10, 100);

        //Converting Mat back to Bitmap
        Utils.matToBitmap(cannyEdges, currentBitmap);
        loadImageToImageView();
    }

在前面的代码中,我们首先将图像转换为灰度图像,然后简单地调用 OpenCV API for Android 中实现的Imgproc.Canny()函数。这里需要注意的重要事项是Imgproc.Canny()函数中的最后两个参数。它们分别代表低阈值和高阈值。在 Canny 边缘检测算法中,我们将图像中的每个点分类为三类之一,即抑制点弱边缘点强边缘点。所有强度梯度值低于低阈值值的点被分类为抑制点,强度梯度值在低阈值和高阈值之间的点被分类为弱边缘点,强度梯度值高于高阈值值的点被分类为强边缘点。

根据算法,我们忽略所有被抑制的点。它们不会成为图像中任何边缘的一部分。强边缘点肯定构成边缘的一部分。对于弱边缘点,我们检查它们是否通过检查该弱点周围的八个像素与图像中的任何强边缘点相连。如果那些八个像素中有任何强边缘点,我们将那个弱点计为边缘的一部分。这就是 Canny 边缘检测!

Sobel 算子

计算图像边缘的另一种技术是使用 Sobel 算子(或 Sobel 滤波器)。与 Canny 边缘检测类似,我们计算像素的强度梯度,但方式不同。在这里,我们通过使用两个 3x3 核(水平和垂直方向各一个)对图像进行卷积来计算近似强度梯度:

Sobel 算子

Sobel 滤波器中使用的卷积矩阵

使用水平和垂直梯度值,我们使用此公式计算每个像素的绝对梯度:

Sobel 算子

对于近似梯度,通常使用以下公式:

Sobel 算子

使用 Sobel 算子计算边缘的步骤如下:

  1. 将图像转换为灰度图像。

  2. 计算水平方向上的强度梯度的绝对值。

  3. 计算垂直方向上的强度梯度的绝对值。

  4. 使用前面的公式计算结果梯度。结果梯度值基本上是边缘。

现在我们将 Sobel 滤波器添加到我们的 Features App 中。首先在菜单的 XML 文件中添加一个Sobel 滤波器菜单选项:

<item android:id="@+id/SobelFilter" android:title="@string/SobelFilter"
        android:orderInCategory="100" android:showAsAction="never" />

以下是在 Android OpenCV 中使用的 Sobel 滤波器:

//Sobel Operator
    void Sobel()
    {
        Mat grayMat = new Mat();
        Mat sobel = new Mat(); //Mat to store the result

        //Mat to store gradient and absolute gradient respectively
        Mat grad_x = new Mat();
        Mat abs_grad_x = new Mat();

        Mat grad_y = new Mat();
        Mat abs_grad_y = new Mat();

        //Converting the image to grayscale
        Imgproc.cvtColor(originalMat,grayMat,Imgproc.COLOR_BGR2GRAY);

        //Calculating gradient in horizontal direction
        Imgproc.Sobel(grayMat, grad_x,CvType.CV_16S, 1,0,3,1,0);

        //Calculating gradient in vertical direction
        Imgproc.Sobel(grayMat, grad_y,CvType.CV_16S, 0,1,3,1,0);

        //Calculating absolute value of gradients in both the direction
        Core.convertScaleAbs(grad_x, abs_grad_x);
        Core.convertScaleAbs(grad_y, abs_grad_y);

        //Calculating the resultant gradient
        Core.addWeighted(abs_grad_x, 0.5, abs_grad_y, 0.5, 1, sobel);

        //Converting Mat back to Bitmap
        Utils.matToBitmap(sobel, currentBitmap);
        loadImageToImageView();
    }

在此代码中,我们首先将图像转换为灰度图像。之后,使用灰度图像,我们使用Imgproc.Sobel()函数计算水平和垂直方向上的强度梯度,并将输出存储在grad_xgrad_y中。根据算法中提到的公式,我们计算梯度的绝对值并将它们相加以获得结果梯度值(基本上是边缘)。以下代码片段执行了描述的步骤:

//Calculating absolute value of gradients in both the direction
        Core.convertScaleAbs(grad_x, abs_grad_x);
        Core.convertScaleAbs(grad_y, abs_grad_y);

        //Calculating the resultant gradient
        Core.addWeighted(abs_grad_x, 0.5, abs_grad_y, 0.5, 1, sobel);

最后,我们将 Mat 转换为位图并在屏幕上显示。

小贴士

你可能还感兴趣查看 Prewitt 算子(en.wikipedia.org/wiki/Prewitt_operator)。它与 Sobel 算子类似,但使用不同的矩阵进行卷积。

Harris 角点检测

在这个术语的字面意义上,角点是两条边或一个在其局部邻域内有多个显著边缘方向的点的交点。角点通常被视为图像中的兴趣点,并被用于许多应用,从图像相关性、视频稳定化、3D 建模等。Harris 角点检测是角点检测中最常用的技术之一;在本节中,我们将探讨如何在 Android 平台上实现它。

Harris 角点检测器在图像上使用滑动窗口来计算强度变化。由于角点周围的强度值变化很大,我们正在寻找图像中滑动窗口显示强度变化大的位置。我们试图最大化以下项:

Harris 角点检测

在这里,I是图像,u是滑动窗口在水平方向上的位移,v是滑动窗口在垂直方向上的位移。

以下是用 OpenCV 实现的 Harris 角点检测的示例:

void HarrisCorner() {
        Mat grayMat = new Mat();
        Mat corners = new Mat();

        //Converting the image to grayscale
        Imgproc.cvtColor(originalMat, grayMat, Imgproc.COLOR_BGR2GRAY);

        Mat tempDst = new Mat();
        //finding corners        Imgproc.cornerHarris(grayMat, tempDst, 2, 3, 0.04);

        //Normalizing harris corner's output
        Mat tempDstNorm = new Mat();
        Core.normalize(tempDst, tempDstNorm, 0, 255, Core.NORM_MINMAX);
        Core.convertScaleAbs(tempDstNorm, corners);

        //Drawing corners on a new image
        Random r = new Random();
        for (int i = 0; i < tempDstNorm.cols(); i++) {
            for (int j = 0; j < tempDstNorm.rows(); j++) {
                double[] value = tempDstNorm.get(j, i);
                if (value[0] > 150)
                    Core.circle(corners, new Point(i, j), 5, new Scalar(r.nextInt(255)), 2);
            }
        }

        //Converting Mat back to Bitmap
        Utils.matToBitmap(corners, currentBitmap);
        loadImageToImageView();
    }

在前面的代码中,我们首先将图像转换为灰度图像,然后将其作为Imgproc.cornerHarris()函数的输入。函数的其他输入是块大小、核大小以及一个参数k,它用于解决算法中的一个方程(有关数学细节,请参阅 OpenCV 关于 Harris 角点的文档docs.opencv.org/doc/tutorials/features2d/trackingmotion/harris_detector/harris_detector.html)。Harris 角点的输出是一个 16 位标量图像,它被归一化以获得 0 到 255 范围内的像素值。之后,我们运行一个for循环,并在图像上绘制所有圆,这些圆的中心是强度值大于某个用户设定的阈值的点。

霍夫变换

到目前为止,我们讨论了如何在图像中检测边缘和角点。有时,除了边缘和角点之外,进行图像分析时,你还想检测形状,如直线、圆、椭圆或任何其他形状。比如说,你想要在图像中检测硬币,或者你想要在图像中检测一个盒子或网格。在这种情况下,一个很有用的技术是霍夫变换。它是一种广泛使用的技术,通过使用它们的参数化形式中的数学方程来检测图像中的形状。

广义霍夫变换能够检测任何我们可以提供参数化形式的方程的形状。当形状开始变得复杂(维度增加)时,例如球体或椭球体,计算量会变得很大;因此,我们通常考虑标准霍夫变换来处理简单的二维形状,如直线和圆。

在本节中,我们将探讨霍夫变换以检测线和圆,但如前所述,它可以进一步扩展以检测形状,例如椭圆,甚至简单的三维形状,例如球体。

霍夫线

检测线是霍夫变换的最简单用例之一。在霍夫线中,我们从图像中选择一对点 (x1, y1)(x2, y2),并解以下方程组以得到 (a, m)

y1 = m(x1) + a

y2 = m(x2) + a

我们维护一个包含两列 (a, m) 和一个计数值的表格。计数值记录了解决前一对方程后我们得到多少次 (a, m) 值。这实际上就是一个投票过程。计算了所有可能点对的所有可能的 (a, m) 值后,我们选取计数值大于某个阈值的 (a, m) 值,这些值就是图像中期望的线。

注意

对于霍夫变换,我们从不直接在图像上运行算法。首先,我们在图像中计算边缘,然后对边缘应用霍夫变换。原因在于,图像中的任何突出线都必须是边缘(反之则不然,图像中的每个边缘都不一定是线),仅使用边缘,我们减少了算法运行的点数。

OpenCV 提供了两种霍夫线的实现:标准霍夫线和概率霍夫线。两者之间的主要区别在于,在概率霍夫线中,我们不是使用所有边缘点,而是通过随机采样选择边缘点的一个子集。这使得算法运行得更快,因为要处理的数据点更少,而不会影响其性能。

是时候写一些代码了!首先,在我们的应用程序菜单中添加一个新的 霍夫线 菜单选项。然而,这次尽量自己找出实现这个功能的代码。

希望现在菜单选项已经到位!现在让我们看看一个使用概率霍夫变换在图像中检测线的代码片段,使用 OpenCV for Android:

void HoughLines()
    {

        Mat grayMat = new Mat();
        Mat cannyEdges = new Mat();
        Mat lines = new Mat();

        //Converting the image to grayscale
        Imgproc.cvtColor(originalMat,grayMat,Imgproc.COLOR_BGR2GRAY);

        Imgproc.Canny(grayMat, cannyEdges,10, 100);

        Imgproc.HoughLinesP(cannyEdges, lines, 1, Math.PI/180, 50, 20, 20);

        Mat houghLines = new Mat();
        houghLines.create(cannyEdges.rows(),cannyEdges.cols(),CvType.CV_8UC1);

        //Drawing lines on the image
        for(int i = 0 ; i < lines.cols() ; i++)
        {
            double[] points = lines.get(0,i);
            double x1, y1, x2, y2;

            x1 = points[0];
            y1 = points[1];
            x2 = points[2];
            y2 = points[3];

            Point pt1 = new Point(x1, y1);
            Point pt2 = new Point(x2, y2);

            //Drawing lines on an image
            Core.line(houghLines, pt1, pt2, new Scalar(255, 0, 0), 1);
        }

        //Converting Mat back to Bitmap
        Utils.matToBitmap(houghLines, currentBitmap);
        loadImageToImageView();

    }

如前所述,我们首先使用任何边缘检测技术(前面的代码使用 Canny)在图像中计算边缘。Canny 边缘检测器的输出被用作 Imgproc.HoughLinesP() 函数的输入。前两个参数分别是输入和输出。第三个和第四个参数指定了像素中的 r 和 theta 的分辨率。接下来的两个参数是线的阈值和线应具有的最小点数。少于这个点数的线将被丢弃。

代码中的 For 循环用于在图像上绘制所有线。这只是为了可视化算法检测到的线。

霍夫圆

与霍夫线类似,霍夫圆也遵循相同的程序来检测圆,只是方程有所变化(使用圆的参数化形式)。

这里是使用 OpenCV for Android 实现霍夫圆的示例:

void HoughCircles()
    {
        Mat grayMat = new Mat();
        Mat cannyEdges = new Mat();
        Mat circles = new Mat();

        //Converting the image to grayscale
        Imgproc.cvtColor(originalMat,grayMat,Imgproc.COLOR_BGR2GRAY);

        Imgproc.Canny(grayMat, cannyEdges,10, 100);

        Imgproc.HoughCircles(cannyEdges, circles, Imgproc.CV_HOUGH_GRADIENT,1, cannyEdges.rows() / 15);//, grayMat.rows() / 8);

        Mat houghCircles = new Mat();
        houghCircles.create(cannyEdges.rows(),cannyEdges.cols(),CvType.CV_8UC1);

        //Drawing lines on the image
        for(int i = 0 ; i < circles.cols() ; i++)
        {
            double[] parameters = circles.get(0,i);
            double x, y;
            int r;

            x = parameters[0];
            y = parameters[1];
            r = (int)parameters[2];

            Point center = new Point(x, y);

            //Drawing circles on an image
            Core.circle(houghCircles,center,r, new Scalar(255,0,0),1);
        }

        //Converting Mat back to Bitmap
        Utils.matToBitmap(houghCircles, currentBitmap);
        loadImageToImageView();
    }

代码基本上与霍夫线相同,只有一些改动。Imgproc.HoughCircles()的输出是一个包含圆心坐标和半径的元组(x, y, radius)。要在图像上绘制圆,我们使用Core.circle()

小贴士

一个不错的编码练习是实现霍夫线/圆,而不使用预定义的 OpenCV 函数。

轮廓

我们经常需要将图像分解成更小的部分,以便更专注于感兴趣的对象。比如说,你有一个包含不同运动球类的图像,如高尔夫球、板球、网球和足球。然而,你只对分析足球感兴趣。一种方法是在上一节中我们提到的霍夫圆中进行操作。另一种方法是使用轮廓检测将图像分割成更小的部分,每个部分代表一个特定的球。

下一步是选择面积最大的部分,即你的足球(可以安全地假设足球是最大的!)。

轮廓不过是图像中的连接曲线或连接组件的边界。轮廓通常通过图像中的边缘来计算,但边缘和轮廓之间有一个细微的区别:轮廓是封闭的,而边缘可以是任何东西。边缘的概念非常局部,与点及其邻近像素相关;然而,轮廓关注的是整个对象(它们返回对象的边界)。

让我们看看使用 OpenCV 在 Android 上实现轮廓检测的实现。让我们看看以下代码:

void Contours()
    {
        Mat grayMat = new Mat();
        Mat cannyEdges = new Mat();
        Mat hierarchy = new Mat();

        List<MatOfPoint> contourList = new ArrayList<MatOfPoint>(); //A list to store all the contours

        //Converting the image to grayscale
        Imgproc.cvtColor(originalMat,grayMat,Imgproc.COLOR_BGR2GRAY);

        Imgproc.Canny(grayMat, cannyEdges,10, 100);

        //finding contours
        Imgproc.findContours(cannyEdges,contourList,hierarchy,Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);

        //Drawing contours on a new image
        Mat contours = new Mat();
        contours.create(cannyEdges.rows(),cannyEdges.cols(),CvType.CV_8UC3);
        Random r = new Random();
        for(int i = 0; i < contourList.size(); i++)
        {
            Imgproc.drawContours(contours,contourList,i,new Scalar(r.nextInt(255),r.nextInt(255),r.nextInt(255)), -1);
        }
        //Converting Mat back to Bitmap
        Utils.matToBitmap(contours, currentBitmap);
        loadImageToImageView();
    }

OpenCV 确实让我们的工作变得简单!在这段代码中,我们首先将图像转换为灰度图像(使用图像的灰度版本不是必需的,你也可以直接处理彩色图像),然后使用 Canny 边缘检测找到其中的边缘。在我们有了边缘之后,我们将此图像传递给预定义的函数Imgproc.findContours()。该函数的输出是List<MatOfPoint>,它存储了在该图像中计算出的所有轮廓。传递给Imgproc.findContours()函数的参数很有趣。第一个和第二个参数是输入图像和轮廓列表。第三个和第四个参数很有趣;它们给出了图像中轮廓的层次结构。第三个参数存储层次结构,而第四个参数指定用户想要的层次结构性质。层次结构本质上告诉我们图像中轮廓的整体排列。

小贴士

请参阅docs.opencv.org/master/d9/d8b/tutorial_py_contours_hierarchy.html以获取轮廓层次结构的详细解释。

代码中的 for 循环用于在新的图像上绘制轮廓。Imgproc.drawContours() 函数在图像上绘制轮廓。在这个函数中,第一个参数是一个 Mat,表示你想要绘制轮廓的位置。第二个参数是 Imgproc.findContours() 返回的轮廓列表。第三个参数是我们想要绘制的轮廓的索引,第四个参数是用于绘制轮廓的颜色。在绘制轮廓时,你有两种选择:要么绘制轮廓的边界,要么填充整个轮廓。函数中的第五个参数帮助你指定你的选择。负值表示你需要填充整个轮廓,而任何正值指定边界的厚度。

最后,将 Mat 转换为 Bitmap 并在屏幕上显示。

通过轮廓检测,我们成功完成了我们的特征应用程序。

项目 – 在图像中检测数独谜题

让我们尝试应用本章所学,创建一个检测图像中数独网格的简单应用程序。你每天都在报纸或杂志上看到数独谜题;有时它们提供解决方案,有时则不提供。为什么不为你自己的手机开发一个应用程序,可以点击数独的照片,分析数字,运行一些智能算法来解决谜题,并在几秒钟内,你就可以在手机屏幕上看到解决方案。

在阅读本章之后,我们可以轻松地处理应用程序的第一部分;即从图像中定位(检测)数独网格。随着你阅读这本书,你会遇到一些算法和技术,这些将帮助你构建这个应用程序的其他部分。

让我们把我们的问题陈述分解成子问题,并逐一解决,以拥有一个完全功能的数独定位应用程序:

  • 使用相机捕获图像或从你的相册中加载它。

  • 使用您喜欢的边缘检测算法对图像进行预处理。

  • 在图像中找到一个矩形网格。可能的选项是使用霍夫线检测线条,然后寻找形成矩形的四条线(有点繁琐),或者找到图像中的轮廓,并假设图像中最大的轮廓是你正在寻找的数独网格(这里做出的假设是安全的,只要你点击或加载的图片比其他任何东西都更专注于网格)。

  • 在缩小到网格后,创建一个新的图像,并将仅轮廓区域复制到新图像中。

  • 你已经成功检测到数独网格了!

这里有一个包含数独网格的示例图像:

项目 – 在图像中检测数独谜题

这里是缩小后的网格:

项目 – 在图像中检测数独谜题

小贴士

本章涵盖了创建此应用程序所需了解的所有内容。一旦你自己尝试过,你就可以从 Packt Publishing 网站下载代码。

摘要

在本章中,我们学习了图像的一些基本特征,例如边缘、角点、线条和圆。我们探讨了不同的算法,如 Canny 边缘检测和 Harris 角点检测,这些算法可以在 Android 设备上实现。这些是我们在接下来的章节中将要构建的许多应用中非常有用的基本算法集。

第三章. 检测对象

计算机视觉的一个常见应用是在图像或视频中检测对象。例如,我们可以使用这种方法在许多书籍中检测特定的书籍。检测对象的一种方法是特征匹配。在本章中,我们将学习以下主题:

  • 什么是特征?

  • 图像中的特征检测、描述和匹配

  • SIFT 检测器和描述符

  • SURF 检测器和描述符

  • ORB 检测器和描述符

  • BRISK 检测器和描述符

  • FREAK 描述符

什么是特征?

特征是独特且易于追踪和比较的特定模式。好的特征是可以明显定位的。以下图像显示了不同类型的特征:

什么是特征?

解释特征类型

在前面的图像中,补丁 A 是一个平坦区域,难以精确定位。如果我们把矩形移动到框内的任何地方,补丁内容保持不变。补丁 B 沿着边缘,是一个稍微好一些的特征,因为如果你垂直于边缘移动它,它会改变。然而,如果你平行于边缘移动它,它就与初始补丁相同。因此,我们可以在至少一个维度上定位这类特征。补丁 C 是一个角,是一个好的特征,因为无论你将矩形向哪个方向移动,补丁的内容都会改变,并且可以很容易地定位。因此,好的特征是那些可以很容易定位的,因此也容易追踪。

在前面的章节中,我们看到了一些边缘和角点检测算法。在本章中,我们将探讨一些更多可以通过它们找到特征的算法。这被称为特征检测。仅仅检测特征是不够的。我们需要能够区分不同的特征。因此,我们使用特征描述来描述检测到的特征。这些描述使我们能够在其他图像中找到相似的特征,从而使我们能够识别物体。特征也可以用来对齐图像并将它们拼接在一起。我们将在本书的后续章节中探讨这些应用。

现在我们将探讨一些常见的检测特征的算法,例如 SIFT、SURF、BRIEF、FAST 和 BRISK。

注意

注意,SIFT 和 SURF 是专利算法,因此它们的免费使用仅限于学术和研究目的。对于这些算法的任何商业用途,您需要遵守专利规则和法规,或者与相关人员联系。

尺度不变特征变换

尺度不变特征变换SIFT)是最广泛认可的特征检测算法之一。它由 David Lowe 于 2004 年提出。

注意

论文链接:www.cs.ubc.ca/~lowe/papers/ijcv04.pdf

SIFT 的一些特性如下:

  • 它对物体缩放和旋转变化具有不变性

  • 它对 3D 视点和光照变化也有部分不变性

  • 可以从一个单独的图像中提取大量关键点(特征)

理解 SIFT 的工作原理

SIFT 遵循匹配鲁棒局部特征的战略。它分为四个部分:

  • 尺度空间极值检测

  • 关键点定位

  • 方向分配

  • 关键点描述符

尺度空间极值检测

在这个步骤中,通过高斯模糊逐步模糊图像以去除图像中的某些细节。在合理的假设下,已经从数学上证明(进行高斯模糊是唯一有效执行此操作的方法)。

尺度空间极值检测

一个八度的图像

逐步模糊的图像构成一个八度。通过将前一个八度的原始图像大小减半然后逐步模糊它来形成一个新的八度。Lowe 建议您使用每个八度五幅图像的最佳结果。

因此,我们看到第一个八度的图像是通过逐步模糊原始图像形成的。第二个八度的第一幅图像是通过调整第一个八度中原始图像的大小获得的。第二个八度中的其他图像是通过逐步模糊第二个八度的第一幅图像形成的,依此类推。

尺度空间极值检测

所有八度的图像

为了精确检测图像中的边缘,我们使用拉普拉斯算子。在这个方法中,首先稍微模糊图像,然后计算其二阶导数。这定位了边缘和角落,这对于找到关键点是很有帮助的。这个操作被称为高斯拉普拉斯。

二阶导数对噪声极其敏感。模糊有助于平滑噪声并稳定二阶导数。问题是计算所有这些二阶导数在计算上非常昂贵。因此,我们稍微作弊一下:

尺度空间极值检测尺度空间极值检测

在这里,k是一个常数乘法因子,它表示尺度空间中每个图像的模糊量。尺度空间表示为了计算关键点而放大或缩小的一组图像。例如,如图所示,有两组图像:一组是经过不同模糊半径模糊的五幅原始图像,另一组是缩小后的图像。不同的参数值可以在下表中看到:

尺度
八度 0.707107 1.000000 1.414214 2.000000 2.828427
1.414214 2.000000 2.828427 4.000000 5.656854
2.828427 4.000000 5.656854 8.000000 11.313708
5.656854 8.000000 11.313708 16.000000 22.627417

为了生成高斯拉普拉斯图像,我们在八度音阶中计算连续两幅图像之间的差异。这被称为高斯差分DoG)。这些 DoG 图像大约等于通过计算高斯拉普拉斯得到的图像。使用 DoG 还有额外的优点。得到的图像也是尺度不变的。

尺度空间极值检测

高斯差分

使用高斯拉普拉斯不仅计算量大,而且依赖于应用模糊的程度。由于归一化的结果,这在 DoG 图像中得到了处理。

关键点定位

现在这些图像已经足够预处理,使我们能够找到局部极值。为了定位关键点,我们需要遍历每个像素,并将其与其所有邻居进行比较。我们不仅比较该图像中的八个邻居,还比较该像素在该图像中的值以及在该八度音阶上下方的图像中的值,这些图像各有九个像素:

关键点定位

关键点定位

因此,我们可以看到我们比较了一个像素的值与其26 个邻居。如果一个像素是其 26 个邻居中的最小值或最大值,则它是一个关键点。通常,非极大值或非极小值不需要进行所有 26 次比较,因为我们可能在几次比较中就找到了结果。

我们在八度音阶中最顶层和最底层图像中不计算关键点,因为我们没有足够的邻居来识别极值。

大多数情况下,极值永远不会位于确切的像素上。它们可能存在于像素之间,但我们无法在图像中访问这些信息。定位到的关键点是它们的平均位置。我们使用尺度空间函数关键点定位的泰勒级数展开(到二次项)并平移到当前点作为原点,得到:

关键点定位

在这里,D及其导数是在我们当前测试极值的点上计算的。使用这个公式,通过微分并将结果等于零,我们可以轻松找到亚像素关键点位置:

关键点定位

亚像素极值定位

SIFT 建议生成两个这样的极值图像。因此,为了生成两个极值,我们需要四个 DoG 图像。为了生成这四个 DoG 图像,我们需要五个高斯模糊图像。因此,我们需要一个八度音阶中的五个图像。还发现,当关键点定位关键点定位时,可以获得最佳结果。

到目前为止,定位的关键点数量相当高。其中一些关键点要么位于边缘上,要么对比度不足,对我们来说没有用处。因此,我们需要去除这些关键点。这种方法与哈里斯角检测器中用于去除边缘的方法类似。

为了去除低对比度的关键点,我们只需将当前像素的强度值与预先选择的阈值值进行比较。如果它小于阈值值,则被拒绝。因为我们已经使用了亚像素关键点,所以我们再次需要使用泰勒级数展开来获取亚像素位置的强度值。

为了稳定性,仅仅拒绝低对比度的关键点是不够的。DoG 函数会在边缘处产生强烈的响应,即使边缘的位置确定得不好,因此对噪声的稳定性较差。

为了消除边缘上的关键点,我们在关键点处计算两个相互垂直的梯度。关键点周围区域可以是以下三种类型之一:

  • 一个平坦区域(两个梯度都会很小)

  • 一个边缘(在这里,与边缘平行的梯度会很小,但垂直于它的梯度会很大)

  • 一个角(两个梯度都会很大)

因为我们只想将角作为我们的关键点,所以我们只接受那些两个梯度值都很高的关键点。

为了计算这个,我们使用海森矩阵。这与哈里斯角检测器类似。在哈里斯角检测器中,我们计算两个不同的特征值,而在 SIFT 中,我们通过直接计算它们的比率来节省计算。海森矩阵如下所示:

关键点定位

方向分配

到目前为止,我们已经有了稳定的关键点,并且我们知道这些关键点是在哪些尺度上被检测到的。因此,我们具有尺度不变性。现在我们尝试为每个关键点分配一个方向。这个方向帮助我们实现旋转不变性。

我们尝试计算每个关键点的高斯模糊图像的幅度和方向。这些幅度和方向是使用以下公式计算的:

方向分配

我们计算关键点周围所有像素的幅度和方向。我们创建一个覆盖 360 度方向范围的 36 个桶直方图。添加到直方图中的每个样本都由其梯度幅度和一个以σ为宽度的高斯加权圆形窗口加权,σ是关键点尺度的 1.5 倍。假设你得到一个如图所示的直方图:

方向分配

在对特定关键点的所有相邻像素完成此操作后,我们将在直方图中得到一个峰值。在先前的图中,我们可以看到直方图在20-29的区域达到峰值。因此,我们将这个方向分配给关键点。此外,任何超过80%值的峰值也被转换为关键点。这些新关键点与原始关键点具有相同的位置和比例,但其方向被分配给对应于新峰值的值。

关键点描述符

到目前为止,我们已经实现了缩放和旋转不变性。现在我们需要为各种关键点创建一个描述符,以便能够将其与其他关键点区分开来。

为了生成一个描述符,我们在关键点周围取一个 16x16 的窗口,并将其分成 16 个 4x4 大小的窗口。这可以在以下图像中看到:

关键点描述符

我们这样做是为了考虑到两个图像中的对象很少是完全相同的。因此,我们在计算中尝试牺牲一些精度。在每个 4x4 窗口内,计算梯度幅度和方向。这些方向被放入一个 8 个分箱的直方图中。每个分箱代表 45 度的方向角。

现在我们有了一个较大的区域需要考虑,我们需要考虑从关键点到向量的距离。为了实现这一点,我们使用高斯加权函数:

关键点描述符

我们将 16 个向量放入每个 8 个分箱的直方图中,并对每个 4x4 窗口进行此操作,我们得到 4x4x8 = 128 个数字。一旦我们有了所有这些 128 个数字,我们就对这些数字进行归一化(通过将每个数字除以它们的平方和)。这组 128 个归一化数字形成了特征向量。

通过引入特征向量,会产生一些不希望的依赖关系,如下所示:

  • 旋转依赖性:特征向量使用梯度方向。因此,如果我们旋转图像,我们的特征向量会改变,梯度方向也会受到影响。为了实现旋转不变性,我们从每个方向中减去关键点的旋转。因此,每个梯度方向现在相对于关键点的方向。

  • 光照依赖性:通过在特征向量中设置大值阈值,可以实现光照独立性。因此,任何大于 0.2 的值都被改变为 0.2,并且结果特征向量再次进行归一化。我们现在已经获得了一个光照独立特征向量。

既然我们已经从理论上了解了 SIFT 是如何工作的,让我们看看它在 OpenCV 中是如何工作的以及它匹配对象的能力。

注意

您可以在www.aishack.in/找到 Utkarsh Sinha 对 SIFT 的图像和简化解释。

OpenCV 中的 SIFT

我们将设置一个新的应用程序,名为Chapter3,它类似于前面章节中创建的应用程序。我们将对MainActivity.java进行修改。还需要对HomeActivity.java进行一些修改,但它们将是自解释的。

首先,我们打开res | main_menu.xml文件。在这个文件中,我们将创建两个条目。一个用于选择要匹配的每一张图像。按照惯例,我们将第一张图像作为要检测的对象,第二张图像作为我们想要在其中检测它的场景:

<menu 

    tools:context="com.packtpub.masteringopencvandroid.chapter3.MainActivity">
    <item android:id="@+id/action_load_first_image"
        android:title="@string/action_load_first_image"
        android:orderInCategory="1"
        android:showAsAction="never" />
    <item android:id="@+id/action_load_second_image"
        android:title="@string/action_load_second_image"
        android:orderInCategory="2"
        android:showAsAction="never" />
</menu>

现在,我们需要将这些项目编程到我们的 Java 代码中。这类似于第一章,对图像应用效果,在那里我们使用意图打开了照片选择器。我们将有两个标志变量,将存储每个已选的图像。如果它被选中,我们将执行我们的计算。

我们将在AsyncTask中执行我们的实际计算,因为这些任务计算量很大;为了避免长时间阻塞 UI 线程,我们将计算卸载到异步后台工作线程——AsyncTasks,它使我们能够执行多线程:

new AsyncTask<Void, Void, Bitmap>() {
    private long startTime, endTime;
    @Override
    protected void onPreExecute() {
        super.onPreExecute();
        startTime = System.currentTimeMillis();
    }

    @Override
    protected Bitmap doInBackground(Void... params) {
        return executeTask();
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
        super.onPostExecute(bitmap);
        endTime = System.currentTimeMillis();
        ivImage1.setImageBitmap(bitmap);
        tvKeyPointsObject1.setText("Object 1 : "+keypointsObject1);
        tvKeyPointsObject2.setText("Object 2 : "+keypointsObject2);
        tvKeyPointsMatches.setText("Keypoint Matches : "+keypointMatches);
        tvTime.setText("Time taken : "+(endTime-startTime)+" ms");
    }
}.execute();

在这里,已经调用了executeTask函数,它将执行所有我们的计算。首先,我们需要检测关键点,然后我们需要使用描述符来描述它们。

我们首先声明所有变量:

FeatureDetector detector;
MatOfKeyPoint keypoints1, keypoints2;
DescriptorExtractor descriptorExtractor;
Mat descriptors1, descriptors2;

然后,根据算法,我们初始化这些变量。对于 SIFT,我们使用以下代码片段:

switch (ACTION_MODE){
        case HomeActivity.MODE_SIFT:
                detector = FeatureDetector.create(FeatureDetector.SIFT);
                descriptorExtractor = DescriptorExtractor.create(DescriptorExtractor.SIFT);
                //Add SIFT specific code
                break;
        //Add cases for other algorithms
}

现在我们检测关键点:

detector.detect(src2, keypoints2);
detector.detect(src1, keypoints1);
keypointsObject1 = keypoints1.toArray().length; //These have been added to display the number of keypoints later.
keypointsObject2 = keypoints2.toArray().length;

现在我们有了关键点,我们将计算它们的描述符:

descriptorExtractor.compute(src1, keypoints1, descriptors1);
descriptorExtractor.compute(src2, keypoints2, descriptors2);

匹配特征和检测对象

一旦我们在两个或更多对象中检测到特征,并且有它们的描述符,我们就可以匹配特征以检查图像是否有任何相似性。例如,假设我们想在许多书籍堆中搜索一本书。OpenCV 为我们提供了两种特征匹配算法:

  • 暴力匹配器

  • 基于 FLANN 的匹配器

我们将在以下章节中看到这两个是如何工作的。

对于匹配,我们首先需要声明一些变量:

DescriptorMatcher descriptorMatcher;
MatOfDMatch matches = new MatOfDMatch();

暴力匹配器

它将第一组中的一个特征的描述符与第二组中的所有其他特征进行匹配,使用距离计算,并返回最近的那个。

BF 匹配器有两个可选参数。第一个是距离测量类型,normType。对于 SIFT 和 SURF 等描述符,我们应该使用NORM_L2。对于基于二进制字符串的描述符,如 ORB 和 BRISK,我们使用NORM_HAMMING作为距离测量。第二个是crosscheck。如果设置为 true,匹配器只返回具有值(i,j)的匹配,其中第一张图像中的第 i 个描述符与第二组中的第 j 个描述符是最佳匹配,反之亦然。

在我们的 SIFT 案例中,我们添加以下代码:

descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_SL2);

基于 FLANN 的匹配器

FLANN代表快速近似最近邻库。它包含了一组针对在大数据集和高维特征中进行快速最近邻搜索优化的算法。对于大数据集,它比 BF 匹配器运行得更快。

对于基于 FLANN 的匹配器,我们需要传递两个字典,这些字典指定了要使用的算法、相关参数等。第一个是IndexParams。对于不同的算法,要传递的信息在 FLANN 文档中有解释。

第二个字典是SearchParams。它指定了索引中树应该递归遍历的次数。更高的值会提供更好的精度,但也会花费更多的时间。

要使用基于 FLANN 的匹配器,我们需要按照以下方式初始化它:

descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.FLANNBASED);

匹配点

一旦我们有了DescriptorMatcher对象,我们就使用match()knnMatch()函数。第一个函数返回所有匹配项,而第二个函数返回用户定义的k个匹配项。

在我们计算了描述符之后,我们可以使用以下方法来匹配关键点:

descriptorMatcher.match(descriptors1, descriptors2, matches);1

现在我们展示使用drawMatches()获得的匹配项,这有助于我们绘制匹配项。它将两个图像水平堆叠,并从第一幅图像到第二幅图像绘制线条,显示最佳匹配。还有一个drawMatchesKnn()函数,它绘制所有k个最佳匹配。如果k = 2,它将为每个关键点绘制两条匹配线。因此,如果我们想选择性地绘制它,我们必须传递一个掩码。

为了绘制匹配项,我们将添加一个函数,该函数将查询图像和训练图像合并到一张图中,并在同一张图中显示匹配项:

static Mat drawMatches(Mat img1, MatOfKeyPoint key1, Mat img2, MatOfKeyPoint key2, MatOfDMatch matches, boolean imageOnly){
        Mat out = new Mat();
        Mat im1 = new Mat();
        Mat im2 = new Mat();
        Imgproc.cvtColor(img1, im1, Imgproc.COLOR_BGR2RGB);
        Imgproc.cvtColor(img2, im2, Imgproc.COLOR_BGR2RGB);
        if (imageOnly){
            MatOfDMatch emptyMatch = new MatOfDMatch();
            MatOfKeyPoint emptyKey1 = new MatOfKeyPoint();
            MatOfKeyPoint emptyKey2 = new MatOfKeyPoint();
            Features2d.drawMatches(im1, emptyKey1, im2, emptyKey2, emptyMatch, out);
        } else {
            Features2d.drawMatches(im1, key1, im2, key2, matches, out);
        }
        Bitmap bmp = Bitmap.createBitmap(out.cols(), out.rows(), Bitmap.Config.ARGB_8888);
        Imgproc.cvtColor(out, out, Imgproc.COLOR_BGR2RGB);
        Core.putText(out, "FRAME", new Point(img1.width() / 2,30), Core.FONT_HERSHEY_PLAIN, 2, new Scalar(0,255,255),3);
        Core.putText(out, "MATCHED", new Point(img1.width() + img2.width() / 2,30), Core.FONT_HERSHEY_PLAIN, 2, new Scalar(255,0,0),3);
        return out;
    }

由于 SIFT 和 SURF 是受专利保护的算法,它们不是由 OpenCV 自动构建的。我们需要手动构建nonfree模块,以便能够在 OpenCV 中使用它们。为此,您需要下载 Android NDK,它允许我们与 Java 代码一起使用原生 C++代码。它可在developer.android.com/tools/sdk/ndk/index.html找到。然后,将其提取到合适的位置。

首先,您需要从 OpenCV 的源代码库下载一些文件,该库位于github.com/Itseez/opencv/tree/master/modules。这些是nonfree_init.cppprecomp.cppsift.cppsurf.cpp。这些文件也将与本章的代码一起提供,因此您也可以直接从那里下载它们。现在,在您的src目录中创建一个名为jni的文件夹,并将这些文件复制到那里。我们需要对这些文件进行一些修改。

打开precomp.hpp并删除#include "cvconfig.h"#include "opencv2/ocl/private/util.hpp"这两行代码。

打开nonfree_init.cpp并删除从#ifdef HAVE_OPENCV_OCL开始到#endif结束的代码行。

现在我们将创建一个名为Android.mk的文件,并将以下代码行复制到其中。您需要相应地替换<OpenCV4Android_SDK_location>

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

OPENCV_CAMERA_MODULES:=on
OPENCV_INSTALL_MODULES:=on

include <OpenCV4Android_SDK_location>/sdk/native/jni/OpenCV.mk

LOCAL_MODULE    := nonfree
LOCAL_SRC_FILES := nonfree_init.cpp \
sift.cpp \
surf.cpp
LOCAL_LDLIBS +=  -llog -ldl
include $(BUILD_SHARED_LIBRARY)

接下来,创建一个名为Application.mk的文件,并将以下代码行复制到其中。这些定义了我们的库将要构建的架构:

APP_STL := gnustl_static
APP_CPPFLAGS := -frtti -fexceptions
APP_ABI := armeabi-v7a
APP_PLATFORM := android-8

在你的 app 文件夹中打开 build.gradle 文件。在 android 部分下,添加以下内容:

sourceSets.main {
    jniLibs.srcDir 'src/main/libs'
    jni.srcDirs = [] //disable automatic ndk-build call
}

如果你在 Windows 上,请打开一个终端或命令窗口。然后,使用 cd 命令将目录更改为你的项目。在命令窗口中输入以下内容:

cd <project_directory>/app/src/main/jni

在终端窗口中,输入以下内容,将 <ndk_dir> 替换为适当的目录位置:

<ndk_dir>/ndk-build

之后,我们的库应该已经成功构建,并且应该可以在 src | obj 文件夹下,在正确的架构中找到。

现在,我们需要从我们的 Java 代码中加载这个库。打开 MainActivity.java,在我们的 OpenCV Manager 的回调变量(mOpenCVCallback 文件的 onManagerConnected 函数)中,在 LoaderCallbackInterface.SUCCESS 的情况下添加以下代码行:

System.loadLibrary("nonfree");

库的名称 nonfreeAndroid.mk 文件中定义的模块名称相同。

匹配点

SIFT 特征匹配

检测对象

在前面的章节中,我们在多张图像中检测特征并将它们与另一张图像中相应的特征匹配。我们获得的信息足以在场景中定位对象。

我们使用 OpenCV 的 calib3d 模块中的函数 findHomography()。使用此函数,我们可以找到对象的透视变换,即旋转和倾斜的结果。然后我们使用 perspectiveTransform() 在场景中定位对象。我们需要至少四个匹配点才能成功计算变换。

我们已经看到在匹配过程中可能会出现一些可能的错误,这可能会影响结果。为了解决这个问题,算法使用 RANSACLEAST_MEDIAN(可以通过标志指定)。提供正确估计的匹配称为内点,其余的称为外点。findHomography() 返回一个掩码,指定内点和外点。

现在,我们将查看实现该算法的算法。

首先,我们在两幅图像中检测和匹配关键点。这已经在前面的章节中完成。然后,我们设定一个条件,必须有一定数量的匹配才能检测到对象。

如果找到足够的匹配,我们将从两幅图像中提取匹配关键点的位置。它们被传递以找到透视变换。一旦我们得到这个 3x3 的变换矩阵,我们就用它将 queryImage 的角点变换到 trainImage 中相应的点。然后,我们绘制它。

最后,我们绘制我们的内点(如果我们成功找到对象)或匹配的关键点(如果失败)。

加速鲁棒特征

加速鲁棒特征 (SURF) 由赫伯特·贝(Herbert Bay)、蒂内·图伊特劳斯(Tinne Tuytelaars)和卢克·范·古尔(Luc Van Gool)于 2006 年提出。SIFT 的一些缺点是它速度慢且计算成本高。为了解决这个问题,人们想到了 SURF。除了提高速度之外,SURF 背后的其他动机如下:

  • 快速兴趣点检测

  • 独特兴趣点描述

  • 加速描述符匹配

  • 对以下常见图像变换保持不变:

    • 图像旋转

    • 尺度变化

    • 照明变化

    • 视点的小变化

SURF 检测器

正如 SIFT 近似高斯图像的拉普拉斯变换为高斯差分,SURF 使用积分图像来近似高斯图像的拉普拉斯变换。积分图像(求和面积表)是图像的中间表示,包含图像的灰度像素值之和。它被称为快速 Hessian检测器。另一方面,描述符描述了兴趣点邻域内 Haar 小波响应的分布。

注意

您可以参考以下论文:www.vision.ee.ethz.ch/~surf/eccv06.pdf

为了选择关键点的位置和尺度,SURF 使用 Hessian 矩阵的行列式。SURF 证明高斯被高估了,因为当降低分辨率时没有新的结构出现这一性质仅在 1D 中得到了证明,但不适用于 2D 情况。鉴于 SIFT 在 LoG 近似方面的成功,SURF 进一步使用盒滤波器近似 LoG。盒滤波器近似高斯,并且可以非常快速地计算。以下图像显示了高斯作为盒滤波器的近似:

SURF 检测器

由于使用了盒滤波器和积分图像,我们不再需要执行重复的高斯平滑。我们直接将不同大小的盒滤波器应用到积分图像上。我们不是迭代地缩小图像,而是放大滤波器的大小。因此,尺度分析仅使用单个图像完成。前一个 9x9 滤波器的输出被认为是初始尺度层。其他层通过使用逐渐更大的滤波器进行过滤获得。第一八度图像使用 9x9、15x15、21x21 和 27x27 大小的滤波器获得。在更大的尺度上,滤波器之间的步长也应相应地放大。因此,对于每个新的八度,滤波器大小的步长加倍(即从 6 到 12 到 24)。在下八度中,滤波器的大小是 39x39、51x51 等等。

为了在图像和不同尺度上定位兴趣点,对 3x3x3 邻域应用了非极大值抑制。然后使用布朗和其他人提出的方法,在尺度空间和图像空间中对 Hessian 矩阵行列式的极大值进行插值。在我们的情况下,尺度空间插值尤为重要,因为每个八度第一层的尺度差异相对较大。

SURF 描述符

现在我们已经定位了关键点,我们需要为每个关键点创建一个描述符,以便能够从其他关键点中唯一地识别它。SURF 在类似 SIFT 的原理上工作,但复杂性较低。Bay 和其他人也提出了一种不考虑旋转不变性的 SURF 变体,称为U-SURF(垂直 SURF)。在许多应用中,相机的方向保持相对稳定。因此,我们可以通过忽略旋转不变性来节省大量的计算。

首先,我们需要基于从以关键点为中心的圆形区域获得的信息来固定一个可重复的定位。然后,我们构建一个基于所选定位旋转并对齐的正方形区域,然后我们可以从中提取 SURF 描述符。

定位分配

为了增加旋转不变性,关键点的定位必须稳健且可重复。为此,SURF 提出在 xy 方向上计算 Haar 小波响应。响应是在关键点周围半径为 6 s 的圆形邻域内计算的,其中 s 是图像的尺度(即σ的值)。为了计算 Haar 小波响应,SURF 提出使用 4 s 大小的波 let。在获得小波响应并用以关键点为中心的高斯核 定位分配 加权后,响应表示为向量。向量表示为沿横轴的横向响应强度,以及沿纵轴的纵向响应强度。然后,将覆盖 60 度角度的滑动定位窗口内的所有响应相加。计算出的最长向量被设置为描述符的方向:

定位分配

60 度角度内的 Haar 小波响应

滑动窗口的大小被作为一个参数,需要通过实验计算。小的窗口大小会导致单一的占主导地位的波 let 响应,而大的窗口大小会导致向量长度上的极大值不足以描述。两者都会导致兴趣区域的定位不稳定。对于 U-SURF,这一步被跳过,因为它不需要旋转不变性。

基于 Haar 小波响应的描述符

对于描述符的提取,第一步是构建一个以兴趣点为中心、沿前一小节中选定的方向对齐的正方形区域。对于 U-SURF 来说,这一步不是必需的。窗口的大小为 20 s。寻找描述符的步骤如下:

  1. 将兴趣区域分割成 4x4 的正方形子区域,每个子区域内部有 5x5 均匀分布的采样点。

  2. 计算 Haar 小波响应 d^xd^y [d^x = x 方向上的 Haar 小波响应;d^y = y 方向上的 Haar 小波响应。使用的滤波器大小为 2 s]。

  3. 使用以兴趣点为中心的高斯核对响应进行加权。

  4. 分别对每个子区域的响应进行求和,形成长度为 32 的特征向量,用于dx*和*dy

  5. 为了引入关于强度变化极性的信息,提取响应绝对值的和,这是一个长度为 64 的特征向量。

  6. 将向量归一化到单位长度。

小波响应对光照偏差(偏移)是不变的。通过将描述符转换为单位向量(归一化)来实现对对比度(尺度因子)的不变性。

实验中,Bay 等人测试了一种 SURF 的变体,它添加了一些额外的特征(SURF-128)。对于d^y < 0d^y ≥ 0,分别单独计算dx*和*|dx|的和。同样,根据dx*的符号,将*dy|d^y|的和分开,从而将特征数量翻倍。这个版本的 SURF-128 优于 SURF。

以下表格显示了在各种算法中寻找特征时的比较:

U-SURF SURF SIFT
时间 (ms) 225 354 1036

虽然 SIFT 和 SURF 在寻找良好特征方面表现良好,但它们用于商业用途是专利的。因此,如果您用于商业目的,您必须支付一些费用。

我们从 SURF 获得的一些结果如下:

  • SURF 的速度比 SIFT 快三倍,并且召回精度不低于 SIFT

  • SURF 擅长处理模糊或旋转的图像

  • SURF 在处理视角变化的图像方面表现不佳

OpenCV 中的 SURF

SURF 的代码只需要稍作修改。我们只需要在我们的 switch case 结构中添加一个 case:

case HomeActivity.MODE_SURF:
    detector = FeatureDetector.create(FeatureDetector.SURF);
    descriptorExtractor = DescriptorExtractor.create(DescriptorExtractor.SURF);
    descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_SL2);
    break;

OpenCV 中的 SURF

SURF 特征匹配

定向 FAST 和旋转 BRIEF

定向 FAST 和旋转 BRIEFORB)由 Ethan Rublee、Vincent Rabaud、Kurt Konolige 和 Gary R. Bradski 于 2011 年在 OpenCV 实验室开发,作为一种高效且可行的 SIFT 和 SURF 的替代方案。ORB 主要因为 SIFT 和 SURF 是专利算法而构思。然而,ORB 是免费使用的。

ORB 在这些任务上的表现与 SIFT 相当(并且优于 SURF),同时几乎快两个数量级。ORB 建立在著名的 FAST 关键点检测器和 BRIEF 描述符之上。这两种技术都因其良好的性能和低成本而具有吸引力。ORB 的主要贡献如下:

  • 将快速且精确的定向组件添加到 FAST 中

  • 定向 BRIEF 特征的快速计算

  • 对定向 BRIEF 特征的方差和相关性分析

  • 一种在旋转不变性下去相关 BRIEF 特征的学习方法,从而在最近邻应用中实现更好的性能

oFAST – FAST 关键点方向

FAST 是一种广为人知的特征检测算法,因其快速计算特性而受到认可。它不提出一个描述符来唯一标识特征。此外,它没有方向组件,因此在平面旋转和尺度变化方面表现不佳。我们将探讨 ORB 是如何为 FAST 特征添加方向组件的。

FAST 检测器

首先,我们检测 FAST 关键点。FAST 从用户那里获取一个参数,即中心像素和围绕它的圆形环中的像素之间的阈值值。我们使用 9 像素的环半径,因为它提供了良好的性能。FAST 还会产生沿边缘的关键点。为了克服这一点,我们使用 Harris 角测量法对关键点进行排序。如果我们想得到 N 个关键点,我们首先将阈值设置得足够低,以生成超过 N 个关键点,然后根据 Harris 角测量法选择最上面的 N 个。

FAST 不产生多尺度特征。ORB 使用图像的尺度金字塔,并在金字塔的每一级产生 FAST 特征(通过 Harris 滤波器过滤)。

通过强度质心进行定位

为了给角赋予方向,我们使用强度质心。我们假设角偏离强度质心,并使用此向量将方向赋予一个关键点。

为了计算质心的坐标,我们使用矩。矩的计算如下:

通过强度质心进行定位

质心的坐标可以计算如下:

通过强度质心进行定位

我们从关键点的中心O到质心C构建一个向量通过强度质心进行定位。通过以下方式获得补丁的方向:

通过强度质心进行定位

这里,atan2是具有象限意识的arctan版本。为了提高该测量的旋转不变性,我们确保在半径r的圆形区域内计算xy的矩。我们经验性地选择r为补丁大小,使得xy[−r, r]运行。当|C|接近0时,该测量变得不稳定;通过 FAST 角,我们发现这种情况很少发生。这种方法也可以在噪声严重的图像中很好地工作。

rBRIEF – 旋转感知 BRIEF

BRIEF 是一种特征描述算法,也因其计算速度快而闻名。然而,BRIEF 对旋转并不具有不变性。ORB 试图添加这一功能,同时不牺牲 BRIEF 的速度特性。BRIEF 通过n个二进制测试获得的特征向量如下:

rBRIEF – 旋转感知 BRIEF

其中rBRIEF – 旋转感知 BRIEF定义为:

rBRIEF – 旋转感知 BRIEF

p(x)是像素x的强度值。

指向 BRIEF

BRIEF 的匹配性能在平面旋转超过几度时急剧下降。ORB 提出了一种根据关键点方向调整 BRIEF 的方法。对于任何位于(x^i, y^i)位置的 n 个二进制测试的特征集,我们定义一个2 x n矩阵:

Steered BRIEF

我们使用补丁方向θ和相应的旋转矩阵Rθ*,构建*Steered*版本*SθS

Steered BRIEF

现在,Steered BRIEF 算子变为:

Steered BRIEF

我们将角度离散化为2π/30(12 度)的增量,并构建一个预计算的 BRIEF 模式的查找表。只要关键点方向θ在视图中保持一致,就会使用正确的点集S^θ来计算其描述符。

方差和相关性

BRIEF 的一个特性是每个位特征都有很大的方差和接近 0.5 的平均值。0.5 的平均值给位特征提供了最大样本方差 0.25。Steered BRIEF 为二进制测试产生更均匀的外观。高方差导致特征对输入的反应差异更大。

拥有不相关特征是理想的,因为在这种情况下,每个测试都对结果有贡献。我们在所有可能的二进制测试中搜索,以找到那些具有高方差(以及平均值接近 0.5)且不相关的测试。

ORB 指定 rBRIEF 算法如下:

设置一个由 PASCAL 2006 集中的图像抽取的约 300 k 个关键点的训练集。然后,枚举从 31x31 像素补丁中抽取的所有可能的二进制测试。每个测试是补丁的 5x5 子窗口对。如果我们记补丁的宽度为w^p = 31,测试子窗口的宽度为w^t = 5,那么我们有N = (wp − w^t)²个可能的子窗口。我们希望从中选择两个,所以我们有方差和相关性2 个二进制测试。我们消除重叠的测试,最终得到N = 205590个可能的测试。算法如下:

  • 将每个测试与所有训练补丁进行匹配。

  • 按测试与 0.5 平均值的距离对测试进行排序,形成向量 T。

  • 执行贪婪搜索:

    • 将第一个测试放入结果向量 R 中,并从 T 中移除它。

    • 从 T 中取出下一个测试,并将其与 R 中的所有测试进行比较。如果其绝对相关性大于阈值,则丢弃它;否则将其添加到 R 中。

    • 重复前面的步骤,直到 R 中有 256 个测试。如果少于 256 个,则提高阈值并再次尝试。

rBRIEF 在方差和相关性方面相对于 Steered BRIEF 有显著改进。ORB 在户外数据集上优于 SIFT 和 SURF。在室内数据集上表现大致相同;请注意,像 SIFT 这样的块检测关键点在涂鸦类型图像上往往表现更好。

OpenCV 中的 ORB

ORB 的代码与 SIFT 和 SURF 类似。然而,ORB 作为一个基于二进制字符串的描述符,我们将在我们的 BF 匹配器中使用汉明码。

对 SURF 的代码只需稍作修改。我们只需在我们的 switch case 结构中添加一个情况:

case HomeActivity.MODE_ORB:
    detector = FeatureDetector.create(FeatureDetector.ORB);
    descriptorExtractor = DescriptorExtractor.create(DescriptorExtractor.ORB);
    descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_HAMMING);
    break;

ORB 在 OpenCV 中的使用

ORB 特征匹配

二值鲁棒可伸缩关键点

二值鲁棒可伸缩关键点BRISK)是由 Leutenegger、Chli 和 Siegwart 提出的,旨在成为最先进特征检测、描述和匹配算法的高效替代方案。BRISK 背后的动机是开发一种鲁棒算法,能够以计算效率的方式重现特征。在某些情况下,BRISK 在特征匹配的质量上与 SURF 相当,但所需的计算时间却少得多。

尺度空间关键点检测

BRISK 检测器基于 AGAST 检测器,它是 FAST 更快性能版本的一个扩展。为了实现尺度不变性,BRISK 使用 FAST 分数(s)作为比较参数,在尺度空间中寻找最大值。尽管在比其他高性能检测器(例如快速 Hessian)更粗的间隔处对尺度轴进行离散化,但 BRISK 检测器估计每个关键点在连续尺度空间中的真实尺度。BRISK 的尺度空间包括 n 个八度,c^i 和 n 个跨八度,以及d^i [i = {0, 1, 2, …, n-1}]。BRISK 建议使用n = 4

将原始图像作为c⁰,每个后续八度是从前一个八度的一半采样得到的。每个跨八度 d^i 是下采样得到的,使其位于ci*和*ci+1之间。第一个跨八度d⁰是通过将c⁰以 1.5 的倍数下采样得到的。后续的跨八度是通过前一个跨八度的一半采样得到的。

尺度空间关键点检测

展示八度和跨八度的图像

FAST 9-16 检测器要求在 16 像素的圆形半径内,至少有 9 个像素比中心像素亮或暗,以满足 FAST 标准。BRISK 提出了使用这种 FAST 9-16 检测器。

对于每个八度和跨八度分别计算 FAST 分数。FAST 检测器的分数 s 是计算每个像素的最大阈值,使得图像点被认为是角点。

在应用 FAST 9-16 检测器后,对尺度空间中的关键点进行非极大值抑制。关键点应该是其八个相邻 FAST 分数在同一八度或跨八度中的最大值。此点还必须比其上下层的点具有更高的 FAST 分数。然后我们在具有 2 像素边长的等大小正方形块中检查该层,其中怀疑存在最大值。在块的边界处进行插值,因为相邻层使用与当前层不同的离散化表示。

我们尝试计算在早期步骤中检测到的每个最大值的亚像素位置。将一个二维二次函数拟合到围绕像素的 3x3 像素块,并确定亚像素最大值。这同样适用于当前层上下方的层。然后使用一维抛物线在尺度空间中进行插值,并选择局部最大值作为特征的尺度。

关键点描述

BRISK 描述符通过连接简单亮度比较测试的结果来构成一个二进制字符串。在 BRISK 中,我们需要识别每个关键点的特征方向以实现旋转不变性。

采样模式及旋转估计

BRISK 描述符利用一种用于采样关键点邻域的模式。该模式定义了与关键点同心圆上等间距的 N 个位置,如图所示:

采样模式及旋转估计

BRISK 采样模式

为了避免在采样模式中点 p^i 的图像强度时出现混叠效应,我们应用高斯平滑,其标准差 采样模式及旋转估计 与相应圆上点之间的距离成正比。然后我们计算两个采样点之间的梯度。

使用的公式是:

采样模式及旋转估计

BRISK 定义了一个短距离配对子集 S 和另一个长距离配对子集 L,如下所示:

采样模式及旋转估计采样模式及旋转估计

其中 A 是所有采样点对的集合,如下所示:

采样模式及旋转估计

阈值距离设置为 采样模式及旋转估计采样模式及旋转估计 (t 是关键点的尺度)。BRISK 估计关键点 k 的整体特征模式方向为:

采样模式及旋转估计

构建描述符

为了开发一个旋转和尺度不变描述符,BRISK 在关键点 k 附近应用了一个旋转角度 构建描述符 的采样模式。计算点对之间的短距离强度比较,构建描述符(即在旋转模式中),以获得位向量描述符 d^k。每个位 b 对应于:

构建描述符构建描述符

BRISK 使用确定性采样模式,导致在关键点周围给定半径处的采样点密度均匀。由于这个原因,高斯平滑在比较两个相邻采样点时不会修改亮度比较的信息内容。BRISK 使用的采样点比简单的成对比较少(因为单个点参与更多的比较),从而减少了查找强度值的复杂性。由于亮度变化只需要局部一致,因此这里所做的比较在空间上受到限制。我们使用采样模式和之前显示的距离阈值获得长度为 512 位的字符串。

OpenCV 中的 BRISK

再次,我们将做出的唯一改变是向我们的 switch case 结构中添加另一个情况:

case HomeActivity.MODE_BRISK:
    detector = FeatureDetector.create(FeatureDetector.BRISK);
    descriptorExtractor = DescriptorExtractor.create(DescriptorExtractor.BRISK);
    descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_HAMMING);
    break;

OpenCV 中的 BRISK

BRISK 特征匹配

快速视网膜关键点

快速视网膜关键点FREAK)提出了一种鲁棒的描述符,用于唯一标识关键点,在此过程中,需要更少的计算时间和内存。FREAK 受到了人类视网膜的启发。

视网膜采样模式

FREAK 建议使用视网膜采样网格,它也是圆形的,但与 BRISK 不同的是,中心附近点的密度更高。随着我们远离中心点,点的密度呈指数下降。这与 BRISK 类似,只是指数下降。

每个关键点都需要进行平滑处理,以降低对噪声的敏感性。与 BRIEF 和 ORB 不同,它们对所有点使用相同的核,FREAK 为每个关键点使用不同的核。高斯核的半径与σ的值成比例。

视网膜采样模式

视网膜采样模式

FREAK 遵循 ORB 的方法,通过最大化对之间的方差并选择不相关的对来尝试了解对,以便为每个关键点提供最大信息。

粗到细的描述符

我们需要找到采样点的对,以便创建一个位向量。我们使用类似于 ORB 的方法,即不是匹配每一对,而是尝试了解哪些对会给出最佳结果。我们需要找到不相关的点。算法如下:

  • 我们创建了一个包含近 50,000 个提取的关键点的矩阵 D。每一行对应一个关键点,该关键点通过其大描述符表示,描述符由视网膜采样模式中的所有可能对组成。我们使用 43 个感受野,导致大约 1,000 对。

  • 我们计算每列的平均值。平均值为 0.5 会产生最高的方差。

  • 按照列的方差降序排列。

  • 选择最佳列,并迭代地添加剩余的列,使它们与所选列的低相关性。

在这种方法中,我们首先选择比较外区域采样点的对,而最后几对是在图案的内环中的比较点。这在某种程度上类似于我们的视网膜工作方式,即我们首先尝试定位一个对象,然后通过精确匹配靠近对象密集分布的点来验证它。

眨眼搜索

人类不会以固定方式观察场景。他们的眼睛以不连续的单独运动(称为眨眼)在场景中移动。黄斑区域捕捉高分辨率信息;因此,它在对象的识别和匹配中至关重要。周边区域捕捉低分辨率信息,因此,它被用来大致定位对象。

FREAK 尝试通过搜索描述符的前 16 个字节来模仿视网膜的功能,这些字节代表粗略信息。如果距离小于一个阈值,我们继续搜索下一个字节以获得更精确的结果。由于这个原因,进行了一系列比较,进一步加速了匹配步骤,因为超过 90% 的采样点在第一个 16 字节比较中被丢弃。

方向

FREAK 方法用于赋值方向的方法与 BRISK 类似,不同之处在于,FREAK 不是使用长距离对,而是使用一组预定义的 45 对对称采样对。

FREAK 在 OpenCV 中的应用

FREAK 的代码与之前算法使用的代码类似。然而,鉴于 FREAK 只提供描述符,我们将使用 FAST 检测器来检测关键点:

case HomeActivity.MODE_FREAK:
    detector = FeatureDetector.create(FeatureDetector.FAST);
    descriptorExtractor = DescriptorExtractor.create(DescriptorExtractor.FREAK);
    descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_HAMMING);
    break;

FREAK 在 OpenCV 中的应用

FREAK 特征匹配

摘要

在本章中,我们看到了如何检测图像中的特征并将它们与其它图像中的特征进行匹配。为了执行这个任务,我们研究了各种算法,如 SIFT、SURF、ORB、BRISK 和 FREAK,以及它们的优缺点。我们还看到了如何使用这些算法来定位场景中的特定物体。这些方法有一个限制,即必须场景图像中存在确切的对象才能正确检测。在下一章中,我们将进一步探索检测更一般类别的对象,例如人类、面部、手等。

第四章.深入对象检测——使用级联分类器

在上一章中,我们探讨了用于对象检测的一些非常复杂的算法。在本章中,我们计划进一步探讨另一组被称为级联分类器和 HOG 描述符的算法。这些算法广泛用于检测人类表情,并在监控系统、人脸识别系统和其他简单的生物识别系统中得到应用。人脸检测是级联分类器(Haar 级联分类器)的第一个应用之一,从那时起,已经开发了许多不同的应用。

你是否曾经想过相机是如何在图像中检测到微笑的面孔并自动拍照的?这并不是什么火箭科学。本章将讨论检测人类表情的不同方法,使用这些方法你可以在 Android 平台上构建上述应用的自己的版本。

在本章中,我们将探讨以下算法:

  • 级联分类器

  • HOG 描述符

级联分类器简介

什么是级联分类器?让我们分别看看这两个词的含义,然后再将它们结合起来,看看这个短语实际上是什么意思。分类器就像黑盒,根据训练集将对象分类到不同的类别。最初,我们取一个大的训练数据集,将其输入到任何学习算法中,并计算一个训练好的模型(分类器),该模型能够对新的未知数据进行分类。

让我们了解一下“级联”这个词。在字面上,级联意味着形成一条链。在当前语境中,级联意味着形成一个多阶段分类器,其中前一阶段的输出传递到下一阶段,依此类推。级联分类器用于那些你拥有较低的计算能力且不想在算法速度上妥协的情况。

本章将涵盖的级联分类器如下:

  • Haar 级联(Viola 和 Jones – 人脸检测)

  • LBP 级联

让我们简要了解 Haar 级联和 LBP 级联,然后构建一个使用这些级联在图像中检测人脸的 Android 应用程序。

Haar 级联

Viola 和 Jones 开发的第一种实时人脸检测算法,其灵感来源于 Haar 小波的概念。该算法利用人脸固有的结构和相似性。例如,在每个人的脸上,眼区域比脸颊暗,鼻梁区域比眼睛暗。利用人脸的这些特征,我们学习人脸的通用模型,然后使用这些训练好的模型在图像中检测人脸。

初始时,我们向学习算法提供正面图像(含人脸的图像)和负面图像(不含人脸的图像)并学习分类器。然后我们使用卷积核从图像中提取 Haar 特征(如下面的图像所示)。特征值是通过从白色矩形下的白色像素总和减去黑色矩形下的像素总和得到的。我们将这些核(即 Haar 特征)在整个图像上滑动并计算特征值。如果值高于某个用户定义的阈值,我们说存在匹配,否则我们拒绝该区域。为了减少计算,我们使用了积分图像。

小贴士

en.wikipedia.org/wiki/Summed_area_table可以找到积分图像的解释。

Haar 级联

Haar 特征

在使用分类器之前每次都对其进行训练在性能方面是不可接受的,因为它需要花费很多时间;有时多达 6-7 小时或更长。因此,我们使用 OpenCV(或任何其他来源)提供的预训练分类器。

LBP 级联

局部二值模式LBP)级联是另一种广泛用于计算机视觉的级联分类器。与 Haar 级联相比,LBP 级联处理整数而不是双精度值。因此,使用 LBP 级联进行训练和测试更快,因此在开发嵌入式应用程序时更受欢迎。LBP 的另一个重要特性是对光照变化的容忍度。

在 LBP 中,对于图像中的每个像素,通过考虑八个相邻像素(左上、右上、左、右、左下和右下)创建一个 8 位二进制特征向量。对于每个相邻像素,都有一个相应的位,如果像素值大于中心像素的值,则分配值为 1,否则为 0。8 位特征向量被视为一个二进制数(稍后将其转换为十进制值),并使用每个像素的十进制值计算一个 256 个分箱的直方图。这个直方图被用作图像的代表性。

LBP 特征中包含一些原语,如下面的图像所示:

LBP 级联

纹理原语示例

对于 Haar 级联,我们也制作了一组正面图像(含人脸)和负面图像(不含人脸)。我们为每个图像计算直方图并将其输入到任何学习算法中。

使用级联分类器进行人脸检测

级联分类器最常见的一个应用是面部检测。在 Android 上使用 OpenCV 实现 Haar 和 LBP 分类器的实现非常相似;唯一的区别在于我们用来检测面部的模型。让我们为面部检测创建一个通用应用程序,并对应用程序进行相关更改以适应 Haar 和 LBP 级联。该应用程序将在整个屏幕上显示相机预览(横屏方向),并在每一帧周围绘制矩形。它还将提供一个选项来切换前后摄像头。以下是创建此应用程序的步骤:

  1. 创建一个新的 Eclipse(或 Android Studio)项目,包含一个空白活动,并将应用程序命名为 Face Detection。它将是一个横屏应用,具有全屏相机预览。

  2. 在应用程序标签中,添加以下行以创建全屏应用程序:

    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    
  3. AndroidManifest.xml 中授予以下权限:

    <uses-permission android:name="android.permission.CAMERA"/>
        <uses-feature android:name="android.hardware.camera" android:required="false"/>
        <uses-feature android:name="android.hardware.camera.autofocus"      android:required="false"/>
        <uses-feature android:name="android.hardware.camera.front" android:required="false"/>
        <uses-feature android:name="android.hardware.camera.front.autofocus" android:required="false"/>
    
  4. 在主活动中,添加一个相机预览视图。这将显示相机输出到屏幕上。使用以下行添加视图:

    <org.opencv.android.JavaCameraView
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:id="@+id/java_surface_view" />
    

    注意

    OpenCV 提供了两种相机预览视图:JavaCameraViewNativeCameraView。这两个视图的工作方式相似,但有一些不同。有关差异的详细说明,请参阅 docs.opencv.org/doc/tutorials/introduction/android_binary_package/dev_with_OCV_on_Android.html?highlight=nativecameraview

在此应用程序中,我们将实现 CvCameraViewListener2 接口,该接口具有提供一些对相机控制的功能定义(请参阅 OpenCV 的相机预览教程)。我们将在本节稍后查看这些函数。

与本书中迄今为止看到的其它应用程序不同,此应用程序对 BaseLoaderCallback 类有不同的实现(对于那些无法回忆起的人来说,BaseLoaderCallback 类在应用程序中初始化和加载 OpenCV 模块)。

对于此应用程序,我们将在我们的应用程序中加载 OpenCV 之后加载级联分类器。以下是此应用程序的 BaseLoaderCallback 类:

private BaseLoaderCallback mLoaderCallback = new BaseLoaderCallback(this) {
        @Override
        public void onManagerConnected(int status) {
            switch (status) {
                case LoaderCallbackInterface.SUCCESS:
                {
                    Log.i(TAG, "OpenCV loaded successfully");
                    try{
                        InputStream is = getResources().openRawResource(<INSERT_RESOURCE_IDENTIFIER>);
                        File cascadeDir = getDir("cascade", Context.MODE_PRIVATE);
                        mCascadeFile = new File(cascadeDir, "cascade.xml");
                        FileOutputStream os = new FileOutputStream(mCascadeFile);

                        byte[] buffer = new byte[4096];
                        int bytesRead;
                        while((bytesRead = is.read(buffer)) != -1)
                        {
                            os.write(buffer, 0, bytesRead);
                        }
                        is.close();
                        os.close();

                        haarCascade = new CascadeClassifier(mCascadeFile.getAbsolutePath());
                        if (haarCascade.empty())
                        {
                            Log.i("Cascade Error","Failed to load cascade classifier");
                            haarCascade = null;
                        }
                    }
                    catch(Exception e)
                    {
                        Log.i("Cascade Error: ","Cascase not found");
                    }
                    mOpenCvCameraView.enableView();
                } break;
                default:
                {
                    super.onManagerConnected(status);
                } break;
            }
        }
    };

在前面的代码片段中,我们首先检查 OpenCV 是否成功加载。完成此操作后,我们使用 InputStreamFileOutputStream 将级联文件从项目资源复制到我们的应用程序中,如下所示。创建一个新的文件夹 cascade,并将级联文件的副本复制到该文件夹中的新文件中。现在来看看使用 Haar 级联和 LBP 级联的区别。将 <INSERT_RESOURCE_IDENTIFIER> 替换为你喜欢的级联文件。

注意:其余的代码与您选择的级联类型无关。

注意

OpenCV 为 Haar 和 LBP 都提供了预学习的级联。将级联文件复制到你的 Android 项目中的 res/raw 文件夹。假设你的 Haar 和 LBP 的级联文件分别命名为 haar_cascade.xmllbp_cascade.xml。将 <INSERT_RESOURCE_IDENTIFIER> 替换为 R.raw.id.haar_casacdeR.raw.id.lbp_cascade,具体取决于你想使用哪个分类器。

我们同时复制和保存文件的原因是将文件从你的项目目录传输到手机的文件系统中:

InputStream is = getResources().openRawResource(<INSERT_RESOURCE_IDENTIFIER>);
File cascadeDir = getDir("cascade", Context.MODE_PRIVATE);
mCascadeFile = new File(cascadeDir, "cascade.xml");
FileOutputStream os = new FileOutputStream(mCascadeFile);

byte[] buffer = new byte[4096];
int bytesRead;
while((bytesRead = is.read(buffer)) != -1)
{
os.write(buffer, 0, bytesRead);
}
is.close();
os.close();

完成此操作后,创建一个新的 CascadeClassifier 对象,稍后将在摄像头流中检测人脸,如下代码片段所示:

haarCascade = new CascadeClassifier(mCascadeFile.getAbsolutePath());
if (cascade.empty())
{
    Log.i("Cascade Error","Failed to load cascade classifier");
    cascade = null;
}

到目前为止,我们已经能够在项目中初始化 OpenCV,并将我们喜欢的级联分类器加载到应用程序中。下一步是准备摄像头预览。如前所述,我们正在实现 CvCameraViewListener2 接口,因此我们需要实现其成员函数:

    @Override
    public void onCameraViewStarted(int width, int height) {
        mRgba = new Mat(height, width, CvType.CV_8UC4);
    }

    @Override
    public void onPause()
    {
        super.onPause();
        if (mOpenCvCameraView != null)
            mOpenCvCameraView.disableView();
    }

    @Override
    public void onResume()
    {
        super.onResume();
        OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION_2_4_9, this, mLoaderCallback);
    }

    public void onDestroy() {
        super.onDestroy();
        if (mOpenCvCameraView != null)
            mOpenCvCameraView.disableView();
    }

另一个需要实现的功能是 onCameraFrame()。这里发生所有魔法。在这个函数中,我们将处理每一帧并找到其中的面孔:

@Override
    public Mat onCameraFrame(CameraBridgeViewBase.CvCameraViewFrame inputFrame) {

        //Rotating the input frame
        Mat mGray = inputFrame.gray();
        mRgba = inputFrame.rgba();
        if (mIsFrontCamera)
        {
            Core.flip(mRgba, mRgba, 1);
        }

        //Detecting face in the frame
        MatOfRect faces = new MatOfRect();
        if(cascade != null)
        {
            cascade.detectMultiScale(mGray, faces, 1.1, 2, 2, new Size(200,200), new Size());
        }

        Rect[] facesArray = faces.toArray();
        for (int i = 0; i < facesArray.length; i++)
            Core.rectangle(mRgba, facesArray[i].tl(), facesArray[i].br(), new Scalar(100), 3);
        return mRgba;
    }

在这里,我们首先将摄像头的输出存储在 mRgba 中,mGray 存储摄像头的灰度图像输出。然后我们检查是否正在使用手机的正面摄像头或背面摄像头(如何处理正面摄像头将在本章后面解释)通过一个布尔值 mIsFrontCamera(类的数据成员)。如果正在使用正面摄像头,只需翻转图像。现在创建一个 MatOfRect 对象,它将存储在帧中包围人脸的矩形。然后,调用神奇的功能:

cascade.detectMultiScale(mGray, faces, 1.1, 2, 2, new Size(200,200), new Size());

detectMultiScale() 函数接收一个灰度图像并返回包含人脸(如果有)的矩形。该函数的第三个参数是缩放因子,它指定了在每次图像缩放时图像大小减少的程度。为了获得更准确的结果,人脸检测会在不同的尺度上进行。最后两个参数是可以检测到的最小和最大人脸尺寸。这些参数在一定程度上决定了应用程序的运行速度。设置最小尺寸可能会导致应用程序性能不佳,即每秒帧数非常少。设置这些参数时要小心。

完成!应用程序几乎完成了,只剩下一项功能尚未实现:处理前置摄像头。为了做到这一点,请遵循以下步骤:

  1. 我们首先在应用程序菜单中添加一个菜单选项,允许用户在前后摄像头之间切换,如下所示:

    @Override
        public boolean onCreateOptionsMenu(Menu menu) {
            // Inflate the menu; this adds items to the action bar if it is present.
            getMenuInflater().inflate(R.menu.menu_main, menu);
            Log.i(TAG, "called onCreateOptionsMenu");
            mItemSwitchCamera = menu.add("Toggle Front/Back camera");
            return true;
        }
    
  2. onOptionsItemSelected() 函数中,添加在摄像头之间切换的功能:

    @Override
        public boolean onOptionsItemSelected(MenuItem item) {
            String toastMesage = "";
    
            if (item == mItemSwitchCamera) {
                mOpenCvCameraView.setVisibility(SurfaceView.GONE);
                mIsFrontCamera = !mIsFrontCamera;
                mOpenCvCameraView = (CameraBridgeViewBase) findViewById(R.id.java_surface_view);
                if (mIsFrontCamera) {
    
                    mOpenCvCameraView.setCameraIndex(1);
                    toastMesage = "Front Camera";
                } else {
                    mOpenCvCameraView.setCameraIndex(-1);
                    toastMesage = "Back Camera";
                }
    
                mOpenCvCameraView.setVisibility(SurfaceView.VISIBLE);
                mOpenCvCameraView.setCvCameraViewListener(this);
                mOpenCvCameraView.enableView();
                Toast toast = Toast.makeText(this, toastMesage, Toast.LENGTH_LONG);
                toast.show();
            }
    
            return true;
        }
    
  3. 当用户选择此选项时,我们首先切换 isFrontCamera 的值。之后,通过运行以下代码更改 mOpenCvCameraView 对象的摄像头索引:

    mOpenCvCameraView.setCameraIndex(-1);
    

Android 中的默认相机索引是-1,代表后置摄像头。前置摄像头的索引是 1(这不是一个固定的数字;它可能因手机而异)。根据前面的代码中的isFrontCamera值设置相机索引,并设置通知用户的消息。

通过这个,我们成功构建了我们自己的面部检测应用程序版本!

HOG 描述符

方向梯度直方图HOG)描述符是使用梯度强度方向和边缘方向的特性描述符。对于 HOG 描述符,我们将图像划分为小的单元,为每个单元计算一个直方图,并将这些直方图进一步组合以计算一个单一的描述符。在这一点上,它们与 SIFT 描述符类似,因为两者都使用图像梯度,并且都将图像划分为空间箱并形成直方图,但 SIFT 描述符帮助您匹配局部区域(使用关键点位置),而 HOG 描述符使用滑动窗口来检测对象。HOG 描述符在几何和光照变换方面表现良好,但在对象方向上表现不佳(与 SIFT 不同,SIFT 在方向变化方面表现良好)。

HOG 描述符被分为多个步骤:

  • 计算梯度:我们首先使用任何导数掩模在图像的水平方向和垂直方向上计算图像中所有像素的梯度值(您可以选择一个方向或两个方向)。一些常见的导数掩模是 Sobel 算子、Prewitt 算子等,但原始算法建议您使用 1D 导数掩模,即[-1, 0, +1]。

  • 方向分箱:创建一个直方图,该直方图包含之前步骤中计算的加权梯度。梯度值被分为箱值,范围从 0 到 180,或 0 到 360(取决于我们是否使用有符号或无符号梯度值)。

  • 组合单元格形成块:在为每个单元格计算直方图之后,我们将这些单元格组合成块,并使用其构成单元格的归一化直方图形成块的组合直方图。最终的 HOG 描述符是归一化直方图的向量。

  • 构建分类器:在算法的最后一步,将之前步骤中计算出的 HOG 特征向量输入到您喜欢的学习算法中,并构建一个模型,该模型将用于在图像中检测对象:HOG 描述符

    HOG 描述符流程图

让我们看看一个使用 HOG 描述符检测对象的 Android 应用程序。

由于 OpenCV 提供了用于在图像中检测人员的预训练 HOG 描述符,我们将编写一个 Android 应用程序,可以检测图像中的人员(我们不需要训练我们的描述符)。由于计算 HOG 描述符所需的计算量很大,在有限的计算资源移动平台上制作实时应用程序变得非常困难。因此,我们将构建一个应用程序,它将只检测单张图像中的人员。

对于这个,让我们参考第二章,图像中的基本特征检测,在那里我们构建了一个应用程序,可以从你的手机相册中读取图像并根据用户的选择执行任何操作(希望你仍然在某处保存了那个项目)。我们不需要整个应用程序。我们只需取那个应用程序的基础部分,并创建一个新的函数来检测相册中的任何图像中的人。

如果你保存了第二章的项目,图像中的基本特征检测,对其进行以下修改。向应用程序菜单中添加一个新的菜单选项检测面部(参考第二章,图像中的基本特征检测),并在onSelectedOptionItem()函数中添加以下行:

else if (id == R.id.FaceDetect) {
            //Detec Faces
            HOGDescriptor();
        }

创建一个新的函数HOGDescriptor(),我们将按照以下方式实现人员检测:

void HOGDescriptor() {
        Mat grayMat = new Mat();
        Mat people = new Mat();

        //Converting the image to grayscale
        Imgproc.cvtColor(originalMat, grayMat, Imgproc.COLOR_BGR2GRAY);

        HOGDescriptor hog = new HOGDescriptor();
        hog.setSVMDetector(HOGDescriptor.getDefaultPeopleDetector());

        MatOfRect faces = new MatOfRect();
        MatOfDouble weights = new MatOfDouble();

        hog.detectMultiScale(grayMat, faces, weights);
        originalMat.copyTo(people);
        //Draw faces on the image
        Rect[] facesArray = faces.toArray();
        for (int i = 0; i < facesArray.length; i++)
            Core.rectangle(people, facesArray[i].tl(), facesArray[i].br(), new Scalar(100), 3);

        //Converting Mat back to Bitmap
        Utils.matToBitmap(people, currentBitmap);
        loadImageToImageView();
    }

在前面的代码片段中,我们首先将图像转换为灰度图像。然后,我们使用以下行初始化HOGDescriptor,使用预训练模型(使用 SVM):

HOGDescriptor hog = new HOGDescriptor();
hog.setSVMDetector(HOGDescriptor.getDefaultPeopleDetector());

下一步很简单;我们将调用detectMultiScale()函数,该函数将返回图像中的所有面孔。函数的第二个参数存储了检测到人的区域。然后我们将遍历所有这些区域,并在图像上围绕它们绘制矩形。

项目 – 快乐相机

实践胜于理论。现在是时候应用本章所学,构建一个酷炫的相机应用程序,当它检测到微笑的面孔时会自动拍照。

诀窍在于我们将使用两种不同类型的级联分类器。首先,我们将使用 Haar 级联在图像中找到面孔并存储所有面孔的位置。然后我们将使用 Haar 级联在图像中检测微笑并存储它们。现在我们尝试匹配带有微笑的面孔。对于每个微笑,我们在图像中找到相应的面孔。这是简单的:如果微笑区域在任何检测到的面孔区域内,我们就说这是一个匹配。

在定位到图像中所有微笑的面部后,计算微笑面部与所有面部(微笑和不微笑)的比例,如果这个比例超过某个阈值,我们就说这是一张快乐的图片,然后点击图片。不过这里要注意的一点是我们使用的比例。我们可以使用不同的指标来标记图像为快乐图像。如果我们计算微笑面部与总面部的比例,会存在一个问题,如果你图像中只有两个人,其中一个人没有微笑(或者有标准表情),那么我们的应用程序将不会点击图片。因此,为了避免这种情况,我们选择对微笑面部与不微笑面部的比例采取宽松的策略,以便将图像分类为快乐图像。

我们如何构建这个应用程序?本章已经讨论了应用程序的大部分内容。应用程序剩余的部分如下:

  1. 添加微笑检测器:这非常简单。它与我们检测面部的方法完全相同;在这里,我们将使用 Haar 级联来检测微笑。你可以在github.com/Itseez/opencv/blob/master/data/haarcascades/haarcascade_smile.xml找到预训练模型。

  2. 关联面部和微笑:一旦我们有了面部和微笑,我们需要在图像中找到匹配的面部和微笑对。我们为什么要关联它们?为什么不直接使用微笑的数量呢?是的,我们可以这样做。没有必要关联面部和微笑。这样做额外步骤的唯一优势是减少误报。如果一个微笑没有对应的面部,我们可以在计算中忽略那个微笑。

  3. 标记快乐图像:一旦你准备好了面部和微笑对,计算(前面解释过)的比例,并决定是否保存那张图像。

  4. 实际上保存图像:在标记图像为快乐图像后,创建一个函数,将图像实际保存到你的手机上。

你刚刚创建了一个酷炫的相机应用程序!

只有在你自己尝试构建这个应用程序之后,你才能查看本书附带的代码包中的示例实现。

摘要

本章是上一章的延续,其中我们看到了一些基本特征检测算法。在这里,我们学习了更多可以用于面部、眼睛和人体检测的算法。级联分类器是一种监督学习模型,我们首先使用一些标记数据训练一个分类器,然后使用训练好的模型来检测新的未遇到的数据。

在接下来的章节中,我们将探讨图像拼接以及如何在计算机视觉算法中使用机器学习等主题。

第五章:视频中的目标跟踪

目标跟踪是计算机视觉最重要的应用之一。它可以用于许多应用,以下是一些例子:

  • 人机交互:我们可能想要追踪人的手指位置,并使用其运动来控制我们机器上的光标

  • 监控:街上的摄像头可以捕捉到行人的运动,这些运动可以被追踪以检测可疑活动

  • 视频稳定和压缩

  • 体育统计:通过追踪足球比赛中球员的运动,我们可以提供诸如行进距离、热图等统计数据

在本章中,你将学习以下主题:

  • 光流

  • 图像金字塔

  • 全局运动估计

  • KLT 追踪器

光流

光流是一种检测视频连续帧之间物体或边缘运动模式的算法。这种运动可能是由物体的运动或摄像机的运动引起的。光流是从第一帧到第二帧点的运动的向量。

光流算法基于两个基本假设:

  • 像素强度在连续帧之间几乎保持恒定

  • 相邻像素具有与锚点像素相同的运动

我们可以用 f(x,y,t) 来表示任何帧中像素的强度。在这里,参数 t 代表视频中的帧。让我们假设,在下一个 dt 时间内,像素移动了 (dx,dy)。由于我们假设强度在连续帧之间没有变化,因此我们可以说:

f(x,y,t) = f(x + dx,y + dy,t + dt)

现在我们对前面方程的右侧进行泰勒级数展开:

光流

消除公共项,我们得到:

光流

其中 光流

将方程两边除以 dt,我们得到:

光流

这个方程被称为光流方程。重新排列方程,我们得到:

光流

我们可以看到,这代表了 (u,v) 平面上的直线方程。然而,只有一个方程可用,有两个未知数,因此目前这个问题处于约束状态。在下文中,我们将解释计算光流最广泛使用的两种方法。

Horn 和 Schunck 方法

考虑到我们的假设,我们得到:

Horn 和 Schunck 方法

我们可以说,由于我们的假设亮度在连续帧之间是恒定的,因此第一个项将很小。所以,这个项的平方将更小。第二个项对应于相邻像素与锚点像素具有相似运动的假设。我们需要最小化前面的方程。为此,我们对前面的方程关于 uv 求导。我们得到以下方程:

霍恩-舒恩克方法霍恩-舒恩克方法

这里,霍恩-舒恩克方法霍恩-舒恩克方法分别是uv的拉普拉斯算子。

卢卡斯-卡纳德方法

我们从之前推导出的光流方程开始,并注意到它是不受约束的,因为它有一个方程和两个变量:

卢卡斯-卡纳德方法

为了克服这个问题,我们利用假设,即 3x3 邻域内的像素具有相同的光流:

卢卡斯-卡纳德方法

我们可以将这些方程重写为矩阵形式,如下所示:

卢卡斯-卡纳德方法

这可以重写为以下形式:

卢卡斯-卡纳德方法

其中:

卢卡斯-卡纳德方法

如我们所见,A是一个 9x2 的矩阵,U是一个 2x1 的矩阵,b是一个 9x1 的矩阵。理想情况下,为了求解U,我们只需要在方程的两边乘以卢卡斯-卡纳德方法。然而,这是不可能的,因为我们只能取方阵的逆。因此,我们尝试通过在方程的两边乘以卢卡斯-卡纳德方法来将A转换成一个方阵:

卢卡斯-卡纳德方法

现在卢卡斯-卡纳德方法是一个 2x2 维度的方阵。因此,我们可以取其逆:

卢卡斯-卡纳德方法

解这个方程,我们得到:

卢卡斯-卡纳德方法

这种先乘转置再取逆的方法称为伪逆

这个方程也可以通过找到以下方程的最小值来获得:

卢卡斯-卡纳德方法

根据光流方程和我们的假设,这个值应该等于零。由于邻域像素的值并不完全与锚点像素相同,这个值非常小。这种方法称为最小二乘误差。为了求解最小值,我们对这个方程关于uv求导,并将其等于零。我们得到以下方程:

卢卡斯-卡纳德方法卢卡斯-卡纳德方法

现在我们有两个方程和两个变量,因此这个方程组可以求解。我们将前面的方程重写如下:

卢卡斯-卡纳德方法卢卡斯-卡纳德方法

因此,将这些方程排列成矩阵形式,我们得到与之前相同的方程:

卢卡斯-卡纳德方法

由于矩阵 A 现在是一个 2x2 矩阵,因此可以取其逆。取逆后,得到的方程如下:

Lucas 和 Kanade 方法

这可以简化为:

Lucas 和 Kanade 方法

解算 uv,我们得到:

Lucas 和 Kanade 方法Lucas 和 Kanade 方法

现在我们有了所有 Lucas 和 Kanade 方法Lucas 和 Kanade 方法,和 Lucas 和 Kanade 方法 的值。因此,我们可以找到每个像素的 uv 的值。

当我们实现此算法时,观察到光流在物体边缘附近不是很平滑。这是由于亮度约束未得到满足。为了克服这种情况,我们使用 图像金字塔(在以下章节中详细解释)。

检查 Android 上的光流

要在 Android 上查看光流的效果,我们将在来自摄像头的视频流上创建一个点阵,然后为每个点绘制线条,以描绘视频上该点的运动,该点叠加在网格点上。

在我们开始之前,我们将设置我们的项目以使用 OpenCV 并从摄像头获取视频流。我们将处理帧以计算光流。

首先,在 Android Studio 中创建一个新的项目,就像我们在前面的章节中所做的那样。我们将活动名称设置为 MainActivity.java,并将 XML 资源文件设置为 activity_main.xml。其次,我们将给应用授予访问摄像头的权限。在 AndroidManifest.xml 文件中,将以下行添加到 manifest 标签中:

<uses-permission android:name="android.permission.CAMERA" />

确保您的 MainActivity 活动标签包含以下行作为属性:

android:screenOrientation="landscape"

我们的 activity_main.xml 文件将包含一个简单的 JavaCameraView。这是一个自定义的 OpenCV 定义布局,它使我们能够访问摄像头帧并将它们作为正常的 Mat 对象处理。XML 代码如下所示:

<LinearLayout 

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <org.opencv.android.JavaCameraView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:id="@+id/main_activity_surface_view" />

</LinearLayout>

现在,让我们编写一些 Java 代码。首先,我们将定义一些全局变量,我们将在代码的后续部分或其他章节中使用它们:

private static final String    TAG = "com.packtpub.masteringopencvandroid.chapter5.MainActivity";

    private static final int       VIEW_MODE_KLT_TRACKER = 0;
    private static final int       VIEW_MODE_OPTICAL_FLOW = 1;

    private int                    mViewMode;
    private Mat                    mRgba;
    private Mat                    mIntermediateMat;
    private Mat                    mGray;
    private Mat                    mPrevGray;

    MatOfPoint2f prevFeatures, nextFeatures;
    MatOfPoint features;

    MatOfByte status;
    MatOfFloat err;

    private MenuItem               mItemPreviewOpticalFlow, mItemPreviewKLT;

    private CameraBridgeViewBase   mOpenCvCameraView;

我们需要创建一个类似于之前的 OpenCV 回调函数。除了我们之前使用的代码之外,我们还将启用 CameraView 捕获用于处理的帧:

private BaseLoaderCallback  mLoaderCallback = new BaseLoaderCallback(this) {
        @Override
        public void onManagerConnected(int status) {
            switch (status) {
                case LoaderCallbackInterface.SUCCESS:
                {
                    Log.i(TAG, "OpenCV loaded successfully");

                    mOpenCvCameraView.enableView();
                } break;
                default:
                {
                    super.onManagerConnected(status);
                } break;
            }
        }
    };

现在,我们将检查手机上是否安装了 OpenCV 管理器,其中包含所需的库。在 onResume 函数中,添加以下代码行:

OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION_2_4_10, this, mLoaderCallback);

onCreate() 函数中,在调用 setContentView 之前添加以下行,以防止在使用应用时屏幕关闭:

getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

现在,我们将初始化我们的 JavaCameraView 对象。在调用 setContentView 之后添加以下行:

mOpenCvCameraView = (CameraBridgeViewBase) findViewById(R.id.main_activity_surface_view);
mOpenCvCameraView.setCvCameraViewListener(this);

注意,我们使用this参数调用了setCvCameraViewListener。为此,我们需要让我们的活动实现CvCameraViewListener2接口。所以,你的MainActivity类的类定义应该看起来像以下代码:

public class MainActivity extends Activity implements CvCameraViewListener2

我们将向这个活动添加一个菜单来在章节中的不同示例之间切换。将以下行添加到onCreateOptionsMenu函数中:

mItemPreviewKLT = menu.add("KLT Tracker");
mItemPreviewOpticalFlow = menu.add("Optical Flow");

现在,我们将向菜单项添加一些操作。在onOptionsItemSelected函数中,添加以下行:

if (item == mItemPreviewOpticalFlow) {
            mViewMode = VIEW_MODE_OPTICAL_FLOW;
            resetVars();
        } else if (item == mItemPreviewKLT){
            mViewMode = VIEW_MODE_KLT_TRACKER;
            resetVars();
        }

        return true;

我们使用了一个resetVars函数来重置所有的Mat对象。它已经被定义为以下内容:

private void resetVars(){
        mPrevGray = new Mat(mGray.rows(), mGray.cols(), CvType.CV_8UC1);
        features = new MatOfPoint();
        prevFeatures = new MatOfPoint2f();
        nextFeatures = new MatOfPoint2f();
        status = new MatOfByte();
        err = new MatOfFloat();
    }

我们还将添加代码以确保,当我们的应用程序挂起或被杀死时,摄像头可以供其他应用程序使用。因此,将以下代码片段添加到onPauseonDestroy函数中:

if (mOpenCvCameraView != null)
            mOpenCvCameraView.disableView();

在 OpenCV 摄像头启动后,会调用onCameraViewStarted函数,这是我们添加所有对象初始化的地方:

public void onCameraViewStarted(int width, int height) {
        mRgba = new Mat(height, width, CvType.CV_8UC4);
        mIntermediateMat = new Mat(height, width, CvType.CV_8UC4);
        mGray = new Mat(height, width, CvType.CV_8UC1);
        resetVars();
    }

类似地,当停止捕获帧时,会调用onCameraViewStopped函数。在这里,我们将释放在视图开始时创建的所有对象:

public void onCameraViewStopped() {
        mRgba.release();
        mGray.release();
        mIntermediateMat.release();
    }

现在,我们将添加处理从摄像头捕获的每一帧的实现。OpenCV 为每一帧调用onCameraFrame方法,并将帧作为参数。我们将使用这个方法来处理每一帧。我们将使用viewMode变量来区分光流和 KLT 跟踪器,并为这两个提供不同的 case 结构:

public Mat onCameraFrame(CvCameraViewFrame inputFrame) {
        final int viewMode = mViewMode;
        switch (viewMode) {
            case VIEW_MODE_OPTICAL_FLOW:

我们将使用gray()函数来获取包含捕获帧的灰度格式的 Mat 对象。OpenCV 还提供了一个名为rgba()的类似函数来获取彩色帧。然后我们将检查这是否是第一次运行。如果是第一次运行,我们将创建并填充一个features数组,该数组存储网格中所有点的位置,我们将在这里计算光流:

                mGray = inputFrame.gray();
                if(features.toArray().length==0){
                    int rowStep = 50, colStep = 100;
                    int nRows = mGray.rows()/rowStep, nCols = mGray.cols()/colStep;

                    Point points[] = new Point[nRows*nCols];
                    for(int i=0; i<nRows; i++){
                        for(int j=0; j<nCols; j++){
                            points[i*nCols+j]=new Point(j*colStep, i*rowStep);
                        }
                    }

                    features.fromArray(points);

                    prevFeatures.fromList(features.toList());
                    mPrevGray = mGray.clone();
                    break;
                }

mPrevGray对象指的是灰度格式的上一帧。我们将点复制到一个prevFeatures对象中,我们将使用它来计算光流,并将相应的点存储在下一帧的nextFeatures中。所有的计算都是在 OpenCV 定义的calcOpticalFlowPyrLK函数中进行的。这个函数接受上一帧的灰度版本、当前灰度帧、一个包含需要计算光流的特征点的对象,以及一个将存储当前帧中相应点位置的对象:

                nextFeatures.fromArray(prevFeatures.toArray());
                Video.calcOpticalFlowPyrLK(mPrevGray, mGray, prevFeatures, nextFeatures, status, err);

现在,我们有了点的网格位置以及它们在下一帧中的位置。因此,我们现在将绘制一条线来描述网格上每个点的运动:

                List<Point> prevList=features.toList(), nextList=nextFeatures.toList();
                Scalar color = new Scalar(255);

                for(int i = 0; i<prevList.size(); i++){
                    Core.line(mGray, prevList.get(i), nextList.get(i), color);
                }

在循环结束之前,我们必须将当前帧复制到mPrevGray,以便我们可以在后续帧中计算光流:

                mPrevGray = mGray.clone();
                break;
default: mViewMode = VIEW_MODE_OPTICAL_FLOW;

在我们结束 switch case 结构后,我们将返回一个 Mat 对象。这是将作为应用程序用户输出的图像显示的图像。在这里,由于我们的所有操作和处理都是在灰度图像上进行的,我们将返回此图像:

return mGray;

因此,这就是关于光流的所有内容。结果可以在以下图像中看到:

检查 Android 上的光流

相机馈送中不同点的光流

图像金字塔

金字塔是相同图像的多个副本,它们的大小不同。它们以层的形式表示,如下所示。金字塔中的每一层都是通过将行和列减半获得的。因此,实际上,我们将图像的大小减少到原始大小的四分之一:

图像金字塔

金字塔的相对大小

金字塔本质上定义了减少扩展为其两个操作。减少指的是图像大小的减少,而扩展指的是图像大小的增加。

注意

我们将使用一个约定,即金字塔中的较低层表示缩小后的图像,而较高层表示放大后的图像。

高斯金字塔

在减少操作中,我们使用以下方程来连续找到金字塔中的层级,同时使用一个 5x5 的滑动窗口。请注意,图像的大小减少到原始大小的四分之一:

高斯金字塔

权重核的元素,w,应该加起来等于 1。我们为此任务使用了一个 5x5 的高斯核。这个操作类似于卷积,但结果图像的大小与原始图像不同。以下图像展示了减少操作:

高斯金字塔

减少操作

扩展操作是减少操作的逆过程。我们尝试从属于较低层的图像生成较大尺寸的图像。因此,结果图像是模糊的,并且分辨率较低。我们用于执行扩展的方程如下:

高斯金字塔

在这种情况下,权重核w与用于执行减少操作的权重核相同。以下图像展示了扩展操作:

高斯金字塔

扩展操作

权重是使用我们在第一章中使用的,对图像应用效果,高斯函数来执行高斯模糊计算的。

拉普拉斯金字塔

拉普拉斯金字塔通常表示边缘。它们是从高斯金字塔获得的。它们使用以下公式计算:

拉普拉斯金字塔

g[i]和扩展 (g[i+1])在我们缩小图像后不再相同;我们丢失了信息,这些信息无法恢复。

拉普拉斯金字塔

OpenCV 中的高斯和拉普拉斯金字塔

要了解 OpenCV 中如何创建金字塔,我们将创建两个新的活动,分别称为 PyramidActivityHomeActivityPyramidActivity 类将从图库中加载一张图片,然后根据用户的选项执行所需的操作。HomeActivity 用于根据用户提供的选项调用 PyramidActivityMainActivity。因此,首先,我们为 HomeActivity 类创建资源,并将其命名为 activity_home.xml

<?xml version="1.0" encoding="utf-8"?>

<ScrollView

    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <LinearLayout
        android:layout_height="match_parent"
        android:layout_width="match_parent"
        android:orientation="vertical" >

        <Button
            android:id="@+id/bPyramids"
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="Image Pyramids" />

        <Button
            android:id="@+id/bOptFlowKLT"
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="Optical Flow and KLT Tracker" />

    </LinearLayout>
</ScrollView>

在我们的 Java 代码中,我们将为这些按钮添加监听器以调用相应的活动,如下所示:

Button bPyramids, bOptFlowKLT;
        bPyramids = (Button) findViewById(R.id.bPyramids);
        bOptFlowKLT = (Button) findViewById(R.id.bOptFlowKLT);
        bOptFlowKLT.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent i = new Intent(getApplicationContext(), MainActivity.class);
                startActivity(i);
            }
        });
        bPyramids.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent i = new Intent(getApplicationContext(), PyramidActivity.class);
                startActivity(i);
            }
        });

现在我们转向 PyramidActivity 的实现。首先,我们将查看 activity_pyramid.xml。我们将添加按钮以执行用户选项的各种操作。可能的选项是高斯金字塔向上、高斯金字塔向下和拉普拉斯金字塔计算。以下代码被插入到 ScrollView 内部的 LinearLayout

<ImageView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="0.5"
            android:id="@+id/ivImage" />
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="0.5"
                android:id="@+id/bGaussianPyrUp"
                android:text="Gaussian Pyramid Up"/>
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="0.5"
                android:id="@+id/bGaussianPyrDown"
                android:text="Gaussian Pyramid Down"/>
        </LinearLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:id="@+id/bLaplacianPyr"
                android:text="Laplacian Pyramid"/>
        </LinearLayout>

我们还将为这个活动创建一个菜单文件,用于从图库中加载图片。我们将有一个类似于前面章节中加载图片的方法。在 PyramidActivity.java 中,我们将有以下行:

@Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.menu_pyramid, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int id = item.getItemId();

        if (id == R.id.action_load_first_image) {
            Intent photoPickerIntent = new Intent(Intent.ACTION_PICK);
            photoPickerIntent.setType("image/*");
            startActivityForResult(photoPickerIntent, SELECT_PHOTO);
            return true;
        }

        return super.onOptionsItemSelected(item);
    }

现在我们将定义一些全局变量,我们将需要它们:

private final int SELECT_PHOTO = 1;
private ImageView ivImage;
Mat src;
static int ACTION_MODE = 0;
static final int MODE_NONE = 0, MODE_GAUSSIAN_PYR_UP = 1, MODE_GAUSSIAN_PYR_DOWN = 2, MODE_LAPLACIAN_PYR = 3;
private boolean srcSelected = false;
Button bGaussianPyrUp, bGaussianPyrDown, bLaplacianPyr;

我们还需要指定 OpenCV 回调函数并在 onResume 中初始化它,就像我们之前做的那样。

在我们的 onCreate 函数中,初始化所有按钮之后,我们将首先禁用它们,直到从图库中加载了一张图片。因此,在这个活动中初始化所有按钮之后,添加以下行:

bGaussianPyrDown.setEnabled(false);
bGaussianPyrUp.setEnabled(false);
bLaplacianPyr.setEnabled(false);

在我们的 onActivityResult 中,我们将检查图片是否已成功加载,如果是,我们将激活按钮。我们还将图片加载到 Mat 中并存储起来以供以后使用:

switch(requestCode) {
            case SELECT_PHOTO:
                if(resultCode == RESULT_OK){
                    try {
                        final Uri imageUri = imageReturnedIntent.getData();
                        final InputStream imageStream = getContentResolver().openInputStream(imageUri);
                        final Bitmap selectedImage = BitmapFactory.decodeStream(imageStream);
                        src = new Mat(selectedImage.getHeight(), selectedImage.getWidth(), CvType.CV_8UC4);
                        Utils.bitmapToMat(selectedImage, src);
                        srcSelected = true;
                        bGaussianPyrUp.setEnabled(true);
                        bGaussianPyrDown.setEnabled(true);
                        bLaplacianPyr.setEnabled(true);
                    } catch (FileNotFoundException e) {
                        e.printStackTrace();
                    }
                }
                break;
        }

现在我们将为每个按钮添加监听器。在你的 onCreate 中,添加以下行:

bGaussianPyrUp.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ACTION_MODE = MODE_GAUSSIAN_PYR_UP;
                executeTask();
            }
        });

bGaussianPyrDown.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ACTION_MODE = MODE_GAUSSIAN_PYR_DOWN;
                executeTask();
            }
        });

bLaplacianPyr.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ACTION_MODE = MODE_LAPLACIAN_PYR;
                executeTask();
            }
        });

现在我们将实现 executeTask 函数,该函数将在 AsyncTask 中执行所需的计算,并在完成后将它们加载到我们布局中的 ImageView

private void executeTask(){
        if(srcSelected){

            new AsyncTask<Void, Void, Bitmap>() {
                @Override
                protected void onPreExecute() {
                    super.onPreExecute();
                }

                @Override
                protected Bitmap doInBackground(Void... params) {
                    Mat srcRes = new Mat();
                    switch (ACTION_MODE){
                        case MODE_GAUSSIAN_PYR_UP:
                            Imgproc.pyrUp(src, srcRes);
                            break;
                        case MODE_GAUSSIAN_PYR_DOWN:
                            Imgproc.pyrDown(src, srcRes);
                            break;
                        case MODE_LAPLACIAN_PYR:
                            Imgproc.pyrDown(src, srcRes);
                            Imgproc.pyrUp(srcRes, srcRes);
                            Core.absdiff(src, srcRes, srcRes);
                            break;
                    }

                    if(ACTION_MODE != 0) {
                        Bitmap image = Bitmap.createBitmap(srcRes.cols(), srcRes.rows(), Bitmap.Config.ARGB_8888);

                        Utils.matToBitmap(srcRes, image);
                        return image;
                    }
                    return null;
                }

                @Override
                protected void onPostExecute(Bitmap bitmap) {
                      super.onPostExecute(bitmap);
                      if(bitmap!=null) {
                        ivImage.setImageBitmap(bitmap);
                    }
                }
            }.execute();
        }
    }

在这里,我们只用了两个参数调用了 pyrUppyrDown;然而,你可以通过调用函数 Imgproc.pyrUp(srcMat, dstMat, resultSize) 来指定自定义的结果大小。

OpenCV 不提供单独的函数来计算拉普拉斯金字塔,但我们可以使用高斯金字塔来生成我们的拉普拉斯金字塔。

基本二维变换

3D 空间中的对象可以在 2D 空间中投射出与原始投影不同的投影。这种变换称为二维变换。它们在以下图像中显示。我们将使用其中一些变换来解释本章后面以及在其他章节中讨论的概念:

基本二维变换

我们将这些变换写成数学形式,以及它们的矩阵表示,如下所示:

  • 平移:平移变换的数学表示如下:基本二维变换

  • 仿射:仿射变换的数学表示如下:基本二维变换

  • 刚性:刚性变换的数学表示如下:基本二维变换

  • 投影:投影变换的数学表示如下:基本二维变换

全球运动估计

如其名所示,全球运动估计是在计算中使用帧中所有像素来检测运动。全球运动估计的一些应用包括:

  • 视频稳定化

  • 视频编码/解码

  • 物体分割

该方法由 Bergen 等人(1992 年)提出。在此方法中,当相机与背景场景之间的距离很大时,我们可以将对象的运动近似为仿射变换。我们之前看到的方程如下:

全球运动估计

我们可以将这些方程重写为以下矩阵形式:

全球运动估计

这可以写成全球运动估计

根据光流方程:

全球运动估计

我们试图估计图像中的运动,使得所有像素都满足它。因此,我们求和所有像素的光流方程,并尝试生成一个估计:

全球运动估计

全球运动估计理想情况下应为零,但实际上是一个小值。因此,平方误差将很小。因此,我们需要最小化它以获得最佳结果:

全球运动估计全球运动估计

该方程可以相对于全球运动估计最小化到以下线性方程:

全球运动估计

这个线性方程可以写成:

全球运动估计

该算法现在分为四个子部分:金字塔构建、运动估计、图像扭曲和从粗到细的细化。

对于金字塔构建,我们首先对时间 tt-1 的图像进行高斯金字塔,然后从最底层开始迭代计算全局流,逐渐向更底层移动。

然后,对于每一层,为了找到运动估计,我们使用之前推导出的线性方程来计算时间 tt-1 的帧的 A 和 B,并使用这些信息来计算 a 全局运动估计 的估计。然后我们将时间 t-1 的图像扭曲到另一个图像,该图像试图从原始图像生成对象运动。这个新图像与时间 t 捕获的图像进行比较。然后我们迭代扭曲在 t-1 获得的图像帧以计算 全局运动估计 的值。有了 全局运动估计 的这个值,我们生成另一个扭曲图像,然后将其与时间 t 的图像进行比较。我们使用 全局运动估计 的这个值来更新 a 的值。这个过程执行多次,直到我们得到图像运动的足够好的估计。

图像扭曲是将任何变换应用于图像以产生另一个图像的过程。对于这种方法,我们执行仿射变换,因为我们之前的假设:

全局运动估计

对于最后一步,从粗到细的细化,我们利用图像金字塔将我们的模型扩展到包括密集图像(例如,表示深度图)。

Kanade-Lucas-Tomasi 跟踪器

在了解了局部和全局运动估计之后,我们现在将看看目标跟踪。跟踪目标是计算机视觉最重要的应用之一。Kanade-Lucas-TomasiKLT)跟踪器通过光流在视频中跟踪对象。实现该算法的步骤如下所述:

  1. 在视频的第一帧中检测 Harris 角点。

  2. 对于每个检测到的 Harris 角点,使用光流(translator)和局部仿射变换(affine)计算连续帧之间的运动。

  3. 现在将这些运动矢量从一帧链接到另一帧以跟踪角点。

  4. 在特定数量的帧(例如,10 到 20 帧)之后生成新的 Harris 角点,以补偿场景中进入的新点或丢弃场景外的点。

  5. 跟踪新的和旧的 Harris 点。

在 OpenCV 上检查 KLT 跟踪器

如我们之前所见,KLT 跟踪器是可用的最佳算法之一,用于在视频中跟踪对象。对于这个例子,我们将从摄像头获取输入,检测一些可跟踪的特征,并将这些特征更新到新位置,如 calcOpticalFlowPyrLK 函数获得的位置。我们只需将一个新的情况构造添加到我们为光流编写的代码中:

case VIEW_MODE_KLT_TRACKER:
                mGray = inputFrame.gray();

                if(features.toArray().length==0){
                    Imgproc.goodFeaturesToTrack(mGray, features, 10, 0.01, 10);
                    prevFeatures.fromList(features.toList());
                    mPrevGray = mGray.clone();
                    break;
                }

goodFeaturesToTrack 函数使用 Shi-Tomasi 方法来计算图像中的良好可追踪特征。这可以被任何可靠的特征计算技术所替代。它以灰度格式作为输入帧,并返回特征列表。它还接受要追踪的最大特征数量、特征的质最以及特征之间的最小距离作为参数。在本示例中,我们只将在第一帧中计算特征,并在后续帧中追踪这些特征。

现在我们将获得之前获得的特征点的光流。请注意,nextFeatures 包含在 prevFeatures 中的前一个帧中相应点的位置。我们将用圆圈标记特征点的位置。请注意,我们是在特征的新位置上画圆圈:

                Video.calcOpticalFlowPyrLK(mPrevGray, mGray, prevFeatures, nextFeatures, status, err);
                List<Point> drawFeature = nextFeatures.toList();
                for(int i = 0; i<drawFeature.size(); i++){
                    Point p = drawFeature.get(i);
                    Core.circle(mGray, p, 5, new Scalar(255));
                }

现在我们需要将当前帧设置为前一个帧,并将当前特征点的位置设置为前一个帧中特征的位置,以便启用追踪:

                mPrevGray = mGray.clone();
                prevFeatures.fromList(nextFeatures.toList());
                break;

Shi-Tomasi 追踪器和 KLT 追踪器的结果可以在以下图像中看到:

检查 OpenCV 中的 KLT 追踪器

以下图像中的白色圆圈代表我们正在追踪的特征:

检查 OpenCV 中的 KLT 追踪器

如图中所示,有一些点没有正确追踪。例如,考虑 L 键上的特征点。如您所见,在一个帧中,它位于 L 键上,而在另一个帧中,它移动到了带有分号的键上。如果您考虑 YJ 键上的特征点,它们保持在它们的位置。这是因为 YJ 键上有定义良好的角点;因此,特征点在那里更好。

摘要

在本章中,我们学习了如何在视频中检测局部和全局运动,以及如何追踪对象。我们还了解了高斯和拉普拉斯金字塔,以及它们如何被用来提高某些计算机视觉任务的性能。

在下一章中,我们将学习如何对多张图像进行对齐,以及如何将它们拼接在一起形成全景图像。

第六章. 使用图像对齐和拼接

相机的局限性之一是视野有限,通常简称为 FOV。视野是定义通过相机获得的一帧中可以捕获多少信息的参数。因此,为了捕捉需要更大视野的图像,我们使用图像拼接。图像拼接是一种将多个图像连接起来形成更大图像的方法,该图像表示与原始图像一致的信息。

在本章中,我们将探讨以下主题:

  • 图像拼接

  • 图像对齐

  • 视频稳定

  • 立体视觉

图像拼接

近年来,在图像拼接方面已经做了很多工作,但我们将探讨 OpenCV 内部实现的算法。其中大部分是由 Michael Brown 和 David Lowe 提出的。图像拼接按照以下步骤进行:

  1. 在图像集中找到合适的特征并可靠地匹配它们以获得相对位置。

  2. 开发几何学以选择对旋转、比例和光照不变的可靠特征。

  3. 使用 RANSAC 算法和概率模型进行验证来匹配图像。

  4. 对齐匹配的图像。

  5. 渲染结果以获得全景图像。我们使用自动校正、增益补偿和多波段混合来实现无缝拼接的全景图像,如图所示:图像拼接

特征检测与匹配

首先,我们在所有图像之间找到并匹配 SIFT 特征。通过这样做,我们得到与每个特征点相关的比例和方向。有了这些细节,我们可以形成一个相似性不变矩阵,在其中我们可以进行适当的测量以进行计算。我们在方向直方图中累积局部梯度以获得这样的帧。通过实现这样的算法,边缘可以略微移动而不会修改描述符值,从而提供小的仿射和位移不变性。该算法还建议使用梯度来实现光照不变性,以消除偏差并归一化描述符向量以消除增益。

该算法还假设相机仅围绕其光学中心旋转。由于这个假设,我们可以定义沿三个主轴的旋转,xy,和z,分别如特征检测与匹配特征检测与匹配所示。我们定义一个向量θ,如特征检测与匹配所示。我们还使用焦距,f作为一个参数。因此,我们得到成对的透视变换,如特征检测与匹配所示。

特征检测与匹配

在这里,特征检测与匹配特征检测与匹配是单应性图像位置。特征检测与匹配是二维空间中的图像位置:

特征检测与匹配

特征检测与匹配特征检测与匹配的值定义如下:

特征检测与匹配特征检测与匹配

如您所见,这种 R 的表示与旋转的指数形式表示是一致的。我们已包括允许位置有微小变化的条款。因此,我们得到以下结果:

特征检测与匹配特征检测与匹配

特征检测与匹配表示通过计算特征检测与匹配的线性单应性得到的图像的仿射变换。

在所有图像中检测到特征后,我们需要将它们匹配起来以找到它们的相对排列。为此,我们使用特征空间中的 k 最近邻(k = 4)来匹配重叠特征,以获得重叠特征。这种方法被采用以考虑每个特征可能在一个以上的图像中重叠的事实。

图像匹配

到目前为止,我们已经获得了特征和特征之间的匹配。现在我们需要获得匹配图像以形成全景图。为了形成全景图,我们需要少量图像来匹配任何图像,以便找到相邻图像。算法建议使用六个匹配图像与当前图像匹配。本节分为两部分进行。首先,我们估计两个帧兼容的单应性,并为同一帧找到一组内点。为此,我们使用 RANSAC 算法。然后我们使用概率模型来验证图像之间的匹配。

使用 RANSAC 进行单应性估计

RANSAC 算法,即随机样本一致性算法,是一种使用图像中随机选择的一小部分匹配来估计图像变换参数的算法。对于图像拼接,我们使用四个特征匹配来计算它们之间的单应性。为此,算法建议使用 R. Hartley 和 A. Zisserman 描述的直接线性变换方法。这个过程进行了 500 次迭代,最终选择具有最大数量内点的解。内点是指其线性投影与单应性 H 一致,直到达到指定的像素容差值。通过进行概率计算,发现找到匹配的概率非常高。例如,如果图像之间的内点匹配概率为 0.5,则找不到单应性的概率为使用 RANSAC 进行单应性估计。因此,RANSAC 在估计 H 方面非常成功。这种方法被称为最大似然估计。

使用概率模型验证图像匹配

通过到目前为止获得的模型,我们有一组重叠区域(内点)内的特征匹配,以及重叠区域内不匹配的一些特征(异常值)。使用概率模型,我们将验证获得的内点和异常值集是否产生有效的图像匹配。算法假设使用概率模型验证图像匹配特征匹配的概率是一个独立的伯努利试验。从这个假设中得到的两个方程如下所示:

使用概率模型验证图像匹配使用概率模型验证图像匹配

这里,使用概率模型验证图像匹配代表重叠区域中存在的特征总数。使用概率模型验证图像匹配代表内点的总数。m指定两个图像是否正确匹配。使用概率模型验证图像匹配是在正确图像匹配的条件下,特征是内点的概率。使用概率模型验证图像匹配是在正确图像匹配的条件下,特征不是内点的概率。使用概率模型验证图像匹配代表特征匹配集使用概率模型验证图像匹配B代表二项分布,如下所示:

使用概率模型验证图像匹配

为了本算法的目的,使用概率模型验证图像匹配使用概率模型验证图像匹配的值分别设置为 0.6 和 0.1。使用贝叶斯规则,我们可以计算出图像匹配有效的概率为:

使用概率模型验证图像匹配

如果前述表达式的值大于预先选择的最低概率,则认为图像匹配是有效的。该算法建议使用使用概率模型验证图像匹配使用概率模型验证图像匹配。如果满足以下方程,则匹配被接受,否则被拒绝:

使用概率模型验证图像匹配

从早期假设中产生的一个条件是,对于有效的图像匹配,必须满足以下方程:

使用概率模型验证图像匹配

在原始论文中,作者还提出了一种方法,通过这种方法可以从图像中学习参数,而不是为它们分配固定值。

捆绑调整

布朗和洛的算法提出了使用捆绑调整来获取所有相机参数,对于给定的一组图像匹配,是联合进行的。为此,图像按照特征匹配数量的降序添加到捆绑调整器中。每次,新图像都使用与其匹配的图像的旋转和焦距进行初始化。然后我们使用 Levenberg-Marquadt 算法来更新相机参数。Levenberg-Marquadt 算法通常用于解决曲线拟合问题中的非线性最小二乘问题。

此算法试图最小化平方投影误差的总和。为此,每个特征都被投影到与原始图像匹配的每个其他图像上,然后相对于相机参数最小化平方距离的总和。如果一个图像的捆绑调整特征与另一个图像的捆绑调整特征匹配,我们得到以下投影残差:

捆绑调整

在这里,捆绑调整代表捆绑调整图像中的捆绑调整特征,捆绑调整捆绑调整特征从捆绑调整图像投影到捆绑调整图像后的残差,而捆绑调整捆绑调整捆绑调整图像投影到捆绑调整图像上的投影。

然后,通过将所有特征的所有图像上的鲁棒化残差场误差相加来计算误差函数。为此鲁棒化,使用了 Huber 鲁棒误差函数:

捆绑调整

解决这个问题后,我们得到一个非线性方程,使用 Levenberg-Marquardt 算法求解,以估计相机参数的值。

自动全景校正

到目前为止,该算法已经能够成功地在图像之间找到匹配,并将它们拼接在一起。然而,仍然存在一个未知的 3D 旋转分量,这导致全景图以波浪状输出,如下面的图所示:

自动全景校正

这主要是因为在拍摄多张图像时,相机可能没有完全水平。

这通过考虑人们点击全景图像的方式的启发式方法来解决。假设用户在点击图像时旋转相机的可能性非常低,因此相机向量通常位于同一平面上。因此,我们尝试找到相机向量协方差矩阵的零向量和平面中心及地平线平面的法向量。这样,我们就可以对图像应用旋转,以有效地消除波浪效应。

自动全景校正

增益补偿

增益是描述图像对光敏感性的相机参数。不同的图像可能在不同的增益级别下被拍摄。为了克服这种情况,我们利用增益补偿,如下所示:

增益补偿

增益补偿是指对图像中的增益进行归一化,以方便无缝拼接图像。所使用的方法与计算相机参数的方法类似。这里使用的误差函数是所有重叠像素增益归一化强度的误差之和:

增益补偿

多波段混合

即使在增益补偿之后,拼接似乎仍然不够无缝。我们需要应用一个好的混合算法来拼接图像,使其不明显地看出图像是由多张图像拼接而成的。

为了做到这一点,我们采用了一种良好的混合策略。我们选择了一种混合算法,其中为每个图像分配一个权重函数。这个权重函数与权重成正比,权重中心为 1,边缘为 0。这个权重函数也被扩展到球坐标系。可以使用这些权重函数计算沿每条射线的强度加权总和,但这会导致高频区域被模糊掉。

由于这个原因,我们需要实现多波段混合。多波段混合在大区域内混合低频区域,在相对较小的区域内混合高频区域。我们使用多波段混合为每个图像分配权重,使得多波段混合的值在图像中权重最大时为 1,在区域的权重最大值来自其他图像时为 0。然后我们依次模糊掉这些权重图,最终得到每个波段的混合权重。

然后,我们根据混合权重线性组合每个波段的重叠图像。模糊程度取决于波段的频率。这导致高频波段在短区域内混合,而低频波段在大区域内混合:

多波段混合

使用 OpenCV 进行图像拼接

以下是将图像拼接的流程:

使用 OpenCV 进行图像拼接

我们现在将看到如何实现图像拼接。

首先,我们将以与之前所有章节相同的方式设置我们的项目。对于这个项目,我们将使用包名com.packtpub.masteringopencvandroid.chapter6。首先,我们将编辑我们的清单文件。

我们将向此项目添加所有必需的权限。我们需要访问摄像头的权限,以及读取和写入外部存储的权限。因此,将以下代码添加到您的清单文件中:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
    android:name="android.permission.READ_PHONE_STATE" />
<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE" />

然后,我们将声明我们的活动。对于这个项目,我们只需要一个活动。我们将称之为StitchingActivity

设置 Android NDK

由于拼接模块在 OpenCV 的 Java SDK 中不可用,因此我们需要 NDK 来完成这个项目。所以,我们将编写 C++代码,并使用 Android NDK 进行编译,以便将其作为我们项目的一部分使用。为此,首先从developer.android.com/tools/sdk/ndk下载 NDK,并将其解压缩到您的计算机上的某个位置。然后转到您的local.properties文件,并添加以下行:

ndk.dir=<location of the ndk directory>

接下来,转到您的项目主模块中的build.gradle文件。在这个文件中,在defaultConfig标签内,添加以下代码:

ndk {
    moduleName "stitcher"
}

这是模块的名称,其中将包含我们的函数,我们的计算将在其中执行。现在,在android标签下,在defaultConfig结束之后,添加以下行:

sourceSets.main {
    jniLibs.srcDir 'src/main/libs'
    jni.srcDirs = [] //disable automatic ndk-build call
}

这定义了我们的编译库将被放置的位置。之后,我们需要设置我们项目的 NDK 部分。在src文件夹中,添加一个名为jni的文件夹。在这个文件夹中,我们需要创建两个文件。第一个是Android.mk。这个文件包含有关项目中文件的信息。将以下行复制到该文件中。请记住用你电脑上的位置替换OpenCV4AndroidSDK

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

OPENCV_CAMERA_MODULES:=on
OPENCV_INSTALL_MODULES:=on

include <OpenCV4AndroidSDK>/sdk/native/jni/OpenCV.mk

LOCAL_MODULE    := stitcher
LOCAL_SRC_FILES := stitcher.cpp
LOCAL_LDLIBS +=  -llog -ldl

include $(BUILD_SHARED_LIBRARY)

现在,创建另一个名为Application.mk的文件。这个文件定义了代码需要编译的架构。将以下行复制到该文件中:

APP_STL := gnustl_static
APP_CPPFLAGS := -frtti -fexceptions
APP_ABI := armeabi-v7a
APP_PLATFORM := android-8

现在我们已经准备好在我们的项目中使用 NDK 代码了。

布局和 Java 代码

接下来我们将绘制我们的布局。对于这个项目,我们只需要一个包含一个ImageView标签的布局来显示拼接的图像和两个Button。其中一个按钮用于点击更多图像,另一个用于表示没有更多图像可以点击。我们还将所有项目放入一个ScrollView标签中,以便能够看到超过屏幕大小的完整图像。我们的activity_stitching.xml文件如下:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <LinearLayout android:orientation="vertical" android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="0.5"
            android:id="@+id/ivImage" />
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="0.5"
                android:id="@+id/bClickImage"
                android:text="Click more images"/>
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="0.5"
                android:id="@+id/bDone"
                android:text="Done"/>
        </LinearLayout>

    </LinearLayout>
</ScrollView>

现在我们必须编写我们的 Java 代码。在StitchingActivity.java文件中,在你的 OpenCV BaseLoaderCallback对象中,通过在case LoaderCallbackInterface.SUCCESS中添加以下行来编辑onManagerConnected函数:

System.loadLibrary("stitcher");

注意,这与我们在Android.mk文件中给我们的模块起的名字相同。在我们的 Java 代码中,我们首先声明并初始化我们将需要的所有变量。我们有一个名为bClickImage的按钮,点击它将调用 Android 的相机意图并请求系统的相机应用拍照并发送给应用。我们将把这个Bitmap图像转换成 OpenCV 的Mat并存储在一个ArrayList中。当用户点击bDone按钮时,我们将最终拼接所有图像。两个按钮的onClickListener如下:

bClickImage.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        File imagesFolder = new File(FILE_LOCATION);
        imagesFolder.mkdirs();
        File image = new File(imagesFolder, "panorama_"+ (clickedImages.size()+1) + ".jpg");
        fileUri = Uri.fromFile(image);
        Log.d("StitchingActivity", "File URI = " + fileUri.toString());
        intent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri); // set the image file name

        // start the image capture Intent
        startActivityForResult(intent, CLICK_PHOTO);
    }
});

bDone.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        if(clickedImages.size()==0){
            Toast.makeText(getApplicationContext(), 
              "No images clicked", Toast.LENGTH_SHORT).show();
        } else if(clickedImages.size()==1){
            Toast.makeText(getApplicationContext(), "Only one image clicked", Toast.LENGTH_SHORT).show();
            Bitmap image = Bitmap.createBitmap(src.cols(), src.rows(), Bitmap.Config.ARGB_8888);
            Utils.matToBitmap(src, image);
            ivImage.setImageBitmap(image);
        } else {
            createPanorama();
        }
    }
});

当相机意图从相机应用返回时,将调用onActivityResult函数。我们需要检查是否已经点击了图像,并在必要时将其添加到ArrayList中。我们将使用 OpenCV 的BitmapToMat函数将图像从 Android Bitmap 转换为 OpenCV Mat。代码如下:

switch(requestCode) {
    case CLICK_PHOTO:
        if(resultCode == RESULT_OK){
            try {
                final InputStream imageStream = getContentResolver().openInputStream(fileUri);
                final Bitmap selectedImage = BitmapFactory.decodeStream(imageStream);
                src = new Mat(selectedImage.getHeight(), selectedImage.getWidth(), CvType.CV_8UC4);
                Imgproc.resize(src, src, new Size(src.rows()/4, src.cols()/4));
                Utils.bitmapToMat(selectedImage, src);
                Imgproc.cvtColor(src, src, Imgproc.COLOR_BGR2RGB);
                clickedImages.add(src);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
        }
        break;
}

bDoneonClickListener中,我们调用了createPanorama函数。在这个函数中,我们将执行一个AsyncTask,因为这个任务计算量很大。在AsyncTask中,我们将调用我们的 NDK 来执行实际计算。这就是我们的doInBackground看起来像:

Mat srcRes = new Mat();
int success = StitchPanorama(clickedImages.toArray(), clickedImages.size(), srcRes.getNativeObjAddr());
if(success==0){
    return null;
}
Imgproc.cvtColor(srcRes, srcRes, Imgproc.Color_BGR2RGBA);
Bitmap bitmap = Bitmap.createBitmap(srcRes.cols(), srcRes.rows(), Bitmap.Config.ARGB_8888);
Utils.matToBitmap(srcRes, bitmap);
return bitmap;

我们还需要将StitchPanorama函数声明为原生函数,这样 Android 在执行时就知道在哪里查找它:

public native int StitchPanorama(Object images[], int size, long addrSrcRes);

在此之后,在onPostExecute中,我们只需将返回的Bitmap设置为ImageView的源。这完成了我们这个项目的 Java 代码,所有主要的拼接都是使用 C++代码完成的。

C++代码

在您的jni文件夹中,创建stitcher.cpp文件。请注意,这个名字与Android.mk文件中设置的名字相同。首先,我们需要包含我们将需要的库。我们还将声明我们将使用的一些命名空间和一些全局变量,如下所示:

#include <jni.h>
#include <vector>

#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <opencv2/stitching/stitcher.hpp>
using namespace cv;
using namespace std;

char FILEPATH[100] = "/storage/emulated/0/Download/PacktBook/Chapter6/panorama_stitched.jpg";

然后,我们需要声明我们的函数并在其中编写代码。要声明函数,请编写以下代码:

extern "C" {
    JNIEXPORT jint JNICALL Java_com_packtpub_masteringopencvandroid_chapter6_StitchingActivity_StitchPanorama(JNIEnv*, jobject, jobjectArray, jint, jlong);
    JNIEXPORT jint JNICALL Java_com_packtpub_masteringopencvandroid_chapter6_StitchingActivity_StitchPanorama(JNIEnv* env, jobject, jobjectArray images, jint size, jlong resultMatAddr)
    {
        …
    }
}

省略号是代码将放置的位置的占位符。注意变量及其顺序与 Java 代码中声明的变量相比。首先,我们将初始化一些变量,并将从 Java 发送的 Mat 对象转换为 C++ Mat:

jint resultReturn = 0;
vector<Mat> clickedImages = vector<Mat>();
Mat output_stitched = Mat();
Mat& srcRes = *(Mat*)resultMatAddr, img;

在这里,我们使用了 Mat 对象的地址并将其转换为 C++ Mat 指针。接下来,我们需要将 Java 发送的 Mat 数组转换为 C++ vector。我们将使用以下代码:

jclass clazz = (env)->FindClass("org/opencv/core/Mat");
jmethodID getNativeObjAddr = (env)->GetMethodID(clazz, "getNativeObjAddr", "()J");

for(int i=0; i < size; i++){
    jobject obj = (env->GetObjectArrayElement(images, i));
    jlong result = (env)->CallLongMethod(obj, getNativeObjAddr, NULL);
    img = *(Mat*)result;
    resize(img, img, Size(img.rows/10, img.cols/10));
    clickedImages.push_back(img);
    env->DeleteLocalRef(obj);
}
env->DeleteLocalRef(images);

由于 C++代码不会自动调用垃圾回收器,并且作为移动设备,优化内存使用非常重要,因此我们需要手动删除本地对象。

现在,我们将使用 OpenCV 的拼接模块来拼接我们的图像:

Stitcher stitcher = Stitcher::createDefault();
Stitcher::Status status = stitcher.stitch(clickedImages, output_stitched);

output_stitched.copyTo(srcRes);

imwrite(FILEPATH, srcRes);

if (status == Stitcher::OK)
    resultReturn = 1;
else
    resultReturn = 0;

return resultReturn;

我们使用了默认的图像拼接设置;然而,拼接模块允许通过给予开发者更多控制来修改管道。查看可用的选项,请参阅docs.opencv.org/modules/stitching/doc/introduction.html

现在我们只需要构建我们的 C++代码文件,生成 Java 代码将用于调用 C++函数的对象文件。为此,您需要打开终端/命令提示符,然后使用cd命令将活动目录更改为<project_dir>/app/src/main/jni。现在我们需要构建我们的文件。为此,您需要使用以下命令:

<ndk_dir>/ndk-build

这将生成我们的对象文件并将它们放置在objlibs文件夹中。

这完成了我们在 Android 上使用 OpenCV 进行图像拼接的项目。您可以在以下图像中看到拼接的结果。

以下是一张示例图像:

C++代码

以下是一张第二张示例图像:

C++代码

以下是将图像拼接应用于这两张示例图像的结果:

C++代码

由于拼接模块的高内存需求,您的代码可能会崩溃。这是移动生态系统的限制,可以通过在中间包含一个服务器来执行计算来克服。您可以修改应用程序的源代码,将图像发送到服务器,服务器随后执行拼接并返回拼接结果,该结果可以在应用程序中显示。

摘要

在本章中,我们了解了全景图像是如何拼接的。我们通过寻找单应性、使用 RANSAC 以及整体图像拼接来观察图像对齐。我们还看到了如何使用 OpenCV 在 Android 中实现它。这些图像对齐技术也可以用于视频稳定化。在下一章中,我们将探讨如何利用机器学习算法来自动化一些通常需要人类在场才能完成的复杂任务。

第七章. 使用 OpenCV 机器学习让您的应用生动起来

在我们周围有如此多的数据,我们需要更好的系统和应用程序来处理它们,并从中提取相关信息。处理这一问题的计算机科学领域是机器学习。在本章中,我们将探讨可以用来利用我们周围的所有数据并构建能够处理未遇到的情况或场景的智能应用程序的不同机器学习技术,而不需要任何形式的人类干预。

近年来,计算机视觉和机器学习之间形成了强大的协同作用,从而使得一些极其高效和有用的技术得以实现。类人机器人、机械臂和装配线是计算机视觉和机器学习应用的一些例子。开发者和研究人员现在正试图利用移动平台构建轻量级的应用程序,供普通人使用。在下一节中,我们将使用标准的 OpenCV 和 Android API 构建一个用于光学字符识别OCR)的应用程序。在结尾部分,我们将回顾我们在第二章中开始开发的应用程序——图像中的基本特征检测

我们将在构建应用程序的同时理解机器学习技术。

光学字符识别

光学字符识别OCR)是计算机视觉和机器学习中最受欢迎的研究主题之一。对于 OCR,有许多现成的有效实现和算法可供选择,但为了更好地理解概念,我们将构建自己的 OCR Android 应用程序。在我们开始编写应用程序的代码之前,让我们花一些时间来了解一下不同的字符识别技术及其工作原理。在本章中,我们将使用两种标准的机器学习技术:k 最近邻KNN)和支持向量机SVM),来构建我们的应用程序。

本章的目标是构建一个实时数字识别应用程序。该应用程序将在移动屏幕上显示实时摄像头输出,一旦摄像头捕捉到一个数字,我们就会识别该数字。

使用 k 最近邻进行 OCR

k 最近邻是用于监督分类的最简单算法之一。在 KNN 中,我们提供训练数据集及其相应的标签作为输入。创建一个 n 维空间(其中n是每个训练数据的长度),并将每个训练数据点绘制在该空间中。在分类过程中,我们将要分类的数据绘制到相同的 n 维空间中,并计算该点与空间中其他点的距离。计算出的距离用于为测试数据找到一个合适的类别。

下面是算法工作步骤的逐步解释:

  1. 选择用户定义的k值。

  2. 将训练数据及其类别以行向量的形式存储。

  3. 将输入查询(待分类的数据)与训练数据中的每个行向量之间的距离计算出来(距离的含义将在下面的框中解释)。

  4. 按照查询数据(在上一步计算的距离)的升序对所有的行向量进行排序。

  5. 最后,从第一个k个排序后的行向量中选择具有多数行向量的类别(训练标签),作为预测类别。

注意

向量之间的距离

在欧几里得空间中,我们定义两个向量之间的距离如下:

使用 k 最近邻进行 OCR

其中,xiyi分别是两个向量xy的第i个维度值。n是训练向量(在我们的例子中是xy)的长度。该算法不对我们可以使用的距离类型施加任何限制。我们可以使用的其他一些距离类型包括曼哈顿距离、最大距离等。有关其他距离定义,请参阅en.wikipedia.org/wiki/Distance

简单到极点!我们如何使用它来处理图像数据?为了能够使用 KNN 处理图像数据,我们需要将训练图像转换为某种行向量。

考虑一个 10x10 的灰度图像,该图像可以是 1 到 9 中的任意一个数字。从 10x10 图像中获取特征向量的最简单和最快的方法是将它转换成一个 1x100 的行向量。这可以通过将图像中的行依次连接起来完成。这样,我们可以将训练集中的所有图像转换为行向量,以便以后在 KNN 分类器中使用。

为了使我们能够构建数字识别应用程序,我们将将其分解为以下列出的较小部分,并最终将它们组合在一起:

  • 制作相机应用程序

  • 处理训练数据

  • 识别捕获的数字

制作相机应用程序

我们将首先构建一个简单的相机应用程序,该程序在屏幕上显示相机输出,就像我们在第四章中做的那样,深入对象检测 - 使用级联分类器

  • 在 Eclipse(或 Android Studio)中创建一个新的 Android 项目

  • 在项目中初始化 OpenCV(参考第一章,应用图像效果)。

  • 使用以下代码片段将JavaCameraView添加到主活动:

    <org.opencv.android.JavaCameraView
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:visibility="gone"
            android:id="@+id/java_surface_view"
            opencv:camera_id="any" />
    

一旦相机就位,在屏幕上画一个方框,这将帮助用户定位他/她想要识别的数字。用户将指向该数字并尝试将其带入屏幕上画出的方框内(如图 1 所示)。将以下代码片段复制到 Mat onCameraFrame()函数中:

Mat temp = inputFrame.rgba();

Core.rectangle(temp, new Point(temp.cols()/2 - 200, temp.rows() / 2 - 200), new Point(temp.cols() / 2 + 200, temp.rows() / 2 + 200), new Scalar(255,255,255),1);

在此代码中,我们取移动摄像头捕获的帧的大小,并在图像中心绘制一个 400x400(可根据屏幕大小调整)的白色矩形(如图 1 所示)。就这样。相机应用就准备好了。接下来,是处理应用中的训练数据。

制作相机应用

图 1. 相机应用截图

处理训练数据

这是应用程序中最复杂的一部分。训练数据在任何机器学习应用中都起着至关重要的作用。此类应用处理的数据量通常在几兆字节左右。对于普通桌面应用来说,这可能不是问题,但对于移动应用(由于资源限制),即使正确处理约 50 兆字节的数据也可能导致性能问题。代码需要简洁、直接,并且应尽量减少内存泄漏。

对于此应用,我们将使用公开可用的手写数字数据集——MNIST 来训练 KNN 分类器。

注意

从此页面提供的 MNIST 手写数字数据库 (yann.lecun.com/exdb/mnist/) 包含 60,000 个示例的训练集和 10,000 个示例的测试集。它是 MNIST 可用的大集合的一个子集。数字已被尺寸归一化并居中在固定大小的图像中。(文本摘自 Yann LeCun 教授的网页,该网页可在 yann.lecun.com 上找到。)

首先,使用以下链接下载 MNIST 训练数据:

提取下载的文件并将它们传输到 Android 手机上(确保你有大约 60 MB 的可用空间)。

回到我们的应用,创建一个新的 DigitRecognizer 类,该类将处理与数字识别相关的所有任务,包括将数据集加载到应用中、训练分类器以及最终识别数字。将一个新的 Java 类添加到项目中,并将其命名为 DigitRecognizer

因此,我们已经在手机中存储了训练图像和训练标签。我们需要将这些数据加载到应用中。为此,我们只需从这些文件中读取数据,并使其与 OpenCV 的 API 兼容。

在之前创建的 DigitRecognizer 类中添加一个新的函数 void ReadMNISTData()。这个函数将读取 MNIST 数据集并以 Mat(OpenCV 存储图像的类)的形式存储。将数据集分为两部分读取:首先,训练图像,然后是训练标签。

ReadMNISTData()中,创建一个新的File对象,它将存储手机 SD 卡的路径(如下所示)。如果文件在手机的内部存储中,则跳过此步骤,并提供我们希望在代码中稍后使用的文件的绝对路径:

File external_storage = Environment.getExternalStorageDirectory();

完成这些操作后,创建另一个File对象,它将指向我们希望在应用程序中读取的确切文件,以及一个InputStreamReader对象,它将帮助我们读取文件:

File mnist_images_file = new File(external_storage, images_path);

FileInputStream images_reader = new FileInputStream(mnist_images_file);

在这里,images_pathtrain-images-idx3-ubyte.idx3训练图像文件的绝对路径。

在我们继续编写代码之前,我们需要了解图像是如何存储在文件中的。以下是训练图像文件内容的描述:

[offset] [type]          [value]          [description] 
0000     32 bit integer  0x00000803(2051) magic number 
0004     32 bit integer  60000            number of images 
0008     32 bit integer  28               number of rows 
0012     32 bit integer  28               number of columns 
0016     unsigned byte  ??                pixel 
0017     unsigned byte   ??               pixel 
........ 
xxxx     unsigned byte   ??               pixel

像素按行组织。像素值从 0 到 255,其中 0 表示背景(白色),255 表示前景(黑色)。

有了这些信息,我们可以继续编写代码:

Mat training_images = null;

try{
            //Read the file headers which contain the total number of images and dimensions. First 16 bytes hold the header
            /*
            byte 0 -3 : Magic Number (Not to be used)
            byte 4 - 7: Total number of images in the dataset
            byte 8 - 11: width of each image in the dataset
            byte 12 - 15: height of each image in the dataset
            */

            byte [] header = new byte[16];
            images_reader.read(header, 0, 16);

            //Combining the bytes to form an integer
            ByteBuffer temp = ByteBuffer.wrap(header, 4, 12);
            total_images = temp.getInt();
            width = temp.getInt();
            height = temp.getInt();

            //Total number of pixels in each image
            int px_count = width * height;
            training_images = new Mat(total_images, px_count, CvType.CV_8U);

            //images_data = new byte[total_images][px_count];
            //Read each image and store it in an array.

            for (int i = 0 ; i < total_images ; i++)
            {
                byte[] image = new byte[px_count];
                images_reader.read(image, 0, px_count);
                training_images.put(i,0,image);
            }
            training_images.convertTo(training_images, CvType.CV_32FC1);
            images_reader.close();
        }
        catch (IOException e)
        {
            Log.i("MNIST Read Error:", "" + e.getMessage());
        }

在前面的代码中,首先读取文件的前 16 个字节,这些字节存储了图像的数量、高度和宽度(参考上述描述文件内容的表格)。通过组合四个字节,每个整数一个字节,使用ByteBuffer类从 16 个字节中获取四个整数。

在 OpenCV 中,KNN 的实现要求我们使用 Mat 类传递所有特征向量。每个训练图像都需要被转换为一个行向量,这将形成 Mat 对象的行,然后传递给 KNN 分类器。例如,如果我们有 5,000 个训练图像,每个图像的尺寸为 20x20,我们需要一个 5000x400 维度的 Mat 对象,可以传递给 OpenCV 的 KNN 训练函数。困惑了吗?继续阅读!

从训练数据集中取一个 20x20 的图像,并将其通过逐行追加的方式转换为 1x400 的向量。对所有图像都这样做。最后,我们将拥有 5,000 个这样的 1x400 向量。现在,创建一个新的 Mat 对象,其维度为 5000x400,这个新 Mat 对象的每一行都将是我们刚才通过调整数据集中原始图像的大小而获得的 1x400 向量。

这就是前面那段代码想要做的。首先,使用以下代码读取图像中的所有像素:

byte[] image = new byte[px_count];
images_reader.read(image, 0, px_count);

在这里,px_count是训练图像中像素的总数,image是一个行向量,用于存储图像。如前所述,我们需要将这些行向量复制到 Mat 对象中(training_images指的是将用于存储这些训练图像的 Mat 对象)。按照以下方式将image行向量复制到training_images中:

training_images.put(i,0,image);

训练数据已经就绪。我们现在需要它们的对应标签。正如我们为训练图像所做的那样,它们的对应标签(标签值从 0 到 9)也可以以相同的方式读取。labels文件的内容按以下方式排列:

[offset] [type]         [value]          [description] 
0000     32 bit integer  0x00000801(2049) magic 
  number (MSB first)
0004     32 bit integer  60000            number of items 
0008     unsigned byte   ??               label 
0009     unsigned byte   ??               label 
........ 
xxxx     unsigned byte   ??               label

下面是读取标签的代码:

//Read Labels
        Mat training_labels = null;

        labels_data = new byte[total_images];
        File mnist_labels_file = new File(external_storage, labels_path);
        FileInputStream labels_reader = new FileInputStream(mnist_labels_file);

        try{

            training_labels = new Mat(total_images, 1, CvType.CV_8U);
            Mat temp_labels = new Mat(1, total_images, CvType.CV_8U);
            byte[] header = new byte[8];
            //Read the header
            labels_reader.read(header, 0, 8);
            //Read all the labels at once
            labels_reader.read(labels_data,0,total_images);
            temp_labels.put(0,0, labels_data);

            //Take a transpose of the image
            Core.transpose(temp_labels, training_labels);
            training_labels.convertTo(training_labels, CvType.CV_32FC1);
            labels_reader.close();
        }
        catch (IOException e)
        {
            Log.i("MNIST Read Error:", "" + e.getMessage());
        }

上述代码的基础与用于读取图像的代码类似。

我们已经成功地将训练数据加载到我们的应用程序中。此时,你可以使用 Android 的某些诊断工具来检查应用程序的内存使用情况。你需要注意的一个重要点是不要重复数据。这样做会增加内存消耗量,这可能会影响你应用程序的性能以及手机上运行的其他应用程序的性能。将 training_imagestraining_labels Mat 对象传递给 OpenCV 的 KNN 分类器对象:

knn = new CvKNearest();
knn.train(training_images, training_labels, new Mat(), false, 10, false);

KNN 分类器已经准备好了。我们现在可以开始对数据进行分类了。

识别数字

这是应用程序的最后一部分。在这里,我们将从摄像头捕获的帧作为分类器的输入,并允许分类器预测帧中的数字。

首先,在上一节创建的 DigitRecognizer 类中添加一个新的函数 void FindMatch(),如下所示:

void FindMatch(Mat test_image)
    {

        //Dilate the image
        Imgproc.dilate(test_image, test_image, Imgproc.getStructuringElement(Imgproc.CV_SHAPE_CROSS, new Size(3,3)));
        //Resize the image to match it with the sample image size
        Imgproc.resize(test_image, test_image, new Size(width, height));
        //Convert the image to grayscale
        Imgproc.cvtColor(test_image, test_image, Imgproc.COLOR_RGB2GRAY);
        //Adaptive Threshold
        Imgproc.adaptiveThreshold(test_image,test_image,255,Imgproc.ADAPTIVE_THRESH_MEAN_C, Imgproc.THRESH_BINARY_INV,15, 2);

        Mat test = new Mat(1, test_image.rows() * test_image.cols(), CvType.CV_32FC1);
        int count = 0;
        for(int i = 0 ; i < test_image.rows(); i++)
        {
            for(int j = 0 ; j < test_image.cols(); j++) {
                test.put(0, count, test_image.get(i, j)[0]);
                count++;
            }
        }

        Mat results = new Mat(1, 1, CvType.CV_8U);

        knn.find_nearest(test, 10, results, new Mat(), new Mat());
        Log.i("Result:", "" + results.get(0,0)[0]);

    }

注意

注意:训练数据集中的图像是 28x28 的二进制图像。

摄像头输出不能直接使用。我们需要对图像进行预处理,使其尽可能接近训练数据集中的图像,以便我们的分类器给出准确的结果。

执行以下步骤(最好是按相同顺序)以使摄像头输出可用于 KNN 分类器:

  1. 扩展图像以使数字在图像中更加突出,并减少任何背景噪声。

  2. 将图像调整大小为 28x28。训练图像也是这个尺寸。

  3. 将图像转换为灰度图像。

  4. 对图像执行自适应阈值以获得二值图像。

注意

这里使用的所有参数都受光照条件的影响。请根据您的环境调整这些参数以获得最佳结果。

在完成这些步骤后,我们将有一个需要放入我们在上一节中训练的 KNN 分类器的测试。在这之前,还需要对测试图像进行一项操作——将图像转换为行向量(记得我们对训练图像所做的转换?)。将 28x28 的测试图像转换为 1x784 的行向量。使用以下代码片段进行转换:

Mat test = new Mat(1, test_image.rows() * test_image.cols(), CvType.CV_32FC1);
int count = 0;
for(int i = 0 ; i < test_image.rows(); i++)
{
    for(int j = 0 ; j < test_image.cols(); j++) {
        test.put(0, count, test_image.get(i, j)[0]);
        count++;
    }
}

最后,将转换后的 test 图像传递给 KNN 分类器,并将结果存储在 1x1 Mat 对象 results 中。find_nearest 函数中的最后两个参数是可选的:

knn.find_nearest(test, 10, results, new Mat(), new Mat());

最后一件事情,我们何时以及如何调用 FindMatch 函数?由于我们正在构建一个实时数字识别应用程序,我们需要在摄像头的每一帧输出上执行匹配操作。因此,我们需要在主活动类的 onCameraFrame() 中调用这个函数。函数最终应该看起来像这样:

public Mat onCameraFrame(CvCameraViewFrame inputFrame) {

        //Get image size and draw a rectangle on the image for reference
        Mat temp = inputFrame.rgba();
        Core.rectangle(temp, new Point(temp.cols()/2 - 200, temp.rows() / 2 - 200), new Point(temp.cols() / 2 + 200, temp.rows() / 2 + 200), new Scalar(255,255,255),1);
        Mat digit = temp.submat(temp.rows()/2 - 180, temp.rows() / 2 + 180, temp.cols() / 2 - 180, temp.cols() / 2 + 180).clone();
        Core.transpose(digit,digit);
        mnist.FindMatch(digit);

        return temp;
    }

我们从摄像头的 RGBA 输出中提取出我们在屏幕上之前画出的矩形所包围的图像部分。我们希望用户将数字放入矩形内,以便成功识别。

由于我们的应用程序是为横幅模式编写的(在AndroidManifest.xml文件中设置),但我们使用的是竖屏模式,因此在我们运行识别算法之前需要转置测试图像。因此,运行以下命令:

Core.transpose(digit,digit);

我们已经成功创建了一个实时数字识别应用程序。让我们看看另一种可以用于识别数字的机器学习技术。

使用支持向量机进行 OCR

支持向量机SVMs)是常用的监督学习算法,常用于分类和回归。在 SVMs 中,训练数据被无限超平面划分为不同的区域,每个区域代表一个类别。为了测试数据,我们在与训练点相同的空间中绘制点,并使用超平面计算测试点所在的区域。SVMs 在处理高维数据时非常有用。

注意

关于 SVMs 的详细信息,您可以参考www.support-vector-machines.org/

在本节中,我们将学习如何使用 SVMs 进行数字识别。与 KNN 一样,为了训练 SVM,我们将直接使用训练图像,而不进行任何图像处理或检测额外特征。

注意

而不是直接使用训练图像,我们可以从图像中提取一些特征,并使用这些特征作为 SVM 的训练数据。一个用 Python 实现的 OpenCV 教程采用了不同的路径。在这里,他们首先使用仿射变换来校正图像,然后计算方向梯度直方图(Histogram of Orientation Gradients)。这些 HoG 特征被用来训练 SVM。我们不遵循相同路径的原因是因为计算仿射变换和 HoG 所涉及的计算成本。

在我们之前构建的应用程序中使用 SVM 而不是 KNN 只涉及轻微的修改。基本的相机应用程序和处理训练数据保持不变。唯一需要修改的是数字识别部分,在那里我们训练分类器。

ReadMNISTData()函数中,我们不会创建 KNN 分类器对象,而是创建一个 SVM 对象。删除以下声明和初始化 KNN 对象的行:

knn = new CvKNearest();
knn.train(training_images, training_labels, new Mat(), false, 10, false);

现在,用以下行替换它们(声明和初始化一个 SVM 对象):

svm = new CvSVM();
svm.train(training_images, training_labels);

SVM 分类器现在已经准备好了。KNN 分类器的下一步是将测试图像传递给分类器并检查结果。为此,我们需要修改FindMatch()函数。将使用 KNN 进行分类的行替换为使用 SVM 的适当行。

注意

用户可以在前面的应用程序中采用的一种优化是,他们可以将训练好的分类器保存到设备上的文件中。这将节省重复训练分类器的时间。

让我们看看以下命令:

knn.find_nearest(test, 10, results, new Mat(), new Mat());

我们需要将前面的命令替换为以下命令:

svm.predict(test);

这就结束了。我们的应用程序已经准备好了。我们可以运行应用程序,检查结果,并可能比较在什么条件下哪个算法运行得更好。

解决数独谜题

记得第二章 检测图像的基本特征 中提到的数独谜题项目吗?现在是重新审视这个项目的完美时机,看看我们是否可以用本章学到的任何东西来完成这个应用程序。所以,在第二章 检测图像的基本特征 中,我们成功检测到了数独谜题。在那个应用程序中,只剩下两件事要做:识别数字和解决数独谜题。

识别谜题中的数字

让我们从第二章 检测图像的基本特征 中我们留下的地方继续前进。在成功检测到网格后,我们需要进一步将网格分解成 81 个小方格。有许多可能的方法来做这件事,但在这里,我们只会查看三种技术。

首先,最容易的方法是在图像上绘制九条等距的垂直和水平线,并假设数字将被放置在这些线形成的方框内。

识别谜题中的数字

图 2. 数独网格上绘制的垂直和水平线

第二种方法是使用霍夫线。在数独网格上应用霍夫线,并存储返回的所有线。理想情况下,应该返回九条垂直线和九条水平线,但这种情况发生的可能性非常小,除非你有一个非常好的相机和完美的照明条件。可能会有缺失或不完整的线,这会降低应用程序的性能或可能导致错误的结果。

第三种方法是使用角点检测。运行任何角点检测算法,并获取图像中的所有角点。这些角点代表了包围数字的方框的顶点。一旦你有了所有角点,你可以连接四个角来形成一个方框,并提取该图像部分。

之前提到的方法并不总是能保证完美的结果。不同的技术可能在不同环境和使用的相机类型下表现更好。

使用之前提到的任何技术提取所有 89 张图像,并将它们通过一个预训练的数字分类器——SVM 或 KNN(如前几节所示)进行分类。完成!将分类器的输出转换为你的代码中的 9x9 整数矩阵,并用从网格中识别出的相应数字填充它。现在我们有了这个网格。使用任何暴力破解或人工智能算法来获取数独谜题的正确解决方案。可以使用的不同算法如下:

  • 回溯法

  • 遗传算法

  • 数独作为约束问题

有关这些算法的详细解释,请参阅en.wikipedia.org/wiki/Sudoku_solving_algorithms

摘要

在本章中,我们探讨了如何通过将机器学习融入其中来使应用程序变得智能。我们讨论了支持向量机(Support Vector Machines)和 KNN(K-Nearest Neighbors),以及我们如何使用它们来构建能够从用户输入数据中学习模式的应用程序。到目前为止,我们已经详细介绍了许多计算机视觉算法及其实现。在下一章中,我们将探讨在构建此类应用程序时常见的一些错误,以及一些有助于使应用程序更高效的最佳实践。

第八章. 故障排除和最佳实践

错误是开发周期中不可避免的一部分——无论是网站还是移动应用程序。有时它们是逻辑错误、语法错误,甚至是粗心大意的错误。花费大量时间进行调试或纠正错误可能会分散你的注意力,并显著影响你的生产力。在本章中,我们将讨论开发者在构建应用程序时面临的一些常见错误。这可以显著减少调试代码所花费的时间。此外,构建高效的应用程序非常重要。本章的后半部分将讨论一些可以提高应用程序性能的指导方针。

故障排除错误

本节讨论开发者在构建 Android 应用程序时可能遇到的不同可能的错误,例如权限错误,以及如何使用 Logcat 调试代码。

权限错误

在 Android 生态系统中,每个应用程序在执行任何涉及用户数据的临界操作(例如使用互联网或摄像头等)之前都需要用户的权限,仅举几个例子。为了确保这一点,应用程序开发者(在这种情况下,即我们)必须请求用户权限以执行任何临界操作。开发者通过在 Android 项目中声明所有必需的权限来在构建应用程序时完成此操作(更多细节将在以下页面中解释)。当从 Play 商店或其他方式安装应用程序时,用户会被提示授予或拒绝应用程序所需的权限。

只有当用户授予所有权限后,应用程序才能在移动设备上安装。这样,用户就会了解应用程序将要使用所有任务、服务和功能,例如使用互联网或在您的手机内存中存储数据。

Android 是如何确保所有必要的权限都已授予的呢?开发者很可能在构建应用程序时忘记声明一些权限。为了处理这种情况,Android 提供了一系列预定义的任务,这些任务在执行之前需要用户权限。在生成应用程序的 APK 时,代码会检查所有这些任务以及相应的权限是否由开发者声明。一旦代码通过这一测试,就会生成一个可用的 APK,可以用于在任意 Android 手机上安装应用程序。甚至在生成 APK 之前,即在构建应用程序的过程中,如果未声明对应任务的权限,调试器会抛出系统异常,并强制应用程序关闭。

所以,关于权限的工作原理就讲到这里,那么你应该如何以及在哪里声明这些权限,以及构建与计算机视觉相关或其它相关应用程序时需要的一些常见权限有哪些呢?

小贴士

如果你已经知道如何声明权限,你可以跳过这部分内容,直接进入下一节,即常用权限部分。

在 Android 项目的 AndroidManifest.xml 文件中使用 <uses-permission> 标签声明应用程序的权限。例如,如果应用程序需要连接到互联网,则相应的权限应写作如下:

<uses-permission android:name="android.permission.INTERNET"/>

最终的 AndroidManifest.xml 文件应该看起来像这样:

<manifest 
    package="com.example.Application">

    <application android:allowBackup="true" android:label="@string/app_name"
        android:icon="@mipmap/ic_launcher" android:theme="@style/AppTheme">

        <activity
            android:name="com.example.Application.MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>
    <uses-permission android:name="android.permission.INTERNET"/>
</manifest>

注意

注意:权限是在 <application> 标签内添加的,而不是在 <activity> 标签内。

声明此权限后,你的应用程序将能够使用手机的互联网连接。

注意

有关系统和用户权限的更多信息,请参阅 developer.android.com/guide/topics/security/permissions.html

现在我们来讨论一些 Android 应用程序可能需要的常见权限。

一些常用权限

以下是一些在构建应用程序时常用的权限:

  • 使用互联网的权限:当应用程序想要访问互联网或创建任何网络套接字时,需要此权限:

    <uses-permission android:name="android.permission.INTERNET"/>
    
  • 读写外部存储:当应用程序想要从手机的内部存储或 SD 卡中读取数据时,需要这些权限:

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    
  • 访问设备摄像头:当应用程序想要访问设备摄像头来拍照或录制视频时,需要此权限:

    <uses-permission android:name="android.permission.CAMERA"/>
    
  • 设置屏幕方向:当应用程序想要将屏幕方向从横屏切换到竖屏,或反之时,需要此权限:

    <uses-permission android:name="android.permission.SET_ORIENTATION"/>
    
  • 读取日志:这允许应用程序读取低级系统日志文件。在调试应用程序时,这非常有帮助:

    <uses-permission android:name="android.permission.READ_LOGS"/>
    

这些是一些常见的所需权限。根据应用程序的需求,还需要一些其他权限,例如使用 NFC、蓝牙、清除缓存文件等。

使用 Logcat 调试代码

如前所述,调试代码是开发周期的重要组成部分,拥有一个使调试更容易的工具是再好不过了。Logcat 就是这样一个工具,它可以帮助你在代码中添加类似打印的语句来检查变量值或某些函数的输出。由于 Android 应用程序是在手机上而不是在电脑上运行,因此调试 Android 应用程序比较困难。

Android 中的 Log 类可以帮助你将消息打印到 Logcat。它还提供了不同的日志记录方法,如 verbosewarndebugerrorinformation。以下是将日志记录到 Logcat 的方法定义:

v(String, String) (verbose)
d(String, String) (debug)
i(String, String) (information)
w(String, String) (warning)
e(String, String) (error)

以下代码展示了如何使用 Log 类的示例。此代码摘自 developer.android.com/tools/debugging/debugging-studio.html

import android.util.Log;
public class MyActivity extends Activity {
    private static final String TAG = MyActivity.class.getSimpleName();
    ...
    @Override
    public void onCreate(Bundle savedInstanceState) {
        if (savedInstanceState != null) {
            Log.d(TAG, "onCreate() Restoring previous state");
            /* restore state */
        } else {
            Log.d(TAG, "onCreate() No saved state available");
            /* initialize app */
        }
    }
}

注意

更多关于 Logcat 和Log类的信息,请参阅developer.android.com/tools/debugging/debugging-log.html

最佳实践

移动平台不如个人计算机强大,因此开发者在为移动设备构建应用程序时需要格外小心。糟糕的代码可以使你的应用程序变得缓慢,因此,在编写代码时,必须考虑到移动设备的资源限制,例如有限的 RAM、有限的处理能力和小的缓存大小。

这里有一些可能影响应用程序性能的事项,在构建应用程序时应注意:

  • 内存泄漏:在代码中正确管理变量非常重要。因为大多数代码是用 Java 编写的,开发者不需要在处理内存上花费太多时间,因为 Java 会明确地处理。当使用 C/C++时,处理代码中的变量变得极其重要。

  • 重复数据:在处理使用数据集来训练机器学习算法的应用程序中的大量数据时,我们应该避免在不同形式中有多个相同数据的副本。例如,如果我们有一个以 Mat 对象形式存在的图像,并且将这个对象复制到一个二维整数数组中,那么我们应该确保删除 Mat 对象,因为它不再需要并且无用地占用空间。这样做不仅有助于你的应用程序,还有助于在后台运行的其他应用程序。更多的空闲缓存空间——可以运行更多后台进程的数量。

  • 网络使用:这同样是一个非常重要的点。许多应用程序需要通过互联网从中央服务器或甚至与其他手机交换数据。为了两个原因,最小化这些设备之间交换的数据量变得非常重要:首先,需要传输的数据量越少,传输时间就越快。这将使应用程序响应更快,数据使用量也会更少(有时数据使用量可能非常昂贵)。其次,这将减少你的移动设备消耗的电量。

  • 有限的计算能力:避免不必要的冗余计算。例如,如果你的应用程序在多次迭代中对数组进行一些计算,并且一些计算在不同迭代中重复,尝试将这些计算合并并存储在临时变量中,以便在多次迭代中使用(无需再次计算结果)。这里需要注意的一个重要问题是计算能力和内存容量之间的权衡。可能无法存储在应用程序中可能再次重用的每个计算。这很大程度上取决于应用程序的设计。

上述列表并不全面。在构建应用程序时,还有许多其他重要的事情需要考虑,例如处理图像(对于多媒体应用程序)、在活动之间传输数据以及在你的移动设备和服务器(云基础设施)之间分配工作,这些问题将在以下页面中详细讨论。

处理 Android 中的图像

你是否曾经想过 Android 应用程序是如何能够加载如此多的图像同时还能保持流畅运行的呢?在本节中,我们将探讨如何将图像加载到我们的应用程序中并处理它们,而不会影响应用程序的性能。

加载图像

在许多应用程序中,我们需要从手机的内存中加载图像;例如,在照片编辑器或包含大量缩略图的活动等应用程序中。这样做的问题是需要加载这些图像到应用程序中的内存量。很多时候,即使是ImageView控件也因为内存限制而无法加载图像。因此,为了避免此类问题,在加载之前减小图片的大小总是更好的,Android API 为你提供了实现这一点的简单方法。

以下是在将图像加载到应用程序之前用于压缩或减小图像大小的代码:

public static int calculateInSampleSize(
            BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // Raw height and width of image
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;

    if (height > reqHeight || width > reqWidth) {

        final int halfHeight = height / 2;
        final int halfWidth = width / 2;

        // Calculate the largest inSampleSize value that is a power of 2 and keeps both
        // height and width larger than the requested height and width.
        while ((halfHeight / inSampleSize) > reqHeight
                && (halfWidth / inSampleSize) > reqWidth) {
            inSampleSize *= 2;
        }
    }

    return inSampleSize;
}

处理图像

市面上有许多多媒体应用程序,为用户提供从更改图像亮度、裁剪、调整大小等多种选项。对于此类应用程序来说,高效处理图像非常重要,这意味着这不应该影响用户体验,应用程序也不应该运行缓慢。为了避免这些问题,Android 允许开发者创建除主 UI 线程之外的其他线程,这些线程可以用于在后台执行计算密集型任务。这样做不会影响应用程序的 UI 线程,也不会使应用程序看起来运行缓慢。

在非 UI 线程上卸载计算的一个简单方法是使用ASyncTasks。以下是一个说明如何使用ASyncTasks的示例。(此代码摘自developer.android.com/training/displaying-bitmaps/process-bitmap.html):

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    private final WeakReference<ImageView> imageViewReference;
    private int data = 0;

    public BitmapWorkerTask(ImageView imageView) {
        // Use a WeakReference to ensure the ImageView can be garbage collected
        imageViewReference = new WeakReference<ImageView>(imageView);
    }

    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        data = params[0];
        return decodeSampledBitmapFromResource(getResources(), data, 100, 100));
    }

    // Once complete, see if ImageView is still around and set bitmap.
    @Override
    protected void onPostExecute(Bitmap bitmap) {
        if (imageViewReference != null && bitmap != null) {
            final ImageView imageView = imageViewReference.get();
            if (imageView != null) {
                imageView.setImageBitmap(bitmap);
            }
        }
    }
}

处理多个活动之间的数据

在本节中,我们将探讨在多个活动之间以高效方式共享数据的不同方法。实现这一目标有多种方式,每种方法都有其优缺点。

这里有一些在活动之间交换数据的方法:

  • 通过 Intent 传输数据

  • 使用静态字段

  • 使用数据库或文件

通过 Intent 传输数据

这是 Android 中在活动之间交换信息最常见的方法之一。

在 Android 中,使用 Intent 类启动新的活动。Intent 类允许你将数据作为键值对作为额外的数据发送给正在启动的活动。以下是一个演示此功能的示例:

public void launchNewActivity () {
  Intent intent = new Intent(this, NewActivity.class);
  intent.putExtra("Message", "Sending a string to New Activity");
  startActivity(intent);
}

在前面的代码中,NewActivity 是正在启动的新活动的名称。putExtra 函数分别将键和值作为第一个和第二个参数。

下一步是在启动的活动中检索数据。执行此操作的代码如下:

Intent intent = getIntent();
String message = intent.getStringExtra("Message");

getStringExtra 函数获取函数中作为参数传递的键对应的值;在这种情况下,Message

使用静态字段

在 Android 中,另一种在活动之间交换数据的方法是使用静态字段。使用静态字段的主要思想是它们在整个程序的生命周期中是持久的,并且不需要任何对象来引用它们。

这里是一个使用静态字段进行数据交换的类的示例:

public class StorageClass {
  private static String data;
  public static String getData() {return data;}
  public static String setData(String data) {this.data = data;}
}

StorageClass 函数有一个静态字段 data,它将存储需要传递到新活动中的信息。

从启动活动:

StorageClass.setData("Here is a message");

在启动的活动:

String data = StorageClass.getData();

使用数据库或文件

这是交换活动之间数据最复杂的方式之一。其背后的想法是使用 SQLite 或任何其他数据库框架设置数据库,并将其用作活动之间的共享资源。这种方法需要你编写更多的代码。此外,从数据库写入和读取可能比其他提到的技术要慢。然而,当涉及到共享大量数据而不是简单的字符串或整数时,这种方法更好。这些是一些可以用于在多个活动之间高效交换数据的技巧。

摘要

本章总结了开发者在构建基于 Android 平台的计算机视觉应用程序时可能遇到的所有可能的权限和错误。我们还探讨了可以使应用程序性能更好的最佳实践。在下一章中,我们将尝试巩固迄今为止所学的一切,从头开始构建一个简单而强大的应用程序。

第九章. 开发文档扫描应用

在本章中,我们将构建一个类似于微软的 Office Lens 的文档扫描应用。这个应用可能会大幅提高生产力。例如,你可以在纸上写下笔记,然后只需点击它的图片,无需担心与设备对齐。然后,使用我们在前面章节中学到的一些算法,我们可以检测页面并仅抓取图像的这部分。

在本章中,我们将直接进入代码,并在每一步看到输出。为了了解本章结束时我们将实现什么,让我们看一下以下图。下面的图像显示了微软的 Office Lens 的实际操作截图:

开发文档扫描应用

让我们开始

首先,我们需要设置我们的 Android 项目,就像我们在前面的章节中所做的那样。我们将使用项目 ID com.masteringopencvandroid.chapter9。对于这个应用,我们不会编写任何 C++代码,因为这个任务不是非常计算密集型,它依赖于速度。然而,如果你需要,这个项目可以使用我们前面章节中使用的原生 C++代码来完成。

首先,我们将在我们的 AndroidManifest.xml 文件中声明所需的权限。我们需要这个项目的相机权限和将结果图像保存到内存的权限。因此,在清单标签中添加以下行:

<uses-permission 
        android:name="android.permission.CAMERA"/>
<uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
        <uses-permission
        android:name="android.permission.READ_EXTERNAL_STORAGE"/>

现在,我们将声明我们项目中的活动。为了演示目的,我们只需要一个活动。我们将称之为 LensActivity。因此,我们将向我们的应用程序标签中添加以下内容:

<activity
    android:name=".LensActivity"
    android:label="@string/title_activity_lens"
    android:theme="@style/AppTheme"  >
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

接下来,我们将设置我们的布局文件。我们将称之为 activity_lens.xml。我们的布局将有两个按钮:一个可以用来调用 Android 系统的相机意图,另一个将用于从文件中选择图像。然后,我们将处理系统返回的图像以检测和提取图像中的页面。它还将有一个 ImageView 标签来显示结果图像,如下面的代码所示:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <LinearLayout android:orientation="vertical" android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="0.5"
            android:id="@+id/ivImage" />
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="0.5"
                android:id="@+id/bClickImage"
                android:text="Click image"/>
            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="0.5"
                android:id="@+id/bLoadImage"
                android:text="Load image"/>
        </LinearLayout>

    </LinearLayout>
</ScrollView>

现在我们已经准备好了布局,我们可以深入到 Java 代码中。在下一节中,我们将看到算法的逐步解释。

算法

在本节中,让我们看看我们将采取的步骤以实现我们的结果。我们的第一个任务是检测纸张与背景。为此,我们将应用具有两个聚类中心的 k-means 算法。通过这两个聚类中心,我们可以检测哪一个代表页面,哪一个对应于背景,并创建一个二值图像。

现在,我们将使用代表纸张的聚类并尝试使用矩形核进行形态学开闭操作来去除一些噪声和填补一些空隙。

接下来,我们将尝试找到页面的外边界并使用它来检测角落。为此,我们将检测二值图像中的轮廓,然后识别面积最大的轮廓。

一旦我们有了最大的轮廓,我们将使用概率霍夫变换检测线条。然后,我们将连接这些线条并检测角落。

一旦我们有了角落,我们将检测哪个角落对应于哪个其他角落,然后应用透视变换以仅从整个图像中获取页面。

下图以流程图的形式展示了快速参考的步骤:

算法

这个过程的某些假设和限制是页面必须是白色的,并且必须容易与背景区分开来。

在 Android 上实现

打开LensActivity.java文件。首先,我们将声明并初始化我们的ButtonImageView。然后,我们将为按钮添加onClickListener。我们将调用ImageCapture意图,这将打开相机应用以点击图片,如下所示:

ivImage = (ImageView)findViewById(R.id.ivImage);
Button bClickImage, bLoadImage;

bClickImage = (Button)findViewById(R.id.bClickImage);
bLoadImage = (Button)findViewById(R.id.bLoadImage);

bClickImage.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new 
        Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        errorMsg = null;
        File imagesFolder = new File(FILE_LOCATION);
        imagesFolder.mkdirs();
        File image = new File(imagesFolder, "image_10.jpg");
        fileUri = Uri.fromFile(image);
        Log.d("LensActivity", "File URI = " + fileUri.toString());
        intent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri); 

        // start the image capture Intent
        startActivityForResult(intent, CLICK_PHOTO);
    }
});        
bLoadImage.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent();
        intent.setType("image/*");
        intent.setAction(Intent.ACTION_GET_CONTENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        errorMsg = null;
        startActivityForResult(intent, LOAD_PHOTO);
    }
});

现在,我们将添加接收这些意图调用结果的代码部分,由我们的活动来处理:

@Override
protected void onActivityResult(int requestCode, int 
  resultCode, Intent imageReturnedIntent) {
    super.onActivityResult(requestCode, resultCode, 
      imageReturnedIntent);

    Log.d("LensActivity", requestCode + " " + CLICK_PHOTO + " 
      " + resultCode + " " + RESULT_OK);

    switch(requestCode) {
        case CLICK_PHOTO:
            if(resultCode == RESULT_OK){
                try {
                    Log.d("LensActivity", fileUri.toString());
                    final InputStream imageStream = 
                      getContentResolver().
                      openInputStream(fileUri);
                    final Bitmap selectedImage = 
                      BitmapFactory.decodeStream(imageStream);
                    srcOrig = new Mat(selectedImage.
                      getHeight(), selectedImage.
                      getWidth(), CvType.CV_8UC4);
                    src = new Mat();
                    Utils.bitmapToMat(selectedImage, srcOrig);

                    scaleFactor = calcScaleFactor(
                      srcOrig.rows(), srcOrig.cols());

                    Imgproc.resize(srcOrig, src, new 
                      Size(srcOrig.rows()/scaleFactor, 
                      srcOrig.cols()/scaleFactor));
                    getPage();
                } catch (FileNotFoundException e) {
                    e.printStackTrace();
                }
            }
            break;
        case LOAD_PHOTO:
            if(resultCode == RESULT_OK){
                try {
                    InputStream stream = getContentResolver().
                      openInputStream(
                      imageReturnedIntent.getData());
                    final Bitmap selectedImage = 
                      BitmapFactory.decodeStream(stream);
                    stream.close();
                    ivImage.setImageBitmap(selectedImage);
                    srcOrig = new Mat(selectedImage.
                      getHeight(), selectedImage.
                      getWidth(), CvType.CV_8UC4);
                    Imgproc.cvtColor(srcOrig, srcOrig, 
                      Imgproc.COLOR_BGR2RGB);
                    Utils.bitmapToMat(selectedImage, srcOrig);
                    scaleFactor = calcScaleFactor(
                      srcOrig.rows(), srcOrig.cols());
                    src = new Mat();
                    Imgproc.resize(srcOrig, src, new 
                      Size(srcOrig.rows()/scaleFactor, 
                      srcOrig.cols()/scaleFactor));
                    Imgproc.GaussianBlur(src, src, 
                      new Size(5,5), 1);
                    getPage();
                } catch (FileNotFoundException e) {
                    e.printStackTrace();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            break;
    }
}
private static int calcScaleFactor(int rows, int cols){
    int idealRow, idealCol;
    if(rows<cols){
        idealRow = 240;
        idealCol = 320;
    } else {
        idealCol = 240;
        idealRow = 320;
    }
    int val = Math.min(rows / idealRow, cols / idealCol);
    if(val<=0){
        return 1;
    } else {
        return val;
    }
}

如您所见,我们有一个名为getScaleFactor的函数。由于手持设备的内存和处理能力有限,我们将以最大分辨率 240x320 来减小我们的图像。getPage函数是我们主要算法所在的位置。在这个函数中,我们使用AsyncTask来执行我们的计算,这样就不会阻塞我们的 UI 线程,从而防止 Android 崩溃。

首先,我们将使我们的图像以所需的形式呈现,以便进行具有两个聚类的 k-means 聚类。应用 k-means 的直觉是背景和前景将与背景截然不同,并且大部分区域将被页面占据:

Mat samples = new Mat(src.rows() * src.cols(), 3, CvType.CV_32F);
for( int y = 0; y < src.rows(); y++ ) {
    for( int x = 0; x < src.cols(); x++ ) {
        for( int z = 0; z < 3; z++) {
            samples.put(x + y*src.cols(), z, src.get(y,x)[z]);
        }
    }
}

然后,我们将如下应用 k-means 算法:

int clusterCount = 2;
Mat labels = new Mat();
int attempts = 5;
Mat centers = new Mat();

Core.kmeans(samples, clusterCount, labels, new 
  TermCriteria(TermCriteria.MAX_ITER | 
  TermCriteria.EPS, 10000, 0.0001), attempts, 
  Core.KMEANS_PP_CENTERS, centers);

现在,我们有了两个簇中心和原始图像中每个像素的标签。我们将使用两个簇中心来检测哪一个对应于纸张。为此,我们将找到两个中心颜色和纯白色颜色之间的欧几里得距离。离纯白色颜色更近的一个将被认为是前景:

double dstCenter0 = calcWhiteDist(centers.get(0, 
  0)[0], centers.get(0, 1)[0], centers.get(0, 2)[0]);
double dstCenter1 = calcWhiteDist(centers.get(1, 
  0)[0], centers.get(1, 1)[0], centers.get(1, 2)[0]);
int paperCluster = (dstCenter0 < dstCenter1)?0:1;

static double calcWhiteDist(double r, double g, double b){
    return Math.sqrt(Math.pow(255 - r, 2) + 
      Math.pow(255 - g, 2) + Math.pow(255 - b, 2));
}

我们需要定义两个 Mat 对象,我们将在下一步中使用它们:

Mat srcRes = new Mat( src.size(), src.type() );
Mat srcGray = new Mat();

现在,我们将执行一个分割,我们将显示所有前景像素为白色,所有背景像素为黑色:

for( int y = 0; y < src.rows(); y++ ) {
    for( int x = 0; x < src.cols(); x++ )
    {
        int cluster_idx = (int)labels.get(x + y*src.cols(),0)[0];
        if(cluster_idx != paperCluster){
            srcRes.put(y,x, 0, 0, 0, 255);
        } else {
            srcRes.put(y,x, 255, 255, 255, 255);
        }
    }
}

现在,我们将进行下一步;即检测图像中的轮廓。首先,我们将应用 Canny 边缘检测器来检测边缘,然后应用轮廓算法:

Imgproc.cvtColor(src, srcGray, Imgproc.COLOR_BGR2GRAY);
Imgproc.Canny(srcGray, srcGray, 50, 150);
List<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();

Imgproc.findContours(srcGray, contours, hierarchy, 
  Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE);

现在,我们假设页面占据了前景的最大部分,因此它对应于我们找到的最大轮廓:

int index = 0;
double maxim = Imgproc.contourArea(contours.get(0));

for (int contourIdx = 1; contourIdx < contours.size(); 
  contourIdx++) {
    double temp;
    temp=Imgproc.contourArea(contours.get(contourIdx));
    if(maxim<temp)
    {
        maxim=temp;
        index=contourIdx;
    }
}
Mat drawing = Mat.zeros(srcRes.size(), CvType.CV_8UC1);
Imgproc.drawContours(drawing, contours, index, new Scalar(255), 
  1);

现在,我们将检测图像中的线条,这些线条只包含最大的轮廓。我们将尝试找到这些线条的交点,并使用这个点来检测图像中纸张的角:

Mat lines = new Mat();
Imgproc.HoughLinesP(drawing, lines, 1, Math.PI/180, 70, 30, 10);

ArrayList<Point> corners = new ArrayList<Point>();
for (int i = 0; i < lines.cols(); i++)
{
    for (int j = i+1; j < lines.cols(); j++) {
        double[] line1 = lines.get(0, i);
        double[] line2 = lines.get(0, j);

        Point pt = findIntersection(line1, line2);
        Log.d("com.packtpub.chapter9", pt.x+" "+pt.y);
        if (pt.x >= 0 && pt.y >= 0 && pt.x <= 
          drawing.cols() && pt.y <= drawing.rows()){
            if(!exists(corners, pt)){
                corners.add(pt);
            }
        }
    }
}

static Point findIntersection(double[] line1, double[] line2) {
    double start_x1 = line1[0], start_y1 = line1[1], 
      end_x1 = line1[2], end_y1 = line1[3], start_x2 = 
      line2[0], start_y2 = line2[1], end_x2 = line2[2], 
      end_y2 = line2[3];
    double denominator = ((start_x1 - end_x1) * (start_y2 - 
      end_y2)) - ((start_y1 - end_y1) * (start_x2 - end_x2));

    if (denominator!=0)
    {
        Point pt = new Point();
        pt.x = ((start_x1 * end_y1 - start_y1 * end_x1) * 
          (start_x2 - end_x2) - (start_x1 - end_x1) * 
          (start_x2 * end_y2 - start_y2 * end_x2)) / 
          denominator;
        pt.y = ((start_x1 * end_y1 - start_y1 * end_x1) * 
          (start_y2 - end_y2) - (start_y1 - end_y1) * 
          (start_x2 * end_y2 - start_y2 * end_x2)) / 
          denominator;
        return pt;
    }
    else
        return new Point(-1, -1);
}

通过连接点(x1, y1)和(x2, y2)(形成第一条线),以及(x3, y3)和(x4, y4)(形成第二条线)形成的两条线的交点可以使用以下公式计算:

在 Android 上实现在 Android 上实现

如果分母为 0,我们可以说这些线是平行的。

一旦我们得到了交点,我们将尝试移除一些冗余点。为此,我们说点之间至少需要有一个 10 像素的间隔,这样它们才能被认为是不同的。当修改你正在使用的分辨率时,这个数字应该被修改。为了检查这一点,我们添加了一个名为exists的函数,如下所示:

static boolean exists(ArrayList<Point> corners, Point pt){
    for(int i=0; i<corners.size(); i++){
        if(Math.sqrt(Math.pow(corners.get(i).x-pt.x, 
          2)+Math.pow(corners.get(i).y-pt.y, 2)) < 10){
            return true;
        }
    }
    return false;
}

现在,我们将检查我们是否完美地检测到了四个角。如果没有,算法将返回一个错误信息:

if(corners.size() != 4){
    errorMsg =  "Cannot detect perfect corners";
    return null;
}

现在我们已经检测到了四个角,我们将尝试在四边形上确定它们的相对位置。为此,我们将比较每个角的位置与四边形的中心,该中心是通过取每个角坐标的平均值得到的:

static void sortCorners(ArrayList<Point> corners)
{
    ArrayList<Point> top, bottom;

    top = new ArrayList<Point>();
    bottom = new ArrayList<Point>();

    Point center = new Point();

    for(int i=0; i<corners.size(); i++){
        center.x += corners.get(i).x/corners.size();
        center.y += corners.get(i).y/corners.size();
    }

    for (int i = 0; i < corners.size(); i++)
    {
        if (corners.get(i).y < center.y)
            top.add(corners.get(i));
        else
            bottom.add(corners.get(i));
    }
    corners.clear();

    if (top.size() == 2 && bottom.size() == 2){
        Point top_left = top.get(0).x > top.get(1).x ? 
          top.get(1) : top.get(0);
        Point top_right = top.get(0).x > top.get(1).x ? 
          top.get(0) : top.get(1);
        Point bottom_left = bottom.get(0).x > bottom.get(1).x 
          ? bottom.get(1) : bottom.get(0);
        Point bottom_right = bottom.get(0).x > bottom.get(1).x 
          ? bottom.get(0) : bottom.get(1);

        top_left.x *= scaleFactor;
        top_left.y *= scaleFactor;

        top_right.x *= scaleFactor;
        top_right.y *= scaleFactor;

        bottom_left.x *= scaleFactor;
        bottom_left.y *= scaleFactor;

        bottom_right.x *= scaleFactor;
        bottom_right.y *= scaleFactor;

        corners.add(top_left);
        corners.add(top_right);
        corners.add(bottom_right);
        corners.add(bottom_left);
    }
}

在这里,我们乘以了角值的缩放因子,因为这些很可能是原始图像中角的位置。现在,我们只想得到结果图像中的纸张。我们需要确定结果图像的大小。为此,我们将使用之前步骤中计算的角坐标:

double top = Math.sqrt(Math.pow(corners.get(0).x - corners.get(1).x, 2) + Math.pow(corners.get(0).y - corners.get(1).y, 2));

double right = Math.sqrt(Math.pow(corners.get(1).x - corners.get(2).x, 2) + Math.pow(corners.get(1).y - corners.get(2).y, 2));

double bottom = Math.sqrt(Math.pow(corners.get(2).x - corners.get(3).x, 2) + Math.pow(corners.get(2).y - corners.get(3).y, 2));

double left = Math.sqrt(Math.pow(corners.get(3).x - corners.get(1).x, 2) + Math.pow(corners.get(3).y - corners.get(1).y, 2));
Mat quad = Mat.zeros(new Size(Math.max(top, bottom), Math.max(left, right)), CvType.CV_8UC3);

现在,我们需要使用透视变换来扭曲图像,以便占据整个图像。为此,我们需要创建参考角,对应于corners数组中的每个角:

ArrayList<Point> result_pts = new ArrayList<Point>();
result_pts.add(new Point(0, 0));
result_pts.add(new Point(quad.cols(), 0));
result_pts.add(new Point(quad.cols(), quad.rows()));
result_pts.add(new Point(0, quad.rows()));

注意到corners中的元素与result_pts中的顺序相同。这是为了执行适当的透视变换。接下来,我们将执行透视变换:

Mat cornerPts = Converters.vector_Point2f_to_Mat(corners);
Mat resultPts = Converters.vector_Point2f_to_Mat(result_pts);

Mat transformation = Imgproc.getPerspectiveTransform(cornerPts, 
  resultPts);
Imgproc.warpPerspective(srcOrig, quad, transformation,
  quad.size());
Imgproc.cvtColor(quad, quad, Imgproc.COLOR_BGR2RGBA);
Bitmap bitmap = Bitmap.createBitmap(quad.cols(), quad.rows(),
  Bitmap.Config.ARGB_8888);
Utils.matToBitmap(quad, bitmap);

return bitmap;

现在你有了只包含纸张的结果图像,你可以执行应用程序所需的任何更多处理。

现在我们需要做的只是将结果图像显示在ImageView中。在onPostExecute中添加以下行:

if(bitmap!=null) {
    ivImage.setImageBitmap(bitmap);
} else if (errorMsg != null){
    Toast.makeText(getApplicationContext(), 
      errorMsg, Toast.LENGTH_SHORT).show();
}

这就结束了从场景中分割出一张纸并扭曲成完美矩形的算法。您可以在以下屏幕截图中看到算法的结果:

在 Android 上实现

原始图像(L)和结果图像(R)

摘要

在本章中,我们看到了如何使用多个计算机视觉算法来完成更大的任务,并实现了一个类似于微软的 Office Lens 的系统。这个算法可以通过更好的分割和角点检测算法进行扩展和改进。此外,一旦你得到了最终图像中的页面,你就可以应用机器学习算法来检测页面上的文本。

posted @ 2025-09-23 21:57  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报