Классическая обработка изображений в OpenCV

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

Цветовые пространства и сегментация по диапазонам: HSV, Lab и битовые маски

Цветовые пространства и сегментация по диапазонам: HSV, Lab и битовые маски

Попробуйте выделить спелый красный помидор на фоне зеленых листьев с помощью порогов по каналам RGB. При идеальном студийном освещении условие «много красного, мало зеленого» сработает. Но стоит включить боковой направленный фонарь — и на освещенной стороне помидора значения RR, GG и BB взлетят почти до 255 (блик станет белым). А на затененной стороне все три канала упадут до значений вроде (40,5,5)(40, 5, 5), смешавшись по яркости с темной землей на заднем плане.

В цветовой модели RGB (и BGR в OpenCV) хроматическая информация (сам оттенок) намертво сцеплена с фотометрической (яркостью освещения). Изменение освещенности масштабирует вектор [B,G,R][B, G, R] во всем трехмерном пространстве. Чтобы компьютерное зрение устойчиво распознавало цвета под солнцем, в тени или под люминесцентной лампой, необходимы цветовые пространства, в которых яркость изолирована от хроматической составляющей.

Почему RGB непригоден для цветовой сегментации

Пространство RGB представляет собой трехмерный куб, где координаты R,G,B[0,255]R, G, B \in [0, 255] определяют интенсивность каждого базового излучателя. Когда интенсивность источника света меняется, точка в этом кубе смещается вдоль луча, проведенного из начала координат (0,0,0)(0, 0, 0).

Если вы зададите фиксированные границы в RGB:

# Попытка выделить чистый красный цвет в BGR
lower_bound = np.array([0, 0, 150], dtype=np.uint8)
upper_bound = np.array([80, 80, 255], dtype=np.uint8)

вы вырежете жесткий параллелепипед внутри куба. При этом:

  • Любая глубокая тень на объекте выведет пиксели ниже границы lower_bound по каналу RR.
  • Любой яркий блик добавит значения в каналы BB и GG, выбросив пиксели за пределы upper_bound.

Чтобы изолировать цвет от условий освещения, классическое машинное зрение использует альтернативные системы координат.

Цветовое пространство HSV: тон, насыщенность и яркость

Модель HSV (Hue, Saturation, Value — цветовой тон, насыщенность, значение яркости) переводит кубическую декартову систему в цилиндрические координаты:

  • Hue (HH, Цветовой тон): угол на цветовом круге, задающий чистый спектральный цвет (красный, желтый, зеленый, голубой, синий, пурпурный). В классической колориметрии измеряется в градусах от 00^\circ до 360360^\circ.
  • Saturation (SS, Насыщенность): удаленность от центральной оси (степень разбавления цвета белым). 00 — серый/ахроматический, максимум — спектрально чистый оттенок.
  • Value (VV, Яркость): высота вдоль вертикальной оси цилиндра. 00 — абсолютная темнота (черный), максимум — максимально светлый оттенок при заданной насыщенности.
       Красный (0°)
   Пурпурный \  / Желтый
      (300°)  \/  (60°)
              /\
     Синий   /  \ Зеленый
     (240°) /    \ (120°)
        Голубой (180°)

Критическая особенность OpenCV: Стандартный тип данных для изображений — uint8, максимальное значение которого равно 255. Чтобы вместить угловой диапазон 03600^\circ \dots 360^\circ в один байт, разработчики OpenCV разделили угол на 2:

HOpenCV=Hdegrees2H_{\text{OpenCV}} = \frac{H_{\text{degrees}}}{2}

В OpenCV диапазон канала HH составляет от 00 до 179179. Каналы SS и VV нормализованы к диапазону 02550 \dots 255.

Если оттенок зеленого равен 120120^\circ, в массиве OpenCV ему будет соответствовать значение H=60H = 60. Если вы зададите порог H=240H = 240, алгоритм вернет пустой результат, так как максимальное значение в OpenCV — 179179.

Конвертация выполняется вызовом cv2.cvtColor:

import cv2

# Загрузка кадра и перевод из BGR в HSV
hsv_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2HSV)

Проблема красного сектора в HSV

Поскольку шкала HH представляет собой замкнутый круг (03600^\circ \equiv 360^\circ), красный сектор оказывается «разорванным» пополам на границе шкалы:

  • Начало спектра: 0100^\circ \dots 10^\circ (в OpenCV: H[0,5]H \in [0, 5]).
  • Конец спектра: 350360350^\circ \dots 360^\circ (в OpenCV: H[175,179]H \in [175, 179]).

Для надежного выделения красного цвета всегда требуется объединять два раздельных диапазона.

Цветовое пространство CIELAB (Lab): перцептивная равномерность

Пространство Lab создано Международной комиссией по освещению (CIE) с целью математически смоделировать нелинейность зрительного восприятия человека:

  • LL^* (Lightness): перцептивная яркость от 0 (черный) до 100 (белый). В OpenCV масштабируется в диапазон 02550 \dots 255 по формуле LOpenCV=L×255100L_{\text{OpenCV}} = L^* \times \frac{255}{100}.
  • aa^*: хроматическая ось «зеленый \dots красный». Отрицательные значения соответствуют зеленому, положительные — красному/пурпурному. В OpenCV смещается: aOpenCV=a+128a_{\text{OpenCV}} = a^* + 128.
  • bb^*: хроматическая ось «синий \dots желтый». Отрицательные значения соответствуют синему, положительные — желтому. В OpenCV: bOpenCV=b+128b_{\text{OpenCV}} = b^* + 128.
Параметр HSV CIELAB (Lab)
Геометрия пространства Цилиндр (углы и радиусы) Декартово ортогональное пространство
Физический смысл осей Тон (HH), Чистота (SS), Яркость (VV) Яркость (LL), Зеленый-Красный (aa), Синий-Желтый (bb)
Перцептивная равномерность Низкая (евклидово расстояние не отражает различимость цветов глазом) Высокая (одинаковое евклидово расстояние ΔE\Delta E означает одинаковую видимую разницу)
Диапазон в OpenCV H[0,179]H \in [0, 179], S[0,255]S \in [0, 255], V[0,255]V \in [0, 255] L,a,b[0,255]L, a, b \in [0, 255]
Основное применение Сегментация объектов по оттенку при умеренных изменениях света Промышленный контроль качества цвета, цветокоррекция, сегментация при неравномерном освещении

Преобразование выполняется аналогично:

lab_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2LAB)

Пороговая фильтрация диапазонов: cv2.inRange

Чтобы локализовать пиксели, попадающие в заданный цветовой интервал, используется функция cv2.inRange. Она выполняет поэлементную проверку массива:

dst(y,x)={255,если lower(c)src(y,x,c)upper(c)c{0,1,2}0,иначе\text{dst}(y, x) = \begin{cases} 255, & \text{если } \text{lower}(c) \leq \text{src}(y, x, c) \leq \text{upper}(c) \quad \forall c \in \{0, 1, 2\} \\ 0, & \text{иначе} \end{cases}

Функция возвращает одноканальное 8-битное бинарное изображение (маску), где 255 обозначает пиксели, удовлетворяющие всем условиям, а 0 — пиксели фона.

import cv2
import numpy as np

# Выделение желтого объекта в пространстве HSV
# Желтый цвет в углах: ~60 градусов -> в OpenCV H ~ 30
lower_yellow = np.array([20, 100, 100], dtype=np.uint8)
upper_yellow = np.array([35, 255, 255], dtype=np.uint8)

mask_yellow = cv2.inRange(hsv_image, lower_yellow, upper_yellow)

Здесь границы S и V намеренно ограничены снизу значением 100: это предотвращает ошибочное включение белых, серых и слишком темных пикселей, где координата тона HH нестабильна.

Битовые операции и наложение масок

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

  • cv2.bitwise_and(src1, src2, mask=mask): логическое И (src1src2\text{src1} \land \text{src2}). Если указан аргумент mask, операция выполняется только для тех пикселей, где mask(y,x)0\text{mask}(y, x) \neq 0.
  • cv2.bitwise_or(src1, src2): логическое ИЛИ (src1src2\text{src1} \lor \text{src2}). Используется для объединения нескольких бинарных масок.
  • cv2.bitwise_not(src): логическое НЕ (¬src\neg \text{src}). Инвертирует маску (0255,25500 \to 255, 255 \to 0).

Практический пример: сегментация красного объекта

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

import cv2
import numpy as np

def extract_red_object(bgr_frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """
    Выделяет красные зоны на изображении и возвращает бинарную маску
    вместе с изолированным цветным объектом на черном фоне.
    """
    # 1. Перевод в цветовое пространство HSV
    hsv = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2HSV)

    # 2. Нижний диапазон красного цвета (0 - 10 в терминах OpenCV)
    lower_red_1 = np.array([0, 120, 70], dtype=np.uint8)
    upper_red_1 = np.array([10, 255, 255], dtype=np.uint8)
    mask1 = cv2.inRange(hsv, lower_red_1, upper_red_1)

    # 3. Верхний диапазон красного цвета (170 - 179 в терминах OpenCV)
    lower_red_2 = np.array([170, 120, 70], dtype=np.uint8)
    upper_red_2 = np.array([179, 255, 255], dtype=np.uint8)
    mask2 = cv2.inRange(hsv, lower_red_2, upper_red_2)

    # 4. Объединение двух масок логическим ИЛИ
    full_mask = cv2.bitwise_or(mask1, mask2)

    # 5. Применение маски к исходному цветному изображению
    # Пиксели, где full_mask == 0, станут черными [0, 0, 0]
    extracted_foreground = cv2.bitwise_and(bgr_frame, bgr_frame, mask=full_mask)

    return full_mask, extracted_foreground

Вызов cv2.bitwise_and(bgr_frame, bgr_frame, mask=full_mask) выполняет побитовое умножение кадра самого на себя исключительно в тех позициях, где маска равна 255. Фоновые пиксели гарантированно зануляются.

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

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

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

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

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

Математический фундамент: двумерная дискретная свертка

Фундаментальный инструмент пространственной обработки — операция дискретной двумерной свертки (2D convolution). В контексте компьютерного зрения свертка представляет собой наложение небольшой числовой матрицы — ядра свертки (kernel) — на каждый локальный фрагмент исходного изображения.

Ядро свертки обычно имеет нечетный размер (3×33 \times 3, 5×55 \times 5, 7×77 \times 7), чтобы у него существовал строго определенный центральный элемент — якорь (anchor point).

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

Математически для изображения II и ядра KK размером (2k+1)×(2k+1)(2k+1) \times (2k+1) значение пикселя в координатах (x,y)(x, y) после применения фильтра вычисляется по формуле:

Ifiltered(x,y)=i=kkj=kkI(x+i,y+j)K(i+k,j+k)I_{\text{filtered}}(x, y) = \sum_{i=-k}^{k} \sum_{j=-k}^{k} I(x + i, y + j) \cdot K(i + k, j + k)

Где:

  • I(x+i,y+j)I(x + i, y + j) — значение яркости исходного пикселя со смещением относительно центра окна на ii строк и jj столбцов;
  • K(i+k,j+k)K(i + k, j + k) — фиксированный весовой коэффициент ядра, соответствующий этому смещению;
  • kk — радиус окрестности (для ядра 3×33 \times 3 значение k=1k = 1, сумма берется от 1-1 до +1+1).

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

Краевые эффекты и стратегии дополнения (Border Padding)

Когда центральный якорь ядра находится на граничных пикселях (в первой строке или крайнем столбце), часть окна выходит за пределы матрицы изображения. Чтобы свертка оставалась корректной, OpenCV использует различные стратегии дополнения границ (padding):

  • cv2.BORDER_CONSTANT — область за границей заполняется фиксированным числом (например, нулями);
  • cv2.BORDER_REPLICATE — крайний пиксель дублируется во внешнюю зону (aabcdda|a b c d|d);
  • cv2.BORDER_REFLECT_101 (по умолчанию в большинстве функций) — зеркальное отражение границы без дублирования крайнего элемента (cbabcdcbc b|a b c d|c b).

Универсальный инструмент применения произвольного ядра в OpenCV — функция cv2.filter2D.

import cv2
import numpy as np

# Загрузка полутонового изображения
image = cv2.imread("circuit_board.png", cv2.IMREAD_GRAYSCALE)

# Создание усредняющего ядра 3x3 с нормализацией
kernel_3x3 = np.ones((3, 3), dtype=np.float32) / 9.0

# Применение произвольной свертки
blurred = cv2.filter2D(
    src=image,
    ddepth=-1,  # глубина цвета результата совпадает с исходной (uint8)
    kernel=kernel_3x3,
    borderType=cv2.BORDER_REFLECT_101,
)

Обратите внимание на делитель 9.09.0 в определении ядра. Это нормализация: сумма всех коэффициентов ядра сглаживания должна быть строго равна 1.01.0. Если сумма коэффициентов окажется больше единицы, изображение станет пересвеченным, если меньше — затемненным.

Линейные сглаживающие фильтры

Сглаживание (размытие) решает задачу подавления высокочастотного аддитивного шума за счет перераспределения локальной энергии пикселей.

1. Усредняющий фильтр (Box Blur)

Простейший фильтр усреднения заменяет каждый пиксель средним арифметическим его соседей. Все элементы ядра имеют одинаковый вес. В OpenCV для этого предназначены функции cv2.blur и cv2.boxFilter:

# Быстрое усреднение прямоугольным окном 5x5
box_blurred = cv2.blur(image, (5, 5))

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

2. Гауссов фильтр (Gaussian Blur)

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

Двумерная функция Гаусса задается выражением:

G(x,y)=12πσ2exp(x2+y22σ2)G(x, y) = \frac{1}{2\pi\sigma^2} \exp\left(-\frac{x^2 + y^2}{2\sigma^2}\right)

Где:

  • xx и yy — пространственные координаты относительно центра ядра;
  • σ\sigma (сигма) — стандартное отклонение распределения, контролирующее ширину колокола Гаусса (степень размытия);
  • π\pi — математическая константа.

Если σ\sigma велико, колокол становится пологим, а веса распределяются равномернее — размытие усиливается. При малом σ\sigma центральный пиксель доминирует, сохраняя резкость сцены.

# Гауссово размытие с ядром 5x5 и автоматическим расчетом sigma
gaussian_blurred = cv2.GaussianBlur(image, ksize=(5, 5), sigmaX=1.5)

Если передать sigmaX=0, OpenCV автоматически рассчитает среднеквадратичное отклонение из размера ядра по формуле σ=0.3((ksize1)0.51)+0.8\sigma = 0.3 \cdot ((ksize - 1) \cdot 0.5 - 1) + 0.8.

Гауссов фильтр математически оптимален для подавления аддитивного белого гауссова шума (AWGN), возникающего из-за тепловых флуктуаций матрицы камеры.

Нелинейные фильтры: сохранение границ и борьба с импульсным шумом

Линейные сверточные фильтры принципиально ограничены: усреднение пикселей неизбежно "размазывает" резкие перепады яркости. Для сохранения геометрии объектов и подавления специфических помех применяются нелинейные алгоритмы.

1. Медианный фильтр (Median Blur)

Импульсный шум (шум типа «соль и перец») характеризуется появлением изолированных пикселей с экстремальными значениями (0 или 255). Если к такому пикселю применить гауссово размытие, экстремальное значение не исчезнет, а лишь распределится на соседние пиксели в виде мутного пятна.

Медианный фильтр работает без умножения на весовую матрицу. Алгоритм:

  1. Выбирает все пиксели в квадратном окне вокруг якоря.
  2. Сортирует значения по возрастанию.
  3. Берет центральное (медианное) значение отсортированного списка и записывает его в целевой пиксель.
# Медианная фильтрация с апертурой 5x5 (ksize должен быть нечетным числом)
median_filtered = cv2.medianBlur(image, ksize=5)

Так как изолированные выбросы (0 или 255) всегда оказываются на краях отсортированного массива, они гарантированно отбрасываются. При этом ступенчатые границы объектов не размываются, поскольку медиана локального перепада яркости сохраняет четкий фронт.

2. Двусторонний (билатеральный) фильтр (Bilateral Filter)

Билатеральный фильтр решает фундаментальную дилемму компьютерного зрения: как максимально сгладить однородные текстуры, не повредив при этом границы объектов?

Для этого вычисляются два независимых гауссовых веса:

  1. Пространственный вес (GσsG_{\sigma_s}) — зависит от физического евклидова расстояния между координатами центрального пикселя и его соседа.
  2. Яркостный (фотометрический) вес (GσrG_{\sigma_r}) — зависит от разности значений яркости между центральным пикселем и соседом.

Итоговый вес соседа равен произведению этих двух компонент:

Ibilateral(p)=1WpqΩI(q)exp(pq22σs2)exp(I(p)I(q)22σr2)I_{\text{bilateral}}(p) = \frac{1}{W_p} \sum_{q \in \Omega} I(q) \cdot \exp\left(-\frac{\|p - q\|^2}{2\sigma_s^2}\right) \cdot \exp\left(-\frac{|I(p) - I(q)|^2}{2\sigma_r^2}\right)

Где:

  • pp и qq — векторные координаты центрального и соседнего пикселей в окрестности Ω\Omega;
  • pq\|p - q\| — пространственное расстояние между точками;
  • I(p)I(q)|I(p) - I(q)| — фотометрическая разность интенсивностей (разница в яркости);
  • σs\sigma_s (spatial sigma) — радиус влияния по координатам;
  • σr\sigma_r (range/color sigma) — порог чувствительности к перепадам яркости;
  • WpW_p — нормализующий множитель, равный сумме всех весов в окне.

Если соседний пиксель лежит по другую сторону контрастной границы, разность I(p)I(q)|I(p) - I(q)| велика, второй экспоненциальный множитель стремится к нулю, и этот сосед полностью исключается из усреднения.

# Билатеральная фильтрация
# d — диаметр окрестности пикселя
# sigmaColor — чувствительность к яркостным перепадам
# sigmaSpace — координатный радиус сглаживания
bilateral_filtered = cv2.bilateralFilter(
    src=image,
    d=9,
    sigmaColor=75,
    sigmaSpace=75,
)

Сравнительный анализ алгоритмов фильтрации

Алгоритм Тип операции Лучшая область применения Влияние на границы Вычислительная сложность
Box Blur (cv2.blur) Линейная свертка Быстрое грубое размытие, предварительное усреднение фона Сильно размывает Очень низкая (O(1)O(1) при Box-интеграле)
Gaussian Blur (cv2.GaussianBlur) Линейная свертка Гауссов шум матрицы, подготовка к расчету градиентов Размывает пропорционально σ\sigma Низкая (сепарабельное ядро O(K)O(K))
Median Blur (cv2.medianBlur) Нелинейный ранговый Импульсный шум («соль и перец»), бинарные артефакты масок Сохраняет резкие перепады Средняя (O(K2logK)O(K^2 \log K))
Bilateral (cv2.bilateralFilter) Нелинейный взвешенный Удаление шума и мелкой текстуры с сохранением жестких ребер Идеально сохраняет границы Высокая (не сепарабелен нативно)

Интеграция фильтрации в пайплайн сегментации

Продемонстрируем, как фильтрация решает проблему зашумленных цветовых масок.

Представим конвейер, где на зеленой печатной плате с маркировкой требуется надежно локализовать синий электролитический конденсатор. Сенсор работает при слабом освещении с высоким уровнем усиления (Gain), из-за чего изображение покрыто шумом.

import cv2
import numpy as np

# 1. Загрузка исходного кадра в пространстве BGR
frame = cv2.imread("pcb_low_light.png")

# 2. Первичная фильтрация для подавления сенсорного шума без потери геометрии
# Билатеральный фильтр выравнивает цвет внутри деталей, не смазывая границы
denoised = cv2.bilateralFilter(frame, d=7, sigmaColor=50, sigmaSpace=50)

# 3. Переход в цветовое пространство HSV
hsv = cv2.cvtColor(denoised, cv2.COLOR_BGR2HSV)

# 4. Выделение диапазона синего цвета (электролитический конденсатор)
lower_blue = np.array([100, 80, 50], dtype=np.uint8)
upper_blue = np.array([130, 255, 255], dtype=np.uint8)
raw_mask = cv2.inRange(hsv, lower_blue, upper_blue)

# 5. Очистка бинарной маски от изолированных точечных выбросов
# Медианный фильтр с ядром 5x5 устраняет паразитные белые точки
clean_mask = cv2.medianBlur(raw_mask, ksize=5)

# 6. Извлечение очищенного сегмента объекта
segmented_roi = cv2.bitwise_and(frame, frame, mask=clean_mask)

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

Градиенты яркости и детектор границ Canny

Градиенты яркости и детектор границ Canny

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

Яркостный профиль: [ 10,  10,  12,  85,  90,  92 ]  ->  Резкий скачок (граница)
1-я производная:   [  0,   0,   2,  73,   5,   2 ]  ->  Пик (максимум градиента)

Математика перепада яркости: дискретные производные

Цифровое полутоновое изображение — это двумерная дискретная функция I(x,y)I(x, y), где аргументы — целочисленные координаты сетки пикселей, а значения — уровни яркости. В непрерывном анализе скорость изменения функции описывается градиентом:

I=[IxIy]\nabla I = \begin{bmatrix} \frac{\partial I}{\partial x} \\ \frac{\partial I}{\partial y} \end{bmatrix}

Вектор I\nabla I указывает направление наискорейшего роста яркости, а его длина (норма) показывает, насколько этот рост резок. В дискретном пространстве матрицы шаг между соседними узлами равен одному пикселю (Δx=1,Δy=1\Delta x = 1, \Delta y = 1). Поэтому частные производные заменяются конечными разностями:

  • Односторонняя разность вперед: IxI(x+1,y)I(x,y)\frac{\partial I}{\partial x} \approx I(x+1, y) - I(x, y)
  • Симметричная центральная разность: IxI(x+1,y)I(x1,y)2\frac{\partial I}{\partial x} \approx \frac{I(x+1, y) - I(x-1, y)}{2}

Центральная разность точнее аппроксимирует наклон функции в точке (x,y)(x, y), поскольку учитывает симметричную окрестность. Эту операцию можно представить в виде пространственной свертки со специальными ядрами.

Операторы Собеля и Щарра

Простейшее ядро центральной разности [1,0,1][-1, 0, 1] крайне чувствительно к единичным зашумленным пикселям. В 1968 году Ирвин Собель предложил объединить операцию взятия центральной разности вдоль одной оси с гауссоподобным сглаживанием [1,2,1]T[1, 2, 1]^T вдоль ортогональной оси.

Ядра оператора Собеля KxK_x и KyK_y размера 3×33 \times 3 вычисляют производные по горизонтали и вертикали:

Kx=[101202101],Ky=[121000121]K_x = \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix}, \quad K_y = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{bmatrix}

Ядро KxK_x находит вертикальные границы (перепады слева направо), а KyK_y — горизонтальные (перепады сверху вниз).

import cv2
import numpy as np

# Загрузка полутонового изображения
gray = cv2.imread("circuit_board.png", cv2.IMREAD_GRAYSCALE)

# Вычисление производных с сохранением знака в float64 / int16
grad_x = cv2.Sobel(gray, cv2.CV_64F, dx=1, dy=0, ksize=3)
grad_y = cv2.Sobel(gray, cv2.CV_64F, dx=0, dy=1, ksize=3)

Важно: результат вычисления градиента содержит как положительные, так и отрицательные числа (переход от черного к белому дает +255+255, от белого к черному — 255-255). Если передать целевой тип uint8, все отрицательные значения усекутся в 00, и ровно половина границ на кадре исчезнет. Свертка всегда выполняется в промежуточный тип cv2.CV_16S или cv2.CV_64F.

Если ядро 3×33 \times 3 Собеля дает недостаточную угловою точность при анализе мелких деталей, применяют оператор Щарра (cv2.Scharr). Он оптимизирован под минимизацию угловой анизотропии за счет коэффициентов [3,0,3][-3, 0, 3] и [3,10,3]T[3, 10, 3]^T:

Kx,Scharr=[30310010303]K_{x,\text{Scharr}} = \begin{bmatrix} -3 & 0 & 3 \\ -10 & 0 & 10 \\ -3 & 0 & 3 \end{bmatrix}

Векторное поле градиентов: амплитуда и направление

Вычислив компоненты Gx=IxG_x = \frac{\partial I}{\partial x} и Gy=IyG_y = \frac{\partial I}{\partial y} для каждой точки матрицы, мы получаем полное описание локальной геометрии перепада:

  1. Амплитуда (величина) градиента:

    G=Gx2+Gy2или быстрое приближениеGGx+Gy|G| = \sqrt{G_x^2 + G_y^2} \quad \text{или быстрое приближение} \quad |G| \approx |G_x| + |G_y|

    Амплитуда показывает контрастность границы в данной точке.

  2. Направление (угол) градиента:

    θ=arctan2(Gy,Gx)\theta = \operatorname{arctan2}(G_y, G_x)

    Угол θ\theta ориентирован строго перпендикулярно касательной к физической границе объекта.

# Преобразование компонент декартовой системы в полярные координаты
magnitude, angle = cv2.cartToPolar(grad_x, grad_y, angleInDegrees=True)

# Приведение амплитуды к 8-битному диапазону для визуализации
abs_grad_x = cv2.convertScaleAbs(grad_x)
abs_grad_y = cv2.convertScaleAbs(grad_y)
grad_combined = cv2.addWeighted(abs_grad_x, 0.5, abs_grad_y, 0.5, 0)

Вторая производная и оператор Лапласа

Если первая производная формирует экстремум (пик) на месте перепада яркости, то вторая производная проходит через ноль в точке максимальной крутизны склона.

Оператор Лапласа для 2D-функции задается суммой вторых частных производных:

ΔI=2I=2Ix2+2Iy2\Delta I = \nabla^2 I = \frac{\partial^2 I}{\partial x^2} + \frac{\partial^2 I}{\partial y^2}

Дискретный аналог Лапласиана вычисляется сверткой с изотропным ядром:

L=[010141010]или с учетом диагоналейLdiag=[111181111]L = \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix} \quad \text{или с учетом диагоналей} \quad L_{\text{diag}} = \begin{bmatrix} 1 & 1 & 1 \\ 1 & -8 & 1 \\ 1 & 1 & 1 \end{bmatrix}

# Вычисление Лапласиана
laplacian = cv2.Laplacian(gray, cv2.CV_64F, ksize=3)
laplacian_abs = cv2.convertScaleAbs(laplacian)
Характеристика Градиентные операторы 1-го порядка (Собель, Щарр) Оператор Лапласа 2-го порядка
Математическая суть I=(Ix,Iy)\nabla I = \left(\frac{\partial I}{\partial x}, \frac{\partial I}{\partial y}\right) ΔI=2Ix2+2Iy2\Delta I = \frac{\partial^2 I}{\partial x^2} + \frac{\partial^2 I}{\partial y^2}
Вид границы Широкий гребень максимальных значений Точка пересечения нуля (zero-crossing)
Направление границы Вычисляется через arctan2(Gy,Gx)\operatorname{arctan2}(G_y, G_x) Изотропен (не дает угла перепада)
Устойчивость к шуму Средняя (встроено сглаживание) Крайне низкая (требует фильтра Гаусса — Laplacian of Gaussian / LoG)

Детектор границ Canny: четырехстадийный пайплайн

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

  1. Низкий уровень ошибок: отсутствие ложных срабатываний на шуме и пропусков реальных ребер.
  2. Точная локализация: найденная граница должна лежать максимально близко к истинному центру перепада.
  3. Единственный отклик: одна физическая граница не должна порождать несколько параллельных линий отклика.

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

1. Фильтрация высокочастотного шума

Первичный полутоновый кадр сглаживается ядром Гаусса (обычно 5×55 \times 5 с σ1.4\sigma \approx 1.4), чтобы устранить пиксельный шум, который при последующем дифференцировании создал бы ложные пики.

2. Вычисление градиентов

С помощью ядер Собеля 3×33 \times 3 вычисляются матрицы частных производных GxG_x и GyG_y, после чего для каждого пикселя находятся амплитуда G|G| и угол θ\theta. Непрерывный угол квантуется в один из четырех основных секторов: 00^\circ (горизонталь), 4545^\circ, 9090^\circ (вертикаль) или 135135^\circ.

3. Подавление немаксимумов (Non-Maximum Suppression, NMS)

Цель стадии — утончить широкие склоны перепада яркости до математической линии толщиной строго в 1 пиксель.

Алгоритм сканирует всю матрицу амплитуд:

  1. Для текущего пикселя pp определяется нормаль к границе по его квантованному углу θ\theta.
  2. Значение G(p)|G(p)| сравнивается с двумя соседями qq и rr, лежащими вдоль этой же нормали (по обе стороны от pp).
  3. Если значение G(p)|G(p)| строго больше обоих соседей, пиксель pp сохраняется как локальный гребень. Если хотя бы один сосед имеет большую амплитуду, значение pp обнуляется.
Сектор 0° (нормаль горизонтальна):    [ q ] -- ( p ) -- [ r ]
Если |G(p)| >= |G(q)| и |G(p)| >= |G(r)| -> p сохраняется, иначе p = 0

4. Двойная пороговая фильтрация и трассировка гистерезисом

После NMS на кадре остается множество изолированных пикселей от шума и текстур. Простой одинарный порог разрушает связность: при высоком значении границы рвутся на куски, при низком — появляется мусор. Кэнни применил гистерезис с двумя порогами: TlowT_{\text{low}} и ThighT_{\text{high}} (обычно в соотношении 1:21:2 или 1:31:3).

Все пиксели после NMS делятся на три категории:

  • Сильные (GThigh|G| \geq T_{\text{high}}): гарантированные границы, сразу включаются в финальную бинарную маску.
  • Слабые (TlowG<ThighT_{\text{low}} \leq |G| < T_{\text{high}}): кандидаты на включение.
  • Подавленные (G<Tlow|G| < T_{\text{low}}): шум, безусловно отбрасываются (обнуляются).

Трассировка связности (Edge Tracking by Hysteresis): слабый пиксель признается истинной границей только в том случае, если он пространственно соединен с хотя бы одним сильным пикселем (напрямую или через цепочку других слабых пикселей в 8-связной окрестности). Изолированные слабые цепочки удаляются.

Применение cv2.Canny в OpenCV

В OpenCV весь четырехстадийный пайплайн упакован в высокооптимизированную функцию cv2.Canny:

edges = cv2.Canny(
    image=gray,
    threshold1=50,       # Нижний порог T_low
    threshold2=150,      # Верхний порог T_high
    apertureSize=3,      # Размер ядра Собеля для вычисления градиентов (3, 5 или 7)
    L2gradient=True      # Использовать точную формулу sqrt(Gx^2 + Gy^2) вместо |Gx| + |Gy|
)

Параметр L2gradient=True обеспечивает более точный расчет евклидовой нормы вектора градиента ценой минимального снижения скорости, что критично для устранения артефактов на диагональных границах.

import cv2
import matplotlib.pyplot as plt

# 1. Чтение кадра
image = cv2.imread("mechanical_part.png")
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

# 2. Предварительное подавление шума перед детектором Canny
# Хотя внутри Canny есть базовый фильтр Гаусса 5x5, контролируемое
# внешнее сглаживание позволяет адаптировать масштаб детектируемых деталей
blurred = cv2.GaussianBlur(gray, (5, 5), sigmaX=1.2)

# 3. Вычисление тонких границ
edges_strict = cv2.Canny(blurred, threshold1=100, threshold2=200, L2gradient=True)
edges_sensitive = cv2.Canny(blurred, threshold1=30, threshold2=90, L2gradient=True)

Автоматический расчет порогов по медиане

На конвейере или при переменном освещении фиксированные пороги приводят к нестабильным результатам. Распространенный инженерный подход — автоматический расчет TlowT_{\text{low}} и ThighT_{\text{high}} на основе медианной яркости изображения:

def auto_canny(image, sigma=0.33):
    # Вычисление медианы яркости одноканального изображения
    v = np.median(image)

    # Применение коэффициента сигма для формирования окна вокруг медианы
    lower = int(max(0, (1.0 - sigma) * v))
    upper = int(min(255, (1.0 + sigma) * v))

    return cv2.Canny(image, lower, upper, L2gradient=True)

Если медиана кадра v=120v = 120, то при σ=0.33\sigma = 0.33 пороги составят Tlow=80T_{\text{low}} = 80 и Thigh=159T_{\text{high}} = 159. Для изображений с равномерно темным или светлым фоном диапазон сжимается или расширяется пропорционально базовой освещенности.

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

Пороговая сегментация: глобальные, адаптивные методы и алгоритм Оцу

Пороговая сегментация: глобальные, адаптивные методы и алгоритм Оцу

Детектор границ Canny строит контурный каркас сцены, но контурная линия — это лишь бесконечно тонкая граница между перепадами яркости. Если перед инженером стоит задача измерить площадь детали на конвейере, распознать штрихкод или локализовать текст на скане документа, тонких граней недостаточно. Алгоритму требуется монолитная бинарная маска, которая безошибочно разделяет изображение на две семантические категории: целевой объект (передний план, Foreground) и окружение (фон, Background).

Прямой путь к получению такой маски — пороговая сегментация (Thresholding). Задача формулируется как разделение полутонового массива I(x,y)I(x, y) на два дискретных уровня яркости по некоторому решающему правилу:

B(x,y)={maxval,если I(x,y)>T0,иначеB(x, y) = \begin{cases} \text{maxval}, & \text{если } I(x, y) > T \\ 0, & \text{иначе} \end{cases}

Здесь I(x,y)I(x, y) — исходная яркость пикселя, TT — пороговое значение (Threshold), maxval\text{maxval} — целевая яркость активного пикселя (в 8-битных масках всегда 255), а B(x,y)B(x, y) — результирующий бинарный пиксел.

На практике выбор порога TT разделяет алгоритмы на три фундаментальных класса: фиксированные глобальные методы, статистический автоподбор через гистограмму (метод Оцу) и локально-адаптивные фильтры.


Глобальная бинаризация и флаги cv2.threshold

Когда условия съемки идеальны — контрастный объект освещен равномерно со всех сторон, — достаточно зафиксировать одно скалярное число TT для всей матрицы кадра. В OpenCV за эту операцию отвечает функция cv2.threshold.

retval, dst = cv2.threshold(src, thresh, maxval, type)

Функция принимает одноканальное 8-битное (или 32-битное) изображение src, пороговый уровень thresh, максимальное значение maxval (обычно 255) и флаг режима трансформации. Возвращаемый кортеж содержит фактически использованный порог retval и результирующий массив dst.

Математика трансформации полностью определяется выбранным флагом:

Флаг OpenCV Математическое условие Результат при I(x,y)>TI(x, y) > T Результат при I(x,y)TI(x, y) \leq T
cv2.THRESH_BINARY I>T    maxvalI > T \implies \text{maxval} maxval\text{maxval} (255) 00
cv2.THRESH_BINARY_INV I>T    0I > T \implies 0 00 maxval\text{maxval} (255)
cv2.THRESH_TRUNC I>T    TI > T \implies T TT I(x,y)I(x, y)
cv2.THRESH_TOZERO I>T    II > T \implies I I(x,y)I(x, y) 00
cv2.THRESH_TOZERO_INV IT    II \leq T \implies I 00 I(x,y)I(x, y)

Режимы THRESH_TRUNC и THRESH_TOZERO не создают строгую двоичную маску: они срезают пики яркости или гасят фоновый шум, сохраняя градации серого в объектах. Для выделения масок в подавляющем большинстве задач применяют cv2.THRESH_BINARY или его инверсию cv2.THRESH_BINARY_INV (когда целевой объект темнее фона, например, черный текст на белой бумаге).

Главный изъян ручной установки thresh — хрупкость. Достаточно облаку закрыть солнце или лампе на производстве просесть по напряжению на 5%, как фиксированный порог T=127T = 127 начнет либо «съедать» границы деталей, либо заливать фон ложным шумом.


Статистический оптимум: бинаризация по методу Оцу

Чтобы исключить ручной подбор констант, японский ученый Нобуюки Оцу в 1979 году предложил алгоритм автоматического нахождения глобального порога, опирающийся исключительно на одномерную гистограмму распределения яркостей изображения.

Алгоритм Оцу исходит из предположения, что изображение состоит из двух статистических популяций пикселей (фона и объекта), а его гистограмма бимодальна — имеет два выраженных пика, разделенных впадиной. Порог TT должен пройти точно по этой впадине.

Математически Оцу сформулировал задачу как максимизацию межклассовой дисперсии σB2(T)\sigma_B^2(T) (разброса между средними значениями двух классов), что строго эквивалентно минимизации внутриклассовой дисперсии (размытия внутри самих классов):

σB2(T)=ω0(T)ω1(T)(μ0(T)μ1(T))2\sigma_B^2(T) = \omega_0(T) \cdot \omega_1(T) \cdot \big(\mu_0(T) - \mu_1(T)\big)^2

Поясним каждый элемент этой формулы:

  • ω0(T)\omega_0(T) и ω1(T)\omega_1(T) — относительные доли (вероятности) пикселей, попавших в класс фона (ITI \leq T) и класс объекта (I>TI > T). Сумма ω0+ω1=1\omega_0 + \omega_1 = 1.
  • μ0(T)\mu_0(T) — средняя яркость пикселей фона при пороге TT.
  • μ1(T)\mu_1(T) — средняя яркость пикселей объекта при пороге TT.
  • (μ0μ1)2(\mu_0 - \mu_1)^2 — квадрат расстояния между центрами двух классов.

Если мы разделили кадр удачно, класс «фон» будет состоять только из темных точек (малое среднее μ0\mu_0), а класс «объект» — только из светлых (высокое среднее μ1\mu_1). Разность (μ0μ1)2(\mu_0 - \mu_1)^2 станет максимальной, а вместе с ней достигнет максимума и величина σB2(T)\sigma_B^2(T).

Поскольку яркости пикселей квантованы в диапазоне от 0 до 255, алгоритм Оцу не решает сложные дифференциальные уравнения. Он выполняет быстрый перебор всех 256 возможных значений TT, вычисляет для каждого σB2(T)\sigma_B^2(T) по накопленным суммам гистограммы и выбирает то значение TT^*, при котором σB2\sigma_B^2 достигает абсолютного максимума:

T=argmax0T<255σB2(T)T^* = \arg\max_{0 \leq T < 255} \sigma_B^2(T)

В OpenCV метод Оцу вызывается комбинацией флагов через побитовое сложение | (или +). Входное значение thresh при этом игнорируется (передают 0), а вычисленный оптимальный порог возвращается в первой переменной:

# Загрузка в оттенках серого
gray = cv2.imread("pcb_board.png", cv2.IMREAD_GRAYSCALE)

# Предварительное гауссово сглаживание обязательно для подавления локальных флуктуаций
blurred = cv2.GaussianBlur(gray, (5, 5), 0)

# Автоматический расчет порога по Оцу
optimal_thresh, binary_mask = cv2.threshold(
    blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
)

print(f"Вычисленный порог Оцу: {optimal_thresh}")

Важное инженерное правило: метод Оцу всегда должен применяться в связке с предварительной фильтрацией шума (cv2.GaussianBlur или cv2.medianBlur). Высокочастотный шум искажает форму гистограммы, создавая ложные локальные пики, из-за чего оптимум σB2\sigma_B^2 может сместиться на десятки градаций яркости.


Проблема светового градиента: где глобальные методы бессильны

Глобальные методы (включая метод Оцу) предполагают пространственную однородность: пиксель яркостью 110 в левом углу кадра означает то же самое состояние «объект/фон», что и пиксель яркостью 110 в правом углу. В реальных инженерных условиях это предположение почти всегда нарушается.

Типичные причины неоднородности поля яркости:

  1. Виньетирование оптики: края кадра получают меньше света, чем оптический центр.
  2. Точечный источник света под углом: на сцене возникает плавный линейный градиент освещенности.
  3. Падающие тени: геометрия соседних конструкций перекрывает поток фотонов.

В таких условиях текст на затемненном краю листа может иметь абсолютную яркость 60 (темный объект) на фоне яркостью 80 (темный фон). В то же время на освещенном краю листа чистый белый фон имеет яркость 240, а буквы — яркость 140. Никакое глобальное число TT не сможет разделить такую сцену: если выбрать T=100T = 100, то освещенный текст (140) сольется с фоном, а если T=160T = 160, то вся затемненная половина листа превратится в монолитное черное пятно.


Локально-адаптивная бинаризация (cv2.adaptiveThreshold)

Решение проблемы переменного света — вычисление индивидуального порога T(x,y)T(x, y) для каждого отдельного пикселя на основе статистических свойств его пространственной окрестности.

В OpenCV за этот процесс отвечает метод cv2.adaptiveThreshold:

dst = cv2.adaptiveThreshold(
    src, maxValue, adaptiveMethod, thresholdType, blockSize, C
)

Алгоритм перемещает скользящее квадратное окно размера blockSize×blockSize\text{blockSize} \times \text{blockSize} по всей матрице изображения. Для центрального пикселя (x,y)(x, y) локальный порог T(x,y)T(x, y) рассчитывается по формуле:

T(x,y)=LocalMean(x,y)CT(x, y) = \text{LocalMean}(x, y) - C

Здесь LocalMean(x,y)\text{LocalMean}(x, y) — локальное среднее значение яркости в пределах окрестности, а CC — константа смещения, вычитаемая из среднего.

Режимы вычисления окрестности

  1. cv2.ADAPTIVE_THRESH_MEAN_C: Локальный порог — это простое арифметическое среднее всех пикселей внутри окна размера blockSize×blockSize\text{blockSize} \times \text{blockSize}. Метод эквивалентен быстрой свертке с усредняющим box-фильтром.

  2. cv2.ADAPTIVE_THRESH_GAUSSIAN_C: Локальный порог вычисляется как взвешенная сумма яркостей в окне, где веса распределены по закону Гаусса (центральные пиксели вносят больший вклад, чем периферийные). Этот метод дает существенно более гладкие и чистые маски на краях объектов, подавляя шум от мелких текстурных неоднородностей.

Геометрический и физический смысл параметров

  • blockSize (размер окрестности): Целое нечетное число, строго большее единицы (3,5,7,,31,3, 5, 7, \dots, 31, \dots). Физический смысл: размер окна должен быть заметно больше толщины самых крупных деталей объекта, которые мы сегментируем. Если размер символа текста составляет 15×1515 \times 15 пикселей, а мы выберем blockSize = 7, то внутри заливки толстых букв локальное окно увидит только черный цвет. Среднее станет равным черному, локальный порог опустится, и внутри монолитных букв появятся дыры и «выеденные» участки.
  • C (константа смещения): Действительное число (может быть как положительным, так и отрицательным или нулем). Физический смысл: запас устойчивости к фоновому шуму. Если на идеально ровном сером фоне с яркостью 128 задать C=0C = 0, то малейшие шумовые колебания (±1\pm 1 градация серого) приведут к тому, что половина пикселей фона окажется ниже локального среднего и превратится в ложный белый шум на маске. Задавая C=515C = 5 \dots 15, мы требуем, чтобы пиксель был темнее среднего фона минимум на CC единиц, что гарантирует полную очистку однородных областей от фонового шума.

Сравнительный анализ методов сегментации

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

Метод Плюсы Минусы Вычислительная сложность Типовое применение
Глобальный фиксированный (cv2.threshold) Предельно быстрый (O(N)O(N) по числу пикселей), нулевые накладные расходы Полная непригодность при изменении освещения Минимальная Сцены с калиброванной подсветкой, лабораторные установки
Автоматический по Оцу (THRESH_OTSU) Не требует ручной настройки порога, математически оптимален для двух классов Сбоит при отсутствии четкой бимодальности и градиентах света Низкая (построение гистограммы 256 бинов + 256 итераций) Сортировка контрастных деталей, изоляция объектов на монохромном фоне
Адаптивный Гауссов (ADAPTIVE_THRESH_GAUSSIAN_C) Идеально компенсирует неравномерный свет, тени и виньетирование Требует подбора blockSize и CC, чувствителен к крупным сплошным объектам Средняя (локальная двумерная свертка) OCR, сканирование документов, дорожная разметка, трекинг в уличных камерах

Инженерный шаблон: сборка устойчивого конвейера бинаризации

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

import cv2
import numpy as np

def preprocess_uneven_light(image_bgr: np.ndarray) -> np.ndarray:
    # 1. Перевод в градации серого
    gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)

    # 2. Подавление высокочастотного шума матрицы перед адаптивным окном
    # Размер ядра сглаживания должен быть небольшим, чтобы не размыть полезные грани
    blurred = cv2.GaussianBlur(gray, (3, 3), 0)

    # 3. Локально-адаптивная сегментация
    # blockSize=21 перекрывает масштаб типовых символов/дефектов
    # C=7 отсекает шум матрицы и микротекстуру поверхности
    binary_mask = cv2.adaptiveThreshold(
        blurred,
        maxValue=255,
        adaptiveMethod=cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        thresholdType=cv2.THRESH_BINARY_INV,
        blockSize=21,
        C=7,
    )

    return binary_mask

Инвертирование cv2.THRESH_BINARY_INV выбрано намеренно: в классических алгоритмах OpenCV (включая алгоритмы поиска контуров и топологического анализа) белые пиксели (255255) интерпретируются как активная масса объекта, а черные (00) — как пассивное фоновое пространство.

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

Топология и контуры: поиск связанных границ, аппроксимация и иерархия

Топология и контуры: поиск связанных границ, аппроксимация и иерархия

Бинарная маска после адаптивного порога или детектора Canny — это все еще просто двумерный массив uint8, где каждый объект состоит из разрозненных чисел 255. Компьютер пока не знает, сколько деталей лежит на конвейере, где их геометрические центры, есть ли внутри них сквозные отверстия и какую форму они имеют. Чтобы перейти от низкоуровневой матрицы пикселей к высокоуровневому анализу сцены, необходимо преобразовать растровые скопления единиц в непрерывные векторные границы.

Этот шаг выполняет контурный анализ — фундаментальный мост между пиксельной графикой и аналитической геометрией в компьютерном зрении.


Что такое контур и алгоритм Судзуки-Абэ

Контур — это непрерывная замкнутая кривая, соединяющая смежные граничные точки бинарного объекта одинаковой яркости или цвета.

В OpenCV контур представляется не уравнением кривой, а упорядоченным массивом координат точек границы (x, y). Базовый алгоритм, лежащий в основе функции cv2.findContours, разработан Сатоси Судзуки и Кэити Абэ в 1985 году. Алгоритм выполняет топологический обход границ: сканирует бинарную матрицу построчно, находит границы перехода яркостей 02550 \to 255 (внешняя граница) и 2550255 \to 0 (граница внутреннего отверстия) и трассирует их с учетом заданной связности пикселей (обычно 8-связность).

contours, hierarchy = cv2.findContours(
    image,
    mode=cv2.RETR_TREE,
    method=cv2.CHAIN_APPROX_SIMPLE
)

Функция принимает на вход одноканальное 8-битное бинарное изображение (пиксели объекта должны быть белыми — 255, а фон — черным — 0) и возвращает два значения:

  1. contours — список массивов NumPy. Каждый контур имеет форму (N, 1, 2) и тип int32, где NN — число вершин границы, а последняя ось хранит координаты (x,y)(x, y).
  2. hierarchy — трехмерный массив формы (1, M, 4), кодирующий топологические отношения вложенности между MM найденными контурами.

Сжатие координат: CHAIN_APPROX

При трассировке границы можно сохранять абсолютно все граничные пиксели или только ключевые опорные вершины. За это отвечает параметр method:

Флаг метода Принцип работы Расход памяти Применение
cv2.CHAIN_APPROX_NONE Сохраняет абсолютно все точки границы. Длина горизонтального отрезка в 200 пикселей породит 200 точек. Высокий Анализ тонкой текстуры границ, фрактальный анализ.
cv2.CHAIN_APPROX_SIMPLE Сжимает горизонтальные, вертикальные и диагональные сегменты, оставляя только их конечные точки. Отрезок в 200 пикселей превратится в 2 точки. Минимальный (экономия до 90%) Подавляющее большинство задач промышленного зрения и трекинга.

Топологическая иерархия и режимы извлечения

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

Каждый контур в массиве hierarchy[0][i] описывается вектором из четырех целочисленных индексов:

[Next,Previous,First_Child,Parent][Next, Previous, First\_Child, Parent]

  • NextNext: индекс следующего контура на том же иерархическом уровне (если нет — 1-1).
  • PreviousPrevious: индекс предыдущего контура на том же уровне (если нет — 1-1).
  • First_ChildFirst\_Child: индекс первого вложенного дочернего контура (отверстия или внутреннего объекта).
  • ParentParent: индекс родительского контура, внутри которого лежит данный контур.

Поведение дерева определяется параметром mode:

  • cv2.RETR_EXTERNAL: извлекает только внешние границы самого верхнего уровня. Все внутренние отверстия и вложенные объекты игнорируются (First_Child и Parent всегда 1-1).
  • cv2.RETR_LIST: извлекает абсолютно все контуры без построения связей — плоский список, где все элементы находятся на одном уровне.
  • cv2.RETR_CCOMP: создает строгую двухранговую иерархию: уровень 1 — внешние границы компонентов, уровень 2 — границы внутренних отверстий.
  • cv2.RETR_TREE: строит полное многоуровневое генеалогическое дерево со всеми уровнями вложенности произвольной глубины.

Геометрические моменты, площадь и центроид

Контур как набор точек позволяет мгновенно извлекать скалярные физические параметры объекта: площадь, длину периметра, координаты центра масс и форму.

Периметр и площадь

  • cv2.arcLength(cnt, closed=True): вычисляет длину дуги (периметр). Флаг closed указывает, является ли контур замкнутым полигоном или разомкнутой линией.
  • cv2.contourArea(cnt): вычисляет площадь фигуры, ограниченной контуром. Внутри функции используется формула площади Гаусса (дискретный аналог теоремы Грина).

Пространственные моменты изображения

Для расчета центроида (геометрического центра тяжести) применяются пространственные моменты. Двумерный пространственный момент MpqM_{pq} порядка (p+q)(p + q) для непрерывной замкнутой области определяется интегралом, а для дискретного бинарного контура — суммой:

Mpq=xyxpyqI(x,y)M_{pq} = \sum_{x} \sum_{y} x^p y^q \cdot I(x, y)

где xx и yy — координаты пикселей, принадлежащих фигуре, а I(x,y)=1I(x, y) = 1 для бинарного силуэта.

Элементы моментов несут прямой физический смысл:

  • M00M_{00} — нулевой момент, в точности равный площади бинарного объекта в пикселях.
  • M10M_{10} — сумма координат xx всех точек фигуры.
  • M01M_{01} — сумма координат yy всех точек фигуры.

Координаты центра тяжести (центроида) (xˉ,yˉ)(\bar{x}, \bar{y}) вычисляются нормализацией первых моментов на нулевой:

xˉ=M10M00,yˉ=M01M00\bar{x} = \frac{M_{10}}{M_{00}}, \quad \bar{y} = \frac{M_{01}}{M_{00}}

Например, если для детали на конвейере вычислены моменты M00=4000M_{00} = 4000, M10=1200000M_{10} = 1\,200\,000, M01=800000M_{01} = 800\,000, то центр объекта строго зафиксирован в пикселе (xˉ=300,yˉ=200)(\bar{x} = 300, \bar{y} = 200).

# Вычисление моментов и координат центроида
M = cv2.moments(cnt)
if M["m00"] != 0:
    cx = int(M["m10"] / M["m00"])
    cy = int(M["m01"] / M["m00"])
else:
    cx, cy = 0, 0

Аппроксимация полигонов: алгоритм Рамера-Дугласа-Пекера

Реальные контуры после бинаризации и выделения краев часто содержат зазубрины, вызванные шумом матрицы или дискретностью пиксельной сетки. Окружность может состоять из 600 точек, а идеальный прямоугольник — из 80. Чтобы распознать базовые геометрические примитивы (треугольник, квадрат, круг), контур аппроксимируют полигоном с минимальным числом вершин с помощью алгоритма Рамера-Дугласа-Пекера (cv2.approxPolyDP).

Алгоритм работает рекурсивно:

  1. Соединяет прямой линией две наиболее удаленные точки контура.
  2. Находит точку контура, максимально удаленную от этого отрезка по перпендикуляру (расстояние dmaxd_{max}).
  3. Если dmax>ϵd_{max} > \epsilon, отрезок разбивается на два, точка включается в полигон, и процесс рекурсивно повторяется для каждого плеча.
  4. Если dmaxϵd_{max} \leq \epsilon, все промежуточные точки отбрасываются, а отрезок считается достаточным приближением.

Параметр ϵ\epsilon (эпсилон) задает максимальное допустимое отклонение аппроксимирующей ломаной от исходного контура. На практике его всегда задают пропорционально длине периметра объекта PP:

ϵ=αP\epsilon = \alpha \cdot P

где P=cv2.arcLength(cnt,True)P = \text{cv2.arcLength}(cnt, \text{True}), а α\alpha лежит в диапазоне от 0.010.01 до 0.050.05.

perimeter = cv2.arcLength(cnt, closed=True)
epsilon = 0.02 * perimeter
approx_polygon = cv2.approxPolyDP(cnt, epsilon, closed=True)

# Количество вершин определяет тип фигуры
num_vertices = len(approx_polygon)
if num_vertices == 3:
    shape_type = "Triangle"
elif num_vertices == 4:
    shape_type = "Quadrilateral"
elif num_vertices > 8:
    shape_type = "Circle/Ellipse"

Выпуклые оболочки и ограничивающие контейнеры

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

# 1. Прямой ограничивающий прямоугольник (без поворота)
x, y, w, h = cv2.boundingRect(cnt)

# 2. Повернутый прямоугольник минимальной площади (RotatedRect)
rect = cv2.minAreaRect(cnt)
box = cv2.boxPoints(rect)
box = np.int64(box)

# 3. Минимальная описывающая окружность
(cx, cy), radius = cv2.minEnclosingCircle(cnt)

# 4. Выпуклая оболочка (Convex Hull)
hull = cv2.convexHull(cnt)

Выпуклая оболочка (Convex Hull) — наименьший выпуклый многоугольник, который полностью содержит в себе исходный контур (образно: резиновая лента, натянутая вокруг сложного объекта).

Сравнение площади выпуклой оболочки и исходной площади фигуры позволяет вычислять коэффициент выпуклости (Solidity):

Solidity=AreacontourAreahull\text{Solidity} = \frac{\text{Area}_{contour}}{\text{Area}_{hull}}

У цельных гладких деталей (круг, квадрат) Solidity1.0\text{Solidity} \approx 1.0. У звездообразных объектов или деталей с дефектами и вырезами Solidity<0.8\text{Solidity} < 0.8.


Практический конвейер: от маски до векторных дескрипторов

Соберем изученные топологические и геометрические методы в законченный пайплайн фильтрации и анализа объектов.

import cv2
import numpy as np

def analyze_scene_objects(binary_mask: np.ndarray, min_area: float = 500.0):
    # 1. Поиск всех контуров с полной иерархией
    contours, hierarchy = cv2.findContours(
        binary_mask,
        mode=cv2.RETR_TREE,
        method=cv2.CHAIN_APPROX_SIMPLE
    )

    if not contours or hierarchy is None:
        return []

    detected_objects = []
    hierarchy = hierarchy[0]

    for idx, cnt in enumerate(contours):
        area = cv2.contourArea(cnt)

        # Фильтрация мелкого бинарного шума по площади
        if area < min_area:
            continue

        # Расчет периметра и центроида через моменты
        perimeter = cv2.arcLength(cnt, closed=True)
        M = cv2.moments(cnt)
        cx = int(M["m10"] / M["m00"]) if M["m00"] != 0 else 0
        cy = int(M["m01"] / M["m00"]) if M["m00"] != 0 else 0

        # Полигональная аппроксимация (2% от периметра)
        epsilon = 0.02 * perimeter
        approx = cv2.approxPolyDP(cnt, epsilon, closed=True)

        # Вычисление ориентированного Bounding Box
        rect = cv2.minAreaRect(cnt)
        (center_x, center_y), (width, height), angle = rect

        # Определение наличия вложенных отверстий (дочерних контуров)
        has_holes = hierarchy[idx][2] != -1

        detected_objects.append({
            "id": idx,
            "area": area,
            "centroid": (cx, cy),
            "vertices_count": len(approx),
            "rotated_box": rect,
            "has_holes": has_holes
        })

    return detected_objects

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

Практикум: контурный анализ, вычисление геометрии и локализация зон интереса

Практикум: контурный анализ, вычисление геометрии и локализация зон интереса

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

Чтобы система машинного зрения выделила именно целевой объект и вырезала его для инспекции дефектов, недостаточно просто обнаружить границы. Требуется алгоритмически охарактеризовать геометрию каждого контура, отсеять ложные срабатывания по инвариантным числовым критериям формы и выполнить выравнивание ориентированной зоны интереса (Oriented ROI).

Дескрипторы формы: переход от пикселей к инвариантной геометрии

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

Для этого используются безразмерные геометрические коэффициенты — форм-факторы.

1. Соотношение сторон (Aspect Ratio)

Отношение ширины прямого ограничивающего прямоугольника (boundingRect) к его высоте:

Aspect Ratio=WH\text{Aspect Ratio} = \frac{W}{H}

  • WW — ширина прямоугольника boundingRect в пикселях.
  • HH — высота прямоугольника boundingRect в пикселях.

Пример применения: Для строго вертикальных или горизонтальных объектов (например, горизонтальных штрихкодов) соотношение сторон позволяет сразу отсеять квадратные артефакты. Если объект повернут, аналогичное отношение вычисляется через ширину и высоту повернутого прямоугольника minAreaRect(cnt)[1].

2. Заполненность контейнера (Extent)

Отношение площади контура к площади его прямого ограничивающего прямоугольника:

Extent=ScontourWH\text{Extent} = \frac{S_{\text{contour}}}{W \cdot H}

  • ScontourS_{\text{contour}} — фактическая площадь контура, вычисленная через cv2.contourArea.
  • WHW \cdot H — площадь прямого ограничивающего прямоугольника.

Пример применения: Для идеального прямоугольника, выровненного по осям координат, Extent=1.0\text{Extent} = 1.0. Для идеального круга Extent=πr2(2r)2=π40.785\text{Extent} = \frac{\pi r^2}{(2r)^2} = \frac{\pi}{4} \approx 0.785. Если контур имеет диагональную вытянутую форму или сложную изрезанность, его Extent\text{Extent} падает ниже 0.30.3.

3. Плотность выпуклости (Solidity)

Отношение площади контура к площади его минимальной выпуклой оболочки (convexHull):

Solidity=ScontourShull\text{Solidity} = \frac{S_{\text{contour}}}{S_{\text{hull}}}

  • ScontourS_{\text{contour}} — фактическая площадь контура.
  • ShullS_{\text{hull}} — площадь выпуклого полигона cv2.contourArea(cv2.convexHull(cnt)).

Пример применения: Solidity\text{Solidity} отражает гладкость и монолитность границ. Выпуклые объекты (круги, неповрежденные прямоугольники, шестигранные гайки) имеют Solidity1.0\text{Solidity} \approx 1.0. Если деталь имеет глубокие вырезы, сколы или зазубрины, площадь контура становится заметно меньше площади выпуклой оболочки, и Solidity\text{Solidity} резко снижается (Solidity0.7\text{Solidity} \leq 0.7).

4. Округлость (Circularity / Compactness)

Численная мера близости формы контура к идеальному кругу:

C=4πScontourP2C = \frac{4\pi \cdot S_{\text{contour}}}{P^2}

  • ScontourS_{\text{contour}} — площадь фигуры.
  • PP — периметр контура, вычисленный через cv2.arcLength(cnt, True).

Для идеального круга C=1.0C = 1.0. Любое искажение формы или появление неровностей увеличивает периметр PP относительно площади, из-за чего CC стремится к нулю.

Дескриптор Формула Диапазон Инвариантность к повороту Назначение в CV-пайплайне
Aspect Ratio W/HW / H (0;+)(0; +\infty) Нет (для boundingRect), Да (для minAreaRect) Отбор вытянутых/квадратных объектов
Extent Scontour/SbboxS_{\text{contour}} / S_{\text{bbox}} (0;1](0; 1] Нет (для boundingRect), Да (для minAreaRect) Оценка заполнения контейнера
Solidity Scontour/ShullS_{\text{contour}} / S_{\text{hull}} (0;1](0; 1] Да Детекция зазубрин, вырезов и целостности
Circularity 4πScontour/P24\pi S_{\text{contour}} / P^2 (0;1](0; 1] Да Детекция круглых шайб, отверстий, труб

Алгоритмический конвейер: локализация и геометрическая фильтрация

Соберем модуль пространственного анализа. На вход подается сырое BGR-изображение, содержащее целевые прямоугольные металлические пластины с маркировкой, а также посторонний визуальный мусор (блики, капли эмульсии, срезанные углы).

Задача — локализовать только пластины, отфильтровав артефакты по площади, плотности и соотношению сторон.

import cv2
import numpy as np

def extract_candidate_contours(image_bgr: np.ndarray) -> list:
    # 1. Подавление высокочастотного шума
    gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 1.5)

    # 2. Адаптивная или глобальная бинаризация по Оцу
    _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # 3. Извлечение топологии (только внешние контуры без сжатия прямых участков)
    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    valid_contours = []

    for cnt in contours:
        area = cv2.contourArea(cnt)
        # Отсекаем микрошум и слишком крупные фоновые засветки
        if area < 1000 or area > 100000:
            continue

        perimeter = cv2.arcLength(cnt, True)
        if perimeter == 0:
            continue

        # Вычисление выпуклой оболочки и Solidity
        hull = cv2.convexHull(cnt)
        hull_area = cv2.contourArea(hull)
        solidity = float(area) / hull_area if hull_area > 0 else 0

        # Получение ориентированного ограничивающего прямоугольника
        # rect: ((center_x, center_y), (width, height), angle)
        rect = cv2.minAreaRect(cnt)
        (center_x, center_y), (w, h), angle = rect

        if w == 0 or h == 0:
            continue

        # Инвариантное к ориентации соотношение сторон (всегда >= 1.0)
        aspect_ratio = max(w, h) / min(w, h)
        rect_area = w * h
        extent = float(area) / rect_area if rect_area > 0 else 0

        # Строгая геометрическая фильтрация под спецификацию пластины:
        # Пластина монолитная (Solidity > 0.9), прямоугольная (Extent > 0.85),
        # с пропорциями от 1.8 до 2.4
        if solidity > 0.90 and extent > 0.85 and (1.8 <= aspect_ratio <= 2.4):
            valid_contours.append((cnt, rect))

    return valid_contours

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

Извлечение и нормализация ориентированной зоны интереса (Oriented ROI)

Когда объект на конвейере повернут на произвольный угол θ\theta, простой матричный срез image[ymin:ymax, xmin:xmax] вырезает деталь вместе со значительной частью окружающего фона. Более того, сам объект внутри среза остается перекошенным, что делает невозможным посимвольное чтение маркировки или сверку геометрии с эталонным CAD-чертежом.

Для извлечения канонического горизонтального ROI необходимо:

  1. Извлечь параметры повернутого прямоугольника через cv2.minAreaRect.
  2. Построить матрицу аффинного поворота вокруг центроида объекта.
  3. Повернуть исходный кадр на угол θ-\theta.
  4. Выполнить прямое кадрирование по вычисленным размерам (W,H)(W, H).

Ключевой инсайт: Функция cv2.minAreaRect возвращает угол в диапазоне [90,0)[-90^\circ, 0^\circ) или [0,90)[0^\circ, 90^\circ) (в зависимости от версии OpenCV) и может поменять местами ширину и высоту в зависимости от того, какая грань прямоугольника ближе к горизонтали. Перед формированием матрицы поворота необходимо нормализовать угол и стороны.

def crop_oriented_roi(image: np.ndarray, rect: tuple) -> np.ndarray:
    (center_x, center_y), (width, height), angle = rect

    # Нормализация угла и сторон: гарантируем, что width - большая сторона
    if width < height:
        width, height = height, width
        angle += 90.0

    # Формируем матрицу аффинного преобразования (поворот вокруг центра детали)
    rotation_matrix = cv2.getRotationMatrix2D((center_x, center_y), angle, scale=1.0)

    # Поворачиваем изображение целиком
    img_h, img_w = image.shape[:2]
    rotated = cv2.warpAffine(image, rotation_matrix, (img_w, img_h), flags=cv2.INTER_CUBIC)

    # Кадрируем выровненную область по центру
    x_min = int(center_x - width / 2.0)
    y_min = int(center_y - height / 2.0)
    x_max = int(center_x + width / 2.0)
    y_max = int(center_y + height / 2.0)

    # Защитное усечение координат границами изображения
    x_min, y_min = max(0, x_min), max(0, y_min)
    x_max, y_max = min(img_w, x_max), min(img_h, y_max)

    # Извлечение чистого горизонтального ROI без фонового мусора
    roi = rotated[y_min:y_max, x_min:x_max].copy()
    return roi

Такая процедура нормализации трансформирует произвольно ориентированный объект в инспекционном окне в стандартный ортогональный тензор, готовый для распознавания нейросетями или алгоритмами сопоставления с шаблоном.

Интеграция: сквозной пайплайн инспекции

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

def inspect_conveyor_frame(frame: np.ndarray) -> tuple:
    annotated_frame = frame.copy()
    extracted_rois = []

    # Шаг 1-3: Локализация и отбор по геометрическим инвариантам
    candidates = extract_candidate_contours(frame)

    for cnt, rect in candidates:
        # Шаг 4: Нормализация и извлечение ориентированной области интереса
        roi = crop_oriented_roi(frame, rect)
        if roi.size > 0:
            extracted_rois.append(roi)

        # Отрисовка ориентированного контура на кадре для оператора
        box = cv2.boxPoints(rect)
        box = np.int64(box)
        cv2.drawContours(annotated_frame, [box], 0, (0, 255, 0), 2)

        # Отрисовка центроида детали
        cx, cy = int(rect[0][0]), int(rect[0][1])
        cv2.circle(annotated_frame, (cx, cy), 4, (0, 0, 255), -1)

        # Подпись характеристик на кадре
        w, h = rect[1]
        label = f"W:{int(max(w,h))} H:{int(min(w,h))}"
        cv2.putText(annotated_frame, label, (cx - 30, cy - 10),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1)

    return annotated_frame, extracted_rois

Пайплайн объединяет все методы классического машинного зрения — от базовых матричных преобразований и сверточной фильтрации до пространственных моментов и аффинных трансформаций.

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