16.06, ООО «ВК»
16.06, ООО «ВК»
16.06, ООО «ВК»

Как встроить распознавание документов в Android: пошаговое руководство

Рассказываем, как быстро интегрировать распознавание документов с жесткой и гибкой формой в Android.

99 открытий2К показов
Как встроить распознавание документов в Android: пошаговое руководство

Привет, Tproger!

Мы в Smart Engines занимаемся разработкой софта для распознавания самых разных документов — начиная от паспорта РФ, свидетельства о рождении и заканчивая первичкой вроде УПД или ТОРГ-12, а также банковских карт, номеров телефона и баркодов. Наши библиотеки написаны полностью с нуля (на плюсах), мы уделяем огромное внимание скорости алгоритмов, нейросетям (новым архитектурам, размеру и правильному применению) и оптимизации, за счет чего наш софт портируется на любые архитектуры и может быть использован на любой платформе.

Сегодня продолжим знакомиться через рассказ о нашем софте, на очереди вторая библиотека — Smart Document Engine и ее возможности работы с жесткими и гибкими формами.

Гибкие и жесткие формы

Вначале про сами формы: они могут быть «жесткими» и «гибкими». Жёсткие формы подразумевают, что положение всех объектов на форме может быть задано прямо в виде координат на шаблоне. Самое простое определение для жестких форм — они совпадают «на просвет». Возьмите пару листов А4 с распечатанной жёсткой формой, наложите друг на друга, и места расположения полей точно совпадут. Гибкие формы устроены гораздо сложнее, но все равно имеют свою характерную структуру и топологию.

Распознавание жестких форм можно свести к детекции формы на изображении и распознавании определенных областей, где должны быть искомые поля. Гибкие формы требуют гораздо более сложных систем поиска (к тому же, завязанных на результате предыдущих действий, например, OCR, что только увеличивает возможность ошибки).

Кстати, иногда вместо распознавания текста целиком достаточно просто ответить на вопрос «есть ли текст в выбранной области, и, если есть, то где»

Но не надо думать, что жёсткие формы совсем просты — большие белые поля без каких-либо символов (или одинаковый узор по краям, как это бывает с бланками гособразца), малый объём статического текста и некоторая вариативность бланков тоже заставляют потрудиться над детекцией и классификацией шаблонов.

Также существуют общие для подобных форм проблемы. Правильно интерпретировать галочки в чекбоксах, найти штрихкоды, правильно разметить табличные данные — есть куча проблем, каждая из которых имеет своё state-of-the-art решение и набор алгоритмов, над которыми нужно ломать голову.

Почему не LLM, хотя казалось бы

Сейчас мы переживаем бум развития нейросетей — генеративные и классифицирующие сети появляются как грибы после дождя. Количество задач, которые они могут решить, тоже кажется неисчислимым: казалось бы — дайте обучающую выборку побольше, и всё получится! Тем более, что примеры использования нейросетей для автоматизации рутины уже можно встретить на каждом шагу: об этом пишут заметки и обзорные статьи на научно-популярных ресурсах, а интеграторы и стартапы предлагают решения по созданию чат-ботов и помощников на основе ИИ, обученного на внутренней документации больших компаний.

Однако чем сложнее нейросеть, чем глубже степень обучения — тем выше шанс, что она начнёт бредить. Мы все какое-то время назад смеялись над шести-семипалыми героями очередных сгенерированных изображений, сейчас посмеиваемся над сгенерированными сетями текстами с описанием несуществующих фильмов и книг, но смешно ли будет нам (а особенно бухгалтерии), если нейросеть начнет галлюцинировать при распознавании платёжных реквизитов или суммы НДС? И чем выше степень развития сетей — тем менее заметными будут становиться такие ошибки.

В прошлом году на одной из ключевых конференций в области анализа и распознавания документов — ICDAR — учёные традиционно задались вопросом о будущем OCR. И сошлись во мнении, что OCR нисколько не устарела, благополучно развивается и остается наиболее надежным инструментом распознавания. Обеспечить требования консистентности (и ещё всякого такого) информации, извлекаемой из изображения, все равно сможет только старый добрый OCR и прочие детерминированные алгоритмы. И именно их разработкой (и доведением до совершенства) мы и занимаемся.

Вернёмся к библиотеке Smart Document Engine. Как уже говорилось выше, гибкие формы на то и гибкие, что исключительно геометрией на них не обойдёшься — вопрос местонахождения полей решается «на лету». На процесс взаимодействия с библиотекой это влияет в самом конце, на моменте работы с результатом. Перейдем к знакомству с интерфейсом на примере всё того же встраивания в андроид.

Встраивание

В целом, сценарий работы с библиотекой распознавания документов такой же, как и в прошлой статье про распознавание паспорта: создаём движок, формируем настройки сессии, заводим саму сессию и кормим её картинками, после чего работаем с результатом распознавания. В отличие от документов, удостоверяющих личность, гибкие и жесткие формы могут быть многостраничными, с одинаковыми «по смыслу» полями на каждой странице. Помимо этого, часто документы загружают «пакетом», и в этом случае на одном изображении могут быть несколько разных документов. Поэтому результат распознавания устроен сложнее, чем в прошлом примере. Есть «результат распознавания», внутри него лежит набор найденных документов, каждый документ разбивается на «логический» и «физический» набор полей:

			/**
* Single LOGICAL document data
* (parsed com.smartengines.doc.Document)
*/
data class DocumentData(
   val id          : Int,
   val type        : String,
   val name        : DocName?,
   val attributes  : Map<String,String>,
   // Fields
   val texts       : Map<String,TextField>,
   val tables      : Map<String,TableField>,
   val images      : Map<String,ImageField>,
   // Physical document
   val physicalDoc : PhysicalDocumentData?
)


/**
* Single PHYSICAL document data
* (parsed com.smartengines.doc.DocPhysicalDocument)
*/
data class PhysicalDocumentData(
   val pages : List<PhysicalPageData>
)
data class PhysicalPageData(
   // Image
   val width : Int,
   val height: Int,
   val imageBase64String : String,
   // Fields
   val fields : Map<String,PhysicalFieldInfo>


)
data class PhysicalFieldInfo(
   val geometries  : List<Geometry> // can be many geometries for one field
)

		

Логическая и физическая части документа разбираются отдельно, так как в некоторых случаях геометрия вообще не нужна (если результат распознавания документа дальше идёт в базу данных):

			/**
* PARSE DocResult => DocResultData
*/
fun DocResult.parse(
   processedImages  : List<Bitmap> // processed images
): DocResultData {
   return DocResultData(
       documents = ArrayList<DocumentData>().apply {
           val iterator = DocumentsBegin()
           val end      = DocumentsEnd()
           while (!iterator.Equals(end)){
               val document = iterator.GetDocument()
               val docId = document.GetPhysicalDocIdx()
               val physicalDocument = GetPhysicalDocument(docId)
               add( with(document) {
                   val textFields  = parseTextFields()
                   val tableFields = parseTableFields()
                   val imageFields = parseImageFields()
                   DocumentData(
                       id          = docId,
                       type        = GetType(),
                       name        = null,
                       attributes  = parseAttributes(),
                       texts       = textFields,
                       tables      = tableFields,
                       images      = imageFields,
                       physicalDoc = physicalDocument.parse(
                               processedImages = processedImages,
                               fieldIds = textFields.keys + imageFields.keys + tableFields.keys
                           )
                   )
               })
               iterator.Advance()
           }
       },
       scenes = processedImages
   ).also {
       val str = it.toJson().toString(4)
   }
}


//--------------------------------------------------------------------------------------------------
// ATTRIBUTES parsers for different sources
private fun StringsMapIterator.parseMap(end:StringsMapIterator):Map<String,String> {
   return HashMap<String, String>().apply {
       while (!Equals(end)) {
           put( GetKey(), GetValue())
           Advance()
       }
   }
}
private fun Document.parseAttributes():Map<String,String>{
   return AttributesBegin().parseMap(AttributesEnd())
}
private fun DocBaseFieldInfo.parseAttributes():Map<String,String>{
   return AttributesBegin().parseMap(AttributesEnd())
}


//--------------------------------------------------------------------------------------------------
// FIELD INFO (with Attributes)
private fun DocBaseFieldInfo.parse():FieldInfo{
   val key = GetName()
   return FieldInfo(
       key        = key,
       name       = null,
       confidence = GetConfidence(),
       isAccepted = GetAcceptFlag(),
       attributes = parseAttributes(),
   )
}


//--------------------------------------------------------------------------------------------------
// FIELD GEOMETRIES from DocPhysicalDocument
private fun DocPhysicalDocument.parseFieldGeometries(fieldKey:String):List<Geometry>{
   return ArrayList<Geometry>().apply {
       if(fieldKey.isNotEmpty())
       for(i in 0 until GetPagesCount()){
           val page = GetPhysicalPage(i)
           val iterator = page.BasicObjectsBegin(fieldKey)
           val end      = page.BasicObjectsEnd  (fieldKey)
           while (!iterator.Equals(end)){
               val geo = iterator.GetBasicObject().GetBaseObjectInfo().GetGeometryOnScene()
               add(
                   geo.toGeometry(sceneId = page.GetSourceSceneID())
               )
               iterator.Advance()
           }
       }
   }
}


//--------------------------------------------------------------------------------------------------
// TEXT FIELDS
private fun Document.parseTextFields():Map<String,TextField>{
   return HashMap<String,TextField>().apply {
       val iterator = TextFieldsBegin()
       val end      = TextFieldsEnd()
       while (!iterator.Equals(end)) {
           val key = iterator.GetKey()
           put(
               key,
               iterator.GetField().parse()
           )
           iterator.Advance()
       }
   }
}
private fun DocTextField.parse():TextField{
   return TextField(
       info  = GetBaseFieldInfo().parse(),
       value = GetOcrString().parse(),
   )
}


//--------------------------------------------------------------------------------------------------
// TABLE FIELDS
private fun Document.parseTableFields():Map<String,TableField> {
   return HashMap<String,TableField>().apply {
       val iterator = TableFieldsBegin()
       val end      = TableFieldsEnd()
       while (!iterator.Equals(end)) {
           while (!iterator.Equals(end)) {
               val key = iterator.GetKey()
               put(
                   key   = key,
                   value = iterator.GetField().parse()
               )
               iterator.Advance()
           }
       }
   }
}
private fun DocTableField.parse():TableField{
   val colNum = GetColsCount()
   val rowNum = GetRowsCount()
   return TableField(
       info = GetBaseFieldInfo().parse(),
       header = ArrayList<String>(colNum).apply {
           for( i in 0 until colNum)
                add(GetColName(i))
       },
       rows = ArrayList<List<TextField>>(rowNum).apply {
           for( j in 0 until rowNum){
               add(ArrayList<TextField>(colNum).apply {
                   for( i in 0 until colNum)
                       add(
                           GetCell(j,i).parse()
                       )
               })
           }
       }
   )
}


//--------------------------------------------------------------------------------------------------
// IMAGE FIELDS
private fun Document.parseImageFields():Map<String,ImageField>{
   return HashMap<String,ImageField>().apply {
       val iterator = ImageFieldsBegin()
       val end      = ImageFieldsEnd()
       while (!iterator.Equals(end)) {
               val key = iterator.GetKey()
               put(
                   key   = key,
                   value = iterator.GetField().parse()
               )
           iterator.Advance()
       }
   }
}


private fun DocImageField.parse(): ImageField {
   return  ImageField(
       info        = GetBaseFieldInfo().parse(),
       value       = bitmapFromBase64String(GetImage().GetBase64String().GetCStr()),
   )
}


//--------------------------------------------------------------------------------------------------
// PHYSICAL DOCUMENT
/**
* Parse DocPhysicalDocument
* @param processedImages - processed images
* @param textIds - ids of the logical document text fields
*/
private fun DocPhysicalDocument.parse(
   processedImages: List<Bitmap>, // processed images
   fieldIds        : Collection<String>,
):PhysicalDocumentData{
   return PhysicalDocumentData(
       pages = ArrayList<PhysicalPageData>().apply {
           for(pageId in 0 until GetPagesCount()){
               val page = GetPhysicalPage(pageId)
               // Create data object
               add(page.parse(pageId, processedImages, fieldIds))
           }
       }
   )
}


private fun DocPhysicalPage.parse(
   pageId:Int,
   processedImages: List<Bitmap>, // processed images
   fieldIds        : Collection<String>,
):PhysicalPageData{
   // Scene
   val sceneIndex = GetSourceSceneID()
   val sceneImage = processedImages[sceneIndex]
   // Image
   val image      = GetPageImageFromScene( ImageFactory.imageFromBitmap(sceneImage) ) // SDK Image
   // Text fields
   val fields = HashMap<String,PhysicalFieldInfo>().apply {
       fieldIds.forEach { id->
               put(id, PhysicalFieldInfo(
                   geometries = ArrayList<Geometry>().apply {
                       val iterator = BasicObjectsBegin(id)// TextObjectsBegin(id)
                       val end      = BasicObjectsEnd(id)//TextObjectsEnd(id)
                       while (!iterator.Equals(end)) {
                           val textObject = iterator.GetBasicObject()
                           val polygon =  textObject.GetBaseObjectInfo().GetGeometryOnPage()
                           val geometry = polygon.toGeometry(pageId)
                           add(geometry)
                           iterator.Advance()
                       }
                   }
               )
           )
       }
   }


   // Create data object
   return PhysicalPageData(
       // Image
       width  = image.GetWidth(),
       height = image.GetHeight(),
       imageBase64String = image.GetBase64String().GetCStr(),
       // Fields
       fields = fields
   )
}

		

Как правило, результат распознавания представляют в виде json-объекта, но для наглядности лучше всего пользоваться html — особенно в случае, когда логические и физические поля имеют больше одного соответствия. Вот простенький генератор html на основе документа:

			fun DocResultData.toHtml(
   context: Context,
   showImages: Boolean = true,
   showTables: Boolean = true
):String{
   return try {
       val html = StringBuilder()
       html.append("<!DOCTYPE html>\n")
       html.append("<html>\n")
       // HEAD
       html.append("<head>\n")
       html.append("<meta name='viewport'  content='width=device-width, initial-scale=1.0'>\n")
       html.append("<style type='text/css'>${readAssetsTextFile(context,"doc-result.css")}</style>\n")
       html.append("<script type='text/javascript'>${readAssetsTextFile(context,"doc-result.js")}</script>\n")
       html.append("</head>\n")
       // BODY
       html.append("<body>\n")
       // Documents
       documents.forEach {
           it.toHtml(html, showImages, showTables)
       }
       html.append("</body>\n")
       html.append("</html>\n")
       html.toString()
   }catch (e:Exception){
       "<html><body>Error: $e </body></html>"
   }
}


// SCENE
private fun DocResultData.sceneToHtml(html:StringBuilder,sceneId: Int, bitmap: Bitmap) {
   // Scene bitmap image
   html.append("<div class='svg_container' align='center'>")
   html.append("<svg viewBox='0 0 ${bitmap.width} ${bitmap.height}' id='root' "
           +"xmlns='http://www.w3.org/2000/svg' "
           +"style='max-width: 100%; height:auto'>")
   html.append("<image width='${bitmap.width}' height='${bitmap.height}' href='data:image/jpeg;base64,${bitmapToBase64String(bitmap)}'/>\n")
   html.append("</svg>")
   html.append("</div>")
}


// DOCUMENT
private fun DocumentData.toHtml(html:StringBuilder, showImages: Boolean, showTables: Boolean){
   val docId = id
   html.append("<section>\n")
   // NAME+TYPE
   name?.let {
       html.append("<h1 align='center'>${it.local}</h1>\n")
   }
   html.append("<h4 align='center'>$type</h4>\n")


   // PAGES
   physicalDoc?.pages?.forEach { page->


       // IMAGE
       html.append("<div class='svg_container' align='center'>")
       html.append("<svg viewBox='0 0 ${page.width} ${page.height}' id='root' "
               +"xmlns='http://www.w3.org/2000/svg' "
               +"style='max-width: 100%; height:auto'>")
       html.append("<image width='${page.width}' height='${page.height}' href='data:image/jpeg;base64,${page.imageBase64String}'/>\n")
       // Field frames on the image
       page.fields.forEach{ (fieldId,field)->
           field.geometries.forEach { geometry ->
               geometry.toHtml(html,id="$docId:$fieldId")
           }
       }
       html.append("</svg>")
       html.append("</div>")
   }


   // ATTRIBUTES
   if(attributes.isNotEmpty()) {
       html.append("<h2>Attributes</h2>\n")
       attributes.forEach{
           html.append("<p><b>${it.key}</b><span>${it.value}</span></p>\n")
       }
   }


   // TEXT FIELDS
   if(texts.isNotEmpty()) {
       html.append("<h2>Fields</h2>\n")
       texts.values.forEach{
           // Field info + value
           it.info.toHtml(html,id){
               // field value as text
               html.append(it.value.toHtmlEscaped())
           }
       }
   }


   // IMAGE FIELDS
   if(images.isNotEmpty() && showImages) {
       html.append("<h2>Images</h2>\n")
       images.values.forEach {
           // Field info
           it.info.toHtml(html, id){
               // field value as image
               val bitmap = it.value
               html.append("<svg viewBox='0 0 ${bitmap.width} ${bitmap.height}' id='root' "
                       +"xmlns='http://www.w3.org/2000/svg' "
                       +"style='max-width: 100%; height:auto'>")
               html.append("<image width='${bitmap.width}' height='${bitmap.height}' href='data:image/jpeg;base64,${bitmapToBase64String(bitmap)}'/>\n")
               html.append("</svg>")
           }
       }
   }


   // TABLE FIELDS
   if(tables.isNotEmpty() && showTables) {
       html.append("<h2>Tables</h2>\n")
       tables.values.forEach {
           html.append("<div style='overflow-x:auto;'>\n")
           // Field info
           it.info.toHtml(html,id) {
               // Table
               html.append("<table cellspacing='2' cellpadding='2'>\n")
               it.rows.forEach { row ->
                   html.append("<tr>")
                   row.forEach { field ->
                       val multicol = field.info.attributes["multicol"] ?: "1"
                       html.append("<td colspan=$multicol><span>${field.value}</span></td>")
                   }
                   html.append("</tr>\n")
               }
               html.append("</table>\n")
           }
           html.append("</div>\n")
       }
   }
   html.append("</section>\n")
}
private val FieldInfo.label:String
   get() = name?.local?.let { if(it.isNotEmpty()) it else key }?:key


private fun FieldInfo.toHtml(html:StringBuilder, docId: Int, printValue:()->Unit){
   dataRowToHtml(html,"$docId:$key", isAccepted, label, printValue)
}


private fun String.toHtmlEscaped(): String =
   this.replace("&", "&")
       .replace("<", "<")
       .replace(">", ">")
       .replace("\"", """)
       .replace("'", "'")




//--------------------------------------------------------------------------------------------------
// CONNECTED ITEMS
// Geometry to HTML
fun Geometry.toHtml(html: StringBuilder, id:String){
   // Fill "points"
   val points = points
       .map { "${it.x},${it.y}" }
       .joinToString(" ")
   // Append polygon
   html.append("<polygon class='poly' parent_id='$id' points='$points'/>\n")
}
// Data row to HTML
fun dataRowToHtml(
   html        : StringBuilder,
   id          : String?, // link to the image frame
   isAccepted  : Boolean?,// to set icon
   label       : String?,
   printValue  : ()->Unit
){
   html.append( id?.let { "<p data-id='$id'>" } ?: "<p>" )
   // Circle
   isAccepted?.let {
       val circleColor = if (it) "green" else "yellow"
       html.append("<span class='dot_$circleColor'></span>")
   }
   // Label
   label?.let {
       html.append("<b>$label:</b>")
   }
   // Value
   printValue.invoke()
   html.append("</p>\n")
}

		

В результате получается удобная для взаимодействия html-страничка. Если добавить немного фантазии, то можно сразу сделать форму, в которой можно будет проверять и дополнять неуверенно распознанные поля — очень удобно в случае, если качество изображения плохое или документ плохо пропечатан.

Конечно, помимо андроида встроить распознавание и организовать удобные представление документа (при необходимости) можно и на любой другой платформе, однако с трендом на создание банковских офисов нового поколения и развития курьерской сети автоматизация ввода документов с помощью средненьких мобильных устройств на Андроиде становятся актуальной задачей.

На этом мы не заканчиваем, ждите новых статей!

Следите за новыми постами
Следите за новыми постами по любимым темам
99 открытий2К показов