intel-mobi-proj-tf-merge-1

43 阅读48分钟

TensorFlow 智能移动项目(二)

原文:Intelligent mobile projects with TensorFlow

协议:CC BY-NC-SA 4.0

五、了解简单的语音命令

如今,语音服务(例如 Apple Siri,Amazon Alexa,Google Assistant 和 Google Translate)已变得越来越流行,因为语音是我们在某些情况下查找信息或完成任务的最自然和有效的方法。 这些语音服务中的许多服务都是基于云的,因为用户语音可能会很长而且很自由,并且自动语音识别ASR)非常复杂,并且需要大量的计算能力。 实际上,得益于深度学习的突破,仅在最近几年,在自然和嘈杂的环境中 ASR 才变得可行。

但是在某些情况下,能够离线识别设备上的简单语音命令是有意义的。 例如,要控制 Raspberry-Pi 驱动的机器人的运动,您不需要复杂的语音命令,不仅设备上的 ASR 比基于云的解决方案还快,而且即使在没有网络访问的环境。 设备上的简单语音命令识别还可以通过仅在发出某些明确的用户命令时才向服务器发送复杂的用户语音来节省网络带宽。

在本章中,我们将首先概述 ASR 技术,涵盖基于最新的深度学习系统和顶级开源项目。 然后,我们将讨论如何训练和重新训练 TensorFlow 模型,以识别简单的语音命令,例如"left", "right", "up", "down", "stop", "go"。 接下来,我们将使用训练有素的模型来构建一个简单的 Android 应用,然后再构建两个完整的 iOS 应用,一个由 Objective-C 实现,另一个由 Swift 实现。 在前两章中我们没有介绍使用 TensorFlow 模型的基于 Swift 的 iOS 应用,而本章是回顾和加强我们对构建基于 Swift 的 TensorFlow iOS 应用的理解的好地方。

总之,本章将涵盖以下主题:

  • 语音识别 -- 快速概述
  • 训练简单的命令识别模型
  • 在 Android 中使用简单的语音识别模型
  • 在带有 Objective-C 的 iOS 中使用简单的语音识别模型
  • 在带有 Swift 的 iOS 中使用简单的语音识别模型

语音识别 -- 快速概述

1990 年代出现了第一个实用的独立于说话者的大词汇量和连续语音识别系统。 在 2000 年代初期,领先的初创公司 Nuance 和 SpeechWorks 提供的语音识别引擎为许多第一代基于 Web 的语音服务提供了支持,例如 TellMe,Phone 的 AOL 和 BeVocal。 当时构建的语音识别系统主要基于传统的隐马尔可夫模型HMM),并且需要手动编写语法和安静环境以帮助识别引擎更准确地工作。

现代语音识别引擎几乎可以理解嘈杂环境下人们的任何说话,并且基于端到端深度学习,尤其是另一种更适合自然语言处理的深度神经网络,称为循环神经网络RNN)。 与传统的基于 HMM 的语音识别不同,传统的基于 HMM 的语音识别需要人的专业知识来构建和微调手工设计的特征以及声学和语言模型,而基于 RNN 的端到端语音识别系统则将音频输入直接转换为文本,而无需将音频输入转换为语音表示以进行进一步处理。

RNN 允许我们处理输入和/或输出的序列,因为根据设计,网络可以存储输入序列中的先前项目或可以生成输出序列。 这使 RNN 更适用于语音识别(输入是用户说出的单词序列),图像标题(输出是由一系列单词组成的自然语言句子),文本生成和时间序列预测 。 如果您不熟悉 RNN,则一定要查看 Andrey Karpathy 的博客,循环神经网络的不合理有效性。 在本书的后面,我们还将介绍一些详细的 RNN 模型。

关于 RNN 端到端语音识别的第一篇研究论文发表于 2014 年,使用的是连接主义的时间分类CTC)层。 2014 年下半年,百度发布了 Deep Speech,这是第一个使用基于 CTC 的端到端 RNN 构建但拥有庞大数据集的商业系统之一 ,并在嘈杂的环境中实现了比传统 ASR 系统更低的错误率。 如果您有兴趣,可以查看深度语音的 TensorFlow 实现,但是由于此类基于 CTC 的系统存在问题,生成的模型需要太多的资源才能在手机上运行。 在部署期间,它需要一个大型语言模型来纠正部分由 RNN 的性质引起的生成的文本错误(如果您想知道为什么,请阅读前面链接的 RNN 博客以获取一些见识)。

在 2015 年和 2016 年,较新的语音识别系统使用了类似的端到端 RNN 方法,但将 CTC 层替换为基于注意力的模型,因此运行模型时不需要大型语言模型,因此可以在内存有限的移动设备上进行部署。 在本书的此版本中,我们将不会探讨这种可能性,而将介绍如何在移动应用中使用最新的高级 ASR 模型。 相反,我们将从一个更简单的语音识别模型开始,我们知道该模型肯定会在移动设备上很好地工作。

要将离线语音识别功能添加到移动应用,您还可以使用以下两个领先的开源语音识别项目之一:

  • CMU Sphinx 大约 20 年前开始,但仍在积极开发中。 要构建具有语音识别功能的 Android 应用,您可以使用其为 Android 构建的 PocketSphinx。 要构建具有语音识别功能的 iOS 应用,您可以使用 OpenEars 框架,这是一个免费的 SDK,在 iOS 应用中使用 CMU PocketSphinx 构建离线语音识别和文本转换。
  • Kaldi,成立于 2009 年,最近非常活跃,截至 2018 年 1 月,已有 165 个参与者。要在 Android 上进行尝试,您可以查看此博客文章。 对于 iOS,请查看在 iOS 上使用 Kaldi 的原型

由于这是一本关于在移动设备上使用 TensorFlow 的书,因此 TensorFlow 可用于为图像处理,语音处理和文本处理以及其他智能任务(本章其余部分的)构建强大的模型。 我们将重点介绍如何使用 TensorFlow 训练简单的语音识别模型并将其在移动应用中使用。

训练简单的命令识别模型

在本节中,我们将总结编写良好的 TensorFlow 简单音频识别教程中使用的步骤。 一些在训练模型时可能对您有帮助的提示。

我们将建立的简单语音命令识别模型将能够识别 10 个单词:"yes", "no", "up", "down", "left", "right", "on", "off", "stop", "go"; 它也可以检测沉默。 如果没有发现沉默,并且没有发现 10 个单词,它将生成“未知”。 稍后运行tensorflow/example/speech_commands/train.py脚本时,我们将下载语音命令数据集并用于训练模型,实际上除了这 10 个单词外,还包含 20 个单词:"zero", "two", "three", ..., "ten"(到目前为止,您已经看到的 20 个词称为核心词)和 10 个辅助词:"bed", "bird", "cat", "dog", "happy", "house", "marvin", "sheila", "tree", "wow"。 核心词比辅助词(约 1750)具有更多的.wav文件记录(约 2350)。

语音命令数据集是从开放语音记录站点收集的。您应该尝试一下,也许自己花些时间来录制自己的录音,以帮助改善录音效果,并在需要时了解如何收集自己的语音命令数据集。 关于使用数据集构建模型,还有一个 Kaggle 竞赛,您可以在此处了解有关语音模型和提示的更多信息。

在移动应用中要训练和使用的模型基于纸质卷积神经网络,用于小大小关键词发现,这与大多数其他基于 RNN 的大规模语音识别模型不同。 基于 CNN 的语音识别模型是可能的,但很有趣,因为对于简单的语音命令识别,我们可以在短时间内将音频信号转换为图像,或更准确地说,将频谱图转换为频率窗口期间音频信号的分布(有关使用wav_to_spectrogram脚本生成的示例频谱图图像,请参见本节开头的 TensorFlow 教程链接)。 换句话说,我们可以将音频信号从其原始时域表示转换为频域表示。 进行此转换的最佳算法是离散傅立叶变换DFT),快速傅立叶变换FFT)只是一种有效的选择 DFT 实现的算法。

作为移动开发人员,您可能不需要了解 DFT 和 FFT。 但是,您最好了解所有这些模型训练在移动应用中使用时是如何工作的,因为我们知道我们将要介绍的 TensorFlow 简单语音命令模型训练的幕后花絮,这是 FFT 的使用,前十大模型之一。当然,除其他事项外,20 世纪的算法使基于 CNN 的语音命令识别模型训练成为可能。 有关 DFT 的有趣且直观的教程,您可以阅读以下文章

现在,让我们执行以下步骤来训练简单语音命令识别模型:

  1. 在终端上,cd到您的 TensorFlow 源根,可能是~/tensorflow-1.4.0
  2. 只需运行以下命令即可下载我们之前讨论的语音命令数据集:
python tensorflow/examples/speech_commands/train.py

您可以使用许多参数:--wanted_words默认为以yes开头的 10 个核心词; 您可以使用此参数添加更多可以被模型识别的单词。 要训​​练自己的语音命令数据集,请使用--data_url --data_dir=<path_to_your_dataset>禁用语音命令数据集的下载并访问您自己的数据集,其中每个命令应命名为自己的文件夹,其中应包含 1000-2000 个音频剪辑,大约需要 1 秒钟的长度; 如果音频片段更长,则可以相应地更改--clip_duration_ms参数值。 有关更多详细信息,请参见train.py源代码和 TensorFlow 简单音频识别教程。

  1. 如果您接受train.py的所有默认参数,则在下载 1.48GB 语音命令数据集之后,在 GTX-1070 GPU 驱动的 Ubuntu 上,完成 18,000 个步骤的整个训练大约需要 90 分钟。 训练完成后,您应该在/tmp/speech_commands_train文件夹内看到检查点文件的列表,以及conv.pbtxt图定义文件和名为conv_labels.txt的标签文件,其中包含命令列表(与命令列表相同)。 --wanted_words参数是默认值或设置为,在文件的开头加上两个附加词_silence_unknown):
-rw-rw-r-- 1 jeff jeff 75437 Dec 9 21:08 conv.ckpt-18000.meta
-rw-rw-r-- 1 jeff jeff 433 Dec 9 21:08 checkpoint
-rw-rw-r-- 1 jeff jeff 3707448 Dec 9 21:08 conv.ckpt-18000.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 315 Dec 9 21:08 conv.ckpt-18000.index
-rw-rw-r-- 1 jeff jeff 75437 Dec 9 21:08 conv.ckpt-17900.meta
-rw-rw-r-- 1 jeff jeff 3707448 Dec 9 21:08 conv.ckpt-17900.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 315 Dec 9 21:08 conv.ckpt-17900.index
-rw-rw-r-- 1 jeff jeff 75437 Dec 9 21:07 conv.ckpt-17800.meta
-rw-rw-r-- 1 jeff jeff 3707448 Dec 9 21:07 conv.ckpt-17800.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 315 Dec 9 21:07 conv.ckpt-17800.index
-rw-rw-r-- 1 jeff jeff 75437 Dec 9 21:07 conv.ckpt-17700.meta
-rw-rw-r-- 1 jeff jeff 3707448 Dec 9 21:07 conv.ckpt-17700.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 315 Dec 9 21:07 conv.ckpt-17700.index
-rw-rw-r-- 1 jeff jeff 75437 Dec 9 21:06 conv.ckpt-17600.meta
-rw-rw-r-- 1 jeff jeff 3707448 Dec 9 21:06 conv.ckpt-17600.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 315 Dec 9 21:06 conv.ckpt-17600.index
-rw-rw-r-- 1 jeff jeff 60 Dec 9 19:41 conv_labels.txt
-rw-rw-r-- 1 jeff jeff 121649 Dec 9 19:41 conv.pbtxt

conv_labels.txt包含以下命令:

_silence_
_unknown_
yes
no
up
down
left
right
on
off
stop
go

现在运行以下命令,将图定义文件和检查点文件组合成一个我们可以在移动应用中使用的模型文件:

python tensorflow/examples/speech_commands/freeze.py \
--start_checkpoint=/tmp/speech_commands_train/conv.ckpt-18000 \
--output_file=/tmp/speech_commands_graph.pb
  1. (可选)在移动应用中部署speech_commands_graph.pb模型文件之前,可以使用以下命令对其进行快速测试:
python tensorflow/examples/speech_commands/label_wav.py  \
--graph=/tmp/speech_commands_graph.pb \
--labels=/tmp/speech_commands_train/conv_labels.txt \
--wav=/tmp/speech_dataset/go/9d171fee_nohash_1.wav

您将看到类似以下的输出:

go (score = 0.48427)
no (score = 0.17657)
_unknown_ (score = 0.08560)
  1. 使用summarize_graph工具查找输入节点和输出节点的名称:
bazel-bin/tensorflow/tools/graph_transforms/summarize_graph --in_graph=/tmp/speech_commands_graph.pb

输出应如下所示:

Found 1 possible inputs: (name=wav_data, type=string(7), shape=[]) 
No variables spotted.
Found 1 possible outputs: (name=labels_softmax, op=Softmax) 

不幸的是,它仅对于输出名称是正确的,并且不显示其他可能的输入。 使用tensorboard --logdir /tmp/retrain_logs,然后在浏览器中打开http://localhost:6006与图进行交互也无济于事。 但是,前面各章中显示的小代码段可以帮助您了解输入和输出名称,以下内容与 iPython 进行了交互:

In [1]: import tensorflow as tf
In [2]: g=tf.GraphDef()
In [3]: g.ParseFromString(open("/tmp/speech_commands_graph.pb","rb").read())
In [4]: x=[n.name for n in g.node]
In [5]: x
Out[5]: 
[u'wav_data',
 u'decoded_sample_data',
 u'AudioSpectrogram',
 ...
 u'MatMul',
 u'add_2',
 u'labels_softmax']

因此,我们看到wav_datadecoded_sample_data都是可能的输入。 如果在freeze.py文件中看不到注释,我们就必须深入研究模型训练代码,以准确找出应该使用的输入名称:“结果图包含一个名为 WAV 的编码数据输入 wav_data,用于原始 PCM 数据(在 -1.0 到 1.0 范围内浮动)的一种称为decoded_sample_data,输出称为labels_softmax。” 实际上,在该模型的情况下,有一个 TensorFlow Android 示例应用,这是我们在第 1 章,“移动 TensorFlow 入门”中看到的一部分,称为 TF 语音,专门定义了那些输入名称和输出名称。 在本书后面的几章中,您将看到如何在需要时借助或不借助我们的三种方法来查找模型训练的源代码,以找出关键的输入和输出节点名称。 或者希望,当您阅读本书时,TensorFlow summarize_graph工具将得到改进,以为我们提供准确的输入和输出节点名称。

现在是时候在移动应用中使用我们的热门新模型了。

在 Android 中使用简单的语音识别模型

位于tensorflow/example/android的用于简单语音命令识别的 TensorFlow Android 示例应用具有在SpeechActivity.java文件中进行音频记录和识别的代码,假定该应用需要始终准备好接受新的音频命令。 尽管在某些情况下这确实是合理的,但它导致的代码比仅在用户按下按钮后才进行记录和识别的代码要复杂得多,例如 Apple 的 Siri 的工作方式。 在本部分中,我们将向您展示如何创建新的 Android 应用并添加尽可能少的代码来记录用户的语音命令并显示识别结果。 这应该可以帮助您更轻松地将模型集成到自己的 Android 应用中。 但是,如果您需要处理语音命令应始终自动记录和识别的情况,则应查看 TensorFlow 示例 Android 应用。

使用模型构建新应用

执行以下步骤来构建一个完整的新 Android 应用,该应用使用我们在上一节中构建的speech_commands_graph.pb模型:

  1. 通过接受前面几章中的所有默认设置,创建一个名为AudioRecognition的新 Android 应用,然后将compile 'org.tensorflow:tensorflow-android:+'行添加到应用build.gradle文件依赖项的末尾。
  2. <uses-permission android:name="android.permission.RECORD_AUDIO" />添加到应用的AndroidManifest.xml文件中,以便可以允许该应用记录音频。
  3. 创建一个新的资产文件夹,然后将在上一节的步骤 2 和 3 中生成的speech_commands_graph.pbconv_actions_labels.txt文件拖放到assets文件夹中。
  4. 更改activity_main.xml文件以容纳三个 UI 元素。 第一个是用于识别结果显示的TextView
<TextView
    android:id="@+id/textview"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text=""
    android:textSize="24sp"
    android:textStyle="bold"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

第二个TextView将显示上一节第 2 步中使用train.py Python 程序训练的 10 个默认命令:

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="yes no up down left right on off stop go"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintHorizontal_bias="0.50"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="0.25" />

最后一个 UI 元素是一个按钮,在点击该按钮时,它会开始录音一秒钟,然后将录音发送到我们的模型以进行识别:

<Button
    android:id="@+id/button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Start"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintHorizontal_bias="0.50"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="0.8" />
  1. 打开MainActivity.java,首先创建MainActivity implements Runnable类。 然后添加以下常量,以定义模型名称,标签名称,输入名称和输出名称:
private static final String MODEL_FILENAME = "file:///android_asset/speech_commands_graph.pb";
private static final String LABEL_FILENAME = "file:///android_asset/conv_actions_labels.txt";
private static final String INPUT_DATA_NAME = "decoded_sample_data:0";
private static final String INPUT_SAMPLE_RATE_NAME = "decoded_sample_data:1";
private static final String OUTPUT_NODE_NAME = "labels_softmax";
  1. 声明四个实例变量:
private TensorFlowInferenceInterface mInferenceInterface;
private List<String> mLabels = new ArrayList<String>();
private Button mButton;
private TextView mTextView;
  1. onCreate方法中,我们首先实例化mButtonmTextView,然后设置按钮单击事件处理器,该事件处理器首先更改按钮标题,然后启动线程进行记录和识别:
mButton = findViewById(R.id.button);
mTextView = findViewById(R.id.textview);
mButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        mButton.setText("Listening...");
        Thread thread = new Thread(MainActivity.this);
        thread.start();
    }
});

onCreate方法的末尾,我们逐行读取标签文件的内容,并将每一行保存在mLabels数组列表中。

  1. public void run()方法的开头(单击“开始”按钮时开始),添加代码,该代码首先获得用于创建 Android AudioRecord对象的最小缓冲区大小,然后使用buffersize创建新的AudioRecord实例具有 16,000 SAMPLE_RATE和 16 位单声道格式,这是我们模型所期望的原始音频的类型,并最终从AudioRecord实例开始记录:
int bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
AudioRecord record = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize);

if (record.getState() != AudioRecord.STATE_INITIALIZED) return;
record.startRecording();

Android 中有两个用于记录音频的类:MediaRecorderAudioRecordMediaRecorderAudioRecord更易于使用,但是它会保存压缩的音频文件,直到 Android API Level 24(Android 7.0)为止,该 API 支持录制未经处理的原始音频。 根据这里,截至 2018 年 1 月,市场上有 70% 以上的 Android 设备仍在运行 7.0 或更早的 Android 版本。 您可能不希望将应用定位到 Android 7.0 或更高版本。 另外,要解码由MediaRecorder录制的压缩音频,您必须使用MediaCodec,使用起来非常复杂。 AudioRecord尽管是一个低级的 API,但实际上非常适合记录未处理的原始数据,然后将其发送到语音命令识别模型进行处理。

  1. 创建两个由 16 位短整数组成的数组audioBufferrecordingBuffer,对于 1 秒记录,每次AudioRecord对象读取并填充audioBuffer数组后,实际读取的数据都会附加到 recordingBuffer
long shortsRead = 0;
int recordingOffset = 0;
short[] audioBuffer = new short[bufferSize / 2];
short[] recordingBuffer = new short[RECORDING_LENGTH];
while (shortsRead < RECORDING_LENGTH) { // 1 second of recording
    int numberOfShort = record.read(audioBuffer, 0, audioBuffer.length);
    shortsRead += numberOfShort;
    System.arraycopy(audioBuffer, 0, recordingBuffer, recordingOffset, numberOfShort);
    recordingOffset += numberOfShort;
}
record.stop();
record.release();
  1. 录制完成后,我们首先将按钮标题更改为Recognizing
runOnUiThread(new Runnable() {
    @Override
    public void run() {
        mButton.setText("Recognizing...");
    }
});

然后将recordingBuffer短数组转换​​为float数组,同时使float数组的每个元素都在 -1.0 和 1.0 的范围内,因为我们的模型期望在-之间浮动 1.0 和 1.0:

float[] floatInputBuffer = new float[RECORDING_LENGTH];
for (int i = 0; i < RECORDING_LENGTH; ++i) {
    floatInputBuffer[i] = recordingBuffer[i] / 32767.0f;
}
  1. 如前几章所述,创建一个新的TensorFlowInferenceInterface,然后使用两个输入节点的名称和值调用其feed方法,其中一个是采样率,另一个是存储在floatInputBuffer中的原始音频数据 ]数组:
AssetManager assetManager = getAssets();
mInferenceInterface = new TensorFlowInferenceInterface(assetManager, MODEL_FILENAME);

int[] sampleRate = new int[] {SAMPLE_RATE};
mInferenceInterface.feed(INPUT_SAMPLE_RATE_NAME, sampleRate);

mInferenceInterface.feed(INPUT_DATA_NAME, floatInputBuffer, RECORDING_LENGTH, 1);

之后,我们调用run方法在模型上运行识别推理,然后fetch输出 10 个语音命令中每个命令的输出分数以及“未知”和“沉默”输出:

String[] outputScoresNames = new String[] {OUTPUT_NODE_NAME};
mInferenceInterface.run(outputScoresNames);

float[] outputScores = new float[mLabels.size()];
mInferenceInterface.fetch(OUTPUT_NODE_NAME, outputScores);
  1. outputScores数组与mLabels列表匹配,因此我们可以轻松找到最高得分并获取其命令名称:
float max = outputScores[0];
int idx = 0;
for (int i=1; i<outputScores.length; i++) {
    if (outputScores[i] > max) {
        max = outputScores[i];
        idx = i;
    }
}
final String result = mLabels.get(idx);

最后,我们在TextView中显示结果,并将按钮标题更改回"Start",以便用户可以再次开始记录和识别语音命令:

runOnUiThread(new Runnable() {
    @Override
    public void run() {
        mButton.setText("Start");
        mTextView.setText(result);
    }
});

显示模型驱动的识别结果

现在,在您的 Android 设备上运行该应用。 您将看到如图 5.1 所示的初始屏幕:

图 5.1:应用启动后显示初始屏幕

点击START按钮,然后开始说上面显示的 10 个命令之一。 您将看到按钮标题更改为“监听...”,然后是“识别...”,如图 5.2 所示:

图 5.2:监听录制的音频并识别录制的音频

识别结果几乎实时显示在屏幕中间,如图 5.3 所示:

图 5.3:显示识别的语音命令

整个识别过程几乎立即完成,用于识别的speech_commands_graph.pb模型仅为 3.7MB。 当然,它仅支持 10 条语音命令,但是即使使用train.py脚本的 --wanted_words参数或您自己的数据集支持数十个命令,大小也不会发生太大变化,正如我们在训练部分中讨论的那样。

诚然,此处的应用屏幕截图并不像上一章中那样生动有趣(一张图片价值一千个单词),但是语音识别当然可以做艺术家不能做的事情,例如发出语音命令来控制机器人的运动。

该应用的完整源代码位于 Github 上该书的源代码存储库的Ch5/android文件夹中。 现在让我们看看如何使用该模型构建 iOS 应用,其中涉及一些复杂的 TensorFlow iOS 库构建和音频数据准备步骤,以使模型正确运行。

通过 Objective-C 在 iOS 中使用简单的语音识别模型

如果您已经阅读了前三章中的 iOS 应用,那么您可能更喜欢使用手动构建的 TensorFlow iOS 库而不是 TensorFlow 实验窗格,就像使用手动库方法一样,您可以更好地控制可以添加哪些 TensorFlow 操作来使您的模型满意,这也是我们决定专注于 TensorFlow Mobile 而不是第 1 章,“移动 TensorFlow”的 TensorFlow Lite 的原因之一。

因此,尽管您可以在阅读本书时尝试使用 TensorFlow Pod,以查看 Pod 是否已更新以支持模型中使用的所有操作,但从现在开始,我们将始终使用手动构建的 TensorFlow 库( 请参见 iOS 应用中第 3 章,“检测对象及其位置”的“在 iOS 中使用对象检测模型的”部分的步骤 1 和 2)。

使用模型构建新应用

现在执行以下步骤来创建一个新的 iOS 应用以使用语音命令识别模型:

  1. 在 Xcode 中创建一个名为 AudioRecognition 的新 Objective-C 应用,并将项目设置为使用 TensorFlow 手动构建的库,如“以惊人的艺术样式迁移图片”的步骤 1 中所述。 还将AudioToolbox.frameworkAVFoundation.frameworkAccelerate.framework添加到目标的带库的链接二进制文件。

  2. speech_commands_graph.pb模型文件拖放到项目中。

  3. ViewController.m的扩展名更改为mm,然后添加音频记录和处理所使用的以下标头:

#import <AVFoundation/AVAudioRecorder.h>
#import <AVFoundation/AVAudioSettings.h>
#import <AVFoundation/AVAudioSession.h>
#import <AudioToolbox/AudioToolbox.h>

还添加 TensorFlow 的标头:

#include <fstream>
#include "tensorflow/core/framework/op_kernel.h"
#include "tensorflow/core/framework/tensor.h"
#include "tensorflow/core/public/session.h"

现在,定义一个音频SAMPLE_RATE常量,一个指向浮点数组的 C 指针,该数组保存将要发送到模型的音频数据,我们的关键audioRecognition函数签名以及两个属性,其中包含记录的文件路径和一个 iOS AVAudioRecorder实例。 我们还需要让ViewController实现AudioRecorderDelegate,以便它知道录制何时结束:

const int SAMPLE_RATE = 16000;
float *floatInputBuffer;
std::string audioRecognition(float* floatInputBuffer, int length);
@interface ViewController () <AVAudioRecorderDelegate>
@property (nonatomic, strong) NSString *recorderFilePath;
@property (nonatomic, strong) AVAudioRecorder *recorder;
@end

在此,我们不会显示以编程方式创建两个 UI 元素的代码段:一个按钮,当您点击该按钮时,它将开始录制 1 秒钟的音频,然后将音频发送到我们的模型以进行识别,以及一个显示识别结果的标签。 但是,我们将在下一部分中的 Swift 中展示一些 UI 代码以供复习。

  1. 在按钮的UIControlEventTouchUpInside处理器内,我们首先创建一个AVAudioSession实例,并将其类别设置为记录并将其激活:
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
NSError *err = nil;
[audioSession setCategory:AVAudioSessionCategoryPlayAndRecord error:&err];
if(err){
    NSLog(@"audioSession: %@", [[err userInfo] description]);
    return;
}

[audioSession setActive:YES error:&err];
if(err){
    NSLog(@"audioSession: %@", [[err userInfo] description]);
    return;
}

然后创建一个记录设置字典:

NSMutableDictionary *recordSetting = [[NSMutableDictionary alloc] init];
[recordSetting setValue:[NSNumber numberWithInt:kAudioFormatLinearPCM] forKey:AVFormatIDKey];
[recordSetting setValue:[NSNumber numberWithFloat:SAMPLE_RATE] forKey:AVSampleRateKey];
[recordSetting setValue:[NSNumber numberWithInt: 1] forKey:AVNumberOfChannelsKey];
[recordSetting setValue :[NSNumber numberWithInt:16] forKey:AVLinearPCMBitDepthKey];
[recordSetting setValue :[NSNumber numberWithBool:NO] forKey:AVLinearPCMIsBigEndianKey];
[recordSetting setValue :[NSNumber numberWithBool:NO] forKey:AVLinearPCMIsFloatKey];
[recordSetting setValue:[NSNumber numberWithInt:AVAudioQualityMax] forKey:AVEncoderAudioQualityKey];

最后,在按钮点击处理器中,我们定义保存录制的音频的位置,创建AVAudioRecorder实例,设置其委托并开始录制 1 秒钟:

self.recorderFilePath = [NSString stringWithFormat:@"%@/recorded_file.wav", [NSHomeDirectory() stringByAppendingPathComponent:@"tmp"]];
NSURL *url = [NSURL fileURLWithPath:_recorderFilePath];
err = nil;
_recorder = [[ AVAudioRecorder alloc] initWithURL:url settings:recordSetting error:&err];
if(!_recorder){
    NSLog(@"recorder: %@", [[err userInfo] description]);
    return;
}
[_recorder setDelegate:self];
[_recorder prepareToRecord];
[_recorder recordForDuration:1];
  1. AVAudioRecorderDelegateaudioRecorderDidFinishRecording的委托方法中,我们使用 Apple 的扩展音频文件服务,该服务用于读写压缩和线性 PCM 音频文件,以加载记录的音频,并将其转换为模型所需的格式, 并将音频数据读入存储器。 我们在这里不会显示这部分代码,它主要基于此博客。 在此处理之后,floatInputBuffer指向原始音频样本。 现在,我们可以将数据传递到工作线程中的audioRecognition方法中,并在 UI 线程中显示结果:
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    std::string command = audioRecognition(floatInputBuffer, totalRead);
    delete [] floatInputBuffer;
    dispatch_async(dispatch_get_main_queue(), ^{
        NSString *cmd = [NSString stringWithCString:command.c_str() encoding:[NSString defaultCStringEncoding]];
        [_lbl setText:cmd];
        [_btn setTitle:@"Start" forState:UIControlStateNormal];
    });
});
  1. audioRecognition方法内部,我们首先定义一个 C++ string数组,其中包含要识别的 10 个命令以及两个特殊值"_silence_""_unknown_"
std::string commands[] = {"_silence_", "_unknown_", "yes", "no", "up", "down", "left", "right", "on", "off", "stop", "go"};

在完成标准 TensorFlow SessionStatusGraphDef设置后(如我们在前几章的 iOS 应用中所做的那样),我们读出了模型文件,并尝试使用它创建 TensorFlow Session

NSString* network_path = FilePathForResourceName(@"speech_commands_graph", @"pb");

PortableReadFileToProto([network_path UTF8String], &tensorflow_graph);

tensorflow::Status s = session->Create(tensorflow_graph);
if (!s.ok()) {
    LOG(ERROR) << "Could not create TensorFlow Graph: " << s;
    return "";
}

如果成功创建了会话,则为模型定义两个输入节点名称和一个输出节点名称:

std::string input_name1 = "decoded_sample_data:0";
std::string input_name2 = "decoded_sample_data:1";
std::string output_name = "labels_softmax";
  1. 对于"decoded_sample_data:0",我们需要将采样率值作为标量发送(否则在调用 TensorFlow Sessionrun方法时会出错),并且在 TensorFlow C++ API 中定义了张量,如下所示:
tensorflow::Tensor samplerate_tensor(tensorflow::DT_INT32, tensorflow::TensorShape());
samplerate_tensor.scalar<int>()() = SAMPLE_RATE;

对于 "decoded_sample_data:1",需要将浮点数中的音频数据从floatInputBuffer数组转换为 TensorFlow audio_tensor张量,其方式类似于前几章的image_tensor的定义和设置方式:

tensorflow::Tensor audio_tensor(tensorflow::DT_FLOAT, tensorflow::TensorShape({length, 1}));
auto audio_tensor_mapped = audio_tensor.tensor<float, 2>();
float* out = audio_tensor_mapped.data();
for (int i = 0; i < length; i++) {
    out[i] = floatInputBuffer[i];
}

现在我们可以像以前一样使用输入来运行模型并获取输出:

std::vector<tensorflow::Tensor> outputScores;
tensorflow::Status run_status = session->Run({{input_name1, audio_tensor}, {input_name2, samplerate_tensor}},{output_name}, {}, &outputScores);
if (!run_status.ok()) {
    LOG(ERROR) << "Running model failed: " << run_status;
    return "";
}
  1. 我们对模型的outputScores输出进行简单的解析,然后返回最高分。 outputScores是 TensorFlow 张量的向量,其第一个元素包含 12 个可能的识别结果的 12 个得分值。 可以通过flat方法访问这 12 个得分值,并检查最大得分:
tensorflow::Tensor* output = &outputScores[0];
const Eigen::TensorMap<Eigen::Tensor<float, 1, Eigen::RowMajor>, Eigen::Aligned>& prediction = output->flat<float>();
const long count = prediction.size();
int idx = 0;
float max = prediction(0);
for (int i = 1; i < count; i++) {
    const float value = prediction(i);
    printf("%d: %f", i, value);
    if (value > max) {
        max = value;
        idx = i;
    }
}

return commands[idx];

在应用可以录制任何音频之前,您需要做的另一件事是在应用的Info.plist文件中创建一个新的隐私-麦克风使用说明属性,并将该属性的值设置为诸如“听到并识别” 您的语音命令”。

现在,在 iOS 模拟器上运行该应用(如果您的 Xcode 版本早于 9.2,而 iOS 模拟器版本早于 10.0,则您可能必须在实际的 iOS 设备上运行该应用,因为您可能无法在 iOS 或 iPhone 模拟器(10.0 之前的版本)中录制音频,您将首先看到带有 Start 按钮位于中间的初始屏幕,然后点击该按钮并说出 10 个命令之一,识别结果应出现在顶部 ,如图 5.4 所示:

图 5.4:显示初始画面和识别结果

是的,应该会出现识别结果,但实际上不会出现,因为在 Xcode 输出窗格中会出现错误:

Could not create TensorFlow Graph: Not found: Op type not registered 'DecodeWav' in binary running on XXX's-MacBook-Pro.local. Make sure the Op and Kernel are registered in the binary running in this process.

使用tf_op_files.txt修复模型加载错误

我们已经在前面的章节中看到了这种臭名昭著的错误,除非您知道它的真正含义,否则弄清楚该修复程序可能要花很多时间。 TensorFlow 操作由两部分组成:位于 tensorflow/core/ops文件夹中的称为ops的定义(这有点令人困惑,因为操作既可以表示其定义,其实现,也可以表示其定义)。 和位于 tensorflow/core/kernels文件夹中的实现(称为内核)。 tensorflow/contrib/makefile文件夹中有一个名为tf_op_files.txt的文件,其中列出了在手动构建库时需要内置到 TensorFlow iOS 库中的操作的定义和实现。 tf_op_files.txt文件应该包含所有操作定义文件,如为 TensorFlow 移动部署准备模型,因为它们占用的空间很小。 但从 TensorFlow 1.4 或 1.5 开始,tf_op_files.txt文件中并未包含所有操作的操作定义。 因此,当我们看到“未注册操作类型”错误时,我们需要找出哪个操作定义和实现文件负责该操作。 在我们的情况下,操作类型名为DecodeWav。 我们可以运行以下两个 Shell 命令来获取信息:

$ grep 'REGISTER.*"DecodeWav"' tensorflow/core/ops/*.cc
tensorflow/core/ops/audio_ops.cc:REGISTER_OP("DecodeWav")

$ grep 'REGISTER.*"DecodeWav"' tensorflow/core/kernels/*.cc
tensorflow/core/kernels/decode_wav_op.cc:REGISTER_KERNEL_BUILDER(Name("DecodeWav").Device(DEVICE_CPU), DecodeWavOp);

在 TensorFlow 1.4 的 tf_op_files.txt文件中,已经有一行文本tensorflow/core/kernels/decode_wav_op.cc,但可以肯定的是tensorflow/core/ops/audio_ops.cc丢失了。 我们需要做的就是在tf_op_files.txt文件中的任意位置添加一行tensorflow/core/ops/audio_ops.cc,并像在第 3 章,“检测对象及其位置”中一样运行tensorflow/contrib/makefile/build_all_ios.sh,以重建 TensorFlow iOS 库。 然后再次运行 iOS 应用,并继续轻按启动按钮,然后说出语音命令以识别或误解,直到您无聊为止。

本章将重点介绍如何解决Not found: Op type not registered错误的过程,因为将来在其他 TensorFlow 模型上工作时,可以节省大量时间。

但是,在继续学习下一章中将介绍和使用另一种新的 TensorFlow AI 模型之前,让我们给其他喜欢使用更新的且至少对他们更凉快的 Swift 语言的 iOS 开发人员一些考虑。

通过 Swift 在 iOS 中使用简单的语音识别模型

我们在第 2 章中使用 TensorFlow 窗格创建了一个基于 Swift 的 iOS 应用。 现在让我们创建一个新的 Swift 应用,该应用使用我们在上一节中手动构建的 TensorFlow iOS 库,并在我们的 Swift 应用中使用语音命令模型:

  1. 通过 Xcode 创建一个新的“Single View iOS”项目,并按照与上一节中的步骤 1 和 2 相同的方式设置该项目,除了将语言设置为 Swift。
  2. 选择 Xcode “文件 | 新增 | 文件 ...”,然后选择 Objective-C 文件。 输入名称RunInference。 您将看到一个消息框,询问您“您是否要配置一个 Objective-C 桥接头?” 单击创建桥接标题。 将文件RunInference.m重命名为RunInfence.mm,因为我们将混合使用 C,C++ 和 Objective-C 代码来进行后期录音音频处理和识别。 我们仍在 Swift 应用中使用 Objective-C,因为要从 Swift 调用 TensorFlow C++ 代码,我们需要一个 Objective-C 类作为 C++ 代码的包装。
  3. 创建一个名为RunInference.h的头文件,并添加以下代码:
@interface RunInference_Wrapper : NSObject
- (NSString *)run_inference_wrapper:(NSString*)recorderFilePath;
@end

现在,您在 Xcode 中的应用应类似于图 5.5:

图 5.5:基于 Swift 的 iOS 应用项目

  1. 打开ViewController.swift。 在import UIKit之后的顶部添加以下代码:
import AVFoundation

let _lbl = UILabel()
let _btn = UIButton(type: .system)
var _recorderFilePath: String!

然后使ViewController看起来像这样(未显示为_btn_lbl定义NSLayoutConstraint并调用addConstraint的代码段):

class ViewController: UIViewController, AVAudioRecorderDelegate {
    var audioRecorder: AVAudioRecorder!
override func viewDidLoad() {
    super.viewDidLoad()

    _btn.translatesAutoresizingMaskIntoConstraints = false
    _btn.titleLabel?.font = UIFont.systemFont(ofSize:32)
    _btn.setTitle("Start", for: .normal)
    self.view.addSubview(_btn)

    _btn.addTarget(self, action:#selector(btnTapped), for: .touchUpInside)

    _lbl.translatesAutoresizingMaskIntoConstraints = false
    self.view.addSubview(_lbl)
  1. 添加一个按钮点击处理器,并在其内部,首先请求用户的录制许可:
@objc func btnTapped() {
    _lbl.text = "..."
    _btn.setTitle("Listening...", for: .normal)

    AVAudioSession.sharedInstance().requestRecordPermission () {
        [unowned self] allowed in
        if allowed {
            print("mic allowed")
        } else {
            print("denied by user")
            return
        }
    }

然后创建一个AudioSession实例,并将其类别设置为记录,并将状态设置为活动,就像我们在 Objective-C 版本中所做的一样:

let audioSession = AVAudioSession.sharedInstance()

do {
    try audioSession.setCategory(AVAudioSessionCategoryRecord)
    try audioSession.setActive(true)
} catch {
    print("recording exception")
    return
}

现在定义AVAudioRecorder要使用的设置:

let settings = [
    AVFormatIDKey: Int(kAudioFormatLinearPCM),
    AVSampleRateKey: 16000,
    AVNumberOfChannelsKey: 1,
    AVLinearPCMBitDepthKey: 16,
    AVLinearPCMIsBigEndianKey: false,
    AVLinearPCMIsFloatKey: false,
    AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
    ] as [String : Any]

设置文件路径以保存录制的音频,创建AVAudioRecorder实例,设置其委托并开始录制 1 秒钟:

do {
    _recorderFilePath = NSHomeDirectory().stringByAppendingPathComponent(path: "tmp").stringByAppendingPathComponent(path: "recorded_file.wav")
    audioRecorder = try AVAudioRecorder(url: NSURL.fileURL(withPath: _recorderFilePath), settings: settings)
    audioRecorder.delegate = self
    audioRecorder.record(forDuration: 1)
} catch let error {
    print("error:" + error.localizedDescription)
}
  1. ViewController.swift的末尾,添加具有以下实现的AVAudioRecorderDelegate方法audioRecorderDidFinishRecording,该实现主要调用run_inference_wrapper进行音频后处理和识别:
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
    _btn.setTitle("Recognizing...", for: .normal)
    if flag {
        let result = RunInference_Wrapper().run_inference_wrapper(_recorderFilePath)
        _lbl.text = result
    }
    else {
        _lbl.text = "Recording error"
    }
    _btn.setTitle("Start", for: .normal)
}

AudioRecognition_Swift-Bridging-Header.h文件中,添加#include "RunInference.h",以便前面的 Swift 代码RunInference_Wrapper().run_inference_wrapper(_recorderFilePath)起作用。

  1. run_inference_wrapper方法内的RunInference.mm中,从 Objective-C AudioRecognition应用中的ViewController.mm复制代码,如上一节的步骤 5-8 所述,该代码将保存的录制音频转换为格式 TensorFlow 模型接受模型,然后将其与采样率一起发送给模型以获取识别结果:
@implementation RunInference_Wrapper
- (NSString *)run_inference_wrapper:(NSString*)recorderFilePath {
...
}

如果您确实想将尽可能多的代码移植到 Swift,则可以用 Swift 替换 C 中的音频文件转换代码。 还有一些非官方的开源项目提供了官方 TensorFlow C++ API 的 Swift 包装器。 但是为了简单起见和达到适当的平衡,我们将保持 TensorFlow 模型的推论,在本示例中,还将保持音频文件的读取和转换,以及在 C++ 和 Objective-C 中与控制 UI 和录音,并启动调用来进行音频处理和识别。

这就是构建使用语音命令识别模型的 Swift iOS 应用所需的全部内容。 现在,您可以在 iOS 模拟器或实际设备上运行它,并看到与 Objective-C 版本完全相同的结果。

总结

在本章中,我们首先快速概述了语音识别以及如何使用端到端深度学习方法构建现代 ASR 系统。 然后,我们介绍了如何训练 TensorFlow 模型以识别简单的语音命令,并介绍了如何在 Android 应用以及基于 Objective-C 和 Swift 的 iOS 应用中使用该模型的分步教程。 我们还讨论了如何通过找出丢失的 TensorFlow 操作或内核文件,添加它并重建 TensorFlow iOS 库来修复 iOS 中常见的模型加载错误。

ASR 用于将语音转换为文本。 在下一章中,我们将探讨另一个将文本作为输出的模型,并且文本中将包含完整的自然语言句子,而不是本章中的简单命令。 我们将介绍如何构建模型以将图像,我们的老朋友转换为文本,以及如何在移动应用中使用该模型。 观察和描述您在自然语言中看到的内容需要真正的人类智慧。 福尔摩斯是完成这项任务的最佳人选之一。 我们当然还不如福尔摩斯,但是让我们看看如何开始。

六、用自然语言描述图像

如果图像分类和物体检测是明智的任务,那么用自然语言描述图像绝对是一项更具挑战性的任务,需要更多的智能-请片刻考虑一下每个人如何从新生儿成长(他们学会了识别物体并检测它们的位置)到三岁的孩子(他们学会讲述图片故事)。 用自然语言描述图像的任务的正式术语是图像标题。 与具有长期研究和发展历史的语音识别不同,图像字幕(具有完整的自然语言,而不仅仅是关键词输出)由于其复杂性和 2012 年的深度学习突破而仅经历了短暂而令人兴奋的研究历史。

在本章中,我们将首先回顾基于深度学习的图像字幕模型如何赢得 2015 年 Microsoft COCO(大规模对象检测,分割和字幕数据集),我们在第 3 章,“检测对象及其位置”中简要介绍了该有效模型。 然后,我们将总结在 TensorFlow 中训练模型的步骤,并详细介绍如何准备和优化要在移动设备上部署的复杂模型。 之后,我们将向您展示有关如何构建 iOS 和 Android 应用以使用该模型生成描述图像的自然语言语句的分步教程。 由于该模型同时涉及计算机视觉和自然语言处理,因此您将首次看到两种主要的深度神经网络架构 CNN 和 RNN 如何协同工作,以及如何编写 iOS 和 Android 代码以访问经过训练的网络并进行多个推理。 总而言之,我们将在本章介绍以下主题:

  • 图像字幕 -- 工作原理
  • 训练和冻结图像字幕模型
  • 转换和优化图像字幕模型
  • 在 iOS 中使用图像字幕模型
  • 在 Android 中使用图像字幕模型

图像字幕 -- 工作原理

Show and Tell:从 2015 年 MSCOCO 图像字幕挑战赛中汲取的经验教训。 在讨论训练过程之前,TensorFlow 的 im2txt 模型文档网站中也对此进行了很好的介绍,让我们首先基本了解一下解模型的工作原理。 这也将帮助您了解 Python 中的训练和推理代码,以及本章稍后将介绍的 iOS 和 Android 中的推理代码。

获奖的 Show and Tell 模型是使用端到端方法进行训练的,类似于我们在上一章中简要介绍的最新的基于深度学习的语音识别模型。 它使用 MSCOCO 图像字幕 2014 数据集,可从这里下载,该数据集包含超过 82,000 个训练图像,并以描述它们的自然语言句子为目标。 训练模型以使为每个输入图像输出目标自然语言句子的可能性最大化。 与使用多个子系统的其他更复杂的训练方法不同,端到端方法优雅,简单,并且可以实现最新的结果。

为了处理和表示输入图像,Show and Tell 模型使用预训练的 Inception v3 模型,该模型与我们在第 2 章,“通过迁移学习对图像进行分类”所使用的相同。 Inception v3 CNN 网络的最后一个隐藏层用作输入图像的表示。 由于 CNN 模型的性质,较早的层捕获更多的基本图像信息,而较后的层捕获更高级的图像概念。 因此,通过使用输入图像的最后一个隐藏层来表示图像,我们可以更好地准备具有高级概念的自然语言输出。 毕竟,我们通常会开始用诸如“人”或“火车”之类的词来描述图片,而不是“带有尖锐边缘的东西”。

为了表示目标自然语言输出中的每个单词,使用了单词嵌入方法。 词嵌入只是词的向量表示。 TensorFlow 网站上有一个不错的教程,介绍如何构建模型来获取单词的向量表示。

现在,在既表示输入图像又表示输出单词的情况下(每个这样的单词对构成一个训练示例),给定的最佳训练模型可用于最大化在目标输出中生成每个单词w的概率,给定输入图像和该单词w之前的先前单词,它是 RNN 序列模型,或更具体地说,是长短期记忆LSTM)的 RNN 模型类型。 LSTM 以解决常规 RNN 模型固有的消失和爆炸梯度问题而闻名。 为了更好地了解 LSTM,您应该查看这个热门博客

梯度概念在反向传播过程中用于更新网络权重,因此它可以学习生成更好的输出。 如果您不熟悉反向传播过程,它是神经网络中最基本,功能最强大的算法之一,那么您绝对应该花些时间来理解它-只是 Google 的“反向传播”,排名前五的结果都不会令人失望。 消失的梯度意味着,在深度神经网络反向传播学习过程中,早期层中的网络权重几乎没有更新,因此网络永不收敛。 梯度爆炸意味着这些权重更新得过分疯狂,从而导致网络差异很大。 因此,如果某人头脑封闭,从不学习,或者某人对新事物疯狂而又失去兴趣就快,那么您就会知道他们似乎遇到了什么样的梯度问题。

训练后,可以将 CNN 和 LSTM 模型一起用于推理:给定输入图像,该模型可以估计每个单词的概率,从而预测最有可能为输出语句生成哪n个最佳单词; 然后,给定输入图像和n个最佳单词,可以生成n个最佳的下一个单词,然后继续进行,直到模型返回句子的特定结尾单词,或达到了生成的句子的指定单词长度(以防止模型过于冗长)时,我们得到一个完整的句子。

在每次生成单词时使用n个最佳单词(意味着在末尾具有n个最佳句子)被称为集束搜索。 当n(即集束大小)为 1 时,它仅基于模型返回的所有可能单词中的最高概率值,就成为贪婪搜索或最佳搜索。 TensorFlow im2txt 官方模型的下一部分中的训练和推理过程使用以 Python 实现的集束大小设置为 3 的集束搜索; 为了进行比较,我们将开发的 iOS 和 Android 应用使用更简单的贪婪或最佳搜索。 您将看到哪种方法可以生成更好的字幕。

训练和冻结图像字幕模型

在本部分中,我们将首先总结训练训练名为 im2txt 的 Show and Tell 模型的过程,该模型记录在这个页面中, 一些提示,以帮助您更好地了解该过程。 然后,我们将展示 im2txt 模型项目随附的 Python 代码的一些关键更改,以便冻结该模型以准备在移动设备上使用。

训练和测试字幕生成

如果您已按照第 3 章“检测对象及其位置”中的“设置 TensorFlow 对象检测 API”部分进行操作,那么您已经安装im2txt文件夹; 否则,只需将cd移至您的 TensorFlow 源根目录,然后运行:

 git clone https://github.com/tensorflow/models

您可能尚未安装的一个 Python 库是 自然语言工具包NLTK),这是最流行的用于自然语言处理的 Python 库之一。 只需访问其网站以获得安装说明。

现在,请按照以下步骤来训练模型:

  1. 通过打开终端并运行以下命令来设置保存 2014 MSCOCO 图像字幕训练和验证数据集的位置:
 MSCOCO_DIR="${HOME}/im2txt/data/mscoco" 

请注意,尽管 2014 年要下载和保存的原始数据集约为 20GB,但该数据集将转换为 TFRecord 格式(我们还在第 3 章 “检测对象及其位置”来转换对象检测数据集,这是运行以下训练脚本所需的,并添加了大约 100GB 数据。 因此,使用 TensorFlow im2txt 项目总共需要约 140GB 的训练自己的图像字幕模型。

  1. 转到您的 im2txt 源代码所在的位置,然后下载并处理 MSCOCO 数据集:
cd <your_tensorflow_root>/models/research/im2txt
bazel build //im2txt:download_and_preprocess_mscoco
bazel-bin/im2txt/download_and_preprocess_mscoco "${MSCOCO_DIR}"

download_and_preprocess_mscoco脚本完成后,您将在$MSCOCO_DIR文件夹中看到所有 TFRecord 格式的训练,验证和测试数据文件。

$MSCOCO_DIR文件夹中还生成了一个名为word_counts.txt 的文件。 它总共有 11,518 个单词,每行包含一个单词,一个空格以及该单词出现在数据集中的次数。 文件中仅保存计数等于或大于 4 的单词。 还保存特殊词,例如句子的开头和结尾(分别表示为<S></S> )。 稍后,您将看到我们如何在 iOS 和 Android 应用中专门使用和解析文件来生成字幕。

  1. 通过运行以下命令来获取 Inception v3 检查点文件:
INCEPTION_DIR="${HOME}/im2txt/data"
mkdir -p ${INCEPTION_DIR}
cd ${INCEPTION_DIR}
wget "http://download.tensorflow.org/models/inception_v3_2016_08_28.tar.gz"
tar -xvf inception_v3_2016_08_28.tar.gz -C ${INCEPTION_DIR}
rm inception_v3_2016_08_28.tar.gz

之后,您将在${HOME}/im2txt/data文件夹中看到一个名为inception_v3.ckpt的文件,如下所示:

jeff@AiLabby:~/im2txt/data$ ls -lt inception_v3.ckpt
-rw-r----- 1 jeff jeff 108816380 Aug 28  2016 inception_v3.ckpt
  1. 现在,我们准备使用以下命令来训练我们的模型:
INCEPTION_CHECKPOINT="${HOME}/im2txt/data/inception_v3.ckpt"
MODEL_DIR="${HOME}/im2txt/model"
cd <your_tensorflow_root>/models/research/im2txt
bazel build -c opt //im2txt/...
bazel-bin/im2txt/train \
 --input_file_pattern="${MSCOCO_DIR}/train-?????-of-00256" \
 --inception_checkpoint_file="${INCEPTION_CHECKPOINT}" \
 --train_dir="${MODEL_DIR}/train" \
 --train_inception=false \
 --number_of_steps=1000000

即使在 GPU 上(例如第 1 章, “移动 TensorFlow 入门”中设置的 Nvidia GTX 1070),整个步骤(在前面的--number_of_steps参数中指定)也会超过 5 个昼夜,因为运行 5 万步大约需要 6.5 个小时。 幸运的是,您很快就会看到,即使以大约 50K 的步长,图像字幕的结果也已经相当不错了。 另请注意,您可以随时取消train脚本,然后稍后重新运行它,该脚本将从最后保存的检查点开始; 默认情况下,检查点会每 10 分钟保存一次,因此在最坏的情况下,您只会损失 10 分钟的训练时间。

经过几个小时的训练,取消前面的train脚本,然后查看--train_dir指向的位置。 您将看到类似这样的内容(默认情况下,将保存五组检查点文件,但此处仅显示三组):

ls -lt $MODEL_DIR/train
-rw-rw-r-- 1 jeff jeff 2171543 Feb 6 22:17 model.ckpt-109587.meta
-rw-rw-r-- 1 jeff jeff 463 Feb 6 22:17 checkpoint
-rw-rw-r-- 1 jeff jeff 149002244 Feb 6 22:17 model.ckpt-109587.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 16873 Feb 6 22:17 model.ckpt-109587.index
-rw-rw-r-- 1 jeff jeff 2171543 Feb 6 22:07 model.ckpt-109332.meta
-rw-rw-r-- 1 jeff jeff 16873 Feb 6 22:07 model.ckpt-109332.index
-rw-rw-r-- 1 jeff jeff 149002244 Feb 6 22:07 model.ckpt-109332.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 2171543 Feb 6 21:57 model.ckpt-109068.meta
-rw-rw-r-- 1 jeff jeff 149002244 Feb 6 21:57 model.ckpt-109068.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 16873 Feb 6 21:57 model.ckpt-109068.index
-rw-rw-r-- 1 jeff jeff 4812699 Feb 6 14:27 graph.pbtxt

您可以告诉每 10 分钟生成一组检查点文件(model.ckpt-109068.*model.ckpt-109332.*model.ckpt-109587.*)。 graph.pbtxt是模型的图定义文件(以文本格式),model.ckpt-??????.meta文件还包含模型的图定义,以及特定检查点的其他一些元数据,例如model.ckpt-109587.data-00000-of-00001(请注意, 大小几乎为 150MB,因为所有网络参数都保存在此处)。

  1. 测试字幕生成,如下所示:
CHECKPOINT_PATH="${HOME}/im2txt/model/train"
VOCAB_FILE="${HOME}/im2txt/data/mscoco/word_counts.txt"
IMAGE_FILE="${HOME}/im2txt/data/mscoco/raw-data/val2014/COCO_val2014_000000224477.jpg"
bazel build -c opt //im2txt:run_inference
bazel-bin/im2txt/run_inference \
 --checkpoint_path=${CHECKPOINT_PATH} \
 --vocab_file=${VOCAB_FILE} \
 --input_files=${IMAGE_FILE}

CHECKPOINT_PATH被设置为与--train_dir被设置为相同的路径。 run_inference脚本将生成类似以下内容(不完全相同,具体取决于已执行了多少训练步骤):

Captions for image COCO_val2014_000000224477.jpg:
 0) a man on a surfboard riding a wave . (p=0.015135)
 1) a person on a surfboard riding a wave . (p=0.011918)
 2) a man riding a surfboard on top of a wave . (p=0.009856)

这很酷。 如果我们可以在智能手机上运行此模型,会不会更酷? 但是在此之前,由于模型的相对复杂性以及 Python 中trainrun_inference脚本的编写方式,我们还需要采取一些额外的步骤。

冻结图像字幕模型

在第 4 章,“转换具有惊人艺术风格的图片”,和第 5 章,“了解简单语音命令”中,我们使用了一个名为freeze.py的脚本的两个略有不同的版本,将受过训练的网络权重与网络图定义合并到一个自足的模型文件中,这是我们可以在移动设备上使用的好处。 TensorFlow 还带有freeze脚本的更通用版本,称为freeze_graph.py,位于tensorflow/python/tools文件夹中,可用于构建模型文件。 要使其正常运行,您需要为其提供至少四个参数(要查看所有可用参数,请查看 tensorflow/python/tools/freeze_graph.py):

  • --input_graph--input_meta_graph:模型的图定义文件。 例如,在上一节的第 4 步的命令ls -lt $MODEL_DIR/train的输出中,model.ckpt-109587.meta是一个元图文件,其中包含模型的图定义和其他与检查点相关的元数据,而graph.pbtxt只是模型的图定义。

  • --input_checkpoint :特定的检查点文件,例如 model.ckpt-109587 。 注意,您没有指定大型检查点文件 model.ckpt-109587.data-00000-of-00001 的完整文件名。

  • --output_graph:冻结模型文件的路径–这是在移动设备上使用的路径。

  • --output_node_names:输出节点名称列表,以逗号分隔,告诉freeze_graph工具冻结模型中应包括模型的哪一部分和权重,因此生成特定输出不需要的节点名称和权重将保留。

因此,对于该模型,我们如何找出必备的输出节点名称以及输入节点名称,这些对推理也至关重要,正如我们在上一章的 iOS 和 Android 应用中所见到的那样? 因为我们已经使用run_inference脚本来生成测试图像的标题,所以我们可以看到它是如何进行推理的。

转到您的 im2txt 源代码文件夹models/research/im2txt/im2txt:您可能想在一个不错的编辑器(例如 Atom 或 Sublime Text)中打开它,或者在 Python IDE(例如 PyCharm)中打开它。 在run_inference.py中,对inference_utils/inference_wrapper_base.py中的build_graph_from_config进行了调用,在inference_wrapper.py中调用了build_model,在show_and_tell_model.py中进一步调用了build方法。 最后,build方法将调用build_input方法,该方法具有以下代码:

if self.mode == "inference":
    image_feed = tf.placeholder(dtype=tf.string, shape=[], name="image_feed")
    input_feed = tf.placeholder(dtype=tf.int64,
        shape=[None], # batch_size
        name="input_feed")

还有build_model方法,它具有:

if self.mode == "inference":
    tf.concat(axis=1, values=initial_state, name="initial_state")
    state_feed = tf.placeholder(dtype=tf.float32,
        shape=[None, sum(lstm_cell.state_size)],
        name="state_feed")
...
tf.concat(axis=1, values=state_tuple, name="state")
...
tf.nn.softmax(logits, name="softmax")

因此,名为image_feedinput_feedstate_feed的三个占位符应该是输入节点名称,而initial_statestatesoftmax应当是输出节点名称。 此外,inference_wrapper.py中定义的两种方法证实了我们的侦探工作–第一种是:

  def feed_image(self, sess, encoded_image):
    initial_state = sess.run(fetches="lstm/initial_state:0",
                             feed_dict={"image_feed:0": encoded_image})
    return initial_state

因此,我们提供image_feed并返回initial_statelstm/前缀仅表示该节点在lstm范围内)。 第二种方法是:

 def inference_step(self, sess, input_feed, state_feed):
    softmax_output, state_output = sess.run(
        fetches=["softmax:0", "lstm/state:0"],
        feed_dict={
            "input_feed:0": input_feed,
            "lstm/state_feed:0": state_feed,
        })
    return softmax_output, state_output, None

我们输入input_feedstate_feed,然后返回softmaxstate。 总共三个输入节点名称和三个输出名称。

注意,仅当mode为“推断”时才创建这些节点,因为train.pyrun_inference.py都使用了 show_and_tell_model.py。 这意味着在运行run_inference.py脚本后,将修改在步骤 5 中使用train生成的--checkpoint_path中模型的图定义文件和权重。 那么,我们如何保存更新的图定义和检查点文件?

事实证明,在run_inference.py中,在创建 TensorFlow 会话后,还有一个调用restore_fn(sess)来加载检查点文件,并且该调用在inference_utils/inference_wrapper_base.py中定义:

def _restore_fn(sess):
      saver.restore(sess, checkpoint_path)

在启动run_inference.py之后到达saver.restore调用时,已进行了更新的图定义,因此我们可以在此处保存新的检查点和图文件,从而使_restore_fn函数如下:

 def _restore_fn(sess):
      saver.restore(sess, checkpoint_path)

      saver.save(sess, "model/image2text")
      tf.train.write_graph(sess.graph_def, "model", 'im2txt4.pbtxt')
      tf.summary.FileWriter("logdir", sess.graph_def) 

tf.train.write_graph(sess.graph_def, "model", 'im2txt4.pbtxt')行是可选的,因为当通过调用saver.save保存新的检查点文件时,也会生成一个元文件,freeze_graph.py可以将其与检查点文件一起使用。 但是对于那些希望以纯文本格式查看所有内容,或者在冻结模型时更喜欢使用带有--in_graph参数的图定义文件的人来说,它是在这里生成的。 最后一行tf.summary.FileWriter("logdir", sess.graph_def)也是可选的,但它会生成一个可由 TensorBoard 可视化的事件文件。 因此,有了这些更改,在再次运行run_inference.py之后(除非首先直接使用 Python 运行run_inference.py,否则请记住首先运行bazel build -c opt //im2txt:run_inference),您将在model目录中看到以下新的检查点文件和新的图定义文件:

jeff@AiLabby:~/tensorflow-1.5.0/models/research/im2txt$ ls -lt model
-rw-rw-r-- 1 jeff jeff 2076964 Feb 7 12:33 image2text.pbtxt
-rw-rw-r-- 1 jeff jeff 1343049 Feb 7 12:33 image2text.meta
-rw-rw-r-- 1 jeff jeff 77 Feb 7 12:33 checkpoint
-rw-rw-r-- 1 jeff jeff 149002244 Feb 7 12:33 image2text.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 16873 Feb 7 12:33 image2text.index

logdir目录中:

jeff@AiLabby:~/tensorflow-1.5.0/models/research/im2txt$ ls -lt logdir
total 2124
-rw-rw-r-- 1 jeff jeff 2171623 Feb 7 12:33 events.out.tfevents.1518035604.AiLabby

Running the bazel build command to build a TensorFlow Python script is optional. You can just run the Python script directly. For example, we can run python tensorflow/python/tools/freeze_graph.py without building it first with bazel build tensorflow/python/tools:freeze_graph then running bazel-bin/tensorflow/python/tools/freeze_graph. But be aware that running the Python script directly will use the version of TensorFlow you’ve installed via pip, which may be different from the version you’ve downloaded as source and built by the bazel build command. This can be the cause of some confusing errors so be sure you know the TensorFlow version used to run a script. In addition, for a C++ based tool, you have to build it first with bazel before you can run it. For example, the transform_graph tool, which we'll see soon, is implemented in transform_graph.cc located at tensorflow/tools/graph_transforms; another important tool called convert_graphdef_memmapped_format, which we'll use for our iOS app later, is also implemented in C++ located at tensorflow/contrib/util.

现在我们到了,让我们快速使用 TensorBoard 看一下我们的图–只需运行tensorboard --logdir logdir,然后从浏览器中打开http://localhost:6006。 图 6.1 显示了三个输出节点名称(顶部为softmax,以及lstm/initial_state和红色矩形顶部的突出显示的lstm/state)和一个输入节点名称(底部的state_feed):

图 6.1:该图显示了三个输出节点名称和一个输入节点名称

图 6.2 显示了另一个输入节点名称image_feed

图 6.2:该图显示了一个附加的输入节点名称image_feed

最后,图 6.3 显示了最后一个输入节点名称input_feed

图 6.3:该图显示了最后一个输入节点名称input_feed

当然,这里有很多我们不能也不会涵盖的细节。 但是,您将了解大局,同样重要的是,有足够的细节可以继续前进。 现在运行freeze_graph.py应该像轻风(双关语):

python tensorflow/python/tools/freeze_graph.py --input_meta_graph=/home/jeff/tensorflow-1.5.0/models/research/im2txt/model/image2text.meta --input_checkpoint=/home/jeff/tensorflow-1.5.0/models/research/im2txt/model/image2text --output_graph=/tmp/image2text_frozen.pb --output_node_names="softmax,lstm/initial_state,lstm/state" --input_binary=true

请注意,我们在这里使用元图文件以及将--input_binary参数设置为true,因为默认情况下它为false,这意味着freeze_graph工具期望输入图或元图文件为文本格式。

您可以使用文本格式的图文件作为输入,在这种情况下,无需提供--input_binary参数:

python tensorflow/python/tools/freeze_graph.py  --input_graph=/home/jeff/tensorflow-1.5.0/models/research/im2txt/model/image2text.pbtxt --input_checkpoint=/home/jeff/tensorflow-1.5.0/models/research/im2txt/model/image2text --output_graph=/tmp/image2text_frozen2.pb --output_node_names="softmax,lstm/initial_state,lstm/state" 

两个输出图文件image2text_frozen.pbimage2text_frozen2.pb的大小会稍有不同,但是在经过转换和可能的优化后,它们在移动设备上使用时,它们的行为完全相同。

转换和优化图像字幕模型

如果您真的等不及了,现在决定尝试在 iOS 或 Android 应用上尝试新近冻结的热模型,则可以,但是您会看到一个致命错误No OpKernel was registered to support Op 'DecodeJpeg' with these attrs,迫使你重新考虑你的决定。

使用转换的模型修复错误

通常,您可以使用strip_unused.py, 工具,与 tensorflow/python/tools,中的 freeze_graph.py位于相同位置,来删除不包含在 TensorFlow 核心库中的DecodeJpeg操作。但是由于输入节点image_feed需要进行解码操作(图 6.2), strip_unused之类的工具不会将DecodeJpeg视为未使用,因此不会被剥夺。 您可以先运行strip_unused命令,如下所示进行验证:

bazel-bin/tensorflow/python/tools/strip_unused --input_graph=/tmp/image2text_frozen.pb --output_graph=/tmp/image2text_frozen_stripped.pb --input_node_names="image_feed,input_feed,lstm/state_feed" --output_node_names="softmax,lstm/initial_state,lstm/state" --input_binary=True

然后在 iPython 中加载输出图并列出前几个节点,如下所示:

import tensorflow as tf
g=tf.GraphDef()
g.ParseFromString(open("/tmp/image2text_frozen_stripped", "rb").read())
x=[n.name for n in g.node]
x[:6]

输出如下:

[u'image_feed',
 u'input_feed',
 u'decode/DecodeJpeg',
 u'convert_image/Cast',
 u'convert_image/y',
 u'convert_image']

解决您的 iOS 应用错误的第二种可能解决方案,像第 5 章, “了解简单语音命令”一样,是在 tf_op_files文件中添加未注册的操作实现,并重建 TensorFlow iOS 库。 坏消息是,由于 TensorFlow 中没有DecodeJpeg函数的实现,因此无法将DecodeJpeg的 TensorFlow 实现添加到tf_op_files中。

实际上,在图 6.2 中也暗示了对此烦恼的解决方法,其中convert_image节点用作image_feed输入的解码版本。 为了更准确,单击 TensorBoard 图中的转换和解码节点,如图 6.4 所示,您将从右侧的 TensorBoard 信息卡中看到输入转换(名为convert_image/Cast)的输出为decode/DecodeJpegconvert_image,解码的输入和输出为image_feedconvert_image/Cast

图 6.4:调查解码和conver_image节点

实际上,在im2txt/ops/image_processing.py中有一行image = tf.image.convert_image_dtype(image, dtype=tf.float32)将解码的图像转换为浮点数。 让我们用convert_image/Cast代替 TensorBoard 中显示的名称image_feed,以及前面代码片段的输出,然后再次运行strip_unused

bazel-bin/tensorflow/python/tools/strip_unused --input_graph=/tmp/image2text_frozen.pb  --output_graph=/tmp/image2text_frozen_stripped.pb --input_node_names="convert_image/Cast,input_feed,lstm/state_feed" --output_node_names="softmax,lstm/initial_state,lstm/state"  --input_binary=True

现在,重新运行代码片段,如下所示:

g.ParseFromString(open("/tmp/image2text_frozen_stripped", "rb").read())
x=[n.name for n in g.node]
x[:6]

并且输出不再具有decode / DecodeJpeg节点:

[u'input_feed',
 u'convert_image/Cast',
 u'convert_image/y',
 u'convert_image',
 u'ExpandDims_1/dim',
 u'ExpandDims_1']

如果我们在 iOS 或 Android 应用中使用新的模型文件image2text_frozen_stripped.pb,则No OpKernel was registered to support Op 'DecodeJpeg' with these attrs. 肯定会消失。 但是发生另一个错误, Not a valid TensorFlow Graph serialization: Input 0 of node ExpandDims_6 was passed float from input_feed:0 incompatible with expected int64。 如果您通过名为 TensorFlow for Poets 2 的不错的 Google TensorFlow 代码实验室,可能会想起来,还有另一个名为optimize_for_inference的工具,其功能类似于strip_unused,并且可以很好地用于代码实验室中的图像分类任务。 您可以像这样运行它:

bazel build tensorflow/python/tools:optimize_for_inference

bazel-bin/tensorflow/python/tools/optimize_for_inference \
--input=/tmp/image2text_frozen.pb \
--output=/tmp/image2text_frozen_optimized.pb \
--input_names="convert_image/Cast,input_feed,lstm/state_feed" \
--output_names="softmax,lstm/initial_state,lstm/state"

但是在 iOS 或 Android 应用上加载输出模型文件 image2text_frozen_optimized.pb会导致相同的Input 0 of node ExpandDims_6 was passed float from input_feed:0 incompatible with expected int64 错误。 看起来,尽管我们试图至少在某种程度上实现福尔摩斯在本章中可以做的事情,但有人希望我们首先成为福尔摩斯。

如果您在其他模型(例如我们在前几章中看到的模型)上尝试过strip_unusedoptimize_for_inference工具,则它们可以正常工作。 事实证明,尽管官方 TensorFlow 1.4 和 1.5 发行版中包含了两个基于 Python 的工具,但在优化一些更复杂的模型时却存在一些错误。 更新和正确的工具是基于 C++ 的transform_graph工具,现在是 TensorFlow Mobile 网站推荐的官方工具。 运行以下命令以消除在移动设备上部署时的int64不兼容float的错误:

bazel build tensorflow/tools/graph_transforms:transform_graph

bazel-bin/tensorflow/tools/graph_transforms/transform_graph \
--in_graph=/tmp/image2text_frozen.pb \
--out_graph=/tmp/image2text_frozen_transformed.pb \
--inputs="convert_image/Cast,input_feed,lstm/state_feed" \
--outputs="softmax,lstm/initial_state,lstm/state" \
--transforms='
 strip_unused_nodes(type=float, shape="299,299,3")
 fold_constants(ignore_errors=true, clear_output_shapes=true) 
 fold_batch_norms
 fold_old_batch_norms' 

我们将不讨论所有--transforms选项的详细信息,这些选项在这里有完整记录。 基本上,--transforms设置可以正确消除模型中未使用的节点,例如DecodeJpeg,并且还可以进行其他一些优化。

现在,如果您在 iOS 和 Android 应用中加载image2text_frozen_transformed.pb文件,则不兼容的错误将消失。 当然,我们还没有编写任何真实的 iOS 和 Android 代码,但是我们知道该模型很好,可以随时使用。 很好,但是可以更好。

优化转换后的模型

真正的最后一步,也是至关重要的一步,尤其是在运行复杂的冻结和转换模型(例如我们在较旧的 iOS 设备上训练过的模型)时,是使用位于 tensorflow/contrib/util的另一个工具convert_graphdef_memmapped_format ,将冻结和转换后的模型转换为映射格式。 映射文件允许现代操作系统(例如 iOS 和 Android)将文件直接映射到主内存,因此无需为文件分配内存,也无需写回磁盘,因为文件数据是只读的,这非常重要。 性能提高。

更重要的是,iOS 不会将已映射文件视为内存使用量,因此,当内存压力过大时,即使文件很大,使用已映射文件的应用也不会由于内存使用太大而被 iOS 杀死和崩溃。 实际上,正如我们将在下一节中很快看到的那样,如果模型文件的转换版本未转换为 memmapped 格式,则将在较旧的移动设备(如 iPhone 6)上崩溃,在这种情况下,转换是必须的, 有。

构建和运行该工具的命令非常简单:

bazel build tensorflow/contrib/util:convert_graphdef_memmapped_format

bazel-bin/tensorflow/contrib/util/convert_graphdef_memmapped_format \
--in_graph=/tmp/image2text_frozen_transformed.pb \
--out_graph=/tmp/image2text_frozen_transformed_memmapped.pb

下一节将向您展示如何在 iOS 应用中使用image2text_frozen_transformed_memmapped.pb模型文件。 它也可以在使用本机代码的 Android 中使用,但是由于时间限制,我们将无法在本章中介绍它。

我们花了很多功夫才能最终为移动应用准备好复杂的图像字幕模型。 是时候欣赏使用模型的简单性了。 实际上,使用模型不仅仅是 iOS 中的单个 session->Run 调用,还是 Android 中的 mInferenceInterface.run 调用,就像我们在前面所有章节中所做的那样; 从输入图像到自然语言输出的推论(如您在上一节中研究run_inference.py的工作原理时所见)涉及到对模型的run方法的多次调用。 LSTM 模型就是这样工作的:“继续向我发送新的输入(基于我以前的状态和输出),我将向您发送下一个状态和输出。” 简单来说,我们的意思是向您展示如何使用尽可能少的简洁代码来构建 iOS 和 Android 应用,这些应用使用该模型以自然语言描述图像。 这样,如果需要,您可以轻松地在自己的应用中集成模型及其推理代码。

在 iOS 中使用图像字幕模型

由于该模型的 CNN 部分基于 Inception v3,因此我们在第 2 章,“通过迁移学习对图像进行分类”时使用的模型相同,因此我们可以并且将使用更简单的 TensorFlow Pod 进行以下操作: 创建我们的 Objective-C iOS 应用。 请按照此处的步骤查看如何在新的 iOS 应用中同时使用image2text_frozen_transformed.pbimage2text_frozen_transformed_memmapped.pb模型文件:

  1. 类似于第 2 章,“通过迁移学习对图像进行分类”,“将 TensorFlow 添加到 Objective-C iOS 应用”部分中的前四个步骤, 名为Image2Text的 iOS 项目,添加具有以下内容的名为Podfile的新文件:
target 'Image2Text'
       pod 'TensorFlow-experimental'

然后在终端上运行pod install并打开Image2Text.xcworkspace文件。 将ios_image_load.hios_image_load.mmtensorflow_utils.htensorflow_utils.mm文件从位于tensorflow/examples/ios/camera的 TensorFlow iOS 示例相机应用拖放到 Xcode 的Image2Text项目中。 之前我们已经重用了ios_image_load.*文件,此处tensorflow_utils.*文件主要用于加载映射的模型文件。 tensorflow_utils.mm中有两种方法LoadModelLoadMemoryMappedModel :一种以我们以前的方式加载非映射模型,另一种加载了映射模型 。 如果有兴趣,请看一下LoadMemoryMappedModel的实现方式,并且这个页面上的文档也可能会有用。

  1. 添加在上一节末尾生成的两个模型文件,在“训练和测试字幕生成”小节第 2 步中生成的word_counts.txt文件,以及一些测试图像–我们保存并使用 TensorFlow im2txt 模型页面顶部的四个图像,以便我们比较我们的模型的字幕结果,以及那些由使用更多步骤训练的模型所生成的结果。 还将ViewController.m重命名为.mm,从现在开始,我们将只处理ViewController.mm文件即可完成应用。 现在,您的 Xcode Image2Text项目应类似于图 6.5:

图 6.5:设置Image2Text iOS 应用,还显示如何实现LoadMemoryMappedModel

  1. 打开ViewController.mm并添加一堆 Objective-C 和 C++ 常量,如下所示:
static NSString* MODEL_FILE = @"image2text_frozen_transformed";
static NSString* MODEL_FILE_MEMMAPPED = @"image2text_frozen_transformed_memmapped";
static NSString* MODEL_FILE_TYPE = @"pb";
static NSString* VOCAB_FILE = @"word_counts";
static NSString* VOCAB_FILE_TYPE = @"txt";
static NSString *image_name = @"im2txt4.png";

const string INPUT_NODE1 = "convert_image/Cast";
const string OUTPUT_NODE1 = "lstm/initial_state";
const string INPUT_NODE2 = "input_feed";
const string INPUT_NODE3 = "lstm/state_feed";
const string OUTPUT_NODE2 = "softmax";
const string OUTPUT_NODE3 = "lstm/state";

const int wanted_width = 299;
const int wanted_height = 299;
const int wanted_channels = 3;

const int CAPTION_LEN = 20;
const int START_ID = 2;
const int END_ID = 3;
const int WORD_COUNT = 12000;
const int STATE_COUNT = 1024;

它们都是自我解释的,如果您通读了本章,则应该看起来都很熟悉,除了最后五个常量:CAPTION_LEN是我们要在标题中生成的最大单词数,START_ID是句子起始词<S>的 ID,定义为word_counts.txt文件中的行号; 所以 2 在第二行表示,在第三行表示 3。 word_counts.txt文件的前几行是这样的:

a 969108
<S> 586368
</S> 586368
. 440479
on 213612
of 202290

WORD_COUNT是模型假设的总单词数,对于您很快就会看到的每个推理调用,模型将返回总计 12,000 的概率得分以及 LSTM 模型的 1,024 个状态值。

  1. 添加一些全局变量和一个函数签名:
unique_ptr<tensorflow::Session> session;
unique_ptr<tensorflow::MemmappedEnv> tf_memmapped_env;

std::vector<std::string> words;

UIImageView *_iv;
UILabel *_lbl;

NSString* generateCaption(bool memmapped);

此简单的与 UI 相关的代码类似于第 2 章,“通过迁移学习对图像进行分类”的 iOS 应用的代码。 基本上,您可以在应用启动后点击任意位置,然后选择两个模型之一,图像描述结果将显示在顶部。 当用户在alert操作中选择了映射模型时,将运行以下代码:

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSString *caption = generateCaption(true); 
    dispatch_async(dispatch_get_main_queue(), ^{
        _lbl.text = caption;
    });
});

如果选择了非映射模型,则使用generateCaption(false)

  1. viewDidLoad方法的末尾,添加代码以加载word_counts.txt并将这些单词逐行保存在 Objective-C 和 C++ 中:
NSString* voc_file_path = FilePathForResourceName(VOCAB_FILE, VOCAB_FILE_TYPE);
if (!voc_file_path) {
    LOG(FATAL) << "Couldn't load vocabuary file: " << voc_file_path;
}
ifstream t;
t.open([voc_file_path UTF8String]);
string line;
while(t){
    getline(t, line);
    size_t pos = line.find(" ");
    words.push_back(line.substr(0, pos));
}
t.close();
  1. 剩下的我们要做的就是实现generateCaption函数。 在其中,首先加载正确的模型:
tensorflow::Status load_status;
if (memmapped) 
    load_status = LoadMemoryMappedModel(MODEL_FILE_MEMMAPPED, MODEL_FILE_TYPE, &session, &tf_memmapped_env);
else 
    load_status = LoadModel(MODEL_FILE, MODEL_FILE_TYPE, &session);
if (!load_status.ok()) {
    return @"Couldn't load model";
}
  1. 然后,使用类似的图像处理代码来准备要输入到模型中的图像张量:
int image_width;
int image_height;
int image_channels;
NSArray *name_ext = [image_name componentsSeparatedByString:@"."];
NSString* image_path = FilePathForResourceName(name_ext[0], name_ext[1]);
std::vector<tensorflow::uint8> image_data = LoadImageFromFile([image_path UTF8String], &image_width, &image_height, &image_channels);

tensorflow::Tensor image_tensor(tensorflow::DT_FLOAT, tensorflow::TensorShape({wanted_height, wanted_width, wanted_channels}));
auto image_tensor_mapped = image_tensor.tensor<float, 3>();
tensorflow::uint8* in = image_data.data();
float* out = image_tensor_mapped.data();
for (int y = 0; y < wanted_height; ++y) {
    const int in_y = (y * image_height) / wanted_height;
    tensorflow::uint8* in_row = in + (in_y * image_width * image_channels);
    float* out_row = out + (y * wanted_width * wanted_channels);
    for (int x = 0; x < wanted_width; ++x) {
        const int in_x = (x * image_width) / wanted_width;
        tensorflow::uint8* in_pixel = in_row + (in_x * image_channels);
        float* out_pixel = out_row + (x * wanted_channels);
        for (int c = 0; c < wanted_channels; ++c) {
            out_pixel[c] = in_pixel[c];
        }
    }
}
  1. 现在,我们可以将图像发送到模型,并获取返回的initial_state张量向量,该向量包含 1,200(STATE_COUNT)个值:
vector<tensorflow::Tensor> initial_state;

if (session.get()) {
    tensorflow::Status run_status = session->Run({{INPUT_NODE1, image_tensor}}, {OUTPUT_NODE1}, {}, &initial_state);
    if (!run_status.ok()) {
        return @"Getting initial state failed";
    }
}
  1. 定义input_feedstate_feed张量,并将它们的值分别设置为起始字的 ID 和返回的initial_state值:
tensorflow::Tensor input_feed(tensorflow::DT_INT64, tensorflow::TensorShape({1,}));
tensorflow::Tensor state_feed(tensorflow::DT_FLOAT, tensorflow::TensorShape({1, STATE_COUNT}));

auto input_feed_map = input_feed.tensor<int64_t, 1>();
auto state_feed_map = state_feed.tensor<float, 2>();
input_feed_map(0) = START_ID;
auto initial_state_map = initial_state[0].tensor<float, 2>();
for (int i = 0; i < STATE_COUNT; i++){
    state_feed_map(0,i) = initial_state_map(0,i);
}
  1. CAPTION_LEN上创建一个for循环,然后在该循​​环内,首先创建output_feedoutput_states张量向量,然后馈入我们先前设置的input_feedstate_feed,并运行模型以返回由softmax张量和new_state张量组成的output张量向量:
vector<int> captions;
for (int i=0; i<CAPTION_LEN; i++) { 
    vector<tensorflow::Tensor> output;
    tensorflow::Status run_status = session->Run({{INPUT_NODE2, input_feed}, {INPUT_NODE3, state_feed}}, {OUTPUT_NODE2, OUTPUT_NODE3}, {}, &output);
    if (!run_status.ok()) {
        return @"Getting LSTM state failed";
    } 
    else {
        tensorflow::Tensor softmax = output[0];
        tensorflow::Tensor state = output[1];

        auto softmax_map = softmax.tensor<float, 2>();
        auto state_map = state.tensor<float, 2>();
  1. 现在,找到可能性最大(softmax 值)的单词 ID。 如果是结束字的 ID,则结束for循环;否则,结束循环。 否则,将具有最大 softmax 值的单词id添加到向量captions中。 请注意,此处我们使用贪婪搜索,始终选择概率最大的单词,而不是像run_inference.py脚本中那样将大小设置为 3 的集束搜索。 在for循环的末尾,用最大字数id更新input_feed值,并用先前返回的state值更新state_feed值,然后再将两个输入,所有下一个单词的 softmax 值和下一个状态值,馈送到模型:
        float max_prob = 0.0f;
        int max_word_id = 0;
        for (int j = 0; j < WORD_COUNT; j++){
            if (softmax_map(0,j) > max_prob) {
                max_prob = softmax_map(0,j);
                max_word_id = j;
            }
        }

        if (max_word_id == END_ID) break;
        captions.push_back(max_word_id);

        input_feed_map(0) = max_word_id;
        for (int j = 0; j < STATE_COUNT; j++){
            state_feed_map(0,j) = state_map(0,j);
        }        
    } 
}

我们可能从未详细解释过如何在 C++ 中获取和设置 TensorFlow 张量值。 但是,如果您到目前为止已经阅读了本书中的代码,那么您应该已经学会了。 这就像 RNN 学习:如果您接受了足够的代码示例训练,就可以编写有意义的代码。 总而言之,首先使用Tensor类型定义变量,并使用该变量的数据类型和形状指定,然后调用Tensor类的tensor方法,传入数据类型的 C++ 版本和形状,以创建张量的贴图变量。 之后,您可以简单地使用映射来获取或设置张量的值。

  1. 最后,只需遍历captions向量并将向量中存储的每个词 ID 转换为一个词,然后将该词添加到sentence字符串中,而忽略起始 ID 和结束 ID,然后返回该句子,希望是可读的自然语言:
NSString *sentence = @"";
for (int i=0; i<captions.size(); i++) {
    if (captions[i] == START_ID) continue;
    if (captions[i] == END_ID) break;

    sentence = [NSString stringWithFormat:@"%@ %s", sentence, words[captions[i]].c_str()];

}

return sentence;

这就是在 iOS 应用中运行模型所需的一切。 现在,在 iOS 模拟器或设备中运行该应用,点击并选择一个模型,如图 6.6 所示:

图 6.6:运行Image2Text iOS 应用并选择模型

在 iOS 模拟器上,运行非映射模型需要 10 秒钟以上,运行映射模型则需要 5 秒钟以上。 在 iPhone 6 上,运行贴图模型还需要大约 5 秒钟,但由于模型文件和内存压力较大,运行非贴图模型时会崩溃。

至于结果,图 6.7 显示了四个测试图像结果:

图 6.7:显示图像字幕结果

图 6.8 显示了 TensorFlow im2txt 网站上的结果,您可以看到我们更简单的贪婪搜索结果看起来也不错。 但是对于长颈鹿图片,看来我们的模型或推理代码不够好。 完成本章中的工作后,希望您会在改进训练或模型推断方面有所收获:

图 6.8:字幕示例显示在 TensorFlow im2txt 模型网站上

在我们进行下一个智能任务之前,是时候给 Android 开发人员一个不错的选择了。

在 Android 中使用图像字幕模型

遵循相同的简单性考虑,我们将开发具有最小 UI 的新 Android 应用,并着重于如何在 Android 中使用该模型:

  1. 创建一个名为Image2Text的新 Android 应用,在应用build.gradle文件的依存关系的末尾添加compile 'org.tensorflow:tensorflow-android:+',创建一个assets文件夹,然后将image2text_frozen_transformed.pb模型文件word_counts.txt文件和一些测试图像文件拖放到其中。
  2. activity_main.xml文件中添加一个ImageView和一个按钮:
<ImageView
    android:id="@+id/imageview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="1.0"/>

<Button
    android:id="@+id/button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="DESCRIBE ME"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintHorizontal_bias="0.5"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="1.0"/>
  1. 打开MainActivity.java,使其实现Runnable接口,然后添加以下常量,在前一节中说明了其中的最后五个,而其他则是自解释的:
private static final String MODEL_FILE = "file:///android_asset/image2text_frozen_transformed.pb";
private static final String VOCAB_FILE = "file:///android_asset/word_counts.txt";
private static final String IMAGE_NAME = "im2txt1.png";

private static final String INPUT_NODE1 = "convert_image/Cast";
private static final String OUTPUT_NODE1 = "lstm/initial_state";
private static final String INPUT_NODE2 = "input_feed";
private static final String INPUT_NODE3 = "lstm/state_feed";
private static final String OUTPUT_NODE2 = "softmax";
private static final String OUTPUT_NODE3 = "lstm/state";

private static final int IMAGE_WIDTH = 299;
private static final int IMAGE_HEIGHT = 299;
private static final int IMAGE_CHANNEL = 3;

private static final int CAPTION_LEN = 20;
private static final int WORD_COUNT = 12000;
private static final int STATE_COUNT = 1024;
private static final int START_ID = 2;
private static final int END_ID = 3;

以及以下实例变量和处理器实现:

private ImageView mImageView;
private Button mButton;

private TensorFlowInferenceInterface mInferenceInterface;
private String[] mWords = new String[WORD_COUNT];
private int[] intValues;
private float[] floatValues;

Handler mHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        mButton.setText("DESCRIBE ME");
        String text = (String)msg.obj;
        Toast.makeText(MainActivity.this, text, Toast.LENGTH_LONG).show();
        mButton.setEnabled(true);
    } };
  1. onCreate方法中,首先在ImageView中添加显示测试图像并处理按钮单击事件的代码:
mImageView = findViewById(R.id.imageview);
try {
    AssetManager am = getAssets();
    InputStream is = am.open(IMAGE_NAME);
    Bitmap bitmap = BitmapFactory.decodeStream(is);
    mImageView.setImageBitmap(bitmap);
} catch (IOException e) {
    e.printStackTrace();
}

mButton = findViewById(R.id.button);
mButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        mButton.setEnabled(false);
        mButton.setText("Processing...");
        Thread thread = new Thread(MainActivity.this);
        thread.start();
    }
}); 

然后添加读取word_counts.txt每行的代码,并将每个单词保存在mWords数组中:

String filename = VOCAB_FILE.split("file:///android_asset/")[1];
BufferedReader br = null;
int linenum = 0;
try {
    br = new BufferedReader(new InputStreamReader(getAssets().open(filename)));
    String line;
    while ((line = br.readLine()) != null) {
        String word = line.split(" ")[0];
        mWords[linenum++] = word;
    }
    br.close();
} catch (IOException e) {
    throw new RuntimeException("Problem reading vocab file!" , e);
}
  1. 现在,在public void run()方法中,在DESCRIBE ME按钮发生onClick事件时启动,添加代码以调整测试图像的大小,从调整后的位图中读取像素值,然后将它们转换为浮点数-我们已经在前三章中看到了这样的代码:
intValues = new int[IMAGE_WIDTH * IMAGE_HEIGHT];
floatValues = new float[IMAGE_WIDTH * IMAGE_HEIGHT * IMAGE_CHANNEL];

Bitmap bitmap = BitmapFactory.decodeStream(getAssets().open(IMAGE_NAME));
Bitmap croppedBitmap = Bitmap.createScaledBitmap(bitmap, IMAGE_WIDTH, IMAGE_HEIGHT, true);
croppedBitmap.getPixels(intValues, 0, IMAGE_WIDTH, 0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
for (int i = 0; i < intValues.length; ++i) {
    final int val = intValues[i];
    floatValues[i * IMAGE_CHANNEL + 0] = ((val >> 16) & 0xFF);
    floatValues[i * IMAGE_CHANNEL + 1] = ((val >> 8) & 0xFF);
    floatValues[i * IMAGE_CHANNEL + 2] = (val & 0xFF);
}
  1. 创建一个TensorFlowInferenceInterface实例,该实例加载模型文件,并通过向其提供图像值,然后在initialState中获取返回结果来使用该模型进行第一个推断:
AssetManager assetManager = getAssets();
mInferenceInterface = new TensorFlowInferenceInterface(assetManager, MODEL_FILE);

float[] initialState = new float[STATE_COUNT];
mInferenceInterface.feed(INPUT_NODE1, floatValues, IMAGE_WIDTH, IMAGE_HEIGHT, 3);
mInferenceInterface.run(new String[] {OUTPUT_NODE1}, false);
mInferenceInterface.fetch(OUTPUT_NODE1, initialState);
  1. 将第一个input_feed值设置为起始 ID,并将第一个state_feed值设置为返回的initialState值:
long[] inputFeed = new long[] {START_ID};
float[] stateFeed = new float[STATE_COUNT * inputFeed.length];
for (int i=0; i < STATE_COUNT; i++) {
    stateFeed[i] = initialState[i];
}

如您所见,得益于 Android 中的TensorFlowInferenceInterface实现,在 Android 中获取和设置张量值并进行推理比在 iOS 中更简单。 在我们开始重复使用inputFeedstateFeed进行模型推断之前,我们创建了一个captions列表,该列表包含一对整数和浮点数,其中整数作为单词 ID,具有最大 softmax 值(在模型为每个推理调用返回的所有 softmax 值中)和float作为单词的 softmax 值。 我们可以使用一个简单的向量来保存每个推论返回中具有最大 softmax 值的单词,但是使用对的列表可以使以后我们从贪婪搜索方法切换到集束搜索时更加容易:

List<Pair<Integer, Float>> captions = new ArrayList<Pair<Integer, Float>>();
  1. 在字幕长度的for循环中,我们将上面设置的值提供给input_feedstate_feed,然后获取返回的softmaxnewstate值:
for (int i=0; i<CAPTION_LEN; i++) {
    float[] softmax = new float[WORD_COUNT * inputFeed.length];
    float[] newstate = new float[STATE_COUNT * inputFeed.length];

    mInferenceInterface.feed(INPUT_NODE2, inputFeed, 1);
    mInferenceInterface.feed(INPUT_NODE3, stateFeed, 1, STATE_COUNT);
    mInferenceInterface.run(new String[]{OUTPUT_NODE2, OUTPUT_NODE3}, false);
    mInferenceInterface.fetch(OUTPUT_NODE2, softmax);
    mInferenceInterface.fetch(OUTPUT_NODE3, newstate);
  1. 现在,创建另一个由整数和浮点对组成的列表,将每个单词的 ID 和 softmax 值添加到列表中,并以降序对列表进行排序:
    List<Pair<Integer, Float>> prob_id = new ArrayList<Pair<Integer, Float>>();
    for (int j = 0; j < WORD_COUNT; j++) {
        prob_id.add(new Pair(j, softmax[j]));
    }

    Collections.sort(prob_id, new Comparator<Pair<Integer, Float>>() {
        @Override
        public int compare(final Pair<Integer, Float> o1, final Pair<Integer, Float> o2) {
            return o1.second > o2.second ? -1 : (o1.second == o2.second ? 0 : 1);
        }
    });
  1. 如果最大概率的单词是结束单词,则结束循环。 否则,将该对添加到captions列表,并使用最大 softmax 值的单词 ID 更新input_feed并使用返回的状态值更新state_feed,以继续进行下一个推断:
    if (prob_id.get(0).first == END_ID) break;

    captions.add(new Pair(prob_id.get(0).first, prob_id.get(0).first));

    inputFeed = new long[] {prob_id.get(0).first};
    for (int j=0; j < STATE_COUNT; j++) {
        stateFeed[j] = newstate[j];
    }
}
  1. 最后,遍历captions列表中的每一对,并将每个单词(如果不是开头和结尾的话)添加到sentence字符串,该字符串通过处理器返回,以向用户显示自然语言输出:
String sentence = "";
for (int i=0; i<captions.size(); i++) {
    if (captions.get(i).first == START_ID) continue;
    if (captions.get(i).first == END_ID) break;

    sentence = sentence + " " + mWords[captions.get(i).first];
}

Message msg = new Message();
msg.obj = sentence;
mHandler.sendMessage(msg);

在您的虚拟或真实 Android 设备上运行该应用。 大约需要 10 秒钟才能看到结果。 您可以使用上一节中显示的四个不同的测试图像,并在图 6.9 中查看结果:

图 6.9:在 Android 中显示图像字幕结果

一些结果与 iOS 结果以及 TensorFlow im2txt 网站上的结果略有不同。 但是它们看起来都不错。 另外,在相对较旧的 Android 设备(例如 Nexus 5)上运行该模型的非映射版本也可以。 但是最好在 Android 中加载映射模型,以查看性能的显着提高,我们可能会在本书后面的章节中介绍。

因此,这将使用功能强大的图像字幕模型完成分步的 Android 应用构建过程。 无论您使用的是 iOS 还是 Android 应用,您都应该能够轻松地将我们训练有素的模型和推理代码集成到自己的应用中,或者返回到训练过程以微调模型,然后准备并优化更好的模型。 在您的移动应用中使用的模型。

总结

在本章中,我们首先讨论了由现代端到端深度学习支持的图像字幕如何工作,然后总结了如何使用 TensorFlow im2txt 模型项目训练这种模型。 我们详细讨论了如何找到正确的输入节点名称和输出节点名称,以及如何冻结模型,然后使用最新的图转换工具和映射转换工具修复在将模型加载到手机上时出现的一些讨厌的错误。 之后,我们展示了有关如何使用模型构建 iOS 和 Android 应用以及如何使用模型的 LSTM RNN 组件进行新的序列推断的详细教程。

令人惊讶的是,经过训练了成千上万个图像字幕示例,并在现代 CNN 和 LSTM 模型的支持下,我们可以构建和使用一个模型,该模型可以在移动设备上生成合理的自然语言描述。 不难想象可以在此基础上构建什么样的有用应用。 我们喜欢福尔摩斯吗? 当然不。 我们已经在路上了吗? 我们希望如此。 AI 的世界既令人着迷又充满挑战,但是只要我们不断取得稳步进步并改善自己的学习过程,同时又避免了梯度问题的消失和爆炸,我们就有很大机会建立一个类似于 Holmes 的模型,并可以随时随地在一天中在移动应用中使用它。

漫长的篇章讨论了基于 CNN 和 LSTM 的网络模型的实际使用,我们值得一试。 在下一章中,您将看到如何使用另一个基于 CNN 和 LSTM 的模型来开发有趣的 iOS 和 Android 应用,这些应用使您可以绘制对象然后识别它们是什么。 要快速获得游戏在线版本的乐趣,请访问这里

七、使用 CNN 和 LSTM 识别绘画

在上一章中,我们看到了使用深度学习模型的强大功能,该模型将 CNN 与 LSTM RNN 集成在一起以生成图像的自然语言描述。 如果深度学习驱动的 AI 就像新的电力一样,我们当然希望看到这种混合神经网络模型在许多不同领域中的应用。 诸如图像字幕之类的严肃应用与之相反? 一个有趣的绘画应用,例如 Quick Draw(请参见这里了解有趣的示例数据),使用经过训练并基于 345 个类别中的 5000 万张绘画的模型,并将新绘画分类到这些类别中,听起来不错。 还有一个正式的 TensorFlow 教程,该教程介绍了如何构建这样的模型来帮助我们快速入门。

事实证明,在 iOS 和 Android 应用上使用本教程构建的模型的任务提供了一个绝佳的机会:

  • 加深我们对找出模型的正确输入和输出节点名称的理解,因此我们可以为移动应用适当地准备模型
  • 使用其他方法来修复 iOS 中的新模型加载和推断错误
  • 首次为 Android 构建自定义的 TensorFlow 本机库,以修复 Android 中的新模型加载和预测错误
  • 查看有关如何使用预期格式的输入来输入 TensorFlow 模型以及如何在 iOS 和 Android 中获取和处理其输出的更多示例

此外,在处理所有繁琐而重要的细节的过程中,以便模型可以像魔术一样工作,以进行漂亮的绘画分类,您将在 iOS 和 Android 设备上享受有趣的涂鸦。

因此,在本章中,我们将介绍以下主题:

  • 绘画分类 -- 工作原理
  • 训练并准备绘画分类模型
  • 在 iOS 中使用绘画分类模型
  • 在 Android 中使用绘画分类模型

绘画分类 – 工作原理

TensorFlow 教程中内置的绘画分类模型,首先接受表示为点列表的用户绘画输入,并将规范化输入转换为连续点的增量的张量,以及有关每个点是否是新笔画的开始的信息。 然后将张量穿过几个卷积层和 LSTM 层,最后穿过 softmax 层,如图 7.1 所示,以对用户绘画进行分类:

图 7.1:绘画分类模式

与接受 2D 图像输入的 2D 卷积 API tf.layers.conv2d 不同,此处将 1D 卷积 API tf.layers.conv1d用于时间卷积(例如绘画)。 默认情况下,在绘画分类模型中,使用三个 1D 卷积层,每个层具有 48、64 和 96 个过滤器,其长度分别为 5、5 和 3。 卷积层之后,将创建 3 个 LSTM 层,每层具有 128 个正向BasicLSTMCell节点和 128 个反向BasicLSTMCell节点,然后将其用于创建动态双向循环神经网络,该网络的输出将发送到最终的完全连接层以计算logits(非标准化的对数概率)。

If you don't have a good understanding of all these details, don't worry; to develop powerful mobile apps using a model built by others, you don't have to understand all the details, but in the next chapter we'll also discuss in greater detail how you can build a RNN model from scratch for stock prediction, and with that, you'll have a better understanding of all the RNN stuff.

在前面提到的有趣的教程中详细描述了简单而优雅的模型以及构建模型的 Python 实现,其源代码位于仓库中。 在继续进行下一部分之前,我们只想说一件事:模型的构建,训练,评估和预测的代码与上一章中看到的代码不同,它使用了称为Estimator的 TensorFlow API,或更准确地说,是自定义Estimator。 如果您对模型实现的详细信息感兴趣,则应该阅读有关创建和使用自定义Estimator的指南。 这个页面models/samples/core/get_started/custom_estimator.py上的指南的有用源代码。 基本上,首先要实现一个函数,该函数定义模型,指定损失和准确率度量,设置优化器和training操作,然后创建tf.estimator.Estimator类的实例并调用其trainevaluatepredict方法。 就像您将很快看到的那样,使用Estimator可以简化如何构建,训练和推断神经网络模型,但是由于它是高级 API,因此它还会执行一些更加困难的低级任务,例如找出输入和输出节点名称来推断移动设备。

训练,预测和准备绘画分类模型

训练模型非常简单,但为移动部署准备模型则有些棘手。 在我们开始训练之前,请首先确保您已经在 TensorFlow 根目录中克隆了 TensorFlow 模型库,就像我们在前两章中所做的一样。 然后从这里下载绘画分类训练数据集,大约 1.1GB,创建一个名为rnn_tutorial_data的新文件夹, 并解压缩dataset tar.gz文件。 您将看到 10 个训练 TFRecord 文件和 10 个评估 TFRecord 文件,以及两个带有.classes扩展名的文件,它们具有相同的内容,并且只是该数据集可用于分类的 345 个类别的纯文本,例如"sheep", "skull", "donut", "apple"

训练绘画分类模型

要训​​练模型,只需打开终端cdtensorflow/models/tutorials/rnn/quickdraw ,然后运行以下脚本:

python train_model.py \
  --training_data=rnn_tutorial_data/training.tfrecord-?????-of-????? \
  --eval_data=rnn_tutorial_data/eval.tfrecord-?????-of-????? \
  --model_dir quickdraw_model/ \
  --classes_file=rnn_tutorial_data/training.tfrecord.classes

默认情况下,训练步骤为 100k,在我们的 GTX 1070 GPU 上大约需要 6 个小时才能完成训练。 训练完成后,您将在模型目录中看到一个熟悉的文件列表(省略了其他四组model.ckpt*文件):

ls -lt quickdraw_model/
-rw-rw-r-- 1 jeff jeff 164419871 Feb 12 05:56 events.out.tfevents.1518422507.AiLabby
-rw-rw-r-- 1 jeff jeff 1365548 Feb 12 05:56 model.ckpt-100000.meta
-rw-rw-r-- 1 jeff jeff 279 Feb 12 05:56 checkpoint
-rw-rw-r-- 1 jeff jeff 13707200 Feb 12 05:56 model.ckpt-100000.data-00000-of-00001
-rw-rw-r-- 1 jeff jeff 2825 Feb 12 05:56 model.ckpt-100000.index
-rw-rw-r-- 1 jeff jeff 2493402 Feb 12 05:47 graph.pbtxt
drwxr-xr-x 2 jeff jeff 4096 Feb 12 00:11 eval

如果您运行tensorboard --logdir quickdraw_model,然后从浏览器在http://localhost:6006上启动 TensorBoard,您会看到精度达到约 0.55,损失到约 2.0。 如果继续进行约 200k 的训练,则精度将提高到约 0.65,损失将下降到 1.3,如图 7.2 所示:

图 7.2:300k 训练步骤后模型的准确率和损失

现在,我们可以像上一章一样运行freeze_graph.py工具,以生成用于移动设备的模型文件。 但是在执行此操作之前,我们首先来看一下如何在 Python 中使用该模型进行推断,例如上一章中的run_inference.py脚本。

使用绘画分类模型进行预测

看一下models/tutorial/rnn/quickdraw文件夹中的train_model.py文件。 当它开始运行时,将在create_estimator_and_specs函数中创建一个Estimator实例:

  estimator = tf.estimator.Estimator(
      model_fn=model_fn,
      config=run_config,
      params=model_params)

传递给Estimator类的关键参数是名为model_fn的模型函数,该函数定义:

  • 获取输入张量并创建卷积,RNN 和最终层的函数
  • 调用这些函数来构建模型的代码
  • 损失,优化器和预测

在返回tf.estimator.EstimatorSpec实例之前,model_fn函数还具有一个名为mode的参数,该参数可以具有以下三个值之一:

  • tf.estimator.ModeKeys.TRAIN
  • tf.estimator.ModeKeys.EVAL
  • tf.estimator.ModeKeys.PREDICT

实现train_model.py的方式支持训练和求值模式,但是您不能直接使用它来通过特定的绘画输入进行推理(对绘画进行分类)。 要使用特定输入来测试预测,请按照以下步骤操作:

  1. 复制train_model.py,然后将新文件重命名为predict.py-这样您就可以更自由地进行预测了。
  2. predict.py中,定义[预测]的输入函数,并将features设置为模型期望的绘画输入(连续点的增量,其中第三个数字表示该点是否为笔划的起点) :
def predict_input_fn():
    def _input_fn():

        features = {'shape': [[16, 3]], 'ink': [[
             -0.23137257, 0.31067961, 0\. , 
             -0.05490196, 0.1116505 , 0\. , 
             0.00784314, 0.09223297, 0\. , 
             0.19215687, 0.07766992, 0\. , 
             ...
             0.12156862, 0.05825245, 0\. ,
             0\. , -0.06310678, 1\. , 
             0\. , 0., 0\. , 
             ...
             0\. , 0., 0\. , 
        ]]}
        features['shape'].append( features['shape'][0])
        features['ink'].append( features['ink'][0])
        features=dict(features)

        dataset = tf.data.Dataset.from_tensor_slices(features)
        dataset = dataset.batch(FLAGS.batch_size)

        return dataset.make_one_shot_iterator().get_next()

    return _input_fn

我们并没有显示所有的点值,但它们是使用 TensorFlow RNN 用于绘画分类的教程中显示的示例猫示例数据创建的,并应用了parse_line函数(请参见教程或models/tutorials/rnn/quickdraw文件夹中的create_dataset.py 细节)。

还要注意,我们使用tf.data.Datasetmake_one_shot_iterator方法创建了一个迭代器,该迭代器从数据集中返回一个示例(在这种情况下,我们在数据集中只有一个示例),与模型在处理大型数据集时,在训练和评估过程中获取数据的方式相同–这就是为什么稍后在模型的图中看到OneShotIterator操作的原因。

  1. 在主函数中,调用估计器的predict方法,该方法将生成给定特征的预测,然后打印下一个预测:
  predictions = estimator.predict(input_fn=predict_input_fn())
  print(next(predictions)['argmax'])
  1. model_fn函数中,在logits = _add_fc_layers(final_state)之后,添加以下代码:
  argmax = tf.argmax(logits, axis=1)

  if mode == tf.estimator.ModeKeys.PREDICT:
    predictions = {
      'argmax': argmax,
      'softmax': tf.nn.softmax(logits),
      'logits': logits,
    }

    return tf.estimator.EstimatorSpec(mode, predictions=predictions)

现在,如果您运行predict.py,您将在步骤 2 中获得具有输入数据返回最大值的类 ID。

基本了解如何使用Estimator高级 API 构建的模型进行预测后,我们现在就可以冻结该模型,以便可以在移动设备上使用该模型,这需要我们首先弄清楚输出节点名称应该是什么。

准备绘画分类模型

让我们使用 TensorBoard 看看我们能找到什么。 在我们模型的 TensorBoard 视图的 GRAPHS 部分中,您可以看到,如图 7.3 所示,以红色突出显示的BiasAdd节点是ArgMax操作的输入,用于计算精度,以及 softmax 操作的输入。 我们可以使用SparseSoftmaxCrossEntropyWithLogits(图 7.3 仅显示为SparseSiftnaxCr ...)操作,也可以仅使用Dense/BiasAdd作为输出节点名称,但我们将ArgMaxDense/BiasAdd用作freeze_graph工具的两个输出节点名称,因此我们可以更轻松地查看最终密集层的输出以及ArgMax结果:

图 7.3:显示模型的可能输出节点名称

用您的graph.pbtxt文件的路径和最新的模型检查点前缀替换--input_graph--input_checkpoint值后,在 TensorFlow 根目录中运行以下脚本以获取冻结的图:

python tensorflow/python/tools/freeze_graph.py  --input_graph=/tmp/graph.pbtxt --input_checkpoint=/tmp/model.ckpt-314576 --output_graph=/tmp/quickdraw_frozen_dense_biasadd_argmax.pb --output_node_names="dense/BiasAdd,ArgMax"

您会看到quickdraw_frozen_dense_biasadd_argmax.pb成功创建。 但是,如果您尝试在 iOS 或 Android 应用中加载模型,则会收到一条错误消息,内容为Could not create TensorFlow Graph: Not found: Op type not registered 'OneShotIterator' in binary. Make sure the Op and Kernel are registered in the binary running in this process.

我们在前面的小节中讨论了OneShotIterator的含义。 回到 TensorBoard GRAPHS部分,我们可以看到OneShotIterator(如图 7.4 所示),该区域以红色突出显示,并且还显示在右下方的信息面板中,在图表的底部,以及上方的几个层次中,有一个 Reshape操作用作第一卷积层的输入:

图 7.4:查找可能的输入节点名称

您可能想知道为什么我们不能使用我们之前使用的技术来解决Not found: Op type not registered 'OneShotIterator'错误,即先使用命令grep 'REGISTER.*"OneShotIterator"' tensorflow/core/ops/*.cc(您将看到输出为tensorflow/core/ops/dataset_ops.cc:REGISTER_OP("OneShotIterator")),然后将tensorflow/core/ops/dataset_ops.cc添加到tf_op_files.txt并重建 TensorFlow 库。 即使这可行,也会使解决方案复杂化,因为现在我们需要向模型提供一些与OneShotIterator相关的数据,而不是以点为单位的直接用户绘画。

此外,在右侧上方一层(图 7.5),还有另一种操作 Squeeze ,它是 rnn_classification 子图的输入:

图 7.5:找出输入节点名称的进一步研究

我们不必担心Reshape右侧的Shape运算,因为它实际上是rnn_classification子图的输出。 因此,所有这些研究背后的直觉是,我们可以使用ReshapeSqueeze作为两个输入节点,然后使用在上一章中看到的transform_graph 工具,我们应该能够删除 ReshapeSqueeze以下的节点,包括OneShotIterator

现在在 TensorFlow 根目录中运行以下命令:

bazel-bin/tensorflow/tools/graph_transforms/transform_graph --in_graph=/tmp/quickdraw_frozen_dense_biasadd_argmax.pb --out_graph=/tmp/quickdraw_frozen_strip_transformed.pb --inputs="Reshape,Squeeze" --outputs="dense/BiasAdd,ArgMax" --transforms='
strip_unused_nodes(name=Squeeze,type_for_name=int64,shape_for_name="8",name=Reshape,type_for_name=float,shape_for_name="8,16,3")' 

在这里,我们为strip_unused_nodes使用了更高级的格式:对于每个输入节点名称(SqueezeReshape),我们指定其特定的类型和形状,以避免以后出现模型加载错误。 有关transform_graph工具的strip_unused_nodes的更多详细信息,请参见其上的文档 https://github.com/tensorflow/tensorflow/tree/master/tensorflow/tools/graph_transforms

现在在 iOS 或 Android 中加载模型,OneShotIterator错误将消失。 但是,您可能已经学会了预期,但是会出现一个新错误:Could not create TensorFlow Graph: Invalid argument: Input 0 of node IsVariableInitialized was passed int64 from global_step:0 incompatible with expected int64_ref.

我们首先需要了解有关IsVariableInitialized的更多信息。 如果我们回到 TensorBoard GRAPHS标签,我们会在左侧看到一个IsVariableInitialized操作,该操作以红色突出显示并在右侧的信息面板中以global_step作为其输入(图 7.6)。

即使我们不确切知道它的用途,我们也可以确保它与模型推断无关,该模型推断只需要一些输入(图 7.4 和图 7.5)并生成绘画分类作为输出(图 7.3)。 :

图 7.6:查找导致模型加载错误但与模型推断无关的节点

那么,如何摆脱global_step以及其他相关的cond节点,由于它们的隔离性,它们不会被变换图工具剥离掉? 幸运的是,freeze_graph脚本支持这一点 – 仅在其源代码中记录。 我们可以为脚本使用variable_names_blacklist参数来指定应在冻结模型中删除的节点:

python tensorflow/python/tools/freeze_graph.py --input_graph=/tmp/graph.pbtxt --input_checkpoint=/tmp/model.ckpt-314576 --output_graph=/tmp/quickdraw_frozen_long_blacklist.pb --output_node_names="dense/BiasAdd,ArgMax" --variable_names_blacklist="IsVariableInitialized,global_step,global_step/Initializer/zeros,cond/pred_id,cond/read/Switch,cond/read,cond/Switch_1,cond/Merge"

在这里,我们只列出global_stepcond范围内的节点。 现在再次运行transform_graph工具:

bazel-bin/tensorflow/tools/graph_transforms/transform_graph --in_graph=/tmp/quickdraw_frozen_long_blacklist.pb --out_graph=/tmp/quickdraw_frozen_long_blacklist_strip_transformed.pb --inputs="Reshape,Squeeze" --outputs="dense/BiasAdd,ArgMax" --transforms='
strip_unused_nodes(name=Squeeze,type_for_name=int64,shape_for_name="8",name=Reshape,type_for_name=float,shape_for_name="8,16,3")' 

在 iOS 或 Android 中加载生成的模型文件quickdraw_frozen_long_blacklist_strip_transformed.pb,您将不再看到 IsVariableInitialized错误。 当然,在 iOS 和 Android 上,您还会看到另一个错误。 加载先前的模型将导致此错误:

Couldn't load model: Invalid argument: No OpKernel was registered to support Op 'RefSwitch' with these attrs. Registered devices: [CPU], Registered kernels:
 device='GPU'; T in [DT_FLOAT]
 device='GPU'; T in [DT_INT32]
 device='GPU'; T in [DT_BOOL]
 device='GPU'; T in [DT_STRING]
 device='CPU'; T in [DT_INT32]
 device='CPU'; T in [DT_FLOAT]
 device='CPU'; T in [DT_BOOL]

[[Node: cond/read/Switch = RefSwitch[T=DT_INT64, _class=["loc:@global_step"], _output_shapes=[[], []]](global_step, cond/pred_id)]]

要解决此错误,我们必须以不同的方式为 iOS 和 Android 构建自定义的 TensorFlow 库。 在下面的 iOS 和 Android 部分中讨论如何执行此操作之前,让我们首先做一件事:将模型转换为映射版本,以便在 iOS 中更快地加载并使用更少的内存:

bazel-bin/tensorflow/contrib/util/convert_graphdef_memmapped_format \
--in_graph=/tmp/quickdraw_frozen_long_blacklist_strip_transformed.pb \
--out_graph=/tmp/quickdraw_frozen_long_blacklist_strip_transformed_memmapped.pb

在 iOS 中使用绘画分类模型

要解决以前的 RefSwitch 错误,无论您是否像在第 2 章,“通过迁移学习对图像分类”和第 6 章,“用自然语言描述图像”或手动构建的 TensorFlow 库,就像在其他章节中一样,我们必须使用一些新技巧。 发生错误的原因是RefSwitch操作需要INT64数据类型,但它不是 TensorFlow 库中内置的已注册数据类型之一,因为默认情况下,要使该库尽可能小,仅包括每个操作的共同数据类型。 我们可能会从 Python 的模型构建端修复此问题,但是在这里,我们仅向您展示如何从 iOS 端修复此问题,当您无权访问源代码来构建模型时,这很有用。

为 iOS 构建自定义的 TensorFlow 库

tensorflow/contrib/makefile/Makefile打开 Makefile,然后,如果您使用 TensorFlow 1.4,则搜索IOS_ARCH。 对于每种架构(总共 5 种:ARMV7,ARMV7S,ARM64,I386,X86_64),将-D__ANDROID_TYPES_SLIM__ 更改为 -D__ANDROID_TYPES_FULL__。 TensorFlow 1.5(或 1.6/1.7)中的Makefile稍有不同,尽管它仍位于同一文件夹中。 对于 1.5/1.6/1.7,搜索ANDROID_TYPES_SLIM并将其更改为 ANDROID_TYPES_FULL。 现在,通过运行tensorflow/contrib/makefile/build_all_ios.sh重建 TensorFlow 库。 此后,在加载模型文件时,RefSwitch错误将消失。 使用 TensorFlow 库构建并具有完整数据类型支持的应用大小约为 70MB,而使用默认的细长数据类型构建的应用大小为 37MB。

好像还不够,仍然发生另一个模型加载错误:

Could not create TensorFlow Graph: Invalid argument: No OpKernel was registered to support Op 'RandomUniform' with these attrs. Registered devices: [CPU], Registered kernels: <no registered kernels>.

幸运的是,如果您已经阅读了前面的章节,那么您应该非常熟悉如何解决这种错误。 快速回顾一下:首先找出哪些操作和内核文件定义并实现了该操作,然后检查tf_op_files.txt文件中是否包含操作或内核文件,并且应该至少缺少一个文件,从而导致错误 ; 现在只需将操作或内核文件添加到tf_op_files.txt并重建库。 在我们的情况下,运行以下命令:

grep RandomUniform tensorflow/core/ops/*.cc
grep RandomUniform tensorflow/core/kernels/*.cc

您将看到这些文件作为输出:

tensorflow/core/ops/random_grad.cc
tensorflow/core/ops/random_ops.cc:
tensorflow/core/kernels/random_op.cc

tensorflow/contrib/makefile/tf_op_files.txt文件只有前两个文件,因此只需将最后一个tensorflow/core/kernels/random_op.cc添加到 tf_op_files.txt的末尾,然后再次运行tensorflow/contrib/makefile/build_all_ios.sh

最终,在加载模型时所有错误都消失了,我们可以通过实现应用逻辑来处理用户绘画,将点转换为模型期望的格式并返回分类结果,从而开始获得一些真正的乐趣。

开发 iOS 应用来使用模型

让我们使用 Objective-C 创建一个新的 Xcode 项目,然后从上一章中创建的Image2Text iOS 项目中拖放tensorflow_util.htensorflow_util.mm文件。 另外,将两个模型文件quickdraw_frozen_long_blacklist_strip_transformed.pbquickdraw_frozen_long_blacklist_strip_transformed_memmapped.pb以及training.tfrecord.classes文件从 models/tutorials/rnn/quickdraw/rnn_tutorial_data拖放到QuickDraw项目,然后将training.tfrecord.classes重命名为classes.txt

还将ViewController.m重命名为ViewController.mm,并在tensorflow_util.h中注释GetTopN函数定义,并在tensorflow_util.mm中注释其实现,因为我们将在ViewController.mm中实现修改后的版本。 您的项目现在应如图 7.7 所示:

图 7.7:显示带有ViewController初始内容的QuickDraw Xcode 项目。

我们现在准备单独处理ViewController.mm,以完成我们的任务。

  1. 在按图 7.6 设置基本常量和变量以及两个函数原型之后,在ViewControllerviewDidLoad中实例化UIButtonUILabelUIImageView。 每个 UI 控件都设置有多个NSLayoutConstraint(有关完整的代码列表,请参见源代码仓库)。 UIImageView的相关代码如下:
_iv = [[UIImageView alloc] init];
_iv.contentMode = UIViewContentModeScaleAspectFit;
[_iv setTranslatesAutoresizingMaskIntoConstraints:NO];
[self.view addSubview:_iv];

UIImageView将用于显示通过UIBezierPath实现的用户绘画。 同样,初始化两个用于保存每个连续点和用户绘制的所有点的数组:

_allPoints = [NSMutableArray array];
_consecutivePoints = [NSMutableArray array];
  1. 点击具有初始标题“开始”的按钮后,用户可以开始绘画; 按钮标题更改为“重新启动”,并进行了其他一些重置:
- (IBAction)btnTapped:(id)sender {
    _canDraw = YES;
    [_btn setTitle:@"Restart" forState:UIControlStateNormal];
    [_lbl setText:@""];
    _iv.image = [UIImage imageNamed:@""];
    [_allPoints removeAllObjects];
}
  1. 为了处理用户绘画,我们首先实现touchesBegan方法:
- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    if (!_canDraw) return;
    [_consecutivePoints removeAllObjects];
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self.view];
    [_consecutivePoints addObject:[NSValue valueWithCGPoint:point]];
    _iv.image = [self createDrawingImageInRect:_iv.frame];
}

然后是touchesMoved方法:

- (void) touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    if (!_canDraw) return;
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self.view];
    [_consecutivePoints addObject:[NSValue valueWithCGPoint:point]];
    _iv.image = [self createDrawingImageInRect:_iv.frame];
}

最后是touchesEnd方法:

- (void) touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    if (!_canDraw) return;
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self.view];
    [_consecutivePoints addObject:[NSValue valueWithCGPoint:point]];
    [_allPoints addObject:[NSArray arrayWithArray:_consecutivePoints]];
    [_consecutivePoints removeAllObjects];
    _iv.image = [self createDrawingImageInRect:_iv.frame];

    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        std::string classes = getDrawingClassification(_allPoints);
        dispatch_async(dispatch_get_main_queue(), ^{
            NSString *c = [NSString stringWithCString:classes.c_str() encoding:[NSString defaultCStringEncoding]];
            [_lbl setText:c];
        });
    });
}

这里的代码很容易解释,除了createDrawingImageInRectgetDrawingClassification这两种方法外,我们将在后面介绍。

  1. 方法 createDrawingImageInRect使用UIBezierPath's moveToPointaddLineToPoint方法显示用户绘画。 它首先通过触摸事件准备所有完成的笔划,并将所有点存储在_allPoints数组中:
- (UIImage *)createDrawingImageInRect:(CGRect)rect
{
    UIGraphicsBeginImageContextWithOptions(CGSizeMake(rect.size.width, rect.size.height), NO, 0.0);
    UIBezierPath *path = [UIBezierPath bezierPath];

    for (NSArray *cp in _allPoints) {
        bool firstPoint = TRUE;
        for (NSValue *pointVal in cp) {
            CGPoint point = pointVal.CGPointValue;
            if (firstPoint) {
                [path moveToPoint:point];
                firstPoint = FALSE;
            }
            else
                [path addLineToPoint:point];
        }
    }

然后,它准备当前正在进行的笔划中的所有点,并存储在_consecutivePoints中:

    bool firstPoint = TRUE;
    for (NSValue *pointVal in _consecutivePoints) {
        CGPoint point = pointVal.CGPointValue;
        if (firstPoint) {
            [path moveToPoint:point];
            firstPoint = FALSE;
        }
        else
            [path addLineToPoint:point];
    }

最后,它执行实际绘画,并将绘画作为UIImage返回,以显示在UIImageView中:

    path.lineWidth = 6.0;
    [[UIColor blackColor] setStroke];
    [path stroke];

    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}
  1. getDrawingClassification首先使用与上一章相同的代码来加载模型或其映射版本:
std::string getDrawingClassification(NSMutableArray *allPoints) {
    if (!_modelLoaded) {
        tensorflow::Status load_status;

        if (USEMEMMAPPED) {
            load_status = LoadMemoryMappedModel(MODEL_FILE_MEMMAPPED, MODEL_FILE_TYPE, &tf_session, &tf_memmapped_env);
        }
        else {
            load_status = LoadModel(MODEL_FILE, MODEL_FILE_TYPE, &tf_session);
        }

        if (!load_status.ok()) {
            LOG(FATAL) << "Couldn't load model: " << load_status;
            return "";
        }
        _modelLoaded = YES;
    }

然后,它获得总点数并分配一个浮点数数组,然后调用另一个函数normalizeScreenCoordinates(稍后将介绍)将点转换为模型期望的格式:

    if ([allPoints count] == 0) return "";
    int total_points = 0;
    for (NSArray *cp in allPoints) {
        total_points += cp.count;
    }

    float *normalized_points = new float[total_points * 3]; 
    normalizeScreenCoordinates(allPoints, normalized_points);

接下来,我们定义输入和输出节点名称,并创建一个包含总点数的张量:

    std::string input_name1 = "Reshape";
    std::string input_name2 = "Squeeze";
    std::string output_name1 = "dense/BiasAdd";
    std::string output_name2 = "ArgMax"
    const int BATCH_SIZE = 8;

    tensorflow::Tensor seqlen_tensor(tensorflow::DT_INT64, tensorflow::TensorShape({BATCH_SIZE}));
    auto seqlen_mapped = seqlen_tensor.tensor<int64_t, 1>();
    int64_t* seqlen_mapped_data = seqlen_mapped.data();
    for (int i=0; i<BATCH_SIZE; i++) {
        seqlen_mapped_data[i] = total_points;
    }

请注意,在运行train_model.py来训练模型时,我们必须使用与BATCH_SIZE相同的BATCH_SIZE,默认情况下为 8。

保存所有转换点值的另一个张量在这里创建:

    tensorflow::Tensor points_tensor(tensorflow::DT_FLOAT, tensorflow::TensorShape({8, total_points, 3}));
    auto points_tensor_mapped = points_tensor.tensor<float, 3>();
    float* out = points_tensor_mapped.data();
    for (int i=0; i<BATCH_SIZE; i++) {
        for (int j=0; j<total_points*3; j++)
            out[i*total_points*3+j] = normalized_points[j];
    }
  1. 现在,我们运行模型并获得预期的输出:
    std::vector<tensorflow::Tensor> outputs;

    tensorflow::Status run_status = tf_session->Run({{input_name1, points_tensor}, {input_name2, seqlen_tensor}}, {output_name1, output_name2}, {}, &outputs);
    if (!run_status.ok()) {
        LOG(ERROR) << "Getting model failed:" << run_status;
        return "";
    }

    tensorflow::string status_string = run_status.ToString();
    tensorflow::Tensor* logits_tensor = &outputs[0];
  1. 使用修改后的GetTopN版本并解析logits获得最佳结果:
    const int kNumResults = 5;
    const float kThreshold = 0.1f;
    std::vector<std::pair<float, int> > top_results;
    const Eigen::TensorMap<Eigen::Tensor<float, 1, Eigen::RowMajor>, Eigen::Aligned>& logits = logits_tensor->flat<float>();

    GetTopN(logits, kNumResults, kThreshold, &top_results);
    string result = "";
    for (int i=0; i<top_results.size(); i++) {
        std::pair<float, int> r = top_results[i];
        if (result == "")
            result = classes[r.second];
        else result += ", " + classes[r.second];
    }
  1. 通过将logits值转换为 softmax 值来更改GetTopN,然后返回顶部 softmax 值及其位置:
    float sum = 0.0;
    for (int i = 0; i < CLASS_COUNT; ++i) {
        sum += expf(prediction(i));
    }

    for (int i = 0; i < CLASS_COUNT; ++i) {
        const float value = expf(prediction(i)) / sum;
        if (value < threshold) {
            continue;
        }
        top_result_pq.push(std::pair<float, int>(value, i));

        if (top_result_pq.size() > num_results) {
            top_result_pq.pop();
        }
    }
  1. 最后,normalizeScreenCoordinates函数将其在触摸事件中捕获的屏幕坐标中的所有点转换为增量差异 – 这几乎是这个页面中的 Python 方法parse_line的一部分:
void normalizeScreenCoordinates(NSMutableArray *allPoints, float *normalized) {
    float lowerx=MAXFLOAT, lowery=MAXFLOAT, upperx=-MAXFLOAT, uppery=-MAXFLOAT;
    for (NSArray *cp in allPoints) {
        for (NSValue *pointVal in cp) {
            CGPoint point = pointVal.CGPointValue;
            if (point.x < lowerx) lowerx = point.x;
            if (point.y < lowery) lowery = point.y;
            if (point.x > upperx) upperx = point.x;
            if (point.y > uppery) uppery = point.y;
        }
    }
    float scalex = upperx - lowerx;
    float scaley = uppery - lowery;

    int n = 0;
    for (NSArray *cp in allPoints) {
        int m=0;
        for (NSValue *pointVal in cp) {
            CGPoint point = pointVal.CGPointValue;
            normalized[n*3] = (point.x - lowerx) / scalex;
            normalized[n*3+1] = (point.y - lowery) / scaley;
            normalized[n*3+2] = (m ==cp.count-1 ? 1 : 0);
            n++; m++;
        }
    }

    for (int i=0; i<n-1; i++) {
        normalized[i*3] = normalized[(i+1)*3] - normalized[i*3];
        normalized[i*3+1] = normalized[(i+1)*3+1] - normalized[i*3+1];
        normalized[i*3+2] = normalized[(i+1)*3+2];
    }
}

现在,您可以在 iOS 模拟器或设备中运行该应用,开始绘画,并查看模型认为您正在绘画的内容。 图 7.8 显示了一些绘画和分类结果–不是最佳绘画,而是整个过程!

图 7.8:在 iOS 上显示绘画和分类结果

在 Android 中使用绘画分类模型

现在该看看我们如何在 Android 中加载和使用该模型。 在之前的章节中,我们通过使用 Android 应用的build.gradle文件并添加了一行 compile 'org.tensorflow:tensorflow-android:+'仅添加了 TensorFlow 支持。 与 iOS 相比,我们必须构建一个自定义的 TensorFlow 库来修复不同的模型加载或运行错误(例如,在第 3 章,“检测对象及其位置”中,第四章,“变换具有惊人艺术风格的图片”和第五章,“了解简单的语音命令”),Android 的默认 TensorFlow 库对注册的操作和数据类型有更好的支持,这可能是因为 Android 是 Google 的一等公民,而 iOS 是第二名,甚至是第二名。

事实是,当我们处理各种惊人的模型时,我们不得不面对不可避免的问题只是时间问题:我们必须手动为 Android 构建 TensorFlow 库,以修复默认 TensorFlow 库中的一些根本无法应对的错误。 No OpKernel was registered to support Op 'RefSwitch' with these attrs.错误就是这样的错误之一。 对于乐观的开发人员来说,这仅意味着另一种向您的技能组合中添加新技巧的机会。

为 Android 构建自定义 TensorFlow 库

请按照以下步骤手动为 Android 构建自定义的 TensorFlow 库:

  1. 在您的 TensorFlow 根目录中,有一个名为WORKSPACE的文件。 编辑它,并使android_sdk_repositoryandroid_ndk_repository看起来像以下设置(用您自己的设置替换build_tools_version以及 SDK 和 NDK 路径):
android_sdk_repository(
    name = "androidsdk",
    api_level = 23,
    build_tools_version = "26.0.1",
    path = "$HOME/Library/Android/sdk",
)

android_ndk_repository(
    name="androidndk",
    path="$HOME/Downloads/android-ndk-r15c",
    api_level=14)
  1. 如果您还使用过本书中的 iOS 应用,并且已将tensorflow/core/platform/default/mutex.h#include "nsync_cv.h"#include "nsync_mu.h"更改为#include "nsync/public/nsync_cv.h"#include "nsync/public/nsync_mu.h",请参见第 3 章, “检测对象及其位置” 时,您需要将其更改回以成功构建 TensorFlow Android 库(此后,当您使用手动构建的 TensorFlow 库在 Xcode 和 iOS 应用上工作时,需要先添加nsync/public 这两个标头。

Changing tensorflow/core/platform/default/mutex.h back and forth certainly is not an ideal solution. It's supposed to be just as a workaround. As it only needs to be changed when you start using a manually built TensorFlow iOS library or when you build a custom TensorFlow library, we can live with it for now.

  1. 如果您具有支持 x86 CPU 的虚拟模拟器或 Android 设备,请运行以下命令来构建本机 TensorFlow 库:
bazel build -c opt --copt="-D__ANDROID_TYPES_FULL__" //tensorflow/contrib/android:libtensorflow_inference.so \
   --crosstool_top=//external:android/crosstool \
   --host_crosstool_top=@bazel_tools//tools/cpp:toolchain \
   --cpu=x86_64

如果您的 Android 设备像大多数 Android 设备一样支持 armeabi-v7a,请运行以下命令:

bazel build -c opt --copt="-D__ANDROID_TYPES_FULL__" //tensorflow/contrib/android:libtensorflow_inference.so \
   --crosstool_top=//external:android/crosstool \
   --host_crosstool_top=@bazel_tools//tools/cpp:toolchain \
   --cpu=armeabi-v7a

在 Android 应用中使用手动构建的本机库时,您需要让该应用知道该库是针对哪个 CPU 指令集(也称为应用二进制接口ABI))构建的。 Android 支持两种主要的 ABI:ARM 和 X86,而armeabi-v7a是 Android 上最受欢迎的 ABI。 要找出您的设备或仿真器使用的是哪个 ABI,请运行adb -s <device_id> shell getprop ro.product.cpu.abi。 例如,此命令为我的 Nexus 7 平板电脑返回armeabi-v7a,为我的模拟器返回x86_64

如果您具有支持 x86_64 的虚拟仿真器以在开发过程中进行快速测试,并且在设备上进行最终性能测试,则可能要同时构建两者。

构建完成后,您将在bazel-bin/tensorflow/contrib/android文件夹中看到 TensorFlow 本机库文件libtensorflow_inference.so。 将其拖到android/app/src/main/jniLibs/armeabi-v7aandroid/app/src/main/jniLibs/x86_64app文件夹中,如图 7.9 所示:

图 7.9:显示 TensorFlow 本机库文件

  1. 通过运行以下命令构建 TensorFlow 本机库的 Java 接口:
bazel build //tensorflow/contrib/android:android_tensorflow_inference_java

这将在bazel-bin/tensorflow/contrib/android处生成文件libandroid_tensorflow_inference_java.jar。 将文件移动到 android/app/lib文件夹,如图 7.10 所示:

图 7.10:将 Java 接口文件显示到 TensorFlow 库

现在,我们准备在 Android 中编码和测试模型。

开发一个 Android 应用来使用该模型

请按照以下步骤使用 TensorFlow 库和我们先前构建的模型创建一个新的 Android 应用:

  1. 在 Android Studio 中,创建一个名为 QuickDraw 的新 Android 应用,接受所有默认设置。 然后在应用的build.gradle中,将compile files('libs/libandroid_tensorflow_inference_java.jar')添加到依赖项的末尾。 像以前一样创建一个新的assets文件夹,并将quickdraw_frozen_long_blacklist_strip_transformed.pbclasses.txt拖放到该文件夹​​中。
  2. 创建一个名为QuickDrawView的新 Java 类,该类扩展了View,并如下设置字段及其构造器:
public class QuickDrawView extends View {
    private Path mPath;
    private Paint mPaint, mCanvasPaint;
    private Canvas mCanvas;
    private Bitmap mBitmap;
    private MainActivity mActivity;
    private List<List<Pair<Float, Float>>> mAllPoints = new ArrayList<List<Pair<Float, Float>>>();
    private List<Pair<Float, Float>> mConsecutivePoints = new ArrayList<Pair<Float, Float>>();

    public QuickDrawView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mActivity = (MainActivity) context;
        setPathPaint();
    }

mAllPoints用于保存mConsecutivePoints的列表。 QuickDrawView用于主要活动的布局中,以显示用户的绘画。

  1. 如下定义setPathPaint方法:
    private void setPathPaint() {
        mPath = new Path();
        mPaint = new Paint();
        mPaint.setColor(0xFF000000);
        mPaint.setAntiAlias(true);
        mPaint.setStrokeWidth(18);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeJoin(Paint.Join.ROUND);
        mCanvasPaint = new Paint(Paint.DITHER_FLAG);
    }

添加两个实例化BitmapCanvas对象并向用户显示在画布上绘画的重写方法:

@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
    mCanvas = new Canvas(mBitmap);
}

@Override protected void onDraw(Canvas canvas) {
    canvas.drawBitmap(mBitmap, 0, 0, mCanvasPaint);
    canvas.drawPath(mPath, mPaint);
}
  1. 覆盖方法onTouchEvent用于填充mConsecutivePointsmAllPoints,调用画布的drawPath方法,使图无效(以调用onDraw方法),以及(每次使用MotionEvent.ACTION_UP完成笔划线),以启动一个新线程以使用模型对绘画进行分类:
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!mActivity.canDraw()) return true;
        float x = event.getX();
        float y = event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mConsecutivePoints.clear();
                mConsecutivePoints.add(new Pair(x, y));
                mPath.moveTo(x, y);
                break;
            case MotionEvent.ACTION_MOVE:
                mConsecutivePoints.add(new Pair(x, y));
                mPath.lineTo(x, y);
                break;
            case MotionEvent.ACTION_UP:
                mConsecutivePoints.add(new Pair(x, y));
                mAllPoints.add(new ArrayList<Pair<Float, Float>>
                (mConsecutivePoints));
                mCanvas.drawPath(mPath, mPaint);
                mPath.reset();
                Thread thread = new Thread(mActivity);
                thread.start();
                break;
            default:
                return false;
        }
        invalidate();
        return true;
    }
  1. 定义两个将由MainActivity调用的公共方法,以获取所有点并在用户点击重新启动按钮后重置绘画:
    public List<List<Pair<Float, Float>>> getAllPoints() {
        return mAllPoints;
    }

    public void clearAllPointsAndRedraw() {
        mBitmap = Bitmap.createBitmap(mBitmap.getWidth(),   
        mBitmap.getHeight(), Bitmap.Config.ARGB_8888);
        mCanvas = new Canvas(mBitmap);
        mCanvasPaint = new Paint(Paint.DITHER_FLAG);
        mCanvas.drawBitmap(mBitmap, 0, 0, mCanvasPaint);
        setPathPaint();
        invalidate();
        mAllPoints.clear();
    }
  1. 现在打开MainActivity,并使其实现Runnable及其字段,如下所示:
public class MainActivity extends AppCompatActivity implements Runnable {

    private static final String MODEL_FILE = "file:///android_asset/quickdraw_frozen_long_blacklist_strip_transformed.pb";
    private static final String CLASSES_FILE = "file:///android_asset/classes.txt";

    private static final String INPUT_NODE1 = "Reshape";
    private static final String INPUT_NODE2 = "Squeeze";
    private static final String OUTPUT_NODE1 = "dense/BiasAdd";
    private static final String OUTPUT_NODE2 = "ArgMax";

    private static final int CLASSES_COUNT = 345;
    private static final int BATCH_SIZE = 8;

    private String[] mClasses = new String[CLASSES_COUNT];
    private QuickDrawView mDrawView;
    private Button mButton;
    private TextView mTextView;
    private String mResult = "";
    private boolean mCanDraw = false;

    private TensorFlowInferenceInterface mInferenceInterface;
  1. 在主布局文件activity_main.xml中,除了我们之前所做的TextViewButton之外,还创建一个QuickDrawView元素:
<com.ailabby.quickdraw.QuickDrawView
    android:id="@+id/drawview"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"/>
  1. 返回MainActivity; 在其onCreate方法中,将 UI 元素 ID 与字段绑定,为启动/重启按钮设置点击监听器。 然后将classes.txt文件读入字符串数组:
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    mDrawView = findViewById(R.id.drawview);
    mButton = findViewById(R.id.button);
    mTextView = findViewById(R.id.textview);
    mButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            mCanDraw = true;
            mButton.setText("Restart");
            mTextView.setText("");
            mDrawView.clearAllPointsAndRedraw();
        }
    });

    String classesFilename = CLASSES_FILE.split("file:///android_asset/")[1];
    BufferedReader br = null;
    int linenum = 0;
    try {
        br = new BufferedReader(new InputStreamReader(getAssets().open(classesFilename)));
        String line;
        while ((line = br.readLine()) != null) {
            mClasses[linenum++] = line;
        }
        br.close();
    } catch (IOException e) {
        throw new RuntimeException("Problem reading classes file!" , e);
    }
}
  1. 然后从线程的run方法中调用同步方法classifyDrawing
public void run() {
    classifyDrawing();
}

private synchronized void classifyDrawing() {
    try {
        double normalized_points[] = normalizeScreenCoordinates();
        long total_points = normalized_points.length / 3;
        float[] floatValues = new float[normalized_points.length*BATCH_SIZE];

        for (int i=0; i<normalized_points.length; i++) {
            for (int j=0; j<BATCH_SIZE; j++)
                floatValues[j*normalized_points.length + i] = (float)normalized_points[i];
        }

        long[] seqlen = new long[BATCH_SIZE];
        for (int i=0; i<BATCH_SIZE; i++)
            seqlen[i] = total_points;

即将实现的normalizeScreenCoordinates方法将用户绘画点转换为模型期望的格式。 floatValuesseqlen将被输入模型。 请注意,由于模型需要这些确切的数据类型(floatint64),因此我们必须在floatValues中使用floatseqlen中使用long,否则在使用模型时会发生运行时错误。

  1. 创建一个与 TensorFlow 库的 Java 接口以加载模型,向模型提供输入并获取输出:
AssetManager assetManager = getAssets();
mInferenceInterface = new TensorFlowInferenceInterface(assetManager, MODEL_FILE);

mInferenceInterface.feed(INPUT_NODE1, floatValues, BATCH_SIZE, total_points, 3);
mInferenceInterface.feed(INPUT_NODE2, seqlen, BATCH_SIZE);

float[] logits = new float[CLASSES_COUNT * BATCH_SIZE];
float[] argmax = new float[CLASSES_COUNT * BATCH_SIZE];

mInferenceInterface.run(new String[] {OUTPUT_NODE1, OUTPUT_NODE2}, false);
mInferenceInterface.fetch(OUTPUT_NODE1, logits);
mInferenceInterface.fetch(OUTPUT_NODE1, argmax);
  1. 归一化所提取的logits概率并以降序对其进行排序:
double sum = 0.0;
for (int i=0; i<CLASSES_COUNT; i++)
    sum += Math.exp(logits[i]);

List<Pair<Integer, Float>> prob_idx = new ArrayList<Pair<Integer, Float>>();
for (int j = 0; j < CLASSES_COUNT; j++) {
    prob_idx.add(new Pair(j, (float)(Math.exp(logits[j]) / sum) ));
}

Collections.sort(prob_idx, new Comparator<Pair<Integer, Float>>() {
    @Override
    public int compare(final Pair<Integer, Float> o1, final Pair<Integer, Float> o2) {
        return o1.second > o2.second ? -1 : (o1.second == o2.second ? 0 : 1);
    }
});

获取前五个结果并将其显示在TextView中:

mResult = "";
for (int i=0; i<5; i++) {
    if (prob_idx.get(i).second > 0.1) {
        if (mResult == "") mResult = "" + mClasses[prob_idx.get(i).first]; 
        else mResult = mResult + ", " + mClasses[prob_idx.get(i).first]; 
    }
}

runOnUiThread(
    new Runnable() {
        @Override
        public void run() {
            mTextView.setText(mResult);
        }
    });
  1. 最后,实现normalizeScreenCoordinates方法,它是 iOS 实现的便捷端口:
private double[] normalizeScreenCoordinates() {
    List<List<Pair<Float, Float>>> allPoints = mDrawView.getAllPoints();
    int total_points = 0;
    for (List<Pair<Float, Float>> cp : allPoints) {
        total_points += cp.size();
    }

    double[] normalized = new double[total_points * 3];
    float lowerx=Float.MAX_VALUE, lowery=Float.MAX_VALUE, upperx=-Float.MAX_VALUE, uppery=-Float.MAX_VALUE;
    for (List<Pair<Float, Float>> cp : allPoints) {
        for (Pair<Float, Float> p : cp) {
            if (p.first < lowerx) lowerx = p.first;
            if (p.second < lowery) lowery = p.second;
            if (p.first > upperx) upperx = p.first;
            if (p.second > uppery) uppery = p.second;
        }
    }
    float scalex = upperx - lowerx;
    float scaley = uppery - lowery;

    int n = 0;
    for (List<Pair<Float, Float>> cp : allPoints) {
        int m = 0;
        for (Pair<Float, Float> p : cp) {
            normalized[n*3] = (p.first - lowerx) / scalex;
            normalized[n*3+1] = (p.second - lowery) / scaley;
            normalized[n*3+2] = (m ==cp.size()-1 ? 1 : 0);
            n++; m++;
        }
    }

    for (int i=0; i<n-1; i++) {
        normalized[i*3] = normalized[(i+1)*3] - normalized[i*3];
        normalized[i*3+1] = normalized[(i+1)*3+1] - 
                                        normalized[i*3+1];
        normalized[i*3+2] = normalized[(i+1)*3+2];
    }
    return normalized;
}

在您的 Android 模拟器或设备上运行该应用,并享受分类结果的乐趣。 您应该看到类似图 7.11 的内容:

图 7.11:在 Android 上显示绘画和分类结果

既然您已经了解了训练 Quick Draw 模型的全过程,并在 iOS 和 Android 应用中使用了它,那么您当然可以微调训练方法,使其更加准确,并改善移动应用的乐趣。

在本章我们不得不结束有趣旅程之前的最后一个提示是,如果您使用错误的 ABI 构建适用于 Android 的 TensorFlow 本机库,您仍然可以从 Android Studio 构建和运行该应用,但将出现运行时错误java.lang.RuntimeException: Native TF methods not found; check that the correct native libraries are present in the APK.,这意味着您的应用的jniLibs文件夹中没有正确的 TensorFlow 本机库(图 7.9)。 要找出jniLibs内特定 ABI 文件夹中是否缺少该文件,可以从Android Studio | View | Tool Windows中打开Device File Explorer,然后选择设备的data | app | package | lib来查看,如图 7.12 所示。 如果您更喜欢命令行,则也可以使用adb工具找出来。

图 7.12:使用设备文件资源管理器检出 TensorFlow 本机库文件

总结

在本章中,我们首先描述了绘画分类模型的工作原理,然后介绍了如何使用高级 TensorFlow Estimator API 训练这种模型。 我们研究了如何编写 Python 代码以使用经过训练的模型进行预测,然后详细讨论了如何找到正确的输入和输出节点名称以及如何以正确的方式冻结和转换模型以使移动应用可以使用它。 我们还提供了一种新方法来构建新的 TensorFlow 自定义 iOS 库,并提供了一个逐步教程,以构建适用于 Android 的 TensorFlow 自定义库,以修复使用模型时的运行时错误。 最后,我们展示了 iOS 和 Android 代码,这些代码捕获并显示用户绘画,将其转换为模型所需的数据,并处理和呈现模型返回的分类结果。 希望您在漫长的旅途中学到了很多东西。

到目前为止,除了来自其他开放源代码项目的几个模型以外,所有由我们自己进行预训练或训练的模型,我们在 iOS 和 Android 应用中使用的都是 TensorFlow 开放源代码项目,当然,该项目提供了大量强大的模型,其中一些模型在强大的 GPU 上进行了数周的训练。 但是,如果您有兴趣从头开始构建自己的模型,并且还对本章中使用和应用的强大 RNN 模型以及概念感到困惑,那么下一章就是您所需要的:我们将讨论如何从头开始构建自己的 RNN 模型并在移动应用中使用它,从而带来另一种乐趣-从股市中赚钱-至少我们会尽力做到这一点。 当然,没有人能保证您每次都能从每次股票交易中获利,但是至少让我们看看我们的 RNN 模型如何帮助我们提高这样做的机会。