Привет, Хабр! Меня зовут Кирилл Колодяжный, я пишу код на С++ для систем хранения данных в YADRO. Помимо основной работы, интересуюсь машинным обучением и его возможностями, в том числе на «плюсах». Недавно мне стало интересно разобраться, как развернуть модель компьютерного зрения на мобильном устройстве с операционной системой Android.

Я изучил доступные инструменты, чтобы понять, какие части приложения можно реализовать на С++, и написать само приложение для телефона. Ни в одном из материалов на подобную тему не описывают реализацию такого приложения от начала до конца, поэтому я собрал свой опыт в серию статей.

Расскажу, как реализовать обнаружение объектов в реальном времени с помощью камеры на мобильной платформе Android с использованием библиотек PyTorch и NCNN и моделей компьютерного зрения YOLOv5 и YOLOv4. Шаблон моего приложения пригодится тем, кто хочет проверить прототип функциональности для компьютерного зрения на С++, использующий OpenCV на Android, но не хочет глубоко погружаться в программирование под Android. 

В первой части цикла мы:

  • создадим проект в IDE Android Studio,

  • реализуем сессию непрерывного захвата изображений камеры,

  • преобразуем изображения в матрицу OpenCV, чтобы сделать дальнейшую работу удобной. 

Почему я пишу это приложение на С++ 

Программы на С++ получаются более быстрыми и компактными. Нам доступно больше вычислительных ресурсов, поскольку современные компиляторы могут оптимизировать программу в соответствии с архитектурой целевого процессора. C++ не использует дополнительный сборщик мусора для управления памятью — это существенно влияет на производительность программы. 

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

Инструменты, которые я использовал для решения задачи

YOLO (You Only Look Once) — семейство современных моделей обнаружения объектов на изображениях и видео, которые работают с высокой точностью и скоростью. Модели относительно небольшие и легкие, их просто развернуть на устройствах с ограниченными ресурсами. Также они достаточно производительны, что важно для приложений, которые анализируют видеопоток в реальном времени.

На Android мы можем использовать разные фреймворки машинного обучения: PyTorch, ExecuTorch, TensorFlow Lite, NCNN, ONNX runtime frameworks или другие. Для этой задачи использую платформу PyTorch, а именно — TorchScript и NCNN. Она позволяет задействовать практически любую модель PyTorch на мобильных платформах с минимальными функциональными ограничениями. К тому же это мой основной инструмент для ML.

Ссылки на инструменты:

Исходный код проекта для этой статьи найдете в репозитории GitFlic.

Проект Android Studio

В этом разделе рассмотрим, как использовать IDE Android Studio для создания мобильного приложения. Сначала используем мастер Native C++ Activity в IDE Android Studio для создания заглушки приложения. Если назвать проект objectdetection и выбрать Kotlin как язык разработки, то Android Studio создаст определенную структуру проекта. В следующем примере показаны наиболее важные ее части:

app
|--src
|  `--main
| 	|--cpp
| 	|  |—CmakeLists.txt
| 	|  `—native-lib.cpp
| 	|--java
| 	|  `--com
| 	| 	`--example
| 	|    	`--objectdetection
| 	|       	`--MainActivity.kt
| 	|--res
| 	|  `--layout
| 	| 	`--activity_main.xml
| 	|--values
|    	|--colors.xml
|    	|--strings.xml
|    	|--styles.xml
|      	`—…
|--build.gradle
`--...
  • Папка cpp содержит C++-часть для проекта. В этом проекте нативная часть на C++ создана как библиотека, сборка который настроена с помощью CMake

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

  • Папка res содержит ресурсы проекта: элементы пользовательского интерфейса и определения строк.

В этом проекте основная функциональность приложения будет реализована в нативной части на С++. Там будут функции для детекции объектов и отрисовки захваченного изображения, bounding boxes и меток классов обнаруженных объектов. Таким образом, Kotlin-часть будет максимально сокращена и не будет содержать никакого кода для пользовательского интерфейса.

Kotlin-часть проекта: пишем основу приложения

Kotlin-часть будем использовать для запроса и проверки необходимых разрешений для доступа к камере. К сожалению, подобная функциональность отсутствует в C++ API для Android NDK. Можно, конечно, вызывать ее из С++, используя JNI. Но, думаю, это только усложнит код. Кроме того, из Kotlin будет запускаться сеанс захвата изображения с камеры, если необходимые разрешения предоставлены. Весь код на Kotlin находится в одном файле — MainActivity.kt.

Сохраняем ориентации камеры

В этом проекте я не буду полностью реализовывать обработку поворота устройства, чтобы упростить код и показать только самые интересные моменты работы с моделью обнаружения объектов. Итак, чтобы сделать код стабильным, я отключу ландшафтный режим. Это можно сделать в файле AndroidManifest.xml следующим образом:

…
<activity
	…
	android:screenOrientation="portrait">
…

Я только добавил инструкцию по ориентации экрана к объекту activity, который описывает поведения нашего приложения. 

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

Обрабатываем запрос разрешения доступа к камере

Первым шагом изменим объявление класса MainActivity, чтобы обрабатывать результаты запроса для получения разрешения.

class MainActivity : NativeActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
…
}

Здесь я унаследовал класс MainActivity от интерфейса OnRequestPermissionsResultCallback. Это позволило переопределить метод onRequestPermissionsResult, где можно проверить результат запроса разрешений. Но чтобы получить результат, я должен сначала сделать запрос следующим образом:

override fun onResume() {
  super.onResume()
  val cameraPermission = android.Manifest.permission.CAMERA
  if (checkSelfPermission(cameraPermission) != PackageManager.PERMISSION_GRANTED) {
	requestPermissions(arrayOf(cameraPermission), CAM_PERMISSION_CODE)
  } else {
	val camId = getCameraBackCameraId()
	if (camId.isEmpty()) {
  	Toast.makeText(
           	this,
           	"Camera probably won't work on this device!",
           	Toast.LENGTH_LONG).show()
  	finish()
	}
	initObjectDetection(camId)
  }
}

Я переопределил метод onResume класса Activity. Этот метод вызывается каждый раз, когда приложение начинает работать или возобновляется из фонового режима. Я инициализировал переменную cameraPermission константой для получения разрешения камеры. 

Затем методом checkSelfPermission проверил, предоставлено ли уже это разрешение. 

  • Если разрешения на камеру еще нет, я запрашиваю его с помощью метода requestPermissions. Обратите внимание, что я использовал код CAM_PERMISSION_CODE для идентификации нашего запроса, он будет проверен далее в методе обратного вызова. 

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

  • Если получить идентификатор камеры не получается, я завершаю работу приложения с помощью метода finish и показываю соответствующее сообщение. 

В методе onRequestPermissionsResult я проверяю, было ли предоставлено требуемое разрешение следующим образом:

override fun onRequestPermissionsResult(requestCode: Int,
                                        permissions: Array<out String>,
                                        grantResults: IntArray) {
  super.onRequestPermissionsResult(requestCode,
                                   permissions,
                                   grantResults)
  if (requestCode == CAM_PERMISSION_CODE &&
  	grantResults[0] != PackageManager.PERMISSION_GRANTED) {
	Toast.makeText(this,
               	   "This app requires camera permission",
                   Toast.LENGTH_SHORT).show()
	finish()
  }
}

Сначала я использовал родительский метод, чтобы сохранить стандартное поведение приложения. Затем проверил идентификационный код разрешения CAM_PERMISSION_CODE и убедился, было ли разрешение предоставлено. Если в разрешении отказано, я просто показываю сообщение об ошибке и завершаю работу приложения.

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

private fun getCameraBackCameraId(): String {
  val camManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
  for (camId in camManager.cameraIdList) {
	val characteristics = camManager.getCameraCharacteristics(camId)
	val hwLevel = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)
	val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
	if (hwLevel != INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY && facing == LENS_FACING_BACK) {
  	  return camId
	}
  }
  return ""
}

Сначала я получаю экземпляр объекта CameraManager. И использую его для перебора каждой доступной камеры на устройстве. Для каждого объекта камеры я запрашиваю его характеристики, поддерживаемый аппаратный уровень и место, куда обращена камера. Если камера — обычное устройство и обращена назад, возвращаем ее идентификатор. Если не нашел подходящее устройство, возвращаю пустую строку.

Получив разрешение на доступ к камере и идентификатор камеры, вызываю функцию initObjectDetection, чтобы начать захват изображений и обнаружение объектов. Эта функция, как stopObjectDetection, предоставляется через Java Native Interface из C++ в Kotlin. Функцию stopObjectDetection использую для остановки сеанса захвата изображений:

override fun onPause() {
  super.onPause()
  stopObjectDetection()
}

Этот метод вызывается каждый раз, когда приложение Android закрывается или переходит в фоновый режим.

Загружаем native-библиотеки 

У нас есть два метода — initObjectDetection и stopObjectDetection, которые являются JNI-вызовами функций, реализованных в C ++. Чтобы соединить нативную библиотеку с кодом Java или Kotlin, я использую Java Native Interface (JNI). Это стандартный механизм, который используется для вызова функций C/C++ из Kotlin или Java. 

Сначала я загружаю нативную библиотеку с помощью функции System.LoadLibrary в инициализации companion объекта для нашей Activity. Затем я определяю методы, которые реализованы в нативной библиотеке, объявив их как «внешние». Следующий фрагмент показывает, как это cделать в Kotlin:

private external fun initObjectDetection()
private external fun stopObjectDetection()
 
companion object {
  init {
	System.loadLibrary("object-detection")
  }
}

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

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

Нативная часть проекта на C++: создаем сессию непрерывного захвата изображений

Подключаем библиотеку OpenCV

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

Чтобы использовать библиотеку OpenCV на Android, можно скачать уже скомпилированный SDK из официального релиза, архив opencv-4.10.0-android-sdk.zip. А затем просто распаковать в удобную вам директорию. Он подключится к проекту стандартным для CMake способом:

find_package(OpenCV REQUIRED)
...
add_library(${CMAKE_PROJECT_NAME} SHARED ${SOURCES})
 
target_link_libraries(${CMAKE_PROJECT_NAME}
    	android
    	...
    	opencv_java
)

Чтобы сборка Android-проекта смогла найти OpenCV SDK, нам надо установить значение параметра OpenCV_DIR для CMake. Это можно сделать, изменив скрипты сборки Android-проекта. Так как по умолчанию для сборки Android-проектов используется система Gradle, можно изменить файл build.gradle.kts:

...
val opencvDir = "/development/android/OpenCV-android-sdk/sdk/native/jni/"
...
externalNativeBuild {
	cmake {
		arguments += "-DANDROID_ARM_NEON=TRUE"
		arguments += "-DANDROID_STL=c++_shared"
		arguments += "-DOpenCV_DIR=${opencvDir}"
	}
}

Заводить переменную opencvDir необязательно. Мне показалось, что так удобнее. Посмотрите на детали файлов CMakeLists.txt и build.gradle.kts в репозитории проекта для уточнения деталей.

Инициализируем сессии обнаружения объектов с помощью JNI

Я закончил обсуждение Kotlin-части объявлениями JNI-функций. Соответствующие реализации для initObjectDetection и stopObjectDetection на C++ расположены в файле native-lib.cpp. Он автоматически создается IDE Android Studio для нативных проектов. Следующий фрагмент кода показывает определение функции initObjectDetection:

std::shared_ptr<CameraCapture> camera_capture_;
...
extern "C" JNIEXPORT void JNICALL
Java_com_example_objectdetection_MainActivity_initObjectDetection(
    	JNIEnv *env,
    	jobject /* this */,
    	jstring camId) {
	auto camera_id = env->GetStringUTFChars(camId, nullptr);
	LOGI("Camera ID: %s", camera_id);
	if (camera_capture_) {
    	camera_capture_->allow_camera_session(camera_id);
    	camera_capture_->configure_resources();
	} else
    	LOGE("CameraCapture object is missed!");
}

Я сделал объявление функции, следуя правилам именования JNI, чтобы оно было видимым из Java/Kotlin-части. Имя функции включает полное имя пакета Java, включая пространства имен, а также первые два обязательных параметра — типы JNIEnv* и jobject. Третий параметр — строка, он соответствует идентификатору камеры и существует в объявлении функции на Kotlin.

В реализации функции проверяю, создан ли уже экземпляр объекта CameraCapture. Вызываю метод allow_camera_session с идентификатором камеры, а затем вызываю метод configure_resources. Эти вызовы настраивают соответствующую камеру, окно вывода и инициализируют конвейер захвата изображения в объекте CameraCapture.

Вторая функция, которую я использовали в Kotlin-части — stopObjectDetection, и ее реализация выглядит так:

extern "C" JNIEXPORT void JNICALL
Java_com_example_objectdetection_MainActivity_stopObjectDetection(
    	JNIEnv *,
    	jobject /* this */) {
	camera_capture_->release_resources();
}

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

Вы можете видеть, что я использовал функции LOGI и LOGE. Они определены в файле log.h следующим образом:

#include <android/log.h>
#define LOG_TAG "OBJECT-DETECTION"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define ASSERT(cond, fmt, ...)                            	\
  if (!(cond)) {                                          	\
	__android_log_assert(#cond, LOG_TAG, fmt, ##__VA_ARGS__); \
  }

Я определил эти функции для упрощения логирования в подсистеме Android logcat. Эта серия функций использует один и тот же тег для ведения журнала, и у них меньше аргументов, чем у __android_log_xxx. Кроме того, уровень логирования закодирован в имени функции.

Основной цикл приложения

В этом проекте я буду использовать библиотеку NativeAppGlue. Это библиотека для разработчиков Android, которая помогает создавать нативные приложения. Она реализует определенный слой абстракции между кодом Java/Kotlin и нативным кодом, упрощая разработку приложений с использованием обоих языков.

Эта библиотека позволяет нам определить функцию android_main, схожую по функциональности со стандартной main. В ней можно реализовать цикл для обновления пользовательского интерфейса, обработки пользовательского ввода и системных событий. Следующий фрагмент кода показывает, как я реализовал эту функцию в файле native-lib.cpp:

extern "C" void android_main(struct android_app *app) {
	LOGI("Native entry point");
	camera_capture_ = std::make_shared<CameraCapture>(app);
	app->onAppCmd = process_android_cmd;
	app->onInputEvent = process_android_input;
	while (!app->destroyRequested) {
    	struct android_poll_source *source = nullptr;
    	auto result = ALooper_pollOnce(0, nullptr, nullptr, (void **) &source);
    	ASSERT(result != ALOOPER_POLL_ERROR, "ALooper_pollOnce returned an error");
    	if (source != nullptr) {
        	source->process(app, source);
    	}
    	if (camera_capture_)
        	camera_capture_->draw_frame();
	}
	// application is closing ...
	camera_capture_.reset();
}

Функция android_main принимает экземпляр типа android_app вместо обычных параметров argc и argv. android_app — это класс C ++, который предоставляет доступ к платформе Android и позволяет взаимодействовать с системными службами. Также его можно использовать для доступа к оборудованию устройства, такому как датчики и камеры.

Функция android_main — входная точка для нашего нативного модуля. Я в ней инициализирую глобальный объект camera_capture_, чтобы он стал доступен для функций initObjectDetection и stopObjectDetection. Для инициализации CameraCapture я делаю следующее: 

  • Передаю в конструктор указатель на объект android_app для последующей работы с системными службами. 

  • Затем подключаю функцию обработки команд и пользовательского ввода к объекту android_app

  • Запускаю основной цикл, который будет работать до тех пор, пока приложение не будет закрыто. 

В этом цикле я использовал функцию Android NDK ALooper_pollOnce, чтобы получить указатель на объект опроса команд (событий). С помощью метода process этого объекта я инициирую вызов функций process_android_cmd и process_android_input через объект app. И в конце цикла я использую наш объект camera_capture для захвата изображений с камеры и их обработки в методе draw_frame.

Функция process_android_cmd реализована следующим образом:

static void process_android_cmd(struct android_app */*app*/, int32_t cmd) {
	if (camera_capture_) {
    	switch (cmd) {
        	case APP_CMD_INIT_WINDOW:
            	camera_capture_->configure_resources();
            	break;
        	case APP_CMD_TERM_WINDOW:
            	camera_capture_->release_resources();
            	break;
    	}
	}
}

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

Функция process_android_input используется только для переключения моделей детекции объектов и реализована следующим образом:

static int32_t process_android_input(struct android_app * /*app*/, AInputEvent *event) {
	if (camera_capture_ && event) {
    	camera_capture_->change_detector();
	}
	return 0;
}

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

В следующих подразделах рассмотрим детали реализации класса CameraCapture

Обзор класса CameraCapture

Это главный фасад всего конвейера обнаружения объектов. Функциональность, которую он реализует:

  • Управление доступом к устройству камеры.

  • Конфигурация размеров окна приложения.

  • Управление конвейером захвата изображений.

  • Преобразование изображения с камеры в объекты матрицы OpenCV.

  • Вывод результата обнаружения объектов в окно приложения.

  • Делегирование функции обнаружения объектов — объектам, реализующим интерфейс CameraCapture.

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

CameraCapture::CameraCapture(android_app *app) : android_app_(app) {
	yolo_v5_torch_ = std::make_shared<TorchYolo>(app->activity->assetManager, &stats_);
	yolo_v5_ncnn_ = std::make_shared<NcnnYolo>(app->activity->assetManager, &stats_, false);
	yolo_v4_ncnn_ = std::make_shared<NcnnYolo>(app->activity->assetManager, &stats_, true);
	yolo_ = yolo_v4_ncnn_;
}

Тут я сохраняю указатель на объект android_app и создаю объекты, реализующие детекцию объектов с помощью inference-моделей на базе архитектуры YOLO. Также из объекта android_app я получил указатель на объект типа AAssetManager, который используется для загрузки файлов, упакованных в приложение Android (в APK). Деструктор реализован следующим образом:

CameraCapture::~CameraCapture() {
	release_resources();
	LOGI("СameraCampture was destroyed!");
}
 
void CameraCapture::release_resources() {
	delete_camera();
	delete_image_reader();
	delete_session();
}

Здесь я использовал метод release_resources, в котором закрываю открытое устройство камеры и очищаю объекты конвейера захвата изображений. Следующий фрагмент кода показывает метод, который косвенно вызывается в Kotlin-части с помощью вызова функции initObjectDetection:

void CameraCapture::allow_camera_session(std::string_view camera_id) {
	camera_id_ = camera_id;
}

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

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

bool CameraCapture::is_session_allowed() const {
	return !camera_id_.empty();
}

Тут я просто проверяю, не является ли идентификатор камеры пустым.

В следующих подразделах подробно покажу реализации основных частей функциональности класса CameraCapture.

Конфигурация устройства камеры и окна приложения

Для создания объекта диспетчера камеры и открытия устройства камеры в классе CameraCapture есть метод create_camera:

void CameraCapture::create_camera() {
	camera_mgr_ = ACameraManager_create();
	ASSERT(camera_mgr_, "Failed to create Camera Manager");
	ACameraManager_openCamera(camera_mgr_, 
                              camera_id_.c_str(), 
                              &camera_device_callbacks,
                          	  &camera_device_);
	ASSERT(camera_device_, "Failed to open camera");
}

Здесь camera_mgr_ является членом класса CameraCapture и после инициализации используется для управления камерой. Указатель на открытое устройство камеры будет сохранен в переменной camera_device_. Также обратите внимание, что я использовал строку идентификатора камеры для открытия конкретного устройства. Переменная camera_device_callbacks определяется так:

namespace {
	void onDisconnected([[maybe_unused]]void *context,
                        [[maybe_unused]]ACameraDevice *device) {
    	LOGI("Camera onDisconnected");
	}
	void onError([[maybe_unused]]void *context,
                 [[maybe_unused]]ACameraDevice *device, 
                 int error) {
    	LOGE("Camera error %d", error);
	}
	ACameraDevice_stateCallbacks camera_device_callbacks = {
        	.context = nullptr,
        	.onDisconnected = onDisconnected,
        	.onError = onError,
	};
}

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

Метод create_camera вызывается в методе configure_resources каждый раз, когда приложение активируется:

void CameraCapture::configure_resources() {
	if (!is_session_allowed() || !android_app_ || !android_app_->window) {
    	LOGE("Can't configure output window!");
    	return;
	}
	if (!camera_device_)
    	create_camera();
	// настройка размера и формата окна вывода
	...
	if (!image_reader_ && !session_output_) {
    	create_image_reader();
    	create_session();
	}
}

Вначале я проверяю наличие всех необходимых ресурсов: идентификатора камеры, объекта android_app и того, что у этого объекта есть указатель на окно приложения. Затем я создаю объект диспетчера камер и открываю устройство камеры. Используя диспетчер камер, получаю ориентацию датчика камеры, чтобы настроить соответствующую ширину и высоту окна приложения. Далее, с помощью значений ширины и высоты разрешения камеры (camera resolution), я настраиваю размеры окна следующим образом:

ACameraMetadata *metadata_obj{nullptr};
	ACameraManager_getCameraCharacteristics(camera_mgr_,
                                            camera_id_.c_str(),
                                            &metadata_obj);
	ACameraMetadata_const_entry entry;
	ACameraMetadata_getConstEntry(metadata_obj,
                                  ACAMERA_SENSOR_ORIENTATION,
                                  &entry);
	orientation_ = entry.data.i32[0];
	bool is_horizontal = orientation_ == 0 || orientation_ == 270;
	auto out_width = is_horizontal ? width_ : height_;
	auto out_height = is_horizontal ? height_ : width_;
	auto status = ANativeWindow_setBuffersGeometry(android_app_->window, 
                                                   out_width, out_height, 
                                                   WINDOW_FORMAT_RGBA_8888);
	if (status < 0) {
    	LOGE("Can't configure output window, failed to set output format!");
    	return;
	}

Здесь я использую функцию ACameraManager_getCameraCharacteristics для получения объекта характеристик метаданных камеры. Из полученных характеристик я получаю значение для ориентации сенсора с помощью функции ACameraMetadata_getConstEntry и соответствующей константы ACAMERA_SENSOR_ORIENTATION

Далее в зависимости от ориентации я выбираю соответствующий порядок ширины и высоты. Если ориентация горизонтальная, я поменяю местами ширину и высоту. Точные значения определены в заголовочном файле и равны 800 для высоты и 600 для ширины в режиме portrait. Это очень упрощенная обработка ориентации, она необходима только для корректной работы с буфером окна вывода. Как вы помните, в начале статьи я отключил ландшафтный режим для приложения, поэтому буду игнорировать ориентацию датчика камеры при декодировании изображения. 

В завершение, используя функцию ANativeWindow_setBuffersGeometry, я задаю размеры окна приложения, куда будет происходить отрисовка и форматирование буфера изображения. Я выбрал 32-битный RGBA-формат.

В конце метода configure_resources создается объект camera reader и инициализируется сессия захвата изображений. 

Инициализация сессии захвата изображений

Ранее перед инициализацией конвейера захвата я создавал объект image reader. Это делается в методе create_image_reader:

void CameraCapture::create_image_reader() {
	constexpr int32_t MAX_BUF_COUNT = 4;
	auto status = AImageReader_new(width_, height_,
                                   AIMAGE_FORMAT_YUV_420_888,
                                   MAX_BUF_COUNT,
                                   &image_reader_);
	ASSERT(image_reader_ && status == AMEDIA_OK, "Failed to create AImageReader");
}

Использую функцию AImageReader_new для создания объекта типа AImageReader с параметрами определенной ширины, высоты, формата YUV и с четырьмя буферами изображений. Значения ширины и высоты я использовал те же, что использовались для настройки размеров окна вывода. Формат YUV использован потому, что это родной формат изображения для большинства камер. Четыре буфера изображений используются, чтобы сделать захват изображений независимым от их обработки. Это означает, что процесс захвата изображений будет заполнять один буфер изображения данными камеры, пока мы считываем и обрабатываем другой буфер.

Инициализация сессии захвата — более комплексный процесс, который требует создания нескольких объектов и их соединения друг с другом. Метод create_session реализует это так:

void CameraCapture::create_session() {
	ANativeWindow *output_native_window;
	AImageReader_getWindow(image_reader_, &output_native_window);
    ANativeWindow_acquire(output_native_window);

Я начал с получения объекта окна для объекта image reader и захвата владения им. Это означает, что я взял ссылку на окно и системе не следует его удалять. Это окно будет использоваться как целевое для чтения изображений конвейером захвата, то есть в него будут попадать изображения с камеры.

    ACaptureSessionOutputContainer_create(&output_container_);
  
	ACaptureSessionOutput_create(output_native_window, &session_output_);
	
	ACaptureSessionOutputContainer_add(output_container_, session_output_);

Затем я создаю объект контейнера для выходных потоков сессии и сам объект выходного потока для сессии. Сессия захвата может иметь несколько выходных потоков, и их все следует поместить в контейнер. Каждый выходной поток сессии представляет собой объект для подключения конкретной поверхности или окна вывода, в нашем случае это окно объекта image reader.

	ACameraOutputTarget_create(output_native_window, &output_target_);
  
	ACameraDevice_createCaptureRequest(camera_device_,
                               	       TEMPLATE_PREVIEW,
                                       &capture_request_);
 
	ACaptureRequest_addTarget(capture_request_, output_target_);

Настроив выходные потоки сессии, я создаю объект запроса захвата изображения. И делаю так, чтобы целью его вывода было окно объекта image reader. Это делается созданием и добавлением объекта целевого выхода (см. функции ACameraOutputTarget_create и ACaptureRequest_addTarget). Я настраиваю запрос на захват изображений с открытой камеры в режиме предварительного просмотра.

	ACameraDevice_createCaptureSession(camera_device_,
                                       output_container_,
                                       &session_callbacks,
                                       &capture_session_);
	
	ACameraCaptureSession_setRepeatingRequest(capture_session_,
                                              nullptr,
                                              1,
                                              &capture_request_,
                                              nullptr);
}

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

Связь между сессией и запросом на захват изображений заключается в следующем: создавая сессию захвата, мы настраиваем ее списком возможных выходных потоков, а в запросе (ACaptureRequest) на захват указываем, какие конкретно потоки будут использоваться. Может быть несколько запросов на захват и выходных потоков. В нашем случае у нас один запрос завязывается с одним выходом. Также наш запрос будет непрерывно повторяться, то есть я буду получать изображения в реальном времени как видеопоток. На следующем рисунке показана логическая схема потоков данных в сессии захвата изображений:

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

Управление буфером захвата изображений и буфером окна вывода

Когда я рассказывал про основной цикл приложения, я упомянул метод draw_frame, который вызывается в этом цикле после обработки команд. Этот метод используется для:

  • получения изображения камеры из объекта image_reader_,

  • обнаружения объектов на изображении,

  • отображения результатов обнаружения в окне приложения.

Следующий фрагмент кода показывает реализацию этого метода:

void CameraCapture::draw_frame() {
	if (image_reader_ == nullptr)
    	return;
	AImage *image = nullptr;
	auto status = AImageReader_acquireNextImage(image_reader_, &image);
	if (status != AMEDIA_OK) {
    	return;
	}
	ANativeWindow_acquire(android_app_->window);
	ANativeWindow_Buffer buf;
	if (ANativeWindow_lock(android_app_->window, &buf, nullptr) < 0) {
    	AImage_delete(image);
    	return;
	}
	process_image(&buf, image);
	AImage_delete(image);
	ANativeWindow_unlockAndPost(android_app_->window);
	ANativeWindow_release(android_app_->window);
}

Здесь я получаю следующее доступное изображение с камеры от объекта image_reader_

Помните, я инициализировал его так, чтобы в нем было четыре буфера изображений? Таким образом я получаю изображения из этих буферов одно за другим. Пока обрабатывается одно изображение, следующее уже захватывается и попадает в другой буфер. И так по кругу. Получив изображение с камеры, я получаю и блокирую окно приложения для отрисовки результатов в него. Если блокировка не удалась, то я удаляю текущую ссылку на изображение, останавливаю обработку, и цикл повторяется. Если окно удачно заблокировалось, я обрабатываю текущее изображение. 

С помощью метода process_image я нахожу объекты и вывожу результаты обнаружения в окно приложения. Метод process_image принимает на вход объекты AImage и ANativeWindow_Buffer. Когда я блокирую окно приложения, то получаю указатель на внутренний буфер, который и будет использоваться для рисования. 

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

Обработка полученного изображения 

Метод process_image решает следующие задачи:

  • Преобразует данные изображения Android YUV в матрицу OpenCV.

  • Перенаправляет матрицу изображения в детектор объектов YOLO.

  • Выводит результатов обнаружения в матрицу OpenCV.

  • Копирует матрицу с нарисованными результатами в буфер окна.

Давайте посмотрим на реализацию этих задач. Сигнатура метода process_image выглядит следующим образом:

void process_image(ANativeWindow_Buffer *buf, AImage *image);

Этот метод использует объект буфера окна приложения для отрисовки результатов и объект изображения для фактической обработки. Чтобы в дальнейшем обработать изображение, преобразовываю его в соответствующую структуру данных — в нашем случае это матрица OpenCV. Реализация метода начинается с проверки свойств формата изображения и окна:

ASSERT(buf->format == WINDOW_FORMAT_RGBX_8888 || 
       buf->format == WINDOW_FORMAT_RGBA_8888,
       "Not supported buffer format");
 
int32_t src_format = -1;
AImage_getFormat(image, &src_format);
ASSERT(AIMAGE_FORMAT_YUV_420_888 == src_format,
       "Unsupported image format for displaying");

int32_t num_src_planes{0};
AImage_getNumberOfPlanes(image, &num_src_planes);
ASSERT(num_src_planes == 3,
       "Image for display has unsupported number of planes");

int32_t src_height{0};
AImage_getHeight(image, &src_height);
int32_t src_width{0};
AImage_getWidth(image, &src_width);

Сначала я проверяю, что буфер окна имеет формат RGB (A/X). Далее — уточняю формат изображения (YUV) и то, что у него три канала. Затем я получаю размеры изображения, они пригодятся дальше. После проверки входных данных я получаю данные для каналов YUV:

int32_t y_stride{0};
AImage_getPlaneRowStride(изображение, 0, &y_stride);
int32_t uv_stride1{0};
AImage_getPlaneRowStride(изображение, 1, &uv_stride1);
int32_t uv_stride2{0};
AImage_getPlaneRowStride(изображение, 1, &uv_stride2);
 
uint8_t *y_pixel{nullptr}, *uv_pixel1{nullptr}, *uv_pixel2{nullptr};
int32_t y_len{0}, uv_len1{0}, uv_len2{0};
AImage_getPlaneData(изображение, 0, &y_pixel, &y_len);
AImage_getPlaneData(изображение, 1, &uv_pixel1, &uv_len1);
AImage_getPlaneData(изображение, 2, &uv_pixel2, &uv_len2);

Для начала я получаю значения stride, размер данных и указатель на фактические данные для каждого канала YUV. В этом формате данные изображения разделяются на три компонента: яркость Y и два компонента цветности U и V. Компонент Y обычно сохраняется в полном разрешении, в то время как компоненты U и V могут иметь уменьшенный размер. Это обеспечивает более эффективное хранение и передачу видеоданных. В изображении Android YUV используется половинное разрешение для каналов U и V. Полученные значения stride позволят нам корректно получить доступ к строкам данных в буферах, значения stride зависят от разрешения изображения и расположения памяти данных.

Данные каналов YUV, значения stride ширины и длины я преобразую в матрицы OpenCV:

cv::Size actual_size(src_width, src_height);
cv::Size half_size(src_width / 2, src_height / 2);
 
cv::Mat y(actual_size, CV_8UC1, y_pixel, y_stride);
cv::Mat uv1(half_size, CV_8UC2, uv_pixel1, uv_stride1);
cv::Mat uv2(half_size, CV_8UC2, uv_pixel2, uv_stride2);

Я создал два объекта типа cv::Size, чтобы сохранить исходный размер изображения для канала Y и половинный размер для каналов U и V. Затем я использовал эти размеры, указатели на данные и значения stride для создания матриц OpenCV для каждого канала. 

Обратите внимание, что фактически данные в матричные объекты OpenCV не копируются. Матрицы будут использовать данные, расположенные по указателям, которые были переданы для инициализации. Такой подход экономит память и вычислительные ресурсы. У матрицы канала Y — 8-битный одноканальный тип данных, однако у матрицы для объединенных каналов UV — 8-битный двухканальный тип данных. Это связано с тем, что у формата YUV также может быть различный порядок компоновки. 

Используя функцию OpenCV cvtColorTwoPlane, мы можем преобразовать эти матрицы каналов в формат RGBA:

cv::mat rgba_img_;
...
long addr_diff = uv2.data - uv1.data;
if (addr_diff > 0) {
   cvtColorTwoPlane(y, uv1, rgba_img_, cv::COLOR_YUV2RGBA_NV12);
} else {
	cvtColorTwoPlane(y, uv2, rgba_img_, cv::COLOR_YUV2RGBA_NV21);
}

Для определения порядка компоновки формата YUV я использую разницу адресов данных для каналов U и V, при этом положительная разница соответствует формату NV12, а отрицательная — формату NV21. NV12 и NV21 — это типы формата YUV, которые отличаются порядком расположения U- и V-компонентов в канале цветности. 

В NV12 компонент U предшествует компоненту V, в то время как в NV21 все наоборот. Такая компоновка каналов играет роль в размере используемой памяти и производительности обработки изображений, поэтому выбор, что использовать, зависит от реальной задачи и проекта. Кроме того, компоновка может зависеть от устройства камеры, поэтому я добавил ее обработку.

Функция cvtColorTwoPlane преобразует матрицы Y-канала и матрицу совмещенных UV-каналов в RGBА- или RGB-изображения. Последний аргумент этой функции — флаг, который указывает, какое фактическое преобразование она должна выполнить.

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

cv::rotate(rgba_img_, rgba_img_, cv::ROTATE_90_CLOCKWISE);

Камеры в Android возвращают изображения повернутыми, даже если я зафиксировал ориентацию, поэтому я и использую функцию cv::rotate, чтобы придать им вертикальный вид.

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

auto results = yolo_->detect(rgba_img_);
 
for (auto &result: results) {
	rectangle(rgba_img_,
              result.rect.tl(), result.rect.br(),
              cv::Scalar(255, 0, 0, 255),
              2, cv::LINE_4);
 
	cv::putText(rgba_img_,
                result.class_name,
                result.rect.tl(),
                cv::FONT_HERSHEY_DUPLEX,
                1.0,
                CV_RGB(0, 255, 0),
                2);
}

Я вызываю метод detect объекта ObjectDetector и получаю контейнер results. Этот метод мы рассмотрим позже. Затем для каждого элемента в контейнере я рисую ограничивающую рамку и текстовую метку для обнаруженного объекта. Обращаюсь к функциям OpenCV rectangle и putText, где целевое изображение использует переменную rgba_img_ — наше исходное изображение. Результатом обнаружения является структура, определенная в заголовочном файле detector_interface.h:

struct DetectionResult {
	int class_index;
	std::string class_name;
	float score;
	cv::Rect rect;
};

В ней присутствуют значения индекса класса, строка с названием класса, оценка достоверности, ограничивающая рамку в координатах изображения. Для визуализации наших результатов я использовал только свойства rectangle и class_name.

Последняя задача, которую выполняет метод process_image, — это рендеринг результирующего изображения в буфер окна приложения:

cv::Mat buffer_mat(src_width, src_height, CV_8UC4, buf->bits, buf->stride * 4);
rgba_img_.copyTo(buffer_mat);

Я создаю матрицу OpenCV buffer_mat таким образом, чтобы ее данные были данными буфера окна. Для этого я передаю в конструктор указатель buf->bit на данные буфера окна b и значение смещения buf->stride*4. Размер и тип данных соответствуют формату буфера, который я настроил в методе configure_resources, в формате WINDOW_FORMAT_RGBA_8888.

Затем я просто использую метод матрицы CopyTo, чтобы скопировать изображение визуализированными прямоугольниками и метками классов в объект buffer_mat. Как вы поняли, buffer_mat представляет буфер Android окна как объект OpenCV. Такой подход позволяет писать нам меньше кода и использовать процедуры OpenCV для обработки изображений и управления памятью.

Что будет дальше

 Мы рассмотрели главный фасад нашего приложения для обнаружения объектов. Вот так выглядит интерфейс в процессе работы:

В продолжении статьи я расскажу, как:

  • подключить к проекту библиотеки машинного обучения PyTorch и NCNN,

  • получить модели YOLOv5 и YOLOv4 для использования на мобильном устройстве,

  • реализовать inference моделей для обнаружения объектов,

  • обработать результаты работы моделей YOLO, реализовав алгоритмы Non-Maximum-Suppression и Intersection-Over-Union.

Также мы сравним производительность PyTorch с NCNN и использованных моделей. Подписывайтесь, чтобы не пропустить вторую часть.

Комментарии (6)


  1. TimID
    16.10.2024 11:16

    Возможно всё же не pyTorch, а libTorch? Раз уж на C++


    1. Mik42 Автор
      16.10.2024 11:16

      В целом вы правы. Но так как libtorch это конкретный артефакт(библиотека) который компилируется из репозитория PyTorch то я предпочитаю говорить про PyTorch.


  1. seyko2
    16.10.2024 11:16

    Как то уж слишком подробно. Для начала хотелось бы больше про верхний уровень (функциональный). А так тема и результат - интересны.