Дисклеймер

Я не являюсь хорошим спиециалистом в области программирования на 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.