Привет, Хабр! В одной из прошлых наших статей изучался вопрос встраивания ядра распознавания Smart IDReader в iOS приложения. Пришло время обсудить эту же проблему, но для ОС Android. Ввиду большого количества версий системы и широкого парка устройств это будет посложнее, чем для iOS, но всё же вполне решаемая задача. Disclaimer – приведённая ниже информация не является истинной в последней инстанции, если вы знаете как упростить процесс встраивания/работы с камерой или сделать по другому – добро пожаловать в комментарии!


Допустим, мы хотим добавить функционал распознавания документов в своё приложение и для этого у нас есть Smart IDReader SDK, который состоит из следующих частей:


  • bin – сборка библиотеки ядра libjniSmartIdEngine.so для 32х битной архитектуры ARMv7
  • bin-64 – сборка библиотеки ядра libjniSmartIdEngine.so для 64х битной архитектуры ARMv8
  • bin-x86 – сборка библиотеки ядра libjniSmartIdEngine.so для 32х битной архитектуры x86
  • bindings – 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 и нужно с минимальными усилиями встроить его в проект и начать использовать. Для этого потребуются следующие шаги:


  1. Добавление необходимых файлов к проекту
  2. Подготовка данных и инициализация движка
  3. Подключение камеры к приложению
  4. Передача данных и получение результата

Для того чтобы каждый мог поиграться с библиотекой мы подготовили и выложили исходный код 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.

Поделиться с друзьями
-->

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