Привет, Хабр! В одной из прошлых наших статей изучался вопрос встраивания ядра распознавания 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.so
data
– файлы конфигурации ядраdoc
– документация к SDK
Некоторые комментарии по содержанию SDK.
Наличие трех сборок библиотеки под разные платформы – плата за большое разнообразие устройств на ОС Android (сборку для MIPS не делаем по причине отсутствия устройств данной архитектуры). Сборки для ARMv7 и ARMv8 являются основными, версия для x86 обычно используется нашими клиентами для конкретных устройств на базе мобильных процессоров Intel.
Обёртка JNI (Java Native Interface) jniSmartIdEngineJar.jar
требуется для вызова C++ кода из Java приложений. Сборка обёртки у нас автоматизирована с помощью инструментария SWIG (simplified wrapper and interface generator).
Итак, как говорят французы, revenons a 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 в рабочую папку приложения. Достаточно сделать это один раз при запуске и затем обновлять только при выходе новой версии. Проще всего такую проверку сделать, основываясь на версии кода приложения, и если она изменилась то обновить файлы.
// текущая версия кода приложения
int version_code = BuildConfig.VERSION_CODE;
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.