Рассказываем, как быстро интегрировать распознавание документов с жесткой и гибкой формой в Android.
99 открытий2К показов
Привет, 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-страничка. Если добавить немного фантазии, то можно сразу сделать форму, в которой можно будет проверять и дополнять неуверенно распознанные поля — очень удобно в случае, если качество изображения плохое или документ плохо пропечатан.
Конечно, помимо андроида встроить распознавание и организовать удобные представление документа (при необходимости) можно и на любой другой платформе, однако с трендом на создание банковских офисов нового поколения и развития курьерской сети автоматизация ввода документов с помощью средненьких мобильных устройств на Андроиде становятся актуальной задачей.