Найти тему
IT notes

Пишем простейшее приложение под Android для распознавания объектов с камеры

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

Итоговое приложение в работе будет выглядеть примерно так

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

Что нам понадобится?

  1. Android смартфон или планшет с работающей камерой
  2. USB дата кабель
  3. Компьютер под управлением Windows 10 (можно Linux или MacOS, но примеры в статье именно для Windows)
  4. Выход в глобальную сеть Интернет
  5. Android Studio (я использовал версию 3.6.1 for Windows 64-bit)
  6. Библиотека компьютерного зрения OpenCV ( я использовал версию 4.2)
  7. И примерно полчаса свободного времени, не считая времени на установку и скачивание

Итак, скачали, Android Studio установили (все опции оставляем по умолчанию), OpenCV распаковали из zip архива.

Запускаем Android Studio. Если это первый запуск, то можно просто везде кликать Next, чтобы завершить установку. Когда появится меню, как на картинке ниже, то нажимаем + Start a new Android Studio project.

-2

Выбираем Empty Activity, нажимаем Next.

-3

Мы выбрали простейший шаблон приложения с точки зрения экранных форм. Empty Activity мы в дальнейшем отредактируем для отображения изображения с камеры на полный экран (Android Camera Preview).

Далее настраиваем наш проект: задаём название, имя пакета, расположение исходных файлов проекта, язык программирования и минимальную версию SDK.

Можно сделать всё точно как на картинке, можно написать и выбрать что-то своё.

-4

Название проекта может быть любым на латинице.

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

Расположение проекта - тут всё понятно, но я рекомендую, чтобы Name проекта совпадал с названием директории в конце Save Location.

Язык программирования вы можете выбрать либо Java, либо Kotlin. В нашем случае мы будем писать код на языке Java.

Minimum SDK - это минимальная версия Android, на котором будет работать ваше приложение. Я выбираю значение по умолчанию - API 16, что соответствует версии Android 4.1. Снизу нам подсказывают, что на текущий момент наше приложение будет совместимо примерно с 99,6% устройств.

Вводим всё, что просят, нажимаем Finish.

В итоге мы должны увидеть заготовку похожую на это

-5

Ну, или в тёмной схеме, если вы выбрали её при первом запуске. Схему можно всегда поменять в настройках (для Windows это Ctrl + Alt + S).

Обратите внимание, что внизу в статусной строке справа вы видите надпись 2 processes running... Это значит, что в фоне идут какие-то процессы. Чтобы узнать какие именно, можно кликнуть по этой надписи. Например, в фоне может происходить индексация проекта или подгрузка различных библиотек, или зависимостей.

В принципе, уже всё готово для запуска нашего простейшего приложения в эмуляторе, если при установке не возникло каких-либо проблем.

Если всё хорошо, то верхняя горизонтальная панель инструментов будет выглядеть примерно (или точно) так

-6

Pixel 2 XL API 28 - это название виртуального устройства (эмулятора), в котором запустится приложение при нажатии кнопки Play.

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

Для запуска приложения на виртуальном устройстве вы прямо сейчас можете нажать Shift + F10. При этом сначала запустится само устройство, потом произойдёт сборка приложения, после чего оно зальётся на устройство и уже там запустится наш Hello World!

Хорошо. Но, наша цель запустить приложение на реальном телефоне/планшете. Для этого сначала подготовим сам телефон. На нём необходимо включить режим разработчика.

Открываем меню настройки на телефоне/планшете (обычно это иконка с шестерёнкой), заходим в раздел "О телефоне". Далее нажмите 7 раз подряд на версию оболочки (и это не шутка), затем переходим в Настройки ► Расширенные настройки (или пункт меню Система) ► Для разработчиков. Находим и включаем опцию, разрешающую удалённую отладку или отладку по USB. Телефон готов.

Теперь берём USB дата кабель и соединяем им телефон с компьютером. На телефоне должно выскочить сообщение "Отладка по USB разрешена". Если вы подключаетесь впервые, то на экране телефона должен появиться Цифровой отпечаток ключа RSA. Ставим галочку "Всегда разрешать отладку с этого компьютера" и нажимаем "Разрешить".

После этого в списке устройств должно появиться ваше устройство.

-7

Нажимаем Play или Shift+F10. Приложение Hello Word запускается на телефоне. Поздравляю, теперь вы можете запускать и отлаживать приложения на реальном устройстве!

Если по проводу вам не удобно, и хочется через WiFi, то нет ничего проще. Для этого и телефон, и компьютер должны быть подключены к общему WiFi. Это важно! Провод до окончания настройки не отключайте! На устройстве заходим в Настройки ► Расширенные настройки (или пункт меню Система) ► Для разработчиков, включаем флаг "Отладка по беспроводному ADB". Далее нужно установить плагин Android WiFi ADB от Pedro Vicente Gomez Sanchez для Android Studio. Нажимаем в Android Studio сочетание Ctrl + Alt + S ► Plugins, вводим в поиске Android WiFi ADB, нажимаем Install, потом кнопку Restart IDE, чтобы проинициализировать плагин.

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

-8

Если подключение через WiFi установлено, то к названию устройства добавляется IP адрес (см. картинку выше). Если этого не произошло, то пробуем перегружать сначала Android Studio. Если не поможет, то включать/выключать WiFi и на устройстве, и на компьютере, пока IP адрес не загорится в названии устройства, как на картинке выше. Если и это не поможет, то гда берём бубен и вспоминаем любимые заклинания. (До этого не должно дойти. В крайнем случаем, можно просто продолжать отлаживаться по кабелю.).

Если успех, то отсоединяем провод и нажимать Shift+F10. Видим Hello World! на экране устройства.

Далее начинаем прикручивать библиотеку OpenCV 4.2

В Android Studio выбираем меню File ► New ► Import module и выбираем путь [OpenCV-android-sdk]\sdk\java ( [OpenCV-android-sdk] - это путь, куда вы распаковали скаченный архив с библиотекой OpenCV ). В Module name пишем для порядка openCVLibrary420.

-9

Нажимаем Next, затем, ничего не меняя, нажимаем Finish.

Через некоторое время должна появиться новая вкладка import-summury.txt. Закрываем её, она нам не нужна.

Выбираем файл build.gradle модуля openCVLibrary420, как показано на рисунке

-10

Меняем

apply plugin: 'com.android.application'

на

apply plugin: 'com.android.library'

Также меняем

defaultConfig {
applicationId "org.opencv"
}

на

defaultConfig {
minSdkVersion 16
targetSdkVersion 28
}

Важно, чтобы версия compileSdkVersion, targetSdkVersion в файлах build.gradle модулей app и openCVLibrary420 совпадали! Финально это можно проверить, нажав Ctrl + Alt + Shift + S или кнопку, которая показана на рисунке стрелкой

-11

В появившемся окне проверяем, чтобы Compile Sdk Version для модулей app и openCVLibrary420 были одинаковы

-12

Далее в разделе Dependencies выбираем модуль app. В Declared Dependencies нажимаем "+", выбираем Add Module, отмечаем галочкой наш модуль openCVLibrary420 и нажимаем OK, затем Apply, и снова OK.

-13

Shift+F10 - всё собирается, Hello World! Отлично, продолжаем.

Пару слов о файле build.gradle, чтобы новичкам было хотябы примерно понятно, что мы сейчас сделали. Для разных языков программирования используют различные вспомогательные системы сборки. Они нужны, чтобы автоматизировать скриптами всяческие рутинные работы. Например, можно автоматизировать скачку и установку нужных библиотек, производить какие-либо манипуляции с проектными файлами, запускать автотест кода, и, в итоге, получать те исполняемые файлы, или артефакты, которые уже можно использовать как готовую программу, заливать на устройство или закидывать для работы на сервер. Для Java/Kotlin сейчас, наверное, чаще других используется система сборки Gradle (как в нашем случае), также широко используется система Maven. До появления Gradle и Maven использовалась система Ant (ныне можно считать её устаревшей или устаревающей).

Так вот, в файле build.gradle указываются различные параметры и настройки сборки. Мы сейчас проследили, чтобы не было конфликтов с точки зрения версии Sdk, которую используют различные модули проекта, а также добавили в зависимость основного модуля библиотечный модуль openCVLibrary420.

К слову сказать, сама библиотека OpenCV написана на C++, и для того, чтобы этот код работал в Java/Kotlin нужна некоторая "обёртка". Ровно такой "обёрткой" и являются те Java классы, которые мы подключили в составе модуля openCVLibrary420. Но нам нужны ещё и сама бинарная библиотека OpenCV. Мы, естественно, воспользуемся уже собранной библиотекой, т.к. самостоятельная сборка из исходных кодов - это отдельный квест.

А мы начинаем писать исходный код, чтобы превратить наше Hello World приложение в приложение по распознаванию предметов с камеры в реальном времени с использованием нейронной сети.

Есть такой обязательный для каждого Android приложения файл AndroidManifest.xml. Там описываются все основные параметры Android приложения. В нём мы сделаем следующие изменения:

Параметр

android:theme="@style/AppTheme"

заменим на

android:theme="@android:style/Theme.NoTitleBar.Fullscreen"

Это нужно, чтобы сделать наше приложение полноэкранным.

Строчку

<activity android:name=".MainActivity">

превратим в

<activity android:name=".MainActivity" android:screenOrientation="landscape">

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

До или после блока "application" добавляем разрешение на использование камеры

<uses-permission android:name="android.permission.CAMERA"/>

А также добавляем список фич, которые понадобятся приложению

<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
<uses-feature android:name="android.hardware.camera.front.autofocus" android:required="false" />

Должно получиться как на картинке

-14

Теперь переходим к редактированию файла activity_main.xml. Этот файл содержит вёрстку нашей единственной экранной формы. То есть это некий текст в формате xml, который позволяет декларативно описать какие элементы и каким образом будут располагаться на экранной форме приложения. Мы тут просто всё заменим на следующий текст

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:opencv="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">

<org.opencv.android.JavaCameraView
android:id="@+id/java_camera_view"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:visibility="gone"
opencv:camera_id="any"
opencv:show_fps="false" />
</FrameLayout>

Ctrl + Alt + L позволяет автоматически отформатировать код, чтобы он выглядел по всем канонам принятым в том или ином языке программирования, скриптах или в разметке. В итоге должно получиться так

-15

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

Теперь переходим к самому интересному - это написания кода на Java. Открываем на редактирование файл MainActivity.java.

Мы видем объявление главной "активности", являющейся в нашем случае точкой входа в приложение, о чём сказано в файле AndroidManifest.xml.

public class MainActivity extends AppCompatActivity

Нам нужно заменить эту строчку на

public class MainActivity extends CameraActivity implements CameraBridgeViewBase.CvCameraViewListener2 {

То есть наша "активность" теперь будет унаследована от класса CameraActivity, а также будет реализовывать интерфейс CameraBridgeViewBase.CvCameraViewListener2.

Мы видим, что CameraActivity и CameraBridgeViewBase подсвечиваются красным. Это происходит, потому что мы их не заимпортили в рамках MainActivity.java. Чтобы исправить, тыкаем в красные слова левой кнопкой мыши и нажимаем Alt + Enter, выбираем Import class. При этом Android Studio милостиво добавит недостающие классы в раздел import.

Теперь вся строчка подчёркнута красным. В данном случае Android Studio хочет нам сказать, что мы обязаны реализовать методы интерфейса CvCameraViewListener2. Снова нажимаем Alt + Enter и выбираем Implement methods.

На данном этапе можно собрать и запустить приложение, всё должно работать, но теперь уже вместо Hello World! мы увидим чёрный экран. Это хорошо.

В теле класса MainActivity объявим свойство для инициализации и управления камерой

private CameraBridgeViewBase mOpenCvCameraView;

свойство для инициализации и управления нейронной сетью

private Net net;

А также объявим массив констант с именами классов, которые будет уметь определять наша нейронная сеть

private static final String[] classNames = {"background",
"aeroplane", "bicycle", "bird", "boat",
"bottle", "bus", "car", "cat", "chair",
"cow", "diningtable", "dog", "horse",
"motorbike", "person", "pottedplant",
"sheep", "sofa", "train", "tvmonitor"}

И константу для объявления Тэга, используемого при логировании

private static final String TAG = MainActivity.class.getSimpleName();

В теле метода onCreate мы описываем код, который выполнится при инициализации приложения. Добавим туда строчку

getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

чтобы не выключался экран, когда приложение активно, а также проинициализируем камеру

mOpenCvCameraView = (CameraBridgeViewBase) findViewById(R.id.java_camera_view); mOpenCvCameraView.setVisibility(SurfaceView.VISIBLE); mOpenCvCameraView.setCvCameraViewListener(this);

Не забываем работать магическим сочетанием Alt + Enter.

После OnCreate добавляем реализацию ряда других событий MainActivity

@Override
public void onResume() {
super.onResume();
if (!OpenCVLoader.
initDebug()) {
Log.
d(TAG, "Internal OpenCV library not found. Using OpenCV Manager for initialization");
OpenCVLoader.
initAsync(OpenCVLoader.OPENCV_VERSION, this, mLoaderCallback);
} else {
Log.
d(TAG, "OpenCV library found inside package. Using it!");
mLoaderCallback.onManagerConnected(LoaderCallbackInterface.
SUCCESS);
}
}

private BaseLoaderCallback mLoaderCallback = new BaseLoaderCallback(this) {
@Override
public void onManagerConnected(int status) {
switch (status) {
case LoaderCallbackInterface.
SUCCESS: {
Log.
i(TAG, "OpenCV loaded successfully");
mOpenCvCameraView.enableView();
}
break;
default: {
super.onManagerConnected(status);
}
break;
}
}
};

Тут мы занимаемся инициализацией/деинициализацией камеры, а также инициализацией библиотеки OpenCV. Притом логика инициализации OpenCV соедующая: если библиотека OpenCV била слинкована статически, то продолжаем работать, но если нет, то пытаемся слинковаться динамически с библиотеками из заранее предустановленного APK пакета на устройстве с названием OpenCV Manager. Мы будем линковаться статически, чтобы не создавать неудобных зависимостей, и чтобы сделать наше приложение максимально независимым.

На текущем этапе мы можем снова запустить приложение, которое при запуске предложит нам установить OpenCV Manager. Если же посмотрим в Logcat, то сможем найти там строчку

D/OpenCV/StaticHelper: Cannot load library "opencv_java4"

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

-16

Переводим проект в Project View, как показано на рисунке, и кликом правой кнопки мыши по директории main создаём новую директорию с названием assets (пригодится чуть позже) и директорию jniLibs.

-17

Кликаем на jniLibs правой кнопкой мыши, выбираем Show in Explorer. Сюда мы сейчас скопируем нужные библиотеки из папки [OpenCV-android-sdk]\sdk\native\libs. Копируем.

Теперь, если мы запустим приложение, то увидим в Logcat ошибку

java.lang.UnsatisfiedLinkError: dlopen failed: library "libc++_shared.so" not found

Это нормально, если мы используем библиотеку OpenCV версии 4.x. Для OpenCV версии 3.x такой ошибки не возникает, т.к., по всей видимости, библиотека libc++_shared.so там уже включена в библиотеку opencv_java3.so. Нам же надо будет сделать дополнительное приседание.

Заходим в SDK Manager

-18

Выбираем вкладку SDK Tools, отмечаем галочкой NDK, жмём 2 раза OK. Устанавливается. Я не придумал ничего другого, кроме как взять недостающие библиотеки там. Если кто знает другой способ, пожалуйста напишите, буду признателен.

Пока устанавливается пакет расскажу пару слов, что из себя представляет NDK. Если совсем по простому, то это набор инструментария для того, чтобы в Android приложения, написанные на Java/Kotlin можно было делать вставки кода, написанного на C/C++. Иногда это полезно, если вам нужно заиспользовать какие-то супер библиотеки, написанные на C++, реализации которых не существует на Java или, уж тем более, на Kotlin.

Если на каком-то этапе Android Studio скажет вам, что ей нужно скачать другую версию NDK, то скачайте.

Когда всё скачалось и установилось, то переходим в каталог [Drive]:\Users\[UserName]\AppData\Local\Android\Sdk\ndk\[NdkVersion]\sources\cxx-stl\llvm-libc++\libs. Там видим 4 директории с ровно такими же названиями, как в [YourSuperProject]\app\src\main\jniLibs. А теперь капируем файл libc++_shared.so из каждой директории NDK в соответствующую директорию нашего проекта.

Если мы теперь запустим наше приложение, то в Logcat должны увидет параметры загруженной библиотеки opencv_java4 и заветную фразу

I/MainActivity: OpenCV loaded successfully

Но экран по прежнему чёрный. Исправим это - допишем код в обработчики событий, связанных с камерой.

В MainActivity добавляем

@Override
protected List<? extends CameraBridgeViewBase> getCameraViewList() {
return Collections.
singletonList(mOpenCvCameraView);
}

А в уже созданный обработчик onCameraFrame вместо return null добавим

return inputFrame.rgba();

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

Для этого сначала проинициализируем нашу сеть в обработчике камеры onCameraViewStarted. Допишем туда следующий код

Log.d(TAG, "onCameraViewStarted");

String proto = getPath("MobileNetSSD_deploy.prototxt", this);
String weights = getPath("MobileNetSSD_deploy.caffemodel", this);
net = Dnn.
readNetFromCaffe(proto, weights);
Log.
i(TAG, "Network loaded successfully");

Метод getPath не существует, нам надо его реализовать самостоятельно. Добавляем

private static String getPath(String file, Context context) {
AssetManager assetManager = context.getAssets();
BufferedInputStream inputStream = null;
try {
// Read data from assets.
inputStream = new BufferedInputStream(assetManager.open(file));
byte[] data = new byte[inputStream.available()];
inputStream.read(data);
inputStream.close();
// Create copy file in storage.
File outFile = new File(context.getFilesDir(), file);
FileOutputStream os = new FileOutputStream(outFile);
os.write(data);
os.close();
// Return a path to file which may be read in common way.
return outFile.getAbsolutePath();
} catch (IOException ex) {
Log.
i(TAG, "Failed to upload a file");
}
return "";
}

Что мы сейчас сделали? Мы научили нашу программу в момент инициализации камеры инициализировать нейронную сеть на базе заранее подготовленной обученной модели в формате caffemodel. Файлы мы будем загружать из каталога assets, который мы создали ранее. В момент сборки приложения в стандартный для Android APK файл, внутри него будет каталог assets с заранее скопированными туда файлами с моделью нейронной сети.

Фйлы MobileNetSSD_deploy.prototxt и MobileNetSSD_deploy.caffemodel можно взять отсюда. Я их, в свою очередь, взял отсюда. И копируем их в каталог assets.

Кстати, если мы заглянем в Dnn.java, то увидем там список поддерживаемых форматов

* * {@code *.caffemodel} (Caffe, http://caffe.berkeleyvision.org/)
* * {@code *.pb} (TensorFlow, https://www.tensorflow.org/)
* * {@code *.t7} | {@code *.net} (Torch, http://torch.ch/)
* * {@code *.weights} (Darknet, https://pjreddie.com/darknet/)
* * {@code *.bin} (DLDT, https://software.intel.com/openvino-toolkit)
* * {@code *.onnx} (ONNX, https://onnx.ai/)
* file with the following extensions:
* * {@code *.prototxt} (Caffe, http://caffe.berkeleyvision.org/)
* * {@code *.pbtxt} (TensorFlow, https://www.tensorflow.org/)
* * {@code *.cfg} (Darknet, https://pjreddie.com/darknet/)
* * {@code *.xml} (DLDT, https://software.intel.com/openvino-toolkit)

Ну, и финальный аккорд: заменяем код в обработчике onCameraFrame на

final int IN_WIDTH = 300;
final int IN_HEIGHT = 300;
final float WH_RATIO = (float) IN_WIDTH / IN_HEIGHT;
final double IN_SCALE_FACTOR = 0.007843;
final double MEAN_VAL = 127.5;
final double THRESHOLD = 0.5;
// Get a new frame
Mat frame = inputFrame.rgba();
Imgproc.
cvtColor(frame, frame, Imgproc.COLOR_RGBA2RGB);
// Forward image through network.
Mat blob = Dnn.
blobFromImage(frame, IN_SCALE_FACTOR,
new Size(IN_WIDTH, IN_HEIGHT),
new Scalar(MEAN_VAL, MEAN_VAL, MEAN_VAL), false, false);
net.setInput(blob);
Mat detections = net.forward();
int cols = frame.cols();
int rows = frame.rows();
detections = detections.reshape(1, (int) detections.total() / 7);
for (int i = 0; i < detections.rows(); ++i) {
double confidence = detections.get(i, 2)[0];
if (confidence > THRESHOLD) {
int classId = (int) detections.get(i, 1)[0];
int left = (int) (detections.get(i, 3)[0] * cols);
int top = (int) (detections.get(i, 4)[0] * rows);
int right = (int) (detections.get(i, 5)[0] * cols);
int bottom = (int) (detections.get(i, 6)[0] * rows);
// Draw rectangle around detected object.
Imgproc.rectangle(frame, new Point(left, top), new Point(right, bottom),
new Scalar(0, 255, 0));
if (classId >= 0 && classId <
classNames.length) {
String label =
classNames[classId] + ": " + confidence;
int[] baseLine = new int[1];
Size labelSize = Imgproc.
getTextSize(label, Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, 1, baseLine);
// Draw background for label.
Imgproc.rectangle(frame, new Point(left, top - labelSize.height),
new Point(left + labelSize.width, top + baseLine[0]),
new Scalar(255, 255, 255), Imgproc.
FILLED);
// Write class name and confidence.
Imgproc.putText(frame, label, new Point(left, top),
Imgproc.
FONT_HERSHEY_SIMPLEX, 0.5, new Scalar(0, 0, 0));
}
}
}
return frame;

Дорабатываем Alt + Enter, импортируя недостающие классы в наш MainActivity.java. Если даёт выбор из какого пакета импортировать, то делаем выбор в пользу пакета org.opencv.

Запускаем, направляем на монитор, и видим как он оборачивается в зелёную рамку с надписью сверху "tvmonitor".

Итак, мы сделали это!

Исходный код итогового приложение можно посмотреть тут (не совсем того, что мы создавали, но очень похожего).

Спасибо за внимание и удачи!