Не так давно нам необходимо было реализовать систему распознавания лиц на Android с защитой от мошенничества (fraud). В этой статье я поделюсь самыми интересными аспектами реализации с примерами кода и ссылками. Уверен, вы найдёте что-то новое и интересное для себя, поэтому усаживайтесь поудобнее, начинаем.
Системы распознавания лиц сейчас становятся всё более и более востребованными: количество устройств с функцией разблокировки по лицу растёт, так же как и количество инструментов для разработчиков.
Компания Apple в своих продуктах использует FaceID, кроме этого они позаботились о разработчиках и добавили API для доступа к этой функциональности. FaceID считается достаточно безопасным и его можно использовать для разблокировки банковских приложений. Android SDK же до недавнего времени не имел готового решения. Хотя производители устройств добавляли в свои прошивки возможность разблокировать устройство с помощью лица, разработчики не могли использовать функциональность в приложениях, да и безопасность такого способа разблокировки, оставляла желать лучшего.
Недавно, класс FingerprintManager, который использовался для разблокировки приложений по отпечатку пальцев, задепрекейтили на API 28 и выше, и разработчикам предлагается использовать BiometricPrompt. Это класс, содержит логику, связанную с биометрией, в том числе по идентификации лиц. Однако использовать его в каждом смартфоне не получится, потому что согласно информации от Google, устройство должно иметь высокий рейтинг безопасности.
Некоторые устройства не имеют встроенного сканера отпечатка пальцев, от него отказались ввиду высокого уровня защиты от мошенничества при распознавании лица и всё благодаря фронтальному ToF(Time-of-flight) датчику. С его помощью можно построить карту глубины, тем самым увеличить устойчивость системы к взлому.
Требования
Приложение, которое мы реализовали, по своей функциональности является системой контроля доступа, где в качестве способа идентификации личности лицо. С помощью специальных алгоритмов проверяется принадлежность лица реальному человеку. Нового пользователя можно добавить в базу данных непосредственно с устройства, сфотографировав и указав имя. Если необходимо определить наличие человека в базе данных, то поиск осуществляется по фотографии, сделанной в реальном времени с устройства. Алгоритмы определяют сходство с лицами из базы данных, если такое находится выдаётся информация об этом человеке.
Основной целью мы ставили обеспечение максимального уровня безопасности: необходимо было минимизировать возможность обхода системы распознавания лиц, например, с помощью фотографии, которую поднесли к видоискателю. Для этого решили использовать 3D-камеру Intel RealSense (модель D435i), которая имеет встроенный ToF датчик, благодаря ему можно получить все необходимые данные для построения карты глубины.
В качестве рабочего устройства нам необходимо было использовать планшет с большой диагональю экрана, который не имел встроенной батареи и требовал постоянного подключения к электросети.
Ещё одно не менее важное ограничение работа в оффлайн режиме. Из-за этого мы не могли применять облачные сервисы для распознавания лиц. Кроме этого писать алгоритмы распознавания лиц с нуля неразумно, с учётом ограничения времени и трудозатрат. Возникает вопрос: зачем изобретать велосипед, если уже есть готовые решения? Исходя из всего выше сказанного, решили использовать библиотеку Face SDK от 3DiVi.
Получение изображения с камеры Intel RealSense
На первом этапе реализации необходимо было получить два изображения с 3D камеры: одно цветное, второе с картой глубины. Потом они будут использоваться библиотекой Face SDK для дальнейших вычислений.
Чтобы начать работать с камерой Intel RealSense в Android-проекте, необходимо добавить зависимость RealSense SDK for Android OS: она является оберткой над официальной C++ библиотекой. В официальных семплах можно найти как произвести инициализацию и отобразить картинку с камер, на этом останавливаться не будем, там всё достаточно просто. Перейдём сразу к коду получения изображений:
private val pipeline = Pipeline()private val streamingHandler = Handler()private var streamRunnable: Runnable = object : Runnable { override fun run() { try { FrameReleaser().use { fr -> val frames = pipeline.waitForFrames(1000).releaseWith(fr) val orgFrameSet = frames.releaseWith(fr) val processedFrameSet = frames.applyFilter(align).releaseWith(fr) val orgFrame: Frame = orgFrameSet.first(StreamType.COLOR, StreamFormat.RGB8).releaseWith(fr) // Получаем фрейм цветного изображения val videoFrame: VideoFrame = orgFrame.`as`(Extension.VIDEO_FRAME) val processedDepth: Frame = processedFrameSet.first(StreamType.DEPTH, StreamFormat.Z16).releaseWith(fr) // Получаем фрейм глубины изображения val depthFrame: DepthFrame = processedDepth.`as`(Extension.DEPTH_FRAME) upload(orgFrame) // Выводим на экран цветное изображение } streamingHandler.post(this) } catch (e: Exception) { Logger.d("Streaming, error: " + e.message) } }}streamingHandler.post(streamRunnable) // Запуск
С помощью FrameReleaser() мы получаем отдельные кадры с видеопотока, которые имеют тип Frame. К фреймам можно применять различные фильтры через applyFilter().
Для получения кадра нужного формата, фрейм необходимо преобразовать в соответствующий тип. В нашем случае первый с типом VideoFrame, второй DepthFrame.
Если мы хотим отобразить картинку на экране устройства, то для этого существует метод upload(), в параметрах указывается тип фрейма, который нужно отобразить на экране, у нас это кадры с цветной камеры.
Преобразование фреймов в изображение
На следующем этапе необходимо получить из VideoFrame и DepthFrame картинки в нужном нам формате. Эти картинки мы будем использовать, чтобы определять принадлежит ли лицо на изображении реальному человеку и добавлять информацию в базу данных.
Формат изображений:
- Цветное, с расширением .bmp, получаемое из VideoFrame
- С картой глубины, имеющее расширение .tiff и получаемое из DepthFrame
Чтобы осуществить преобразования нам понадобится библиотека компьютерного зрения с открытым исходным кодом OpenCV. Вся работа заключается в формировании объекта Mat и конвертировании его в нужный формат:
fun videoFrameToMat(videoFrame: VideoFrame): Mat { val colorMat = Mat(videoFrame.height, videoFrame.width, CvType.CV_8UC3) val returnBuff = ByteArray(videoFrame.dataSize) videoFrame.getData(returnBuff) colorMat.put(0, 0, returnBuff) val colorMatNew = Mat() Imgproc.cvtColor(colorMat, colorMatNew, Imgproc.COLOR_RGB2BGR) return colorMatNew}
Для сохранения цветного изображения необходимо сформировать матрицу с типом CvType.CV_8UC3, после конвертировать в BRG, чтобы цвета имели нормальный оттенок.
Используя метод Imgcodecs.imwrite, сохранить на устройстве:
fun VideoFrame.saveToFile(path: String): Boolean { val colorMat = videoFrameToMat(this) return Imgcodecs.imwrite(path + COLOR_IMAGE_FORMAT, colorMat)}
Тоже самое необходимо проделать для DepthFrame с тем лишь отличием, что матрица должна быть с типом CvType.CV_16UC1, так как изображение будет строиться из кадра, который содержит данные с датчика глубины:
fun depthFrameToMat(depthFrame: DepthFrame): Mat { val depthMat = Mat(depthFrame.height, depthFrame.width, CvType.CV_16UC1) val size = (depthMat.total() * depthMat.elemSize()).toInt() val returnBuff = ByteArray(size) depthFrame.getData(returnBuff) val shorts = ShortArray(size / 2) ByteBuffer.wrap(returnBuff).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(shorts) depthMat.put(0, 0, shorts) return depthMat}
Сохранение изображения с картой глубины:
fun DepthFrame.saveToFile(path: String): Boolean { val depthMat = depthFrameToMat(this) return Imgcodecs.imwrite(path + DEPTH_IMAGE_FORMAT, depthMat)}
Работа с библиотекой Face SDK
Face SDK имеет большой объём программных компонентов, но большая часть из них нам не нужна. Библиотека так же, как и RealSense SDK написана на C++ и имеет обёртку, чтобы было удобно работать под Android. Face SDK не бесплатна, но если вы разработчик, то вам выдадут тестовую лицензию.
Большинство компонентов библиотеки настраиваются с помощью XML конфигурационных файлов. В зависимости от конфигурации, будет применяться тот или иной алгоритм.
Чтобы начать работать необходимо создать экземпляр класса FacerecService, он используется при инициализации других компонентов, в параметрах передается путь до DLL библиотек, конфигурационных файлов и лицензии.
Далее, используя этот сервис, нужно создать объекты классов FacerecService.Config и Capturer:
private val service: FacerecService = FacerecService.createService( dllPath, confDirPath, onlineLicenseDir )private val confManual: FacerecService.Config = service.Config("manual_capturer.xml")private val capturerManual: Capturer = service.createCapturer(confManual)
Класс Capturer используется для распознавания лиц. Конфигурация manual_capturer.xml означает, что мы будем использовать алгоритмы из библиотеки OpenCV это детектор фронтальных лиц Viola-Jones, для распознавания используются признаки Хаара. Библиотека предоставляет готовое множество XML файлов с конфигурациями, отличающихся по характеристикам качества распознавания и времени работы. Менее быстрые методы имеют лучшие показатели по качеству распознавания. Если нам нужно распознавать лица в профиль, то следует использовать другой конфигурационный XML файл common_lprofile_capturer.xml. Конфигов достаточно много, с ними можно подробнее ознакомиться в документации. В нашем случае необходимо было использовать конфиг common_capturer4_singleface.xml это конфигурация с пониженным порогом качества в результате использования которой, всегда будет возвращаться не более одного лица.
Чтобы найти лицо на изображении применяется метод capturerSingleFace.capture(), в который передаётся массив байтов картинки, которая содержит лицо человека:
fun createRawSample(imagePath: String): RawSample? { val imageColorFile = File(imagePath) val originalColorByteArray = ImageUtil.readImage(imageColorFile) return capturerSingleFace.capture(originalColorByteArray).getOrNull(0)}
Объект RawSample хранит информацию о найденном лице и содержит набор различных методов, например если вызвать getLandmarks(), то можно получить антропометрические точки лица.
Принадлежность лица реальному человеку
Чтобы определить реальный ли человек находится в кадре, а не фотография, приставленная к камере детекции лиц, библиотека Face SDK, предоставляет модуль DepthLivenessEstimator, он возвращает enum с одним из четырех значений:
- NOT_ENOUGH_DATA слишком много отсутствующих значений на карте глубины
- REAL наблюдаемое лицо принадлежит живому человеку
- FAKE наблюдаемое лицо является фотографией
- NOT_COMPUTED не удалось произвести вычисления
Инициализация модуля:
val depthLivenessEstimator: DepthLivenessEstimator = service.createDepthLivenessEstimator( "depth_liveness_estimator_cnn.xml" )
Определение принадлежности лица реальному человеку:
fun getLivenessState( rgbPath: String, depthPath: String ): DepthLivenessEstimator.Liveness { val imageColorFile = File(rgbPath + COLOR_IMAGE_FORMAT) val originalColorByteArray = readImage(imageColorFile) val originalRawSimple = capturerSingleFace.capture(originalColorByteArray).getOrNull(0) val originalRawImage = RawImage( SCREEN_RESOLUTION_WIDTH, SCREEN_RESOLUTION_HEIGHT, RawImage.Format.FORMAT_BGR, originalColorByteArray ) val originalDepthPtr = Natives().readDepthMap(depthPath + DEPTH_IMAGE_FORMAT)// параметры камеры val hFov = 69.4f val vFov = 42.5f val depthMapRaw = DepthMapRaw() with(depthMapRaw) { depth_map_rows = originalRawImage.height depth_map_cols = originalRawImage.width depth_map_2_image_offset_x = 0f depth_map_2_image_offset_y = 0f depth_map_2_image_scale_x = 1f depth_map_2_image_scale_y = 1f horizontal_fov = hFov vertical_fov = vFov depth_unit_in_millimeters = 1f depth_data_ptr = originalDepthPtr depth_data_stride_in_bytes = (2 * originalRawImage.width) } return depthLivenessEstimator.estimateLiveness(originalRawSimple, depthMapRaw)}
Метод getLivenessState() в качестве параметров получает ссылки на изображения: цветное и с картой глубины. Из цветного мы формируем объект RawImage, этот класс предоставляет данные изображения в сыром виде и опциональной информации для обрезки. Из карты глубины формируется DepthMapRaw карта глубины, отрегистрированная в соответствии с исходным цветным изображением. Это необходимо сделать, чтобы использовать метод estimateLiveness(originalRawSimple, depthMapRaw), который вернёт нам enum с информацией реальное ли лицо было в кадре.
Стоит обратить внимание на формирование объекта DepthMapRaw. Одна из переменных имеет наименование depth_data_ptr это указатель на данные глубины, но как известно в Java нет указателей. Для получения указателя надо воспользоваться JNI функцией, которая в качестве аргумента принимает ссылку на изображение с картой глубины:
extern "C" JNIEXPORT jlong JNICALL Java_ru_face_detect_Natives_readDepthMap(JNIEnv *env, jobject obj, jstring jfilename){ const char * buf = env->GetStringUTFChars(jfilename, NULL); std::string filename = buf; env->ReleaseStringUTFChars(jfilename, buf); cv::Mat depth_map = cv::imread(filename, -1); unsigned char * data = new unsigned char[depth_map.rows * depth_map.cols * depth_map.elemSize()]; memcpy(data, depth_map.data, depth_map.rows * depth_map.cols * depth_map.elemSize()); return (jlong) data;}
Для вызова кода написанного на C в Kotlin, необходимо создать класс такого типа:
class Natives { init { System.loadLibrary("native-lib") } external fun readDepthMap(fileName: String): Long}
В System.loadLibrary() передаётся наименование файла .cpp, где содержится метод readDepthMap(), в нашем случае это native-lib.cpp. Также необходимо поставить модификатор external, который означает, что метод реализован не в Kotlin.
Идентификация лица
Не менее важная функция определение личности найденного лица в кадре. Face SDK позволяет реализовать это с помощью модуля Recognizer. Инициализация:
val recognizer: Recognizer = service.createRecognizer( "method8v7_recognizer.xml", true, true, true)
Мы используем конфигурационный файл method8v7_recognizer.xml, который имеет самую высокую скорость распознавания, но при этом качество распознавания ниже, чем у методов 6v7 и 7v7.
Перед тем, как идентифицировать лицо, необходимо создать список лиц, используя который мы будем находить соответствие по образцу фотографии. Для реализации, нужно создать Vector из объектов Template:
var templates = Vector<Template>()val rawSample = createRawSample(imageUrl)val template = recognizer.processing(rawSample)templates.add(template)
Для создания Template используется метод recognizer.processing(), в качестве параметра передаётся RawSample. После того, как список с шаблонами лиц сформирован, его необходимо добавить в Recognizer и сохранить полученный TemplatesIndex, который нужен для быстрого поиска в больших базах:
val templatesIndex = recognizer.createIndex(templates, SEARCH_THREAD_COUNT)
На этом этапе, нами был сформирован объект Recognizer, который содержит всю необходимую информацию, чтобы произвести идентификацию:
fun detectFaceSearchResult(rgbPath: String): Recognizer.SearchResult { val rawSample = createRawSample(rgbPath + COLOR_IMAGE_FORMAT) val template = recognizer.processing(rawSample) val searchResult = recognizer.search( template, templateIndex, searchResultCount, Recognizer.SearchAccelerationType.SEARCH_ACCELERATION_1 ).firstElement() return searchResult}
Функция recognizer.search() вернёт нам результат, где мы можем получить индекс найденного элемента, сопоставить его со списком лиц из базы данных и идентифицировать персону. Кроме этого, мы можем узнать величину сходства, действительное число от 0 до 1. Данная информация предоставлена в классе Recognizer.MatchResult, переменная scope:
val detectResult = detectFaceSearchResult(rgbPath)// Величина сходства шаблонов - действительное число от 0 до 1.val scoreResult = detectResult.matchResult.score
Заключение
Нет сомнения, что в будущем подобные системы будут использоваться повсеместно: подходя к подъезду, дверь будет автоматически открываться, а кофемашина в офисе будет выбирать вашу любимую степень помола.
В Android SDK, постепенно добавляется API, который позволяет разработчику работать с системой идентификации лиц, однако сейчас всё находится на начальном этапе развития. А если говорить о системе контроля доступа с использованием планшета на Android, библиотеки Face SDK и 3D камеры Intel RealSense, хочется отметить большую гибкость и расширяемость. Нет привязки к устройству, камеру можно подключить к любому современному смартфону. Можно расширить линейку поддерживаемых 3D камер, а также подключить несколько штук к одному устройству. Есть возможность адаптировать написанное приложение под Android Things, и использовать его в своем умном доме. Если посмотреть на возможности библиотеки Face SDK, то с её помощью мы можем добавить идентификацию лиц в непрерывном видеопотоке, определять пол, возраст и эмоции. Эти возможности дают простор для множества экспериментов. А мы на своём опыте можем сказать: не бойтесь экспериментов и бросайте вызов себе!