Несмотря на цифровизацию всего и вся, во время когда человечество стоит на пороге создания нейроинтерфейса, когда ИИ стало обыденностью, классическая задача получения данных со скана/картинки до сих пор актуальна.
Доброго времени суток. Меня зовут Алексей. Работаю 1с программистом в компании по продаже техники. У меня были свои наработки по распознаванию и загрузке данных в учётную программу, и как раз менеджеры вручную заносили десятки страниц pdf-документов, которые нельзя было так просто перенести в ЭДО. Предложил им опробовать моё решение.
Изначально для распознавания использовался ABBYY Cloud, но он не бесплатный, а триальный режим недостаточно длинный. Решил написать свой API на питоне, где используется вся мощь бесплатного tesseracta. Проблема в том, что tesseract - это распознавание именно текста, и таблицы он не определяет, получается малополезная каша. Как раз накануне читал статью https://vc.ru/ml/139816-povyshenie-kachestva-raspoznavaniya-skanov-dokumentov-s-tablicami-s-pomoshchyu-vychisleniya-koordinat-yacheek, где с помощью openCV получают все ячейки таблицы, каждую ячейку прогоняют через tesseract и таким образом можно получать корректные данные. Решил опробовать данный метод. О том, что получилось, и будет пост.
Для теста я взял из демо базы 1с ТОРГ-12. В этой форме достаточно сложная структура, много таблиц, много текста, много данных. Как раз то что надо.
Так как это pdf файл, с помощью gostscript конвертирую в картинку. Пробовал ImageMagick, но почему-то было отвратительное качество через питон. Чрез cmd лучше, но gostscript вне конкуренции.
Для начала нужно убрать штрих код, так как при openCV может найти там то чего нет, особенно на QR-кодах. Для этого использую библиотеку pyzbar.
Для улучшения качества, делаю предобработку изображения. Первым делом увеличиваю контрастность, так как на практике были цветные шапки таблиц. При конвертации изображения в серый цвет, шапка и граница практически сливались, что отрицательно влияло на результат. После преобразую изображение в черно-белое по пороговому значению яркости и размываю.
clahe = cv2.createCLAHE(clipLimit=50, tileGridSize=(50, 50))
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
l2 = clahe.apply(l)
lab = cv2.merge((l2, a, b))
img2 = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(gray, 75, 255, cv2.THRESH_BINARY_INV )
kernel = np.ones((2, 2), np.uint8)
obr_img = cv2.erode(thresh, kernel, iterations=1)
obr_img = cv2.GaussianBlur(obr_img, (3,3), 0)
После это ищу возможные контура, при этом отсеивая совсем уж мелкие. Так как изображение может быть разного качества, то абсолютное значение ограничения неправильно. Чисто эмпирически вышел на 5 промилле, а также вычисляю важнейшую переменную delta.
contours, hierarchy = cv2.findContours(obr_img, cv2.RETR_TREE, cv2.CHAIN_APPROX_TC89_L1)
coordinates = []
ogr = round(max(img.shape[0], img.shape[1]) * 0.005)
delta = round(ogr/2 +0.5)
ind = 1;
for i in range(0, len(contours)):
l, t, w, h = cv2.boundingRect(contours[i])
if (h > ogr and w > ogr):
#Индекс блока
#Индекс контура
#Индекс родителя
#х
#у
#ширина
#высота
#текст
coordinates.append((0, ind, 0, l, t, w, h, ''))
ind = ind + 1
По итогу получил кучу контуров, раскиданных по всему изображению. С помощью sqlite3 делаю БД и загоняю туда coordinates. Запросами гораздо удобней сравнивать данные и отбирать нужные. К примеру, по идее индекс родителя должен быть в hierarchy, но я его не заполняю, так как он может некорректно определятся. Поэтому индекс родителя каждого контура я вычисляю сам с помощью нехитрого запроса.
Для разбора документа нужно определить где есть таблицы, обработать эти таблицы. Всё что кроме таблиц - текст. Обрабатываю текст, связываю всё вместе и готово.
Синим контуром отображено то, где родитель пустой, красным - где он есть. Легко заметить, что все таблицы представляют собой связное множество прямоугольников, у которых один родитель. По данному признаку и буду их искать. А также стоит включить КО и сказать, что прямоугольная таблица должна быть прямоугольной, даже если это не так. То есть, если какая то линия не определилась, или, как в данном примере, таблица товаров, таблица кодов имеют пустые места , то нужно автоматически достроить то что есть до полного прямоугольника.
Получается уже 2 подзадачи:
Получить все возможные связные множества
Достройка множеств до границ родителя
Связность определяется достаточно просто. Если в дельта окрестности прямоугольника есть другой прямоугольник, то они связаны. Используется окрестность, так как координаты не идеальны и всегда есть погрешность. Алгоритм не сложен. Выбираю прямоугольник с родителем, смотрю есть ли в связные с ним слева, справа, сверху, снизу. Если да - записываю какие. Таким образом и получается множество. Единственная проблема, что бывают контура находятся в дельта окрестности, но не являются ячейкой таблицы. Такие нужно исключать. Самый действенный выход получился такой. Делаю кластеризацию ячеек по ширине и высоте. Разница в высоте/ширине прямоугольника между первым и последним элементом кластера должно быть не более 2*дельта. Сортирую по возрастанию. Если очевидна разница в размерах и мощности между первым и вторым кластером, то скорее всего первый кластер - мусор, удаляю его. Получаю такую вполне симпатичную картину.
В данном случае достройка до полного прямоугольника нужна для таблицы кодов и товаров. Идея достаточна проста. От каждой прямоугольника по 4 сторонам смотрю ближайший прямоугольник. Если ближайший не лежит в дельта окрестности, или его вообще нет, то это "дырка". Сравниваю всё множество таких "дырок" между собой, если они пересекаются, то это даёт информацию какие прямоугольники нужно добавить. Также если какие-то связанные прямоугольники попадают внутрь достроенных, или меньше 4 элементов, то убираю.
По итогу получается следующая картина
Я знаю координаты каждой ячейки таблицы. Можно нарезать изображение и каждую отдельную ячейку распознать. Так как tesseract далеко не идеален, то приходится одно поле распознавать 3 раза, сравнивая результаты между собой. К примеру, слово "кол-во". В одном может распознаться как "кол-во", в другом как "---00", в третьем тупо набором символов. И непонятно в каком случае кому верить.
text1 = pytesseract.image_to_string(image[t1:t2,l1:l2], lang=lang, config='--psm 6')
text2 = pytesseract.image_to_string(image[t1:t2,l1:l2], lang=lang, config='')
text3 = pytesseract.image_to_string(image[t1+round(delta/2):t2-round(delta/2),l1+round(delta/2):l2-round(delta/2)], lang=lang, config='--psm 7')
text1 = text1.replace("\n", " ")
text2 = text2.replace("\n", " ")
text3 = text3.replace("\n", " ")
text1 = re.sub(' *[^ \(\)А-Яа-я\d\w\/\\\.\-,:; ]+ *', ' ', text1)
text2 = re.sub(' *[^ \(\)А-Яа-я\d\w\/\\\.\-,:; ]+ *', ' ', text2)
text3 = re.sub(' *[^ \(\)А-Яа-я\d\w\/\\\.\-,:; ]+ *', ' ', text3)
while text1.find(' ')!=-1:
text1 = text1.replace(' ',' ')
while text2.find(' ') != -1:
text2 = text2.replace(' ', ' ')
while text3.find(' ') != -1:
text3 = text3.replace(' ', ' ')
Итак. Все ячейки распознались, теперь нужно собрать таблицы. Вроде всё просто, определяешь строку по высоте и запихиваешь последовательно по левой границе. Но, во-первых, координаты не идеальны, поэтому нужно кластеризовать по высоте, выбрать для каждого кластера какую то среднюю высоту. Во вторых могут быть объединения ячеек, как в шапке таблицы товаров, поэтому так просто строки не сделаешь. Более того, бывают таблицы, в которых строк нет, типа реквизитов банка в счёте на оплату. Возникает вопрос, как удобно представить таблицу, с учётом её структуры? Каждую ячейку таблицы я принял за точку, если 2 ячейки рядом, точки соединяются. Таким образом получается граф. Благодаря такому представлению, я могу с любой ячейки узнать какие рядом, с какой стороны, какая в них информация. При необходимости разделить на строки, как мне нужно; узнать какая колонка шапки для данной конкретной ячейки; даже для данной структуры шапки таблицы сделать шаблон парсера строк, если надо. И много другое.
В документе остался просто текст. Тут подход такой же. От выявленный таблиц по 4 сторонам смотрю ближайшую таблицу или границу документа. Снова появляется множество "дырок". Сравниваю, получаю перекрестия, получаю новые прямоугольники. Распознаю также как и в предыдущий раз.
Из всего этого строю граф, вершиной которого является либо текст, либо таблица. Таким образом я получаю нужную мне информацию, сохраняю структуру документа и структуру таблиц. Ответ API пакую в JSON, принимаю на стороне 1с и обрабатываю. Работает, менеджеры почти довольны. Проблема в данном методе со скоростью. Он очень медленный. 1 страница pdf обрабатывается от 20 секунд до минуты, всё зависит от размера таблиц в файле. Думаю, решить это используя Tesserocr вместо Pytesseract, ну и распараллелить местами.
Ссылка на код https://github.com/Trim891/API. Так как по работе с PyCharm я ближе к категории "чайник", то я устал разбираться в ошибках при попытке синхронизировать проект с GitHub, поэтому тупо выложил файлы *.py и requirements.txt. Если есть дельные предложения как лучше и разумней выложить, или хотите поделиться опытом, то, пожалуйста, в форме как для ученика младших классов спецшколы с особенностью развития, так как чаще всего я не понимаю сленга; не понимаю когда говорят "в документации написано, иди читай", а документация на пару сотен страниц, на высшем эльфийском, и что конкретно читать - непонятно; не понимаю абстрактный действий для сверх крутого проекта, с десятками разработчиков, когда у меня всего 2 файла и список библиотек. Спасибо за понимание.
P.S. В файлах много комментов, много лишнего и вообще говнокод творческий беспорядок. Это всё было для внутреннего пользования, прихорашивать времени не было =)