Дисклеймер
Я не являюсь хорошим спиециалистом в области программирования на Python. Возможно какие-то мои решения вызовут бунт и недовльство у опытных senior и middle разработчиков. Попрошу таких комментаторов воздержаться от своего мнения относительно моего подхода к решению данной задачи. Спасибо!
Поговорим о ТЗ
Необходимо было разработать API сервис (не важно на каком ЯП), который мог принимать в себя .pdf документ, выполнять какую-то процедуру по извлечению из него необходимых данных, возвращать их в каком-то формате. Конкретнее: есть сертификат экспорта авто из Японии в РФ. На этом сертификате есть параметр «Номер кузова авто». Необходимо его извлечь из документа, прочитать с помощью машинного зрения, проверить данное значение по базе данных организации. В случае успешной операции — положить файл на ftp сервер, переименовав его в идентификатор записи с БД.
Данный документ представлял с собой обычный скан в виде изображения в формате .pdf (с него нельзя копировать текст, путем выделения его мышью). Добавляло сложности в поиске решения задачи добавлялось то, что таких документов всего было 3 типа. И в каждом типе — положение необходимой ячейки с номером кузова было отличным от другого. Плюс, так как — это был скан из МФУ, нельзя было рассчитать точное положение только исходя из типа документа, по причине человеческого фактора(при сканировании документ можно прижать ближе к верхнему правому краю лотка, так и к нижнему левому краю + угол смещения).
Первые шаги
Необходимо было составить четкий план для того, чтобы прийти к оптимальному решению. Я составил следующий алгоритм решения задачи:
-
Ввод. Получаем документ в формате .pdf
-
Конвертация .pdf => .png для дальнейшей работы с изображением
-
Определение типа документа, для поиска дальнейших координат обрезки изображения
-
Обрезка рабочей области по заданным координатам
-
Чтение текста из рабочей области
-
Работа с БД на основе прочтенного текста
-
Вывод. В случае успеха — переименовываем файл и кладем на сервер исходный .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/
Добавить комментарий