OpenCV3 安卓应用编程:1~6 全
原文:Android Application Programming with OpenCV 3
译者:飞龙
本文来自【ApacheCN 计算机视觉 译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。
当别人说你没有底线的时候,你最好真的没有;当别人说你做过某些事的时候,你也最好真的做过。
一、设置 OpenCV
本章是为 Android 和 OpenCV 设置开发环境的快速指南。 我们还将研究 OpenCV 示例应用,文档和社区。
到本章末,我们的开发环境将包括以下组件:
-
Java 开发工具包(JDK)7:此包括用于 Java 编程的工具。 JDK 7 是我们需要的确切版本。 Android 开发尚不支持最新版本的 JDK 8。
-
Cygwin 1.7 或更高版本(仅 Windows):这是一个兼容性层,可在 Windows 上提供类似 Unix 的编程工具。 我们需要它以便在 Android 上用 C++ 开发。
-
Android 软件开发工具包(Android SDK)r24.0.2 或更高版本:这包括用于用 Java 编程 Android 应用的工具。
-
Android Native Development Kit(Android NDK)r10d 或更高版本:这包括用于以 C++ 编程 Android 应用的工具。
-
Eclipse 4.4.2(Luna)或更高版本:这是一个集成开发环境(IDE)。 尽管 Google 已开始推荐 Android Studio 作为 Android 开发的 IDE,但仍然支持 Eclipse。 OpenCV 库和官方样本已预先配置为 Eclipse 项目,因此出于我们的目的,Eclipse 比 Android Studio 更加方便。
-
Java 开发工具(JDT):这是一个用于 Java 编程的 Eclipse 插件(已包含在大多数 Eclipse 发行版中)。
-
C/C++ 开发工具(CDT)8.2.0 或更高版本:这是用于 C/C++ 编程的 Eclipse 插件。
-
Android 开发工具(ADT)24.0.2 或更高版本:这是用于 Android 编程的 Eclipse 插件。
-
OpenCV4Android 3.0 或更高版本:这是 OpenCV 的 Android 版本,包括 Java 和 C++ 库。
注意
在撰写本文时,OpenCV4Android 的最新版本是 3.0。 本书以 3.0 版为目标,但同时也包含有关 OpenCV 3.x 和 OpenCV 2.x 之间差异的全面说明。 作者的网站提供了两组代码捆绑:一套用于 OpenCV 3.x(已通过 3.0 测试),另一套用于 OpenCV 2.x(已通过 2.4 测试)。
有许多可能的方法来安装和配置这些组件。 我们将介绍几种常见的设置方案,但是如果您对其他选项感兴趣,请参见 OpenCV 的官方文档。
系统要求
用于 Android 和 OpenCV 的所有开发工具都是跨平台的。 几乎相同的设置过程支持以下操作系统:
- Windows XP 或更高版本
- Mac OS 10.6(Snow Leopard)或更高版本
- Debian Wheezy 或更高版本,包括诸如 Ubuntu 12.04(Pangolin)或更高版本的衍生版本
- 许多其他类似 Unix 的系统(尽管本书没有特别介绍)
要运行 OpenCV 示例以及后来的我们自己的应用,我们应该有一个具有以下规格的 Android 设备:
- Android 2.2(Froyo)或更高版本(必需)
- 摄像头(必填):前后摄像头(推荐)
- 自动对焦(推荐)
不建议使用 Android 虚拟设备(AVD)。 OpenCV 的某些部分依赖于低级摄像机访问,并且可能因虚拟化摄像机而失败。
设置开发环境
我们将分别安装开发环境的各个组件,并将它们配置为可协同工作。 大致而言,此任务分为以下两个阶段:
- 设置通用的 Android 开发环境。
- 设置 OpenCV 以在此环境中使用。 我们可以使用预打包的,预配置的 OpenCV 版本,或者可以从源代码配置和构建 OpenCV。
让我们从查看通用 Android 开发环境的设置步骤开始。 我们不会在这里详细讨论,因为在给定的链接上有很好的说明,并且作为 Android 或 Java 开发人员,您之前可能已经经历过类似的步骤。
提示
如果您已经有一个 Android 开发环境或另一个 Java 开发环境,并且只想向其添加组件,则以下某些步骤可能不适用于您。
步骤如下:
-
从这个页面下载并安装 Oracle JDK 7。 或者,如这个页面所述,在 Debian 或 Ubuntu 上,从 WebUpd8 PPA 安装 Oracle JDK 7。 尽管大多数 Linux 发行版在其标准存储库中都包含 OpenJDK,但建议将 Oracle JDK 推荐用于 Android 开发。
-
下载 Eclipse 并将其解压缩到任何目标位置,我们将其称为
<eclipse>。 可从这个页面获得许多最新的 Eclipse 发行版。 其中,面向 Java 开发人员的 Eclipse IDE 是 Android 开发环境的基础的不错选择。 -
现在,我们需要为 Eclipse 设置 Android SDK 和 ADT 插件。 转到这个页面并仅下载 SDK 工具。 将其安装或解压缩到任何目标位置,我们将其称为
<android_sdk>。 打开 Eclipse 并根据这个页面上的官方说明安装 ADT 插件。 重新启动 Eclipse。 应该会出现一个窗口欢迎来到安卓开发。 单击使用现有的 SDK,浏览到<android_sdk>,然后单击下一步。 关闭 Eclipse。 -
从 Eclipse 菜单系统,导航到 Windows | Android SDK Manager。 根据这个页面上的官方说明,选择并安装其他 SDK 包。 特别是,我们将需要以下包的最新版本:最新的 Android API,例如 Android 5.1.1(API 22),Android SDK 工具,Android SDK 平台工具,Android SDK 生成工具和 Android 支持库 。 安装包后,关闭 Eclipse。
-
如果我们使用的是 Windows,请从这个页面下载并安装 Cygwin。
-
从这个页面下载 Android NDK。 将其解压缩到任何目标,我们将其称为
<android_ndk>。 -
编辑系统的
Path(在 Windows 中)或PATH(在 Mac,Linux 或其他类似 Unix 的系统中)以包含<android_sdk>/platform-tools,<android_sdk>/tools和<android_ndk>。 另外,创建一个名为NDKROOT的环境变量,并将其值设置为<android_ndk>。 (如果不确定如何编辑Path,PATH或其他环境变量,请参阅本页和下一页框中的提示。)提示
在 Windows 上编辑环境变量
可以在控制面板的环境变量窗口中编辑系统的
Path变量和其他环境变量。在 Windows Vista/7/8 上,打开开始菜单,然后启动控制面板。 现在,转到系统和安全性 | 系统 | 高级系统设置。 单击环境变量按钮。
在 Windows XP 上,打开开始菜单,然后转到控制面板 | 系统。 单击高级选项卡。 单击环境变量按钮。
现在,在系统变量下,选择一个现有的环境变量,例如
Path,然后单击编辑按钮。 或者,通过单击新建按钮来创建新的环境变量。 根据需要编辑变量的名称和值。 例如,如果要将C:\android-sdk\platform-tools和C:\android-sdk\tools添加到Path,则应将;C:\android-sdk\platform-tools;C:\android-sdk\tools附加到Path的现有值上。 注意使用分号作为分隔符。要应用更改,请单击所有 OK 按钮,直到回到控制面板的主窗口。 现在,注销并再次登录。
提示
在 Mac 上编辑环境变量
编辑
~/.profile。要将
~/.profile中现有的环境变量附加到,请添加诸如export PATH=$PATH:~/android-sdk/platform-tools:~/android-sdk/tools之类的行。 本示例将~/android-sdk/platform-tools和~/android-sdk/tools附加到PATH。 注意使用冒号作为分隔符。要在
~/.profile中创建新的环境变量,请添加诸如export NDKROOT=~/android-ndk的行。保存您的更改,注销,然后再次登录。
在 Linux 上编辑环境变量
编辑
~/.profile(如先前对 Mac 所述)或~/.pam_environment(如下文所述)。 请注意,~/.profile和~/.pam_environment对变量使用略有不同的格式。要附加到
~/.pam_environment中的现有环境变量,请添加一行,例如PATH DEFAULT=${PATH}:~/android-sdk/platform-tools:~/android-sdk/tools。 本示例将~/android-sdk/platform-tools和~/android-sdk/tools附加到PATH。 注意使用冒号作为分隔符。要在
~/.pam_environment.中创建新的环境变量,请添加诸如NDKROOT DEFAULT=~/android-ndk的行。保存您的更改,注销,然后再次登录。
现在我们有了一个 Android 开发环境,但是我们仍然需要 OpenCV。 我们可以选择下载 OpenCV 的预构建版本,也可以从源代码构建它。 以下两个小节将讨论这些选项。
获得预建的 OpenCV4Android
可以从这个页面下载 OpenCV4Android 的预构建版本。 查找名称中带有opencv-android的文件,例如OpenCV-3.0.0-android-sdk.zip(撰写本文时的最新版本)。 下载最新版本并将其解压缩到任何目标位置,我们将其称为<opencv>。
从源代码构建 OpenCV4Android
另外,在这个页面记录了用于从干线(最新的不稳定源代码)构建 OpenCV4Android 的流程。 有关该过程的摘要,请继续阅读本节。 否则,请跳至本章稍后的“使用 Eclipse 构建 OpenCV 示例”。
提示
由于主干包含最新的,不稳定的源代码,因此不能保证构建过程将成功。 如果要从中继进行构建,则可能需要自行进行故障排除。
要从源代码构建 OpenCV ,我们需要以下附加软件:
- Git:这是源代码管理(SCM)工具,我们将使用该获得 OpenCV 的源代码。 在 Windows 或 Mac 上,从这个页面下载并安装 Git 。 在 Linux 上,使用包管理器进行安装。 例如,在 Debian 或 Ubuntu 上,打开终端并运行
$ sudo apt-get install git-core。 - CMake:这是一组生成工具。 在 Windows 或 Mac 上,从这个页面下载并安装 CMake 。 在 Linux 上,使用包管理器进行安装。 例如,在 Debian 或 Ubuntu 上,打开终端并运行
$ sudo apt-get install cmake。 - Apache Ant 1.8.0 或更高版本:这是一套用于 Java 的构建工具。 在 Linux 上,只需使用包管理器安装 Ant。 例如,在 Debian 或 Ubuntu 上,打开终端并运行
$ sudo apt-get install ant。 在 Windows 或 Mac 上,从这个页面下载 Ant 并将其解压缩到任何目标,我们将其称为<ant>。 对您的环境变量进行以下更改:- 将
<ant>/bin添加到Path(Windows)或PATH(Unix)。 - 创建一个值为
<ant>的变量ANT_HOME。
- 将
- Python 2.6 或更高版本(但不是 3.0 或更高版本):这是某些的 OpenCV 构建脚本使用的脚本语言。 在 Mac 和大多数 Linux 系统(包括 Debian 和 Ubuntu)上预先安装了合适的 Python 版本。 在 Windows 上,从这个页面下载并安装 Python 。 如果您在系统上安装了多个版本的 Python,请确保在
Path(Windows)或PATH(Unix)中仅安装 Python 2.6 或更高版本(而不是 3.0 或更高版本)。 OpenCV 构建脚本无法在 Python 3.0 或更高版本上正常运行。
一旦具备这些先决条件,就可以将 OpenCV 源代码下载到任何位置,我们将其称为<opencv_source>。 然后,我们可以使用包含的脚本来构建它。 具体来说,我们应该采取以下步骤:
在 Windows 上,打开 Git Bash(Git 的命令提示符)。 在 Mac,Debian,Ubuntu 或其他类似 Unix 的系统上,打开终端(或其他命令行外壳)。
运行以下命令:
$ git clone git://code.opencv.org/opencv.git <opencv_source>
$ cd <opencv_source>/platforms
$ sh ./scripts/cmake_android_arm.sh
$ cd build_android_arm
$ make -j8
–j8标志指定make命令将使用 8 个线程,对于四核处理器而言,这通常是一个很好的数字。 对于双核处理器,更好的选择可能是–j4标志(4 个线程)。
如果一切顺利,我们应该在<opencv_source>/platforms/build_android_arm中获得 OpenCV4Android 的构建。 如果愿意,我们可以将其移至其他地方。 我们将其最终位置称为<opencv>。
您可能想知道cmake_android_arm.sh构建脚本在做什么。 实际上,它只是创建一个构建目录并运行 CMake 命令以使用 OpenCV 的特定配置填充该目录。 这是脚本文件的全部内容:
#!/bin/sh
cd `dirname $0`/..
mkdir -p build_android_arm
cd build_android_arm
cmake -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON - DCMAKE_TOOLCHAIN_FILE=../android/android.toolchain.cmake $@ ../..
熟悉 CMake 的高级用户可能要复制和修改此脚本以创建 OpenCV 的自定义配置。 有关 OpenCV 的 CMake 选项的定义,请参考<opencv_source>/CMakeLists.txt中的代码。
注意
前面的步骤使用cmake_android_arm.sh脚本为 ARM 生成 OpenCV4Android 构建,这是大多数 Android 手机和平板电脑的架构。 另外,您可以将cmake_android_x86.sh脚本用于 x86 或将cmake_android_mips.sh脚本用于 MIPS。 请注意,构建目录的名称也会根据架构而改变。
使用 Eclipse 构建 OpenCV 示例
构建和运行一些示例应用是测试 OpenCV 是否正确设置的一种好方法。 同时,我们可以练习使用 Eclipse。
让我们从启动 Eclipse 开始。 Eclipse 启动器应位于<eclipse>/eclipse.exe(Windows),<eclipse>/Eclipse.app(Mac)或<eclipse>/eclipse(Linux)。 运行。
我们应该看到一个名为工作空间启动器的窗口,该窗口要求我们选择一个工作区。 工作空间是一组相关 Eclipse 项目的根目录。 输入您选择的任何位置。

提示
我们可以随时通过文件 | 切换工作区 | 其他…菜单返回工作区启动器。
如果出现 Eclipse 欢迎屏幕,请单击工作分支按钮:

现在,我们应该看到一个包含几个面板的窗口,包括包浏览器。 如果不使用 TAPD,则需要将 OpenCV 示例项目导入到我们的新工作区中。 右键单击包浏览器,然后从上下文菜单中选择导入…:

导入窗口应出现。 导航至常规 | 现有项目进入工作区,然后单击下一步:

在导入窗口的第二页上,在的选择根目录中输入<opencv>字段。 在项目标签下,应显示检测到的项目的列表。 (如果没有,请单击刷新)。该列表应包括 OpenCV 库,示例和教程。 默认情况下应全部选中它们。
提示
下载示例代码
您可以从这个页面上的帐户下载示例代码文件,以获取所有购买的 Packt Publishing 图书。 如果您在其他地方购买了此书,则可以访问这个页面并注册以将文件直接通过电子邮件发送给您。
这意味着 Eclipse 已找到 OpenCV 库,示例和教程,并将其识别为 Eclipse 项目。 不要选择将项目复制到工作区,因为 OpenCV 示例和教程项目依赖于库项目的相对路径,并且如果项目被复制到工作区中,该相对路径将不会保留。 单击完成导入项目:

导入项目后,我们可能需要解决一些配置问题。 我们的开发环境可能与示例默认配置中的路径和 Android SDK 版本不同:

任何导致的错误将在问题选项卡中报告。 有关可能的解决方案,请参阅本章后面的“对 Eclipse 项目进行故障排除”。
提示
我们首先应该解决 OpenCV 库项目中的所有错误,因为示例和教程取决于该库。
一旦 OpenCV 项目不再显示任何错误,我们就可以准备在 Android 设备上对其进行测试。 回想一下,该设备必须装有 Android 2.2(Froyo)或更高版本以及一个摄像头。 要使 Eclipse 与设备通信,我们必须启用设备的 USB 调试选项。 在 Android 设备上,执行以下步骤:
- 打开设置应用。
- 在 Android 4.2 或更高版本上,转到关于手机或关于平板电脑部分,然后点击内部版本号七次。 此步骤启用开发人员选项部分。
- 转到开发人员选项部分(在 Android 4.0 或更高版本上)或应用 | 开发部分(在 Android 3.2 或更低版本上)。 启用 USB 调试选项。
现在,我们需要安装一个名为 OpenCV Manager 3 的 Android 应用,该应用将在我们运行任何 OpenCV 应用时检查 OpenCV 库更新。 在撰写本文时,Play 商店尚未提供 OpenCV Manager 3。 但是,在我们的开发环境的<opencv>/apk文件夹中,我们可以找到各种架构的预构建应用捆绑包(.apk文件)。 选择名称与您的 Android 设备架构匹配的.apk文件。 在撰写本文时,ARMv7-A 是适用于 Android 设备的流行架构。 对于此架构,OpenCV 3.0 提供了OpenCV_3.0.0_Manager_3.00_a.apk文件。 打开命令提示符并输入如下命令,以通过 USB 将相应的.apk安装到您的 Android 设备上:
$ adb install <opencv>/apk/OpenCV_3.0.0_Manager_3.00_armeabi-v7a.apk
如果安装成功,终端应打印Success。
提示
支持 OpenCV 2.x 应用
在撰写本文时, Play 商店包含仅支持 OpenCV 2.x 的较旧版本的 OpenCV Manager。 如果您要同时运行 OpenCV 2.x 和 OpenCV 3.x 应用,则可以从 Play 商店与 OpenCV Manager 3 一起安装此旧版本。它们不会冲突。
将 Android 设备插入计算机的 USB 端口。 在 Eclipse 中,在包浏览器 选择一个 OpenCV 示例项目。 然后,从菜单系统导航至运行为… | Android 应用:

应显示 Android 设备选择器窗口。 您的 Android 设备应在选择运行的 Android 设备下列出。 如果未列出该设备,请参阅本章后面的“对 USB 连接进行故障排除”。
选择设备,然后单击 OK:

如果出现自动监视器 Logcat 窗口,则选择是单选按钮和详细下拉选项,然后单击 OK。 此选项确保在 Eclipse 中可见应用的所有日志输出:

在 Android 设备上,您可能会收到一条消息:OpenCV 库包未找到! 尝试安装吗? 确保设备已连接到互联网,然后触摸设备上的是按钮。 Play 商店将打开以显示 OpenCV 包。 安装包,然后按硬件后退按钮以返回示例应用,该应用应已准备就绪。
对于 OpenCV 3.0,示例和教程具有以下功能:
-
示例 – 15 个拼图:此拆分了一个相机供稿,以制作一个滑块拼图。 用户可以滑动块以移动它们。
-
样本 – 色球检测:此操作可检测相机馈送中的颜色区域。 用户可以触摸任何地方以查看颜色区域的轮廓。
-
样本 – 面部检测:这会在相机摘要中的面部周围绘制绿色矩形。
-
样本 – 图像处理:这会将过滤器应用于相机源。 用户可以按 Android 菜单按钮从过滤器列表中进行选择。 例如,一个过滤器绘制一个颜色直方图(图像中存在的颜色的条形图),如以下屏幕截图的底部所示:
![Building the OpenCV samples with Eclipse]()
-
示例 - 本机活动:此使用本机(C++)代码显示摄像机供稿。
-
教程 1 – 摄像机预览:这将显示摄像机源。 用户可以按
...菜单,选择,选择其他相机源实现(Java 或本机 C++)。 -
教程 2 – 混合处理:此使用本机(C++)代码将过滤器应用于相机 Feed。 用户可以按
...菜单从过滤器列表中进行选择。 过滤器之一在相机摘要中的兴趣点或特征周围绘制红色圆圈。 一般而言,兴趣点或特征位于图像的高对比度边缘上。 它们在图像识别和跟踪应用中可能很有用,我们将在本书的后面看到。 -
教程 3 – 摄像机控制:这会将过滤器应用于具有可自定义分辨率的摄像机源。 用户可以按
...菜单从过滤器列表和分辨率列表中进行选择。
在您的 Android 设备上尝试这些应用! 当应用运行时,其日志输出应出现在 Eclipse 的 LogCat 选项卡中:

可以通过包浏览器到随意浏览项目的源代码,以了解它们的制作方式。 另外,一旦我们在本书的过程中构建了自己的应用,您可能希望稍后返回官方示例和教程。
对 Eclipse 项目进行故障排除
本部分与 Java 代码故障排除无关。 相反,它解决了 Eclipse 项目的配置和构建过程中的一些常见问题。 在使用 OpenCV 库,OpenCV 示例项目,其他导入的项目甚至您自己的新项目时,您可能会遇到这些问题。
有时,Eclipse 在项目或其依赖项之一已更改之后(或在导入依赖项之后)无法识别需要重建项目。 如有疑问,请尝试导航至项目 | 清理… | 清理所有项目,在菜单系统中单击清理所有项目。 这将迫使 Eclipse 重新构建所有内容,从而确保所有错误,警告和成功信息都是最新的。
如果一组清理的项目仍然存在神秘错误,则可能是配置问题。
可能未正确指定目标 Android 版本。 症状是从java和android包导入失败,并且出现诸如该项目由于其构建路径不完整而未构建之类的错误消息。 解决方案是右键单击包浏览器中的项目,从上下文菜单中选择属性,选择 Android 部分,然后选中其中一个 Android 版本。 所有项目都应重复这些步骤。
在编译时,OpenCV 及其示例必须针对 Android 3.0(API 级别 11)或更高版本,尽管在运行时它们还支持 Android 2.2(API 级别 8)或更高版本:

如果在 Mac 或 Linux 上导入,则 OpenCV C++ 示例可能被错误配置为使用 Windows 构建可执行文件。 症状是在路径中找不到程序/ndk-build.cmd之类的错误消息。 解决方案是右键单击包浏览器中的项目,从上下文菜单中选择属性,选择 C/C++ 构建部分,然后编辑生成命令字段用于删除.cmd扩展名。 对于所有本机(C++)项目,应重复这些步骤,包括 OpenCV 示例 – 人脸检测和 OpenCV 教程 2 - 混合处理:

如果我们仍然收到错误消息,例如程序/ndk-build.cmd未在PATH中找到,则可以得出结论,Eclipse 无法识别NDKROOT环境变量。 作为依赖环境变量的替代方法,我们可以将NDKROOT作为 Eclipse 构建变量添加到 Eclipse | 首选项 | C/C++ | 构建 | 构建变量。 (这些首选项在 Eclipse 项目之间共享。)作为变量的类型,选择字符串,并输入其 NDK 路径(我们以前将其称为<android_ndk>)作为其值:

对 USB 连接进行故障排除
如果您的 Android 设备未出现在 Eclipse 的 Android 设备选择器窗口中,或者如果adb命令在命令提示符下失败,则 USB 连接可能有问题。 具体来说,通过称为 Android 调试桥(ADB)的工具来控制与 Android 设备的 USB 通信,而该工具(或连接的其他某些组件)可能不会如预期般运作。 在本节中尝试可能的解决方案。
注意
要验证 USB 连接是否正常工作,请在命令提示符下运行以下命令:
$ adb devices
如果连接正常,终端应打印您所连接的 Android 设备的序列号和名称,例如019d86b921300c7c device
许多连接问题是间歇性的,可以通过将 USB 连接恢复到初始状态来解决。 请尝试以下步骤,并在每个步骤之后测试问题是否得到解决:
-
从主机的 USB 端口上拔下 Android 设备。 然后,将其重新插入。
-
禁用并重新启用设备的 USB 调试选项,如先前在“使用 Eclipse 构建 OpenCV 示例”部分中所述。
-
在 Mac 或 Linux 上,在终端(或另一个命令提示符)中运行以下命令:
sudo sh -c "adb kill-server && start-server"
较不常见的是,连接问题可能与驱动程序或权限有关。 如下所述,一次性设置过程应解决此类问题。
在 Windows 上,我们可能需要手动为 Android 设备安装 USB 驱动程序。 不同的供应商和设备具有不同的驱动程序。 Android 的官方文档在这个页面上提供了各个供应商的驱动程序下载站点的链接。
在 Linux 上,通过 USB 连接 Android 设备之前,我们可能需要在权限文件中指定设备的供应商。 每个供应商都有唯一的 ID 号,如这个页面上的官方 Android 文档中所列。 我们将此 ID 号称为<vendor_id>。 要创建权限文件,请打开命令提示符应用(例如终端)并运行以下命令:
$ cd /etc/udev/rules.d/
$ sudo touch 51-android.rules
$ sudo chmod a+r 51-android-rules
请注意,权限文件需要具有root所有权,因此我们在创建或修改它时使用sudo。 现在,在诸如gedit之类的编辑器中打开文件:
$ sudo gedit 51-android-rules
对于每个供应商,在文件中追加一行。 这些行中的每一行都应具有以下格式:
SUBSYSTEM=="usb", ATTR{idVendor}=="<vendor_id>", MODE="0666", GROUP="plugdev"
保存权限文件并退出编辑器。 重启。
在 Mac 上,不需要特殊的驱动程序或权限。
查找文档和帮助
OpenCV Java API 和 C++ API 都与 Android 相关。 Java API 文档在线发布于这个页面,OpenCV4Android 资源索引在线发布于这个页面。 C++ API 文档在线发布。 以下文档(主要使用 C++ 代码)也可作为可下载的 PDF 文件提供:
如果文档似乎无法回答您的问题,请尝试与 OpenCV 社区联系。 在一些网站上,您会找到有用的人:
另外,您可以在这个页面上阅读或提交错误报告。 最后,如果您需要将问题提交给最高权限,则可以通过android@opencv.org向 OpenCV4Android 开发人员发送电子邮件。
总结
到目前为止,我们应该拥有一个 Android 和 OpenCV 开发环境,该环境可以完成本书其余各章中描述的应用所需的一切。 根据我们采用的方法,我们可能还会有一组工具,可用于重新配置和重建 OpenCV,以满足未来的需求。
我们知道如何在 Eclipse 中构建 OpenCV Android 示例。 这些示例涵盖了本书项目的不同功能范围,但它们可作为其他学习辅助工具使用。 我们也知道在哪里可以找到文档和帮助。
现在我们已经掌握了必要的工具和参考资料,作为应用开发人员,我们的首要目标是控制相机! 在下一章中,我们将使用 Android SDK 和 OpenCV 预览,捕获和共享照片。
二、使用相机帧
在本章中,我们将重点构建一个基本的照片捕获应用,该应用使用 OpenCV 捕获摄像机输入的帧。 我们的应用将使用户能够预览,保存,编辑和共享照片。 它将通过 Android 的MediaStore和Intent类与设备上的其他应用交互。 因此,我们将学习如何在 OpenCV 和标准 Android 之间建立桥梁。 在随后的章节中,我们将使用 OpenCV 的更多功能来扩展我们的应用。
注意
可以从作者的网站下载本章的完整 Eclipse 项目。 该项目有两个版本:
OpenCV 3.x 的版本位于这个页面。
OpenCV 2.x 的版本位于这个页面。
设计我们的应用 – Second Sight
让我们制作一个应用,该应用使人们可以查看新的视觉样式,为这些样式设置动画并与之交互,并以图片形式共享它们。 这个想法是简单而通用的。 从儿童到计算机视觉专家的任何人都可以欣赏视觉模式。 通过移动设备上的计算机视觉魔力,任何用户都可以更轻松地查看,更改和共享任何场景中的隐藏图案。
在这个应用中,我选择了“第二视线”这个名字,这个词在神话中有时被用来指代超自然的和象征性的视觉。
Second Sight 的核心是相机应用。 它将使用户能够预览,保存和共享照片。 像许多其他相机应用一样,它也使用户可以将过滤器应用于预览和保存的照片。 但是,许多过滤器将不是传统的摄影效果。 例如,更复杂的过滤器将使用户能够看到风格化的边缘,甚至可以看到与真实场景(增强现实)融合的渲染对象。
在本章中,我们将构建 Second Sight 的基本相机和共享功能,而无需任何过滤器。 我们的应用的第一个版本将包含两个名为CameraActivity和LabActivity的活动类。 CameraActivity类将显示预览并提供菜单操作,以便用户可以选择摄像机(如果设备具有多个摄像机),图像大小(在菜单的...部分下,如果相机支持多种图像尺寸),然后拍照。 然后,LabActivity类将打开以显示保存的照片并提供菜单操作,以便用户可以删除照片或将其发送到另一个应用进行编辑或共享。
为了更好地了解我们的目标,让我们看一些屏幕截图。 我们的CameraActivity的第一个版本如下所示:

当用户单击拍照菜单项时,将打开LabActivity类。 它看起来像以下屏幕截图:

当用户按下,分享菜单项时,意图选择器(用于选择目标应用的对话框)将显示在照片的顶部,如以下屏幕截图所示:

例如,通过按下 Google+ 磁贴,用户可以在 Google+应用中打开照片,以便在社交网络上共享。 因此,我们有一个完整的典型用法示例。 通过几次触摸交互,用户可以拍摄并共享照片。
创建 Eclipse 项目
我们需要为我们的应用创建一个新的 Eclipse 项目。 我们可以在与 OpenCV 库项目和示例相同的工作空间中执行此操作。 另外,如果我们使用另一个工作空间,我们也必须将 OpenCV 库项目导入该工作空间。 (有关设置工作区和导入库项目的说明,请参阅第 1 章,“设置 OpenCV”的“使用 Eclipse 构建 OpenCV 示例”部分。)
在包含库项目的工作区中打开 Eclipse。 然后,从菜单系统导航至文件 | 新增 | Android 应用项目。 新的 Android 应用窗口应出现。 输入以下屏幕快照中显示的选项:

用于编译的目标 SDK字段应设置为 API 11:Android 3.0 或更高版本。 选择最新的 API 版本是安全的,在撰写本文时,该版本为 API 22:Android 5.1.1。 最低要求的 SDK 字段应保留为默认值,即 API 8:Android 2.2(Froyo),因为我们将编写后备代码以使我们的代码能够在该版本上运行 。
单击下一个按钮。 应显示一个清单。 确保仅选中创建活动和在工作区中创建项目的选项,如以下屏幕截图所示:

单击下一个按钮。 活动模板列表应出现。 选择BlankActivity,如以下屏幕截图所示:

单击下一个按钮。 应显示有关活动的更多选项。 在活动名称字段中输入CameraActivity,如以下屏幕截图所示:

单击完成按钮。 我们的项目已创建。 另外,您还将在 Package Explorer 窗格中看到另一个新项目appcompat_v7。 v7 appcompat 库是 Android 支持库的一部分,后者随 Android SDK 一起提供。 支持库提供向后兼容性,因此即使用户设备运行的是旧版操作系统,应用也可以使用 Android 的许多新功能。
注意
请记住,工作空间还必须包含 OpenCV 库项目。 如果尚不存在,请按照第 1 章,“设置 OpenCV”的“使用 Eclipse 构建 OpenCV 示例”部分中的描述进行导入。 同样,有关与设置环境,使用 Eclipse 或对项目进行配置和故障排除有关的任何其他问题,请参考第 1 章。
我们必须指定我们的应用依赖于 OpenCV。 右键单击包浏览器中的SecondSight项目,然后从上下文菜单中选择属性。 属性窗口应出现。 转到其 Android 标签,然后使用添加...按钮添加对 OpenCV 库项目的引用。 确认 v7 appcompat 库也已作为依赖项列出。 添加引用后,该窗口应类似于以下屏幕截图:

单击下一步至应用新的首选项。
我们应该能够在包浏览器窗格中浏览SecondSight项目的内容。 让我们删除并添加一些文件。 执行以下步骤:
- 删除
res/layout/activity_camera.xml。 (右键单击它,从上下文菜单中选择删除,然后单击 OK。)我们的界面布局非常简单,因此用 Java 代码代替此单独的 XML 文件,创建起来会更加方便。 但是,如果您确实希望使用在 XML 布局中使用 OpenCV 的示例,则可以参考该库随附的示例应用。 请参阅第 1 章,“设置 OpenCV”中的“使用 Eclipse 构建 OpenCV 示例”部分。 - 删除
res/values-v11和res/values-v14文件夹。 它们包含为某些 Android API 级别定义替代 GUI 样式的文件。 但是,由于有了 v7 appcompat 库,我们可以为所有受支持的 API 级别使用单个样式文件res/values/styles.xml。 - 创建
src/com/nummist/secondsight/LabActivity.java。 (右键单击com.nummist.secondsight,从上下文菜单中导航至新建 | 类,在名称字段中输入LabActivity,然后单击完成。) - 创建
res/menu/activity_lab.xml。 (右键单击父文件夹,从上下文菜单中导航到新建 | Android XML 文件,在文件字段中输入activity_lab,然后单击完成。)
现在,我们有了项目的框架。 在本章的其余部分中,我们将编辑几个文件以提供适当的功能和内容。
在清单中启用相机和磁盘访问
AndroidManifest.xml文件(清单)指定了 Android 应用的要求和组件。 与默认清单相比,Second Sight 中的清单需要执行以下附加工作:
- 确保,设备至少具有一台摄像机。
- 获得使用相机的许可。
- 获得将文件写入永久存储的权限。
- 将屏幕方向限制为横向模式,因为 OpenCV 的摄像机预览不能很好地处理纵向模式(至少在 OpenCV 2.x 和 OpenCV 3.0 中)。 请参阅以下错误报告,其中描述了该问题,并暗示了在 OpenCV 3.x 的将来版本中进行修复的可能性 。
- 注册第二个活动。
我们可以通过编辑清单中的uses-permission,uses-feature和activity标签来完成这些任务。
注意
有关 Android 清单的详细信息,请参见这个页面上的官方文档。
打开AndroidManifest.xml,它位于项目的根目录下。 单击,在标有AndroidManifest.xml的标签上,以源代码模式查看它。 在片段之后,通过在中添加突出显示的代码来编辑文件:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android=
"http://schemas.android.com/apk/res/android"
package="com.nummist.secondsight"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk
android:minSdkVersion="8"
android:targetSdkVersion="22" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name=
"android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus"
android:required="false" />
<uses-feature android:name="android.hardware.camera.flash"
android:required="false" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<activity
android:name="com.nummist.secondsight.CameraActivity"
android:label="@string/app_name"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name=
"android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="com.nummist.secondsight.LabActivity"
android:label="@string/app_name"
android:screenOrientation="landscape">
</activity>
</application>
</manifest>
提示
使代码适配 OpenCV 2.x
将android:targetSdkVersion="22"替换为android:targetSdkVersion="19"。 此更改启用了向后兼容模式,从而避免了 Android 5.x(Lollipop)上 OpenCV 2.x 的加载程序中的严重问题。 以下栈溢出线程描述了问题。 (对于 OpenCV 3.x,此问题已得到解决。)
通过要求函数android.hardware.camera,我们指定 Google Play 仅应将我们的应用分发到具有后置摄像头的设备。 由于的历史原因,前置摄像头无法满足和android.hardware.camera的要求。 如果需要前置摄像头,则可以指定android.hardware.camera.front函数。 相反,如果我们需要任何(前置或后置)摄像头,则原则上可以指定android.hardware.camera.any函数。 但是,实际上,Google Play 在大多数设备上均无法正确识别此函数。 因此,最实用的过滤器是android.hardware.camera。 另一种选择是完全省略uses-feature标签,而在运行时测试设备的硬件功能。 在本章后面的“在CameraActivity中预览和保存照片”部分中,我们将看到如何查询相机的数量及其功能。
创建菜单和字符串资源
我们的应用菜单和可本地化的文本在 XML 文件中进行了描述。 这些资源文件中的标识符由 Java 代码引用,我们将在后面看到。
注意
有关 Android 应用资源的详细信息,请参见这个页面上的官方文档。
首先,让我们编辑res/menu/activity_camera.xml,使其具有以下实现,描述CameraActivity的菜单项:
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_next_camera"
app:showAsAction="ifRoom|withText" android:title="@string/menu_next_camera"{ }/>
<item
android:id="@+id/menu_take_photo"
app:showAsAction="always|withText"
android:title="@string/menu_take_photo"{ }/>
</menu>
请注意,我们使用app:showAsAction属性使菜单项出现在应用的顶部栏中,如先前的屏幕截图所示。 为了向后兼容,此属性在 v7 appcompat 库的资源中定义。 通过引用 v7 appcompat 库,我们的项目将库的资源合并到应用的资源中,并在前面的代码块中使用属性xmlns:app="http://schemas.android.com/apk/res-auto"将app定义为这些资源的 XML 命名空间。
另请注意,在前面的代码块中未定义图像尺寸的菜单项。 我们将根据在运行时查询的摄像机功能以编程方式创建这些菜单项。
类似地,res/menu/activity_lab.xml中描述了LabActivity的菜单项,如下所示:
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">>>
<item
android:id="@+id/menu_delete"
app:showAsAction="ifRoom|withText"
android:title="@string/delete" />
<item
android:id="@+id/menu_edit"
app:showAsAction="ifRoom|withText"
android:title="@string/edit" />
<item
android:id="@+id/menu_share"
app:showAsAction="ifRoom|withText"
android:title="@string/share" />
</menu>
要支持操作条,我们必须更改应用的 GUI 样式。 编辑应用的默认样式文件res/values/styles.xml,然后更改AppBaseTheme的声明以匹配以下内容:
<style name="AppBaseTheme"
parent="Theme.AppCompat.Light.DarkActionBar">
如果尚未这样做,请删除res/values-v11和res/values-v14文件夹,它们包含某些 API 级别不需要的替代样式。 多亏了 v7 appcompat 库,我们可以为所有受支持的 API 级别使用默认样式文件。
res/values/strings.xml中描述了在应用的各个位置使用的用户可读文本的字符串,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Second Sight</string>
<string name="delete">Delete</string>
<string name="edit">Edit</string>
<string name="menu_next_camera">Next Cam</string>
<string name="menu_take_photo">Take Photo</string>
<string name="menu_image_size">Size</string>
<string name="photo_delete_prompt_message">This photo is saved
in your Gallery. Do you want to delete it?</string>
<string name="photo_delete_prompt_title">Delete photo?</string>
<string name="photo_error_message">Failed to save photo</string>
<string name="photo_edit_chooser_title">Edit photo
with…</string>
<string name="photo_send_chooser_title">Share photo
with…</string>
<string name="photo_send_extra_subject">My photo from Second
Sight</string>
<string name="photo_send_extra_text">Check out my photo from the Second Sight app! http://nummist.com/opencv/</string>
<string name="share">Share</string>
</resources>
在定义了这些样板资源后,我们可以继续在 Java 中实现我们应用的功能。
在CameraActivity中预览和保存照片
我们的主要活动CameraActivity需要执行以下操作:
- 启动时,使用 OpenCV Manager 3 确保适当的 OpenCV 共享库可用。 (有关 OpenCV Manager 3 和早期版本的更多信息,请参考第 1 章,“设置 OpenCV”中的“使用 Eclipse 构建 OpenCV 示例”部分。)
- 显示实时摄像机供稿。
- 提供以下菜单操作:
- 切换活动摄像机(对于具有多个摄像机的设备)
- 更改图像尺寸(对于支持多种图像尺寸的相机)
- 保存照片并将其插入
MediaStore,以便诸如 Gallery 之类的应用可以使用。 立即在LabActivity中打开照片。
即使我们可以仅使用标准的 Android 库来显示实时摄像机供稿,保存照片等,我们仍将在可行的地方使用 OpenCV 功能。 要获取有关设备相机功能的信息,我们将依赖于名为Camera的标准 Android 类。
注意
从 API 级别 21(Lollipop)开始,不推荐使用Camera类,而推荐使用新的包android.hardware.camera2。 (请参见官方文档)。 但是,在 API 级别 21 之前,camera2包没有后向兼容性。因此,为了支持更多设备,我们将使用不推荐使用的Camera类。
OpenCV 为提供了一个名为CameraBridgeViewBase的抽象类,其中代表实时摄像机供稿。 此类扩展了 Android 的SurfaceView类,因此其实例可以成为视图层次结构的一部分。 此外,CameraBridgeViewBase实例可以将事件调度到实现CvCameraViewListener或CvCameraViewListener2两个接口之一的任何监听器。 通常,CameraActivity的监听器将是一个活动。
CvCameraViewListener和CvCameraViewListener2接口提供回调,以处理摄像机输入流的开始和停止并处理每个帧的捕获。 这两个接口在图像格式方面有所不同。 CvCameraViewListener始终接收 RGBA 彩色帧,该帧作为 OpenCV 的Mat类的实例传递。 从概念上讲,Mat是可以存储像素数据的多维数组。 CvCameraViewListener2接收每个帧作为 OpenCV 的CvCameraViewFrame类的实例。 从传递的CvCameraViewFrame中,我们可以获取 RGBA 彩色或灰度格式的Mat图像。 因此,CvCameraViewListener2是更灵活的接口,它是我们在CameraActivity中实现的接口。
由于CameraBridgeViewBase是抽象类,因此我们需要一个实现。 OpenCV 提供了两种实现:JavaCameraView和NativeCameraView。 它们都是 Java 类,但是NativeCameraView是围绕本机 C++ 类的 Java 包装器。 NativeCameraView可能会产生更高的帧速率,但是它容易出现设备特定的错误,并且在新的 Android OS 版本问世时也容易出错。 因此,为了提高可靠性,我们在应用中使用了JavaCameraView。
为了支持 OpenCV Manager 和客户端应用之间的交互,OpenCV 提供了一个名为BaseLoaderCallback的抽象类。 此类声明一个回调方法,该方法在 OpenCV Manager 确保库可用之后执行。 通常,此回调是启用摄像机视图并创建任何其他 OpenCV 对象的适当位置。
现在,我们了解了有关 OpenCV 类型的信息,让我们打开CameraActivity.java并添加以下关于活动类及其成员变量的声明:
注意
为简便起见,本书中的代码清单省略了package和import语句。 Eclipse 在创建文件时应自动生成package语句,在声明变量时应自动生成import语句。
// Use the deprecated Camera class.
@SuppressWarnings("deprecation")
public class CameraActivity extends ActionBarActivity
implements CvCameraViewListener2 {
// A tag for log output.
private static final String TAG =
CameraActivity.class.getSimpleName();
// A key for storing the index of the active camera.
private static final String STATE_CAMERA_INDEX = "cameraIndex";
// A key for storing the index of the active image size.
private static final String STATE_IMAGE_SIZE_INDEX =
"imageSizeIndex";
// An ID for items in the image size submenu.
private static final int MENU_GROUP_ID_SIZE = 2;
// The index of the active camera.
private int mCameraIndex;
// The index of the active image size.
private int mImageSizeIndex;
// Whether the active camera is front-facing.
// If so, the camera view should be mirrored.
private boolean mIsCameraFrontFacing;
// The number of cameras on the device.
private int mNumCameras;
// The camera view.
private CameraBridgeViewBase mCameraView;
// The image sizes supported by the active camera.
private List<Size> mSupportedImageSizes;
// Whether the next camera frame should be saved as a photo.
private boolean mIsPhotoPending;
// A matrix that is used when saving photos.
private Mat mBgr;
// Whether an asynchronous menu action is in progress.
// If so, menu interaction should be disabled.
private boolean mIsMenuLocked;
// The OpenCV loader callback.
private BaseLoaderCallback mLoaderCallback =
new BaseLoaderCallback(this) {
@Override
public void onManagerConnected(final int status) {
switch (status) {
case LoaderCallbackInterface.SUCCESS:
Log.d(TAG, "OpenCV loaded successfully");
mCameraView.enableView();
mBgr = new Mat();
break;
default:
super.onManagerConnected(status);
break;
}
}
状态(各种操作模式)的概念是 Android 活动的中心,CameraActivity也不是例外。 当用户选择菜单操作来打开相机或拍照时,效果不是瞬间的。 动作会影响后续框架中必须完成的工作。 某些工作甚至是异步完成的。 因此,CameraActivity的许多成员变量专用于跟踪活动的逻辑状态。
提示
了解 Android 中的异步事件冲突
许多 Android 库方法(例如startActivity)都是异步执行的,这意味着它们在后台线程上运行,以允许主(用户界面)线程继续处理事件。 也就是说,在执行工作的同时,用户可以继续使用界面,从而有可能启动其他与第一工作在逻辑上不一致的工作。
例如,假设单击某个按钮时调用了startActivity。 如果用户快速多次按下按钮,则可能将一个以上的新活动推入活动栈。 此行为可能不是开发人员或用户想要的。 一种解决方案是禁用单击的按钮,直到其活动恢复。 类似的考虑因素也会影响CameraActivity中的菜单系统。
与任何 Android 活动类似,CameraActivity也实现了几个在中执行的对标准状态变化(即活动生命周期中的变化)的响应的回调。 让我们从开始查看onCreate和onSaveInstanceState回调。 在活动生命周期的开始和结尾分别调用这些方法。 onCreate回调通常设置活动的视图层次结构,初始化数据,并读取上次调用onSaveInstanceState时可能已写入的所有已保存数据。
注意
有关 Android 活动生命周期的详细信息,请参阅这个页面上的官方文档。
下图总结了 Android 活动生命周期中的各种状态以及状态转换期间调用的回调。 (其中一些回调,例如onStart并未在CameraActivity中实现;相反,我们使用默认实现。)此外,图中的灰色框表示 OpenCV 库和摄像机视图的生命周期中的状态。 虚线表示活动生命周期和 OpenCV 生命周期中的状态之间的关系。

在CameraActivity中,onCreate回调设置摄像机视图并初始化有关摄像机的数据。 它还读取可能由onSaveInstanceState写入的有关活动摄像机的所有先前数据。 这里是两种方法的实现:
// Suppress backward incompatibility errors because we provide
// backward-compatible fallbacks.
@SuppressLint("NewApi")
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Window window = getWindow();
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if (savedInstanceState != null) {
mCameraIndex = savedInstanceState.getInt(
STATE_CAMERA_INDEX, 0);
mImageSizeIndex = savedInstanceState.getInt(
STATE_IMAGE_SIZE_INDEX, 0);
} else {
mCameraIndex = 0;
mImageSizeIndex = 0;
}
final Camera camera;
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.GINGERBREAD) {
CameraInfo cameraInfo = new CameraInfo();
Camera.getCameraInfo(mCameraIndex, cameraInfo);
mIsCameraFrontFacing =
(cameraInfo.facing ==
CameraInfo.CAMERA_FACING_FRONT);
mNumCameras = Camera.getNumberOfCameras();
camera = Camera.open(mCameraIndex);
} else { // pre-Gingerbread
// Assume there is only 1 camera and it is rear-facing.
mIsCameraFrontFacing = false;
mNumCameras = 1;
camera = Camera.open();
}
final Parameters parameters = camera.getParameters();
camera.release();
mSupportedImageSizes =
parameters.getSupportedPreviewSizes();
final Size size = mSupportedImageSizes.get(mImageSizeIndex);
mCameraView = new JavaCameraView(this, mCameraIndex);
mCameraView.setMaxFrameSize(size.width, size.height);
mCameraView.setCvCameraViewListener(this);
setContentView(mCameraView);
}
public void onSaveInstanceState(Bundle savedInstanceState) {
// Save the current camera index.
savedInstanceState.putInt(STATE_CAMERA_INDEX, mCameraIndex);
// Save the current image size index.
savedInstanceState.putInt(STATE_IMAGE_SIZE_INDEX,
mImageSizeIndex);
super.onSaveInstanceState(savedInstanceState);
}
请注意,关于设备的相机的某些数据在 Froyo(我们支持的最旧的 Android 版本)上不可用。 为了避免运行时错误,我们在使用新的 API 之前先检查Build.VERSION.SDK_INT。 此外,为避免在静态分析期间(即在编译之前)看到错误,我们将@SuppressLint("NewApi")注解添加到onCreate的声明中。
还要注意,每次对Camera.open的调用都必须与对Camera实例的release方法的调用配对,以便以后使摄像机可用。 否则,我们的应用和其他应用随后可能会在调用Camera.open时遇到RuntimeException。
注意
有关类Camera的更多详细信息,请参见这个页面上的官方文档。
当我们切换到不同的摄像机或图像尺寸时,最方便的是重新创建活动,以便onCreate将再次运行。 在 Honeycomb 和更新的 Android 版本上,可以使用recreate方法,但为了向后兼容,我们应该编写自己的替代实现,如下所示:
// Suppress backward incompatibility errors because we provide
// backward-compatible fallbacks.
@SuppressLint("NewApi")
@Override
public void recreate() {
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.HONEYCOMB) {
super.recreate();
} else {
finish();
startActivity(getIntent());
}
}
Intent(例如startActivity自变量)是一个活动创建或与另一个活动进行通信的方法。 getIntent方法仅获得首先用于启动当前活动的Intent。 因此,此Intent适用于重新创建活动。 我们将在本章后面的“在LabActivity中删除,编辑和共享照片”一节中全面讨论意图。
其他几个活动生命周期回调也与 OpenCV 有关。 当活动进入后台(onPause回调)或结束(onDestroy回调)时,应禁用摄影机视图。 当活动进入前台(onResume回调)时,OpenCVLoader应该尝试初始化库。 (请记住,一旦库成功初始化,便启用了相机视图。)以下是相关回调的实现:
@Override
public void onPause() {
if (mCameraView != null) {
mCameraView.disableView();
}
super.onPause();
}
@Override
public void onResume() {
super.onResume();
OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION_3_0_0,
this, mLoaderCallback);
mIsMenuLocked = false;
}
@Override
public void onDestroy() {
if (mCameraView != null) {
mCameraView.disableView();
}
super.onDestroy();
}
提示
使代码适配 OpenCV 2.x
将OpenCVLoader.OPENCV_VERSION_3_0_0替换为较早的版本,例如OpenCVLoader.OPENCV_VERSION_2_4_9。
请注意,在onResume中,我们重新启用了菜单交互。 如果以前在将子活动推入栈时禁用了,则可以这样做。
至此,我们的活动具有必要的代码来设置摄像机视图并获取有关设备摄像机的数据。 接下来,我们应该执行菜单操作,以使用户能够打开相机,更改图像尺寸并请求拍照。 同样,存在相关的活动生命周期回调,例如onCreateOptionsMenu和onOptionsItemSelected。 在onCreateOptionsMenu中,我们从菜单的资源文件中加载菜单。 然后,如果设备只有一个摄像头,则删除下个摄像头菜单项。 如果活动摄像机支持多个图像尺寸,我们将为所有支持的尺寸创建一组菜单选项。 在onOptionsItemSelected中,我们将通过使用指定的图像尺寸重新创建活动来处理任何图像尺寸菜单项。 (请记住,图像尺寸索引已保存在onSaveInstanceState中,并已还原到onCreate中,用于构建摄像机视图。)类似地,我们通过循环切换到下个摄像头菜单项来处理下一个摄像机索引,然后重新创建活动。 (请记住,相机索引已保存在onSaveInstanceState中,并已在onCreate中还原,用于构建相机视图。)我们处理拍摄照片菜单项,方法是设置一个布尔值值,我们稍后会在 OpenCV 回调中检入该值。 无论哪种情况,我们都将阻止菜单选项的任何进一步处理,直到完成当前的处理(例如,直到onResume为止)。 这是两个与菜单相关的回调的实现:
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
getMenuInflater().inflate(R.menu.activity_camera, menu);
if (mNumCameras < 2) {
// Remove the option to switch cameras, since there is
// only 1.
menu.removeItem(R.id.menu_next_camera);
}
int numSupportedImageSizes = mSupportedImageSizes.size();
if (numSupportedImageSizes > 1) {
final SubMenu sizeSubMenu = menu.addSubMenu(
R.string.menu_image_size);
for (int i = 0; i < numSupportedImageSizes; i++) {
final Size size = mSupportedImageSizes.get(i);
sizeSubMenu.add(MENU_GROUP_ID_SIZE, i, Menu.NONE,
String.format("%dx%d", size.width, size.height));
}
}
return true;
}
// Suppress backward incompatibility errors because we provide
// backward-compatible fallbacks (for recreate).
@SuppressLint("NewApi")
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (mIsMenuLocked) {
return true;
}
if (item.getGroupId() == MENU_GROUP_ID_SIZE) {
mImageSizeIndex = item.getItemId();
recreate();
return true;
}
switch (item.getItemId()) {
case R.id.menu_next_camera:
mIsMenuLocked = true;
// With another camera index, recreate the activity.
mCameraIndex++;
if (mCameraIndex == mNumCameras) {
mCameraIndex = 0;
}
mImageSizeIndex = 0;
recreate();
return true;
case R.id.menu_take_photo:
mIsMenuLocked = true;
// Next frame, take the photo.
mIsPhotoPending = true;
return true;
default:
return super.onOptionsItemSelected(item);
}
}
接下来,让我们看一下CvCameraViewListener2接口所需的回调。 摄像头供稿启动(onCameraViewStarted回调)或停止(onCameraViewStopped回调)时,CameraActivity不需要执行任何操作,但每当有新帧到达时,可能需要执行一些操作(onCameraFrame回调)。 首先,如果用户已请求照片,则应拍照。 (照片捕获功能实际上非常复杂,因此我们将其放在辅助方法takePhoto中,我们将在本节稍后部分对其进行研究。)
其次,如果活动摄像机是正面的(即面向用户的),则应该将摄像机的视图镜像(水平翻转),因为人们习惯于从镜子中看自己,而不是从摄像机的真实角度看。 OpenCV 的Core.flip方法可用于镜像图像。 Core.flip的参数是源Mat,目标Mat(可能与源相同),以及指示翻转是否应为垂直(0),水平(1 )或两者(-1)。 这是CvCameraViewListener2回调的实现:
@Override
public void onCameraViewStarted(final int width,
final int height) {
}
@Override
public void onCameraViewStopped() {
}
@Override
public Mat onCameraFrame(final CvCameraViewFrame inputFrame) {
final Mat rgba = inputFrame.rgba();
if (mIsPhotoPending) {
mIsPhotoPending = false;
takePhoto(rgba);
}
if (mIsCameraFrontFacing) {
// Mirror (horizontally flip) the preview.
Core.flip(rgba, rgba, 1);
}
return rgba;
}
现在,终于,实现了可以捕获用户的心和思想,或者至少是他们的照片的功能。 作为参数,takePhoto接收从相机读取的 RGBA 颜色Mat。 我们希望使用称为Imgcodecs.imwrite的 OpenCV 方法将此映像写入磁盘。 此方法需要 BGR 或 BGRA 颜色格式的图像,因此首先我们必须使用Imgproc.cvtColor方法转换 RGBA 图像。 除了将图像保存到磁盘之外,我们还希望使其他应用能够通过 Android 的MediaStore找到它。 为此,我们生成有关照片的一些元数据,然后使用ContentResolver对象将此元数据插入MediaStore,并获取 URI。
如果我们在保存或插入照片时遇到失败,我们将放弃并调用一个辅助方法onTakePhotoFailed,该方法将解锁菜单交互并向用户显示错误消息。 (例如,如果我们从AndroidManifest.xml中省略了WRITE_EXTERNAL_STORAGE,或者用户用完了磁盘空间,或者文件名与MediaStore中的上一个条目冲突,则会导致失败。)另一方面,如果一切成功后,我们启动LabActivity并将所需的数据传递给它,以找到保存的照片。 这是takePhoto和onTakePhotoFailed的实现:
private void takePhoto(final Mat rgba) {
// Determine the path and metadata for the photo.
final long currentTimeMillis = System.currentTimeMillis();
final String appName = getString(R.string.app_name);
final String galleryPath =
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_PICTURES).toString();
final String albumPath = galleryPath + File.separator +
appName;
final String photoPath = albumPath + File.separator +
currentTimeMillis + LabActivity.PHOTO_FILE_EXTENSION;
final ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DATA, photoPath);
values.put(Images.Media.MIME_TYPE,
LabActivity.PHOTO_MIME_TYPE);
values.put(Images.Media.TITLE, appName);
values.put(Images.Media.DESCRIPTION, appName);
values.put(Images.Media.DATE_TAKEN, currentTimeMillis);
// Ensure that the album directory exists.
File album = new File(albumPath);
if (!album.isDirectory() && !album.mkdirs()) {
Log.e(TAG, "Failed to create album directory at " +
albumPath);
onTakePhotoFailed();
return;
}
// Try to create the photo.
Imgproc.cvtColor(rgba, mBgr, Imgproc.COLOR_RGBA2BGR, 3);
if (!Imgcodecs.imwrite(photoPath, mBgr)) {
Log.e(TAG, "Failed to save photo to " + photoPath);
onTakePhotoFailed();
}
Log.d(TAG, "Photo saved successfully to " + photoPath);
// Try to insert the photo into the MediaStore.
Uri uri;
try {
uri = getContentResolver().insert(
Images.Media.EXTERNAL_CONTENT_URI, values);
} catch (final Exception e) {
Log.e(TAG, "Failed to insert photo into MediaStore");
e.printStackTrace();
// Since the insertion failed, delete the photo.
File photo = new File(photoPath);
if (!photo.delete()) {
Log.e(TAG, "Failed to delete non-inserted photo");
}
onTakePhotoFailed();
return;
}
// Open the photo in LabActivity.
final Intent intent = new Intent(this, LabActivity.class);
intent.putExtra(LabActivity.EXTRA_PHOTO_URI, uri);
intent.putExtra(LabActivity.EXTRA_PHOTO_DATA_PATH,
photoPath);
startActivity(intent);
}
private void onTakePhotoFailed() {
mIsMenuLocked = false;
// Show an error message.
final String errorMessage =
getString(R.string.photo_error_message);
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(CameraActivity.this, errorMessage,
Toast.LENGTH_SHORT).show();
}
});
}
}
提示
使代码适配 OpenCV 2.x
将Imgcodecs.imwrite替换为Highgui.imwrite。
目前,这是我们要CameraActivity做的所有事情。 通过在onCameraFrame回调中添加更多菜单操作并对其进行处理,我们将在以下章节中将扩展此类。
在LabActivity中删除,编辑和共享照片
我们的第二活动LabActivity需要执行以下操作:
- 从先前的活动中,接收 PNG 文件的 URI 和文件路径。
- 显示 PNG 文件中包含的图像。
- 提供以下菜单操作:
- 删除:显示确认对话框。 确认后,删除 PNG 文件并完成活动。
- 编辑:显示一个意图选择器,以便用户可以选择一个应用来编辑 PNG 文件。 (以
EDIT意图传递 URI。) - 共享:显示选择器,以便用户可以选择要共享或发送 PNG 文件的应用。 (以
SEND意图传递 URI。)
所有这些功能都依赖于标准的 Android 库类,尤其是Intent类。 意图是活动相互交流的手段。 一个活动从其父(创建该活动的活动)接收意图,并在其完成时从其子(创建的活动)接收意图。 通信活动可能在不同的应用中。 一个意图可能包含称为extras的键值对。
注意
有关目的的详细信息,请参见这个页面上的官方文档。
LabActivity声明了它和CameraActivity使用的几个公共常量。 这些常数与图像的文件类型以及CameraActivity和LabActivity通过意图进行通信时使用的额外键有关。 LabActivity还具有用于存储 URI 和路径值的成员变量,这些变量是从 Extras 中提取的。 onCreate方法负责提取这些值并设置显示 PNG 文件的图像视图。 实现如下:
public class LabActivity extends ActionBarActivityActionBarActivity {
public static final String PHOTO_FILE_EXTENSION = ".png";
public static final String PHOTO_MIME_TYPE = "image/png";
public static final String EXTRA_PHOTO_URI =
"com.nummist.secondsight.LabActivity.extra.PHOTO_URI";
public static final String EXTRA_PHOTO_DATA_PATH =
"com.nummist.secondsight.LabActivity.extra.PHOTO_DATA_PATH";
private Uri mUri;
private String mDataPath;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Intent intent = getIntent();
mUri = intent.getParcelableExtra(EXTRA_PHOTO_URI);
mDataPath = intent.getStringExtra(EXTRA_PHOTO_DATA_PATH);
final ImageView imageView = new ImageView(this);
imageView.setImageURI(mUri);
setContentView(imageView);
}
同样,我们正在用 Java 代码创建活动的布局(而不是加载 XML 布局的替代方法)。 我们的布局很简单,但是我们需要基于图像 URI 动态配置它,因此在这种情况下仅使用 Java 是明智的。
LabActivity中的菜单逻辑比CameraActivity中的菜单逻辑更简单。 LabActivity的所有菜单操作都会显示一个对话框或选择器,并且由于对话框或选择器会阻止其余的用户界面,因此我们不必担心自己会阻止冲突的输入。 我们只需将菜单的资源文件加载到onCreateOptionsMenu中,然后为onOptionsItemSelected中的每个可能的操作调用一个辅助方法。 实现如下:
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
getMenuInflater().inflate(R.menu.activity_lab, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_delete:
deletePhoto();
return true;
case R.id.menu_edit:
editPhoto();
return true;
case R.id.menu_share:
sharePhoto();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}
让我们逐一检查菜单操作助手方法,从deletePhoto开始。 该方法的大多数实现都是样板代码,用于设置确认对话框。 对话框的确认按钮具有onClick回调,该回调将从和MediaStore中删除图像并完成活动。 deletePhoto的实现如下:
/*
* Show a confirmation dialog. On confirmation, the photo is
* deleted and the activity finishes.
*/
private void deletePhoto() {
final AlertDialog.Builder alert = new AlertDialog.Builder(
LabActivity.this);
alert.setTitle(R.string.photo_delete_prompt_title);
alert.setMessage(R.string.photo_delete_prompt_message);
alert.setCancelable(false);
alert.setPositiveButton(R.string.delete,
new DialogInterface.OnClickListener() {
@Override
public void onClick(final DialogInterface dialog,
final int which) {
getContentResolver().delete(
Images.Media.EXTERNAL_CONTENT_URI,
MediaStore.MediaColumns.DATA + "=?",
new String[] { mDataPath });
finish();
}
});
alert.setNegativeButton(android.R.string.cancel, null);
alert.show();
}
下一个辅助方法editPhoto会使用Intent.createChooser方法设置意图并为此意图启动选择器。 用户可以取消此选择器或使用它来选择活动。 如果选择了活动,则editPhoto将其启动。 实现如下:
/*
* Show a chooser so that the user may pick an app for editing
* the photo.
*/
private void editPhoto() {
final Intent intent = new Intent(Intent.ACTION_EDIT);
intent.setDataAndType(mUri, PHOTO_MIME_TYPE);
startActivity(Intent.createChooser(intent,
getString(R.string.photo_edit_chooser_title)));
}
最后一个助手方法sharePhoto与editPhoto的类似,尽管意图配置不同。 实现如下:
/*
* Show a chooser so that the user may pick an app for sending
* the photo.
*/
private void sharePhoto() {
final Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType(PHOTO_MIME_TYPE);
intent.putExtra(Intent.EXTRA_STREAM, mUri);
intent.putExtra(Intent.EXTRA_SUBJECT,
getString(R.string.photo_send_extra_subject));
intent.putExtra(Intent.EXTRA_TEXT,
getString(R.string.photo_send_extra_text));
startActivity(Intent.createChooser(intent,
getString(R.string.photo_send_chooser_title)));
}
}
这是,最后的功能,我们需要才能捕获基本照片并共享应用。 现在,我们应该能够构建和运行 Second Sight。 (请记住要在 LogCat 窗格中注意任何运行时错误!)
总结
我们使用 OpenCV 来创建和显示实时摄像机馈送,并保存该馈送中的静止图像。 我们还看到了如何将相机供稿的生命周期集成到 Android 活动生命周期中,以及如何跨活动和应用边界共享保存的图像。
下一章将通过在CameraActivity和LabActivity菜单中添加各种图像过滤选项来扩展我们的 Second Sight 应用。
三、应用图像效果
对于本章,我们的目标是向 Second Sight 添加几个图像过滤器。 这些过滤器依靠各种 OpenCV 函数通过拆分,合并,算术运算或为复杂函数应用查找表来操纵矩阵。 某些过滤器还依赖于名为 Apache Commons Math 的数学库。
注意
可以从作者的网站下载本章的完整 Eclipse 项目。 该项目有两个版本:
OpenCV 3.x 的版本位于这个页面。
OpenCV 2.x 的版本位于这个页面。
将文件添加到项目
我们需要向 Eclipse 项目中添加几个文件,以便创建新的类型(接口和类),并需要链接到新库 Apache Commons Math。 以下是我们要创建的新类型:
-
com.nummist.secondsight.filters.Filter:这是代表可以将应用于图像的过滤器的接口。 -
com.nummist.secondsight.filters.NoneFilter:这是一个类,它表示不执行的过滤器。 它实现了Filter接口。 -
com.nummist.secondsight.filters.convolution.StrokeEdgesFilter:这是一个类别,其中表示在边缘区域顶部绘制粗黑线的过滤器。 它实现了Filter接口。 -
com.nummist.secondsight.filters.curve.CurveFilter:这是代表过滤器的类,该过滤器可以将单独的曲线变换应用于图像中的每个颜色通道。 (就像 Photoshop 或 GIMP 中的曲线一样。)它实现了Filter接口。 -
com.nummist.secondsight.filters.curve.CrossProcessCurveFilter:这是CurveFilter的子类。 它模拟了称为交叉处理的照相胶片处理技术。 -
com.nummist.secondsight.filters.curve.PortraCurveFilter:这是CurveFilter的子类。 它模仿了一个名为 Kodak Portra 的摄影胶片品牌。 -
com.nummist.secondsight.filters.curve.ProviaCurveFilter:这是CurveFilter的子类。 它模仿了一个名为 Fuji Provia 的摄影胶片品牌。 -
com.nummist.secondsight.filters.curve.VelviaCurveFilter:这是CurveFilter的子类。 它模仿了一个名为 Fuji Velvia 的摄影胶片品牌。 -
com.nummist.secondsight.filters.mixer.RecolorCMVFilter:这是一个类别,表示线性组合色彩通道的过滤器,从而使图像看起来像是由有限的青色,品红色和白色调色板混合而成。 (这类似于 Photoshop 或 GIMP 中通道混合器的特化。)它实现了Filter接口。 -
com.nummist.secondsight.filters.mixer.RecolorRCFilter:这是代表过滤器的类,该过滤器线性组合颜色通道,从而使图像看起来像是由有限的红色和青色调色板混合而成。 (这类似于 Photoshop 或 GIMP 中通道混合器的特化。)它实现了Filter接口。 -
com.nummist.secondsight.filters.mixer.RecolorRGVFilter: 此类代表表示线性组合颜色通道的过滤器,从而使图像看起来像是由有限的红色,绿色和白色调色板混合而成。 (这就像是 Photoshop 或 GIMP 中“通道混合器”的特化。)它实现了Filter接口。注意
Photoshop 和 GNU 图像处理器(GIMP)是流行的图像编辑应用。 本书不需要它们,但是(可选)您可能想要尝试这种程序,以便可以在代码中实现图像处理效果之前对其进行实验。 Photoshop 是一种商业应用。 GIMP 是开源程序,可从这个页面免费获得。
在包浏览器窗格的src目录下创建适当的包和 Java 文件。 (右键单击src目录,然后导航到新建 | 包,新建 | 接口或上下文菜单中的新建 | 类。)
现在,让我们获得 Apache Commons Math 库。 从这个页面下载最新版本。 解压缩下载的文件。 在解压缩的文件夹中,找到一个名称为commons-math3-3.5.jar的文件。 (版本号可能不同。)将此文件复制到 Eclipse 项目的libs文件夹中。
添加完所有必需的文件后,您的包资源管理器窗格应类似于以下屏幕快照中的一个:

定义过滤器接口
出于我们的目的,过滤器是可以应用于源图像和目标图像的任何转换。 (源和目标可能是同一张图像,也可能是不同的图像。)我们的应用需要互换使用过滤器,因此,对过滤器接口的定义进行形式化是一个好主意。 让我们编辑Filter.java,以使Filter接口定义如下:
public interface Filter {
public abstract void apply(final Mat src, final Mat dst);
}
就我们的应用而言,apply方法是我们的过滤器必须具有的唯一共同点。 其他所有内容都是实现细节。
Filter接口的最基本实现是NoneFilter类。 顾名思义,NoneFilter完全不进行过滤。 让我们实现它如下:
public class NoneFilter implements Filter {
@Override
public void apply(final Mat src, final Mat dst) {
// Do nothing.
}
}
NoneFilter只是其他过滤器的便捷替代品。 当我们要关闭过滤但仍具有符合Filter接口的对象时,可以使用它。
混合颜色通道
正如我们在第 2 章和“处理相机帧”中看到的那样,OpenCV 将图像数据存储在类型为Mat的矩阵中,类似于多维数组。 行和列(分别由第一和第二索引指定)对应于图像中的 y 和 x 像素坐标。 元素是像素值。 像素值可以用一个数字(在灰度图像的情况下)或多个数字(在彩色图像的情况下)表示。 这些数字中的每一个都属于一个通道。 不透明的灰度图像只有一个通道值(亮度),缩写为 V。彩色图像可能具有多达四个通道,例如红色,绿色,蓝色和 alpha(透明度),它们构成了 RGBA 颜色模型。 彩色图像的其他有用模型包括 RGB(红色,绿色,蓝色),HSV(色相,饱和度,值),和 YUV(亮度,绿色与蓝色,绿色与红色)。 在本书中,我们重点介绍 RGB 和 RGBA 图像,但是 OpenCV 支持其他颜色模型和每种模型的各种数字格式。 如上一章所述,我们可以使用Imgproc.cvtColor静态方法在颜色格式之间进行转换。
如果我们将 RGB 图像矩阵的通道分开,则可以制作三个不同的灰度图像矩阵,每个矩阵都有一个通道。 然后,我们可以对这些单通道矩阵应用一些矩阵算法,并将结果合并以获得另一个 RGB 图像矩阵。 生成的 RGB 图像看起来好像是从其他调色板混合到原始图像。 该技术称为通道混合。 对于 RGB 图像,我们可以使用伪代码如下定义通道混合:
dst.r = funcR(src.r, src.g, src.b)
dst.g = funcG(src.r, src.g, src.b)
dst.b = funcB(src.r, src.g, src.b)
这意味着目标图像中的每个通道都是源图像中任何或所有通道的函数。 我们不会将定义局限于任何特定类型的功能。 但是,让我们注意以下操作的视觉效果,在使用 RGB 图像时,这些效果非常有用:
- 平均值或加权平均值似乎会淡化输出通道。 例如,在伪代码中,如果
dst.b = 0.5 * src.r + 0.5 * src.b,则本来是蓝色的图像区域会变成红色或紫色。 min操作似乎使输出通道去饱和。 例如,在伪代码中,如果dst.b = min(src.r, src.g, src.b),则蓝色变为灰色。max操作似乎使输出通道的互补色不饱和。 例如,在伪代码中,如果dst.b = max(src.r, src.g, src.b),则黄色变为灰色。 (当我们处理 RGB 颜色模型时,黄色是蓝色的补码,换句话说,白色减去蓝色是黄色。)
考虑到这些影响,让我们看一下将用于生成它们的 OpenCV 功能。 OpenCV 的Core类提供所有相关功能作为静态方法。 Core.split(Mat m, List<Mat> mv)方法负责信道拆分。 它以源矩阵和目标矩阵列表作为参数。 将源中的每个通道复制到目标列表中的单通道矩阵中。 如有必要,将用新矩阵填充目标列表。
使用Core.split方法后,我们可以将矩阵运算应用于各个通道。 Core.addWeighted(Mat src1, double alpha, Mat src2, double beta, double gamma, Mat dst)方法可用于获取两个通道的加权平均值。 前四个参数是权重和源矩阵。 第五个参数是添加到结果中的常量。 最后一个参数是目标矩阵。 用伪代码dst = alpha * src1 + beta * src2 + gamma。
注意
通常,使用 OpenCV 中的方法,可以安全地传递目标矩阵,该目标矩阵也是源矩阵。 当然,在这种情况下,源矩阵中的值将被覆盖。 这称为原地操作。
Core.min(Mat src1, Mat src2, Mat dst)和Core.max(Mat src1, Mat src2, Mat dst)方法均采用一对源矩阵和一个目标矩阵。 这些方法执行每个元素的最小值或最大值。
最后,Core.split的倒数是Core.merge(List<Mat> mv, Mat m)。 我们可以使用它从分割的通道重新创建多通道图像。
为了研究信道混合的实际示例,我们打开RecolorRCFilter.java并编写该类的以下实现:
public class RecolorRCFilter implements Filter {
private final ArrayList<Mat> mChannels = new ArrayList<Mat>(4);
@Override
public void apply(final Mat src, final Mat dst) {
Core.split(src, mChannels);
final Mat g = mChannels.get(1);
final Mat b = mChannels.get(2);
// dst.g = 0.5 * src.g + 0.5 * src.b
Core.addWeighted(g, 0.5, b, 0.5, 0.0, g);
// dst.b = dst.g
mChannels.set(2, g);
Core.merge(mChannels, dst);
}
}
该过滤器的作用是将绿色和蓝色变成青色,留下有限的红色和青色调色板。 它类似于某些旧电影和旧计算机游戏的调色板。
作为成员变量,RecolorRCFilter具有四个矩阵的列表。 每当调用apply方法时,此列表中就会填充源矩阵的四个通道。 (我们假设源矩阵和目标矩阵都有四个通道,以 RGBA 顺序排列。)我们获得绿色和蓝色通道(在列表中的索引1和2处),取它们的平均值,然后将结果分配回到相同的通道。 最后,我们将四个通道合并到目标矩阵中,该矩阵可能与源矩阵相同。
我们其他两个通道混合过滤器的代码相似,因此,为了节省时间,我们将省略大部分代码。 请注意,RecolorRGVFilter依赖于以下操作:
// dst.b = min(dst.r, dst.g, dst.b)
Core.min(b, r, b);
Core.min(b, g, b);
该过滤器的作用是使蓝色饱和,从而留下有限的红色,绿色和白色调色板。 它也类似于某些旧电影和旧计算机游戏的调色板。
同样,RecolorCMVFilter依赖于以下操作:
// dst.b = max(dst.r, dst.g, dst.b)
Core.max(b, r, b);
Core.max(b, g, b);
该过滤器的作用是使黄色去饱和,从而留下有限的青色,品红色和白色调色板。 尚无人使用此调色板制作电影(至今!),但对于 1980 年代的游戏玩家来说,这将是熟悉的景象。
以下屏幕截图带是我们的通道混合过滤器的比较。 从左到右,我们看到一个未过滤的图像,然后是用RecolorRCFilter,RecolorRGFilter和RecolorCMVFilter过滤的图像:

注意
这些通道混合过滤器之间的差异在黑白照片中并不明显。 有关彩色图像,请参阅电子书。
RGB 中的任意通道混合功能往往会产生粗体和风格化的效果,而不是微妙的效果。 这是我们这里的例子。 接下来,让我们看一下一系列过滤器,这些过滤器更易于参数化,以获得微妙,自然的结果。
通过曲线的细微的颜色偏移
在观看场景时,我们可能会从颜色在不同图像区域之间移动的方式中获得一些微妙的线索。 例如,在外面的晴天,由于从蓝天反射的环境光,阴影具有淡蓝色的色调,而高光部分则处于阳光直射下,因此具有淡黄色的色调。 当我们在照片中看到偏蓝的阴影和偏黄的高光时,我们可能会感到“温暖而阳光明媚”。 此效果可能是自然的,或者可能被过滤器放大了。
曲线过滤器对于此类操作非常有用。 曲线过滤器通过一组控制点进行参数设置。 例如,每个颜色通道可能有一组控制点。 每个控制点都是一对数字,代表给定通道的输入和输出值。 例如,对(128, 180)意味着给定颜色通道中的128的值变亮,成为180的值。 控制点之间的值沿曲线插值(因此,名称,曲线过滤器)。 在 GIMP 中,具有控制点(0, 0),(128, 180)和(255, 255)的曲线是可视化的,如以下屏幕截图所示:

x轴显示输入值,范围从 0 到 255,而y轴显示相同范围内的输出值。 除了显示曲线外,该图还显示了y = x线(无变化)用于比较。
曲线插值有助于确保颜色过渡平滑而不突然。 因此,曲线过滤器使创建细微,自然的效果相对容易。 我们可以使用伪代码如下定义 RGB 曲线过滤器:
dst.r = funcR(src.r) where funcR interpolates pointsR
dst.g = funcG(src.g) where funcG interpolates pointsG
dst.b = funcB(src.b) where funcB interpolates pointsB
现在,我们将使用 RGB 和 RGBA 曲线过滤器以及通道值范围从 0 到 255 的工作。如果我们希望这种曲线过滤器产生自然的结果,则应使用以下经验法则:
- 每组控制点都应包括
(0, 0)和(255, 255)。 这样,黑色保持黑色,白色保持白色,并且图像似乎没有整体色调。 - 随着输入值的增加,输出值也应始终增加。 (它们的关系应该单调增加。)这样,阴影仍然是阴影,高光仍然是高光,并且图像似乎没有不一致的照明或对比度。
OpenCV 不提供曲线插值功能,但是 Apache Commons Math 库提供。 (有关设置 Apache Commons Math 的说明,请参阅本章前面的“向项目中添加文件”。)该库提供了名为UnivariateInterpolator和UnivariateFunction的接口,这些接口的实现包括LinearInterpolator, SplineInterpolator,LinearFunction和PolynomialSplineFunction。 (样条线是曲线的一种。)UnivariateInterpolator有一个实例方法interpolate(double[] xval, double[] yval),该方法获取控制点的输入和输出值的数组并返回UnivariateFunction对象。 UnivariateFunction对象可以通过value(double x)方法提供内插值。
注意
可在这个页面上找到 Apache Commons Math 的 API 文档。
这些内插函数在计算上是昂贵的。 我们不想在每一帧中为每个像素的每个通道一次又一次地运行它们。 幸运的是,我们不必这样做。 每个通道只有 256 个可能的输入值,因此预先计算所有可能的输出值并将它们存储在查找表中是可行的。 出于 OpenCV 的目的,查找表是Mat对象,其索引表示输入值,而其元素表示输出值。 可以使用静态方法Core.LUT(Mat src, Mat lut, Mat dst)执行查找。 用伪代码dst = lut[src]。 lut中的元素数量应与src中值的范围相匹配,lut中的通道数应与src中的通道数相匹配。
现在,使用 Apache Commons Math 和 OpenCV,让为通道值范围从0到255的 RGBA 图像实现曲线过滤器。 打开CurveFilter.java并编写以下代码:
public class CurveFilter implements Filter {
// The lookup table.
private final Mat mLUT = new MatOfInt();
public CurveFilter(
final double[] vValIn, final double[] vValOut,
final double[] rValIn, final double[] rValOut,
final double[] gValIn, final double[] gValOut,
final double[] bValIn, final double[] bValOut) {
// Create the interpolation functions.
UnivariateFunction vFunc = newFunc(vValIn, vValOut);
UnivariateFunction rFunc = newFunc(rValIn, rValOut);
UnivariateFunction gFunc = newFunc(gValIn, gValOut);
UnivariateFunction bFunc = newFunc(bValIn, bValOut);
// Create and populate the lookup table.
mLUT.create(256, 1, CvType.CV_8UC4);
for (int i = 0; i < 256; i++) {
final double v = vFunc.value(i);
final double r = rFunc.value(v);
final double g = gFunc.value(v);
final double b = bFunc.value(v);
mLUT.put(i, 0, r, g, b, i); // alpha is unchanged
}
}
@Override
public void apply(final Mat src, final Mat dst) {
// Apply the lookup table.
Core.LUT(src, mLUT, dst);
}
private UnivariateFunction newFunc(final double[] valIn,
final double[] valOut) {
UnivariateInterpolator interpolator;
if (valIn.length > 2) {
interpolator = new SplineInterpolator();
} else {
interpolator = new LinearInterpolator();
}
return interpolator.interpolate(valIn, valOut);
}
}
CurveFilter将查找表存储在成员变量中。 构造器方法根据作为参数的四组控制点填充查找表。 为了方便起见,除了每个 RGB 通道的一组控制点之外,构造器还为图像的整体亮度采用了一组控制点。 辅助方法newFunc为每组控制点创建一个适当的插值函数(线性或样条曲线)。 然后,我们遍历可能的输入值并填充查找表。
apply方法是单线的。 它只是将预计算的查找表与给定的源矩阵和目标矩阵一起使用。
可以在子类中扩展CurveFilter,以定义具有一组特定控制点的过滤器。 例如,让我们打开PortraCurveFilter.java并编写以下代码:
public class PortraCurveFilter extends CurveFilter {
public PortraCurveFilter() {
super(
new double[] { 0, 23, 157, 255 }, // vValIn
new double[] { 0, 20, 173, 255 }, // vValOut
new double[] { 0, 69, 213, 255 }, // rValIn
new double[] { 0, 69, 218, 255 }, // rValOut
new double[] { 0, 52, 189, 255 }, // gValIn
new double[] { 0, 47, 196, 255 }, // gValOut
new double[] { 0, 41, 231, 255 }, // bValIn
new double[] { 0, 46, 228, 255 }); // bValOut
}
}
此过滤器可以增亮图像,使阴影更冷(更蓝),并使高光更暖(更黄)。 它会产生讨人喜欢的肤色,并趋于使事物看起来更加阳光和清洁。 它类似于通常用于人像拍摄的柯达摄影胶片品牌的色彩特征。
我们其他三个通道混合过滤器的代码相似。 ProviaCurveFilter类对其控制点使用以下参数:
new double[] { 0, 255 }, // vValIn
new double[] { 0, 255 }, // vValOut
new double[] { 0, 59, 202, 255 }, // rValIn
new double[] { 0, 54, 210, 255 }, // rValOut
new double[] { 0, 27, 196, 255 }, // gValIn
new double[] { 0, 21, 207, 255 }, // gValOut
new double[] { 0, 35, 205, 255 }, // bValIn
new double[] { 0, 25, 227, 255 }); // bValOut
此过滤器的作用是增加阴影和高光之间的对比度,并在大多数色调中使图像稍微凉一点(带蓝色)。 天空,水和阴影比太阳更突出。 它类似于一个名为 Fuji Provia 的摄影胶片品牌,该胶片通常用于风景拍摄。 例如,下面的照片是在 Provia 胶片上拍摄的,该胶片在其他阳光明媚的场景中突显了蓝色,雾蒙蒙的背景:

VelviaCurveFilter类使用以下参数作为其控制点:
new double[] { 0, 128, 221, 255 }, // vValIn
new double[] { 0, 118, 215, 255 }, // vValOut
new double[] { 0, 25, 122, 165, 255 }, // rValIn
new double[] { 0, 21, 153, 206, 255 }, // rValOut
new double[] { 0, 25, 95, 181, 255 }, // gValIn
new double[] { 0, 21, 102, 208, 255 }, // gValOut
new double[] { 0, 35, 205, 255 }, // bValIn
new double[] { 0, 25, 227, 255 }); // bValOut
该过滤器的作用是产生深阴影和鲜艳的色彩。 它类似于一个名为 Fuji Velvia 的摄影胶片品牌,通常用于描绘风景,白天有蔚蓝的天空,日落时有深红色的云。 下一张照片是在 Velvia 胶片上拍摄的,在这个阳光明媚的场景中,我们可以看到 Velvia 独特的深阴影和蔚蓝的天空(或黑白印刷版中的板岩灰色天空):

最后,CrossProcessCurveFilter类将以下参数用作其控制点:
new double[] { 0, 255 }, // vValIn
new double[] { 0, 255 }, // vValOut
new double[] { 0, 56, 211, 255 }, // rValIn
new double[] { 0, 22, 255, 255 }, // rValOut
new double[] { 0, 56, 208, 255 }, // gValIn
new double[] { 0, 39, 226, 255 }, // gValOut
new double[] { 0, 255 }, // bValIn
new double[] { 20, 235 }); // bValOut
效果是阴影中的颜色为深蓝色或绿蓝色,高光中的颜色为深黄色或绿黄色。 它类似于一种称为交叉处理的胶片处理技术,该技术有时用于生成时装模特,流行歌手等的,脚照片。
注意
有关如何模拟摄影胶片的各个品牌的良好讨论,请参阅 Petteri Sulonen 的博客。 我们使用的控制点基于本文给出的示例。
以下屏幕截图展示了我们的曲线过滤器。 有些差异是细微的。 从左到右,我们看到一个未过滤的图像,然后是用PortraCurveFilter,ProviaCurveFilter,VelviaCurveFilter和CrossProcessCurveFilter过滤的图像:

注意
这些曲线过滤器之间的差异在黑白打印中并不明显。 有关彩色图像,请参阅电子书。
曲线过滤器是用于操纵颜色和对比度的便捷工具,但是它们受到限制,因为每个目标像素仅受单个输入像素的影响。 接下来,我们将研究更灵活的过滤器系列,这些过滤器可以使每个目标像素受到输入像素邻域的影响。
使用卷积过滤器混合像素
对于卷积过滤器,每个输出像素处的通道值是输入像素附近的相应通道值的加权平均值。 我们可以将权重放在称为卷积矩阵或核的矩阵中。 例如,考虑以下内核:
{{ 0, -1, 0},
{-1, 4, -1},
{ 0, -1, 0}}
中心元素是具有与目标像素相同索引的源像素的权重。 其他元素表示输入像素附近其他部分的权重。 在这里,我们正在考虑一个3 x 3的社区。 但是,OpenCV 支持具有任何平方和奇数尺寸的内核。 这个特定的内核是一种边缘查找过滤器,称为拉普拉斯过滤器。 对于纯色邻域(无对比度),它将产生黑色输出像素。 对于高对比度的邻域,可能会产生明亮的输出像素。
让我们考虑另一个内核,其中心元素大 1:
{{ 0, -1, 0},
{-1, 5, -1},
{ 0, -1, 0}}
这等效于获取拉普拉斯过滤器的结果,然后将其添加到原始图像中。 代替边缘查找,我们得到边缘锐化。 即,边缘区域变亮,而图像的其余部分保持不变。
提示
当心大核
内核越大,计算越昂贵。 大于5 x 5(每个输出像素 25 个输入像素)的内核对于当今在典型的 Android 设备上处理实时高清视频可能不切实际。
OpenCV 为使用某些流行内核的卷积过滤器提供了许多静态方法。 以下是一些示例:
-
Imgproc.blur(Mat src, Mat dst, Size ksize):这通过对尺寸ksize的邻域进行简单的平均来模糊图像。 例如,如果ksize为new Size(5, 5),则内核如下:{{0.04, 0.04, 0.04, 0.04, 0.04}, {0.04, 0.04, 0.04, 0.04, 0.04}, {0.04, 0.04, 0.04, 0.04, 0.04}, {0.04, 0.04, 0.04, 0.04, 0.04}, {0.04, 0.04, 0.04, 0.04, 0.04}} -
Imgproc.Laplacian(Mat src, Mat dst, int ddepth, int ksize, double scale, double delta):这是拉普拉斯边缘查找过滤器,如先前所述。 结果与常数(scale参数)相乘,然后加到另一个常数(delta参数)上。 -
Imgproc.Scharr(Mat src, Mat dst, int ddepth, int dx, int dy, double scale, double delta):这是一个 Scharr 寻边过滤器。 与拉普拉斯过滤器不同,Scharr 过滤器仅查找沿特定方向延伸的边缘,即垂直(如果dx为 1 且dy为 0)或水平(如果dx为 0 且dy为 1)的边缘。 对于垂直边缘检测,内核如下:{{ -3, 0, 3}, {-10, 0, 10}, { -3, 0, 3}}同样,对于水平边缘检测,Scharr 内核如下:
{{ -3, -10, -3}, { 0, 0, 0}, { 3, 10, 3}}
而且,OpenCV 提供了一个静态方法Imgproc.filter2D(Mat src, Mat dst, int ddepth, Mat kernel),它使我们能够指定自己的内核。 出于学习目的,我们将采用这种方法。 ddepth参数确定目标数据的数字类型。 该参数可以是以下任意一个:
-
-1:这表示与源相同的数字类型。 -
CvType.CV_16S:代表 16 位有符号整数。 源必须是 8 位带符号整数。 -
CvType.CV_32F:这表示 32 位浮点数。 -
CvType.CV_64F:这表示 64 位浮点数。 源也必须是 64 位浮点数。注意
有关上述所有过滤器函数的更多信息,请参见 OpenCV 的
Imgproc模块的主要文档,位于这个页面。 本文档包括过滤器的数学描述。 另请参阅Imgproc的官方教程,其中提供了涵盖各种过滤器的示例。有关
CvType成员的更多信息,请参见这个页面上的相关 Javadoc。
让我们将卷积过滤器用作更复杂的过滤器的一部分,该过滤器在图像边缘区域的顶部绘制粗黑线。 为了达到这种效果,我们还依赖于 OpenCV 的另外两种静态方法:
Core.bitwise_not(Mat src, Mat dst):此方法反转图像的亮度和颜色,以使白色变为黑色,红色变为青色,依此类推。 这对我们很有用,因为我们的卷积过滤器会在黑场上产生白色边缘,而我们希望在白场上产生相反的黑色边缘。Core.multiply(Mat s, Mat src2, Mat dst, double scale):此方法通过将一对图像的值相乘在一起来混合它们。 结果值由常量(scale参数)缩放。 例如,scale可用于将乘积标准化为[0, 255]范围。 出于我们的目的,Core.multiply可用于在原始图像上叠加黑色边缘。
以下是StrokeEdgesFilter中变黑边缘效果的实现:
public class StrokeEdgesFilter implements Filter {
private final Mat mKernel = new MatOfInt(
0, 0, 1, 0, 0,
0, 1, 2, 1, 0,
1, 2, -16, 2, 1,
0, 1, 2, 1, 0,
0, 0, 1, 0, 0
);
private final Mat mEdges = new Mat();
@Override
public void apply(final Mat src, final Mat dst) {
Imgproc.filter2D(src, mEdges, -1, mKernel);
Core.bitwise_not(mEdges, mEdges);
Core.multiply(src, mEdges, dst, 1.0/255.0);
}
}
注意
有关Mat及其子类(例如MatOfInt)的更多信息,请参见Mat Javadoc。
下面的对屏幕截图是未过滤图像(左)与用StrokeEdgesFilter过滤图像(右)之间的比较:

接下来,让我们添加一个用于启用和禁用所有过滤器的用户界面。
将过滤器添加到CameraActivity
我们将使用户可以随时激活多达个通道混合过滤器,一个曲线过滤器和一个卷积过滤器。 对于每个过滤器类别,我们将提供一个菜单按钮,使用户可以循环浏览可用的过滤器或不使用过滤器。
让我们开始编辑相关的资源文件,以定义菜单按钮及其文本。 我们应该在res/values/strings.xml中添加以下字符串:
<string name="menu_next_curve_filter">Next Curve</string>
<string name="menu_next_mixer_filter">Next Mixer</string>
<string name="menu_next_convolution_filter">Next Kernel</string>
然后,我们应如下编辑res/menu/activity_camera.xml:
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_next_curve_filter"
app:showAsAction="ifRoom|withText"
android:title="@string/menu_next_curve_filter" />
<item
android:id="@+id/menu_next_mixer_filter"
app:showAsAction="ifRoom|withText"
android:title="@string/menu_next_mixer_filter" />
<item
android:id="@+id/menu_next_convolution_filter"
app:showAsAction="ifRoom|withText"
android:title="@string/menu_next_convolution_filter" />
<item
android:id="@+id/menu_next_camera"
app:showAsAction="ifRoom|withText"
android:title="@string/menu_next_camera" />
<item
android:id="@+id/menu_take_photo"
app:showAsAction="always|withText"
android:title="@string/menu_take_photo" />
</menu>
为了存储有关可用过滤器和选定过滤器的信息,我们需要在CameraActivity中添加几个新变量。 可用的过滤器只是Filter[]数组。 通过将整数与 Android Bundle对象进行串行化和反序列化(保存和恢复),可以按照与选定相机设备的索引相同的方式存储选定过滤器的索引。 以下是我们必须添加到CameraActivity的变量声明:
// Keys for storing the indices of the active filters.
private static final String STATE_CURVE_FILTER_INDEX =
"curveFilterIndex";
private static final String STATE_MIXER_FILTER_INDEX =
"mixerFilterIndex";
private static final String STATE_CONVOLUTION_FILTER_INDEX =
"convolutionFilterIndex";
// The filters.
private Filter[] mCurveFilters;
private Filter[] mMixerFilters;
private Filter[] mConvolutionFilters;
// The indices of the active filters.
private int mCurveFilterIndex;
private int mMixerFilterIndex;
private int mConvolutionFilterIndex;
由于我们的Filter实现依赖于 OpenCV 中的类,因此在加载 OpenCV 库之前,无法实例化它们。 因此,我们的BaseLoaderCallback对象负责初始化Filter[]数组。 我们应该对其进行如下编辑:
private BaseLoaderCallback mLoaderCallback =
new BaseLoaderCallback(this) {
@Override
public void onManagerConnected(final int status) {
switch (status) {
case LoaderCallbackInterface.SUCCESS:
Log.d(TAG, "OpenCV loaded successfully");
mCameraView.enableView();
mBgr = new Mat();
mCurveFilters = new Filter[] {
new NoneFilter(),
new PortraCurveFilter(),
new ProviaCurveFilter(),
new VelviaCurveFilter(),
new CrossProcessCurveFilter()
};
mMixerFilters = new Filter[] {
new NoneFilter(),
new RecolorRCFilter(),
new RecolorRGVFilter(),
new RecolorCMVFilter()
};
mConvolutionFilters = new Filter[] {
new NoneFilter(),
new StrokeEdgesFilter()
};
break;
default:
super.onManagerConnected(status);
break;
}
}
};
onCreate方法可以初始化所选的过滤器索引,或从savedInstanceState参数加载。 让我们如下编辑方法:
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Window window = getWindow();
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if (savedInstanceState != null) {
mCameraIndex = savedInstanceState.getInt(
STATE_CAMERA_INDEX, 0);
mImageSizeIndex = savedInstanceState.getInt(
STATE_IMAGE_SIZE_INDEX, 0);
mCurveFilterIndex = savedInstanceState.getInt(
STATE_CURVE_FILTER_INDEX, 0);
mMixerFilterIndex = savedInstanceState.getInt(
STATE_MIXER_FILTER_INDEX, 0);
mConvolutionFilterIndex = savedInstanceState.getInt(
STATE_CONVOLUTION_FILTER_INDEX, 0);
} else {
mCameraIndex = 0;
mImageSizeIndex = 0;
mCurveFilterIndex = 0;
mMixerFilterIndex = 0;
mConvolutionFilterIndex = 0;
}
// ...
}
同样,onSaveInstanceState方法应将选定的过滤器索引保存到savedInstanceState参数中。 让我们如下编辑方法:
public void onSaveInstanceState(Bundle savedInstanceState) {
// Save the current camera index.
savedInstanceState.putInt(STATE_CAMERA_INDEX, mCameraIndex);
// Save the current image size index.
savedInstanceState.putInt(STATE_IMAGE_SIZE_INDEX,
mImageSizeIndex);
// Save the current filter indices.
savedInstanceState.putInt(STATE_CURVE_FILTER_INDEX,
mCurveFilterIndex);
savedInstanceState.putInt(STATE_MIXER_FILTER_INDEX,
mMixerFilterIndex);
savedInstanceState.putInt(STATE_CONVOLUTION_FILTER_INDEX,
mConvolutionFilterIndex);
super.onSaveInstanceState(savedInstanceState);
}
为了使每个新菜单都起作用,我们只需要添加一些样板代码即可更新相关的过滤器索引。 让我们如下编辑onOptionsItemSelected方法:
public boolean onOptionsItemSelected(final MenuItem item) {
if (mIsMenuLocked) {
return true;
}
if (item.getGroupId() == MENU_GROUP_ID_SIZE) {
mImageSizeIndex = item.getItemId();
recreate();
return true;
}
switch (item.getItemId()) {
case R.id.menu_next_curve_filter:
mCurveFilterIndex++;
if (mCurveFilterIndex == mCurveFilters.length) {
mCurveFilterIndex = 0;
}
return true;
case R.id.menu_next_mixer_filter:
mMixerFilterIndex++;
if (mMixerFilterIndex == mMixerFilters.length) {
mMixerFilterIndex = 0;
}
return true;
case R.id.menu_next_convolution_filter:
mConvolutionFilterIndex++;
if (mConvolutionFilterIndex ==
mConvolutionFilters.length) {
mConvolutionFilterIndex = 0;
}
return true;
// ...
default:
return super.onOptionsItemSelected(item);
}
}
现在,在onCameraFrame回调方法中,我们应该将每个选定的过滤器应用于图像。 以下是新的实现:
public Mat onCameraFrame(final CvCameraViewFrame inputFrame) {
final Mat rgba = inputFrame.rgba();
// Apply the active filters.
mCurveFilters[mCurveFilterIndex].apply(rgba, rgba);
mMixerFilters[mMixerFilterIndex].apply(rgba, rgba);
mConvolutionFilters[mConvolutionFilterIndex].apply(
rgba, rgba);
if (mIsPhotoPending) {
mIsPhotoPending = false;
takePhoto(rgba);
}
if (mIsCameraFrontFacing) {
// Mirror (horizontally flip) the preview.
Core.flip(rgba, rgba, 1);
}
return rgba;
}
就这样! 运行该应用,选择过滤器,拍摄一些照片并共享。 作为应用外观的示例,下面是启用PortraCurveFilter,RecolorRCFilter和StrokeEdgesFilter的屏幕截图:

注意
记住要查看...菜单下的更多选项,包括下个核和大小。
请注意,由于使用了StrokeEdgesFilter的,猫的的皱纹显得非常暗。 另外,如果您正在查看彩色图像(在电子书中),请注意,由于使用RecolorRCFilter,姜猫和黄色沙发都带有淡红色调,而背景中的叶子则带有青色调。
总结
Second Sight 现在具有一些功能,而不仅仅是读取和共享相机数据更有趣。 可以选择几个过滤器并将其组合在一起,以使我们的照片具有风格化或复古的外观。 这些过滤器也足够有效地应用于实时视频,因此我们在预览模式和保存的照片中使用它们。
尽管照片过滤器很有趣,但它们只是 OpenCV 的最基本用途。 在我们可以真正说出我们已经制作了一个计算机视觉应用之前,我们需要根据所看到的内容使该应用做出不同的响应。 该目标将是下一章的重点。
四、识别和跟踪图像
本章的目标是将图像跟踪添加到 Second Sight。 我们将训练该应用以识别某些任意的矩形图像(例如绘画),并确定它们在 2D 投影中的姿势。 当该应用出现在相机源中时,它将在跟踪的图像周围绘制轮廓。 所有的跟踪和绘制都是使用 OpenCV 而不是其他 Android 库完成的。
注意
可以从作者的网站下载本章的完整 Eclipse 项目。 该项目有两个版本:
OpenCV 3.x 的版本位于这个页面。
位于这个页面的 OpenCV 2.x 版本。
将文件添加到项目
对于本章,我们需要添加一个新类com.nummist.secondsight.filters.ar.ImageDetectionFilter。 我们还需要添加一些资源文件,即我们要跟踪的图像。 从这个页面下载图像,将其解压缩,并将其放入项目的res/drawable-nodpi文件夹中。
这些图像是 19 世纪荷兰画家文森特·梵高和 16 世纪印度画家 Basawan。 我们的跟踪器可以很好地处理这些图像,因为它们包含许多高对比度的细节,而无需重复很多图案。 因此,是,可以在每个图像的大多数部分中进行跟踪。 例如,这是巴沙旺(Basawan)的一幅画作《阿克巴猎豹》:

这是文森特·梵高的画作之一《星空》:

了解图像跟踪
想象下面的对话:
A:我找不到《星空》的印刷品。 你知道在哪里吗?
B:看起来像什么?
对于计算机或对于西方艺术无知的人,B 的问题是很合理的。 在我们可以使用视感(或其他感官)来追踪某物之前,我们需要先感知到该物。 否则,我们至少需要对我们将要感知到的东西有一个很好的描述。 对于计算机视觉,我们必须提供参考图像,它将与实时摄像机图像或场景进行比较。 如果目标具有复杂的几何形状或运动部件,我们可能需要提供许多参考图像以说明不同的视角和姿势。 但是,对于使用著名绘画的示例,我们将假定目标是矩形且刚性的。
注意
Google 的按图像搜索是流行工具的示例,该工具要求用户提供参考图像。 如果您以前从未使用过它,请转到这个页面,单击相机图标,然后通过输入 URL 或上传文件来提供参考图像。 搜索结果应包括匹配或相似的图像。 该工具可以帮助想要验证图像原始来源的研究人员。
出于本章的目的,我们说跟踪的目的是确定如何在 3D 中放置矩形目标。 有了这些信息,我们就可以围绕目标绘制轮廓。 在最终的 2D 图像中,轮廓将为四边形。 (它不一定是矩形,因为目标可能会偏离相机。)
这种跟踪涉及四个主要步骤:
- 在参考图像和场景中找到特征。 从不同的距离或角度观察时,特征是可能保持相似外观的点。 例如,角点经常具有这种特性。 请参阅这个页面和这个页面。
- 为每组特征(参考和场景特征)找到描述符。 描述符是有关特征的数据向量。 某些功能不适合生成描述符,因此图像比特征具有更少的描述符。 参见这个页面。
- 查找两组描述符(参考和场景描述符)之间的匹配项。 如果我们将描述符想象为多维空间中的点,则根据点之间的距离度量来定义匹配。 彼此距离足够近的描述符被视为匹配项。 当一对描述符是匹配项时,我们也可以说基础特征对是匹配项。
- 在场景中的参考图像和匹配图像之间找到单应性。 单应性是一种 3D 变换,需要将两个投影的 2D 图像对齐(或尽可能靠近以对齐它们)。 它是基于两个图像的匹配特征点计算的。 通过将单应性应用于矩形,我们可以获得跟踪对象的轮廓。 参见这个页面。
有很多不同的技术可以执行前三个步骤。 OpenCV 提供了称为FeatureDetector,DescriptorExtractor和DescriptorMatcher分别支持几种技术。 我们将使用 OpenCV 称为FeatureDetector.ORB,DescriptorExtractor.ORB和DescriptorMatcher.BRUTEFORCE_HAMMINGLUT的技术组合。 这种组合是相对快速且鲁棒的。 与某些替代方案不同,它是缩放不变的和旋转不变的,这意味着可以在各种放大倍率,分辨率或距离下跟踪目标,并且从各个角度来看。 另外,与其他替代方案不同,它没有专利权,因此即使在商业应用中也可以免费使用。
注意
有关 ORB 的描述,其他技术及其相对优点,请参阅 Gil Levi 博客上二进制描述符的多部分教程。 作为该主题的先驱,另请参见描述符简介。
BahinIşık 和 KemalÖzkan 撰写的《对知名特征检测器和描述符的比较评估》是 ORB 及其替代品的另一个很好的来源。 可在这个页面上获得本文的电子版本。
最后,有关 OpenCV 中实现的 ORB 和替代方案的基准,请参阅 Ievgen Khvedchenya 的特征检测器和描述符提取器的博客文章。
在撰写本文时,OpenCV 的特征检测器仅支持灰度图像。 就我们的目的而言,此限制并不是很糟糕,因为用于灰度特征检测的算法比用于颜色特征检测的算法要快。 在移动设备上,我们必须进行节能以保持实时视频的合理速度,并且在每帧的灰度版本上运行计算机视觉功能是一种节能的方法。 但是,为了获得最佳结果,我们应确保选择的目标图像在转换为灰度时仍具有很强的对比度。
以下伪代码表示 OpenCV 中使用的标准灰度转换公式:
grayValue(r, g, b) = (0.299 * r) + (0.587 * g) + (0.114 * b)
前面的公式在保留蓝黄色对比度(其中黄色是红色加绿色)以及当然在黑白对比度方面最有效。 例如,此标准灰度转换在《星空》中保持对比度方面做得很好,该调色板具有主要由蓝色,黄色,黑色和白色组成的调色板。
编写图像跟踪过滤器
我们将把我们的跟踪器编写为在上一章中创建的Filter接口的实现。 追踪器的类别名称将为ImageDetectionFilter。 作为成员变量,此类具有FeatureDetector,DescriptorExtractor和DescriptorMatcher的实例,以及几个Mat实例,这些实例存储图像数据以及跟踪计算的中间或最终结果。 之所以存储其中一些结果,是因为它们在不同的帧之间不会改变。 之所以存储其他文件,是因为它比为每个帧重新创建Mat实例更有效。 类和成员变量的声明如下:
public class ImageDetectionFilter implements Filter {
// The reference image (this detector's target).
private final Mat mReferenceImage;
// Features of the reference image.
private final MatOfKeyPoint mReferenceKeypoints =
new MatOfKeyPoint();
// Descriptors of the reference image's features.
private final Mat mReferenceDescriptors = new Mat();
// The corner coordinates of the reference image, in pixels.
// CvType defines the color depth, number of channels, and
// channel layout in the image. Here, each point is represented
// by two 32-bit floats.
private final Mat mReferenceCorners =
new Mat(4, 1, CvType.CV_32FC2);
// Features of the scene (the current frame).
private final MatOfKeyPoint mSceneKeypoints =
new MatOfKeyPoint();
// Descriptors of the scene's features.
private final Mat mSceneDescriptors = new Mat();
// Tentative corner coordinates detected in the scene, in
// pixels.
private final Mat mCandidateSceneCorners =
new Mat(4, 1, CvType.CV_32FC2);
// Good corner coordinates detected in the scene, in pixels.
private final Mat mSceneCorners = new Mat(4, 1,
CvType.CV_32FC2);
// The good detected corner coordinates, in pixels, as integers.
private final MatOfPoint mIntSceneCorners = new MatOfPoint();
// A grayscale version of the scene.
private final Mat mGraySrc = new Mat();
// Tentative matches of scene features and reference features.
private final MatOfDMatch mMatches = new MatOfDMatch();
// A feature detector, which finds features in images.
private final FeatureDetector mFeatureDetector =
FeatureDetector.create(FeatureDetector.ORB);
// A descriptor extractor, which creates descriptors of
// features.
private final DescriptorExtractor mDescriptorExtractor =
DescriptorExtractor.create(DescriptorExtractor.ORB);
// A descriptor matcher, which matches features based on their
// descriptors.
private final DescriptorMatcher mDescriptorMatcher =
DescriptorMatcher.create(
DescriptorMatcher.BRUTEFORCE_HAMMINGLUT);
// The color of the outline drawn around the detected image.
private final Scalar mLineColor = new Scalar(0, 255, 0);
我们想要一种方便的方法来制作任意图像的图像跟踪器。 我们可以使用我们的应用将图像打包为可绘制资源,这些资源可以由任何 Android Context子类(例如Activity)加载。 因此,我们提供了一个构造器ImageDetectionFilter(final Context context, final int referenceImageResourceID),该构造器使用给定的Context和资源标识符加载参考图像。 图像的 RGBA 和灰度版本存储在成员变量中。 图像的角点也将被存储,其特征和描述符也将被存储。 构造器的代码如下:
public ImageDetectionFilter(final Context context,
final int referenceImageResourceID) throws IOException {
// Load the reference image from the app's resources.
// It is loaded in BGR (blue, green, red) format.
mReferenceImage = Utils.loadResource(context,
referenceImageResourceID,
Imgcodecs.CV_LOAD_IMAGE_COLOR);
// Create grayscale and RGBA versions of the reference image.
final Mat referenceImageGray = new Mat();
Imgproc.cvtColor(mReferenceImage, referenceImageGray,
Imgproc.COLOR_BGR2GRAY);
Imgproc.cvtColor(mReferenceImage, mReferenceImage,
Imgproc.COLOR_BGR2RGBA);
// Store the reference image's corner coordinates, in pixels.
mReferenceCorners.put(0, 0,
new double[] {0.0, 0.0});
mReferenceCorners.put(1, 0,
new double[] {referenceImageGray.cols(), 0.0});
mReferenceCorners.put(2, 0,
new double[] {referenceImageGray.cols(),
referenceImageGray.rows()});
mReferenceCorners.put(3, 0,
new double[] {0.0, referenceImageGray.rows()});
// Detect the reference features and compute their
// descriptors.
mFeatureDetector.detect(referenceImageGray,
mReferenceKeypoints);
mDescriptorExtractor.compute(referenceImageGray,
mReferenceKeypoints, mReferenceDescriptors);
}
提示
使代码适配 OpenCV 2.x
将Imgcodecs.CV_LOAD_IMAGE_COLOR替换为Highgui.CV_LOAD_IMAGE_COLOR。
回想一下Filter接口声明了一种方法apply(final Mat src, final Mat dst)。 我们对这种方法的实现将特征检测器,描述符提取器和描述符匹配器应用于源图像的灰度版本。 然后,我们调用帮助程序函数,该函数查找被跟踪目标的四个角(如果有),并绘制四边形轮廓。 代码如下:
@Override
public void apply(final Mat src, final Mat dst) {
// Convert the scene to grayscale.
Imgproc.cvtColor(src, mGraySrc, Imgproc.COLOR_RGBA2GRAY);
// Detect the scene features, compute their descriptors,
// and match the scene descriptors to reference descriptors.
mFeatureDetector.detect(mGraySrc, mSceneKeypoints);
mDescriptorExtractor.compute(mGraySrc, mSceneKeypoints,
mSceneDescriptors);
mDescriptorMatcher.match(mSceneDescriptors,
mReferenceDescriptors, mMatches);
// Attempt to find the target image's corners in the scene.
findSceneCorners();
// If the corners have been found, draw an outline around the
// target image.
// Else, draw a thumbnail of the target image. draw(src, dst);
}
findSceneCorners()辅助方法是一个更大的代码块,但是很多方法只是简单地遍历匹配项以汇编最佳列表。 如果所有匹配都非常差(如较大的距离值所示),则假定目标不在场景中,并且清除之前对其角点位置的任何估计。 如果比赛既不好也不好,我们假定目标在场景中的某个地方,但是我们保留其先前估计的角点位置。 此政策有助于我们稳定对角点位置的估计。 最后,如果匹配良好且至少有四个匹配项,我们找到单应性并使用它来更新估计的角点位置。
注意
有关查找单应性的数学描述,请参见官方 OpenCV 文档。
findSceneCorners()的实现如下:
private void findSceneCorners() {
List<DMatch> matchesList = mMatches.toList();
if (matchesList.size() < 4) {
// There are too few matches to find the homography.
return;
}
List<KeyPoint> referenceKeypointsList =
mReferenceKeypoints.toList();
List<KeyPoint> sceneKeypointsList =
mSceneKeypoints.toList();
// Calculate the max and min distances between keypoints.
double maxDist = 0.0;
double minDist = Double.MAX_VALUE;
for(DMatch match : matchesList) {
double dist = match.distance;
if (dist < minDist) {
minDist = dist;
}
if (dist > maxDist) {
maxDist = dist;
}
}
// The thresholds for minDist are chosen subjectively
// based on testing. The unit is not related to pixel
// distances; it is related to the number of failed tests
// for similarity between the matched descriptors.
if (minDist > 50.0) {
// The target is completely lost.
// Discard any previously found corners.
mSceneCorners.create(0, 0, mSceneCorners.type());
return;
} else if (minDist > 25.0) {
// The target is lost but maybe it is still close.
// Keep any previously found corners.
return;
}
// Identify "good" keypoints based on match distance.
ArrayList<Point> goodReferencePointsList =
new ArrayList<Point>();
ArrayList<Point> goodScenePointsList =
new ArrayList<Point>();
double maxGoodMatchDist = 1.75 * minDist;
for(DMatch match : matchesList) {
if (match.distance < maxGoodMatchDist) {
goodReferencePointsList.add(
referenceKeypointsList.get(match.trainIdx).pt);
goodScenePointsList.add(
sceneKeypointsList.get(match.queryIdx).pt);
}
}
if (goodReferencePointsList.size() < 4 ||
goodScenePointsList.size() < 4) {
// There are too few good points to find the homography.
return;
}
// There are enough good points to find the homography.
// (Otherwise, the method would have already returned.)
// Convert the matched points to MatOfPoint2f format, as
// required by the Calib3d.findHomography function.
MatOfPoint2f goodReferencePoints = new MatOfPoint2f();
goodReferencePoints.fromList(goodReferencePointsList);
MatOfPoint2f goodScenePoints = new MatOfPoint2f();
goodScenePoints.fromList(goodScenePointsList);
// Find the homography.
Mat homography = Calib3d.findHomography(
goodReferencePoints, goodScenePoints);
// Use the homography to project the reference corner
// coordinates into scene coordinates.
Core.perspectiveTransform(mReferenceCorners,
mCandidateSceneCorners, homography);
// Convert the scene corners to integer format, as required
// by the Imgproc.isContourConvex function.
mCandidateSceneCorners.convertTo(mIntSceneCorners,
CvType.CV_32S);
// Check whether the corners form a convex polygon. If not,
// (that is, if the corners form a concave polygon), the
// detection result is invalid because no real perspective can
// make the corners of a rectangular image look like a concave
// polygon!
if (Imgproc.isContourConvex(mIntSceneCorners)) {
// The corners form a convex polygon, so record them as
// valid scene corners.
mCandidateSceneCorners.copyTo(mSceneCorners);
}
}
我们的另一个帮助器方法draw(Mat src, Mat dst)首先将源图像复制到目标位置。 然后,如果未跟踪目标,则在图像的一角绘制一个缩略图,以便用户知道要查找的内容。 如果正在追踪目标,我们会在其周围绘制轮廓。 以下一对屏幕快照显示了draw方法的结果,当未跟踪目标(左侧)时和在跟踪目标(右侧)时:

以下代码实现draw帮助器方法:
protected void draw(Mat src, Mat dst) {
if (dst != src) {
src.copyTo(dst);
}
if (mSceneCorners.height() < 4) {
// The target has not been found.
// Draw a thumbnail of the target in the upper-left
// corner so that the user knows what it is.
// Compute the thumbnail's larger dimension as half the
// video frame's smaller dimension.
int height = mReferenceImage.height();
int width = mReferenceImage.width();
int maxDimension = Math.min(dst.width(),
dst.height()) / 2;
double aspectRatio = width / (double)height;
if (height > width) {
height = maxDimension;
width = (int)(height * aspectRatio);
} else {
width = maxDimension;
height = (int)(width / aspectRatio);
}
// Select the region of interest (ROI) where the thumbnail
// will be drawn.
Mat dstROI = dst.submat(0, height, 0, width);
// Copy a resized reference image into the ROI.
Imgproc.resize(mReferenceImage, dstROI, dstROI.size(),
0.0, 0.0, Imgproc.INTER_AREA);
return;
}
// Outline the found target in green.
Imgproc.line(dst, new Point(mSceneCorners.get(0, 0)),
new Point(mSceneCorners.get(1, 0)), mLineColor, 4);
Imgproc.line(dst, new Point(mSceneCorners.get(1, 0)),
new Point(mSceneCorners.get(2, 0)), mLineColor, 4);
Imgproc.line(dst, new Point(mSceneCorners.get(2, 0)),
new Point(mSceneCorners.get(3, 0)), mLineColor, 4);
Imgproc.line(dst, new Point(mSceneCorners.get(3,0)),
new Point(mSceneCorners.get(0, 0)), mLineColor, 4);
}
}
提示
使代码适配 OpenCV 2.x
将Imgproc.line替换为Core.line。
尽管ImageDetectionFilter的实现比我们以前的过滤器更复杂,但它仍然具有简单的接口。 您必须使用可绘制资源实例化它,然后根据需要将过滤器应用于源图像和目标图像。
将跟踪器过滤器添加到CameraActivity
为了使用ImageDetectionFilter的实例,我们对CameraActivity进行了与上一章中其他过滤器相同的修改。 回想一下,我们所有的过滤器类都实现了Filter接口,以便CameraActivity可以类似的方式使用它们。
首先,我们需要在res/values/strings.xml中定义一些文本(用于菜单按钮):
<string name="menu_next_image_detection_filter">Next
Tracker</string>
接下来,我们需要在res/menu/activity_camera.xml中定义菜单按钮本身:
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_next_image_detection_filter"
app:showAsAction="ifRoom|withText"
android:title="@string/menu_next_image_detection_filter" />
<!-- ... -->
</menu>
我们的其余修改都与CameraActivity.java有关。 我们需要添加新的成员变量来跟踪所选的图像检测过滤器:
// Keys for storing the indices of the active filters.
private static final String STATE_IMAGE_DETECTION_FILTER_INDEX =
"imageDetectionFilterIndex";
private static final String STATE_CURVE_FILTER_INDEX =
"curveFilterIndex";
private static final String STATE_MIXER_FILTER_INDEX =
"mixerFilterIndex";
private static final String STATE_CONVOLUTION_FILTER_INDEX =
"convolutionFilterIndex";
// The filters.
private Filter[] mImageDetectionFilters;
private Filter[] mCurveFilters;
private Filter[] mMixerFilters;
private Filter[] mConvolutionFilters;
// The indices of the active filters.
private int mImageDetectionFilterIndex;
private int mCurveFilterIndex;
private int mMixerFilterIndex;
private int mConvolutionFilterIndex;
初始化 OpenCV 之后,我们需要实例化所有图像检测过滤器并将它们放置在数组中。 为简便起见,我添加了两个图像检测过滤器作为示例,但您可以轻松修改以下代码以支持跟踪更多图像或不同图像:
public void onManagerConnected(final int status) {
switch (status) {
case LoaderCallbackInterface.SUCCESS:
Log.d(TAG, "OpenCV loaded successfully");
mCameraView.enableView();
mBgr = new Mat();
final Filter starryNight;
try {
starryNight = new ImageDetectionFilter(
CameraActivity.this,
R.drawable.starry_night);
} catch (IOException e) {
Log.e(TAG, "Failed to load drawable: " +
"starry_night");
e.printStackTrace();
break;
}
final Filter akbarHunting;
try {
akbarHunting = new ImageDetectionFilter(
CameraActivity.this,
R.drawable.akbar_hunting_with_cheetahs);
} catch (IOException e) {
Log.e(TAG, "Failed to load drawable: " +
"akbar_hunting_with_cheetahs");
e.printStackTrace();
break;
}
mImageDetectionFilters = new Filter[] {
new NoneFilter(),
starryNight,
akbarHunting
};
// ...
}
}
};
创建活动后,我们需要加载有关所选图像检测过滤器的所有已保存数据:
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Window window = getWindow();
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if (savedInstanceState != null) {
mCameraIndex = savedInstanceState.getInt(
STATE_CAMERA_INDEX, 0);
mImageSizeIndex = savedInstanceState.getInt(
STATE_IMAGE_SIZE_INDEX, 0);
mImageDetectionFilterIndex =
savedInstanceState.getInt(
STATE_IMAGE_DETECTION_FILTER_INDEX, 0);
mCurveFilterIndex = savedInstanceState.getInt(
STATE_CURVE_FILTER_INDEX, 0);
mMixerFilterIndex = savedInstanceState.getInt(
STATE_MIXER_FILTER_INDEX, 0);
mConvolutionFilterIndex = savedInstanceState.getInt(
STATE_CONVOLUTION_FILTER_INDEX, 0);
} else {
mCameraIndex = 0;
mImageSizeIndex = 0;
mImageDetectionFilterIndex = 0;
mCurveFilterIndex = 0;
mMixerFilterIndex = 0;
mConvolutionFilterIndex = 0;
}
// ...
}
相反,在活动被销毁之前,我们需要保存有关所选图像检测过滤器的数据:
public void onSaveInstanceState(Bundle savedInstanceState) {
// Save the current camera index.
savedInstanceState.putInt(STATE_CAMERA_INDEX, mCameraIndex);
// Save the current image size index.
savedInstanceState.putInt(STATE_IMAGE_SIZE_INDEX,
mImageSizeIndex);
// Save the current filter indices.
savedInstanceState.putInt(STATE_IMAGE_DETECTION_FILTER_INDEX,
mImageDetectionFilterIndex);
savedInstanceState.putInt(STATE_CURVE_FILTER_INDEX,
mCurveFilterIndex);
savedInstanceState.putInt(STATE_MIXER_FILTER_INDEX,
mMixerFilterIndex);
savedInstanceState.putInt(STATE_CONVOLUTION_FILTER_INDEX,
mConvolutionFilterIndex);
super.onSaveInstanceState(savedInstanceState);
}
当按下下个跟踪器菜单按钮时,需要更新所选图像检测过滤器:
public boolean onOptionsItemSelected(final MenuItem item) {
if (mIsMenuLocked) {
return true;
}
if (item.getGroupId() == MENU_GROUP_ID_SIZE) {
mImageSizeIndex = item.getItemId();
recreate();
return true;
}
switch (item.getItemId()) {
case R.id.menu_next_image_detection_filter:
mImageDetectionFilterIndex++;
if (mImageDetectionFilterIndex ==
mImageDetectionFilters.length) {
mImageDetectionFilterIndex = 0;
}
return true;
// ...
default:
return super.onOptionsItemSelected(item);
}
}
最后,当相机捕获一帧时,需要将选定的图像检测过滤器应用于该帧。 为了确保其他过滤器不会干扰图像检测,重要的是首先应用图像检测过滤器:
public Mat onCameraFrame(final CvCameraViewFrame inputFrame) {
final Mat rgba = inputFrame.rgba();
// Apply the active filters.
if (mImageDetectionFilters != null) {
mImageDetectionFilters[mImageDetectionFilterIndex].apply(
rgba, rgba);
}
if (mCurveFilters != null) {
mCurveFilters[mCurveFilterIndex].apply(rgba, rgba);
}
if (mMixerFilters != null) {
mMixerFilters[mMixerFilterIndex].apply(rgba, rgba);
}
if (mConvolutionFilters != null) {
mConvolutionFilters[mConvolutionFilterIndex].apply(
rgba, rgba);
}
if (mIsPhotoPending) {
mIsPhotoPending = false;
takePhoto(rgba);
}
if (mIsCameraFrontFacing) {
// Mirror (horizontally flip) the preview.
Core.flip(rgba, rgba, 1);
}
return rgba;
}
就这样! 打印目标图像或在屏幕上显示它们。 然后,运行应用,选择合适的图像检测过滤器,然后将相机对准目标。 如果视频太不稳定(由于图像处理缓慢),请选择较低的相机分辨率,然后重试。 另外,您可能需要将 Android 设备保持一两秒钟的静止不动,以使相机自动对焦在目标上。 然后,您应该看到目标以绿色概述。 例如,请参见以下屏幕快照中的《星空》周围的轮廓:

请注意,即使目标图像旋转或倾斜,图像检测过滤器也可以工作。 现在,尝试将图像检测过滤器与其他过滤器组合使用。 因为我们在其他过滤之前执行图像检测,所以目标图像在检测器中看起来仍然相同,应该被检测到。 例如,在以下屏幕截图中,请注意,即使RecolorRGVFilter,CrossProcessCurveFilter和StrokeEdgesFilter也正在运行时,也会检测到《阿克巴猎豹》(并用绿色框出):

总结
Second Sight 应用现在可以看到! 至少,它可以识别预定义集中的任何图像,并在该图像周围绘制四边形。 在某种程度上,此功能在缩放,旋转和倾斜方面都非常强大。 例如,可以在各种放大率,分辨率,距离和视角下跟踪图像。
尽管在本章中仅添加了一个类,但我们介绍了许多 OpenCV 功能。 接下来,我们将退后一步,考虑如何将该 OpenCV 功能与其他类型的图形进行集成,这些图形将实时响应摄像机的输入。 我们将在 Second Sight 的图像识别过滤器上构建一个小的 3D 渲染场景。
五、将图像跟踪与 3D 渲染相结合
本章的目标是将图像跟踪与 3D 渲染相结合。 我们将修改现有的图像跟踪器,以使其完全确定目标在 3D 中的位置和旋转。 然后,使用 Android SDK 的 OpenGL ES 实现,我们将在跟踪图像的前面绘制一个彩色 3D 立方体。 这是增强现实(AR)的情况,这意味着我们正在将虚拟对象(立方体)叠加到真实场景的特定部分上。
OpenGL(开放图形库)是一种标准化的,独立于语言,独立于平台的 API,用于通常使用 GPU 渲染 2D 和 3D 图形。 OpenGL 帮助开发人员从虚拟相机的角度定义形状(几何形状),表面(材料)和灯光的外观。 像 OpenCV 一样,OpenGL 在矩阵上执行计算。 例如,这些矩阵可以存储颜色数据或空间数据(包括位置和旋转)。 OpenGL ES(适用于嵌入式系统的 OpenGL)是 OpenGL 的子集,设计用于资源相对受限的设备,例如智能手机和平板电脑。
注意
可以从作者的网站下载本章的完整 Eclipse 项目。 该项目有两个版本:
OpenCV 3.x 的版本位于这个页面。
位于这个页面的 OpenCV 2.x 版本。
将文件添加到项目
在本章中,我们将修改现有的ImageDetectionFilter类。 我们还将为以下新类和接口添加文件:
com.nummist.secondsight.ARCubeRenderer:此类表示位于跟踪的真实对象前面的彩色立方体的渲染逻辑。 该类实现了 Android 标准库中的GLSurfaceView.Renderer接口。 投影矩阵由CameraProjectionAdapter实例确定,而立方体的姿势矩阵由ARFilter实例确定,如稍后所述。com.nummist.secondsight.adapters.CameraProjectionAdapter:此类表示物理相机与投影矩阵之间的关系。 其他类可以采用 OpenCV 或 OpenGL 格式获取投影矩阵。com.nummist.secondsight.filters.ar.ARFilter:此接口表示过滤器,该过滤器以 OpenGL 矩阵形式捕获实际对象的位置和旋转。 我们将修改ImageDetectionFilter以实现此接口。com.nummist.secondsight.filters.ar.NoneARFilter:此类表示不执行任何操作的过滤器。 它扩展了NoneFilter类并实现了ARFilter接口。 当我们要关闭过滤但仍具有符合ARFilter接口的对象时,可以使用NoneARFilter。
这些类型一起支持虚拟 3D 环境的渲染,该环境与真实摄像机和场景的某些属性一致。
定义ARFilter接口
给定源图像,我们先前的过滤器仅生成了目标图像。 现在,我们还希望生成有关源图像中可能可见的物体的姿势(位置和旋转)的数据。 出于 OpenGL 的目的,姿势表示为 16 个浮点数的数组,表示4 x 4转换矩阵。 因此,我们可以如下定义ARFilter接口:
注意
如果您不熟悉在 3D 几何图形中使用向量代数和矩阵代数,则可能会发现本章的某些部分难以理解。 粗略地说,您可以将变换矩阵想象成一个表格,其中包含基于 3D 位置的三个坐标和 3D 旋转的三个角度的三角函数的值。 通过矩阵乘法可以连续应用两个变换。 有关这些主题的入门知识,请参见在线书籍书《3D 计算机图形学的向量数学》。
public interface ARFilter extends Filter {
public float[] getGLPose();
}
当姿势矩阵未知时,getGLPose()应该返回null。
ARFilter接口的最基本实现是NoneARFilter类。 NoneARFilter实际上没有找到姿势矩阵。 相反,getGLPose()方法始终返回null,如下面的代码所示:
public class NoneARFilter extends NoneFilter implements ARFilter {
@Override
public float[] getGLPose() {
return null;
}
}
NoneARFilter类类似于其父类NoneFilter的,只是其他过滤器的便捷替代。 当我们要关闭过滤但仍具有符合ARFilter接口的对象时,可以使用NoneARFilter。
在CameraProjectionAdapter中构建投影矩阵
这是观光者的练习。 选择一张著名照片,该照片是在可识别的位置拍摄的,该位置今天看起来仍然很相似。 前往该站点并对其进行探索,直到您知道摄影师如何设置照片为止。 相机在哪里放置以及如何旋转?
如果找到答案并确定答案,则必须已经知道摄影师使用了哪个镜头(对于变焦镜头,是哪个变焦设置)。 没有这些信息,您就不可能将可行的摄像机姿势缩小到一个真实姿势。
当试图确定被摄物体相对于单眼(单镜头)相机的姿态时,我们面临着类似的问题。 为了找到独特的解决方案,我们首先需要知道相机的水平和垂直视场以及水平和垂直分辨率(以像素为单位)。
幸运的是,我们可以通过android.hardware.Camera.Parameters类获取此数据。 我们的CameraProjectionAdapter类将允许客户端代码提供Camera.Parameters对象,然后获取 OpenCV 或 OpenGL 格式的投影矩阵。
注意
不幸的是,在某些设备上,Camera.Parameters提供的数据具有误导性或完全错误。
在具有变焦镜头的设备上,水平和垂直视场可能基于镜头的最宽(1x)变焦设置。 有关基于当前缩放设置查找视野的建议,请参见 StackOverflow 帖子。
在某些设备上,视野报告为 360 度或其他不正确的值。 例如,Sony Xperia Arc 可能报告 360 度的视野。
作为依赖Camera.Parameters的替代方法,我们可以要求用户在运行时校准摄像机。 OpenCV 提供了校准功能,要求用户拍摄棋盘的一系列照片。 我们不会在本书中介绍这些功能,但您可以在官方文档中了解它们,或其他 OpenCV 书籍,例如 RobertLaganière 的《OpenCV 2 计算机视觉应用编程手册》(Packt Publishing)。
CameraProjectionAdapter作为成员变量,存储构造投影矩阵所需的所有数据。 它还存储矩阵本身和boolean标志,以指示矩阵是否脏(在下一次客户端代码获取它们时是否需要重构它们)。 让我们编写以下有关类和成员变量的声明:
// Use the deprecated Camera class.
@SuppressWarnings("deprecation")
public class CameraProjectionAdapter {
float mFOVY = 45f; // equivalent in 35mm photography: 28mm lens
float mFOVX = 60f; // equivalent in 35mm photography: 28mm lens
int mHeightPx = 480;
int mWidthPx = 640;
float mNear = 0.1f;
float mFar = 10f;
final float[] mProjectionGL = new float[16];
boolean mProjectionDirtyGL = true;
MatOfDouble mProjectionCV;
boolean mProjectionDirtyCV = true;
请注意,如果客户端代码未能提供Camera.Parameters实例,我们将采用一些默认值。 还要注意,mNear和mFar变量存储和剪切距离的近和远,这表示 OpenGL 相机不会渲染接近或接近的任何东西。 比这些各自的距离更远。 我们可以根据摄像机的规格和当前图像大小来设置一些变量,如以下方法所示:
public void setCameraParameters(
final Parameters cameraParameters, final Size imageSize) {
mFOVY = cameraParameters.getVerticalViewAngle();
mFOVX = cameraParameters.getHorizontalViewAngle();
mHeightPx = imageSizeimageSize.height;
mWidthPx = imageSizeimageSize.width;
mProjectionDirtyGL = true;
mProjectionDirtyCV = true;
}
作为获取图像长宽比的便捷方法,让我们提供以下方法:
public float getAspectRatio() {
return (float)mWidthPx / (float)mHeightPx;
}
对于近和远剪切距离,我们只需要一个简单的设置器,就可以实现如下:
public void setClipDistances(float near, float far) {
mNear = near;
mFar = far;
mProjectionDirtyGL = true;
}
由于裁剪距离仅与 OpenGL 有关,因此我们仅为 OpenGL 矩阵设置脏标志。
接下来,让我们考虑一下 OpenGL 投影矩阵的获取器。 如果矩阵很脏,我们将其重建。 为了构造投影矩阵,OpenGL 提供了一个称为frustumM(float[] m, int offset, float left, float right, float bottom, float top, float near, float far)的函数。 前两个参数是应在其中存储矩阵数据的数组和偏移量。 其余参数描述视图平截头体的边缘,这是相机可以看到的空间区域。 尽管您可能会认为该区域是圆锥形的,但实际上它是一个截顶的金字塔。 圆锥体的末端由于近乎修剪,远处修剪以及用户屏幕的矩形形状而丢失。 这是视锥内部的视锥的可视化效果:

基于剪切距离和视场,我们可以使用简单的三角函数找到视锥的其他测量值,如以下实现所示:
public float[] getProjectionGL() {
if (mProjectionDirtyGL) {
final float right =
(float)Math.tan(0.5f * mFOVX * Math.PI / 180f) * mNear;
// Calculate vertical bounds based on horizontal bounds
// and the image's aspect ratio. Some aspect ratios will
// be crop modes that do not use the full vertical FOV
// reported by Camera.Paremeters.
final float top = right / getAspectRatio();
Matrix.frustumM(mProjectionGL, 0,
-right, right, -top, top, mNear, mFar);
mProjectionDirtyGL = false;
}
return mProjectionGL;
}
OpenCV 投影矩阵的获取器稍微复杂些,因为该库没有提供类似的辅助函数来构造矩阵。 因此,我们必须了解 OpenCV 投影矩阵的内容并自行构建。 它具有以下3 x 3格式:
focusLengthXInPixels |
0 | centerXInPixels |
|---|---|---|
| 0 | focusLengthYInPixels |
centerYInPixels |
| 0 | 0 | 1 |
我们将假设一个对称的镜头系统和一个正方形像素的传感器。 受这些约束的影响,矩阵简化为以下格式:
focusLengthInPixels |
0 | 0.5 * widthInPixels |
|---|---|---|
| 0 | focusLengthInPixels |
0.5 * heightInPixels |
| 0 | 0 | 1 |
焦距是镜头无限远对焦时相机传感器与镜头系统光学中心之间的距离。 出于 OpenCV 的目的,焦距以像素相关单位表示。 名义上,我们可以将物理尺寸归因于像素。 我们可以通过将相机传感器的宽度或高度除以其水平或垂直分辨率来做到这一点。 但是,由于我们不知道传感器的任何物理尺寸,因此我们改用三角函数来确定与像素有关的焦距。 实现如下:
public MatOfDouble getProjectionCV() {
if (mProjectionDirtyCV) {
if (mProjectionCV == null) {
mProjectionCV = new MatOfDouble();
mProjectionCV.create(3, 3, CvType.CV_64FC1);
}
// Calculate focal length using the aspect ratio of the
// FOV values reported by Camera.Parameters. This is not
// necessarily the same as the image's current aspect
// ratio, which might be a crop mode.
final float fovAspectRatio = mFOVX / mFOVY;
double diagonalPx = Math.sqrt(
(Math.pow(mWidthPx, 2.0) +
Math.pow(mWidthPx / fovAspectRaio, 2.0)));
double diagonalFOV = Math.sqrt(
(Math.pow(mFOVX, 2.0) +
Math.pow(mFOVY, 2.0)));
double focalLengthPx = diagonalPx /
(2.0 * Math.tan(0.5 * diagonalFOV * Math.PI / 180f));
mProjectionCV.put(0, 0, focalLengthPx);
mProjectionCV.put(0, 1, 0.0);
mProjectionCV.put(0, 2, 0.5 * mWidthPx);
mProjectionCV.put(1, 0, 0.0);
mProjectionCV.put(1, 1, focalLengthPx);
mProjectionCV.put(1, 2, 0.5 * mHeightPx);
mProjectionCV.put(2, 0, 0.0);
mProjectionCV.put(2, 1, 0.0);
mProjectionCV.put(2, 2, 1.0);
}
return mProjectionCV;
}
}
客户端代码可以通过将实例化来使用CameraProjectionAdapter,只要活动摄像机或图像尺寸发生变化,就调用setCameraParameters,并且当 OpenGL 或 OpenCV 计算需要投影矩阵时,就调用getProjectionGL和getProjectionCV。 。
为 3D 跟踪修改ImageDetectionFilter
对于 3D 跟踪,除了mSceneCorners变量外,ImageDetectionFilter需要与相同的所有成员变量。 我们还需要几个新变量来存储有关目标姿势的计算。 此外,该类需要实现ARFilter接口。 让我们修改ImageDetectionFilter如下:
public class ImageDetectionFilter implements ARFilter {
// ...
// The reference image's corner coordinates, in 3D, in real
// units.
private final MatOfPoint3f mReferenceCorners3D =
new MatOfPoint3f();
// Good corner coordinates detected in the scene, in // pixels.
private final MatOfPoint2f mSceneCorners2D =
new MatOfPoint2f();
// Distortion coefficients of the camera's lens.
// Assume no distortion.
private final MatOfDouble mDistCoeffs =
new MatOfDouble(0.0, 0.0, 0.0, 0.0);
// An adaptor that provides the camera's projection matrix.
private final CameraProjectionAdapter mCameraProjectionAdapter;
// The Euler angles of the detected target.
private final MatOfDouble mRVec = new MatOfDouble();
// The XYZ coordinates of the detected target.
private final MatOfDouble mTVec = new MatOfDouble();
// The rotation matrix of the detected target.
private final MatOfDouble mRotation = new MatOfDouble();
// The OpenGL pose matrix of the detected target.
private final float[] mGLPose = new float[16];
// Whether the target is currently detected.
private boolean mTargetFound = false;
构造器需要两个新参数。 其中的一个是CameraProjectionAdapter的实例,我们将其存储在成员变量中。 另一个是代表打印图像较小尺寸(横向图像的高度或纵向图像的宽度)尺寸的数字,我们用它来计算所跟踪对象的 3D 边界。 我们可以选择任意度量单位,但是在其他地方,当指定近片段距离,远片段距离和立方体的比例时,必须使用相同的单位。 例如,如果我们指定打印图像的尺寸为 1.0,而多维数据集的比例为 0.5,则该多维数据集将是打印图像较小尺寸的一半。 在以下代码中可以看到新的参数及其用法:
public ImageDetectionFilter(final Context context,
final int referenceImageResourceID,
final CameraProjectionAdapter cameraProjectionAdapter,
final double realSize)
throws IOException {
// ...
// Compute the image's width and height in real units, based
// on the specified real size of the image's smaller
// dimension.
final double aspectRatio = (double)referenceImageGray.cols()
/(double)referenceImageGray.rows();
final double halfRealWidth;
final double halfRealHeight;
if (referenceImageGray.cols() > referenceImageGray.rows()) {
halfRealHeight = 0.5f * realSize;
halfRealWidth = halfRealHeight * aspectRatio;
} else {
halfRealWidth = 0.5f * realSize;
halfRealHeight = halfRealWidth / aspectRatio;
}
// Define the printed image so that it normally lies in the
// xy plane (like a painting or poster on a wall).
// That is, +z normally points out of the page toward the
// viewer.
mReferenceCorners3D.fromArray(
new Point3(-halfRealWidth, -halfRealHeight, 0.0),
new Point3( halfRealWidth, -halfRealHeight, 0.0),
new Point3( halfRealWidth, halfRealHeight, 0.0),
new Point3(-halfRealWidth, halfRealHeight, 0.0));
mCameraProjectionAdapter = cameraProjectionAdapter;
}
为了满足ARFilter接口,我们需要为 OpenGL 姿势矩阵实现获取器。 当目标丢失时,此获取器应返回null,因为我们没有有关姿势的有效数据。 我们可以如下实现获取器:
@Override
public float[] getGLPose() {
return (mTargetFound ? mGLPose : null);
}
让我们将findSceneCorners方法重命名为findPose。 为了反映此名称更改,apply方法的实现更改如下:
@Override
public void apply(final Mat src, final Mat dst) {
// Convert the scene to grayscale.
Imgproc.cvtColor(src, mGraySrc, Imgproc.COLOR_RGBA2GRAY);
// Detect the scene features, compute their descriptors,
// and match the scene descriptors to reference descriptors.
mFeatureDetector.detect(mGraySrc, mSceneKeypoints);
mDescriptorExtractor.compute(mGraySrc, mSceneKeypoints,
mSceneDescriptors);
mDescriptorMatcher.match(mSceneDescriptors,
mReferenceDescriptors, mMatches);
// Attempt to find the target image's 3D pose in the // scene.
findPose();
// If the pose has not been found, draw a thumbnail of the
// target image.
draw(src, dst);
}
findPose的实现涵盖了旧findSceneCorners方法之外的一些其他步骤。 找到角点后,我们从CameraProjectionAdapter实例获得一个 OpenCV 投影矩阵。 接下来,我们根据匹配的角点和投影来求解目标的位置和旋转。 大多数计算由称为Calib3d.solvePnP(MatOfPoint3f objectPoints, MatOfPoint2f imagePoints, Mat cameraMatrix, MatOfDouble distCoeffs, Mat rvec, Mat tvec)的 OpenCV 函数完成。 此函数将位置和旋转结果放在两个单独的向量中。 与 OpenGL 相比,OpenCV 中的 y 和 z 轴反转。 角度的方向也被反转。 因此,我们需要将向量的某些分量乘以 -1。 我们使用另一个称为Calib3d.Rodrigues(Mat src, Mat dst)的 OpenCV 函数将旋转向量转换为矩阵。 最后,我们手动转换生成的旋转矩阵并将向量定位到适合 OpenGL 的float[16]数组中。 代码如下:
private void findPose() {
// ...
if (minDist > 50.0) {
// The target is completely lost.
mTargetFound = false;
return;
} else if (minDist > 25.0) {
// The target is lost but maybe it is still close.
// Keep using any previously found pose.
return;
}
// ...
// Convert the scene corners to integer format, as required
// by the Imgproc.isContourConvex function.
mCandidateSceneCorners.convertTo(mIntSceneCorners,
CvType.CV_32S);
// Check whether the corners form a convex polygon. If not,
// (that is, if the corners form a concave polygon), the
// detection result is invalid because no real perspective
// can make the corners of a rectangular image look like a
// concave polygon!
if (!Imgproc.isContourConvex(mIntSceneCorners)) {
return;
}
double[] sceneCorner0 = mCandidateSceneCorners.get(0, 0);
double[] sceneCorner1 = mCandidateSceneCorners.get(1, 0);
double[] sceneCorner2 = mCandidateSceneCorners.get(2, 0);
double[] sceneCorner3 = mCandidateSceneCorners.get(3, 0);
mSceneCorners2D.fromArray(
new Point(sceneCorner0[0], sceneCorner0[1]),
new Point(sceneCorner1[0], sceneCorner1[1]),
new Point(sceneCorner2[0], sceneCorner2[1]),
new Point(sceneCorner3[0], sceneCorner3[1]));
MatOfDouble projection =
mCameraProjectionAdapter.getProjectionCV();
// Find the target's Euler angles and XYZ coordinates.
Calib3d.solvePnP(mReferenceCorners3D mSceneCorners2D,
projection, mDistCoeffs, mRVec, mTVec);
// Positive y is up in OpenGL, down in OpenCV.
// Positive z is backward in OpenGL, forward in OpenCV.
// Positive angles are counter-clockwise in OpenGL,
// clockwise in OpenCV.
// Thus, x angles are negated but y and z angles are
// double-negated (that is, unchanged).
// Meanwhile, y and z positions are negated.
double[] rVecArray = mRVec.toArray();
rVecArray[0] *= -1.0; // negate x angle
mRVec.fromArray(rVecArray);
// Convert the Euler angles to a 3x3 rotation matrix.
Calib3d.Rodrigues(mRVec, mRotation);
double[] tVecArray = mTVec.toArray();
// OpenCV's matrix format is transposed, relative to
// OpenGL's matrix format.
mGLPose[0] = (float)mRotation.get(0, 0)[0];
mGLPose[1] = (float)mRotation.get(0, 1)[0];
mGLPose[2] = (float)mRotation.get(0, 2)[0];
mGLPose[3] = 0f;
mGLPose[4] = (float)mRotation.get(1, 0)[0];
mGLPose[5] = (float)mRotation.get(1, 1)[0];
mGLPose[6] = (float)mRotation.get(1, 2)[0];
mGLPose[7] = 0f;
mGLPose[8] = (float)mRotation.get(2, 0)[0];
mGLPose[9] = (float)mRotation.get(2, 1)[0];
mGLPose[10] = (float)mRotation.get(2, 2)[0];
mGLPose[11] = 0f;
mGLPose[12] = (float)tVecArray[0];
mGLPose[13] = -(float)tVecArray[1]; // negate y position
mGLPose[14] = -(float)tVecArray[2]; // negate z position
mGLPose[15] = 1f;
mTargetFound = true;
}
最后,让我们通过删除围绕跟踪图像绘制绿色边框的代码来修改我们的draw方法。 (相反,ARCubeRenderer类将负责在跟踪的图像前面绘制一个多维数据集。)在删除不需要的代码之后,我们将使用draw方法的以下实现:
protected void draw(Mat src, Mat dst) {
if (dst != src) {
src.copyTo(dst);
}
if (!mTargetFound) {
// The target has not been found.
// Draw a thumbnail of the target in the upper-left
// corner so that the user knows what it is.
// Compute the thumbnail's larger dimension as half the
// video frame's smaller dimension.
int height = mReferenceImage.height();
int width = mReferenceImage.width();
int maxDimension = Math.min(dst.width(),
dst.height()) / 2;
double aspectRatio = width / (double)height;
if (height > width) {
height = maxDimension;
width = (int)(height * aspectRatio);
} else {
width = maxDimension;
height = (int)(width / aspectRatio);
}
// Select the region of interest (ROI) where the thumbnail
// will be drawn.
Mat dstROI = dst.submat(0, height, 0, width);
// Copy a resized reference image into the ROI.
Imgproc.resize(mReferenceImage, dstROI, dstROI.size(),
0.0, 0.0, Imgproc.INTER_AREA);
}
}
}
接下来,我们来看如何使用 OpenGL 渲染立方体。
在ARCubeRenderer中渲染多维数据集
Android 提供了一个名为GLSurfaceView的类,它是由 OpenGL 绘制的小部件。 绘制逻辑通过名为GLSurfaceView.Renderer的接口封装,我们将在ARCubeRenderer中实现该接口。 该接口需要以下方法:
onDrawFrame(GL10 gl):这被称为绘制当前帧。 在这里,我们还将配置 OpenGL 透视图和视口(其在屏幕上的绘制区域),因为ARCubeRenderer和CameraProjectionAdapter的接口可能允许视角以逐帧方式改变。onSurfaceChanged(GL10 gl, int width, int height):当表面尺寸更改时调用。 出于我们的目的,此方法仅需要将宽度和高度存储在成员变量中。onSurfaceCreated(GL10 gl, EGLConfig config):在创建曲面或重新创建时调用。 通常,此方法配置我们以后不会更改的所有 OpenGL 设置。 换句话说,这些设置与透视图和绘制内容无关。
作为参数传递的GL10实例提供对标准 OpenGL ES 1.0 功能的访问。 基本上,我们对两种 OpenGL 功能感兴趣:将矩阵转换应用于 3D 顶点,然后根据转换后的顶点绘制三角形。 我们的立方体将有 8 个顶点和 12 个三角形(6 个正方形,每个正方形 2 个三角形)。 我们将为每个顶点指定一种颜色,并将三角形描述为一系列顶点索引(每个三角形三个顶点索引)。
顶点,顶点颜色和三角形均存储在ByteBuffer实例中。 由于我们仅支持一种样式的多维数据集,因此我们将使用ByteBuffer的静态实例,以便多个ARCubeRenderer实例可以共享它们。 作为成员变量,我们还希望ARFilter提供多维数据集的姿势矩阵,CameraProjectionAdapter提供投影矩阵,以及允许客户端代码调整多维数据集大小的比例。 ARCubeRenderer及其变量的声明如下:
public class ARCubeRenderer implements GLSurfaceView.Renderer {
public ARFilter filter;
public CameraProjectionAdapter cameraProjectionAdapter;
public float scale = 1f;
private int mSurfaceWidth;
private int mSurfaceHeight;
private static final ByteBuffer VERTICES;
private static final ByteBuffer COLORS;
private static final ByteBuffer TRIANGLES;
由于顶点,颜色和三角形是static变量,因此我们在static块中对其进行初始化。 对于每个缓冲区,我们必须指定所需的字节数。 顶点占用96个字节(8 个顶点,每个顶点 3 个浮点,每个浮点 4 个字节)。 我们为 2 个单位宽的立方体指定顶点。 填充缓冲区后,我们将其指针倒回到第一个索引。 代码如下:
static {
VERTICES = ByteBuffer.allocateDirect(96);
VERTICES.order(ByteOrder.nativeOrder());
VERTICES.asFloatBuffer().put(new float[] {
// Front.
-0.5f, -0.5f, 0.5f,
0.5f, -0.5f, 0.5f,
0.5f, 0.5f, 0.5f,
-0.5f, 0.5f, 0.5f,
// Back.
-0.5f, -0.5f, -0.5f,
0.5f, -0.5f, -0.5f,
0.5f, 0.5f, -0.5f,
-0.5f, 0.5f, -0.5f
});
VERTICES.position(0);
顶点颜色占用 32 个字节(每个顶点 8 个顶点,4 字节 RGBA 颜色)。 我们为每个顶点指定不同的颜色,如以下代码所示:
COLORS = ByteBuffer.allocateDirect(32);
final byte maxColor = (byte)255;
COLORS.put(new byte[] {
// Front.
maxColor, 0, 0, maxColor, // red
maxColor, maxColor, 0, maxColor, // yellow
maxColor, maxColor, 0, maxColor, // yellow
maxColor, 0, 0, maxColor, // red
// Back.
0, maxColor, 0, maxColor, // green
0, 0, maxColor, maxColor, // blue
0, 0, maxColor, maxColor, // blue
0, maxColor, 0, maxColor // green
});
COLORS.position(0);
三角形占用 36 个字节(12 个三角形,每个三角形 3 个顶点索引)。 从三角形的背面看时,OpenGL 要求我们以逆时针顺序指定每个三角形的顶点索引。 此排序称为反时钟绕组,在以下代码中可以看到:
TRIANGLES = ByteBuffer.allocateDirect(36);
TRIANGLES.put(new byte[] {
// Front.
0, 1, 2, 2, 3, 0,
3, 2, 6, 6, 7, 3,
7, 6, 5, 5, 4, 7,
// Back.
4, 5, 1, 1, 0, 4,
4, 0, 3, 3, 7, 4,
1, 5, 6, 6, 2, 1
});
TRIANGLES.position(0);
}
创建GLSurfaceView的实例时,我们必须将其配置为使用完全透明的背景色,以便基础视频可见。 我们还必须指定我们希望 OpenGL 执行背面剔除,这意味着仅当三角形面对观察者时才会绘制三角形。 由于我们的立方体是不透明的,因此我们不希望 OpenGL 在其内部绘制! 此初始配置在onSurfaceCreated方法中实现,如下所示:
@Override
public void onSurfaceCreated(final GL10 gl,
final EGLConfig config) {
gl.glClearColor(0f, 0f, 0f, 0f); // transparent
gl.glEnable(GL10.GL_CULL_FACE);
}
调整GLSurfaceView实例的大小后,我们将记录新的大小,如以下代码所示:
@Override
public void onSurfaceChanged(final GL10 gl, final int width,
final int height) {
mSurfaceWidth = width;
mSurfaceHeight = height;
}
}
当绘制到GLSurfaceView的实例时,我们首先清除所有先前的内容(将其替换为我们先前指定的完全透明的背景色)。 然后,我们检查投影矩阵和姿势矩阵是否可用。 如果可用,我们告诉 OpenGL 调整视口的大小,使用投影和姿势矩阵,最后移动和缩放立方体,以便在跟踪图像的前面有一个适当大小的立方体。 然后,我们向 OpenGL 提供的顶点和顶点颜色,并告诉它绘制三角形。 实现如下:
@Override
public void onDrawFrame(final GL10 gl) {
gl.glClear(GL10.GL_COLOR_BUFFER_BIT |
GL10.GL_DEPTH_BUFFER_BIT);
if (filter == null) {
return;
}
if (cameraProjectionAdapter == null) {
return;
}
float[] pose = filter.getGLPose();
if (pose == null) {
return;
}
final int adjustedWidth = (int)(mSurfaceHeight *
cameraProjectionAdapter.getAspectRatio());
final int marginX = (mSurfaceWidth - adjustedWidth) / 2;
gl.glViewport(marginX, 0, adjustedWidth, mSurfaceHeight);
gl.glMatrixMode(GL10.GL_PROJECTION);
float[] projection =
cameraProjectionAdapter.getProjectionGL();
gl.glLoadMatrixf(projection, 0);
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadMatrixf(pose, 0);
gl.glScalef(scale, scale, scale);
// Move the cube forward so that it is not halfway inside
// the image.
gl.glTranslatef(0f, 0f, 0.5f);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
gl.glVertexPointer(3, GL11.GL_FLOAT, 0, VERTICES);
gl.glColorPointer(4, GL11.GL_UNSIGNED_BYTE, 0, COLORS);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
gl.glDrawElements(GL10.GL_TRIANGLES, 36,
GL10.GL_UNSIGNED_BYTE, TRIANGLES);
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
gl.glDisableClientState(GL10.GL_COLOR_ARRAY);
}
现在,我们准备好将 3D 跟踪和渲染集成到我们的应用中。
向CameraActivity添加 3D 跟踪和渲染
我们需要对CameraActivity进行一些更改,以使其与我们对ImageDetectionFilter的更改以及与ARFilter提供的新接口保持一致。 我们还需要修改活动的布局,使其包含GLSurfaceView。 此GLSurfaceView的适配器为ARCubeRenderer。 ImageDetectionFilter和ARCubeRenderer方法将使用CameraProjectionAdapter来协调其投影矩阵。
首先,让我们对CameraActivity的成员变量进行以下更改:
// The filters.
private ARFilter[] mImageDetectionFilters;
private Filter[] mCurveFilters;
private Filter[] mMixerFilters;
private Filter[] mConvolutionFilters;
// ...
// The camera view.
private CameraBridgeViewBase mCameraView;
// An adapter between the video camera and projection // matrix.
private CameraProjectionAdapter mCameraProjectionAdapter;
// The renderer for 3D augmentations.
private ARCubeRenderer mARRenderer;
与往常一样,一旦 OpenCV 库被加载,我们就需要创建过滤器。 唯一的变化是我们需要将CameraProjectionAdapter的实例传递给ImageDetectionFilter的每个构造器,并且我们需要使用NoneARFilter代替NoneFilter。 代码如下:
public void onManagerConnected(final int status) {
switch (status) {
case LoaderCallbackInterface.SUCCESS:
Log.d(TAG, "OpenCV loaded successfully");
mCameraView.enableView();
mBgr = new Mat();
final ARFilter starryNight;
try {
// Define The Starry Night to be 1.0 units // tall
starryNight = new ImageDetectionFilter(
CameraActivity.this,
R.drawable.starry_night,
mCameraProjectionAdapter, 1.0);
} catch (IOException e) {
Log.e(TAG, "Failed to load drawable: " +
"starry_night");
e.printStackTrace();
break;
}
final ARFilter akbarHunting;
try {
// Define Akbar Hunting with Cheetahs to be 1.0
// units wide.
akbarHunting = new ImageDetectionFilter(
CameraActivity.this,
R.drawable.akbar_hunting_with_cheetahs,
mCameraProjectionAdapter, 1.0);
} catch (IOException e) {
Log.e(TAG, "Failed to load drawable: " +
"akbar_hunting_with_cheetahs");
e.printStackTrace();
break;
}
mImageDetectionFilters = new ARFilter[] {
new NoneARFilter(),
starryNight,
akbarHunting
};
// ...
}
}
其余更改属于onCreate方法,在这里我们应该创建并配置GLSurfaceView,ARCubeRenderer和CameraProjectionAdapter的实例。 该实现包括一些样板代码,以将GLSurfaceView的实例覆盖在JavaCameraView的实例之上。 这两个视图包含在名为FrameLayout的标准 Android 布局小部件内。 设置布局后,我们需要一个Camera实例和一个Camera.Parameters实例,以便进行剩余的配置。 如前几章所述,Camera实例是通过静态方法Camera.open获得的。 代码如下:
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// ...
final FrameLayout layout = new FrameLayout(this);
layout.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
setContentView(layout);
mCameraView = new JavaCameraView(this, mCameraIndex);
mCameraView.setCvCameraViewListener(this);
mCameraView.setLayoutParams(
new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
layout.addView(mCameraView);
GLSurfaceView glSurfaceView =
new GLSurfaceView(this);
glSurfaceView.getHolder().setFormat(
PixelFormat.TRANSPARENT);
glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 0, 0);
glSurfaceView.setZOrderOnTop(true);
glSurfaceView.setLayoutParams(new
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
layout.addView(glSurfaceView);
mCameraProjectionAdapter =
new CameraProjectionAdapter();
mARRenderer = new ARCubeRenderer();
mARRenderer.cameraProjectionAdapter =
mCameraProjectionAdapter;
// Earlier, we defined the printed image's size as
// 1.0 unit.
// Define the cube to be half this size.
mARRenderer.scale = 0.5f;
glSurfaceView.setRenderer(mARRenderer);
final Camera camera;
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.GINGERBREAD) {
CameraInfo cameraInfo = new CameraInfo();
Camera.getCameraInfo(mCameraIndex, cameraInfo);
mIsCameraFrontFacing = (cameraInfo.facing ==
CameraInfo.CAMERA_FACING_FRONT);
mNumCameras = Camera.getNumberOfCameras();
camera = Camera.open(mCameraIndex);
} else { // pre-Gingerbread
// Assume there is only 1 camera and it is rear-facing.
mIsCameraFrontFacing = false;
mNumCameras = 1;
camera = Camera.open();
}
final Parameters parameters = camera.getParameters();
camera.release();
mSupportedImageSizes = parameters.getSupportedPreviewSizes();
final Size size = mSupportedImageSizes.get(mImageSizeIndex);
mCameraProjectionAdapter.setCameraParameters(
parameters, size);
// Earlier, we defined the printed image's size as // 1.0 unit.
// Leave the near and far clip distances at their default
// values, which are 0.1 (one-tenth the image size)
// and 10.0 (ten times the image size).
mCameraView.setMaxFrameSize(size.width, size.height);
mCameraView.setCvCameraViewListener(this);
}
就这样! 运行并测试 Second Sight。 激活ImageDetectionFilter和实例中的实例时,将适当的打印图像保留在相机的前面,您应该会在图像前面看到一个彩色的立方体。 例如,请参见以下屏幕截图:

如果视频太不稳定(由于图像处理缓慢)或多维数据集没有紧密粘在跟踪图像的中心,请选择较低的相机分辨率,然后重试。 我们的参考图像分辨率较低(较大尺寸约为 600 像素),这对于低相机分辨率(例如800x600或640x480)应该是最佳的。 最后,请记住,您可能需要将 Android 设备保持一两秒钟才能使相机自动对焦在目标上。
了解有关 Android 3D 图形的更多信息
当然,在 3D 图形的世界中,绘制立方体类似于打印Hello World。 只是一个基本演示。 尽管我们介绍了网格,变换和透视图,但还有许多其他主题我们根本没有涉及,例如照明,材料(逼真的表面)以及从 3D 艺术包中导入艺术家的作品。 为了更深入地了解 Android 上的 3D 图形,请阅读以下书籍:
- 《用于 Android 的 Pro OpenGL ES》 (Apress),作者:Mike Smithwick 和 Mayank Verma。 本书涵盖了 Android 的 OpenGL ES Java API。
- 《OpenGL ES 2.0 编程指南》(Addison-Wesley),作者:Aaftab Munshi,Dan Ginsburg 和 Dave Shreiner。 本书涵盖了用于 OpenGL ES 的跨平台 C++ API。
- Jens Grubert 和 Raphael Grasset 博士为 Android 应用开发开发的《增强现实》(Packt Publishing)。 本书介绍了如何使用 jMonkeyEngine(跨平台 Java 游戏引擎)在跟踪的真实图像上叠加 3D 图形。
关于 Android 游戏开发的书籍也很多,其中可能包括 3D 图形的良好介绍。
总结
现在,我们对 OpenCV 的 Java 接口和 Android SDK 进行了介绍。 我们介绍了 OpenCV 的几种主要用途,包括捕获相机输入,对图像应用效果,以 2D 和 3D 跟踪图像以及与 OpenGL 集成以增强现实渲染。
利用到目前为止所获得的知识,您可以继续使用 Java 开发其他 OpenCV 应用,无论是针对 Android 还是其他平台。 您可能还希望探索 OpenCV 的 C++ 版本,该版本也是跨平台的并且可以与 Android NDK 交互。 为了让您有一种选择的感觉,在下一章和最后一章中,我们将 Second Sight 的一部分转换为 C++,并通过称为 Java 本机接口(JNI)的互操作性层将此代码称为 C++ 代码。
六、通过 JNI 混合 Java 和 C++
本章的目标是重写一些 Java 类,以使它们成为围绕 C++ 类的精简包装。 我们将使用一个中介框架 Java 本机接口(JNI),该框架可以彼此公开 Java 和 C++ 代码。 在此过程中,我们将对 OpenCV 的 Java 和 C++ 接口有更深入的了解。
注意
可以从作者的网站下载本章的完整 Eclipse 项目。 该项目具有以下两个版本:
OpenCV 3.x 的版本位于这个页面。
OpenCV 2.x 的版本位于这个页面。
了解 JNI 的作用
JNI 使 Java 代码可以调用 C 或 C++ 代码(反之亦然)。 OpenCV4Android,Android SDK 和 Java 标准库均依赖 JNI。 也就是说,这些主要的 Java 库部分构建在 C++ 或 C 库之上。
OpenCV 主要用 C++ 编写。 尽管该库还提供了 Java 接口(OpenCV4Android)和 Python 接口,但是这些接口的大部分都是 C++ 实现之上的薄层。 例如,org.opencv.core.Mat对象(在 Java 接口中)或 NumPy 数组(在 Python 接口中)由cv::Mat对象(在 C++ 实现中)支持,并且它们共享对相同数据的引用。 没有数据重复。
当 OpenCV 的 Java 或 Python 接口将函数调用转发到 C++ 实现时,确实会产生少量开销。 如果我们的代码每帧进行数千次 OpenCV 函数调用(例如,每帧每像素一个或多个调用),我们可能会开始担心这种开销。 但是,通常,节俭的程序员不会每帧进行数千次 OpenCV 函数调用,并且选择 OpenCV 接口(Java,Python 或 C++)对帧速率没有明显影响! 为了进行比较,请考虑以下有关使用 Android NDK(C++ 接口)与 Android SDK(Java 接口)的评论:
在下载 NDK 之前,您应该了解 NDK 不会使大多数应用受益。 作为开发人员,您需要在其优点与缺点之间取得平衡。 值得注意的是,在 Android 上使用本机代码通常不会带来明显的性能改进,但始终会增加应用的复杂性。 通常,仅应在对您的应用至关重要的情况下使用 NDK,因为绝对不喜欢使用 C/C++ 进行编程,因此绝对不要。
另一方面,OpenCV 的 C++ 接口确实提供了 Java 接口中缺少的几个功能:
- 手动内存管理:OpenCV 的 Java 接口按垃圾回收器的时间表释放内存,而 OpenCV 的 C++ 接口按命令释放内存。 在我们面临资源的严格限制(内存或分配和释放内存所需的 CPU 周期)的情况下,此手动控制可能会很有用。
- 与其他库的互操作性:OpenCV 的 C++ 接口提供对图像数据的原始字节访问,可以直接由许多其他 C++ 或 C 库解释和使用它们,而无需复制或修改。
- 不具有 Java 的跨平台兼容性:OpenCV 的 C++ 接口可以在 Java 运行时不可用或未安装的平台上使用。 我们可以在 Windows,Mac,Linux,Android,iOS,WinRT 和 Windows Phone 8 中重用单个 C++ 代码库。
这些功能使 C++ 接口成为我们的 OpenCV 之旅中的重要主题。 通过利用 JNI 并编写我们自己的 C++ 代码,我们将学习使用 OpenCV 的另一种通用方法。
测量效果
为了使自己确信 OpenCV 的 Java 和 C++ 接口提供相似的速度,您可能希望在我们在本章中进行修改之前和之后评估应用的性能。 较好的整体性能衡量标准是onCameraFrame回调处理的每秒帧数(FPS)。 (可选)OpenCV 的CameraView类可以计算并显示此 FPS 指标。 当启用CameraView(在onManagerConnected回调中)时,我们还可以启用 FPS 仪表,如以下代码所示:
@Override
public void onManagerConnected(final int status) {
switch (status) {
case LoaderCallbackInterface.SUCCESS:
Log.d(TAG, "OpenCV loaded successfully");
mCameraView.enableView();
mCameraView.enableFpsMeter();
//...
尽管 FPS 是一项重要的统计数据,但它无法准确告诉我们该应用如何使用系统资源。 例如,我们可能想知道应用在每种方法上花费了多少 CPU 时间。 为了确定,我们可以使用 Dalvik 调试监视器服务器(DDMS),这是一个集成到 Eclipse 中的 Android 调试工具。 以下是通过 DDMS 分析 CPU 使用情况的步骤:
-
打开 Eclipse。 通过单击 DDMS 按钮(通常在窗口的右上角)或导航至菜单选项开放视野 | 其他… | DDMS,打开 DDMS 透视图。 。
-
确保您的 Android 设备通过 USB 连接,并且在设备上已打开 Second Sight 应用。
-
在 DDMS 透视图的设备窗格中,您应该看到您的 Android 设备。 展开它。 选择
com.nummist.sightsight进程,该进程应在设备下方列出。 -
在 Android 设备上,将 Second Sight 配置为使用要配置的任何过滤器和相机设置。
-
要开始分析应用的 CPU 使用情况,请单击设备窗格顶部的启动方法分析按钮。 该按钮看起来像三个带有红点的箭头。 (下一个屏幕截图用突出显示的半透明圆圈标记了按钮的位置。)即使单击该按钮,也不会看到有关 CPU 使用率的报告。 该报告是在步骤 7 之后生成的。
![Measuring performance]()
-
继续使用该应用,直到完成您要分析的所有操作(例如图像识别)。
-
要停止分析应用的 CPU 使用情况,请单击停止方法分析按钮。 它看起来像三个带有黑色正方形的箭头,位于启动方法分析按钮所在的位置。 现在,您应该看到有关每种方法的 CPU 使用率的报告。 列出了每个方法名称及其在应用总 CPU 使用率中所占的百分比。 请参见以下屏幕截图:
![Measuring performance]()
设备窗格中的其他一些按钮还提供对性能分析报告的访问,例如内存使用和 OpenGL 调用的分类。 收集尽可能多的报告! 在您配置了现有应用后,我们将继续进行修改。
将文件添加到项目
Android 项目的 C++ 文件应该始终位于名为jni的子目录中。 此子目录还应该包含 C++ 代码的依赖项以及一种名为 Makefiles 的编译指令。 具体来说,我们需要在 Second Sight 项目文件夹中创建以下新文件夹和文件:
jni/include/opencv2/:此文件夹包含 OpenCV 的 C++ 接口的头文件(类型和函数的定义)。 将<opencv>/sdk/native/jni/include/opencv2/的内容复制到此文件夹中。 (用系统上的 OpenCV4Android 路径替换<opencv>。)jni/libs/:此文件夹包含 OpenCV 的 C++ 接口和 OpenCV 的依赖项的库文件(编译的实现)。 将<opencv>/sdk/native/libs/和<opencv>/sdk/native/3rdparty/libs/的内容复制到此文件夹中。jni/Application.mk:此 Makefile 描述将使用 C++ 库的环境类型。 环境的功能包括硬件架构,Android 版本以及应用对 C++ 语言功能和标准库的使用。jni/Android.mk:此 Makefile 描述了我们的 C++ 库对其他库(如 OpenCV)的依赖关系。ImageDetectionFilter.hpp:此 C++ 头文件包含ImageDetectionFilter类的定义。ImageDetectionFilter.cpp:此 C++ 源文件包含ImageDetectionFilter类的实现详细信息。RecolorCMVFilter.hpp:这是RecolorCMVFilter类的头文件。RecolorCMVFilter.cpp:这是RecolorCMVFilter类的源文件。RecolorRCFilter.hpp:这是RecolorRCFilter类的头文件。RecolorRCFilter.cpp:这是RecolorRCFilter类的源文件。RecolorRGVFilter.hpp:这是RecolorRGVFilter类的头文件。RecolorRGVFilter.cpp:这是RecolorRGVFilter类的源文件。StrokeEdgesFilter.hpp:这是StrokeEdgesFilter类的头文件。StrokeEdgesFilter.cpp:这是StrokeEdgesFilter类的源文件。SecondSightJNI.cpp:此文件定义和实现 JNI 函数,它们是可从 Java 调用的 C++ 函数。 我们的 JNI 函数包装了 C++ 类的公共方法。
如前面列表中所述,我们必须将 OpenCV 头文件和库文件复制到我们的项目中。 所有其他文件将包含我们自己的代码,我们将在本章其余部分中详细讨论。 首先,让我们将注意力转向 Makefile,这将使我们能够构建 C++ 库。
建立本机库
C++ 是编译语言。 这意味着 C++ 代码不能用作机器(例如 ARMv7-A 芯片)或虚拟机(例如 Java 虚拟机或 JVM)的指令集。 。 相反,我们使用名为编译器的工具将 C++ 代码转换为给定平台可以理解的指令。 编译每个 C++ 源文件以生成一个对象文件(不要与面向对象编程中的“对象”一词相混淆)。 然后,我们使用一个名为链接器的工具将多个目标文件组合成一个库或一个可执行文件。 链接器还可以组合库以产生其他库或可执行文件。 库的链接可以是静态的(意味着将给定版本的输入库“烘焙”到输出中),也可以是动态的(意味着可以在运行时加载库的任何可用版本)。 Android NDK 提供了一个名为ndk-build的工具,该工具抽象了编译器和链接器的使用,但是我们仍然必须在 Makefiles 中指定某些编译器标志和链接器标志。 我们的库将静态链接到 OpenCV(因为在 OpenCV 情况下动态链接更加复杂)。 因此,我们的编译器和链接器标志必须满足 OpenCV 的需求以及我们自己的代码的需求。
OpenCV 依赖于 C++ 语言的高级功能。 特别是运行时类型信息(RTTI)和运行时异常是使 OpenCV 能够可靠地处理各种(有效或无效)数据类型的语言功能,用户可能会尝试处理它们。 OpenCV 还依赖于 C++ 标准模板库(STL),该库提供了用于存储和处理数据的模式。 一些 STL 实现不支持 RTTI。 一个安全的选择是 GNU STL 实现,它确实支持 RTTI。 我们的Application.mk文件必须告诉编译器使用 RTTI,异常和 GNU STL。 此外,此 Makefile 必须指定我们针对的硬件架构和 Android 版本。 要规定这些要求,请打开Application.mk并编写以下代码:
APP_STL := gnustl_static # GNU STL
APP_CPPFLAGS := -frtti –fexceptions # RTTI, exceptions
APP_ABI := arm64-v8a armeabi armeabi-v7a mips mips64 x86 x86_64
APP_PLATFORM := android-8
将更改保存到Application.mk。 这些更改会影响所有应用 C++ 模块的构建方式。 接下来,在Android.mk中,我们需要提供说明以构建将在本章中编写的特定 C++ 模块。 此 Makefile 首先指定模块的名称,其对其他库的依赖关系及其源文件,如以下代码所示:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := SecondSight
LOCAL_C_INCLUDES += $(LOCAL_PATH)/include
LOCAL_LDLIBS := -lstdc++ # C++ standard library
LOCAL_LDLIBS += -llog # Android logging
LOCAL_LDLIBS += -lz # zlib
LOCAL_STATIC_LIBRARIES += opencv_calib3d
LOCAL_STATIC_LIBRARIES += opencv_features2d
LOCAL_STATIC_LIBRARIES += opencv_flann
LOCAL_STATIC_LIBRARIES += opencv_imgproc
LOCAL_STATIC_LIBRARIES += opencv_core
LOCAL_STATIC_LIBRARIES += opencv_hal
ifneq ($(TARGET_ARCH_ABI), arm64-v8a)
ifneq ($(TARGET_ARCH_ABI), armeabi)
LOCAL_STATIC_LIBRARIES += tbb
endif
endif
LOCAL_SRC_FILES := SecondSightJNI.cpp
LOCAL_SRC_FILES += RecolorCMVFilter.cpp
LOCAL_SRC_FILES += RecolorRCFilter.cpp
LOCAL_SRC_FILES += RecolorRGVFilter.cpp
LOCAL_SRC_FILES += StrokeEdgesFilter.cpp
LOCAL_SRC_FILES += ImageDetectionFilter.cpp
include $(BUILD_SHARED_LIBRARY)
提示
使代码适配 OpenCV 2.x
删除行LOCAL_STATIC_LIBRARIES += opencv_hal,因为 OpenCV 2.x 没有hal(硬件抽象层)模块。
让我们进一步思考前面的代码块。 LOCAL_MODULE变量是我们模块的名称。 LOCAL_C_INCLUDES变量是编译器将搜索头文件的路径。 LOCAL_LDLIBS变量是动态库的列表,可在 Android 上使用它们在运行时加载,而LOCAL_STATIC_LIBRARIES变量是静态库的列表,这些库将内置在我们的模块中。 后者包括六个 OpenCV 模块(calib3d,features2d,flann,imgproc,core和hal),以及 OpenCV 的一个可选依赖项,Intel 线程构建块(TBB)。 最后,LOCAL_SRC_FILES是我们的源文件的列表。
注意
TBB 是一个多处理库,它使 OpenCV 可以优化许多操作。 但是,TBB 与 armeabi 架构(即 ARMv5 和 ARMv6)无关,因为该架构仅使用单核处理器。 TBB 尚不适用于相对较新的架构 arm64-v8a(即 64 位 ARMv8-A)。
要完成Android.mk,我们必须指定在LOCAL_STATIC_LIBRARIES中命名的每个库的路径。 以下是相关代码:
include $(CLEAR_VARS)
LOCAL_MODULE := opencv_calib3d
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libopencv_calib3d.a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := opencv_features2d
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libopencv_features2d.a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := opencv_flann
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libopencv_flann.a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := opencv_imgproc
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libopencv_imgproc.a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := opencv_core
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libopencv_core.a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := opencv_hal
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libopencv_hal.a
include $(PREBUILT_STATIC_LIBRARY)
ifneq ($(TARGET_ARCH_ABI), arm64-v8a)
ifneq ($(TARGET_ARCH_ABI), armeabi)
include $(CLEAR_VARS)
LOCAL_MODULE := tbb
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libtbb.a
include $(PREBUILT_STATIC_LIBRARY)
endif
endif
提示
使代码适配 OpenCV 2.x
删除以下几行,因为 OpenCV 2.x 没有hal(硬件抽象层)模块:
include $(CLEAR_VARS)
LOCAL_MODULE := opencv_hal
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libopencv_hal.a
include $(PREBUILT_STATIC_LIBRARY)
注意
请注意,我们并未使用 OpenCV 随附的所有库文件。 (可选)我们可以从项目中删除未使用的库文件(和相关的头文件)。 但是,当我们构建 Second Sight 时,应用的大小最终将不受项目jni文件夹中未使用的库和标头的影响。
将更改保存到Android.mk。 我们已经完成了 Makefile 的编写。 现在,让我们对构建过程进行简单测试。 打开终端(在 Mac 或 Linux 中)或命令提示符(在 Windows 中),然后运行以下命令:
$ cd <secondsight_project_path>
$ ndk-build
注意
请注意,<secondsight_project_path>是指项目的根文件夹,而不是jni子文件夹。 如果找不到ndk-build命令,请参考第 1 章,“设置 OpenCV”,并确保将 Android NDK 目录正确添加到系统的PATH变量中(在 Unix)或Path变量(在 Windows 中)。
ndk-build命令应打印尝试生成的结果(包括任何警告和错误)。 如果构建成功,则应该在 Second Sight 项目文件夹中创建以下库文件:
-
libs/arm64-v8a/libSecondSight.so -
libs/armeabi/libSecondSight.so -
libs/armeabi-v7a/libSecondSight.so -
libs/mips/libSecondSight.so -
libs/mips64/libSecondSight.so -
libs/x86/libSecondSight.so -
libs/x86_64/libSecondSight.so注意
.so扩展名表示共享库,它是已编译库的标准名称(在 Unix 系统中,例如 Android),可以在运行时由一个或多个应用加载。 这类似于动态链接库或.dll(在 Windows 系统中)。
请注意,这四个编译库对应于Application.mk中指定的四个目标架构。 到目前为止,由于尚未在头文件和源文件中编写任何代码,因此我们尚未将任何有用的内容编译到这些库文件中! 让我们继续修改Filter接口,然后编写我们的第一个 C++ 类,其 JNI 绑定以及使用它的 Java 代码。
修改过滤器接口
正如我们在“了解 JNI 的作用”中讨论一样,C++ 是为手动内存管理而设计的。 它根据命令释放内存,而不是按照垃圾收集器的时间表释放内存。 如果我们也想为 Java 用户提供一定程度的手动内存管理,则可以编写一个公共方法,该方法负责释放与 Java 类关联的任何 C++ 资源(或其他非托管资源)。 通常,这种方法被命名为dispose。 由于我们的几个Filter实现都将拥有 C++ 资源,因此我们将dispose方法添加到Filter.java中:
public interface Filter {
public abstract void dispose();
public abstract void apply(final Mat src, final Mat dst);
}
让我们修改NoneFilter.java以提供dispose的空实现,如以下代码所示:
public class NoneFilter implements Filter {
@Override
public void dispose() {
// Do nothing.
}
@Override
public void apply(final Mat src, final Mat dst) {
// Do nothing.
}
}
类似地,作为占位符,我们可以向Filter的所有其他具体实现中添加一个空的dispose方法。 稍后,在使用 JNI 的类中,我们将向dispose的实现中添加更多代码。
提示
OpenCV4Android 不提供dispose方法,但在关联的 Java 对象被垃圾收集时,它会覆盖finalize方法以释放 C++ 资源。 (finalize方法是从java.lang.Object继承的,并在对象被垃圾回收时被调用。)当我们编写dispose的非空实现时,我们还将覆盖finalize来调用dispose。 因此,如果没有手动调用dispose,我们的类将提供自动内存管理作为后备。
活动的onDestroy方法是释放资源的好地方。 让我们修改CameraActivity.java中的onDestroy方法,以确保每个Filter对象的dispose方法都被调用:
@Override
public void onDestroy() {
if (mCameraView != null) {
mCameraView.disableView();
}
// Dispose of native resources.
disposeFilters(mImageDetectionFilters);
disposeFilters(mCurveFilters);
disposeFilters(mMixerFilters);
disposeFilters(mConvolutionFilters);
super.onDestroy();
}
private void disposeFilters(Filter[] filters) {
if (filters != null) {
for (Filter filter : filters) {
filter.dispose();
}
}
}
请注意,我们正在使用辅助方法disposeFilters来遍历Filter[]数组并在每个成员上调用dispose。
注意
要查找有关各种系统和语言的内存管理和垃圾回收的大量信息,请浏览内存管理参考。
在使重新定义了Filter接口之后,接下来让我们研究一个在 C++ 类之上构建的实现。
将通道混合过滤器移植到 C++
C++(与其祖先 C 一样)将定义与实现分开。 Java 类是在一个文件中定义和实现的,而 C++ 类或函数通常是在头文件(扩展名为.h或.hpp)中定义的,并在源文件(扩展名为.cpp)。 标头包含其他标头和源文件可能需要的完整接口规范,以实现或使用该类。 通常,多个文件取决于给定的标头,因此,标头将从多个文件中多次导入。 我们必须手动确保仅一次定义了该类,而不管其标题被导入了多少次。 为了实现这一点,我们将类定义(或其他定义)包装在名为守卫的一组编译时指令中,如下所示:
#ifndef MY_CLASS
#define MY_CLASS
// ... TODO: Define MyClass here.
#endif
Java 用户应该熟悉与 C++ 头文件相关的其他概念,例如导入,名称空间,类,方法,实例变量和保护级别。 C++ 名称空间是可选的,但与 Java 名称空间一样,它们具有限定一组类(和函数)名称的重要目的。 因此,如果我们碰巧导入两个相同名称的类,则只要它们属于不同的名称空间,就可以在它们之间进行歧义消除。 C++ 保护级别与 Java 保护级别一样,包括public(使方法或变量甚至在其类之外可见),protected(在类及其子类内部可见)和private(仅在类内部可见) )。 C++ 保护级别还可以具有修饰符friend(对于指定的其他类或函数可见)。 这在某种程度上可以与 Java 的保护级别internal(对于同一名称空间中的其他类可见)进行比较。
让我们继续一个例子。 我们将把RecolorRCFilter类从 Java 移植到 C++。 请记住,此类具有apply方法,并且作为实例变量,它具有矩阵的集合(用于存储颜色通道操纵的中间结果)。 这些矩阵的 C++ 类型为cv::Mat(cv名称空间中的Mat类)。 此类型在 OpenCV 的核心模块中定义,我们将导入其头文件。 让我们打开RecolorRCFilter.hpp并为RecolorRCFilter类编写以下标头:
#ifndef RECOLOR_RC_FILTER
#define RECOLOR_RC_FILTER
#include <opencv2/core/core.hpp>
namespace secondsight {
class RecolorRCFilter
{
public: // All subsequent methods or variables are public.
void apply(cv::Mat &src, cv::Mat &dst);
private: // All subsequent methods or variables are private.
cv::Mat mChannels[4];
};
} // namespace secondsight
#endif // RECOLOR_RC_FILTER
请注意,apply方法的自变量名称前面带有和号(&),这意味着这些自变量是通过引用(而不是通过值)传递的。 但是,即使我们确实按值传递了cv::Mat对象,底层的图像数据也将在cv::Mat的调用方和被调用方的副本之间共享(除非我们执行某些操作,导致任何副本重新分配或重新创建其数据) 缓冲)。 这是cv::Mat设计的显着特征:cv::Mat不是其数据的专有所有者,而是cv::Mat仅持有指针指向(地址为 ) 数据。
注意
有关cv::Mat的更多信息,请参见位于这个页面的官方文档。 另外,请注意 Packt Publishing 即将出版的书《OpenCV 蓝图》。 本书除其他主题外,还介绍了在图像处理管道的不同阶段(以及潜在的不同库)之间共享数据的陷阱和最佳实践。 这样的讨论促进了对 OpenCV(尤其是cv::Mat)如何以及为什么查看原始图像数据的更深入的了解。
现在,让我们编写相应的源文件RecolorRCFilter.cpp。 在这里,我们必须实现标头中声明的所有方法的主体,在这种情况下,仅是apply方法。 该实现必须使用完全限定的名称来引用方法,例如secondsight::RecolorRCFilter::apply(secondsight命名空间中RecolorRCFilter类的apply方法),但如果我们先前已经做了一个语句,例如using namespace secondsight;。 这是完整的实现:
#include "RecolorRCFilter.hpp"
using namespace secondsight;
void RecolorRCFilter::apply(cv::Mat &src, cv::Mat &dst)
{
cv::split(src, mChannels);
cv::Mat g = mChannels[1];
cv::Mat b = mChannels[2];
// dst.g = 0.5 * src.g + 0.5 * src.b
cv::addWeighted(g, 0.5, b, 0.5, 0.0, g);
// dst.b = dst.g
g.copyTo(b);
cv::merge(mChannels, 4, dst);
}
请注意,相关的 OpenCV 函数在 Java 和 C++ 版本中具有相同的名称,并且参数几乎相同。 通常是这种情况。
我们的下一个任务是编写 JNI 函数,以向 Java 调用者公开 C++ 类的功能。 JNI 函数必须包装在如下所示的块中:
extern "C" {
// TODO: Put JNI functions here.
}
当 C++ 编译器遇到extern "C"块时,它将以 C 或 C++ 链接器将能够理解它们的方式编译内容。 这是至关重要的,因为 JNI(像许多其他互操作性层一样)理解 C,但不能理解 C++。 因此,SecondSightJNI.cpp的实现从extern "C"块开始,如以下代码所示:
#include <jni.h>
#include "RecolorRCFilter.hpp"
using namespace secondsight;
extern "C" {
JNI 函数倾向于使用长名称,因为函数名称与标准 Java 方法名称相对应。 此外,JNI 定义了许多特殊类型,它们表示 Java 原语,类和对象。 考虑以下 JNI 函数的签名和主体,其标准 Java 名称为com.nummist.secondsight.filters.mixer.RecolorRCFilter.newSelf(在com.nummist.secondsight.filters.mixer名称空间的RecolorRCFilter类中称为newSelf的方法):
JNIEXPORT jlong JNICALL
Java_com_nummist_secondsight_filters_mixer_RecolorRCFilter_newSelf(
JNIEnv *env, jclass clazz)
{
RecolorRCFilter *self = new RecolorRCFilter();
return (jlong)self;
}
前两个参数表示拥有该方法的 Java 环境和 Java 类。 (在这种情况下,Java 类为RecolorRCFilter。)每当我们从 Java 端进行 JNI 函数调用时,都会隐式传递这两个参数。 我们的newSelf函数的作用是创建一个 C++ 对象,并为 Java 端提供对该对象的引用。 返回类型jlong是 Java long,我们可以使用它来表示 C++ 对象的内存地址。 请注意,我们使用 C++ 的new运算符创建一个对象并获取其内存地址。 C++ 知道此地址存储特定类型的实例(在本例中为RecolorRCFilter),并且我们说该地址是RecolorRCFilter指针或RecolorRCFilter *(其中*表示指针)。 Java 不了解此 C++ 类型信息,因此我们只是将指针转换为jlong,然后再将其返回给 Java 端。 我们的其他 JNI 函数将接受selfAddr参数,这将使 Java 端可以指定RecolorRCFilter类的实例。 因此,该地址成为将 Java 对象映射到对应的 C++ 对象的键。
每当我们使用new运算符创建 C++ 对象时,我们就要负责该对象的生命周期。 具体来说,当不再需要该对象时,我们必须使用delete运算符销毁它(从而释放其内存)。 释放地址处的内存后,将指针的值设置为 0(在 C++ 中也称为NULL)是一个很好的策略,这意味着指针不再指向任何东西。 以下 JNI 函数允许 Java 调用程序删除给定地址处的RecolorRCFilter C++ 对象(如果该地址尚未为 0 或NULL):
JNIEXPORT void JNICALL
Java_com_nummist_secondsight_filters_mixer_RecolorRCFilter_deleteSelf(
JNIEnv *env, jclass clazz, jlong selfAddr)
{
if (selfAddr != 0)
{
RecolorRCFilter *self = (RecolorRCFilter *)selfAddr;
delete self;
}
}
请注意,我们将地址强制转换为RecolorRCFilter *。 此步骤至关重要。 delete操作符需要指针的类型信息。
为了向 Java 调用者公开apply方法,我们将再次使用对象的地址作为 JNI 参数。 这次,我们不仅需要RecolorRCFilter对象的地址,还需要源矩阵和目标矩阵。 考虑以下实现:
JNIEXPORT void JNICALL
Java_com_nummist_secondsight_filters_mixer_RecolorRCFilter_apply(
JNIEnv *env, jclass clazz, jlong selfAddr, jlong srcAddr,
jlong dstAddr)
{
if (selfAddr != 0)
{
RecolorRCFilter *self = (RecolorRCFilter *)selfAddr;
cv::Mat &src = *(cv::Mat *)srcAddr;
cv::Mat &dst = *(cv::Mat *)dstAddr;
self->apply(src, dst);
}
}
} // extern "C"
即使您以前使用过 C++,诸如cv::Mat &src = *(cv::Mat *)srcAddr;之类的行的语法也可能有点令人困惑。 让我们从右到左阅读声明。 这意味着:将地址转换为矩阵指针,将其解引用(在地址处获取矩阵),然后存储对矩阵的引用(而不是按值复制)。 左星号(*)是解除引用运算符,而右星号是强制转换运算符的一部分。 同时,self->apply(src, dst);行使用箭头(->)运算符,这意味着我们要解引用左侧,然后访问其方法或字段之一。 等效但更冗长的表达是(*self).apply(src, dst);。
我们在 C++ 中的工作已针对此过滤器完成。 现在,我们将从头开始重写RecolorRCFilter.java的内容,以便我们的 Java 类成为围绕 C++ 类的精简包装。 包装器类将只有一个实例变量long mSelfAddr,它将存储相应 C++ 对象的内存地址。 RecolorRCFilter的 Java 实现如下所示:
public final class RecolorRCFilter implements Filter {
private long mSelfAddr;
在使用libSecondSight.so(我们内置的 C++ 库)中的任何功能之前,我们必须调用静态方法System.loadLibrary("SecondSight");。 请注意,该参数是库文件名的简化版本,省略了lib和.so。 如下所示,static初始化块是加载库的好地方:
static {
// Load the native library if it is not already loaded.
System.loadLibrary("SecondSight");
}
注意
我们可以安全地将loadLibrary调用放在多个类的静态初始化块中。 尽管有多次调用,但是给定的库仅加载一次,并在整个应用中共享。
Java 类的构造器通过newSelf JNI 函数调用 C++ 类的构造器。 此函数返回 C++ 对象的内存地址,该 Java 对象存储在mSelfAddr实例变量中。 该实现是单线的:
public RecolorRCFilter() {
mSelfAddr = newSelf();
}
相反,dispose方法(在 Java 端)通过deleteSelf JNI 函数调用(在 C++ 对象上)delete运算符。 然后,Java 对象将其mSelfAddr变量设置为 0(相当于 C++ NULL指针),以便我们不再尝试访问已删除的内存。 由于在deleteSelf的实现中进行了 NULL 检查,因此可以安全地多次调用dispose。 该实现有两个方面:
@Override
public void dispose() {
deleteSelf(mSelfAddr);
mSelfAddr = 0;
}
我们还重写了finalize方法,如下所示,以确保在对象被垃圾回收之前,我们的dispose方法始终至少被调用一次:
@Override
protected void finalize() throws Throwable {
dispose();
}
Java 类的apply方法是 JNI 函数的另一个简单包装。 请记住,JNI 函数需要cv::Mat对象的内存地址,它们是源矩阵和目标矩阵。 方便地,org.opencv.core.Mat提供了一种方法getNativeObjectAddr,该方法返回关联的cv::Mat对象的内存地址。 因此,请注意,OpenCV4Android 提供了与我们使用的相同的包装器:Java 对象保存 C++ 对象的地址。 在以下apply的实现中,请注意RecolorRCFilter对象的地址(mSelfAddr)以及源矩阵和目标矩阵的地址的使用:
@Override
public void apply(final Mat src, final Mat dst) {
apply(mSelfAddr, src.getNativeObjAddr(),
dst.getNativeObjAddr());
}
最后,我们使用native关键字声明 JNI 函数。 函数名称,返回类型和参数类型必须与SecondSightJNI.cpp中的函数名称匹配。 (否则,我们将在运行时出现错误。)以下是相关代码:
private static native long newSelf();
private static native void deleteSelf(long selfAddr);
private static native void apply(long selfAddr, long srcAddr,
long dstAddr);
}
现在我们准备测试我们的第一套 C++ 和 JNI 代码! 确保已保存对所有 C++ 文件的更改,运行ndk-build,在 Eclipse 中重建项目,然后在您的 Android 设备上尝试重新构建 Second Sight。 红青色过滤器的行为应与我们之前的构建完全相同。
对于RecolorCMVFilter和RecolorRGVFilter的 C++ 和 JNI 版本,请下载本章的代码包。 由于这些类与RecolorRCFilter非常相似,因此我们将在此处省略其代码以节省时间。 接下来,让我们看一下StrokeEdgesFilter的移植,该移植使用卷积过滤器增强边缘。
将边缘增强过滤器移植到 C++
我们只有一个边缘增强过滤器类可移植到 C++。 让我们首先编写其标题。 StrokeEdgesFilter.hpp将是一个简单的头文件,该文件指定StrokeEdgesFilter类具有构造器,apply方法和两个矩阵(用于存储边缘查找内核和中间计算)。 该类的定义只有一个依赖项,即 OpenCV 的core模块。 这是头文件的完整代码:
#ifndef STROKE_EDGES_FILTER
#define STROKE_EDGES_FILTER
#include <opencv2/core/core.hpp>
namespace secondsight {
class StrokeEdgesFilter
{
public:
StrokeEdgesFilter();
void apply(cv::Mat &src, cv::Mat &dst);
private:
cv::Mat mKernel;
cv::Mat mEdges;
};
} // namespace secondsight
#endif // STROKE_EDGES_FILTER
继续实现,我们可以通过导入头文件和其他依赖项 OpenCV 的imgproc模块开始StrokeEdgesFilter.cpp:
#include <opencv2/imgproc/imgproc.hpp>
#include "StrokeEdgesFilter.hpp"
using namespace secondsight;
现在,我们可以考虑方法的主体。 构造器需要创建5 x 5内核矩阵并设置其值。 在这种情况下,我们需要显式创建指定大小和类型的矩阵。 (相比之下,许多 OpenCV 函数可能隐式创建或重新创建作为参数给出的矩阵,具体取决于函数所暗示的大小和类型。)内核包含可能为正数或为负数的小数,因此让我们使用 8 位无符号整数作为数据类型。 这是构造器的实现,该实现说明如何使用cv::Mat的create方法设置大小和类型,以及at方法来设置元素的值:
StrokeEdgesFilter::StrokeEdgesFilter()
{
mKernel.create(5, 5, CV_8S);
mKernel.at<char>(0, 0) = 0;
mKernel.at<char>(0, 1) = 0;
mKernel.at<char>(0, 2) = 1;
mKernel.at<char>(0, 3) = 0;
mKernel.at<char>(0, 4) = 0;
mKernel.at<char>(1, 0) = 0;
mKernel.at<char>(1, 1) = 1;
mKernel.at<char>(1, 2) = 2;
mKernel.at<char>(1, 3) = 1;
mKernel.at<char>(1, 4) = 0;
mKernel.at<char>(2, 0) = 1;
mKernel.at<char>(2, 1) = 2;
mKernel.at<char>(2, 2) = -16;
mKernel.at<char>(2, 3) = 2;
mKernel.at<char>(2, 4) = 1;
mKernel.at<char>(3, 0) = 0;
mKernel.at<char>(3, 1) = 1;
mKernel.at<char>(3, 2) = 2;
mKernel.at<char>(3, 3) = 1;
mKernel.at<char>(3, 4) = 0;
mKernel.at<char>(4, 0) = 0;
mKernel.at<char>(4, 1) = 0;
mKernel.at<char>(4, 2) = 1;
mKernel.at<char>(4, 3) = 0;
mKernel.at<char>(4, 4) = 0;
}
apply方法的 C++ 实现使用与旧 Java 实现相同的 OpenCV 函数序列。 通过使用过滤边缘查找内核并将结果反转,我们得到了白色背景上黑色边缘的图像。 然后,我们将此中间结果与源图像相乘,以生成目标图像,该图像在正常的彩色背景上具有黑色边缘。 以下是相关代码:
void StrokeEdgesFilter::apply(cv::Mat &src, cv::Mat &dst)
{
cv::filter2D(src, mEdges, -1, mKernel);
cv::bitwise_not(mEdges, mEdges);
cv::multiply(src, mEdges, dst, 1.0/255.0);
}
这样就完成了StrokeEdgesFilter类的 C++ 版本。 我们还需要修改SecondSightJNI.cpp和StrokeEdgesFilter.java。 这些修改与我们为RecolorRC类编写的代码(在上一节中)非常相似,因此为节省时间,此处将其省略。 您可以在本章的可下载代码包中找到它们。
进行必要的修改后,您可能想再次尝试构建并运行 Second Sight。 确保已保存对所有 C++ 文件的更改,运行ndk-build,在 Eclipse 中重建项目,然后在 Android 设备上测试该应用。 所有过滤器应起作用,就像它们在先前章节中一样。 如果遇到任何问题,请尝试运行ndk-build clean && ndk-build,以确保从头开始重建我们的 C++ 库。
我们最后(也是最大)的修改集与图像跟踪过滤器有关。 让我们看看这个复杂的类在 C++ 中的样子!
将ARFilter移植到 C++
我们将ImageDetectionFilter类移植到 C++。 但是,为了限制该项目的范围,我们将CameraProjectionAdapter和ARCubeRenderer保留为纯 Java 类。
再次让我们从头文件ImageDetectionFilter.hpp开始。 此标头使用来自 OpenCV 的core和features2d模块的类型,以及标准模板库的std::vector类。 (后一类类似于 Java 的ArrayList。)因此,我们可以从以下代码开始头文件:
#ifndef IMAGE_DETECTION_FILTER
#define IMAGE_DETECTION_FILTER
#include <vector>
#include <opencv2/core/core.hpp>
#include <opencv2/features2d/features2d.hpp>
namespace secondsight {
C++ 版本的ImageDetectionFilter具有与 Java 版本相同的方法。 参数略有不同,因为我们仍然需要 Java 端提供参考图像(用于构造器)和投影矩阵(用于apply方法)。 让我们声明如下的 C++ 方法:
class ImageDetectionFilter
{
public:
ImageDetectionFilter(cv::Mat &referenceImageBGR,
double realSize);
float *getGLPose();
void apply(cv::Mat &src, cv::Mat &dst, cv::Mat &projection);
private:
void findPose(cv::Mat &projection);
void draw(cv::Mat &src, cv::Mat &dst);
ImageDetectionFilter的实例变量包括几个cv::Mat对象和std::vector对象。 我们对ImageDetectionFilter的 C++ 定义也有几个cv::Ptr(OpenCV 指针)对象,它们存储对特征检测器,描述符提取器和描述符匹配器的引用。 与我们以前在实现 JNI 函数时遇到的普通旧指针不同,cv::Ptr是智能指针,这意味着在不再引用任何对象之后,它会自动管理该引用对象的删除。 OpenCV 中智能指针的作用之一是提供一种延迟复杂对象创建的方法。 (指针可以只将引用到NULL,直到我们准备为其创建真实的对象为止。)以下是实例变量的声明:
// The reference image (this detector's target).
cv::Mat mReferenceImage;
// Features of the reference image.
std::vector<cv::KeyPoint> mReferenceKeypoints;
// Descriptors of the reference image's features.
cv::Mat mReferenceDescriptors;
// The corner coordinates of the reference image, in pixels.
cv::Mat mReferenceCorners;
// The reference image's corner coordinates, in 3D, in real
// units.
std::vector<cv::Point3f> mReferenceCorners3D;
// Features of the scene (the current frame).
std::vector<cv::KeyPoint> mSceneKeypoints;
// Descriptors of the scene's features.
cv::Mat mSceneDescriptors;
// Tentative corner coordinates detected in the scene, in
// pixels.
cv::Mat mCandidateSceneCorners;
// A grayscale version of the scene.
cv::Mat mGraySrc;
// Tentative matches of the scene features and reference features.
std::vector<cv::DMatch> mMatches;
// A feature detector, which finds features in images, and
// descriptor extractor, which creates descriptors of features.
cv::Ptr<cv::Feature2D> mFeatureDetectorAndDescriptorExtractor;
// A descriptor matcher, which matches features based on their
// descriptors.
cv::Ptr<cv::DescriptorMatcher> mDescriptorMatcher;
// Distortion coefficients of the camera's lens.
cv::Mat mDistCoeffs;
// The Euler angles of the detected target.
cv::Mat mRVec;
// The XYZ coordinates of the detected target.
cv::Mat mTVec;
// The rotation matrix of the detected target.
cv::Mat mRotation;
// The OpenGL pose matrix of the detected target.
float mGLPose[16];
// Whether the target is currently detected.
bool mTargetFound;
};
} // namespace secondsight
#endif // IMAGE_DETECTION_FILTER
提示
使代码适配 OpenCV 2.x
OpenCV 3.x 的 C++ 接口使用一种类别Feature2D进行特征检测和描述符提取,而 OpenCV 2.x 使用两种类别的FeatureDetector和DescriptorExtractor。 因此,替换以下行:
cv::Ptr<cv::Feature2D> mFeatureDetectorAndDescriptorExtractor;
请改用以下几行:
cv::Ptr<cv::FeatureDetector> mFeatureDetector;
cv::Ptr<cv::DescriptorExtractor> mDescriptorExtractor;
完成标头后,我们打开ImageDetectionFilter.cpp编写实现。 除了 OpenCV 的core和features2d模块(我们已经在标头中导入)之外,我们还需要导入imgproc和calib3d模块。 另外,我们需要 C 标准库的float模块,因为我们需要将一些局部变量初始化为float的最大值。 这是导入语句和我们通常的namespace声明:
#include <float.h>
#include <opencv2/imgproc/imgproc.hpp>
#include <opencv2/calib3d/calib3d.hpp>
#include "ImageDetectionFilter.hpp"
using namespace secondsight;
构造器的实现需要处理参考图像,它作为参数(从 Java 端)接收。 第一步是将图像从 BGR 格式转换为我们将使用的两种格式,即灰度和 RGBA。 在以下代码中可以看到这些转换:
ImageDetectionFilter::ImageDetectionFilter(
cv::Mat &referenceImageBGR, double realSize)
{
// Create grayscale and RGBA versions of the reference image.
cv::Mat referenceImageGray;
cv::cvtColor(referenceImageBGR, referenceImageGray,
cv::COLOR_BGR2GRAY);
cv::cvtColor(referenceImageBGR, mReferenceImage,
cv::COLOR_BGR2RGBA);
接下来,构造器需要定义 2D 和 3D 中参考图像角的坐标。 查看下面的代码,请注意使用cv::Mat的at方法设置矩阵元素,并使用std::vector的push_back方法追加元素:
int cols = referenceImageGray.cols;
int rows = referenceImageGray.rows;
// Store the reference image's corner coordinates, in pixels.
mReferenceCorners.create(4, 1, CV_32FC2);
mReferenceCorners.at<cv::Vec2f>(0, 0)[0] = 0.0f;
mReferenceCorners.at<cv::Vec2f>(0, 0)[1] = 0.0f;
mReferenceCorners.at<cv::Vec2f>(1, 0)[0] = cols;
mReferenceCorners.at<cv::Vec2f>(1, 0)[1] = 0.0f;
mReferenceCorners.at<cv::Vec2f>(2, 0)[0] = cols;
mReferenceCorners.at<cv::Vec2f>(2, 0)[1] = rows;
mReferenceCorners.at<cv::Vec2f>(3, 0)[0] = 0.0f;
mReferenceCorners.at<cv::Vec2f>(3, 0)[1] = rows;
// Compute the image's width and height in real units, based
// on the specified real size of the image's smaller dimension.
float aspectRatio = (float)cols /(float)rows;
float halfRealWidth;
float halfRealHeight;
if (cols > rows) {
halfRealHeight = 0.5f * realSize;
halfRealWidth = halfRealHeight * aspectRatio;
} else {
halfRealWidth = 0.5f * realSize;
halfRealHeight = halfRealWidth / aspectRatio;
}
// Define the real corner coordinates of the printed image
// so that it normally lies in the xy plane (like a painting
// or poster on a wall).
// That is, +z normally points out of the page toward the
// viewer.
mReferenceCorners3D.push_back(
cv::Point3f(-halfRealWidth, -halfRealHeight, 0.0f));
mReferenceCorners3D.push_back(
cv::Point3f( halfRealWidth, -halfRealHeight, 0.0f));
mReferenceCorners3D.push_back(
cv::Point3f( halfRealWidth, halfRealHeight, 0.0f));
mReferenceCorners3D.push_back(
cv::Point3f(-halfRealWidth, halfRealHeight, 0.0f));
为了完成构造器的实现,我们将创建并使用特征检测器,描述符提取器和描述符匹配器。 请记住,我们的ImageDetectionFilter对象存储指向检测器,提取器和匹配器的智能指针(cv::Ptr对象)。 cv::Ptr类支持星号和箭头运算符,模仿了这些运算符应用于普通旧指针的方式。 因此,我们可以使用一种熟悉的语法来取消对 OpenCV 的智能指针的引用。 对于一个实际的示例,请考虑以下代码,该代码完成了构造器:
// Create the feature detector, descriptor extractor, and
// descriptor matcher.
mFeatureDetector = cv::FeatureDetector::create("ORB");
mDescriptorExtractor = cv::DescriptorExtractor::create("ORB");
mDescriptorMatcher = cv::DescriptorMatcher::create(
"BruteForce-HammingLUT");
// Detect the reference features and compute their descriptors.
mFeatureDetector->detect(referenceImageGray,
mReferenceKeypoints);
mDescriptorExtractor->compute(referenceImageGray,
mReferenceKeypoints, mReferenceDescriptors);
mCandidateSceneCorners.create(4, 1, CV_32FC2);
// Assume no distortion.
mDistCoeffs.zeros(4, 1, CV_64F);
mTargetFound = false;
}
提示
使代码适配 OpenCV 2.x
OpenCV 3.x 的 C++ 接口使用Feature2D类进行特征检测和描述符提取,而 OpenCV 2.x 使用 2 种类,即FeatureDetector和DescriptorExtractor。 因此,替换以下行:
mFeatureDetectorAndDescriptorExtractor =
cv::ORB::create();
mDescriptorMatcher = cv::DescriptorMatcher::create(
"BruteForce-HammingLUT");
mFeatureDetectorAndDescriptorExtractor->detect(
referenceImageGray, mReferenceKeypoints);
mFeatureDetectorAndDescriptorExtractor->compute(
referenceImageGray, mReferenceKeypoints,
mReferenceDescriptors);
请改用以下几行:
mFeatureDetector = cv::FeatureDetector::create("ORB");
mDescriptorExtractor =
cv::DescriptorExtractor::create("ORB");
mDescriptorMatcher = cv::DescriptorMatcher::create(
"BruteForce-HammingLUT");
mFeatureDetector->detect(referenceImageGray,
mReferenceKeypoints);
mDescriptorExtractor->compute(referenceImageGray,
mReferenceKeypoints, mReferenceDescriptors);
getGLPose方法仅返回 OpenGL 姿势矩阵(如果已找到目标)或NULL(如果未找到目标):
float *ImageDetectionFilter::getGLPose()
{
return (mTargetFound ? mGLPose : NULL);
}
apply方法查找关键点,描述符和匹配项。 然后,它调用辅助方法以尝试找到目标的 3D 姿势并绘制目标的预览(以防用户尚未找到目标)。 C++ 实现类似于旧的 Java 实现,不同之处在于,投影矩阵现在是一个自变量(将从 Java 端传递到 C++ 端)。 这是 C++ 代码:
void ImageDetectionFilter::apply(cv::Mat &src, cv::Mat &dst,
cv::Mat &projection)
{
// Convert the scene to grayscale.
cv::cvtColor(src, mGraySrc, cv::COLOR_RGBA2GRAY);
// Detect the scene features, compute their descriptors,
// and match the scene descriptors to reference descriptors.
mFeatureDetectorAndDescriptorExtractor->detect(
referenceImageGray, mReferenceKeypoints);
mFeatureDetectorAndDescriptorExtractor->compute(
referenceImageGray, mReferenceKeypoints,
mReferenceDescriptors);
mDescriptorMatcher->match(mSceneDescriptors,
mReferenceDescriptors, mMatches);
// Attempt to find the target image's 3D pose in the scene.
findPose(projection);
// If the pose has not been found, draw a thumbnail of the
// target image.
draw(src, dst);
}
提示
使代码适配 OpenCV 2.x
OpenCV 3.x 的 C++ 接口使用一类Feature2D进行特征检测和描述符提取,而 OpenCV 2.x 使用两类FeatureDetector和DescriptorExtractor。 因此,请考虑以下几行:
mFeatureDetectorAndDescriptorExtractor->detect(
referenceImageGray, mReferenceKeypoints);
mFeatureDetectorAndDescriptorExtractor->compute(
referenceImageGray, mReferenceKeypoints,
mReferenceDescriptors);
我们必须将它们替换为:
mFeatureDetector->detect(mGraySrc, mSceneKeypoints);
mDescriptorExtractor->compute(mGraySrc, mSceneKeypoints,
mSceneDescriptors);
findPose helper 方法的实现很长,我们将分几个部分来考虑。 首先,如果我们有足够的匹配项来找到姿势,我们将继续查找匹配项之间的最小和最大(最佳和最差)距离。 这是机会,可以编写循环遍历std::vector的元素的循环,如以下代码所示:
void ImageDetectionFilter::findPose(cv::Mat &projection)
{
if (mMatches.size() < 4) {
// There are too few matches to find the pose.
return;
}
// Calculate the max and min distances between keypoints.
float maxDist = 0.0f;
float minDist = FLT_MAX;
for (int i = 0; i < mMatches.size(); i++) {
cv::DMatch match = mMatches[i];
float dist = match.distance;
if (dist < minDist) {
minDist = dist;
}
if (dist > maxDist) {
maxDist = dist;
}
}
如果总体上看似合理(基于最小距离),我们将继续在std::vector<cv::Point2f>>对象中收集良好的匹配关键点。 (这些类似于我们在 Java 实现中使用的List<Point>对象。)这是相关的 C++ 代码:
// The thresholds for minDist are chosen subjectively
// based on testing. The unit is not related to pixel
// distances; it is related to the number of failed tests
// for similarity between the matched descriptors.
if (minDist > 50.0) {
// The target is completely lost.
mTargetFound = false;
return;
} else if (minDist > 25.0) {
// The target is lost but maybe it is still close.
// Keep using any previously found pose.
return;
}
// Identify "good" keypoints based on match distance.
std::vector<cv::Point2f> goodReferencePoints;
std::vector<cv::Point2f> goodScenePoints;
double maxGoodMatchDist = 1.75 * minDist;
for(int i = 0; i < mMatches.size(); i++) {
cv::DMatch match = mMatches[i];
if (match.distance < maxGoodMatchDist) {
goodReferencePoints.push_back(
mReferenceKeypoints[match.trainIdx].pt);
goodScenePoints.push_back(
mSceneKeypoints[match.queryIdx].pt);
}
}
如果我们至少有四个匹配良好的关键点对,则可以找到单应性,估计 3D 中的角位置,并验证投影的角是否形成凸形。 为了符合相关 OpenCV 函数的参数类型,我们的旧 Java 实现需要将List<Point>对象转换为MatOfPoint2f对象。 OpenCV 的 C++ 接口对参数类型的限制较少。 大多数参数可以是cv::Mat或std::vector对象。 为了使这两种类型可以互换,OpenCV 的作者编写了一个名为InputArray的代理类(提供公共接口的替代品)。 它可以从cv::Mat或std::vector隐式构造自己。 因此,只要您在 OpenCV 的 C++ API 文档中看到InputArray,便会知道可以提供cv::Mat或std::vector。
InputArray的隐式构造很便宜,并且不复制图像数据。 以下代码块显示了我们在没有显式转换的情况下继续使用std::vector对象的情况:
if (goodReferencePoints.size() < 4 ||
goodScenePoints.size() < 4) {
// There are too few good points to find the pose.
return;
}
// There are enough good points to find the pose.
// (Otherwise, the method would have already returned.)
// Find the homography.
cv::Mat homography = cv::findHomography(
goodReferencePoints, goodScenePoints);
// Use the homography to project the reference corner
// coordinates into scene coordinates.
cv::perspectiveTransform(mReferenceCorners,
mCandidateSceneCorners, homography);
// Check whether the corners form a convex polygon. If not,
// (that is, if the corners form a concave polygon), the
// detection result is invalid because no real perspective can
// make the corners of a rectangular image look like a concave
// polygon!
if (!cv::isContourConvex(mCandidateSceneCorners)) {
return;
}
方法实现的其余部分负责查找目标的 3D 姿势(基于角点)并将此姿势转换为 OpenGL 的格式。 请注意,使用cv::Mat的at方法来获取和设置矩阵的元素:
// Find the target's Euler angles and XYZ coordinates.
cv::solvePnP(mReferenceCorners3D, mCandidateSceneCorners,
projection, mDistCoeffs, mRVec, mTVec);
// Positive y is up in OpenGL, down in OpenCV.
// Positive z is backward in OpenGL, forward in OpenCV.
// Positive angles are counter-clockwise in OpenGL,
// clockwise in OpenCV.
// Thus, x angles are negated but y and z angles are
// double-negated (that is, unchanged).
// Meanwhile, y and z positions are negated.
mRVec.at<double>(0, 0) *= -1.0; // negate x angle
// Convert the Euler angles to a 3x3 rotation matrix.
cv::Rodrigues(mRVec, mRotation);
// OpenCV's matrix format is transposed, relative to
// OpenGL's matrix format.
mGLPose[0] = (float)mRotation.at<double>(0, 0);
mGLPose[1] = (float)mRotation.at<double>(0, 1);
mGLPose[2] = (float)mRotation.at<double>(0, 2);
mGLPose[3] = 0.0f;
mGLPose[4] = (float)mRotation.at<double>(1, 0);
mGLPose[5] = (float)mRotation.at<double>(1, 1);
mGLPose[6] = (float)mRotation.at<double>(1, 2);
mGLPose[7] = 0.0f;
mGLPose[8] = (float)mRotation.at<double>(2, 0);
mGLPose[9] = (float)mRotation.at<double>(2, 1);
mGLPose[10] = (float)mRotation.at<double>(2, 2);
mGLPose[11] = 0.0f;
mGLPose[12] = (float)mTVec.at<double>(0, 0);
mGLPose[13] = -(float)mTVec.at<double>(1, 0); // negate y position
mGLPose[14] = -(float)mTVec.at<double>(2, 0); // negate z position
mGLPose[15] = 1.0f;
mTargetFound = true;
}
对于draw助手方法,C++ 实现看上去与旧 Java 实现有点相似。 但是,cv::Mat(与 Java 接口中的org.opencv.core.Mat不同)没有submat方法。 相反,它具有adjustROI方法,其参数是cv::Mat的顶部,底部,左侧和右侧边缘的偏移量。 在我们的情况下,偏移量为负,因为我们正在缩小关注区域(ROI)。 之后,我们需要将 ROI 恢复到其原始状态,因此我们使用正偏移量。 这是draw方法的实现,突出显示了adjustROI的使用:
void ImageDetectionFilter::draw(cv::Mat &src, cv::Mat &dst)
{
if (&src != &dst) {
src.copyTo(dst);
}
if (!mTargetFound) {
// The target has not been found.
// Draw a thumbnail of the target in the upper-left
// corner so that the user knows what it is.
// Compute the thumbnail's larger dimension as half the
// video frame's smaller dimension.
int height = mReferenceImage.rows;
int width = mReferenceImage.cols;
int maxDimension = MIN(dst.rows, dst.cols) / 2;
double aspectRatio = width / (double)height;
if (height > width) {
height = maxDimension;
width = (int)(height * aspectRatio);
} else {
width = maxDimension;
height = (int)(width / aspectRatio);
}
// Select the region of interest (ROI) where the thumbnail
// will be drawn.
int offsetY = height - dst.rows;
int offsetX = width - dst.cols;
dst.adjustROI(0, offsetY, 0, offsetX);
// Copy a resized reference image into the ROI.
cv::resize(mReferenceImage, dst, dst.size(), 0.0, 0.0,
cv::INTER_AREA);
// Deselect the ROI.
dst.adjustROI(0, -offsetY, 0, -offsetX);
}
}
尽管ImageDetectionFilter C++ 类的实现很长,但关联的 JNI 函数仅比先前编写的函数稍微复杂。 首先,我们必须修改SecondSightJNI.cpp以导入新的头文件,如以下代码片段所示:
#include <jni.h>
// ...
#include "ImageDetectionFilter.hpp"
using namespace secondsight;
extern "C" {
// ...
在同一文件的后面,我们将添加新的 JNI 函数,以从其构造器开始公开所有ImageDetectionFilter的公共方法。 Java 端负责以任意单位提供参考图像的地址及其实际(打印的)尺寸:
JNIEXPORT jlong JNICALL
Java_com_nummist_secondsight_filters_ar_ImageDetectionFilter_newSelf(
JNIEnv *env, jclass clazz, jlong referenceImageBGRAddr,
jdouble realSize)
{
cv::Mat &referenceImageBGR = *(cv::Mat *)referenceImageBGRAddr;
ImageDetectionFilter *self = new ImageDetectionFilter(
referenceImageBGR, realSize);
return (jlong)self;
}
与往常一样,我们通过以下方式公开delete操作:
JNIEXPORT void JNICALL
Java_com_nummist_secondsight_filters_ar_ImageDetectionFilter_deleteSelf(
JNIEnv *env, jclass clazz, jlong selfAddr)
{
if (selfAddr != 0)
{
ImageDetectionFilter *self = (ImageDetectionFilter *)selfAddr;
delete self;
}
}
要公开getGLPose方法,我们必须创建一个新的 Java 数组,该数组引用与 C++ 数组相同的数据,如以下代码所示:
JNIEXPORT jfloatArray JNICALL
Java_com_nummist_secondsight_filters_ar_ImageDetectionFilter_getGLPose(
JNIEnv *env, jclass clazz, jlong selfAddr)
{
if (selfAddr == 0)
{
return NULL;
}
ImageDetectionFilter *self = (ImageDetectionFilter *)selfAddr;
float *glPoseNative = self->getGLPose();
if (glPoseNative == NULL)
{
return NULL;
}
jfloatArray glPoseJava = env->NewFloatArray(16);
if (glPoseJava != NULL)
{
env->SetFloatArrayRegion(glPoseJava, 0, 16, glPoseNative);
}
return glPoseJava;
}
要公开apply方法,我们只需将源,目标和投影矩阵的地址重新解释,这些地址来自 Java 端为long整数:
JNIEXPORT void JNICALL
Java_com_nummist_secondsight_filters_ar_ImageDetectionFilter_apply(
JNIEnv *env, jclass clazz, jlong selfAddr, jlong srcAddr,
jlong dstAddr, jlong projectionAddr)
{
if (selfAddr != 0)
{
ImageDetectionFilter *self = (ImageDetectionFilter *)selfAddr;
cv::Mat &src = *(cv::Mat *)srcAddr;
cv::Mat &dst = *(cv::Mat *)dstAddr;
cv::Mat &projection = *(cv::Mat *)projectionAddr;
self->apply(src, dst, projection);
}
}
} // extern "C"
现在,让我们将的注意力转向 Java 包装器。 与往常一样,它存储 C++ 对象的地址。 它还有一个CameraProjectionAdapter的实例,它是一个纯 Java 类,在上一章中我们没有进行过修改。 我们修改后的ImageDetectionFilter.java文件将开始如下:
public final class ImageDetectionFilter implements ARFilter {
private long mSelfAddr;
private final CameraProjectionAdapter mCameraProjectionAdapter;
static {
// Load the native library if it is not already loaded.
System.loadLibrary("SecondSight");
}
构造器的参数与以前的版本相同。 它从应用的资源中加载参考图像,然后将该图像以及图像的实际(打印的)大小(以任意单位)传递给 C++ 类的构造器。 这是 Java 类的构造器的修改后的实现:
public ImageDetectionFilter(final Context context,
final int referenceImageResourceID,
final CameraProjectionAdapter cameraProjectionAdapter,
final double realSize)
throws IOException {
final Mat referenceImageBGR = Utils.loadResource(context,
referenceImageResourceID,
Imgcodecs.CV_LOAD_IMAGE_COLOR);
mSelfAddr = newSelf(referenceImageBGR.getNativeObjAddr(),
realSize);
mCameraProjectionAdapter = cameraProjectionAdapter;
}
提示
使代码适配 OpenCV 2.x
将Imgcodecs.CV_LOAD_IMAGE_COLOR替换为Highgui.CV_LOAD_IMAGE_COLOR。
与其他包装器类一样,我们需要通过以下实现添加dispose和finalize方法:
@Override
public void dispose() {
deleteSelf(mSelfAddr);
mSelfAddr = 0;
}
@Override
protected void finalize() throws Throwable {
dispose();
}
需要修改getGLPose方法,以便仅返回等效 C++ 方法的结果:
@Override
public float[] getGLPose() {
return getGLPose(mSelfAddr);
}
同样,我们对apply方法的新实现将简单地将源,目标和投影矩阵传递给 C++ apply方法。 投影矩阵来自CameraProjectionAdapter对象,如以下代码所示:
@Override
public void apply(final Mat src, final Mat dst) {
final Mat projection =
mCameraProjectionAdapter.getProjectionCV();
apply(mSelfAddr, src.getNativeObjAddr(),
dst.getNativeObjAddr(),
projection.getNativeObjAddr());
}
为了完成包装,我们将对 JNI 方法进行以下声明:
private static native long newSelf(long referenceImageBGRAddr,
double realSize);
private static native void deleteSelf(long selfAddr);
private static native float[] getGLPose(long selfAddr);
private static native void apply(long selfAddr, long srcAddr,
long dstAddr, long projectionAddr);
}
就这样! 确保已保存对所有 C++ 文件的更改,运行ndk-build,在 Eclipse 中重建项目,然后在您的 Android 设备上测试 Second Sight。 该应用的行为应与我们在上一章中完成的纯 Java 版本相同。 我们已经学习了如何使用 OpenCV 的 Java 接口或其 C++ 接口以及 JNI 来获得相同的结果!
了解有关 OpenCV 和 C++ 的更多信息
Packt Publishing 提供了大大量 OpenCV 书籍,其中许多书籍都集中在库的 C++ 接口上。 这里有一些很好的例子:
- 《OpenCV 本质》,由多位作者撰写:这是对带有 C++ 的 OpenCV 的简要介绍。
- 《通过 OpenCV 学习图像处理》,由多位作者撰写:这本书着重从开始到高级的照片和视频增强技术。
- 《OpenCV 计算机视觉应用编程手册》,作者:RobertLaganière:这是一本秘籍书,展示了如何使用 OpenCV 有效解决常见的计算机视觉问题。
- 《精通 OpenCV 和实用的计算机视觉项目》由多个作者编写,这是一组高级项目。 每个都结合了 OpenCV 的多种功能来解决问题。
- 《OpenCV 蓝图》(即将出版),由多位作者撰写:这是高级教程和文章的集合,旨在消除有关使用 OpenCV 构建鲁棒应用的谜团。 涵盖了理论,实现和基准测试的组合。
相反,如果您正在寻找有关 C++ 的一般参考指南,请查看 Bjarne Stroustrup 的书,例如《C++ 之旅》(由 Addison-Wesley 出版)。 Stroustrup 是 C++ 的设计者和原始实现者。
总结
我们弥合了 Java 和 C++ 之间的鸿沟! 更具体地说,我们已经将几个 Java 类移植到 C++,并编译了这些 C++ 类以及可从 Java 调用的 JNI 函数。 另外,通过跨 JNI 边界传递 C++ 对象的地址,我们探索了一种创建 Java 类的技术,该 Java 类将 C++ 类包装得很薄。 该技术是 OpenCV Java 接口(包装 C++ 接口)的基础。 因此,在不知道的情况下,我们在先前的章节中已经依赖于 JNI 和 C++ 库(OpenCV)! 利用我们的新知识,我们可以更好地控制应用利用 C++ 库的方式。 我们也可以更好地学习如何在其他平台和其他库上使用 OpenCV。
通过从 Java 到 C++ 的“转义”,我们还完成了 Android 和 OpenCV 的快速浏览。 恭喜,您学到了所有知识,并感谢您通过 OpenCV Android 应用编程第二版的成功! 如果您对此书有任何疑问,或者想告诉我有关您其他 OpenCV 的工作,请写信给josephhowse@nummist.com保持联系。 另外,请记住检查这个页面以获取更新和勘误。
在我们再次见面之前,希望您享有清晰的视野和启发性的发现!





浙公网安备 33010602011771号