Привет! Я Андрей Татаринов, директор AGIMA.AI. Мы занимаемся проектами в области машинного обучения и анализа данных. В этой статье расскажу, как мы использовали фреймворк Mediapipe для iOS и Android, запускали его на десктопе, писали кастомные калькуляторы и в поддержку сообщества.
Корпорация Google анонсировала Mediapipe на CVPR в 2019 году. Оригинальная статья предлагает простую идею: давайте рассматривать процесс работы с данными как граф, вершины которого — это модули обработки данных. Помимо удобного способа описания последовательности работы с данными, Mediapipe обещает сбилдить ваше решение в качестве библиотек для iOS и Android. И это очень актуально!
Запускать ML-модели и процессить видео на конечных устройствах сложно. Если самостоятельно реализовывать обработку видео в реальном времени и «в лоб» запускать нейросети, то легко попасть в ситуацию неконтролируемого роста объема работы: приложение «утечет» по памяти, появятся глюки или скопится очередь из кадров.
Поэтому Mediapipe пользуются компании-разработчики мобильных приложений. У них нет ресурсов на самостоятельный процессинг видео. Да и зачем их искать, если можно взять готовую компоненту из Mediapipe и «приклеить» к ней собственную модель или иную необходимую фишку? Фреймворк реализует графическое преобразование видео, позволяет комбинировать ML-ные компоненты с механической обработкой и на выходе получать кросс-платформенное решение для мобильных и десктопных устройств.
Mediapipe компенсирует ограниченную функциональность мобильных устройств и на выходе дает видео приемлемого качества. Идеально, не так ли?
Задачи, которые решает Mediapipe из коробки
Как проще всего познакомиться с Mediapipe? Запустить один из уже разработанных ими примеров, а потом попробовать немного его поменять.
Что умеет делать Mediapipe из коробки:
распознавать лица и делать Face Mesh;
определять радужные оболочки глаза: зрачки и контуры глаза;
находить руки, ноги, определять позы;
сегментировать волосы и селфи;
запускать модель детекции и трекать ее предсказания;
мгновенно идентифицировать движения;
Objectron: определять 3D-объекты по 2D-изображениям;
KNIFT: сопоставлять признаки на основе шаблонов;
AutoFlip: конвейер автоматической обрезки видео.
Сразу замечу, что стандартного двухстадийного пайплайна «детекция + классификация» в нем не реализовано. Но его несложно собрать из готовых модулей обработки данных. В общем и целом, можно даже написать и свой собственный модуль :)
Из чего состоит Mediapipe
Mediapipe состоит из трех основных структурных компонентов: калькуляторов (вершин графа), входных/выходных пакетов и графов вычислений.
Калькуляторы
Калькулятор — вершина графа, код, выполняющий трансформацию над входными данными и отдающий выходные.
Чтобы создать калькулятор, нужно отнаследоваться от CalculatorBase
и реализовать четыре метода:
GetContract(). Проверяем типы входящих и выходящих данных на соответствие заранее заданным. Практика показала, что если этот метод не реализовывать, ничего не сломается.
Open(). Инициализируем калькулятор при запуске.
Process(). Запускаем вычисление ноды, используя понятие контекст — некой сущности, в которой записаны текущие переменные в текущем моменте запуска пайплайна.
Close(). Освобождаем ресурсы калькулятора.
Например, если стоит задача прогнать некую модельку классификации на входном изображении, то нужно:
Проинициализировать эту модель в Open().
В Process() из контекста вытащить входное изображение и использовать на неё модель, получить предсказание и отдать его в выход.
Освободить ресурсы в Close().
(Калькулятор: пример кода для модели классификации на входном изображении).
class ClassificationCalculator : public Node {
public:
// ожидаем на входе либо тип Image, либо тип GpuBuffer
// на выходе отдаем список из объектов Classification
static constexpr Input<OneOf<mediapipe::Image,
mediapipe::ImageFrame>> kInImage{"IMAGE"};
static constexpr Input<GpuBuffer>::Optional
kInImageGpu{"IMAGE_GPU"};
static constexpr Output<ClassificationList>
kOutClassificationList{"CLASSIFICATIONS"};
MEDIAPIPE_NODE_CONTRACT(kInImage, kInImageGpu,
kOutClassificationList);
absl::Status Open(CalculatorContext* cc) override;
absl::Status Process(CalculatorContext* cc) override;
absl::Status Close(CalculatorContext* cc) override;
absl::Status SomeCalculator::Open(CalculatorContext* cc) {
// вот здесь загружаем модель в private переменную
return absl::OkStatus();
}
// Copied some code from image_to_tensor_calculator.cc
absl::Status SomeCalculator::Process(CalculatorContext* cc) {
// проверяем, что в данном контексте имеется вход
if ((kInImage(cc).IsConnected() && kInImage(cc).IsEmpty()) ||
(kInImageGpu(cc).IsConnected() && kInImageGpu(cc).IsEmpty())) {
// Timestamp bound update happens automatically.
return absl::OkStatus();
}
ASSIGN_OR_RETURN(auto image_ptr, GetInputImage(cc));
ASSIGN_OR_RETURN(auto classification_list_ptr, GetClassificationList(cc));
// прогоняем модель на картинке image_ptr
// записываем результаты в выходные данные
auto out_classification_list = absl::make_unique<ClassificationList>();
Classification* classification = out_classification_list->add_classification();
classification->set_index(0);
classification->set_score(0.8);
classification->set_label("class0")
classification->set_display_name("Dog");
____kOutClassificationList(cc).Send(std::move(out_classification_list));
return absl::OkStatus();
}
absl::Status SomeCalculator::Close(CalculatorContext* cc) {
// освобождаем ресурсы (удаляем модель из памяти)
return absl::OkStatus();
}
Входные/выходные пакеты и графы вычислений
Граф — совокупность входных данных и вершин (калькуляторов), реализующий какой-то пайплайн вычисления. Граф определяется в формате TensorFlow Graph Text и может быть описан в виде файла .pbtxt
.
В калькулятор можно подключать опции, описываемые протоколом Protobuf, которые можно изменять. Так можно указывать размер изображения для ресайза.
Например, картинку сверху можно было бы описать следующим графом:
input_stream: "input1"
input_stream: "input2"
input_stream: "input3"
output_stream: "output1"
output_stream: "output2"
node {
calculator: "CalculatorCalculator1"
input_stream: "INPUT_TAG1:input1"
input_stream: "INPUT_TAG2:input2"
input_stream: "INPUT_TAG3:input3"
output_stream: "OUTPUT_TAG:output"
}
node {
calculator: "CalculatorCalculator2"
input_stream: "INPUT_TAG:output"
output_stream: "OUTPUT_TAG1:output1"
output_stream: "OUTPUT_TAG2:output2"
}
В Mediapipe принят следующий синтаксис записи данных: TAG:variable_name
. Теги позволяют калькулятором детектить нужные ему входные переменные, получаемые из контекста. Например, в коде выше с калькулятором мы записываем тег или как «IMAGE», который хранится на CPU, или как «IMAGE_GPU», который хранится на GPU. В зависимости от того, какие переменные были поданы калькулятору, будет делаться Process.
Mediapipe даже имеет собственный сервис, в который можно загрузить граф и посмотреть красивую визуализацию: https://viz.mediapipe.dev/.
Не так подробно и более схематично умеет визуализировать графы и Netron (netron.app).
Стандартно написанные калькуляторы
В Mediapipe определено много стандартных типов и калькуляторов, реализующих разную логику работы. В качестве примеров типов можно указать видео, изображения, аудио, тексты, временные ряды. Далее идут тензоры — принятые типы в машинном обучении, с помощью которых можно получать объекты результатов предсказаний модели: Detections, Classifications, Landmarks (кейпоинты) — всё это тоже типы. Примеры можно посмотреть здесь.
Калькуляторы позволяют совершать над этими типами разного рода трансформации, преобразовывающие данные из одного типа в другой.
Все калькуляторы (и подграфы) определены здесь. Большую часть из них можно найти в папке calculators/ или gpu/. Какой-либо внешней документации по ним нет, но сам код почти везде задокументирован: рядом с определением калькулятора в виде комментариев описывается сам калькулятор. Помимо этого, рядом с калькулятором (записанные в файлах .cc и .h) может лежать и .proto-файл — они определяют так называемые опции калькуляторов и записаны в формате Protobuf.
В качестве примера можно рассмотреть задачу детекции объектов на входном изображении некоторой ML-моделью. Что для этого нужно:
Получить входную картинку и преобразовать её размеры в 640х640 (пусть наша модель ожидает входной тензор такого размера — эти числа нужно указывать в опцию соответствующего калькулятора).
Превратить сжатую картинку в тензор.
Прогнать этот тензор через модель и получить сырые предсказания.
Сырые предсказания сконвертировать в понятный для Mediapipe тип Detections.
Получить этот тип на выходе графа.
В Mediapipe все эти действия превращаются в элегантный граф — последовательность вершин с определенными типами входных/выходных данных и параметрами вершины:
# Задаем входящий и выходящий потоки (выходящий, например, мы можем ловить коллбеком в приложении)
input_stream: "image"
output_stream: "detections"
# Входное изображение превращается в изображение размера 640х640 с
# сохранением aspect ratio. Остальная часть картинки заливается нулевыми
# пикселями (преобразование, имеющее название леттербоксинг).
# Затем изображение трансформируется в тензор, требуемый моделью TensorFlow Lite.
node {
calculator: "ImageToTensorCalculator"
input_stream: "IMAGE:image"
output_stream: "TENSORS:input_tensor"
output_stream: "LETTERBOX_PADDING:letterbox_padding"
options: {
[mediapipe.ImageToTensorCalculatorOptions.ext] {
output_tensor_width: 640
output_tensor_height: 640
keep_aspect_ratio: true
output_tensor_float_range {
min: 0.0
max: 255.0
}
border_mode: BORDER_ZERO
}
}
}
# Здесь просто делаем инференс tflite модельки
# Есть поддержка делегатов gpu, tflite, nnapi и тп
node {
calculator: "InferenceCalculator"
input_stream: "TENSORS:input_tensor"
output_stream: "TENSORS:output_tensor"
options: {
[mediapipe.InferenceCalculatorOptions.ext] {
model_path: "model.tflite"
delegate { gpu {} }
}
}
}
# Декодирует тензоры, полученные моделью TensorFlow Lite в выход
# типа Detections. Каждый Detection -- это минимум Label, # Label_id, Score и Bounding Box,описывающий обнаруженный объект # в формате (xmin, ymin, xmax, ymax), где координаты нормализованы # в диапазоне [0,1]
node {
calculator: "TensorsToDetectionsCalculator"
input_stream: "TENSORS:output_tensor"
output_stream: "DETECTIONS:raw_detections"
options: {
[mediapipe.TensorsToDetectionsCalculatorOptions.ext] {
num_classes: 1
num_boxes: 2
num_coords: 4
min_score_thresh: 0.5
}
}
}
# Превращает полученные моделью координаты детекции относительно ресайзнутой
# леттербоксингом картинки в координаты детекции относительно # оригинального изображения
node {
calculator: "DetectionLetterboxRemovalCalculator"
input_stream: "DETECTIONS:raw_detections"
input_stream: "LETTERBOX_PADDING:letterbox_padding"
output_stream: "DETECTIONS:detections"
}
Полученные Detections можно отдавать обратно в контекст и работать с ними дальше (например, сохранить их в файлы с разметкой или отрисовать на полученной вначале картинке).
Какой граф делали мы
Теперь давайте перейдем от общего к частному.
Перед нами стояла задача перенести двухстадийный пайплайн (детекция + классификация) в риалтайм для мобильных телефонов. Причем после модели классификации в некоторых случаях была необходимость запускать C++ код, обрабатывающий изображение определенным алгоритмом.
Как модель детекции мы использовали Yolov5S, как модель классификации — MobileNetV2.
Очень быстро выяснились следующие вещи:
кастомные графы почему-то в Python не работают;
General community discussion around MediaPipe скорее мертва, чем жива;
для поддержки в Github Issue в целом нормально копипастить фразу «не могли бы вы предоставить логи об ошибке» (после того, как ты их предоставил, естественно) и в целом игнорировать твой вопрос месяцами.
С другой стороны, их примеры вполне работали на CPP, а библиотеки успешно конвертировались для iOS и Android, так что мы решили не сдаваться.
В итоге мы реализовали следующий граф:
Какую еще полезную информацию мы можем сообщить миру?
В самом Mediapipe реализованы калькуляторы, выполняющие инференс только на Tensorflow. Мы лично пользовались только tflite, но якобы есть поддержка еще saved_model и frozen_graph. Если вы, например, любитель PyTorch, будьте готовы либо конвертировать вашу модель в tflite, либо писать собственный калькулятор для инференса.
Разработчики без стеснения пушат в мастер, так что, если хотите быть уверенными в успешности сборки, фиксируйтесь на каком-нибудь теге.
Будьте готовы к сражениям с разными делегатами, OpenGl и OpenCl, если ваша модель отличается от некоего «стандартного набора». Без шуток, не так просто найти и сконвертить модель детекции, запускающуюся в Mediapipe с поддержкой ГПУ.
Писать кастомные калькуляторы достаточно просто; благо, в репозитории есть много примеров, которые можно взять за основу.
Не игнорируйте setup_{}.sh-скрипты — они корректно настраивают OpenCV и Android SDK/NDK для Mediapipe.
Ну, и в общем и целом, описанный выше граф вполне реально реализовать в Mediapipe и сбилдить из него библиотеки для Android и iOS, работающие с приличным FPS.
Создание библиотек с графом Mediapipe для iOS и Android
Для сборки решения Mediapipe использует Bazel. Как и с построеним графа, с процессом билда мы тоже решили пойти от простого к сложному. Итак, что нужно сделать, чтобы сбилдить решение с кастомным графом для Linux:
Написать этот граф и, например, положить в его новую папочку mediapipe/graphs/custom_graph/cutom_graph.pbtxt (по аналогии с этим решением). К слову, начиная с версии 0.8.11 не обязательно всё складывать внутрь //mediapipe.
В папке с этим графом в BUILD прописываем вручную все нужные зависимости для него (в основном — калькуляторы) в cc_library (для примера из предыдущего пункта).
Положить в папочку mediapipe/examples/desktop/custom_example/ BUILD файл, в котором определить cc_binary, зависящий от калькуляторов из предыдущего пункта и CPP кода, запускающего этот граф (пример).
Сбилдить командой:
bazel build -c opt --define MEDIAPIPE_DISABLE_GPU=1 mediapipe/examples/desktop/custon_example:{cc_binary name}
После этого сбилдится cc_binary (в логах вы увидите путь к нему), и останется только запустить этот граф командой:
GLOG_logtostderr=1 {cc_binary_path} --calculator_graph_config_file=mediapipe/graphs/custom_graph/custom_graph.pbtxt \
--input_side_packets=input_video_path=<input video path>,output_video_path=<output video path>
Вроде бы ничего сложного, и, в принципе, оригинал этой инструкции есть и в документации.
Важно еще понимать, что граф и его запуск — это разные вещи. В примере выше мы запускаем граф, который на вход ожидает путь к видео (и указываем его в аргументе --input_side_packets=input_video_path), а сам же граф запускается внутри кода C++.
Последнее, на что хотелось бы обратить внимание при Linux-сборке — сторонние зависимости. В одном из наших калькуляторов использовалась внешняя библиотека, написанная на C++. Ее мы добавляли по аналогии с OpenCV: заранее сбилдили, создали в WORKSPACE-файле new_local_repository (у OpenCV — http_archive ) и записали в папочку third_party/ соответствующий BUILD файл. И указали эту зависимость в BUILD-файле калькулятора.
Предположим, что у нас не возникло ошибок на этом этапе и можно двигаться дальше!
Во-первых, следует определиться с кодом приложения, в котором вы этот граф собираетесь запускать. Для Android уже есть два примера, которые рекомендует официальная документация (раз и два). Для iOS есть инструкция и шаблон, так что на этом этапе iOS-девелопер все-таки понадобится.
Не забудьте разобраться, какое именно изображение поступает в граф — Image или Image_GPU (это зависит от кода приложения). Проставьте делегаты в зависимости от того, какие ускорители вы можете/хотите использовать на устройстве. И самое главное, проверьте на соответствие input_stream и output_stream в графе и приложении.
Далее уже идут особенности билдов, и для Android и iOS они, естественно, различаются.
Android
Для Android, как минимум не требуется XCode и Mac.
В целом, существующая инструкция описывает максимально подробно процесс билда AAR под Android. После AAR необходимо еще сбилдить граф в .binarypb. Для этого нужно в BUILD-файл добавить граф и выполнить команду bazel build -c opt mediapipe/graphs/custom_graph:custom_graph.
Потом поместить все модели, txt-файлы с классами и сам граф в app/src/main/assets в приложении (мы брали первый пример), а в app/src/main/java/com/example/myfacedetectionapp/MainActivity.java правильно расставить названия для input_stream и output_stream, а также сбилженного графа.
Если дополнительных библиотек к Mediapipe подключать не требуется, то вас можно поздравить! Однако, как я рассказывал выше, сторонняя зависимость в нашей сборке была. Как и в Linux, добавляли мы ее по аналогии с OpenCV. Сбилдили СPP-код заранее для arm-v8a и armeabi-v7 (здесь нам помогла эта статья). Заполнили WORKSPACE, добавили в third_party/ и… ничего не произошло!
Несмотря на то, что зависимость была проставлена в BUILD-файле калькулятора, приложение в Android AAR ее не видело. Все оказалось чуточку сложнее: AAR билдится с помощью написанного разработчиками специального Bazel-правила, которое вы можете найти здесь.
Когда мы добавили native.cc_library с нашей зависимостью туда (помним, что всё по аналогии с OpenCV), по ощущениям стало лучше, но Bazel почему-то отказывался находить сбилженные под Android исходники. Добавление флагов --linkopt=-L{path} с путем к исходникам эту проблему решило — и приложение стало работать корректно.
iOS
Вот и мы подобрались к iOS. Если вы нашли какой-нибудь Mac и успешно установили на него Xcode — дело за малым! Конкретно в нашем кейсе была необходимость создать не приложение, а библиотеку, которая бы использовалась в приложении заказчика. Снова создаем BUILD-файл под наш фреймворк. В качестве примера можно взять вот этот. Мы заменяли правило ios_application на ios_framework, для него необходимо сделать Header-файл с методами библиотеки и подсунуть в аргумент hdrs. Настроить bundle_id поможет эта инструкция. Далее останется только правильно расставить ссылки на граф, модели и txt-файлы с классами для моделей и запустить билд-командой bazel build --config=ios_arm64 :FrameworkName --verbose_failures из папки с BUILD-файлом.
Билдить отдельно граф или что-либо еще дополнительно не нужно — в логах Bazel покажет путь к ZIP-архиву, в котором уже лежат все необходимые файлы.
Те, кто внимательно следили за нашей историей, наверное, задаются вопросом: а как мы здесь добавили стороннюю зависимость? Так вот, здесь впервые на нашем пути нам крупно повезло: у нашей стороней завимости существовал Wrapper для iOS, который мы просто корректно прилинковали в аргумент deps правила ios_framework (и положили в папочку с приложением).
Заключение
Надеюсь, вам было интересно! Я не пытался рассказать о Mediapipe всё и не претендовал на полное исчерпывающее русскоязычное руководство по Mediapipe. Посвятив вас в контекст в первой половине статьи, во второй я пытался рассказать о нашем пути к инференсу в риалтайме, скрасив его полезными советами, ссылками и трюками. По крайней мере вам не придется набивать те же шишки, что уже набили мы.
Если у вас нет большого желания или ресурсов ввязываться в сложный мир риалтаймового инференса ML-моделей на iOS и Android, я рекомендую вам рассмотреть Mediapipe. Безусловно, это не панацея, время на работу с ним вы все равно потратите. Однако за счет большого количества уже готовых и оптимизированных модулей для работы с данными или моделями, это будет явно проще и быстрее, чем писать все с нуля. А учитывая существование этой статьи — это пройдет почти безболезненно ;)