Разбираемся, как реализовать нативное приложение с возможностью распознавания ДУЛ прямо на устройстве
158 открытий2К показов
Привет, Tproger!
Мы в Smart Engines занимаемся разработкой софта для распознавания самых разных документов – начиная от паспорта РФ, свидетельства о рождении и заканчивая первичкой вроде УПД или ТОРГ-12, а также банковских карт, номеров телефона и баркодов.
Наши библиотеки написаны полностью с нуля (на плюсах), мы уделяем огромное внимание скорости алгоритмов, нейросетям (новым архитектурам, размеру и правильному применению) и оптимизации, за счет чего наш софт портируется на любые архитектуры и может быть использован на любой платформе.
Объяснять (и хвастаться) проще всего на примере – покажем, как развернуть распознавание паспорта РФ прямо на устройстве на Android.
Зачем это всё?
Кстати, распознавание паспорта РФ на мобильном телефоне – ключевой элемент процесса удостоверения личности в цифровых каналах. Функциональность дает возможность удаленно открывать счета в банковских приложениях, регистрироваться в шеринговых сервисах, покупать билеты на самолеты и поезда и многое другое.
Кроме того, распознавание на мобильнике открывает возможность к ещё одной функциональности — распознаванию в видеопотоке. При таком сценарии пользователь наводит камеру на документ, а система анализирует входящие кадры до тех пор, пока не будет обеспечено достаточное для распознавания документа качества. Таким образом клиенту не нужно несколько раз переснимать документ, если вдруг кадр не удался — получился размазанным, засвеченным и так далее.
Немного вводных
SDK Smart ID Engine – это библиотека, собранная под нативную платформу, конфигурационный бандл и обёртка для вызова функций библиотеки с помощью других языков программирования (в этой статье нам понадобится java).
Сперва коротко об интерфейсе библиотеки, без этого мы не сможем написать адекватное работающее приложение. Система распознавания включает:
Движок распознавания. Он создаётся с указанием конфигурационного бандла и представляет собой что-то вроде “склада инструментов” для распознавания различных документов. Движок создаётся в самом начале, существует на всём протяжении жизни (приложения или хотя бы сценария, в котором используется распознавание). От него создаются следующие элементы интерфейса:
Сессия распознавания. Это сам процесс распознавания конкретного физического объекта – паспорта, ВУ, СТС, загранпаспорта и других. Всего по состоянию на сегодняшний день мы поддерживаем более 3 тысяч типов документов. В сессию можно передать всего одно изображение, а можно - серию изображений последовательно (видеопоток), при этом после каждого нового распознавания результат будет уточняться, а так же на основе уверенности в результате будет приниматься решение, нужно ли распознавать дальше или лучше уже не будет.
Важная оговорка: Вообще сессии бывают разных типов - помимо распознавания документов также существуют сессия сравнения лиц (сравнить два изображения на степень “похожести” или провести проверку определения живости лица. Кстати, это происходит без выявления биометрических дескрипторов). А также сессия файловой форензики (поиск признаков вмешательства в исходное изображение) и другие.
Настройки сессии. Они хранят список доступных для распознавания типов документов (сам по себе этот список определяется конфигурационным бандлом), дополнительную информацию о каждом документе, а также позволяют настроить процесс распознавания: задать список ожидаемых типов документов для сессии распознавания (кстати, если поставить *, то система сама определит тип документа из всех преднастроенных шаблонов), включить\выключить проверки признаков подлинности, задать таймаут для сессии распознавания и так далее. Настройки создаются перед созданием самой сессии и не могут редактироваться в процессе распознавания.
Результат распознавания. Это объект, в котором хранится множество различной информации: координаты шаблонов документов (допустим, вторая и третья страницы паспорта ищутся отдельно) и всех полей на этих шаблонах, текстовые поля (вплоть до разбиения на варианты каждого символа с их весами), поля с изображениями, поля с проверками признаков подлинности документа. Как этим всем богатством пользоваться, расскажем чуть ниже.
Беглого взгляда на этот интерфейс достаточно, чтобы понять, как его правильно встроить в приложение. При старте приложения (или сценария) создаем экземпляр движка, перед непосредственно распознаванием создаем настройки сессии и заполняем их, после чего при переходе на экран с превью камеры создаем саму сессию распознавания и “кормим” её кадрами до тех пор, пока сессия не затерминалится (= не решит, что “ей хватит”). После чего можно переходить к разбору результата распознавания.
Как это встраивается и работает
А теперь немного кода - покажем, как делается распознавание документа с помощью камеры устройства на Android. Полный код можно найти, перейдя по ссылке на GitHub, а ниже – наиболее интересные моменты.
Начнём с создания движка:
Объявим класс:
interface Engine {
val isVideoModeAllowed:Boolean
fun createEngine (bundle: ByteArray?)
fun createSession(target: SessionTarget, sessionType: SessionType): Session
}
И приведём его имплементацию:
class IdEngineWrapper(
private val signature : String
): Engine {
lateinit var idEngine: IdEngine
private set
override val isVideoModeAllowed = true
override fun createEngine(bundle: ByteArray?){
// Create SDK engine
idEngine = if(bundle!=null) IdEngine.Create(bundle, true)
}
override fun createSession(target: SessionTarget, sessionType: SessionType): Session {
// Create new session settings object
val sessionSettings: IdSessionSettings = idEngine.CreateSessionSettings()
// Fill by the target
target.fillSessionSettings(sessionSettings)
return IdEngineSession(idEngine, sessionSettings, signature)
}
}
Теперь нам нужно создать и подготовить настройки сессии, чтобы библиотека знала, какие именно инструменты использовать при распознавании:
interface SessionTarget{
fun fillSessionSettings(sessionSettings:Any)
}
С имплементацией:
data class IdTarget(
val mode : String,
val mask : String
) : AppTarget {
override val name: String
get() = "$mode : $mask"
override val description: String?
get() = if(mode=="anydoc") "Any document" else null
override val cropImage: Boolean
get() = false // do not crop images for id-engine
override fun fillSessionSettings(sessionSettings:Any){
with(sessionSettings as IdSessionSettings) {
// Set current mode
SetCurrentMode(mode)
// Set document mask
AddEnabledDocumentTypes(mask)
}
}
}
Дальше, наконец, само распознавание. Поскольку для распознавания нужно откуда-то брать кадры (мы всё же не волшебники), логично завести контроллер, который будет хранить сессию, иметь доступ к камере и кормить сессию кадрами, пока та не попросит остановиться. Реализуем свой ImageProcessor, который будет кормить кадры из превью сессии распознавания:
/**
* IMAGE PROCESSOR
* Implements the document recognition process from a sequence of images (the main business logic)
*/
class ImageProcessor() {
// State
private val _state = MutableStateFlow<ImageProcessingState>( ImageProcessingState.READY )
private fun setState(newState:ImageProcessingState) {
Log.w(TAG," >>> state: $newState")
_state.value = newState
}
val state = _state.asStateFlow()
//----------------------------------------------------------------------------------------------
// EVENTS
fun startProcessing(engine : Engine, target: SessionTarget, sessionType: SessionType, photo: Bitmap?){
processingJob = scope.launch {
process(engine, target, sessionType, photo)
}
}
fun addFrame(frame:Frame){
scope.launch {
try {
frameChannel!!.send(frame)
} catch (e: Exception) {
frame.close()
}
}
}
fun stopProcessing(){
processingJob?.cancel()
}
fun finish(){
setState( ImageProcessingState.READY )
}
//----------------------------------------------------------------------------------------------
// IMPLEMENTATION
private val scope : CoroutineScope = CoroutineScope(Dispatchers.IO)
private var processingJob : Job? = null
private var frameChannel : Channel<Frame>?=null // link to the processing coroutine
private suspend fun process(engine : Engine, target: SessionTarget, sessionType: SessionType, photo: Bitmap?){
Log.w(TAG,"processing thread started: ${Thread.currentThread().id} ${Thread.currentThread().name}")
var session:Session?=null
try {
// SET PROCESSING STATE
setState(
if(photo!=null) ImageProcessingState.PHOTO_PROCESSING(
target = target,
visualization = null,
photo = photo
)
else ImageProcessingState.VIDEO_PROCESSING(
target = target,
visualization = null,
)
)
// CREATE SESSION
session = engine.createSession(
target = target,
sessionType
)
// Session visualization
var lastFrameSize = android.util.Size(1,1)
val visualization = Visualization(
quadsPrimary = MutableStateFlow<List<QuadFrame>>(emptyList()).apply {
// Subscribe to the session data
scope.launch {
session.quadsPrimary?.collect{
emit(value + QuadFrame(
quads = it,
imageSize = lastFrameSize
))
}
}
},
quadsSecondary = MutableStateFlow<List<QuadFrame>>(emptyList()).apply {
// Subscribe to the session data
scope.launch {
session.quadsSecondary?.collect{
emit(value + QuadFrame(
quads = it,
imageSize = lastFrameSize
))
}
}
},
instruction = session.instruction
)
// PREPARE CHANNEL Image queue (for the "thread")
frameChannel = Channel<Frame>(capacity = photo?.let { 1 }?: 0).apply {
// Fill channel immediately for photo mode
photo?.let { send( Frame(bitmap = photo, imageProxy = null)) }
}
val framesLimit = photo?.let { 1 }?: Int.MAX_VALUE
var framesProcessed = 0
// SET PROCESSING STATE
setState(
if(photo!=null) ImageProcessingState.PHOTO_PROCESSING(target, visualization, photo)
else ImageProcessingState.VIDEO_PROCESSING(target, visualization)
)
// THE MAIN LOOP
while (!session.isTerminal && !session.isSelfieCheckRequired && framesProcessed<framesLimit){
// Receive the next frame
val frame = frameChannel!!.receive() // BLOCKING CODE!!!
framesProcessed++
lastFrameSize = with(frame.bitmap){android.util.Size(width,height)}
val image = frame.bitmap
// DO PROCESS
val timeStart = System.currentTimeMillis()
session.processImage(image)
val timeFinish = System.currentTimeMillis()
// Free resources
frame.close()
}
// SET FINISHED STATE
setState(
if(session.isSelfieCheckRequired) ImageProcessingState.SELFIE_CHECKING(session)
else ImageProcessingState.FINISHED(session)
)
} catch (e: kotlinx.coroutines.CancellationException) {
setState(
if(session!=null) ImageProcessingState.FINISHED(session)
else ImageProcessingState.READY// session is not created yet
)
}catch (e:Exception){
setState(ImageProcessingState.ERROR(e))
}
processingJob = null
frameChannel = null
}
}
Когда в процессе распознавания достигается терминальность, можно переходить к разбору результата распознавания. Результат распознавания хранит в себе множество информации - помимо собственно текстовых полей и фото владельца, там лежит геометрия (координаты документа на изображении), данные проверок признаков подлинности и различные атрибуты.
/**
* PARSE IdResult => IdResultData
*/
fun IdResult.parse(): IdResultData?{
// DOC TYPE
val docType = GetDocumentType()
if(docType.isNullOrEmpty()) return null
return IdResultData(
docType = docType,
textFields = parseTextFields(),
images = parseImages(),
forensicCheckFields = parseForensicCheckFields(),
forensicTextFields = parseForensicTextFields(),
forensicImages = parseForensicImages(),
)
}
//--------------------------------------------------------------------------------------------------
// ATTRIBUTES
/**
* PARSE StringsMapIterator => Attribute
*/
fun IdBaseFieldInfo.parseAttributes():List<Attribute>{
return ArrayList<Attribute>().apply {
val iterator = AttributesBegin()
val iterEnd = AttributesEnd()
while (!iterator.Equals(iterEnd)) {
add(
Attribute(
key = iterator.GetKey(),
value = iterator.GetValue()
)
)
iterator.Advance()
}
}
}
//--------------------------------------------------------------------------------------------------
// TEXT FIELDS
/**
* IdResult => TEXT FIELDS
*/
fun IdResult.parseTextFields():List<TextField>{
return ArrayList<TextField>().apply {
val iterator = TextFieldsBegin()
val iterEnd = TextFieldsEnd()
while (!iterator.Equals(iterEnd)) {
add(iterator.parseTextField())
iterator.Advance()
}
}
}
/**
* PARSE IdTextFieldsMapIterator => FieldInfo
*/
fun IdTextFieldsMapIterator.parseTextField(): TextField {
val info = GetValue().GetBaseFieldInfo()
return TextField(
key = GetKey(),
value = GetValue().GetValue().GetFirstString().GetCStr(),
isAccepted = info.GetIsAccepted(),
attr = info.parseAttributes()
)
}
//--------------------------------------------------------------------------------------------------
// IMAGES
/**
* IdResult => IMAGES
*/
fun IdResult.parseImages():List<ImageField>{
return ArrayList<ImageField>().apply {
val iterator = ImageFieldsBegin()
val iterEnd = ImageFieldsEnd()
while (!iterator.Equals(iterEnd)) {
try {
add(iterator.parseImage())
}catch(e:Exception){
Log.e(TAG,"IdResult.parseImages",e)
}
iterator.Advance()
}
}
}
/**
* PARSE IdTextFieldsMapIterator => ImageInfo
*/
fun IdImageFieldsMapIterator.parseImage(): ImageField {
val info = GetValue().GetBaseFieldInfo()
val base64String = GetValue().GetValue().GetBase64String().GetCStr()
val base64Buf = Base64.decode(base64String, Base64.DEFAULT)
return ImageField(
key = GetKey(),
value = BitmapFactory.decodeByteArray(base64Buf, 0, base64Buf.size),
isAccepted = info.GetIsAccepted(),
attr = info.parseAttributes()
)
}
//--------------------------------------------------------------------------------------------------
// FORENSICS
/**
* IdResult => FORENSIC CHECK FIELDS
*/
fun IdResult.parseForensicCheckFields():List<TextField>{
return ArrayList<TextField>().apply {
val iterator = ForensicCheckFieldsBegin()
val iterEnd = ForensicCheckFieldsEnd()
while (!iterator.Equals(iterEnd)) {
add(iterator.parseForensicCheckField())
iterator.Advance()
}
}
}
/**
* PARSE IdCheckFieldsMapIterator => FieldInfo
*/
fun IdCheckFieldsMapIterator.parseForensicCheckField(): TextField {
val info = GetValue().GetBaseFieldInfo()
return TextField(
key = GetKey(),
value = GetValue().GetValue().toString(),
isAccepted = info.GetIsAccepted(),
attr = info.parseAttributes()
)
}
/**
* IdResult => FORENSIC TEXT FIELDS
*/
fun IdResult.parseForensicTextFields():List<TextField>{
return ArrayList<TextField>().apply {
val iterator = ForensicTextFieldsBegin()
val iterEnd = ForensicTextFieldsEnd()
while (!iterator.Equals(iterEnd)) {
add(iterator.parseTextField())
iterator.Advance()
}
}
}
/**
* IdResult => IMAGES
*/
fun IdResult.parseForensicImages():List<ImageField>{
return ArrayList<ImageField>().apply {
val iterator = ForensicImageFieldsBegin()
val iterEnd = ForensicImageFieldsEnd()
while (!iterator.Equals(iterEnd)) {
add(iterator.parseImage())
iterator.Advance()
}
}
}
Теперь вы знаете, что встроить распознавание паспорта РФ в приложение для Android — намного проще, чем может показаться на первый взгляд.
Это далеко не единственный вариант использования нашего софта. Как минимум, помимо распознавания документов также можно сверять лица, проверять лицо на “живость”, организовывать распознавание двусторонних документов и еще много чего.
Но схема работы останется той же: движок – настройки сессии – сессия – разбор результата. Такая схема позволяет как сделать сервер с распознаванием тысяч документов одновременно, так и встроить распознавание хоть в браузер.
Если заинтересовались – добро пожаловать на наш сайт. И не расходимся, скоро расскажем еще много чего интересного!
Вы все сделали идеально, нажимаете кнопку Deploy, и наступает тот самый момент, когда сердце замирает. Прод горит, мониторинги упали, команда в ужасе. Что нужно сделать, чтобы такого не было — рассказываем в статье.