- PVSM.RU - https://www.pvsm.ru -
Экспериментируя с улучшениями для модели прогнозирования Guess.js [1], я стал присматриваться к глубокому обучению: к рекуррентным нейронным сетям (RNN), в частности, LSTM из-за их «необоснованной эффективности» [2] в той области, где работает Guess.js. В то же время я начал играться с свёрточными нейросетями (CNN), которые тоже часто используются для временных рядов. CNN обычно используют для классификации, распознавания и обнаружения изображений.
Управление MK.js [3] с помощью TensorFlow.js
Исходный код для этой статьи [4] и МК.js [3] лежат у меня на GitHub [5]. Я не выложил набор данных для обучения, но можете собрать свои собственные и обучить модель, как описано ниже!
Поиграв с CNN, я вспомнил об эксперименте [6], который проводил несколько лет назад, когда разработчики браузеров выпустили getUserMedia
API. В нём камера пользователя служила контроллером для воспроизведения небольшого JavaScript-клона Mortal Kombat 3. Вы можете найти ту игру в репозитории GitHub [3]. В рамках эксперимента я реализовал базовый алгоритм определения положения, который классифицирует изображение по следующим классам:
Алгоритм настолько прост, что я могу объяснить его в нескольких фразах:
Алгоритм фотографирует фон. Как только пользователь появляется в кадре, алгоритм вычисляет разницу между фоном и текущим кадром с пользователем. Так он определяет положение фигуры пользователя. Следующий шаг — отображение тела пользователя белым на чёрном. После этого строятся вертикальная и горизонтальная гистограммы, суммирующие значения для каждого пикселя. На основе этого вычисления алгоритм определяет текущее положение тела.
На видео показано, как работает программа. Исходный код на GitHub [7].
Хотя крошечный клон MK работал успешно, алгоритм далёк от совершенства. Требуется кадр с фоном. Для правильной работы фон должен быть одинакового цвета на протяжении всего выполнения программы. Такое ограничение означает, что изменения света, тени и прочего внесут помехи и дадут неточный результат. Наконец, алгоритм не распознаёт действия; он классифицирует новый кадр всего лишь как положение тела из предопределённого набора.
Теперь, благодаря прогрессу в веб-API, а именно WebGL, я решил вернуться к этой задаче, применив TensorFlow.js.
В этом статье я поделюсь опытом создания алгоритма классификации положений тела с помощью TensorFlow.js и MobileNet. Рассмотрим следующие темы:
В этой статье мы сведём проблему до определения положения тела на основе одного кадра, в отличие от распознавания действия по последовательности кадров. Мы разработаем модель глубокого обучения с учителем, которая на основе изображения с веб-камеры пользователя определяет движения человека: удар рукой, ногой или ничего из этого.
К концу статьи мы сможем построить модель для игры в МК.js [3]:
Для лучшего понимания статьи читатель должен быть знаком с фундаментальными понятиями программирования и JavaScript. Базовое понимание глубокого обучения тоже полезно, но не обязательно.
Точность модели глубокого обучения в значительной степени зависит от качества данных. Нужно стремиться к сбору обширного набора данных, как в продакшне.
Наша модель должна уметь распознавать удары руками и ногами. Это означает, что мы должны собирать изображения трёх категорий:
В этом эксперименте мне помогали собирать фотографии два волонтёра (@lili_vs [9] и @gsamokovarov [10]). Мы записали 5 видеороликов QuickTime на моём MacBook Pro, каждый из которых содержит 2−4 удара рукой и 2−4 удара ногой.
Затем используем ffmpeg для извлечения из видеороликов отдельных кадров и сохранения их в виде изображений jpg
:
ffmpeg -i video.mov $filename%03d.jpg
Для выполнения вышеуказанной команды сначала нужно установить [11] на компьютере ffmpeg
.
Если мы хотим обучить модель, то должны предоставить входные данные и соответствующие им выходные данные, но на этом этапе у нас только куча изображений трёх человек в разных позах. Чтобы структурировать данные, нужно классифицировать кадры в трёх категориях: удары руками, ногами и другие. Для каждой категории создаётся отдельная директория, куда перемещаются все соответствующие изображения.
Таким образом, в каждом каталоге должно быть около 200 изображений, похожих на приведённые ниже:
Обратите внимание, что в каталоге «Другие» будет гораздо больше изображений, потому что относительно мало кадров содержат фотографии ударов руками и ногами, а на остальных кадрах люди ходят, оборачиваются или управляют видеозаписью. Если у нас слишком много изображений одного класса, то мы рискуем обучить модель предвзято к этому конкретному классу. В этом случае при классификации изображения с ударом нейронная сеть всё равно может определить класс «Другие». Чтобы уменьшить эту предвзятость, можно удалить некоторые фотографии из каталога «Другие» и обучать модель на равном количестве изображений из каждой категории.
Для удобства присвоим изображениям в каталогах цифры от 1
до 190
, так что первое изображение будет 1.jpg
, второе 2.jpg
и т.д.
Если мы будем обучать модель только на 600 фотографиях, сделанных в одной среде с теми же людьми, то не достигнем очень высокого уровня точности. Чтобы извлечь как можно больше пользы из наших данных, лучше сгенерировать несколько дополнительных выборок с помощью аугментации данных.
Аугментация данных — это техника, которая увеличивает количество точек данных путём синтеза новых точек из существующего набора. Обычно аугментацию используют для увеличения размера и разнообразия обучающего набора. Мы передаём исходные изображения в конвейер преобразований, которые создают новые изображения. Нельзя слишком агрессивно подходить к преобразованиям: из удара рукой должны генерироваться только другие удары рукой.
Допустимые преобразования — вращение, инвертирование цветов, размытие и др. Есть отличные инструменты с открытым исходным кодом для аугментации данных. На момент написания статьи на JavaScript было не слишком много вариантов, поэтому я использовал библиотеку, реализованную на Python — imgaug [8]. В ней есть набор аугментеров, которые можно применять вероятностно.
Вот логика аугментации данных для этого эксперимента:
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 других картинок. Вот пример, как они выглядят:
Обратите внимание, что в приведённом выше скрипте мы масштабируем изображения до 100x56
пикселей. Мы это делаем, чтобы уменьшить объём данных и, соответственно, количество вычислений, которые наша модель выполняет во время обучения и оценки.
Теперь построим модель для классификации!
Поскольку мы имеем дело с изображениями, то используем свёрточную нейросеть (CNN). Эта сетевая архитектура, как известно, подходит для распознавания изображений, обнаружения объектов и классификации.
На рисунке ниже показан популярная CNN VGG-16, используемая для классификации изображений.
Нейросеть VGG-16 распознаёт 1000 классов изображений. У неё 16 слоёв (не считая слои пулинга и выходных данных). Такую многослойную сеть трудно обучить на практике. Для этого потребуется большой набор данных и много часов обучения.
Скрытые слои обученной CNN распознают различные элементы изображений из обучающего набора, начиная с краёв, переходя к более сложным элементам, таким как фигуры, отдельные объекты и так далее. Обученная CNN в стиле VGG-16 для распознавания большого набора изображений должна иметь скрытые слои, которые усвоили много признаков из обучающего набора. Такие признаки будут общими для большинства изображений и, соответственно, многократно использоваться в разных задачах.
Перенос обучения позволяет повторно использовать уже существующую и обученную сеть. Мы можем взять выходные данные из любого из слоёв существующей сети и передать их в качестве входных данных новой нейронной сети. Таким образом, обучая вновь созданную нейросеть, со временем её можно научить распознавать новые признаки более высокого уровня и корректно классифицировать изображения из классов, которых исходная модель никогда раньше не видела.
Для наших целей возьмём нейросеть MobileNet из пакета @tensorflow-models/mobilenet [13]. MobileNet так же мощна, как и VGG-16, но она намного меньше, что ускоряет прямое распространение, то есть активацию сети (forward propagation), и уменьшает время загрузки в браузере. MobileNet обучалась на наборе данных для классификации изображений ILSVRC-2012-CLS [14].
При разработке модели с переносом обучения у нас есть варианты выбора по двум пунктам:
Первый момент весьма существенен. В зависимости от выбранного слоя, мы получим признаки на более низком или более высоком уровне абстракции в качестве входных данных для нашей нейронной сети.
Мы не собираемся обучать какие-то слои MobileNet. Выберем выходные данные из global_average_pooling2d_1
и передадим их в качестве входных данных нашей крошечной модели. Почему я выбрал именно этот слой? Эмпирически. Я сделал несколько тестов, и этот слой работает достаточно хорошо.
Первоначальная задача состояла в том, чтобы классифицировать изображение по трём классам: удары рукой, ногой и другие движения. Давайте сначала решим проблему поменьше: определим, есть в кадре удар рукой или нет. Эта типичная задача бинарной классификации. Для этой цели мы можем определить следующую модель:
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
. Последняя выдаёт число от 0
до 1
, в зависимости от вероятности наличия удара рукой в данном кадре.
Почему я выбрал 1024
юнита для второго уровня и скорость обучения 1e-6
? Ну, я попробовал несколько разных вариантов и увидел, что такие параметры работают лучше всего. «Метод тыка» кажется не лучшим подходом, но в значительной степени именно так работает настройка гиперпараметров в глубоком обучении — основываясь на своём понимании модели, мы используем интуицию для обновления ортогональных параметров и эмпирически проверяем, как работает модель.
Метод compile
компилирует слои вместе, подготавливая модель для обучения и оценки. Здесь мы объявляем, что хотим использовать алгоритм оптимизации adam
. Также объявляем, что будем вычислять потерю (loss) с перекрёстной энтропии, и указываем, что хотим оценить точность модели. Затем TensorFlow.js вычисляет точность по формуле:
Accuracy = (True Positives + True Negatives) / (Positives + Negatives)
Если переносить обучение с исходной модели MobileNet, то сначала нужно загрузить её. Поскольку нецелесообразно обучать нашу модель на более чем 3000 изображениях в браузере, мы применим Node.js и загрузим нейросеть из файла.
Скачать MobileNet можно здесь [15]. В каталоге лежит файл 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)
. Метод infer
принимает в качестве аргументов тензор и слой. Слой определяет, из какого скрытого слоя мы хотим получить выходные данные. Если вы откроете model.json [16] и поищете global_average_pooling2d_1
, то найдёте такое название у одного из слоёв.
Теперь нужно создать набор данных для обучения модели. Для этого мы должны пропустить все изображения через метод infer
в MobileNet и присвоить им метки: 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;
В приведённом коде мы сначала считываем файлы в каталогах с ударами и без. Затем определяем одномерный тензор, содержащий выходные метки. Если у нас n
изображений с ударами и m
других изображений, в тензоре будет n
элементов со значением 1 и m
элементов со значением 0.
В xs
мы складываем результаты вызова метода infer
для отдельных изображений. Обратите внимание, что для каждого изображения мы вызываем метод 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
считывает изображение с диска и после этого декодирует jpg из буфера с помощью пакета jpeg-js [17]. В imageToInput
мы преобразуем изображение в трёхмерный тензор.
В итоге, для каждого i
от 0
до TotalImages
должно быть ys[i]
равно 1
, если xs[i]
соответствует изображению с ударом, и 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 [3]!
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 video
на страницеLayer
содержит имя слоя из MobileNet, из которого мы хотим получить выходные данные и передать их в качестве входных данных для нашей моделиmobilenetInfer
— функция, которая принимает экземпляр MobileNet и возвращает другую функцию. Возвращаемая функция принимает входные данные и возвращает соответствующие выходные данные из указанного слоя MobileNetcanvas
указывает на элемент HTML5 canvas
, который мы будем использовать для извлечения кадров из видеоscale
— ещё один canvas
, который используется для масштабирования отдельных кадров
После этого мы получаем видеопоток с камеры пользователя и устанавливаем его в качестве источника для элемента video
.
Следующий шаг — реализовать фильтр оттенков серого, который принимает canvas
и преобразует его содержимое:
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
вызываем анонимную функцию. В ней сначала поверх canvas
рендерится видео c текущим кадром. Потом уменьшаем размер кадра до 100x56
и применяем к нему фильтр оттенков серого.
Следующим шагом передаём кадр в MobileNet, получаем выходные данные из желаемого скрытого слоя и передаём их в качестве входных данных в метод predict
нашей модели. Тот возвращает тензор с одним элементом. С помощью dataSync
получаем значение из тензора и присваиваем его константе punching
.
Наконец, проверяем: если вероятность удара рукой превышает 0.4
, то вызываем метод onPunch
глобального объекта Detect
. MK.js предоставляет глобальный объект с тремя методами: onKick
, onPunch
и onStand
, которые мы можем использовать для управления одним из персонажей.
Готово! Вот результат!
В следующем разделе сделаем более умную модель: нейросеть, которая распознаёт удары руками, ногами и другие изображения. На этот раз начнём с подготовки обучающего набора:
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]
.
Вектор из n
элементов, в котором n - 1
элементов со значением 0
и один элемент со значением 1
, мы называем унитарным вектором (one-hot vector).
После этого формируем входной тензор xs
, складывая выход каждого изображения из MobileNet.
Тут придётся обновить определение модели:
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']
});
Единственными двумя отличиями от предыдущей модели являются:
В выходном слое три юнита, потому что у нас три разных категории изображений:
На этих трёх юнитах вызывается активация softmax
, которая преобразовывает их параметры к тензору с тремя значениями. Почему три юнита для выходного слоя? Каждое из трёх значений для трёх классов можно представить двумя битами: 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 с уменьшенным кадром в оттенках серого, потом передаём результат нашей обученной модели. Модель возвращает одномерный тензор, который мы преобразуем в Float32Array
с dataSync
. На следующем шаге используем Array.from
для приведения типизированного массива к массиву JavaScript. Затем извлекаем вероятности того, что на кадре присутствует удар рукой, удар ногой или ничего.
Если вероятность третьего результата превышает 0.4
, возвращаемся. В противном случае, если вероятность удара ногой выше 0.32
, отправляем в MK.js команду удара ногой. Если вероятность удара рукой выше 0.32
и выше вероятности удара ногой, то отправляем действие удара рукой.
В целом это всё! Результат показан ниже:
Если собрать большой и разнообразный набор данных о людях, которые бьют руками и ногами, то можно построить модель, которая отлично работает на отдельных кадрах. Но достаточно ли этого? Что если мы хотим пойти ещё дальше и выделить два разных типа ударов ногой: с разворота и со спины (back kick).
Как видно на кадрах внизу, в определённый момент времени с определённого угла оба удара выглядят одинаково:
Но если посмотреть на исполнение, то движения совсем другие:
Как же обучить нейросеть анализировать последовательность кадров, а не один фрейм?
Для этой цели мы можем исследовать другой класс нейронных сетей, называемый рекуррентными нейронными сетями (RNN). Например, RNN отлично подходят для работы с временными рядами:
Реализация такой модели выходит за рамки этой статьи, но давайте рассмотрим пример архитектуры, чтобы получить представление о том, как всё это будет работать вместе.
На диаграмме ниже показана модель распознавания действий:
Берём последние n
кадров из видео и передаём их CNN. Выход CNN для каждого кадра передаём в качестве входных данных RNN. Рекуррентная нейросеть определит зависимости между отдельными кадрами и распознает, какому действию они соответствуют.
В этой статье мы разработали модель классификации изображений. Для этой цели мы собрали набор данных: извлекли кадры видео и вручную разделили их по трём категориям. Затем осуществили аугментацию данных, добавив изображения с помощью imgaug [8].
После этого мы объяснили, что такое перенос обучения, и использовали в своих целях обученную модель MobileNet из пакета @tensorflow-models/mobilenet [13]. Мы загрузили MobileNet из файла в процессе Node.js и обучили дополнительный плотный слой, куда данные подавались из скрытого слоя MobileNet. После обучения мы достигли точности более 90%!
Для использования этой модели в браузере мы загрузили её вместе с MobileNet и запустили категоризацию кадров с веб-камеры пользователя каждые 100 мс. Мы связали модель с игрой MK.js [3] и использовали выходные данные модели для управления одним из персонажей.
Наконец, мы рассмотрели, как улучшить модель, объединив ее с рекуррентной нейросетью для распознавания действий.
Надеюсь, вам понравился этот крошечный проект не меньше, чем мне!
Автор: m1rko
Источник [18]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/297276
Ссылки в тексте:
[1] Guess.js: https://github.com/guess-js/guess
[2] «необоснованной эффективности»: https://karpathy.github.io/2015/05/21/rnn-effectiveness/
[3] MK.js: https://github.com/mgechev/mk.js
[4] этой статьи: https://github.com/mgechev/mk-tfjs
[5] на GitHub: https://github.com/mgechev
[6] эксперименте: https://www.youtube.com/watch?v=0_yfU_iNUYo
[7] на GitHub: https://github.com/mgechev/movement.js
[8] imgaug: https://github.com/aleju/imgaug
[9] @lili_vs: https://twitter.com/lili_vs
[10] @gsamokovarov: https://twitter.com/gsamokovarov
[11] установить: https://www.ffmpeg.org/download.html
[12] Image: https://blog.mgechev.com/images/tfjs-cnn/aug.jpg
[13] @tensorflow-models/mobilenet: https://www.npmjs.com/package/@tensorflow-models/mobilenet
[14] ILSVRC-2012-CLS: http://www.image-net.org/challenges/LSVRC/2012/
[15] здесь: https://github.com/mgechev/mk-tfjs/tree/master/mobile-net
[16] model.json: https://github.com/mgechev/mk-tfjs/blob/master/mobile-net/model.json
[17] jpeg-js: https://www.npmjs.com/package/jpeg-js
[18] Источник: https://habr.com/post/428019/?utm_source=habrahabr&utm_medium=rss&utm_campaign=428019
Нажмите здесь для печати.