2025, Oct 17 19:18
Отделение внешней границы от содержимого: связные компоненты в OpenCV
Метод для чёрно-белых изображений: отделяем внешнюю границу от внутренних фигур без инпейнтинга, сохраняя толщину штриха на основе связных компонент в OpenCV.
Отделить внешнюю границу от всего, что внутри, — типичная задача при работе с черно-белой графикой. Частые случаи: толстая рамка вокруг содержимого или контур ладони с внутренними деталями. Цель — выделить границу отдельно от внутренних фигур, не искажая толщину линий и не оставляя артефактов.
Где простой подход дает сбой
Первое, что приходит в голову, — найти самый крупный внешний контур и удалить его. Для рамок это кажется разумным, но как только внешняя форма перестаёт быть прямоугольной, метод начинает хромать и часто оставляет следы от инпейнтинга. Ниже компактный пример такого подхода:
import cv2
import numpy as np
def strip_frame_only(input_file,
                     frame_px=20,
                     patch_r=3):
    """
    Удалить только внешние линии рамки толщиной `frame_px`,
    сохранив внутреннее содержимое.
    Аргументы:
        input_file (str): Путь к входному изображению.
        frame_px (int): Примерная толщина внешней границы в пикселях.
        patch_r (int): Радиус для алгоритма инпейнтинга.
    Возвращает:
        np.ndarray: Изображение, где удалена только рамка.
    """
    bgr = cv2.imread(input_file)
    if bgr is None:
        raise FileNotFoundError(f"Cannot read: {input_file}")
    grayImg = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    thr = cv2.adaptiveThreshold(
        grayImg, 255,
        cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY_INV,
        blockSize=51, C=10)
    cnts, _ = cv2.findContours(
        thr, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not cnts:
        return bgr.copy()
    biggest = max(cnts, key=cv2.contourArea)
    filledMask = np.zeros_like(grayImg)
    cv2.drawContours(filledMask, [biggest], -1, 255, thickness=cv2.FILLED)
    kk = frame_px // 2 * 2 + 1
    element = cv2.getStructuringElement(cv2.MORPH_RECT, (kk, kk))
    edgeMask = cv2.morphologyEx(filledMask, cv2.MORPH_GRADIENT, element)
    cleaned = cv2.inpaint(bgr, edgeMask, patch_r, cv2.INPAINT_TELEA)
    return cleaned
Этот путь опирается только на внешние контуры и оценивает толщину через морфологический градиент. На практике он часто не сохраняет точную толщину штриха внешней границы и может размывать края при инпейнтинге. Кроме того, он плохо обобщается на контуры вроде ладоней и другие непрямоугольные формы.
Что здесь происходит на самом деле
Когда извлекаются только внешние контуры, внутренние фигуры выпадают из иерархии контуров. В итоге вы находите «что-то внешнее», но теряете сведения о вложенных областях. В паре с инпейнтингом метод скорее стирает пиксели, чем семантически разделяет слои. Выбор ядра лишь приблизительно задаёт толщину, но очертания различаются, и этап инпейнтинга может оставлять заметные артефакты. Часто рекомендуют вместо RETR_EXTERNAL изучать иерархию контуров, использовать заливку (flood fill), если изображения строго чёрно-белые, и проверять метод на более сложных примерах, прежде чем делать его частью стандартного пайплайна.
Надёжный приём, который работает не только с прямоугольниками
Куда стабильнее подход: считать всё не белое частью «фигуры» и находить связные компоненты. Первый небелый пиксель в порядке чтения обязательно принадлежит самой внешней фигуре, потому что любая внутренняя область встретится только после того, как её внешняя граница уже появилась. Так можно разделить изображение на внешнюю фигуру и всё остальное без подбора толщины и без инпейнтинга. Предполагается белый фон и небелое содержимое.
import cv2
import numpy as np
src = cv2.imread("shapeToSeparate.jpg")
if src is None:
    raise FileNotFoundError("Cannot read input image")
g = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)
# 1 для пикселей фигуры, 0 для фона (предполагается белый фон)
mask01 = (g < 250) * np.uint8(1)
# Первый ненулевой в порядке сканирования принадлежит внешней фигуре
seed_idx = np.argmax(mask01)
# Маркируем связные компоненты на бинарной маске
n_labels, labels = cv2.connectedComponents(mask01)
# Значение метки внешней компоненты
outer_id = labels.ravel()[seed_idx]
# Булева маска внешней компоненты
outer_mask = labels == outer_id
# Визуализация рядом: внешняя слева, внутренняя справа
h, w, ch = src.shape
canvas = np.full((h, 2 * w, ch), 255, dtype=np.uint8)
canvas[:, :w][outer_mask] = src[outer_mask]
canvas[:, w:][~outer_mask] = src[~outer_mask]
Такой подход отделяет внешнюю границу от всего внутреннего содержимого и при этом сохраняет исходную толщину штрихов, поскольку не применяются разрушающие операции вроде инпейнтинга. Результат помещается на белое полотно вдвое шире исходного: слева рисуется внешняя фигура, справа — всё остальное. Вы можете изменить способ вывода двух частей; важно, что сегментация строится на связности и чётко определённой внешней компоненте, а не на догадках о контурах или ширине морфологических кромок.
Почему это важно
Когда входные данные — строго чёрно-белые композиции, надёжное разделение внешнего контура и внутреннего содержимого обеспечивает предсказуемую последующую обработку. Например, при работе с шаблонами дизайна, наложенными на ладони, пальцы или щёки, важно чисто отделить обрамляющий контур от рисунка внутри. Подход со связными компонентами даёт стабильный результат как для прямоугольников, так и для контуров рук и других сложных силуэтов — при условии белого фона и трактовки всего небелого как части фигуры. Этот метод также продемонстрирован на более сложных композициях, не ограничиваясь случаем простой прямоугольной рамки.
Практические выводы
Если фон белый, а «чернила» небелые, выбирайте решение на основе связности, а не эвристики только внешнего контура и инпейнтинга. Оно сохраняет толщину линий без смазывания и хорошо работает с непрямоугольными контурами. Если позже понадобится выйти за рамки предположения о белом фоне или учесть более сложные иерархии вложенных областей, изучите анализ иерархии контуров или подход с заливкой, ориентированный на строго чёрно-белые входы, и подтвердите работоспособность на более широком наборе типичных изображений, прежде чем фиксировать конвейер.
Иными словами, заранее определите, что считать «фигурой», а что — фоном, найдите настоящую внешнюю фигуру через связные компоненты и разделяйте результат, не трогая нужные пиксели. Так кромки остаются чёткими, а поведение — предсказуемым, даже когда внешняя граница — не простая рамка.