Введение
Некоторое время назад в нашей стране крупные сети магазинов стали вводить электронные чеки. В частности, магазины сети Rimi. Эти чеки покупатель получает по почте в виде PDF документа. У меня скопилось много таких чеков, и мне стало интересно посмотреть на разного рода статистику: например, на цены на различные товары в разное время, сколько чего было приобретено и т. п.
К сожалению, PDF документы, которые покупатели получают – это картинка. Получить интересующую меня информацию из них без оптического распознавания символов (
Сразу уточню: речь не идёт о распознавании любых чеков. Это всего лишь чеки одной конкретной сети магазинов.
Забегая вперёд — в результате всех моих стараний только 4 чека из более чем 300 распознаются с ошибками. Новые чеки, которые появляются, тоже успешно обрабатываются.
В этой статье я расскажу о моём подходе к разработке приложения, о сложностях, с которыми столкнулся и как их решал, а также о том, какие альтернативные подходы я пробовал и почему от них в итоге отказался.
Код приложения доступен на Github. Там же есть тест с примером чека.
Инструменты, которые я использовал
Для Tesseract. Там уже была готовая модель для распознавания латышского языка, и он изначально хорошо справлялся с распознаванием чеков.
В плане языка разработки я выбрал Java + Spring Boot + Tess4j. Java – потому что это на данный момент мой основной язык и, несмотря на то, что я Python знаю в достаточной мере, чтобы подобный проект реализовать, я предпочитаю что-то более надёжное, с сильной типизацией и с возможностью ловить ошибки при компиляции. Spring Boot – по сути просто для своего удобства, а Tess4j — это библиотека, которая позволяет «общаться» с Tesseract-ом.
Так же я использовал opencv для обработки картинок.
Цель проекта
Разработать приложение, которое будет превращать PDF чеки в JSON c такими полями:
-
Время/Дата чека
-
Общая сумма чека
-
Общая сумма скидок
-
Список купленных товаров, каждый купленный товар должен иметь:
-
Название
-
Количество
-
Единицы (килограммы, штуки, упаковки)
-
Цену за единицу
-
Скидку
-
Окончательную цену
-
Вот расположение этих значений на чеке:
Так же важно точно знать, что чек распознан правильно – как минимум суммы сходятся. В противном случае должна быть отображена информация об ошибках.
Процесс разработки
Процесс разработки у меня был итеративный и повторялся он до тех пор, пока я не стал удовлетворён результатом. Выглядел он так:
На каждой итерации я делал изменения в коде. Дальше с этими изменениями обрабатывал все имеющиеся PDF файлы. В результате получал JSON документы по каждому чеку и общий репорт по всем чекам.
Следующий шаг – это ручная проверка. Но не подумайте, я не сравнивал вручную 300+ картинок с получившимися документами. Я всего лишь смотрел на изменения в JSON документах и в общем репорте. Репорт содержит для каждого чека либо слово SUCCESS, либо список ошибок, которые были выявлены на этапе проверки. Подробнее о нахождении ошибок я напишу дальше.
По результату ручной проверки принималось решение – либо фиксировать текущее состояние кода, документов и репорта, либо нужны дополнительные изменения.
Замечу, что хранение готовых JSON документов и некоторых промежуточных файлов в системе контроля версий помогает легко отслеживать изменения и сопоставлять их с изменениями в коде. В моём случае я хранил JSON документы и репорт в отдельном от кода репозитории, но IDE позволяла удобно использовать параллельно 2 репозитория в одном проекте.
Вот пример репорта, когда было сделано улучшившее результат разбора текста чека:
А тут изменение в JSON документе чека:
Подробнее о том, как происходит поиск ошибок и создаётся репорт я расскажу дальше.
OCR – оптическое распознавание символов
Первый шаг в процессе превращения PDF документа в JSON — это OCR или оптическое распознавание символов. Я начал с самого примитивного подхода: скармливал Tesseract-у PDF документ как есть и получал весь найденный текст. Однако, при попытках дальнейшей обработки этого текста и дальнейшей валидации полученных документов я осознал, что найденные ошибки распознавания трудно исправить, так как в этом случае нет информации о местоположении символов на картинке и, следовательно, нет возможности как-либо пытаться эту ошибку исправить. Поэтому, вместо того, чтоб запрашивать у Tesseract-а просто текст, я стал запрашивать TSV документ. Описание формата этого документа я нашёл тут.
Выглядит он так:
Для более удобного использования я собрал его в структуру объектов и распечатал в виде JSON документа:
Помимо самих распознанных слов теперь я имел доступ и к местоположению этих слов на картинке, и имел возможность пытаться исправить неточности распознавания уже на этапе обработки распознанного текста.
Дополнительно я пытался улучшить качество распознавания всего чека как посредством изменений настроек Tesseract-а, так и с помощью предварительной обработки картинки. Про настройки я углубляться не буду – тут всё решалось методом подбора, а вот с предварительной обработкой – тут есть чем поделиться.
Я нашёл эту статью в которой описаны варианты предварительной обработки картинки, но ввиду того, что чеки достаточно хорошего качества, единственное полезный совет в этой статье для моего случая – это удаление шума.
Я обратил внимание на то, что на чеке, как раз вокруг текста, есть блеклые пятна – шум, его я удалил с помощью Imgproc.threshold. Параметры я подбирал «на глаз»: сохранил картинки результата обработки с разными параметрами и выбрал наиболее подходящий. Получилось так:
Такая обработка улучшила результаты распознавания, но всё равно ошибки встречались достаточно часто. Поэтому я опробовал ещё способы улучшить распознавание.
Способ 1: увеличить расстояние между строк
На первый взгляд это казалось весьма тривиальной задачей – проверять по пикселям каждую строчку в картинке чека:
-
находим строку, где все пиксели белые
-
вставляем 30 пикселей белых строк вместо этой
строки.
Схематически это выглядит так (красной рамочкой обозначены строки, состоящие только из белых пикселей.
На практике же обилие диакритиков и высоких букв (которые залезают на межстрочное пространство) превратило это задачу в неподъёмную. Возможно, в языках, где диакритиков нет или их количество меньше, разделение на строки таким образом может сработать. Во всяком случае в тех местах, где удалось увеличить расстояние между строками, качество распознавания так же улучшалось. Например:
А вот, например, проблема с диакритиком – он неправильно «приклеился» к нижней строке, соответственно и текст теперь распознан неправильно:
В общем первый способ в целом успехом не увенчался, и я решил продолжать без него.
Способ 2: Проверка на соответствие шаблону (template matching).
Идея проста – для каждой цифры сделать шаблон картинки. Дальше, после распознавания, проверять правильность правильность полученых чисел сверяя текст с картинкой по шаблонам.
Надо отметить, что Tesseract не распознаёт отдельные цифры, а только числа целиком, например, на картинке слева число 0,10 является одним словом.
Следовательно, чтобы проверять картинку этого текста на соответствие распознанному тексту я должен взять область на картинке всего чека по координатам "x" : 324, "y" : 2845 с размерами "width" : 127, "height" : 43 и внутри этой области искать шаблоны каждой цифры. Далее, основываясь на результатах поиска, делать вывод соответствует ли текст картинке.
В opencv есть функционал поиска картинки в картинке, но, я, пытаясь реализовать этот подход, ушёл в глубокие дебри (даже рассказывать о них не вижу смысла) и решил его отложить на случай, если не найду более простых решений. Spoiler alert – нашёл, но об этом дальше.
Способ 3: Чёрный список символов
Тут всё просто: в моём случае часто в тексте появлялись символы подчёркивания «_» и тире «—».
В первом случае это всегда была путаница между знаком долготы над буквой на следующей строке, а во втором – знак минус.
Добавление этих символов в чёрный список в Tesseract-е решило эту проблему.
Кэширование результатов OCR
Так как процесс OCR занимает достаточно много времени, а конфигурация меняется не часто, я решил кешировать результаты. То есть все результаты распознавания целого чека сохраняются в отдельные файлы:
-
Сконвертированный PDF в TIFF (всё же Tesseract работает с картинками, а не с PDF),
-
Обработанный TIFF,
-
Текст распознавания всего чека,
-
TSV распознавания всего чека,
-
JSON представление информации из TSV документа.
Соответственно, когда изменения вносятся в обработку текста и TSV документа, нет необходимости прогонять OCR всего документа – достаточно считать имеющиеся файлы.
Процесс обработки текста + исправление ошибок распознавания
Всю обработку чека можно разбить на несколько частей:
-
Поиск интересующих частей во всём тексте документа (дата/время, купленные товары),
-
Сбор и трансформация данных,
-
Проверка правильности и исправление ошибок.
Поиск
Чтобы найти необходимые участки в тексте, например, время или список товаров или общая сумма чека я использовал регулярные выражения или шаблоны строк.
Например, чтобы найти общую сумму чека, я искал строку, которая соответствует регулярному выражению Samaksai EUR +(.*).
Или, например, для определения начала и конца списка товаров я схожим образом выбираю определённые строки: context.getLinesBetween("KLIENTS:", "Maksājumu karte");
Понятное дело, что это всё работает ровно до тех пор, пока структура чека не меняется, но эта проблема общая для парсинга практически всего. Тут важно, чтоб изменения в формате чека не оставались незамеченными – т.е. даже если сам парсинг не упал, то какие-то проверки в конце должны показывать ошибки.
Дополнительно такой поиск осложнён тем, что распознавание текста не идеальное, и из-за этого приходится изощряться, например строка, в которой указаны количество, цена за единицу и стоимость товара может иметь весьма экзотический вид:
Тут и лишние пробелы, и буквы вместо цифр, и неправильный формат чисел, и вообще много всяких интересностей. В результате и регулярка, которая используется для распознавания такой строки должна все эти варианты распознавать: .?(.*) +.* +(X|xXx) +(\d+([.,] ?\d+)?)\W+.{2,3}(/ ?kg)? +(.*)
Дальше из найденных строк конкретные части можно доставать по индексу, или по индексу с конца, или уже по принципу: 3-е слово после блаблабла.
Сбор и трансформация данных
На этом шаге найденный тест должен превратиться в структуру данных. То есть, по сути, сложить найденные данные в поля объектов: всё на свои места.
Для текстовых полей всё просто – никаких трансформаций не требуется (даже если текст неправильно распознан, с этим ничего не сделать – сохраняем как есть).
В то же время числа надо сначала получить из текста. И здесь, (о, сюрприз!) числа не всегда правильно. К счастью, я смог найти несколько способов, как эти ошибки можно исправить.
В коде я пытаюсь превратить получить значение числа из текста такими способами:
-
Слово как его нашёл Tesseract

Например, я ожидаю что текст является суммой денег. В таком случае слово «-0,36» соответствует формату числа, а слово «36» – не соответствует, так как не содержит двух знаков после запятой.
-
Объединить со следующим словом в строке.
Например, опять же, ожидается сумма денег, но имеется текст «36». Тогда можно попробовать взять предыдущее слово в строке и сложить их в одно, результат проверить на соответствие формату. В этом случае, если предыдущее слово, например, «-0,», то результат «-0,36» будет соответствовать формату суммы денег. Примечание – я не пробовал склеить слово со следующим потому, что, изначально, я выбирал слова с конца строки – например последнее слово в строчке. Если брать слова по индексу с начала строки, то можно пробовать склеивать так же и со следующим словом в строке.
-
Заново сделать OCR для слова
Иногда Tesseract ошибается с распознаванием чисел при распознавании всего чека целиком, а если попросить его заново распознать место в картинке, где он сам нашёл это слово – то он уже показывает правильный результат.
Например, изначально распознано было так
Но при этом Tesseract верно определил положение текста скидки в картинке:
Следовательно я могу запустить распознавание заново именно для этой области. Дополнительно, на этом шаге, есть возможность указать список допустимых символов в настройках Tesseract-а – т.е. цифры от 0 до 9, знаки «+» и «-» и запятая, отделяющая целую и дробную часть. Это существенно улучшает качество распознавания.
-
Заново сделать OCR для всей строки – суть подхода такая же, как и в предыдущем подходе, разве что нет возможности ограничить список ожидаемых символов, так как в строке присутствуют и цифры и буквы.
Дополнительно, я обнаружил, что из-за того, что некоторые секции чека имеют другой шрифт – им вообще следует делать OCR всегда заново. Таким образом, с помощью этого безусловного повторного прогона OCR исправляются проблемы неправильного распознавания цифр, например:
Проверка правильности и исправление ошибок
После того, как один товар полностью обработан, то есть полностью пройдена секция с одним товаром – есть возможность проверить правильно ли распознаны все значения относительно друг друга.
А именно, общая сумма (Gala cena 1,75) должна быть равна кол-ву (1 gab), умноженному на стоимость единицы (2.19 EUR), минус скидка (Atl. -0,44). Если это не так, то делается повторное распознавание всех этих текстов. Т.е. распознаётся текст в областях где находятся числа с использованием списка разрешённых символов.
Это часто помогает решить проблему, когда путаются цифры 0,8,6,9.
Подобным образом можно поступать и когда весь чек уже обработан: можно проверять числа общей суммы чека или общей суммы скидок относительно сумм соответствующих значений в списке товаров и делать повторное распознавание. Мне это делать не пришлось, так как ошибок, которые такой подход решил бы, не осталось.
Автоматические проверки правильности чеков и заключительный репорт
Чтобы оценивать правильность распознавания чеков, я сделал автоматические проверки. Все проверки касаются чисел, которые фигурируют в чеке. Проверять на правильность названия, в теории, тоже можно было бы, но мне правильность названий на данном этапе не так важна, поэтому я фокусировался на правильности чеков с точки зрения правильности всех чисел на них.
Проверки выполняются на заключительном этапе, когда есть уже готовые объекты чеков и выполняются лишь для того, чтоб составить окончательный репорт.
Вот список проверок:
-
Для каждого числа – формат соответствует. Т.е. если это деньги, то это две позиции после запятой; если вес, то 3 позиции после запятой; если количество, то целое число.
-
Общая сумма скидок (напечатана на чеке отдельно) равна сумме скидок всех товаров
-
Для каждого товара: количество * стоимость за единицу – скидка = конечная стоимость
-
Общая сумма чека равна сумме конечных стоимостей всех товаров
-
Сумма категорий скидок (отдельная секция на чеке) равна общей сумме скидок
-
Сумма платежей разными методами (карта, наличка, купоны) равна общей сумме чека
Ошибки, найденные в результате всех этих проверок, записываются в репорт и позволяют в одном месте видеть общую картину по всем чекам.
Кроме возможности оценить изменения в коде, это позволяет увидеть, какая проблема встречается чаще всего, чтобы выбрать наиболее широко распространённую проблему для решения в следующей итерации.
Выводы
-
Препроцессинг картинок который производит сам Tesseract, может быть, недостаточно хорош и есть смысл пробовать делать дополнительный препроцессинг самому.
-
Дополнительное расстояние между строк текста на картинке улучшает качество распознавания, но диакритики усложняют автоматическое расширение межстрочного пространства
-
Использование чёрного списка и белого списка символов улучшает точность распознавания
-
Повторное распознавание областей картинки позволяет точнее распознать текст в этих областях. Интересующие области можно получать, используя TSV результат распознавания от Tesseract-a
-
Наличие разных шрифтов в тексте ухудшает качество распознавания. Области имеющие разный шрифт по возможности следует распознавать отдельно.
-
Необходима автоматическая валидация полученного результата
Спасибо всем, кто прочитал до конца 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/788296/
Добавить комментарий