Чтение номера кузова из .pdf EasyOCR

от автора

Дисклеймер

Я не являюсь хорошим спиециалистом в области программирования на Python. Возможно какие-то мои решения вызовут бунт и недовльство у опытных senior и middle разработчиков. Попрошу таких комментаторов воздержаться от своего мнения относительно моего подхода к решению данной задачи. Спасибо!

Поговорим о ТЗ

Необходимо было разработать API сервис (не важно на каком ЯП), который мог принимать в себя .pdf документ, выполнять какую-то процедуру по извлечению из него необходимых данных, возвращать их в каком-то формате. Конкретнее: есть сертификат экспорта авто из Японии в РФ. На этом сертификате есть параметр «Номер кузова авто». Необходимо его извлечь из документа, прочитать с помощью машинного зрения, проверить данное значение по базе данных организации. В случае успешной операции — положить файл на ftp сервер, переименовав его в идентификатор записи с БД.

Пример документа

Пример документа

Данный документ представлял с собой обычный скан в виде изображения в формате .pdf (с него нельзя копировать текст, путем выделения его мышью). Добавляло сложности в поиске решения задачи добавлялось то, что таких документов всего было 3 типа. И в каждом типе — положение необходимой ячейки с номером кузова было отличным от другого. Плюс, так как — это был скан из МФУ, нельзя было рассчитать точное положение только исходя из типа документа, по причине человеческого фактора(при сканировании документ можно прижать ближе к верхнему правому краю лотка, так и к нижнему левому краю + угол смещения).

Первые шаги

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

  1. Ввод. Получаем документ в формате .pdf

  2. Конвертация .pdf => .png для дальнейшей работы с изображением

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

  4. Обрезка рабочей области по заданным координатам

  5. Чтение текста из рабочей области

  6. Работа с БД на основе прочтенного текста

  7. Вывод. В случае успеха — переименовываем файл и кладем на сервер исходный .pdf документ

1 Этап. Определение типа документа

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

  • Цветное изображение / Черно-белое изображение? (Далее узнаете зачем)

  • Какой это из трех типов документов?

Про определение — цветное или черно-белое изображение писать не буду, поговорим сразу об ответе на 2 вопрос.

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

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

b_px = 0 w_px = 0  type_image = self.image.crop(crod)  width, height = type_image.size pixels = type_image.load()  for y in range(height):     for x in range(width):          R, G ,B = pixels[x, y]          if R <= 60 and G <= 60 and B <= 60:             b_px += 1         else:             w_px += 1                                      if prc == 1:     if b_px > 50:         self.doctype = 1         break else:     if b_px > 50:         self.doctype = 3     else:         self.doctype = 2

2 Этап. Подготовка изображения к чтению

Для корректного чтения текста с изображения — его необходимо предварительно подготовить. Одна из таких подготовок — это поиск необходимой области для обрезки и дальнейшего чтения. Как оговаривалось в ТЗ — сложность в человеческом факторе и положению изображения на лотке сканера. Соответственно, задать определенные координаты для рабочей области не получится, так как на первом документе все будет чотко по центру, а на втором — номер кузова будет обрезан на половину из за разного положения документа в сканере МФУ.

Можно задать координаты X,Y рабочей области с запасом + 50-200 пикселей в каждую сторону. Но в таком случае из-за лишнего текста на изображении, границ ячеек — результат чтения был не совсем корректным.

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

Но спустя 5-10 попыток проверки моей задумки — выяснилось, что в 3 из 5 тестах — рабочая область определяется некорректно.

Наиболее оптимальным подходом к решению, мне помог ответ одного человека из habr https://qna.habr.com/q/1373714. Он посоветовал провести фильтрацию по верхним и нижним границам изображения. Отличный и перспективный вариант, но в таком методе тоже иногда случались осечки. Решением которых было — небольшая коррекция порогов или примерную координату необходимой области. Соответственно, изменив ту или иную величину — рабочая область задавалась корректно.

Бегая туда-сюда от 1 значения порогов к другому — я решил не менять способ решения, а способ подхода к нему. К примеру, мы задаем 4(+2 для черно/белых изображений) начальные величины:

lowp = 249 - (i) #Нижний порог hiwp = 254 #Верхний порог xtmp = CONCERN[mod][self.doctype]['x1'] + i #Приблизительно центр нужной ячейки (Х) ytmp =  round(CONCERN[mod][self.doctype]['y1'] + (i / 3))  #Приблизительно центр нужной ячейки (У)

Все, этого было более чем достаточно. Затем я просто запустил по кругу эту процедуру 20 раз, с измененим данных значений на i =+ 1

times = 20 i  = 0 while i <= times:     i += 1     try:         print(f'{getDate(1)} [Img]: {i} stage starting!')         self.image_cv2 = cv2.imread(self.png, cv2.IMREAD_GRAYSCALE)          if self.colormode == False:             lowp = 249 - (i)             hiwp = 254         else:             lowp = 180 + (i * 3) #10 раза по +3             hiwp = 255          final_image = None          if self.colormode == False or self.doctype == 3:             denoised_image = cv2.medianBlur(self.image_cv2, 3)             _, binary_image = cv2.threshold(denoised_image, lowp, hiwp, cv2.THRESH_BINARY)             kernel = np.ones((3, 3), np.uint8)             final_image = cv2.morphologyEx(binary_image, cv2.MORPH_OPEN, kernel, iterations=2)         else:             _, final_image = cv2.threshold(self.image_cv2, lowp, hiwp, cv2.THRESH_BINARY)          x = 0         y = 0                  num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(final_image, connectivity=8)                  xtmp = CONCERN[mod][self.doctype]['x1'] + i         ytmp =  round(CONCERN[mod][self.doctype]['y1'] + (i / 3))          point = (xtmp, ytmp)          label = labels[point[1], point[0]]          x = stats[label, cv2.CC_STAT_LEFT]          y = stats[label, cv2.CC_STAT_TOP]                  width = stats[label, cv2.CC_STAT_WIDTH]          height = stats[label, cv2.CC_STAT_HEIGHT]          x1 = x         x2 = x1 + width         y1 = y         y2 = y1 + height                  cropped_image = self.image_cv2[y1:y2, x1:x2]          cv2.imwrite(f'{tmp_mod_path}/{i}.png',cropped_image)                  except Exception as e:         print(e)         continue

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

3 Этап. Фильтр, чтение, обработка результатов

Как оговаривали на 1 этапе — одним из вопросов об изображении — было: цветное или черно-белое. Это было необходимо, для определения — какие фильтры будут применяться для обработки уже подготовленной рабочей области перед чтением с нее текста.

Если изображение было цветное, то на нем просто повышалась контрастность и яркость и немного убрать свободную область сверху. Что давало четкое изображение букв и цифр, а так-же прятало все подложки. Что-то такое получалось на выходе:

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

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

image = cv2.imread(file, cv2.IMREAD_GRAYSCALE) _, binary_image = cv2.threshold(image, 130, 230, cv2.THRESH_BINARY_INV) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary_image, connectivity=8) min_area = 3 cleaned_image = np.zeros_like(binary_image) for i in range(1, num_labels):     if stats[i, cv2.CC_STAT_AREA] >= min_area:         cleaned_image[labels == i] = 255  cleaned_image = cv2.bitwise_not(cleaned_image) kernel = np.ones((1, 2), np.uint8) cleaned_image = cv2.morphologyEx(cleaned_image, cv2.MORPH_CLOSE, kernel)  cv2.imwrite(file, cleaned_image)

Что касается самого чтения. Изначально я использовать библиотеку Teseract, но после — отказался в сторону EasyOCR. Она проще в использовании и так как изображение предварительно подготовлено — нет необходимости в использовании кучи разных настроек. Считаю, что с поставленной задачей он хорошо справляется. Время чтения одного такого изображения на виртуальном сервере без GPU составляет 10-15 секунд. Без фильтров вывода текста тоже не обошелся, и пришлось немного покостылить(хотя для многих опытных разрабов — весь проект будет казаться одним большим костылем) с заменами символов в результате:

value = value.replace(",", "") value = value.replace("\"", "") value = value.replace("~", "-") value = value.replace(".", "") value = value.replace(";", "") value = value.replace(":", "") value = value.replace("*", "") value = value.replace("+", "") value = value.replace("/", "") value = value.replace("'", "") value = value.replace("`", "") value = value.replace("=", "-") value = value.replace("_", "-") value = value.replace("--", "-") value = value.replace("}", "") value = value.replace("{", "") value = value.replace("#", "") value = value.replace("$", "S") value = value.replace("&", "8") value = value.replace("^", "A") value = value.replace("-", "") value = value.upper()  if ']' in value and value[-1] != ']':     value = value.replace("]", "J") else:     value = value value = value.rstrip('-')

По самому процессу больше рассказать нечего. А про flask для работы с API и работу с запросами — рассказывать не хочу.

Заключение

Таким образом, разработка данного ПО заняла 1,5-2 месяца. Было куча ошибок, багов, неверно читался текст, неверно обрезались рабочие области изображения. Было 6 ревизий этого проекта.

Данным решением пользуются сотрудники организации более полу года. Ежедневно они пропускаю через него 30-100 таких документов. Соотношение верных к общему количеству, предоставлю в виде логов:

[14.05.2025 / 15:48:13] Task "sort docs" is completed. Result: 55/64. User is: 1369 [13.05.2025 / 14:42:19] Task "sort docs" is completed. Result: 25/27. User is: 1369 [12.05.2025 / 15:23:18] Task "sort docs" is completed. Result: 49/62. User is: 1369 [09.05.2025 / 16:13:54] Task "sort docs" is completed. Result: 28/36. User is: 1369 [08.05.2025 / 14:11:36] Task "sort docs" is completed. Result: 37/38. User is: 1369 [07.05.2025 / 14:19:32] Task "sort docs" is completed. Result: 2/3. User is: 1369 [02.05.2025 / 14:52:34] Task "sort docs" is completed. Result: 35/41. User is: 1369 [01.05.2025 / 15:56:58] Task "sort docs" is completed. Result: 46/50. User is: 1322 [01.05.2025 / 17:07:21] Task "sort docs" is completed. Result: 15/17. User is: 1369 [30.04.2025 / 17:10:34] Task "sort docs" is completed. Result: 83/96. User is: 1369 [30.04.2025 / 16:54:46] Task "sort docs" is completed. Result: 16/18. User is: 1369 [30.04.2025 / 16:47:57] Task "sort docs" is completed. Result: 86/100. User is: 1369 [25.04.2025 / 16:28:35] Task "sort docs" is completed. Result: 43/50. User is: 1369

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

Возможно в будущем стоит поработать над оптимизацией, так как обработка 1 документа от загрузки с сервера, то загрузки переименованного файла в необходимую папку проходит 20-40 секунд. Из за этого, коллегам приходится иногда выпивать 2 кружки кофе, вместо 1.


ссылка на оригинал статьи https://habr.com/ru/articles/909550/