2025, Nov 15 18:01

Устойчивое распознавание квадратов с диагональю в OpenCV

Как в OpenCV надежно находить квадраты с внутренней диагональю: аппроксимация контуров, поиск перпендикулярных граней и реконструкция четвертой вершины.

Находить квадраты с чистыми границами несложно; сложности начинаются, когда внутри квадрата проведена диагональ — здесь типичный конвейер на контурах часто дает сбой. Если на карте границ присутствует внутренняя диагональ, полученный вами контур может перестать соответствовать внешнему квадрату. Ниже — практичный способ сделать такие детекции устойчивыми, оставаясь в привычном рабочем процессе OpenCV.

Обзор задачи

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

Базовый конвейер, который пропускает коробку с диагональю

Ниже приведен код, который выполняет выделение границ оператором Лапласа, извлекает контуры, фильтрует их по площади и с помощью cv2.minAreaRect проверяет кандидаты по размеру. Логика корректная, но она спотыкается, когда диагональ разбивает фигуру.

def pull_cells(mat, trace: bool = False):
    if trace:
        mat = cv2.imread(mat)
    new_w = 710
    new_h = 710
    mat = cv2.resize(mat, (new_w, new_h))
    gray = cv2.cvtColor(mat, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    lap = cv2.Laplacian(blur, cv2.CV_8U, ksize=5)
    panel = np.ones_like(mat) * 255
    curves, hier = cv2.findContours(lap, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
    print(f"Number of Contours: {len(curves)}")
    tiles = []
    for shp in curves:
        patch = cv2.contourArea(shp)
        if patch < 1900:
            continue
        rr = cv2.minAreaRect(shp)
        pts = cv2.boxPoints(rr)
        pts = np.int32(pts)
        ww, hh = rr[1]
        if 50 < min(ww, hh) < 80 and 50 < max(ww, hh) < 80:
            tiles.append(pts)
            cv2.drawContours(mat, [pts], 0, (0, 255, 0), 2)
            cv2.drawContours(panel, [pts], 0, (0, 0, 255), 2)
    cv2.imshow('lap', lap)
    cv2.imshow('Detected Blocks', mat)
    cv2.imshow('Detected Blocks (White Background)', panel)
    cv2.waitKey(0)
    cv2.destroyAllWindows()

Почему это не работает

После оператора Лапласа каждая выраженная линия превращается в границу — и диагональ тоже. При извлечении контуров получается форма, которая следует по диагонали, фактически деля квадрат на два треугольника. Минимальный ограничивающий прямоугольник, подогнанный к такому контуру, уже не обязан отражать геометрию внешнего квадрата и может не пройти проверку по размеру. Проще говоря, внутренняя диагональ меняет связные компоненты, и целевой квадрат больше не представлен единым «чистым» контуром.

Решение: реконструировать квадрат по перпендикулярным осям

Сохраняем прежнюю предобработку и извлечение контуров. Вместо того чтобы сразу подгонять повернутый прямоугольник, аппроксимируйте каждый контур полигоном и ищите две соседние грани, которые почти перпендикулярны и длины которых укладываются в ваши ограничения по сторонам. Нашли пару — восстановите недостающую четвертую вершину и соберите квадрат, даже если из‑за диагонали контур дал лишь треугольник. Метод опирается только на геометрию, извлеченную из самого контурa.

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

Обновленный метод: реконструкция по осям

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

def pull_cells_axes(mat, trace: bool = False):
    if trace:
        mat = cv2.imread(mat)
    new_w = 710
    new_h = 710
    mat = cv2.resize(mat, (new_w, new_h))
    gray = cv2.cvtColor(mat, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    lap = cv2.Laplacian(blur, cv2.CV_8U, ksize=5)
    panel = np.ones_like(mat) * 255
    curves, hier = cv2.findContours(lap, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
    print(f"Number of Contours: {len(curves)}")
    tiles = []
    for shp in curves:
        patch = cv2.contourArea(shp)
        if patch < 1900:
            continue
        eps = 0.02 * cv2.arcLength(shp, True)
        poly = cv2.approxPolyDP(shp, eps, True)
        for k in range(len(poly)):
            a = poly[k][0]
            b = poly[(k + 1) % len(poly)][0]
            c = poly[(k + 2) % len(poly)][0]
            v1 = b - a
            v2 = c - b
            d1 = np.linalg.norm(v1)
            d2 = np.linalg.norm(v2)
            if 50 < d1 < 80 and 50 < d2 < 80:
                cosang = np.dot(v1, v2) / (d1 * d2)
                ang = np.arccos(cosang) * 180 / np.pi
                if 80 < ang < 100:
                    print(f"Found square with side lengths {d1:.2f} and {d2:.2f} and angle {ang:.2f}")
                    t4 = c - v1
                    t4 = a + v2
                    t4 = t4.astype(int)
                    quad = np.array([a, b, c, t4])
                    tiles.append(quad)
                    cv2.drawContours(mat, [quad], 0, (0, 255, 0), 2)
                    cv2.drawContours(panel, [quad], 0, (0, 0, 255), 2)
                    break
    print(f"Number of Blocks: {len(tiles)}")
    cv2.imshow('lap', lap)
    cv2.imshow('Detected Blocks', mat)
    cv2.imshow('Detected Blocks (White Background)', panel)
    cv2.waitKey(0)
    cv2.destroyAllWindows()

Пример вывода

Number of Contours: 3982
Found square with side lengths 63.20 and 68.03 and angle 87.15
Found square with side lengths 68.12 and 65.19 and angle 91.03
Found square with side lengths 63.63 and 67.74 and angle 90.92
Found square with side lengths 66.76 and 65.80 and angle 89.75
Number of Blocks: 4

Почему это важно

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

Итоги

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