机器学习教程:使用摄像头在浏览器上玩真人快打

在尝试改进Guess.js的预测模型时,我开始研究深度学习。我主要关注RNN,特别是LSTM,因为它们在Guess.js领域具有不合理的有效性(unreasonable effectiveness)。并且,我开始使用CNN,虽然传统上不那么常用,但也可用于时间序列。CNN通常用于图像分类,识别和检测。

机器学习教程:使用摄像头在浏览器上玩真人快打

使用TensorFlow.js 控制MK.js.

你可以在我的GitHub帐户中找到本文和MK.js的源代码(文末)。我没有分享我用于训练的数据集,但你可以自己随意收集并训练模型!此外,要了解一切如何协同工作,请随意使用下面的窗口小部件(请访问文末原文使用)。

在使用CNN后,我想起几年前我做过的一个实验,当时浏览器厂商引入了getUserMedia API。在这个实验中,我使用用户的相机作为控制器来玩《真人快打3》的JavaScript复制版本。作为实验的一部分,我实现了一种基本的姿势检测算法,算法将图像分为以下几类:

  • 左拳和右拳
  • 左踢腿和右踢腿
  • 左右走
  • 以上都不是

游戏:https://github.com/mgechev/mk.js

算法很简单,我可以用几句话解释它:

该算法拍摄用户背后的背景快照。算法就会找出原始背景帧和当前帧(用户就在其中)之间的区别。这样,它就能够检测用户身体的位置。作为下一步,算法在黑色画布上呈现用户的身体为白色。之后,它构建了一个垂直和水平直方图,将每个像素的值相加。基于计算结果,算法检测当前用户姿势是什么。

你可以在下面的视频中找到实施的演示。源代码在我的GitHub帐户。

虽然我在控制我的小型、可复制的方面取得了成功,但算法远非完美。它需要一个具有用户背后背景的帧。为了使检测过程正常工作,在整个程序执行过程中,背景帧需要保持相同的颜色。这种限制意味着光线,阴影等的变化会引起干扰和不准确的结果。并导致该算法无法识别基于背景帧的动作,它会将其他帧分类为预定义的姿势。

现在,鉴于Web平台的API,特别是WebGL的进步,我决定通过使用TensorFlow.js来解决问题。

介绍

在本文中,我将分享使用TensorFlow.js和MobileNet构建姿势分类算法的经验。在此过程中,我们将关注如下主题:

  • 收集图像分类的训练数据
  • 使用imgaug执行数据增强
  • 使用MobileNet迁移学习
  • 二元分类和n元分类
  • 使用Node.js训练用于图像分类的TensorFlow.js模型并在浏览器中使用它
  • 简述使用LSTM的行动分类

在这里,我们将问题放宽到基于单个帧的姿势检测上,而不是从一系列帧中识别动作。我们将开发一种有监督的深度学习模型,模型使用来自用户的笔记本电脑相机的图像,检测用户是否进行了出拳和踢腿。

在文章的最后,我们可以建立一个模型来玩真人快打:

机器学习教程:使用摄像头在浏览器上玩真人快打

想要了解本文的大部分内容,读者应该熟悉软件工程和JavaScript的基本概念。对深度学习有基本理解更佳。

收集数据

深度学习模型的准确性在很大程度上取决于训练数据的质量。我们要有一个丰富的训练集,类似于我们将在生产系统中得到的输入。

我们的模型应该能够识别拳击和踢腿。这意味着我们应该收集三个不同类别的图像:

  • 其他

在这个实验中,我在两名志愿者(lili_vs和gsamokovarov)的帮助下收集了照片。我们在MacBook Pro上用QuickTime录制了5个视频,每个视频包含2-4个出拳和2-4个踢腿。

现在,由于我们需要图像,而不是mov视频,我们可以使用ffmpeg,提取单个帧并将它们存储为jpg图片:

ffmpeg -i video.mov $filename%03d.jpg

要运行上述命令,你可能需要先在计算机上安装ffmpeg。

如果我们想训练模型,我们必须提供输入及其相应的输出,在这一步,我们有一堆三个人采取不同姿势的图像。为了构建我们的数据,我们必须对我们在上面三个类别中提取的视频中的帧进行分类 – 出拳,踢腿,其他。对于每个类别,我们可以创建一个单独的目录并将相应的图像挪进去。

这样,在每个目录中我们应该有大约200个图像,类似于下面:

机器学习教程:使用摄像头在浏览器上玩真人快打

请注意,在“Others”目录中,我们可能会有更多的图像,因为出拳和踢腿的照片比走路、转身的照片要少。如果我们一个类有太多图像并且我们在所有类上训练模型,那么我们就有可能使它偏向于这个特定的类。所以,即使我们试图对一个人出拳的图像进行分类,神经网络很可能会输出“Others”这个类。为了减少这种偏差,我们可以删除“Others”目录中的一些照片,使训练模型时每个类别的图像数量相同。

为方便起见,我们将在数字之后将各个目录中的图像命名1为190,因此第一个图像将是1.jpg,第二个是2.jpg,等等。

如果我们在相同的环境中仅使用相同的人员拍摄的600张照片来训练模型,我们将无法达到很高的准确度。为了从我们的数据中提取尽可能多的价值,我们可以通过使用数据增强生成一些额外的样本。

数据增强

数据增强是一种让我们通过现有数据集合成新数据点来增加数据点数量的技术。通常,我们使用数据增强来增加训练集的大小和种类。我们将原始图像传递给产生新图像的转换管道。我们不应该让这种转换过于激烈 – 这种转换应用在分类为出拳的图像上,应该产生可分类为出拳的其他图像。

对我们的图像进行有效的转换将是旋转,反相颜色,模糊图像等。在编写代码的时候,JavaScript没有很多可用的选择,所以我使用了一个用Python实现的库—imgaug。它包含一组可以应用概率的增强器。

这是我在此实验中应用的数据增强:

np.random.seed(44)
ia.seed(44)

def main():
    for i in range(1, 191):
        draw_single_sequential_images(str(i), "others", "others-aug")
    for i in range(1, 191):
        draw_single_sequential_images(str(i), "hits", "hits-aug")
    for i in range(1, 191):
        draw_single_sequential_images(str(i), "kicks", "kicks-aug")

def draw_single_sequential_images(filename, path, aug_path):
    image = misc.imresize(ndimage.imread(path + "/" + filename + ".jpg"), (56, 100))
    sometimes = lambda aug: iaa.Sometimes(0.5, aug)
    seq = iaa.Sequential(
        [
            iaa.Fliplr(0.5), # horizontally flip 50% of all images
            # crop images by -5% to 10% of their height/width
            sometimes(iaa.CropAndPad(
                percent=(-0.05, 0.1),
                pad_mode=ia.ALL,
                pad_cval=(0, 255)
            )),
            sometimes(iaa.Affine(
                scale={"x": (0.8, 1.2), "y": (0.8, 1.2)}, # scale images to 80-120% of their size, individually per axis
                translate_percent={"x": (-0.1, 0.1), "y": (-0.1, 0.1)}, # translate by -10 to +10 percent (per axis)
                rotate=(-5, 5),
                shear=(-5, 5), # shear by -5 to +5 degrees
                order=[0, 1], # use nearest neighbour or bilinear interpolation (fast)
                cval=(0, 255), # if mode is constant, use a cval between 0 and 255
                mode=ia.ALL # use any of scikit-image's warping modes (see 2nd image from the top for examples)
            )),
            iaa.Grayscale(alpha=(0.0, 1.0)),
            iaa.Invert(0.05, per_channel=False), # invert color channels
            # execute 0 to 5 of the following (less important) augmenters per image
            # don't execute all of them, as that would often be way too strong
            iaa.SomeOf((0, 5),
                [
                    iaa.OneOf([
                        iaa.GaussianBlur((0, 2.0)), # blur images with a sigma between 0 and 2.0
                        iaa.AverageBlur(k=(2, 5)), # blur image using local means with kernel sizes between 2 and 5
                        iaa.MedianBlur(k=(3, 5)), # blur image using local medians with kernel sizes between 3 and 5
                    ]),
                    iaa.Sharpen(alpha=(0, 1.0), lightness=(0.75, 1.5)), # sharpen images
                    iaa.Emboss(alpha=(0, 1.0), strength=(0, 2.0)), # emboss images
                    iaa.AdditiveGaussianNoise(loc=0, scale=(0.0, 0.01*255), per_channel=0.5), # add gaussian noise to images
                    iaa.Add((-10, 10), per_channel=0.5), # change brightness of images (by -10 to 10 of original value)
                    iaa.AddToHueAndSaturation((-20, 20)), # change hue and saturation
                    # either change the brightness of the whole image (sometimes
                    # per channel) or change the brightness of subareas
                    iaa.OneOf([
                        iaa.Multiply((0.9, 1.1), per_channel=0.5),
                        iaa.FrequencyNoiseAlpha(
                            exponent=(-2, 0),
                            first=iaa.Multiply((0.9, 1.1), per_channel=True),
                            second=iaa.ContrastNormalization((0.9, 1.1))
                        )
                    ]),
                    iaa.ContrastNormalization((0.5, 2.0), per_channel=0.5), # improve or worsen the contrast
                ],
                random_order=True
            )
        ],
        random_order=True
    )

    im = np.zeros((16, 56, 100, 3), dtype=np.uint8)
    for c in range(0, 16):
        im[c] = image

    for im in range(len(grid)):
        misc.imsave(aug_path + "/" + filename + "_" + str(im) + ".jpg", grid[im])

上面的脚本有一个main方法,它有三个for循环(每个图像类别一个)。在每次迭代时,在每个循环中,我们调用方法draw_single_sequential_images,图像名称作为第一个参数,图像路径作为第二个参数,函数应存储增强图像的目录作为第三个参数。

之后,我们从磁盘读取图像并对其应用一组转换。我已经记录了上面代码段中的大部分转换,所以这里不再赘述。

对于现有数据集中的每个图像,转换产生16个图像。以下是增强的图像示例:

机器学习教程:使用摄像头在浏览器上玩真人快打

请注意,在上面的脚本中,我们将图像缩放为100×56像素。我们这样做是为了减少数据量和我们的模型在训练和评估过程中必须执行的计算量。

构建模型

现在让我们构建分类模型!

由于我们正在处理图像,我们将使用CNN。该网络架构适用于图像识别,对象检测和分类。

迁移学习

下图显示了VGG-16,一种流行的CNN,用于图像分类。

机器学习教程:使用摄像头在浏览器上玩真人快打

VGG-16网络可识别1000类图像。它有16层(我们不计入输出和池化层)。这种多层网络在实践中很难训练。它需要一个大型数据集和大量的训练。

受过训练的CNN中的隐藏层从边缘开始识别来自其​​训练集的图像的不同特征,并转向更高级的特征,例如形状,特殊对象等。经过训练的CNN,类似于VGG-16,可识别大量图像,其隐藏层可从其训练集中发现许多特征。这些特征在大多数图像之间是共同的,并且可以分别在不同的任务之间重复使用。

转移学习允许我们重用已经存在且经过训练的网络。我们可以从现有网络的任何层获取输出,并将其作为输入提供给新的神经网络。这样,通过训练新创建的神经网络,随着时间的推移,可以教它识别新的、更高级别的特征,并正确地对源模型从未见过的类中的图像进行分类。

机器学习教程:使用摄像头在浏览器上玩真人快打

为此,我们将使用来自@ tensorflow-models / mobilenet包的MobileNet神经网络。MobileNet与VGG-16一样强大,但它小得多,这使得它的前向传播速度更快,并减少浏览器的加载时间。MobileNet已经在ILSVRC-2012-CLS图像分类数据集上进行了训练(你可以访问原文相应的窗口小部件中尝试使用MobileNet。它可以随意从文件系统中选择图像或使用相机作为输入)。

当我们使用转移学习开发模型时,我们需要:

  1. 使用源模型层的输出作为目标模型的输入。
  2. 如果有的目标模型的话,我们要从目标模型中训练多少层?

第一点非常重要。根据我们选择的层,我们将获得更低或更高抽象级别的特征,作为我们神经网络的输入。

出于我们的目的,我们不从MobileNet训练任何层。我们从中选择输出global_average_pooling2d_1并将其作为输入传递给我们的小型模型。为什么我选择这个层?经验!我做了一些测试,这一层表现相当不错。

定义模型

将图像分为三类 – punch, kick和other。让我们首先解决一个较简单的问题 – 检测用户是否在帧上出拳(punch)。这个任务是典型的二元分类问题。因此,我们可以定义以下模型:

import * as tf from '@tensorflow/tfjs';

const model = tf.sequential();
model.add(tf.layers.inputLayer({ inputShape: [1024] }));
model.add(tf.layers.dense({ units: 1024, activation: 'relu' }));
model.add(tf.layers.dense({ units: 1, activation: 'sigmoid' }));
model.compile({
  optimizer: tf.train.adam(1e-6),
  loss: tf.losses.sigmoidCrossEntropy,
  metrics: ['accuracy']
});

上面的代码片段定义了一个简单的模型,其中包含带有1024个神经单元和ReLU激活层,以及一个通过sigmoid激活函数的输出单元。sigmoid将产生一个介于0和1之间的数字,这取决于用户在给定帧上出拳的概率。

为什么我为第二层选择1024单元和1e-6学习率?因为,我尝试了几种不同的选择,发现1024和1e-6效果最好。“尝试和看到什么”可能听起来不是最好的方法,但这基本就是深度学习中超参数的调整的方法 – 基于我们对模型的理解,我们使用直觉来更新正交参数并凭经验检查模型是否表现很好。

compile方法将各层编译在一起,为训练和评估准备模型。这里我们声明我们要用adam用作优化算法。我们还声明我们想用Sigmoid函数计算损失,并且我们指定我们想要评估模型的准确性。TensorFlow.js使用以下公式计算准确性:

Accuracy = (True Positives + True Negatives) / (Positives + Negatives)

要将MobileNet作为源模型应用转移学习,我们首先需要加载它。因为在浏览器中使用超过3k的图像来训练模型肯定不现实,所以我们将使用Node.js并从文件加载网络。

下载MobileNet :https://github.com/mgechev/mk-tfjs/tree/master/mobile-net

在目录中,你可以找到model.json文件,它包含模型的架构 – 层,激活等等。其余文件包含模型的参数。你可以使用以下命令从文件中加载模型:

export const loadModel = async () => {
  const mn = new mobilenet.MobileNet(1, 1);
  mn.path = `file://PATH/TO/model.json`;
  await mn.load();
  return (input): tf.Tensor1D =>
      mn.infer(input, 'global_average_pooling2d_1')
        .reshape([1024]);
};

请注意,在该loadModel方法中,我们返回一个函数,该函数接受一维张量作为输入并返回mn.infer(input, Layer)。MobileNet的infer方法接受输入张量和层作为参数。该层指定我们要从哪个隐藏层获取输出。如果你打开model.json并搜索global_average_pooling2d_1,你会发现它是其中一个层的名称。

现在,为了训练这个模型,我们必须创建训练集。为此,我们必须通过MobileNet的infer方法传递我们的每一个图像并将标签与它相关联 – 1对应包含出拳的图像,0对应没有出拳的图像:

const punches = require('fs')
  .readdirSync(Punches)
  .filter(f => f.endsWith('.jpg'))
  .map(f => `${Punches}/${f}`);

const others = require('fs')
  .readdirSync(Others)
  .filter(f => f.endsWith('.jpg'))
  .map(f => `${Others}/${f}`);

const ys = tf.tensor1d(
  new Array(punches.length).fill(1)
    .concat(new Array(others.length).fill(0)));

const xs: tf.Tensor2D = tf.stack(
  punches
    .map((path: string) => mobileNet(readInput(path)))
    .concat(others.map((path: string) => mobileNet(readInput(path))))
) as tf.Tensor2D;

在上面的代码片段中,我们首先读取目录中包含punches图片和其他图片的文件。之后,我们定义一个包含输出标签的一维张量。如果我们有n个出拳图像和m个其他图像,张量就有n个值为1的元素,和m个值为0的元素。

在xs中,我们为单个图像调用MobileNet的Inferer方法的结果进行堆栈。请注意,对于每个图像,我们都会调用readInput方法。我们来看看它的实现:

export const readInput = img => imageToInput(readImage(img), TotalChannels);

const readImage = path => jpeg.decode(fs.readFileSync(path), true);

const imageToInput = image => {
  const values = serializeImage(image);
  return tf.tensor3d(values, [image.height, image.width, 3], 'int32');
};

const serializeImage = image => {
  const totalPixels = image.width * image.height;
  const result = new Int32Array(totalPixels * 3);
  for (let i = 0; i < totalPixels; i++) {
    result[i * 3 + 0] = image.data[i * 4 + 0];
    result[i * 3 + 1] = image.data[i * 4 + 1];
    result[i * 3 + 2] = image.data[i * 4 + 2];
  }
  return result;
};

readInput首先调用函数readImage,然后将其调用委托给imageToInput。readImage从磁盘读取图像,然后使用jpeg-js包将缓冲区解码为jpg图像。在imageToInput中,我们将图像转换为三维张量。

最后,对于从0到TotalImages的每个i,如果xs[i]对应的是一个出拳的图像,则ys[i]为1,否则为0。

训练模型

现在我们准备训练模型了!为此,调用模型实例的方法fit:

await model.fit(xs, ys, {
  epochs: Epochs,
  batchSize: parseInt(((punches.length + others.length) * BatchSize).toFixed(0)),
  callbacks: {
    onBatchEnd: async (_, logs) => {
      console.log('Cost: %s, accuracy: %s', logs.loss.toFixed(5), logs.acc.toFixed(5));
      await tf.nextFrame();
    }
  }
});

上面的代码调用fit,使用三个参数xs,ys以及一个配置对象。在配置对象中,我们设置了我们想要训练模型的周期数,我们提供了批量大小,以及一个回调,TensorFlow.js会在每批之后调用这个回调。

批量大小决定了xs和ys我们每个周期训练我们的模型要用多大的子集。对每个周期,TensorFlow.js将从中选择一个子集xs和相应的元素ys,它将执行前向传播,通过sigmoid激活从层获取输出,然后基于损失,它将使用adam算法进行优化。

运行训练脚本后,你应该看到类似于下面的输出:

Cost: 0.84212, accuracy: 1.00000
eta=0.3 >---------- acc=1.00 loss=0.84 Cost: 0.79740, accuracy: 1.00000
eta=0.2 =>--------- acc=1.00 loss=0.80 Cost: 0.81533, accuracy: 1.00000
eta=0.2 ==>-------- acc=1.00 loss=0.82 Cost: 0.64303, accuracy: 0.50000
eta=0.2 ===>------- acc=0.50 loss=0.64 Cost: 0.51377, accuracy: 0.00000
eta=0.2 ====>------ acc=0.00 loss=0.51 Cost: 0.46473, accuracy: 0.50000
eta=0.1 =====>----- acc=0.50 loss=0.46 Cost: 0.50872, accuracy: 0.00000
eta=0.1 ======>---- acc=0.00 loss=0.51 Cost: 0.62556, accuracy: 1.00000
eta=0.1 =======>--- acc=1.00 loss=0.63 Cost: 0.65133, accuracy: 0.50000
eta=0.1 ========>-- acc=0.50 loss=0.65 Cost: 0.63824, accuracy: 0.50000
eta=0.0 ==========>
293ms 14675us/step - acc=0.60 loss=0.65
Epoch 3 / 50
Cost: 0.44661, accuracy: 1.00000
eta=0.3 >---------- acc=1.00 loss=0.45 Cost: 0.78060, accuracy: 1.00000
eta=0.3 =>--------- acc=1.00 loss=0.78 Cost: 0.79208, accuracy: 1.00000
eta=0.3 ==>-------- acc=1.00 loss=0.79 Cost: 0.49072, accuracy: 0.50000
eta=0.2 ===>------- acc=0.50 loss=0.49 Cost: 0.62232, accuracy: 1.00000
eta=0.2 ====>------ acc=1.00 loss=0.62 Cost: 0.82899, accuracy: 1.00000
eta=0.2 =====>----- acc=1.00 loss=0.83 Cost: 0.67629, accuracy: 0.50000
eta=0.1 ======>---- acc=0.50 loss=0.68 Cost: 0.62621, accuracy: 0.50000
eta=0.1 =======>--- acc=0.50 loss=0.63 Cost: 0.46077, accuracy: 1.00000
eta=0.1 ========>-- acc=1.00 loss=0.46 Cost: 0.62076, accuracy: 1.00000
eta=0.0 ==========>
304ms 15221us/step - acc=0.85 loss=0.63

注意:随着时间的推移准确性会增加,损失会减少。

使用我的数据集,在模型训练完成后,我达到了92%的准确度。我做了一个小部件,你可以在其中使用预训练的模型。你可以从计算机中选择图像,或者使用相机拍摄图像并将其分类为出拳或没有(访问文末链接)。

不过,由于我提供的小型训练集,准确性可能不会很高。

在浏览器中运行模型

在上一节中,我们训练了二元分类的模型。现在让我们在浏览器中运行它,并将它与MK.js连接在一起

const video = document.getElementById('cam');
const Layer = 'global_average_pooling2d_1';
const mobilenetInfer = m => (p): tf.Tensor<tf.Rank> => m.infer(p, Layer);
const canvas = document.getElementById('canvas');
const scale = document.getElementById('crop');

const ImageSize = {
  Width: 100,
  Height: 56
};

navigator.mediaDevices
  .getUserMedia({
    video: true,
    audio: false
  })
  .then(stream => {
    video.srcObject = stream;
  });

在上面的代码段中,我们声明:

  • video – 包含对页面上HTML5视频元素的引用
  • Layer – 包含我们想要从中获取输出并将其作为输入传递给我们的模型的MobileNet层的名称
  • mobilenetInfer – 是一个接受MobileNet实例并返回另一个函数的函数。返回的函数接受输入并从指定的MobileNet层返回相应的输出
  • canvas – 指向我们将用于从视频中提取帧的HTML5canvas元素(画布元素)
  • scale – 是我们将用于缩放各个帧的另一个画布

之后,我们从用户的相机获取视频流并将其设置为视频元素的源。

下一步,我们将实现一个灰度过滤器,它接受画布并转换其内容:

const grayscale = (canvas: HTMLCanvasElement) => {
  const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;
  for (let i = 0; i < data.length; i += 4) {
    const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
    data[i] = avg;
    data[i + 1] = avg;
    data[i + 2] = avg;
  }
  canvas.getContext('2d').putImageData(imageData, 0, 0);
};

下一步,让我们将模型与MK.js连接起来:

let mobilenet: (p: any) => tf.Tensor<tf.Rank>;
tf.loadModel('http://localhost:5000/model.json').then(model => {
  mobileNet
    .load()
    .then((mn: any) => mobilenet = mobilenetInfer(mn))
    .then(startInterval(mobilenet, model));
});

在上面的代码中,首先,我们加载我们上面训练的模型,然后加载MobileNet。我们将MobileNet传递给mobilenetInfer方法,这样我们就可以得到从网络的隐藏层计算输出的捷径。之后,我们调用使用两个网络作为参数的startInterval方法。

const startInterval = (mobilenet, model) => () => {
  setInterval(() => {
    canvas.getContext('2d').drawImage(video, 0, 0);

    grayscale(scale
      .getContext('2d')
      .drawImage(
        canvas, 0, 0, canvas.width,
        canvas.width / (ImageSize.Width / ImageSize.Height),
        0, 0, ImageSize.Width, ImageSize.Height
      ));

    const [punching] = Array.from((
      model.predict(mobilenet(tf.fromPixels(scale))) as tf.Tensor1D)
    .dataSync() as Float32Array);

    const detect = (window as any).Detect;
    if (punching >= 0.4) detect && detect.onPunch();

  }, 100);
};

startInterval是有趣的地方!首先,我们启动一个间隔,每100ms我们调用一个anonymous函数。在这个函数中,我们首先在包含当前帧的画布上渲染视频。之后,我们缩小帧到100×56,并对其应用灰度滤镜。

下一步,我们将缩放的帧传递给MobileNet,我们从所需的隐藏层得到输出并将其作为输入传递给我们的模型的predict方法。我们模型的predict方法返回一个具有单个元素的张量。通过使用dataSync我们从张量中获取值并将其分配给常量“punching”。

最后,我们检查用户在这个帧上出拳的概率是否超过0.4,根据这个结果,我们调用全局对象Detect的onPunch方法。MK.js使用三种方法公开一个全局对象:onKick,onPunch和onStand,我们可以使用这些来控制其中一个角色。结果如下:

机器学习教程:使用摄像头在浏览器上玩真人快打

用N元分类识别踢腿和出拳

在这里,我们将制作一个更智能的模型 – 我们将开发一个识别出拳,踢腿和其他图像的神经网络。我们从准备训练集的过程开始:

const punches = require('fs')
  .readdirSync(Punches)
  .filter(f => f.endsWith('.jpg'))
  .map(f => `${Punches}/${f}`);

const kicks = require('fs')
  .readdirSync(Kicks)
  .filter(f => f.endsWith('.jpg'))
  .map(f => `${Kicks}/${f}`);

const others = require('fs')
  .readdirSync(Others)
  .filter(f => f.endsWith('.jpg'))
  .map(f => `${Others}/${f}`);

const ys = tf.tensor2d(
  new Array(punches.length)
    .fill([1, 0, 0])
    .concat(new Array(kicks.length).fill([0, 1, 0]))
    .concat(new Array(others.length).fill([0, 0, 1])),
  [punches.length + kicks.length + others.length, 3]
);

const xs: tf.Tensor2D = tf.stack(
  punches
    .map((path: string) => mobileNet(readInput(path)))
    .concat(kicks.map((path: string) => mobileNet(readInput(path))))
    .concat(others.map((path: string) => mobileNet(readInput(path))))
) as tf.Tensor2D;

就像之前一样,首先我们读取包含出拳,踢腿和其他图像的目录。但,我们的预期输出为二维张量,而不是一维。如果我们有n个出拳的图像,m个踢腿的图像和k个其他图像,ys张量将有n个值为[1, 0, 0]的元素,m个值为[0, 1, 0]的元素和k个值为[0, 0, 1]的元素。在图像对应于出拳的情况下,我们将向量[1, 0, 0]与之对应。以此类推,踢腿关联的图像[0, 1, 0],其他[0, 0, 1]。

一个有n个元素的向量,有n – 1个元素是0,有一个元素是0,我们称为独热向量。

然后,我们通过从MobileNet上叠加每个图像的输出来形成输入张量xs。

为此,我们必须更新模型定义:

const model = tf.sequential();
model.add(tf.layers.inputLayer({ inputShape: [1024] }));
model.add(tf.layers.dense({ units: 1024, activation: 'relu' }));
model.add(tf.layers.dense({ units: 3, activation: 'softmax' }));
await model.compile({
  optimizer: tf.train.adam(1e-6),
  loss: tf.losses.sigmoidCrossEntropy,
  metrics: ['accuracy']
});

与之前模型的不同之处是:

  • 输出层中的单元数
  • 输出层的激活

我们在输出层中有3个单元的原因是我们有三种不同的图像类别:

  • Punching
  • Kicking
  • Others

在这些3单元之上调用的softmax激活将其参数转换为具有3值的张量。输出层的单元之所以为3,是因为,我们可以用2位来表示3个值(00,01,10,每一个都是我们的类)。softmax产生的张量的值和等于1,这意味着我们永远不会得到00,因此我们永远无法对其中一个类别的图像进行分类。

在训练500次之后,我取得了92%的准确性!这很不错,别忘了这是训练在一个小数据集上。

下一步是在浏览器中运行模型!这个逻辑非常类似于二元分类模型,让我们看看最后一步,我们根据模型的输出选择一个动作:

const [punch, kick, nothing] = Array.from((model.predict(
  mobilenet(tf.fromPixels(scaled))
) as tf.Tensor1D).dataSync() as Float32Array);

const detect = (window as any).Detect;
if (nothing >= 0.4) return;

if (kick > punch && kick >= 0.35) {
  detect.onKick();
  return;
}
if (punch > kick && punch >= 0.35) detect.onPunch();

最初,我们调用缩放的、灰度画布的MobileNet,之后我们将输出传递给我们训练好的模型。模型返回一个使用dataSync将其转换为Float32Array的一维张量。下一步,通过使用Array.from我们将类型化的数组转换为JavaScript数组,我们提取帧上的姿势的概率(即,出拳、踢腿和其他)。

如果既不是踢腿也不是出拳的姿势的概率高于0.4,我们返回。否则,如果我们有更高的概率认为帧显示是踢腿,并且这个概率高于0.32我们向MK.js发出踢腿命令。反之,如更高的概率认为帧显示是踢腿,并且概率高于阈值,那么我们发出一个出拳的动作。

结果:

机器学习教程:使用摄像头在浏览器上玩真人快打

窗口小部件请访问文末链接。

行动识别

如果我们收集大量不同的人物出拳和踢腿的数据集,我们将能够建立一个在单个帧上表现出色的模型。但是,这就够了吗?如果我们想要更进一步并区分两种不同类型的踢腿,比如后踢和回旋踢怎么办?

如下图显示,从某个角度看,两个踢法在特定的时间点看起来都很相似:

机器学习教程:使用摄像头在浏览器上玩真人快打

机器学习教程:使用摄像头在浏览器上玩真人快打

但是如果我们看看动作执行情况,就可以看出动作就完全不同了:

机器学习教程:使用摄像头在浏览器上玩真人快打

那么,我们如何教我们的神经网络来查看一系列帧而不是单个帧呢?

这里,我们可以使用RNN。RNN非常适合处理时间序列

不过,实现这种的模型已经超出了本文的范围,我们可以看一下示例架构,以便我们可以直观地了解所有东西是如何协同工作!

RNN的力量

动作识别模型图:

机器学习教程:使用摄像头在浏览器上玩真人快打

我们从视频中获取最后n帧并将它们传递给CNN。每帧的CNN输出,我们作为输入传递给RNN。RNN将找出各个帧之间的依赖关系并识别它们编码的动作。

GitHub:https://github.com/mgechev

尝试窗口小部件访问:https://blog.mgechev.com/2018/10/20/transfer-learning-tensorflow-js-data-augmentation-mobile-net/

 

本文为ATYUN(www.atyun.com)编译作品,ATYUN专注人工智能
请扫码或微信搜索ATYUN订阅号及时获取最新内容

发表评论