Привет, Хабр! В одной из прошлых наших статей изучался вопрос встраивания ядра распознавания Smart IDReader в iOS приложения. Пришло время обсудить эту же проблему, но для ОС Android. Ввиду большого количества версий системы и широкого парка устройств это будет посложнее, чем для iOS, но всё же вполне решаемая задача. Disclaimer – приведённая ниже информация не является истинной в последней инстанции, если вы знаете как упростить процесс встраивания/работы с камерой или сделать по другому – добро пожаловать в комментарии!
Допустим, мы хотим добавить функционал распознавания документов в своё приложение и для этого у нас есть Smart IDReader SDK, который состоит из следующих частей:
bin– сборка библиотеки ядраlibjniSmartIdEngine.soдля 32х битной архитектуры ARMv7bin-64– сборка библиотеки ядраlibjniSmartIdEngine.soдля 64х битной архитектуры ARMv8bin-x86– сборка библиотеки ядраlibjniSmartIdEngine.soдля 32х битной архитектуры x86bindings– JNI обёрткаjniSmartIdEngineJar.jarнад библиотекойlibjniSmartIdEngine.sodata– файлы конфигурации ядраdoc– документация к SDK
Некоторые комментарии по содержанию SDK.
Наличие трех сборок библиотеки под разные платформы – плата за большое разнообразие устройств на ОС Android (сборку для MIPS не делаем по причине отсутствия устройств данной архитектуры). Сборки для ARMv7 и ARMv8 являются основными, версия для x86 обычно используется нашими клиентами для конкретных устройств на базе мобильных процессоров Intel.
Обёртка JNI (Java Native Interface) jniSmartIdEngineJar.jar требуется для вызова C++ кода из Java приложений. Сборка обёртки у нас автоматизирована с помощью инструментария SWIG (simplified wrapper and interface generator).
Итак, как говорят французы, revenons à nos moutons! У нас есть SDK и нужно с минимальными усилиями встроить его в проект и начать использовать. Для этого потребуются следующие шаги:
- Добавление необходимых файлов к проекту
- Подготовка данных и инициализация движка
- Подключение камеры к приложению
- Передача данных и получение результата
Для того чтобы каждый мог поиграться с библиотекой мы подготовили и выложили исходный код Smart IDReader Demo for Android на Github. Проект сделан для Android Studio и демонстрирует пример работы с камерой и ядром на основе простого приложения.
Добавление необходимых файлов к проекту
Рассмотрим данный процесс на примере проекта приложения под Android Studio, для пользователей других IDE процесс не особо отличается. По умолчанию в каждом проекте Android Studio создает папку libs, из которой сборщик Gradle забирает и добавляется к проекту JAR файлы. Именно туда скопируем JNI обёртку jniSmartIdEngineJar.jar. Для добавления библиотек ядра существует несколько способов, проще всего это сделать с помощью JAR архива. Создаем в папке libs архив с именем native-libs.jar (это важно!) и внутри архива подпапки lib/armeabi-v7a и lib/arm64-v8a и помещаем туда соответствующие версии библиотек (для x86 библиотеки подпапка будет lib/x86).
В этом случае ОС Android после установки приложения автоматически развернёт нужную версию библиотеки для данного устройства. Сопутствующие файлы конфигурации движка добавляем в папку assets проекта, если данная папка отсутствует, то её можно создать вручную или с помощью команды File|New|Folder|Assets Folder. Как видим, добавить файлы к проекту очень просто и занимает совсем немного времени.
Подготовка данных и инициализация движка
Итак, мы добавили необходимые файлы к приложению и даже успешно его собрали. Руки так и тянутся попробовать новый функционал в деле, но для этого нужно ещё немного поработать :-) А именно сделать следующее:
- Развернуть файлы конфигурации ядра из assets
- Загрузить библиотеку и инициализировать движок
Чтобы библиотека могла получить доступ к файлам конфигурации необходимо перенести их из assets в рабочую папку приложения. Достаточно сделать это один раз при запуске и затем обновлять только при выходе новой версии. Проще всего такую проверку сделать, основываясь на версии кода приложения, и если она изменилась то обновить файлы.
// текущая версия кода приложения PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0); int version_code = packageInfo.versionCode; SharedPreferences sPref = PreferenceManager.getDefaultSharedPreferences(this); // версия кода из настроек int version_current = sPref.getInt("version_code", -1); // если версии отличаются нужно обновить данные need_copy_assets = version_code != version_current; // обновляем версию кода в настройках SharedPreferences.Editor ed = sPref.edit(); ed.putInt("version_code", version_code); ed.commit(); … if (need_copy_assets == true) copyAssets();
Сама процедура копирования не сложна и заключается в получении данных из файлов, находящихся в assets приложения, и записи эти данных в файлы рабочего каталога. Пример кода функции, осуществляющей такое копирование, можно посмотреть в примере на Github.
Осталось только загрузить библиотеку и инициализировать ядро. Вся процедура занимает определённое время, поэтому разумно выполнять её в отдельном потоке, чтобы не затормаживать основной GUI поток. Пример инициализации на основе AsyncTask
private static RecognitionEngine engine; private static SessionSettings sessionSettings; private static RecognitionSession session; ... сlass InitCore extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... unused) { if (need_copy_assets) copyAssets(); // конфигурирование ядра configureEngine(); return null; } @Override protected void onPostExecute(Void aVoid) { super.onPostExecute(aVoid); if(is_configured) { // устанавливаем ограничения на распознаваемые документы (например, rus.passport.* означает подмножество документов российского паспорта) sessionSettings.AddEnabledDocumentTypes(document_mask); // получаем полные наименования распознаваемых документов StringVector document_types = sessionSettings.GetEnabledDocumentTypes(); ... } } } … private void configureEngine() { try { // загрузка библиотеки ядра System.loadLibrary("jniSmartIdEngine"); // путь к файлу настроек ядра String bundle_path = getFilesDir().getAbsolutePath() + File.separator + bundle_name; // инициализация ядра engine = new RecognitionEngine(bundle_path); // инициализация настроек сессии sessionSettings = engine.CreateSessionSettings(); is_configured = true; } catch (RuntimeException e) { ... } catch(UnsatisfiedLinkError e) { ... } }
Подключение камеры к приложению
Если ваше приложение уже использует камеру, то можете спокойно пропустить этот раздел и перейти к следующему. Для оставшихся рассмотрим вопрос использования камеры для работы с видео потоком для распознавания документов посредством Smart IDReader. Сразу оговоримся, что мы используем класс Camera, а не Camera2, хотя он и объявлен как deprecated начиная с версии API 21 (Android 5.0). Это осознанно сделано по следующим причинам:
- Класс Camera значительно проще в использовании и содержит необходимый функционал
- Поддержка старых устройств на Android 2.3.x и 4.x.x до сих пор актуальна
- Класс Camera до сих пор отлично поддерживается, тогда как в начале запуска Android 5.0 у многих производителей были проблемы с реализацией Camera2
Чтобы добавить поддержку камеры в приложение нужно прописать в манифест следующие строки:
<uses-permission android:name="android.permission.CAMERA" /> <uses-feature android:name="android.hardware.camera" />
Хорошим тоном является запрос разрешения на использование камеры, реализованные в Android 6.x и выше. К тому же пользователи этих систем всегда могут отобрать разрешения у приложения в настройках, так что проверку все равно проводить нужно.
// если необходимо - запрашиваем разрешение if( needPermission(Manifest.permission.CAMERA) == true ) requestPermission(Manifest.permission.CAMERA, REQUEST_CAMERA); … public boolean needPermission(String permission) { // проверка разрешения int result = ContextCompat.checkSelfPermission(this, permission); return result != PackageManager.PERMISSION_GRANTED; } public void requestPermission(String permission, int request_code) { // запрос на разрешение работы с камерой ActivityCompat.requestPermissions(this, new String[]{permission}, request_code); } @Override public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) { switch (requestCode) { case REQUEST_CAMERA: { // запрос на разрешение работы с камерой boolean is_granted = false; for(int grantResult : grantResults) { if(grantResult == PackageManager.PERMISSION_GRANTED) // разрешение получено is_granted = true; } if (is_granted == true) { camera = Camera.open(); // открываем камеру .... } else toast("Enable CAMERA permission in Settings"); } default: super.onRequestPermissionsResult(requestCode, permissions, grantResults); } }
Важной частью работы с камерой является установка её параметров, а именно режима фокусировки и разрешения предпросмотра. Из-за большого разнообразия устройств и характеристик их камер этому вопросу следует уделить особое внимание. Если камера не поддерживает возможности фокусировки, то приходится работать с фиксированным фокусом или направленным на бесконечность. В таком случае особо ничего сделать нельзя, получаем изображения с камеры as is. А если нам повезло и фокусировка доступна, то проверяем, поддерживаются ли режимы FOCUS_MODE_CONTINUOUS_PICTURE или FOCUS_MODE_CONTINUOUS_VIDEO, что означает постоянный процесс фокусировки на объектах съемки в процессе работы. Если эти режимы поддерживаются, то выставляем их в параметрах. Если же нет, то можно сделать следующий финт – запустить таймер и самим вызывать функцию фокусировки у камеры с заданной периодичностью.
Camera.Parameters params = camera.getParameters(); // список поддерживаемых режимов фокусировки List<String> focus_modes = params.getSupportedFocusModes(); String focus_mode = Camera.Parameters.FOCUS_MODE_AUTO; boolean isAutoFocus = focus_modes.contains(focus_mode); if (isAutoFocus) { if (focus_modes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) focus_mode = Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE; else if (focus_modes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) focus_mode = Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO; } else { // если нет автофокуса то берём первый возможный режим фокусировки focus_mode = focus_modes.get(0); } // установка режима фокусировки params.setFocusMode(focus_mode); // запуск автофокуса по таймеру если нет постоянного режима фокусировки if (focus_mode == Camera.Parameters.FOCUS_MODE_AUTO) { timer = new Timer(); timer.schedule(new Focus(), timer_delay, timer_period); } … // таймер периодической фокусировки private class Focus extends TimerTask { public void run() { focusing(); } } public void focusing() { try{ Camera.Parameters cparams = camera.getParameters(); // если поддерживается хотя бы одна зона для фокусировки if( cparams.getMaxNumFocusAreas() > 0) { camera.cancelAutoFocus(); cparams.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO); camera.setParameters(cparams); } }catch(RuntimeException e) { ... } }
Установка разрешения предпросмотра достаточно проста, основные требования чтобы соотношения сторон preview камеры соответствовали сторонам области отображения для отсутствия искажений при просмотре, и желательно чтобы разрешение было как можно выше, так как от него зависит качество распознавания документа. В нашем примере приложение отображает preview на весь экран, поэтому выбираем максимальное разрешение, соответствующее соотношениям сторон экрана.
DisplayMetrics metrics = new DisplayMetrics(); getWindowManager().getDefaultDisplay().getMetrics(metrics); // соотношение сторон экрана float best_ratio = (float)metrics.heightPixels / (float)metrics.widthPixels; List<Camera.Size> sizes = params.getSupportedPreviewSizes(); Camera.Size preview_size = sizes.get(0); // допустимое отклонение от оптимального соотношения при выборе final float tolerance = 0.1f; float preview_ratio_diff = Math.abs( (float) preview_size.width / (float) preview_size.height - best_ratio); // выбираем оптимальное разрешение preview камеры по соотношению сторон экрана for (int i = 1; i < sizes.size() ; i++) { Camera.Size tmp_size = sizes.get(i); float tmp_ratio_diff = Math.abs( (float) tmp_size.width / (float) tmp_size.height - best_ratio); if( Math.abs(tmp_ratio_diff - preview_ratio_diff) < tolerance && tmp_size.width > preview_size.width || tmp_ratio_diff < preview_ratio_diff) { preview_size = tmp_size; preview_ratio_diff = tmp_ratio_diff; } } // установка размера preview в настройках камеры params.setPreviewSize(preview_size.width, preview_size.height);
Осталось совсем немного – установить ориентацию камеры и отображение preview на поверхность Activity. По умолчанию углу 0 градусов соответствует альбомная ориентация устройства, при поворотах экрана её нужно соответственно менять. Тут можно еще вспомнить добрым словом Nexus 5X от Google, матрица которого установлена в устройстве вверх ногами и для которого нужна отдельная проверка на ориентацию.
private boolean is_nexus_5x = Build.MODEL.contains("Nexus 5X"); SurfaceView surface = (SurfaceView) findViewById(R.id.preview); ... // портретная ориентация camera.setDisplayOrientation(!is_nexus_5x ? 90: 270); // отображение preview на поверхность приложения camera.setPreviewDisplay(surface.getHolder()); // начало процесса preview camera.startPreview();
Передача данных и получение результата
Итак, камера подключена и работает, осталось самое интересное – задействовать ядро и получить результат. Запускаем процесс распознавания, начав новую сессию и установив callback для получения кадров с камеры в режиме preview.
void start_session() { if (is_configured == true && camera_ready == true) { // установка параметров сессии, например тайм-аут sessionSettings.SetOption("common.sessionTimeout", "5.0"); // создании сессии распознавания session = engine.SpawnSession(sessionSettings); try { session_working = true; // семафоры готовности кадра к обработке и ожидания кадра frame_waiting = new Semaphore(1, true); frame_ready = new Semaphore(0, true); // запуск потока распознавания в отдельном AsyncTask new EngineTask().execute(); } catch (RuntimeException e) { ... } // установка callback для получения изображений с камеры camera.setPreviewCallback(this); } }
Функция onPreviewFrame() получает текущее изображение с камеры в виде массива байт формата YUV NV21. Так как она может вызываться только в основном потоке, чтобы его не замедлять вызовы ядра для обработки изображения помещаются в отдельный поток с помощью AsyncTask, синхронизация процесса происходит с помощью семафоров. После получения изображения с камеры даём сигнал рабочему потоку начать его обработку, по окончании — сигнал на получение нового изображения.
// текущее изображение private static volatile byte[] data; ... @Override public void onPreviewFrame(byte[] data_, Camera camera) { if(frame_waiting.tryAcquire() && session_working) { data = data_; // семафор готовности изображения к обработке frame_ready.release(); } } … class EngineTask extends AsyncTask<Void, RecognitionResult, Void> { @Override protected Void doInBackground(Void... unused) { while (true) { try { frame_ready.acquire(); // ждем новый кадр if(session_working == false) // остановка если сессия завершена break; Camera.Size size = camera.getParameters().getPreviewSize(); // передаём кадр в ядро и получаем результат RecognitionResult result = session.ProcessYUVSnapshot(data, size.width, size.height, !is_nexus_5x ? ImageOrientation.Portrait : ImageOrientation.InvertedPortrait); ... // семафор ожидания нового кадра frame_waiting.release(); }catch(RuntimeException e) { ... } catch(InterruptedException e) { ... } } return null; }
После обработки каждого изображения ядро возвращает текущий результат распознавания. Он включает в себя найденные зоны документа, текстовые поля со значениями и флагами уверенности, а также графические поля, такие как фотографии или подписи. Если данные распознаны корректно или произошел тайм-аут, то устанавливается флаг IsTerminal, сигнализирующий о завершении процесса. Для промежуточных результатов можно производить отрисовку найденных зон и полей, показывать текущий прогресс по качеству распознавания и многое другое, все зависит от вашей фантазии.
void show_result(RecognitionResult result) { // получаем распознанные поля с документа StringVector texts = result.GetStringFieldNames(); // получаем изображения с документа, такие как фотография, подпись и так далее StringVector images = result.GetImageFieldNames(); for (int i = 0; i < texts.size(); i++) // текстовые поля документа { StringField field = result.GetStringField(texts.get(i)); String value = field.GetUtf8Value(); // данные поля boolean is_accepted = field.IsAccepted(); .. статус поля ... } for (int i = 0; i < images.size(); i++) // графические поля документа { ImageField field = result.GetImageField(images.get(i)); Bitmap image = getBitmap(field.GetValue()); // получаем Bitmap ... } ... }
После этого нам остается только остановить процесс получения изображений с камеры и прекратить процесс распознавания.
void stop_session() { session_working = false; data = null; frame_waiting.release(); frame_ready.release(); camera.setPreviewCallback(null); // останавливаем процесс получения изображений с камеры ... }
Заключение
Как можно убедиться на нашем примере, процесс подключения Smart IDReader SDK к Android приложениям и работа с камерой не являются чем-то сложным, достаточно всего лишь следовать некоторым правилам. Целый ряд наших заказчиков успешно применяют наши технологии в своих мобильных приложениях, причем сам процесс добавления нового функционала занимает весьма небольшой время. Надеемся, с помощью данной статьи и вы смогли убедиться в этом!
P.S. Чтобы посмотреть, как Smart IDReader выглядит в нашем исполнении после встраивания, вы можете скачать бесплатные полные версии приложений из App Store и Google Play.