Оптическое распознавание символов и разбор чеков Rimi

Введение

Некоторое время назад в нашей стране крупные сети магазинов стали вводить электронные чеки. В частности, магазины сети Rimi. Эти чеки покупатель получает по почте в виде PDF документа. У меня скопилось много таких чеков, и мне стало интересно посмотреть на разного рода статистику: например, на цены на различные товары в разное время, сколько чего было приобретено и т. п.

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

Пример чека - список товаров

Пример чека — список товаров

Сразу уточню: речь не идёт о распознавании любых чеков. Это всего лишь чеки одной конкретной сети магазинов.

Забегая вперёд — в результате всех моих стараний только 4 чека из более чем 300 распознаются с ошибками. Новые чеки, которые появляются, тоже успешно обрабатываются.

В этой статье я расскажу о моём подходе к разработке приложения, о сложностях, с которыми столкнулся и как их решал, а также о том, какие альтернативные подходы я пробовал и почему от них в итоге отказался.

Код приложения доступен на Github. Там же есть тест с примером чека.

Инструменты, которые я использовал

Для OCR я использовал Tesseract. Там уже была готовая модель для распознавания латышского языка, и он изначально хорошо справлялся с распознаванием чеков.

В плане языка разработки я выбрал Java + Spring Boot + Tess4j. Java — потому что это на данный момент мой основной язык и, несмотря на то, что я Python знаю в достаточной мере, чтобы подобный проект реализовать, я предпочитаю что-то более надёжное, с сильной типизацией и с возможностью ловить ошибки при компиляции. Spring Boot — по сути просто для своего удобства, а Tess4j — это библиотека, которая позволяет «общаться» с Tesseract-ом.

Так же я использовал opencv для обработки картинок.

Цель проекта

Разработать приложение, которое будет превращать PDF чеки в JSON c такими полями:

  1. Время/Дата чека

  2. Общая сумма чека

  3. Общая сумма скидок

  4. Список купленных товаров, каждый купленный товар должен иметь:

    1. Название

    2. Количество

    3. Единицы (килограммы, штуки, упаковки)

    4. Цену за единицу

    5. Скидку

    6. Окончательную цену  

Вот расположение этих значений на чеке:

Пример чека - расположение элементов из списка на чеке

Пример чека — расположение элементов из списка на чеке

    Так же важно точно знать, что чек распознан правильно — как минимум суммы сходятся. В противном случае должна быть отображена информация об ошибках.

Процесс разработки

Процесс разработки у меня был итеративный и повторялся он до тех пор, пока я не стал удовлетворён результатом. Выглядел он так:

Процесс разработки

Процесс разработки

На каждой итерации я делал изменения в коде. Дальше с этими изменениями обрабатывал все имеющиеся PDF файлы. В результате получал JSON документы по каждому чеку и общий репорт по всем чекам.

Следующий шаг — это ручная проверка. Но не подумайте, я не сравнивал вручную 300+ картинок с получившимися документами. Я всего лишь смотрел на изменения в JSON документах и в общем репорте. Репорт содержит для каждого чека либо слово SUCCESS, либо список ошибок, которые были выявлены на этапе проверки. Подробнее о нахождении ошибок я напишу дальше.

По результату ручной проверки принималось решение — либо фиксировать текущее состояние кода, документов и репорта, либо нужны дополнительные изменения.

Замечу, что хранение готовых JSON документов и некоторых промежуточных файлов в системе контроля версий помогает легко отслеживать изменения и сопоставлять их с изменениями в коде. В моём случае я хранил JSON документы и репорт в отдельном от кода репозитории, но IDE позволяла удобно использовать параллельно 2 репозитория в одном проекте.  

Вот пример репорта, когда было сделано улучшившее результат разбора текста чека:

Улучшение результатов обработки документа

Улучшение результатов обработки документа

А тут изменение в JSON документе чека:

Улучшение разбора текста

Улучшение разбора текста

Подробнее о том, как происходит поиск ошибок и создаётся репорт я расскажу дальше.

OCR — оптическое распознавание символов

Первый шаг в процессе превращения PDF документа в JSON — это OCR или оптическое распознавание символов. Я начал с самого примитивного подхода: скармливал Tesseract-у PDF документ как есть и получал весь найденный текст. Однако, при попытках дальнейшей обработки этого текста и дальнейшей валидации полученных документов я осознал, что найденные ошибки распознавания трудно исправить, так как в этом случае нет информации о местоположении символов на картинке и, следовательно, нет возможности как-либо пытаться эту ошибку исправить. Поэтому, вместо того, чтоб запрашивать у Tesseract-а просто текст, я стал запрашивать TSV документ. Описание формата этого документа я нашёл тут.

Выглядит он так:

Пример результата OCR в TSV документ

Пример результата OCR в TSV документ

Для более удобного использования я собрал его в структуру объектов и распечатал в виде JSON документа:

JSON представление TSV результата OCR

JSON представление TSV результата OCR

Помимо самих распознанных слов теперь я имел доступ и к местоположению этих слов на картинке, и имел возможность пытаться исправить неточности распознавания уже на этапе обработки распознанного текста.

Дополнительно я пытался улучшить качество распознавания всего чека как посредством изменений настроек Tesseract-а, так и с помощью предварительной обработки картинки. Про настройки я углубляться не буду — тут всё решалось методом подбора, а вот с предварительной обработкой — тут есть чем поделиться.

Я нашёл эту статью в которой описаны варианты предварительной обработки картинки, но ввиду того, что чеки достаточно хорошего качества, единственное полезный совет в этой статье для моего случая — это удаление шума.

Я обратил внимание на то, что на чеке, как раз вокруг текста, есть блеклые пятна — шум, его я удалил с помощью Imgproc.threshold. Параметры я подбирал «на глаз»: сохранил картинки результата обработки с разными параметрами и выбрал наиболее подходящий. Получилось так:

Слева - оригинал, справа - после обработки

Слева — оригинал, справа — после обработки

Такая обработка улучшила результаты распознавания, но всё равно ошибки встречались достаточно часто. Поэтому я опробовал ещё способы улучшить распознавание.

Способ 1: увеличить расстояние между строк

На первый взгляд это казалось весьма тривиальной задачей — проверять по пикселям каждую строчку в картинке чека:

  1. находим строку, где все пиксели белые

  2. вставляем 30 пикселей белых строк вместо этой
    строки.

Схематически это выглядит так (красной рамочкой обозначены строки, состоящие только из белых пикселей.

Расширение межстрочного пространства

Расширение межстрочного пространства

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

Слева - оригинал, справа - увеличено межстрочное пространство

Слева — оригинал, справа — увеличено межстрочное пространство

Слева распознавание оригинала, справа - с увеличеным межстрочным пространством

Слева распознавание оригинала, справа — с увеличеным межстрочным пространством

А вот, например, проблема с диакритиком — он неправильно «приклеился» к нижней строке, соответственно и текст теперь распознан неправильно:

Знак смягчения под буквой n

Знак смягчения под буквой n «приклеился» к 2 на следующей строке

В общем первый способ в целом успехом не увенчался, и я решил продолжать без него.

Способ 2: Проверка на соответствие шаблону (template matching).

Идея проста — для каждой цифры сделать шаблон картинки. Дальше, после распознавания, проверять правильность правильность полученых чисел сверяя текст с картинкой по шаблонам.

Распознаное слово и его координаты на картинке чека

Распознаное слово и его координаты на картинке чека

Надо отметить, что Tesseract не распознаёт отдельные цифры, а только числа целиком, например, на картинке слева число 0,10 является одним словом.

Следовательно, чтобы проверять картинку этого текста на соответствие распознанному тексту я должен взять область на картинке всего чека по координатам "x" : 324, "y" : 2845 с размерами "width" : 127, "height" : 43 и внутри этой области искать шаблоны каждой цифры. Далее, основываясь на результатах поиска, делать вывод соответствует ли текст картинке.

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

Способ 3: Чёрный список символов

Тут всё просто: в моём случае часто в тексте появлялись символы подчёркивания »_» и тире »—».

В первом случае это всегда была путаница между знаком долготы над буквой на следующей строке, а во втором — знак минус.

Добавление этих символов в чёрный список в Tesseract-е решило эту проблему.

Кэширование результатов OCR

Так как процесс OCR занимает достаточно много времени, а конфигурация меняется не часто, я решил кешировать результаты. То есть все результаты распознавания целого чека сохраняются в отдельные файлы:

  1. Сконвертированный PDF в TIFF (всё же Tesseract работает с картинками, а не с PDF),

  2. Обработанный TIFF,

  3. Текст распознавания всего чека,

  4. TSV распознавания всего чека,

  5. JSON представление информации из TSV документа.

Пример папки со всеми артефактами распознавания чека

Пример папки со всеми артефактами распознавания чека

Соответственно, когда изменения вносятся в обработку текста и TSV документа, нет необходимости прогонять OCR всего документа — достаточно считать имеющиеся файлы.

Процесс обработки текста + исправление ошибок распознавания

Всю обработку чека можно разбить на несколько частей:

  1. Поиск интересующих частей во всём тексте документа (дата/время, купленные товары),

  2. Сбор и трансформация данных,

  3. Проверка правильности и исправление ошибок.

Поиск

Чтобы найти необходимые участки в тексте, например, время или список товаров или общая сумма чека я использовал регулярные выражения или шаблоны строк.

Например, чтобы найти общую сумму чека, я искал строку, которая соответствует регулярному выражению Samaksai EUR +(.*).

Или, например, для определения начала и конца списка товаров я схожим образом выбираю определённые строки: context.getLinesBetween("KLIENTS:", "Maksājumu karte");

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

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

Возможные варианты распознования строки с информацией о товаре

Возможные варианты распознования строки с информацией о товаре

Тут и лишние пробелы, и буквы вместо цифр, и неправильный формат чисел, и вообще много всяких интересностей. В результате и регулярка, которая используется для распознавания такой строки должна все эти варианты распознавать: .?(.*) +.* +(X|xXx) +(\d+([.,] ?\d+)?)\W+.{2,3}(/ ?kg)? +(.*)
Дальше из найденных строк конкретные части можно доставать по индексу, или по индексу с конца, или уже по принципу: 3-е слово после блаблабла.

Сбор и трансформация данных

На этом шаге найденный тест должен превратиться в структуру данных. То есть, по сути, сложить найденные данные в поля объектов: всё на свои места.

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

В то же время числа надо сначала получить из текста. И здесь, (о, сюрприз!) числа не всегда правильно. К счастью, я смог найти несколько способов, как эти ошибки можно исправить.

В коде я пытаюсь превратить получить значение числа из текста такими способами:

  1. Слово как его нашёл Tesseract

6c6907aaa3bda7b9747ed89d0784dccb.png

Например, я ожидаю что текст является суммой денег. В таком случае слово »-0,36» соответствует формату числа, а слово »36» — не соответствует, так как не содержит двух знаков после запятой.

  1. Объединить со следующим словом в строке.

Два последовательных слова образуют правильное число

Два последовательных слова образуют правильное число

Например, опять же, ожидается сумма денег, но имеется текст »36». Тогда можно попробовать взять предыдущее слово в строке и сложить их в одно, результат проверить на соответствие формату. В этом случае, если предыдущее слово, например,»-0,», то результат »-0,36» будет соответствовать формату суммы денег. Примечание — я не пробовал склеить слово со следующим потому, что, изначально, я выбирал слова с конца строки — например последнее слово в строчке. Если брать слова по индексу с начала строки, то можно пробовать склеивать так же и со следующим словом в строке.

  1. Заново сделать OCR для слова

Иногда Tesseract ошибается с распознаванием чисел при распознавании всего чека целиком, а если попросить его заново распознать место в картинке, где он сам нашёл это слово — то он уже показывает правильный результат.

Например, изначально распознано было так

Формат числа скидки

Формат числа скидки »-1,865» не соответствует формату суммы

Но при этом Tesseract верно определил положение текста скидки в картинке:

Область в которой Tesseract нашёл слово

Область в которой Tesseract нашёл слово »-1,865»

Следовательно я могу запустить распознавание заново именно для этой области. Дополнительно, на этом шаге, есть возможность указать список допустимых символов в настройках Tesseract-а — т.е. цифры от 0 до 9, знаки »+» и »-» и запятая, отделяющая целую и дробную часть. Это существенно улучшает качество распознавания.

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

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

Секция с другим шрифтом отдельно распознаётся лучше. Слева - распознавание всего чека, справа - распознавание отдельной области чека.

Секция с другим шрифтом отдельно распознаётся лучше. Слева — распознавание всего чека, справа — распознавание отдельной области чека.

Проверка правильности и исправление ошибок

После того, как один товар полностью обработан, то есть полностью пройдена секция с одним товаром — есть возможность проверить правильно ли распознаны все значения относительно друг друга.

Пример секции в которой описан один приобретённый товар

Пример секции в которой описан один приобретённый товар

А именно, общая сумма (Gala cena 1,75) должна быть равна кол-ву (1 gab), умноженному на стоимость единицы (2.19 EUR), минус скидка (Atl. -0,44). Если это не так, то делается повторное распознавание всех этих текстов. Т.е. распознаётся текст в областях где находятся числа с использованием списка разрешённых символов.

Это часто помогает решить проблему, когда путаются цифры 0,8,6,9.

Первая строка - оригинальное распознавание. Вторая строка - повторное распознавание после найденой ошибки.

Первая строка — оригинальное распознавание. Вторая строка — повторное распознавание после найденой ошибки.

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

Автоматические проверки правильности чеков и заключительный репорт

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

Проверки выполняются на заключительном этапе, когда есть уже готовые объекты чеков и выполняются лишь для того, чтоб составить окончательный репорт.

Вот список проверок:

  1. Для каждого числа — формат соответствует. Т.е. если это деньги, то это две позиции после запятой; если вес, то 3 позиции после запятой; если количество, то целое число.

  2. Общая сумма скидок (напечатана на чеке отдельно) равна сумме скидок всех товаров

  3. Для каждого товара: количество * стоимость за единицу — скидка = конечная стоимость

  4. Общая сумма чека равна сумме конечных стоимостей всех товаров

  5. Сумма категорий скидок (отдельная секция на чеке) равна общей сумме скидок

Категории скидок

Категории скидок

  1. Сумма платежей разными методами (карта, наличка, купоны) равна общей сумме чека

Ошибки, найденные в результате всех этих проверок, записываются в репорт и позволяют в одном месте видеть общую картину по всем чекам.

Кроме возможности оценить изменения в коде, это позволяет увидеть, какая проблема встречается чаще всего, чтобы выбрать наиболее широко распространённую проблему для решения в следующей итерации.

Выводы

  1. Препроцессинг картинок который производит сам Tesseract, может быть, недостаточно хорош и есть смысл пробовать делать дополнительный препроцессинг самому.

  2. Дополнительное расстояние между строк текста на картинке улучшает качество распознавания, но диакритики усложняют автоматическое расширение межстрочного пространства

  3. Использование чёрного списка и белого списка символов улучшает точность распознавания

  4. Повторное распознавание областей картинки позволяет точнее распознать текст в этих областях. Интересующие области можно получать, используя TSV результат распознавания от Tesseract-a

  5. Наличие разных шрифтов в тексте ухудшает качество распознавания. Области имеющие разный шрифт по возможности следует распознавать отдельно.

  6. Необходима автоматическая валидация полученного результата

Спасибо всем, кто прочитал до конца :)

© Habrahabr.ru