Распознавание рукописных цифр с помощью TensorFlow и MNIST стало довольно распространённым введением в искусственный интеллект (ИИ) и ML. «MNIST» — это база данных, которая содержит 70 000 примеров рукописных цифр. Она широко используется как источник изображений для обучения систем обработки изображений и программного обеспечения для машинного обучения.

Хотя учебные пособия по ML с использованием TensorFlow и MNIST стали привычными, до недавнего времени они обычно демонстрировались в полнофункциональных средах обработки с архитектурой x86 и графическими процессорами класса рабочих станций. Однако сегодня можно создать полнофункциональное приложение для распознавания рукописного ввода MNIST даже на 8-разрядном микроконтроллере. Чтобы продемонстрировать это, мы собираемся создать полнофункциональное приложение для распознавания рукописного ввода MNIST, используя TensorFlow Lite для получения результатов ИИ на маломощном микроконтроллере STMicroelectronics на базе процессора ARM Cortex M7.



В этой статье предполагается, что вы знакомы с C/C++ и машинным обучением, но не волнуйтесь, если это не так. Вы всё равно можете изучить предлагаемый пример и попробовать развернуть проект на собственном устройстве дома! Подобная реализация демонстрирует возможность создания надёжных приложений на основе машинного обучения даже на автономных устройствах с батарейным питанием в самых разных сценариях для Интернета вещей и портативных устройств.



Для создания этого проекта потребуется несколько компонентов:


Код этого проекта можно найти на GitHub.

Краткий обзор


Прежде чем начать, давайте рассмотрим действия, необходимые для запуска проекта ИИ с глубоким обучением на микроконтроллере с помощью TensorFlow:

  1. Обучить прогнозирующую модель на основе набора данных (рукописные цифры MNIST).
  2. Преобразовать модель в формат TensorFlow Lite.
  3. Создать встроенное приложение.
  4. Создать образцы данных.
  5. Развернуть и протестировать приложение.

Чтобы ускорить и упростить этот процесс, я создал записную книжку Jupyter в Google Colab, чтобы сделать первые два шага за вас из вашего браузера, не устанавливая и не настраивая Python на вашем компьютере. Она также может служить справочным материалом для других проектов, поскольку содержит весь код, необходимый для обучения и оценки модели MNIST с помощью TensorFlow, а также для преобразования модели в целях автономного использования в TensorFlow Lite для микроконтроллеров и создания версии кода массива Си модели для простой компиляции в любую программу на C++.

Чтобы перейти к встроенному приложению на шаге 3, сначала в меню записной книжки нажмите Runtime Run All (Время выполнения > Выполнить всё), чтобы создать файл model.h. Загрузите его из списка файлов на левой стороне. Также можно загрузить предварительно созданную модель из репозитория GitHub, чтобы включить её в проект.

Чтобы выполнить эти действия локально на своём компьютере, убедитесь, что используете платформу TensorFlow версии 2.0 или более поздней и дистрибутив Anaconda для установки и использования Python. Если вы используете упомянутую ранее записную книжку Jupyter, о которой говорилось выше, вам не придётся беспокоиться об установке TensorFlow 2.0, так как эта версия входит в состав этой записной книжки.

Обучение модели TensorFlow с использованием MNIST


Keras – это высокоуровневая библиотека Python для нейронных сетей, часто используемая для создания прототипов ИИ-решений. Она интегрирована с TensorFlow, а также содержит встроенный набор данных MNIST из 60 000 изображений и 10 000 тестовых образцов, доступных прямо в TensorFlow.
 
Чтобы прогнозировать рукописные цифры, этот набор данных использовался для обучения относительно простой модели, в которой изображение 28?28 принимается в качестве входной формы и выводятся до 10 категорий результатов с помощью функции активации Softmax с одним скрытым слоем между входным и выходным слоями. Этого было достаточно для достижения точности 96,6 %, но при желании можно добавить больше скрытых слоёв или тензоров.

За более глубоким обсуждением работы с набором данных MNIST в TensorFlow я рекомендую обратиться к некоторым (из многих) замечательным учебным пособиям по TensorFlow в Интернете, таким как Not another MNIST tutorial with TensorFlow, автор О'Рейли (O'Reilly). Вы также можете обратиться к примеру синусоидальной модели TensorFlow в этой записной книжке, чтобы ознакомиться с обучением и оценкой моделей TensorFlow и преобразованием модели в формат TensorFlow Lite для микроконтроллеров.



Преобразование модели в формат TensorFlow Lite


Созданная на первом шаге модель полезна и очень точна, но размер файла и использование памяти делают её недоступной для переноса или использования на встроенном устройстве. Именно здесь на помощь приходит TensorFlow Lite, так как данная среда выполнения оптимизирована для мобильных, встроенных устройств и устройств Интернета вещей и обеспечивает низкую задержку при очень небольших требованиях к размеру (всего несколько килобайт!). Это позволяет найти компромисс между точностью, скоростью и размером и выбрать модель в соответствии со своими потребностями.

В этом случае платформа TensorFlow Lite нужна, чтобы приложение занимало как можно меньше места во флеш-памяти и ОЗУ, оставаясь при этом быстрым, чтобы можно было немного понизить точность, не жертвуя слишком многим.

Чтобы ещё больше уменьшить размер, преобразователь TensorFlow Lite поддерживает дискретизацию модели, чтобы перейти в вычислениях от 32-разрядных значений с плавающей запятой к 8-разрядным целым числам, так как часто высокая точность значений с плавающей запятой не требуется. В результате также значительно уменьшается размер модели и повышается производительность.

Мне не удалось получить дискретизированную модель для правильного и согласованного использования функции Softmax. На моём устройстве STM32F7 Discovery возникает ошибка «не удалось вызвать». Преобразователь TensorFlow Lite постоянно развивается, и некоторые конструкции моделей ещё не поддерживаются. Например, этот инструмент преобразует некоторые веса в значения типа int8 вместо uint8, а тип int8 не поддерживается. По крайней мере пока.

При этом, если преобразователь поддерживает все элементы, используемые в вашей модели, он может значительно уменьшить размер модели, и для этого требуется всего несколько строк кода. Поэтому я рекомендую попробовать. Необходимые строки кода просто закомментированы в моей записной книжке и готовы к тому, чтобы вы их раскомментировали и сгенерировали окончательную модель, чтобы проверить, правильно ли она работает на вашем устройстве.
 
У встроенных микроконтроллеров в полевых условиях часто ограничено пространство для хранения данных. На стенде для внешнего хранения данных всегда можно использовать карту памяти большего размера. Однако, чтобы смоделировать среду без доступа к внешнему хранилищу для файлов .tflite, можно экспортировать модель как код, чтобы она содержалась в самом приложении.
 
Я добавил скрипт Python в конец своей записной книжки, чтобы обработать эту часть и превратить её в файл model.h. При желании в Linux с помощью команды оболочки «xxd -i» созданный tflite-файл также можно преобразовать в массив Си. Загрузите этот файл из меню слева и приготовьтесь добавить его в проект встроенного приложения на следующем шаге.

import binascii

def convert_to_c_array(bytes) -> str:
  hexstr = binascii.hexlify(bytes).decode("UTF-8")
  hexstr = hexstr.upper()
  array = ["0x" + hexstr[i:i + 2] for i in range(0, len(hexstr), 2)]
  array = [array[i:i+10] for i in range(0, len(array), 10)]
  return ",\n  ".join([", ".join(e) for e in array])

tflite_binary = open("model.tflite", 'rb').read()
ascii_bytes = convert_to_c_array(tflite_binary)
c_file = "const unsigned char tf_model[] = {\n  " + ascii_bytes + 
  "\n};\nunsigned int tf_model_len = " + str(len(tflite_binary)) + ";"
# print(c_file)
open("model.h", "w").write(c_file)

Создание встроенного приложения


Теперь мы готовы взять нашу обученную модель MNIST и реализовать её на реальном маломощном микроконтроллере. Ваши конкретные действия могут зависеть от используемого набора инструментов, но с моими интегрированной средой разработки PlatformIO и устройством STM32F746G Discovery мною были предприняты следующие действия.

Сначала создан новый проект приложения с настройками для соответствующего устройства на базе ARM Cortex-M и подготовлены основные функции setup и loop. Я выбрал структуру Stm32Cube, чтобы выводить результаты на экран. Если вы используете Stm32Cube, вы можете загрузить файлы stm32_app.h и stm32_app.c из репозитория и создать файл main.cpp с функциями setup и loop, например, как здесь:

#include "stm32_app.h"

void setup() {
}

void loop() {
}



Добавьте или загрузите библиотеку TensorFlow Lite Micro. Я предварительно настроил библиотеку для интегрированной среды разработки PlateformIO, чтобы вы могли загрузить папку tfmicro отсюда в папку lib проекта и добавить её в качестве зависимости библиотеки в файл platformio.ini:

[env:disco_f746ng]
platform = ststm32
board = disco_f746ng
framework = stm32cube
lib_deps = tfmicro

В верхней части своего кода укажите заголовки библиотек TensorFlowLite, например, как здесь:

#include "stm32_app.h"
#include "tensorflow/lite/experimental/micro/kernels/all_ops_resolver.h"
#include "tensorflow/lite/experimental/micro/micro_error_reporter.h"
#include "tensorflow/lite/experimental/micro/micro_interpreter.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "tensorflow/lite/version.h"

void setup() {
}

void loop() {
}

Включите преобразованный ранее файл model.h в этот проект в папку Include и добавьте его под заголовками TensorFlow. Затем сохраните результат и выполните сборку, чтобы убедиться, что всё в порядке, ошибок нет.

#include "model.h"



Определите для TensorFlow следующие глобальные переменные, которые будут использоваться в вашем коде:

// Globals
const tflite::Model* model = nullptr;
tflite::MicroInterpreter* interpreter = nullptr;
tflite::ErrorReporter* reporter = nullptr;
TfLiteTensor* input = nullptr;
TfLiteTensor* output = nullptr;
constexpr int kTensorArenaSize = 5000; // Just pick a big enough number
uint8_t tensor_arena[ kTensorArenaSize ] = { 0 };
float* input_buffer = nullptr;

В своей функции setup загрузите модель, настройте средство выполнения TensorFlow, назначьте тензоры и сохраните входные и выходные векторы вместе с указателем на входной буфер, с которым мы будем взаимодействовать как с массивом значений с плавающей запятой. Теперь ваша функция должна выглядеть следующим образом:

void setup() {
  // Load Model
  static tflite::MicroErrorReporter error_reporter;
  reporter = &error_reporter;
  reporter->Report( "Let's use AI to recognize some numbers!" );

  model = tflite::GetModel( tf_model );
  if( model->version() != TFLITE_SCHEMA_VERSION ) {
	reporter->Report( 
	  "Model is schema version: %d\nSupported schema version is: %d", 
	  model->version(), TFLITE_SCHEMA_VERSION );
	return;
  }
 
  // Set up our TF runner
  static tflite::ops::micro::AllOpsResolver resolver;
  static tflite::MicroInterpreter static_interpreter(
	  model, resolver, tensor_arena, kTensorArenaSize, reporter );
  interpreter = &static_interpreter;
 
  // Allocate memory from the tensor_arena for the model's tensors.
  TfLiteStatus allocate_status = interpreter->AllocateTensors();
  if( allocate_status != kTfLiteOk ) {
	reporter->Report( "AllocateTensors() failed" );
	return;
  }

  // Obtain pointers to the model's input and output tensors.
  input = interpreter->input(0);
  output = interpreter->output(0);

  // Save the input buffer to put our MNIST images into
  input_buffer = input->data.f;
}

Подготовьте TensorFlow к выполнению на устройстве ARM Cortex-M при каждом вызове функции loop с короткой задержкой (одна секунда) между обновлениями, например, как здесь:

void loop() {
  // Run our model
  TfLiteStatus invoke_status = interpreter->Invoke();
  if( invoke_status != kTfLiteOk ) {
	reporter->Report( "Invoke failed" );
	return;
  }
 
  float* result = output->data.f;
  char resultText[ 256 ];
  sprintf( resultText, "It looks like the number: %d", std::distance( result, std::max_element( result, result + 10 ) ) );
  draw_text( resultText, 0xFF0000FF );

  // Wait 1-sec til before running again
  delay( 1000 );
}

Приложение готово к работе. Оно просто ждёт, когда мы скормим ему несколько забавных тестовых изображений MNIST для обработки!

Создание образца данных MNIST для встраивания


Теперь давайте получим несколько изображений рукописных цифр, которые наше устройство сможет прочитать.

Чтобы добавить эти изображения в программу независимо от внешнего хранилища, мы можем заранее преобразовать 100 изображений MNIST из формата JPEG в чёрно-белые изображения, сохранённые в виде массивов, так же как и наша модель TensorFlow. Для этого я использовал веб-инструмент с открытым исходным кодом под названием image2cpp, который выполняет большую часть этой работы за нас в одном пакете. Если вы хотите сгенерировать их самостоятельно, проанализируйте пиксели и закодируйте по восемь в каждый байт и запишите их в формате массива Си, как показано ниже.

ПРИМЕЧАНИЕ. Веб-инструмент генерирует код для интегрированной среды разработки Arduino, поэтому в коде найдите и удалите все экземпляры PROGMEM, а затем компилируйте код в среде PlatformIO.

Например, это тестовое изображение рукописного нуля должно быть преобразовано в следующий массив:



// 'mnist_0_1', 28x28px
const unsigned char mnist_1 [] PROGMEM = {
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x1f, 0x80, 0x00,
  0x00, 0x3f, 0xe0, 0x00, 0x00, 0x7f, 0xf0, 0x00, 0x00, 0x7e, 0x30, 0x00, 0x00, 0xfc, 0x38, 0x00,
  0x00, 0xf0, 0x1c, 0x00, 0x00, 0xe0, 0x1c, 0x00, 0x00, 0xc0, 0x1e, 0x00, 0x00, 0xc0, 0x1c, 0x00,
  0x01, 0xc0, 0x3c, 0x00, 0x01, 0xc0, 0xf8, 0x00, 0x01, 0xc1, 0xf8, 0x00, 0x01, 0xcf, 0xf0, 0x00,
  0x00, 0xff, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x00, 0x7f, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};

Сохраните сгенерированные изображения в новый файл mnist.h в проекте или, чтобы сэкономить время и пропустить этот шаг, можно просто загрузить мою версию изGitHub.

В нижней части файла я объединил все массивы в одну окончательную коллекцию, чтобы каждую секунду мы могли выбрать для обработки произвольное изображение:

const unsigned char* test_images[] = {
  mnist_1, mnist_2, mnist_3, mnist_4, mnist_5, 
  mnist_6, mnist_7, mnist_8, mnist_9, mnist_10,
  mnist_11, mnist_12, mnist_13, mnist_14, mnist_15, 
  mnist_16, mnist_17, mnist_18, mnist_19, mnist_20,
  mnist_21, mnist_22, mnist_23, mnist_24, mnist_25, 
  mnist_26, mnist_27, mnist_28, mnist_29, mnist_30,
  mnist_31, mnist_32, mnist_33, mnist_34, mnist_35, 
  mnist_36, mnist_37, mnist_38, mnist_39, mnist_40,
  mnist_41, mnist_42, mnist_43, mnist_44, mnist_45, 
  mnist_46, mnist_47, mnist_48, mnist_49, mnist_50,
  mnist_51, mnist_52, mnist_53, mnist_54, mnist_55, 
  mnist_56, mnist_57, mnist_58, mnist_59, mnist_60,
  mnist_61, mnist_62, mnist_63, mnist_64, mnist_65, 
  mnist_66, mnist_67, mnist_68, mnist_69, mnist_70,
  mnist_71, mnist_72, mnist_73, mnist_74, mnist_75, 
  mnist_76, mnist_77, mnist_78, mnist_79, mnist_80,
  mnist_81, mnist_82, mnist_83, mnist_84, mnist_85, 
  mnist_86, mnist_87, mnist_88, mnist_89, mnist_90,
  mnist_91, mnist_92, mnist_93, mnist_94, mnist_95, 
  mnist_96, mnist_97, mnist_98, mnist_99, mnist_100,
};

Не забудьте включить в верхнюю часть кода заголовок нового изображения:

#include "mnist.h"

Тестирование изображений MNIST


После добавления этих образцов изображений в код можно добавить две вспомогательные функции: одна – для считывания монохромного изображения во входной вектор, а другая – для визуализации на встроенном дисплее. Ниже перечислены функции, которые я разместил непосредственно над функцией setup:

void bitmap_to_float_array( float* dest, const unsigned char* bitmap ) { // Populate input_vec with the monochrome 1bpp bitmap
  int pixel = 0;
  for( int y = 0; y < 28; y++ ) {
	for( int x = 0; x < 28; x++ ) {
	  int B = x / 8; // the Byte # of the row
	  int b = x % 8; // the Bit # of the Byte
	  dest[ pixel ] = ( bitmap[ y * 4 + B ] >> ( 7 - b ) ) & 
						0x1 ? 1.0f : 0.0f;
	  pixel++;
	}
  }
}

void draw_input_buffer() {
  clear_display();
  for( int y = 0; y < 28; y++ ) {
	for( int x = 0; x < 28; x++ ) {
	  draw_pixel( x + 16, y + 3, input_buffer[ y * 28 + x ] > 0 ? 0xFFFFFFFF : 0xFF000000 );
	}
  }
}

И, наконец, в нашем цикле можно выбрать случайное тестовое изображение для считывания во входной буфер и рисования на дисплее, например, как здесь:

void loop() {
  // Pick a random test image for input
  const int num_test_images = ( sizeof( test_images ) / 
								sizeof( *test_images ) );
  bitmap_to_float_array( input_buffer, 
						 test_images[ rand() % num_test_images ] );
  draw_input_buffer();
 
  // Run our model
  ...
}

Если всё в порядке, ваш проект будет скомпонован и развёрнут, и вы увидите, как ваш микроконтроллер распознаёт все рукописные цифры и выдаёт отличные результаты! Верите?




Что дальше?


Теперь, когда вы узнали о возможностях маломощных микроконтроллеров ARM Cortex-M, позволяющих использовать возможности глубокого обучения с помощью TensorFlow, вы готовы сделать гораздо больше! От обнаружения животных и предметов различных типов до обучения устройства понимать речь или отвечать на вопросы — вы со своим устройством можете открыть новые горизонты, которые ранее считались возможными только при использовании мощных компьютеров и устройств.

На GitHub доступны несколько потрясающих примеров TensorFlow Lite для микроконтроллеров, разработанных командой TensorFlow. Ознакомьтесь с этими рекомендациями, чтобы убедиться, что вы максимально эффективно используете свой проект ИИ, работающий на устройстве Arm Cortex-M. А если хотите прокачать себя в Data Science, Machine Learning или поднять уровень уже имеющихся знаний — приходите учиться, будет сложно, но интересно.

image
Узнайте подробности, как получить Level Up по навыкам и зарплате или востребованную профессию с нуля, пройдя онлайн-курсы SkillFactory со скидкой 40% и промокодом HABR, который даст еще +10% скидки на обучение: