Морфология и геометрические трансформации

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

Математическая морфология: структурирующие элементы, эрозия и дилатация бинарных масок

Математическая морфология: структурирующие элементы, эрозия и дилатация бинарных масок

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

Теоретико-множественная природа бинарного растра

Классическая свертка, изученная ранее, оперирует линейной алгеброй — перемножает коэффициенты ядра на яркости пикселей и суммирует результат. Математическая морфология, основы которой заложили Жорж Матерон и Жан Серра в 1960-х годах, опирается на иную парадигму — теорию множеств и нелинейную геометрию.

Бинарное изображение рассматривается не как числовая матрица, а как дискретное множество точек AZ2A \subset \mathbb{Z}^2, где точка принадлежит множеству, если соответствующий пиксель равен 255 (передний план, белый), и не принадлежит ему, если пиксель равен 0 (фон, черный). Вся морфологическая обработка сводится к взаимодействию этого исходного множества AA с малым зондирующим множеством BB, называемым структурирующим элементом (Structuring Element, SE).

Базовой математической операцией морфологии выступает сложение Минковского. Сложение множеств AA и BB определяется как объединение всех попарных векторных сумм их элементов:

AB={a+baA,bB}A \oplus B = \{a + b \mid a \in A, b \in B\}

Здесь aa — вектор координат пикселя переднего плана объекта AA, bb — вектор смещения точки структурирующего элемента BB относительно его начала координат (якоря). На практике это означает, что структурирующий элемент BB последовательно центрируется на каждом пикселе объекта AA, и результирующая фигура формируется объединением всех таких отпечатков.

Структурирующий элемент: геометрия и якорная точка

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

Форма структурирующего элемента напрямую задает геометрический характер трансформации:

  • Прямоугольник (MORPH_RECT): все элементы матрицы равны единице. Воздействует на объект одинаково по осям и диагоналям, сглаживая форму в сторону прямых углов.
  • Крест (MORPH_CROSS): единицы расположены строго по центральной вертикали и горизонтали (связность 4). Бережно относится к диагональным скосам.
  • Эллипс / Диск (MORPH_ELLIPSE): аппроксимация круга внутри квадратной матрицы. Обеспечивает изотропную (равномерную во всех направлениях) обработку без искажения естественной округлости границ.

В библиотеке OpenCV создание структурирующего элемента выполняется функцией cv2.getStructuringElement:

import cv2

# Прямоугольное ядро 5x5
rect_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))

# Эллиптическое ядро 5x5 (изотропное)
ellipse_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

# Крестообразное ядро 3x3
cross_kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))

Размерность ядра всегда выбирается нечетной ((2k+1)×(2k+1)(2k+1) \times (2k+1)), чтобы якорь лежал строго на центральном целочисленном пикселе.

Эрозия (Erosion): математика сжатия

Эрозия (вычитание Минковского) — операция топологического уменьшения объекта. Она «соскабливает» граничный слой пикселей переднего плана.

Математически эрозия множества AA по структурирующему элементу BB определяется как геометрическое место всех таких точек смещения zz, при которых структурирующий элемент, сдвинутый в точку zz (обозначается как BzB_z), полностью содержится внутри множества AA:

AB={zZ2BzA}A \ominus B = \{z \in \mathbb{Z}^2 \mid B_z \subseteq A\}

Если хотя бы один единичный пиксель зонда BzB_z выходит за пределы белой области AA (попадает на черный фон), центральный пиксель zz в результирующем изображении обнуляется.

Ключевой инсайт эрозии: Центральный пиксель сохраняет значение 255 тогда и только тогда, когда ВСЕ пиксели маски, накрытые единицами структурирующего элемента, равны 255. Это эквивалентно локальной логической операции AND по всему окну зонда.

Практические эффекты эрозии:

  1. Удаление изолированного шума: мелкие белые пятна, размер которых меньше размера ядра BB, исчезают полностью.
  2. Разрыв тонких перемычек: узкие паразитные мостики между двумя близко расположенными объектами истончаются и рвутся.
  3. Уменьшение габаритов: общая площадь объектов сокращается, а внутренние отверстия увеличиваются.

В OpenCV эрозия вызывается методом cv2.erode:

# Базовый вызов эрозии
eroded_mask = cv2.erode(src=binary_mask, kernel=ellipse_kernel, iterations=1)

Дилатация (Dilation): математика расширения

Дилатация (наращивание) — операция, дуальная эрозии. Она наращивает внешний контур объектов переднего плана и затягивает внутренние пустоты.

Математически дилатация множества AA по структурирующему элементу BB определяется как геометрическое место точек zz, при которых сдвинутый структурирующий элемент BzB_z пересекается с множеством AA хотя бы по одному элементу:

AB={zZ2BzA}A \oplus B = \{z \in \mathbb{Z}^2 \mid B_z \cap A \neq \varnothing\}

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

Ключевой инсайт дилатации: Выходной пиксель равен 255, если ХОТЯ БЫ ОДИН пиксель под единичными ячейками ядра равен 255. Это эквивалентно локальной логической операции OR по всему окну зонда.

Практические эффекты дилатации:

  1. Заполнение внутренних пустот: мелкие черные дефекты и точечные пробоины внутри объекта исчезают.
  2. Сращивание фрагментов: разорванные части одного контура расширяются навстречу друг другу и объединяются в связную область.
  3. Увеличение габаритов: общая площадь объекта возрастает, а наружные границы сглаживаются согласно форме ядра.

Вызов в OpenCV:

# Базовый вызов дилатации
dilated_mask = cv2.dilate(src=binary_mask, kernel=ellipse_kernel, iterations=1)

Сравнение механизмов эрозии и дилатации

Для выбора подходящей операции при очистке масок необходимо четко понимать их дуальные свойства:

Критерий Эрозия (cv2.erode) Дилатация (cv2.dilate)
Логическая операция ядра Логическое AND (строгое совпадение) Логическое OR (хотя бы одно совпадение)
Влияние на площадь объекта Уменьшает площадь Увеличивает площадь
Влияние на шум Уничтожает белый шум на фоне Уничтожает черный шум внутри объекта
Внутренние отверстия Расширяет Затягивает / устраняет
Связность объектов Разделяет слабо связанные объекты Объединяет близко стоящие объекты

Управление итерациями и граничные условия

Параметр iterations в функциях cv2.erode и cv2.dilate определяет, сколько раз подряд операция применяется к результату предыдущего шага:

AnB=(((AB)B)B)A \ominus_n B = (((A \ominus B) \ominus B) \dots \ominus B)

Применение эрозии с ядром 3×33 \times 3 и параметром iterations=2 математически эквивалентно однократной эрозии с ядром 5×55 \times 5. Однако последовательное применение малого ядра вычислительно эффективнее, так как обходит меньшее число ячеек памяти на каждом проходе.

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

# Явное указание граничных условий
eroded = cv2.erode(
    binary_mask,
    kernel=rect_kernel,
    iterations=2,
    borderType=cv2.BORDER_CONSTANT,
    borderValue=0
)

Для бинарной морфологии критически важно использовать borderType=cv2.BORDER_CONSTANT с borderValue=0 (для эрозии) или borderValue=0/borderValue=255 в зависимости от того, считается ли край кадра фоном. Если край считается фоном (borderValue=0), эрозия естественным образом отсекает объекты, касающиеся границ изображения.

Практический пример: разделение слипшихся контуров

Рассмотрим реальный конвейерный сценарий. Две круглые металлические шайбы диаметром 60 пикселей упали рядом на конвейерную ленту. В месте касания образовалась паразитная перемычка шириной 4 пикселя. Наша цель — разделить их на два независимых контура для корректного подсчета.

import numpy as np
import cv2

def separate_touching_parts(raw_mask: np.ndarray) -> tuple[int, np.ndarray]:
    """
    Разделяет соприкасающиеся детали с помощью адаптивной эрозии
    и возвращает количество найденных валидных объектов.
    """
    # 1. Создаем изотропный структурирующий элемент радиусом больше перемычки (3x3 или 5x5)
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

    # 2. Выполняем эрозию: перемычка толщиной 4px гарантированно разрушится за 1-2 итерации
    eroded_mask = cv2.erode(raw_mask, kernel, iterations=2)

    # 3. Извлекаем контуры разделенных объектов
    contours, _ = cv2.findContours(eroded_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    # 4. Фильтруем контуры по площади, отсекая случайный остаточный шум
    valid_objects = [cnt for cnt in contours if cv2.contourArea(cnt) > 50]

    return len(valid_objects), eroded_mask

В этом примере эрозия уменьшила радиус каждой шайбы, но полностью уничтожила перемычку толщиной 4 пикселя, так как ядро 5×55 \times 5 за две итерации «съело» по 4 пикселя с каждой стороны. В результате топологический алгоритм findContours увидел два изолированных замкнутых контура вместо одного слипшегося.

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

Составные морфологические операции: размыкание, замыкание, градиент и фильтры Top-Hat и Black-Hat

Составные морфологические операции: размыкание, замыкание, градиент и фильтры Top-Hat и Black-Hat

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

Решение заключается в каскадном объединении базовых операций и вычислении их разностей.

Размыкание и замыкание: фильтрация без изменения масштаба

Последовательное применение эрозии и дилатации с одним и тем же структурирующим элементом формирует два базовых составных оператора — размыкание (Opening) и замыкание (Closing).

Размыкание (Opening): Исходная маска → [ Эрозия ] → [ Дилатация ] → Результат
Замыкание (Closing):   Исходная маска → [ Дилатация ] → [ Эрозия ] → Результат

Размыкание (Opening)

Математически размыкание множества AA структурирующим элементом BB обозначается символом \circ и определяется как эрозия, за которой следует дилатация:

AB=(AB)BA \circ B = (A \ominus B) \oplus B

  • AA — исходное бинарное изображение (множество пикселей переднего плана);
  • BB — структурирующий элемент (ядро зондирования);
  • \ominus — операция эрозии (сжатия);
  • \oplus — операция дилатации (расширения).

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

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

Замыкание (Closing)

Замыкание множества AA структурирующим элементом BB обозначается символом \bullet и представляет собой дилатацию с последующей эрозией:

AB=(AB)BA \bullet B = (A \oplus B) \ominus B

  • AA — исходное бинарное изображение;
  • BB — структурирующий элемент;
  • \oplus — операция дилатации;
  • \ominus — операция эрозии.

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

Идемпотентность морфологических фильтров

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

(AB)B=ABи(AB)B=AB(A \circ B) \circ B = A \circ B \quad \text{и} \quad (A \bullet B) \bullet B = A \bullet B

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

Универсальный интерфейс OpenCV: функция cv2.morphologyEx

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

import cv2
import numpy as np

# Создаем прямоугольное ядро 5x5
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))

# 1. Размыкание: удаление белого шума и разрыв перемычек
opened_mask = cv2.morphologyEx(raw_mask, cv2.MORPH_OPEN, kernel, iterations=1)

# 2. Замыкание: затягивание черных дыр и трещин внутри контуров
closed_mask = cv2.morphologyEx(raw_mask, cv2.MORPH_CLOSE, kernel, iterations=1)

Параметр iterations задает количество последовательных циклов базовых шагов: для MORPH_OPEN это означает N эрозий подряд, а затем N дилатаций подряд.

Операция Флаг OpenCV Механизм Главная задача
Opening cv2.MORPH_OPEN Erode \rightarrow Dilate Удаление фонового шума, разделение тонких перемычек
Closing cv2.MORPH_CLOSE Dilate \rightarrow Erode Затягивание пор, трещин и склеивание разрывов контура
Gradient cv2.MORPH_GRADIENT Dilate - Erode Извлечение топологических границ заданной толщины
Top-Hat cv2.MORPH_TOPHAT Src - Opening Выделение ярких объектов меньше ядра на темном/неравномерном фоне
Black-Hat cv2.MORPH_BLACKHAT Closing - Src Выделение темных дефектов меньше ядра на светлом фоне

Морфологический градиент: геометрическое выделение границ

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

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

G(A)=(AB)(AB)G(A) = (A \oplus B) - (A \ominus B)

  • G(A)G(A) — результирующая матрица границ;
  • ABA \oplus B — расширенная версия объекта;
  • ABA \ominus B — сжатая версия объекта.
# Вычисление стандартного градиента в OpenCV
morph_gradient = cv2.morphologyEx(binary_mask, cv2.MORPH_GRADIENT, kernel)

Помимо стандартного градиента существуют два асимметричных варианта:

  • Внутренний градиент: Gint(A)=A(AB)G_{\text{int}}(A) = A - (A \ominus B) — линия границы лежит строго внутри исходного контура объекта.
  • Внешний градиент: Gext(A)=(AB)AG_{\text{ext}}(A) = (A \oplus B) - A — линия границы опоясывает объект снаружи по фону.

Ключевой инсайт: в отличие от детектора Canny, морфологический градиент не требует вычисления производных и тригонометрии направлений. Толщина извлекаемой границы строго детерминирована размером структурирующего элемента BB: ядро 3×33 \times 3 дает границу шириной ровно в 2 пикселя, а ядро 5×55 \times 5 — в 4 пикселя.

Морфология в градациях серого (Grayscale Morphology)

Морфологические операторы применимы не только к бинарным маскам (0 и 255), но и к полутоновым 8-битным изображениям (grayscale).

В градациях серого пиксели интерпретируются как непрерывная топографическая поверхность: яркость пикселя задает высоту рельефа (светлые пиксели — «пики» и «плато», темные пиксели — «впадины» и «овраги»).

Для плоского структурирующего элемента BB операции на полутоновом изображении I(x,y)I(x, y) принимают вид локальных нелинейных статистик:

  • Эрозия в градациях серого заменяет значение в центре окна на локальный минимум яркости в окрестности, заданной ядром:

    (IB)(x,y)=min(i,j)BI(x+i,y+j)(I \ominus B)(x, y) = \min_{(i, j) \in B} I(x + i, y + j)

  • Дилатация в градациях серого заменяет значение в центре окна на локальный максимум яркости в окрестности:

    (IB)(x,y)=max(i,j)BI(x+i,y+j)(I \oplus B)(x, y) = \max_{(i, j) \in B} I(x + i, y + j)

Соответственно, полутоновое размыкание срезает узкие яркие пики (светлые блики, точечные дефекты), а полутоновое замыкание заливает узкие темные ямы (царапины, поры), не затрагивая пологие склоны крупного масштаба.

Фильтры Top-Hat и Black-Hat: компенсация неравномерного освещения

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

Если фон в левом углу кадра имеет яркость 180, а целевой дефект в правом углу — яркость 120, никакой единый порог не сможет изолировать дефект. Здесь на помощь приходят разностные фильтры Top-Hat (White Top-Hat) и Black-Hat (Bottom-Hat).

White Top-Hat: выделение локальных ярких элементов

Оператор Top-Hat вычисляет разность между исходным полутоновым изображением и его размыканием:

That(I)=I(IB)T_{\text{hat}}(I) = I - (I \circ B)

  • II — исходное полутоновое изображение;
  • IBI \circ B — результат размыкания полутонового изображения.

Физический смысл: если структурирующий элемент BB выбран больше, чем целевые светлые объекты (например, символы гравировки или зерна дефектов), но меньше зоны медленного изменения фонового освещения, то операция размыкания IBI \circ B сотрет мелкие символы, но идеально сохранит низкочастотный фоновый световой градиент.

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

Black-Hat: выделение локальных темных элементов

Оператор Black-Hat зеркален к Top-Hat и вычисляет разность между замыканием изображения и самим оригиналом:

Bhat(I)=(IB)IB_{\text{hat}}(I) = (I \bullet B) - I

  • IBI \bullet B — результат полутонового замыкания;
  • II — исходное изображение.

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

import cv2

# Загружаем полутоновое изображение металлической пластины
gray = cv2.imread('metal_part.png', cv2.IMREAD_GRAYSCALE)

# Структурирующий элемент выбираем строго БОЛЬШЕ толщины символов/трещин (например, 15x15)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))

# 1. Извлечение светлых элементов на неравномерном фоне (лазерная маркировка)
white_tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)

# 2. Извлечение темных элементов на ярком фоне (микротрещины, гравировка)
black_hat = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)

Практический пайплайн: извлечение контуров маркировки при экстремальном световом градиенте

Соберем изученные компоненты в законченный инженерный конвейер. Задача: извлечь замкнутые контуры выбитого серийного номера на стальной детали, когда поперек кадра падает резкая косая тень.

import cv2
import numpy as np

def extract_engraved_contours(image_path: str):
    # 1. Загрузка в полутоновом режиме
    gray = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
    if gray is None:
        raise FileNotFoundError("Не удалось загрузить изображение")

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

    # 3. Фильтрация низкочастотного градиента освещенности через Top-Hat
    # Шрифт имеет толщину линий ~4px, выбираем прямоугольное ядро 17x17
    kernel_illum = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17))
    tophat_corrected = cv2.morphologyEx(blurred, cv2.MORPH_TOPHAT, kernel_illum)

    # 4. Пороговая сегментация Оцу (теперь гистограмма строго бимодальна!)
    _, binary = cv2.threshold(
        tophat_corrected, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
    )

    # 5. Очистка бинарной маски:
    # Замыкание (склеиваем точки штамповки в сплошные символы)
    kernel_clean = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel_clean)
    # Размыкание (удаляем остаточный точечный шум)
    cleaned = cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel_clean)

    # 6. Извлечение внешних контуров символов
    contours, _ = cv2.findContours(
        cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    return contours, cleaned

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

Аффинные трансформации и методы интерполяции: математика матриц 2x3 и передискретизация растра

Аффинные трансформации и методы интерполяции: математика матриц 2x3 и передискретизация растра

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

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


Пространство аффинных преобразований

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

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

Инварианты аффинного преобразования:

  1. Прямые линии всегда переходят в прямые линии.
  2. Параллельные прямые остаются параллельными.
  3. Отношение длин параллельных отрезков сохраняется.
  4. Отношение площадей фигур остается постоянным для всей плоскости.

В двумерном евклидовом пространстве точку с координатами (x,y)(x, y) переводят в новую точку (x,y)(x', y') с помощью системы уравнений:

{x=a11x+a12y+txy=a21x+a22y+ty\begin{cases} x' = a_{11} x + a_{12} y + t_x \\ y' = a_{21} x + a_{22} y + t_y \end{cases}

В этой системе коэффициенты a11,a12,a21,a22a_{11}, a_{12}, a_{21}, a_{22} отвечают за поворот, масштабирование и сдвиг фазы (скос/shear), а свободные члены txt_x и tyt_y задают параллельный перенос вдоль осей XX и YY.

Чтобы записать преобразование компактно в виде одного матричного умножения, используют однородные координаты (homogeneous coordinates), расширяя двумерный вектор фиктивной единицей:

[xy]=[a11a12txa21a22ty][xy1]=M[xy1]\begin{bmatrix} x' \\ y' \end{bmatrix} = \begin{bmatrix} a_{11} & a_{12} & t_x \\ a_{21} & a_{22} & t_y \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = M \cdot \begin{bmatrix} x \\ y \\ 1 \end{bmatrix}

Матрица MM размером 2×32 \times 3 содержит ровно 6 степеней свободы, полностью определяющих поведение плоскости.

Базовые матрицы преобразований

Любое сложное аффинное преобразование раскладывается в последовательное применение элементарных операций:

  1. Параллельный перенос (Translation) на вектор (tx,ty)(t_x, t_y):

Mtrans=[10tx01ty]M_{trans} = \begin{bmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \end{bmatrix}

  1. Масштабирование (Scaling) с коэффициентами sxs_x и sys_y:

Mscale=[sx000sy0]M_{scale} = \begin{bmatrix} s_x & 0 & 0 \\ 0 & s_y & 0 \end{bmatrix}

  1. Скос (Shear / Skew) вдоль координатных осей:

Mshear=[1shx0shy10]M_{shear} = \begin{bmatrix} 1 & sh_x & 0 \\ sh_y & 1 & 0 \end{bmatrix}

  1. Поворот (Rotation) вокруг начала координат (0,0)(0, 0) на угол θ\theta против часовой стрелки:

Mrot=[cosθsinθ0sinθcosθ0]M_{rot} = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \end{bmatrix}

Если повернуть изображение вокруг центра объекта (xc,yc)(x_c, y_c), а не вокруг левого верхнего угла матрицы, выполняется каскад из трех преобразований: перенос центра в точку (0,0)(0,0), поворот на угол θ\theta и обратный перенос. В OpenCV для этого используется функция cv2.getRotationMatrix2D(center, angle, scale):

import cv2

# Получение матрицы поворота на 45 градусов вокруг точки (100, 100) с масштабом 1.0
center = (100.0, 100.0)
angle = 45.0
scale = 1.0

M = cv2.getRotationMatrix2D(center, angle, scale)
# Результат M: ndarray формы (2, 3) типа float64

Поскольку матрица MM имеет 6 неизвестных параметров, для ее однозначного вычисления достаточно знать соответствие трех неколлинеарных точек на исходном и результирующем изображениях. Функция cv2.getAffineTransform(src_pts, dst_pts) формирует и решает систему линейных уравнений:

import numpy as np

# Задание трех опорных точек в исходной и целевой системах координат
src_pts = np.float32([[50, 50], [200, 50], [50, 200]])
dst_pts = np.float32([[70, 100], [220, 50], [150, 250]])

M = cv2.getAffineTransform(src_pts, dst_pts)

Прямой и обратный маппинг координат

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

Если применить прямое отображение (Forward Mapping), для каждого целочисленного пикселя исходного изображения (xsrc,ysrc)(x_{src}, y_{src}) вычисляются координаты (xdst,ydst)=M[xsrc,ysrc,1]T(x_{dst}, y_{dst}) = M \cdot [x_{src}, y_{src}, 1]^T. Результатом вычислений почти всегда являются вещественные числа (например, xdst=12.43,ydst=87.19x_{dst} = 12.43, y_{dst} = 87.19).

Округление до ближайших целых значений приводит к двум фатальным дефектам:

  • Дырки (Holes): часть пикселей целевого растра не получает ни одного значения из-за растяжения или поворота.
  • Наложения (Collisions): несколько исходных пикселей претендуют на одну и ту же ячейку целевого растра.

По этой причине все библиотеки компьютерного зрения, включая OpenCV, реализуют геометрические трансформации через обратный маппинг (Inverse Mapping / Backward Mapping).

Алгоритм обратного маппинга:

  1. Выполняется итерация по строго целочисленной сетке координат целевого изображения (xdst,ydst)[0,W1]×[0,H1](x_{dst}, y_{dst}) \in [0, W-1] \times [0, H-1].
  2. Для каждого целевого пикселя с помощью обратной матрицы M1M^{-1} находятся его координаты в исходном изображении:

[xsrcysrc]=M1[xdstydst1]\begin{bmatrix} x_{src} \\ y_{src} \end{bmatrix} = M^{-1} \begin{bmatrix} x_{dst} \\ y_{dst} \\ 1 \end{bmatrix}

  1. Значение цвета или яркости в вещественной точке (xsrc,ysrc)(x_{src}, y_{src}) рассчитывается через интерполяцию соседних дискретных пикселей исходного кадра.

Методы интерполяции и передискретизация растра

Когда вычисленные координаты (xsrc,ysrc)(x_{src}, y_{src}) падают между узлами целочисленной сетки пикселей, выбор алгоритма интерполяции определяет резкость, скорость работы и наличие артефактов на результирующем кадре.

Пусть xsrc=x0+Δxx_{src} = x_0 + \Delta x и ysrc=y0+Δyy_{src} = y_0 + \Delta y, где x0=xsrcx_0 = \lfloor x_{src} \rfloor, y0=ysrcy_0 = \lfloor y_{src} \rfloor — целые части, а Δx,Δy[0,1)\Delta x, \Delta y \in [0, 1) — дробные смещения.

Метод интерполяции Флаг OpenCV Окрестность Особенности и применение
Ближайший сосед (Nearest Neighbor) cv2.INTER_NEAREST 1×11 \times 1 Мгновенная скорость, ступенчатые края. Обязателен для бинарных масок и карт сегментации, так как не создает промежуточных классов.
Билинейная (Bilinear) cv2.INTER_LINEAR 2×22 \times 2 Оптимальный баланс между качеством и скоростью. Стандарт по умолчанию в большинстве пайплайнов детекции.
Бикубическая (Bicubic) cv2.INTER_CUBIC 4×44 \times 4 Высокая резкость, кубические сплайны. Требует в 343-4 раза больше вычислительных ресурсов, чем билинейная.
Ланцоша (Lanczos) cv2.INTER_LANCZOS4 8×88 \times 8 Интерполяция через sinc-фильтр. Максимальная детализация для высокоточного оптического контроля, наиболее ресурсоемкая.
По площади (Area) cv2.INTER_AREA Адаптивная Передискретизация через усреднение пиксельных блоков. Лучший выбор для уменьшения масштаба (downsampling): подавляет муар и алиасинг.

Математическая модель билинейной интерполяции

Билинейная интерполяция вычисляет взвешенное среднее четырех ближайших узлов сетки: I(x0,y0)I(x_0, y_0), I(x0+1,y0)I(x_0+1, y_0), I(x0,y0+1)I(x_0, y_0+1) и I(x0+1,y0+1)I(x_0+1, y_0+1).

I(xsrc,ysrc)=(1Δx)(1Δy)I(x0,y0)+Δx(1Δy)I(x0+1,y0)+(1Δx)ΔyI(x0,y0+1)+ΔxΔyI(x0+1,y0+1)\begin{aligned} I(x_{src}, y_{src}) &= (1 - \Delta x)(1 - \Delta y) \cdot I(x_0, y_0) \\ &+ \Delta x (1 - \Delta y) \cdot I(x_0 + 1, y_0) \\ &+ (1 - \Delta x) \Delta y \cdot I(x_0, y_0 + 1) \\ &+ \Delta x \Delta y \cdot I(x_0 + 1, y_0 + 1) \end{aligned}

В этой формуле веса пикселей пропорциональны площадям противоположных прямоугольников в пределах единичной ячейки. Если Δx=0.2\Delta x = 0.2 и Δy=0.1\Delta y = 0.1, максимальный вклад (0.8×0.9=0.720.8 \times 0.9 = 0.72, то есть 72%72\%) вносит верхний левый пиксель I(x0,y0)I(x_0, y_0), что обеспечивает гладкий градиент без резких скачков яркости.


Практическая реализация: конвейер трансформации через cv2.warpAffine

Для выполнения трансформации всего растра в OpenCV применяется функция cv2.warpAffine:

dst=warpAffine(src,M,dsize,flags,borderMode,borderValue)\text{dst} = \text{warpAffine}(\text{src}, M, \text{dsize}, \text{flags}, \text{borderMode}, \text{borderValue})

Параметры:

  • src — исходное изображение (любое число каналов, uint8 или float32).
  • M — матрица аффинной трансформации размером 2×32 \times 3 типа float32 или float64.
  • dsize — кортеж (ширина, высота) результирующего кадра. Важно: порядок аргументов (width, height), а не (height, width).
  • flags — комбинация метода интерполяции (например, cv2.INTER_LINEAR) и опционального флага cv2.WARP_INVERSE_MAP (если передаваемая матрица MM уже является обратной).
  • borderMode — метод экстраполяции пикселей, выходящих за пределы исходного кадра (cv2.BORDER_CONSTANT, cv2.BORDER_REFLECT_101 и др.).
  • borderValue — цвет заполнения пустых областей при BORDER_CONSTANT (по умолчанию 0 — черный).

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

import cv2
import numpy as np

def align_component(image: np.ndarray, binary_mask: np.ndarray) -> np.ndarray:
    """
    Выравнивает объект горизонтально по главной оси минимального описанного прямоугольника.
    """
    # 1. Поиск внешнего контура объекта
    contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return image.copy()

    main_contour = max(contours, key=cv2.contourArea)

    # 2. Получение ориентированного прямоугольника: ((cx, cy), (w, h), angle)
    rect = cv2.minAreaRect(main_contour)
    (cx, cy), (w, h), angle = rect

    # Нормализация угла для горизонтального выравнивания
    if w < h:
        angle = angle - 90.0

    # 3. Вычисление матрицы поворота вокруг центроида
    h_img, w_img = image.shape[:2]
    M = cv2.getRotationMatrix2D(center=(cx, cy), angle=angle, scale=1.0)

    # 4. Применение аффинной трансформации с билинейной интерполяцией
    aligned = cv2.warpAffine(
        src=image,
        M=M,
        dsize=(w_img, h_img),
        flags=cv2.INTER_LINEAR,
        borderMode=cv2.BORDER_CONSTANT,
        borderValue=(0, 0, 0)
    )

    return aligned

Аффинные преобразования дают полный контроль над плоским движением, масштабированием и сдвигом объектов. Однако если плоскость объекта наклонена относительно матрицы камеры под углом в трехмерном пространстве, параллельные линии на изображении сходятся в точку схода. Для компенсации таких искажений возможностей матрицы 2×32 \times 3 недостаточно — требуется переход к проективной геометрии и матрицам гомографии 3×33 \times 3.

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

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

Аффинная матрица 2×32 \times 3 безупречно компенсирует сдвиг, вращение и масштабирование, когда объект съёмки расположен строго параллельно плоскости матрицы камеры. Однако в реальных промышленных условиях камера на конвейере часто установлена под углом к ленте, сканер считывает этикетку с искривлённой поверхности, а робот-манипулятор наблюдает за рабочей зоной по диагонали. В таких условиях прямоугольный объект превращается в произвольный четырёхугольник, параллельные направляющие сходятся в точку схода, а попытка выровнять такой кадр аффинным преобразованием терпит крах.

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

От аффинности к проекции: геометрия гомографии

Когда плоскость объекта не параллельна сенсору камеры, лучи света сходятся в оптическом центре объектива, создавая перспективное сокращение: ближний край объекта кажется шире, а дальний — уже. Проективное преобразование (гомография) — это взаимно-однозначное отображение одной плоскости на другую в проективном пространстве [1].

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

Свойство трансформации Аффинное (2×32 \times 3) Проективное / Гомография (3×33 \times 3)
Сохранение прямых линий Да Да
Сохранение параллельности Да Нет (параллельные линии сходятся)
Сохранение отношений отрезков на прямой Да Нет (сохраняется только сложное двойное отношение)
Степеней свободы (DOF) 6 8
Мин. число опорных точек для расчёта 3 точки (3×2=63 \times 2 = 6 уравнений) 4 точки (4×2=84 \times 2 = 8 уравнений)

В однородных координатах проективное преобразование точки (x,y)(x, y) в точку (x,y)(x', y') описывается матрицей 3×33 \times 3:

[xwyww]=H[xy1]=[h11h12h13h21h22h23h31h32h33][xy1]\begin{bmatrix} x'_w \\ y'_w \\ w' \end{bmatrix} = \mathbf{H} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = \begin{bmatrix} h_{11} & h_{12} & h_{13} \\ h_{21} & h_{22} & h_{23} \\ h_{31} & h_{32} & h_{33} \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix}

Здесь H\mathbf{H} — матрица гомографии [1], (x,y)(x, y) — исходные декартовы координаты пикселя, (xw,yw,w)(x'_w, y'_w, w') — промежуточный вектор в однородных координатах. Коэффициенты h31h_{31} и h32h_{32} отвечают за наклон плоскости в пространстве и вызывают перспективное схождение прямых.

Чтобы вернуться из однородных координат к реальным пиксельным координатам (x,y)(x', y'), необходимо выполнить перспективное деление на коэффициент масштаба ww':

x=xww=h11x+h12y+h13h31x+h32y+h33,y=yww=h21x+h22y+h23h31x+h32y+h33x' = \frac{x'_w}{w'} = \frac{h_{11}x + h_{12}y + h_{13}}{h_{31}x + h_{32}y + h_{33}}, \quad y' = \frac{y'_w}{w'} = \frac{h_{21}x + h_{22}y + h_{23}}{h_{31}x + h_{32}y + h_{33}}

Матрица H\mathbf{H} определена с точностью до масштабного множителя: умножение всех девяти элементов hijh_{ij} на ненулевую константу λ\lambda не меняет результат деления на ww' [1]. Поэтому фиксируют h33=1h_{33} = 1, оставляя ровно 8 независимых степеней свободы [1].

Поскольку каждая пара соответствующих точек (x,y)(x,y)(x, y) \to (x', y') даёт два независимых линейных уравнения для коэффициентов гомографии, для однозначного нахождения всех 8 параметров матрицы H\mathbf{H} необходимо и достаточно ровно 4 пары точек, из которых никакие три не лежат на одной прямой [1].

Вычисление геометрии целевого прямоугольника

Перед тем как исправлять перспективу объекта (например, контура печатной платы или маркировочной таблички), нужно рассчитать размеры WW и HH целевого неискажённого изображения.

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

Пусть вершины четырёхугольника в порядке обхода обозначены как P0P_0 (верхний левый), P1P_1 (верхний правый), P2P_2 (нижний правый), P3P_3 (нижний левый).

d(A,B)=(xBxA)2+(yByA)2d(A, B) = \sqrt{(x_B - x_A)^2 + (y_B - y_A)^2}

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

  • widthA=d(P0,P1)\text{width}_A = d(P_0, P_1), widthB=d(P3,P2)    W=max(widthA,widthB)\text{width}_B = d(P_3, P_2) \implies W = \max(\text{width}_A, \text{width}_B)
  • heightA=d(P0,P3)\text{height}_A = d(P_0, P_3), heightB=d(P1,P2)    H=max(heightA,heightB)\text{height}_B = d(P_1, P_2) \implies H = \max(\text{height}_A, \text{height}_B)

Целевые координаты dst\text{dst} задаются прямоугольником от (0,0)(0, 0) до (W1,H1)(W - 1, H - 1):

dst_points = np.array([
    [0, 0],
    [max_w - 1, 0],
    [max_w - 1, max_h - 1],
    [0, max_h - 1]
], dtype=np.float32)

Проблема согласованного порядка вершин

Алгоритмы аппроксимации контуров (approxPolyDP) возвращают 4 вершины четырёхугольника в произвольном порядке и с произвольной стартовой точки. Если передать несогласованные точки в функцию расчёта гомографии, результирующее изображение окажется повернутым на бок, перевернутым вверх ногами или вывернутым «восьмеркой» из-за пересечения диагоналей.

Топологический порядок вершин источника и приёмника должен строго совпадать. Если первой точкой источника указан левый верхний угол, то и первой точкой приёмника обязана быть точка (0,0)(0, 0).

Универсальный и вычислительно устойчивый способ сортировки 4 точек выпуклого четырёхугольника базируется на проекции координат на диагональные оси:

  1. Сумма координат (x+yx + y):
    • Верхняя левая точка расположена ближе всего к началу координат, поэтому сумма x+yx + y для неё минимальна.
    • Нижняя правая точка максимально удалена от начала координат, её сумма x+yx + y максимальна.
  2. Разность координат (yxy - x):
    • Верхняя правая точка имеет малый yy и большой xx, следовательно, разность yxy - x минимальна (наиболее отрицательна).
    • Нижняя левая точка имеет большой yy и малый xx, следовательно, разность yxy - x максимальна.
import numpy as np

def order_quad_points(pts: np.ndarray) -> np.ndarray:
    """
    Упорядочивает 4 точки четырехугольника в порядке:
    [top-left, top-right, bottom-right, bottom-left].

    :param pts: массив координат формы (4, 2)
    :return: упорядоченный массив формы (4, 2) типа float32
    """
    pts = pts.reshape((4, 2))
    ordered = np.zeros((4, 2), dtype=np.float32)

    # Сумма координат: min -> top-left, max -> bottom-right
    coord_sum = pts.sum(axis=1)
    ordered[0] = pts[np.argmin(coord_sum)]
    ordered[2] = pts[np.argmax(coord_sum)]

    # Разность координат (y - x): min -> top-right, max -> bottom-left
    coord_diff = np.diff(pts, axis=1)  # вычисляет (y - x)
    ordered[1] = pts[np.argmin(coord_diff)]
    ordered[3] = pts[np.argmax(coord_diff)]

    return ordered

Программная реализация в OpenCV: getPerspectiveTransform и warpPerspective

В OpenCV расчёт матрицы гомографии и трансформация растра разделены на два этапа:

  • cv2.getPerspectiveTransform(src, dst) — принимает массивы координат src и dst формы (4,2)(4, 2) типа float32 и возвращает матрицу гомографии H\mathbf{H} размером 3×33 \times 3 [1].
  • cv2.warpPerspective(src_img, M, dsize, flags, borderMode, borderValue) — выполняет обратный маппинг пикселей с билинейной или бикубической интерполяцией.

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

import cv2
import numpy as np

def unwarp_perspective_region(image: np.ndarray, quad_pts: np.ndarray) -> np.ndarray:
    """
    Вырезает и устраняет перспективные искажения четырехугольной зоны интереса.
    """
    # 1. Сортировка исходных вершин в стабильном порядке
    src_ordered = order_quad_points(quad_pts)
    (tl, tr, br, bl) = src_ordered

    # 2. Вычисление физических габаритов целевого прямоугольника
    width_a = np.linalg.norm(br - bl)
    width_b = np.linalg.norm(tr - tl)
    max_w = int(max(width_a, width_b))

    height_a = np.linalg.norm(tr - br)
    height_b = np.linalg.norm(tl - bl)
    max_h = int(max(height_a, height_b))

    # Защитная валидация нулевых размеров
    if max_w <= 0 or max_h <= 0:
        return np.empty((0, 0, image.shape[2] if image.ndim == 3 else 1), dtype=image.dtype)

    # 3. Формирование сетки целевых координат
    dst_ordered = np.array([
        [0, 0],
        [max_w - 1, 0],
        [max_w - 1, max_h - 1],
        [0, max_h - 1]
    ], dtype=np.float32)

    # 4. Вычисление матрицы гомографии 3x3
    homography_matrix = cv2.getPerspectiveTransform(src_ordered, dst_ordered)

    # 5. Обратный маппинг с билинейной интерполяцией
    unwarped = cv2.warpPerspective(
        image,
        homography_matrix,
        (max_w, max_h),
        flags=cv2.INTER_LINEAR,
        borderMode=cv2.BORDER_CONSTANT,
        borderValue=(0, 0, 0)
    )

    return unwarped

Сквозной конвейер: от бинарной маски до ортотрансформированного объекта

Объединим методы контурного анализа и проективной геометрии в единый пайплайн нормализации объектов на конвейере:

  1. Бинаризация и фильтрация шума: выделение силуэта детали с помощью порогового отсечения или цветовой маски.
  2. Анализ контуров: поиск внешнего контура наибольшей площади и его полигональная аппроксимация через cv2.approxPolyDP.
  3. Проверка геометрии: верификация, что аппроксимированный полигон имеет ровно 4 вершины и является выпуклым (cv2.isContourConvex).
  4. Ортотрансформация: расчёт матрицы H\mathbf{H} и выравнивание через cv2.warpPerspective.
def extract_ortho_component(frame: np.ndarray, binary_mask: np.ndarray) -> np.ndarray | None:
    # Поиск внешних границ
    contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return None

    # Поиск крупнейшего контура
    largest_cnt = max(contours, key=cv2.contourArea)

    # Полигональная аппроксимация
    perimeter = cv2.arcLength(largest_cnt, True)
    approx = cv2.approxPolyDP(largest_cnt, 0.02 * perimeter, True)

    # Если контур аппроксимировался в четырехугольник и он выпуклый
    if len(approx) == 4 and cv2.isContourConvex(approx):
        quad_points = approx.reshape(4, 2)
        return unwarp_perspective_region(frame, quad_points)

    return None

После выполнения ортотрансформации объект оказывается приведен к каноническому ортогональному виду «сверху вниз» (Bird's-Eye View). Это позволяет последующим модулям оптического контроля производить прецизионные измерения размеров, считывать штрихкоды и анализировать микродефекты с помощью стандартных дескрипторов, не заботясь об угле наклона камеры.

Произвольные координатные отображения: ремаппинг и преобразование в полярные координаты

Произвольные координатные отображения: ремаппинг и преобразование в полярные координаты

Матрица гомографии 3×33 \times 3 способна компенсировать перспективный наклон камеры и восстановить геометрию плоского документа, однако она бессильна, если маркировка детали выбита по дуге вдоль фланца или круглой крышки. Причина фундаментальна: проективные и аффинные матрицы сохраняют прямолинейность линий. Преобразовать искривлённую траекторию в горизонтальную строку текста без нарушения коллинеарности матричным умножением невозможно.

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


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

В основе любых растровых трансформаций лежит идея обратного маппинга: для каждого целочисленного пикселя целевого изображения (xdst,ydst)(x_{\text{dst}}, y_{\text{dst}}) необходимо определить непрерывные вещественные координаты (xsrc,ysrc)(x_{\text{src}}, y_{\text{src}}) в исходном кадре, а затем вычислить результирующую яркость через интерполяцию.

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

  • mapx(xdst,ydst)\text{map}_x(x_{\text{dst}}, y_{\text{dst}}) — матрица, хранящая вещественную XX-координату источника для каждого пикселя назначения.
  • mapy(xdst,ydst)\text{map}_y(x_{\text{dst}}, y_{\text{dst}}) — матрица, хранящая вещественную YY-координату источника для каждого пикселя назначения.

Оба массива имеют пространственную форму (H_dst, W_dst), совпадающую с геометрическими размерами целевого кадра. Вычисление итогового растра выполняет функция cv2.remap:

dst = cv2.remap(
    src,
    map1=map_x,
    map2=map_y,
    interpolation=cv2.INTER_LINEAR,
    borderMode=cv2.BORDER_CONSTANT,
    borderValue=0
)

Форматы данных и оптимизация производительности

Для обеспечения субпиксельной точности карты координат создаются с типом np.float32. Однако на конвейерных скоростях чтение двух независимых матриц с плавающей точкой создает избыточную нагрузку на шину памяти процессора.

OpenCV предоставляет механизм оптимизации через функцию cv2.convertMaps. Она объединяет map_x и map_y в компактный целочисленный формат с фиксированной точкой:

Формат карт Тип данных map1 Тип данных map2 Назначение и специфика
Вещественный (базовый) CV_32FC1 (float32) CV_32FC1 (float32) Удобен для генерации математических формул через векторные операции NumPy
Фиксированная точка CV_16SC2 (int16, 2 канала) CV_16UC1 (uint16) map1 хранит целочисленные координаты пикселей, map2 — таблицу коэффициентов интерполяции
# Конвертация float32-карт в оптимизированный формат для быстрого выполнения
map1_fast, map2_fast = cv2.convertMaps(map_x, map_y, cv2.CV_16SC2)
dst = cv2.remap(src, map1_fast, map2_fast, interpolation=cv2.INTER_LINEAR)

Такая оптимизация ускоряет выполнение cv2.remap на CPU в 1.5–2.5 раза за счёт плотной упаковки данных в L1/L2 кэш-памяти.


Математика полярного преобразования

Частным и наиболее востребованным случаем нелинейного ремаппинга является переход из декартовой системы координат (x,y)(x, y) в полярную (θ,r)(\theta, r).

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

x=xc+rcos(θ),y=yc+rsin(θ)x = x_c + r \cdot \cos(\theta), \quad y = y_c + r \cdot \sin(\theta)

Элементы формулы:

  • xc,ycx_c, y_c — декартовы координаты центра полярной системы (полюс/центр окружности).
  • rr — текущий радиус, изменяющийся от 00 до rmaxr_{\max}.
  • θ\theta — полярный угол в радианах, изменяющийся от 00 до 2π2\pi.

В реализации OpenCV (cv2.warpPolar) радиальная координата rr отображается на горизонтальную ось XX (столбцы), а угловая координата θ\theta — на вертикальную ось YY (строки). В результате такого отображения концентрические окружности исходного изображения становятся вертикальными линиями, а радиальные лучи — горизонтальными строками.


Инструменты OpenCV: cv2.warpPolar

Вместо ручного заполнения сеток map_x и map_y тригонометрическими функциями в OpenCV используется оптимизированная функция cv2.warpPolar:

dst = cv2.warpPolar(
    src,
    dsize=(dsize_w, dsize_h),
    center=(center_x, center_y),
    maxRadius=max_radius,
    flags=cv2.WARP_POLAR_LINEAR + cv2.INTER_LINEAR
)

Параметры трансформации

  1. dsize: Кортеж (ширина, высота) результирующего кадра. В OpenCV dsize[0] (ширина) задаёт радиальное разрешение rr (от 00 до rmaxr_{\max}), а dsize[1] (высота) — угловое разрешение θ\theta (периметр развёртки от 00 до 360360^\circ).
  2. center: Точка (xc,yc)(x_c, y_c), вокруг которой выполняется развёртка. Ошибка в определении центра даже на 1–2 пикселя приводит к волнообразным искажениям линий на развёртке.
  3. maxRadius: Вещественное значение максимального радиуса зондирования rmaxr_{\max}. Пиксели за пределами этого радиуса отсекаются.
  4. flags: Комбинация метода интерполяции и геометрии развёртки:
    • cv2.WARP_POLAR_LINEAR — стандартная линейная шкала по радиусу (равномерный шаг пикселей от 00 до rmaxr_{\max}).
    • cv2.WARP_POLAR_LOG — логарифмическая шкала радиуса. Растягивает область вблизи центра и сжимает периферию (используется для анализа спектров Фурье и инвариантного к масштабу сопоставления).
    • cv2.WARP_INVERSE_MAP — обратная трансформация (сворачивание прямоугольной полосы обратно в диск).

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

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

import cv2
import numpy as np

def unwrap_annular_roi(image: np.ndarray, min_radius: float, max_radius: float) -> np.ndarray:
    # 1. Подавление высокочастотного шума перед локализацией
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 1.5)

    # 2. Выделение границы детали детектором Canny
    edges = cv2.Canny(blurred, threshold1=50, threshold2=150)

    # 3. Поиск контуров и нахождение точного центра окружности
    contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        raise ValueError("Внешний контур детали не обнаружен")

    main_contour = max(contours, key=cv2.contourArea)
    (cx, cy), radius = cv2.minEnclosingCircle(main_contour)

    # 4. Расчет габаритов развернутого изображения
    # В cv2.warpPolar: ширина = радиальное разрешение, высота = длина окружности (угол)
    circumference = int(2 * np.pi * max_radius)
    radial_width = int(max_radius)

    # 5. Выполнение полярной развертки всего диска
    polar_full = cv2.warpPolar(
        image,
        dsize=(radial_width, circumference),
        center=(cx, cy),
        maxRadius=max_radius,
        flags=cv2.WARP_POLAR_LINEAR + cv2.INTER_CUBIC
    )

    # 6. Выделение кольцевого диапазона радиусов по горизонтальной оси X
    roi_polar = polar_full[:, int(min_radius):int(max_radius), :]

    # 7. Поворот на 90 градусов для получения горизонтальной строки текста
    roi_unwrapped = cv2.rotate(roi_polar, cv2.ROTATE_90_COUNTERCLOCKWISE)

    return roi_unwrapped

# Пример применения морфологической обработки к развернутой строке
# img = cv2.imread('bearing_ring.png')
# unwrapped_text = unwrap_annular_roi(img, min_radius=85.0, max_radius=120.0)
# kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
# text_cleaned = cv2.morphologyEx(unwrapped_text, cv2.MORPH_CLOSE, kernel)

После выполнения развёртки криволинейная цепочка символов трансформируется в стандартный прямоугольный массив пикселей. К нему становятся применимы классические дескрипторы формы (Aspect Ratio, Solidity), горизонтальная проекция профиля яркости и алгоритмы оптического распознавания символов (OCR).

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

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

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

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


Архитектура сквозного конвейера

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

Конвейер состоит из пяти последовательных фаз:

  1. Фотометрическая нормализация: компенсация неравномерного светового поля и подавление высокочастотного сенсорного шума.
  2. Морфологическая стабилизация: выделение структурного каркаса объекта и затягивание разрывов в контуре передней границы.
  3. Топологическая фильтрация и полигональная аппроксимация: локализация доминирующего контура, проверка выпуклости и поиск 4 ключевых вершин.
  4. Каноническое упорядочивание вершин: однозначное сопоставление углов четырехугольника топологическим позициям (Top-Left, Top-Right, Bottom-Right, Bottom-Left).
  5. Проективная ортотрансформация (Bird's-Eye View): вычисление матрицы гомографии 3×33 \times 3 и билинейная передискретизация целевой области.

Фаза 1 и 2: Фотометрическая компенсация и морфологическая сборка маски

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

Для изоляции ярких локальных объектов на темном градиентном фоне мы применяем оператор White Top-Hat с прямоугольным структурирующим элементом, превышающим масштаб локальных дефектов. Последующее морфологическое замыкание (Closing) соединяет расслоившиеся границы:

import cv2
import numpy as np

def preprocess_and_segment(
    image_bgr: np.ndarray,
    tophat_kernel_size: tuple[int, int] = (25, 25),
    close_kernel_size: tuple[int, int] = (7, 7)
) -> np.ndarray:
    """Выполняет фотометрическую нормализацию и морфологическую сборку маски."""
    gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)

    # 1. Подавление высокочастотного шума
    blurred = cv2.GaussianBlur(gray, (5, 5), sigmaX=1.2)

    # 2. Выделение локальных контрастных структур через Top-Hat
    se_tophat = cv2.getStructuringElement(cv2.MORPH_RECT, tophat_kernel_size)
    tophat = cv2.morphologyEx(blurred, cv2.MORPH_TOPHAT, se_tophat)

    # 3. Автоматическая бинаризация Оцу
    _, binary = cv2.threshold(tophat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # 4. Морфологическое замыкание для объединения разорванных ребер границы
    se_close = cv2.getStructuringElement(cv2.MORPH_RECT, close_kernel_size)
    closed_mask = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, se_close)

    return closed_mask

Морфологическое замыкание ядром 7×77 \times 7 ликвидирует зазоры шириной до 6 пикселей, не допуская проникновения алгоритма трассировки внутрь тела объекта при поиске контуров.


Фаза 3: Топологическая локализация и выделение 4 вершин

После формирования стабильной бинарной маски извлекаются только внешние границы (cv2.RETR_EXTERNAL). Из всего множества контуров отбирается кандидат с максимальной площадью, удовлетворяющий критериям минимального габарита и выпуклости.

Для поиска 4 угловых точек используется алгоритм Рамера-Дугласа-Пекера (cv2.approxPolyDP) с адаптивным радиусом аппроксимации:

ε=kP\varepsilon = k \cdot P

где:

  • ε\varepsilon — максимальное допустимое расстояние (погрешность) в пикселях между исходным контуром и его упрощенной ломаной;
  • PP — периметр контура в пикселях (cv2.arcLength);
  • kk — безразмерный коэффициент сжатия (обычно 0.015k0.040.015 \leq k \leq 0.04). Например, при периметре объекта P=1200P = 1200 пикселей и k=0.02k = 0.02 допустимое отклонение составит ε=24\varepsilon = 24 пикселя.

Если при базовом значении k=0.02k = 0.02 аппроксимированный полигон содержит больше или меньше 4 вершин, конвейер выполняет динамический поиск: итеративно варьирует kk в заданном диапазоне с шагом 0.0050.005, пока не будет найден стабильный четырехугольник.

def extract_quadrilateral_corners(
    mask: np.ndarray,
    min_area: float = 1000.0
) -> np.ndarray:
    """Локализует доминирующий четырехугольный контур и возвращает 4 вершины."""
    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        raise ValueError("На бинарной маске не обнаружено связных контуров.")

    # Сортировка контуров по площади
    valid_contours = [c for c in contours if cv2.contourArea(c) >= min_area]
    if not valid_contours:
        raise ValueError(f"Нет контуров с площадью >= {min_area} px.")

    best_contour = max(valid_contours, key=cv2.contourArea)
    perimeter = cv2.arcLength(best_contour, closed=True)

    # Динамический подбор коэффициента эпсилон
    quad_corners = None
    for k in np.linspace(0.01, 0.05, 17):
        epsilon = k * perimeter
        approx = cv2.approxPolyDP(best_contour, epsilon, closed=True)

        if len(approx) == 4 and cv2.isContourConvex(approx):
            quad_corners = approx.reshape(4, 2)
            break

    if quad_corners is None:
        # Fallback: аппроксимация через минимальный ориентированный прямоугольник
        rect = cv2.minAreaRect(best_contour)
        quad_corners = np.int64(cv2.boxPoints(rect))

    return quad_corners.astype(np.float32)

Фаза 4: Упорядочивание координат и расчет целевой сетки

Функция cv2.getPerspectiveTransform требует строгого взаимно однозначного соответствия между 4 точками источника (srcsrc) и 4 точками назначения (dstdst). Нарушение порядка приведет к зеркальному отражению, повороту на 9090^\circ или геометрическому вырождению (скручиванию) вырезанного растра.

Для упорядочивания вершин в каноническую последовательность [(x0,y0),(x1,y1),(x2,y2),(x3,y3)][(x_0, y_0), (x_1, y_1), (x_2, y_2), (x_3, y_3)], соответствующую положению:

  1. Top-Left (верхний левый)
  2. Top-Right (верхний правый)
  3. Bottom-Right (нижний правый)
  4. Bottom-Left (нижний левый)

применяется алгебраический критерий суммы и разности экранных координат (x,y)(x, y):

  • Top-Left: минимальная сумма координат (x+y)(x + y).
  • Bottom-Right: максимальная сумма координат (x+y)(x + y).
  • Top-Right: минимальная разность (yx)(y - x) (или максимальная разность xyx - y).
  • Bottom-Left: максимальная разность (yx)(y - x) (или минимальная разность xyx - y).
def order_corner_points(pts: np.ndarray) -> np.ndarray:
    """Упорядочивает 4 точки: [Top-Left, Top-Right, Bottom-Right, Bottom-Left]."""
    rect = np.zeros((4, 2), dtype=np.float32)

    # Сумма координат x + y
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]  # Top-Left
    rect[2] = pts[np.argmax(s)]  # Bottom-Right

    # Разность координат y - x
    diff = np.diff(pts, axis=1)  # вычисляет (y - x)
    rect[1] = pts[np.argmin(diff)]  # Top-Right: y минимален относительно x
    rect[3] = pts[np.argmax(diff)]  # Bottom-Left: y максимален относительно x

    return rect

После фиксации порядка вершин вычисляются физические габариты целевого прямоугольника (ширина WdstW_{dst} и высота HdstH_{dst}) на основе максимальных евклидовых расстояний между противоположными ребрами:

Wdst=max((x1x0)2+(y1y0)2,(x2x3)2+(y2y3)2)W_{dst} = \max \left( \sqrt{(x_1 - x_0)^2 + (y_1 - y_0)^2}, \sqrt{(x_2 - x_3)^2 + (y_2 - y_3)^2} \right)

Hdst=max((x3x0)2+(y3y0)2,(x2x1)2+(y2y1)2)H_{dst} = \max \left( \sqrt{(x_3 - x_0)^2 + (y_3 - y_0)^2}, \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2} \right)

где:

  • WdstW_{dst} и HdstH_{dst} — ширина и высота результирующего выровненного растра в пикселях;
  • (x0,y0),(x1,y1),(x2,y2),(x3,y3)(x_0, y_0), (x_1, y_1), (x_2, y_2), (x_3, y_3) — упорядоченные экранные координаты углов (соответственно Top-Left, Top-Right, Bottom-Right, Bottom-Left).
  • Формула вычисляет длины пар противоположных ребер по теореме Пифагора и берет наибольшую величину (например, если верхнее ребро составляет 400 пикселей, а нижнее из-за перспективы растянуто до 430 пикселей, ширина WdstW_{dst} выбирается равной 430, чтобы избежать сжатия текстур).
def calculate_destination_bounds(ordered_pts: np.ndarray) -> tuple[int, int]:
    """Вычисляет габариты целевой ортогональной плоскости (W, H)."""
    tl, tr, br, bl = ordered_pts

    width_top = np.linalg.norm(tr - tl)
    width_bottom = np.linalg.norm(br - bl)
    max_width = int(max(width_top, width_bottom))

    height_left = np.linalg.norm(bl - tl)
    height_right = np.linalg.norm(br - tr)
    max_height = int(max(height_left, height_right))

    return max(max_width, 1), max(max_height, 1)

Фаза 5: Проективная трансформация и инспекция микрогеометрии

Завершающий шаг пайплайна — расчет матрицы 3×33 \times 3 и обратный билинейный маппинг (cv2.warpPerspective). На выровненном изображении детали можно выполнять прецизионный размерный контроль и детекцию микродефектов с помощью морфологического градиента.

Соберем законченный промышленный модуль:

from dataclasses import dataclass
from typing import Optional

@dataclass
class RectificationResult:
    rectified_image: np.ndarray
    ordered_corners: np.ndarray
    homography_matrix: np.ndarray
    target_dimensions: tuple[int, int]

def rectify_object_perspective(
    source_bgr: np.ndarray,
    min_area: float = 2000.0
) -> Optional[RectificationResult]:
    """Сквозной конвейер локализации и ортотрансформации объекта в кадре."""
    # 1-2. Сегментация и морфология
    mask = preprocess_and_segment(source_bgr)

    # 3. Поиск вершин
    try:
        corners = extract_quadrilateral_corners(mask, min_area=min_area)
    except ValueError:
        return None

    # 4. Упорядочивание и расчет геометрии
    ordered_src = order_corner_points(corners)
    dst_w, dst_h = calculate_destination_bounds(ordered_src)

    ordered_dst = np.array([
        [0, 0],
        [dst_w - 1, 0],
        [dst_w - 1, dst_h - 1],
        [0, dst_h - 1]
    ], dtype=np.float32)

    # 5. Проективная трансформация
    h_matrix = cv2.getPerspectiveTransform(ordered_src, ordered_dst)
    rectified = cv2.warpPerspective(
        source_bgr,
        h_matrix,
        (dst_w, dst_h),
        flags=cv2.INTER_CUBIC,
        borderMode=cv2.BORDER_CONSTANT,
        borderValue=(0, 0, 0)
    )

    return RectificationResult(
        rectified_image=rectified,
        ordered_corners=ordered_src,
        homography_matrix=h_matrix,
        target_dimensions=(dst_w, dst_h)
    )

Анализ дефектов геометрии на выровненном растре

После устранения перспективы геометрические оси объекта совпадают с растровой сеткой X/YX/Y. Чтобы извлечь точный контур детали без размытия краев, применяется морфологический градиент (разность между дилатацией и эрозией):

def inspect_edges(rectified_bgr: np.ndarray) -> np.ndarray:
    """Вычисляет контурную карту ребер детали для контроля сколов и заусенцев."""
    gray = cv2.cvtColor(rectified_bgr, cv2.COLOR_BGR2GRAY)
    se = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    # Морфологический градиент толщиной в 2 пикселя
    gradient_edges = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, se)
    return gradient_edges

Сравнение подходов к выравниванию

Критерий Аффинное выравнивание (cv2.warpAffine) Проективное выравнивание (cv2.warpPerspective)
Число опорных точек 3 вершины 4 вершины
Матрица преобразования 2×32 \times 3 (6 степеней свободы) 3×33 \times 3 (8 степеней свободы)
Сохраняемые инварианты Параллельность прямых, отношение площадей Прямолинейность линий, двойное отношение
Компенсация наклона камеры Только поворот в плоскости кадра (ZZ) Наклон по осям X,YX, Y и ракурсная перспектива
Вычислительная сложность Ниже (линейные уравнения без деления) Выше (требуется нормализация ww)

Итоги и чек-лист надежности пайплайна

При переносе конвейера в рабочую среду всегда контролируйте три критические точки отказа:

  1. Замыкание перед контурным анализом: ядро морфологического замыкания должно быть строго больше максимального зазора в границе детали, иначе контур разорвется.
  2. Валидация выпуклости: проверка cv2.isContourConvex обязательна перед вычислением гомографии, так как проективное преобразование невыпуклого полигона приводит к пространственным самопересечениям растра.
  3. Безопасные габариты: защита max(dst_w, 1) предотвращает сбой cv2.warpPerspective при схлопывании полигона в вырожденную прямую.

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