CoreML-机器学习-全-

CoreML 机器学习(全)

原文:annas-archive.org/md5/81fb483ff59bf077682b56a26add29aa

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我们正处于一个新时代的计算边缘,在这个时代,计算机更多地成为我们的伴侣而不是工具。我们口袋里的设备将很快更好地理解我们的世界和我们自己,这将对我们如何与之互动和使用它们产生深远的影响。

但目前,许多这些令人兴奋的进步都停留在研究人员的实验室里,而不是设计师和开发者的手中,这使得它们对用户可用和可访问。这并不是因为细节被锁起来;相反,在大多数情况下,它们是免费可用的。

这种差距部分是由于我们满足于坚持我们所知道的,让用户做所有的工作,让他们点击按钮。如果其他什么都没有,我希望这本书能让你对现有的东西感到好奇,以及如何用它来创造新的体验,或者改善现有的体验。

在本书的页面上,您将找到一系列示例,帮助您了解深度神经网络的工作原理以及它们如何被应用。

本书专注于一系列模型,以更好地理解图像和照片,特别是研究它们如何在 iOS 平台上进行适配和应用。这种基于图像的模型和 iOS 平台的狭窄关注是有意为之的;我发现图像的视觉性质使得概念更容易可视化,而 iPhone 提供了完美的候选者和实验环境。

因此,当你阅读这本书时,我鼓励你开始思考这些模型的新用途以及你可以创造的新体验。话虽如此,让我们开始吧!

本书面向的对象

本书将吸引三个广泛的读者群体。第一群是中级 iOS 开发者,他们对学习和应用机器学习ML)感兴趣;对 ML 概念的一些了解可能有益,但并非必需,因为本书涵盖了其中使用的概念和模型背后的直觉。

第二群是有 ML 经验但没有 iOS 开发经验的人,他们正在寻找资源来帮助他们掌握 Core ML;对于这一群体,建议与一本涵盖 iOS 开发基础的书一起阅读。

第三群是经验丰富的 iOS 开发者和 ML 实践者,他们好奇地想看看各种模型在 iOS 平台背景下的应用情况。

本书涵盖的内容

第一章,机器学习简介,简要介绍了 ML,包括一些对核心概念、问题类型、算法以及创建和使用 ML 模型的一般工作流程的解释。本章通过探讨一些 ML 正在被应用的例子结束。

第二章,苹果核心 ML 简介,介绍了 Core ML,讨论了它是什么,它不是什么,以及使用它的一般工作流程。

第三章,识别世界中的物体,从零开始构建 Core ML 应用程序。到本章结束时,我们将完成获取模型、将其导入项目以及使用它的整个过程。

第四章,使用 CNN 进行情感检测,探讨了计算机更好地理解我们的可能性,特别是我们的情绪。我们首先建立机器学习如何推断你的情绪的直觉,然后通过构建一个执行此操作的应用程序来将其付诸实践。我们还利用这个机会介绍 Vision 框架,并了解它如何补充 Core ML。

第五章,在世界上定位物体,不仅限于识别单个物体,而且能够通过物体检测在单个图像中识别和定位多个物体。在理解其工作原理后,我们将将其应用于一个视觉搜索应用程序,该应用程序不仅根据物体进行过滤,还根据物体的组合进行过滤。在本章中,我们还将有机会通过实现自定义层来扩展 Core ML。

第六章,使用风格迁移创建艺术,揭示了流行的照片效果应用程序 Prisma 背后的秘密。我们首先讨论如何训练模型区分图像的风格和内容,然后继续构建一个将一种图像的风格应用到另一种图像上的 Prisma 版本。在本章的最后,我们将探讨优化模型的方法。

第七章,使用 CNN 辅助绘图,介绍了如何构建一个应用程序,该程序可以使用之前章节中介绍的概念来识别用户的草图。一旦识别出用户试图绘制的对象,我们将探讨如何使用 CNN 的特征向量找到类似的替代品。

第八章,使用 RNNs 辅助绘图,在上一章的基础上,探讨了用卷积神经网络CNN)替换循环神经网络RNN)进行草图分类,从而引入 RNNs 并展示它们如何应用于图像。除了讨论学习序列,我们还将深入了解如何远程下载和编译 Core ML 模型。

第九章,使用 CNN 进行物体分割,介绍了构建ActionShot摄影应用程序的过程。在这个过程中,我们引入了另一个模型和相关概念,并获得了准备和处理数据的实际经验。

第十章,《创建 ML 简介》,是最后一章。我们介绍了 Create ML,这是一个在 Xcode 中使用 Swift 创建和训练 Core ML 模型的框架。到本章结束时,你将了解如何快速创建、训练和部署自定义模型。

要充分利用本书

要能够跟随本书中的示例,您需要以下软件:

  • macOS 10.13 或更高版本

  • Xcode 9.2 或更高版本

  • iOS 11.0 或更高版本(设备和模拟器)

对于依赖于 Core ML 2 的示例,您需要以下软件:

  • macOS 10.14

  • Xcode 10.0 测试版

  • iOS 12(设备和模拟器)

建议您使用notebooks.azure.com(或任何其他 Jupyter 笔记本服务提供商)来使用 Core ML Tools Python 包跟随示例,但那些想要本地运行或训练模型的人需要以下软件:

  • Python 2.7

  • Jupyter Notebooks 1.0

  • TensorFlow 1.0.0 或更高版本

  • NumPy 1.12.1 或更高版本

  • Core ML Tools 0.9(以及 Core ML 2 示例的 2.0 版本)

下载示例代码文件

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

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择 SUPPORT 标签页。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入本书的名称,并遵循屏幕上的说明。

文件下载后,请确保使用最新版本解压缩或提取文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Machine-Learning-with-Core-ML。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/MachineLearningwithCoreML_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在课程顶部,我们定义了VideoCaptureDelegate协议。”

代码块设置如下:

public protocol VideoCaptureDelegate: class {
    func onFrameCaptured(
      videoCapture: VideoCapture, 
      pixelBuffer:CVPixelBuffer?, 
      timestamp:CMTime)
}

当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

@IBOutlet var previewView:CapturePreviewView!
@IBOutlet var classifiedLabel:UILabel!

let videoCapture : VideoCapture = VideoCapture()

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会以这种方式显示。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要提示如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告此错误。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现任何形式的我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packtpub.com.

第一章:机器学习(ML)简介

让我们从展望未来开始,设想我们将如何与计算机互动。与今天需要我们不断输入电子邮件和密码才能访问信息的计算机不同,未来的计算机将能够通过我们的面部、声音或活动轻松地识别我们。与今天需要逐步指令才能执行操作的计算机不同,未来的计算机将能够预测我们的意图,并为我们提供一种自然的方式与它进行对话,就像我们与其他人互动一样,然后继续帮助我们实现目标。我们的计算机不仅会帮助我们,还会成为我们的朋友、医生等等。它可以在门口递送我们的杂货,并成为我们与日益复杂和充满信息丰富的物理世界之间的接口。

这个愿景令人兴奋的是,它不再属于科幻领域,而是一个正在出现的现实。推动这一进展的主要因素之一是机器学习(ML)技术的进步和采用,这是一门赋予计算机人类感知能力的学科,从而赋予它们看到、听到和理解世界——无论是物理世界还是数字世界——的能力。

尽管在过去 3-4 年中取得了巨大的进步,但大多数想法和潜力都锁在研究项目和论文中,而不是用户手中。因此,本书的目标是帮助开发者更好地理解这些概念。它将使您能够将它们付诸实践,以便我们能够到达这个未来——一个计算机增强我们而不是由于它们无法理解我们的世界而奴役我们的未来。

由于 Core ML 的限制——它只能执行推理——本书与其他机器学习(ML)书籍有很大不同,核心关注点是机器学习的应用。具体来说,我们将专注于计算机视觉应用而不是机器学习的细节。但为了更好地让您充分利用机器学习(ML),我们将花一些时间介绍每个示例相关的概念。

在动手实践示例之前,让我们从头开始,培养对机器学习(ML)是什么以及如何应用它的欣赏。在本章中,我们将:

  • 首先,介绍机器学习(ML)。我们将了解它与经典编程的不同之处以及为什么您可能会选择它。

  • 看一看机器学习(ML)今天的一些应用示例,以及所使用的数据类型和机器学习(ML)算法。

  • 最后,介绍机器学习(ML)项目的典型工作流程。

让我们从首先讨论机器学习(ML)是什么以及为什么每个人都谈论它开始。

什么是机器学习(ML)?

机器学习(ML)是人工智能(AI)的一个子领域,人工智能(AI)是 20 世纪 50 年代诞生的计算机科学话题,其目标是试图让计算机思考或提供与我们人类相似水平的自动化智能。

早期在人工智能领域取得的成功是通过使用一套广泛的定义规则实现的,这些规则被称为符号人工智能,允许专家决策通过计算机来模仿。这种方法在许多领域都取得了良好的效果,但有一个很大的不足,那就是为了创建一个专家,你需要一个专家。不仅如此,他们的专业知识还需要以某种方式数字化,这通常需要明确的编程。

机器学习提供了一种替代方案;我们不需要手动编写规则,它从示例和经验中学习。它还与经典编程不同,它是概率性的,而不是离散的。也就是说,它能够比其对应物更好地处理模糊性或不确定性,当给出一个未明确识别和处理的模糊输入时,其对应物可能会失败。

我将借用谷歌工程师 Josh Godron 在机器学习入门视频中使用的例子,以更好地突出机器学习的差异和价值。

假设你被分配了一个对苹果和橙子进行分类的任务。让我们首先使用我们称之为经典编程的方法来处理这个问题:

图片

我们的输入是每个图像的像素数组,对于每个输入,我们需要明确定义一些规则,这些规则能够区分苹果和橙子。使用前面的例子,你可以通过简单地计算橙色和绿色像素的数量来解决这个问题。绿色像素比例较高的将被分类为苹果,而橙色像素比例较高的将被分类为橙子。这对于这些例子来说效果很好,但如果我们的输入变得更加复杂,这种方法就会失效:

图片

新图像的引入意味着我们简单的颜色计数函数再也无法充分区分我们的苹果和橙子,甚至无法对苹果进行分类。我们需要重新实现该函数以处理引入的新细微差别。因此,我们的函数变得更加复杂,并且与输入更加紧密地耦合,不太可能泛化到其他输入。我们的函数可能类似于以下内容:

func countColors(_ image:UIImage) -> [(color:UIColor, count:Int)]{
// lots of code 
}

func detectEdges(_ image:UIImage) -> [(x1:Int, y1:Int, x2:Int, y2:Int)]
{
// lots of code
}

func analyseTexture(_ image:UIImage) -> [String]
{
// lots of code 
} 

func fitBoundingBox(_ image:UIImage) -> [(x:Int, y:Int, w:Int, h:Int)]
{
// lots of code 
}

这个函数可以被认为是我们的模型,它描述了输入与其标签(苹果或橙子)之间的关系,如下面的图示所示:

图片

替代方案,以及我们感兴趣的方法,是让这个模型自动使用示例;本质上,这就是机器学习的全部内容。它为我们提供了一个有效的工具来模拟复杂任务,这些任务否则几乎不可能通过规则来定义。

机器学习模型的创建阶段被称为训练,它由所选的机器学习算法和数据输入类型决定。一旦模型被训练,也就是说,一旦它学会了,我们就可以用它从数据中做出推断,如下面的图示所示:

图片

我们在这里展示的例子,对橙子和苹果进行分类,是一种特定的机器学习算法,称为分类器,或者更具体地说,是多分类分类器。该模型是通过监督学习训练的;也就是说,我们提供了带有相关标签(或类别)的输入示例。了解存在的机器学习算法类型以及训练类型是有用的,这是下一节的主题。

机器学习算法简要概述

在本节中,我们将探讨一些机器学习应用的例子,并且在每个例子中,我们将推测所使用的数据类型、学习风格和机器学习算法。我希望到本节结束时,你将受到机器学习可能性的启发,并对存在的数据类型、算法和学习风格有所欣赏。

在本节中,我们将通过介绍数据类型、算法和学习风格来展示一些现实生活中的例子。我们的意图并不是展示示例的准确数据表示或实现,而是利用这些例子使思想更加具体化。

Netflix – 提供推荐

没有一本机器学习书籍会不提及推荐引擎——这可能是机器学习最广为人知的应用之一。部分原因在于 Netflix 宣布了一项针对电影评分预测的 100 万美元竞赛,也称为推荐。再加上亚马逊在利用这一技术上的商业成功。

推荐引擎的目标是预测某人想要特定产品或服务的可能性。在 Netflix 的背景下,这意味着向其用户推荐电影或电视节目。

提供推荐的一种直观方式是尝试模仿现实世界,在现实世界中,一个人可能会向志同道合的人寻求推荐。构成相似性的因素取决于领域。例如,你可能会有一组朋友,你会向他们寻求餐厅推荐,另一组朋友则是电影推荐。决定这些群体的因素是他们对你特定领域的品味与你自己的品味相似程度。我们可以使用(基于用户的)协同过滤(CF)算法来复制这一点。该算法通过找到每个用户之间的距离,然后使用这些距离作为相似性指标来推断特定用户的电影预测;也就是说,那些更相似的人将对预测做出更大的贡献,而那些有不同偏好的人则贡献较小。让我们看看 Netflix 的数据可能的形式:

用户 电影 评分
0: Jo A: 怪兽电力公司 5
B: 波恩身份 2
C: 火星救援 2
D: 银河系漫游指南 1
1: Sam C: 火星救援 4
D: 银河系漫游指南 4
E: 矩阵 4
F: 梦幻特工 5
2: Chris B: 波恩身份 4
C: 火星救援 5
D: Blade Runner 5
F: Inception 4

对于每个示例,我们有一个用户、一部电影和一个分配的评分。为了找到每个用户之间的相似度,我们首先可以计算每对用户之间共享电影的欧几里得距离。欧几里得距离为最不相似的用户提供了较大的值;我们通过将 1 除以这个距离来反转这个值,得到一个结果,其中 1 代表完美匹配,0 表示用户之间最不相似。以下为欧几里得距离和计算两个用户之间相似度的函数的公式:

图片

欧几里得距离和相似度公式

func calcSimilarity(userRatingsA: [String:Float], userRatingsB:[String:Float]) -> Float{
  var distance = userRatingsA.map( { (movieRating) -> Float in 
    if userRatingsB[movieRating.key] == nil{
      return 0 
    }
    let diff = movieRating.value - (userRatingsB[movieRating.key] ?? 0)
    return diff * diff
  }).reduce(0) { (prev, curr) -> Float in 
    return prev + curr
  }.squareRoot()
  return 1 / (1 + distance)
}

为了使这一点更加具体,让我们通过以下步骤来找到与山姆最相似的用户,山姆对以下电影进行了评分:["The Martian" : 4, "Blade Runner" : 4, "The Matrix" : 4, "Inception" : 5]。现在,让我们计算山姆和乔之间的相似度,然后是山姆和克里斯之间的相似度。

山姆和乔

乔对电影的评分是["Monsters Inc." : 5, "The Bourne Identity" : 2, "The Martian" : 2, "Blade Runner" : 1];通过计算每个用户评分集合交集的相似度,我们得到一个值为0.22

山姆和克里斯

与之前类似,但现在,通过使用克里斯的电影评分(["The Bourne Identity" : 4, "The Martian" : 5, "Blade Runner" : 5, "Inception" : 4])来计算相似度,我们得到一个值为0.37

通过人工检查,我们可以看到克里斯与山姆的相似度高于乔,我们的相似度评分通过给克里斯一个比乔更高的值来显示这一点。

为了帮助说明为什么这有效,让我们将每个用户的评分投影到以下图表中,如图所示:

图片

上述图表显示了用户在偏好空间中的分布;在这个偏好空间中,两个用户越接近,他们的偏好就越相似。这里我们只展示了两个轴,但正如前表所示,这可以扩展到多个维度。

我们现在可以使用这些相似性作为权重,这些权重有助于预测特定用户会对特定电影给出何种评分。然后,利用这些预测,我们可以推荐一些用户可能想要观看的电影。

上述方法是一种聚类算法,属于无监督学习,在这种学习风格中,示例没有关联的标签,机器学习算法的职责是在数据中找到模式。其他常见的无监督学习算法包括 Apriori 算法(篮子分析)和 K-means。

当有大量信息可以受益于在呈现给用户之前进行过滤和排序时,推荐是适用的。在设备上执行推荐操作提供了许多好处,例如能够在过滤和排序结果时结合用户的上下文。

阴影绘制 – 为自由手绘提供实时用户指导

为了突出人与机器之间的协同作用,人工智能有时被称为增强智能AI),强调系统增强我们的能力而不是完全取代我们。

一个越来越受欢迎的领域,并且对我个人来说特别感兴趣的是辅助创作系统,这个领域位于人机交互HCI)和机器学习(ML)的交叉点。这些系统是为了辅助一些创作任务而创建的,例如绘画、写作、视频和音乐。

在本节中我们将讨论的例子是阴影绘制,这是一个由 Y.J. Lee、L. Zitnick 和 M. Cohen 在 2011 年于微软进行的研究项目。阴影绘制是一个通过匹配和定位现有物体数据集中的参考图像来辅助用户绘制的系统,然后在背景中轻柔地渲染阴影作为用户绘图的指南。例如,如果预测用户正在绘制一辆自行车,那么系统就会在用户的笔下方渲染指南来帮助他们绘制该物体,如图所示:

图片

就像我们之前做的那样,让我们来看看我们可能如何处理这个问题,具体关注对草图进行分类;也就是说,我们将预测用户正在绘制什么物体。这将给我们机会看到新的数据类型、算法和机器学习的应用。

本项目中使用的数据集由从互联网上通过 40 个类别查询(如人脸、汽车和自行车)收集的 30,000 张自然图像组成,每个类别存储在其自己的目录中;以下图表显示了这些图像的一些示例:

图片

在获得原始数据后,下一步,也是任何机器学习项目的典型步骤,是执行数据预处理特征工程。以下图表显示了预处理步骤,包括:

  • 重新缩放每张图像

  • 去饱和(变为黑白)

  • 边缘检测

图片

我们下一步是将我们的数据抽象成对我们的机器学习算法更有意义和有用的东西;这被称为特征工程,在典型的机器学习工作流程中是一个关键步骤。

一种方法,以及我们将描述的方法,是创建一个被称为视觉词袋的东西。这本质上是对描述每个图像以及集体描述每个类别的特征(视觉词)的直方图。构成特征的是什么取决于数据和机器学习算法;例如,我们可以提取并计算每张图像的颜色,其中颜色成为我们的特征,并集体描述我们的图像,如下面的图表所示:

图片

但因为我们处理的是草图,我们想要一个相当粗糙的东西——可以捕捉到将封装图像的一般结构的一般线条方向。例如,如果我们描述一个正方形和一个圆,正方形将包含水平和垂直线条,而圆将主要包含对角线条。为了提取这些特征,我们可以使用一种称为方向梯度直方图HOG)的计算机视觉算法;在处理完图像后,你将得到图像局部区域中的梯度方向直方图。这正是我们想要的!为了帮助说明这个概念,这里对单个图像的此过程进行了总结:

图片

在处理完我们数据集中的所有图像后,我们的下一步是找到一个(或多个)直方图,可以用来识别每个类别;我们可以使用一种称为K-means的无监督学习聚类技术,其中每个类别的直方图是该聚类的中心。以下图表描述了这一过程;我们首先为每个图像提取特征,然后使用 K-means 对这些特征进行聚类,其中距离是通过直方图梯度来计算的。一旦我们的图像被聚类到它们的组中,我们就提取每个组的中心(均值)直方图作为我们的类别描述符:

图片

一旦我们为每个类别(代码簿)获得了直方图,我们就可以使用每个图像提取的特征(视觉词)和相关的类别(标签)来训练一个分类器。一个流行且有效的分类器是支持向量机SVM)。SVM 试图找到一个最佳分离类别的超平面;这里的“最佳”指的是具有最大类别成员之间距离的平面。术语“超”是因为它将向量转换到高维空间,这样类别就可以用线性平面(因为我们在一个空间内工作)来分离。以下图表显示了在二维空间中两个类别可能看起来是什么样子:

图片

我们的模型现在已经训练完成,因此用户在绘制图像时,我们可以实时地对图像进行分类,这样我们就可以通过为他们提供想要绘制的对象(或至少提及我们预测他们将要绘制的对象)的指南来协助用户。这非常适合像 iPhone 或 iPad 这样的触摸界面!这不仅有助于绘图应用,而且在用户需要输入的任何时候,如基于图像的搜索或记笔记时,都非常有用。

在这个例子中,我们展示了如何使用特征工程和无监督学习来增强数据,使得我们的模型能够更充分地使用监督学习算法 SVM 进行分类。在深度神经网络出现之前,特征工程是机器学习中的一个关键步骤,有时也是这些原因的限制因素:

  • 这需要特殊技能和有时需要领域专业知识

  • 它取决于一个能够找到和提取有意义特征的人

  • 它要求提取的特征能够在整个人群中推广,也就是说,足够表达,可以应用于所有例子

在下一个例子中,我们介绍了一种称为卷积神经网络(CNNConvNet)的神经网络类型,它负责处理大量的特征工程。

描述实际项目和方法的论文可以在这里找到:vision.cs.utexas.edu/projects/shadowdraw/shadowdraw.html

Shutterstock – 基于构图进行图像搜索

在过去的 10 年里,我们在网络上看到了视觉内容的创建和消费呈爆炸式增长,但在 CNN 成功之前,图像是通过在手动分配的标签上执行简单的关键词搜索来找到的。所有这些都改变了,大约在 2012 年,A. Krizhevsky,I. Sutskever 和 G. E. Hinton 发表了他们的论文《使用深度卷积网络的 ImageNet 分类》。这篇论文描述了他们用于赢得 2012 年ImageNet 大规模视觉识别挑战赛(ILSVRC)的架构。这是一个像奥运会一样的计算机视觉竞赛,团队在一系列 CV 任务(如分类、检测和目标定位)中进行竞争。而且那一年,CNN 首次以 15.4%的测试错误率获得了第一名(下一个最佳参赛者的测试错误率为 26.2%)。从那时起,CNN 已经成为计算机视觉任务的默认方法,包括成为执行视觉搜索的新方法。很可能,它已经被 Google、Facebook 和 Pinterest 等公司采用,使得找到正确的图像变得比以往任何时候都容易。

最近,(2017 年 10 月),Shutterstock 宣布了一种 CNN(卷积神经网络)更为新颖的应用,他们引入了用户不仅能够在图像中搜索多个项目,还能搜索这些项目的构图的能力。以下截图展示了一个搜索小猫和电脑的例子,小猫位于电脑的左侧:

图片

那么,CNNs 是什么?如前所述,CNNs 是一种非常适合视觉内容的神经网络,因为它们能够保留空间信息。它们在某种程度上类似于之前的例子,其中我们明确定义了一个过滤器来从图像中提取局部特征。CNN 执行类似的操作,但与我们的前一个例子不同,过滤器不是明确定义的。它们是通过训练学习的,并且它们不是局限于单层,而是由许多层构建的。每一层都建立在上一层的基础上,每一层在它所代表的内容上变得越来越抽象(这里的抽象意味着更高阶的表示,即从像素到形状)。

为了帮助说明这一点,以下图表展示了网络如何建立对猫的理解。第一层的过滤器提取简单的特征,例如边缘和角落。下一层在这些基础上使用自己的过滤器,从而提取出更高层次的概念,例如形状或猫的各个部分。这些高层次的概念随后被组合起来用于分类目的:

图片

这种更深入理解数据并减少对手动特征工程依赖的能力,使得深度神经网络在过去几年中成为最受欢迎的机器学习算法之一。

为了训练模型,我们使用图像作为输入,标签作为预期输出,向网络提供示例。给定足够的示例,模型将为每个标签构建一个内部表示,这可以足够用于分类;当然,这是一种监督学习

我们最后一个任务是找到项目或项目的位置;为了实现这一点,我们可以检查网络的权重,以找出哪些像素激活了特定的类别,然后围绕具有最大权重的输入创建一个边界框。

我们现在已经识别了图像中的项目及其位置。有了这些信息,我们可以预处理我们的图像库,并将其作为元数据缓存,以便通过搜索查询访问。我们将在本书的后面部分再次探讨这个想法,届时你将有机会实现一个版本,以帮助用户在相册中找到图像。

在本节中,我们看到了如何使用机器学习来改善用户体验,并简要介绍了 CNNs 背后的直觉,这是一种非常适合视觉环境的神经网络,其中保留特征邻近性和构建更高层次的抽象很重要。在下一节中,我们将继续探索机器学习应用,通过介绍另一个提高用户体验的例子以及一种非常适合序列数据(如文本)的新类型的神经网络。

iOS 键盘预测 - 下一个字母预测

引用可用性专家 Jared Spool 的话,“好的设计,当做得好时,应该是无形的。”这对于机器学习也是正确的。机器学习的应用不必对用户明显,有时(更常见的情况)机器学习的微妙应用可以证明同样有影响力。

这的一个很好的例子是 iOS 的一个功能,称为动态目标调整大小;每次你在 iOS 键盘上输入时,它都在工作,它积极地尝试预测你想要输入的单词:

图片

使用这个预测,iOS 键盘会动态地改变键的触摸区域(在此由红色圆圈表示),这个键是基于之前输入的最可能字符。

例如,在先前的图中,用户输入了"Hell";现在合理地假设用户最可能点击的下一个字符是"o"。鉴于我们对英语语言的知识,这是直观的,但我们如何教会机器知道这一点呢?

这就是循环神经网络RNNs)发挥作用的地方;它是一种在时间上持续状态的神经网络。你可以将这种持续状态视为一种记忆形式,这使得 RNNs 非常适合处理序列数据,如文本(任何输入和输出相互依赖的数据)。这种状态是通过使用从细胞输出到反馈循环来创建的,如下面的图所示:

图片

先前的图显示了单个 RNN 细胞。如果我们将其展开到时间上,我们会得到如下所示的东西:

图片

hello为例,先前的图显示了在五个时间步长上的展开 RNN;在每一个时间步长,RNN 预测下一个可能的字符。这个预测是由其内部的语言表示(来自训练)和后续输入决定的。这种内部表示是通过在文本样本上训练来构建的,其中输出使用输入,但在下一个时间步(如前面所示)。一旦训练完成,推理遵循类似的路径,只是我们将预测的字符从输出馈送到网络,以获取下一个输出(以生成序列,即单词)。

神经网络和大多数机器学习算法需要它们的输入是数字,因此我们需要将我们的字符转换为数字,然后再转换回来。处理文本(字符和单词)时,通常有两种方法:独热编码嵌入。让我们快速了解每个方法,以获得一些处理文本的直觉。

文本(字符和单词)被认为是分类的,这意味着我们不能用一个单一的数字来表示文本,因为文本和价值之间没有固有的关系;也就是说,将the分配为 10 和cat分配为 20 意味着catthe更有价值。相反,我们需要将它们编码成一种不引入偏差的形式。一种解决方案是使用单热编码,它使用一个大小为你的词汇表(在我们的例子中是字符的数量)的数组,将特定字符的索引设置为 1,其余设置为 0。以下图展示了语料库"hello"的编码过程:

图片

在前面的图中,我们展示了编码字符时所需的步骤;我们首先将语料库拆分为单个字符(称为标记,这个过程称为标记化)。然后我们创建一个作为我们的词汇表集合,最后我们用每个字符分配一个向量来编码这个集合。

在这里,我们只展示在将文本传递给我们的机器学习算法之前所需的步骤。

一旦我们的输入被编码,我们就可以将它们输入到我们的网络中。输出也将以这种格式表示,其中最可能的字符是具有最大值的索引。例如,如果预测到'e',那么最可能的输出可能类似于[0.95, 0.2, 0.2, 0.1]。

但单热编码有两个问题。第一个问题是对于大型词汇表,我们最终得到一个非常稀疏的数据结构。这不仅是对内存的不高效使用,还需要额外的计算来进行训练和推理。第二个问题,当操作单词时更为明显,是我们编码后失去了任何上下文意义。例如,如果我们对单词dogdogs进行编码,那么编码后我们就会失去这些单词之间的任何关系。

一种替代方案,也是解决这两个问题的方案,是使用嵌入。这些通常是从训练好的网络中得到的权重,每个标记使用密集向量表示,这可以保留一些上下文意义。这本书主要关注计算机视觉任务,所以我们不会在这里详细介绍。只需记住,我们需要将我们的文本(字符)编码成我们的机器学习算法可以接受的形式。

我们使用弱监督来训练模型,类似于监督学习,但不需要显式标记来推断标签。一旦训练完成,我们可以使用前面描述的多类分类来预测下一个字符。

在过去几年里,我们见证了辅助写作的演变;一个例子是谷歌的智能回复(Smart Reply),它提供了一种端到端的方法来自动生成简短的电子邮件回复。令人兴奋的时代!

这结束了我们对机器学习问题类型及其相关数据类型、算法和学习风格的简要介绍。我们对每个方面都只是触及了皮毛,但随着你阅读本书,你将接触到更多数据类型、算法和学习风格。

在下一节中,我们将回顾训练和推理的整体工作流程,然后结束本章。

一个典型的机器学习工作流程

如果我们分析到目前为止所提供的每个示例,我们会看到每个都遵循一个类似的模式。首先是定义问题或所需的功能。一旦我们确定了我们想要做什么,我们就确定可用的数据以及/或所需的数据。有了数据在手,我们的下一步是创建我们的机器学习模型并为训练准备数据。

训练之后,我们在这里没有讨论的内容是验证我们的机器学习模型,即测试它是否满意地达到了我们的要求。一个例子是能够做出准确的预测。一旦我们训练了一个模型,我们就可以通过输入真实数据来利用它,即输入训练集之外的数据。在下面的图中,我们看到了训练和推理步骤的总结:

图片

在本书中,我们将大部分时间用于使用训练好的模型,但了解我们如何得到这些模型将有助于你开始创建自己的智能应用。这也有助于你识别在现有数据上应用机器学习的机会,或者激发你寻找新的数据来源。还值得注意的是,在训练数据上的预处理步骤与在推理时对输入数据进行预处理是等效的——这是我们将在整本书中花费大量时间讨论和编码的内容。

摘要

在本章中,我们通过将其与经典编程进行对比,介绍了机器学习及其价值。然后,我们花了一些时间探索了机器学习的不同应用,并对每种应用都推测了所使用的数据类型、算法和学习风格。采取这种方法是为了帮助揭开机器学习的工作原理,并鼓励你开始思考如何利用数据来改善用户体验或提供新的功能。我们将继续以这种方式贯穿整本书,显然会更多地强调通过与计算机视觉相关的示例应用来利用机器学习。

在下一章中,我们将介绍 Core ML,这是 iOS 专门设计的框架,旨在使那些对机器学习经验很少或没有经验的开发者能够使用机器学习。

第二章:Apple Core ML 简介

在本章中,我们将简要介绍我们将在这本书中使用的框架——Core ML。但在这样做之前,我们将详细阐述训练和推理是什么,特别是它们之间的区别;然后我们将探讨在边缘执行机器学习ML)的动机,即在您的 iOS 设备上。

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

  • 强调训练模型和使用模型进行推理之间的区别

  • 在边缘执行推理的动机和机会

  • 介绍 Core ML 和一般工作流程

  • 一些机器学习算法的简要介绍

  • 在开发具有机器学习功能的应用程序时需要考虑的一些事项

训练和推理的区别

训练和推理之间的区别类似于学生在学校学习代数等知识后将其应用于现实世界的情况。在学校,学生会做大量的练习;对于每个练习,学生会尝试回答问题,并将答案交给教师,教师会提供反馈,指出答案是否正确。最初,这种反馈可能倾向于学生答错多于答对,但经过多次尝试,随着学生对概念的理解开始建立,反馈逐渐转向主要是正确的。此时,学生被认为已经足够了解代数,能够将其应用于现实世界中的未见问题,在那里他/她可以根据在学校课程中提供的练习来自信地回答问题。

机器学习模型也没有不同;构建模型的初始阶段是通过训练过程完成的,在这个过程中,模型被提供了许多示例。对于每个示例,使用损失函数代替教师提供反馈,这反过来又用于调整模型以减少损失(模型答案错误的程度)。这个过程可能需要多次迭代,通常是计算密集型的,但它提供了并行化的机会(特别是对于神经网络);也就是说,很多计算可以并行进行。因此,通常在云端或一些具有足够内存和计算能力的专用机器上执行训练。以下图表展示了这个过程:

图片

为了更好地说明所需的计算能力,在博客文章《Cortana Intelligence and Machine Learning Blog》中,微软数据科学家 Miguel Fierro 和其他人详细介绍了在 ImageNet 数据集(1,000 个类别,超过 1.2 百万张照片)上使用 18 层 ResNet 架构进行训练的基础设施和时间要求。在一个配备 4 个 GPU、24 个 CPU 核心和 224 GB 内存的 Azure N-series NC-24 虚拟机上,大约需要三天时间训练超过 30 个 epoch。详细内容描述在这里:blogs.technet.microsoft.com/machinelearning/2016/11/15/imagenet-deep-neural-network-training-using-microsoft-r-server-and-azure-gpu-vms/

训练完成后,模型现在可以用于现实世界;就像我们的学生一样,我们现在可以部署并使用我们的模型来解决未见过的难题。这被称为推理。与训练不同,推理只需要通过模型进行单次遍历,使用其在训练中获得的认知,即权重和系数。此外,我们的模型中还有一些部分不再需要,因此可以进行一定程度的剪枝(减少不影响准确性的不那么重要的方面),以进一步优化模型:

图片

由于这些条件,单次遍历和剪枝,我们可以在性能较差的机器上执行推理,比如我们的智能手机。但为什么你想这样做呢?在边缘进行推理的优势是什么?这是下一节的主题。

边缘推理

对于那些不熟悉“边缘计算”这个术语的人来说,它简单指的是在网络末端或边缘进行的计算,而不是将其发送到中央服务器进行计算。边缘设备的例子包括汽车、机器人、物联网IoT)和当然,智能手机。

在数据所在边缘进行计算的动力在于,将数据通过网络发送是昂贵且耗时的;这种产生的延迟和成本限制了我们可以提供给用户的体验。移除这些障碍将开启原本不可能的新应用。在边缘进行推理的另一个好处是数据隐私;无需通过网络传输个人数据,减少了恶意用户获取它的机会。

幸运的是,技术以惊人的速度发展,硬件和软件的改进现在使得在边缘进行推理变得可行。

由于本书的重点是 iOS 上的应用机器学习;详细的模型架构和训练已被有意省略,因为当前的训练需要大量的计算能力,而这对于今天的大多数边缘设备来说仍然难以达到——尽管随着边缘设备的日益强大,这种情况可能会在不久的将来改变,最有可能的下一步进展将是使用设备上存储的个人数据调整和个性化模型。

设备上机器学习的常见用例包括:

  • 语音识别:目前通常在本地执行唤醒(或热)词检测,而不是在网络中持续传输数据。例如,“嘿,Siri”很可能是在本设备上本地执行的,一旦检测到,它将语音传输到服务器进行进一步处理。

  • 图像识别:设备能够理解它所看到的内容,以便协助用户拍照,例如应用适当的滤镜、添加标题以便更容易找到照片以及将相似图像分组,这些功能可能不足以证明连接到远程服务器是合理的。但由于这些功能可以在本地执行,我们可以使用它们而无需担心成本、延迟或隐私问题。

  • 目标定位:有时,知道视图中的内容以及它们在视图中的位置是有用的。这种例子可以在增强现实AR)应用中看到,其中信息被叠加到场景上。这些体验的响应性对于其成功至关重要,因此在进行推理时需要极低的延迟。

  • 光学字符识别:神经网络最早的商业应用之一,即使在 1989 年用于美国邮局时也依然有用。能够读取内容使得数字化物理副本或在其上进行计算成为可能;例如语言翻译或解决数独谜题。

  • 翻译:即使没有网络连接,也能快速准确地翻译一种语言到另一种语言,这是一个重要的用例,并且补充了我们之前讨论的许多基于视觉的场景,例如增强现实和光学字符识别。

  • 手势识别:手势识别为我们提供了一种丰富的交互模式,允许快速捷径和直观的用户交互,这可以改善和增强用户体验。

  • 文本预测:能够预测用户将要输入的下一个单词,甚至预测用户的响应,已经将一个相当繁琐且使用起来痛苦的东西(智能手机软键盘)变成了与它的对应物(传统键盘)一样快,甚至更快。能够在设备上执行这种预测提高了您保护用户隐私和提供响应式解决方案的能力。如果请求必须路由到远程服务器,这是不可行的。

  • 文本分类:这涵盖了从情感分析到主题发现的所有内容,并促进了许多有用的应用,例如为用户提供推荐相关内容或消除重复内容的方法。

这些用例和应用的示例可能展示了为什么我们可能想在边缘执行推理;这意味着您可以提供比在设备上执行推理更高的交互级别。它允许您在设备网络连接差或没有网络连接的情况下提供体验。最后,它是可扩展的——需求的增加并不直接与服务器负载相关。

到目前为止,我们已经介绍了推理及其在边缘执行的重要性。在下一节中,我们将介绍在 iOS 设备上促进这一功能的框架:Core ML。

Core ML 简介

随着 iOS 11 和 Core ML 的发布,执行推理只需几行代码。在 iOS 11 之前,推理是可能的,但需要一些工作来将预训练的模型通过现有的框架(如Acceleratemetal 性能着色器(MPSes))迁移过来。Accelerate和 MPSes 仍然在 Core ML 的底层使用,但 Core ML 负责决定您的模型应该使用哪个底层框架(对于内存密集型任务使用 CPU 的Accelerate,对于计算密集型任务使用 GPU 的 MPSes)。它还负责抽象掉很多细节;这一层抽象在以下图中展示:

图片

除了这些额外的层之外;iOS 11 引入并扩展了特定领域的层,这些层进一步抽象了您在处理图像和文本数据时可能使用的许多常见任务,例如人脸检测、对象跟踪、语言翻译和命名实体识别(NER)。这些特定领域的层封装在视觉自然语言处理(NLP)框架中;我们在这里不会深入探讨这些框架的细节,但在后面的章节中,您将有机会使用它们:

图片

值得注意的是,这些层并不是相互排斥的,你可能会发现自己同时使用它们,特别是那些提供有用的预处理方法的特定领域框架,我们可以在将数据发送到 Core ML 模型之前使用这些方法来准备数据。

那么,Core ML 究竟是什么呢?你可以将 Core ML 视为一套工具,用于简化将机器学习模型带到 iOS 并封装在标准接口中的过程,这样你就可以轻松地在代码中访问和使用它们。现在让我们更详细地看看使用 Core ML 时的典型工作流程。

工作流程

如前所述,机器学习工作流程的两个主要任务包括训练推理。训练涉及获取和准备数据,定义模型,然后进行实际训练。一旦你的模型在训练期间达到了令人满意的结果,并且能够进行适当的预测(包括对之前未见过的数据),那么你的模型就可以部署并使用训练集之外的数据进行推理。Core ML 提供了一套工具,以简化将训练好的模型导入 iOS 的过程,其中之一就是名为Core ML Tools的 Python 打包工具;它用于从一个众多流行的包中提取模型(包括架构和权重),并导出.mlmodel文件,然后可以将其导入你的 Xcode 项目中。

一旦导入,Xcode 将为模型生成一个接口,使其通过你熟悉的代码轻松访问。最后,当你构建你的应用程序时,模型将得到进一步优化并打包到你的应用程序中。生成模型的流程总结如下图所示:

图片

上一张图展示了创建.mlmodel的过程,无论是使用支持框架中的一个现有模型,还是从头开始训练。Core ML Tools 支持大多数框架,无论是内部还是第三方插件,包括 Keras、turi、Caffe、scikit-learn、LibSVN 和 XGBoost 框架。苹果公司还使这个包开源并模块化,以便于其他框架或你自己进行适配。导入模型的流程如下图所示:

图片

此外,还有一些与 Core ML 更紧密集成的框架,如Turi CreateIBM Watson Services for Core MLCreate ML,它们负责生成 Core ML 模型。

我们将在第十章介绍 Create ML;对于那些对 Turi Create 和 IBM Watson Services for Core ML 感兴趣并想了解更多信息的人,请通过以下链接访问官方网站:

Turi Create; github.com/apple/turicreate

IBM Watson Services for Core ML; developer.apple.com/ibm/

一旦模型被导入,如前所述,Xcode 会生成一个界面,该界面封装了 模型、模型 输入输出。你将在本书的其余部分熟悉这些内容,所以我们不会在这里进一步详细介绍。

在之前的图中,我们已经看到了训练和导入 模型 的工作流程——现在让我们深入了解这个模型是什么以及 Core ML 当前支持的内容。

学习算法

在 第一章,机器学习简介中,我们看到了许多不同类型的机器学习算法,并了解到机器学习实际上是一个在给定一组示例的情况下自动发现规则的过程。这个过程所需的主要组件,特别是对于监督学习,包括:

  • 输入数据点:对于图像分类,我们需要我们想要分类的领域的图像,例如,动物。

  • 对于这些输入的预期输出:继续我们之前关于动物图像分类的例子,预期输出可以是与每张图像关联的标签,例如,猫、狗以及更多。

  • 机器学习算法:这是用于自动学习如何将输入数据点转换为有意义输出的算法。这些通过学习过程(称为 训练)推导出的规则集就是我们所说的模型。

通过一个简单的例子来具体化这些概念。

瑞典汽车保险

如果你还没有这样做,请导航到 github.com/joshnewnham/MachineLearningWithCoreML 上的存储库并下载最新代码。下载完成后,导航到 Chapter2/Start/ 目录并打开 playground LinearRegression.playground

我们将创建一个模型,该模型将根据索赔数量(x)预测所有索赔的总支付额(y);我们将使用的数据集是瑞典的汽车保险索赔数据。它包含 2 列和 64 行,第一列包含索赔数量,第二列包含所有索赔的总支付额。以下是数据集的摘录:

索赔数量 所有索赔的总支付额(瑞典克朗千位)
108 329.5
19 46.2
13 15.7
124 422.2
... ...

更多详情,请访问源网站:college.cengage.com/mathematics/brase/understandable_statistics/7e/students/datasets/slr/frames/slr06.html

在 playground 脚本中,你会看到我们正在创建一个 ScatterPlotView 类型的视图,并将其分配给 playground 的实时视图。我们将使用这个视图来可视化模型的数据和预测:

let view = ScatterPlotView(frame: CGRect(x: 20, y: 20, width: 300, height: 300))

PlaygroundPage.current.liveView = view

通过使用这种视图,我们可以使用 view.scatter(dataPoints:) 方法绘制一系列数据点,并使用 view.line(pointA:,pointB) 方法绘制一条线。让我们加载数据并可视化它:

let csvData = parseCSV(contents:loadCSV(file:"SwedishAutoInsurance"))

let dataPoints = extractDataPoints(data: csvData, xKey: "claims", yKey: "payments")

view.scatter(dataPoints)

在前面的代码片段中,我们首先将数据加载到 csvData 变量中,然后将其转换为强类型数组 DataPoint(一个强类型数据对象,我们的视图所期望的)。一旦加载,我们就通过 scatter 方法将我们的数据传递给视图,它渲染以下输出:

图片

每个点代表一个单独的数据点,它与索赔数量(x轴)和所有索赔的总支付额(y轴)相对应。从这种可视化中,我们可以推断出索赔数量和所有索赔的总支付额之间的一些线性关系;也就是说,索赔数量的增加会导致所有索赔的总支付额增加。利用这种直觉,我们将尝试根据线性模型建模数据,这种模型在给定索赔数量时能够预测所有索赔的总支付额。我们在这里描述的是一种称为简单线性回归的算法;本质上,这只是在寻找最适合我们数据的直线。它可以描述为函数 y = w * x + b,其中 y 是所有索赔的总支付额,x 是索赔数量,wyx 之间的关系,而 b 是截距。

线性回归是一种回归模型,它将一组连续输入映射到一个连续输出上的线性函数。例如,你可能想要建模和预测房价;在这里,你的输入可能是卧室数量浴室数量。使用这两个特征,你将想要找到一个可以预测房价的函数,它假设存在线性相关性。

简单易懂!我们接下来的问题是找到最适合我们数据的这条线。为此,我们将使用一种称为梯度下降的方法;关于梯度下降的理论和技术细节有很多书籍进行深入探讨,所以在这里我们只介绍一些背后的直觉,并留给好奇的读者去研究细节。

梯度下降是一组算法,用于最小化一个函数;在我们的情况下,它们最小化输出与实际输出之间的损失。它们通过从一个初始参数集(权重或系数)开始,并迭代调整这些参数以最小化计算出的损失来实现这一点。这些调整的方向和幅度由预测值与预期误差以及参数贡献之间的差异决定。

你可以将梯度下降视为寻找某个最小点的搜索;决定这个最小点的东西被称为损失函数。对我们来说,它将是我们的预测与实际索赔数量之间的绝对误差。算法通过计算我们每个变量的相对贡献(这里是指wb)来引导。让我们通过train方法来看看它在代码中的样子:

func train(
    x:[CGFloat],
    y:[CGFloat],
    b:CGFloat=0.0,
    w:CGFloat=0.0,
    learningRate:CGFloat=0.00001,
    epochs:Int=100,
    trainingCallback: ((Int, Int, CGFloat, CGFloat) -> Void)? = nil) -> (b:CGFloat, w:CGFloat){

    var B = b // bias
    var W = w // weight

    let N = CGFloat(x.count) // number of data points

    for epoch in 0...epochs{
        // TODO: create variable to store this epoch's gradient for b and w
        for i in 0..<x.count{
            // TODO: make a prediction (using the linear equation y = b + x * w
            // TODO: calculate the absolute error (prediction - actual value)
            // TODO: calculate the gradient with respect to the error and b (); adding it to the epochs bias gradient
            // TODO: calculate the gradient with respect to the error and w (); adding it to the epochs weight gradient
        }
        // TODO: update the bias (B) using the learningRate
        // TODO: update the weight (W) using the learningRate
        if let trainingCallback = trainingCallback{
            trainingCallback(epoch, epochs, W, B)
        }
    }

    return (b:B, w:W)
}   

我们的train方法接受以下参数:

  • x:一个包含索赔数量的DataPoint数组

  • y:一个包含总支付次数的DataPoint数组

  • b:这是在我们的线性函数中用于开始搜索的随机值

  • w:在我们的线性函数中用于开始搜索的另一个随机值

  • learningRate:我们调整权重的速度

  • epochs:我们迭代的次数,也就是说,进行预测,并根据预测值和期望值之间的差异调整我们的系数

  • trainingCallback:这个函数在每个时代之后被调用以报告进度

接下来,我们创建一些将在整个训练过程中使用的变量,并开始搜索(for epoch in 0...epochs)。让我们逐步通过每个TODO并将它们替换为相应的代码。

首先,我们创建两个变量来保存变量bw的梯度(这些是我们需要对其各自的系数进行调整以最小化损失,也称为绝对误差):

// TODO: create variable to store this epoch's gradient for b and w
var bGradient : CGFloat = 0.0
var wGradient : CGFloat = 0.0

接下来,我们遍历每个数据点,并对每个数据点进行预测,然后计算绝对误差:

// TODO: make a prediction (using the linear equation y = b + x * w
let yHat = W * x[i] + B
// TODO: calculate the absolute error (prediction - actual value)
let error = y[i] - yHat

现在,计算关于误差的偏导数。将这视为引导搜索正确方向的方法,也就是说,计算这个值给我们提供了改变bw以最小化我们的误差所需的方向大小

注意,这是在遍历所有数据点之后完成的;也就是说,它受到所有数据点的影响。另一种选择是针对每个数据点或子集(称为批次)执行此更新。

// TODO: calculate the gradient with respect to the error and b (); adding it to the epochs bias gradient
B = B - (learningRate * bGradient)

// TODO: calculate the gradient with respect to the error and w (); adding it to the epochs weight gradient 
W = W - (learningRate * wGradient)

在遍历每个数据点之后,我们使用它们的累积梯度调整系数BW

每经过一个时代,就会调用trainingCallback来使用当前模型的系数(其当前最佳拟合线,该线适合数据)绘制一条线;这个过程在下面的图中显示:

诚然,没有关键信息很难解释!但希望模式会很明显;随着每次迭代的进行,我们的线将更好地拟合数据。经过 100 个时代后,我们得到这个模型:

描述这条线的函数是y = 0.733505317339142 + 3.4474988368438 * x。使用这个模型,我们可以根据索赔数量(只需将x替换为索赔数量)预测所有索赔的总支付额

支持的学习算法

在前面的例子中,我们使用线性回归(算法)构建了一个模型,该模型根据索赔数量(输入)预测所有索赔的总支付额(输出)。这是许多机器学习算法之一;以下图表中绘制了一些算法,分为无监督监督,以及连续分类

图片

创建 Core ML 模型的过程涉及将模型从源框架转换为可以在 iOS 上运行的形式。以下图表显示了 Core ML 目前支持的学习算法:

图片

支持的算法和神经网络应该足够灵活,以适应大多数机器学习任务,但鉴于这个领域发展迅速,你不可避免地会遇到一些不受支持的算法。苹果公司已经预见到这一点,并提供了两种扩展框架的协议;MLCustomLayer可用于创建自定义层(我们将在后续章节中介绍)和MLCustomModel用于创建自定义模型。

这可能让你对 Core ML 在一般机器学习工作流程中的位置以及苹果为何做出这些设计决策有了些了解。我们将通过查看在 iOS 设备上处理机器学习时的一些高级考虑因素来结束本章,或者更普遍地说,在总结之前,关注边缘计算。

考虑因素

在边缘执行机器学习时,你会失去在更强大的设备上运行时通常拥有的某些奢侈(尽管这一切都在不断变化)。以下是一些需要记住的考虑因素:

  • 模型大小:之前,我们介绍了构建一个简单的线性回归模型。该模型本身由两个浮点数(偏差和权重系数)组成,当然在内存需求方面是可以忽略不计的。但是,当你深入到深度学习的世界时,你会发现模型的大小通常是几百兆字节。例如,VGG16 模型是一个 16 层的传统神经网络架构,在用于图像分类的 ImageNet 数据集上训练,可在苹果网站上找到。它的大小正好超过 500 兆字节。目前,苹果允许应用的大小为 2 吉字节,但要求用户下载如此大的文件可能会让他们望而却步。

  • 内存:你需要注意的不仅仅是可执行文件的大小,还有可用的工作内存量。桌面机器通常具有 16-32 吉字节的内存,但最新 iPhone(iPhone 8)的内存仅为 2 吉字节——对于一个移动设备来说非常令人印象深刻,但与其对应设备相比,差距很大。这种限制可能会决定你选择哪种模型,而不是它在磁盘上占用的内存量。还值得一提的是,你需要加载到内存中的不仅仅是模型权重;你还需要加载任何标签数据,当然,还有你正在执行推理的输入数据。

  • 速度:这当然与模型大小(在正常情况下)相关,并适用于您的特定用例。只需记住,执行推理只是工作流程的一部分。您还需要考虑预处理和后处理任务,例如加载和预处理输入数据。在某些情况下,您可能需要在准确性和性能、大小之间进行权衡。

  • 支持算法和数据类型:在前一节中,我们介绍了 Core ML 当前支持的算法。除此之外,Core ML 还支持一部分数据类型,以下表格中为了方便起见进行了总结:

输入类型 数据类型
数字 Double, Int64
类别 String, Int64
图像 CVPixelBuffer
数组 MLMultiArray
字典 [String : Double], [Int64, Double]

在这里,我们仅从高层次上介绍了在移动设备上执行机器学习时的一些考虑因素。具体细节将取决于您的用例和可用的模型,但值得将这些考虑因素放在心中,并提醒自己,尽管这些设备功能强大,但它们仍然是移动设备。它们运行在电池上,因此需要考虑通常对移动项目所需的典型考虑和优化。对于计划创建自己模型的人来说,这些考虑因素尤为重要,如果您的计划是利用机器学习。

摘要

在本章中,我们讨论了训练和推理之间的区别,以及典型的机器学习工作流程以及 Core ML 如何融入其中。我们还看到了 Core ML 不仅仅是一个框架,而是一套工具,它有助于将预训练模型引入 iOS 平台,并通过熟悉且简单的界面使它们对您的应用程序可用。因此,它使机器学习民主化,并将其交到许多 iOS 应用开发者的手中。

有观点认为,各种应用的激增有助于智能手机采用的成功;如果这是真的,那么请准备好迎接下一个增强型 AI 应用的爆炸式增长。并且请放心,您正处于开始和引领这一旅程的完美位置,我们将探索许多与 Core ML 相关的计算机视觉概念和示例,包括以下内容:

  • 通过摄像头视频流识别物体

  • 利用目标检测构建智能图像搜索,允许您搜索具有特定物体及其相对位置的图像

  • 识别面部表情并推断人的情绪状态

  • 使用卷积神经网络识别手绘草图,然后使用循环神经网络

  • 学习 Prisma 风格转换背后的秘密并实现自己的版本

  • 最后,使用图像分割创建动作镜头效果

有很多内容需要了解,让我们开始吧!

第三章:识别世界中的物体

在本章中,我们将通过研究可能被认为是 101 Core ML 应用程序的内容,深入到机器学习(ML)和 Core ML 的世界中。我们将使用一个图像分类模型,使用户能够将 iPhone 对准任何物体,并让应用识别视图中最占主导地位的物体。

我们将首先讨论卷积神经网络ConvNetsCNNs),这是一种非常适合图像分类的神经网络类别,然后再进入实现阶段。从骨架项目开始,你很快就会发现,在 Core ML 的帮助下,将机器学习集成到你的应用中是多么容易。

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

  • 获得一些关于机器如何理解图像的直观认识

  • 构建本章的示例应用程序

  • 捕获照片帧并在传递给 Core ML 模型之前对其进行预处理

  • 使用 Core ML 模型进行推理并解释结果

卷积神经网络通常被称为 CNNs 或 ConvNets,这两个术语在这本书中是互换使用的。

理解图像

如前所述,我的意图不是给你一个特定 ML 算法的理论或深入理解,而是温和地介绍一些主要概念。这将帮助你获得对它们如何工作的直观理解,以便你知道在哪里以及如何应用它们,同时为你提供一个深入了解特定主题的平台,我强烈建议你这样做。

对于一本关于深度学习的优秀入门书籍,我强烈推荐 Andrew Trask 的书《Grokking Deep Learning》。对于 ML 的一般介绍,我建议 Toby Segaran 的书《Programming Collective Intelligence: Building Smart Web 2.0 Applications》。

在本节中,我们将介绍 CNNs,特别是介绍它们是什么以及为什么它们非常适合空间数据,即图像。但在讨论 CNNs 之前,我们将首先检查数据;然后我们将看到为什么 CNNs 比它们的对应物,即全连接神经网络(或简称神经网络)表现得更好。

为了说明这些概念,考虑以下任务:对以下数字进行分类,其中每个数字都表示为 5 x 5 像素矩阵。深灰色像素的值为 1,浅灰色像素的值为 0:

图片

使用全连接神经网络(单个隐藏层),我们的模型将学习每个像素与其相关标签的联合概率;也就是说,模型将为与标签相关的像素分配正权重,并使用最可能性的输出作为最可能的标签。在训练过程中,我们将每个图像展平,然后将其输入到我们的网络中,如下面的图所示:

图片

这效果非常好,如果你有机器学习,尤其是深度学习的经验,你很可能已经遇到过 MNIST 数据集。这是一个包含标记的手写数字数据集,每个数字都中心渲染成 28 x 28 的灰度图(单通道,像素值范围从 0-255)。使用单层全连接网络很可能会得到接近 90%的验证准确率。但是,如果我们引入一些复杂性,比如将图像移动到一个更大的空间中,如图所示,会发生什么呢?

图片

全连接网络没有空间或局部关系的概念;在这种情况下,模型需要学习每个数字在每个可能位置的所有变体。为了进一步强调能够捕捉空间数据关系的重要性,考虑需要学习更复杂的图像,例如使用丢弃 2D 信息的网络来对猫和狗进行分类。单独的像素无法描绘出眼睛、鼻子或耳朵等复杂形状;只有当你考虑相邻像素时,你才能描述这些更复杂的形状:

图片

图片来自 Kaggle 竞赛猫狗对比(https://www.kaggle.com/c/dogs-vs-cats)

我们需要一种能够从原始像素中抽象出来的东西,能够使用高级特征来描述图像。让我们回到我们的数字数据集,并研究我们如何可能提取用于分类任务的高级特征。正如在先前的例子中提到的,我们需要一组从原始像素中抽象出来的特征,不受位置影响,并保留 2D 空间信息。如果你熟悉图像处理,或者甚至图像处理工具,你很可能已经遇到过边缘检测边缘滤波器的概念和结果;最简单的说法,这些是通过在整个图像上传递一组核来实现,输出是强调边缘的图像。让我们看看这图示上是如何的。首先,我们有我们的核集;每个核提取图像的特定特征,例如水平边缘、垂直边缘或 45 度角的边缘:

图片

对于这些滤波器中的每一个,我们将它们应用到我们的图像上,提取每个特征;为了帮助说明这一点,让我们取一个数字并将垂直核应用到它上面:

图片

如前图所示,我们将水平核在图像上滑动,使用图像和核的值生成一个新的图像。我们继续这样做,直到达到图像的边界,如下图所示:

图片

这的输出是一个显示图像内检测到的垂直线的映射。使用这个和其他内核,我们现在可以用每个类的主导梯度来描述每个类,而不是使用像素位置。这个更高层次的抽象使我们能够独立于位置识别类别,以及描述更复杂的对象。

在处理内核时,有两个需要注意的有用事项是步长值和填充。步长决定了你在滑动内核跨过图像时步长的大小。在上一个例子中,我们的步长设置为 1;也就是说,我们只滑动一个值。填充指的是你如何处理边界;在这里,我们使用valid,这意味着我们只处理有效范围内的像素。same则意味着在图像周围添加一个边界,以确保输出大小与输入相同。

我们在这里所做的是所谓的特征工程,这是神经网络自动执行的事情;特别是,这正是 CNN 所做的事情。它们创建一系列内核(或卷积矩阵),用于通过卷积图像从相邻像素中提取局部特征。与我们的先前工程示例不同,这些内核是在训练期间学习的。因为它们是自动学习的,我们可以创建许多过滤器,这些过滤器可以提取图像的细微差别,从而允许我们有效地堆叠卷积层。这允许我们学习更高层次的抽象。例如,第一层可能学会检测简单的边缘,而第二层(在先前提取的特征上操作)可能学会提取简单的形状。我们走得越深,我们的特征达到的层次就越高,如图所示:

图片

就这样!一个能够通过学习特征和抽象层来高效描述世界的架构。现在,让我们通过使用预训练模型和 Core ML 来实践,让我们的手机能够识别它所看到的物体。

识别世界中的物体

为了回顾,我们本章的目标是创建一个能够识别它所看到的应用程序。我们将首先捕获视频帧,为我们的模型准备这些帧,最后将它们输入到 Core ML 模型中进行推理。让我们开始吧。

数据采集

如果你还没有这样做,请从配套仓库下载最新代码:github.com/packtpublishing/machine-learning-with-core-ml。下载后,导航到目录Chapter3/Start/ObjectRecognition/并打开项目ObjectRecognition.xcodeproj。加载后,你将看到本章的骨架项目,如下面的截图所示:

图片

为了帮助您在项目中导航,以下是一个核心文件/类及其主要功能的列表:

  • VideoCapture 将负责管理和处理摄像头,包括捕获视频帧

  • CaptureVideoPreviewView.swift 包含 CapturePreviewView 类,它将被用来展示捕获的帧

  • CIImageCIImage 类提供了便利的扩展,用于准备帧以供 Core ML 模型使用

  • 如您所期望的,VideoController 是应用程序的控制器,负责与导入的 Core ML 模型进行接口交互

我们将在接下来的章节中对这些进行修改,以实现所需的功能。我们的第一个任务将是获取对摄像头的访问权限并开始捕获帧;为此,我们将利用 Apple 的 iOS 框架 AVFoundationCoreVideo

AVFoundation 框架包含用于在 iOS 和其他平台上处理捕获、处理、合成、控制、导入和导出音视频媒体类的集合。在本章中,我们最感兴趣的是该框架的一个子集,用于处理摄像头和媒体捕获,但您可以在 Apple 的官方文档网站上了解更多关于 AVFoundation 框架的信息:developer.apple.com/documentation/avfoundation

CoreVideo 提供了一个基于管道的 API,用于操作数字视频,能够通过 Metal 和 OpenGL 的支持来加速处理过程。

我们将指定将设置和从摄像头捕获帧的责任分配给 VideoCapture 类;现在让我们直接进入代码。从左侧面板中选择 VideoCapture.swift 以在编辑窗口中打开。在做出修改之前,让我们检查已经存在的内容以及还需要完成的工作。

在类的顶部,我们定义了 VideoCaptureDelegate 协议:

public protocol VideoCaptureDelegate: class {
    func onFrameCaptured(
      videoCapture: VideoCapture, 
      pixelBuffer:CVPixelBuffer?, 
      timestamp:CMTime)
}

VideoCapture 将将捕获的帧传递给已注册的代理,从而使 VideoCapture 类能够专注于捕获帧的任务。我们传递给代理的是对自身的引用、类型为 CVPixelBuffer 的图像数据(捕获帧)以及类型为 CMTime 的时间戳。CVPixelBuffer 是 CoreVideo 专门用于存储像素数据的数据结构,也是我们的 Core ML 模型所期望的数据结构(我们将在稍后看到)。CMTime 是一个用于封装时间戳的结构体,我们将直接从视频帧中获取它。

在协议下,我们有 VideoCapture 类的骨架。在本节中,我们将逐步介绍它,以及一个扩展来实现 AVCaptureVideoDataOutputSampleBufferDelegate 协议,我们将使用它来捕获帧:

public class VideoCapture : NSObject{
    public weak var delegate: VideoCaptureDelegate?
    public var fps = 15
    var lastTimestamp = CMTime()
    override init() {
        super.init()
    }
    private func initCamera() -> Bool
    {
        return true
    }
    public func asyncStartCapturing(
        completion: (() -> Void)? = nil)
        {
         }
    public func asyncStopCapturing(
        completion: (() -> Void)? = nil)
        {
        }
}

大部分内容应该是显而易见的,所以我只会强调不那么明显的地方,从变量fpslastTimestamp开始。我们使用这些变量一起控制我们向代理传递帧的速率;我们这样做是因为我们假设我们捕获帧的速度远快于我们处理它们的速度。为了避免我们的摄像头出现延迟或跳跃,我们明确限制向代理传递帧的速率。每秒帧数fps)设置这个频率,而lastTimestamp则与计算自上次处理帧以来经过的时间一起使用。

我在这里要强调的代码的另一部分是asyncStartCapturingasyncStopCapturing方法;正如其名称所暗示的,这些方法分别负责启动和停止捕获会话。因为它们都将使用阻塞方法,这可能会花费一些时间,所以我们将任务从主线程派发出去,以避免阻塞它并影响用户体验。

最后,我们有扩展;它实现了AVCaptureVideoDataOutputSampleBufferDelegate协议:

extension VideoCapture : AVCaptureVideoDataOutputSampleBufferDelegate{
    public func captureOutput(_ output: AVCaptureOutput,
                              didOutput sampleBuffer: CMSampleBuffer,
                              from connection: AVCaptureConnection)
    {
    }
}

我们将在稍后讨论细节,但基本上是我们分配给摄像头以处理摄像头传入帧的代理。然后我们将它代理到分配给这个类的VideoCaptureDelegate代理。

现在我们来逐步实现这个类的各个方法,从initCamera方法开始。在这个方法中,我们想要设置一个管道,该管道将从设备的物理摄像头捕获帧并将它们传递到我们的代理方法。我们通过首先获取物理摄像头的引用,然后将其包装在AVCaptureDeviceInput类的一个实例中来实现这一点,该类负责管理与物理摄像头的连接和通信。最后,我们为帧添加一个目的地,这是我们在其中使用AVCaptureVideoDataOutput类的一个实例的地方,我们将自己指定为接收这些帧的代理。这个管道被包裹在称为AVCaptureSession的东西中,它负责协调和管理这个管道。

现在我们定义一些我们将需要的实例变量;在类VideoCapture内部添加以下变量:

let captureSession = AVCaptureSession()
let sessionQueue = DispatchQueue(label: "session queue")

我们之前提到了captureSession的目的,但也介绍了一个DispatchQueue。当将代理添加到AVCaptureVideoDataOutput(用于处理新帧的到来)时,你也会传递一个DispatchQueue;这允许你控制帧在哪个队列上被管理。在我们的例子中,我们将处理图像的操作从主线程上移除,以避免影响用户界面的性能。

现在我们已经声明了实例变量,我们将把注意力转向initCamera方法,将其分解成小的代码片段。在方法体内添加以下内容:

captureSession.beginConfiguration()       
captureSession.sessionPreset = AVCaptureSession.Preset.medium 

我们通过调用方法 beginConfigurationcaptureSession 信号我们想要批量配置多个配置;这些更改不会在我们通过调用会话的 commitConfiguration 方法提交之前生效。然后,在下一行代码中,我们设置所需的品质级别:

guard let captureDevice = AVCaptureDevice.default(for: AVMediaType.video) else {
    print("ERROR: no video devices available")
    return false
}

guard let videoInput = try? AVCaptureDeviceInput(device: captureDevice) else {
    print("ERROR: could not create AVCaptureDeviceInput")
    return false
}

if captureSession.canAddInput(videoInput) {
    captureSession.addInput(videoInput)
}

在下一个代码片段中,我们获取物理设备;在这里,我们获取默认的能够录制视频的设备,但您也可以轻松地搜索具有特定功能(如前置摄像头)的设备。在成功获取设备后,我们将它包装在一个 AVCaptureDeviceInput 的实例中,该实例将负责从物理摄像头捕获数据并将其最终添加到会话中。

我们现在必须添加这些帧的目的地;再次,将以下片段添加到 initCamera 方法中,您之前停止的地方:

let videoOutput = AVCaptureVideoDataOutput()

let settings: [String : Any] = [
    kCVPixelBufferPixelFormatTypeKey as String: NSNumber(value: kCVPixelFormatType_32BGRA)
]
videoOutput.videoSettings = settings
videoOutput.alwaysDiscardsLateVideoFrames = true
videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)

if captureSession.canAddOutput(videoOutput) {
    captureSession.addOutput(videoOutput)
}

videoOutput.connection(with: AVMediaType.video)?.videoOrientation = .portrait

在之前的代码片段中,我们创建、设置并添加了我们的输出。我们首先实例化 AVCaptureVideoDataOutput 的一个实例,然后定义我们想要的数据。在这里,我们请求全彩(kCVPixelFormatType_32BGRA),但根据您的模型,请求灰度图像(kCVPixelFormatType_8IndexedGray_WhiteIsZero)可能更有效率。

alwaysDiscardsLateVideoFrames 设置为 true 意味着在调度队列忙碌时到达的任何帧都将被丢弃——这对于我们的示例来说是一个理想的功能。然后,我们使用方法 videoOutput.setSampleBufferDelegate(self, queue: sessionQueue) 将我们自己以及我们的专用调度队列指定为处理传入帧的代理。一旦我们配置了输出,我们就可以将其作为配置请求的一部分添加到会话中。为了防止我们的图像被旋转 90 度,我们随后请求图像以竖直方向显示。

添加最终语句以提交这些配置;只有在我们这样做之后,这些更改才会生效:

captureSession.commitConfiguration()

这现在完成了我们的 initCamera 方法;让我们迅速(请原谅这个双关语)转到负责启动和停止此会话的方法。将以下代码添加到 asyncStartCapturing 方法的主体中:

sessionQueue.async {
    if !self.captureSession.isRunning{
       self.captureSession.startRunning()
    }

    if let completion = completion{
        DispatchQueue.main.async {
            completion()
        }
    }
 }

如前所述,startRunningstopRunning 方法都会阻塞主线程,并且可能需要一些时间来完成;因此,我们将它们在主线程之外执行,再次避免影响用户界面的响应性。调用 startRunning 将启动从订阅的输入(摄像头)到订阅的输出(代理)的数据流。

如果有任何错误,将通过通知 AVCaptureSessionRuntimeError 报告。您可以使用默认的 NotificationCenter 订阅以监听它。同样,您可以通过通知 AVCaptureSessionDidStartRunningAVCaptureSessionDidStopRunning 分别订阅以监听会话开始和停止。

类似地,将以下代码添加到 asyncStopCapturing 方法中,该方法将负责停止当前会话:

sessionQueue.async {
    if self.captureSession.isRunning{
        self.captureSession.stopRunning()
    }

    if let completion = completion{
        DispatchQueue.main.async {
            completion()
         }
     }
 }

initCamera方法中,我们使用语句videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)将自己注册为代理以处理到达的帧;现在让我们关注如何处理它。如您所回忆的那样,我们在captureOutput方法中为VideoCapture类添加了一个扩展来实现AVCaptureVideoDataOutputSampleBufferDelegate协议。添加以下代码:

guard let delegate = self.delegate else{ return }

 let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)

 let elapsedTime = timestamp - lastTimestamp
 if elapsedTime >= CMTimeMake(1, Int32(fps)) {

 lastTimestamp = timestamp

 let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)

 delegate.onFrameCaptured(videoCapture: self,
 pixelBuffer:imageBuffer,
 timestamp: timestamp)
 }

在浏览这段代码片段之前,值得提一下这个方法接收了哪些参数以及我们如何使用它们。第一个参数,output,其类型为AVCaptureVideoDataOutput,它引用了与此帧相关的输出。下一个参数,sampleBuffer,其类型为CMSampleBuffer,这是我们用来访问当前帧数据的。与帧一起,每个帧关联的持续时间、格式和时间戳也可以获得。最后一个参数,connection,其类型为AVCaptureConnection,它提供了与接收到的帧相关联的连接的引用。

现在,让我们逐行分析代码,我们首先检查是否有未分配的代理,并在必要时提前返回。然后我们确定自上次处理帧以来是否已经过去了足够的时间,记住我们正在限制处理帧的频率以确保流畅的体验。在这里,我们不是使用系统时钟,而是通过语句let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)获取与最新帧相关的时间;这确保了我们是在相对于帧的相对时间而不是系统的绝对时间进行测量。鉴于已经过去了足够的时间,我们继续通过语句CMSampleBufferGetImageBuffer(sampleBuffer)获取样本的图像缓冲区引用,最后将其传递给指定的代理。

现在已经完成了VideoCapture类的编写;让我们继续使用ViewController将其连接到我们的视图。但在跳入代码之前,让我们通过故事板检查接口,以便更好地理解我们将在哪里展示视频流。在 Xcode 中,从左侧的项目导航器面板中选择Main.storyboard以打开界面构建器;打开后,您将看到一个类似于以下截图的布局:

图片

没有什么复杂的;我们有一个标签来显示我们的结果,还有一个视图来渲染我们的视频帧。如果您选择 VideoPreview 视图并检查分配给它的类,您将看到我们有一个名为 CapturePreviewView 的自定义类来处理渲染。让我们跳入这个类的代码并进行必要的修改:

import AVFoundation
 import UIKit

 class CapturePreviewView: UIView {

 }

幸运的是,AVFoundation 提供了一个专门用于从摄像头渲染帧的 CALayer 子类;我们剩下的工作就是重写视图的 layerClass 属性并返回适当的类。将以下代码添加到 CapturePreviewView 类中:

override class var layerClass: AnyClass {
    return AVCaptureVideoPreviewLayer.self
}

这种方法在视图创建的早期阶段就被称作,用于确定要实例化哪个 CALayer 并将其与该视图关联。正如之前提到的,AVCaptureVideoPreviewLayer ——正如其名称所暗示的那样——专门用于处理视频帧。为了获取渲染的帧,我们只需将 AVCaptureSession 赋值为 AVCaptureVideoPreviewLayer.session 属性。现在让我们来做这件事;首先在 Xcode 中打开 ViewController 类,并添加以下变量(粗体):

@IBOutlet var previewView:CapturePreviewView!
@IBOutlet var classifiedLabel:UILabel!

 let videoCapture : VideoCapture = VideoCapture()

previewViewclassifiedLabel 是通过 Interface Builder 与界面关联的现有变量。在这里,我们创建了一个 VideoCapture 的实例,这是我们之前实现的。接下来,我们将使用 VideoCapture 实例设置并启动摄像头,然后将会话分配给我们的 previewView 层。在 ViewDidLoad 方法下的 super.viewDidLoad() 语句中添加以下代码:

if self.videoCapture.initCamera(){
 (self.previewView.layer as! AVCaptureVideoPreviewLayer).session = self.videoCapture.captureSession

 (self.previewView.layer as! AVCaptureVideoPreviewLayer).videoGravity = AVLayerVideoGravity.resizeAspectFill

 self.videoCapture.asyncStartCapturing()
 } else{
 fatalError("Failed to init VideoCapture")
 }

大部分的代码应该对你来说都很熟悉,因为其中很多都是使用我们刚刚实现的方法。首先我们初始化摄像头,调用 VideoCamera 类的 initCamera 方法。然后,如果成功,我们将创建的 AVCaptureSession 分配给层的会话。我们还向层暗示了我们希望它如何处理内容,在这种情况下是填充屏幕同时尊重其宽高比。最后,我们通过调用 videoCapture.asyncStartCapturing() 启动摄像头。

现在一切完成,是时候测试一切是否正常工作了。如果你在 iOS 11+ 设备上构建和部署,你应该能在手机屏幕上看到渲染的视频帧。

在下一节中,我们将介绍如何在执行推理(识别)之前,如何捕获和处理这些帧以供我们的模型使用。

数据预处理

在这个阶段,我们的应用正在渲染来自摄像头的帧,但我们还没有接收到任何帧。为了做到这一点,我们将自己设置为接收这些帧,正如前一个章节中实现的那样。现有的 ViewController 类已经有一个扩展实现了 VideoCaptureDelegate 协议。剩下要做的就是将我们自己设置为 VideoCapture 实例的代理并实现回调方法的细节;以下是为 extension 编写的代码:

extension ViewController : VideoCaptureDelegate{
    func onFrameCaptured(videoCapture: VideoCapture,
     pixelBuffer:CVPixelBuffer?,
     timestamp:CMTime){
     }
 }

根据你的编码风格,你同样可以在主类内部实现协议。我倾向于使用扩展来实现协议——这是一个个人偏好。

首先,让我们将自己设置为代理以开始接收帧;在 ViewController 类的 ViewDidLoad 方法中,在我们初始化摄像头之前添加以下语句:

self.videoCapture.delegate = self

现在我们已经将自己指定为代理,我们将通过回调接收帧(在定义的帧率下):

func onFrameCaptured(videoCapture: VideoCapture,
 pixelBuffer:CVPixelBuffer?,
 timestamp:CMTime){
 // TODO
 }

在这个方法中,我们将准备并喂给模型数据以对帧内的主要对象进行分类。模型期望的内容取决于模型本身,因此为了更好地了解我们需要传递什么,让我们下载我们将用于此示例的训练好的模型并将其导入到我们的项目中。

训练好的模型可以从各种来源获得;在某些情况下,您可能需要将它们进行转换,而在其他情况下,您可能需要自己训练模型。但在这个例子中,我们可以利用苹果公司提供的模型;打开您的网络浏览器,导航到 developer.apple.com/machine-learning/

您将被带到苹果公司提供了一系列预训练和转换模型的网页。方便的是,大多数可用的模型都是专门用于对象分类的;鉴于我们的用例,我们特别感兴趣的是在大量对象上训练的模型。我们的选项包括 MobileNet、SqueezeNet、ResNet50、Inception v3 和 VGG16。其中大多数都是在 ImageNet 数据集上训练的,这是一个包含超过 1000 万个图像的参考数据集,这些图像已被手动分配到 1000 个类别之一。可以通过“查看原始模型详细信息”链接获取对原始研究论文和性能的引用。对于这个例子,我们将使用 Inception v3,它在大小和准确性之间取得了良好的平衡。

在这里,我们使用的是 Inception v3 模型,但更换模型的工作量很小;它需要更新引用,因为生成的类名都带有模型名称的前缀,正如您很快就会看到的,并确保您符合模型预期的输入(这可以通过使用 Vision 框架来缓解,您将在未来的章节中看到)。

点击“下载 Core ML 模型”链接以继续下载,下载完成后,将 Inceptionv3.mlmodel 文件拖放到 Xcode 左侧的“项目导航器”面板上,如果需要则检查“复制项目”,否则保持默认设置。从左侧的项目导航器面板中选择 Inceptionv3.mlmodel 文件,以便在编辑器区域中显示详细信息,如下面的截图所示:

确保模型被正确分配到适当的目标是重要的;在这个例子中,这意味着验证 ObjectRecognition 目标是否被选中,如右边的工具面板上所见。还值得注意的是模型的预期输入和输出。在这里,模型期望一个 299 x 299 大小的彩色图像作为输入,并返回一个字符串形式的单个类标签以及所有类别的字符串-双精度浮点数概率字典。

当导入 .mlmodel 文件时,Xcode 将为模型本身以及输入和输出参数生成包装器,以便与模型接口;这在此处得到了说明:

图片

你可以通过在模型类部分旁边的箭头按钮上轻触来轻松访问它;轻触后,你会看到以下代码(分为三个不同的块以提高可读性):

@available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *)
 class Inceptionv3Input : MLFeatureProvider {

 /// Input image to be classified as color (kCVPixelFormatType_32BGRA) image buffer, 299 pixels wide by 299     pixels high
 var image: CVPixelBuffer

 var featureNames: Set<String> {
     get {
         return ["image"]
     }
 }

 func featureValue(for featureName: String) -> MLFeatureValue? {
     if (featureName == "image") {
         return MLFeatureValue(pixelBuffer: image)
     }
     return nil
 }

 init(image: CVPixelBuffer) {
     self.image = image
     }
 }

上述代码的第一个块是我们模型的输入。这个类实现了 MLFeatureProvider 协议,该协议代表模型的一组特征值,在这种情况下,是图像特征。在这里,你可以看到预期的数据结构,CVPixelBuffer,以及注释中声明的具体细节。让我们继续检查生成的类,看看输出的绑定:

@available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *)
 class Inceptionv3Output : MLFeatureProvider {

 /// Probability of each category as dictionary of strings to doubles
 let classLabelProbs: [String : Double]

 /// Most likely image category as string value
 let classLabel: String

 var featureNames: Set<String> {
     get {
         return ["classLabelProbs", "classLabel"]
     }
 }

 func featureValue(for featureName: String) -> MLFeatureValue? {
     if (featureName == "classLabelProbs") {
         return try! MLFeatureValue(dictionary: classLabelProbs as [NSObject : NSNumber])
     }
     if (featureName == "classLabel") {
         return MLFeatureValue(string: classLabel)
     }
     return nil
}

 init(classLabelProbs: [String : Double], classLabel: String) {
     self.classLabelProbs = classLabelProbs
     self.classLabel = classLabel
     }
 }

如前所述,输出公开了一个概率目录和一个表示主导类的字符串,每个都作为属性公开或可以通过传递特征名称的 getter 方法 featureValue(for featureName: String) 访问。我们最后提取生成的代码是模型本身;现在让我们检查一下:

@available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *)
 class Inceptionv3 {
 var model: MLModel

 /**
 Construct a model with explicit path to mlmodel file
 - parameters:
 - url: the file url of the model
 - throws: an NSError object that describes the problem
 */
 init(contentsOf url: URL) throws {
 self.model = try MLModel(contentsOf: url)
 }

 /// Construct a model that automatically loads the model from the app's bundle
 convenience init() {
 let bundle = Bundle(for: Inceptionv3.self)
 let assetPath = bundle.url(forResource: "Inceptionv3", withExtension:"mlmodelc")
 try! self.init(contentsOf: assetPath!)
 }

 /**
 Make a prediction using the structured interface
 - parameters:
 - input: the input to the prediction as Inceptionv3Input
 - throws: an NSError object that describes the problem
 - returns: the result of the prediction as Inceptionv3Output
 */
 func prediction(input: Inceptionv3Input) throws -> Inceptionv3Output {
 let outFeatures = try model.prediction(from: input)
 let result = Inceptionv3Output(classLabelProbs: outFeatures.featureValue(for: "classLabelProbs")!.dictionaryValue as! [String : Double], classLabel: outFeatures.featureValue(for: "classLabel")!.stringValue)
 return result
 }

 /**
 Make a prediction using the convenience interface
 - parameters:
 - image: Input image to be classified as color (kCVPixelFormatType_32BGRA) image buffer, 299 pixels wide by 299 pixels high
 - throws: an NSError object that describes the problem
 - returns: the result of the prediction as Inceptionv3Output
 */
 func prediction(image: CVPixelBuffer) throws -> Inceptionv3Output {
 let input_ = Inceptionv3Input(image: image)
 return try self.prediction(input: input_)
 }
 }

这个类封装了模型类,并为通过 prediction(input: Inceptionv3Input)prediction(image: CVPixelBuffer) 方法执行推理提供了强类型方法,每个方法都返回我们之前看到的输出类——Inceptionv3Output。现在,知道了我们的模型期望什么,让我们继续实现预处理功能,以便将捕获的帧输入到模型中。

Core ML 2 引入了一种处理批量的能力;如果你的模型是用 Xcode 10+ 编译的,你还将看到额外的 <CODE>func predictions(from: MLBatchProvider, options: MLPredictionOptions)</CODE> 方法,允许你对输入批次进行推理。

在这个阶段,我们知道我们正在接收正确的数据类型 (CVPixelBuffer) 和图像格式(在配置捕获视频输出实例时在设置中明确定义的 kCVPixelFormatType_32BGRA),来自相机。但我们接收到的图像比预期的 299 x 299 大得多。我们的下一个任务将是创建一些实用方法来进行调整大小和裁剪。

为了实现这一点,我们将扩展CIImage以包装和处理我们接收到的像素数据,并利用CIContext再次获取原始像素。如果您不熟悉 CoreImage 框架,那么可以说它是一个致力于高效处理和分析图像的框架。CIImage可以被认为是该框架的基数据对象,通常与CIFilterCIContextCIVectorCIColor等其他 CoreImage 类一起使用。在这里,我们关注CIImage,因为它提供了方便的方法来操作图像,以及与CIContext一起提取CIImage中的原始像素数据(CVPixelBuffer)。

在 Xcode 中,从项目导航器中选择CIImage.swift文件以在编辑器区域中打开它。在这个文件中,我们扩展了CIImage类,添加了一个负责缩放的方法以及一个返回原始像素(CVPixelBuffer)的方法,这是我们的 Core ML 模型所需的格式:

extension CIImage{

 func resize(size: CGSize) -> CIImage {
     fatalError("Not implemented")
 }

 func toPixelBuffer(context:CIContext,
 size insize:CGSize? = nil,
     gray:Bool=true) -> CVPixelBuffer?{
         fatalError("Not implemented")
     }
 }

让我们从实现resize方法开始;这个方法接收期望的大小,我们将使用它来计算相对比例;然后我们将使用这个比例来均匀缩放图像。将以下代码片段添加到resize方法中,替换fatalError("Not implemented")语句:

let scale = min(size.width,size.height) / min(self.extent.size.width, self.extent.size.height)

 let resizedImage = self.transformed(
 by: CGAffineTransform(
 scaleX: scale,
 y: scale))

除非图像是正方形,否则我们可能会在垂直或水平方向上出现溢出。为了处理这个问题,我们将简单地居中图像并将其裁剪到期望的大小;通过在resize方法中添加以下代码来实现(在前面代码片段下方):

let width = resizedImage.extent.width
 let height = resizedImage.extent.height
 let xOffset = (CGFloat(width) - size.width) / 2.0
 let yOffset = (CGFloat(height) - size.height) / 2.0
 let rect = CGRect(x: xOffset,
 y: yOffset,
 width: size.width,
 height: size.height)

 return resizedImage
 .clamped(to: rect)
 .cropped(to: CGRect(
 x: 0, y: 0,
 width: size.width,
 height: size.height))

我们现在有了调整图像大小的功能;我们下一个功能是获取CIImageCVPixelBuffer。让我们通过实现toPixelBuffer方法的主体来实现这一点。首先,让我们回顾一下方法签名,然后简要谈谈所需的功能:

func toPixelBuffer(context:CIContext, gray:Bool=true) -> CVPixelBuffer?{
     fatalError("Not implemented")
 }

此方法期望一个CIContext和一个标志,指示图像应该是灰度(单通道)还是全色;CIContext将用于将图像渲染到像素缓冲区(我们的CVPixelBuffer)。现在让我们逐步完善toPixelBuffer的实现。

在图像上所需的预处理(调整大小、灰度化和归一化)取决于 Core ML 模型及其训练数据。您可以通过在 Xcode 中检查 Core ML 模型来了解这些参数。如果您还记得,我们模型期望的输入是(图像颜色 299 x 299);这告诉我们 Core ML 模型期望图像是彩色(三个通道)且大小为 299 x 299。

我们首先创建我们将渲染图像的像素缓冲区;将以下代码片段添加到toPixelBuffer方法的主体中,替换fatalError("Not implemented")语句:

let attributes = [
 kCVPixelBufferCGImageCompatibilityKey:kCFBooleanTrue,
 kCVPixelBufferCGBitmapContextCompatibilityKey:kCFBooleanTrue
 ] as CFDictionary

 var nullablePixelBuffer: CVPixelBuffer? = nil
 let status = CVPixelBufferCreate(kCFAllocatorDefault,
 Int(self.extent.size.width),
 Int(self.extent.size.height),
 gray ? kCVPixelFormatType_OneComponent8 : kCVPixelFormatType_32ARGB,
 attributes,
 &nullablePixelBuffer)

 guard status == kCVReturnSuccess, let pixelBuffer = nullablePixelBuffer
 else { return nil }

我们首先创建一个数组来保存定义像素缓冲区兼容性要求的属性;在这里,我们指定我们希望我们的像素缓冲区与CGImage类型兼容(kCVPixelBufferCGImageCompatibilityKey)以及与 CoreGraphics 位图上下文兼容(kCVPixelBufferCGBitmapContextCompatibilityKey)。

我们接下来创建一个像素缓冲区,传入我们的兼容性属性、格式(取决于gray的值,可以是灰度或全色)、宽度、高度以及变量的指针。然后解包可空的像素缓冲区,并确保调用成功;如果这些中的任何一个为false,则返回NULL。否则,我们就可以将CIImage渲染到新创建的像素缓冲区中。将以下代码追加到toPixelBuffer方法中:

CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))

 context.render(self,
 to: pixelBuffer,
 bounds: CGRect(x: 0,
 y: 0,
 width: self.extent.size.width,
 height: self.extent.size.height),
 colorSpace:gray ?
 CGColorSpaceCreateDeviceGray() :
 self.colorSpace)

 CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))

 return pixelBuffer

在绘制之前,我们通过CVPixelBufferLockBaseAddress锁定像素缓冲区的地址,然后使用CVPixelBufferUnlockBaseAddress方法解锁。当我们从 CPU 访问像素数据时,我们必须这样做,我们在这里就是这样做的。

一旦锁定,我们只需使用CIContext将缩放后的图像渲染到缓冲区,传入目标矩形(在这种情况下,像素缓冲区的完整大小)和目标颜色空间,这取决于之前提到的gray的值。在解锁像素缓冲区后,如前所述,我们返回我们新创建的像素缓冲区。

我们现在扩展了CIImage,添加了两个方便的方法,一个负责缩放,另一个负责创建其自身的像素缓冲区表示。我们现在将返回到ViewController类,处理在将数据传递给模型之前所需的预处理步骤。在 Xcode 的项目导航面板中选择ViewController.swift文件以显示源代码,并在ViewController类的主体中添加以下变量:

let context = CIContext()

如前所述,我们将将其传递给CIImage.toPixelBuffer方法以将图像渲染到像素缓冲区。现在返回到onFrameCaptured方法,并添加以下代码,以使用我们刚刚创建的预处理方法:

guard let pixelBuffer = pixelBuffer else{ return }

 // Prepare our image for our model (resizing)
 guard let scaledPixelBuffer = CIImage(cvImageBuffer: pixelBuffer)
 .resize(size: CGSize(width: 299, height: 299))
 .toPixelBuffer(context: context) else{ return }

我们首先解包pixelBuffer,如果它是NULL则返回;然后我们创建一个CIImage实例,传入当前帧,然后使用我们的扩展方法执行缩放(299 x 299)并将渲染输出到像素缓冲区(将灰度参数设置为 false,因为模型期望全色图像)。如果成功,我们将返回一个准备好的图像,可以传递给我们的模型进行推理,这是下一节的重点。

进行推理

对于期待一些硬核编码的人来说,这可能有点令人失望,但它的简单性无疑是对苹果工程师在使这个框架成为与机器学习模型工作最便捷方式之一所付出的努力的致敬。无需多言,让我们把最后几块拼图放在一起;我们从之前章节导入的模型实例化开始。

ViewController类的主体部分靠近顶部的地方,添加以下行:

let model = Inceptionv3()

我们的模式现在准备好了;我们回到onFrameCaptured方法,从我们之前离开的地方开始,并添加以下代码片段:

let prediction = try? self.model.prediction(image:scaledPixelBuffer)

 // Update label
 DispatchQueue.main.sync {
 classifiedLabel.text = prediction?.classLabel ?? "Unknown"
 }

如果您错过了,我在进行推理的地方使用了粗体。就是这样!

执行推理后,我们只需将classLabel属性(概率最高的类别)分配给我们的UILabelclassifiedLabel

在放置好最后一块拼图后,我们构建并部署。看看我们的应用程序表现如何,识别我们附近的一些物体。在你完成对空间的调查后,回到这里,我们将结束本章,并继续更伟大、更令人印象深刻的例子。

摘要

在本章中,我们介绍了对象识别,这是使用 Core ML 的机器学习 101 项目。我们花了一些时间介绍 CNN 或卷积神经网络,这是一类非常适合从图像中提取模式的神经网络。我们讨论了它们如何通过每个卷积层构建越来越高的抽象层次。然后我们利用我们新获得的知识,通过实现允许我们的应用程序通过其摄像头识别物理世界的功能来使用它。我们亲眼看到,大部分工作不是执行推理,而是实现促进和利用它的功能。这就是我们的收获;智能本身并没有用。我们在这本书中感兴趣的是将训练好的模型应用于提供直观和智能的体验。例如,这个例子可以轻松地变成一个语言辅导助手,使用户通过观察周围的世界来学习一门新语言。

在下一章中,我们将继续我们的 Core ML 计算机视觉之旅,看看我们如何通过识别某人的面部表情来推断他们的情绪状态。让我们开始吧。

第四章:使用卷积神经网络进行情感检测

直到最近,与计算机的交互与与,比如说,电动工具的交互并没有太大的区别;我们拿起它,打开它,手动控制它,然后放下,直到下一次我们需要它来完成那个特定任务。但最近,我们看到了这种状况即将改变的迹象;计算机允许自然形式的交互,并且正在变得更加普遍、更强大、更深入到我们的日常生活中。它们正在变得越来越不像无情的笨拙工具,更像朋友,能够娱乐我们,照顾我们,并帮助我们完成工作。

随着这种转变,计算机需要能够理解我们的情绪状态。例如,你不想在你失业(被一个 AI 机器人取代)下班回家后,你的社交机器人还开玩笑(!)。这是一个被称为情感计算(也称为人工情感智能情感 AI)的计算机科学领域,该领域研究能够识别、解释、处理和模拟人类情绪的系统。这一阶段的第一步是能够识别情绪状态,这是本章的主题。我们将首先介绍我们将使用的数据和模型,然后介绍我们如何处理 iPhone 上的表情识别问题以及如何适当地预处理数据以进行推理。

到本章结束时,你将实现以下目标:

  • 构建了一个简单的应用程序,该程序将使用前置摄像头实时推断您的情绪

  • 通过使用Vision框架获得了实践经验

  • 卷积神经网络CNNs)的工作原理以及它们如何在边缘应用有了更深入的理解和直觉

让我们先介绍我们将使用的数据和模型。

面部表情

我们的面部是我们情绪的最强指示器之一;当我们笑或哭时,我们将我们的情绪展示出来,让他人窥视我们的内心。这是一种非言语交流形式,据称,它占我们与他人交流的 50%以上。四十个独立控制的肌肉使面部成为我们拥有的最复杂的系统之一,这也可能是我们将其用作传达我们当前情绪状态这样重要信息媒介的原因。但我们能对其进行分类吗?

2013 年,国际机器学习会议ICML)举办了一场竞赛,邀请参赛者使用超过 28,000 张灰度图像的训练数据集构建面部表情分类器。它们被标记为愤怒、厌恶、恐惧、快乐、悲伤、惊讶或中性。以下是一些训练数据的样本(可在www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge找到):

如前所述,训练数据集包含 28,709 张 48 x 48 像素的灰度人脸图像,其中每个面部居中,并关联一个定义分配情感的标签。这种情感可以是以下标签之一(添加了文本描述以提高可读性):

图片

神经网络(或任何其他机器学习算法)实际上不能自己做什么。神经网络所做的只是找到两个数据集(输入及其相应的输出)之间的直接或间接相关性。为了使神经网络学习,我们需要向它展示两个有意义的、输入和输出之间存在真实相关性的数据集。在处理任何新的数据问题时,一个好的做法是提出一个预测理论,说明你如何接近它或使用数据可视化或其他探索性数据分析技术来寻找相关性。这样做,我们也能更好地理解我们需要如何准备我们的数据,以便与训练数据对齐。

让我们看看可以应用于训练数据的数据可视化技术的结果;在这里,我们假设每个表情(快乐、悲伤、愤怒等)之间都存在某种模式。一种视觉检查这种方法的方式是通过平均每个表情及其相关的方差。这可以通过找到所有图像的相应类(表情示例、快乐、愤怒等)的平均值和标准差来实现。以下图像显示了某些表情的结果:

图片

在你克服了图像的诡异感之后,你会感觉到确实存在某种模式,并理解我们的模型需要学习什么才能识别面部表情。从这个练习中得出的其他一些值得注意的、相当明显的结果包括厌恶表情的方差量;这暗示我们的模型可能难以有效地学习识别这种表情。另一个观察结果——并且更适用于我们本章的任务——是训练数据由面向前的面部图像组成,面部周围几乎没有填充,因此突出了模型期望的输入。现在我们对我们数据有了更好的了解;让我们继续介绍本章我们将使用的模型。

在第三章,“世界中的物体识别”,我们介绍了 CNN 或卷积神经网络背后的直觉。因此,鉴于我们本章不会介绍任何新概念,我们将省略对模型细节的任何讨论,只是在这里提供参考,并对其架构和期望输入数据格式进行一些评论:

图片

上一张图是模型架构的可视化;它是一个典型的 CNN,在扁平化并输入一系列全连接层之前,有一堆卷积和池化层。最后,它被输入到一个 softmax 激活层,用于多类分类。如前所述,模型期望一个维度为 48 x 48 x 1 的 3D 张量(宽度、高度、通道)。为了避免将大数值(0 - 255)输入到我们的模型中,输入已经被归一化(每个像素除以 255,得到 0.0 - 1.0 的范围)。模型输出给定输入相对于每个类的概率,即每个类代表给定输入相关性的概率。为了做出预测,我们只需选择概率最大的类。

该模型在 22,967 个样本上进行了训练,其余 5,742 个样本用于验证。经过 15 个 epoch 后,模型在验证集上达到了大约 59%的准确率,成功挤进了 Kaggle 竞赛的第 13 名(在撰写本章时)。以下图表显示了训练过程中的准确率和损失:

图片

这就结束了我们对本章将使用的数据和模型的简要介绍。两个主要的收获是对模型在训练期间所喂食的数据的欣赏,以及我们的模型仅达到了 59%的准确率。

前者决定了我们在将数据输入模型之前如何获取和处理数据。后者提供了一个进一步研究的机会,以更好地理解是什么因素拉低了准确率以及如何提高它;它也可以被视为一个设计挑战——一个围绕这个限制条件的设计。

在本章中,我们主要关注前者,因此,在下一节中,我们将探讨如何在将数据输入模型之前获取和预处理数据。让我们开始吧。

输入数据和预处理

在本节中,我们将实现预处理功能,以将图像转换为模型所期望的形式。我们将在 Playground 项目中逐步构建这个功能,然后在下一节将其迁移到我们的项目中。

如果您还没有这样做,请从配套仓库中拉取最新代码:github.com/packtpublishing/machine-learning-with-core-ml。下载完成后,导航到目录Chapter4/Start/并打开 Playground 项目ExploringExpressionRecognition.playground。加载完成后,您将看到本章的 Playground,如下面的截图所示:

图片

在开始之前,为了避免看到我的照片,请将测试图像替换为你的个人照片或来自互联网的免费照片,理想情况下是一组表达各种情绪的照片。

除了测试图像外,这个游乐场还包括一个编译好的 Core ML 模型(我们在上一张图片中介绍了它)及其生成的输入、输出和模型本身的包装器。还包括一些对UIImageUIImageViewCGImagePropertyOrientation和空CIImage扩展的扩展,我们将在本章后面返回。其他扩展提供了帮助我们在游乐场中可视化图像的实用函数。

在跳入代码之前,让我们快速讨论我们将采取的方法,以确定我们实际上需要实现的内容。

到目前为止,我们执行机器学习的过程相当直接;除了对输入数据进行一些格式化外,我们的模型不需要太多工作。这里的情况并非如此。典型的人的照片通常不仅仅是一个面部,除非你正在处理护照照片,否则他们的面部通常不会很好地对齐到框架中。在开发机器学习应用时,你有两条主要路径。

第一种方法,正变得越来越流行,是使用一种端到端机器学习模型,它只需接收原始输入并产生足够的结果。在端到端模型中取得巨大成功的特定领域之一是语音识别。在端到端深度学习之前,语音识别系统由许多较小的模块组成,每个模块都专注于提取特定的数据片段以供下一个模块使用,而下一个模块通常是手动设计的。现代语音识别系统使用端到端模型,接收原始输入并输出结果。以下图中可以见到这两种描述的方法:

图片

显然,这种方法不仅限于语音识别,我们还看到它被应用于图像识别任务以及其他许多任务。但是,有两件事使这个特定案例与众不同;第一是我们可以通过首先提取面部来简化问题。这意味着我们的模型需要学习的特征更少,提供了一个更小、更专业的模型,我们可以对其进行调整。第二件事,无疑很明显,是我们的训练数据仅由面部组成,而不是自然图像。因此,我们别无选择,只能将我们的数据通过两个模型运行,第一个用于提取面部,第二个用于对提取的面部进行表情识别,如图所示:

图片

幸运的是,对于我们的第一个任务——通过 iOS 11 发布的Vision框架来检测面部——苹果公司已经为我们处理得差不多了。《Vision》框架提供了高性能的图像分析和计算机视觉工具,通过简单的 API 暴露出来。这使得面部检测、特征检测和跟踪,以及图像和视频中的场景分类成为可能。后者(表情识别)是我们将使用之前介绍过的 Core ML 模型来处理的。

在引入Vision框架之前,面部检测通常是通过 Core Image 过滤器来完成的。更早之前,你需要使用类似 OpenCV 的工具。你可以在这里了解更多关于 Core Image 的信息: developer.apple.com/library/content/documentation/GraphicsImaging/Conceptual/CoreImaging/ci_detect_faces/ci_detect_faces.html.

现在我们已经对需要完成的工作有了鸟瞰图,让我们将注意力转向编辑器,开始将这些内容组合起来。首先加载图片;将以下代码片段添加到你的 playground 中:

var images = [UIImage]()
for i in 1...3{
    guard let image = UIImage(named:"images/joshua_newnham_\(i).jpg")
        else{ fatalError("Failed to extract features") }

    images.append(image)
}

let faceIdx = 0 
let imageView = UIImageView(image: images[faceIdx])
imageView.contentMode = .scaleAspectFit

在前面的代码片段中,我们只是简单地加载我们资源文件夹Images中包含的每一张图片,并将它们添加到一个我们可以方便地在整个 playground 中访问的数组中。一旦所有图片都加载完毕,我们设置一个常量faceIdx,这将确保我们在整个实验中访问相同的图片。最后,我们创建一个ImageView来轻松预览它。一旦运行完成,点击右侧面板中的眼睛图标来预览加载的图片,如图所示:

接下来,我们将利用Vision框架中提供的功能来检测面部。使用Vision框架时的典型流程是定义一个请求,这决定了你想要执行哪种分析,以及定义处理程序,它将负责执行请求并提供获取结果的方式(通过代理或显式查询)。分析的结果是一系列观察结果,你需要将它们转换为适当的观察类型;每个这些的具体示例可以在以下内容中看到:

如前述图表所示,请求确定将执行哪种类型的图像分析;处理器使用请求或多个请求以及图像执行实际分析并生成结果(也称为观察结果)。这些结果可以通过属性或代理访问,如果已经分配的话。观察的类型取决于执行的请求;值得注意的是,Vision框架紧密集成到 Core ML 中,并在您与数据和过程之间提供另一层抽象和一致性。例如,使用分类 Core ML 模型将返回类型为VNClassificationObservation的观察结果。这层抽象不仅简化了事情,还提供了一种与机器学习模型一致工作的方法。

在前面的图中,我们展示了专门用于静态图像的请求处理器。Vision还提供了一个专门用于处理图像序列的请求处理器,这在处理如跟踪等请求时更为合适。以下图表展示了适用于此用例的一些具体请求和观察类型示例:

那么,何时使用VNImageRequestHandlerVNSequenceRequestHandler?尽管名称提供了何时使用一个而不是另一个的线索,但概述一些差异是值得的。

图像请求处理器用于交互式探索图像;它在其生命周期内保持对图像的引用,并允许优化各种请求类型。序列请求处理器更适合执行如跟踪等任务,并且不对图像上的多个请求进行优化。

让我们看看这一切在代码中的样子;将以下片段添加到您的游乐场中:

let faceDetectionRequest = VNDetectFaceRectanglesRequest()
let faceDetectionRequestHandler = VNSequenceRequestHandler()

在这里,我们只是创建请求和处理程序;如前述代码所述,请求封装了图像分析的类型,而处理器负责执行请求。接下来,我们将faceDetectionRequestHandler用于运行faceDetectionRequest;添加以下代码:

try? faceDetectionRequestHandler.perform(
    [faceDetectionRequest],
    on: images[faceIdx].cgImage!,
    orientation: CGImagePropertyOrientation(images[faceIdx].imageOrientation)) 

处理器的perform函数在失败时可能会抛出错误;因此,我们在语句的开头用try?包裹调用,并且可以查询处理器的error属性来识别失败的原因。我们向处理器传递一个请求列表(在本例中,只有我们的faceDetectionRequest),我们想要进行分析的图像,以及,最后,图像在分析期间可以使用的方向。

一旦分析完成,我们就可以通过请求本身的results属性来检查获得的观察结果,如下所示:

if let faceDetectionResults = faceDetectionRequest.results as? [VNFaceObservation]{
    for face in faceDetectionResults{
 // ADD THE NEXT SNIPPET OF CODE HERE
    }
}

观察的类型取决于分析;在这种情况下,我们期待一个VNFaceObservation。因此,我们将其转换为适当的类型,然后遍历所有观察结果。

接下来,我们将对每个识别到的脸部提取边界框。然后,我们将继续在图像中绘制它(使用在UIImageViewExtension.swift文件中找到的UIImageView扩展方法)。在前面代码中显示的for循环内添加以下代码块:

if let currentImage = imageView.image{
    let bbox = face.boundingBox

    let imageSize = CGSize(
        width:currentImage.size.width,
        height: currentImage.size.height)

    let w = bbox.width * imageSize.width
    let h = bbox.height * imageSize.height
    let x = bbox.origin.x * imageSize.width
    let y = bbox.origin.y * imageSize.height

    let faceRect = CGRect(
        x: x,
        y: y,
        width: w,
        height: h)

    let invertedY = imageSize.height - (faceRect.origin.y + faceRect.height)
    let invertedFaceRect = CGRect(
        x: x,
        y: invertedY,
        width: w,
        height: h)

    imageView.drawRect(rect: invertedFaceRect)
}

我们可以通过boundingBox属性获取每个脸部的边界框;结果是归一化的,因此我们需要根据图像的尺寸进行缩放。例如,你可以通过将boundingBox与图像的宽度相乘来获取宽度:bbox.width * imageSize.width

接下来,我们将反转 y 轴,因为 Quartz 2D 的坐标系与 UIKit 的坐标系相反,如图所示:

图片

我们通过从图像的高度中减去边界框的起点和高度来反转坐标,然后将这个值传递给我们的UIImageView以渲染矩形。点击右面板中与imageView.drawRect(rect: invertedFaceRect)语句对齐的“眼睛”图标以预览结果;如果成功,你应该看到以下类似的内容:

图片

反转脸部矩形的另一种方法是使用AfflineTransform,例如:

var transform = CGAffineTransform(scaleX: 1, y: -1)

transform = transform.translatedBy(x: 0, y: -imageSize.height)

let invertedFaceRect = faceRect.apply(transform)

这种方法代码更少,因此出错的机会也更少。所以,这是推荐的方法。之前采用长方法是为了帮助阐明细节。

现在我们快速转换一下,尝试另一种类型的请求;这次,我们将使用VNDetectFaceLandmarksRequest来分析我们的图像。它与VNDetectFaceRectanglesRequest类似,因为这个请求将检测脸部并暴露其边界框;但是,与VNDetectFaceRectanglesRequest不同,VNDetectFaceLandmarksRequest还提供了检测到的面部特征点。一个特征点是一个显著的面部特征,如你的眼睛、鼻子、眉毛、面部轮廓或任何其他可检测的特征,它描述了面部的一个重要属性。每个检测到的面部特征点由一组描述其轮廓(轮廓线)的点组成。让我们看看这看起来怎么样;在下面的代码中添加一个新的请求:

imageView.image = images[faceIdx]

let faceLandmarksRequest = VNDetectFaceLandmarksRequest()

try? faceDetectionRequestHandler.perform(
    [faceLandmarksRequest],
    on: images[faceIdx].cgImage!,
    orientation: CGImagePropertyOrientation(images[faceIdx].imageOrientation))

前面的代码片段应该对你来说很熟悉;它几乎与之前我们做的相同,但这次我们将VNDetectFaceRectanglesRequest替换为VNDetectFaceLandmarksRequets。我们还使用imageView.image = images[faceIdx]语句刷新了图像视图。像之前一样,让我们遍历每个检测到的观察结果并提取一些常见的特征点。首先创建外循环,如下面的代码所示:

if let faceLandmarkDetectionResults = faceLandmarksRequest.results as? [VNFaceObservation]{
    for face in faceLandmarkDetectionResults{
        if let currentImage = imageView.image{
            let bbox = face.boundingBox

            let imageSize = CGSize(width:currentImage.size.width,
                                   height: currentImage.size.height)

            let w = bbox.width * imageSize.width
            let h = bbox.height * imageSize.height
            let x = bbox.origin.x * imageSize.width
            let y = bbox.origin.y * imageSize.height

            let faceRect = CGRect(x: x,
                                  y: y,
                                  width: w,
                                  height: h)

        }
    }
}

到目前为止,代码看起来很熟悉;接下来,我们将查看每个里程碑。但首先,让我们创建一个函数来处理将我们的点从 Quartz 2D 坐标系转换为 UIKit 坐标系的转换。我们在faceRect声明相同的块中添加以下函数:

func getTransformedPoints(
    landmark:VNFaceLandmarkRegion2D,
    faceRect:CGRect,
    imageSize:CGSize) -> [CGPoint]{

    return landmark.normalizedPoints.map({ (np) -> CGPoint in
        return CGPoint(
            x: faceRect.origin.x + np.x * faceRect.size.width,
            y: imageSize.height - (np.y * faceRect.size.height + faceRect.origin.y))
    })
} 

如前所述,每个里程碑由一组描述该特定里程碑轮廓的点组成,并且,像我们之前的特征一样,这些点在 0.0 - 1.0 之间归一化。因此,我们需要根据相关的面部矩形对其进行缩放,这正是我们在前面的例子中所做的。对于每个点,我们对其进行缩放和转换,将其转换到适当的坐标系中,然后将映射后的数组返回给调用者。

现在让我们定义一些我们将用于可视化每个里程碑的常量;我们在刚刚实现的函数getTransformedPoints中添加以下两个常量:

let landmarkWidth : CGFloat = 1.5
let landmarkColor : UIColor = UIColor.red 

我们现在将逐步展示一些重要里程碑,展示我们如何提取特征,偶尔也会展示结果。让我们从左眼和右眼开始;在您刚刚定义的常量之后立即添加以下代码:

if let landmarks = face.landmarks?.leftEye {
    let transformedPoints = getTransformedPoints(
        landmark: landmarks,
        faceRect: faceRect,
        imageSize: imageSize)

    imageView.drawPath(pathPoints: transformedPoints,
                       closePath: true,
                       color: landmarkColor,
                       lineWidth: landmarkWidth,
                       vFlip: false)

    var center = transformedPoints
        .reduce(CGPoint.zero, { (result, point) -> CGPoint in
        return CGPoint(
            x:result.x + point.x,
            y:result.y + point.y)
    })

    center.x /= CGFloat(transformedPoints.count)
    center.y /= CGFloat(transformedPoints.count)
    imageView.drawCircle(center: center,
                         radius: 2,
                         color: landmarkColor,
                         lineWidth: landmarkWidth,
                         vFlip: false)
}

if let landmarks = face.landmarks?.rightEye {
    let transformedPoints = getTransformedPoints(
        landmark: landmarks,
        faceRect: faceRect,
        imageSize: imageSize)

    imageView.drawPath(pathPoints: transformedPoints,
                       closePath: true,
                       color: landmarkColor,
                       lineWidth: landmarkWidth,
                       vFlip: false)

    var center = transformedPoints.reduce(CGPoint.zero, { (result, point) -> CGPoint in
        return CGPoint(
            x:result.x + point.x,
            y:result.y + point.y)
    })

    center.x /= CGFloat(transformedPoints.count)
    center.y /= CGFloat(transformedPoints.count)
    imageView.drawCircle(center: center,
                         radius: 2,
                         color: landmarkColor,
                         lineWidth: landmarkWidth,
                         vFlip: false)
} 

希望从前面的代码片段中可以看出,我们通过查询面部观察的landmark属性来获取每个里程碑的引用,该属性本身引用了适当的里程碑。在前面的代码中,我们获取了leftEyerightEye里程碑的引用。对于每个里程碑,我们首先绘制眼睛的轮廓,如图所示:

接下来,我们遍历每个点以找到眼睛的中心,并使用以下代码绘制一个圆:

var center = transformedPoints
    .reduce(CGPoint.zero, { (result, point) -> CGPoint in
    return CGPoint(
        x:result.x + point.x,
        y:result.y + point.y)
})

center.x /= CGFloat(transformedPoints.count)
center.y /= CGFloat(transformedPoints.count)
imageView.drawCircle(center: center,
                     radius: 2,
                     color: landmarkColor,
                     lineWidth: landmarkWidth,
                     vFlip: false)

这稍微有些不必要,因为可用的里程碑之一是leftPupil,但我想要使用这个实例来强调检查可用里程碑的重要性。块的下一半关注于对右眼执行相同的任务;到结束时,您应该得到一个类似以下内容的图像,其中画有双眼:

让我们继续突出显示一些可用的里程碑。接下来,我们将检查面部轮廓和鼻子;添加以下代码:

if let landmarks = face.landmarks?.faceContour {
    let transformedPoints = getTransformedPoints(
        landmark: landmarks,
        faceRect: faceRect,
        imageSize: imageSize)

    imageView.drawPath(pathPoints: transformedPoints,
                       closePath: false,
                       color: landmarkColor,
                       lineWidth: landmarkWidth,
                       vFlip: false)
}

if let landmarks = face.landmarks?.nose {
    let transformedPoints = getTransformedPoints(
        landmark: landmarks,
        faceRect: faceRect,
        imageSize: imageSize)

    imageView.drawPath(pathPoints: transformedPoints,
                       closePath: false,
                       color: landmarkColor,
                       lineWidth: landmarkWidth,
                       vFlip: false)
}

if let landmarks = face.landmarks?.noseCrest {
    let transformedPoints = getTransformedPoints(
        landmark: landmarks,
        faceRect: faceRect,
        imageSize: imageSize)

    imageView.drawPath(pathPoints: transformedPoints,
                       closePath: false,
                       color: landmarkColor,
                       lineWidth: landmarkWidth,
                       vFlip: false)
}

模式现在应该很明显了;在这里,我们可以绘制faceContournosenoseCrest这些里程碑;完成这些后,您的图像应该看起来像以下这样:

作为练习,使用innerLipsouterLips里程碑绘制嘴唇(以及任何其他面部里程碑)。实现这一点后,您应该得到类似以下的内容:

在返回到我们的任务——对面部表情进行分类之前,让我们快速完成关于里程碑检测的一些实际用途的旁白(除了在脸上绘制或放置眼镜之外)。

如前所述,我们的训练集主要由正面朝前且方向相当直的图像组成。考虑到这一点,了解每个眼睛的位置的一个实际用途是能够对图像进行评估;也就是说,人脸是否足够在视野中并且方向正确?另一个用途是将人脸稍微重新定位,以便更好地与您的训练集匹配(考虑到我们的图像被缩小到 28 x 28,因此可以忽略一些质量损失)。

目前,我将这些实现的细节留给您,但通过使用两个眼睛之间的角度,您可以应用仿射变换来纠正方向,即旋转图像。

现在我们回到主要任务——分类;像之前一样,我们将创建一个VNDetectFaceRectanglesRequest请求来处理识别给定图像中的每个面部,并且对于每个面部,我们将在将其输入到我们的模型之前进行一些预处理。如果您还记得我们关于模型的讨论,我们的模型期望一个单通道(灰度)的人脸图像,大小为 48 x 48,其值在 0.0 和 1.0 之间归一化。让我们一步一步地通过这个任务的每个部分,从创建请求开始,就像我们之前做的那样:

imageView.image = images[faceIdx]
let model = ExpressionRecognitionModelRaw()

if let faceDetectionResults = faceDetectionRequest.results as? [VNFaceObservation]{
    for face in faceDetectionResults{
        if let currentImage = imageView.image{
            let bbox = face.boundingBox

            let imageSize = CGSize(width:currentImage.size.width,
                                   height: currentImage.size.height)

            let w = bbox.width * imageSize.width
            let h = bbox.height * imageSize.height
            let x = bbox.origin.x * imageSize.width
            let y = bbox.origin.y * imageSize.height

            let faceRect = CGRect(x: x,
                                  y: y,
                                  width: w,
                                  height: h)                        
        }
    }
}

现在您应该对前面的代码很熟悉了,唯一的区别在于我们模型的实例化(粗体语句):let model = ExpressionRecognitionModelRaw()。接下来,我们想要从图像中裁剪出人脸;为了做到这一点,我们需要编写一个实用函数来实现这一功能。由于我们希望将其应用到我们的应用程序中,让我们将其编写为CIImage类的一个扩展。在左侧面板中点击Sources文件夹内的CIImageExtension.swift文件以打开相关文件;目前,此文件只是一个空的扩展体,如下面的代码所示:

extension CIImage{
}

尝试在CIImage的体内添加以下代码片段以实现裁剪功能:

public func crop(rect:CGRect) -> CIImage?{
    let context = CIContext()
    guard let img = context.createCGImage(self, from: rect) else{
        return nil
    }
    return CIImage(cgImage: img)
}

在前面的代码中,我们只是创建了一个新的图像,该图像被限制为传入的区域;这个方法context.createCGImage返回一个CGImage,然后我们将其包装在CIImage中,然后再返回给调用者。在我们的裁剪方法处理完毕后,我们回到主 playground 源代码,并在之前声明的面部矩形之后添加以下代码片段以从我们的图像中裁剪人脸:

let ciImage = CIImage(cgImage:images[faceIdx].cgImage!)

let cropRect = CGRect(
    x: max(x - (faceRect.width * 0.15), 0),
    y: max(y - (faceRect.height * 0.1), 0),
    width: min(w + (faceRect.width * 0.3), imageSize.width),
    height: min(h + (faceRect.height * 0.6), imageSize.height))

guard let croppedCIImage = ciImage.crop(rect: cropRect) else{
    fatalError("Failed to cropped image")
} 

我们首先从CGImage(由UIImage实例引用)创建一个CIImage实例;然后我们填充我们的面部矩形。这样做的原因是为了更好地匹配我们的训练数据;如果你参考我们的先前实验,检测到的边界紧密地围绕着眼睛和下巴,而我们的训练数据则包含了一个更全面的视图。选定的数字是通过试错得到的,但我猜想眼睛之间的距离和面部高度之间可能存在某种统计相关的比率——也许吧。我们最后使用之前实现的crop方法裁剪我们的图像。

接下来,我们将图像(调整到模型期望的大小)进行缩放,但,同样,这个功能目前还没有可用。所以,我们的下一个任务!回到CIImageExtension.swift文件,并添加以下方法来处理缩放:

public func resize(size: CGSize) -> CIImage {
    let scale = min(size.width,size.height) / min(self.extent.size.width, self.extent.size.height)

    let resizedImage = self.transformed(
        by: CGAffineTransform(
            scaleX: scale,
            y: scale))

    let width = resizedImage.extent.width
    let height = resizedImage.extent.height
    let xOffset = (CGFloat(width) - size.width) / 2.0
    let yOffset = (CGFloat(height) - size.height) / 2.0
    let rect = CGRect(x: xOffset,
                      y: yOffset,
                      width: size.width,
                      height: size.height)

    return resizedImage
        .clamped(to: rect)
        .cropped(to: CGRect(
            x: 0, y: 0,
            width: size.width,
            height: size.height))
}

你可能会注意到,我们在这里没有像之前那样反转面部矩形;原因是我们之前只要求这样做,以便从 Quartz 2D 坐标系转换到 UIKit 坐标系,而我们在这里没有这样做。

尽管代码行数很多,但大部分代码都是关于计算使图像居中所需的缩放和转换。一旦我们计算出这些值,我们只需将一个包含我们的缩放值的CGAffineTransform传递给transformed方法,然后将居中对齐的矩形传递给clamped方法。现在这个功能已经实现,让我们回到主 playground 代码,通过以下行来使用它,对裁剪后的图像进行缩放:

let resizedCroppedCIImage = croppedCIImage.resize(
    size: CGSize(width:48, height:48))

在我们可以将数据传递给模型进行推理之前,还需要三个步骤。第一步是将它转换为单通道,第二步是将像素重新缩放,使它们的值在 0.0 和 1.0 之间,最后我们将它包裹在一个MLMultiArray中,然后我们可以将其喂给模型的predict方法。为了实现这一点,我们将向我们的CIImage类添加另一个扩展。它将使用单通道渲染图像,同时提取像素数据,并以数组的形式返回,这样我们就可以轻松地访问它进行缩放。回到CIImageExtension.swift文件,并添加以下方法:

public func getGrayscalePixelData() -> [UInt8]?{
    var pixelData : [UInt8]?

    let context = CIContext()

    let attributes = [
        kCVPixelBufferCGImageCompatibilityKey:kCFBooleanTrue,
        kCVPixelBufferCGBitmapContextCompatibilityKey:kCFBooleanTrue
        ] as CFDictionary

    var nullablePixelBuffer: CVPixelBuffer? = nil
    let status = CVPixelBufferCreate(
        kCFAllocatorDefault,
        Int(self.extent.size.width),
        Int(self.extent.size.height),
        kCVPixelFormatType_OneComponent8,
        attributes,
        &nullablePixelBuffer)

    guard status == kCVReturnSuccess, let pixelBuffer = nullablePixelBuffer
        else { return nil }

    CVPixelBufferLockBaseAddress(
        pixelBuffer,
        CVPixelBufferLockFlags(rawValue: 0))

    context.render(
        self,
        to: pixelBuffer,
        bounds: CGRect(x: 0,
                       y: 0,
                       width: self.extent.size.width,
                       height: self.extent.size.height),
        colorSpace:CGColorSpaceCreateDeviceGray())

    let width = CVPixelBufferGetWidth(pixelBuffer)
    let height = CVPixelBufferGetHeight(pixelBuffer);

    if let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) {
        pixelData = Array<UInt8>(repeating: 0, count: width * height)
        let buf = baseAddress.assumingMemoryBound(to: UInt8.self)
        for i in 0..<width*height{
            pixelData![i] = buf[i]
        }
    }

    CVPixelBufferUnlockBaseAddress(
        pixelBuffer,
        CVPixelBufferLockFlags(rawValue: 0))

    return pixelData
}

再次强调,不要被代码的数量吓倒;这个方法主要完成两个任务。第一个任务是将图像渲染到使用单通道灰度的CVPixelBuffer中。为了突出这一点,负责的代码如下所示:

public func getGrayscalePixelData() -> [UInt8]?{
    let context = CIContext()

    let attributes = [
        kCVPixelBufferCGImageCompatibilityKey:kCFBooleanTrue,
        kCVPixelBufferCGBitmapContextCompatibilityKey:kCFBooleanTrue
        ] as CFDictionary

    var nullablePixelBuffer: CVPixelBuffer? = nil
    let status = CVPixelBufferCreate(
        kCFAllocatorDefault,
        Int(self.extent.size.width),
        Int(self.extent.size.height),
        kCVPixelFormatType_OneComponent8,
        attributes,
        &nullablePixelBuffer)

    guard status == kCVReturnSuccess, let pixelBuffer = nullablePixelBuffer
        else { return nil }

    // Render the CIImage to our CVPixelBuffer and return it
    CVPixelBufferLockBaseAddress(
        pixelBuffer,
        CVPixelBufferLockFlags(rawValue: 0))

    context.render(
        self,
        to: pixelBuffer,
        bounds: CGRect(x: 0,
                       y: 0,
                       width: self.extent.size.width,
                       height: self.extent.size.height),
        colorSpace:CGColorSpaceCreateDeviceGray())        

    CVPixelBufferUnlockBaseAddress(
        pixelBuffer,
        CVPixelBufferLockFlags(rawValue: 0))
}

我们将图像渲染到CVPixelBuffer中,为我们提供了一个方便的方式来访问原始像素,然后我们可以使用这些像素来填充我们的数组。然后我们将这个结果返回给调用者。负责这一过程的代码主要部分如下所示:

let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer);

if let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) {
    pixelData = Array<UInt8>(repeating: 0, count: width * height)
    let buf = baseAddress.assumingMemoryBound(to: UInt8.self)
    for i in 0..<width*height{
        pixelData![i] = buf[i]
    }
}

在这里,我们首先通过使用CVPixelBufferGetWidthCVPixelBufferGetHeight分别获取图像的宽度和高度来确定维度。然后我们使用这些信息创建一个适当大小的数组来存储像素数据。然后我们获取CVPixelBuffer的基址并调用它的assumingMemoryBound方法来给我们一个类型指针。我们可以使用这个指针来访问每个像素,我们这样做是为了在返回之前填充pixelData数组。

现在你已经实现了getGrayscalePixelData方法,回到沙盒的主源代码,继续你之前中断的地方,添加以下代码:

guard let resizedCroppedCIImageData =
    resizedCroppedCIImage.getGrayscalePixelData() else{
        fatalError("Failed to get (grayscale) pixel data from image")
}

let scaledImageData = resizedCroppedCIImageData.map({ (pixel) -> Double in
    return Double(pixel)/255.0
})

在前面的代码片段中,我们使用getGrayscalePixelData方法获取我们裁剪图像的原始像素,在将每个像素除以 255.0(最大值)之前进行缩放。我们准备工作的最后任务是把我们数据放入模型可以接受的数据结构中,即MLMultiArray。添加以下代码来完成这个任务:

guard let array = try? MLMultiArray(shape: [1, 48, 48], dataType: .double) else {
    fatalError("Unable to create MLMultiArray")
}

for (index, element) in scaledImageData.enumerated() {
    array[index] = NSNumber(value: element)
}

我们首先创建一个具有输入数据形状的MLMultiArray实例,然后继续复制我们的标准化像素数据。

在我们的模型实例化和数据准备就绪后,我们现在可以使用以下代码进行推理:

DispatchQueue.global(qos: .background).async {
    let prediction = try? model.prediction(
        image: array)

    if let classPredictions = prediction?.classLabelProbs{
        DispatchQueue.main.sync {
            for (k, v) in classPredictions{
                print("\(k) \(v)")
            }
        }
    }
} 

此前,我们在后台线程上进行了推理,然后将每个类别的所有概率打印到控制台。现在,完成这个任务后,运行你的沙盒,如果一切正常,你应该会得到以下类似的结果:

生气 0.0341557003557682
快乐 0.594196200370789
厌恶 2.19011440094619e-06
悲伤 0.260873317718506
恐惧 0.013140731491148
惊讶 0.000694742717314512
中立 0.0969370529055595

作为智能系统的设计师和构建者,你的任务是解释这些结果并将它们呈现给用户。以下是一些你可能想要问自己的问题:

  • 在将类别设置为真之前,一个可接受的概率阈值是多少?

  • 这个阈值是否可以依赖于其他类的概率来消除歧义?也就是说,如果悲伤快乐的概率为 0.3,你可以推断出预测是不准确的,或者至少不是很有用。

  • 是否有接受多个概率的方法?

  • 是否应该向用户公开阈值,并允许他们手动设置和/或调整它?

这些只是你应该问的几个问题。具体问题及其答案将取决于你的用例和用户。到目前为止,我们已经拥有了预处理和进行推理所需的一切;现在让我们将注意力转向本章的应用。

如果你发现没有任何输出,可能是因为你需要将沙盒标记为无限运行,这样它就不会在运行后台线程之前退出。你可以在你的沙盒中添加以下语句来实现这一点:PlaygroundPage.current.needsIndefiniteExecution = true

当这个设置为true时,你需要明确停止沙盒。

将所有内容整合在一起

如果您还没有做的话,请从附带的存储库中拉取最新的代码:github.com/packtpublishing/machine-learning-with-core-ml。下载后,导航到目录Chapter4/Start/FacialEmotionDetection并打开项目FacialEmotionDetection.xcodeproj。一旦加载,您可能会希望识别出项目结构,因为它与我们第一个例子非常相似。因此,我们将只关注这个项目特有的主要组件,我建议您回顾前面的章节,以澄清任何不清楚的地方。

让我们先回顾一下我们的项目和其主要组件;您的项目应该看起来与以下截图所示相似:

图片

如前一个截图所示,项目看起来与我们的前几个项目非常相似。我将假设类VideoCaptureCaptureVideoPreviewViewUIColorExtension看起来很熟悉,并且您对它们的内 容感到舒适。CIImageExtension是我们之前章节中刚刚实现的,因此我们在这里不会涉及它。EmotionVisualizerView类是一个自定义视图,用于可视化我们模型的输出。最后,我们有捆绑的ExpressionRecognitionModelRaw.mlmodel。在本节中,我们的主要焦点将是将之前章节中实现的功能进行封装,以处理预处理,并将其在ViewController类中连接起来。在我们开始之前,让我们快速回顾一下我们正在做什么,并考虑一些表情/情感识别的实际应用。

在本节中,我们正在构建一个简单的检测到的面部可视化;我们将把我们的摄像头输入传递给我们的预处理程序,然后将其交给我们的模型进行推理,最后将结果传递给我们的EmotionVisualizerView以在屏幕上渲染输出。这是一个简单的例子,但足以实现嵌入到您自己的创作中所需的所有机制。那么,它的一些实际用途有哪些呢?

从广义上讲,有三个主要用途:分析反应预测。分析通常是您可能会听到的。这些应用通常观察用户对所呈现内容的反应;例如,您可能会测量用户观察到的唤醒程度,然后将其用于驱动未来的决策。

虽然分析体验主要保持被动,但反应性应用会根据实时反馈主动调整体验。一个很好地说明这一点的例子是来自麻省理工学院社会机器人小组的研究项目DragonBot,该项目探索智能辅导系统。

DragonBot 使用情感意识来适应学生;例如,其应用之一是一个根据识别到的情感调整单词的阅读游戏。也就是说,系统可以根据用户的能力调整任务的难度(在这种情况下是单词),这种能力是通过识别到的情感确定的。

最后,我们有预测性应用。预测性应用是半自主的。它们主动尝试推断用户的环境并预测一个可能的行为,因此调整它们的状态或触发一个动作。一个虚构的例子可能是一个电子邮件客户端,如果用户在愤怒时编写了消息,它会延迟发送消息。

希望这能突出一些机会,但就目前而言,让我们回到我们的例子,开始构建负责处理预处理的类。首先创建一个新的 Swift 文件,命名为ImageProcess.swift;在文件中,添加以下代码:

import UIKit
import Vision

protocol ImageProcessorDelegate : class{
    func onImageProcessorCompleted(status: Int, faces:[MLMultiArray]?)
}

class ImageProcessor{

    weak var delegate : ImageProcessorDelegate?

    init(){

    }

    public func getFaces(pixelBuffer:CVPixelBuffer){
        DispatchQueue.global(qos: .background).async {  

    }
}

在这里,我们为代理定义了处理预处理完成后结果的协议,以及暴露启动任务方法的主体类。我们将使用的绝大部分代码是我们已经在 playground 中编写的;首先在类级别声明请求和请求处理器:

let faceDetection = VNDetectFaceRectanglesRequest()

let faceDetectionRequest = VNSequenceRequestHandler()

现在,让我们利用请求,让我们的处理器在getFaces方法背景队列调度块的体内执行它:

let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
let width = ciImage.extent.width
let height = ciImage.extent.height

// Perform face detection
try? self.faceDetectionRequest.perform(
    [self.faceDetection],
    on: ciImage) 

var facesData = [MLMultiArray]()

if let faceDetectionResults = self.faceDetection.results as? [VNFaceObservation]{
    for face in faceDetectionResults{

    }
}

这一切都应该对您来说都很熟悉。我们将请求和图像传递给图像处理器。然后,我们实例化一个数组来保存图像中检测到的每个面部的数据。最后,我们获得观察结果并开始遍历它们。在这个块中,我们将执行预处理并填充我们的facesData数组,就像我们在 playground 中所做的那样。在循环中添加以下代码:

let bbox = face.boundingBox

let imageSize = CGSize(width:width,
                       height:height)

let w = bbox.width * imageSize.width
let h = bbox.height * imageSize.height
let x = bbox.origin.x * imageSize.width
let y = bbox.origin.y * imageSize.height

let paddingTop = h * 0.2
let paddingBottom = h * 0.55
let paddingWidth = w * 0.15

let faceRect = CGRect(x: max(x - paddingWidth, 0),
                      y: max(0, y - paddingTop),
                      width: min(w + (paddingWidth * 2), imageSize.width),
                      height: min(h + paddingBottom, imageSize.height))

在前面的块中,我们获得了检测到的面部边界框并创建了包括填充在内的裁剪边界。我们的下一个任务将从图像中裁剪面部,将其调整到我们的目标大小 48 x 48,提取原始像素数据并对其进行归一化,最后填充一个MLMultiArray。然后,这个数组被添加到我们的facesData数组中,以便返回给代理;将以下代码添加到您的脚本中即可实现这一点:

if let pixelData = ciImage.crop(rect: faceRect)?
    .resize(size: CGSize(width:48, height:48))
    .getGrayscalePixelData()?.map({ (pixel) -> Double in
        return Double(pixel)/255.0 
    }){
    if let array = try? MLMultiArray(shape: [1, 48, 48], dataType: .double)     {
        for (index, element) in pixelData.enumerated() {
            array[index] = NSNumber(value: element)
        }
        facesData.append(array)
    }
}

除了将方法链接起来以使其更易于阅读(至少对我来说是这样)之外,这里没有引入任何新内容。我们的最终任务是完成通知代理;在观察循环块外部添加以下代码:

DispatchQueue.main.async {
    self.delegate?.onImageProcessorCompleted(status: 1, faces: facesData)
}

现在,随着这一切的完成,我们的ImageProcessor已经准备好使用。让我们将其连接起来。跳转到ViewController类,我们将在这里连接我们的ImageProcessor。我们将将其结果传递给我们的模型,最后将模型的输出传递给EmotionVisualizerView以向用户展示结果。让我们首先回顾一下目前存在的内容:

import UIKit
import Vision
import AVFoundation

class ViewController: UIViewController {

    @IBOutlet weak var previewView: CapturePreviewView!

    @IBOutlet weak var viewVisualizer: EmotionVisualizerView!

    @IBOutlet weak var statusLabel: UILabel!

    let videoCapture : VideoCapture = VideoCapture() 

    override func viewDidLoad() {
        super.viewDidLoad()

        videoCapture.delegate = self

        videoCapture.asyncInit { (success) in
            if success{

                (self.previewView.layer as! AVCaptureVideoPreviewLayer).session = self.videoCapture.captureSession

                (self.previewView.layer as! AVCaptureVideoPreviewLayer).videoGravity = AVLayerVideoGravity.resizeAspectFill

                self.videoCapture.startCapturing()
            } else{
                fatalError("Failed to init VideoCapture")
            }
        }

        imageProcessor.delegate = self
    }
}

extension ViewController : VideoCaptureDelegate{

    func onFrameCaptured(
        videoCapture: VideoCapture,
        pixelBuffer:CVPixelBuffer?,
        timestamp:CMTime){
        // Unwrap the parameter pixxelBuffer; exit early if nil
        guard let pixelBuffer = pixelBuffer else{
            print("WARNING: onFrameCaptured; null pixelBuffer")
            return
        }
    }
}

我们的ViewController引用了其 IB 对应物,最值得注意的是previewViewviewVisualizer。前者将渲染捕获的相机帧,而viewVisualizer将负责可视化模型的输出。然后我们有videoCapture,这是一个封装设置、捕获和拆除相机的实用类。我们通过指定自己为代理并实现适当的协议来获取捕获的帧,就像我们在底部作为扩展所做的那样。

让我们先声明完成任务所需的模型和ImageProcessor变量;在ViewController的类级别添加以下内容:

let imageProcessor : ImageProcessor = ImageProcessor()

let model = ExpressionRecognitionModelRaw()

接下来,我们需要将自己指定为ImageProcessor的代理,以便在处理完成后接收结果。将以下语句添加到viewDidLoad方法的底部:

imageProcessor.delegate = self

我们将很快返回以实现所需的协议;现在,让我们通过传递从相机接收到的帧来利用我们的ImageProcessor。在onFrameCaptured方法中,我们添加以下语句,该语句将每个帧传递给我们的ImageProcessor实例。以下代码块中已用粗体显示:

extension ViewController : VideoCaptureDelegate{

    func onFrameCaptured(
        videoCapture: VideoCapture,
        pixelBuffer:CVPixelBuffer?,
        timestamp:CMTime){

        guard let pixelBuffer = pixelBuffer else{
            print("WARNING: onFrameCaptured; null pixelBuffer")
            return
        }

        self.imageProcessor.getFaces(
 pixelBuffer: pixelBuffer)
    }
} 

我们最后的任务将是实现ImageProcessorDelegate协议;当我们的ImageProcessor完成对给定相机帧的每个面部识别和提取,以及执行模型所需的预处理时,将调用此协议。一旦完成,我们将数据传递给我们的模型以进行推理,最后将这些数据传递到我们的EmotionVisualizerView。因为这里没有引入新的内容,所以让我们直接添加整个块:

extension ViewController : ImageProcessorDelegate{

    func onImageProcessorCompleted(
        status: Int,
        faces:[MLMultiArray]?){
        guard let faces = faces else{ return }

        self.statusLabel.isHidden = faces.count > 0

        guard faces.count > 0 else{
            return
        }

        DispatchQueue.global(qos: .background).async {
            for faceData in faces{

                let prediction = try? self.model
                    .prediction(image: faceData)

                if let classPredictions =
                    prediction?.classLabelProbs{
                    DispatchQueue.main.sync {
                        self.viewVisualizer.update(
                            labelConference: classPredictions
                        )
                    }
                }
            }
        }
    }
}

值得注意的是,我们的模型需要在后台线程上进行推理,而ImageProcessor在其代理上调用主线程。因此,我们将推理调度到后台,然后在主线程上返回结果——这在你想要更新用户界面时是必要的。

完成这些后,我们现在处于构建和部署以进行测试的良好位置;如果一切顺利,你应该会看到以下内容:

让我们通过回顾我们已经涵盖的内容并指出一些在进入下一章之前值得探索的有趣领域来结束本章。

在本章中,我们采取了天真方法来处理捕获的帧;在商业应用中,您可能希望优化此过程,例如利用Vision框架中的对象跟踪来替换显式的面部检测,这计算成本更低。

摘要

在本章中,我们应用了卷积神经网络(CNN)来识别面部表情的任务。利用这一点,我们可以推断出给定面部的情绪状态。像往常一样,我们花费了大部分时间来理解模型所需的输入并实现促进这一功能的特性。但是,在这个过程中,我们发现了开发智能应用时的一些重要考虑因素;第一个是明确意识到使用端到端解决方案或多步骤方法,其中多步骤方法是您最常用的方法。

这实际上意味着你,作为智能应用的设计师和构建者,将构建由许多模型组成的数据管道,每个模型都在为下一个步骤转换数据。这与深度网络的工作方式类似,但提供了更大的灵活性。第二个考虑因素是强调 iOS 上可用的互补框架的可用性,特别是Vision框架。它被用作我们流程中的一步,但为常见任务提供了很多便利,以及一致的流程。

在这个例子中,我们的流程只包含两个步骤:面部检测和情绪识别。但我们还简要地尝试了Vision框架的一个功能,该功能可以用来识别面部特征点。因此,考虑使用面部特征点来训练情绪分类器而不是原始像素是合理的。在这种情况下,我们的流程将包括三个步骤:面部检测、特征点检测,最后是情绪识别。

最后,我们简要探讨了几个用例,展示了情绪识别如何被应用;随着我们的计算机从纯工具向伴侣转变,能够检测和反应用户的情绪状态将变得越来越重要。因此,这是一个值得进一步探索的领域。

在下一章中,我们将介绍迁移学习的概念以及我们如何利用它将一种图像的风格转移到另一种图像上。

第五章:在世界中定位物体

到目前为止,我们只限于使用卷积神经网络CNN)识别图像中单个最显著的物体。我们看到了如何训练一个模型来接受图像并提取一系列特征图,然后将其输入到全连接层以输出一系列类别的概率分布。然后,通过激活层将这些输出解释为对图像中物体的分类,如下所示:

图片

在本章中,我们将在此基础上构建,探讨如何检测和定位单个图像中的多个物体。我们将首先建立对这个过程的理解,然后通过实现一个用于相册应用的图像搜索功能来演示。这个应用允许用户根据图像中存在的物体以及它们之间的相对位置(物体组成)来过滤和排序图像。在这个过程中,我们还将亲身体验 Core ML Tools,这是苹果公司发布的一套工具,用于将流行的机器学习ML)框架中的模型转换为 Core ML。

让我们先了解如何在图像中检测多个物体需要哪些条件。

目标定位和目标检测

如本章引言中所述,我们已经介绍了使用 CNN 进行目标识别背后的概念。在这种情况下,我们使用一个训练好的模型来执行分类;它通过使用卷积层学习一系列特征图,然后输入到全连接(或密集)层,并通过激活层最终输出,从而给出了每个类别的概率。通过选择概率最大的类别来推断类别。

让我们区分一下目标识别、目标定位和目标检测。目标识别是识别图像中最主要物体的任务,而目标定位则执行分类并预测物体的边界框。目标检测进一步扩展了这一概念,允许检测和定位多个类别,这也是本章的主题。

这个过程被称为目标识别,是一个分类问题,但在这里我们并没有获得完整的图像(有意为之)。那么,检测到的物体的位置如何?这将有助于提高机器人系统的感知能力或扩展智能界面的应用范围,例如智能裁剪和图像增强。那么,检测多个物体及其位置呢?前者,检测单个物体的位置,被称为目标定位,而后者通常被称为目标检测,如下所示:

图片

我们将首先介绍目标定位,然后逐步过渡到目标检测。这些概念是互补的,前者可以看作是目标识别的扩展,这是你已经熟悉的。

当训练用于分类的模型时,我们调整权重以使它们在预测单个类别时达到最小损失。对于目标定位,我们本质上希望扩展这一功能,不仅预测类别,还要预测识别出的对象的位置。让我们通过一个具体的例子来帮助说明这个概念。想象我们正在训练一个模型来识别和定位猫、狗或人。为此,我们的模型需要输出每个类别的概率(猫、狗或人),正如我们之前看到的,还需要它们的定位。这可以用物体的中心 xy 位置以及宽度和高度来描述。为了简化训练任务,我们还包含一个表示对象是否存在或否的值。以下图展示了两个输入图像及其相关的输出。假设这里我们的独热编码类别顺序为猫、狗和人。也就是说,猫将被编码为 (1,0,0),狗为 (0,1,0),人为 (**0,0,1)

图片

前面图像中显示的输出结构由以下元素组成:

图片

在这里,我们有三个类别,但这可以推广到包括任意数量的类别。值得注意的是,如果没有检测到对象(我们输出的第一个元素),则忽略其余元素。另一个需要强调的重要点是,边界框是用单位而不是绝对值来描述的。

例如,b[x] 的值为 0.5 将表示图像宽度的一半,其中左上角是 (0, 0),右下角是 (1, 1),如图所示:

图片

我们可以借鉴用于分类的典型 CNN 的很多结构。在那里,图像通过一系列卷积层,其输出是一个特征向量,然后通过一系列全连接层,最后通过 softmax 激活进行多类别分类(给出所有类别的概率分布)。我们不仅可以将卷积层的特征向量传递到单个全连接层,还可以将它们传递到一层(或几层)进行二元分类(第四个元素:对象是否存在)和另一层(或一系列层)来预测边界框,使用回归方法。

这些修改的结构可以在以下图中看到,其中修改的内容用粗体表示:

图片

这是一个好的开始,但我们的图像通常包含许多对象。因此,让我们简要描述如何解决这个问题。

我们现在进入目标检测领域,我们感兴趣的是在单个图像中检测和定位多个对象(不同类别的对象)。到目前为止,我们已经看到如何从图像中检测单个对象及其位置,因此从这个角度来看,逻辑上的下一步是将我们的问题围绕这个架构进行重塑。

我的意思是,我们可以使用这种方法或类似的方法,但不是传递完整的图像,而是可以传递图像的裁剪区域;这些区域是通过在图像上滑动窗口来选择的,如下所示(并且与我们的猫主题保持一致):

图片

由于显而易见的原因,这被称为滑动窗口检测算法,对于那些有计算机视觉经验的人来说应该是熟悉的(在模板匹配等许多应用中使用)。同时,也需要强调训练上的差异;在目标定位中,我们通过传递完整的图像及其相关的输出向量(b[x],b[y],b[w],h[y],p[c],c[1],c[2],c[3],...)来训练网络,而在这里,网络是在每个对象的紧密裁剪图像上训练的,这些图像可能占据我们的窗口大小。

对于好奇的读者想知道这个算法如何检测那些不适合窗口大小的对象,一个方法可能是简单地调整图像的大小(既减小也增大),或者类似地,使用不同大小的窗口,即一组小、中、大窗口。

这种方法有两个主要缺点;第一个是它计算成本高,第二个是由于依赖于窗口大小和步长大小,它不允许非常精确的边界框。前者可以通过重新设计 CNN,使其在单次遍历中执行滑动窗口算法来解决,但我们仍然面临着边界框不准确的问题。

幸运的是,对于 2015 年的我们,J. Redmon、S. Divvala、R. Girshick 和 A. Farhadi 发布了他们的论文《你只需看一次(YOLO):统一、实时目标检测》。它描述了一种只需要单个网络的方法,该网络能够在单次遍历中从完整图像中预测边界框和概率。由于它是一个统一的流程,整个过程足够高效,可以在实时中进行,因此我们本章使用的网络就是这样的。

论文《你只需看一次:统一、实时目标检测》的链接如下:arxiv.org/abs/1506.02640

让我们花些时间熟悉 YOLO 算法,我们将简要了解算法的一般概念和输出解释,以便在后续章节的示例应用中使用。

与本章中我们讨论的先前方法相比,YOLO 的一个主要区别在于模型的训练方式。与第一个方法类似,当引入目标定位时,模型是在图像和标签对上训练的,标签的元素包括(b[x], b[y], b[w], b[h], p[c], c[1], c[2], ...),YOLO 网络也是如此。但是,YOLO 网络不是在整张图像上训练,而是将图像分解成网格,每个单元格都有一个相关的标签,正如之前概述的那样。在下一张图中,你可以看到这一点。这里展示的网格是一个 3 x 3 的,为了可读性;这些通常更密集,正如你很快就会看到的:

在训练时,我们输入图像,网络结构使得它为每个网格单元格输出一个向量(如之前所示)。为了使这一点更具体,以下图展示了这些输出中的一些是如何在这个网格的单元格中呈现的:

这应该对你来说很熟悉,因为正如之前提到的,这与我们最初介绍的目标定位方法非常相似。唯一的重大区别是,它是在每个网格单元格上进行的,而不是在整个图像上。

值得注意的是,尽管对象跨越多个单元格,但训练样本只使用单个单元格(通常是图像中心的单元格)来标记对象,其他包含单元格没有分配给任何对象。

在我们继续之前,让我们来解释一下边界框变量。在先前的图中,我为每个对象输入了近似值;就像目标定位一样,这些值在单元格内的值之间归一化到0.01.0。但是,与目标定位不同,这些值是局部于单元格本身的,而不是图像。以第一只猫为例,我们可以看到它在x轴上的中心位置是0.6,在y轴上的中心位置是0.35;这可以解释为在单元格x轴上的位置是 60%,在单元格y轴上的位置是 35%。因为我们的边界框超出了单元格,所以分配的值大于一,正如我们在先前的图中看到的:

之前,我们强调训练样本只为每个对象分配一个单元格,但鉴于我们在每个单元格上运行对象检测和定位,我们很可能会得到多个预测。为了管理这一点,YOLO 使用了一种称为非极大值抑制的方法,我们将在接下来的几段中介绍,你将在即将到来的示例中实现它。

如前所述,因为我们对每个网格单元格执行对象检测和定位,所以我们最终可能会得到多个边界框。这在下图中显示。为了简化说明,我们只关注一个对象,但当然这也适用于所有检测到的对象:

图片

在先前的图中,我们可以看到网络在三个单元格中预测并定位了猫。对于每个单元格,我在它们相关的边界框的右上角添加了一个虚构的置信值。

置信值是通过将对象存在概率(p[c])与最可能的类别相乘来计算的,即概率最高的类别。

非极大值抑制的第一步很简单,就是过滤掉不满足一定阈值的预测;从所有目的来看,让我们将我们的对象阈值设为0.3。使用这个值,我们可以看到我们过滤掉了一个预测(置信值为0.2),留下我们只有两个,如下图所示:

图片

在下一步中,我们遍历所有检测到的框,从置信度最高的框到最低的框,移除任何占用相同空间的其它边界框。在先前的图中,我们可以清楚地看到这两个边界框实际上占用了相同的空间,但我们如何程序化地确定这一点呢?

为了确定这一点,我们计算所谓的交并比IoU)并将返回值与一个阈值进行比较。正如其名所示,我们通过将两个边界框的交集面积除以它们的并集面积来计算这个值。值为1.0告诉我们两个边界框正好占用了相同的空间(如果你使用单个边界框自身进行此计算,你会得到这个值)。任何低于此值的都给出了重叠占用的比率;一个典型的阈值是0.5。下图说明了我们示例的交集和并集面积:

图片

由于这两个边界框的交并比(IoU)相对较高,我们将剪除最不可能的框(置信度分数较低的框),最终得到一个单一的边界框,如下图所示:

图片

我们重复此过程,直到遍历了模型的所有预测。在进入本章的示例项目并开始编写代码之前,还有一个概念需要介绍。

到目前为止,我们假设(或受限于这种想法)每个单元格都与没有对象或单个对象相关联。但是,对于两个对象重叠的情况,即两个对象的重心位置占据相同的网格单元格,又该如何处理呢?为了处理这种情况,YOLO 算法实现了一种称为锚框的东西。锚框允许在边界形状不同的情况下,多个对象占据单个网格单元格。让我们通过直观的解释来使这一点更加具体。在下一张图中,我们有一个图像,其中两个对象的重心占据相同的网格单元格。根据我们当前的输出向量,我们需要将单元格标记为人物或自行车,如下所示:

锚框的思路是我们将输出向量扩展到包括不同类型的锚框(在此处表示为两个,但实际上可以是任何数量)。这允许每个单元格编码多个对象,只要它们的边界形状不同。

从上一张图中,我们可以看到我们可以使用两个锚框,一个用于人物,另一个用于自行车,如下所示:

现在我们已经定义了锚框,我们将向量输出扩展,以便对于每个网格单元格,我们可以编码两个锚框的输出,如下所示:

每个锚框可以独立于同一单元格和其他单元格的任何其他输出进行处理;也就是说,我们处理它的方式与之前完全相同,唯一的增加是我们现在有更多的边界框需要处理。

为了澄清,尽管某些形状比其他形状更适合,但锚框并不局限于特定的类别。它们通常是使用某种无监督学习算法(如k-means)在现有数据集中找到的典型通用形状。

这就结束了我们需要理解本章示例的所有概念以及我们将在接下来的章节中实现的内容,但在我们这样做之前,让我们通过使用 Core ML Tools Python 包将 Keras 模型转换为 Core ML 的过程进行回顾。

将 Keras Tiny YOLO 转换为 Core ML

在上一节中,我们讨论了本章将使用的模型和算法的概念。在本节中,我们将通过使用苹果的 Core ML Tools Python 包将训练好的 Tiny YOLO Keras 模型转换为 Core ML,从而更接近实现本章的示例项目;但在这样做之前,我们将简要讨论该模型及其训练所使用的数据。

YOLO 是在一个名为darknet的神经网络框架上构思的,目前默认的 Core ML 工具包不支持它;幸运的是,YOLO 和 darknet 的作者已经在他们的网站上公开了训练模型的架构和权重,网址为pjreddie.com/darknet/yolov2/。YOLO 有一些变体,它们是在Common Objects in ContextCOCO)数据集上训练的,该数据集包含 80 个类别,或者是在 PASCAL Visual Object ClassesVOC)Challenge 2007 上训练的,该挑战包含 20 个类别。

官方网站可以在cocodataset.org找到,The PASCAL VOC Challenge 2007的网站在host.robots.ox.ac.uk/pascal/VOC/index.html

在本章中,我们将使用 YOLOv2 的 Tiny 版本以及从The PASCAL VOC Challenge 2007数据集上训练的模型权重。我们将使用的 Keras 模型是基于官方网站上可用的配置文件和权重构建的(之前提供的链接)。

如同往常,我们将省略模型的大部分细节,而是以图表形式提供模型,如下所示。然后我们将讨论一些相关部分,然后再将其转换为 Core ML 模型:

图片

首先要注意的是输入和输出的形状;这表明我们的模型将期望输入什么,以及它将输出什么供我们使用。如前所述,输入大小是 416 x 416 x 3,这正如你可能所怀疑的,是一个 416 x 416 的 RGB 图像。输出形状需要更多的解释,当我们到达本章的示例编码时,它将变得更加明显。

输出形状是 13 x 13 x 125。13 x 13 告诉我们应用网格的大小,也就是说,416 x 416 的图像被分割成 13 x 13 的单元格,如下所示:

图片

如前所述,每个单元都有一个 125-向量的编码,表示对象存在的概率,如果存在,则包括边界框和所有 20 个类别的概率;直观上,这可以这样解释:

图片

我想要强调的关于这个模型的最后一个要点是其简洁性;网络的大部分由由卷积层组成的卷积块构成:批量归一化LeakyReLU激活,最后是一个最大池化层。这逐渐增加了网络的滤波器大小(深度),直到达到所需的网格大小,然后仅使用卷积层、批量归一化LeakyReLU激活来转换数据,并丢弃最大池化

现在,我们介绍了批归一化和漏斗 ReLU 等术语,这些可能对一些人来说不熟悉;在这里,我将简要描述每个术语,从批归一化开始。在将输入层馈送到网络之前对其进行归一化被认为是最佳实践。例如,我们通常将像素值除以 255,将它们强制进入 0 到 1 的范围。我们这样做是为了使网络更容易学习,通过消除任何大值(或值的较大方差),这可能导致我们在训练过程中调整权重时网络发生振荡。批归一化对隐藏层的输出而不是输入执行相同的调整。

ReLU 是一个激活函数,将所有小于 0 的值设置为 0,即它不允许非正值在网络中传播。漏斗 ReLU 提供了 ReLU 的较宽松实现,当神经元不活跃时允许一个小的非零梯度通过。

这就结束了我们对模型的简要概述。您可以从 J. Redmon 和 A. Farhadi 的官方论文《YOLO9000:更好、更快、更强》中了解更多信息,该论文可在arxiv.org/abs/1612.08242找到。现在,让我们将注意力转向将训练好的 Tiny YOLO Keras 模型转换为 Core ML。

如第一章《机器学习导论》中所述,Core ML 更像是工具套件而不是单一框架。套件的一部分是 Core ML Tools Python 包,它帮助将其他框架训练的模型转换为 Core ML,以便于快速集成。目前,官方转换器支持 Caffe、Keras、LibSVM、scikit-learn 和 XGBoost,但该包是开源的,为其他流行的机器学习框架提供了许多其他转换器,例如 TensorFlow。

转换过程的核心是生成一个模型规范,它是学习模型的机器可解释表示,并由 Xcode 用于生成 Core ML 模型,包括以下内容:

  • 模型描述:编码模型输入和输出的名称和类型信息

  • 模型参数:表示模型特定实例所需的一组参数(模型权重/系数)

  • 附加元数据:关于模型来源、许可和作者的信息

在本章中,我们展示了最简单的流程,但我们将回顾第六章 Creating Art with Style Transfer 中的 Core ML Tools 包,以了解如何处理自定义层。

为了避免在本地或远程机器上设置环境时出现任何复杂性,我们将利用微软提供的免费 Jupyter 云服务。请访问notebooks.azure.com并登录,或者如果您还没有,请注册。

登录后,点击导航栏中的“库”菜单链接,这将带您到一个包含所有库列表的页面,类似于以下截图所示:

图片

接下来,点击“+新建库”链接以打开创建新库对话框:

图片

然后点击“从 GitHub”标签,并在 GitHub 仓库字段中输入https://github.com/packtpublishing/machine-learning-with-core-ml。之后,为您的库起一个有意义的名称,并点击导入按钮以开始克隆仓库并创建库的过程。

创建库后,您将被重定向到根目录;从这里,点击Chapter5/Notebooks文件夹以打开本章的相关文件夹。最后,点击笔记本Tiny YOLO_Keras2CoreML.ipynb。为了确保我们都在同一页面上(有意为之),以下是点击Chapter5/Notebooks文件夹后您应该看到的截图:

图片

现在我们已经加载了笔记本,是时候逐个检查每个单元格以创建我们的 Core ML 模型了;所有必要的代码都已存在,剩下的只是依次执行每个单元格。要执行一个单元格,您可以使用快捷键Shift + Enter,或者点击工具栏中的运行按钮(这将运行当前选定的单元格),如下面的截图所示:

图片

我将对每个单元格的功能进行简要说明;确保我们在浏览它们时执行每个单元格,这样我们最终都会得到转换后的模型,然后我们将下载并用于下一节的 iOS 项目。

我们首先通过运行以下单元格来确保环境中可用 Core ML Tools Python 包:

!pip install coremltools

安装完成后,我们通过导入它来使包可用:

import coremltools

模型架构和权重已序列化并保存到文件tinyyolo_voc2007_modelweights.h5中;在下面的单元格中,我们将将其传递给 Keras 转换器的转换函数,该函数将返回转换后的 Core ML 模型(如果无错误发生)。除了文件外,我们还传递了input_namesimage_input_namesoutput_namesimage_scale参数的值。input_names参数接受单个字符串或字符串列表(用于多个输入),并用于在 Core ML 模型的接口中显式设置用于引用 Keras 模型输入的名称。

我们还将此输入名称传递给image_input_names参数,以便转换器将输入视为图像而不是 N 维数组。与input_names类似,传递给output_names的值将在 Core ML 模型的界面中用于引用 Keras 模型的输出。最后一个参数image_scale允许我们在将输入传递给模型之前添加一个缩放因子。在这里,我们将每个像素除以 255,这迫使每个像素处于0.01.0的范围内,这是处理图像时的典型预处理任务。还有许多其他参数可供选择,允许您调整和微调模型的输入和输出。您可以在官方文档网站上了解更多信息,网址为apple.github.io/coremltools/generated/coremltools.converters.keras.convert.html。在下一个片段中,我们将使用我们刚刚讨论的内容进行实际转换:

coreml_model = coremltools.converters.keras.convert(
    'tinyyolo_voc2007_modelweights.h5',
    input_names='image',
    image_input_names='image',
    output_names='output',
    image_scale=1./255.)

参考转换后的模型coreml_model,我们添加了元数据,这些数据将在 Xcode 的 ML 模型视图中提供和显示:

coreml_model.author = 'Joshua Newnham'
coreml_model.license = 'BSD'
coreml_model.short_description = 'Keras port of YOLOTiny VOC2007 by Joseph Redmon and Ali Farhadi'
coreml_model.input_description['image'] = '416x416 RGB Image'
coreml_model.output_description['output'] = '13x13 Grid made up of: [cx, cy, w, h, confidence, 20 x classes] * 5 bounding boxes'

我们现在准备好保存我们的模型;运行最后的单元格以保存转换后的模型:

coreml_model.save('tinyyolo_voc2007.mlmodel')

现在我们已经保存了我们的模型,我们回到之前显示Chapter5目录内容的标签页,并下载tinyyolo_voc2007.mlmodel文件。我们可以通过右键点击它并选择下载菜单项,或者点击下载工具栏项来完成,如下面的截图所示:

图片

拥有我们转换后的模型在手,现在是时候跳入 Xcode 并处理本章的示例项目了。

使查找照片变得更简单

在本节中,我们将把我们的模型应用于智能搜索应用中;我们首先快速介绍该应用,以便我们清楚地了解我们想要构建的内容。然后,我们将实现与解释模型输出和搜索启发式相关联的功能。我们将省略许多常规的 iOS 功能,以便我们能够专注于应用程序的智能方面。

在过去的几年里,我们看到了智能在相册应用中的大量嵌入,为我们提供了高效的方法来展示那些隐藏在数百(如果不是数千)张我们多年来积累的照片中的猫照片。在本节中,我们希望继续这一主题,但通过利用通过目标检测获得的语义信息,将智能水平提升一点。我们的用户将能够搜索图像中的特定对象,以及基于对象及其相对位置的照片。例如,他们可以搜索两个人并排站立在车前的图像或图像。

用户界面允许用户绘制他们希望定位的对象及其相对大小。在本节中,我们的任务是实现基于这些搜索标准的智能,以返回相关图像。

下一个图示显示了用户界面;前两个截图显示了搜索屏幕,用户可以在其中直观地表达他们想要寻找的内容。通过使用标记的边界框,用户能够描述他们想要寻找的内容,他们希望如何排列这些对象以及相对对象的大小。最后两个截图显示了搜索结果,当展开(最后一个截图)时,图像将叠加检测到的对象及其关联的边界框:

图片

在导入我们刚刚转换和下载的模型之前,让我们先浏览一下现有的项目。

如果您还没有做,请从配套仓库中拉取最新代码:github.com/packtpublishing/machine-learning-with-core-ml。下载完成后,导航到目录 Chapter5/Start/ 并打开项目 ObjectDetection.xcodeproj。加载后,您将看到本章的项目,如下面的截图所示:

图片

我将把探索整个项目作为您的练习,而我会专注于本节中的文件 PhotoSearcher.swiftYOLOFacade.swiftPhotoSearcher.swift 是我们将实现负责根据搜索标准和 YOLOFacade.swift 中检测到的对象过滤和排序照片的成本函数的地方,YOLOFacade.swift 的唯一目的是封装 Tiny YOLO 模型并实现解释其输出的功能。但在深入代码之前,让我们快速回顾一下我们将要工作的流程和数据结构。

下面的图示说明了应用程序的一般流程;用户首先通过 SearchViewController 定义搜索标准,该控制器被描述为一个包含归一化 ObjectBounds 的数组。我们稍后会详细介绍这些内容。当用户启动搜索(右上角的搜索图标)时,这些标准会被传递给 SearchResultsViewController,它将找到合适图像的任务委托给 PhotoSearcher

PhotoSearcher 继续遍历我们所有的照片,将每一张照片通过 YOLOFacade 进行对象检测,使用我们在上一节中转换的模型。这些结果被传回 PhotoSearcher,它根据搜索标准评估每一项的成本,然后过滤和排序结果,最后将它们传回 SearchResultsViewController 以供显示:

图片

每个组件都通过数据对象 ObjectBoundsSearchResult 与另一个组件进行通信。因为我们将在本章的其余部分使用它们,所以让我们快速介绍它们,所有这些都在 DataObjects.swift 文件中定义。让我们从以下片段中的 ObjectBounds 开始:

struct ObjectBounds {
    public var object : DetectableObject
    public var origin : CGPoint
    public var size : CGSize

    var bounds : CGRect{
        return CGRect(origin: self.origin, size: self.size)
    }
}    

如其名所示,ObjectBounds 正是这样——它使用 originsize 变量封装了对象的边界。object 本身是 DetectableObject 类型,它提供了一个结构来存储类索引及其关联的标签。它还提供了一个静态对象数组,这些对象可用于我们的搜索,如下所示:

struct DetectableObject{
    public var classIndex : Int
    public var label : String

    static let objects = [
        DetectableObject(classIndex:19, label:"tvmonitor"),
        DetectableObject(classIndex:18, label:"train"),
        DetectableObject(classIndex:17, label:"sofa"),
        DetectableObject(classIndex:14, label:"person"),
        DetectableObject(classIndex:11, label:"dog"),
        DetectableObject(classIndex:7, label:"cat"),
        DetectableObject(classIndex:6, label:"car"),
        DetectableObject(classIndex:5, label:"bus"),
        DetectableObject(classIndex:4, label:"bottle"),
        DetectableObject(classIndex:3, label:"boat"),
        DetectableObject(classIndex:2, label:"bird"),
        DetectableObject(classIndex:1, label:"bicycle")
    ]
}

ObjectBounds 用于用户定义的搜索标准和 YOLOFacade 返回的搜索结果;在前者中,它们描述了用户感兴趣寻找的位置和对象(搜索标准),而在后者中,它们封装了对象检测的结果。

SearchResult 并不复杂;它的目的是封装搜索结果,并添加图像和成本,这些成本在成本评估阶段(步骤 8)设置,如前图所示。对于完整的代码,结构如下:

struct SearchResult{
    public var image : UIImage  
    public var detectedObjects : [ObjectBounds]
    public var cost : Float
}

值得注意的是,在之前的图中,用词 Normalized 注释的 ObjectBounds 消息,指的是基于源或目标大小的单位值;也就是说,x = 0.5y = 0.5 的原点定义了源图像的中心。这样做是为了确保边界不受它们操作图像变化的影响。你很快就会看到,在将图像传递给我们的模型之前,我们需要将其调整大小并裁剪到 416 x 416 的大小(这是我们模型的预期输入),但我们需要将它们转换回原始大小以渲染结果。

现在,我们对我们将消费和生成哪些对象有了更好的了解;让我们继续实现 YOLOFacade 并逐步构建。

让我们从导入上一节中刚刚转换的模型开始;找到下载的 .mlmodel 文件并将其拖放到 Xcode 中。导入后,从左侧面板中选择它以检查元数据,以便提醒自己需要实现的内容。它应该类似于以下截图:

图片

现在我们已经导入了模型,让我们来了解 YOLOFacade 负责的功能实现;这包括预处理图像,将其传递给我们的模型进行推理,然后解析模型的输出,包括执行 非最大值抑制。从左侧面板中选择 YOLOFacade.swift 以在主窗口中显示代码。

该类通过扩展分为三个部分,第一部分包括变量和入口点;第二部分包括执行推理和解析模型输出的功能;第三部分包括我们在本章开头讨论的非最大抑制算法。让我们从开始的地方开始,目前看起来是这样的:

class YOLOFacade{

    // TODO add input size (of image)
    // TODO add grid size
    // TODO add number of classes
    // TODO add number of anchor boxes
    // TODO add anchor shapes (describing aspect ratio)

    lazy var model : VNCoreMLModel? = {
        do{
            // TODO add model
            return nil
        } catch{
            fatalError("Failed to obtain tinyyolo_voc2007")
        }
    }()

    func asyncDetectObjects(
        photo:UIImage,
        completionHandler:@escaping (_ result:[ObjectBounds]?) -> Void){

        DispatchQueue.global(qos: .background).sync {

            self.detectObjects(photo: photo, completionHandler: { (result) -> Void in
                DispatchQueue.main.async {
                    completionHandler(result)
                }
            })
        }
    }

}  

asyncDetectObjects方法是类的入口点,由PhotoSearcher在接收到来自 Photos 框架的每一张图片时调用;当被调用时,此方法简单地委托任务到后台的detectObject方法,并等待结果,然后将它们传递回主线程上的调用者。我已经用TODO注释了这个类,以帮助您保持专注。

让我们先声明模型所需的目标大小;这将用于预处理模型的输入并将归一化边界转换为源图像的尺寸。添加以下代码:

// TODO add input size (of image)
var targetSize = CGSize(width: 416, height: 416)

接下来,我们定义模型在输出解析过程中使用的属性;这些包括网格大小、类别数量、锚框数量,以及每个锚框的尺寸(每一对分别描述宽度和高度)。对您的YOLOFacade类进行以下修改:

// TODO add grid size
let gridSize = CGSize(width: 13, height: 13)
// TODO add number of classes
let numberOfClasses = 20
// TODO add number of anchor boxes
let numberOfAnchorBoxes = 5
// TODO add anchor shapes (describing aspect ratio)
let anchors : [Float] = [1.08, 1.19, 3.42, 4.41, 6.63, 11.38, 9.42, 5.11, 16.62, 10.52]

现在让我们实现模型属性;在这个例子中,我们将利用 Vision 框架来处理预处理。为此,我们需要将我们的模型包装在一个VNCoreMLModel实例中,这样我们就可以将其传递给VNCoreMLRequest;按照以下所示进行以下修改,加粗显示:

lazy var model : VNCoreMLModel = {
    do{
        // TODO add model
        let model = try VNCoreMLModel(
 for: tinyyolo_voc2007().model)
 return model
    } catch{
        fatalError("Failed to obtain tinyyolo_voc2007")
    }
}()

现在让我们将注意力转向detectObjects方法。它将负责通过VNCoreMLRequestVNImageRequestHandler进行推理,将模型的输出传递给detectObjectsBounds方法(我们将在下一章中介绍),并将归一化边界转换为原始(源)图像的尺寸。

在本章中,我们将推迟对 Vision 框架类(VNCoreMLModelVNCoreMLRequestVNImageRequestHandler)的讨论,直到下一章,届时我们将简要说明每个类的作用以及它们是如何协同工作的。

YOLOFacade类的detectObjects方法中,将注释// TODO preprocess image and pass to model替换为以下代码:

let request = VNCoreMLRequest(model: self.model)
request.imageCropAndScaleOption = .centerCrop

let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])

do {
    try handler.perform([request])
} catch {
    print("Failed to perform classification.\n\(error.localizedDescription)")
    completionHandler(nil)
    return
}

在前面的代码片段中,我们首先创建了一个VNCoreMLRequest实例,并将我们的模型传递给它,该模型本身已经被一个VNCoreMLModel实例包装。这个请求执行了繁重的工作,包括预处理(由模型的元数据推断)和执行推理。我们将它的imageCropAndScaleOption属性设置为centerCrop,正如你可能预期的,这决定了图像如何调整大小以适应模型输入。请求本身并不实际执行任务;这是VNImageRequestHandler的责任,我们通过传递我们的源图像并执行处理器的perform方法来声明它。

如果一切按计划进行,我们应该期望通过请求的结果属性获得模型的输出。让我们继续到最后一个代码片段;将注释// TODO pass models results to detectObjectsBounds(::)和随后的语句completionHandler(nil)替换为以下代码:

guard let observations = request.results as? [VNCoreMLFeatureValueObservation] else{
    completionHandler(nil)
    return
}

var detectedObjects = [ObjectBounds]()

for observation in observations{
    guard let multiArray = observation.featureValue.multiArrayValue else{
        continue
    }

    if let observationDetectedObjects = self.detectObjectsBounds(array: multiArray){

        for detectedObject in observationDetectedObjects.map(
            {$0.transformFromCenteredCropping(from: photo.size, to: self.targetSize)}){
                detectedObjects.append(detectedObject)
        }
    }
}

completionHandler(detectedObjects) 

我们首先尝试将结果转换为VNCoreMLFeatureValueObservation数组,这是一种图像分析观察类型,它提供键值对。其中之一是multiArrayValue,然后我们将其传递给detectObjectsBounds方法以解析输出并返回检测到的对象及其边界框。一旦detectObjectsBounds返回,我们就使用ObjectBounds方法的transformFromCenteredCropping将每个结果映射,该方法负责将归一化边界转换为源图像的空间。一旦每个边界都被转换,我们就调用完成处理程序,传递检测到的边界。

下两个方法封装了 YOLO 算法的大部分内容和此类的大部分代码。让我们从detectObjectsBounds方法开始,逐步进行。

此方法将接收一个形状为(125, 13, 13)MLMultiArray;这应该对你来说很熟悉(尽管是反过来的),其中(13, 13)是我们网格的大小,而 125 编码了五个块(与我们的五个锚框相对应),每个块包含边界框、对象存在的概率(或不存在)以及跨越 20 个类别的概率分布。为了方便起见,我再次添加了说明此结构的图解:

图片

为了提高性能,我们将直接访问MLMultiArray的原始数据,而不是通过MLMultiArray下标。虽然直接访问给我们带来了性能提升,但它也有一个权衡,即我们需要正确计算每个值的索引。让我们定义我们将用于计算这些索引的常量,以及获取对原始数据缓冲区和一些用于存储中间结果的数组的访问权限;在你的detectObjectsBounds方法中添加以下代码:

let gridStride = array.strides[0].intValue
let rowStride = array.strides[1].intValue
let colStride = array.strides[2].intValue

let arrayPointer = UnsafeMutablePointer<Double>(OpaquePointer(array.dataPointer))

var objectsBounds = [ObjectBounds]()
var objectConfidences = [Float]() 

如前所述,我们首先定义了网格、行和列的步长常量,每个常量都用于计算当前值。这些值通过MLMultiArraystrides属性获得,它给出了每个维度的数据元素数量。在这种情况下,这将是 125、13 和 13。接下来,我们获取MLMultiArray的底层缓冲区的引用,最后,我们创建两个数组来存储边界和相关的置信度值。

接下来,我们想要遍历模型的输出并独立处理每个网格单元及其后续的锚框;我们通过使用三个嵌套循环并计算相关索引来实现这一点。让我们通过添加以下代码片段来完成这个任务:

for row in 0..<Int(gridSize.height) {
    for col in 0..<Int(gridSize.width) {
        for b in 0..<numberOfAnchorBoxes {

            let gridOffset = row * rowStride + col * colStride
            let anchorBoxOffset = b * (numberOfClasses + numberOfAnchorBoxes)
            // TODO calculate the confidence of each class, ignoring if under threshold 
        }
    }
}

这里重要的值是gridOffsetanchorBoxOffsetgridOffset给出了特定网格单元的相关偏移量(正如其名称所暗示的),而anchorBoxOffset给出了相关锚框的索引。现在我们有了这些值,我们可以使用(anchorBoxOffset + INDEX_TO_VALUE) * gridStride + gridOffset 索引来访问每个元素,其中INDEX_TO_VALUE是我们想要访问的锚框向量中的相关值,如图所示:

![图片

现在我们知道了如何访问我们缓冲区中每个网格单元的每个边界框,让我们用它来找到最可能的类别,并在我们的第一次测试中忽略任何未达到我们阈值(定义为方法参数,默认值为 0.3*:objectThreshold:Float = 0.3)的预测。添加以下代码,替换掉之前的注释// TODO calculate the confidence of each class, ignoring if under threshold

let confidence = sigmoid(x: Float(arrayPointer[(anchorBoxOffset + 4) * gridStride + gridOffset]))

var classes = Array<Float>(repeating: 0.0, count: numberOfClasses)
for c in 0..<numberOfClasses{
    classes[c] = Float(arrayPointer[(anchorBoxOffset + 5 + c) * gridStride + gridOffset])
}
classes = softmax(z: classes)

let classIdx = classes.argmax
let classScore = classes[classIdx]
let classConfidence = classScore * confidence

if classConfidence < objectThreshold{
    continue
}

// TODO obtain bounding box and transform to image dimensions 

在前面的代码片段中,我们首先获取一个对象存在的概率并将其存储在常量confidence中。然后,我们用一个包含所有类别概率的数组填充数组,在它们之上应用 softmax。这将压缩这些值,使得它们的累积值等于1.0,本质上为我们提供了所有类别的概率分布。

然后,我们找到概率最大的类别索引,并将其与我们的confidence常量相乘,这给了我们我们将要阈值化并用于非极大值抑制的类别置信度。如果预测未达到我们的阈值,我们将忽略该预测。

在继续进行程序之前,我想快速地绕道一下,突出并解释一下前面代码片段中使用的一些方法,即softmax方法和类数组中的argmax属性。Softmax 是一种逻辑函数,它本质上将一个数字向量压缩,使得向量中的所有值加起来等于 1;它是一种在处理多类分类问题时常用的激活函数,其中结果被解释为每个类的可能性,通常将具有最大值的类作为预测类(在阈值内)。实现可以在Math.swift文件中找到,该文件利用 Accelerate 框架来提高性能。为了完整性,这里展示了方程和实现,但细节被省略,留给您去探索:

在这里,我们使用了一个略微修改过的之前显示的方程;在实践中,如果任何值非常大,计算 softmax 值可能会出现问题。对它应用指数运算会使它爆炸,而将任何值除以一个巨大的值可能会导致算术计算问题。为了避免这种情况,通常最好的做法是从所有元素中减去最大值。

因为有相当多的函数用于这个操作,让我们从内到外逐步构建。以下是一个执行逐元素减法的函数:

/**
 Subtract a scalar c from a vector x
 @param x Vector x.
 @param c Scalar c.
 @return A vector containing the difference of the scalar and the vector
 */
public func sub(x: [Float], c: Float) -> [Float] {
    var result = (1...x.count).map{_ in c} 
    catlas_saxpby(Int32(x.count), 1.0, x, 1, -1.0, &amp;result, 1) 
    return result
}

接下来,计算数组逐元素指数的函数:

/**
 Perform an elementwise exponentiation on a vector 
 @param x Vector x.
 @returns A vector containing x exponentiated elementwise.
 */
func exp(x: [Float]) -> [Float] {
    var results = Float 
    vvexpf(&amp;results, x, [Int32(x.count)]) 
    return results
}

现在,执行数组求和的函数,如下所示:

/**
 Compute the vector sum of a vector
 @param x Vector.
 @returns A single precision vector sum.
 */
public func sum(x: [Float]) -> Float {
    return cblas_sasum(Int32(x.count), x, 1)
}

这是 softmax 函数使用的最后一个函数!它将负责对给定的标量执行逐元素除法,如下所示:

 /**
 Divide a vector x by a scalar y
 @param x Vector x.
 @parame c Scalar c.
 @return A vector containing x dvidided elementwise by vector c.
 */
public func div(x: [Float], c: Float) -> [Float] {
    let divisor = Float
    var result = Float 
    vvdivf(&amp;result, x, divisor, [Int32(x.count)]) 
    return result
}

最后,我们使用 softmax 函数(使用之前描述的 max 技巧):

/**
 Softmax function
 @param z A vector z.
 @return A vector y = (e^z / sum(e^z))
 */
func softmax(z: [Float]) -> [Float] {
    let x = exp(x:sub(x:z, c: z.maxValue))    
    return div(x:x, c: sum(x:x))
}

除了前面提到的函数之外,它还使用 Swift 数组类的扩展属性maxValue;这个扩展还包括之前提到的argmax属性。因此,我们将它们一起在下面的代码片段中展示,该片段位于Array+Extension.swift文件中。在展示代码之前,提醒一下argmax属性的功能——它的目的是返回数组中最大值的索引,这是 Python 包 NumPy 中常见的方法:

extension Array where Element == Float{

    /**
     @return index of the largest element in the array
     **/
    var argmax : Int {
        get{
            precondition(self.count > 0)

            let maxValue = self.maxValue
            for i in 0..<self.count{
                if self[i] == maxValue{
                    return i
                }
            }
            return -1
        }
    }

    /**
     Find the maximum value in array
     */
    var maxValue : Float{
        get{
            let len = vDSP_Length(self.count)

            var max: Float = 0
            vDSP_maxv(self, 1, &amp;max, len)

            return max
        }
    }
}

现在,让我们将注意力转回到模型输出的解析和提取检测到的对象及其关联的边界框。在循环中,我们现在有一个我们相当有信心的预测,因为它已经通过了我们的阈值过滤器。下一个任务是提取和转换预测对象的边界框。添加以下代码,替换掉// TODO obtain bounding box and transform to image dimensions这一行:

let tx = CGFloat(arrayPointer[anchorBoxOffset * gridStride + gridOffset])
let ty = CGFloat(arrayPointer[(anchorBoxOffset + 1) * gridStride + gridOffset])
let tw = CGFloat(arrayPointer[(anchorBoxOffset + 2) * gridStride + gridOffset])
let th = CGFloat(arrayPointer[(anchorBoxOffset + 3) * gridStride + gridOffset])

let cx = (sigmoid(x: tx) + CGFloat(col)) / gridSize.width 
let cy = (sigmoid(x: ty) + CGFloat(row)) / gridSize.height
let w = CGFloat(anchors[2 * b + 0]) * exp(tw) / gridSize.width 
let h = CGFloat(anchors[2 * b + 1]) * exp(th) / gridSize.height

// TODO create a ObjectBounds instance and store it in our array of candidates  

我们首先从网格单元的锚框段获取前四个值;这返回了相对于网格的中心位置和大小。下一个块负责将这些值从网格坐标系转换到图像坐标系。对于中心位置,我们通过一个sigmoid函数传递返回的值,使其保持在0.0 - 1.0之间,并根据相关列(或行)进行偏移。最后,我们将其除以网格大小(13)。同样,对于尺寸,我们首先获取相关的锚框,将其乘以预测尺寸的指数,然后除以网格大小。

如前所述,我现在提供函数sigmoid的实现,供参考,该函数可以在Math.swift文件中找到。方程如下:

/**
 A sigmoid function 
 @param x Scalar
 @return 1 / (1 + exp(-x))
 */
public func sigmoid(x: CGFloat) -> CGFloat {
    return 1 / (1 + exp(-x))
}

代码的最后部分只是创建了一个ObjectBounds实例,传递了转换后的边界框和相关的DetectableObject类(根据类索引进行过滤)。添加以下代码,替换注释// TODO create a ObjectBounds instance and store it in our array of candidates

guard let detectableObject = DetectableObject.objects.filter(
    {$0.classIndex == classIdx}).first else{
    continue
}

let objectBounds = ObjectBounds(
    object: detectableObject,
    origin: CGPoint(x: cx - w/2, y: cy - h/2),
    size: CGSize(width: w, height: h))

objectsBounds.append(objectBounds)
objectConfidences.append(classConfidence)

除了存储ObjectBounds之外,我们还存储confidence,这将在我们实现非极大值抑制时使用。

这完成了嵌套循环内所需的功能;到这个过程结束时,我们有一个填充了候选检测对象的数组。我们的下一个任务将是过滤它们。在detectObjectsBounds方法的末尾,添加以下语句(在任意循环之外):

return self.filterDetectedObjects(
    objectsBounds: objectsBounds,
    objectsConfidence: objectConfidences)

在这里,我们只是返回filterDetectedObjects方法的结果,我们现在将关注这个方法。该方法已被屏蔽,但尚未实现任何功能,如下所示:

func filterDetectedObjects(
    objectsBounds:[ObjectBounds],
    objectsConfidence:[Float],
    nmsThreshold : Float = 0.3) -> [ObjectBounds]?{

    // If there are no bounding boxes do nothing
    guard objectsBounds.count > 0 else{
        return []
    }
        // TODO implement Non-Max Supression

    return nil
}

我们的工作是实现非极大值抑制算法;为了回顾,算法可以描述如下:

  1. 按照置信度从高到低对检测到的框进行排序

  2. 当有效框仍然存在时,执行以下操作:

    1. 选择置信度值最高的框(我们排序数组中的顶部)

    2. 遍历所有剩余的框,丢弃任何 IoU 值大于预定义阈值的框

让我们从创建传递给方法的置信度数组的副本开始;我们将使用它来获取一个排序索引数组,以及标记任何被前一个框充分重叠的框。这是通过简单地将其置信度值设置为 0 来完成的。添加以下语句来完成这项工作,同时创建排序索引数组,替换注释// TODO implement Non-Max Suppression

var detectionConfidence = objectsConfidence.map{
    (confidence) -> Float in
    return confidence
}

let sortedIndices = detectionConfidence.indices.sorted {
    detectionConfidence[$0] > detectionConfidence[$1]
}

var bestObjectsBounds = [ObjectBounds]()

// TODO iterate through each box 

如前所述,我们首先克隆置信度数组,将其分配给变量detectionConfidence。然后,我们按降序排序索引,最后创建一个数组来存储我们想要保留和返回的框。

接下来,我们将创建包含算法大部分内容的循环,包括选择置信度最高的下一个框并将其存储在我们的bestObjectsBounds数组中。添加以下代码,替换注释// TODO iterate through each box

for i in 0..<sortedIndices.count{
    let objectBounds = objectsBounds[sortedIndices[i]]

    guard detectionConfidence[sortedIndices[i]] > 0 else{
        continue
    }

    bestObjectsBounds.append(objectBounds)

    for j in (i+1)..<sortedIndices.count{
        guard detectionConfidence[sortedIndices[j]] > 0 else {
            continue
        }
        let otherObjectBounds = objectsBounds[sortedIndices[j]]

        // TODO calculate IoU and compare against our threshold        
    }
}

大部分代码应该是自解释的;值得注意的一点是,在每一个循环中,我们测试相关框的置信度是否大于 0。如前所述,我们使用这个方法来表示一个对象由于被置信度更高的框充分重叠而被丢弃。

剩下的工作是计算objectBoundsotherObjectBounds之间的 IoU,如果otherObjectBounds不满足我们的 IoU 阈值nmsThreshold,则使其无效。用以下代码替换注释// TODO calculate IoU and compare against our threshold

if Float(objectBounds.bounds.computeIOU(
    other: otherObjectBounds.bounds)) > nmsThreshold{
    detectionConfidence[sortedIndices[j]] = 0.0
}

在这里,我们使用一个CGRect扩展方法computeIOU来处理计算。让我们看看这个在CGRect+Extension.swift文件中实现的代码:

extension CGRect{

    ...

    var area : CGFloat{
        get{
            return self.size.width * self.size.height
        }
    }

    func computeIOU(other:CGRect) -> CGFloat{
        return self.intersection(other).area / self.union(other).area
    }    
}

由于CGRect结构体中已经存在intersectionunion,这个方法既简洁又方便。

在我们完成YOLOFacade类以及 YOLO 算法之前,还有一件最后的事情要做,那就是返回结果。在filterDetectedObjects方法的底部,返回数组bestObjectsBounds;完成这个步骤后,我们现在可以将注意力转向实现智能搜索照片应用程序之前的功能的最后部分。

本章很好地强调了将机器学习集成到您的应用程序中的大部分工作都围绕着在将数据输入模型之前对数据进行预处理以及在模型输出上进行解释Vision 框架很好地减轻了预处理任务,但处理输出仍然需要大量的工作。幸运的是,无疑是因为对象检测对许多应用程序来说非常有吸引力,苹果明确添加了一个新的观察类型,用于对象检测,称为VNRecognizedObjectObservation。尽管我们在这里没有涉及;我鼓励您查阅官方文档developer.apple.com/documentation/vision/vnrecognizedobjectobservation

下一个功能部分是评估每个返回的检测对象相对于用户搜索标准的花费;我的意思是过滤和排序照片,以便结果与用户所寻求的内容相关。作为提醒,搜索标准由一个ObjectBounds数组定义,共同描述用户在图像中想要的对象、它们之间的相对位置,以及相对于彼此和图像本身的尺寸。以下图显示了用户如何在我们的应用程序中定义他们的搜索:

在这里,我们只实现四个评估中的两个,但这应该为你自己实现剩下的两个提供了一个足够的基础。

YOLOFacade返回所有图像的检测对象后,在PhotoSearcher类中执行成本评估。此代码位于asyncSearch方法(在PhotoSearcher.swift文件中),如下代码片段所示:

public func asyncSearch(
    searchCriteria : [ObjectBounds]?,
    costThreshold : Float = 5){
    DispatchQueue.global(qos: .background).async {
        let photos = self.getPhotosFromPhotosLibrary()

        let unscoredSearchResults = self.detectObjects(photos: photos)

        var sortedSearchResults : [SearchResult]?

        if let unscoredSearchResults = unscoredSearchResults{
            sortedSearchResults = self.calculateCostForObjects(
 detectedObjects:unscoredSearchResults,
 searchCriteria: searchCriteria).filter({
 (searchResult) -> Bool in
 return searchResult.cost < costThreshold
 }).sorted(by: { (a, b) -> Bool in
 return a.cost < b.cost
 })
        }

        DispatchQueue.main.sync {
            self.delegate?.onPhotoSearcherCompleted(
                status: 1,
                result: sortedSearchResults)
        }
    }
} 

calculateCostForObjects方法接收来自YOLOFacade的搜索条件和结果,并返回一个SearchResults数组,其中包含从detectObjects返回的具有成本属性的搜索结果,之后它们会被过滤和排序,然后返回给代理。

让我们深入到calculateCostForObjects方法,并讨论一下我们所说的成本;calculateCostForObjects方法的代码如下:

 private func calculateCostForObjects(
    detectedObjects:[SearchResult],
    searchCriteria:[ObjectBounds]?) -> [SearchResult]{

    guard let searchCriteria = searchCriteria else{
        return detectedObjects
    }

    var result = [SearchResult]()

    for searchResult in detectedObjects{
        let cost = self.costForObjectPresences(
            detectedObject: searchResult,
            searchCriteria: searchCriteria) +
            self.costForObjectRelativePositioning(
                detectedObject: searchResult,
                searchCriteria: searchCriteria) +
            self.costForObjectSizeRelativeToImageSize(
                detectedObject: searchResult,
                searchCriteria: searchCriteria) +
            self.costForObjectSizeRelativeToOtherObjects(
                detectedObject: searchResult,
                searchCriteria: searchCriteria)

        let searchResult = SearchResult(
            image: searchResult.image,
            detectedObjects:searchResult.detectedObjects,
            cost: cost)

        result.append(searchResult)
    }

    return result
}

SearchResult每次与用户的搜索条件不同时都会产生成本,这意味着成本最低的结果是与搜索条件匹配得更好的结果。我们对四个不同的启发式方法执行成本评估;每种方法将负责将计算出的成本添加到每个结果中。在这里,我们只实现costForObjectPresencescostForObjectRelativePositioning,剩下的两个将作为你的练习。

让我们直接进入并开始实现costForObjectPresences方法;目前,它不过是一个占位符,如下所示:

private func costForObjectPresences(
    detectedObject:SearchResult,
    searchCriteria:[ObjectBounds],
    weight:Float=2.0) -> Float{

    var cost : Float = 0.0

    // TODO implement cost function for object presence

    return cost * weight
}

在编写代码之前,让我们快速讨论一下我们要评估的内容。也许,这个函数更好的名字应该是costForDifference,因为我们不仅想要评估图像是否在搜索条件中声明了对象,而且我们也同样希望增加额外对象的成本。也就是说,如果用户只搜索了两只狗,但照片上有三只狗或两只狗和一只猫,我们希望增加这些额外对象的成本,以便我们更倾向于与搜索条件(仅两只狗)最相似的对象。

为了计算这个,我们只需要找到两个数组之间的绝对差异;为了做到这一点,我们首先为detectedObjectsearchCriteria中的所有类别创建一个计数字典。字典的键将是对象的标签,相应的值将是数组中对象的计数。以下图示了这些数组和用于计算的公式:

现在,让我们来实现它;添加以下代码来完成这个任务,替换掉注释// TODO implement cost function for object presence

var searchObjectCounts = searchCriteria.map {
    (detectedObject) -> String in
    return detectedObject.object.label
    }.reduce([:]) {
        (counter:[String:Float], label) -> [String:Float] in
        var counter = counter
        counter[label] = counter[label]?.advanced(by: 1) ?? 1
        return counter
}

var detectedObjectCounts = detectedObject.detectedObjects.map {
    (detectedObject) -> String in
    return detectedObject.object.label
    }.reduce([:]) {
        (counter:[String:Float], label) -> [String:Float] in
        var counter = counter
        counter[label] = counter[label]?.advanced(by: 1) ?? 1
        return counter
}

// TODO accumulate cost based on the difference

现在,随着我们的计数字典创建并填充,我们只需遍历所有可用的类别(使用DetectableObject.objects中的项)并根据两个数组之间的绝对差异来计算成本。添加以下代码,它执行此操作,通过替换注释// TODO accumulate cost based on the difference

for detectableObject in DetectableObject.objects{
    let label = detectableObject.label

    let searchCount = searchObjectCounts[label] ?? 0
    let detectedCount = detectedObjectCounts[label] ?? 0

    cost += abs(searchCount - detectedCount)
}

这的结果是对于与搜索条件差异最大的图像,成本更大;最后要指出的是,成本在返回之前乘以一个权重(函数参数)。每个评估方法都有一个权重参数,允许在设计和运行时轻松调整搜索,优先考虑一种评估方法而不是另一种。

我们接下来要实现的是下一个,也是最后一个成本评估函数,即costForObjectRelativePositioning方法;此方法的存根如下:

 private func costForObjectRelativePositioning(
    detectedObject:SearchResult,
    searchCriteria:[ObjectBounds],
    weight:Float=1.5) -> Float{

    var cost : Float = 0.0

    // TODO implement cost function for relative positioning

    return cost * weight
} 

正如我们之前所做的那样,让我们快速讨论一下这个评估背后的动机以及我们计划如何实现它。此方法用于优先考虑与用户搜索组成相匹配的项目;这允许我们的搜索显示与用户搜索的排列非常相似的图像。例如,用户可能正在寻找两张狗并排坐着或并排的图像,或者他们可能想要一张两张狗并排坐在沙发上的图像。

对于这个任务,无疑有许多你可以采取的方法,这可能是一个神经网络用例,但这里采取的方法是我能想到的最简单的方法,以避免不得不解释复杂的代码;所使用的算法描述如下:

  1. 对于searchCriteria中每个类型为ObjectBounds的对象(a

    1. 在邻近度(仍在searchCriteria内)中找到最近的对象(b

    2. ab创建一个归一化方向向量

    3. detectedObject中找到匹配的对象a'(相同的类别)

      1. detectedObject中搜索所有其他具有与b相同类别的对象(b'

        1. a'b'创建一个归一化方向向量

        2. 计算两个向量(角度)的点积;在这种情况下,我们的向量是a->ba'->b'

    4. 使用具有最低点积的a'b',根据角度与搜索条件的差异来增加成本

实质上,我们所做的是从searchCriteriadetectedObject数组中找到两个匹配的对,并基于角度差异来计算成本。

两个对象的方向向量是通过从一个对象的位置减去另一个对象并归一化它来计算的。然后可以在两个(归一化)向量上使用点积来找到它们的角度,其中如果向量指向同一方向,则返回1.0;如果它们垂直,则返回0.0;如果指向相反方向,则返回-1.0

下图展示了这一过程的一部分;我们首先在搜索条件中找到一对接近的对象。在计算点积后,我们遍历图像中检测到的所有对象,并找到最合适的对;“合适”在这里意味着相同的对象类型和可能的匹配对中与搜索条件最接近的角度:

图片

一旦找到可比较的成对对象,我们将根据角度差异计算成本,正如我们很快将看到的。但我们在自己之前走得太远了;我们首先需要一种找到最近对象的方法。让我们使用一个嵌套函数来实现,我们可以在costForObjectRelativePositioning方法中调用它。添加以下代码,替换掉注释// TODO implement cost function for relative positioning

func indexOfClosestObject(
    objects:[ObjectBounds],
    forObjectAtIndex i:Int) -> Int{

    let searchACenter = objects[i].bounds.center

    var closestDistance = Float.greatestFiniteMagnitude
    var closestObjectIndex : Int = -1

    for j in 0..<objects.count{
        guard i != j else{
            continue
        }

        let searchBCenter = objects[j].bounds.center
        let distance = Float(searchACenter.distance(other: searchBCenter))
        if distance < closestDistance{
            closestObjectIndex = j
            closestDistance = distance
        }
    }

    return closestObjectIndex
}

// TODO Iterate over all items in the searchCriteria array 

之前的功能将用于在给定ObjectBounds数组和我们要搜索的对象索引的情况下找到最近的对象。从那里,它只是遍历数组中的所有项目,返回那个,嗯,最近的对象。

现在我们已经实现了辅助函数,让我们创建一个循环来检查用户搜索标准中的搜索项对。将以下代码添加到costForObjectRelativePositioning方法中,替换掉注释// TODO Iterate over all items in the searchCriteria array

for si in 0..<searchCriteria.count{
    let closestObjectIndex = indexOfClosestObject(
        objects: searchCriteria,
        forObjectAtIndex: si)

    if closestObjectIndex < 0{
        continue
    }

    // Get object types
    let searchAClassIndex = searchCriteria[si].object.classIndex
    let searchBClassIndex = searchCriteria[closestObjectIndex].object.classIndex

    // Get centers of objects
    let searchACenter = searchCriteria[si].bounds.center
    let searchBCenter = searchCriteria[closestObjectIndex].bounds.center

    // Calcualte the normalised vector from A -> B
    let searchDirection = (searchACenter - searchBCenter).normalised

    // TODO Find matching pair
}  

我们首先搜索当前对象最近的对象,如果没有找到,则跳到下一个项目。一旦我们有了搜索对,我们就通过从第一个边界的中点减去其配对并归一化结果来计算方向。

现在我们需要找到两个类中的所有对象,我们将逐一评估它们以找到最佳匹配。在此之前,让我们先获取searchAClassIndexsearchBClassIndex索引的所有类;添加以下代码,替换掉注释// TODO Find matching pair

// Find comparable objects in detected objects
let detectedA = detectedObject.detectedObjects.filter {
    (objectBounds) -> Bool in
    objectBounds.object.classIndex == searchAClassIndex
}

let detectedB = detectedObject.detectedObjects.filter {
    (objectBounds) -> Bool in
    objectBounds.object.classIndex == searchBClassIndex
}

// Check that we have matching pairs
guard detectedA.count > 0, detectedB.count > 0 else{
    continue
}

// TODO Search for the most suitable pair

如果我们无法找到匹配的成对对象,我们将继续到下一个项目,知道已经为两个数组中对象的不匹配添加了成本。接下来,我们遍历所有成对对象。对于每一对,我们计算归一化方向向量,然后与我们的searchDirection向量计算点积,取点积最接近的那个(角度最接近)。将以下代码替换掉注释// TODO Search for the most suitable pair

var closestDotProduct : Float = Float.greatestFiniteMagnitude
for i in 0..<detectedA.count{
    for j in 0..<detectedB.count{
        if detectedA[i] == detectedB[j]{
            continue
        }

        let detectedDirection = (detectedA[i].bounds.center - detectedB[j].bounds.center).normalised
        let dotProduct = Float(searchDirection.dot(other: detectedDirection))
        if closestDotProduct > 10 ||
            (dotProduct < closestDotProduct &amp;&amp;
                dotProduct >= 0) {
            closestDotProduct = dotProduct
        }
    }
}

// TODO Add cost 

与我们处理搜索对的方式类似,我们通过减去成对的中心位置来计算方向向量,然后对结果进行归一化。然后,使用两个向量searchDirectiondetectedDirection,我们计算点积,如果它是第一个或迄今为止点积最低的,则保留引用。

对于这个方法和这个项目,我们还需要做最后一件事。但在这样做之前,让我们稍微偏离一下,看看对CGPoint所做的几个扩展,特别是之前使用的dotnormalize。这些扩展可以在CGPoint+Extension.swift文件中找到。像之前一样,我将列出代码以供参考,而不是描述细节,其中大部分我们已经讨论过了:

extension CGPoint{

    var length : CGFloat{
        get{
            return sqrt(
                self.x * self.x + self.y * self.y
            )
        }
    }

    var normalised : CGPoint{
        get{
            return CGPoint(
                x: self.x/self.length,
                y: self.y/self.length)
        }
    }

    func distance(other:CGPoint) -> CGFloat{
        let dx = (self.x - other.x)
        let dy = (self.y - other.y)

        return sqrt(dx*dx + dy*dy)
    }

    func dot(other:CGPoint) -> CGFloat{
        return (self.x * other.x) + (self.y * other.y)
    }

    static func -(left: CGPoint, right: CGPoint) -> CGPoint{
        return CGPoint(
            x: left.x - right.x,
            y: left.y - right.y)
    }
}   

现在,回到costForObjectRelativePositioning方法,完成我们的方法和项目。我们的最终任务是添加到成本中;这很简单,只需从1.0中减去存储的closestDotProduct(记住,我们希望增加较大差异的成本,其中两个归一化向量指向同一方向时点积为1.0),并通过将其包装在abs函数中来确保值是正的。现在让我们来做这件事;添加以下代码,替换注释// TODO add cost

cost += abs((1.0-closestDotProduct))

完成这些工作后,我们已经完成了这个方法,以及本章的编码。做得好!现在是时候测试一下了;构建并运行项目,看看你的辛勤工作是如何发挥作用的。下面展示了一些搜索及其结果:

尽管 YOLO 算法性能良好,适用于近实时使用,但我们的示例远未优化,不太可能在大量照片集上表现良好。随着 Core ML 2 的发布,Apple 提供了一条途径,我们可以用它来使我们的过程更高效。这将是下一节的主题,在总结之前。

批量优化

目前,我们的过程涉及遍历每一张照片并对每一张单独进行推理。随着 Core ML 2 的发布,我们现在可以选择创建一个批处理并将这个批处理传递给我们的模型进行推理。就像规模经济带来的效率一样,在这里,我们也获得了显著的改进;所以让我们通过将我们的项目调整为以单个批处理而不是单个处理我们的照片来适应我们的项目。

让我们从栈的底部开始工作,从我们的YOLOFacade类开始,然后移动到PhotoSearcher。为此,我们将直接使用我们的模型而不是通过 Vision 代理,所以我们的第一个任务是替换YOLOFacade类的model属性,如下所示:

let model = tinyyolo_voc2007().model

现在,让我们重写detectObjects方法,以处理照片数组而不是单个实例;因为大多数更改都集中在这里,我们将从头开始。所以,请从你的YOLOFacade类中删除该方法,并用以下存根替换它:

func detectObjects(photos:[UIImage], completionHandler:(_ result:[[ObjectBounds]]?) -> Void){

    // TODO batch items (array of MLFeatureProvider)

    // TODO Wrap our items in an instance of MLArrayBatchProvider 

    // TODO Perform inference on the batch 

 // TODO (As we did before) Process the outputs of the model 

 // TODO Return results via the callback handler 
}

我已经对方法的签名进行了修改,并列举了我们的剩余任务。第一个任务是创建一个MLFeatureProvider数组。如果你还记得第三章中的内容,识别世界中的物体,当我们将 Core ML 模型导入 Xcode 时,它会为模型、输入和输出生成接口。输入和输出是MLFeatureProvider的子类,因此在这里我们想要创建一个tinyyolo_voc2007Input数组,它可以由CVPixelBuffer的实例化对象来实例化。

要创建这个,我们将转换传递给方法的照片数组,包括所需的预处理步骤(调整大小为 416 x 416)。将注释// TODO batch items (array of MLFeatureProvider)替换为以下代码:

let X = photos.map({ (photo) -> tinyyolo_voc2007Input in
    guard let ciImage = CIImage(image: photo) else{
        fatalError("\(#function) Failed to create CIImage from UIImage")
    }
    let cropSize = CGSize(
        width:min(ciImage.extent.width, ciImage.extent.height),
        height:min(ciImage.extent.width, ciImage.extent.height))

    let targetSize = CGSize(width:416, height:416)

    guard let pixelBuffer = ciImage
        .centerCrop(size:cropSize)?
        .resize(size:targetSize)
        .toPixelBuffer() else{
        fatalError("\(#function) Failed to create CIImage from UIImage")
    }

    return tinyyolo_voc2007Input(image:pixelBuffer)

}) 

// TODO Wrap our items in an instance of MLArrayBatchProvider 

// TODO Perform inference on the batch 

// TODO (As we did before) Process the outputs of the model 

// TODO Return results via the callback handler 

为了简单和可读性,我们省略了任何类型的错误处理;显然,在生产环境中,您需要适当地处理异常。

要对一个批次进行推理,我们需要确保我们的输入符合MLBatchProvider接口。幸运的是,Core ML 提供了一个具体的实现,它方便地封装了数组。现在让我们来做这件事;将注释// TODO Wrap our items in an instance of MLArrayBatchProvider替换为以下代码:

let batch = MLArrayBatchProvider(array:X)

// TODO Perform inference on the batch 

// TODO (As we did before) Process the outputs of the model 

// TODO Return results via the callback handler 

要进行推理,只需在我们的模型上调用predictions方法即可;像往常一样,将注释// TODO Perform inference on the batch替换为以下代码:

guard let batchResults = try? self.model.predictions(
    from: batch,
    options: MLPredictionOptions()) else{
        completionHandler(nil)
        return
}

// TODO (As we did before) Process the outputs of the model 
// TODO Return results via the callback handler 

我们得到的是一个MLBatchProvider的实例(如果成功);这基本上是我们每个样本(输入)的结果集合。我们可以通过批提供者的features(at: Int)方法访问特定的结果,该方法返回一个MLFeatureProvider的实例(在我们的案例中,是tinyyolo_voc2007Output)。

在这里,我们简单地像之前一样处理每个结果,以获得最显著的结果;将注释// TODO (As we did before) Process the outputs of the model替换为以下代码:

var results = [[ObjectBounds]]()

for i in 0..<batchResults.count{
    var iResults = [ObjectBounds]()

    if let features = batchResults.features(at: i)
        as? tinyyolo_voc2007Output{

        if let observationDetectObjects = self.detectObjectsBounds(
            array: features.output){

            for detectedObject in observationDetectObjects.map(
                {$0.transformFromCenteredCropping(
                    from: photos[i].size,
                    to: self.targetSize)}){

                    iResults.append(detectedObject)
            }

        }
    }
    results.append(iResults)
}

// TODO Return results via the callback handler 

与之前相比,唯一的区别是我们现在正在迭代一个输出批次而不是单个输出。我们需要做的最后一件事是调用处理器;将注释// TODO Return results via the callback handler替换为以下语句:

completionHandler(results)

现在已经完成了对YOLOFacade类所需的所有更改;让我们跳转到PhotoSearcher并做出必要的、最后的更改。

这里的重大变化是我们现在需要一次性传递所有照片,而不是逐个传递。定位detectObjects方法,并用以下代码替换其主体:

var results = [SearchResult]()

yolo.detectObjects(photos: photos) { (photosObjectBounds) in
    if let photosObjectBounds = photosObjectBounds,
        photos.count == photosObjectBounds.count{
        for i in 0..<photos.count{
            results.append(SearchResult(
                image: photos[i],
                detectedObjects: photosObjectBounds[i],
                cost: 0.0))
        }
    }
}

return results

代码相同,但组织得略有不同,以处理从YOLOFacade类输入和输出的批次。现在是构建、部署和运行应用程序的好时机;特别注意从适应批量推理中获得的高效性。当你回来时,我们将用简要的总结结束本章。

概述

在本章中,我们介绍了目标检测的概念,将其与目标识别和目标定位进行了比较。虽然其他两个概念仅限于单个主要目标,但目标检测允许多目标分类,包括预测它们的边界框。然后我们花了一些时间介绍了一个特定的算法 YOLO,在熟悉 Apple 的 Core ML Tools Python 包之后,我们逐步将训练好的 Keras 模型转换为 Core ML。一旦我们有了模型,我们就继续用 Swift 实现 YOLO,目标是创建一个智能搜索应用程序。

尽管这是一章相当长的内容,但我希望您觉得它很有价值,并且对深度神经网络如何学习和理解图像以及它们如何以新颖的方式应用于创造新体验有了更深的直觉。提醒自己,使用相同的架构,我们只需更换训练数据,就可以创建新的应用。例如,您可以在手及其对应边界框的数据集上训练这个模型,通过允许用户通过触摸与数字内容互动,从而创建一个更加沉浸式的增强现实(AR)体验。

但现在,让我们继续我们的 Core ML 理解之旅,探索我们还可以如何应用它。在下一章中,您将看到流行的 Prisma 如何通过风格转换创建那些令人惊叹的照片。

第六章:使用风格迁移创作艺术

在本章中,我们将探讨 2017 年最流行的主流深度学习应用之一——风格迁移。我们首先介绍风格迁移的概念,然后是其更快的替代方案,即快速神经风格迁移。与其他章节类似,我们将提供模型背后的直觉(而不是细节),通过这样做,您将获得对深度学习算法潜力的更深入理解和欣赏。与之前的章节不同,本章将更多地关注使模型在 iOS 上工作所需的步骤,而不是构建应用程序,以保持内容的简洁性。

到本章结束时,您将实现以下目标:

  • 对风格迁移的工作原理有了直观的理解

  • 通过使用 Core ML Tools Python 包和自定义层,获得了在 Core ML 中使 Keras 模型工作的实际经验

让我们从介绍风格迁移并建立对其工作原理的理解开始。

将风格从一个图像转移到另一个图像

想象一下,能够让历史上最伟大的画家之一,如文森特·梵高或巴勃罗·毕加索,用他们独特的风格重新创作一张您喜欢的照片。简而言之,这就是风格迁移允许我们做的事情。简单来说,它是一个使用另一个内容生成照片的过程,其风格来自另一个,如下所示:

图片

在本节中,我们将描述(尽管是高层次地)它是如何工作的,然后转向一个允许我们在显著更短的时间内执行类似过程的替代方案。

我鼓励您阅读 Leon A. Gatys、Alexander S. Ecker 和 Matthias Bethge 撰写的原始论文,《艺术风格神经算法》,以获得更全面的概述。这篇论文可在arxiv.org/abs/1508.06576找到。

到目前为止,我们已经了解到神经网络通过迭代减少损失来学习,损失是通过使用某些指定的成本函数计算的,该函数用于指示神经网络在预期输出方面的表现如何。然后,预测输出预期输出之间的差异被用来通过称为反向传播的过程调整模型的权重,以最小化这种损失。

上述描述(有意)省略了此过程的细节,因为我们的目标是提供直观的理解,而不是细节。我建议阅读 Andrew Trask 的《Grokking Deep Learning》以获得对神经网络底层细节的温和介绍。

与我们迄今为止所使用的分类模型不同,其中输出是跨越某些标签集的概率分布,我们更感兴趣的是模型生成能力。也就是说,我们不是调整模型的权重,而是想调整生成图像的像素值,以减少一些定义的成本函数。

因此,如果我们定义一个成本函数来衡量生成图像和内容图像之间的损失,以及另一个来衡量生成图像和风格图像之间的损失,我们就可以简单地合并它们。这样我们就得到了整体损失,并使用这个损失来调整生成图像的像素值,以创建一个具有目标内容的目标风格的图像,如图所示:

图片

在这个阶段,我们已经对所需的过程有一个大致的了解;剩下的是建立对这些成本函数背后的直觉。也就是说,你是如何确定你生成的图像在内容图像的某些内容和风格图像的风格方面有多好?为此,我们将稍微回顾一下,通过检查每个激活来了解 CNN 的其他层是如何学习的。

关于卷积神经网络CNNs)学习细节和图像的详细信息,来自 Matthew D. Zeiler 和 Rob Fergus 的论文《可视化与理解卷积网络》,可在arxiv.org/abs/1311.2901找到。

CNN 的典型架构由一系列卷积和池化层组成,然后输入到一个全连接网络(用于分类情况),如图所示:

图片

这种平面表示忽略了 CNN 的一个重要特性,即在每一对后续的卷积和池化层之后,输入的宽度和高度都会减小。这种结果就是感受野向网络深处增加;也就是说,深层有更大的感受野,因此比浅层捕获更高层次的特征。

为了更好地说明每一层学习的内容,我们将参考 Matthew D. Zeiler 和 Rob Fergus 的论文《可视化与理解卷积网络》。在他们之前的论文中,他们通过将训练集中的图像传递过去,以识别最大化每一层激活的图像块;通过可视化这些块,我们可以了解每一层的每个神经元(隐藏单元)学习到了什么。以下是 CNN 中一些这些块的截图:

图片

来源:《可视化与理解卷积网络》;Matthew D Zeiler, Rob Fergus

在前面的图中,你可以看到九个图像块,这些图像块在每个网络层的每个隐藏单元中最大化。前面图中省略的是尺寸上的变化;也就是说,你走得越深,图像块就会越大。

从前面的图像中可以明显看出,较浅的层提取简单的特征。例如,我们可以看到层 1的单个隐藏单元被对角线边缘激活,而层 2的单个隐藏单元则被垂直条纹块激活。而较深的层提取高级特征或更复杂的特征,再次,在前面图中,我们可以看到层 4的单个隐藏单元被狗脸的块激活。

我们回到定义内容与风格成本函数的任务,首先从内容成本函数开始。给定一个内容图像和一个生成图像,我们想要测量我们有多接近,以便最小化这种差异,从而保留内容。我们可以通过选择我们之前看到的具有大感受野的 CNN 中的一个较深层来实现这一点,它能够捕捉复杂的特征。我们通过内容图像和生成图像传递,并测量输出激活(在这一层)之间的距离。这可能会在更深层的网络学习复杂特征,如狗的脸或汽车的情况下显得合乎逻辑,但将它们与较低级别的特征(如边缘、颜色和纹理)解耦。以下图展示了这一过程:

图片

这就解决了我们的内容成本函数问题,可以通过运行实现此功能的网络来轻松测试。如果实现正确,它应该会产生一个看起来与输入(内容图像)相似的生成图像。现在,让我们将注意力转向测量风格。

在前面的图中,我们看到了网络的较浅层学习简单的特征,如边缘、纹理和颜色组合。这为我们提供了关于在尝试测量风格时哪些层可能有用的线索,但我们仍然需要一种提取和测量风格的方法。然而,在我们开始之前,究竟什么是风格?

www.dictionary.com/上进行快速搜索,可以发现“风格”被定义为“一种独特的外观,通常由设计时所依据的原则决定”。让我们以葛饰北斋的《神奈川冲浪里》为例:

图片

神奈川冲浪里是称为木版印刷的过程的输出;这是艺术家草图被分解成层(雕刻的木块),每个层(通常每个颜色一个)用于复制艺术品。它类似于手动印刷机;这个过程产生了一种独特的平坦和简化的风格。在前面的图像中还可以看到另一种主导风格(以及可能的副作用),那就是使用了有限的颜色范围;例如,水由不超过四种颜色组成。

我们可以捕获风格的方式,如 L. Gatys、A. Ecker 和 M. Bethge 在论文《艺术风格神经算法》中定义的那样。这种方式是使用风格矩阵(也称为gram 矩阵)来找到给定层中不同通道之间激活的相关性。正是这些相关性定义了风格,并且我们可以用它来衡量我们的风格图像和生成的图像之间的差异,从而影响生成的图像的风格。

为了使这一点更加具体,借鉴 Andrew Ng 在他的 Coursera 深度学习课程中使用的例子,让我们从之前的例子中取层 2。风格矩阵计算的是给定层中所有通道之间的相关性。如果我们使用以下插图,展示两个通道的九个激活,我们可以看到第一通道的垂直纹理与第二通道的橙色块之间存在相关性。也就是说,当我们看到第一通道中的垂直纹理时,我们预计最大化第二通道激活的图像块将带有橙色:

图片

这个风格矩阵为风格图像和生成的图像都进行了计算,我们的优化迫使生成的图像采用这些相关性。计算完这两个风格矩阵后,我们可以通过简单地找到两个矩阵之间平方差的和来计算损失。以下图示说明了这个过程,就像我们之前在描述内容损失函数时做的那样:

图片

有了这些,我们现在已经完成了对风格迁移的介绍,并希望给你一些关于如何使用网络对图像的感知理解来提取内容和风格的直观感受。这种方法效果很好,但有一个缺点,我们将在下一节中解决。

转移风格的更快方法

如你所从本节标题中推断出的,前节中介绍的方法的一个主要缺点是,该过程需要迭代优化,以下图示总结了这一点:

图片

这种优化在执行许多迭代以最小化损失方面类似于训练。因此,即使使用一台普通的计算机,这也通常需要相当多的时间。正如本书开头所暗示的,我们理想情况下希望将自己限制在边缘进行推理,因为它需要的计算能力显著较低,并且可以在接近实时的情况下运行,使我们能够将其用于交互式应用程序。幸运的是,在他们的论文《用于实时风格迁移和超分辨率的感知损失》中,J. Johnson、A. Alahi 和 L. Fei-Fei 描述了一种将风格迁移的训练(优化)和推理解耦的技术。

之前,我们描述了一个网络,它以生成图像、风格图像和内容图像作为输入。该网络通过迭代调整生成图像,使用内容和风格的损失函数来最小化损失;这提供了灵活性,允许我们插入任何风格和内容图像,但代价是计算成本高,即速度慢。如果我们牺牲这种灵活性以换取性能,将自己限制在单一风格,并且不执行生成图像的优化,而是训练一个 CNN 会怎样?CNN 将学习风格,一旦训练完成,就可以通过网络的单次遍历(推理)来生成风格化的图像。这正是论文《用于实时风格迁移和超分辨率的感知损失》所描述的,也是我们将在本章中使用的网络。

为了更好地阐明先前方法和这种方法之间的区别,请花一点时间回顾并比较前面的图与以下图:

图片

与先前的方法不同,在先前的方法中,我们针对一组给定内容、风格和生成图像进行优化,并调整生成图像以最小化损失,我们现在向 CNN 提供一组内容图像,并让网络生成图像。然后,我们执行与之前描述的相同损失函数,针对单一风格。但是,我们不是调整生成图像,而是使用损失函数的梯度来调整网络的权重。我们重复这个过程,直到我们足够地最小化了所有内容图像的平均损失。

现在,随着我们的模型训练完成,我们可以让我们的网络通过单次遍历来风格化图像,如图所示:

图片

在前两节中,我们以高层次概述了这些网络的工作原理。现在,是时候构建一个利用所有这些功能的应用程序了。在下一节中,我们将快速浏览如何将训练好的 Keras 模型转换为 Core ML,然后再继续本章的主要主题——为 Core ML 实现自定义层。

将 Keras 模型转换为 Core ML

与我们在上一章中所做的一样,在本节中,我们将使用 Core ML Tools 包将训练好的 Keras 模型转换为 Core ML 模型。为了避免在您的本地或远程机器上设置环境的任何复杂性,我们将利用微软提供的免费 Jupyter 云服务。访问 notebooks.azure.com 并登录(如果您还没有,请先注册)。

登录后,点击导航栏中的“库”菜单链接,这将带您到一个包含您所有库列表的页面,类似于以下截图所示:

图片

接下来,点击“新建库”链接以打开创建新库对话框:

图片

然后,点击“从 GitHub”标签,并在 GitHub 仓库字段中输入 https://github.com/packtpublishing/machine-learning-with-core-ml。之后,给您的库起一个有意义的名字,并点击导入按钮以开始克隆仓库并创建库的过程。

创建库后,您将被重定向到根目录。从那里,点击 Chapter6/Notebooks 文件夹以打开本章的相关文件夹,最后点击笔记本 FastNeuralStyleTransfer_Keras2CoreML.ipynb。以下是点击 Chapter6 文件夹后您应该看到的截图:

图片

讨论笔记本的细节,包括网络和训练的细节超出了本书的范围。对于好奇的读者,我在 training 文件夹中的 chapters 文件夹内包含了本书中使用的每个模型的原始笔记本。

我们的笔记本现在已加载,是时候逐个遍历每个单元格来创建我们的 Core ML 模型了;所有必要的代码都已存在,剩下的只是依次执行每个单元格。要执行一个单元格,你可以使用快捷键 Shift + Enter 或者点击工具栏中的运行按钮(这将运行当前选中的单元格),如下面的截图所示:

图片

我将简要解释每个单元格的作用。确保我们在遍历它们时执行每个单元格,这样我们最终都能得到转换后的模型,然后我们可以将其下载并导入到我们的 iOS 项目中:

import helpers
reload(helpers)

我们首先导入一个包含创建并返回我们想要转换的 Keras 模型的函数的模块:

model = helpers.build_model('images/Van_Gogh-Starry_Night.jpg')

我们接着使用我们的 helpers 方法 build_model 来创建模型,传入模型训练所用的风格图像。请记住,我们正在使用一个在单个风格上训练的前馈网络;虽然该网络可以用于不同的风格,但每个风格的权重是唯一的。

调用 build_model 将需要一些时间来返回;这是因为模型使用了一个在返回之前下载的已训练模型(VGG16)。

说到权重(之前训练的模型),现在让我们通过运行以下单元格来加载它们:

model.load_weights('data/van-gogh-starry-night_style.h5')

与上述代码类似,我们传递了在文森特·梵高的《星夜》画作上训练的模型的权重,用于其风格。

接下来,让我们通过在模型本身上调用 summary 方法来检查模型的架构:

model.summary()

调用此方法将返回,正如其名称所暗示的,我们模型的摘要。以下是生成的摘要摘录:

____________________________________________________________________
Layer (type) Output Shape Param # Connected to 
====================================================================
input_1 (InputLayer) (None, 320, 320, 3) 0 
____________________________________________________________________
zero_padding2d_1 (ZeroPadding2D) (None, 400, 400, 3) 0 input_1[0][0] 
____________________________________________________________________
conv2d_1 (Conv2D) (None, 400, 400, 64) 15616 zero_padding2d_1[0][0] 
____________________________________________________________________
batch_normalization_1 (BatchNorm (None, 400, 400, 64) 256 conv2d_1[0][0] 
____________________________________________________________________
activation_1 (Activation) (None, 400, 400, 64) 0 batch_normalization_1[0][0] 
____________________________________________________________________
...
...
____________________________________________________________________
res_crop_1 (Lambda) (None, 92, 92, 64) 0 add_1[0][0] 
____________________________________________________________________
...
... 
____________________________________________________________________
rescale_output (Lambda) (None, 320, 320, 3) 0 conv2d_16[0][0] 
====================================================================
Total params: 552,003
Trainable params: 550,083
Non-trainable params: 1,920

如前所述,深入探讨 Python、Keras 或该模型的细节超出了范围。相反,我在这里提供了一段摘录,以突出模型中嵌入的自定义层(粗体行)。在 Core ML Tools 的上下文中,自定义层是指尚未定义的层,因此它们在转换过程中不会被处理,因此处理这些层的责任在我们。您可以将转换过程视为将机器学习框架(如 Keras)中的层映射到 Core ML 的过程。如果没有映射存在,那么就由我们来填写细节,如下面的图示所示:

图片

之前显示的两个自定义层都是 Lambda 层;Lambda 层是一个特殊的 Keras 类,它方便地允许使用函数或 Lambda 表达式(类似于 Swift 中的闭包)来编写快速且简单的层。Lambda 层对于没有状态的层非常有用,在 Keras 模型中常见,用于执行基本计算。这里我们看到两个被使用,res_croprescale_output

res_crop 是 ResNet 模块的一部分,它裁剪输出(正如其名称所暗示的);该函数足够简单,其定义如下面的代码所示:

def res_crop(x):
    return x[:, 2:-2, 2:-2] 

我建议您阅读 K. He、X. Zhang、S. Ren 和 J. Sun 的论文《用于图像识别的深度残差学习》,以了解更多关于 ResNet 和残差块的信息,该论文可在以下链接找到:arxiv.org/pdf/1512.03385.pdf

实际上,这所做的一切就是使用宽度高度轴上的填充 2 来裁剪输出。我们可以通过运行以下单元格来进一步调查这个层的输入和输出形状:

res_crop_3_layer = [layer for layer in model.layers if layer.name == 'res_crop_3'][0] 

print("res_crop_3_layer input shape {}, output shape {}".format(
    res_crop_3_layer.input_shape, res_crop_3_layer.output_shape))

此单元格打印了层 res_crop_3_layer 的输入和输出形状;该层接收形状为 (None, 88, 88, 64) 的张量,并输出形状为 (None, 84, 84, 64) 的张量。这里元组被分解为:(批量大小,高度,宽度,通道)。批量大小设置为 None,表示它在训练过程中动态设置。

我们下一个 Lambda 层是 rescale_output;这是在网络末尾用于将 Convolution 2D 层的输出重新缩放,该层通过 tanh 激活传递数据。这迫使我们的数据被限制在 -1.0 和 1.0 之间,而我们需要它在 0 到 255 的范围内,以便将其转换为图像。像之前一样,让我们看看它的定义,以更好地了解这个层的作用,如下面的代码所示:

def rescale_output(x):
    return (x+1)*127.5 

此方法执行一个元素级操作,将值 -1.0 和 1.0 映射到 0 和 255。类似于前面的方法 (res_crop),我们可以通过运行以下单元格来检查这个层的输入和输出形状:

rescale_output_layer = [layer for layer in model.layers if layer.name == 'rescale_output'][0]

print("rescale_output_layer input shape {}, output shape {}".format(
    rescale_output_layer.input_shape, 
    rescale_output_layer.output_shape))

一旦运行,这个单元格将打印出层的输入形状为 (None, 320, 320, 3) 和输出形状为 (None, 320, 320, 3)。这告诉我们这个层不会改变张量的形状,同时也显示了我们的图像输出维度为 320 x 320,具有三个通道(RGB)。

我们现在已经审查了自定义层,并看到了它们实际的功能;下一步是执行实际的转换。运行以下单元格以确保环境中已安装 Core ML Tools 模块:

!pip install coremltools

一旦安装,我们可以通过运行以下单元格来加载所需的模块:

import coremltools
from coremltools.proto import NeuralNetwork_pb2, FeatureTypes_pb2

在这种情况下,我提前警告你我们的模型包含自定义层;在某些(如果不是大多数)情况下,你可能会在转换过程失败时发现这一点。让我们通过运行以下单元格并检查其输出来看一下具体是什么样子:

coreml_model = coremltools.converters.keras.convert(
    model, 
    input_names=['image'], 
    image_input_names=['image'], 
    output_names="output")

在前面的代码片段中,我们将我们的模型传递给 coremltools.converters.keras.convert 方法,该方法负责将我们的 Keras 模型转换为 Core ML。除了模型外,我们还传递了模型的输入和输出名称,以及设置 image_input_names 以告知该方法我们希望输入 image 被视为图像而不是多维数组。

如预期,运行这个单元格后,你会收到一个错误。如果你滚动到输出的底部,你会看到行 ValueError: Keras layer '<class 'keras.layers.core.Lambda'>' not supported。在这个阶段,你需要审查你模型的架构以确定导致错误的层,并继续你即将要做的事情。

通过在转换调用中启用参数 add_custom_layers,我们防止转换器在遇到它不认识的层时失败。作为转换过程的一部分,将插入一个名为 custom 的占位符层。除了识别自定义层外,我们还可以将 delegate 函数传递给参数 custom_conversion_functions,这允许我们向模型的规范中添加元数据,说明如何处理自定义层。

现在让我们创建这个 delegate 方法;运行以下代码的单元格:

def convert_lambda(layer):
    if layer.function.__name__ == 'rescale_output':
        params = NeuralNetwork_pb2.CustomLayerParams()
        params.className = "RescaleOutputLambda"
        params.description = "Rescale output using ((x+1)*127.5)"
        return params
    elif layer.function.__name__ == 'res_crop':
        params = NeuralNetwork_pb2.CustomLayerParams()
        params.className = "ResCropBlockLambda"
        params.description = "return x[:, 2:-2, 2:-2]"
        return params
    else:
        raise Exception('Unknown layer')
    return None 

这个delegate会传递转换器遇到的每个自定义层。因为我们处理的是两个不同的层,所以我们首先检查我们正在处理哪个层,然后继续创建并返回一个CustomLayerParams实例。这个类允许我们在创建模型规范时添加一些元数据,用于 Core ML 转换。在这里,我们设置其className,这是我们在 iOS 项目中实现这个层的 Swift(或 Objective-C)类的名称,以及description,这是在 Xcode 的 ML 模型查看器中显示的文本。

现在我们已经实现了delegate方法,让我们重新运行转换器,传递适当的参数,如下面的代码所示:

coreml_model = coremltools.converters.keras.convert(
    model, 
    input_names=['image'], 
    image_input_names=['image'], 
    output_names="output",
    add_custom_layers=True,
    custom_conversion_functions={ "Lambda": convert_lambda })

如果一切顺利,你应该会看到转换器输出它访问的每一层,没有错误消息,最后返回一个 Core ML 模型实例。现在我们可以给我们的模型添加元数据,这是在 Xcode 的 ML 模型视图中显示的内容:

coreml_model.author = 'Joshua Newnham'
coreml_model.license = 'BSD'
coreml_model.short_description = 'Fast Style Transfer based on the style of Van Gogh Starry Night'
coreml_model.input_description['image'] = 'Preprocessed content image'
coreml_model.output_description['output'] = 'Stylized content image' 

在这个阶段,我们可以保存模型并将其导入 Xcode,但我想再做一些事情来让我们的生活更轻松。在本质上(请原谅这个双关语),Core ML 模型是 Xcode 在导入时用于构建模型的网络规范(包括模型描述、模型参数和元数据)。我们可以通过调用以下语句来获取对这个规范的引用:

spec = coreml_model.get_spec() 

参考模型的规范,我们接下来搜索输出层,如下面的代码片段所示:

output = [output for output in spec.description.output if output.name == 'output'][0]

我们可以通过简单地打印出来来检查输出;运行以下代码的单元格来完成这个操作:

output

你应该会看到类似以下的内容:

name: "output"
shortDescription: "Stylized content image"
type {
  multiArrayType {
    shape: 3
    shape: 320
    shape: 320
    dataType: DOUBLE
  }
}

注意类型,目前是multiArrayType(它在 iOS 中的对应物是MLMultiArray)。这没问题,但需要我们显式地将其转换为图像;如果模型直接输出图像而不是多维数组会方便得多。我们可以通过简单地修改规范来实现这一点。具体来说,在这个例子中,这意味着填充类型的imageType属性,以提示 Xcode 我们期望一个图像。现在让我们通过运行带有以下代码的单元格来完成这个操作:

output.type.imageType.colorSpace = FeatureTypes_pb2.ImageFeatureType.ColorSpace.Value('RGB') 

output.type.imageType.width = width 
output.type.imageType.height = height

coreml_model = coremltools.models.MLModel(spec) 

我们首先设置颜色空间为 RGB,然后设置图像的预期宽度和高度。最后,我们通过传递更新后的规范并使用语句coremltools.models.MLModel(spec)创建一个新的模型。现在,如果你查询输出,你应该会看到以下类似输出:

name: "output"
shortDescription: "Stylized content image"
type {
  imageType {
    width: 320
    height: 320
    colorSpace: RGB
  }
}

我们已经为执行这个转换节省了大量代码;我们的最后一步是在将其导入 Xcode 之前保存模型。运行最后一个单元格,它正是这样做的:

coreml_model.save('output/FastStyleTransferVanGoghStarryNight.mlmodel')

在关闭浏览器之前,让我们下载模型。你可以通过返回到 Chapter6/Notebooks 目录并深入到 output 文件夹来完成此操作。在这里,你应该能看到文件 FastStyleTransferVanGoghStarryNight.mlmodel;只需右键单击它并选择下载菜单项(或者通过左键单击并选择下载工具栏项):

拥有我们的模型后,现在是时候跳入 Xcode 并实现那些自定义层了。

在 Swift 中构建自定义层

在本节中,我们将主要关注实现模型所依赖的自定义层,并且通过使用现有的模板——你无疑已经非常熟悉的结构——来省略应用的大部分细节。

如果你还没有这样做,请从配套的仓库中拉取最新的代码:github.com/packtpublishing/machine-learning-with-core-ml。下载后,导航到目录 Chapter6/Start/StyleTransfer/ 并打开项目 StyleTransfer.xcodeproj。一旦加载,你将看到本章的项目:

应用程序由两个视图控制器组成。第一个,CameraViewController,为用户提供相机实时流和拍照的能力。拍照时,控制器会展示另一个视图控制器 StyleTransferViewController,并传递捕获的相片。StyleTransferViewController 然后展示图像,并在底部包含一个水平 CollectionView,其中包含用户可以通过点击选择的样式集。

每当用户选择一种样式时,控制器都会更新 ImageProcessors 样式属性,然后调用其方法 processImage,传入指定的图像。正是在这里,我们将实现将图像传递给模型并通过指定的代理 onImageProcessorCompleted 方法返回结果的功能,然后将其展示给用户。

现在,随着我们的项目已加载,让我们导入我们刚刚创建的模型;找到下载的 .mlmodel 文件并将其拖放到 Xcode 中。一旦导入,我们从左侧面板中选择它来检查元数据,以提醒自己需要实现的内容:

通过检查模型,我们可以看到它期望一个大小为 320 x 320 的输入 RGB 图像,并且它将以相同的尺寸输出图像。我们还可以看到模型期望两个名为 ResCropBlockLambdaRescaleOutputLambda 的自定义层。在实现这些类之前,让我们将模型连接起来,并且为了好玩,看看在没有实现自定义层的情况下尝试运行它会发生什么。

从左侧面板选择ImageProcessor.swift;在这个项目中,我们将使用 Vision 框架来完成所有预处理。首先,在ImageProcessor类的主体中添加以下属性,例如在style属性下方:

lazy var vanCoghModel : VNCoreMLModel = {
    do{
        let model = try VNCoreMLModel(for: FastStyleTransferVanGoghStarryNight().model)
        return model
    } catch{
        fatalError("Failed to obtain VanCoghModel")
    }
}()

第一个属性返回一个VNCoreMLModel实例,封装了我们的FastStyleTransferVanGoghStarryNight模型。封装我们的模型是必要的,以便使其与 Vision 框架的请求类兼容。

在下面添加以下片段,它将负责根据所选样式返回适当的VNCoreMLModel

var model : VNCoreMLModel{
    get{
        if self.style == .VanCogh{
            return self.vanCoghModel
        }

        // default
        return self.vanCoghModel
    }
}

最后,我们创建一个方法,它将负责根据当前所选模型(由当前的style确定)返回一个VNCoreMLRequest实例:

func getRequest() -> VNCoreMLRequest{
    let request = VNCoreMLRequest(
        model: self.model,
        completionHandler: { [weak self] request, error in
            self?.processRequest(for: request, error: error)
        })
    request.imageCropAndScaleOption = .centerCrop
    return request
}

VNCoreMLRequest负责在将输入图像传递给分配的 Core ML 模型之前对其进行必要的预处理。我们实例化VNCoreMLRequest,传入一个完成处理程序,当调用时,它将简单地将其结果传递给ImageProcessor类的processRequest方法。我们还设置了imageCropAndScaleOption.centerCrop,以便我们的图像在保持其宽高比的同时调整大小到 320 x 320(如果需要,裁剪中心图像的最长边)。

现在我们已经定义了属性,是时候跳转到processImage方法来启动实际的工作了;添加以下代码(以粗体显示,并替换// TODO注释):

public func processImage(ciImage:CIImage){        
    DispatchQueue.global(qos: .userInitiated).async {
 let handler = VNImageRequestHandler(ciImage: ciImage)
 do {
 try handler.perform([self.getRequest()])
 } catch {
 print("Failed to perform classification.\n\(error.localizedDescription)")
 }
    }
}

前面的方法是我们的图像风格化入口点;我们首先实例化一个VNImageRequestHandler实例,传入图像,并通过调用perform方法启动过程。一旦分析完成,请求将调用我们分配给它的delegate,即processRequest,传入相关请求和结果(如果有错误)。现在让我们具体实现这个方法:

func processRequest(for request:VNRequest, error: Error?){
    guard let results = request.results else {
        print("ImageProcess", #function, "ERROR:",
              String(describing: error?.localizedDescription))
        self.delegate?.onImageProcessorCompleted(
            status: -1,
            stylizedImage: nil)
        return
    }

    let stylizedPixelBufferObservations =
        results as! [VNPixelBufferObservation]

    guard stylizedPixelBufferObservations.count > 0 else {
        print("ImageProcess", #function,"ERROR:",
              "No Results")
        self.delegate?.onImageProcessorCompleted(
            status: -1,
            stylizedImage: nil)
        return
    }

    guard let cgImage = stylizedPixelBufferObservations[0]
        .pixelBuffer.toCGImage() else{
        print("ImageProcess", #function, "ERROR:",
              "Failed to convert CVPixelBuffer to CGImage")
        self.delegate?.onImageProcessorCompleted(
            status: -1,
            stylizedImage: nil)
        return
    }

    DispatchQueue.main.sync {
        self.delegate?.onImageProcessorCompleted(
            status: 1,
            stylizedImage:cgImage)
    }
}

虽然VNCoreMLRequest负责图像分析,但VNImageRequestHandler负责执行请求(或请求)。

如果分析过程中没有发生错误,我们应该返回带有其结果属性的请求实例。因为我们只期望一个请求和结果类型,我们将结果转换为VNPixelBufferObservation数组的实例,这是一种适合使用 Core ML 模型进行图像分析的观察类型,其作用是图像到图像处理,例如我们的风格转换模型。

我们可以通过属性pixelBuffer从结果中获取的观察结果中获取我们风格化图像的引用。然后我们可以调用扩展方法toCGImage(在CVPixelBuffer+Extension.swift中找到)以方便地以我们可以轻松使用的格式获取输出,在这种情况下,更新图像视图。

如前所述,让我们看看当我们尝试在不实现自定义层的情况下运行图像通过我们的模型时会发生什么。构建并部署到设备上,然后拍照,然后从显示的样式中选择梵高风格。这样做时,你会观察到构建失败并报告错误:“从工厂创建 Core ML 自定义层实现时出错,层名为 "RescaleOutputLambda"`(正如我们所预期的)。

让我们现在通过实现每个自定义层来解决这个问题,从 RescaleOutputLambda 类开始。创建一个名为 RescaleOutputLamdba.class 的新 Swift 文件,并用以下代码替换模板代码:

import Foundation
import CoreML
import Accelerate

@objc(RescaleOutputLambda) class RescaleOutputLambda: NSObject, MLCustomLayer {    
    required init(parameters: [String : Any]) throws {
        super.init()
    }

    func setWeightData(_ weights: [Data]) throws {

    }

    func outputShapes(forInputShapes inputShapes: [[NSNumber]]) throws
        -> [[NSNumber]] {

    }

    func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws {

    }
}

在这里,我们创建了一个名为 MLCustomLayer 的具体类,这是一个定义我们神经网络模型中自定义层行为的协议。该协议包含四个必需的方法和一个可选方法,具体如下:

  • init(parameters): 初始化传递字典 parameters 的自定义层实现,该字典包含任何额外的配置选项。如您所回忆的,我们在将我们的 Keras 模型转换为自定义层时为每个自定义层创建了一个 NeuralNetwork_pb2.CustomLayerParams 实例。在这里,我们可以添加更多条目,这些条目将被传递到这个字典中。这提供了一些灵活性,例如允许您根据设置的参数调整您的层。

  • setWeightData(): 为层内连接分配权重(对于具有可训练权重的层)。

  • outputShapes(forInputShapes): 这个方法确定层如何修改输入数据的大小。我们的 RescaleOutputLambda 层不会改变层的大小,所以我们只需返回输入形状,但我们将利用这个方法来实现下一个自定义层。

  • evaluate(inputs, outputs): 这个方法执行实际的计算;这是一个必需的方法,当模型在 CPU 上运行时会被调用。

  • encode(commandBuffer, inputs, outputs): 这个方法是可选的,作为 evaluate 方法的替代,后者使用 GPU 而不是 CPU。

由于我们没有传递任何自定义参数或设置任何可训练权重,我们可以跳过构造函数和 setWeightData 方法;让我们逐一介绍剩余的方法,从 outputShapes(forInputShapes) 开始。

如前所述,这个层不会改变输入的形状,因此我们可以简单地返回输入形状,如下面的代码所示:

func outputShapes(forInputShapes inputShapes: [[NSNumber]]) throws
    -> [[NSNumber]] {
        return inputShapes
}

现在我们已经实现了outputShapes(forInputShapes)方法,让我们将注意力转向层的实际计算工作,即evaluate方法。evaluate方法接收一个MLMultiArray对象的数组作为输入,以及另一个MLMultiArray对象的数组,其中它预期存储结果。让evaluate方法接受输入和输出数组,这为支持不同的架构提供了更大的灵活性,但在这个例子中,我们只期望有一个输入和一个输出。

作为提醒,这个层是为了将每个元素从-1.0 - 1.0 的范围缩放到 0 - 255 的范围(这是典型图像所期望的)。最简单的方法是遍历每个元素并使用我们在 Python 中看到的方程进行缩放:((x+1)*127.5。这正是我们将要做的;将以下(加粗)代码添加到evaluate方法的主体中:

func evaluate(inputs: [MLMultiArray],outputs: [MLMultiArray]) throws {    
    let rescaleAddition = 1.0
 let rescaleMulitplier = 127.5

 for (i, input) in inputs.enumerated(){
 // expecting [1, 1, Channels, Kernel Width, Kernel Height]
 let shape = input.shape 
 for c in 0..<shape[2].intValue{
 for w in 0..<shape[3].intValue{
 for h in 0..<shape[4].intValue{
 let index = [
 NSNumber(value: 0),
 NSNumber(value: 0),
 NSNumber(value: c),
 NSNumber(value: w),
 NSNumber(value: h)]
 let outputValue = NSNumber(
 value:(input[index].floatValue + rescaleAddition)
 * rescaleMulitplier)

 outputs[i][index] = outputValue
 }
 }
 }
 }
} 

这种方法的主体是由用于创建索引的代码组成的,该索引用于从输入中获取适当的值并指向其输出对应项。一旦创建了索引,Python 公式就被移植到 Swift 中:input[index].doubleValue + rescaleAddition) * rescaleMulitplier。这标志着我们第一个自定义层的结束;现在让我们实现第二个自定义层,ResCropBlockLambda

创建一个名为ResCropBlockLambda.swift的新文件,并添加以下代码,覆盖任何现有代码:

import Foundation
import CoreML
import Accelerate

@objc(ResCropBlockLambda) class ResCropBlockLambda: NSObject, MLCustomLayer {

    required init(parameters: [String : Any]) throws {
        super.init()
    }

    func setWeightData(_ weights: [Data]) throws {
    }

    func outputShapes(forInputShapes inputShapes: [[NSNumber]]) throws
        -> [[NSNumber]] {
    }

    func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws {
    }
}

正如我们在上一个自定义层中所做的那样,我们已经根据MLCustomLayer协议确定了所有必需的方法。再次强调,我们可以忽略构造函数和setWeightData方法,因为在这个层中它们都没有被使用。

如果你还记得,正如其名称所暗示的,这个层的功能是裁剪残差块的一个输入的宽度和高度。我们需要在outputShapes(forInputShapes)方法中反映这一点,以便网络知道后续层的输入维度。使用以下代码更新outputShapes(forInputShapes)方法:

func outputShapes(forInputShapes inputShapes: [[NSNumber]]) throws
    -> [[NSNumber]] {        
 return [[NSNumber(value:inputShapes[0][0].intValue),
 NSNumber(value:inputShapes[0][1].intValue),
 NSNumber(value:inputShapes[0][2].intValue),
 NSNumber(value:inputShapes[0][3].intValue - 4),
 NSNumber(value:inputShapes[0][4].intValue - 4)]];
}

在这里,我们从宽度和高度中减去了一个常数4,实际上是在宽度和高度上填充了 2。接下来,我们实现evaluate方法,它执行这个裁剪。用以下代码替换evaluate方法:

func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws {
 for (i, input) in inputs.enumerated(){

 // expecting [1, 1, Channels, Kernel Width, Kernel Height]
 let shape = input.shape
 for c in 0..<shape[2].intValue{
 for w in 2...(shape[3].intValue-4){
 for h in 2...(shape[4].intValue-4){
 let inputIndex = [
 NSNumber(value: 0),
 NSNumber(value: 0),
 NSNumber(value: c),
 NSNumber(value: w),
 NSNumber(value: h)]

 let outputIndex = [
 NSNumber(value: 0),
 NSNumber(value: 0),
 NSNumber(value: c),
 NSNumber(value: w-2),
 NSNumber(value: h-2)]

 outputs[i][outputIndex] = input[inputIndex]
 }
 }
 }
 }
} 

与我们的RescaleOutputLambda层的evaluate方法类似,这个方法的主体必须与创建输入和输出数组的索引有关。我们只是通过限制循环的范围来调整它,以达到所需的宽度和高度。

现在,如果你构建并运行项目,你将能够将图像通过梵高网络运行,并得到它的风格化版本,类似于以下图像所示:

在模拟器上运行时,整个过程大约花费了22.4 秒。在接下来的两个部分中,我们将花时间探讨如何减少这个时间。

加速我们的层

让我们回到RescaleOutputLambda层,看看我们可能在哪里能减少一秒或两秒的处理时间。作为提醒,这个层的作用是对输出中的每个元素进行缩放,其中我们的输出可以被视为一个大向量。幸运的是,苹果为我们提供了高效的框架和 API 来处理这种情况。我们不会在循环中对每个元素进行操作,而是将利用Accelerate框架及其vDSPAPI在单步中执行此操作。这个过程被称为向量化,是通过利用 CPU 的单指令多数据SIMD)指令集来实现的。回到RescaleOutputLambda类,并使用以下代码更新evaluate方法:

func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws {
    var rescaleAddition : Float = 1.0
    var rescaleMulitplier : Float = 127.5

 for (i, _) in inputs.enumerated(){

 let input = inputs[i]
 let output = outputs[i]

 let count = input.count
 let inputPointer = UnsafeMutablePointer<Float>(
 OpaquePointer(input.dataPointer)
 )
 let outputPointer = UnsafeMutablePointer<Float>(
 OpaquePointer(output.dataPointer)
 )

 vDSP_vsadd(inputPointer, 1,
 &rescaleAddition,
 outputPointer, 1,
 vDSP_Length(count))

 vDSP_vsmul(outputPointer, 1,
 &rescaleMulitplier,
 outputPointer, 1,
 vDSP_Length(count))
 }
}

在前面的代码中,我们首先获取每个输入和输出缓冲区的指针的引用,将它们包装在UnsafeMutablePointer中,这是 vDSP 函数所要求的。然后,只需简单地使用等效的 vDSP 函数应用我们的缩放操作,我们将逐一介绍这些函数。

首先,我们将常数1添加到输入中,并将结果保存到输出缓冲区中,如下面的代码片段所示:

vDSP_vsadd(inputPointer, 1,
           &rescaleAddition,
           outputPointer, 1,
           vDSP_Length(count))

其中函数vDSP_vsadd接收指向我们的向量(inputPointer)的指针,并将rescaleAddition添加到其每个元素中,然后再将其存储到输出中。

接下来,我们将我们的乘数应用于输出(当前每个值都设置为输入加 1)的每个元素;此代码的示例如下:

vDSP_vsmul(outputPointer, 1,
           &rescaleMulitplier,
           outputPointer, 1,
           vDSP_Length(count))

vDSP_vsadd类似,vDSP_vsmul接收输入(在这种情况下,我们的输出);我们想要乘以每个元素的标量;输出;用于持久化结果的步长;最后,我们想要操作的元素数量。

如果你重新运行应用程序,你会看到我们已经成功将总执行时间减少了几秒钟——考虑到这个层只在我们的网络末尾运行一次,这已经很不错了。我们能做得更好吗?

利用 GPU

你可能还记得,当我们介绍MLCustomLayer协议时,有一个可选方法encode(commandBuffer, inputs, outputs),它被保留用于在宿主设备支持的情况下在 GPU 上执行评估。这种灵活性是 Core ML 相对于其他机器学习框架的优势之一;它允许混合运行在 CPU 和 GPU 上的层,并允许它们协同工作。

要使用 GPU,我们将使用苹果的Metal框架,这是一个与 OpenGL 和 DirectX(现在还有 Vulkan)相当的图形框架,对于那些熟悉 3D 图形的人来说。与我们的先前解决方案不同,它们将所有代码包含在一个方法中,我们需要在外部文件中编写执行计算的代码,这个文件被称为Metal 着色器文件。在这个文件中,我们将定义一个内核,该内核将被编译并存储在 GPU 上(当加载时),允许它并行地在 GPU 上分散数据。现在让我们创建这个内核;创建一个名为rescale.metal的新metal文件,并添加以下代码:

#include <metal_stdlib>
using namespace metal;

kernel void rescale(
    texture2d_array<half, access::read> inTexture [[texture(0)]],
    texture2d_array<half, access::write> outTexture [[texture(1)]],
    ushort3 gid [[thread_position_in_grid]])
{
    if (gid.x >= outTexture.get_width() || gid.y >= outTexture.get_height())
    {
        return;
    }

    const float4 x = float4(inTexture.read(gid.xy, gid.z));
    const float4 y = (1.0f + x) * 127.5f;

    outTexture.write(half4(y), gid.xy, gid.z);
}  

讨论metal的细节超出了范围,所以我们只突出一些与之前方法的关键差异和相似之处。首先,值得认识到为什么 GPU 已经成为神经网络复兴的主要催化剂。GPU 架构允许为我们的数组中的每个元素生成一个内核(之前已看到)——大规模并行!

由于 GPU 框架传统上是为了图形操作而构建的,我们在操作数据和操作内容上存在一些细微差别。其中最显著的是,我们将MLMultiArray替换为texture2d_array(纹理),并通过thread_position_in_grid进行采样来访问它们。不过,实际的计算应该与原始 Python 代码相似,const float4 y = (1.0f + x) * 127.5f。一旦计算完成,我们将它转换为 float 16(半精度)并写入输出纹理。

我们的下一步是配置RescaleOutputLambda类以使用Metal和 GPU,而不是 CPU。回到RescaleOutputLambda.swift文件,并做出以下修改。

首先,通过在文件顶部添加以下语句来导入Metal框架:

import Metal

接下来,我们定义一个类型为MTLComputePipelineState的类变量,作为我们刚刚创建的内核的处理程序,并在RescaleOutputLambda类的构造函数中设置它。按照代码片段中加粗的部分对类和构造函数进行以下修改:

@objc(RescaleOutputLambda) class RescaleOutputLambda: NSObject, MLCustomLayer {

 let computePipeline: MTLComputePipelineState

    required init(parameters: [String : Any]) throws {
 let device = MTLCreateSystemDefaultDevice()!
 let library = device.makeDefaultLibrary()!
 let rescaleFunction = library.makeFunction(name: "rescale")!
 self.computePipeline = try! device.makeComputePipelineState(function: rescaleFunction)

        super.init()
    }
    ...
}

如果没有抛出错误,我们将有一个编译后的缩放内核的引用;最后一步是利用它。在RescaleOutputLambda类中添加以下方法:

func encode(commandBuffer: MTLCommandBuffer,
            inputs: [MTLTexture],
            outputs: [MTLTexture]) throws {

    guard let encoder = commandBuffer.makeComputeCommandEncoder() else{
        return
    }

    let w = computePipeline.threadExecutionWidth
    let h = computePipeline.maxTotalThreadsPerThreadgroup / w
    let threadGroupSize = MTLSizeMake(w, h, 1)

    for i in 0..<inputs.count {
        let threadGroups = MTLSizeMake(
            (inputs[i].width + threadGroupSize.width - 1) /
                threadGroupSize.width,
            (inputs[i].height+ threadGroupSize.height - 1) /
                threadGroupSize.height,
            (inputs[i].arrayLength + threadGroupSize.depth - 1) /
                threadGroupSize.depth)

        encoder.setTexture(inputs[i], index: 0)
        encoder.setTexture(outputs[i], index: 1)
        encoder.setComputePipelineState(computePipeline)
        encoder.dispatchThreadgroups(
            threadGroups,
            threadsPerThreadgroup:
            threadGroupSize)
        encoder.endEncoding()
}

如前所述,我们将省略细节,只突出这种方法与之前方法的关键差异和相似之处。

简而言之,这个方法的大部分工作是通过编码器将数据传递给计算内核,然后在 GPU 上分发它。我们首先传递输入和输出纹理,如下面的代码片段所示:

encoder.setTexture(inputs[i], index: 0)
encoder.setTexture(outputs[i], index: 1)

然后我们设置处理器,它指向我们在前面的代码片段中创建的缩放内核:

encoder.setComputePipelineState(computePipeline)

最后,将任务分发给 GPU;在这种情况下,我们的计算内核对输入纹理的每个通道的每个像素进行调用:

encoder.dispatchThreadgroups(
    threadGroups,
    threadsPerThreadgroup:
    threadGroupSize)
encoder.endEncoding()

如果你再次构建和运行,你可能会希望得到相同的结果,但用时更少。我们已经看到了两种优化我们网络的方法;我将优化ResCropBlockLambda作为一个练习留给你。现在,在我们结束这一章之前,让我们将注意力转移到讨论你的模型权重上。

减少你的模型权重

我们已经花费了大量时间讨论网络的层;我们了解到层由权重组成,这些权重被配置成能够将输入转换为期望的输出。然而,这些权重是有代价的;每一个(默认情况下)都是一个 32 位的浮点数,特别是在计算机视觉中,典型的模型有数百万个,导致网络大小达到数百兆字节。除此之外;你的应用程序可能需要多个模型(本章就是一个很好的例子,需要为每种风格创建一个模型)。

幸运的是,我们本章中的模型权重数量适中,仅重 2.2 MB;但这可能是一个例外。所以我们将利用这一章作为借口来探索一些我们可以减少模型权重的途径。但在这样做之前,让我们快速讨论一下,尽管这可能是显而易见的。你应该注意你的模型大小的三个主要原因包括:

  • 下载时间

  • 应用程序占用空间

  • 对内存的需求

这些都可能会阻碍用户体验,并且是用户快速卸载应用程序或根本不下载的原因。那么,你如何减少你的模型大小以避免阻碍用户。有三种主要的方法:

  • 减少你的网络使用的层数

  • 减少每个层中的单元数量

  • 减少权重的尺寸

前两个要求你能够访问原始网络和工具来重新架构和训练模型;最后一个是最容易获得的,也是我们现在要讨论的。

在 iOS 11.2 中,苹果允许你的网络使用半精度浮点数(16 位)。现在,随着 iOS 12 的发布,苹果更进一步,引入了量化,这允许我们使用八个或更少的位来编码我们的模型权重。在下面的图中,我们可以看到这些选项之间的比较:

图片

让我们逐一讨论,首先从通过将它的浮点数从 32 位转换为 16 位来降低我们的权重精度开始。

对于这两种技术(半精度和量化),我们将使用 Core ML Tools Python 包;因此,首先打开您的浏览器并转到 notebooks.azure.com。页面加载后,导航到文件夹 Chapter6/Notebooks/ 并打开 Jupyter Notebook FastNeuralStyleTransfer_OptimizeCoreML.ipynb。像之前一样,我们将在这里逐个介绍 Notebook 的单元格,假设您将按照我们介绍的内容执行每个单元格(如果您正在一起工作的话)。

我们首先导入 Core ML Tools 包;执行以下代码的单元格:

try:
    import coremltools
except:
    !pip install coremltools    
    import coremltools 

为了方便起见,我们将 import 包裹在一个异常块中,这样如果它不存在,它会自动安装该包。

在撰写本文时,Core ML 2 仍然处于测试版,并且最近才公开宣布。如果您使用的 Core ML Tools 版本低于 2.0,请将 !pip install coremltools 替换为 !pip install coremltools>=2.0b1 以安装最新的测试版,以便访问本节所需的模块。

接下来,我们将使用以下语句加载我们之前保存的 mlmodel 文件:

coreml_model = coremltools.models.MLModel('output/FastStyleTransferVanGoghStarryNight.mlmodel')

接下来,我们通过简单地调用 coremltools.utils.convert_neural_network_weights_to_fp16 并传入您的模型来执行转换。如果成功,此方法将返回一个等效模型(您传入的模型),使用半精度权重而不是 32 位来存储其权重。运行以下代码的单元格来完成此操作:

 fp16_coreml_model = coremltools.utils.convert_neural_network_weights_to_fp16(coreml_model)

最后,我们将其保存下来,以便我们可以在以后下载并导入到我们的项目中;运行下一个单元格的代码:

fp16_coreml_model.save('output/fp16_FastStyleTransferVanGoghStarryNight.mlmodel')

执行上述操作(本质上只有三行代码)后,我们已经成功将模型的大小从 2.2 MB 减小到 1.1 MB——那么,有什么问题吗?

如您所料,这里有一个权衡;降低模型权重的精度将影响其准确性,但可能不足以引起关注。您唯一知道的方法是通过比较优化后的模型和原始模型,并在测试数据上重新评估它,确保它满足您所需的准确度/结果。为此,Core ML Tools 提供了一系列工具,使得这个过程相当无缝,您可以在官方网站 apple.github.io/coremltools/index.html 上了解这些工具。

与通过 Core ML Tools 使用概念相比,量化并不复杂;它是一种巧妙的技术,所以让我们快速讨论它是如何实现 8 位压缩的,然后再运行代码。

从高层次来看,量化是一种将连续值范围映射到离散集的技术;你可以将其视为将你的值聚类到一组离散的组中,然后创建一个查找表,将你的值映射到最近的组。大小现在取决于使用的聚类数量(索引),而不是值,这允许你使用从 8 位到 2 位的任何位数来编码你的权重。

为了使这个概念更具体,以下图表说明了颜色量化的结果;其中 24 位图像被映射到 16 种离散颜色:

图片

而不是每个像素代表其颜色(使用 24 位/8 位/通道),现在它们现在是 16 色调色板的索引,即从 24 位到 4 位。

在我们使用 Core ML Tools 包进行量化优化模型之前,你可能想知道这种调色板(或离散值集)是如何得到的。简短的回答是,有多种方法,从将值线性分组,到使用 k-means 等无监督学习技术,甚至使用自定义的、特定领域的技巧。Core ML Tools 允许所有变体,选择将取决于你的数据分布和测试期间获得的结果。让我们开始吧;首先,我们将从导入模块开始:

from coremltools.models.neural_network import quantization_utils as quant_utils

通过这个声明,我们已经导入了模块并将其分配给别名quant_utils;下一个单元,我们将使用不同的大小和方法来优化我们的模型:

lq8_coreml_model = quant_utils.quantize_weights(coreml_model, 8, 'linear')
lq4_coreml_model = quant_utils.quantize_weights(coreml_model, 4, 'linear')
km8_coreml_model = quant_utils.quantize_weights(coreml_model, 8, 'kmeans')
km4_coreml_model = quant_utils.quantize_weights(coreml_model, 4, 'kmeans')

完成此操作后,在我们将它们下载到本地磁盘并导入 Xcode 之前,让我们将每个优化后的模型保存到输出目录(这可能需要一些时间):

coremltools.models.MLModel(lq8_coreml_model) \
    .save('output/lq8_FastStyleTransferVanGoghStarryNight.mlmodel')
coremltools.models.MLModel(lq4_coreml_model) \
    .save('output/lq4_FastStyleTransferVanGoghStarryNight.mlmodel')
coremltools.models.MLModel(km8_coreml_model) \
    .save('output/km8_FastStyleTransferVanGoghStarryNight.mlmodel')
coremltools.models.MLModel(km4_coreml_model) \
    .save('output/km8_FastStyleTransferVanGoghStarryNight.mlmodel')

由于我们已经在本章中详细介绍了下载和将模型导入项目的步骤,因此我将省略这些细节,但我确实鼓励你检查每个模型的输出结果,以了解每种优化如何影响结果——当然,这些影响高度依赖于模型、数据和领域。以下图表显示了每种优化的结果以及模型的大小:

图片

承认,由于图像分辨率低(以及可能因为你正在以黑白阅读),很难看到差异,但一般来说,原始图像和 k-means 8 位版本之间的质量差异很小。

随着 Core ML 2 的发布,Apple 提供了另一个强大的功能来优化您的 Core ML 模型;具体来说,是关于将多个模型合并成一个单一包。这不仅减少了您应用程序的大小,而且对您,即开发者,与模型交互时也方便。例如,灵活的形状和大小允许变量输入和输出维度,也就是说,您有多个变体或在一个限制范围内的变量范围。您可以在他们的官方网站上了解更多关于这个功能的信息:developer.apple.com/machine-learning;但在此阶段,我们将在进入下一章之前,对这个章节做一个简要总结。

摘要

在本章中,我们介绍了风格迁移的概念;这是一种旨在将图像的内容与其风格分离的技术。我们讨论了它是如何通过利用一个训练好的 CNN 来实现这一点的,我们看到了网络的深层如何提取关于图像内容的特征,同时丢弃任何无关信息。

同样,我们看到较浅的层提取了更细微的细节,如纹理和颜色,我们可以通过寻找每个层的特征图(也称为 卷积核过滤器)之间的相关性来使用这些细节来隔离给定图像的风格。这些相关性就是我们用来衡量风格和引导我们网络的方法。在隔离了内容和风格之后,我们通过结合两者生成了一个新的图像。

然后,我们指出了在实时进行风格迁移(使用当前技术)的局限性,并介绍了一个轻微的变化。我们不是每次都优化风格和内容,而是训练一个模型来学习特定的风格。这将允许我们通过网络的单次通过为给定的图像生成一个风格化的图像,正如我们在本书中处理的其他许多示例中所做的那样。

在介绍了这些概念之后,我们接着展示了如何将 Keras 模型转换为 Core ML,并借此机会实现自定义层,这是一种以 Swift 为中心的实现层的方法,这些层在机器学习框架和 Core ML 之间没有直接的映射。在实现了自定义层之后,我们花了些时间研究如何使用 Accelerate(SIMD)和 Metal 框架(GPU)来优化它们。

优化的主题延续到下一节,我们讨论了一些可用于减少模型大小的工具;在那里,我们研究了两种方法,以及我们如何使用 Core ML 工具包以及一个关于大小和精度之间权衡的警告来利用它们。

在下一章中,我们将探讨如何将我们所学应用到识别用户草图。

第七章:使用卷积神经网络(CNN)辅助绘图

到目前为止,我们已经看到如何利用 Core ML 以及一般性的机器学习ML)来更好地理解我们所生活的物理世界(感知任务)。从设计用户界面的角度来看,这使我们能够减少用户和系统之间的摩擦。例如,如果你能够从用户的面部照片中识别出用户,你可以省去认证所需的步骤,就像 iPhone X 上的 Apple Face ID 功能所展示的那样。有了 Core ML,我们有可能让设备更好地为我们服务,而不是我们为它们服务。这符合开发者 Eric Raymond 提出的一条规则:一台 计算机永远不应该要求用户提供任何它可以自动检测、复制或推断的信息

我们可以将这个想法进一步深化;给定足够的数据量,我们可以预测用户试图做什么,并帮助他们完成任务。这是本章的基点。在很大程度上受到谷歌的 AutoDraw AI 实验的启发和影响,我们将实现一个应用程序,试图猜测用户试图画什么,并提供预先绘制的图案供用户替换(图像搜索)。

在本章中,我们将通过探讨如何尝试预测用户试图画什么,并为他们找到替换建议来探索这个想法。我们将探讨两种技术。第一种是使用我们越来越熟悉的卷积神经网络CNN)来进行预测,然后看看我们如何应用基于上下文的相似度排序策略,以更好地将建议与用户试图绘制的图像对齐。在下一章中,我们将继续探索,看看我们如何使用循环神经网络RNN)来完成同样的任务。

到本章结束时,你将:

  • 将卷积神经网络(CNN)应用于草图识别任务

  • 获得为模型准备输入的更多经验

  • 学习如何从卷积神经网络(CNN)中提取特征图,并用于测量两张图像的相似度

有很多内容要介绍,所以让我们从构建一个简单的绘图应用程序开始。

向智能界面迈进

在深入探讨如何之前,让我们先快速讨论一下为什么,以便激励我们并鼓励对这一概念进行创造性探索。正如引言中提到的,第一个动机是减少摩擦。考虑一下你手机上的软键盘(没有物理按钮的键盘);由于媒介的限制,如空间和反馈的缺乏,没有预测文本的输入将会变得繁琐到无法使用的地步。同样,尽管用手指画画很方便,但我们的手指并不那么精确,这使得绘画变得困难。

这个概念(增强)的另一个优点是其民主化绘图技术技能的能力。人们通常甚至不尝试绘图,因为他们已经说服自己这超出了他们的能力范围,或者可能我们认为我们可以提高一个人的绘图能力。这是 2011 年在 SIGGRAPH 上由 Yong Jae Lee、Larry Zitnick 和 Michael Cohen 提出的名为ShadowDraw的研究项目的动机。他们的项目表明,通过在用户的笔触下方引导阴影图像,可以显著提高输出质量。

最后,我想强调的最后一个原因是,这个概念为用户提供了一种在更高层次抽象上工作的方法。例如,想象一下,如果你被要求绘制一个新动画的故事板草图。当你绘制场景时,系统会根据正在工作的内容替换你的草图及其相关的角色和道具,让你能够在更高保真度下设计,而不牺牲速度。

希望到现在为止,我已经说服你将人工智能集成到用户界面中的潜在机会。让我们将我们的重点转向“如何”,我们将在下一节开始讨论。

绘画

在本节中,我们将首先检查现有的入门应用程序并实现绘图功能。然后,在下一节中,我们将探讨如何通过预测用户试图绘制的图像并提供他们可以替换的替代图像来增强用户。

如果你还没有这样做,请从随附的存储库github.com/packtpublishing/machine-learning-with-core-ml中拉取最新代码。下载后,导航到Chapter7/Start/QuickDraw/目录并打开项目QuickDraw.xcodeproj。加载后,你将看到本章的入门项目,如下面的屏幕截图所示:

图片

在之前的屏幕截图中,你可以看到应用程序的整体应用;界面由一个视图组成,其中在左侧下方有一个简单的工具栏,允许用户在草图和移动之间切换。有一个清除所有内容的按钮。工具栏右侧的区域是画布,它将负责渲染用户所绘制的任何图像和替代图像。最后,顶部区域由一个标签和集合视图组成。集合视图将使用户可以替换的建议图像可用,而标签只是为了让用户意识到通过集合视图呈现给他们的图像的目的。

如前所述,我们的第一个任务将是实现绘图功能。一些管道工作已经完成,但大部分工作尚未完成,这给了我们一个机会来遍历代码,更好地理解应用程序的架构以及机器学习是如何被集成的。在深入代码之前,让我们简要讨论项目内每个相关源文件的目的。就像一本书的目录一样,这将让你更好地了解事物是如何拼接在一起的,并帮助你更熟悉项目:

  • SketchView:这是一个自定义的UIControl,负责捕捉用户的触摸并将其转换为绘图。它还将负责渲染这些绘图和替代绘图,即已被替换的草图。如前所述,此控件已添加到视图中。在本节中,我们将实现触摸事件的功能。

  • SketchViewController:这是主视图背后的控制器,负责监听用户完成编辑(抬起他们的指针)并将当前草图传递给QueryFacade进行处理。此控制器还负责处理模式切换(草图、移动或清除所有内容)以及在移动模式下在屏幕上拖动草图。

  • BingService:我们将使用微软的 Bing 图像搜索 API 来查找我们的建议图像。Bing 提供了一个简单的 RESTful 服务,允许进行图像搜索,以及一些相关参数来微调你的搜索。注意:我们不会编辑这个。

  • SketchPreviewCell:这是UICollectionViewCell类的一个简单扩展,使得嵌套在单元格内的UIImageView可用。注意:我们不会编辑这个。

  • CIImage:你应该很熟悉——这是我们之前在第三章中实现的,识别世界中的物体。我们将在此章中广泛使用它来进行缩放和获取图像的原始数据(包括草图)。

  • Sketch:这是我们草图的模式;我们将实现两个版本。一个是用于渲染用户创建的草图,由笔触构成,另一个是用于封装一个UIImage,它替代了草图(笔触)。

  • Stroke:一个描述草图一部分的数据对象,本质上编码了用户绘制的路径,以便我们可以渲染它。

  • QueryFacade:这是一个将执行所有繁重工作的类。一旦用户完成编辑,视图控制器将导出草图并将其传递给QueryFacade,该类将负责三件事:猜测用户试图绘制的内容、获取和下载相关建议,并在将它们排序后传递回视图控制器,通过集合视图向用户展示。此过程的示意图如下:

图片

希望你现在对如何将所有事物串联起来有了更好的理解;让我们从底部开始,逐步构建。点击Stroke.swift文件以将文件聚焦在主区域;一旦打开,你会看到一个不太显眼的代码量,正如这个片段所示:

import UIKit
    class Stroke{    
}

只为了回顾一下,Stroke的目的在于封装用户绘制的单个路径,以便在将其重新渲染到屏幕上时能够重新创建它。路径不过是一系列点,这些点在用户沿着屏幕移动手指时被捕获。除了路径之外,我们还将存储笔迹的颜色和宽度;这些决定了笔迹的视觉美学。将以下属性添加到Stroke类中,以及类的构造函数:

var points : [CGPoint] = [CGPoint]()
var color : UIColor!
var width : CGFloat!

init(startingPoint:CGPoint,
     color:UIColor=UIColor.black,
     width:CGFloat=10.0) {
    self.points.append(startingPoint)
    self.color = color
    self.width = width
}

接下来,我们将向我们的Stroke类添加一些计算属性,这些属性将在渲染和导出草图时使用。从辅助渲染的属性开始,我们将使用 Core Graphics 框架来渲染与草图关联的每条笔迹的路径。渲染是通过 Core Graphics 上下文(CGContext)完成的,它方便地公开了使用addPathdrawPath方法渲染路径的方法,我们很快就会看到。addPath方法期望一个CGPath类型,这不过是一系列绘图指令,描述了如何绘制路径,我们可以很容易地从笔迹的点中推导出来。现在让我们这样做;将path属性添加到Stroke类中:

var path : CGPath{
    get{
        let path = CGMutablePath.init()
        if points.count > 0{
            for (idx, point) in self.points.enumerated(){
                if idx == 0{
                    path.move(to: point)
                } else{
                    path.addLine(to: point)
                }
            }
        }        
        return path
    }
}

如前所述,CGPath由一系列绘图指令组成。在前面的片段中,我们正在使用与Stroke关联的点创建路径。除了第一个之外,所有其他点都通过线条连接,而第一个只是将其移动到正确的位置。

Core Graphics 框架是一个轻量级且底层的 2D 绘图引擎。它包括基于路径的绘图、变换、颜色管理、离屏渲染、图案和阴影、图像创建和图像蒙版等功能。

我们接下来的两个属性用于获取草图的边界框,即包含所有笔迹的最小和最大xy位置的边界。在笔迹本身中实现这些将使我们的任务更容易。将minPointmaxPoint属性添加到你的Stroke类中,如下面的代码块所示:

var minPoint : CGPoint{
    get{
        guard points.count > 0 else{
            return CGPoint(x: 0, y: 0)
        }

        let minX : CGFloat = points.map { (cp) -> CGFloat in
            return cp.x
            }.min() ?? 0

        let minY : CGFloat = points.map { (cp) -> CGFloat in
            return cp.y
            }.min() ?? 0

        return CGPoint(x: minX, y: minY)
    }
}

var maxPoint : CGPoint{
    get{
        guard points.count > 0 else{
            return CGPoint(x: 0, y: 0)
        }

        let maxX : CGFloat = points.map { (cp) -> CGFloat in
            return cp.x
            }.max() ?? 0

        let maxY : CGFloat = points.map { (cp) -> CGFloat in
            return cp.y
            }.max() ?? 0

        return CGPoint(x: maxX, y: maxY)
    }
}

对于每个属性,我们只是将每个轴(xy)映射到它自己的数组中,然后根据它们的方法找到最小值或最大值。现在我们已经完成了Stroke类的构建。让我们向上移动到层,并实现Sketch类的功能。从左侧面板中选择Sketch.swift以在编辑窗口中打开。在做出修改之前,让我们检查一下已经存在的内容以及还需要完成的工作:

protocol Sketch : class{
    var boundingBox : CGRect{ get }
    var center : CGPoint{ get set }
    func draw(context:CGContext)
    func exportSketch(size:CGSize?) -> CIImage?
}

目前,尚无具体的类存在,这将是本节这一部分的我们的任务。在我们开始编码之前,让我们回顾一下 Sketch 的职责。如前所述,我们的 Sketch 将负责渲染与用户绘图相关的笔划集合或用户选择的用于替代自己绘图的图像。因此,我们将使用 Stroke 类的两个实现,一个专门用于处理笔划,另一个用于图像;我们将从负责管理和渲染笔划的那个开始。

每个实现都应公开一个 drawexportSketch 方法以及 boundingBoxcenter 属性。现在让我们简要地描述一下这些方法,从最明显的 draw 方法开始。我们期望 Sketch 负责渲染自身,无论是绘制每一笔还是根据草图类型渲染指定的图像。exportSketch 方法将用于获取草图的矢量版本,并依赖于 boundingBox 属性,使用它来确定画布上包含信息(即绘图)的区域。然后,它将草图矢量化为 CIImage,这可以随后用于喂养模型。最后一个属性 center 返回并设置中心点,在用户在移动模式下拖动它时使用,如前所述。

现在让我们继续实现一个处理笔划的 Sketch 的具体版本。在 Sketch 类中添加以下代码,仍然在 Sketch.swift 文件中:

class StrokeSketch : Sketch{
    var label : String?  
    var strokes = [Stroke]()
    var currentStroke : Stroke?{
        get{
            return strokes.count > 0 ? strokes.last : nil 
        }
    }   
    func addStroke(stroke:Stroke){
        self.strokes.append(stroke)
    }
}  

在这里,我们定义了一个新的类 StrokeSketch,遵循 Sketch 协议。我们定义了两个属性:一个用于存储所有笔划的列表,一个字符串,我们可以用它来注释草图。我们还公开了两个辅助方法。一个是返回当前笔划,在用户绘图时使用,另一个是方便地添加新笔划的方法。

现在让我们实现负责渲染草图的函数;将以下代码添加到 StrokeSketch 类中:

 func draw(context:CGContext){
    self.drawStrokes(context:context)
}

func drawStrokes(context:CGContext){
    for stroke in self.strokes{
        self.drawStroke(context: context, stroke: stroke)
    }
}

private func drawStroke(context:CGContext, stroke:Stroke){
    context.setStrokeColor(stroke.color.cgColor)
    context.setLineWidth(stroke.width)
    context.addPath(stroke.path)
    context.drawPath(using: .stroke)
} 

我们将实现协议的 draw 方法,但将绘制任务委托给 drawStrokesdrawStroke 方法。drawStrokes 方法简单地遍历我们草图类当前持有的所有笔划,并将它们传递给 drawStroke 方法,传递 Core Graphics 上下文和当前的 Stroke 引用。在 drawStroke 方法中,我们首先更新上下文的笔划颜色和线宽,然后继续添加并绘制相关的路径。现在我们已经实现了这一点,用户可以绘制了。但为了完整性,让我们实现获取边界框、获取和更新草图、中心和将草图矢量化为 CIImage 的功能。我们从 boundingBox 属性及其相关方法开始。将以下代码添加到 StrokeSketch 类中:

var minPoint : CGPoint{
    get{
        guard strokes.count > 0 else{
            return CGPoint(x: 0, y: 0)
        }

        let minPoints = strokes.map { (stroke) -> CGPoint in
            return stroke.minPoint
        }

        let minX : CGFloat = minPoints.map { (cp) -> CGFloat in
            return cp.x
            }.min() ?? 0

        let minY : CGFloat = minPoints.map { (cp) -> CGFloat in
            return cp.y
            }.min() ?? 0

        return CGPoint(x: minX, y: minY)
    }
}

var maxPoint : CGPoint{
    get{
        guard strokes.count > 0 else{
            return CGPoint(x: 0, y: 0)
        }

        let maxPoints = strokes.map { (stroke) -> CGPoint in
            return stroke.maxPoint
        }

        let maxX : CGFloat = maxPoints.map { (cp) -> CGFloat in
            return cp.x
            }.max() ?? 0

        let maxY : CGFloat = maxPoints.map { (cp) -> CGFloat in
            return cp.y
            }.max() ?? 0

        return CGPoint(x: maxX, y: maxY)
    }
}

var boundingBox : CGRect{
    get{
        let minPoint = self.minPoint
        let maxPoint = self.maxPoint

        let size = CGSize(width: maxPoint.x - minPoint.x, height: maxPoint.y - minPoint.y)

        let paddingSize = CGSize(width: 5,
                                 height: 5)

        return CGRect(x: minPoint.x - paddingSize.width,
                      y: minPoint.y - paddingSize.height,
                      width: size.width + (paddingSize.width * 2),
                      height: size.height + (paddingSize.height * 2))
    }
}

我们首先实现了minPointmaxPoint属性;它们类似于我们Stroke类中的minPointmaxPoint。但它们不是在点集合上操作,而是在笔划集合上操作,并利用它们的对应物(Stroke类的minPointmaxPoint属性)。接下来,我们实现了boundingBox属性,它创建一个CGRect,包含这些最小和最大点,并添加一些填充以避免裁剪笔划本身。

现在,我们将实现Stroke协议中声明的center属性。这个协议期望实现getset块。获取器将简单地返回我们刚刚实现的边界框的中心,而设置器将遍历所有笔划并使用前一个中心和新的中心值之间的差异来平移每个点。让我们现在实现它。将以下代码添加到您的StrokeSketch类中;在这里,boundingBox属性是一个很好的位置:

var center : CGPoint{
    get{
        let bbox = self.boundingBox
        return CGPoint(x:bbox.origin.x + bbox.size.width/2,
                       y:bbox.origin.y + bbox.size.height/2)
    }
    set{
        let previousCenter = self.center
        let newCenter = newValue
        let translation = CGPoint(x:newCenter.x - previousCenter.x,
                                  y:newCenter.y - previousCenter.y)
        for stroke in self.strokes{
            for i in 0..<stroke.points.count{
                stroke.points[i] = CGPoint(
                    x:stroke.points[i].x + translation.x,
                    y:stroke.points[i].y + translation.y)
            }
        }
    }
}

在这里,我们首先获取当前中心,然后计算这个中心与分配给属性的新的中心之间的差异。之后,我们遍历所有笔划及其相应的点,添加这个偏移量。

我们需要实现的最后一个方法以符合 Sketch 协议是exportSketch。这个方法的目的是将草图光栅化成图像(CIImage),并根据size参数进行缩放(如果有的话);否则,它默认为草图本身的实际大小。这个方法本身相当长,但并没有做过于复杂的事情。我们已经在draw方法中实现了渲染草图的功能。但不是将渲染到由视图传入的 Core Graphics 上下文中,而是想要创建一个新的上下文,根据大小参数和实际的草图尺寸调整比例,并最终从它创建一个CIImage实例。

为了使其更易于阅读,让我们将方法分解为这些部分,从计算比例开始。然后我们将查看创建和渲染到context的过程,最后将其包裹在CIImage中;将以下代码添加到您的StrokeSketch类中:

func exportSketch(size:CGSize?=nil) -> CIImage?{
    let boundingBox = self.boundingBox
    let targetSize = size ?? CGSize(
        width: max(boundingBox.width, boundingBox.height),
        height: max(boundingBox.width, boundingBox.height))

    var scale : CGFloat = 1.0

    if boundingBox.width > boundingBox.height{
        scale = targetSize.width / (boundingBox.width)
    } else{
        scale = targetSize.height / (boundingBox.height)
    }

    guard boundingBox.width > 0, boundingBox.height > 0 else{
        return nil
    }     
}

在这个代码块中,我们声明了我们的方法并实现了确定导出大小和比例的功能。如果没有传递大小,我们则简单地回退到草图边界框属性的尺寸。最后,我们确保我们有一些可以导出的内容。

现在我们任务是创建context并根据导出的比例渲染草图;将以下代码添加到exportSketch方法中:

UIGraphicsBeginImageContextWithOptions(targetSize, true, 1.0)

guard let context = UIGraphicsGetCurrentContext() else{
    return nil
}

UIGraphicsPushContext(context)

UIColor.white.setFill()
context.fill(CGRect(x: 0, y: 0,
                    width: targetSize.width, height: targetSize.height))

context.scaleBy(x: scale, y: scale)

let scaledSize = CGSize(width: boundingBox.width * scale, height: boundingBox.height * scale)

context.translateBy(x: -boundingBox.origin.x + (targetSize.width - scaledSize.width)/2,
                    y: -boundingBox.origin.y + (targetSize.height - scaledSize.height)/2)

self.drawStrokes(context: context)

UIGraphicsPopContext()

我们使用 Core Graphics 中的 UIGraphicsBeginImageContextWithOptions 方法来创建一个新的 context,并通过 UIGraphicsGetCurrentContext 方法获取对这个 context 的引用。UIGraphicsBeginImageContextWithOptions 创建一个临时的渲染上下文,其中第一个参数是此上下文的目标大小,第二个参数确定我们是否使用不透明或透明背景,最后一个参数确定显示缩放因子。然后我们用白色填充 context,并使用 scaleBy 方法更新上下文的 CGAffineTransform 属性。随后的绘制方法,如移动和绘制,都将通过这种方式进行变换,这为我们很好地处理了缩放。然后我们将这个 context 传递给我们的 sketch 的 draw 方法,该方法负责将 sketch 渲染到上下文中。我们的最终任务是获取 context 中的图像并将其包装在一个 CIImage 实例中。现在让我们来做这件事;将以下代码添加到您的 exportSketch 方法中:

guard let image = UIGraphicsGetImageFromCurrentImageContext() else{
    UIGraphicsEndImageContext()
    return nil
}
UIGraphicsEndImageContext()

return image.ciImage != nil ? image.ciImage : CIImage(cgImage: image.cgImage!)

多亏了 Core Graphics 方法 UIGraphicsGetImageFromCurrentImageContext,这项任务变得非常简单。UIGraphicsGetImageFromCurrentImageContext 返回一个包含上下文光栅化版本的 CGImage 实例。要创建一个 CIImage 实例,我们只需将我们的图像传递给构造函数并返回它。

我们已经完成了 Sketch 类——至少目前是这样——并且我们正缓慢地向上层移动。接下来,我们将完善 SketchView 类,它将负责促进 sketch 的创建和绘制。从左侧面板选择 SketchView.swift 文件,将其在编辑窗口中打开,让我们快速回顾现有的代码。SketchView 已经使用扩展被分解成块;为了使代码更易读,我们将展示每个块及其核心功能:

class SketchView: UIControl {
    var clearColor : UIColor = UIColor.white
    var strokeColor : UIColor = UIColor.black
    var strokeWidth : CGFloat = 1.0
    var sketches = [Sketch]()
    var currentSketch : Sketch?{
        get{
            return self.sketches.count > 0 ? self.sketches.last : nil
        }
        set{
            if let newValue = newValue{
                if self.sketches.count > 0{
                    self.sketches[self.sketches.count-1] = newValue
                } else{
                    self.sketches.append(newValue)
                }
            } else if self.sketches.count > 0{
                self.sketches.removeLast()
            }
            self.setNeedsDisplay()
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    func removeAllSketches(){
        self.sketches.removeAll()
        self.setNeedsDisplay()
    }
}  

之前的代码的大部分应该是自解释的,但我确实想快速将您的注意力引到 currentSketch 属性上;我们将使用这个获取器为我们提供一个方便的方式来获取最后一个 sketch,我们将认为它是当前活动的 sketch。设置器稍微有些模糊;它为我们提供了一个方便的方式来替换当前活动(最后一个)的 sketch,当我们处理用用户建议的图像替换用户的 sketch 时,我们将使用它。下一个块实现了绘制功能,这应该对您来说很熟悉;在这里,我们只是清除 context 并遍历所有 sketch,将绘制委托给它们:

extension SketchView{ 
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else{ return }
        self.clearColor.setFill()
        UIRectFill(self.bounds)
        // them draw themselves
        for sketch in self.sketches{
            sketch.draw(context: context)
        }
    }
}  

我们最后的块将负责实现绘制功能;目前,我们只是创建了拦截触摸事件的占位方法。完善这些方法将是我们的下一个任务:

extension SketchView{
    override func beginTracking(_ touch: UITouch, 
                                with event: UIEvent?) -> Bool{
        return true
    }
    override func continueTracking(_ touch: UITouch?, 
                                   with event: UIEvent?) -> Bool {
        return true
    }
    override func endTracking(_ touch: UITouch?, 
                              with event: UIEvent?) {        
    }
    override func cancelTracking(with event: UIEvent?) { 
    }
}

在我们编写代码之前,让我们简要回顾一下我们在这里试图实现的目标。如前所述,SketchView将负责功能,允许用户用手指绘图。我们过去几页构建了支持此功能的数据对象(StrokeSketch),我们将在这里使用它们。

当用户第一次触摸视图时(beginTracking),触摸开始。当我们检测到这一点时,我们首先检查是否有一个当前活动且合适的草图;如果没有,我们将创建一个并将其设置为当前草图。接下来,我们将创建一个笔划,用于跟踪用户在屏幕上拖动手指。一旦用户抬起手指或手指被拖出视图边界,它就被认为是完成的。然后我们将请求视图重新绘制自己,并通过广播事件UIControlEvents.editingDidBegin动作通知任何监听方。让我们将这些放入代码中;将以下代码添加到SketchView类中的beginTracking方法内:

let point = touch.location(in: self)
if sketches.count == 0 || !(sketches.last is StrokeSketch){
    sketches.append(StrokeSketch())
}
guard let sketch = self.sketches.last as? StrokeSketch else {
    return false
}
sketch.addStroke(stroke:Stroke(startingPoint: point,
                               color:self.strokeColor,
                               width:self.strokeWidth))
self.setNeedsDisplay()
self.sendActions(for: UIControlEvents.editingDidBegin)
return true

如 iOS 文档所述,在此,我们遵循常见的控件中的目标-动作机制,通过广播有趣的事件来简化其他类如何与该控件集成。

接下来,我们将实现continueTracking方法的主体;在这里,我们只是将一个新的点添加到当前草图当前笔划中。正如我们之前所做的那样,我们请求视图重新绘制自己并广播UIControlEvents.editingChanged动作。将以下代码添加到continueTracking方法的主体中:

guard let sketch = self.sketches.last as? StrokeSketch, let touch = touch else{
    return false
}
let point = touch.location(in: self)
sketch.currentStroke?.points.append(point)
self.setNeedsDisplay()
self.sendActions(for: UIControlEvents.editingChanged)
return true

之前的代码与用户抬起手指时我们需要的大部分代码相似,除了返回 true(这告诉平台该视图希望继续消耗事件)以及将UIControlEvents.editingChanged事件替换为UIControlEvents.editingDidEnd。将以下代码添加到你的endTracking方法主体中:

guard let sketch = self.sketches.last as? StrokeSketch, let touch = touch else{
    return
}
let point = touch.location(in: self)
sketch.currentStroke?.points.append(point)
self.setNeedsDisplay()
self.sendActions(for: UIControlEvents.editingDidEnd)

我们需要添加到SketchView类的最后一部分代码是处理当前手指跟踪被取消的情况(当手指从当前视图移出或超出设备的跟踪范围,即移出屏幕时触发)。在这里,我们只是将其视为跟踪已完成,除了不添加最后一个点。将以下代码添加到你的cancelTracking方法主体中:

guard let _ = self.sketches.last as? StrokeSketch else{
    return
}
self.setNeedsDisplay()
self.sendActions(for: UIControlEvents.editingDidEnd)

我们的SketchView完成之后,我们的应用程序现在支持绘图功能。现在是时候在模拟器或设备上构建并运行应用程序,以检查一切是否正常工作。如果是这样,那么你应该能够在屏幕上绘制,如下面的图像所示:

图片

移动和清除画布的功能已经实现;点击移动按钮可以拖动你的草图,点击垃圾桶按钮可以清除画布。我们的下一个任务将是导入一个训练好的 Core ML 模型,并实现为用户分类和推荐图像的功能。

识别用户的草图

在本节中,我们将首先回顾我们将使用的数据集和模型,以猜测用户正在绘制的内容。然后,我们将将其集成到正在绘制用户的流程中,并实现替换用户草图与所选图像的功能。

检查训练数据和模型

对于本章,我们使用 Mathias Eitz、James Hays 和 Marc Alexa 在 2012 年 SIGGRAPH 上发表的研究论文《人类如何绘制物体?》中使用并公开的数据集训练了一个 CNN。该论文比较了人类对草图进行分类的性能与机器的性能。数据集包含 20,000 个草图,均匀分布在 250 个对象类别中,从飞机到斑马;这里展示了几个示例:

图片

从感知研究中,他们发现人类正确识别草图的对象类别(如雪人、葡萄等)的比例为 73%。他们的竞争对手,他们的机器学习模型,正确识别的比例为 56%。还不错!你可以在官方网站上了解更多关于这项研究并下载相关数据集:cybertron.cg.tu-berlin.de/eitz/projects/classifysketch/.

在这个项目中,我们将使用一个稍微小一些的集合,其中 250 个类别中有 205 个;具体的类别可以在 CSV 文件 /Chapter7/Training/sketch_classes.csv 中找到,以及用于准备数据和训练模型的 Jupyter Notebooks。原始草图以 SVG 和 PNG 格式提供。由于我们使用的是 CNN,因此使用了光栅化图像(PNG),但已从 1111 x 1111 重新缩放为 256 x 256;这是我们模型的预期输入。然后,数据被分为训练集和验证集,其中 80%(每个类别的 64 个样本)用于训练,20%(每个类别的 17 个样本)用于验证。

网络架构与之前章节中使用的大致相似,只是第一层使用了更大的核窗口来提取草图的辅助特征,如下所示:

图片

回想一下,将卷积层堆叠在一起允许模型构建一组共享的高级模式,然后可以使用这些模式进行分类,而不是使用原始像素。最后一个卷积层被展平,然后输入到全连接层,在那里最终做出预测。你可以将这些全连接节点视为当输入中存在某些(高级)模式时开启的开关,如下面的图表所示。我们将在本章后面实现排序时回到这个概念。

图片

经过 68 次迭代(周期)后,模型在验证数据上达到了大约 65%的准确率。并不算出色,但如果考虑前两个或三个预测,那么这个准确率会增加到近 90%。以下图表显示了训练和验证准确率以及训练过程中的损失:

图片

在我们的模型训练完成后,下一步是使用苹果提供的 Core ML 工具(如前几章所述)将其导出,并将其导入到我们的项目中。

绘图分类

在本节中,我们将介绍如何将 Core ML 模型导入到我们的项目中,并将其连接起来,包括使用模型对用户的草图进行推理,以及搜索和推荐替代图像供用户替换草图。让我们开始将 Core ML 模型导入到我们的项目中。

在项目仓库文件夹/CoreMLModels/Chapter7/cnnsketchclassifier.mlmodel中定位模型;选择模型后,将其拖入你的 Xcode 项目,保留导入选项的默认设置。一旦导入,选择模型以检查详细信息,应该类似于以下截图:

图片

正如我们所有的模型一样,我们验证模型是否包含在目标中,通过检查适当的 Target Membership 是否被选中,然后我们将注意力转向输入和输出,这些现在应该已经很熟悉了。我们可以看到,我们的模型期望一个单通道(灰度)256 x 256 的图像,并通过输出对象的 classLabel 属性返回主导类别,同时通过 classLabelProbs 属性返回所有类别的概率字典。

现在我们已经导入了模型,让我们讨论如何将其集成到我们的项目中的细节。回想一下,我们的SketchView在用户绘制时发出UIControlEvents.editingDidStartUIControlEvents.editingChangedUIControlEvents.editingDidEnd事件。如果你检查SketchViewController,你会看到我们已经注册了监听UIControlEvents.editingDidEnd事件,如下面的代码片段所示:

override func viewDidLoad() {
        super.viewDidLoad()
        ...
        ...
        self.sketchView.addTarget(self, action:
 #selector(SketchViewController.onSketchViewEditingDidEnd),
 for: .editingDidEnd)
        queryFacade.delegate = self 
}

每当用户结束一笔画时,我们将开始尝试猜测用户正在绘制的草图并搜索合适的替代品。此功能通过.editingDidEnd动作方法onSketchViewEditingDidEnd触发,但将被委托给QueryFacade类,该类将负责实现此功能。这就是我们将在这个部分和下一个部分花费大部分时间的地方。同时,也值得在之前的代码片段中突出显示queryFacade.delegate = self这一声明。QueryFacade将在主线程之外执行大部分工作,并在完成后通知此代理状态和结果,我们将在稍后讨论。

让我们先实现onSketchViewEditingDidEnd方法的功能,然后再关注QueryFacade类。在SketchViewController类中,导航到onSketchViewEditingDidEnd方法,并添加以下代码:

guard self.sketchView.currentSketch != nil,
    let sketch = self.sketchView.currentSketch as? StrokeSketch else{
    return
} 

queryFacade.asyncQuery(sketch: sketch)

在这里,我们正在获取当前草图,如果没有草图可用或它不是一个StrokeSketch,则返回它;我们将其交给我们的queryFacadeQueryFacade类的一个实例)。现在让我们将注意力转向QueryFacade类;在 Xcode 的左侧面板中选择QueryFacade.swift文件,将其在编辑区域中打开。已经实现了很多底层代码,以便我们能够专注于预测、搜索和排序的核心功能。让我们快速讨论一些细节,从属性开始:

let context = CIContext()
let queryQueue = DispatchQueue(label: "query_queue")
var targetSize = CGSize(width: 256, height: 256)
weak var delegate : QueryDelegate?
var currentSketch : Sketch?{
    didSet{
        self.newQueryWaiting = true
        self.queryCanceled = false
    }
}

fileprivate var queryCanceled : Bool = false
fileprivate var newQueryWaiting : Bool = false
fileprivate var processingQuery : Bool = false
var isProcessingQuery : Bool{
    get{
        return self.processingQuery
    }
}

var isInterrupted : Bool{
    get{
        return self.queryCanceled || self.newQueryWaiting
    }
} 

QueryFacade只关注最新的草图。因此,每次使用currentSketch属性分配新的草图时,queryCanceled被设置为true。在每次任务(如执行预测、搜索和下载)期间,我们检查isInterrupted属性,如果为true,则提前退出并继续处理最新的草图。

当你将草图传递给asyncQuery方法时,草图被分配给currentSketch属性,然后继续调用queryCurrentSketch方法来完成大部分工作,除非当前有一个正在处理中:

func asyncQuery(sketch:Sketch){
    self.currentSketch = sketch

    if !self.processingQuery{
        self.queryCurrentSketch()
    }
}

fileprivate func processNextQuery(){
    self.queryCanceled = false

    if self.newQueryWaiting && !self.processingQuery{
        self.queryCurrentSketch()
    }
}

fileprivate func queryCurrentSketch(){
    guard let sketch = self.currentSketch else{
        self.processingQuery = false
        self.newQueryWaiting = false

        return
    }

    self.processingQuery = true
    self.newQueryWaiting = false

    queryQueue.async {

        DispatchQueue.main.async{
            self.processingQuery = false
            self.delegate?.onQueryCompleted(
                status:self.isInterrupted ? -1 : -1,
                result:nil)
            self.processNextQuery()
        }
    }
}

最终,我们到达queryCurrentSketch方法,我们将在这里转向并实现所需的功能。但在这样做之前,让我们快速讨论我们将要做什么。

回想一下,我们的目标是帮助用户快速绘制场景;我们计划通过预测用户试图绘制的内容并建议图像来实现这一点,用户可以用这些图像替换他们的草图。预测是这个系统的主要组成部分,使用我们刚刚导入的训练模型进行,但请记住,我们在验证数据集上达到了大约 65%的准确率。这留下了很多错误的空间,可能会抑制用户而不是增强他们。为了减轻这一点并提供更多功能,我们将选择前 3-4 个预测并拉下相关图像,而不是依赖于单一分类。

我们将这些预测类别传递给微软的 Bing 图像搜索 API 以查找相关图像,然后继续下载每个图像(虽然这并不是最优化方法,但对于实现这个原型来说是足够的)。一旦我们下载了图像,我们将根据每个图像与用户所绘制的图像的相似度对图像进行排序,我们将在下一节中回到这一点,但现在我们将专注于这一步骤之前的过程。让我们继续猜测用户试图做什么。

如我们之前所做的那样,让我们自下而上地工作,在将所有支持方法在 queryCurrentSketch 方法中整合在一起之前先实现它们。让我们首先声明我们模型的实例;在 QueryFacade 类的顶部附近添加以下变量:

let sketchClassifier = cnnsketchclassifier()

现在,我们的模型已经实例化并准备就绪,我们将导航到 QueryFacade 类的 classifySketch 方法;在这里,我们将使用我们导入的模型进行推理,但让我们首先回顾一下已经存在的内容:

func classifySketch(sketch:Sketch) -> [(key:String,value:Double)]?{
    if let img = sketch.exportSketch(size: nil)?
        .resize(size: self.targetSize).rescalePixels(){
        return self.classifySketch(image: img)
    }    
    return nil
}
func classifySketch(image:CIImage) -> [(key:String,value:Double)]?{    
    return nil
}

在这里,我们可以看到 classifySketch 是重载的,一个方法接受一个 Sketch,另一个接受一个 CIImage。当调用前者时,将使用 exportSketch 方法获取草图的光栅化版本。如果成功,它将使用 targetSize 属性调整光栅化图像的大小。然后,它将调整像素值,然后将准备好的 CIImage 传递给备选的 classifySketch 方法。

像素值在 0-255(每个通道;在这种情况下,只有一个通道)的范围内。通常,你试图避免在网络中拥有大数字。原因是它们会使你的模型学习(收敛)变得更加困难——某种程度上类似于试图驾驶一个方向盘只能向左或向右硬转的车。这些极端会导致大量过度转向,使导航变得极其困难。

第二个 classifySketch 方法将负责执行实际推理;我们已经在第三章,“世界中的物体识别”中看到如何做到这一点。在 classifySketch(image:CIImage) 方法内添加以下代码:

if let pixelBuffer = image.toPixelBuffer(context: self.context, gray: true){
    let prediction = try? self.sketchClassifier.prediction(image: pixelBuffer)

    if let classPredictions = prediction?.classLabelProbs{
        let sortedClassPredictions = classPredictions.sorted(by: { (kvp1, kvp2) -> Bool in
            kvp1.value > kvp2.value
        })

        return sortedClassPredictions
    }
}

return nil

在这里,我们使用图像、toPixelBuffer方法,这是我们之前在第三章中添加到CIImage类的一个扩展,以获得其自身的灰度CVPixelBuffer表示。现在,根据其缓冲区,我们将其传递给我们的模型实例sketchClassifierprediction方法,以获得每个标签的概率。我们最终将这些概率从最有可能到最不可能进行排序,然后将排序后的结果返回给调用者。

现在,根据我们对用户试图绘制的对象的了解,我们将继续搜索和下载我们最有信心的一些图像。搜索和下载的任务将由QueryFacade类中的downloadImages方法负责。此方法将使用现有的BingService,该服务公开了搜索和下载图像的方法。现在让我们连接这些功能;跳转到downloadImages方法并将其体中的以下突出显示代码添加进去:

func downloadImages(searchTerms:[String],
                    searchTermsCount:Int=4,
                    searchResultsCount:Int=2) -> [CIImage]?{
 var bingResults = [BingServiceResult]()

 for i in 0..<min(searchTermsCount, searchTerms.count){
 let results = BingService.sharedInstance.syncSearch(
 searchTerm: searchTerms[i], count:searchResultsCount)

 for bingResult in results{
 bingResults.append(bingResult)
 }

 if self.isInterrupted{
 return nil
 }
 }
}

downloadImages方法接受searchTermssearchTermsCountsearchResultsCount参数。searchTerms是我们classifySketch方法返回的标签的排序列表,searchTermsCount确定我们使用这些搜索术语的数量(默认为 4)。最后,searchResultsCount限制了每个搜索术语返回的结果数量。

上述代码使用传递给方法中的搜索术语进行顺序搜索。如前所述,这里我们使用微软的 Bing 图像搜索 API,这需要注册,我们将在稍后回到这个问题。在每次搜索后,我们检查isInterrupted属性以确定是否需要提前退出;否则,我们继续进行下一个搜索。

搜索返回的结果包括一个引用图像的 URL;我们将使用这个 URL 下载每个结果中的图像,在返回CIImage数组给调用者之前。现在让我们添加这个功能。将以下代码添加到downloadImages方法中:

var images = [CIImage]()

for bingResult in bingResults{
    if let image = BingService.sharedInstance.syncDownloadImage(
        bingResult: bingResult){
        images.append(image)
    }

    if self.isInterrupted{
        return nil
    }
}

return images

如前所述,这个过程是同步的,在每次下载后,我们检查isInterrupted属性以确定是否需要提前退出,否则将下载的图像列表返回给调用者。

到目前为止,我们已经实现了支持预测、搜索和下载的功能;我们的下一个任务是连接所有这些功能。回到queryCurrentSketch方法,并在queryQueue.async块内添加以下代码。确保您替换掉DispatchQueue.main.async块:

queryQueue.async {

 guard let predictions = self.classifySketch(
 sketch: sketch) else{
 DispatchQueue.main.async{
 self.processingQuery = false
 self.delegate?.onQueryCompleted(
 status:-1, result:nil)
 self.processNextQuery()
 }
 return
 }

 let searchTerms = predictions.map({ (key, value) -> String in
 return key
 })

 guard let images = self.downloadImages(
 searchTerms: searchTerms,
 searchTermsCount: 4) else{
 DispatchQueue.main.async{
 self.processingQuery = false
 self.delegate?.onQueryCompleted(
 status:-1, result:nil)
 self.processNextQuery()
 }
 return
 }

 guard let sortedImage = self.sortByVisualSimilarity(
 images: images,
 sketch: sketch) else{
 DispatchQueue.main.async{
 self.processingQuery = false
 self.delegate?.onQueryCompleted(
 status:-1, result:nil)
 self.processNextQuery()
 }
 return
 }

    DispatchQueue.main.async{
 self.processingQuery = false
 self.delegate?.onQueryCompleted(
 status:self.isInterrupted ? -1 : 1,
 result:QueryResult(
 predictions: predictions,
 images: sortedImage))
 self.processNextQuery()
 }
}

这是一段大量的代码,但并不复杂;让我们快速浏览一下。我们首先调用我们刚刚实现的classifySketch方法。如您所忆,除非被中断,否则此方法将返回标签和概率的排序列表,否则将返回nil。我们应该通过在方法早期退出之前通知委托来处理这种情况(这是我们应用于所有任务的一个检查)。

一旦我们获得了排序标签的列表,我们就将它们传递给downloadImages方法以接收相关的图像,然后我们将这些图像传递给sortByVisualSimilarity方法。这个方法目前只返回图像列表,但这是我们将在下一节中返回的内容。最后,该方法通过主线程将状态和排序后的图像包装在QueryResult实例中传递给委托,然后在调用processNextQuery方法之前检查是否需要处理新的草图。

在这个阶段,我们已经实现了根据我们对用户当前绘制的猜测下载替代图像所需的所有功能。现在,我们只需跳转到SketchViewController类来设置这个功能,但在这样做之前,我们需要获取一个订阅密钥来使用 Bing 的图像搜索。

在您的浏览器中,访问azure.microsoft.com/en-gb/services/cognitive-services/bing-image-search-api/并点击“尝试 Bing 图像搜索 API”,如图所示:

点击“尝试 Bing 图像搜索 API”后,您将看到一系列对话框;阅读并(如果)同意后,登录或注册。继续按照屏幕提示操作,直到你到达一个页面,告知 Bing 搜索 API 已成功添加到您的订阅中,如图所示:

在这个页面上,向下滚动直到你找到条目“Bing 搜索 API v7”。如果你检查这个块,你应该会看到一个端点和密钥的列表。将其中一个密钥复制并粘贴到BingService.swift文件中,替换常量subscriptionKey的值;以下截图显示了包含服务密钥的网页:

通过从左侧面板中选择SketchViewController.swift文件返回到SketchViewController,并定位到方法onQueryCompleted

func onQueryCompleted(status: Int, result:QueryResult?){
}  

回想一下,这是一个在QueryDelegate协议中定义的方法签名,QueryFacade使用它来通知代理查询是否失败或完成。正是在这里,我们将展示通过我们刚刚实现的过程找到的匹配图像。我们首先检查状态。如果被认为成功(大于零),我们将从queryImages数组中移除所有引用的项,这是我们的UICollectionView数据源,用于向用户展示建议的图像。一旦清空,我们遍历QueryResult实例中引用的所有图像,在请求UICollectionView重新加载数据之前,将它们添加到queryImages数组中。将以下代码添加到onQueryCompleted方法的主体中:

guard status > 0 else{
    return
}

queryImages.removeAll()

if let result = result{
    for cimage in result.images{
        if let cgImage = self.ciContext.createCGImage(cimage, from:cimage.extent){
            queryImages.append(UIImage(cgImage:cgImage))
        }
    }
}

toolBarLabel.isHidden = queryImages.count == 0
collectionView.reloadData() 

到此为止,我们已经准备好处理猜测用户绘制的内容并展示可能的建议。现在是时候在模拟器或设备上构建和运行应用程序,以检查一切是否正常工作。如果是这样,你应该会看到以下类似的内容:

图片

在完成本节之前,还有一件事要做。记住我们的目标是帮助用户快速绘制场景或类似的东西,我们的假设是猜测用户正在绘制的内容并建议预先绘制的图像将帮助他们完成任务。到目前为止,我们已经进行了预测并向用户提供了建议,但当前用户无法用任何建议替换他们的草图。现在让我们解决这个问题。

我们的SketchView目前只渲染StrokeSketch(它封装了用户绘制的元数据)。因为我们的建议是光栅化图像,我们的选择是扩展这个类(以渲染笔触和光栅化图像)或创建一个新的Sketch协议的具体实现。在这个例子中,我们将选择后者,并实现一个能够渲染光栅化图像的新类型的Sketch。选择Sketch.swift文件,将其带到 Xcode 的编辑区域焦点,滚动到最底部,并添加以下代码:

class ImageSketch : Sketch{
   var image : UIImage!
   var size : CGSize!
   var origin : CGPoint!
   var label : String!

    init(image:UIImage, origin:CGPoint, size:CGSize, label: String) {
        self.image = image
        self.size = size
        self.label = label
        self.origin = origin
    }    
}

我们定义了一个简单的类,它引用了一个图像、原点、大小和标签。原点决定了图像应该渲染的左上角位置,而大小决定了其大小!为了满足Sketch协议,我们必须实现centerboundingBox属性以及drawexportSketch方法。让我们依次实现这些,首先是boundingBox

boundingBox属性是从originsize属性派生出的计算属性。将以下代码添加到您的ImageSketch类中:

var boundingBox : CGRect{
    get{
        return CGRect(origin: self.origin, size: self.size)
    }
} 

类似地,center将是另一个从originsize属性派生出的计算属性,简单地将origin相对于size进行转换。将以下代码添加到您的ImageSketch类中:

var center : CGPoint{
    get{
        let bbox = self.boundingBox
        return CGPoint(x:bbox.origin.x + bbox.size.width/2,
                       y:bbox.origin.y + bbox.size.height/2)
    } set{
        self.origin = CGPoint(x:newValue.x - self.size.width/2,
                              y:newValue.y - self.size.height/2)
    }
}

draw方法将简单地使用传入的contextboundingBox内渲染分配的image;将以下代码添加到您的ImageSketch类中:

func draw(context:CGContext){
    self.image.draw(in: self.boundingBox)
}  

我们最后一个方法exportSketch也非常直接。在这里,我们创建一个CIImage实例,传入image(类型为UIImage)。然后,我们使用我们在第三章中实现的扩展方法对其进行调整,识别世界中的物体。将以下代码添加到完成ImageSketch类的代码中:

func exportSketch(size:CGSize?) -> CIImage?{
    guard let ciImage = CIImage(image: self.image) else{
        return nil
    }

    if self.image.size.width == self.size.width && self.image.size.height == self.size.height{
        return ciImage
    } else{
        return ciImage.resize(size: self.size)
    }
} 

我们现在有一个可以处理渲染光栅化图像(如我们搜索返回的图像)的Sketch实现。我们的最终任务是替换用户绘制的草图与用户从UICollectionView中选择的项。通过在 Xcode 左侧面板中选择SketchViewController.swift来返回SketchViewController类,将其在编辑区域中打开。一旦加载,导航到方法collectionView(_ collectionView:, didSelectItemAt:);这对大多数人来说应该很熟悉。这是处理从UICollectionView中选择的单元格的代理方法,我们将在这里处理替换用户当前草图与所选项。

让我们从获取当前草图和所选的关联图像开始。将以下代码添加到collectionView(_collectionView:,didSelectItemAt:)方法体中:

guard let sketch = self.sketchView.currentSketch else{
    return
}
self.queryFacade.cancel()
let image = self.queryImages[indexPath.row] 

现在,根据当前的草图和图像,我们想要尝试保持大小与用户的草图相对一致。我们将通过简单地获取草图的边界框并按比例缩放尺寸以尊重所选图像的宽高比来实现这一点。添加以下代码以处理此操作:

    var origin = CGPoint(x:0, y:0)
    var size = CGSize(width:0, height:0)

    if bbox.size.width > bbox.size.height{
        let ratio = image.size.height / image.size.width
        size.width = bbox.size.width
        size.height = bbox.size.width * ratio
    } else{
        let ratio = image.size.width / image.size.height
        size.width = bbox.size.height * ratio
        size.height = bbox.size.height
    } 

接下来,我们通过获取草图的中心并相对于其宽度和高度进行偏移来获取原点(图像的左上角)。通过添加以下代码来完成此操作:

origin.x = sketch.center.x - size.width / 2
origin.y = sketch.center.y - size.height / 2

我们现在可以使用图像、大小和原点来创建一个ImageSketch,并通过将其分配给SketchView实例的currentSketch属性来简单地替换当前的草图。将以下代码添加以执行此操作:

self.sketchView.currentSketch = ImageSketch(image:image,
                                            origin:origin,
                                            size:size,
                                            label:"")

最后,进行一些清理工作;我们将通过从queryImages数组(其数据源)中移除所有图像并请求它重新加载自身来清除UICollectionView。将以下代码块添加到完成collectionView(_ collectionView:,didSelectItemAt:)方法的代码中:

self.queryImages.removeAll()
self.toolBarLabel.isHidden = queryImages.count == 0
self.collectionView.reloadData()

现在所有连接都已就绪;我们已经实现了所有猜测用户绘制的内容、提供建议以及允许用户将他们的草图与替代方案进行交换的功能。现在是构建和运行的好时机,以确保一切按计划进行。如果是这样,那么您应该能够用顶部显示的其中一个建议替换您的草图,如图所示:

图片

在结束本章之前,还有一个最后的章节。在本节中,我们将探讨一种技术,以微调我们的搜索结果,使其更好地匹配用户所绘的内容。

按视觉相似度排序

到目前为止,我们已经实现了我们设定的目标,即推断用户试图绘制的内容,并向他们提供可以交换草图的建议。但我们的解决方案目前还不足以理解用户。当然,它可能预测正确并提供用户所绘内容的正确类别,但它忽略了用户绘图中任何风格或细节。例如,如果用户在画,并且只想画一个猫头,我们的模型可能会正确预测用户在画猫,但忽略了他们的画缺少身体的事实。它可能会建议全身猫的图片。

在本节中,我们将探讨一种更敏感地对待用户输入的技术,并提供一个非常基础的解决方案,但可以在此基础上构建。这种方法将尝试根据与用户草图相似的程度对图像进行排序。在深入代码之前,让我们先简要讨论一下相似度度量,通过查看我们如何测量不同领域中的相似度,例如句子。以下是我们将基于讨论的三个句子:

  • "the quick brown fox jumped over the lazy dog"

  • "the quick brown fox runs around the lazy farm dog"

  • "machine learning creates new opportunities for interacting with computers"

这个练习对于那些熟悉表示方法的人来说很熟悉。在这里,我们将创建一个包含我们语料库(在这个例子中是三个句子)中所有单词的词汇表,然后通过以下截图所示,为每个句子创建向量,即通过将句子中的单词与词汇表中的相应索引进行增量。

图片

由于我们的句子现在已被编码为向量,我们可以通过执行距离操作(如欧几里得距离余弦距离)来衡量每句话之间的相似度。以下为每个这些距离的方程式:

图片

现在我们来计算每句话之间的距离,并比较结果。以下截图显示了结果:

图片

正如你所期望的,句子“the quick brown fox jumped over the lazy dog”和“the quick brown fox ran around the lazy farm dog”之间的距离比句子“machine learning creates new opportunities for interacting with computers”之间的距离要小。如果你要构建一个推荐引擎,尽管是一个简单的推荐引擎,你可能会将具有更多共同词汇的句子排名高于具有较少共同词汇的句子。对于图像来说,情况也是如此,但与句子不同,我们在这里使用的是来自网络层的特征。

回想一下,我们用于分类草图的网络由一系列卷积层组成,每一层都是基于其下层的模式构建更高层次的模式。直观上,我们可以将这些更高层次的模式视为我们的词汇(特征),而全连接网络则代表给定图像中存在的词汇。为了使这一点更清晰,这里展示了一个简单的插图:

图片 1

检查这张图,我们可以看到左侧的特征图集合,这些可以被视为用于从图像中提取水平、垂直、左对角和右对角边缘的卷积核。

中间是我们将从中提取这些特征的样本。最后,在最右边,我们有每个样本提取的特征(直方图)。我们使用这些提取的特征作为我们的特征向量,并可以使用它们来计算它们之间的距离,就像我们在之前的图中看到的那样。

因此,如果我们能够从一个图像中提取这种类型的特征向量,那么我们也将能够根据用户的草图(使用其提取的特征向量)对它们进行排序。但我们是怎样得到这个特征向量的呢?回想一下,我们已经有了一个学习高级特征图的网络。如果我们能够获得一个向量,指示给定图像中哪些特征最为活跃,那么我们可以使用这个向量作为我们的特征向量,并使用它来计算其他图像之间的距离,例如用户的草图和下载的图像。这正是我们将要做的;我们不会通过一个 softmax 激活层(用于对类别进行预测)来喂给网络,而是从我们的网络中移除这个层,留下最后的全连接层作为新的输出层。这实际上为我们提供了一个特征向量,我们可以用它来与其他图像进行比较。以下图显示了更新后的网络的结构:

图片 2

如果你将这个网络与上一节中展示的网络进行比较,你会注意到唯一的变化是缺少了全连接层。现在这个网络的输出是一个大小为 512 的特征向量。让我们通过实际操作来使这个概念更加具体。

我假设您已经从仓库github.com/packtpublishing/machine-learning-with-core-ml中拉取了相应的代码。导航到Chapter7/Start/QuickDraw/目录并打开游乐场FeatureExtraction.playground。这个游乐场包括之前描述的生成代码和编译模型,以及一些我们将使用的视图和辅助方法;所有这些都应该相当直观。让我们首先通过添加以下代码到游乐场的顶部来导入一些依赖项并声明一些变量:

import Accelerate
import CoreML

let histogramViewFrame = CGRect(
    x: 0, y: 0,
    width: 600, height: 300)

let heatmapViewFrame = CGRect(
    x: 0, y: 0,
    width: 600, height: 600)

let sketchFeatureExtractor = cnnsketchfeatureextractor()
let targetSize = CGSize(width: 256, height: 256)
let context = CIContext()

在这里,我们声明了两个矩形;它们将确定我们稍后创建的视图的框架,最重要的是,实例化我们的模型,我们将使用它从每个图像中提取特征。关于这一点,如果你在左侧面板上展开Resources文件夹,然后在Images文件夹中再次展开,你会看到我们将使用的图片,如图所示:

图片

正如我们之前讨论的,我们希望能够对图像进行排序,以便建议的图像与用户绘制的图像尽可能接近。继续我们的用户只画猫头的例子,我们希望有一种方法可以排序图像,使得只有猫头的图像出现在有猫和身体的图像之前。让我们继续我们的实验;添加以下方法,我们将使用这些方法从给定的图像中提取特征:

func extractFeaturesFromImage(image:UIImage) -> MLMultiArray?{
    guard let image = CIImage(
        image: image) else{
        return nil
    }
    return extractFeaturesFromImage(image: image)
}

func extractFeaturesFromImage(image:CIImage) -> MLMultiArray?{
    guard let imagePixelBuffer = image.resize(
        size: targetSize)
        .rescalePixels()?
        .toPixelBuffer(context: context,
                       gray: true) else {
        return nil
    }

    guard let features = try? sketchFeatureExtractor.prediction(
        image: imagePixelBuffer) else{
        return nil
    }

    return features.classActivations
}

大多数代码应该对你来说都很熟悉;我们有一个用于处理UIImage的重载方法,该方法在将其传递给其他方法之前,简单地创建一个CIImage实例。这将处理图像的准备工作,并将其最终输入到模型中。一旦完成推理,我们返回模型属性classActiviations,正如之前所讨论的。这是来自最后一个全连接层的输出,我们将将其用作我们的特征向量进行比较。

接下来,我们将加载所有图像并从每个图像中提取特征。将以下代码添加到您的游乐场中:

var images = [UIImage]()
var imageFeatures = [MLMultiArray]()
for i in 1...6{
    guard let image = UIImage(named:"images/cat_\(i).png"),
        let features = extractFeaturesFromImage(image:image) else{
            fatalError("Failed to extract features")
    }

    images.append(image)
    imageFeatures.append(features)
}

现在我们有了图像和特征,让我们检查一些图像及其特征图。我们可以通过创建一个HistogramView实例并将特征传递给它来完成此操作。以下是完成此操作的代码:

let img1 = images[0]
let hist1 = HistogramView(frame:histogramViewFrame, data:imageFeatures[0])

let img2 = images[1]
let hist2 = HistogramView(frame:histogramViewFrame, data:imageFeatures[1])

// cat front view
let img3 = images[2]
let hist3 = HistogramView(frame:histogramViewFrame, data:imageFeatures[2])

let img4 = images[3]
let hist4 = HistogramView(frame:histogramViewFrame, data:imageFeatures[3])

// cats head
let img5 = images[4]
let hist5 = HistogramView(frame:histogramViewFrame, data:imageFeatures[4])

let img6 = images[5]
let hist6 = HistogramView(frame:histogramViewFrame, data:imageFeatures[5]) 

您可以通过在预览视图中的眼睛图标上单击来手动检查每个图像,如图中所示:

图片

单独检查它们并没有提供太多见解。因此,在这个图中,我展示了我们可以检查的三张图片:

图片

没有太多关注,你可以感觉到猫头的特征向量比猫的侧面视图的特征向量更接近,尤其是在图表的右侧。

让我们进一步通过计算每张图像之间的余弦距离并在热图上绘制它们来探索这一点。首先添加以下代码;它将用于计算余弦距离:

func dot(vecA: MLMultiArray, vecB: MLMultiArray) -> Double {
    guard vecA.shape.count == 1 && vecB.shape.count == 1 else{
        fatalError("Expecting vectors (tensor with 1 rank)")
    }

    guard vecA.count == vecB.count else {
        fatalError("Excepting count of both vectors to be equal")
    }

    let count = vecA.count
    let vecAPtr = UnsafeMutablePointer<Double>(OpaquePointer(vecA.dataPointer))
    let vecBPptr = UnsafeMutablePointer<Double>(OpaquePointer(vecB.dataPointer))
    var output: Double = 0.0

    vDSP_dotprD(vecAPtr, 1, vecBPptr, 1, &output, vDSP_Length(count))

    var x: Double = 0

    for i in 0..<vecA.count{
        x += vecA[i].doubleValue * vecB[i].doubleValue
    }

    return x
}

func magnitude(vec: MLMultiArray) -> Double {
    guard vec.shape.count == 1 else{
        fatalError("Expecting a vector (tensor with 1 rank)")
    }

    let count = vec.count
    let vecPtr = UnsafeMutablePointer<Double>(OpaquePointer(vec.dataPointer))
    var output: Double = 0.0
    vDSP_svsD(vecPtr, 1, &output, vDSP_Length(count))

    return sqrt(output)
} 

方程的细节之前已经介绍过,这只是在 Swift 中将其翻译过来;重要的是使用 iOS Accelerate 框架中可用的向量数字信号处理vDSP)函数。如文档所述,vDSP API 为语音、声音、音频、视频处理、诊断医学成像、雷达信号处理、地震分析和科学数据处理等应用提供数学函数。因为它建立在 Accelerate 之上,所以继承了通过单指令多数据SIMD)实现的性能提升——在数据向量上同时运行相同的指令——这在处理来自神经网络等大型向量时非常重要。诚然,一开始这似乎不太直观,但文档提供了你使用它所需的大部分信息;让我们检查magnitude方法来感受一下。

我们使用vDSP_svsD函数来计算特征向量的幅度;该函数期望以下参数(按顺序):数据指针(UnsafePointer<Double>),步长(vDSP_Stride),输出变量指针(UnsafeMutablePointer<Double>),以及最后的长度(vDSP_Length)。大部分工作在于准备这些参数,如下代码片段所示:

let vecPtr = UnsafeMutablePointer<Double>(OpaquePointer(vec.dataPointer))
var output: Double = 0.0
vDSP_svsD(vecPtr, 1, &output, vDSP_Length(vec.count))

在此函数返回后,我们将计算出存储在output变量中的给定向量的幅度。现在让我们利用这个结果来计算每张图像之间的距离。将以下代码添加到你的 playground 中:

var similarities = Array(repeating: Array(repeating: 0.0, count: images.count), count: images.count)

for i in 0..<imageFeatures.count{
    for j in 0..<imageFeatures.count{
        let sim = cosineSimilarity(
            vecA: imageFeatures[i],
            vecB: imageFeatures[j])
        similarities[i][j] = sim
    }
}

在这里,我们正在对每张图像进行两次迭代,以创建一个矩阵(多维数组,在这种情况下)来存储每张图像之间的距离(相似性)。我们现在将这个矩阵以及相关的图像输入到HeatmapView实例中,它将可视化每张图像之间的距离。添加以下代码,然后通过在结果面板中点击眼睛图标来扩展视图以查看结果:

let heatmap = HeatmapView(
    frame:heatmapViewFrame,
    images:images,
    data:similarities)

如前所述,通过预览视图,你应该看到以下类似的图:

图片

这个可视化显示了每张图像之间的距离;单元格越暗,它们越接近。例如,如果你查看 1x1 单元格、2x2 单元格等,你会看到这些单元格都较暗(距离为 0,因为它们是同一张图像)。你还会注意到另一个模式:沿着图表对角线排列的四个单元格的集群。这,因此,是我们的目标——看看我们是否能够通过它们的相似性对草图进行排序,例如正面绘制的猫、猫头和侧面绘制的猫。

带着我们的新知识,让我们回到 iPhone 项目 QuickDraw.xcodeproj,我们将复制此代码并实现排序。

随着 QuickDraw 项目现在已打开,从项目仓库文件夹 /CoreMLModels/Chapter7/cnnsketchfeatureextractor.mlmodel 定位到特征提取模型。选择模型后,将其拖放到你的 Xcode 项目中,保留导入选项的默认设置。

现在模型已导入,从左侧面板(在 Xcode 中)选择文件 QueryFacade.swift 以将其在编辑区域中打开。在打开类后,在 QueryFacade 类的顶部添加一个实例变量,如下所示:

let sketchFeatureExtractor = cnnsketchfeatureextractor()

接下来,将 extractFeaturesFromImagecosineSimilaritydotmagnitude 方法从你的游乐场复制到 QueryFacade 类中,如下所示:

func extractFeaturesFromImage(image:CIImage) -> MLMultiArray?{
    // obtain the CVPixelBuffer from the image
    guard let imagePixelBuffer = image
        .resize(size: self.targetSize)
        .rescalePixels()?
        .toPixelBuffer(context: self.context, gray: true) else {
        return nil
    }

    guard let features = try? self.sketchFeatureExtractor
        .prediction(image: imagePixelBuffer) else{
        return nil
    }

    return features.classActivations
}

func cosineSimilarity(vecA: MLMultiArray,
                                  vecB: MLMultiArray) -> Double {
    return 1.0 - self.dot(vecA:vecA, vecB:vecB) /
        (self.magnitude(vec: vecA) * self.magnitude(vec: vecB))
}

func dot(vecA: MLMultiArray, vecB: MLMultiArray) -> Double {
    guard vecA.shape.count == 1 && vecB.shape.count == 1 else{
        fatalError("Expecting vectors (tensor with 1 rank)")
    }

    guard vecA.count == vecB.count else {
        fatalError("Excepting count of both vectors to be equal")
    }

    let count = vecA.count
    let vecAPtr = UnsafeMutablePointer<Double>(
        OpaquePointer(vecA.dataPointer)
    )
    let vecBPptr = UnsafeMutablePointer<Double>(
        OpaquePointer(vecB.dataPointer)
    )
    var output: Double = 0.0

    vDSP_dotprD(vecAPtr, 1,
                vecBPptr, 1,
                &output,
                vDSP_Length(count))

    var x: Double = 0

    for i in 0..<vecA.count{
        x += vecA[i].doubleValue * vecB[i].doubleValue
    }

    return x
}

func magnitude(vec: MLMultiArray) -> Double {
    guard vec.shape.count == 1 else{
        fatalError("Expecting a vector (tensor with 1 rank)")
    }

    let count = vec.count
    let vecPtr = UnsafeMutablePointer<Double>(
        OpaquePointer(vec.dataPointer)
    )
    var output: Double = 0.0
    vDSP_svsD(vecPtr, 1, &output, vDSP_Length(count))

    return sqrt(output)
}

现在我们有了这些方法,是时候利用它们了。定位到方法 sortByVisualSimilarity(images:[CIImage], sketchImage:CIImage);此方法已在 queryCurrentSketch 方法中调用,但目前它只是返回传入的列表。我们想要在这个方法中添加一些排序,以便将最类似于用户草图的照片放在前面。让我们分块构建,从提取用户草图的照片特征开始。将以下代码添加到 sortByVisualSimilarity 方法的主体中,替换其当前内容:

guard let sketchFeatures = self.extractFeaturesFromImage(
    image: sketchImage) else{
    return nil
}

接下来,我们想要所有其他图像的特征,这可以通过遍历列表并将它们存储在数组中简单地完成。添加以下代码以实现这一点:

var similatiryScores = Array<Double>(
    repeating:1.0,
    count:images.count)

for i in 0..<images.count{
    var similarityScore : Double = 1.0

    if let imageFeatures = self.extractFeaturesFromImage(
        image: images[i]){
        similarityScore = self.cosineSimilarity(
            vecA: sketchFeatures,
            vecB: imageFeatures)
    }

    similatiryScores[i] = similarityScore

    if self.isInterrupted{
        return nil
    }
}

如我们之前所做的那样,在处理每张图像后,我们检查属性 isInterrupted 以确定进程是否被中断,然后再继续处理下一张图像。我们的最终任务是排序并返回这些图像;将以下代码添加到 sortByVisualSimilarity 方法的主体中:

return images.enumerated().sorted { (elemA, elemB) -> Bool in
    return similatiryScores[elemA.offset] < similatiryScores[elemB.offset]
    }.map { (item) -> CIImage in
        return item.element
}

在实施之后,现在是构建和运行你的项目的好时机,以查看一切是否正常工作,并将结果与之前的构建进行比较。

这就结束了本章;在进入下一章之前,我们将简要总结。

摘要

你还在这里。我很惊讶,恭喜你!这是一个漫长但富有成效的章节。我们看到了另一个例子,说明了我们可以如何应用卷积神经网络(CNNs),在这个过程中,我们进一步了解了它们的工作原理、如何调整它们以及我们可以如何修改它们。我们看到了如何使用学习到的特征不仅用于分类,还用于排名,这是一种在许多领域(如时尚发现和推荐引擎)中使用的技巧。我们还花了很多时间构建一个绘图应用程序,我们将在下一章继续使用它。在那里,我们将再次探索如何使用在 Google 的 QuickDraw 数据集上训练的循环神经网络(RNN)执行草图分类。前方有很多乐趣,让我们开始吧。

第八章:使用 RNNs 进行辅助绘图

在前一章中,我们介绍了构建一个简单的绘图应用程序的过程,该应用程序会尝试推断用户正在绘制的内容,并根据最可能的预测类别向用户提供替代方案;这个应用程序的目的是通过提供由微软的 Bing 图像搜索获得的完成草图来提高绘图任务的效率,而不是花费时间纠结于细节。

在本章中,我们将重新审视这个应用程序,但将探讨一种推断用户正在绘制的内容的替代方法。在这个过程中,我们将接触新的数据类型和机器学习模型。遵循熟悉的格式,我们首先将回顾任务,探索数据和模型,然后在一个游乐场中逐步构建所需的功能,最后将其迁移到我们的应用程序中。让我们开始吧。

辅助绘图

在本节中,我们将简要介绍本章的项目以及我们的目标。回顾前一章,我们描述了一个能够预测用户试图绘制的图像的应用程序,并根据预测的分类(如帆船)检索相似图像。基于这个预测,应用程序会搜索并下载该类别的图像。下载后,它会根据与用户草图相似度进行排序。然后,它会向用户展示排序后的替代方案,用户可以用草图进行交换。

完成的项目如下所示:

图片

用于执行此分类的模型基于卷积神经网络CNN),这是一种非常适合理解图像的神经网络类型,因为它能够找到局部模式并在这些较低层次模式的基础上构建更复杂和有趣的模式。我们利用这些高级模式,将它们作为排序下载图像的基础,使得那些在风格上与用户草图更相似的图像会首先显示出来。我们通过比较使用单词作为特征(单词类似于我们的高级模式)和距离公式来计算相似度,来推理这种工作的原理。

但我们的方法存在一些开销;为了进行准确的分类,我们需要完成大量的草图,还需要使用内存和 CPU 周期将图像光栅化,然后才能将其输入到我们的模型中。在本章中,我们将使用一种不依赖于像素作为特征,而是使用绘制它所用的笔触序列作为特征的替代方法。你可能有无数个理由想要这样做,包括:

  • 数据或更大数据集的可访问性

  • 预测准确性的潜在改进

  • 生成能力,即能够预测和生成下一组笔触

但在这里,它为我们提供了探索一种本质上编码相同内容的数据类型——草图的机会。让我们在下一节中进一步探讨,我们将介绍在这个项目中将使用的数据集和模型。

绘图分类的循环神经网络

本章使用的模型是在谷歌的 AI 实验 Quick**, Draw! 中使用的数据集上训练的。

Quick, Draw! 是一款游戏,玩家被挑战绘制一个给定的物体,看看计算机是否能识别它;以下展示了数据的一个摘录:

图片

这种技术是从手写识别(谷歌翻译)的工作中受到启发的,在那里,团队不是将整个图像作为一个整体来看待,而是与描述字符如何绘制的特征数据一起工作。这在下图中得到了说明:

图片

来源:experiments.withgoogle.com/ai/quick-draw

这里的假设是,人们绘制某些类型物体时存在某种一致的规律;但要发现这些规律,我们需要大量的数据,而这些数据我们确实拥有。数据集包括从 Quick, Draw! 游戏玩家那里巧妙获得的超过 5000 万个绘图,涵盖了 345 个类别。每个样本都由带时间戳的向量和关联元数据描述,这些元数据说明了玩家所在的国家和用户请求的类别。您可以从官方网站了解更多关于数据集的信息:github.com/googlecreativelab/quickdraw-dataset

为了使数据集和训练变得可管理,我们的模型只在 345 个类别中的 172 个类别上进行了训练,但用于创建和训练模型的 Notebook 供那些想要深入了解细节的人使用。为了更好地理解数据,让我们看一下单个样本,如下所示:

{
    "key_id":"5891796615823360",
    "word":"nose",
    "countrycode":"AE",
    "timestamp":"2017-03-01 20:41:36.70725 UTC",
    "recognized":true,
    "drawing":[[[129,128,129,129,130,130,131,132,132,133,133,133,133,...]]]
 }

草图的细节被分解成一系列的笔触,每个笔触由一个包含 xy 位置和 timestamp 的三维数组描述,这些构成了笔触的路径:

[
    [ // First stroke
    [x0, x1, x2, x3, ...],
    [y0, y1, y2, y3, ...],
    [t0, t1, t2, t3, ...]
 ],
    [ // Second stroke
    [x0, x1, x2, x3, ...],
    [y0, y1, y2, y3, ...],
    [t0, t1, t2, t3, ...]
 ],
    ... // Additional strokes
 ]

如前所述,这是一个来自 原始数据集 的示例,Quick, Draw! 背后的团队发布了许多数据变体,从原始样本到预处理和压缩版本。我们主要对探索原始和简化版本感兴趣:前者因为它是我们拥有的最接近用户获取数据的表示,后者因为它被用来训练模型。

剧透:本章的大部分内容都涉及用户输入的预处理。

原始和简化版本都将每个类别存储在单独的文件中,文件格式为 NDJSON。

NDJSON 文件格式,即换行分隔的 JSON,是一种方便的格式,用于存储和流式传输可能逐条记录处理的结构化数据。正如其名所示,它将多个 JSON 格式的对象存储在单行中。在我们的情况下,这意味着每个样本都存储为一个单独的对象,由换行符分隔;你可以在ndjson.org了解更多关于该格式的信息。

你可能会想知道原始版本和简化版本之间的区别是什么。当我们构建此应用程序所需的预处理功能时,我们将详细介绍这些细节,但正如其名所示,简化版本通过删除任何不必要的点以及应用某种程度的标准化来降低每个笔触的复杂性——这是处理任何数据时的典型要求,以便使样本更具可比性。

现在我们对我们处理的数据有了更好的理解,让我们转向如何从这些序列中学习,通过简要讨论本章使用的模型细节来建立一些直觉。

在前面的章节中,我们看到了许多例子,说明了卷积神经网络(CNNs)如何从局部的二维块中学习有用的模式,这些模式本身可以进一步从原始像素抽象出更具描述力的东西。鉴于我们对图像的理解并非由独立的像素组成,而是一系列与相邻像素相关的像素集合,这些像素集合反过来描述了物体的各个部分,这一点相当直观。在第一章《机器学习简介》中,我们介绍了一种循环神经网络RNN),它是构建用于语言翻译的序列到序列Seq2Seq)模型的主要组成部分,并看到了其记忆能力使其非常适合顺序数据,其中顺序很重要。正如之前所强调的,我们的给定样本由笔触序列组成;循环神经网络是学习对草图进行分类的合适候选者。

作为快速回顾,循环神经网络(RNNs)通过一个反馈循环实现了一种选择性记忆,这个反馈循环在训练过程中进行调整;从图解上看,如下所示:

图片

左边是实际的网络,右边是在四个时间步上展开的相同网络。随着草图笔触的点被输入,它们被层权重与当前状态相乘,然后被反馈回网络并/或输出。在训练过程中,这种反馈允许网络学习有序序列的模式。我们可以将这些循环层堆叠起来,就像我们在 CNN 中做的那样,以学习更复杂和抽象的模式。

但递归层并不是从序列数据中学习模式的唯一方法。如果你将 CNN 的概念推广为能够在任何维度上学习局部模式(而不仅仅是两个维度),那么你就可以看到我们如何可以使用 1D 卷积层来实现与递归层类似的效果。在那里,类似于 2D 卷积层,我们在序列中学习 1D 核(将时间视为空间维度)以找到局部模式来表示我们的数据。使用卷积层的优势是它的计算成本远低于其对应层,这使得它非常适合处理器和功率受限的设备,如手机。它还有其能够学习独立于顺序的模式的能力,类似于 2D 核对位置的不变性。在此图中,我们说明了 1D 卷积层如何作用于输入数据:

在这个上下文中,笔触(局部于窗口大小)将独立于它们在序列中的位置而被学习,并将输出紧凑的表示,然后我们可以将其输入到 RNN 中,从这些笔触中学习有序序列(而不是从原始点中学习)。直观地,你可以将我们的模型视为最初学习垂直和水平等笔触(独立于时间),然后在后续的由 RNN 组成的层中学习从这些笔触的有序序列中更高阶的模式,如形状。以下图说明了这个概念:

在左侧,我们有输入到模型中的原始点。中间部分显示了 1D 卷积层如何从这些点中学习局部模式,以笔触的形式。最后,在右侧,我们有后续的 RNN 从这些笔触的序列中学习顺序敏感的模式。

在介绍模型之前,还有一个概念需要介绍,但在这样做之前,我想让你快速思考一下你如何画一个正方形。你是按顺时针方向画还是按逆时针方向画?

在本节中,我想简要介绍的最后一种概念是双向层;双向层试图使我们的网络对先前的问题不变。我们之前讨论了 RNN 对顺序的敏感性,这正是它们在这里有用的原因,但正如我希望已经强调的,我们的草图可能是按相反的顺序绘制的。为了解决这个问题,我们可以使用双向层,正如其名称所暗示的,它以两个方向(按时间顺序和逆时间顺序)处理输入序列,然后合并它们的表示。通过从两个方向处理序列,我们的模型可以变得对绘制方向有一定的不变性。

我们现在已经介绍了用于此模型的所有构建块;以下图显示了整个模型:

提醒一下,这本书主要关注与 Core ML 相关的机器学习应用。因此,我们不会深入探讨这个(或任何)模型的细节,但会涵盖足够的内容,以便您能够直观地理解模型的工作原理,以便您使用和进一步探索。

如前所述,我们的模型由一系列一维卷积层组成,这些层输入到一个长短期记忆LSTM)堆栈中,这是一个循环神经网络(RNN)的实现,然后输入到一个全连接层,在那里我们做出预测。这个模型在 172 个类别上进行了训练,每个类别使用 10,000 个训练样本和 1,000 个验证样本。经过 16 个 epoch 后,模型在训练和验证数据上达到了大约 78%的准确率,如下所示:

我们现在有了我们的模型,但只是匆匆浏览了我们实际上输入到模型中的内容。在下一节中,我们将讨论我们的模型是用什么进行训练的(因此期望什么)并实现所需的函数来准备它。

输入数据和预处理

在本节中,我们将实现预处理功能,以将我们的原始用户输入转换为模型所期望的格式。我们将在操场项目中构建此功能,然后在下一节中将其迁移到我们的项目中。

如果您还没有这样做,请从配套的仓库中拉取最新的代码github.com/PacktPublishing/Machine-Learning-with-Core-ML。下载后,导航到目录Chapter8/Start/并打开操场项目ExploringQuickDrawData.playground。一旦加载,您将看到本章的操场,如下所示:

操场包括一些原始Quick, Draw!数据集的样本,一个简化的提取,以及我们在上一章中创建的编译模型和支持类,用于表示草图(Stroke.swiftSketch.swift)和渲染它(SketchView.swift)。本节的目标将是更好地理解在将数据输入模型之前所需的预处理;这样做时,我们将扩展我们现有的类以封装此功能。

在我们继续前进之前,让我们先回顾一下现有的代码;如果您向下滚动打开的源文件,您将看到createFromJSONdrawSketch方法。前者接受一个 JSON 对象(我们的样本保存的格式)并返回一个强类型对象:StrokeSketch。提醒一下,每个样本由以下内容组成:

  • key_id: 唯一标识符

  • word: 类别标签

  • countrycode: 样本抽取的国家代码

  • timestamp: 样本创建的时间戳

  • recognized: 一个标志,表示草图是否当前被识别

  • drawing:一个多维数组,由xy坐标的数组组成,以及自点创建以来经过的时间

StrokeSketch将单词映射到标签属性,并将xy坐标映射到笔触点。我们丢弃其他所有内容,因为在分类中认为它没有用,并且我们的模型也没有使用。drawSketch方法是一个实用方法,它在创建SketchView实例以渲染缩放和居中的草图之前处理缩放和居中。

最后一块代码预加载 JSON 文件,并通过字典loadedJSON使它们可用,其中键是相关文件名,值是加载的 JSON 对象。

让我们先看看数据,比较原始样本和简化样本;将以下代码添加到您的游乐场中:

if let rJson = loadedJSON["small_raw_airplane"],
    let sJson = loadedJSON["small_simplified_airplane"]{

    if let rSketch = StrokeSketch.createFromJSON(json: rJson[0] as?   [String:Any]),
        let sSketch = StrokeSketch.createFromJSON(json: sJson[0] as? [String:Any]){
        drawSketch(sketch: rSketch)
        drawSketch(sketch: sSketch)
    }

    if let rSketch = StrokeSketch.createFromJSON(json: rJson[1] as? [String:Any]),
        let sSketch = StrokeSketch.createFromJSON(json: sJson[1] as? [String:Any]){
        drawSketch(sketch: rSketch)
        drawSketch(sketch: sSketch)
    }
}

在前面的代码片段中,我们只是获取我们加载的 JSON 文件的引用,并将索引 0 和 1 的样本传递给我们的createFromJSON文件,它将返回它们的StrokeSketch表示。然后我们继续将其传递给drawSketch方法以创建渲染视图。运行后,您可以通过单击位于方法drawSketch同一行的右侧面板上的眼睛图标来预览每个草图。以下图像展示了两个输出并排比较:

从原始数据集和简化数据集的样本中可以看到的主要差异在前面的图中。原始样本要大得多,也更平滑。从之前的图像中不明显的是,简化样本位于左上角,而原始样本由原始和绝对位置上的点组成(回想一下,我们的drawSketch方法在需要时重新缩放并居中草图)。

提醒一下,原始样本类似于我们期望从用户那里接收到的输入,而另一方面,我们的模型是在简化数据集的样本上训练的。因此,我们需要执行与将原始数据转换为简化对应物相同的预处理步骤,在将数据输入我们的模型之前。这些步骤在数据存储库github.com/googlecreativelab/quickdraw-dataset中描述如下,这是我们将在我们的游乐场中实现的内容:

  • 将绘图对齐到左上角,使其最小值为零

  • 将绘图均匀缩放,使其最大值为 255

  • 使用一像素间距对所有笔触进行重采样

  • 使用 2.0 的 epsilon 值,使用 Ramer-Douglas-Peucker 算法简化所有笔触

Ramer-Douglas-Peucker 算法接受由线段(笔画)组成的曲线,并找到具有更少点的更简单曲线。您可以在以下链接中了解更多关于该算法的信息:en.wikipedia.org/wiki/Ramer-Douglas-Peucker_algorithm.

这些步骤背后的原理应该是相当直观的,并且从显示飞机两个草图的图中可以突出显示。也就是说,飞机应该对其在屏幕上的实际位置不变,并且对比例不变。简化笔画使得我们的模型更容易学习,因为它有助于确保我们只捕获显著特征。

首先,创建您的StrokeSketch类的扩展,并像下面所示地创建simplify方法的占位符:

public func simplify() -> StrokeSketch{
    let copy = self.copy() as! StrokeSketch     
}

我们将修改实例的副本,这就是为什么我们首先创建一个副本。接下来,我们想要计算将草图缩放到最大高度和/或宽度为 255,同时尊重其宽高比所需的比例因子;将以下代码添加到您的simplify方法中,它正是这样做的:

let minPoint = copy.minPoint
let maxPoint = copy.maxPoint
let scale = CGPoint(x: maxPoint.x-minPoint.x, y:maxPoint.y-minPoint.y)

var width : CGFloat = 255.0
var height : CGFloat = 255.0

// adjust aspect ratio
if scale.x > scale.y{
    height *= scale.y/scale.x
} else{
    width *= scale.y/scale.x
} 

对于每个维度(宽度高度),我们已经计算了确保我们的草图被缩放到255维度的所需比例。现在我们需要将此应用于StrokeSketch类持有的每个笔画相关的每个点;当我们遍历每个点时,将草图对齐到左上角(x= 0y = 0)作为所需的预处理步骤也是有意义的。我们可以通过减去每个维度的最小值来实现这一点。将以下代码附加到您的simplify方法中来完成此操作:

for i in 0..<copy.strokes.count{
    copy.strokes[i].points = copy.strokes[i].points.map({ (pt) -> CGPoint in
        let x : CGFloat = CGFloat(Int(((pt.x - minPoint.x)/scale.x) * width))
        let y : CGFloat = CGFloat(Int(((pt.y - minPoint.y)/scale.y) * height))        
        return CGPoint(x:x, y:y)
    })
}   

我们的最后一步是使用 Ramer-Douglas-Peucker 算法简化曲线;为此,我们将使Stroke负责实现细节,并将任务委托给那里。将以下代码添加到您的StrokeSketch扩展中的simplify方法中:

copy.strokes = copy.strokes.map({ (stroke) -> Stroke in
    return stroke.simplify()
})

return copy

Ramer-Douglas-Peucker 算法递归遍历曲线,最初从第一个和最后一个点开始,找到离这个线段最远的点。如果这个点比给定的阈值近,那么可以丢弃当前标记为保留的任何点,但如果这个点比我们的阈值大,那么这个点必须保留。然后算法递归地调用自身,使用第一个点和最远点以及最远点和最后一个点。遍历整个曲线后,结果是只包含之前描述的标记为保留的点的简化曲线。这个过程在以下图中总结:

图片

让我们从扩展CGPoint结构以包括一个计算给定直线上的点距离的方法开始;将此代码添加到您的游乐场中:

public extension CGPoint{

    public static func getSquareSegmentDistance(p0:CGPoint,
                                                p1:CGPoint,
                                                p2:CGPoint) -> CGFloat{
        let x0 = p0.x, y0 = p0.y
        var x1 = p1.x, y1 = p1.y
        let x2 = p2.x, y2 = p2.y
        var dx = x2 - x1
        var dy = y2 - y1

        if dx != 0.0 && dy != 0.0{
            let numerator = (x0 - x1)
                * dx + (y0 - y1)
                * dy
            let denom = dx * dx + dy * dy
            let t = numerator / denom

            if t > 1.0{
                x1 = x2
                y1 = y2
            } else{
                x1 += dx * t
                y1 += dy * t
            }
        }

        dx = x0 - x1
        dy = y0 - y1

        return dx * dx + dy * dy
    }
} 

在这里,我们向CGPoint结构中添加了一个静态方法;它计算给定一条线(这是我们之前描述的比较阈值以简化线条的值)的点的垂直距离。接下来,我们将实现描述中的递归方法,它将用于通过测试和丢弃低于阈值的任何点来构建曲线。如前所述,我们将此功能封装在Stroke类本身中,所以我们首先通过创建扩展的占位符开始:

public extension Stroke{
}

现在,在扩展中添加递归方法:

func simplifyDPStep(points:[CGPoint], first:Int, last:Int,
                    tolerance:CGFloat, simplified: inout [CGPoint]){

    var maxSqDistance = tolerance
    var index = 0

    for i in first + 1..<last{
        let sqDist = CGPoint.getSquareSegmentDistance(
            p0: points[i],
            p1: points[first],
            p2: points[last])

        if sqDist > maxSqDistance {
            maxSqDistance = sqDist
            index = i
        }
    }

    if maxSqDistance > tolerance{
        if index - first > 1 {
            simplifyDPStep(points: points,
                           first: first,
                           last: index,
                           tolerance: tolerance,
                           simplified: &simplified)
        }

        simplified.append(points[index])

        if last - index > 1{
            simplifyDPStep(points: points,
                           first: index,
                           last: last,
                           tolerance: tolerance,
                           simplified: &simplified)
        }
    }
} 

这大部分应该很容易理解,因为它是对描述的算法的直接实现。我们首先找到最远的距离,它必须大于我们的阈值;否则,该点将被忽略。我们将该点添加到要保留的点数组中,然后将线段的每个端点传递给我们的递归方法,直到我们遍历整个曲线。

我们需要实现的最后一个方法是负责启动此过程的那个方法,我们也将将其封装在我们的Stroke扩展中;所以请继续并添加以下方法到你的扩展中:

public func simplify(epsilon:CGFloat=3.0) -> Stroke{

    var simplified: [CGPoint] = [self.points.first!]

    self.simplifyDPStep(points: self.points,
                        first: 0, last: self.points.count-1,
                        tolerance: epsilon * epsilon,
                        simplified: &simplified)

    simplified.append(self.points.last!)

    let copy = self.copy() as! Stroke
    copy.points = simplified

    return copy
}

simplify方法简单地(请原谅这个双关语)创建了一个包含简化曲线点的数组,添加第一个点,然后启动我们刚刚实现的递归方法。然后,当曲线被遍历后,它最终添加最后一个点,并在返回简化点的Stroke之前完成。

到目前为止,我们已经实现了将原始输入转换为简化形式所需的功能,正如在Quick, Draw!仓库中指定的那样。让我们通过比较我们的简化原始草图与现有简化草图来验证我们的工作。将以下代码添加到你的游乐场中:

  if let rJson = loadedJSON["small_raw_airplane"],
    let sJson = loadedJSON["small_simplified_airplane"]{

    if let rSketch = StrokeSketch.createFromJSON(json: rJson[2] as? [String:Any]),
        let sSketch = StrokeSketch.createFromJSON(json: sJson[2] as? [String:Any]){
        drawSketch(sketch: rSketch)
        drawSketch(sketch: sSketch)
        drawSketch(sketch: rSketch.simplify())
    }
}

如前所述,你可以点击右侧面板中每个drawSketch调用内的眼睛图标来预览每个草图。第一个是从原始数据集中的草图,第二个是从简化数据集中,第三个是使用我们的简化实现,使用原始数据集的样本。如果一切按计划进行,你应该会看到以下类似的内容:

图片

仔细观察,我们的简化版本看起来比简化数据集的样本更具侵略性,但我们可以通过调整阈值轻松地调整这一点。然而,从所有目的来看,目前这已经足够了。到目前为止,我们已经拥有了简化数据集所需的功能,将其转换成类似于训练数据集的形式。但在将数据输入模型之前,我们还有更多的预处理要做;现在就让我们开始,先快速讨论一下我们的模型期望的是什么。

我们的模型期望每个样本具有三个维度;点位置(x, y)和一个标志位,表示该点是否为其相关笔画的最后一个点。拥有这个标志位的原因是我们传递了一个固定长度的序列,大小为 75。也就是说,每个草图要么被截断以挤入这个序列,要么用前导零填充以填满它。使用标志位是一种添加上下文的方式,指示它是否是笔画的结束(记住我们的序列代表我们的草图,我们的草图由许多笔画组成)。

然后,像往常一样,我们将输入归一化到0.0 - 1.0的范围内,以避免在训练过程中由于权重过大而导致模型波动。最后的调整是将我们的绝对值转换为增量,这当你这么想的时候很有道理。第一个原因是,我们希望我们的模型对每个点的实际位置不变;也就是说,我们可以并排绘制相同的草图,理想情况下,我们希望这些被分类为同一类。在前一章中,我们通过使用在像素数据范围和位置上操作的 CNN 实现了这一点,就像我们现在所做的那样。使用增量而不是绝对值的第二个原因是,增量比绝对位置携带更多的有用信息,即方向。在实现这一点后,我们将准备好测试我们的模型,让我们开始吧;首先添加以下扩展和方法,它将负责这个预处理步骤:

extension StrokeSketch{

    public static func preprocess(_ sketch:StrokeSketch)
        -> MLMultiArray?{
        let arrayLen = NSNumber(value:75 * 3) 

        guard let array = try? MLMultiArray(shape: [arrayLen],
                                            dataType: .double)
            else{ return nil }

        let simplifiedSketch = sketch.simplify()

    }
}     

在这里,我们通过扩展将静态方法preprocess添加到StrokeSketch类中;在这个方法中,我们首先设置将传递给我们的模型的缓冲区。这个缓冲区的大小需要适合完整的序列,这可以通过简单地乘以序列长度(75)和维度数(3)来计算。然后我们调用StrokeSketch实例上的simplify以获得简化的草图,确保它与我们训练模型的数据非常相似。

接下来,我们将对每一笔的每个点进行迭代,对点进行归一化,并确定标志位的值(一个表示笔画的结束;否则为 0)。将以下代码添加到您的preprocess方法中:

let minPoint = simplifiedSketch.minPoint
let maxPoint = simplifiedSketch.maxPoint
let scale = CGPoint(x: maxPoint.x-minPoint.x,
                    y:maxPoint.y-minPoint.y)

var data = Array<Double>()

for i in 0..<simplifiedSketch.strokes.count{
    for j in 0..<simplifiedSketch.strokes[i].points.count{
        let point = simplifiedSketch.strokes[i].points[j]
        let x = (point.x-minPoint.x)/scale.x
        let y = (point.y-minPoint.y)/scale.y
        let z = j == simplifiedSketch.strokes[i].points.count-1
            ? 1 : 0

        data.append(Double(x))
        data.append(Double(y))
        data.append(Double(z))
    }

我们首先获取最小值和最大值,我们将使用这些值来归一化每个点(使用方程x^i−min(x)/max(x)−min(x),其中x[i]是一个单独的点,x代表该笔画内的所有点)。然后我们创建一个临时位置来存储数据,在迭代所有我们的点、归一化每个点并确定标志位的值,如之前所述之前。

现在,我们想要计算每个点的增量,并最终移除最后一个点,因为我们无法计算它的增量;将以下内容添加到您的preprocess方法中:

let dataStride : Int = 3
for i in stride(from: dataStride, to:data.count, by: dataStride){
    data[i - dataStride] = data[i] - data[i - dataStride] 
    data[i - (dataStride-1)] = data[i+1] - data[i - (dataStride-1)] 
    data[i - (dataStride-2)] = data[i+2] 
}

data.removeLast(3)

之前的代码应该是自解释的;唯一值得强调的显著点是,我们现在处理的是一个展平的数组,因此我们在遍历数据时需要使用3的步长。

需要添加的最后一段代码!我们需要确保我们的数组等于 75 个样本(即我们的序列长度,也就是长度为 225 的数组)。我们可以通过截断数组(如果太大)或填充它(如果太小)来实现这一点。我们可以在从我们的临时数组data复制数据到我们将传递给模型的缓冲区array时轻松完成此操作。在这里,我们首先计算起始索引,然后遍历整个序列,如果当前索引已经超过起始索引,则复制数据,否则用零填充。将以下片段添加到完成您的preprocess方法:

var dataIdx : Int = 0
let startAddingIdx = max(array.count-data.count, 0)

for i in 0..<array.count{
    if i >= startAddingIdx{
        array[i] = NSNumber(value:data[dataIdx])
        dataIdx = dataIdx + 1
    } else{
        array[i] = NSNumber(value:0)
    }
}

return array

我们的preprocess方法现在已经完成,我们准备测试我们的模型。我们将首先实例化我们的模型(包含在游乐场中),然后输入我们之前使用过的飞机样本,在测试其他类别之前进行测试。将以下代码添加到您的游乐场中:

let model = quickdraw()

if let json = loadedJSON["small_raw_airplane"]{
    if let sketch = StrokeSketch.createFromJSON(json: json[0] as? [String:Any]){
        if let x = StrokeSketch.preprocess(sketch){
            if let predictions = try? model.prediction(input:quickdrawInput(strokeSeq:x)){
                print("Class label \(predictions.classLabel)")
                print("Class label probability/confidence \(predictions.classLabelProbs["airplane"] ?? 0)")
            }
        }
    }
}

如果一切顺利,您的游乐场将在控制台输出以下内容:

它已经预测了飞机类别,并且相当自信地预测了(概率约为 77%)。在我们将代码迁移到我们的应用程序之前,让我们测试一些其他类别;我们将首先实现一个处理所有前期工作的方法,然后传递一些样本以执行推理。将以下方法添加到您的游乐场中,它将负责在将样本传递给模型进行预测之前获取和预处理样本,然后以包含最可能类别和概率的格式化字符串返回结果:

func makePrediction(key:String, index:Int) -> String{
    if let json = loadedJSON[key]{
        if let sketch = StrokeSketch.createFromJSON(
            json: json[index] as? [String:Any]){
            if let x = StrokeSketch.preprocess(sketch){
                if let predictions = try? model.prediction(input:quickdrawInput(strokeSeq:x)){
                    return "\(predictions.classLabel) \(predictions.classLabelProbs[predictions.classLabel] ?? 0)"
                }
            }
        }
    }

    return "None"
}

现在大部分工作已经完成,我们只剩下令人紧张的测试任务,即测试我们的预处理实现和模型是否足够能够预测我们传递的样本。让我们对每个类别进行测试;将以下代码添加到您的游乐场中:

print(makePrediction(key: "small_raw_airplane", index: 0))
print(makePrediction(key: "small_raw_alarm_clock", index: 1))
print(makePrediction(key: "small_raw_bee", index: 2))
print(makePrediction(key: "small_raw_sailboat", index: 3))
print(makePrediction(key: "small_raw_train", index: 4))
print(makePrediction(key: "small_raw_truck", index: 5))
print(makePrediction(key: "small_simplified_airplane", index: 0))

这些输出的截图如下所示:

不错!我们成功预测了所有类别,尽管卡车只给出了 41%的概率。而且有趣的是,我们的简化飞机样本被赋予了更高的概率(84%),而其来自原始数据集的对应样本的概率为 77%。

出于好奇,让我们看看我们要求模型预测的卡车样本:

尽管对艺术家表示敬意,但我很难从这个草图预测出一辆卡车,所以完全归功于我们的模型。

我们现在已经将我们的模型暴露于各种类别中,并且我们能够正确预测每一个类别,这意味着我们的预处理代码已经满意地实现了。我们现在准备将我们的代码迁移到我们的应用程序中,但在这样做之前,还有一个最后的实验。让我们思考一下我们的模型是如何被训练的,以及它将在应用程序的上下文中如何被使用。模型是在用户绘制草图时产生的序列(即笔触)上训练的。这正是用户将如何与我们的应用程序互动;他们将通过一系列(或序列)的笔触来绘制某物;每次他们完成一笔,我们都想尝试预测他们试图绘制的内容。让我们通过逐步构建一个样本笔触,并在每次添加后续笔触后进行预测,来模拟这种行为,以评估模型在更真实的环境中的表现。将以下代码添加到您的游乐场中:

if let json = loadedJSON["small_raw_bee"]{
    if let sketch = StrokeSketch.createFromJSON(json: json[2] as? [String:Any]){
        let strokeCount = sketch.strokes.count
        print("\(sketch.label ?? "" ) sketch has \(strokeCount) strokes")

        for i in (0..<strokeCount-1).reversed(){
            let copyOfSketch = sketch.copy() as! StrokeSketch
            copyOfSketch.strokes.removeLast(i)
            if let x = StrokeSketch.preprocess(copyOfSketch){
                if let predictions = try? model.prediction(input:quickdrawInput(strokeSeq:x)){
                    let label = predictions.classLabel
                    let probability = String(format: "%.2f", predictions.classLabelProbs[predictions.classLabel] ?? 0)

                    print("Guessing \(label) with probability of \(probability) using \(copyOfSketch.strokes.count) strokes")
                }
            }
        }
    }
} 

这里没有引入任何新内容;我们只是在加载一个草图,像之前讨论的那样,逐笔慢慢构建,并将部分草图传递给我们的模型进行推理。以下是结果,以及它们对应的草图,以提供更多上下文:

所有的合理预测,可能揭示了许多人如何绘制冰球嘴巴蜜蜂。现在,对我们实现的结果感到满意后,让我们继续到下一部分,我们将迁移这段代码,并看看我们如何在运行时获取和编译模型。

将所有这些整合在一起

如果您还没有做,请从配套的仓库中拉取最新的代码:github.com/packtpublishing/machine-learning-with-core-ml。下载后,导航到目录Chapter8/Start/QuickDrawRNN并打开项目QuickDrawRNN.xcodeproj。一旦加载,您将看到一个您应该很熟悉的项目,因为它几乎是我们之前章节中构建的项目的一个复制品。因此,我这里不会详细说明,但您可以自由地通过快速浏览上一章来刷新您的记忆。

我更想花些时间强调我认为设计和构建人与机器学习系统之间界面最重要的一个方面。让我们从这里开始,然后继续将我们的代码从游乐场项目迁移过来。

我认为 Quick, Draw!是一个很好的例子,它突出了任何机器学习系统界面设计师的主要责任。让它脱颖而出的不是使它对缩放和转换不变的巧妙预处理。也不是它能够有效学习复杂序列的复杂架构,而是用来捕获训练数据的机制。我们在创建智能系统时面临的一个主要障碍是获取(足够的)干净和标记的数据,我们可以用这些数据来训练我们的模型。"Quick, Draw!"通过,我假设,故意成为一个通过引人入胜的游戏界面来捕获和标记数据的工具——足够吸引人,足以激励大量用户生成足够数量的标记数据。尽管一些草图可能值得怀疑,但草图的数量本身稀释了这些异常值。

问题的关键在于机器学习系统不是静态的,我们应该设计机会让用户在适用的情况下纠正系统,并捕获新的数据,无论是隐式地(在用户同意的情况下)还是显式地。在用户和系统之间允许一定程度的透明度,并允许用户在模型错误时进行纠正,这不仅为我们提供了改进模型的新数据,而且——同样重要的是——帮助用户构建一个有用的系统心理模型。因此,它围绕我们系统的可用性建立了一些直觉,这有助于他们正确使用它。

在我们的示例项目中,我们可以轻松地展示预测并提供用户纠正模型的方法。但为了确保本章简洁,我们只看看我们如何获得一个更新的模型,通常(记住 Core ML 适合推理而不是训练),我们会在设备上对其进行训练。在这种情况下,你会上传数据到中央服务器,并在可用时获取更新的模型。如前所述,这里我们将探讨后者:我们如何获得更新的模型。让我们看看。

之前我提到过,并暗示过,你通常会上传新的训练数据并在设备上训练你的模型。当然,这并不是唯一的选择,使用用户的个人数据在设备上调整模型进行训练也是合理的。在本地训练的优势是隐私和低延迟,但它的缺点是会削弱集体智慧,即从集体行为中改进模型。谷歌提出了一种巧妙的解决方案,确保了隐私并允许协作。在一篇题为“联邦学习:无需集中训练数据的协同机器学习”的帖子中,他们描述了一种在设备上使用个性化数据本地训练的技术,然后将调整后的模型仅上传到服务器,服务器会平均来自众人的权重,在更新中央模型之前。我鼓励你阅读这篇帖子,research.googleblog.com/2017/04/federated-learning-collaborative.html

正如你在使用 Core ML 时可能已经预料到的,大部分工作不是与框架接口,而是它之前和之后的活动。编译和实例化一个模型只需两行代码,如下所示:

let compiledUrl = try MLModel.compileModel(at: modelUrl)
let model = try MLModel(contentsOf: compiledUrl)

其中modelUrl是本地存储的.mlmodel文件的 URL。将其传递给compileModel将返回.mlmodelc文件。这可以用来初始化MLModel的一个实例,它提供了与你的应用程序捆绑的模型相同的功能。

下载和编译是耗时的。因此,你不仅想在主线程之外做这件事,还想避免执行不必要的任务;也就是说,本地缓存,并在需要时才更新。现在让我们实现这个功能;点击左侧面板上的QueryFacade.swift文件,将其带到主编辑窗口的焦点。然后向QueryFacade类添加一个新的扩展,我们将在这里添加负责下载和编译模型的代码。

我们的首要任务是测试我们是否需要下载模型。我们通过简单地检查我们是否有模型以及我们的模型是否被认为是最近的来做到这一点。我们将使用NSUserDefaults来跟踪编译模型的存储位置以及最后一次更新的时间戳。将以下代码添加到负责检查我们是否需要下载模型的QueryFacade扩展中:

private var SyncTimestampKey : String{
    get{
        return "model_sync_timestamp"
    }
}

private var ModelUrlKey : String{
    get{
        return "model_url"
    }
}

private var isModelStale : Bool{
    get{
        if let modelUrl = UserDefaults.standard.string(
            forKey: self.ModelUrlKey){
            if !FileManager.default.fileExists(atPath: modelUrl){
                return true
            }
        }

        let daysToUpdate : Int = 10
        let lastUpdated = Date(timestamp:UserDefaults.standard.integer(forKey: SyncTimestampKey))

        guard let numberOfDaysSinceUpdate = NSCalendar.current.dateComponents([.day], from: lastUpdated, to: Date()).day else{
            fatalError("Failed to calculated elapsed days since the model was updated")
        }
        return numberOfDaysSinceUpdate >= daysToUpdate
    }
}

如前所述,我们首先检查模型是否存在,如果存在,则测试自模型上次更新以来过去了多少天,并将这个测试与一些任意阈值进行比较,我们认为模型已经过时。

我们接下来要实现的方法将负责下载模型(.mlmodel 文件);这对大多数 iOS 开发者来说应该很熟悉,唯一值得注意的是代码中使用了一个信号量来使任务同步,因为调用此方法的方法将在主线程上运行。将以下代码附加到你的 QueryFacade 扩展中:

private func downloadModel() -> URL?{
    guard let modelUrl = URL(
        string:"https://github.com/joshnewnham/MachineLearningWithCoreML/blob/master/CoreMLModels/Chapter8/quickdraw.mlmodel?raw=true") else{
            fatalError("Invalid URL")
    }

    var tempUrl : URL?

    let sessionConfig = URLSessionConfiguration.default
    let session = URLSession(configuration: sessionConfig)

    let request = URLRequest(url:modelUrl)

 let semaphore = DispatchSemaphore(value: 0)

    let task = session.downloadTask(with: request) { (tempLocalUrl, response, error) in
        if let tempLocalUrl = tempLocalUrl, error == nil {
            tempUrl = tempLocalUrl
        } else {
            fatalError("Error downloading model \(String(describing: error?.localizedDescription))")
        }

 semaphore.signal()
    }
    task.resume()
 _ = semaphore.wait(timeout: .distantFuture)

    return tempUrl
}

我已经突出显示了与使此任务同步相关的语句;本质上,调用 semaphore.wait(timeout: .distantFuture) 将保持当前线程,直到通过 semaphore.signal() 信号通知它继续。如果成功,此方法将返回已下载文件的本地 URL。

我们最后一个任务是把这些所有东西结合起来;我们接下来要实现的方法将在 QueryFacade 实例化时被调用(我们将在之后添加)。它将负责检查是否需要下载模型,如果需要,则继续下载和编译,并实例化一个实例变量 model,我们可以使用它来进行推理。将最后的代码片段附加到你的 QueryFacade 扩展中:

private func syncModel(){
    queryQueue.async {

        if self.isModelStale{
            guard let tempModelUrl = self.downloadModel() else{
                return
            }

            guard let compiledUrl = try? MLModel.compileModel(
                at: tempModelUrl) else{
                fatalError("Failed to compile model")
            }

            let appSupportDirectory = try! FileManager.default.url(
                for: .applicationSupportDirectory,
                in: .userDomainMask,
                appropriateFor: compiledUrl,
                create: true)

            let permanentUrl = appSupportDirectory.appendingPathComponent(
                compiledUrl.lastPathComponent)
            do {
                if FileManager.default.fileExists(
                    atPath: permanentUrl.absoluteString) {
                    _ = try FileManager.default.replaceItemAt(
                        permanentUrl,
                        withItemAt: compiledUrl)
                } else {
                    try FileManager.default.copyItem(
                        at: compiledUrl,
                        to: permanentUrl)
                }
            } catch {
                fatalError("Error during copy: \(error.localizedDescription)")
            }

            UserDefaults.standard.set(Date.timestamp,
                                      forKey: self.SyncTimestampKey)
            UserDefaults.standard.set(permanentUrl.absoluteString,
                                      forKey:self.ModelUrlKey)
        }

        guard let modelUrl = URL(
            string:UserDefaults.standard.string(forKey: self.ModelUrlKey) ?? "")
            else{
            fatalError("Invalid model Url")
        }

        self.model = try? MLModel(contentsOf: modelUrl)
    }
}

我们首先检查是否需要下载模型,如果是,则继续下载和编译它:

guard let tempModelUrl = self.downloadModel() else{
    return
}

guard let compiledUrl = try? MLModel.compileModel(
    at: tempModelUrl) else{
    fatalError("Failed to compile model")
}

为了避免不必要地执行此步骤,我们随后将详细信息永久保存到某个地方,设置模型的位置和当前时间戳到 NSUserDefaults

let appSupportDirectory = try! FileManager.default.url(
    for: .applicationSupportDirectory,
    in: .userDomainMask,
    appropriateFor: compiledUrl,
    create: true)

let permanentUrl = appSupportDirectory.appendingPathComponent(
    compiledUrl.lastPathComponent)
do {
    if FileManager.default.fileExists(
        atPath: permanentUrl.absoluteString) {
        _ = try FileManager.default.replaceItemAt(
            permanentUrl,
            withItemAt: compiledUrl)
    } else {
        try FileManager.default.copyItem(
            at: compiledUrl,
            to: permanentUrl)
    }
} catch {
    fatalError("Error during copy: \(error.localizedDescription)")
}

UserDefaults.standard.set(Date.timestamp,
                          forKey: self.SyncTimestampKey)
UserDefaults.standard.set(permanentUrl.absoluteString,
                          forKey:self.ModelUrlKey)

最后,我们将创建并分配一个 MLModel 实例给我们的实例变量 model。最后一个任务是更新 QueryFacade 类的构造函数,以便在实例化时启动此过程;使用以下代码更新 QueryFacadeinit 方法:

init() {
    syncModel()
}

在这个阶段,我们的模型已经准备好进行推理;我们的下一个任务是迁移我们在游乐场中开发的代码到我们的项目中,并将它们全部连接起来。鉴于我们已经在本章的第一部分讨论了细节,我将在这里跳过具体内容,而是为了方便和完整性包括添加的内容。

让我们从对 CGPoint 结构的扩展开始;在你的项目中添加一个新的 Swift 文件,命名为 CGPointRNNExtension.swift,并在其中添加以下代码:

extension CGPoint{
    public static func getSquareSegmentDistance(
        p0:CGPoint,
        p1:CGPoint,
        p2:CGPoint) -> CGFloat{
        let x0 = p0.x, y0 = p0.y
        var x1 = p1.x, y1 = p1.y
        let x2 = p2.x, y2 = p2.y
        var dx = x2 - x1
        var dy = y2 - y1

        if dx != 0.0 && dy != 0.0{
            let numerator = (x0 - x1) * dx + (y0 - y1) * dy
            let denom = dx * dx + dy * dy
            let t = numerator / denom

            if t > 1.0{
                x1 = x2
                y1 = y2
            } else{
                x1 += dx * t
                y1 += dy * t
            }
        }

        dx = x0 - x1
        dy = y0 - y1

        return dx * dx + dy * dy
    }
}

接下来,在你的项目中添加另一个新的 Swift 文件,命名为 StrokeRNNExtension.swift,并添加以下代码:

extension Stroke{

    public func simplify(epsilon:CGFloat=3.0) -> Stroke{

        var simplified: [CGPoint] = [self.points.first!]

        self.simplifyDPStep(points: self.points,
                            first: 0, last: self.points.count-1,
                            tolerance: epsilon * epsilon,
                            simplified: &simplified)

        simplified.append(self.points.last!)

        let copy = self.copy() as! Stroke
        copy.points = simplified

        return copy
    }

    func simplifyDPStep(points:[CGPoint],
                        first:Int,
                        last:Int,
                        tolerance:CGFloat,
                        simplified: inout [CGPoint]){

        var maxSqDistance = tolerance
        var index = 0

        for i in first + 1..<last{
            let sqDist = CGPoint.getSquareSegmentDistance(
                p0: points[i],
                p1: points[first],
                p2: points[last])

            if sqDist > maxSqDistance {
                maxSqDistance = sqDist
                index = i
            }
        }

        if maxSqDistance > tolerance{
            if index - first > 1 {
                simplifyDPStep(points: points,
                               first: first,
                               last: index,
                               tolerance: tolerance,
                               simplified: &simplified)
            }

            simplified.append(points[index])

            if last - index > 1{
                simplifyDPStep(points: points,
                               first: index,
                               last: last,
                               tolerance: tolerance,
                               simplified: &simplified)
            }
        }
    }
}

最后,我们将添加几个在游乐场中实现的方法到我们的 StrokeSketch 类中,以处理所需的预处理;首先添加一个新的 .swift 文件,命名为 StrokeSketchExtension.swift,并按照以下方式定义扩展:

import UIKit
import CoreML

extension StrokeSketch{

}

接下来,我们将复制并粘贴 simplify 方法,我们将在游乐场中按如下方式实现它:

public func simplify() -> StrokeSketch{
    let copy = self.copy() as! StrokeSketch
    copy.scale = 1.0

    let minPoint = copy.minPoint
    let maxPoint = copy.maxPoint
    let scale = CGPoint(x: maxPoint.x-minPoint.x,
                        y:maxPoint.y-minPoint.y)

    var width : CGFloat = 255.0
    var height : CGFloat = 255.0

    if scale.x > scale.y{
        height *= scale.y/scale.x
    } else{
        width *= scale.y/scale.x
    }

    // for each point, subtract the min and divide by the max
    for i in 0..<copy.strokes.count{
        copy.strokes[i].points = copy.strokes[i].points.map({
            (pt) -> CGPoint in
            let x : CGFloat = CGFloat(
                Int(((pt.x - minPoint.x)/scale.x) * width)
            )
            let y : CGFloat = CGFloat(
                Int(((pt.y - minPoint.y)/scale.y) * height)
            )

            return CGPoint(x:x, y:y)
        })
    }

    copy.strokes = copy.strokes.map({ (stroke) -> Stroke in
        return stroke.simplify()
    })

    return copy
}

作为提醒,此方法负责对一系列笔迹进行预处理,如前所述。接下来,我们在 StrokeSketch 扩展中添加我们的静态方法 preprocess,它接受一个 StrokeSketch 实例,并负责将其简化状态放入我们可以传递给模型进行推理的数据结构中:

public static func preprocess(_ sketch:StrokeSketch)
    -> MLMultiArray?{
    let arrayLen = NSNumber(value:75 * 3) 

    let simplifiedSketch = sketch.simplify()

    guard let array = try? MLMultiArray(shape: [arrayLen],
                                        dataType: .double)
        else{ return nil }

    let minPoint = simplifiedSketch.minPoint
    let maxPoint = simplifiedSketch.maxPoint
    let scale = CGPoint(x: maxPoint.x-minPoint.x,
                        y:maxPoint.y-minPoint.y)

    var data = Array<Double>()
    for i in 0..<simplifiedSketch.strokes.count{
        for j in 0..<simplifiedSketch.strokes[i].points.count{
            let point = simplifiedSketch.strokes[i].points[j]
            let x = (point.x-minPoint.x)/scale.x
            let y = (point.y-minPoint.y)/scale.y
            let z = j == simplifiedSketch.strokes[i].points.count-1 ?
                1 : 0

            data.append(Double(x))
            data.append(Double(y))
            data.append(Double(z))
        }
    }

    let dataStride : Int = 3
    for i in stride(from: dataStride, to:data.count, by: dataStride){
        data[i - dataStride] = data[i] - data[i - dataStride] 
        data[i - (dataStride-1)] = data[i+1] - data[i - (dataStride-1)] 
        data[i - (dataStride-2)] = data[i+2] // EOS
    }

    data.removeLast(3)

    var dataIdx : Int = 0
    let startAddingIdx = max(array.count-data.count, 0)

    for i in 0..<array.count{
        if i >= startAddingIdx{
            array[i] = NSNumber(value:data[dataIdx])
            dataIdx = dataIdx + 1
        } else{
            array[i] = NSNumber(value:0)
        }
    }

    return array
}

如果有任何内容看起来不熟悉,我鼓励你重新阅读前面的章节,在那里我们深入探讨了这些方法的作用细节(以及原因)。

现在我们有了模型和预处理输入的功能;我们的最后一项任务是把这些都整合起来。回到 QueryFacade 类,找到 classifySketch 方法。作为一个提醒,这个方法是通过 queryCurrentSketch 调用的,而 queryCurrentSketch 又会在用户完成一笔画时被触发。该方法预期返回一个包含类别和概率对的字典,然后用于搜索和下载最可能类别的相关绘画。在这个阶段,这只是一个使用我们之前完成的工作的问题,只有一个小的注意事项。如果你还记得前面的章节,当我们把模型导入到项目中时,Xcode 会方便地为我们模型的输入和输出生成一个强类型包装器。在运行时下载和导入的一个缺点是我们放弃了这些生成的包装器,不得不手动完成。

从后往前,在做出预测之后,我们期望返回一个 MLFeatureProvider 的实例,它有一个名为 featureValue 的方法。这个方法为给定的输出键(classLabelProbs)返回一个 MLFeatureValue 的实例。返回的 MLFeatureValue 实例暴露了模型在推理期间设置的属性;这里我们感兴趣的是类型为 [String:Double]dictionaryValue 属性(类别及其相关的概率)。

显然,为了获得这个输出,我们需要在我们的模型上调用 predict 方法,该模型期望一个符合我们之前提到的 MLFeatureProvider 协议的实例。鉴于在大多数情况下,你将能够访问和了解模型,生成这个包装器最简单的方法是导入模型并提取生成的输入,这正是我们将要做的。

在配套的仓库 github.com/packtpublishing/machine-learning-with-core-ml 中找到文件 CoreMLModels/Chapter8/quickdraw.mlmodel,并将其拖入你的项目中,就像我们在前面的章节中所做的那样。一旦导入,从左侧面板中选择它,然后在模型类部分点击箭头按钮,如图所示:

图片

这将打开生成的类;找到类 quickdrawInput 并将其复制粘贴到你的 QueryFacade.swift 文件中,确保它位于 QueryFacade 类(或扩展)之外。因为我们只关心 strokeSeq 输入,我们可以移除所有其他变量;清理后,你将得到如下所示的内容:

class quickdrawInput : MLFeatureProvider {

    var strokeSeq: MLMultiArray

    var featureNames: Set<String> {
        get {
            return ["strokeSeq"]
        }
    }

    func featureValue(for featureName: String) -> MLFeatureValue? {
        if (featureName == "strokeSeq") {
            return MLFeatureValue(multiArray: strokeSeq)
        }
        return nil
    }

    init(strokeSeq: MLMultiArray) {
        self.strokeSeq = strokeSeq
    }
}

我们终于准备好进行推理了;回到 QueryFacade 类中的 classifySketch 方法,并添加以下代码:

if let strokeSketch = sketch as? StrokeSketch, let
    x = StrokeSketch.preprocess(strokeSketch){

    if let modelOutput = try! model?.prediction(from:quickdrawInput(strokeSeq:x)){
        if let classPredictions = modelOutput.featureValue(
            for: "classLabelProbs")?.dictionaryValue as? [String:Double]{

            let sortedClassPredictions = classPredictions.sorted(
                by: { (kvp1, kvp2) -> Bool in
                kvp1.value > kvp2.value
            })

            return sortedClassPredictions
        }
    }
}

return nil

毫无疑问,其中大部分对你来说都很熟悉;我们首先通过本章开头实现的preprocess方法提取特征。一旦我们获得了这些特征,我们就将它们包装在quickdrawInput的一个实例中,然后再将它们传递给模型的prediction方法进行推理。如果成功,我们将返回输出,然后我们继续提取适当输出,如前所述。最后,我们在返回给调用者之前对结果进行排序。

完成这些后,你现在处于一个很好的测试位置。构建并部署到模拟器或设备上,如果一切按计划进行,你应该能够测试你模式(或绘制,取决于你如何看待它)的准确性:

图片

让我们通过回顾我们已经涵盖的内容来结束本章。

摘要

在本章中,我们回顾了一个先前的问题(草图识别),但使用了不同的数据集和不同的方法。之前,我们使用 CNN 来解决这个问题,但在这章中,我们识别了数据收集的细微差别,这反过来又使我们能够采用不同的方法使用 RNN。像往常一样,大部分努力都花在为模型准备数据上。这样做突出了我们可以用来使数据对缩放和转换不变的技术,以及减少输入细节(通过简化)以帮助我们的模型更容易找到模式的有用性。

最后,我们强调了为机器学习系统设计界面的重要方面,即添加一层透明度和控制层,以帮助用户构建一个有用的心理模型,并通过显式的用户反馈(如更正)来改进模型。

让我们继续我们的机器学习应用之旅,深入下一章,我们将探讨我们的最终视觉应用:图像分割。

第九章:使用 CNN 进行对象分割

在本书的各个章节中,我们看到了各种机器学习模型,每个模型都逐渐增强了它们的感知能力。我的意思是,我们首先接触到的模型能够对图像中存在的单个对象进行分类。然后是能够不仅对多个对象进行分类,还能对它们相应的边界框进行分类的模型。在本章中,我们通过引入语义分割继续这一进程,也就是说,能够将每个像素分配到特定的类别,如下面的图所示:

图片

来源:http://cocodataset.org/#explore

这使得对场景的理解更加深入,因此,为更易于理解的界面和服务提供了机会。但这并不是本章的重点。在本章中,我们将使用语义分割来创建一个图像效果应用程序,以此展示不完美的预测。我们将利用这一点来激发对设计构建机器学习(或人工智能)界面最重要的一个方面——处理模型的不确定或错误结果的讨论。

到本章结束时,你将:

  • 对语义分割的理解

  • 建立了对如何实现(学习)的直观理解

  • 通过构建动作镜头照片效果应用程序,学习了如何以新颖的方式应用于现实生活应用

  • 对处理机器学习模型产生的概率结果有了欣赏和认识

让我们先更好地理解语义分割是什么,以及它是如何实现的直观理解。

像素分类

如我们之前所讨论的,执行语义分割的模型所期望的输出是一个图像,其中每个像素都被分配了其最可能的类别标签(甚至是一个特定类别的实例)。在整个本书中,我们也看到了深度神经网络层学习在检测到满足特定特征的相应输入时被激活的特征。我们可以使用称为类别激活图CAMs)的技术来可视化这些激活。输出产生了一个输入图像上类别激活的热图;热图由与特定类别相关的分数矩阵组成,本质上为我们提供了一个空间图,显示了输入区域如何强烈地激活指定的类别。以下图显示了为猫类别的一个 CAM 可视化输出。在这里,你可以看到热图描绘了模型认为对这个类别重要的特征(以及区域):

图片

前面的图是使用 R. Selvaraju 在论文《Grad-CAM:通过基于梯度的定位从深度网络中获取视觉解释》中描述的实现生成的。方法是取卷积层的输出特征图,并按类别的梯度为该特征图中的每个通道加权。有关其工作原理的更多详细信息,请参阅原始论文:arxiv.org/abs/1610.02391

早期对语义分割的尝试使用了略微修改的分类模型,如 VGG 和 Alexnet,但它们只能产生粗略的近似。这可以从前面的图中看出,这主要是因为网络使用了重复的池化层,这导致了空间信息的丢失。

U-Net 是解决这一问题的架构之一;它由一个编码器和一个解码器组成,并在两者之间添加了捷径以保留空间信息。由 O. Ronneberger、P. Fischer 和 T. Brox 于 2015 年发布,用于生物医学图像分割,由于其有效性和性能,它已成为分割的首选架构之一。以下图显示了本章我们将使用的修改后的 U-Net:

图片

U-Net 是用于语义分割的众多架构之一。Sasank Chilamkurthy 的文章《2017 年深度学习语义分割指南》提供了一个关于最流行架构的精彩概述和比较,可在blog.qure.ai/notes/semantic-segmentation-deep-learning-review找到。有关 U-Net 的更多详细信息,请参阅前面提到的原始论文。它可在arxiv.org/pdf/1505.04597.pdf找到。

在前面的图中左侧,我们有本章项目使用的完整网络,而在右侧,我们有网络编码器和解码器部分使用的块提取。作为提醒,本书的重点是应用机器学习,而不是模型本身的细节。因此,我们不会深入探讨细节,但有一些有趣且有用的事情值得指出。

第一个是网络的一般结构;它由一个编码器和一个解码器组成。编码器的角色是捕捉上下文。解码器的任务是使用这个上下文和来自相应快捷方式的特征,将其理解投影到像素空间,以获得密集和精确的分类。通常的做法是使用训练好的分类模型(如 VGG16)的架构和权重来启动编码器。这不仅加快了训练速度,而且很可能会提高性能,因为它带来了对训练过的图像的深度(有意为之)理解,这通常来自更大的数据集。

另一个值得强调的点是在编码器和解码器之间的快捷方式。如前所述,它们用于在最大池化下采样之前保留从每个编码块输出的卷积层的空间信息。这些信息用于帮助模型进行精确定位。

这是这本书中第一次看到上采样层。正如其名所示,这是一种将你的图像(或特征图)上采样到更高分辨率的技巧。其中一种最简单的方法是使用我们用于图像上采样的相同技术,即,将输入缩放到期望的大小,并使用插值方法(如双线性插值)计算每个点的值。

最后,我想引起你的注意,即模型的输入和输出。模型期望输入一个 448 x 448 的彩色图像,并输出一个 448 x 448 x 1(单通道)矩阵。如果你检查架构,你会注意到最后一层是 sigmoid 激活,其中 sigmoid 函数通常用于二元分类,这正是我们在这里所做的事情。通常,你会对语义分割任务执行多类分类,在这种情况下,你会将 sigmoid 激活替换为 softmax 激活。在介绍语义分割时常用到的例子是自动驾驶汽车的场景理解。以下是从剑桥大学基于运动的分割和识别数据集中标记的场景示例,其中每种颜色代表一个不同的类别:

来源:http://mi.eng.cam.ac.uk/research/projects/VideoRec/CamVid/

但在这个例子中,一个二元分类器就足够了,这一点在我们深入了解项目细节时会变得明显。然而,我想在这里强调它,因为架构只需通过将最后一层与 softmax 激活交换并更改损失函数,就可以扩展到多类分类。

因此,你已经看到了我们将在这章中使用的架构。现在让我们看看我们将如何使用它以及用于训练模型的数据。

驱动所需效果的数据——动作镜头

现在是介绍本章中想要创建的摄影效果的好时机。据我所知,这种效果被称为动作快照。它本质上是一张静态照片,展示了某人(或某物)在运动中,可能最好用一张图片来展示——就像这里展示的这张一样:

图片

如前所述,本章中使用的模型执行的是二元(或单类别)分类。使用二元分类器而不是多类别分类器的这种简化,是由仅从背景中分割人物的预期用途所驱动的。与任何软件项目一样,您应该努力在可能的情况下保持简单。

为了提取人物,我们需要一个模型来学习如何识别人物及其相关的像素。为此,我们需要一个包含人物图像及其相应像素标记图像的数据集——而且数量要很多。与用于分类的数据集相比,用于对象分割的数据集并不常见,也不那么庞大。考虑到标记此类数据集所需的额外努力,这是可以理解的。一些常见的对象分割数据集,以及在本章中考虑的数据集包括:

  • PASCAL VOC:一个包含 20 个类别、9,993 个标记图像的数据集。您可以在host.robots.ox.ac.uk/pascal/VOC/voc2012/index.html找到该数据集。

  • 来自马萨诸塞大学阿默斯特分校的 Labeled Faces in the Wild (LFW):一个包含 2,927 个面部图像的数据集。每个图像都有头发、皮肤和背景的标记(三个类别)。您可以在vis-www.cs.umass.edu/lfw/part_labels/找到该数据集。

  • Common Objects in Context (COCO)数据集:一个涵盖所有与计算机视觉相关内容的流行数据集,包括分割。其分割数据集包含大约 80 个类别、200,000 个标记图像。这是我们在本节中将使用并简要探讨的数据集。您可以在cocodataset.org/#home找到该数据集。

  • 虽然本项目没有考虑,但值得了解的是剑桥大学的Cambridge-driving Labeled Video DatabaseCamVid)。从其名称可以看出,该数据集由汽车摄像头的视频流帧组成——对于任何想要训练自己的自动驾驶汽车的人来说都是理想的。您可以在mi.eng.cam.ac.uk/research/projects/VideoRec/CamVid/找到该数据集。

在这里列出数据集可能是多余的,但语义分割是一个如此激动人心的机会,具有巨大的潜力,我希望在这里列出这些数据集能鼓励您探索和实验其新的应用。

幸运的是,COCO 的 13+ GB 数据集包含许多标记的人物图像,并有一个方便的 API 来轻松查找相关图像。对于本章,使用了 COCO 的 API 来查找所有包含人物的图像。然后,进一步过滤这些图像,只保留那些包含一个或两个人物且其面积占图像 20%到 70%的图像,丢弃那些人物太小或太大的图像。对于这些图像中的每一个,都获取了每个人的轮廓,然后用于创建二值掩码,这成为了我们训练的标签。以下图示了单个图像的此过程:

图片

来源:COCO 数据集(http://cocodataset.org

下面是一些动作图像的例子,其中模型能够足够地分割出图像中的人物:

图片

最后,这里有一些,许多中的几个,动作图像的例子,其中模型不太成功:

图片

我们已经涵盖了模型和训练数据,并检查了模型的输出。现在是时候将我们的注意力转向本章的应用程序了,我们将在下一节开始着手处理。

构建照片效果应用程序

在本节中,我们将简要地查看应用程序,并突出一些有趣的代码片段,省略大部分内容,因为它们已经在之前的章节中讨论过了。如引言中所述,这个例子是为了提供一个案例研究,以便在后面的章节中讨论构建智能界面和服务时可以使用的广泛策略。

如果您还没有做,请从随附的仓库github.com/packtpublishing/machine-learning-with-core-ml下载最新的代码。下载完成后,导航到Chapter9/Start/目录并打开项目ActionShot.xcodeproj

如前所述,本章的示例是一个照片效果应用。在其中,用户可以拍摄一个动作快照,应用会从帧中提取每个人,并将他们合成到最终帧中,如图所示:

图片

该应用由两个视图控制器组成;一个负责捕获帧,另一个负责展示合成图像。处理工作再次委托给了ImageProcessor类,这也是我们将从该角度审查这个项目的视角。

ImageProcessor既是接收器也是处理器;通过接收器我指的是它是一个从CameraViewController接收捕获的帧并将其保存在内存中以供处理的类。让我们看看代码的样子;从左侧面板选择ImageProcessor.swift将源代码聚焦。让我们看看有什么存在;最初特别关注处理接收到的帧的属性和方法,然后继续到它们的处理。

在文件顶部,你会注意到已经声明了一个协议,该协议由EffectViewController实现;它用于广播任务的进度:

protocol ImageProcessorDelegate : class{

    func onImageProcessorFinishedProcessingFrame(
        status:Int, processedFrames:Int, framesRemaining:Int)

    func onImageProcessorFinishedComposition(
        status:Int, image:CIImage?)
}

第一个回调onImageProcessorFinishedProcessingFrame用于通知代理帧逐帧处理的进度,而另一个回调onImageProcessorFinishedComposition则用于在最终图像创建完成后通知代理。这些离散的回调故意分开,因为处理已经被分解为分割和合成。分割负责使用我们的模型分割每一帧,而合成则负责使用处理过的(分割的)帧生成最终图像。这种结构也在类的布局中得到了体现,类被分解为四个部分,本节我们将遵循的流程。

第一部分声明了所有变量。第二部分实现了在捕获过程中检索帧的属性和方法。第三部分包含处理帧的所有方法,通过onImageProcessorFinishedProcessingFrame回调通知代理。最后一部分,我们将最关注的部分,包含生成最终图像的方法,即合成帧。让我们先看看第一部分,以了解可用的变量,如下面的代码片段所示:

class ImageProcessor{

    weak var delegate : ImageProcessorDelegate?

    lazy var model : VNCoreMLModel = {
        do{
            let model = try VNCoreMLModel(
                for: small_unet().model
            )
            return model
        } catch{
            fatalError("Failed to create VNCoreMLModel")
        }
    }()    

    var minMaskArea:CGFloat = 0.005
    var targetSize = CGSize(width: 448, height: 448)
    let lock = NSLock()
    var frames = [CIImage]()
    var processedImages = [CIImage]()
    var processedMasks = [CIImage]()
    private var _processingImage = false

    init(){

    }
}

没有什么特别之处。我们首先声明一个属性,它将我们的模型包装在一个VNCoreMLModel实例中,这样我们就可以利用 Vision 框架的预处理功能。然后,我们声明一系列变量来处理存储帧和处理;我们使用一个NSLock实例来避免不同的线程读取过时的属性值。

以下代码片段和ImageProcessor类的一部分包括处理检索和释放捕获帧的变量和方法:

extension ImageProcessor{

    var isProcessingImage : Bool{
        get{
            self.lock.lock()
            defer {
                self.lock.unlock()
            }
            return _processingImage
        }
        set(value){
            self.lock.lock()
            _processingImage = value
            self.lock.unlock()
        }
    }

    var isFrameAvailable : Bool{
        get{
            self.lock.lock()
            let frameAvailable =
                    self.frames.count > 0
            self.lock.unlock()
            return frameAvailable
        }
    }

    public func addFrame(frame:CIImage){
        self.lock.lock()
        self.frames.append(frame)
        self.lock.unlock()
    }

    public func getNextFrame() -> CIImage?{
        self.lock.lock()
        let frame = self.frames.removeFirst()
        self.lock.unlock()
        return frame
    }

    public func reset(){
        self.lock.lock()
        self.frames.removeAll()
        self.processedImages.removeAll()
        self.processedMasks.removeAll()
        self.lock.unlock()
    }
}

虽然相当冗长,但应该都是不言自明的;可能唯一值得特别指出的是addFrame方法,该方法在相机捕获每个帧时都会被调用。为了说明一切是如何联系在一起的,以下图表展示了捕获帧时的总体流程:

图片

流程的细节将在以下几点中介绍:

  1. 尽管在CameraViewController的生命周期内持续捕获帧,但只有在用户点击(并按住)动作按钮后,这些帧才会被传递给ImageProcessor

  2. 在这段时间内,每个捕获的帧(以限制的速率—目前为每秒 10 帧)都会传递给CameraViewController

  3. 然后,它使用前面显示的addFrame方法将帧传递给ImageProcessor

  4. 当用户从动作按钮抬起手指时,捕获停止,完成后,将实例化并展示EffectsViewController,同时传递一个包含捕获帧引用的ImageProcessor引用

ImageProcessor类的下一部分负责处理这些图像中的每一个;这是通过processFrames方法启动的,该方法由EffectsViewController在加载后调用。这部分有更多的代码,但其中大部分应该对你来说都很熟悉,因为这是我们在这本书的许多项目中使用的样板代码。让我们首先检查以下片段中的processFrames方法:

本章的其余部分,除非另有说明,所有剩余的代码都假定为ImageProcessor类内部;也就是说,将省略类和类扩展声明,以便使代码更容易阅读。

public func processFrames(){
    if !self.isProcessingImage{
        DispatchQueue.global(qos: .background).async {
            self.processesingNextFrame()
        }
    }
}

此方法简单地调度processingNextFrame方法到后台线程。当使用 Core ML 进行推理时这是强制性的,而且在执行计算密集型任务时也是良好的实践,以避免锁定用户界面。让我们继续追踪,通过检查processingNextFrame方法以及负责返回VNCoreMLRequest实例的方法,后者在以下代码片段中显示:

func getRequest() -> VNCoreMLRequest{
    let request = VNCoreMLRequest(
        model: self.model,
        completionHandler: { [weak self] request, error in
            self?.processRequest(for: request, error: error)
    })
    request.imageCropAndScaleOption = .centerCrop
    return request
}

func processesingNextFrame(){
    self.isProcessingImage = true

    guard let nextFrame = self.getNextFrame() else{
        self.isProcessingImage = false
        return
    }

    var ox : CGFloat = 0
    var oy : CGFloat = 0
    let frameSize = min(nextFrame.extent.width, nextFrame.extent.height)
    if nextFrame.extent.width > nextFrame.extent.height{
        ox = (nextFrame.extent.width - nextFrame.extent.height)/2
    } else if nextFrame.extent.width < nextFrame.extent.height{
        oy = (nextFrame.extent.height - nextFrame.extent.width)/2
    }
    guard let frame = nextFrame
        .crop(rect: CGRect(x: ox,
                           y: oy,
                           width: frameSize,
                           height: frameSize))?
        .resize(size: targetSize) else{
            self.isProcessingImage = false
            return
    }

    self.processedImages.append(frame)
    let handler = VNImageRequestHandler(ciImage: frame)

    do {
        try handler.perform([self.getRequest()])
    } catch {
        print("Failed to perform classification.\n\(error.localizedDescription)")
        self.isProcessingImage = false
        return
    }
}

我们首先将属性isProcessingImage设置为true,并检查我们是否有帧要处理,否则提前退出方法。

以下内容可能看起来有些反直觉(因为确实如此);从前几章中我们了解到VNCoreMLRequest处理了图像裁剪的预处理任务。那么,为什么我们在这里要手动处理呢?原因更多在于使代码更简洁和满足出版截止日期。在这个例子中,最终图像是通过调整大小的帧合成的,以避免对模型输出的缩放和偏移,这留作你的练习。因此,在这里,我们执行这个操作,并将结果持久化存储在processedImages数组中,以便在最终阶段使用。最后,我们执行请求,传入图像,一旦完成,调用我们的processRequest方法,传入模型的结果。

继续我们的追踪,现在我们将检查processRequest方法;由于这个方法相当长,我们将将其分解成块,从上到下工作:

func processRequest(for request:VNRequest, error: Error?){
    self.lock.lock()
    let framesReaminingCount = self.frames.count
    let processedFramesCount = self.processedImages.count
    self.lock.unlock()

 ...
}

我们首先获取最新的计数,这些计数将在此方法完成或失败时广播给代理。说到这一点,以下代码块验证是否返回了类型为[VNPixelBufferObservation]的结果,否则通知代理并返回,如下面的代码片段所示:

 func processRequest(for request:VNRequest, error: Error?){
 ... 

    guard let results = request.results,
        let pixelBufferObservations = results as? [VNPixelBufferObservation],
        pixelBufferObservations.count > 0 else {
            print("ImageProcessor", #function, "ERROR:",
                  String(describing: error?.localizedDescription))

            self.isProcessingImage = false

            DispatchQueue.main.async {
                self.delegate?.onImageProcessorFinishedProcessingFrame(
                    status: -1,
                    processedFrames: processedFramesCount,
                    framesRemaining: framesReaminingCount)
            }
            return
    }

 ...
}

参考我们的结果(CVBufferPixel),我们的下一个任务是创建一个CIImage实例,传入缓冲区并请求将颜色空间设置为灰度,以确保创建单通道图像。然后,我们将将其添加到下面的代码片段中processedMasks数组:

func processRequest(for request:VNRequest, error: Error?){
 ...

 let options = [
 kCIImageColorSpace:CGColorSpaceCreateDeviceGray()
 ] as [String:Any]

    let ciImage = CIImage(
        cvPixelBuffer: pixelBufferObservations[0].pixelBuffer,
        options: options)

    self.processedMasks.append(ciImage)

 ...
}

只剩下两件事要做!我们通知代理我们已经完成了一帧,并继续处理下一帧(如果有的话):

func processRequest(for request:VNRequest, error: Error?){
 ... 

    DispatchQueue.main.async {
        self.delegate?.onImageProcessorFinishedProcessingFrame(
            status: 1,
            processedFrames: processedFramesCount,
            framesRemaining: framesReaminingCount)
    }

    if self.isFrameAvailable{
        self.processesingNextFrame()
    } else{
        self.isProcessingImage = false
    }
}

这标志着我们ImageProcessor的第三部分结束;到目前为止,我们有两个数组,包含调整大小的捕获帧和模型生成的分割图像。在继续到这个类的最后部分之前,让我们从鸟瞰图的角度回顾一下我们刚才所做的工作,如图流图所示:

图片

流程的细节如下所示:

  1. 如前图所示,一旦加载了EffectsViewController,就会启动处理过程,这会启动后台线程来处理捕获到的每一帧

  2. 每一帧首先被调整大小和裁剪,以匹配模型的输出

  3. 然后,它被添加到processedFrames数组中,并传递给我们的模型进行干扰(分割)

  4. 一旦模型返回结果,我们实例化一个单色的CIImage实例

  5. 此实例存储在processedMasks数组中,并通知代理进度

当所有帧都处理完毕时会发生什么?这是我们计划在下一部分回答的,我们将讨论创建效果的详细方法。首先,让我们讨论这个过程是如何开始的。

一旦代理(EffectsViewController)收到回调,使用onImageProcessorFinishedProcessingFrame,其中所有帧都已处理,它就调用 ImageProcessor 中的compositeFrames方法来开始创建效果的过程。让我们回顾一下这部分ImageProcessor类中的现有代码:

func compositeFrames(){

    var selectedIndicies = self.getIndiciesOfBestFrames()
    if selectedIndicies.count == 0{
        DispatchQueue.main.async {
            self.delegate?.onImageProcessorFinishedComposition(
                status: -1,
                image: self.processedImages.last!)
        }
        return
    }

    var finalImage = self.processedImages[selectedIndicies.last!]
    selectedIndicies.removeLast() 
    // TODO Composite final image using segments from intermediate frames
    DispatchQueue.main.async {
        self.delegate?.onImageProcessorFinishedComposition(
            status: 1,
            image: finalImage)
    }
}

func getIndiciesOfBestFrames() -> [Int]{
 // TODO; find best frames for the sequence i.e. avoid excessive overlapping
 return (0..<self.processedMasks.count).map({ (i) -> Int in
 return i
 })
}

func getDominantDirection() -> CGPoint{
 var dir = CGPoint(x: 0, y: 0)
 // TODO detected dominate direction
 return dir
}

我已经将重要的/有趣的部分加粗,本质上是我们将要实现的部分,但在编写更多代码之前,让我们回顾一下我们目前拥有的内容(就处理过的图像而言)以及创建我们效果的方法。

在这个阶段,我们有一个processedFrames数组,其中包含捕获图像的调整大小和裁剪版本,还有一个名为processedMasks的数组,包含来自我们的分割模型的单通道图像。以下图示了这些示例:

图片

如果我们像现在这样合成每个帧,我们最终会得到很多不需要的伪影和过多的重叠。一种方法可能是调整已经处理(以及可能捕获)的帧,即跳过每n帧以分散帧。这种方法的问题在于它假设所有主题将以相同的速度移动;为了解决这个问题,你需要将这种调整暴露给用户进行手动调整(这是一种合理的方法)。我们将采取的方法是提取每个帧的边界框,并使用这些帧的位移和相对重叠来确定何时插入帧以及何时跳过帧。

为了计算边界框,我们只需扫描图像的每条边上的每一行,即从顶部到底部,以确定物体的顶部。然后,我们从底部到顶部做同样的事情,以确定物体的底部。同样,我们在水平轴上做同样的事情,以下图示了这一点:

图片

即使有边界框,我们仍然需要确定在插入帧之前物体应该移动多远。为了确定这一点,我们首先确定主导方向,这是通过找到分割物体的第一帧和最后一帧之间的方向来计算的。然后,这被用来确定比较位移的轴;也就是说,如果主导方向在水平轴上(如前图所示),那么我们测量沿x轴的位移,忽略y轴。然后,我们简单地测量帧之间的距离与一些预定的阈值,以决定是否合成帧或忽略它。以下图示了这一点:

图片

让我们看看代码中的样子,从确定主导方向开始。将以下代码添加到getDominantDirection方法中:

var dir = CGPoint(x: 0, y: 0)

var startCenter : CGPoint?
var endCenter : CGPoint?

// Find startCenter
for i in 0..<self.processedMasks.count{
    let mask = self.processedMasks[i]

    guard let maskBB = mask.getContentBoundingBox(),
    (maskBB.width * maskBB.height) >=
        (mask.extent.width * mask.extent.height) * self.minMaskArea
    else {
        continue
    }

    startCenter = maskBB.center
    break
}

// Find endCenter
for i in (0..<self.processedMasks.count).reversed(){
    let mask = self.processedMasks[i]

    guard let maskBB = mask.getContentBoundingBox(),
    (maskBB.width * maskBB.height) >=
        (mask.extent.width * mask.extent.height) * self.minMaskArea
    else {
        continue
    }

    endCenter = maskBB.center
    break
}

if let startCenter = startCenter, let endCenter = endCenter, startCenter != endCenter{
    dir = (startCenter - endCenter).normalised
}

return dir

如前所述,我们首先找到我们序列帧的开始和结束的边界框,并使用它们的中心来计算主导方向。

这里省略了CIImage方法的getContentBoundingBox实现,但可以在CIImage+Extension.swift文件中找到相应的源代码。

有了主导方向,我们现在可以继续确定要包含哪些帧以及要忽略哪些帧。我们将在ImageProcessor类的getIndiciesOfBestFrames方法中实现这一点,该方法遍历所有帧,测量重叠并忽略那些不满足特定阈值的帧。该方法返回一个索引数组,这些索引满足这个阈值,可以合成到最终图像上。将以下代码添加到getIndiciesOfBestFrames方法中:

var selectedIndicies = [Int]()
var previousBoundingBox : CGRect?
let dir = self.getDominateDirection()

for i in (0..<self.processedMasks.count).reversed(){
    let mask = self.processedMasks[i]
   guard let maskBB = mask.getContentBoundingBox(),
        maskBB.width < mask.extent.width * 0.7,
        maskBB.height < mask.extent.height * 0.7 else {
        continue
    }

    if previousBoundingBox == nil{
        previousBoundingBox = maskBB
        selectedIndicies.append(i)
    } else{
        let distance = abs(dir.x) >= abs(dir.y)
            ? abs(previousBoundingBox!.center.x - maskBB.center.x)
            : abs(previousBoundingBox!.center.y - maskBB.center.y)
        let bounds = abs(dir.x) >= abs(dir.y)
            ? (previousBoundingBox!.width + maskBB.width) / 4.0
            : (previousBoundingBox!.height + maskBB.height) / 4.0

        if distance > bounds * 0.5{
            previousBoundingBox = maskBB
            selectedIndicies.append(i)
        }
    }

}

return selectedIndicies.reversed()

我们首先获取主导方向,如前所述,然后按逆序(因为假设用户的 heroshot 是最后一帧)遍历我们的帧序列。对于每个帧,我们获取边界框,如果它是第一个要检查的帧,我们将其分配给变量previousBoundingBox。这将用于比较后续的边界框(并更新为最新的包含帧)。如果previousBoundingBox不为空,则根据主导方向计算两者之间的位移,如下面的代码片段所示:

let distance = abs(dir.x) >= abs(dir.y)
    ? abs(previousBoundingBox!.center.x - maskBB.center.x)
    : abs(previousBoundingBox!.center.y - maskBB.center.y) 

然后,我们计算分离两个对象所需的最小长度,这是通过相对轴的合并大小除以 2 来计算的。这给我们一个距离,是合并帧的一半,如下面的代码片段所示:

let bounds = abs(dir.x) >= abs(dir.y)
    ? (previousBoundingBox!.width + maskBB.width) / 2.0
    : (previousBoundingBox!.height + maskBB.height) / 2.0

然后,我们将距离与边界以及阈值进行比较,如果距离满足这个阈值,就继续将帧添加到当前索引中:

if distance > bounds * 0.15{
    previousBoundingBox = maskBB
    selectedIndicies.append(i)
}

返回到compositeFrames方法,我们现在准备合成选定的帧。为了实现这一点,我们将利用CoreImages过滤器;但在这样做之前,让我们快速回顾一下我们到底想实现什么。

对于每个选定的(处理过的)图像和掩码对,我们希望裁剪出图像并将其叠加到最终图像上。为了提高效果,我们将应用逐渐增加的 alpha 值,使得接近最终帧的帧将具有接近 1.0 的不透明度,而远离最终帧的帧将逐渐变得透明;这将给我们一个渐变的拖尾效果。这个过程在以下图中进行了总结:

让我们通过首先实现过滤器将这个想法转化为代码。如前所述,我们将传递内核输出图像、叠加图像及其相应的掩码和一个 alpha 值。在ImageProcessor类的顶部附近,添加以下代码:

lazy var compositeKernel : CIColorKernel? = {
    let kernelString = """
        kernel vec4 compositeFilter(
            __sample image,
            __sample overlay,
            __sample overlay_mask,
            float alpha){
            float overlayStrength = 0.0;

            if(overlay_mask.r > 0.0){
                overlayStrength = 1.0;
            }

            overlayStrength *= alpha;

            return vec4(image.rgb * (1.0-overlayStrength), 1.0)
                + vec4(overlay.rgb * (overlayStrength), 1.0);
        }
    """
    return CIColorKernel(source:kernelString)
}()

在之前,我们已经实现了CIColorKernel,正如所讨论的那样,它负责将所有我们的帧组合到最终图像中。我们首先测试掩码的值,如果它是 1.0,我们分配强度 1.0(这意味着我们想要用叠加的颜色替换最终图像中该位置的颜色)。否则,我们分配 0,忽略它。然后,我们将强度与传递给我们的内核的混合参数相乘。最后,我们使用语句vec4(image.rgb * (1.0-overlayStrength), 1.0) + vec4(overlay.rgb * (overlayStrength), 1.0)计算并返回最终颜色。现在我们的过滤器已经实现,让我们返回compositeFrames方法并投入使用。在compositeFrames中,将注释// TODO 使用中间帧的段来组合最终图像替换为以下代码:

let alphaStep : CGFloat = 1.0 / CGFloat(selectedIndicies.count)

for i in selectedIndicies{
    let image = self.processedImages[i]
    let mask = self.processedMasks[i]

    let extent = image.extent
    let alpha = CGFloat(i + 1) * alphaStep
    let arguments = [finalImage, image, mask, min(alpha, 1.0)] as [Any]
    if let compositeFrame = self.compositeKernel?.apply(extent: extent, arguments: arguments){
        finalImage = compositeFrame
    }
}

大部分内容应该都是不言自明的;我们首先计算一个 alpha 步长,它将被用来在接近最终帧时逐步增加不透明度。然后,我们遍历所有选定的帧,应用我们在前面的片段中实现的过滤器,组合我们的最终图像。

完成这些工作后,我们现在已经完成了这一方法以及本章的编码。做得好!是时候测试一下了;构建并运行项目,看看你的辛勤工作是如何付诸实践的。以下是一个周末公园访问的结果:

图片

在结束本章之前,让我们简要讨论一下与机器学习模型一起工作时的一些策略。

与概率结果一起工作

正如本章开头所提到的,并在上一节中亲身体验到的那样,与机器学习模型一起工作需要一套新的技术和策略来处理不确定性。所采取的方法将是特定领域的,但有一些值得记住的广泛策略,这就是我们将在本章的示例项目中讨论的内容。

改进模型

第一点是改进模型。当然,根据模型和数据集的来源,可能会有局限性,但理解模型可以如何改进是很重要的,因为其输出直接关联到用户体验的质量。

在这个项目的背景下,我们可以使用现有的预训练图像分类器作为编码器来增强模型,如前所述。这不仅加快了训练速度,提供了更多的迭代机会,而且很可能会通过让模型从更全面的数据集中转移现有知识来提高性能。

另一个是调整模型训练所使用的数据集。一个简单的、相关的例子是,任何用户手持物体(已被标记)的图像都可以看到模型如何被改进。以下图中的例子就是这样,吉他被从人身上裁剪出来:

图片

你如何处理这个问题取决于你模型期望的特性。在本章所展示的应用程序背景下,进行多类分类,包括人们通常持有的物体,或者将它们包含在掩码中,是有意义的。

另一种常见的技术是数据增强。这是指你人工调整图像(输入),以增加数据集的方差,或者甚至调整它以使其更符合你特定用例的数据。一些增强的例子包括模糊(在处理快速移动的物体时很有用)、旋转、添加随机噪声、颜色调整——本质上任何在现实世界中可能遇到的图像处理效果。

当然,还有许多其他技术和工具可以改进模型和数据;在这里,我们的意图只是强调主要领域,而不是深入细节。

在约束中设计

这在某种程度上是不可避免的,而且我们设计智能界面的方式仍处于初级阶段。也就是说,你向用户暴露多少透明度?你如何有效地帮助他们构建一个有用的心理模型,而不会分散他们的注意力或失去最初使用模型时的便利性?但在这里,我仅仅是指在设计体验中的约束条件,以提高模型成功的可能性。一个很好的例子,尽管与主题稍有不相关,就是家用机器人和洗碗机。尽管它没有机器人特性,但忠诚的洗碗机可以被认为是第一代用于家庭任务的机器人,就像《杰森一家》中的 Rosie。然而,与 Rosie 不同的是,我们还没有能够让洗碗机适应我们的环境。因此,我们为洗碗机调整了环境,也就是说,我们将其封装在一个箱子环境中,而不是使用我们习惯的现有厨房水槽。

一种简单的方法是让用户意识到如何获得最佳结果;在这个例子中,这可能仅仅是通过要求他们使用墙壁作为背景。这些提示可以在使用前提供,也可以在出现性能不佳的迹象时提供(或者两者都有)。自动检测性能不佳的一种方法是对边界框及其质心进行测量,并将其与预期的质心进行比较,如图所示:

图片

这就自然引出了下一个策略:嵌入启发式方法。

嵌入启发式方法

启发式方法本质上是在你的脑海中编码规则以解决特定任务,通常通过一个返回分数的函数来实现。这用于对一组替代方案进行排序。在上一节“在设计约束中”,我们看到了如何使用质心和边界框来确定分割图像中像素的分布情况。这反过来可以用来对每个帧进行排序,优先考虑那些质心靠近边界框中心的帧。我们还实现了一种启发式方法,在确定保留哪些帧和忽略哪些帧时,通过测量重叠来实现。

启发式方法可以是一个强大的盟友,但请注意确保你推导出的启发式方法能够很好地泛化到你的问题上,就像你期望一个好的模型那样。此外,还要注意使用它们所带来的额外计算成本。

后处理和集成技术

可以借鉴图像处理、计算机视觉和计算机图形学中的技术来提高输出质量以及检测能力。例如,一个典型的图像处理任务是执行开闭形态学操作。这种组合通常用于去除二值图像中的噪声和填充小孔。另一个我们可以从计算机视觉中借鉴的有用的后处理任务是流域,这是一种将图像视为地形图的分割技术,其中变化的强度定义了脊和填充(或分割)的边界。

另一个用于后处理的工具是另一个模型。你对 YOLO(用于对象检测)很熟悉。我们可以将其应用于获取对象的预测边界,然后我们可以使用这些边界来细化我们的分割。另一个模型,也是用于此任务的模型,是条件随机字段CRF),它能够平滑我们的掩模边缘。

从图像处理、计算机视觉和计算机图形学等领域有大量的技术可供选择,我强烈建议你探索每个领域以构建你的工具集。

如果你刚开始接触计算机视觉,我推荐 T. Morris 的《计算机视觉与图像处理》和 J. Parker 的《图像处理和计算机视觉算法》这两本书,作为对这个领域的实用介绍。

人工辅助

有时包含人类在调整模型输出中是不可避免的,甚至可能是期望的。在这些情况下,模型被用来辅助用户而不是完全自动化任务。本章项目中可能采用的一些方法包括以下内容:

  • 提供一个中间步骤,让用户可以整理掩模。我的意思是允许用户擦除被错误分类或用户不想要的掩模部分。

  • 向用户展示一系列帧,并让他们选择用于合成的帧。

  • 向用户展示最终合成图像的不同变体,并让他们选择最具吸引力的一个。

另一个相关的概念是引入人机交互的机器学习。当模型对其预测没有信心时,会有人类介入,并将分类和/或纠正的责任转交给用户。用户的修改将被存储并用于训练模型以改进性能。在这个例子中,我们可以让用户(或众包这个任务)分割图像,并在重新训练模型时使用这些数据。最终,在获得足够的数据后,模型将提高其在使用环境中相关的性能。

希望这一节强调了在处理机器模型时处理不确定性的重要性,并提供了足够的跳板,以便您可以从这里概述的角度来设计智能应用程序。现在,让我们通过回顾我们已经涵盖的内容来结束这一章。

摘要

在本章中,我们介绍了语义分割的概念,这是一种使我们的应用程序对照片和视频有更高感知理解的方法。它通过训练一个模型将每个像素分配到特定的类别来实现。为此,一个流行的架构是 U-Net,它通过保留空间信息,通过连接卷积层,实现了高精度的定位。然后我们回顾了用于训练的数据以及模型的某些示例输出,包括突出模型局限性的示例。

我们看到了如何通过创建一个图像效果应用程序来使用这个模型,其中分割的图像被用来从一系列帧中剪取人物并将它们组合在一起以创建动作镜头。但这只是语义分割应用的一个例子;它通常用于机器人、安全监控和工厂质量保证等领域,仅举几个例子。它还可以如何应用,取决于你。

在最后一节中,我们花了一些时间讨论处理模型时的策略,特别是它们的概率性(或不确定性水平)输出,以提高用户体验。

这是我们应用机器学习的最后一个例子。在下一章,也就是最后一章中,我们将转换方向,通过 Create ML 的帮助介绍如何构建自己的模型。让我们开始吧。

第十章:Create ML 简介

这本书的目的是探索在 iPhone 上应用机器学习的方法,特别是关注计算机视觉任务。即使这个焦点很窄,我们也只是触及了目前可能性的表面。但,希望我们已经涵盖了足够的内容来激发您的兴趣,并提供了足够的直觉来帮助您在构建智能应用的过程中理解机器学习模型的细节。

本章旨在通过介绍Create ML,一个与 Core ML 2 一同发布的工具,该工具提供了一种使用自定义数据创建一些常见模型的方法,来继续这一旅程。尽管我们只提供了高级介绍,特别是关于计算机视觉,但这仍然应该足以帮助您在自己的应用中使用它。

到本章结束时,您将:

  • 修订了机器学习工作流程

  • 认识到将数据分为训练集和验证集的重要性

  • 使用 Create ML 创建自定义图像分类器

  • 看过其他工具和框架以继续您的旅程

让我们从回顾典型的机器学习工作流程开始。

典型的工作流程

就像任何项目一样,您在进入过程时对您要构建的内容(问题)有一些了解。您对此了解得越清楚,您就越有能力解决它。

在理解了您要做什么之后(在构建机器学习模型的背景下),您的下一个问题(或任务)是“我需要什么数据?”这包括探索可用的数据以及您可能需要自己生成哪些数据。

一旦您理解了您要做什么以及您需要什么数据,您的下一个问题/任务就是决定需要什么算法(或模型)。这显然取决于您的任务和您拥有的数据;在某些情况下,您可能需要创建自己的模型,但更常见的情况是,您将可以使用一个合适的模型,或者至少是一个可以与您自己的数据一起使用的架构。以下表格显示了典型的计算机视觉任务及其相关的机器学习对应物:

任务 机器学习算法
标注图像 图像分类
识别多个对象及其位置 目标检测和语义分割
找到相似图像 图像相似度
创建风格化的图像 风格迁移

下一步是训练您的模型;通常,这是一个迭代的过程,需要大量的微调,直到您有一个在未训练过的数据上足够好地完成其任务的模型。

最后,在训练好模型后,您可以在您的应用中部署和使用您的模型。这个过程在以下图中总结:

图片

之前的图表是对过程的过度简化;通常,工作流程是更循环的,在训练、选择和调整模型之间有多个迭代。同时运行多个模型(和模型参数)也是常见的。

为了使本章的概念更加具体,让我们以一个假设的简报为例,即必须构建一个有趣的应用程序来帮助幼儿学习水果的名称。你和你的团队已经提出了一个概念,即让幼儿通过使用设备的摄像头找到特定的水果。当幼儿正确地使用设备识别出水果时,他们会获得积分。现在我们的任务已经定义,让我们讨论我们需要哪些数据。

准备数据

对于我们的任务,我们需要一组标注好的水果照片。如您从第一章,“机器学习简介”中回忆的那样,这类机器学习问题被称为监督学习。我们需要我们的模型接收一张图片并返回它认为图片所代表的标签,也称为多类分类

开始收集水果照片。创建 ML 允许以多种方式组织你的数据,但我发现按照以下方式在文件夹中组织是最容易的:

图片

来源:http://www.image-net.org/

在这里,我们已经将我们的数据组织成文件夹,文件夹名称用作其内容的标签。另一种方法是给每张图片标注,例如,特定类别的每个实例都有一个后缀数字,例如 banana.0.jpgbanana.1.jpg,依此类推。或者,您可以简单地传递一个包含标签及其相关图像 URL 列表的字典。

在这个阶段,你可能想知道你应该获取多少张图片。苹果公司建议每个类别至少需要 10 张图片,但你通常希望收集尽可能多的图片,以帮助模型通过确保它在训练期间看到很多变化来泛化。同时,尽可能获取与模型将要使用的真实数据尽可能接近的图片也很重要(在现实世界中)。这是因为模型不会根据它学到的内容产生偏见。它只是学习它需要学习的内容。也就是说,如果你的苹果示例都是红色苹果,背景为白色,那么你的模型很可能会学会将这些颜色与苹果联系起来,每次它看到这些颜色时,它都会预测该图像包含苹果。

如前所述,苹果公司建议至少需要 10 张图片;这应该让你感到有些惊讶。通常,当谈论训练深度神经网络时,你期望数据集很大,非常大。例如,用于训练图像分类器的一个标准数据集是 ImageNet。这个数据集包含超过 1400 万张图片;这也是秘密的一部分。正如我们在整本书中讨论的那样,CNN 的层学习如何从图像中提取有意义特征,然后它们使用这些特征来推断图像的类别。对于像我们的水果分类器这样的专用分类器,一个常见的做法是借鉴在数百万张图片上训练的模型的这些学习成果,并使用它提取的特征来训练我们较小的数据集上的分类器——这种技术被称为迁移学习

下面的两个图提供了这个过程的说明性示例,第一个图显示了一个在大数据集上训练的网络,第二个图则使用它所学的知识来训练一个更专业的数据集:

图片

我们对卷积层学习的特征向量感兴趣;你可以将其视为其对输入图像理解的编码。这是我们想要用来训练我们自己的分类器,如下图中所示:

图片

采用这种方法,我们无需学习如何提取特征,只需训练一个全连接网络的权重以进行分类,利用先前网络提取有意义特征的能力。Create ML 使用这种技术为其图像分类器服务。使用驻留在设备上并已针对超过 1,000 个类别进行训练的预训练模型意味着我们只需训练一个相对较小的网络进行分类。这是通过使用预训练网络提供的特征来完成的。这不仅使我们能够从较小的数据集中学习,而且减少了训练所需的时间。

Create ML 提供的另一个功能,并且代表我们进行有效的小数据集训练的是一种称为数据增强的技术。数据增强简单来说就是通过在训练过程中对每个图像应用一系列随机变换来增加数据集的方差,例如水平翻转图像。目标是,在训练时间,你的模型将看到许多图像的变体,以提高你的模型泛化的能力,也就是说,学习对它之前未见过的数据有意义的特征。以下图展示了数据增强通常执行的一些变换:

图片

Create ML 提供的另一个便利之处是,它处理了与图像一起工作时所需的典型预处理任务,例如裁剪和调整大小。它们通常具有固定大小的输入和输出,需要您显式地预处理图像以匹配模型,或者使用 Vision 框架来为您处理这些任务。Create ML 建立在 Vision 之上的额外后果是,它处理了您在训练模型时通常需要手动执行的大量管道。

在我们继续创建和训练模型之前,还有一个重要的话题我想强调;这与平衡数据集或不平衡数据集的影响有关。平衡数据集指的是每个类都有相同数量的示例;也就是说,您避免在您每个类中的示例数量之间有大的差异。这为什么很重要?为了回答这个问题,让我们提醒一下模型是如何训练的以及它学到了什么。以下图示了训练过程,其中训练是对给定输入进行推理(前向传递)的迭代过程。然后,对模型的权重进行小调整,以便它们减少预测值和预期值(损失)之间的任何差异:

图片

另一种说法是,过度暴露一个类将主导调整权重的这个过程,使得权重将更好地适应它们自己的类而不是其他类。这在批量训练时尤其如此,因为错误通常是批集中所有样本的平均值。所以,如果您的模型能够有效地预测主导类,它很可能会实现合理的损失,并且无法为其他类学习到任何有用的东西。

在这个阶段,我们已经知道我们想要实现的目标,拥有我们的平衡训练集,并且知道我们需要什么机器学习任务;我们现在准备好构建和训练我们的模型了。

创建和训练模型

感谢苹果工程师们的巨大努力,创建常见机器学习模型的过程极其简单,无疑将在接下来的几个月内引发一波新的智能应用浪潮。

在本节中,您将看到我们如何使用 Create ML 创建一个图像分类器,这将展示其有多么简单。

使用 Xcode Playground 可以访问 Create ML,因此这是一个很好的开始地方。打开 Xcode 并创建一个新的 Playground,确保您选择 macOS 作为平台,如图所示:

图片

一旦进入 Playground,按照以下方式导入CreateMLFoundation

import CreateML
import Foundation

接下来,创建一个指向包含您的训练数据目录的URL

let trainingDir = URL(fileURLWithPath: "/<PATH TO DIRECTORY WITH TRAINING DATA>")

剩下的唯一事情就是创建我们模型的实例,传入我们训练数据的路径(我确实说过这非常简单):

let model = try MLImageClassifier(
    trainingData: .labeledDirectories(at: trainingDir))

Create ML 为您提供了提供自定义标签字典及其相关文件的灵活性,或者通过MLImageClassifier.DataSource的便利性。这可以是类组织到相应文件夹中的分层目录结构,MLImageClassifier.DataSource.labeledDirectories(如我们在本例中所做的那样),或者每个文件都根据其相关类命名的结构,MLImageClassifier.DataSource.labeledFiles

一旦模型实例化,它将开始训练。一旦完成,它将输出在您的训练集上达到的准确率到控制台,如下面的截图所示:

图片

我们几乎完成了;这告诉我们我们的模型很好地拟合了我们的训练数据,但它并没有告诉我们它将如何泛化,也就是说,它将如何在新未见过的图像上工作。深度神经网络记住它们的训练数据,这通常被称为过拟合。为了避免过拟合,并因此更有可能在现实世界中产生可用的东西,通常将数据分成三个桶。第一个桶用于训练模型。第二个桶,称为验证数据,在训练期间使用(通常在每个迭代/周期结束时)来查看模型泛化的程度。它还提供了有关模型何时开始过拟合的线索(当训练准确率和验证准确率开始偏离时)。最后一个桶只在您对模型在验证数据上的表现满意后使用,这是模型实际工作效果的决定因素;这个桶被称为测试数据。

您为验证和测试保留了多少数据?对于浅层学习者来说,通常有一个 70/20/10(训练、验证和测试)的分割。但深度学习通常意味着大数据集,在这种情况下,为验证和测试保留的数据可能过多。因此,答案实际上取决于您有多少数据以及数据的类型。

因此,在我们部署模型之前,我们将在训练期间未见过的数据集上评估它。再次提醒,为您的每个类别收集相等数量的数据,并在完成后返回此处。

如我们之前所做的那样,创建一个指向包含您的验证数据的目录的 URL:

let validationDir = URL(fileURLWithPath: "/<PATH TO DIRECTORY WITH VALIDATION DATA>")

现在只需在模型上调用evaluation即可,如下所示:

model.evaluation(on: .labeledDirectories(at: validationDir))

这将在我们的每个验证样本上执行推理,并报告准确率,您可以通过快速查看访问:

图片

对于我们的验证准确率感到满意后,我们现在准备导出我们的模型,但在我们这样做之前,让我们对一个单独的图像进行预测。

您可以通过调用模型实例的 prediction 方法(如果您有多个样本要执行推理,则为 predictions)轻松完成此操作,如下面的代码片段所示:

let strawberryUrl = URL(
    fileURLWithPath: "/<PATH TO STRAWBERRY>")

print(try model.prediction(from: strawberryUrl)) 

如果一切顺利,那么 Strawberry 应该会输出到您的控制台。现在,对我们模型有了信心,是时候导出它了。

根据 Create ML 的本质,导出只是一行代码:

try model.write(toFile: "<PATH TO FILE>")

从这里,只需将 Core ML 模型导入到您的项目中即可,正如我们在本书中多次看到的。

我们几乎结束了对 Create ML 的简要介绍;但在我们继续之前,我想快速强调一些事情,首先是模型参数。

模型参数

在上一节中,我提到了数据增强对于小型数据集的有用性。那么,您如何在训练期间使用它呢?选项通过 MLImageClassifier.ModelParameters 结构暴露给您,您可以在实例化分类器时传递其实例。其中一个参数是 OptionSet CreateML.MLImageClassifier.ImageAugmentationOptions,它允许您打开和关闭各种增强技术。

MLImageClassifier.ModelParameters 还允许您指定最大迭代次数、特征提取的版本和验证数据。您可以在官方网页上了解更多信息:developer.apple.com/documentation/create_ml/mlimageclassifier/modelparameters

模型元数据

当与 第五章 中的 Core ML 工具包和 第六章 中的 使用风格迁移创建艺术 一起工作时,我们将 Keras 模型转换为 Core ML,我们看到了如何显式设置元数据,这在 Xcode 中显示。Create ML 通过在导出模型时传递 MLModelMetadata 的实例来提供显式设置这些数据的方法。它提供了我们在使用 Core ML 工具包时看到的全部元数据,如名称、描述等。

替代工作流程(图形化)

在进入下一节之前,最后一点!在本章中,我们已经通过编程的方式创建、训练和验证了一个模型。Create ML 提供了一种替代方案,在这里,您可以使用图形界面来构建模型,而不是使用代码。这可以通过 CreateMLUI 库访问,您只需创建一个 MLImageClassifierBuilder 的实例并调用其 showInLiveView 方法:

import CreateMLUI

let builder = MLImageClassifierBuilder()
builder.showInLiveView()

一旦运行,您将在实时视图中看到一个小部件,它允许您通过拖放训练和验证示例来简单地训练模型。以下图显示了训练和验证后的此小部件以及输入元数据的面板:

图片

这部分、这一章和这本书的内容到此结束。我们将以一些总结性的思考结束,包括一些其他工具的列表,以帮助您在创建更智能的应用程序的道路上。

总结性思考

这个工具通过允许任何(有能力的人)创建自定义模型,实际上使机器学习民主化,但简单性和表达性之间总是存在权衡。因此,这里有一份简短的工具列表,您可能想要探索:

  • Turi create:来自 2016 年被苹果公司收购的公司;它提供了与 Core ML 的紧密集成,使得部署和定制模型变得容易。它还提供了一套更全面的机器学习模型,如风格转换和分割。您可以在github.com/apple/turicreate了解更多关于 Turi create 的信息。

  • IBM Watson Services for Core ML:IBM Watson 是 IBM 的 AI 平台,提供了一系列常见的机器学习模型作为服务。他们最近通过 Core ML 模型提供了一些这些服务,允许您的应用程序在离线时也能利用 IBM Watson 的服务。

  • ML Kit:谷歌在 2018 年初宣布了 ML Kit,作为一个平台,用于常见的机器学习任务,如图像标注和光学字符识别。该平台还负责模型分发,包括自定义模型。

  • TensorFlowLite:TensorFlow 流行机器学习框架的轻量级版本。与 Core ML 类似,它支持设备端推理。

这些只是将机器学习集成到您应用程序中的一些选项,所有这些在未来的几年里都可能显著增长。但是,正如我们在整本书中看到的,机器学习算法只是方程的一部分;数据是推动体验的因素,所以我鼓励您寻找并尝试新的数据集,看看您能利用这里学到的知识创造出什么样的独特体验。

机器学习正在以惊人的速度发展。Arxiv 网站是研究人员发布论文的流行仓库;只需监测这个网站一周以上,您就会对发表的论文数量和取得的进步感到惊讶和兴奋。

但是,目前研究界和行业从业者之间存在差距,这在一定程度上促使我写这本书。我希望您在这本书的页面上所读到的内容已经给您足够关于深度神经网络的直觉,更重要的是,激发了对您继续探索和实验的足够的好奇心和兴奋感。正如我在本章开头提到的,我们刚刚触及了目前存在和可能的表面,更不用说 12 个月后的情况了。

因此,请把这看作是邀请或挑战,加入我一起创造下一代应用程序。我期待看到您创造出的成果!

概述

在本章中,我们介绍了 Create ML,这是一个使训练和部署常见机器学习模型变得极其简单的工具。我们看到了如何使用极少的示例和极少的代码创建一个图像分类器。我们讨论了这是如何通过使用迁移学习来实现的,然后介绍了一些关于您的训练数据以及将其分割用于验证和测试的重要性的注意事项。

posted @ 2025-09-03 09:53  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报