Время прочтения: 5 мин.

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

Его работу можно глобально разложить на два этапа – обрезание документа на фото, т.е. избавление от всего лишнего и его «бинаризация» – преобразование пикселей к двум возможным значениям: «0» – пиксель черный, «255» – пиксель белый.

Сначала импортируем все необходимые библиотеки:

import numpy as np
import cv2 as cv
from PIL import Image
from PIL import ImageEnhance

Далее откроем изображение, которое мы хотим обработать с помощью следующей строки кода:

img = cv.imread('doc.jpg')

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

Запишем в отдельную переменную изображение в первоначальном виде, чтобы не потерять его при преобразованиях, с помощью метода copy():

first_img = img.copy()

Обрезание документа на изображении

Чтобы наш алгоритм правильно определял границы документа необходимо произвести некоторые преобразования с изображения:

  1. Для улучшения точности алгоритма нужно увеличить контраст изображения. В OpenCV нет встроенного метода для этой операции, но в библиотеке PIL – есть, именно для этого мы ее и импортировали.
pil_image_1 = Image.fromarray(img)
img = ImageEnhance.Contrast(pil_image_1).enhance(1.6)

При считывании изображения из файла через OpenCV он записывается в формате numpy.array, следовательно, к нему можно применять встроенные методы библиотеки numpy. Но библиотека PIL с этим типом данных не работает, поэтому необходимо сначала перевести массив numpy в тип данных класса Image. К преобразованному изображению уже применяем усилитель Contrast и вызываем метод enhance, в который в качестве параметра передается процент увеличения контраста, где 1.6 = 160%.  Для дальнейшей работы с изображением преобразуем его обратно в массив:

img = np.array(img)

2. Если изображение имеет высокое разрешение (намного больше, чем 1280 пикселей), необходимо его уменьшить. Это делается для того, чтобы алгоритм «сконцентрировался» на крупном объекте (документ), и игнорировал мелкие детали, которые могут привести к неточности работы.

img = cv.resize(img, (1024, 1280))

В этом примере изображение уменьшено примерно в три раза. Размер вашего изображения можно проверить командой, которая выведет размеры в формате (w, h, n):

print(first_img.shape)

Где w – ширина, h – высота, n – количество каналов. Для цветного изображения n =3. Для того, чтобы после всех преобразований применить результат алгоритма к первоначальному изображению, необходимо вычислить коэффициенты масштабирования изображений:

x_ratio = first_img.shape[0] / img.shape[0]
y_ratio = first_img.shape[1] / img.shape[1]

Представим изображение в градациях серого, размоем его фильтром Гаусса и проведем бинаризацию изображения для того, чтобы отделить объект от фона:

gray = cv.cvtColor(img,cv.COLOR_BGR2GRAY)
blurred = cv.GaussianBlur(gray, (7, 7), 0)
thresh = cv.threshold(blurred, 135, 255, cv.THRESH_BINARY)[1]

(7,7) – ядро фильтра, отвечает за то, насколько сильно нужно размыть изображение. Параметры 135, 255 – пороговые значения, показывающие, что пиксели со значением яркости в интервале (135; 255) отнесутся к белому цвету, а в интервале (0; 135) – к черному.

Этапы предобработки представлены на следующих изображениях:

Чтобы обрезать наш документ, необходимо определить его границы. Для этих целей используем метод библиотеки OpenCV — goodFeaturesToTrack, который находит углы на изображении. Он принимает на вход массив с изображением, максимальное число детектируемых углов, уровень качества углов и минимальную дистанцию между углами, а на выходе формирует массив с координатами центров углов.

corners = cv.goodFeaturesToTrack(thresh,20,0.05,200)

Элементы массива получились с плавающей точкой, поэтому дабы избежать ошибок в будущем, приведем их все к целочисленному формату.

corners = np.int0(corners)

Можно проверить верно ли были найдены углы с помощью следующего фрагмента кода:

for i in corners:
    x,y = i.ravel()
    cv.circle(img,(x,y),20,255,-1)
cv.imshow(‘corners’, img)
cv.waitKey(0)

Результат поиска всех углов на изображении:

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

left_x = 10000
left_y = 10000
right_x = 0
right_y = 0
for corner in corners:
if corner[0][0] <= left_x:
        left_x = corner[0][0]
    elif corner[0][0] >= right_x:
        right_x = corner[0][0]
    if corner[0][1] <= left_y:
        left_y = corner[0][1]
    elif corner[0][1] >= right_y:
        right_y = corner[0][1]

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

left_x = int(left_x * x_ratio)
left_y = int(left_y * y_ratio)
right_x = int(right_x * x_ratio)
right_y = int(right_y * y_ratio)

Наложение на изображение эффекта скана и отображение результатов

Обрезаем первоначальное изображение по вычисленным координатам и заново бинаризируем его, с целью получения эффекта скана.

crop = first_img[left_y:right_y, left_x:right_x]
thresh2 = cv.threshold(crop, 50, 255, cv.THRESH_BINARY)[1]
cv.imshow('1', thresh2)
cv.waitKey(0)

Визуализируем полученное изображение, и вуаля!

В данном алгоритме присутствует много настраиваемых параметров, так что вы сможете перенастроить их под конкретные обстоятельства – освещение, фон и т.д.

Алгоритм легко обобщается для обработки нескольких фотографий. Для этого достаточно обернуть код алгоритма в цикл по названию фотографии, например, так:

import os
for img_name in [ filename for filename in os.listdir(os.getcwd()) if filename.endswith('.jpg')]:

В метод cv.imread() теперь нужно передавать не название одной фотографии, а переменную цикла:

img = cv.imread(img_name)

При такой модификации, вместо набора плохо сфотографированных документов, мы получим набор выровненных и приведенных к единому размеру чёрно-белых скан-копий.

Надеюсь, статья была полезна!