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