Недавно у меня была возможность выступить на митапе Venice Computer Vision. Если вы не знакомы, это мероприятие, спонсируемое Trueface, где разработчики компьютерного зрения и энтузиасты могут продемонстрировать передовые исследования компьютерного зрения, приложения и практические руководства.

В этой статье я расскажу о своей учебной презентации о том, как разработать не зависящий от языка комплект разработчика программного обеспечения для компьютерного зрения (SDK) для межплатформенного развертывания и максимальной расширяемости. Если вы хотите просмотреть прямую запись презентации, вы можете сделать это здесь. Я также сделал весь проект открытым исходным кодом, так что не стесняйтесь использовать его в качестве шаблона для вашего следующего проекта компьютерного зрения.



Почему это руководство имеет значение

По моему опыту, я никогда не находил всеобъемлющего руководства, в котором резюмировались бы все соответствующие шаги, необходимые для создания кроссплатформенного SDK, не зависящего от языка. Мне пришлось прочесать разрозненную документацию в поисках нужной информации, изучить каждый компонент по отдельности, а затем самому разбирать все это по частям. Это было неприятно. На это ушло много времени. И теперь вы, дорогой читатель, получите пользу от всей моей работы. Впереди вы узнаете, как создать кроссплатформенный SDK, не зависящий от языка. Все самое необходимое есть. Никакой ерунды, кроме нескольких мемов. Наслаждаться.

В этом руководстве вы можете узнать, как:

  • Создайте базовую библиотеку компьютерного зрения на C ++
  • Скомпилируйте и кросс-компилируйте библиотеку для AMD64, ARM64 и ARM32.
  • Упакуйте библиотеку и все зависимости как одну статическую библиотеку.
  • Автоматизировать модульное тестирование
  • Настройте конвейер непрерывной интеграции (CI)
  • Напишите привязки python для нашей библиотеки
  • Создавайте документацию прямо из нашего API

Для этой демонстрации мы создадим SDK для обнаружения лиц и ориентиров, используя детектор лиц с открытым исходным кодом под названием MTCNN.

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

Примечание. В этом руководстве я буду работать с Ubuntu 18.04.

Зачем использовать C ++ для нашей библиотеки?

Большая часть нашей библиотеки будет написана на C ++, компилируемом и статически типизированном языке. Не секрет, что C ++ - очень быстрый язык программирования; это достаточно низкий уровень, чтобы обеспечить желаемую скорость и минимальные дополнительные затраты времени выполнения.

В приложениях компьютерного зрения мы обычно манипулируем большим количеством изображений, выполняем матричные операции, выполняем логический вывод машинного обучения, и все это требует огромного объема вычислений. Поэтому скорость выполнения имеет решающее значение. Это особенно важно в приложениях реального времени, где вам нужно уменьшить задержку, чтобы достичь желаемой частоты кадров - часто у нас есть только миллисекунды для выполнения всего нашего кода.

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

Структура каталогов

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

3rdparty будет содержать сторонние библиотеки зависимостей, необходимые для нашего проекта.

dist будет содержать файлы, которые будут распространяться среди конечных пользователей SDK. В нашем случае это будет сама библиотека и связанный с ней файл заголовка.

docker будет содержать файл докера, который будет использоваться для создания образа докера для сборок CI.

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

include будет содержать любые включаемые файлы для общедоступного API.

models будет содержать файлы моделей глубокого обучения для обнаружения лиц.

python будет содержать код, необходимый для создания привязок Python.

src будет содержать любые файлы cpp, которые будут скомпилированы, а также любые файлы заголовков, которые не будут распространяться с SDK (внутренние файлы заголовков).

test будет содержать наши модульные тесты.

tools будет содержать файлы цепочки инструментов CMake, необходимые для кросс-компиляции.

Установка библиотек зависимостей

Для этого проекта требуются сторонние библиотеки зависимостей: ncnn, облегченная библиотека вывода машинного обучения, OpenCV, библиотека увеличения изображений, Catch2, библиотека модульного тестирования и, наконец, pybind11, библиотека. используется для создания привязок Python. Первые две библиотеки необходимо будет скомпилировать как отдельные библиотеки, тогда как последние две являются только заголовочными, и поэтому нам нужен только исходный код.

Один из способов добавить эти библиотеки в наши проекты - использовать подмодули git. Хотя этот подход работает, я лично являюсь поклонником использования сценариев оболочки, которые извлекают исходный код, а затем собирают его для желаемых платформ: в нашем случае AMD64, ARM32 и ARM64.

Вот пример того, как выглядит один из этих скриптов сборки:

Сценарий довольно прост. Он начинается с извлечения исходного кода желаемого выпуска из репозитория git. Затем CMake используется для подготовки сборки, затем вызывается make, чтобы заставить компилятор собрать исходный код.

Вы заметите, что основное различие между сборкой AMD64 и сборками ARM заключается в том, что сборки ARM передают дополнительный параметр CMake, называемый CMAKE_TOOLCHAIN_FILE. Этот аргумент используется для указания CMake, что целевая архитектура сборки (ARM32 или ARM64) отличается от архитектуры хоста (AMD64 / x86_64). Поэтому CMake получает указание использовать кросс-компилятор, указанный в выбранном файле цепочки инструментов, для создания библиотеки (подробнее о файлах цепочки инструментов позже в этом руководстве). Чтобы этот сценарий оболочки работал, на вашем компьютере с Ubuntu должны быть установлены соответствующие кросс-компиляторы. Их можно легко установить с помощью apt-get, и инструкции о том, как это сделать, показаны здесь.

Наша библиотека API

Наша библиотека API выглядит так:

Поскольку я очень креативен, я решил назвать свой SDK MySDK. В нашем API есть перечисление с именем ErrorCode, у нас есть структура с именем Point и, наконец, у нас есть одна общедоступная функция-член с именем getFaceBoxAndLandmarks. В рамках этого руководства я не буду вдаваться в подробности реализации SDK. Суть в том, что мы считываем изображение в память с помощью OpenCV, а затем выполняем логический вывод машинного обучения с использованием ncnn с моделями с открытым исходным кодом для обнаружения ограничивающего прямоугольника лица и ориентиров. Если вы хотите погрузиться в реализацию, вы можете сделать это здесь.

Но я хочу, чтобы вы обратили внимание на шаблон проектирования, который мы используем. Мы используем метод под названием Указатель на реализацию или для краткости pImpl, который в основном удаляет детали реализации класса, помещая их в отдельный класс. В приведенном выше коде это достигается путем прямого объявления класса Impl, а затем присвоения unique_ptr этому классу в качестве частной переменной-члена. При этом мы не только скрываем реализацию от любопытных глаз конечного пользователя (что может быть очень важно в коммерческом SDK), но также уменьшаем количество заголовков, от которых зависит наш заголовок API (и, таким образом, предотвращаем нашу Заголовок API из #includeing заголовков библиотеки зависимостей).

Примечание о файлах модели

Я сказал, что мы не собираемся вдаваться в подробности реализации, но я думаю, что есть кое-что, о чем стоит упомянуть. По умолчанию используемый нами детектор лиц с открытым исходным кодом, называемый MTCNN, загружает файлы модели машинного обучения во время выполнения. Это не идеально, потому что нам нужно будет распространить модели среди конечных пользователей. Эта проблема еще более значительна для коммерческих моделей, когда вы не хотите, чтобы пользователи имели свободный доступ к этим файлам моделей (подумайте о бесчисленных часах, которые ушли на обучение этих моделей). Одно из решений - зашифровать файлы этих моделей, что я настоятельно рекомендую сделать. Однако это по-прежнему означает, что нам нужно отправить файлы модели вместе с SDK. В конечном итоге мы хотим уменьшить количество файлов, которые мы отправляем пользователю, чтобы им было проще использовать наше программное обеспечение (меньше файлов - меньше мест, где можно ошибиться). Поэтому мы можем использовать метод, показанный ниже, чтобы преобразовать файлы модели в файлы заголовков и фактически встроить их в сам SDK.

Команда xxd bash используется для создания шестнадцатеричных дампов и может использоваться для создания файла заголовка из двоичного файла. Поэтому мы можем включать файлы модели в наш код, как обычные файлы заголовков, и загружать их прямо из памяти. Ограничением этого подхода является то, что он непрактичен с очень большими файлами модели, так как он потребляет слишком много памяти во время компиляции. Вместо этого вы можете использовать такой инструмент, как ld, для преобразования этих больших файлов моделей непосредственно в объектные файлы.

CMake и компиляция нашей библиотеки

Теперь мы можем использовать CMake для создания файлов сборки для нашего проекта. Если вы не знакомы, CMake - это генератор системы сборки, используемый для управления процессом сборки. Ниже вы увидите, как выглядит часть корневого файла CMakeLists.txt (файл CMake).

По сути, мы создаем статическую библиотеку с именем my_sdk_static с двумя исходными файлами, которые содержат нашу реализацию, my_sdk.cpp и mtcnn.cpp. Причина, по которой мы создаем статическую библиотеку, заключается в том, что, по моему опыту, статическую библиотеку проще распространять среди пользователей, и она более удобна для встроенных устройств. Как я упоминал выше, если исполняемый файл связан со статической библиотекой, его можно запустить на встроенном устройстве, на котором даже нет операционной системы. Это просто невозможно с динамической библиотекой. Кроме того, с динамическими библиотеками нам нужно беспокоиться о версиях зависимостей. Нам может даже понадобиться файл манифеста, связанный с нашей библиотекой. Статически связанные библиотеки также имеют немного лучший профиль производительности, чем их динамические аналоги.

Следующее, что мы делаем в нашем сценарии CMake, - это указываем CMake, где найти необходимые файлы заголовков включения, которые требуются нашим исходным файлам. Обратите внимание: хотя наша библиотека на этом этапе будет компилироваться, когда мы попытаемся выполнить компоновку с нашей библиотекой (например, с исполняемым файлом), мы получим абсолютную массу неопределенных ссылок на ошибки символов. Это потому, что мы не связали ни одну из наших библиотек зависимостей. Поэтому, если мы действительно хотим успешно связать исполняемый файл с libmy_sdk_static.a, тогда нам придется отслеживать и связывать также все библиотеки зависимостей (модули OpenCV, ncnn и т. Д.). В отличие от динамических библиотек, статические библиотеки не могут разрешать свои собственные зависимости. По сути, они представляют собой просто набор объектных файлов, упакованных в архив.

Позже в этом руководстве я продемонстрирую, как мы можем объединить все библиотеки зависимостей в нашу статическую библиотеку, чтобы пользователю не нужно было беспокоиться о компоновке с какой-либо из библиотек зависимостей.

Кросс-компиляция нашей библиотеки и файлов Toolchain

Многие приложения компьютерного зрения развертываются на периферии. Обычно это включает запуск кода на маломощных встраиваемых устройствах, которые обычно имеют процессоры ARM. Поскольку C ++ является компилируемым языком, мы должны скомпилировать наш код для архитектуры процессора, на которой будет выполняться приложение (каждая архитектура использует разные инструкции сборки).

Прежде чем мы углубимся в это, давайте также коснемся разницы между ARM32 и ARM64, также называемыми AArch32 и AArch64. AArch64 относится к 64-битному расширению архитектуры ARM и зависит как от ЦП, так и от операционной системы. Так, например, несмотря на то, что Raspberry Pi 4 имеет 64-битный процессор ARM, Raspbian по умолчанию использует 32-битную операционную систему. Поэтому для такого устройства требуется скомпилированный двоичный файл AArch32. Если бы мы запускали 64-битную операционную систему, такую ​​как Gentoo на этом устройстве Pi, то нам потребовался бы скомпилированный двоичный файл AArch64. Другой пример популярного встраиваемого устройства - NVIDIA Jetson со встроенным графическим процессором и запуском AArch64.

Для кросс-компиляции нам нужно указать CMake, что мы не компилируем для архитектуры машины, на которой в настоящее время строим. Следовательно, нам нужно указать кросс-компилятор, который должен использовать CMake. Для AArch64 мы используем компилятор aarch64-linux-gnu-g++, а для AArch32 мы используем компилятор arm-linux-gnuebhif-g++ (hf означает жесткое плавание).

Ниже приведен пример файла цепочки инструментов. Как видите, мы указываем использовать кросс-компилятор AArch64.

Вернувшись в корневой каталог CMakeLists.txt, мы можем добавить следующий код в начало файла.

По сути, мы добавляем параметры CMake, которые можно включить из командной строки для кросс-компиляции. Включение параметров BUILD_ARM32 или BUILD_ARM64 выберет соответствующий файл инструментальной цепочки и настроит сборку для кросс-компиляции.

Упаковка нашего SDK с библиотеками зависимостей

Как упоминалось ранее, если разработчик хочет связать с нашей библиотекой на этом этапе, ему также потребуется связать со всеми библиотеками зависимостей, чтобы разрешить все символы из библиотек зависимостей. Несмотря на то, что наше приложение довольно простое, у нас уже есть восемь библиотек зависимостей! Первый - это ncnn, затем у нас есть три библиотеки модулей OpenCV, затем у нас есть четыре служебных библиотеки, которые были созданы с помощью OpenCV (libjpeg, libpng, zlib, libtiff). Мы могли бы потребовать, чтобы пользователь сам создал библиотеки зависимостей или даже отправил их вместе с нашей библиотекой, но в конечном итоге это требует от пользователя больше работы, и мы все стремимся снизить барьер для использования. Идеальная ситуация, если мы можем отправить пользователю одну библиотеку, которая содержит нашу библиотеку вместе со всеми сторонними библиотеками зависимостей, кроме стандартных системных библиотек. Оказывается, мы можем добиться этого с помощью магии CMake.

Сначала мы добавляем настраиваемую цель к нашему CMakeLists.txt, а затем выполняем то, что называется сценарием MRI. Этот сценарий MRI передается команде ar -M bash, которая в основном объединяет все статические библиотеки в один архив. Что замечательно в этом методе, так это то, что он изящно обрабатывает перекрывающиеся имена членов из исходных архивов, поэтому нам не нужно беспокоиться о конфликтах там. Создание этой настраиваемой цели приведет к libmy_sdk.a, который будет содержать наш SDK вместе со всеми архивами зависимостей.

Постойте на секунду: давайте подведем итоги того, что мы уже сделали.

На данный момент у нас есть статическая библиотека с именем libmy_sdk.a, которая содержит наш SDK и все библиотеки зависимостей, которые мы упаковали в один архив. У нас также есть возможность компилировать и кросс-компилировать (используя аргументы командной строки) для всех наших целевых платформ.

Единичные тесты

Мне, вероятно, не нужно объяснять, почему модульные тесты важны, но в основном они являются важной частью дизайна SDK, которая позволяет разработчику гарантировать, что SDK работает с отступом. Кроме того, если в процессе работы вносятся какие-либо критические изменения, это помогает отслеживать их и быстрее выпускать исправления.

В этом конкретном случае создание исполняемого файла модульного теста также дает нам возможность связать с объединенной библиотекой, которую мы только что создали, чтобы гарантировать, что мы можем правильно связать, как задумано (и мы не получаем ни одной из этих неприятных неопределенных ссылок на символ ошибки).

Мы используем Catch2 в качестве нашей среды модульного тестирования. Синтаксис представлен ниже:

Принцип работы Catch2 заключается в том, что у нас есть этот макрос с именем TEST_CASE и другой макрос с именем SECTION. Для каждого SECTION TEST_CASE выполняется с самого начала. Итак, в нашем примере сначала будет инициализирован mySdk, затем будет запущен первый раздел с именем «Без изображения лица». Затем mySdk будет деконструирован перед реконструкцией, затем будет запущена вторая секция с названием «Лица на изображении». Это замечательно, потому что это гарантирует, что у нас будет свежий MySDK объект для работы для каждого раздела. Затем мы можем использовать макросы, такие как REQUIRE, чтобы делать наши утверждения.

Мы можем использовать CMake для создания исполняемого файла модульного тестирования с именем run_tests. Как видно из вызова target_link_libraries в строке 3 ниже, единственная библиотека, с которой нам нужно связать, - это наша libmy_sdk.a и никакие другие библиотеки зависимостей.

Документация

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

Чтобы фактически создать документацию, нам понадобится что-то, называемое doxyfile, который в основном является планом для инструкций doxygen, как создавать документацию. Мы можем сгенерировать общий файл doxy, запустив doxygen -g в нашем терминале, если в вашей системе установлен doxygen. Затем мы можем отредактировать файл doxyfile. Как минимум, нам нужно указать выходной каталог, а также входные файлы.

В нашем случае мы хотим генерировать документацию только из нашего файла заголовка API, поэтому мы указали каталог include. Наконец, вы используете CMake для создания документации, что можно сделать вот так.

Привязки Python

Будем честны. C ++ - не самый простой и не самый удобный язык для разработки. Поэтому мы хотим расширить нашу библиотеку для поддержки языковых привязок, чтобы упростить ее использование для разработчиков. Я продемонстрирую это с помощью python, поскольку это популярный язык прототипирования компьютерного зрения, но другие языковые привязки так же легко написать. Для этого мы используем pybind11:

Мы начинаем с использования макроса PYBIND11_MODULE, который создает функцию, которая будет вызываться при выполнении инструкции импорта изнутри python. Итак, в приведенном выше примере имя модуля python - mysdk. Затем мы можем определять наши классы и их члены, используя синтаксис pybind11.

Вот что следует отметить: в C ++ довольно часто переменные передаются с использованием изменяемой ссылки, которая обеспечивает доступ как для чтения, так и для записи. Именно это мы сделали с нашей функцией-членом API с параметрами faceDetected и fbAndLandmarks. В Python все аргументы передаются по ссылке. Однако некоторые базовые типы Python неизменяемы, в том числе bool. По совпадению, наш параметр faceDetected - это логическое значение, которое передается по изменяемой ссылке. Поэтому мы должны использовать обходной путь, показанный в приведенном выше коде в строках с 31 по 34, где мы определяем bool в нашей функции-оболочке python, а затем передаем его нашей функции C ++, прежде чем возвращать переменную как часть кортежа.

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

Непрерывная интеграция

Для нашего конвейера непрерывной интеграции мы будем использовать инструмент под названием CircleCI, который мне очень нравится, потому что он напрямую интегрируется с Github. Новая сборка будет автоматически запускаться каждый раз, когда вы нажимаете фиксацию. Для начала перейдите на сайт CircleCI и подключите его к своей учетной записи Github, затем выберите проект, который хотите добавить. После добавления вам нужно будет создать каталог .circleci в корне вашего проекта и создать файл с именем config.yml в этом каталоге.

Для тех, кто не знаком, YAML - это язык сериализации, обычно используемый для файлов конфигурации. Мы можем использовать его, чтобы указать, какие операции мы хотим, чтобы CircleCI выполнял. В приведенном ниже фрагменте YAML вы можете увидеть, как мы сначала создаем одну из библиотек зависимостей, затем собираем сам SDK и, наконец, создаем и запускаем модульные тесты.

Если мы разумны (а я полагаю, что вы умны, если вы уже зашли так далеко), мы можем использовать кеширование, чтобы значительно сократить время сборки. Например, в YAML выше мы кэшируем сборку OpenCV, используя хэш сценария сборки в качестве ключа кеширования. Таким образом, библиотека OpenCV будет перестроена только в том случае, если скрипт сборки был изменен - ​​в противном случае будет использоваться кешированная сборка. Также следует отметить, что мы запускаем сборку внутри выбранного нами образа докера. Я выбрал собственный образ докера (здесь - файл Docker), в который я установил все системные зависимости.

Вот и все. Как и любой хорошо спроектированный продукт, мы хотим поддерживать самые востребованные платформы и делать его простым в использовании для наибольшего числа разработчиков. Используя приведенное выше руководство, мы создали SDK, который доступен на нескольких языках и может быть развернут на нескольких платформах. И вам даже не нужно было самому читать документацию по pybind11. Я надеюсь, что вы нашли это руководство полезным и интересным. Счастливое здание.