Python3 OpenCV4 计算机视觉学习手册(三)

原文:Learning OpenCV 4 Computer Vision with Python 3

协议:CC BY-NC-SA 4.0

九、相机模型和增强现实

如果您喜欢几何图形,摄影或 3D 图形,那么本章的主题尤其适合您。 我们将学习 3D 空间和 2D 投影之间的关系。 我们将根据相机和镜头的基本光学参数对这种关系进行建模。 最后,我们将相同的关系应用于在精确的透视投影中绘制 3D 形状的任务。 在所有这些过程中,我们将整合我们之前在图像匹配和对象跟踪方面的知识,以便跟踪其真实世界对象的 3D 运动,该对象的 2D 投影由相机实时捕获。

在实践上,我们将构建一个增强现实应用,该应用使用有关相机,对象和运动的信息,以便将 3D 图形实时叠加在被跟踪对象的顶部。 为此,我们将克服以下技术挑战:

  • 建模相机和镜头的参数
  • 使用 2D 和 3D 关键点建模 3D 对象
  • 通过匹配的关键点来检测对象
  • 使用cv2.solvePnPRansac函数查找对象的 3D 姿势
  • 使用卡尔曼过滤器平滑 3D 姿势
  • 在对象上方绘制图形

在本章的过程中,如果您继续构建自己的增强现实引擎或任何其他依赖 3D 跟踪的系统(例如机器人导航系统),则将获得对您有用的技能。

技术要求

本章使用 Python,OpenCV 和 NumPy。 请返回第 1 章,“设置 OpenCV”,了解安装说明。

本章的完整代码和示例视频可以在本书的 GitHub 存储库中找到,位于chapter09文件夹中。

本章代码包含摘自 Joseph Howse(本书作者之一)称为“可视化不可见”的开源演示项目的摘录。 要了解有关此项目的更多信息,请访问这个页面中的资源库。

了解 3D 图像跟踪和增强现实

我们已经在第 6 章,“检索图像并使用图像描述符搜索”中解决了图像匹配问题。 此外,我们在第 8 章,“跟踪对象”中解决了涉及连续跟踪的问题。 因此,尽管我们尚未解决任何 3D 跟踪问题,但我们熟悉图像跟踪系统的许多组件。

那么,3D 跟踪到底是什么? 嗯,这是一个不断更新 3D 空间中对象姿态估计值的过程,通常使用六个变量:三个变量来表示对象的 3D 平移(即位置),以及其他三个变量代表其 3D 旋转。

3D 跟踪的一个更专业的术语是 6DOF 跟踪 –也就是说,使用 6 个自由度的跟踪,即我们刚才提到的 6 个变量。

有 3 种方式将 3D 旋转表示为三个变量。 在其他地方,您可能会遇到各种各样的欧拉角表示形式,它们以围绕xyz的三个单独的 2D 旋转来描述 3D 旋转。 ]轴按特定顺序排列。 OpenCV 不使用欧拉角来表示 3D 旋转。 相反,它使用称为 Rodrigues 旋转向量的表示形式。 具体来说,OpenCV 使用以下六个变量来表示 6DOF 姿态:

  1. t[x]:这是对象沿x轴的平移。
  2. t[y]:这是对象沿y轴的平移。
  3. t[z]:这是对象沿z轴的平移。
  4. r[x]:这是对象 Rodrigues 旋转向量的第一个元素。
  5. r[y]:这是对象 Rodrigues 旋转向量的第二个元素。
  6. r[z]:这是对象 Rodrigues 旋转向量的第三个元素。

不幸的是,在 Rodrigues 表示中,没有简单的方法来解释r[x]r[y]r[z]彼此分开。 总之,它们作为向量r编码旋转轴和围绕该轴的旋转角。 具体来说,以下公式定义了r向量之间的关系; 角度θ,归一化的轴向量,; 和3 x 3旋转矩阵R

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/195c32cf-e876-419e-bd2e-db6f9f92cbf3.png

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/1c6d1338-0714-497d-8340-26e989e2c863.png

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/d0618dea-fa06-4362-a19b-e586dbbe5670.png

作为 OpenCV 程序员,我们没有义务直接计算或解释任何这些变量。 OpenCV 提供了将 Rodrigues 旋转向量作为返回值的函数,我们可以将此旋转向量作为参数传递给其他 OpenCV 函数-无需自己操纵其内容。

出于我们的目的(实际上,对于计算机视觉中的许多问题),相机是 3D 坐标系的原点。 因此,在任何给定的帧中,摄像机的当前t[x]t[y]t[z]r[x]r[y]r[z]值均定义为 0。我们将努力跟踪相对于相机当前姿势的其他对象。

当然,为了便于讲授,我们将可视化 3D 跟踪结果。 这将我们带入增强现实AR)的领域。 广义上讲,AR 是一种持续跟踪现实世界对象之间的关系并将这些关系应用于虚拟对象的过程,以这种方式,用户可以将虚拟对象视为固定在现实世界中的某物上。 通常,视觉 AR 基于 3D 空间和透视投影的关系。 确实,我们的情况很典型。 我们希望通过在框架中跟踪的对象上方绘制一些 3D 图形的投影来可视化 3D 跟踪结果。

稍后,我们将回到透视投影的概念。 同时,让我们概述一下 3D 图像跟踪和可视 AR 的一组典型步骤:

  1. 定义相机和镜头的参数。 我们将在本章中介绍该主题。
  2. 初始化我们将用于稳定 6DOF 跟踪结果的卡尔曼过滤器。 有关卡尔曼滤波的更多信息,请参考第 8 章,“跟踪对象”。
  3. 选择一个参考图像,代表我们要跟踪的对象的表面。 对于我们的演示,对象将是一个平面,例如一张打印图像的纸。
  4. 创建一个 3D 点列表,代表对象的顶点。 坐标可以是任何单位,例如米,毫米或任意单位。 例如,您可以任意定义 1 个单位以等于对象的高度。
  5. 从参考图像中提取特征描述符。 对于 3D 跟踪应用,ORB 是描述符的一种流行选择,因为它甚至可以在智能手机等适度的硬件上实时进行计算。 我们的演示将使用 ORB。 有关 ORB 的更多信息,请参考第 6 章,“检索图像并使用图像描述符搜索”。
  6. 使用与“步骤 4”中相同的映射,将特征描述符从像素坐标转换为 3D 坐标。
  7. 开始从相机捕获帧。 对于每个帧,执行以下步骤:
    1. 提取特征描述符,并尝试在参考图像和框架之间找到良好的匹配。 我们的演示将使用基于 FLANN 的匹配和比率测试。 有关这些用于匹配描述符的方法的更多信息,请参考第 6 章,“检索图像并使用图像描述符搜索”。
    2. 如果找到的匹配次数不足,请继续下一帧。 否则,请继续执行其余步骤。

在继续演示代码之前,让我们进一步讨论此概述的两个方面:第一,相机和镜头的参数;第二,相机和镜头的参数。 第二,神秘函数cv2.solvePnPRansac的作用。

了解相机和镜头参数

通常,当我们捕获图像时,至少涉及三个对象:

  • 主题是我们要在图像中捕获的东西。 通常,它是一个反射光的对象,我们希望该对象在图像中聚焦(清晰)。
  • 透镜透射光,并将所有来自焦平面的反射光聚焦到像平面上。 焦平面是包括主体(如先前定义)的圆形空间切片。 图像平面是一个圆形的空间切片,其中包含图像传感器(稍后定义)。 通常,这些平面垂直于镜头的主轴(长度方向)。 镜头具有光学中心,这是来自焦平面的入射光在聚光回像平面之前会聚的点。 焦距(即,光学中心与焦平面之间的距离)根据光学中心与像平面之间的距离而变化。 如果我们将光学中心移近图像平面,则焦距会增加; 相反,如果我们将光学中心移离图像平面更远,则焦距会减小(通常,在相机系统中,通过简单地前后移动镜头的机制来调整焦点)。 焦距定义为当焦距为无穷远时光学中心与像平面之间的距离。
  • 图像传感器是一种感光表面,可在模拟介质(例如胶片)或数字介质中接收光并将其记录为图像。 通常,图像传感器是矩形的。 因此,它不会覆盖圆形图像平面的角。 图像的对角线视场FOV:要成像的 3D 空间的角度范围)与焦距,图像传感器的宽度和图像传感器的高度具有三角关系 。 我们将尽快探讨这种关系。

这是说明上述定义的图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/ab0c8470-0238-469e-865c-e331c5e6ea7b.png

对于计算机视觉,我们通常使用固定焦距的镜头,这对于给定的应用是最佳的。 但是,镜头可以具有可变的焦距。 这种镜头称为变焦镜头放大意味着增加焦距,而缩小意味着减少焦距。 在机械上,变焦镜头通过移动镜头内部的光学元件来实现此目的。

让我们使用变量f表示焦距,然后使用变量(c[x]c[y])代表图像传感器在图像平面内的中心点。 OpenCV 使用以下矩阵,称为摄像机矩阵,表示摄像机和镜头的基本参数:

f0c[x]
0fc[y]
001

假设图像传感器在图像平面中居中(通常应该如此),我们可以计算出c[x]c[y],具体取决于图像传感器的宽度w和高度h,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/7506878d-90e0-4091-8a0f-ea302317d26d.png

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/1f51d1cf-fedc-4d5e-8532-cfb3b4acfb34.png

如果我们知道对角 FOV θ,则可以使用以下三角公式来计算焦距:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/47c9fc57-a965-4d7a-9597-8866328e6e28.png

或者,如果我们不知道对角 FOV,但是我们知道水平 FOV ɸ和垂直 FOV ψ,则可以如下计算焦距:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/7b1d78be-2fb1-4cc4-84e7-784a6b8f9db6.png

您可能想知道我们如何获取这些变量中任何一个的值作为起点。 有时,相机或镜头的制造商会在产品的规格表中提供有关传感器尺寸,焦距或 FOV 的数据。 例如,规格表可能以毫米为单位列出传感器尺寸和焦距,以度为单位列出 FOV。 但是,如果规格表的信息不足,我们还有其他方法来获取必要的数据。 重要的是,传感器的大小和焦距无需以实际单位(例如毫米)表示。 我们可以用任意单位表示它们,例如像素等效单位

您可能会问,什么是像素等效单位? 好吧,当我们从相机捕获一帧时,图像中的每个像素对应于图像传感器的某个区域,并且该区域具有真实世界的宽度(和真实世界的高度,通常与宽度相同) )。 因此,如果我们要捕获分辨率为1280 x 720的帧,则可以说图像传感器的宽度w为 1280 个像素当量单位,高度为h, 是 720 像素等效单位。 这些单元无法在不同的实际传感器尺寸或分辨率下进行比较。 但是,对于给定的摄像机和分辨率,它们使我们能够进行内部一致的测量,而无需知道这些测量的实际规模。

这个技巧使我们能够为任何图像传感器定义wh(因为我们始终可以检查捕获帧的像素尺寸)。 现在,为了能够计算焦距,我们只需要另一种数据类型:FOV。 我们可以使用一个简单的实验来测量。 拿一张纸并将其粘贴到墙壁(或另一个垂直表面)上。 放置相机和镜头,使其直接面对纸张,并且纸张对角填充框架。 (如果纸张的纵横比与框架的纵横比不匹配,请裁切纸张以使其匹配。)测量从纸张的一个角到对角线对角的对角线大小s。 此外,测量从纸张到镜头镜筒下半点的距离d。 然后,通过三角法计算对角 FOV θ

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/7f285e25-ea97-444c-bec6-e968b4b1154f.png

假设通过该实验,我们确定给定的相机和镜头的对角 FOV 为 70 度。 如果我们知道以1280 x 720的分辨率捕获帧,则可以按像素等效单位计算焦距,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/c4a7a69d-3309-4946-835a-3b94794da526.png

除此之外,我们还可以计算图像传感器的中心坐标:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/a223d627-103a-4141-a05c-0b759e70b4f9.png

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/321cf98a-fcbf-44a8-b826-5a24c71f15f9.png

因此,我们有以下相机矩阵:

1048.70640
01048.7360
001

前面的参数对于 3D​​跟踪是必需的,它们正确地代表了理想的相机和镜头。 但是,实际设备可能会明显偏离此理想状态,并且仅相机矩阵无法代表所有可能的偏差类型。 失真系数是一组附加参数,可以表示与理想模型的以下几种偏差:

  • 径向畸变:这意味着镜头无法平等地放大图像的所有部分; 因此,它会使直边显得弯曲或波浪状。 对于径向失真系数,使用诸如k[n]等变量名(例如通常使用k[1]k[2]k[3]等)。 如果k[1] < 0,则通常表示镜头遭受镜筒变形的影响,这意味着直边似乎朝着镜框的边界向外弯曲。 图片。 相反,k[1] > 0通常表示镜头遭受枕形畸变,这意味着直边似乎向内向图像中心弯曲。 如果符号在整个系列中交替出现(例如k[1] > 0k[2] < 0k[3] > 0),这可能意味着镜头遭受了胡子变形的困扰,这意味着笔直的边缘显得波浪状。
  • 切向失真:这意味着镜头的主轴(长度方向)不垂直于图像传感器; 因此,透视图是倾斜的,直线边缘之间的角度似乎不同于正常透视图投影中的角度。 对于切向失真系数,使用变量名称,例如p[n](例如,通常使用p[1]p[2]等)。 系数的符号取决于镜头相对于图像传感器的倾斜方向。

下图说明了某些类型的径向变形:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/e9873543-c06a-427e-adbe-3307ca26f8f5.png

OpenCV 提供的功能可处理多达五个失真系数:k[1]k[2]p[1]p[2]k[3]。 (OpenCV 期望它们以此顺序作为数组的元素。)很少地,您可能能够从相机或镜头供应商那里获得有关畸变系数的官方数据。 另外,您可以使用 OpenCV 的棋盘校准过程来估计失真系数以及相机矩阵。 这涉及从各种位置和角度捕获一系列印刷棋盘图案的图像。 有关更多详细信息,请参考官方教程

出于演示目的,我们将简单假设所有失真系数均为 0,这意味着没有失真。 当然,我们并不真正相信我们的网络摄像头是光学工程中的无畸变杰作。 我们只是认为失真还不足以明显影响我们的 3D 跟踪和 AR 演示。 如果我们试图构建一个精确的测量设备而不是视觉演示,那么我们将更加关注失真的影响。

与棋盘校准过程相比,我们在本节中概述的公式和假设产生了更为受限或理想的模型。 但是,我们的方法具有更简单,更容易重现的优点。 棋盘校准过程比较费力,每个用户可能会以不同的方式执行它,从而产生不同的(有时是错误的)结果。

吸收了有关相机和镜头参数的背景信息之后,现在让我们检查一个 OpenCV 函数,该函数使用这些参数作为 6DOF 跟踪问题解决方案的一部分。

了解cv2.solvePnPRansac

cv2.solvePnPRansac函数为所谓的n点透视(PnP)问题实现了求解器。 给定 3D 和 2D 点之间的一组n唯一匹配,以及生成此 3D 点 2D 投影的相机和镜头的参数,求解器将尝试估计 3D 对象相对于相机的 6DOF 姿态。 这个问题有点类似于寻找一组 2D 到 2D 关键点匹配的单应性,就像我们在第 6 章“检索图像并使用图像描述符搜索”中所做的那样。 但是,在 PnP 问题中,我们有足够的其他信息来估计更具体的空间关系(自由度姿势),而不是单应性,后者只是告诉我们一种投影关系。

那么cv2.solvePnPRansac如何工作? 顾名思义,该函数实现了 Ransac 算法,这是一种通用的迭代方法,旨在处理可能包含异常值(在我们的情况下为不匹配)的一组输入。 每次 Ransac 迭代都会找到一个潜在的解决方案,该解决方案可最大程度地减少对输入的平均误差的度量。 然后,在下一次迭代之前,将具有不可接受的大误差的所有输入标记为离群值并丢弃。 此过程一直持续到解收敛为止,这意味着没有发现新的异常值,并且平均误差也可以接受。

对于 PnP 问题,误差是根据重投影误差来衡量的,这意味着根据相机和镜头参数观察到的 2D 点的位置与预测位置之间的距离以及我们得出的 6DOF 姿态,目前正在考虑它作为潜在的解决方案。 在过程的最后,我们希望获得与大多数 3D 到 2D 关键点匹配一致的 6DOF 姿势。 此外,我们想知道该解决方案的匹配项是哪些。

让我们考虑cv2.solvePnPRansac的函数签名:

retval, rvec, tvec, inliers = cv.solvePnPRansac(
    objectPoints,
    imagePoints,
    cameraMatrix,
    distCoeffs,
    rvec=None,
    tvec=None,
    useExtrinsicGuess=False
    iterationsCount=100,
    reprojectionError=8.0,
    confidence=0.98,
    inliers=None,
    flags=cv2.SOLVEPNP_ITERATIVE)

如我们所见,该函数具有四个返回值:

  • retval:如果求解器收敛于一个解,则为True; 否则为False
  • rvec:此数组包含r[x]r[y]r[z]– 6DOF 姿态中的三个旋转自由度。
  • tvec:此数组包含t[x]t[y]t[z]– 6DOF 姿态中的三个平移(位置)自由度。
  • inliers:如果求解器收敛于一个解,则此向量包含与该解一致的输入点的索引(在objectPointsimagePoints中)。

该函数还具有 12 个参数:

  • objectPoints:这是 3D 点的数组,表示没有平移和旋转时(换句话说,当 6DOF 姿态变量都为 0 时)对象的关键点。
  • imagePoints:这是 2D 点的数组,代表图像中对象的关键点匹配。 具体而言,认为imagePoints[i]objectPoints[i]匹配。
  • cameraMatrix:此 2D 数组是相机矩阵,我们可以按照前面的“了解相机和镜头参数”部分中介绍的方式导出。
  • distCoeffs:这是失真系数的数组。 如果我们不知道它们,可以(为简单起见)假定它们全为 0,如上一节所述。
  • rvec:如果求解器收敛于一个解,它将把解的r[x]r[y]r[z]此数组中的值。
  • tvec:如果求解器收敛于一个解,它将把解的t[x]t[y]t[z]值在此数组中。
  • useExtrinsicGuess:如果这是True,则求解器会将rvectvec参数中的值视为初始猜测,然后尝试找到与这些近似的解决方案。 否则,求解器将在搜索解决方案时采取不偏不倚的方法。
  • iterationsCount:这是求解器应尝试的最大迭代次数。 如果经过此迭代次数后仍未收敛于解决方案,则放弃。
  • reprojectionError:这是求解器将接受的最大重投影误差; 如果某个点的重投影误差大于此误差,则求解器会将其视为异常值。
  • confidence:求解器尝试收敛于置信度得分大于或等于此值的解决方案。
  • inliers:如果求解器收敛于一个解,则它将解的内点的索引放入此数组中。
  • flags:这些标志指定求解器的算法。 默认值cv2.SOLVEPNP_ITERATIVE是使重投影误差最小并且没有特殊限制的方法,因此通常是最佳选择。 一个有用的替代方法是cv2.SOLVEPNP_IPPEIPPE,是基于无限小平面的姿势估计的缩写),但它仅限于平面对象。

尽管此函数涉及很多变量,但我们会发现它的使用是对第 6 章,“检索图像和使用图像描述符搜索”的关键点匹配问题的自然扩展。 ,以及本章要介绍的 3D 和投影问题。 考虑到这一点,让我们开始探索本章的示例代码。

实现演示应用

我们将在单个脚本ImageTrackingDemo.py中实现我们的演示,该脚本将包含以下组件:

  1. 导入语句
  2. 用于自定义灰度转换的辅助函数
  3. 辅助函数可将关键点从 2D 空间转换为 3D 空间
  4. 应用类ImageTrackingDemo,它将封装相机和镜头的模型,参考图像的模型,卡尔曼过滤器,6DOF 跟踪结果,以及将跟踪图像并绘制简单的 AR 可视化效果的应用循环
  5. main函数启动应用

该脚本将依赖于另一个文件reference_image.png,它将代表我们要跟踪的图像。

事不宜迟,让我们深入研究脚本的实现。

导入模块

在 Python 标准库中,我们将使用math模块进行三角计算,并使用timeit模块进行精确的时间测量(这将使我们能够更有效地使用卡尔曼过滤器)。 和往常一样,我们还将使用 NumPy 和 OpenCV。 因此,我们对ImageTrackingDemo.py的实现始于以下import语句:

import math
import timeit

import cv2
import numpy

现在,让我们继续执行辅助函数。

执行灰度转换

在本书中,我们使用以下代码执行了灰度转换:

gray_img = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY)

也许早就应该提出一个问题:此函数如何将 BGR 值准确映射到灰度值? 答案是每个输出像素的灰度值都是相应输入像素的 B,G 和 R 值的加权平均值,如下所示:

gray = (0.114 * blue) + (0.587 * green) + (0.299 * red)

这些砝码被广泛使用。 它们来自于 1982 年发布的称为 CCIR 601 的电信行业标准。 当我们看到明亮的场景时,我们的眼睛对黄绿色光线最为敏感。 此外,这些配重应该在带有淡黄色灯光和带蓝色阴影的场景(例如晴天的室外场景)中产生高对比度。 这些是我们使用 CCIR 601 砝码的充分理由吗? 不,他们不是; 没有科学证据表明 CCIR 601 转换权重可为计算机视觉中的任何特定目的提供最佳的灰度输入。

实际上,出于图像跟踪的目的,有证据支持其他灰度转换算法。 SamuelMacêdo,GivânioMelo 和 Judith Kelner 在他们的论文中谈到了这个主题,《针对应用于 SIFT 描述符的灰度转换技术的比较研究》(SBC 交互式系统杂志,第 6 卷,第 2 期,2015 年) 。 他们测试各种转换算法,包括以下类型:

  • 加权平均转换gray = (0.07 * blue) + (0.71 * green) + (0.21 * red),与 CCIR 601 有点相似
  • 非加权平均转换gray = (blue + green + red) / 3
  • 仅基于单个颜色通道的转换,例如gray = green
  • 经过伽玛校正的转换,例如gray = 255 * (green / 255) ^ (1/2.2),其中,灰度值随输入呈指数(非线性)变化

根据该论文,加权平均转换产生的结果相对不稳定-有利于找到某些图像的匹配和单应性,而对于其他图像则不好。 非加权平均转换和单通道转换产生更一致的结果。 对于某些图像,经伽玛校正的转换可产生最佳结果,但这些转换在计算上更加昂贵。

为了演示的目的,我们将通过获取每个像素的 B,G 和 R 值的简单(未加权)平均值来执行灰度转换。 这种方法在计算上很便宜(在实时跟踪中很理想),并且我们期望它比 OpenCV 中的默认加权平均转换带来更一致的跟踪结果。 这是我们用于执行自定义转换的辅助函数的实现:

def convert_to_gray(src, dst=None):
    weight = 1.0 / 3.0
    m = numpy.array([[weight, weight, weight]], numpy.float32)
    return cv2.transform(src, m, dst)

注意cv2.transform函数的使用。 这是 OpenCV 提供的经过优化的通用矩阵转换函数。 我们可以使用它来执行以下操作:像素输出通道的值是输入通道值的线性组合。 在我们的 BGR 到灰度转换的情况下,我们有一个输出通道和三个输入通道,因此我们的转换矩阵m有一行三列。

编写了用于灰度转换的辅助函数后,让我们继续考虑用于从 2D 到 3D 空间转换的辅助函数。

执行 2D 到 3D 空间的转换

请记住,我们有一个参考图像reference_image.png,并且我们希望 AR 应用跟踪该图像的打印副本。 出于 3D 跟踪的目的,我们可以将此打印图像表示为 3D 空间中的平面。 让我们定义局部坐标系的方式是,通常(当 6DOF 姿势的元素全部为 0 时),此平面对象像悬挂在墙上的图片一样竖立; 它的正面是上面有图像的一侧,其原点是图像的中心。

现在,让我们假设我们想将参考图像中的给定像素映射到此 3D 平面上。 给定 2D 像素坐标,图像的像素尺寸以及将像素转换为我们要在 3D 空间中使用的度量单位的比例因子,我们可以使用以下辅助函数将像素映射到平面上:

def map_point_onto_plane(point_2D, image_size, image_scale):
    x, y = point_2D
    w, h = image_size
    return (image_scale * (x - 0.5 * w),
            image_scale * (y - 0.5 * h),
            0.0)

比例因子取决于打印图像的实际大小和我们选择的单位。 例如,我们可能知道我们的打印图像高 20 厘米–或我们可能不在乎绝对比例,在这种情况下,我们可以定义一个任意单位,以使打印图像高一个单位。 无论如何,只要以任何单位(绝对或相对)给出 2D 像素坐标,参考图像的尺寸以及参考图像的实际高度的列表,我们就可以使用以下帮助器函数在列表上获取相应 3D 坐标的列表。 飞机:

def map_points_to_plane(points_2D, image_size, image_real_height):

    w, h = image_size
    image_scale = image_real_height / h

    points_3D = [map_point_onto_plane(
                     point_2D, image_size, image_scale)
                 for point_2D in points_2D]
    return numpy.array(points_3D, numpy.float32)

请注意,我们为多个点map_points_to_plane提供了一个辅助函数,并且为每个点map_point_to_plane都调用了一个辅助函数。

稍后,在“初始化跟踪器”部分中,我们将为参考图像生成 ORB 关键点描述符,并且我们将使用我们的map_points_to_plane辅助函数将关键点坐标从 2D 转换为 3D。 我们还将转换图像的四个 2D 顶点(即其左上角,右上角,右下角和左下角),以获得平面的四个 3D 顶点。 在执行 AR 绘制时,我们将使用这些顶点-特别是在“绘制跟踪结果”部分中。 与绘图相关的功能(在 OpenCV 和许多其他框架中)期望为 3D 形状的每个面按顺时针顺序(从正面角度)指定顶点。 为了满足此要求,让我们实现另一个专用于映射顶点的辅助函数。 这里是:

def map_vertices_to_plane(image_size, image_real_height):

    w, h = image_size

    vertices_2D = [(0, 0), (w, 0), (w, h), (0, h)]
    vertex_indices_by_face = [[0, 1, 2, 3]]

    vertices_3D = map_points_to_plane(
        vertices_2D, image_size, image_real_height)
    return vertices_3D, vertex_indices_by_face

请注意,我们的顶点映射帮助函数map_vertices_to_plane调用了map_points_to_plane帮助函数,该函数又调用了map_point_to_plane。 因此,我们所有的映射函数都有一个共同的核心。

当然,除了平面外,2D 到 3D 关键点映射和顶点映射也可以应用于其他 3D 形状。 若要了解我们的方法如何扩展到 3D 长方体和 3D 圆柱体,请参阅 Joseph Howse 的《可视化不可视》demo 项目,该项目可在这个页面

我们已经完成了辅助函数的实现。 现在,让我们继续进行代码的面向对象部分。

实现应用类

我们将在名为ImageTrackingDemo的类中实现我们的应用,该类将具有以下方法:

  • __init__(self, capture, diagonal_fov_degrees, target_fps, reference_image_path, reference_image_real_height):初始化器将为参考图像设置捕获设备,相机矩阵,卡尔曼过滤器以及 2D 和 3D 关键点。
  • run(self):此方法将运行应用的主循环,该循环捕获,处理和显示帧,直到用户通过按Esc键退出。 在其他方法的帮助下执行每个帧的处理,这些方法将在此列表中接下来提到。
  • _track_object(self):此方法将执行 6DOF 跟踪并绘制跟踪结果的 AR 可视化图像。
  • _init_kalman_transition_matrix(self, fps):此方法将配置卡尔曼过滤器,以确保针对指定的帧速率正确模拟加速度和速度。
  • _apply_kalman(self):此方法将通过应用卡尔曼过滤器来稳定 6DOF 跟踪结果。

让我们从__init__开始一步一步地介绍方法的实现。

初始化追踪器

__init__方法涉及许多步骤来初始化相机矩阵,ORB 描述符提取器,卡尔曼过滤器,参考图像的 2D 和 3D 关键点以及与我们的跟踪算法相关的其他变量:

  1. 首先,让我们看一下__init__接受的参数。 其中包括一个称为capturecv2.VideoCapture对象(相机); 摄像机的对角 FOV,以度为单位; 每秒帧FPS)中的预期帧速率; 包含参考图像的文件的路径; 以及参考图像实际高度的度量(以任何单位):
class ImageTrackingDemo():

    def __init__(self, capture, diagonal_fov_degrees=70.0,
                 target_fps=25.0,
                 reference_image_path='reference_image.png',
                 reference_image_real_height=1.0):
  1. 我们尝试从相机捕获一帧以确定其像素尺寸。 否则,我们将从相机的属性中获取尺寸:
        self._capture = capture
        success, trial_image = capture.read()
        if success:
            # Use the actual image dimensions.
            h, w = trial_image.shape[:2]
        else:
            # Use the nominal image dimensions.
            w = capture.get(cv2.CAP_PROP_FRAME_WIDTH)
            h = capture.get(cv2.CAP_PROP_FRAME_HEIGHT)
        self._image_size = (w, h)
  1. 现在,给定帧的尺寸(以像素为单位)以及相机和镜头的 FOV,我们可以使用三角函数以像素等效单位计算焦距。 (该公式是我们在本章前面的“了解相机和镜头参数”部分中得出的公式。)此外,利用焦距和镜框的中心点,我们可以构建相机矩阵。 以下是相关代码:
        diagonal_image_size = (w ** 2.0 + h ** 2.0) ** 0.5
        diagonal_fov_radians = \
            diagonal_fov_degrees * math.pi / 180.0
        focal_length = 0.5 * diagonal_image_size / math.tan(
            0.5 * diagonal_fov_radians)
        self._camera_matrix = numpy.array(
            [[focal_length, 0.0, 0.5 * w],
             [0.0, focal_length, 0.5 * h],
             [0.0, 0.0, 1.0]], numpy.float32)
  1. 为了简单起见,我们假定镜头不会遭受任何扭曲:
        self._distortion_coefficients = None
  1. 最初,我们不跟踪对象,因此我们无法估计其旋转和位置。 我们只将相关变量定义为None
        self._rotation_vector = None
        self._translation_vector = None
  1. 现在,让我们设置一个卡尔曼过滤器:
        self._kalman = cv2.KalmanFilter(18, 6)

        self._kalman.processNoiseCov = numpy.identity(
            18, numpy.float32) * 1e-5
        self._kalman.measurementNoiseCov = numpy.identity(
            6, numpy.float32) * 1e-2
        self._kalman.errorCovPost = numpy.identity(
            18, numpy.float32)

        self._kalman.measurementMatrix = numpy.array(
            [[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
            numpy.float32)

        self._init_kalman_transition_matrix(target_fps)

如前面的代码cv2.KalmanFilter(18, 6)所示,该卡尔曼过滤器将基于 6 个输入变量(或测量值)跟踪 18 个输出变量(或预测)。 具体来说,输入变量是 6DOF 跟踪结果的元素:t[x]t[y]t[z]r[x]r[y]r[z]。 输出变量是稳定的 6DOF 跟踪结果的元素,以及它们的一阶导数(速度)和二阶导数(加速度),其顺序如下:t[x]t[y]t[z]t[x]'t[y]'t[z]'t[x]'t[y]'t[z]'r[x]r[y]r[z]r[x]'r[y]'r[z]'r[x]'r[y]'r[z]'。 卡尔曼过滤器的测量矩阵有 18 列(代表输出变量)和 6 行(代表输入变量)。 在每一行中,我们在与匹配的输出变量相对应的索引中放入 1.0; 在其他地方,我们放 0.0。 我们还初始化了一个转换矩阵,该矩阵定义了输出变量之间随时间的关系。 初始化的这一部分由辅助方法_init_kalman_transition_matrix(target_fps)处理,我们将在稍后的“初始化和应用卡尔曼过滤器”部分中进行检查。

并非我们的__init__方法都会初始化所有的卡尔曼过滤器矩阵。 由于实际帧速率(以及时间步长)可能会发生变化,因此在跟踪过程中每帧更新过渡矩阵。 每次我们开始跟踪对象时,都会初始化状态矩阵。 我们将在适当的时候在“初始化和应用卡尔曼过滤器”部分中介绍卡尔曼过滤器使用的这些方面。

  1. 我们需要一个布尔变量(最初是False)来指示我们是否成功跟踪了前一帧中的对象:
        self._was_tracking = False
  1. 我们需要定义一些 3D 图形的顶点,作为 AR 可视化的一部分,我们将绘制每一帧。 具体而言,图形将是代表对象的XYZ轴的一组箭头。 这些图形的比例将与实际对象的比例有关,即我们要跟踪的打印图像。 请记住,作为其参数之一,__init__方法采用图像的比例尺-特别是其高度-并且此度量单位可以是任何单位。 让我们将 3D 轴箭头的长度定义为打印图像高度的一半:
        self._reference_image_real_height = \
            reference_image_real_height
        reference_axis_length = 0.5 * reference_image_real_height
  1. 使用我们刚刚定义的长度,让我们定义相对于打印图像中心[0.0, 0.0, 0.0]的轴箭头的顶点:
        self._reference_axis_points_3D = numpy.array(
            [[0.0, 0.0, 0.0],
             [-reference_axis_length, 0.0, 0.0],
             [0.0, -reference_axis_length, 0.0],
             [0.0, 0.0, -reference_axis_length]], numpy.float32)

请注意,OpenCV 的坐标系具有非标准轴方向,如下所示:

  • X(正 X 方向)是对象的左手方向,或者是在对象正视图中查看者的右手方向。
  • Y是向下。
  • Z是对象的后向方向,即在对象的正面视图中观察者的向前方向。

为了获得以下标准右手坐标系,我们必须取反所有上述方向,就像在许多 3D 图形框架(例如 OpenGL)中使用的那样:

  • X是对象正面的方向,即观看者的左手方向。
  • Y是向上。
  • Z是对象的正面方向,或者是查看者在对象的正面视图中的向后方向。

出于本书的目的,我们使用 OpenCV 绘制 3D 图形,因此即使在绘制可视化效果时,我们也可以简单地遵循 OpenCV 的非标准轴方向。 但是,如果将来要进行进一步的 AR 工作,则可能需要使用右手坐标系将计算机视觉代码与 OpenGL 和其他 3D 图形框架集成在一起。 为了更好地为您做好准备,我们将在以 OpenCV 为中心的演示中转换轴方向。

  1. 我们将使用三个数组来保存三种图像:BGR 视频帧(将在其中进行 AR 绘制),帧的灰度版本(将用于关键点匹配)和遮罩(在其中进行绘制) 被跟踪对象的轮廓)。 最初,这些数组都是None
        self._bgr_image = None
        self._gray_image = None
        self._mask = None
  1. 我们将使用cv2.ORB对象来检测关键点,并为参考图像以及随后的相机帧计算描述符。 我们按以下方式初始化cv2.ORB对象:
        # Create and configure the feature detector.
        patchSize = 31
        self._feature_detector = cv2.ORB_create(
            nfeatures=250, scaleFactor=1.2, nlevels=16,
            edgeThreshold=patchSize, patchSize=patchSize)

有关 ORB 算法及其在 OpenCV 中的用法的更新,请参考第 6 章,“检索图像并使用图像描述符进行搜索”,特别是“将 ORB 与 FAST 特征和 BERIEF 描述符一起使用”部分。

在这里,我们为cv2.ORB的构造器指定了几个可选参数。 描述符覆盖的直径为 31 个像素,我们的图像金字塔有 16 个级别,连续级别之间的缩放系数为 1.2,并且每次检测尝试最多需要 250 个关键点和描述符。

  1. 现在,我们从文件中加载参考图像,调整其大小,将其转换为灰度,并为其创建一个空的遮罩:
        bgr_reference_image = cv2.imread(
            reference_image_path, cv2.IMREAD_COLOR)
        reference_image_h, reference_image_w = \
            bgr_reference_image.shape[:2]
        reference_image_resize_factor = \
            (2.0 * h) / reference_image_h
        bgr_reference_image = cv2.resize(
            bgr_reference_image, (0, 0), None,
            reference_image_resize_factor,
            reference_image_resize_factor, cv2.INTER_CUBIC)
        gray_reference_image = convert_to_gray(bgr_reference_image)
        reference_mask = numpy.empty_like(gray_reference_image)

调整参考图像的大小时,我们选择使其比相机框高两倍。 确切的数字是任意的; 但是,我们的想法是我们要使用覆盖了有用放大倍率的图像金字塔来执行关键点检测和描述。 金字塔的底面(即调整大小后的参考图像)应大于摄像头框架,以便即使目标对象离摄像头非常近,以致无法完全适合框架,我们也可以以适当的比例匹配关键点 。 相反,金字塔的顶层应该小于摄影机框架,这样即使目标物体距离无法填满整个框架,我们也可以以适当的比例匹配关键点。

让我们考虑一个例子。 假设我们的原始参考图像为4000 x 3000像素,而我们的相机帧为4000 x 3000像素。 我们将参考图像的尺寸调整为4000 x 3000像素(帧高度的两倍,并且纵横比与原始参考图像相同)。 因此,我们的图像金字塔的底面也是4000 x 3000像素。 由于我们的cv2.ORB对象配置为使用 16 个金字塔等级且比例因子为 1.2,因此图像金字塔的顶部宽度为1920 / (1.2^(16-1)) = 124像素,高度为1440 / (1.2^(16-1)) = 93像素; 换句话说,它是4000 x 3000像素。 因此,即使物体相距太远,以至于它仅占框架宽度或高度的 10%,我们也可以匹配关键点并跟踪该物体。 实际上,要在此级别上执行有用的关键点匹配,我们需要一个好的镜头,该物体需要聚焦,并且照明也必须很好。

  1. 在此阶段,我们有一个大小适当的 BGR 颜色和灰度参考图像,并且对此图像有一个空遮罩。 我们将图像划分为 36 个大小相等的兴趣区域(在6 x 6网格中),并且对于每个区域,我们将尝试生成多达 250 个关键点和描述符(因为已使用最大数量的关键点和描述符配置cv2.ORB对象)。 这种分区方案有助于确保我们在每个区域中都有一些关键点和描述符,因此即使对象的大多数部分在给定帧中不可见,我们也可以潜在地匹配关键点并跟踪对象。 以下代码块显示了我们如何在兴趣区域上进行迭代,并为每个区域创建掩码,执行关键点检测和描述符提取,以及将关键点和描述符附加到主列表中:
        # Find keypoints and descriptors for multiple segments of
        # the reference image.
        reference_keypoints = []
        self._reference_descriptors = numpy.empty(
            (0, 32), numpy.uint8)
        num_segments_y = 6
        num_segments_x = 6
        for segment_y, segment_x in numpy.ndindex(
                (num_segments_y, num_segments_x)):
            y0 = reference_image_h * \
                segment_y // num_segments_y - patchSize
            x0 = reference_image_w * \
                segment_x // num_segments_x - patchSize
            y1 = reference_image_h * \
                (segment_y + 1) // num_segments_y + patchSize
            x1 = reference_image_w * \
                (segment_x + 1) // num_segments_x + patchSize
            reference_mask.fill(0)
            cv2.rectangle(
                reference_mask, (x0, y0), (x1, y1), 255, cv2.FILLED)
            more_reference_keypoints, more_reference_descriptors = \
                self._feature_detector.detectAndCompute(
                    gray_reference_image, reference_mask)
            if more_reference_descriptors is None:
                # No keypoints were found for this segment.
                continue
            reference_keypoints += more_reference_keypoints
            self._reference_descriptors = numpy.vstack(
                (self._reference_descriptors,
                 more_reference_descriptors))
  1. 现在,我们在灰度参考图像上方绘制关键点的可视化效果:
        cv2.drawKeypoints(
            gray_reference_image, reference_keypoints,
            bgr_reference_image,
            flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
  1. 接下来,我们将可视化文件保存到名称后附加_keypoints的文件中。 例如,如果参考图像的文件名是reference_image.png,则将可视化文件另存为reference_image_keypoints.png。 以下是相关代码:
        ext_i = reference_image_path.rfind('.')
        reference_image_keypoints_path = \
            reference_image_path[:ext_i] + '_keypoints' + \
            reference_image_path[ext_i:]
        cv2.imwrite(
            reference_image_keypoints_path, bgr_reference_image)
  1. 我们继续使用自定义参数初始化基于 FLANN 的匹配器:
        FLANN_INDEX_LSH = 6
        index_params = dict(algorithm=FLANN_INDEX_LSH,
                            table_number=6, key_size=12,
                            multi_probe_level=1)
        search_params = dict()
        self._descriptor_matcher = cv2.FlannBasedMatcher(
            index_params, search_params)

这些参数指定我们正在使用具有 6 个哈希表,12 位哈希键大小和 1 个多探针级别的多探针 LSH(位置敏感哈希)索引算法。

有关多探针 LSH 算法的说明,请参阅论文《多探针 LSH:高维相似性搜索的有效索引》(VLDB,2007 年),由 Qin Lv,William Josephson,Zhe Wang, 摩西·查里卡尔(Moses Charikar)和李凯(Kai Li)。 可在这个页面获得电子版本。

  1. 我们通过向其提供参考描述符来训练匹配器:
        self._descriptor_matcher.add([self._reference_descriptors])
  1. 我们获取关键点的 2D 坐标,并将它们馈送到map_points_to_plane辅助函数中,以便在对象平面的表面上获得等效的 3D 坐标:
        reference_points_2D = [keypoint.pt
                               for keypoint in reference_keypoints]
        self._reference_points_3D = map_points_to_plane(
            reference_points_2D, gray_reference_image.shape[::-1],
            reference_image_real_height)
  1. 类似地,我们调用map_vertices_to_plane函数以获得平面的 3D 顶点和 3D 面:
        (self._reference_vertices_3D,
         self._reference_vertex_indices_by_face) = \
            map_vertices_to_plane(
                    gray_reference_image.shape[::-1],
                    reference_image_real_height)

到此结束__init__方法的实现。 接下来,让我们看一下run方法,它表示应用的主循环。

实现主循环

像往常一样,我们的主循环的主要作用是捕获和处理帧,直到用户按下Esc键。 每个帧的处理(包括 3D 跟踪和 AR 绘制)都委托给称为_track_object的辅助方法,稍后将在《跟踪 3D 图像》部分中进行探讨。 主循环还具有辅助作用:即通过测量帧速率并相应地更新卡尔曼过滤器的转换矩阵来执行计时。 此更新委托给另一种辅助方法_init_kalman_transition_matrix,我们将在“初始化和应用卡尔曼过滤器”部分中进行研究。 考虑到这些角色,我们可以在run方法中实现main循环,如下所示:

    def run(self):

        num_images_captured = 0
        start_time = timeit.default_timer()

        while cv2.waitKey(1) != 27:  # Escape
            success, self._bgr_image = self._capture.read(
                self._bgr_image)
            if success:
                num_images_captured += 1
                self._track_object()
                cv2.imshow('Image Tracking', self._bgr_image)
            delta_time = timeit.default_timer() - start_time
            if delta_time > 0.0:
                fps = num_images_captured / delta_time
                self._init_kalman_transition_matrix(fps)

请注意 Python 标准库中timeit.default_timer函数的使用。 此函数提供了以秒为单位的当前系统时间的精确测量值(作为浮点数,因此可以表示秒的分数)。 就像名称timeit所暗示的那样,此模块包含有用的功能,适用于以下情况:您具有时间敏感的代码,并且想要为其计时

让我们继续进行_track_object的实现,因为此助手代表run执行了应用工作的最大部分。

在 3D 中追踪图像

_track_object方法直接负责关键点匹配,关键点可视化和解决 PnP 问题。 此外,它调用其他方法来处理卡尔曼滤波,AR 绘制和掩盖被跟踪的对象:

  1. 为了开始_track_object的实现,我们调用convert_to_gray辅助函数将帧转换为灰度:
    def _track_object(self):

        self._gray_image = convert_to_gray(
            self._bgr_image, self._gray_image)
  1. 现在,我们使用cv2.ORB对象检测灰度图像的遮罩区域中的关键点并计算描述符:
        if self._mask is None:
            self._mask = numpy.full_like(self._gray_image, 255)

        keypoints, descriptors = \
            self._feature_detector.detectAndCompute(
                self._gray_image, self._mask)

如果我们已经在前一帧中跟踪了对象,则遮罩将覆盖我们先前找到该对象的区域。 否则,遮罩会覆盖整个框架,因为我们不知道对象可能在哪里。 稍后,我们将在“绘制跟踪结果并屏蔽被跟踪的对象”部分中了解如何创建遮罩。

  1. 接下来,我们使用 FLANN 匹配器查找参考图像的关键点与帧的关键点之间的匹配项,并根据比率测试过滤这些匹配项:
        # Find the 2 best matches for each descriptor.
        matches = self._descriptor_matcher.knnMatch(descriptors, 2)

        # Filter the matches based on the distance ratio test.
        good_matches = [
            match[0] for match in matches
            if len(match) > 1 and \
            match[0].distance < 0.6 * match[1].distance
        ]

有关 FLANN 匹配和比率测试的详细信息,请参考第 6 章,“检索图像并使用图像描述符进行搜索”。

  1. 在此阶段,我们列出了通过比率测试的良好匹配项。 让我们选择与这些良好匹配相对应的框架关键点的子集,然后在框架上绘制红色圆圈以可视化这些关键点:
        # Select the good keypoints and draw them in red.
        good_keypoints = [keypoints[match.queryIdx]
                          for match in good_matches]
        cv2.drawKeypoints(self._gray_image, good_keypoints,
                          self._bgr_image, (0, 0, 255))
  1. 找到了不错的比赛之后,我们显然知道其中有多少人。 如果计数很小,那么总的来说,这组匹配项可能会令人怀疑且不足以进行跟踪。 我们为良好匹配的最小数量定义了两个不同的阈值:较高的阈值(如果我们只是开始跟踪(即,我们没有在前一帧中跟踪对象))和较低的阈值(如果我们正在继续跟踪) 跟踪前一帧中的对象):
        min_good_matches_to_start_tracking = 8
        min_good_matches_to_continue_tracking = 6
        num_good_matches = len(good_matches)
  1. 如果我们甚至没有达到下限阈值,那么我们会注意到我们没有在该帧中跟踪对象,因此我们将遮罩重置为覆盖整个帧:
        if num_good_matches < min_good_matches_to_continue_tracking:
            self._was_tracking = False
            self._mask.fill(255)
  1. 另一方面,如果我们有足够的匹配项来满足适用的阈值,那么我们将继续尝试跟踪对象。 第一步是在框架中选择良好匹配的 2D 坐标,并在reference对象的模型中选择其 3D 坐标:
        elif num_good_matches >= \
                min_good_matches_to_start_tracking or \
                    self._was_tracking:

            # Select the 2D coordinates of the good matches.
            # They must be in an array of shape (N, 1, 2).
            good_points_2D = numpy.array(
                [[keypoint.pt] for keypoint in good_keypoints],
                numpy.float32)

            # Select the 3D coordinates of the good matches.
            # They must be in an array of shape (N, 1, 3).
            good_points_3D = numpy.array(
                [[self._reference_points_3D[match.trainIdx]]
                 for match in good_matches],
                numpy.float32)
  1. 现在,我们准备使用本章开头在“了解cv2.solvePnPRansac”部分中介绍的各种参数来调用cv2.solvePnPRansac。 值得注意的是,我们仅从良好匹配中使用 3D 参考关键点和 2D 场景关键点:
            # Solve for the pose and find the inlier indices.
            (success, self._rotation_vector,
             self._translation_vector, inlier_indices) = \
                cv2.solvePnPRansac(good_points_3D, good_points_2D,
                                   self._camera_matrix,
                                   self._distortion_coefficients,
                                   self._rotation_vector,
                                   self._translation_vector,
                                   useExtrinsicGuess=False,
                                   iterationsCount=100,
                                   reprojectionError=8.0,
                                   confidence=0.99,
                                   flags=cv2.SOLVEPNP_ITERATIVE)
  1. 解算器可能收敛或未收敛于 PnP 问题的解决方案。 如果没有收敛,则此方法将不再做任何事情。 如果收敛,则下一步是检查是否已在上一帧中跟踪对象。 如果我们尚未跟踪它(换句话说,如果我们开始在此帧中重新跟踪对象),则可以通过调用辅助方法_init_kalman_state_matrices重新初始化卡尔曼过滤器:
            if success:

                if not self._was_tracking:
                    self._init_kalman_state_matrices()
  1. 现在,无论如何,我们都在该帧中跟踪对象,因此我们可以通过调用另一个辅助方法_apply_kalman来应用卡尔曼过滤器:
                self._was_tracking = True

                self._apply_kalman()
  1. 在这一阶段,我们有一个经过卡尔曼滤波的 6DOF 姿态。 我们还列出了cv2.solvePnPRansac中的内部关键点。 为了帮助用户可视化结果,让我们以绿色绘制内部关键点:
                # Select the inlier keypoints.
                inlier_keypoints = [good_keypoints[i]
                                    for i in inlier_indices.flat]

                # Draw the inlier keypoints in green.
                cv2.drawKeypoints(self._bgr_image, inlier_keypoints,
                                  self._bgr_image, (0, 255, 0))

请记住,在此方法的前面,我们用红色绘制了所有关键点。 现在,我们以绿色绘制了内部关键点,只有外部关键点仍然是红色。

  1. 最后,我们再调用两个辅助方法:self._draw_object轴绘制被跟踪对象的 3D 轴,self._make_and_draw_object_mask绘制并绘制包含对象的区域的遮罩:
                # Draw the axes of the tracked object.
                self._draw_object_axes()

                # Make and draw a mask around the tracked object.
                self._make_and_draw_object_mask()

结束我们的_track_object方法的实现。 到目前为止,我们已经大致了解了跟踪算法的实现,但是我们仍然需要实现与卡尔曼过滤器有关的辅助方法(在下一节“初始化和应用卡尔曼过滤器”中)以及遮罩和 AR 绘制(在其后的“绘制跟踪结果并遮盖跟踪的对象”部分中)。

初始化和应用卡尔曼过滤器

我们在“初始化跟踪器”部分中介绍了卡尔曼过滤器初始化的某些方面。 但是,在该部分中,我们注意到,随着应用运行在各种帧以及跟踪或不跟踪的各种状态下,卡尔曼过滤器的某些矩阵需要多次初始化或重新初始化。 具体来说,以下矩阵将发生变化:

  • 转换矩阵:此矩阵表示所有输出变量之间的时间关系。 例如,该矩阵可以模拟加速度对速度的影响以及速度对位置的影响。 我们将每帧重新初始化转换矩阵,因为帧速率(以及帧之间的时间步长)是可变的。 有效地,这是缩放先前的加速度和速度预测以匹配新时间步长的一种方法。
  • 校正前和校正后状态矩阵:这些矩阵包含输出变量的预测。 预校正矩阵中的预测仅考虑先前状态和转换矩阵。 校正后矩阵中的预测还考虑了新的输入和卡尔曼过滤器的其他矩阵。 每当我们从非跟踪状态变为跟踪状态时,换句话说,当我们无法在前一帧中跟踪对象但现在我们成功地在当前帧中跟踪对象时,我们将重新初始化状态矩阵。 实际上,这是一种清除过时的预测并从新的测量重新开始的方法。

让我们先看一下转换矩阵。 其初始化方法将使用一个参数fps,即每秒帧数。 我们可以通过三个步骤来实现该方法:

  1. 我们首先验证fps参数。 如果不是正数,我们将立即返回而不会更新过渡矩阵:
    def _init_kalman_transition_matrix(self, fps):

        if fps <= 0.0:
            return
  1. 确定fps为正后,我们继续计算速度和加速度的过渡速率。 我们希望速度转换速率与时间步长(即每帧的时间)成比例。 因为fps(每秒帧数)是时间步长的倒数(即每帧秒数),所以速度转换率与fps成反比。 加速度变化率与速度变化率的平方成正比(因此,加速度变化率与fps的平方成反比)。 选择 1.0 作为速度转换率的基本比例,选择 0.5 作为加速度转换率的基本比例,我们可以在代码中进行如下计算:
        # Velocity transition rate
        vel = 1.0 / fps

        # Acceleration transition rate
        acc = 0.5 * (vel ** 2.0)
  1. 接下来,我们填充转换矩阵。 由于我们有 18 个输出变量,因此转换矩阵具有 18 行和 18 列。 首先,让我们看一下矩阵的内容,然后,我们将考虑如何解释它:
        self._kalman.transitionMatrix = numpy.array(
            [[1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]],
            numpy.float32)

每行表示一个公式,用于根据前一帧的输出值来计算新的输出值。 让我们以第一行为例。 我们可以将其解释如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/b78164c4-8d50-4ac4-9845-d37035752c29.png

新的t[x]值取决于旧的t[x]t[x]'t[x]'值,以及速度转换率v和加速度转换率a。 正如我们之前在此函数中看到的那样,这些过渡速率可能会有所变化,因为时间步长可能会有所不同。

到此结束了用于初始化或更新转换矩阵的辅助方法的实现。 请记住,由于帧速率(以及时间步长)可能已更改,因此我们每帧都调用此函数。

我们还需要一个辅助函数来初始化状态矩阵。 请记住,每当我们从非跟踪状态过渡到跟踪状态时,我们都会调用此方法。 此过渡是清除以前所有预测的适当时间; 相反,我们重新开始时就相信对象的 6DOF 姿势正是 PnP 求解器所说的。 此外,我们假设物体是静止的,速度为零,加速度为零。 这是辅助方法的实现:

    def _init_kalman_state_matrices(self):

        t_x, t_y, t_z = self._translation_vector.flat
        r_x, r_y, r_z = self._rotation_vector.flat

        self._kalman.statePre = numpy.array(
            [[t_x], [t_y], [t_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0],
             [r_x], [r_y], [r_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0]], numpy.float32)
        self._kalman.statePost = numpy.array(
            [[t_x], [t_y], [t_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0],
             [r_x], [r_y], [r_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0]], numpy.float32)

注意,由于我们有 18 个输出变量,因此状态矩阵有 1 行和 18 列。

现在我们已经介绍了初始化和重新初始化卡尔曼过滤器的矩阵的过程,让我们看一下如何应用过滤器。 正如我们之前在第 8 章,“跟踪对象”中所看到的,我们可以要求卡尔曼过滤器估计对象的新姿态(输出变量的校正前状态),然后我们可以告诉它考虑最新的不稳定跟踪结果(输入变量)以调整其估计值(从而产生校正后的状态),最后,我们可以从调整后的估计值中提取变量以用作稳定后的跟踪结果。 与我们以前的工作相比,这次的唯一区别是我们有更多的输入和输出变量。 以下代码显示了我们如何实现在 6DOF 跟踪器的上下文中应用卡尔曼过滤器的方法:

    def _apply_kalman(self):

        self._kalman.predict()

        t_x, t_y, t_z = self._translation_vector.flat
        r_x, r_y, r_z = self._rotation_vector.flat

        estimate = self._kalman.correct(numpy.array(
            [[t_x], [t_y], [t_z],
             [r_x], [r_y], [r_z]], numpy.float32))

        self._translation_vector = estimate[0:3]
        self._rotation_vector = estimate[9:12]

这里,请注意,estimate[0:3]对应于t[x]t[y]t[z],而estimate[9:12]对应于r[x]r[y]r[z]estimate数组的其余部分对应于一阶导数(速度)和二阶导数(加速度)。

至此,我们几乎完全探索了 3D 跟踪算法的实现,包括使用卡尔曼过滤器来稳定 6DOF 姿态以及速度和加速度。 现在,让我们将注意力转向ImageTrackingDemo类的两个最终实现细节:AR 绘制方法和基于跟踪结果创建遮罩。

绘制跟踪结果并遮盖被跟踪对象

我们将实现一个辅助方法_draw_object_axes,以绘制跟踪对象的XYZ轴的可视化图像。 我们还将实现另一种辅助方法_make_and_draw_object_mask,以将对象的顶点从 3D 投影到 2D,基于对象的轮廓创建遮罩,并将该遮罩的区域染成黄色以显示。

让我们从_draw_object_axes的实现开始。 我们可以分三个阶段来考虑:

  1. 首先,我们要获取一组沿轴放置的 3D 点,并将这些点投影到 2D 图像空间。 请记住,我们在“初始化跟踪器”部分的__init__方法中定义了 3D 轴点。 它们将仅用作我们将绘制的轴箭头的端点。 使用cv2.projectPoints函数,6DOF 跟踪结果和相机矩阵,我们可以找到 2D 投影点,如下所示:
    def _draw_object_axes(self):

        points_2D, jacobian = cv2.projectPoints(
            self._reference_axis_points_3D, self._rotation_vector,
            self._translation_vector, self._camera_matrix,
            self._distortion_coefficients)

除了返回投影的 2D 点之外,cv2.projectPoints还返回雅可比矩阵,该矩阵表示用于计算 2D 点的函数的偏导数(相对于输入参数)。 此信息可能对相机校准很有用,但在本示例中不使用它。

  1. 投影点采用浮点格式,但是我们需要整数才能传递给 OpenCV 的绘图函数。 因此,我们将以下转换为整数格式:
        origin = (int(points_2D[0, 0, 0]), int(points_2D[0, 0, 1]))
        right = (int(points_2D[1, 0, 0]), int(points_2D[1, 0, 1]))
        up = (int(points_2D[2, 0, 0]), int(points_2D[2, 0, 1]))
        forward = (int(points_2D[3, 0, 0]), int(points_2D[3, 0, 1]))
  1. 在计算了端点之后,我们现在可以绘制三个箭头线来表示 X,Y 和 Z 轴:
        # Draw the X axis in red.
        cv2.arrowedLine(self._bgr_image, origin, right, (0, 0, 255))

        # Draw the Y axis in green.
        cv2.arrowedLine(self._bgr_image, origin, up, (0, 255, 0))

        # Draw the Z axis in blue.
        cv2.arrowedLine(
            self._bgr_image, origin, forward, (255, 0, 0))

我们已经完成了_draw_object_axes的实现。 现在,让我们将注意力转移到_make_and_draw_object_mask上,我们也可以从三个步骤来考虑:

  1. 像以前的函数一样,该函数从将点从 3D 投影到 2D 开始。 这次,我们在“初始化跟踪器”部分的__init__方法中定义了参考对象的顶点。 这是投影代码:
    def _make_and_draw_object_mask(self):

        # Project the object's vertices into the scene.
        vertices_2D, jacobian = cv2.projectPoints(
            self._reference_vertices_3D, self._rotation_vector,
            self._translation_vector, self._camera_matrix,
            self._distortion_coefficients)
  1. 同样,我们将投影点从浮点格式转换为整数格式(因为 OpenCV 的绘图函数需要整数):
        vertices_2D = vertices_2D.astype(numpy.int32)
  1. 投影的顶点形成凸多边形。 我们可以将遮罩涂成黑色(作为背景),然后以白色绘制此凸多边形:
        # Make a mask based on the projected vertices.
        self._mask.fill(0)
        for vertex_indices in \
                self._reference_vertex_indices_by_face:
            cv2.fillConvexPoly(
                self._mask, vertices_2D[vertex_indices], 255)

请记住,我们的_track_object方法在处理下一帧时将使用此掩码。 具体来说,_track_object将仅在遮罩区域中查找关键点。 因此,它将尝试在我们最近找到它的区域中找到该对象。

潜在地,我们可以通过应用形态学扩张操作来扩展遮罩区域来改进此技术。 这样,我们不仅可以在最近找到它的区域中搜索对象,而且可以在周围区域中搜索。

  1. 现在,在 BGR 框架中,让我们以黄色突出显示被遮罩的区域,以可视化被跟踪对象的形状。 为了使区域更黄,我们可以从蓝色通道中减去一个值。 cv2.subtract函数适合我们的目的,因为它接受可选的mask参数。 这是我们的用法:
        # Draw the mask in semi-transparent yellow.
        cv2.subtract(
            self._bgr_image, 48, self._bgr_image, self._mask)

当我们告诉cv2.subtract从图像中减去单个标量值(例如 48)时,它仅从图像的第一个通道(在这种情况下(大多数情况下)是 BGR 图像的蓝色通道)中减去该值。 可以说这是一个错误,但可以方便地将其着色为黄色!

那是ImageTrackingDemo类中的最后一个方法。 现在,让我们通过实例化该类并调用其run方法来使演示栩栩如生!

运行和测试应用

为了完成ImageTrackingDemo.py的实现,让我们编写一个main函数,该函数以指定的捕获设备,FOV 和目标帧速率启动应用:

def main():

    capture = cv2.VideoCapture(0)
    capture.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
    diagonal_fov_degrees = 70.0
    target_fps = 25.0

    demo = ImageTrackingDemo(
        capture, diagonal_fov_degrees, target_fps)
    demo.run()

if __name__ == '__main__':
    main()

在这里,我们使用的捕获分辨率为 1280 x 720,对角 FOV 为 70 度,目标帧速率为 25 FPS。 您应该选择适合您的相机和镜头以及系统速度的参数。

假设我们运行该应用,并从reference_image.png加载以下图像:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/de7a946a-6182-4735-b6af-6da92f3ba45f.png

当然,这是约瑟夫·霍斯(Joseph Howse)所著的《OpenCV 4 for Secret Agents》(Packt Publishing,2019)的封面。 它不仅是秘密知识的库,而且还是图像跟踪的良好目标。 您应该购买印刷本!

在初始化期间,应用将参考关键点的以下可视化保存到名为reference_image_keypoints.png的新文件中:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/ec7a39dc-9e79-4bce-a24d-ece3ca277e6d.png

在第 6 章,“检索图像和使用图像描述符进行搜索”之前,我们已经看到了这种类型的可视化。 大圆圈表示可以在小范围内匹配的关键点(例如,当我们从远距离或使用低分辨率相机查看打印的图像时)。 小圆圈代表可以大规模匹配的关键点(例如,当我们近距离观看打印的图像或使用高分辨率相机时)。 最好的关键点是许多同心圆标记的,因为它们可以在不同的比例下匹配。 在每个圆圈内,径向线表示关键点的法线方向。

通过研究此可视化,我们可以推断出该图像的最佳关键点集中在图像顶部的高对比度文本(白色对深灰色)中。 在许多区域中还可以找到其他有用的关键点,包括图像底部的高对比度线(黑与饱和色)。

接下来,我们看到一个相机供稿。 将参考图像打印在相机前面时,我们会看到跟踪结果的 AR 可视化效果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/617090ff-841d-45e9-864b-f06adfbf1017.png

当然,前面的屏幕快照显示了书封面的近正面视图。 轴方向按预期绘制。 X 轴(红色)指向书套的右侧(查看者的左侧)。 Y 轴(绿色)指向上方。 Z 轴(蓝色)从书的封面指向前方(朝着查看者)。 作为增强现实效果,在跟踪的书的封面(包括由 Joseph Howse 的食指和中指覆盖的部分)上叠加了半透明的黄色高光。 绿色和红色小点的位置表明,在此帧中,良好的关键点匹配集中在书名的区域中,而这些良好的匹配中的大多数都是cv2.solvePnPRansac的整数。

如果您正在阅读本书的印刷版,则屏幕截图将以灰度复制。 为了使 X,Y 和 Z 轴在灰度打印中更容易区分,已将文本标签手动添加到屏幕截图中。 这些文本标签不属于程序输出的一部分。

因为我们努力在整个图像的多个区域中找到良好的关键点,所以即使被跟踪图像的很大一部分处于阴影,被遮盖或在框架外时,跟踪也可以成功。 例如,在下面的屏幕截图中,即使大部分书的封面(包括几乎所有具有最佳关键点的书名)都在框架之外,轴方向和突出显示的区域也是正确的:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/13ce7249-4049-43e4-a447-959288040e24.png

继续并使用各种参考图像,照相机和观看条件进行自己的实验。 为参考图像和相机尝试各种分辨率。 切记要测量相机的 FOV 并相应地调整 FOV 参数。 研究关键点的可视化效果和跟踪结果。 在我们的演示中,哪种输入产生良好(或不良)的跟踪结果?

如果您发现使用打印的图像进行跟踪不方便,则可以将相机对准要显示要跟踪的图像的屏幕(例如智能手机屏幕)。 由于屏幕是背光的(也可能是光滑的),因此它可能无法忠实地表示打印图像在任何给定场景中的外观,但通常可以很好地用于跟踪器的目的。

对心脏的内容进行实验后,让我们考虑一些 3D 跟踪器可以改进的方法。

改进 3D 跟踪算法

本质上,我们的 3D 跟踪算法结合了三种方法:

  1. 使用 PnP 求解器查找 6DOF 姿势,该姿势的输入取决于基于 FLANN 的 ORB 描述符匹配。
  2. 使用卡尔曼过滤器来稳定 6DOF 跟踪结果。
  3. 如果在前一帧中跟踪到对象,请使用遮罩将搜索限制到现在最有可能找到该对象的区域。

3D 跟踪的商业解决方案通常涉及其他方法。 我们依靠成功地为每个帧使用描述符匹配器和 PnP 解算器。 但是,更复杂的算法可能会提供一些替代方案,如后备或交叉检查机制。 这是因为描述符匹配器和 PnP 求解器在某些帧中错过了对象,或者它们在计算上过于昂贵而无法用于每个帧。 广泛使用以下替代方法:

  • 根据光流更新先前的关键点匹配,并根据关键点的旧位置和新位置之间的单应性更新前一个 6DOF 姿势(根据光流)。
  • 根据陀螺仪和磁力计(罗盘)更新 6DOF 姿势的旋转分量。 通常,即使在消费类设备中,这些传感器也可以成功地测量旋转的大小变化。
  • 根据气压计和 GPS 更新 6DOF 姿态的位置分量。 通常,在消费类设备中,气压计可以以大约 10cm 的精度测量高度变化,而 GPS 可以以大约 10m 的精度测量经度和纬度的变化。 根据使用情况,这些精度可能是有用的,也可能不是。 如果我们试图在大而远的景观特征上进行增强现实(例如),如果我们想绘制一条栖息在真实山顶上的虚拟巨龙,那么 10m 的精度可能会更好。 对于详细的工作(例如,如果我们想在真实的手指上画一个虚拟的戒指),则无法使用 10 厘米的精度。
  • 根据加速度计更新卡尔曼过滤器的位置加速度分量。 通常,在消费类设备中,加速度计会产生漂移(误差会在一个方向或另一个方向上显示失控的趋势),因此应谨慎使用此选项。

这些替代技术不在本书的讨论范围之内,实际上,其中一些不是计算机视觉技术,因此我们将其留给您进行独立研究。

最后一句话:有时,通过更改预处理算法而不是跟踪算法本身,可以显着改善跟踪结果。 在本章前面的“执行灰度转换”部分中,我们提到了 Macêdo,Melo 和 Kelner 关于灰度转换算法和 SIFT 描述符的论文。 您可能希望阅读该论文并进行自己的实验,以确定在使用 ORB 描述符或其他类型的描述符时,灰度转换算法的选择如何影响跟踪内线的数量。

总结

本章介绍了 AR,以及一组针对 3D 空间中图像跟踪问题的可靠方法。

我们首先学习了 6DOF 跟踪的概念。 我们认识到,熟悉的工具(例如 ORB 描述符,基于 FLANN 的匹配和卡尔曼滤波)在这种跟踪中很有用,但是我们还需要使用相机和镜头参数来解决 PnP 问题。

接下来,我们讨论了如何以灰度图像,一组 2D 关键点和一组 3D 关键点的形式最好地表示参考对象(例如书的封面或照片)的实际考虑。

我们着手实现了一个类,该类封装了 3D 空间中的图像跟踪演示,并以 3D 高亮效果作为 AR 的基本形式。 我们的实现涉及实时考虑,例如需要根据帧速率的波动来更新卡尔曼过滤器的转换矩阵。

最后,我们考虑了使用其他计算机视觉技术或其他基于传感器的技术来潜在改善 3D 跟踪算法的方法。

现在,我们正在接近本书的最后一章,该章对到目前为止我们已经解决的许多问题提供了不同的观点。 我们可以暂时搁置相机和几何学的思想,而开始以统计学家的身份思考,因为我们将通过研究人工神经网络ANN)。

十、使用 OpenCV 的神经网络简介

本章介绍了一系列称为人工神经网络ANNs)或有时仅称为神经网络的机器学习模型。 这些模型的主要特征是它们试图以多层的方式学习变量之间的关系。 在将这些结果合并为一个函数以预测有意义的内容(例如对象的类别)之前,他们学习了多种特征来预测中间结果。 OpenCV 的最新版本包含越来越多的与 ANN 相关的功能-尤其是具有多层的 ANN,称为深度神经网络DNN)。 在本章中,我们将对较浅的 ANN 和 DNN 进行试验。

在其他各章中,我们已经对机器学习有所了解,尤其是在第 7 章,“构建自定义对象检测器”中,我们使用 SURF 描述符开发了汽车/非汽车分类器, BoW 和一个 SVM。 以此为基础进行比较,您可能会想知道,人工神经网络有什么特别之处? 我们为什么将本书的最后一章专门介绍给他们?

人工神经网络旨在在以下情况下提供卓越的准确率:

  • 输入变量很多,它们之间可能具有复杂的非线性关系。
  • 有许多输出变量,这些变量可能与输入变量具有复杂的非线性关系。 (通常,分类问题中的输出变量是类的置信度得分,因此,如果有很多类,那么会有很多输出变量。)
  • 有许多隐藏的(未指定)变量可能与输入和输出变量具有复杂的非线性关系。 DNN 甚至旨在建模多个隐变量层,这些隐层主要彼此相关,而不是主要与输入或输出变量相关。

这些情况存在于许多(也许是大多数)现实世界中的问题中。 因此,人工神经网络和 DNN 的预期优势是诱人的。 另一方面,众所周知,人工神经网络(尤其是 DNN)是不透明的模型,因为它们通过预测是否存在可能与其他所有事物有关的任意数量的无名,隐藏变量而起作用。

在本章中,我们将涵盖以下主题:

  • 将人工神经网络理解为统计模型和有监督的机器学习工具。
  • 了解 ANN 拓扑,或者将 ANN 组织到相互连接的神经元层中。 特别地,我们将考虑使 ANN 能够用作一种分类器的拓扑,称为多层感知器MLP)。
  • 在 OpenCV 中训练和使用人工神经网络作为分类器。
  • 生成检测和识别手写数字(0 到 9)的应用。 为此,我们将基于被广泛使用的称为 MNIST 的数据集训练 ANN,该数据集包含手写数字的样本。
  • 在 OpenCV 中加载和使用经过预训练的 DNN。 我们将介绍 DNN 的对象分类,人脸检测和性别分类的示例。

到本章结束时,您将很容易在 OpenCV 中训练和使用 ANN,可以使用来自各种来源的经过预先训练的 DNN,并可以开始探索其他可用来训练自己的 DNN 的库。

技术要求

本章使用 Python,OpenCV 和 NumPy。 有关安装说明,请参阅第 1 章,“设置 OpenCV”的

本章的完整代码和示例视频可以在本书的 GitHub 存储库中找到,位于chapter10文件夹中。

了解人工神经网络

让我们根据其基本角色和组成部分来定义 ANN。 尽管有关人工神经网络的许多文献都强调它们是通过神经元在大脑中的连接方式受到生物学启发,但我们并不需要是生物学家或神经科学家来了解人工神经网络的基本概念。

首先,人工神经网络是统计模型。 什么是统计模型? 统计模型是一对元素,即空间S(一组观察值)和概率P,其中P是近似于S的分布(换句话说,一个函数,它生成一组与S非常相似的观察结果。

这是思考P的两种不同方法:

  • P是复杂场景的简化。
  • P是首先生成S或至少与S非常相似的一组观察结果的函数。

因此,人工神经网络是一个模型,它采用一个复杂的现实,对其进行简化,并推导一个函数以(近似)以数学形式表示我们期望从该现实中获得的统计观察结果。

与其他类型的机器学习模型一样,人工神经网络可以通过以下方式之一从观察中学习:

  • 监督学习:在这种方法下,我们希望模型的训练过程产生一个函数,该函数将一组已知的输入变量映射到一组已知的输出变量。 我们知道,先验是预测问题的性质,我们将找到解决该问题的函数的过程委托给了 ANN。 要训​​练模型,我们必须提供输入样本以及正确的相应输出。 对于分类问题,输出变量可以是一个或多个类别的置信度得分。
  • 无监督学习:在这种方法下,先验不知道输出变量的集合。 模型的训练过程必须产生一组输出变量,以及将输入变量映射到这些输出变量的函数。 对于分类问题,无监督学习可能导致发现先前未知的类别,例如医学数据中的先前未知的疾病。 无监督学习可以使用包括(但不限于)聚类的技术,我们在第 7 章,“构建自定义对象检测器”的 BoW 模型的上下文中对此进行了探讨。
  • 强化学习:这种方法可以颠倒典型的预测问题。 在训练模型之前,我们已经有一个系统,当我们为一组已知的输入变量输入值时,该系统会为一组已知的输出变量产生值。 我们知道,先验是一种基于输出的优劣(合意性)或缺乏而对输出序列进行评分的方法。 但是,我们可能不知道将输入映射到输出的实际函数,或者,即使我们知道它,也是如此复杂,以至于无法为最佳输入求解。 因此,我们希望模型的训练过程能够产生一个函数,该函数根据最后的输出来预测序列中的下一个最优输入。 在训练过程中,模型从分数中学习,该分数最终是由其动作(所选输入)产生的。 从本质上讲,该模型必须学会在特定的奖惩系统中成为优秀的决策者。

在本章的其余部分中,我们将讨论仅限于监督学习,因为这是在计算机视觉环境下进行机器学习的最常用方法。

理解 ANN 的下一步是了解 ANN 如何在简单的统计模型和其他类型的机器学习方面进行改进。

如果生成数据集的函数可能需要大量(未知)输入怎么办?

人工神经网络采用的策略是将工作委托给多个神经元节点单元,每个单元都可以近似于创建神经元的功能。 输入。 在数学中,逼近是定义一个更简单的函数的过程,至少对于某些输入范围,其输出类似于更复杂的函数的输出。

近似函数的输出与原始函数的输出之间的差异称为误差。 神经网络的定义特征是神经元必须能够逼近非线性函数。

让我们仔细看看神经元。

了解神经元和感知器

通常,为了解决分类问题,将 ANN 设计为多层感知器MLP),其中每个神经元都充当一种称为感知器的二分类器。 感知器的概念可以追溯到 1950 年代。 简而言之,感知器是一种需要大量输入并产生单个值的函数。 每个输入具有关联的权重,该权重表示其在激活函数中的重要性。 激活函数应具有非线性响应; 例如,Sigmoid 函数(有时称为 S 曲线)是常见的选择。 将阈值函数判别式应用于激活函数的输出,以将其转换为 0 或 1 的二分类。这是此序列的可视化图,左边是输入,激活函数在中间,右边是阈值函数:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/46e3e3ef-46bf-4f05-b4d9-8316b98fea3f.png

输入权重代表什么,如何确定?

在一个神经元的输出可以作为许多其他神经元的输入的范围内,神经元是相互关联的。 每个输入权重定义了两个神经元之间连接的强度。 这些权重是自适应的,这意味着它们会根据学习算法随时间变化。

由于神经元的互连性,网络具有层次。 现在,让我们检查一下通常如何组织这些层。

了解神经网络的各层

这是神经网络的直观表示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/e0bfde8f-89b0-484d-ae27-2b847fcdcb99.png

如上图所示,神经网络中至少有三个不同的层:输入层隐藏层输出层。 可以有多个隐藏层。 但是,一个隐藏层足以解决许多现实生活中的问题。 具有多个隐藏层的神经网络有时称为深度神经网络DNN)。

如果我们将 ANN 用作分类器,则每个输出节点的输出值是一个类的置信度得分。 对于给定的样本(即给定的一组输入值),我们想知道哪个输出节点产生最高的输出值。 该得分最高的输出节点对应于预测的类别。

我们如何确定网络的拓扑结构,以及我们需要为每个层创建多少个神经元? 让我们逐层进行此确定。

选择输入层的大小

根据定义,输入层中的节点数是网络的输入数。 例如,假设您要创建一个人工神经网络,以帮助您根据动物的物理属性确定动物的种类。 原则上,我们可以选择任何可测量的属性。 如果我们选择根据重量,长度和牙齿数量对动物进行分类,那就是三个属性的集合,因此我们的网络需要包含三个输入节点。

这三个输入节点是否是物种分类的充分基础? 好吧,对于现实生活中的问题,当然不是-但是在玩具问题中,这取决于我们试图实现的输出,这是我们接下来要考虑的问题。

选择输出层的大小

对于分类器,根据定义,输出层中的节点数就是网络可以区分的分类数。 继续前面的动物分类网络示例,如果我们知道要处理以下动物,则可以使用四个节点的输出层:狗,秃鹰,海豚和龙(!)。 如果我们尝试对不在这些类别之一中的动物的数据进行分类,则网络将预测最有可能与这种无代表性动物相似的类别。

现在,我们遇到了一个困难的问题-隐藏层的大小。

选择隐藏层的大小

选择隐藏层的大小没有公认的经验法则。 必须根据实验进行选择。 对于要在其上应用 ANN 的每个实际问题,都需要对 ANN 进行训练,测试和重新训练,直到找到许多可以接受的准确率的隐藏节点。

当然,即使通过实验选择参数值,您也可能希望专家为您的测试建议一个起始值或一系列值。 不幸的是,在这些方面也没有专家共识。 一些专家根据以下广泛建议提供经验法则(这些建议应加盐):

  • 如果输入层很大,则隐藏神经元的数量应在输入层的大小和输出层的大小之间,并且通常应更接近输出层的大小。
  • 另一方面,如果输入和输出层都较小,则隐藏层应为最大层。
  • 如果输入层较小,但输出层较大,则隐藏层应更接近输入层的大小。

其他专家建议,还应考虑训练样本的数量; 大量的训练样本意味着更多的隐藏节点可能有用。

要记住的一个关键因素是过拟合。 与训练数据实际提供的信息相比,当隐藏层中包含如此大量的伪信息时,就会发生过拟合,因此分类不太有意义。 隐藏层越大,为了正确学习而需要的训练数据就越多。 当然,随着训练数据集的大小增加,训练时间也会增加。

对于本章中的一些 ANN 示例项目,我们将使用 60 的隐藏层大小作为起点。 给定一个庞大的训练集,对于各种分类问题,60 个隐藏节点可以产生不错的准确率。

现在,我们对什么是人工神经网络有了一个大致的了解,让我们看看 OpenCV 如何实现它们,以及如何充分利用它们。 我们将从一个最小的代码示例开始。 然后,我们将充实我们在前两节中讨论的以动物为主题的分类器。 最后,我们将努力开发更现实的应用,在该应用中,我们将基于图像数据对手写数字进行分类。

在 OpenCV 中训练基本的 ANN

OpenCV 提供了cv2.ml_ANN_MLP类,该类将 ANN 实现为多层感知器MLP)。 这正是我们之前在“了解神经元和感知器”部分中描述的模型。

要创建cv2.ml_ANN_MLP的实例并为该 ANN 的训练和使用格式化数据,我们依赖于 OpenCV 的机器学习模块cv2.ml中的功能。 您可能还记得过,这与我们在第 7 章,“构建自定义对象检测器”中用于 SVM 相关功能的模块相同。 此外,cv2.ml_ANN_MLPcv2.ml_SVM共享一个称为cv2.ml_StatModel的公共基类。 因此,您会发现 OpenCV 为 ANN 和 SVM 提供了类似的 API。

让我们来看一个虚拟的例子,作为对 ANN 的简要介绍。 该示例将使用完全无意义的数据,但它将向我们展示用于在 OpenCV 中训练和使用 ANN 的基本 API:

  1. 首先,我们照常导入 OpenCV 和 NumPy:
import cv2
import numpy as np
  1. 现在,我们创建一个未经训练的人工神经网络:
ann = cv2.ml.ANN_MLP_create()
  1. 创建 ANN 后,我们需要配置其层数和节点数:
ann.setLayerSizes(np.array([9, 15, 9], np.uint8))

层大小由传递给setLayerSizes方法的 NumPy 数组定义。 第一个元素是输入层的大小,最后一个元素是输出层的大小,所有中间元素定义隐藏层的大小。 例如,[9, 15, 9]指定 9 个输入节点,9 个输出节点以及具有 15 个节点的单个隐藏层。 如果将其更改为[9, 15, 13, 9],它将指定两个分别具有 15 和 13 个节点的隐藏层。

  1. 我们还可以配置激活函数,训练方法和训练终止标准,如下所示:
ann.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0)
ann.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1)
ann.setTermCriteria(
    (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS, 100, 1.0))

在这里,我们使用对称的 Sigmoid 激活函数(cv2.ml.ANN_MLP_SIGMOID_SYM)和反向传播训练方法(cv2.ml.ANN_MLP_BACKPROP)。 反向传播是一种算法,用于计算输出层的预测误差,从先前的层向后追溯误差的来源,并更新权重以减少误差。

  1. 让我们训练 ANN。 我们需要指定训练输入(或 OpenCV 术语中的samples),相应的正确输出(或responses),以及数据的格式(或layout)是每个样本一行还是每个样本一行。 这是一个如何使用单个样本训练模型的示例:
training_samples = np.array(
    [[1.2, 1.3, 1.9, 2.2, 2.3, 2.9, 3.0, 3.2, 3.3]], np.float32)
layout = cv2.ml.ROW_SAMPLE
training_responses = np.array(
    [[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]], np.float32)
data = cv2.ml.TrainData_create(
    training_samples, layout, training_responses)
ann.train(data)

实际上,我们希望使用包含一个以上样本的更大数据集来训练任何 ANN。 我们可以通过扩展training_samplestraining_responses使其包含多个行来表示多个样本及其相应的响应,从而做到这一点。 或者,我们可以多次调用 ANN 的train方法,每次都使用新数据。 后一种方法需要train方法使用一些其他参数,下一节“在多个周期中训练 ANN 分类器”将对此进行演示。

请注意,在这种情况下,我们正在训练 ANN 作为分类器。 每个响应都是一个类的置信度得分,在这种情况下,有 9 个类。 我们将通过基于 0 的索引将它们称为 0 到 8 类。在这种情况下,我们的训练样本的响应为[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],这意味着它是 5 类的实例(置信度 1.0),并且它绝对不是任何其他类的实例(因为其他所有类的置信度为 0.0)。

  1. 为了完成对 ANN API 的最小介绍,让我们制作另一个示例,对其进行分类并打印结果:
test_samples = np.array(
    [[1.4, 1.5, 1.2, 2.0, 2.5, 2.8, 3.0, 3.1, 3.8]], np.float32)
prediction = ann.predict(test_samples)
print(prediction)

这将打印以下结果:

(5.0, array([[-0.08763029, -0.01616517, 0.13196233, 0.0402631 , 0.05711843,
         1.1642447 , 0.18130444, 0.1857026 , -0.07486832]],
      dtype=float32))

这意味着所提供的输入被归类为第 5 类。再次,这只是一个虚拟示例,该分类是毫无意义的。 但是,网络行为正常。 在前面的代码中,我们仅提供了一个训练记录,该训练记录是第 5 类的样本,因此网络将新输入归为第 5 类。(据我们有限的训练数据集显示,除 5 以外的其他类可能永远不会发生。)

您可能已经猜到了,预测的输出是一个元组,第一个值是类,第二个值是包含每个类的概率的数组。 预测的类别将具有最高的值。

让我们继续一个更可信的例子-动物分类。

在多个周期中训练 ANN 分类器

让我们创建一个 ANN,尝试根据三种度量对动物进行分类:体重,长度和牙齿数量。 当然,这是一个模拟场景。 实际上,没有人会只用这三个统计数据来描述动物。 但是,我们的目的是在将 ANN 应用于图像数据之前,加深对 ANN 的理解。

与上一节中的最小示例相比,我们的动物分类模型将通过以下方式更加复杂:

  • 我们将增加隐藏层中神经元的数量。
  • 我们将使用更大的训练数据集。 为方便起见,我们将随机生成此数据集。
  • 我们将在多个周期训练 ANN,这意味着我们将使用相同的数据集每次对其进行多次训练和重新训练。

隐藏层中神经元的数量是重要的参数,需要进行测试才能优化任何 ANN 的准确率。 您会发现,较大的隐藏层可以在一定程度上提高准确率,然后过拟合,除非您开始使用庞大的训练数据集进行补偿。 同样,在一定程度上,更多的周期可能会提高准确率,但过多的周期会导致过拟合。

让我们逐步执行一下实现:

  1. 首先,我们照例导入 OpenCV 和 NumPy。 然后,从 Python 标准库中,导入randint函数以生成伪随机整数,并导入uniform函数以生成伪随机浮点数:
import cv2
import numpy as np
from random import randint, uniform
  1. 接下来,我们创建并配置 ANN。 这次,我们使用三个神经元输入层,一个 50 神经元隐藏层和一个四个神经元输出层,如以下代码中以粗体突出显示:
animals_net = cv2.ml.ANN_MLP_create()
animals_net.setLayerSizes(np.array([3, 50, 4]))
animals_net.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0)
animals_net.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1)
animals_net.setTermCriteria(
    (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS, 100, 1.0))
  1. 现在,我们需要一些数据。 我们对准确地代表动物并不感兴趣。 我们只需要一堆记录作为训练数据即可。 因此,我们定义四个函数以生成不同类别的随机样本,另外定义四个函数以生成正确的分类结果以进行训练:
"""Input arrays
weight, length, teeth
"""

"""Output arrays
dog, condor, dolphin, dragon
"""

def dog_sample():
    return [uniform(10.0, 20.0), uniform(1.0, 1.5),
        randint(38, 42)]

def dog_class():
    return [1, 0, 0, 0]

def condor_sample():
    return [uniform(3.0, 10.0), randint(3.0, 5.0), 0]

def condor_class():
    return [0, 1, 0, 0]

def dolphin_sample():
    return [uniform(30.0, 190.0), uniform(5.0, 15.0), 
        randint(80, 100)]

def dolphin_class():
    return [0, 0, 1, 0]

def dragon_sample():
    return [uniform(1200.0, 1800.0), uniform(30.0, 40.0), 
        randint(160, 180)]

def dragon_class():
    return [0, 0, 0, 1]
  1. 我们还定义了以下辅助函数,以便将样本和分类转换为一对 NumPy 数组:
def record(sample, classification):
    return (np.array([sample], np.float32),
            np.array([classification], np.float32))
  1. 让我们继续创建假动物数据。 我们将为每个类创建 20,000 个样本:
RECORDS = 20000
records = []
for x in range(0, RECORDS):
    records.append(record(dog_sample(), dog_class()))
    records.append(record(condor_sample(), condor_class()))
    records.append(record(dolphin_sample(), dolphin_class()))
    records.append(record(dragon_sample(), dragon_class()))
  1. 现在,让我们训练 ANN。 正如我们在本节开头所讨论的,我们将使用多个训练周期。 每个周期都是循环的迭代,如以下代码所示:
EPOCHS = 10
for e in range(0, EPOCHS):
    print("epoch: %d" % e)
    for t, c in records:
        data = cv2.ml.TrainData_create(t, cv2.ml.ROW_SAMPLE, c)
        if animals_net.isTrained():
            animals_net.train(data, cv2.ml.ANN_MLP_UPDATE_WEIGHTS | cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)
        else:
            animals_net.train(data, cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)

对于具有庞大且多样化的训练数据集的实际问题,ANN 可能会受益于数百个训练周期。 为了获得最佳结果,您可能希望继续训练和测试 ANN,直到达到收敛为止,这意味着进一步的周期将不再对结果的准确率产生明显的改善。

请注意,我们必须将cv2.ml.ANN_MLP_UPDATE_WEIGHTS标志传递给 ANN 的train函数,以更新以前训练的模型,而不是从头开始训练新的模型。 这是每当您逐步训练模型时都必须记住的关键点,就像我们在这里所做的那样。

  1. 训练完我们的人工神经网络后,我们应该进行测试。 对于每个类别,让我们生成 100 个新的随机样本,使用 ANN 对其进行分类,并跟踪正确分类的数量:
TESTS = 100

dog_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([dog_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 0:
        dog_results += 1

condor_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([condor_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 1:
        condor_results += 1

dolphin_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([dolphin_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 2:
        dolphin_results += 1

dragon_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([dragon_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 3:
        dragon_results += 1
  1. 最后,让我们打印准确率统计信息:
print("dog accuracy: %.2f%%" % (100.0 * dog_results / TESTS))
print("condor accuracy: %.2f%%" % (100.0 * condor_results / TESTS))
print("dolphin accuracy: %.2f%%" % \
    (100.0 * dolphin_results / TESTS))
print("dragon accuracy: %.2f%%" % (100.0 * dragon_results / TESTS))

当我们运行脚本时,前面的代码块应产生以下输出:

dog accuracy: 100.00%
condor accuracy: 100.00%
dolphin accuracy: 100.00%
dragon accuracy: 100.00%

由于我们正在处理随机数据,因此每次您运行脚本时,结果可能会有所不同。 通常,由于我们已经建立了一个简单的分类问题,即输入数据的范围不重叠,因此准确率应该很高甚至是完美的。 (狗的随机权重值的范围与龙的范围不重叠,依此类推。)

您可能需要花一些时间来尝试以下修改(一次进行一次),以便了解 ANN 的准确率如何受到影响:

  • 通过修改RECORDS变量的值来更改训练样本的数量。
  • 通过修改EPOCHS变量的值来更改训练周期的数量。
  • 通过在dog_samplecondor_sampledolphin_sampledragon_sample函数中编辑uniformrandint函数调用的参数,使输入数据的范围部分重叠。

准备就绪后,我们将继续一个包含真实图像数据的示例。 这样,我们将训练 ANN 来识别手写数字。

用人工神经网络识别手写数字

手写数字是 10 个阿拉伯数字(0 到 9)中的任何一个,用笔或铅笔手动书写,而不是用机器打印。 手写数字的外观可能会有很大差异。 不同的人有不同的笔迹,并且-一个熟练的书法家可能会例外-一个人每次书写都不会产生相同的数字。 这种可变性意味着手写数字的视觉识别对于机器学习来说是一个不小的问题。 确实,机器学习的学生和研究人员经常通过尝试训练手写数字的准确识别器来测试他们的技能和新算法。 我们将通过以下方式应对这一挑战:

  1. 从 MNIST 数据库的 Python 友好版本加载数据。 这是一个广泛使用的数据库,其中包含手写数字的图像。
  2. 使用 MNIST 数据,在多个周期训练 ANN。
  3. 加载一张纸上有许多手写数字的图像。
  4. 基于轮廓分析,检测纸张上的各个数字。
  5. 使用我们的人工神经网络对检测到的数字进行分类。
  6. 查看结果,以确定我们的探测器和基于 ANN 的分类器的准确率。

在深入研究实现之前,让我们回顾一下有关 MNIST 数据库的信息。

了解 MNIST 手写数字数据库

可在这个页面上公开获得 MNIST 数据库(或美国国家标准混合技术研究院数据库)。该数据库包括一个包含 60,000 个手写数字图像的训练集。 其中一半是由美国人口普查局的雇员撰写的,而另一半是由美国的高中生撰写的。

该数据库还包括从同一作者那里收集的 10,000 张图像的测试集。 所有训练和测试图像均为灰度格式,尺寸为28 x 28像素。 在黑色背景上,数字为白色(或灰色阴影)。 例如,以下是 MNIST 训练样本中的三个:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/aafb1f29-c71f-488d-8696-2307830f5cea.png

作为使用 MNIST 的替代方法,您当然可以自己构建一个类似的数据库。 这将涉及收集大量手写数字的图像,将图像转换为灰度图像,对其进行裁剪以使每个图像在标准化位置均包含一个数字,然后缩放图像以使它们都具有相同的大小。 您还需要标记图像,以便程序可以读取正确的分类,以训练和测试分类器。

许多作者提供了有关如何将 MNIST 数据库与各种机器学习库和算法结合使用的示例-不仅是 OpenCV,还不仅仅是 ANN。 免费在线书籍《神经网络和深度学习》的作者 Michael Nielsen 在这里为 MNIST 和 ANN 专门撰写了一章。 他展示了如何仅使用 NumPy 几乎从头开始实现 ANN,如果您想加深对 OpenCV 公开的高级功能的了解,那么这是一本非常好的读物。 他的代码可在 GitHub 上免费获得。

Nielsen 提供了 MNIST 版本,为PKL.GZ(gzip 压缩的 Pickle)文件,可以轻松地将其加载到 Python 中。 出于本书 OpenCV 示例的目的,我们(作者)采用了 Nielsen 的 MNIST 的PKL.GZ版本,为我们的目的对其进行了重组,并将其放置在本书的chapter10/digits_data/mnist.pkl.gz的 GitHub 存储库中。

既然我们已经了解了 MNIST 数据库,那么让我们考虑一下适合该训练集的 ANN 参数。

为 MNIST 数据库选择训练参数

每个 MNIST 样本都是一个包含 784 像素(即28 x 28像素)的图像。 因此,我们的人工神经网络的输入层将具有 784 个节点。 输出层将有 10 个节点,因为有 10 类数字(0 到 9)。

我们可以自由选择其他参数的值,例如隐藏层中的节点数,要使用的训练样本数以及训练周期数。 与往常一样,实验可以帮助我们找到可提供可接受的训练时间和准确率的值,而不会使模型过度适合训练数据。 根据本书作者所做的一些实验,我们将使用 60 个隐藏节点,50,000 个训练样本和 10 个周期。 这些参数足以进行初步测试,将训练时间缩短至几分钟(取决于计算机的处理能力)。

实现训练 ANN 的模块

您也可能希望在未来的项目中基于 MNIST 训练 ANN。 为了使我们的代码更具可重用性,我们可以编写一个专门用于此训练过程的 Python 模块。 然后(在下一节“实现主模块”中),我们将把这个训练模块导入到主模块中,在这里我们将进行数字检测和分类的演示。

让我们在名为digits_ann.py的文件中实现训练模块:

  1. 首先,我们将从 Python 标准库中导入gzippickle模块。 和往常一样,我们还将导入 OpenCV 和 NumPy:
import gzip
import pickle

import cv2
import numpy as np

我们将使用gzippickle模块解压缩并从mnist.pkl.gz文件中加载 MNIST 数据。 我们之前在“了解 MNIST 手写数字数据库”部分中简要提到了此文件。 它包含嵌套元组中的 MNIST 数据,格式如下:

((training_images, training_ids),
 (test_images, test_ids))

反过来,这些元组的元素具有以下格式:

  1. 让我们编写以下帮助函数来解压缩并加载mnist.pkl.gz的内容:
def load_data():
    mnist = gzip.open('./digits_data/mnist.pkl.gz', 'rb')
    training_data, test_data = pickle.load(mnist)
    mnist.close()
    return (training_data, test_data)

注意,在前面的代码中,training_data是一个元组,等效于(training_images, training_ids)test_data也是一个元组,等效于(test_images, test_ids)

  1. 我们必须重新格式化原始数据,以匹配 OpenCV 期望的格式。 具体来说,当我们提供用于训练 ANN 的样本输出时,它必须是具有 10 个元素(用于 10 类数字)的向量,而不是单个数字 ID。 为方便起见,我们还将应用 Python 内置的zip函数以一种可以对匹配的输入和输出向量对(如元组)进行迭代的方式来重组数据。 让我们编写以下辅助函数来重新格式化数据:
def wrap_data():
    tr_d, te_d = load_data()
    training_inputs = tr_d[0]
    training_results = [vectorized_result(y) for y in tr_d[1]]
    training_data = zip(training_inputs, training_results)
    test_data = zip(te_d[0], te_d[1])
    return (training_data, test_data)
  1. 请注意,前面的代码调用load_data和另一个帮助函数vectorized_result。 后者将 ID 转换为分类向量,如下所示:
def vectorized_result(j):
    e = np.zeros((10,), np.float32)
    e[j] = 1.0
    return e

例如,将 ID 1转换为包含值[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0\. 0.0]的 NumPy 数组。 您可能已经猜到,这个由 10 个元素组成的数组对应于 ANN 的输出层,我们在训练 ANN 时可以将其用作正确输出的样本。

先前的函数load_datawrap_datavectorized_result已从 Nielsen 的代码中进行了修改,以加载他的mnist.pkl.gz版本。 有关 Nielsen 的工作的更多信息,请参阅本章的“了解 MNIST 手写数字数据库”部分。

  1. 到目前为止,我们已经编写了用于加载和重新格式化 MNIST 数据的函数。 现在,让我们编写一个函数来创建未经训练的 ANN:
def create_ann(hidden_nodes=60):
    ann = cv2.ml.ANN_MLP_create()
    ann.setLayerSizes(np.array([784, hidden_nodes, 10]))
    ann.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0)
    ann.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1)
    ann.setTermCriteria(
        (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS,
         100, 1.0))
    return ann

请注意,我们已经根据 MNIST 数据的性质对输入和输出层的大小进行了硬编码。 但是,我们允许此函数的调用者指定隐藏层中的节点数。

有关参数的进一步讨论,请参考本章“选择 MNIST 数据库的训练参数”。

  1. 现在,我们需要一个训练函数,允许调用者指定 MNIST 训练样本的数量和周期的数量。 我们以前的 ANN 样本应该熟悉很多训练函数,因此让我们看一下整个实现,然后再讨论一些细节:
def train(ann, samples=50000, epochs=10):

    tr, test = wrap_data()

    # Convert iterator to list so that we can iterate multiple 
    # times in multiple epochs.
    tr = list(tr)

    for epoch in range(epochs):
        print("Completed %d/%d epochs" % (epoch, epochs))
        counter = 0
        for img in tr:
            if (counter > samples):
                break
            if (counter % 1000 == 0):
                print("Epoch %d: Trained on %d/%d samples" % \
                      (epoch, counter, samples))
            counter += 1
            sample, response = img
            data = cv2.ml.TrainData_create(
                np.array([sample], dtype=np.float32),
                cv2.ml.ROW_SAMPLE,
                np.array([response], dtype=np.float32))
            if ann.isTrained():
                ann.train(data, cv2.ml.ANN_MLP_UPDATE_WEIGHTS | cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)
            else:
                ann.train(data, cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)
    print("Completed all epochs!")

    return ann, test

请注意,我们加载数据,然后通过迭代指定数量的训练周期(每个周期中都有指定数量的样本)来递增地训练 ANN。 对于我们处理的每 1,000 个训练样本,我们会打印一条有关训练进度的消息。 最后,我们同时返回经过训练的 ANN 和 MNIST 测试数据。 我们可能刚刚返回了 ANN,但是如果我们想检查 ANN 的准确率,则手头准备测试数据会很有用。

  1. 当然,经过训练的 ANN 的目的是进行预测,因此我们将提供以下predict函数,以便包装 ANN 自己的predict方法:
def predict(ann, sample):
    if sample.shape != (784,):
        if sample.shape != (28, 28):
            sample = cv2.resize(sample, (28, 28),
                                interpolation=cv2.INTER_LINEAR)
        sample = sample.reshape(784,)
    return ann.predict(np.array([sample], dtype=np.float32))

该函数获取训练有素的人工神经网络和样本图像; 它通过确保样本图像为28 x 28并通过调整大小(如果不是)来执行最少的数据清理。 然后,它将图像数据展平为向量,然后再将其提供给 ANN 进行分类。

这就是我们支持演示应用所需的所有与 ANN 相关的函数。 但是,让我们还实现一个test函数,该函数通过对一组给定的测试数据(例如 MNIST 测试数据)进行分类来测量经过训练的 ANN 的准确率。 以下是相关代码:

def test(ann, test_data):
    num_tests = 0
    num_correct = 0
    for img in test_data:
        num_tests += 1
        sample, correct_digit_class = img
        digit_class = predict(ann, sample)[0]
        if digit_class == correct_digit_class:
            num_correct += 1
    print('Accuracy: %.2f%%' % (100.0 * num_correct / num_tests))

现在,让我们走一小段弯路,编写一个利用所有前面的代码和 MNIST 数据集的最小测试。 之后,我们将继续实现演示应用的主要模块。

实现最小的测试模块

让我们创建另一个脚本test_digits_ann.py,以测试digits_ann模块中的功能。 测试脚本非常简单; 这里是:

from digits_ann import create_ann, train, test

ann, test_data = train(create_ann())
test(ann, test_data)

请注意,我们尚未指定隐藏节点的数量,因此create_ann将使用其默认参数值:60 个隐藏节点。 同样,train将使用其默认参数值:50,000 个样本和 10 个周期。

当我们运行此脚本时,它应打印类似于以下内容的训练和测试信息:

Completed 0/10 epochs
Epoch 0: Trained on 0/50000 samples
Epoch 0: Trained on 1000/50000 samples
... [more reports on progress of training] ...
Completed all epochs!
Accuracy: 95.39%

在这里,我们可以看到,对 MNIST 数据集中的 10,000 个测试样本进行分类时,ANN 的准确率达到了 95.39%。 这是一个令人鼓舞的结果,但让我们看一下 ANN 的概括程度。 是否可以对来自与 MNIST 无关的完全不同来源的数据进行准确分类? 我们的主要应用会从我们自己的一张纸的图像中检测数字,这将给分类器带来这种挑战。

实现主要模块

我们的演示程序的主要脚本吸收了本章中有关 ANN 和 MNIST 的所有知识,并将其与我们在前几章中研究的一些对象检测技术相结合。 因此,从很多方面来说,这对我们来说都是一个顶点项目。

让我们在名为detect_and_classify_digits.py的新文件中实现主脚本:

  1. 首先,我们将导入 OpenCV,NumPy 和我们的digits_ann模块:
import cv2
import numpy as np

import digits_ann
  1. 现在,让我们编写一些辅助函数来分析和调整数字和其他轮廓的边界矩形。 如前几章所述,重叠检测是一个常见问题。 以下称为inside的函数将帮助我们确定一个边界矩形是否完全包含在另一个边界矩形内:
def inside(r1, r2):
    x1, y1, w1, h1 = r1
    x2, y2, w2, h2 = r2
    return (x1 > x2) and (y1 > y2) and (x1+w1 < x2+w2) and \
            (y1+h1 < y2+h2)

借助inside函数,我们将能够轻松地为每个数字选择最外面的矩形。 这很重要,因为我们不希望检测器遗漏任何手指的四肢。 这样的检测错误可能使分类器的工作变得不可能。 例如,如果我们仅检测到数字的下半部分 8,则分类器可能会合理地将该区域视为 0。

为了进一步确保边界矩形满足分类器的需求,我们将使用另一个名为wrap_digit的辅助函数,将紧密拟合的边界矩形转换为带有围绕数字填充的正方形。 请记住,MNIST 数据包含28 x 28像素的数字正方形图像,因此在尝试使用 MNIST 训练的 ANN 对其进行分类之前,我们必须将任何兴趣区域重新缩放至此大小。 通过使用填充的边界正方形而不是紧密拟合的边界矩形,我们确保骨感数字(例如 1)和粗体数字(例如 0)不会不同地拉伸。

  1. 让我们看一下wrap_digit的实现。 首先,我们修改矩形的较小尺寸(宽度或高度),使其等于较大尺寸,然后修改矩形的xy位置,以使中心保持不变:
def wrap_digit(rect, img_w, img_h):

    x, y, w, h = rect

    x_center = x + w//2
    y_center = y + h//2
    if (h > w):
        w = h
        x = x_center - (w//2)
    else:
        h = w
        y = y_center - (h//2)
  1. 接下来,我们在所有侧面添加 5 像素填充:
    padding = 5
    x -= padding
    y -= padding
    w += 2 * padding
    h += 2 * padding

在这一点上,我们修改后的矩形可能会延伸到图像外部。

  1. 为了避免超出范围的问题,我们对矩形进行裁剪,使其完全位于图像内。 在这些边缘情况下,这可能会给我们留下非正方形的矩形,但这是可以接受的折衷方案。 我们宁愿使用感兴趣的非正方形区域,而不是仅仅因为它位于图像的边缘而完全抛弃检测到的数字。 这是用于边界检查和裁剪矩形的代码:
    if x < 0:
        x = 0
    elif x > img_w:
        x = img_w

    if y < 0:
        y = 0
    elif y > img_h:
        y = img_h

    if x+w > img_w:
        w = img_w - x

    if y+h > img_h:
        h = img_h - y
  1. 最后,我们返回修改后的矩形的坐标:
    return x, y, w, h

到此结束wrap_digit辅助函数的实现。

  1. 现在,让我们进入程序的主要部分。 在这里,我们首先创建一个 ANN 并在 MNIST 数据上对其进行训练:
ann, test_data = digits_ann.train(
    digits_ann.create_ann(60), 50000, 10)

请注意,我们正在使用digits_ann模块中的create_anntrain函数。 如前所述(“在 MNIST 数据库中选择参数”),我们正在使用 60 个隐藏节点,50,000 个训练样本和 10 个周期。 尽管这些是函数的默认参数值,但无论如何我们还是在这里指定它们,以便以后我们想尝试其他值时更易于查看和修改。*

  1. 现在,让我们在一张白纸上加载一个包含许多手写数字的测试图像:
img_path = "./digit_img/digits_0.jpg"
img = cv2.imread(img_path, cv2.IMREAD_COLOR)

我们使用的是乔·米尼诺(Joe Minichino)手写的以下图像(但是,当然,您可以根据需要替换其他图像):

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/5bac9c25-a8d2-4f07-9238-d7e5998374be.jpg

  1. 让我们将图像转换为灰度并使其模糊,以消除噪点并使墨水的暗度更加均匀:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cv2.GaussianBlur(gray, (7, 7), 0, gray)
  1. 现在我们有了一个平滑的灰度图像,我们可以应用一个阈值和一些形态学操作,以确保数字与背景脱颖而出,并且轮廓相对没有不规则性,这可能会超出预测。 以下是相关代码:
ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)
erode_kernel = np.ones((2, 2), np.uint8)
thresh = cv2.erode(thresh, erode_kernel, thresh, iterations=2)

注意阈值标志cv2.THRESH_BINARY_INV,它是反二进制阈值。 由于 MNIST 数据库中的样本是黑底白字(而不是黑底白字),因此我们将图像转换为带有白色数字的黑色背景。 我们将阈值图像用于检测和分类。

  1. 进行形态学操作后,我们需要分别检测图片中的每个数字。 为此,首先,我们需要找到轮廓:
contours, hier = cv2.findContours(thresh, cv2.RETR_TREE,
                                  cv2.CHAIN_APPROX_SIMPLE)
  1. 然后,我们遍历轮廓并找到其边界矩形。 我们丢弃任何我们认为太大或太小而无法数字化的矩形。 我们还将丢弃完全包含在其他矩形中的所有矩形。 其余的矩形将追加到一个良好的矩形列表中(我们相信),这些矩形包含单个数字。 让我们看下面的代码片段:
rectangles = []

img_h, img_w = img.shape[:2]
img_area = img_w * img_h
for c in contours:

    a = cv2.contourArea(c)
    if a >= 0.98 * img_area or a <= 0.0001 * img_area:
        continue

    r = cv2.boundingRect(c)
    is_inside = False
    for q in rectangles:
        if inside(r, q):
            is_inside = True
            break
    if not is_inside:
        rectangles.append(r)
  1. 现在我们有了一个好的矩形列表,可以遍历它们,使用wrap_digit函数对它们进行清理,并对其中的图像数据进行分类:
for r in rectangles:
    x, y, w, h = wrap_digit(r, img_w, img_h)
    roi = thresh[y:y+h, x:x+w]
    digit_class = int(digits_ann.predict(ann, roi)[0])
  1. 此外,在对每个数字进行分类之后,我们绘制了经过清理的边界矩形和分类结果:
    cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)
    cv2.putText(img, "%d" % digit_class, (x, y-5),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
  1. 处理完所有兴趣区域后,我们将保存阈值图像和带有完整标注的图像,并显示它们,直到用户按下任何键以结束程序为止:
cv2.imwrite("detected_and_classified_digits_thresh.png", thresh)
cv2.imwrite("detected_and_classified_digits.png", img)
cv2.imshow("thresh", thresh)
cv2.imshow("detected and classified digits", img)
cv2.waitKey()

脚本到此结束。 运行它时,我们应该看到阈值图像以及检测和分类结果的可视化。 (最初两个窗口可能重叠,因此您可能需要移动一个窗口才能看到另一个窗口。)这是阈值图像:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/92d5e988-1408-45b1-922e-a5b78068deeb.png

这是结果的可视化:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/e2a4e4cd-f574-49cb-a452-0a70c4d37ab0.png

该图像包含 110 个采样位:从 0 到 9 的一位数字中的 10 位,再加上从 10 到 59 的两位数字中的 100 位。在这 110 个采样中,可以正确检测到 108 个采样的边界,这意味着,探测器的准确率为 98.18%。 然后,在这 108 个正确检测的样本中,对 80 个样本的分类结果是正确的,这意味着 ANN 分类器的准确率为 74.07%。 这比随机分类器要好得多,后者只能在 10% 的时间内正确分类一个数字。

因此,ANN 显然能够学习一般地对手写数字进行分类,而不仅仅是 MNIST 训练和测试数据集中的数字。 让我们考虑一些改善学习的方法。

试图改善人工神经网络的训练

我们可以对训练 ANN 的问题进行一些潜在的改进。 我们已经提到了其中一些潜在的改进,但让我们在这里进行回顾:

  • 您可以尝试训练数据集的大小,隐藏节点的数量和周期的数量,直到找到最高的准确率。
  • 您可以修改digits_ann.create_ann函数,使其支持多个隐藏层。
  • 您也可以尝试其他激活函数。 我们使用了cv2.ml.ANN_MLP_SIGMOID_SYM,但这不是唯一的选择。 其他包括cv2.ml.ANN_MLP_IDENTITYcv2.ml.ANN_MLP_GAUSSIANcv2.ml.ANN_MLP_RELUcv2.ml.ANN_MLP_LEAKYRELU
  • 同样,您可以尝试不同的训练方法。 我们使用了cv2.ml.ANN_MLP_BACKPROP。 其他选项包括cv2.ml.ANN_MLP_RPROPcv2.ml.ANN_MLP_ANNEAL

有关 OpenCV 中与 ANN 相关的参数的更多信息,请访问这个页面上的官方文档。

除了试验参数外,请仔细考虑您的应用需求。 例如,您的分类器将在哪里和由谁使用? 并非每个人都以相同的方式绘制数字。 确实,不同国家的人们倾向于以略有不同的方式得出数字。

MNIST 数据库是在美国编译的,数字 7 与手写字符 7 一样是手写的。但是,在欧洲,数字 7 通常是用数字的对角线部分中间的一条小水平线手写的。 引入此笔划是为了帮助区分手写数字 7 和手写数字 1。

有关区域手写变化的更详细概述,请查看 Wikipedia 上有关该主题的文章,这是一个很好的介绍,可在这个页面上找到。

这种变化意味着在 MNIST 数据库上训练的 ANN 在应用于欧洲手写数字的分类时可能不太准确。 为了避免这样的结果,您可以选择创建自己的训练数据集。 在几乎所有情况下,最好利用属于当前应用域的训练数据。

最后,请记住,一旦对分类器的准确率感到满意,就可以随时将其保存并稍后重新加载,这样它就可以在应用中使用,而不必每次都训练 ANN。

该界面类似于在“保存和加载受过训练的 SVM”部分中看到的接口,该部分接近第 7 章,“构建自定义对象检测器”。 具体来说,您可以使用以下代码将经过训练的 ANN 保存到 XML 文件:

ann = cv2.ml.ANN_MLP_create()
data = cv2.ml.TrainData_create(
    training_samples, layout, training_responses)
ann.train(data)
ann.save('my_ann.xml')

随后,您可以使用如下代码重新加载经过训练的 ANN:

ann = cv2.ml.ANN_MLP_create()
ann.load('my_ann.xml')

既然我们已经学习了如何为手写数字分类创建可重用的 ANN,让我们考虑一下这种分类器的用例。

寻找其他潜在的应用

前面的演示仅是手写识别应用的基础。 您可以轻松地将方法扩展到视频并实时检测手写数字,也可以训练 ANN 识别整个字母,以实现完整的光学字符识别OCR)系统。

汽车牌照的检测和识别将是到目前为止我们所学课程的另一个有用的扩展。 车牌上的字符具有一致的外观(至少在给定的国家/地区内),这应该是问题的 OCR 部分的简化因素。

您也可以尝试将 ANN 应用于以前使用过 SVM 的问题,反之亦然。 这样,您可以看到它们的准确率如何与不同类型的数据进行比较。 回想一下,在第 7 章,“构建自定义对象检测器”中,我们使用 SIFT 描述符作为 SVM 的输入。 同样,人工神经网络能够处理高级描述符,而不仅仅是普通的旧像素数据。

如我们所见,cv2.ml_ANN_MLP类用途广泛,但实际上,它仅涵盖了 ANN 设计方法的一小部分。 接下来,我们将了解 OpenCV 对更复杂的深度神经网络DNN)的支持,这些网络可以通过其他各种框架进行训练。

在 OpenCV 中使用其他框架的 DNN

OpenCV 可以加载和使用在以下任何框架中经过训练的 DNN:

深度学习部署工具包DLDT)是英特尔 OpenVINO 工具包的一部分。 DLDT 提供了用于优化其他框架中的 DNN 并将其转换为通用格式的工具。 兼容 DLDT 的模型的集合可在称为开放模型动物园的存储库中免费获得。 DLDT,开放模型动物园和 OpenCV 在其开发团队中拥有一些相同的人。 这三个项目均由英特尔赞助。

这些框架使用各种文件格式来存储经过训练的 DNN。 其中一些框架使用了一对文件格式的组合:一个用于描述模型参数的文本文件,以及一个用于存储模型本身的二进制文件。 以下代码段显示了与从每个框架加载模型相关的文件类型和 OpenCV 函数:

caffe_model = cv2.dnn.readNetFromCaffe(
    'my_model_description.protext', 'my_model.caffemodel')

tensor_flow_model = cv2.dnn.readNetFromTensorflow(
    'my_model.pb', 'my_model_description.pbtxt')

# Some Torch models use the .t7 extension and others use
# the .net extension.
torch_model_0 = cv2.dnn.readNetFromTorch('my_model.t7')
torch_model_1 = cv2.dnn.readNetFromTorch('my_model.net')

darknet_model = cv2.dnn.readNetFromDarket(
    'my_model_description.cfg', 'my_model.weights')

onnx_model = cv2.dnn.readNetFromONNX('my_model.onnx')

dldt_model = cv2.dnn.readNetFromModelOptimizer(
    'my_model_description.xml', 'my_model.bin')

加载模型后,我们需要预处理将用于模型的数据。 必要的预处理特定于给定 DNN 的设计和训练方式,因此,每当我们使用第三方 DNN 时,我们都必须了解该 DNN 的设计和训练方式。 OpenCV 提供了cv2.dnn.blobFromImage函数,该函数可以执行一些常见的预处理步骤,具体取决于我们传递给它的参数。 在将数据传递给此函数之前,我们可以手动执行其他预处理步骤。

神经网络的输入向量有时称为张量Blob,因此称为函数名称cv2.dnn.blobFromImage

让我们继续来看一个实际的示例,在该示例中,我们将看到第三方 DNN 的运行。

使用第三方 DNN 检测和分类对象

对于此演示,我们将实时捕获来自网络摄像头的帧,并使用 DNN 来检测和分类任何给定帧中可能存在的 20 种对象。 是的,单个 DNN 可以在程序员可能使用的典型笔记本电脑上实时完成所有这些操作!

在深入研究代码之前,让我们介绍一下我们将使用的 DNN。 它是称为 MobileNet-SSD 的模型的 Caffe 版本,它使用 Google 的 MobileNet 框架与另一个称为单发检测器SSD)MultiBox。 后一个框架在这个页面上有一个 GitHub 存储库。 Caffe 版本的 MobileNet-SSD 的训练技术由 GitHub 上的一个项目提供。 可以在本书的存储库中的chapter10/objects_data文件夹中找到以下 MobileNet-SSD 文件的副本:

  • MobileNetSSD_deploy.caffemodel:这是模型。
  • MobileNetSSD_deploy.prototxt:这是描述模型参数的文本文件。

随着我们的示例代码的进行,该模型的功能和正确用法将很快变得清晰起来:

  1. 与往常一样,我们首先导入 OpenCV 和 NumPy:
import cv2
import numpy as np
  1. 我们以上一节中介绍的相同方式继续使用 OpenCV 加载 Caffe 模型:
model = cv2.dnn.readNetFromCaffe(
    'objects_data/MobileNetSSD_deploy.prototxt',
    'objects_data/MobileNetSSD_deploy.caffemodel')
  1. 我们需要定义一些特定于该模型的预处理参数。 它期望输入图像为 300 像素高。 此外,它期望图像中的像素值在 -1.0 到 1.0 的范围内。 这意味着相对于从 0 到 255 的通常标度,有必要减去 127.5,然后除以 127.5。 我们将参数定义如下:
blob_height = 300
color_scale = 1.0/127.5
average_color = (127.5, 127.5, 127.5)
  1. 我们还定义了一个置信度阈值,表示为了将检测作为真实对象而需要的最低置信度得分:
confidence_threshold = 0.5
  1. 该模型支持 20 类对象,其 ID 为 1 到 20(而不是 0 到 19)。 这些类的标签可以定义如下:
labels = ['airplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus',
    'car', 'cat', 'chair', 'cow', 'dining table', 'dog',
    'horse', 'motorbike', 'person', 'potted plant', 'sheep',
    'sofa', 'train', 'TV or monitor']

稍后,当我们使用类 ID 在列表中查找标签时,必须记住从 ID 中减去 1,以获得 0 到 19(而不是 1 到 20)范围内的索引。

有了模型和参数,我们准备开始捕获帧。

  1. 对于每一帧,我们首先计算纵横比。 请记住,此 DNN 期望输入基于 300 像素高的图像; 但是,宽度可以变化以匹配原始的宽高比。 以下代码段显示了如何捕获帧并计算适当的输入大小:
cap = cv2.VideoCapture(0)

success, frame = cap.read()
while success:

    h, w = frame.shape[:2]
    aspect_ratio = w/h

    # Detect objects in the frame.

    blob_width = int(blob_height * aspect_ratio)
    blob_size = (blob_width, blob_height)
  1. 此时,我们可以简单地使用cv2.dnn.blobFromImage函数及其几个可选参数来执行必要的预处理,包括调整帧的大小并将其像素数据转换为 -1.0 到 1.0 的比例:
    blob = cv2.dnn.blobFromImage(
        frame, scalefactor=color_scale, size=blob_size,
        mean=average_color)
  1. 我们将生成的 Blob 馈送到 DNN 并获取模型的输出:
    model.setInput(blob)
    results = model.forward()

结果是一个数组,其格式特定于我们使用的模型。

  1. 对于此对象检测 DNN(以及使用 SSD 框架训练的其他 DNN),结果包括检测到的对象的子数组,每个对象都有自己的置信度得分,矩形坐标和类 ID。 以下代码显示了如何访问它们,以及如何使用 ID 在我们先前定义的列表中查找标签:
    # Iterate over the detected objects.
    for object in results[0, 0]:
        confidence = object[2]
        if confidence > confidence_threshold:

            # Get the object's coordinates.
            x0, y0, x1, y1 = (object[3:7] * [w, h, w, h]).astype(int)

            # Get the classification result.
            id = int(object[1])
            label = labels[id - 1]
  1. 遍历检测到的对象时,我们绘制检测矩形,分类标签和置信度得分:
            # Draw a blue rectangle around the object.
            cv2.rectangle(frame, (x0, y0), (x1, y1),
                          (255, 0, 0), 2)

            # Draw the classification result and confidence.
            text = '%s (%.1f%%)' % (label, confidence * 100.0)
            cv2.putText(frame, text, (x0, y0 - 20),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
  1. 我们对框架所做的最后一件事就是展示它。 然后,如果用户按下Esc键,则退出; 否则,我们将捕获另一帧并继续循环的下一个迭代:
    cv2.imshow('Objects', frame)

    k = cv2.waitKey(1)
    if k == 27: # Escape
        break

    success, frame = cap.read()

如果插入网络摄像头并运行脚本,则应该看到检测结果和分类结果的可视化图像,并实时更新。 这是一个截图,显示约瑟夫·豪斯和萨尼贝尔·德尔菲姆·安德洛梅达(一只强大,善良和公义的猫)在加拿大一个渔村的客厅中:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/ad4e5406-cd70-4be5-850b-8949888fe547.png

DNN 已正确检测并分类了人类(置信度为 99.4%),(85.4%),装饰性瓶子(72.1%),并进行了分类, 沙发的一部分(61.2%),以及的纺织品图片(52.0%)。 显然,该 DNN 能够很好地对航海环境中的客厅进行分类!

这只是 DNN 可以做的事情的第一手–实时! 接下来,让我们看看通过在一个应用中组合三个 DNN 可以实现什么。

使用第三方 DNN 检测和分类人脸

在此演示中,我们将使用一个 DNN 来检测面部,并使用另外两个 DNN 来分类每个检测到的面部的年龄和性别。 具体来说,我们将使用预先训练的 Caffe 模型,这些模型存储在本书 GitHub 存储库的chapter10/faces_data文件夹中的以下文件中。

以下是此文件夹中文件的清单以及这些文件的来源:

  • detection/res10_300x300_ssd_iter_140000.caffemodel:这是用于人脸检测的 DNN。 OpenCV 团队已在这个页面提供了此文件。 这个 Caffe 模型是使用 SSD 框架训练的。 因此,它的拓扑类似于上一节示例中使用的 MobileNet-SSD 模型。
  • detection/deploy.prototxt:这是文本文件,描述了用于人脸检测的先前 DNN 的参数。 OpenCV 团队在这个页面提供此文件。

chapter10/faces_data/age_gender_classification文件夹包含以下文件,这些文件均由 Gil Levi 和 Tal Hassner 在其 GitHub 存储库及其项目页面上提供,他们在年龄和性别分类方面的工作:

  • age_net.caffemodel:这是用于年龄分类的 DNN。
  • age_net_deploy.protext:这是文本文件,描述了用于年龄分类的先前 DNN 的参数。
  • gender_net.caffemodel:这是用于性别分类的 DNN。
  • gender_net_deploy.protext:这是文本文件,描述了用于年龄分类的先前 DNN 的参数。
  • average_face.npyaverage_face.png:这些文件表示分类器训练数据集中的平均面孔。 来自 Levi 和 Hassner 的原始文件称为mean.binaryproto,但我们已将其转换为 NumPy 可读格式和标准图像格式,这对于我们的使用更加方便。

让我们看看如何在代码中使用所有这些文件:

  1. 为了开始示例程序,我们加载人脸检测 DNN,定义其参数,并定义置信度阈值。 我们以与上一节样本中的对象检测 DNN 大致相同的方式执行此操作:
import cv2
import numpy as np

face_model = cv2.dnn.readNetFromCaffe(
    'faces_data/detection/deploy.prototxt',
    'faces_data/detection/res10_300x300_ssd_iter_140000.caffemodel')
face_blob_height = 300
face_average_color = (104, 177, 123)
face_confidence_threshold = 0.995

我们不需要为此 DNN 定义标签,因为它不执行任何分类。 它只是预测面矩形的坐标。

  1. 现在,让我们加载年龄分类器并定义其分类标签:
age_model = cv2.dnn.readNetFromCaffe(
    'faces_data/age_gender_classification/age_net_deploy.prototxt',
    'faces_data/age_gender_classification/age_net.caffemodel')
age_labels = ['0-2', '4-6', '8-12', '15-20',
              '25-32', '38-43', '48-53', '60+']

请注意,在此模型中,年龄标签之间存在间隙。 例如,'0-2'后跟'4-6'。 因此,如果一个人实际上是 3 岁,则分类器没有适合这种情况的标签; 最多可以选择'0-2''4-6'之一。 大概是,模型的作者有意选择了不连续的范围,以确保类别相对于输入而言是可分离的。 让我们考虑替代方案。 根据面部图像中的数据,是否可以将 4 岁以下的人群与每天 4 岁以下的人群分开? 当然不是。 他们看起来一样。 因此,根据连续的年龄范围来制定分类问题是错误的。 可以训练 DNN 将年龄预测为连续变量(例如,浮点数的年数),但这与分类器完全不同,分类器预测各个类别的置信度得分。

  1. 现在,让我们加载性别分类器并定义其标签:
gender_model = cv2.dnn.readNetFromCaffe(
    'faces_data/age_gender_classification/gender_net_deploy.prototxt',
    'faces_data/age_gender_classification/gender_net.caffemodel')
gender_labels = ['male', 'female']
  1. 年龄和性别分类器使用相同的 Blob 大小和相同的平均值。 他们使用的不是平均颜色,而是平均颜色的人脸图像,我们将从NPY文件中加载该图像(作为浮点格式的 NumPy 数组)。 稍后,我们将在执行分类之前从实际的面部图像中减去该平均面部图像。 以下是斑点大小和平均图像的定义:
age_gender_blob_size = (256, 256)
age_gender_average_image = np.load(
    'faces_data/age_gender_classification/average_face.npy')

如果要查看普通脸的外观,请打开chapter10/faces_data/age_gender_classification/average_face.png的文件,该文件包含标准图像格式的相同数据。 这里是:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/3bc621b4-edb4-4462-8520-68e5ad9014b3.png

当然,这只是特定训练数据集的平均面孔。 它不一定代表世界人口或任何特定国家或社区的真实平均面孔。 即使这样,在这里,我们仍可以看到一张由许多面孔组成的模糊面孔,并且没有明显的年龄或性别线索。 请注意,该图像是方形的,以鼻子的尖端为中心,并且从前额的顶部垂直延伸到颈部的底部。 为了获得准确的分类结果,我们应注意将此分类器应用于以相同方式裁剪的面部图像。

  1. 设置好模型及其参数后,让我们继续从相机捕获和处理帧。 对于每一帧,我们首先创建一个与帧相同的宽高比的 blob,然后将此 blob 馈送到人脸检测 DNN:
cap = cv2.VideoCapture(0)

success, frame = cap.read()
while success:

    h, w = frame.shape[:2]
    aspect_ratio = w/h

    # Detect faces in the frame.

    face_blob_width = int(face_blob_height * aspect_ratio)
    face_blob_size = (face_blob_width, face_blob_height)

    face_blob = cv2.dnn.blobFromImage(
        frame, size=face_blob_size, mean=face_average_color)

    face_model.setInput(face_blob)
    face_results = face_model.forward()
  1. 就像我们在上一部分示例中使用的对象检测器一样,人脸检测器提供置信度得分和矩形坐标作为结果的一部分。 对于每个检测到的面部,我们需要检查置信度得分是否可以接受地高,如果是,则将获得面部矩形的坐标:
    # Iterate over the detected faces.
    for face in face_results[0, 0]:
        face_confidence = face[2]
        if face_confidence > face_confidence_threshold:

            # Get the face coordinates.
            x0, y0, x1, y1 = (face[3:7] * [w, h, w, h]).astype(int)
  1. 此人脸检测 DNN 生成的矩形长于宽度。 但是,DNN 的年龄和性别分类要求使用方形面孔。 让我们加宽检测到的脸部矩形以使其成为正方形:
            # Classify the age and gender of the face based on a
            # square region of interest that includes the neck.

            y1_roi = y0 + int(1.2*(y1-y0))
            x_margin = ((y1_roi-y0) - (x1-x0)) // 2
            x0_roi = x0 - x_margin
            x1_roi = x1 + x_margin
            if x0_roi < 0 or x1_roi > w or y0 < 0 or y1_roi > h:
                # The region of interest is partly outside the
                # frame. Skip this face.
                continue

请注意,如果正方形的一部分落在图像的边界之外,我们将跳过此检测结果并继续进行下一个检测。

  1. 此时,我们可以选择正方形兴趣区域ROI),其中包含将用于年龄和性别分类的图像数据。 我们将 ROI 缩放到分类器的斑点大小,将其转换为浮点格式,然后减去平均脸部。 根据生成的缩放后的标准化脸部,创建斑点:
            age_gender_roi = frame[y0:y1_roi, x0_roi:x1_roi]
            scaled_age_gender_roi = cv2.resize(
                age_gender_roi, age_gender_blob_size,
                interpolation=cv2.INTER_LINEAR).astype(np.float32)
            scaled_age_gender_roi[:] -= age_gender_average_image
            age_gender_blob = cv2.dnn.blobFromImage(
                scaled_age_gender_roi, size=age_gender_blob_size)
  1. 我们将斑点输入年龄分类器,选择具有最高置信度得分的类 ID,然后记下该 ID 的标签和置信度得分:
            age_model.setInput(age_gender_blob)
            age_results = age_model.forward()
            age_id = np.argmax(age_results)
            age_label = age_labels[age_id]
            age_confidence = age_results[0, age_id]
  1. 同样,我们将性别分类:
            gender_model.setInput(age_gender_blob)
            gender_results = gender_model.forward()
            gender_id = np.argmax(gender_results)
            gender_label = gender_labels[gender_id]
            gender_confidence = gender_results[0, gender_id]
  1. 我们绘制检测到的脸部矩形,扩展的方形 ROI 和分类结果的可视化图像:
            # Draw a blue rectangle around the face.
            cv2.rectangle(frame, (x0, y0), (x1, y1),
                          (255, 0, 0), 2)

            # Draw a yellow square around the region of interest
            # for age and gender classification.
            cv2.rectangle(frame, (x0_roi, y0), (x1_roi, y1_roi),
                          (0, 255, 255), 2)

            # Draw the age and gender classification results.
            text = '%s years (%.1f%%), %s (%.1f%%)' % (
                age_label, age_confidence * 100.0,
                gender_label, gender_confidence * 100.0)
            cv2.putText(frame, text, (x0_roi, y0 - 20),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
  1. 最后,我们显示带标注的帧,并继续捕获更多帧,直到用户按下Esc键:
cv2.imshow('Faces, age, and gender', frame)

k = cv2.waitKey(1)
if k == 27: # Escape
    break

success, frame = cap.read()

该程序如何报告约瑟夫·豪斯? 让我们来看看:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/learn-opencv4-cv-py3/img/7061d12a-b3ed-4db8-9c5a-9ba3c0f259ba.png

没有虚荣心,约瑟夫·豪斯(Joseph Howse)将就此结果写几段文字。

首先,让我们考虑面部的检测和 ROI 的选择。 已正确检测到脸部。 ROI 已正确扩展到包括脖子的方形区域,或者在这种情况下为完整的胡须,这对于分类年龄和性别可能是重要的区域。

其次,让我们考虑分类。 事实是,约瑟夫·豪斯(Joseph Howse)是男性,在这张照片拍摄时大约 35.8 岁。 看到约瑟夫·豪斯的脸的其他人也能够完全自信地断定他是男性。 但是,他们对他的年龄的估计差异很大。 性别分类 DNN 满怀信心(100.0%)说约瑟夫·豪斯是男性。 年龄分类 DNN 充满信心(96.6%)表示他年龄在 25-32 岁之间。 取这个范围的中点 28.5 也许很诱人,并说该预测的误差为-7.3 年,从客观上来说,这是一个大大的低估了,它是真实年龄的-20.4%。 但是,这种评估是预测含义的延伸。

请记住,此 DNN 是年龄分类器,而不是连续年龄值的预测指标,并且 DNN 的年龄类别被标记为不连续的范围; '25-32'之后的下一个是'38-43'。 因此,该模型与约瑟夫·豪斯(Joseph Howse)的真实年龄之间存在差距,但至少它设法从边界上选择了两个类别之一。

该演示结束了我们对 ANN 和 DNN 的介绍。 让我们回顾一下我们学到的东西和做过的事情。

总结

本章概述了人工神经网络的广阔而迷人的世界。 我们了解了人工神经网络的结构,以及如何根据应用需求设计网络拓扑。 然后,我们专注于 OpenCV 对 MLP ANN 的实现,以及 OpenCV 对在其他框架中进行过训练的各种 DNN 的支持。

我们将神经网络应用于现实世界中的问题:特别是手写数字识别; 目标检测和分类; 以及实时的人脸识别,年龄分类和性别分类的组合。 我们看到,即使在这些入门演示中,神经网络在多功能性,准确率和速度方面也显示出很大的希望。 希望这可以鼓励您尝试各种作者的经过预先训练的模型,并学习在各种框架中训练自己的高级模型。

带着这种思想和良好的祝愿,我们现在将分开。

本书的作者希望您通过 OpenCV 4 的 Python 绑定一起经历了我们的旅程。尽管涵盖了 OpenCV 4 的所有功能及其所有绑定将涉及一系列书籍,但我们探索了许多有趣而又充满未来感的概念,并且我们鼓励您与我们以及 OpenCV 社区取得联系,让我们了解您在计算机视觉领域的下一个突破性项目!

十一、附录 A:使用“曲线”过滤器弯曲颜色空间

从第 3 章“使用 OpenCV 处理图像”开始,我们的Cameo演示应用合并了一种称为曲线的图像处理效果,用于模拟某些物体的色偏。 摄影胶片。 本附录描述了曲线的概念及其使用 SciPy 的实现。

曲线是一种重新映射颜色的技术。 使用曲线时,目标像素处的通道值是(仅)源像素处的相同通道值的函数。 而且,我们不直接定义函数; 而是,对于每个函数,我们定义一组必须通过插值拟合的控制点。 在伪代码中,对于 BGR 图像,我们具有以下内容:

dst.b = funcB(src.b) where funcB interpolates pointsB
dst.g = funcG(src.g) where funcG interpolates pointsG
dst.r = funcR(src.r) where funcR interpolates pointsR

尽管应避免控制点处的不连续坡度,但会产生曲线,但这种插值方式可能会因实现方式而异。 只要控制点数量足够,我们将使用三次样条插值。

让我们先来看一下如何实现插值。

定义曲线

我们迈向基于曲线的过滤器的第一步是将控制点转换为函数。 大部分工作都是通过名为scipy.interp1d的 SciPy 函数完成的,该函数接受两个数组(xy坐标)并返回一个对点进行插值的函数。 作为scipy.interp1d的可选参数,我们可以指定kind插值; 支持的选项包括'linear''nearest''zero''slinear'(球形线性),'quadratic''cubic'。 另一个可选参数bounds_error可以设置为False,以允许外插和内插。

让我们编辑我们在Cameo演示中使用的utils.py脚本,并添加一个将scipy.interp1d包裹起来的函数,该函数的接口稍微简单一些:

def createCurveFunc(points):
    """Return a function derived from control points."""
    if points is None:
        return None
    numPoints = len(points)
    if numPoints < 2:
        return None
    xs, ys = zip(*points)
    if numPoints < 3:
        kind = 'linear'
    elif numPoints < 4:
        kind = 'quadratic'
    else:
        kind = 'cubic'
    return scipy.interpolate.interp1d(xs, ys, kind,
                                      bounds_error = False)

我们的函数不是使用两个单独的坐标数组,而是采用(xy)对的数组,这可能是指定控制点的一种更易读的方式。 必须对数组进行排序,以使x从一个索引增加到下一个索引。 通常,为获得自然效果,y值也应增加,并且第一个和最后一个控制点应为(0, 0)(255, 255),以保留黑白。 注意,我们将x视为通道的输入值,并将y视为对应的输出值。 例如,(128, 160)将使通道的中间色调变亮。

请注意,三次插值至少需要四个控制点。 如果只有三个控制点,则退回到二次插值;如果只有两个控制点,则退回到线性插值。 为了获得自然效果,应避免这些后备情况。

在本章的其余部分中,我们力求以有效且井井有条的方式使用由createCurveFunc函数生成的曲线。

缓存和应用曲线

现在,我们可以获得插入任意控制点的曲线的函数。 但是,此函数可能很昂贵。 我们不希望每个通道每个像素运行一次(例如,如果应用于640 x 480视频的三个通道,则每帧运行 921,600 次)。 幸运的是,我们通常只处理 256 个可能的输入值(每个通道 8 位),并且可以廉价地预先计算并存储许多输出值。 然后,我们的每通道每像素成本只是对缓存的输出值的查找。

让我们编辑utils.py文件并添加一个将为给定函数创建查找数组的函数:

def createLookupArray(func, length=256):
    """Return a lookup for whole-number inputs to a function.

    The lookup values are clamped to [0, length - 1].

    """
    if func is None:
        return None
    lookupArray = numpy.empty(length)
    i = 0
    while i < length:
        func_i = func(i)
        lookupArray[i] = min(max(0, func_i), length - 1)
        i += 1
    return lookupArray

我们还添加一个函数,该函数会将查找数组(例如前一个函数的结果)应用于另一个数组(例如图像):

def applyLookupArray(lookupArray, src, dst):
    """Map a source to a destination using a lookup."""
    if lookupArray is None:
        return
    dst[:] = lookupArray[src]

请注意,createLookupArray中的方法仅限于输入值为整数(非负整数)的输入值,因为该输入值用作数组的索引。 applyLookupArray函数通过使用源数组的值作为查找数组的索引来工作。 Python 的切片符号([:])用于将查找的值复制到目标数组中。

让我们考虑另一个优化。 如果我们要连续应用两个或更多曲线怎么办? 执行多次查找效率低下,并且可能导致精度降低。 我们可以通过在创建查找数组之前将两个曲线函数组合为一个函数来避免这些问题。 让我们再次编辑utils.py并添加以下函数,该函数返回两个给定函数的组合:

def createCompositeFunc(func0, func1):
    """Return a composite of two functions."""
    if func0 is None:
        return func1
    if func1 is None:
        return func0
    return lambda x: func0(func1(x))

createCompositeFunc中的方法仅限于采用单个参数的输入函数。 参数必须是兼容类型。 请注意,使用 Python 的lambda关键字创建匿名函数。

以下是最终的优化问题。 如果我们想对图像的所有通道应用相同的曲线怎么办? 在这种情况下,拆分和合并通道很浪费,因为我们不需要区分通道。 我们只需要applyLookupArray使用的一维索引。 为此,我们可以使用numpy.ravel函数,该函数将一维接口返回到预先存在的给定数组(可能是多维数组)。 返回类型为numpy.view,其接口与numpy.array几乎相同,除了numpy.view仅拥有对数据的引用,而非副本。

NumPy 数组具有flatten方法,但这将返回一个副本。

numpy.ravel适用于具有任意数量通道的图像。 因此,当我们希望所有通道都相同时,它可以抽象出灰度图像和彩色图像之间的差异。

现在,我们已经解决了与曲线使用有关的几个重要的优化问题,让我们考虑如何组织代码,以便为诸如Cameo之类的应用提供简单且可重用的界面。

设计面向对象的曲线过滤器

由于我们为每个曲线缓存了一个查找数组,因此基于曲线的过滤器具有与之关联的数据。 因此,我们将它们实现为类,而不仅仅是函数。 让我们制作一对曲线过滤器类,以及一些可以应用任何函数而不仅仅是曲线函数的相应高级类:

  • VFuncFilter:这是一个用函数实例化的类,然后可以使用apply将其应用于图像。 该函数适用​​于灰度图像的 V(值)通道或彩色图像的所有通道。
  • VCurveFilter:这是VFuncFilter的子类。 而不是使用函数实例化,而是使用一组控制点实例化,这些控制点在内部用于创建曲线函数。
  • BGRFuncFilter:这是一个用最多四个函数实例化的类,然后可以使用apply将其应用于 BGR 图像。 这些函数之一适用于所有通道,而其他三个函数均适用于单个通道。 首先应用整体函数,然后再应用每通道函数。
  • BGRCurveFilter:这是BGRFuncFilter的子类。 而不是使用四个函数实例化,而是使用四组控制点实例化,这些控制点在内部用于创建曲线函数。

此外,所有这些类都接受数字类型的构造器参数,例如numpy.uint8,每个通道 8 位。 此类型用于确定查找数组中应包含多少个条目。 数值类型应为整数类型,并且查找数组将覆盖从 0 到该类型的最大值(包括该值)的范围。

首先,让我们看一下VFuncFilterVCurveFilter的实现,它们都可以添加到filters.py中:

class VFuncFilter(object):
    """A filter that applies a function to V (or all of BGR)."""

    def __init__(self, vFunc=None, dtype=numpy.uint8):
        length = numpy.iinfo(dtype).max + 1
        self._vLookupArray = utils.createLookupArray(vFunc, length)

    def apply(self, src, dst):
        """Apply the filter with a BGR or gray source/destination."""
        srcFlatView = numpy.ravel(src)
        dstFlatView = numpy.ravel(dst)
        utils.applyLookupArray(self._vLookupArray, srcFlatView,
                               dstFlatView)

class VCurveFilter(VFuncFilter):
    """A filter that applies a curve to V (or all of BGR)."""

    def __init__(self, vPoints, dtype=numpy.uint8):
        VFuncFilter.__init__(self, utils.createCurveFunc(vPoints),
                             dtype)

在这里,我们正在内部使用几个以前的函数:utils.createCurveFuncutils.createLookupArrayutils.applyLookupArray。 我们还使用numpy.iinfo根据给定的数字类型确定相关的查找值范围。

现在,让我们看一下BGRFuncFilterBGRCurveFilter的实现,它们也都可以添加到filters.py中:

class BGRFuncFilter(object):
    """A filter that applies different functions to each of BGR."""

    def __init__(self, vFunc=None, bFunc=None, gFunc=None,
                 rFunc=None, dtype=numpy.uint8):
        length = numpy.iinfo(dtype).max + 1
        self._bLookupArray = utils.createLookupArray(
            utils.createCompositeFunc(bFunc, vFunc), length)
        self._gLookupArray = utils.createLookupArray(
            utils.createCompositeFunc(gFunc, vFunc), length)
        self._rLookupArray = utils.createLookupArray(
            utils.createCompositeFunc(rFunc, vFunc), length)

    def apply(self, src, dst):
        """Apply the filter with a BGR source/destination."""
        b, g, r = cv2.split(src)
        utils.applyLookupArray(self._bLookupArray, b, b)
        utils.applyLookupArray(self._gLookupArray, g, g)
        utils.applyLookupArray(self._rLookupArray, r, r)
        cv2.merge([b, g, r], dst)

class BGRCurveFilter(BGRFuncFilter):
    """A filter that applies different curves to each of BGR."""

    def __init__(self, vPoints=None, bPoints=None,
                 gPoints=None, rPoints=None, dtype=numpy.uint8):
        BGRFuncFilter.__init__(self,
                               utils.createCurveFunc(vPoints),
                               utils.createCurveFunc(bPoints),
                               utils.createCurveFunc(gPoints),
                               utils.createCurveFunc(rPoints), dtype)

同样,我们正在内部使用几个以前的函数:utils.createCurvFuncutils.createCompositeFuncutils.createLookupArrayutils.applyLookupArray。 我们还使用numpy.iinfocv2.splitcv2.merge

这四个类可以按原样使用,在实例化时将自定义函数或控制点作为参数传递。 或者,我们可以创建其他子类,这些子类对某些功能或控制点进行硬编码。 这样的子类可以实例化而无需任何参数。

现在,让我们看一下子类的一些示例。

模拟摄影胶片

曲线的常用用法是模拟数字前摄影中常见的调色板。 每种类型的胶卷都有自己独特的颜色(或灰色)表示法,但我们可以概括一些与数字传感器的区别。 电影往往会损失细节和阴影饱和度,而数字往往会遭受高光的这些缺陷。 而且,胶片在光谱的不同部分上往往具有不均匀的饱和度,因此每张胶片都有某些弹出或跳出的颜色。

因此,当我们想到漂亮的电影照片时,我们可能会想到明亮的且具有某些主导色彩的场景(或副本)。 在另一个极端,也许我们还记得曝光不足的胶卷的暗淡外观,而实验室技术人员的努力并不能改善它。

在本节中,我们将使用曲线创建四个不同的类似于电影的过滤器。 它们受到三种胶片和冲洗技术的启发:

  • 柯达波特拉(Kodak Portra),这是一系列针对肖像和婚礼进行了优化的电影。
  • Fuji Provia,一个通用电影家族。
  • 富士·维尔维亚(Fuji Velvia),针对风景优化的电影系列。
  • 交叉处理是一种非标准的胶片处理技术,有时用于在时装和乐队摄影中产生低劣的外观。

每个电影模拟效果都实现为BGRCurveFilter的非常简单的子类。 在这里,我们只需重写构造器即可为每个通道指定一组控制点。 控制点的选择基于摄影师 Petteri Sulonen 的建议。 有关更多信息,请参见他在这个页面上有关胶片状曲线的文章。

Portra,Provia 和 Velvia 效果应产生看起来正常的图像。 除了前后比较之外,这些效果应该不明显。

让我们从 Portra 过滤器开始,检查四个胶片仿真过滤器中每个过滤器的实现。

模拟柯达 Portra

Portra 具有宽广的高光范围,倾向于暖色(琥珀色),而阴影则较冷(蓝色)。 作为人像电影,它倾向于使人们的肤色更白皙。 而且,它会夸大某些常见的衣服颜色,例如乳白色(例如婚纱)和深蓝色(例如西装或牛仔裤)。 让我们将 Portra 过滤器的此实现添加到filters.py

class BGRPortraCurveFilter(BGRCurveFilter):
    """A filter that applies Portra-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            vPoints = [(0,0),(23,20),(157,173),(255,255)],
            bPoints = [(0,0),(41,46),(231,228),(255,255)],
            gPoints = [(0,0),(52,47),(189,196),(255,255)],
            rPoints = [(0,0),(69,69),(213,218),(255,255)],
            dtype = dtype)

从柯达到富士,接下来我们将模拟 Provia。

模拟富士 Provia

普罗维亚(Provia)具有很强的对比度,并且在大多数色调中略微凉爽(蓝色)。 天空,水和阴影比太阳增强更多。 让我们将 Provia 过滤器的此实现添加到filters.py

class BGRProviaCurveFilter(BGRCurveFilter):
    """A filter that applies Provia-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            bPoints = [(0,0),(35,25),(205,227),(255,255)],
            gPoints = [(0,0),(27,21),(196,207),(255,255)],
            rPoints = [(0,0),(59,54),(202,210),(255,255)],
            dtype = dtype)

接下来是我们的 Fuji Velvia 过滤器。

模拟富士 Velvia

Velvia 具有深阴影和鲜艳的色彩。 它通常可以在白天产生蔚蓝的天空,在日落时产生深红色的云。 这种效果很难模拟,但是这是我们可以添加到filters.py的尝试:

class BGRVelviaCurveFilter(BGRCurveFilter):
    """A filter that applies Velvia-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            vPoints = [(0,0),(128,118),(221,215),(255,255)],
            bPoints = [(0,0),(25,21),(122,153),(165,206),(255,255)],
            gPoints = [(0,0),(25,21),(95,102),(181,208),(255,255)],
            rPoints = [(0,0),(41,28),(183,209),(255,255)],
            dtype = dtype)

现在,让我们来看一下交叉处理的外观!

模拟交叉处理

交叉处理会在阴影中产生强烈的蓝色或绿蓝色调,在高光区域产生强烈的黄色或绿黄色。 黑色和白色不一定要保留。 而且,对比度非常高。 交叉处理的照片看起来很不舒服。 人们看起来黄疸,而无生命的物体看起来很脏。 让我们编辑filters.py并添加以下交叉处理过滤器的实现:

class BGRCrossProcessCurveFilter(BGRCurveFilter):
    """A filter that applies cross-process-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            bPoints = [(0,20),(255,235)],
            gPoints = [(0,0),(56,39),(208,226),(255,255)],
            rPoints = [(0,0),(56,22),(211,255),(255,255)],
            dtype = dtype)

现在我们已经看过一些有关如何实现胶片仿真过滤器的示例,我们将包装本附录,以便您可以回到第 3 章“使用 OpenCV 处理图像”中的Cameo应用的主要实现。

总结

scipy.interp1d函数的基础上,我们实现了一系列曲线过滤器,这些过滤器高效(由于使用查找数组)并且易于扩展(由于面向对象的设计)。 我们的工作包括专用曲线过滤器,可以使数字图像看起来更像胶卷照。 这些过滤器可以很容易地集成到诸如Cameo之类的应用中,如第 3 章,“用 OpenCV 处理图像”中使用我们的 Portra 胶片仿真过滤器所示。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值