Определение контуров отсканированных фотографий при помощи OpenCV


Опубликовано 2017.01.13


После долгого и муторного сканирования семейного фотоархива образовалась огромная куча “цифрового полуфабриката”.

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

Ниже пойдет речь о том, как это можно сделать при помощи Python и OpenCV.

В качестве примеров исходных данных для демонстрации были взяты 2 скана, на которых реальные семейные фотографии заменены на нейтральные.

Рис.1: Пример А - разворот страницы фотоальбома
Рис.2: Пример Б - просто несколько фотографий
Рис.3: Пример А - контуры найденные при помощи OpenCV
Рис.4: Пример Б - контуры найденные при помощи OpenCV
Рис.5: Пример А - итоговые контуры фотографий
Рис.6: Пример Б - итоговые контуры фотографий

Скрипт с подробным описанием работы алгоритма:

#!/usr/bin/python2
# -*- coding: utf-8 -*-
#
# Copyright 2017, Durachenko Aleksey V. <durachenko.aleksey@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import argparse
import cv2
import os


# исходное изображение очень большое: приблизительно 10x14 тысяч пикселей.
# во-первых, выделение контуров на таком большом изображении займет много
# времени, а во-вторых на нем будет слишком много избыточной информации. 
# необходимы же только контуры больших фигур - фотографий, поэтому изображение
# можно смело уменьшать.
image_scale_factor = 0.1        # 1.0 - 100% изображения

# на рис.3 видно, что OpenCV решил выделить контур альбомного листа, что
# в нашем случае совершенно неприемлемо. для исключения таких ситуаций
# вводится ограничение на максимальный размер ширины и высоты контура фотографии.
contour_maximum_width_factor = 0.8      # 1.0 - 100% изображения
contour_maximum_height_factor = 0.8     # 1.0 - 100% изображения

# после работы алгоритма слияния контуров(о нем пойдет речь ниже)
# могут появиться области, которые ошибочно приняты за контуры фотографий.
# на рис.5 хорошо видно такие области. чтобы их исключить вводится
# правило, по которому контур фотографии не может быть меньше определенного 
# размера
contour_minimum_width_factor = 0.1      # 1.0 - 100% изображения
contour_minimum_height_factor = 0.1     # 1.0 - 100% изображения

# найденные контуры фотографий будет расширены на указанное кол-во пикселей
# во все стороны
contour_additional_pixels = 50

# цвет контуров на диагностических изображениях
contour_color = (0, 255, 0,)
# ширина линий контуров на диагностических изображениях
contour_width = 2

# параметры фильтрации и детектора контуров (примера А)
#gaussian_blur_size = (7, 7)
#canny_threshold_1 = 15
#canny_threshold_2 = 140

# параметры фильтрации и детектора контуров (примера Б)
gaussian_blur_size = (3, 3) 
canny_threshold_1 = 5
canny_threshold_2 = 100


# прямоугольник, описывающий контур фотографии
class MyRect:
    x1 = 0
    y1 = 0
    x2 = 0
    y2 = 0

    def __init__(self, x1=0, y1=0, x2=0, y2=0):
        self.x1 = x1
        self.y1 = y1
        self.x2 = x2
        self.y2 = y2

    def __str__(self):
        return "((%d, %d), (%d, %d))" % (self.x1, self.y1, self.x2, self.y2,)

    def __repr__(self):
        return self.__str__()

    # проверка пересечения двух прямоугольников
    def overlaps(self, rect):
        if rect.x1 <= self.x2 and rect.x2 >= self.x1 and rect.y1 <= self.y2 and rect.y2 >= self.y1:
            return True
        return False

    # слияние двух прямоугольников. итоговый прямоугольник будет полностью
    # вписывать два исходных
    def join(self, rect):
        result = MyRect()
        result.x1 = min(rect.x1, self.x1)
        result.y1 = min(rect.y1, self.y1)
        result.x2 = max(rect.x2, self.x2)
        result.y2 = max(rect.y2, self.y2)
        return result


# алгоритм слияния контуров найденных OpenCV в итоговые контуры фотографий.
# его суть заключается в том, что для каждого контура строится прямоугольник
# полностью вписывающий его, а также слияние всех пересекающихся прямоугольников. 
# в итоге должны получиться прямоугольники, описывающие контуры фотографий.
# так же алгоритм учитывает особый случай: слишком большие контуры
# (вероятно, контуры листа альбома) игнорируются
# примеры слияния: 
# контуры рис.3 в прямоугольники рис.5, 
# контуры рис.4 в прямоугольники рис.6
def merge_contours(contours):
    rectangles = []
    for contour in contours:
        if len(contour) > 2:
            x1 = x2 = contour.tolist()[0][0][0]
            y1 = y2 = contour.tolist()[0][0][1]
            for item in contour.tolist():
                x = item[0][0]
                y = item[0][1]
                x1 = min(x, x1)
                y1 = min(y, y1)
                x2 = max(x, x2)
                y2 = max(y, y2)
                        
            if ((x2 - x1) / image_scale_factor > image_width * contour_maximum_width_factor 
                or (y2 - y1) / image_scale_factor > image_height * contour_maximum_height_factor):
                continue;
            
            # add rect to rectangles and compact them
            rect = MyRect(x1, y1, x2, y2)
            while True:
                new_rect_arr = []
                found = False
                for tmp_rect in rectangles:
                    if rect.overlaps(tmp_rect):
                        rect = rect.join(tmp_rect)
                        found = True
                    else:
                        new_rect_arr.append(tmp_rect)
                rectangles = new_rect_arr
                if not found:
                    break
            rectangles.append(rect)            
    return rectangles


# сохранение изображения с выделенными прямоугольниками фотографий
def write_image_with_rects(filename, image, rectangles):
    contours = []
    for rect in rectangles:
        contours.append(np.array([[rect.x1, rect.y1], [rect.x1, rect.y2], [rect.x2, rect.y2], [rect.x2, rect.y1]]))
    cv2.drawContours(image, contours, -1, contour_color, contour_width)
    cv2.imwrite(filename, image)


# сохранение изображений с выделенными контурами OpenCV
def write_image_with_conts(filename, image, contours):
    cv2.drawContours(image, contours, -1, contour_color, contour_width)
    cv2.imwrite(filename, image)


# чтение аргументов командной строки
appargs = argparse.ArgumentParser()
appargs.add_argument("-i", "--image", required=True, help="path to the image")
args = vars(appargs.parse_args())
# имя исходного файла
image_filename = args["image"]
# базовое имя файла без расширения
image_basefilename = os.path.splitext(os.path.basename(image_filename))[0]
# имя файла с отмеченными итоговыми контурами фотографий
image_rects_filename = image_basefilename + "_rects.tiff"
# имя файла с отмеченными контурами найденными OpenCV
image_conts_filename = image_basefilename + "_conts.tiff"
# чтение исходного изображения с диска
image = cv2.imread(image_filename)
# геометрия исходного изображения
image_height, image_width, image_channels = image.shape
# изменение размера изображения
image = cv2.resize(image, (0, 0), fx=image_scale_factor, fy=image_scale_factor)
# перевод изображения в Ч\Б
image_prepared = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# размывание, чтобы скрыть мелкие детали (иначе они будут ошибочно приняты за конутры)
image_prepared = cv2.GaussianBlur(image_prepared, gaussian_blur_size, 0)
# поиск контуров на изображении
image_prepared = cv2.Canny(image_prepared, canny_threshold_1, canny_threshold_2)
(contours, _) = cv2.findContours(image_prepared, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# слияние найденных контуров в итоговые контуры фотографий
rectangles = merge_contours(contours)
# печать кол-ва найденных контуров фотографий на экран (включая ложные)
print("# %s has %s images" % (image_filename, len(rectangles),))
# запись диагностических изображений с контурами на диск
write_image_with_conts(image_conts_filename, image.copy(), contours)
write_image_with_rects(image_rects_filename, image.copy(), rectangles)
# вывод на экран команд для вырезания фотографий по координатам их контуров
n = 1
for rect in rectangles:
    x1 = rect.x1 / image_scale_factor
    x2 = rect.x2 / image_scale_factor
    y1 = rect.y1 / image_scale_factor
    y2 = rect.y2 / image_scale_factor
    # слишком маленькие контуры фотографий игнорируются (считаются ложными)
    if (x2 - x1 >= image_width * contour_minimum_width_factor
        and y2 - y1 >= image_height * contour_minimum_height_factor):
        x1 -= contour_additional_pixels
        x2 += contour_additional_pixels
        y1 -= contour_additional_pixels
        y2 += contour_additional_pixels
        if x1 < 0:
            x1 = 0
        if y1 < 0:
            y1 = 0
        if x2 >= image_width:
            x2 = image_width - 1
        if y2 >= image_width:
            y2 = image_height - 1
        w = x2 - x1
        h = y2 - y1
        print("convert %s -crop %dx%d+%d+%d %s_%d.%s" % (image_filename, w, h, x1, y1, image_basefilename, n, "ppm",))
        n += 1

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

# img1.pnm has 6 images
convert img1.pnm -crop 4650x7410+5350+6620 img1_1.pnm
convert img1.pnm -crop 4590x7440+650+6590 img1_2.pnm
convert img1.pnm -crop 4220x5580+870+460 img1_3.pnm
convert img1.pnm -crop 4440x5890+5590+430 img1_4.pnm
# img2.pnm has 13 images
convert img2.pnm -crop 4740x6330+180+7700 img2_1.pnm
convert img2.pnm -crop 4490x6350+5030+7680 img2_2.pnm
convert img2.pnm -crop 4540x5870+5120+1470 img2_3.pnm
convert img2.pnm -crop 4450x5980+340+1350 img2_4.pnm

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

Ссылки